简介面向数据应用竞赛与心电图智能识别学习者该资源为山东赛第三届数据应用创新创业大赛“心电图智能事件识别”赛道的亚军方案完整呈现了从原始心电数据获取、信号预处理、特征工程到模型训练、推理与结果融合的竞赛级代码流程。包体精简但结构清晰共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 sdsd 通常指 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, headerNone) return df.values[:, 0].astype(np.float32) elif path.endswith(.npy): return np.load(path).astype(np.float32) else: raise ValueError(funsupported 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], btypebandpass, fsfs) 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_size15, stride1, padding7), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), ) # 第二层扩大感受野捕获更长的节律信息 self.conv2 nn.Sequential( nn.Conv1d(64, 128, kernel_size9, stride1, padding4), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(kernel_size2, stride2), ) # 第三层全局特征聚合 self.conv3 nn.Sequential( nn.Conv1d(128, 256, kernel_size5, stride1, padding2), nn.BatchNorm1d(256), nn.ReLU(inplaceTrue), 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 size15 对应 200Hz 采样率下约 75ms 的窗口正好覆盖一个 QRS 波群。序列信息的建模这套方案选择的是卷积堆叠没有额外加 LSTM。原因很简单短窗口样本里CNN 的感受野已经能覆盖完整心拍如果一定要加序列建模建议只在 conv3 之后加一层双向 LSTMhidden 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_splits5): 按病人 ID 分组划分确保同一个病人的心拍不会同时出现在训练和验证集 gkf GroupKFold(n_splitsn_splits) for train_idx, val_idx in gkf.split(records, labels, groupspatient_ids): yield train_idx, val_idxGroupKFold 是这种任务里必须用到的划分方式。普通 KFold 会把同一个病人的数据拆到不同折里验证分数虚高但到了真正的测试集上效果大幅下滑。按病人分组划分之后验证集分数才接近真实上线水平。optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30, eta_min1e-5) criterion torch.nn.CrossEntropyLoss(label_smoothing0.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_norm5.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_DEVICES0 export PYTHONPATH$PWD:$PYTHONPATH python infer.py \ --model_dir ./checkpoints \ --input_dir ./data/test \ --output_dir ./submissions \ --write_probabilitiesCUDA_VISIBLE_DEVICES0 指定单卡运行避免多卡环境出现显存分配异常。--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, axis0, weightsweights) preds np.argmax(weighted, axis1) 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.pt3跑 infer.sh 生成概率文件确认 submissions 下出现 fold_0.npy4跑 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 里的数字复现了再动手改造。希望这套思路对你也同样适用。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?