运动想象脑机接口(BCI)实战项目
·
📋 项目简介
我们要做什么?
想象一个场景:
你戴着脑电帽坐在电脑前
屏幕上出现向左的箭头 ←
你就在脑海中想象"左手在动"
(注意:不是真的动,只是想!)
电脑分析你的脑电信号
自动判断:你在想左手还是右手?
这就是"运动想象脑机接口"!
🪜 第1级:数据初探
🎯 目标
拿到新数据,就像拿到一本新书
不要急着分析,先翻一翻,看个大概:
- 这本书有多少页?(数据有多长)
- 字有多大?(采样率多高)
- 是什么类型的书?(有多少通道)
💻 完整代码
# ========== 环境设置 ==========
# matplotlib.use('TkAgg'):
# 告诉电脑用什么"画笔"来画图
# TkAgg 是 Windows 系统自带的画笔,不需要额外安装
# 这行必须在 import pyplot 之前写
import matplotlib
matplotlib.use('TkAgg')
# pyplot:画图的工具箱
# plt 是大家约定俗成的简写
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("🪜 第1级:数据初探")
print("="*60)
# ---------- 加载数据 ----------
# mne.datasets.sample.data_path():
# 找到 MNE 自带的示例数据在哪里
# 这个数据你之前下载过了,所以不会重新下载
sample_data_folder = mne.datasets.sample.data_path()
# os.path.join():安全地拼接文件路径
# Windows 用 \,Mac 用 /,这个函数自动处理
raw_fname = os.path.join(
sample_data_folder, # 数据文件夹
'MEG', # MEG 子文件夹
'sample', # sample 子文件夹
'sample_audvis_raw.fif' # 数据文件名
)
# read_raw_fif():读取脑电数据文件
# .fif 是 MNE 专用的脑电数据格式
# preload=False:先不全部加载到内存(省内存)
raw = mne.io.read_raw_fif(raw_fname, preload=False)
# pick_types(eeg=True, stim=True):
# 只保留两种通道:
# eeg=True → EEG通道(脑电信号,我们要分析的)
# stim=True → 刺激通道(记录实验事件的,后面会用到)
# 就像从工具箱里只拿出需要的工具
raw_eeg = raw.copy().pick_types(eeg=True, stim=True)
# ---------- 问3个最基本的问题 ----------
print("\n📋 数据的'身份证'信息:")
# 问题1:有多少个通道?
# raw_eeg.ch_names 是通道名称的列表
# len() 计算列表中有多少个元素
print(f" 问题1:有多少个通道? → {len(raw_eeg.ch_names)} 个")
# 问题2:每秒采多少个点?
# raw_eeg.info['sfreq'] 是采样率
# 采样率 = 每秒记录多少个数据点
# 250Hz = 每秒250个点 = 每4毫秒一个点
print(f" 问题2:每秒采多少个点? → {raw_eeg.info['sfreq']} Hz")
# 问题3:录了多长时间?
# raw_eeg.times[-1] 是最后一个时间点的秒数
# 约等于总录制时长
print(f" 问题3:录了多长时间? → {raw_eeg.times[-1]:.0f} 秒")
print(f" (约 {raw_eeg.times[-1]/60:.1f} 分钟)")
# ---------- 看一眼数据 ----------
# plot():画原始数据的时间序列图
# 横轴 = 时间(秒)
# 纵轴 = 电压(微伏)
print("\n👀 看一眼前10秒的数据...")
raw_eeg.plot(
duration=10, # 一次显示10秒
n_channels=5, # 只显示5个通道(太多看不清)
scalings='auto', # 自动调整显示比例
block=True, # 保持窗口打开
title='这就是原始脑电数据 - 先混个脸熟'
)
# ---------- 看一眼频谱 ----------
# plot_psd():画功率谱密度图
# PSD = Power Spectral Density
#
# 通俗理解:
# 就像把一首歌分解成"低音、中音、高音"各有多少
# 横轴 = 频率(Hz,低音在左,高音在右)
# 纵轴 = 能量(这个频率有多强)
print("\n👀 看一眼频谱...")
raw_eeg.plot_psd(
fmin=0.5, # 最低频率 0.5 Hz
fmax=50, # 最高频率 50 Hz
picks='eeg', # 只看EEG通道
average=True, # 取所有通道的平均值
show=True
)
plt.suptitle('功率谱密度 - 看看哪些频率能量强', fontsize=14)
plt.show(block=True)
print("\n✅ 第1级完成!")
print("你现在能回答:")
print(" 1. 数据有多少通道、多长、采样率多高")
print(" 2. 脑电信号'看起来'像什么样(上下波动的线)")
print(" 3. 低频能量比高频能量强(这是脑电的普遍特征)")
📖 知识解释
采样率是什么?
想象你在拍视频:
普通视频 = 30帧/秒 = 每秒30张照片
脑电采样 = 250Hz = 每秒250个数据点
采样率越高 → 数据越精细 → 文件越大
250Hz 意味着每 4 毫秒记录一次
(1000毫秒 ÷ 250 = 4毫秒)
功率谱密度怎么看?
功率 ↑
│
│ ╱╲
│ ╱ ╲ ← 这里能量高(低频)
│╱ ╲___
│ ╲___ ← 这里能量低(高频)
│ ╲
└────────────────→ 频率 (Hz)
0 10 20 30 40 50
脑电的普遍规律:
低频能量高(大脑的慢节奏活动)
高频能量低(快节奏活动相对少)
🪜 第2级:电极位置
🎯 目标
上一级我们看到一堆通道名叫 EEG 001、EEG 002...
这就像座位号,但不知道具体在哪里
这一级我们要:
1. 给每个通道改个有意义的名字
2. 知道每个电极在头皮上的位置
3. 认识几个"关键电极"
💻 完整代码
# ========== 环境设置 ==========
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("🪜 第2级:电极位置")
print("="*60)
# ---------- 加载数据 ----------
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, stim=True)
# ---------- 看看现在的通道名 ----------
print("\n📋 现在的通道名(看不出位置):")
eeg_chs = [ch for ch in raw_eeg.ch_names if ch.startswith('EEG')]
print(f" 前10个: {eeg_chs[:10]}")
print(f" 问题:EEG 001 在头顶还是后脑勺?看不出来!")
# ---------- 给电极改名字 ----------
#
# make_standard_montage('standard_1020'):
# 创建一个"标准电极位置表"
# 里面记录了每个标准电极名的3D坐标
# 例如:'Fz' 在 (0, 0.07, 0) 位置
# 'Cz' 在 (0, 0, 0.07) 位置
montage = mne.channels.make_standard_montage('standard_1020')
# 从标准表中取相同数量的名字
standard_names = montage.ch_names[:len(eeg_chs)]
# dict(zip(旧名列表, 新名列表)):
# 把两个列表配对成字典
# 就像:{'EEG 001': 'Fz', 'EEG 002': 'Cz', ...}
rename_dict = dict(zip(eeg_chs, standard_names))
# rename_channels():批量重命名
raw_eeg.rename_channels(rename_dict)
# set_montage():设置每个电极的3D坐标
raw_eeg.set_montage(montage)
print(f"\n📋 改名后的通道名(能看出位置了):")
print(f" 前10个: {raw_eeg.ch_names[:10]}")
# ---------- 理解电极命名规则 ----------
print(f"\n📖 电极命名规则:")
print(f" 字母 = 脑区位置")
print(f" F = Frontal (额叶,前额)")
print(f" C = Central (中央,头顶)")
print(f" P = Parietal (顶叶,头顶后部)")
print(f" T = Temporal (颞叶,耳朵附近)")
print(f" O = Occipital (枕叶,后脑勺)")
print(f"")
print(f" 数字 = 左右位置")
print(f" 奇数(1,3,5,7) = 左侧")
print(f" 偶数(2,4,6,8) = 右侧")
print(f" z = zero = 中线")
# 举例
print(f"\n 举例:")
print(f" Fz = 额叶中线")
print(f" Cz = 头顶中央")
print(f" C3 = 中央区左侧(左脑运动皮层)")
print(f" C4 = 中央区右侧(右脑运动皮层)")
# ---------- 画出电极位置 ----------
print(f"\n👀 画出电极在头皮上的位置...")
raw_eeg.plot_sensors(
show_names=True, # 显示电极名字
title='电极位置图 - 鼻子朝上',
block=True
)
# ---------- 认识关键电极 ----------
print(f"\n📌 运动想象最关键的3个通道:")
print(f" Cz = 头顶中央")
print(f" C3 = 左脑运动皮层(控制右手!)")
print(f" C4 = 右脑运动皮层(控制左手!)")
print(f"")
print(f" 为什么C3控制右手?")
print(f" 因为大脑是'对侧支配'的:")
print(f" 左脑 → 控制右手和右脚")
print(f" 右脑 → 控制左手和左脚")
print(f"\n✅ 第2级完成!")
print("你现在能回答:")
print(" 1. Fz、Cz、Pz 分别在什么位置")
print(" 2. C3 和 C4 哪个在左边")
print(" 3. 为什么运动想象要看 C3 和 C4")
📖 知识解释
对侧支配是什么?
大脑控制身体是"交叉"的:
大脑 身体
┌─────────┐
│ 左脑 │──────→ 右手 ✋
│ │
│ 右脑 │──────→ 左手 ✋
└─────────┘
所以:
想象左手动 → 右脑活跃 → C4通道会变化
想象右手动 → 左脑活跃 → C3通道会变化
🪜 第3级:事件提取
🎯 目标
实验不是一直做下去的,而是:
出现提示 → 被试想象动作 → 休息 → 下一个提示
我们需要知道:
1. 每次提示是什么时候出现的?
2. 提示的是"左手"还是"右手"?
这些信息记录在"刺激通道"中
💻 完整代码
# ========== 环境设置 ==========
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("🪜 第3级:事件提取")
print("="*60)
# ---------- 加载数据 ----------
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, stim=True)
# ---------- 理解"刺激通道" ----------
print("\n📖 什么是刺激通道?")
print(" 刺激通道不是真实的电极")
print(" 它是一个'事件记录器'")
print(" 平时值是 0")
print(" 有刺激时变成对应的编号")
print("")
print(" 就像实验助手的记录本:")
print(" 第10秒:出现左手提示 → 记下编号1")
print(" 第15秒:出现右手提示 → 记下编号2")
print(" 第20秒:出现左手提示 → 记下编号1")
print(" ...")
# ---------- 找到刺激通道 ----------
stim_chs = [ch for ch in raw_eeg.ch_names if 'STI' in ch]
print(f"\n📋 刺激通道名称: {stim_chs}")
# ---------- 提取事件 ----------
# find_events():从刺激通道中自动找到所有事件
# 返回一个数组,每行 = [采样点编号, 持续时间, 事件编号]
events = mne.find_events(raw_eeg, stim_channel='STI 014')
print(f"\n📋 提取结果:")
print(f" 共找到 {len(events)} 个事件")
print(f" 事件数组格式:[采样点, 持续时间, 事件编号]")
# 看前5个事件
print(f"\n 前5个事件:")
for i in range(5):
sample = events[i, 0] # 第0列:采样点编号
duration = events[i, 1] # 第1列:持续时间
code = events[i, 2] # 第2列:事件编号
# 采样点编号 ÷ 采样率 = 时间(秒)
time_sec = sample / raw_eeg.info['sfreq']
print(f" 第{time_sec:.0f}秒: 事件编号={code}")
# ---------- 理解事件编号 ----------
print(f"\n📋 这个实验有4种刺激:")
print(f" 编号1 = 听觉/左耳 = 我们用来类比'想象左手'")
print(f" 编号2 = 听觉/右耳 = 我们用来类比'想象右手'")
print(f" 编号3 = 视觉/左眼 = 本次不用")
print(f" 编号4 = 视觉/右眼 = 本次不用")
# ---------- 给事件起名字 ----------
# event_id 字典:{名称: 编号}
# 以后就可以用名称来索引,不用记编号了
event_id = {
'条件A(类比左手)': 1,
'条件B(类比右手)': 2,
}
# ---------- 统计 ----------
print(f"\n📊 事件统计:")
for name, code in event_id.items():
# np.sum(events[:,2] == code):统计编号为code的行数
count = np.sum(events[:, 2] == code)
print(f" {name}: {count} 次")
# ---------- 可视化事件 ----------
print(f"\n👀 画出事件分布图...")
mne.viz.plot_events(
events,
sfreq=raw_eeg.info['sfreq'],
first_samp=raw_eeg.first_samp,
event_id=event_id,
show=True
)
plt.suptitle('实验事件分布 - 每一竖线是一次刺激', fontsize=14)
plt.show(block=True)
print(f"\n✅ 第3级完成!")
print("你现在能回答:")
print(" 1. 刺激通道是做什么的")
print(" 2. 事件数组的3列分别是什么")
print(" 3. 每种条件有多少次")
📖 知识解释
事件数组的结构:
events = [
[15000, 0, 1], ← 第1个事件
[23000, 0, 2], ← 第2个事件
[31000, 0, 1], ← 第3个事件
...
]
每一行 = [采样点编号, 持续时间, 事件编号]
第1个事件:
采样点15000 → 15000÷250Hz = 第60秒
持续时间0 → 瞬时事件
事件编号1 → 左手提示
就像:
第60秒:左手!
第92秒:右手!
第124秒:左手!
...
🪜 第4级:数据预处理
🎯 目标
原始数据就像一个嘈杂的菜市场:
有你要听的人说话(脑电信号)
有空调嗡嗡声(低频漂移)
有远处的高频噪音(肌肉活动)
预处理 = 把噪音去掉,只留你想听的声音
💻 代码
# ========== 第4级:数据预处理 ==========
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("🪜 第4级:数据预处理 - 把数据'洗干净'")
print("="*60)
# --- 加载数据 ---
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, 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()
# --- 看"洗之前"的频谱 ---
print("\n🔍 洗之前:看原始频谱")
raw_eeg.plot_psd(fmin=0.5, fmax=50, picks='eeg', average=True, show=True)
plt.suptitle('洗之前 - 所有频率都有', fontsize=14)
plt.show(block=True)
# --- 带通滤波 7-30 Hz ---
print("\n🧹 带通滤波(7-30 Hz)...")
print(" 去掉<7Hz的慢波(出汗、漂移)")
print(" 去掉>30Hz的快波(肌肉噪声)")
print(" 保留mu节律(8-12Hz)和beta节律(13-30Hz)")
raw_eeg.filter(l_freq=7, h_freq=30, picks='eeg', verbose=False)
print(" ✅ 滤波完成")
# --- 看"洗之后"的频谱 ---
print("\n🔍 洗之后:看滤波后频谱")
raw_eeg.plot_psd(fmin=0.5, fmax=50, picks='eeg', average=True, show=True)
plt.suptitle('洗之后 - 只保留了7-30 Hz', fontsize=14)
plt.show(block=True)
# --- 平均参考 ---
print("\n🧹 平均参考...")
print(" 每个通道 = 原值 - 所有通道平均值")
raw_eeg.set_eeg_reference('average', verbose=False)
print(" ✅ 平均参考完成")
print(f"\n✅ 第4级完成!")
📖 知识解释
滤波通俗理解:
滤波前(原始数据):
├── 0.1-7Hz:慢波(出汗、漂移) ← 去掉!
├── 7-30Hz:mu+beta节律 ← 保留!
└── 30-200Hz:肌电、噪声 ← 去掉!
就像调收音机,只听想听的频道
平均参考通俗理解:
量身高的问题:
❌ 每个人站在不同高度的台阶上 → 无法比较
✅ 所有人都站在同一个平面上 → 公平比较
平均参考 = 统一测量基准
每个通道 = 原值 - 所有通道平均值
🪜 第5级:数据分段
🎯 目标
连续数据就像一整段录音
但我们需要分析的是"每次刺激前后的反应"
分段 = 把每次刺激前后的一小段时间"切"出来
💻 代码
# ========== 第5级:数据分段 ==========
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("🪜 第5级:数据分段(Epochs)")
print("="*60)
# --- 加载+预处理(第1-4级) ---
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, 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.filter(l_freq=7, h_freq=30, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
# --- 提取事件(第3级) ---
events = mne.find_events(raw_eeg, stim_channel='STI 014')
event_id = {'条件A': 1, '条件B': 2}
# --- 理解分段 ---
print("\n📖 分段的时间轴:")
print(" -0.2秒 ─── 0秒 ─────── 1.0秒")
print(" ↑ ↑ ↑")
print(" 基线期 刺激点 反应期")
print("")
print(" 基线期(-0.2~0秒):刺激前的'平静状态'")
print(" 反应期(0~1.0秒):刺激后的'大脑反应'")
print(" 基线校正:每个分段减去基线期的平均值")
print(" 效果:所有分段从同一起跑线开始")
# --- 创建Epochs ---
epochs = mne.Epochs(
raw_eeg, events, event_id=event_id,
tmin=-0.2, tmax=1.0,
baseline=(-0.2, 0),
reject=dict(eeg=150e-6),
preload=True, verbose=False
)
print(f"\n📊 分段结果:")
print(f" 总分段数: {len(epochs)}")
print(f" 条件A: {len(epochs['条件A'])} 个")
print(f" 条件B: {len(epochs['条件B'])} 个")
print(f" 数据形状: {epochs.get_data().shape}")
print(f" (分段数, 通道数, 时间点数)")
# --- 可视化 ---
print(f"\n👀 前10个分段...")
epochs[:10].plot(n_epochs=10, n_channels=5, scalings='auto', show=True)
plt.suptitle('每个分段 = 一次刺激的数据快照', fontsize=14)
plt.show(block=True)
print(f"\n✅ 第5级完成!")
📖 知识解释
Epochs 三维结构:
epochs.get_data() 返回三维数组:
(分段数, 通道数, 时间点数)
例子:(144, 60, 301)
144个分段 × 60个通道 × 301个时间点
就像一摞卡片:
每张卡片 = 一个分段
卡片上 = 60行 × 301列的表格
基线校正通俗理解:
问题:不同分段可能在不同"高度"
分段1:~~~~~/~~~~~ 整体偏上(+20 μV)
分段2:___/‾‾‾‾‾___ 整体偏下(-10 μV)
校正后:
分段1:___/‾‾‾‾‾___ 都从0开始
分段2:___/‾‾‾‾‾___ 都从0开始
🪜 第6级:ERP分析
🎯 目标
单个分段噪声太大,看不清
把所有分段叠加平均 → 噪声抵消 → 真正的大脑反应浮现
这就是 ERP(事件相关电位)
💻 代码
# ========== 第6级:ERP分析 ==========
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("🪜 第6级:ERP分析 - 大脑的'平均反应'")
print("="*60)
# --- 加载+预处理+分段(第1-5级) ---
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, 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.filter(l_freq=7, h_freq=30, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
events = mne.find_events(raw_eeg, stim_channel='STI 014')
event_id = {'条件A': 1, '条件B': 2}
epochs = mne.Epochs(raw_eeg, events, event_id=event_id,
tmin=-0.2, tmax=1.0, baseline=(-0.2, 0),
reject=dict(eeg=150e-6), preload=True, verbose=False)
# --- 叠加平均 ---
print("\n📖 叠加平均原理:")
print(" 条件A有72个分段,每个都有随机噪声")
print(" 72个取平均 → 噪声抵消 → 真正的反应浮现")
print(" 就像拍100张照片叠在一起,噪点消失")
evoked_A = epochs['条件A'].average()
evoked_B = epochs['条件B'].average()
print(f"\n 条件A: {evoked_A.nave} 个分段 → 1个ERP")
print(f" 条件B: {evoked_B.nave} 个分段 → 1个ERP")
# --- 画ERP波形 ---
print(f"\n👀 画Cz通道的ERP波形...")
plot_ch = 'Cz' if 'Cz' in evoked_A.ch_names else evoked_A.ch_names[0]
fig, ax = plt.subplots(figsize=(12, 6))
times_ms = evoked_A.times * 1000
ax.plot(times_ms, evoked_A.get_data(picks=plot_ch)[0]*1e6, 'b-', linewidth=2, label='条件A')
ax.plot(times_ms, evoked_B.get_data(picks=plot_ch)[0]*1e6, 'r-', linewidth=2, label='条件B')
ax.axvline(x=0, color='k', linestyle='--', alpha=0.5, label='刺激点')
ax.axhline(y=0, color='k', linestyle='-', alpha=0.3)
ax.set_xlabel('时间 (毫秒)', fontsize=12)
ax.set_ylabel('振幅 (微伏)', fontsize=12)
ax.set_title(f'ERP波形对比 - {plot_ch}通道', fontsize=14, fontweight='bold')
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('erp_comparison.png', dpi=150)
print("✅ 已保存为 erp_comparison.png")
plt.show(block=True)
# --- 脑地形图 ---
print(f"\n👀 画出脑地形图...")
print(" 红色=正电压,蓝色=负电压")
evoked_A.plot_topomap(
times=[0.10, 0.15],
ch_type='eeg',
show=True,
average=0.02,
time_unit='ms'
)
plt.suptitle('脑地形图 - 头皮上的电压分布', fontsize=14)
plt.show(block=True)
print(f"\n✅ 第6级完成!")
📖 知识解释
叠加平均为什么有效?
假设:
真正的脑电反应:每次刺激后都出现 +2 μV
随机噪声:每次不同(+3, -1, -2, +2...)
分段1:信号(+2) + 噪声(+3) = +5
分段2:信号(+2) + 噪声(-1) = +1
分段3:信号(+2) + 噪声(-2) = 0
分段4:信号(+2) + 噪声(+2) = +4
...
平均:(+5 + 1 + 0 + 4 + ...) / 72 ≈ +2
随机噪声平均后趋近于0,信号浮现!
🪜 第7级:时频分析(ERD/ERS)
🎯 目标
上一级我们看了 ERP(事件相关电位)
ERP 告诉我们:刺激后,大脑的电压怎么变化?
但 ERP 有个局限:
它只能看到"时间 × 振幅"
就像听歌时只能听到音量变化
听不出是高音还是低音
时频分析 = 时间 × 频率 × 功率
就像看乐谱:
横轴 = 时间(什么时候)
纵轴 = 频率(什么音高)
颜色 = 功率(多大声)
📖 什么是 ERD/ERS?
ERD = Event-Related Desynchronization(事件相关去同步)
= 某个频率的功率降低了
= 大脑变活跃了
= 图上显示为蓝色
ERS = Event-Related Synchronization(事件相关同步)
= 某个频率的功率升高了
= 大脑变抑制了
= 图上显示为红色
通俗理解:
大脑在"休息"时,很多神经元同步活动 → 功率高
大脑在"干活"时,神经元各干各的 → 功率降低 → ERD
运动想象时:
想象左手动 → 右脑运动皮层活跃 → C4通道出现ERD(蓝色)
想象右手动 → 左脑运动皮层活跃 → C3通道出现ERD(蓝色)
💻 完整代码
# ========== 第7级:时频分析(ERD/ERS) ==========
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("🪜 第7级:时频分析(ERD/ERS)")
print("=" * 60)
# --- 加载+预处理+分段(第1-5级的内容,这里合并在一起) ---
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, 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.filter(l_freq=7, h_freq=30, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
# 提取事件
events = mne.find_events(raw_eeg, stim_channel='STI 014')
event_id = {'条件A': 1, '条件B': 2}
# 创建Epochs(时频分析需要更长的分段)
epochs = mne.Epochs(raw_eeg, events, event_id=event_id,
tmin=-0.5, tmax=1.5, baseline=None,
reject=dict(eeg=150e-6), preload=True, verbose=False)
# ===== 时频分析 =====
print("\n📖 时频分析 vs ERP:")
print(" ERP:时间 × 振幅(只看'音量')")
print(" 时频:时间 × 频率 × 功率(还能看'音调')")
print(" 蓝色(ERD)=功率降低=大脑活跃")
print(" 红色(ERS)=功率升高=大脑抑制")
# 选Cz通道(头顶中央)
ch = 'Cz' if 'Cz' in epochs.ch_names else epochs.ch_names[0]
freqs = np.arange(7, 31, 1) # 分析 7-30 Hz
print(f"\n⏳ 计算 {ch} 通道的时频表示...")
# 🔑 Morlet小波变换
# 小波变换 = 同时分析时间和频率的方法
# 就像用不同大小的"放大镜"看信号
# 大放大镜 → 看低频(慢变化)
# 小放大镜 → 看高频(快变化)
power_A = mne.time_frequency.tfr_morlet(
epochs['条件A'].copy().pick([ch]), # 只分析Cz通道
freqs=freqs, # 要分析的频率
n_cycles=freqs/2, # 小波周期数
return_itc=False, # 不计算相位
average=True, # 对所有trial取平均
verbose=False
)
power_B = mne.time_frequency.tfr_morlet(
epochs['条件B'].copy().pick([ch]),
freqs=freqs, n_cycles=freqs/2,
return_itc=False, average=True, verbose=False
)
# 提取数据
data_A = power_A.data[0] # 形状: (频率数, 时间点数)
data_B = power_B.data[0]
times = power_A.times
# 🔑 基线校正
# 和ERP的基线校正同理:用刺激前的一段时间做参考
# 这里用 -0.4 到 -0.1 秒作为基线
mask = (times >= -0.4) & (times <= -0.1)
baseline_A = np.mean(data_A[:, mask], axis=1, keepdims=True)
baseline_B = np.mean(data_B[:, mask], axis=1, keepdims=True)
# ERD/ERS = (数据 - 基线) / 基线 × 100%
# 正值 = 功率升高 = ERS
# 负值 = 功率降低 = ERD
erd_A = (data_A - baseline_A) / baseline_A * 100
erd_B = (data_B - baseline_B) / baseline_B * 100
print(f" ERD/ERS 范围: {erd_A.min():.0f}% ~ {erd_A.max():.0f}%")
# ===== 画图 =====
print(f"\n👀 绘制 ERD/ERS 对比图...")
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# 左图:条件A
im1 = axes[0].imshow(
erd_A, # 数据
aspect='auto', # 自动调整宽高比
origin='lower', # 原点在左下角
cmap='RdBu_r', # 颜色:红-白-蓝(_r表示反转)
extent=[times[0], times[-1], # X轴范围
freqs[0], freqs[-1]], # Y轴范围
vmin=-50, vmax=50 # 颜色范围 ±50%
)
axes[0].axvline(x=0, color='k', linestyle='--', linewidth=1, label='刺激点')
axes[0].set_title('条件A', fontsize=12, fontweight='bold')
axes[0].set_xlabel('时间 (秒)')
axes[0].set_ylabel('频率 (Hz)')
axes[0].legend(fontsize=8, loc='upper right')
# 右图:条件B
im2 = axes[1].imshow(erd_B, aspect='auto', origin='lower', cmap='RdBu_r',
extent=[times[0], times[-1], freqs[0], freqs[-1]],
vmin=-50, vmax=50)
axes[1].axvline(x=0, color='k', linestyle='--', linewidth=1, label='刺激点')
axes[1].set_title('条件B', fontsize=12, fontweight='bold')
axes[1].set_xlabel('时间 (秒)')
axes[1].set_ylabel('频率 (Hz)')
axes[1].legend(fontsize=8, loc='upper right')
# 颜色条
cbar = fig.colorbar(im2, ax=axes.ravel().tolist(), shrink=0.6, pad=0.08)
cbar.set_label('ERD/ERS (%)', fontsize=10)
plt.suptitle(f'ERD/ERS 对比({ch}通道)\n蓝=ERD(大脑活跃) 红=ERS(大脑抑制)',
fontsize=14, fontweight='bold')
plt.tight_layout()
plt.savefig('07_erd_ers.png', dpi=150, bbox_inches='tight')
print("✅ 已保存为 07_erd_ers.png")
plt.show(block=True)
print(f"\n✅ 第7级完成!")
📖 代码详解
1. 什么是 Morlet 小波?
小波变换 = 同时分析时间和频率的工具
想象你有一排放大镜:
大放大镜(低频)→ 看大范围,但看不清细节
小放大镜(高频)→ 看小范围,细节清晰
Morlet小波 = 一种特定形状的"放大镜"
由一个正弦波(提供频率信息)
乘以一个高斯窗(限制时间范围)
n_cycles 控制放大镜的大小:
n_cycles=3.5(7Hz时)→ 大放大镜,看低频
n_cycles=15(30Hz时)→ 小放大镜,看高频
问题:如何同时看到"时间"和"频率"?
傅里叶:只看频率,不看时间
信号 → [傅里叶] → 哪些频率强?
"10Hz很强" ← 但不知道什么时候强
短时傅里叶:固定窗口看时间和频率
信号 → [固定窗口] → 每个窗口内什么频率强?
"第1秒10Hz强,第2秒20Hz强"
但窗口大小固定,低频看不清,高频不够快
Morlet 小波:自适应窗口看时间和频率
信号 → [小窗口看高频 + 大窗口看低频] → 精确!
"精确到毫秒的频率变化"
低频用大窗口(精确频率)
高频用小窗口(精确时间)
Morlet 小波就像一个聪明的"音乐分析器",它知道低频声音要仔细听一会儿才能确定音高,高频声音可以快速判断。所以它用不同大小的"耳朵"去听不同频率的声音,最终画出完整的"乐谱"——这就是我们第7级画的 ERD/ERS 时频图。
2. 基线校正怎么做的?
和 ERP 的基线校正同理:
基线期:-0.4 到 -0.1 秒(刺激前)
→ 这段时间没有刺激,是"安静状态"
每个频率都计算基线期的平均功率
然后用这个平均值做参考:
ERD/ERS = (刺激后功率 - 基线功率) / 基线功率 × 100%
例如:
基线期 Alpha(10Hz) 功率 = 100
刺激后 Alpha(10Hz) 功率 = 70
ERD/ERS = (70-100)/100 × 100% = -30%
负值 = ERD = 功率降低了30%
3. 时频图怎么看?
频率(Hz) ↑
30 ┤
│ ░░
20 ┤ ░░░░
│ ░░░░░░ ← 蓝色=ERD(大脑活跃)
10 ┤ ░░░░░░░░
│ ░░░░░░░░░░
7 ┤────────────────→ 时间(秒)
-0.5 0 0.5 1.0 1.5
↑
刺激点
横轴 = 时间(什么时候)
纵轴 = 频率(什么频率在变化)
颜色 = ERD/ERS 百分比
蓝色 = 负值 = ERD = 功率降低 = 大脑活跃
红色 = 正值 = ERS = 功率升高 = 大脑抑制
白色 = 接近0 = 没有变化
🪜 第8级:CSP+LDA分类——让机器学习区分大脑状态
🎯 目标
前7级我们一直在"看"数据:
看波形、看频谱、看地形图、看时频图...
这一级我们要做一件新的事情:
让机器"学习"如何区分两种大脑状态!
具体来说:
输入:条件A的脑电 和 条件B的脑电
输出:机器判断这是条件A还是条件B
这就是"分类"——脑机接口的核心!
📖 核心概念
CSP 是什么?
CSP = Common Spatial Patterns(共空间模式)
通俗理解:找到"最佳观察角度"
想象你在一个嘈杂的房间里:
左边有个人在说中文
右边有个人在说英文
你站在中间,两个声音混在一起
CSP 就像找到两个"最佳听音位置":
位置1:离左边近 → 中文清楚,英文模糊
位置2:离右边近 → 英文清楚,中文模糊
脑电中的 CSP:
我们有22个电极(22个"麦克风")
CSP 找到一种"重新组合"电极的方法
让条件A和条件B的差异最大化
用数学理解 CSP:
原始数据:22个电极的信号
Ch1, Ch2, Ch3, ..., Ch22
CSP 创建"虚拟电极":
虚拟电极1 = w1×Ch1 + w2×Ch2 + ... + w22×Ch22
虚拟电极2 = w1'×Ch1 + w2'×Ch2 + ... + w22'×Ch22
权重(w)是 CSP 自动计算的
目的是让虚拟电极1对条件A敏感
虚拟电极2对条件B敏感
LDA 是什么?
LDA = Linear Discriminant Analysis(线性判别分析)
通俗理解:画一条线,把两类分开
想象桌上有两种水果:
苹果:红色、圆的、直径约8cm
橘子:橙色、圆的、直径约7cm
LDA 找到一个"最佳分界线":
颜色红 + 直径大 → 苹果
颜色橙 + 直径小 → 橘子
脑电中的 LDA:
CSP 提取了4个特征值
LDA 在这4个特征上画一条线(实际上是超平面)
线的一边是条件A,另一边是条件B
用图理解 LDA:
CSP特征2
↑
│ ● ●
│ ● ● ● 条件A(比如左手)
│ ● ●
│ ─ ─ ─ ─ ─ ← LDA分界线
│ ▲ ▲
│ ▲ ▲ ▲ 条件B(比如右手)
│ ▲ ▲
└──────────────→ CSP特征1
分界线左边 → 预测为条件A
分界线右边 → 预测为条件B
什么是交叉验证?
交叉验证 = 测试模型是否真的学会了
问题:如果考试题和练习题一模一样
→ 学生可能只是背答案,不是真懂
解决:把数据分成两份
训练集(80%):用来学习
测试集(20%):用来考试
这样做10次,每次换不同的80%和20%
取10次的平均准确率
就像:
10次模拟考试,每次考题不同
平均分反映了真实水平
💻 完整代码
# ========== 第8级:CSP+LDA分类 ==========
import matplotlib
matplotlib.use('TkAgg')
import matplotlib.pyplot as plt
import mne
import numpy as np
import os
import warnings
warnings.filterwarnings('ignore')
# 🔑 导入机器学习工具
# Pipeline:流水线,把多个步骤串起来
from sklearn.pipeline import Pipeline
# LDA:线性判别分析分类器
from sklearn.discriminant_analysis import LinearDiscriminantAnalysis as LDA
# cross_val_score:交叉验证评分
# ShuffleSplit:随机划分训练集和测试集
from sklearn.model_selection import cross_val_score, ShuffleSplit
# CSP:共空间模式,脑电特征提取
from mne.decoding import CSP
plt.rcParams['font.sans-serif'] = ['Microsoft YaHei', 'SimHei']
plt.rcParams['axes.unicode_minus'] = False
print("=" * 60)
print("🪜 第8级:CSP+LDA分类 - 让机器学习区分")
print("=" * 60)
# --- 加载+预处理+分段(第1-5级的内容) ---
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, 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.filter(l_freq=7, h_freq=30, picks='eeg', verbose=False)
raw_eeg.set_eeg_reference('average', verbose=False)
events = mne.find_events(raw_eeg, stim_channel='STI 014')
event_id = {'条件A': 1, '条件B': 2}
epochs = mne.Epochs(raw_eeg, events, event_id=event_id,
tmin=-0.2, tmax=1.0, baseline=(-0.2, 0),
reject=dict(eeg=150e-6), preload=True, verbose=False)
# ===== 理解分类流程 =====
print("\n📖 分类流程(通俗版):")
print(" ┌─────────────────────────────────────────┐")
print(" │ 原始脑电(22通道 × 301时间点) │")
print(" │ ↓ │")
print(" │ CSP 提取特征(22通道 → 4个特征值) │")
print(" │ ↓ │")
print(" │ LDA 分类(4个特征 → 预测类别) │")
print(" │ ↓ │")
print(" │ 输出:这是条件A还是条件B? │")
print(" └─────────────────────────────────────────┘")
# ===== 准备数据 =====
print("\n📊 准备分类数据...")
# 只取两个条件的数据
epochs_binary = epochs['条件A', '条件B']
# X:数据(脑电信号)
# 形状:(样本数, 通道数, 时间点数)
X = epochs_binary.get_data()
# y:标签(每个样本属于哪一类)
# y 中的值是事件编号(1或2)
y = epochs_binary.events[:, -1]
print(f" 样本总数: {len(X)}")
print(f" 每个样本: {X.shape[1]} 通道 × {X.shape[2]} 时间点")
print(f" 条件A: {np.sum(y == 1)} 个样本")
print(f" 条件B: {np.sum(y == 2)} 个样本")
# ===== 创建分类流水线 =====
print("\n🔧 创建分类流水线...")
# Pipeline:把 CSP 和 LDA 串起来
# 就像工厂流水线:
# 原料(脑电数据)→ 加工1(CSP)→ 加工2(LDA)→ 成品(分类结果)
clf = Pipeline([
# 第1步:CSP 特征提取
# n_components=4:提取4个特征
# 前2个对条件A最敏感
# 后2个对条件B最敏感
('CSP', CSP(
n_components=4, # 提取4个空间滤波器
reg='ledoit_wolf', # 正则化方法(防止过拟合)
log=True, # 对特征取对数(让数据更稳定)
norm_trace=False # 不归一化
)),
# 第2步:LDA 分类
# 在 CSP 特征上画分界线
('LDA', LDA())
])
print(" ✅ 流水线: 原始脑电 → CSP(4特征) → LDA → 预测结果")
# ===== 交叉验证 =====
print("\n⏳ 进行交叉验证...")
print(" 把数据分成10份")
print(" 每次用9份训练,1份测试")
print(" 重复10次,每次测试的那份不同")
# ShuffleSplit:随机划分
# n_splits=10:重复10次
# test_size=0.2:每次20%做测试
# random_state=42:固定随机种子(让结果可重复)
cv = ShuffleSplit(n_splits=10, test_size=0.2, random_state=42)
# cross_val_score:自动完成训练+测试+评分
scores = cross_val_score(
clf, # 分类流水线
X, # 数据
y, # 标签
cv=cv, # 交叉验证方式
n_jobs=1 # 单核运行
)
# ===== 结果 =====
print(f"\n📊 分类结果:")
print(f" 每次准确率: ", end="")
for i, s in enumerate(scores):
print(f"{s*100:.0f}%", end=" ")
print()
print(f" 平均准确率: {scores.mean()*100:.1f}%")
print(f" 标准差: {scores.std()*100:.1f}%")
print(f" 最高: {scores.max()*100:.1f}%")
print(f" 最低: {scores.min()*100:.1f}%")
print(f" 随机水平: 50%")
# 判断
if scores.mean() > 0.7:
print(f"\n 🎉 分类效果好!远超随机水平")
elif scores.mean() > 0.6:
print(f"\n 👍 分类有效果,高于随机水平")
else:
print(f"\n ⚠️ 接近随机水平")
print(f" (正常,我们用的是听觉数据模拟BCI)")
print(f" (真正的运动想象数据准确率通常在70-85%)")
# ===== 画分类结果图 =====
print(f"\n👀 绘制分类结果图...")
fig, ax = plt.subplots(figsize=(10, 6))
# 画每次交叉验证的准确率柱状图
colors = ['#2ecc71' if s > 0.6 else '#e74c3c' for s in scores]
bars = ax.bar(range(1, 11), scores * 100, color=colors, alpha=0.8, edgecolor='white')
# 标注数值
for bar, score in zip(bars, scores):
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height + 1,
f'{score*100:.0f}%', ha='center', va='bottom', fontsize=10, fontweight='bold')
# 参考线
ax.axhline(y=50, color='gray', linestyle=':', linewidth=2, label='随机水平: 50%')
ax.axhline(y=scores.mean()*100, color='#e74c3c', linestyle='--', linewidth=2,
label=f'平均: {scores.mean()*100:.1f}%')
# 美化
ax.set_xlabel('交叉验证次数', fontsize=12)
ax.set_ylabel('准确率 (%)', fontsize=12)
ax.set_title('CSP+LDA 分类结果\n(10次交叉验证)', fontsize=14, fontweight='bold')
ax.set_xticks(range(1, 11))
ax.set_xticklabels([f'第{i}次' for i in range(1, 11)])
ax.set_ylim(0, 100)
ax.legend(fontsize=10, loc='lower right')
ax.grid(True, alpha=0.3, axis='y')
plt.tight_layout()
plt.savefig('08_classification.png', dpi=150, bbox_inches='tight')
print("✅ 已保存为 08_classification.png")
plt.show(block=True)
# ===== 总结 =====
print(f"\n" + "=" * 60)
print(f"🎉 全部8级完成!")
print(f"=" * 60)
print(f"""
你完成了一个完整的 BCI 分析流程:
第1级:加载数据,看看长什么样
第2级:设置电极位置,认识关键通道
第3级:提取实验事件
第4级:滤波 + 重参考(洗数据)
第5级:切分数据段(Epochs)
第6级:ERP 分析(看大脑平均反应)
第7级:时频分析(看频率变化)
第8级:CSP+LDA 分类(机器学习)✨
分类结果:{scores.mean()*100:.1f}% ± {scores.std()*100:.1f}%
""")
📖 代码详解
1. Pipeline(流水线)是什么?
clf = Pipeline([
('CSP', CSP(n_components=4)),
('LDA', LDA())
])
Pipeline = 工厂流水线
没有流水线时(手动操作):
步骤1:用 CSP 处理数据 → 得到特征
步骤2:把特征交给 LDA → 得到预测
容易出错,代码冗长
有流水线时(自动化):
数据 → [CSP] → [LDA] → 结果
一条龙服务,简洁可靠
就像:
原料(脑电)→ 机器1(CSP)→ 机器2(LDA)→ 成品(分类)
2. CSP 的 n_components=4 是什么意思
CSP 创建"虚拟电极",每个虚拟电极是一个特征
n_components=4 表示提取4个特征:
特征1:对条件A最敏感(条件A的方差最大)
特征2:对条件A次敏感
特征3:对条件B最敏感(条件B的方差最大)
特征4:对条件B次敏感
为什么是4个?
太少(比如2个):可能丢失信息
太多(比如10个):可能包含噪声
4个是常用的平衡选择
3. 交叉验证怎么理解?
交叉验证 = 多次"模拟考试"
不使用交叉验证的问题:
用同一份数据训练和测试
→ 就像用练习题当考试题
→ 分数高不代表真会了
10折交叉验证:
把数据分成10份
第1次:用第1-9份训练,用第10份测试 → 分数1
第2次:用第1-8、10份训练,用第9份测试 → 分数2
...
第10次:用第2-10份训练,用第1份测试 → 分数10
最终得分 = 10次分数的平均
→ 更能反映真实水平
4. 分类结果图怎么看?
准确率 ↑
100% │
│
80% │ ▓▓▓▓ ▓▓▓▓
│ ▓▓▓▓ ▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓
60% │ ▓▓▓▓ ▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓
│ ▓▓▓▓ ▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓
50% │--▓▓▓▓-▓▓▓-▓▓▓▓-▓▓▓▓-▓▓▓▓-▓▓▓▓-▓▓▓▓-▓▓▓▓-- ← 随机水平
│ ▓▓▓▓ ▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓ ▓▓▓▓
│
0% └──────────────────────────────────────────
第1次 第2次 第3次 ... 第10次
绿色柱子:准确率 > 60%
红色柱子:准确率 ≤ 60%
红色虚线:10次的平均准确率
灰色虚线:随机水平(50%)
更多推荐
所有评论(0)