☰
1DCNN直接处理原始脑电时间序列:癫痫发作检测的端到端方案
2026/10/7 13:08:33 网站建设 项目流程

简介:这份资源提供基于一维卷积神经网络(1DCNN)的脑电信号处理与癫痫发作检测Python代码,适合从事脑电分类、神经疾病诊断或时间序列深度学习研究的学生与工程师使用。项目围绕原始EEG信号建模,涵盖数据预处理、模型构建、训练、评估与预测等核心流程,可帮助读者快速上手利用1DCNN捕捉脑电信号中的时序特征以实现癫痫识别。资源包共1个文件,为py格式源码,大小仅2KB,轻量易读,适合作为基线模型参考或直接扩展修改。已有720人学习下载,说明其在脑电信号入门与实验复现场景中具备一定参考价值。借助该代码,用户可掌握一维卷积在生物医学序列分析中的实际应用思路,并围绕癫痫发作检测任务开展进一步优化与实验。

1. 1DCNN直接吃原始脑电时间序列:癫痫发作检测的端到端路线

有个现象在脑电信号处理里很典型:你花了两周把原始EEG转成小波能量、样本熵、频带功率这些手工特征,最后分类AUC只涨了0.01,而旁边同事直接让一维卷积神经网络(1DCNN)去啃Raw Time Series(原始时间序列),三天就拿到了差不多的结果。我接触过几个做癫痫发作检测的工程团队,真正能跑到临床验证阶段的方案,大多不是靠更复杂的特征工程,而是靠把预处理尽量减薄、把波形本身交给卷积神经网络去学。这篇笔记就把这条路线拆开讲:为什么1DCNN适合做原始脑电,窗口怎么切,网络怎么搭,训练评估有哪些容易被高估的坑,以及怎么验证模型真的在学癫痫放电而不是背样本。适合正在做脑电分类落地、或者刚接触医疗时间序列建模的从业者。

2. 从原始EEG到1DCNN输入:Raw Time Series的窗口切分与预处理

2.1 为什么不用2D-CNN或LSTM:1DCNN处理原始EEG的三个理由

2D-CNN在EEG上的常见做法是先把信号做短时傅里叶变换或连续小波变换,把一维时间序列变成二维时频图。这个转换有两个代价:一是频率分辨率和时间分辨率互相牵制,窗口长了时间细节丢失,窗口短了频率又糊成一片;二是绝大多数2D-CNN输入取功率谱或幅值谱,相位信息直接被丢掉了。癫痫发作期典型的棘波、尖波本质上是短暂的双相波形,相位信息恰恰是区分棘波和肌电伪迹的关键线索。把这些信息压进一张频谱图里,等于主动丢掉了一部分判别力。

LSTM和GRU这类循环网络看起来是处理时间序列的天然选择,但你实际跑一遍就会发现两个麻烦。脑电的采样率通常是256Hz或512Hz,一个4秒的窗口就是1024到2048个时间步,LSTM在长序列上的显存开销和训练时间都明显高于卷积网络;更要命的是,癫痫棘波不是靠超长依赖关系建模的,它更多是局部波形形态加上多导联的传播顺序,这种结构用卷积核去匹配反而更直接。我做过对比实验,在同一个数据集上LSTM的收敛速度明显更慢,验证集F1也没超出一维卷积网络,而调参成本高出一截。

还有一个容易被忽略的点:时间序列的平移不变性。癫痫棘波可能出现在窗口的任何位置,1DCNN的卷积核在时间维度上滑动,天然对时间位置不敏感;而如果把信号截断了再喂给全连接网络,模型就得重新学习棘波出现在不同位置时的特征分布。1DCNN对这种局部时移的鲁棒性是结构自带的,不用额外做数据增强。

路线输入表示主要代价适用场景
2D-CNN时频图/小波图相位丢失、时间分辨率受限频带特征强、波形相位不关键的信号
LSTM/GRU原始时间序列长序列训练慢、调参敏感需要显式建模长期依赖的序列
1DCNN原始时间序列需要手调卷积核尺度局部波形结构明确的生物信号

