☰
DEAP脑电情感识别:CNN-LSTM数据切分与LOSO评估避坑
2026/11/2 5:16:28 网站建设 项目流程

简介:这套代码面向希望用深度学习完成DEAP脑电情感识别的初学者与项目实践者,围绕脑电信号预处理、特征提取、时空特征建模与分类评估展开,帮助读者理解CNN与LSTM如何协同处理连续脑电序列。压缩包共35个文件、约11.86MB,包含6个Jupyter Notebook记录特征工程及LSTM、CNN1D结合LSTM/GRU/SimpleRNN等实验,3个Python脚本承担训练与测试流程,另有PNG可视化图、XML项目配置、npy与h5模型权重等,便于按模块查阅和复现。已有2522人学习下载,说明其在中低门槛脑电深度学习实践中具有一定参考价值。读者可据此搭建从带通滤波、特征整理到模型训练、混淆矩阵与损失曲线分析的基本流程,并对比不同循环网络结构的实验设置,快速形成可修改、可继续调参的代码基线。

1. 一段 60 秒脑电信号能读出什么:DEAP + CNN-LSTM 想解决的问题

假设你手上有一份公开的情感脑电数据集,里面每个被试看了 40 段一分钟的音乐视频,头皮上 32 个电极把这 60 秒的电位变化全录了下来,看完之后被试给这段体验打两个分:愉悦度(valence)和唤醒度(arousal),都是 1 到 9。现在问题来了——能不能只凭那 8064 个采样点的波形,把「高愉悦 / 低愉悦」判出来?这份 CNN-LSTM 代码干的就是这件事:CNN 负责在前端把局部时间窗里的节律形态抠出来,LSTM 负责在降采样后的序列上记住更长跨度的变化,最后一层 sigmoid 给出二分类概率。它适合两类人:一类是刚拿到 DEAP 数据集、被40 × 40 × 8064这个形状卡住不知道从哪下手的同学;另一类是把模型跑出 99% 训练准确率、换个人测就掉到 55% 想搞清楚为什么的老手。深度学习做脑电情感识别这条路,坑基本都集中在数据侧而不是模型侧,这篇就按我拆这份代码的顺序走一遍。

2. 数据准备:把 s01.dat 拆成能喂进模型的张量

2.1 目录骨架与依赖版本

这份代码的工程结构不复杂,但目录一定要先理顺,不然后面路径能把你折腾半小时。我习惯按下面这样铺:

deap_cnn_lstm/ ├── data_preprocessed_python/ # 官方预处理版,s01.dat ~ s32.dat ├── src/ │ ├── loader.py # 读 .dat、切片、标准化 │ ├── model.py # CNN-LSTM 结构定义 │ ├── train_intra.py # 被试内 10 折 │ └── train_loso.py # 留一被试交叉验证 ├── outputs/ # 权重、混淆矩阵、日志 └── requirements.txt

依赖就三件套,别装太杂:TensorFlow 2.x(自带 Keras)、numpy、scikit-learn。requirements.txt里我会钉住大版本:

tensorflow==2.13.0 numpy==1.24.3 scipy==1.10.1 scikit-learn==1.3.0 matplotlib==3.7.2

TensorFlow 2.13 之后 Keras 3 的 API 变动不小,layers.LSTM的参数名基本没动,但保存格式和model.fit的部分回调行为有差异。如果你照着某篇老文章写from keras.layers import LSTM,在新版本里会报一堆兼容错误,统一走tensorflow.keras这条路最稳。numpy 钉在 1.24 附近是为了避开 2.x 那波和 TensorFlow 编译 wheel 的 ABI 冲突,这个坑我后面避坑章会再提一句。

数据侧还有一个前提:DEAP 的预处理版下载下来是两个压缩包,data_preprocessed_python.zip里是 32 个.dat文件,每个对应一个被试。解压后直接放进data_preprocessed_python/,代码里按s01.dat到s32.dat循环读取就行。

2.2 读 .dat:latin1 编码与 40×32×8064 的形状

第一次读这个文件的人,十个里有八个会撞上UnicodeDecodeError。原因很朴素——这些.dat是早年用 Python 2 的pickle序列化的,里面的字符串是str而不是bytes,Python 3 默认按 UTF-8 解码就会炸。指定encoding='latin1'是标准解法。

