2. 队列数据模型与数据结构设计

临床研究队列的数据不是一张“大表”,而是一组彼此约束的实体集合。受试者(Subject)回答“谁进入研究”,访视(Visit)回答“在什么时间点被观察”,事件(Event)回答“发生了什么临床终点或过程”,变量字典(Variable Dictionary)回答“每个字段代表什么、允许什么取值”。如果这四类实体没有清晰的关系模型,后续的清洗、统计和审计都会失去依据。本节的目标是:用 pandas 承载数据、用 pydantic 定义可验证结构,让“数据能不能进队列”变成一个可执行、可报错的判断。

一、四个核心实体及其关系

先明确实体之间的基数关系,这是建模的骨架:

  • 受试者:一行一个受试者,主键为 subject_id,携带中心号、入组日期、出生年份、性别、分组等相对稳定的属性。
  • 访视:一行一次访视,主键为 (subject_id, visit_code),外键指向受试者。同一受试者可有多条访视记录,形成一对多。
  • 事件:一行一个事件,主键为 event_id,外键指向受试者,可附带发生日期、事件类型、严重程度。一个受试者可发生零到多个事件。
  • 变量字典:一行一个变量定义,主键为 variable_name,描述变量所属表、数据类型、单位、允许取值、是否必填。它不直接存数据,而是约束前三张表。

关系上,受试者是中心节点,访视与事件都挂在它下面;变量字典是横向的元数据层,对三张表的所有字段施加约束。这种“一主两从加一字典”的结构,既避免了宽表的大量空值,又保留了纵向随访的时序信息。

队列核心实体关系模型 受试者 Subject PK: subject_id site_id · enroll_date birth_year · sex · arm 一行一个受试者 访视 Visit PK: subject_id + visit_code FK: subject_id visit_date · weight · sbp 一行一次访视 事件 Event PK: event_id FK: subject_id event_type · event_date 一行一个事件 变量字典 Variable Dictionary variable_name · dtype · unit · allowed 1 : N 1 : N 约束三表字段

二、用 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_nametabledtypeunitallowedrequired
subject_idsubjectstring—^S\d{4}$是
sexsubjectcategory—M/F是
visit_codevisitcategory—V0/V1/V2/V3是
weight_kgvisitfloatkg0<x≤300否
event_typeeventcategory—AE/SAE/PD/DEATH是

有了字典,就可以写一个通用校验函数:遍历 DataFrame 的列,对照字典检查 dtype、必填性和允许取值,把不符合的单元格位置输出成问题清单。这样新增变量时只需改字典,不必改校验代码。

建议把变量字典本身也用 pydantic 建模(如 VariableDef),保证字典表自身的字段完整、取值合法,避免“约束的约束”失控。

五、设计时的三个关键判断

  • 主键是否稳定:subject_id 一旦分配不应变更;访视主键用“受试者+访视码”而非自增行号,才能在多次数据合并中保持唯一。
  • 缺失与零值区分:weight_kg 用 Optional 表示“未测量”,不要用 0 代替,否则会污染均值计算。
  • 时间字段统一为 date/datetime:字符串日期在排序和区间筛选中极易出错,校验阶段就转成日期类型。

把上述模型落地后,队列数据就具备了“结构清晰、入口可验、字段可查”的基础。后续的随访完整性检查、事件时间分析和统计建模,都可以建立在这套稳定结构之上。