File size: 11,108 Bytes
352e308
 
 
 
 
 
 
 
 
 
 
 
 
93a454a
352e308
 
 
 
 
 
 
 
 
18391e3
 
352e308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6e9f020
 
 
 
 
352e308
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Transcription factor and pathway activity scoring workflow helpers.

Two families of functions live here:

1. Manifest-aware parameter helpers
   ----------------------------------
   get_scoring_params   Extract organism, contrast, and applicable workflows
                        from a dataset manifest.

2. Generic sample-level scoring
   ------------------------------
   score_bulk_samples_with_decoupler
                        Run decoupleR activity estimation (ULM/MLM/zscore) on a
                        pre-normalised samples Γ— genes expression DataFrame.
                        Returns a samples Γ— activities DataFrame and diagnostic
                        metadata.  Does not hard-code any dataset.
"""

from __future__ import annotations

from typing import Any

import pandas as pd

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

SUPPORTED_RESOURCES: frozenset[str] = frozenset({"progeny", "collectri", "hallmark"})

SUPPORTED_METHODS: frozenset[str] = frozenset({"ulm", "mlm", "zscore"})

_RESOURCE_DESCRIPTIONS: dict[str, str] = {
    "progeny":   "PROGENy β€” 14 cancer signalling pathway signatures",
    "collectri": "CollecTRI β€” TF–target regulons (~1 000 TFs)",
    "hallmark":  "MSigDB Hallmark β€” 50 curated gene sets",
}


# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------

def get_scoring_params(manifest: "dict | object") -> dict[str, Any]:
    """
    Extract activity scoring parameters from a dataset manifest.

    Accepts both a DatasetManifest dataclass instance and a raw dict.

    Returns
    -------
    dict with keys:
        organism  (str)  β€” "human" or "mouse".
        contrast  (dict) β€” design_factor, test_group, control_group, method.
        workflows (list) β€” which workflow modules apply to this dataset.
    """
    if hasattr(manifest, "organism"):
        return {
            "organism": manifest.organism,
            "contrast": (manifest.default_contrasts or [{}])[0]
                        if hasattr(manifest, "default_contrasts") else {},
            "workflows": getattr(manifest, "valid_workflows", []),
        }
    return {
        "organism": manifest.get("organism", "human"),
        "contrast": manifest.get("contrast", {}),
        "workflows": manifest.get("workflows", []),
    }


# ---------------------------------------------------------------------------
# Internal helper
# ---------------------------------------------------------------------------

def _find_network_target_col(net: "pd.DataFrame") -> "str | None":
    """Return the target/gene column name in a decoupler network, or None."""
    for col in ("target", "gene", "Gene", "to"):
        if col in net.columns:
            return col
    return None


# ---------------------------------------------------------------------------
# Generic sample-level scoring
# ---------------------------------------------------------------------------

def score_bulk_samples_with_decoupler(
    expression_df: "pd.DataFrame",
    resource: str = "progeny",
    organism: str = "human",
    method: str = "ulm",
    min_n: int = 5,
    _network: "pd.DataFrame | None" = None,
) -> dict[str, Any]:
    """
    Score bulk expression samples using decoupleR activity estimation.

    Computes per-sample activity scores for pathways (PROGENy), TF regulons
    (CollecTRI), or hallmark gene sets by fitting a linear model (ULM/MLM)
    or weighted sum (WSUM) across the genes in each regulon/pathway.

    This function is generic β€” it does not assume any specific dataset.
    Pass any pre-normalised samples Γ— genes expression matrix.

    Input requirements
    ------------------
    expression_df:
        Samples as rows (index = sample IDs), genes as columns (HGNC symbols).
        Values must be pre-normalised (log-CPM, log-intensity, log2-TPM, etc.).
        Do NOT pass raw integer counts β€” activity estimation assumes continuous,
        roughly symmetric expression values.

    Output
    ------
    activity_df:
        Same row index as expression_df.  Columns are activity names
        (pathway names, TF names, or hallmark names).

    Parameters
    ----------
    resource:
        "progeny"   β€” PROGENy pathway signatures (14 pathways).
        "collectri" β€” CollecTRI TF regulons (~1 000 TFs, human/mouse).
        "hallmark"  β€” MSigDB Hallmark gene sets (50 sets).
    organism:
        "human" or "mouse".
    method:
        "ulm"  β€” Univariate Linear Model (recommended; fastest).
        "mlm"  β€” Multivariate Linear Model (accounts for co-linearity).
        "zscore" β€” Z-score (simpler, no distributional assumptions).
    min_n:
        Minimum expected samples. Emits a warning when fewer are present.
    _network:
        For testing only.  Supply a pre-loaded network DataFrame to bypass
        the decoupler network download (dc.op.*).  Not exposed in MCP tools.

    Returns
    -------
    dict with keys:
        activity_df     (pd.DataFrame) β€” samples Γ— activities.
        pvalue_df       (pd.DataFrame) β€” samples Γ— activities (p-values).
        n_samples       (int)
        n_activities    (int)   β€” number of scored activities (sources).
        n_network_genes (int)   β€” total target genes in the network.
        n_matched_genes (int)   β€” network genes present in expression_df.
        coverage_pct    (float) β€” % of network genes matched; None if unknown.
        resource        (str)
        organism        (str)
        method          (str)
        warnings        (list[str])

    Raises
    ------
    ValueError  if resource or method is not in the supported set.
    """
    import decoupler as dc

    # A caller-supplied network (``_network``) is a custom signature β€” e.g. a
    # Loveless-derived cell-state signature scored against a bulk cohort
    # (ADR-0006 Role 2). In that case ``resource`` is just a free-form label for
    # outputs/diagnostics, so the built-in-resource gate does not apply.
    if _network is None and resource not in SUPPORTED_RESOURCES:
        raise ValueError(
            f"resource must be one of {sorted(SUPPORTED_RESOURCES)}, got '{resource}'"
        )
    if method not in SUPPORTED_METHODS:
        raise ValueError(
            f"method must be one of {sorted(SUPPORTED_METHODS)}, got '{method}'"
        )

    run_warnings: list[str] = []

    # ── Load network ─────────────────────────────────────────────────────
    if _network is not None:
        net = _network.copy()
    elif resource == "progeny":
        net = dc.op.progeny(organism=organism)
    elif resource == "collectri":
        net = dc.op.collectri(organism=organism)
    else:  # hallmark
        net = dc.op.hallmark(organism=organism)

    # ── Gene coverage ────────────────────────────────────────────────────
    target_col = _find_network_target_col(net)
    expr_genes = set(expression_df.columns.astype(str))

    if target_col:
        network_genes: set[str] = set(net[target_col].astype(str).unique())
        n_network = len(network_genes)
        n_matched = len(network_genes & expr_genes)
        coverage_pct: float | None = (
            round(100 * n_matched / n_network, 1) if n_network > 0 else 0.0
        )

        if coverage_pct is not None and coverage_pct < 20:
            run_warnings.append(
                f"Low gene coverage: {n_matched}/{n_network} network genes "
                f"({coverage_pct}%) found in expression matrix. "
                "Check that column names are HGNC gene symbols."
            )
        elif coverage_pct is not None and coverage_pct < 50:
            run_warnings.append(
                f"Moderate gene coverage: {n_matched}/{n_network} network genes "
                f"({coverage_pct}%) found in expression matrix."
            )
    else:
        n_network = len(net)
        n_matched = 0
        coverage_pct = None
        run_warnings.append(
            "Could not identify target gene column in network β€” "
            "gene coverage check skipped."
        )

    # ── Sample count ─────────────────────────────────────────────────────
    n_samples = len(expression_df)
    if n_samples < min_n:
        run_warnings.append(
            f"Expression matrix has {n_samples} sample(s), below min_n={min_n}. "
            "Activity estimates from very small cohorts should be interpreted "
            "with caution."
        )

    # ── Pre-flight: zero-overlap guard ───────────────────────────────────
    if target_col and n_matched == 0:
        raise ValueError(
            f"Zero gene overlap: none of the {n_network} {resource} network "
            f"genes were found in the expression columns. "
            "Ensure column names are HGNC gene symbols."
        )

    # ── Run scoring ──────────────────────────────────────────────────────
    # Wrap decoupler's AssertionError (too few overlapping targets per source)
    # into a ValueError with a more actionable message.
    try:
        if method == "ulm":
            acts, pvals = dc.mt.ulm(data=expression_df, net=net)
        elif method == "mlm":
            acts, pvals = dc.mt.mlm(data=expression_df, net=net)
        else:  # zscore
            acts, pvals = dc.mt.zscore(data=expression_df, net=net)
    except AssertionError as exc:
        raise ValueError(
            f"decoupleR could not score with resource='{resource}': {exc}. "
            f"Gene coverage: {n_matched}/{n_network} ({coverage_pct}%). "
            "Each source requires β‰₯5 overlapping target genes by default. "
            "Ensure expression columns are HGNC gene symbols and sufficient "
            "genes are covered."
        ) from exc

    run_warnings.append(
        f"Activity scores computed with method='{method}', "
        f"resource='{resource}' ({_RESOURCE_DESCRIPTIONS.get(resource, '')}), "
        f"organism='{organism}'. "
        "Input is assumed to be pre-normalised expression. "
        "Do not interpret activity scores as log fold-changes."
    )

    return {
        "activity_df": acts,
        "pvalue_df": pvals,
        "n_samples": n_samples,
        "n_activities": int(acts.shape[1]),
        "n_network_genes": n_network,
        "n_matched_genes": n_matched,
        "coverage_pct": coverage_pct,
        "resource": resource,
        "organism": organism,
        "method": method,
        "warnings": run_warnings,
    }