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,
}
|