简介:基于两种脉冲神经网络预测脑电癫痫发作的实践项目包,面向脑电信号处理、神经计算与机器学习初学者及研究人员。项目以公开脑电数据集中单通道信号为分析对象,采用8~30赫兹频段内135个频率空间样本作为特征,对比LIF神经元等脉冲模型与传统方法在癫痫发作预测任务上的表现。压缩包共71个文件,约12.42MB,含27个脚本、14个实验笔记及16篇参考文献,另有流程图和结果图,便于从模型训练、特征提取、预处理到后处理快速理解整体设计。项目还完整记录了从原始脑电读取、短时傅里叶变换等预处理,到多脉冲与单脉冲网络训练、结果绘制的实验路径。目前已有414人学习下载,适合希望快速上手脉冲神经网络脑电建模、复现实验并扩展自己研究的人。借助完整代码、实验笔记和参考文献,可较清晰地梳理从脑电特征构建、脉冲网络搭建到结果评估的完整流程。
1. 为什么用 SNN 盯 EEG:癫痫发作是一条带时间戳的事件流
连续脑电监护是最折磨人的数据场景之一:一位患者背上记录仪躺三五天,真正发作加起来往往不到半小时,而你要在剩余上百小时里把假警报压到接近零。我在这类任务上试过 CNN、LSTM、Transformer,最后把注意力放回 Spiking Neural Network(SNN)上,原因很简单:脑电图(EEG)不是静态图像,而是一串按时间排列的节律演变。癫痫发作最常见的起始形态是棘波节律、慢波爆发和频率增高,这些都是典型的时域事件。而 LIF 神经元(漏积分放电神经元)天然就是漏电积分器,输入电流累积到阈值才发一个脉冲,时间轴上是逐拍推进的,这对发作波形特别对口。
本文不做科普式罗列,直接讲我在这条路上压箱底的一套组合:一种带替代梯度的监督训练 SNN,一种基于 STDP 的无监督 SNN,两者都基于 LIF 神经元模型。文章会把数据编码、模型实现、参数调法和踩坑点尽量写细,读者可以照着最小代码自己跑一遍,再根据自己设备通道和病历标注做替换。
2. 从脑电图到脉冲序列:两种 SNN 模型的共用数据管线
SNN 吃的是脉冲,不是浮点数。训练之前的这段转换,决定了两种模型最终是“认真预测癫痫”还是“对随机噪声分类”。我一般把这条管线分成三步:通道裁剪与滤波、脉冲编码、标签时序对齐。前两步解决“喂什么”,第三步解决“怎么算对”。
2.1 通道与频段选择:先裁成 8 通道、0.5 到 30 Hz,再谈编码
先说一个反直觉的结论:通道越多,SNN 越难训。EEG 设备动辄 32 导、64 导,但癫痫被监测到的泛化放电往往在中央区、颞区和枕区已经有强表达。多通道让网络参数上涨,而脉冲编码层对噪声更敏感——眼电和肌电伪差在头皮导联上幅度远大于脑电,一旦编码成脉冲,伪差会长时间霸占发放率,把真实的棘波节律淹没掉。
我一般会保留 8 个有效导联,以双极导联为主做差分。比如 Fp2-C4、C4-P4、P4-O2、Fp1-C3、C3-P3、P3-O1,外加两条靠近病灶区的横轴导联。这个集合覆盖了额-中央、中央-顶、顶-枕三条纵向链,发作期出现频率增高时,至少有两个通道能抓到。如果你的患者在颞叶有明确病灶,就把颞链 T3-T5、T4-T6 加进来,把额部导联去掉,病灶侧优先。
滤波这一步建议用 0.5~30 Hz 带通。0.5 Hz 以下主要是呼吸和电极直流漂移,30 Hz 以上主要是肌电伪差。带通之后再加一个 50 Hz 陷波,国内设备工频干扰很常见,国外设备按 60 Hz 处理。我习惯用 scipy 的 butter + filtfilt 做零相位滤波,避免相位偏移把发作起始点推后几十毫秒。
from scipy import signal def preprocess_raw(raw_data, fs=256.0): # raw_data: shape (channels, samples),已经是选择的8导联 # 1) 0.5~30Hz 带通,滤除直流漂移和肌电 b_bp, a_bp = signal.butter(4, [0.5, 30], btype='bandpass', fs=fs) data = signal.filtfilt(b_bp, a_bp, raw_data, axis=1) # 2) 50Hz 工频陷波(按当地市电频率调整) b_bs, a_bs = signal.butter(2, 50.0, btype='bandstop', fs=fs) data = signal.filtfilt(b_bs, a_bs, data, axis=1) return datafiltfilt 是零相位滤波,比 lfilter 好在不产生群延迟。4 阶巴特沃斯带通已经够用,阶数再高会让脉冲编码层对锐利棘波过度敏感。滤波之后强烈建议做一个坏道检查:如果某个通道的方差是其他通道均值的三倍以上,直接置零或者剔除,不要硬塞给编码器。
2.2 阈值对比脉冲编码:把幅度折叠成发放率,同时保留时间顺序
脉冲编码是 SNN 的入口,也是最容易被当成“黑匣子”的一步。常见做法有速率编码、首脉冲时间编码、阈值对比编码。对于脑电,我推荐阈值对比编码:每个时间步取窗口内的峰值幅度,归一化后作为脉冲发放概率,随机产生 0/1 脉冲。
为什么不用纯速率编码?纯速率编码需要统计一个时间窗内发放多少次,这使得发作起始处的高幅节律和背景慢波之间差异不明显,因为两者都能在固定窗口内放出足够多的脉冲。阈值对比编码更直接:幅度越大,本时间步发脉冲的概率越高,幅度接近本底噪声时就静默。这样棘波节律、电压升高、慢波爆发这些事件在脉冲序列上会呈现明显的高发段。
import numpy as np def threshold_contrast_encode(eeg_window, fs=256.0, dt=0.5, gain=2.0, v_ref=5.0, seed=0): """ 阈值对比脉冲编码。 eeg_window: shape (channels, samples) dt: 每个时间步代表多少秒 返回: shape (channels, n_steps) 的 0/1 脉冲 """ rng = np.random.default_rng(seed) win_len = int(fs * dt) # 每个时间步对应的采样点数 n_ch, n_samples = eeg_window.shape n_steps = n_samples // win_len spikes = np.zeros((n_ch, n_steps), dtype=np.uint8) for i in range(n_steps): seg = eeg_window[:, i * win_len:(i + 1) * win_len] x = np.abs(seg).max(axis=1) * gain p = 1.0 - np.exp(-np.clip(x, 0.0, 20.0) / v_ref) spikes[:, i] = (rng.uniform(size=n_ch) < p).astype(np.uint8) return spikes这里 gain 和 v_ref 是两个真正的自由参数。gain 控制整体发放率:脑电信号滤波后幅度大致在几十到一两百微伏,乘 2.0 后进入编码器才比较明显。v_ref 控制非线性压缩程度,5.0 是基准值,想让正常背景活动更静默就调大 v_ref,想让小幅度节律也能拉高发放率就调小。
随机数种子固定很重要。脑电实验讲究可复现,编码层一旦带随机性,每次训练等价于换了一份输入数据,模型差异会完全盖过参数差异。我一般会在每个患者上固定同一个 seed,跨患者重新生成。
2.3 标签时序对齐:把“发作段”切成监督窗口,并将预警窗口放在发作前
多数公开脑电数据集的标注格式是“发作起始时间 + 发作结束时间”,比如某个患者从 10:32:15 到 10:34:40 是发作。直接用整段发作做分类样本,会遇到一个麻烦:发作中期的大幅节律和发作起始前几秒的模式差别非常大,而临床真正需要的往往是提前几十秒的预警。
我的切法是把每一个发作起始点作为零时刻,往回取一段 20 秒的窗口作为正样本。这段窗口必须满足两个条件:窗口内没有发作,窗口距离发作起始点不超过预警目标时间。比如目标是提前 60 秒预警,那么窗口结束点落在发作前 60 到 1 秒之间都算正样本。
负样本从发作间期随机截取,但要排除发作结束后 5 分钟“发作后抑制期”的片段。发作后抑制期的脑电活动明显被压低,如果被当成负样本,模型会学到“高幅就是发作、低幅就是正常”这种错误规则,而真实发作前往往是从低幅快速升高的。
def build_window_labels(annotations, fs=256.0, pre_sec=60.0, win_sec=20.0): """ 构造监督样本的起止时刻。 annotations: [(start_sec, end_sec), ...],发作段标注 返回样本字典: {'start': ..., 'end': ..., 'label': ...} """ samples = [] for onset, _ in annotations: positive_end = onset - 0.5 # 留出半个时间步边界 positive_start = positive_end - win_sec if positive_end - pre_sec >= 0: samples.append({ 'start': positive_start, 'end': positive_end, 'label': 1, }) # 负样本在发作间期随机抽,这里省略具体随机选择逻辑 return samples这段代码只展示了正样本边界计算,实际工程里负样本数量通常是正样本的 5 到 10 倍,因为发作确实稀少。采集样本时还要注意窗口不能与任何发作段有重叠,否则正负样本边界会糊。标签对齐是整个管线里最需要人工检查的一步:我会把每个正样本窗口的起始、结束时刻打印出来,目测一段原始波形确认“这个窗口确实处于发作前正常节律”,再决定是否保留。
3. 两种 LIF 神经元 SNN:监督替代梯度与无监督 STDP 两条路线
模型部分才是标题所说的“两种 SNN”核心差异。两条路都建立在同一种 LIF 神经元模型上,区别只在于权重的更新方式:一条用反向传播,把梯度近似强行穿过脉冲函数;另一条用脉冲时间依赖可塑性,让突触权重自己长出来。这两条路线各有不可替代的价值。
3.1 LIF 神经元模型:泄漏、积分、发放、硬复位每一步都在改变输入到输出的关系
先固定神经元方程。LIF 神经元在连续时间的写法是膜电位随输入电流累积,同时向静息电位泄漏,一旦跨过阈值就发放,发放后膜电位复位。离散化之后每一步可以写成一个简单映射:
v_t = leak * v_{t-1} + x_t
其中 leak = exp(-dt / tau_m),tau_m 是膜时间常数,dt 是离散时间步。当 v_t 超过阈值 v_th 时,产生脉冲,并将膜电位复位为 0。把它写成可复用的 PyTorch 模块如下。
import torch import torch.nn as nn class LIFCell(nn.Module): def __init__(self, hidden, tau_m=20.0, v_th=1.0, dt=0.5): super().__init__() self.hidden = hidden self.tau_m = tau_m self.v_th = v_th self.dt = dt self.decay = torch.exp(-torch.tensor(dt / tau_m)) def forward(self, x, membrane): # membrane: 上一时间步的膜电位,形状 (batch, hidden) membrane = membrane * self.decay + x spike = (membrane >= self.v_th).float() membrane = membrane * (1 - spike) # 硬复位 return membrane, spike这里 tau_m 的单位要注意训练时常见混淆。生理上的 LIF 膜时间常数是 10 到 30 毫秒,但我们的时间步并不是毫秒步长:前面编码器的 dt 是 0.5 秒一个脉冲帧。此时再套用 20 毫秒的 tau_m,神经元在一个时间步内几乎漏掉全部电位,什么都发不出来。所以代码注释里的 tau_m=20.0 是以“时间步”为单位的经验值,表示膜电位大约 20 个时间步漏到 e^-1,相当于脑电层面的 10 秒左右。做脑电预测时我一般把 tau_m 设在 8 到 30 个时间步之间,而不是参考生理值。
“硬复位”也是一个关键决定:脉冲后用乘法把膜电位清零还是减去阈值电压。硬复位实现简单,但梯度在复位点会突变为 0,影响反向传播稳定性;减去阈值的方式更接近真实神经元,但会让膜电位在连续多发放时产生残余。工程上我优先用硬复位,后面会专门讲它的梯度坑。
3.2 模型 A:带替代梯度的监督脉冲网络——最小可运行代码
监督 SNN 的难点在于“脉冲发放函数不可导”。阈值比较是一个阶跃,它的导数在阈值处是无穷大、其余地方为 0,直接反向传播梯度必然消失。替代梯度做的事情很简单:在正向传播时用真实的阶跃函数,在反向传播时用一个形状相似的平滑函数导数替换。
我用一个快速 sigmoid 型替代梯度:
class GridStep(torch.autograd.Function): @staticmethod def forward(ctx, membrane, v_th): ctx.save_for_backward(membrane) ctx.v_th = v_th return (membrane >= v_th).float() @staticmethod def backward(ctx, grad_output): membrane, = ctx.saved_tensors # 快速sigmoid替代梯度:阈值附近梯度为1,远离阈值快速衰减 denom = (1.0 + torch.abs(membrane - ctx.v_th)) ** 2 return grad_output / denom, None正向用硬比较,反向给一个可穿过的梯度。这个替代函数的宽度决定了梯度回传的敏感区间:width 太窄,远离阈值的神经元拿不到学习信号,训练变慢;width 太宽,脉冲事件和非脉冲事件之间的梯度差异模糊,网络学不准。
配套的监督网络我把结构压到最小:输入层线性映射 + 一层 LIF + 全局脉冲率池化 + 线性读出层。全部代码约 60 行。
class SupervisedSNN(nn.Module): def __init__(self, in_ch, hidden=64, out_cls=2, tau_m=20.0, v_th=1.0, dt=0.5): super().__init__() self.fc_in = nn.Linear(in_ch, hidden) self.lif = LIFCell(hidden, tau_m, v_th, dt) self.readout = nn.Linear(hidden, out_cls) def forward(self, x): # x: (batch, time_steps, channels),已经是脉冲 batch, T, C = x.shape membrane = torch.zeros(batch, self.lif.hidden, device=x.device) spike_records = [] for t in range(T): current = self.fc_in(x[:, t, :]) membrane, spikes = self.lif(current, membrane) spike_records.append(spikes) spike_seq = torch.stack(spike_records, dim=1) # (B, T, hidden) rate = spike_seq.mean(dim=1) # 平均脉冲率 return self.readout(rate) model = SupervisedSNN(in_ch=8, hidden=64)训练时用标准交叉熵,优化器选 Adam,学习率 1e-3 起步。前向传播里是步进循环,每个时间步跑一次全连接和 LIF,时间步数 T 通常 40 到 80 步。读出层用的不是最后一个时间步状态,而是整段脉冲的平均发放率,这样模型不会因为窗口末尾恰好处于低发放相位而误判。
这种结构和标准 RNN 很像,但它每一步输出的不是浮点隐藏状态而是稀疏脉冲。实际训练中我观察到:如果编码层脉冲密度过低,LIF 层几乎没有输入,模型很快就变成常数输出;脉冲密度过高,所有时间步都被拉平,发作起始处的上升沿信息丢失。所以模型 A 的表现很大程度上由编码器发放率和 LIF 阈值共同决定,不是靠堆网络层数。
3.3 模型 B:STDP 无监督特征提取层 + 读出层,面向小样本优化
第二条路不走反向传播。STDP 的规则一句话:如果突触前神经元先发放、突触后神经元后发放,就把这条突触权重增强;顺序反过来,权重减弱。这不需要标签,只需要脉冲序列本身的时间顺序,因此非常适合标注稀缺的脑电数据。一个患者身上几个小时甚至几天的清醒脑电都可以拿来给 STDP 层做无监督自组织。
实现 STDP 的常见方式是给每个神经元维护两条指数衰减的“迹”:突触前迹由突触前脉冲刷新,突触后迹由突触后脉冲刷新。两个迹交叉相乘决定权重增减。
def stdp_update(weights, pre_spikes, post_potential, dt=1.0, tau_pre=4.0, tau_post=5.0, a_plus=0.01, a_minus=0.012): """ 简化STDP:权重按脉冲时间差更新。 pre_spikes: shape (T, n_pre) post_potential: shape (T, n_post),来自LIF层发放 """ T, n_pre = pre_spikes.shape _, n_post = post_potential.shape pre_trace = torch.zeros(n_pre) post_trace = torch.zeros(n_post) for t in range(T): pre_trace = pre_trace * torch.exp(-dt / tau_pre) + pre_spikes[t] post_trace = post_trace * torch.exp(-dt / tau_post) + post_potential[t] # 突触前脉冲到达时,若突触后有迹,则增强 weights += a_plus * torch.outer(pre_spikes[t], post_trace) # 突触后脉冲到达时,若突触前有迹,则减弱 weights -= a_minus * torch.outer(pre_trace, post_potential[t]) weights.clamp_(0.0, 1.0) return weights这个实现做了简化:真正的 STDP 需要记录每对脉冲的精确毫秒级时间差,而冗余的脑电时间步宽度是 0.5 秒,很难做精确事件排序。替代方案是“迹近似”,它把时间差信息折叠在指数梯度里,工程上足够好用。
整个模型 B 的结构是:输入脉冲 → 一层 LIF 神经元,权重由 STDP 更新 → 把脉冲率特征池化 → 线性读出层做有监督分类。注意 STDP 只训练前一层权重,读出层仍然需要少量带标签样本。训练顺序是:先用无标签脑电跑 STDP,把权重固定住,再抽取带标签样本训练读出层。
这套流程在小样本条件下比监督 SNN 稳。比如一个患者只有 3 次发作标注,直接训练模型 A,过拟合几乎是必然的;模型 B 的 STDP 层从海量无标签数据里学到了“哪类输入形态能引起持续发放”,读出层只需要在少量样本上学会关联最后一级特征。
但 STDP 也有明显的副作用:权重容易两极分化,强连接越来越强,最后整个网络只看少数几个输入通道,其他通道全部失效。所以权重必须 clamp 在 [0,1],并且每次样本结束后可以对权重做一次归一化,强制保留竞争能力。
4. 参数怎么定:采样率、时间步、阈值和损失不再“物理上正确就行”
把脑电变成脉冲、再把脉冲喂给 LIF,这中间每一步参数都相互耦合。单独谈某个参数没有意义,必须成套地看。我按经验把关键参数分成三组:时间组、膜电位组、损失评估组。
4.1 时间步 T 与采样率:每步时间步长决定模型“看到”多块的节律
编码器的 dt 表示一个脉冲对应脑电的时长。dt 太大,比如 1 秒,那么发作期 2~4 Hz 的棘慢波在同一个时间步里被压成一个脉冲,时间结构丢失;dt 太小,比如 0.05 秒,每个时间步的信号量不足,脉冲序列发散而稀疏,LIF 层的输入电流被噪声主导。
我一般用 0.25 到 0.5 秒。0.5 秒时间步能覆盖 2 Hz 慢波的半个周期,同时把一个 20 秒窗口压缩成 40 个时间步,训练开销可以接受。采样率本身要注意:256 Hz 的原始信号,不要做 4 倍降采样再去编码,直接保留原始采样率,在编码时用窗口长度换算即可。
| 时间步 dt | 20 秒窗口对应 T | 节奏分辨率 | 适用场景 |
|---|---|---|---|
| 1.0 s | 20 步 | 只能捕捉 1 Hz 以下趋势 | 不推荐,发作节律被压平 |
| 0.5 s | 40 步 | 2 Hz 的慢波可辨 | 默认起手参数 |
| 0.25 s | 80 步 | 4 Hz 棘波节律可辨 | 颞叶局灶发作或棘波密集时用 |
| 0.1 s | 200 步 | 高分辨率但训练慢 | 除非你有神经形态硬件,否则慎用 |
T 的大小决定了模型能不能看到发作前几十秒的“趋势爬升”。如果预警目标是提前 60 秒,而样本窗口只有 10 秒,模型就只能根据刚开始的节律突变做判断。我建议样本窗口长度等于或者超过预警目标的一半,即 20 秒起步。
4.2 阈值 v_th 与泄漏常数 tau_m:敏感度和稳定性的天平
v_th 是 LIF 的发放门槛。v_th 偏高,神经元几乎不发放,整层脉冲率接近 0,监督学习学不到任何信息;v_th 偏低,背景脑电本身就以高发放率通过,发作和正常的边界消失。这两个状态在训练曲线上表现完全相反,前者是 loss 不降,后者是 loss 降到一定程度后 test 指标波动剧烈。
我通常从 v_th=1.0 起步,同时监控第一层 LIF 的平均发放率。正常训练下,最好让 20% 到 40% 的时间步有脉冲出现。如果低于 5%,说明阈值偏高或输入权重初始化偏小,先降 v_th 而不是调学习率。注意 v_th 和输入线性层的权重初始化必须配套:线性层输出均值 0.1 时,v_th 设 1.0 就注定发不出来,初始化时把权重缩放控制在 0.05~0.2 范围内。
tau_m 影响神经元对输入的时间累积长度。tau_m 小,神经元忘得快,只对当前时间步敏感;tau_m 大,神经元会把前面十几步的输入都累计进来。脑电任务的 tau_m 我建议设成 10 到 30 个时间步,也就是 5 到 15 秒的生理累积。这个尺度既能捕捉发作前几秒的频率爬升,又不会把一个 20 秒窗口历史全合并掉。
| 参数 | 经验起点 | 范围 | 观察信号 |
|---|---|---|---|
| v_th | 1.0 | 0.1 ~ 2.0 | 首层脉冲率低于 5% 就调低 |
| tau_m | 20 步 | 8 ~ 30 步 | 发作起始处高发段是否被拉平 |
| 输入层权重初始值 | 0.1 | 0.05 ~ 0.3 | 训练初期 loss 是否下降过快 |
| 编码器 gain | 2.0 | 1.0 ~ 5.0 | 背景段发放率过高就调小 |
| 编码器 v_ref | 5.0 | 3.0 ~ 8.0 | 发作段与正常段脉冲率差异 |
4.3 损失函数与评估指标:不仅要正确,还要在正确前提前 60 秒
这部分容易被忽视,但它决定了两种 SNN 有没有实际临床价值。任务不是“这一段有没有发作”,而是“未来 60 秒会不会发作”。所以评估指标应该包含两个维度:一是有没有在发作起始前报出来,二是每小时假警次数。
我建议用“预警灵敏度”和“每小时假警数”两个指标替代准确率。准确率在极不平衡脑电数据里没有任何意义:哪怕模型永远输出“正常”,准确率也可能高达 95% 以上。用 F1 会好一些,但 F1 还不足反映时间提前量。正确的做法是做一个简单的事件表:每个发作是否被至少一次警告覆盖,警告是否发作前 60 秒内发出,发作间期每小时产生了多少次假警。
模型 A 是监督训练,直接在损失函数里加入权重平衡即可。我会把正样本权重设为负样本的 10 到 30 倍,具体数值取决于真实发作占比,通常发作者只占记录时长的 2% 到 5%。
训练时也要注意样本配对方式:一个发作前的正常窗口被标注为正样本,但同一患者相邻发作间期可能极其相似,模型容易学的不是发作前特征而是患者个体特征。这就是常说的过拟合患者。正确做法是训练集和测试集按患者分组,同一患者的样本不能横跨两边。
5. 避坑:SNN 预测癫痫发作最常踩的 5 个坑,现象到原因到解法
这一章的每条都是真正让我花掉通宵的坑。写出来是按“现象 → 原因 → 解决”的顺序,读者可以按需对照。
5.1 坑 1:网络输出全零,训练损失纹丝不动,梯度仿佛不存在
现象:训练 20 个 epoch,loss 完全不变,打印中间张量发现 LIF 层的脉冲输出全部为 0。很多人第一反应是调学习率或换优化器,实际上问题根本不在优化器。
原因:v_th 和输入电流不匹配。编码器输出脉冲后经过一个随机初始化的线性层,输入 LIF 的电流幅值只有 0.01 量级,而 v_th=1.0,膜电位累积几十步也跨不过阈值。反过来,硬复位函数的反向梯度在膜电位低于阈值时被替代梯度压缩得很小,即使偶尔发一个脉冲,梯度也回传不到输入端。
解决:先逐层检查脉冲发放率。我这里有一个调试口诀:给每一层 LIF 挂一个 hook,统计一段训练数据里脉冲数除以时间步数,目标是在 0.05 到 0.4 之间。如果为 0,把 v_th 调小到 0.1,同时把线性层初始化缩放倍率提高;如果脉冲率超过 0.8,说明阈值偏低,要反过来调高。调参顺序永远是先看脉冲率,再看 loss,不要颠倒。
5.2 坑 2:发作太少,编码层把发作本身也淹没在静默脉冲里
现象:训练样本里发作前窗口只有几十个,负样本上千个。模型很快就学会输出“正常”,训练损失比随机好一点,但灵敏度几乎为零。你以为是模型太傻,其实是正样本编码之后和负样本没有区别了。
原因:阈值对比编码的 p 值在全段数据上做过全局统计后再换算,导致少数有限的高幅发作前窗口被归一化压缩。另一个常见原因是正样本窗口取得太短:如果发作前窗口只有 5 秒,而这个患者发作前往往有一段短暂的慢波升高期,这 5 秒可能刚好落在平静段,脉冲特征和普通负样本完全相同。
解决:不要对全段数据做统一归一化,而是把编码器的 v_ref 参数在正样本和负样本子集上分别统计,保证两个子集的平均脉冲发放率有可区分度。同时把正样本窗口加大到 20 秒以上,如果仍然无效,检查你是否把发作后抑制期误标成了负样本,那是另一个数据污染源。
5.3 坑 3:训练测试时间泄漏让评估结果虚高,部署后再现不了
现象:离线交叉验证 AUC 0.98,敏感度 90%,一上实时数据马上崩到 40%。这种翻车几乎都来自数据拆分不当。
原因:EEG 是连续性记录,发作间期和发作前窗口之间的脑电状态存在时间相关。如果按随机样本划分训练集和测试集,训练集里一个窗口与测试集里另一个窗口可能只相隔 2 秒,测试窗口的脑电节律几乎复制了训练窗口的模式。这样模型学的其实是短期自相似性,而不是发作前特征。
解决:按患者分组,然后按时间段连续切分。同一个患者的前 70% 记录进训练集,后 30% 进测试集,中间留出 5 分钟过渡带不用。这是做脑电预测评估的基本纪律,但我看到太多人在代码里用 sklearn 的 train_test_split 默认随机划分,评价指标好看得完全失真。
5.4 坑 4:STDP 权重几分钟内爆成 NaN,之后全部脉冲变零
现象:STDP 权重在十几个样本后出现 NaN,再往下训练,整个模型的输出全部变成 0,而且 TensorBoard 里看不到任何预警,只是 loss 突然变 NaN。
原因:STDP 步进更新里,如果突触前脉冲和突触后电位同时处于高位,权重增量 a_plus 和 a_minus 同时累积,几次大脉冲后权重突破 clamp 边界,数值溢出。脉冲编码层的随机发放可能产生连续高发段,这是脑电节律本身的特点,不是代码 bug。
解决:给输入脉冲做归一化,让每个通道的脉冲率保持一致;STDP 权重更新完后用 clamp 归一化,不只是把数值压到 [0,1],而是按输入通道做 L2 规范化,让每个输出神经元的输入权重向量长度恒定。还建议把 a_plus 从 0.01 降到 0.003,STDP 本来就不用大步长,稳定优先。
5.5 坑 5:把 Python 循环推理时间理想化,以为 SNN 实用不了
现象:模型 A 在训练时一个 batch 要跑上百时间步循环,测试时你以为会快,实测 20 秒窗口推理用了 2 秒多,心里拔凉:这怎么上实时监测?
原因:LIF 网络本质上是一个循环网络,PyTorch 的 forward 循环一个时间步一个时间步跑,每步都要重新计算线性层和 LIF 层的前向,GPU 利用率极低。但这不代表 SNN 不行,只是你还没做时序折叠。
解决:当时间步固定时,将时间上的逐点循环合并成矩阵运算,一次算出所有时间步的电流,再用并行扫描方式做 LIF 累积。更简单的做法是把时间步循环展开,利用 PyTorch 的 scan 语义或直接把时间维和 batch 维合并计算。如果只是做离线预测,一个样本 300 毫秒内跑完完全可行;真正要上实时边缘设备,换 C++ 实现或者固件库,脉冲网络在硬件上的功耗优势才会体现出来。先用 CPU 单线程跑一次完整推理,记录真实耗时再评估能不能满足临床预警时延。
6. 最后的健康检查:做一次能在床旁设备上跑通的验证
模型在 Jupyter Notebook 里跑得再顺也不代表能落地。我习惯把最后一步做成一个固定在床旁设备上的端到端验证:原始数据推流进来,预处理、编码、SNN 推理、预测事件输出,全链路延迟低于 1 秒,并把每一次预测结果连同原始波形片段打上时间戳保存下来。
验证的关键是“滑动窗口 + 事件锁定”的协议。20 秒窗口每 2 秒滑动一次,连续 3 个窗口都输出正样本才产生一个预警事件,这样可以过滤掉单个窗口的随机脉冲波动。伪代码逻辑很简单:计数窗口连续预测为正的次数,达到 3 次就触发一次事件,事件触发后 60 秒内不重复触发。
复查时我会重点看被漏掉的发作:如果某个发作前后都没有触发任何事件,把发作起始前 60 秒的脉冲打印出来,一行一行看是编码层丢了信号,还是 LIF 层发放不足。相反,如果假警集中在某个时间段,很可能是患者翻身、电极接触不良造成的伪差,需要在编码层加一个伪差抑制阈值,例如所有通道同时发放的步直接置零。
两种模型在最后环节的分工也不一样。模型 A 适合作为高灵敏度预筛,模型 B 的 STDP 特征层适合做确认环节,两者拼接可以兼顾召回和误报。我最后给出的模型包通常是 A 为主、B 为辅:先跑一个快速 SNN 初筛,触发后再用 STDP 特征层核对,核对通过才输出警告。把靠在床旁的 0.7 TFlops 边缘盒子上做完整验证,再考虑生产部署。这一步不做,离线指标再高也只是纸上谈兵。希望这个组合方案对你手头的脑电癫痫预测项目有用,至少在参数起点和踩坑顺序上帮你省几个通宵。
本文还有配套的精品资源,点击获取