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"),再在模板中套用三线表格式。

分组基线表自动生成的数据流 受试者级数据 df arm 分组列 age / bmi / sbp sex / smoking / nyha 含 NaN 缺失 连续变量:groupby().agg() count / mean / std median / q1 / q3 missing = isna().sum() 输出 mean±sd、median(IQR) 分类变量:value_counts() 按组内水平计数 cnt denom = 非缺失总数 pct = cnt / denom × 100 输出 n (%),0 例也保留 纵向拼接 concat MultiIndex(变量, 统计量) 列顺序固定:试验组 / 对照组 缺失计数单独成列 百分比保留 1 位小数 to_excel 导出三线表 输出表(示例结构) 变量 试验组 (n=120) 对照组 (n=118) 年龄 (岁) 56.3 ± 9.8 57.1 ± 10.2 性别:男 68 (56.7%) 63 (53.4%)

结果核查与常见坑

生成表后不要直接交付,至少核对三点。第一,各组 n 是否与随机化人数一致,若某变量缺失较多,应在脚注说明“该变量缺失 x 例”。第二,分类变量各水平百分比之和是否接近 100%(四舍五入可能为 99.9% 或 100.1%,属正常)。第三,连续变量的均值与中位数若差距很大,说明分布偏态,报告中应优先采用中位数(IQR),而不是强行用均值 ± 标准差。

不要把百分比分母写成全表总人数。分组表的每一列百分比都应以该组该变量的非缺失例数为分母,否则两组百分比之和会超过 100%,审阅时容易被质疑。
如果同一张表要反复生成,建议把 cont_summary 和 cat_summary 放进一个 table1.py 模块,主分析脚本只负责传入变量清单。这样当分组变量从“治疗组”换成“疾病亚型”时,只需改一个参数,整张表自动重算。