news 2026/9/24 23:15:11

单通道脑电睡眠分期实战:Python实现五分类自动分期

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
单通道脑电睡眠分期实战:Python实现五分类自动分期

简介:这是一套面向计算机相关专业学生与项目实战学习者的单通道脑电信号自动睡眠分期研究完整资料,源自经导师指导并通过评审的高分毕业设计,适合作为毕设参考、课程设计或期末大作业。资源包共22个文件,约10.85MB,以12个Python源码文件为核心,涵盖数据预处理、模型训练、预测与Web服务等模块;另含2个pt模型权重、3个txt说明、1个docx手册、1个sh运行脚本及html、png等辅助文件,结构清晰便于按模块查阅。项目围绕单通道脑电信号展开,包含数据下载、特征准备、网络定义、训练测试与可视化展示等环节,并配有项目说明文档,帮助读者理解从数据到模型再到应用的完整流程。目前已有122人学习下载,可作为睡眠分期方向入门与实战练习的参考素材。

1. 单通道脑电睡眠分期:为什么一个电极也能跑出可用的五分类

睡眠分期是睡眠医学里最基础的一项判读工作,传统做法靠多导睡眠图(PSG)采集整晚的脑电、眼电、肌电、心电,再由技师按 30 秒一帧逐帧打标签。问题在于设备贵、导联多、贴片麻烦,普通人根本没法在家做长期监测。而单通道脑电(single-channel EEG)只用一个电极,通常放在额区或枕区,就能拿到整晚的神经电活动信号,硬件成本能压到很低。基于 Python 的单通道脑电自动睡眠分期,要解决的就是:拿到这一路信号后,怎么用代码把它自动分成 W、N1、N2、N3、REM 五个阶段,并且准确率能到可用水平。

这套东西适合谁?一是做可穿戴睡眠监测的嵌入式/算法工程师,二是睡眠方向的研究生需要一套能复现的 baseline,三是想入门生理信号处理的 Python 开发者。它不追求替代医院 PSG,而是解决「长期、居家、低成本」场景下的粗分期需求。整条链路的核心难点有三个:单通道信息量少导致 N1 和 REM 容易混、类别极度不平衡(N2 占一半以上)、以及个体差异让跨被试泛化很难。后面几章我会按「数据怎么准备 → 特征/模型怎么搭 → 怎么训练 → 坑在哪 → 怎么验证」的顺序,把一套能跑通的方案讲清楚,源码结构、数据集组织、文档说明该写什么也一并交代。

2. 数据集准备与预处理:从原始 EDF 到模型能吃的片段

2.1 单通道睡眠数据集怎么选、怎么读

做这个方向,绕不开公开数据集。最常用的是 Sleep-EDF(含 Sleep-EDF-20 和 Sleep-EDF-78 两个子集),它本身就是单通道 Fpz-Cz 或 Pz-Oz 脑电,采样率 100Hz,每 30 秒一个 epoch,标签按 R&K 标准给出。另一个常见的是 SHHS,但它是多导的,需要自己抽单通道。选数据集时重点看三件事:通道是不是单通道、标签是不是按 30 秒 epoch 对齐、采样率是否统一。Sleep-EDF 这三条都满足,所以是首选。

读取用 MNE 最省事,它能直接解析 EDF 并拿到注释里的睡眠标签。下面是最小读取代码:

import mne import numpy as np # 读取一条记录,preload=True 把信号载入内存 raw = mne.io.read_raw_edf("SC4001E0-PSG.edf", preload=True) # 只保留单通道脑电,Sleep-EDF 里通常是 EEG Fpz-Cz raw.pick_channels(["EEG Fpz-Cz"]) raw.resample(100) # 统一到 100Hz,避免不同记录采样率不一致 # 注释里存的是睡眠分期标签 annot = raw.annotations print(annot.description[:10], annot.onset[:10])

逻辑说明:pick_channels保证只留一路信号,这是「单通道」的前提;resample是为了后续所有记录长度对齐。参数上,100Hz 是 Sleep-EDF 原生采样率,如果你用别的数据集,建议重采样到 100 或 128Hz,太高会增加计算量,太低会丢高频特征(比如纺锤波在 12-16Hz,100Hz 采样足够)。

