临床 Python 进阶路线图
⌕ /
路线图 › 第二阶段 · 数据操作

第 09 章 · 合并、重塑与分组汇总

本章目标:掌握 PROC TRANSPOSE 的 pandas 对应物(pivot / melt), 以及"从长表到宽表、从宽表到长表"的思维。 这是临床报表生成的核心技能——几乎每一张 CSR 表格背后都是一次重塑。


9.1 长表 vs 宽表:先建立概念

临床数据有两种形态,理解它们的差异是本章的基础:

文本
长表(Long / 竖向)                宽表(Wide / 横向)
─────────────────────────         ────────────────────────────────
USUBJID        VISIT   AVAL       USUBJID        VISIT1 VISIT2 VISIT3
01-701-1015    1       24.0       01-701-1015    24.0   22.0   20.0
01-701-1015    2       22.0       01-701-1023    27.0   25.0   NaN
01-701-1015    3       20.0
01-701-1023    1       27.0
01-701-1023    2       25.0
长表(SDTM/ADaM 的常见形态) 宽表(报表/建模的常见形态)
特点 每个测量占一行,变量少行多 每个受试者一行,变量多
CDISC 例子 ADVS、ADLBC、ADAE ADSL
SAS 里叫什么 原始形态 PROC TRANSPOSE 之后
pandas 里叫什么 melt 之后 pivot 之后

记忆口诀: - melt(熔化):宽 → 长(把列"熔"成行) - pivot(旋转):长 → 宽(把行"转"成列)


9.2 长 → 宽:pivot() 与 pivot_table()

对应 SAS 的 PROC TRANSPOSE

SAS
proc transpose data=vs out=vs_wide prefix=VISIT;
    by USUBJID;
    id VISITNUM;
    var SYSBP;
run;
/* 结果列:USUBJID VISIT1 VISIT2 ... */
Python
vs_wide = vs.pivot(index="USUBJID", columns="VISITNUM", values="SYSBP")

# 加上前缀美化列名(对应 prefix=VISIT)
vs_wide = vs_wide.add_prefix("VISIT")
vs_wide = vs_wide.reset_index()          # 把 USUBJID 从索引变回普通列

参数语义对照

SAS TRANSPOSE pandas
by 变量 index=
id 变量 columns=
var 变量 values=
prefix= .add_prefix() / .add_suffix()
suffix= .add_suffix()

pivot vs pivot_table:什么时候用哪个

pivot() pivot_table()
要求 每个 (index, columns) 组合唯一 允许重复,用 aggfunc 汇总
重复组合 报错 ValueError: Index contains duplicate entries 自动聚合
用途 结构性转置(如按 VISIT 转置) 汇总统计表(表格里的"均值"格子)
Python
# ❌ 如果有重复组合,pivot 会报错
df.pivot(index="USUBJID", columns="VISIT", values="AVAL")

# ✅ 用 pivot_table 聚合(默认 aggfunc='mean')
df.pivot_table(index="USUBJID", columns="VISIT", values="AVAL", aggfunc="mean")

# 临床报表最常用:统计每个治疗组在每个访视的均值("表格"形态)
tbl = df.pivot_table(
    index="TRT01P", columns="VISIT",
    values="AVAL", aggfunc=["mean", "std"], fill_value="",
)
tbl.round(2)

🔥 pivot_table 的 aggfunc 参数就是专门为"生成报表表格"设计的。 有了它,你不需要手工写 groupby + unstack 两步。 但要注意 pivot_table 默认会丢弃全为 NaN 的组合,临床报表要求"空组也要出现"时, 需要额外处理(见 9.5)。

遇到重复时的三个处理策略

Python
# 策略 1:先聚合再 pivot(推荐,语义最清晰)
agg = df.groupby(["USUBJID", "VISIT"], as_index=False)["AVAL"].mean()
wide = agg.pivot(index="USUBJID", columns="VISIT", values="AVAL")

