简介:睡眠分期是脑电信号分析中的经典任务,临床中依赖医生对多导睡眠图逐页判读,而借助深度学习可将这一过程自动化。单通道脑电信号因其采集便捷、设备门槛低,成为睡眠分期研究的重要切入点。本质上,睡眠分期是一个典型的时序分类问题,需要模型同时捕捉单窗口内的波形特征与窗口间的睡眠阶段转移规律。以Sleep-EDF公开数据集为基础,通过窗口切分、标签映射构建训练样本,再结合GRU、LSTM、Attention等时序网络进行建模,最后用focal loss缓解类别不平衡,即可搭建完整的自动分期流程。该技术广泛应用于睡眠监测、健康管理及临床辅助诊断,尤其适合作为课程设计与科研实验的起点。本文围绕这一链路,展示从数据预处理、模型设计到训练评估的完整工程实现,并为复现中的常见问题提供排查思路。
1. 单通道脑电自动睡眠分期:一个能直接跑的时序分类实验包
睡眠分期这事,放到临床里是医生对着多导睡眠图一页一页判读的;放到课程设计或毕设里,就是一个典型的单通道脑电信号序列分类问题。这个压缩包的核心,是围绕“基于单通道脑电信号的自动睡眠分期”给出了一条完整链路:从 Sleep-EDF 公开数据集下载原始记录,到预处理成 numpy 数组,再到用 GRU、LSTM、Attention 这类经典结构做训练和测试,最后还能通过自带 Web 页面做预测演示。它不是那种只有模型文件的半成品,而是把数据处理、训练、评估、展示串起来的工程包,适合想快速跑通一条实验链路、又不想从零开始搭代码的同学。如果你只是想把网络结构换成别的,或者把这套流程搬到其他时序分类问题上,里面的 dataset.py 和 network.py 都有直接的参考价值。
2. 数据准备链路:Sleep-EDF 下载、窗口切分与标签映射
2.1 SC 子集、Fpz-Cz 通道和 30 秒标注
整个实验基于 Sleep-EDF Database Expanded 公开数据集。项目用的是其中的 SC(sleep cassette)子集,一共 153 条整晚睡眠记录。每条记录通常包含两个文件:一个是 PSG 多导睡眠信号文件,另一个是 Hypnogram 标注文件,里面是睡眠专家按 30 秒一段给出的阶段标注。项目里明确取的是 Fpz-Cz 通道,采样率为 100Hz。这个导联是前额到中央区的纵向通道,在公开数据集里信号质量和标注一致性都比较好,单通道睡眠分期方案里属于首选之一。
这里要先立住两个单位概念。第一,睡眠分期的标注粒度是 30 秒,一个 epoch 就是 30 秒长的信号片段;第二,在 100Hz 采样率下,一个 epoch 恰好是 3000 个采样点。整晚一整段信号会被切成很多个 3000 点的窗口,每个窗口对应一个睡眠阶段标签。Sleep-EDF 的原始标注里有 W、1、2、3、4、R 这几种,对应清醒、N1、N2、N3、N4 和 REM,其中 N3 和 N4 在主流标准里已经合并成 N3 深睡。少数记录里还会出现 ?(无法判读)和 M(运动干扰),预处理时要么删掉这些窗口,要么单独标记后跳过,这点到第 5 章避坑部分还会展开。
2.2 下载脚本与依赖安装:先跑通 download_sleepedf.py
压缩包里自带了数据获取脚本。先说明一下命名:README 里写的是 download_sleepedf.py,解压后如果看到 downloading_sleepedf.py,其实是同一个脚本在不同版本里的命名差异,执行时以实际文件名为准。第一步是安装环境依赖:
pip install -r requirements.txtrequirements.txt 里一般会包含 torch、numpy、pyedflib、wandb、flask 这些。pyedflib 负责读取 edf 格式信号,如果安装时遇到 wheel 编译问题,Windows 下优先用 pip 安装预编译版本,个别 Linux 环境可能需要先装 libopenblas 基础库。装完依赖后执行下载:
python download_sleepedf.py这个脚本会遍历 SC 子集的记录编号,把每条记录的 PSG 信号文件和 Hypnogram 标注文件下载到本地 data 目录。信号和标注都是 edf 后缀。下载过程中如果网络中断,常见做法是脚本里做了“本地已存在同名文件就跳过”的续传逻辑,重跑一遍脚本就能接着下,不要手动删掉已经落盘的文件。
数据全量下完体积不小,153 条整晚记录在 1GB 以上,建议磁盘预留 2GB 以上空间。整个下载过程是个体力活,我一般会挂后台跑,隔一段时间回来看一眼输出日志,确认没有某个编号一直重试失败。
2.3 prepare_data.py 核心逻辑:EDF 转 NumPy 与标签映射
下载下来的 edf 不能直接喂给 PyTorch,还要经过 prepare_data.py 转换成 numpy 数组。这个脚本做的事情可以拆成四步:读取每条 PSG 信号并抽取 Fpz-Cz 通道;按 30 秒窗口把长信号切成多个 3000 点的片段;读取 Hypnogram 标注并与窗口对齐;做标签映射后保存为 numpy 格式。核心逻辑近似如下:
import numpy as np from pyedflib import highlevel def edf_to_numpy(psg_path, hyp_path, fs=100, window_sec=30): # 读取 edf:signals 形状为 [通道数, 采样点数] signals, _ = highlevel.read_edf(psg_path) eeg = signals[0] # 取 Fpz-Cz 通道 window_len = fs * window_sec # 100 * 30 = 3000 n_windows = len(eeg) // window_len X = eeg[:n_windows * window_len].reshape(n_windows, window_len) stages = parse_hypnogram(hyp_path) y = np.array([label_map.get(s, -1) for s in stages[:n_windows]]) return X.astype(np.float32), y.astype(np.int64)这段代码的逻辑说明:highlevel.read_edf 返回的 signals 是按通道组织的二维数组,第一维是通道序号,第二维是采样点,因此取 signals[0] 就是 Fpz-Cz 通道。整段信号长度除以 3000 得到完整窗口数量,末尾不足一个窗口的余数直接丢掉。Hypnogram 标注同样按 30 秒一条记录,取前 n_windows 个标注,通过 label_map 映射成类别索引,映射不到的 ? 和 M 统一赋成 -1,后续在 dataset 层过滤。
这里 fs 和 window_sec 是核心参数,实验统一固定为 100Hz 和 30 秒。这两个值一旦改动,模型输入端维度、窗口切分、标注对齐全部要跟着变,所以不属于常规调参范围。标签映射建议这样处理:W 映射为 0,1 映射为 1,2 映射为 2,3 和 4 都映射为 3,R 映射为 4。最终的分类任务是五分类:清醒、N1、N2、N3、REM。
2.4 dataset.py 加载方式:seq_len 决定序列模型看到多长的上下文
数据保存成 numpy 之后,dataset.py 直接继承 torch.utils.data.Dataset。这个类比常见的“按文件路径读一条样本”多做了一层:它定义了 seq_len,把多个连续的 30 秒 epoch 拼成一条训练样本。seq_len=64 的含义是,一次给模型 64 个连续窗口,也就是约 32 分钟的脑电上下文。
这里要区分两个容易搞混的概念。batch_size 是每次迭代喂给模型的样本数,seq_len 是每条样本内部的时间步数。比如 train.py 里执行 batch_size=16、seq_len=64 时,模型实际输入的张量形状是 [16, 64, 3000],含义是一次处理 16 条样本,每条样本包含 64 个连续窗口,每个窗口 3000 个采样点。也就是说,模型先对每个窗口做特征提取,再把 64 个窗口的特征作为时间序列送入 GRU 或 LSTM。这种“窗口特征提取 + 序列建模”的套路是所有睡眠分期模型的通用范式。
dataset.py 里还有一个 shuffle_seed 参数。睡眠数据不是独立同分布的,同一个受试者相邻窗口高度相关,如果每次打乱顺序都用不同的随机种子,训练结果会有明显波动。固定 shuffle_seed 是为了保证实验可复现,也方便对比不同网络结构之间的指标差异。我自己复现时习惯在命令行里把 shuffle_seed 写死,这样测试集上的指标波动基本只来自模型本身,而不是数据顺序。
3. 网络结构拆解:TinySleepNet 骨架与 GRU/Attention 改进
3.1 整体骨架:CNN 提特征,RNN 学时序
网络整体参考了 TinySleepNet 的设计思路,但 RNN 部分做了较大改动。结构可以分成三段。第一段是一维卷积特征提取器,输入是单窗口的 3000 点原始脑电波形,通过若干层卷积和池化,把每个窗口压缩成一个固定维度的特征向量。第二段是序列模型,把连续 seq_len 个窗口的特征当成时间步输入到 GRU 或 LSTM 中,学习睡眠阶段之间的转移规律。第三段是全连接分类头,输出每个窗口的五个睡眠阶段概率。
为什么要把 CNN 和 RNN 分开看?因为两个部分解决的问题完全不同。CNN 只看一个窗口内部,负责回答“这 30 秒波形长什么样”;RNN 看的是窗口与窗口之间的先后关系,负责回答“睡着 20 分钟后突然出现的低频高幅波形更可能是深睡还是清醒”。睡眠分期在临床上本身就有很强的时序上下文依赖,比如 REM 之前通常伴随 N2 或 N3,单纯对每个窗口独立分类很难达到理想准确率,这也是这个项目保留序列模型的原因。
3.2 GRU、LSTM 与 Attention:network.py 里可切换的时序模块
这个项目最值得看的部分就是时序模块的灵活设计。network.py 里定义了 rnn_type 参数,可以在 GRU、LSTM、RNN 之间切换,同时支持单向和双向。我按该项目里最可能的结构写一个示意:
import torch.nn as nn class SequenceEncoder(nn.Module): def __init__(self, input_size, hidden_size, num_layers, rnn_type="GRU", bidirectional=True): super().__init__() self.rnn_type = rnn_type.upper() self.bidirectional = bidirectional rnn_cls = {"GRU": nn.GRU, "LSTM": nn.LSTM}.get(self.rnn_type, nn.GRU) self.rnn = rnn_cls( input_size=input_size, hidden_size=hidden_size, num_layers=num_layers, batch_first=True, bidirectional=bidirectional, ) def forward(self, x): # x 形状: [batch, seq_len, input_size] output, _ = self.rnn(x) return output # [batch, seq_len, hidden_size * 2]这段代码的逻辑说明:input_size 是 CNN 特征提取器输出的特征维度,hidden_size 是 RNN 隐藏层维度,num_layers 是叠加的 RNN 层数。bidirectional 设为 True 时,模型同时从正向和反向看 64 个窗口的上下文,输出维度会翻倍,这也是常见做法,因为睡眠阶段不仅受过去影响,也和未来的几分钟相关。batch_first=True 保证输入输出都是 [batch, seq_len, feature] 布局,和 dataset.py 返回的张量直接吻合。
Attention 的引入方式常见是把 RNN 输出的最后一个时间步换成加权求和。简单理解就是:64 个窗口对当前分类的贡献不应该一样,Attention 让模型学会把权重更多地分配给“更像判别依据”的窗口。一个极简实现是:
class Attention(nn.Module): def __init__(self, hidden_size): super().__init__() self.score = nn.Linear(hidden_size, 1) def forward(self, rnn_out): # rnn_out: [batch, seq_len, hidden_size] weights = torch.softmax(self.score(rnn_out), dim=1) return torch.sum(rnn_out * weights, dim=1)这段代码里 self.score 对每个时间步打分,softmax 把分数归一化成权重,再对序列维度做加权求和。这样无论 seq_len 取多少,输出都是一个固定长度的向量,可以直接接全连接分类头。实际项目中 Attention 和双向 GRU 可以叠加使用,这也是这个网络相比原始 TinySleepNet 最明显的扩展点。
3.3 超参联动:seq_len、hidden_size、num_layers 怎么配合
network.py 另一个亮点是把 seq_len 作为构造参数暴露出来了,这让 batch_size 和 seq_len 完全解耦。项目正文给的参考训练命令是:
python train.py --n_epochs 150 --batch_size 16 --seq_len 64 --network "GRU"这里的 seq_len=64 和 hidden_size、num_layers 之间存在联动关系。如果 seq_len 取 64,说明序列模型需要处理 64 个时间步,隐藏层维度通常取 128 或 256。hidden_size 太小会压不住时序特征,太大则容易在训练后期过拟合,毕竟每个睡眠阶段标签的分布很不均匀。num_layers 建议先从 2 层开始,1 层对长时间依赖的建模能力偏弱,3 层以上训练难度陡增,而且对显存占用影响明显。
还有个容易忽略的点:双向 RNN 会把输出维度翻倍,如果 hidden_size=128 且 bidirectional=True,Attention 打分层的输入维度要写成 hidden_size*2,而不是 hidden_size。这类维度不匹配的报错在第一次复现时很常见,后面第 5 章还会专门说。
4. 训练与测试:focal loss、超参设置与指标解读
4.1 用 focal loss 处理睡眠分期的类别不平衡
睡眠分期的标签分布是出了名的偏。一整晚睡眠中,N2 往往占最大比例,N1 和深睡占比明显偏少,普通交叉熵损失会把模型推向“绝大多数样本都预测成高频类别”的偷懒解。项目里专门定义了 focal loss 损失函数,这是一个针对类别不平衡设计的改进版本。
focal loss 的核心是给每个样本的损失乘以一个调制因子。对于已经被模型正确分类且概率很高的样本,损失被大幅压低;对于难分类、概率较低的样本,损失保留得更多。公式写作 FL = -α_t(1-p_t)^γ log(p_t),其中 γ 通常取 2,α 是类别权重。对应代码可以简化如下:
import torch.nn.functional as F class FocalLoss(nn.Module): def __init__(self, alpha=None, gamma=2.0): super().__init__() self.gamma = gamma self.alpha = alpha def forward(self, logits, targets): ce_loss = F.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce_loss) loss = (1 - pt) ** self.gamma * ce_loss if self.alpha is not None: alpha_t = self.alpha[targets] loss = alpha_t * loss return loss.mean()这段代码的逻辑说明:先用 cross_entropy 算每个样本的标准损失,pt 表示模型对正确类别的置信度。pt 越大说明样本越容易分类,(1-pt) 的 gamma 次方越小,损失被压得越狠。alpha 是每个类别的权重列表,比如标签分布中 N1 占比最低,可以给 N1 的 alpha 设大一些。实际使用时,alpha 可以按训练集各类别频率的倒数来算,也可以手动先给一个偏向少数类的初始值。
在原项目里,focal loss 被单独拆到 focal_loss.py 文件中,train.py 直接引用。这个设计对扩展很友好,想换回标准交叉熵时,只需要把损失函数一行换掉即可。
4.2 train.py 训练入口与命令行参数
train.py 是训练入口,典型执行命令是:
python train.py --n_epochs 150 --batch_size 16 --seq_len 64 --network "GRU"如果不确定有哪些参数,可以直接运行 python train.py -h 查看帮助。常用参数整理如下:
| 参数名 | 示例值 | 作用 |
|---|---|---|
| --n_epochs | 150 | 最大训练轮数 |
| --batch_size | 16 | 每次迭代样本数 |
| --seq_len | 64 | 每条样本包含的连续窗口数 |
| --network | GRU | RNN 类型,可选 GRU / LSTM / RNN |
| --hidden_size | 128 | RNN 隐藏层维度 |
| --num_layers | 2 | RNN 层数 |
| --lr | 0.001 | 学习率 |
| --use_attention | True | 是否启用 Attention |
训练时模型会逐步打印每个 epoch 的 loss 和验证集指标。seq_len 和 batch_size 的选择背后是显存和上下文的权衡:seq_len 越大,模型看到的历史越长,但显存占用线性上升;batch_size 越小,梯度估计越不稳,睡眠分期这种强不平衡任务更容易震荡。项目给的 batch_size=16、seq_len=64 是一个在普通单卡上比较稳妥的起点。
4.3 训练迭代细节:wandb 记录与断点续训
train.py 里集成了 wandb,这是实验记录工具,能跟踪每个 epoch 的 loss、准确率、学习率变化曲线。对做课程设计和毕设的人来说,它最大的价值不是花哨的图表,而是能让你把同一份数据的多次实验记录集中对比,比如 GRU 和 LSTM 哪个收敛更快、不同 seq_len 下验证集指标波动多大。官方使用时要先登录账号,这个细节我会在第 5 章避坑里专门讲,因为很多第一次跑的人会被卡在这里。
另外,保存的模型在 models 目录下,文件名是 model_GRU.pt。这类 PyTorch 权重文件在加载时要保证模型结构和保存时一致。如果自己改了 hidden_size 或加了一层 Attention,再去加载原来的 model_GRU.pt 会直接报 state_dict 不匹配,这是新手最容易遇到的错误之一。
4.4 test.py 输出指标:从 accuracy 到 per-class F1
test.py 会计算并输出多个评估指标,包括 accuracy、mf1、按类别分开的 recall、precision、f1。这里的 mf1 指的是宏平均 F1(macro F1),即先分别算五个类别各自的 F1,再取平均。宏平均 F1 比 accuracy 更能反映模型在少数类别上的表现。
| 指标 | 关注点 |
|---|---|
| accuracy | 全局正确率,受高频类别主导 |
| mf1 | 五个类别的 F1 取平均,少数类权重更大 |
| recall_confusion_matrics | 每个类别的召回率列表 |
| precision_confusion_matrics | 每个类别的精确率列表 |
| f1_confusion_matrics | 每个类别的 F1 列表 |
看这些指标时,我一般会先看 N1 的 recall 和 F1。因为 N1 是浅睡阶段,波形特征和清醒、N2 都有重叠,是所有类别里最难分对的一类。如果整体 accuracy 很高但 N1 的 F1 明显低,说明模型在“吃掉”少数类,这时候优先调 focal loss 的 alpha 权重,而不是盲目加网络层数。
5. 避坑与排查:五个复现路上的具体翻车点
5.1 通道方向没统一,模型输入维度直接报错
现象:训练刚启动就抛出类似 RuntimeError: size mismatch 的错误,检查数据形状时发现 X 是 [3000, n_windows] 而不是 [n_windows, 3000]。
原因:edf 读取时返回的信号默认是 [通道数, 采样点数],如果预处理脚本里没有明确转置,窗口切分就会按采样点方向切,把整个时间轴搞反。有些版本的 pyedflib 返回的又是 [采样点数, 通道数],两个库混用更容易踩坑。
解决:在 prepare_data.py 落盘之前,强制打印一次 X.shape,确认第一维是窗口数、第二维是 3000。建议所有通道处理统一走“先取通道,再窗口化”的路径,不要在不同脚本里交叉使用两种布局。
5.2 标注里的 ? 和 M 混入训练集,分类任务莫名其妙变成七类
现象:训练和测试都没有报错,但预测结果里出现了一个从未定义的类别,或者混淆矩阵维度是 7x7。
原因:Sleep-EDF 原始 Hypnogram 文件中,? 和 M 代表无法判读和运动干扰。如果 label_map 里没有处理这两个值,而预处理时又用了 “get 不到就保留原始字符” 的逻辑,这两个标记会被当成独立的类别编码进 y。
解决:在 prepare_data.py 里对 ? 和 M 统一赋 -1,并在 dataset.py 采样时过滤掉 y == -1 的窗口。注意过滤后要对模型输出维度重新确认,保证分类头是 5 而不是 7。
5.3 按记录随机切分导致数据泄漏,测试集指标虚高
现象:训练集 accuracy 和测试集 accuracy 都高得离谱,随便跑一个简单 GRU 都能到 90% 以上,但换了一组真实新数据效果明显变差。
原因:如果划分训练集和测试集时只按窗口随机切分,同一个受试者一整晚的相邻窗口会同时出现在训练集和测试集里。模型在训练时已经见过同一受试者高度相似的数据,测试指标自然虚高。
解决:按受试者或按记录编号划分数据,保证同一个人的全部窗口只出现在训练集或只出现在测试集中。Sleep-EDF 的记录编号中,同一受试者会有多条记录,划分时要先按受试者分组再切分,而不是简单 random split。
5.4 wandb 未登录把训练卡在启动阶段
现象:运行 train.py 后终端一直停在 wandb 的初始化信息,不输出 loss,也不进入训练循环。
原因:train.py 里调用了 wandb.init,如果环境变量里没有配置 WANDB_API_KEY,也没有登录过,进程会等待交互,在无人值守的训练环境里就像卡住了一样。
解决:可以在初始化时显式使用离线模式,通过环境变量 WANDB_MODE=offline 关闭在线同步;如果不需要记录,直接设 WANDB_DISABLED=true 禁用。这个设置不会影响模型训练,只影响实验日志的同步。
5.5 测试阶段忘了沿用训练集的标准化参数
现象:训练时验证集 F1 不错,但用 predict.py 对一条新信号做预测时,概率输出接近均匀分布,五个类别的置信度都差不多。
原因:训练时如果对窗口做了 z-score 标准化,均值和标准差是在训练数据上算出来的。测试时如果对每条新窗口重新计算均值和标准差,相当于改变了数据的统计尺度,模型输入分布和训练时不一致。
解决:训练完成后把均值、标准差作为常量保存下来,测试和预测阶段直接加载,而不是重新计算。这是所有信号处理项目里最容易犯又最容易被忽略的细节,数据预处理的一致性比模型结构本身更能决定最终效果。
6. 进阶用法:把模型封装成 Web 服务,顺手验证一波自定义数据
项目压缩包里还有 server.py、predict.py 和 templates 目录,这是一个 Flask 封装的 Web 演示层。整套推理链路的常见设计是:predict.py 负责把一段原始脑电信号切成和训练时完全一致的 30 秒窗口,做同样的标准化处理后,交给 model_GRU.pt 输出五个类别的概率;server.py 启动本地 Web 服务,前端页面上传或输入一段信号,后端返回分期结果。
这段逻辑里最值得留意的还是数据一致性。predict.py 切窗时的窗口长度、采样率、标签映射顺序都必须和 prepare_data.py、train.py 完全一致。我自己验证时习惯拿一段带标注的 Sleep-EDF 记录,先跑 test.py 得到整条记录的指标,再用 predict.py 对同一段数据逐窗预测,对比两者输出是否吻合。如果同一个窗口在两条路径上预测不一致,说明推理链路里切窗或标准化的某个环节有偏差,需要逐行比对。
如果手里没有合适的脑电数据,项目自带的 eeg_signal.txt 示例信号可以直接用来做流程验证。这类文本格式的信号文件读取以后就是一维数组,喂给 predict.py 就能看到输出。要做到这一步,最好再确认一下预测脚本内部是否包含了滑动窗口补足逻辑。因为训练时 seq_len=64,预测时如果原始信号只有几十个窗口,常见做法是取最近 64 个窗口作为上下文,或者不足时做边缘填充,而不是强行要求输入恰好是 64 个完整窗口。这一点在不同版本的 predict.py 里实现方式不同,跑之前花两分钟看一下输入形状处理逻辑,能省很多定位时间。
从那以后,我每次拿到新的脑电数据,都会强制先走一遍标签分布统计和标准化参数校验,再进入网络训练或预测环节。这个习惯救过我很多次,因为大多数看似模型出来的奇怪结果,最后都出在数据口径不统一上。希望帮到你。
本文还有配套的精品资源,点击获取