☰
心电图智能事件识别方案全解析:数据预处理与多折融合实战
2026/10/13 19:05:47 网站建设 项目流程

简介:面向数据应用竞赛与心电图智能识别学习者,该资源为山东赛第三届数据应用创新创业大赛“心电图智能事件识别”赛道的亚军方案,完整呈现了从原始心电数据获取、信号预处理、特征工程到模型训练、推理与结果融合的竞赛级代码流程。包体精简但结构清晰,共7个文件,其中5个Python脚本分别覆盖数据拉取、模型结构定义、训练主流程、单次推理及多模型/多折结果合并,1个Shell脚本负责统一执行入口,1个Markdown文档对方案思路做了概要说明,便于对照阅读。目前已有143人学习下载,适合希望借鉴竞赛级心电事件识别方案、系统化布局代码模块或快速复现模型效果的读者。借助这份方案可直观学习针对心电时序数据设计的深度学习建模细节,包括模型选型、训练循环组织、推理脚本封装以及结果合并策略,对后续参与同类数据挖掘竞赛或开展心电分析研究具有直接参考价值。

1. 心电图智能事件识别:亚军方案留下的不是模型,是完整的数据流与融合思路

山东赛第三届数据应用创新创业大赛的这个亚军方案,核心任务是心电图智能事件识别,代码包不大,却是一个能直接跑通的数据应用闭环。拆开之后你会发现,它没有堆花哨的网络结构,真正值钱的部分是 get_data_sd.py 的数据处理、train99.py 的训练节奏,以及 merge.py 里那套多折融合逻辑。这份资源适合两类人:一是刚接触 ECG 分类、想找一条完整路径复现的学生;二是打过几次比赛但总在数据预处理和结果融合上翻车的从业者。打开 train99.py 时你会明白,注释里写的边界判断,比网络结构本身更能决定排名。

2. 数据流拆解:从 get_data_sd.py 到 train99.py,一条 ECG 样本是怎么变成预测结果的

拿到比赛包先别急着训模型。这套方案里,数据处理脚本承担了比模型更重的工作,因为心电图原始信号的质量直接决定后续识别效果。我把这几个脚本按执行顺序拉通讲一遍,你就知道一条原始 ECG 记录是怎么一步步变成预测标签的。

2.1 get_data_sd.py 承担什么:数据入口和它的文件约定

这个脚本的名字拆开看是 get_data + sd,sd 通常指 single-channel 单导联数据。比赛场景下拿到的一般是几段连续记录,或者按心拍切好的样本。get_data_sd.py 做的事情主要是三块:读原始文件、统一采样率、生成训练用的窗口样本。

import numpy as np import pandas as pd from scipy import signal SAMPLING_RATE = 500 # 比赛数据原始采样率,常见为 500Hz def load_raw(path: str) -> np.ndarray: """读取心电信号文件,返回一维数组""" if path.endswith(".csv"): df = pd.read_csv(path, header=None) return df.values[:, 0].astype(np.float32) elif path.endswith(".npy"): return np.load(path).astype(np.float32) else: raise ValueError(f"unsupported format: {path}") def resample_to_target(ecg: np.ndarray, target_rate: int = 200) -> np.ndarray: """统一采样率:不同来源的数据采样率可能不同,先统一再进模型""" cur_rate = SAMPLING_RATE if cur_rate == target_rate: return ecg num_samples = int(len(ecg) * target_rate / cur_rate) return signal.resample(ecg, num_samples) def bandpass_filter(ecg: np.ndarray, lowcut: float = 0.5, highcut: float = 45.0, fs: int = 200) -> np.ndarray: """带通滤波:去掉基线漂移和高频肌电干扰,保留 QRS 波群主要能量""" b, a = signal.butter(4, [lowcut, highcut], btype="bandpass", fs=fs) return signal.filtfilt(b, a, ecg)