标签映射要注意:R&K 标准里 W、1、2、3、4、REM、MOVEMENT、UNKNOWN 都有,通常做法是把 3 和 4 合并成 N3,丢弃 MOVEMENT 和 UNKNOWN,最终得到 5 类。这一步不做干净,后面训练会莫名其妙多出类别。

2.2 30 秒 epoch 切分与滤波归一化

拿到连续信号和标签后,要按 30 秒切段,和标签一一对应。这里有个血泪经验:注释的 onset 不一定从 0 开始,也不一定严格对齐 30 秒边界,直接按索引切会错位。稳妥做法是按注释时间戳去切。

from mne import Epochs # 事件id映射,把字符串标签转成整数 event_id = {"Sleep stage W": 0, "Sleep stage 1": 1, "Sleep stage 2": 2, "Sleep stage 3": 3, "Sleep stage 4": 3, "Sleep stage R": 4} events, _ = mne.events_from_annotations(raw, event_id=event_id) # 按 30 秒切 epoch,tmin=0 表示从事件点开始取 epochs = Epochs(raw, events, event_id=event_id, tmin=0, tmax=29.99, baseline=None, preload=True) X = epochs.get_data() # 形状 (n_epochs, 1, 3000) y = epochs.events[:, -1] # 对应标签 # 带通滤波 0.5-40Hz,去掉基线漂移和工频 epochs.filter(0.5, 40.0, fir_design="firwin") # 逐 epoch 做 z-score 归一化,消除个体幅度差异 X = epochs.get_data() X = (X - X.mean(axis=-1, keepdims=True)) / (X.std(axis=-1, keepdims=True) + 1e-8) np.save("X.npy", X); np.save("y.npy", y)

逻辑说明:tmax=29.99而不是 30,是因为 MNE 的区间是左闭右开,写 30 会多采一个点导致长度 3001,训练时形状对不上。滤波范围 0.5-40Hz 是睡眠分期的通用选择,0.5Hz 以下有漂移,40Hz 以上主要是肌电干扰。归一化用逐 epoch 的 z-score,而不是全局归一化,原因是不同被试、不同时段的信号幅度差异极大,全局归一化会让某些记录被压扁。

参数怎么改:如果你做的是实时推理,滤波要用因果滤波器(fir_design="firwin"换成在线版本),否则会有前瞻延迟。如果内存吃紧,可以边切边存,不要一次性preload整晚。

3. 特征工程与模型选型:从手工特征到 1D-CNN 的两条路

3.1 手工特征 + 传统分类器:小数据下的稳妥 baseline

单通道数据量不大时(比如只有几十个被试),手工特征加树模型往往比深度学习更稳。睡眠分期的经典特征分四类:时域(均值、方差、偏度、峰度、Hjorth 参数)、频域(各频带功率:delta 0.5-4Hz、theta 4-8Hz、alpha 8-12Hz、sigma 12-16Hz、beta 16-30Hz)、时频(小波能量)、非线性(样本熵、排列熵)。频带功率是最有判别力的,尤其 sigma 功率和 N2 的纺锤波强相关。

from scipy.signal import welch from scipy.stats import skew, kurtosis def extract_features(epoch, fs=100): # epoch 形状 (3000,) feats = [] feats += [epoch.mean(), epoch.std(), skew(epoch), kurtosis(epoch)] # 频带功率 f, psd = welch(epoch, fs=fs, nperseg=fs*2) bands = {"delta": (0.5, 4), "theta": (4, 8), "alpha": (8, 12), "sigma": (12, 16), "beta": (16, 30)} for lo, hi in bands.values(): idx = (f >= lo) & (f < hi) feats.append(np.trapz(psd[idx], f[idx])) # 频带绝对功率 # 频带相对功率,消除整体幅度影响 total = np.trapz(psd, f) for lo, hi in bands.values(): idx = (f >= lo) & (f < hi) feats.append(np.trapz(psd[idx], f[idx]) / (total + 1e-8)) return np.array(feats)

