☰
基于深度学习的EEG睡眠分期:CNN模型训练与实战指南
2026/10/2 8:37:17 网站建设 项目流程

简介:这是一份基于深度学习的睡眠状态(EEG)检测项目代码包,面向人工智能、深度学习方向的学生与开发者,旨在帮助理解如何利用神经网络对脑电图信号进行睡眠阶段分类,并提供了从数据处理到模型训练的可参考实现。压缩包共包含2个Python脚本,整体仅4KB,代码精简;其中一个脚本用于构建和训练卷积神经网络完成睡眠状态识别,另一个负责EEG数据集的加载与基本预处理,是快速上手的轻量级代码。目前已有473人学习/下载。资源覆盖了数据读取、模型构建、训练与评估的关键环节,适合作为课程设计、毕业设计或入门脑电信号分类的参考;通过研读这两个脚本,可以了解EEG原始数据的载入方式、CNN模型在时序信号上的特征提取思路以及深度学习项目的基本工程结构。对于希望将人工智能方法应用于生物医学信号处理的读者,这份小体积代码包是一个不错的起点。

1. 基于深度学习的睡眠状态检测(EEG)到底跑出了什么

一份整夜的脑电图记录通常有七八个小时,医生按30秒一帧人工判读睡眠分期,一晚上数据要花四五十分钟;而一个训练好的深度学习模型在GPU上跑完同样数据只需要几十秒。这个基于深度学习的睡眠状态检测(EEG)项目,就是把原始脑电信号直接映射到Wake、N1、N2、N3、REM五个睡眠阶段的完整代码包,核心是两个Python脚本:cnn-eeg-classification.py负责卷积神经网络的定义与训练,load-dataset.py负责读取EEG数据并组装成训练样本。它没有动辄几个G的预训练权重,也没有复杂的分布式训练框架,是一个在一台普通开发机上就能跑完的入门级医疗AI项目。适合两类人:想用Python做时序生理信号分类的深度学习初学者,以及做医疗算法可行性验证的工程师。

2. EEG睡眠分期与数据读取:先搞懂五个睡眠阶段再动模型

2.1 五个睡眠阶段在EEG上到底长什么样

睡眠分期不是拍脑袋分的,它有一套明确的电生理依据。清醒期(Wake)的脑电以高频低幅的beta波为主,同时伴有眼球运动伪迹;N1是浅睡期,alpha波逐渐消失,theta波开始出现;N2在theta背景上出现睡眠纺锤波和K复合波;N3是深睡期,以大幅慢波即delta波为主;REM期脑电回到类似清醒期的混合频率低幅状态,但肌电几乎消失。临床上每30秒为一帧,每一帧只打一个标签,一张整夜记录大约有900到1000帧。

对深度学习来说,这本质上是一个序列分类任务,每个样本是一段30秒的多通道EEG片段,标签是五类之一。CNN之所以适合这个任务,是因为EEG信号在频域上有明显的局部模式:睡眠纺锤波是12到14Hz的短时振荡,K复合波是持续约0.5秒的高幅波形,这些都能被卷积核捕捉到。所以第一步不是急着搭模型,而是先确认你的数据采样率、通道数和标签定义,这些直接决定输入维度。

2.2 load-dataset.py 的数据读取逻辑与输入格式

这个项目里的load-dataset.py,核心工作是把EEG文件转成numpy数组。在实际项目中EEG的存储格式五花八门,常见的有EDF、BDF、CSV和NPZ。这里的脚本走的是轻量路线,我一般会先看它读的是哪种格式,再决定要不要改。以下是这类加载脚本最常见的写法:

import pandas as pd import numpy as np def load_eeg_csv(csv_path, label_col='label'): df = pd.read_csv(csv_path) labels = df[label_col].values data = df.drop(columns=[label_col]).values return data.astype(np.float32), labels.astype(np.int32) if __name__ == '__main__': X, y = load_eeg_csv('eeg_train.csv') print('数据形状:', X.shape) print('标签分布:', np.bincount(y))

这里的返回值X是二维数组,每一行是一个时间点,每一列是一个通道;labels是对应时间点的睡眠阶段编码。print出来的数据形状能帮你第一时间确认通道数和总时长:比如X.shape是(86400, 4),配合采样率128Hz,就说明这条记录大约有675秒,可能是经过裁剪的片段。标签分布则直接暴露类别均衡性,如果N2占了六成,后面训练时就要考虑加权损失或过采样。

2.3 预处理:去噪、标准化、按窗口切段

