临床 Python 进阶路线图
⌕ /
路线图 › 实战案例 · 数据与报表

案例 03 · 不良事件汇总表

case03_不良事件汇总表.py · 242 行 · 9 KB
案例 03 · 不良事件汇总表(按 MedDRA SOC / PT) ================================================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 03 · 不良事件汇总表(按 MedDRA SOC / PT)
================================================

**目标**:生成 CSR 中最核心的安全性表——治疗中出现的不良事件(TEAE)按
系统器官分类(SOC)与首选术语(PT)的受试者数与百分比。

**对应 SAS**:``PROC FREQ`` + ``PROC TRANSPOSE`` + ``PROC REPORT`` 组合,
但本案例用 pandas 的 ``pivot_table`` + 受试者层级去重一次完成。

**覆盖教程章节**:第 09 章(重塑)、第 13 章(TFL 报表)

★ 这是最容易做错的一张表,重点在于三个"隐形陷阱" ★
----------------------------------------------------
1. **分子是受试者数,不是事件数。**
   同一受试者发生 3 次 Nausea,分子只能是 1。
   → 用 ``drop_duplicates(["USUBJID","AEBODSYS","AEDECOD"])`` 做受试者层级去重
2. **分母是该治疗组的分析人群人数**(ADSL 的 SAFFL='Y'),
   而不是 AE 数据集里的记录数。
3. **没发生 AE 的受试者要计入分母(分子为 0)。**
   CDISC 试点数据中 254 人里有 29 人没有任何 AE —— 他们不能被悄悄丢掉。

运行
----
    python cases/case03_不良事件汇总表.py
    python cases/case03_不良事件汇总表.py --all-events   # 不限定 TEAE
