Section 1 of 16

1 Before you start

Before you begin, take a few minutes to check what you already know and how confident you feel. You will see the same questions again at the end of the module — this helps both you and us measure what you have learned. Click an option for every question and confidence rating, then click Next to continue.

Pre-test

You have a wide DataFrame with columns patient_id, v1, v2, v3 (weights at three visits). Which call turns it into long form with one row per (patient, visit, weight)?

Pre-test

You have a long DataFrame with columns patient_id, timepoint, weight. Which call turns it into wide form — one row per patient, one column per timepoint?

Pre-test

pd.crosstab(df["clinic"], df["sex"]) returns what?

Pre-test

You merge a one-row-per-patient demographics table with a multiple-rows-per-patient labs table using an inner join on patient_id. How many rows does the result have?

Pre-test

You want to keep EVERY row of the demographics table, attaching lab values where a patient has them and NaN where they do not. Which how= do you pass to pd.merge?

Pre-test

pd.get_dummies(df, columns=["clinic"]) does what to the DataFrame?

Pre-test

What is the role of the round brackets in a chained pandas pipeline like (df.pipe(...).merge(...).assign(...))?

Pre-test

df.to_numpy() on a DataFrame of numeric columns returns what?

Pre-confidence

I can pivot a long-form DataFrame to wide form and melt a wide-form DataFrame to long form.

Not at all confident
Fully confident
Pre-confidence

I can merge two tables on a key column and pick the right join type (inner, left, full outer) for the question I am asking.

Not at all confident
Fully confident
Pre-confidence

I can write a small chained pipeline that joins two tables, one-hot encodes the categorical columns, and bridges into NumPy with .to_numpy().

Not at all confident
Fully confident
Section 2 of 16

2 Introduction

Parts I and II covered everything you do with a single DataFrame — reading it in, looking at it, filtering it, cleaning it. Part III is about working across DataFrames and getting your data out the other end. You reshape the table into the form your next tool expects, you combine multiple tables that hold different facts about the same people, you encode categories into numbers, you wrap the whole pipeline into one fluent expression, and finally you hand the result off to NumPy or to disk.

This is the third and final part on pandas. By the end of it you can take two messy CSVs and produce a clean numeric feature matrix in one chained pipeline.

This part covers six tools:

  • Reshaping data with pivot and melt — moving between wide form (one column per measurement) and long form (one row per measurement).
  • Two-way categorical tables with pd.crosstab — counts in every combination of two categories.
  • Joining data — combining two tables with merge, choosing inner, left, or full outer.
  • One-hot encoding with pd.get_dummies — turning category labels into 0/1 columns.
  • Building processing pipelines with method chaining — writing the whole analysis as one fluent expression that reads top-to-bottom.
  • From pandas to NumPy and back — the bridge into the rest of the scientific-Python stack.
Different six pandas operations, namely pivot and melt, crosstab, merge, get_dummies, method chains, and the DataFrame to NumPy bridge.
Different six pandas operations, namely pivot and melt, crosstab, merge, get_dummies, method chains, and the DataFrame to NumPy bridge.

Try every snippet in the Python Scratchpad on the right. By the end of this part you will take two patient tables, join them, encode the categorical columns, chain it all into one pipeline, and convert the result into a NumPy feature matrix.

Section 3 of 16

3 Reshaping data (pivot and melt; wide to long)

The same information can sit in two very different shapes. In wide form, every measurement type gets its own column (patient_id, weight_baseline, weight_3month, weight_6month), and each row is a single patient. In long form, every measurement is its own row (patient_id, timepoint, weight), and each patient has one row per timepoint. Humans usually find wide form easier to read; pandas almost always finds long form easier to work with. Reshaping moves between the two.

The same dataset can be arranged in wide form (one row per subject, separate columns for each measurement) or long form (one row per measurement, with the measurement type as its own column).
The same dataset can be arranged in wide form (one row per subject, separate columns for each measurement) or long form (one row per measurement, with the measurement type as its own column).
Section 3.1 of 16

3.1 Wide to long form

Going from wide to long uses pd.melt. You tell it which columns to keep as identifiers (id_vars) and which columns to stack into a single new column (value_vars). The names of the stacked columns become entries in a new "variable" column; their values become entries in a new "value" column. You name both.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

wide = pd.DataFrame({
    "patient_id": ["P001", "P002"],
    "weight_baseline": [72, 80],
    "weight_3month": [70, 78],
    "weight_6month": [69, 77],
})
long = pd.melt(
    wide,
    id_vars=["patient_id"],
    value_vars=["weight_baseline", "weight_3month", "weight_6month"],
    var_name="timepoint",
    value_name="weight",
)
print(long)
pandas pd.melt reshapes a wide DataFrame into long format by unpivoting one measurement column at a time into stacked variable and value columns.
pandas pd.melt reshapes a wide DataFrame into long format by unpivoting one measurement column at a time into stacked variable and value columns.

