news 2026/10/3 1:25:33

基于机器学习的心电信号分类:从数据预处理到模型部署的完整流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于机器学习的心电信号分类:从数据预处理到模型部署的完整流程

简介:这是一份面向生物医学工程、机器学习方向本科生与研究生的毕业论文参考资料,聚焦心电信号自动分类这一交叉研究课题,适合正在开展毕业设计或希望系统梳理该领域知识脉络的读者。压缩包内仅含1个PDF文件,大小约8.34MB,即完整论文正文,涵盖绪论、心电基础知识及去噪、特征提取与分类、实验结果分析、结论等章节。论文从心电信号产生机理与P波、QRS波、T波等组成讲起,依次展开小波阈值去噪、时频域特征提取、迁移学习分类以及支持向量机、随机森林、卷积神经网络等算法应用,并结合PTB与MIT-BIH数据库说明实验设计。目前已有294人学习,读者可借此获得一份结构完整、知识点覆盖全面的论文范本,用于选题参考、方法对比与写作框架借鉴。

1. 心电信号分类这篇毕业论文,到底在解决什么真问题

心电信号分类不是拿个数据集跑个准确率就完事。真正做过的人都知道,心电图里那些 P 波、QRS 波群、T 波,形态差异极小,不同人的正常心拍长得都不一样,同一个人的不同时段也有漂移。你要做的是让模型在噪声、基线漂移、个体差异三重干扰下,把一段心电片段正确归到预设类别里——正常、房颤、室性早搏、房室传导阻滞,或者更细的几十类心律失常。

这篇毕业论文的标题里,“基于机器学习”是方法限定,“心电信号分类”是任务定义。它要回答的核心问题是:在 MIT-BIH、PTB-XL、CPSC 2018 这类公开心电数据集上,用传统机器学习或深度学习方案,能不能做到临床可接受的分类精度,以及整个流程怎么搭、参数怎么调、坑在哪。适合正在做毕业论文选题的本科生和研究生,也适合刚进入心电算法方向的工程师。热搜词里“机器学习项目”“机器学习应用流程”“python机器学习入门”这些,恰好对应了这篇论文的落地路径——从数据预处理到特征工程再到模型训练,每一步都有可复现的操作。

2. 心电信号从原始波形到可训练样本:预处理与数据集选择

2.1 为什么原始心电信号不能直接喂给模型

拿到的心电信号通常是 360Hz 或 500Hz 采样的时间序列,长度从几秒到几十秒不等。直接送进模型有三个致命问题:第一,基线漂移让整段信号的均值缓慢变化,模型会学到“漂移方向”这种无关特征;第二,工频干扰和肌电噪声在 50Hz 附近叠加,频谱上和 QRS 波群的高频分量重叠;第三,不同记录的长度不一致,没法组成固定维度的输入矩阵。

常见做法是先做带通滤波,保留 0.5Hz 到 45Hz 的成分。0.5Hz 以下砍掉基线漂移,45Hz 以上砍掉工频和大部分肌电噪声。然后做 R 波检测,以每个 R 波为基准,向前取 0.25 秒、向后取 0.4 秒,切出固定长度的单心拍片段。这样每个样本就是 0.65 秒 × 采样率的定长向量。如果采样率是 360Hz,一个心拍就是 234 个点。

import numpy as np from scipy.signal import butter, filtfilt, find_peaks def bandpass_filter(signal, fs, low=0.5, high=45, order=4): """对心电信号做带通滤波,去除基线漂移和高频噪声""" nyq = fs / 2.0 b, a = butter(order, [low / nyq, high / nyq], btype='band') return filtfilt(b, a, signal) def segment_beats(signal, fs, pre=0.25, post=0.4): """基于 R 波检测切分单心拍""" # 先做差分平方增强 QRS 波群 diff = np.diff(signal) squared = diff ** 2 # 滑动窗口积分,窗口约 0.15 秒 window = int(0.15 * fs) integrated = np.convolve(squared, np.ones(window) / window, mode='same') # 自适应阈值检测 R 波 threshold = np.mean(integrated) + 2 * np.std(integrated) peaks, _ = find_peaks(integrated, height=threshold, distance=int(0.25 * fs)) # 以 R 波为基准切分 pre_samples = int(pre * fs) post_samples = int(post * fs) beats = [] for p in peaks: if p - pre_samples >= 0 and p + post_samples < len(signal): beats.append(signal[p - pre_samples:p + post_samples]) return np.array(beats)

