☰
基于机器学习的心电信号分类:从论文到工程落地的可复现方案
2026/9/27 21:05:39 网站建设 项目流程

简介:这是一份面向生物医学工程、机器学习方向本科生与研究生的毕业论文参考资料,聚焦心电信号自动分类这一交叉研究课题,适合正在选题、撰写或复现相关实验的读者。全文围绕心电信号基础知识、去噪处理、特征提取、迁移学习分类及卷积神经网络等算法展开,并配有PTB、MIT-BIH数据库说明与心肌梗死自动分类实验章节,目录结构完整、章节层次清晰。资源包共1个PDF文件,约8.34MB,为完整论文正文,可直接用于阅读、引用与思路借鉴。目前已有294人学习下载,读者可从中获取从绪论、国内外研究现状到实验结果分析与结论的完整写作框架,理解小波阈值去噪、时频域特征提取与迁移学习分类的实现脉络,为自身课题的算法选型、实验设计与论文撰写提供可参考的范本。

1. 从一份「基于机器学习的心电信号分类研究_毕业论文.pdf」说起:这套方案到底能不能落地

如果你手里正躺着一份《基于机器学习的心电信号分类研究_毕业论文.pdf》,大概率你面对的不是「读不懂论文」,而是「读懂了却复现不出来」。心电信号分类这件事,论文里写得头头是道:MIT-BIH 数据库、小波去噪、特征提取、SVM 或随机森林、准确率 98%。可你一旦打开 Python,把数据下载下来,就会发现信号长度对不齐、R 波检测飘、类别极度不平衡、训练集和测试集按 beat 切分导致数据泄漏——准确率瞬间从 98% 掉到 70%。这不是你菜,这是心电分类这个方向最典型的「论文与工程之间的鸿沟」。

这篇笔记不打算复述任何一篇论文的摘要,而是把「基于机器学习的心电信号分类」当成一个可复现的工程任务来拆:数据从哪来、怎么切、特征怎么提、模型怎么选、指标怎么看、坑在哪。适合三类人:正在做毕业论文选题、需要一套能跑通 baseline 的学生;想把 ECG 分类接进可穿戴或健康监测产品的工程师;以及被「机器学习检测」这个词吸引、想找一个真实信号处理场景练手的入门者。读完你应该能自己搭出一条从原始 ECG 到分类输出的完整链路,并且知道每一步为什么这么做。

2. 心电信号分类的任务边界:先把「分类什么」定死

2.1 三种分类粒度,决定了后面所有代码的写法

很多人一上来就写模型,结果卡在「标签对不上」。心电分类按粒度分三种,选错了后面全乱。

第一种是心跳级(beat-level)分类,也是 MIT-BIH arrhythmia 数据集最经典的用法:把连续 ECG 按 R 波位置切成一个个单拍,每个拍子打一个标签(N、V、A、L、R 等)。这是绝大多数毕业论文采用的设定,因为样本量大、类别明确。第二种是片段级(segment-level)分类,把 10 秒或 30 秒的片段整体判为「正常/房颤/其他」,适合可穿戴设备的粗筛。第三种是记录级(record-level)分类,一个受试者一条记录一个标签,样本极少,通常只用于特定疾病筛查。

毕业论文里 90% 写的是第一种,但代码里经常混入第二种的切分方式,导致同一个人的相邻心跳同时出现在训练集和测试集——这就是数据泄漏,准确率虚高的头号原因。

2.2 MIT-BIH 之外,你还能用什么数据

数据集记录数采样率标签粒度适合场景
MIT-BIH Arrhythmia48 条360 Hz心跳级毕业论文 baseline
MIT-BIH AFIB25 条250 Hz片段级房颤检测
PTB Diagnostic549 条1000 Hz记录级多疾病分类
CPSC 20186877 条500 Hz片段级多标签、类别不平衡
PhysioNet 202043101 条500 Hz片段级12 导联、大规模

选数据集的判断标准很简单:你的标签粒度必须和你的切分方式一致。做心跳级就用 MIT-BIH,做片段级就用 CPSC 或 2020,别拿 MIT-BIH 硬切片段,样本量根本不够。

2.3 为什么我不建议一上来就上深度学习

