3. 临床数据清洗与标准化

临床分析报告自动化的第一道真正门槛,不是画图,也不是统计,而是把来自 EDC、实验室、中心药房、外部供应商的原始数据整理成“可分析”的形态。CDISC 的 SDTM 与 ADaM 给出了两套互补的思路:SDTM 负责把采集到的原始观测按标准域(DM、AE、LB、VS、EX 等)组织成提交级结构,ADaM 则在 SDTM 之上构建面向分析的数据集(如 ADSL、ADAE、ADLB)。本节不追求完整实现某个域,而是抽取其中对自动化最关键的五个动作:变量重命名、类型转换、单位统一、日期解析、受试者标识管理。

先建立“标准变量字典”,再谈清洗

清洗最容易失控的地方,是边写代码边改列名,最后没人说得清 SUBJID、subject_id、受试者编号 是不是同一个东西。正确做法是先落一份变量映射字典,把“源列名 → 标准变量名 → 目标类型 → 单位 → 是否必填”固定下来。例如源数据里 pt_no 映射为 USUBJID 的组成部分,sex 映射为 SEX,visit_date 映射为 SVSTDTC。字典一旦确定,后续所有清洗函数都只认标准名,源数据的差异被隔离在映射层。

import pandas as pd

VAR_MAP = {
    "pt_no":      ("SUBJID",   "string"),
    "sex":        ("SEX",      "category"),
    "age":        ("AGE",      "Int64"),
    "visit_date": ("SVSTDTC",  "datetime64[ns]"),
    "hgb":        ("HGB",      "float64"),
    "hgb_unit":   ("HGBU",     "category"),
}

def rename_and_cast(df, var_map):
    out = df.rename(columns={k: v[0] for k, v in var_map.items()})
    for src, (std, dtype) in var_map.items():
        if std in out.columns:
            out[std] = out[std].astype(dtype)
    return out

这里用 Int64 而不是 int64,是因为临床数据中年龄、剂量等字段常存在缺失,普通整型无法承载 NA。类型转换的目标不是“看起来整齐”,而是让后续比较、合并、排序不会因字符串与数值混用而静默出错。

单位统一:先归一,再换算

实验室数据是单位问题的重灾区。同一指标可能以 g/L、g/dL、mg/dL 出现。处理原则是:先确定一个分析基准单位,再把所有记录换算到该单位,并保留原始值与原始单位以便追溯。例如血红蛋白以 g/L 为基准,遇到 g/dL 则乘以 10。换算必须带条件判断,不能对空值或未知单位强行计算。

UNIT_FACTOR = {("g/L", "g/L"): 1.0,
               ("g/dL", "g/L"): 10.0,
               ("mg/dL", "g/L"): 0.01}

def normalize_unit(value, src_unit, target_unit):
    if pd.isna(value) or src_unit not in [u for u, _ in UNIT_FACTOR]:
        return pd.NA
    factor = UNIT_FACTOR.get((src_unit, target_unit))
    return value * factor if factor is not None else pd.NA

换算后应新增 HGB(标准单位值)与 HGBU(标准单位),同时保留 HGB_ORIG、HGBU_ORIG。这样既满足分析一致性,又能在核查时回答“这个值原来是多少”。

日期解析:ISO 8601 是共同语言

SDTM 中日期时间统一采用 ISO 8601,如 2024-03-15 或 2024-03-15T09:30。源数据却可能是 15/03/2024、2024.3.15、Mar 15, 2024。解析时不要依赖单一格式,而应显式列出候选格式并逐条尝试,失败则标记为待核查,而不是让 pandas 猜测后产生错误日期。

DATE_FORMATS = ["%Y-%m-%d", "%d/%m/%Y", "%Y.%m.%d", "%b %d, %Y"]

def parse_date(raw):
    if pd.isna(raw):
        return pd.NaT
    for fmt in DATE_FORMATS:
        try:
            return pd.to_datetime(raw, format=fmt)
        except (ValueError, TypeError):
            continue
    return pd.NaT

