拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MNE-Python脑电预处理全流程教程:读取、可视化、滤波与保存

MNE-Python脑电预处理全流程教程:读取、可视化、滤波与保存

做脑电数据处理这几年,MNE-Python一直是我最顺手的工具包,没有之一。这篇笔记是我MNE脑电信号处理学习笔记的第一篇,主题是脑电数据最基础也最绕不开的一整套流程:数据的读取、可视化、剪裁、滤波和保存。你可能会说,这不就是几个函数的事吗?确实,MNE把很多操作都封装成了几行代码,但真正上手时,格式选不对、参数没配好、滤波顺序错了、保存格式不合适,都会让你抓狂半天。这篇笔记就是专门给刚接触MNE、准备拿自己脑电数据练手的同学看的,把我踩过的坑、常用的参数配置、筛选逻辑都写清楚,让你少走弯路。

1. MNE是什么:脑电数据处理的第一站

1.1 MNE解决的三个痛点

MNE-Python(下文统一叫MNE)最初是专门为处理脑磁图(MEG)和脑电图(EEG)数据设计的开源Python库,后来成了整个神经科学领域处理电生理信号的事实标准之一。它能把从原始脑电文件到特征分析这条长链路吃干净,对我这种既要处理几十个通道连续记录、又要切分事件相关电位、还要做时频分析的研究人员来说,相当于把“读文件、预处理、切段、统计”这些活打包成了一整套标准零件。

它解决了我三个长期痛点。第一,脑电数据格式五花八门,EDF、BrainVision、BDF、NeuroScan、EEGLAB的set文件,每个厂商的脑电文件长得都不一样,MNE把这些读取接口统一成了一个模式,基本是“换一个read_raw_xxx函数,后面的处理逻辑完全一致”。第二,脑电信号里噪声太多,眨眼、肌电、电极松动、市电工频,MNE提供了滤波、ICA去伪迹、坏通道插值、重参考等一系列标准流程,不需要自己用numpy从头写滤波函数。第三,脑电数据量大,动辄上百MB甚至几个GB,MNE的Raw对象用内存映射的方式处理数据,不强制把所有数据一次读进内存,在实际操作中非常灵活。

1.2 环境搭建与数据准备

MNE的安装很简单,直接pip装就行:

pip install mne

如果你要在Jupyter Notebook里做交互式可视化,我建议再加两个包:

pip install jupyter ipympl

然后在Notebook里输入:

%matplotlib widget

这样出来的脑电波形就能放大、拖动、测量,比静态图片好太多。我做预处理的时候基本全程开着这个交互模式,眼睛盯住波形,随时判断滤波参数合不合适。

除了MNE本身,日常处理脑电几乎避不开numpy和pandas,建议一并装上。数据方面,你可以用自己手里的脑电数据,也可以去公开数据集找,比如PhysioNet上有大量带注释的睡眠脑电、癫痫脑电数据集,格式多为EDF,正好适合用来练手。我自己初期就是拿一个包含眼电伪迹的睁眼闭眼静息态数据来试流程,因为这类数据特征清晰,滤波和ICA效果很容易看出来。

2. 数据读取:把脑电文件变成Raw对象

MNE里所有的连续脑电数据都被封装成Raw对象,后面所有操作——可视化、滤波、剪裁、保存——几乎都是围绕Raw对象展开的。所以第一步的关键就是把不同格式的原始文件读成Raw。

2.1 读取EDF格式:最通用的起步姿势

EDF(European Data Format)是最通用的脑电数据格式之一,很多医院设备和公开数据集都用它。MNE读取EDF的代码非常直接:

import mne raw = mne.io.read_raw_edf('eeg_data.edf', preload=True) print(raw.info) print(raw.ch_names)

这里有个参数重点说一下:preload。它的作用是“是否把全部数据读进内存”。我个人的习惯是:如果文件不大(几十MB),直接preload=True,后续每次访问数据都快很多;如果文件特别大或者你只想先看看通道信息、做几秒钟试读,就用默认的preload=False,此时MNE是按需从磁盘读取数据,内存占用低,但后续每次做实际运算时它会自动帮你load,速度慢一些。