逻辑说明:welch用 2 秒窗做功率谱估计,平衡频率分辨率和方差。绝对功率和相对功率都保留,是因为绝对功率受电极阻抗影响大,相对功率更稳,但绝对功率对 N3 的 delta 判断有用。分类器我一般先用 LightGBM 或随机森林,特征维度几十维,几千个 epoch 就能训,几分钟出结果,作为深度学习前的 sanity check 非常值。

参数怎么改:nperseg越大频率分辨率越高但方差越大,2 秒是常用折中。如果你发现 N1 识别率极低(通常都低),可以额外加 theta/alpha 比值、以及 alpha 功率的爬升斜率特征。

3.2 1D-CNN + BiLSTM:端到端方案的结构与理由

数据量够(几百个被试、上万 epoch)时,端到端深度模型能省掉手工特征,且能自动学到时序上下文。单通道睡眠分期最经典的架构是「1D-CNN 提局部波形 + BiLSTM 抓 epoch 间时序 + 全连接分类」。为什么用 BiLSTM 而不是单向?因为睡眠阶段有强上下文:N2 后面大概率还是 N2,REM 往往在 N2 之后出现,双向能看到前后文,实测比单向高 2-3 个百分点。

import torch import torch.nn as nn class SleepNet(nn.Module): def __init__(self, n_classes=5): super().__init__() # 三层 1D 卷积,逐层增大感受野 self.cnn = nn.Sequential( nn.Conv1d(1, 32, 50, stride=6, padding=25), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(8, stride=8), nn.Conv1d(32, 64, 8, stride=1, padding=4), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(4, stride=4), nn.Conv1d(64, 128, 8, stride=1, padding=4), nn.BatchNorm1d(128), nn.ReLU(), ) self.lstm = nn.LSTM(128, 128, batch_first=True, bidirectional=True) self.fc = nn.Linear(256, n_classes) def forward(self, x): # x: (batch, seq_len, 3000) -> (batch, 1, seq_len*3000) 不行 # 正确做法:逐 epoch 过 CNN,再按序列送 LSTM b, s, t = x.shape x = x.view(b * s, 1, t) x = self.cnn(x) # (b*s, 128, L) x = x.mean(dim=-1) # 全局平均池化 -> (b*s, 128) x = x.view(b, s, 128) x, _ = self.lstm(x) # (b, s, 256) return self.fc(x) # (b, s, 5)

逻辑说明:第一层卷积核 50、步长 6,是为了在 100Hz 下覆盖约 0.5 秒的波形,抓纺锤波和 K 复合波;后面两层核 8 抓更细的局部模式。全局平均池化把每个 epoch 压成 128 维向量,再按序列送 LSTM。输入组织成(batch, seq_len, 3000),seq_len 通常取 20-30,即用前后各若干 epoch 的上下文。

参数怎么改:seq_len是关键,太小抓不到上下文,太大显存吃不消且引入无关历史,20 左右是常见值。类别不平衡要在 loss 里处理,用带权重的交叉熵,权重按类别频率的倒数设。

4. 训练、评估与类别不平衡处理:让 N1 不再被吞掉

4.1 加权损失与按被试划分

睡眠分期最大的坑是类别不平衡。Sleep-EDF 里 N2 能占 50% 以上,N1 常常不到 5%。如果直接训,模型会把所有难分的都预测成 N2,总体准确率看着有 80%,但 N1 的 F1 接近 0,这种模型在临床上没用。解决办法是加权交叉熵,权重取1 / 类别频率,再归一化。

from sklearn.utils.class_weight import compute_class_weight import numpy as np classes = np.unique(y_train) weights = compute_class_weight("balanced", classes=classes, y=y_train) weights = torch.tensor(weights, dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=weights)

逻辑说明:balanced模式自动按n_samples / (n_classes * count)算权重,少数类权重高。注意权重别设太极端,否则模型会过度预测少数类,N3 和 REM 的精确率掉得厉害。我一般会在 balanced 权重基础上做一次开方平滑,比如weights ** 0.5,实测更稳。

划分数据集必须按被试划分,不能按 epoch 随机划分。同一被试的不同 epoch 高度相关,随机划分会让训练集和测试集泄漏,准确率虚高十几个点。正确做法是留出若干被试完全不参与训练,只做测试。

