简介:面向睡眠科研与脑电数据分析人员的 YASA 睡眠分析工具箱资源包,定位为可直接安装使用的 Python 源码项目。YASA 支持多导睡眠图自动分期、纺锤波/慢波/快速眼动事件检测、伪影剔除,以及频谱、相位幅度耦合与 1/f 斜率等分析,适用于熟悉 NumPy、Pandas、MNE 并希望用 Jupyter Lab 开展睡眠数据研究的初学者和进阶用户。资源包共 254 个文件,约 115.78MB,主体为 27 个 py 源码文件与 16 个 ipynb 示例笔记本,另含 59 个 png 图例、42 个 html 文档及 css/js 等网页资源,以及若干测试数据与配置文件,目录结构完整,便于搭建环境后快速复现各项分析流程。内容涵盖从数据导入、事件检测到睡眠分期与统计输出的完整调用示例,并附带构建文档和演示图表。已有 803 人浏览学习,适合需要处理睡眠脑电数据并构建自动化分析管线的研究者参考。
1. 用 YASA 把多导睡眠图变成可二次计算的 Python 数据
拿到一份带标注的 PSG 记录,人工分期通常要花掉小半天:从夜间 10 点到第二天 6 点,按 30 秒一页翻,脑电、眼电、肌电三个通道轮流看。YASA 做完同一件事只要几十秒,而且它最初不是为分期设计的。它的全称 Yet Another Spindle Algorithm,本意是自动检测睡眠纺锤波,后来扩展成完整的自动睡眠分期流水线,现在还能分析慢波、微觉醒和睡眠结构。这个 Python 包适合两类人:一类是做睡眠研究的临床科研人员,需要按脚本分析几十上百份记录;另一类是数据工程师和算法工程师,需要把 PSG 里的睡眠阶段变成可计算的标签或特征。关键在于 YASA 的输出不是一张报告图片,而是 pandas DataFrame,每个事件、每个分期都可以继续加工。
2. 多导睡眠图与 YASA 的输入约定:先读懂 EDF、通道名与算法边界
几乎所有人第一次跑yasa.sleep_staging报错,都是因为通道配置不符合约定。PSG 数据以 EDF/EDF+ 格式居多,本质上是多通道时间序列的容器,通道名、采样率、物理单位和校准系数都存在文件头里。YASA 不会自动知道哪条是脑电,哪条是眼电或者肌电;它通过通道名字符串匹配,并依赖 MNE 的Raw对象来理解数据。因此,在调用任何 YASA 函数之前,我会先确认四件事:数据能被 MNE 正常读取、采样率符合预期、通道名能对应到 YASA 的默认别名、脑电信号极性是否正常。
2.1 多导睡眠图记录里的核心信号
标准过夜 PSG 至少包含三路脑电、两路眼电和一路下颌肌电。脑电按 10-20 系统放置,常见位置是 Fz/Cz/Oz 或者 F3/C3/O1,采样率在 100 Hz 到 500 Hz 之间;EOG 记录眼球电位差,REM 期会出现快速共轭眼动;EMG 记录颏舌肌张力,REM 时肌张力会显著下降。再加上心电、腿动、呼吸、血氧饱和度,整份记录通常超过 30 个通道,但自动睡眠分期并不需要全部通道。YASA 只取三个 EEG 通道、一个 EOG 和一个 EMG,剩余通道会在特征计算之外被忽略。
2.2 通道名映射:YASA 如何找到 Fz、Cz、Oz
YASA 内部对通道名的关键字匹配是“包含”而不是“等于”。比如通道标注EEG Cz、CZ、Cz-REF都能命中Cz关键字,EOG LOC能命中eog,Chin1能命中emg。如果数据集里只有F3、C3和O1,则必须显式告诉 YASA 这三个通道分别扮演什么角色,而不是去改 EDF 文件。
| YASA 参数 | 默认匹配关键字 | 常见可接受别名 |
|---|---|---|
eeg_fz | Fz | Fz,FZ,EEG Fz,F3 |
eeg_cz | Cz | Cz,CZ,EEG Cz,C3 |
eeg_oz | Oz | Oz,OZ,EEG Oz,O1 |
eog | EOG | EOG,EOGL,EOGR,LOC,ROC |
emg | EMG | EMG,Chin,Chin1,EMG1 |
这个表同样适用于spindles_detect和slow_wave_detect:检测时如果只传入一段 ndarray,函数只需要采样率sf;如果传入 MNERaw,则建议同时传ch_name告诉 YASA 使用哪条通道。
2.3 YASA 自动分期的算法骨架与适用边界
sleep_staging的思路不是深度学习,而是一套手工特征加随机森林:从每 30 秒的数据窗中提取 EEG 各频段相对功率、频谱斜率、EOG 方差、EMG 低幅比例等特征,然后由随机森林分类器给出 W/N1/N2/N3/REM 的概率。模型在训练集上对健康成人整夜数据的 Kappa 普遍在 0.7 左右,但这个表现依赖通道配置和睡眠结构。如果记录来自儿童、老年期认知障碍患者,或者受试者在记录中大量翻动,YASA 的误判会明显增多。
处理这类问题前,先在原始信号上做一次性检查:
import mne raw = mne.io.read_raw_edf("demo.edf", preload=True, verbose=False) print(raw.info["sfreq"]) # 采样率 print(raw.get_channel_types()) # 通道类型列表 print(raw.ch_names) # 全部通道名输出结果如果是misc、stim混杂,说明 MNE 没有正确识别通道类型。常见做法是用raw.set_channel_types({"Fz": "eeg", "EOG": "eog", "EMG": "emg"})手动纠正,必要时对非脑电通道做重命名,再运行 YASA。这段代码的价值在于尽早暴露 EDF 标签问题:YASA 报错或者给出全是 Wake 的结果时,90% 是通道类型不对,而不是算法能力不足。
3. 从 Python 安装到跑通 YASA:环境、pip 与第一批睡眠分期
YASA 是纯 Python 包,依赖 NumPy、SciPy、pandas、scikit-learn 和 MNE,不依赖 GPU,也不要求深度学习框架。这意味着安装链路比许多现代分析工具短,但环境仍值得单独建一个,因为 MNE 和 pandas 的版本对 YASA 输出有直接影响。
3.1 安装 Python 环境的三种方式与踩坑
在 Windows 或 macOS 本地,直接安装 Python 3.10 或 3.11,然后在 VSCode 里选中同一个解释器即可。很多 Python 安装教程只教你装最新版,但 YASA 对最新 Python 的支持往往滞后,更稳妥的是装 3.10 或 3.11。在 Linux 服务器上批量分析时,用虚拟环境隔离依赖,避免污染系统自带 Python:
python3 -m venv ~/psg_env source ~/psg_env/bin/activate pip install --upgrade pip命令行里不要用sudo pip install。混用系统包管理器与 pip 是环境出问题的主要来源,轻则 import 失败,重则覆盖系统包。VSCode 里配置 Python 环境时,需要在.vscode/settings.json中确保python.defaultInterpreterPath指向虚拟环境。
3.2 安装 YASA 并验证依赖
在虚拟环境激活后安装:
pip install yasa python -c "import yasa; print(yasa.__version__)"第一条命令装 YASA 及其核心依赖,第二条命令验证版本号和 import 路径。如果 import 报ModuleNotFoundError,优先检查当前解释器是否在虚拟环境内,再用pip list | grep -i mne看 MNE 是否装上。导出 Excel 报告时另装openpyxl,不装也不影响分期和事件检测。
提示:不要在 conda 和 pip 混装的情况下使用 sudo pip install,权限问题会让 Python 找到错误的包路径。
3.3 最小可复现:加载 EDF 并运行 sleep_staging
下面的代码可以从一份 EDF 文件直接得到整夜分期。通道名按常见 PSG 数据集的命名来写,如果你的文件名是 F3/C3/O1,把参数换成对应的实际通道名即可。
import mne import yasa # 读取 EDF,preload=True 让数据进入内存,避免后续反复磁盘 IO raw = mne.io.read_raw_edf("subject01.edf", preload=True, verbose=False) # 只保留分期需要的通道,减小计算量 keep = [ch for ch in ["Fz", "Cz", "Oz", "EOG", "EMG"] if ch in raw.ch_names] raw.pick_channels(keep) # 执行自动睡眠分期,hypno 是 30 秒一页的分期结果 staging = yasa.sleep_staging( raw, eeg_fz="Fz", eeg_cz="Cz", eeg_oz="Oz", eog="EOG", emg="EMG", metadata=dict(subject_id="sub-01"), ) hypno = staging.sleep_stages if hasattr(staging, "sleep_stages") else staging print(hypno.head(10))这段代码的逻辑是先用 MNE 读取 EDF,然后pick_channels把不参与分期的呼吸、腿动、血氧等通道丢弃,最后把通道别名传给sleep_staging。eeg_fz、eeg_cz、eeg_oz三个参数指定脑电位置,eog和emg指定眼电和肌电。如果原始文件只有 F3/C3/O1,直接修改这些参数的值,例如eeg_fz="F3",不需要修改 EDF 内部标注。
3.4 看懂 sleep_staging 输出:分期的含义与常用参数
hypno是长度为整夜页码的数组或 Series,每 30 秒一个值。YASA 的输出约定是:-1 表示未评分,0 表示 Wake,1 表示 N1,2 表示 N2,3 表示 N3,4 表示 REM。用下面代码可以快速看整夜分布:
import pandas as pd hypno = pd.Series(hypno) stage_map = {-1: "Unscored", 0: "Wake", 1: "N1", 2: "N2", 3: "N3", 4: "REM"} print(hypno.map(stage_map).value_counts().sort_index())sleep_staging经常改的参数有四个:
| 参数 | 作用 | 设置建议 |
|---|---|---|
l_freq | EEG 高通频率,默认 0.5 Hz | 有严重漂移时可提到 1 Hz |
h_freq | EEG 低通频率,默认 45 Hz | 不要低于 30 Hz,会切掉纺锤波 |
downsample | 是否重采样到 128 Hz | 原始采样率已经是 128 时设 False 省算力 |
verbose | 控制日志输出 | 批量跑设为 False |
如果某个受试者整夜都是 Wake,先检查原始波形里是否真的包含睡眠信号,再看raw.get_channel_types()是否把 EEG 标成了misc。这个问题在公开数据集里很常见。
4. 用 YASA 深入分析 PSG:纺锤波、慢波、睡眠结构与批量脚本
sleep_staging只是入口。YASA 名字里的 Spindle 指睡眠纺锤波,这也意味着事件检测才是它的看家本领。拿到 hypno 之后,我一般会立刻跑纺锤波和慢波检测,因为这两个事件直接决定睡眠深度,也是很多科研项目真正需要的特征。
4.1 用 spindles_detect 检测睡眠纺锤波并理解每列输出
纺锤波是 N2 期的标志性事件,频率集中在 10-16 Hz,持续时间 0.3-2 秒。YASA 提供spindles_detect,输入可以是 MNERaw对象,也可以是裸数组加采样率。推荐传入Raw,这样能利用通道名信息:
sf = raw.info["sfreq"] result = yasa.spindles_detect(raw, sf=sf, hypno=hypno, freq_range=(10, 16), duration=(0.3, 2.0)) spindles = result[0] if isinstance(result, tuple) else result print(spindles.columns)返回的每一行代表一个检测到的纺锤波。常见列包括Start、Peak、End、Duration、Frequency、Amplitude、RMS、Power和Stage。Stage是 YASA 根据传入的 hypno 标注的事件所在睡眠期,可以直接按Stage分组统计:
spindles.groupby("Stage").agg( event_count=("Start", "count"), avg_duration=("Duration", "mean"), avg_frequency=("Frequency", "mean") )关键参数表格:
| 参数 | 默认值 | 说明 |
|---|---|---|
hypno | None | 不传则全夜检测,传入则只在对应睡眠期检测 |
freq_range | (10, 16) | 儿童或老年人可扩展到 (9, 16) |
duration | (0.3, 2.0) | 太短通常是 alpha 伪迹,不是纺锤波 |
relative_power | 0.2 | 调大减少误检,调小提高灵敏度 |
一个常被忽视的坑是幅度单位。MNE 读取 EDF 后如果通道单位是 μV,Amplitude单位也近似是 μV。如果原始数据被转成整数型 int16,幅度数值会大几千倍,检测阈值完全失效。所以批量分析时,应先把数据统一到 float32 和 μV 单位。
4.2 用 slow_wave_detect 检测慢波并提取慢波密度
慢波集中在 N3 期,Delta 频率 0.5-4 Hz,幅度高,是深度睡眠的核心指标。YASA 提供的slow_wave_detect和纺锤波检测类似,但参数偏向于负相波的持续时间和幅度:
sw = yasa.slow_wave_detect(raw, sf=sf, hypno=hypno, dur_neg=(0.3, 1.5), amp_neg=(40, 180)) sw = sw[0] if isinstance(sw, tuple) else sw sw["duration"] = sw["End"] - sw["Start"]dur_neg定义负相持续时间,amp_neg定义负相峰幅度范围,单位同样跟随输入信号单位。如果慢波检出太少,把amp_neg下界从 40 降到 30;如果检出太多疑似肌电干扰,把上界从 180 调低。医学文献里常用慢波密度指标,即每小时 N3 睡眠中的慢波数量:
total_sleep_time = yasa.hypno_summary(hypno, sf_hypno=1/30).get("TST", 0) sw_density = len(sw) / (total_sleep_time / 3600) print("Slow wave density:", round(sw_density, 2), "events/hour")慢波检测的通道建议选用 Fz 或 Fz 对应位置,因为慢波在前额区域幅度最大。用 Oz 检测会漏掉不少事件。
4.3 计算睡眠结构:TST、SE、各期比例用 hypno_summary
睡眠结构参数是报告里最常出现的表格:总睡眠时间、睡眠效率、入睡潜伏期、入睡后醒来时间、各期比例。YASA 提供hypno_summary,一行代码直接算完:
summary = yasa.hypno_summary(hypno, sf_hypno=1/30) print(summary)sf_hypno表示分期序列的采样率。因为 hypno 每 30 秒一个点,所以是1/30。返回结果通常包含TST、SE、SOL、WASO、各期分钟数与百分比。如果需要把每 30 秒的分期标签对齐到每个采样点,用yasa.hypno_upsample_to_data,配合np.zeros(raw.n_times)作为长度占位:
import numpy as np hypno_sample = yasa.hypno_upsample_to_data( hypno, sf_hypno=1/30, data=np.zeros(raw.n_times) )这样做的好处是后续做脑电谱分析时,可以直接用布尔索引提取 N2 或 N3 的连续片段,而不必反复计算页码边界。
4.4 批量跑几十个 EDF 文件时的内存管理与断点记录
当文件数量超过 10 个,脚本必须能失败后继续跑下去。典型做法是遍历目录,每个文件用try-except捕获异常,并把已完成的 hypno 存成.npy文件:
from pathlib import Path import numpy as np out_dir = Path("results") out_dir.mkdir(exist_ok=True) for f in Path("edf").glob("*.edf"): try: raw = mne.io.read_raw_edf(f, preload=True, verbose=False) keep = [ch for ch in ["Fz", "Cz", "Oz", "EOG", "EMG"] if ch in raw.ch_names] if len(keep) < 5: print(f"{f.name}: not enough channels, skip") continue raw.pick_channels(keep) st = yasa.sleep_staging(raw, eeg_fz="Fz", eeg_cz="Cz", eeg_oz="Oz", eog="EOG", emg="EMG") hypno = st.sleep_stages if hasattr(st, "sleep_stages") else st np.save(out_dir / (f.stem + "_hypno.npy"), hypno) except Exception as err: print(f.name, "ERROR", err)preload=True会让大文件直接载入内存,但 YASA 必须随机访问多个时间窗,因此这一步不能省。大批量时,每跑完一个文件就立刻释放内存,不让raw变量跨循环累积;若单文件超 1GB,可先用raw.pick_channels裁剪通道,把内存峰值从几 GB 降到几百 MB。打印日志时不要只用print,最后汇总失败列表,再针对失败的EDF单独排查。
5. 验证 YASA 分期与事件检测的三个技巧
5.1 与人工分期比一致性,先算 Kappa
YASA 的自动分期不能盲信。科研文章中要求报告与人工分期的一致性,通常用 Cohen’s Kappa。前提是两边都是同一时长、同一 30 秒网格的整数标签。先用astype(int)统一类型,再用 scikit-learn 计算:
from sklearn.metrics import cohen_kappa_score yasa_int = yasa_hypno.astype(int) manual_int = manual_hypno.astype(int) kappa = cohen_kappa_score(yasa_int, manual_int) print("Kappa:", kappa)Kappa 超过 0.6 可以接受,0.7 以上被认为一致良好。如果 Kappa 低于 0.5,优先比较 N1 与 Wake 的混淆,因为 YASA 对 N1 的识别最不稳定。
5.2 把检测事件画回原始波形,查假阳性
事件检测不能只看数量。用 MNE 的 Annotations 把纺锤波起点和持续时间标回原始信号:
import mne annot = mne.Annotations( onset=spindles["Start"].values, duration=spindles["Duration"].values, description=["spindle"] * len(spindles), ) raw.set_annotations(annot) raw.plot(start=100, duration=30, scalings="auto")可视化能快速暴露两类问题:一是把 alpha 波当成了纺锤波,二是把 EMG 突触当成慢波。批量分析前随机抽 3-5 个文件肉眼确认,比事后看统计数字更可靠。
5.3 固定依赖版本,建立回归基线
YASA 依赖 MNE、pandas、scikit-learn,任何一个版本变化都可能改变分期结果。合理做法是把环境和基线输出都保存下来:
pip freeze > requirements_lock.txt选定一个标准文件运行完整分析,把hypno_summary和纺锤波统计保存为 CSV,作为基线。后续升级依赖后,用pd.testing.assert_frame_equal对比当前结果与基线结果:
pd.testing.assert_frame_equal(current, baseline, check_exact=False, rtol=1e-3)允许微小浮点误差,但TST、SE增幅超过几个百分点时必须查版本变更说明。把yasa.__version__和scikit-learn.__version__写进 README,是成本最低的复现保障。
本文还有配套的精品资源,点击获取