news 2026/9/13 18:36:19

YASA自动化多导睡眠图分析:从EDF到睡眠分期与纺锤波检测

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
YASA自动化多导睡眠图分析:从EDF到睡眠分期与纺锤波检测

简介:这是一份面向睡眠研究人员、脑电数据分析者及Python开发者的YASA工具箱完整源码包。YASA专注于多导睡眠图(PSG)的自动分析,涵盖自动睡眠分期、纺锤波/慢波/快速眼动事件检测、伪影剔除、频谱分析及催眠图统计等功能,需要结合NumPy、Pandas、MNE与Jupyter Lab使用。资源共254个文件,压缩包约115.78MB,主体为Python脚本(27个py)和Jupyter Notebook示例(16个ipynb),同时包含HTML文档、CSS/JS网页资源、PNG/SVG图示、RST说明及少量测试数据(npz、fif、joblib)等,目录结构完整,便于直接参考与二次开发。目前已有803人学习下载,适合具备一定Python基础、希望快速搭建睡眠分期或事件检测流程的研究者。通过源码、示例文档与可视化图表,读者可掌握YASA的调用方式、参数配置思路及典型分析流程,节省从零摸索环境与接口的时间。

1. YASA 是来给多导睡眠图做自动分析的,不是又一个人工智能框架

睡眠实验室最耗人的一件事,是夜里导出的七八个小时多导睡眠图(PSG)要一段一段人工判读。脑电、眼电、肌电十几个通道叠在一起,先分睡眠分期,再回头找纺锤波、慢波和快速眼动,光靠肉眼拖波形,一晚记录的处理时间按小时算。YASA(Yet Another Spindle Algorithm)是一个用 Python 写的 PSG 分析包,名字听上去像又造了一个纺锤波检测轮子,实际它把自动睡眠分期、睡眠统计指标、纺锤波/慢波/REM 事件检测和频谱分析全收进了同一套接口。它能直接吃 EDF 转出来的 DataFrame,输出的是带时间戳的事件表和完整睡眠参数,而不是一张没法复核的图。适合刚接触脑电数据想快速出指标的工程师,也适合已经在用 MNE 做预处理、想把睡眠事件指标接进现有管道的分析人员。有一点要提前说清楚:YASA 计算的是可复现的量化指标,不等同于临床诊断结论,用它做科研和筛选可以,做临床报告要有人工复核环节。

2. 睡眠记录的数据准备:EDF、通道命名与 hypno 时间轴

2.1 多导睡眠图里的三类信号与 30 秒分期规则

PSG 记录里通常包含脑电(EEG,常见 C3-M2、C4-M1、Fz-Cz、Pz-Oz)、眼电(EOG,左右眼各一个通道)、下颌肌电(EMG),另外还有心电、口鼻气流、血氧饱和度这些辅助通道。YASA 真正用到的是前三类,辅助通道可以留着,但不会参与睡眠分期和事件检测。国际通用的睡眠分期把一夜切成一个个 30 秒的 epoch,每个 epoch 打一个标签:清醒(W)、N1、N2、N3 或 REM。整套分析的时间轴都挂在这个 30 秒网格上,所以数据准备的核心不是训练什么模型,而是把原始信号和分期标签在时间上对齐。很多第一次跑 YASA 的人,坑都不在算法上,而是 hypno 的时间索引起点跟数据起点错了几秒,最后算出来的睡眠潜伏期和睡眠效率整体漂移。

2.2 用 MNE 读 EDF、统一单位与采样率

YASA 的输入约定很明确:数据是一个 pandas DataFrame,index 为采样时间戳,columns 为通道名;hypno 是一个 Series,index 为每个 epoch 的起始时间戳,值为 0-4 或 W/N1/N2/N3/R。从 EDF 文件到这种结构,最常见的路径是先用 MNE 读入,再转成 DataFrame:

