样本熵+深度神经网络的癫痫检测:脑电数据处理与模型实现 简介一份关于癫痫自动检测方法的学术论文PDF面向医学信号处理、深度学习与机器学习方向的科研人员及学生。论文以脑电图数据为基础提出将样本熵与小波变换结合作为特征提取手段并以样本熵下降点作为发作标签训练深度神经网络最终检测准确率达到99.5%适合作为自动诊断、生理信号建模和DNN应用研究的参考资料。资源共1个PDF文件压缩包大小约1.13MB内容包含研究背景、数据集说明、算法流程、实验设计与结果分析等完整章节。目前已有76人学习下载。阅读该论文可快速掌握样本熵计算、小波预处理、模型训练与评估的完整思路也能为癫痫检测、EEG分类等相关课题提供方法借鉴和实验设计参考。1. 癫痫检测不一定要端到端样本熵先降维DNN再分类评估一套癫痫检测方案能不能落地只看分类准确率远远不够。我见过不少只用深度神经网络直接吃原始脑电的复现数据量小、标注不一致训练集上收敛很快一上测试集就现原形。反而是把样本熵作为前置特征、再交给深度神经网络做分类的路线在小样本医疗数据上更稳也更容易排查问题。这篇笔记就把“样本熵深度神经网络”的癫痫检测链路拆开从脑电切片、熵值计算、模型设计到验证协议和踩坑点给出能直接照做的参数和代码。2. EEG数据准备与滑动窗口切片先处理原始脑电再谈特征这一章先把“原料”处理干净。EEG信号能不能学得动取决于切片和标签做得干不干净。下面这些操作是整条链路里最枯燥、但最决定成败的一步。2.1 公开数据集CHB-MIT标注细TUH样本多做癫痫检测研究公开数据首选CHB-MIT这是波士顿儿童医院采集的23例耐药性癫痫患者头皮脑电。EDF格式采样率256Hz每人18~23个通道每个记录文件都附了文本标注精确给出该文件内每一次发作的起止秒数。这个数据集标注粒度细非常适合验证“样本熵在发作期是否真的下降”这类假设。如果嫌CHB-MIT的样本量不够做泛化测试可以用TUH EEG Corpus。它规模大得多来自坦普尔大学医院带癫痫与非癫痫的节段级标签。但TUH的标注粒度是“这段脑电属于哪种类别”没有CHB-MIT那种逐次发作的精确起止时间做窗口级二分类时要自己读标注文档再决定切法。学术使用两者都免费先看许可协议病人隐私数据不能外传。读取EDF文件用MNE库这是脑电分析的标准工具。它把EDF头信息里的通道名、采样率、导联顺序都解析好了省掉手工折腾二进制头文件的时间import mne raw mne.io.read_raw_edf(chb01_01.edf, preloadTrue) sfreq raw.info[sfreq] data raw.get_data() # 形状 (通道数, 采样点数) print(f采样率: {sfreq}, 信号形状: {data.shape})逻辑说明read_raw_edf默认只读取元数据preloadTrue才把信号真正加载进内存方便后面切片。data的每一行是一个通道一个1小时记录在256Hz采样率下约有92万个采样点。这里要反复提醒自己通道顺序一旦确定就不要乱换后面算样本熵按通道循环、拼特征向量、喂给DNN全都依赖这个顺序。2.2 滤波和伪迹处理0.5-40Hz带通加50Hz陷波EEG原始信号长什么样基线低频漂移、50Hz工频干扰、肌电尖峰、电极脱落造成的突变这些伪迹的幅度往往比真正的棘波还大。直接拿去算熵算出来的全是伪迹的复杂度而不是脑活动的复杂度。先做带通滤波。低端0.5Hz压住基线漂移高端40Hz保留癫痫发作的主要频段成分同时压掉一部分肌电伪迹因为肌电能量主要分布在40Hz以上。再用陷波滤波器干掉50Hz市电工频干扰注意如果数据是在60Hz电网地区采集的陷波频率要改成60。from scipy import signal def eeg_filter(data, fs256, low0.5, high40.0, notch50.0): 对 (通道, 采样点) 的EEG信号做带通陷波滤波 b, a signal.butter(4, [low / (fs / 2), high / (fs / 2)], btypeband) data_f signal.filtfilt(b, a, data, axis-1) bN, aN signal.iirnotch(notch, Q30, fsfs) data_f signal.filtfilt(bN, aN, data_f, axis-1) return data_f参数说明butter的阶数取4兼顾陡峭和平稳阶数越高幅频响应越陡但相位失真也越大。filtfilt做的是零相位滤波对检测任务很关键——普通lfilter会把波形整体平移发作起始点对的标签就会错位。iirnotch的Q30表示陷波带宽约为50/30≈1.7Hz既能吸掉50Hz±0.8Hz的工频干扰又不过度损伤相邻频段。滤波之后还要拒绝伪迹段。常见做法是幅度阈值头皮EEG正常节律很少超过500μV电极脱落或肌电爆发往往远超这个值。把超过阈值的采样点区间直接裁掉避免它们后面的熵值变成离群值。import numpy as np valid_mask np.max(np.abs(data_f), axis0) 500 # 幅度阈值单位μV data_f data_f[:, valid_mask]逻辑说明valid_mask按采样点标记哪些位置所有通道的幅度都小于500μVdata_f按列过滤后剩余的都是相对干净的段。这一步会砍掉一部分数据长度后面做窗口切片时要先检查剩余长度是否还够滑出足够的窗口否则该记录直接跳过。2.3 滑动窗口切片与标签发作起始处多留一个小时窗的缓冲连续EEG不能整段丢进模型要切成等长的短窗口。窗口长度选2秒比较合适太短小于1秒熵值的方差会非常大因为样本熵对短序列的估计本来就不稳太长大于10秒会把发作和背景混在同一个窗口里标签就脏了。滑动步长怎么定训练阶段可以无重叠步长等于窗口长度2秒也可以50%重叠步长1秒来扩充样本量。测试和验证阶段不要重叠否则同一个发作事件的相似片段会同时出现在训练和验证里评估结果虚高。这是很多复现翻车的源头。def split_windows(data, fs256, win_len2.0, stepNone): if step is None: step win_len win_pts int(fs * win_len) step_pts int(fs * step) windows [] for start in range(0, data.shape[-1] - win_pts 1, step_pts): windows.append(data[..., start:start win_pts]) return np.stack(windows) # 返回 (窗口数, 通道数, win_pts)逻辑说明stepNone时默认无重叠需要更多样本就把step改成1.0秒。返回值的形状是三维最后一维是每个窗口的采样点数256Hz下2秒窗对应512点。如果某个文件过滤后剩余长度不足一个窗口range循环直接得不到结果这种情况要捕获并记录。标签赋值时有一个关键细节不要把发作起止边界上的过渡段硬塞进某个类别。发作不是瞬间开始的标注时间前后各1秒左右的状态很难界定模型如果在这段上学到了“从背景到发作的过渡模式”换到临床数据上往往失效。左右各丢1秒再打标签win_starts np.arange(0, total_pts - win_pts 1, step_pts) labels np.zeros(len(win_starts), dtypeint) for start_s, end_s in events: # events 来自标注解析单位是采样点 start_s int(start_s fs) # 左丢 1 秒过渡 end_s int(end_s - fs) # 右丢 1 秒过渡 labels[(win_starts start_s) (win_starts end_s)] 1逻辑说明events是标注文件解析出的发作区间解析时要把“秒”乘以采样率换算成采样点坐标。代码里start_s fs和end_s - fs就是在左右各让出256个采样点1秒。做完之后检查一下labels.sum() / len(labels)正样本占比通常只有5%~10%这个比例直接决定后面损失函数怎么配。3. 样本熵的计算与批量抽取把一段脑电浓缩成一个数熵不是玄学它是衡量信号“乱不乱”的统计量。正常清醒状态的脑电复杂度高熵值偏高癫痫发作时大量神经元同步放电波形从杂乱变得有节奏熵值明显下降。这个落差就是分类器的“抓手”。3.1 为什么选样本熵而不是近似熵或排列熵计算时间序列复杂度有几种常见选择我直接说结论样本熵在这条链路上最稳。近似熵是经典方法但它把序列自身的点对也算进匹配统计里对短序列有偏估两个熵值在不同数据长度下排序会变这在分类任务里是大忌。排列熵计算快但它只比较相邻采样点的大小顺序丢掉了波形幅度信息而癫痫发作时的幅度变化和节律变化同样重要。样本熵的改进在于去掉了自身匹配统计一致性更好对噪声也更稳健。代价是计算复杂度O(N²)不过对2秒512个采样点的窗口来说完全可接受。三类特征在本文方案里的定位对比特征计算量对短序列稳定性可解释性本方案中的角色近似熵O(N²)有偏好备选不推荐排列熵O(N log N)较稳一般可作辅助特征样本熵O(N²)好好主特征推荐样本熵有两个参数嵌入维度m和相似容差r。常规取m2、r0.2倍序列标准差。m2是几十篇文献验证过的稳定值m再大对数据长度的要求指数上升我们的窗口只有512点。r取太小会把噪声当成有效结构取太大又失去区分度0.15~0.25×SD是安全区间。但前提是序列先做标准化否则不同通道的幅度基线不同r的实际含义完全不同这一点在第3.3节还要再强调。3.2 样本熵的向量化实现样本熵的计算逻辑可以描述成三步把长度为N的序列构造成N-m1个m维向量统计两两之间切比雪夫距离小于r的匹配对数对m维和m1维各做一遍最后取两个匹配对数的比值求负对数。写成公式就是SampEn(m, r, N) -ln(A/B)A是m1维匹配对数B是m维匹配对数。最笨的实现是双重循环遍历所有向量对512点窗口下要比较约13万个向量对Python跑一个窗口就要几十秒整个数据集根本跑不完。用scipy.spatial.distance.pdist把成对距离计算向量化能提速两个数量级import numpy as np from scipy.spatial.distance import pdist def sample_entropy(x, m2, rNone): x np.asarray(x, dtypenp.float64) if r is None: r 0.2 * np.std(x) N len(x) def _build_mat(seq, dim): n N - dim 1 return np.stack([seq[i:i dim] for i in range(n)]) B_mat _build_mat(x, m) A_mat _build_mat(x, m 1) B np.sum(pdist(B_mat, metricchebyshev) r) A np.sum(pdist(A_mat, metricchebyshev) r) if B 0 or A 0: return 0.0 return -np.log(A / B)逻辑说明_build_mat把序列转换成模板向量矩阵每一行是一个m维向量。pdist计算所有向量对之间的距离只返回上三角部分减少一半计算量。metricchebyshev对应样本熵定义里的切比雪夫距离也就是两个向量对应元素差的最大值。返回的-np.log(A / B)就是样本熵值越小说明信号越规则。参数说明rNone时取0.2 * np.std(x)这是默认行为不建议直接给一个固定数值因为不同通道不同数据段的幅度差异很大固定值没法用。边界情况要单独处理当序列标准差接近0时说明这通道已经掉线此时熵值没有意义返回0在后续训练里会被当作噪声处理。更稳妥的做法是在调用层先检查np.std(x) 1e-6就直接跳过该通道不进入熵值计算。3.3 批量抽取每个窗口、每个通道算一个熵值对每个切片窗口的每个通道分别计算样本熵得到一个长度为通道数的特征向量。以CHB-MIT为例通道数23那每个窗口的特征向量就是23维。这个维度足够小深度神经网络处理起来毫无压力而且每个维度都有明确的物理含义——该通道在该时间段内的信号复杂度。批量抽取的代码要把两件事一起做先对窗口内的单通道信号做z-score标准化再调用sample_entropy。标准化这一步不能省它保证r0.2*std在所有通道上含义一致否则有的通道幅度大、有的通道幅度小同一个r值对不同通道的“相似度”要求天差地别。def extract_features(windows, m2): n_win, n_ch, n_pts windows.shape feats np.zeros((n_win, n_ch)) for i in range(n_win): for c in range(n_ch): x windows[i, c] if np.std(x) 1e-6: feats[i, c] 0.0 # 脱线通道置 0 占位 continue x (x - np.mean(x)) / np.std(x) feats[i, c] sample_entropy(x, mm, r0.2) return feats # 返回 (窗口数, 通道数)逻辑说明外层循环遍历窗口内层循环遍历通道。每个通道独立标准化算完的熵值只反映该通道信号的“形态复杂度”不受幅度绝对值影响。脱线通道置0是占位策略真正的处理策略在第5章的排查部分讲。参数说明m2和r0.2是标准配置。如果你想扩展特征维度可以在熵值之外并列几个统计量比如窗口内信号的方差和过零率但不要堆太多相关性高的特征DNN在低维输入下更容易学到稳定边界。23维的熵值向量已经是合理的最低限度输入。4. 深度神经网络建模与训练特征向量进网络不是原始波形进网络特征矩阵准备好之后剩下的就是一个标准的二分类任务输入23维熵值向量输出是否为癫痫发作。这里有个很多人绕不过去的弯既然用了深度神经网络为什么不用原始波形端到端训练答案很简单数据量不支持解释性也没有。4.1 选型三层全连接别上CNN如果你在头歌平台上练过“给图片分类”的神经网络入门作业可能会惯性地上卷积网络。但这里的输入既不是图像也不是长序列而是每个窗口算出来的一堆熵值它们之间没有局部卷积结构。再加上一个病人只有几千个窗口样本深网络在这里只会把验证集上的loss震荡到怀疑人生。三层全连接网络已经足够。输入层接23维熵值特征第一层128维、第二层64维中间用ReLU激活和Dropout输出层1个节点接sigmoid。这个结构参数量不到2万个在小样本上不容易过拟合训练也快。用PyTorch定义import torch.nn as nn class EntropyDNN(nn.Module): def __init__(self, in_dim, hidden1128, hidden264, p0.4): super().__init__() self.bn nn.BatchNorm1d(in_dim) self.fc1 nn.Linear(in_dim, hidden1) self.fc2 nn.Linear(hidden1, hidden2) self.out nn.Linear(hidden2, 1) self.drop nn.Dropout(p) self.relu nn.ReLU() def forward(self, x): x self.bn(x) x self.drop(self.relu(self.fc1(x))) x self.drop(self.relu(self.fc2(x))) return self.out(x).squeeze(-1)参数说明in_dim等于特征向量的维度对应你的通道数。BatchNorm1d放在输入层之后能把各通道熵值的分布拉到一个相对集中的量纲减少通道间差异带来的训练难度。Dropout(p0.4)是经过比较的安全值低于0.3正则效果不够高于0.5容易欠拟合。输出层不接sigmoid因为损失函数用的是带logits的BCE数值上更稳定。4.2 类别不均衡与优化器配置正样本只有10%不能直接训把标签统计一下就会发现问题正样本发作窗口通常只占5%~10%负样本占了压倒性多数。如果不做处理网络学到的决策就是“永远输出0”准确率能到90%以上但敏感性是0一点用都没有。处理办法有两招可以同时用。第一招是类别加权损失BCEWithLogitsLoss的pos_weight参数按负样本数除以正样本数设置。第二招是过采样正样本在训练集上对有放回抽样正样本让每个epoch看到的正负比维持在1:2左右。注意只能在训练集上做验证集和测试集必须保持原始临床分布。from sklearn.utils.class_weight import compute_class_weight weights compute_class_weight(balanced, classes[0, 1], ytrain_labels) pos_weight torch.tensor([weights[1] / weights[0]]) criterion nn.BCEWithLogitsLoss(pos_weightpos_weight) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, patience5)逻辑说明compute_class_weight根据训练集标签自动算出每个类别的权重类别数越少权重越大。pos_weight取正类权重除以负类权重含义是“把正样本的损失放大多少倍”这个值通常落在5到20之间。优化器用Adam初始学习率1e-3配合ReduceLROnPlateau在验证loss连续5个epoch不下降时把学习率乘0.1避免后期震荡。参数说明batch_size从64开始试如果显卡内存紧张就32。学习率不要一上来就1e-2熵值特征虽然维度低但不同通道的熵值分布差异不小学习率太大容易在训练初期发散。4.3 训练循环与早停每一步都要看到验证指标训练循环本身不复杂但有两个细节要养成习惯。第一每个epoch结束都要在验证集上算AUC并保存最优权重训练结束后加载最优权重而不是最后一个epoch的权重。第二早停条件设为验证AUC连续10个epoch不提升直接停止。def train_one_epoch(model, loader, criterion, optimizer): model.train() total_loss 0.0 for xb, yb in loader: optimizer.zero_grad() pred model(xb) loss criterion(pred, yb.float()) loss.backward() optimizer.step() total_loss loss.item() * len(xb) return total_loss / len(loader.dataset)逻辑说明pred是logits不是概率criterion用的是BCEWithLogitsLoss内部会先做sigmoid再算交叉熵所以这里不需要手动加激活。每个epoch结束时用验证集跑一次预测把logits经sigmoid转成概率计算ROC AUC。AUC比准确率更能反映不均衡样本下的真实能力训练过程中要盯的指标就是它。完整训练循环大概长这样外层循环遍历epoch每个epoch先调用train_one_epoch再在验证集上计算AUCAUC比历史最佳高就保存模型连续10个epoch没提升就break最后加载历史最佳权重。这套流程写一次可以复用到所有类似信号分类任务。5. 验证协议与踩坑排查结果虚高多半是协议和熵值出了问题上一章的模型看起来能跑但评估结果能不能代表真实水平取决于验证协议是否严谨。这一章先给正确的协议再列我实际踩过的高频坑。5.1 必须按病人分组做交叉验证随机划分会让结果虚高EEG数据有一个天然陷阱:同一个病人的所有窗口高度相似。如果把同一个病人的窗口同时分到训练集和测试集模型实际上在“认病人”而不是“认发作”测试AUC可能高达0.95以上但换一批新病人立刻掉到0.7。正确做法是按病人分组做交叉验证。sklearn的GroupKFold专门干这件事groups参数传每个窗口对应的病人编号它会保证同一个病人的数据不会同时出现在训练和测试里。from sklearn.model_selection import GroupKFold gkf GroupKFold(n_splits5) for train_idx, test_idx in gkf.split(X, y, groupspatient_ids): # train_idx 里的病人编号与 test_idx 完全不相交 pass逻辑说明patient_ids是长度等于窗口数的数组每个元素是窗口所属病人的编号。GroupKFold按组切分5折交叉验证下每折的测试集是5个病人的全部窗口训练集是其余18个病人的窗口。这比随机划分慢但结果可信。做消融实验时对比“随机划分AUC”和“按病人划分AUC”的差距是判断模型是否真正学到泛化特征的最快方法。5.2 五个高频踩坑现象、原因、解决踩坑1模型全预测阴性准确率90%以上敏感性为0。 原因发作窗口只占5%~10%网络学会了输出全0因为预测“正常”在多数类上永远是对的。 解决用4.2节的pos_weight加权损失或过采样正样本评价指标以敏感性、特异性、AUC为准不要只看准确率。踩坑2验证集AUC很高换一批病人数据就翻车。 原因训练和测试划分没有按病人分组模型学到了病人个体特征而不是发作共性。或者切片时把发作起始前1秒的过渡段强行标为“正常”模型学到的是“是否处于发作过渡”而不是真正的发作模式。 解决严格用GroupKFold按病人划分标签赋值时按2.3节的做法左右各丢1秒过渡段不参与训练。踩坑3所有窗口的熵值挤在一起模型分不开。 原因没有对单通道信号做标准化就计算熵值不同通道幅度基线差异巨大r0.2*std在不同通道上含义不统一或者r取了一个固定数值在幅度大的通道上等于把一切结构都当成相似。 解决每个通道窗口内先做z-score标准化再算熵r0.2*std中的std是标准化后序列的标准差变成1这样r在所有通道上可比。踩坑4训练到一半loss变成NaN。 原因EEG原始数据存在脱线通道整段为0或恒定值特征矩阵里混入了全0向量或者学习率太大导致梯度爆炸。脱线通道在头歌这类平台的标准数据集里几乎不会出现但真实EEG里非常常见。 解决进入训练前检查np.isfinite(feats).all()和np.abs(feats).sum()对脱线通道不要只填0因为全0向量经过BatchNorm后可能产生NaN用该通道历史均值填充更稳。学习率从1e-4起步逐步调大不要一上来就1e-3。踩坑5样本熵计算太慢整个数据集跑不完。 原因pdist复杂度是O(N²)2秒窗口512点、23通道每个窗口要算约26万个成对距离如果窗口数有几万个纯Python循环要跑一晚上。 解决实验阶段把采样率从256Hz降到128Hz熵值对重采样不敏感计算量直接减半工程阶段用Cython重写内层循环或对滑动窗口做增量更新复用上一窗口距离矩阵中未变化的部分。不要为了省时间把窗口长度缩到1秒以下熵值估计会变得很不稳定。5.3 决策阈值先定敏感性再定阈值模型输出的是概率默认阈值0.5在类别不均衡下往往不合适。医疗筛查场景中漏诊的代价远高于误报所以应该先定一个敏感性底线再在这个约束下选特异性最高的阈值。比如要求敏感性不低于0.9然后在验证集上找到满足这个条件时特异性最高的阈值。from sklearn.metrics import roc_curve fpr, tpr, thresholds roc_curve(y_val, val_prob) # 找出敏感性 0.9 时特异度最高的阈值 valid_idx tpr 0.9 best_thr thresholds[valid_idx][np.argmax(1 - fpr[valid_idx])]逻辑说明roc_curve返回每个阈值对应的假阳性率和真阳性率。valid_idx筛出敏感性达标的所有阈值1 - fpr就是特异性取其中最大值对应的阈值就是“敏感性优先”的最优决策点。这个阈值要在训练阶段就定好并固定下来测试阶段不能再用测试集重新调否则又是数据泄漏。6. 从论文到落地这套方案的代价、边界与投入建议整套方案的实时性瓶颈不在深度神经网络而在样本熵计算。DNN推理一个23维向量耗时微秒级但每2秒窗口、23通道的熵计算在Python向量化实现下也要几十毫秒。这个量级做离线批量分析绰绰有余——比如长程脑电回顾性分析几十个小时的记录丢进去几十分钟出报告。但如果要做ICU床旁实时报警就得优化把采样率降到128Hz、用Cython或C重写熵计算、只对关键通道算熵甚至用增量更新的方式复用滑窗内的距离矩阵把单窗口延迟压到10毫秒以内。这套方案最值得投入的地方是可解释性。样本熵可以直接画成趋势曲线医生在界面上能看到发作起始时刻熵值明显下降的过程再对照原始波形确认这比端到端深度学习给出的黑匣子决策容易建立信任。如果目标是发论文熵特征曲线加上按病人分组的严谨评估本身就是可以独立成章的工作。如果目标是落地产品建议保留特征曲线作为辅助诊断证据而不是只给一个风险概率。边界条件要提前想清楚。第一肌电伪迹严重的数据段熵值会被伪迹拉高掩盖发作期的熵值下降这类样本要做伪迹段标注或剔除。第二如果是新生儿EEG正常背景节律和成人差异很大参数m、r以及决策阈值都要在新的年龄段数据上重新标定。第三多中心数据合并使用时不同采集设备的电压值域和噪声特性不同标准化之后再算熵能抵消大部分设备差异但最好还是按中心做一次交叉验证确认。我以前做信号处理相关项目时一度觉得深度学习必须原封不动吃原始波形才算先进结果被临床数据反复教育——把简单的熵特征做扎实模型再小也能找到真正可用的拐点。这套“先算熵、再用小网络分类”的路线在数据量有限、又需要可解释性的医疗场景里性价比远高于盲目堆模型。希望帮到你。本文还有配套的精品资源点击获取