pandas

Lesson 5 — Python

Lesson 5 of 20 Intermediate ~140 min

Learning objectives

  • Work with Series and DataFrame, and understand the index
  • Select rows and columns with .loc and .iloc correctly
  • Filter, transform, group and aggregate
  • Merge and reshape data
  • Handle missing values with the nullable dtypes
  • Avoid SettingWithCopyWarning and the chained-assignment trap

Series and DataFrame

import pandas as pd
import numpy as np

s = pd.Series([1, 2, 3], name="age")
s = pd.Series([1, 2, 3], index=["a", "b", "c"])

df = pd.DataFrame({
    "usubjid": ["001", "002", "003"],
    "age":     [45, 72, 38],
    "sex":     ["F", "M", "F"],
    "arm":     ["Placebo", "Drug A", "Placebo"],
})

df.shape            # (3, 4)
df.columns
df.dtypes
df.info()
df.head(); df.tail()
df.describe()
df.describe(include="all")

A DataFrame is a dict of Series sharing an index. That is the mental model — and the index is the part with no R equivalent.

The index

df.index                    # RangeIndex(start=0, stop=3, step=1)
df = df.set_index("usubjid")
df.loc["001"]               # select by index label
df = df.reset_index()       # index becomes a column again

The index is a labelled axis used for alignment. It is powerful and it is the source of most pandas confusion.

WarningAlignment happens automatically
a = pd.Series([1, 2, 3], index=["x", "y", "z"])
b = pd.Series([10, 20, 30], index=["z", "y", "x"])

a + b
#> x    31
#> y    22
#> z    13

The addition aligned on the index labels, not position. This is usually what you want and occasionally a nasty surprise — particularly after a groupby or a filter, where the index carries the original row positions.

reset_index(drop=True) after filtering avoids most of the trouble.

Selection

The two accessors, and using them correctly:

df.loc[row_label, column_label]      # label-based, stop INCLUSIVE
df.iloc[row_pos, column_pos]         # position-based, stop exclusive
# Columns
df["age"]                   # a Series
df[["age", "sex"]]          # a DataFrame
df.age                      # works, but fails on names with spaces or that
                            # collide with methods — avoid in code

# Rows
df.loc[0]                   # by index label
df.iloc[0]                  # by position
df.loc[0:2]                 # labels 0, 1 AND 2 — inclusive!
df.iloc[0:2]                # positions 0, 1     — exclusive

# Both
df.loc[0, "age"]
df.loc[0:2, ["age", "sex"]]
df.iloc[0:2, 1:3]
df.loc[df["age"] > 40, "usubjid"]

# Scalar access, fastest
df.at[0, "age"]
df.iat[0, 1]
Important.loc slices are inclusive
df.loc[0:2]     # three rows: 0, 1, 2
df.iloc[0:2]    # two rows:   0, 1

.loc slices on labels, and label slicing includes both endpoints — unlike every other slice in Python. This is deliberate (a label slice "2026-01":"2026-03" should include March) and it catches everyone once.

Filtering

df[df["age"] > 40]
df[(df["age"] > 40) & (df["sex"] == "F")]
df[df["arm"].isin(["Placebo", "Drug A"])]
df[df["age"].between(40, 70)]
df[df["age"].isna()]
df[df["usubjid"].str.startswith("00")]
df[~df["arm"].isin(["Screen Failure"])]

# query() — string-based, often more readable
df.query("age > 40 and sex == 'F'")
df.query("arm in ['Placebo', 'Drug A']")

threshold = 40
df.query("age > @threshold")        # @ references a Python variable

Same rules as NumPy: &, |, ~, with parentheses. and/or raise.

Adding and modifying columns

df["bmi"] = df["weight"] / (df["height"] / 100) ** 2

# assign() returns a new DataFrame — chainable, no mutation
df = df.assign(
    bmi=lambda d: d["weight"] / (d["height"] / 100) ** 2,
    bmi_cat=lambda d: pd.cut(
        d["bmi"],
        bins=[-np.inf, 18.5, 25, 30, np.inf],
        labels=["Underweight", "Normal", "Overweight", "Obese"],
    ),
)

assign with lambdas is the pandas equivalent of dplyr::mutate() — later expressions can reference earlier ones via d, and the whole thing composes into a pipeline.

Conditional logic:

# Two branches
df["flag"] = np.where(df["age"] >= 65, "Y", "N")

# Many branches
df["agegr"] = np.select(
    [df["age"] < 18, df["age"] < 65],
    ["<18", "18-64"],
    default=">=65",
)