import mne import pandas as pd raw = mne.io.read_raw_edf("night1.edf", preload=True) # PSG 里 EEG 和 EOG 采样率不一致时先统一 if raw.info["sfreq"] != 500: raw.resample(500) # 过滤掉直流漂移和肌电高频段,保留睡眠分析的频带 raw.filter(0.3, 35, picks=["eeg", "eog"]) raw.notch_filter(50, picks=["eeg", "eog"]) # 市电干扰按本地频率选 50 或 60 df = raw.to_data_frame() # MNE 默认单位是 V,YASA 期望 uV,必须换算 for col in df.columns: if any(k in col.lower() for k in ("eeg", "eog", "emg")): df[col] *= 1e6

这段代码有三个地方不能省。第一是重采样到统一采样率,YASA 内部对事件检测采用固定时间窗,采样率不一致会让事件起止时间在不同通道间对不齐。第二是 50 Hz 陷波,纺锤波频带是 12–15 Hz,市电干扰不会直接踩在频带里,但它的谐波会污染包络,检测出来的事件会偏多。第三是单位换算,YASA 内部所有幅度阈值都基于微伏设计,EDF 文件里多数采集系统导出的是 V,不换算的话幅度阈值完全失去意义,慢波和纺锤波事件数量会成倍膨胀或归零。

2.3 构建 hypno:时间索引与标签语义

hypno 的来源一般是采集软件导出的分期文本,或者由睡眠技师在配套工具里逐 epoch 标注后导出。拿到的是每行一个标签的文本文件,要转成带时间索引的 Series:

import pandas as pd stages = pd.read_csv("stages.txt", header=None, names=["stage"]) hypno = pd.Series(stages["stage"].values) # 索引必须跟数据起点对齐:第一个 epoch 从数据第一秒开始 epoch_start = pd.date_range(df.index[0], periods=len(hypno), freq="30s") hypno.index = epoch_start hypno.name = "Hypnogram"

标签语义上要注意,YASA 接受的 hypno 值是 0=W、1=N1、2=N2、3=N3、4=REM,也接受直接的文本标签。不同设备导出的分期文件可能用 AASM 标准的 R 表示 REM,用 W 表示清醒,处理文本时先做一次映射表统一成 0-4,能省掉后面很多判断逻辑。一个常见的隐蔽错误是 epoch 起始时间戳写成了 epoch 中点,或者第一段记录前有几分钟的静息态没算 epoch,这两种情况会让事件检测阶段把事件错误挂到相邻分期上。如果记录里完全没有人工标注,又想快速摸底,可以用 YASA 自带的自动分期生成一版 hypno 先跑通流程,但正式的睡眠报告参数不能直接采信,这个边界后面会展开。

2.4 通道命名的约定与检查

YASA 判断通道类别靠的是列名里的关键字,EEG 通道名要带 "eeg" 或直接给出脑电导联名并辅以 eeg_name 参数,EOG 要带 "eog",EMG 要带 "emg"。如果从 EDF 读进来通道名是 "Cz"、"LOC" 这种,需要手工补上类别标记。我通常在拿到 DataFrame 后先打印通道列表,确认每个通道能被正确归类,再进入检测流程,否则 spindles_detect 里找不到 EOG 或 EMG 通道时,报错信息会很隐晦。

3. 睡眠分期与睡眠统计参数的计算逻辑和边界

3.1 sleep_stats 返回的核心睡眠指标

数据准备好之后,第一步通常是算整夜睡眠统计。YASA 提供了 sleep_stats,输入数据、hypno 就能得到一串常用临床指标:

import yasa stats = yasa.sleep_stats(dat, hypno) stats

输出是一个表格,其中比较关键的字段列在下面:

