
简介基于PyTorch框架实现的单通道EEG睡眠分期项目是一份面向计算机相关专业毕业设计、课程设计及深度学习实战学习者的高分完整代码包。项目围绕脑电信号自动划分浅睡、深睡、快速眼动REM等睡眠阶段这一核心任务完整涵盖数据预处理、EmbedSleepNet模型构建、训练与基准测试等环节评审得分99分代码完整、可直接运行适合初次接触睡眠分期的小白系统复现。压缩包内共21个文件以Python源码为主辅以PyCharm项目配置、Markdown说明文档、依赖清单及开源许可证整体仅19KB结构紧凑、模块清晰便于快速定位与二次开发。目前已有98人学习资源既呈现了科研级实验流程也照顾到工程落地视角可作为课程设计、期末大作业或论文实验的有力参考帮助读者打通从原始EEG信号到睡眠分期结果的全链路。1. 单通道EEG睡眠分期为什么不能按多通道脑电的直觉来设计睡眠分期不是“看哪个通道更活跃”而是抓住脑电节律的时间结构。临床上多导睡眠图动辄几十路信号但单通道EEG在睡眠分期任务里依然能打出不错的成绩——这一点常让第一次接触的人意外。原因是睡眠的宏观阶段主要由频率成分和瞬态事件定义单通道若能覆盖纺锤波、慢波这些线索模型就能学会判别。这个基于PyTorch的项目把整条链路打包成套可以跑通的Python源代码preprocess.py负责EDF预处理dataset.py切epochmodel.py定义EmbedSleepNettrain.py配合lightning_wrapper.py管理训练benchmark.py验证指标。适合毕业设计、课程设计或深度学习实战练习使用。2. 从原始EDF到PyTorch张量EEG预处理与epoch切分原始Sleep-EDF格式的数据并不是直接能喂给神经网络的。整夜的脑电信号是连续长序列标记者给出的睡眠阶段按30秒一个epoch组织必须先把信号切块、映射标签再交给模型。preprocess.py要解决的正是数据形态转换问题核心包括通道定位、滤波降采样、标签映射和Dataset封装。2.1 信号通道与参考位置的选择Sleep-EDF最常用的是Fpz-Cz通道这是额头中央到顶区的双极导联睡眠纺锤波和慢波的相位关系在它上面比较清楚。多通道方案的优势是空间信息缺点是数据量和导联间干扰。单通道策略选Fpz-Cz后网络就只需要关注时间维的模式这也是模型能保持轻量的前提。EDF标头里不一定总叫Fpz-Cz有的版本叫EEG Fpz-Cz有的经过重命名。处理时用MNE读取后先打印raw.info[ch_names]确认避免取错索引。我一般会在预处理脚本里做一个映射表# preprocess.py 中常见的通道选择逻辑 import mne raw mne.io.read_raw_edf(edf_path, preloadTrue) target_channel None for ch in raw.info[ch_names]: if Fpz-Cz in ch.upper() or FPZ-CZ in ch.upper(): target_channel ch break if target_channel is None: raise ValueError(未找到Fpz-Cz通道请检查数据版本) eeg raw.copy().pick_channels([target_channel])这段代码先把EDF完整读入内存再通过字符串匹配定位目标通道避免不同命名版本带来的不兼容。注意preloadTrue在小数据集上没问题如果整夜数据较多建议先只读取标头并延迟加载。2.2 降采样、带通滤波与标准化单通道睡眠分期通常不需要保留100Hz以上的频率常见EEG频段在0.5Hz到30Hz之间但为了保留睡眠纺锤波11-16Hz和可能的高频成分许多项目会把带通设为0.5-40Hz然后降采样到100Hz。每个epoch取30秒这样每段样本长度就是3000个采样点。降采样的好处不仅是缩短训练时间更重要的是统一不同采集设备的采样率避免模型对采样率过拟合。# preprocess.py 中滤波与降采样的核心片段 raw eeg.copy().filter(0.5, 40, methodfir, fir_designfirwin) raw.resample(100, npadauto) # 按整夜连续信号截取epoch data raw.get_data()[0] # shape: (n_samples,) epoch_seconds 30 epoch_len 100 * epoch_seconds # 3000 n_epochs len(data) // epoch_len x data[:n_epochs * epoch_len].reshape(n_epochs, epoch_len)代码逻辑是三步先做带通滤波把工频和肌电噪声压掉再用重采样把采样率归一化到100Hz最后用整除切块避免末尾不完整片段进入训练集。参数上firwin是MNE常用的FIR滤波器设计0.5和40分别是高通和低通截止频率如果担心基线漂移可以把高通改成0.3Hz但要留意慢波幅度可能被衰减。标准化不能忽略。有的实现直接对全局信号做z-score另一种是对每个epoch单独减均值除标准差。两者差异在灯光阶段尤其明显全局标准化保留了夜间整体基线漂移而按epoch标准化会放大噪声幅值。我在项目里倾向用epoch内标准化并在dataset.py里完成保证每个Batch独立同时避免数据穿越。2.3 标签映射与Dataset封装Sleep-EDF标准标签有W, N1, N2, N3, REM有的版本还会出现M运动或?。训练前必须统一映射成0-4的整数。常见的映射表如下原始标签类别编号WWake0N1N11N2N22N3N33REMREM4把这一层放在preprocess.py里最省事直接根据标注文件或EDF标头逐行读取。注意不要在dataset.py里再修改映射否则后面做混淆矩阵时很难追溯。有了epoch矩阵和整数标签再用PyTorch的Dataset封装# dataset.py把numpy数组转成PyTorch可迭代数据集 import torch from torch.utils.data import Dataset class SleepEpochDataset(Dataset): def __init__(self, x, y): # x: (n_epochs, epoch_len), y: (n_epochs,) self.x torch.from_numpy(x).float().unsqueeze(1) # 增加通道维 self.y torch.from_numpy(y).long() def __len__(self): return len(self.y) def __getitem__(self, idx): return self.x[idx], self.y[idx]这里unsqueeze(1)把形状从(batch, 3000)变成(batch, 1, 3000)适配PyTorch的Conv1D输入要求。返回的标签是LongTensor直接喂给交叉熵损失函数。如果想进一步减少内存占用可以在训练前把x转成float16但要注意batch normalization在低精度下可能出现数值不稳我一般保留float32只在推理时用half。3. EmbedSleepNet1D卷积多尺度Embedding的结构设计前面的数据把EEG切成了3000维向量接下来要回答的关键问题是用什么网络结构把一维时间序列映射到睡眠阶段。model.py里的EmbedSleepNet并不像图像分类那样堆大网络而是用多尺度1D卷积把EEG中的短时形态和长时节奏同时编码成一个embedding向量。这样设计不是偶然背后有明确的信号学依据。3.1 多尺度卷积与embedding的提取原理全连接网络对输入的时间对齐很敏感EEG中某个特征只要平移几十个采样点结果就可能变化很大。1D卷积天然具备平移等变性能够学习到「无论纺锤波出现在window中间还是靠边都触发相同的卷积核响应」。DeepSleepNet的做法是用两个不同尺寸的卷积层分别学习高频细节和慢波节奏EmbedSleepNet延续了这种思路但把多分支特征拼接后显式地变成了一个低维向量再交给全连接分类头。具体来说我把输入看成(1, 3000)的单通道序列。第一个分支用kernel size为50的小卷积核覆盖0.5秒适合捕捉纺锤波和theta振荡第二个分支用kernel size为200的大卷积核覆盖2秒适合捕捉Delta和慢波第三个分支再加一层kernel size为400的下采样式卷积覆盖4秒让网络感知整个30秒epoch的整体状态。三个分支的输出经过全局平均池化后拼接就得到了模型的embedding。3.2 EmbedSleepNet的PyTorch实现下面这段代码是model.py中主干结构的简化版本我把BN和Dropout都保留因为睡眠信号上的小模型不加正则化很容易过拟合。# model.py: EmbedSleepNet 多尺度一维卷积实现 import torch import torch.nn as nn class EmbedSleepNet(nn.Module): def __init__(self, n_classes5, dropout0.3): super().__init__() # 分支1小卷积核捕捉0.5秒内的短时形态 self.branch1 nn.Sequential( nn.Conv1d(1, 32, kernel_size50, stride2, padding24), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size8, stride2, padding3), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) # 分支2大卷积核捕捉2秒左右的长时形态 self.branch2 nn.Sequential( nn.Conv1d(1, 32, kernel_size200, stride4, padding99), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size12, stride4, padding5), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) # 分支3更大的感受野编码慢波节奏 self.branch3 nn.Sequential( nn.Conv1d(1, 32, kernel_size400, stride8, padding199), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size16, stride4, padding7), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) embedding_dim 64 * 3 self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(embedding_dim, 128), nn.ReLU(), nn.Dropout(dropout), nn.Linear(128, n_classes), ) def forward(self, x): if x.dim() 2: x x.unsqueeze(1) f1 self.branch1(x) # (B, 64, 1) f2 self.branch2(x) f3 self.branch3(x) embedding torch.cat([f1, f2, f3], dim1).flatten(1) return self.classifier(embedding)用AdaptiveAvgPool1d(1)把每个分支输出的时间维压成1这样不管输入长度怎么变后面的全连接层输入尺寸都稳定。这也是embedding的意思把整段30秒EEG压缩成一个192维向量再映射到5个睡眠阶段的logits。三个分支并联的优势在于梯度能同时到达长短不同的感受野不会因为全卷积堆叠导致浅层的高频细节被后续下采样抹掉。3.3 类别不平衡与加权交叉熵睡眠分期数据天然不均衡N3和REM通常比N2少N1更少。如果只用普通nn.CrossEntropyLoss()网络会倾向于把N1预测成N2或Wake因为多数样本都来自这两类。我一般根据训练集统计频率计算每个类别的权重# train.py 中根据标签分布构造加权交叉熵 import numpy as np from sklearn.utils.class_weight import compute_class_weight classes np.array([0, 1, 2, 3, 4]) weights compute_class_weight(balanced, classesclasses, ytrain_labels) weights torch.from_numpy(weights).float() criterion nn.CrossEntropyLoss(weightweights)这里用compute_class_weight(balanced)让少数类获得更大的权重。注意要只统计训练集标签部署时再用同一个权重避免验证集信息泄漏。加权后重点不是让准确率飙升而是让N1的召回率不再塌陷。实际训练时如果发现REM和N1仍然混得很严重可以进一步把这两类的错误惩罚乘上一个额外系数例如class_weight[1] * 1.5。4. train.py、Lightning封装与benchmark.py一套可复现的训练体系数据与模型就位后剩下的就是训练。项目里train.py是主入口lightning_wrapper.py把训练循环包成PyTorch Lightning模块benchmark.py负责事后验证。这种拆分方式比较适合课程设计和毕业论文入口脚本负责参数wrapper负责逻辑benchmark负责结果三件事互不干扰。4.1 使用train.py启动一次完整训练打开train.py会看到argparse解析的参数核心超参数大概有这么几个数据目录、模型名、epoch数、batch size、学习率、随机种子。命令行可以通过如下方式启动python train.py \ --data_dir /path/to/sleepedf \ --model embedsleepnet \ --epochs 50 \ --batch_size 64 \ --lr 1e-3 \ --val_split 0.2 \ --seed 42这里--seed 42看起来不起眼却很关键。睡眠分期的训练集在时间上是连续切出的如果不固定seedDataLoader每次shuffle的乱序不同得到的验证集划分也不同结果就难以对比。--val_split 0.2表示按个体或整夜记录切分不要按epoch随机抽20%否则同一个人的相邻epoch出现在训练和验证集里会造成数据穿越评估指标虚高。训练过程会打印每个epoch的loss、验证准确率和Kappa。其中Kappa比准确率更能反映睡眠分期的一致性因为数据类别不均衡把全部预测成占比最高的N2类型也能拿到很高准确率但Kappa会很低。建议把Kappa作为保存checkpoint的监控指标。4.2 用lightning_wrapper.py统一训练逻辑PyTorch Lightning的作用是去掉样板代码。模型还是原来的EmbedSleepNet只不过被包在LightningModule里# lightning_wrapper.py: 封装训练/验证/优化器配置 import torch import pytorch_lightning as pl class LitSleepStage(pl.LightningModule): def __init__(self, model, learning_rate1e-3, class_weightsNone): super().__init__() self.model model self.lr learning_rate self.criterion torch.nn.CrossEntropyLoss(weightclass_weights) def training_step(self, batch, batch_idx): x, y batch logits self.model(x) loss self.criterion(logits, y) self.log(train_loss, loss, prog_barTrue) return loss def validation_step(self, batch, batch_idx): x, y batch logits self.model(x) preds logits.argmax(dim1) acc (preds y).float().mean() self.log(val_acc, acc, prog_barTrue) def configure_optimizers(self): return torch.optim.Adam(self.model.parameters(), lrself.lr)training_step里只做前向和loss计算backward由Lightning自动完成。self.log默认会在每个epoch结束算均值用于后面的早停与模型选择。优化器用Adam就足够因为睡眠分期网络不算深不需要SGD的精细调参如果想再稳一点可以保持Adam默认的betas(0.9, 0.999)学习率设为1e-3配合batch size 64。4.3 benchmark.py验证与指标解读训练完成后benchmark.py会加载最优checkpoint在独立测试集上逐样本预测再输出混淆矩阵和分类报告。下面提供一个等价的最小实现# benchmark.py 中评估指标的简化写法 import numpy as np import torch from sklearn.metrics import confusion_matrix, cohen_kappa_score, f1_score def evaluate(model, dataloader, device): model.eval() y_true, y_pred [], [] with torch.no_grad(): for x, y in dataloader: logits model(x.to(device)) y_pred.extend(logits.argmax(dim1).cpu().numpy()) y_true.extend(y.numpy()) y_true np.array(y_true) y_pred np.array(y_pred) print(confusion_matrix(y_true, y_pred)) print(macro F1:, f1_score(y_true, y_pred, averagemacro)) print(kappa:, cohen_kappa_score(y_true, y_pred))confusion_matrix能直接看出相邻睡眠阶段互相错分的情况比如N1被分到Wake或N2这是最常见的。cohen_kappa_score需要sklearn版本一致。建议同时打印每个类别的召回率而不是只看平均准确率。作为参考在Sleep-EDF单通道Fpz-Cz、五分类的标准设置下合理结果大致是指标通常范围说明准确率0.75-0.85单通道、五分类macro F10.70-0.80N1类别会明显拉低Cohens Kappa0.65-0.78一致性指标更敏感这段只是给一个预期区间不能当作标准。如果你拿到的数据切分方式不同数值变化会很大。5. 调参外的三个细节固定随机种子、早停和坏道样本过滤这里不讲理论分享三个能直接提升训练稳定性的技巧。第一个是彻底固定随机种子包括PyTorch、NumPy和Python内置random三个入口只靠argparse里的--seed还不够因为DataLoader的worker也可能引入随机性。# train.py 中完整的seed固定写法 import random import numpy as np import torch def set_seed(seed42): random.seed(seed) np.random.seed(seed) torch.manual_seed(seed) torch.cuda.manual_seed_all(seed) torch.backends.cudnn.deterministic True torch.backends.cudnn.benchmark Falsecudnn.deterministic True会让同一个模型和输入在GPU上产出完全相同的结果代价是部分卷积算法不能使用训练会慢一点。对毕业设计和课程设计来说可复现比那一点点速度更重要。第二个技巧是早停。Lightning里可以直接用EarlyStopping和ModelCheckpoint配合import pytorch_lightning as pl from pytorch_lightning.callbacks import EarlyStopping, ModelCheckpoint checkpoint ModelCheckpoint(monitorval_kappa, modemax, save_top_k1) early_stop EarlyStopping(monitorval_kappa, patience8, modemax) trainer pl.Trainer(max_epochs50, callbacks[checkpoint, early_stop])如果监控的是val_acc在类别不平衡时会选出一个偏向多数类的模型监控val_kappa要更稳。patience8表示连续8个epoch没有提升就停对睡眠分期这种波动明显的任务够用了。第三个技巧与EEG数据质量相关也是容易忽略的坑EDF里偶尔会有坏道片段表现为整段高频毛刺或高幅值漂移。训练时这些样本会让模型误以为「异常波形」是一种睡眠阶段。我通常在预处理后加一个简单的坏道过滤用绝对中位差MAD判断幅值异常# preprocess.py 中按epoch过滤异常幅度样本 def is_bad_epoch(epoch, threshold12.0): median np.median(epoch) mad np.median(np.abs(epoch - median)) if mad 1e-6: return True # 平直线段 z_score 0.6745 * (epoch - median) / mad return np.abs(z_score).max() thresholdMAD比标准差更抗脉冲干扰一个短时尖峰不会把整段数据的MAD拉得太高这样能精准标记出真正的坏道。threshold12.0是经验值数据不同可以调整。过滤规则要同时用在训练集和测试集上避免训练时见过异常波形、测试时却把它当成正常输入。以上三个技巧都直接影响模型最终效果。在实际运行时建议先做小规模训练确认预处理和模型输出维度没问题再跑完整数据。这样能从日志里区分「网络结构错了」还是「数据切错了」而不是等到训练结束才发现问题。本文还有配套的精品资源点击获取