4、数据模型设计:用Python定义样本元数据的类与数据类

我记得刚入行那会儿,接手一个生物信息学项目。团队里每个人对样本元数据的理解都不一样。

有人用字典存,有人用元组,还有人直接塞进JSON字符串里。结果呢?数据解析的时候,字段名对不上、类型搞混、缺失值处理乱成一锅粥。

说白了,没有统一的数据模型,管道就是纸糊的。

这一章,咱们就来聊聊怎么用Python的类和数据类,把样本元数据这块地基打牢。我个人习惯在设计管道的伊始,先把数据模型定下来,哪怕后面要改,也比写一堆临时脚本再重构要舒服得多。

4.1 为什么需要数据模型?

先问个问题:你见过用普通字典管理元数据的代码吗?

sample = {
    "id": "S001",
    "type": "tumor",
    "collection_date": "2024-01-15",
    "age": 45,
    "gender": "male"
}

看起来挺直观是吧。但我在项目中踩过坑——字典的value是什么类型?全靠注释和约定。有人传了字符串"45",有人传了整数45,哪天一个同事把"age"拼写成了"aeg",程序就默默把它当成新字段了。

我曾经让一个分析流程跑了三天,最后发现是字段名拼写错误导致数据被忽略。 后来我才明白,用裸字典管理元数据,相当于在高速公路上不画车道线——你知道车要往哪开,但每辆都可能跑偏。

4.2 类与数据类:两个武器

Python提供了两套方案来定义严谨的数据模型:传统类和 dataclass。我们来看看它们的对比。

特性 传统类 数据类
代码量 需要手动编写 __init__、__repr__ 等 自动生成,简洁高效
类型标注 支持,但需手动检查 原生支持,配合 IDE 友好
可变性 默认可变 支持 frozen=True 冻结
适用场景 复杂的业务逻辑、自定义行为 纯粹的数据容器、DTO
我个人习惯把数据类作为第一选择。大部分样本元数据就是「容器」,不需要花里胡哨的方法。用到业务逻辑时再换成普通类,这样代码更清爽。

4.3 开始搭建:样本元数据实体

咱们先定义最核心的实体——Sample。它代表一个生物学样本。你想想看,一个样本需要记录什么?

from dataclasses import dataclass
from datetime import datetime
from typing import Optional

@dataclass
class Sample:
    sample_id: str
    project: str
    tissue_type: str
    collection_date: Optional[datetime] = None
    age: Optional[int] = None
    gender: Optional[str] = None
    note: Optional[str] = None

看到 Optional 了吗?嗯,这里要注意,样本元数据经常有缺失值。比如急诊样本可能没来得及记录年龄。用 Optional 标注,意思是「这个字段要么有值,要么是 None」。

但光有一个 Sample 还不够。实际应用中,元数据往往是嵌套的。比如一个样本关联着测序 run、文件路径、质控指标。我们可以这样组织:

@dataclass
class SequencingRun:
    run_id: str
    platform: str
    read_length: int
    file_path: str

@dataclass
class MetadataRecord:
    sample: Sample
    run: SequencingRun
    qc_status: str  # passed / failed / pending

这样设计,关系清晰,类型明确。用 sample.run.platform 就能拿到测序平台信息,IDE 还能自动补全。说到底,这才是结构化数据该有的样子。

4.4 枚举与类型别名:让代码更安全

元数据里很多字段是固定取值。像 gender 只有"male"、"female"、"unknown"。用字符串来存,早晚会出幺蛾子。

我曾经在数据里同时看到过"Male"、"M"、"男"三种写法。从那以后,我规定团队必须用枚举。

from enum import Enum

class Gender(Enum):
    MALE = "male"
    FEMALE = "female"
    UNKNOWN = "unknown"

class TissueType(Enum):
    TUMOR = "tumor"
    NORMAL = "normal"
    METASTASIS = "metastasis"

@dataclass
class Sample:
    sample_id: str
    tissue_type: TissueType  # 不再是字符串
    gender: Gender = Gender.UNKNOWN

再配合类型别名,让复杂类型有名字:

from typing import List, Dict

SampleList = List[Sample]
MetadataIndex = Dict[str, MetadataRecord]

这样,函数的签名就变成 def validate(samples: SampleList) -> MetadataIndex,读起来像英语一样自然。

4.5 数据不可变性:避免无意识修改

在一个管道里,数据可能被多个步骤消费。如果某个环节不小心改了元数据,后面的分析就全错了。

建议在定义数据类时加上 frozen=True,让实例变为不可变。
@dataclass(frozen=True)
class Sample:
    sample_id: str
    tissue_type: TissueType
    # ... 其他字段

试试修改已冻结的实例:

s = Sample(sample_id="S001", tissue_type=TissueType.TUMOR)
s.tissue_type = TissueType.NORMAL  # 报错!

运行时会抛出 FrozenInstanceError。这个保护机制,说白了就是「想改?先过我这关」。如果你确实需要修改,可以手动创建一个新实例,用 replace 函数:

from dataclasses import replace
s_new = replace(s, tissue_type=TissueType.METASTASIS)

这样,修改意图就非常明确,不会悄悄污染数据。

4.6 整体结构图

下面是本章数据模型关系的SVG图,展示了 Sample、SequencingRun 和 MetadataRecord 之间的组合关系,以及类型系统如何约束字段。

样本元数据数据模型结构图 Sample(样本) sample_id: str project: str tissue_type: TissueType gender: Gender collection_date: Optional[datetime] age: Optional[int] SequencingRun(测序批次) run_id: str platform: str read_length: int file_path: str MetadataRecord(元数据记录) sample: Sample run: SequencingRun qc_status: str(passed / failed / pending) 说明:Sample 和 SequencingRun 通过 MetadataRecord 关联,形成一个完整的数据记录。枚举与 Optional 类型约束字段取值。

4.7 高阶技巧:用工厂方法创建复杂实例

有时样本元数据的构造逻辑很繁琐。比如从 CSV 行或 JSON 字典初始化。我不建议把解析逻辑写在 __init__ 里,我个人喜欢用类方法做工厂:

@dataclass
class Sample:
    sample_id: str
    tissue_type: TissueType
    gender: Gender
    age: Optional[int] = None

    @classmethod
    def from_dict(cls, data: dict) -> "Sample":
        return cls(
            sample_id=data["sample_id"],
            tissue_type=TissueType(data["tissue_type"]),
            gender=Gender(data.get("gender", "unknown")),
            age=int(data["age"]) if data.get("age") else None
        )

调用时很干净:

raw = {"sample_id": "S002", "tissue_type": "tumor", "gender": "female"}
sample = Sample.from_dict(raw)

这样,类型转换和默认值都在一个地方管理,后续改起来也方便。

4.8 小结:数据模型是管道的坐标系

这一章的核心,就是把「模糊的元数据描述」变成「精确的Python类」。你想想看,没有坐标系的航海图根本没法用。数据模型就是管道里的坐标系。

好的数据模型应该让你「看一眼类定义,就知道数据长什么样」。如果还需要翻文档才能理解字段含义,那就是设计没到位。