1. 脑电信号预处理:从“嘈杂录音”到“清晰对话”

大家好,我是老张,在脑机接口和神经科学领域折腾了十多年,处理过的脑电数据连起来能绕地球好几圈。今天咱们不聊那些虚头巴脑的理论,直接上手干,用Python把脑电预处理中最关键的两个环节——分段和基线校正——给彻底整明白。

你完全可以把这个过程想象成处理一段嘈杂的录音。我们的大脑无时无刻不在产生电信号,但用头皮电极记录下来的,可不止是“思考的声音”。这就像在一个喧闹的菜市场里,试图听清两个人的悄悄话。你能听到的,除了对话本身,还有隔壁摊位的叫卖声、路过的汽车声、甚至是你自己呼吸的杂音。这些无关的“噪音”,在脑电里我们统称为“伪迹”,包括眼动、眨眼、肌肉紧张、心跳,甚至头皮的汗液都会掺和进来。预处理的目的,就是动用各种技术手段,把这些杂七杂八的声音尽量滤掉,让我们能专注于大脑本身的“对话”。

分段基线校正,就是这场“降噪”音乐会的前两个关键乐章。分段负责把连续不断的“现场录音”,按照关键事件(比如被试看到图片、听到声音的瞬间)剪成一个个独立的“歌曲片段”。基线校正则像是给每个片段统一调音,消除因为设备或生理原因导致的整体音高偏移,确保所有片段都在同一个基准线上,方便我们比较和叠加。接下来,我就带你用Python,一步步把这两个步骤从概念变成屏幕上实实在在的代码和波形图。

2. 脑电分段:把连续的时间流切成有意义的“故事片段”

2.1 为什么非得“切一刀”?理解分段的本质

很多刚入门的朋友会问,原始数据不是好好的吗,为啥非要切碎?我打个比方你就懂了。假设你有一整天的监控录像,但你只想研究“每次有人推门进来”前后30秒发生了什么。你会怎么做?肯定是找到所有“推门”的时间点,然后把前后30秒的片段都剪出来,单独分析。脑电分段干的是一模一样的事。

原始的脑电数据是一个连续的时间序列,可能长达一小时甚至更久,里面包含了实验全程的所有大脑活动、伪迹和休息状态。但我们关心的,往往是大脑对特定刺激的反应。比如,在认知实验中,我们想知道大脑看到红色方块和看到蓝色方块时有什么不同。如果不分段,我们面对的就是一整条混杂了各种反应的“数据长河”,根本无从下手。

分段,就是根据实验程序中预先打好的标记,把这条长河截成许多条事件相关的小溪流。每一个片段,我们称之为一个 Epoch 或一个 Trial。这个片段通常以事件发生的时间点为中心,向前后各延伸一段时间。例如,在事件发生前200毫秒开始,到发生后800毫秒结束,这样就得到了一个时长1秒的Epoch。这样做有几个实实在在的好处:

  • 目标聚焦:分析对象从漫无目的的全局信号,变成了与明确实验条件绑定的局部信号。
  • 降维提效:数据量从数十GB的连续记录,变成了成千上万个长度统一的小数组,后续的统计分析、机器学习建模都变得可行。
  • 信噪提升:单个Epoch的信噪比可能不高,但当我们把同一个条件下所有Epoch叠加平均后,随机噪声会相互抵消,而稳定的事件相关信号则会凸显出来,这就是计算事件相关电位 的核心思想。

2.2 实战第一步:找到你的“事件标记”

理论说再多,不如代码跑一遍。我们选用业界最流行的 MNE-Python 库来操作,它功能强大且接口友好。假设你已经有了一个预处理(比如初步滤波)后的原始数据对象 raw。分段的第一步,是找到数据中的“路标”——事件标记。

在脑电实验中,刺激呈现软件会在数据流中插入特定的数字码,这些码就是事件标记。MNE可以很方便地读取它们。

import mne
import numpy as np
import matplotlib.pyplot as plt

# 假设我们已经加载了一个原始数据文件
# 这里用MNE自带的示例数据演示,你的数据可能是 .fif, .edf, .bdf 等格式
sample_data_folder = mne.datasets.sample.data_path()
sample_data_raw_file = sample_data_folder / 'MEG' / 'sample' / 'sample_audvis_filt-0-40_raw.fif'
raw = mne.io.read_raw_fif(sample_data_raw_file, preload=True)

# 查找事件:find_events函数会自动扫描刺激通道(如‘STI 014’)
events = mne.find_events(raw, stim_channel='STI 014')
print(f'找到了 {len(events)} 个事件')
print('事件数组前5行:\n', events[:5])

# 可视化事件分布,检查是否与实验设计吻合
fig = mne.viz.plot_events(events, sfreq=raw.info['sfreq'],
                          first_samp=raw.first_samp, event_id=None)
