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 之间的组合关系,以及类型系统如何约束字段。
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类」。你想想看,没有坐标系的航海图根本没法用。数据模型就是管道里的坐标系。