File size: 11,466 Bytes
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ec3eabb
 
 
b6d529b
 
bfe6079
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
 
 
b6d529b
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
 
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c3b49d6
bfe6079
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Statistical analysis of TF and pathway activity scores.

Provides dataset-agnostic functions for extracting contrast definitions
from manifests, ranking activity scores, and comparing activities between
metadata-defined sample groups.

Functions (implemented)
-----------------------
get_contrast_groups        Extract test/control group labels from a manifest.
rank_by_activity           Rank TFs or pathways by mean absolute activity score.
compare_activity_by_group  Welch's t-test + BH FDR across all activities between
                           two metadata-defined sample groups.  Returns a table
                           with activity, mean_test, mean_control, effect_size
                           (Cohen's d), statistic, pvalue, padj, n_test, n_control.

"""

from __future__ import annotations

from typing import Any

import numpy as np
import pandas as pd

from src.workflows.metadata_validation import subset_and_require_group


def get_contrast_groups(manifest: dict | object) -> dict[str, str]:
    """
    Extract the contrast definition from a manifest.

    Accepts both a DatasetManifest dataclass instance and a raw dict so this
    function works regardless of how the manifest was loaded.

    Returns a dict with keys:
        design_factor (str)  β€” column in obs used as the grouping variable.
        test_group    (str)  β€” label of the test / foreground condition.
        control_group (str)  β€” label of the reference / background condition.
        method        (str)  β€” DE method: "deseq2", "ttest", or "limma".

    Returns empty strings for any missing fields so callers can check
    truthiness rather than catching KeyError.
    """
    if hasattr(manifest, "default_contrasts"):
        contrasts = manifest.default_contrasts or []
        contrast = contrasts[0] if contrasts else {}
    else:
        contrast = manifest.get("contrast", {})

    return {
        "design_factor": contrast.get("design_factor", "condition"),
        "test_group": contrast.get("test_group", ""),
        "control_group": contrast.get("control_group", ""),
        "method": contrast.get("method", "ttest"),
    }


def rank_by_activity(
    activities: dict[str, float],
    top_n: int = 25,
    by_abs: bool = True,
) -> list[tuple[str, float]]:
    """
    Rank features (TFs, pathways) by activity score.

    Parameters
    ----------
    activities:  Mapping of feature_name β†’ activity_score (float).
    top_n:       Number of top features to return.
    by_abs:      If True, rank by |score| so both activated and repressed
                 features surface.  If False, rank by raw score (top = most
                 activated).

    Returns
    -------
    Sorted list of (feature_name, score) tuples, descending by the ranking key.
    """
    key = (lambda kv: abs(kv[1])) if by_abs else (lambda kv: kv[1])
    ranked = sorted(activities.items(), key=key, reverse=True)
    return ranked[:top_n]


def compare_activity_by_group(
    activity_df: pd.DataFrame,
    metadata_df: pd.DataFrame,
    group_column: str,
    test_group: str,
    control_group: str,
    subset_query: str | None = None,
    method: str = "welch_ttest",
) -> dict[str, Any]:
    """
    Compare activity scores between two metadata-defined sample groups.

    Use this after dataset_score_bulk_samples when the user asks whether
    TF/pathway/hallmark activity differs between two sample groups (e.g.
    Classical vs Basal PDAC, treated vs untreated, tumour vs normal).

    Runs Welch's t-test independently for each activity (column) and applies
    Benjamini-Hochberg FDR correction across all tests.  Effect size is
    Cohen's d: (mean_test βˆ’ mean_control) / pooled_std β€” positive values
    indicate higher activity in the test group.

    This function operates on pre-computed activity scores (output of
    score_bulk_samples_with_decoupler), not on raw expression matrices.

    Parameters
    ----------
    activity_df:
        Samples Γ— activities DataFrame (e.g. output of
        score_bulk_samples_with_decoupler).  Index = sample IDs.
    metadata_df:
        Sample metadata DataFrame.  Index must align with activity_df.index.
    group_column:
        Column in metadata_df containing the group labels.
    test_group:
        Label of the foreground / test condition.
    control_group:
        Label of the reference / background condition.
    subset_query:
        Optional pandas query string applied to metadata_df before grouping,
        e.g. ``"tissue == 'tumor'"``.
    method:
        Statistical method.  Currently only "welch_ttest" is supported.

    Returns
    -------
    dict with keys:
        dataframe       (pd.DataFrame) β€” activities Γ— stats, sorted by padj.
                        Columns: mean_test, mean_control, effect_size,
                                 statistic, pvalue, padj, n_test, n_control.
                        Index name: "activity".
        n_activities    (int)
        n_test          (int)
        n_control       (int)
        group_column    (str)
        test_group      (str)
        control_group   (str)
        subset_query    (str | None)
        method          (str)
        significant_05  (int) β€” activities with padj < 0.05
        significant_01  (int) β€” activities with padj < 0.01
        warnings        (list[str])

    Raises
    ------
    ValueError  if group_column is missing, groups are not found, either group
                has fewer than 2 samples, or subset_query fails.
    """
    import warnings as _w

    from scipy import stats
    from statsmodels.stats.multitest import multipletests

    _SUPPORTED = frozenset({"welch_ttest"})
    if method not in _SUPPORTED:
        raise ValueError(f"method must be one of {sorted(_SUPPORTED)}, got '{method}'")

    run_warnings: list[str] = []

    # Subset (optional) + require the group column (shared helper).
    working_meta = subset_and_require_group(metadata_df, subset_query, group_column)

    # ── Validate group labels ─────────────────────────────────────────────
    available = working_meta[group_column].unique().tolist()
    available_str = sorted(str(g) for g in available)

    if test_group == control_group:
        raise ValueError("test_group and control_group must be different.")
    if test_group not in available:
        raise ValueError(
            f"test_group '{test_group}' not found in column '{group_column}'. "
            f"Available: {available_str}"
        )
    if control_group not in available:
        raise ValueError(
            f"control_group '{control_group}' not found in column '{group_column}'. "
            f"Available: {available_str}"
        )

    # ── Align activity_df to (subsetted) metadata ─────────────────────────
    common_idx = activity_df.index.intersection(working_meta.index)
    if len(common_idx) == 0:
        raise ValueError(
            "No common samples between activity_df and metadata_df. "
            "Check that indices are aligned (both should use sample IDs)."
        )
    if len(common_idx) < len(activity_df):
        run_warnings.append(
            f"{len(activity_df) - len(common_idx)} activity sample(s) had no "
            "metadata match and were excluded."
        )

    aligned_act = activity_df.loc[common_idx]
    aligned_meta = working_meta.loc[common_idx]

    # ── Build group masks ─────────────────────────────────────────────────
    test_mask = aligned_meta[group_column] == test_group
    ctrl_mask = aligned_meta[group_column] == control_group
    n_test = int(test_mask.sum())
    n_ctrl = int(ctrl_mask.sum())

    if n_test < 2:
        raise ValueError(
            f"test_group '{test_group}' has {n_test} sample(s) after alignment β€” "
            "need at least 2 for Welch's t-test."
        )
    if n_ctrl < 2:
        raise ValueError(
            f"control_group '{control_group}' has {n_ctrl} sample(s) after alignment β€” "
            "need at least 2 for Welch's t-test."
        )
    if n_test < 5 or n_ctrl < 5:
        run_warnings.append(
            f"Small group sizes (test={n_test}, control={n_ctrl}). "
            "Statistical power is limited; interpret results with caution."
        )

    n_excluded = len(aligned_act) - n_test - n_ctrl
    if n_excluded > 0:
        other = [str(g) for g in available if g not in (test_group, control_group)]
        run_warnings.append(
            f"{n_excluded} sample(s) in other group(s) excluded from comparison "
            f"({other[:5]}{'...' if len(other) > 5 else ''})."
        )

    # ── Vectorised Welch's t-test ─────────────────────────────────────────
    X_test = aligned_act[test_mask].values  # n_test Γ— n_activities
    X_ctrl = aligned_act[ctrl_mask].values  # n_ctrl Γ— n_activities

    with _w.catch_warnings():
        _w.simplefilter("ignore", RuntimeWarning)
        t_stats, p_vals = stats.ttest_ind(X_test, X_ctrl, axis=0, equal_var=False)

    # Replace NaN / Inf from zero-variance activities before BH correction
    t_stats = np.where(np.isfinite(t_stats), t_stats, 0.0)
    p_vals = np.where(np.isfinite(p_vals), p_vals, 1.0)

    _, padj, _, _ = multipletests(p_vals, method="fdr_bh")

    mean_test = X_test.mean(axis=0)
    mean_ctrl = X_ctrl.mean(axis=0)

    # ── Cohen's d effect size ─────────────────────────────────────────────
    # pooled_std = sqrt(((n1-1)*s1Β² + (n2-1)*s2Β²) / (n1+n2-2))
    # Positive d β†’ test_group has higher activity.
    std_test = X_test.std(axis=0, ddof=1)
    std_ctrl = X_ctrl.std(axis=0, ddof=1)
    pooled_var = ((n_test - 1) * std_test**2 + (n_ctrl - 1) * std_ctrl**2) / (n_test + n_ctrl - 2)
    with np.errstate(divide="ignore", invalid="ignore"):
        cohens_d = np.where(
            pooled_var > 0,
            (mean_test - mean_ctrl) / np.sqrt(pooled_var),
            0.0,
        )

    activities = aligned_act.columns.tolist()
    result_df = pd.DataFrame(
        {
            "mean_test": mean_test,
            "mean_control": mean_ctrl,
            "effect_size": cohens_d,
            "statistic": t_stats,
            "pvalue": p_vals,
            "padj": padj,
            "n_test": n_test,
            "n_control": n_ctrl,
        },
        index=activities,
    )
    result_df.index.name = "activity"
    result_df = result_df.sort_values("padj")

    run_warnings.append(
        f"effect_size is Cohen's d: (mean_test βˆ’ mean_control) / pooled_std. "
        f"Positive values indicate higher activity in '{test_group}'. "
        "Activities with zero variance in either group are assigned d=0."
    )

    return {
        "dataframe": result_df,
        "n_activities": len(activities),
        "n_test": n_test,
        "n_control": n_ctrl,
        "group_column": group_column,
        "test_group": test_group,
        "control_group": control_group,
        "subset_query": subset_query,
        "method": method,
        "significant_05": int((padj < 0.05).sum()),
        "significant_01": int((padj < 0.01).sum()),
        "warnings": run_warnings,
    }