简介:这份资源面向生物医学工程、康复医学与人机交互方向的学习者和研究者,围绕表面肌电信号(sEMG)分类任务,提供数据集与可运行代码的完整实践材料,帮助读者从特征抽取到分类评估走通一遍机器学习流程。压缩包共7个文件,以6个Python脚本和1个数据压缩包为主,脚本分别对应主成分分析、SVM递归消除法、相关性热力图三种特征处理方式,以及KNN、支持向量机、随机森林三种分类器的训练与评估,数据包用于训练和测试,整体约14.8MB,结构清晰便于按模块对照学习。目前已有539人学习下载,适合希望理解sEMG特征降维、特征选择与多分类算法差异的读者,可据此复现实验、比较不同方法的效果,并迁移到自己的研究或项目中。
1. 从一段“看起来全是噪声”的波形说起
第一次拿到表面肌电(sEMG)数据的人,多半会盯着屏幕上那团高频抖动发呆:它不像心电有清晰的 QRS 波,也不像脑电有节律性的慢波,乍看就是一条毛刺线。可正是这条毛刺线里,藏着手指屈伸、手腕翻转、握拳松手的意图。这个项目要解决的,就是把 sEMG 原始信号变成可训练的特征矩阵,再交给机器学习分类算法,输出对应的动作类别。它适合两类人:一类是想入门肌电信号处理、手上缺一份能直接跑的数据和脚本的学生;另一类是要做手势识别原型、需要一套可复现基线来对比自己算法的工程师。数据集加代码的组合,省掉的正是最耗时的“数据从哪来、标签怎么对、特征怎么提”这三道坎。
2. 项目定位与技术栈:这份资源到底给了什么
2.1 数据集的组织方式与信号规格
这类 sEMG 分类资源,核心资产是两样东西:一段段带标签的肌电采集记录,和一套把记录读成特征矩阵的脚本。从项目标题看,它对应的是某个时间点发布的一篇实践记录,配套的数据集通常按“受试者 / 动作类别 / 试次”三级组织。常见做法是每个动作重复若干次采集,每次采集是一个通道数固定、采样率固定的数组,标签单独存一张表或用文件名编码。采样率一般在 1kHz 上下,通道数从单通道到 8 通道不等,具体以资源内文件为准。
我一般会先确认三件事再动手:采样率是多少、通道顺序是什么、标签和文件怎么对应。这三件事任何一件搞错,后面特征全废。资源里如果带了说明文档,优先读它;没有的话,就靠读一个样本文件的形状反推。下面这段是探查数据结构的通用写法,跑一遍就能把家底摸清:
import numpy as np import os data_dir = "./dataset" # 换成资源里实际的数据目录 for fname in sorted(os.listdir(data_dir))[:5]: if not fname.endswith(".npy"): continue arr = np.load(os.path.join(data_dir, fname)) # 打印形状、数据类型、数值范围,判断是原始信号还是已提好的特征 print(fname, arr.shape, arr.dtype, float(arr.min()), float(arr.max()))这段代码不依赖任何项目私有函数,纯粹用 numpy 把文件读进来观察。shape告诉你这是(采样点, 通道)还是(试次, 采样点, 通道);dtype决定要不要转 float;min/max能帮你判断信号是否已经归一化过。如果数值范围在正负几百毫伏量级,多半是原始电压;如果在 0 到 1 之间,可能是已经处理过的特征。这一步看着笨,但能避免后面拿着特征当原始信号再提一遍特征的翻车。
2.2 技术栈:numpy + scikit-learn 为主干
从“机器学习分类算法”这个关键词判断,这套代码的主干不会是深度学习框架,而是经典机器学习流水线:numpy 做数组运算,scipy 做滤波,scikit-learn 做特征标准化、模型训练和评估。这是 sEMG 小样本场景下最务实的选择——数据量通常撑不起一个大网络,而 SVM、随机森林、LDA 这类算法在手工特征上往往表现稳定、训练快、可解释。
典型依赖清单大致如下,版本不必严格对齐,但建议锁在一个能跑通的组合里:
| 依赖 | 作用 | 备注 |
|---|---|---|
| numpy | 数组与矩阵运算 | 几乎所有脚本的基础 |
| scipy | 滤波、重采样 | 巴特沃斯滤波常用 |
| scikit-learn | 特征标准化、分类器、交叉验证 | 流水线核心 |
| matplotlib | 波形与混淆矩阵可视化 | 调试必备 |
| pandas | 标签表读取与整理 | 标签用 csv 存时需要 |
如果资源里还带了滑窗分割、时域频域特征提取的脚本,那基本就是一条完整的“原始信号 → 特征 → 分类”链路。选经典算法而不是深度学习的另一个原因是可复现性:随机种子一固定,结果就能对上,方便你判断是自己改坏了还是数据本身有波动。
2.3 目录结构预判与阅读顺序
这类资源到手后,目录通常长这样(具体名称以实际为准,但角色大同小异):
project/ ├── dataset/ # 原始或预处理后的信号文件 ├── labels.csv # 标签映射表 ├── preprocess.py # 滤波、去噪、归一化 ├── features.py # 时域/频域特征提取 ├── train.py # 模型训练与评估 ├── config.py # 采样率、窗长、通道等参数 └── README.md # 运行说明阅读顺序我建议反过来:先看config.py里的参数,再看train.py的主流程,最后回头读preprocess.py和features.py的细节。原因是参数决定了整条链路的形状,主流程决定了模块怎么串,细节可以按需深挖。如果资源没有这么规整,就按“谁 import 谁”画一张依赖图,从入口脚本往下追。
3. 跑通链路:从原始信号到分类结果
3.1 环境准备与第一次运行
先把环境隔离出来,别污染系统 Python。用 conda 或 venv 都行,我习惯 conda,因为 scipy 的二进制包省心:
conda create -n semg python=3.9 -y conda activate semg pip install numpy scipy scikit-learn pandas matplotlib装完先别急着跑训练脚本,先确认数据能读进来。很多资源第一次跑失败不是算法问题,而是路径写死成了作者本机的绝对路径。打开入口脚本,把所有C:\Users\...或/home/xxx/...之类的路径改成相对路径或配置项。改完跑一个最小验证:
import numpy as np from sklearn.model_selection import train_test_split X = np.load("./dataset/features.npy") # 特征矩阵,形状 (样本数, 特征数) y = np.load("./dataset/labels.npy") # 标签,形状 (样本数,) print("样本数:", X.shape[0], "特征数:", X.shape[1], "类别数:", len(np.unique(y))) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y ) print("训练集:", X_train.shape, "测试集:", X_test.shape)stratify=y保证训练测试集类别比例一致,小样本下这一步很关键,否则某个类别可能全被分到一边,评估结果失真。random_state固定后,每次划分一致,方便对比不同模型的差异。如果这里就报形状不匹配,说明特征和标签没对齐,先解决对齐问题再谈模型。
3.2 预处理:滤波与滑窗分割
sEMG 的有效能量集中在几十到几百赫兹,工频干扰和运动伪迹是两大噪声源。常见做法是先用带通滤波保留有效频段,再做滑窗分割,把长序列切成固定长度的样本。下面是一个可复用的预处理片段:
from scipy.signal import butter, filtfilt import numpy as np def bandpass(signal, fs, low=20, high=450, order=4): # 设计巴特沃斯带通滤波器,filtfilt 做零相位滤波避免波形偏移 nyq = fs / 2.0 b, a = butter(order, [low / nyq, high / nyq], btype="band") return filtfilt(b, a, signal, axis=0) def sliding_window(signal, win=200, step=100): # 按窗长和步长切分,返回 (样本数, 窗长, 通道) 的数组 segments = [] for start in range(0, len(signal) - win + 1, step): segments.append(signal[start:start + win]) return np.array(segments)low和high要根据实际采样率调整,high必须小于奈奎斯特频率,否则butter会报错。order越高过渡带越陡,但相位失真和数值不稳定风险也上升,4 阶是个稳妥起点。filtfilt做前后向滤波,等效零相位,代价是边缘效应,所以切窗时最好丢掉首尾各一段。win和step决定样本数量和重叠率,窗太短特征不稳,窗太长样本变少,200 点配 100 步长是常见折中,具体按采样率换算成时间再定。
3.3 特征提取:时域与频域的组合拳
切完窗之后,每个窗要压成一组特征向量。sEMG 常用的时域特征有均方根(RMS)、过零率、波形长度、斜率符号变化数;频域可以取平均功率频率和中值频率。下面这段把几个常用特征拼在一起:
import numpy as np def extract_features(segment): # segment 形状 (窗长, 通道),逐通道提取后拼接 feats = [] for ch in range(segment.shape[1]): x = segment[:, ch] rms = np.sqrt(np.mean(x ** 2)) # 均方根,反映能量 wl = np.sum(np.abs(np.diff(x))) # 波形长度,反映复杂度 zc = np.sum(np.abs(np.diff(np.sign(x))) > 0) # 过零率 feats.extend([rms, wl, zc]) return np.array(feats)rms对幅度变化敏感,是区分发力大小的主力特征;wl在肌肉疲劳时会有明显变化;zc偏噪声敏感,滤波没做好时它的波动会很大。三个特征乘上通道数就是单个样本的维度,通道多的时候维度会涨得很快,后面标准化和降维要跟上。如果资源里已经提好了特征,这段可以跳过,但建议至少读一遍确认特征定义和你的理解一致,别把“波形长度”当成“信号长度”。
3.4 训练与评估:一个能复现的基线
特征齐了就可以上分类器。小样本 sEMG 我一般先跑 SVM 和随机森林两个基线,前者对标准化敏感但边界清晰,后者几乎不用调参就能给出一个像样的结果。评估别只看准确率,混淆矩阵和每类召回率更能暴露问题:
from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from sklearn.metrics import classification_report, confusion_matrix clf = make_pipeline(StandardScaler(), SVC(kernel="rbf", C=1.0, gamma="scale")) clf.fit(X_train, y_train) y_pred = clf.predict(X_test) print(confusion_matrix(y_test, y_pred)) print(classification_report(y_test, y_pred, digits=3))StandardScaler必须放进 pipeline,否则训练集和测试集的标准化参数会不一致,这是新手最容易踩的坑之一。C控制正则强度,gamma控制核宽度,scale是个安全的默认值,先跑通再网格搜索。classification_report里的macro avg比accuracy更能反映类别不均衡时的真实水平。如果某个动作的召回率明显偏低,先回去看那个动作的样本数和信号质量,而不是急着换模型。
4. 核心模块拆解:配置、特征与训练怎么串起来
4.1 配置集中管理:别让魔法数字散落各处
这类项目最容易腐化的地方,就是采样率、窗长、通道数这些参数被硬编码在好几个文件里。改一个忘一个,结果就是特征维度和模型输入对不上。稳妥做法是抽一个config.py,所有模块从它读:
# config.py FS = 1000 # 采样率 Hz WIN = 200 # 窗长(采样点) STEP = 100 # 步长 CHANNELS = 8 # 通道数 LOW, HIGH = 20, 450 # 带通截止频率 TEST_SIZE = 0.2 RANDOM_STATE = 42把FS和WIN放一起,你随时能算出单窗时长是 0.2 秒;CHANNELS决定了特征维度下限。所有脚本from config import *或显式导入,改参数只改一处。这一步不写代码也能做,但写了之后二次开发会轻松很多,尤其是你要做跨受试者实验、换采样率的时候。
4.2 特征与标签对齐:一次错位,全盘皆输
滑窗切出来的样本顺序,必须和标签的展开顺序严格一致。常见错误是信号按文件循环切窗,标签却按类别循环展开,两边顺序一错,模型学到的就是随机映射。我一般会在切窗函数里同时返回标签,或者切完立刻断言样本数和标签数相等:
def build_dataset(signals, labels, win, step): X, y = [], [] for sig, lab in zip(signals, labels): segs = sliding_window(sig, win, step) X.append(segs) y.extend([lab] * len(segs)) # 每个窗继承所在记录的标签 X = np.concatenate(X, axis=0) y = np.array(y) assert X.shape[0] == y.shape[0], "样本数与标签数不一致" return X, yzip(signals, labels)保证信号和标签同步遍历,y.extend([lab] * len(segs))让每个窗都拿到所属记录的标签。最后的assert是一道保险,宁可在这里报错,也别让错位数据流进训练。如果资源里的实现没有这个断言,建议你补上,这是排查“准确率异常低”时最先该看的地方。
4.3 交叉验证与受试者划分
sEMG 数据有个特点:同一受试者的不同试次之间高度相似,如果随机划分训练测试集,测试集里混进了同一试次的相邻窗,准确率会虚高。更严谨的做法是按试次或按受试者划分。资源如果只做了随机划分,你在二次开发时要意识到这个偏差:
from sklearn.model_selection import GroupKFold import numpy as np groups = np.load("./dataset/groups.npy") # 每个样本所属的试次/受试者编号 gkf = GroupKFold(n_splits=5) for train_idx, test_idx in gkf.split(X, y, groups): # 同一 group 不会同时出现在训练和测试中 clf.fit(X[train_idx], y[train_idx]) score = clf.score(X[test_idx], y[test_idx]) print("fold score:", round(score, 3))GroupKFold的关键是groups数组,它标记每个样本来自哪个试次。这样切分能更真实地反映模型对未见数据的泛化能力。如果资源里没有groups文件,可以自己按文件顺序生成一个,成本很低但结论可信度高很多。
5. 避坑与排查:那些让准确率归零的细节
5.1 现象:训练准确率很高,测试准确率断崖下跌
原因通常是数据泄漏——滑窗重叠导致相邻窗同时进了训练集和测试集,模型记住了样本而不是学到了模式。解决办法是改用按试次划分的GroupKFold,或者在随机划分前先按记录去重。判断方法很简单:把重叠率降到 0(step == win)再跑一次,如果测试准确率明显下降,基本可以确认是泄漏。
5.2 现象:某个类别召回率始终接近 0
先查样本数,再看信号。小样本场景下,某个动作采集次数少,模型会倾向于预测多数类。解决上可以加class_weight="balanced",或者对该类做过采样。但更根本的是回去看那类信号的幅值范围,如果明显低于其他类,可能是电极接触不良或采集时发力不足,这种数据再怎么调模型也救不回来。
5.3 现象:换一台机器跑,结果对不上
原因多半是随机种子没固定,或者依赖版本差异导致数值微小变化被放大。检查train_test_split、模型初始化、交叉验证划分是否都传了random_state。另外numpy和scikit-learn的大版本升级有时会改变默认行为,建议把依赖版本写进requirements.txt,别用“最新版”三个字糊弄自己。
5.4 现象:滤波后信号幅值异常或出现 NaN
butter的截止频率超过奈奎斯特频率会直接报错,但如果high刚好卡在边界附近,数值可能不稳定。另外filtfilt对短信号做高阶滤波时,边缘填充可能引入异常值。解决是把high控制在0.45 * fs以内,信号太短时降低阶数或改用sosfiltfilt。跑完滤波加一句np.isnan(signal).any()检查,能省掉后面很多莫名其妙的调试。
5.5 现象:特征维度对不上模型输入
改过窗长或通道数之后,特征维度变了,但模型还是按旧维度加载。解决是把特征维度也写进配置或在训练脚本里打印X.shape,加载已保存模型前先断言维度一致。这类错误报错信息通常很直白,但如果你用的是保存好的.pkl模型,报错可能发生在预测阶段,排查起来反而绕。
6. 二次开发:换数据、换特征、换模型的改造路径
拿到这套资源,真正有价值的是把它当成一个可替换的骨架,而不是只能跑一次的 demo。改造通常从三个方向入手,我按改动成本从低到高排一下。
第一个方向是换分类器。把SVC换成RandomForestClassifier或LDA,只需要改一行,但能帮你快速判断特征本身的信息量够不够。如果换了两三个模型准确率都在同一水平,说明瓶颈在特征而不是模型,别再调参了。下面是一个可切换的工厂函数,方便你批量对比:
from sklearn.svm import SVC from sklearn.ensemble import RandomForestClassifier from sklearn.discriminant_analysis import LinearDiscriminantAnalysis def get_model(name): models = { "svm": SVC(kernel="rbf", C=1.0, gamma="scale", class_weight="balanced"), "rf": RandomForestClassifier(n_estimators=200, random_state=42), "lda": LinearDiscriminantAnalysis(), } return models[name]class_weight="balanced"在类别不均衡时值得默认打开,代价很小。n_estimators到 200 之后继续加收益递减,先固定住再比别的。
第二个方向是换特征。时域特征之外,可以加频域的中值频率,或者用滑窗内的短时傅里叶变换取频带能量。改extract_features时注意保持返回维度一致,否则下游全要跟着改。我的习惯是每加一组特征就单独跑一次基线,记录准确率变化,别一次加五组然后不知道是哪组起了作用。
第三个方向是换数据。这套代码的输入约定是“信号数组 + 标签”,只要你的新数据能整理成同样形状,就能直接复用整条链路。跨受试者实验时,把groups换成受试者编号,用GroupKFold评估,得到的数字才敢写进报告。
验证改造是否有效,我一般看两个指标:一是按试次划分的交叉验证均值,二是混淆矩阵里最差那一类的召回率。前者看整体,后者看短板。只看准确率容易被多数类掩盖问题。
血泪经验是:每次改完参数,先把X.shape、类别分布、随机种子打印出来再跑,别嫌烦。我有一次改窗长忘了同步特征维度,模型训练没报错,预测阶段才崩,回头查了半小时才发现是配置没对齐。从那以后我每次动config.py都强制走一遍形状断言,希望帮到你。
本文还有配套的精品资源,点击获取