ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python皮肤电信号情绪识别实操:从预处理到模型训练

Python皮肤电信号情绪识别实操:从预处理到模型训练 简介面向情感计算与生理信号处理学习者这是一套基于Python皮肤电信号的情绪识别算法项目资料。内容涵盖完整可运行源码、预训练模型、配套讲解PPT、详细技术文档及全部实验数据源码已在本地编译验证评审分95分以上难度适中适合作为课程设计、毕业设计或入门实践参考。包内共102个文件约1.91MB以78个CSV格式的皮肤电信号数据文件为主另有7个Python算法脚本、6个XML配置、4个MATLAB脚本、3个编译后的pyc文件及PPT、Markdown说明文档等结构与类型划分清晰便于按需查阅。目前已有217人学习下载。通过该项目可掌握皮肤电信号预处理、特征提取、情绪分类模型构建与评估的完整流程同时获得可直接运行的工程代码与讲解素材是快速上手情绪识别方向的实用资料。1. 从“皮肤电信号”到“情绪识别”这一包资料到底解决什么问题基于 Python 皮肤电信号的情绪识别算法源码加模型加文档加数据资料这一整套东西在从业者眼里解决的是“用低成本传感器判断情绪唤醒度”的落地问题。皮肤电信号EDA也叫 GSR是唯一一个靠两个电极贴在手指或手掌上就能记录的情绪相关生理信号情绪唤起时汗腺活动改变皮肤电导整个过程受交感神经控制几乎没办法主观伪装。这类 zip 资料包在大学实验室和交互设计团队里流传很广原因很现实做毕业设计需要一个能复现的基线系统做产品原型的工程师想验证“情绪识别”到底能不能作为交互输入研究团队则希望有一份完整数据可以跑对比实验。我拿到这类资料的第一反应是先把 Python 环境准备好然后去看模型结构——这是新手最容易踩的方向真正决定情绪识别上限的往往不是模型而是预处理和特征提取。本篇文章不替你复述 PPT 内容也不假设你手里已经有同样一份数据。我把 EDA 情绪识别的完整套路拆开讲清楚预处理怎么做、特征怎么提、模型怎么训练、哪些地方最容易翻车以及拿到源码后应该从哪里下手改。2. 用滑动窗口滤波做好 EDA 预处理一条可直接复用的 Python 管线2.1 为什么滑动窗口滤波是皮肤电信号预处理的默认起点EDA 原始信号由两部分叠加而成缓慢变化的皮肤电导水平SCL和短促的皮肤电导反应SCR。情绪刺激引起的 SCR 通常持续 1 到 3 秒主要能量集中在 0.05 到 0.5 Hz 之间。运动伪迹、电极接触松动和设备量化噪声则表现为尖锐毛刺和高频抖动波形形态上和真实 SCR 非常像。滑动窗口滤波之所以是默认起点是因为它符合 EDA 的生理特点窗口内取中值或均值能压制局部异常点同时保留慢变的整体趋势。相比全局滤波滑动窗口是局部操作不会因为某一段伪迹把整个波形的均值拉偏。实际操作上先做中值滤波再做低通滤波最后做线性去趋势是公认的稳定组合。参数上最容易出错的是窗口长度。习惯做法是把窗口长度用“秒”乘以采样率换成采样点数中值滤波窗口约 1 秒移动平均窗口约 0.5 到 1 秒。采样率越高窗口内样本越多平滑效果越明显但窗口太大也会把真实的 SCR 峰抹平导致后续峰提取数量偏少。网上经常搜到的“滑动窗口滤波模型”这个词在 EDA 场景下指的就是这种时域平滑手段它和深度学习中滑动窗口预测是两个概念别混。提示数据资料里的原始文件采样率可能不统一。拿到数据第一件事是重采样到 256 Hz或者至少统一到 128 Hz后续所有窗口参数才能按秒直接换算。2.2 三个滤波步骤中值滤波、低通滤波与线性去趋势下面这段代码是最小可用的预处理管线适用于大多数 EDA 数据。这里默认采样率 fs256原始波形以 NumPy 一维数组传入单位为微西门子。import numpy as np from scipy.signal import medfilt, butter, filtfilt, detrend def preprocess_eda(raw: np.ndarray, fs: int 256) - np.ndarray: raw : 原始EDA波形一维数组单位一般为微西门子 fs : 采样率默认256Hz # 1) 中值滤波窗口按1秒长度换算必须是奇数 win int(fs * 1.0) if win % 2 0: win 1 eda medfilt(raw, kernel_sizewin) # 2) 低通滤波截止频率0.5Hz滤掉运动伪迹和高频噪声 cutoff 0.5 order 2 b, a butter(order, cutoff / (fs / 2), btypelow) eda filtfilt(b, a, eda) # 3) 线性去趋势消除电极基线漂移 eda detrend(eda) return eda中值滤波的窗口为什么取 1 秒SCR 单峰宽度通常大于 1 秒1 秒窗口足以区分真实峰和窄毛刺窗口小于 0.5 秒时毛刺残留多大于 2 秒时峰被压平。filtfilt 和普通 filter 的区别是零相位波形上不会出现时间滞后——这一点对后续按事件切窗口很关键滞后哪怕 100 毫秒都会让 SCR 峰位置偏移。detrend 默认用最小二乘法拟合线性趋势并减去主要用于抵消电极极化或皮肤出汗造成的慢漂移。如果你手里的数据采样率很低比如只有 25 Hzmedfilt 的 1 秒窗口只有 25 个点此时滤波效果有限。建议先用 scipy 的 interpolate 线性插值到至少 64 Hz 再做这套流程否则后续峰值检测的分辨率不够。2.3 伪迹剔除当滑动窗口也压不住运动噪声时的兜底方案中值滤波能处理单个采样点的毛刺但被试者手部大幅动作会造成连续数秒的信号剧烈波动这种伪迹即使滤波后仍然保留着异常的能量结构。做法是用一阶差分加移动平均再用 z-score 阈值标记伪迹区段。def mark_artifacts(eda: np.ndarray, fs: int 256, z_thresh: float 5.0, smooth_s: float 0.3) - np.ndarray: 返回布尔数组True 表示该采样点被判定为伪迹 n_smooth max(1, int(fs * smooth_s)) # 一阶差分近似信号变化幅度伪迹起始点差分值会瞬间跳高 diff np.abs(np.diff(eda, prependeda[0])) # 移动平均把突变点向两侧扩展成连续伪迹区段 kernel np.ones(n_smooth) / n_smooth diff_smooth np.convolve(diff, kernel, modesame) # z-score 标准化超过阈值判为伪迹 mu, std diff_smooth.mean(), diff_smooth.std() z (diff_smooth - mu) / (std 1e-6) return z z_thresh这段代码的设计逻辑是伪迹的起点在差分信号里是一个明显的尖峰但直接对原始差分值设阈值只会标记出三五个点无法覆盖整个动作区段。移动平均的意义在于把尖峰向周围扩散把整个连续异常区域包进来。z_thresh 取 4 到 6 比较常见取 5 表示“偏离平均水平 5 个标准差”对大多数静息态实验够用。smooth_s 控制扩散范围取 0.3 秒比较保守如果动作持续较长可以改成 0.5 秒。标记出伪迹后不要直接删除这些采样点否则波形长度变短SCR 峰的时间对齐会错乱。正确做法是插值替换把伪迹区段两侧的干净数据作为端点做线性插值尽量保持信号时间轴连续。如果一段情绪刺激窗口内伪迹比例超过 20%这段样本最好直接舍弃插值救不回来。3. 提取情绪特征时域形态、频域能量与窗口参数怎么定3.1 事件相关窗口从连续 EDA 到情绪片段的切片方法情绪识别实验通常按“刺激开始、情绪诱发、恢复”三个时间点打标签原始连续 EDA 必须切成长度相近的片段才能提取特征。常见做法是以刺激开始点为基准往前取 1 秒作为基线往后取 3 到 5 秒作为分析窗口。基线为什么要取前 1 秒SCR 响应一般滞后刺激 1 到 3 秒刺激开始前 1 秒的皮肤电导水平可以作为个体的基线。把分析窗口内的峰值减去基线均值得到的是“由刺激引起的相对变化”这比直接用原始电导值更抗个体差异。分析窗口短于 3 秒容易漏掉完整的 SCR 峰长于 5 秒则会混入刺激之后的自发恢复过程特征噪声变大。如果数据资料里没有刺激事件标记只能退而求其次用连续滑动窗口做样本增广。比如 2 秒窗口、50% 重叠把一条长序列切成几十段。这种做法的代价是相邻样本之间存在时间相关性训练时不能随机切分否则等于变相数据泄漏。3.2 时域与频域特征提取源码参数逐个说明情绪识别用的 EDA 特征大致分两类时域特征描述波形形态频域特征描述能量分布。下面这段代码覆盖了最常见的一组特征。from scipy.signal import welch, find_peaks import numpy as np def count_scr(seg: np.ndarray, fs: int, peak_thresh: float 0.02) - int: 统计片段内SCR峰数量 seg : 预处理后的EDA片段 grad np.abs(np.gradient(seg)) # 峰高阈值0.02峰间最小间隔0.5秒 peaks, _ find_peaks(grad, heightpeak_thresh, distanceint(fs * 0.5)) return len(peaks) def extract_features(seg: np.ndarray, fs: int 256) - dict: n len(seg) feat { mean: float(np.mean(seg)), std: float(np.std(seg)), range: float(np.max(seg) - np.min(seg)), max: float(np.max(seg)), t_peak: float(np.argmax(seg) / fs), # 峰值出现时间 n_scr: count_scr(seg, fs, peak_thresh0.02), } # 频域特征利用Welch法计算功率谱密度 freqs, psd welch(seg, fsfs, npersegmin(n, int(fs * 2))) for lo, hi, name in [(0.05, 0.2, psd_slow), (0.2, 0.5, psd_fast)]: mask (freqs lo) (freqs hi) feat[name] float(psd[mask].sum()) return featcount_scr 里 peak_thresh 取 0.02 是经验值单位是微西门子每秒的一阶导绝对值。你可以把它理解为“变化速度快到什么程度才算一个 SCR 峰”阈值设太小会把噪声起伏也算成峰设太大则漏掉弱响应。distance 参数设为 0.5 秒是为了避免一个宽峰被分裂成两个相邻峰。频域特征把功率谱分成慢变段0.05 到 0.2 Hz和快变段0.2 到 0.5 Hz分别求和。EDA 的主要能量集中在慢变段情绪唤醒度高时快变段能量占比会上升这两个特征结合起来能反映出“唤醒水平”而不仅仅是“有没有反应”。特征数量不需要贪多这 8 个特征配合原始值做标准化已经足够支撑一个多分类模型。3.3 特征选择为什么零方差特征会把模型带偏特征提完之后先别急着训练。EDA 特征里有几个天然的坑mean 和 std 在不同被试者身上差异巨大t_peak 可能出现大量重复值某些频段能量在所有样本上几乎相等。这类零方差或低方差特征会让模型的分裂节点变得不稳定。from sklearn.preprocessing import StandardScaler from sklearn.feature_selection import VarianceThreshold # 先标准化让所有特征在同一个量纲下比较方差 scaler StandardScaler() X_scaled scaler.fit_transform(X_feature_matrix) # 方差阈值设为1e-5滤掉近似常数特征 selector VarianceThreshold(threshold1e-5) X_sel selector.fit_transform(X_scaled) print(保留特征数, X_sel.shape[1])StandardScaler 的必要性在于mean 可能是几百微西门子n_scr 只有个位数如果不标准化模型的距离计算和梯度更新会被大数值特征主导。VarianceThreshold 的 1e-5 阈值没有标准答案你可以先看保留特征数如果原始 80 维被砍到只剩 20 维说明大多数特征没提供有效信息。另外两个特征高度相关比如 mean 和 max 的相关系数超过 0.9时建议只保留一个LightGBM 能容忍冗余特征但逻辑回归不行。4. 训练情绪识别模型分组切分、LightGBM 与评估指标4.1 按受试者分组切分数据跨个体验证不翻车的前提情绪识别数据集最大的坑就是同一个人的多次实验样本出现在训练集和测试集两边。人脸识别模型可以在同一人的不同照片之间学习身份信息情绪识别同样会被模型当作“身份识别”来学。同一个人的手汗水平和波形形态高度稳定模型不需要理解情绪只要记住这个人测试指标就会异常好看。from sklearn.model_selection import GroupKFold, cross_val_score from sklearn.ensemble import RandomForestClassifier # user_ids 是每个样本对应的受试者编号长度与X的行数一致 gkf GroupKFold(n_splits5) model RandomForestClassifier(n_estimators200, random_state42) scores cross_val_score(model, X, y, groupsuser_ids, cvgkf, scoringf1_weighted) print(GroupKFold F1:, scores.mean())GroupKFold 和普通 KFold 的区别在于它保证同一组的样本只出现在训练集或只出现在测试集。如果数据资料里没有明确的受试者编号可以用“会话开始时间戳”作为分组依据同一时间段采集的数据视为同一组。跨用户验证的结果才代表真实场景模型最终要面对的是没参与训练的新用户。4.2 用 LightGBM 训练情绪分类器参数怎么调表格型生理信号特征上LightGBM 通常比随机森林收敛更快、精度略高。后面这段代码是一个可以直接跑的分类训练流程。from lightgbm import LGBMClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import f1_score X_tr, X_va, y_tr, y_va train_test_split( X, y, test_size0.2, stratifyy, random_state42) model LGBMClassifier( n_estimators300, learning_rate0.05, num_leaves31, class_weightbalanced, random_state42, ) model.fit(X_tr, y_tr) y_pred model.predict(X_va) print(验证集 weighted F1:, f1_score(y_va, y_pred, averageweighted))几个关键参数的取值逻辑n_estimators 设为 300配合 0.05 的学习率既不会训练过久也能让每棵树贡献得足够小不容易过拟合。num_leaves 是 LightGBM 的核心复杂度控制项31 表示每棵树最多 31 个叶子对应树深度大约 5 层情绪识别样本量一般只有几千条叶子太多会过拟合。class_weight 设为 balanced 是让少数类样本的梯度贡献被放大。如果训练数据特别少把 n_estimators 降到 100learning_rate 保持 0.05否则验证集分数会波动很大。4.3 混淆矩阵与多分类指标PPT 里最有说服力的一张图情绪识别的评价指标不能只看准确率因为情绪类别天然不平衡。拿到模型预测结果后先看一眼混淆矩阵。from sklearn.metrics import confusion_matrix cm confusion_matrix(y_va, y_pred) print(cm)假设你的标签是三类积极、中性、消极那混淆矩阵是一个 3x3 矩阵。对角线上的数字是正确分类的样本数非对角线上的数字代表错误情况。值得注意的模式有两种一是相邻情绪类别之间混淆严重比如“高唤醒积极”和“低唤醒积极”经常混在一起这通常说明特征只反映了唤醒度没有反映效价二是某个类别的样本几乎全被分到另一类说明特征对该类别的区分力不足这时要回到特征工程而不是调模型参数。多分类场景下PPT 里建议放 three 行混淆矩阵热力图、weighted F1、每个类别的召回率。召回率比准确率更能反映用户感知一个从不把中性误判成恐怖的模型比总体准确率高了几个点的模型更实用。5. 避坑皮肤电信号情绪识别中最常见的五个翻车现场5.1 现象训练集 F1 接近 0.95换一批被试者直接掉到 0.6原因情绪诱发过程中被试者往往伴随身体动作伪迹和情绪标签在时间上高度重合。模型学到的高区分度特征可能是伪迹的形状而不是皮肤电信号本身的反应。解决回到预处理环节用 2.3 节的伪迹标记代码统计每个片段的伪迹占比。占比超过 20% 的样本删掉再把伪迹区段插值替换重新提取特征训练一次。如果模型性能依然骤降再看特征清单把所有高频相关特征剔除只保留 0.05 到 0.5 Hz 频段内的能量特征。5.2 现象所有样本都预测成中性情绪原因不同天的实验基线漂移严重被试者皮肤电导水平随作息、温度、出汗状态波动很大。模型学到的是“此人绝对电导水平高 积极情绪”而不是“电导相对基线上升了 积极情绪”。解决在特征提取时把每个片段的特征值减去该片段基线段的均值或者对每个被试者的全部特征做 z-score 标准化。后一种做法更激进但跨天场景下效果最好。碰到这种情况不要急着调模型参数先看样本特征分布里有没有明显的个体分群。5.3 现象交叉验证分数高得离谱但报告中不敢写原因训练时把同一个人的多个实验片段同时放进了训练集和测试集。虽然验证集没重叠但模型见过这个人的波形风格评价指标失真。解决使用 GroupKFold 按受试者编号分组。如果数据资料里没有编号按会话时间划分。分组后重新跑一遍分数你会发现指标通常会下降 10 到 20 个百分点这个下降幅度才是真实水平。5.4 现象准确率 80%但召回率只有 30%原因情绪类别数量不均衡比如中性样本占总样本的 60%模型只要全部输出中性就能拿到 60% 准确率。多分类任务里准确率是最容易误导人的指标。解决在模型里设置 class_weight 或 sample_weight把少数类样本的权重提上来评价时用 weighted f1 和每个类别的召回率。分类边界如果不合理再考虑对少数类做简单重复采样但注意不要用 SMOTE 生成伪样本EDA 特征空间里的插值样本可能在真实场景中不存在。5.5 现象两台不同设备记录的数据合到一起做训练效果不如单台设备原因A 设备采样率 256 Hz、量化精度高B 设备采样率 100 Hz、数据范围窄两台设备的绝对电导值和噪声底噪完全不同。解决统一把所有数据重采样到 128 Hz 或 256 Hz再做 min-max 归一化到 [0,1] 区间。更推荐的做法是先把每台设备的数据单独跑一遍基线模型确认单台设备没问题后再合并调整。合并后模型指标下降并不是算法问题而是数据分布被设备差异干扰了。6. 再进一步实时预测、TCN 模型结构与跨用户泛化6.1 滑动窗口叠加参数与预测平滑离线实验做完往实时方向走时会遇到一个明显问题相邻时间窗的预测结果来回跳变。解决办法是让滑动窗口的步长小于窗口长度比如窗口 2 秒、步长 0.25 秒让相邻窗口重叠 87.5%。然后把预测概率做指数平滑。# 每0.25秒调用一次pred_proba为当前窗口预测概率 alpha 0.6 smoothed_proba alpha * pred_proba (1 - alpha) * last_proba last_proba smoothed_probaalpha 越大跟随得越快抖动也越大alpha 取 0.6 是在实时响应和稳定之间比较折中的位置。如果做的是事后分析而不是实时反馈可以不加平滑保留原始预测结果。6.2 TCN 模型结构为什么适合 EDA 时序信号当特征工程不能满足需求时可以考虑直接让模型从预处理后的波形上学习。常见的替代方案是 LSTM 或 Transformer但 LSTM 训练慢、显存占用高Transformer 在小数据量上容易过拟合。TCNTemporal Convolutional Network用扩张因果卷积实现长时记忆kernel_size 取 3dilations 按 1、2、4、8、16 逐层递增感受野能覆盖数秒的 EDA 信号参数规模却比 LSTM 小很多。依赖关系通过残差连接跨层传递训练稳定性比深层 LSTM 好是一类适合低采样率生理信号的模型结构。6.3 跨用户验证才算真正能用这几年的血泪经验总结成一句话皮肤电信号情绪识别项目永远把跨用户验证放在优化模型之前。无论模型训练指标多漂亮先问一个问题——没参与训练的新用户预测分数能到多少这个数字才是算法真正能部署的水平。换人、换设备、换天的环境里仍然稳定的模型才有资格进入产品。希望这篇笔记能帮你把坑都踩在别人前面做出来的情绪识别模型不再只是“实验室里跑得漂亮”。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进