處理到模型部署)
簡介面向情緒識別與機(jī)器學(xué)習(xí)初學(xué)者及課程設(shè)計(jì)人群這份基于Python皮膚電信號的情緒識別算法資源完整覆蓋從數(shù)據(jù)采集、特征處理到模型訓(xùn)練與評估的全流程適合用于畢業(yè)設(shè)計(jì)、課堂項(xiàng)目或算法入門實(shí)戰(zhàn)。壓縮包共102個(gè)文件包含78個(gè)CSV格式的皮膚電信號樣本數(shù)據(jù)、7個(gè)Python源碼文件、3個(gè)pyc模型文件及4個(gè)MAT數(shù)據(jù)文件另有PPT演示文稿和Markdown技術(shù)文檔便于快速理解算法思路與復(fù)現(xiàn)實(shí)驗(yàn)。現(xiàn)有215人學(xué)習(xí)查看資源經(jīng)本地編譯運(yùn)行通過項(xiàng)目評審得分95分以上內(nèi)容難度適中并已通過助教審定。下載后可獲得可運(yùn)行的整套源碼、預(yù)訓(xùn)練模型、詳細(xì)技術(shù)文檔與全部實(shí)驗(yàn)數(shù)據(jù)足以支撐完成一個(gè)完整的情緒識別項(xiàng)目是兼顧學(xué)習(xí)與參考價(jià)值的實(shí)用資料。1. 皮膚電信號情緒識別為什么這個(gè)任務(wù)比想象中難拿到一份標(biāo)注為happy_other.csv、normal_sad.csv、1.csv、2.csv這類文件的Python源碼包時(shí)第一反應(yīng)通常是“直接跑模型”。但真正做過皮膚電信號EDA/GSR情緒識別的人都知道這個(gè)任務(wù)的難點(diǎn)不在分類器而在信號本身EDA信號是低頻、慢變化、且容易被運(yùn)動偽跡污染的生物電信號情緒誘發(fā)的皮電響應(yīng)SCR往往只有0.2到0.5微西門子左右的幅值波動還伴隨基線漂移。這意味著如果不先做濾波、去偽跡和分段再好的模型也會被噪聲帶偏。這個(gè)資源里同時(shí)提供了源碼、訓(xùn)練好的模型、PPT和全套數(shù)據(jù)適合正在做情感計(jì)算、人機(jī)交互或生理信號處理課題的學(xué)生和工程師。下面按實(shí)際項(xiàng)目推進(jìn)順序把預(yù)處理、特征、建模和部署的關(guān)鍵細(xì)節(jié)逐一拆開。2. 皮膚電信號預(yù)處理與特征提取從原始波形到可訓(xùn)練特征2.1 數(shù)據(jù)形態(tài)分析與缺失值處理拿到源碼后我建議先不要急著 import 模型文件而是用 Pandas 把所有 CSV 的結(jié)構(gòu)統(tǒng)一讀一遍。這個(gè)資源里的 CSV 以情緒名或編號命名每列通常代表一個(gè)通道行代表采樣時(shí)間點(diǎn)。第一步是確認(rèn)采樣率、通道數(shù)量、缺失值的分布。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()) # 極少數(shù)缺失點(diǎn)用前后均值填充不要直接刪行 df df.interpolate(methodlinear, limit_directionboth) print(df.isnull().sum().sum())這段代碼做的事情很直接先看形狀和缺失量再用線性插值填充。皮膚電信號是連續(xù)采樣的生理信號刪除整行會破壞時(shí)間連續(xù)性線性插值在采樣率較高時(shí)幾乎不影響后續(xù)特征。注意limit_directionboth確保首尾的缺失點(diǎn)也能被填上。如果某個(gè)通道缺失超過百分之二十我一般直接丟棄該通道避免插值造出虛假波動。2.2 濾波與去偽跡移動平均和閾值剔除的配合EDA信號的主要成分集中在0.05Hz到1Hz之間情緒相關(guān)的慢波變化更低。高頻噪聲通常來自工頻干擾和肌肉電偽跡運(yùn)動偽跡則表現(xiàn)為短時(shí)大幅尖峰。常見做法是先用低通濾波壓掉高頻再用幅值閾值識別殘留尖峰。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 # 假設(shè)采樣率100Hz按資源文檔調(diào)整 filtered butter_lowpass(df.iloc[:, 0].values, cutoff1.5, fsfs) # 標(biāo)記超過均值±4倍標(biāo)準(zhǔn)差的點(diǎn)視為運(yùn)動偽跡 mean np.mean(filtered) std np.std(filtered) artifact_idx np.where(np.abs(filtered - mean) 4 * std)[0] print(f偽跡點(diǎn)數(shù)量: {len(artifact_idx)})這里選擇cutoff1.5Hz是因?yàn)?EDA 的有效頻帶一般不超過 1Hz保留 1.5Hz 是一個(gè)比較寬松的上限能在濾掉高頻肌電的同時(shí)不把皮電響應(yīng)頂部削平。filtfilt是零相位濾波不會引入相位偏移這是處理生物電信號時(shí)的硬性要求用普通lfilter會導(dǎo)致 SCR 峰值位置產(chǎn)生時(shí)移影響后續(xù)分段對齊。偽跡點(diǎn)我只做標(biāo)記等分段后再判斷是否剔除整個(gè)片段因?yàn)閱渭儾钪笛a(bǔ)點(diǎn)可能把一次實(shí)際的情緒誘發(fā)響應(yīng)誤刪掉。2.3 滑動窗口與統(tǒng)計(jì)特征情緒識別不可能拿整段長信號直接分類因?yàn)橐淮螌?shí)驗(yàn)中被試可能經(jīng)歷了多個(gè)情緒片段。常見做法是固定窗口切分窗口大小根據(jù)誘因時(shí)長設(shè)定比如 10 秒窗口、5 秒步長。每個(gè)窗口內(nèi)需要計(jì)算一組能刻畫皮電水平SCL和皮電響應(yīng)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] # 時(shí)域特征 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) # 一階差分近似皮電響應(yīng)變化率 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計(jì)算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])這段代碼輸出了每個(gè)窗口的 11 維特征。其中diff模擬了信號的瞬時(shí)變化率SCR 的上升沿通常持續(xù) 1 到 3 秒因此差分均值能反映情緒喚醒帶來的快速變化。過零率對高頻噪聲敏感但經(jīng)過前面的濾波后它能在一定程度上反映信號的呼吸性波動。頻域特征里的低頻能量占比是判斷EDA是否含有足夠皮電活動的關(guān)鍵情緒平靜時(shí)段低頻能量占比通常偏低。特征維數(shù)不高避免小樣本場景下訓(xùn)練過擬合。2.4 多窗口特征合并與樣本標(biāo)簽對齊切窗之后還要做一步關(guān)鍵操作把原始文件名對應(yīng)的情緒標(biāo)簽映射到每個(gè)窗口上。命名方式類似happy_other.csv和normal_sad.csv說明這份資源里不是簡單的單標(biāo)簽分類可能同時(shí)包含混合情緒狀態(tài)。label_map { happy_other: 0, normal_sad: 1, 1: 2, 2: 2, 9: 2, 18: 2, 19: 2, 20: 2, 21: 2, 22: 2, }這里只是給出映射思路具體數(shù)字要對照資源里的 PPT 或文檔。1.csv這類純數(shù)字命名很可能來自同一批受試者的不同試次標(biāo)簽應(yīng)該與文檔里的實(shí)驗(yàn)范式對應(yīng)不能憑文件名猜測。我通常會在讀入時(shí)維護(hù)一個(gè)sample_info列表把subject_id、session_id和窗口序號記錄成元數(shù)據(jù)為后面的留一被試交叉驗(yàn)證做準(zhǔn)備。合并時(shí)需要注意窗口邊界不能跨兩個(gè)不同情緒片段。如果原始 CSV 本身已經(jīng)是分段錄制可以先按行號分段再切窗。如果確實(shí)無法知道分段邊界就把每一行 CSV 獨(dú)立處理不要在文件內(nèi)部跨文件拼接數(shù)據(jù)。3. 基于Python的情緒識別模型構(gòu)建從基線到多分類3.1 特征矩陣與標(biāo)簽對齊特征提取完成后需要把全部 CSV 的特征堆疊成一個(gè)矩陣同時(shí)保持標(biāo)簽順序一致。這里最常踩的坑是遍歷文件時(shí)使用了字典或集合導(dǎo)致順序不固定最后特征和標(biāo)簽錯(cuò)位。我一般用有序列表按文件名排序處理并在拼接后用一個(gè)assert檢查行數(shù)。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會把每個(gè)文件產(chǎn)生的窗口特征按順序堆疊np.full為每個(gè)窗口生成對應(yīng)標(biāo)簽。最后用assert保證行數(shù)一致這是一道廉價(jià)但有效的防線。這里的label_map一定要在用之前打印確認(rèn)特別是數(shù)字命名的文件。3.2 分類器選型為什么首選隨機(jī)森林和XGBoost皮膚電信號樣本量通常不會很大一共幾十個(gè) CSV 文件切窗后最多幾千個(gè)樣本。做情緒分類時(shí)我傾向于先跑隨機(jī)森林和 XGBoost 兩個(gè)基線而不是直接上深度學(xué)習(xí)原因有兩點(diǎn)。其一特征維度只有 11 維左右樹模型對非線性關(guān)系的刻畫能力足夠而且不容易被個(gè)別離群特征帶偏。其二生理信號的標(biāo)簽本身帶噪聲同一個(gè)被試在不同時(shí)間做同一實(shí)驗(yàn)皮電響應(yīng)幅值也會相差明顯樹模型對尺度變化不敏感泛化更穩(wěn)。SVM 也可以作為對比但對特征尺度敏感必須先標(biāo)準(zhǔn)化。深度學(xué)習(xí)在這個(gè)任務(wù)里并非不行但千級樣本量的 LSTM 容易過擬合需要配合數(shù)據(jù)增強(qiáng)和正則化作為課程項(xiàng)目投入產(chǎn)出比不高。3.3 訓(xùn)練腳本與關(guān)鍵參數(shù)下面用隨機(jī)森林給出一個(gè)可直接運(yùn)行的訓(xùn)練基線同時(shí)保留 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))參數(shù)設(shè)置并不是越大越好。n_estimators300在千級樣本下足夠收斂繼續(xù)加樹只會拖慢訓(xùn)練。max_depth10防止樹過深記住單條樣本min_samples_leaf3強(qiáng)制每個(gè)葉子至少有三個(gè)樣本這對噪聲較大的生理信號很有效。class_weightbalanced是必要的因?yàn)榍榫w類別往往不平衡happy 和 other 的比例可能明顯高于 sad。分類報(bào)告里的macro avg在這種情況下比粗糙的 accuracy 更有參考價(jià)值。如果要切到 XGBoost只需把分類器替換掉但要注意把數(shù)據(jù)集轉(zhuǎn)成 DMatrix 是 XGBoost 的標(biāo)準(zhǔn)做法。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都設(shè)為 0.8相當(dāng)于每棵樹只用 80% 的樣本和 80% 的特征這是一種輕量正則化比單純調(diào)高max_depth性價(jià)比更高。3.4 類別不平衡的額外處理如果class_weightbalanced依然不夠我會再做一次分層采樣把數(shù)量最多的類別下采樣到接近第二大類別的量級。注意不要對測試集做任何重采樣只動訓(xùn)練集。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會隨機(jī)丟掉多數(shù)類樣本。這里有信息損失但對于嚴(yán)重失衡且樣本量本來就不大的場景比盲目過采樣復(fù)制少數(shù)類更不容易過擬合。也可以嘗試 SMOTE 過采樣但生成的人工 EDA 特征可能會破壞生理信號固有的相關(guān)性所以課程項(xiàng)目里我優(yōu)先做下采樣。4. 模型評估與調(diào)優(yōu)混淆矩陣、交叉驗(yàn)證與魯棒性驗(yàn)證4.1 評估指標(biāo)選擇情緒識別里準(zhǔn)確率是最不靠譜的指標(biāo)。類別不平衡時(shí)模型把全部樣本預(yù)測為多數(shù)類也能拿到高分但實(shí)際意義為零。更合理的指標(biāo)是查準(zhǔn)率、召回率和 F1-score。對于皮電情緒識別我尤其關(guān)注召回率因?yàn)槁﹫?bào)意味著沒有檢測到某種情緒狀態(tài)比誤報(bào)更影響后續(xù)人機(jī)交互決策。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})混淆矩陣打印出來后重點(diǎn)關(guān)注對角線的相對大小。如果某個(gè)類別的行和列都偏低說明該類樣本太少或特征可分性差下一步可以針對該類別單獨(dú)做特征可視化。4.2 K折交叉驗(yàn)證與留一被試驗(yàn)證普通train_test_split只能用來快速驗(yàn)證流程有沒有跑通不能作為最終結(jié)論。原因是被試的個(gè)體差異極大同一個(gè)人的訓(xùn)練集和測試集窗口有相似分布會虛高估計(jì)精度。真正可靠的做法是留一被試驗(yàn)證Leave-One-Subject-Out即把同一個(gè)被試的所有窗口全部放到測試集其他被試的數(shù)據(jù)訓(xùn)練模型。后面我會專門寫一個(gè)按被試劃分的交叉驗(yàn)證腳本。一個(gè)被試在訓(xùn)練時(shí)完全不可見這比隨機(jī)切分更接近真實(shí)部署場景。一次性實(shí)驗(yàn)里某人的基線皮電水平偏高不代表新用戶也會這樣這一點(diǎn)在做生理信號識別時(shí)必須強(qiáng)調(diào)。4.3 超參數(shù)搜索隨機(jī)森林的超參數(shù)空間不大用網(wǎng)格搜索就能覆蓋。我一般只搜max_depth、min_samples_leaf和n_estimators不要搜太多維否則在交叉驗(yàn)證里會放大噪聲。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很關(guān)鍵。如果使用默認(rèn) accuracy搜索會偏向多數(shù)類使用f1_macro會將每個(gè)類別的 F1 先算平均再優(yōu)化讓模型在少數(shù)類上也保持一定表現(xiàn)。cv5在幾百到幾千樣本量下是比較合適的折中cv10會顯著增加訓(xùn)練時(shí)間但對結(jié)果提升有限。4.4 失敗案例分析什么時(shí)候該回退到特征工程做完一輪交叉驗(yàn)證后如果 F1 依然低于 0.6我不會繼續(xù)調(diào)參而是回頭檢查兩個(gè)點(diǎn)。第一滑動窗口是否覆蓋了完整的情緒誘發(fā)區(qū)間。情緒誘發(fā)的 SCR 在刺激出現(xiàn)后 1 到 5 秒才開始上升窗口太短會把上升沿截?cái)鄬?dǎo)致特征丟失。第二原始數(shù)據(jù)是否已經(jīng)經(jīng)過逐文件的人工排查。有時(shí)候某個(gè)2.csv里混入了大量運(yùn)動偽跡在 2.2 節(jié)里沒有完全剔除這種壞數(shù)據(jù)會讓整個(gè)類別被污染。快速排查方法是對每個(gè)文件單獨(dú)跑一遍模型輸出每個(gè)文件窗口的平均預(yù)測置信度如果某個(gè)文件的置信度明顯低于同類別其他文件就人工查看該文件的波形。5. 模型落地信號流改造與實(shí)時(shí)情緒識別的避坑細(xì)節(jié)5.1 離線流程改造成實(shí)時(shí)推理課程項(xiàng)目和真實(shí)應(yīng)用之間隔著一步如何把離線切窗流程改成流式處理。實(shí)時(shí)推理時(shí)不能等整個(gè)窗口都收集完再算特征而是每來一個(gè)新采樣點(diǎn)就滑動一個(gè)步長。常見做法是維護(hù)一個(gè)固定長度的緩沖區(qū)緩沖區(qū)滿后立即復(fù)用extract_features函數(shù)但要把特征計(jì)算函數(shù)改成只對最近一個(gè)窗口計(jì)算。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)會在窗口填滿后自動丟棄最老的數(shù)據(jù)省去手動管理數(shù)組的麻煩。注意extract_features在實(shí)時(shí)模式下不能再用原來的循環(huán)切窗邏輯需要改成只處理seg的快速版本否則會重復(fù)計(jì)算大量無用窗口。模型文件要用訓(xùn)練好的model對象不能每次采樣都重新加載。5.2 模型文件管理與推理接口資源里自帶模型文件但訓(xùn)練用的 Python 版本和依賴可能與當(dāng)前環(huán)境不完全一致。加載時(shí)建議用joblib或pickle并打印模型類型確認(rèn)。import joblib model joblib.load(emotion_model.pkl) print(type(model))如果model是RandomForestClassifier直接調(diào)用predict_proba是安全的。如果是 XGBoost 模型需要保持與訓(xùn)練時(shí)一致的use_label_encoder開關(guān)狀態(tài)。如果加載報(bào)版本不兼容錯(cuò)誤最簡單的方案是重訓(xùn)模型因?yàn)檫@個(gè)數(shù)據(jù)集中樣本量不大重訓(xùn)耗時(shí)通常在一分鐘內(nèi)。5.3 常見坑位清單實(shí)時(shí)部署時(shí)最容易踩的坑有三個(gè)。第一個(gè)是采樣率不一致訓(xùn)練時(shí)假設(shè)fs100部署時(shí)如果是另一套硬件以 200Hz 采集必須重采樣或者動態(tài)調(diào)整緩沖區(qū)長度。第二個(gè)是濾波造成的冷啟動問題filtfilt對短信號會有邊界效應(yīng)實(shí)時(shí)場景下緩沖區(qū)剛填滿時(shí)濾波結(jié)果前幾個(gè)點(diǎn)不可靠建議丟棄窗口前 5% 的預(yù)測結(jié)果。第三個(gè)是特征歸一化狀態(tài)丟失如果訓(xùn)練時(shí)用了StandardScaler部署時(shí)要保存同一個(gè) scaler 對象不能手動用測試集的均值和方差替代。坑點(diǎn)表現(xiàn)規(guī)避方式采樣率不一致特征分布偏移部署端統(tǒng)一重采樣到訓(xùn)練采樣率窗口前端濾波失真前幾個(gè)點(diǎn)預(yù)測跳變丟棄窗口前 5% 預(yù)測歸一化參數(shù)丟失精度明顯下降與模型一起保存 scaler緩沖區(qū)未清空不同被試數(shù)據(jù)混合被試切換時(shí)清空 buffer5.4 快速驗(yàn)證腳本最后給一個(gè)可直接用的驗(yàn)證腳本用來確認(rèn)整個(gè)落地鏈路是否工作正常。它讀取一個(gè)新的 CSV 文件經(jīng)過濾波、特征提取和模型預(yù)測輸出每個(gè)窗口的預(yù)測類別和置信度作為模型上線前的冒煙測試。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)這段腳本輸出置信度最高的類別和對應(yīng)概率。如果某個(gè)窗口所有類別的置信度都低于 0.4說明輸入信號質(zhì)量不佳需要回到 2.2 節(jié)加大濾波強(qiáng)度或提高偽跡剔除閾值。這比直接把預(yù)測結(jié)果拿去給業(yè)務(wù)方要穩(wěn)妥得多因?yàn)榍榫w識別系統(tǒng)的下游動作往往跟安防、教育、醫(yī)療相關(guān)低置信度的結(jié)果必須有明確的拒絕機(jī)制。用這個(gè)腳本做完一整個(gè)新文件的前向驗(yàn)證整個(gè)源碼包的可用性就算真正證明了。本文還有配套的精品資源點(diǎn)擊獲取