解析结果应同时输出标准字符串列(用于 SDTM 提交)和 datetime 列(用于计算访视间隔、暴露时长)。若某行解析失败,进入异常清单,由数据管理员回查,而不是被静默丢弃。

受试者标识管理:USUBJID 是主键

CDISC 要求每个受试者在全研究中拥有唯一标识 USUBJID,通常由研究编号与受试者编号拼接而成,如 STUDY01-001-0007。它的价值在于:跨域合并时不会因不同中心出现相同 SUBJID 而错配。构建时应统一补零、统一分隔符,并校验唯一性。

def build_usubjid(study_id, site_id, subj_id):
    return f"{study_id}-{site_id}-{str(subj_id).zfill(4)}"

df["USUBJID"] = df.apply(
    lambda r: build_usubjid(r["STUDYID"], r["SITEID"], r["SUBJID"]), axis=1)

assert df["USUBJID"].is_unique, "USUBJID 存在重复,需核查"

唯一性断言是自动化流程中的关键闸门。一旦重复,后续按受试者聚合的统计结果会整体失真,因此宁可让流程在此处失败,也不要带着重复主键继续跑。

临床数据清洗与标准化流水线(SDTM/ADaM 思路) 源数据 EDC 采集表 中心实验室 外部供应商 中心药房 列名/类型/单位/日期格式各异 变量映射字典 源列名 → 标准名 目标类型 目标单位 是否必填 隔离源差异,统一入口 清洗与标准化 ① 变量重命名 ② 类型转换(Int64/float) ③ 单位统一(g/L 基准) ④ 日期解析(ISO 8601) 标准化输出 USUBJID 主键 标准变量名 标准单位值 ISO 日期列 可追溯原始值 受试者标识管理 USUBJID = STUDYID - SITEID - SUBJID(补零至4位) 唯一性断言:重复即中断流程,防止聚合失真 异常清单 日期解析失败 单位未知 / 主键重复 关键原则 · 先定字典再清洗,源差异隔离在映射层 · 换算保留原始值与原始单位,保证可追溯 · 日期显式列格式,失败进异常清单而非静默丢弃 · USUBJID 唯一性是跨域合并与聚合统计的前提

把五个动作串成可复用的清洗函数

实际项目中,上述动作不应散落在多个脚本里。更稳妥的组织方式是定义一个 clean_domain(df, var_map, unit_rules, date_cols) 函数,按“重命名 → 类型转换 → 单位归一 → 日期解析 → 主键构建与校验”的顺序执行,并返回清洗后数据与异常清单两部分。异常清单至少包含:行号、受试者标识、问题字段、原始值、问题类型。这样数据管理员可以逐条回查,而不是在几千行数据里靠肉眼找问题。

建议在清洗函数中为每一步记录处理计数,例如“重命名 12 列、类型转换 8 列、单位换算 143 行、日期解析失败 3 行”。这些计数在流程日志中非常有用,能快速判断某次运行是否出现异常波动。

与 SDTM/ADaM 的衔接

清洗完成后的数据,应按域组织为接近 SDTM 的结构:DM 域保留 USUBJID、SEX、AGE 等;LB 域保留 USUBJID、LBTESTCD、LBORRES、LBSTRESN、LBSTRESU 等。ADaM 阶段再在此基础上派生分析变量,如 TRTSDT、TRTEDT、AVAL。本节建立的标准化层,正是 SDTM 与 ADaM 之间稳定衔接的地基:变量名统一、类型明确、单位一致、日期可计算、主键唯一,后续所有自动化报告才具备可重复执行的基础。

不要用 pd.to_datetime 的默认推断模式批量解析临床日期。当数据中同时存在 03/04/2024 这类歧义格式时,推断结果可能在不同运行环境或不同数据批次间发生变化,导致日期错位且难以察觉。显式指定格式列表是更安全的选择。