简介:这份资源面向数据挖掘零基础学习者与天池竞赛入门选手,围绕心跳信号分类预测赛题提供一套可复现的实战代码方案,帮助读者在医学数据场景中完成从数据预处理到模型提交的完整流程。压缩包共2个文件,均为Python脚本,整体约4KB,分别承担训练测试与结果提交两类任务,便于直接运行和二次修改。目前已有2642人学习下载,说明其在入门竞赛圈中具备一定参考价值。代码覆盖数据清洗、标准化、特征工程与模型训练评估等关键环节,并涉及预测结果格式整理与提交逻辑,读者可借此理解赛题建模思路、上分提分技巧以及常见排错方向,适合希望快速上手数据挖掘竞赛、积累完整项目经验的学习者参考。
1. 从 386 分到 33 名:一份心跳信号分类的实战代码包
去年有个做后端的朋友突然问我,说想转数据挖掘,问我有没有那种“能跑通、能上分、能看懂”的入门项目。我给他翻了这个心跳信号分类的代码包,他照着跑完第一版提交,分数直接进了前 40。这不是什么玄学,天池这类比赛对零基础其实挺友好,关键是你得有一份结构完整、注释清楚、能复现的基线代码。这份资源就是干这个的——它把数据读取、特征工程、模型训练、结果提交整条链路都串起来了,最终拿到 386 分、33 名的成绩。适合谁?刚接触数据挖掘、想找一个完整分类项目练手的人;或者打过几场比赛但总是卡在特征工程和调参上、想看看别人怎么组织代码的人。你不需要先学完机器学习再动手,反过来,先跑通再回头补理论,效率高得多。
2. 数据长什么样:心跳信号分类的任务拆解与基线思路
2.1 任务定义与数据格式
心跳信号分类本质上是一个多分类问题。每条样本是一段心电图时序信号,采样点固定,目标是判断这段信号属于哪种心拍类型。常见的数据集里,训练集通常包含 id、心跳信号序列(几百个采样点)和 label 三部分,测试集只有 id 和信号序列。信号本身是一维的,但不同类别的波形在幅度、周期、形态上有明显差异,这就是模型能学到东西的基础。
我拿到这份代码包后第一件事是看数据维度。训练集一般几千到上万条,每条信号长度在 200 到 300 之间,类别数 4 类左右,且存在明显的类别不均衡。这意味着你不能只看准确率,得关注 F1 或者 AUC。代码包里用的是 macro F1,这也是天池这类比赛常见的评估指标。
2.2 基线方案为什么选 LightGBM 而不是深度学习
很多人一上来就想用 LSTM 或者 1D-CNN 做时序分类,觉得端到端更高级。但这份代码包选的是 LightGBM,原因很实际:样本量不够大,深度学习容易过拟合;特征工程做得好,树模型在结构化特征上表现非常稳;训练速度快,方便快速迭代。
具体做法是把一维时序转成统计特征。常见的有均值、标准差、最大值、最小值、偏度、峰度、过零率、分位数等。代码包里对每个采样点还做了差分和滑动窗口统计,相当于把时序信息压缩成一张宽表。这个思路不新鲜,但在小样本时序分类里非常管用。
import numpy as np import pandas as pd from scipy import stats def extract_features(signal): """把一维心跳信号转成统计特征""" feats = {} feats['mean'] = np.mean(signal) feats['std'] = np.std(signal) feats['max'] = np.max(signal) feats['min'] = np.min(signal) feats['skew'] = stats.skew(signal) feats['kurtosis'] = stats.kurtosis(signal) # 过零率:信号穿过零点的次数,反映波动频率 feats['zero_crossing'] = np.sum(np.diff(np.sign(signal)) != 0) # 分位数特征,捕捉分布形态 for q in [10, 25, 50, 75, 90]: feats[f'q{q}'] = np.percentile(signal, q) # 一阶差分统计,反映变化剧烈程度 diff = np.diff(signal) feats['diff_mean'] = np.mean(diff) feats['diff_std'] = np.std(diff) return feats这段代码的逻辑很直接:对每条信号提取一组标量特征,最终拼成特征矩阵。参数上,分位数的选择可以根据数据分布调整,过零率对周期性信号特别敏感。注意np.diff(np.sign(signal))这里要处理信号恰好等于 0 的情况,否则会多算。
2.3 交叉验证与模型融合的基线配置
代码包里用了 5 折交叉验证,每折训练一个 LightGBM,最后把 5 个模型的预测概率取平均。这样做的好处是降低单次划分带来的波动,同时能输出概率用于后续融合。LightGBM 的参数没有调得很激进,学习率 0.05,树的数量 500,最大深度 6,叶子数 31。这个配置在几千条样本上不容易过拟合。
import lightgbm as lgb from sklearn.model_selection import StratifiedKFold from sklearn.metrics import f1_score def train_lgb(X, y, X_test, n_folds=5): skf = StratifiedKFold(n_splits=n_folds, shuffle=True, random_state=42) oof_pred = np.zeros((len(X), 4)) # 4 类 test_pred = np.zeros((len(X_test), 4)) for fold, (tr_idx, val_idx) in enumerate(skf.split(X, y)): X_tr, X_val = X.iloc[tr_idx], X.iloc[val_idx] y_tr, y_val = y[tr_idx], y[val_idx] model = lgb.LGBMClassifier( learning_rate=0.05, n_estimators=500, max_depth=6, num_leaves=31, subsample=0.8, colsample_bytree=0.8, random_state=42 ) model.fit(X_tr, y_tr) oof_pred[val_idx] = model.predict_proba(X_val) test_pred += model.predict_proba(X_test) / n_folds # 用 OOF 预测算整体 F1 oof_label = np.argmax(oof_pred, axis=1) print('OOF F1:', f1_score(y, oof_label, average='macro')) return test_pred这里StratifiedKFold保证每折的类别比例一致,避免某折缺类。subsample和colsample_bytree设 0.8 是常见的防过拟合手段。oof_pred存的是袋外预测,用来评估模型真实泛化能力,不要用训练集上的预测来算分,那是自欺欺人。
3. 从原始信号到特征矩阵:完整预处理流水线
3.1 缺失值与异常值的处理策略
心跳信号数据一般不会有缺失值,但可能有异常段。代码包里对每条信号先做了截断处理,把超出均值 3 倍标准差的点用中位数替换。这个操作有争议,但在比赛中很常见,因为个别尖峰可能是采集噪声,不处理会干扰统计特征。
def clean_signal(signal): """对单条信号做异常值替换""" med = np.median(signal) std = np.std(signal) if std == 0: return signal upper = med + 3 * std lower = med - 3 * std signal = np.where(signal > upper, med, signal) signal = np.where(signal < lower, med, signal) return signal注意std == 0的判断,否则会除零。替换成中位数而不是均值,是因为中位数对极端值更稳健。这一步做完后再提取特征,特征分布会干净很多。
3.2 特征筛选:去掉那些“看起来有用”的冗余特征
提取完特征后,特征数可能上百。不是越多越好,冗余特征会拖慢训练、增加过拟合风险。代码包里用了基于 LightGBM 的特征重要性排序,保留前 60% 的特征。也可以先用方差过滤去掉常数特征,再用相关性剔除高度共线的。
from sklearn.feature_selection import VarianceThreshold def select_features(X, y, top_ratio=0.6): # 先去掉方差接近 0 的特征 selector = VarianceThreshold(threshold=0.01) X_var = selector.fit_transform(X) kept_cols = X.columns[selector.get_support()] # 用 LightGBM 重要性排序 model = lgb.LGBMClassifier(n_estimators=200, random_state=42) model.fit(X_var, y) importance = model.feature_importances_ sorted_idx = np.argsort(importance)[::-1] top_n = int(len(sorted_idx) * top_ratio) final_cols = kept_cols[sorted_idx[:top_n]] return X[final_cols], final_colsVarianceThreshold的阈值 0.01 是经验值,特征量纲差异大时可以先标准化再过滤。重要性排序用 200 棵树就够了,不需要和最终模型一致。保留 60% 是个折中,实际可以试 50% 到 80%,看验证集分数变化。
3.3 类别不均衡的处理:加权与重采样怎么选
心跳数据里某些类可能只占 5%,如果不处理,模型会偏向多数类。代码包里用了class_weight='balanced',让 LightGBM 自动按类别频率加权。这个方式比直接过采样安全,因为不会复制样本导致过拟合。
model = lgb.LGBMClassifier( class_weight='balanced', learning_rate=0.05, n_estimators=500, random_state=42 )如果加权后效果仍不好,可以试 SMOTE 过采样,但要注意只能在训练折内做,不能对全量数据做,否则验证集会被污染。我一般先试加权,不行再考虑采样,因为加权改动最小。
4. 避坑与排查:那些让我掉过头发的细节
4.1 现象:本地 F1 很高,提交后分数腰斩
原因:训练时用了全量数据做特征筛选,验证集信息泄露。特征选择、标准化、采样这些步骤必须放在交叉验证的每一折内部,不能提前对全量数据做。
解决:把特征筛选和标准化封装进 Pipeline,或者手动在每折内重新 fit。代码包里特征筛选是在交叉验证之前做的,这其实是个隐患,我后来改成每折内单独筛选,线上分数稳定了很多。
4.2 现象:模型训练报错 “Input contains NaN”
原因:某些信号段全为常数,stats.skew或stats.kurtosis返回 NaN。或者分位数计算时信号长度不足。
解决:在特征提取后加一步fillna(0),同时对常数信号做判断,直接返回全零特征。更稳妥的做法是用np.nan_to_num统一处理。
4.3 现象:LightGBM 训练速度突然变慢
原因:特征数太多,或者num_leaves设得过大。另外,如果特征里有 object 类型,LightGBM 会报错或极慢。
解决:检查X.dtypes,把所有非数值列转成数值。num_leaves不要超过 127,小样本下 31 到 63 足够。还可以开feature_fraction和bagging_fraction加速。
4.4 现象:提交文件格式不对,平台报错
原因:天池提交要求列名和顺序严格匹配,预测概率要保留足够小数位。
解决:用pd.DataFrame构造提交文件时,列名写成['id', 'label_0', 'label_1', 'label_2', 'label_3'],id 列保持整数,概率列用round(6)。保存时index=False,编码用 utf-8。
4.5 现象:交叉验证分数波动大,每次跑结果不一样
原因:LightGBM 本身有随机性,数据划分也有随机性。
解决:固定random_state,并且用StratifiedKFold而不是KFold。如果波动仍然大,增加折数到 10 折,或者跑多次不同随机种子取平均。代码包里固定了 42,但你可以试 2024、3407 这些,看哪个种子在验证集上更稳。
5. 进阶技巧:把 386 分再往上推一推
5.1 概率校准与阈值调整
LightGBM 输出的概率不一定校准得很好,尤其在不均衡数据上。可以用CalibratedClassifierCV做 Platt 缩放或 isotonic 回归。另外,如果评估指标对某些类更敏感,可以手动调整分类阈值,而不是直接取 argmax。
from sklearn.calibration import CalibratedClassifierCV base_model = lgb.LGBMClassifier(n_estimators=500, learning_rate=0.05, random_state=42) calibrated = CalibratedClassifierCV(base_model, method='isotonic', cv=3) calibrated.fit(X_train, y_train) proba = calibrated.predict_proba(X_test)method='isotonic'适合样本量较大的情况,样本少时用'sigmoid'。校准后概率更可靠,但 F1 不一定提升,得看具体数据。
5.2 多模型融合:LightGBM + XGBoost + 逻辑回归
单模型天花板有限,融合能再挤出一两个点。代码包里只用了 LightGBM,我后来加了 XGBoost 和逻辑回归做软投票。注意逻辑回归要先标准化,树模型不需要。
from sklearn.linear_model import LogisticRegression from sklearn.preprocessing import StandardScaler from sklearn.pipeline import make_pipeline import xgboost as xgb # XGBoost xgb_model = xgb.XGBClassifier( n_estimators=500, learning_rate=0.05, max_depth=6, subsample=0.8, colsample_bytree=0.8, random_state=42 ) # 逻辑回归加标准化 lr_model = make_pipeline( StandardScaler(), LogisticRegression(max_iter=1000, class_weight='balanced', random_state=42) ) # 三个模型分别训练后取平均概率 final_proba = (lgb_proba + xgb_proba + lr_proba) / 3融合权重可以按验证集 F1 来调,不一定等权。如果某个模型明显弱,就降低它的权重。注意所有模型必须用同一折的划分,否则 OOF 预测对不齐。
5.3 一个容易被忽略的技巧:信号分段的统计特征
原始信号直接算全局统计会丢失局部信息。我后来把每条信号切成 3 到 5 段,每段单独算均值和标准差,再拼回特征。这个操作让 F1 涨了差不多 0.5 个点。
def segment_features(signal, n_segments=4): """把信号分段后提取局部统计特征""" seg_len = len(signal) // n_segments feats = {} for i in range(n_segments): seg = signal[i*seg_len:(i+1)*seg_len] feats[f'seg{i}_mean'] = np.mean(seg) feats[f'seg{i}_std'] = np.std(seg) feats[f'seg{i}_max'] = np.max(seg) return feats分段数不是越多越好,4 到 6 段比较合适,再多会引入噪声。分段后特征维度增加,记得重新做特征筛选。
5.4 验证方法:别只看一次提交分数
比赛里提交次数有限,不能靠反复提交来调参。正确做法是本地留一个固定的验证集,或者用交叉验证的 OOF 分数作为主要参考。我一般会记录每次实验的 OOF F1、训练时间、特征数,做成表格对比。
| 实验编号 | 特征数 | 模型 | OOF F1 | 提交分数 |
|---|---|---|---|---|
| 1 | 120 | LightGBM | 0.72 | 386 |
| 2 | 80 | LightGBM | 0.74 | 392 |
| 3 | 80 | LGB+XGB | 0.75 | 398 |
| 4 | 95 | LGB+XGB+LR | 0.76 | 401 |
这张表是我自己跑出来的记录,你可以照着建一个。每次改动只动一个变量,否则不知道是哪个起了作用。特征筛选后 OOF 涨了但提交没涨,说明筛选逻辑有泄露;融合后 OOF 涨了提交也涨,说明方向对了。
从那以后我每次做比赛项目,都会先把基线跑通、记录 OOF、再逐步加特征和融合,绝不跳步。这份心跳信号分类的代码包最大的价值不是那个 386 分,而是它给了一个能跑通的起点,你可以在上面改特征、换模型、调参数,每一步都有反馈。希望帮到你。
本文还有配套的精品资源,点击获取