Python深度神经网络实现睡眠分期:从EDF到ONNX部署全链路
简介本资源是一套基于Python实现的深度神经网络睡眠分期检测完整研究方案面向生物医学工程、人工智能交叉领域的初学者与进阶学习者适用于本科毕设、课程设计及科研入门项目。包内含2005个文件主体为1893个Python脚本涵盖数据下载、预处理、模型训练与预测全流程、28份PDF技术文档含方法说明与实验分析、27个C/C头文件支持底层计算加速及配套配置文件整体压缩包达702.32MB结构清晰、模块解耦便于理解EEG信号处理与深度学习建模的端到端实践路径。已有176人学习下载提供可复现的Sleep-EDF公开数据集适配代码、多折交叉验证训练逻辑、GPU/CPU双模式运行支持及最佳模型自动加载机制附带详细日志记录与输出目录规范显著降低算法复现门槛与调试成本。1. 为什么用 Python 深度神经网络做睡眠分期比传统手工特征SVM 翻车率低 47%你手上有一份多导睡眠图PSG数据EEG、EOG、EMG 三通道连续采样256 Hz一整晚 8 小时——共约 737 万采样点。如果交给老派方法先人工设计 δ/θ/α/β 功率谱比、纺锤波密度、K-复合波计数等 37 个特征再喂给 SVM 或随机森林……结果往往在 N1 和 REM 阶段边界上反复横跳Kappa 系数卡在 0.62 上不去。而基于 Python 深度神经网络的睡眠分期检测方法研究核心不是“用深度学习炫技”而是让模型自己从原始时序信号里学出判别性表征CNN 抓局部振荡模式比如纺锤波的 12–14 Hz 短时爆发RNN/LSTM 建模阶段转换的时序依赖N2→N3 的慢波渐进增强Attention 机制聚焦关键片段REM 期快速眼动爆发前 2 秒的 EEG 同步化。这不是替代医生而是把临床标注的“金标准”变成监督信号让模型在 1000 例 PSG 数据上迭代收敛。适合正在处理公开数据集如 MASS、Sleep-EDF、SHHS的研究生、睡眠医学工程师或需要嵌入式部署轻量模型的医疗设备开发者——本文不讲论文复现只讲从 raw.edf 文件到可验证 .onnx 模型的完整链路。2. 从 EDF 文件加载到张量Python 睡眠分期数据预处理四步法2.1 用 mne-python 解析原始 EDF避开采样率错位这个玄学坑睡眠数据常以 EDF 格式存储但不同设备导出的 EDF 存在 channel order 不一致、采样率声明错误、时间戳偏移等问题。直接用scipy.io.loadmat或pandas.read_csv会丢信号完整性。必须用专业神经电生理库mneimport mne import numpy as np # 加载 EDF 文件注意不要用 glob 通配符直接 load先确认单个文件结构 raw mne.io.read_raw_edf(subject_01.edf, preloadTrue, verboseFalse) # 关键检查确认 EEG 通道名和采样率是否与标注一致 print(fChannels: {raw.ch_names}) print(fSampling rate: {raw.info[sfreq]} Hz) # 必须等于标注文档写的值否则后续切片全错 # 提取指定通道按临床标准C3-A2, C4-A1, EOG-L, EOG-R, Chin-EMG ch_map { EEG F3-M2: eeg_f3, EEG F4-M1: eeg_f4, EEG C3-M2: eeg_c3, EEG C4-M1: eeg_c4, EOG LOC-M2: eog_l, EOG ROC-M1: eog_r, EMG Chin: emg_chin } raw.rename_channels(ch_map) raw.pick([eeg_c3, eog_l, emg_chin]) # 三通道输入符合 AASM 标准提示preloadTrue是必须项。若设为 False后续get_data()会触发 lazy loading导致np.array()转换失败verboseFalse避免日志刷屏干扰调试。2.2 按 30 秒 epoch 切片 重采样为什么必须用 resample 而非 downsampleAASM 标准将睡眠划分为 30 秒一段的 epoch每段标注为 W/N1/N2/N3/REM。原始采样率 256 Hz → 每段含 7680 个点。但多数轻量 CNN 输入尺寸固定如 128×128需降维。错误做法直接x[::2]下采样 → 引入混叠aliasing丢失 12–15 Hz 纺锤波能量。正确做法用scipy.signal.resample重采样至目标频率from scipy.signal import resample def slice_and_resample(raw, target_fs128, epoch_sec30): data raw.get_data() # shape: (n_ch, n_samples) n_samples_per_epoch int(raw.info[sfreq] * epoch_sec) n_epochs data.shape[1] // n_samples_per_epoch epochs [] for i in range(n_epochs): start i * n_samples_per_epoch end start n_samples_per_epoch epoch_data data[:, start:end] # (3, 7680) # 重采样抗混叠滤波 重采样非简单下采样 resampled resample(epoch_data, numint(target_fs * epoch_sec), # 128*30 3840 axis1) epochs.append(resampled) return np.stack(epochs, axis0) # (n_epochs, n_ch, 3840) X slice_and_resample(raw, target_fs128) # shape: (N, 3, 3840)逻辑说明resample内部调用 FFT 插值自动应用低通滤波器截止频率为target_fs/2确保 64 Hz 以上高频噪声被抑制。参数target_fs128是平衡计算量与信息保留的常见选择——实测在 MASS 数据集上128 Hz 比 64 Hz 提升 Kappa 0.03比 256 Hz 仅下降 0.005 但推理快 2.1 倍。2.3 标签对齐从 hypnogram.txt 到 one-hot tensor 的硬核映射PSG 标注文件常为.txt或.csv每行一个 epoch 标签如W,N1,N2,N3,REM。但深度学习要求数字标签且与模型输出层匹配import pandas as pd # 假设 hypnogram.txt 格式每行一个标签无 header共 N 行 labels_raw pd.read_csv(hypnogram.txt, headerNone, squeezeTrue).values # AASM 标准映射必须严格按此顺序否则 loss 计算错位 label_map {W: 0, N1: 1, N2: 2, N3: 3, REM: 4} y np.array([label_map[l.strip()] for l in labels_raw[:len(X)]]) # 截断对齐 X 长度 # 转 one-hot用于 categorical_crossentropy from tensorflow.keras.utils import to_categorical y_onehot to_categorical(y, num_classes5) # shape: (N, 5) # 验证检查标签分布是否合理N2 应占 45–55%N3 25% print(Label distribution:, np.bincount(y) / len(y))参数说明num_classes5对应五类分期to_categorical输出 float32适配 GPU 计算y[:len(X)]强制截断是因 EDF 实际时长常比标注多几秒必须丢弃尾部未标注 epoch否则训练报错。3. 构建可落地的深度神经网络CNN-LSTM-Attention 三段式架构3.1 输入层设计为什么用 1D-CNN 而非 2D-CNN 处理时序信号有人尝试把 3840 点 EEG 拉成 64×60 图像喂 ResNet——效果差且显存爆炸。根本原因EEG 是强时序信号局部相关性在时间轴上而非空间二维网格。1D-CNN 卷积核沿时间维度滑动能高效捕获100 ms 内的瞬态事件如 K-复合波500 ms 内的节律振幅调制δ 波包络2 s 内的相位同步变化REM 期 θ-γ 耦合import tensorflow as tf from tensorflow.keras import layers, Model def build_cnn_backbone(input_shape(3, 3840)): # (n_ch, time_steps) inputs layers.Input(shapeinput_shape) # 第一层跨通道融合 时间卷积 x layers.Conv1D(32, kernel_size64, strides8, paddingsame)(inputs) # 感受野≈500ms x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.Dropout(0.2)(x) # 第二层增大感受野捕获慢波 x layers.Conv1D(64, kernel_size128, strides16, paddingsame)(x) # 感受野≈2s x layers.BatchNormalization()(x) x layers.ReLU()(x) x layers.Dropout(0.3)(x) # 全局平均池化压缩时间维度保留通道特征 x layers.GlobalAveragePooling1D()(x) # shape: (batch, 64) return Model(inputs, x) cnn_feat build_cnn_backbone()((3, 3840))逻辑说明kernel_size64对应 64/1280.5 秒128 Hz 下匹配纺锤波持续时间strides8控制下采样率避免过早丢失细节GlobalAveragePooling1D替代Flatten减少参数量且对时序长度变化鲁棒。3.2 时序建模层LSTM 为何比 GRU 在睡眠分期中更稳虽然 GRU 参数少但在 N2→N3 过渡期慢波渐增和 REM→W 醒转期θ 波骤降的长程依赖建模上LSTM 的 cell state 更稳定。实测在 MASS 数据集上LSTM 比 GRU 的 N3 阶段 F1 提升 0.023def build_lstm_branch(cnn_output): # cnn_output shape: (batch, 64) # 扩展为 (batch, 1, 64) 以适配 LSTM 输入 x layers.Reshape((1, -1))(cnn_output) # (batch, 1, 64) # 双向 LSTM 捕获前后文 x layers.Bidirectional( layers.LSTM(128, return_sequencesFalse, dropout0.3, recurrent_dropout0.2) )(x) # output: (batch, 256) return x lstm_out build_lstm_branch(cnn_feat.output)参数说明return_sequencesFalse因只需最终状态dropout0.3防止过拟合睡眠数据标注噪声大recurrent_dropout0.2对循环连接加 Dropout提升泛化Bidirectional让模型同时看到“前 30 秒”和“后 30 秒”的上下文对边界 epoch 关键。3.3 注意力融合层用 Self-Attention 替代 Concat 的真实收益传统做法把 CNN 特征和 LSTM 特征Concatenate后接 Dense——但 CNN 擅长局部模式LSTM 擅长全局趋势强行拼接会稀释判别性。Self-Attention 让模型自己学权重def build_attention_fusion(cnn_feat, lstm_out): # 将 CNN 特征 reshape 为序列形式模拟多头注意力输入 cnn_seq layers.Reshape((1, -1))(cnn_feat.output) # (batch, 1, 64) lstm_seq layers.Reshape((1, -1))(lstm_out) # (batch, 1, 256) # 拼接为 (batch, 2, d_model)d_model256 fused layers.Concatenate(axis1)([cnn_seq, lstm_seq]) # (batch, 2, 256) # Multi-Head Attention2 heads, key_dim128 attn_out layers.MultiHeadAttention( num_heads2, key_dim128, dropout0.2 )(fused, fused) # LayerNorm FFN x layers.LayerNormalization()(attn_out) x layers.Dense(128, activationrelu)(x) x layers.Dropout(0.3)(x) return layers.Flatten()(x) # (batch, 2*128) attention_fused build_attention_fusion(cnn_feat, lstm_out)逻辑说明key_dim128控制 attention 计算复杂度dropout0.2防止 attention 权重过拟合Flatten()后接分类头比直接GlobalAveragePooling1D提升 0.015 Kappa——因为 attention 显式建模了 CNN 局部特征与 LSTM 全局状态的交互关系。4. 训练与验证如何避免在睡眠分期任务中过拟合与标签泄露4.1 分层 K 折交叉验证为什么不能用 sklearn 的 StratifiedKFold睡眠数据存在强个体相关性同一受试者的多个 epoch 高度相似若随机打乱划分验证集会包含与训练集同源的样本导致指标虚高。必须按受试者分层from sklearn.model_selection import GroupKFold import numpy as np # 假设 subject_ids 是每个 epoch 对应的受试者 ID 数组shape(N,) # 例如[0,0,0,...,1,1,1,...,2,2,2...] 共 10 个受试者 gkf GroupKFold(n_splits10) for fold, (train_idx, val_idx) in enumerate(gkf.split(X, y, groupssubject_ids)): print(fFold {fold}: train{len(train_idx)}, val{len(val_idx)}) X_train, X_val X[train_idx], X[val_idx] y_train, y_val y[train_idx], y[val_idx] # 构建并训练模型此处省略模型定义 model.fit(X_train, y_train, validation_data(X_val, y_val), epochs50, batch_size32, callbacks[tf.keras.callbacks.EarlyStopping(patience7)])注意groupssubject_ids是强制参数确保同一受试者的所有 epoch 要么全在训练集要么全在验证集。实测若用普通 StratifiedKFoldN3 阶段 F1 虚高 0.08。4.2 损失函数选择Focal Loss 为何比 Categorical Crossentropy 更适配睡眠分期睡眠分期标签严重不平衡W 占 25%N2 占 50%N3 占 15%N1 占 5%REM 占 5%。标准 crossentropy 会让模型忽略 N1/REM。Focal Loss 动态降低易分类样本权重import tensorflow as tf def focal_loss(gamma2., alpha0.25): def focal_loss_fixed(y_true, y_pred): epsilon tf.keras.backend.epsilon() y_pred tf.clip_by_value(y_pred, epsilon, 1. - epsilon) cross_entropy -y_true * tf.math.log(y_pred) weight alpha * y_true * tf.pow((1 - y_pred), gamma) loss weight * cross_entropy return tf.reduce_sum(loss, axis1) return focal_loss_fixed model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-4), lossfocal_loss(gamma2, alpha0.25), # alpha 平衡类别权重 metrics[accuracy] )参数说明gamma2是经验最优值加大难样本权重alpha0.25降低多数类N2贡献提升少数类N1/REM梯度clip_by_value防止 log(0) 导致 NaN。4.3 避坑睡眠分期训练中的 4 个致命陷阱现象 1验证 loss 持续下降但 Kappa 系数卡在 0.55 不动原因未对 EEG 通道做 z-score 归一化不同受试者基线漂移差异大如某人 EEG 幅度是另一人的 3 倍模型学到的是幅度而非模式。解决对每个 epoch 的每个通道独立归一化epoch_ch (epoch_ch - np.mean(epoch_ch)) / np.std(epoch_ch)不能在整个数据集上 fit scaler。现象 2N3 阶段 recall 极低0.3但 precision 0.9原因N3 样本少且形态变异大老年人慢波少模型因 loss 权重不足而拒绝预测 N3。解决除 Focal Loss 外在class_weight中显式加强class_weight{0:1.0, 1:2.0, 2:1.0, 3:3.0, 4:2.0}N3 权重设为 3.0。现象 3训练 10 个 epoch 后 val_accuracy 突然跳变 ±15%原因验证集混入了未清洗的 artifact epoch如电极脱落导致的 flatline模型将其误判为 W 或 N1。解决在预处理阶段加入 artifact 检测计算每个 epoch 的 RMS 幅度剔除 RMS 5 μV 或 200 μV 的 epoch正常 EEG RMS 为 10–80 μV。现象 4模型在训练集上 accuracy0.92测试集仅 0.68原因数据增强方式错误——对 EEG 添加 Gaussian noise 会破坏生理节律而 time-warping时间扭曲会改变纺锤波持续时间违背生物约束。解决仅使用幅度缩放scale factor ∈ [0.8, 1.2]和通道置换swap EEG/EOG/EMG 顺序模拟导联贴错禁用时域变换。5. 模型部署与临床可用性验证从 Keras 到 ONNX 的轻量化实战5.1 模型剪枝与量化如何把 120MB 的 H5 模型压到 8MB 以内医疗设备端常受限于内存64MB和算力ARM Cortex-A53。Keras 默认保存含 optimizer 状态的完整 H5必须精简# 1. 保存纯推理模型不含 optimizer model.save(sleep_model.h5, include_optimizerFalse) # 2. 转 ONNX需安装 onnx, onnxruntime, tf2onnx import tf2onnx import onnx # 加载模型并转换 spec (tf.TensorSpec((None, 3, 3840), tf.float32),) onnx_model, _ tf2onnx.convert.from_keras(model, input_signaturespec) onnx.save(onnx_model, sleep_model.onnx) # 3. ONNX 量化INT8 from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( sleep_model.onnx, sleep_model_quant.onnx, weight_typeQuantType.QInt8 )逻辑说明include_optimizerFalse剔除训练相关变量减小 30% 体积tf2onnx转换时input_signature显式声明输入形状避免动态 batch 导致 runtime 错误QuantType.QInt8量化后体积降至原 1/15实测在 Raspberry Pi 4 上推理速度从 120ms 提升至 28ms精度损失 0.005 Kappa。5.2 临床级验证用混淆矩阵 Cohens Kappa 替代 accuracyAccuracy 在不平衡数据中失效N2 占 50%随便全猜 N2 就有 0.5 准确率。必须报告真实\预测WN1N2N3REMW8203218512N14510228311N2281912404231N3125673208REM2214296180计算 Cohens Kappafrom sklearn.metrics import cohen_kappa_score, classification_report kappa cohen_kappa_score(y_true, y_pred, weightsquadratic) print(fQuadratic Weighted Kappa: {kappa:.3f}) # ≥0.8 为优秀 print(classification_report(y_true, y_pred, target_names[W,N1,N2,N3,REM]))提示weightsquadratic对相邻分期如 N2↔N3惩罚较轻对跨期W↔N3惩罚重符合临床实际。5.3 边缘设备部署用 ONNX Runtime 在树莓派上跑通实时分期树莓派 4B4GB RAM部署关键步骤# 1. 安装 ARM 版 ONNX Runtime非 pip install onnxruntime wget https://github.com/microsoft/onnxruntime/releases/download/v1.15.1/onnxruntime-1.15.1-cp39-cp39-linux_armv7l.whl pip3 install onnxruntime-1.15.1-cp39-cp39-linux_armv7l.whl # 2. Python 推理脚本realtime_inference.py import numpy as np import onnxruntime as ort session ort.InferenceSession(sleep_model_quant.onnx) input_name session.get_inputs()[0].name # 模拟实时流每次接收 30 秒新数据3,3840 def infer_epoch(epoch_data: np.ndarray) - int: # epoch_data shape: (3, 3840), dtypefloat32 input_tensor epoch_data.astype(np.float32)[np.newaxis, ...] # add batch dim pred session.run(None, {input_name: input_tensor})[0] # (1,5) return np.argmax(pred[0]) # 测试单次推理 test_epoch np.random.randn(3, 3840).astype(np.float32) stage infer_epoch(test_epoch) print(fPredicted stage: {[W,N1,N2,N3,REM][stage]})参数说明onnxruntime-1.15.1-cp39-cp39-linux_armv7l.whl是官方编译的 ARMv7 轮子比通用版快 3.2 倍np.newaxis添加 batch 维度匹配 ONNX 输入要求session.run返回 list索引[0]是 logits 输出。我坚持在每次模型交付前用 MASS 数据集的 10 个未见受试者做 blind test——不是看平均 Kappa而是逐个检查 N3 和 REM 的 recall 是否都 0.75。曾因一个受试者 EOG 信噪比过低导致 REM 漏检回溯发现预处理时未启用 EOG artifact 检测从此把rms_eog 2 μV加入 pipeline 强制过滤。技术可以迭代但临床责任不能打折。希望帮到你。本文还有配套的精品资源点击获取