单从计算资源看,1DCNN在CPU上也能做小规模的实验,一张入门级显卡就能跑出可用的结果。这对很多没有GPU集群的医疗AI团队来说是个很现实的优势。另外,1DCNN的卷积核参数数量远少于2D-CNN的同等规模结构,在小样本的脑电数据集上没那么容易过拟合。这里说的"小样本"不是指窗口数量少,而是指独立的发作事件数量少:几十个小时的脑电里,发作段可能只有几十到几百秒,真正的独立样本数并不充裕。

2.2 窗口长度、步长与标准化:一个可复现的EEG预处理流程

窗口长度是第一个要拍板的参数。常见做法是取1到4秒,具体看你检测的颗粒度。如果目标是判断某个时刻是否处于发作期,1到2秒的窗口比较合适;如果要给医生一个"这段是否发作"的事件级判断,4秒窗口更稳。窗口太长会把发作段和正常段混在一起,窗口太短又截断了一个完整的棘波-慢波复合体。我一般从4秒窗口起步,先跑一个基线,再缩短到1到2秒对比,因为发作在时间上往往是连续的一段,窗口越短,窗口标签的噪声越大。

步长决定窗口之间的重叠度。训练阶段为了扩样本量,步长可以取窗口长度的一半甚至更小,但要克制:步长越小,相邻窗口越相似,验证集的指标会被严重高估,这个坑后面避坑章节会专门讲。我这里给出一个干净的滑动窗口函数,它把连续EEG切成一堆定长窗口,并且按窗口内发作标注的占比来决定这个窗口的标签。

import numpy as np def sliding_window(eeg, labels, fs=256, win_len=4, stride=1): # eeg: (n_samples, n_channels),原始脑电,float32 # labels: (n_samples,) 逐采样点的发作标注,1表示处于发作期 # fs: 采样率,win_len: 窗口长度(秒),stride: 滑窗步长(秒) win_size = int(win_len * fs) step = int(stride * fs) n_windows = (len(eeg) - win_size) // step + 1 X, y = [], [] for i in range(n_windows): start = i * step win = eeg[start:start + win_size] lab = labels[start:start + win_size] X.append(win) # 窗口内发作采样点占比大于0.5,整个窗口标为发作 y.append(1 if lab.mean() > 0.5 else 0) return np.stack(X), np.array(y)

这个函数的关键判断是"窗口内发作采样点占比大于0.5才标为发作"。如果改成"窗口内只要有一个发作采样点就标发作",你会得到大量仅蹭到一点发作边缘的窗口,标签噪声很大,模型会把正常段末端的背景波形也学成发作特征。占比阈值0.5是保守做法,宁可少标一点,也不要制造一堆边界模糊的样本。如果你的发作标注是按事件给的,没有逐采样点的标签,那就要先根据发作起止时间把一维label数组铺出来,再做窗口切分。

标准化这一步有个反直觉的问题。许多脑电分类论文直接用整段信号计算均值和标准差做z-score,这在离线实验里没问题,但你要想清楚:测试窗口可能来自某一个受试者的后半段记录,你用了整段信号(包括测试段本身)的统计量,等于提前偷看了测试数据的分布。更稳妥的做法是只用训练窗口的统计量,把它固定下来,测试和推理阶段都沿用这个统计量。

def fit_normalizer(train_windows): # train_windows: (n_windows, win_size, n_channels) # 只从训练集中估计每个通道的均值和标准差 mean = train_windows.mean(axis=(0, 1), keepdims=True) std = train_windows.std(axis=(0, 1), keepdims=True) return mean, std def normalize_with(x, mean, std): # 训练、验证、测试全部用同一组统计量,避免数据泄漏 return (x - mean) / (std + 1e-6)

这里的参数有一个细节:mean和std是按通道分别算的。脑电不同导联的幅值范围差异很大,比如Fz导联和Pz导联的背景节律幅值可以差好几倍,混在一起标准化会把弱导联的信号压没。按通道标准化之后,每个通道都在相近的量纲上,模型学到的权重对不同导联才有可比性。

