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

案例 04 · 实验室移位表

case04_实验室移位表.py · 245 行 · 9 KB
案例 04 · 实验室检查移位表(Shift Table) ==========================================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 04 · 实验室检查移位表(Shift Table)
==========================================

**目标**:生成"基线 → 基线后"的实验室分级迁移表。
这是每个 CSR 都有的安全性表,用来展示治疗是否导致实验室指标恶化。

**覆盖教程章节**:第 09 章(透视/重塑)、第 13 章(报表)、第 14 章(统计)

移位表最容易搞错的四件事(本案例逐一显式处理)
----------------------------------------------
1. **分级阈值从哪里来?**
   优先用数据集自带的 ``ANRLO`` / ``ANRHI``(参考范围下限/上限)
   和 ``ANRIND`` / ``BNRIND``(分析/基线时的正常性指示符)。
   **不要自己硬编码阈值** —— 那会与 define.xml 的声明不一致。
2. **百分比的分母是什么?**
   行百分比(分母 = 该基线分类的合计)还是列百分比(分母 = 该基线后分类的合计)?
   **两者都有,必须按 SAP 规定**。本案例默认行百分比,并提供参数切换。
3. **用哪一次"基线后"访视?**
   末次访视?END OF TREATMENT?固定访视?
   本案例默认用 **End of Treatment**,并演示如何改用其他访视。
4. **同一受试者同一次访视有多条记录怎么办?**
   必须先定规则(取最后一次 / 取均值 / 取最差)。

运行
----
    python cases/case04_实验室移位表.py
    python cases/case04_实验室移位表.py --param ALB        # 只看白蛋白
    python cases/case04_实验室移位表.py --pct col          # 列百分比
    python cases/case04_实验室移位表.py --visit "Week 24"  # 改用其他访视