字段含义计算口径
Total time in bed卧床总时长hypno 第一个到最后一个 epoch 的时间跨度
Sleep period time睡眠周期时长入睡到最终醒来的时长
Total sleep time总睡眠时间所有非清醒 epoch 的累加时长
Sleep onset latency入睡潜伏期从记录开始到第一个非清醒 epoch 的时间
WASO入睡后清醒时间入睡之后所有清醒 epoch 的累加
Number of awakenings觉醒次数入睡后转为清醒的连续片段数
Sleep efficiency睡眠效率TST 除以 TBT 的百分比
REM latencyREM 潜伏期入睡到第一个 REM epoch 的时间
Stage durations各期时长与占比按 W/N1/N2/N3/REM 分别统计

这些字段在 sleep_stats 的结果里直接可以取到,但口径要按研究需求确认一遍。比如 Sleep onset latency 的定义,有些 protocol 要求必须连续出现三个非清醒 epoch 才算真正入睡,YASA 默认取第一个非清醒 epoch,两边会差几分钟到十几分钟。如果你所在的项目采用的是连续三 epoch 标准,需要在拿到结果后自己再算一版,不能直接改参数让 YASA 换口径。

3.2 bandpower 是分期和事件检测的共同底座

睡眠分期本质上是在看不同频带功率随睡眠周期的变化。YASA 里最直接的工具是 bandpower,它对每个通道每个 epoch 做 Welch 功率谱估计,再按给定频带积分:

bands = [(0.5, 4, "Delta"), (4, 8, "Theta"), (8, 12, "Alpha"), (12, 15, "Sigma"), (15, 30, "Beta"), (30, 45, "Gamma")] bp = yasa.bandpower(dat, hypno=hypno, bands=bands) print(bp.head())

返回结果里每行是一个通道,列包含每个频带的绝对功率、总绝对功率、相对功率和相对总功率。做纺锤波分析时,Sigma 频带的相对功率是最该先看的指标:如果受试者整夜 Sigma 相对功率明显偏低,说明这一夜可能根本没有多少典型纺锤波,或记录中 EMG 噪声把信号盖掉了。这种情况下直接跑检测器,得到的零星事件很难用于统计。我一般先用 bandpower 画一遍各通道的功率谱变化,确认要检测的频带确实有信号,再进事件检测,这一步能省掉后面大量无意义的调参。

3.3 自动分期的模型边界与工程兜底

YASA 的自动睡眠分期用的不是深度学习,而是人工设计的时域加频域特征加随机森林分类器。特征包括各频带相对功率、频谱边缘频率、频谱熵、EMG 功率等,分类器按 30 秒 epoch 逐段预测,最后输出整夜 hypno。好处是计算快、依赖少、特征可解释,坏处是训练数据以正常成人为主,模型对儿童、老年人、睡眠呼吸暂停患者和部分神经系统疾病记录的泛化能力有限。实际项目中,这几类记录经常出现 N1 被低估、N3 被高估的情况,因为病理脑电的背景频率和慢波形态与训练集差异较大。把自动分期当成预标注用是完全合理的,直接拿它的输出做最终睡眠报告风险就高了。我的做法是自动分期结果永远保留概率或置信度字段,只把置信度高的段落入自动统计,边界段交给人工复核。

4. 纺锤波检测的参数、算法与慢波/REM 事件输出

4.1 纺锤波检测:带通、滑动阈值与事件合并

YASA 名称里的 Spindle Algorithm 是整个包最核心的部分。纺锤波是睡眠 N2/N3 期出现的 12–15 Hz 震荡,典型时长在 0.3 到 3 秒,幅度在脑电背景上并不总是明显。自动检测的经典做法不是神经网络,而是信号处理三步:带通滤波、包络阈值、事件合并。首先是 12–15 Hz 带通把信号限制在 Sigma 频带,然后对包络计算滑动平均,设置高、低两档相对阈值,低阈值确定事件边界,高阈值确认图中峰值,最后把被间隙分开但实际连续的事件合并。YASA 内部用 numba 做加速,单通道一整夜数据跑下来通常只需要几秒到几十秒,比纯 Python 实现快得多。

调用入口很简洁:

