本章目标:用 Python 做描述统计、常见假设检验、生存分析与临床常用图形。 注意:本章不追求"覆盖所有统计方法",而是建立"知道有什么、知道去哪查"的能力, 并强调跨软件结果一致性这一临床特有要求。
14.1 从 PROC 到库:该用什么工具
| 任务 | SAS | Python | 备注 |
|---|---|---|---|
| 描述统计 | PROC MEANS / UNIVARIATE |
pandas + scipy.stats |
pandas 覆盖 80% |
| 频数 / 交叉表 | PROC FREQ(含 Fisher) |
pandas.crosstab + scipy.stats.fisher_exact |
|
| t 检验 | PROC TTEST |
scipy.stats.ttest_ind / ttest_rel |
|
| 方差分析 | PROC ANOVA / GLM |
statsmodels / pingouin |
|
| 非参数检验 | PROC NPAR1WAY |
scipy.stats.mannwhitneyu 等 |
|
| 线性/逻辑回归 | PROC REG / LOGISTIC |
statsmodels / sklearn |
statsmodels 输出更接近 SAS |
| 混合模型 / MMRM | PROC MIXED |
statsmodels.MixedLM / pymer4 |
⚠️ 需仔细核对 |
| 生存分析 | PROC LIFETEST / PHREG |
lifelines |
见 14.4 |
| 重复测量 | PROC MIXED |
statsmodels |
|
| 样本量/检验效能 | PROC POWER |
statsmodels.stats.power |
pip install scipy statsmodels lifelines pingouin
📌 你的已有资产:你在
jinbeiwang/mmrm-guide和lmm-notes里 已经研究过 MMRM / LMM。Python 侧的对应实现是statsmodels.MixedLM(通用 LMM)与pymer4(更接近 SAS 的PROC MIXED语法)。 跨语言实现一致性可参考 PHUSE 的 CAMIS 项目 (https://psiaims.github.io/CAMIS/)——它专门比对 SAS/R/Python 的同一方法实现。
14.2 描述统计与假设检验
import numpy as np
import pandas as pd
from scipy import stats
adsl = pd.read_csv("data/samples/adsl.csv")
# ---- 描述统计(对应 PROC MEANS)----
age = adsl["AGE"].dropna()
print(f"N={len(age)} Mean={age.mean():.2f} SD={age.std(ddof=1):.2f} "
f"Median={age.median():.1f} Min={age.min():.0f} Max={age.max():.0f}")
# ---- 分组描述统计(对应 PROC MEANS + CLASS)----
summary = adsl.groupby("TRT01P")["AGE"].agg(
N="count", Mean="mean", SD=lambda s: s.std(ddof=1),
Median="median", Min="min", Max="max",
).round(2)
# ---- 正态性检验(对应 PROC UNIVARIATE 的 NORMAL 选项)----
# Shapiro-Wilk(SAS 里是 W 检验)
stat, p = stats.shapiro(adsl["AGE"].dropna())
print(f"Shapiro-Wilk W={stat:.4f}, p={p:.4f} "
f"-> {'不符合正态分布' if p < 0.05 else '未发现偏离正态'}")
# ---- 两独立样本 t 检验(对应 PROC TTEST)----
g1 = adsl.loc[adsl["TRT01P"] == "Placebo", "AGE"].dropna()
g2 = adsl.loc[adsl["TRT01P"] == "Xanomeline High Dose", "AGE"].dropna()
# SAS 默认输出的是"合并方差 t",R/Python 默认 Welch t —— 必须显式指定!
t_eq, p_eq = stats.ttest_ind(g1, g2, equal_var=True) # ≈ SAS 默认
t_w, p_w = stats.ttest_ind(g1, g2, equal_var=False) # Welch
print(f"合并方差 t={t_eq:.4f}, p={p_eq:.4f}")
print(f"Welch t={t_w:.4f}, p={p_w:.4f}")
# ---- 非参数检验(对应 PROC NPAR1WAY WILCOXON)----
u, p = stats.mannwhitneyu(g1, g2, alternative="two-sided")
print(f"Mann-Whitney U={u:.1f}, p={p:.4f}")
# ---- 卡方 / Fisher(对应 PROC FREQ 的 CHISQ / EXACT)----
ct = pd.crosstab(adsl["TRT01P"], adsl["SEX"])
chi2, p, dof, expected = stats.chi2_contingency(ct)
print(f"Chi-square={chi2:.4f}, df={dof}, p={p:.4f}")
# 2x2 表用 Fisher 精确检验(对应 / exact 选项)
ct2 = pd.crosstab(adsl["SEX"], adsl["AGEGR1"] == ">80")
odds, p_fisher = stats.fisher_exact(ct2)
print(f"Fisher exact p={p_fisher:.4f}, OR={odds:.4f}")
⚠️ 跨软件差异的三个经典陷阱(PHUSE CAMIS 专门在研究这些): 1. t 检验的方差假设:SAS
PROC TTEST默认合并方差; R 的t.test和scipy默认 Welch。不指定就会得到不同结果。 2. 列联表的卡方:SAS 默认连续性校正(Yates),scipy 默认不校正 (correction=True才是校正版)。 3. 分位数算法:SAS 与 numpy 的percentile在插值方式上有默认差异 (numpy 有method="linear"/"lower"/"higher"等多种,SAS 用其中一种)。临床编程的应对:这些差异必须在 ADRG / ADRG-like 文档里 明确定义并说明理由,不能"默认"。
14.3 回归分析:statsmodels ≈ SAS 输出
临床场景常用的是"输出一张系数表",statsmodels 的 output 最接近 SAS。
import statsmodels.api as sm
import statsmodels.formula.api as smf
adsl = pd.read_csv("data/samples/adsl.csv")
# ---- 线性回归(对应 PROC REG / PROC GLM)----
model = smf.ols("AGE ~ C(TRT01P) + C(SEX) + BMIBL", data=adsl).fit()
print(model.summary())
# ---- 逻辑回归(对应 PROC LOGISTIC)----
# 例:预测"是否出现严重不良事件"
adae = pd.read_csv("data/samples/adae.csv")
adsl["HAS_SAE"] = adsl["USUBJID"].isin(
adae.loc[adae["AESER"] == "Y", "USUBJID"]
).astype(int)
logit = smf.logit("HAS_SAE ~ AGE + C(SEX) + C(TRT01P)", data=adsl).fit(disp=0)
# ---- 输出 OR 与 95% CI(这是 CSR 里真正要的东西)----
import numpy as np
res = pd.DataFrame({
"coef": logit.params,
"se": logit.bse,
"p": logit.pvalues,
})
res["OR"] = np.exp(res["coef"])
res["CI_low"] = np.exp(res["coef"] - 1.96 * res["se"])
res["CI_high"] = np.exp(res["coef"] + 1.96 * res["se"])
print(res.round(4).to_string())
📌 两个必须知道的差异: 1. 置信区间:SAS 的
PROC LOGISTIC默认用 Wald CI; R 的glm默认 profile likelihood CI。statsmodels用 Wald,与 SAS 一致。 2. 参考水平:SAS 默认用字母序最大的类别作参考 (或REF=指定);pandas 的C()默认也是最后一个。 但显式指定永远更安全:C(TRT01P, Treatment(reference='Placebo'))
14.4 生存分析:lifelines
ADTTE 数据集就是为这个准备的。
# pip install lifelines
import pandas as pd
from lifelines import KaplanMeierFitter, CoxPHFitter
from lifelines.statistics import logrank_test
adtte = pd.read_csv("data/samples/adtte.csv")
print(adtte["PARAMCD"].value_counts())
print(f"事件数: {(adtte['CNSR'] == 0).sum()}, 删失数: {(adtte['CNSR'] == 1).sum()}")
# 只分析 OS(总生存)
os_df = adtte.query("PARAMCD == 'OS'")
# ---- Kaplan-Meier(对应 PROC LIFETEST)----
kmf = KaplanMeierFitter()
for trt in ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]:
sub = os_df[os_df["TRT01P"] == trt]
kmf.fit(sub["AVAL"] / 30.44, # 天 → 月
event_observed=1 - sub["CNSR"], # ★ CNSR=1 是删失,所以事件 = 1-CNSR
label=trt)
print(f"{trt:24s} 中位生存 = {kmf.median_survival_time_:.1f} 月")
# ---- log-rank 检验(对应 PROC LIFETEST 的 STRATA)----
a = os_df.query("TRT01P == 'Placebo'")
b = os_df.query("TRT01P == 'Xanomeline High Dose'")
lr = logrank_test(a["AVAL"], b["AVAL"],
event_observed_A=1 - a["CNSR"],
event_observed_B=1 - b["CNSR"])
print(f"log-rank p = {lr.p_value:.4f}")
# ---- Cox 比例风险(对应 PROC PHREG)----
cox_df = os_df[["AVAL", "CNSR", "AGE", "SEX", "TRT01P"]].copy()
cox_df["EVENT"] = 1 - cox_df["CNSR"] # ★ 关键:CNSR 语义转换
cox_df = pd.get_dummies(cox_df, columns=["TRT01P"], drop_first=True)
cph = CoxPHFitter()
cph.fit(cox_df[["AVAL", "EVENT", "AGE", "TRT01P_Xanomeline High Dose"]],
duration_col="AVAL", event_col="EVENT")
cph.print_summary()
🔥
CNSR的语义转换是生存分析最容易出错的地方: ADaM 里CNSR=1表示删失(censored),CNSR=0表示发生事件。 而lifelines的参数是event_observed(True = 发生事件)。 所以必须是event_observed = 1 - CNSR。如果忘了取反,你会得到一个"完全颠倒"的结果—— 而且它看起来完全正常(有中位生存时间、有 p 值),不会报错。 这类"静默错误"是临床编程中最危险的。
防范方法:拟合后打印事件数,与数据核对: ```python print(f"传给模型的事件数: {cox_df['EVENT'].sum()}," f"数据中的实际事件数: {(os_df['CNSR'] == 0).sum()}")
两个数字必须相等
```
生存曲线(KM Plot)
import matplotlib.pyplot as plt
# 中文字体(Windows)
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
fig, ax = plt.subplots(figsize=(8, 5.5))
for trt, color in zip(["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"],
["#2E5C8A", "#C0392B", "#E67E22"]):
sub = os_df[os_df["TRT01P"] == trt]
KaplanMeierFitter().fit(sub["AVAL"] / 30.44,
event_observed=1 - sub["CNSR"],
label=f"{trt} (n={len(sub)})").plot_survival_function(
ax=ax, ci_show=False, color=color, linewidth=2)
ax.set_xlabel("时间(月)")
ax.set_ylabel("生存概率")
ax.set_title("图 1. 总生存期 Kaplan-Meier 曲线(安全性人群)")
ax.grid(alpha=0.3, linestyle="--")
ax.legend(loc="upper right", frameon=False)
fig.tight_layout()
fig.savefig("outputs/figure01_km_os.png", dpi=150)
14.5 临床常用图形的 Python 实现
图形对照表
| CSR 图形 | SAS | Python |
|---|---|---|
| KM 生存曲线 | PROC LIFETEST + SGPLOT |
lifelines + matplotlib |
| 森林图 | SGPLOT / 自绘 |
matplotlib errorbar / forestplot |
| 瀑布图(肿瘤) | SGPLOT highlow |
matplotlib barh |
| 泳道图 / Swimmer plot | SGPLOT |
matplotlib barh |
| 箱线图 | SGPLOT vbox |
seaborn.boxplot |
| 均值变化图(Mean±SE) | SGPLOT series |
matplotlib errorbar |
| 移位表热图 | PROC REPORT + heatmap |
seaborn.heatmap |
| AE 频率条形图 | SGPLOT hbar |
matplotlib barh |
例:均值随访视变化的图(Mean ± SE,MMRM 类分析的标配图)
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
vs = pd.read_csv("data/samples/vs_bp.csv")
sysbp = vs.query("VSTESTCD == 'SYSBP'").copy()
sysbp["VSSTRESN"] = pd.to_numeric(sysbp["VSSTRESN"], errors="coerce")
sysbp["VISITNUM"] = pd.to_numeric(sysbp["VISITNUM"], errors="coerce")
agg = (sysbp.groupby(["TRT_UNKNOWN", "VISITNUM"]) if False else
sysbp.groupby(["VISITNUM"])["VSSTRESN"]
.agg(mean="mean", sd=lambda s: s.std(ddof=1), n="count").reset_index())
agg["se"] = agg["sd"] / np.sqrt(agg["n"])
fig, ax = plt.subplots(figsize=(9, 5))
ax.errorbar(agg["VISITNUM"], agg["mean"], yerr=1.96 * agg["se"],
marker="o", capsize=4, linewidth=1.8, color="#2E5C8A")
ax.set_xlabel("访视")
ax.set_ylabel("收缩压 (mmHg),Mean ± 95% CI")
ax.set_title("图 2. 收缩压随访视的变化")
ax.grid(alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig("outputs/figure02_sysbp_trend.png", dpi=150)
例:AE 发生频率的水平条形图
top_soc = (adae.drop_duplicates(["USUBJID", "AEBODSYS"])
.groupby("AEBODSYS")["USUBJID"].nunique()
.sort_values(ascending=True).tail(10))
fig, ax = plt.subplots(figsize=(9, 5.5))
ax.barh([s.title()[:42] for s in top_soc.index], top_soc.values, color="#C0392B", alpha=0.85)
ax.set_xlabel("受试者数")
ax.set_title("图 3. 治疗中出现的不良事件(Top 10 SOC)")
for i, v in enumerate(top_soc.values):
ax.text(v + 0.3, i, str(v), va="center", fontsize=9)
ax.grid(axis="x", alpha=0.3, linestyle="--")
fig.tight_layout()
fig.savefig("outputs/figure03_ae_soc.png", dpi=150)
💡 Python 绘图相对 SAS 的真实优势: - 版式控制精细(
tight_layout、GridSpec多面板) - 主题统一容易(定义一次rcParams) - 交互式输出(plotly可生成可缩放 HTML,给 DMC 会议用极方便) - 批量出图简单(一个for循环出 50 张图,SAS 要写宏)
14.6 移位表(Shift Table)—— 实验室/Vitals 的标准输出
移位表用于展示"基线 → 基线后"的状态迁移(如 Lab 的 LOW/NORMAL/HIGH)。
"""case04_实验室移位表.py —— 生命体征移位表(同一代码可用于 ADLBC)"""
import numpy as np
import pandas as pd
vs = pd.read_csv("data/samples/vs_bp.csv")
vs["VSSTRESN"] = pd.to_numeric(vs["VSSTRESN"], errors="coerce")
vs["VISITNUM"] = pd.to_numeric(vs["VISITNUM"], errors="coerce")
# 只分析收缩压
bp = vs.query("VSTESTCD == 'SYSBP'").copy()
# ---- Step 1: 按临床惯例定义分级(对应 SAS 自定义 format)----
def grade(x):
"""SYSBP 分级:<90 低 / 90-140 正常 / >140 高(示例阈值,实际以 SAP 为准)"""
if pd.isna(x):
return np.nan
if x < 90:
return "LOW"
if x <= 140:
return "NORMAL"
return "HIGH"
bp["GRADE"] = bp["VSSTRESN"].apply(grade)
GRADE_ORDER = ["LOW", "NORMAL", "HIGH"]
# ---- Step 2: 确定基线与基线后 ----
# 数据集自带 VSBLFL='Y' 标记基线(优先用它)
baseline = bp.loc[bp["VSBLFL"] == "Y", ["USUBJID", "GRADE"]].rename(
columns={"GRADE": "GRADE_BL"})
post = bp.loc[bp["VISITNUM"] > 1].copy()
post = post.merge(baseline, on="USUBJID", how="inner")
# ---- Step 3: 交叉制表(基线 × 基线后),按受试者去重 ----
# 每位受试者取最后一次基线后访视(惯例之一:END OF TREATMENT)
last_visit = post.groupby("USUBJID")["VISITNUM"].transform("max")
eot = post[post["VISITNUM"] == last_visit].drop_duplicates("USUBJID")
shift = pd.crosstab(
pd.Categorical(eot["GRADE_BL"], categories=GRADE_ORDER),
pd.Categorical(eot["GRADE"], categories=GRADE_ORDER),
dropna=False,
)
# ---- Step 4: 每个基线行内算百分比(行百分比)----
row_tot = shift.sum(axis=1)
shift_pct = (shift.div(row_tot.replace(0, np.nan), axis=0) * 100).round(1)
# ---- Step 5: 组装成 "n (%)" 格式 ----
out = pd.DataFrame(index=shift.index)
for g in GRADE_ORDER:
if g in shift.columns:
out[g] = [f"{int(shift.loc[i, g])} ({shift_pct.loc[i, g]:.1f}%)"
if row_tot[i] else "-" for i in shift.index]
out.index.name = "基线"
print("表 3. 收缩压分级移位表(基线 vs 治疗结束)")
print(out.to_string())
🧠 移位表最容易搞错的三点: 1. 百分比的分母:是"该基线分类的行合计"还是"该治疗组总人数"? 两者都有,取决于 SAP,且必须写清楚。 2. 用哪一次"基线后"访视:末次访视?END OF TREATMENT?固定访视? 3. 同一受试者同一访视有多条记录(重复测量)→ 必须先定规则(取均值?取最后一次?)
14.7 动手练习
-
描述统计:对 ADSL 的 AGE / BMIBL 分别按治疗组计算 N / Mean / SD / Median / Min / Max,输出成表。
-
检验差异:用 t 检验比较 Placebo 与 Xanomeline High Dose 的基线 BMI, 分别报告合并方差与 Welch 的结果,说明差异。
-
回归:用逻辑回归分析
AESER='Y'(严重 AE)与 AGE、SEX、治疗组的关系, 输出 OR 与 95% CI(注意 SAE 只有 3 例,样本量不足——这本身就是个教学点: 何时应该放弃建模)。 -
生存分析:用 ADTTE 的
PARAMCD='OS'分别画出三个治疗组的 KM 曲线, 做 log-rank 检验。务必验证事件数是否与数据一致。 -
移位表:把 14.6 节的代码改成"按治疗组分别输出三张移位表"。
-
(进阶) 把 14.6 节的代码改写成适用于实验室数据的版本 (提示:需要先下载 ADLBC,按
PARAMCD分组, 用ANRLO/ANRHI参考范围来定义 LOW/NORMAL/HIGH)。
上一章 ← 第 13 章 · ADaM 衍生与 TFL 报表生成 下一章 → 第 15 章 · AI 辅助编程与 SAS→Python 迁移