简介:这份资源面向具备一定深度学习基础、希望动手实践脑电情感识别的学生与研究者,围绕DEAP数据集给出CNN与LSTM结合的完整代码实现,可用于生物信号处理、人机交互或心理健康相关的课程设计、毕设与实验复现。压缩包共35个文件、约11.86MB,以15张png图像、6个ipynb笔记本、3个py脚本为主,辅以xml工程配置、h5与npy模型权重等;图像涵盖Delta、Theta、Alpha、Beta、Gamma频段分布以及混淆矩阵、accuracy与loss曲线,notebook则记录特征提取、LSTM训练及CNN1D结合LSTM、GRU、SimpleRNN的对比实验,脚本负责训练与测试入口。已有2522人学习下载。读者可据此完成从带通滤波、特征提取到建模训练与评估的全流程,借助保存的权重直接预测,并通过多版本实验记录理解超参数与网络结构对识别效果的影响,减少自行搭建环境与调试的成本。
1. 先看清这份 CNN-LSTM 代码包:它替你把 DEAP 的哪几步做掉了
如果你把 DEAP 的 pickle 直接塞进 LSTM,十有八九会在第一个 epoch 就看到 loss 不动——这不是模型不灵,而是通道、基线和切片顺序没对齐。这份 Deap脑电信号识别CNN-LSTM代码 把 32 导 EEG 的读取、去基线、2 秒窗切片、按被试划分、CNN-LSTM 训练和推理串成了一条线,适合已经跑过普通时序分类、但被 DEAP 的 40×40×8064 形状卡住的人。它不替你调参,也不承诺某个准确率,价值在于把血泪经验里最容易翻车的几个接口先固定下来。
更直白地说,它解决的是“知道 CNN-LSTM 能做脑电情绪识别,但不知道从哪一行开始改”的问题。新手可以顺着数据形状、切片、模型、训练四段代码跑通一个被试的小实验;熟手可以直接看输入布局、损失函数和避坑清单,把自己工程里的数据泄漏和标签对齐问题重新过一遍。下面按“数据怎么进、模型怎么搭、结果怎么验”的顺序拆,每一步都给出可抄的代码和参数边界。
2. 从 DEAP 原始 pickle 到可训练张量:通道、基线、切片三件事
DEAP 的原始文件不是“一个 CSV 扔进 DataLoader”那种结构。每个被试一个 pickle,里面通常有data和labels两个键:data是 40 个 trial × 40 个通道 × 8064 个采样点,前 32 个通道是 EEG,后 8 个是外周信号;labels是 40 个 trial × 4 列,前两列常被拿来做 valence 和 arousal 的二分类。采样率 128 Hz,8064 个点里包含 3 秒静息基线,也就是前 384 个点。很多人第一次跑 CNN-LSTM 时把所有通道和基线一起送进网络,模型当然也能跑,但学到的往往是“被试静息段差异”,不是任务相关特征。
2.1 先读 labels 和 data 的形状,别急着进模型
我一般会先写一个只做读取和打印的函数,不接模型。先把形状、通道范围、标签范围确认清楚,再谈切片。下面这段代码读取单个被试文件,取前 32 个 EEG 通道,去掉前 3 秒基线,并做 0.5–45 Hz 的带通滤波。
import pickle import numpy as np from scipy.signal import butter, filtfilt def load_deap_subject(path, fs=128, baseline_sec=3, use_channels=32): with open(path, "rb") as f: obj = pickle.load(f, encoding="latin1") X = obj["data"] # (40, 40, 8064) y = obj["labels"] # (40, 4) eeg = X[:, :use_channels, :] # 只取前 32 个 EEG 通道 baseline = int(baseline_sec * fs) eeg = eeg[:, :, baseline:] # 去掉 3 秒静息基线 # 0.5–45 Hz 带通,axis=-1 表示沿采样点方向滤波 b, a = butter(4, [0.5 / (fs / 2), 45 / (fs / 2)], btype="band") eeg = filtfilt(b, a, eeg, axis=-1) return eeg.astype(np.float32), y.astype(np.float32) eeg, labels = load_deap_subject("s01.dat") print(eeg.shape, labels.shape) # 期望 (40, 32, 7680) (40, 4) print(labels[:3])这段代码的逻辑很直接:先按 DEAP 的原始维度取数,再把通道维压到 32,最后沿时间轴去基线、滤波。参数上,fs=128要和数据实际采样率一致;baseline_sec=3是 DEAP 常见设置;use_channels=32只保留 EEG,后 8 个外周通道不建议和 EEG 混在一起送进同一个卷积核。滤波阶数用 4 阶 Butterworth 是常见做法,截止频率 0.5 Hz 和 45 Hz 分别对应漂移和工频以上的高频噪声边界。若你的数据已经降采样或做过陷波,这一段要相应改,不能照抄。
注意:
filtfilt的axis一定要指向时间轴。DEAP 的eeg是(trial, channel, time),所以是axis=-1;如果先把通道转置再滤波,轴号会变,这是很常见的翻车点。
2.2 去基线、带通滤波与按时间窗切片
整段 60 秒 EEG 直接送进 LSTM,序列太长,LSTM 很难在有限样本里学到稳定时序。常见做法是切成 1–4 秒的窗口,窗口之间可以有重叠。情绪诱发响应不是毫秒级事件,2 秒窗在我的经验里比较稳:比 1 秒窗包含更多上下文,又不像 4 秒窗那样让样本数掉得太快。下面按 2 秒窗、1 秒步长切片,并在每个窗口内做通道级标准化。
def make_windows(eeg, labels, fs=128, win_sec=2, stride_sec=1, threshold=5.0): win = int(win_sec * fs) stride = int(stride_sec * fs) X_win, y_win = [], [] for i in range(eeg.shape[0]): trial = eeg[i] # (32, 7680) for start in range(0, trial.shape[1] - win + 1, stride): seg = trial[:, start:start + win] # (32, 256) # 每个通道在自己的窗口内做标准化,降低被试间幅值差异 seg = (seg - seg.mean(axis=1, keepdims=True)) / ( seg.std(axis=1, keepdims=True) + 1e-6 ) X_win.append(seg.T) # (256, 32),时间在前,通道在后 y_win.append((labels[i, :2] >= threshold).astype(np.float32)) return np.stack(X_win), np.stack(y_win) X, Y = make_windows(eeg, labels) print(X.shape, Y.shape) # 约 (40*58, 256, 32) 和 (2320, 2)切片逻辑是“先按 trial 循环,再按时间起点滑窗”,这样不会把不同 trial 的尾部和头部拼在一起。win_sec=2对应 256 个采样点,stride_sec=1表示 50% 重叠,既能增加样本,又保留时间连续性。threshold=5.0是把 1–9 的 valence/arousal 打分二值化,大于等于 5 记为正类;这个阈值不是金标准,后面评估时要允许调整。段内标准化用每个通道自己的均值和标准差,而不是全数据集统一均值,目的是削弱被试间幅值差异。注意标准化要放在切片之后、送模型之前,且训练集和测试集不能互相借用统计量。
提示:先拿一个被试跑通切片,打印
X.shape和Y.mean(axis=0)。如果正负比例极端到 9:1,先别怀疑模型,先看标签阈值和 trial 分布。
2.3 标签二值化与按被试划分,避免数据泄漏
DEAP 有 40 个被试,每个被试 40 个 trial。切片后同一个 trial 会产生几十个高度重叠的窗口,如果随机打乱所有窗口再做训练/验证划分,训练集和验证集里会同时出现同一个 trial 的相邻窗口,指标会虚高。更稳妥的做法是按被试划分:留几个被试做测试,其余被试做训练和验证;如果样本量实在小,至少按 trial 划分。下面这个划分函数只做索引切分,不碰数据内容。
def subject_split(subject_ids, test_ids): test_ids = set(test_ids) train_idx = [i for i, sid in enumerate(subject_ids) if sid not in test_ids] test_idx = [i for i, sid in enumerate(subject_ids) if sid in test_ids] return np.array(train_idx), np.array(test_idx) # 示例:subject_ids 是每个窗口对应的被试编号 # 这里只演示划分思路,实际要把多个被试的 X、Y 先拼起来 # train_idx, test_idx = subject_split(subject_ids, test_ids={1, 2, 3})这段代码本身不复杂,关键是划分发生在切片之前还是之后。我的习惯是:先决定哪些被试进测试集,再对这些被试的数据做切片;不要在切片后的窗口层面随机train_test_split。参数上,test_ids建议留 3–5 个被试做跨被试测试,训练集内部再按被试留一个验证集。若你要做被试内分类,也要按 trial 划分,不能按窗口划分。DEAP 只有 40 个 trial,窗口重叠又大,泄漏一次就会让 AUC 看起来漂亮,换一批被试立刻掉下来。
3. 深度学习模型怎么搭:CNN-LSTM 把局部电极相关性和秒级时序分开建模
CNN-LSTM 在 DEAP 上的常见思路不是“卷积越大越好”,而是分工:一维卷积沿时间轴提取局部波形和相邻电极的组合模式,LSTM 再把卷积输出的时间片段串起来。输入布局如果搞错,卷积会把通道当时间,LSTM 会把时间当特征,最后模型也能训练,但学的东西完全不对。这个章节先把形状固定,再给一个可以直接改参数的小模型。
3.1 输入布局:为什么我常用 (batch, channels, time) 再转置
DataLoader 里我通常让样本是(time, channels),因为切片时按时间窗取数据更自然;进模型后第一件事是transpose(1, 2)变成(batch, channels, time),交给Conv1d。这样卷积核在时间轴上滑动,in_channels就是 32 个 EEG 通道。下面这张表把几个关键形状列清楚,改代码时对着看。
| 位置 | 形状 | 含义 |
|---|---|---|
原始data | (40, 40, 8064) | trial × 通道 × 采样点 |
| 取 EEG 后 | (40, 32, 7680) | 去掉 3 秒基线 |
| 单窗口 | (256, 32) | 2 秒 × 128 Hz,时间在前 |
| 模型输入 | (B, 256, 32) | batch × time × channel |
| 卷积输入 | (B, 32, 256) | transpose 后 channel × time |
参数上,256是 2 秒窗的采样点数,32是 EEG 通道数。若你把窗口改成 4 秒,时间维变成 512,卷积和 LSTM 的序列长度也跟着变;若只选特定通道,比如额区 8 导,in_channels要同步改成 8。不要在一个已经训练好的模型上直接换通道数,分类头的输入维度不会自动适配。
3.2 Conv1D 提局部特征,LSTM 接时序摘要
下面这个模型用两层Conv1d加池化,把时间维压到原来的四分之一,再送双向 LSTM,最后取最后一个时间步做二分类。输出 2 个 logits,对应 valence 和 arousal,多标签分类用BCEWithLogitsLoss。
import torch from torch import nn class CNNLSTM(nn.Module): def __init__(self, n_ch=32, n_classes=2, hidden=64, dropout=0.3): super().__init__() self.conv = nn.Sequential( nn.Conv1d(n_ch, 64, kernel_size=7, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(2), nn.Conv1d(64, 128, kernel_size=5, padding=2), nn.BatchNorm1d(128), nn.ReLU(), nn.MaxPool1d(2), ) self.lstm = nn.LSTM( input_size=128, hidden_size=hidden, num_layers=1, batch_first=True, bidirectional=True, ) self.head = nn.Sequential( nn.Dropout(dropout), nn.Linear(hidden * 2, n_classes), ) def forward(self, x): # x: (B, T, C) -> (B, C, T) x = x.transpose(1, 2) x = self.conv(x) # (B, 128, T/4) x = x.transpose(1, 2) # (B, T/4, 128) out, _ = self.lstm(x) # (B, T/4, hidden*2) last = out[:, -1, :] # 取最后时间步 return self.head(last)逻辑说明:第一层卷积核 7、64 个输出通道,负责捕捉约 55 ms 的局部波形;第二层卷积核 5、128 个输出通道,继续组合更抽象的模式;两次MaxPool1d(2)把 256 个时间点压到 64 个,LSTM 的序列长度就是 64。hidden=64加双向,输出 128 维,最后线性层映射到 2 个标签。dropout=0.3放在分类头前,减少小样本过拟合。num_layers=1是有意为之:DEAP 单被试只有 40 个 trial,切片后样本看似多,但相关性强,堆两层 LSTM 很容易过拟合。参数上,n_ch必须等于输入通道数,n_classes=2对应两个二分类标签;如果你只做 valence 单任务,把它改成 1,损失函数也要相应调整。
注意:
nn.LSTM的dropout只有在num_layers > 1时才生效。单层 LSTM 写dropout=0.5不会报错,但也不会起作用,别把它当成正则化手段。
3.3 损失函数、类别权重与早停参数
DEAP 的二值化标签经常不平衡,尤其当阈值取 5 时,某些被试的正负比例会偏。直接上BCEWithLogitsLoss可以用pos_weight给正类加权,权重按训练集每个标签的负正比计算。优化器我一般用 AdamW,学习率 1e-3,权重衰减 1e-4,配合ReduceLROnPlateau和早停。下面是一段配置代码。
import numpy as np import torch from torch import nn, optim def build_loss(train_y): # train_y: (N, 2),计算每个标签的 neg/pos pos = train_y.sum(axis=0) neg = len(train_y) - pos pos_weight = torch.tensor(neg / np.maximum(pos, 1), dtype=torch.float32) return nn.BCEWithLogitsLoss(pos_weight=pos_weight) model = CNNLSTM(n_ch=32, n_classes=2, hidden=64, dropout=0.3) criterion = build_loss(Y_train) optimizer = optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", factor=0.5, patience=5 )这段代码做了两件事:计算每个标签的pos_weight,以及把优化器和学习率调度器配好。pos_weight的直观含义是“正类样本少,就给正类更大的损失权重”,但不要把它当成万能药;如果正负比超过 10:1,先检查标签阈值是否合理。AdamW的weight_decay=1e-4是轻量正则,脑电小样本上比不加更稳。ReduceLROnPlateau的mode="max"表示监控验证集 AUC 或 F1,patience=5表示连续 5 个 epoch 不提升就减半学习率。早停的patience可以设 10–15,和调度器分开,避免刚减学习率就停。
4. 训练与评估脚本:固定随机种子、保存 best checkpoint、按被试汇总
训练脚本最容易被忽略的不是模型,而是“每次跑出来的数不一样”。DEAP 样本量小,随机种子、数据顺序、权重初始化都会让 AUC 波动几个点。我的做法是把种子、划分文件、标准化参数和 best checkpoint 一起保存,后面做消融时只改一个变量。这个章节给一个最小训练循环、一个评估函数和一个单 trial 推理入口。
4.1 训练循环里必须记录的四个量
训练循环不要只打印 loss。至少记录训练损失、验证 AUC、验证 F1 和当前学习率。AUC 看排序能力,F1 看阈值 0.5 下的平衡表现,学习率用来判断调度器有没有乱跳。下面是一个 epoch 的训练函数。
import torch def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, total_n = 0.0, 0 for xb, yb in loader: xb, yb = xb.to(device), yb.to(device) optimizer.zero_grad() logits = model(xb) loss = criterion(logits, yb) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() total_loss += loss.item() * xb.size(0) total_n += xb.size(0) return total_loss / total_n逻辑说明:前向、算损失、反向、梯度裁剪、更新参数,是标准流程。clip_grad_norm_(..., max_norm=1.0)对 LSTM 特别有用,脑电序列里偶尔会出现幅度突变,不裁剪容易让梯度炸掉。total_loss按样本数加权平均,避免最后一个 batch 较小时把 epoch 损失带偏。参数上,max_norm=1.0是常见起点;如果训练 loss 频繁 NaN,先降到 0.5 试,再检查输入是否标准化。device建议显式传cuda或cpu,不要依赖默认张量位置。
4.2 验证集指标与混淆矩阵
验证函数要返回概率,不要只返回 0/1。AUC 需要概率,F1 需要阈值,混淆矩阵需要离散标签。下面这个函数对两个标签分别算 AUC,并在 0.5 阈值下算 F1。
import numpy as np import torch from sklearn.metrics import roc_auc_score, f1_score, confusion_matrix @torch.no_grad() def evaluate(model, loader, device): model.eval() probs, targets = [], [] for xb, yb in loader: logits = model(xb.to(device)) probs.append(torch.sigmoid(logits).cpu().numpy()) targets.append(yb.numpy()) probs = np.concatenate(probs, axis=0) targets = np.concatenate(targets, axis=0) aucs = [roc_auc_score(targets[:, i], probs[:, i]) for i in range(targets.shape[1])] preds = (probs >= 0.5).astype(np.int32) f1s = [f1_score(targets[:, i], preds[:, i], zero_division=0) for i in range(targets.shape[1])] cm = confusion_matrix(targets[:, 0], preds[:, 0]) return probs, targets, aucs, f1s, cm这段代码先收集所有 batch 的概率,再统一算指标。roc_auc_score对每个标签单独算,最后取平均或分别报告;f1_score的zero_division=0防止某个标签全预测为负时报警。混淆矩阵这里只展示第一个标签,实际项目里两个标签都看。参数上,阈值 0.5 只是默认值,若正类召回太低,可以在验证集上扫 0.3–0.7 选阈值,但不要在测试集上调。评估时保持model.eval()和torch.no_grad(),否则 BatchNorm 和 Dropout 会让结果不稳定。
4.3 推理脚本与单 trial 预测
真正落地时,输入往往是一个 trial 的整段 EEG,而不是已经切好的窗口。推理脚本要负责切片、标准化、批量预测,再把窗口概率聚合回 trial 级别。下面这个函数用均值聚合,适合 2 秒窗、1 秒步长的配置。
@torch.no_grad() def predict_trial(model, trial_eeg, device, fs=128, win_sec=2, stride_sec=1): # trial_eeg: (32, T),已去基线、已滤波 win = int(win_sec * fs) stride = int(stride_sec * fs) windows = [] for start in range(0, trial_eeg.shape[1] - win + 1, stride): seg = trial_eeg[:, start:start + win] seg = (seg - seg.mean(axis=1, keepdims=True)) / ( seg.std(axis=1, keepdims=True) + 1e-6 ) windows.append(seg.T) x = torch.tensor(np.stack(windows), dtype=torch.float32).to(device) probs = torch.sigmoid(model(x)).cpu().numpy() return probs.mean(axis=0) # 输出形如 [valence_prob, arousal_prob]逻辑说明:推理时复用训练阶段的窗口长度和步长,每个窗口单独标准化,模型输出概率后按窗口平均。参数上,win_sec和stride_sec必须与训练一致,否则分布会偏;trial_eeg要提前去基线、滤波,不能把原始 8064 点直接传进来。均值聚合简单,但如果某些窗口明显是伪迹,可以考虑中位数或去掉高方差窗口。返回两个概率后,阈值仍按验证集选定的值走,不要临时拍脑袋。
5. 避坑排查:DEAP 上 CNN-LSTM 最容易翻车的五个地方
DEAP 上的 CNN-LSTM 代码看起来不长,真正花时间的往往是排查。下面五条是我见过最多的翻车场景,按“现象 → 原因 → 解决”写。它们不针对某个特定脚本,而是针对这类数据形状和训练流程。你可以把它当成跑代码前的检查清单,也可以当成指标异常时的排查顺序。
5.1 数据层踩坑:形状、基线、泄漏
第一条是形状错位。现象是模型报维度不匹配,或者 loss 一直是 NaN。原因通常是直接把data的(40, 40, 8064)当成(trial, time, channel),结果Conv1d的in_channels被设成 8064,通道数完全对不上。解决方法是先打印X.shape,确认第二维是通道、第三维是时间,取前 32 个通道后再切片。切片输出我习惯用(time, channel),模型里transpose(1, 2),这样卷积的in_channels=32。如果这一步不确定,先写一个只有Conv1d的小网络,输入(2, 32, 256),能跑通再接 LSTM。
第二条是基线没去或滤波轴错。现象是验证 AUC 长期在 0.5 附近,模型学不到任务相关差异。原因是 DEAP 的 8064 个点包含前 3 秒静息基线,如果不去掉,网络会把静息段和被试个体差异当成主要信号;另一个常见错误是filtfilt的axis写成了axis=1,把通道方向滤了,时间方向没动。解决方法是先去掉前3 * 128 = 384个点,再沿最后一维滤波。切片后每个通道做段内标准化,训练集和测试集分别用自己的窗口统计量,不要全量一起标准化。排查时打印一个窗口的均值和标准差,正常应该在 0 和 1 附近。
第三条是数据泄漏。现象是验证集 AUC 很高,换一批被试或换一个随机种子就大幅下降。原因是切片后同一个 trial 的相邻窗口高度重叠,如果按窗口随机划分,训练集和验证集会共享几乎相同的信息。解决方法是先按被试划分,再切片;至少也要按 trial 划分。具体做法是给每个窗口记录subject_id和trial_id,划分时以 subject 或 trial 为单位,不要用train_test_split直接打乱窗口。DEAP 只有 40 个被试,留 3–5 个做测试并不算多,指标会难看一点,但更接近真实跨被试场景。
5.2 训练层踩坑:过拟合、阈值、显存
第四条是标签阈值和类别不平衡。现象是 AUC 看起来还行,但 F1 很低,混淆矩阵里某一类几乎全空。原因是 valence/arousal 用 5 做阈值时,正负比例可能偏斜,模型偏向多数类;另外多标签任务里两个标签的分布不一定一致,不能用一个阈值套两个标签。解决方法是先打印训练集每个标签的正负比,用pos_weight给正类加权;评估时同时看 AUC 和 F1,不要只盯准确率。阈值可以在验证集上按 F1 或召回扫一遍,选好后固定,再到测试集上跑。若某个标签正类少于 10%,先确认这不是被试个体差异导致的,再考虑合并标签或换任务定义。
第五条是过拟合和显存。现象是训练 loss 持续下降,验证 loss 早早抬头;或者 batch 设到 64 就 OOM。原因是 DEAP 单被试样本有限,切片后的窗口相关性又强,双向 LSTM 加两层卷积容量偏大;显存则和序列长度、batch、LSTM hidden 成正比。解决方法是先减小hidden到 32 或 48,dropout保持 0.3–0.5,加weight_decay和梯度裁剪,早停耐心设 10–15。显存不够时优先减 batch 或窗口长度,不要一上来换更小的模型结构。排查时记录每个 epoch 的训练损失和验证 AUC,如果训练损失降到很低而验证 AUC 不涨,基本就是过拟合,不是数据不够。
注意:不要用测试集反复调阈值、调 hidden、调窗口。调参只在训练集内部划出的验证集上做,测试集留到最后跑一次。DEAP 样本小,反复看测试集很容易把结论调成“看起来能发论文,换数据就不行”。
6. 把复现结果变成可验证的结论:三种消融与一个固定检查习惯
复现实验最怕只看一个准确率。我的做法是跑三组消融,固定同一份被试划分、同一个随机种子、同一套预处理,只改模型结构:第一组 CNN-only,把 LSTM 换成全局平均池化;第二组 LSTM-only,去掉Conv1d,直接把(B, T, C)送 LSTM;第三组完整 CNN-LSTM。每组至少跑 3 个种子,报告均值而不是单次最好结果。下面这张表是我会记录的格式,指标列留空,跑完再填。
| 配置 | 输入 | 关键改动 | AUC-valence | AUC-arousal | F1-valence | F1-arousal |
|---|---|---|---|---|---|---|
| CNN-only | (B, 256, 32) | LSTM 换AdaptiveAvgPool1d(1) | ||||
| LSTM-only | (B, 256, 32) | 去掉卷积,LSTM 输入 32 | ||||
| CNN-LSTM | (B, 256, 32) | 完整结构 |
表格不是为了好看,而是逼自己把“结构变化”和“指标变化”对上。CNN-only 如果和完整模型差不多,说明 LSTM 没带来时序增益,先检查序列长度和池化是否把时间信息压没了;LSTM-only 如果明显更差,说明局部波形和通道组合确实有用,但也可能是 LSTM 在小样本上更难训。消融跑完再决定要不要加通道选择、换窗口长度,而不是一开始就把所有 trick 堆上去。
固定检查习惯我一般放在训练脚本入口:每次跑之前打印X_train.shape、Y_train.mean(axis=0)、subject_ids的唯一值数量,以及模型第一层卷积的in_channels。这四个量能挡掉大部分低级错误。训练结束后保存best_state_dict、划分文件、标准化参数和随机种子,下一次换数据时只改路径,不重写流程。从那以后我每次碰 DEAP 这类多 trial 脑电数据,都强制先跑一个被试的过拟合小实验:样本少、模型小,能在训练集上把 AUC 拉到接近 1,再谈跨被试泛化;如果连小实验都过不了,说明形状、标签或损失函数里还有黑匣子没打开。希望帮到你。
本文还有配套的精品资源,点击获取