皮肤电信号情绪识别实战:从预处理到模型选型的完整链路
发布时间:2026/9/28 1:47:32来源:尧图网络
简介面向机器学习初学者及生物信号处理相关课题研究者这套基于Python皮肤电信号EDA的情绪识别算法资料包提供了一条从数据读取、特征构造到模型训练与评估的完整实现路径。压缩包内共102个文件以78个CSV情绪标注样本含快乐、中性、悲伤等类别、7个Python源码、1份讲解PPT、1份Markdown详细文档和4个MATLAB脚本为主整体仅1.91MB轻量便于部署。源码均已在本地编译验证评审得分95分以上且经过助教老师审定难度适中适合课程设计、毕业设计或作为入门情绪识别算法的实践参考。配套文档对算法原理、数据处理和实验步骤做了细致说明能够帮助使用者快速复现实验深入理解EDA信号与情绪状态之间的映射逻辑。目前已有217人浏览学习需要可直接运行、结构清晰的完整项目模板时这份资料值得下载。1. 皮肤电信号做情绪识别为什么这条技术路线最适合落地皮肤电信号EDA也叫皮电反应GSR做情绪识别是穿戴式场景里少数能直接落地的生理信号方案。运动手环的压力分数底层就是皮肤电导率变化人紧张时交感神经让汗腺分泌增加皮肤导电性上升刺激出现后1到3秒内就能被传感器捕捉。因此它识别的是情绪唤起度不是高兴还是难过这类效价。这个边界决定了信号处理、模型选型和评估方式也圈定了适用人群做情绪计算研究的学生、做智能穿戴与心理评估的工程师。标题这套基于Python皮肤电信号的情绪识别算法源码模型PPT详细文档全部数据资料.zip覆盖从原始信号到模型落地的完整链路。我拿到这类资料不急着跑代码先核对标注协议和验证方式——皮肤电项目翻车九成翻在数据处理和评估上。2. 从原始EDA到可训练特征信号清洗与滑动窗口的两个关键处理2.1 强直成分与相位成分为什么必须先拆开再算特征皮肤电信号不是一条平滑曲线它混合了两种生理成分。强直成分tonic又叫皮肤电导水平SCL是缓慢变化的基线频率集中在0到0.2Hz反映整体唤醒水平一个人从平静进入持续紧张基线会慢慢抬高。相位成分phasic叫皮肤电导反应SCR是叠加在基线上的一次性突起持续2到5秒刺激出现后0.5到1.5秒开始上升这才是情绪诱发出来的直接反应。如果不先拆开这两种成分直接在原始曲线上算均值、方差结果会被基线漂移带着走——被试在空调房里坐十分钟基线缓缓下降特征里混进的全是温度漂移的假象。常见拆分做法有两种。第一种是滤波0.2Hz以下低通滤出强直成分原始信号减掉强直成分得到相位成分计算量小、可复现性高。第二种是cvxEDA这类凸优化分解把信号拆成强直、相位和噪声三项分解更干净但对每条信号要做迭代优化样本量大时处理时间明显变长。我一般先用滤波版本跑通全流程确认基线漂移真的影响分类效果再决定要不要换cvxEDA。工程上先跑通再优化比一上来追求算法精致更划算。import numpy as np from scipy.signal import butter, filtfilt def split_eda(eda, fs, split_freq0.2): 把原始皮肤电信号拆成强直成分和相位成分 eda : 皮肤电导序列单位微西门子uS fs : 采样率穿戴设备常见 4~100 Hz split_freq : 拆分频率经验范围 0.05~0.2 Hz b, a butter(2, split_freq, btypelow, fsfs) tonic filtfilt(b, a, eda) # 强直成分对应SCL基线 phasic eda - tonic # 相位成分对应SCR反应 return tonic, phasic这里两个参数直接决定特征质量。split_freq取0.2Hz是文献里的常见起点SCR的功率密度集中在0.2到2Hz低于0.2Hz的部分基本属于基线这个截止值既能保留完整SCR形态又能滤掉大部分基线漂移如果拆出来的相位成分里还有明显慢起伏就往0.05Hz方向调。filtfilt是零相位滤波普通lfilter会引入相位偏移把SCR峰值位置推移后面的时间对齐标签就会错位。皮肤电预处理里可复现性比技巧重要参数固定下来后同一份数据每次跑都要得到完全一致的结果。2.2 滑动窗口怎么切窗口时长、重叠率与情绪刺激的对应关系拆完成分后长序列要切成分类器能吃的样本。滑动窗口切分是皮肤电项目里的默认做法它同时扮演两个角色把连续生理信号转成独立样本通过重叠做数据增强。窗口时长取决于情绪刺激协议看视频诱发情绪时单段刺激通常持续5到10秒窗口取5秒能覆盖一次完整SCR反应静息态对比场景窗口放宽到10到30秒把稳定的SCL统计进来。重叠率取50%到75%重叠让相邻窗口的特征连续模型输出随时间更平滑触发判断不容易抖动。常见误用是把信号切成不重叠的窗口。那样样本量只有重叠方案的四分之一到二分之一情绪状态的边界还被固定切点切断模型学到的不是情绪特征而是切割位置的特征。窗口设计上还有一个硬性要求窗口不能跨越两个不同刺激段否则一个样本里混了两种情绪标签。def sliding_windows(signal, win_len, overlap0.5): 滑动窗口切分信号 signal : 一维信号 win_len : 窗口长度点数 overlap : 重叠比例0 表示不重叠建议 0.5~0.75 step max(1, int(win_len * (1 - overlap))) n len(signal) starts np.arange(0, n - win_len 1, step) return np.array([signal[s:s win_len] for s in starts])注意overlap到0.75以上时收益骤减样本量翻倍但相邻样本高度相似交叉验证里数据泄漏风险同步上升。我一般固定在50%数据量实在不够做增强时再上75%。切窗之后按刺激时间轴给每个窗口打标签这段逻辑单独写一个函数把刺激段表start, end, label和窗口起点对齐避免后面想换窗口参数时标签逻辑跟着改。2.3 组装预处理流水线从CSV到干净的特征矩阵预处理在项目里应该是纯函数式的输入原始CSV路径输出特征矩阵和标签数组中间不产生任何随机性。用pycharm配置python环境时给这个项目单独建一个虚拟环境把numpy、scipy、pandas固定版本写进requirements.txt不然半年后换机器复现版本漂移会直接改变滤波结果。import pandas as pd from scipy.signal import resample def load_eda_csv(path, target_fs25): 读取皮肤电原始CSV并统一采样率 df pd.read_csv(path) # 常见列名timestamp, eda / time, skin_conductance按实际数据调整 t df[timestamp].to_numpy() x df[eda].to_numpy() fs_actual 1.0 / np.median(np.diff(t)) if abs(fs_actual - target_fs) 1: keep np.diff(t) np.median(np.diff(t)) * 3 # 丢掉设备断连的大缺口 x x[keep] if len(x) target_fs * 10: # 可用数据过短就整段放弃 return None, None num int(len(x) * target_fs / fs_actual) x resample(x, num) return x, target_fs这里时间戳检查是很多人跳过的步骤。皮肤电设备蓝牙一旦断开数据里就会出现缺段直接按整数下标做滤波和谱估计频率全是错的。resample要求输入等间隔所以重采样前先按时间戳过滤掉缺口如果整段有效数据不足10秒直接返回None宁缺毋滥用插值填充出来的假信号会训练出设备断连识别器。流水线最后输出的特征矩阵形状是样本数特征数直接交给下一章的分类器。3. 特征工程与模型选型把皮肤电信号变成情绪分类器的完整流程3.1 时域特征提取SCR峰值、上升时间、半恢复时间时域特征是皮肤电分类里信息量最高的一组。先做SCR峰值检出再统计峰个数、平均幅度、平均上升时间和半恢复时间。峰值检出用scipy.signal的find_peaks就能做但要理解皮肤电峰的形态它上升快、回落慢、不对称所以除了幅度阈值还要限制峰间距否则同一个SCR的上升沿和回落沿会被重复计数。from scipy.signal import find_peaks def time_domain_features(phasic, fs): 从相位成分提取时域特征 peaks, props find_peaks(phasic, height0.02, distanceint(fs)) if len(peaks) 0: return dict(scr_count0, scr_amp0.0, scr_rise0.0, scr_half0.0, phasic_meanfloat(np.mean(phasic))) amps props[peak_heights] rises, halves [], [] for peak_idx, amp in zip(peaks, amps): # 上升时间从幅度达到10%的位置算起到峰值 seg phasic[:peak_idx] idx_below np.where(seg 0.1 * amp)[0] onset idx_below[-1] if len(idx_below) else 0 rise (peak_idx - onset) / fs rises.append(rise) # 半恢复时间峰值后回落一半经过的时间 after phasic[peak_idx:] idx_half np.where(after 0.5 * amp)[0] if len(idx_half) and rise 0: halves.append(idx_half[0] / fs) return dict(scr_countlen(peaks), scr_ampfloat(np.mean(amps)), scr_risefloat(np.mean(rises)), scr_halffloat(np.mean(halves)) if halves else 0.0, phasic_meanfloat(np.mean(phasic)))幅度阈值0.02uS是文献常用起点峰间距限制为1秒假设两次独立SCR之间不可能短于1秒。上升时间用幅度达到峰值10%的位置作为起点是个简化严格定义要用信号导数拐点但工程上10%阈值已经够稳定对分类F1的影响可以忽略。窗口内没有检出SCR时五个特征全部置0并显式返回不能跳过否则特征矩阵形状对不上。情绪识别特征提取里最常见的低级错误就是某个窗口提不出特征就跳过等训练时才报维度不一致回头查半天。3.2 频域与非线性特征真正起作用的频段频域特征里最值得提取的是0.05到0.5Hz频带的功率和占比而不是全频带能量。人的SCR主能量集中在这个频段做welch谱估计时单段信号至少要覆盖4到8秒否则频率分辨率不够频带能量泄漏到相邻频点。样本熵这类非线性特征能反映信号复杂度对情绪状态区分有增量但计算开销比时域特征高一个量级样本量大时要先降采样再做。from scipy.signal import welch def spectral_features(phasic, fs): 提取频带能量特征与占比 nperseg min(fs * 8, len(phasic)) # 窗口至少覆盖8秒 f, pxx welch(phasic, fs, npersegnperseg) band (f 0.05) (f 0.5) band_power np.trapz(pxx[band], f[band]) total np.trapz(pxx, f) return dict(eda_band_powerband_power, eda_band_ratioband_power / total if total 0 else 0.0, eda_psd_meanfloat(np.mean(pxx[band])))特征拼成矩阵后先用python数据分析与可视化确认质量按情绪类别画特征分布箱线图或者用t-SNE把特征降到二维看一眼能不能分开。这一步花十分钟能避免很多白训模型的时间。之后做标准化人的皮肤电绝对值差异极大有人基线2微西门子有人10微西门子不标准化的话分类器学的是这个人是谁而不是他什么情绪。标准化选全局还是被试内也很关键全局标准化方便但跨被试场景下效果差被试内标准化每个被试先减自身均值、除自身标准差明显更好代价是预测时要先拿到这个人一段基线数据。穿戴产品通常不存在这个问题因为设备本来就在持续采集。3.3 模型选型特征模型、LightGBM与TCN的边界在哪特征矩阵做好后模型选择的边界非常务实。样本量在几百到几千条时树模型和核模型的稳定性远超深度学习调参成本也低。LightGBM在表格特征上表现很稳还能输出特征重要性帮你排查哪条特征提取链路出了问题SVM配合RBF核和class_weightbalanced在类别不均衡的小样本上泛化性好。这两个模型是皮肤电情绪识别的默认起点直接拿原始波形上深度模型反而是新手最常踩的坑。TCN这类序列模型真正发挥价值的地方是不做窗口切分、直接对连续信号做逐点预测的场景。TCN的因果空洞卷积让感受野随层数指数扩展几层就能覆盖几十秒上下文参数量比LSTM小普通CPU就能推理——皮肤电情绪识别模型是轻量级方向的代表根本用不到高显存和那些动辄几十GB的大模型完全是两个世界。如果你的数据本身是人为主观标注的刺激段窗口已经对齐了刺激特征也已经提完模型面对的其实是表格分类问题序列模型的优势就没了。模型输入适用样本量调参成本部署成本适用场景SVMRBF特征向量200~2000低低小样本、类别不均衡LightGBM特征向量1千~10万低低需要特征重要性、做基线1D-CNN原始波形每类1000以上中中固定刺激段TCN/LSTM原始序列每类2000以上高中连续情绪流、流式预测我个人的默认做法是LightGBM和SVM同时跑F1接近就选LightGBM因为特征重要性可以直接辅助排查等样本量积累到每类上千条再引入TCN做流式预测对比。这个顺序能在大多数项目里用最低成本拿到可信基线。4. 训练与调参皮肤电情绪识别模型的4个关键参数与评估方式4.1 SCR检出阈值定错它特征全错find_peaks的height参数就是这个阈值。它决定多大的波动算一次有效SCR。阈值太高小反应被忽略两类样本在SCR数量上的差异被抹平阈值太低噪声抖动被当成反应特征里全是假峰。文献常用0.01到0.05uS具体取决于电极类型、皮肤状态和采样设备。我一般用一个校准流程取一段已知强刺激比如惊吓视频后的数据如果刺激后1到3秒内检不出峰说明阈值偏高反过来平静基线段频繁出峰说明阈值偏低。这个阈值应该写进文档因为它是整个特征工程里牵一发动全身的参数。4.2 窗口时长与重叠率样本量的放大器窗口时长影响的不只是样本量还有特征完整性。短于3秒的窗口里SCR上升期都没走完上升时间和半恢复时间特征被截断提取出来的是残缺特征。窗口太长又容易跨刺激边界。重叠率在这里扮演数据增强的角色但注意重叠率越高相邻样本相关性越强交叉验证中的信息泄漏也越严重。所以重叠率不是越高越好50%是大部分项目的合理点数据不够再升到75%同时用分组交叉验证来兜底。这两个参数在调参顺序上要排在最前面因为它们先于模型决定样本质量和数量。4.3 交叉验证方案按被试分组不做就是白训皮肤电情绪识别最隐蔽的陷阱是数据泄漏。同一个被试的相邻窗口高度相关如果随机划分训练集和测试集同一个人的样本同时出现在两侧模型记住的是个体特征分数虚高。正确做法是按被试ID分组做交叉验证这也是这套资料里详细文档最值得核对的一页。from sklearn.model_selection import GroupKFold, cross_val_score from sklearn.svm import SVC X features # (样本数, 特征数) y labels # 情绪类别 subject_ids groups # 每个样本对应的被试ID长度与X相同 model SVC(C1.0, kernelrbf, gammascale, class_weightbalanced) gkf GroupKFold(n_splits5) scores cross_val_score(model, X, y, cvgkf, groupssubject_ids, scoringf1_macro) print(f被试独立5折验证 F1(macro) {scores.mean():.3f} ± {scores.std():.3f})这个问题的本质和量化交易里的时序回测一模一样金融时间序列里如果让未来数据泄漏进训练集回测绩效虚高实盘直接归零皮肤电实验里的未来数据就是同一个被试的相邻窗口两者的防护手段都是严格的组划分。如果被试数少于5个GroupKFold会报错改成留一被试法LeaveOneGroupOut配合多次随机种子取平均避免单次划分的偶然性。4.4 评估指标别只看准确率情绪诱发实验里标签通常不均衡积极片段往往比消极片段多。模型全预测积极类准确率也有七成但没有任何实际价值。二分类看F1和AUC多分类看F1_macro这是基线如果目标是产品级的焦虑监测还要看事件级命中率和检出延迟。窗口预测结果要做时间平滑连续3个窗口都判定为高唤起才触发一次报警这样能滤掉孤立窗口的误报。这组指标的定义要在实验前定好否则结果出来后挑一个最好看的指标写结论就是典型的论文式自欺。5. 皮肤电情绪识别排错与避坑5个高频翻车点的排查记录5.1 基线漂移把分类器带偏模型学到的是室温不是情绪现象训练时F1超过0.85隔天换一批被试采集的数据放上去直接掉到0.5附近。原因SCL基线受环境温度、空气湿度和身体活动影响很大空调房和高温房间的基线能差好几个微西门子。特征里直接使用SCL绝对值时模型学到的是采集环境不是情绪状态。解决把特征换成窗口内SCL变化量或者做被试内标准化。条件允许的话把环境温湿度也记录下来作为协变量做回归补偿。这道工序做完跨时段稳定性通常能回来一大截。5.2 自我报告标签不靠谱标注噪声让模型怎么调都追不上现象loss降不下去验证集分数来回跳换随机种子结果剧烈波动。原因情绪标签用自我报告量表采集不同人对同一刺激的主观感受差异极大。有人觉得恐怖片段可怕有人觉得好笑同一个人在不同状态下看同一段视频打分也不一样。标签噪声大模型每轮都在学不同的目标。解决一致性筛选保留多次标注结果稳定的样本把五分类改成三分类甚至二分类降低标签噪声影响回归方案改成排序方案利用标签的序关系而不是精确数值。宁可少而干净的训练样本不要多而混乱的。5.3 运动伪迹伪装成SCR特征重要性第一名的假象现象模型在某个类别上准确率异常高特征重要性排序里SCR数量排第一。原因手部动作、电极线晃动产生的尖峰和SCR形态相似频率特征也接近。放松任务里被试一挠头或一伸懒腰就制造出大量假SCR。解决同步采集加速度信号加速度变化大的时间窗直接剔除没有加速度计就用幅度突变率检测。def mark_artifact(eda, fs, thresh3.0): 用滑动窗口极差标记运动伪迹返回布尔掩码 win int(fs * 2) # 2秒窗口 step win // 2 # 50%重叠 ranges [] for i in range(0, len(eda) - win, step): ranges.append(np.ptp(eda[i:i win])) thr thresh * np.median(ranges) mask np.zeros(len(eda), dtypebool) for k, r in enumerate(ranges): if r thr: s, e k * step, k * step win mask[s:e] True return mask注意伪迹剔除要在切窗之前做否则窗口边界会保留半个伪迹特征照样被污染。5.4 跨被试验证直接失效性能断崖式下跌现象按被试分组交叉验证F1比随机划分低20个点甚至接近随机猜测。原因随机划分时同一被试的数据同时进训练和测试模型记住了被试身份跨被试时个体差异大于情绪差异模型就失效了。解决改用GroupKFold或LeaveOneGroupOut。分组后性能真的接近随机先确认特征是否做了被试内标准化做了还不行说明当前特征集合不跨被试要回退到相对变化量特征或者扩大被试样本量。这个检查应该写进每个皮肤电项目的验收清单。5.5 小样本过拟合训练集95%验证集62%现象训练集F1很高验证集一直上不去调参、加模型复杂度都没有改善。原因一次情绪诱发实验常常只有几十个被试、每类几百条样本提取特征后又拼出几十维特征数量接近样本量模型自由度过高。解决先用带L1正则的Logistic回归或LinearSVC跑基线限制特征数量在样本量的十分之一以内给实验结果附加多次随机种子的均值与方差不要把单次划分的分数当结论。数据不够时任何复杂模型都是在表演过拟合。6. 进阶验证三模型同基线对比与留样回归6.1 三模型在同一份特征矩阵上公平对比公平对比的前提是共用同一份特征矩阵和同一套折划分。把特征、标签、被试ID存成npy三个模型直接在上面跑import numpy as np from sklearn.model_selection import cross_val_score from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier import lightgbm as lgb models { svm: SVC(C1.0, kernelrbf, class_weightbalanced), rf: RandomForestClassifier(n_estimators300, n_jobs-1), lgb: lgb.LGBMClassifier(n_estimators300, learning_rate0.05), } for name, m in models.items(): sc cross_val_score(m, X, y, cvgkf, groupssubject_ids, scoringf1_macro) print(f{name}: {sc.mean():.3f} ± {sc.std():.3f})跑之前先配好环境pycharm里为这个项目单独建虚拟环境再装依赖避免系统Python被污染lightgbm安装慢就换国内镜像源这是python新手最常见的安装翻车点。如果三个模型F1接近优先选LightGBM做特征排查选SVM做上线理由前面说过特征重要性辅助排查RBF核小样本更稳。6.2 留样回归给自己留一版后悔药我现在每个皮肤电项目都会锁一份golden dataset和一份固定评估脚本。任何特征提取改动、模型参数改动先跑golden test分数不低于上一版才允许合入代码。这份资料的详细文档正好用来记录参数预处理、特征、模型的每个参数写进去后面复现的人就不用靠猜。皮肤电项目里能复现三个字值一半的分数数据、标签、参数、随机种子全部固定下来的那套流程才是这个方向真正能沉淀下来的资产。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网