sp = yasa.spindles_detect( dat, hypno=hypno, include=(2, 3), # 只在 N2 和 N3 阶段检测 freq_sp=(12, 15), # Sigma 频带 min_duration=0.3, # 最短事件时长,秒 remove_edges=False, # 是否剔除记录首尾的事件 verbose=True ) print(sp)

sp 是一个 pandas DataFrame,index 是通道名,每一行代表检测到一个纺锤波事件,包含 Start、Peak、End 时间戳,Duration、Frequency、RMS、AbsPower、RelPower 这些特征列,如果传了 hypno,还会带上 Stage 列,标记这个事件落在哪个睡眠分期。这个输出结构是之后做统计和可视化的基础,建议在项目开始阶段就把事件表规范成统一的 parquet 格式,别每跑一次就换一种导出。

4.2 关键参数表与调整场景

spindles_detect 的默认参数对绝大多数成人整夜记录是合理的,但项目里真正花时间的地方是理解每个参数在什么场景下要动。

参数默认值需要调整的场景
freq_sp(12, 15)儿童纺锤波频率偏高,可放宽到 (12, 16)
include(2, 3)只想统计 N2 时改为 (2,)
min_duration0.3信号噪声大时调高到 0.5 过滤假阳
remove_edgesFalse分析完整夜时建议保持 False
thresholds相对功率两档阈值低幅纺锤波为主时下调高阈值档,同时接受更多假阳

阈值参数是误用高发区。YASA 的阈值是相对功率而不是绝对微伏值,理解为"相对背景功率的高出倍数"更准确,因此不同受试者之间不需要因为放大器增益不同而反复调整。真正需要调的场景是纺锤波幅值整体偏低的记录,比如额叶导联检出的纺锤波比中央导联低,这时把阈值下调会找回更多事件,但要同步用可视化确认找回的是真实纺锤波而不是 EMG 碎片。另一个高频错误是在有周期性肢体运动的通道上硬跑检测,运动伪迹会在 Sigma 频带产生类似事件,跑之前先看原始波形的伪迹情况。

4.3 慢波与 REM 检测的入口和输出列

慢波检测的入口是 sw_detect,原理是对负向峰值幅度做自适应阈值匹配。与纺锤波不同,慢波的幅度绝对值更大,检测器会先估计整夜负峰幅度的分布,再基于分布确定阈值,所以它不像纺锤波那样对相对功率阈值敏感:

sw = yasa.sw_detect( dat, hypno=hypno, include=(3,), # 慢波主要出现在 N3 freq_sw=(0.5, 4.5) ) print(sw)

sw 输出的特征列包括负峰出现时间和幅度、事件时长、频率、相位斜率等。REM 检测由 rem_detect 负责,它依赖 EOG 通道识别快速眼动,同时用 EMG 通道确认肌电抑制状态以排除单纯清醒时的眼动。调用时要确保 EOG 通道带 eog 标记,或者直接用 eog_name 参数指定:

rem = yasa.rem_detect(dat, hypno=hypno, include=(4,), eog_name=("LOC", "ROC"))

REM 检测的实际产出不只是一次次眼动事件,REM 密度(每分钟眼动次数)是一个常用指标,后面做组间对比时可以直接由事件表算出来。

4.4 按睡眠分期统计纺锤波密度

检测完的事件表带 Stage 列,统计密度时就不需要再回头对着 hypno 逐个匹配了:

# 统计 N2 和 N3 各自的总分钟数 n2_n3_epochs = hypno.isin([2, 3]).sum() # epoch 数 n2_n3_min = n2_n3_epochs * 0.5 # 30 秒一个 epoch sp_density = sp.groupby("Stage").size() / n2_n3_min print(sp_density)

这里有一个容易算错的地方:hypno 是 30 秒一个 epoch,统计时长时要把 epoch 数乘以 0.5 换算成分钟,直接用 epoch 数当分钟数会把密度夸大 2 倍。事件表和 hypno 的 Stage 列偶见不一致,例如检测时 include 限定了 (2, 3),但 hypno 本身有分期待修正的片段,这时事件表里的 Stage 会显示实际归属,按 groupby 统计即可,不要再用 hypno 重新映射。

5. 批量处理多晚睡眠记录:缓存、并行与结果合并

5.1 单夜处理函数中的缓存设计

单晚记录的处理流程可以收敛成独立的函数,再缓存中间结果。事件检测是重计算,睡眠统计和频谱分析相对轻量,按文件粒度做缓存是最合适的选择。我通常的做法是每个受试者一个目录,中间结果只存 parquet 事件表和统计表,原始 EDF 和人工分期文件不进入产物目录:

from pathlib import Path import pandas as pd import mne import yasa def analyze_one(edf_path, stages_path, out_dir): raw = mne.io.read_raw_edf(edf_path, preload=True) if raw.info["sfreq"] != 500: raw.resample(500) raw.filter(0.3, 35, picks=["eeg", "eog"]) dat = raw.to_data_frame() * 1e6 stages = pd.read_csv(stages_path, header=None)[0] hypno = pd.Series(stages.values) hypno.index = pd.date_range(dat.index[0], periods=len(hypno), freq="30s") hypno.name = "Hypnogram" stats = yasa.sleep_stats(dat, hypno) stats.to_json(out_dir / f"{Path(edf_path).stem}_stats.json") sp = yasa.spindles_detect(dat, hypno=hypno) sp.to_parquet(out_dir / f"{Path(edf_path).stem}_spindles.parquet")

缓存设计上,parquet 比 CSV 更适合事件表,因为它保留时间戳和多数值列的类型,重新读入后不需要再处理字符串转时间的问题。如果一个受试者跑完又改了参数,只重跑事件检测这一层,统计表不用动,这正是把函数拆成单夜独立流程的好处。

5.2 多进程跑批与 numba 的上下文注意事项

YASA 的事件检测在底层依赖 numba 的 JIT 编译,批量并行时对进程启动方式有要求。写多进程批处理时,直接在命令行跑没有保护会看到 numba 对象无法 pickled 的报错:

from concurrent.futures import ProcessPoolExecutor def run_batch(): jobs = [(edf_path, stages_path, out_dir) for edf_path in edf_list] with ProcessPoolExecutor(max_workers=4) as ex: list(ex.map(lambda x: analyze_one(*x), jobs)) if __name__ == "__main__": run_batch()

并行进程数不能只看 CPU 核数。YASA 检测单通道事件时会用 numba 多线程,一个 analyze_one 进程可能吃掉两到四个逻辑核,工作站核再多,max_workers 设成 8 也会把内存和算力打满。我一般按机器物理核数的四分之一到二分之一起步。整夜记录在 500 Hz 采样率下,16 通道数据量约一两百 MB,加载进内存后叠加 numba 中间变量,单进程内存占用到 1 GB 很常见,内存比核数更容易成为瓶颈。

5.3 结果合并与后续分析

批量跑完后,把所有 parquet 读进来拼成大表做组间分析,是标准操作。事件表的 index 是通道名,合并时要把受试者 ID 作为一列写进每个事件行,否则几十个文件拼在一起后没法区分来源:

files = sorted(out_dir.glob("*_spindles.parquet")) all_sp = [] for f in files: tmp = pd.read_parquet(f) tmp["subject_id"] = f.stem.split("_")[0] all_sp.append(tmp) events = pd.concat(all_sp, ignore_index=True)

到这里,整夜的统计表、事件表、频谱表已经可以进入统计建模环节。事件表里每个事件的频带功率、幅度、时长作为特征,可以按受试者聚合,也可以按通道聚合,具体口径取决于研究问题。

6. 验证检测结果的三个具体操作:事件分布、可视化与误用排查

检测器跑完不等于工作结束,先把三个验证动作固定到流程里,能拦截大部分异常结果。第一个动作是检查事件时长分布。纺锤波正常时长分布在 0.3 到 3 秒之间,睡眠波检测器给出的慢波一般更长。跑完检测后第一件事是打印描述统计:

d = sp["Duration"] print(d.describe()) print((d < 0.3).mean())

如果事件数量异常多,时长小于 0.3 秒的事件占比超过两成,基本可以判定阈值过低,检测到了 EMG 碎片或滤波振铃。这种情况不要急着调阈值,先看原始波形确认噪声类型。第二个动作是可视化抽查。把检测到的事件叠加到原始脑电和滤波包络上,肉眼确认事件峰值是否落在 Sigma 包络的强震荡段。YASA 自带 plot_events,传入原始数据、hypno 和事件表就能快速抽查,抽查的 epoch 不要只选第一段,要覆盖前半夜、后半夜和各个分期。

第三个动作是排查三类高频误用。单位错误最常见,EDF 读出 V 直接进 YASA,所有幅度阈值失效,事件数量要么爆炸要么归零,排查方式是打印数据列的最大值和标准差,正常 EEG 的幅值应该在几十到几百微伏量级。hypno 与数据错位是第二个高频问题,事件表里的 Stage 列和实际波形对不上时,先检查 hypno 的索引起点和 freq 参数,别先怀疑检测器。第三是先降采样再检测,有人为了省内存把数据降到 100 Hz,纺锤波频带上限 15 Hz 在 100 Hz 下虽然名义上满足奈奎斯特条件,但包络细节严重失真,事件起止时间会整体模糊。保留 250 Hz 以上再跑事件检测,是稳妥的工程底线。把这三个检查写进你的分析管线开头,比事后对着一堆异常事件排查要省时间。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 18:35:56

LunaTranslator 使用指南:GalGame 翻译工具三种取词模式的完整流程

LunaTranslator 使用指南&#xff1a;GalGame 翻译工具三种取词模式的完整流程 【免费下载链接】LunaTranslator 视觉小说翻译器 / Visual Novel Translator 项目地址: https://gitcode.com/GitHub_Trending/lu/LunaTranslator 如果你在玩日文或英文游戏&#xff0c;想按…

作者头像 李华
网站建设 2026/9/13 18:35:45

大数据时代的数据分片技术原理与实践指南

1. 数据分片技术概述 在大数据时代&#xff0c;数据量呈指数级增长&#xff0c;传统单一数据库架构已无法满足海量数据存储和高并发访问的需求。数据分片&#xff08;Sharding&#xff09;作为一种有效的分布式数据管理技术&#xff0c;通过将数据分散存储在多个数据库节点上&a…

作者头像 李华
网站建设 2026/9/13 18:35:18

Wagtail v3 API 实战:Sites 站点的增删改查接口与权限模型解析

Wagtail v3 API 实战&#xff1a;Sites 站点的增删改查接口与权限模型解析 【免费下载链接】wagtail A Django content management system focused on flexibility and user experience 项目地址: https://gitcode.com/GitHub_Trending/wa/wagtail Wagtail 8.0 引入的 v…

作者头像 李华
网站建设 2026/9/13 18:34:08

3 步装好并激活 Office:LKY Office Tools 一键部署实操记录

3 步装好并激活 Office&#xff1a;LKY Office Tools 一键部署实操记录 【免费下载链接】LKY_OfficeTools 一键自动化 下载、安装、激活 Office 的利器。 项目地址: https://gitcode.com/GitHub_Trending/lk/LKY_OfficeTools 重装系统后 Office 装不上&#xff0c;是很多…

作者头像 李华
网站建设 2026/9/13 18:31:20

Beads 项目 npm 包发布指南:@beads/bd 的发布全流程与源码级解析

Beads 项目 npm 包发布指南&#xff1a;beads/bd 的发布全流程与源码级解析 【免费下载链接】beads Beads - A memory upgrade for your coding agent 项目地址: https://gitcode.com/GitHub_Trending/beads1/beads 本文以仓库内 npm-package/PUBLISHING.md 为主干&…

作者头像 李华