简介:本资源是一套基于Python与皮肤电信号(EDA)实现情绪识别的完整项目源码,面向心理学、人机交互及智能系统方向的研究者与开发者,帮助其快速理解并复现从生理信号到情绪分类的全流程。压缩包共102个文件,以78个csv数据文件、7个py脚本、6个xml配置、4个m文件及mat、pptx等为主,整体约1.92MB,涵盖数据采集、信号预处理、特征提取、情绪分类与结果评估等模块,并涉及Numpy、Pandas、Scipy、Matplotlib及Scikit-learn等常用库。目前已有815人学习下载。通过研读源码,读者可掌握EDA信号的去噪与特征工程方法,理解非平稳性、个体差异等实际问题的处理思路,并借助实验案例对照验证算法效果,适合作为情绪识别入门与项目实战的参考素材。
1. 情绪识别项目上手:从一堆 CSV 到能跑通的 EDA 分类器
第一次拿到这个项目压缩包,解压后看到的是一堆命名随意的 CSV:happy_other.csv、normal_sad.csv、1.csv、2.csv、18.csv……没有 README,没有 requirements,没有目录分层。很多人到这一步就卡住了——数据到底怎么对应标签?哪个文件是训练集?其实这正是生理信号类项目的常态:数据采集阶段往往是先录信号、后补标签,文件名本身就是标签线索。这个项目用 Python 把皮肤电信号(EDA)从原始波形一路处理到情绪分类,覆盖采集、预处理、特征提取、分类、评估五个环节,适合做心理生理计算、人机交互方向的学生和工程师拿来当练手底稿,也适合想快速验证 EDA 特征有效性的研究者。它不依赖昂贵设备,核心逻辑全在代码里,跑通之后你能清楚看到一段皮肤电波形是怎么变成「开心 / 平静 / 悲伤」这类标签的。
2. 项目结构与数据组织:先搞清楚每个 CSV 在链路里的位置
2.1 目录分层与模块职责
这个项目没有用复杂的工程脚手架,属于典型的「脚本 + 数据」结构。按常见做法,解压后大致会形成下面这样的布局,我按功能把文件归了类,方便你对照自己手里的压缩包:
eda_emotion/ ├── data/ │ ├── raw/ # 原始采集数据 │ │ ├── happy_other.csv │ │ ├── normal_sad.csv │ │ ├── 1.csv 2.csv 9.csv │ │ ├── 18.csv 19.csv 20.csv │ │ └── 21.csv 22.csv │ └── processed/ # 预处理后的中间数据 ├── src/ │ ├── preprocess.py # 滤波、去基线、分段 │ ├── features.py # 时域/频域特征提取 │ ├── train.py # 模型训练与交叉验证 │ └── evaluate.py # 混淆矩阵、指标输出 ├── models/ # 保存的模型文件 └── requirements.txtdata/raw里那些数字命名的 CSV 通常是按被试编号或采集批次命名的,happy_other.csv和normal_sad.csv则直接带了情绪标签。这里有个血泪经验:不要假设所有 CSV 的列结构一致,采集设备不同、导出软件不同,列名可能是EDA、GSR、conductance甚至中文。第一步永远是先统一列名和采样率。
2.2 数据加载与标签映射
标签怎么来?文件名就是最直接的来源。下面这段代码是我一般会先写的「数据盘点」脚本,跑一遍就能知道每个文件多少行、采样率大概多少、有没有缺失:
import os import pandas as pd RAW_DIR = "data/raw" # 文件名到情绪标签的映射,按项目实际命名调整 LABEL_MAP = { "happy_other.csv": "happy", "normal_sad.csv": "sad", } def inspect_csv(path): df = pd.read_csv(path) # 统一列名:把常见 EDA 列名归一到 conductance rename_dict = {} for col in df.columns: low = col.lower() if "eda" in low or "gsr" in low or "conduct" in low: rename_dict[col] = "conductance" elif "time" in low or "timestamp" in low: rename_dict[col] = "time" df = df.rename(columns=rename_dict) return df for fname in os.listdir(RAW_DIR): if not fname.endswith(".csv"): continue df = inspect_csv(os.path.join(RAW_DIR, fname)) label = LABEL_MAP.get(fname, "unknown") print(f"{fname:20s} rows={len(df):6d} cols={list(df.columns)} label={label}")这段代码做了三件事:遍历原始目录、把不同来源的列名统一成conductance和time、根据文件名推断标签。LABEL_MAP是唯一需要你手动确认的地方——如果数字命名的 CSV 也有对应标签表,就把它补进这个字典。跑完这一步,你会得到一张「文件 → 行数 → 列名 → 标签」的清单,后面所有处理都基于这份清单,避免中途发现某个文件列名对不上导致整个 pipeline 崩掉。
注意:如果某个 CSV 只有一列数据没有时间列,说明采样率是固定的,需要在加载时手动补一个时间轴,否则后续按秒切窗会出错。
3. 信号预处理与特征提取:EDA 去噪、分解与特征工程
3.1 滤波与去基线
皮肤电信号很「娇气」,采集时稍微动一下、呼吸重一点都会引入伪迹。原始 EDA 通常包含两部分:缓慢变化的 tonic 成分(基础皮电水平)和快速变化的 phasic 成分(皮肤电反应,SCR)。情绪识别真正有用的是 phasic 部分,所以预处理的核心是「去趋势 + 分离」。常见做法是用 Butterworth 低通滤波去掉高频噪声,再用平滑先验或高通滤波去掉 tonic 漂移:
import numpy as np from scipy.signal import butter, filtfilt def bandpass_filter(signal, fs=4.0, low=0.05, high=1.0, order=4): """对 EDA 信号做带通滤波,保留 0.05-1.0 Hz 的 SCR 频段""" nyq = 0.5 * fs b, a = butter(order, [low / nyq, high / nyq], btype="band") # filtfilt 零相位滤波,避免波形时移影响后续特征对齐 return filtfilt(b, a, signal) def decompose_eda(signal, fs=4.0, cutoff=0.05): """用低通滤波估计 tonic 成分,相减得到 phasic""" nyq = 0.5 * fs b, a = butter(2, cutoff / nyq, btype="low") tonic = filtfilt(b, a, signal) phasic = signal - tonic return tonic, phasicbandpass_filter里的fs是采样率,EDA 常用 4Hz 到 32Hz,如果你不知道自己的数据采样率,可以用时间列相邻两点的差值倒数估算。low和high控制保留频段,0.05Hz 以下基本是漂移,1Hz 以上多是运动伪迹。decompose_eda用低通估计 tonic,再用原始信号减掉它得到 phasic,这是最轻量的分解方式,比 cvxEDA 那种凸优化方法快很多,适合快速迭代。
3.2 时域与频域特征提取
特征决定了分类器的上限。EDA 情绪识别里常用的特征分三类:统计特征(均值、标准差、偏度、峰度)、SCR 形态特征(峰值个数、上升时间、幅度)、频域特征(功率谱密度)。下面这个特征提取函数把一段 phasic 信号压成一个特征向量:
from scipy.stats import skew, kurtosis from scipy.signal import find_peaks def extract_features(phasic, fs=4.0): feats = {} # 统计特征 feats["mean"] = np.mean(phasic) feats["std"] = np.std(phasic) feats["skew"] = skew(phasic) feats["kurt"] = kurtosis(phasic) # SCR 峰值特征:幅度阈值设为 0.01 微西门子,按设备量纲调整 peaks, props = find_peaks(phasic, height=0.01, distance=int(fs * 1.0)) feats["scr_count"] = len(peaks) feats["scr_amp_mean"] = np.mean(props["peak_heights"]) if len(peaks) else 0.0 # 频域特征:0.05-0.5 Hz 频段能量占比 freqs = np.fft.rfftfreq(len(phasic), d=1.0 / fs) psd = np.abs(np.fft.rfft(phasic)) ** 2 band = (freqs >= 0.05) & (freqs <= 0.5) feats["psd_ratio"] = psd[band].sum() / (psd.sum() + 1e-8) return featsfind_peaks的height和distance是两个关键参数:height决定多小的波动算一次 SCR,太小会把噪声当峰值,太大会漏掉弱反应;distance限制两个峰值的最小间隔,一般设 1 秒,因为生理上 SCR 不会比这更快。psd_ratio用 FFT 粗略估计频段能量占比,比 Welch 法快,适合数据量大的场景。提取完所有样本的特征后,你会得到一个(n_samples, n_features)的矩阵,直接喂给 sklearn 就行。
提示:个体差异是 EDA 最大的坑。同一种情绪,不同人的皮电幅度可能差一个数量级。常见做法是对每个被试做 z-score 标准化,再合并训练,否则模型学到的可能是「谁」而不是「什么情绪」。
4. 分类模型训练与评估:从特征矩阵到情绪标签
4.1 模型选择与交叉验证
特征矩阵有了,标签也有了,接下来是分类。这个项目规模不大,用 SVM 或随机森林就够,没必要上深度学习。SVM 在小样本高维特征上表现稳定,随机森林则能直接输出特征重要性,方便你判断哪些特征真正有用:
from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score, StratifiedKFold from sklearn.preprocessing import StandardScaler from sklearn.pipeline import Pipeline def build_model(kind="svm"): if kind == "svm": clf = SVC(kernel="rbf", C=1.0, gamma="scale", probability=True) else: clf = RandomForestClassifier(n_estimators=200, max_depth=6, random_state=42) # 标准化必须放进 Pipeline,避免交叉验证时数据泄漏 return Pipeline([("scaler", StandardScaler()), ("clf", clf)]) def evaluate(X, y): cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) model = build_model("svm") scores = cross_val_score(model, X, y, cv=cv, scoring="f1_macro") print(f"F1 macro: {scores.mean():.3f} ± {scores.std():.3f}") return scores这里有个容易翻车的地方:标准化一定要放进Pipeline,不能先对全量数据fit_transform再交叉验证。否则验证折的统计量泄漏到了训练折,准确率会虚高,上线就露馅。StratifiedKFold保证每折里各类情绪比例一致,样本少的时候尤其重要。f1_macro比准确率更适合情绪识别,因为「平静」样本往往远多于「开心」和「悲伤」,用准确率会被多数类带偏。
4.2 结果评估与混淆矩阵
训练完不能只看一个数字,要看混淆矩阵,搞清楚模型到底把哪种情绪认错了:
from sklearn.metrics import confusion_matrix, classification_report from sklearn.model_selection import train_test_split def detailed_report(X, y): X_tr, X_te, y_tr, y_te = train_test_split( X, y, test_size=0.3, stratify=y, random_state=42 ) model = build_model("svm") model.fit(X_tr, y_tr) y_pred = model.predict(X_te) print(classification_report(y_te, y_pred)) print(confusion_matrix(y_te, y_pred)) return modelclassification_report会给出每类的 precision、recall、f1,confusion_matrix则直观显示错分方向。如果发现「悲伤」经常被认成「平静」,大概率是这两类的 phasic 特征区分度不够,需要回到特征工程补 SCR 上升时间、恢复时间这类形态特征。如果所有类都表现平平,先检查预处理是不是把有效信号也滤掉了——把bandpass_filter的high从 1.0 放宽到 2.0 再试一次,很多时候问题就出在这里。
5. 避坑与常见问题排查:EDA 情绪识别里最容易翻车的几件事
5.1 采样率不一致导致时间窗错位
现象:训练时准确率正常,换一批数据后断崖式下跌。原因:不同 CSV 的采样率不同,代码里按固定点数切窗,实际时间长度不一致,特征量纲全乱。解决:加载时先估算fs = 1 / np.median(np.diff(time)),统一重采样到 4Hz 再切窗,别用固定行数。
5.2 标签泄漏:文件名和特征里的隐藏信息
现象:交叉验证 F1 高得离谱,换测试集就崩。原因:特征里混入了被试编号、采集批次这类元信息,模型直接背下了标签。解决:特征矩阵只保留生理特征列,被试编号单独存一份用于分组交叉验证(GroupKFold),别让它进模型。
5.3 基线漂移没去干净
现象:同一段情绪,前半段和后半段预测结果相反。原因:tonic 成分缓慢漂移,phasic 里还残留趋势。解决:在decompose_eda之后再做一次去趋势,或者把窗长缩短到 10 秒以内,减少漂移影响。
5.4 类别极度不平衡
现象:模型把所有样本都预测成「平静」,准确率还有 70%。原因:normal_sad.csv这类文件样本量远大于其他类。解决:用class_weight="balanced",或者对少数类做 SMOTE 过采样,评估指标换成f1_macro。
5.5 峰值检测参数一刀切
现象:有的人数据一个 SCR 都检不出,有的人满屏都是峰。原因:find_peaks的height是绝对值,没考虑个体幅度差异。解决:先对每个被试的 phasic 做 z-score,再用相对阈值(如height=0.5)检测,或者用prominence替代height。
6. 二次开发与改造思路:把 demo 变成能用的模块
跑通之后,这个项目最值得改的地方有三个。第一是特征工程,现在的统计特征太粗,可以补上 SCR 的上升时间、半恢复时间、峰值间期变异性,这些形态特征在情绪唤醒度区分上比均值标准差有效得多。第二是模型层面,如果样本量能到几千条,可以试试把特征序列直接喂给 1D-CNN 或 LSTM,让网络自己学时序模式,但前提是做好被试分组划分,否则个体泄漏会让指标虚高。第三是工程化,把preprocess.py、features.py、train.py串成一个sklearn的Pipeline,用joblib保存,对外只暴露predict(eda_signal)一个接口,这样别人拿到的就是一个能直接调用的情绪识别模块,而不是一堆脚本。
改造时我一般会先写一个最小验证脚本,固定随机种子,跑三遍确认指标稳定,再动特征和模型。下面这个表格是我常用的改造优先级参考:
| 改造方向 | 预期收益 | 风险 | 建议顺序 |
|---|---|---|---|
| 补 SCR 形态特征 | 中高 | 低 | 1 |
| 被试内标准化 | 高 | 低 | 2 |
| 换时序模型 | 高 | 中 | 3 |
| 增加传感器融合 | 高 | 高 | 4 |
最后说个习惯:每次改完特征或模型,我都会把混淆矩阵打出来看一遍,确认错分方向没有变奇怪。有一次我加了一堆频域特征,F1 涨了 5 个点,结果混淆矩阵显示模型把「开心」和「悲伤」全认反了——原来是标签映射写错了。从那以后我每次动标签相关代码,都强制走一遍「打印前 10 个样本的标签和文件名」的检查。希望帮到你。
本文还有配套的精品资源,点击获取