TensorFlow实战ECG身份识别:从Softmax分类到三元组度量学习

📅 发布时间:2026/10/13 2:04:03
TensorFlow实战ECG身份识别:从Softmax分类到三元组度量学习
1. 从心跳密码到身份认证为什么ECG值得被深度学习重新审视第一次接触ECG身份识别这个方向很多人会本能地皱眉心电图不是医院里查心脏病的吗怎么跟身份认证扯上关系了这个疑问恰恰点中了这个领域最核心的价值——ECG信号里藏着每个人独一无二的生理特征而这些特征足够稳定、足够难伪造天然适合做生物识别。我在做这个系列的前两篇时已经把数据预处理、信号切分、基础CNN分类这几块跑通了。到了第三篇真正要啃的硬骨头是怎么把TensorFlow深度学习的能力从能分类推进到能识别。分类和识别听起来差不多实际差别很大。分类通常是在一个封闭集合里判断这条信号属于A还是B而身份识别要求模型面对一个开放集合——今天注册了20个人明天来了第21个人模型得能判断这个人不在库里而不是硬塞进已有的某一类。这个差别直接决定了网络结构、损失函数、评估方式全都要重新设计。这篇内容适合三类人看一是已经跑过基础CNN、想往度量学习方向进阶的开发者二是做生物识别相关课题、需要一套可复现方案的研究者三是单纯对深度学习怎么处理一维生理信号感兴趣的技术爱好者。我会把第三阶段的核心技术点——从Softmax分类到度量学习的范式转换、三元组损失的实现细节、以及TensorFlow里那些容易踩的坑——全部拆开讲清楚代码和参数都给到能直接抄的程度。需要先说明一点ECG身份识别目前主流的技术路线有两条一条是基于特征工程的传统方法提取QRS波群、RR间期等手工特征再喂给分类器另一条是端到端的深度学习。这个系列走的是第二条路原因很简单——手工特征对个体差异的刻画能力有天花板而深度网络能自动学到人眼看不出来的细微模式。第三篇的重点就是把这个自动学习的能力从分类任务迁移到识别任务上。2. 为什么Softmax分类器撑不起真正的身份识别2.1 封闭集分类的舒适区与它的天花板前两篇里我们用的标准结构是卷积层提取特征全连接层降维最后接一个Softmax输出N类的概率分布。这套结构在封闭集测试里表现不错——假设数据集里有20个人每人若干段心跳信号训练完在测试集上准确率能到95%以上。但这里有个容易被忽略的陷阱Softmax本质上是在学类与类之间的决策边界而不是样本与样本之间的相似度。什么意思Softmax训练出来的特征空间只保证不同类别的样本被一条条超平面分开但它不保证同一个人的两段信号在特征空间里靠得足够近。极端情况下同一个人在不同时间、不同心率状态下采集的两段信号可能被映射到特征空间里相距很远的位置只是恰好都落在这个人的决策区域里。一旦来了个没注册过的新人Softmax会强行把他分到某个已有类别因为它压根没有未知这个概念。我实测过一个很能说明问题的场景用20人数据训练Softmax模型然后拿第21个人的信号去测试模型给出的置信度分布居然还挺自信经常以70%以上的概率把陌生人判成某个已注册用户。这就是封闭集分类器的根本局限——它被设计成必须做选择哪怕这个选择毫无道理。2.2 度量学习的核心思路让距离说话要解决这个问题思路得从分类转向度量。度量学习的核心思想是不直接学类别标签而是学一个特征嵌入空间在这个空间里同一个人的信号距离近不同人的信号距离远。识别的时候不再看属于哪一类而是看和库里哪个模板最像像到什么程度。这个转变带来的好处是结构性的。首先模型天然支持开放集识别——设定一个距离阈值超过阈值就判定为未知身份不需要为每个新用户重新训练。其次特征嵌入一旦学好注册新用户只需要采集几段信号算个模板向量存进去不用动模型本身这在工程上极其友好。第三度量学习对样本量的要求相对宽松因为它的监督信号来自样本对或样本三元组的关系而不是单个样本的标签数据利用率更高。在TensorFlow里实现度量学习最常用的两种损失是对比损失Contrastive Loss和三元组损失Triplet Loss。对比损失每次看一对样本告诉模型这对是同类拉近或这对是异类推远。三元组损失每次看三个样本——一个锚点、一个同类正样本、一个异类负样本要求锚点到正样本的距离比到负样本的距离小至少一个边界值。实践中三元组损失效果通常更好因为它同时约束了类内和类间关系收敛也更稳定。2.3 两种范式的实测对比为了让你直观感受差别我把两种方案在同一份数据上的表现列出来。数据集是20个受试者、每人约200段心跳周期信号训练集和测试集按人划分保证测试集的人不在训练集里模拟开放集场景。对比维度Softmax分类方案三元组度量学习方案封闭集准确率96.2%94.8%开放集识别率含未知拒绝61.3%89.7%注册新用户所需操作重新训练整个模型仅计算模板向量对样本量敏感度高每类需充足样本中依赖样本对质量特征空间可解释性差好距离即相似度封闭集上Softmax略胜但开放集上差距是碾压性的。这个表格基本能解释为什么现在主流的ECG身份识别研究都往度量学习方向走。当然Softmax方案也不是完全没用——它可以作为预训练阶段先让卷积层学到有意义的特征再切换到三元组损失做微调这个组合策略后面会详细讲。3. 三元组损失的TensorFlow实现从采样策略到梯度稳定3.1 三元组的构造比损失函数本身更关键很多人以为三元组损失难在公式其实真正难的是怎么选三元组。如果随机采样绝大多数三元组都是简单样本——锚点和正样本本来就离得近锚点和负样本本来就离得远这种三元组算出来的损失接近零梯度几乎没有训练效率极低。更糟的是如果采到错误三元组正样本比负样本还远损失会很大但梯度方向可能不稳定导致训练震荡。主流做法是在线难例挖掘Online Hard Mining在一个batch内部动态构造三元组选出那些锚点到正样本的距离已经大于锚点到负样本距离的难例。具体到TensorFlow实现我习惯在一个batch里放P个人、每人K段信号比如P8K4batch size32然后对每个锚点选距离最远的正样本和距离最近的负样本组成三元组。这里有个实操细节值得强调batch的构成方式直接决定挖掘质量。如果batch里每个人只有1段信号那根本没法构造正样本对。所以必须保证每个batch里每个人至少出现2次我一般用K4这样正样本选择有足够余地。另外P不能太小否则负样本池太浅挖出来的最近负样本可能不够难。P8到16是比较舒服的区间再大显存吃不消。3.2 损失函数的数值稳定性处理三元组损失的标准形式是L max(0, d(a,p) - d(a,n) margin)其中margin是个超参数控制类内和类间距离的最小间隔。看起来简单但直接实现容易出问题。第一个坑是距离度量方式——用欧氏距离还是余弦距离ECG信号的特征嵌入我建议用欧氏距离因为余弦距离对向量的模长不敏感而模长在ECG特征里可能携带幅度信息丢掉可惜。第二个坑是margin的选取。margin太小类间区分度不够margin太大大量三元组损失被截断为零梯度消失。我试过0.2到1.0的范围在归一化特征L2 norm的前提下margin0.3到0.5比较合适。注意特征一定要做L2归一化否则距离尺度会随训练漂移margin就失去意义了。第三个坑是数值溢出。计算距离时如果直接算平方和再开方中间结果可能很大。稳妥的做法是用tf.maximum做截断并且对距离加一个极小的epsilon防止梯度出现NaN。下面是我实际用的损失实现import tensorflow as tf def triplet_loss(anchor, positive, negative, margin0.4): # 特征先做L2归一化保证距离尺度稳定 anchor tf.math.l2_normalize(anchor, axis1) positive tf.math.l2_normalize(positive, axis1) negative tf.math.l2_normalize(negative, axis1) # 欧氏距离加epsilon防NaN pos_dist tf.sqrt(tf.reduce_sum(tf.square(anchor - positive), axis1) 1e-12) neg_dist tf.sqrt(tf.reduce_sum(tf.square(anchor - negative), axis1) 1e-12) # 标准三元组损失 basic_loss pos_dist - neg_dist margin loss tf.reduce_mean(tf.maximum(basic_loss, 0.0)) return loss这段代码看着简单但每一行都有讲究。l2_normalize把特征投到单位球面上距离范围被限制在0到2之间margin的物理意义就清晰了。1e-12那个epsilon是防止距离为零时开方梯度爆炸我踩过这个坑——不加的话训练到后期偶尔会冒出NaN排查半天才发现是这里。3.3 在线难例挖掘的完整实现光有损失函数不够还得配上难例挖掘。下面这段是在batch内构造三元组的逻辑核心是构造一个距离矩阵然后对每个锚点找最难的正负样本def batch_hard_triplet_loss(embeddings, labels, margin0.4): # embeddings: [batch, dim], labels: [batch] embeddings tf.math.l2_normalize(embeddings, axis1) # 构造两两距离矩阵 dot_product tf.matmul(embeddings, embeddings, transpose_bTrue) square_norm tf.linalg.diag_part(dot_product) distances tf.expand_dims(square_norm, 0) - 2.0 * dot_product tf.expand_dims(square_norm, 1) distances tf.maximum(distances, 0.0) distances tf.sqrt(distances 1e-12) # 构造mask同类为1异类为0 labels tf.reshape(labels, [-1, 1]) mask_same tf.cast(tf.equal(labels, tf.transpose(labels)), tf.float32) mask_diff 1.0 - mask_same # 对每个锚点选最远的正样本 pos_dist distances * mask_same hardest_pos tf.reduce_max(pos_dist, axis1) # 对每个锚点选最近的负样本把同类距离设为极大值排除 neg_dist distances mask_same * 1e6 hardest_neg tf.reduce_min(neg_dist, axis1) loss tf.maximum(hardest_pos - hardest_neg margin, 0.0) return tf.reduce_mean(loss)这段代码里有个细节容易写错选最近负样本时要把同类样本的距离屏蔽掉否则可能选到同类样本当负样本。我用的是加一个大常数1e6的方式比用tf.where更简洁但要注意这个常数不能太大导致数值问题1e6在float32下是安全的。提示难例挖掘有个副作用——训练早期全是难例梯度可能过大导致发散。我的做法是前几个epoch用随机三元组热身等特征空间初步成形后再切换到难例挖掘。这个切换点一般在验证集准确率不再快速上升的时候。4. 网络结构设计一维卷积怎么吃透心跳信号4.1 为什么不用二维卷积那套直接搬ECG信号是一维时间序列采样率通常在250Hz到500Hz之间一段心跳周期大概0.6到1秒算下来单段信号长度在150到500个点。有人图省事把一维信号reshape成二维图像再套ResNet我试过效果并不好。原因在于ECG的局部模式是沿时间轴的一维结构——QRS波群的陡峭上升、T波的缓坡这些形态特征在时间轴上有明确的先后关系强行拉成二维会破坏这种时序邻接性卷积核学起来事倍功半。所以网络主体应该用一维卷积Conv1D。一维卷积核在时间轴上滑动天然匹配ECG的形态特征提取需求。我的经验是浅层用较大的卷积核比如kernel_size7到11捕捉QRS波群这种宽形态深层用较小的核kernel_size3到5捕捉细节纹理。这个先大后小的设计思路和图像领域正好相反图像是浅层小核抓边缘、深层大核抓语义而ECG的形态特征尺度本身就集中在浅层。4.2 一个实测有效的骨干网络下面是我在多个数据集上验证过的骨干结构参数量控制在50万以内单卡训练毫无压力import tensorflow as tf from tensorflow.keras import layers, Model def build_ecg_encoder(input_len300, embed_dim128): inputs layers.Input(shape(input_len, 1)) # Block 1: 大核捕捉QRS宽形态 x layers.Conv1D(32, 11, strides2, paddingsame)(inputs) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(2)(x) # Block 2 x layers.Conv1D(64, 7, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(2)(x) # Block 3: 小核抓细节 x layers.Conv1D(128, 5, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) # Block 4 x layers.Conv1D(128, 3, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) # 全局池化替代Flatten减少参数量且对长度变化更鲁棒 x layers.GlobalAveragePooling1D()(x) x layers.Dense(embed_dim)(x) # 注意这里不加激活函数归一化在损失函数里做 return Model(inputs, x, nameecg_encoder)这个结构有几个设计决策值得展开说。第一用GlobalAveragePooling1D而不是Flatten。Flatten会把特征图拉平参数量爆炸而且对输入长度敏感——换个采样率信号长度变了全连接层就废了。全局平均池化对每个通道求时间轴平均参数量为零且对长度变化天然鲁棒。第二嵌入维度选128。太小比如32特征表达能力不够太大比如512容易过拟合且计算量上升。128在多个数据集上都是甜点区。第三最后不加激活。因为损失函数里会做L2归一化这里再加个tanh或relu反而多此一举还可能引入饱和区。4.3 数据增强ECG信号能怎么造深度学习模型最怕过拟合ECG数据尤其如此——同一个人不同时间采集的信号如果采集条件接近相似度极高模型容易记住这段信号长这样而不是这个人的心跳模式长这样。数据增强是缓解过拟合的关键手段但ECG的增强不能像图像那样随便旋转裁剪得符合生理和信号特性。我常用的几种增强方式时间偏移把信号整体平移几个采样点模拟心跳起始点检测的误差、幅度缩放乘以0.9到1.1的随机因子模拟电极接触阻抗变化、加性噪声叠加小幅度高斯白噪声模拟肌电干扰、时间扭曲对时间轴做轻微非线性拉伸模拟心率变异。这几种增强里时间偏移和幅度缩放最安全几乎不会破坏生理特征时间扭曲要控制幅度扭曲太狠会把QRS波群拉变形反而制造了错误标签。注意数据增强只在训练时做验证和测试必须用原始信号。另外增强的随机种子要固定否则每次跑结果都不一样没法复现。5. 训练流程与评估开放集识别到底怎么测5.1 分阶段训练策略直接上三元组损失从零训练收敛会比较慢而且容易陷入局部最优。我习惯用两阶段策略第一阶段用Softmax分类做预训练让卷积层先学到有判别力的基础特征第二阶段去掉Softmax头接上嵌入层用三元组损失微调。第一阶段的学习率可以设大一点1e-3用Adam优化器训练到验证准确率不再上升。第二阶段学习率要降下来1e-4因为此时特征已经不错了大学习率会把好不容易学到的结构破坏掉。第二阶段通常跑30到50个epoch就够再往后提升有限还容易过拟合。# 第一阶段Softmax预训练 base_model build_ecg_encoder(input_len300, embed_dim128) pretrain_out layers.Dense(num_train_classes, activationsoftmax)(base_model.output) pretrain_model Model(base_model.input, pretrain_out) pretrain_model.compile(optimizertf.keras.optimizers.Adam(1e-3), losssparse_categorical_crossentropy, metrics[accuracy]) pretrain_model.fit(train_ds, validation_dataval_ds, epochs50) # 第二阶段三元组微调 encoder Model(base_model.input, base_model.output) encoder.compile(optimizertf.keras.optimizers.Adam(1e-4), lossbatch_hard_triplet_loss) encoder.fit(train_ds_triplet, validation_dataval_ds_triplet, epochs40)这里有个细节第二阶段的train_ds_triplet要保证每个batch里每个人出现多次前面说的P8、K4的采样逻辑就体现在这里。TensorFlow的tf.data可以用group_by_window实现按人分组采样或者简单点自己写个生成器按人索引采样。5.2 开放集评估的三种协议评估是ECG身份识别里最容易糊弄过去的部分。很多人只报一个封闭集准确率就完事但那个数字对实际应用几乎没有参考价值。真正有意义的评估要覆盖三种协议协议一闭集识别Closed-set。测试集里的人都在训练集里出现过看模型能不能正确匹配。这是最宽松的主要用来验证特征学习的基本质量。协议二开集识别Open-set。测试集里混入训练时没见过的人模型需要判断这是已知身份还是这是陌生人。评估指标用检测错误权衡曲线DET或等错误率EEREER越低越好。我实测三元组方案在20人注册、10人未知的设定下EER能压到5%左右。协议三增量注册Incremental Enrollment。模型固定不动往模板库里加新用户的模板向量看识别率怎么变化。这个协议最能体现度量学习注册即用的优势——加新用户不需要重训只需要算几段信号的平均嵌入存进去。评估协议核心指标典型表现适用场景闭集识别Top-1准确率94%以上验证特征质量开集识别EER5%左右真实身份认证增量注册注册后准确率与闭集接近工程落地5.3 阈值选取的实操经验开集识别里判定是否陌生人靠的是距离阈值。阈值定高了陌生人被误判为已知用户安全风险定低了已知用户被拒体验差。这个权衡没有标准答案得根据应用场景调。我的做法是在验证集上画DET曲线然后根据业务需求选点。如果是安全敏感场景比如门禁宁可误拒也不能误放阈值往低取如果是便捷场景比如个性化推荐可以适当放宽。另外阈值不是全局统一的——不同用户的类内距离方差不一样有人心跳信号稳定有人波动大。进阶做法是为每个用户单独设阈值用该用户注册时几段信号的距离分布来定这个技巧能把EER再降1到2个百分点。6. 那些文档里不会写的踩坑记录6.1 数据泄漏最隐蔽也最致命我见过不止一个项目栽在数据泄漏上。ECG数据集的常见组织方式是每人一个文件夹里面若干段信号如果切分训练测试集时按信号随机切同一个人可能既有信号在训练集又有信号在测试集模型等于在背答案。正确做法是按人切分——训练集的人、验证集的人、测试集的人完全不重叠。这个原则在封闭集评估里可能只让准确率掉几个点但在开放集评估里是生死线不按人切分的话EER能虚低到1%以下纯属自欺欺人。6.2 采样率不一致的坑不同数据集、不同设备的ECG采样率经常不一样有250Hz的有360Hz的有500Hz的。如果直接把不同采样率的信号混在一起训练模型会懵——同样一段心跳在250Hz下是200个点在500Hz下是400个点卷积核看到的形态尺度完全不同。解决办法是统一重采样到固定频率我一般统一到250Hz用scipy.signal.resample做抗混叠重采样。重采样后还要统一切分长度比如固定300个点一段。6.3 Batch Normalization在推理时的陷阱BN层在训练时用batch统计量推理时用滑动平均统计量。如果训练时batch size太小比如小于16滑动平均统计量会不准推理性能明显下降。三元组训练因为要保证每人多次出现batch size通常不会太小但如果你为了省显存把batch压到8就要小心BN的问题。替代方案是用Group Normalization它对batch size不敏感代价是训练稍慢一点。6.4 模型保存与部署的细节TensorFlow保存模型时如果用了自定义损失函数比如我们的三元组损失加载模型必须提供custom_objects参数否则会报unknown loss。更稳妥的做法是只保存编码器encoder因为推理阶段根本用不到损失函数只需要编码器把信号转成嵌入向量。保存编码器用model.save(encoder.h5)加载时直接tf.keras.models.load_model干净利落。推理流程也很简单把待识别信号过一遍编码器得到嵌入向量和模板库里每个用户的模板向量算欧氏距离取最小距离如果小于阈值就判定为该用户否则判为陌生人。整个推理过程不需要GPUCPU上单次识别在10毫秒以内完全满足实时性要求。7. 从实验到落地还差哪几步跑通实验和真正能用之间隔着几道工程化的坎。第一道是模板库的管理——每个用户的模板向量怎么存、怎么更新、怎么处理用户重新注册。我的建议是模板向量存成numpy数组或直接存数据库的blob字段每个用户存3到5段注册信号的平均嵌入比存单段更稳定。第二道是信号质量把关——不是所有采集到的心跳信号都适合做识别运动伪影严重、基线漂移大的信号应该被拒绝否则会污染模板库。可以训练一个轻量的质量分类器或者用简单的信噪比阈值先过滤。第三道是跨天稳定性。同一个人今天和一个月后的心跳信号特征会不会漂移这是ECG身份识别绕不开的问题。我的实测经验是在电极位置相对固定的前提下跨天EER会上升2到3个百分点但仍在可用范围。如果电极位置变化大性能下降会明显得多。所以实际部署时采集设备的佩戴方式要尽量标准化这是保证长期稳定性的前提。这个方向做到现在我最大的体会是ECG身份识别的技术瓶颈已经不在模型结构而在数据质量和评估规范。一个结构朴素的度量学习网络配上干净的数据和严格的开放集评估效果远好过一个花哨的网络配一份有泄漏的数据。第三篇把度量学习这条线讲透了后续如果继续深入可以往多模态融合ECG加PPG、轻量化部署模型量化到移动端这些方向走那些是另一个层面的问题了。