Split-Apply-Combine
Split-Apply-Combine
Core Principle: The split-apply-combine strategy structures data processing by partitioning a dataset into subsets, applying a function to each partition independently, and combining the results into a unified output.
Named by Hadley Wickham (2011). Once you see it you cannot unsee it: it is groupby in pandas, GROUP BY in SQL, MapReduce, np.add.reduceat, and every "per-gene", "per-sample", "per-chromosome" sentence you have ever written.
The three steps
flowchart LR
A["Input Data"] --> B["Split: Group by Key"]
B --> C["Apply: f(group)"]
C --> D["Combine: Output DataFrame"]
In pandas:
df.groupby("gene")["count"].mean()
# split ──────┘ └─ apply ─┘ combine is implicit
Three apply shapes, three different outputs
This is the distinction people get wrong. What you get back depends on what your function returns.
| Method | Function returns | Output shape |
|---|---|---|
.agg() |
one scalar per group | one row per group |
.transform() |
one value per original row | same shape as input |
.filter() |
a boolean per group | subset of original rows |
.apply() |
anything | pandas guesses; slowest |
# aggregate: 20,000 genes in → 20,000 rows out
df.groupby("gene")["count"].agg("mean")
# transform: 5,000,000 rows in → 5,000,000 rows out (broadcast back)
df["centred"] = df["count"] - df.groupby("gene")["count"].transform("mean")
# filter: keep only genes observed in >10 samples
df.groupby("gene").filter(lambda g: len(g) > 10)
The transform operation is particularly valuable for per-group normalization, including batch z-score calculations, per-sample transcript abundance scaling, and gene-level mean centering, eliminating the need for intermediate table merges.
The same idea in NumPy
Without labels, grouping is done positionally:
# mean per row (per gene) of an expression matrix
counts.mean(axis=1)
# grouped sums over sorted group boundaries
np.add.reduceat(values, group_start_indices)
# group counts / sums by integer code
np.bincount(codes)
np.bincount(codes, weights=values)
See Axes and Reductions. The axis= argument is split-apply-combine where the groups are "everything along this axis".
Why it matters in bioinformatics
Practically every question in the field is a grouped one:
- variants per gene, per sample, per consequence class
- mean coverage per exon
- differential expression: a test per gene
- allele frequency per population
- per-sample QC metrics from per-read data
The unit you group by is usually the observational unit from Tidy Data. If grouping feels awkward, your frame is usually not tidy yet.
Performance note
Prefer built-in aggregation strings ("mean", "sum", "size", "nunique") over lambdas. The strings dispatch to compiled Cython; a lambda runs Python once per group. On 20,000 gene groups that is a 50–100× difference. See pandas Performance.
See also
groupby · Tidy Data · Axes and Reductions · Window Functions · pandas Performance · Applied - Expression Matrices with NumPy
Exercise
Write grouped(rows), where rows is a list of (gene, sample, count)
triples in file order. Implement the pattern by hand with a dictionary rather
than with pandas.groupby, so that the three steps stay visible: split the
rows by gene, discard every group holding fewer than two rows, and then run
both an aggregation and a transform over the groups that remain.
Return the output lines in this order. First, one aggregate line per surviving
gene in alphabetical order, formatted <gene> n=<rows> mean=<mean> with the
mean to three decimal places. Second, one transform line per row belonging to a
surviving gene, in the original row order, formatted
<gene> <sample> <centred> where centred is that row's count minus its own
group's mean, to three decimal places. Finish with a single line
groups=<total> kept=<surviving>. When every group holds one row, only that
last line is printed.
Test cases · 4
| # | via | input | expected stdout |
|---|---|---|---|
| 1 | stdin | TP53 s1 10 BRCA1 s1 4 TP53 s2 20 BRCA1 s2 6 TP53 s3 30 | BRCA1 n=2 mean=5.000 TP53 n=3 mean=20.000 TP53 s1 -10.000 BRCA1 s1 -1.000 TP53 s2 0.000 BRCA1 s2 1.000 TP53 s3 10.000 groups=2 kept=2 |
| 2 | stdin | TP53 s1 10 BRCA1 s1 4 EGFR s1 7 | groups=3 kept=0 |
| 3 | stdin | MYC s1 5 MYC s2 5 MYC s3 5 | MYC n=3 mean=5.000 MYC s1 0.000 MYC s2 0.000 MYC s3 0.000 groups=1 kept=1 |
| 4 | stdin | KRAS s1 1.5 KRAS s2 2.5 GAPDH s1 100 | KRAS n=2 mean=2.000 KRAS s1 -0.500 KRAS s2 0.500 groups=2 kept=1 |
Hints · 3
01Hint
`groups.setdefault(gene, []).append(...)` builds the split in one pass and keeps each group's rows in file order.
02Hint
The transform runs over the original list, not over the groups — that is what makes it return one value per input row rather than one per group.
03Hint
Compute each group's mean once and store it; recomputing it inside the transform loop is the slow shape this lesson warns about.