Long form is the natural shape for almost every pandas operation. groupby works on it. Filtering on timepoint is a single boolean expression. Plotting weight against timepoint with a line per patient is one matplotlib call. The wide form looked friendlier to a human eye but did not let you do any of those things.

Section 3.2 of 16

3.2 Long to wide form

Going from long to wide uses df.pivot. Tell it which column should become the rows (index), which column should become the new columns, and which column holds the values that fill the cells.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

long = pd.DataFrame({
    "patient_id": ["P001", "P001", "P002", "P002"],
    "timepoint": ["baseline", "3month", "baseline", "3month"],
    "weight": [72, 70, 80, 78],
})
wide = long.pivot(index="patient_id", columns="timepoint", values="weight")
print(wide)
How DataFrame pivot uses its index, columns, and values arguments to reshape long data into wide form.
How DataFrame pivot uses its index, columns, and values arguments to reshape long data into wide form.

If the same patient_id and timepoint pair shows up more than once in your long data — two readings on the same day, perhaps — plain pivot raises an error because it does not know which value to use. The fix is df.pivot_table, which is the same idea but with an aggfunc argument that says how to combine duplicates. The default aggfunc is mean, but you can pass "median", "max", "first", or any function you want.

Section 4 of 16

4 Two-way categorical tables with pd.crosstab

value_counts summarises one column. To cross-tabulate two - how many patients fall into each combination of clinic and sex - use pd.crosstab. It returns a DataFrame with one column per category on one axis and one row per category on the other.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "clinic": ["A", "A", "B", "B", "C", "C"],
    "sex": ["F", "M", "F", "F", "M", "F"],
})
print(pd.crosstab(df["clinic"], df["sex"]))
pd.crosstab counts each row's combination of two categorical columns to produce a 2D summary table, extending the 1D logic of value_counts.
pd.crosstab counts each row's combination of two categorical columns to produce a 2D summary table, extending the 1D logic of value_counts.
Section 5 of 16

5 Joining data (inner, left, full outer)

Different tables hold different facts about the same people. A demographics table has patient_id, age, sex, ethnicity. A lab results table has patient_id, glucose, cholesterol, blood pressure.

To answer a question that crosses both ("what is mean cholesterol by age band?"), you need them combined into one DataFrame, matched up by their shared patient_id. That operation is called a join, and the pandas function is pd.merge.

Section 5.1 of 16

5.1 Inner join

The default behaviour of pd.merge keeps only the rows that have a match on both sides. This is called an inner join, and it is what you want when only patients with both kinds of information matter for your analysis.

How pd.merge with an inner join keeps only the patient rows that appear in both source tables and discards the rest.
How pd.merge with an inner join keeps only the patient rows that appear in both source tables and discards the rest.
Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

demographics = pd.DataFrame({
 "patient_id": ["P001", "P002", "P003"],
 "age": [54, 61, 47],
})
labs = pd.DataFrame({
 "patient_id": ["P002", "P003", "P004"],
 "glucose": [5.4, 6.1, 4.9],
})
combined = pd.merge(demographics, labs, on="patient_id")
print(combined)

Two patients survive the merge: P002 and P003, the two who appear in both tables. P001 (no labs) and P004 (no demographics) are dropped. That is the inner join's promise: every output row has a value in every column.

Section 5.2 of 16

5.2 Left and outer join

When that is too strict, you reach for one of the other join types. Pass how="left" to keep every row from the left table, filling NaN where the right table has no match. Pass how="right" for the mirror image. Pass how="outer" to keep every row from both (the union), with NaN wherever a side is missing.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

demographics = pd.DataFrame({
 "patient_id": ["P001", "P002", "P003"],
 "age": [54, 61, 47],
})
labs = pd.DataFrame({
 "patient_id": ["P002", "P003", "P004"],
 "glucose": [5.4, 6.1, 4.9],
})
left = pd.merge(demographics, labs, on="patient_id", how="left")
outer = pd.merge(demographics, labs, on="patient_id", how="outer")
print(left)
print(outer)
The how= argument in pandas merge controls which rows survive a join between two tables, with NaN filling any cell that has no match on the other side.
The how= argument in pandas merge controls which rows survive a join between two tables, with NaN filling any cell that has no match on the other side.
Section 5.3 of 16

5.3 How to pick

How to pick. Inner is the right default when you want only complete records. Left is the right choice when one of the two tables is your primary list and you want to enrich it without losing anyone (a list of trial participants, say, getting annotated with whatever lab values exist for them). Outer is the right move when you are auditing two sources for completeness and need to see exactly which side each record came from.