这是血泪经验。心电信号在心跳级任务上,特征工程 + 树模型的 baseline 往往能到 95% 以上,而 1D-CNN 如果没调好,可能只有 90%。原因有三个:一是单拍长度只有 200~300 点,深度模型容易过拟合;二是类别不平衡严重(正常拍占 80% 以上),深度模型对少数类不敏感;三是论文里报的深度学习结果,很多是在「随机切分」下得到的,一旦改成按记录切分,性能掉得比传统方法还狠。

所以我的建议是:先用统计特征 + 随机森林跑通全流程,拿到一个可信的 baseline,再考虑要不要换深度学习。这个 baseline 也是你论文里最有说服力的一组对照。

3. 从原始信号到特征矩阵:一条能跑通的 Python 链路

3.1 读取与去噪:wfdb 读数据,带通滤波去基线漂移

import wfdb import numpy as np from scipy.signal import butter, filtfilt def read_record(rec_path): # 读取 MIT-BIH 记录,返回信号和注释 record = wfdb.rdrecord(rec_path) annotation = wfdb.rdann(rec_path, 'atr') signal = record.p_signal[:, 0] # 取第一导联 fs = record.fs return signal, annotation, fs def bandpass_filter(signal, fs, low=0.5, high=40.0, order=3): # 0.5~40Hz 带通,去掉基线漂移和高频噪声 nyq = 0.5 * fs b, a = butter(order, [low/nyq, high/nyq], btype='band') return filtfilt(b, a, signal) signal, ann, fs = read_record('mitdb/100') clean = bandpass_filter(signal, fs) print(fs, len(signal), len(ann.sample))

这段代码做了三件事:wfdb.rdrecord读取原始信号,rdann读取专家标注的 R 波位置和类型,bandpass_filter用 0.5~40 Hz 的巴特沃斯带通滤掉基线漂移(<0.5 Hz)和肌电高频(>40 Hz)。参数上,order=3是经验值,阶数太高会引入相位失真,filtfilt做零相位滤波避免 R 波位置偏移。注意 MIT-BIH 的采样率是 360 Hz,奈奎斯特频率 180 Hz,40 Hz 上限对 QRS 形态保留足够。

3.2 心跳切分:以 R 波为中心,前后各取固定窗口

def segment_beats(signal, ann, fs, before=0.25, after=0.4): # 以 R 波为中心切分,前 0.25s 后 0.4s,共约 0.65s win_b = int(before * fs) win_a = int(after * fs) beats, labels = [], [] for pos, sym in zip(ann.sample, ann.symbol): if pos - win_b < 0 or pos + win_a >= len(signal): continue seg = signal[pos-win_b: pos+win_a] beats.append(seg) labels.append(sym) return np.array(beats), np.array(labels) beats, labels = segment_beats(clean, ann, fs) print(beats.shape, np.unique(labels, return_counts=True))

切分窗口的选择直接影响分类难度。前 0.25 s 覆盖 P 波和 PR 段,后 0.4 s 覆盖 QRS 和 T 波,总长 0.65 s 在 360 Hz 下约 234 个点。窗口太短会丢掉 P 波信息,房性和室性拍就分不开;窗口太长会引入相邻拍干扰。before=0.25, after=0.4是我在 MIT-BIH 上试出来比较稳的一组,你可以按 0.05 s 步长微调。

3.3 特征提取:时域、频域、形态学三类特征

from scipy.stats import skew, kurtosis from numpy.fft import rfft, rfftfreq def extract_features(beat, fs): feats = [] # 时域统计 feats += [np.mean(beat), np.std(beat), skew(beat), kurtosis(beat)] feats += [np.max(beat), np.min(beat), np.ptp(beat)] # 频域能量分布 spec = np.abs(rfft(beat)) freqs = rfftfreq(len(beat), 1/fs) for lo, hi in [(0,5),(5,15),(15,40)]: mask = (freqs >= lo) & (freqs < hi) feats.append(np.sum(spec[mask]**2)) # 形态学:R 波峰值与前后波谷差 r_idx = np.argmax(np.abs(beat)) feats.append(beat[r_idx]) feats.append(beat[:r_idx].min() if r_idx > 0 else 0) return np.array(feats) X = np.array([extract_features(b, fs) for b in beats]) print(X.shape)