import pickle import numpy as np DATA_DIR = 'data_preprocessed_python' FS = 128 # 预处理版已经下采样到 128Hz BASELINE_SEC = 3 # 每个 trial 前 3 秒是静息基线 SEG_SEC = 4 # 我们自己切片的窗口长度 def load_subject(path): with open(path, 'rb') as f: # 关键:Python2 时代的 pickle,必须显式指定 latin1 obj = pickle.load(f, encoding='latin1') # data: (40, 40, 8064) -> 只保留前 32 个 EEG 通道 eeg = obj['data'][:, :32, :].astype(np.float32) # labels: (40, 4) -> valence, arousal, dominance, liking labels = obj['labels'].astype(np.float32) return eeg, labels

逐段说一下这里在干什么。obj['data']的形状是(40, 40, 8064),第一维是 40 个 trial,第二维是 40 个通道,第三维是 60 秒乘以 128Hz 得到的 8064 个采样点。40 个通道里前 32 个是按 10-20 系统排的 EEG 电极,后面 8 个是眼电、肌电、皮电、呼吸这类外周信号。做情感分类的主流做法是只留前 32 个,外周信号在特定任务里有价值,但混进卷积里会污染时间轴的局部形态。

labels是(40, 4),四列分别是 valence、arousal、dominance、liking 的主观打分,1 到 9。这份代码只用了前两列。如果你只做二分类,那dominance和liking先留着不用动,后面改多任务时还能捡回来。

还有一个参数要留意:预处理版是 128Hz。如果你拿到的是原始 512Hz 版本,data的形状会不一样,FS和后面所有的时间窗长度都得跟着改,别硬套。

2.3 去基线、切片、标签二值化

40 个 trial 直接整段喂给网络是不现实的——8064 个时间步的 LSTM,光显存和时间就够你喝一壶,而且一整段里包含的情绪状态未必稳定。常见做法是滑窗切段,每段独立成样本。另外每个 trial 开头那 3 秒是纯静息基线,用来做基线校正,不能当成情绪段算进去。

def segment(eeg, labels, fs=FS, seg_sec=SEG_SEC, baseline_sec=BASELINE_SEC, thresh=5.0): seg_len = fs * seg_sec X, y_v, y_a = [], [], [] for t in range(eeg.shape[0]): trial = eeg[t] # (32, 8064) base = trial[:, :fs * baseline_sec].mean(axis=1, keepdims=True) trial = trial - base # 减掉静息基线 sig = trial[:, fs * baseline_sec:] # (32, 7680) for s in range(0, sig.shape[1] - seg_len + 1, seg_len // 2): seg = sig[:, s:s + seg_len].T # (512, 32) 时间在前 X.append(seg) y_v.append(1 if labels[t, 0] >= thresh else 0) y_a.append(1 if labels[t, 1] >= thresh else 0) return np.stack(X), np.array(y_v), np.array(y_a)

三个参数值得单独说。seg_sec=4配 128Hz 得到 512 个时间步,这是速度和信息量之间的一个折中,太短(小于 1 秒)抓不到 alpha 节律的一个完整周期,太长又会让 LSTM 吃力。步长用seg_len // 2,也就是 50% 重叠,一段 60 秒的 trial 能切出 29 个样本,40 个 trial 就是 1160 个样本,够训练了。

.T这一步是把(通道, 时间)转成(时间, 通道)。这一步转反了,后面 Conv1D 会把 32 当成时间步、512 当成通道特征,模型照样能跑,只是学出来的东西完全没意义——这是后面避坑章第一条。

标签阈值thresh=5.0是 DEAP 里最常见的切法,1 到 9 的中点是 5。但这会带来一个隐患:如果某个被试打分整体偏高,样本就会严重偏斜。所以我在segment之后还会打印一下正负样本比例,比值超过 1:2 就换成按被试自己的中位数切。

2.4 按被试做 z-score,别在切分前标准化

标准化这一步,写错了整个实验结论都是假的。脑电的绝对幅值在不同被试之间差好几倍,电极阻抗、头皮厚度、个体差异都会影响,所以必须做 z-score。但关键是:在哪个维度上做,以及在切分之前做还是之后做。

def zscore_per_subject(X): # X: (n_seg, 512, 32) # 每个通道在自己这 512 个点上归一化,不跨被试、不跨样本 mu = X.mean(axis=1, keepdims=True) sd = X.std(axis=1, keepdims=True) + 1e-8 return (X - mu) / sd

