简介:这是一份基于 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_name="Fpz-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_hours=6): """简单校验:时长少于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.py:30秒切片与标签对齐的关键细节
睡眠分期标注的粒度是30秒,因此信号必须切成同样宽度的窗口。这里最隐蔽的问题是:EDF文件的采样率在不同批次里并不完全是整数,信号总长度也可能不是窗口长度的整数倍。直接硬切会导致末尾少一段或者多出半段残差,标签和信号错位,模型在训练时根本对不齐。
def segment_signal(signal, sfreq, epoch_sec=30): """把连续信号切成固定长度的时间窗""" 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", encoding="utf-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_sfreq=100, target_sfreq=100, lowcut=0.5, highcut=40.0, order=4): """带通滤波,保留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], btype="band", output="sos") 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, dtype=np.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再到N2,REM之前往往有一段N2,这些前后依赖是判定睡眠结构的关键线索。所以本项目的模型设计是两段式:先用卷积把窗口内波形压缩成低维特征,再用GRU建模窗口之间的时间关系。
3.1 network.py:GRU为什么比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_dim=40, hidden_dim=128, num_layers=2, num_classes=5, dropout=0.3): super().__init__() self.feature_extractor = nn.Sequential( nn.Conv1d(1, 16, kernel_size=31, stride=4, padding=15), nn.BatchNorm1d(16), nn.ReLU(inplace=True), nn.Conv1d(16, 32, kernel_size=15, stride=4, padding=7), nn.BatchNorm1d(32), nn.ReLU(inplace=True), nn.AdaptiveAvgPool1d(output_size=input_dim) ) self.gru = nn.GRU( input_size=input_dim, hidden_size=hidden_dim, num_layers=num_layers, batch_first=True, dropout=dropout if num_layers > 1 else 0.0, bidirectional=False ) self.classifier = nn.Sequential( nn.Linear(hidden_dim, 64), nn.ReLU(inplace=True), 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, alpha=None, gamma=2.0, reduction="mean"): super().__init__() if alpha is not None: self.alpha = torch.tensor(alpha, dtype=torch.float32) else: self.alpha = None self.gamma = gamma self.reduction = reduction def forward(self, logits, targets): ce = F.cross_entropy(logits, targets, reduction="none") 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(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) criterion = FocalLoss(alpha=[0.18, 0.07, 0.42, 0.12, 0.21], gamma=2.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_norm=5.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_norm=5.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, dtype=np.float32),并把preprocess_eeg、normalize_segment做成公共模块,禁止在Web端重新写一遍。前端只负责传原始信号,所有信号处理都在后端调用同一个pipeline函数,不要在浏览器端做任何预处理。
4.5 现象:训练时显存溢出,或者单个epoch耗时太长
原因:3000维的窗口如果不先用卷积压缩,直接送进GRU,显存占用会非常大。多层GRU在反向传播时需要保存每个时间步的隐状态,显存开销和batch size呈线性增长。
解决:第一优先降低batch size,睡眠分期的任务batch=32足够;第二确认网络已经包含卷积特征压缩,把3000点压成40维再进GRU,这一步能省掉大部分显存。如果还慢,把num_layers从2改成1,hidden_dim保持128,速度能提升明显,精度损失通常在一个点以内。再不行就开torch.backends.cudnn.benchmark=True,在序列长度固定时能给卷积层提速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_len=8, window_len=3000): self.model = model self.seq_len = seq_len self.window_len = window_len self.buffer = deque(maxlen=seq_len) self._warmup = np.zeros((window_len,), dtype=np.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(maxlen=seq_len)自动丢弃最旧窗口,维持滑动窗口语义;暖机阶段用零向量补齐缺失的历史窗口,保证模型输入长度恒定;返回的是概率分布而不是argmax结果,前端可以画出五类概率的堆叠趋势图,能直观看到模型置信度变化,比单一标签更有说服力。
5.2 用同一段EEG反复测试模型鲁棒性
交付前我必做的一个验证:取记录中间的连续90秒信号,切成三个30秒窗口,各自单独预测一次,再按不同顺序组合预测,对比结果是否稳定。这个操作能暴露两类问题:一是模型是否对窗口边界敏感,二是滑动窗口补位逻辑是否和训练时一致。
我遇到过这样一个案例:单独预测时模型对某个N1样本输出置信度0.8,但放进滑动窗口里输出却翻成了N2,排查半天发现是暖机零向量的长度和训练时不一致,导致补位后的序列分布错位。从那以后我每次训练跑完,都强制走一遍"离线批量评估+单条随机验证+Web滑动窗口对比"三条路径,确认三处输出完全一致才算真正交付。这个习惯帮我省了不少来回扯皮的时间,也希望能帮到你。
本文还有配套的精品资源,点击获取