fig.show()

运行这段代码,你会看到一个 events 数组。它通常有三列:[采样点, 0, 事件ID]采样点 是事件在数据中发生的具体位置;事件ID 是一个数字,代表事件的类型(比如1代表红色方块,2代表蓝色方块)。mne.viz.plot_events 生成的图能让你一眼看清所有事件在时间轴上的分布,这是检查数据质量的重要环节,比如看看标记有没有漏打或错打。

2.3 动手分段:创建Epochs对象

找到事件后,我们就可以下刀了。需要定义两个关键参数:tmintmax,即每个Epoch相对于事件时间的起始点和结束点(单位:秒)。

# 定义我们感兴趣的事件ID。需要根据你的实验说明来映射。
# 这里示例数据中,事件ID 1 和 2 分别代表听觉刺激在左耳和右耳呈现
event_id = {'auditory/left': 1, 'auditory/right': 2}

# 定义分段时间窗:事件前0.2秒到事件后0.5秒
tmin = -0.2
tmax = 0.5

# 执行分段,创建Epochs对象
# `preload=True` 会将数据从硬盘读入内存,后续操作更快
# `baseline=None` 我们先不做基线校正,后面单独做
# `reject_by_annotation=True` 会自动跳过被标记为坏段的数据
epochs = mne.Epochs(raw, events, event_id, tmin, tmax,
                    baseline=None, preload=True,
                    reject_by_annotation=True)

print(epochs)  # 打印Epochs对象的信息,查看分段数量等

一个常见的“坑”是时间窗定义不合理。tmin 不能早于数据的实际开始时间(比如事件发生在记录开始后第2秒,你非要取事件前2秒的窗,那就越界了)。MNE会智能地处理这个问题,但你会得到更少的Epoch。另外,时间窗太长会包含更多无关信息,太短又可能丢失重要的神经反应成分,需要根据你的研究领域经验来定。

现在,epochs 对象已经包含了所有切好的数据片段。你可以像访问字典一样访问不同条件的数据:epochs[‘auditory/left’] 就是所有左耳听觉刺激对应的Epochs。让我们快速看一眼切出来的样子:

# 随机绘制几个Epoch的波形进行视觉检查
epochs.plot(n_epochs=5, n_channels=10, scalings='auto', title='原始分段数据')

你应该能看到一个个对齐的、长度一致的波形片段。但仔细看,你会发现每个片段的波形似乎不在同一个“零线”上,有的整体偏上,有的整体偏下。这就是我们接下来要解决的“基线漂移”问题。

3. 基线校正:给所有片段一个公平的“起跑线”

3.1 漂移从何而来?为什么必须校正?

当你观察上面画出的原始Epochs时,可能会疑惑:为什么大脑还没接受刺激(事件前),波形就不是平的呢?这通常不是大脑的“预热”活动,而是各种技术性和生理性伪迹导致的直流偏移慢漂移

主要原因包括:

  1. 电极阻抗变化:电极和头皮之间的导电膏干了,或者被试出汗,都会导致接触电阻改变,产生缓慢变化的电压偏移。
  2. 皮肤电位:皮肤本身会产生稳定的电位,且因人、因部位、因时间而异。
  3. 放大器特性:生物电放大器本身也可能存在微小的直流偏移。

这种偏移带来的最大问题是:它会污染你对事件相关脑电活动的测量。比如,一个在刺激后300毫秒出现的正波,其波幅可能有一部分是继承自基线期的正向偏移,而非纯粹由刺激诱发。如果不校正,当我们叠加不同试次时,这种随机的偏移不会抵消,反而会扭曲平均波形,让后续的波幅测量(比如N100, P300成分的振幅)完全失去意义。

3.2 基线校正的原理与操作:减法艺术

基线校正的思路极其直观:找一个我们认为大脑处于“静息”或“中性”状态的时间段,把这个时间段内的平均电压作为“零”参考点,然后整个Epoch的每个时间点都减去这个平均值。

这个“中性”时间段就是基线期。在绝大多数ERP研究中,基线期被定义在刺激呈现之前的一小段时间,比如刺激前200毫秒到刺激瞬间(0毫秒)。选择刺激前是因为理论上此时大脑还未受到当前刺激的影响,信号主要包含持续的漂移和背景脑电。

在MNE中,实现基线校正简单到只需一行代码。我们可以对刚才创建的 epochs 对象直接操作:

# 定义基线时间窗:使用事件前的时间段,例如 -0.2秒 到 0秒
baseline = (-0.2, 0.0)

# 应用基线校正
epochs.apply_baseline(baseline)