axis=1是时间轴,keepdims=True保证广播时形状对得上。这样每个样本的每个通道都会变成均值 0、标准差 1,个体之间的幅值差异就被抹平了。

但这里有个更隐蔽的问题:如果你先在全体样本上算均值和方差、再划分训练测试集,测试集的信息就已经漏进训练过程了,这叫数据泄漏。被试内实验里影响不大,因为同一个人、同一批样本,但被试间实验里能让你凭空多出好几个百分点,最后当成真实提升写进结论,就是典型自欺欺人。稳妥的顺序永远是:切分 → 在训练集上算统计量 → 应用到验证集。

数据加载这块就到这,(n, 512, 32)的张量出来以后,剩下的交给模型。

3. 模型拆解:CNN 前端接 LSTM 的完整前向链路

3.1 输入张量约定:(batch, 512, 32) 到底谁在前

Keras 的 Conv1D 和 LSTM 对输入形状的约定是一致的:(batch, timesteps, features)。所以我们的输入就是(None, 512, 32),512 是时间步,32 是通道(在这里被当成特征维)。这个约定和 PyTorch 的(batch, channels, length)正好相反,从 PyTorch 转过来的人最容易在这里翻车。

为什么这么设计?Conv1D 的卷积核是在时间轴上滑动的,滑到每一个位置时,它同时看到 32 个通道的瞬时值,做加权求和。这意味着卷积核学到的是一段局部时间窗内「32 个电极怎么协同变化」的模式。alpha 节律在枕区出现时,O1、O2、Pz 几个通道会同步起伏,这种跨通道的共变关系正是卷积能捕捉到的。

另一个选择是先把 32 通道排成一张 9×9 的拓扑图,用 Conv2D 处理,这也是 DEAP 上常见的做法。两种路子各有说法,2D 保留了电极的空间邻接关系,1D 更轻、参数更少,跟 LSTM 对接也更自然。这份代码走 1D 路线。

3.2 CNN 前端:Conv1D 沿时间轴滑动,卷积核跨通道

前端两层卷积,每层后面接 BN 和池化。堆叠方式如下:

from tensorflow.keras import layers, models, regularizers def build_cnn_front(timesteps=512, channels=32): inp = layers.Input(shape=(timesteps, channels), name='eeg_input') # 第一层:宽卷积核,抓 1 秒左右的粗节律形态 x = layers.Conv1D(filters=64, kernel_size=16, padding='same', kernel_regularizer=regularizers.l2(1e-4))(inp) x = layers.BatchNormalization()(x) x = layers.Activation('relu')(x) x = layers.MaxPooling1D(pool_size=2)(x) # 512 -> 256 # 第二层:窄卷积核,抓更精细的波形细节 x = layers.Conv1D(filters=128, kernel_size=8, padding='same', kernel_regularizer=regularizers.l2(1e-4))(x) x = layers.BatchNormalization()(x) x = layers.Activation('relu')(x) x = layers.MaxPooling1D(pool_size=2)(x) # 256 -> 128 x = layers.Dropout(0.3)(x) return inp, x

第一层kernel_size=16配 128Hz,感受野大约 125 毫秒,覆盖不到一个完整的 alpha 周期,但足够捕捉波形上升沿和下降沿的斜率特征。第二层kernel_size=8,感受野叠加后到了 300 毫秒以上,开始能覆盖 theta 到 alpha 的节律片段。

padding='same'是为了让输出长度不变,池化层才是唯一改变时间分辨率的地方,这样形状推算起来干净。BatchNormalization放在卷积和激活之间是 Keras 的常见写法,在脑电这种幅值波动大的数据上 BN 作用很明显,没有它学习率稍大一点就发散了。L2 正则加在卷积核上,系数 1e-4 是个保守值,被试内实验里可以关掉,被试间实验里建议留着。

两层池化之后,时间维从 512 降到 128,序列长度砍到四分之一。这一步非常关键——直接决定了后面 LSTM 能不能在可接受的时间内跑完。

3.3 LSTM 后端:双向结构 + 池化后的序列长度