# Binning
df["agegr"] = pd.cut(
    df["age"],
    bins=[0, 18, 65, np.inf],
    labels=["<18", "18-64", ">=65"],
    right=False,            # [0,18), [18,65), [65,inf)
)

# Mapping
df["armn"] = df["arm"].map({"Placebo": 0, "Drug A": 1, "Drug B": 2})

SettingWithCopyWarning

The most confusing pandas message.

subset = df[df["age"] > 40]
subset["flag"] = "Y"
# SettingWithCopyWarning: A value is trying to be set on a copy of a slice

The problem: pandas cannot tell whether subset is a view or a copy, so the assignment may or may not affect df. The fixes:

# 1. Be explicit that you want a copy
subset = df[df["age"] > 40].copy()
subset["flag"] = "Y"

# 2. Assign into the original with .loc
df.loc[df["age"] > 40, "flag"] = "Y"

# 3. Use assign in a chain (no mutation at all)
result = df.query("age > 40").assign(flag="Y")
TipCopy-on-Write

pandas 3.0 makes Copy-on-Write the default, which removes this ambiguity entirely — chained assignment simply never propagates, and the warning disappears. Enable it now:

pd.options.mode.copy_on_write = True

Turning it on early surfaces any code that was relying on the ambiguous behaviour, which is code you want to find.

Grouping and aggregation

df.groupby("arm")["age"].mean()
df.groupby("arm")["age"].agg(["count", "mean", "std", "min", "max"])
df.groupby(["arm", "sex"])["age"].mean()

# Named aggregations — the clearest form
summary = df.groupby("arm").agg(
    n=("usubjid", "count"),
    n_distinct=("usubjid", "nunique"),
    mean_age=("age", "mean"),
    sd_age=("age", "std"),
    n_female=("sex", lambda s: (s == "F").sum()),
).reset_index()

# Multiple columns, multiple functions
df.groupby("arm").agg({"age": ["mean", "std"], "weight": "mean"})

# transform — returns the original shape, broadcast back
df["arm_mean_age"] = df.groupby("arm")["age"].transform("mean")
df["centred"] = df["age"] - df.groupby("arm")["age"].transform("mean")

# filter — keep whole groups
df.groupby("arm").filter(lambda g: len(g) >= 10)

# apply — arbitrary function per group (slowest; use only when needed)
df.groupby("arm").apply(lambda g: g.nlargest(3, "age"))

groupby(...).transform(...) is the equivalent of mutate(..., .by = ...) in dplyr, and agg is summarise. Note that groupby puts the grouping keys in the index by default — .reset_index() or as_index=False brings them back as columns.

df.groupby("arm", as_index=False).agg(n=("usubjid", "count"))
df.groupby("arm", dropna=False)         # keep NaN groups — default drops them!

dropna=False matters: by default groupby silently discards rows where the grouping key is missing, which can quietly remove subjects from a summary.

Merging

pd.merge(adsl, adae, on="usubjid", how="left")
pd.merge(adsl, adae, on="usubjid", how="inner")
pd.merge(adsl, adae, on="usubjid", how="outer")
pd.merge(adsl, sites, left_on="siteid", right_on="site_number")
pd.merge(a, b, on=["usubjid", "paramcd"])
pd.merge(a, b, on="usubjid", suffixes=("_adsl", "_adae"))

df.join(other)                          # joins on the index
pd.concat([df1, df2])                   # stack rows
pd.concat([df1, df2], axis=1)           # side by side
Importantvalidate= is the equivalent of dplyr’s relationship=
pd.merge(adsl, sites, on="siteid", how="left", validate="many_to_one")
# MergeError: Merge keys are not unique in right dataset;
#             not a many-to-one merge

Options: "one_to_one", "one_to_many", "many_to_one", "many_to_many".

Always declare it. A merge on a non-unique key silently multiplies rows, and that is the single most common way an analysis produces wrong numbers. See the same argument in R’s dplyr lesson.

Also useful:

pd.merge(a, b, on="usubjid", how="left", indicator=True)["_merge"].value_counts()
#> both          1847
#> left_only       12       <- subjects with no events

Reshaping

# Wide -> long
long = df.melt(
    id_vars=["usubjid"],
    value_vars=["week0", "week4", "week8"],
    var_name="visit",
    value_name="value",
)

# Long -> wide
wide = long.pivot(index="usubjid", columns="visit", values="value")

# With aggregation
pd.pivot_table(
    df,
    index="arm",
    columns="sex",
    values="age",
    aggfunc=["mean", "count"],
    margins=True,
)

# Cross-tabulation
pd.crosstab(df["arm"], df["sex"], margins=True, normalize="index")