读取之后,raw.info会输出一大串信息,包括采样率(sfreq)、通道数(nchan)、通道类型、测量日期、文件内部的事件标记数量等。我每次读完文件先不急着看波形,一定先扫一眼raw.info,确认采样率跟我预期一致、通道名没有乱码、有没有畸形通道名,因为后面所有操作都建立在info正确的前提下。

2.2 读取BrainVision格式

BrainVision格式(.vhdr + .eeg + .vmrk)在科研圈也很常见,MNE同样提供了专门函数:

raw = mne.io.read_raw_brainvision('demo.vhdr', preload=True)

这个函数会根据.vhdr文件里记录的参数,自动找到对应的.eeg数据文件和.vmrk事件标记文件。读取后建议立刻看一眼raw.info['sfreq'],因为BrainVision格式允许不同通道用不同采样率(不推荐,但存在),一旦各通道采样率不一致,MNE会以最大采样率重采样并产生警告,你最好提前知道这一点。

其他常见读法再列两个备用:BDF格式用mne.io.read_raw_bdf,EEGLAB导出的.set文件用mne.io.read_raw_eeglab。用法大同小异,注意安装相应的依赖包(如pyedflib、pymatreader)即可。

2.3 读取数据时的三个高频坑

读取阶段我实际踩过、也帮别人排查过的坑主要有三个。

第一个坑是通道名不标准。有的设备会导出类似EEG Fz、Fz-REF这种夹杂额外字符的通道名,或者干脆把两个通道合并成一个字符串。这会导致后面做电极定位(set_montage)时完全匹配不上。解决办法是读取后先统一清洗,比如用raw.rename_channels批量改名,或者用字典把通道名映射成标准名称。

第二个坑是参考电极与接地电极的类型问题。有的EDF文件把参考通道标记为EEG类型,导致后续pick_types(eeg=True)时把参考也算进去了。我通常会在读取后用raw.set_channel_types手动把参考通道改成REF类型,或者直接用raw.drop_channels把多余通道删掉。

第三个坑是单位不统一。MNE读取不同厂商数据时,有些原始数据用的是µV,有些是V,有些甚至带着奇怪的缩放因子。读取后先统计一下各通道的信号幅度范围,比如计算raw.get_data().std(axis=1),可以快速判断有没有哪个通道幅度离谱。如果读出来信号小到1e-10量级,多半是单位换算没对上,要检查设备说明书里的单位定义。

3. 可视化:处理之前先把数据“看明白”

脑电处理有一条铁律:先看图,再动手。没有可视化,你根本不知道数据里是啥情况——是基线漂移严重,还是工频干扰很大,还是某个通道已经掉了。MNE提供了好几层可视化工具,我从最常用的讲起。

3.1 原始波形怎么看:raw.plot()

查看原始脑电波形的函数是raw.plot(),我用得最多的配置是这样的:

raw.plot( n_channels=10, # 一屏显示10个通道 duration=5, # 每屏显示5秒数据 scalings='auto', # 自动调整幅值显示范围 block=True # 窗口阻塞,关掉窗口才继续运行 )

block=True这个参数对我来说非常重要。如果脚本里后面还有自动处理步骤,不加block的话波形窗口一闪而过,连看的机会都没有;加上block,代码会停在这里,直到你手动关掉窗口再继续往下跑。这在调试流程时特别顺手,相当于一个可视化断点。

打开波形窗口后,不要只盯着波形来回翻。我一般先看三个东西:有没有某个通道的波形幅度比其他通道大一个数量级(多半是坏通道或电极松动);有没有明显的、周期性的50Hz噪声叠加在信号上(波形看起来像毛茸茸的锯齿);有没有大幅度的低频漂移(整段波形像波浪一样上下起伏)。这三种情况对应后续不同的处理策略:坏通道要么标记要么插值,工频噪声靠陷波,漂移靠高通滤波。

另外,raw.plot还有标记坏通道的交互功能。在波形窗口里直接点击某个通道名,它就会被标成红色,并且同步写入raw.info['bads']。这个操作看起来不起眼,实际上非常提升效率,我每次先花几分钟把明显坏的通道点掉,后续处理就省很多事。

3.2 通道信息与电极位置可视化

如果数据里带电极坐标,你可以把电极位置画出来,检查有没有放错位置的情况:

montage = mne.channels.make_standard_montage('standard_1020') raw.set_montage(montage) raw.plot_sensors(show_names=True)

这里分两步:先根据通道名创建一个标准的10-20系统电极位置模板,再把这个模板赋给Raw对象。如果通道名和模板里的电极名匹配不上,MNE会直接报错或者自动忽略不匹配的通道。

这个步骤常被新手忽略,但它对后面做topography地图、源定位、ICA去除眼电伪迹都很关键。没有电极位置,很多可视化功能直接废掉。有一个小技巧:如果标准模板里有部分电极匹配不上,多半是通道名大小写、多余字符问题,先清洗通道名再set_montage,能解决大部分报错。

3.3 频谱可视化:功率谱密度

处理脑电只盯着时域波形是远远不够的,频率域信息同样关键。MNE里看功率谱的写法在不同版本里有变化,新版本推荐用compute_psd:

raw.compute_psd(fmax=50).plot()

这里fmax=50表示只画到50Hz。不同频段对应不同生理意义:delta波(1-4Hz)在深睡眠多,theta波(4-8Hz)在困倦和记忆任务中常见,alpha波(8-13Hz)在睁眼闭眼放松状态非常明显,beta波(13-30Hz)与主动思考、运动相关。我拿到一段未知数据后,先看PSD,能快速判断数据大致属于哪类实验。

更关键的是,PSD能清楚暴露噪声的类型。50Hz附近出现又尖又高的峰,就是典型工频干扰;30Hz以上整体抬高,多半是肌电噪声;全频段均匀抬高,可能是放大器底噪;低频段异常抬高,往往是基线漂移或电极接触不良。每次做完滤波,我都会再画一次PSD对比,确认目标频段压下去了、该保留的成分没被误伤。

4. 数据剪裁:去坏段、选通道、切事件

原始脑电记录往往很长,中间可能有休息、有无关事件,还有被受试者动作污染的片段。剪裁分为三个维度:时间维度上切取感兴趣区段,通道维度上去掉不需要的通道,事件维度上把连续数据切成一段一段的事件相关数据。

4.1 时间维度裁剪:crop

crop是最直接的时间剪裁,它按时间早晚切一段:

raw_cropped = raw.copy().crop(tmin=10, tmax=120)

这段代码把数据切到第10秒到第120秒。这里有两件事要提醒。

第一,建议先raw.copy()再crop,因为crop默认是在原对象上直接修改。虽然它只是修改元数据、不实际释放原来的数据块,但如果你后面还要用原始数据做对比,先copy一份更稳妥。

第二,crop之后数据看起来变短了,但raw.info里面的“测量日期”等元数据不会变,所以如果你需要记录处理链条,最好自己另外保存一个处理日志,别指望自动留痕。

crop很常用,但有一个细节:处理脑电时,如果滤波或ICA之后前几秒经常因为边界效应变得不可用,很多人会选择先滤波、后crop,把受污染的边界直接切掉。这是一个低成本且有效的处理顺序。

4.2 通道维度选择:pick系列

数据里不一定每个通道都有用,比如有的文件把心电、呼吸、血氧都记录进去了,分析脑电时就得把它们挑出来单独处理。MNE提供了多种pick用法:

# 按通道名精确选择 raw_eeg_names = raw.pick(picks=['Fz', 'Cz', 'Pz']) # 按通道类型选择 raw_eeg = raw.pick_types(eeg=True, eog=False, stim=False) # 排除坏通道 raw_good = raw.pick_types(eeg=True, exclude='bads')

我习惯用pick_types(eeg=True)来框定所有脑电通道,因为它对通道类型的识别比名称更鲁棒。exclude='bads'这个参数建议每次都写上,它会把之前标记为坏通道的那些通道自动排除,避免它们混进后续分析里。

需要注意的是,pick类操作默认也直接修改原对象,需要用copy()保护原始数据的话就要先复制。另外,pick之后如果通道数太少,比如只剩两三个通道,后续做ICA或源定位可能会因为信息不足效果极差,这时候要想清楚为什么要drop,别手滑把关键通道删了。

4.3 事件切分:从连续数据到epochs

做事件相关电位分析时,连续数据只是原料,真正的分析单元是epochs——围绕每个事件标记截取的一段固定长度数据。MNE切epochs的核心是事件(events)数组。

事件数组是一个n x 3的整数数组,每一行包含三个数:事件发生的时间点(单位为采样点,不是秒)、前一个事件的数值(MNE用的占位值)、事件类型编码。要从连续脑电里提取事件,先要找到事件标记所在的刺激通道:

events = mne.find_events(raw, stim_channel='STI 014') print(events[:5])

这里的STI 014是很多EDF文件里默认的刺激通道名。不同设备、不同采集软件,刺激通道名字可能不同,有的叫Trigger,有的叫Status,甚至有的把刺激信息编码在某个模拟通道里。我第一次处理一个EDF文件时,死活找不到事件,后来发现该文件的刺激信息在STI 014通道里而不是我以为的那个通道。遇到这种情况,建议先把所有通道名列出来,找名字里带STI、TRIG、Trigger、Event、Digital之类的通道。

拿到events之后,再定义你要切哪些类型的事件,创建epochs:

event_id = {'visual_stim': 1, 'auditory_stim': 2} epochs = mne.Epochs( raw, events, event_id, tmin=-0.2, # 事件前200ms tmax=0.8, # 事件后800ms baseline=(None, 0), # 基线校正到事件前 preload=True ) print(epochs)

这里tmin=-0.2表示从事件前200毫秒开始截取,tmax=0.8表示截到事件后800毫秒。基线校正baseline=(None, 0)的意思是取事件前所有数据作为基线,把这段的平均值从整个epoch中减去,能有效消除直流漂移带来的基线差异。

创建epochs后,我建议第一时间检查两个东西:一是epochs.drop_log,看有多少个epoch因为超出数据边界或包含坏段被自动丢弃了;二是epochs.plot(),随机看几十个epoch,确认切出来的数据和事件对齐是否正确。这一步千万别省,如果事件延迟有偏差,后面所有的ERP结果都会出问题。

5. 滤波:选对参数,事半功倍

滤波是脑电预处理里最需要“知其所以然”的一步。MNE的滤波代码很简单,难的是理解为什么这样设参数、这样设会带来什么后果。

5.1 滤波参数怎么定:从生理信号说起

脑电滤波一般分三类:高通滤波、低通滤波、陷波滤波。高通滤波用来去掉低频漂移,常见参数是0.1Hz到1Hz;低通滤波用来去掉高频噪声,常见参数是30Hz到100Hz;陷波滤波用来去掉特定频率的干扰,最常见的是50Hz或者60Hz,对应市电工频。

怎么选具体数值,取决于你要分析什么信号。静息态功能连接分析,通常用0.1-40Hz;事件相关电位分析,往往用0.1-30Hz或0.5-30Hz;高频的gamma振荡研究,低通会放到100Hz甚至更高。如果只是做个通用预处理,0.1-40Hz是很多人的默认区间,既能保留大部分生理信号,又能滤掉绝大多数高频肌电和漂移。

市电频率要根据你所在国家来定,国内是50Hz,北美有些地区是60Hz。MNE里有专门的陷波函数:

raw_notched = raw.copy().notch_filter(freqs=50)

我看到有人会一次性列出多个陷波频率来滤基波加谐波,比如50、100、150Hz,但我的经验是:先做低通滤波,把40Hz以上的信号整体压掉一部分,那么50Hz陷波其实压力小很多;如果你的实验根本不关心40Hz以上的频段,低通滤波本身就能干掉大部分工频,陷波只是精修。

5.2 滤波实操代码

带通和陷波组合在一起的实际代码是这样的:

# 先做带通滤波 raw_band = raw.copy().filter(l_freq=1, h_freq=40) # 再做50Hz陷波 raw_clean = raw_band.notch_filter(freqs=50)

执行完后,我强烈建议做两件事:看一眼raw_clean.plot()确认波形没有明显变形,再看一眼raw_clean.compute_psd(fmax=60).plot()确认频段特征符合预期。

MNE滤波默认是零相位FIR滤波,也就是说滤波不会引入相位偏移,这对ERP分析极其重要——如果你用的工具会引入相位延迟,事件相关电位的时间点就会偏离真实位置。但零相位FIR不是没有代价,它需要更长的滤波器阶数,计算量大,而且边界会有振铃效应。解决办法一是滤波后裁掉首尾一小段,二是尽量在数据量足够多时先滤波后切割。

5.3 滤波的边界效应与顺序问题

我刚开始用MNE滤波时,总喜欢在crop之后直接filter,结果发现每次滤波后数据开头总有一段不自然的大幅波动,这就是边界振铃效应。滤波器在数据开头拿不到足够的历史数据,就会出现这种人为伪差。现在我的习惯是:先滤波,再crop掉边界那段受影响的区域,或者直接先filter再切epochs,因为epochs是切在数据中间的,不受边界影响。

滤波顺序方面,常规做法是先做高通去漂移,再做低通去高频噪声,最后陷波去工频。实际上只要不是特别离谱,顺序对结果影响不算大。但有一类特殊情况要小心:如果后续要跑ICA去眼电伪迹,我一般只先做1Hz高通滤波,不做40Hz低通,因为高频成分丢掉太多,ICA分离眼电和脑电的效果会变差。ICA做完之后再补低通滤波。这就是一个典型的“按步骤调整滤波”的场景。

还有一个小细节:滤波会对幅度有影响,这是正常的,因为带通滤波本身就是对频段加权。做ERP分析时,不同被试用了不同滤波参数会让结果完全不可比,所以同一实验里一定要统一滤波参数,并且在文章里明确报告。MNE里可以用raw.info['highpass']和raw.info['lowpass']来记录高通和低通的截止频率,保存数据后再次读取依然能看到,是个不错的元数据保留方式。

6. 数据保存:处理结果如何落地

处理完之后,数据总要存下来。MNE提供了很完善的保存机制,但不同的保存方式对应不同的下游需求,不能随手存一个格式就完事。

6.1 保存fif格式:MNE的原生格式

MNE的“原生格式”是.fif,保存只需要一行:

raw_clean.save('sub01_clean_raw.fif', overwrite=True)

用fif格式保存的好处是,你处理过程中的所有元信息都会跟着数据一起保存:通道类型、采样率、滤波参数、坏通道列表、电极位置、事件标记,甚至你手动添加的注释一并保留。下次mne.io.read_raw_fif读回来,基本就是接着上次处理完的状态继续往下做,断点续跑非常方便。

对于epochs级别的数据,同样有对应的保存方式:

epochs.save('sub01_epochs-epo.fif', overwrite=True) epochs_data = mne.read_epochs('sub01_epochs-epo.fif')

如果是多人数据集,每个人一个fif文件再统一管理,是目前我见过最整洁的方案。不建议把所有人的数据都揉进一个超大文件,MNE对文件大小和加载速度都会有压力。

6.2 导出EDF、CSV等其他格式

fif虽然好用,但有时候合作方、别的工具或平台不认识fif格式,这时候需要导出成通用格式。MNE从较新版本开始支持直接导出EDF:

raw_clean.export('sub01_clean.edf', fmt='edf')

导出EDF适合把预处理后的数据交给医院系统或老牌分析软件处理。注意不是所有数据结构都能完美导回EDF,比如某些特殊通道类型或刺激编码规则可能丢失,导出后最好重新读取一遍检查通道名和事件标记是否完好。

如果你想把脑电数据当成普通数值矩阵用pandas、Excel或者其他机器学习库来分析,那最常见的做法是导出CSV:

import pandas as pd data = raw_clean.get_data() # 返回 numpy 数组,形状=(通道数, 采样点数) df = pd.DataFrame(data.T, columns=raw_clean.ch_names) df.to_csv('sub01_eeg.csv', index=False)

拿到CSV之后,你就能用任何熟悉的数据分析工具来处理脑电数据了。但记住,CSV是纯数值文本,采样率、通道类型、电极位置这些元信息全部丢了,所以导出CSV只是“最后一步送数据出去”的做法,不建议作为中间处理环节的存档。

6.3 保存实操要点

保存这块我踩过最值得说的坑有两个。

第一个坑是中途处理完直接覆盖了原始文件。早期我在Notebook里调试,一个不留意raw.save('原始数据文件名.fif'),把原始数据覆盖了。后来我养成一个硬性习惯:原始数据永远只读不写,任何中间产物都保存成带处理步骤后缀的新名字,比如sub01_raw_orig、sub01_clean_1_40Hz、sub01_epochs_ica。这样处理流程可以回退、可以复现,一旦发现后面某步出了问题,还能回到上一步重新来。

第二个坑是保存前不记得drop坏通道。如果你在可视化阶段点击标记了一些坏通道,但没执行raw.drop_channels(raw.info['bads'])或后续的ICA插值,那么保存出来的数据里依然带着坏通道,只是通道名不太一样或数据还是脏的。所以保存前我会习惯性检查一下raw.info['bads']和raw.annotations,确认我自己知道的数据状态和实际数据文件是一致的。

7. 常见问题与排查技巧实录

最后把这套流程里经常遇到的典型问题整理成一张直观的速查表,都是我自己实际踩过或帮人排查过的案例,供你遇到类似情况时对症下药。

问题现象可能原因排查与解决办法
读取EDF报错或通道乱码非标准EDF头信息、编码问题用mne.io.read_raw_edf(..., verbose='error')缩小范围;检查通道名是否含空格/特殊字符
raw.plot()窗口一闪而过脚本没有阻塞等待窗口关闭加上block=True;Notebook环境改用%matplotlib widget
找不到事件标记刺激通道名称不对或刺激编码不在这条通道先print(raw.ch_names)找含STI/Trigger/Status/Event的通道;用mne.find_events时调整min_duration参数
滤波后数据开头出现大波动滤波器边界效应先滤波后crop,或者先切epochs再滤波,避开数据边界
50Hz工频滤不干净低通截止没设好或陷波参数不对先低通滤波压掉高频再陷波;检查是否用了notch_filter(freqs=50)
PSD里低频异常高基线漂移或电极接触不良使用高通滤波;检查原始记录时段的电极阻抗记录
保存后的fif文件打开没通道位置信息没执行set_montage保存前确认raw.get_montage()不为空
导出的CSV特别大,Excel打不开脑电数据本身采样点多降低采样率后再导出,或只导出分析需要的通道和时段

除了这张表,我再分享一个排查习惯。遇到数据异常时,不要急着改参数再跑一遍,先逐层定位:先看原始数据是否正常,再看处理后的数据是否正常,然后用二分法找到是哪个函数、哪个参数引入的问题。具体操作上,我会用raw.copy().crop(tmin=0, tmax=10).get_data()把一小段数据拿出来,打印最大值、最小值、均值,对比处理前后的数值变化。脑电数据的尺度一般在几十到几百微伏之间,如果你看到处理后的数据数值大到几千,十有八九是单位或者参考问题,而不是滤波参数问题。

最后再分享一个小技巧:每个处理步骤的关键参数,建议直接以注释的形式写进脚本,并且用raw.info里能保存的字段记录下来,比如设置前记得raw.info['highpass']和raw.info['lowpass']已经对应上你用的滤波频率。脑电预处理的“可复现性”比你想象的重要,几个月后回来整理数据写论文时,你会发现当初随手写的参数记录、坏通道记录有多值钱。

返回列表