随附材料 · 实验代码
fetch_corpus.py
#!/usr/bin/env python3
"""从 PubMed 公开 E-utilities 取结直肠癌腹膜转移文献的 MeSH 标注。"""
import json, time, urllib.parse, urllib.request, pathlib
KEY = pathlib.Path.home().joinpath(".config/ncbi/api_key").read_text().strip()
EU = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
TERM = ('("colorectal neoplasms"[MeSH] OR colorectal cancer[Title/Abstract]) AND '
'("peritoneal neoplasms"[MeSH] OR peritoneal metastas*[Title/Abstract] OR '
'peritoneal carcinomatosis[Title/Abstract]) AND 2015:2026[dp]')
def get(path, **kw):
kw["api_key"] = KEY
with urllib.request.urlopen(f"{EU}/{path}?{urllib.parse.urlencode(kw)}", timeout=180) as fh:
return fh.read()
ids = json.loads(get("esearch.fcgi", db="pubmed", term=TERM, retmax=10000,
retmode="json"))["esearchresult"]["idlist"]
print(f" 检索命中 {len(ids)} 篇")
recs = []
for i in range(0, len(ids), 200):
xml = get("efetch.fcgi", db="pubmed", id=",".join(ids[i:i + 200]), retmode="xml").decode("utf-8", "ignore")
for art in xml.split("<PubmedArticle>")[1:]:
yr = None
for tag in ("<Year>", "<MedlineDate>"):
if tag in art:
seg = art.split(tag, 1)[1][:12]
digits = "".join(ch for ch in seg if ch.isdigit())[:4]
if len(digits) == 4:
yr = int(digits); break
mesh = []
for chunk in art.split("<DescriptorName")[1:]:
if ">" in chunk:
mesh.append(chunk.split(">", 1)[1].split("<")[0].strip())
if yr and mesh:
recs.append({"year": yr, "mesh": sorted(set(mesh))})
print(f" 已解析 {len(recs)} 篇", end="\r")
time.sleep(0.15)
json.dump({"term": TERM, "n": len(recs), "records": recs}, open("corpus.json", "w"), ensure_ascii=False)
print(f"\n 有年份且有 MeSH 的: {len(recs)} 篇")
experiment.py
#!/usr/bin/env python3
"""结直肠癌腹膜转移文献:2015–2019 vs 2020–2026 的主题词占比变化。
判定规则写在 ../claims.json,跑之前定死。零模型是年份标签随机打乱 1000 次。
"""
import json, collections
import numpy as np
from scipy.stats import fisher_exact
N_PERM, MIN_DOCS, SEED = 1000, 20, 20260827
def bh(p):
p = np.asarray(p, float); n = len(p); o = np.argsort(p); adj = np.empty(n); prev = 1.0
for r, i in enumerate(o[::-1]):
prev = min(prev, p[i] * n / (n - r)); adj[i] = prev
return adj
def screen(recs, labels, terms):
"""labels: True=后期。返回每个词的 (p, 前期占比, 后期占比)。"""
n_late = int(labels.sum()); n_early = len(labels) - n_late
out = []
for t in terms:
has = np.array([t in r["mesh"] for r in recs])
a = int((has & labels).sum()) # 后期有
b = int((has & ~labels).sum()) # 前期有
_, p = fisher_exact([[a, n_late - a], [b, n_early - b]])
out.append((t, float(p), b / n_early, a / n_late))
return out
def main():
d = json.load(open("corpus.json"))
recs = [r for r in d["records"] if 2015 <= r["year"] <= 2026]
labels = np.array([r["year"] >= 2020 for r in recs])
print(f"[data] 文献 {len(recs)} 篇|2015–2019 {int((~labels).sum())} 篇|2020–2026 {int(labels.sum())} 篇")
cnt = collections.Counter(m for r in recs for m in r["mesh"])
terms = [t for t, c in cnt.items() if c >= MIN_DOCS]
print(f"[data] 至少出现在 {MIN_DOCS} 篇里的 MeSH 词: {len(terms)} 个")
obs = screen(recs, labels, terms)
q = bh([x[1] for x in obs])
sig = [(t, p, e, l, qq) for (t, p, e, l), qq in zip(obs, q) if qq < 0.05]
sig.sort(key=lambda x: -(x[3] - x[2]))
print(f"[实测] FDR<0.05 的主题词: {len(sig)} 个")
rng = np.random.default_rng(SEED)
null = []
for _ in range(N_PERM):
lab = labels.copy(); rng.shuffle(lab)
pp = [x[1] for x in screen(recs, lab, terms)]
null.append(int((bh(pp) < 0.05).sum()))
null = np.array(null)
p95 = float(np.percentile(null, 95))
h1 = bool(len(sig) > p95 and len(sig) >= 5)
print(f"[零模型] {N_PERM} 次打乱:均值 {null.mean():.2f}|95分位 {p95:.1f}|最大 {null.max()}")
print(f"[H1] {len(sig)} > {p95:.1f} 且 >=5 → {'✅成立' if h1 else '❌被推翻'}")
rising = [s for s in sig if s[3] > s[2]][:12]
falling = sorted([s for s in sig if s[3] < s[2]], key=lambda x: x[3] - x[2])[:12]
print("\n 升幅最大:")
for t, p, e, l, qq in rising[:6]:
print(f" {t[:44]:<46} {e*100:5.1f}% → {l*100:5.1f}% (FDR {qq:.1e})")
print(" 降幅最大:")
for t, p, e, l, qq in falling[:6]:
print(f" {t[:44]:<46} {e*100:5.1f}% → {l*100:5.1f}% (FDR {qq:.1e})")
HOT = ("Immunotherapy", "Single-Cell", "Molecular Targeted", "Immune Checkpoint",
"Antineoplastic Combined", "Tumor Microenvironment", "Biomarkers")
hot_hits = [s[0] for s in rising if any(h.lower() in s[0].lower() for h in HOT)]
h2 = len(hot_hits) > 0
print(f"\n[H2] 升幅词里含免疫/单细胞/靶向类新兴主题 → {'✅成立' if h2 else '❌被推翻'}|命中 {hot_hits}")
json.dump({"seed": SEED, "n_docs": len(recs), "n_early": int((~labels).sum()),
"n_late": int(labels.sum()), "n_terms": len(terms), "min_docs": MIN_DOCS,
"n_significant": len(sig), "null_mean": float(null.mean()), "null_p95": p95,
"null_max": int(null.max()),
"empirical_p": float((null >= len(sig)).mean()),
"H1_passes": h1, "H2_passes": h2, "hot_hits": hot_hits,
"rising": [{"term": t, "early": e, "late": l, "fdr": qq} for t, p, e, l, qq in rising],
"falling": [{"term": t, "early": e, "late": l, "fdr": qq} for t, p, e, l, qq in falling],
"null_distribution": null.tolist()},
open("results.json", "w"), ensure_ascii=False, indent=1)
if __name__ == "__main__":
main()
adversarial_check.py
#!/usr/bin/env python3
"""敌意复核:这些"变化"里有多少是 MeSH 词表版本与索引完整度造成的假象?"""
import json, collections
import numpy as np
d = json.load(open("corpus.json")); recs = d["records"]
print("== 质疑 1:每篇的 MeSH 标注数逐年变了吗(索引完整度)==")
by = collections.defaultdict(list)
for r in recs:
by[r["year"]].append(len(r["mesh"]))
for y in sorted(by):
print(f" {y} 文献 {len(by[y]):>4} 篇 平均 MeSH 词数 {np.mean(by[y]):5.1f}")
early = [n for y, v in by.items() if y < 2020 for n in v]
late = [n for y, v in by.items() if y >= 2020 for n in v]
print(f" → 前期平均 {np.mean(early):.1f} 词/篇,后期 {np.mean(late):.1f} 词/篇"
f"({'⚠️ 后期标注更少,会系统性压低所有词的占比' if np.mean(late) < np.mean(early) else '差异不大'})")
print("\n== 质疑 2:HIPEC 从 0.0% 跳到 29.1%,是新概念还是新词条 ==")
first = {}
for r in sorted(recs, key=lambda x: x["year"]):
for m in r["mesh"]:
first.setdefault(m, r["year"])
res = json.load(open("results.json"))
for row in res["rising"][:8]:
t = row["term"]
print(f" {t[:46]:<48} 语料里首次出现于 {first.get(t)} 年")
print(" → 首次出现年份 >=2020 的词,极可能是 MeSH 当年新增词条,而不是研究方向新出现。")
print("\n== 质疑 3:把人口学检索标签剔掉,还剩多少真变化 ==")
CHECK_TAGS = {"Male", "Female", "Humans", "Adult", "Aged", "Middle Aged", "Young Adult",
"Adolescent", "Aged, 80 and over", "Child", "Animals", "Mice",
"Retrospective Studies", "Prospective Studies", "Treatment Outcome",
"Survival Rate", "Follow-Up Studies"}
sig_terms = [r["term"] for r in res["rising"]] + [r["term"] for r in res["falling"]]
real = [t for t in sig_terms if t not in CHECK_TAGS]
print(f" 显著词(示例集 {len(sig_terms)} 个)里,剔掉检索标签后剩 {len(real)} 个内容词")
print(f" 被剔掉的: {sorted(set(sig_terms) & CHECK_TAGS)}")
print("\n== 质疑 4:只看 2020 年后就存在的词,变化还在吗 ==")
stable = [t for t in real if first.get(t, 9999) < 2018]
print(f" 语料里 2018 年前就出现过的内容词: {len(stable)} 个 → {stable[:8]}")
print("\n== 质疑 5:零模型均值 0.04 是不是太干净了 ==")
null = np.array(res["null_distribution"])
print(f" 1000 次打乱:{int((null==0).sum())} 次给 0,最大 {null.max()}")
print(" → 零模型这么干净,说明 Fisher+BH 在这个规模下不会随便造出显著词;")
print(" 所以 55 个显著词是真的统计信号 —— 但信号来源可能是索引而非研究内容,见质疑 1–3。")
json.dump({"early_mesh_per_doc": float(np.mean(early)), "late_mesh_per_doc": float(np.mean(late)),
"first_year": {t: first.get(t) for t in [r["term"] for r in res["rising"][:8]]},
"content_terms_after_filter": len(real), "stable_content_terms": len(stable)},
open("adversarial.json", "w"), ensure_ascii=False, indent=1)
corrected.py
#!/usr/bin/env python3
"""校正版:扣掉 MeSH 词表版本与索引完整度两个混杂,再看还剩多少真变化。
两处校正:
1) 只保留 2018 年前就在语料里出现过的词 —— 排除 MeSH 新增词条
2) 用「该词占本篇 MeSH 总数的份额」而不是「出现率」—— 抵消索引完整度漂移
另加一条对照:把人口学检索标签单独拎出来当"内部对照"。若校正有效,
这些与研究内容无关的标签在校正后应当不再显著。
"""
import json, collections
import numpy as np
from scipy.stats import mannwhitneyu
CHECK_TAGS = {"Male", "Female", "Humans", "Adult", "Aged", "Middle Aged", "Young Adult",
"Adolescent", "Aged, 80 and over", "Child", "Animals", "Retrospective Studies",
"Prospective Studies", "Treatment Outcome", "Survival Rate", "Follow-Up Studies"}
def bh(p):
p = np.asarray(p, float); n = len(p); o = np.argsort(p); adj = np.empty(n); prev = 1.0
for r, i in enumerate(o[::-1]):
prev = min(prev, p[i] * n / (n - r)); adj[i] = prev
return adj
recs = [r for r in json.load(open("corpus.json"))["records"] if 2015 <= r["year"] <= 2026]
first = {}
for r in sorted(recs, key=lambda x: x["year"]):
for m in r["mesh"]:
first.setdefault(m, r["year"])
cnt = collections.Counter(m for r in recs for m in r["mesh"])
terms = [t for t, c in cnt.items() if c >= 20 and first[t] < 2018]
print(f" 校正后参与检验的词: {len(terms)} 个(原 115 个,剔除 MeSH 新增词条)")
late = np.array([r["year"] >= 2020 for r in recs])
share = {t: np.array([(1.0 / len(r["mesh"])) if t in r["mesh"] else 0.0 for r in recs])
for t in terms}
ps = []
for t in terms:
a, b = share[t][late], share[t][~late]
ps.append(mannwhitneyu(a, b, alternative="two-sided")[1])
qs = bh(ps)
sig = [(t, float(q), float(share[t][~late].mean()), float(share[t][late].mean()))
for t, q in zip(terms, qs) if q < 0.05]
sig.sort(key=lambda x: -(x[3] - x[2]))
tag_sig = [s for s in sig if s[0] in CHECK_TAGS]
content_sig = [s for s in sig if s[0] not in CHECK_TAGS]
print(f" 校正后显著: {len(sig)} 个|其中内容词 {len(content_sig)}|检索标签 {len(tag_sig)}")
print(f" 内部对照:检索标签{'仍有 %d 个显著 ⚠️ 校正不彻底' % len(tag_sig) if tag_sig else '全部不再显著 ✅ 校正有效'}")
print("\n 校正后真正上升的内容词:")
for t, q, e, l in content_sig[:8]:
if l > e: print(f" {t[:44]:<46} 份额 {e:.4f} → {l:.4f} (FDR {q:.1e})")
print(" 校正后真正下降的内容词:")
for t, q, e, l in sorted(content_sig, key=lambda x: x[3] - x[2])[:6]:
if l < e: print(f" {t[:44]:<46} 份额 {e:.4f} → {l:.4f} (FDR {q:.1e})")
json.dump({"n_terms_after_filter": len(terms), "n_sig": len(sig),
"n_content_sig": len(content_sig), "n_tag_sig": len(tag_sig),
"correction_worked": len(tag_sig) == 0,
"rising": [{"term": t, "early_share": e, "late_share": l, "fdr": q}
for t, q, e, l in content_sig if l > e][:12],
"falling": [{"term": t, "early_share": e, "late_share": l, "fdr": q}
for t, q, e, l in sorted(content_sig, key=lambda x: x[3] - x[2]) if l < e][:12]},
open("corrected.json", "w"), ensure_ascii=False, indent=1)
plot_results.py
#!/usr/bin/env python3
import json
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np, collections
d=json.load(open("corpus.json"))["records"]
by=collections.defaultdict(list)
for r in d:
if 2015<=r["year"]<=2026: by[r["year"]].append(len(r["mesh"]))
ys=sorted(by); m=[np.mean(by[y]) for y in ys]
fig,ax=plt.subplots(1,2,figsize=(12,4.8))
ax[0].plot(ys,m,"o-",color="#c0392b"); ax[0].axvline(2019.5,ls="--",c="k",lw=1)
ax[0].text(2019.6,max(m)*0.97,"period split",fontsize=8)
ax[0].set_xlabel("year"); ax[0].set_ylabel("MeSH terms per article")
ax[0].set_title("Indexing completeness drifts:\nthis alone deflates every term's rate in the later period")
ax[0].grid(alpha=.3)
r=json.load(open("results.json")); null=np.array(r["null_distribution"])
ax[1].hist(null,bins=np.arange(0,max(null.max(),6)+2)-.5,color="#95a5a6",edgecolor="white")
ax[1].axvline(r["n_significant"],color="#c0392b",lw=2.5)
ax[1].text(r["n_significant"]*0.55,ax[1].get_ylim()[1]*.6,f"observed = {r['n_significant']}",color="#c0392b",fontsize=10)
ax[1].set_xlabel("significant MeSH terms"); ax[1].set_ylabel("permutations")
ax[1].set_title(f"Year-shuffle null (n={len(null)}): the statistics are sound —\nthe problem is the data, not the test")
plt.tight_layout(); plt.savefig("figs/indexing_drift.png",dpi=150); print("figs/indexing_drift.png 已生成")