前面这两个函数连起来就是一条最小可复现的预处理流水线:原始EEG加逐采样点标签 → 滑动窗口切分 → 按训练集统计量标准化。整个过程不涉及带通滤波。有些公开数据集里的原始信号确实有基线漂移,但如果你是Raw Time Series的直接输入派,第一层卷积核本身就带滤波作用,先让模型跑一遍看结果再决定要不要加滤波,往往更效率。真到了部署阶段,与设备硬件相关的一个带通滤波器是必要的,那是工程问题,不是建模问题。

3. 搭建1DCNN癫痫检测网络:卷积核尺寸与三层结构的关键参数

3.1 卷积核尺寸怎么定:从采样率与棘波时长反推感受野

卷积核尺寸是1DCNN里最敏感的参数,调不好整个网络的学习效率会差很多。一个可靠的思路是从信号的生理周期反推。假设采样率是256Hz,每个采样点之间的间隔大约是3.9毫秒;一段典型的癫痫棘波持续20到70毫秒,换算成采样点大约是5到18个;棘波之后跟着的慢波则可能持续200到500毫秒,对应51到128个采样点。如果第一层卷积核只有3到5个采样点,你就只能看到波形的一个毛边,根本匹配不上完整棘波,模型就得靠后面好几层去逐步拼出这个结构,学习效率低且容易过拟合。

常见做法是让第一层卷积核覆盖大约一个慢波的周期,也就是在256Hz下取64到128个采样的核。这样第一层就可以一次性看到一个完整的棘波-慢波复合体,后续层的核可以逐步缩小去提取更精细的局部形态。如果你的采样率是512Hz,卷积核尺寸要相应翻倍,比如取128到256。这里可以记一个公式:kernel_size = int(fs × duration),duration从0.25秒到1秒之间选,不要低于0.25秒,否则覆盖不了慢波的完整形态。

def pick_kernel_size(fs, duration_sec=0.5): # fs: 采样率,duration_sec: 希望覆盖的信号时长,单位秒 # 返回值是第一个卷积层的卷积核大小 return max(16, int(fs * duration_sec))

卷积核大小之外,卷积层的数量也很关键。我在多个数据集上的经验是:一到两层卷积就能学到相当可分的特征,三层是一个稳妥的上限,四层在脑电这种中小规模数据上往往开始过拟合。这跟图像分类不太一样,图像有百万级样本,脑电的独立发作事件往往只有几十到几百个,网络深了没有足够的样本去支撑深层语义特征的训练。网络宽度上,第一层64个滤波器、第二层128个滤波器已经足够,再往上加对准确率的提升非常有限,但训练时间会明显变长。

3.2 核心网络结构:Keras里搭一个可运行的1DCNN

下面这个结构是我在多个脑电二分类任务上跑过的基础骨架。它接受原始时间序列,输出两个类别的概率,可以直接用在癫痫发作检测的初步实验里。

import tensorflow as tf from tensorflow.keras import layers, Model def build_1dcnn(win_size, n_channels, n_classes=2): # win_size: 单个窗口的采样点数,例如4秒*256Hz=1024 # n_channels: 使用的脑电导联数,单导联传1,多导联传实际导联数 inputs = tf.keras.Input(shape=(win_size, n_channels)) # 第一层大卷积核,覆盖约0.25秒的信号,匹配棘波-慢波复合体 x = layers.Conv1D(filters=64, kernel_size=64, strides=2, activation='relu', padding='same')(inputs) x = layers.BatchNormalization()(x) x = layers.MaxPooling1D(pool_size=4)(x) # 第二层中等卷积核,提取棘波内部的局部形态 x = layers.Conv1D(filters=128, kernel_size=16, strides=2, activation='relu', padding='same')(x) x = layers.BatchNormalization()(x) x = layers.MaxPooling1D(pool_size=4)(x) # 第三层小卷积核,做全时段的特征聚合 x = layers.Conv1D(filters=64, kernel_size=8, activation='relu', padding='same')(x) x = layers.BatchNormalization()(x) # 全局平均池化代替Flatten,减少参数量,抑制过拟合 x = layers.GlobalAveragePooling1D()(x) x = layers.Dropout(0.5)(x) outputs = layers.Dense(n_classes, activation='softmax')(x) model = Model(inputs, outputs) return model model = build_1dcnn(win_size=1024, n_channels=1) model.summary()