pandas DataFrame.merge() with how='inner', 'left', and 'outer', showing how each option combines two DataFrames and produces NaN where rows have no match.
pandas DataFrame.merge() with how='inner', 'left', and 'outer', showing how each option combines two DataFrames and produces NaN where rows have no match.

If the key column has different names on each side, replace on= with the pair left_on= and right_on=. The shape is the same; pandas just stops assuming the names match.

How pd.merge joins two DataFrames on a shared key, comparing inner (matches only) and outer (all rows, with NaN for missing values) joins
How pd.merge joins two DataFrames on a shared key, comparing inner (matches only) and outer (all rows, with NaN for missing values) joins

One trap to know about. If your key is not unique on one side (a patient with three lab visits, all under the same patient_id), every match on the unique side gets duplicated to line up with every match on the duplicated side. You can join a 100-row demographics table to a 300-row lab table and end up with 300 output rows: one per lab visit, each carrying its patient's demographics. That may be exactly what you want, or may be a shock. Check df["patient_id"].is_unique on each side before the merge and you will not be surprised.

Section 6 of 16

6 One-hot encoding with pd.get_dummies

Many tools that accept a numeric feature matrix cannot consume a column of category labels directly. pd.get_dummies converts each categorical column into a set of 0/1 columns - one per category. Pass drop_first=True to drop one column per group, which is the convention when the columns will go into a regression with an intercept.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "patient_id": ["P001", "P002", "P003", "P004"],
    "clinic": ["A", "B", "A", "C"],
    "age": [54, 61, 47, 72],
})
print(pd.get_dummies(df, columns=["clinic"]))
print(pd.get_dummies(df, columns=["clinic"], drop_first=True))
pd.get_dummies one-hot encodes the clinic column into three binary columns, then dropping the first as the reference category.
pd.get_dummies one-hot encodes the clinic column into three binary columns, then dropping the first as the reference category.
Section 7 of 16

7 Building processing pipelines with method chaining

Most real analyses are not one pandas call but several in a row. Read the file, drop the missing rows, filter to a cohort, group by clinic, summarise, sort the result. You can write each step as its own line and store the intermediate DataFrames in named variables — and sometimes that is the right thing to do — but pandas methods almost all return a DataFrame, which means each method's output is another DataFrame ready to feed into the next method. Stringing the calls together is called method chaining, and the result is a single expression that reads top to bottom like a recipe.

In pandas, method chaining replaces a string of intermediate variables with a single expression that reads top to bottom, because each method returns a DataFrame the next one can run on.
In pandas, method chaining replaces a string of intermediate variables with a single expression that reads top to bottom, because each method returns a DataFrame the next one can run on.

Here is the same analysis written first as a sequence of separate steps, then as a chain. The two produce the same result.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "clinic": ["A", "A", "B", "B", "B"],
    "age": [54, 47, 61, 72, 38],
    "bmi": [27.3, 31.2, 24.1, 22.8, 29.5],
})

# Unchained
filtered = df[df["age"] > 40]
grouped = filtered.groupby("clinic")["bmi"].mean()
result = grouped.sort_values(ascending=False)
print(result)
Three pandas operations (filter, groupby mean, and sort) applied step by step to a small DataFrame.
Three pandas operations (filter, groupby mean, and sort) applied step by step to a small DataFrame.
Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd

df = pd.DataFrame({
    "clinic": ["A", "A", "B", "B", "B"],
    "age": [54, 47, 61, 72, 38],
    "bmi": [27.3, 31.2, 24.1, 22.8, 29.5],
})

# Chained
result = (
    df
    .loc[df["age"] > 40]
    .groupby("clinic")["bmi"]
    .mean()
    .sort_values(ascending=False)
)
print(result)
A chained pandas pipeline applying filter, groupby, mean, and sort in sequence to produce per-clinic average BMI.
A chained pandas pipeline applying filter, groupby, mean, and sort in sequence to produce per-clinic average BMI.

Method chaining is a fantastic way to express a flow of operations, but without strict formatting, a long chain quickly becomes unreadable.

Three rules make this layout highly readable:

  • Wrap it in parentheses (): This allows Python to safely ignore the line breaks inside. Without them, you would need an ugly backslash (\) at the end of every line.
  • Line up the dots vertically: This naturally guides the reader's eye downward through the sequence of operations.
  • One method per line: This makes it incredibly easy to comment out a single step for debugging, or to insert a new step, without touching the rest of the code.

Because a method chain is a single continuous expression, you can't use standard assignment (like df['new_col'] = 5) in the middle of it.

To add a new column without breaking your chain, use .assign(). It evaluates your data and returns a brand-new DataFrame with the added column, allowing it to slot cleanly right into your pipeline.

Something important to note. Method chaining is a tool for readability. If your pipeline grows past five or six steps, or if you need to inspect an intermediate result to see what is going wrong, it is time to break the chain. Saving your progress back into named variables is often the clearer choice.