这段代码里,butter的阶数选 4 是经验值,阶数太高会引入相位失真,阶数太低过渡带太宽。filtfilt做零相位滤波,避免 R 波位置偏移。R 波检测用差分平方加滑动窗口积分,这是 Pan-Tompkins 算法的简化版,对大部分正常心拍够用。distance参数设为 0.25 秒,因为生理上两次心拍间隔不会短于这个值。切分时向前 0.25 秒覆盖 P 波,向后 0.4 秒覆盖 T 波,这个窗口在文献里被反复验证过。

2.2 数据集怎么选:MIT-BIH、PTB-XL 和 CPSC 2018 的取舍

选数据集直接决定论文的对比基线是否合理。MIT-BIH 是经典中的经典,48 条记录,360Hz 采样,标注了 15 类心拍。它的优点是标注质量高、文献多、容易复现;缺点是样本量小,只有约 11 万个心拍,而且部分记录有起搏器伪影。PTB-XL 更大,21837 条 12 导联记录,500Hz 采样,71 种标注。它的优势是数据量大、导联多,适合做多导联融合;劣势是类别极不平衡,某些罕见病只有几十条。CPSC 2018 是竞赛数据集,6877 条记录,500Hz,9 类心律失常,专门为分类任务设计,评价指标明确。

我一般会建议:如果论文重点是方法创新,用 MIT-BIH 做快速验证,因为跑一轮只要几分钟;如果重点是模型泛化能力,用 PTB-XL 做跨患者划分;如果要做端到端对比,CPSC 2018 的官方划分最省事。注意,不管用哪个数据集,都必须按患者划分训练集和测试集,不能随机打乱心拍。同一个患者的不同心拍同时出现在训练和测试里,准确率会虚高十几个百分点,这是血泪教训。

from sklearn.model_selection import GroupShuffleSplit def patient_wise_split(beats, labels, patient_ids, test_size=0.2): """按患者划分,避免同一患者的心拍同时出现在训练和测试集""" gss = GroupShuffleSplit(n_splits=1, test_size=test_size, random_state=42) train_idx, test_idx = next(gss.split(beats, labels, groups=patient_ids)) return beats[train_idx], beats[test_idx], labels[train_idx], labels[test_idx]

GroupShuffleSplit的groups参数传患者 ID,保证同一患者的样本只出现在一边。random_state固定住,否则每次跑出来的划分不一样,论文里的数字没法复现。

3. 特征工程与模型选型:传统机器学习还是深度学习

3.1 手工特征怎么提:时域、频域和形态学三路并进

传统机器学习方案的核心是特征工程。心电信号的特征大致分三路:时域特征、频域特征和形态学特征。时域特征包括 RR 间期均值、标准差、相邻 RR 间期差值均方根,这些反映心率变异性。频域特征对 RR 间期序列做功率谱密度估计,提取低频功率、高频功率和低频高频比。形态学特征直接描述波形,比如 QRS 波群宽度、R 波幅值、T 波幅值、P 波是否存在。

import numpy as np from scipy.stats import skew, kurtosis def extract_time_features(beats, fs): """提取时域和形态学特征""" features = [] for beat in beats: # 时域统计量 mean_val = np.mean(beat) std_val = np.std(beat) skewness = skew(beat) kurt = kurtosis(beat) # 形态学:R 波峰值和位置 r_peak = np.max(beat) r_pos = np.argmax(beat) / fs # QRS 宽度:以 R 峰为中心,幅值降到 20% 的宽度 threshold = 0.2 * r_peak above = np.where(beat > threshold)[0] qrs_width = (above[-1] - above[0]) / fs if len(above) > 1 else 0 features.append([mean_val, std_val, skewness, kurt, r_peak, r_pos, qrs_width]) return np.array(features)

