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)、滤波去噪等基础工作。但我们拿到手后,还得进行一番精加工,才能喂给模型。

我参考了一些论文的做法,主要走了下面几步:

  1. 提取有效通道:原始数据有40个通道,但其中8个是其他生理信号(比如肌电、眼电),我们只关心那32个脑电通道,所以第一步就是把这32个通道的数据“抠”出来。
  2. 数据降维:这是最关键也最有技巧的一步。每个通道有8064个时间点,直接扔进模型,计算量太大,而且序列太长模型也很难学到有效特征。我的策略是“分段统计”。我把每个通道的8064个点分成10小段(前9段每段807点,最后一段801点),对每一小段计算9个统计特征:均值、中位数、最大值、最小值、标准差、方差、极差、偏度和峰度。这9个特征能很好地刻画一段波形的整体形态和分布。最后,再对整个通道的8064个点也计算这9个特征。这样一来,一个通道就从8064维压缩到了 10段 * 9特征 + 9全局特征 = 99 维。这个 reduce_dim 函数是预处理的核心。
  3. 数据标准化:为了让模型训练更稳定,需要对数据进行标准化。我做了两级标准化:先对每个通道自己的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结构不算很深,但宽度够大:

  1. 一个 Flatten 层把输入打平。
  2. 紧接着一个 Dropout 层,丢弃率25%,这是为了防止模型一上来就过拟合。
  3. 然后是第一层全连接层,把3168维的输入映射到一个5000维的高维空间,接着是ReLU激活函数和高达50%丢弃率的Dropout层。这一步是希望模型能学习到非常丰富的特征组合。
  4. 后面再接两个隐层,维度分别是500和1000,同样都配有ReLU和Dropout。
  5. 最后是一个输出为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结构比较轻量:

  1. 首先在输入上加一个虚拟的通道维度,变成 (batch, 1, 32, 99),符合PyTorch卷积层输入格式。
  2. 第一层卷积:用20个 3x3 的卷积核,保持空间尺寸不变(通过padding),目的是提取一些基础的局部特征。
  3. 第二层卷积:将通道数扩展到40,进一步组合特征。
  4. 然后接一个 2x2 的最大池化层,将特征图的高和宽各缩小一半,这是为了降低计算量并增加一些平移不变性。
  5. 将池化后的特征图展平,送入两个全连接层,最终输出一个值。

这里我用了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() 函数来封装这个过程。

这里有几个提升训练效果和体验的小技巧:

  1. 定期验证:我设置每训练完一个周期,就在测试集上跑一次,计算准确率。这样我能实时监控模型在未见过的数据上的表现,防止它只在训练集上“死记硬背”(过拟合)。
  2. 保存最佳模型:在验证过程中,我会记录测试集上的最高准确率。一旦有新的最高分出现,就立刻把当前的模型参数保存下来。这样训练结束后,我拿到手的直接就是历史上最好的模型,而不是最后一个可能已经过拟合的模型。
  3. 损失监控:除了准确率,每个周期的平均损失值我也会记录下来并打印出来。如果损失一直不下降,或者出现剧烈震荡,那可能就是学习率设得不合适,或者模型结构有问题,需要及时调整。
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%都有。

这说明了几个关键问题:

  1. 个体差异巨大:脑电信号具有很强的个人特异性。在一个被试身上训练得很好的模型,换一个人,性能可能大打折扣。这是脑机接口领域一个公认的挑战。
  2. 模型泛化能力有待提高:我们的模型,无论是DNN还是CNN,都还没有很好地学会那些“人”的共性情绪脑电模式,更多是记忆了训练集中被试的特有模式。
  3. 数据量是瓶颈:尽管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问题的完整方法论。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