单通道脑电睡眠分期实战:Python源码与避坑指南
简介这份资源面向计算机、人工智能、通信工程、自动化等专业的在校学生与教师以及希望入门生理信号处理的企业员工提供一套基于单通道脑电信号实现自动睡眠分期的完整Python项目。项目围绕睡眠分期任务涵盖数据下载与预处理、数据集构建、深度网络模型定义、训练与预测脚本并附带网页端演示模块可作为毕业设计、课程设计或项目立项的参考范例。压缩包共22个文件约10.67MB其中12个py脚本承担数据处理、模型训练与推理等核心逻辑另有2个pt权重文件、3个txt依赖与说明、1个sh运行脚本及md文档、html页面、png示意图等结构清晰便于按模块学习。目前已有118人学习下载。读者可借此掌握单通道脑电的预处理流程、深度网络搭建思路与训练调参方法并参考README与文档快速复现实验在现有代码基础上修改以拓展其他功能。1. 单通道脑电做睡眠分期为什么一个电极也能跑出可用的自动判读睡眠分期是睡眠医学里最耗人力的环节。标准做法要贴十几个电极额区、中央区、枕区全上再按 AASM 规则每 30 秒一帧地人工判读一整夜下来几百帧技师盯屏幕盯到眼花。而单通道脑电single-channel EEG只用一个电极通常是 Fpz-Cz 或 Pz-Oz就能把整夜的睡眠结构自动分成 Wake、N1、N2、N3、REM 五类。这件事的意义在于设备成本降一个量级可穿戴头带、家用睡眠监测仪都能落地python 生态里又有现成的信号处理和深度学习库复现门槛比想象中低。这篇笔记拆的是「基于单通道脑电信号实现的自动睡眠分期研究 python 源码 文档说明 数据」这个方向。它解决的是从原始脑电到分期标签的完整链路数据怎么读、预处理怎么做、特征或网络怎么设计、结果怎么评估。适合两类人一类是刚拿到 Sleep-EDF 这类公开数据集、想跑通第一个 baseline 的算法新手另一类是想把分期模型塞进嵌入式或移动端、需要单通道轻量方案的工程师。下面按「数据与原理 → 预处理 → 模型 → 训练评估 → 避坑 → 进阶」的顺序讲透。2. 数据从哪来、单通道脑电到底长什么样2.1 公开数据集的选择与单通道信号的物理含义做睡眠分期绕不开公开数据集。最常用的是 Sleep-EDF也叫 Sleep-EDFx包含健康受试者和轻度失眠受试者的整夜多导睡眠图PSG采样率 100 Hz配套有专家按 RK 或 AASM 标准标注的每 30 秒一帧的睡眠阶段标签。另一个常见的是 SHHS规模更大但通道更多、下载门槛高。单通道方案一般从 Sleep-EDF 里挑 Fpz-Cz 这一路因为它对慢波和纺锤波的可见度都还行是文献里单通道基线用得最多的导联。单通道脑电的物理含义要说清楚它记录的是电极间电位差随时间的变化单位微伏幅度通常在 10 到 100 微伏之间。不同睡眠阶段的差异体现在频率成分上——Wake 以 alpha8-13 Hz和 beta13-30 Hz为主N1 是 theta4-8 Hz抬头、alpha 衰减N2 有睡眠纺锤波12-14 Hz和 K 复合波N3 是 delta0.5-4 Hz高幅慢波占主导REM 则接近 Wake 的低幅混合频率但伴随眼动。自动分期的本质就是从这段一维时序里把这些频段能量和瞬态事件抠出来。2.2 把 EDF 读进 python最小可运行的数据加载Sleep-EDF 是 EDF 格式python 里用mne或pyedflib都能读。我一般用 mne因为它对通道名和事件标注的处理更省心。下面这段是加载单个受试者、取出 Fpz-Cz、对齐标签的最小代码。import mne import numpy as np # 读取 PSG 与标注文件Sleep-EDF 的标注在 Hypnogram 文件里 raw mne.io.read_raw_edf(SC4001E0-PSG.edf, preloadTrue) annot mne.read_annotations(SC4001EC-Hypnogram.edf) raw.set_annotations(annot) # 只保留 Fpz-Cz 单通道单位转成伏特便于后续统一 raw.pick_channels([EEG Fpz-Cz]) raw.resample(100) # 原始已是 100 Hz这里显式声明避免歧义 # 按 30 秒一帧切分得到 (n_epochs, n_samples) 的数组 events, event_id mne.events_from_annotations(raw) sfreq raw.info[sfreq] epochs mne.Epochs(raw, events, event_id, tmin0, tmax30 - 1/sfreq, baselineNone, preloadTrue) data epochs.get_data()[:, 0, :] # 形状 (n_epochs, 3000) labels epochs.events[:, 2] # 整数编码的睡眠阶段逻辑说明read_raw_edf把信号读成 mne 的 Raw 对象read_annotations单独读标注再挂上去是因为 Sleep-EDF 的标注和信号在两个文件里。pick_channels只留一路这是单通道方案的关键动作。events_from_annotations把标注转成事件数组Epochs按 30 秒切帧正好对应 AASM 的判读单位。参数说明tmax设成30 - 1/sfreq是为了让每帧恰好 3000 个采样点避免边界多一个点导致形状不齐。resample这里其实没改采样率写出来是提醒你——如果换用 256 Hz 的数据集要先降采样到 100 Hz 再切帧否则每帧点数对不上。baselineNone是因为脑电本身没有刺激前基线别用默认的基线校正。2.3 标签映射与类别不平衡的初步认识Sleep-EDF 的标注是字符串得映射成整数。RK 标准里还有 S3、S4 和 MOVEMENT、UNKNOWN要合并或丢弃。常见做法是把 S3、S4 合并成 N3把 MOVEMENT 和 UNKNOWN 整帧丢掉。label_map { Sleep stage W: 0, Sleep stage 1: 1, Sleep stage 2: 2, Sleep stage 3: 3, Sleep stage 4: 3, # 合并进 N3 Sleep stage R: 4, } # 丢弃未映射的帧 mask np.isin(labels, list(label_map.values())) data, labels data[mask], labels[mask]这里要提前意识到类别不平衡N2 通常占整夜的 45% 到 55%N3 和 N1 各占 5% 到 10%REM 约 20%。如果直接拿准确率当指标模型全猜 N2 也能到 50% 以上这是后面评估章节要重点处理的坑。3. 预处理与特征工程单通道信号怎么洗干净3.1 滤波、去伪迹与归一化的具体参数原始脑电里有工频干扰、基线漂移和眼动伪迹。单通道没有参考通道做伪迹回归只能靠滤波和幅度阈值。标准流程是0.5 Hz 高通去基线漂移45 Hz 低通去高频噪声50 Hz 或 60 Hz 陷波去工频。带通范围别设太窄否则 delta 和纺锤波会被削掉。raw.filter(0.5, 45, fir_designfirwin) # 带通 raw.notch_filter(50, fir_designfirwin) # 工频国内 50 Hz # 按帧做 z-score 归一化消除个体幅度差异 data (data - data.mean(axis1, keepdimsTrue)) / (data.std(axis1, keepdimsTrue) 1e-8)参数说明fir_designfirwin用窗函数法设计 FIR相位线性不会让波形错位这对后面要看瞬态事件很重要。陷波频率按数据采集地选欧洲数据用 50 Hz美洲用 60 Hz选错等于没滤。逐帧 z-score 是单通道方案里很关键的一步因为不同受试者的绝对幅度差好几倍不归一化的话模型会学到受试者身份而不是睡眠阶段。伪迹处理上简单做法是设幅度阈值比如某帧峰峰值超过 200 微伏就标记为坏帧丢弃。更细的做法是用小波或经验模态分解把眼动成分分离但单通道下分离不干净我一般先用阈值粗筛够用。3.2 时频特征与深度特征的取舍传统机器学习的路子是手工提特征每个 30 秒帧里算各频段delta、theta、alpha、sigma、beta的相对功率谱密度加上 Hjorth 参数、样本熵、峰度偏度凑成几十维向量喂给随机森林或 SVM。这条路可解释性强小数据量下稳但上限有限。深度学习的路子是直接喂原始时序或时频图。1D-CNN 吃 3000 点原始信号或者先做短时傅里叶变换得到频谱图再喂 2D-CNN。近年主流是 CNN LSTM/Transformer 的混合结构CNN 提局部波形特征循环或注意力层抓帧间时序依赖——因为睡眠阶段是有转移规律的N1 后面大概率接 N2不会从 N3 直接跳 REM。选型理由如果数据只有几十个受试者、算力有限手工特征 树模型是稳妥起点一两天能出结果。如果有几百个受试者、有 GPU端到端深度模型上限更高但要注意过拟合。我一般先跑一个手工特征的随机森林当 baseline再上深度模型对比baseline 的混淆矩阵能告诉你哪些阶段天生难分。3.3 帧间上下文窗口的构造单帧 30 秒的信息有限N1 和 REM 在单帧频谱上很像。解决办法是给每帧拼上前后若干帧形成上下文窗口。常见做法是取当前帧前后各 2 帧共 5 帧作为一个样本标签取当前帧。def make_context(data, labels, ctx2): n len(data) X, y [], [] for i in range(ctx, n - ctx): # 沿通道维拼接前后帧形状 (2*ctx1, 3000) X.append(data[i-ctx:ictx1]) y.append(labels[i]) return np.array(X), np.array(y) X, y make_context(data, labels, ctx2)逻辑说明ctx2表示前后各两帧窗口总长 5 帧即 150 秒。这样模型能看到阶段转移的趋势。参数说明ctx越大上下文越丰富但样本数会减少2*ctx个且窗口跨越阶段边界时标签和上下文会不一致。经验值是 2 到 4太大反而引入噪声。注意切窗口要在归一化之后做否则前后帧的归一化基准不统一。4. 模型搭建与训练从 baseline 到可复现的深度网络4.1 手工特征 随机森林 baseline 的完整实现先给一个能当天跑通的 baseline。用 scipy 算功率谱sklearn 训随机森林。from scipy.signal import welch from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import cross_val_score import numpy as np def bandpower(epoch, sfreq100, bands((0.5,4),(4,8),(8,13),(12,14),(13,30))): freqs, psd welch(epoch, sfreq, npersegsfreq*4) feats [] for lo, hi in bands: idx (freqs lo) (freqs hi) feats.append(np.trapz(psd[idx], freqs[idx])) # 频段绝对功率 total np.trapz(psd, freqs) feats [f / (total 1e-8) for f in feats] # 转相对功率 feats [epoch.std(), np.percentile(epoch, 75) - np.percentile(epoch, 25)] return feats X_feat np.array([bandpower(e) for e in data]) clf RandomForestClassifier(n_estimators200, class_weightbalanced, random_state0) scores cross_val_score(clf, X_feat, labels, cv5, scoringf1_macro) print(macro-F1:, scores.mean())逻辑说明welch做功率谱估计npersegsfreq*4即 4 秒一段做平均平滑谱估计。五个频段覆盖 delta 到 betasigma 单独列出来是因为纺锤波在 N2 里关键。相对功率消除幅度影响再加两个时域统计量补充。参数说明class_weightbalanced是应对类别不平衡的关键让随机森林按类别频率反比加权否则 N1 会被完全忽略。scoringf1_macro而不是 accuracy因为 macro-F1 对每个类别等权能暴露少数类被牺牲的问题。n_estimators200是精度和速度的折中再多收益递减。4.2 1D-CNN BiLSTM 的端到端网络结构深度模型这边给一个经典结构三层 1D 卷积提局部特征接两层双向 LSTM 抓时序最后全连接分类。输入是上下文窗口。import torch import torch.nn as nn class SleepNet(nn.Module): def __init__(self, n_classes5, ctx5): super().__init__() self.cnn nn.Sequential( nn.Conv1d(1, 32, 50, stride6, padding25), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(8), nn.Conv1d(32, 64, 25, stride3, padding12), nn.BatchNorm1d(64), nn.ReLU(), nn.MaxPool1d(4), nn.Conv1d(64, 128, 10, stride1, padding5), nn.BatchNorm1d(128), nn.ReLU(), ) self.lstm nn.LSTM(128, 128, num_layers2, batch_firstTrue, bidirectionalTrue, dropout0.3) self.fc nn.Linear(256, n_classes) def forward(self, x): # x: (batch, ctx, 3000) - (batch, 1, ctx*3000) b, c, t x.shape x x.reshape(b, 1, c * t) x self.cnn(x) # (b, 128, T) x x.permute(0, 2, 1) # (b, T, 128) x, _ self.lstm(x) x x[:, -1, :] # 取最后时间步 return self.fc(x)逻辑说明第一层卷积核 50、步长 6对应约 0.5 秒的感受野抓纺锤波这种短事件后面核变小步长变小抓更抽象的模式。BatchNorm加速收敛MaxPool降维。BiLSTM 双向是因为上下文窗口内前后帧都已知不像在线推理只能看历史。取最后时间步接全连接是因为标签对应窗口中心帧而中心帧在序列末尾附近。参数说明dropout0.3防过拟合睡眠数据受试者少时容易过拟合。bidirectionalTrue让隐藏维翻倍成 256全连接输入要对应。输入 reshape 把上下文窗口在时间维拼起来是一种简化处理更讲究的做法是每帧单独过 CNN 再拼特征序列喂 LSTM但那样显存占用高。4.3 训练循环、损失函数与早停训练时用交叉熵但类别不平衡要加权。早停按验证集 macro-F1。from torch.utils.data import DataLoader, TensorDataset device cuda if torch.cuda.is_available() else cpu X_t torch.tensor(X, dtypetorch.float32) y_t torch.tensor(y, dtypetorch.long) loader DataLoader(TensorDataset(X_t, y_t), batch_size32, shuffleTrue) # 按类别频率反比给权重 counts np.bincount(y, minlength5) weights torch.tensor((1.0 / (counts 1e-6)), dtypetorch.float32) weights weights / weights.sum() * 5 criterion nn.CrossEntropyLoss(weightweights.to(device)) model SleepNet().to(device) opt torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) best_f1, patience, wait 0, 8, 0 for epoch in range(60): model.train() for xb, yb in loader: xb, yb xb.to(device), yb.to(device) opt.zero_grad() loss criterion(model(xb), yb) loss.backward() opt.step() # 验证与早停逻辑略按 macro-F1 更新 best_f1逻辑说明weight按类别频率反比算再归一化到均值为 1这样少数类 N1 的损失被放大模型不敢忽略。weight_decay1e-4是 L2 正则配合 dropout 一起压过拟合。早停patience8表示验证指标连续 8 轮不升就停避免在训练集上磨到过拟合。参数说明lr1e-3是 Adam 的常用起点如果 loss 震荡就降到 3e-4。batch_size32在单通道小模型上够用显存紧张可以降到 16。注意权重张量要搬到和模型同一设备否则报设备不一致的错。5. 评估、避坑与常见翻车点排查5.1 为什么 accuracy 会骗你指标选择与混淆矩阵前面提过N2 占比过半全猜 N2 的 accuracy 能到 50% 以上但模型对 N1、N3 完全没用。必须看 macro-F1 和每类召回率还要画混淆矩阵。睡眠分期文献里通用指标是 accuracy、macro-F1、Cohens kappa。kappa 排除了随机一致的概率比 accuracy 更能反映真实一致性人工判读的 inter-rater kappa 也就 0.75 到 0.85模型能到 0.7 以上就算可用。混淆矩阵里最常见的错误是 N1 被大量判成 N2 或 REM因为 N1 是过渡阶段频谱上和两者都重叠。如果 N1 召回率低于 0.3先别急着换模型回去检查标签映射有没有把 N1 和 N2 的边界搞错以及上下文窗口是不是太大把 N1 的短暂时段淹没了。5.2 五条血泪踩坑记录现象训练 loss 一直降验证 macro-F1 卡在 0.5 不动。原因逐帧归一化做在了切帧之前导致归一化基准是全夜统计量帧间幅度信息被抹平。解决归一化必须在切帧之后、按帧独立做保留帧内相对幅度。现象换一个受试者测试性能断崖式下跌。原因训练集和测试集按帧随机划分同一受试者的帧同时出现在两边模型记住了受试者特征。解决必须按受试者划分留出整夜数据做测试这是睡眠分期评估的铁律。现象模型把大部分 REM 判成 Wake。原因REM 和 Wake 都是低幅混合频率单通道下频谱接近且 REM 占比低被加权后仍不够。解决加入眼动相关的间接特征额区电极对眼动敏感或把上下文窗口拉长让模型看到 REM 的周期性出现。现象工频陷波后信号出现振铃纺锤波变形。原因IIR 陷波器相位非线性且 Q 值设太高导致时域振铃。解决改用 FIR 陷波或把带通上限设到 45 Hz 让工频自然衰减不单独陷波。现象GPU 显存爆掉batch 调到 4 还是 OOM。原因上下文窗口在时间维拼接后序列长度是 ctx*3000ctx5 就是 15000 点第一层卷积输出维度巨大。解决改成每帧单独过 CNN 再拼特征序列或把 ctx 降到 2或先降采样到 50 Hz。5.3 跨受试者泛化与数据泄漏的边界睡眠分期模型最大的敌人是受试者间差异。同一个人不同夜的脑电都比不同人之间像。所以评估协议必须是 leave-one-subject-out每次留一个受试者做测试其余训练最后平均。如果论文里只报随机划分的结果基本不可信。数据泄漏还有几个隐蔽来源预处理时用全夜统计量做归一化测试集信息泄漏到训练、滤波时用了未来样本因果性破坏、按帧划分时相邻帧高度相关。这几个坑我都踩过最隐蔽的是滤波filtfilt这种零相位滤波会用整段信号在线推理时根本做不到训练时用了就等于作弊。离线评估可以用但要在文档里写清楚别当成在线方案。6. 把单通道分期推到可用进阶技巧与验证习惯走到这一步baseline 和深度模型都能跑了但离「可用」还差一层。分享几个我实际调优时反复用到的技巧。第一是测试时增强。推理时对同一帧做轻微的时间平移或加小噪声多次预测取平均macro-F1 通常能涨 1 到 2 个点代价是推理变慢。对离线分析完全值得。第二是后处理平滑。睡眠阶段有强转移规律可以用维特比解码或简单的众数滤波修正孤立预测。比如模型偶尔把一帧 N2 判成 Wake前后都是 N2直接平滑掉。转移矩阵可以从训练集的标签序列统计出来。from scipy.stats import mode def smooth(preds, k5): # 对预测序列做滑动众数滤波k 为奇数窗口 pad k // 2 padded np.pad(preds, pad, modeedge) out [mode(padded[i:ik], keepdimsFalse).mode for i in range(len(preds))] return np.array(out)逻辑说明滑动众数对离散标签比均值滤波合适modeedge用边界值填充避免引入非法类别。参数说明k5对应前后各两帧和上下文窗口一致k 太大会把真实的阶段转换也抹平N1 这种短阶段首当其冲所以 k 别超过 7。第三是验证习惯。我现在的固定动作是每次改完模型先跑 leave-one-subject-out再看混淆矩阵最后单独看 N1 和 N3 的召回率。这三个数不达标其他指标再好看也不发。还有一条任何预处理步骤都要问一句「在线推理时能不能做到」做不到的就标成离线专用别混进部署方案。单通道脑电睡眠分期这个方向数据公开、python 工具链成熟、单卡就能训是入门生理信号深度学习很好的切入点。但它的天花板也明确单通道丢掉了空间信息N1 和 REM 的区分始终吃力想再往上走要么加通道要么加多模态眼动、肌电、心率。我自己的习惯是先把单通道 baseline 做扎实把评估协议和数据泄漏这两个地基打牢再考虑堆模型。地基不牢模型再深也是沙上建塔。希望帮到你。本文还有配套的精品资源点击获取