4.2 评估指标:别只看准确率

评估要看三样:总体准确率(ACC)、宏平均 F1(macro-F1)、以及混淆矩阵。macro-F1 对少数类敏感,是判断模型是否可用的核心指标。一个能用的单通道模型,在 Sleep-EDF 上通常能做到 ACC 82-86%、macro-F1 0.75-0.80。如果 ACC 很高但 macro-F1 低于 0.7,基本是类别不平衡没处理好。

from sklearn.metrics import classification_report, confusion_matrix preds = model.predict(X_test) print(classification_report(y_test, preds, target_names=["W","N1","N2","N3","REM"])) print(confusion_matrix(y_test, preds))

逻辑说明:classification_report直接给出每类的 precision/recall/F1,重点看 N1 那一行。混淆矩阵能看出错误集中在哪,通常 N1 和 N2、N1 和 REM 之间混淆最多,这是单通道的固有局限,不是模型问题。

参数怎么改:如果 N1 的 recall 太低,可以调低 N1 的判定阈值(后处理),或者用 focal loss 替代加权交叉熵。如果 N3 和 N2 混,检查滤波是否把 delta 频段削掉了。

5. 避坑与排查:单通道睡眠分期最常见的五个翻车点

现象一:训练 loss 正常下降,但验证集准确率一直 20% 左右(等于瞎猜)。原因:标签映射错了,或者 epoch 和标签错位。最常见的是events_from_annotations返回的事件顺序和get_data的 epoch 顺序没对齐,或者把 UNKNOWN 类也当成了有效类。 解决:打印前 20 个 epoch 的标签分布,和原始注释对照;确认event_id里没有把不同阶段映射成同一个整数(除了 3 和 4 合并成 N3)。

现象二:换一个被试测试,准确率暴跌到 60%。原因:个体差异。不同人的脑电幅度、阻抗、甚至电极位置都有差异,模型学到了训练被试的特定模式。 解决:逐 epoch z-score 归一化必须做;训练时做被试级的数据增强(随机幅度缩放、加高斯噪声);有条件的用对抗训练或域自适应。

现象三:N1 的 F1 始终接近 0。原因:N1 是过渡期,本身就和 W、N2 边界模糊,加上样本极少,模型直接放弃。 解决:加权损失 + 后处理阈值调整;把 N1 和 REM 的区分特征(眼动相关,但单通道没有眼电)用频域特征近似;接受 N1 的 F1 在 0.3-0.4 是单通道的正常水平,不要强求。

现象四:显存爆了,或者训练极慢。原因:把整晚信号一次性送进 LSTM,序列长度上千。 解决:按 seq_len=20 切窗口,每个窗口独立送模型;用DataLoadernum_workers并行加载;信号用 float32 而不是 float64。

现象五:推理时结果和训练时对不上。原因:训练用了双向 LSTM,推理时如果按流式逐 epoch 输入,没有未来信息,性能会掉。 解决:要么推理时也攒够一个窗口再出结果(有延迟),要么训练时就用单向 LSTM 或因果卷积,牺牲一点精度换实时性。

6. 源码结构与文档说明:让这套东西能被别人跑起来

一套能交付的单通道睡眠分期项目,目录结构我一般这么组织:data/放原始 EDF 和切好的 npy,src/放预处理、特征、模型、训练脚本,configs/放 YAML 参数,checkpoints/存权重,docs/放说明。文档说明里最该写清楚的不是模型多牛,而是三件事:数据集从哪下、怎么放、跑哪条命令能复现论文里的数字。

# 典型复现流程 python src/preprocess.py --data_dir data/raw --out_dir data/processed python src/train.py --config configs/sleepnet.yaml --gpu 0 python src/evaluate.py --ckpt checkpoints/best.pt --split test

逻辑说明:预处理和训练解耦,预处理只跑一次,把 EDF 转成 npy,后续训练直接读 npy,速度快很多。configs里把学习率、batch size、seq_len、滤波范围都参数化,换数据集只改配置不改代码。