def build_full_model(timesteps=512, channels=32): inp, x = build_cnn_front(timesteps, channels) # 双向 LSTM:正向看历史,反向看未来,脑电这种非因果信号两头都有用 x = layers.Bidirectional( layers.LSTM(64, return_sequences=False, dropout=0.2, recurrent_dropout=0.0))(x) x = layers.Dense(64, activation='relu')(x) x = layers.Dropout(0.5)(x) out = layers.Dense(1, activation='sigmoid', name='valence')(x) model = models.Model(inp, out) model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='binary_crossentropy', metrics=['accuracy', tf.keras.metrics.AUC(name='auc')], ) return model

return_sequences=False表示只取最后一个时间步的隐状态,因为我们要的是整段信号的单一标签,不是逐点输出。如果要改成逐秒的情绪追踪,改成True再套一层时间分布的全连接即可。

双向的选择是有讲究的。脑电离线分析没有实时性约束,一段 4 秒的信号里,「未来」的信息同样有效,双向能让每个位置的表示同时包含前文和后文。反向 LSTM 在实时场景下用不了,所以如果你后面要做在线推理,得换回单向并把merge_mode调一下。

recurrent_dropout=0.0是刻意留的。Keras 里 recurrent dropout 在 CPU 上实现效率很差,开了以后训练速度能掉一半以上,收益又不明显,我一般直接关掉,把正则压力交给前面的卷积 Dropout 和最后的全连接 Dropout。

Dropout(0.5)在最后那层全连接前是重头戏。DEAP 单被试的样本量撑死一两千,模型参数几十万,不加这个基本就是死记硬背。

3.4 编译与训练循环的关键参数

模型结构定了,训练侧还有几个参数需要定下来:

