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()