Section 8 of 16

8 From pandas to NumPy and back

Once your data is clean inside a DataFrame, the bridge into the NumPy world is .to_numpy() (or its older alias .values). It returns the underlying 2D array with the index and column labels dropped. Going the other way, pd.DataFrame(arr, columns=[...]) wraps an array back into a DataFrame with named columns.

Try it out

Try this snippet in the Python Scratchpad on the right.

Try this snippet
import pandas as pd
import numpy as np

df = pd.DataFrame({"age": [54, 61, 47], "bmi": [27.4, 31.1, 24.8]})
arr = df.to_numpy()
print(arr)
print(arr.shape)

back = pd.DataFrame(arr, columns=["age", "bmi"])
print(back)
Converting between a pandas DataFrame and a NumPy array preserves the underlying numeric values while only the column and row labels are removed and reattached.
Converting between a pandas DataFrame and a NumPy array preserves the underlying numeric values while only the column and row labels are removed and reattached.
Section 9 of 16

9 Putting it together

The six tools in this part are the workflow you reach for at the end of an analysis — once your data is clean, you reshape it, combine it with what else you have, encode the categorical pieces, chain everything into one expression, and hand it off to the next stage as a NumPy array.

Worked example · Two tables to feature matrix in one pipeline

Work through this example in three stages. You unlock each stage only after the tutor confirms the previous one. Each stage removes more of the scaffolding — by the end you are writing it yourself.

Problem: You have patients.csv (one row per patient, with clinic and demographics) and labs.csv (multiple rows per patient, one per lab visit). Read both, summarise labs per patient (mean glucose), left-merge the summary back onto patients, one-hot encode the clinic column, and convert the resulting feature DataFrame into a NumPy array ready for the next tool.

Stage 1 · Study the solved example
Fully solved solution
import pandas as pd

patients = pd.read_csv("patients.csv")
labs = pd.read_csv("labs.csv")
lab_summary = labs.groupby("patient_id", as_index=False)["glucose"].mean()

X = (
    patients
    .merge(lab_summary, on="patient_id", how="left")
    .pipe(pd.get_dummies, columns=["clinic"], drop_first=True)
    .drop(columns=["patient_id"])
    .to_numpy()
)
print(X.shape)
Walk-through
  1. First we load both tables and collapse the long labs table to one row per patient by grouping on patient_id and taking the mean glucose.
  2. Then we build the feature matrix as one chained pipeline: a left merge keeps every patient (even those with no labs), and .pipe(pd.get_dummies, ...) one-hot encodes the clinic column with drop_first=True to avoid the dummy-variable trap.
  3. Finally we drop patient_id (an identifier, not a feature) and call .to_numpy() to hand the matrix off to the next tool. Printing .shape confirms the number of rows and feature columns.
Section 10 of 16

10 Check your understanding

You have reached the end of the module. Try the same questions again — your answers here, paired with your pre-test answers, are how we measure what the module taught you. Answer every question and confidence rating, then click Submit and see results to view your score.

Post-test

You have a wide DataFrame with columns patient_id, v1, v2, v3 (weights at three visits). Which call turns it into long form with one row per (patient, visit, weight)?

Post-test

You have a long DataFrame with columns patient_id, timepoint, weight. Which call turns it into wide form — one row per patient, one column per timepoint?

Post-test

pd.crosstab(df["clinic"], df["sex"]) returns what?

Post-test

You merge a one-row-per-patient demographics table with a multiple-rows-per-patient labs table using an inner join on patient_id. How many rows does the result have?

Post-test

You want to keep EVERY row of the demographics table, attaching lab values where a patient has them and NaN where they do not. Which how= do you pass to pd.merge?

Post-test

pd.get_dummies(df, columns=["clinic"]) does what to the DataFrame?

Post-test

What is the role of the round brackets in a chained pandas pipeline like (df.pipe(...).merge(...).assign(...))?

Post-test

df.to_numpy() on a DataFrame of numeric columns returns what?

Post-confidence

I can pivot a long-form DataFrame to wide form and melt a wide-form DataFrame to long form.

Not at all confident
Fully confident
Post-confidence

I can merge two tables on a key column and pick the right join type (inner, left, full outer) for the question I am asking.

Not at all confident
Fully confident
Post-confidence

I can write a small chained pipeline that joins two tables, one-hot encodes the categorical columns, and bridges into NumPy with .to_numpy().

Not at all confident
Fully confident
Section 11 of 16

11 Your results

Here is how your post-test answers compare with your pre-test answers. The pre/post pairing is the most reliable way to see what this module actually taught you.

Your score

Submit the post-test to see your results.

Muddiest point

What is the one thing from this module that is still unclear to you?

Rate this module

Overall, how would you rate this module?

How likely are you to recommend this module to a peer? (0 = not at all, 10 = extremely likely)