简介:这是一套基于Python的皮肤电信号(GSR)情绪识别系统开发包,面向生物信号处理与情感计算方向的初学者、研究者和开发者,旨在解决从原始生理信号采集、数据预处理到情绪分类识别的完整工程问题。压缩包共123个文件,以78个带情绪标签的CSV数据集为主体,样本覆盖 happy、sad、normal 等多种状态,可支撑模型训练与验证;配套7个Python源码文件、MATLAB脚本、XML配置,以及PPT演示文稿和Markdown技术说明,其中Python和MATLAB文件负责算法实现,CSV提供训练样本,PPT和文档方便汇报与二次开发。整体大小仅1.91MB,轻量易部署,目录层级清晰。目前已有48人学习浏览,尤其适合课程设计、毕业课题或情绪识别方向入门实践。开发包内含可运行代码、训练好的模型、完整技术文档与数据集,已在本地环境验证通过、评估超过95分,难度适中;按文档指引即可快速复现数据清洗、特征提取、模型训练和情绪分类全过程,也可局部替换数据或参数做进一步研究,显著降低从理论到落地的门槛。
1. 皮肤电信号情绪识别,为什么比表情识别更值得拆
一个人可以不说话,表情也可以控制,但皮肤电信号很难伪装。皮肤电信号(EDA/GSR)由交感神经驱动,紧张、兴奋、压力出现时汗腺活动改变皮肤电导,产生毫秒级变化。这个Python开发包把“信号读取—预处理—特征工程—分类模型—文档与PPT”完整打包,附带多个CSV数据文件和已训练好的模型,本地验证得分超过95分。它最大的价值是用具体数据把“情绪识别”从概念变成可复现的工程流程:新手能按代码一步步跑通,有机器学习经验的人也能通过模型权重和特征参数倒推别人的设计思路。我拿到之后没有先跑demo,而是从数据文件开始拆,下面按这个顺序还原整个系统。
2. 数据先于模型:解析皮肤电信号CSV的三层结构
2.1 CSV文件里到底存了什么
打开开发包后看到一批CSV:happy_other.csv、normal_sad.csv,以及几十个数字命名的文件。初次运行前别急着建模,先做数据侦察。常见的数据格式是每行一条采样记录,包含时间戳、皮肤电导值和事件标记。用pandas读一个文件:
import pandas as pd df = pd.read_csv("19.csv") print(df.head()) print(df.columns.tolist()) print(df.shape) print(df.dtypes)这段代码先把列名、数据规模和每列类型打出来。如果文件用GBK编码会导致中文列名乱码,可以改为pd.read_csv("19.csv", encoding="utf-8")。皮肤电信号文件一般包含下面几类字段:
| 字段名 | 含义 | 典型取值范围 | 用途 |
|---|---|---|---|
| timestamp | 采集时间戳 | Unix时间或相对毫秒 | 判断采样率是否均匀 |
| EDA / GSR | 皮电值 | 0.1-10 microsiemens | 核心信号 |
| label / emotion | 情绪标签 | happy、normal、sad、other | 监督训练目标 |
| trigger | 事件标记 | 0/1 | 定位刺激起止点 |
注意,不是每个CSV都有label列。如果某个文件只有信号列,标签要从文件名推断,例如normal_sad.csv说明该文件包含normal和sad两类数据;数字命名的文件通常是一次实验会话。拿到数据后先用df.isnull().sum()检查缺失值,采样率则用时间戳差值的众数来估计。用df["timestamp"].diff().mode()能看到采样间隔是否为固定值,存在掉帧会直接影响后面的窗口长度,需要先插值补齐。
2.2 采样率检查与滑动窗口切分
皮肤电信号是低频信号,多数设备采样率在4-256 Hz之间。如果不做滑动窗口,整段数据只能得到几个样本,模型量不够。滑动窗口是把连续时间序列切成长度固定的小段,每段再打上同一个情绪标签。窗口长度太短会丢失情绪响应的完整形态,太长又会把多个皮电反应混在一起。我一般对EDA信号用5-10秒窗,重叠50%。情绪刺激常持续2-5秒,10秒窗口能覆盖一次完整反应。
def sliding_window(data, window_size=300, step_size=150): samples = [] for i in range(0, len(data) - window_size + 1, step_size): seg = data[i:i + window_size] samples.append(seg) return samples # 假设EDA列在第1列,转换为一维数组 eda = df["EDA"].values windows = sliding_window(eda, window_size=300, step_size=150) print(f"原始{len(eda)}个点,切出{len(windows)}个窗口")window_size=300对应每秒采样30点时10秒的数据;step_size=150是50%重叠,让相邻窗口共享一半信息。参数选择依据是:SCR(皮电响应)持续时间约1-5秒,窗口需要覆盖一次完整生理反应。采样率变化后,窗口长度也要等比调整,不能直接沿用同一数值。切完窗口后,每个窗口后面还要对应同一个标签,后续特征提取时用y[i] = label保存。
2.3 去噪与标准化:先处理运动伪迹再做标准化
皮肤电信号最怕两类噪声:运动伪迹和基线漂移。运动伪迹表现为突然的尖峰,基线漂移来自呼吸或电极松动。处理顺序很重要:先去除尖峰,再做平滑,最后标准化。如果顺序反了,尖峰经过平滑后会扩散成一个“小山包”,很难再消除。
import numpy as np from scipy.signal import medfilt def clean_eda(signal, kernel_size=5): # 中值滤波去除尖峰噪声 filtered = medfilt(signal, kernel_size=kernel_size) # 基线漂移用平滑信号减去局部均值 base = np.convolve(filtered, np.ones(50)/50, mode="same") detrended = filtered - base # 标准化到0均值单位方差 return (detrended - detrended.mean()) / (detrended.std() + 1e-6) clean = clean_eda(eda)这里kernel_size=5表示中值滤波的窗口覆盖5个采样点,太小去不掉尖峰,太大会破坏真实的皮电响应;局部均值窗口50点用于估计基线,实际大小要和采样率挂钩,30Hz数据用50点相当于1.67秒平滑窗口。标准化加了一个1e-6防止分母为0。如果原始文件里有明显0值或极大离群点,先做“截尾”而不是直接删除,删除会破坏时间连续性。建议先画一段原始信号和清洗信号的对比图,确认滤波没有把SCR的持续时间削平,再进入特征工程。
提示:连续长时间记录会存在明显的基线漂移,如果有采集设备的参考值,用参考值去除比局部均值更可靠。
3. 特征工程与模型选型:把波形压缩成情绪分类器
3.1 为什么不能直接把波形喂给模型
这套开发包的数据规模有限,CSV文件十几个,窗口化后样本量可能只有几百到几千条。如果把原始波形展开成一维向量,特征维度随窗口长度成倍增加,比如10秒窗口30Hz就是300维,直接展开很容易过拟合,而且无法迁移到采样率不同的数据。更稳的做法是提取时域、频域和统计特征,让模型面对的是“信号物理含义”而不是原始采样点。原始信号给线性模型,模型看不到“峰值持续时间”“上升斜率”这类关键信息;卷积网络虽然能自动提取,但没有足够数据训练,验证集上会出现明显波动。
3.2 三组特征的计算与选择
特征分成时域、频域和形态三组。时域特征包括均值、标准差、一阶差分均值、偏度、峰度;频域特征用功率谱密度在0.1-0.5 Hz低频段的能量,EDA能量集中在这个频段;形态特征包括局部峰值数量、峰均比、SCL斜率等。
from scipy.stats import skew, kurtosis from scipy.signal import welch, find_peaks def extract_features(signal): feats = {} diff = np.diff(signal) feats["mean"] = np.mean(signal) feats["std"] = np.std(signal) feats["diff_mean"] = np.mean(np.abs(diff)) feats["diff_max"] = np.max(np.abs(diff)) feats["skew"] = skew(signal) feats["kurtosis"] = kurtosis(signal) feats["p25"] = np.percentile(signal, 25) feats["p75"] = np.percentile(signal, 75) # EDA 低频段功率 freqs, psd = welch(signal, fs=30, nperseg=256) low_band = (freqs >= 0.1) & (freqs <= 0.5) feats["eda_power"] = np.sum(psd[low_band]) # 峰值特征,只统计超过75分位的明显响应 peaks, props = find_peaks(signal, height=np.percentile(signal, 75)) feats["peak_count"] = len(peaks) if len(peaks) > 0: feats["peak_mean"] = np.mean(props["peak_heights"]) else: feats["peak_mean"] = 0 return feats代码里fs=30是假设采样率30Hz,必须从时间戳实际计算;nperseg=256控制功率谱变换块大小,窗口太大会让频域分辨率提升但时间分辨率下降,小数据场景256是常见折中。find_peaks的高度阈值取75分位,目的是只统计明显的皮电响应,避免把微小波动也算成峰值。最终把所有窗口的特征拼成一个二维数组,每行是一个样本。如果特征数量不足,再补充能量值、相邻窗口差值、过零率等特征。
3.3 模型选型:随机森林和梯度提升优先
对于这种小规模表格数据,开始不需要上深度模型。开发包里既然带了模型,大概率也是基于sklearn或LightGBM训练的。随机森林对特征尺度不敏感,能给出特征重要性;XGBoost/LightGBM在同样数据上往往精度再高1-2分,但参数多。做基线时优先用随机森林,不用调参也能得到可解释的结果。
| 模型 | 适合场景 | 训练耗时 | 解释性 |
|---|---|---|---|
| 随机森林 | 小数据、特征几十个 | 秒级 | 特征重要性最直观 |
| 梯度提升(GBDT) | 数据量中等,需要更高精度 | 分钟级 | 有分裂增益可看 |
| MLP | 特征比较抽象、非线性强 | 需要调学习率 | 弱 |
| 1D CNN | 原始波形、数据量大 | 数据不够时易过拟合 | 弱 |
from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, stratify=y, random_state=42 ) clf = RandomForestClassifier( n_estimators=200, max_depth=8, min_samples_leaf=4, n_jobs=-1, random_state=42 ) clf.fit(X_train, y_train) print(classification_report(y_test, clf.predict(X_test)))stratify=y保证了每个类别在训练集和测试集中占比一致,情绪数据天然不平衡,不能省。max_depth=8和min_samples_leaf=4是为了防止树完全生长导致过拟合。接着输出特征重要性:
import pandas as pd importance = pd.Series(clf.feature_importances_, index=feature_names).sort_values(ascending=False) print(importance.head(10))看排名第一的特征是否合理。在我拆过的类似项目中,eda_power和diff_mean通常会排在最前面,因为它们直接对应皮肤电导变化的强度和速度。如果某个无关特征排第一,通常说明标签划分有问题,先回去检查数据文件,而不是继续调参。
4. 把模型跑起来:开发包里的训练预测全流程
4.1 先看目录结构和入口文件
开发包解压后一般包含几个Python主程序、models/目录、data/目录、ppt/和doc/。先看入口文件名,常见的是main.py或train.py,也可能提供predict.py。建议先跑预测demo,再跑训练脚本,因为训练脚本依赖数据清洗流程,demo能先验证模型文件是否正常加载。目录结构类似:
├── data/ # 所有CSV ├── models/ # 训练完成的模型 ├── ppt/ # 演示文稿 ├── doc/ # 技术文档 ├── main.py # 预测入口 ├── train.py # 训练入口 └── requirements.txt如果项目里有requirements.txt,直接执行pip install -r requirements.txt;没有就按需安装。运行预测demo前,检查模型文件是否真的存在且非空,我遇到过模型文件只有几KB的情况,一般是训练被打断或保存路径错误。
4.2 训练运行与环境依赖
运行训练脚本前,先确认Python环境。Python 3.9以上、scikit-learn 1.0以上、pandas、numpy、scipy、matplotlib装齐就能跑通。如果你还没配好Python基础环境,先用Anaconda创建一个新环境是最省事的做法。执行命令:
pip install pandas numpy scipy scikit-learn joblib matplotlib python train.py --data_dir data --model_save_path models/eda_emotion_rf.pkl--data_dir参数指定CSV所在文件夹,--model_save_path指定模型输出路径。第一次运行可能报错,常见的报错和处理方式如下:
| 报错信息 | 常见原因 | 处理方式 |
|---|---|---|
| UnicodeDecodeError | CSV编码不是UTF-8 | pd.read_csv(path, encoding="utf-8") |
| KeyError: 'EDA' | 列名是GSR或其他 | 先打印列名再重命名对齐 |
| AttributeError: 'NoneType' | 模型路径不存在 | 检查models目录是否有pkl文件 |
| ValueError: X has 15 features | 预测和训练特征数不一致 | 确认特征提取函数版本一致 |
训练完成后会打印验证集指标,重点看classification_report里的macro avg f1-score,不要只看accuracy。开发包自带的模型如果只有几KB,最好重新训练一遍,代码能跑通比直接用旧模型更重要。
4.3 模型持久化与单条样本预测接口
训练完的模型要用于新测试文件,不能每次重新切分训练数据。用joblib保存模型,加载后封装预测函数:
import joblib joblib.dump(clf, "models/eda_emotion_rf.pkl") def preprocess_and_predict(model, csv_path): df = pd.read_csv(csv_path) # 兼容不同列名 col = "EDA" if "EDA" in df.columns else "GSR" signal = df[col].values signal = clean_eda(signal) windows = sliding_window(signal, window_size=300, step_size=150) features = [extract_features(w) for w in windows] preds = model.predict(features) # 多数投票得到该文件整体情绪 final_label = pd.Series(preds).mode()[0] return final_label, preds label, per_window = preprocess_and_predict(clf, "21.csv") print(f"整体情绪:{label},窗口预测分布:{per_window}")这里把滑动窗口、清洗、特征提取、模型预测按顺序串起来。对于一条长信号,窗口级别预测可能出现不同结果,用众数投票确定整个文件的情感标签,避免单窗口误判。需要注意,新文件的采样率和训练数据不一致时,前面fs=30就没意义,要用实际采样率重新计算窗口长度和滤波参数。
提示:模型文件用joblib保存,跨版本加载时可能报sklearn版本不一致,尽量保持训练和预测环境的sklearn小版本一致。
5. 进阶:在自己数据上微调时的五个参数坑
5.1 采样率与窗口大小要联动调整
开发包默认窗口300个采样点,对应30Hz采样率的10秒。换到50Hz设备时仍用300就只剩6秒,情绪响应可能被截断。正确做法是先计算采样率,再让窗口时长固定:window_size = int(10 * fs),重叠率保持0.5。这个坑最隐蔽,因为代码不会报错,但模型分数会悄悄下降。
5.2 类别不平衡时不要只看accuracy
情绪标签中happy和other往往占比高,normal或sad可能只有几十个窗口。训练脚本里常见的处理有两种:设置class_weight="balanced",或者在切分后对少数类做SMOTE。stratify=y只是保证切分比例,不代表类别均衡。评估时看f1-score比看accuracy可靠,accuracy在95%也可能只是把所有样本预测成一类。
5.3 滤波器参数要和采样率一起缩放
EDA有效能量集中在0-0.5 Hz,但很多开源代码用5 Hz低通滤波,这会把高频噪声放进来。开发包中的中值滤波kernel_size=5只在30Hz附近有效,100Hz设备就该用7或9。滤波后画一张对比图,有毛刺说明核太小,波形变平说明核太大。这个验证只需要几行matplotlib,值得每次换数据都做。
5.4 同一文件窗口存在泄漏,要用文件分组验证
最后给大家一个最实用的验证方法:跨文件验证。
from sklearn.model_selection import LeaveOneGroupOut from sklearn.metrics import accuracy_score # group为文件名编号,测试时整个文件不参与训练 groups = df["file_id"].values logo = LeaveOneGroupOut() scores = [] for train_idx, test_idx in logo.split(X, y, groups): clf.fit(X[train_idx], y[train_idx]) scores.append(accuracy_score(y[test_idx], clf.predict(X[test_idx]))) print(f"跨文件验证平均准确率:{np.mean(scores):.3f}")这个技巧适合这套开发包:每个CSV对应一个人或一次实验,如果随机切分窗口,同一个人的训练窗口和测试窗口会互相泄漏,模型得分虚高。用文件编号作为分组,做留一文件验证,才能估计真实泛化能力。开发包评估的95分大概率是在同分布数据上测的,换到真实场景要重新验证。
5.5 预测概率阈值比硬分类更能反映不确定性
如果只需要判断“是否放松”,调整决策阈值比改模型结构更直接。用predict_proba取第二类的概率,再设一个业务阈值:
proba = clf.predict_proba(windows)[:, 1] final_label = int((proba > 0.6).mean() > 0.5)阈值0.6表示只有超过60%的窗口被激活才判为该情绪,适合误报成本高的场景。开发包里的四分类标签,可以两两合并成“正常/异常”或“高兴/非高兴”后再做阈值判断,这一步通常比调整模型结构更快见效。
本文还有配套的精品资源,点击获取