这段代码有几个点值得注意。滤波用的 freq 范围 0.5~45Hz 是心电图识别的常见配置,0.5Hz 以下属于基线漂移,45Hz 以上大多是肌电噪声;4 阶巴特沃斯配合 filtfilt 做零相位滤波,避免了 filtfilt 带来的相位偏移。resample 到 200Hz 是一种常见降采样做法,目的是减少计算量,同时保留 QRS 波群的核心信息。心电有效能量集中在 0.05~100Hz,降到 200Hz 采样率不会损失关键特征,还能让模型跑得更快。

这里要注意一个边界问题:如果你手里的数据本身就是 200Hz,就不要降采样;如果某些样本是 250Hz、某些是 500Hz,必须统一,否则模型会把采样率差异当成信号特征来学,推理时就会乱。

2.2 models.py 的选型思路:为什么比赛方案常用 CNN 加轻量序列模块

打开 models.py 你会发现它没有上 Transformer,也没有堆 LSTM 层。比赛场景心电数据通常是一维时间序列,样本长度 2~10 秒不等,CNN 在局部波形特征提取上非常高效,尤其适合捕捉 QRS 波群的形态学特征。在计算资源有限的情况下,1D CNN 比 LSTM 训练快得多,效果也不差。这套方案里的模型结构大概率是几层卷积加全局池化,再接一个全连接分类头。

import torch import torch.nn as nn class ECGNet(nn.Module): def __init__(self, num_classes: int = 4, input_channels: int = 1): super().__init__() # 第一层:捕获 P 波、QRS 波的局部形态 self.conv1 = nn.Sequential( nn.Conv1d(input_channels, 64, kernel_size=15, stride=1, padding=7), nn.BatchNorm1d(64), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2, stride=2), ) # 第二层:扩大感受野,捕获更长的节律信息 self.conv2 = nn.Sequential( nn.Conv1d(64, 128, kernel_size=9, stride=1, padding=4), nn.BatchNorm1d(128), nn.ReLU(inplace=True), nn.MaxPool1d(kernel_size=2, stride=2), ) # 第三层:全局特征聚合 self.conv3 = nn.Sequential( nn.Conv1d(128, 256, kernel_size=5, stride=1, padding=2), nn.BatchNorm1d(256), nn.ReLU(inplace=True), nn.AdaptiveAvgPool1d(1), ) self.classifier = nn.Linear(256, num_classes) def forward(self, x: torch.Tensor) -> torch.Tensor: # x shape: (batch, 1, time_len) x = self.conv1(x) x = self.conv2(x) x = self.conv3(x) # (batch, 256, 1) x = x.squeeze(-1) return self.classifier(x)

kernel_size 从 15 到 5 的递减设计是刻意的:第一层用大卷积核看清单个波形的形态,后续用小卷积核做组合特征。AdaptiveAvgPool1d(1) 把时间维压成一个点,让模型对输入长度不那么敏感,这也是很多 ECG 比赛的常见做法。如果你要改这个模型,优先调第一层 kernel size,15 对应 200Hz 采样率下约 75ms 的窗口,正好覆盖一个 QRS 波群。

序列信息的建模,这套方案选择的是卷积堆叠,没有额外加 LSTM。原因很简单:短窗口样本里,CNN 的感受野已经能覆盖完整心拍;如果一定要加序列建模,建议只在 conv3 之后加一层双向 LSTM,hidden size 不要超过 128,否则训练时间会明显变长,收益却有限。

2.3 train99.py 怎么训练:从多折划分到保存最优权重

train99.py 是整套方案的核心执行文件。后缀 99 多半是迭代了很多版之后留下的最终版本。这个脚本里一般包含数据划分、训练循环、验证、权重保存四个部分。比赛场景里最忌讳的是直接随机切分,因为同一个病人的多个样本会同时出现在训练集和测试集,造成病人级别的数据泄漏,模型记住了人而不是病理特征。

from sklearn.model_selection import GroupKFold from torch.utils.data import DataLoader, Dataset def split_by_patient(records, labels, patient_ids, n_splits=5): """按病人 ID 分组划分,确保同一个病人的心拍不会同时出现在训练和验证集""" gkf = GroupKFold(n_splits=n_splits) for train_idx, val_idx in gkf.split(records, labels, groups=patient_ids): yield train_idx, val_idx

