写这份学习笔记之前,我翻了翻之前跑过的分析流程,发现真正卡住新手的往往不是某个算法不好懂,而是“读数据之后不知道下一步该干嘛”。MNE这个库本身功能很全,但全也意味着容易迷路:是先滤波还是先剪裁?用crop还是Epochs?为什么画出来的图全是毛刺?这篇笔记就把我平时处理脑电数据最常用的一条主流程——读取、可视化、剪裁、滤波、保存——完整过一遍,每一步都说明原因,也把踩过的坑一并写出来。适合手里刚拿到一批脑电数据、想用MNE做预处理但还没有完整思路的同学参考。
1. 先把环境收拾利索:MNE安装与原始数据的内存结构
1.1 我安装MNE时踩过的几个依赖坑
MNE本体装起来不难,pip install mne一行就能搞定。但实际处理脑电数据时,几乎一定会用到NumPy、SciPy、Matplotlib,做ICA去伪迹还需要scikit-learn。我最初图省事只装了mne,等到运行raw.plot()时才发现Matplotlib版本不匹配,弹窗直接报错,排查了半天才意识到是依赖版本问题。建议一次性装齐:
pip install mne numpy scipy matplotlib scikit-learn国内网络环境下,直接用pip有时候会卡在下载大文件上,我习惯先把pip源切到清华镜像,再安装就顺畅多了。版本方面需要注意的一点:MNE从1.0开始,有些旧API已经调整,比如raw.plot_psd()逐渐被raw.compute_psd().plot()取代,网上下载的旧代码直接跑往往会有DeprecationWarning,不是不能用,但最好跟着新版API走,后面维护省心。
如果你用的是Anaconda,也可以conda install -c conda-forge mne,conda-forge源里的MNE更新速度跟PyPI基本同步,还自动帮你处理底层依赖,对新手更友好。
1.2 Raw对象到底是个什么东西
MNE里所有原始数据都装在一个叫Raw的对象里。这个对象不是简单的二维数组,而是个三层结构:
- 数据矩阵:形状是
(通道数, 采样点数),比如64导脑电、采样率1000Hz、记录了10分钟,那矩阵就是(64, 600000)。 - info字典:保存了采样率、通道名称、通道类型、电极位置、滤波历史等所有元数据。
- annotations:保存实验过程中的注释,比如事件标记、伪影区间说明。
很多新手不理解为什么不能直接对原始数组操作。举个例子:你想看某个通道的数据,raw.get_data()拿到的是完整矩阵,但MNE里更推荐用raw.copy().pick_channels()或者raw.get_data(picks=['Fz', 'Cz']),因为这样拿到的数据仍然带着通道名、单位这些“上下文”。数据一旦脱离了MNE对象,单位、通道顺序这些信息就全丢了。
单位问题尤其容易被忽略。MNE内部统一用伏特(V)存储数据,但很多脑电设备导出时用的是微伏(μV)。如果没意识到这一点,画图时可能发现幅值小得离谱或者大得离谱,适应放大倍数之后才能看清波形。后面保存导出时,单位转换也是一定要处理的点。
1.3 FIF、EDF、BrainVision:三种常见格式读进来之后要核对什么
不同设备导出的文件格式不一样,MNE针对常见格式都提供了对应的读取函数。我实际用下来,最常碰到的三种:
| 格式 | 扩展名 | 读取函数 | 典型场景 |
|---|---|---|---|
| FIF | .fif | mne.io.read_raw_fif() | MNE原生格式,信息最完整 |
| EDF/EDF+ | .edf | mne.io.read_raw_edf() | 医院临床数据、公开数据集 |
| BrainVision | .vhdr | mne.io.read_raw_brainvision() | 科研设备(Brain Products等)常见导出格式 |
读取函数按文件格式选就行。比如BrainVision格式实际是三个文件一起的:.vhdr是头文件,.eeg是数据文件,.vmrk是标记文件,读取时只需要传入.vhdr的路径,MNE会自动去找另外两个。
读进来之后,我强烈建议先做三件事再谈处理:
import mne raw = mne.io.read_raw_fif("sub-01_raw.fif", preload=True) # 1. 看整体信息 print(raw.info) # 2. 看采样率和通道名 print("采样率:", raw.info['sfreq']) print("通道:", raw.info['ch_names']) # 3. 看坏道标注(默认是空的) print("已标注坏道:", raw.info['bads'])preload=True这一步很多人会忽略。如果不加,MNE是懒加载模式,数据不会一次性读入内存,后续每次访问都要去磁盘读,做滤波、切分段时速度慢得让人崩溃。如果你的数据量不大(几百MB以内),直接preload=True最省事。
如果设备导出的文件里包含多种类型的通道(比如脑电和眼电混在一起),可以先筛选出需要的部分再继续:
raw.pick_types(meg=False, eeg=True, eog=True)这句话的意思是只保留脑电和眼电通道,去掉可能存在的肌电、心电等通道。为什么要这么做?因为后续ICA去伪迹时,眼电通道是很重要的参考信号,而肌电通道如果不单独处理,会被混进ICA成分里干扰判断。
2. 可视化不是给你看的,是给你找问题的
很多教程把可视化放在最后当展示环节,但我的习惯是数据读进来第一件事就是画图。不是走形式看个大概,而是通过图快速判断这段数据“脏不脏”、要不要做额外处理。这一步做扎实了,后面滤波参数的设定才有依据。
2.1 raw.plot()交互界面的五个高频操作
打开交互式绘图界面:
raw.plot(n_channels=20, duration=10, scalings='auto', block=True)n_channels控制一屏显示多少个通道,duration控制每屏显示多少秒,block=True会让脚本停在绘图窗口,关掉窗口才继续往下跑——这个参数在Jupyter里跑很有用,不然图一弹出脚本就继续执行了,你根本来不及交互。
窗口打开之后,这几个操作是我每批数据都要用到的:
- 滚轮缩放:鼠标悬停在时间轴上滚动,可以拉近看单个波形的细节,也可以拉远看整段的大趋势。
- 按住左键拖动:平移时间窗口,快速扫视整段数据。我一般会花几十秒从头到尾过一遍,找有没有大幅飘移或突然的尖峰。
- 点击通道名:可以暂时隐藏某个通道,让波形显示更清爽。
- 鼠标悬停在数据点上:左下角会实时显示当前通道名、时间点、幅值。怀疑某段数据有异常时,鼠标指过去就能看到具体数值,比肉眼估靠谱得多。
- 选中一段区域右键:可以直接添加注释(annotation),比如标记一段明显的眼动伪影。这个操作对后面的Epochs处理特别有用。
scalings='auto'的意思是让MNE根据每个通道的实际幅值自动选择缩放系数。这个参数一定要加,否则脑电信号幅度(几十微伏)和眼电信号幅度(一两百微伏)相差很大,默认尺缩下脑电会被压成一条看似平整的线,什么细节都看不清。
2.2 用频谱图快速定位噪声源
只看时域波形还不够,我建议紧接着画频谱图:
raw.compute_psd().plot(fmin=1, fmax=80)如果是旧版本MNE,可以用raw.plot_psd(fmin=1, fmax=80),效果一样。
画这一步的目的是回答三个问题:
- 50Hz附近有没有一个异常突出的尖峰?我国市电频率是50Hz,如果屏蔽没做好,工频干扰会以50Hz为中心形成很明显的窄峰。一旦看到这个尖峰,后面就要考虑加陷波滤波。
- 低频段(0-1Hz)是不是能量异常高?如果是,说明存在明显的基线漂移,高通滤波的截止频率就不能设得太低。
- 高频段是不是一直喇着下不去?如果30Hz以上还有很大能量,很可能是肌电干扰比较重,这时候低通滤波的截止频率要考虑压低一些。
读图的时候对比一下电力线噪声的尖峰和脑电本身在alpha频段(8-12Hz)的能量峰:闭眼静息态数据在alpha频段会有一个小鼓包,如果没有、还全是乱七八糟的毛刺,那数据质量就要打问号了。
我每次处理新一批数据前,都会先看一眼频谱再决定滤波参数,而不是拿到数据直接套一个“高通1Hz低通40Hz”的模板。参数是服务于数据质量的,不是拿来装样子的。
3. 剪裁数据:按时间裁剪和按事件切分是两码事
“剪裁”这个词在MNE里其实对应两件不同的事:一种是直接按时间轴切一段数据出来用,另一种是按实验事件把连续数据切成一个个小片段。两种操作的目的完全不同,混着用容易出乱子。
3.1 copy()和crop()的恩怨
直接按时间裁剪用raw.crop():
raw_cropped = raw.copy().crop(tmin=10, tmax=100)tmin和tmax单位是秒。这里最关键的坑在于:MNE里很多方法是原地修改(in-place)的,crop()会在原对象上直接截断数据。如果你不想破坏原始数据,就必须先raw.copy()再crop,或者先把原始文件备份一份。
我第一次用crop时没注意这个问题,直接raw.crop(tmin=10),然后发现raw对象只剩下一分钟的数据了,后面想重新处理只能重新读文件。听起来是小事,但在处理经过复杂预处理的数据时,这个失误会浪费很多时间。
另外提醒一下copy()是深拷贝,数据量大的时候会占不少内存。比如一个1GB的FIF文件,copy后再crop,内存峰值可能到2GB以上。如果内存紧张,可以先crop再存盘,再从盘上读回,避免两个大对象同时在内存里。
3.2 从刺激通道里把事件标记抠出来
按事件切分数据,第一步是提取事件列表:
events = mne.find_events(raw, stim_channel='STI 014')events是一个n_events × 3的数组,每一行代表一个事件,三列的含义分别是:事件发生时的采样点序号、前一事件的数值(这个一般用不上)、事件编码。
不同设备的刺激通道名称不一样,有的叫STI 014,有的叫Trigger,有的叫DI16。如果你不确定自己的设备用的是哪个通道,直接查看通道列表:
print(raw.info['ch_names'])找到那个名字看起来像触发通道的,再去看它的数据类型。如果实在找不到,也可以print(events)看提取出来的事件编码是不是和实验设计一致。
find_events()默认会把一段连续的同一触发脉冲合并成一个事件,这个默认行为通常是对的。如果有些实验里需要保留每一次脉冲(比如连续TMS刺激),可以设置consecutive=False调整。
3.3 用Epochs把连续数据切成可用片段
事件列表提取好之后,就可以切分数据了:
epochs = mne.Epochs( raw, events, event_id={'Go': 1, 'NoGo': 2}, tmin=-0.2, tmax=0.8, baseline=(-0.2, 0), preload=True, reject=dict(eeg=150e-6) )几个关键参数说一下:
event_id把实验条件映射到事件编码上。比如编码1对应“Go”刺激,编码2对应“NoGo”刺激。做ERP分析时,后面按条件分别叠加平均,靠的就是这个映射。tmin=-0.2, tmax=0.8表示取刺激前200ms到刺激后800ms的数据。前200ms通常用作基线,所以baseline=(-0.2, 0)的意思就是用这段时间的平均值做基线校正。preload=True会把所有分段一次性加载进内存,后面的运算明显更快。reject=dict(eeg=150e-6)表示如果某段数据中任何一个脑电通道的峰值超过150微伏,这段就自动标记为bad。注意这里单位是伏特,150微伏要写成150e-6,写错单位会让拒绝阈值失效。
切完之后,epochs对象就成了一个三维数据结构:(试验次数, 通道数, 采样点数)。到这个阶段,你才真正拥有了可以做叠加平均、时频分析、源定位的“干净数据块”。
4. 滤波:参数好定,顺序更值得花心思
滤波是脑电预处理里最绕不开的环节,也是问题最多的环节。参数选得不合理,波形可能被扭曲;顺序搞反了,后面的ICA可能白做。这一节把我自己的处理逻辑完整写一遍。
4.1 高通、低通、陷波各管什么噪声
高通滤波(保留高频,滤掉低频)主要对付基线漂移。脑电记录过程中,电极与皮肤接触状态会缓慢变化,导致整段数据出现大幅度上下飘移,这种飘移的频率通常低于1Hz,对后续分析干扰很大。一般设0.1Hz到1Hz之间,具体看漂移有多严重。漂移明显的设高一点,不明显就设低一点,尽量保留更多低频脑电成分。
低通滤波(保留低频,滤掉高频)主要对付高频噪声和肌电干扰。肌肉活动(咬牙、皱眉、颈部紧张)会产生30-200Hz范围内的高频信号,混进脑电里。一般的ERP分析设40Hz低通就够了,如果关心的是高频gamma频段(30Hz以上),低通可以设到100Hz。
陷波滤波专门对付固定频率的工频干扰。国内市电50Hz,国外有些地方是60Hz。先把频谱图调出来看一眼,50Hz如果有尖峰就加陷波,没有就不加,不必习惯性加50Hz陷波。
具体代码:
# 只做高通 raw.filter(l_freq=0.5, h_freq=None, picks='eeg') # 高通+低通 raw.filter(l_freq=0.5, h_freq=40, picks='eeg') # 单独做50Hz陷波 raw.notch_filter(freqs=50, picks='eeg')4.2 滤波顺序比参数更影响结果
这是我觉得MNE预处理流程里最容易被忽略的部分。教科书上经常把滤波、分段、伪迹剔除分开讲,但实际处理时顺序错了,结果会差很多。
我自己的处理顺序是:
- 剔除坏道、设置参考电极
- 高通滤波(0.5Hz左右)
- ICA去伪迹(去除眼动、心跳等)
- 低通滤波(40Hz或100Hz)
- 分段成Epochs
为什么低通要放到ICA后面?因为ICA做成分分离时,靠的是不同信号在时间波形上的独立性。眼电伪迹和脑电在低频段有较多重叠,但波形形态差别明显,ICA恰恰能利用这些形态差异把眼电分离出来。如果你先做了40Hz低通,眼电伪迹里比较“锐利”的瞬态成分会被削掉一部分,ICA能找到的独立性线索就变少了,分离效果会明显变差。
这也是我自己实际试出来的教训。有一次处理一批行为学数据,我为了省事先把滤波全做完再跑ICA,结果ICA成分图里怎么也看不出一眼干净的眼电成分,反复调参数都没用。后来把低通挪到ICA后面,同样的数据,眨眼伪迹成分干净利落地就分出来了。
高通放在最前面也有讲究:基线漂移如果不先去掉,ICA会分出一些“飘移成分”,这些成分看起来像是低频振荡,容易干扰对真正脑电成分的判断。
4.3 滤波参数怎么选:以实际数据为准
滤波参数里除了截止频率,还有几个容易被忽略的选项:
raw.filter( l_freq=0.5, h_freq=40, fir_design='firwin', phase='zero', pad='reflect' )fir_design='firwin':用FIR滤波器而不是IIR。FIR是线性相位,对所有频率成分的延迟一致,不会造成波形扭曲,虽然计算量大一点,但脑电分析里波形保真度比实时性重要得多。phase='zero':零相位滤波,输出不会产生时间偏移。如果不设置这个,滤波后的ERP波形会出现几毫秒到几十毫秒的延迟,做潜伏期分析时会带来系统性误差。pad='reflect':对信号边缘做反射填充,减少滤波起始和结束时的边界效应。边界效应是滤波的固有问题——滤波器窗口在数据开头和结尾会“露出半截”,导致这两段的数据更不可靠。处理完数据后,我会把每段开头和结尾的几十毫秒数据当作潜在问题区,分析重点放在时间段中间。
滤波效果需要量化验证。我会在滤波前后各做一次频谱对比,确认目标频段确实被压下去了、非目标频段没有明显改变,而不是只看时域波形觉得“顺眼”就行。再就是滤波后的波形如果出现明显的振铃(在某个陡峭波形前后出现等幅振荡),说明滤波参数过于激进,要降低阶数或放宽截止频率。
5. 保存:存成什么格式,决定了你下一步能不能省事
预处理做完了,很多人随便存个CSV就交差,结果下次要回看某个参数、想换一种滤波方案重跑时,原数据已经找不到了,processed数据又缺信息,只能重新处理。保存这一环,值得认真对待。
5.1 处理完的数据优先存FIF
MNE的原生FIF格式保存的信息最完整,通道名、采样率、事件标记、坏道标注、滤波历史全部都会保留:
raw_clean = raw.copy().filter(l_freq=0.5, h_freq=40) raw_clean.save("sub-01_task-read_clean_raw.fif", overwrite=True)如果你已经切成了Epochs,也可以直接保存epochs:
epochs.save("sub-01_task-read_epo.fif", overwrite=True)下次使用只需要一行代码就能把预处理结果完整加载回来,不用重新读原始文件、重新滤波、重新分段。我个人的习惯是:原始数据不动,所有预处理结果都存成带_clean_raw或_epo后缀的新文件,文件名里写清楚被试ID和任务,这样后续做统计分析、画图,直接load对应文件就行。
5.2 什么时候导出CSV和MAT
虽然FIF格式最全,但有时候不可避免要跟别的工具链对接。比如要把脑电数据导入SPSS做统计分析,或者把这个数据交给不用MNE的同事,CSV和MAT就会派上用场。
导出CSV:
import pandas as pd data = raw_clean.get_data(picks='eeg').T * 1e6 # 转置成(时间点, 通道)并转成微伏 df = pd.DataFrame(data, columns=raw_clean.info['ch_names']) df.to_csv("sub-01_export.csv", index=False)导出MAT(给Matlab用):
from scipy.io import savemat savemat( "sub-01_export.mat", { "data": raw_clean.get_data(picks='eeg'), "sfreq": raw_clean.info['sfreq'], "ch_names": raw_clean.info['ch_names'] } )导出时有三件事必须处理,否则别人拿到数据会一头雾水:一是单位,MNE内部用伏特,统计分析常用微伏,我导出前会乘以1e6转成微伏;二是采样率,一定要写清楚,否则做时间轴分析就是猜盲盒;三是通道顺序,直接raw.get_data()返回的顺序和raw.info['ch_names']是对应的,导出的列名也按这个顺序写,不要自己重新排序。
5.3 保存前过一遍检查清单
预处理到保存之间,我每次都会过一遍检查清单,避免“存完了才发现漏了一步”:
- 坏道是否已经标注进
raw.info['bads']?如果只是肉眼看到某个通道波形异常但没标注,后续分析它还会被当作正常通道参与计算。 - 参考电极设置了吗?如果用的是平均参考,
set_eeg_reference('average')这一步执行了吗? - 滤波参数是否记录在案?我会把高通、低通、陷波的截止频率、滤波器类型、ICA成分数量等关键参数写进一个JSON文件,和数据一起存好。
import json params = { "subject": "sub-01", "task": "read", "sfreq": raw.info['sfreq'], "filter": {"l_freq": 0.5, "h_freq": 40, "fir_design": "firwin"}, "notch": None, "ica_components": 20, "reference": "average", "bads": raw.info['bads'], "n_epochs": len(epochs) } with open("sub-01_processing_params.json", "w") as f: json.dump(params, f, indent=2, ensure_ascii=False)这个JSON文件看起来不起眼,但过两个月再回来写论文时,它就是你还原整个预处理流程的救命稻草。脑电分析最忌讳的就是做完不记录过程,最后审稿人一问参数,你只能翻聊天记录。
我个人的习惯是,不管数据多急着分析,读取之后一定先花几分钟把图和频谱过一遍,再做任何处理。虽然看起来是“浪费时间”,但它能帮你避开一整天的无用功。还有一个实操上的小建议:如果做的是ERP研究,滤波之前的raw也留一份备份。不少审稿人和复现者会要求看到未滤波的原始数据和完整的处理流程,手上有这份备份,回审意见时会从容很多。