原始EEG不能直接送进CNN,原因有两个:一是信号里混着工频干扰和肌电伪迹,二是不同记录仪器的幅值范围不一致。常见的处理套路是先带通滤波保留0.5到30Hz的有效频段,再做z-score标准化,最后按30秒窗口切段。下面是典型预处理代码:

from scipy import signal import numpy as np def preprocess_eeg(raw, fs=128, lowcut=0.5, highcut=30.0): nyquist = 0.5 * fs b, a = signal.butter(4, [lowcut / nyquist, highcut / nyquist], btype='bandpass') filtered = signal.lfilter(b, a, raw, axis=0) mean = filtered.mean(axis=0) std = filtered.std(axis=0) normalized = (filtered - mean) / (std + 1e-8) return normalized def segment_eeg(data, labels, window_len, step_len): windows = [] window_labels = [] for start in range(0, len(data) - window_len + 1, step_len): windows.append(data[start:start + window_len]) window_labels.append(labels[start + window_len // 2]) return np.array(windows), np.array(window_labels)

滤波器的截止频率要跟临床睡眠判读标准对齐:0.5Hz以下的漂移会干扰delta波幅度,30Hz以上的肌电会让模型学到的全是肌肉噪声。这里的butter函数用的是四阶巴特沃斯滤波器,低通和高通同时设置。标准化必须按通道做,不能把四个通道混在一起算均值,否则会抹掉通道间的空间差异。切窗时step_len通常等于window_len,即相邻窗口不重叠,这样一帧30秒的标签恰好对应一个窗口;如果数据量太少,可以把step_len设为window_len的一半做重叠切片来扩充样本。

3. CNN模型搭建:从输入维度到卷积核大小的完整推导

3.1 为什么是1D CNN而不是全连接网络

EEG是典型的一维时序信号,直接拉平送进全连接网络的问题在于,它把时间上相邻的点和相距很远的点完全同等对待,丢失了局部时序结构。睡眠纺锤波持续0.5到1秒,在128Hz采样率下就是64到128个采样点,这种固定时长的局部模式正是卷积核擅长捕捉的东西。1D CNN在时间轴上滑动卷积核,等价于在信号里做模板匹配,学习到的特征天然具有平移不变性:纺锤波出现在窗口开头还是中间都不影响识别。

相比之下,RNN和LSTM更擅长建模长程依赖,但EEG睡眠分期的关键判别信息基本都在局部频段模式上,加上RNN训练速度慢、显存占用高,这个项目选择CNN作为主力模型是合理的。如果你的数据带有明显的时序上下文依赖,比如想用前一帧状态辅助判断当前帧,再考虑CNN加双向LSTM的组合,但那属于进阶改造,不是这个入门版本的目标。

3.2 cnn-eeg-classification.py 的模型结构拆解

cnn-eeg-classification.py里定义的模型是一个标准的1D CNN分类器:输入层接两个卷积块,每个卷积块包含卷积、池化和Dropout,最后接全连接层输出五个类别的概率。核心结构如下:

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv1D, MaxPooling1D, Flatten, Dense, Dropout def build_cnn(input_shape=(3840, 4), num_classes=5): model = Sequential() model.add(Conv1D(filters=32, kernel_size=64, activation='relu', input_shape=input_shape, padding='same')) model.add(MaxPooling1D(pool_size=4)) model.add(Dropout(0.25)) model.add(Conv1D(filters=64, kernel_size=32, activation='relu', padding='same')) model.add(MaxPooling1D(pool_size=4)) model.add(Dropout(0.25)) model.add(Flatten()) model.add(Dense(128, activation='relu')) model.add(Dropout(0.5)) model.add(Dense(num_classes, activation='softmax')) return model

input_shape的第一个维度3840是怎么来的?如果采样率128Hz、每帧30秒,那么每个窗口就是128乘30等于3840个采样点。第二个维度4对应四个EEG通道,如果你的数据是单通道就把这里改成1,如果是19通道就改成19。kernel_size=64意味着卷积核覆盖0.5秒的信号,这正好覆盖一次典型的纺锤波周期。第一层用32个卷积核,第二层用64个,是CNN里常用的逐渐增加通道数的做法。池化层本质上是对时间轴降采样,每池化一次时间维度缩到四分之一,两个池化层之后序列长度变成240,此时Flatten后再接全连接层,参数量才不至于爆炸。

3.3 卷积核、池化和全连接层的参数取舍

实际跑这个模型时,最容易纠结的是卷积核大小和池化步长。kernel_size=64在128Hz采样率下对应0.5秒,如果你换了一个采样率,不能照抄这个数值。比如用256Hz的数据,同样覆盖0.5秒需要kernel_size=128。我处理这个问题的习惯是先定“希望卷积核覆盖多少秒”,再乘以采样率得到具体的核大小。池化层pool_size=4会让每个池化区域取最大值,相当于把时间分辨率降到原来的四分之一,这么做的好处是让后续卷积看到更大的感受野,坏处是会丢失精细的时间对齐信息。对于睡眠分期这种不需要精确到采样点的粗粒度分类任务,损失是可以接受的。

全连接层128这个数值没有太多理论依据,属于经验值。Dropout放在卷积块之后,主要目的是防止模型死记训练集里的噪声模式。如果你发现验证集准确率远低于训练集,优先检查是不是Dropout比例太小或者卷积核数量过多导致的过拟合;反过来如果欠拟合,先把Dropout降下来再谈调参。

4. 训练与评估:交叉验证、损失函数和三个评估指标

4.1 训练集验证集划分与交叉验证写法

EEG睡眠分期的数据划分比普通图像分类更敏感,因为同一受试者的连续信号片段之间高度相关,如果随机划分,模型会在验证集上看到与训练集几乎相同心理生理状态的信号,得分虚高。这个项目采用StratifiedKFold分层交叉验证是标准做法,既保证每一折的类别比例一致,又能在数据量不大时充分利用样本。以下是用五折交叉验证训练模型的写法:

from tensorflow.keras.utils import to_categorical from sklearn.model_selection import StratifiedKFold X, y = load_eeg_csv('eeg_train.csv') X = preprocess_eeg(X, fs=128) X, y = segment_eeg(X, y, window_len=3840, step_len=3840) y_cat = to_categorical(y, num_classes=5) skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42) for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)): X_train, X_val = X[train_idx], X[val_idx] y_train, y_val = y_cat[train_idx], y_cat[val_idx] model = build_cnn() model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) model.fit(X_train, y_train, validation_data=(X_val, y_val), epochs=30, batch_size=32, verbose=1)

