预计用时:6–8 小时。
实验 2 给出了约 1000 个差异基因,但一张基因列表本身讲不出生物学故事。这个实验把基因列表翻译成"通路"。
输入统一用实验 2 参考解的结果(course fetch 3会自动算好并缓存),所以实验 2 没做完也可以先做这个。
问题:地塞米松上调和下调的基因,分别集中在哪些生物学过程里?这些过程和糖皮质激素"抗炎"的作用对得上吗?
| 数据 | 内容 | 来源 |
|---|---|---|
data/cache/airway_de_results.csv | 实验 2 的差异表达结果,约 19000 个基因 | 由实验 2 参考解生成 |
data/genesets/h.all.v2024.1.Hs.symbols.gmt | MSigDB Hallmark 基因集:50 个精选通路,每个约 30–200 个基因 | Broad Institute MSigDB v2024.1(CC BY 4.0) |
Hallmark 是 MSigDB 从几千个原始基因集里提炼出的 50 个"标志性"生物学过程,冗余少,很适合入门。
| 概念 | 一句话 |
|---|---|
| 基因集 / 通路 | 一组功能相关的基因,比如"TNF-α 通过 NF-κB 的信号转导"通路里的 200 个基因 |
| ORA | 先用阈值挑出差异基因,再问:这个通路里的基因,在差异基因中占的比例是否高于随机预期? |
| 超几何分布 | 袋子里有 M 个球,其中 n 个红球。不放回地抽 N 个,抽到 k 个红球的概率 |
| 背景(universe) | 袋子里原本有哪些球。应该是实验里检测得到的基因,不是全基因组的两万个 |
| fold enrichment | 实际命中数 ÷ 期望命中数,大于 1 表示富集 |
| BH / FDR | 50 个通路就是 50 次检验。BH 校正后的 padj < 0.05,意思是在"显著"的通路里,假阳性所占比例约 5% |
| GSEA | 不设阈值,把所有基因按变化方向和强度排好序,看某个通路的基因是否集中在列表的顶端或底端 |
| NES | GSEA 的标准化富集分数。正值表示富集在上调端,负值表示富集在下调端 |
两个关键的生物学背景:
NFKBIA 也属于这类基因,它在本数据里同样上调(log2FC ≈ 0.8,padj 显著),只是没过 |log2FC| ≥ 1 的阈值,所以 ORA 看不到它。GSEA 不设阈值,能把它算进去。
先运行 course fetch 3 和 course start 3,然后在 交付/enrich.py 里完成。
read_gmtbh_adjustscipy.stats.false_discovery_control 对答案。hypergeom_pvaluehypergeom.sf(x, …) 算的是 P(X > x),我们要的是 P(X ≥ k),差了 1。orarank_metric / run_gseastat,它同时包含了效应方向、大小和可信度。course run 3交付/ 里会生成:ora_up.csv、ora_down.csv、ora_dotplot.png、gsea.csv、gsea_nes.png、结果摘要.md。
交付/思考题.md)ora_up.csv,看 TNFA_SIGNALING_VIA_NFKB 那一行命中的 26 个基因。查其中 5 个,比如 DUSP1、TSC22D1、NFIL3、KLF9、PER1,它们是促进炎症还是抑制炎症?这和"激素抗炎"矛盾吗?交付/de_results.csv 里的结果替换缓存,重新跑一遍,看结果是否一致。c5.go.bp),体会基因集多了以后,冗余和多重检验带来的困难。| 检查点 | 通过标准 | 对应测试 |
|---|---|---|
| GMT | 50 个基因集,P53 通路 200 个基因 | 任务1_* |
| BH | 手算例子正确;和 scipy 一致;结果 ≤ 1 | 任务2_* |
| 超几何 | 和 Fisher 单侧检验一致 | 任务3_* |
| ORA | 背景 3483,查询 164;上调第一名 TNFA/NF-κB(26 个),下调第一名 P53(20 个) | 任务4_* |
| GSEA | TNFA/NF-κB 的 NES > 1.5,FDR < 0.05 | 任务5_* |
| 交付 | course check 3 全部通过;6 个文件齐全;思考题已写 | 任务6_* |
| 现象 | 原因 | 解决 |
|---|---|---|
| p 值整体偏大一截 | sf(k) 少减了 1 | sf(k - 1, M, n, N) |
| BH 结果不单调 | 漏了"取累计最小值"这一步 | np.minimum.accumulate(x[::-1])[::-1] |
| BH 结果顺序全乱了 | 忘了按原顺序放回 | out[order] = adjusted |
| 背景变成 19211,p 值偏小 | 把没有注释的基因也算进了背景 | 背景先和所有基因集的并集取交集 |
| GSEA 报 "duplicate index" | 同名基因没有去重 | 保留 \ |
| GSEA 结果排序怪怪的 | NES 是字符串,按字母顺序排了 | 先 astype(float) |
| 每次运行 GSEA 结果都略有不同 | 置换检验是随机的 | 固定 seed |
卡住超过 20 分钟再看。对照着看自己是哪一步想岔了 —— 抄一遍没用。
"""实验 3 · 富集分析(参考解)
输入:实验 2 的差异表达结果(airway,地塞米松 vs 未处理)。
问题:上调、下调的那几百个基因,集中在哪些生物学通路里?
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
from scipy.stats import hypergeom
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[2]
sys.path.insert(0, str(ROOT / "tools"))
import datasets # noqa: E402
import de_cache # noqa: E402
# ---------------------------------------------------------------- 任务 1:读基因集
def read_gmt(path) -> dict[str, set[str]]:
"""GMT:每行 = 基因集名 \\t 描述/链接 \\t 基因1 \\t 基因2 …"""
sets: dict[str, set[str]] = {}
with open(path, encoding="utf-8") as f:
for line in f:
parts = line.rstrip("\r\n").split("\t")
if len(parts) < 3:
continue
sets[parts[0]] = {g for g in parts[2:] if g}
return sets
# ---------------------------------------------------------------- 任务 2:多重检验校正
def bh_adjust(pvalues) -> np.ndarray:
"""Benjamini–Hochberg:p_(i) × m / i,再从大到小取累计最小值,封顶 1;按原顺序返回。"""
p = np.asarray(pvalues, dtype=float)
m = len(p)
if m == 0:
return p
order = np.argsort(p)
ranked = p[order] * m / np.arange(1, m + 1)
ranked = np.minimum.accumulate(ranked[::-1])[::-1]
out = np.empty(m)
out[order] = np.minimum(ranked, 1.0)
return out
# ---------------------------------------------------------------- 任务 3:超几何检验
def hypergeom_pvalue(k: int, M: int, n: int, N: int) -> float:
"""背景 M 个基因,其中 n 个属于这个通路;随机抽 N 个,抽中 ≥ k 个通路基因的概率。"""
return float(hypergeom.sf(k - 1, M, n, N))
# ---------------------------------------------------------------- 任务 4:ORA
def ora(genes, universe, gene_sets: dict[str, set[str]], min_size: int = 15, max_size: int = 500) -> pd.DataFrame:
"""过表达分析(over-representation analysis)。
背景 = universe(检测过的基因)∩ 至少出现在一个基因集里的基因——
和 clusterProfiler 的默认做法一致:没有任何注释的基因不可能"命中",不该稀释背景。
"""
annotated = set().union(*gene_sets.values())
bg = set(universe) & annotated
query = set(genes) & bg
M, N = len(bg), len(query)
rows = []
for term, members in gene_sets.items():
s = members & bg
n = len(s)
if n < min_size or n > max_size:
continue
hits = sorted(query & s)
k = len(hits)
expected = N * n / M if M else 0.0
rows.append({
"term": term, "overlap": k, "set_size": n, "query_size": N, "universe_size": M,
"expected": expected, "fold_enrichment": k / expected if expected else np.nan,
"pvalue": hypergeom_pvalue(k, M, n, N), "genes": ",".join(hits),
})
table = pd.DataFrame(rows)
if table.empty:
return table
table["padj"] = bh_adjust(table["pvalue"])
return table.sort_values(["pvalue", "term"]).reset_index(drop=True)
# ---------------------------------------------------------------- 任务 5:GSEA(排序式)
def rank_metric(results: pd.DataFrame) -> pd.Series:
"""用 DESeq2 的 Wald 统计量(stat)给所有检测过的基因排序:正=处理后升高,负=降低。
去掉 stat 为 NaN、没有基因名的;同名基因保留 |stat| 最大的一个;从大到小排。"""
df = results.loc[results["stat"].notna() & (results["gene_name"] != ""), ["gene_name", "stat"]]
df = df.assign(abs_stat=df["stat"].abs()).sort_values("abs_stat", ascending=False)
df = df.drop_duplicates("gene_name")
return df.set_index("gene_name")["stat"].sort_values(ascending=False)
def run_gsea(ranking: pd.Series, gene_sets: dict[str, set[str]], permutations: int = 1000, seed: int = 7) -> pd.DataFrame:
import gseapy
pre = gseapy.prerank(
rnk=ranking, gene_sets={k: sorted(v) for k, v in gene_sets.items()},
permutation_num=permutations, min_size=15, max_size=500, seed=seed,
threads=1, outdir=None, verbose=False,
)
res = pre.res2d.copy()
res["NES"] = res["NES"].astype(float)
res["FDR q-val"] = res["FDR q-val"].astype(float)
return res.sort_values("NES", ascending=False).reset_index(drop=True)
# ---------------------------------------------------------------- 任务 6:出图与报告
def load_inputs():
res = de_cache.load()
gene_sets = read_gmt(datasets.path_of("hallmark_gmt"))
tested = res[res["padj"].notna() & (res["gene_name"] != "")]
universe = set(tested["gene_name"])
up = set(tested.loc[tested["class"] == "up", "gene_name"])
down = set(tested.loc[tested["class"] == "down", "gene_name"])
return res, gene_sets, universe, up, down
def main(out_dir) -> None:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
out = Path(out_dir)
out.mkdir(parents=True, exist_ok=True)
res, gene_sets, universe, up, down = load_inputs()
tables = {"up": ora(up, universe, gene_sets), "down": ora(down, universe, gene_sets)}
for direction, t in tables.items():
t.to_csv(out / f"ora_{direction}.csv", index=False, encoding="utf-8-sig")
# 点图:两个方向各取前 10
fig, axes = plt.subplots(1, 2, figsize=(12, 5), sharex=False)
for ax, (direction, t) in zip(axes, tables.items()):
top = t.head(10).iloc[::-1]
sc = ax.scatter(top["fold_enrichment"], top["term"].str.replace("HALLMARK_", ""),
s=top["overlap"] * 8, c=-np.log10(top["padj"]), cmap="viridis")
ax.set(xlabel="fold enrichment", title=f"ORA · {direction}-regulated (Hallmark)")
fig.colorbar(sc, ax=ax, label="-log10 padj")
fig.tight_layout()
fig.savefig(out / "ora_dotplot.png", dpi=150)
plt.close(fig)
# GSEA
ranking = rank_metric(res)
gsea = run_gsea(ranking, gene_sets)
gsea.to_csv(out / "gsea.csv", index=False, encoding="utf-8-sig")
fig, ax = plt.subplots(figsize=(7, 8))
g = gsea.sort_values("NES")
colors = np.where(g["FDR q-val"] < 0.05, np.where(g["NES"] > 0, "#d62728", "#1f77b4"), "#bbbbbb")
ax.barh(g["Term"].str.replace("HALLMARK_", ""), g["NES"], color=colors)
ax.tick_params(axis="y", labelsize=6)
ax.set(xlabel="NES (dex vs untreated)", title="GSEA preranked · Hallmark (colored: FDR < 0.05)")
fig.tight_layout()
fig.savefig(out / "gsea_nes.png", dpi=150)
plt.close(fig)
lines = ["# 实验 3 结果摘要(airway · Hallmark)\n",
f"- 背景(检测过且有 Hallmark 注释的基因):{tables['up']['universe_size'].iloc[0]} 个",
f"- 上调基因 {len(up)} 个,其中有注释的 {tables['up']['query_size'].iloc[0]} 个;"
f"下调 {len(down)} 个,有注释的 {tables['down']['query_size'].iloc[0]} 个\n"]
for direction, t in tables.items():
lines += [f"## ORA · {direction}\n", "| term | overlap | set | fold | padj |", "|---|---:|---:|---:|---:|"]
lines += [f"| {r.term} | {r.overlap} | {r.set_size} | {r.fold_enrichment:.2f} | {r.padj:.2e} |"
for r in t.head(8).itertuples()]
lines.append("")
sig = gsea[gsea["FDR q-val"] < 0.05]
lines += ["## GSEA(FDR < 0.05)\n", "| term | NES | FDR |", "|---|---:|---:|"]
lines += [f"| {t} | {n:.2f} | {q:.3f} |" for t, n, q in zip(sig["Term"], sig["NES"], sig["FDR q-val"])]
(out / "结果摘要.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
print("\n".join(lines))