这里提了三类共 12 维特征:时域统计(均值、标准差、偏度、峰峰值)刻画整体幅度分布;频域把 0~40 Hz 分成三段算能量,QRS 主能量在 5~15 Hz,P/T 波在 0~5 Hz,房颤时高频能量会上升;形态学特征直接取 R 波峰值和 R 前波谷,对室性早搏(宽大畸形 QRS)特别敏感。特征维度不是越多越好,我试过堆到 40 维,随机森林的准确率反而因为冗余特征掉了 1~2 个点。

3.4 按记录切分训练测试集:这一步决定你的准确率是不是真的

from sklearn.model_selection import train_test_split # 假设 beats 和 labels 已经按记录顺序排列,records 是每条拍所属记录号 records = np.array([...]) # 每条 beat 对应的记录 ID unique_recs = np.unique(records) train_recs, test_recs = train_test_split(unique_recs, test_size=0.2, random_state=42) train_mask = np.isin(records, train_recs) test_mask = np.isin(records, test_recs) X_train, y_train = X[train_mask], labels[train_mask] X_test, y_test = X[test_mask], labels[test_mask] print(X_train.shape, X_test.shape)

按记录切分是心电分类里最重要的一行代码。如果直接train_test_split(X, y),同一个人的相邻心跳会同时进训练和测试,模型只是记住了这个人的波形,准确率能虚高 10~20 个点。按记录切分后,测试集里全是训练时没见过的受试者,这才是真实泛化能力。random_state=42固定随机种子,保证你论文里的结果可复现。

4. 模型选型与训练:从随机森林到 1D-CNN 的取舍

4.1 随机森林 baseline:参数少、可解释、够用

from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import classification_report, confusion_matrix rf = RandomForestClassifier( n_estimators=200, max_depth=12, class_weight='balanced', random_state=42, n_jobs=-1 ) rf.fit(X_train, y_train) y_pred = rf.predict(X_test) print(classification_report(y_test, y_pred, zero_division=0)) print(confusion_matrix(y_test, y_pred))

三个关键参数:n_estimators=200是精度和训练时间的平衡点,再往上收益很小;max_depth=12防止对少数类过拟合;class_weight='balanced'让少数类(如室性早搏)的权重按类别频率反比放大,这是处理心电类别不平衡最省事的手段。跑完看classification_report,重点不是 accuracy,而是少数类的 recall 和 F1。如果 V 类 recall 低于 0.7,说明模型在偷懒,全预测成 N 了。

4.2 1D-CNN 什么时候值得上

import torch import torch.nn as nn class ECGNet(nn.Module): def __init__(self, n_classes=5): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 16, kernel_size=7, padding=3), nn.BatchNorm1d(16), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(16, 32, kernel_size=5, padding=2), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(32, 64, kernel_size=3, padding=1), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1) ) self.fc = nn.Linear(64, n_classes) def forward(self, x): # x: (B, 1, L) return self.fc(self.conv(x).squeeze(-1))

这个网络只有三层卷积,参数量不到 3 万,适合单拍 200~300 点的输入。BatchNorm在信号任务里比 Dropout 更稳,因为心电幅度受个体差异影响大,归一化能显著加速收敛。AdaptiveAvgPool1d(1)替代全连接展平,减少参数量同时保留通道语义。什么时候值得上 CNN?我的判断是:当你的特征工程 baseline 已经调到瓶颈,且样本量超过 5 万拍时,CNN 才可能带来 1~3 个点的提升。否则优先优化特征和切分。

4.3 类别不平衡的三种处理方式对比

方法做法优点缺点
类权重class_weight='balanced'不改数据、简单对极端不平衡效果有限
过采样SMOTE 生成少数类提升少数类 recall可能生成不真实波形
欠采样随机丢弃多数类训练快丢失大量正常样本信息

心电信号我不建议用 SMOTE,因为生成的波形在生理上不一定成立,模型学到的是插值噪声。类权重 + 阈值调整(对少数类降低判定阈值)是更稳妥的组合。如果非要过采样,用RandomOverSampler复制真实样本,比 SMOTE 安全。

5. 避坑与排查:心电分类里最容易翻车的五件事

