Pandas01/21
Starting sandbox…
Beginner·4 graded tests

Categorical dtype

Categorical dtype

Core Principle: The category dtype encodes repeated string values as compact integer codes referencing a unique category array, reducing memory consumption and enforcing biological sorting orders.

The mechanism

[1]python
out1ms
0    0
1    1
2    0
3    0
dtype: int8

A 10-million-row chromosome column: ~600 MB as strings, ~10 MB as a category (one int8 per row plus 25 strings). That is the headline benefit.

Creating

df["chrom"] = df["chrom"].astype("category")
pd.read_csv(path, dtype={"chrom": "category"})     # best: never materialises the strings

pd.Categorical(values, categories=["low", "med", "high"], ordered=True)
pd.cut(df["af"], bins=[0, 0.01, 0.05, 1], labels=["rare", "low", "common"])
pd.qcut(df["expr"], q=4, labels=["Q1", "Q2", "Q3", "Q4"])

pd.cut (fixed bin edges) and pd.qcut (equal-count quantile bins) both return categoricals, which is why binning and categoricals usually arrive together.

Ordered categoricals

The second reason to use them: a meaningful non-alphabetical order.

sev = pd.Categorical(
    df["impact"],
    categories=["MODIFIER", "LOW", "MODERATE", "HIGH"],
    ordered=True,
)

df["impact"] = sev
df[df["impact"] >= "MODERATE"]          # comparison works!
df.sort_values("impact")                 # sorts by severity, not alphabetically
df["impact"].max()                       # 'HIGH'

Without this, sorting variant impacts gives you HIGH, LOW, MODERATE, MODIFIER: alphabetical and meaningless. The same applies to timepoints (day0, day7, day14 sorts wrong as strings), dose levels, and disease stages.

Ordered categoricals also fix plot ordering. seaborn respects category order, so setting it once fixes every subsequent figure. See seaborn Themes and Palettes.

Managing categories

s.cat.categories
s.cat.add_categories(["chrM"])
s.cat.remove_categories(["chrUn"])
s.cat.remove_unused_categories()          # important after filtering!
s.cat.rename_categories({"chr1": "1"})
s.cat.reorder_categories([...], ordered=True)
s.cat.set_categories(ALL_CHROMS)          # enforce a canonical set

remove_unused_categories() after filtering matters more than it sounds. Filter to chr17 only, and the column still lists all 25 categories. groupby will then produce 25 groups, 24 of them empty, and your plot gets 24 empty x-axis slots. (Modern groupby defaults to observed=True, which mitigates this, but plotting and value_counts still see the full list.)

Common Edge Cases & Behavioral Constraints

1. Assigning a new value.

df["chrom"] = df["chrom"].astype("category")
df.loc[0, "chrom"] = "chrM"       # if chrM is not a category → error or NaN
df["chrom"] = df["chrom"].cat.add_categories(["chrM"])    # add it first

2. Concatenating frames with different categories.

pd.concat([df1, df2])      # differing categories → falls back to object dtype

Fix by setting the same categories on both first, or use union_categoricals.

3. Comparing two differently-categoried columns raises TypeError. Comparing a category column to a plain string is fine.

4. String operations. .str accessors work but operate on the categories, and the result may not stay categorical.

When to use it

Yes:

  • Chromosome, strand, REF/ALT bases, FILTER, consequence, impact
  • Sample group, condition, tissue, batch
  • Any low-cardinality repeated string in a large frame
  • Anything with a meaningful non-alphabetical order
  • Anything you will groupby repeatedly (grouping on codes is faster)

No:

  • Variant IDs, read names, gene IDs in a per-gene table (cardinality ≈ row count)
  • Free-text fields
  • Columns you will heavily mutate
  • Small frames where the complexity is not worth it

The heuristic: use it when nunique() / len() is below roughly 0.5, and especially when it is below 0.05.

Bioinformatics example

CHROMS = [f"chr{i}" for i in range(1, 23)] + ["chrX", "chrY", "chrM"]
IMPACTS = ["MODIFIER", "LOW", "MODERATE", "HIGH"]