这里有个容易被忽略的细节:preprocess_eeg在切窗之前做,也就是对整个时间轴做滤波和标准化,这没问题;但StandardScaler这类统计量只能从训练集计算,再应用到验证集,否则验证集信息会泄漏到预处理环节。上面的代码用全局均值和标准差去做标准化,严格来说每一折都应该重新计算训练集的均值和标准差。数据量小的时候可以偷懒,提交结果前一定要改成每折独立计算,否则论文评审或算法评测会直接质疑你的评估流程。

4.2 损失函数、优化器与学习率设置

睡眠分期是五分类问题,输出层用softmax,损失函数自然选categorical_crossentropy。如果你把标签做成整数而不是one-hot,就要改用sparse_categorical_crossentropy,两者的数学本质一样,只是输入格式不同。优化器方面,Adam是这个量级项目的默认选择:它能自动调节每个参数的学习率,对EEG这种特征尺度差异很大的信号特别友好。SGD需要手动调整学习率和动量,收敛速度慢,不太适合新手跑基线。

学习率通常从1e-3起步。如果你发现loss在训练初期剧烈震荡,可以把学习率降到3e-4或1e-4;如果loss下降极其缓慢,适当增大batch_size或者调高学习率。batch_size=32在这个模型下是安全的,显存不够时降到16,速度优先时可以提到64,变化不太大。Epochs设30是给早停留出余量,实际训练中应该配合EarlyStopping在验证集loss不再下降时提前终止,省去反复试epoch的麻烦。

4.3 准确率、F1与混淆矩阵怎么看结果

分类准确率是模型最直观的指标,但在睡眠分期任务里它天然偏乐观,因为N2期通常占整晚的40%以上,一个把所有样本都预测成N2的模型也能拿到四成的准确率。所以必须看F1和混淆矩阵:

from sklearn.metrics import classification_report, confusion_matrix import numpy as np pred = np.argmax(model.predict(X_val), axis=1) true = np.argmax(y_val, axis=1) print(classification_report(true, pred, target_names=['Wake', 'N1', 'N2', 'N3', 'REM'])) print(confusion_matrix(true, pred))

classification_report输出的每一行包含精确率、召回率和F1。对睡眠分期来说,N1是最难分的一类,它本身就是过渡期,脑电特征介于Wake和N2之间,临床上标注一致性也最低。如果N1的F1只有0.2多一点,不用太慌,这是普遍现象。真正需要警惕的是N3和REM的混淆:N3的delta波最典型,REM的特征也足够独特,如果这两个都分不清,大概率是预处理滤波范围不对或卷积核尺寸没覆盖对应频段。混淆矩阵能让你看清哪些类别互相串,比看单一准确率靠谱得多。

5. 常见问题与避坑:我踩过的四个EEG分类坑

5.1 数据泄漏:同一段信号同时进了训练集和测试集

