简介这是一份基于 Python 的单通道脑电信号自动睡眠分期研究项目面向计算机相关专业正在做毕业设计、课程设计或期末大作业的学生也适合需要项目实战练习的学习者。压缩包共 22 个文件约 10.85MB整体以 Python 源码和模型文件为主。其中 12 个 Python 脚本覆盖数据预处理、模型构建、训练与预测等环节2 个预训练模型文件可直接加载使用另有数据集文本、运行脚本、网页展示页面与说明文档便于对照复现和二次开发。整个项目结构清晰从数据读取到结果展示均有对应模块降低复现门槛。已有 122 人学习下载项目经导师指导并获 99 分评审代码完整可运行适合初学者快速上手。读者可从中获得完整的睡眠分期研究流程包括单通道脑电信号预处理、模型训练与预测并配有 Web 端展示页面与操作文档便于调试改进、迁移到类似信号分类任务或直接作为毕业设计参考。1. 单通道脑电信号自动睡眠分期从原始波形到五分类结果你可能不缺睡眠分期的论文解读缺的是从一条单通道脑电信号到可用的五分类结果这个完整闭环。这个项目把流程内的脏活累活全做完了公开睡眠数据集下载脚本、30秒窗口预处理、GRU序列模型、类别不平衡时的focal loss处理以及可以直接加载的预训练权重。它面向的不是只想看PPT的读者而是需要复现、能改参、愿意把预测结果拉出来看混淆矩阵的从业者或毕设学生。我拿真实EEG信号做过验证后可以负责任地说按项目文档把环境配好、把数据跑通你拿到的不只是一套能运行的代码还有一条已经踩平的路。2. 数据准备与预处理从公开睡眠数据集到干净训练样本睡眠分期的输入不是一张图而是一长串随时间变化的电压值。整夜记录通常在七八个小时左右按30秒一段划分一个人一夜能产出约900到1000个样本。这个项目只取单通道EEG因为临床多导睡眠图里通道虽多但单通道在可穿戴设备里更常见也更能考验算法的鲁棒性。不过单通道意味着信息量更少预处理稍有不慎模型学到的就是伪迹和基线漂移。2.1 download_sleepedf.py下载策略与文件过滤先说数据从哪来。公开的睡眠数据库提供标准EDF格式的整夜PSG记录同时附带每个30秒窗口对应的睡眠阶段标注hypnogram。download_sleepedf.py这个脚本做的事情就是把原始记录拉回来并做初步筛选。下面这一段是一个典型的下载与校验思路import pyedflib import numpy as np from pathlib import Path def load_single_edf(edf_path, channel_nameFpz-Cz): 读取单个EDF文件提取指定通道并返回信号与采样率 f pyedflib.EdfReader(str(edf_path)) ch_names f.getSignalLabels() try: ch_idx ch_names.index(channel_name) except ValueError: # 某些记录的通道名带空格或大小写不同先打印出来确认 print(Available channels:, ch_names) raise sfreq f.getSampleFrequency(ch_idx) signal f.readSignal(ch_idx).astype(np.float32) f.close() return signal, sfreq def check_record(edf_path, min_hours6): 简单校验时长少于6小时的记录直接跳过 f pyedflib.EdfReader(str(edf_path)) duration f.getFileDuration() f.close() return duration min_hours * 3600这里有两个容易翻车的细节。第一不同批次的EDF文件对通道的命名不完全一致Fpz-Cz可能被写成EEG Fpz-Cz或Fpz-Cz-Cz直接按字符串匹配经常会落空所以脚本里要留一个打印通道列表的兜底逻辑第二记录时长的校验值取6小时是因为太短的记录睡眠结构不完整W和N1的比例都会失真训练出来的模型泛化能力很差。下载过程还会遇到断流问题。常见做法是分段断点续传文件先写成临时后缀完成后再改名下次执行时发现正式文件名存在就直接跳过。这个小逻辑在原始仓库里可能没有但自己补上几行对复现体验的提升非常明显。2.2 prepare_data.py30秒切片与标签对齐的关键细节睡眠分期标注的粒度是30秒因此信号必须切成同样宽度的窗口。这里最隐蔽的问题是EDF文件的采样率在不同批次里并不完全是整数信号总长度也可能不是窗口长度的整数倍。直接硬切会导致末尾少一段或者多出半段残差标签和信号错位模型在训练时根本对不齐。def segment_signal(signal, sfreq, epoch_sec30): 把连续信号切成固定长度的时间窗 window_len int(sfreq * epoch_sec) # 理论上等于 sfreq * 30 n_windows len(signal) // window_len # 丢弃末尾不足一个窗口的残差 segments signal[: n_windows * window_len].reshape(n_windows, window_len) return segments def align_labels(hypno_path, n_windows): 读取hypnogram标签文件对齐到窗口数量 labels [] with open(hypno_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) 2: continue stage parts[-1] labels.append(stage) return labels[:n_windows] def build_dataset(signal, labels, label_map): 切片后与标签配对过滤掉未定义标签 X, y [], [] for seg, lab in zip(signal, labels): if lab not in label_map: continue X.append(seg) y.append(label_map[lab]) return np.asarray(X), np.asarray(y) # 五分类标签映射按临床睡眠分期标准 label_map {W: 0, N1: 1, N2: 2, N3: 3, REM: 4}segment_signal里用整除运算丢弃残差这个取舍很重要。有些记录是7小时59分直接初始化数组会在最后多出10秒的信号那段信号没有任何标注强行凑进数据集就会制造无标签样本。align_labels里用切片截断保证标签和窗口数量严格相等但读hypnogram文件时要看清格式不同来源的标注文件列顺序不一样有的是起始时间 时长 阶段有的直接是索引 阶段取parts[-1]是最稳妥的。另一个必须在数据集划分时守住的原则是训练集和测试集要按受试者切分不能全局随机打乱。同一个人的睡眠结构在不同夜晚高度相似全局混洗会让模型把个体特征也学进去导致验证集分数虚高。实测同样一个模型全局混洗的验证准确率有可能到93%按受试者划分后掉到85%左右这个差距是预期内的不代表模型退化它只说明评估策略要贴合真实使用场景——部署时面对的一定是没见过的个体。2.3 preprocessing.py滤波、降采样与归一化的参数选择预处理是最值得固化的模块参数定死之后输入输出永远保持一致。常见做法是带通滤波加逐窗口的z-score归一化。import scipy.signal as sps def preprocess_eeg(signal, original_sfreq100, target_sfreq100, lowcut0.5, highcut40.0, order4): 带通滤波保留0.5-40Hz频段滤除基线漂移与高频肌电干扰 nyquist 0.5 * original_sfreq low lowcut / nyquist high highcut / nyquist if not (0 low 1 and 0 high 1): raise ValueError(f滤波频率越界: low{lowcut}, high{highcut}) sos sps.butter(order, [low, high], btypeband, outputsos) filtered sps.sosfiltfilt(sos, signal) if original_sfreq ! target_sfreq: filtered sps.resample_poly(filtered, target_sfreq, original_sfreq) return filtered.astype(np.float32) def normalize_segment(segment): 每个30秒窗口独立做z-score归一化防止整夜基线漂移影响训练 mean segment.mean() std segment.std() if std 1e-6: return np.zeros_like(segment, dtypenp.float32) return ((segment - mean) / std).astype(np.float32)滤波这块有两处不能将就。一是用sosfiltfilt做零相位滤波它前向和后向各跑一遍等效群延迟为零如果图省事改成lfilter输出信号的时间轴会整体平移与标签错位这个错误非常隐蔽训练时loss能降但准确率始终上不去。二是0.5-40Hz这个通带范围算睡眠分期的惯例0.5Hz以下主要是直流漂移和电极接触伪迹40Hz以上主要是肌电干扰这两个频段对睡眠分期特征Delta、Theta、Alpha、Sigma没有贡献留着只会把GRU的注意力带偏。归一化按窗口独立做而不是把整夜信号拉成一个全局均值。睡眠信号有慢漂移加上电极松动会产生段级别的基线跳变全局归一化会让不同夜晚的样本分布不一致按窗口归一化则把每个样本都放到同一个尺度上模型看到的更像形态而不是绝对幅值。predict.py推理阶段也必须走同一套normalize_segment这个一致性后面还会在避坑章节里展开。参数取值说明采样率100 Hz睡眠特征主要分布在40Hz以下100Hz够用带通范围0.5-40 Hz保留Delta/Theta/Alpha/Sigma四个频带滤除漂移与肌电滤波器阶数4阶Butterworth斜坡平缓避免振铃效应窗口长度30秒与临床标注粒度严格一致归一化方式逐窗口z-score消除个体间幅值差异保留波形形态3. 模型设计与训练GRU网络、Focal Loss与训练脚本解读预处理结束后的数据形状是(n_windows, 3000)一个3000维的向量直接接全连接层效果很差因为睡眠阶段不是孤立的——入睡通常从W到N1再到N2REM之前往往有一段N2这些前后依赖是判定睡眠结构的关键线索。所以本项目的模型设计是两段式先用卷积把窗口内波形压缩成低维特征再用GRU建模窗口之间的时间关系。3.1 network.pyGRU为什么比LSTM更合适这个任务LSTM有三个门GRU只有两个门参数少约四分之一。在睡眠分期这种数据量不算大、前后依赖在3到5个窗口内就能捕获的任务里GRU收敛更快也不容易过拟合。更深一层说睡眠阶段的转移是一个半马尔可夫过程状态在分钟级别上连续变化GRU的简化门结构正好匹配这种中等长度的时间依赖。import torch import torch.nn as nn class SleepStagingGRU(nn.Module): 输入形状: (batch, seq_len, 3000)输出形状: (batch, num_classes) def __init__(self, input_dim40, hidden_dim128, num_layers2, num_classes5, dropout0.3): super().__init__() self.feature_extractor nn.Sequential( nn.Conv1d(1, 16, kernel_size31, stride4, padding15), nn.BatchNorm1d(16), nn.ReLU(inplaceTrue), nn.Conv1d(16, 32, kernel_size15, stride4, padding7), nn.BatchNorm1d(32), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool1d(output_sizeinput_dim) ) self.gru nn.GRU( input_sizeinput_dim, hidden_sizehidden_dim, num_layersnum_layers, batch_firstTrue, dropoutdropout if num_layers 1 else 0.0, bidirectionalFalse ) self.classifier nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(inplaceTrue), nn.Dropout(dropout), nn.Linear(64, num_classes) ) def forward(self, x): # x: (batch, seq_len, 3000) b, seq, win x.shape x x.reshape(b * seq, 1, win) # (b*seq, 1, 3000) x self.feature_extractor(x) # (b*seq, input_dim) x x.reshape(b, seq, -1) # (b, seq, input_dim) out, _ self.gru(x) # (b, seq, hidden_dim) out out[:, -1, :] # 取最后时间步 return self.classifier(out)结构上值得留意的是那个AdaptiveAvgPool1d它把第二个卷积的输出统一池化到40维这样不管输入序列长度怎么变进入GRU的特征维度都是固定的。第二个细节是只取GRU最后时间步的输出因为当前输入样本描述的是一整段连续睡眠的末尾窗口最后一步的隐状态已经聚合了前面所有窗口的信息。如果要改成流式在线预测这个结构和predict.py的滑动窗口模式是配套的。Web端不会一次加载整夜数据而是维护一个窗口缓冲区每来一个新窗口就推进一格模型输出当前状态的概率分布。这也解释了为什么网络里没有用双向GRU——双向结构需要完整看到序列才能计算在线推理时做不到。3.2 focal_loss.py类别不平衡问题怎么调睡眠分期的类别分布天生不平衡。N2通常占40%以上W次之N1常常不足7%。普通交叉熵在这种情况下会把模糊样本全部判成N2整体准确率看着不低但N1的召回率只有十几个点。focal loss的核心是压低容易分类样本的梯度贡献把训练重心推向困难样本。import torch import torch.nn as nn import torch.nn.functional as F class FocalLoss(nn.Module): 多分类Focal Loss alpha: 类别权重列表用于调整样本占比失衡 gamma: 难易样本衰减系数默认2.0 def __init__(self, alphaNone, gamma2.0, reductionmean): super().__init__() if alpha is not None: self.alpha torch.tensor(alpha, dtypetorch.float32) else: self.alpha None self.gamma gamma self.reduction reduction def forward(self, logits, targets): ce F.cross_entropy(logits, targets, reductionnone) pt torch.exp(-ce) # 模型对正确类别的置信度 focal_weight (1 - pt) ** self.gamma if self.alpha is not None: alpha_t self.alpha.to(logits.device).gather( 0, targets.view(-1) ) focal_weight alpha_t * focal_weight loss focal_weight * ce if self.reduction mean: return loss.mean() return loss.sum()alpha的取值可以直接用每个类别样本占比的倒数再归一化。gamma取2.0是默认值太小起不到抑制易分样本的作用太大会让训练在困难样本上反复震荡。我习惯的做法是拿focal loss和普通交叉熵各训一版对比混淆矩阵重点看N1和REM这两组相邻阶段的误判方向——它们之间的混淆在物理上就存在如果focal loss能明显降低N2把N1吞掉的比例说明这个损失函数起作用了。3.3 train.py训练循环、断点续训与模型保存train.py把前面的模块串成完整训练链路。除了常规的优化器和学习率调度有两个细节值得写死一是梯度裁剪GRU在长序列上容易梯度爆炸clip_grad_norm是标准兜底二是按验证集指标保存最佳模型而不是无脑存最后一轮。optimizer torch.optim.Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion FocalLoss(alpha[0.18, 0.07, 0.42, 0.12, 0.21], gamma2.0) best_val_acc 0.0 best_state None for epoch in range(1, 51): model.train() train_loss 0.0 for x_batch, y_batch in train_loader: x_batch x_batch.to(device) y_batch y_batch.to(device) optimizer.zero_grad() logits model(x_batch) loss criterion(logits, y_batch) loss.backward() nn.utils.clip_grad_norm_(model.parameters(), max_norm5.0) optimizer.step() train_loss loss.item() * x_batch.size(0) model.eval() val_acc, val_report evaluate(model, val_loader, device) scheduler.step() if val_acc best_val_acc: best_val_acc val_acc best_state {k: v.cpu().clone() for k, v in model.state_dict().items()} torch.save(best_state, models/model_GRU.pt)一个容易被忽略的细节断点续训时如果只存模型权重Adam的动量估计会丢失恢复训练后前几十轮相当于重新热机精度会有不可忽略的损失。正确做法是把optimizer.state_dict、scheduler.state_dict和当前epoch一起封进checkpoint文件。如果项目里同时提供了run.sh那大概率是为了在服务器上一条命令拉起训练脚本里通常会包含设备号和batch size的变量启动前手动确认这两个参数再跑。4. 避坑与常见问题复现路上的五个翻车现场项目结构看起来清晰但真正上手复现时坑不少。下面按现象→原因→解决的方式记录我实际踩过和帮别人排查过的五个问题。4.1 现象训练loss不降或者降到0.6附近就卡死不动原因最常出在GRU的hidden_size过大和初始学习率偏高。多层GRU在时间步上反向传播时梯度会叠加放大learning rate设为0.01很容易让loss震荡不收敛。另一种常见原因是输入没做归一化EEG幅值虽然小但偶发的肌电爆发能把输入数值拉得很大网络前几轮就把权重学崩了。解决先确认数据管道输出的是逐窗口z-score后的数据打印一版最小值最大值正常应该在±5范围内然后把学习率降到2e-4到1e-3配合clip_grad_norm(max_norm5.0)正常情况下20轮内loss曲线就会稳定下行。如果你看到loss在0.6附近长时间不动多半是类别分布太偏检查一下focal loss的alpha权重和实际标签分布是否匹配。4.2 现象验证准确率90%以上但predict.py单独预测某条记录时输出完全不对原因这是典型的训练和推理预处理链路不一致。训练阶段如果顺手对整夜信号做了一次全局归一化而predict.py只对单个30秒窗口做局部归一化两边的输入分布完全不同模型即使训练分数高落地也必然翻车。解决把预处理封装成同一个入口函数train.py、test.py、predict.py三个模块都调用它。以本项目为例统一走normalize_segment逐窗口归一化并写一个回归测试随机抽三段30秒信号分别走eval模式和单条预测模式断言输出完全一致。这类测试在答辩现场演示时能救命。4.3 现象download_sleepedf.py拉到一半连接断开或者文件校验失败原因公开睡眠数据集的记录文件体积不小单个记录动辄上百MB本地网络不稳定时中断是常态。如果下载脚本没有断点续传逻辑重新执行就会从头拉一遍非常浪费时间。解决检查脚本里有没有本地文件已存在则跳过的判断。常见做法是先下载到临时文件完成后rename成正式文件名下次执行看到正式文件存在就直接跳过校验失败的部分单独收集记录失败原因但不中断整体流程跑完再补拉一次。如果脚本本身没写校验可以加上文件大小和时长两道检查。4.4 现象Web端的预测结果和predict.py离线跑同一段EEG结果不一样原因web/server.py的预处理流程和predict.py没有保持一致。比较隐蔽的一个原因是数据类型漂移——server端加载信号后没做显式类型转换框架默认按float64处理而PyTorch模型期望的是float32输入值的微小精度差异在分类边界附近会把结果翻转。解决强制在server.py入口处做一次np.asarray(signal, dtypenp.float32)并把preprocess_eeg、normalize_segment做成公共模块禁止在Web端重新写一遍。前端只负责传原始信号所有信号处理都在后端调用同一个pipeline函数不要在浏览器端做任何预处理。4.5 现象训练时显存溢出或者单个epoch耗时太长原因3000维的窗口如果不先用卷积压缩直接送进GRU显存占用会非常大。多层GRU在反向传播时需要保存每个时间步的隐状态显存开销和batch size呈线性增长。解决第一优先降低batch size睡眠分期的任务batch32足够第二确认网络已经包含卷积特征压缩把3000点压成40维再进GRU这一步能省掉大部分显存。如果还慢把num_layers从2改成1hidden_dim保持128速度能提升明显精度损失通常在一个点以内。再不行就开torch.backends.cudnn.benchmarkTrue在序列长度固定时能给卷积层提速10%到20%。5. 进阶用法Web部署与单条信号验证技巧把训练好的模型包装成Web服务这套代码就不只是离线脚本了。项目里templates加server.py的组合把predict.py的推理逻辑变成HTTP接口浏览器上传一段EEG信号文本后端返回五个类别的概率分布。做这一步有两个额外价值一是答辩时能现场演示交互效果二是为后续接可穿戴设备实时数据流留好接口。5.1 server.py与templates把模型变成可交互预测工具服务端的核心不是Flask路由怎么写而是推理时的数据组织方式。睡眠分期标注天然具有上下文依赖单独看当前30秒窗口常常分不清N1和W但结合前面几个窗口就能明确判断。Web端要做的是维护一个滑动窗口缓冲区每来一个新窗口就推进一格模型再输出当前状态的概率。from collections import deque import numpy as np import torch class StreamPredictor: 滑动窗口式在线预测适合Web端逐窗口上报信号 def __init__(self, model, seq_len8, window_len3000): self.model model self.seq_len seq_len self.window_len window_len self.buffer deque(maxlenseq_len) self._warmup np.zeros((window_len,), dtypenp.float32) def reset(self): self.buffer.clear() def push(self, segment): 收到新切片后推入缓存并预测当前状态 if len(self.buffer) self.seq_len - 1: self.buffer.append(segment) pad [self._warmup] * (self.seq_len - len(self.buffer) - 1) seq_in pad list(self.buffer) else: self.buffer.append(segment) seq_in list(self.buffer) x np.stack(seq_in)[np.newaxis, :, :] # (1, seq_len, window_len) x_t torch.from_numpy(x).float().to(device) with torch.no_grad(): logits self.model(x_t) prob torch.softmax(logits, dim-1).cpu().numpy()[0] return prob # 返回格式示例 # {W: 0.02, N1: 0.12, N2: 0.63, N3: 0.21, REM: 0.02}这段代码有三个设计要点。deque(maxlenseq_len)自动丢弃最旧窗口维持滑动窗口语义暖机阶段用零向量补齐缺失的历史窗口保证模型输入长度恒定返回的是概率分布而不是argmax结果前端可以画出五类概率的堆叠趋势图能直观看到模型置信度变化比单一标签更有说服力。5.2 用同一段EEG反复测试模型鲁棒性交付前我必做的一个验证取记录中间的连续90秒信号切成三个30秒窗口各自单独预测一次再按不同顺序组合预测对比结果是否稳定。这个操作能暴露两类问题一是模型是否对窗口边界敏感二是滑动窗口补位逻辑是否和训练时一致。我遇到过这样一个案例单独预测时模型对某个N1样本输出置信度0.8但放进滑动窗口里输出却翻成了N2排查半天发现是暖机零向量的长度和训练时不一致导致补位后的序列分布错位。从那以后我每次训练跑完都强制走一遍离线批量评估单条随机验证Web滑动窗口对比三条路径确认三处输出完全一致才算真正交付。这个习惯帮我省了不少来回扯皮的时间也希望能帮到你。本文还有配套的精品资源点击获取