
简介一份基于深度学习卷积神经网络的心电异常检测项目资源面向医疗AI方向的开发者、数据科学学习者与Python实践者解决如何利用CNN自动识别心电图中的异常信号。资源内共8个文件含5个Python脚本、2张结果图与1个测试文件脚本覆盖数据读取、自有数据生成、训练与测试等环节便于快速复现和迁移到其他生理信号任务。压缩包整体仅9KB虽体量小但流程完整结构清晰适合想入门深度学习医疗应用的读者参考。目前已有469人学习下载从中可了解心电信号预处理、CNN模型构建与评估的基本思路并为实际项目提供可直接调整的代码骨架。1. 这一声「异常」到底怎么被机器听见先说清楚这个 zip 在解决什么问题拿到「基于深度学习的心电异常检测.zip」这个压缩包时大部分人的第一反应是赶紧解压找模型文件。但真正值得关注的不是压缩包本身而是它背后那条完整链路从原始心电信号ECG到异常类别判决中间要过数据清洗、波形切分、模型设计、训练调参、边缘端部署这几道坎。这个 zip 通常不是给你一个现成的黑匣子模型而是一套工程骨架——里面有 MIT-BIH 数据预处理脚本、一个或多个网络结构定义、训练日志和推理示例。如果你手头正好有动态心电设备产生的长时程数据或者你正在做的心电 AI 产品被准确率、功耗、模型体积卡住这个方向能直接缩短你造轮子的时间。它适合两类人一类是用深度学习做医疗信号处理的算法工程师另一类是想把心电异常检测从论文搬进嵌入式设备的硬件开发者。接下来按我做这类项目的习惯把环境、数据、预处理、模型、训练一直讲到部署避坑照着做能少走不少弯路。2. 跑通最小闭环开发环境、数据准备与测试集划分2.1 环境选型为什么我建议先用 Python 3.8 TensorFlow 2.x 当基线做心电异常检测环境选错会浪费大量时间在装库上。常见做法是选 Python 3.8 搭配 TensorFlow 2.10 或 PyTorch 1.13两者我都跑过前者在部署到树莓派或瑞芯微这类边缘设备时生态更顺。这个 zip 里的脚本如果注释写得完整通常能直接复用预训练权重若没有权重文件你也要能自己从零训出基线。安装依赖我一般用虚拟环境避免把系统 Python 搞乱。如果你拿到的是解压后的源码目录先看 requirements.txt没有的话按下面的命令手动装。核心包就几个numpy、scipy、wfdb、tensorflow、scikit-learn、matplotlib。wfdb 是读 MIT-BIH 心电数据库的专用库这在心电项目里几乎是标配。python -m venv ecg_env source ecg_env/bin/activate # Windows 下用 ecg_env\Scripts\activate pip install numpy scipy wfdb scikit-learn matplotlib tensorflow2.10逻辑说明venv 是隔离环境的常规手段防止项目间的依赖互相污染。安装顺序上先装 scipy 和 wfdb 再装 tensorflow 可以避免 numpy 版本冲突。TensorFlow 版本不必追新2.10 在 CPU 和 GPU 上表现都稳定且对 ONNX 导出的兼容性好后面部署章节会用到。参数说明Python 版本过高比如 3.11时tf 2.10 可能装不上——不用纠结直接用 3.8 或 3.9。GPU 版本需要注意 CUDA 与 cuDNN 的版本对应关系纯 CPU 训练时数据量小比如单条记录几万心跳也够用只是慢一些。2.2 数据从哪里来MIT-BIH 的目录结构你必须知道大部分心电异常检测的学术项目都基于 MIT-BIH Arrhythmia Database。这个库包含 48 条长度为 30 分钟的 dual-channel ECG 记录采样率 360 Hz。每条记录包含三个文件.dat 存放原始信号自定义格式不是标准 wav、.hea 是头文件说明采样率和通道数、.atr 是专家标注的 Beat 注释——正常心跳 N、室性早搏 V、左束支阻滞 L、右束支阻滞 R 等。用 wfdb 库读取时很多人第一次会踩坑直接用wfdb.rdsamp(100)读取但没注意到返回的 units 和 fmt 参数影响数值范围。MIT-BIH 的原始数据是增益后的整数范围一般是 0 到 2048真实电压值需要根据gain换算成 mV。这个细节同步影响后续归一化后面会细说。import wfdb import numpy as np record wfdb.rdrecord(100, sampto3600) # 只读前10秒做示例 annotation wfdb.rdann(100, atr, sampto3600) # 拿到原始信号 sig record.p_signal[:, 0] # 取第一导联 fs record.fs # 采样率MIT-BIH 固定为 360 Hz # 拿到R峰位置和对应标注类型 r_peaks annotation.sample symbols annotation.symbol print(f采样率: {fs}, R峰数量: {len(r_peaks)}, 前5个标签: {symbols[:5]})逻辑说明sampto 控制读取长度单位是采样点。这里读 3600 个点就是 10 秒。rdann 返回的 annotationsample 是 R 峰的采样点索引symbol 是字符串标签。这个结构决定了后续切片方式——以 R 峰为中心左右各截取若干采样点构成一个心跳样本。参数说明采样率 360 Hz 意味着每个心跳周期约 70~100 个采样点对应心率 60~100 bpm。切片窗口如果取 250 ms 到 400 ms 之间那前后各取 90~144 个点。经验值是「R 峰前 80 点 R 峰后 80 点」总共 160 点兼容绝大多数正常和早搏形态。窗口太小会截断 ST 段窗口太大则引入相邻心跳的干扰。2.3 测试集划分的独立性为什么不能随机打乱全部样本这是整个流程里最容易被忽略、后果却最严重的一个点。如果把所有心跳样本而不是患者混在一起随机划分训练集和测试集同一个病人的心跳会同时出现在两边模型相当于「见过」这个人的心率变异性测试准确率会虚高 10 到 20 个百分点。业界标准是按记录即患者划分也就是 record 级别的独立测试集。# 按患者维度划分而不是按样本维度 patient_ids [101, 102, 103, 104, 105, 106, 107, 108, 109, 111, 112, 113, 114, 115, 116, 117, 118, 119, 121, 122, 123, 124, 200, 201, 202, 203, 205, 207, 208, 209, 210, 212, 213, 214, 215, 217, 219, 220, 221, 222, 223, 228, 230, 231, 232, 233, 234] train_patients patient_ids[:32] test_patients patient_ids[32:] print(f训练集患者数: {len(train_patients)}, 测试集患者数: {len(test_patients)})逻辑说明把 47 条记录按 ID 顺序切成两部分训练集 32 个患者、测试集 15 个患者。注意 MIT-BIH 里 100~124 是门诊病人200~234 是住院病人信号质量与异常类型分布有明显差异。如果只拿 100 段的门诊数据训练到 200 段的住院数据上做测试准确率会有明显下滑。所以更严格的做法是在两个子集里都均衡包含 100 段和 200 段样本。参数说明这里的 32/15 划分是我常用的比例约为 7:3。如果某个类别的样本总量太少比如个别罕见心律失常数只有几十个样本可以考虑按类别比例做分层划分但前提仍然是不拆散同一患者的心跳记录。3. 把原始心电信号切成能喂给模型的心跳样本预处理与特征窗口设计3.1 去噪与基线漂移处理用中值滤波做基础清洗原始心电信号里混着三种噪声基线漂移主要由呼吸和电极移动引起频率低于 0.5 Hz、工频干扰50 Hz 或 60 Hz、肌电噪声高频毛刺。深度模型对高频噪声有一定鲁棒性但基线漂移会直接改变 ST 段的形态——而 ST 段抬升/压低是心肌缺血的关键判据不能让它被漂移污染。最常见的做法是一阶中值滤波加高通滤波。中值滤波窗口选 200 ms 左右对应 72 个采样点 360 Hz能有效估计基线再用原始信号减去基线即可消除漂移。from scipy.signal import medfilt def remove_baseline_wander(sig, fs360): window_size int(fs * 0.2) # 200ms窗口 if window_size % 2 0: window_size 1 baseline medfilt(sig, kernel_sizewindow_size) filtered sig - baseline # 再做一个50Hz陷波可选看信号质量 return filtered filtered_sig remove_baseline_wander(sig)逻辑说明中值滤波是非线性滤波器对脉冲噪声和基线突变的效果优于同窗口的均值滤波。窗口大小直接决定基线估计的平滑程度——窗口太小会把 ST 段也当成基线减掉窗口太大则跟不上呼吸引起的慢漂移。200 ms 对应 0.5 Hz 左右的截止频率和呼吸频率0.2~0.4 Hz接近能兼顾二者。参数说明如果信号来自可穿戴设备比如单导联胸贴运动伪迹更强建议把窗口缩小到 120 ms 并结合自适应滤波。但注意压缩包里给的预训练模型如果是在 MIT-BIH 上训练的你换用其他设备数据时预处理参数必须保持一致否则形态分布不匹配模型表现会断崖式下降。3.2 R 峰定位与心跳切片Pan-Tompkins 算法的工程实现切片的准确度决定样本质量。深度模型可以容忍 R 峰位置偏移几个采样点但偏移超过 20 个采样点会让样本里的 P 波、QRS 波群、T 波错位模型学到的形态特征就成了废的。常见的 R 峰定位算法有两个选择直接用 wfdb 库里的xqrs_detect或者自己实现 Pan-Tompkins。前者方便后者可控性强。这里给出一个简化版 Pan-Tompkins 的核心流程供你理解阈值设定逻辑from scipy.signal import butter, filtfilt def pan_tompkins_rpeak(sig, fs360): # 带通滤波 5-15 Hz 增强 QRS 分量 b, a butter(2, [5/(fs/2), 15/(fs/2)], btypeband) filtered filtfilt(b, a, sig) # 求导 平方 滑动窗口积分 diff np.diff(filtered) squared diff ** 2 window int(0.12 * fs) kernel np.ones(window) / window integrated np.convolve(squared, kernel, modesame) # 自适应阈值简化 threshold np.mean(integrated) * 2.5 candidates np.where(integrated threshold)[0] # 合并距离过近的候选峰 merged [] for c in candidates: if not merged or c - merged[-1] int(0.25 * fs): merged.append(c) return np.array(merged) r_peaks pan_tompkins_rpeak(filtered_sig)逻辑说明Pan-Tompkins 的原理是QRS 波群的能量集中在 5~15 Hz经过带通滤波后R 波的斜率最大。求导放大这个斜率变化平方化让所有值为正且强化大值滑动窗口积分进一步平滑最后用自适应阈值卡出峰位置。实现里的2.5倍是经验值信噪比高的 MIT-BIH 信号没问题但如果你用的是可穿戴设备数据阈值要降到 1.5~2.0。参数说明0.12 * fs的积分窗口对应 QRS 波群宽度太短会把 R 波分成两个峰太长会把 T 波熔进来。合并距离0.25 * fs对应不应期——生理上相邻两次心跳间隔不会低于 250 ms。这两个参数在 360 Hz 采样率下是安全值换采样率时要同步换算。3.3 标签映射与类别不平衡AAMI 标准怎么落到代码里MIT-BIH 原始标注有 15 种以上符号直接做 15 分类既不实用也难收敛。AAMI美国医疗器械促进协会标准把它们归并为 5 大类N正常含 LBBB/RBBB/房早、S室上性异位搏动、V室性异位搏动、F融合搏动、Q未分类。这是心电异常检测的事实标准你的模型输出层设计成 5 类就够。aami_map { N: N, L: N, R: N, B: N, A: S, a: S, J: S, S: S, V: V, E: V, F: F, P: F, f: F, /: Q, Q: Q } def map_symbols(symbols): return [aami_map.get(s, Q) for s in symbols] mapped_labels map_symbols(symbols[:10]) print(mapped_labels)规则说明映射的争议点在「A 类房早归 S 还是 N」——AAMI 规范把它归为 S因为房早和室上性心动过速机制相近。临床标签中 P 波融合归 F 类但这类样本在 MIT-BIH 里极少不足 1%训练时几乎可以忽略。类别不平衡是绕不开的问题MIT-BIH 里 N 类占绝大多数V 类次之S 和 F 类很少。直接用交叉熵训练模型会「懒惰」地把所有样本预测为 N。常见的压制手段有两个一是对少数类过采样重复采样二是用类别权重加权损失。我在工程上一律用权重方案因为过采样容易过拟合到少数类的重复样本上。from sklearn.utils.class_weight import compute_class_weight import numpy as np classes np.array([N, S, V, F, Q]) y_all ... # 全部训练样本的标签numpy array weights compute_class_weight(balanced, classesclasses, yy_all) class_weight_dict dict(zip(classes, weights)) print(class_weight_dict) # 输出类似 {N: 0.3, S: 4.2, V: 2.1, F: 8.5, Q: 3.7}逻辑说明compute_class_weight自动计算每个类别的权重少数类权重远大于多数类。把这个字典传给 Keras 的model.fit(..., class_weightclass_weight_dict)损失函数会对少数类的误判施加更大惩罚模型便不再无脑预测为 N。参数说明如果 F 类的权重超过了 10说明该类样本太少硬训练只会过拟合。这时我建议在评估指标上单独看 F1不要强求这类达到高准确率——AAMI 评测也更关注 N 和 V 的敏感性因为这两类覆盖了临床最常见的致死性心律失常。4. 构建一个轻量级基线模型从结构设计到训练参数的选择4.1 为什么用卷积而不是 LSTM可解释性和部署友好度更高心电信号是典型的时间序列很多人第一反应是 LSTM。但 LSTM 有两个问题一是训练慢、难以收敛二是导出到边缘设备比如用 ONNX 转 RKNN 跑在 NPU 上时支持不完善经常要手写算子。一维卷积网络1D CNN结构简单、推理速度块而且每一层卷积可以理解成「模板匹配」——卷积核在时间轴上滑动匹配特定的波形形态比如宽大畸形的 QRS 波对应室早。这让模型的行为更容易被验证。我的基线结构选一个类似「小 ResNet」的设计两层卷积 全局平均池化 全连接输出。不做残差连接也可以但加上残差能让深层的梯度更稳训练从零开始也能收敛。import tensorflow as tf from tensorflow.keras import layers, models def build_qrs_cnn(input_len160, num_classes5): inputs layers.Input(shape(input_len, 1)) # 第一个卷积块提取基础波形特征 x layers.Conv1D(32, kernel_size5, strides1, paddingsame)(inputs) x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.MaxPooling1D(pool_size2)(x) # 第二个卷积块提取高层次的形态组合 x layers.Conv1D(64, kernel_size3, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.MaxPooling1D(pool_size2)(x) # 第三个卷积块上下文特征 x layers.Conv1D(128, kernel_size3, strides1, paddingsame)(x) x layers.BatchNormalization()(x) x layers.ReLU()(x) # 全局池化替代 Flatten减少参数量 x layers.GlobalAveragePooling1D()(x) x layers.Dropout(0.3)(x) outputs layers.Dense(num_classes, activationsoftmax)(x) model models.Model(inputs, outputs) return model model build_qrs_cnn() model.summary()逻辑说明第一层卷积核 32 个、kernel_size 5在 160 个采样点的时间窗上滑。kernel 5 覆盖约 14 ms刚好捕捉 QRS 波群的局部斜率变化。第二层 kernel 3 减小感受野增长速率避免参数爆炸。最后的全局平均池化层把每个特征图压缩成一个数值等价于把 128 个特征做加权平均参数量远小于 Flatten 后接全连接的做法。Dropout 0.3 是防止全连接层过拟合的标准值。参数说明输入长度 160 对应 R 峰前 80 点后 80 点与前文切片方案一致。如果你的采样率不是 360 Hz需要把input_len换算比如 250 Hz 采样率下前后各 56 点、总长 112 点。通道数为 1只用单导联——如果你的设备是双导联输入形状可以改成(160, 2)但训练数据也要改成双通道。4.2 训练策略优化器、学习率与回调函数的工程经验训练心电模型最容易犯的错是学习率设成固定值从头训到底。交叉熵损失在类别不平衡下的收敛行为很诡异前期 loss 下降很快但到第 20 个 epoch 之后开始震荡准确率在 92% 上下卡死。这时候把学习率降一个数量级往往还能再涨两个点。我惯用的方案是 Adam 初始学习率 0.001 ReduceLROnPlateau。这个组合的稳健性在多个医疗信号任务里都验证过。model.compile( optimizertf.keras.optimizers.Adam(learning_rate0.001), losssparse_categorical_crossentropy, metrics[accuracy] ) callbacks [ tf.keras.callbacks.ReduceLROnPlateau( monitorval_loss, factor0.5, patience5, min_lr1e-6 ), tf.keras.callbacks.EarlyStopping( monitorval_loss, patience10, restore_best_weightsTrue ), tf.keras.callbacks.ModelCheckpoint( best_ecg_model.h5, monitorval_accuracy, save_best_onlyTrue ) ] history model.fit( X_train, y_train, validation_data(X_val, y_val), epochs60, batch_size128, callbackscallbacks, class_weightclass_weight_dict, verbose1 )逻辑说明sparse_categorical_crossentropy要求 y 是整数编码而不是 one-hot省内存。ReduceLROnPlateau 在验证 loss 连续 5 个 epoch 不降时把学习率减半EarlyStopping 在 10 个 epoch 内验证 loss 不改善就停止同时恢复最优权重ModelCheckpoint 按验证准确率保存最优模型。三个回调配合即使你不管训练过程也能拿到一个可用的模型。参数说明batch_size 128 是 GPU 和 CPU 都舒适的值。如果你的训练样本数量在十万级别epoch 数 40~60 就够再多就可能过拟合到权重高的少数类上。前 10 个 epoch 建议观察训练 loss 是否从 1.5 附近快速降到 0.5 以下如果 loss 一直在 1.6 附近不动说明学习率过大或特征没有归一化。4.3 训练前的数据归一化样本级 max-min 比全局归一化靠谱心电信号存在个体差异同一个人的 R 波振幅在不同导联上可能差 3 倍不同人之间的 T 波振幅差异更大。如果做全局归一化用整个数据集的均值和标准差振幅小的样本会被压制到接近零的区间卷积核学不到这些形态。我一般用样本级归一化对每个 160 点的心跳窗单独做 max-min 缩放让每个样本的幅值都在 [-1, 1] 或 [0, 1] 区间。这等价于只看形态不看幅值幅度信息虽然丢了但对分类任务影响不大——而且正好增强了跨设备泛化能力因为电极贴片位置差异主要影响幅度。def normalize_sample(sample): # sample: numpy array shape (160,) min_val np.min(sample) max_val np.max(sample) if max_val - min_val 1e-6: return np.zeros_like(sample) return (sample - min_val) / (max_val - min_val) * 2 - 1 X_train_norm np.array([normalize_sample(s) for s in X_train]) X_train_norm X_train_norm[..., np.newaxis] # (n_samples, 160, 1)逻辑说明max_val - min_val 1e-6是防除零的保险分支正常心电信号几乎不会出现全平信号但设备关机和脱落瞬间会产生这样的样本直接归一化会得到 NaN污染整个 batch 的梯度。参数说明这里每个样本都做独立的 min-max 会出现一个副作用——噪声段的微小抖动也会被放大到满幅。假如信号质量差建议先去噪再做归一化。顺序不能反过来否则去噪会失真。4.4 混淆矩阵与敏感性验证准确率高不等于模型可用模型训完第一步不是去看测试准确率而是打印混淆矩阵。医疗场景更关注敏感性Recall——实际异常的样本有多少被查出来了。室性早搏漏检的代价远高于把正常误报为室早。from sklearn.metrics import confusion_matrix, classification_report import numpy as np y_pred np.argmax(model.predict(X_test), axis1) print(classification_report(y_test, y_pred, target_names[N, S, V, F, Q])) cm confusion_matrix(y_test, y_pred) # 按类别行归一化便于查看召回率 cm_norm cm.astype(float) / cm.sum(axis1)[:, np.newaxis] print(归一化混淆矩阵行为真实类别:) print(np.round(cm_norm, 3))参数说明classification_report 里的 precision/recall/f1-score 是每类的评估指标重点关注 V 类的 recall 是否高于 0.85。如果在 0.7 以下大概率是样本不平衡没处理好或切片窗口截断了 QRS 的主形态。此时优先调切片窗口的偏移R 峰前 90 点后 70 点而不是急着换模型结构——形态截断问题换再大的网络也救不回来。5. 避坑手册数据泄漏、过拟合与标签噪声的三类典型翻车5.1 患者级泄漏一版准确率 99% 但临床不能用的模型是怎么出现的现象测试集准确率高达 98.7%F1 全部超过 0.95看上去完美。但是换成新患者的动态心电数据一测准确率直接跌到 72%。原因这是最常见的「数据泄漏」——训练前对全部样本做了归一化用的是整个数据集的均值和标准差包括测试集。测试集中的样本在归一化时「见过」了训练集数据的统计信息等于提前泄题。还有一种更隐蔽的用同一个病人多次就诊的记录同时出现在训练和测试里模型记住了病人的个体波形模式。解决把归一化放到数据划分之后训练集算出的 min/max 或 mean/std 存成 json 文件测试时加载这份统计量做变换。患者必须按人划分反复确认不同时间的就诊记录有没有被拆散。# 正确顺序先划分再归一化 X_train, X_test, y_train, y_test train_test_split_by_patient(...) # 只在训练集上拟合归一化参数 mean X_train.mean(axis(0, 1), keepdimsTrue) std X_train.std(axis(0, 1), keepdimsTrue) X_train (X_train - mean) / std X_test (X_test - mean) / std5.2 样本级归一化的隐藏风险把噪声放大成「伪特征」现象模型在公开数据集上表现良好但换用自己的采集设备后预测结果出现大量乱跳——一会儿正常、一会儿室早毫无连续性。原因我对每个心跳窗做独立归一化如果某个窗口里信号本身很平但叠加了微小噪声归一化后噪声被放大成正常幅值的「伪波形」模型把它当成了信号特征。这在运动场景跑步时的心率监测尤其明显。解决先用中值滤波去基线再做样本级 max-min 归一化如果噪声严重归一化后追加一个 25 Hz 的低通滤波肌肉噪声主要分布在 25 Hz 以上。另外可考虑全局归一化与样本级归一化的混合方案先用全局均值方差去除个体间幅度差异再用样本级 max-min 消除残留漂移。5.3 标签噪声与前向验证单导联要特别警惕翻转电极现象训练 loss 在 30 个 epoch 后不再下降验证准确率停在 88% 左右上不去。查了数据和代码模型结构也没问题最后发现是约 15% 的训练标签标注错了。原因MIT-BIH 的标注整体可靠但如果你用了自采数据或第三方标注工具心电图导联接反左右手互换会让 QRS 波群整体倒置而标注人员按正常方向标注模型被迫去学习同一形态的两种相反标签。解决在切片阶段检查 QRS 波的极性。计算每个心跳窗内 R 波峰值位置的正负——如果超过 80% 样本的主峰是负向说明导联接反需要把信号乘 -1 翻转。这个规则能自动修正大部分电极接反问题。做完极性矫正后训练 loss 应该从 30 个 epoch 处继续下降。def check_and_flip(sample): # 如果主峰为负翻转信号 peak_idx np.argmax(np.abs(sample)) if sample[peak_idx] 0: return -sample return sample X_train np.array([check_and_flip(s) for s in X_train])现象二还有一类标签错误是时间戳偏移——R 峰位置标记偏了 30~50 ms导致切片后的心跳窗整体平移。这个用前向验证法排查把切片出来的样本按标签分组画平均波形。以 V 类为例如果平均波形在 QRS 位置出现双峰或模糊说明不少样本的 R 峰没对齐。对齐手段是找到检测到的 R 峰附近 ±50 ms 窗口内幅值最大点重新令其为切片中心。解决这个操作通常能把 V 类召回率从 0.82 拉到 0.90 以上。代价是推理时也需要同样的峰值精确定位否则会出现训练与推理不一致的偏差这也是模型上线后掉点的常见原因。6. 模型压缩与边缘端推理把训练好的模型部署到树莓派或手机设备上模型训练完成只是第一步真正要落地到便携式心电贴片上必须解决体积、延迟和功耗三个问题。一个 60 MB 的 Keras h5 模型在手机上加载没问题但放到带 NPU 的 MCU 上就超资源了。常见的做法是把模型导出为 ONNX 格式再做动态量化让权重从 FP32 变 INT8。压缩比大约 4 倍准确率损失控制在 1% 以内。如果你没有专门的量化校准数据集直接用 TensorFlow Lite 的默认量化也能跑但效果不如 ONNX Runtime 的 QDQ 模式。6.1 导出与转换TensorFlow 到 ONNX 的动态量化流程pip install tf2onnx onnxruntimeimport tf2onnx import onnx # 把训练好的 Keras 模型转换为 ONNX spec (tf.TensorSpec((None, 160, 1), tf.float32, nameinput)) onnx_model, _ tf2onnx.convert.from_keras(model, input_signaturespec, opset13) # 保存成文件 onnx.save(onnx_model, ecg_model.onnx)参数说明opset 13 是动态量化支持的稳妥版本高于 15 的版本有些算子比如 BatchNormalization在转换时可能失败或行为变化。如果你的模型里有自定义层转换前先确保它只是由标准算子组合而成否则 tf2onnx 会报 NOT_FOUND。拿到 ONNX 后用 ONNX Runtime 做量化有两种路径动态量化对权重做 INT8、激活保持浮点质量损失小但加速有限静态量化需要校准数据。我建议先做动态量化如果模型体积仍超标再进一步做静态 INT8 量化。from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic(ecg_model.onnx, ecg_model_int8.onnx, weight_typeQuantType.QUInt8) import os print(f原始大小: {os.path.getsize(ecg_model.onnx)/1024:.0f} KB) print(f量化后大小: {os.path.getsize(ecg_model_int8.onnx)/1024:.0f} KB)逻辑说明动态量化把权重从 FP32 压到 8-bit 整数。推理时权重载入内存只有原来的四分之一CPU 访存压力大幅下降在树莓派或手机端跑起来快不少。QUInt8与QInt8的区别是数值表示范围ARM 平台用 QUInt8 更合适。6.2 推理管线端到端的心跳级预测与滑动窗口去抖设备端推理不能只做单心跳分类——通常是每 5 秒一个窗口通过心跳级预测投票得到最终结论避免单次误检造成误报警。推理管线分四步读取信号流 → 去基线漂移 → 定位 R 峰并切窗 → 模型逐窗预测 → 投票输出。import onnxruntime as ort import numpy as np # 加载量化后的模型 sess ort.InferenceSession(ecg_model_int8.onnx, providers[CPUExecutionProvider]) input_name sess.get_inputs()[0].name def predict_heartbeat(ecg_window): # ecg_window: numpy array shape (160,) x ecg_window.reshape(1, 160, 1).astype(np.float32) logits sess.run(None, {input_name: x})[0] return np.argmax(logits) def predict_5s(beats, window_size5): # beats: list of (160,) arrays来自一个5s窗口内的所有心跳 votes [predict_heartbeat(b) for b in beats] # 取出现次数最多的类别作为这个窗口的结论 return max(set(votes), keyvotes.count)参数说明投票窗口大小直接影响灵敏度——窗口越长越稳但报警越迟钝对于室颤这类致命异常建议窗口缩短到 3 秒。如果设备算力充裕可以改成加权投票给距离窗口中心更近的心跳更高权重。6.3 验证部署版模型的精度量化后必须回归测试量化虽然只压缩权重但推理结果非线性变化个别样本可能从预测正确变成错误。我习惯在部署前跑一遍全量测试集对比原始模型和量化模型对每类样本的召回率。一个我在项目里反复踩的坑是量化后 V 类召回率从 0.92 掉到 0.87看着还能接受但实际到了床边设备上室早漏检率翻倍。原因是个别 V 类样本的 QRS 形态比较「秀气」量化误差恰好把激活值推到了分类边界另一侧。此时不要盲目换量化方案而是用校准数据做静态量化或者把 BatchNormalization 层融合进卷积层后再导出能显著减少量化误差。import onnxruntime as ort def eval_model(model_path, X_test, y_test): sess ort.InferenceSession(model_path) preds [] for i in range(len(X_test)): out sess.run(None, {sess.get_inputs()[0].name: X_test[i:i1].astype(np.float32)})[0] preds.append(np.argmax(out)) preds np.array(preds) acc np.mean(preds y_test) return acc acc_fp32 eval_model(ecg_model.onnx, X_test, y_test) acc_int8 eval_model(ecg_model_int8.onnx, X_test, y_test) print(fFP32 准确率: {acc_fp32:.4f}, INT8 准确率: {acc_int8:.4f})到这里从数据准备到模型训练再到边缘端量化的这条链路就完整了。我每换一个新数据集或者新设备跑这个流程时上述任何一个环节都可能出问题所以养成了一个习惯每个环节都写一版最小验证脚本数据形状对不上立刻报错绝不靠眼睛检查。这个习惯帮我避免过很多次返工也希望帮到你。本文还有配套的精品资源点击获取