一、什么是时频分析?
1.1 ERP 的局限性
python
到目前为止,我们做的是 ERP 分析:
ERP = 时间 × 振幅
↑
只保留了时间信息
频率信息被叠加平均"抹掉"了
问题:
大脑的某些活动不是"时间锁定"的
而是"频率锁定"的
比如:闭眼后 Alpha 波增强
这个变化在时间上不是精确对准的
但在频率上非常明显(8-13Hz)
1.2 时频分析的原理
python
时频分析 = 时间 × 频率 × 功率
↑ ↑ ↑
什么时候 什么频率 多强
就像看乐谱:
横轴是时间(第几秒)
纵轴是频率(什么音高)
颜色是强度(多大声)
时频分析就是给脑电信号写"乐谱"!
1.3 什么是 ERD/ERS?
python
ERD = Event-Related Desynchronization(事件相关去同步)
ERS = Event-Related Synchronization(事件相关同步)
概念:
大脑在静息时,神经元有一定程度的同步活动
当某个脑区被"激活"时:
神经元开始各自工作 → 同步性下降 → ERD(功率降低)
当某个脑区被"抑制"时:
神经元同步活动增强 → ERS(功率升高)
计算方式:
ERD/ERS = (刺激后功率 - 基线功率) / 基线功率 × 100%
正值(ERS)= 功率升高 = 同步增强
负值(ERD)= 功率降低 = 去同步(活跃)
二、环境准备与数据加载
2.1 导入库
python
# ========== 环境设置 ==========
# matplotlib.use('TkAgg'):
# 设置 matplotlib 的后端为 TkAgg
# TkAgg 基于 Tkinter,Windows 自带,无需额外安装
# 必须在 import pyplot 之前设置
import matplotlib
matplotlib.use('TkAgg')
# pyplot:matplotlib 的绘图接口
import matplotlib.pyplot as plt
# mne:脑电分析核心库
import mne
# numpy:科学计算库
# np 是约定俗成的简写
import numpy as np
# os:操作系统路径处理
import os
# warnings:警告控制
import warnings
warnings.filterwarnings('ignore')
# ========== 中文字体设置 ==========
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
print("="*60)
print("MNE-Python 第8天:时频分析(ERD/ERS)")
print("="*60)
2.2 加载数据并预处理
python
# ---------- 1. 加载数据并预处理 ----------
sample_data_folder = mne.datasets.sample.data_path()
raw_fname = os.path.join(sample_data_folder, 'MEG', 'sample', 'sample_audvis_raw.fif')
raw = mne.io.read_raw_fif(raw_fname, preload=False)
# 提取 EEG + EOG + STIM 通道
raw_eeg = raw.copy().pick_types(eeg=True, eog=True, stim=True)
# 重命名通道(第2天的知识)
eeg_names = [ch for ch in raw_eeg.ch_names if ch.startswith('EEG')]
montage = mne.channels.make_standard_montage('standard_1020')
standard_names = montage.ch_names[:len(eeg_names)]
raw_eeg.rename_channels(dict(zip(eeg_names, standard_names)))
raw_eeg.set_montage(montage)
# 加载到内存
raw_eeg.load_data()
# 滤波
raw_eeg.notch_filter(freqs=60, picks='eeg', verbose=False)
raw_eeg.filter(l_freq=1, h_freq=40, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
print("✅ 数据预处理完成")
# 查看可用通道
eeg_chs = [ch for ch in raw_eeg.ch_names
if raw_eeg.get_channel_types(picks=[ch])[0] == 'eeg']
print(f"可用 EEG 通道: {len(eeg_chs)} 个")
2.3 提取事件和创建 Epochs
python
# ---------- 2. 提取事件和创建 Epochs ----------
# 提取事件
events = mne.find_events(raw_eeg, stim_channel='STI 014')
# 事件 ID 字典
event_id = {
'听觉/左耳': 1,
'听觉/右耳': 2,
'视觉/左眼': 3,
'视觉/右眼': 4
}
# 时频分析需要更长的分段
# 为什么?
# 低频信号(如 4Hz)一个周期就需要 250ms
# 如果分段太短,低频信息会丢失
# 通常需要 ±1 秒或更长
epochs = mne.Epochs(
raw_eeg,
events,
event_id=event_id,
tmin=-1.0, # 刺激前 1 秒(比 ERP 分析更长)
tmax=2.0, # 刺激后 2 秒(比 ERP 分析更长)
baseline=(-0.5, 0), # 用刺激前 0.5 秒做基线
reject=dict(eeg=150e-6),
preload=True,
verbose=False
)
print(f"✅ Epochs 创建完成: {len(epochs)} 个分段")
print(f" 时间范围: {epochs.times[0]:.1f} ~ {epochs.times[-1]:.1f} 秒")
print(f" 分段时长: {epochs.times[-1] - epochs.times[0]:.1f} 秒")
时频分析的 Epochs 为什么更长?
python
ERP 分析的 Epochs:
-0.2s ─── 0s ───────── 0.5s
只需看到刺激后的即时反应
时频分析的 Epochs:
-1.0s ───────── 0s ───────────── 2.0s
需要看到:
1. 刺激前足够长的基线(计算 ERD/ERS 需要)
2. 刺激后低频成分的完整变化
3. 频率越低,需要的时长越长
三、选择感兴趣通道
python
# ---------- 3. 选择通道 ----------
print("\n选择感兴趣通道...")
# 时频分析通常关注特定脑区
# 运动想象:C3, C4, Cz(运动皮层)
# 听觉:T7, T8(颞叶)
# 视觉:O1, O2, Oz(枕叶)
# 选择实际存在的通道
desired_channels = ['Cz', 'C3', 'C4', 'Fz', 'Pz']
available_channels = [ch for ch in desired_channels if ch in eeg_chs]
if len(available_channels) == 0:
# 如果想要的通道都不存在,用前几个
available_channels = eeg_chs[:5]
print(f" 想要的通道: {desired_channels}")
print(f" 可用的通道: {available_channels}")
# 从 epochs 中只选择这些通道
epochs_tfr = epochs.copy().pick(available_channels)
print(f" ✅ 选择了 {len(epochs_tfr.ch_names)} 个通道用于时频分析")
四、Morlet 小波变换
4.1 什么是小波变换?
python
傅里叶变换 vs 小波变换:
傅里叶变换:
把信号分解为不同频率的正弦波
但失去时间信息
信号: ┌─┐ ┌─┐
─┘ └───┘ └───
傅里叶: "包含 5Hz 和 10Hz"
"但不知道什么时候出现"
小波变换:
用小波(一段波形)在时间上滑动
同时保留时间和频率信息
信号: ┌─┐ ┌─┐
─┘ └───┘ └───
小波: "0-1秒: 5Hz 很强"
"1-2秒: 10Hz 很强"
4.2 Morlet 小波
python
# Morlet 小波 = 正弦波 × 高斯窗
#
# 正弦波部分:提供频率信息
# 高斯窗部分:限制时间范围
#
# 参数 n_cycles(周期数)控制时间-频率的平衡:
# n_cycles 大 → 频率分辨率高,时间分辨率低
# n_cycles 小 → 时间分辨率高,频率分辨率低
4.3 代码实现
python
# ---------- 4. Morlet 小波变换 ----------
print("\n" + "="*60)
print("Morlet 小波时频分析")
print("="*60)
# 定义要分析的频率范围
# Alpha 波:8-13 Hz(放松)
# Beta 波:13-30 Hz(活跃/运动想象)
# 这里分析 4-30 Hz
frequencies = np.arange(4, 31, 1) # 4, 5, 6, ..., 30 Hz
# np.arange(起始, 终止, 步长)
# 注意:终止值不包含在内,所以是 4~30
# 每个频率的小波周期数
# n_cycles = frequencies / 2
# 低频用少周期(如 4Hz 用 2 个周期)
# 高频用多周期(如 30Hz 用 15 个周期)
# 这样可以平衡时间-频率分辨率
n_cycles = frequencies / 2
print(f"频率范围: {frequencies[0]} ~ {frequencies[-1]} Hz")
print(f"频率数量: {len(frequencies)}")
print(f"周期数范围: {n_cycles[0]:.1f} ~ {n_cycles[-1]:.1f}")
# tfr_morlet():使用 Morlet 小波计算时频表示
#
# 参数详解:
# epochs_tfr:要分析的 Epochs 数据
# freqs:要分析的频率列表
# n_cycles:每个频率的小波周期数
# return_itc:是否返回相位一致性(ITC)
# average:是否对所有分段取平均
# decim:降采样因子(每隔 N 个时间点取一个,减少数据量)
power = mne.time_frequency.tfr_morlet(
epochs_tfr, # 输入数据
freqs=frequencies, # 频率列表
n_cycles=n_cycles, # 周期数
return_itc=False, # 不计算 ITC(只计算功率)
average=True, # 对所有分段取平均
decim=2, # 降采样(每隔2个点取1个)
n_jobs=1, # 并行计算的核心数
verbose=False # 不打印详细信息
)
print(f"✅ 时频分析完成")
print(f" 功率数据形状: {power.data.shape}")
print(f" (通道数, 频率数, 时间点数) = {power.data.shape}")
tfr_morlet() 参数详解:

五、时频图可视化
5.1 单通道时频图
python
print("\n" + "="*60)
print("时频图可视化")
print("="*60)
# plot():绘制时频图
#
# 横轴:时间(秒)
# 纵轴:频率(Hz)
# 颜色:功率(dB)或 ERD/ERS(%)
#
# baseline 参数:基线校正
# baseline=(-0.5, 0):用刺激前 0.5 秒做基线
# mode='percent':显示为相对于基线的百分比变化
# → ERD/ERS 图!
print("1. 绘制 Cz 通道的时频图...")
fig = power.plot(
picks='Cz', # Cz 通道(头顶中央)
baseline=(-0.5, 0), # 基线范围
mode='percent', # 显示为百分比变化
vmin=-50, # 颜色范围最小值
vmax=50, # 颜色范围最大值
title='Cz 通道 ERD/ERS',
show=True
)
plt.show(block=True)
时频图怎么看?
python
频率 (Hz)
30 ┤ ░░ ░░
│ ░░░░ ░░░░ ░░░░
20 ┤ ░░░░░ ░░░░░░ ░░░░░
│ ░░░░░░ ░░░░░░░░ ░░░░░░░
10 ┤ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
4 ┤────────────────────────────────
-1.0 -0.5 0 0.5 1.0 1.5 2.0
↑
刺激点
颜色含义(mode='percent'):
红色 = ERS(功率升高 > 基线)
蓝色 = ERD(功率降低 < 基线)
典型模式:
Alpha 频段(8-13Hz):
刺激后出现 ERD(蓝色)
= 大脑活跃,Alpha 被抑制
Beta 频段(13-30Hz):
刺激后可能出现 ERS(红色)
= 运动相关同步
5.2 多通道时频图
python
print("\n2. 绘制所有选中通道的时频图...")
# 对每个可用通道绘制时频图
fig, axes = plt.subplots(
len(available_channels), 1, # 行数 = 通道数
figsize=(12, 3 * len(available_channels))
)
# 确保 axes 是数组(如果只有一个通道)
if len(available_channels) == 1:
axes = [axes]
for idx, ch_name in enumerate(available_channels):
power.plot(
picks=ch_name,
baseline=(-0.5, 0),
mode='percent',
vmin=-50, vmax=50,
axes=axes[idx],
show=False,
colorbar=False if idx < len(available_channels) - 1 else True
)
axes[idx].set_title(f'{ch_name} ERD/ERS', fontsize=12)
plt.suptitle('多通道时频图对比', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('day8_multichannel_tfr.png', dpi=150, bbox_inches='tight')
print("✅ 多通道时频图已保存")
plt.show(block=True)
六、ERD/ERS 地形图
6.1 什么是 ERD/ERS 地形图?
python
时频图 = 单个通道的 时间×频率 图
ERD/ERS 地形图 = 某个频率、某个时间,头皮上的 ERD/ERS 分布
相当于把时频图上的一个"像素"展开为整个头皮的地图
6.2 代码实现
python
print("\n" + "="*60)
print("ERD/ERS 地形图")
print("="*60)
# plot_topo():绘制地形图 × 时间序列
# 每一行是一个通道的时频图
# 顶部是在特定时间点的地形图
print("绘制 ERD/ERS 地形图...")
fig = power.plot_topo(
baseline=(-0.5, 0), # 基线范围
mode='percent', # 百分比变化
vmin=-50, vmax=50, # 颜色范围
title='ERD/ERS 地形图',
show=True
)
plt.show(block=True)
七、不同条件的 ERD/ERS 对比
7.1 分条件计算
python
print("\n" + "="*60)
print("不同条件的 ERD/ERS 对比")
print("="*60)
# 对每个条件分别计算时频分析
power_dict = {}
for cond_name in ['听觉/左耳', '视觉/左眼']:
print(f"\n计算 {cond_name} 的时频分析...")
# 筛选该条件的分段
epochs_cond = epochs_tfr[cond_name]
print(f" 分段数: {len(epochs_cond)}")
# Morlet 小波变换
power_cond = mne.time_frequency.tfr_morlet(
epochs_cond,
freqs=frequencies,
n_cycles=n_cycles,
return_itc=False,
average=True,
decim=2,
verbose=False
)
power_dict[cond_name] = power_cond
print("✅ 各条件时频分析完成")
7.2 对比可视化
python
print("\n绘制听觉 vs 视觉的 ERD/ERS 对比...")
fig, axes = plt.subplots(1, 2, figsize=(16, 6))
fig.suptitle('听觉 vs 视觉 ERD/ERS(Cz通道)', fontsize=14, fontweight='bold')
for idx, (cond_name, power_cond) in enumerate(power_dict.items()):
power_cond.plot(
picks='Cz',
baseline=(-0.5, 0),
mode='percent',
vmin=-50, vmax=50,
axes=axes[idx],
show=False
)
axes[idx].set_title(cond_name, fontsize=12)
plt.tight_layout()
plt.savefig('day8_condition_comparison.png', dpi=150, bbox_inches='tight')
print("✅ 条件对比图已保存")
plt.show(block=True)
八、提取特定频段的 ERD/ERS 时间序列
8.1 代码实现
python
print("\n" + "="*60)
print("提取特定频段的 ERD/ERS 时间序列")
print("="*60)
# 有时候我们只关心某个特定频段
# 比如 Alpha 频段(8-13 Hz)
# 从功率数据中提取 Alpha 频段
alpha_freqs = (frequencies >= 8) & (frequencies <= 13)
# alpha_freqs 是一个布尔数组
# [False, False, False, False, True, True, True, True, True, True, False, ...]
# ↑4Hz ↑5Hz ↑6Hz ↑7Hz ↑8Hz ↑9Hz ↑10Hz ↑11Hz ↑12Hz ↑13Hz ↑14Hz
# 对 Alpha 频段的功率取平均
alpha_power = power.data[:, alpha_freqs, :].mean(axis=1)
# 结果形状:(通道数, 时间点数)
print(f"Alpha 频段: 8-13 Hz")
print(f"Alpha 功率形状: {alpha_power.shape}")
# 绘制 Alpha ERD/ERS 时间序列
fig, ax = plt.subplots(figsize=(12, 6))
# 获取时间数组
times = power.times
# 绘制每个通道的 Alpha ERD/ERS
for idx, ch_name in enumerate(power.ch_names):
# 计算相对于基线的百分比变化
baseline_idx = (times >= -0.5) & (times <= 0)
baseline_mean = alpha_power[idx, baseline_idx].mean()
# ERD/ERS = (功率 - 基线) / 基线 × 100%
erd_ers = (alpha_power[idx, :] - baseline_mean) / baseline_mean * 100
ax.plot(times, erd_ers, label=ch_name, linewidth=1.5, alpha=0.8)
ax.axvline(x=0, color='red', linestyle='--', alpha=0.5, label='刺激点')
ax.axhline(y=0, color='black', linestyle='-', alpha=0.3)
ax.set_xlabel('时间 (秒)', fontsize=12)
ax.set_ylabel('ERD/ERS (%)', fontsize=12)
ax.set_title('Alpha 频段 (8-13 Hz) ERD/ERS 时间序列', fontsize=14, fontweight='bold')
ax.legend(fontsize=8, loc='upper right')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('day8_alpha_erd_ers.png', dpi=150, bbox_inches='tight')
print("✅ Alpha ERD/ERS 时间序列已保存")
plt.show(block=True)
ERD/ERS 时间序列解读:
python
Alpha ERD/ERS (%)
+50 │ ERS(同步增强)
│ ← 通常在闭眼或放松时出现
+25 │
0 ┼──────────────────────→ 时间
-25 │ ╲
│ ╲___ ← ERD(去同步)
-50 │ ← 大脑活跃时 Alpha 被抑制
└──────────────────────────
-1.0 0 1.0 2.0
刺激后 Alpha ERD 的解释:
刺激引起大脑活跃 → Alpha 被抑制
→ 出现负的百分比变化(ERD)
刺激后 Alpha ERS 的解释:
刺激后大脑恢复静息 → Alpha 反弹
→ 出现正的百分比变化(ERS)
九、Beta 频段分析
python
print("\n" + "="*60)
print("Beta 频段 ERD/ERS")
print("="*60)
# 提取 Beta 频段(13-30 Hz)
beta_freqs = (frequencies >= 13) & (frequencies <= 30)
beta_power = power.data[:, beta_freqs, :].mean(axis=1)
print(f"Beta 频段: 13-30 Hz")
# 绘制 Beta ERD/ERS 时间序列
fig, ax = plt.subplots(figsize=(12, 6))
times = power.times
for idx, ch_name in enumerate(power.ch_names):
baseline_idx = (times >= -0.5) & (times <= 0)
baseline_mean = beta_power[idx, baseline_idx].mean()
erd_ers = (beta_power[idx, :] - baseline_mean) / baseline_mean * 100
ax.plot(times, erd_ers, label=ch_name, linewidth=1.5, alpha=0.8)
ax.axvline(x=0, color='red', linestyle='--', alpha=0.5, label='刺激点')
ax.axhline(y=0, color='black', linestyle='-', alpha=0.3)
ax.set_xlabel('时间 (秒)', fontsize=12)
ax.set_ylabel('ERD/ERS (%)', fontsize=12)
ax.set_title('Beta 频段 (13-30 Hz) ERD/ERS 时间序列', fontsize=14, fontweight='bold')
ax.legend(fontsize=8, loc='upper right')
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('day8_beta_erd_ers.png', dpi=150, bbox_inches='tight')
print("✅ Beta ERD/ERS 时间序列已保存")
plt.show(block=True)
十、第8天完整代码
python
# ========== 环境设置 ==========
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
import mne
import numpy as np
import os
import warnings
warnings.filterwarnings('ignore')
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
print("="*60)
print("MNE-Python 第8天:时频分析(ERD/ERS)")
print("="*60)
# ---------- 1. 加载数据并预处理 ----------
sample_data_folder = mne.datasets.sample.data_path()
raw_fname = os.path.join(sample_data_folder, 'MEG', 'sample', 'sample_audvis_raw.fif')
raw = mne.io.read_raw_fif(raw_fname, preload=False)
raw_eeg = raw.copy().pick_types(eeg=True, eog=True, stim=True)
eeg_names = [ch for ch in raw_eeg.ch_names if ch.startswith('EEG')]
montage = mne.channels.make_standard_montage('standard_1020')
standard_names = montage.ch_names[:len(eeg_names)]
raw_eeg.rename_channels(dict(zip(eeg_names, standard_names)))
raw_eeg.set_montage(montage)
raw_eeg.load_data()
raw_eeg.notch_filter(freqs=60, picks='eeg', verbose=False)
raw_eeg.filter(l_freq=1, h_freq=40, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
print("✅ 数据预处理完成")
# 查看可用通道
eeg_chs = [ch for ch in raw_eeg.ch_names
if raw_eeg.get_channel_types(picks=[ch])[0] == 'eeg']
# ---------- 2. 提取事件和创建 Epochs ----------
events = mne.find_events(raw_eeg, stim_channel='STI 014')
event_id = {'听觉/左耳': 1, '听觉/右耳': 2, '视觉/左眼': 3, '视觉/右眼': 4}
epochs = mne.Epochs(raw_eeg, events, event_id=event_id,
tmin=-1.0, tmax=2.0, baseline=(-0.5, 0),
reject=dict(eeg=150e-6), preload=True, verbose=False)
print(f"✅ Epochs: {len(epochs)} 个分段")
# ---------- 3. 选择通道 ----------
desired_channels = ['Cz', 'C3', 'C4', 'Fz', 'Pz']
available_channels = [ch for ch in desired_channels if ch in eeg_chs]
if len(available_channels) == 0:
available_channels = eeg_chs[:5]
epochs_tfr = epochs.copy().pick(available_channels)
print(f"✅ 选择通道: {available_channels}")
# ---------- 4. Morlet 小波变换 ----------
frequencies = np.arange(4, 31, 1)
n_cycles = frequencies / 2
power = mne.time_frequency.tfr_morlet(
epochs_tfr, freqs=frequencies, n_cycles=n_cycles,
return_itc=False, average=True, decim=2, verbose=False)
print(f"✅ 时频分析完成,数据形状: {power.data.shape}")
# ---------- 5. 时频图 ----------
print("\n绘制时频图...")
fig = power.plot(picks='Cz', baseline=(-0.5, 0), mode='percent',
vmin=-50, vmax=50, title='Cz ERD/ERS', show=True)
plt.show(block=True)
# ---------- 6. Alpha ERD/ERS ----------
print("\n提取 Alpha ERD/ERS...")
alpha_freqs = (frequencies >= 8) & (frequencies <= 13)
alpha_power = power.data[:, alpha_freqs, :].mean(axis=1)
fig, ax = plt.subplots(figsize=(12, 6))
times = power.times
for idx, ch_name in enumerate(power.ch_names):
baseline_idx = (times >= -0.5) & (times <= 0)
baseline_mean = alpha_power[idx, baseline_idx].mean()
erd_ers = (alpha_power[idx, :] - baseline_mean) / baseline_mean * 100
ax.plot(times, erd_ers, label=ch_name, linewidth=1.5)
ax.axvline(x=0, color='red', linestyle='--', alpha=0.5)
ax.axhline(y=0, color='black', linestyle='-', alpha=0.3)
ax.set_xlabel('时间 (秒)'); ax.set_ylabel('ERD/ERS (%)')
ax.set_title('Alpha (8-13 Hz) ERD/ERS', fontsize=14)
ax.legend(); ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('day8_alpha_erd_ers.png', dpi=150)
plt.show(block=True)
# ---------- 7. 保存 ----------
power.save('day8_tfr_power.h5', overwrite=True)
print("\n✅ 时频分析结果已保存")
print("\n" + "="*60)
print("第8天学习完成!")
print("="*60)
print("\n🎯 明日预告:第9天 - 源定位基础")