简介:这套源码实现了基于单通道脑电信号的自动睡眠分期,面向计算机、数学、电子信息等专业学生及时序分类初学者,可作为课程设计、毕业设计或入门神经网络的参考项目。项目基于Sleep-EDF公开数据集,覆盖153条整晚睡眠记录,使用Fpz-Cz通道信号,采样率100Hz;代码简洁并配有注释,支持在GRU、LSTM、双向RNN与Attention之间切换,帮助理解不同时序特征提取方式。训练阶段引入focal loss缓解类别不平衡,测试阶段可输出准确率、宏F1、混淆矩阵等评估指标,便于对比实验。压缩包内共22个文件,以Python脚本、PT模型、运行脚本、说明文档和数据预处理配置为主,整体约10.66MB,目录按数据下载、预处理、训练、测试、网页演示等模块划分,配合已有模型可直接复现结果。目前已有475人学习浏览,适合希望结合脑电数据快速开展深度学习实验、并进一步扩展研究方向的读者。
1. 为什么拿单通道脑电做睡眠分期,比你想的更适合练手
做睡眠分期,医院里标准做法是整夜多导睡眠监测(PSG),眼电、肌电、心电、口鼻气流加脑电,十几个通道一起采。但落到工程复现和算法实验上,这套配置太重了,光数据清洗就能劝退一批人。相比之下,单通道Fpz-Cz脑电加上对应的Hypnogram标注,是把问题压缩到了刚好能动手的规模:Sleep-EDF Expanded数据集的SC子集里有153条整晚记录,采样率100Hz,每条记录被切成30秒一个epoch,标注成Wake、N1、N2、N3、REM五类。这个任务表面上是在处理EEG信号,实际上是一个典型的时序数据分类问题——每个30秒片段先被编码成特征向量,再由GRU、LSTM这类循环结构把“前一小时睡得深不深”这个上下文带进来。正因为它足够简单又保留了序列建模的核心难点,很适合做课程设计、毕设,或者作为你第一次上手序列分类的练手项目。
2. 数据准备:从Sleep-EDF下载到numpy预处理
2.1 下载脚本与SC数据约定
这份资源里的数据下载脚本是download_sleepedf.py,和官网的PhysioNet目录结构是对应的。Sleep-EDF Expanded里分两个子集:SC(Sleep Cassette)和ST(Sleep Telemetry),本项目用的是SC子集。这里有个容易搞混的点:SC子集的注释文件是独立的Hypnogram文件,扩展名是.edf,但它里面存的不是脑电,而是逐段睡眠分期标签,每个标签对应30秒的脑电。
下载命令一般长这样:
python download_sleepedf.py --data_dir ./data --use_sc参数说明:
--data_dir:指定原始EDF文件存放目录,脚本会按subject ID建子目录;--use_sc:只拉取SC子集,不加这个参数会把ST也拉下来,浪费流量;- 下载的是
SC4xxxx系列记录,每条记录包含一个PSG文件和一个Hypnogram文件,两者靠文件名里的编号对应。
提示:如果你拿到的脚本里写的是
downloading_sleepedf.py,那是早期版本的文件名,直接用download_sleepedf.py即可,功能没有区别。
2.2 prepare_data.py:切片、通道选择和标签映射
下载完的原始EDF文件不能直接喂给模型,原因有两个:一是里面含多个通道,我们只需要Fpz-Cz这一路;二是存储格式是压缩的,每次训练都要去解EDF会很慢。所以prepare_data.py的职责就是把153条整晚记录读进来,抽通道、按30秒切段、做标签映射,最后存成numpy数组。
常见做法是像下面这样处理,逻辑和这份代码里一致:
import numpy as np import pyedflib def prepare_record(psg_path, hyp_path, channel='Fpz-Cz', fs=100, epoch_sec=30): f = pyedflib.EdfReader(psg_path) ch_names = f.getSignalLabels() ch_idx = ch_names.index(channel) signal = f.readSignal(ch_idx) f.close() # 按30秒一段切分,每段3000个采样点 epoch_len = fs * epoch_sec n_epochs = len(signal) // epoch_len signal = signal[:n_epochs * epoch_len].reshape(n_epochs, epoch_len) # 读取Hypnogram,去掉开头的空白记录 hyp = pyedflib.EdfReader(hyp_path).readSignal(0) labels = hyp.astype(int) return signal, labels逻辑说明:
readSignal(ch_idx)返回的是整个晚上的连续电压数据,单位是微伏;- 按
epoch_len=3000个点切段后,每一行就是一个30秒epoch,正好和Hypnogram里的一个标注对应; - 标签在原始EDF里是数值编码,通常Wake对应0、N1对应1、N2对应2、N3对应3、REM对应4,还要做一次数值映射把可能的边缘值清理掉。
预处理脚本跑完后,工作目录下会多出几个numpy文件,比如X_all.npy和Y_all.npy,分别保存所有样本的特征矩阵和标签向量。这一步是后续所有实验的基础,做好了后面训练时数据加载就是纯内存操作,不再碰EDF格式。
2.3 预处理结果怎么验证
不要急着直接开训,先花一分钟验证预处理结果。最简单的检查是打印shape和标签分布:
import numpy as np X = np.load('X_all.npy') Y = np.load('Y_all.npy') print(X.shape, Y.shape) for cls in range(5): print(cls, (Y == cls).mean())预期结果是:X_all.npy的shape是[样本数, 3000],也就是每行一个epoch的原始波形;标签分布里N2占比最高、N1最低。如果标签分布严重异常,比如REM接近0,那问题几乎肯定出在Hypnogram读取时没有跳过记录头,回去查pyedflib读到的信号起点位置即可。
3. dataset.py 与网络结构:把30秒片段串成序列
3.1 dataset.py 里的seq_len和shuffle_seed
预处理做完后,数据是一整段一整段的epoch序列,但训练时不能一次性把所有样本灌进模型,因为整晚睡眠有上千个epoch,GPU显存放不下。dataset.py直接继承torch.utils.data.Dataset,把“按固定窗口长度切片”这件事放在了数据加载层。
import torch from torch.utils.data import Dataset class SleepEDFDataset(Dataset): def __init__(self, X, Y, seq_len=64, shuffle_seed=None): self.X = torch.FloatTensor(X) self.Y = torch.LongTensor(Y) self.seq_len = seq_len if shuffle_seed is not None: torch.manual_seed(shuffle_seed) def __len__(self): return len(self.X) // self.seq_len def __getitem__(self, idx): start = idx * self.seq_len end = start + self.seq_len return self.X[start:end], self.Y[start:end]参数说明:
seq_len:一次喂给循环神经网络的连续epoch数量,默认64,也就是32分钟的睡眠上下文;shuffle_seed:固定后每次运行切出来的样本顺序一致,配合训练时的随机种子,可以精确复现实验结果;__getitem__返回的是[seq_len, 3000]的原始波形和对应标签,形状是[64]。
值得注意,这里切片是纯顺序切,idx乘上seq_len得到起始位置。这种设计保证了同一个窗口内的epoch在时间上连续,不会把凌晨2点的片段和凌晨4点的片段拼在一起。如果数据加载时做随机抽样而不是顺序切片,序列模型学到的时间上下文就废了。
3.2 编码器:TinySleepNet式的CNN特征提取
睡眠分期的经典做法是两步走:先让CNN把每个30秒epoch压缩成一个特征向量,再让RNN在这些特征向量上建模时序依赖。network.py里的整体结构参照TinySleepNet,但RNN部分做了大幅修改。
编码器部分是几层一维卷积加最大池化,大致结构如下:
class Encoder(torch.nn.Module): def __init__(self, in_channels=1, feature_dim=128): super().__init__() self.conv1 = torch.nn.Conv1d(in_channels, 32, kernel_size=50, stride=6, padding=24) self.bn1 = torch.nn.BatchNorm1d(32) self.pool1 = torch.nn.MaxPool1d(kernel_size=8, stride=8) self.conv2 = torch.nn.Conv1d(32, 64, kernel_size=8, stride=1, padding=4) self.bn2 = torch.nn.BatchNorm1d(64) self.pool2 = torch.nn.MaxPool1d(kernel_size=4, stride=4) def forward(self, x): # x: [batch*seq_len, 1, 3000] x = self.pool1(torch.relu(self.bn1(self.conv1(x)))) x = self.pool2(torch.relu(self.bn2(self.conv2(x)))) return x.mean(dim=-1) # 全局池化成 [batch*seq_len, 64]逻辑说明:
- 第一层卷积的kernel设成50,对100Hz信号来说就是0.5秒的窗口,能捕捉单个睡眠纺锤波这类短时事件;
- 后面的池化逐步压缩时间分辨率,最终用全局平均池化把每个epoch压成一个固定长度的特征向量;
- 这里把
batch和seq_len合并到同一维度处理,目的是让CNN部分独立地对每个epoch编码,不混合时间上下文。
3.3 时序层:GRU、LSTM、双向和Attention的取舍
编码器负责回答“这30秒波形长什么样”,时序层负责回答“结合前后几个epoch,这30秒应该归为哪一类”。network.py里定义了seq_len参数,同时支持通过--network切换GRU、LSTM、双向RNN和Attention结构。
def build_rnn(rnn_type, input_size, hidden_size, num_layers): if rnn_type == "GRU": return torch.nn.GRU(input_size, hidden_size, num_layers, batch_first=True) elif rnn_type == "LSTM": return torch.nn.LSTM(input_size, hidden_size, num_layers, batch_first=True) elif rnn_type == "BiGRU": return torch.nn.GRU(input_size, hidden_size, num_layers, batch_first=True, bidirectional=True) elif rnn_type == "Attention": return torch.nn.MultiheadAttention(input_size, num_heads=4, batch_first=True)实际使用中,我的建议是优先试双向GRU。原因是睡眠结构本身不是严格因果的——一个epoch被标成N1还是REM,往往要等后面几个epoch出来才能判断。单向GRU只能看到过去,双向GRU能看到过去也能看到未来,在Sleep-EDF上通常能带来1到2个百分点的mf1提升。
Attention结构在样本量不足时容易过拟合,因为多头注意力的参数远多于GRU,而153条整晚记录分割出的训练样本虽然多,但个体差异本身就大,注意力矩阵很容易学到受试者id而不是睡眠特征。如果一定要用,建议把num_heads调小,并且配合更强的dropout。
4. train.py:focal loss训练与wandb实验记录
4.1 focal loss解决类别不平衡的原理与实现
睡眠分期的标签分布极不均衡,N2通常占到全晚的40%以上,N1只有几个百分点。用交叉熵训练时,模型只要把所有样本都预测成N2,准确率就能到50%,但这种模型没有实用价值。train.py里用了focal loss来压制这个倾向。
import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.alpha = alpha # 类别权重,形状[5] self.gamma = gamma # 聚焦参数 def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, reduction='none') pt = torch.exp(-ce) if self.alpha is not None: alpha_t = self.alpha[targets] loss = alpha_t * (1 - pt) ** self.gamma * ce else: loss = (1 - pt) ** self.gamma * ce return loss.mean()参数说明:
alpha:长度为类别数的向量,给N1、N3这类稀有类别更高的权重,实现上直接按targets索引出来;gamma:聚焦参数,默认2.0,它让已经分类正确的样本(pt接近1)的损失大幅缩小,模型被迫把注意力放到难分样本上;- 相比直接对交叉熵加权重,focal loss的好处是动态调节:易分样本即使类别少,只要模型已经能分对,也不会主导梯度。
4.2 训练超参数和运行方式
python train.py --n_epochs 150 --batch_size 16 --seq_len 64 --network "GRU"这是README里给的训练命令,拆开看每个参数的含义:
--n_epochs 150:完整遍历训练集150次。Sleep-EDF样本量大,配合早停策略,实际跑到100轮左右就基本收敛;--batch_size 16:每批16个序列,注意这里的样本单位不再是单个epoch,而是长度为seq_len=64的序列,所以一次前向实际处理了16*64=1024个epoch,占了显存大头的是这个乘积;--seq_len 64:时间窗口长度,对应32分钟睡眠上下文;--network "GRU":切换时序层类型,可选GRU、LSTM、BiGRU、Attention。
如果你的显卡显存有限,优先减小batch_size而不是seq_len。seq_len小于32时,模型看不到完整的睡眠周期(一个完整周期约90分钟),REM和N1的区分度会明显下降。
4.3 用wandb记录每次实验
train.py里集成了wandb,训练时自动记录loss、验证集mf1和当前学习率。第一次跑记得先登录:
pip install wandb wandb login然后在训练脚本里初始化:
import wandb wandb.init(project="sleep-staging", config={ "n_epochs": 150, "batch_size": 16, "seq_len": 64, "network": "GRU", "lr": 1e-3, }) for epoch in range(n_epochs): train_loss = train_one_epoch() val_mf1 = evaluate() wandb.log({"train_loss": train_loss, "val_mf1": val_mf1})每轮把train_loss和val_mf1写进wandb,就可以在网页端对比不同network参数下mf1的曲线走向。如果你不想连外部服务,设置环境变量WANDB_MODE=offline,日志会先落到本地,之后再同步。每次实验记录下seq_len、batch_size和随机种子,后面写论文或者做毕设复盘时能省大量时间。
训练完成后,权重默认保存为models/model_GRU.pt之类的文件。总之,保存的checkpoint里除了模型状态字典,建议连同seq_len、network超参一起存成json,避免隔几天自己都忘了这个模型是怎么训出来的。
5. test.py指标解读与几个复现避坑点
5.1 四类评估指标分别看什么
测试脚本运行方式:
python test.py --model_path models/model_GRU.pt --network GRU --seq_len 64test.py输出的指标有五个:accuracy、mf1、recall_confusion_matrics、precision_confusion_matrics、f1_confusion_matrics。后面三个名字容易混淆,它们的区别在于混淆矩阵的归一化方向:
| 指标名称 | 计算方式 | 实际含义 |
|---|---|---|
| accuracy | 正确预测数 / 总数 | 整体判断正确的比例,受N2主导 |
| mf1 | 五类F1的算术平均 | 各类别F1的宏平均,类别均衡视角 |
| recall_confusion_matrics | 按行归一化召回率 | 真实为某类的样本中被正确找出的比例 |
| precision_confusion_matrics | 按列归一化精确率 | 预测为某类的样本中确实属于该类的比例 |
| f1_confusion_matrics | 每个格子的F1 | 矩阵中每个位置综合precision和recall后的分数 |
我一般先看mf1,因为它比accuracy诚实。Sleep-EDF这种数据上,accuracy做到85%不代表模型好,可能只是N2和N3分得准;mf1要到75%以上才说明N1和REM也有实际区分度。
5.2 指标组合怎么判断模型真实水平
混淆矩阵里最值得盯的是N1这一行和REM这一行。N1是浅睡与清醒的过渡,脑电特征和REM、Wake都有重叠,任何分期模型都会在这两类上掉点。如果recall矩阵显示N1大量被预测成REM,说明模型没有学到N1的特异性特征(比如顶尖波和theta节律混合),而不是单纯调参能解决的问题。
另一个检查点是N3慢波。N3的delta波幅值大、频率低,理论上最容易分,如果N3的precision低,大概率是编码器第一层卷积的窗口太短,把慢波拆碎了。回到network.py,把第一层卷积kernel从50上调到100左右,重新训练看看。
5.3 复现时容易踩的三个坑
第一个坑是跨记录切片。如果预处理时把所有记录拼成一个长数组,然后统一按seq_len切,会出现某条序列跨过两条记录边界的情况——前半夜在受试者A身上,后半夜切到受试者B身上。正确做法是在prepare_data.py里按记录组织样本,或至少确保窗口不跨记录边界。
第二个坑是EDF文件里的信号单位。部分PSG文件中的信号单位不是微伏而是其他量纲,直接做标准化可以解决数值范围问题,但要注意标准化应该用训练集的均值方差,而不是全量数据的。prepare_data.py里如果已经做了全局标准化,测试时也要用同一组参数,否则波形幅度分布变了,GRU的输出会偏移。
第三个坑是验证集划分。153条记录如果按epoch随机划分训练集和测试集,同一个人的相邻片段会同时出现在两边,模型的mf1虚高。标准做法是按记录划分,比如133条训练、20条测试。如果项目代码里没有现成的划分逻辑,自己写一个按subject id的split。想快速验证代码能不能跑通,先用单条记录(比如SC4001)切出训练和测试子集,epoch数控制在100以内,确认loss能下降、test.py能输出矩阵,再全量训练。
本文还有配套的精品资源,点击获取