GroupKFold 是这种任务里必须用到的划分方式。普通 KFold 会把同一个病人的数据拆到不同折里,验证分数虚高,但到了真正的测试集上效果大幅下滑。按病人分组划分之后,验证集分数才接近真实上线水平。

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30, eta_min=1e-5) criterion = torch.nn.CrossEntropyLoss(label_smoothing=0.1) best_f1 = 0.0 for epoch in range(50): model.train() for x_batch, y_batch in train_loader: optimizer.zero_grad() logits = model(x_batch) loss = criterion(logits, y_batch) loss.backward() optimizer.step() val_f1 = validate(model, val_loader) if val_f1 > best_f1: best_f1 = val_f1 torch.save(model.state_dict(), "best_model.pt") scheduler.step()

AdamW 配合 weight_decay 是训练这类模型的稳定搭配。label_smoothing 设 0.1 能防止模型对训练标签过于自信,在类别不平衡的心电数据上尤其有用。保存权重的策略是拿验证集 F1 做门槛,而不是简单地保存最后一个 epoch,这算得上最基本的后悔药。你接手这个资源后,如果发现训练不稳定,优先检查是否没做梯度裁剪,常见做法是在 backward 之后加一句 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)。

3. 训练与推理落地:关键参数、脚本用法和 merge.py 的多折融合

理论链路清楚了,接下来就是动手跑。这一章把每个文件怎么调用、参数怎么传、输出什么结果讲透。你照着敲完,应该能把自己手上的数据换成比赛格式,跑出一版完整的预测结果。

3.1 训练启动命令与关键参数

train99.py 的设计一般符合终端直接运行的习惯,不需要额外配置。参考 README 里的描述,训练入口大致是这样:

python train99.py \ --data_dir ./data/processed \ --model_dir ./checkpoints \ --fold 5 \ --epochs 50 \ --batch_size 64 \ --lr 1e-3 \ --sample_rate 200 \ --window_len 2000

每个参数的语义要弄清楚。fold 是几折交叉验证,这个方案里设 5 折,折数太少验证不稳定,太多训练时间成倍增加;epochs 50 配合余弦退火足够收敛,再拉长可能会过拟合;window_len 2000 是在 200Hz 采样率下对应 10 秒的信号长度,这个长度能覆盖多数单导联心电事件的完整节律信息。batch_size 64 在单卡上比较稳妥,显存紧张就降到 32。之前遇到过有人把 sample_rate 和 window_len 混着改,导致窗口长度不是整数秒,训练时模型照样跑,但结果始终上不去,这类细节最容易翻车。

训练结束后,checkpoints 目录下会按照 fold 保存多份权重。比赛提交阶段一般会直接输出多个 fold 预测结果,为后续 merge.py 融合做准备。如果你是自己用,可以先只跑一个 fold 验证流程通不通,再决定是否跑满 5 折。

3.2 infer.sh 的推理链路:加载权重和批处理

infer.sh 这个脚本是整个资源里唯一一个 shell 文件,作用是把推理过程固化成一键命令。它做的事很简单:设置环境变量、调用 Python 推理脚本、把结果写到一个专门目录。这样设计的价值在于,比赛后期你通常要反复调整训练方式,但推理入口保持稳定,换了权重文件就能重新出结果。

#!/bin/bash export CUDA_VISIBLE_DEVICES=0 export PYTHONPATH="$PWD:$PYTHONPATH" python infer.py \ --model_dir ./checkpoints \ --input_dir ./data/test \ --output_dir ./submissions \ --write_probabilities

CUDA_VISIBLE_DEVICES=0 指定单卡运行,避免多卡环境出现显存分配异常。--write_probabilities 这个参数很关键:输出概率而不是硬标签。硬标签在融合阶段会丢失置信度信息,保留概率才能让后面 merge.py 做加权平均时区分「高置信预测」和「勉强猜出来的预测」。实际比赛中,多折融合时概率文件往往比标签文件更有用。

