简介:这份资源是面向计算机相关专业学生与项目实战学习者的单通道脑电信号自动睡眠分期研究完整方案,源自经导师指导并通过评审的高分毕业设计,适合作为毕设参考、课程设计或期末大作业。压缩包共22个文件,约10.85MB,以12个Python脚本为核心,涵盖数据预处理、模型训练、预测与Web服务等模块;另含2个pt模型权重、3个txt说明与数据文件、1个docx手册、1个sh运行脚本及html、png等辅助内容,结构清晰便于按模块查阅。资源围绕单通道脑电信号展开,包含数据下载与准备、特征处理、网络定义、训练测试及可视化展示等环节,并配有项目说明文档,可帮助读者理解睡眠分期的整体流程与实现思路。目前已有122人学习下载,适合希望快速上手脑电信号分析与深度学习实践的学习者参考借鉴。
1. 单通道脑电睡眠分期:从一段信号到一张睡眠结构图
手环和智能戒指上的睡眠评分,背后大多跑的是单通道脑电(single-channel EEG)自动睡眠分期。它要做的事很朴素:把一整夜只有一路电极采到的脑电波形,按 30 秒一帧切成小段,逐段判成 W、N1、N2、N3、REM 五个阶段,最后拼成一张睡眠结构图(hypnogram)。为什么强调“单通道”?因为多导睡眠图(PSG)动辄十几路信号,采集贵、贴片多、居家场景根本铺不开;而单通道只要一个干电极贴在额头或耳后,配合 Python 就能把整套流程跑通,这也是它成为入门睡眠算法首选任务的原因。这篇笔记面向两类人:想用 Python 从零复现一套睡眠分期流水线的工程新手,以及手里已有数据、想搞清楚特征、模型、评估该怎么选的老手。我会按“信号怎么读进来 → 特征怎么提 → 模型怎么搭 → 坑在哪 → 怎么验证”的顺序,把能抄作业的部分写实。
2. 数据与预处理:单通道 EEG 怎么变成模型能吃的张量
2.1 先搞清楚睡眠分期的数据长什么样
睡眠分期的公开数据集,常见做法是采用带专家标注的整夜 PSG 记录,每个 30 秒 epoch 对应一个标签。标签体系一般遵循 AASM 标准,五类:W(清醒)、N1(浅睡过渡)、N2(稳定浅睡)、N3(深睡/慢波睡眠)、REM(快速眼动)。单通道任务里,我们只保留其中一路 EEG,比如 Fpz-Cz 或 Pz-Oz,其余通道全部丢掉。
这里有个新手最容易忽略的点:类别极度不平衡。一整夜里 N2 往往占 45% 以上,N3 和 REM 各占 20% 左右,而 N1 常常不到 5%。如果你直接拿准确率(accuracy)当指标,模型只要全猜 N2 就能刷到 45% 以上,看起来很美,实际毫无用处。所以从读数据的第一行代码起,就要把标签分布打印出来,心里有数。
数据文件格式常见两种:EDF(欧洲数据格式,整夜原始信号)和已经切好的 npz/pickle(每个 epoch 一段数组)。下面这段代码演示怎么用 Python 把一段单通道 EEG 读进来、按 30 秒切帧、并统计标签分布。
import numpy as np import mne # 读取 EDF 常用库 # 1. 读取整夜 EDF,只保留一路 EEG raw = mne.io.read_raw_edf("SC4001E0-PSG.edf", preload=True) raw.pick_channels(["EEG Fpz-Cz"]) # 单通道:只留 Fpz-Cz sfreq = raw.info["sfreq"] # 常见 100 Hz signal = raw.get_data()[0] # 形状 (n_samples,) # 2. 按 30 秒一帧切分 epoch_len = int(30 * sfreq) # 30s * 100Hz = 3000 点 n_epochs = len(signal) // epoch_len epochs = signal[:n_epochs * epoch_len].reshape(n_epochs, epoch_len) # 3. 标签(示例:从注释文件读入,长度需与 epochs 对齐) labels = np.load("labels.npy") # 0=W,1=N1,2=N2,3=N3,4=REM print("epochs:", epochs.shape, "labels:", labels.shape) # 4. 打印类别分布,先看清不平衡程度 for i, name in enumerate(["W", "N1", "N2", "N3", "REM"]): print(name, (labels == i).sum(), f"{(labels == i).mean():.1%}")逻辑说明:pick_channels是单通道任务的关键一步,多留一路都会让“单通道”名不副实;reshape把一维长信号变成(帧数, 3000)的二维矩阵,这是后续所有特征和模型的输入形状。参数说明:sfreq决定每帧点数,100 Hz 下是 3000,若你的数据是 256 Hz,则每帧 7680 点,切帧前务必确认采样率,否则帧边界会错位,标签和信号对不上,这是血泪经验里最常见的一类翻车。
2.2 滤波、去伪迹与归一化:三个必调参数
原始 EEG 里混着工频干扰、眼动、肌电和基线漂移。单通道没有参考通道做伪迹回归,所以预处理要更克制。常见做法是:带通滤波 0.3–35 Hz(保留慢波和纺锤波,砍掉高频肌电和直流漂移),再对每个 epoch 做 z-score 归一化。注意不要过度滤波,0.3 Hz 以下砍太狠会把 N3 的慢波削平,模型就分不出深睡了。
from scipy.signal import butter, filtfilt def bandpass(x, low=0.3, high=35.0, fs=100, order=4): nyq = fs / 2 b, a = butter(order, [low / nyq, high / nyq], btype="band") return filtfilt(b, a, x, axis=-1) # 零相位滤波,避免帧内时移 epochs_f = bandpass(epochs, fs=sfreq) # 逐帧 z-score,消除个体幅值差异 mean = epochs_f.mean(axis=1, keepdims=True) std = epochs_f.std(axis=1, keepdims=True) + 1e-8 epochs_n = (epochs_f - mean) / std逻辑说明:filtfilt做前后向滤波,相位不失真,对睡眠分期这种依赖波形形态的任务很重要;逐帧归一化而不是整夜归一化,是因为整夜幅值会随电极阻抗漂移,整夜归一化会把这种漂移当成信号。参数说明:low=0.3是慢波下限,high=35覆盖纺锤波(约 12–16 Hz)和大部分有用频段;order=4是经验值,阶数太高会振铃。三个必调参数就是低截止、高截止、滤波阶数,改任何一个都要重新看 N3 的波形有没有被削。
3. 特征工程与模型:从手工特征到端到端 CNN
3.1 手工特征:频带功率与时域统计怎么算
在深度学习普及前,睡眠分期的标准做法是提手工特征再喂给分类器。即便现在用 CNN,手工特征也常作为辅助输入或基线对照。核心特征分两类:频域的相对功率(delta 0.5–4 Hz、theta 4–8、alpha 8–12、sigma 12–16、beta 16–30),以及时域统计(均值、方差、偏度、峰度、Hjorth 参数)。相对功率比绝对功率更稳,因为它对整体幅值不敏感。
from scipy.signal import welch def band_power(epoch, fs=100, bands=((0.5,4),(4,8),(8,12),(12,16),(16,30))): f, pxx = welch(epoch, fs=fs, nperseg=fs*4) # 4 秒窗做谱估计 feats = [] total = pxx[(f>=0.5)&(f<=30)].sum() + 1e-8 for lo, hi in bands: idx = (f >= lo) & (f < hi) feats.append(pxx[idx].sum() / total) # 相对功率 return np.array(feats) feat_matrix = np.array([band_power(e, fs=sfreq) for e in epochs_n]) print("特征维度:", feat_matrix.shape) # (n_epochs, 5)逻辑说明:welch用分段平均降低谱估计方差,nperseg=fs*4即 4 秒一段,兼顾频率分辨率和稳定性;相对功率用总功率归一,跨被试更可比。参数说明:频带边界按 AASM 惯例设定,delta 对应 N3 慢波,sigma 对应 N2 纺锤波,这两个频带是区分 N2/N3 的主力。如果只做基线,把feat_matrix丢给随机森林或梯度提升树,五分类 kappa 通常能到 0.6 左右,够用来验证你的预处理没写错。
3.2 端到端 CNN:单通道输入的模型结构
想冲更高精度,端到端 CNN 是主流。输入就是(3000,)的一维信号,或者转成时频图(freq, time)的二维谱。一维 CNN 更省事,结构也简单:几层卷积 + 池化 + 全局池化 + 全连接。下面给一个能直接跑的最小结构。
import torch import torch.nn as nn class SleepCNN(nn.Module): def __init__(self, n_classes=5): super().__init__() self.net = nn.Sequential( nn.Conv1d(1, 16, kernel_size=50, stride=6, padding=25), # 大核抓低频形态 nn.BatchNorm1d(16), nn.ReLU(), nn.MaxPool1d(8), nn.Conv1d(16, 32, kernel_size=8, padding=4), nn.BatchNorm1d(32), nn.ReLU(), nn.MaxPool1d(8), nn.Conv1d(32, 64, kernel_size=8, padding=4), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), # 全局池化 ) self.fc = nn.Linear(64, n_classes) def forward(self, x): # x: (B, 1, 3000) return self.fc(self.net(x).squeeze(-1)) model = SleepCNN() x = torch.randn(8, 1, 3000) print(model(x).shape) # (8, 5)逻辑说明:第一层用kernel_size=50, stride=6的大核大步长,等效于对原始波形做粗粒度的低频特征提取,这是处理长序列 EEG 的常见技巧;AdaptiveAvgPool1d(1)把时间维压成 1,让模型对输入长度不敏感。参数说明:stride=6把 3000 点快速降到 500,减少后续计算量;BatchNorm在单通道小 batch 下要小心,batch 太小(如 8)时统计量不稳,可换GroupNorm。训练时用交叉熵,但记得给 N1 类加权,否则模型会直接放弃 N1。
3.3 序列建模:为什么单帧分类不够
单帧分类有个硬伤:它把每个 30 秒 epoch 当独立样本,但睡眠阶段是有强时序依赖的——N2 后面大概率还是 N2,N3 很少直接跳到 REM。忽略这层依赖,模型会在阶段切换处疯狂抖动,hypnogram 看起来像锯齿。常见补救是加一层序列模型,比如在 CNN 特征上再套 BiLSTM 或 Transformer,输入是连续若干帧的特征序列,输出是每帧的标签。
class CNNLSTM(nn.Module): def __init__(self, n_classes=5, hidden=64): super().__init__() self.cnn = SleepCNN().net # 复用上面的卷积主干 self.lstm = nn.LSTM(64, hidden, batch_first=True, bidirectional=True) self.fc = nn.Linear(hidden * 2, n_classes) def forward(self, x): # x: (B, T, 1, 3000) B, T, C, L = x.shape x = x.view(B * T, C, L) feat = self.cnn(x).squeeze(-1).view(B, T, -1) # (B, T, 64) out, _ = self.lstm(feat) return self.fc(out) # (B, T, 5)逻辑说明:把 batch 和时间维展平后过 CNN,再 reshape 回序列喂 LSTM,这样卷积参数在时间上共享;双向 LSTM 同时看前后文,对阶段切换边界帮助明显。参数说明:hidden=64是平衡精度和显存的常用值,序列长度 T 一般取 10–20 帧(即 5–10 分钟上下文),太长会拖慢训练且收益递减。注意训练时序列要按整夜切,不能跨被试拼接,否则会引入虚假的时序依赖。
4. 避坑与排查:单通道睡眠分期最容易翻车的五件事
4.1 标签和信号错位半帧
现象:训练 loss 正常下降,但验证 kappa 死活上不去,混淆矩阵里 N1 和 W 混成一团。原因:EDF 注释的起始时间和信号起始时间有偏移,或者切帧时用了//丢掉尾部导致标签索引整体错位。解决:切帧后随机抽 5 个 epoch,把波形和对应标签画出来人工核对,确认 N3 帧里确实有高幅慢波。这一步花十分钟,能省掉后面几小时的玄学调参。
4.2 采样率假设错误
现象:模型在 A 数据集上表现正常,换到 B 数据集直接崩。原因:A 是 100 Hz,B 是 256 Hz,但代码里epoch_len写死成 3000,导致 B 的每帧只有约 11.7 秒,波形被压缩。解决:切帧前统一重采样到同一频率,常见做法是全部降到 100 Hz,用scipy.signal.resample或mne的resample,并在日志里打印实际采样率。
4.3 归一化用了整夜统计量
现象:离线评估很好,部署到新受试者身上精度骤降。原因:整夜 z-score 用到了未来帧的均值和方差,属于数据泄漏,且不同受试者幅值差异被抹平后模型学到的是伪特征。解决:改成逐帧归一化,或只用训练集统计量做全局归一化,验证和测试集复用训练集的均值方差。
4.4 忽略 N1 导致 kappa 虚高
现象:准确率 85%,但 kappa 只有 0.5,N1 的召回率接近 0。原因:N1 样本少且与 W、N2 边界模糊,不加权时模型直接放弃这一类。解决:损失函数里给 N1 更高权重(如按类别频率倒数加权),或做重采样;评估时重点看每类召回率和 Cohen's kappa,别只看准确率。
4.5 序列模型跨被试拼接
现象:加了 LSTM 后训练集精度飙升,测试集反而下降。原因:构造序列时把不同受试者的 epoch 拼在一起,模型学到了“被试身份”这种捷径特征。解决:序列必须按受试者、按整夜切分,训练/验证/测试也按受试者划分,绝不能把同一个人的数据同时放进训练和测试。
5. 评估与进阶:怎么判断你的分期模型真的能用
5.1 指标怎么选、混淆矩阵怎么看
睡眠分期的标准评估指标是 Cohen's kappa 和每类 F1,准确率只能当参考。原因前面说过,类别不平衡会让准确率失真。看混淆矩阵时重点盯三处:N1 被误判成 W 还是 N2(决定你后续往哪个方向补特征)、N3 和 N2 的边界(慢波幅度阈值问题)、REM 和 N1 的混淆(眼动信息缺失时单通道确实难分)。一个能落地的模型,五分类 kappa 通常要到 0.75 以上,N1 的 F1 至少 0.4,否则临床意义有限。
from sklearn.metrics import cohen_kappa_score, classification_report kappa = cohen_kappa_score(y_true, y_pred) print("kappa:", round(kappa, 3)) print(classification_report(y_true, y_pred, target_names=["W", "N1", "N2", "N3", "REM"], digits=3))逻辑说明:cohen_kappa_score扣除了随机一致性的影响,比准确率更能反映真实水平;classification_report一次给出每类精确率、召回率和 F1,方便定位短板。参数说明:target_names顺序必须和标签编码一致,否则报告会张冠李戴,这种低级错误在赶进度时特别容易犯。
5.2 一个具体技巧:用平滑后处理压住抖动
模型逐帧输出后,hypnogram 常有孤立跳变,比如 N2 中间突然插一帧 W。一个几乎零成本的后处理是用中值滤波或转移规则平滑:对预测序列做窗口为 5 的中值滤波,或按“睡眠阶段不会频繁跳变”的常识,把孤立单帧改成前后帧的多数类。这个技巧不需要重训模型,通常能把 kappa 再抬 0.02–0.04。
from scipy.signal import medfilt y_smooth = medfilt(y_pred, kernel_size=5) # 5 帧中值滤波 print("平滑后 kappa:", cohen_kappa_score(y_true, y_smooth))逻辑说明:medfilt对孤立跳变不敏感,能保留真实的阶段切换(因为真实切换通常持续多帧),只抹掉单帧噪声。参数说明:kernel_size=5对应 2.5 分钟窗口,太大(如 11)会把真实的短暂 REM 段也抹掉,太小(如 3)压不住抖动,5 是经验甜点。注意平滑只在推理后做,训练时不要平滑标签,否则会破坏监督信号。
5.3 值不值得做:单通道的边界在哪
单通道 EEG 睡眠分期的天花板是明确的:N1 和 REM 的区分天然吃亏,因为没有眼电和肌电辅助。如果你的目标是消费级睡眠监测(看趋势、算睡眠时长和深睡比例),单通道完全够用,成本低、可居家。如果要做临床辅助诊断,单通道只能当筛查,最终还得靠多导。我自己的习惯是:先把单通道流水线跑通、kappa 做到 0.75 以上,再考虑加通道或加模态,而不是一上来就堆多路信号。这套流程里最值钱的不是模型结构,而是预处理和评估的严谨度——把标签对齐、采样率统一、按受试者划分这三件事做扎实,比换十个网络都管用。希望帮到你。
本文还有配套的精品资源,点击获取