# 再次绘制,观察校正后的效果
epochs.plot(n_epochs=5, n_channels=10, scalings='auto',
            title='基线校正后的分段数据')

运行后对比前后两张图,你会发现校正后的Epochs,在刺激前(基线期)的波形基本上都围绕零线波动了。这就是校正成功的标志。apply_baseline 函数内部做的事情,就是为每个Epoch、每个通道单独计算其基线期内的均值,然后进行减法。

3.3 深入探讨:基线校正的陷阱与高级策略

事情当然不会永远这么简单。基线校正虽好,但用错了地方反而会引入新问题。这里分享几个我踩过的坑和对应的解决方案:

陷阱一:基线期本身有污染。 如果基线期内恰好有一次眨眼或大的身体运动,那么用这个被污染的平均值去做校正,会把伪迹“烙印”到整个Epoch上,导致整个片段失真。

  • 解决方案:在分段前或分段后,严格进行伪迹检测与剔除。MNE的 epochs.drop_bad() 方法可以基于峰值-峰值振幅等阈值自动剔除坏段。更精细的做法是结合独立成分分析 去除眼电、心电等成分后,再进行分段和基线校正。

陷阱二:该不该对每个试次单独校正? 我们上面做的就是试次特异性基线校正,这是最标准的方法。但在某些特殊分析中,比如研究非常慢的电位变化(慢皮层电位),或者数据经过了一些特殊的重参考处理(如平均参考)后,可能需要考虑其他策略,例如使用整个实验区块或某个条件所有试次的全局平均基线。这需要根据具体科学问题来定。

陷阱三:基线期长度选择。 太短的基线期(如50毫秒)可能不足以稳定地估计偏移,容易受随机噪声影响;太长的基线期(如500毫秒)又可能包含了非预期的认知活动(比如前一个试次的残留效应)。-200ms到0ms是一个经过时间检验的、广泛接受的默认选择,对大多数ERP研究都安全。

为了更直观地展示基线校正的效果,我们可以写个小脚本对比一下校正前后,某个通道在刺激后特定时间点波幅的分布变化:

# 选取一个中央区电极,例如EEG 052
channel_name = 'EEG 052'
pick_channel = mne.pick_channels(epochs.ch_names, include=[channel_name])

# 提取校正前后,刺激后100ms处的波幅数据(假设我们关心P100成分)
latency = 0.1  # 100毫秒
# 计算该潜伏期对应的样本点索引
sample_idx = np.argmin(np.abs(epochs.times - latency))

# 由于apply_baseline是原地修改,我们需要重新创建一份未校正的数据用于对比
epochs_uncorrected = mne.Epochs(raw, events, event_id, tmin, tmax,
                                 baseline=None, preload=True,
                                 reject_by_annotation=True)
amplitude_uncorrected = epochs_uncorrected.get_data()[:, pick_channel, sample_idx].squeeze()
amplitude_corrected = epochs.get_data()[:, pick_channel, sample_idx].squeeze()

# 绘制分布对比图
fig, ax = plt.subplots(1, 2, figsize=(10, 4))
ax[0].hist(amplitude_uncorrected * 1e6, bins=20, alpha=0.7, color='skyblue', edgecolor='black')
ax[0].axvline(x=amplitude_uncorrected.mean()*1e6, color='red', linestyle='--', label=f'Mean: {amplitude_uncorrected.mean()*1e6:.2f} µV')
ax[0].set_xlabel('Amplitude (µV)')
ax[0].set_ylabel('Count')
ax[0].set_title('Before Baseline Correction')
ax[0].legend()
ax[0].grid(True, alpha=0.3)

ax[1].hist(amplitude_corrected * 1e6, bins=20, alpha=0.7, color='lightgreen', edgecolor='black')
ax[1].axvline(x=amplitude_corrected.mean()*1e6, color='red', linestyle='--', label=f'Mean: {amplitude_corrected.mean()*1e6:.2f} µV')
ax[1].set_xlabel('Amplitude (µV)')
ax[1].set_title('After Baseline Correction')
ax[1].legend()
ax[1].grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

你会发现,校正前后,波幅的分布形态可能变化不大,但整体的中心(均值) 会发生变化。基线校正的目标不是改变分布,而是移除那个共同的、影响所有试次的直流偏移,使得波幅的测量值更能真实反映神经活动的变化。

4. 构建稳健的预处理流水线:超越基础操作

4.1 将分段与校正嵌入完整流程

在实际项目中,分段和基线校正很少是孤立的步骤,它们是一个自动化预处理流水线中的一环。一个典型的、相对完整的流水线可能长这样:

