首页 / 资讯中心 / 文章详情

肌电信号分类实战:从数据预处理到随机森林避坑指南

肌电信号分类实战:从数据预处理到随机森林避坑指南 ★ FEATURED ARTICLE
简介面向生物医学工程、康复医学及人机交互方向的肌电信号分类实践资源以解决sEMG特征提取与模式识别问题为核心适用于手势识别、康复评估等典型场景。压缩包共7个文件含6个Python脚本与1个zip原始数据集整体14.8MB。其中脚本分别实现主成分分析、支持向量机递归消除、相关性热力图三种特征抽取方法以及K近邻、支持向量机、随机森林三种分类器覆盖从特征降维、选择到模型训练、评估的完整流程。数据集可直接用于算法验证便于直观对比不同特征与分类组合的效果脚本与数据配合紧密可支持完整实验复现。已有537人学习下载适合具备一定机器学习基础、希望复现肌电手势识别或肌肉活动分类的入门与进阶者通过动手实践还能将高维生物信号处理思路迁移至假肢控制、运动分析等项目中。1. 肌电信号分类数据集别急着跑模型先搞懂这批样本能回答什么问题如果你是从 2022.5.21 那篇博客找过来的那这篇笔记要聊的东西就非常明确一份肌电信号分类数据集配上对应的分类代码目标是把表面肌电信号SEMG映射成几个离散动作类别比如握拳、伸掌、手腕内翻、手腕外翻再或者是手指 pinch。这类项目的应用方向很直白——假肢控制、手势交互、康复评估本质上都是把「肌肉发力特征」翻译成「用户意图」。做这个方向的人也很杂做毕设的本科生、刚接触生物信号处理的研究生、想验证某个分类算法能不能用在 EMG 上的工程师都属于目标读者。我想先说一个反直觉的结论这类项目的准确率瓶颈通常不是分类器而是数据划分方式和窗口构造方式。很多人在训练集上拿到 95%换一天再测就掉到 60%问题出在数据泄漏和跨受试者分布漂移上。所以这篇我会把从数据加载、预处理、特征提取到分类器训练和评估的整条链路拆开讲并把最容易翻车的地方单独拎出来。2. 读懂肌电数据集结构通道、采样率、动作标签怎么对应到一次分类任务2.1 表面肌电信号的采集逻辑与数据存储格式表面肌电信号是皮肤表面记录到的运动单元动作电位叠加幅度在微伏到毫伏级能量主要集中在 20–500Hz 这个频段。这意味着它天然不适合直接用原始波形做长时间序列建模——你感兴趣的是肌肉激活的统计特征而不是每个采样点的绝对数值。常见采集方案是贴 4 到 16 个电极贴在对应肌肉的肌腹位置例如前臂的屈指肌群和伸指肌群。采样率常见 1000Hz 或 2000Hz1000Hz 就够覆盖 500Hz 的有效带宽过高只会让数据文件变大。这类数据集的组织方式通常有两种。一种是把所有受试者的数据拼成一个大数组另一列是标签另一种是按人、按动作、按重复次数分成多个文件。我见过最多的是类似这样的结构import numpy as np # 假设从 .mat 或 .npy 加载变量名可能不同先打印结构 data np.load(emg_data.npy, allow_pickleTrue).item() print(type(data)) # dict print(data.keys()) # 常见有 emg, label, fs, subject_id, trial_id emg data[emg] # shape: (n_samples, n_channels) label data[label] # shape: (n_samples,) fs data[fs] # 采样率单位 Hz逻辑说明这里把数据文件读成字典先打印 key 再使用。不要一上来就假设列名因为每个研究者导出的命名习惯不同。emg的n_samples是总采样点数不是动作段数n_channels是电极通道数。label通常已经编码成整数0 表示静息或者无动作1 到 N 表示各个动作。fs一定要看如果后面做滤波和分帧所有时间尺度都要用它换算。参数说明allow_pickleTrue是为了兼容 numpy 旧版本存下来的 dict 对象如果你的文件是.mat格式可以用scipy.io.loadmat读取读出的是 dict里面可能还有一个__header__字段那是 MATLAB 版本信息不用管。2.2 动作段与静息段的划分先搞清楚分类难度下限很多初学者拿到数据第一件事就是写网络模型我建议先花十分钟搞清楚标签的时间结构。肌电分类数据集中一个动作通常记录为「静息 — 发力保持 — 静息」三段。你关心的分类目标一般是发力保持段也就是信号相对平稳、肌肉激活达到稳态的中段。但有些数据集把从开始发力到结束的整段都标成同一个动作这会让分类难度上升——因为发力过程的过渡态信号和保持段信号的统计特征差异不小。判断方法是打印每个类别对应的样本长度分布以及相邻标签的切换点from collections import Counter import numpy as np label data[label] # 标签分布 print(Counter(label)) # 找标签切换位置判断连续动作段有多长 change_points np.where(np.diff(label) ! 0)[0] seg_lengths np.diff(change_points) print(动作段数量:, len(seg_lengths)) print(段长度统计: min%d, max%d, mean%.1f (采样点) % (seg_lengths.min(), seg_lengths.max(), seg_lengths.mean())) print(等效时间: min%.2fs, max%.2fs % (seg_lengths.min() / fs, seg_lengths.max() / fs))逻辑说明np.diff(label) ! 0会标出标签发生变化的索引位置相邻两个变化点之间的长度就是一段连续同类信号的持续时间。这个信息决定你后面滑动窗口该开多大。如果一段动作只有 0.5 秒窗口开 300ms 再配 50% 重叠一段里只能取到 3 个窗样本量会明显偏少。参数说明这里没有硬编码fs用变量代替方便换数据集时只改加载处。Counter(label)是快速看类别平衡度的手段如果发现某个类别样本数是另一个的十倍后面训练时必须处理类别不平衡。2.3 训练集与测试集的划分方式同人划分还是跨人划分这是整个项目里最影响可信度的一步。常见的划分策略有两种。第一种是在同一个人的记录里按时间顺序划分比如前 70% 做训练、后 30% 做测试第二种是跨受试者划分用 A、B、C 三个人的数据做训练D 的做测试。两者的难度差异非常大同人划分很容易拿到 95% 以上跨人划分掉到 70% 甚至更低都正常。原因很朴素——每个人的肌肉解剖结构、皮下脂肪厚度、电极放置位置的微小偏差都会让信号分布移动。如果你做的是「验证算法能不能用」至少要报告跨人结果。如果做的是「这个数据集能不能复现博客里的结论」那就要看博客原帖是哪种划分。一般我拿到数据会先这样分析import numpy as np subject_id data[subject_id] trial_id data[trial_id] # 每个受试者有多少样本、多少试验 for sub in np.unique(subject_id): mask subject_id sub print(subject, sub, 样本数, mask.sum(), 试验数, len(np.unique(trial_id[mask])))逻辑说明这步是划分前的摸底。样本数少的受试者会让跨人测试的方差变大——如果某个人只有几百个样本测试时随机波动就会很大指标参考意义下降。参数说明np.unique返回排序后的去重值配合mask.sum()统计样本数。如果数据集没有提供subject_id和trial_id你的首要任务是先对照博客说明搞清楚这批数据是哪几个人采集的否则后面的交叉验证分组无从谈起。3. 把原始肌电变成训练样本滤波、分帧与特征提取3.1 滤波50Hz 工频干扰和基线漂移是怎么影响分类的表面肌电采集最常碰到的噪声有三个50Hz 工频干扰及谐波、电极与皮肤相对移动产生的基线漂移、以及肌肉疲劳导致的信号非平稳。50Hz 干扰来自市电在国内就是 50Hz 及其谐波基线漂移的频率通常在几 Hz 以下。在特征提取前不处理这两个问题特征值会被噪声主导分类器学到的是噪声模式而不是动作模式。我常用的滤波链是先用 20Hz 高通去掉基线漂移再用 450Hz 低通去掉高频噪声如果工频干扰明显加一个 50Hz 陷波。代码用scipy.signal实现from scipy.signal import butter, filtfilt def filter_emg(emg, fs, low20, high450, notch50): # 高通 20Hz去掉基线漂移和电极移动伪迹 b_hp, a_hp butter(4, low / (0.5 * fs), btypehighpass) x filtfilt(b_hp, a_hp, emg, axis0) # 低通 450Hz去掉高频噪声保留有效肌电能量 b_lp, a_lp butter(4, high / (0.5 * fs), btypelowpass) x filtfilt(b_lp, a_lp, x, axis0) if notch is not None: # 陷波 50Hz去掉工频干扰带宽设窄一些避免误伤邻近频率 b_notch, a_notch butter(2, [notch - 1, notch 1] / (0.5 * fs), btypebandstop) x filtfilt(b_notch, a_notch, x, axis0) return x逻辑说明这里用filtfilt而不是lfilter原因是filtfilt做零相位滤波输出没有延迟对于后续分帧和特征对齐很重要。axis0按通道逐列滤波通道间互不干扰。高通截止频率 20Hz 的依据是在静息状态下 20Hz 以下几乎没有肌电能量而基线漂移主要集中在这段所以可以安全切掉。参数说明butter的阶数我一般取 4阶数越高过渡带越窄但相位失真和数值稳定性变差4 阶配合filtfilt是工程上的折中。陷波带宽 1Hz 是经验值太窄滤波效果不彻底太宽会牺牲 49–51Hz 附近的有效信号。如果你的采集设备已经内置工频抑制可以省略陷波这步避免过度处理。3.2 滑窗分帧窗口长度与重叠率如何影响分类肌电是典型的非平稳信号但在一段短时间里近似平稳——这个时间尺度经验值是 200–300ms。窗口太短肌肉收缩的统计特征估计不准窗口太长实时控制的延迟变大而且窗口内可能跨越多个动作状态。常见做法是窗口取 200ms、重叠率 50% 到 75%。50% 重叠意味着两步一帧样本量大约是非重叠的两倍75% 重叠样本量更大但相邻窗口高度相关后面做交叉验证时容易造成数据泄漏。分帧代码def sliding_window(emg, fs, win_len0.2, overlap0.5): win_size int(win_len * fs) # 窗口内采样点数 step int(win_size * (1 - overlap)) # 每次滑动的步长 n_windows (emg.shape[0] - win_size) // step 1 windows np.stack( [emg[i * step: i * step win_size] for i in range(n_windows)], axis0 ) return windows # shape: (n_windows, win_size, n_channels)逻辑说明这段把连续信号切成固定长度的窗返回三维数组。注意最后一个不完整窗口被丢弃了——如果你不想丢失尾部数据可以用零填充但一般尾部是静息段丢了对分类影响不大。每个窗口对应一个标签标签取窗口内出现次数最多的类别如果窗口恰好跨在两个动作的边界取众数能减少边界噪声。参数说明win_len0.2对应 200msoverlap0.5对应 50%。一个 1000Hz 采样的信号200ms 窗口就是 200 个采样点如果采样率换成 2000Hz同样的win_len会自动得到 400 个点所以这里没有硬编码点数。3.3 特征提取时域特征与频域特征的取舍窗口切好之后每个窗口是(win_size, n_channels)的矩阵。直接把它拉平喂给线性分类器是一种做法但维度太高且噪声敏感。更稳的是提取手工特征。肌电分类里常用的是四类时域特征平均绝对值MAV、均方根RMS、过零率ZC、波形长度WL再加上频域里的平均功率频率MNF和中值频率MDF。每个通道都算一遍特征向量长度就是「特征数 × 通道数」。import numpy as np def extract_features(windows, fs): n_win, win_len, n_ch windows.shape feats np.zeros((n_win, 6 * n_ch)) for i in range(n_win): x windows[i] # (win_len, n_ch) for c in range(n_ch): seg x[:, c] mav np.mean(np.abs(seg)) rms np.sqrt(np.mean(seg ** 2)) zc np.sum(np.diff(np.sign(seg)) ! 0) / (win_len - 1) wl np.sum(np.abs(np.diff(seg))) # 频域特征先做周期图再按频率加权求均值 freqs np.fft.rfftfreq(win_len, 1 / fs) psd np.abs(np.fft.rfft(seg)) ** 2 total_power psd.sum() 1e-10 mnf (freqs * psd).sum() / total_power mdf_idx np.where(np.cumsum(psd) total_power * 0.5)[0] mdf freqs[mdf_idx[0]] if len(mdf_idx) 0 else 0 base c * 6 feats[i, base 0] mav feats[i, base 1] rms feats[i, base 2] zc feats[i, base 3] wl feats[i, base 4] mnf feats[i, base 5] mdf return feats逻辑说明np.diff(np.sign(seg)) ! 0统计了信号穿越零点的次数再除以窗长归一化这样不同窗口长度下特征数值尺度一致。rfftfreq生成正频率轴rfft计算频谱cumsum找到累计功率达到一半处的索引对应中值频率。加1e-10是为了防止静息窗口总功率为零时除零。参数说明zc对工频残留非常敏感如果滤波没做好过零率会被 50Hz 噪声拉高特征区分度下降这是最常见的「玄学」问题——特征看着都对分类器就是不准。另外mdf_idx[0]取的是第一个超过一半的位置如果psd在前几个频点就达到一半说明信号能量集中在低频这也符合肌电的生理特征。4. 从特征到分类器训练、调参与评估指标4.1 基线模型选择为什么先跑随机森林而不是直接上深度学习很多人在这个项目里的第一反应是上 CNN 或者 LSTM我建议第一步先跑一个随机森林或者 SVM 基线。原因有三个第一手头样本量通常只有几万甚至几千深度学习在这个规模下优势不明显反而容易过拟合第二随机森林对高维特征稳健对特征尺度不敏感不需要标准化调试成本低第三它能输出特征重要性帮你发现哪几个通道、哪几个特征在起作用。等基线跑通了再决定要不要上更复杂的网络。这里有一个非常容易踩的坑随机森林不需要特征标准化但 SVM 和线性模型需要。如果你打算对比多个模型统一先做标准化from sklearn.preprocessing import StandardScaler scaler StandardScaler() X_train scaler.fit_transform(X_train) X_test scaler.transform(X_test)逻辑说明fit_transform在训练集上计算均值和标准差transform在测试集上只用训练集的统计量做变换。千万不要对全集做标准化后再切分那属于跨样本信息泄漏会让测试指标虚高。4.2 训练代码与评估交叉验证别把同一个动作段分进两折训练本身不复杂复杂的是正确的数据划分。如果你用了 75% 重叠的滑窗相邻窗口样本高度相似随机train_test_split会把同一段动作的窗口拆到训练集和测试集测试准确率会虚高。正确做法是按试验trial或者按动作段分组保证同一个连续动作段的所有窗口落在同一折里。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import GroupKFold from sklearn.metrics import accuracy_score, f1_score # labels_win: 每个窗口的标签; groups_win: 每个窗口所属的动作段ID # 这里 groups_win 要在分帧时同步生成给每个动作段一个唯一ID再重复到对应窗口 model RandomForestClassifier(n_estimators200, max_depth12, n_jobs-1) gkf GroupKFold(n_splits5) accs, f1s [], [] for train_idx, test_idx in gkf.split(X, labels_win, groupsgroups_win): X_tr, X_te X[train_idx], X[test_idx] y_tr, y_te labels_win[train_idx], labels_win[test_idx] model.fit(X_tr, y_tr) pred model.predict(X_te) accs.append(accuracy_score(y_te, pred)) f1s.append(f1_score(y_te, pred, averagemacro)) print(5折准确率: %.3f ± %.3f % (np.mean(accs), np.std(accs))) print(5折F1: %.3f ± %.3f % (np.mean(f1s), np.std(f1s)))逻辑说明GroupKFold的作用是保证同一个 group ID 的样本不会同时出现在训练和测试里。这里的groups_win是关键它必须是从原始动作段 ID 扩展出来的数组——每个窗口继承它所在动作段的 ID。如果数据集有trial_id直接用trial_id也行如果连动作段信息都没有至少要用连续标签段生成 group ID。参数说明n_estimators200对几万样本足够超过 500 收益很小max_depth12限制树深度防止对噪声过拟合。n_jobs-1让 sklearn 用满所有 CPU 核心在八核机器上能把训练时间缩短好几倍。指标上accuracy容易被多数类主导所以同时看f1_score的 macro 平均这样少数类表现差会直接反映在指标里。4.3 三个必调参数窗口长度、重叠率、特征组合窗口长度和重叠率的组合直接决定了样本量和时间分辨率。我做过一组对比200ms 窗口 75% 重叠样本量是 300ms 50% 的 2.5 倍但相邻窗口相关性也大幅上升交叉验证时要更小心。另一个值得调的维度是特征组合——不是所有特征都有用特别是zc在滤波不干净时反而是负优化。下面这张表是我在类似数据集上调参的起点给新手一个参考范围参数推荐起点调参方向窗口长度200ms信号平稳段短就减小到 150ms实时性要求高也减小重叠率50%样本量不足时加到 75%注意数据泄漏风险高通截止20Hz基线漂移严重时提高到 30Hz陷波带宽1Hz工频残留明显时加宽到 2Hz随机森林树数200特征多、样本多可加到 400特征组合MAVRMSWLZCMNFMDF用特征重要性排序后删掉排名末尾的调参的常见误区是只调分类器参数忽略了信号处理参数。信号处理参数改一个可能让特征分布整体变化对结果的影响往往比max_depth加两层更大。建议每次只改一个参数固定其他变量用交叉验证指标做对比别同时改窗口和特征否则无法归因。5. 肌电分类避坑指南数据泄漏、跨受试者漂移与标签偏移5.1 现象训练集准确率 95%测试集只有 60%原因最典型的是数据泄漏。滑窗重叠率太高随机划分把同一个动作段的窗口拆进了训练和测试另一种可能是对全体数据做了标准化或者用了cross_val_score却忘记在 Pipeline 里放进标准化步骤。解决改用GroupKFoldgroup 用动作段 ID标准化只 fit 训练集如果要严格验证试试「一个受试者的数据全部留出做测试」那才是真实场景下的难度。5.2 现象训练指标很好但预测时相邻窗口结果跳来跳去原因单窗口的预测方差大。即使分类器整体准确率 90%那 10% 的错误分散在时间轴上看起来就是连续几个窗口出一次错误。这在实时控制场景里是致命问题——控制指令会抖动。解决不要用单窗口决定输出用最近 3–5 个窗口的预测做多数投票或者对预测概率做指数滑动平均。from collections import Counter def smooth_prediction(pred_history): # pred_history: 最近 k 个窗口的预测结果 return Counter(pred_history).most_common(1)[0][0]逻辑说明多数投票本质上是低成本的时间平滑它假设动作至少保持几百毫秒短时间内的错误预测会被正确结果淹没。投票窗口选 5 帧配合 50% 重叠相当于 500ms 的决策延迟在假肢控制场景里是可接受的。5.3 现象换一天重新采集数据旧模型直接失灵原因跨时间、跨session的分布漂移。电极位置差 1 厘米、皮肤出汗、肌肉疲劳都会让信号幅度和频谱改变。这是肌电分类里永远绕不开的问题不是模型不够好。解决最朴素的办法是每次使用前做短校准——让用户做一遍每个动作用新数据微调分类器或者做特征归一化。更进一步可以用领域自适应但投入产出比要自己衡量很多场景下校准就够用了。5.4 现象标签和实际发力时刻对不上模型学到的边界全是错的原因数据标注时按视觉计时打标签但实际操作中人从听到指令到肌肉真正发力有几百毫秒的反应延迟。如果标签段包含了发力前的静息段同一个动作的窗口里既有静息特征又有发力特征类别混淆。解决训练时只截取发力保持段的中部比如去掉动作段前 20% 和后 20% 的窗口。你可以在分帧时传入一个valid_mask只保留离段边界足够远的窗口。5.5 现象某个动作准确率特别高另一个动作特别差原因类别不平衡或者两类动作的肌电模式本身接近。比如「放松」类样本总是数量庞大模型倾向于把所有不确定样本判成放松「无名指单独抬起」和「中指单独抬起」这类精细动作肌肉激活模式高度重叠。解决先用Counter检查类别分布必要时对少数类过采样或者对多数类降采样再看混淆矩阵找出具体哪两类在互相混淆。如果精细化动作本身物理上就区分度低那就增强特征比如加入时频图中的瞬时频率特征或者增加电极通道光调参没有用。6. 进阶从离线分类走向实时手势识别的最小闭环离线跑通之后下一步是把这套流程搬到一个实时流式系统里做一个能响应的手势识别原型。最小闭环只需要三个组件一个循环不断读取新采样点维护一个滑动缓冲区每攒够一个窗口长度就提取特征并预测输出端做多数投票避免抖动。import numpy as np from collections import deque class RealtimeEMGClassifier: def __init__(self, model, scaler, fs, win_len0.2, step_len0.1): self.model model self.scaler scaler self.fs fs self.win_size int(win_len * fs) self.step_size int(step_len * fs) self.buffer deque(maxlenself.win_size) self.vote_buffer deque(maxlen5) self.last_pred 0 def add_sample(self, sample): self.buffer.append(sample) if len(self.buffer) self.win_size: return # 每攒够一个 step 就做一次预测其余时间只更新缓冲区 if len(self.buffer) % self.step_size 0: x np.array(self.buffer).T # (n_channels, win_size) feat extract_features(x[np.newaxis, :, :], self.fs) feat self.scaler.transform(feat) pred self.model.predict(feat)[0] self.vote_buffer.append(pred) self.last_pred self._vote() def _vote(self): if len(self.vote_buffer) 3: return self.last_pred return max(set(self.vote_buffer), keyself.vote_buffer.count)逻辑说明这个类把缓冲、分帧、特征、预测、投票封装在一起add_sample每次接收一个新采样点。deque(maxlenwin_size)自动丢弃最旧的样本保证缓冲区始终是最新的一个窗口。投票缓冲区存最近 5 个预测结果取众数作为当前输出。参数说明step_len0.1表示每 100ms 做一次预测配合 200ms 窗口输出延迟大约 300ms体感上是「跟手」的。如果你的动作切换很快可以把step_len改小到 0.05代价是计算量翻倍。提取特征时用x[np.newaxis, :, :]加一个 batch 维度和离线时的调用保持一致。这套闭环的实用价值在于它把之前所有零散组件整合成了一个能真实验证的东西。你自己动动手腕看预测标签能不能跟上动作切换——这个「跟上」的体感比任何离线准确率都更能说明问题。我在做这类项目时的血泪经验是先把窗口、特征、投票这套闭环跑顺让实时输出稳定了再回头去调分类模型。顺序一旦反了你会发现调模型调得再准部署时还是被延迟和抖动搞得很狼狈。希望这篇笔记能帮你少走一段弯路把时间和精力花在真正影响结果的地方。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站