news 2026/9/12 14:36:56

用Python做皮肤电信号情绪识别:从预处理到模型验证的完整指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python做皮肤电信号情绪识别:从预处理到模型验证的完整指南

简介:面向情绪识别与生理信号处理学习者的完整项目资料包,内含基于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, fs=20.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, fs=20.0): # 1) 中值滤波去除运动伪迹尖峰 eda = signal.medfilt(eda, kernel_size=5) # 2) 0.05Hz高通滤波去掉基线漂移 sos_high = signal.butter(2, 0.05/(fs/2), 'highpass', output='sos') eda = signal.sosfiltfilt(sos_high, eda) # 3) 2Hz低通滤波去掉工频及高频肌电 sos_low = signal.butter(2, 2.0/(fs/2), 'lowpass', output='sos') eda = signal.sosfiltfilt(sos_low, eda) return eda

代码第一个关键点是重采样。很多可穿戴设备标称20Hz,实际是20.0x或19.8x Hz,长时间记录会让时间轴累积漂移,情绪标签边界对不齐。第二个关键点是sosfiltfilt而不是lfilter,前者是零相位滤波,不会让反应事件整体移位。

参数说明:kernel_size=5滤掉孤立尖峰,同时保留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, keepdims=True)) / (np.std(eda, axis=-1, keepdims=True) + 1e-6)

注意在z-score之前完成重采样和滤波,否则尖峰会把标准差拉大,让正常反应被压扁。

2.4 信号质量检查:别让坏段进训练

实际项目中,我不会把全部样本都交给模型。采集过程中,电极可能松动、连接线可能虚接,出现大段平直或饱和的信号。简单做法是切段后统计一阶差分:

def quality_ratio(seg, fs=20.0, thr=0.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, fs=20.0, window_s=60): """从一段信号里提取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:(i+1)*win] t = np.arange(len(seg)) / fs # SCL基线:60s移动平均 scl = np.convolve(seg, np.ones(300)/300, mode='same') scr = seg - scl # SCR响应检测:阈值取SCR标准差的两倍 thr = 2 * np.std(scr) peaks, props = signal.find_peaks(scr, height=thr, distance=fs*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倍,捕获更弱的反应。distance=fs*0.5限制两个峰值间隔至少0.5秒,防止把单个SCR的毛刺数成多个事件。

peak_rise_time这里用的是相邻峰间隔,作为情绪唤起节奏的粗略估计。真要做严格的SCR形态学,还要算每个峰值的上升时间、半恢复时间,数据量大时可以用neurokit2补齐这部分。关键是特征种类不必多,但要覆盖“基线水平、基线趋势、事件密度、事件幅度、事件节奏”这五个角度。想覆盖频域的话,可在特征字典里追加一段:

freq, psd = signal.welch(scr, fs=fs, nperseg=min(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', axis=1) y = feature_df['label'] groups = feature_df['subject_id'] # 每个被试唯一编号 gkf = GroupKFold(n_splits=5) 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_estimators=100, max_depth=8, random_state=42, class_weight='balanced') clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(f"Acc={accuracy_score(y_test, y_pred):.3f} F1={f1_score(y_test, y_pred, average='macro'):.3f}")

注意:GroupKFold.split的第三个参数是groups,不是标签。如果把y当作groups传进去,跨被试划分就失效了。

max_depth=8比默认值小,因为特征数量少、取值范围窄,树太深只会记住训练集的个体噪声。n_estimators=100在这个量级下够用,再翻倍不会带来明显收益,反而拖慢交叉验证。class_weight='balanced'在类别不平衡时尤其有用,紧张样本通常远少于平静样本,不加权重模型会倾向于把所有人都判成多数类。

4. 模型构建与调参:从随机森林到轻量网络

4.1 二分类和多分类要先分清

EDA信号能可靠区分的其实是唤醒度(calm/aroused),而不是效价(positive/negative)。紧张和激动的SCR形态非常接近,光靠皮电很难区分。如果数据标签是离散情绪(平静、愉快、紧张、悲伤),算法上通常先转成二分类或三分类唤醒度任务,再在外面接规则或文本特征。多分类可以跑,但要接受准确率上限比二分类低不少,这不代表代码有问题,是模态本身的限制。

4.2 三种基线模型一次性跑完

特征表出来后,第一步不是调参,而是横向比较几组基线。RandomForestClassifierLogisticRegression加标准化和浅层梯度提升。下面代码基于上一节的gkf继续跑:

from sklearn.preprocessing import StandardScaler from sklearn.linear_model import LogisticRegression from sklearn.pipeline import make_pipeline models = { 'rf': RandomForestClassifier(n_estimators=200, max_depth=10, random_state=42, class_weight='balanced'), 'lr': make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)), } 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~300
max_depth树深6~12
min_samples_leaf叶节点最少样本5~20
cnn_kernel卷积核长度0.5~2秒对应样本数
cnn_filters卷积通道数16~64
dropout丢弃率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_classes=2): super().__init__() self.conv = nn.Sequential( nn.Conv1d(1, 16, kernel_size=101, stride=10, padding=50), nn.ReLU(), nn.MaxPool1d(4), nn.Dropout(0.3), nn.Conv1d(16, 32, kernel_size=51, stride=5, padding=25), 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_size=101覆盖5秒,正好跨过一整个SCR生命周期;stride=10等效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, cv=3, method='sigmoid') 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讲得再漂亮,都不如一次按人分组、跨设备留一验证结果有说服力。皮肤电信号情绪识别的难点从来不是模型结构,而是数据链路里这些不显眼的偏差。我一般把这个检查单贴在训练脚本头部最显眼的位置,跑之前先过一遍,能省下整个项目周期里最贵的那几天。

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

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

C/C++生成不重复三位数组合的算法实现与优化

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

作者头像 李华
网站建设 2026/9/12 14:36:03

ESP32 WebSocket PCM音频流实时对话链路重构

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

作者头像 李华
网站建设 2026/9/12 14:27:30

降AI率解读:为什么纯手写论文AIGC检测也会超标2026深度解析

降AI率解读&#xff1a;为什么纯手写论文AIGC检测也会超标2026深度解析 手写论文降AI率超标原因解读背后的机制&#xff0c;很多人说不清楚。这篇梳理清楚降AI率核心逻辑&#xff0c;以及针对性的解决方案。 主推嘎嘎降AI&#xff08;www.aigcleaner.com&#xff09;&#xf…

作者头像 李华
网站建设 2026/9/12 14:27:15

Costas环载波同步仿真:BPSK/QPSK/MSK/GMSK的Simulink实现

简介&#xff1a;这是一套面向通信与信号处理方向学习者的 MATLAB/Simulink 仿真资源&#xff0c;重点围绕 MSK、GMSK、QPSK、BPSK 四种调制方式下的 Costas 环载波同步问题&#xff0c;提供可直接运行的仿真模型&#xff0c;适合本科、硕士阶段的课程作业、科研入门以及教师备…

作者头像 李华