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