数据预处理实战:滤波、降采样、独立成分分析去伪迹、基线校正
好,咱们直接进入正题。数据预处理这步,说白了就是给原始脑电信号「洗澡」。你想想看,直接从放大器采回来的信号,里面什么都有——工频干扰、肌电、眼动、还有被试自己晃脑袋产生的伪迹。不洗干净,后面的迁移学习模型再牛也白搭。
我个人习惯把这四个步骤串成一个流水线:滤波 → 降采样 → ICA去伪迹 → 基线校正。顺序别搞反,尤其是ICA一定要放在降采样之后做,不然计算量会让你怀疑人生。
1. 滤波:先把脏东西筛掉
滤波是第一步,也是最容易出问题的一步。我见过不少新手上来就搞个0.1-100Hz的宽通带,觉得「保留信息越多越好」。其实不是这样的。
对于运动想象或SSVEP这类任务,我们关心的频段其实很窄。比如运动想象主要看mu节律(8-12Hz)和beta节律(18-26Hz)。你留太多高频成分,反而把肌电噪声引进来了。
我一般这么配:
from scipy import signal
import numpy as np
def apply_filter(data, fs, low=0.5, high=45):
"""
data: 通道数 × 时间点
fs: 采样率
"""
sos = signal.butter(4, [low, high], btype='bandpass', fs=fs, output='sos')
filtered = signal.sosfilt(sos, data, axis=1)
return filtered
# 再加一个50Hz陷波(国内工频是50Hz)
b_notch, a_notch = signal.iirnotch(50, 30, fs)
data_notched = signal.filtfilt(b_notch, a_notch, filtered, axis=1)
2. 降采样:别让计算量爆炸
现在很多脑电设备采样率动辄1000Hz甚至更高。但说实话,对于大多数BCI任务,250Hz完全够用了。为什么?因为我们的有效信号频率上限也就45Hz左右,根据奈奎斯特定理,100Hz采样就够了。留点余量,250Hz很安全。
降采样有个关键点:必须先做抗混叠滤波。直接降采样会产生频谱混叠,高频噪声会折叠到低频段,到时候你分不清是真实信号还是伪迹。
from scipy import signal
def downsample(data, original_fs, target_fs=250):
# 先做抗混叠低通滤波
cutoff = target_fs / 2 * 0.9 # 留10%余量
sos = signal.butter(8, cutoff, btype='low', fs=original_fs, output='sos')
data_lp = signal.sosfilt(sos, data, axis=1)
# 再降采样
factor = int(original_fs / target_fs)
downsampled = data_lp[:, ::factor]
return downsampled
3. ICA去伪迹:把眼电和肌电揪出来
独立成分分析(ICA)是去伪迹的利器。它的原理很简单:把多通道信号分解成统计独立的成分,然后找出那些明显是噪声的成分,把它们剔除。
怎么判断哪个成分是噪声?我一般看三个特征:
- 地形图分布:眼电成分通常集中在前额,呈对称分布
- 时间序列:眼电成分有明显的眨眼波形,肌电成分则是高频毛刺
- 功率谱:眼电成分低频能量高,肌电成分高频能量高
from sklearn.decomposition import FastICA
def ica_denoise(data, n_components=None):
"""
data: 通道数 × 时间点
返回去除伪迹后的数据
"""
n_channels = data.shape[0]
if n_components is None:
n_components = n_channels # 保留全部成分
ica = FastICA(n_components=n_components, random_state=42)
sources = ica.fit_transform(data.T).T # 转置成 成分数 × 时间点
# 这里需要手动标记哪些成分是噪声
# 实际项目中我会画图逐个检查
# 假设我们标记了第0、1、2成分为眼电
bad_components = [0, 1, 2]
# 将噪声成分置零
sources[bad_components, :] = 0
# 重建信号
clean_data = ica.inverse_transform(sources.T).T
return clean_data
🔑 关键经验: 我在做跨被试迁移学习时发现,不同被试的ICA成分差异很大。有的被试眨眼频繁,眼电成分能量特别高;有的被试肌电干扰严重。所以千万别用同一套ICA参数去处理所有被试的数据。我建议每个被试单独跑ICA,手动检查成分。
4. 基线校正:让数据站在同一起跑线
基线校正说白了就是「归零」。每个trial在刺激出现前会有一段静息期(比如-200ms到0ms),这段时间里理论上不应该有任务相关的脑电活动。但实际采集时,由于电极极化、直流漂移等原因,每个trial的基线电平都不一样。
基线校正的做法很简单:用刺激前这段数据的均值,减去整个trial的数据。
def baseline_correction(epochs, baseline_window=(-200, 0), fs=250):
"""
epochs: trial数 × 通道数 × 时间点
baseline_window: 基线窗口,单位ms
"""
# 将ms转换为采样点
start_idx = int(baseline_window[0] * fs / 1000)
end_idx = int(baseline_window[1] * fs / 1000)
# 计算基线均值
baseline_mean = np.mean(epochs[:, :, start_idx:end_idx], axis=2, keepdims=True)
# 减去基线
corrected = epochs - baseline_mean
return corrected
完整预处理流程
把上面四步串起来,就是一套完整的预处理流水线。我一般会封装成一个函数,方便批量处理多个被试的数据。
def preprocess_pipeline(raw_data, fs=1000, target_fs=250):
"""
完整的预处理流水线
raw_data: 通道数 × 时间点
"""
# 1. 滤波
print("正在滤波...")
data = apply_filter(raw_data, fs)
# 2. 降采样
print("正在降采样...")
data = downsample(data, fs, target_fs)
# 3. ICA去伪迹
print("正在运行ICA...")
data = ica_denoise(data)
# 4. 基线校正(假设epochs已经切好)
# data = baseline_correction(epochs)
return data
嗯,到这里预处理部分就讲完了。你可能会问:为什么顺序是固定的?其实我试过先降采样再滤波,结果发现降采样前的抗混叠滤波和后面的带通滤波有重叠,浪费计算资源。先滤波再降采样,能保证进入降采样环节的信号已经是干净的,抗混叠效果更好。
最后说一句:预处理没有标准答案。不同设备、不同实验范式、不同被试,参数都要微调。我给你的这套参数,是我在多个项目里试出来的「安全起点」。你可以在这个基础上根据实际情况调整。
📌 核心要点回顾:
- 滤波:0.5-45Hz带通 + 50Hz陷波,用零相位滤波
- 降采样:先抗混叠滤波,再降采样到250Hz
- ICA:每个被试单独跑,手动检查成分
- 基线校正:-200ms到0ms窗口,减去均值