文档里必须写的一个表是「参数默认值与调参范围」:

参数默认值可调范围影响
采样率100Hz100-128高频特征完整性
滤波带0.5-40Hz0.3-45Hz漂移与肌电抑制
seq_len2010-30上下文长度与显存
学习率1e-31e-4~5e-3收敛速度
类别权重balanced^0.50.5-1.0 次方少数类召回

验证方法上,我习惯做两件事:一是留一被试交叉验证(LOSO),最能反映泛化;二是把模型输出和原始 hypnogram 画在一起,肉眼看错误是不是集中在阶段切换处。如果错误都堆在切换点,说明模型对过渡期不敏感,可以加一个「阶段转移矩阵」的后处理,用维特比解码平滑预测序列,通常能再涨 1-2 个点。

最后说个我自己的习惯:每次改完模型,先在一个被试上跑通全流程再上全量数据,别一上来就训 78 个被试,等两小时发现标签错了,那才叫后悔药没处买。这套方案值不值得做,取决于你的场景——如果是要做居家长期监测的粗分期,单通道加这套流程完全够用;如果是要做临床级判读,还是老老实实上多导。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 23:13:59

交通标志检测数据集实战指南:YOLOv8训练避坑与鲁棒性验证

简介&#xff1a;本资源是面向自动驾驶算法工程师、计算机视觉研究者及智能交通系统开发者的高精度YOLO格式目标检测数据集&#xff0c;专为多类别交通物体与标志联合识别任务设计。数据集覆盖真实道路场景下的7大类146个精细子类&#xff0c;包括134种交通标志、多种交通工具、…

作者头像 李华
网站建设 2026/9/24 23:13:34

AgentScope 2.0多Agent编排实战:Java后端集成与Dify搭配指南

这些年陆陆续续搭过不少多智能体应用&#xff0c;从早期的纯Prompt拼接、到后来的LangChain/CrewAI&#xff0c;再到真正把多个Agent放进业务系统里跑起来&#xff0c;我最大的感受是&#xff1a;单个Agent好写&#xff0c;多个Agent协作的系统很容易烂尾。最近这段时间我密集调…

作者头像 李华
网站建设 2026/9/24 23:13:30

Python+Ollama+Chroma+LangChain:打造能记住上下文的客服机器人

用 Python Ollama Chroma LangChain 攒一个能记住上下文的客服机器人&#xff0c;其实没有想象中那么难先说个场景&#xff1a;我在本地跑过不少开源大模型&#xff0c;也试过直接调用各种在线 API 来做问答。但真到了要做一个“能记住用户上一句说了什么”的客服系统时&…

作者头像 李华
网站建设 2026/9/24 23:12:32

智能设备断网还能响应?揭秘本地唤醒与离线控制原理

1. 从“断网小智”这个反常识现象说起很多人第一次发现家里的智能音箱在Wi-Fi断掉后还能响应“小智小智”&#xff0c;第一反应是&#xff1a;它是不是偷偷连着别的网&#xff1f;或者根本没断网&#xff1f;我去年帮朋友调试一套全屋智能系统时&#xff0c;就亲眼看着他家路由…

作者头像 李华
网站建设 2026/9/24 23:11:47

WiFi-DensePose与OpenHarmony融合:分布式智慧家居感知方案

1. 从WiFi信号到人体姿态&#xff1a;这个融合方案到底在解决什么问题第一次看到"WiFi-DensePose OpenHarmony 智慧家居融合"这个组合的时候&#xff0c;我脑子里冒出来的第一个念头是&#xff1a;终于有人把这两件事往一块儿凑了。WiFi-DensePose 本身是近几年无线…

作者头像 李华
网站建设 2026/9/24 23:11:28

AI Agent 驱动 Elasticsearch 查询优化:基准测试框架与实战

1. 为什么我们要让 AI agent 来碰 Elasticsearch 的查询优化Elasticsearch 的性能调优这件事&#xff0c;做过的人都知道&#xff0c;它属于那种"看起来有章可循&#xff0c;实际上处处是坑"的活。官方文档给了一堆参数&#xff0c;什么refresh_interval、translog.d…

作者头像 李华