pythonforbio.
[WASM idle]
pandas09/21

Missing Data in pandas

Starting sandbox…
Beginnerlesson

Missing Data in pandas

In one line: unlike NumPy, pandas has real missing-value support — but there are two sentinels (NaN and pd.NA) and knowing which you have matters.

Detecting

df.isna(); df.notna()          # element-wise
df.isna().sum()                 # count per column
df.isna().sum(axis=1)           # count per row
df.isna().any().any()           # is there any at all?
df["af"].isna().mean()          # proportion missing

df.isna().sum().sort_values(ascending=False).head(10)   # worst columns first

isnull/notnull are exact aliases. Use isna/notna for consistency with fillna, dropna.

The two sentinels

np.nan pd.NA
Where NumPy-backed float columns nullable and Arrow-backed dtypes
NA == NA False pd.NA (propagates!)
bool(NA) False raises TypeError
In an int column impossible — promotes to float fine
pd.Series([1, 2, None])                    # float64 with NaN — int promoted
pd.Series([1, 2, None], dtype="Int64")     # Int64 with <NA> — stays integer

pd.NA propagating through comparisons is the "correct" three-valued logic (like SQL NULL), and it is why if x == pd.NA raises instead of quietly being False. In practice you rarely touch it directly — use .isna().

The integer promotion problem is the one you meet daily: a genomic position column with one missing value becomes float64, and to_csv writes 7676154.0. Int64 fixes it.

Dropping

df.dropna()                                  # any NA in any column
df.dropna(how="all")                         # only fully-empty rows
df.dropna(subset=["padj", "log2fc"])         # ← usually what you want
df.dropna(axis=1, thresh=len(df) * 0.8)      # drop columns >20% missing

Bare df.dropna() is almost always too aggressive. One sparse annotation column will delete most of your rows. Always name the columns you actually require.

Filling

df.fillna(0)
df.fillna({"af": 0, "consequence": "unknown"})   # per-column
df["x"].fillna(df["x"].median())
df.ffill(); df.bfill()                            # forward/backward fill
df["x"].interpolate(method="linear")

# per-group fill — the common real case
df["x"] = df["x"].fillna(df.groupby("gene")["x"].transform("mean"))

ffill on unsorted data propagates values across group boundaries. Sort first, or group:

df = df.sort_values(["sample", "pos"])
df["depth"] = df.groupby("sample")["depth"].ffill()

NA-aware behaviour

df["x"].sum()            # skipna=True by default → skips NA
df["x"].sum(skipna=False)
df["x"].mean()           # denominator is the non-NA count
df["x"].count()          # non-NA count (vs len(df) for all rows)

df.groupby("gene")["x"].mean()          # NA dropped within each group
df.groupby("gene", dropna=False).size() # keep NA as its own group key
df["gene"].value_counts(dropna=False)   # ← always pass this

pandas skipping NA by default is the opposite of NumPy, where one NaN poisons everything. Convenient, but it means your denominator changes silently. Always report the count alongside the mean:

df.groupby("gene")["af"].agg(["count", "mean"])

value_counts(dropna=False) deserves to be a reflex. The default hides missing values from your frequency table, which is exactly where you would want to see them.

Missing does not mean the same thing everywhere

In genomics, the reason for missingness is usually informative:

Situation Meaning Sensible handling
padj is NA in DESeq2 Filtered by independent filtering or flagged as an outlier Do not fill with 1 — exclude from the test set
Genotype ./. No call — low coverage Keep as missing; imputation is a modelling decision
Zero in scRNA-seq A real measurement (dropout) Not missing. Do not impute as NA
Proteomics below LOD Left-censored, not random Model it, or impute low, not with the mean
Empty VCF INFO field Annotation absent Depends on the annotator

df.fillna(0) applied blindly across all of these produces different kinds of wrong. Decide per column, and write down why.

The DESeq2 case is worth spelling out: padj = NA means "this gene was excluded from multiple-testing correction". Filling with 1 makes it look tested-and-not-significant, which changes your denominator and your FDR. Drop those rows instead.

Interaction with joins

merged = df.merge(annot, on="gene", how="left")
merged["annotation"].isna().sum()       # ← how many failed to match?

A left join produces NA for unmatched keys. Always check that count. A high number usually means an ID-format mismatch (Ensembl IDs with version suffixes ENSG00000141510.16 vs without) rather than genuinely absent annotation. See Merging and Joining.

Common mistakes

  • df.dropna() with no subset=, deleting most of your data.
  • fillna(0) on p-values or fold changes. Zero is a meaningful value there.
  • Filling DESeq2 padj NAs with 1.
  • Treating scRNA-seq zeros as missing.
  • value_counts() without dropna=False.
  • Not noticing an integer column became float because of one NA.
  • ffill across group boundaries.
  • Reporting a group mean without the count, so a mean over 2 of 50 samples looks like a mean over 50.
  • Not checking post-join NA counts.

See also

Missing Data in NumPy · pandas dtypes · groupby · Merging and Joining · Reading and Writing Data

lesson example

No output yet — run the code to populate this drawer.