skew和kurtosis描述波形对称性和尖峰程度,房颤时 RR 间期绝对不规则,这两个统计量会明显偏离正常。qrs_width用 20% 阈值法估算,宽 QRS 波提示室性早搏或束支传导阻滞。这些特征加起来大概 20 到 30 维,配合随机森林或 SVM 就能跑出不错的结果。

3.2 一维 CNN 和 LSTM 的取舍:什么时候该上深度学习

如果手工特征做到头了,准确率卡在 95% 上不去,可以考虑一维 CNN。心电信号是典型的一维时序,一维卷积核在时间轴上滑动,自动学习局部形态特征。相比手工特征,CNN 的优势是能捕捉到人眼难以描述的细微模式,比如 QRS 波群的微小切迹。

import torch import torch.nn as nn class ECG1DCNN(nn.Module): def __init__(self, num_classes=5, input_len=234): super().__init__() self.conv_block1 = nn.Sequential( nn.Conv1d(1, 32, kernel_size=5, padding=2), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2) ) self.conv_block2 = nn.Sequential( nn.Conv1d(32, 64, kernel_size=5, padding=2), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2) ) self.conv_block3 = nn.Sequential( nn.Conv1d(64, 128, kernel_size=3, padding=1), nn.BatchNorm1d(128), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.fc = nn.Linear(128, num_classes) def forward(self, x): # x shape: (batch, 1, input_len) x = self.conv_block1(x) x = self.conv_block2(x) x = self.conv_block3(x) x = x.squeeze(-1) return self.fc(x)

三层卷积,通道数从 32 到 64 到 128,卷积核 5 和 3 交替。BatchNorm加速收敛,AdaptiveAvgPool1d(1)把时间维度压成 1,避免全连接层参数爆炸。输入长度 234 对应 360Hz 采样下 0.65 秒的片段。这个模型参数量不到 10 万,在 MIT-BIH 上训练 50 个 epoch 就能收敛。如果要做长时依赖,比如捕捉房颤的 RR 间期不规则性,可以在 CNN 后面接一层 LSTM,但注意 LSTM 训练慢,小数据集上容易过拟合。

注意:一维 CNN 的输入需要归一化。我一般按每个心拍做 z-score 标准化,即减去均值除以标准差。不要用全局均值和标准差,因为不同患者的基线水平差异很大。

4. 训练、评估与调参:让论文里的数字站得住脚

4.1 类别不平衡怎么处理:重采样、加权和 focal loss

心电数据集的类别不平衡是常态。MIT-BIH 里正常心拍占 90%,房颤和室早加起来不到 10%。如果直接训练,模型会把所有样本预测成正常,准确率也有 90%,但敏感度为零。常见做法有三种:对少数类过采样、对多数类欠采样、在损失函数里给少数类更高权重。

from torch.utils.data import WeightedRandomSampler import numpy as np def make_weighted_sampler(labels): """根据类别频率构造加权采样器""" class_counts = np.bincount(labels) class_weights = 1.0 / class_counts sample_weights = class_weights[labels] sampler = WeightedRandomSampler( weights=torch.DoubleTensor(sample_weights), num_samples=len(labels), replacement=True ) return sampler

WeightedRandomSampler让少数类样本在每个 batch 里出现的概率更高。replacement=True表示有放回采样,保证每个 epoch 看到的样本数不变。如果不想改采样策略,可以在 CrossEntropyLoss 里传weight参数,把少数类的权重设成多数类的 5 到 10 倍。Focal loss 是另一种选择,通过调制因子降低易分类样本的损失贡献,但参数调起来更玄学,毕业论文里用加权交叉熵就够。

4.2 评估指标不能只看准确率:敏感度、特异度和 F1 的取舍

论文里如果只报准确率,审稿人大概率会质疑。心电分类的标准指标是:敏感度、特异度、阳性预测值和 F1 分数。敏感度衡量漏诊率,特异度衡量误诊率,F1 是精确率和召回率的调和平均。对于房颤检测,敏感度比特异度更重要,因为漏掉一个房颤患者可能导致中风。对于室性早搏,特异度更关键,因为误报会引起不必要的焦虑。

from sklearn.metrics import classification_report, confusion_matrix def evaluate_model(y_true, y_pred, class_names): """输出每个类别的敏感度、特异度和 F1""" print(classification_report(y_true, y_pred, target_names=class_names, digits=4)) cm = confusion_matrix(y_true, y_pred) print("Confusion Matrix:") print(cm) # 计算每个类别的敏感度和特异度 for i, name in enumerate(class_names): tp = cm[i, i] fn = cm[i, :].sum() - tp fp = cm[:, i].sum() - tp tn = cm.sum() - tp - fn - fp sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0 specificity = tn / (tn + fp) if (tn + fp) > 0 else 0 print(f"{name}: Sensitivity={sensitivity:.4f}, Specificity={specificity:.4f}")

classification_report直接给出每个类别的精确率、召回率和 F1。混淆矩阵看哪些类别容易混,比如室上速和窦性心动过速在形态上接近,混淆矩阵里这两类的非对角线元素会偏高。敏感度和特异度手动算一遍,写论文时直接引用。

4.3 超参数怎么调:学习率、batch size 和卷积核数量的组合

深度学习调参没有银弹,但心电分类有几个经验区间。学习率从 1e-3 开始,用余弦退火降到 1e-5。batch size 设 64 或 128,太小梯度噪声大,太大泛化差。卷积核数量从 32 起步,每层翻倍,但不要超过 256,否则小数据集上过拟合严重。Dropout 加在卷积层后面,概率 0.3 到 0.5。

from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR model = ECG1DCNN(num_classes=5) optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-5) criterion = nn.CrossEntropyLoss(weight=class_weights)