代码里几个参数要说清楚。第一层卷积核是64,strides=2,等价于把时间轴下采样一半,既压缩了数据量,又让第二层卷积的感受野变大。最大池化pool_size=4进一步把时间轴压缩到原来的四分之一。如果你在更大的数据集上训练,可以适当增加滤波器数量,但第一层超过128后收益就开始递减。BatchNormalization放在卷积之后、池化之前,它让每一层的输入分布稳定,在脑电这种非平稳信号上尤其重要——不同受试者的背景节律差异大,BN可以帮助网络更快适应。

全局平均池化(GlobalAveragePooling1D)是这结构里一个值得注意的选择。它把第三个卷积层的每个特征通道在时间维度上取平均,得到一个向量。相比直接把时间轴Flatten成超长向量,GAP的参数量少一个数量级。在训练数据只有几万个窗口的规模上,少参数意味着更稳的收敛和更低的过拟合风险。代价是你会丢掉时间定位信息,但这个信息在分类任务里不是必须的,后面做可解释性时有另外的办法拿回来。

模型编译和初调的优化器设置也一并给出。我通常先用1e-3的初始学习率跑20轮看趋势,然后按早停结果回落。损失函数用sparse_categorical_crossentropy,因为我们的标签是0/1整数,不需要转成one-hot等额外操作。

model.compile( optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3), loss='sparse_categorical_crossentropy', metrics=['accuracy'] )

这里的学习率1e-3不是随便拍的。脑电数据上Adam太激进容易让loss在前期震荡,太保守又会在50轮内摸不到底。1e-3配合ReduceLROnPlateau是最稳的起点,如果你发现验证损失在某个平台期反复横跳,把学习率降到3e-4再继续训练,效果往往比从头再来一轮更快。

4. 训练与评估:类别不平衡下的数据划分与指标选择

4.1 训练策略:为什么按受试者划分数据比随机划分更保守

训练集和验证集的划分直接决定你能信多少自己的测试结果。初学者最容易犯的错误是用滑动窗口切出来的几万个窗口直接做随机划分。这些窗口相互之间高度重叠——同一段发作被切成几十个几乎相同的窗口,随机划分后一部分进训练集,一部分进验证集,两边都有来自同一时间段的几乎相同的数据,模型等于把那些窗口背了下来。验证准确率0.99,实际拿给没参与训练的病人跑,直接掉到0.6以下,这种例子我在交流群里见得太多了。

正确的做法是按独立个体或者按发作事件划分数据。如果数据集包含多个受试者,那就按受试者划分:让同一受试者的所有窗口只出现在训练集、验证集或测试集中的一个,绝不跨集合。具体到代码上,假设你已经有了一个包含subject_id的窗口列表,划分逻辑是这样的:

def split_by_subject(window_ids, subject_ids, val_ratio=0.15, test_ratio=0.15): # window_ids: 窗口编号数组 # subject_ids: 每个窗口对应的受试者编号 subjects = np.unique(subject_ids) # 先把受试者打乱,再按比例切分 rng = np.random.RandomState(42) rng.shuffle(subjects) n_val = int(len(subjects) * val_ratio) n_test = int(len(subjects) * test_ratio) val_subjects = subjects[:n_val] test_subjects = subjects[n_val:n_val + n_test] train_subjects = subjects[n_val + n_test:] train_idx = window_ids[np.isin(subject_ids, train_subjects)] val_idx = window_ids[np.isin(subject_ids, val_subjects)] test_idx = window_ids[np.isin(subject_ids, test_subjects)] return train_idx, val_idx, test_idx

