Files
RGC-ADOA/script/11_pseudobulk_de.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

156 lines
7.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 -*-
"""
11_pseudobulk_de.py — P1-③ pseudobulk DE:方向一致性策略(决策记录 Step 5 核心层)
策略(WT n=1 无法做正式推断):
1. 每 样本×细胞组 对 counts 求和 → CPM + log2
2. LFC_S1 = S1WTLFC_S2 = S2WTLFC_null = S2S1(同基因型阴性校准)
3. 一致基因:sign(LFC_S1)==sign(LFC_S2) 且 |LFC|>=0.5 且 |LFC_null| < 两次生物 LFC 的较小者
4. 表达过滤:至少一个样本 CPM>=10
5. 与作者 S2 各细胞类型表做 Spearman 相关 + 方向一致率
细胞组:所有 cell_typeWT>=20 核)+ RGC 按签名分 RGC1-like/RGC2-likeRGC2-like WT 仅 7 核,标记低功效)。
产出:output/11_pseudobulk_de/
"""
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
import scipy.sparse as sp
from scipy.stats import spearmanr
OUT = Path("output/11_pseudobulk_de")
OUT.mkdir(parents=True, exist_ok=True)
plt.rcParams.update({"figure.dpi": 300, "savefig.dpi": 300, "font.size": 8})
MIN_NUCLEI = 20
LFC_THR = 0.5
CPM_THR = 10
adata = sc.read_h5ad("data/05_rgc2sig.h5ad")
counts = sp.csr_matrix(adata.layers["counts"])
# ---- 细胞组定义:cell_type + RGC 签名亚组 ----
obs = adata.obs.copy()
obs["pb_group"] = obs["cell_type"].astype(str)
is_rgc = obs["cell_type"] == "RGC"
obs.loc[is_rgc, "pb_group"] = obs.loc[is_rgc, "rgc2_sig_group"].astype(str) # RGC1-like / RGC2-like
obs.loc[obs["pb_group"] == "LowConf", "pb_group"] = "LowConf"
sizes = obs.groupby(["pb_group", "sample"]).size().unstack(fill_value=0)
sizes.to_csv(OUT / "pseudobulk_group_sizes.csv")
groups = [g for g in sizes.index if (sizes.loc[g] >= MIN_NUCLEI).all()]
lowpower = [g for g in sizes.index if g not in groups and (sizes.loc[g] >= 5).all() and g not in ("LowConf",)]
print("正式组:", groups)
print("低功效组(描述性):", lowpower)
samples = ["WT", "Opa1V291D_S1", "Opa1V291D_S2"]
def pseudobulk(group):
"""返回 3×genes 的 raw count 矩阵。"""
mat = np.zeros((3, adata.n_vars))
for i, s in enumerate(samples):
idx = np.where((obs["pb_group"] == group) & (obs["sample"] == s))[0]
mat[i] = np.asarray(counts[idx].sum(axis=0)).ravel()
return mat
def median_of_ratios(mat):
"""DESeq2 式 size factor:基因几何均值跨样本,样本 size factor = count/geomean 的中位数。
校正文库组成与全局检出率差异(CPM 在深度悬殊时产生全局 LFC 偏倚)。"""
pos = (mat > 0).all(axis=0)
geomean = np.exp(np.log(mat[:, pos]).mean(axis=0))
ratios = mat[:, pos] / geomean
sf = np.median(ratios, axis=1)
return mat / sf[:, None]
def compute_de(group):
mat = pseudobulk(group)
norm = median_of_ratios(mat) # 归一化后的"每 size-factor"计数
keep = (mat >= 1).any(axis=0) & ((mat / mat.sum(axis=1, keepdims=True) * 1e6) >= CPM_THR).any(axis=0)
l2 = np.log2(norm + 0.5)
df = pd.DataFrame({
"gene": adata.var_names,
"cpm_WT": mat[0] / mat[0].sum() * 1e6, "cpm_S1": mat[1] / mat[1].sum() * 1e6,
"cpm_S2": mat[2] / mat[2].sum() * 1e6,
"lfc_S1": l2[1] - l2[0], "lfc_S2": l2[2] - l2[0], "lfc_null": l2[2] - l2[1],
})[keep].copy()
# 零检出守卫:UP 要求 WT 有基线检出(CPM>=1),DOWN 要求突变有检出,
# 防止深度不对称造成的"零 vs 有"伪差异(突变文库深,假 UP 偏多)
detect_ok = np.where(
np.sign(df["lfc_S1"]) > 0, df["cpm_WT"] >= 1.0,
np.minimum(df["cpm_S1"], df["cpm_S2"]) >= 1.0)
consistent = (
(np.sign(df["lfc_S1"]) == np.sign(df["lfc_S2"]))
& (df["lfc_S1"].abs() >= LFC_THR) & (df["lfc_S2"].abs() >= LFC_THR)
& (df["lfc_null"].abs() < np.minimum(df["lfc_S1"].abs(), df["lfc_S2"].abs()))
& detect_ok
)
df["consistent"] = consistent
df["direction"] = np.where(consistent, np.sign(df["lfc_S1"]).map({1.0: "UP", -1.0: "DOWN"}), "ns")
return df
# ---- 作者 S2 对照 ----
author_map = {"Rod": "Rod", "Cone": "Cone", "RGC1-like": "RGC-1", "RGC2-like": "RGC-2",
"RGC": "RGC-2", "Amacrine": "Amacrine", "Muller": "Muller",
"Uveal_Melanocyte": "Uveal", "Bipolar": "Bipolar",
"Horizontal": "Horizontal", "Pericyte": "Pericyte"}
xl = "ref/Data files/S2. snRNAseq-DEG_All_Celltype.xlsx"
all_groups = groups + [g for g in lowpower if g in author_map or g in ("RGC2-like",)]
summary_rows = []
de_tables = {}
for g in all_groups:
df = compute_de(g)
df["n_WT_nuclei"] = int(sizes.loc[g, "WT"])
df["low_power"] = g not in groups
df.to_csv(OUT / f"de_{g.replace('/', '_')}.csv", index=False)
de_tables[g] = df
n_up = (df["direction"] == "UP").sum()
n_dn = (df["direction"] == "DOWN").sum()
row = {"group": g, "n_genes_tested": len(df), "consistent_UP": int(n_up),
"consistent_DOWN": int(n_dn), "UP_DOWN_ratio": round(n_up / max(n_dn, 1), 3),
"n_WT_nuclei": int(sizes.loc[g, "WT"]), "low_power": g not in groups}
if g in author_map:
a = pd.read_excel(xl, sheet_name=author_map[g])
a.columns = [c.lower() for c in a.columns]
mg = df.merge(a[["gene", "avg_log2fc"]], on="gene", how="inner")
mg["our_lfc_mean"] = (mg["lfc_S1"] + mg["lfc_S2"]) / 2
rho, p = spearmanr(mg["our_lfc_mean"], mg["avg_log2fc"])
sig_a = a[a["p_val"] < 0.001].merge(df, on="gene", how="inner")
if len(sig_a):
sig_a = sig_a.assign(our_mean=(sig_a["lfc_S1"] + sig_a["lfc_S2"]) / 2)
conc = (np.sign(sig_a["avg_log2fc"]) == np.sign(sig_a["our_mean"])).mean()
else:
conc = np.nan
row.update({"author_sheet": author_map[g], "spearman_rho": round(rho, 3),
"author_sig_gene_concordance": round(conc, 3) if conc == conc else None})
de_tables[g + ".__author_merge"] = mg
summary_rows.append(row)
summary = pd.DataFrame(summary_rows)
summary.to_csv(OUT / "de_summary.csv", index=False)
print("\n", summary.to_string(index=False))
# ---- 图:与作者 log2FC 散点对照(Muller / RGC2-like / Rod / RGC1-like ----
show = [g for g in ["Muller", "RGC2-like", "RGC1-like", "Rod"] if g + ".__author_merge" in de_tables]
fig, axes = plt.subplots(1, len(show), figsize=(3.4 * len(show), 3.4))
if len(show) == 1:
axes = [axes]
for ax, g in zip(axes, show):
mg = de_tables[g + ".__author_merge"]
ax.scatter(mg["avg_log2fc"], mg["our_lfc_mean"], s=1, alpha=0.15, color="grey", rasterized=True)
cons = mg[mg["consistent"]]
ax.scatter(cons["avg_log2fc"], (cons["lfc_S1"] + cons["lfc_S2"]) / 2, s=2, alpha=0.5,
color="firebrick", rasterized=True)
rho = summary.loc[summary["group"] == g, "spearman_rho"].iloc[0]
ax.axhline(0, color="k", lw=0.4); ax.axvline(0, color="k", lw=0.4)
ax.set_xlabel("authors avg_log2FC"); ax.set_ylabel("our pseudobulk LFC (mean)")
ax.set_title(f"{g} (Spearman {rho})")
fig.tight_layout()
fig.savefig(OUT / "author_comparison_scatter.png", bbox_inches="tight")
print("Done ->", OUT)