callbacks = [ tf.keras.callbacks.EarlyStopping( monitor='val_auc', mode='max', patience=12, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau( monitor='val_loss', factor=0.5, patience=5, min_lr=1e-6), ] history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=120, batch_size=32, class_weight={0: 1.0, 1: w_pos}, # w_pos 由训练集正负比算出 shuffle=True, callbacks=callbacks, verbose=2, )

monitor用val_auc而不是val_accuracy。二分类里如果正负样本稍微偏一点,accuracy 很容易被多数类撑起来,AUC 对类别不平衡更稳健。patience=12是我在 DEAP 上试出来的经验值,太小了模型还没收敛就停了,太大了纯浪费时间。

class_weight配合前面提到的标签偏斜。w_pos = n_neg / n_pos,只在训练集上算。如果正负比接近 1:1,这一项可以省掉,写死成None也行。

batch_size=32是显存和梯度噪声的折中。512 个时间步 × 32 通道的输入,单样本也就 6 万多个浮点数,32 一批在 8G 显存的卡上很宽松,可以往上调到 64 试试。

4. 训练与评估:被试内和被试间是两套完全不同的跑法

4.1 被试内 10 折:训练脚本怎么组织

被试内实验,顾名思义,训练集和测试集来自同一个人。做法是把单个被试切出来的所有样本打乱,做 10 折交叉验证。

from sklearn.model_selection import StratifiedKFold from sklearn.metrics import roc_auc_score, confusion_matrix def run_intra_subject(X, y, n_splits=10, seed=42): skf = StratifiedKFold(n_splits=n_splits, shuffle=True, random_state=seed) aucs, accs = [], [] for fold, (tr, va) in enumerate(skf.split(X, y)): model = build_full_model(timesteps=X.shape[1], channels=X.shape[2]) model.fit(X[tr], y[tr], validation_data=(X[va], y[va]), epochs=120, batch_size=32, verbose=0, callbacks=callbacks) prob = model.predict(X[va], verbose=0).ravel() aucs.append(roc_auc_score(y[va], prob)) accs.append(((prob > 0.5).astype(int) == y[va]).mean()) tf.keras.backend.clear_session() # 每折清图,防显存泄漏 return np.mean(aucs), np.std(aucs), np.mean(accs)

StratifiedKFold而不是KFold,因为标签可能偏斜,分层能保证每折的正负比例跟整体一致。shuffle=True加上固定random_state,是为了结果可复现——不固定种子跑三次差 3 个百分点,你会怀疑人生。

clear_session()这一行千万别省。Keras 每建一个新模型都会往默认图里加节点,10 折跑下来显存会一路涨到 OOM,报错信息还特别含糊。加上这一行每次折结束把图清干净。

被试内的结果通常很好看,AUC 能到 0.95 以上,有些被试甚至接近 1.0。这时候别急着高兴,往下一节看。

4.2 被试独立:留一被试交叉验证

被试间是真正有难度的设定:用 31 个人的数据训练,预测第 32 个人。这是留一被试交叉验证(LOSO)的标准流程。

def run_loso(subjects, labels_list): # subjects: list of (n_seg_i, 512, 32) # labels_list: list of (n_seg_i,) all_auc = [] for i in range(len(subjects)): X_tr = np.concatenate([subjects[j] for j in range(len(subjects)) if j != i]) y_tr = np.concatenate([labels_list[j] for j in range(len(labels_list)) if j != i]) X_te, y_te = subjects[i], labels_list[i] # 标准化统计量只能来自训练集 mu, sd = X_tr.mean(axis=(0, 1), keepdims=True), X_tr.std(axis=(0, 1), keepdims=True) + 1e-8 X_tr, X_te = (X_tr - mu) / sd, (X_te - mu) / sd model = build_full_model() model.fit(X_tr, y_tr, epochs=120, batch_size=64, verbose=0, validation_split=0.1, callbacks=callbacks) prob = model.predict(X_te, verbose=0).ravel() all_auc.append(roc_auc_score(y_te, prob)) tf.keras.backend.clear_session() return np.mean(all_auc), np.std(all_auc)

这里有两处容易写错。第一处是标准化,mu和sd从X_tr算,然后同时应用到训练集和测试集,顺序不能反。第二处是validation_split=0.1,这是从训练集里再切 10% 出来做早停监控,跟测试被试完全隔离,不会污染评估。

被试间的 AUC 一般落在 0.55 到 0.65 之间。这个数字看着寒酸,但在 DEAP 上属于正常水平,能稳定超过 0.6 已经比大多数基线强了。如果你的被试间结果报到 0.9 以上,八成是标准化泄漏或者被试划分写错了。

4.3 指标与混淆矩阵:别只看 accuracy

单看 accuracy 会掩盖很多问题。二分类至少要看三个东西:AUC、混淆矩阵、以及两个类别的召回率。

cm = confusion_matrix(y_te, (prob > 0.5).astype(int)) tn, fp, fn, tp = cm.ravel() print(f'召回率-低:{tn/(tn+fp):.3f} 召回率-高:{tp/(tp+fn):.3f}') print(f'精确率-高:{tp/(tp+fp+1e-8):.3f} AUC:{roc_auc_score(y_te, prob):.3f}')

如果低愉悦类的召回率 0.85、高愉悦类只有 0.45,说明模型基本在赌多数类,这时候调阈值或者加class_weight比调模型结构有用得多。

阈值本身也值得扫一遍。0.5 只是默认值,在验证集上按 Youden 指数(tpr - fpr最大)找最优阈值,通常能再挤出 2 到 3 个百分点。这个最优阈值要在验证集上定,然后固定应用到测试集,别在测试集上调。

5. 避坑与排查:DEAP 上跑 CNN-LSTM 的五个高频翻车点

5.1 报 UnicodeDecodeError,pickle 读不进来

现象:pickle.load(f)直接抛UnicodeDecodeError: 'utf-8' codec can't decode byte 0x...。

原因:.dat文件是 Python 2 序列化的,里面的字符串对象在 Python 3 里默认按 UTF-8 解码。

解决:pickle.load(f, encoding='latin1')。latin1 是单字节映射,任何字节序列都能解出来。如果换成encoding='bytes',字典的 key 会变成 bytes 类型,后面obj['data']就会 KeyError,还得再转一次,多此一举。

5.2 训练 loss 一路降,验证 accuracy 卡在 50%

现象:训练集准确率很快冲到 0.95 以上,验证集死活上不去,损失还在震荡。

原因:九成是.T用错了,输入形状变成了(32, 512)却按(512, 32)喂进去。Conv1D 会把 512 当成通道特征,卷积核在 32 个时间步上滑,完全失去了时间建模能力。模型还是能收敛,因为它能靠通道间的静态差异做分类,但那和脑电节律没关系。

解决:在segment返回前打印一次X[0].shape,确认是(512, 32)。再补一句assert X.shape[1] == seg_len。

5.3 被试内 99%,被试间 52%

现象:同一个被试内部交叉验证几乎满分,换成留一被试直接掉到随机水平。

原因:模型学到的是个体特征而不是情绪特征。每个人的电极阻抗、头形、基础节律都不一样,模型把「这是谁」当成了判据。被试内实验里训练测试同源,这个捷径永远有效。

解决:三个方向。一是加严正则,把 LSTM 单元从 64 降到 32,全连接层砍一半;二是每个被试单独做 z-score(2.4 节那个函数要真的按被试循环调用,别在拼接后统一做);三是上域适应,最简单的做法是在训练时对被试做留一,让模型见过足够多的人。

5.4 LSTM 层训练慢到跑不完

现象:一个 epoch 要几分钟,10 折跑一晚上还没完。

原因:时间步太长。如果跳过切片直接把 8064 点喂给 LSTM,序列长度是 8064,每个时间步都要做一次矩阵乘,复杂度是线性叠加的,GPU 并行度还上不去。

解决:切片到 512 步,再加上 CNN 前端的两层池化降到 128 步,速度能提升一个数量级。另外把recurrent_dropout设为 0,unroll=False(默认值)保持,别开unroll=True,那会把图展开成一长串,编译时间爆炸。

5.5 每折显存涨一点,跑到第七折 OOM

现象:前几折正常,跑到中途报Resource exhausted: OOM when allocating tensor。

原因:Keras 默认图不清空,每建一个新模型都会往同一张图里堆节点,显存只增不减。

解决:每折结束时tf.keras.backend.clear_session(),并且把模型对象显式del掉。如果还不行,用tf.config.experimental.set_memory_growth(gpu, True)打开显存按需增长。

6. 二次开发:从二分类到四象限,改三处就能跑通

拿到这份代码的人,八成不会满足于只判一个 valence。最常见的改造是把二分类扩成四象限:valence 和 arousal 各切一刀,得到高愉悦高唤醒、高愉悦低唤醒、低愉悦高唤醒、低愉悦低唤醒四类。

改动集中在三处。第一处是标签生成,segment里把两个布尔值编码成 0 到 3;第二处是输出层,Dense(1, sigmoid)换成Dense(4, softmax),损失换sparse_categorical_crossentropy;第三处是评估,roc_auc_score要加multi_class='ovr'。

# 1) 标签:四象限编码 y4 = (y_v * 2 + y_a).astype(np.int32) # 0/1/2/3 # 2) 输出层 out = layers.Dense(4, activation='softmax', name='quadrant')(x) model.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss='sparse_categorical_crossentropy', metrics=['accuracy'])

要注意四分类的样本量问题。原来二分类每个被试有一千多样本,分到四类每类就剩两三百,被试间实验里这个量级基本学不动。我的做法是保留二分类主干,改成两个 sigmoid 输出做多任务,共享 CNN-LSTM 前端,两个头分别算 valence 和 arousal 的 BCE,损失加权求和。这样每个头的监督信号还是全量样本,不用被切碎。

第二个值得做的改造是加注意力池化。LSTM 输出 128 个时间步的隐状态后,用一个可学习的小网络给每步打分再加权平均,比直接取最后一步更稳,因为情绪相关的片段未必在末尾。

h = layers.Bidirectional(layers.LSTM(64, return_sequences=True))(x) # 注意力池化:让模型自己决定哪几段更重要 score = layers.Dense(1, activation='tanh')(h) # (B, T, 1) score = layers.Softmax(axis=1)(score) att = layers.Multiply()([h, score]) att = layers.Lambda(lambda t: tf.reduce_sum(t, axis=1))(att)

第三个方向是频带特征双分支。原始波形走一路 CNN-LSTM,另一路用 scipy 算 theta、alpha、beta、gamma 四个频带的功率谱密度,接个浅层全连接,两路 concat 之后进分类头。脑电情感识别里频带功率是很强的先验,纯时域模型经常抓不稳,加这一路通常能涨 3 到 5 个百分点。

改造之后怎么验证?我的规矩是固定三样东西:数据切分种子、模型初始化种子、样本顺序。每次改完跑一遍被试内 10 折、一遍留一被试,两组数字都记下来。被试内涨了但被试间没动,多半是过拟合加深;两边一起涨才说明改对了。这个习惯是被坑出来的——早年我改了个结构,被试内从 0.96 涨到 0.99,高兴了两天,回头补跑被试间发现从 0.61 掉到 0.55,白忙一场。从那以后我每次动模型结构,都强制先跑被试间那一套,被试内只当回归测试用,别再被漂亮数字骗一次。希望帮到你。

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

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

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

立即咨询