
简介面向情绪识别与生理信号处理学习者的完整项目资料包内含基于Python皮肤电信号的情绪识别算法源码、训练好的模型、答辩PPT、详细说明文档及全部数据集适用于课程设计、毕业设计或算法入门源码均经过本地编译运行评审分95分以上难度适中。压缩包共102个文件以78个csv数据文件为主另有7个py源码、6个xml配置、4个m脚本、1个pptx演示文稿、1个md文档及1个mat数据文件等整体仅1.91MB结构清晰便于按需查阅。已有215人学习下载。资源覆盖数据预处理、特征提取、模型训练与评估等关键环节csv数据可直接用于复现实验py源码便于二次开发PPT和文档可辅助快速理解算法与答辩汇报。适合希望快速上手皮肤电信号情绪识别项目的读者。1. 用Python做皮肤电信号情绪识别为什么第一步不是调模型用Python做皮肤电信号EDA/GSR的情绪识别第一步往往不是调模型而是先认识这条信号本身。EDA记录的是情绪波动时手掌汗腺的分泌变化交感神经一兴奋皮肤导电率跟着变。比起摄像头微表情这条生理信号路线的好处是抗表情伪装、不容易被主观压制。市面上做情感计算的不少但多数聚焦视频和文本皮肤电信号项目能完整交付的很少。适合谁读算法工程师、可穿戴产品开发者、情感计算与心理学方向的学生。源码、模型、PPT和文档意味着你拿到的不只是一个分类器而是一条从裸信号到情绪标签的完整链路。接下来就从EDA本身的特点出发把最容易出错的坑一次说清。2. EDA信号预处理先把出汗量变化转成干净的时间序列2.1 为什么EDA要先处理不做会发生什么和心电ECG、脑电EEG相比EDA的最大特点是“慢”。皮肤电导反应SCR的上升时间通常在一两秒回落时间能跨5到10秒皮肤电导水平SCL更是以数十秒为单位缓慢漂移。如果把ECG那套1Hz到30Hz高通滤波器照搬过来绝大多数EDA信息直接被滤没了只剩下噪声。更麻烦的是运动伪迹。手环、腕表这类消费级设备采集时擦碰和握持会产生幅度极高的尖峰。这些尖峰在波形上很像一次短促的SCR模型很容易学到“手动了就是情绪激动”。预处理不到位后面再接什么模型都是在噪声上拟合。这个模块输出的应该是一条受过试者内校准、没有尖峰、时间轴和标签对齐的干净曲线。2.2 最小清洗流程加载、滤波、去伪迹下面的代码只依赖numpy、scipy和pandas把一列原始EDA和标签文件读进来再统一到20Hz。项目里常见的数据格式是CSV列名可能是timestamp, eda, label也可能是gsr, event改一下列名即可。import numpy as np import pandas as pd from scipy import signal def load_eda(path, fs20.0): 从csv读入eda和标签返回统一采样率的时间序列 df pd.read_csv(path) # 假设csv有列: timestamp, eda, label df[timestamp] pd.to_datetime(df[timestamp]) # 统一重采样到20Hz防个别设备采样率漂移 df df.set_index(timestamp).resample(f{int(1000/fs)}ms).mean().interpolate() return df[eda].values, df[label].ffill().values def preprocess_eda(eda, fs20.0): # 1) 中值滤波去除运动伪迹尖峰 eda signal.medfilt(eda, kernel_size5) # 2) 0.05Hz高通滤波去掉基线漂移 sos_high signal.butter(2, 0.05/(fs/2), highpass, outputsos) eda signal.sosfiltfilt(sos_high, eda) # 3) 2Hz低通滤波去掉工频及高频肌电 sos_low signal.butter(2, 2.0/(fs/2), lowpass, outputsos) eda signal.sosfiltfilt(sos_low, eda) return eda代码第一个关键点是重采样。很多可穿戴设备标称20Hz实际是20.0x或19.8x Hz长时间记录会让时间轴累积漂移情绪标签边界对不齐。第二个关键点是sosfiltfilt而不是lfilter前者是零相位滤波不会让反应事件整体移位。参数说明kernel_size5滤掉孤立尖峰同时保留SCR的慢形状高通截止频率取0.05Hz只去除刚戴上电极时的指数漂移不要调到0.5Hz以上那会把SCL的有效信息切掉低通2Hz对多数光电式或干电极设备够用采样率更高的设备可以升到5Hz。代码对Python版本要求不高Python环境配置成3.8以上即可避免pandas的resample行为在不同版本间出现偏差。2.3 运动伪迹和个体校准几乎所有人第一步就会做错EDA数据里的伪迹常见三类高频跳变、尖峰、基线整体台阶变化。处理基线台阶时有人会去开一个50Hz陷波器这对EDA完全没用因为信号能量几乎全部在1Hz以下陷波器只会引入新的相位畸变。更可靠的做法是检测差分阈值把变化斜率超过正常SCR物理上限的样本替换成邻域中值。正常SCL变化率一般不超过每秒2到3微西门子运动尖峰的差分会高出几十倍。个体差异是另一个容易被忽略的问题。不同人的手掌出汗基线差别极大有人安静状态SCL是2μS有人是20μS。如果直接把原始电导值送进分类器模型学到的可能是“这个人手汗多”而不是“这个人有情绪”。常见做法是每个被试内部做z-score把电导值除以这个人自身标准差让模型聚焦相对变化。代码加一行就够了eda (eda - np.mean(eda, axis-1, keepdimsTrue)) / (np.std(eda, axis-1, keepdimsTrue) 1e-6)注意在z-score之前完成重采样和滤波否则尖峰会把标准差拉大让正常反应被压扁。2.4 信号质量检查别让坏段进训练实际项目中我不会把全部样本都交给模型。采集过程中电极可能松动、连接线可能虚接出现大段平直或饱和的信号。简单做法是切段后统计一阶差分def quality_ratio(seg, fs20.0, thr0.5): diff np.abs(np.diff(seg)) return np.mean(diff thr)如果比值大于0.1认为这段是低质量信号。处理方式有两种一是直接删除适合数据量充足二是保留并把一个low_quality布尔特征塞进特征表让模型学会忽视坏段。后者在连续可穿戴场景更好能避免时间轴断裂。质量检查放在重采样之后、z-score之前执行标记结果作为样本权重或特征参与训练。这个顺序很重要如果整段已经做了个体标准化坏段的方差会污染全体数据。3. 特征工程从时域、频域和统计量里榨出情绪线索3.1 为什么EDA特征不能只取均值方差如果把整段EDA按窗口取均值和方差得到的只是“手上总出汗量”和情绪起伏关联很弱。行业里把EDA拆成SCL和SCR两层SCL是几十秒尺度的缓慢基线反映唤醒水平的底色SCR是秒级的小突起代表一次具体情绪唤起的生理反应。SCL均值相同的两个人一个可能是稳定平静另一个可能是持续紧张只看均值分不出来。所以特征工程要么用成熟库自动分解要么自己按“慢基线 残差”拆开。移动设备的信号噪声大自动分解经常失效我一般会手动算SCL移动平均再用原始信号减掉它得到SCR残差在此基础上统计。窗口长度通常取60秒SCR事件才够多太短的窗口只剩下噪声。3.2 特征提取代码和参数怎么设def extract_features(eda, fs20.0, window_s60): 从一段信号里提取EDA特征向量 eda: 预处理后的原始信号 window_s: 特征窗长度 win int(window_s * fs) n_windows len(eda) // win records [] for i in range(n_windows): seg eda[i*win:(i1)*win] t np.arange(len(seg)) / fs # SCL基线60s移动平均 scl np.convolve(seg, np.ones(300)/300, modesame) scr seg - scl # SCR响应检测阈值取SCR标准差的两倍 thr 2 * np.std(scr) peaks, props signal.find_peaks(scr, heightthr, distancefs*0.5) features { scl_mean: np.mean(scl), scl_slope: np.polyfit(t, scl, 1)[0], # 整体趋势 scr_std: np.std(scr), n_peaks: len(peaks), # 每窗SCR个数 peak_mean_amp: np.mean(props[peak_heights]) if len(peaks) 0 else 0, peak_rise_time: np.mean(np.diff(peaks)/fs) if len(peaks) 2 else 0, } records.append(features) return pd.DataFrame(records)这里的移动平均窗口300个点在20Hz下正好15秒。这条基线比直接整个窗口平均更符合SCL的慢变属性。find_peaks的阈值用2倍标准差适合二分类唤醒度任务如果是维度回归建议降到1.5倍捕获更弱的反应。distancefs*0.5限制两个峰值间隔至少0.5秒防止把单个SCR的毛刺数成多个事件。peak_rise_time这里用的是相邻峰间隔作为情绪唤起节奏的粗略估计。真要做严格的SCR形态学还要算每个峰值的上升时间、半恢复时间数据量大时可以用neurokit2补齐这部分。关键是特征种类不必多但要覆盖“基线水平、基线趋势、事件密度、事件幅度、事件节奏”这五个角度。想覆盖频域的话可在特征字典里追加一段freq, psd signal.welch(scr, fsfs, npersegmin(len(scr), 256)) features[psd_045_25] psd[(freq 0.045) (freq 0.25)].sum()0.045到0.25Hz这个频段对应SCL的慢波振荡和情绪唤醒度的相关性在文献里反复出现比直接算整段方差更能体现短时波动。3.3 特征该交给什么模型可解释性优先特征维度不高时常见做法是直接上随机森林或逻辑回归。如果把算法流程图按三层去画——清洗层、特征层、分类层——你会发现情绪识别并不是某个模型的功劳而是三层共同作用的结果。EDA特征之间的关联大多是线性的逻辑回归配标准化通常能到基线水平随机森林再往上提几个点。这时候不需要堆神经网络特征可解释性带来的排错价值远大于那零点几的准确率。特征提取完不要急着训练先看一次随机森林特征重要性。如果模型反复忽略SCR相关特征说明预处理阶段峰值检测阈值有问题如果只依赖SCL均值可能是窗口太大。特征重要性在这里不是用来筛特征的而是当作排错的仪表盘。3.4 数据划分一组人的信号不能同时出现在训练和测试里情绪识别项目里最隐蔽的“假高分”来自数据划分错误。同一被试的连续记录有很强的个体一致性随机切分意味着训练集见过这个人一半数据测试分数虚高。正确做法按被试分组用GroupKFold做交叉验证。from sklearn.model_selection import GroupKFold from sklearn.ensemble import RandomForestClassifier from sklearn.metrics import f1_score, accuracy_score X feature_df.drop(label, axis1) y feature_df[label] groups feature_df[subject_id] # 每个被试唯一编号 gkf GroupKFold(n_splits5) for train_idx, test_idx in gkf.split(X, y, groups): X_train, X_test X.iloc[train_idx], X.iloc[test_idx] y_train, y_test y.iloc[train_idx], y.iloc[test_idx] clf RandomForestClassifier(n_estimators100, max_depth8, random_state42, class_weightbalanced) clf.fit(X_train, y_train) y_pred clf.predict(X_test) print(fAcc{accuracy_score(y_test, y_pred):.3f} F1{f1_score(y_test, y_pred, averagemacro):.3f})注意GroupKFold.split的第三个参数是groups不是标签。如果把y当作groups传进去跨被试划分就失效了。max_depth8比默认值小因为特征数量少、取值范围窄树太深只会记住训练集的个体噪声。n_estimators100在这个量级下够用再翻倍不会带来明显收益反而拖慢交叉验证。class_weightbalanced在类别不平衡时尤其有用紧张样本通常远少于平静样本不加权重模型会倾向于把所有人都判成多数类。4. 模型构建与调参从随机森林到轻量网络4.1 二分类和多分类要先分清EDA信号能可靠区分的其实是唤醒度calm/aroused而不是效价positive/negative。紧张和激动的SCR形态非常接近光靠皮电很难区分。如果数据标签是离散情绪平静、愉快、紧张、悲伤算法上通常先转成二分类或三分类唤醒度任务再在外面接规则或文本特征。多分类可以跑但要接受准确率上限比二分类低不少这不代表代码有问题是模态本身的限制。4.2 三种基线模型一次性跑完特征表出来后第一步不是调参而是横向比较几组基线。RandomForestClassifier、LogisticRegression加标准化和浅层梯度提升。下面代码基于上一节的gkf继续跑from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline models { rf: RandomForestClassifier(n_estimators200, max_depth10, random_state42, class_weightbalanced), lr: make_pipeline(StandardScaler(), LogisticRegression(max_iter1000)), } for name, model in models.items(): scores [] for train_idx, test_idx in gkf.split(X, y, groups): model.fit(X.iloc[train_idx], y.iloc[train_idx]) scores.append(accuracy_score(y.iloc[test_idx], model.predict(X.iloc[test_idx]))) print(f{name}: {np.mean(scores):.3f} ± {np.std(scores):.3f})逻辑回归做标准化是为了把SCL绝对电导值的量纲压掉模型会倾向于学习相对变化形状。两组分数如果接近说明手工特征里的信息基本是线性的后面再上复杂模型也只是微调。如果随机森林明显优于逻辑回归才需要考虑特征交互或非线性边界的因素。4.3 必调参数一张表说清范围参数作用合理区间n_estimators树数量100~300max_depth树深6~12min_samples_leaf叶节点最少样本5~20cnn_kernel卷积核长度0.5~2秒对应样本数cnn_filters卷积通道数16~64dropout丢弃率0.2~0.5在这个尺度下把划分策略和预处理做好比开着Optuna搜一晚上划算得多。数据泄漏没解决的时候调参只是在放大误差。4.4 用轻量1D-CNN处理窗口数据如果资料包里提供深度模型多半是这种小型1D卷积网络输入是原始信号窗口而不是特征表。下面是一个可在PyTorch里跑的骨架窗口取20秒20Hz下正好4000个点import torch import torch.nn as nn class EDACNN(nn.Module): def __init__(self, n_classes2): super().__init__() self.conv nn.Sequential( nn.Conv1d(1, 16, kernel_size101, stride10, padding50), nn.ReLU(), nn.MaxPool1d(4), nn.Dropout(0.3), nn.Conv1d(16, 32, kernel_size51, stride5, padding25), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.fc nn.Linear(32, n_classes) def forward(self, x): return self.fc(self.conv(x).squeeze(-1))kernel_size101覆盖5秒正好跨过一整个SCR生命周期stride10等效0.5秒下采样降低计算量并保留有效频带。CNN在EDA上的典型表现是不会大幅超过手工特征加随机森林它的优势在于对“峰位置对不准”的抖动更鲁棒。真正值得用深度模型的场景是多模态输入比如EDA和加速度计、心率拼在一起时卷积层可以天然做特征融合。4.5 训练和推理必须共用同一条信号处理链窗口化之后训练阶段和部署阶段容易不知不觉切到两套预处理。预测时如果把整段信号先归一化再分批滑窗与训练时的逐窗处理顺序冲突。正确做法是训练时按窗口划分、每个窗口单独标准化部署时也按同样的窗口长度和重叠率处理。滑动窗口重叠率设50%可以消除决策边界振荡。预测阶段不要对滑窗内再做sosfiltfilt零相位滤波会引入窗口边界效应和训练时的样本分布不一致。5. 模型验证与情绪推理的软肋从换人实验到置信度校准5.1 换人实验跨设备鲁棒性检验源码跑通、指标不错还不能算结束。落地前补一次换人实验用另一台设备记录同一个人或者用同一台设备记录另一组人只看跨组准确率掉多少。如果跨设备准确率骤降说明模型学过的是设备增益不是情绪。这时候按受试者内z-score重做特征并在特征表里补一列设备ID让模型有机会学到设备偏移而不是依赖它去猜情绪。5.2 置信度校准比准确率更值得关注分类器输出0.9的概率真实可靠度可能只有70%。情绪识别要作为产品功能输出时概率校准比准确率更重要。用CalibratedClassifierCV包一层from sklearn.calibration import CalibratedClassifierCV clf_calibrated CalibratedClassifierCV(clf, cv3, methodsigmoid) clf_calibrated.fit(X_train, y_train) prob clf_calibrated.predict_proba(X_test)[:, 1]sigmoid适合样本量少的二分类场景样本上万可以换isotonic。校准之后模型说“紧张概率0.8”才真的意味着十次里有八次是紧张。5.3 延迟对齐EDA反应比标记慢几秒EDA反应发生在情绪事件后1到3秒如果记录设备时间轴和事件打标之间有偏差监督信号会整体错位。跑一次事件相关平均看峰值出现在标记之后的第几秒。如果平均滞后明显超过5秒基本可以断定标记有延迟需要整体平移后再训练。这个检查用不了十分钟但漏掉它的项目后期排错成本最高。5.4 交付前快速检查清单检查项常见错误修改方向预处理用有相位失真的lfilter改用sosfiltfilt特征提取对绝对幅值设阈值先做被试内z-score再定阈值数据划分随机划分同一被试使用GroupKFold按人分组模型调参一味追求准确率比较跨设备准确率和校准曲线推理阶段滑窗内二次滤波训练和推理共用一条处理链资料包里带模型文件和PPT时我一般会先跑特征脚本换回训练好的pkl或h5再验证一遍。PPT讲得再漂亮都不如一次按人分组、跨设备留一验证结果有说服力。皮肤电信号情绪识别的难点从来不是模型结构而是数据链路里这些不显眼的偏差。我一般把这个检查单贴在训练脚本头部最显眼的位置跑之前先过一遍能省下整个项目周期里最贵的那几天。本文还有配套的精品资源点击获取