news 2026/9/28 2:26:12

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 数据。这里默认采样率 fs=256,原始波形以 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_size=win) # 2) 低通滤波:截止频率0.5Hz,滤掉运动伪迹和高频噪声 cutoff = 0.5 order = 2 b, a = butter(order, cutoff / (fs / 2), btype="low") eda = filtfilt(b, a, eda) # 3) 线性去趋势:消除电极基线漂移 eda = detrend(eda) return eda

中值滤波的窗口为什么取 1 秒:SCR 单峰宽度通常大于 1 秒,1 秒窗口足以区分真实峰和窄毛刺;窗口小于 0.5 秒时毛刺残留多,大于 2 秒时峰被压平。filtfilt 和普通 filter 的区别是零相位,波形上不会出现时间滞后——这一点对后续按事件切窗口很关键,滞后哪怕 100 毫秒都会让 SCR 峰位置偏移。detrend 默认用最小二乘法拟合线性趋势并减去,主要用于抵消电极极化或皮肤出汗造成的慢漂移。

如果你手里的数据采样率很低,比如只有 25 Hz,medfilt 的 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, prepend=eda[0])) # 移动平均把突变点向两侧扩展成连续伪迹区段 kernel = np.ones(n_smooth) / n_smooth diff_smooth = np.convolve(diff, kernel, mode="same") # 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, height=peak_thresh, distance=int(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_thresh=0.02), } # 频域特征:利用Welch法计算功率谱密度 freqs, psd = welch(seg, fs=fs, nperseg=min(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 feat

count_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(threshold=1e-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_splits=5) model = RandomForestClassifier(n_estimators=200, random_state=42) scores = cross_val_score(model, X, y, groups=user_ids, cv=gkf, scoring="f1_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_size=0.2, stratify=y, random_state=42) model = LGBMClassifier( n_estimators=300, learning_rate=0.05, num_leaves=31, class_weight="balanced", random_state=42, ) model.fit(X_tr, y_tr) y_pred = model.predict(X_va) print("验证集 weighted F1:", f1_score(y_va, y_pred, average="weighted"))

几个关键参数的取值逻辑:n_estimators 设为 300,配合 0.05 的学习率,既不会训练过久,也能让每棵树贡献得足够小,不容易过拟合。num_leaves 是 LightGBM 的核心复杂度控制项,31 表示每棵树最多 31 个叶子,对应树深度大约 5 层;情绪识别样本量一般只有几千条,叶子太多会过拟合。class_weight 设为 balanced 是让少数类样本的梯度贡献被放大。如果训练数据特别少,把 n_estimators 降到 100,learning_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_proba

alpha 越大,跟随得越快,抖动也越大;alpha 取 0.6 是在实时响应和稳定之间比较折中的位置。如果做的是事后分析而不是实时反馈,可以不加平滑,保留原始预测结果。

6.2 TCN 模型结构为什么适合 EDA 时序信号

当特征工程不能满足需求时,可以考虑直接让模型从预处理后的波形上学习。常见的替代方案是 LSTM 或 Transformer,但 LSTM 训练慢、显存占用高,Transformer 在小数据量上容易过拟合。TCN(Temporal Convolutional Network)用扩张因果卷积实现长时记忆,kernel_size 取 3,dilations 按 1、2、4、8、16 逐层递增,感受野能覆盖数秒的 EDA 信号,参数规模却比 LSTM 小很多。依赖关系通过残差连接跨层传递,训练稳定性比深层 LSTM 好,是一类适合低采样率生理信号的模型结构。

6.3 跨用户验证才算真正能用

这几年的血泪经验总结成一句话:皮肤电信号情绪识别项目,永远把跨用户验证放在优化模型之前。无论模型训练指标多漂亮,先问一个问题——没参与训练的新用户,预测分数能到多少?这个数字才是算法真正能部署的水平。换人、换设备、换天的环境里仍然稳定的模型,才有资格进入产品。希望这篇笔记能帮你把坑都踩在别人前面,做出来的情绪识别模型不再只是“实验室里跑得漂亮”。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 2:19:22

诗风秦韵诗词学习话廊“1+7管理模式”

1个理念&#xff0c;7个步骤。 1个理念&#xff1a;1、培养一群善于解决问题的组员&#xff0c;而不是自己去解决所有问题。 7个步骤&#xff1a;1、创建舒服的创作环境&#xff0c;让组员有更好的积极性、创造性去解决问题。2.调节组员的情绪&#xff0c;让组员从积极的角度看…

作者头像 李华
网站建设 2026/9/28 2:16:04

STM32开发参考方案全梳理:从环境搭建到资料平台,避开常见坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华