Files
RGC-ADOA/script/09_muller_downsample.py
rain 092f39a662 chore: 初始化 RGC-ADOA 分析仓库
纳入 script/doc/ref/output 及配置;忽略 data/(26G 原始/中间数据)。
git 身份:rain <wjs_Rain@126.com>。
2026-09-18 18:21:19 +08:00

170 lines
8.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
09_muller_downsample.py — P1-① Müller 全面上调的"真激活 vs 深度假象"裁决
背景:突变 Müller 的 ETC/CI/核糖体/糖酵解模块分全面上调(P0 意外发现)。
但突变文库核数多(1070/1319 vs WT 183)且测序深(中位 UMI 高 ~35%),
模块分可能受检出率膨胀驱动。本脚本做匹配敏感性对照(决策记录 第二轮裁决 #1):
1. 突变 Müller 随机抽至 183 核(=WT 核数)
2. 每核按 WT Müller total_counts 经验分布逐个下采样 UMI(深度也匹配)
3. 重算 5 个代谢模块分,比较 WT vs 下采样突变
4. 5 个随机种子重复,看方向是否稳定
阳性对照:RGC_highETC 的糖酵解/线粒体自噬下调(P0 已复现的真信号)做同样处理,
验证"下采样后仍能检出真实差异"的功效。WT RGC_highETC 仅 21 核,结果仅供参考。
产出:output/09_downsample/
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import scanpy as sc
OUT = Path("output/09_downsample")
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"figure.dpi": 300, "savefig.dpi": 300, "font.size": 8})
N_SEEDS = 5
GENESETS = {
"ETC_WP295": "Ndufs3/Ndufb6/Cox17/Ndufa1/Uqcrfs1/Slc25a4/Ndufb4/Ndufa3/Ndufa5/Ndufb7/Ndufv2/Ndufs5/Uqcrb/Ndufa6/Uqcrc2/Cox7b/Uqcr11/Slc25a5/Ndufb9/Ndufv1/Ndufa10/Ndufc1/Uqcr10/Sdhc/Ndufc2/Ndufb10/Ndufb5/Cox6b1/Ndufa2/Cox8a/Slc25a14/Cox5a/Surf1/Ndufs7",
"CI_biogenesis_RE": "Ndufs3/Ndufb6/Ndufa1/Ndufb4/Ndufa3/Ndufa5/Ndufb7/Ndufaf3/Ndufv2/Ndufs5/Ndufa6/Ndufb8/Nubpl/Ndufb9/Ndufv1/Ndufaf5/Ndufa13/Ndufa10/Ndufc1/Ecsit/Ndufc2/Ndufb10/Ndufb5/Ndufa2/Tmem126b/Ndufs7",
"Glycolysis_WP157": "Gapdh/Pgam2/Tpi1/Aldoc/Ldhb/Eno2/Aldoa/Pkm/Got2/Pdha1/Mdh1/Ldha/Pgk1/Pgam1",
"Mitophagy_Autophagy": "Pink1/Prkn/Bnip3/Bnip3l/Fundc1/Map1lc3b/Sqstm1/Ulk1/Atg5/Atg7/Becn1/Gabarap",
}
adata = sc.read_h5ad("data/04_scored.h5ad") # 含 rgc_group 与 counts 层
counts = adata.layers["counts"]
if not hasattr(counts, "tocsr"):
counts = counts # 已是稀疏
import scipy.sparse as sp
counts = sp.csr_matrix(counts)
def downsample_group(obs_mask, wt_ref_counts_per_cell, rng, label):
"""对 mask 选中的核:先抽至 len(wt_ref) 个核,再逐核下采样到 WT 经验深度。返回下采样后的 counts 子矩阵与细胞名。"""
idx = np.where(obs_mask.values)[0]
n_target = len(wt_ref_counts_per_cell)
if len(idx) <= n_target:
chosen = idx
else:
chosen = rng.choice(idx, size=n_target, replace=False)
sub = counts[chosen].copy()
targets = rng.choice(wt_ref_counts_per_cell, size=len(chosen), replace=True)
rows, cols, data = [], [], []
for i in range(sub.shape[0]):
row = sub.getrow(i)
tot = row.sum()
tgt = targets[i]
if tot <= tgt or tot == 0:
continue
# 逐 UMI 不放回抽样
gene_idx = row.indices
gene_cnt = row.data.astype(int)
expanded = np.repeat(gene_idx, gene_cnt)
keep = rng.choice(expanded, size=int(tgt), replace=False)
uc, cc = np.unique(keep, return_counts=True)
rows.append(np.full(len(uc), i)); cols.append(uc); data.append(cc)
if rows:
r = np.concatenate(rows); c = np.concatenate(cols); d = np.concatenate(data)
sub = sp.csr_matrix((d, (r, c)), shape=sub.shape)
names = adata.obs_names[chosen]
return names, sub
def score_subset(wt_names, wt_mat, mu_names, mu_mat, gs):
"""WT + 下采样突变拼一个对象,normalize+log1p+score,返回两组中位数差。"""
import anndata as ad
X = sp.vstack([wt_mat, mu_mat])
obs = pd.DataFrame(
{"group": ["WT"] * len(wt_names) + ["MU"] * len(mu_names)},
index=list(wt_names) + list(mu_names))
a = ad.AnnData(X=X, obs=obs, var=pd.DataFrame(index=adata.var_names))
sc.pp.normalize_total(a, target_sum=1e4)
sc.pp.log1p(a)
out = {}
for k, v in gs.items():
sc.tl.score_genes(a, gene_list=v, score_name="s", use_raw=False, random_state=1)
med = a.obs.groupby("group")["s"].median()
out[k] = med["MU"] - med["WT"]
return out
# 基因集与数据取交集
gs = {k: [g for g in v.split("/") if g in adata.var_names] for k, v in GENESETS.items()}
gs["Ribosomal_WP163"] = [g for g in adata.var_names if g.startswith(("Rpl", "Rps"))][:200]
print({k: len(v) for k, v in gs.items()})
# ---- 任务组:Muller(裁决对象) + RGC_highETC(阳性对照) ----
rgc_hi = adata.obs["rgc_group"] == "RGC_highETC(~RGC-2)"
tasks = {
"Muller": adata.obs["cell_type"] == "Muller",
"RGC_highETC": rgc_hi,
}
results = []
for task_name, mask in tasks.items():
wt_mask = mask & (adata.obs["genotype"] == "WT")
wt_names = adata.obs_names[wt_mask]
wt_mat = counts[np.where(wt_mask.values)[0]]
wt_depths = np.asarray(wt_mat.sum(axis=1)).ravel()
print(f"\n== {task_name}: WT n={wt_mask.sum()}, WT 深度中位 {np.median(wt_depths):.0f}")
for sample in ["Opa1V291D_S1", "Opa1V291D_S2"]:
mu_mask = mask & (adata.obs["sample"] == sample)
for seed in range(N_SEEDS):
rng = np.random.default_rng(1000 + seed)
mu_names, mu_mat = downsample_group(mu_mask, wt_depths, rng, task_name)
deltas = score_subset(wt_names, wt_mat, mu_names, mu_mat, gs)
for k, d in deltas.items():
results.append({"group": task_name, "sample": sample, "seed": seed,
"geneset": k, "delta_median_score": round(d, 4)})
print(f" {sample}: 完成 {N_SEEDS} 种子")
res = pd.DataFrame(results)
res.to_csv(OUT / "downsample_replicates.csv", index=False)
summary = res.groupby(["group", "sample", "geneset"])["delta_median_score"].agg(["mean", "std", "count"]).round(4)
summary.to_csv(OUT / "downsample_summary.csv")
print("\n", summary.to_string())
# ---- 对照:原始全量数据的 delta(从 04_scored.h5ad 读模块分中位数) ----
full = sc.read_h5ad("data/04_scored.h5ad")
full_rows = []
for task_name, mask in tasks.items():
for k in gs:
col = f"score_{k}"
wt_med = full.obs.loc[mask & (full.obs["genotype"] == "WT"), col].median()
for s in ["Opa1V291D_S1", "Opa1V291D_S2"]:
mu_med = full.obs.loc[mask & (full.obs["sample"] == s), col].median()
full_rows.append({"group": task_name, "sample": s, "geneset": k,
"delta_full": round(mu_med - wt_med, 4)})
fulldf = pd.DataFrame(full_rows)
fulldf.to_csv(OUT / "fulldata_deltas.csv", index=False)
# ---- 图:全量 vs 下采样 delta 对照 ----
gs_keys = list(gs.keys())
fig, axes = plt.subplots(1, 2, figsize=(11, 4), sharey=False)
for ax, task_name in zip(axes, ["Muller", "RGC_highETC"]):
x = np.arange(len(gs_keys))
w = 0.35
for off, s, c in [(-w/2, "Opa1V291D_S1", "firebrick"), (w/2, "Opa1V291D_S2", "darkorange")]:
full_d = fulldf[(fulldf["group"] == task_name) & (fulldf["sample"] == s)].set_index("geneset")["delta_full"].reindex(gs_keys)
ax.bar(x + off, full_d, width=w, color=c, alpha=0.35, label=f"{s.split('_')[-1]} full", hatch="//")
sub = res[(res["group"] == task_name) & (res["sample"] == s)]
m = sub.groupby("geneset")["delta_median_score"].mean().reindex(gs_keys)
e = sub.groupby("geneset")["delta_median_score"].std().reindex(gs_keys)
ax.bar(x + off, m, width=w * 0.9, yerr=e, color=c, alpha=0.9, capsize=2,
label=f"{s.split('_')[-1]} downsampled (5 seeds)")
ax.set_xticks(x, [g.replace("_WP295", "").replace("_RE", "").replace("_WP157", "").replace("_WP163", "").replace("_", "\n") for g in gs_keys], fontsize=7)
ax.axhline(0, color="grey", lw=0.6)
ax.set_title(task_name)
ax.legend(fontsize=6, frameon=False)
axes[0].set_ylabel("Δ median module score (MU WT)")
fig.suptitle("Does Muller upregulation survive downsampling (nuclei + depth matched to WT)?", y=1.02)
fig.tight_layout()
fig.savefig(OUT / "downsample_adjudication.png", bbox_inches="tight")
print("Done ->", OUT)