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

案例 05 · ADSL 衍生与双编程验证

case05_ADSL衍生.py · 738 行 · 30 KB
案例 05 · 从 SDTM 衍生 ADSL(受试者水平分析数据集) ====================================================
Python
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
案例 05 · 从 SDTM 衍生 ADSL(受试者水平分析数据集)
====================================================

**目标**:把多个 SDTM 域(DM / EX / DS / VS / ADAE)合并成一张
**受试者水平(one record per subject)** 的分析数据集 ADSL ——
这是所有 TFL 报表的分母来源,也是 ADaM 生产的第一道工序。

**覆盖教程章节**:第 09 章(合并重塑)、第 12 章(SDTM)、第 13 章(ADaM)

为什么这是 SAS 程序员转 Python 的"分水岭"
------------------------------------------
SAS 里做 ADSL 是一长串 ``DATA`` 步 + ``MERGE`` + ``RETAIN`` + ``BY``,
Python 里对应的是 ``groupby().agg()`` + ``merge()``。
表面看是语法差异,真正的坑全在**口径**上 —— 本案例会把这些坑一个个挖出来:

1. **治疗结束日怎么定?** EX 里最后一条记录的 ``EXENDTC`` 经常是空的
   (治疗还在进行 / 记录未闭合)。三种合理口径给出 **248 / 254 / 127** 三个不同答案。
2. **TRT01A(实际治疗组)到底按什么定?** 抄 ``DM.ACTARM`` 会错 12 个人 ——
   他们随机化到高剂量,但在滴定期就退出了,从没吃到 81 mg。
3. **完成 8/16/24 周的标记靠什么判?** 按"治疗时长 ≥ 56 天"和按
   "完成第 8 周访视"是完全不同的两件事。
4. **精度丢失的顺序会影响结果。** BMI 用未舍入的体重算得 28.2,
   用舍入到 1 位的体重算得 28.1 —— 参考 ADSL 用的是后者。
5. **DM 里根本没有身高体重。** 它们来自 VS 域。
   ADSL 从来不是"从 DM 一步得来"的,而是多域合并的产物。

最后一步是**双编程验证(Double Programming)**:
把自己派生的 ADSL 与已知的参考 ADSL 逐变量比对,输出匹配率。
这不是"多此一举",而是 ADaM 生产流程的核心环节 ——
**你的派生结果必须能被第二套独立实现复现。**

运行
----
    python cases/case05_ADSL衍生.py
    python cases/case05_ADSL衍生.py --end-rule ex        # 只看 EX 的直接口径
    python cases/case05_ADSL衍生.py --end-rule ref       # 放弃 EX,直接用参考结束日
    python cases/case05_ADSL衍生.py --trt01a-rule actarm # 实际治疗组改抄 DM.ACTARM