weight_decay设 1e-4 做 L2 正则,防止权重过大。CosineAnnealingLR的T_max等于总 epoch 数,eta_min是学习率下限。class_weights从训练集类别频率算出来,传给损失函数。这套配置在 MIT-BIH 上跑 50 个 epoch,验证集 F1 能到 0.98 左右。

5. 避坑与排查:心电分类论文里最容易翻车的五个地方

5.1 数据泄漏:同一患者的心拍同时出现在训练和测试集

现象:验证集准确率 99%,测试集准确率 99%,但换一个数据集或者换一批患者,准确率掉到 70%。原因:随机划分心拍时,同一个患者的不同心拍被分到了训练和测试两边。模型记住了这个患者的波形特征,而不是学到通用的分类模式。解决:用GroupShuffleSplit按患者 ID 划分,确保同一患者的样本只出现在一边。如果数据集本身没有患者 ID,用记录编号代替。

5.2 滤波参数不当:QRS 波群被削平或基线漂移没去干净

现象:滤波后 QRS 波群幅值明显变小,或者波形整体上下浮动。原因:带通滤波的低频截止设得太高,比如 1Hz,会削掉 QRS 波群的低频成分;高频截止设得太低,比如 20Hz,会削掉 QRS 波群的尖峰。解决:低频截止 0.5Hz,高频截止 45Hz,阶数 4。滤波后画几段波形对比,肉眼确认 QRS 波群形态没变形。

5.3 类别不平衡导致模型只预测多数类

现象:训练 loss 一直在降,但验证集上少数类的 F1 是 0。原因:多数类样本太多,模型学到“全部预测成正常”就能拿到低 loss。解决:用加权采样或加权损失函数。检查方法是打印每个 batch 的类别分布,如果某个类别的样本数长期为 0,说明采样器没生效。

5.4 过拟合:训练集 F1 1.0,验证集 F1 0.85