# Stack / unstack — move between index and columns
df.stack()
df.unstack()

melt is pivot_longer; pivot is pivot_wider. pivot raises on duplicate index/column pairs, where pivot_table aggregates them — the pandas equivalent of tidyr’s list-column warning, and the same advice applies: investigate the duplicates rather than aggregating them away.

Missing values

df.isna(); df.notna()
df.isna().sum()                 # missing count per column
df.isna().sum().sum()           # total

df.dropna()                     # any NaN
df.dropna(subset=["age"])
df.dropna(how="all")
df.fillna(0)
df.fillna({"age": 0, "sex": "Unknown"})
df["age"].fillna(df["age"].median())
df.ffill(); df.bfill()          # forward / backward fill

Nullable dtypes

The traditional problem: an integer column with a missing value becomes float.

pd.Series([1, 2, None])
#> 0    1.0
#> 1    2.0
#> 2    NaN
#> dtype: float64          — became float!

pd.Series([1, 2, None], dtype="Int64")      # capital I
#> 0       1
#> 1       2
#> 2    <NA>
#> dtype: Int64            — stays integer

The nullable dtypes:

"Int64", "Int32"        # nullable integers
"Float64"               # nullable float
"boolean"               # nullable boolean
"string"                # nullable string (better than object)
df = df.convert_dtypes()        # convert everything to the nullable equivalents

These behave much more like R’s typed NA values, and they are worth adopting for clinical data where the distinction between an integer count and a float matters for the output.

The Arrow backend goes further:

df = pd.read_csv("data.csv", dtype_backend="pyarrow")

Faster, lower memory, better string handling, and NA semantics throughout.

Strings and dates

df["usubjid"].str.upper()
df["usubjid"].str.strip()
df["usubjid"].str.len()
df["usubjid"].str.contains("001")
df["usubjid"].str.startswith("STUDY")
df["usubjid"].str.replace("-", "_", regex=False)
df["usubjid"].str.split("-", expand=True)      # -> a DataFrame
df["usubjid"].str.extract(r"(\d{3})-(\d{4})")
df["usubjid"].str[0:3]

pd.to_datetime(df["dtc"])
pd.to_datetime(df["dtc"], format="%Y-%m-%d", errors="coerce")
df["date"].dt.year; df["date"].dt.month; df["date"].dt.day
df["date"].dt.dayofweek
df["date"].dt.strftime("%d%b%Y")
(df["end"] - df["start"]).dt.days
df["date"] + pd.Timedelta(days=30)
df["date"] + pd.DateOffset(months=1)

errors="coerce" turns unparseable values into NaT rather than raising — essential for partial dates, and it means you must then check how many became NaT.

Method chaining

The pandas equivalent of the pipe:

result = (
    adsl
    .query("SAFFL == 'Y'")
    .assign(
        agegr=lambda d: pd.cut(d["AGE"], [0, 65, 80, np.inf],
                               labels=["<65", "65-80", ">80"], right=False)
    )
    .groupby(["TRT01A", "agegr"], observed=True, dropna=False)
    .agg(n=("USUBJID", "nunique"), mean_age=("AGE", "mean"))
    .reset_index()
    .sort_values(["TRT01A", "agegr"])
)

Wrap the whole chain in parentheses so each method can go on its own line. Use .pipe() for custom functions:

def add_flags(df, threshold):
    return df.assign(high=df["AVAL"] > threshold)

result = df.query("PARAMCD == 'ALT'").pipe(add_flags, threshold=40).head()

.pipe(f, args) is f(df, args) — exactly R’s pipe.

R and Python side by side

Task dplyr pandas
Filter rows filter(df, age > 40) df[df["age"] > 40] or df.query("age > 40")
Select columns select(df, a, b) df[["a", "b"]]
Drop columns select(df, -a) df.drop(columns="a")
Add column mutate(df, x = y * 2) df.assign(x=lambda d: d["y"] * 2)
Rename rename(df, new = old) df.rename(columns={"old": "new"})
Sort arrange(df, x) df.sort_values("x")
Sort descending arrange(df, desc(x)) df.sort_values("x", ascending=False)
Distinct distinct(df, x) df.drop_duplicates("x")
Group + summarise summarise(..., .by = g) df.groupby("g").agg(...)
Group + mutate mutate(..., .by = g) df.groupby("g")[c].transform(...)
Count count(df, x) df["x"].value_counts()
Left join left_join(a, b, "id") pd.merge(a, b, on="id", how="left")
Bind rows bind_rows(a, b) pd.concat([a, b])
Long pivot_longer() df.melt()
Wide pivot_wider() df.pivot()
Rows nrow(df) len(df)
Columns names(df) df.columns
Structure glimpse(df) df.info()
Pipe \|> Method chain or .pipe()