5.1 准确率 99% 但混淆矩阵全是 N 类

现象:训练完一看 accuracy 0.98,兴奋半天,打印混淆矩阵发现所有样本都被预测成 N(正常拍)。原因:MIT-BIH 里正常拍占比超过 80%,模型只要全猜 N 就能拿到高准确率,这是典型的类别不平衡陷阱。解决:把评估指标从 accuracy 换成 macro-F1 或少数类 recall,训练时加class_weight='balanced',并在论文里同时报告混淆矩阵。

5.2 R 波检测偏移导致切分窗口错位

现象:用wfdb的注释切出来的拍子,QRS 不在窗口中央,有的甚至切到 T 波上。原因:MIT-BIH 的注释位置是专家标注的 R 波峰值,但不同记录里注释点可能落在 R 波上升沿而非峰值,直接以注释点为中心会偏移 10~20 个采样点。解决:以注释点为中心开一个 ±50 ms 的搜索窗,在窗内找绝对值最大点作为真实 R 波位置,再切分。这一步能让形态学特征的稳定性明显提升。

5.3 滤波把 QRS 幅度削掉了

现象:带通滤波后,R 波峰值从 1.2 mV 降到 0.8 mV,室性早搏的宽 QRS 变得不明显。原因:巴特沃斯滤波器的截止频率设得太低(比如 20 Hz),而 QRS 的主能量在 10~30 Hz,被衰减了。解决:高通截止不要高于 0.5 Hz,低通截止不要低于 40 Hz。如果信号噪声大,宁可先用中值滤波去基线,再带通,不要一味压低低通截止。

5.4 训练集和测试集按 beat 随机切分

现象:论文里写准确率 98%,但换一个数据集测试掉到 75%。原因:同一个受试者的相邻心跳高度相似,随机切分让训练集和测试集共享了受试者信息,模型在「背答案」。解决:严格按记录 ID 切分,训练集和测试集的记录号完全不重叠。这是心电分类论文能不能站住脚的分水岭。

5.5 用 accuracy 选模型,越选越偏

现象:调参时 accuracy 一直在涨,但少数类 F1 在跌,最后模型只会预测多数类。原因:accuracy 对类别不平衡不敏感,优化它等于优化多数类。解决:选模型时用 macro-F1 或 balanced accuracy,早停和超参搜索都以这个指标为准。论文里报告结果时,accuracy、macro-F1、少数类 recall 三个都给。

6. 让结果站得住:交叉验证、指标报告与一个可复现的小技巧

走到这一步,你已经有了一条能跑的链路。但毕业论文和工程落地之间还差一件事:结果的可信度。单次按记录切分的测试集只有 10 条左右记录,随机性很大,换个random_state准确率可能差 3 个点。我的习惯是做按记录分组的 5 折交叉验证,用GroupKFold保证同一记录不出现在两折里。

from sklearn.model_selection import GroupKFold, cross_val_score gkf = GroupKFold(n_splits=5) scores = cross_val_score(rf, X, labels, cv=gkf, groups=records, scoring='f1_macro') print(scores.mean(), scores.std())

groups=records是关键,它告诉交叉验证器「同一个记录号的样本必须在一起」。scoring='f1_macro'而不是默认的 accuracy,保证少数类被公平对待。跑完你会得到 5 个 F1 值,报告mean ± std,这比单次切分有说服力得多。如果 std 超过 0.05,说明你的模型对受试者差异太敏感,需要回头检查特征是否过度依赖个体幅度。

另一个我常用的技巧是画每类的 PR 曲线,而不是只报一个阈值下的结果。心电分类在临床场景里,医生更关心「在 recall 95% 的前提下,precision 能到多少」,这需要看 PR 曲线上的工作点,而不是一个固定阈值。用sklearn.metrics.precision_recall_curve对每个类别单独画,能直观看出哪些类难分。

最后说一个我踩过的坑:不要用测试集调参。我见过太多论文,测试集准确率 97%,一问才知道滤波截止频率、窗口长度、树深度都是在测试集上试出来的。正确做法是训练集内部再切一个验证集,或者用交叉验证选参数,测试集只在最后跑一次。这个习惯看起来麻烦,但它是你的结果能不能被别人复现的底线。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询