深度学习实战:基于DEAP数据集的脑电情绪识别(DNN与CNN对比实验)
1. 从脑电信号到情绪识别:一个有趣的实战项目
大家好,我是老张,在AI和智能硬件这块摸爬滚打了十来年。今天想和大家聊一个特别有意思的话题:怎么用电脑“读懂”人的情绪。听起来是不是有点科幻?其实这事儿在学术界和工业界已经研究好多年了,用的就是我们大脑产生的电信号——脑电图,也就是EEG。
你可能见过医院里做脑电图检查,病人头上戴个布满电极的“帽子”。那些电极捕捉到的,就是大脑神经元活动产生的微弱电信号。科学家们发现,当我们高兴、悲伤、平静或兴奋时,大脑不同区域的活动模式是不一样的。这就给了我们一个机会:能不能训练一个AI模型,让它学会从这些复杂的脑电波纹里,分辨出人当前是开心还是低落,是平静还是激动呢?
这就是“脑电情绪识别”要干的事儿。它属于情感计算的一个分支,目标就是让机器更懂人。这个技术潜在的应用场景非常多,比如评估心理健康状态、为自闭症儿童提供辅助交互、甚至用在游戏或VR里,让体验更沉浸。当然,今天我们不聊那么远,咱们就踏踏实实,手把手复现一个经典的实验:用两种最基础的深度学习模型——深度神经网络和卷积神经网络,在公开的DEAP数据集上,比比看谁更擅长“读心术”。
我会把整个流程掰开揉碎了讲,从数据怎么来的、怎么处理,到模型怎么搭、怎么训,再到结果怎么看。即使你之前没接触过脑电信号或者PyTorch,跟着走一遍,也能把这个项目跑起来,亲自感受一下AI解码情绪的魔力。
2. 理解我们的“原料”:DEAP数据集详解
工欲善其事,必先利其器。做任何机器学习项目,第一步永远是搞清楚你的数据。我们这次实验用的“原料”是情感计算领域一个非常著名的公开数据集,叫DEAP。它全称是“Database for Emotion Analysis using Physiological Signals”,2014年发布的,到现在还被广泛用作基准。
2.1 DEAP里到底有什么?
你可以把DEAP想象成一个精心设计的“情绪刺激-反应”数据库。研究者找了32位志愿者,让他们安安静静地坐在实验室里,头上戴着32个电极的脑电帽,看40段一分钟长的音乐视频。这些视频风格各异,有的激昂,有的舒缓,目的就是为了诱发观看者不同的情绪。
在看视频的同时,设备会全程记录他们的生理信号,主要是32个通道的脑电信号,采样率是128Hz(意味着每秒钟记录128个数据点)。看完每个视频后,志愿者还要自己给刚才的情绪打分,主要从三个维度:效价、唤醒度和优势度。
- 效价:简单理解就是心情的“好坏”。高分代表愉悦、快乐(比如看喜剧),低分代表不悦、悲伤(比如看悲剧)。
- 唤醒度:指的是情绪的“强烈程度”。高分代表兴奋、激动(比如看动作片),低分代表平静、放松(比如看风景片)。
- 优势度:感觉自己对情境的掌控程度,这个维度我们这次实验先不涉及。
所以,对于每个志愿者,我们得到了40段“视频刺激”,每段刺激对应着63秒(3秒基线+60秒视频)的脑电信号,以及一组三维的情绪标签。原始数据量非常大,一个志愿者就有 40个视频 * 32个通道 * (63秒 * 128Hz) ≈ 40 * 32 * 8064 个数据点。
2.2 数据预处理:把“生肉”做成“熟菜”
原始脑电信号就像一块充满筋膜的“生肉”,直接下锅炒肯定咬不动。里面混杂了眨眼、肌肉运动、工频干扰等各种噪声。好在DEAP官方提供了一个预处理版本,帮我们做了下采样(从512Hz降到128Hz)、滤波去噪等基础工作。但我们拿到手后,还得进行一番精加工,才能喂给模型。
我参考了一些论文的做法,主要走了下面几步:
- 提取有效通道:原始数据有40个通道,但其中8个是其他生理信号(比如肌电、眼电),我们只关心那32个脑电通道,所以第一步就是把这32个通道的数据“抠”出来。
- 数据降维:这是最关键也最有技巧的一步。每个通道有8064个时间点,直接扔进模型,计算量太大,而且序列太长模型也很难学到有效特征。我的策略是“分段统计”。我把每个通道的8064个点分成10小段(前9段每段807点,最后一段801点),对每一小段计算9个统计特征:均值、中位数、最大值、最小值、标准差、方差、极差、偏度和峰度。这9个特征能很好地刻画一段波形的整体形态和分布。最后,再对整个通道的8064个点也计算这9个特征。这样一来,一个通道就从8064维压缩到了
10段 * 9特征 + 9全局特征 = 99维。这个reduce_dim函数是预处理的核心。 - 数据标准化:为了让模型训练更稳定,需要对数据进行标准化。我做了两级标准化:先对每个通道自己的99个特征做标准化(减均值除标准差),消除不同通道间的量纲差异;然后再对所有通道的所有特征一起做一次全局标准化,让整个数据集的分布以0为中心,标准差为1。
经过这一套“组合拳”,最终我们得到的数据形状是 (32, 99),可以看作是一张32行(通道)、99列(特征)的“图像”或者一个长向量。标签则根据任务需要,取效价或唤醒度的二值化结果(通常以5分为界,高于5分为“高”,否则为“低”)。
# 数据降维核心函数 reduce_dim 的简化示意
import numpy as np
import scipy.stats as sp
def reduce_dim(data):
# 输入 data 形状为 (32, 8064)
processed_data = np.zeros((32, 99))
for channel_n in range(32):
# 分段计算统计特征
for batch_n in range(10):
# 取出一段数据
start_idx = batch_n * 807
end_idx = start_idx + 807 if batch_n != 9 else 8064
batch = data[channel_n, start_idx:end_idx]
# 计算9个统计量
stats = [
np.mean(batch), np.median(batch), np.max(batch), np.min(batch),
np.std(batch), np.var(batch), np.max(batch)-np.min(batch),
sp.skew(batch), sp.kurtosis(batch)
]
processed_data[channel_n, batch_n*9 : batch_n*9+9] = stats
# 计算整个通道的全局统计特征
global_stats = [np.mean(data[channel_n, :]), np.median(data[channel_n, :]), ...] # 同上9个
processed_data[channel_n, 90:99] = global_stats
return processed_data # 形状 (32, 99)
3. 搭建我们的“读心”模型:DNN与CNN设计
数据准备好了,接下来就是设计模型,也就是我们“读心术”的大脑。这次我们请出深度学习里的两位老将:全连接的深度神经网络和擅长处理空间结构的卷积神经网络,让它们同台竞技。
3.1 深度神经网络:大力出奇迹的经典思路
DNN的思路非常直接,就是把我们那个 32*99=3168 维的预处理后数据,全部展平,变成一个长长的向量,然后一层一层地做全连接变换。你可以把它想象成一个非常复杂、多层的“信号过滤器”或者“特征组合器”。
我设计的这个DNN结构不算很深,但宽度够大:
- 一个
Flatten层把输入打平。 - 紧接着一个
Dropout层,丢弃率25%,这是为了防止模型一上来就过拟合。 - 然后是第一层全连接层,把3168维的输入映射到一个5000维的高维空间,接着是ReLU激活函数和高达50%丢弃率的Dropout层。这一步是希望模型能学习到非常丰富的特征组合。
- 后面再接两个隐层,维度分别是500和1000,同样都配有ReLU和Dropout。
- 最后是一个输出为1维的全连接层,因为我们做的是二分类(高效价/低效价,或高唤醒/低唤醒),用这个单值输出再经过Sigmoid函数就可以得到概率。
整个模型有接近1900万个参数,全靠后面的数据驱动来学习。优化器我选择了RMSprop,损失函数是二分类交叉熵。这里有个小经验,对于这种表格型数据(每个特征都有明确物理意义),DNN这种“暴力”学习特征间复杂关系的方式,往往效果不差。
import torch.nn as nn
class DNN(nn.Module):
def __init__(self, input_dim=32*99, sizes=(5000, 500, 1000), dropout_probs=(0.25, 0.5)):
super(DNN, self).__init__()
self.flatten = nn.Flatten()
self.dropout_input = nn.Dropout(dropout_probs[0])
self.linear1 = nn.Linear(input_dim, sizes[0])
self.linear2 = nn.Linear(sizes[0], sizes[1])
self.linear3 = nn.Linear(sizes[1], sizes[2])
self.linear4 = nn.Linear(sizes[2], 1) # 二分类输出
self.dropout_hidden = nn.Dropout(dropout_probs[1])
self.relu = nn.ReLU()
def forward(self, x):
x = self.flatten(x)
x = self.dropout_input(x)
x = self.relu(self.linear1(x))
x = self.dropout_hidden(x)
x = self.relu(self.linear2(x))
x = self.dropout_hidden(x)
x = self.relu(self.linear3(x))
x = self.dropout_hidden(x)
x = self.linear4(x) # 输出 logits
return x
3.2 卷积神经网络:挖掘通道与特征的局部关联
CNN最初是为图像设计的,它有个绝活:通过卷积核扫描,自动捕捉空间上的局部模式。那脑电数据 (32, 99) 像不像一张高度32、宽度99的灰度图?32个通道就是“高度”,99个时间/特征维度就是“宽度”。CNN或许能发现不同通道之间、或者特征之间的某种空间相关性。
我设计的CNN结构比较轻量:
- 首先在输入上加一个虚拟的通道维度,变成
(batch, 1, 32, 99),符合PyTorch卷积层输入格式。 - 第一层卷积:用20个
3x3的卷积核,保持空间尺寸不变(通过padding),目的是提取一些基础的局部特征。 - 第二层卷积:将通道数扩展到40,进一步组合特征。
- 然后接一个
2x2的最大池化层,将特征图的高和宽各缩小一半,这是为了降低计算量并增加一些平移不变性。 - 将池化后的特征图展平,送入两个全连接层,最终输出一个值。
这里我用了SGD优化器并开启了动量(0.9),因为经验上CNN配合SGD有时能收敛到更好的局部最优。同样,在卷积层后我也加入了Dropout2d(空间丢弃),在全连接层前也加了Dropout,都是常规的防过拟合操作。
class CNN(nn.Module):
def __init__(self, dropout_probs=(0.25, 0.15, 0.5, 0.25)):
super(CNN, self).__init__()
# 假设输入形状为 (batch, 1, 32, 99)
self.conv1 = nn.Conv2d(1, 20, kernel_size=3, padding=1) # 输出 (batch, 20, 32, 99)
self.conv2 = nn.Conv2d(20, 40, kernel_size=3, padding=1) # 输出 (batch, 40, 32, 99)
self.pool = nn.MaxPool2d(2, 2) # 输出 (batch, 40, 16, 49)
# 展平后维度: 40 * 16 * 49 = 31360
self.fc1 = nn.Linear(40 * 16 * 49, 128)
self.fc2 = nn.Linear(128, 1)
self.relu = nn.ReLU()
self.dropout_conv = nn.Dropout2d(dropout_probs[1])
self.dropout_fc = nn.Dropout(dropout_probs[2])
def forward(self, x):
x = x.unsqueeze(1) # 增加通道维
x = self.relu(self.conv1(x))
x = self.dropout_conv(x)
x = self.relu(self.conv2(x))
x = self.pool(x)
x = torch.flatten(x, 1) # 展平
x = self.dropout_fc(x)
x = self.relu(self.fc1(x))
x = self.dropout_fc(x)
x = self.fc2(x)
return x
4. 训练与评估:让模型在数据中学习
模型搭好了,接下来就是最关键的训练环节。这个过程就像教小孩认东西,你得反复给他看样本(数据),告诉他答案(标签),并纠正他的错误(损失函数反向传播)。
4.1 训练前的准备工作
在按下“开始训练”的按钮前,有几件琐碎但重要的事必须做好。
首先是数据划分。 我们把整个数据集(32人*40 trials = 1280个样本)按大约9:1的比例,随机分成训练集和测试集。这里一定要用固定的随机种子,保证每次实验划分一致,结果才可复现。训练集用来更新模型参数,测试集则完全不动,只在每个训练周期结束后用来评估模型真正的泛化能力。
其次是超参数设置。 我把这些配置都写进了一个YAML文件里,管理起来清晰方便。比如DNN我用RMSprop优化器,学习率设为0.0001;CNN我用SGD,学习率也是0.0001,动量0.9。两者都训练250个周期,但批次大小不同,DNN是310,CNN是50。这些值不是凭空来的,是我参考了相关论文并经过几次小规模尝试后定下来的。损失函数都选用带Logits的二元交叉熵,它比先Sigmoid再BCE在数值上更稳定。
最后是模型初始化。 神经网络的初始状态很重要,好的初始化能让训练更快更稳。我采用了Xavier正态分布来初始化所有全连接层的权重,并把偏置项设为零。这是一个非常通用且有效的初始化策略。
# deap_dnn_arousal.yml 配置文件示例
DATASET:
dataset_to_use: 'deap'
deap_dataset_path: './deap_processed_data/'
MODEL:
model: 'dnn' # 或 'cnn'
sizes: [5000, 500, 1000]
dropout_probs: [0.25, 0.5]
TRAIN:
classification_of: 'arousal' # 或 'valence'
num_epochs: 250
batch_size: 310 # CNN 改为 50
lr: 0.0001
momentum: 0.9 # DNN 时此项不生效
seed: 42
train_test_split: [1180, 100] # 训练集和测试集大小
EXPORT:
model_path: './saved_models/'
4.2 训练循环与技巧
训练的核心就是一个大循环。在每个周期里,遍历整个训练集的所有批次。对于每个批次的数据,前向传播得到预测值,计算损失,然后反向传播求梯度,最后优化器根据梯度更新模型参数。我写了一个 train() 函数来封装这个过程。
这里有几个提升训练效果和体验的小技巧:
- 定期验证:我设置每训练完一个周期,就在测试集上跑一次,计算准确率。这样我能实时监控模型在未见过的数据上的表现,防止它只在训练集上“死记硬背”(过拟合)。
- 保存最佳模型:在验证过程中,我会记录测试集上的最高准确率。一旦有新的最高分出现,就立刻把当前的模型参数保存下来。这样训练结束后,我拿到手的直接就是历史上最好的模型,而不是最后一个可能已经过拟合的模型。
- 损失监控:除了准确率,每个周期的平均损失值我也会记录下来并打印出来。如果损失一直不下降,或者出现剧烈震荡,那可能就是学习率设得不合适,或者模型结构有问题,需要及时调整。
def train_one_epoch(model, train_loader, criterion, optimizer, device):
model.train()
running_loss = 0.0
for data, labels in train_loader:
data, labels = data.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(data)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item() * data.size(0)
epoch_loss = running_loss / len(train_loader.dataset)
return epoch_loss
5. 结果对比与分析:DNN与CNN谁更胜一筹?
模型训完了,最激动人心的时刻到了:看看这两位“读心术士”到底学得怎么样。我主要从两个角度来评估:一是看它们在固定测试集上的表现,二是用更严格的K折交叉验证来检验稳定性。
5.1 固定划分下的性能比拼
我把训练集和测试集固定下来,让DNN和CNN各自训练250轮,并记录每轮在测试集上的准确率。最后画出的准确率变化曲线很有意思。
DNN模型起步很快,前期准确率飙升,大约在50个周期后就达到了一个不错的水平,之后缓慢提升并逐渐稳定。最终,在效价二分类任务上,DNN的测试准确率最高能达到79% 左右,平均也在70%以上。这个结果说实话比我预想的要好,说明经过精心预处理后的特征,用全连接网络这种“蛮力”模型已经能学到很强的判别规律。
再看CNN模型,它的学习曲线相对平缓一些,震荡也更明显。最终的最高准确率大约在75%,平均准确率比DNN低几个百分点。这个结果让我思考了一阵。理论上,CNN应该更擅长挖掘 (32, 99) 这种“伪图像”中的空间结构信息,比如不同脑区(通道)之间的协同活动模式。但实际效果却稍逊一筹。
我分析可能有几个原因:第一,我们的数据预处理中,99个特征是基于时间分段统计出来的,它们之间的局部空间关联性可能没有原始时间序列那么强,削弱了CNN的优势。第二,DNN模型参数巨大(近1900万),而我们的数据量(1280样本)相对有限,DNN强大的拟合能力可能在这个规模的数据上恰好发挥了作用,但这也潜藏着过拟合的风险。第三,CNN的结构和超参数(如卷积核大小、层数)可能不是最优的,需要更精细的调整。
5.2 K折交叉验证:揭开“幸运”的面纱
固定划分测试有个问题:万一我们随机分到的测试集特别简单,或者特别契合模型呢?为了得到更可靠、更泛化的性能估计,我采用了K折交叉验证。这里我用了32折,基本上等于“留一法”,因为总共有32个被试,每次用31个人的数据训练,在剩下的1个人数据上测试,循环32次。
结果一出来,我就知道之前的固定划分结果确实有“运气”成分。K折验证的平均准确率,无论是DNN还是CNN,都出现了显著下降,大概在55%-65% 这个区间。而且不同折(即不同被试作为测试集)之间的准确率波动非常大,从最低的43%到最高的78%都有。
这说明了几个关键问题:
- 个体差异巨大:脑电信号具有很强的个人特异性。在一个被试身上训练得很好的模型,换一个人,性能可能大打折扣。这是脑机接口领域一个公认的挑战。
- 模型泛化能力有待提高:我们的模型,无论是DNN还是CNN,都还没有很好地学会那些“人”的共性情绪脑电模式,更多是记忆了训练集中被试的特有模式。
- 数据量是瓶颈:尽管DEAP有32个被试,但每个人只有40个试次,对于深度学习模型来说,数据量依然捉襟见肘。这也是为什么我们需要更复杂的模型(如跨被试学习、迁移学习)和更多的数据增强技术。
在K折验证这个更公平的擂台上,DNN和CNN的表现差距变得很小,有时CNN的平均表现甚至略好一点点。这说明在排除了“幸运测试集”的干扰后,两者处理这类问题的能力在伯仲之间。
6. 实战后的思考与优化方向
跑完整个实验,看着那些跳动的损失曲线和准确率数字,我感触挺深的。这不仅仅是一次简单的模型对比,更是一次对脑电信号特性、深度学习模型能力以及实验严谨性的深入体验。
首先,数据决定了天花板。在脑电情绪识别这个任务里,数据的质量、预处理的方式,可能比模型本身的选择影响更大。我那套“分段统计”的降维方法,虽然有效,但肯定不是最优的。是不是可以尝试更高级的特征,比如功率谱密度、微分熵、功能连接矩阵?或者干脆用更深的网络直接从原始信号或时频图里学?这些都是可以探索的方向。
其次,模型需要量身定制。DNN和CNN在这个任务上表现接近,但它们的“思考”方式不同。DNN像一个博闻强记的学者,努力记住所有特征组合;CNN则像一个寻找规律的侦探,试图发现通道间的关联模式。或许我们可以把两者结合起来,比如用CNN提取空间特征,再用DNN或者RNN(循环神经网络)来捕捉时间动态,构成一个混合模型。我在后续的尝试中,加入了一个简单的LSTM层来建模99个特征维度上的时间关系,初期结果显示出一定的提升潜力。
再者,评估方式至关重要。固定划分测试很容易给出过于乐观的估计,K折交叉验证,尤其是按被试划分的交叉验证,更能反映模型在真实场景下面对新人的表现。这提醒我们,在论文里或者实际应用中,报告交叉验证的结果比报告单一测试集的结果要严谨得多。
最后,工程细节不容忽视。比如Dropout率、学习率、优化器的选择,对最终结果的影响可能超乎你的想象。我在实验中发现,对于DNN,把第一个Dropout率从0.25调到0.4,有时能提升1-2个点的稳定准确率。对于CNN,使用学习率衰减策略(比如每50轮减半)能帮助它更好地收敛。这些都需要耐心地做消融实验。
这个项目就像一个微缩的科研过程:定义问题、处理数据、设计模型、训练调优、分析结果、反思改进。它让我再次认识到,在AI领域,尤其是和生物信号这种复杂数据打交道时,没有一个放之四海而皆准的“银弹”模型。我们需要的是对数据的深刻理解、对模型的灵活运用,以及严谨细致的实验态度。如果你也跟着做了一遍,相信你收获的不仅仅是两个能跑通的模型,更是一套处理真实世界AI问题的完整方法论。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)