Common mistakes

Mistake Consequence Fix
and/or in a filter ValueError &, | with parentheses
SettingWithCopyWarning ignored Assignment may not take effect .copy() or .loc[]
.loc slice treated as exclusive Off by one .loc includes both endpoints
Merge without validate= Silent row multiplication Always declare the cardinality
groupby dropping NaN keys Subjects silently excluded dropna=False
Integer column becomes float Type changes on missing Nullable "Int64"
df.column attribute access Breaks on names with spaces or method collisions df["column"]
apply where a vectorised method exists 100× slower Use the built-in method
Forgetting reset_index() after groupby Keys stuck in the index as_index=False or .reset_index()

Exercise 5.1 — Demographics summary

From an ADSL DataFrame, produce a summary by treatment arm with N, mean and SD of age, count and percentage female, and count and percentage aged 65+. Restrict to SAFFL == "Y".

Show solution
import pandas as pd
import numpy as np

pd.options.mode.copy_on_write = True


def demographics_summary(adsl: pd.DataFrame) -> pd.DataFrame:
    """Summarise demographics by treatment arm for the safety population."""
    return (
        adsl
        .query("SAFFL == 'Y'")
        .groupby("TRT01A", as_index=False, dropna=False)
        .agg(
            N=("USUBJID", "nunique"),
            age_mean=("AGE", "mean"),
            age_sd=("AGE", "std"),
            age_n=("AGE", "count"),          # non-missing — the small n
            n_female=("SEX", lambda s: (s == "F").sum()),
            n_elderly=("AGE", lambda a: (a >= 65).sum()),
        )
        .assign(
            pct_female=lambda d: 100 * d["n_female"] / d["N"],
            pct_elderly=lambda d: 100 * d["n_elderly"] / d["N"],
        )
        .sort_values("TRT01A")
        .reset_index(drop=True)
    )


summary = demographics_summary(adsl)
print(summary.to_string(index=False))
              TRT01A   N  age_mean   age_sd  age_n  n_female  n_elderly  pct_female  pct_elderly
             Placebo  86     75.21    8.590     86        53         73       61.63        84.88
 Xanomeline High Dose 84     74.38    7.886     84        40         71       47.62        84.52
 Xanomeline Low Dose  84     75.67    8.286     84        40         73       47.62        86.90

Points that matter:

  • N uses nunique on USUBJID, not count. If ADSL is correct these are identical, but nunique fails safe: a duplicated subject inflates count and every percentage silently, while nunique gives the right denominator. It also makes the intent explicit.

  • age_n is separate from N. N is the population size and the percentage denominator; age_n is the number of non-missing ages. They differ when age is missing, and conflating them is the most common finding in a demographics table review — see TLF generation.

  • dropna=False on the groupby. A subject with a missing TRT01A would otherwise vanish from the summary entirely and the totals would not add up.

  • Percentages in a separate assign after the aggregation, so they use the aggregated N rather than being recomputed.

Formatting for display

formatted = summary.assign(
    Age=lambda d: d.apply(lambda r: f"{r['age_mean']:.1f} ({r['age_sd']:.2f})", axis=1),
    Female=lambda d: d.apply(lambda r: f"{r['n_female']:.0f} ({r['pct_female']:.1f})", axis=1),
    Elderly=lambda d: d.apply(lambda r: f"{r['n_elderly']:.0f} ({r['pct_elderly']:.1f})", axis=1),
)[["TRT01A", "N", "Age", "Female", "Elderly"]]

print(formatted.to_string(index=False))
              TRT01A   N            Age      Female     Elderly
             Placebo  86  75.2 (8.59)  53 (61.6)   73 (84.9)
Keeping the numeric and formatted versions separate matters: the numeric one is what a QC comparison uses, and the formatted one is what goes in the table. The same separation as computation versus formatting in R.

Exercise 5.2 — Baseline and change

From a long-format lab DataFrame with USUBJID, PARAMCD, AVISITN, ADT and AVAL, derive BASE (last value at or before visit 0), CHG and PCHG, and a flag for the last record per subject/parameter/visit. Verify the result.

Show solution
import pandas as pd
import numpy as np

pd.options.mode.copy_on_write = True