现象:训练集指标远高于验证集,且差距随 epoch 增大。原因:模型参数量相对于数据量太大,或者训练轮数太多。解决:加 Dropout、加 L2 正则、减少卷积核数量、早停。早停的 patience 设 10 到 15 个 epoch,验证集 loss 连续不降就停。

5.5 评估指标选错:用准确率掩盖了敏感度不足

现象:论文里准确率 98%,但审稿人问“房颤的敏感度是多少”,答不上来。原因:准确率在不平衡数据集上有欺骗性。解决:报每个类别的敏感度、特异度和 F1,画混淆矩阵。如果某个类别的敏感度低于 0.9,在论文里要专门讨论原因和改进方向。

6. 从论文到落地:把心电分类模型部署成可调用的推理服务

论文写完了,模型训练好了,下一步是让它能跑起来。我一般会把 PyTorch 模型导出成 ONNX 格式,然后用 FastAPI 包一层 HTTP 接口。这样前端或者移动端可以直接发一段心电信号过来,返回分类结果。

import torch import onnx import onnxruntime as ort import numpy as np from fastapi import FastAPI, UploadFile import io # 导出 ONNX model = ECG1DCNN(num_classes=5) model.load_state_dict(torch.load("ecg_cnn.pth", map_location="cpu")) model.eval() dummy_input = torch.randn(1, 1, 234) torch.onnx.export(model, dummy_input, "ecg_cnn.onnx", input_names=["signal"], output_names=["logits"], dynamic_axes={"signal": {0: "batch"}}) # 推理服务 app = FastAPI() session = ort.InferenceSession("ecg_cnn.onnx") @app.post("/predict") async def predict(file: UploadFile): content = await file.read() signal = np.frombuffer(content, dtype=np.float32).reshape(1, 1, -1) # 做同样的预处理:滤波、切分、归一化 # 这里省略预处理步骤,假设输入已经是切分好的单心拍 logits = session.run(None, {"signal": signal})[0] pred = int(np.argmax(logits, axis=1)[0]) prob = float(np.max(torch.softmax(torch.tensor(logits), dim=1).numpy())) return {"class": pred, "confidence": prob}

torch.onnx.export把模型转成 ONNX,dynamic_axes让 batch 维度可变。onnxruntime做推理,比 PyTorch 原生快 2 到 3 倍,而且不依赖 PyTorch 环境。FastAPI 的UploadFile接收二进制信号数据,预处理步骤要和训练时完全一致,否则推理结果会偏。置信度低于 0.7 的样本建议转人工复核,这在临床场景里是后悔药。

验证方法很简单:拿测试集里的样本,先走一遍训练时的预处理,再发到接口,对比接口返回的类别和真实标签。如果一致率低于 95%,检查预处理是否对齐。我习惯在预处理函数里加断言,确保输入长度和训练时一致。

最后说一个我踩过的坑:ONNX 导出时如果模型里有AdaptiveAvgPool1d,某些版本的 ONNX 算子集不支持,需要把 opset 设成 11 以上。另外,推理服务的输入归一化参数要从训练集统计出来,存成 JSON 文件,服务启动时加载,不要硬编码。这个方案值不值得做?如果你只是写毕业论文,训练完模型跑出指标就够了;如果你想让论文有落地价值,或者后续想做成演示系统,花半天时间搭个推理接口,答辩时演示效果会好很多。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 1:25:18

数据分析师面试技能软件全景:SQL、Python与BI工具高频考点解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:24:44

Ubuntu 20.04 + ROS Noetic 下 LIO-SAM 编译与避坑全指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:24:03

真需求判断指南:三层过滤与数据验证,避开伪需求陷阱

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:23:58

SkyWalking指标体系实战解读:CPM、SLA、P99与JVM监控指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:23:27

工程伦理期末复习指南:核心概念、决策框架与案例分析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:23:24

基于两阶段鲁棒优化的微网多电源容量配置与CCG实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华