File size: 18,419 Bytes
99f6b9e 541a9d0 99f6b9e 541a9d0 99f6b9e 541a9d0 99f6b9e 541a9d0 99f6b9e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 | """
Pseudobulk aggregation β the required Path P step before any bulk DE.
Why this exists
---------------
Single-cell datasets (`modality: sc_rnaseq`, ADR-0006 / Path P) must NEVER be
handed to DESeq2 or limma per cell. Cells from one donor are not independent
replicates: treating them as such inflates n from ~dozens of samples to ~tens of
thousands of cells and produces p-values that are essentially all significant.
The standard fix is to **sum raw counts within each biological sample** first,
recovering a genuine sample x genes counts matrix with one row per donor, which
then takes the ordinary bulk Path A (DESeq2) route.
This module is the aggregation half of that. It is deliberately generic β it
knows nothing about any specific dataset, only about obs columns the caller
names.
Design notes
------------
- **Sum, not mean.** DESeq2 models counts and their mean-variance relationship;
averaging destroys the count scale and the library-size information its size
factors depend on. ``mode="mean"`` is offered for scoring-style uses but the
tool warns that the result is not DESeq2 input.
- **Raw counts in, or nothing.** Aggregating already-normalised values sums
per-cell-normalised numbers, which is meaningless. Guarded, not assumed.
- **Small groups are dropped, loudly.** A "sample" built from 3 cells is noise;
it is excluded and reported rather than silently carried into DE.
- Aggregation itself delegates to ``decoupler.pp.pseudobulk`` so the numerics
match the rest of the decoupleR pipeline; everything around it (validation,
filtering, diagnostics) is ours.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
SUPPORTED_MODES: frozenset[str] = frozenset({"sum", "mean"})
# A pseudobulk sample built from very few cells is dominated by sampling noise.
# 10 is the common floor in the pseudobulk literature and decoupler's tutorials.
DEFAULT_MIN_CELLS: int = 10
# Total summed counts below this usually means a failed/empty library.
DEFAULT_MIN_COUNTS: int = 1_000
# Below this many pseudobulk samples, a DE contrast has no usable replication.
MIN_SAMPLES_FOR_DE: int = 4
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _matrix_values(adata, layer: str | None) -> np.ndarray:
"""Return a bounded 1-D sample of the matrix values, dense, for inspection."""
import scipy.sparse as sp
X = adata.layers[layer] if layer else adata.X
data = X.data if sp.issparse(X) else np.asarray(X).ravel()
return np.asarray(data[:100_000], dtype=float)
def looks_like_raw_counts(adata, layer: str | None = None) -> bool:
"""Heuristic: raw UMI counts are non-negative and integer-valued."""
sample = _matrix_values(adata, layer)
if sample.size == 0:
return False
if (sample < 0).any():
return False
return bool(np.allclose(sample, np.round(sample)))
def _total_counts_per_row(adata) -> np.ndarray:
import scipy.sparse as sp
X = adata.X
totals = X.sum(axis=1) if sp.issparse(X) else np.asarray(X).sum(axis=1)
return np.asarray(totals).ravel()
# ---------------------------------------------------------------------------
# Aggregation
# ---------------------------------------------------------------------------
def aggregate_to_pseudobulk(
adata,
sample_col: str,
groups_col: str | None = None,
layer: str | None = None,
mode: str = "sum",
min_cells: int = DEFAULT_MIN_CELLS,
min_counts: int = DEFAULT_MIN_COUNTS,
require_raw_counts: bool = True,
filter_genes: bool = True,
filter_group: str | None = None,
) -> dict[str, Any]:
"""
Aggregate a per-cell AnnData into a pseudobulk samples x genes AnnData.
Parameters
----------
adata:
Per-cell AnnData. ``adata.X`` (or ``layer``) must hold RAW counts.
sample_col:
obs column identifying the biological sample / donor / library. This
is the unit of replication β one pseudobulk row per value (per group,
when ``groups_col`` is given).
groups_col:
Optional obs column (e.g. cell type / cluster). When given, aggregation
is per sample x group, so a contrast can be run within one cell type.
layer:
Optional layer name holding raw counts, when ``X`` has been normalised.
mode:
"sum" (default; the only correct input for DESeq2) or "mean".
min_cells:
Drop pseudobulk samples aggregated from fewer than this many cells.
min_counts:
Drop pseudobulk samples whose total counts fall below this.
require_raw_counts:
When True (default), raise if the matrix does not look like raw counts.
filter_genes:
When True (default), drop lowly-expressed genes after aggregation via
``dc.pp.filter_by_expr`` (decoupler's port of edgeR ``filterByExpr``).
A per-cell matrix is extremely sparse, so an unfiltered pseudobulk object
carries thousands of all-zero and near-zero genes. Leaving them in makes
DESeq2's dispersion trend fail to converge (it falls back to a mean-based
trend), inflates the multiple-testing burden with untestable genes, and
manufactures implausible log2FCs off near-zero group means. Verified on
the real GSE155698 subset β see the module tests.
filter_group:
obs column passed to ``filter_by_expr`` as the grouping (normally the DE
design factor), so a gene expressed in only one condition is kept.
Returns
-------
dict with keys:
adata_pseudobulk (AnnData) β samples x genes, `psbulk_n_cells` in obs.
n_cells_in (int)
n_samples_out (int)
n_genes (int)
sample_col / groups_col / mode / layer
dropped_low_cells (list[dict]) β group + n_cells for each drop.
dropped_low_counts (list[dict])
cells_per_sample (dict) β retained pseudobulk sample -> n cells.
warnings (list[str])
Raises
------
ValueError on unknown mode/columns, non-count input, or nothing surviving.
"""
import decoupler as dc
run_warnings: list[str] = []
# ββ Validate arguments ββββββββββββββββββββββββββββββββββββββββββββββββ
if mode not in SUPPORTED_MODES:
raise ValueError(f"mode must be one of {sorted(SUPPORTED_MODES)}, got '{mode}'")
if sample_col not in adata.obs.columns:
raise ValueError(
f"sample_col '{sample_col}' not found in obs. "
f"Available columns: {list(adata.obs.columns)}"
)
if groups_col is not None and groups_col not in adata.obs.columns:
raise ValueError(
f"groups_col '{groups_col}' not found in obs. "
f"Available columns: {list(adata.obs.columns)}"
)
if layer is not None and layer not in adata.layers:
raise ValueError(
f"layer '{layer}' not found. Available layers: {list(adata.layers.keys())}"
)
# ββ Guard: raw counts in, or nothing ββββββββββββββββββββββββββββββββββ
# Summing per-cell-normalised values is meaningless, and the resulting
# matrix would then sail through DESeq2's own integer check having lost the
# library-size information its size factors depend on.
is_counts = looks_like_raw_counts(adata, layer)
if not is_counts:
msg = (
"Matrix does not look like raw counts (values are negative and/or "
"non-integer). Pseudobulk aggregation must run on RAW counts β "
"summing normalised per-cell values is not meaningful, and DESeq2 "
"downstream requires true counts. Pass layer='counts' (or whichever "
"layer holds them)."
)
if require_raw_counts:
raise ValueError(msg)
run_warnings.append(msg)
if mode == "mean":
run_warnings.append(
"mode='mean' does not produce DESeq2 input β averaging destroys the "
"count scale and library-size information. Use mode='sum' for any "
"differential-expression contrast."
)
n_cells_in = int(adata.n_obs)
# ββ Aggregate βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
pdata = dc.pp.pseudobulk(
adata,
sample_col=sample_col,
groups_col=groups_col,
layer=layer,
mode=mode,
skip_checks=True, # our own guards above are stricter and better-messaged
)
# ββ Cell counts per pseudobulk sample βββββββββββββββββββββββββββββββββ
# decoupler records this, but the key has moved across versions β recompute
# from the source obs so the number is ours and always present.
keys = [sample_col] + ([groups_col] if groups_col else [])
counts = adata.obs.groupby(keys, observed=True).size()
def _n_cells_for(obs_row) -> int:
key = obs_row[sample_col] if not groups_col else (obs_row[sample_col], obs_row[groups_col])
try:
return int(counts.get(key, 0))
except TypeError:
return 0
pdata.obs["psbulk_n_cells"] = [_n_cells_for(r) for _, r in pdata.obs.iterrows()]
pdata.obs["psbulk_total_counts"] = _total_counts_per_row(pdata)
# ββ Filter undersized pseudobulk samples ββββββββββββββββββββββββββββββ
dropped_low_cells = [
{"sample": str(idx), "n_cells": int(row["psbulk_n_cells"])}
for idx, row in pdata.obs.iterrows()
if row["psbulk_n_cells"] < min_cells
]
keep_cells = pdata.obs["psbulk_n_cells"] >= min_cells
pdata = pdata[keep_cells].copy()
# ``min_counts`` is a library-size threshold and only means anything on the
# count scale. Under mode="mean" the row totals are per-cell averages, which
# are ~3 orders of magnitude smaller, so applying it there would silently
# delete every sample. Skip it rather than filter on a meaningless number.
dropped_low_counts: list[dict] = []
if mode == "sum":
dropped_low_counts = [
{"sample": str(idx), "total_counts": float(row["psbulk_total_counts"])}
for idx, row in pdata.obs.iterrows()
if row["psbulk_total_counts"] < min_counts
]
keep_counts = pdata.obs["psbulk_total_counts"] >= min_counts
pdata = pdata[keep_counts].copy()
elif min_counts:
run_warnings.append(
f"min_counts={min_counts} was ignored: it is a library-size threshold "
"and is only meaningful for mode='sum'. Cell-count filtering "
f"(min_cells={min_cells}) still applied."
)
if dropped_low_cells:
run_warnings.append(
f"Dropped {len(dropped_low_cells)} pseudobulk sample(s) built from "
f"fewer than min_cells={min_cells} cells β too few cells to be a "
"reliable expression profile."
)
if dropped_low_counts:
run_warnings.append(
f"Dropped {len(dropped_low_counts)} pseudobulk sample(s) with total "
f"counts below min_counts={min_counts}."
)
if pdata.n_obs == 0:
raise ValueError(
"No pseudobulk samples survived filtering "
f"(min_cells={min_cells}, min_counts={min_counts}). "
f"Started from {n_cells_in} cells across "
f"{adata.obs[sample_col].nunique()} value(s) of '{sample_col}'. "
"Lower the thresholds or check that sample_col is the donor/library "
"column rather than a per-cell identifier."
)
# ββ Drop lowly-expressed genes ββββββββββββββββββββββββββββββββββββββββ
# Single-cell matrices are extremely sparse, so a raw pseudobulk object is
# mostly untestable genes. On the real GSE155698 subset, 8,821 of 36,601
# genes were all-zero and 12,404 were detected in fewer than 3 samples;
# feeding that to DESeq2 made the dispersion trend fail to converge and
# produced |log2FC| >= 10 artefacts off near-zero group means (which the
# ADR-0002 sanity layer then correctly flagged as critical).
n_genes_before = int(pdata.n_vars)
n_genes_filtered = 0
if filter_genes and mode == "sum":
if filter_group is not None and filter_group not in pdata.obs.columns:
run_warnings.append(
f"filter_group '{filter_group}' is not an obs column of the "
"pseudobulk object β gene filtering ran ungrouped."
)
filter_group = None
try:
genes = dc.pp.filter_by_expr(pdata, group=filter_group, inplace=False)
if genes is not None:
kept = np.asarray(genes)
# decoupler returns either a boolean mask or the kept gene names.
pdata = pdata[:, kept].copy()
n_genes_filtered = n_genes_before - int(pdata.n_vars)
except Exception as exc: # never fail the aggregation over a filter
run_warnings.append(f"Gene filtering skipped ({exc}).")
if n_genes_filtered:
run_warnings.append(
f"Dropped {n_genes_filtered} lowly-expressed gene(s) of "
f"{n_genes_before} (edgeR filterByExpr via dc.pp.filter_by_expr"
+ (f", grouped by '{filter_group}'" if filter_group else "")
+ f"), leaving {pdata.n_vars}. Single-cell data is sparse: keeping "
"all-zero genes breaks DESeq2's dispersion-trend fit and "
"manufactures large fold-changes off near-zero means."
)
elif filter_genes and mode != "sum":
run_warnings.append(
"Gene filtering skipped: filter_by_expr is a count-based filter and "
"only applies to mode='sum'."
)
# ββ Replication diagnostics βββββββββββββββββββββββββββββββββββββββββββ
if pdata.n_obs < MIN_SAMPLES_FOR_DE:
run_warnings.append(
f"Only {pdata.n_obs} pseudobulk sample(s) remain. A DE contrast needs "
"at least ~2 replicates per group; results from this few samples are "
"not interpretable."
)
# A per-cell identifier passed as sample_col is the classic mistake β it
# yields ~one cell per "sample" and defeats the whole point of pseudobulk.
if mode == "sum" and n_cells_in > 0:
mean_cells = float(np.mean(pdata.obs["psbulk_n_cells"])) if pdata.n_obs else 0.0
if mean_cells < 2:
run_warnings.append(
f"Pseudobulk samples average {mean_cells:.1f} cells each β "
f"'{sample_col}' looks like a per-cell identifier rather than a "
"biological sample/donor column. Check the obs columns."
)
run_warnings.append(
f"Aggregated {n_cells_in} cells into {pdata.n_obs} pseudobulk sample(s) "
f"by '{sample_col}'"
+ (f" x '{groups_col}'" if groups_col else "")
+ f" using mode='{mode}'"
+ (f" on layer '{layer}'" if layer else "")
+ ". Cells within a sample are not independent replicates β the "
"pseudobulk sample is the unit of replication for downstream DE."
)
cells_per_sample = {str(idx): int(row["psbulk_n_cells"]) for idx, row in pdata.obs.iterrows()}
return {
"adata_pseudobulk": pdata,
"n_cells_in": n_cells_in,
"n_samples_out": int(pdata.n_obs),
"n_genes": int(pdata.n_vars),
"n_genes_before_filter": n_genes_before,
"n_genes_filtered_out": n_genes_filtered,
"filter_genes": filter_genes,
"filter_group": filter_group,
"sample_col": sample_col,
"groups_col": groups_col,
"mode": mode,
"layer": layer,
"input_looked_like_counts": is_counts,
"dropped_low_cells": dropped_low_cells,
"dropped_low_counts": dropped_low_counts,
"cells_per_sample": cells_per_sample,
"warnings": run_warnings,
}
def summarize_design(pdata, design_factor: str | None) -> dict[str, Any]:
"""
Report per-level replication for a candidate DE design factor.
Returned separately from aggregation so the agent can check a contrast is
powered *before* paying for DESeq2. ``usable`` is False when any level has
fewer than 2 pseudobulk replicates β DESeq2 cannot estimate dispersion from
a single sample per group.
"""
if design_factor is None:
return {"design_factor": None, "levels": {}, "usable": None, "note": None}
if design_factor not in pdata.obs.columns:
return {
"design_factor": design_factor,
"levels": {},
"usable": False,
"note": (
f"design_factor '{design_factor}' is not an obs column of the "
f"pseudobulk object. Available: {list(pdata.obs.columns)}"
),
}
counts = pdata.obs[design_factor].value_counts(dropna=False)
levels = {str(k): int(v) for k, v in counts.items()}
under = [k for k, v in levels.items() if v < 2]
return {
"design_factor": design_factor,
"levels": levels,
"usable": not under,
"note": (
f"Level(s) {under} have fewer than 2 pseudobulk replicates β DESeq2 "
"cannot estimate dispersion for them. Merge, drop, or choose another "
"factor."
if under
else f"All {len(levels)} level(s) have >= 2 pseudobulk replicates."
),
}
def pseudobulk_metadata_frame(pdata) -> pd.DataFrame:
"""Return the pseudobulk obs as a plain DataFrame for CSV export."""
return pdata.obs.copy()
|