临床 Python 进阶路线图
⌕ /
路线图 › 工具包 clinic/ · 数据与报表

clinic/qc.py

qc.py · 326 行 · 11 KB
clinic.qc —— 数据质量检查规则
Python
"""
clinic.qc —— 数据质量检查规则

把临床编程里靠"人眼看日志 / 双编程比对"的检查,固化成**可重复运行的代码**。

设计要点
--------
- 每个检查返回结构化的 issue 列表(而不是 print),便于汇总成报告。
- 严重性分三级:``高``(必须修复)/ ``中``(需确认)/ ``信息``(记录状态)。
- 检查规则通过常量声明,便于按项目定制。

对应 SAS 场景
-------------
- ``PROC CONTENTS``  → ``profile``
- ``PROC SORT nodupkey`` → 主键唯一性检查
- 双编程 + ``PROC COMPARE`` → ``compare_frames``
"""

from __future__ import annotations

from pathlib import Path

import numpy as np
import pandas as pd

# --------------------------------------------------------------------------
# 检查规则配置
# --------------------------------------------------------------------------

# 各数据集的必需变量(按 CDISC 标准的最小集)
REQUIRED_VARS: dict[str, list[str]] = {
    # SDTM
    "dm": ["STUDYID", "DOMAIN", "USUBJID", "SUBJID", "RFSTDTC", "AGE", "SEX", "RACE"],
    "ae": ["STUDYID", "DOMAIN", "USUBJID", "AESEQ", "AEDECOD", "AEBODSYS", "AESTDTC"],
    "cm": ["STUDYID", "DOMAIN", "USUBJID", "CMSEQ", "CMDECOD", "CMSTDTC"],
    "ex": ["STUDYID", "DOMAIN", "USUBJID", "EXSEQ", "EXDOSE", "EXSTDTC"],
    "ds": ["STUDYID", "DOMAIN", "USUBJID", "DSSEQ", "DSDECOD"],
    "vs": ["STUDYID", "DOMAIN", "USUBJID", "VSSEQ", "VSTESTCD", "VSORRES"],
    "lb": ["STUDYID", "DOMAIN", "USUBJID", "LBSEQ", "LBTESTCD", "LBORRES"],
    # ADaM
    "adsl": ["STUDYID", "USUBJID", "SUBJID", "TRT01P", "TRT01A", "SAFFL"],
    "adae": ["USUBJID", "TRTEMFL", "AEDECOD", "AEBODSYS", "ASTDT"],
    "adtte": ["USUBJID", "PARAMCD", "AVAL", "CNSR"],
    "adlbc": ["USUBJID", "PARAMCD", "AVAL", "ANRIND"],
    "advs": ["USUBJID", "PARAMCD", "AVAL"],
    "adqsadas": ["USUBJID", "PARAMCD", "AVAL"],
}

# 主键(用于唯一性检查)
KEY_VARS: dict[str, list[str]] = {
    "dm": ["USUBJID"], "adsl": ["USUBJID"], "adtte": ["USUBJID", "PARAMCD"],
    "ae": ["USUBJID", "AESEQ"], "adae": ["USUBJID", "AESEQ"], "cm": ["USUBJID", "CMSEQ"],
    "ex": ["USUBJID", "EXSEQ"], "ds": ["USUBJID", "DSSEQ"], "vs": ["USUBJID", "VSSEQ"],
    "lb": ["USUBJID", "LBSEQ"], "adlbc": ["USUBJID", "PARAMCD", "AVISITN", "LBSEQ"],
    "advs": ["USUBJID", "PARAMCD", "AVISITN", "VSSEQ"],
}

# 受控术语(CDISC CT 的常用子集)
CONTROLLED_TERMS: dict[str, dict[str, list[str]]] = {
    "dm": {
        "SEX": ["M", "F", "U", "UNDIFFERENTIATED"],
        "DTHFL": ["Y", "N"],
        "RACE": ["WHITE", "BLACK OR AFRICAN AMERICAN",
                 "AMERICAN INDIAN OR ALASKA NATIVE", "ASIAN",
                 "NATIVE HAWAIIAN OR OTHER PACIFIC ISLANDER",
                 "OTHER", "MULTIPLE"],
    },
    "ae": {
        "AESEV": ["MILD", "MODERATE", "SEVERE"],
        "AESER": ["Y", "N"],
        "AEOUT": ["RECOVERED/RESOLVED", "RECOVERING/RESOLVING",
                  "NOT RECOVERED/NOT RESOLVED", "RECOVERED/RESOLVED WITH SEQUELAE",
                  "FATAL", "UNKNOWN"],
    },
    "adsl": {"SAFFL": ["Y", "N"], "ITTFL": ["Y", "N"], "EFFFL": ["Y", "N"]},
    "adae": {"TRTEMFL": ["Y", "N"], "AESEV": ["MILD", "MODERATE", "SEVERE"]},
}