def preprocess_eeg_pipeline(raw_file_path, event_dict, tmin, tmax, baseline_window, l_freq, h_freq):
    """
    一个简化的EEG预处理流水线函数。
    参数:
        raw_file_path: 原始数据文件路径
        event_dict: 事件ID与名称的映射字典
        tmin, tmax: 分段时间窗
        baseline_window: 基线校正时间窗 (tuple)
        l_freq, h_freq: 带通滤波的高低截止频率
    """
    # 1. 读取数据
    raw = mne.io.read_raw_fif(raw_file_path, preload=True)

    # 2. 设置电极位置(如果有单独的坐标文件)
    # montage = mne.channels.read_custom_montage('your_montage_file.loc')
    # raw.set_montage(montage)

    # 3. 滤波:去除高频噪声和低频漂移(先于分段!)
    raw.filter(l_freq=l_freq, h_freq=h_freq, fir_design='firwin', phase='zero-double')

    # 4. 重参考:例如转换为平均参考
    raw.set_eeg_reference(ref_channels='average', projection=False)

    # 5. 检测并标记坏段(剧烈运动等)
    # 这里可以使用自动算法或手动检查raw.plot()来标记
    # annot = mne.Annotations(...)
    # raw.set_annotations(annot)

    # 6. 寻找事件
    events = mne.find_events(raw, stim_channel='STI 014')

    # 7. 创建Epochs,并设置拒绝阈值以自动丢弃坏段
    reject_criteria = dict(eeg=150e-6)  # 峰值-峰值振幅超过150µV的试次丢弃
    epochs = mne.Epochs(raw, events, event_dict, tmin, tmax,
                        baseline=None,  # 先不校正
                        preload=True,
                        reject=reject_criteria,
                        reject_by_annotation=True)  # 尊重第5步的标记

    print(f'分段后保留 {len(epochs)} 个试次。')

    # 8. 运行ICA去除眼电等伪迹(可选但推荐)
    # ica = mne.preprocessing.ICA(n_components=20, random_state=97)
    # ica.fit(epochs)
    # ica.exclude = [...] # 通过ica.plot_components()手动选择或自动检测
    # ica.apply(epochs)

    # 9. 执行基线校正
    epochs.apply_baseline(baseline_window)

    # 10. 插值坏导(如果在epochs创建后发现有坏导)
    # epochs.info['bads'] = ['EEG 012'] # 标记坏导
    # epochs.interpolate_bads(reset_bads=True)

    return epochs

# 使用示例
config = {
    'raw_file_path': 'your_data.fif',
    'event_dict': {'condition/A': 1, 'condition/B': 2},
    'tmin': -0.2,
    'tmax': 0.8,
    'baseline_window': (-0.2, 0.0),
    'l_freq': 0.1,  # 高通0.1Hz去除超低频漂移
    'h_freq': 40.0   # 低通40Hz去除肌电和高频噪声
}

clean_epochs = preprocess_eeg_pipeline(**config)

这个流水线体现了几个关键顺序原则:滤波通常在分段之前进行,因为滤波(尤其是高通滤波)本身就能消除一部分慢漂移。重参考通常在滤波之后、分段之前严格的伪迹剔除(如基于振幅的拒绝或ICA)最好在基线校正之前完成,以免被污染的基线影响校正效果。

4.2 结果验证与可视化:相信你的眼睛

所有步骤完成后,不要急着跑统计。花时间做质量检查至关重要。除了之前用到的 epochs.plot() 进行随机浏览,还有几个强有力的可视化工具:

  • 绘制所有试次的叠加平均图:这是查看ERP成分的金标准。

    evoked = clean_epochs.average()  # 计算所有试次的平均
    evoked.plot(titles='Average ERP after full preprocessing', time_unit='s')
    

    观察平均波形是否平滑,经典的ERP成分(如N1, P2, P3)是否清晰可见。

  • 绘制试次图像图:这张图能同时展示所有单个试次和它们的平均。

    clean_epochs.plot_image(picks='EEG 052', combine='mean', title='Single-trial activity at EEG 052')
    

    你可以看到每个试次的颜色图,以及叠加在上面的平均波形。检查基线期(时间0点左侧)是否颜色均匀地围绕零值(通常用绿色表示),这是基线校正成功的另一个直观证据。

  • 检查试次数量:确保每个条件有足够的试次(通常>30-40个)来进行可靠的统计分析。len(clean_epochs[‘condition/A’]) 可以告诉你具体数字。

数据处理从来不是一套固定拳法,而是一门需要根据数据实际情况不断调整的艺术。分段和基线校正为你搭建了稳固的舞台,但台上的表演——即后续的时频分析、源定位、机器学习分类——能否精彩,很大程度上取决于这个舞台搭建得是否平整牢靠。多动手,多画图,多思考每个步骤背后的“为什么”,你就能越来越熟练地驾驭这些工具,让脑电数据真正开口说话。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