简介:这是一套基于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_rate=sr_origin, lowcut=0.01, highcut=20, method="butterworth", order=4 ) # 重采样到100Hz,与特征提取的时间窗对齐 sr = 100 eda_resampled = nk.signal_resample( eda_filt, sampling_rate=sr_origin, desired_sampling_rate=sr ) # 把EDA分解为phasic和tonic,phasic用于特征提取 # Bukhari法适合常规离线分析,cvxEDA更慢但对强基线漂移更稳 phasic, info = nk.eda_phasic(eda_resampled, sampling_rate=sr, method="Bukhari")这段代码的逻辑很直白:先滤波、再降采样、最后分解。滤波之所以放在降采样之前,是因为如果先降采样,高频噪声会折叠回低频段,后面再怎么处理都去不掉。eda_phasic内部用的是平滑先验分解,返回的phasic信号已经去掉了tonic基线,直接反映汗腺的瞬态活动,后续特征基本都从这条信号上提取。
| 参数 | 典型取值 | 作用 |
|---|---|---|
| lowcut / highcut | 0.01 / 20 Hz | 滤除基线漂移与高频噪声,防止混叠 |
| sr_origin / sr | 256 → 100 Hz | 统一采样率,保证窗口长度和频谱分辨率一致 |
| method | Bukhari / cvxEDA | 相位分解算法;cvxEDA慢约20倍但抗漂移更好 |
| signal_quality | 0 或 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], btype="bandpass", fs=sr_origin) eda_filt = filtfilt(b, a, eda_raw) # 用中值滤波器估算tonic基线,窗口取5秒跨度 # 后续直接用原始信号减基线得到phasic近似 tonic_approx = median_filter(eda_filt, size=int(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, sr=100): # 阈值0.2uS是公认的最小有效SCR幅度 # distance=1秒用于剔除相邻过近的伪迹峰 peaks, _ = find_peaks(phasic, height=0.2, distance=int(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这里有两个容易踩的参数:height=0.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, sr=100, win_s=5): 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, fs=sr, nperseg=min(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_splits=5) fold_accs = [] for train_idx, val_idx in gkf.split(X, y, groups=subject_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, verbose=False)] ) val_pred = model.predict(X[val_idx]) fold_accs.append(accuracy_score(y[val_idx], val_pred))GroupKFold是这里的关键,groups=subject_id保证同一个被试的所有片段不会同时出现在训练集和验证集里。如果不加这一层,同一个人的相似波形会被模型记住,线上效果会打七折。learning_rate=0.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_classes=4): super().__init__() self.features = nn.Sequential( # kernel_size=50在100Hz采样率下覆盖0.5秒波形 nn.Conv1d(1, 32, kernel_size=50, stride=4, padding=24), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(2), # 第二层缩小到0.25秒跨度,提取短时形态 nn.Conv1d(32, 64, kernel_size=25, stride=2, padding=12), 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上升沿的前半段;第二层kernel=25覆盖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", classes=np.unique(y), y=y) criterion = nn.CrossEntropyLoss(weight=torch.tensor(weights, dtype=torch.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, sr=self.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, sr=100, base_seconds=10): 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, threshold=0.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必须三个一起发布,否则任何一环缺失都复现不出技术文档里的基准结果。
本文还有配套的精品资源,点击获取