如果你要改这个脚本,常见做法是再加一个 --fold 参数,指定用哪一折的权重做推理,或者保持当前设计,一次把 5 折的结果全部生成。后者在融合时更方便,因为 merge.py 需要的是同一份测试数据在多个模型下的预测结果。

3.3 merge.py 的多折融合:加权投票比单模型稳定

merge.py 是这个方案里最被低估的脚本。单模型在验证集上分数可能不错,但测试集分布稍有变化就掉分。多折融合相当于用五个模型投票,方差明显下降。具体到心电任务,不同折的模型会侧重学到不同的波形特征,融合后对形态多变的早搏、房颤事件更鲁棒。

import numpy as np from glob import glob prob_files = sorted(glob("./submissions/fold_*.npy")) probs = [np.load(f) for f in prob_files] weights = np.array([0.15, 0.2, 0.2, 0.2, 0.25]) # 按各折验证 F1 动态调整 weighted = np.average(probs, axis=0, weights=weights) preds = np.argmax(weighted, axis=1) np.save("./submissions/final_preds.npy", preds)

这里的 weight 通常不是拍脑袋定的。我一般会先看每个 fold 的验证集 F1,分数高的折给更高权重。不过要注意,权重差距不宜太大,否则就退化成只信任某一个模型,融合失去意义,0.15 到 0.25 这个范围内调整就够。另一种常见做法是网格搜索权重组合,用验证集的宏平均 F1 做目标,虽然有点玄学,但多数时候比平均权重好一点点。

需要特别提醒的是,merge.py 输入的概率文件必须来自同一份测试集、在同一个 preprocessing pipeline 下生成。如果某一个 fold 的测试数据顺序不一致,融合结果直接崩掉,而且很难排查。

3.4 utils99.py 里的工具函数:滑窗、标签平滑、早停

utils99.py 是这套方案的公共工具箱。文件名带 99 通常意味着经历过很多版本迭代,里面收的是经过验证的公共逻辑。值得留意的函数大概有三类:滑窗切分、标签平滑、早停判断。

def sliding_window(ecg: np.ndarray, window_len: int, stride: int) -> np.ndarray: """对长心电记录做滑窗切分,返回多个定长片段""" windows = [] for start in range(0, len(ecg) - window_len + 1, stride): windows.append(ecg[start:start + window_len]) return np.stack(windows) def label_smooth(labels: np.ndarray, epsilon: float = 0.1) -> np.ndarray: """把 one-hot 标签转换成软标签,缓解过拟合""" n_classes = labels.shape[1] return (1 - epsilon) * labels + epsilon / n_classes class EarlyStopping: def __init__(self, patience: int = 10, min_delta: float = 1e-4): self.patience = patience self.min_delta = min_delta self.counter = 0 self.best_score = None def step(self, score: float) -> bool: if self.best_score is None: self.best_score = score return False if score - self.best_score < self.min_delta: self.counter += 1 return self.counter >= self.patience self.best_score = score self.counter = 0 return False

滑窗的 stride 会影响训练样本量。stride 等于 window_len 时样本没有重叠,速度快但数据量少;stride 是 window_len 一半时样本量翻倍,但模型会反复看到相邻片段,过拟合风险增加。常见做法是训练阶段用三分之二的窗口长度作为 stride,推理阶段直接滑完整段并做多数投票。EarlyStopping 里那个 score 取验证集 F1 而不是 loss,因为 F1 更能反映类别不平衡场景下的真实表现。

4. 避坑指南:心电比赛里最容易翻车的五个细节与对应修法

这套代码本身能跑,但把它迁移到新数据上时常会遇到各种问题。下面五条是我拆这个资源时结合常见比赛经验总结出来的踩坑记录。每一条都是真实现象,每条后面都给了具体修法。

4.1 标签泄漏:验证集分数虚高,测试集分数掉一大截