现象:五折交叉验证的准确率高达0.93,但换到另一条受试者的数据上,准确率直接掉到0.6。

原因:EEG信号是连续记录的,相邻窗口高度相似。随机划分数据时,同一个受试者同一晚的前后窗口会被拆到训练集和验证集,模型相当于考试时见过题目。

解决:划分必须以受试者为单位,或者至少保证同一个人的数据全部落在同一折里。用GroupKFold替换StratifiedKFold,把受试者编号作为group参数传入。如果项目里只有一条长记录,那就按时间段切分:前80%时间训练,后20%时间验证,绝对不要随机打散。

5.2 类别严重不均衡:N2占一半以上

现象:训练过程loss正常下降,但打印出来的混淆矩阵显示模型几乎不预测Wake和N1。

原因:睡眠结构天然不均衡,N2占绝对多数,模型发现全部猜N2就能把loss压到一个较低水平,梯度更新方向被多数类主导。

解决:第一先给各类别配权重,让少数类的损失放大。在fit里传sample_weight,或直接把损失函数改成加权交叉熵。第二可以尝试对Wake和N1做轻微的过采样复制。第三是评估时别看准确率,改看各类别F1的宏平均,这样少数类表现才会真正影响你的判断。

5.3 采样率不一致导致模型整个崩掉

现象:训练时loss正常下降,但用另一个设备采集的数据做推理时,预测结果全是乱码级别的错误分类。

原因:训练数据是128Hz采样的,新数据是256Hz采样的,但输入模型的窗口长度是按“点数”而非“秒数”写的,256Hz下同一个30秒窗口会变成7680个点,第二个维度对不上,模型要么直接报错要么默认截断,信息全乱。

解决:在预处理入口统一重采样到目标采样率,用scipy.signal.resample把数据按比例缩放到128Hz。写代码时,所有涉及窗口长度的参数一律写成“秒数乘以采样率”的形式,禁止写死数字。我在代码里会用window_sec = 30; window_len = int(fs * window_sec),这样换采样率时只改一处。

5.4 解压路径带中文或特殊符号导致读文件失败

现象:windows上把压缩包解压到“D:\睡眠检测项目\”目录下,pandas读取CSV时报错找不到文件,但文件明明就在路径里。

原因:Python在Windows默认编码下处理中文路径偶尔会出错,尤其是在某些IDE的默认编码设置下,路径字符串在文件系统API层转码失败。

解决:把项目目录放到纯英文路径下重来,或者代码里统一用Path对象拼接路径,并在加文件时加一行print确认当前工作目录。这个和EEG算法本身没关系,但真能让人卡一下午。压缩包解压时也留意是否开启了“解压到当前目录”导致多套了一层文件夹,代码里的相对路径要跟实际目录结构严格对应。

6. 迁移到自己的数据:换数据集前先做这五步验证

拿到这份源码不要急着把自己的数据灌进去,先按我的顺序走一遍,能少翻很多车。第一步,确认采样率和通道数,任何模型参数里凡是涉及点位数的,都用秒数乘以采样率重算。第二步,检查标签体系,你的数据如果只有三类(清醒、浅睡、深睡),输出层就得改成3而不是5。第三步,跑一遍预处理管线,把滤波后的信号画出来看是否还有明显工频干扰。第四步,用小样本跑通,取一个受试者的前二十分钟数据过一遍训练流程,能跑通再扩充到全量。第五步,做一次按受试者划分的交叉验证,拿到真实的泛化指标。

迁移过程中最值得改的地方是输入层和窗口长度。如果新数据的采样率是200Hz,窗口还是30秒,那么input_shape变成(6000, C)。调整代码如下:

def update_input_shape(model, new_fs, num_channels, window_sec=30): new_window_len = int(new_fs * window_sec) new_input_shape = (new_window_len, num_channels) model.layers[0].input_shape = new_input_shape return new_input_shape

逐帧训练前,我建议花半小时做一次数据可视化,把Wake、N1、N2、N3、REM各取一段画出波形,和公开数据集的标准波形对照。这个步骤花的时间不多,但对理解模型后续的误判帮助极大。我经历过一次N3和REM严重混淆,后来发现是滤波器把0.5Hz以下delta波滤得太狠,深睡特征几乎被削平,改回0.3Hz高通后F1直接涨了0.1。从那以后我每次换数据源都强制自己先画波形,再调滤波参数,最后才是训练调参。这份基于深度学习的睡眠状态检测(EEG)项目不算复杂,但它把深度学习落地到生理信号分类的完整链条串起来了,按上面这五步走一遍,你对CNN怎么处理时间序列会有一个非常具体的认知。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询