这个函数的关键是打乱和切分的粒度都是受试者级别,不是窗口级别。RandomState(42)固定了随机序列,这能保证你重复跑实验时,划分结果是一致的——这在调试代码和复现结果时能省掉大量困惑。如果你的数据只有一个受试者,那就要按发作事件划分:把每次发作的时间段视为一个独立整体,整段发作切出的窗口全部只进一个集合,绝不能拆散。发作事件一般持续几十秒,拆散一个事件去分配训练和验证,和上面按受试者划分的泄漏问题一模一样。

4.2 class_weight与早停参数:训练代码与参数说明

数据划分好之后,训练代码有一个很重要的平衡参数。癫痫发作在脑电记录里占比通常很低,一个数据集里发作窗口可能只占1%到5%。如果不处理这个不平衡,模型会倾向于把所有窗口都判为正常——因为这样整体的准确率也是95%以上。class_weight是Keras里最直接的工具,它给损失函数按类别加权,让模型把"把发作窗口分错"看得更严重。

# 假设x_train, y_train, x_val, y_val已经按受试者划分好 from tensorflow.keras.optimizers import Adam train_history = model.fit( x_train, y_train, validation_data=(x_val, y_val), batch_size=64, epochs=50, class_weight={0: 1.0, 1: 3.0}, # 发作类权重设为3.0 callbacks=[ tf.keras.callbacks.ReduceLROnPlateau(monitor='val_loss', factor=0.5, patience=4), tf.keras.callbacks.EarlyStopping(monitor='val_loss', patience=8, restore_best_weights=True) ] )

class_weight的取值不要一上来就给太大。我踩过的经验是:权重从2到5之间起步,观察验证集上的灵敏度和特异性的变化。权重设到10以上,模型会把大量正常段也判成发作,灵敏度看起来很高,但特异性崩到不如随机猜测,F1反而下降。这里的batch_size=64在大多数实验里是稳的,显卡显存紧张就降到32,但不要低于16,否则BN层的统计量会因为小batch而抖动,训练波动变大。

早停的monitor用val_loss而不是val_accuracy,这是因为类别不平衡下准确率会被多数类主导,loss能更灵敏地反映少数类的拟合情况。patience=8表示连续8个epoch验证集loss没有改善就停掉,同时restore_best_weights把参数回滚到最优的那个epoch。ReduceLROnPlateau的factor=0.5表示每次学习率减半,patience=4表示4个epoch不改善就降。这两个回调配合起来,基本不需要手动盯着训练曲线。

4.3 评估指标:灵敏度、特异性与F1在癫痫检测中的优先级

在癫痫发作检测这个任务里,准确率是最没有参考价值的指标,因为发作样本占比太少,全猜正常也能有很高的准确率。我更建议直接看三个指标:灵敏度(也叫召回率)、特异性和F1。它们各自关心的东西不一样。

指标计算公式在检测任务中的意义
灵敏度TP / (TP + FN)真正发作的窗口里有百分之多少被抓住,漏报一次发作的代价比误报高得多
特异性TN / (TN + FP)正常窗口有百分之多少被正确放过,特异性低意味着医生要被大量假警报淹没
F12 × 精确率 × 灵敏度 / (精确率 + 灵敏度)两者出现矛盾时的调和平均值,对少数类更敏感
正预测值TP / (TP + FP)每次报警里有多大比例是真正发作,帮助判断报警的实际可用性

我给你一个具体的数值直觉。假设1000个窗口里只有20个是发作。模型把20个发作全部测出来,但同时误报了10个正常窗口。这时候准确率是99%,灵敏度是100%,特异性是98.98%,F1大概是0.80,正预测值只有67%。医生说每次响三声警报,只有两声是对的,临床上是很难接受的;但如果为了把正预测值提上去,把误报压到零,灵敏度很可能会掉到85%,那就意味着每二十次发作漏掉三次。这两个指标之间本身是矛盾的,你要根据具体使用场景来定阈值。

评估代码上,不建议直接读Keras训练日志里的accuracy,而是用测试集输出概率再手动算全部指标。