"""

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

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

# 正常性指示符的取值(CDISC 惯例)
# N = Normal / L = Low / H = High / A = Abnormal(部分项目会用)
RIND_ORDER = ["L", "N", "H"]
RIND_LABEL = {"L": "低于正常范围", "N": "正常范围", "H": "高于正常范围"}

pd.set_option("display.width", 200)


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


def load_lab(data_dir: Path) -> pd.DataFrame:
    """载入实验室数据(优先用 ADLBC,退回到裁剪样本)。"""
    for name in ("adlbc.csv", "adlbc_shift.csv"):
        p = data_dir / name
        if p.exists():
            df = cio.read_any(p)
            print(f"已载入 {name}:{len(df)} 行 × {df.shape[1]} 列")
            return df
    raise FileNotFoundError(
        "找不到实验室数据。请先运行:\n"
        "    python scripts/download_data.py --core\n"
        "    python scripts/make_samples.py"
    )


def pick_post_visit(df: pd.DataFrame, visit: str | None) -> pd.DataFrame:
    """选择"基线后"的访视。

    默认规则:每位受试者取 **End of Treatment**;
    若某受试者没有 EOT,则退回到其最后一次有记录的访视(末次观测结转 LOCF 的简化版)。
    """
    d = df.copy()
    d["AVISIT"] = d["AVISIT"].astype(str).str.strip()
    if visit:
        return d[d["AVISIT"] == visit].copy()

    eot = d[d["AVISIT"] == "End of Treatment"].copy()
    missing = set(d["USUBJID"]) - set(eot["USUBJID"])
    if missing:
        # 对这些受试者,取最后一次访视(按 AVISITN 最大)
        d["AVISITN"] = pd.to_numeric(d["AVISITN"], errors="coerce")
        d = d[d["AVISIT"] != "Baseline"]
        last_idx = d.groupby("USUBJID")["AVISITN"].idxmax()
        fallback = d.loc[last_idx]
        fallback = fallback[fallback["USUBJID"].isin(missing)]
        eot = pd.concat([eot, fallback], ignore_index=True)
        print(f"  {len(missing)} 位受试者无 End of Treatment 记录,"
              f"改取其末次可用访视(LOCF 简化版)")
    return eot


def build_shift(lab: pd.DataFrame, param: str, post_visit: str | None) -> pd.DataFrame:
    """为单个实验室参数构造"受试者 × (基线, 基线后)"的配对表。"""
    d = lab[lab["PARAMCD"].astype(str).str.strip() == param].copy()

    # ---- 基线:优先用 ABLFL='Y';否则用 AVISIT='Baseline' ----
    d["ABLFL"] = d.get("ABLFL", pd.Series(index=d.index, dtype=object)).astype(str).str.strip()
    d["AVISIT"] = d["AVISIT"].astype(str).str.strip()

    bl = d[(d["ABLFL"] == "Y") | (d["AVISIT"] == "Baseline")].copy()
    # 同一受试者多条基线记录 → 取最后一次(规则必须写死)
    bl = bl.sort_values("AVISITN" if "AVISITN" in bl.columns else "AVAL")
    bl = bl.drop_duplicates("USUBJID", keep="last")

    # 基线分类:优先用数据集自带的 BNRIND,否则用 ANRIND
    bl_ind_col = "BNRIND" if "BNRIND" in bl.columns and bl["BNRIND"].notna().any() else "ANRIND"
    bl = bl[["USUBJID", "TRTP", bl_ind_col]].rename(
        columns={bl_ind_col: "IND_BL"}).copy()
    bl["IND_BL"] = bl["IND_BL"].astype(str).str.strip().str[:1].str.upper()

    # ---- 基线后 ----
    post = pick_post_visit(d, post_visit)
    post = post[["USUBJID", "ANRIND"]].copy()
    post["IND_POST"] = post["ANRIND"].astype(str).str.strip().str[:1].str.upper()
    # 同一受试者多条 → 取最差的(L/H 优先于 N)—— 安全性分析的保守做法
    post["_rank"] = post["IND_POST"].map({"N": 0, "L": 1, "H": 1}).fillna(-1)
    post = post.sort_values("_rank").drop_duplicates("USUBJID", keep="last")

    # ---- 配对 ----
    paired = bl.merge(post[["USUBJID", "IND_POST"]], on="USUBJID", how="inner")
    valid = paired["IND_BL"].isin(RIND_ORDER) & paired["IND_POST"].isin(RIND_ORDER)
    n_drop = int((~valid).sum())
    if n_drop:
        print(f"  排除 {n_drop} 条基线或基线后分级缺失的记录")
    return paired[valid].copy()


def main() -> None:
    ap = argparse.ArgumentParser(description="实验室检查移位表")
    ap.add_argument("--param", default=None, help="只分析某个 PARAMCD,如 ALB / ALT / CREAT")
    ap.add_argument("--pct", default="row", choices=["row", "col", "total"],
                    help="百分比口径:row=行百分比(默认)/ col=列百分比 / total=总计")
    ap.add_argument("--visit", default=None, help="指定基线后访视,如 'Week 24'")
    ap.add_argument("--top", type=int, default=6, help="默认分析多少个参数(按记录数)")
    args = ap.parse_args()

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

    print("=" * 84)
    print("  案例 04 · 实验室检查移位表")
    print("=" * 84)

    lab = load_lab(data_dir)

    section("1. 确定分析参数")
    counts = lab["PARAMCD"].astype(str).str.strip().value_counts()
    params = [args.param] if args.param else list(counts.head(args.top).index)
    print(f"共 {counts.nunique() if False else len(counts)} 个参数,"
          f"本案例分析 {len(params)} 个:{params}")

    section(f"2. 移位表(百分比口径:{args.pct})"
            + (f",访视:{args.visit}" if args.visit else ",访视:End of Treatment"))

    all_tables = {}
    for param in params:
        print(f"\n{'-' * 84}")
        print(f"参数 {param}")
        print(f"{'-' * 84}")

        paired = build_shift(lab, param, args.visit)
        if len(paired) == 0:
            print("  无可配对的记录,跳过")
            continue

        # 按治疗组分别出表
        rows = []
        for trt in TRT_LEVELS:
            sub = paired[paired["TRTP"].astype(str).str.strip() == trt]
            if len(sub) == 0:
                continue
            tbl = rpt.crosstab_shift(sub["IND_BL"], sub["IND_POST"], RIND_ORDER, pct=args.pct)
            for bl_ind in RIND_ORDER:
                if bl_ind not in tbl.index:
                    continue
                row = {"治疗组": trt if bl_ind == RIND_ORDER[0] else "",
                       "基线": f"{bl_ind} ({RIND_LABEL[bl_ind]})"}
                for col in RIND_ORDER + ["合计"]:
                    key = (f"{col} ({RIND_LABEL[col]})" if col in RIND_LABEL
                           else col)
                    row[key] = tbl.loc[bl_ind, col]
                rows.append(row)

        out = pd.DataFrame(rows)
        print(out.to_string(index=False))
        all_tables[param] = out

        # 恶化率汇总(临床最关心的一列)
        print(f"\n  【恶化率】基线正常(N) → 基线后异常(L/H) 的比例:")
        for trt in TRT_LEVELS:
            sub = paired[(paired["TRTP"].astype(str).str.strip() == trt)
                         & (paired["IND_BL"] == "N")]
            if len(sub) == 0:
                continue
            n_worse = int((sub["IND_POST"].isin(["L", "H"])).sum())
            print(f"    {trt:26s} {n_worse:>4} / {len(sub):>4}"
                  f"  ({n_worse / len(sub) * 100:5.1f}%)")

    # ------------------------------------------------------------------
    section("3. 输出文件")
    if all_tables:
        combined = pd.concat(
            [t.assign(参数=p) for p, t in all_tables.items()], ignore_index=True
        )
        combined.to_csv(out_dir / "table03_lab_shift.csv", index=False, encoding="utf-8-sig")
        rpt.to_html(combined, out_dir / "table03_lab_shift.html",
                    title="表 3. 实验室检查分级移位表",
                    subtitle=("基线 vs 治疗结束(End of Treatment);"
                              f"单元格为 n ({'行' if args.pct == 'row' else '列'}百分比)"),
                    footnote=("分级依据数据集自带的分析正常性指示符(ANRIND / BNRIND)。"
                              "N=正常范围,L=低于正常范围,H=高于正常范围。"
                              "数据来源:CDISC 试点项目 CDISCPILOT01(公开数据)。"),
                    label_cols=("参数", "治疗组", "基线"))
        print(f"已输出:\n  {out_dir / 'table03_lab_shift.csv'}"
              f"\n  {out_dir / 'table03_lab_shift.html'}")

    # ------------------------------------------------------------------
    section("4. QC 提示")
    print("  ① 分母口径:本案例用行百分比(分母=该基线分类合计)。")
    print("     若 SAP 要求列百分比(分母=该基线后分类合计),用 --pct col。")
    print("  ② 基线定义:优先用 ABLFL='Y';没有该标记时才用 AVISIT='Baseline'。")
    print("  ③ 基线后访视:默认 End of Treatment;无 EOT 的受试者取末次可用访视。")
    print("  ④ 同一受试者多次记录:基线取最后一次;基线后取最差等级(保守做法)。")
    print("     这些规则都必须与 SAP 一致,并在 ADRG 中说明。")


if __name__ == "__main__":
    main()