# 策略 2:直接 pivot_table
wide = df.pivot_table(index="USUBJID", columns="VISIT", values="AVAL", aggfunc="mean")

# 策略 3:找出重复(QC 用——为什么要重复?这是数据问题还是代码问题?)
dups = df[df.duplicated(["USUBJID", "VISIT"], keep=False)]
print(f"有 {dups['USUBJID'].nunique()} 个受试者存在重复访视记录")

9.3 宽 → 长:melt()

SAS
/* SAS 里宽转长比较别扭,通常要写宏或用 DATA 步 + 数组 */
data long;
    set wide;
    array v{3} VISIT1-VISIT3;
    do i = 1 to 3;
        AVAL = v{i};
        VISIT = i;
        output;
    end;
    keep USUBJID AVAL VISIT;
run;
Python
# pandas 一行搞定
long = wide.melt(
    id_vars=["USUBJID"],               # 保持不变的列(对应 keep)
    value_vars=["VISIT1", "VISIT2", "VISIT3"],   # 要"熔"成行的列
    var_name="VISIT",                  # 新列:原来列名放这里
    value_name="AVAL",                 # 新列:原来的值放这里
)

参数对照:

melt 参数 含义 省略时的默认
id_vars 作为标识保留的列 无(则所有列都被熔化)
value_vars 要熔化的列 除 id_vars 外的所有列
var_name 新"变量名"列的名字 variable
value_name 新"值"列的名字 value

💡 melt 的高频用途: 1. 从宽格式的 Excel 报表还原成可分析的表格 2. 把多个结局变量(如 SYSBP/DIABP)一起熔化后统一分析 3. 整理"手工维护的宽表"(很多临床试验都用 Excel 维护访视数据)

Python
# 实用例:把多个结局变量一次性熔化
long = vs_wide.melt(
    id_vars=["USUBJID", "TRT01P"],
    value_vars=["SYSBP_V1", "SYSBP_V2", "DIABP_V1", "DIABP_V2"],
    var_name="PARAM_VISIT", value_name="AVAL",
)
# 再拆回两个变量(相当于 SAS 的 scan + 派生)
long[["PARAMCD", "VISIT"]] = long["PARAM_VISIT"].str.split("_", expand=True)

9.4 多级列与多级索引(MultiIndex)

pivot 有多列 values 时会产生多级列,groupby 多键聚合会产生多级索引。 这看起来很吓人,但处理套路很固定。

Python
# 多级列
wide = df.pivot_table(index="USUBJID", columns="VISIT", values=["AVAL", "CHG"])
print(wide.columns)
# MultiIndex([('AVAL', 'VISIT1'), ('AVAL', 'VISIT2'), ('CHG', 'VISIT1'), ...])

# 把多级列"压平"成单层(最常用的技巧)
wide.columns = [f"{v}_{g}" for v, g in wide.columns]
# ['AVAL_VISIT1', 'AVAL_VISIT2', 'CHG_VISIT1', ...]

# 或者更简单:
wide.columns = wide.columns.map("_".join)
Python
# 多级索引
summary = adsl.groupby(["TRT01P", "AGEGR1"])["AGE"].mean()
# TRT01P                  AGEGR1
# Placebo                 <65       62.5
#                        65-80     72.1
# ...

# 压平成普通表格(报表几乎都要这一步)
flat = summary.reset_index()

# 转成宽表(等价 SAS PROC MEANS 输出的"交叉表"形态)
wide = summary.unstack(level="AGEGR1")
wide.columns = wide.columns.droplevel(0)     # 去掉多余的层级

多级操作的四个常用方法

Python
mi = df.groupby(["TRT01P", "AGEGR1", "SEX"])["AGE"].mean()

mi.reset_index()                        # 多级索引 → 普通列(最常用)
mi.unstack("SEX")                       # 把某层"转"成列
mi.droplevel(0)                         # 丢掉某一层
mi.swaplevel(0, 1)                      # 交换两层
mi.sort_index()                         # 按索引层级排序