def evaluate_metrics(y_true, y_prob, threshold=0.5): # y_true: 整数标签,0正常1发作 # y_prob: 模型输出的softmax概率,取发作类的概率 pred = (y_prob[:, 1] >= threshold).astype(int) tp = ((pred == 1) & (y_true == 1)).sum() fn = ((pred == 0) & (y_true == 1)).sum() fp = ((pred == 1) & (y_true == 0)).sum() tn = ((pred == 0) & (y_true == 0)).sum() sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0 specificity = tn / (tn + fp) if (tn + fp) > 0 else 0.0 precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0 f1 = 2 * sensitivity * precision / (sensitivity + precision) if (sensitivity + precision) > 0 else 0.0 return { "sensitivity": sensitivity, "specificity": specificity, "precision": precision, "F1": f1, "threshold": threshold, } # 用法示例 y_prob = model.predict(x_test) print(evaluate_metrics(y_test, y_prob, threshold=0.5))

threshold是决定预测标签的截断点,默认0.5,但这个值不一定最优。当你用class_weight或者昂贵的False Negative(漏检)来惩罚时,模型输出的概率分布会偏移,所以用验证集扫描几个不同的threshold值,找到灵敏度、特异性和正预测值都相对均衡的操作点,是评估阶段的最后一步,也是最实用的一步。

5. 脑电1DCNN训练排查清单:数据泄漏、标准化泄漏与阈值漂移

5.1 滑窗重叠:训练验证双双虚高,跨病人就崩

现象:训练集准确率和验证集准确率都到了0.98以上,你觉得模型已经训练完成,高高兴兴拿它去测一个新病人的数据,结果灵敏度掉到0.6,看起来像是白训了。

原因:滑动窗口的步长小于窗口长度时,相邻窗口有大量重叠,同一个发作事件在训练集和验证集里各自衍生出了几十个几乎相同的窗口。模型学到的是"记住这个片段"而不是"识别发作的一般形态"。

解决:先按受试者或发作事件划分数据,再切窗口。如果数据只有一个受试者,那么训练集、验证集、测试集必须按发作事件的时间段划分,保证同一个事件的所有窗口只属于一个集合。检验是否泄漏很简单:把训练集和验证集里随机各抽一个窗口,计算它们的平均绝对差值,如果差值趋近于零,说明泄漏已经发生。

5.2 标准化泄漏:整段记录的统计量里藏着未来数据

现象:在离线实验里把整个数据集的均值、标准差算出来做标准化,验证集指标看着不错,但在实际部署的流式/在线系统里,模型的表现明显变差。

原因:标准化用了包含验证集和测试集在内的整段信号统计量,相当于让模型在测试时偷看了整个记录的数据分布。在线推理时,你不可能拿到未来几十分钟信号的均值,这个前提从一开始就不成立。

解决:只用训练集窗口估计每个通道的均值和标准差,验证和测试时沿用这个固定的统计量。在部署时,这个统计量直接用训练时算好的参数固化在模型配置里,不随输入数据更新。

5.3 class_weight过高:灵敏度上去了,特异性直接崩

现象:为了把发作类样本的损失权重调到10以上,期望提升灵敏度。结果灵敏度确实变得很高,比如0.98,但特异性掉到了0.3,F1反而比调之前低。

原因:权重过大会让模型倾向于把所有输入都判为发作。脑电数据里发作窗口占比本来就低,把损失放得过大,相当于告诉优化器"错把正常当发作的惩罚远小于错把发作当正常",模型于是选择保守的"宁可错杀"策略。

解决:把class_weight控制在2到5之间,然后回到验证集上扫描threshold。通常你会发现用0.3或0.4做阈值比0.5更好——因为模型输出的概率被类别权重拉偏了,调整阈值比继续增大class_weight更可控。多试几个权重和阈值的组合,在验证集上找灵敏度和特异性都相对可接受的档位,再把最优组合固定到测试集上做一次最终评估。

5.4 随机种子漂移:结果差异到底是模型问题还是运气问题

