资讯动态

基于Python的皮肤电信号情绪识别全流程:从预处理到模型部署

发布时间:2026/9/12 13:38:31 来源:尧图企业网站定制
简介面向情绪识别与机器学习初学者及课程设计人群这份基于Python皮肤电信号的情绪识别算法资源完整覆盖从数据采集、特征处理到模型训练与评估的全流程适合用于毕业设计、课堂项目或算法入门实战。压缩包共102个文件包含78个CSV格式的皮肤电信号样本数据、7个Python源码文件、3个pyc模型文件及4个MAT数据文件另有PPT演示文稿和Markdown技术文档便于快速理解算法思路与复现实验。现有215人学习查看资源经本地编译运行通过项目评审得分95分以上内容难度适中并已通过助教审定。下载后可获得可运行的整套源码、预训练模型、详细技术文档与全部实验数据足以支撑完成一个完整的情绪识别项目是兼顾学习与参考价值的实用资料。1. 皮肤电信号情绪识别为什么这个任务比想象中难拿到一份标注为happy_other.csv、normal_sad.csv、1.csv、2.csv这类文件的Python源码包时第一反应通常是“直接跑模型”。但真正做过皮肤电信号EDA/GSR情绪识别的人都知道这个任务的难点不在分类器而在信号本身EDA信号是低频、慢变化、且容易被运动伪迹污染的生物电信号情绪诱发的皮电响应SCR往往只有0.2到0.5微西门子左右的幅值波动还伴随基线漂移。这意味着如果不先做滤波、去伪迹和分段再好的模型也会被噪声带偏。这个资源里同时提供了源码、训练好的模型、PPT和全套数据适合正在做情感计算、人机交互或生理信号处理课题的学生和工程师。下面按实际项目推进顺序把预处理、特征、建模和部署的关键细节逐一拆开。2. 皮肤电信号预处理与特征提取从原始波形到可训练特征2.1 数据形态分析与缺失值处理拿到源码后我建议先不要急着 import 模型文件而是用 Pandas 把所有 CSV 的结构统一读一遍。这个资源里的 CSV 以情绪名或编号命名每列通常代表一个通道行代表采样时间点。第一步是确认采样率、通道数量、缺失值的分布。import pandas as pd import numpy as np df pd.read_csv(happy_other.csv) print(df.shape, df.columns.tolist()) print(df.isnull().sum()) # 极少数缺失点用前后均值填充不要直接删行 df df.interpolate(methodlinear, limit_directionboth) print(df.isnull().sum().sum())这段代码做的事情很直接先看形状和缺失量再用线性插值填充。皮肤电信号是连续采样的生理信号删除整行会破坏时间连续性线性插值在采样率较高时几乎不影响后续特征。注意limit_directionboth确保首尾的缺失点也能被填上。如果某个通道缺失超过百分之二十我一般直接丢弃该通道避免插值造出虚假波动。2.2 滤波与去伪迹移动平均和阈值剔除的配合EDA信号的主要成分集中在0.05Hz到1Hz之间情绪相关的慢波变化更低。高频噪声通常来自工频干扰和肌肉电伪迹运动伪迹则表现为短时大幅尖峰。常见做法是先用低通滤波压掉高频再用幅值阈值识别残留尖峰。from scipy.signal import butter, filtfilt def butter_lowpass(data, cutoff2.0, fs100.0, order4): nyq 0.5 * fs normal_cutoff cutoff / nyq b, a butter(order, normal_cutoff, btypelow, analogFalse) return filtfilt(b, a, data) fs 100 # 假设采样率100Hz按资源文档调整 filtered butter_lowpass(df.iloc[:, 0].values, cutoff1.5, fsfs) # 标记超过均值±4倍标准差的点视为运动伪迹 mean np.mean(filtered) std np.std(filtered) artifact_idx np.where(np.abs(filtered - mean) 4 * std)[0] print(f伪迹点数量: {len(artifact_idx)})这里选择cutoff1.5Hz是因为 EDA 的有效频带一般不超过 1Hz保留 1.5Hz 是一个比较宽松的上限能在滤掉高频肌电的同时不把皮电响应顶部削平。filtfilt是零相位滤波不会引入相位偏移这是处理生物电信号时的硬性要求用普通lfilter会导致 SCR 峰值位置产生时移影响后续分段对齐。伪迹点我只做标记等分段后再判断是否剔除整个片段因为单纯差值补点可能把一次实际的情绪诱发响应误删掉。2.3 滑动窗口与统计特征情绪识别不可能拿整段长信号直接分类因为一次实验中被试可能经历了多个情绪片段。常见做法是固定窗口切分窗口大小根据诱因时长设定比如 10 秒窗口、5 秒步长。每个窗口内需要计算一组能刻画皮电水平SCL和皮电响应SCR的特征。from scipy.stats import skew, kurtosis def extract_features(signal, fs, win_sec10, step_sec5): win_len win_sec * fs step_len step_sec * fs features [] for start in range(0, len(signal) - win_len 1, step_len): seg signal[start:start win_len] # 时域特征 seg_mean np.mean(seg) seg_std np.std(seg) seg_min np.min(seg) seg_max np.max(seg) seg_range seg_max - seg_min seg_skew skew(seg) seg_kurt kurtosis(seg) # 一阶差分近似皮电响应变化率 diff np.diff(seg) seg_deriv_mean np.mean(diff) seg_deriv_std np.std(diff) # 过零率 zero_cross np.sum(np.abs(np.diff(np.signbit(seg)).astype(int))) # 频域特征用FFT计算2Hz以下能量占比 freqs np.fft.rfftfreq(len(seg), d1/fs) fft_vals np.abs(np.fft.rfft(seg)) mask freqs 2.0 seg_low_energy np.sum(fft_vals[mask]) / np.sum(fft_vals 1e-10) features.append([seg_mean, seg_std, seg_min, seg_max, seg_range, seg_skew, seg_kurt, seg_deriv_mean, seg_deriv_std, zero_cross, seg_low_energy]) return np.array(features) feat extract_features(filtered, fs) print(feat.shape, feat[:2])这段代码输出了每个窗口的 11 维特征。其中diff模拟了信号的瞬时变化率SCR 的上升沿通常持续 1 到 3 秒因此差分均值能反映情绪唤醒带来的快速变化。过零率对高频噪声敏感但经过前面的滤波后它能在一定程度上反映信号的呼吸性波动。频域特征里的低频能量占比是判断EDA是否含有足够皮电活动的关键情绪平静时段低频能量占比通常偏低。特征维数不高避免小样本场景下训练过拟合。2.4 多窗口特征合并与样本标签对齐切窗之后还要做一步关键操作把原始文件名对应的情绪标签映射到每个窗口上。命名方式类似happy_other.csv和normal_sad.csv说明这份资源里不是简单的单标签分类可能同时包含混合情绪状态。label_map { happy_other: 0, normal_sad: 1, 1: 2, 2: 2, 9: 2, 18: 2, 19: 2, 20: 2, 21: 2, 22: 2, }这里只是给出映射思路具体数字要对照资源里的 PPT 或文档。1.csv这类纯数字命名很可能来自同一批受试者的不同试次标签应该与文档里的实验范式对应不能凭文件名猜测。我通常会在读入时维护一个sample_info列表把subject_id、session_id和窗口序号记录成元数据为后面的留一被试交叉验证做准备。合并时需要注意窗口边界不能跨两个不同情绪片段。如果原始 CSV 本身已经是分段录制可以先按行号分段再切窗。如果确实无法知道分段边界就把每一行 CSV 独立处理不要在文件内部跨文件拼接数据。3. 基于Python的情绪识别模型构建从基线到多分类3.1 特征矩阵与标签对齐特征提取完成后需要把全部 CSV 的特征堆叠成一个矩阵同时保持标签顺序一致。这里最常踩的坑是遍历文件时使用了字典或集合导致顺序不固定最后特征和标签错位。我一般用有序列表按文件名排序处理并在拼接后用一个assert检查行数。import os import numpy as np feature_list [] label_list [] file_order [] for fname in sorted(os.listdir(data)): if not fname.endswith(.csv): continue signal pd.read_csv(os.path.join(data, fname)).iloc[:, 0].values signal_f butter_lowpass(signal, cutoff1.5, fsfs) feats extract_features(signal_f, fs) feature_list.append(feats) label_list.append(np.full(feats.shape[0], label_map.get(fname.replace(.csv, ), 2))) file_order.append(fname) X np.vstack(feature_list) y np.concatenate(label_list) print(X shape:, X.shape, y shape:, y.shape) assert X.shape[0] y.shape[0]np.vstack会把每个文件产生的窗口特征按顺序堆叠np.full为每个窗口生成对应标签。最后用assert保证行数一致这是一道廉价但有效的防线。这里的label_map一定要在用之前打印确认特别是数字命名的文件。3.2 分类器选型为什么首选随机森林和XGBoost皮肤电信号样本量通常不会很大一共几十个 CSV 文件切窗后最多几千个样本。做情绪分类时我倾向于先跑随机森林和 XGBoost 两个基线而不是直接上深度学习原因有两点。其一特征维度只有 11 维左右树模型对非线性关系的刻画能力足够而且不容易被个别离群特征带偏。其二生理信号的标签本身带噪声同一个被试在不同时间做同一实验皮电响应幅值也会相差明显树模型对尺度变化不敏感泛化更稳。SVM 也可以作为对比但对特征尺度敏感必须先标准化。深度学习在这个任务里并非不行但千级样本量的 LSTM 容易过拟合需要配合数据增强和正则化作为课程项目投入产出比不高。3.3 训练脚本与关键参数下面用随机森林给出一个可直接运行的训练基线同时保留 XGBoost 的切换入口。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_size0.2, random_state42, stratifyy ) rf RandomForestClassifier( n_estimators300, max_depth10, min_samples_leaf3, class_weightbalanced, random_state42 ) rf.fit(X_train, y_train) y_pred rf.predict(X_test) print(classification_report(y_test, y_pred))参数设置并不是越大越好。n_estimators300在千级样本下足够收敛继续加树只会拖慢训练。max_depth10防止树过深记住单条样本min_samples_leaf3强制每个叶子至少有三个样本这对噪声较大的生理信号很有效。class_weightbalanced是必要的因为情绪类别往往不平衡happy 和 other 的比例可能明显高于 sad。分类报告里的macro avg在这种情况下比粗糙的 accuracy 更有参考价值。如果要切到 XGBoost只需把分类器替换掉但要注意把数据集转成 DMatrix 是 XGBoost 的标准做法。from xgboost import XGBClassifier xgb XGBClassifier( n_estimators200, max_depth4, learning_rate0.05, subsample0.8, colsample_bytree0.8, eval_metricmlogloss, use_label_encoderFalse ) xgb.fit(X_train, y_train, verboseFalse)subsample和colsample_bytree都设为 0.8相当于每棵树只用 80% 的样本和 80% 的特征这是一种轻量正则化比单纯调高max_depth性价比更高。3.4 类别不平衡的额外处理如果class_weightbalanced依然不够我会再做一次分层采样把数量最多的类别下采样到接近第二大类别的量级。注意不要对测试集做任何重采样只动训练集。from imblearn.under_sampling import RandomUnderSampler rus RandomUnderSampler(random_state42) X_resampled, y_resampled rus.fit_resample(X_train, y_train) print({label: sum(y_resampled label) for label in set(y_resampled)})RandomUnderSampler会随机丢掉多数类样本。这里有信息损失但对于严重失衡且样本量本来就不大的场景比盲目过采样复制少数类更不容易过拟合。也可以尝试 SMOTE 过采样但生成的人工 EDA 特征可能会破坏生理信号固有的相关性所以课程项目里我优先做下采样。4. 模型评估与调优混淆矩阵、交叉验证与鲁棒性验证4.1 评估指标选择情绪识别里准确率是最不靠谱的指标。类别不平衡时模型把全部样本预测为多数类也能拿到高分但实际意义为零。更合理的指标是查准率、召回率和 F1-score。对于皮电情绪识别我尤其关注召回率因为漏报意味着没有检测到某种情绪状态比误报更影响后续人机交互决策。from sklearn.metrics import confusion_matrix, accuracy_score cm confusion_matrix(y_test, y_pred) print(cm) acc accuracy_score(y_test, y_pred) print(faccuracy: {acc:.3f})混淆矩阵打印出来后重点关注对角线的相对大小。如果某个类别的行和列都偏低说明该类样本太少或特征可分性差下一步可以针对该类别单独做特征可视化。4.2 K折交叉验证与留一被试验证普通train_test_split只能用来快速验证流程有没有跑通不能作为最终结论。原因是被试的个体差异极大同一个人的训练集和测试集窗口有相似分布会虚高估计精度。真正可靠的做法是留一被试验证Leave-One-Subject-Out即把同一个被试的所有窗口全部放到测试集其他被试的数据训练模型。后面我会专门写一个按被试划分的交叉验证脚本。一个被试在训练时完全不可见这比随机切分更接近真实部署场景。一次性实验里某人的基线皮电水平偏高不代表新用户也会这样这一点在做生理信号识别时必须强调。4.3 超参数搜索随机森林的超参数空间不大用网格搜索就能覆盖。我一般只搜max_depth、min_samples_leaf和n_estimators不要搜太多维否则在交叉验证里会放大噪声。from sklearn.model_selection import GridSearchCV param_grid { max_depth: [6, 10, 14], min_samples_leaf: [2, 4, 6], n_estimators: [200, 300] } grid GridSearchCV( RandomForestClassifier(class_weightbalanced, random_state42), param_grid, cv5, scoringf1_macro, n_jobs-1 ) grid.fit(X_train, y_train) print(grid.best_params_)scoringf1_macro很关键。如果使用默认 accuracy搜索会偏向多数类使用f1_macro会将每个类别的 F1 先算平均再优化让模型在少数类上也保持一定表现。cv5在几百到几千样本量下是比较合适的折中cv10会显著增加训练时间但对结果提升有限。4.4 失败案例分析什么时候该回退到特征工程做完一轮交叉验证后如果 F1 依然低于 0.6我不会继续调参而是回头检查两个点。第一滑动窗口是否覆盖了完整的情绪诱发区间。情绪诱发的 SCR 在刺激出现后 1 到 5 秒才开始上升窗口太短会把上升沿截断导致特征丢失。第二原始数据是否已经经过逐文件的人工排查。有时候某个2.csv里混入了大量运动伪迹在 2.2 节里没有完全剔除这种坏数据会让整个类别被污染。快速排查方法是对每个文件单独跑一遍模型输出每个文件窗口的平均预测置信度如果某个文件的置信度明显低于同类别其他文件就人工查看该文件的波形。5. 模型落地信号流改造与实时情绪识别的避坑细节5.1 离线流程改造成实时推理课程项目和真实应用之间隔着一步如何把离线切窗流程改成流式处理。实时推理时不能等整个窗口都收集完再算特征而是每来一个新采样点就滑动一个步长。常见做法是维护一个固定长度的缓冲区缓冲区满后立即复用extract_features函数但要把特征计算函数改成只对最近一个窗口计算。from collections import deque buffer deque(maxlen1000) # 10s * 100Hz def on_sample(value, fs100): buffer.append(value) if len(buffer) buffer.maxlen: seg np.array(buffer) filtered butter_lowpass(seg, cutoff1.5, fsfs) feat extract_features(filtered, fs)[-1].reshape(1, -1) proba model.predict_proba(feat)[0] return proba return Nonedeque(maxlen1000)会在窗口填满后自动丢弃最老的数据省去手动管理数组的麻烦。注意extract_features在实时模式下不能再用原来的循环切窗逻辑需要改成只处理seg的快速版本否则会重复计算大量无用窗口。模型文件要用训练好的model对象不能每次采样都重新加载。5.2 模型文件管理与推理接口资源里自带模型文件但训练用的 Python 版本和依赖可能与当前环境不完全一致。加载时建议用joblib或pickle并打印模型类型确认。import joblib model joblib.load(emotion_model.pkl) print(type(model))如果model是RandomForestClassifier直接调用predict_proba是安全的。如果是 XGBoost 模型需要保持与训练时一致的use_label_encoder开关状态。如果加载报版本不兼容错误最简单的方案是重训模型因为这个数据集中样本量不大重训耗时通常在一分钟内。5.3 常见坑位清单实时部署时最容易踩的坑有三个。第一个是采样率不一致训练时假设fs100部署时如果是另一套硬件以 200Hz 采集必须重采样或者动态调整缓冲区长度。第二个是滤波造成的冷启动问题filtfilt对短信号会有边界效应实时场景下缓冲区刚填满时滤波结果前几个点不可靠建议丢弃窗口前 5% 的预测结果。第三个是特征归一化状态丢失如果训练时用了StandardScaler部署时要保存同一个 scaler 对象不能手动用测试集的均值和方差替代。坑点表现规避方式采样率不一致特征分布偏移部署端统一重采样到训练采样率窗口前端滤波失真前几个点预测跳变丢弃窗口前 5% 预测归一化参数丢失精度明显下降与模型一起保存 scaler缓冲区未清空不同被试数据混合被试切换时清空 buffer5.4 快速验证脚本最后给一个可直接用的验证脚本用来确认整个落地链路是否工作正常。它读取一个新的 CSV 文件经过滤波、特征提取和模型预测输出每个窗口的预测类别和置信度作为模型上线前的冒烟测试。import sys def predict_file(path, model, fs100): raw pd.read_csv(path).iloc[:, 0].values filt butter_lowpass(raw, cutoff1.5, fsfs) feats extract_features(filt, fs) proba model.predict_proba(feats) preds model.predict(feats) for i, p in enumerate(proba): print(fwindow {i}: pred{preds[i]}, conf{p.max():.3f}) if __name__ __main__: model joblib.load(sys.argv[1]) predict_file(sys.argv[2], model)这段脚本输出置信度最高的类别和对应概率。如果某个窗口所有类别的置信度都低于 0.4说明输入信号质量不佳需要回到 2.2 节加大滤波强度或提高伪迹剔除阈值。这比直接把预测结果拿去给业务方要稳妥得多因为情绪识别系统的下游动作往往跟安防、教育、医疗相关低置信度的结果必须有明确的拒绝机制。用这个脚本做完一整个新文件的前向验证整个源码包的可用性就算真正证明了。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价