第一次拿到一个重症监护数据集的时候,大多数人都会愣一下:里面的时间轴不是整整齐齐的,而是一堆参数参差不齐的散点。心跳、血压、呼吸机参数是高频连续的,一次普通查房记录里却只有七个时间戳;上一秒还在按秒采样的监护仪,下一秒因为护理操作急剧降采样;一个地区医院的数据里,记录间隔甚至会呈幂律分布。医疗时序基座模型,就是在这个背景下被推出来的方案:不再把不规则的原始观测强行压成等距表格,而是直接在这些不均匀的时间点上进行预训练和推理。这套思路解决的,恰恰是医疗数据落地建模时最磨人的一个核心问题——不规则采样。
如果你之前做过医疗时序相关的任务,比如院内预后预测、ICU事件预警、慢病随访建模,你大概率有过这种体验:项目的精力一半以上都花在了“数据规整”这件事上——插值、重采样、窗口对齐,做完之后还不放心,因为插出来的值本身就是假的,模型学得再好,也是基于假数据。基座模型要做的,是把这一套折腾人的流程彻底绕开。这篇文章我会把不规则采样的本质、基座模型的设计思路、代码级实现方法,以及我在实操中踩过的坑,一次讲清楚。
1. 为什么医疗时序数据这么难处理
1.1 不规则采样到底意味着什么
很多人一听到“不规则采样”,第一反应是“数据有缺失,需要插值”。这个理解不够准确。医疗时序数据的不规则,不是简单的“偶尔少了一个点”,而是整个观测过程本身就是不均匀的。常见的有四种形态,我分别说。
第一种是时间间隔不固定。同一个病人在ICU里,心率可能是每秒一个点,血压是每十五分钟测一次,血气分析则是几小时才抽一次。它们落在同一个时间轴上,但彼此之间的间隔差了三个数量级。这里不存在什么“等间隔的原始数据”,你想要多少间隔,完全取决于你打算怎么切。
第二种是变量维度本身缺失严重。某个时刻记录了血压,但没有同时记录血糖;另一个时刻做了血液检验,但没有测量任何生命体征。传统表格化表示直接把每个时间窗内的所有变量铺开,就会出现大量空位——有些变量在整个窗口里一次都没出现过。
第三种是事件驱动的采样。医疗场景里,采样频率并不是随机独立的,它和病人的病情状态强相关。病情稳定时,护士可能四小时量一次血压;病情恶化时,可能每五分钟就要测一次,还会追加检验项目。换句话说,“有没有被测量”这件事本身就携带临床信息,不是可有可无的噪声。
第四种是跨设备跨频率的信号混叠。心电波形是连续高频信号,护理记录是低维稀疏事件,把它们融合到一个模型里时,时间轴对齐本身就是个大问题。实测中我见过不少团队在数据清洗阶段花费数周,就是为了把每分钟心电特征和每天一次的检验报告对齐到同一张表里。
把这四种形态合在一起,你会发现:不规则采样才是医疗时序数据的常态,规则采样反而是人为加工后的特殊形态。
1.2 常规时序模型为什么集体失灵
传统的处理套路不外乎几条路,每一条都有明确代价。
重采样是最直观的路径。把时间轴切成固定长度的桶,比如每五分钟一个点,超过这个频率的数据降采样,低于这个频率的数据用前向填充。它的致命问题在于:降采样会丢失高频事件里的细节,ICU里血压骤降可能就发生在几秒钟之内;前向填充则会把“不知道”变成“已知”,模型根本无法区分“这个时刻真的测过”和“用上一个值凑数”。
插值是第二条路。线性插值、样条插值、卡尔曼滤波插值,观感上让数据“变整齐了”,但插出来的值在临床上没有物理意义。更麻烦的是,插值过程本身会改变数据的分布特性,比如方差被压缩、峰值被削平。模型在插值后的数据上学到的模式,可能和真实临床模式根本不是一回事。
RNN和Transformer在离散等距序列上很强,但如果直接把时间戳作为额外特征喂进去,模型对“时间间隔的语义”理解是粗糙的。RNN的隐状态天然假设相邻时间步具有均匀相关性,而医疗场景里,隔五分钟的两个观测和隔两小时的两个观测,相关性差异要到指数级别。普通Transformer虽然可以加相对位置编码,但位置编码是离散桶,处理不了任意实数时间间隔。
还有一个很容易被忽略的问题:大多数模型把“缺失”当作占位,忽略了一个关键信号——观测模式本身就是有意义的。比如一个指标没有被测量,可能是因为它不需要被测量,这说明病人情况稳定。传统模型把这种“未观测”状态直接抹掉了。
2. 基座模型的整体设计思路
2.1 先想清楚:我们解决的是建模问题,不是补全问题
在设计这个基座模型之前,需要做一个关键判断:我们要解决的是“数据补全”,还是“在原始数据上建模”?我选择后者。
补全问题的本质是重建一个完整的、规则的潜在信号,这在信号处理和某些预测任务里是合理的。但在医疗场景里,补全面临双重困境:第一,你无法验证补出来的值是否正确,因为没有ground truth;第二,补全会让模型误以为缺失模式没有意义,而这恰恰是临床信息的重要组成。
基座模型的思路更接近现代大语言模型的做法:不修整原始输入,也不要求输入等长等距,而是把每个观测事件当作一个token,让模型直接学习这些token之间的关系。这样,时间间隔、变量类型、观测频率、缺失模式,全部作为输入的一部分参与建模,而不是被数据预处理阶段提前抹掉。
2.2 架构的四个关键决策
基于“直接在不规则时间点上建模”这个前提,我在架构层面做了四个核心决策。
决策一:输入事件化。每个观测点被编码成一个结构化事件,至少包含四要素:病人ID、事件时间、变量ID、观测值。如果是分类变量,还会有事件类型。模型看到的不是一张时序表格,而是一连串事件流。
决策二:连续时间表示。不用离散位置编码,而是把真实时间戳通过一组连续基函数映射到高维空间。这样可以处理任意精细的时间间隔,无论是毫秒级的高频信号还是数天一次的慢病随访,都能在同一个时间坐标系中表达。
决策三:时间衰减注意力。注意力权重中显式加入时间间隔衰减项,两个事件之间的时间跨度越长,注意力的基础权重越低,而且这个衰减是可学习的。这就模拟了临床直觉:离得近的观测通常更有参考价值。
决策四:缺失模式感知。模型需要知道“某个变量在上一次观测之后隔了多久才被再次观测”,这种观测间隔特征本身要作为预测信号的一部分。说白了,如果一个指标很久没测了,模型应该感觉到“这是一个需要留意的信号”,而不是简单忽略。
这四个决策共同指向一个目标:让模型不依赖规则的时间网格,也能完成序列学习。
2.3 预训练任务怎么定
基座模型的价值要靠预训练任务来体现。在医疗时序场景里,我在传统掩码重建之外还设计了一个特殊任务。
第一个任务是掩码重建。随机遮蔽一部分事件,要求模型根据上下文重建被遮事件的变量值和观测时间。这里的掩码单位不是“时间步”,也不是“变量”,而是“事件”。遮蔽时要注意,不能让模型通过时间戳的缺失来判断位置,所以时间信息要部分保留,否则任务会退化成一个“查表”问题。
第二个任务更有意思:预测下一次观测的变量以及观测时间。医疗里,医生决定“什么时候测什么指标”,本身就是一个决策过程,这个过程受到病情严重程度、诊疗路径、个体差异的多重影响。如果模型能够预测“下一步医生会做什么检查”,它实际上学到的是一种诊疗行为模式,这种模式对很多下游任务都有帮助。
第三个任务是对比学习。把同一病人不同时间段的子序列视为正样本,拉近它们的表示,同时拉远不同病人的表示。对比学习能让模型抓住病人层面的稳定性特征,对下游少样本适配特别有用。
三个任务联合训练,预训练损失是三个损失的加权求和。在实践中,对比学习的权重不宜过大,否则模型会偏向病人身份判别,弱化了变量间的时序关系建模。
3. 核心模块与代码级实现
3.1 数据组织:回到事件存储
传统表格存储会让不规则数据付出巨大代价,因为你要为每个时间窗口补出空位。事件存储则天然适配不规则采样。我这里用的是parquet格式,按病人分区,每行代表一个观测事件。
import pandas as pd from datetime import datetime # 事件表样例:每个观测点一行 # patient_id 表示病人,var_id 表示变量编号 # event_time 是时间戳(统一用整数或float,避免字符串比较) # value 是观测值,numeric 标量 # event_type 标注来源:监护仪/检验/护理记录/医嘱 events = pd.read_parquet("icu_events.parquet") print(events[["patient_id", "var_id", "event_time", "value"]].head()) # 关键预处理步骤1:以入院时刻为0点,把绝对时间转为相对时间 # 这样可以消除不同入院时刻(凌晨/中午、不同季节)带来的伪周期干扰 events["rel_time"] = ( events["event_time"] - events["admit_time"] ).dt.total_seconds() # 关键预处理步骤2:数值型变量分两类处理 # 一类是长尾分布(肌钙蛋白、炎症指标),做log1p变换 # 另一类是有生理边界(体温、pH),保留原始值并做z-score mask_longtail = events["var_id"].isin(creatinine, troponin, crp) events.loc[mask_longtail, "value"] = np.log1p(events.loc[mask_longtail, "value"])这里有两个容易踩的坑,我在项目初期反复栽过。
第一是时间戳统一问题。原始数据里会有纳秒级的高频时间戳和精确到小时级的检验记录时间戳。我建议全部统一转成epoch秒,变量再额外保存一个小数部分。如果混用datetime64和float,后续排序和差值计算会非常慢,而且容易出现时间回退。
第二是数值变换的适用性。log变换只适用于数据范围横跨多个数量级的指标,对pH值、体温这种有生理边界的数据做log变换会导致分布畸形,模型学到的模式会失真。建议在预处理阶段按变量ID建立变换映射表,而不是对全部变量统一做一种变换。
3.2 时间连续编码与间隔衰减注意力
时间编码是基座模型的核心之一。我采用了一组正弦基函数加一个可学习的线性映射,把时间间隔投射到高维空间。这里不直接用原时间值,而是用相对时间差,保证平移不变性。
import torch import torch.nn as nn import math class Time2Vec(nn.Module): def __init__(self, hidden_dim, k = 8): super().__init__() # k 是正弦基频的数量,hidden_dim 是模型隐藏维度 self.linear = nn.Linear(1, hidden_dim - k) # 可学习的基频和初相 self.freq = nn.Parameter(torch.randn(k, 1) * 0.1) self.phase = nn.Parameter(torch.randn(k, 1) * 0.1) def forward(self, delta_time): # delta_time: [batch, seq_len, 1] 相对某个参考时刻 periodic = torch.sin( delta_time * self.freq * 2 * math.pi + self.phase ) # [batch, seq_len, k] non_periodic = self.linear(delta_time) # [batch, seq_len, hidden_dim - k] return torch.cat([periodic, non_periodic], dim = -1)选基频的时候有个细节:基频的取值范围要覆盖实际数据中的观测量级。如果数据里最小的有效间隔是30秒,最大的间隔是一周,基频范围至少要横跨这个区间。我建议先画出对数时间间隔的直方图,然后让基频覆盖中位数附近的两个数量级。瞎猜数值会导致时间编码对某些尺度不敏感。
时间衰减注意力的实现是另一个关键。我改写了标准Transformer里的注意力打分过程:两个token的attention权重,在原始Query-Key点积的基础上,乘以一个随它们时间间隔增大的衰减项。我采用的是可学习的对数衰减:
class TimeDecayAttention(nn.Module): def __init__(self, hidden_dim): super().__init__() self.decay_rate = nn.Parameter(torch.tensor(0.5)) def forward(self, query, key, time_intervals): # time_intervals: [batch, seq_len, seq_len] # 间隔越大,衰减越强,但用 log 压缩防止梯度爆炸 decay = torch.exp(-self.decay_rate * torch.log1p(time_intervals)) attn_weights = torch.matmul(query, key.transpose(-2, -1)) attn_weights = attn_weights * decay attn_weights = torch.softmax(attn_weights, dim = -1) return attn_weights这里decay_rate是可学习的,初始值设小一点,让模型先学会基础语义关联,再逐步调整时间衰减强度。我试过初始值设成1.0,结果早期训练阶段梯度很大,模型一度很难收敛。后来把初始值降到0.5就好很多。
3.3 预训练的完整流程
预训练流程分四步:构建事件序列、生成训练样本、执行自监督任务、保存通用表示。
事件序列构建的粒度很关键。一个病人整个住院周期的完整序列太长了,GPU显存根本放不下。我采用滑窗:以某个观测事件为锚点,向后取不超过一定时间跨度、且事件数不超过上限的子序列。这样既能覆盖局部时间窗口,又能控制训练样本复杂度。窗口长度我实际用的是72小时,事件上限512个,超过上限就按时间截断。
掩码策略也要细化。随机遮蔽15%的事件点听起来简单,实际操作里有几个讲究。遮蔽要优先遮高频变量,否则模型学会的都是对高频变量的重建,低频变量(如检验指标)学不到。另外,时间戳本身不能完全遮蔽,我遮蔽掉时间戳的小数部分,保留整数部分,防止模型直接猜出位置。
对比学习部分,我用的是同一个病人两个时间窗口的表征向量做相似度损失,负样本从其他病人中抽取。每个batch里,正样本对是同一个人的两个不同窗口;负样本对是不同病人的窗口。训练时,病人级别的批次组织很重要,我按病人ID分组采样,确保一个batch里同时包含同一个病人的多个窗口。
整个预训练流程在我的环境里跑了大概两周,用了一张消费级显卡配合混合精度训练。如果你用的是多卡环境,要特别注意序列长度的负载均衡,不同病人的序列长度差异很大,直接贪心拼batch会导致显存利用率很惨。
3.4 训练工程与成本控制
医疗时序数据量很大,尤其是ICU高频数据。训练工程上我强调三件事。
第一,数值稳定性。时间衰减项里log1p的作用不只是平滑衰减,还能防止大数溢出。ICU里一个病危患者一周的时间戳跨度也就是六万秒,但如果数据里有随访数年的慢病数据,跨度可以到千万秒量级。log压缩能把跨多个数量级的时间间隔拉回到可控范围。
第二,混合精度训练。医疗时序模型的数值范围本来就宽,FP16训练容易出现梯度溢出。我采用的是梯度缩放加动态损失缩放,同时只对Attention层启用FP16,其他层保持FP32。这样既能节省显存,又能保住稳定性。
第三,早停评估。预训练阶段没有自带标签,怎么判断模型有没有学好?我在预训练过程中定期跑一个冻结特征的小任务:用当前模型编码一批带标签的时间序列,学一个线性分类器看AUROC。这个指标不需要从头训练模型,但能直观判断预训练进度。AUROC超过0.75之后基本就进入平台期,再继续预训练的意义不大。
4. 下游适配与效果评估
4.1 不同下游任务怎么接
基座模型预训练产出的是一个通用时间序列编码器。下游任务不需要重新训练整个模型,只需要在这个编码器之上做轻量适配。
对于时序分类任务,比如院内死亡预测、急性肾损伤预测、术后感染预测,我采用的是“编码加池化加分类头”的结构。把窗口内的所有事件编码成token表示,然后做时间衰减池化:越近的token权重越大,时间越久远的token权重越低。池化结果输入一个两层的MLP分类头。微调时,分类头可以全量训练,基座模型只微调最后两层,这样训练速度快,也不容易在小样本上过拟合。
对于事件时间预测任务,比如“预测下一次化验检查发生在何时”,就不能只输出一个标量了。我这里用了一个离散化的方式:把未来时间窗口切成多个桶,比如未来6小时、12小时、24小时、48小时、72小时,要求模型预测事件落在哪个桶里。这比直接回归时间戳更稳定,也更好评估。
在少样本场景下,基座模型的优势特别明显。只给1%标注数据时,从零训练一个分类模型基本不可能,而基座模型只需要学一个线性头,通常可以做到AUROC在0.75到0.80之间。到了10%标注数据时,微调全模型效果会更好。坦白讲,在原始数据上直接插值建模的baseline也能随着数据量增加而提升,但样本越少,基座模型相对优势越大。
4.2 效果评估指标怎么选
医疗时序数据普遍面临类别极度不平衡的问题,比如院内死亡率可能只有5%,急性肾损伤发生率也普遍低于10%。在这种场景下,只看AUROC会给人误导,因为AUROC对不平衡没那么敏感,看起来数值很高,实际正例召回率很低。
我强烈建议把AUPRC(精确率召回率曲线下面积)作为主要指标。AUPRC对少数类敏感,能够更真实地反映模型对正例的识别能力。对于时序事件预测,还可以在动态时间窗口上计算动态AUPRC:预测窗口越短,难度越大,指标越低,这能反映模型在不同预警提前量下的性能变化。
校准度也不能忽视。临床场景里,模型输出的概率要被医生当作决策依据,如果概率校准差,比如预测死亡率0.8时实际死亡率只有0.3,那这个模型是没法用的。我用期望校准误差(ECE)来评估,并且会在实际部署前做概率修正。这里有个技巧:对模型输出的logits做一个温度缩放,温度系数在校验集上网格搜索,用ECE最小化来选择。
4.3 实际效果预期与局限
从实际效果来看,基座模型在院内死亡预测、ICU再插管预测、急性肾损伤预测这类任务上,相对传统插值加LightGBM的baseline,AUPRC大约能提升5到10个百分点。提升幅度和任务相关:那些和采样频率强相关的任务提升最明显,而那些和观测值本身强相关的任务(比如直接预测某个检验结果数值)提升有限。
但基座模型不是万能钥匙。一个明显的短板是:可解释性比结构化模型差。LightGBM还能拿到特征重要性,知道哪些指标贡献大;基座模型的高维表示很难直接映射回临床变量。如果你想说服临床科室接受这个模型,需要额外做一层归因分析,比如计算变量ID对预测结果的梯度贡献,或者用注意力权重做病例级别的解释,这本身就是一个不小的工程。
5. 常见问题与避坑指南
5.1 最容易翻车的五个现场
第一,信息泄漏。这个问题在医疗时序建模里极其普遍。归一化统计量(均值和标准差)如果用全量数据计算,训练集和验证集之间就有了间接信息流通。正确做法是先划分病人级别的数据,再在训练集内部计算统计量,最后把同一套统计量应用到验证集和测试集。对于时间序列任务,要按照时间先后划分,预测T时刻之后的事件时,T时刻之前的所有信息才能进模型。
第二,患者级别的数据泄漏。同一个病人的多次入院记录如果不做拆分,训练集和验证集里会包含同一个病人的不同时间段,模型会退化成了“记住病人”,而不是“学会建模病情演化规律”。验证集要按病人ID而不是按时间段划分,确保不同病人的数据不会交叉出现在训练和验证里。
第三,高频数据直接硬塞。心电波形这种高频数据如果作为原始信号输入,序列长度瞬间爆炸。我建议先做窗口聚合,把1秒级信号聚合成多个分位统计量,比如每分钟的P5、P50、P95、标准差,把波形信息压缩成低维特征。不要信什么“原始信号无损输入”,计算资源和收益完全不成正比。
第四,掩码遮蔽不均匀。预训练掩码如果按事件均匀随机采样,低频变量学到的东西会非常少。要按变量分层的掩码策略,保证每个变量的掩码事件数量基本均衡。
第五,盲目追求长的上下文窗口。窗口越长,序列越长,注意力计算复杂度是平方级的。我试过用7天窗口加1024事件上限,训练时间整整翻了三倍,但下游任务效果几乎没有变化。后来的经验是:先用小窗口训练一版模型作为对照baseline,再逐步拉长窗口,如果baseline指标没有明显变化,就不要再加长。
5.2 合规与数据安全的硬性要求
医疗数据的使用有严格的合规边界,说几个实操层面的纲领性要求。
首先是去标识化。病人ID、出生日期、入院日期这些直接标识符不能出现在模型输入里。我实际的做法是:把病人ID重映射成内部随机ID,把入院日期偏移到某一周的周一作为锚点,所有时间都转换为相对入院时间。这一步做在预处理最前面,之后任何环节都接触不到原始标识信息。
其次是训练环境的隔离。预训练模型和下游微调模型需要在同一计算环境内完成,防止中间表示以文件形式流转出去时泄漏信息。如果必须跨团队协作,中间表示也要经过脱敏和聚合处理。
最后是模型的部署审计。医疗模型如果直接参与临床辅助决策,需要留下完整的推理日志,包括输入变量列表、输出概率、模型版本、推理时间。这不是可选的,部署环节缺了这个,基本不可能通过院内审核。
5.3 算力不够时的替代路径
不是所有人都有一块新卡来做预训练。算力受限时,我建议用三步替代方案。
第一步:用公开的ICU数据集做小规模预训练。公开的重症监护数据集(比如某ICU数据集)虽然和自有数据的分布有差异,但时间序列的基础模式是相通的。在公开数据上预训练一个模型,可以验证架构设计是否合理,也可以作为后续继续训练的起点。
第二步:领域自适应预训练。用自己的数据在公开数据训练出的模型基础上继续训练,不需要重新预训练。这个阶段通常只需要训练几百步,算力需求比从头预训练低一个量级。
第三步:减少预训练任务数量。如果你实在连自监督训练的算力都紧张,可以考虑只用掩码重建任务,把对比学习和事件预测任务去掉。这样模型照样能学到基础时序表示,只是在下游少样本任务上提升没那么明显。根据我实测,去掉后两类任务,预训练时间大约缩短一半,少样本提升幅度会下降三成左右。
结束语:一套值得长期投入的路线
这套基座模型的路线,我前后调了三轮才相对稳定。期间印象最深的一个教训是:不要在数据规整阶段偷懒,也不要试图让模型替你做本该在预处理阶段解决的事情。事件化表示、时间连续编码、时间衰减注意力、缺失模式感知,这四个核心设计彼此咬合,只改其中一环而没有整体配合,效果都会打折扣。
我个人实际操作中发现,最值得投入精力的其实不是模型结构本身,而是数据组织层面的工程锻炼。事件表的构建、时间戳的清洗、变量变换映射、窗口切分策略——这些工作直接决定模型能够学到什么。结构化数据的版本管理也要重视,一个变量ID映射表的错误可能导致整个预训练推倒重来,在项目初期就做好变量字典登记。
如果后续算力和数据条件允许,我会在这个基座模型上继续扩展两个方向。一是把医疗文本数据(病历、出院小结)和时序数据用联合预训练的方式对齐,让模型同时理解“医生写了什么”和“机器测到了什么”。二是把模型输出校准到可以在床旁直接做临床决策支持的程度,而不只是停留在离线实验阶段。这需要更严格的临床验证,路还长,但方向我确信是对的。