本章目标:处理真实临床数据里的"脏"——日期格式混乱、缺失值含义多样、 变量标签丢失。这三件事占了日常数据处理的 50% 时间。
10.1 日期时间:SAS 数字 vs Python datetime
根本差异
| SAS | Python | |
|---|---|---|
| 日期的本质 | 数字(1960-01-01 起的天数) | datetime 对象(年/月/日时/分/秒) |
| 显示 | 靠 format(date9.、yymmdd10.) |
靠 strftime() 格式化,或 str() 默认 |
| 存储 | 数值变量 | datetime64[ns] dtype |
| 缺失 | . |
NaT(Not a Time) |
| 范围 | sas 日期可为负(1960 前) | 支持 1677–2262 年 |
data _null_;
d = '05JAN2014'd; /* 日期字面量 */
put d; /* 19737 —— 内部是数字 */
put d date9.; /* 05JAN2014 */
days = d - '02JAN2014'd; /* 3 —— 日期可做减法 */
run;
import pandas as pd
from datetime import date
d = pd.Timestamp("2014-01-05")
print(d) # 2014-01-05 00:00:00
print((d - pd.Timestamp("2014-01-02")).days) # 3
# 对应 SAS 的 '01JAN1960'd —— 基准日
epoch = pd.Timestamp("1960-01-01")
(pd.Timestamp("2014-01-05") - epoch).days # 19737 ← 与 SAS 一致!
💡 这个"19737"很有用:pandas 的 datetime 与 SAS 日期基准日相同 (都是 1960-01-01),所以与 SAS 做数值交换时可以直接换算天数(见 10.5)。
与 SAS 日期函数对照
| SAS | pandas / Python |
|---|---|
today() |
pd.Timestamp.today() / date.today() |
date() |
pd.Timestamp(x).date() |
year(d) |
d.year / series.dt.year |
month(d) |
d.month / .dt.month |
day(d) |
d.day / .dt.day |
weekday(d) |
d.weekday()(周一=0) |
intck('day', a, b) |
(b - a).days |
intck('month', a, b) |
用 relativedelta 或 (b.year-a.year)*12 + b.month-a.month |
intck('year', a, b) |
b.year - a.year(注意 SAS 的 intck 有"生日是否已过"的细节) |
intnx('month', d, 1) |
d + pd.DateOffset(months=1) |
mdy(m, d, y) |
pd.Timestamp(year=y, month=m, day=d) |
datepart(dt) |
.dt.normalize()(时间归零) |
timepart(dt) |
.dt.time |
dhms(d, h, m, s) |
pd.Timestamp(...) + pd.Timedelta(...) |
把字符串转成日期(临床最高频操作)
临床数据里日期几乎都是字符串(ISO 8601 格式:2014-01-05),
或者部分日期(2014-01、2014)。
import pandas as pd
# 基础转换
df["RFSTDTC_DT"] = pd.to_datetime(df["RFSTDTC"], errors="coerce")
# 指定格式(比自动推断更快更可靠)
df["DT"] = pd.to_datetime(df["DT_STR"], format="%Y-%m-%d", errors="coerce")
# ⚠️ errors="coerce" 必须加:无法解析的变成 NaT,而不是报错
# (对应 SAS 的 input(x, ??yymmdd10.) 抑制日志)
# 混合格式(如同时有 2014-01-05 和 05JAN2014)
df["DT"] = pd.to_datetime(df["DT_STR"], format="mixed", errors="coerce")
# 常见 SAS 格式对应的 Python 格式串
FORMATS = {
"date9.": "%d%b%Y", # 05JAN2014
"yymmdd10.": "%Y-%m-%d", # 2014-01-05
"mmddyy10.": "%m/%d/%Y", # 01/05/2014
"ddmmyy10.": "%d/%m/%Y", # 05/01/2014
"yymmdd8.": "%Y%m%d", # 20140105
"datetime20.": "%d%b%Y:%H:%M:%S",
}
⚠️
%d/%m/%Y还是%m/%d/%Y? 这是一个致命的歧义。03/04/2014到底是 3 月 4 日还是 4 月 3 日? 临床数据处理中,必须根据数据来源(CRF 设计、EDC 系统的地区设置)明确指定, 绝不能靠"自动推断"——否则会静默产生错误的日期,而且很难被发现。 这是数据质量事故的典型来源之一。
格式化为字符串(对应 PUT)
d = pd.Timestamp("2014-01-05")
d.strftime("%Y-%m-%d") # '2014-01-05' ← yymmdd10.
d.strftime("%d%b%Y").upper() # '05JAN2014' ← date9.
d.strftime("%Y-%m") # '2014-01'
d.strftime("%Y") # '2014'
# 整列的格式化(输出报表用)
df["RFSTDTC_FMT"] = df["RFSTDTC_DT"].dt.strftime("%d%b%Y").str.upper()
日期运算
df["AGE_AT_VISIT"] = (df["VISITDT"] - df["BRTHDT"]).dt.days / 365.25
df["DAYS_ON_TRT"] = (df["TRTEDT"] - df["TRTSDT"]).dt.days + 1 # 含首尾(+1)
df["NEXT_MONTH"] = df["DT"] + pd.DateOffset(months=1)
df["PREV_DAY"] = df["DT"] - pd.Timedelta(days=1)
# 日期差(对应 SAS 的 yrdif / 精确年数)
df["EXACT_AGE"] = (df["VISITDT"] - df["BRTHDT"]).dt.days / 365.25
📌
+1的陷阱:SAS 里试验天数常写TRTEDT - TRTSDT + 1(含首尾)。 pandas 的日期减法与 SAS 完全一致(都是差的天数), 所以转换时要保留这个+1,否则结果会差 1 天——这是 QC 常抓到的差异。
部分日期(临床特有)
临床数据常有"只知道年月"的日期:2014-01、2014。
SAS 处理这类靠自建 format;pandas 里需要小心。
# 部分日期不能直接 to_datetime(会变成 2014-01-01,引入虚假的"日")
partial = pd.Series(["2014-01", "2014", "2014-01-05"])
# 策略:保留原始字符串,另外派生"精度"标记
df["DT_PRECISION"] = np.select(
[df["DTC"].str.len() == 10, df["DTC"].str.len() == 7],
["DAY", "MONTH"], default="YEAR",
)
# 只在精度足够时做计算
df["DT_FULL"] = pd.to_datetime(
df["DTC"].where(df["DT_PRECISION"] == "DAY"), errors="coerce"
)
10.2 缺失值:临床数据的三种"空"
在临床数据里,"空"至少有三种含义,混在一起处理会产生错误结论:
| 含义 | 例子 | 正确处理 |
|---|---|---|
| 未收集(Not collected) | CRF 上这一项没填 | 保持缺失,统计时排除 |
| 不适用(Not applicable) | 男性受试者的"妊娠史" | 保持缺失,但分母也要排除 |
| 未发生(Not occurred) | 无不良事件 → AE 数据集中无记录 | 补 0,不能当缺失 |
# ---- 识别 ----
df.isna().sum() # 每列缺失数(对应 NMISS)
df.isna().mean().round(3) # 缺失率
df.isna().any(axis=1).sum() # 有任一缺失的行数
# ---- 按条件填充 ----
df["AEDECOD"] = df["AEDECOD"].fillna("ANY EVENT") # 无 AE 时填占位值
df["AVAL"] = df["AVAL"].fillna(0)
# 填充策略一览
s = df["AVAL"]
s.fillna(0) # 填常数
s.ffill() # 用上一个非缺失值(对应 retain)
s.bfill() # 用下一个非缺失值
s.fillna(s.median()) # 填中位数
s.fillna(s.groupby(df["TRT01P"]).transform("median")) # 按治疗组填组内中位数
s.interpolate() # 线性插值(时间序列)
空串 vs NaN:临床数据的最脏之处
SAS 里空串就是缺失,但 pandas 里空串是一个合法的非缺失值。
从 Excel / CSV 读进来的数据,空单元格可能变成 ''、' '、'NA'、'NULL'、'.' 等各种形态。
# ✅ 标准做法:读取时就把"伪缺失"统一识别
NA_VALUES = ["", " ", "NA", "N/A", "NULL", "null", ".", "Missing", "UNKNOWN"]
df = pd.read_csv("x.csv", na_values=NA_VALUES, keep_default_na=True)
# 读取之后统一清洗(处理已读入的脏数据)
import numpy as np
char_cols = df.select_dtypes(include="object").columns
df[char_cols] = df[char_cols].apply(
lambda s: s.str.strip().replace({"": np.nan, "NA": np.nan, ".": np.nan})
)
# SAS 里判断字符缺失的等价写法
# SAS: if name = '' then flag = 'Y';
# Python:
df["FLAG"] = np.where(
df["NAME"].isna() | (df["NAME"].astype(str).str.strip() == ""), "Y", "N"
)
🔥 这一段是"数据质量"的核心。请把它固化成一个函数放进你的工具包:
python def clean_missing(df, na_token=("", " ", "NA", "N/A", "NULL", ".", "UNKNOWN")): """把各种"伪缺失"统一成 NaN。对应 SAS 里空串即缺失的语义。""" df = df.copy() for c in df.columns: if df[c].dtype == "object": s = df[c].astype(str).str.strip() df[c] = s.mask(s.isin(na_token)) return df
dropna 与 PROC MEANS 的默认行为差异(务必记住)
| 操作 | SAS 默认 | pandas 默认 |
|---|---|---|
PROC MEANS 的 mean |
排除缺失 | mean() 排除(skipna=True)✅ 一致 |
PROC MEANS 的 N |
非缺失数 | count() 非缺失 ✅ 一致 |
PROC FREQ 的分组 |
缺失单独成组 | value_counts() 排除缺失 ❌ 不同 |
PROC SORT 中缺失位置 |
数值缺失排最前 | NaN 排最后 ❌ 不同 |
# 让 pandas 与 SAS 的 PROC FREQ 行为一致
df["TRT01P"].value_counts(dropna=False) # 缺失单独成组
df.groupby("TRT01P", dropna=False).size() # 同上
# 让排序中 NaN 在最前(对应 SAS)
df.sort_values("AGE", na_position="first")
10.3 变量标签与元数据(SAS 的无形资产)
SAS 数据集自带 label 和 format,这是临床编程的重要资产 (define.xml 和报表表头都依赖它)。pandas 原生不支持,这是迁移中的真实痛点。
读取时保留标签
import pyreadstat
df, meta = pyreadstat.read_xport("data/raw/adsl.xpt")
# meta.column_names_to_labels → {'STUDYID': 'Study Identifier', 'AGE': 'Age', ...}
# meta.original_variable_types, meta.variable_value_labels 等
# 也可以用 read_sas7bdat 读永久数据集
# df, meta = pyreadstat.read_sas7bdat("adsl.sas7bdat")
⚠️ 注意:
pandas.read_sas()会丢弃所有标签, 只有pyreadstat能读出来。所以: 要标签就用 pyreadstat,只关心数据用 pandas.read_sas。
把标签带进 pandas 工作流
import json
from pathlib import Path
# 方案 1:存成 JSON 侧车文件(推荐)
labels = meta.column_names_to_labels
Path("metadata").mkdir(exist_ok=True)
Path("metadata/adsl_labels.json").write_text(
json.dumps(labels, ensure_ascii=False, indent=2), encoding="utf-8"
)
# 方案 2:挂到 df.attrs 上(只在当前会话有效)
df.attrs["labels"] = labels
# 方案 3:用 DataFrame 的列名映射做输出(最实用)
def pretty_columns(df, labels, sep=" | "):
"""把列名替换成 '变量名 | 标签' 形式,便于人工核对。"""
return df.rename(columns={c: f"{c}{sep}{labels.get(c, '')}" for c in df.columns})
值标签(对应 SAS 的自定义 format)
# SAS: proc format; value $sext 'F'='Female' 'M'='Male'; ... format SEX $sext.;
SEX_FMT = {"F": "Female", "M": "Male", "U": "Unknown"}
df["SEX_DECODE"] = df["SEX"].map(SEX_FMT).fillna(df["SEX"])
# pyreadstat 可以直接读出 SAS 的 format 定义
# meta.variable_value_labels → {'SEX': {'F': 'Female', 'M': 'Male'}}
if hasattr(meta, "variable_value_labels"):
for var, mapping in meta.variable_value_labels.items():
if mapping:
df[f"{var}_DECODE"] = df[var].map(mapping).fillna(df[var].astype(str))
10.4 数据质量检查:把 QC 写进代码
临床编程的核心价值在于质量。Python 的一大优势是 可以把 QC 规则固化成可重复运行的代码(而不是靠人眼查日志)。
一套通用的数据集体检清单
from pathlib import Path
import pandas as pd
def qc_check(df, name="DATASET", required_vars=None, keys=None,
expected_n=None, verbose=True):
"""临床数据集质量检查。返回问题列表(空列表表示全部通过)。"""
issues = []
# 1) 必需变量存在性
if required_vars:
missing = [v for v in required_vars if v not in df.columns]
if missing:
issues.append(f"缺少必需变量: {missing}")
# 2) 主键唯一性
if keys:
dup = df.duplicated(subset=keys, keep=False)
if dup.any():
issues.append(f"主键 {keys} 不唯一:{int(dup.sum())} 条重复")
# 3) 行数符合预期
if expected_n is not None and len(df) != expected_n:
issues.append(f"行数不符:期望 {expected_n},实际 {len(df)}")
# 4) 关键变量缺失
high_miss = [(c, int(df[c].isna().sum())) for c in df.columns
if df[c].isna().mean() > 0 and df[c].isna().all()]
if high_miss:
issues.append(f"全缺失变量: {[c for c, _ in high_miss]}")
# 5) 字符变量首尾空格(常见的数据录入问题)
obj_cols = df.select_dtypes(include="object").columns
padded = [c for c in obj_cols
if df[c].dropna().astype(str).str.strip().ne(df[c].dropna().astype(str)).any()]
if padded:
issues.append(f"存在前后空格: {padded}")
# 6) 日期范围异常(若有日期列)
for c in df.columns:
if pd.api.types.is_datetime64_any_dtype(df[c]):
if (df[c] < pd.Timestamp("1900-01-01")).any():
issues.append(f"{c} 存在 1900 年之前的日期")
if (df[c] > pd.Timestamp.today()).any():
issues.append(f"{c} 存在未来日期")
if verbose:
status = "通过" if not issues else f"发现 {len(issues)} 个问题"
print(f"[QC] {name}:{len(df):,} 行 —— {status}")
for i in issues:
print(f" - {i}")
return issues
跨数据集一致性检查(最容易被忽略的一类)
def cross_check(dm, adsl, ae=None):
"""检查 SDTM 与 ADaM 之间的受试者一致性——真实 QC 场景。"""
sdtm_subj = set(dm["USUBJID"])
adam_subj = set(adsl["USUBJID"])
only_sdtm = sdtm_subj - adam_subj
only_adam = adam_subj - sdtm_subj
print(f"SDTM DM 受试者数: {len(sdtm_subj)}")
print(f"ADaM ADSL 受试者数: {len(adam_subj)}")
if only_sdtm:
print(f"[WARN] 在 DM 但不在 ADSL({len(only_sdtm)} 个): {sorted(only_sdtm)[:5]}")
if only_adam:
print(f"[ERROR] 在 ADSL 但不在 DM({len(only_adam)} 个): {sorted(only_adam)[:5]}")
# 若有 AE,检查"有 AE 记录的受试者是否都在人群里"
if ae is not None:
ae_subj = set(ae["USUBJID"])
orphan = ae_subj - adam_subj
if orphan:
print(f"[ERROR] AE 中有 {len(orphan)} 个受试者不在 ADSL 中")
# 治疗组一致性(同一受试者在两个数据集里应属同一组)
merged = dm[["USUBJID"]].merge(adsl[["USUBJID", "TRT01P"]], on="USUBJID", how="inner")
return merged
🧠 为什么这些检查值得写死成代码:SAS 时代这些检查靠"双编程 + PROC COMPARE", 每次都要重做。写成 Python 函数后,一次编写,永久复用, 而且可以在 CI(第 17 章)里自动跑。这是 Python 在临床场景的真实增量价值。
10.5 与 SAS 交换数据:日期换算与 XPT 往返
# SAS 日期(数字,1960-01-01 = 0) → pandas datetime
def sas_date_to_dt(sas_num):
return pd.Timestamp("1960-01-01") + pd.to_timedelta(sas_num, unit="D")
# pandas datetime → SAS 日期数字
def dt_to_sas_date(dt):
return (pd.Timestamp(dt) - pd.Timestamp("1960-01-01")).days
# 整列
df["DT"] = pd.Timestamp("1960-01-01") + pd.to_timedelta(df["SAS_DT"], unit="D")
# 输出给 SAS 用:XPT v5 格式(pyreadstat 可以写 xport)
import pyreadstat
pyreadstat.write_xport(
df, "outputs/adsl_new.xpt",
file_label="ADSL",
column_labels=[labels.get(c, c) for c in df.columns], # 保留变量标签
)
# 或者输出 CSV(SAS 用 PROC IMPORT 读)
df.to_csv("outputs/adsl_new.csv", index=False, encoding="utf-8-sig")
📌 什么时候该输出 XPT:当 Python 生成的数据集要交给 SAS 用户、 或要进入原来的提交包时。XPT v5 是 CDISC 提交的标准传输格式, 所以
pyreadstat.write_xport是 Python ↔ SAS 桥梁的关键工具。
10.6 动手练习
-
日期转换:把
data/samples/adsl.csv里的TRTSDT(字符串YYYY-MM-DD) 转成 datetime,再派生TRTSDT_YEAR(年)、TRTSDT_DATE9(DDMMMYYYY格式)。 -
日期差:计算每个受试者的
TRTDURD(治疗天数 = TRTEDT − TRTSDT + 1), 与原始的TRTDURD列比对,看是否有差异(这是一个真实的核对练习)。 -
缺失值:统计 ADSL 中每列的缺失数与缺失率, 找出缺失率 > 10% 的列。再用
clean_missing()处理其中的"伪缺失"。 -
QC 函数:把 10.4 节的
qc_check()跑在data/samples/下的 dm / adsl / adae 三个数据集上,记录发现的问题。 -
交叉检查:用
cross_check()检查 DM 与 ADSL 的受试者一致性。 预期结果:两者应该完全一致(306 个)。 -
(进阶) 用
pyreadstat.write_xport()把一个 pandas DataFrame 写成 XPT 文件,再用pd.read_sas()读回来,验证数据往返一致。
上一章 ← 第 09 章 · 合并、重塑与分组汇总 下一章 → 第 11 章 · 读取 XPT / SAS7BDAT 与临床数据结构