现象:同一套代码、同一个数据集,换一个随机种子重新训练,跨病人的灵敏度从0.88掉到了0.81,F1也跟着波动。你开始怀疑是模型结构有问题还是数据划分有问题。

原因:脑电数据集、尤其是发作样本,本身的数量就不大,模型初始化、随机失活、数据打乱的顺序都会影响收敛路径。如果你只跑一次实验,这0.07的波动完全可能只是随机噪声。

解决:固定随机种子只是第一步,只做一次不是可靠的做法。更稳的方法是把训练过程重复跑5到10次,每次用不同的固定随机种子,然后报告灵敏度、特异性的均值和标准差。如果多次运行的性能波动超过正负0.03,说明当前数据规模下模型本身的稳定性还不够,优先去增加独立样本量,而不是继续微调超参数。

6. 进阶用法:把1DCNN当作特征提取器,用类激活图定位波形

6.1 冻结卷积层,把1DCNN固化成在线特征提取器

训练好的1DCNN不只是分类器,它的卷积层学习到的是一组对癫痫波形敏感的滤波器。在实际部署里,我经常去掉最后的全连接层,把GlobalAveragePooling之前的输出作为特征向量,喂给更轻量的下游模型或者直接做相似度检索。

# 提取全局平均池化前的输出作为特征向量 feature_model = tf.keras.Model( inputs=model.input, outputs=model.get_layer('global_average_pooling1d').output ) features = feature_model.predict(x_test) # features的形状为 (n_windows, 64),每个窗口变成一个64维向量

这个64维向量在后续的聚类、降维可视化、甚至跨中心数据迁移时非常好用。肉眼看一下特征分布,常常能看出模型是否把发作窗口和正常窗口分成了清晰的两团——如果重叠严重,那说明模型还在靠边缘细节做判断,特征不够健壮。

6.2 类激活图:确认模型看的是棘波而不是伪迹

脑电领域里模型"学歪"的情况非常多:数据里如果有小幅运动伪迹集中出现在某个导联,模型很可能学的是伪迹的形状而不是癫痫波形。类激活图(CAM)可以帮你看清决策依据。1DCNN的CAM就是最后一个卷积层每个时间位置的特征图,按类别权重做加权求和,然后映射回原始时间轴,得到"哪个时间片段对分类贡献最大"的热力图。

def compute_cam(model, x_window, class_idx=1): # 只实现思路:取最后一个卷积层的输出,用Dense层的第class_idx类权重加权 last_conv = model.get_layer('conv1d_2') # 注意按实际层名调整 cam_model = tf.keras.Model(inputs=model.input, outputs=last_conv.output) conv_output = cam_model.predict(x_window)[0] # (time_steps, channels) dense_weights = model.layers[-1].get_weights()[0] # (channels, n_classes) w = dense_weights[:, class_idx] # 在通道维上做加权平均,得到每个时间步的激活强度 cam = np.dot(conv_output, w) cam = np.maximum(cam, 0) # 只保留正向激活 cam = cam / (cam.max() + 1e-6) return cam

拿一段带发作标注的测试窗口跑这个cam,然后把热力图和原始波形画在一张图里,检查最大激活的时段是不是落在棘波最密集的地方。如果模型在正常段也给出高激活,就去查那个时段有没有眼电或肌电伪迹。我第一次跑这一步时发现模型其实对某导联的基线漂移高度敏感,而不是棘波——后来加了高通滤波、重新训练,测试集的性能才真正稳定。从那以后,"先看CAM再谈上线"就成了我检验这类模型的固定动作。一个模型就算通过所有量化指标,不看它的决策依据,你都不知道它下一秒会在哪个没有出现过的新伪迹上翻车。

这也解释了为什么我始终不建议跳步:先按受试者划分好数据,再用带标准化泄漏控制的预处理,搭一个卷积核尺寸匹配波形尺度的1DCNN,最后看一眼CAM确认模型在学什么。这套流程每一步都在减少一次"看着指标很好、落地就废"的风险,希望帮到你。

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

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

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

立即咨询