1. 从心电到身份这个项目到底在做什么心电信号能用来识别身份这件事第一次听到的人多半会觉得有点玄。我们平时说生物识别第一反应是指纹、人脸、虹膜顶多再加个声纹。心电图ECG这东西在医院里是用来查心律不齐、心肌缺血的怎么就跟你是谁扯上关系了答案藏在心脏的物理结构里。每个人的心脏大小、形状、在胸腔里的位置、心肌细胞的电传导路径都不一样这些差异会直接反映在ECG波形的形态上——P波的幅度、QRS波群的宽度、T波的形状、各个间期的时间长度组合起来就形成了一个足够独特的电指纹。更关键的是ECG是活体信号人活着才有想伪造难度极高这一点比指纹可以被硅胶膜复制和人脸可以被照片或面具欺骗有天然优势。这个项目要解决的问题很具体给定一段原始ECG信号判断它属于哪一个已注册的用户。这是一个典型的多分类任务输入是一维时间序列输出是身份标签。我选择用TensorFlow来做原因不复杂——生态成熟、部署路径清晰、从研究到落地的工具链完整而且Keras高层API让模型迭代速度足够快。这篇文章是这个系列的第三篇前两篇分别处理了信号预处理和特征工程这一篇聚焦模型构建、训练策略和身份识别环节的完整实现。适合已经对深度学习有基本概念、想找一个完整项目练手的读者也适合做生物特征识别方向、想了解ECG这条技术路线的从业者。我会把参数选择、踩过的坑、调参的直觉都写清楚代码可以直接抄。2. 整体方案设计与技术选型思路2.1 为什么是深度学习而不是传统特征分类器传统ECG身份识别走的是手工特征浅层分类器的路子先提取R峰位置计算RR间期、QRS宽度、P波幅度等几十个特征再喂给SVM或者随机森林。这套方法在数据干净、样本量小的时候能跑出不错的结果但有几个绕不开的问题。第一手工特征依赖R峰检测的准确性。一旦信号质量差、R峰漏检或误检后面所有特征全部崩盘。第二手工特征能表达的信息有限很多细微的波形形态差异根本没法用几个标量描述清楚。第三不同数据集、不同导联、不同采集设备下同一套特征的可迁移性很差。深度学习的好处在于端到端把原始或轻度预处理后的信号直接喂进去让网络自己去学该关注什么。卷积核在时间轴上滑动天然适合捕捉ECG的局部形态特征深层堆叠又能组合出全局的节律模式。我实测下来在同样数据量下一维CNN比手工特征SVM的准确率高出好几个百分点而且对信号质量的鲁棒性明显更好。2.2 一维CNN还是RNN还是Transformer这是选型时最纠结的地方。我把三条路线的取舍逻辑列一下方案优势劣势适用场景1D-CNN训练快、参数少、局部形态捕捉强长程依赖建模弱单心跳或短片段识别LSTM/GRU时序依赖建模好训练慢、难并行、易过拟合长序列节律分析Transformer全局注意力、表达力强数据需求大、计算重大规模数据、长序列ECG身份识别的核心判别信息其实集中在单个心跳周期内的波形形态上。也就是说局部形态比长程节律更重要。基于这个判断我最终选了1D-CNN为主干在末端接全局池化和全连接做分类。这个选择在数据量中等几千到几万条的情况下最稳训练也快方便反复迭代。如果你手上的数据量特别大或者要做跨长时段的连续识别可以在CNN后面叠一层注意力机制但那是后话先把CNN这条基线打扎实。2.3 输入形态单心跳切片还是固定长度窗口这个决定直接影响整个数据管线和模型结构。两种做法单心跳切片以每个R峰为中心向前向后各取固定长度比如覆盖一个完整心动周期每条样本就是一个心跳。优点是样本对齐好、判别信息集中缺点是需要精确的R峰检测且丢失了心跳间的节律信息。固定长度窗口直接切固定时长比如5秒或10秒的片段不依赖R峰。优点是鲁棒、实现简单缺点是窗口内可能包含多个心跳且边界不对齐。我选的是固定长度窗口长度设为覆盖约2到3个心跳周期。理由是R峰检测本身就是一个误差源我不想让上游的误差传导到识别环节而且多心跳窗口能让网络同时看到形态和节律信息更丰富。代价是模型要自己学会在窗口内定位关键区域但这正是CNN擅长的事。提示窗口长度不要拍脑袋定。先统计你数据集的平均心率算出平均RR间期再乘以你想要的覆盖心跳数。比如平均心率75 bpmRR约0.8秒覆盖3个心跳就是2.4秒采样率若为360Hz窗口长度就是864个采样点。取整到900或1024都行。3. 数据准备与预处理的关键细节3.1 信号清洗滤波不是越干净越好原始ECG里混着三类噪声基线漂移低频0.5Hz以下、工频干扰50Hz或60Hz、肌电噪声高频。标准做法是带通滤波但通带范围的选择很讲究。我一开始用了0.5–40Hz的带通结果发现QRS波群的高频成分被削掉了一部分识别准确率反而下降。后来把上限放宽到45Hz保留了更多形态细节效果明显改善。下限保持在0.5Hz是为了去掉基线漂移但如果你用的是已经很干净的数据集下限可以放到1Hz甚至更高避免过度滤波损伤ST段信息。import numpy as np from scipy.signal import butter, filtfilt def bandpass_filter(signal, lowcut0.5, highcut45.0, fs360, order4): nyq 0.5 * fs low lowcut / nyq high highcut / nyq b, a butter(order, [low, high], btypeband) # 用filtfilt做零相位滤波避免相位偏移 return filtfilt(b, a, signal)这里有个细节一定要用filtfilt而不是lfilter。lfilter会引入相位延迟导致滤波后的波形在时间轴上整体偏移QRS波群的位置就变了。filtfilt做前向反向两次滤波相位是零波形位置不变。这个坑我踩过当时怎么调模型都不对最后发现是滤波把R峰位置挪了。3.2 归一化z-score还是min-maxECG的幅度因人而异同一个人不同时间采集的幅度也可能不同。归一化是必须的但选哪种有讲究。z-score减均值除标准差对幅度缩放不敏感适合形态识别。min-max缩放到[0,1]或[-1,1]保留相对幅度信息但对异常值敏感。身份识别主要看形态所以我用z-score而且是逐样本归一化每条样本自己减自己的均值、除自己的标准差而不是用整个数据集的统计量。逐样本归一化的好处是彻底消除个体间的幅度差异逼着网络去学形态而不是幅度。实测下来逐样本归一化比全局归一化准确率高约2个百分点。def zscore_normalize(signal): mean np.mean(signal) std np.std(signal) if std 1e-8: return signal - mean return (signal - mean) / std3.3 数据增强ECG能怎么增强深度学习吃数据ECG标注数据又贵又难搞数据增强是提升泛化能力的必要手段。但ECG不能像图像那样随便翻转旋转得用符合信号物理特性的增强方式时间缩放把信号在时间轴上拉伸或压缩±10%模拟心率变化。注意缩放后要重新切窗口。幅度缩放整体乘以0.9–1.1的随机因子模拟电极接触阻抗变化。加性噪声叠加小幅度的高斯白噪声或肌电噪声提升抗噪能力。时间平移在窗口内随机平移几个采样点模拟R峰对齐误差。导联丢弃如果是多导联数据随机丢弃某个导联模拟导联脱落。我一般组合使用时间缩放幅度缩放高斯噪声这三种增强倍数设为2到3倍。增强太多反而会引入不真实的样本让模型学到噪声模式。注意数据增强只能在训练集上做验证集和测试集必须用原始数据否则评估结果会虚高。这个原则在信号领域同样适用。4. 模型结构设计与逐层解析4.1 主干网络五层一维卷积的堆叠逻辑我的主干是一个五层的1D-CNN每层都是卷积批归一化激活池化的标准组合。为什么是五层这是实验出来的——三层欠拟合七层过拟合且训练慢五层在准确率和训练成本之间最平衡。import tensorflow as tf from tensorflow.keras import layers, models def build_ecg_model(input_length, num_classes): inputs layers.Input(shape(input_length, 1)) # Block 1 x layers.Conv1D(32, kernel_size7, paddingsame)(inputs) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(pool_size2)(x) # Block 2 x layers.Conv1D(64, kernel_size5, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(pool_size2)(x) # Block 3 x layers.Conv1D(128, kernel_size3, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(pool_size2)(x) # Block 4 x layers.Conv1D(128, kernel_size3, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) x layers.MaxPooling1D(pool_size2)(x) # Block 5 x layers.Conv1D(256, kernel_size3, paddingsame)(x) x layers.BatchNormalization()(x) x layers.Activation(relu)(x) # 全局池化 分类头 x layers.GlobalAveragePooling1D()(x) x layers.Dropout(0.5)(x) x layers.Dense(128, activationrelu)(x) x layers.Dropout(0.3)(x) outputs layers.Dense(num_classes, activationsoftmax)(x) model models.Model(inputs, outputs) return model逐层说一下设计意图第一层用大卷积核7是为了在浅层就捕捉到较宽的波形特征比如整个QRS波群的轮廓。浅层感受野大一点能更快地看到有意义的形态单元。后面几层卷积核逐渐减小5→3→3→3是因为深层已经在处理抽象特征小卷积核参数更少、更精细能组合出复杂的模式。通道数递增32→64→128→128→256这是CNN的通用设计原则随着空间维度这里是时间维度被池化压缩通道维度要相应增加保证信息容量不丢失。每层都加BatchNormalization这是训练稳定的关键。ECG信号幅度变化大没有BN的话各层输入分布漂移严重学习率稍微大一点就发散。加了BN之后学习率可以放心用1e-3甚至更大。全局平均池化代替Flatten这是减少参数、防止过拟合的重要手段。Flatten会把特征图拉成一维长向量参数量爆炸全局平均池化对每个通道求均值参数量几乎为零而且自带正则化效果。4.2 分类头Dropout和Dense的搭配分类头部分我用了两层Dense中间夹Dropout。第一层Dense(128)做特征压缩和重组第二层Dense(num_classes)输出各类别的概率。Dropout率的选择全局池化后接0.5Dense(128)后接0.3。为什么不一样因为全局池化后的特征维度还比较大过拟合风险高用0.5压制经过Dense(128)压缩后维度已经降下来了0.3就够了太高会导致欠拟合。实操心得如果你的类别数很多比如50个以上身份Dense(128)可能不够可以加到256。但别加太多分类头参数过多是过拟合的重灾区。我试过Dense(512)验证集准确率反而掉了。4.3 参数量与计算量估算这个模型大概有多少参数粗算一下第一层卷积 7×1×3232≈256第二层 5×32×6464≈10304第三层 3×64×128128≈24704第四层 3×128×128128≈49280第五层 3×128×256256≈98560加上BN参数和分类头总共约25万参数。25万参数在深度学习里算很小的模型好处是训练快、部署轻、不容易过拟合。在普通GPU上几千条样本训练几十个epoch也就几分钟的事。如果你的数据量特别大可以适当加深加宽但我觉得没必要——ECG的判别信息就那么多模型太大反而学噪声。5. 训练策略与调参实战5.1 损失函数与优化器的选择多分类任务损失函数用稀疏分类交叉熵sparse_categorical_crossentropy因为我的标签是整数而不是one-hot。如果你用的是one-hot标签就用categorical_crossentropy。两者数学上等价只是输入格式不同。优化器用Adam初始学习率1e-3。Adam的自适应学习率对ECG这种信噪比不稳定的数据很友好不用花太多精力调学习率。我试过SGDMomentum收敛慢且对学习率敏感不推荐新手用。model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losssparse_categorical_crossentropy, metrics[accuracy] )5.2 学习率调度余弦退火比阶梯下降更稳固定学习率训练到后期容易在最优解附近震荡加个学习率调度能明显改善收敛。我用的是余弦退火Cosine Annealing学习率从1e-3平滑降到1e-6比阶梯式下降更平滑。def cosine_schedule(epoch, lr): total_epochs 100 min_lr 1e-6 return min_lr 0.5 * (1e-3 - min_lr) * (1 np.cos(np.pi * epoch / total_epochs)) lr_callback tf.keras.callbacks.LearningRateScheduler(cosine_schedule)余弦退火的好处是前期学习率大、快速下降后期学习率小、精细调整整个训练过程没有突变loss曲线很平滑。5.3 早停与模型保存早停是防止过拟合的第一道防线。监控验证集loss如果连续15个epoch没有改善就停止训练并恢复验证集loss最低时的权重。early_stop tf.keras.callbacks.EarlyStopping( monitorval_loss, patience15, restore_best_weightsTrue, verbose1 ) checkpoint tf.keras.callbacks.ModelCheckpoint( best_ecg_model.h5, monitorval_accuracy, save_best_onlyTrue, verbose1 )restore_best_weightsTrue这个参数很关键。没有它的话早停触发时模型停留在最后一个epoch的权重可能已经过拟合了有了它自动恢复到验证集表现最好的那个时刻。5.4 类别不平衡的处理身份识别数据集天然不平衡——有些人的数据多有些人少。如果不处理模型会偏向多数类少数类的识别率惨不忍睹。处理方法有三种类别权重在fit时传入class_weight让少数类的损失权重更大。过采样对少数类样本重复采样让各类数量接近。数据增强补偿对少数类做更多增强。我一般用类别权重实现简单且不改变数据分布。计算方式是反比于类别频率from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( balanced, classesnp.unique(y_train), yy_train ) class_weight_dict dict(enumerate(class_weights))然后在fit里传class_weightclass_weight_dict。实测下来这招能把少数类的召回率提升10个百分点以上。6. 身份识别环节的完整实现6.1 训练主循环把前面的模块串起来完整的训练流程如下# 假设 X_train, y_train, X_val, y_val 已经准备好 # X shape: (样本数, 窗口长度, 1) input_length X_train.shape[1] num_classes len(np.unique(y_train)) model build_ecg_model(input_length, num_classes) model.compile( optimizertf.keras.optimizers.Adam(1e-3), losssparse_categorical_crossentropy, metrics[accuracy] ) callbacks [ tf.keras.callbacks.EarlyStopping( monitorval_loss, patience15, restore_best_weightsTrue, verbose1 ), tf.keras.callbacks.ModelCheckpoint( best_ecg_model.h5, monitorval_accuracy, save_best_onlyTrue, verbose1 ), tf.keras.callbacks.LearningRateScheduler(cosine_schedule) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs100, batch_size64, class_weightclass_weight_dict, callbackscallbacks, verbose1 )batch_size我用的64。太小如16训练慢且梯度噪声大太大如256泛化可能变差。64是个稳妥的中间值。如果你的显存够可以试128但记得同步调整学习率。6.2 识别阶段的推理流程训练完之后识别一个新用户的过程是这样的def identify_ecg(model, raw_signal, fs360, window_length900): # 1. 滤波 filtered bandpass_filter(raw_signal, fsfs) # 2. 切窗口如果信号长于窗口可以滑窗取多个片段投票 if len(filtered) window_length: filtered np.pad(filtered, (0, window_length - len(filtered))) segments [] for start in range(0, len(filtered) - window_length 1, window_length // 2): seg filtered[start:start window_length] seg zscore_normalize(seg) segments.append(seg) segments np.array(segments)[..., np.newaxis] # 3. 推理 probs model.predict(segments, verbose0) # 4. 多片段投票平均概率 avg_prob np.mean(probs, axis0) pred_class np.argmax(avg_prob) confidence avg_prob[pred_class] return pred_class, confidence这里有个实用技巧滑窗概率平均。如果输入信号比较长切成多个重叠窗口分别推理再把概率平均比单窗口推理稳定得多。这相当于一种测试时增强TTA能显著降低单次误判的概率。6.3 置信度阈值与拒识机制身份识别系统不能来者不拒。如果输入的是一个没注册过的用户模型也会强行输出一个类别这是很危险的。所以要加拒识机制当最高置信度低于某个阈值时判定为未知用户。def identify_with_rejection(model, signal, threshold0.7): pred_class, confidence identify_ecg(model, signal) if confidence threshold: return -1, confidence # -1 表示未知 return pred_class, confidence阈值怎么定在验证集上画置信度分布图把正确识别的样本和错误识别的样本的置信度分别画直方图找两条曲线的交叉点附近作为阈值。我实测下来0.7到0.8之间比较合适。太低会放过冒认者太高会把合法用户拒之门外。注意拒识阈值和你的应用场景强相关。如果是高安全场景如门禁阈值调高宁可错拒不可错认如果是便捷场景如个性化推荐阈值调低优先保证通过率。7. 常见问题与排查技巧实录7.1 训练loss不下降或震荡这是最常见的问题排查顺序如下现象可能原因排查方法解决loss完全不降学习率太小/数据标签错检查标签分布、打印几个样本调大学习率、修正标签loss震荡剧烈学习率太大/batch太小看loss曲线幅度降学习率、增大batchloss降但val_loss升过拟合对比训练/验证曲线加Dropout、加数据增强loss突然变NaN梯度爆炸/除零检查归一化加梯度裁剪、检查std我遇到过一次loss一直卡在2.3左右约等于ln(10)即10分类的随机水平查了半天发现是标签在预处理时被错误地重新编码了所有标签都变成了同一个值。标签检查永远是第一步别急着调模型。7.2 验证集准确率高但测试集崩了这是典型的数据泄漏或分布不一致。常见原因同一条原始记录被切成的多个窗口一部分进了训练集一部分进了测试集。同一段信号高度相关等于变相泄漏。训练集和测试集来自不同的采集时段或设备分布差异大。解决办法按原始记录划分数据集而不是按窗口。同一条记录的所有窗口必须整体进训练集或整体进测试集。这个原则在信号领域极其重要我见过太多人栽在这里。7.3 某些身份总是识别错如果发现某几个身份之间互相混淆严重说明它们的ECG形态太相似。可以画混淆矩阵定位具体是哪几类混淆。对混淆的类别检查原始信号质量可能是采集时电极位置不对导致形态失真。增加这些类别的训练样本或对它们做更有针对性的数据增强。考虑用度量学习如Triplet Loss替代softmax强制拉开类间距离。7.4 推理速度慢如果部署到边缘设备推理速度是硬指标。优化手段模型量化用TensorFlow Lite做INT8量化模型体积缩小4倍推理速度提升2到3倍精度损失通常在1%以内。剪枝去掉不重要的卷积通道参数量可减少30%到50%。降低采样率如果原始采样率是360Hz可以降采样到180Hz甚至125Hz输入长度减半计算量大幅下降。ECG的主要能量在40Hz以下降采样到125Hz完全够用。# TensorFlow Lite 量化示例 converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() with open(ecg_model_quantized.tflite, wb) as f: f.write(tflite_model)7.5 跨数据集泛化差在一个数据集上训练换到另一个数据集上准确率暴跌这是ECG身份识别的老大难问题。根本原因是不同数据集的采集设备、导联配置、采样率、人群分布都不同。缓解手段统一预处理重采样到统一频率统一滤波参数统一归一化方式。域适应用对抗训练让特征在不同域之间对齐。多数据集联合训练把多个数据集混在一起训练提升多样性。微调在新数据集上用小学习率微调几个epoch。说实话跨数据集泛化目前还没有完美解法工程上更现实的做法是针对目标场景重新采集数据训练而不是指望一个模型打天下。8. 一些实操心得和后续扩展方向模型跑通只是第一步真正落地还有不少细节。我分享几个踩过坑之后总结的经验。关于窗口长度别迷信固定值。如果你的数据集心率跨度大比如运动员和普通人混在一起固定窗口会导致有些人窗口内只有一个心跳有些人有三四个。可以考虑自适应窗口先做R峰检测按RR间期动态调整窗口长度再重采样到统一长度。这样每条样本都覆盖相同数量的心跳一致性更好。关于模型集成单模型准确率到瓶颈后集成是性价比最高的提升手段。训练3到5个结构略有差异的模型比如不同卷积核大小、不同层数推理时投票。我实测下来5模型集成比单模型准确率高2到3个百分点代价是推理成本翻几倍。如果对延迟不敏感值得做。关于可解释性ECG身份识别有个天然优势——你可以用类激活映射CAM可视化模型到底关注波形的哪个部分。如果模型关注的是QRS波群符合医学直觉说明它学到了合理特征如果关注的是噪声区域那就要警惕了。这个分析对调试和建立信任都很有用。后续可以扩展的方向我个人比较看好两个一是开集识别即模型能自动判断这个人是新用户需要注册而不是强行分到已知类二是持续学习让模型在不遗忘旧用户的前提下不断注册新用户。这两个方向更贴近真实应用但难度也更大值得单独开篇来讲。最后说个小事。这个项目我从数据预处理到模型调通前后大概花了两周其中一半时间耗在数据清洗和标签核对上。深度学习项目里数据质量永远比模型结构重要。与其花时间堆层数、调超参不如先把数据管线做扎实——滤波参数对不对、归一化方式合不合理、数据集划分有没有泄漏这些基础工作做到位一个简单的五层CNN就能跑出很好的结果。