# 日期逻辑检查:结束日期不得早于开始日期
DATE_PAIRS: dict[str, list[tuple[str, str]]] = {
    "ae": [("AESTDTC", "AEENDTC")],
    "cm": [("CMSTDTC", "CMENDTC")],
    "ex": [("EXSTDTC", "EXENDTC")],
    "adae": [("ASTDT", "AENDT")],
}


# --------------------------------------------------------------------------
# 工具
# --------------------------------------------------------------------------
def _issue(category: str, severity: str, detail: str) -> dict:
    return {"类别": category, "严重性": severity, "详情": detail}


def profile(df: pd.DataFrame, name: str = "数据集") -> pd.DataFrame:
    """快速数据体检(≈ ``PROC CONTENTS`` + 缺失概况)。

    Returns
    -------
    DataFrame
        每行一个变量:dtype / 缺失数 / 缺失率 / 唯一值数 / 示例值
    """
    rows = []
    for c in df.columns:
        s = df[c]
        nn = s.dropna()
        rows.append({
            "变量": c,
            "类型": str(s.dtype),
            "缺失数": int(s.isna().sum()),
            "缺失率%": round(float(s.isna().mean() * 100), 1),
            "唯一值数": int(s.nunique(dropna=True)),
            "示例值": ", ".join(str(v) for v in nn.unique()[:3]),
        })
    out = pd.DataFrame(rows)
    out.attrs["shape"] = df.shape
    out.attrs["name"] = name
    return out


def check_dataset(df: pd.DataFrame, name: str,
                  dataset_type: str = "auto") -> list[dict]:
    """对单个数据集执行标准质量检查。

    Parameters
    ----------
    dataset_type : ``'sdtm'`` / ``'adam'`` / ``'auto'``。
        ``'auto'`` 时按名称是否以 ``ad`` 开头判断。

    Returns
    -------
    list[dict]
        问题列表。每项含 ``类别`` / ``严重性`` / ``详情``。
        **空列表表示未发现"高"严重性问题。**
    """
    key = name.lower().strip()
    issues: list[dict] = []

    # 0) 数据规模(信息)
    issues.append(_issue("数据规模", "信息", f"{name}: {len(df)} 行 × {df.shape[1]} 列"))

    # 1) 必需变量存在性
    required = REQUIRED_VARS.get(key, [])
    missing = [v for v in required if v not in df.columns]
    if missing:
        issues.append(_issue("必需变量缺失", "高",
                             f"{name} 缺少必需变量 {missing}"))

    # 2) 主键唯一性
    keys = KEY_VARS.get(key)
    if keys:
        present = [k for k in keys if k in df.columns]
        if len(present) == len(keys):
            dup_mask = df.duplicated(subset=keys, keep=False)
            n_dup = int(dup_mask.sum())
            if n_dup:
                sample = (df.loc[dup_mask, keys].drop_duplicates().head(3)
                            .to_dict(orient="records"))
                issues.append(_issue(
                    "主键重复", "高",
                    f"{name} 主键 {keys} 有 {n_dup} 条重复记录;示例:{sample}"))
            else:
                issues.append(_issue("主键唯一性", "信息",
                                     f"{name} 主键 {keys} 唯一({len(df)} 条)"))
        else:
            issues.append(_issue("主键不可用", "中",
                                 f"{name} 主键变量 {keys} 不完整(存在:{present})"))

    # 3) 日期逻辑
    for start, end in DATE_PAIRS.get(key, []):
        if start in df.columns and end in df.columns:
            s = pd.to_datetime(df[start], errors="coerce")
            e = pd.to_datetime(df[end], errors="coerce")
            n_bad = int(((s.notna()) & (e.notna()) & (e < s)).sum())
            if n_bad:
                issues.append(_issue(
                    "日期逻辑错误", "高",
                    f"{name}: {n_bad} 条记录的 {end} 早于 {start}"))

    # 4) 受控术语
    for var, allowed in CONTROLLED_TERMS.get(key, {}).items():
        if var in df.columns:
            actual = set(df[var].dropna().astype(str).str.strip().unique())
            illegal = sorted(actual - set(allowed))
            if illegal:
                issues.append(_issue(
                    "受控术语违规", "中",
                    f"{name}.{var} 出现非标准取值 {illegal[:8]}"
                    f"(共 {len(illegal)} 种)"))

    # 5) 全缺失变量
    all_na = [c for c in df.columns if df[c].isna().all()]
    if all_na:
        issues.append(_issue("全缺失变量", "中",
                             f"{name} 有 {len(all_na)} 个变量完全缺失: {all_na[:8]}"))

    # 6) 字符首尾空格
    padded = []
    for c in df.columns:
        if pd.api.types.is_numeric_dtype(df[c]) or pd.api.types.is_datetime64_any_dtype(df[c]):
            continue
        s = df[c].dropna().astype(str)
        if len(s) and bool(s.str.strip().ne(s).any()):
            padded.append(c)
    if padded:
        issues.append(_issue("首尾空格", "低",
                             f"{name} 中 {padded[:8]} 存在首尾空格"))

    # 7) 数值变量的异常值(粗筛,便于人工确认)
    for c in df.columns:
        if not pd.api.types.is_numeric_dtype(df[c]):
            continue
        s = df[c].dropna()
        if len(s) < 30:
            continue
        q1, q3 = s.quantile([0.25, 0.75])
        iqr = q3 - q1
        if iqr > 0:
            far = int(((s < q1 - 5 * iqr) | (s > q3 + 5 * iqr)).sum())
            if far:
                issues.append(_issue("离群值", "低",
                                     f"{name}.{c} 有 {far} 个极端值(超出 Q1/Q3 ± 5×IQR)"))
    return issues


