简介:基于PyTorch框架实现的单通道EEG睡眠分期项目,是一份面向计算机相关专业毕业设计、课程设计及深度学习实战学习者的高分完整代码包。项目围绕脑电信号自动划分浅睡、深睡、快速眼动(REM)等睡眠阶段这一核心任务,完整涵盖数据预处理、EmbedSleepNet模型构建、训练与基准测试等环节;评审得分99分,代码完整、可直接运行,适合初次接触睡眠分期的小白系统复现。压缩包内共21个文件,以Python源码为主,辅以PyCharm项目配置、Markdown说明文档、依赖清单及开源许可证;整体仅19KB,结构紧凑、模块清晰,便于快速定位与二次开发。目前已有98人学习,资源既呈现了科研级实验流程,也照顾到工程落地视角,可作为课程设计、期末大作业或论文实验的有力参考,帮助读者打通从原始EEG信号到睡眠分期结果的全链路。
1. 单通道EEG睡眠分期:为什么不能按多通道脑电的直觉来设计
睡眠分期不是“看哪个通道更活跃”,而是抓住脑电节律的时间结构。临床上多导睡眠图动辄几十路信号,但单通道EEG在睡眠分期任务里依然能打出不错的成绩——这一点常让第一次接触的人意外。原因是睡眠的宏观阶段主要由频率成分和瞬态事件定义,单通道若能覆盖纺锤波、慢波这些线索,模型就能学会判别。这个基于PyTorch的项目把整条链路打包成套可以跑通的Python源代码:preprocess.py负责EDF预处理,dataset.py切epoch,model.py定义EmbedSleepNet,train.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, preload=True) 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完整读入内存,再通过字符串匹配定位目标通道,避免不同命名版本带来的不兼容。注意preload=True在小数据集上没问题,如果整夜数据较多,建议先只读取标头并延迟加载。
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, method='fir', fir_design='firwin') raw.resample(100, npad='auto') # 按整夜连续信号截取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的整数。常见的映射表如下:
| 原始标签 | 类别 | 编号 |
|---|---|---|
| W | Wake | 0 |
| N1 | N1 | 1 |
| N2 | N2 | 2 |
| N3 | N3 | 3 |
| REM | REM | 4 |
把这一层放在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. EmbedSleepNet:1D卷积+多尺度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_classes=5, dropout=0.3): super().__init__() # 分支1:小卷积核,捕捉0.5秒内的短时形态 self.branch1 = nn.Sequential( nn.Conv1d(1, 32, kernel_size=50, stride=2, padding=24), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size=8, stride=2, padding=3), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) # 分支2:大卷积核,捕捉2秒左右的长时形态 self.branch2 = nn.Sequential( nn.Conv1d(1, 32, kernel_size=200, stride=4, padding=99), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size=12, stride=4, padding=5), nn.BatchNorm1d(64), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) # 分支3:更大的感受野,编码慢波节奏 self.branch3 = nn.Sequential( nn.Conv1d(1, 32, kernel_size=400, stride=8, padding=199), nn.BatchNorm1d(32), nn.ReLU(), nn.Conv1d(32, 64, kernel_size=16, stride=4, padding=7), 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], dim=1).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', classes=classes, y=train_labels) weights = torch.from_numpy(weights).float() criterion = nn.CrossEntropyLoss(weight=weights)这里用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看起来不起眼,却很关键。睡眠分期的训练集在时间上是连续切出的,如果不固定seed,DataLoader每次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_rate=1e-3, class_weights=None): super().__init__() self.model = model self.lr = learning_rate self.criterion = torch.nn.CrossEntropyLoss(weight=class_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_bar=True) return loss def validation_step(self, batch, batch_idx): x, y = batch logits = self.model(x) preds = logits.argmax(dim=1) acc = (preds == y).float().mean() self.log('val_acc', acc, prog_bar=True) def configure_optimizers(self): return torch.optim.Adam(self.model.parameters(), lr=self.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(dim=1).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, average='macro')) 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 F1 | 0.70-0.80 | N1类别会明显拉低 |
| Cohen's Kappa | 0.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(seed=42): 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(monitor='val_kappa', mode='max', save_top_k=1) early_stop = EarlyStopping(monitor='val_kappa', patience=8, mode='max') trainer = pl.Trainer(max_epochs=50, callbacks=[checkpoint, early_stop])如果监控的是val_acc,在类别不平衡时会选出一个偏向多数类的模型;监控val_kappa要更稳。patience=8表示连续8个epoch没有提升就停,对睡眠分期这种波动明显的任务够用了。
第三个技巧与EEG数据质量相关,也是容易忽略的坑:EDF里偶尔会有坏道片段,表现为整段高频毛刺或高幅值漂移。训练时这些样本会让模型误以为「异常波形」是一种睡眠阶段。我通常在预处理后加一个简单的坏道过滤,用绝对中位差MAD判断幅值异常:
# preprocess.py 中按epoch过滤异常幅度样本 def is_bad_epoch(epoch, threshold=12.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拉得太高,这样能精准标记出真正的坏道。threshold=12.0是经验值,数据不同可以调整。过滤规则要同时用在训练集和测试集上,避免训练时见过异常波形、测试时却把它当成正常输入。
以上三个技巧都直接影响模型最终效果。在实际运行时,建议先做小规模训练确认预处理和模型输出维度没问题,再跑完整数据。这样能从日志里区分「网络结构错了」还是「数据切错了」,而不是等到训练结束才发现问题。
本文还有配套的精品资源,点击获取