def derive_baseline_change(adlb: pd.DataFrame) -> pd.DataFrame:
    """Derive BASE, CHG, PCHG and the analysis record flag.

    Baseline is the last non-missing value on or before visit 0.
    """
    df = adlb.sort_values(["USUBJID", "PARAMCD", "ADT", "AVISITN"]).copy()

    key = ["USUBJID", "PARAMCD"]

    # --- Baseline flag: last non-missing record at or before visit 0 --------
    pre = df["AVAL"].notna() & (df["AVISITN"] <= 0)

    # Position of the last qualifying record within each subject/parameter
    last_pre_idx = (
        df[pre]
        .groupby(key, dropna=False)
        .tail(1)
        .index
    )

    df["ABLFL"] = np.where(df.index.isin(last_pre_idx), "Y", None)

    # --- Baseline value broadcast to every record --------------------------
    baseline = (
        df.loc[df["ABLFL"] == "Y", key + ["AVAL"]]
        .rename(columns={"AVAL": "BASE"})
    )

    df = df.merge(baseline, on=key, how="left", validate="many_to_one")

    # --- Change ------------------------------------------------------------
    df["CHG"] = df["AVAL"] - df["BASE"]
    df["PCHG"] = np.where(
        df["BASE"].notna() & (df["BASE"] != 0),
        100 * (df["AVAL"] - df["BASE"]) / df["BASE"],
        np.nan,
    )

    # --- Analysis record flag: last record per subject/parameter/visit -----
    anl_idx = (
        df[df["AVAL"].notna()]
        .sort_values(["USUBJID", "PARAMCD", "AVISITN", "ADT"])
        .groupby(["USUBJID", "PARAMCD", "AVISITN"], dropna=False)
        .tail(1)
        .index
    )
    df["ANL01FL"] = np.where(df.index.isin(anl_idx), "Y", None)

    return df.reset_index(drop=True)

Verification

result = derive_baseline_change(adlb)

# 1. Exactly one baseline per subject and parameter
dupe_base = (
    result[result["ABLFL"] == "Y"]
    .groupby(["USUBJID", "PARAMCD"])
    .size()
)
assert (dupe_base == 1).all(), f"Multiple baselines: {dupe_base[dupe_base > 1]}"

# 2. Baseline is never after visit 0
assert (result.loc[result["ABLFL"] == "Y", "AVISITN"] <= 0).all()

# 3. CHG is 0 on the baseline record itself
base_chg = result.loc[result["ABLFL"] == "Y", "CHG"]
assert (base_chg.fillna(0) == 0).all(), "CHG must be 0 at baseline"

# 4. Row count unchanged
assert len(result) == len(adlb), "Derivation changed the row count"

# 5. Subjects with no baseline — expected, but report them
no_base = (
    result.groupby(["USUBJID", "PARAMCD"])["ABLFL"]
    .apply(lambda s: (s == "Y").any())
    .pipe(lambda s: s[~s])
)
print(f"{len(no_base)} subject/parameter combinations with no baseline")

# 6. PCHG must be NaN where BASE is zero, not inf
assert not np.isinf(result["PCHG"]).any(), "PCHG contains infinity"

Four design decisions worth explaining:

validate="many_to_one" on the baseline merge. If the baseline extraction somehow produced two rows for a subject/parameter, this merge would silently double every record. Declaring the cardinality turns that into an immediate, located error. This is the pandas equivalent of dplyr’s relationship= argument and it should be on every merge.

groupby(...).tail(1) rather than a boolean condition. Getting “the last qualifying record per group” with a filter requires computing a max date and then matching it, which breaks on ties. tail(1) after an explicit sort gives exactly one row per group by construction.

The PCHG guard against BASE == 0. Without it, division by zero produces inf rather than NaN, and inf propagates through means and standard deviations to produce a summary table full of inf. Assertion 6 catches this.

Sorting includes AVISITN as a tiebreaker. Two records on the same date with different visit numbers must order deterministically, or the same code produces different baselines on different runs. This is the same requirement as deterministic --SEQ derivation.

Assertion 5 is the one to act on rather than assert: subjects with no baseline have missing CHG throughout, and how they are handled in the analysis is a statistical decision, not a programming one.

Recap

  • A DataFrame is a dict of Series sharing an index; the index aligns operations automatically
  • .loc is label-based and slice-inclusive; .iloc is positional and exclusive
  • &, |, ~ with parentheses; query() for readable filters
  • .assign() with lambdas is mutate(); .pipe() is the pipe
  • Always pass validate= to merge() — silent row multiplication is the main data bug
  • groupby(dropna=False) or missing keys silently drop rows
  • Nullable dtypes ("Int64", "string") behave like R’s typed NA
  • Enable Copy-on-Write to eliminate SettingWithCopyWarning

Next: Data visualization.

Back to top