variants = pd.read_csv("variants.tsv", sep="\t", dtype={
    "chrom": "category", "ref": "category", "alt": "category",
    "consequence": "category", "filter": "category",
})

# canonical chromosome order: not alphabetical (chr10 < chr2 alphabetically!)
variants["chrom"] = variants["chrom"].cat.set_categories(CHROMS, ordered=True)
variants["impact"] = pd.Categorical(variants["impact"], categories=IMPACTS, ordered=True)

variants.sort_values(["chrom", "pos"])           # correct genomic order
variants[variants["impact"] >= "MODERATE"]
variants.groupby("chrom", observed=False).size() # all chroms, including empty ones

print(variants.memory_usage(deep=True).sum() / 1e6, "MB")

The chromosome ordering point deserves emphasis: alphabetically, chr10 comes before chr2. Every chromosome-ordered plot you have ever seen with chr1, chr10, chr11... instead of chr1, chr2, chr3 is this bug. An ordered categorical fixes it once, everywhere.

observed= in groupby

df.groupby("chrom", observed=True).size()     # only chromosomes present
df.groupby("chrom", observed=False).size()    # all categories, zeros for absent

observed=True is the modern default. Pass observed=False deliberately when you want the zeros: e.g. a per-chromosome count table where absent chromosomes should show 0 rather than vanish.

Common mistakes

  • Assigning an unlisted category.
  • concat silently degrading to object on mismatched categories.
  • Forgetting remove_unused_categories() after filtering.
  • Categorizing a high-cardinality column: costs memory instead of saving it.
  • Assuming default alphabetical order is fine for chromosomes, timepoints, or severity.
  • Not setting ordered=True and then wondering why >= fails.
  • astype("category") after loading instead of dtype= at read time: you still pay the peak memory of the string column.

See also

pandas dtypes · groupby · Sorting and Ranking · String Accessor · seaborn Themes and Palettes · pandas Performance

Exercise

Write severity_report(df, categories, threshold) for a table with columns gene and impact. categories is the impact vocabulary in increasing order of severity, and threshold is one of those categories. Convert impact to an ordered categorical over exactly that vocabulary, which turns any value outside it into a missing value rather than a silently accepted new level.

Return invalid=<n> for the number of rows whose impact was not in the vocabulary, then one <category> <n> line for every category in the given order: including categories no row uses, which must show a count of zero, then severe=<genes> listing the distinct genes whose impact is greater than or equal to threshold as a comma-separated, alphabetically sorted string, or severe=none, and finally max=<category> for the highest impact present, or max=none when no row has a valid impact. The comparison and the maximum must follow the given order, not alphabetical order.

exerciseEvery submission runs independently in a fresh namespace.

Test cases · 4

#viainputexpected stdout
1stdinMODIFIER,LOW,MODERATE,HIGH MODERATE gene impact TP53 HIGH BRCA1 MODERATE EGFR LOW KRAS HIGH MYC moderate GAPDH LOW invalid=1 MODIFIER 0 LOW 2 MODERATE 1 HIGH 2 severe=BRCA1,KRAS,TP53 max=HIGH
2stdinMODIFIER,LOW,MODERATE,HIGH HIGH gene impact TP53 LOW BRCA1 LOW EGFR MODIFIER invalid=0 MODIFIER 1 LOW 2 MODERATE 0 HIGH 0 severe=none max=LOW
3stdinday0,day7,day14 day7 gene impact TP53 day14 BRCA1 day0 EGFR day7 invalid=0 day0 1 day7 1 day14 1 severe=EGFR,TP53 max=day14
4stdinMODIFIER,LOW,MODERATE,HIGH LOW gene impact TP53 unknown BRCA1 invalid=2 MODIFIER 0 LOW 0 MODERATE 0 HIGH 0 severe=none max=none

Hints · 2

01Hint

Series.where(Series.isin(categories)) blanks the values outside the vocabulary first; astype(pd.CategoricalDtype(categories, ordered=True)) then sets both the vocabulary and the order.

02Hint

value_counts().reindex(categories, fill_value=0) puts the counts back into your order and restores the categories nobody used.