简介:面向研究生数学建模竞赛的2020年C题脑电波分析完整资源包,适合备战电赛、生物医学工程及神经信号处理方向的学生。资源以P300脑机接口数据为核心,覆盖数据预处理去噪、特征提取、线性回归/聚类/主成分分析及机器学习建模等完整流程,提供可直接运行的Python代码、结果可视化图片与数据表格。全套共263个文件,含83个pyc和22个py代码文件、80个png结果图、25个xlsx数据表,以及xml工程配置、txt说明、docx赛题报告等;压缩包118.59MB,目录按模块划分,便于按步骤复现竞赛方案。通过阅读代码与报告,可了解脑电信号分类预测的建模思路,掌握从数据清洗到结果解释的科研方法,对康复工程、疾病监测等应用有参考价值。目前已有46人学习,适合需要系统复盘竞赛题、参考完整实现的学生与指导教师。
1. 这不是一个开箱即用的答案包:2020 年研究生数学建模竞赛 C 题脑电波分析代码与数据到底怎么用
拿到 2020 年研究生数学建模竞赛 C 题(脑电波分析)的代码及数据压缩包,大多数人的第一反应是解压、找 README、跑一遍模型拿个分数。我的建议是反过来:先把包里的数据形态、事件标记和预处理链路读明白,再碰模型。因为这个题目的真实难点不在分类器多先进,而在脑电信号本身——50Hz 工频干扰、眼电肌电伪迹、个体差异和交叉验证泄漏,每一个都能让结果从 90% 掉到 60%。这篇笔记会按最常见的组织方式拆解这个包:数据长什么样、预处理怎么做、特征提哪几类、模型怎么选、坑都在哪,以及如何把整套流程固化下来供评审复现。适合准备竞赛的队伍、做脑电分类开题的学生,以及想用最小成本上手的信号处理从业者。
2. 先读懂数据再谈建模:脑电数据格式、事件标记与预处理链路
一个竞赛脑电包最容易被忽略、也最影响后续所有工作的部分,是数据本身长什么样。脑电数据不像图像那样解压就能看,它至少涉及三个维度:通道数、采样点数、试次数,外加一套事件标记说明。如果你连采样率和通道顺序都没确认就开始提特征,后面所有结果都要推翻重来。
我拿到数据一般分两步走:第一步把文件清单过一遍,第二步写一个极短的探查脚本,打印数据结构、字段名和形状。大多数竞赛数据包逃不出下面三种组织形态,先搞清楚是哪一种,再决定后续代码怎么写。
| 数据形态 | 常见存放格式 | 对建模的影响 |
|---|---|---|
| 原始脑电信号 + 事件标记 | MAT、NPY、EDF,配 CSV/TXT 标记文件 | 需要完整预处理链路,自由度最高,也最容易踩坑 |
| 预处理后的分段数据 | 每个试次一个矩阵,可能附带标签 | 可直接提特征,但要问清楚预处理参数是什么 |
| 特征矩阵 + 标签 | CSV 或 Pickle | 直接建模,但要警惕特征是否已泄漏测试集信息 |
2.1 从压缩包到可用样本:数据组织形态、字段含义和采样率检查
如果压缩包里是原始脑电数据,常见结构是一个三维数组或字典风格的 MAT 文件。先用下面这个脚本把结构看清楚,不要急着跑分类:
import os import numpy as np from scipy.io import loadmat # 1. 先列出压缩包解压后的文件树 for root, _, files in os.walk("."): for f in files: print(os.path.join(root, f)) # 2. 如果是 MAT 格式,查看顶层字段 data = loadmat("data/subject01.mat") print(data.keys()) # 3. 找到数据字段后,打印形状 eeg = data["eeg"] # 常见形状: (n_epochs, n_channels, n_times) 或 (n_channels, n_times) print("eeg shape:", eeg.shape) print("dtype:", eeg.dtype)这段代码的作用是先把数据维度钉死:(试次数, 通道数, 采样点数)和(通道数, 采样点数)是完全不同的组织方式,前者已经分段,后者是连续记录。随后确认三件事:采样率是多少、是否所有被试的采样率一致、标签和事件标记是否对齐。很多队伍栽在「数据是拼接的」这个问题上——不同被试的采样率一个 250Hz 一个 512Hz,直接合并后特征空间是乱的。
如果包内给了事件标记文件,我一般会额外检查每个试次的时间长度是否一致。脑电分段通常是从刺激前 0.2 秒到刺激后 1 秒,共 1.2 秒;在 250Hz 下就是 300 个采样点。若发现试次长度参差不齐,说明数据要么未正确分段,要么中间夹了坏段,需要先用后续的分段逻辑统一窗口。
提示:采样率是脑电数据里最重要的元信息。它决定滤波器边界、Welch 窗长和频谱分辨率,一错全错。建议在探查阶段就把
sfreq写进配置常量并在每一步代码里引用,而不是散落各处写魔法数字。
2.2 脑电预处理链路:50Hz 陷波、带通滤波、ICA 去伪迹与分段参数
确认数据结构后,预处理是所有后续工作的地基。脑电预处理的标准链路是:陷波去工频、带通滤波限定频段、重参考校准空间基线、剔除坏段或坏导联、ICA 去眼电/肌电伪迹、最后按事件分段。下面这段代码以 MNE 库为例,它把上面这些步骤都串起来了:
import mne import numpy as np # 假设 raw_array 是 2D 数组: (n_channels, n_times),sfreq 已确认 ch_names = ["Fz", "Cz", "Pz", "Oz", "F3", "F4", "C3", "C4"] # 以实际数据为准 info = mne.create_info(ch_names=ch_names, sfreq=sfreq, ch_types="eeg") raw = mne.io.RawArray(raw_array, info) # 1) 50Hz 工频陷波:国内电网是 50Hz,用 spectrum_fit 方式保留更多低频成分 raw.notch_filter(freqs=(50,), method="spectrum_fit", notch_widths=1.0, verbose=False) # 2) 带通滤波:保留 0.5-40Hz,覆盖 Delta 到 Gamma 主频带 raw.filter(0.5, 40, method="fir", phase="zero-double", verbose=False) # 3) 平均参考:以全部通道均值作为参考 raw.set_eeg_reference("average", projection=True) # 4) 标记并剔除幅值超过阈值的坏段 raw.drop_bad(reject={"eeg": 150e-6}) # 5) ICA 去伪迹:保留 95% 方差成分,固定随机种子保证可复现 ica = mne.preprocessing.ICA(n_components=0.95, method="infomax", random_state=42, fit_params=dict(extended=True)) ica.fit(raw) # 6) 按事件切分:-0.2s 到 1.0s,用基线做校正 events, event_id = mne.events_from_annotations(raw) epochs = mne.Epochs(raw, events, event_id, tmin=-0.2, tmax=1.0, baseline=(-0.2, 0), preload=True, verbose=False) print(epochs)这里有几个参数值得展开说。notch_widths=1.0表示陷波带宽为 1Hz,太窄滤不干净,太宽会把 Alpha 频段边缘能量误伤。带通滤波的phase="zero-double"是关键中的关键——脑电分析不能用普通 IIR 滤波,那会引入非线性相位偏移,导致事件起点整体漂移几个采样点;零相位滤波虽然计算量翻倍,但保证了波形对齐。drop_bad的 150 微伏阈值对应的是头皮脑电的典型幅值范围,如果数据已经做了 ICA 去眼电,这个阈值可以放松到 200 微伏。
ICA 的n_components=0.95是「保留 95% 方差」而不是固定成分数,这对前额导联多、眼电强的数据更稳健。跑完 ICA 后一定要人工看一眼成分地形图,把呈眼电分布形态的成分加入ica.exclude,再执行ica.apply(epochs)。全自动 ICA 不是不能用,但选错成分会把真实脑电也一起滤掉,属于典型的「黑匣子事故」。预处理做到这里,数据才算真正进入可提特征的状态。
3. 特征提取决定上限:频段功率、熵与 Hjorth 参数的计算和参数选择
预处理之后的原始脑电不适合直接喂给传统分类器,因为维度太高且噪声残量不小。竞赛场景里特征提取的作用是把每个试次压缩成一个紧凑向量,同时保留能区分不同任务状态的神经生理信息。以 32 通道为例,如果只提取 5 个频段功率,特征维度是 160;如果再加上熵类和 Hjorth 参数,能到 300 维左右。这个量级对 SVM 和随机森林都友好,而对深度学习来说,原始脑电直接进网络是另一条路,放在第 4 章讲。
我在做这类题目时的习惯是:先把频段功率做扎实,因为它物理意义明确,答辩时最好解释;再补熵类特征,因为脑电的非线性特征是任务态区分里非常灵敏的指标;Hjorth 参数作为快速交叉验证的辅助。特征不是越多越好,维度上去后样本量不增,反而容易过拟合。
3.1 频段功率特征:用 Welch 功率谱密度提取五频带特征
计算频段功率,最稳的工具是 Welch 周期图法。它先把信号分段加窗,再对各段功率谱取平均,比直接对整个序列做 FFT 平滑得多。下面用scipy.signal.welch实现五频带功率提取:
from scipy import signal import numpy as np BANDS = {"delta": (0.5, 4), "theta": (4, 8), "alpha": (8, 13), "beta": (13, 30), "gamma": (30, 40)} def power_in_band(psd, freqs, band): idx = np.logical_and(freqs >= band[0], freqs < band[1]) return np.sum(psd[idx]) def extract_band_power(epochs_array, sfreq=250.0): """epochs_array: (n_epochs, n_channels, n_times)""" feats = [] for ep in epochs_array: ep_feat = [] for ch_data in ep: freqs, psd = signal.welch(ch_data, fs=sfreq, nperseg=256, noverlap=128, window="hamming") for band_name, band_range in BANDS.items(): ep_feat.append(power_in_band(psd, freqs, band_range)) feats.append(ep_feat) return np.asarray(feats) X_band = extract_band_power(epochs.get_data(), sfreq=sfreq) print("feature matrix:", X_band.shape) # (n_epochs, n_channels * 5)nperseg=256在 250Hz 采样率下对应约 1 秒窗长,频率分辨率约 0.98Hz,刚好能把 Alpha(8-13Hz)边缘分清楚。noverlap=128是 50% 重叠,能让相邻窗之间的频谱估计更连续;hamming窗抑制频谱泄漏,比矩形窗稳定得多。如果你用的是 512Hz 采样率,建议把nperseg提到 512,保证分辨率不降。还有个小细节:average="mean"是默认平均方式,但如果某个试次里有偶发大幅伪迹,换成average="median"能让频谱更抗离群点。
这里要注意绝对功率和相对功率的差别。绝对功率直接反映各频段的能量大小,个体差异大;相对功率是当前频段占全频段总功率的比例,能压掉一部分个体差异。竞赛里如果被试间基线水平差异明显,推荐在现有代码上再加一个总功率归一化,即每个频段除以 0.5-40Hz 的总功率。这个小改动常常比模型换参更有效。
3.2 熵与 Hjorth 参数:样本熵、谱熵和非线性描述的取舍
频段功率只描述了信号的频谱分布,没有刻画信号的不规则性和自相似性。脑电在任务态下往往会展现出复杂度变化,这时熵类特征就派上用场。竞赛场景里我建议至少算三种:样本熵、谱熵和去趋势波动分析,它们从不同侧面描述非线性特征。
import antropy as ant import numpy as np def extract_entropy_features(epoch): """epoch: (n_channels, n_times)""" feats = [] for ch in epoch: ch = ch - np.mean(ch) # 去直流,样本熵对直流偏移敏感 feats.append(ant.sample_entropy(ch)) feats.append(ant.spectral_entropy(ch, sf=sfreq, method="welch")) feats.append(ant.detrended_fluctuation(ch)) return feats # 调用示例(假设 epochs 已存在) X_entropy = np.array([extract_entropy_features(ep) for ep in epochs.get_data()]) print("entropy feature shape:", X_entropy.shape)antropy.sample_entropy内部默认参数是模板长度m=2、容差r=0.2 * standard deviation,这是时间序列领域经验上最好的起点。谱熵则用 Welch 法估算概率分布后再算 Shannon 熵,对频段能量的「均匀程度」敏感。样本熵的特点是计算慢,通道数多时要注意耗时,32 通道数据量下一般可接受。
另一个性价比极高的特征是 Hjorth 三参数:活动度、移动度和复杂度。它们本质上来自一阶和二阶差分序列的方差关系,代码极短,物理意义也直观:
def hjorth_params(seq): first_diff = np.diff(seq) second_diff = np.diff(first_diff) var0 = np.var(seq) var1 = np.var(first_diff) var2 = np.var(second_diff) activity = var0 mobility = np.sqrt(var1 / var0) complexity = np.sqrt(var2 / var1) / mobility return activity, mobility, complexityHjorth 三参数直接拼在频段功率后面作为补充特征,通常能带来 1-3 个百分点的准确率提升,并且几乎不增加调参负担。很多参赛队伍只盯着深度学习,反而把这些传统非线性特征束之高阁,错失了一个「小而美」的加分项。特征选择上我的原则是:先算完,再做相关性筛选或留一列交叉验证,别在一开始就省。
3.3 特征矩阵组装与归一化边界:别让数据泄漏毁掉结果
特征提取完成后,下一步是把所有试次、所有被试的特征拼成一个大矩阵。这里最容易出问题的是标准化——脑电特征的数值范围跨数量级,比如绝对功率可能是1e-10量级,Hjorth 活动度可能是1e-6量级,不归一化会让 SVM 完全失去意义。但归一化的统计量从哪里来,决定了你是否踩中数据泄漏。
from sklearn.preprocessing import StandardScaler from sklearn.model_selection import train_test_split # 假设 X 是拼接后的特征矩阵,y 是标签,subject_ids 是被试编号 # 正确做法:先切分,再 fit 标准化器 X_train, X_test, y_train, y_test, subj_train, subj_test = train_test_split( X, y, subject_ids, test_size=0.2, stratify=y, random_state=1) scaler = StandardScaler().fit(X_train) # 只用训练集统计量 X_train = scaler.transform(X_train) X_test = scaler.transform(X_test) # 测试集只做 transform很多人在这一步写成了先对全部X做fit_transform,再切分训练集和测试集。这么做的结果是测试集信息以均值和标准差的形式混进了训练过程,交叉验证分数虚高 3-5 个百分点不是偶然。评审老师只要看一眼代码,这个问题基本跑不掉。另一个容易被忽略的泄漏源是通道剔除和滤波参数:如果你在全体数据上观察过哪些通道坏、再决定剔除名单,这同样是使用了测试集信息。正确的做法是只用训练集做这些判断,或至少用完全固定的规则。
特征组装完成后,建议把X_train / X_test / y_train / y_test / subj_train / subj_test连同预处理参数一起存成.npz或.pkl。一方面方便后续多次调参不用重新提特征,另一方面也防止你在调模型时不小心重新执行了带泄漏的预处理链路。数据存档这件事,在竞赛时间和心理压力下能省下大量返工成本。
4. 建模与评估:从 RBF-SVM 基线到 EEGNet 的完整流程
特征矩阵准备好之后,建模反而变成流程中相对轻松的一环。竞赛评阅和论文答辩往往更看重「方法合理、结果可解释、实验严谨」这三点,而不是无脑堆模型。所以我的建模策略永远是:先以一个可解释的机器学习模型拿到基线分数,再根据基线决定要不要上更复杂的深度学习模型。大部分脑电竞赛题,RBF-SVM 或者随机森林已经能拿到一个能交差的分数,深度网络是冲刺用的加分项。
4.1 建一个可解释的 RBF-SVM 基线:C、gamma 的搜索范围和组合逻辑
RBF-SVM 是脑电特征分类最经典的选择:特征维度在几百量级、样本量在几百到几千,完全在它的舒适区。核函数把特征映射到高维空间,同时保留决策边界相对稀疏的特性,配合概率输出还能直接做 ROC 分析。网格搜索时要把交叉验证设为按被试分组,否则会过度乐观。
from sklearn.pipeline import Pipeline from sklearn.svm import SVC from sklearn.model_selection import GridSearchCV, GroupKFold pipe = Pipeline([ ("scaler", StandardScaler()), ("svm", SVC(kernel="rbf", probability=True, random_state=0)) ]) param_grid = { "svm__C": [0.1, 1, 10, 100], "svm__gamma": [0.001, 0.01, 0.1, 1], } gkf = GroupKFold(n_splits=5) search = GridSearchCV(pipe, param_grid, cv=gkf, scoring="accuracy", n_jobs=-1, verbose=1) search.fit(X_train, y_train, groups=subj_train) print("best params:", search.best_params_) print("best CV score:", search.best_score_)C控制误分类惩罚强度,gamma控制单个样本的影响半径。以标准化后的特征为前提,C在 0.1-100、gamma在 0.001-1 的对数网格通常够用。如果你发现最优参数在边界上,比如C=100且gamma=1,说明搜索范围不够,需要把网格扩大而不是直接接受。GroupKFold的作用是保证同一个被试的所有试次不会同时出现在训练和验证折内,这能客观反映「跨被试泛化能力」。向评审解释分类器性能时,这个细节远比一个漂亮分数更有说服力。
如果 SVM 调完准确率不到 70%,通常问题不在分类器,而在前面特征质量或预处理链路,这时候回头检查比继续调参更有价值。前期准备做得好的话,SVM 的准确率一般能到 75%-85% 区间,这已经是可以写进论文的基线结果了。
4.2 用 EEGNet 吃原始脑电片段:卷积核、池化与 dropout 的设计逻辑
当传统特征达到瓶颈后,很多同学会考虑深度学习。这里要冷静:竞赛数据集规模通常只有几十个被试、几百个试次,深层网络极容易过拟合。我的建议是使用 EEGNet 这类为脑电设计的紧凑卷积网络,它用深度可分离卷积大幅减少参数量,适合小样本场景。
from tensorflow import keras from tensorflow.keras import layers from tensorflow.keras.callbacks import EarlyStopping def build_eegnet(n_channels, n_times, n_classes): inp = keras.Input((n_channels, n_times, 1)) # 空间卷积:融合所有通道,等价于空间滤波器 x = layers.Conv2D(8, (n_channels, 1), padding="same")(inp) x = layers.DepthwiseConv2D((1, 32), depth_multiplier=2, padding="same")(x) x = layers.BatchNormalization()(x) x = layers.Activation("elu")(x) x = layers.AveragePooling2D((1, 4))(x) x = layers.Dropout(0.25)(x) # 时间卷积:进一步提取时序模式 x = layers.Conv2D(16, (1, 16), padding="same")(x) x = layers.BatchNormalization()(x) x = layers.Activation("elu")(x) x = layers.AveragePooling2D((1, 8))(x) x = layers.Dropout(0.25)(x) x = layers.Flatten()(x) out = layers.Dense(n_classes, activation="softmax")(x) return keras.Model(inp, out) model = build_eegnet(n_channels=32, n_times=300, n_classes=2) model.compile(optimizer="adam", loss="categorical_crossentropy", metrics=["accuracy"])这里的DepthwiseConv2D((1, 32))是关键设计:它对每个通道分别做时间卷积后再用1x1卷积混合通道信息,参数量远小于普通 2D 卷积,天然具备脑电信号处理里「空间滤波+时间滤波」的流程结构。AveragePooling2D((1, 4))和(1, 8)逐步压缩时间维度,相当于对特征图做降采样。Dropout(0.25)和BatchNormalization是防过拟合的标配组合。
使用 EEGNet 前,数据要重新组织成(n_epochs, n_channels, n_times, 1)的形状,标签做 one-hot 编码。训练时一定开早停,别让模型在验证集上过拟合之后还继续跑。一个小窍门是把卷积核数量从 8 和 16 开始,如果训练过程准确率振荡明显,就先减半;如果不收敛,先加一倍再看。脑电竞赛里,稳妥的小网络永远优于花哨的大网络。
4.3 评估协议怎么定:按被试的 GroupKFold 与混淆矩阵解读
模型训练完,评估环节直接决定结果能不能站住脚。竞赛论文里经常出现「交叉验证准确率 92%」这种局部最优,但换上严格的分组评估后只剩 70%。我强烈建议把「按被试分组」的交叉验证作为默认评估协议,并在论文里明确写出验证策略。
from sklearn.metrics import classification_report, confusion_matrix y_pred = search.predict(X_test) print(classification_report(y_test, y_pred)) # 混淆矩阵 cm = confusion_matrix(y_test, y_pred) print(cm)混淆矩阵在脑电二分类里有特殊意义:如果假阳性集中在某一类试次,往往不是模型问题,而是某一类任务状态的脑电解剖分布更接近另一类。举个例子,情绪效价分类里,中性刺激诱发的脑电本来就介于正负情绪之间,混淆在边界上是生理现象而非bug。答辩时主动把这个现象讲清楚,比藏着等评审问要加分。
评估指标方面,二分类报告里的 F1-score 和 ROC-AUC 都要看。如果类别不平衡明显,准确率会虚高,F1 和 AUC 才反映真实区分能力。另外,如果做了 EEGNet,它和 SVM 之间的结果对比最好用同一套GroupKFold划分来跑,保证可比性。到这里,整套「数据→预处理→特征→模型→评估」的主链路已经完整,接下来要聊的是这条路上最容易让人重跑三天的各种坑。
5. 脑电分析避坑实录:5 个让我重跑三天的坑及排查方法
脑电分析项目的诡异之处在于,每一个坑单拎出来都像「玄学」:数据没变、代码没变,换台机器结果就变了;滤波前一个分数,滤波后又一个分数,你也说不清是谁的问题。下面这 5 个坑是按坐在电脑前的时间损耗排序的,每一条都是真实发生过、且重现率极高的问题。写在这里,给后来者一颗后悔药。
5.1 滤波相位失真让事件对齐偏移
现象:分段后的事件起始点明明在标记位置,提取特征却发现时间窗整体偏移了几十毫秒,两个类别的差异消失了。
原因:用了scipy.signal.lfilter或默认的 IIR 滤波器做带通滤波。此类滤波器是非线性相位的,会让不同频率成分产生不同延迟,事件起点在滤波后发生相对位移。脑电分类对时间对齐非常敏感,尤其是 ERP 成分。
解决:切换为零相位滤波。MNE 的filter默认phase="zero"就是零相位;用scipy时改成sosfiltfilt或filtfilt。特别注意,filtfilt会让边缘效应变强,可以先给信号多取一段边缘 padding 再裁掉。换完之后重新提取特征,多数情况下分类准确率会回升几个点。
5.2 归一化泄漏带来虚高准确率
现象:训练集准确率 90%,测试集准确率也能到 90%,但换成完全独立的新被试数据后,掉到 60%。评审问一句「你把测试集结果放进训练了吗」,一时语塞。
原因:最常见的是在切分数据之前,用全部数据计算了均值和标准差做标准化,或者用全部数据做了 PCA 降维。这些统计量携带了测试集的分布信息,模型相当于「开卷考试」。
解决:严格保证所有统计量只在训练集上计算。用sklearn.pipeline.Pipeline组装标准化器和分类器,再放到GridSearchCV里,能自动避免手工fit_transform带来的泄漏问题。自查的方式很简单:在测试集上手动遮住标签跑一遍推理,看看会不会报错——如果心里没底,就检查代码里fit的输入是不是X_train。
5.3 相邻 epoch 的高度相关让交叉验证失真
现象:随机 5 折交叉验证准确率 88%,换成按被试分组后只剩 72%。同一套代码和特征,分数忽高忽低。
原因:脑电分段后的相邻试次,时间上只隔几百毫秒,信号高度相关。随机切分会把同一个被试的相邻片段同时分到训练集和测试集,信息泄漏极其严重。这是脑电竞赛里最隐蔽、最容易翻车的坑。
解决:评估时始终用GroupKFold按被试分组,或者直接把每个被试的数据按时间段前 70% 训练、后 30% 测试。分组单位是被试,而非单个试次。如果被试数量太少,比如只有 8 个人,可以考虑留下一个被试做外部验证,其余被试做内部调参。
5.4 采样率不统一导致合并数据翻车
现象:合并多个被试的数据时报数组维度错误,或者模型训练不收敛。用眼睛看波形,总觉得有些试次短一截。
原因:数据采集设备或导出设置不一致,导致一半被试是 250Hz、另一半是 512Hz。这种情况在很多拼盘数据里非常常见,你自己私下采集的数据和官方数据拼接时尤其容易发生。
解决:在数据探查阶段统一检查所有被试的采样率信息。发现不一致后,用scipy.signal.resample_poly或 MNE 的resample统一到较低采样率,比如全部降到 250Hz。重采样之后再跑预处理,否则滤波器参数和窗长都会错乱。顺手把这个检查写进数据处理脚本的开头,每次跑数据前先断言一遍。
5.5 ICA 不收敛或出现 NaN 的数据质量问题
现象:ICA 跑完之后,成分图里全是 NaN,或者某个通道数据变成全零;强行做下去,分类器直接报错。
原因:原始数据里存在长时间静默段、直流漂移或电极脱落导致的超大异常值,使得协方差矩阵奇异或不稳定。ICA 的n_components设得过高,也会在有限样本下把噪声也拟合成独立成分。
解决:ICA 之前先做带通滤波(已经做了就再检查一遍),用np.isfinite检查数据里是否有非有限值,删除静默段或超大伪迹段后再拟合。n_components从 0.9 而不是 0.95 开始试,并固定random_state,这样每次跑结果一致,方便排错。如果某个通道坏得彻底,趁早剔除该通道,再用周围导联插值,而不是硬扛着跑。
| 现象 | 潜在风险 | 快速自查方式 |
|---|---|---|
| 滤波后事件对齐偏移 | 时间窗特征失真 | 对比滤波前后波形峰值位置 |
| 准确率虚高到 98% 以上 | 特征或标准化泄漏 | 检查标准化器是在切分前还是切分后 fit |
| 交叉验证分数波动大 | 相邻试次相关性 | 改用 GroupKFold 按被试分组 |
| 合并数据维度报错 | 采样率不一致 | 逐一打印各被试 sfreq |
| ICA 成分含 NaN | 原始数据不干净 | 先用 isfinite 检查并清理坏段 |
这张表可以打印出来贴在工位上,每次跑完实验逐项过一遍,能在提交前拦住绝大部分低水平错误。
6. 把整套流程变成可审计资产:一个能自动重跑与留痕的小封装
竞赛结束后回头看,最值钱的往往不是最终模型,而是整套流程的可复现性。某个版本用了哪个滤波频带、哪组特征、哪个随机种子,如果靠脑子记,十天之后绝对记不清。我的做法是用一个极简的配置封装把每一步参数和中间产物固化下来,让任何一次结果都能被精确重放。
import json, hashlib, numpy as np CONFIG = { "sfreq": 250, "filter": [0.5, 40], "notch": 50, "features": ["band_power", "entropy", "hjorth"], "model": "rbf_svm", "svm_C": 1, "svm_gamma": 0.1, "cv": "groupkfold_5", "random_state": 42, } def save_checkpoint(data, path): digest = hashlib.sha256(data.tobytes()).hexdigest()[:16] np.save(path, data) return digest # 每次跑完实验,把配置和结果摘要存下来 result_summary = { "config": CONFIG, "acc": float(best_acc), "f1": float(best_f1), "data_digest": data_digest, } with open("run_result.json", "w") as f: json.dump(result_summary, f, indent=2)这个封装的核心不是代码多高级,而是强制每次实验留痕。CONFIG里记录所有关键参数,save_checkpoint给数据打哈希指纹,结果摘要写进 JSON。下次你想复现某一次 85% 准确率的结果,只需要找到对应的 JSON 和特征矩阵,按配置重跑一遍即可。如果评审想验证结果,你也能比多数队伍更快响应。
我现在拿到任何脑电类竞赛包,第一步都不会立刻跑通代码,而是先把数据探查脚本和这份留痕框架写好。前期花掉的半小时,往往会在地狱式调参那几天加倍省回来。希望帮你避开我当年走过的弯路,比赛顺利。
本文还有配套的精品资源,点击获取