本章目标:掌握
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
proc transpose data=vs out=vs_wide prefix=VISIT;
by USUBJID;
id VISITNUM;
var SYSBP;
run;
/* 结果列:USUBJID VISIT1 VISIT2 ... */
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 转置) | 汇总统计表(表格里的"均值"格子) |
# ❌ 如果有重复组合,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)。
遇到重复时的三个处理策略
# 策略 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 里宽转长比较别扭,通常要写宏或用 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;
# 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 维护访视数据)
# 实用例:把多个结局变量一次性熔化
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 多键聚合会产生多级索引。
这看起来很吓人,但处理套路很固定。
# 多级列
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)
# 多级索引
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) # 去掉多余的层级
多级操作的四个常用方法
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:即使某个治疗组中某年龄段没有受试者,也会输出 0 行 */
proc freq data=adsl;
tables TRT01P * AGEGR1 / nocol norow nopercent;
run;
# 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 里要写宏的东西
# 累计(对应 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) 等)
# 逐行最大值/最小值(对应 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。原始长表 → 汇总宽表的完整流程:
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 动手练习
-
pivot:把
data/samples/adtte.csv或 ADAE 按USUBJID为行、PARAMCD为列、AVAL为值做透视。 -
melt:把上面透视出来的宽表再 melt 回长表, 验证"pivot 再 melt"能还原成原来的数据(列顺序可能不同)。
-
空组处理:用
pd.Categorical固定治疗组顺序为Placebo → Xanomeline Low Dose → Xanomeline High Dose, 统计 ADSL 中各组的 AGEGR1 分布,确认三个组都出现且顺序正确。 -
多级索引:对 ADSL 做
groupby(["TRT01P","AGEGR1","SEX"]).size(), 然后分别用reset_index()、unstack()转成两种不同的表形态。 -
综合:把 9.8 节的 AE 汇总表脚本改成 "按 SOC 汇总"(不细分 PT) 的版本,并统计总受试者数。
上一章 ← 第 08 章 · 数据操作对照 下一章 → 第 10 章 · 日期、缺失值、格式与数据质量