ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python皮肤电信号情绪识别开发包:从预处理到模型部署

Python皮肤电信号情绪识别开发包:从预处理到模型部署 简介这是一套基于Python实现的皮肤电信号GSR情绪识别系统开发包适合初入情感计算、人机交互方向的学生或研究者学习参考也适用于本科毕业设计、课程实践等场景。开发包完整覆盖从皮肤电信号数据采集、预处理到情绪分类识别的流程包含可运行的Python主程序、预训练模型、Matlab辅助脚本、用于演示的PPT幻灯片以及详细的技术说明文档同时附带了大量CSV格式的标注数据可供模型训练与效果验证。压缩包共123个文件大小约1.91MB文件类型以csv数据集、zbak备份、py脚本、xml配置、m脚本等为主结构清晰便于按模块取用。该资源已有48人浏览学习项目自述通过本地环境验证且评分超过95分难度适中既能帮助新手理解信号处理与情绪识别的基本方法也能为二次开发或论文实验提供可直接参考的代码框架与数据基础。1. 从波动形态识别情绪Python皮肤电信号情绪识别系统开发包在解决什么皮肤电信号的情绪识别系统准确率到底卡在哪很多人以为是分类模型不够强但实际跑过一轮就会发现真正的问题出在“同一个刺激对不同被试产生的EDA波形幅度可能相差5倍”。高唤醒的紧张、低唤醒的平静在原始信号里并不以绝对幅值呈现而是以波形形态呈现。这套开发包的价值不在于把模型换得更大而是把从原始EDA到情绪标签的整条链路——预处理、特征提取、模型训练、工程化封装——一次性跑通交付物是可直接运行的Python代码、训练好的模型文件、演示PPT和完整技术文档。面向需要用Python快速搭建情绪识别原型的研发者以及做可穿戴情感计算实验的研究者下面是一套可落地、可复现的做法。2. 皮肤电信号预处理链路从RAW信号到可计算特征空间皮肤电信号EDA也叫GSR原始记录里包含两个生理成分tonic和phasic。tonic是皮肤电导水平SCL一条缓慢漂移的基线反映的是整体唤醒状态phasic是皮肤电导反应SCR叠加在tonic之上的短时瞬态波峰才是情绪刺激的直接响应。情绪刺激引发的SCR潜伏期通常在1到5秒上升时间0.5到2秒回落阶段可持续数秒。预处理的目的只有两个把phasic和tonic分离干净以及统一采样率让后续特征提取的时间窗有稳定的物理含义。2.1 EDA的生理基线、相位成分与情绪响应时间窗我在开发包里处理过的EDA原始数据采样率从64Hz到512Hz都有低采样率来自消费级手环高采样率来自实验室放大器。采样率不统一会导致两个问题一是同一段窗口内的数据点数不同特征提取脚本没办法复用二是直接对原始信号做频谱分析时频率分辨率对不上看起来是同一组特征实际含义完全不一致。另一个坑在信号质量。电极接触不良、手部活动、汗液饱和都会产生大幅伪迹这些伪迹在波形上和真实SCR非常像分类器会照单全收。常见做法是先做信号质量标记再决定哪些片段可以进入特征提取。NeuroKit2提供的eda_quality接口返回0/1质量标签离线分析时直接用掩码剔除低质量片段效果比事后清理干净得多。2.2 用NeuroKit2完成降采样、带通滤波与相位分离下面这段代码基本是开发包里预处理模块的骨架输入是原始EDA记录输出是清洗后的phasic信号所有参数都从配置文件读取不写死在代码里。import numpy as np import pandas as pd import neurokit2 as nk # 原始记录采样率256Hz统一降到100Hz减小后续计算量 raw_df pd.read_csv(examples/eda_raw.csv) sr_origin 256 eda_raw raw_df[eda].values # 降采样前先做0.01~20Hz带通滤波避免后续重采样产生混叠 # 0.01Hz截止频率滤掉直流漂移20Hz保留EDA主要能量范围 eda_filt nk.signal_filter( eda_raw, sampling_ratesr_origin, lowcut0.01, highcut20, methodbutterworth, order4 ) # 重采样到100Hz与特征提取的时间窗对齐 sr 100 eda_resampled nk.signal_resample( eda_filt, sampling_ratesr_origin, desired_sampling_ratesr ) # 把EDA分解为phasic和tonicphasic用于特征提取 # Bukhari法适合常规离线分析cvxEDA更慢但对强基线漂移更稳 phasic, info nk.eda_phasic(eda_resampled, sampling_ratesr, methodBukhari)这段代码的逻辑很直白先滤波、再降采样、最后分解。滤波之所以放在降采样之前是因为如果先降采样高频噪声会折叠回低频段后面再怎么处理都去不掉。eda_phasic内部用的是平滑先验分解返回的phasic信号已经去掉了tonic基线直接反映汗腺的瞬态活动后续特征基本都从这条信号上提取。参数典型取值作用lowcut / highcut0.01 / 20 Hz滤除基线漂移与高频噪声防止混叠sr_origin / sr256 → 100 Hz统一采样率保证窗口长度和频谱分辨率一致methodBukhari / cvxEDA相位分解算法cvxEDA慢约20倍但抗漂移更好signal_quality0 或 1标记低质量片段特征化前直接剔除需要注意eda_quality返回的质量标签适合离线分析做掩码不要作为在线系统的硬阻断条件。真实场景里传感器噪声是持续存在的一旦把超过10%的片段都干掉可用样本会少到没法训练。2.3 不依赖NeuroKit2的手工Butterworth近似实现开发包的技术文档里我留了一个无NeuroKit2也能跑的预案方便离线环境不够时做快速原型。scipy自带Butterworth滤波和中值滤波可以实现简化版的相位分离。一般我会在离线环境确认最终效果时切回NeuroKit2两者在干净数据上差别不大。from scipy.signal import butter, filtfilt from scipy.ndimage import median_filter # 手工实现带通滤波截止频率和顺序与上文保持一致 b, a butter(4, [0.01, 20], btypebandpass, fssr_origin) eda_filt filtfilt(b, a, eda_raw) # 用中值滤波器估算tonic基线窗口取5秒跨度 # 后续直接用原始信号减基线得到phasic近似 tonic_approx median_filter(eda_filt, sizeint(5 * sr_origin)) phasic_approx eda_filt - tonic_approx这段做的量和eda_phasic等价但中值滤波对大幅伪迹更敏感伪迹会把中位数拉偏所以只适合跑通pipeline验证思路正式实验我还是以NeuroKit2的输出为准。3. 特征提取工程把皮肤电信号转换为情绪分类器可用的数值向量预处理完成后拿到的是100Hz、长度几百秒的phasic序列序列本身没法直接进树模型必须转成固定长度的特征向量。开发包里的特征提取分两条线事件相关特征和滑动窗口特征。事件相关特征关注的是“一个刺激出现后皮肤电响应长什么样”适合有明确事件标记的范式滑动窗口特征关注的是“某个时间段内的整体波形形态”适合自然状态下的情绪监测。3.1 事件相关特征SCR幅值、潜伏期与恢复时间计算SCR的经典指标包括幅值、潜伏期、上升时间和恢复时间。幅值并不是直接取峰值而是峰相对于刺激前基线的增量。基线窗口的大小会对结果产生明显影响开发包里统一取峰前0.5到1秒的平均值这样可以避开头一个SCR的拖尾污染。import numpy as np from scipy.signal import find_peaks def extract_scr_features(phasic, sr100): # 阈值0.2uS是公认的最小有效SCR幅度 # distance1秒用于剔除相邻过近的伪迹峰 peaks, _ find_peaks(phasic, height0.2, distanceint(1 * sr)) feats {scr_count: len(peaks)} if len(peaks) 0: return feats amps [] risetimes [] for p in peaks: onset max(0, p - int(1 * sr)) # 基线取峰前0.5~1秒的平均值 base np.mean(phasic[onset: p - int(0.5 * sr)]) amp phasic[p] - base amps.append(amp) risetimes.append((p - onset int(0.5 * sr)) / sr) feats[scr_amp_mean] np.mean(amps) feats[scr_amp_max] np.max(amps) feats[scr_risetime_mean] np.mean(risetimes) # 恢复时间峰值跌回50%幅值所需秒数 # 恢复过慢对应更持续的紧张状态比幅值更稳定 recover [] for p, a in zip(peaks, amps): if a 0: continue target phasic[p] - 0.5 * a tail phasic[p:] idx np.where(tail target)[0] if idx.size 0 and idx[0] int(0.5 * sr): recover.append(idx[0] / sr) feats[scr_recovery_mean] np.mean(recover) if recover else np.nan return feats这里有两个容易踩的参数height0.2的单位是微西门子uS如果采集设备输出的是纳西门子nS必须除以1000再做阈值判断否则一个SCR都检不出来。distance建议按秒换算成采样点数直接给点数会让参数在不同采样率下含义漂移。恢复时间容易受后一个SCR的干扰超过2秒还没回到半幅值就截断避免算出一堆异常大的数值。3.2 滑动窗口统计特征频带能量、偏度和样本熵事件相关特征需要知道刺激出现的时刻没有事件标记的连续信号就用滑动窗口。窗口长度我一般取5秒这个长度能完整包住一个SCR的上升和回落形状。窗口内统计均值、标准差、偏度、峰度再加频带能量总共一二十列配合树模型已经够用。from scipy.stats import skew, kurtosis from scipy.signal import welch import pandas as pd def extract_window_features(phasic, sr100, win_s5): n int(win_s * sr) rows [] for start in range(0, len(phasic), n): seg phasic[start:start n] if len(seg) n: break f, psd welch(seg, fssr, npersegmin(256, n)) rows.append({ mean: np.mean(seg), std: np.std(seg), skew: skew(seg), kurt: kurtosis(seg), band_0_5: np.trapz(psd[(f 0) (f 0.5)]), band_0_5_2: np.trapz(psd[(f 0.5) (f 2)]) }) return pd.DataFrame(rows)skew和kurt描述波形是否拖尾、是否尖锐尖锐且右偏的波形通常对应快速上冲的SCR。频带能量这块EDA没有脑电里alpha、beta那样固定的频带定义0.5到2Hz这个区间是经验值刻画的是峰簇出现的快慢。nperseg取小值会让频带能量颗粒度更粗但窗口只有5秒时取256点已经够稳。特征类别典型特征生理含义幅值类scr_amp_mean / scr_amp_max响应强度受个体汗腺密度影响时间类scr_risetime_mean / scr_recovery_mean上冲速度和回落速度紧张时回落变慢形态类skew / kurt波形对称性右偏明显说明快速激活频带类band_0_5 / band_0_5_2峰簇的振荡密度3.3 特征标准化按被试分组做鲁棒归一化EDA特征的个体差异太大跨被试直接拼接特征矩阵会让分类器学会“识别被试”而不是“识别情绪”。我一般用RobustScaler按被试分组做标准化而不是全局Z-score因为EDA特征分布偏态明显Z-score会被少数极端峰值带偏。标准化之前一定要先按被试切分训练集和验证集缩放器只fit在训练集上验证集用同一个scaler.transform否则会造成信息泄露交叉验证分数虚高。4. 情绪识别模型选型与训练从LightGBM到轻量级CNN特征工程做完模型选型反而简单。开发包里我放了两个方向的模型LightGBM处理手工特征适合样本量在几百到几千的小数据集1D-CNN直接消费预处理后的phasic序列适合数据量大、想绕开繁杂特征工程的团队。两个模型共享同一套验证框架才能在文档和PPT里公平对比。4.1 基线模型LightGBM与分被试交叉验证手工特征做成的表格数据LightGBM是我第一个试的模型因为不需要做太多特征缩放对缺失值也不敏感训练速度快到可以反复试参。import lightgbm as lgb from sklearn.model_selection import GroupKFold from sklearn.metrics import accuracy_score params { n_estimators: 1000, learning_rate: 0.05, num_leaves: 31, min_child_samples: 20, subsample: 0.8, colsample_bytree: 0.8, random_state: 42 } gkf GroupKFold(n_splits5) fold_accs [] for train_idx, val_idx in gkf.split(X, y, groupssubject_id): model lgb.LGBMClassifier(**params) model.fit( X[train_idx], y[train_idx], eval_set[(X[val_idx], y[val_idx])], callbacks[lgb.early_stopping(50, verboseFalse)] ) val_pred model.predict(X[val_idx]) fold_accs.append(accuracy_score(y[val_idx], val_pred))GroupKFold是这里的关键groupssubject_id保证同一个被试的所有片段不会同时出现在训练集和验证集里。如果不加这一层同一个人的相似波形会被模型记住线上效果会打七折。learning_rate0.05配early_stopping(50)是树模型最稳的组合先高学习率探路再降学习率微调多数情况下不需要再改其它参数。4.2 端到端方案1D-CNN直接消费phasic片段手工特征提取毕竟要人工设计窗口和频带后期维护成本高。样本量达到几千段以后1D-CNN直接把固定长度的phasic序列映射到情绪类别效果通常与手工特征相当但省去了特征工程。import torch import torch.nn as nn class EDA_CNN(nn.Module): def __init__(self, n_classes4): super().__init__() self.features nn.Sequential( # kernel_size50在100Hz采样率下覆盖0.5秒波形 nn.Conv1d(1, 32, kernel_size50, stride4, padding24), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), # 第二层缩小到0.25秒跨度提取短时形态 nn.Conv1d(32, 64, kernel_size25, stride2, padding12), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(32), ) self.classifier nn.Linear(64 * 32, n_classes) def forward(self, x): f self.features(x) return self.classifier(f.flatten(1))第一层卷积核50在100Hz下对应0.5秒刚好覆盖SCR上升沿的前半段第二层kernel25覆盖0.25秒捕捉更细的波形抖动。AdaptiveAvgPool1d(32)把变长输入压到固定长度避免不同被试数据长度不同导致模型输入尺寸崩掉。层数控制在两层卷积加一层全连接EDA信号的信息密度远低于语音和图像加深网络只是徒增过拟合。4.3 类别不平衡加权损失与评估指标选择情绪数据天然不平衡平静状态占比可能超过60%紧张和兴奋只占少数。直接用CrossEntropyLoss会让模型永远输出“平静”。开发包里用compute_class_weight算类别权重反传给损失函数比过采样省事且不容易过拟合。from sklearn.utils.class_weight import compute_class_weight weights compute_class_weight(balanced, classesnp.unique(y), yy) criterion nn.CrossEntropyLoss(weighttorch.tensor(weights, dtypetorch.float))评估指标不要只看准确率。类别不平衡场景下准确率80%可能只是把所有样本都判成平静的假象我一般用macro-F1和混淆矩阵作为最终指标。PPT里的效果展示也要用macro-F1否则很容易被现场问答环节问穿。5. 开发包的工程化实现项目结构、模型导出与可复现运行模型在笔记本里跑通只算完成一半。开发包的真正价值在于别人拿到手后能按文档跑通demo能换自己的数据重新训练PPT里的效果数字能通过脚本复现。工程化要做三件事目录结构清晰、配置统一管理、模型与scaler一起导出。5.1 目录结构与配置管理开发包的目录我一般设计成下面这样数据、配置、源码、模型、文档各归其位互不污染。eda_emotion/ ├── data/ │ ├── raw/ │ └── processed/ ├── configs/ │ └── default.yaml ├── src/ │ ├── preprocess.py │ ├── features.py │ ├── models.py │ ├── train.py │ └── inference.py ├── models/ │ ├── lgb_model.joblib │ └── scaler.joblib ├── docs/ │ ├── api.md │ └── model_card.md └── requirements.txtmodels/目录里除了模型文件还必须放训练好的scaler和一份config.json。我在开发包里遇到过最普遍的问题就是用户只拿走了模型没拿走scaler推理时特征没做标准化效果直接崩盘。配置文件统一用YAML训练和推理都从同一个文件读取参数避免离线训练和在线推理不一致。data: raw_path: data/raw sr: 100 preprocess: lowcut: 0.01 highcut: 20 phasic_method: Bukhari features: window_s: 5 scr_threshold: 0.2 train: model_type: lightgbm n_estimators: 1000 learning_rate: 0.05default.yaml里的每一项都对应代码里的一个参数改配置不需要改代码。scr_threshold这种参数如果在两处出现最后一定会有人只改了一处导致开发包跑出来的结果和文档对不上。5.2 推理API封装与模型导出推理接口要暴露给调用方最简单的方式是封装一个类传入模型目录就初始化好全部依赖。import json import joblib import numpy as np class EDAService: def __init__(self, model_dir): self.cfg json.load(open(f{model_dir}/config.json)) self.model joblib.load(f{model_dir}/lgb_model.joblib) self.scaler joblib.load(f{model_dir}/scaler.joblib) def predict_single_window(self, eda_segment): # eda_segment长度必须等于window_s * sr feats extract_window_features(eda_segment, srself.cfg[data][sr]) feats_scaled self.scaler.transform(feats) proba self.model.predict_proba(feats_scaled)[0] label int(np.argmax(proba)) return label, float(proba[label])extract_window_features和训练阶段共用同一个函数这是推理不出偏差的前提。窗口长度、采样率、特征顺序全部从config读取不硬编码成魔法数字。模型导出我建议在早停确定n_estimators后把全部训练数据再重训一遍并序列化这样最终模型用的树数量和验证曲线一致文档里写的每一轮精度都可追溯。5.3 技术文档和PPT要能互相验证PPT里演示的准确率、混淆矩阵、每个类别的精确率和召回率应当能从train.py的日志文件里重新算出来。我在做开发包时会在训练脚本里加一个--seed 42参数把所有随机过程固定住这样PPT上的数字和用户自己跑出的数字一致不会出现“报告里85%复现只有70%”的尴尬。技术文档里还要标注Python版本和依赖库的版本只写neurokit2不写版本号将来依赖一升级预处理结果就变全部指标都失效。6. 落地验证的4个关键技巧个体校准、时间窗增强与置信度输出最后把我最常用的4个落地技巧单独写出来这些技巧在离线实验里看起来不起眼但线上效果差距基本都在这里拉开。6.1 个体基线校准前10秒中位数做相对化每个被试的EDA绝对值差异太大直接把原始幅值特征送进模型会被“你是谁”主导。我一般取每个被试前10秒中位数作为基线把信号转成相对增幅。def calibrate_eda(phasic, sr100, base_seconds10): base np.median(phasic[:int(base_seconds * sr)]) # 加1e-6防止长时间平直段导致除零 return (phasic - base) / (base 1e-6)注意校准必须在相位分离之后做先减基线再分离会把tonic成分误删一部分后续峰值检测全部失真。6.2 时间窗增强对相位序列做线性时间拉伸可穿戴数据样本量永远不够我不用加高斯噪声因为EDA信噪比本来就低加噪只会压过真实响应改用时间拉伸更安全让同一段波形在时间轴上有快慢变化模拟不同人的反应速度差异。def augment_window(seg, scale_range(0.8, 1.2)): scale np.random.uniform(*scale_range) n len(seg) new_n int(n * scale) x_old np.linspace(0, 1, n) x_new np.linspace(0, 1, new_n) return np.interp(x_new, x_old, seg)6.3 置信度阈值与“不确定”输出情绪分类天然存在模糊地带硬要五分类只会让错误集中在边界样本上。我通常保留一个“uncertain”输出当最高概率低于阈值或前两个类别分数接近时不给出情绪标签而是返回置信度由上层业务决策。LABELS [neutral, stress, excited, calm] def verdict(proba, threshold0.6): top2 np.sort(proba)[-2:] if proba.max() threshold or (top2[1] - top2[0]) 0.1: return uncertain, float(proba.max()) return LABELS[int(np.argmax(proba))], float(proba.max())线上系统把“不确定”当成一个合法输出比强行给一个错误标签更有利于用户体验和后续的数据回收。真实部署时把个体校准、时间窗增强和置信度阈值放进同一条推理管线比单独调模型超参数更容易提升整体F1分数模型文件、scaler和config.json必须三个一起发布否则任何一环缺失都复现不出技术文档里的基准结果。本文还有配套的精品资源点击获取
返回列表