def check_subject_consistency(dm: pd.DataFrame,
                              others: dict[str, pd.DataFrame]) -> list[dict]:
    """检查各域的 USUBJID 是否都在 DM 中(真实 QC 场景)。

    Returns
    -------
    list[dict]
        对每个域给出一条信息或问题记录。
    """
    base = set(dm["USUBJID"].dropna().astype(str))
    issues: list[dict] = [
        _issue("受试者基准", "信息", f"DM 中共 {len(base)} 位受试者")
    ]
    for name, df in others.items():
        if "USUBJID" not in df.columns:
            continue
        subj = set(df["USUBJID"].dropna().astype(str))
        orphan = subj - base
        no_rec = base - subj
        if orphan:
            issues.append(_issue(
                "孤儿受试者", "高",
                f"{name} 有 {len(orphan)} 位受试者不在 DM 中:"
                f"{sorted(orphan)[:5]}"))
        else:
            issues.append(_issue(
                "受试者一致性", "信息",
                f"{name}: {len(subj)} 位受试者全部在 DM 中;"
                f"{len(no_rec)} 位 DM 中的受试者在 {name} 无记录"
                f"(通常属正常情况:该受试者没有此类记录)"))
    return issues


def compare_frames(a: pd.DataFrame, b: pd.DataFrame, keys: list[str],
                   tolerance: float = 1e-9) -> dict:
    """比对两个 DataFrame(≈ ``PROC COMPARE``)。

    用于端到端验证:例如把 Python 生成的 ADSL 与 SAS 生成的 ADSL 对照。

    Returns
    -------
    dict
        含 ``行数一致`` / ``仅A有`` / ``仅B有`` / ``差异单元格`` 等键。
    """
    a = a.copy(); b = b.copy()
    for df in (a, b):
        for k in keys:
            df[k] = df[k].astype(str)

    set_a = set(map(tuple, a[keys].to_numpy()))
    set_b = set(map(tuple, b[keys].to_numpy()))

    common = set_a & set_b
    ai = a.set_index(keys)
    bi = b.set_index(keys)
    common_idx = [k for k in common]
    if not common_idx:
        return {"行数一致": False, "仅A有": sorted(set_a - set_b)[:5],
                "仅B有": sorted(set_b - set_a)[:5], "差异单元格": {}}

    ai = ai.loc[common_idx]; bi = bi.loc[common_idx]
    shared_cols = [c for c in ai.columns if c in bi.columns]

    diffs: dict[str, list] = {}
    for c in shared_cols:
        x, y = ai[c], bi[c]
        if pd.api.types.is_numeric_dtype(x) and pd.api.types.is_numeric_dtype(y):
            bad = ~np.isclose(x.astype(float), y.astype(float),
                              rtol=tolerance, atol=tolerance, equal_nan=True)
        else:
            bad = x.astype(str).ne(y.astype(str)) & ~(x.isna() & y.isna())
        if bool(bad.any()):
            diffs[c] = list(zip(ai.index[bad][:3], x[bad].tolist()[:3], y[bad].tolist()[:3]))

    return {
        "行数一致": len(a) == len(b),
        "A 行数": len(a), "B 行数": len(b),
        "仅A有": sorted(set_a - set_b)[:5],
        "仅B有": sorted(set_b - set_a)[:5],
        "共同记录数": len(common_idx),
        "差异变量": diffs,
        "结论": "一致" if (len(a) == len(b) and not diffs
                          and set_a == set_b) else "存在差异",
    }


def report(issues: list[dict], name: str = "") -> str:
    """把 issue 列表渲染成可读文本。"""
    high = [i for i in issues if i["严重性"] == "高"]
    mid = [i for i in issues if i["严重性"] == "中"]
    info = [i for i in issues if i["严重性"] == "信息"]

    lines = [f"{'=' * 70}",
             f"QC 报告 {name}  ——  高 {len(high)} / 中 {len(mid)} / 信息 {len(info)}",
             "=" * 70]
    for label, group in (("高", high), ("中", mid), ("信息", info)):
        for it in group:
            lines.append(f"[{label}] {it['类别']:12s} {it['详情']}")
    lines.append(f"\n结论:{'未发现高优先级问题' if not high else f'发现 {len(high)} 个高优先级问题'}")
    return "\n".join(lines)