💡 实用建议:pandas 的多级结构虽然强大,但容易让人迷失。 临床场景推荐 "做完聚合立刻 reset_index()", 让数据一直保持"普通二维表"形态——这样列名清晰、便于核对,也更接近 SAS 的输出。


9.5 让"空组"也出现在结果里(临床报表硬需求)

这是从 SAS 转 pandas 时最容易出错的点。

SAS 的 PROC MEANS ... COMPLETETYPES 或 PROC FREQ 默认会显示"计数为 0 的组"; pandas 的 groupby 只输出实际存在的组合。

SAS
/* SAS:即使某个治疗组中某年龄段没有受试者,也会输出 0 行 */
proc freq data=adsl;
    tables TRT01P * AGEGR1 / nocol norow nopercent;
run;
Python
# pandas:不存在的组合直接消失
adsl.groupby(["TRT01P", "AGEGR1"]).size()
# 问题:Xanomeline Low Dose + >80 这一组合不存在 → 整行消失

# ✅ 解法 1:用 Categorical 声明完整取值 + crosstab(推荐)
trts = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
agegrs = ["<65", "65-80", ">80"]

tab = pd.crosstab(
    pd.Categorical(adsl["TRT01P"], categories=trts),
    pd.Categorical(adsl["AGEGR1"], categories=agegrs),
    dropna=False,
)
# 结果:3×3 完整矩阵,空组显示 0

# ✅ 解法 2:reindex 补齐
full_index = pd.MultiIndex.from_product([trts, agegrs], names=["TRT01P", "AGEGR1"])
counts = adsl.groupby(["TRT01P", "AGEGR1"]).size().reindex(full_index, fill_value=0)

🔥 这是 CSR 表格"行数不对"问题的头号原因。 记住:任何分组统计前,先想清楚"空组要不要出现"。 要出现就用 Categorical 固定 categories(顺便解决输出顺序问题)。


9.6 窗口与滚动:SAS 里要写宏的东西

Python
# 累计(对应 retain + sum)
df["CUMSUM"] = df.groupby("USUBJID")["AVAL"].cumsum()
df["CUMMAX"] = df.groupby("USUBJID")["AVAL"].cummax()
df["CUMNUM"] = df.groupby("USUBJID").cumcount() + 1

# 滚动平均(移动窗口)
df["MA3"] = df["AVAL"].rolling(3).mean()                  # 3 期移动平均
df["MA3_GRP"] = df.groupby("USUBJID")["AVAL"].rolling(3).mean().reset_index(0, drop=True)

# 扩展窗口(从第一行累加到当前行)
df["EXPAND_MEAN"] = df["AVAL"].expanding().mean()

# 指数加权(临床试验里偶尔用于趋势平滑)
df["EWM"] = df["AVAL"].ewm(span=3).mean()

⚠️ groupby().rolling() 会返回多级索引, 记得 .reset_index(0, drop=True) 才能对齐回原 DataFrame。


9.7 逐行操作(SAS 的 max(of a--c) 等)

Python
# 逐行最大值/最小值(对应 SAS 的 max(of x1-x5))
value_cols = ["SYSBP1", "SYSBP2", "SYSBP3"]
df["MAXBP"] = df[value_cols].max(axis=1)        # axis=1 = 按行
df["MINBP"] = df[value_cols].min(axis=1)
df["MEANBP"] = df[value_cols].mean(axis=1)
df["NBP"] = df[value_cols].count(axis=1)        # 非缺失个数
df["N_MISS"] = df[value_cols].isna().sum(axis=1)

# 逐行求和(对应 SAS 的 sum(of x1-x5),注意 SAS 把缺失当 0)
df["TOTAL"] = df[value_cols].sum(axis=1, skipna=True)   # skipna=True ≈ SAS 行为

# 逐行首/末非缺失值
df["FIRST_NN"] = df[value_cols].bfill(axis=1).iloc[:, 0]
df["LAST_NN"]  = df[value_cols].ffill(axis=1).iloc[:, -1]