"""

from __future__ import annotations

import argparse
import sys
from pathlib import Path

import numpy as np
import pandas as pd

BASE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(BASE))

from clinic import io as cio                        # noqa: E402
from clinic import report as rpt                    # noqa: E402
from clinic.derive import to_categorical            # noqa: E402

TRT_LEVELS = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]

pd.set_option("display.width", 220)
pd.set_option("display.max_colwidth", 60)


def section(t: str) -> None:
    print("\n" + "=" * 86)
    print(f"  {t}")
    print("=" * 86)


def build(data_dir: Path, treatment_only: bool = True):
    """准备数据:人群、分母、受试者层级的 SOC/PT 计数。"""
    adsl = cio.read_any(data_dir / "adsl.csv")
    adae = cio.read_any(data_dir / "adae.csv")

    # ---- Step 1: 分析人群(分母)----
    saf = adsl[adsl["SAFFL"].astype(str).str.strip() == "Y"].copy()
    saf["TRT01P"] = saf["TRT01P"].astype(str).str.strip()
    denom = saf.groupby("TRT01P")["USUBJID"].nunique().to_dict()

    # ---- Step 2: 限定分析事件 ----
    ae = adae.copy()
    if treatment_only and "TRTEMFL" in ae.columns:
        n_before = len(ae)
        ae = ae[ae["TRTEMFL"].astype(str).str.strip() == "Y"]
        print(f"限定治疗中出现的不良事件(TRTEMFL='Y'):{n_before} → {len(ae)} 条记录")

    # ---- Step 3: 只保留分析人群内的受试者(关键!)----
    n0 = ae["USUBJID"].nunique()
    ae = ae[ae["USUBJID"].isin(set(saf["USUBJID"]))].copy()
    print(f"限定在安全性人群内:{n0} → {ae['USUBJID'].nunique()} 位受试者")

    # ---- Step 3b: 取治疗组变量 ----
    # ★ 注意:ADAE 里通常只有 TRTA(实际治疗),没有 TRT01P(计划治疗)。
    #   CSR 报表惯例使用 **计划治疗 TRT01P**,所以要从 ADSL 带过来。
    #   这既保证了分组口径与 Table 1 一致,也避免了"实际治疗"因
    #   提前退出而与计划治疗不一致带来的口径混乱。
    if "TRT01P" not in ae.columns:
        ae = ae.merge(saf[["USUBJID", "TRT01P"]], on="USUBJID", how="left")
        print("已从 ADSL 引入 TRT01P(ADAE 原生只有 TRTA)")
    ae["TRT01P"] = ae["TRT01P"].astype(str).str.strip()

    # ---- Step 4: ★受试者层级去重★ ----
    # 同一受试者同一 SOC 发生多次 → 只计 1 人
    subj_soc = ae.drop_duplicates(["USUBJID", "TRT01P", "AEBODSYS"])
    # 同一受试者同一 PT 发生多次 → 只计 1 人
    subj_pt = ae.drop_duplicates(["USUBJID", "TRT01P", "AEBODSYS", "AEDECOD"])
    # 有任一 TEAE 的受试者
    subj_any = ae.drop_duplicates(["USUBJID", "TRT01P"])

    n_any = subj_any.groupby("TRT01P")["USUBJID"].nunique().to_dict()
    return saf, denom, subj_soc, subj_pt, n_any, len(ae)


def pivot_counts(df_uniq: pd.DataFrame, keys: list[str],
                 denom: dict) -> tuple[pd.DataFrame, pd.DataFrame]:
    """按 keys 分组统计各治疗组受试者数,返回 (计数表, n(%) 格式表)。"""
    cnt = (df_uniq.groupby(keys + ["TRT01P"])["USUBJID"]
                  .nunique().rename("N").reset_index())
    wide = cnt.pivot_table(index=keys, columns="TRT01P", values="N",
                           aggfunc="sum", fill_value=0, observed=False)
    # ★ 保证所有治疗组列都存在(即使某组为 0)
    for t in TRT_LEVELS:
        if t not in wide.columns:
            wide[t] = 0
    wide = wide[TRT_LEVELS].reset_index()

    fmt = wide[keys].copy()
    for t in TRT_LEVELS:
        fmt[t] = [rpt.pct_format(int(n), denom.get(t, 0)) for n in wide[t]]
    return wide, fmt


def main() -> None:
    ap = argparse.ArgumentParser(description="AE 按 SOC/PT 汇总表")
    ap.add_argument("--all-events", action="store_true",
                    help="统计全部 AE(默认只统计治疗中出现的 TEAE)")
    args = ap.parse_args()

    data_dir = BASE / "data" / "samples"
    out_dir = BASE / "outputs"
    out_dir.mkdir(exist_ok=True)

    treatment_only = not args.all_events
    label = "治疗中出现的不良事件(TEAE)" if treatment_only else "全部不良事件"

    print("=" * 86)
    print(f"  案例 03 · {label} 汇总表(按 SOC / PT,受试者层级)")
    print("=" * 86)

    saf, denom, subj_soc, subj_pt, n_any, n_records = build(data_dir, treatment_only)

    section("1. 分母核对(这是整张表的基础)")
    print(f"{'治疗组':26s}{'安全性人群人数':>12s}")
    for t in TRT_LEVELS:
        print(f"{t:26s}{denom.get(t, 0):>12d}")
    print(f"{'合计':26s}{sum(denom.values()):>12d}")
    print(f"\nAE 记录数(未去重):{n_records}")
    print(f"有任一 AE 的受试者:{sum(n_any.values())} / {sum(denom.values())}")

    # ------------------------------------------------------------------
    section("2. 总体行")
    print(f"{'':56s}" + "".join(f"{t[:18]:>22s}" for t in TRT_LEVELS))
    line = f"{'有任一' + ('TEAE' if treatment_only else 'AE') + '的受试者':56s}"
    for t in TRT_LEVELS:
        line += rpt.pct_format(n_any.get(t, 0), denom.get(t, 0)).rjust(22)
    print(line)

    # ------------------------------------------------------------------
    section("3. 按 SOC 汇总")
    soc_w, soc_fmt = pivot_counts(subj_soc, ["AEBODSYS"], denom)
    soc_w["_tot"] = soc_w[TRT_LEVELS].sum(axis=1)
    order = soc_w.sort_values("_tot", ascending=False).index
    soc_fmt = soc_fmt.loc[order].reset_index(drop=True)

    print(f"{'SOC':56s}" + "".join(f"{t[:18]:>22s}" for t in TRT_LEVELS))
    print("-" * 122)
    for _, r in soc_fmt.iterrows():
        print(f"{str(r['AEBODSYS'])[:54]:56s}"
              + "".join(str(r[t]).rjust(22) for t in TRT_LEVELS))

    # ------------------------------------------------------------------
    section("4. 按 SOC → PT 汇总(完整表,前 6 个 SOC)")
    pt_w, pt_fmt = pivot_counts(subj_pt, ["AEBODSYS", "AEDECOD"], denom)
    pt_w["_tot"] = pt_w[TRT_LEVELS].sum(axis=1)

    print(f"{'SOC / PT':56s}" + "".join(f"{t[:18]:>22s}" for t in TRT_LEVELS))
    print("-" * 122)
    for soc_name in soc_fmt["AEBODSYS"].head(6):
        soc_row = soc_fmt[soc_fmt["AEBODSYS"] == soc_name].iloc[0]
        print(f"{str(soc_name)[:54]:56s}"
              + "".join(str(soc_row[t]).rjust(22) for t in TRT_LEVELS))
        pts = pt_w[pt_w["AEBODSYS"] == soc_name].sort_values("_tot", ascending=False)
        for _, p in pts.iterrows():
            n = int(p["_tot"])
            # CSR 惯例:只显示发生频率 >= 一定阈值的 PT(这里用 >=1)
            if n == 0:
                continue
            pct_row = pt_fmt[(pt_fmt["AEBODSYS"] == soc_name)
                             & (pt_fmt["AEDECOD"] == p["AEDECOD"])].iloc[0]
            print(f"{'  ' + str(p['AEDECOD'])[:52]:56s}"
                  + "".join(str(pct_row[t]).rjust(22) for t in TRT_LEVELS))
        print()

    # ------------------------------------------------------------------
    # 输出
    section("5. 输出文件")
    soc_fmt.to_csv(out_dir / "table02_teae_soc.csv", index=False, encoding="utf-8-sig")
    pt_fmt.sort_values("AEBODSYS").to_csv(out_dir / "table02_teae_pt.csv",
                                          index=False, encoding="utf-8-sig")

    # 拼装成一张分层表(SOC 行 + PT 行,符合 CSR 排版惯例)
    combined = []
    for soc_name in soc_fmt["AEBODSYS"]:
        sr = soc_fmt[soc_fmt["AEBODSYS"] == soc_name].iloc[0]
        combined.append({"层级": str(soc_name), "级别": "SOC",
                         **{t: sr[t] for t in TRT_LEVELS}})
        pts = pt_fmt[pt_fmt["AEBODSYS"] == soc_name]
        for _, pr in pts.iterrows():
            cnt = pt_w[(pt_w["AEBODSYS"] == soc_name)
                       & (pt_w["AEDECOD"] == pr["AEDECOD"])]["_tot"].iloc[0]
            if cnt == 0:
                continue
            combined.append({"层级": "  " + str(pr["AEDECOD"]), "级别": "PT",
                             **{t: pr[t] for t in TRT_LEVELS}})
    combined_df = pd.DataFrame(combined)
    combined_df.to_csv(out_dir / "table02_teae_hierarchical.csv",
                       index=False, encoding="utf-8-sig")
    rpt.to_html(combined_df, out_dir / "table02_teae.html",
                title=f"表 2. {label}(按 SOC / PT)",
                subtitle="安全性人群,受试者层级计数",
                footnote=("分子为受试者数(同一受试者同一事件只计一次)。"
                          "分母为该治疗组的安全性人群人数。"
                          "数据来源:CDISC 试点项目 CDISCPILOT01(公开数据)。"),
                label_cols=("层级", "级别"))

    print(f"已输出到 {out_dir}:")
    print("  table02_teae_soc.csv            SOC 层级")
    print("  table02_teae_pt.csv             PT 层级")
    print("  table02_teae_hierarchical.csv   分层合并表(推荐)")
    print("  table02_teae.html               带排版的 HTML")

    # ------------------------------------------------------------------
    section("6. QC 提示(迁移到 Python 时最容易搞错的三处)")
    print("  ① 分子:必须做受试者层级去重。")
    print(f"     未去重的事件数 = {n_records};去重后的受试者数 = {sum(n_any.values())}")
    print("     如果用事件数当分子,所有百分比都会偏高。")
    print("  ② 分母:必须是 ADSL 的安全性人群人数,不是 AE 记录数。")
    print("  ③ 空组:某治疗组在某 SOC 下无事件时,必须显示 '0' 而不是不显示该行。")


if __name__ == "__main__":
    main()