ARTICLE DETAIL

资讯详情

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

1DCNN直接处理原始脑电时间序列:癫痫发作检测的端到端方案

1DCNN直接处理原始脑电时间序列:癫痫发作检测的端到端方案 简介这份资源提供基于一维卷积神经网络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或LSTM1DCNN处理原始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, fs256, win_len4, stride1): # 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), keepdimsTrue) std train_windows.std(axis(0, 1), keepdimsTrue) 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_sec0.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_classes2): # win_size: 单个窗口的采样点数例如4秒*256Hz1024 # n_channels: 使用的脑电导联数单导联传1多导联传实际导联数 inputs tf.keras.Input(shape(win_size, n_channels)) # 第一层大卷积核覆盖约0.25秒的信号匹配棘波-慢波复合体 x layers.Conv1D(filters64, kernel_size64, strides2, activationrelu, paddingsame)(inputs) x layers.BatchNormalization()(x) x layers.MaxPooling1D(pool_size4)(x) # 第二层中等卷积核提取棘波内部的局部形态 x layers.Conv1D(filters128, kernel_size16, strides2, activationrelu, paddingsame)(x) x layers.BatchNormalization()(x) x layers.MaxPooling1D(pool_size4)(x) # 第三层小卷积核做全时段的特征聚合 x layers.Conv1D(filters64, kernel_size8, activationrelu, paddingsame)(x) x layers.BatchNormalization()(x) # 全局平均池化代替Flatten减少参数量抑制过拟合 x layers.GlobalAveragePooling1D()(x) x layers.Dropout(0.5)(x) outputs layers.Dense(n_classes, activationsoftmax)(x) model Model(inputs, outputs) return model model build_1dcnn(win_size1024, n_channels1) model.summary()代码里几个参数要说清楚。第一层卷积核是64strides2等价于把时间轴下采样一半既压缩了数据量又让第二层卷积的感受野变大。最大池化pool_size4进一步把时间轴压缩到原来的四分之一。如果你在更大的数据集上训练可以适当增加滤波器数量但第一层超过128后收益就开始递减。BatchNormalization放在卷积之后、池化之前它让每一层的输入分布稳定在脑电这种非平稳信号上尤其重要——不同受试者的背景节律差异大BN可以帮助网络更快适应。全局平均池化GlobalAveragePooling1D是这结构里一个值得注意的选择。它把第三个卷积层的每个特征通道在时间维度上取平均得到一个向量。相比直接把时间轴Flatten成超长向量GAP的参数量少一个数量级。在训练数据只有几万个窗口的规模上少参数意味着更稳的收敛和更低的过拟合风险。代价是你会丢掉时间定位信息但这个信息在分类任务里不是必须的后面做可解释性时有另外的办法拿回来。模型编译和初调的优化器设置也一并给出。我通常先用1e-3的初始学习率跑20轮看趋势然后按早停结果回落。损失函数用sparse_categorical_crossentropy因为我们的标签是0/1整数不需要转成one-hot等额外操作。model.compile( optimizertf.keras.optimizers.Adam(learning_rate1e-3), losssparse_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_ratio0.15, test_ratio0.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_size64, epochs50, class_weight{0: 1.0, 1: 3.0}, # 发作类权重设为3.0 callbacks[ tf.keras.callbacks.ReduceLROnPlateau(monitorval_loss, factor0.5, patience4), tf.keras.callbacks.EarlyStopping(monitorval_loss, patience8, restore_best_weightsTrue) ] )class_weight的取值不要一上来就给太大。我踩过的经验是权重从2到5之间起步观察验证集上的灵敏度和特异性的变化。权重设到10以上模型会把大量正常段也判成发作灵敏度看起来很高但特异性崩到不如随机猜测F1反而下降。这里的batch_size64在大多数实验里是稳的显卡显存紧张就降到32但不要低于16否则BN层的统计量会因为小batch而抖动训练波动变大。早停的monitor用val_loss而不是val_accuracy这是因为类别不平衡下准确率会被多数类主导loss能更灵敏地反映少数类的拟合情况。patience8表示连续8个epoch验证集loss没有改善就停掉同时restore_best_weights把参数回滚到最优的那个epoch。ReduceLROnPlateau的factor0.5表示每次学习率减半patience4表示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, threshold0.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, threshold0.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.3F1反而比调之前低。原因权重过大会让模型倾向于把所有输入都判为发作。脑电数据里发作窗口占比本来就低把损失放得过大相当于告诉优化器错把正常当发作的惩罚远小于错把发作当正常模型于是选择保守的宁可错杀策略。解决把class_weight控制在2到5之间然后回到验证集上扫描threshold。通常你会发现用0.3或0.4做阈值比0.5更好——因为模型输出的概率被类别权重拉偏了调整阈值比继续增大class_weight更可控。多试几个权重和阈值的组合在验证集上找灵敏度和特异性都相对可接受的档位再把最优组合固定到测试集上做一次最终评估。5.4 随机种子漂移结果差异到底是模型问题还是运气问题现象同一套代码、同一个数据集换一个随机种子重新训练跨病人的灵敏度从0.88掉到了0.81F1也跟着波动。你开始怀疑是模型结构有问题还是数据划分有问题。原因脑电数据集、尤其是发作样本本身的数量就不大模型初始化、随机失活、数据打乱的顺序都会影响收敛路径。如果你只跑一次实验这0.07的波动完全可能只是随机噪声。解决固定随机种子只是第一步只做一次不是可靠的做法。更稳的方法是把训练过程重复跑5到10次每次用不同的固定随机种子然后报告灵敏度、特异性的均值和标准差。如果多次运行的性能波动超过正负0.03说明当前数据规模下模型本身的稳定性还不够优先去增加独立样本量而不是继续微调超参数。6. 进阶用法把1DCNN当作特征提取器用类激活图定位波形6.1 冻结卷积层把1DCNN固化成在线特征提取器训练好的1DCNN不只是分类器它的卷积层学习到的是一组对癫痫波形敏感的滤波器。在实际部署里我经常去掉最后的全连接层把GlobalAveragePooling之前的输出作为特征向量喂给更轻量的下游模型或者直接做相似度检索。# 提取全局平均池化前的输出作为特征向量 feature_model tf.keras.Model( inputsmodel.input, outputsmodel.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_idx1): # 只实现思路取最后一个卷积层的输出用Dense层的第class_idx类权重加权 last_conv model.get_layer(conv1d_2) # 注意按实际层名调整 cam_model tf.keras.Model(inputsmodel.input, outputslast_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确认模型在学什么。这套流程每一步都在减少一次看着指标很好、落地就废的风险希望帮到你。本文还有配套的精品资源点击获取
返回列表