资讯动态

单通道脑电睡眠分期:从数据到模型部署的完整实现

发布时间:2026/10/29 15:34:09 来源:尧图企业网站定制
简介面向睡眠分期研究与深度学习时序分类入门者这款资源包提供了基于单通道脑电信号的自动睡眠分期完整Python实现覆盖源码、项目说明、预训练模型与数据处理流程。项目使用Sleep-EDF公开数据集的SC子集采用Fpz-Cz通道与100Hz采样包含153条整晚睡眠记录可直接用于训练与验证。网络结构参照TinySleepNet扩展了双向循环网络、GRU和注意力机制支持通过参数调整选择合适结构数据加载器可按需设置序列长度代码注释细致降低了阅读门槛。资源包共21个文件以十二个Python源文件为核心涵盖训练、测试、预测等模块另含预训练权重、文本配置、Shell运行脚本、Markdown说明以及图片和网页文件整体体积约10.66MB便于快速部署。目前已有三百七十六人学习浏览适合毕业设计或科研预实验也可作为从零理解时序分类的入门项目借此掌握LSTM、GRU与Attention在脑电分期中的实际应用。1. 从毕设选题到落地单通道脑电睡眠分期为什么值得复现第一次看到这份基于单通道脑电信号的自动睡眠分期 Python 源码时我以为是又一个“毕业设计罐头”——压缩包里堆满文件但能跑起来的没几个。解压之后翻了network.py和dataset.py才确认它是真能用的数据用的是 Sleep-EDF Expanded v1.0.0 的 SC 子集153 条整晚睡眠记录Fpz-Cz 单通道、100Hz 采样网络骨架类似 TinySleepNet 但把 RNN 部分换成了可切换的双向 GRU / LSTM / Attention 结构。对正在找毕设方向或者想入门时序分类的人来说这份资源的价值不在于“睡眠分期”这个任务本身而在于它把一条完整的流水线摊开了——从 EDF 原始数据下载、预处理、滑窗切分到 Dataset 封装、模型训练、测试评估、单条预测最后还带一个 Web 可视化页面。你拿到的不只是一个模型权重而是一整套可以改、可以拆、可以换数据集的工程模板。2. 数据先行download_sleepedf.py 与 Sleep-EDF 的下载、解析、预处理流水线2.1 Sleep-EDF 数据集的正确打开方式做睡眠分期绕不开 Sleep-EDF但很多人第一步就卡在数据下载上。Sleep-EDF Expanded v1.0.0 在 PhysioNet 上提供的是 EDF 格式文件SC 子集对应的是健康受试者的整晚多导睡眠图记录包含了 Fpz-Cz 和 Pz-Oz 两个脑电通道、EOG 眼电、EMG 肌电还有标注好的睡眠分期标签。这份源码用的是 Fpz-Cz 通道采样频率 100Hz每个 epoch 是 30 秒对应 3000 个采样点。SC 子集一共 153 条记录标注体系是 RK 的 W / N1 / N2 / N3 / R 五类但很多实现里会把 N3 和 N4 合并或者也把 REM 单列具体看训练脚本怎么映射。下载这块建议直接用项目自带的download_sleepedf.py因为手动去 PhysioNet 页面勾选文件很费时间——SC 子集的文件命名规律是SC4xxxPSG.edf加对应的SC4xxxPSG.edf标注文件一条记录要下两个文件。脚本会按编号批量拉取省掉逐个点击的体力活。跑之前确认机器上装了wfdb或者用pyedflib来读 EDFrequirements.txt里已经有这些依赖。下载完的数据体积不小153 条记录加起来接近 1GB注意磁盘空间。2.2 把 EDF 转成干净的特征预处理脚本的实际配置预处理是整个流程里最枯燥但最关键的环节。preprocessing.py做的事情可以拆成三步读 EDF 原始信号、按 30 秒窗口切片、把标签文件和信号对齐。设计上有个值得借鉴的点标签文件里的睡眠分期是每个 epoch 一个标注而原始信号是连续采样点所以切窗时必须以 epoch 为最小单位切不能随意滑动。代码里这个对齐逻辑写得很清楚# 从 EDF 标注文件读取睡眠分期标签 def load_annotations(ann_file): stages [] with open(ann_file, r) as f: for line in f: if line.startswith(Sleep stage): # 格式如 Sleep stage W / Sleep stage N1 / Sleep stage R stage_code line.strip().split()[-1] stages.append(stage_code) # 将字符串标签映射为整数索引 stage_map {W: 0, N1: 1, N2: 2, N3: 3, N4: 3, R: 4} return [stage_map[s] for s in stages if s in stage_map]逻辑不复杂但注意两点。第一Sleep-EDF 早期版本的标注里有 N4RK 标准里 N3 和 N4 同属慢波睡眠所以映射成同一个类别是常见做法这份代码也这么处理了。第二load_annotations里过滤掉了无法识别的标签遇到空标注行时不会崩这在处理脏数据时很实用——我在跑别的数据集时遇到过标注文件末尾多一个空行导致数组长度不匹配的问题加个过滤条件就能绕过去。接着是信号切片和归一化def cut_signal(signal, fs100, epoch_sec30): epoch_len fs * epoch_sec n_epochs len(signal) // epoch_len # 只保留完整 epoch丢弃末尾不足一个 epoch 的残段 epochs signal[:n_epochs * epoch_len].reshape(n_epochs, epoch_len) # 逐 epoch 做 z-score 归一化 epochs (epochs - epochs.mean(axis1, keepdimsTrue)) / (epochs.std(axis1, keepdimsTrue) 1e-8) return epochs这里reshape能成功的前提是信号长度能整除epoch_len而 EDF 文件录音时长基本是整分整秒整除问题不大但下载不完整或文件损坏时会出现余数。加一个n_epochs len(signal) // epoch_len再丢弃残段比直接reshape安全得多。归一化为什么逐 epoch 做而不是整段做因为单通道脑电信号在不同受试者之间有幅值差异逐段归一化能让模型学到的是波形形态而不是绝对幅值这也是 Sleep-EDF 相关论文里通行做法。2.3 prepare_data.py 的缓存机制与目录结构prepare_data.py的作用是把预处理结果落盘成模型可直接加载的格式。它按受试者编号分割数据每个受试者的连续 epoch 序列被保存在单独的.npy文件里这是为了后面做按受试者划分的训练/测试集——如果直接把所有 epoch 随机打乱再划分同一个人的前后 epoch 会同时出现在训练集和测试集里造成数据泄漏模型指标会虚高。正确的划分方式是按记录编号切分比如前 120 条做训练、剩余 33 条做测试或者按比例随机选一部分受试者做测试。缓存文件命名我建议用{subject_id}_data.npy和{subject_id}_label.npy这种格式这个项目也是类似思路。这样做的好处是训练脚本启动时先检查缓存目录命中就直接加载不需要每次重新解析 EDF。你可以把预处理想象成一次编译过程缓存目录就是编译产物后面调模型参数时只动训练脚本不用再碰原始数据。3. Dataset 封装与训练/测试划分seq_len 参数和标签对齐的细节3.1 继承 torch Dataset 时你得知道的事dataset.py继承了torch.utils.data.Dataset核心是__len__和__getitem__两个方法。但睡眠分期有个特殊之处模型输入不能是单个 epoch。因为 N1、N2、REM 这些分期在相邻 epoch 之间有很强的时序依赖——REM 通常出现在 N2 之后K 复合波和睡眠纺锤波的特征需要看上下文才能判断。所以__getitem__返回的不再是(1, 3000)的向量而是一个窗口比如(seq_len, 3000)的矩阵其中seq_len是窗口内的 epoch 数量默认常见取 20预测的目标是窗口内最后一个 epoch 的标签。class SleepDataset(Dataset): def __init__(self, data_dir, seq_len20, shuffle_between_epochsFalse): self.seq_len seq_len self.samples [] # 每个元素是 (特征窗口, 标签) for npy_file in data_dir.iterdir(): if not npy_file.name.endswith(_data.npy): continue subject_data np.load(npy_file) subject_labels np.load(str(npy_file).replace(_data.npy, _label.npy)) for start in range(len(subject_data) - seq_len): win subject_data[start:start seq_len] lab subject_labels[start seq_len - 1] # 预测当前窗口最后一个 epoch self.samples.append((win, lab))代码里有个细节值得说明标签取的是start seq_len - 1也就是窗口最末一个 epoch 的标签。为什么不用窗口中间或起点的标签因为推理时模型看到的是「过去 seq_len 个 epoch 的信号」要预测的是「此刻处于哪个睡眠分期」而训练时「此刻」对应窗口的最后一个位置。这个对齐如果在__getitem__里搞错了训练损失会下降但测试时预测结果会整体偏移一个相位表现是混淆矩阵对角线偏移这是新手最容易踩的坑之一。3.2 seq_len 参数怎么调20、25 还是 50seq_len是这份源码里最值得调的超参数。从生理学常识看人的睡眠周期大约 90 分钟一个 epoch 30 秒一个完整周期约 180 个 epoch。但分期间的转换往往在几分钟内完成模型不需要看满 90 分钟才能判断当前状态。TinySleepNet 原论文用的是 20 个 epoch 的窗口这份源码沿用了这个值。seq_len变大模型能感知的上下文更长理论上对 N2 和 SWS 这类有前序依赖的阶段更友好但代价是训练样本数量减少——总 epoch 数固定窗口越长可切出的窗口越少。更关键的是RNN 类结构在长序列上容易梯度衰减seq_len超过 30 时 GRU 的隐状态可能已经记不住早期信息了。我调参时一般从 20 起步试 25 和 30超过 30 基本收益递减。如果你的显存够大可以配合增大 batch_size 一起试。另一个和seq_len绑定的参数是shuffle。在Dataset.__init__里有个shuffle_between_epochs开关注意这个 shuffle 作用在样本列表层面——把不同受试者、不同时间窗口的样本顺序打乱。不做这个 shuffle每个 batch 里的样本会高度相关同一个人的相邻窗口梯度更新方向会偏。但 shuffle 完训练时序列间的时序连续性就断了所以模型结构里对上下文建模靠的是窗口内部的seq_len而不是跨样本的序列关系这个设计要理解清楚别把两者搞混。4. 网络结构与训练配置TinySleepNet 骨架、双向 GRU 与 Attention 怎么选4.1 network.py 的结构拆解CNN 特征提取 RNN 时序建模打开network.py整体结构是典型的「前端 CNN 提取局部特征后端 RNN 建模时序依赖」。具体来说每个 epoch 的 3000 个采样点先经过几个一维卷积层把波形中的局部形态特征纺锤波、K 复合波、慢波编码成特征向量然后把这些特征向量按时间顺序送入 RNN 层最后接一个全连接分类头输出 5 类概率。class SleepNet(nn.Module): def __init__(self, input_dim3000, seq_len20, rnn_typeGRU, n_classes5, use_attentionTrue, bidirectionalTrue): super().__init__() # 前端一维卷积特征提取类似 TinySleepNet 的 encoder self.cnn nn.Sequential( nn.Conv1d(1, 128, kernel_size50, stride6, padding24), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Conv1d(128, 128, kernel_size8, stride1, padding4), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Conv1d(128, 128, kernel_size8, stride1, padding4), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Conv1d(128, 128, kernel_size8, stride1, padding4), nn.BatchNorm1d(128), nn.ReLU(inplaceTrue), nn.Dropout(0.5) ) # 后端根据参数切换 RNN 类型 rnn_hidden 128 if rnn_type.upper() GRU: self.rnn nn.GRU(input_size128, hidden_sizernn_hidden, num_layers2, bidirectionalbidirectional, batch_firstTrue, dropout0.5) elif rnn_type.upper() LSTM: self.rnn nn.LSTM(input_size128, hidden_sizernn_hidden, num_layers2, bidirectionalbidirectional, batch_firstTrue, dropout0.5) rnn_out_dim rnn_hidden * 2 if bidirectional else rnn_hidden if use_attention: self.attn nn.MultiheadAttention(embed_dimrnn_out_dim, num_heads4, batch_firstTrue) else: self.attn nn.Identity() self.fc nn.Linear(rnn_out_dim, n_classes)这段代码把可配置性拉满了。几个关键参数的作用kernel_size50对应 0.5 秒的时域窗口在 100Hz 采样率下刚好覆盖半个秒——过小只能看到瞬时波形过大则模糊了纺锤波约 11-16Hz周期 60-90ms的形态stride6做降采样把 3000 点压到约 500 个特征帧。bidirectionalTrue意味着每个时间步的隐状态同时包含过去和未来的信息这对离线分析睡眠记录是合理的因为整夜数据都在手上不是在线实时判断。但如果你未来要做在线监测比如实时分期双向 RNN 有未来信息泄漏必须改成单向。Attention 层放在 RNN 之后、分类头之前作用是对 RNN 输出的所有时间步做加权求和让模型自己学会“哪些历史时刻对判断当前分期更重要”。从实际效果看加 Attention 对 N1 这个类别的召回率有提升——N1 是一个过渡阶段持续时间短波形特征不明显依赖注意力机制去捕捉前后文中的微弱线索是有效的。4.2 损失函数与样本不平衡为什么用 focal_loss.py睡眠分期一个显见的工程问题是类别极度不平衡。整夜睡眠中 N2 占比大约 40%-50%W 的占比看受试者入睡速度通常 10%-20%而 N1 只有 5% 左右。如果直接拿交叉熵训练模型会倾向于把所有样本预测为 N2整体准确率可能做到 70% 以上但对 N1 的召回率几乎为 0。项目里的focal_loss.py就是针对这个问题的。Focal Loss 最早用于目标检测解决前景背景不平衡在睡眠分期里同样适用。它给易分类样本更低的权重让模型把注意力放在难分类的少数类上。参数alpha控制类别权重gamma控制难易样本的调节强度class FocalLoss(nn.Module): def __init__(self, alphaNone, gamma2.0, class_weightsNone): super().__init__() self.gamma gamma self.alpha alpha # 每类权重常见做法是取各类样本占比的倒数 self.class_weights class_weights def forward(self, logits, targets): ce F.cross_entropy(logits, targets, weightself.class_weights, reductionnone) pt torch.exp(-ce) focal (1 - pt) ** self.gamma * ce if self.alpha is not None: alpha_t self.alpha[targets] focal alpha_t * focal return focal.mean()gamma取 2 是默认值它的含义是当某个样本已经被正确分类且置信度很高pt接近 1时(1-pt)^gamma接近 0该样本的损失被压制而难分类样本pt小损失不会被过度压制。分类别设置alpha时我一般用1 / 类别占比然后归一化让少数类的梯度贡献被放大。如果你发现 N1 的召回率特别低而整体准确率被 W 和 N2 撑起来了优先调gamma到 2.5 或者调大 N1 对应的alpha。4.3 train.py 的训练循环、早停与超参数推荐训练脚本的逻辑比较标准但有两个设置值得单独说。第一个是学习率调度源码里用的是 CosineAnnealingLR这在 RNN 类模型上比 StepLR 更稳——因为睡眠分期训练后期 loss 曲线容易在小范围内震荡余弦退火可以平滑地逼近最低点。第二个是早停机制监控验证集上的 Cohen Kappa 而不是准确率因为准确率会被多数类带偏。Kappa 系数考虑了随机一致性的影响是睡眠分期论文里通用的评估指标。optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) best_kappa 0.0 patience 10 for epoch in range(50): model.train() for x, y in train_loader: out model(x) loss focal_loss(out, y) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() val_kappa evaluate(model, val_loader) # 用 Cohen Kappa 评估 if val_kappa best_kappa: best_kappa val_kappa torch.save(model.state_dict(), models/model_GRU.pt) early_stop_counter 0 else: early_stop_counter 1 if early_stop_counter patience: break这里我建议学习率调成1e-3起步如果 loss 前 3 个 epoch 不下降则降到5e-4。clip_grad_norm_(max_norm1.0)是 RNN 训练的必要防护——GRU 和 LSTM 在长序列上容易出现梯度爆炸激活函数哪怕用 ReLU隐状态在时间维度展开之后梯度范数也会快速积累。不加梯度裁剪跑着跑着 loss 变成 NaN 是正常现象。超参数参考组合seq_len20batch_size64取决于显存rnn_typeGRUbidirectionalTrueuse_attentionTruefocal_loss(gamma2.0, alpha[0.2, 1.0, 0.8, 1.2, 1.0])。在这个组合下训练到第 15-25 个 epoch 时验证集 Kappa 基本能到 0.70-0.75 区间准确率 78%-82%和 TinySleepNet 原论文的 SC 子集结果在同一水平。5. 避坑记录从数据下载到模型部署的五个翻车现场5.1 标签文件对不齐训练 loss 正常但预测结果偏移现象模型训练完测试集准确率只有 40% 左右但训练集准确率有 85%怎么看都是过拟合。画出预测序列和真实序列的对照图才发现预测的每个 epoch 分期都比真实标签晚了 1-2 个 epoch。原因dataset.py的__getitem__里窗口和标签的对齐错了。窗口取的是epochs[start:startseq_len]但标签取成了labels[start]而不是labels[startseq_len-1]。在训练时模型会根据窗口前部的特征预测当前分期但由于滑动窗口之间有重叠短期 loss 也能降下来问题到推理阶段才暴露。解决强制把预测目标改到窗口末尾修改后重新跑训练。从那以后我每次写时序分类的 Dataset 都会先打印一个样本的(x.shape, y.shape)和对应的标签值人工验证对齐关系再开训练。血泪经验别省这个打印。5.2 下载的 EDF 文件不完整preprocessing 直接崩现象download_sleepedf.py下载中断后续传preprocessing.py在解析某个.edf文件时报EOFError整个脚本挂掉。原因PhysioNet 的服务器不支持断点续传下载中断后的文件是残缺的。脚本没有对读取异常做捕获一个文件损坏就全盘停摆。解决第一下载时改用curl -C -或者校验文件大小是否与官网一致再做预处理。第二在preprocessing.py的读取环节加try/except失败时记录文件名并跳过——153 条记录缺一条不致命但脚本卡死在 50% 位置才是真浪费时间。第三个通用做法下载完成后对全部 EDF 做一次完整性校验计算文件数量是否等于 306153 条记录 × 信号文件 标注文件。5.3 归一化方式选错模型在测试集上表现骤降现象训练集 F1 score 0.80测试集 F1 score 0.55。Kappa 更是只有 0.45。原因预处理时用的是整段信号的全局均值和标准差做归一化。训练集和测试集是不同受试者的记录幅值分布差异很大全局归一化让测试集的幅值特征分布被压缩到了训练集没见过的范围。更严重的是全局归一化会让模型学到的是“绝对幅值大小”而不是“波形形态”。解决改成逐 epoch 局部归一化也就是每个 30 秒窗口用自己的均值和标准差做标准化实现上一章cut_signal里展示的那样。改完之后测试集 F1 回到 0.76 左右。这里的教训是睡眠脑电的幅值在不同人、不同睡眠阶段差异极大任何跨样本的全局统计量都会引入偏差。你甚至可以试试逐通道独立归一化效果基本一致但更稳。5.4 显存 OOMseq_len 和 batch_size 一起加导致显存爆掉现象seq_len调到 30、batch_size调到 128 后训练刚开始就报CUDA out of memory。原因RNN 类的显存占用和序列长度成正比因为必须存储每个时间步的隐状态用于反向传播。seq_len和batch_size同时翻倍显存占用翻四倍不只是翻两倍。解决显存不足时优先固定seq_len调小batch_size如果 batch_size 小到模型不收敛可以试梯度累积——每 4 个 batch 做一次梯度更新效果等价于大 batch。另一个技巧是开启torch.backends.cudnn.benchmark TrueCUDNN 会针对输入尺寸做算子优化显存占用不变但训练速度能提升 20% 左右。5.5 加载 model_GRU.pt 时提示 shape 不匹配现象从官网下的预训练模型权重加载时报size mismatch for fc.weight模型压根跑不起来。原因预训练模型的n_classes可能是 5网络最后一层fc的输入维度是rnn_out_dim但你加载时如果use_attentionFalsernn_out_dim没有做对应调整或者bidirectionalFalse导致 RNN 输出维度减半全连接层的输入维度就对不上。解决predict.py和train.py里实例化SleepNet时所有超参数必须和训练时完全一致——rnn_type、bidirectional、use_attention、seq_len。我看到这份源码的predict.py里构造网络时这些参数都是写死的如果你改了训练参数记得同步改推理脚本。一个实用做法是把网络参数写进一个 JSON 配置文件训练和推理都从同一份配置加载避免这类低级错位。6. 验证与部署从 predict.py 到 Web 可视化的完整链路6.1 predict.py 单条记录推理输入格式与输出解释predict.py是这套源码里最容易被忽略但最实用的文件。它做的事情是读取一段原始的.edf或者.npy数据切窗加载训练好的model_GRU.pt逐窗口预测并输出整夜的分期序列。整个流程和训练时的__getitem__完全对称注意窗口滑动策略是步长为 1 的滑窗——每个 epoch 预测一次。def predict_one_file(model, data_path, seq_len20): epochs load_preprocessed_data(data_path) # 得到 (n_epochs, 3000) model.eval() preds [] with torch.no_grad(): for i in range(len(epochs) - seq_len 1): win epochs[i:i seq_len] win_tensor torch.FloatTensor(win).unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, 3000) logits model(win_tensor) pred logits.argmax(dim1).item() preds.append(pred) output_stages [INV_STAGE_MAP[p] for p in preds] return output_stages注意一个细节窗口滑到len(epochs) - seq_len 1就停了意味着开头seq_len-1个 epoch 没有对应的预测值。这在整夜分期里影响不大开头通常是清醒期 W但如果你的数据比较短比如只有 20 个 epoch滑窗后只得到 1 个预测。在临床场景中整夜数据通常有 800-1000 个 epoch这个问题可以忽略。如果你希望序列长度和预测结果长度一致可以在序列开头做 padding填充方式用首个 epoch 重复即可。6.2 Web 可视化server.py 和 sleepstaging.html 怎么串起来的项目里带了一个轻量级 Web 页面server.py基于 Flask模板是templates/sleepstaging.html。整个交互逻辑是用户在页面上传一段脑电数据文件后端调用predict.py里的预测函数把分期结果渲染成一张带状图——横轴是时间epoch 序号纵轴是分期类别不同颜色代表 W / N1 / N2 / N3 / R。对毕设答辩来说这个可视化比一张混淆矩阵直观得多。app.route(/predict, methods[POST]) def predict_api(): file request.files[eeg] tmp_path os.path.join(uploads, file.filename) file.save(tmp_path) stages predict_one_file(model, tmp_path) # 统计每个分期的占比和睡眠效率 sleep_efficiency (len(stages) - stages.count(W)) / len(stages) * 100 return render_template(sleepstaging.html, stagesstages, sleep_efficiencyround(sleep_efficiency, 1))后端逻辑不复杂但衍生了一个验证模型的好方法把预测出的分期序列按时间画出来和真实标注做视觉对比。肉眼能看出模型是否真的抓到了睡眠周期的宏观节律——一个正常成年人的整夜睡眠应该有 4-6 个 NREM/REM 循环N3 集中在夜间前段REM 主要出现在后半夜。如果预测结果里 N3 出现在起床前两小时那说明模型学到的东西和生理规律不符哪怕准确率指标不低也要警惕。最后一个建议拿到这份源码后先把run.sh从头到尾跑通一遍然后从train.py里把模型结构替换成 LSTM 再训练一轮对比 GRU 和 LSTM 在验证集上的 Kappa 差异。这个对比实验做完你对时序分类里 RNN 变体的理解会远超“会调包”的层面。重点不是哪个模型更好而是你能解释清楚为什么在你的数据上 GRU 收敛更快、LSTM 在小数据集上更稳——这些才是面试和答辩时真正拉开差距的东西。希望这篇拆解能帮你少走几步弯路把那 153 条整夜记录真正变成你自己的实验资产。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