EEG睡眠分期实战:基于深度学习的睡眠状态检测方案
简介面向人工智能与深度学习方向的睡眠状态检测实践项目聚焦脑电数据的自动分期帮助识别快速眼动睡眠与非快速眼动睡眠等不同阶段适合相关专业学生、研究人员及工程师参考学习。压缩包内共两个Python脚本分别用于脑电数据加载和基于卷积神经网络的分类模型构建整体大小仅4KB便于快速查看与迁移使用。已有474人学习下载。脚本覆盖脑电信号预处理、特征提取、模型训练与效果评估等关键步骤包括卷积神经网络对时序信号的特征捕捉、交叉验证方法以及准确率和F1分数等评价指标可作为入门深度学习的动手范例。通过阅读代码能够掌握从原始脑电数据到睡眠分期结果的完整处理流程并在此基础上尝试循环神经网络或注意力机制等改进思路为人工智能与医疗健康的交叉研究打下基础。1. 睡眠状态检测为什么必须用 EEG 加深度学习睡眠状态检测EEG这个标题看起来像是一个打包好的代码工程但拆开看它背后是睡眠医学与深度学习的一次直接碰撞用脑电信号代替多导睡眠监测里繁琐的肌电、眼电用神经网络代替手工规则做睡眠分期。现在可穿戴设备越来越多家用睡眠监测、失眠干预、鼾症初筛都需要一个能自动把整夜睡眠切成 Wake、N1、N2、N3、REM 五个阶段的模型。这套流程的核心难点不在模型结构而在数据——EEG 通道怎么选、分段多长、伪迹怎么处理这些决定模型上限。这篇笔记我会把整套方案拆开讲信号处理部分该做什么、模型怎么选、参数怎么定、训练时最容易踩哪些坑。目标是让你拿到一个公开的睡眠 EEG 数据集后能独立跑通完整的训练和评估流程并知道换到自己的设备数据时哪里要返工。适合正在做脑电分类、睡眠监测产品原型、或刚入手生物信号深度学习的开发者。新手按步骤走熟手直接看第五章的坑和第六章的验证策略。2. 睡眠分期的任务定义先搞清楚你要模型输出什么2.1 从多导睡眠图到单通道 EEG任务边界在哪里临床上睡眠分期遵循 RK 规则或 AASM 规则标准做法是同时采集 EEG、EOG眼电和 EMG肌电由技师按 30 秒一屏逐段判读。深度学习方案的目标是用更少的通道逼近这个人工标注结果。常见做法是用一到两个 EEG 通道比如 F4-M1、C4-M1加一个参考通道把 30 秒作为一个样本输出五个阶段的概率分布。这里有一个容易误判的点单通道 EEG 做睡眠分期不是降级版而是产品化过程中的必然取舍。睡眠纺锤波、K 复合波、慢波这些核心特征在中央区导联上足够明显去掉 EOG 和 EMG 后REM 阶段主要靠 EEG 的低波幅混合频率特征来区分确实会损失一部分信息但换来的是设备复杂度大幅下降。你的模型天花板由此被数据通道数决定而不是被网络结构决定。2.2 五个睡眠阶段的 EEG 特征模型到底在学什么Wake 阶段Beta13Hz和 Alpha8-13Hz活动明显闭眼时 Alpha 波在枕区突出。N1 是清醒到浅睡的过渡Alpha 波减弱Theta4-8Hz增多。N2 的标志是睡眠纺锤波Sigma 频段11-16Hz和 K 复合波。N3 是慢波睡眠Delta0.5-4Hz占比超过 20%。REM 阶段低波幅混合频率锯齿波偶见但最典型的特征是没有肌电EEG 上看是去同步化。深度学习模型不需要你手工提取这些特征但理解阶段的频段区别对预处理参数设置至关重要。比如滤波下限如果设成 0.5Hz 以下会把基线漂移放进模型如果设成 1Hz 以上Delta 波被削弱N3 的识别率会明显下降。这些参数不是拍脑袋定的是跟着生理信号特征走的。在你设计预处理流程前先花十分钟把上表记在脑子里后面调参时会有方向感。2.3 30 秒分段的原因为什么不能随便改窗口长度睡眠分期标准里每个标注单元的时长是 30 秒。这个长度不是随意定的它对应着临床上判读的基本时间单位。你在复现深度学习方案时锚定 30 秒分段是第一原则。窗口太短比如 5 秒瞬态特征会被误判为整段状态窗口太长比如 5 分钟阶段切换的边界会糊掉N1 这种平均只有几分钟的阶段会被直接淹没。工程上有时候会做细粒度分段比如 10 秒或 1 秒再用后处理合并成 30 秒的多数投票这是为了配合在线监测或者解决标注边界问题。但如果你刚开始做先用标准 30 秒分段把所有流程跑通再考虑下一步优化。改窗口长度不是找最优超参而是改变任务定义本身——评估指标、模型容量、后处理策略全部要跟着变牵一发动全身。3. 数据准备和预处理决定模型上限的脏活累活3.1 公开数据集怎么选文件格式坑与采样率坑目前做睡眠分期最常用的公开数据集是 Sleep-EDF 系列和 MASS 数据集。Sleep-EDF 的 PSG 文件是 EDF 格式里面有 EEG、EOG、EMG 多路信号还有逐段的 hypnogram 标注。下载解压后你会看到 .edf 和 .rec 两类文件标注通常在 -Hypnogram.edf 里。读取 EDF 文件我用的是 MNE-Python它把 EDF 解析、通道选择、事件读取都封装好了。核心代码如下import mne import numpy as np # 读取 PSG 主文件preloadTrue 一次性载入内存 raw mne.io.read_raw_edf( subject01.edf, preloadTrue, verboseFalse ) # 读取同名的 Hypnogram 标注文件 annot mne.read_annotations(subject01-Hypnogram.edf) # 把标注挂到 raw 对象上按起始时间对齐 raw.set_annotations(annot) # 只保留 EEG 通道丢掉 EOG/EMG/温度等通道减少干扰 raw.pick_channels([EEG Fpz-Cz, EEG Pz-Oz]) # 统一重采样到 100HzSleep-EDF 原始是 100Hz但其他数据集可能是 256Hz 或 512Hz raw.resample(100, npadauto)逻辑说明read_raw_edf 只做解析不做重采样preloadTrue 是必须的不然后面切片时反复读磁盘会慢到怀疑人生。pick_channels 这一步很关键如果保留全部通道模型会学到非 EEG 通道的泄漏特征。resample 到 100Hz 足够覆盖睡眠分期的所有频段Delta 到 Beta 不过几十 Hz还能把数据量降到原来的四分之一到二分之一训练速度快很多。参数说明通道名在不同数据集里不一样Sleep-EDF 是 EEG Fpz-CzMASS 是 C4-A1 这类命名。跑任何新数据集前先打印 raw.info[ch_names] 确认通道名别拿到就 pick会直接报 KeyError。采样率统一到 100Hz 后后续所有滤波截止频率都按 100Hz 设计。3.2 滤波、去伪迹、归一化三条必须遵守的纪律滤波是对每个样本做带通我用 0.5-40Hz。下限 0.5Hz 保护 Delta 波上限 40Hz 去掉高频肌电噪声。注意滤波要在分段之前做因为滤波本身有边缘效应分段后再滤波会在每段首尾产生振铃。这一段必须写在处理里# 带通滤波0.5Hz 下限保护慢波40Hz 上限去掉肌电 raw.filter(0.5, 40, fir_designfirwin, verboseFalse) # 按 30 秒窗口切分成事件得到样本起止时刻 events, event_id mne.events_from_annotations(raw) # 切片并提取数据tmin0, tmax30 表示每个 epoch 取完整 30 秒 epochs mne.Epochs( raw, events, event_idevent_id, tmin0, tmax30, baselineNone, preloadTrue, verboseFalse ) # 拿到数值数组形状为 (n_epochs, n_channels, n_times) data epochs.get_data() labels epochs.events[:, 2] # 事件码就是睡眠阶段标签逻辑说明mne 的 Epochs 会把连续信号按事件时间戳切成固定长度片段。这里 tmin0、tmax30 意味着每个 epoch 严格从标注起点开始取 30 秒。baselineNone 是因为 EEG 的绝对基线对睡眠分期没有意义我们关心的是相对变化。labels 直接取自事件码需要做一个映射字典把事件码转成阶段编号。参数说明不同数据集的标注编码不一样Sleep-EDF 的事件码是 1Wake、2N1、3N2、4N3、5REM但 MASS 里是 0Wake 开始。务必打印 labels 的取值分布确认映射关系这一步错了后面全是白做。伪迹处理这里暂时不做独立剔除因为深度学习模型有一定抗噪能力过度清洗反而会损失 N1 和 REM 这类低幅波段的样本。归一化我放在模型输入前做用 z-score 按通道均值和标准差归一化不按样本归一化。按样本归一化会抹掉清醒和深睡之间幅度的绝对值差异等于把模型最重要的线索洗掉了。按全局统计量归一化保住了相对振幅信息计算量也小。3.3 标签映射和类不平衡训练前不看分布等于白做把事件码映射成模型输出的类别序号后先看一眼分布。睡眠数据里 N1 样本天然稀少通常只占总量的 5-8%N2 占一半N3 在年轻人里也很多。如果直接训练模型会倾向把所有样本都判成 N2整体准确率照样能到 60% 以上——这成绩是假的。处理方式有两个方向一是用加权交叉熵给 N1 更高权重二是用过采样对 N1 做重复抽样或生成少量合成样本。我一般先加权因为简单可靠不会引入额外偏差。代码层面就是一个 class_weight 字典后面模型训练章节会展开。数据准备阶段的核心动作是确认每个类别的样本数然后决定策略。4. 模型选型与训练从 CNN 到序列模型参数怎么定4.1 为什么先用 CNN 而不是 TransformerEEG 睡眠分期的最常见的深度模型是CNN 提取局部特征 序列模型捕捉时间依赖的架构。纯 CNN比如把 30 秒数据当 2D 图像做分类也能跑到 80% 左右准确率但它把每个 30 秒当成独立样本忽略了睡眠阶段的前后依赖——N2 后面大概率是 N3 或 REM这个上下文信息能显著降低跳变错误。我习惯先跑一个纯 CNN 基线把分类问题彻底打通再加序列模型。这样出了问题容易定位CNN 部分负责特征提取序列部分负责时间建模管线拆开调。纯 CNN 的另一个好处是训练快Sleep-EDF 全集大概 80 个受试者单卡训练半小时能出一个完整 epoch迭代试错成本低。先跑通再优化。4.2 一个能直接跑的 CNN 基线模型我用的基线结构第一层卷积核较大覆盖约 1 秒信号捕捉慢波和纺锤波后续小卷积核捕捉局部细节最后全局池化接全连接。代码可以直接跑输入形状是 (batch, 1, 3000)对应 100Hz 采样率下 30 秒的单通道数据import torch import torch.nn as nn class SleepCNN(nn.Module): def __init__(self, num_classes5, input_channels1, fs100): super().__init__() # 大卷积核覆盖 1 秒信号捕获 Delta 慢波和纺锤波整体形态 self.conv1 nn.Sequential( nn.Conv1d(input_channels, 64, kernel_sizefs, stridefs // 2, paddingfs // 2), nn.BatchNorm1d(64), nn.ReLU(inplaceTrue) ) # 小卷积核堆叠提取局部细节 self.conv2 nn.Sequential( nn.Conv1d(64, 128, kernel_size3, padding1), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Conv1d(128, 128, kernel_size3, padding1), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.MaxPool1d(2) ) # 池化序列固定输出长度 self.global_pool nn.AdaptiveAvgPool1d(1) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): x self.conv1(x) # (B, 64, 30) x self.conv2(x) # (B, 128, 15) x self.global_pool(x).squeeze(-1) # (B, 128) return self.classifier(x)逻辑说明stridefs//2 的意思是每 50 个采样点0.5 秒滑动一次相邻窗口有 50% 重叠这样 Slow Wave 不会因为恰好落在窗口边界而丢失。AdaptiveAvgPool 把不同长度的特征压成固定长度这样即使你换采样率分类头也不用改。参数说明kernel_sizefs 这个写法是故意的100Hz 采样率下 kernel100 表示覆盖 1 秒。对手上的数据如果采样率是 256Hzkernel_size 要跟着改成 256。BatchNorm 对 EEG 这种幅度变化大的信号很有用把每层的分布拉回标准范围训练稳定很多。Dropout 0.5 用在最后的分类层前防止模型记住个别受试者的噪声模式。4.3 训练参数和优化器学习率、批大小、早停的判断标准训练配置我固定用 AdamW初始学习率 1e-3权重衰减 1e-4batch size 64。Sleep-EDF 单受试者约 8 小时睡眠得到约 960 个 epoch30 秒样本80 个受试者总共约 7 万样本batch 64 一个 epoch 约 1100 步。代码from torch.utils.data import DataLoader, TensorDataset # data: (n_epochs, 1, 3000)labels: (n_epochs,) dataset TensorDataset( torch.FloatTensor(data), torch.LongTensor(label_vec) ) # shuffleTrue 打乱样本顺序保证每个 batch 类别分布接近全局分布 loader DataLoader(dataset, batch_size64, shuffleTrue) criterion nn.CrossEntropyLoss( weighttorch.FloatTensor([1.0, 2.0, 1.0, 1.0, 1.5]) ) # N1 权重2.0REM 权重1.5 optimizer torch.optim.AdamW( model.parameters(), lr1e-3, weight_decay1e-4 ) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max30, eta_min1e-5 )逻辑说明CrossEntropyLoss 的 weight 按类别样本数反比粗略设置。N1 最少给 2.0REM 也比较少给 1.5N2 最多保持 1.0。准确率不是唯一标准后续要算每类的 F1权重设置要以 F1 均衡为目标不是以整体准确率为目标。参数说明初始学习率 1e-3 配合 AdamW 对大多数 CNN 结构够用如果 loss 震荡不下降先降到 3e-4 再试。CosineAnnealing 把学习率先高后低地降下来比固定学习率在后期更稳。T_max30 表示 30 个 epoch 完成一个余弦周期如果你的数据量少比如只有 20 个受试者T_max 减半。早停看验证集的 Cohens Kappa不看 loss因为 loss 对类别不平衡不敏感Kappa 对睡眠分期这种阶段连续性问题更可靠。连续 10 个 epoch Kappa 不升就停然后载入最优 checkpoint。4.4 序列建模在 CNN 后面加双向 LSTM参数这样设CNN 抽取的是单样本特征LSTM 用来建模前后阶段依赖。做法是先把每个 30 秒样本过 CNN 得到特征向量再按睡眠顺序排成序列每 20 个连续 epoch 组成一个输入序列LSTM 输出每个位置的分类结果class SleepSequenceModel(nn.Module): def __init__(self, cnn, hidden_size128, num_layers2, num_classes5): super().__init__() self.cnn cnn # 双向 LSTM回头看前文也看后文 self.lstm nn.LSTM( input_size128, # CNN 输出维度 hidden_sizehidden_size, num_layersnum_layers, batch_firstTrue, bidirectionalTrue, # 双向能捕捉阶段切换的两侧上下文 dropout0.3 # 多层 LSTM 只有层间 dropout 生效 ) self.classifier nn.Sequential( nn.Dropout(0.3), nn.Linear(hidden_size * 2, num_classes) # 双向要乘2 ) def forward(self, x_sequence): # x_sequence: (B, seq_len, 1, 3000) batch_size, seq_len x_sequence.size(0), x_sequence.size(1) # CNN 处理每个时间步 cnn_feats self.cnn( x_sequence.view(batch_size * seq_len, 1, 3000) ).view(batch_size, seq_len, -1) # LSTM 捕捉时间依赖 out, _ self.lstm(cnn_feats) return self.classifier(out)逻辑说明训练时把连续的 20 个 epoch 拼成一个 batch 喂进去seq_len20 是经验值20 个 30 秒片段等于 10 分钟足够覆盖 N1 到 N3 的完整转换。输出形状是 (batch, 20, 5)loss 计算时和标签序列直接对齐。推理阶段用滑窗每 30 秒输出一个分类结果。参数说明hidden_size128 对单通道 EEG 够用增大到 256 涨点有限但显存翻倍。num_layers2 是折中再深容易在小数据上过拟合。bidirectionalTrue 双向建模时当前阶段会受到前后各约 5 分钟上下文的影响对消除孤立跳变特别有效。代价是延迟增加——在线监测场景要用单向 LSTM 并限制反向范围这是后话。5. 睡眠分期避坑记录现象、原因、解决5.1 准确率很高但 Kappa 很低类别不平衡的假象现象训练后整体准确率 82%但画出混淆矩阵发现 N1 几乎全被分到 N2 和 WakeN1 的 F1 只有 0.2。原因是 N1 样本占比太少模型学到的决策边界完全被 N2 主导。解决方法是加权交叉熵给 N1 权重 2.5并计算每类 F1 作为报告指标。调整后整体准确率降到 78%但 Kappa 从 0.61 升到 0.74这才是真实进步。5.2 验证集指标好换一批受试者就崩跨受试者的过拟合陷阱现象按受试者随机划分训练集和测试集时准确率 85%按受试者分组划分即同一人的数据不在两个集合里同时出现准确率立刻掉到 70%。原因是数据集存在受试者个体差异模型记住了特定人的波形特征。解决方法是训练时按受试者分组划分用 GroupKFold 做交叉验证推理前做通道级归一化时用训练集的均值标准差绝不能混入测试集统计量。血泪经验睡眠分期领域跨受试者泛化才是真问题。5.3 换设备后结果全面恶化采样率和通道位置不一致现象用临床设备数据训练的模型拿到可穿戴单通道设备上Kappa 从 0.72 掉到 0.4。原因是可穿戴设备采样率通常是 200Hz 或 250Hz导联位置在额头或耳后信号形态和临床数据差距很大。解决方法是重采样到 100Hz并做一次通道响应的粗略校正。更实际的做法是在新设备上采集小样本微调冻结前两层卷积只训练后面的层30 分钟数据就能把 Kappa 拉回 0.65 以上。5.4 模型的睡眠阶段跳变频繁缺少后处理现象模型输出的分期序列出现大量单点跳变比如 N2-N3-N2-N3 在 2 分钟里反复横跳。单看每 30 秒的准确率尚可但画成 hypnogram 后完全没法看临床医生拒绝接受。原因是模型没有输出平滑约束睡眠阶段在实际中不会 30 秒就切换一次。解决方法是加一个维特比解码或多数投票后处理窗口 5 期前后各 2 个把中间那个改成窗口内的多数类。这种简单后处理能把 Kappa 提升 0.03-0.05而且不消耗任何额外计算量。5.5 训练 loss 一直降不下去检查标签对齐和通道极性现象loss 在 1.2 附近震荡就是不降用几个样本手动检查发现预测结果完全随机。原因是标签和事件没有对齐——Hypnogram 文件里的标注时间起点和 PSG 文件不一致导致切出来的数据段和标签错位。解决方法是打印 events 和标注时间取前 10 个样本画出波形对照标签一眼就能看出神经网络的输入是清醒脑电、标签却写着 N3。这类问题属于黑匣子陷阱数据不出错模型永远学不出有意义的结果。另外有些公开数据的 EEG 通道极性反了Fpz-Cz 和 Cz-Fpz预处理阶段统一乘以 -1 即可。6. 模型验证和部署从离线分期到在线监测的技巧最终验证不能只看准确率我固定看三件事Cohens Kappa 是否大于 0.7每类 F1 的标准差是否过大以及 hypnogram 可视化结果是否合理。Kappa 0.7 以上是睡眠分期领域公认的可用门槛N1 的 F1 标准差如果超过 0.15说明某几个受试者的 N1 完全没学会hypnogram 要看整夜的结构入睡潜伏期、N3 占比、REM 周期大约 90 分钟一个循环如果模型输出没有这种周期结构说明时间依赖没建模好。模型导出用 ONNXTorch 转 ONNX 时固定 seq_len1这样每个时刻只依赖已计算的特征和有限的历史状态不依赖未来数据。在线场景里每 30 秒拿到一个新 epoch维护一个长度 20 的特征队列每次推理输出当前时刻的分类实现延迟约一个 epoch。import onnx import torch # 把 CNNLSTM 模型转成 ONNX固定序列长度为1的推理形式 dummy_input torch.randn(1, 1, 1, 3000) torch.onnx.export( model, dummy_input, sleep_model.onnx, input_names[eeg_epoch], output_names[stage_logits], dynamic_axes{eeg_epoch: {0: batch}}, opset_version13 ) # 用 onnxruntime 加载并推理 import onnxruntime as ort sess ort.InferenceSession(sleep_model.onnx) logits sess.run(None, {eeg_epoch: new_epoch.numpy()})[0]逻辑说明dynamic_axes 把 batch 维度设为动态推理时每次只传一个样本省内存。ONNX 部署避免在线上环境装 PyTorch模型体积和推理速度都更友好。参数说明opset_version13 兼容大部分 CPU/GPU 推理环境。如果目标是嵌入式设备再用 int8 量化但先确认量化后 Kappa 不掉超过 0.05否则放弃量化换更小的模型。睡眠状态检测这个方向表面上是在做模型实际上做的是数据管道加评测标准。模型结构找个公开基线抄都行但你知道自己的标注边界在哪、伪迹处理到什么程度、验证集怎么划分才公平这才是能上线和不能上线的差别。我的习惯是每跑一个实验先画 hypnogram 盯着看一遍再去看指标数字——黑匣子可以接受但黑到完全不知道模型学了什么那才真的让人睡不着。希望帮到你。本文还有配套的精品资源点击获取