📌 axis 参数的方向(pandas 里最容易记混的): - axis=0:沿行方向操作 → 对每一列做聚合 - axis=1:沿列方向操作 → 对每一行做聚合

记忆法:axis=1 表示"结果长度 = 1 行一条",即逐行。 (pandas 3.0 起推荐用 axis="columns" / axis="index" 更明确)


9.8 实战:从 ADAE 生成"AE 按 SOC/PT 汇总表"的数据

这是每个 CSR 都有的 TFL。原始长表 → 汇总宽表的完整流程:

Python
from pathlib import Path
import pandas as pd

BASE = Path(__file__).resolve().parent.parent
adae = pd.read_csv(BASE / "data" / "samples" / "adae.csv")

# --- 步骤 1:准备分析人群(安全性人群 + 治疗中出现的事件)---
saf = pd.read_csv(BASE / "data" / "samples" / "adsl.csv").query("SAFFL == 'Y'")
saf = saf[["USUBJID", "TRT01P"]]
adae = adae.merge(saf, on="USUBJID", how="inner")

# 只保留治疗中出现的不良事件(TEAE)
teae = adae.query("TRTEMFL == 'Y'").copy() if "TRTEMFL" in adae.columns else adae

# --- 步骤 2:受试者层级去重(同一受试者同一 PT 只算一次)---
# 这是 AE 表的核心逻辑:分子是"受试者数"而不是"事件数"
subj_pt = teae.drop_duplicates(["USUBJID", "TRT01P", "AEBODSYS", "AEDECOD"])

# --- 步骤 3:按 SOC + PT 计数 ---
n_by_group = saf.groupby("TRT01P")["USUBJID"].nunique()   # 分母:各组人数

counts = (subj_pt.groupby(["AEBODSYS", "AEDECOD", "TRT01P"])
                 .size().rename("N").reset_index())

# --- 步骤 4:长 → 宽(每个治疗组一列)---
wide = counts.pivot_table(
    index=["AEBODSYS", "AEDECOD"], columns="TRT01P",
    values="N", aggfunc="sum", fill_value=0,
).reset_index()

# --- 步骤 5:算百分比并格式化成 "n (%)" ---
trt_cols = [c for c in wide.columns if c not in ("AEBODSYS", "AEDECOD")]
for c in trt_cols:
    wide[c] = wide[c].apply(lambda n: f"{n} ({n / n_by_group[c] * 100:.1f}%)" if n else "0")

print(wide.head(20).to_string(index=False))

这段代码体现的临床编程要点: 1. 分母是"该治疗组的受试者数",不是总人数。 2. 受试者层级去重:同一受试者同一 PT 发生 3 次,只算 1 个受试者。 (这是 AE 表和"事件数表"的根本区别,QC 时最容易出错) 3. 百分比保留 1 位小数,且通常用 n (%) 的合并格式。

💡 完整可运行版本见 cases/case03_不良事件汇总表.py。


9.9 动手练习

  1. pivot:把 data/samples/adtte.csv 或 ADAE 按 USUBJID 为行、PARAMCD 为列、AVAL 为值做透视。

  2. melt:把上面透视出来的宽表再 melt 回长表, 验证"pivot 再 melt"能还原成原来的数据(列顺序可能不同)。

  3. 空组处理:用 pd.Categorical 固定治疗组顺序为 Placebo → Xanomeline Low Dose → Xanomeline High Dose, 统计 ADSL 中各组的 AGEGR1 分布,确认三个组都出现且顺序正确。

  4. 多级索引:对 ADSL 做 groupby(["TRT01P","AGEGR1","SEX"]).size(), 然后分别用 reset_index()、unstack() 转成两种不同的表形态。

  5. 综合:把 9.8 节的 AE 汇总表脚本改成 "按 SOC 汇总"(不细分 PT) 的版本,并统计总受试者数。


上一章 ← 第 08 章 · 数据操作对照 下一章 → 第 10 章 · 日期、缺失值、格式与数据质量