第一次拿到一个重症监护数据集的时候大多数人都会愣一下里面的时间轴不是整整齐齐的而是一堆参数参差不齐的散点。心跳、血压、呼吸机参数是高频连续的一次普通查房记录里却只有七个时间戳上一秒还在按秒采样的监护仪下一秒因为护理操作急剧降采样一个地区医院的数据里记录间隔甚至会呈幂律分布。医疗时序基座模型就是在这个背景下被推出来的方案不再把不规则的原始观测强行压成等距表格而是直接在这些不均匀的时间点上进行预训练和推理。这套思路解决的恰恰是医疗数据落地建模时最磨人的一个核心问题——不规则采样。如果你之前做过医疗时序相关的任务比如院内预后预测、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的baselineAUPRC大约能提升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映射表的错误可能导致整个预训练推倒重来在项目初期就做好变量字典登记。如果后续算力和数据条件允许我会在这个基座模型上继续扩展两个方向。一是把医疗文本数据病历、出院小结和时序数据用联合预训练的方式对齐让模型同时理解“医生写了什么”和“机器测到了什么”。二是把模型输出校准到可以在床旁直接做临床决策支持的程度而不只是停留在离线实验阶段。这需要更严格的临床验证路还长但方向我确信是对的。