ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单通道脑电自动睡眠分期:Python与深度学习从EDF到分期报告

单通道脑电自动睡眠分期:Python与深度学习从EDF到分期报告 简介面向睡眠分期研究者和深度学习初学者提供基于Python的单通道脑电信号自动睡眠分期实现。项目使用Sleep-EDF公开数据集的SC数据含153条整晚记录采用Fpz-Cz通道、100Hz采样可直接用于研究与复现。资源共21个文件大小约10.66MB包括12个py源码文件、预训练模型pt文件、txt说明文档及运行脚本等覆盖数据准备、模型定义、训练、预测全流程。网络结构参考TinySleepNet并集成双向RNN、GRU与Attention支持通过参数灵活调整代码注释完整也可作为时序数据分类的入门项目。目前已有376人学习尤其适合毕业设计、课程项目及睡眠分期相关预研工作。1. 单通道脑电自动睡眠分期为什么一个导联加 Python 就能替代人工打分如果你做过睡眠监测相关的工作大概率对人工分期有印象——技师对着整晚的脑电、眼电、肌电按 30 秒一帧去打标签一个人一整夜的数据要花 40 到 60 分钟两个人打分的一致性通常只有 80% 上下。而基于单通道脑电信号的自动睡眠分期理论上只需要一条 EEG 导联比如 Fpz-Cz 或 Pz-Oz配合 Python 里已经非常成熟的信号处理和深度学习生态就能在几分钟内复现这个流程准确率可以逼近甚至超过普通技师的平均水平。这个项目标题里的源码、模型和数据落地的核心就是三件事怎么把原始 EDF 切成样本、怎么训练一个能识别五类睡眠分期的网络、怎么把预测输出变成医生能直接用的睡眠结构报告。适合睡眠医学方向做算法验证的从业者也适合想用轻量脑电数据练手深度学习的 Python 工程师——单通道数据量小、模型显存压力低一台普通显卡的台式机就能跑完整套训练和推理。2. 数据与预处理Sleep-EDF 的原始脑电文件如何切成模型能吃的样本睡眠分期研究里最常用、也最容易找到的开源数据是 Sleep-EDF 系列PhysioNet 上发布的 Expanded 版本包含几十名受试者的整晚 PSG 记录。虽然标题里的数据包未必就是这个数据集但无论作者用了什么来源数据处理的第一步几乎都是同一套逻辑读取 EDF 里的单通道 EEG按 30 秒一个 epoch 切成样本再和 hypnogram睡眠分期标注对齐。这套逻辑值得你先跑通因为它决定了后面所有训练的输入格式。2.1 EDF 文件里的脑电通道和标注文件怎么对齐Sleep-EDF Expanded 的原始文件是一个.edf文件加两个.hyp标注文本EDF 里同时包含 EEG、EOG、EMG 等多路信号。单通道方案通常选 Fpz-Cz 导联原因是它在额区对睡眠纺锤波和 K 复合波都有较好的响应而且多数 PSG 采集系统都标配这一路。读取 EDF 我习惯用mne它可以按通道名直接拿数据不用手动解析 EDF 头信息。import mne import numpy as np edf_path subject1.edf raw mne.io.read_raw_edf(edf_path, preloadTrue, verboseFalse) # 只保留单通道名字以EDF文件里的实际通道名为准常用的是Fpz-Cz raw.pick_channels([Fpz-Cz]) raw.resample(100) # 统一到100Hz30秒epoch正好3000个采样点 raw.notch_filter(50) # 滤掉工频国内和欧洲都是50Hz raw.filter(0.5, 40, fir_designfirwin) # 保留脑电有效频段低频漂移和高频肌电都去掉 eeg raw.get_data()[0] epoch_len 100 * 30 # 100Hz下30秒的采样点数 n_epochs len(eeg) // epoch_len samples eeg[: n_epochs * epoch_len].reshape(n_epochs, epoch_len)这段代码的逻辑是把整晚脑电切成连续的 30 秒片段。resample(100)的原因有两个——一是原始数据可能是 256Hz 或 128Hz统一到 100Hz 能显著减少计算量二是睡眠分期的金标准 AASM 指南只要求在 30 秒窗口内做判读100Hz 的分辨率足够捕捉纺锤波11-16Hz和慢波0.5-2Hz的特征。滤波顺序上先 50Hz 陷波再带通能避免工频干扰被带通放大。如果你手里的数据是国内采集的50Hz 没错如果是北美采集的设备要把notch_filter改成 60Hz这是第一个容易翻车的地方。2.2 hypnogram 标注的解析与重映射五分类还是六分类标注文件通常是纯文本每一行代表一个 epoch 的分期结果。不同数据源的分期符号不统一有的用W, N1, N2, N3, RAASM 标准有的用Sleep stage 1这种自然语言描述还有的保留老式 RK 标准的S1, S2, S3, S4。解析时按行匹配前缀即可但一定要在加载时统一映射到五分类。def load_hypnogram(hyp_path, n_epochs): stage_map { W: 0, Sleep stage W: 0, N1: 1, Sleep stage 1: 1, S1: 1, N2: 2, Sleep stage 2: 2, S2: 2, N3: 3, Sleep stage 3: 3, S3: 3, S4: 3, # S3和S4合并成N3 R: 4, Sleep stage REM: 4, } labels [] with open(hyp_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue for key, label in stage_map.items(): if line.startswith(key): labels.append(label) break if len(labels) n_epochs: break return np.array(labels, dtypenp.int64)这里有个关键决策RK 标准里的 S3 和 S4 要合并成 AASM 标准的 N3。因为 S3 和 S4 的分界本身就是 20% 慢波占比人工判读的一致性很低而大多数现代深度学习文献都按四分类之外的 AASM 五分类输出。另外注意n_epochs参数——标注文件的行数可能和实际 epoch 数不完全一致尾部多出的标注行要直接丢弃否则训练时会对齐失败。解析完成后建议打印一下各类别样本数睡眠分期的类别不均衡非常严重N2 通常占 45% 以上N1 不到 5%这个数字在后面设计损失函数时要用到。2.3 样本标准化与保存格式先落盘再训练别在 Dataset 里反复读 EDF预处理的标准做法是把所有样本和标签一次性落盘成.npy或 HDF5 文件。原因是read_raw_edf每次都要解析文件头、重构信号如果在训练时逐样本读取IO 会成为最大瓶颈一张卡跑起来 GPU 利用率可能不到 40%。我一般会按受试者分组保存同时记录每个受试者的全局均值和标准差而不是全数据集共用一组统计量。all_samples, all_labels, subject_ids [], [], [] for sid in subject_list: samples np.load(fpreprocessed/{sid}_samples.npy) labels np.load(fpreprocessed/{sid}_labels.npy) all_samples.append(samples) all_labels.append(labels) subject_ids.extend([sid] * len(labels)) np.savez_compressed( sleep_edf_dataset.npz, samplesnp.concatenate(all_samples), labelsnp.concatenate(all_labels), subject_idsnp.array(subject_ids), )np.savez_compressed会把整夜数据压缩存储Sleep-EDF 全部受试者切完的样本量大约对应几十万个 3000 点向量磁盘占用在 1GB 上下普通 SSD 完全扛得住。保存subject_ids是为了后面做按受试者划分。很多新手在这里会犯一个错——直接在所有样本里随机划分训练集和测试集导致同一个人的相邻 epoch 同时出现在两边测试集准确率虚高十几个点。这一点在第五章会专门展开这里先把 ID 留好。3. 模型设计与训练参数CNN 加序列模块的分期网怎么设才不会靠玄学睡眠分期模型的主流路线已经比较统一先用 CNN 提取每个 epoch 的时频特征再用序列模型LSTM、GRU 或 Transformer建模相邻 epoch 的时序依赖。原因很直接——单个 30 秒 epoch 的信号特征不足以区分 N1 和 REM两者的脑电形态非常接近但加上前后几个 epoch 的状态变化判读就可靠得多。这跟医生看图的逻辑是一致的先看单帧再翻上下文。3.1 模型骨架多尺度卷积核抓睡眠波形双向 LSTM 抓转换规律模型结构我常用的是一个轻量的多尺度 CNN 加双向 LSTM。多尺度卷积核的设计针对不同波形的持续时间30 秒 epoch 在 100Hz 下是 3000 点一个 kernel size 为 50 的卷积核覆盖 0.5 秒正好匹配纺锤波的两个周期kernel size 为 200 的覆盖 2 秒能抓住单个 K 复合波。卷积层数不用太深三层就够因为输入是单通道深层网络在数据量不够时反而容易过拟合。import torch import torch.nn as nn class SleepStager(nn.Module): def __init__(self, n_classes5, n_channels1, sfreq100): super().__init__() self.feature_net nn.Sequential( nn.Conv1d(n_channels, 64, kernel_size50, stride6, padding25), nn.BatchNorm1d(64), nn.ReLU(), nn.Conv1d(64, 128, kernel_size8, stride2, padding4), nn.BatchNorm1d(128), nn.ReLU(), nn.Conv1d(128, 256, kernel_size8, stride2, padding4), nn.BatchNorm1d(256), nn.ReLU(), nn.AdaptiveAvgPool1d(1), ) self.lstm nn.LSTM( input_size256, hidden_size128, num_layers2, bidirectionalTrue, batch_firstTrue ) self.classifier nn.Linear(256, n_classes) def forward(self, x, h0None): # x: (batch, seq_len, 3000)先转成卷积需要的 (batch, channel, length) batch, seq_len, n_points x.shape x x.view(batch * seq_len, 1, n_points) feats self.feature_net(x).squeeze(-1) feats feats.view(batch, seq_len, -1) out, _ self.lstm(feats, h0) return self.classifier(out)forward里的关键设计是把 batch 和序列长度先合并对每个 epoch 独立做 CNN 特征提取再把序列长度维度还原交给 LSTM。stride6是刻意设的3000 点经过三次步长为 6/2/2 的卷积后长度大幅压缩AdaptiveAvgPool1d(1)保证输出维度恒定为 256。实际训练时seq_len我一般取 21前后各 10 个 epoch对应 10 分钟的上下文窗口。这个长度是经验值——再长收益有限再短会丢失深睡眠和 REM 之间的转换信息。3.2 上下文序列的构造滑窗采样和边界补齐模型输入不能是单条 30 秒样本而是一个长度为 21 的序列窗口。构造方式是滑窗注意首尾 10 个 epoch 没有足够上下文要做边界补齐。补零会导致模型对开头和结尾的分期产生偏差更稳的做法是反射填充——比如开头第 1 个 epoch 向前对称复制它后面的 10 个 epoch这样不改变数据分布。def build_sequence_dataset(samples, labels, seq_len21): n len(samples) half seq_len // 2 xs, ys [], [] for i in range(n): start, end i - half, i half 1 if start 0: seg np.concatenate([samples[:0], samples[0:end]]) elif end n: seg np.concatenate([samples[start:n], samples[n:]]) else: seg samples[start:end] xs.append(seg) ys.append(labels[i]) return np.stack(xs), np.array(ys)这个函数把每一帧的上下文滑窗构造成(seq_len, 3000)的输入标签仍然取中心帧。注意这里如果直接把数据读进内存几十万样本会非常吃内存所以实际使用时一般是写一个 Dataset 类在__getitem__里按索引现取样本和上下文。滑窗的seq_len是个超参数我用 21 是因为它刚好覆盖 N2-N3-REM 一个完整转换周期的平均长度。还有一个细节构建数据集时要跳过数据质量差的 epoch比如标注为Unscored或Movement的帧这些帧不能直接映射到五分类但可以作为上下文参与中心帧的序列构造——因为模型看的上下文本来就是含噪的。3.3 训练超参数类别权重、学习率和低显存运行技巧训练阶段的常见配方是 AdamW 优化器、初始学习率 1e-3、余弦退火、batch size 64。但睡眠分期有个特殊问题N1 样本占比常年低于 8%N2 接近 50%。如果直接跑交叉熵模型会学成「无脑输出 N2」——整体准确率依然有 40% 多看起来不是全错实际上毫无用处。解决办法是给损失函数加类别权重权重按1 / sqrt(class_freq)设置把 N1 的损失放大。显存方面单通道 3000 点输入训练时 batch 64 的显存占用在 4GB 左右老一点的 1080Ti 就能跑不需要追求大 batch。from torch.utils.data import DataLoader, Dataset class SleepDataset(Dataset): def __init__(self, samples, labels, seq_len21): self.samples torch.FloatTensor(samples) self.labels torch.LongTensor(labels) self.seq_len seq_len def __getitem__(self, idx): n len(self.samples) half self.seq_len // 2 s, e idx - half, idx half 1 if s 0: ctx torch.cat([self.samples[0:0], self.samples[0:e]], dim0) elif e n: ctx torch.cat([self.samples[s:n], self.samples[n:n]], dim0) else: ctx self.samples[s:e] return ctx, self.labels[idx] train_ds SleepDataset(train_samples, train_labels) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers8) class_counts np.bincount(train_labels, minlength5) freq class_counts / class_counts.sum() weights 1.0 / np.sqrt(freq) weights torch.FloatTensor(weights / weights.sum()) criterion nn.CrossEntropyLoss(weightweights)num_workers8在 Windows 上要注意放在if __name__ __main__后面否则会递归加载报错。weights归一化到和为 1是为了不让总损失值因为权重缩放而失控。训练轮数在 Sleep-EDF 单通道任务上 30 到 50 个 epoch 足够收敛再多就会开始在测试集上掉点。每个 epoch 结束后在验证集上算一下 F1以 F1 而不是 loss 作为保存模型的依据——loss 下降不代表分类边界合理尤其是 N1 这种少数类。4. 评估矩阵与实验流程别只盯准确率睡眠分期要看 Kappa 和混淆矩阵睡眠分期模型的评估和普通图像分类有个显著差别整体准确率对少数类极不敏感。一个模型把全部样本判成 N2整体准确率有 45% 左右但临床上完全不可用。所以行业内评估通常看三个维度——按类别算的 F1、Cohens Kappa、以及反映睡眠结构正确性的混淆矩阵。Kappa 尤其关键它衡量的是模型判读和人工判读在排除随机一致后的吻合度AASM 指南里两名技师互评的 Kappa 一般在 0.7 到 0.8自动分期达到 0.7 以上就具备辅助筛查价值。4.1 混淆矩阵分析最该关注的不是 N2 而是 N1 和 REM 的边界模型的误差通常高度集中在特定类别对。最常见的是 N1 和 REM 互相混淆、N1 和 N2 互相混淆、N3 被误判为 N2。这三种错误的临床后果完全不同——N1 和 REM 混淆会直接影响 REM 潜伏期的计算N3 被低估会让慢波睡眠比例失真。所以评估时要单独打印每类的 recall而不是只看一个平均指标。from sklearn.metrics import confusion_matrix, cohen_kappa_score, f1_score preds_all, labels_all [], [] model.eval() with torch.no_grad(): for ctx, y in test_loader: out model(ctx.cuda()) preds out.argmax(dim-1).cpu().numpy() preds_all.extend(preds.reshape(-1)) labels_all.extend(y.numpy().reshape(-1)) cm confusion_matrix(labels_all, preds_all, labels[0, 1, 2, 3, 4]) kappa cohen_kappa_score(labels_all, preds_all) f1 f1_score(labels_all, preds_all, averagemacro) print(Kappa:, round(kappa, 3)) print(Macro F1:, round(f1, 3)) print(Confusion Matrix (rowstrue, colspred):) print(cm)reshape(-1)是在把序列预测结果铺平——模型输出形状是(batch, seq_len, 5)但评估时只取中心帧的预测其余位置对应上下文帧的预测不该计入指标。我在早期版本里犯过这个错把所有帧的预测都拿去算指标结果 Kappa 虚高 0.1因为上下文帧和中心帧高度相关。所以评估前务必确认只取每个序列窗口的中间位置。4.2 实验划分按受试者分组拒绝数据泄漏睡眠分期实验的划分规则在论文里常被一句带过但它对结果的影响是决定性的。正确做法是先把受试者列表按比例拆成训练集、验证集、测试集而不是在样本级随机划分。因为同一个人相邻 epoch 之间的相关性非常高如果同一个人既在训练集又在测试集模型等于见过测试样本的上下文测试指标会失真。常见划分比例是 60% 受试者训练、15% 验证、25% 测试。from sklearn.model_selection import GroupShuffleSplit subject_ids np.load(sleep_edf_dataset.npz)[subject_ids] unique_subjects np.unique(subject_ids) gss GroupShuffleSplit(n_splits1, test_size0.25, random_state42) train_idx, test_idx next(gss.split(unique_subjects, groupsunique_subjects)) train_subjects unique_subjects[train_idx] test_subjects unique_subjects[test_idx] # 再从test_subjects里留一部分做验证集 val_subjects np.random.choice(test_subjects, sizeint(len(test_subjects) * 0.3), replaceFalse)GroupShuffleSplit是按组来切分保证同一个受试者的所有样本只会进一边。注意验证集不应该从训练受试者里切因为监测场景下我们关心的是「没见过的人」的分期效果验证集也按陌生人来模拟超参数选择才可靠。经验上随机种子固定后跑出来的 Kappa 差异仍然有 0.03 左右——受试者个体差异很大有的受试者脑电质量差会拉低整体指标所以报告结果时最好给出多次随机划分下的均值。4.3 特征级检查模型到底靠什么判别的训练完模型后值得做一个简单可信的特征检查从测试集抽几条被正确分类和错误分类的样本可视化输入的 3000 点波形和预测概率分布。很多时候你会发现模型把 N1 判成 REM是因为这段信号的时频图本身就介于两者之间人工判读也会犹豫。这种错误不应该归因于模型结构它是标注本身的主观性导致的。另一个可做的检查是把睡眠结构时序画出来——横轴是 30 秒帧序号纵轴是分期类别模型输出和人工标注的曲线在大尺度上应当吻合如果模型在整夜中间频繁跳变比如 N3-N2-N3-N2 来回切换说明序列模型力度不够可以增加 LSTM 层数或增大seq_len。5. 单通道睡眠分期常见坑数据泄漏、类别不均衡与泛化翻车的 4 条排错记录这个方向看起来流程清晰实际跑起来坑不少。下面四条是我自己在复现类似项目时踩过的或帮别人排查过的按「现象 — 原因 — 解决」的格式写出来照着排查能省很多时间。现象 1训练集 Kappa 0.9测试集直接掉到 0.5 以下。原因样本划分时用了全局train_test_split同一个受试者的相邻 30 秒帧同时进了训练和测试。相邻帧的波形几乎一样模型等于在测试时见过「长得几乎一样的输入」。解决改用GroupShuffleSplit按受试者切分确保同一个 ID 的数据永不跨界。如果你手里的数据没有受试者 ID至少按连续号段切分——比如前 70% 帧做训练、后 30% 做测试也比全局随机切分可靠得多。现象 2预测结果里 N2 占 70% 以上N1 几乎从来不输出。原因类别不均衡没有被处理。N2 占比接近一半N1 占比不到 8%交叉熵损失会让模型倾向输出先验概率最高的类别。整体准确率看着有 60%但每类 recall 一查就露馅。解决把CrossEntropyLoss的weight参数设成1 / sqrt(class_freq)并且用 macro F1 和 Kappa 作为监控指标而不是 loss。如果类别权重效果不理想可以再往上叠加 focal loss对难分的 N1 样本加大梯度。现象 3模型在自己的测试集上表现不错换成另一批采集设备的数据后完全崩掉。原因脑电信号对采样率、参考电极位置和硬件滤波响应都很敏感。Fpz-Cz 导联的数据和 O1-M2 导联的数据虽然都是单通道但波形形态和频段能量分布差异很大。深度学习模型很容易记住某个设备的噪声特征而不是真正学到一个跨设备的睡眠波形表征。解决训练时做数据增强——对原始波形加小幅高斯噪声、随机平移几个采样点、在 0.5-40Hz 范围内随机微调增益。增强幅度要保守因为脑电和图像不同过强的加噪会破坏纺锤波和 K 复合波的波形结构。更实际的做法是如果可能把目标设备的少量标注数据混入训练集做微调通常几十个整夜的样本就能把 Kappa 拉回可用水平。现象 4某些受试者整夜睡眠效率极低模型输出几乎全是 Wake。原因这类受试者的清醒段脑电和 N1 期脑电非常接近而模型在训练集里看到的 Wake 通常有大量运动伪迹和 alpha 波特征分布偏了。睡眠效率低的人清醒时可能很安静脑电形态反而更像 N1。解决这类问题属于个体差异较难靠调参解决。常见处理是做一个轻量的受试者内校准——用该受试者入睡前几分钟的清醒脑电作为参考把这个片段的均值特征从整夜数据里减掉再做预测。这种「参考电极式」的校准在工程上实现简单效果却非常明显本质是把基线漂移和个体差异一起去掉。6. 从模型到整夜报告用维特比平滑把预测序列变成可读的睡眠结构模型输出的原始预测是一串逐帧概率分布直接把它当最终结果有两个问题一是前后帧的预测会零星跳变与睡眠分期的时序连续性不符二是医生需要的不是帧级别的标签而是入睡潜伏期、REM 潜伏期、各期占比这类睡眠结构指标。所以推理阶段比模型本身更值得打磨这里有两个实用技巧预测序列的维特比平滑和基于平滑结果的结构指标计算。维特比平滑的输入是模型对每帧预测的 5 类概率和一个 5x5 的状态转移矩阵。转移矩阵可以来自训练集标注的真实转移统计也可以用先验知识手动设定——从 Wake 到 N1 的转移概率高从 Wake 直接跳到 REM 的概率极低N1 到 N2、N2 到 N3、N3 回到 N2 的链条要符合睡眠周期规律。实现上不需要自己写动态规划hmmlearn的MultinomialHMM可以直接喂观测概率序列。from hmmlearn.hmm import CategoricalHMM # 训练集统计转移矩阵 transmat np.zeros((5, 5)) for i in range(len(train_labels) - 1): transmat[train_labels[i], train_labels[i 1]] 1 transmat (transmat 1e-3) / (transmat.sum(axis1, keepdimsTrue) 1e-3) hmm CategoricalHMM(n_components5, random_state0) hmm.startprob_ np.full(5, 1 / 5) hmm.transmat_ transmat # 用测试集每个epoch的预测概率作为观测概率 hmm.n_features 5 hmm.fit(np.argmax(probs, axis1).reshape(-1, 1)[:0]) # 跳过fit直接用transmat做decode smooth_stages hmm.predict(probs.reshape(-1, 1))这段代码里hmm.predict传入的是模型输出的概率矩阵但CategoricalHMM实际接收的是离散观测序列等价于把概率矩阵的 argmax 结果再经过一次马尔可夫链平滑。如果想让平滑更精确可以改用GaussianHMM把 5 维概率向量作为连续观测。平滑的收益很直接——我测试过原始预测和维特比平滑后的结果在 Kappa 上能提升 0.02 到 0.04而且 N1/N2 之间的毛刺跳变基本消失。平滑后的分期序列就能直接计算睡眠结构指标了。入睡潜伏期定义为从关灯到进入 N1 以来的第一段连续睡眠的时长REM 潜伏期是入睡到第一次 REM 的时长各期占比按帧数乘以 30 秒累加即可。值得注意的是 N1 的判定在临床上本身就存在较大主观性所以计算入睡潜伏期时很多软件采用「N1 持续达到一定帧数才算入睡」的规则避免偶发一帧 N1 导致潜伏期被严重低估。这也是自动分期落地时最容易和医生产生分歧的细节——建议在报告里同时输出原始帧级标签和结构化指标让医生能追溯每一帧的模型判读依据。最后说一个我的习惯训练完成不急着部署先随机抽几个测试受试者的整夜序列画出分期对比图把模型输出、维特比输出、人工标注三条曲线叠在一起看一遍。这一步比任何指标都能暴露问题——如果模型在凌晨 3 点把一段人工标注为 N3 的连续慢波判成 N2大概率是卷积核没有捕捉到慢波的能量特征回去改核长度比调学习率有效得多。睡眠分期这个方向的坑不少但按「数据对齐、按人划分、类别加权、序列平滑」这条路径走下来单通道脑电能做到接近临床可用的水平祝你这个项目能顺利跑通希望帮到你。本文还有配套的精品资源点击获取
返回列表