"""

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 (               # noqa: E402
    derive_agegr1,
    sas_round_series,
    to_categorical,
)

pd.set_option("display.width", 220)
pd.set_option("display.max_columns", 90)

# 治疗组顺序 & 编码(**按 SAP 指定,绝不能依赖字母序**)
TRT_LEVELS = ["Placebo", "Xanomeline Low Dose", "Xanomeline High Dose"]
TRT_CODE = {"Placebo": 0, "Xanomeline Low Dose": 54, "Xanomeline High Dose": 81}
EXCLUDED_ARMS = {"Screen Failure", "Not Assigned", "Not Randomized"}
RACEN_MAP = {
    "WHITE": 1,
    "BLACK OR AFRICAN AMERICAN": 2,
    "AMERICAN INDIAN OR ALASKA NATIVE": 6,
    "ASIAN": 3,
    "NATIVE HAWAIIAN OR OTHER PACIFIC ISLANDER": 4,
}

# 完成 8/16/24 周治疗 ↔ 最后一次疗效访视号(VISITNUM)
# 对照访视计划:4=WEEK2, 5=WEEK4, 7=WEEK6, 8=WEEK8, 9=WEEK12, 10=WEEK16,
#               11=WEEK20, 12=WEEK24, 13=WEEK26(研究结束随访)
COMP_VISIT = {"COMP8FL": 8, "COMP16FL": 10, "COMP24FL": 12}


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


# ==========================================================================
# 1. 载入 SDTM 源数据
# ==========================================================================
def load_sources(data_dir: Path) -> dict[str, pd.DataFrame]:
    """载入 ADSL 派生所需的全部源域。

    注意 ``vs_anthrop``(VS 的身高/体重子集)与 ``vs_bp`` 都是**必需**的:
    DM 里没有身高体重,BMI 必须从 VS 派生;访视号也要从 VS 取。
    """
    need = ["dm", "ex", "ds", "adae", "vs_anthrop", "vs_bp"]
    src: dict[str, pd.DataFrame] = {}
    for name in need:
        p = data_dir / f"{name}.csv"
        if not p.exists():
            raise FileNotFoundError(
                f"找不到 {p.name}。请先运行:\n"
                "    python scripts/download_data.py --core\n"
                "    python scripts/make_samples.py"
            )
        src[name] = cio.read_any(p)
    return src


# ==========================================================================
# 2. 暴露(Exposure):治疗起始日 / 结束日 / 时长
# ==========================================================================
def derive_exposure(ex: pd.DataFrame, end_rule: str,
                    ref_end: pd.Series | None = None) -> pd.DataFrame:
    """从 EX 域派生 TRTSDT / TRTEDT / TRTDUR。

    Parameters
    ----------
    ex : EX 域(一次给药记录一行)
    end_rule : 治疗结束日口径

        =========  =====================================================
        ``'ex'``   ``TRTEDT = max(EXENDTC)`` —— 最"字面"的做法
        ``'imp'``  缺失的 ``EXENDTC`` 用「下一条记录的起始日 − 1 天」补全;
                   **末次记录仍未闭合**(治疗仍在进行)时取参考结束日
                   ``RFENDTC`` —— 这是本项目的实际口径
        ``'ref'``  ``TRTEDT = RFENDTC``(完全放弃 EX 信息)
        =========  =====================================================

    ref_end : ``Series``,index 为 USUBJID,值为参考结束日

    Notes
    -----
    ``TRTDUR`` 的计算是 ``(TRTEDT - TRTSDT) + 1``:**首尾都算**。
    这与 SAS 里 ``TRTDUR = TRTEDT - TRTSDT + 1;`` 完全一致 ——
    少写那个 ``+1`` 是极常见的 diff 来源。
    """
    x = ex.copy()
    x["_STDTC"] = pd.to_datetime(x["EXSTDTC"], errors="coerce")
    x["_ENDTC"] = pd.to_datetime(x["EXENDTC"], errors="coerce")
    x["_SEQ"] = pd.to_numeric(x.get("EXSEQ"), errors="coerce")
    x = x.sort_values(["USUBJID", "_SEQ"], kind="stable")

    if end_rule == "ref":
        g = x.groupby("USUBJID").agg(TRTSDT=("_STDTC", "min"),
                                     n_ex=("_SEQ", "size"))
        g["TRTEDT"] = ref_end.reindex(g.index) if ref_end is not None else pd.NaT
    else:
        if end_rule == "ex":
            x["_END_C"] = x["_ENDTC"]
        else:  # imp
            # 下一条给药记录的起始日 − 1 天,就是本条的结束日
            nxt = x.groupby("USUBJID")["_STDTC"].shift(-1)
            x["_END_C"] = x["_ENDTC"].fillna(nxt - pd.Timedelta(days=1))

        g = x.groupby("USUBJID").agg(
            TRTSDT=("_STDTC", "min"),
            TRTEDT=("_END_C", "max"),
            n_ex=("_SEQ", "size"),
        )
        if end_rule == "imp":
            # ★ 关键一步:末次给药记录**没有结束日**时,"最后一次给药"是
            #   max(_END_C) 算不出来的(它只会给出倒数第二条的结束日)。
            #   这种人意味着"治疗仍在进行",正确做法是取参考结束日。
            last = x.groupby("USUBJID").tail(1).set_index("USUBJID")
            still_open = last["_ENDTC"].isna()
            g.loc[still_open.reindex(g.index).fillna(False), "TRTEDT"] = \
                ref_end.reindex(g.index)[still_open.reindex(g.index).fillna(False)]

    g["TRTDUR"] = (g["TRTEDT"] - g["TRTSDT"]).dt.days + 1
    return g


def derive_actual_trt(ex: pd.DataFrame, rule: str) -> pd.Series:
    """派生 TRT01A(实际治疗组)。

    ============  ==================================================
    ``'arm'``     直接取随机化臂(DM.ARM)—— 本项目 ADSL 的口径
    ``'actarm'``  取 DM.ACTARM(申办方已按实际给药派生好的值)
    ``'maxdose'`` 按受试者实际接受的**最高剂量**判断
    ============  ==================================================

    ★ 三者会给出**不同答案**,本案例的 12 位受试者就是活教材:
      随机化到 Xanomeline High Dose,但在滴定期(第 1–14 天)就退出了,
      全程只吃到 54 mg。于是:

      - 随机化臂 / 我们的 'arm' 口径 → High Dose(254/254 与参考一致)
      - DM.ACTARM                  → Low Dose(242/254)
      - 最高剂量                   → Low Dose

      **两种口径都说得通**,但必须与 SAP 一致,并写进 ADRG。
      这也是"实际治疗组"这个变量在不同项目里含义不同的原因。
    """
    if rule == "maxdose":
        e = ex.copy()
        e["_D"] = pd.to_numeric(e["EXDOSE"], errors="coerce")
        mx = e.groupby("USUBJID")["_D"].max()
        return mx.map({0.0: "Placebo", 54.0: "Xanomeline Low Dose",
                       81.0: "Xanomeline High Dose"})
    return None                    # 'arm' / 'actarm' 走主流程里的直接映射


# ==========================================================================
# 3. 处置(Disposition):停药原因 / 死亡
# ==========================================================================
def derive_disposition(ds: pd.DataFrame) -> pd.DataFrame:
    """从 DS 域派生 DCDECOD / DSRAEFL。

    关键点一:DS 里同时有 ``DISPOSITION EVENT``(处置事件)和
    ``OTHER EVENT``(访视记录)。**只取处置事件**才算得对,
    否则会把 "FINAL LAB VISIT" 之类当成停药原因。

    关键点二:``DSRAEFL`` 不是"有没有停药原因",而是
    **"是否因不良事件而停药"**。本项目口径是 ``DCDECOD == 'ADVERSE EVENT'``
    (92 人)。若按"DCDECOD 非缺失就置 Y"来写会得到 144 人 ——
    这不是"差一点",而是**口径完全错了**。

    关键点三(pandas 专属陷阱):``out.loc[mask, col] = value`` 里
    如果 mask 定位到的行**根本不在 out 的索引里**(比如"完成研究"的受试者
    压根没进非完成子集),这条赋值会**静默失效**,不报任何错。
    SAS 里你至少会看到一个 note。所以这里先 ``reindex`` 补齐索引。
    """
    disp = ds[ds["DSCAT"].astype(str).str.strip() == "DISPOSITION EVENT"].copy()
    non_dc = {"COMPLETED", "SCREEN FAILURE"}          # 非"停药事件"
    d = disp[~disp["DSDECOD"].isin(non_dc)].copy()

    out = d.groupby("USUBJID").agg(DCDECOD=("DSDECOD", "first"),
                                  _DCDTC=("DSDTC", "max"))
    # ★ 先补全索引,否则下面的 .loc 赋值会静默丢失
    out = out.reindex(pd.Index(sorted(set(ds["USUBJID"]))).rename("USUBJID"))

    comp = disp.loc[disp["DSDECOD"] == "COMPLETED", "USUBJID"].unique()
    out.loc[out.index.isin(comp), "DCDECOD"] = "COMPLETED"

    out["DSRAEFL"] = pd.NA
    out.loc[out["DCDECOD"] == "ADVERSE EVENT", "DSRAEFL"] = "Y"
    return out


def derive_death(dm: pd.DataFrame, ds: pd.DataFrame) -> pd.DataFrame:
    """DTHFL:DS 里出现 DEATH 处置事件即为 'Y'。

    DM 里也有 ``DTHFL``,但**权威来源是 DS 的处置事件**;
    两者不一致时应以 DS 为准并记录 QC 问题。
    """
    disp = ds[ds["DSCAT"].astype(str).str.strip() == "DISPOSITION EVENT"]
    died = set(disp.loc[disp["DSDECOD"] == "DEATH", "USUBJID"])
    out = pd.DataFrame(index=pd.Index(sorted(set(dm["USUBJID"])), name="USUBJID"))
    out["DTHFL"] = pd.NA
    out.loc[out.index.isin(died), "DTHFL"] = "Y"
    # 与 DM 的声明值交叉核对(QC)
    out["_DM_DTHFL"] = dm.set_index("USUBJID")["DTHFL"].reindex(out.index)
    return out


# ==========================================================================
# 4. 访视派生:VISNUMEN / 完成周标记
# ==========================================================================
def derive_visits(vs_bp: pd.DataFrame, subj: pd.Index) -> pd.DataFrame:
    """从访视数据派生 ``VISNUMEN``(最后一次计划访视的编号)。

    ★ 这里要说清一件很要紧的事:
      **ADSL 里的 VISNUMEN 不是"最后一次访视的编号"那么简单。**
      本项目的定义是"最后一次**疗效评估**访视的编号",
      它依赖疗效数据集(ADAS-Cog),而那份数据**不在本案例可用的域里**。
      所以这里只能做**最佳努力的近似**:取计划访视(VISITNUM 1–12)的最大值。
      结果 234/254 与参考一致 —— 另外 20 位是提前终止的受试者,
      他们的 ADSL 值比访视数据大 1(终止访视单独计数)。

      ★ 结论:当你需要的数据不在手上时,正确做法是**去要数据**,
        而不是用一个"看起来像"的近似值悄悄顶上。
        本案例会把这个差异**明确报告出来**,而不是掩盖它。
    """
    v = vs_bp.copy()
    v["_V"] = pd.to_numeric(v["VISITNUM"], errors="coerce")
    v = v[v["_V"].between(1, 12)]                     # 只看在治疗期内的计划访视
    mx = v.groupby("USUBJID")["_V"].max()
    return pd.DataFrame({"VISNUMEN": mx.reindex(subj)})


# ==========================================================================
# 5. 主派生流程
# ==========================================================================
def build_adsl(src: dict[str, pd.DataFrame], end_rule: str = "imp",
               trt01a_rule: str = "arm", verbose: bool = True) -> pd.DataFrame:
    dm, ex, ds, adae = src["dm"], src["ex"], src["ds"], src["adae"]

    # ---- 第一步:确定分析人群 -------------------------------------------
    # ADSL 只放"随机化"的受试者(Screen Failure 不进分析人群)
    base = dm[~dm["ARM"].astype(str).str.strip().isin(EXCLUDED_ARMS)].copy()
    base = base.sort_values("USUBJID").reset_index(drop=True)
    if verbose:
        print(f"  DM 共 {len(dm)} 位受试者;排除 Screen Failure 后 "
              f"进入 ADSL 的:{len(base)} 位")
        print("  (对照:参考 ADSL 共 254 位 —— 数字一致说明人群口径对了)")

    adsl = base[["STUDYID", "USUBJID", "SUBJID", "SITEID", "AGE", "AGEU",
                 "SEX", "RACE", "ETHNIC", "COUNTRY",
                 "RFSTDTC", "RFENDTC", "ACTARM", "ARM"]].copy()

    # ---- 第二步:治疗组(计划 vs 实际)---------------------------------
    # ★ 报表里**通常用 TRT01P**(分母是随机化人群,用实际治疗会自相矛盾)。
    adsl["TRT01P"] = adsl["ARM"]
    adsl["TRT01PN"] = adsl["TRT01P"].map(TRT_CODE)

    if trt01a_rule == "arm":
        adsl["TRT01A"] = adsl["ARM"]
    elif trt01a_rule == "actarm":
        adsl["TRT01A"] = adsl["ACTARM"]
    else:                                    # maxdose
        md = derive_actual_trt(ex, "maxdose")
        adsl["TRT01A"] = md.reindex(adsl["USUBJID"].to_numpy()).to_numpy()
    adsl["TRT01AN"] = adsl["TRT01A"].map(TRT_CODE)
    adsl["TRT01P"] = to_categorical(adsl["TRT01P"], TRT_LEVELS)

    # ---- 第三步:暴露 ---------------------------------------------------
    ref_end = pd.to_datetime(base.set_index("USUBJID")["RFENDTC"], errors="coerce")
    expo = derive_exposure(ex, end_rule, ref_end)
    adsl = adsl.merge(expo, left_on="USUBJID", right_index=True, how="left")

    # ---- 第四步:人群标记 ----------------------------------------------
    adsl["SAFFL"] = np.where(adsl["TRTSDT"].notna(), "Y", "N")
    # ITTFL:随机化即入组(本项目里与 SAFFL 完全一致,但要分开写 ——
    # 一旦出现"随机化后未给药"的受试者,两者立刻分叉)
    adsl["ITTFL"] = np.where(
        pd.Categorical(adsl["TRT01P"]).isin(TRT_LEVELS), "Y", "N")
    # EFFFL:需要"基线 + 至少一次基线后疗效评估",依赖 ADAS-Cog 数据。
    # ★ 不在可用域里 → 显式置为缺失,并在验证环节标注"不可派生"。
    adsl["EFFFL"] = pd.Series(pd.NA, index=adsl.index, dtype="object")

    # ---- 第五步:处置 / 死亡 -------------------------------------------
    disp = derive_disposition(ds)
    adsl = adsl.merge(disp[["DCDECOD", "DSRAEFL"]],
                      left_on="USUBJID", right_index=True, how="left")
    death = derive_death(dm, ds)
    adsl = adsl.merge(death[["DTHFL", "_DM_DTHFL"]],
                      left_on="USUBJID", right_index=True, how="left")

    # ---- 第六步:访视 → VISNUMEN → 完成周标记 ---------------------------
    vis = derive_visits(src["vs_bp"], pd.Index(adsl["USUBJID"].to_numpy()))
    vis.index = adsl.index
    adsl["VISNUMEN"] = vis["VISNUMEN"]
    for flag, vnum in COMP_VISIT.items():
        adsl[flag] = np.where(adsl["VISNUMEN"] >= vnum, "Y", "N")

    # ---- 第七步:基线值(身高 / 体重 → BMI)----------------------------
    # ★ DM 里根本没有身高体重 —— 它们来自 VS 域。
    # ★ 另一个坑:VS.VSSTRESN 是"标准单位下的全精度值",
    #   体重 120 LB → 54.43 kg;而 ADSL 的 WEIGHTBL 是 ROUND(., 0.1) 的 54.4。
    #   直接拿 VSSTRESN 去比会 250/254 不一致 —— **先舍入再比**。
    anth = src.get("vs_anthrop")
    if anth is not None:
        a = anth.copy()
        a["_N"] = pd.to_numeric(a["VSSTRESN"], errors="coerce")
        a["_CODE"] = a["VSTESTCD"].astype(str).str.strip()
        a["_VISITN"] = pd.to_numeric(a["VISITNUM"], errors="coerce")
        blfl = a["VSBLFL"].astype("string").str.strip()
        a["_BLFL"] = blfl.mask(blfl.isin(["", "nan", "None", "<NA>"]))

        # 身高:本研究只在筛选期测一次 → 取该受试者的唯一(最后一次)记录
        h = (a.loc[a["_CODE"] == "HEIGHT"].sort_values("_VISITN")
               .drop_duplicates("USUBJID", keep="last").set_index("USUBJID")["_N"])
        # 体重:优先取基线访视(VSBLFL='Y');没有该标记时退回最早访视
        w_bl = a[(a["_CODE"] == "WEIGHT") & (a["_BLFL"] == "Y")]
        if len(w_bl):
            w = w_bl.drop_duplicates("USUBJID").set_index("USUBJID")["_N"]
        else:
            w = (a.loc[a["_CODE"] == "WEIGHT"].sort_values("_VISITN")
                   .drop_duplicates("USUBJID").set_index("USUBJID")["_N"])

        anc = pd.DataFrame({"_H": h, "_W": w}).reindex(adsl["USUBJID"].to_numpy())
        anc.index = adsl.index
        # 报表里这两列按 1 位小数呈现(与 ADSL 的口径一致)
        adsl["HEIGHTBL"] = sas_round_series(anc["_H"], 1)
        adsl["WEIGHTBL"] = sas_round_series(anc["_W"], 1)
        # ★ BMI 的精度顺序:
        #   本项目参考 ADSL 是「先舍入到 1 位的 W/H,再算 BMI,最后再舍入」,
        #   与"用全精度 W/H 算完再舍入"会差 25 个人。
        #   两种都"没错",但结果不同 —— 这就是为什么口径要写死在 ADRG 里。
        adsl["BMIBL"] = sas_round_series(
            adsl["WEIGHTBL"] / (adsl["HEIGHTBL"] / 100) ** 2, 1)
    else:
        for c in ("HEIGHTBL", "WEIGHTBL", "BMIBL"):
            adsl[c] = pd.NA

    adsl["BMIBLGR1"] = np.select(
        [adsl["BMIBL"] < 25, adsl["BMIBL"] < 30], ["<25", "25-<30"], default=">=30")
    adsl.loc[adsl["BMIBL"].isna(), "BMIBLGR1"] = pd.NA

    # ---- 第八步:年龄分组(复用 clinic.derive 里显式写死边界的实现)------
    adsl = derive_agegr1(adsl, age_var="AGE", out_var="AGEGR1", num_var="AGEGR1N")
    adsl["AGEGR1"] = to_categorical(adsl["AGEGR1"], ["<65", "65-80", ">80"])
    adsl["RACEN"] = adsl["RACE"].map(RACEN_MAP)

    # ---- 第九步:安全性补充(首次 TEAE = 治疗中出现的不良事件)----------
    if "TRTEMFL" in adae.columns:
        teae = adae[adae["TRTEMFL"].astype(str).str.strip() == "Y"].copy()
        teae["_ASTDT"] = pd.to_datetime(teae["ASTDT"], errors="coerce")
        first = teae.groupby("USUBJID").agg(
            TRTEMFL_first=("_ASTDT", "min"), TRTEMFL_n=("_ASTDT", "size"))
        adsl = adsl.merge(first, left_on="USUBJID", right_index=True, how="left")
        adsl["TRTEMFL"] = np.where(adsl["TRTEMFL_first"].notna(), "Y", "N")

    # ---- 排序列(模拟 SAS 的 KEEP 顺序)--------------------------------
    order = [
        "STUDYID", "USUBJID", "SUBJID", "SITEID", "COUNTRY",
        "AGE", "AGEU", "AGEGR1", "AGEGR1N", "SEX", "RACE", "RACEN", "ETHNIC",
        "ARM", "ACTARM", "TRT01P", "TRT01PN", "TRT01A", "TRT01AN",
        "TRTSDT", "TRTEDT", "TRTDUR",
        "SAFFL", "ITTFL", "EFFFL", "COMP8FL", "COMP16FL", "COMP24FL",
        "DCDECOD", "DSRAEFL", "DTHFL",
        "RFSTDTC", "RFENDTC", "VISNUMEN",
        "WEIGHTBL", "HEIGHTBL", "BMIBL", "BMIBLGR1",
        "TRTEMFL", "TRTEMFL_n", "TRTEMFL_first",
    ]
    return adsl[[c for c in order if c in adsl.columns]].copy()


# ==========================================================================
# 6. 双编程验证:与参考 ADSL 逐变量比对
# ==========================================================================
def _norm(s: pd.Series) -> pd.Series:
    """把值标准化后再比 —— 否则 ``'2013-07-14'`` 与 ``Timestamp`` 会被判为不等。

    ★ 这里埋着一个很典型的坑 ★

    ``clinic.io.clean_missing()`` 会把所有字符列转成 pandas 的
    **StringDtype**,而**不是** ``object``。如果判断 dtype 时只写
    ``s.dtype == "object"``,这些列就会掉进 ``pd.to_numeric`` 分支 ——
    整列变成 NaN,比对报告显示"100% 不一致",但数据其实完全正确。

    教训:**比对工具本身也需要被验证**。看到"全表 0% 匹配"时,
    第一反应应该是"我的比对代码有问题",而不是"我的派生逻辑有问题"。
    """
    if pd.api.types.is_numeric_dtype(s) and not pd.api.types.is_bool_dtype(s):
        return pd.to_numeric(s, errors="coerce")
    if pd.api.types.is_datetime64_any_dtype(s):
        return s.dt.strftime("%Y-%m-%d")
    return s.astype("string").str.strip().replace({"": pd.NA})


# 这些变量**无法**从本案例可用的域派生出来,必须单独标注,
# 否则报告里会显示成"0% 匹配",看起来像是实现错了。
NON_DERIVABLE = {
    "EFFFL": "需疗效数据(ADAS-Cog):基线 + 至少一次基线后评估,本案例无此数据",
}

# 口径正确、但**所需源数据不完整**,只能做最佳努力近似。
# ★ 这类差异必须显式报告出来,绝不能悄悄放过。
LIMITED = {
    "VISNUMEN": "需疗效评估访视数据(ADAS-Cog);本案例用计划访视 VISITNUM 1–12 近似",
}


def validate(derived: pd.DataFrame, reference: pd.DataFrame,
             check_vars: list[str]) -> pd.DataFrame:
    """逐变量比对派生结果与参考实现,返回匹配率表。

    这是**双编程(Double Programming)**的最小可用版本:
    独立实现 → 比对 → 逐变量报告匹配率 → 定位差异根因。

    判定分四档,**不要把所有不一致都当成 bug**:

    ==================  ====================================================
    ``一致``            完全一致
    ``近似``            匹配率 ≥ 95%,口径正确但有边界差异,需记录
    ``近似·数据受限``   口径正确,但所需源数据不在手上,只能逼近
    ``不可派生``        所需源数据完全缺失,**不应给出任何值**
    ``不一致``          必须定位根因并修复
    ==================  ====================================================
    """
    d = derived.set_index("USUBJID")
    r = reference.set_index("USUBJID")
    idx = d.index.union(r.index)
    d, r = d.reindex(idx), r.reindex(idx)

    rows = []
    for var in check_vars:
        if var not in d.columns or var not in r.columns:
            rows.append({"变量": var, "匹配数": 0, "总数": len(idx),
                         "匹配率": np.nan, "判定": "不可比", "说明": "变量缺失"})
            continue
        a, b = _norm(d[var]), _norm(r[var])
        both_na = a.isna() & b.isna()          # 两边同为缺失 → 算一致
        n_eq, n_tot = int(((a == b) | both_na).sum()), len(idx)
        rate = n_eq / n_tot * 100
        if var in NON_DERIVABLE:
            verdict, note = "不可派生", NON_DERIVABLE[var]
        elif var in LIMITED:
            verdict, note = "近似·数据受限", LIMITED[var]
        elif n_eq == n_tot:
            verdict, note = "一致", ""
        elif rate >= 95:
            verdict, note = "近似", f"{n_tot - n_eq} 条不一致(见根因分析)"
        else:
            verdict, note = "不一致", f"{n_tot - n_eq} 条不一致(见根因分析)"
        rows.append({"变量": var, "匹配数": n_eq, "总数": n_tot,
                     "匹配率": rate, "判定": verdict, "说明": note})
    return pd.DataFrame(rows)


def show_mismatch(derived: pd.DataFrame, reference: pd.DataFrame,
                  var: str, limit: int = 6) -> None:
    """把不一致的记录打出来 —— 定位差异根因的唯一办法。"""
    d, r = derived.set_index("USUBJID"), reference.set_index("USUBJID")
    idx = d.index.union(r.index)
    a, b = _norm(d.reindex(idx)[var]), _norm(r.reindex(idx)[var])
    bad = ~((a == b) | (a.isna() & b.isna()))
    sub = pd.DataFrame({"USUBJID": idx[bad],
                        "派生值": a[bad].astype(object).to_numpy(),
                        "参考值": b[bad].astype(object).to_numpy()})
    print(sub.head(limit).to_string(index=False))
    if len(sub) > limit:
        print(f"  … 另有 {len(sub) - limit} 条")


# ==========================================================================
# main
# ==========================================================================
def main() -> None:
    ap = argparse.ArgumentParser(description="从 SDTM 衍生 ADSL 并做双编程验证")
    ap.add_argument("--end-rule", default="imp", choices=["ex", "imp", "ref"],
                    help="治疗结束日口径:ex=直接用 EXENDTC / imp=补全缺失(默认)/ ref=参考结束日")
    ap.add_argument("--trt01a-rule", default="arm", choices=["arm", "actarm", "maxdose"],
                    help="实际治疗组口径:arm=随机化臂(默认)/ actarm=DM.ACTARM / maxdose=最高剂量")
    ap.add_argument("--no-validate", action="store_true", help="跳过双编程验证")
    args = ap.parse_args()

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

    print("=" * 88)
    print("  案例 05 · 从 SDTM 衍生 ADSL(受试者水平分析数据集)")
    print("=" * 88)

    section("1. 载入源数据")
    src = load_sources(data_dir)
    for k, v in src.items():
        print(f"  {k.upper():12s} {v.shape[0]:6d} 行 × {v.shape[1]:3d} 列")
    print("\n  ★ 注意 DM 有 306 行,但 ADSL 只应该有 254 行 —— 因为 ADSL 是")
    print("    **分析人群**,Screen Failure 不进分析人群。")
    print("  ★ 还要注意:DM 里**没有**身高体重 —— BMI 得从 VS 域派生。")

    section(f"2. 派生 ADSL(结束日口径={args.end_rule},实际治疗组口径={args.trt01a_rule})")
    adsl = build_adsl(src, args.end_rule, args.trt01a_rule)
    print(f"\n  派生完成:{adsl.shape[0]} 行 × {adsl.shape[1]} 列")
    print("\n  治疗组分布(TRT01P):")
    for k, v in adsl["TRT01P"].value_counts().reindex(TRT_LEVELS).items():
        print(f"    {k:24s} {v:4d}")
    dur = pd.to_numeric(adsl["TRTDUR"], errors="coerce")
    print(f"\n  治疗暴露:TRTSDT 非缺失 {adsl['TRTSDT'].notna().sum()} 位,"
          f"TRTEDT 非缺失 {adsl['TRTEDT'].notna().sum()} 位")
    print(f"             TRTDUR 中位数 {dur.median():.0f} 天,"
          f"最短 {dur.min():.0f} 天,最长 {dur.max():.0f} 天")

    # ---- 三种结束日口径的对照(本案例第 1 个教学点)---------------------
    section("3. 三种治疗结束日口径 = 三个不同答案")
    ref_adsl = cio.read_any(data_dir / "adsl.csv")
    ref = ref_adsl.set_index("USUBJID")
    ref_end = pd.to_datetime(src["dm"].set_index("USUBJID")["RFENDTC"], errors="coerce")
    refT = pd.to_datetime(ref["TRTEDT"], errors="coerce")
    print(f"  {'口径':<8}{'说明':<46}{'与参考 TRTEDT 一致':>20}")
    print("  " + "-" * 76)
    for rule, desc in [
        ("ex", "TRTEDT = max(EXENDTC)(字面做法)"),
        ("imp", "缺失 EXENDTC→下一条起始日−1;末条未闭合→RFENDTC"),
        ("ref", "TRTEDT = RFENDTC(完全放弃 EX)"),
    ]:
        g = derive_exposure(src["ex"], rule, ref_end)
        a = g["TRTEDT"].reindex(refT.index)
        n = int(((a == refT) | (a.isna() & refT.isna())).sum())
        mark = "   ← 本项目口径" if rule == "imp" else ""
        print(f"  {rule:<8}{desc:<46}{n:>10d} / 254{mark}")
    print("""
  ★ 差的都是同一种人:**EX 末次给药记录没有 EXENDTC**(治疗仍在进行 / 记录未闭合)。
    照字面 max(EXENDTC) 会把他们的治疗时长严重截断。
    'ref' 口径看起来"最省事",但它等于把 EX 域整个丢掉 —— 127/254。

  ★ 这就是为什么 ADaM 派生必须写清规则,并且**必须双编程验证**:
    "我把 TRTEDT 算出来了" 这句话,离开口径就没有意义。""")

    # ---- 实际治疗组口径对照(第 2 个教学点)-----------------------------
    section("4. 三种 TRT01A 口径:同一批人,三个身份")
    print(f"  {'口径':<12}{'说明':<40}{'与参考 TRT01A 一致':>20}")
    print("  " + "-" * 76)
    refA = ref["TRT01A"].astype("string")
    for rule, desc in [("arm", "取随机化臂 DM.ARM"),
                       ("actarm", "取 DM.ACTARM(申办方已派生的实际治疗)"),
                       ("maxdose", "按实际接受的最高剂量判断")]:
        tmp = build_adsl(src, "imp", rule, verbose=False)
        a = tmp.set_index("USUBJID")["TRT01A"].astype("string").reindex(refA.index)
        n = int((a == refA).sum())
        mark = "   ← 本项目口径" if rule == "arm" else ""
        print(f"  {rule:<12}{desc:<40}{n:>10d} / 254{mark}")
    print("""
  ★ 不一致的 12 位受试者是这样的:
    随机化到 Xanomeline High Dose(应服 81 mg),但在**滴定期**内退出了
    (最短 1 天、最长 14 天),全程只吃到 54 mg 的起始剂量。
      · 随机化臂口径 → High Dose(本项目参考 ADSL 的做法)
      · ACTARM / 最高剂量口径 → Low Dose
    两种都说得通,**但必须与 SAP 一致并写进 ADRG**。
    这也是"实际治疗组"这个变量在不同项目里含义不同的根本原因。""")

    # ---- 双编程验证 ----------------------------------------------------
    section("5. 双编程验证(与参考 ADSL 逐变量比对)")
    rep = None
    if args.no_validate:
        print("  已跳过(--no-validate)")
    else:
        # 口径对齐:验证时统一用与参考一致的口径,
        # 否则差异来自"口径不同"而不是"实现不同",比对就失去意义
        py_adsl = build_adsl(src, "imp", "arm", verbose=False)
        check_vars = [
            "AGE", "AGEGR1", "AGEGR1N", "SEX", "RACE", "RACEN", "ETHNIC",
            "TRT01P", "TRT01PN", "TRT01A", "TRT01AN",
            "TRTSDT", "TRTEDT", "TRTDUR",
            "SAFFL", "ITTFL", "EFFFL", "COMP8FL", "COMP16FL", "COMP24FL",
            "DCDECOD", "DSRAEFL", "DTHFL", "VISNUMEN",
            "WEIGHTBL", "HEIGHTBL", "BMIBL", "BMIBLGR1",
        ]
        rep = validate(py_adsl, ref_adsl, check_vars)
        show = rep.copy()
        show["匹配率"] = show["匹配率"].map(
            lambda x: "-" if pd.isna(x) else f"{x:6.2f}%")
        print(show.to_string(index=False))

        cnt = rep["判定"].value_counts().to_dict()
        order_v = ["一致", "近似", "近似·数据受限", "不可派生", "不一致", "不可比"]
        print("\n  汇总:" + " | ".join(
            f"{k} {cnt[k]}" for k in order_v if k in cnt) + f"   (共 {len(rep)} 个变量)")
        print("  ★ 报告口径:'近似' 和 '数据受限' 不是 bug,但**必须写进 ADRG**;")
        print("    只有 '不一致' 才代表实现有问题。")

        # ---- 逐项根因分析 ----
        # TRTEDT 用 'ex' 口径来演示,才能把差异"演"出来 —— 这正是
        # 双编程验证的价值:它把口径问题变成了**可量化的差异**。
        print("\n  " + "-" * 74)
        print("  【根因分析 · TRTEDT】下面故意换成 'ex' 字面口径,看差异长什么样")
        print("  " + "-" * 74)
        bad_ex = build_adsl(src, "ex", "arm", verbose=False)
        show_mismatch(bad_ex, ref_adsl, "TRTEDT", limit=4)
        print("""
  这 6 位的共同点:**EX 末次给药记录没有 EXENDTC**(治疗仍在进行)。
    · 01-704-1233 / 01-705-1031 / 01-705-1303 / 01-705-1377
      → 最后一条给药记录只填了开始日,没填结束日
    · 01-705-1018 / 01-705-1382
      → 只有一条给药记录,且结束日为空
  照字面 max(EXENDTC) 会拿到"倒数第二条"的结束日,把治疗时长严重截断
  (例如 01-704-1233:字面口径 15 天 vs 实际 116 天)。
  改用 'imp' 口径后 → 254/254 完全一致。""")

        print("\n  " + "-" * 74)
        print("  【根因分析 · VISNUMEN / COMP8FL】")
        print("  " + "-" * 74)
        show_mismatch(py_adsl, ref_adsl, "COMP8FL", limit=4)
        print("""
  VISNUMEN 在本项目里是"最后一次**疗效评估**访视的编号",
  依赖 ADAS-Cog 数据集 —— **不在本案例可用的域里**。
  本案例只能从访视数据做最佳努力近似(234/254)。
  不一致的 20 位全是提前终止的受试者,其 ADSL 值比访视数据大 1
  (终止访视单独计数)。

  COMP8FL 的口径同样值得记一笔:
    · 本案例(正确口径)= 最后一次疗效访视号 >= 8
    · "治疗时长 >= 56 天"(直觉口径)→ 只有 183 人,比参考少 7 人
      例如 01-704-1241 只治疗了 46 天,但他完成了第 8 周访视 → 参考是 Y。
    ★ 在临床统计里,"完成 8 周治疗"通常指**完成了第 8 周访视**,
      不是"活了 56 天"。凭直觉写代码在这里一定会错。

  ★ 当你需要的数据不在手上时,正确做法是**去要数据**,
    而不是用一个"看起来像"的近似值悄悄顶上。
    一个编出来的标记比一个空值危险得多。""")

    section("6. 输出文件")
    stamp = pd.Timestamp.now().strftime("%Y-%m-%d %H:%M")
    out_csv = out_dir / "adsl_derived.csv"
    adsl.to_csv(out_csv, index=False, encoding="utf-8-sig")
    print(f"  派生 ADSL:{out_csv}  ({adsl.shape[0]} 行 × {adsl.shape[1]} 列)")

    if rep is not None:
        rep_out = rep.copy()
        rep_out.insert(0, "生成时间", stamp)
        rep_out.insert(1, "结束日口径", "imp")
        rep_out.insert(2, "TRT01A口径", "arm")
        rp = out_dir / "adsl_validation.csv"
        rep_out.to_csv(rp, index=False, encoding="utf-8-sig")
        rpt.to_html(
            rep_out, out_dir / "adsl_validation.html",
            title="ADSL 双编程验证报告(Python 派生 vs 参考实现)",
            subtitle=f"生成时间:{stamp};逐变量比对受试者水平数据集(254 位受试者)",
            footnote=(
                "匹配率 = 值完全一致(含【两边同为缺失】的情形)的受试者比例。"
                "标注为【不可派生】的变量表示所需源数据不在本次可用的域中,"
                "这**不是**实现错误。其余任何 &lt; 100% 的变量都必须定位根因并记录在 ADRG 中。"
            ),
            label_cols=("变量",),
        )
        print(f"  验证报告:{rp}")
        print(f"  HTML 版本:{out_dir / 'adsl_validation.html'}")

    section("7. 从 SAS 到 Python:这一段到底对应什么")
    print("""  下面这张表是给"写惯了 SAS 的人"看的 —— 左侧是你脑子里想的东西。

  +------------------------------------------------------------+----------------------------------------+
  | SAS 里你会写                                                | Python 里对应什么                      |
  +------------------------------------------------------------+----------------------------------------+
  | DATA ADSL; SET DM; IF ARM NOT IN (...);                     | dm[~dm["ARM"].isin(EXCLUDED_ARMS)]     |
  | PROC SORT DATA=EX; BY USUBJID EXSEQ;                        | ex.sort_values(["USUBJID","EXSEQ"])    |
  | RETAIN TRTSDT; TRTSDT=MIN(TRTSDT,EXSTDTC);                  | groupby("USUBJID").agg(min, max)       |
  |   TRTEDT=MAX(TRTEDT,EXENDTC);                               |   ← 不需要 RETAIN,聚合一步到位        |
  | TRTDUR = TRTEDT - TRTSDT + 1;                               | (TRTEDT-TRTSDT).dt.days + 1            |
  | MERGE ADSL(IN=A) _EX; BY USUBJID; IF A;                     | adsl.merge(expo, how="left")           |
  | CLASS 顺序靠 order= / preloadfmt                            | pd.Categorical(categories=TRT_LEVELS)  |
  | ROUND(BMI, 0.1)                                             | sas_round_series(BMI, 1) ★不是 round() |
  | PROC COMPARE BASE=ADSL COMPARE=ADSL_PY;                     | validate() —— 见本案例第 5 节          |
  +------------------------------------------------------------+----------------------------------------+

  ★ 四个最容易踩的坑:
    1) SAS 的 ROUND(x, 0.1) 是「按单位舍入 + 四舍五入」,
       Python 的 round(x, 1) 是「按小数位 + 银行家舍入」——
       round(2.5) 会给 2。必须用 clinic.derive.sas_round_series。
    2) SAS 里空字符串就是缺失,IF X = '' 能命中;
       pandas 里空串是合法值,必须 clean_missing()。
    3) SAS 的 MERGE 按 BY 值匹配且**自动去重主键**;
       pandas 的 merge 是"笛卡尔积后再取"——
       右表主键有重复时行数会**成倍膨胀**,必须先 drop_duplicates。
    4) pandas 的 .loc[mask, col] = value 在 mask 定位到
       **不存在的索引标签**时会**静默失效**,不报错。
       SAS 至少会给你一个 note。做"补齐后再赋值"时一定要先 reindex。""")


if __name__ == "__main__":
    main()