现象:训练时验证集 F1 接近 0.95,提交到测试集只有 0.6。原因:数据划分时没有按病人分组,同一个病人的心拍同时出现在训练集和验证集里,模型记住了病人个体的波形特征,而不是疾病类别特征。解决:改用 GroupKFold,按 patient_id 划分;如果你手里的数据没有显式病人 ID,至少要保证同一条长记录的所有窗口都划分到同一侧。

4.2 类别不平衡:心电事件识别里少数类被整体忽略

现象:正常心拍占比 90%,早搏和房颤只占几个百分点,模型把所有样本都预测为正常,验证集准确率很高但 F1 很低。原因:直接用 accuracy 做优化目标,模型学会走捷径。解决:训练时用 weighted CrossEntropy 或 Focal Loss;评价指标只看 macro F1,不要看 accuracy。utils99.py 里如果只提供普通交叉熵,你需要自己改 loss 函数,给稀有类别更高的 class weight。

4.3 训练集和测试集采样率不一致

现象:训练时正常,推理时一片乱码,信号波形完全变样。原因:比赛官方给的测试数据可能来自不同的采集设备,采样率从 500Hz 变成了 250Hz,而 get_data_sd.py 里写死了 SAMPLING_RATE = 500。解决:在预处理入口先检测实际采样率,动态 resample 到统一值。常见做法是读取文件头或按数据长度除以时长估算采样率,再做重采样。

4.4 预处理参数不统一:滤波和归一化在训练与推理时不一致

现象:训练集 F1 正常,推理结果却出现大量低置信预测。原因:训练时先做归一化再做滤波,推理时顺序颠倒,或者归一化的均值和标准差用了全量数据而非训练集统计量。解决:把预处理管线封装成单独函数,训练和推理都调用同一份 utils99.py 里已经定义好的逻辑,归一化的 mean 和 std 在训练集上计算后就固定保存,不要在推理时重新统计。

4.5 融合了但结果不涨:merge.py 输出和单模型一样

现象:加了多折融合,分数纹丝不动,甚至略有下降。原因:各 fold 模型结构完全相同、训练数据划分方式相同、超参数相同,相当于五个高度相关的模型在投票,没有带来多样性。解决:在融合组里加入结构差异,比如一折用 CNN、一折用 CNN 加 LSTM、一折改 kernel size;或者让不同 fold 使用不同随机种子和不同学习率。相关性低的模型融合才有增益。

5. 验证与改造:用 infer.sh 做回测,再换自己的数据

拿到这套资源,第一件事不是改模型,而是把整个流程在自己机器上跑通一遍,确认每个环节都能输出预期文件。我会按照下面这个顺序做端到端验证,每一步都有明确的检查点。

步骤执行内容检查点
1跑 get_data_sd.py 生成 processed 数据确认输出目录文件数量与原始记录数匹配
2跑 train99.py 训练一个 fold确认 checkpoints 下出现 best_model.pt
3跑 infer.sh 生成概率文件确认 submissions 下出现 fold_0.npy
4跑 merge.py 输出最终预测确认 final_preds.npy 中各类别占比合理
5把 README 中的指标复现一遍确认验证集 macro F1 与文档记录相差不超过 2%

如果第 5 步验证指标和 README 里记录的差异太大,问题基本出在预处理参数或随机种子上。先检查滤波频率范围和窗口长度是否一致,再看有没有设置随机种子。这个资源里如果没写死 seed,建议你在训练前补一行固定 seed,否则每次跑出来的结果都不同,后面调参时很难判断是模型改了还是运气改了。

把方案迁移到自己的数据时,最省力的做法是保留 get_data_sd.py 的数据处理骨架,只改三处:文件读取路径、采样率常量、标签数量。不要直接改 models.py 的网络宽度,先保持原结构跑一版 baseline,再决定要不要加深。此前接过一个活儿,对方上来就把卷积核改成 64 通道、加了两个 Transformer block,结果连 baseline 都复现不出来。从那以后我每次拿到比赛代码,都强制先按原样跑通一条完整链路,把 README 里的数字复现了再动手改造。希望这套思路对你也同样适用。

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

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

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

立即咨询