2. 队列数据模型与数据结构设计
临床研究队列的数据不是一张“大表”,而是一组彼此约束的实体集合。受试者(Subject)回答“谁进入研究”,访视(Visit)回答“在什么时间点被观察”,事件(Event)回答“发生了什么临床终点或过程”,变量字典(Variable Dictionary)回答“每个字段代表什么、允许什么取值”。如果这四类实体没有清晰的关系模型,后续的清洗、统计和审计都会失去依据。本节的目标是:用 pandas 承载数据、用 pydantic 定义可验证结构,让“数据能不能进队列”变成一个可执行、可报错的判断。
一、四个核心实体及其关系
先明确实体之间的基数关系,这是建模的骨架:
- 受试者:一行一个受试者,主键为
subject_id,携带中心号、入组日期、出生年份、性别、分组等相对稳定的属性。 - 访视:一行一次访视,主键为
(subject_id, visit_code),外键指向受试者。同一受试者可有多条访视记录,形成一对多。 - 事件:一行一个事件,主键为
event_id,外键指向受试者,可附带发生日期、事件类型、严重程度。一个受试者可发生零到多个事件。 - 变量字典:一行一个变量定义,主键为
variable_name,描述变量所属表、数据类型、单位、允许取值、是否必填。它不直接存数据,而是约束前三张表。
关系上,受试者是中心节点,访视与事件都挂在它下面;变量字典是横向的元数据层,对三张表的所有字段施加约束。这种“一主两从加一字典”的结构,既避免了宽表的大量空值,又保留了纵向随访的时序信息。
二、用 pydantic 定义可验证结构
pandas 擅长批量运算,但不擅长逐行校验。把 pydantic 放在数据入口处,可以在数据进入 DataFrame 之前就拦截非法记录。下面定义受试者与访视两个模型,字段类型、取值范围和跨字段逻辑都在模型里表达:
from datetime import date
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class Subject(BaseModel):
subject_id: str = Field(pattern=r"^S\d{4}$")
site_id: str = Field(pattern=r"^C\d{2}$")
enroll_date: date
birth_year: int = Field(ge=1900, le=2025)
sex: Literal["M", "F"]
arm: Literal["A", "B", "OBS"]
@model_validator(mode="after")
def enroll_after_birth(self):
if self.enroll_date.year - self.birth_year < 18:
raise ValueError("入组时年龄不足 18 岁")
return self
class Visit(BaseModel):
subject_id: str = Field(pattern=r"^S\d{4}$")
visit_code: Literal["V0", "V1", "V2", "V3"]
visit_date: date
weight_kg: Optional[float] = Field(default=None, gt=0, le=300)
sbp: Optional[int] = Field(default=None, ge=40, le=300)
@field_validator("visit_date")
@classmethod
def not_future(cls, v: date):
if v > date.today():
raise ValueError("访视日期不能晚于今天")
return v
这里 pattern 约束编号格式,ge/le 约束数值边界,Literal 约束枚举取值,model_validator 处理“入组年龄≥18”这类跨字段规则。校验失败会抛出带字段路径的 ValidationError,便于定位到具体行。
三、从校验到 DataFrame
实际流程是:先把原始记录逐条送入 pydantic 校验,通过的记录再组装成 DataFrame。这样 DataFrame 中的每一列类型都是可信的,后续 groupby、merge 不会因脏数据产生静默错误。
import pandas as pd
from pydantic import ValidationError
raw_subjects = [
{"subject_id": "S0001", "site_id": "C01", "enroll_date": "2024-03-11",
"birth_year": 1985, "sex": "F", "arm": "A"},
{"subject_id": "S0002", "site_id": "C01", "enroll_date": "2024-04-02",
"birth_year": 2010, "sex": "M", "arm": "B"}, # 年龄不足,应被拦截
]
valid, errors = [], []
for i, row in enumerate(raw_subjects):
try:
valid.append(Subject(**row).model_dump())
except ValidationError as e:
errors.append({"row": i, "error": e.errors()[0]["msg"]})
df_subject = pd.DataFrame(valid)
df_error = pd.DataFrame(errors)
print(df_subject.dtypes)
print(df_error)
预期结果:df_subject 只含 S0001 一行,enroll_date 已是 datetime64[ns];df_error 记录第 1 行“入组时年龄不足 18 岁”。这就是“可验证数据结构”的直接价值——错误在入口暴露,而不是在统计阶段才发现。
四、变量字典驱动字段约束
变量字典把“字段应该长什么样”从代码里抽出来,变成可维护的数据。一个最小可用的字典表包含:变量名、所属表、数据类型、单位、允许取值、是否必填。例如:
| variable_name | table | dtype | unit | allowed | required |
|---|---|---|---|---|---|
| subject_id | subject | string | — | ^S\d{4}$ | 是 |
| sex | subject | category | — | M/F | 是 |
| visit_code | visit | category | — | V0/V1/V2/V3 | 是 |
| weight_kg | visit | float | kg | 0<x≤300 | 否 |
| event_type | event | category | — | AE/SAE/PD/DEATH | 是 |
有了字典,就可以写一个通用校验函数:遍历 DataFrame 的列,对照字典检查 dtype、必填性和允许取值,把不符合的单元格位置输出成问题清单。这样新增变量时只需改字典,不必改校验代码。
VariableDef),保证字典表自身的字段完整、取值合法,避免“约束的约束”失控。五、设计时的三个关键判断
- 主键是否稳定:
subject_id一旦分配不应变更;访视主键用“受试者+访视码”而非自增行号,才能在多次数据合并中保持唯一。 - 缺失与零值区分:
weight_kg用Optional表示“未测量”,不要用 0 代替,否则会污染均值计算。 - 时间字段统一为 date/datetime:字符串日期在排序和区间筛选中极易出错,校验阶段就转成日期类型。
把上述模型落地后,队列数据就具备了“结构清晰、入口可验、字段可查”的基础。后续的随访完整性检查、事件时间分析和统计建模,都可以建立在这套稳定结构之上。