4. 描述性统计表自动生成
临床研究报告中的“表1”通常是人口学与基线特征表,它按治疗组、剂量组或疾病亚型等分组变量,把年龄、性别、BMI、病程、合并症等变量的分布并列展示。手工在 Excel 里逐列算均值、数频数、算百分比,再复制粘贴对齐,既慢又容易在分组人数变化后忘记更新分母。本节的目标是把这张表变成一个可重复执行的函数:输入一份受试者级数据框和分组变量,输出一张可直接进入报告的分组统计表。
先明确三类变量的统计口径
在动手写代码前,必须先确定每个变量属于哪一类,因为口径决定了输出格式。连续变量(如年龄、收缩压、BMI)通常报告“均值 ± 标准差”或“中位数(四分位数间距)”,前者适合近似正态分布,后者适合偏态分布或存在离群值。分类变量(如性别、吸烟史、NYHA 分级)报告“频数(百分比)”,百分比的分母是该组内非缺失的受试者数,而不是全表总人数。还有一类是二分类的“是/否”变量,如“是否合并高血压”,它本质上是分类变量,但常被压缩成一行“n (%)”展示。
这里有一个容易被忽略的细节:缺失值。pandas 的 mean() 默认跳过 NaN,但 value_counts() 默认也不计入 NaN,因此频数之和可能小于组内人数。规范做法是在表底或脚注中报告各组“缺失例数”,或在计算百分比时明确分母。本节的示例统一采用“分母 = 该组该变量非缺失例数”,并在输出中保留缺失计数列,便于核查。
用 groupby 与 agg 一次算出连续变量
假设数据框 df 包含分组列 arm(取值如“试验组”“对照组”)以及连续变量 age、bmi、sbp。计算分组均值、标准差、中位数和四分位数,可以用一次 groupby().agg() 完成:
import pandas as pd
import numpy as np
def cont_summary(df, group_col, var):
g = df.groupby(group_col)[var]
out = g.agg(
n="count",
mean="mean",
std="std",
median="median",
q1=lambda s: s.quantile(0.25),
q3=lambda s: s.quantile(0.75),
missing=lambda s: s.isna().sum(),
)
out["mean_sd"] = out["mean"].round(1).astype(str) + " ± " + out["std"].round(1).astype(str)
out["median_iqr"] = (out["median"].round(1).astype(str)
+ " (" + out["q1"].round(1).astype(str)
+ ", " + out["q3"].round(1).astype(str) + ")")
return out
summary_age = cont_summary(df, "arm", "age")
print(summary_age[["n", "mean_sd", "median_iqr", "missing"]])
注意 std 默认使用样本标准差(ddof=1),这与临床统计中报告标准差的惯例一致;如果误用 np.std 的总体标准差,数值会略小。四分位数用 quantile(0.25) 与 quantile(0.75),pandas 默认采用线性插值,与多数统计软件一致。示例中若试验组 120 人、对照组 118 人,年龄均值可能输出为“56.3 ± 9.8”与“57.1 ± 10.2”,中位数则可能为“56.0 (49.0, 63.0)”这类形式。
分类变量的频数与百分比
分类变量需要按组内每个水平计数,再除以该组非缺失总数。可以写一个通用函数,返回“n (%)”字符串:
def cat_summary(df, group_col, var):
rows = []
for level, sub in df.groupby(group_col):
vc = sub[var].value_counts(dropna=True)
denom = vc.sum()
for cat, cnt in vc.items():
rows.append({
"group": level,
"category": cat,
"n": cnt,
"pct": round(cnt / denom * 100, 1),
"display": f"{cnt} ({cnt/denom*100:.1f}%)"
})
return pd.DataFrame(rows)
sex_tab = cat_summary(df, "arm", "sex")
print(sex_tab.pivot(index="category", columns="group", values="display"))
这里 value_counts() 默认按频数降序排列,做表时通常希望按临床逻辑排序(如“男/女”“I/II/III/IV”),可以在得到结果后用 reindex 指定顺序。百分比保留一位小数即可,过多小数位在报告中反而干扰阅读。若某组某水平为 0 例,应显示“0 (0.0%)”,而不是留空,避免读者误以为缺失。
把多变量拼成一张基线表
真实的人口学表包含十几个变量,需要把连续变量和分类变量的结果纵向拼接。推荐的做法是:为每个变量生成一个“变量名 + 统计量”的行,再统一 concat。下面给出一个可复用的骨架:
def build_baseline_table(df, group_col, cont_vars, cat_vars):
frames = []
for v in cont_vars:
s = cont_summary(df, group_col, v)
s = s[["n", "mean_sd", "median_iqr", "missing"]]
s.columns = pd.MultiIndex.from_product([[v], s.columns])
frames.append(s.T)
for v in cat_vars:
c = cat_summary(df, group_col, v)
piv = c.pivot(index="category", columns="group", values="display")
piv.index = pd.MultiIndex.from_product([[v], piv.index])
frames.append(piv)
return pd.concat(frames)
table1 = build_baseline_table(
df, "arm",
cont_vars=["age", "bmi", "sbp"],
cat_vars=["sex", "smoking", "nyha"]
)
print(table1)
输出后建议做两件事:一是把列顺序固定为“试验组、对照组”,避免 groupby 按字母序打乱;二是把缺失计数单独成列或放到脚注,不要混进“n (%)”里。若需要导出到 Word 或 Excel,可用 table1.to_excel("table1.xlsx"),再在模板中套用三线表格式。
结果核查与常见坑
生成表后不要直接交付,至少核对三点。第一,各组 n 是否与随机化人数一致,若某变量缺失较多,应在脚注说明“该变量缺失 x 例”。第二,分类变量各水平百分比之和是否接近 100%(四舍五入可能为 99.9% 或 100.1%,属正常)。第三,连续变量的均值与中位数若差距很大,说明分布偏态,报告中应优先采用中位数(IQR),而不是强行用均值 ± 标准差。
cont_summary 和 cat_summary 放进一个 table1.py 模块,主分析脚本只负责传入变量清单。这样当分组变量从“治疗组”换成“疾病亚型”时,只需改一个参数,整张表自动重算。