From ceb11fc30b7c14894e0223930743f882335e148f Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Fri, 20 Dec 2024 16:11:00 +0100 Subject: [PATCH 01/19] Modern packaging + ruff (NPY201) --- .coveragerc | 14 ---- .github/workflows/black.yml | 10 --- .github/workflows/python_tests.yml | 16 ++-- .github/workflows/ruff.yml | 13 +++ MANIFEST.in | 19 +---- README.rst | 8 ++ codecov.yml | 2 - docs/contributing.rst | 4 +- pyproject.toml | 128 ++++++++++++++++++++++++++++- requirements-test.txt | 4 - requirements.txt | 16 ---- setup.cfg | 26 ------ setup.py | 76 ----------------- yasa/detection.py | 4 +- yasa/evaluation.py | 2 +- yasa/spectral.py | 2 +- yasa/staging.py | 3 +- yasa/tests/test_plotting.py | 1 - 18 files changed, 163 insertions(+), 185 deletions(-) delete mode 100644 .coveragerc delete mode 100644 .github/workflows/black.yml create mode 100644 .github/workflows/ruff.yml delete mode 100644 codecov.yml delete mode 100644 requirements-test.txt delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 5f93c58..0000000 --- a/.coveragerc +++ /dev/null @@ -1,14 +0,0 @@ -[run] -branch = True -source = yasa -include = yasa/* -omit = - */__init__.py - */setup.py - */features.py - */examples/* - */notebooks/* - */tests/* - -[report] -show_missing = True diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml deleted file mode 100644 index 98b2a66..0000000 --- a/.github/workflows/black.yml +++ /dev/null @@ -1,10 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - lint: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: psf/black@stable \ No newline at end of file diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index 84b4e4d..69b2679 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -2,9 +2,9 @@ name: Python tests on: push: - branches: [master, develop] + branches: [master] pull_request: - branches: [master, develop] + branches: [master] jobs: build: @@ -20,27 +20,25 @@ jobs: FORCE_COLOR: true steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt - pip install . + pip install .[test] - name: Test with pytest run: | - pytest --cov --cov-report=xml --cov-config=setup.cfg --verbose + pytest --cov --cov-report=xml --verbose - name: Upload coverage report if: ${{ matrix.platform == 'ubuntu-latest' && matrix.python-version == 3.9 }} - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: token: a58a0c62-fb11-4429-977b-65bec01ecb44 file: ./coverage.xml \ No newline at end of file diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml new file mode 100644 index 0000000..6251dfa --- /dev/null +++ b/.github/workflows/ruff.yml @@ -0,0 +1,13 @@ +name: Ruff +on: [push, pull_request] +jobs: + ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: "Linting" + uses: astral-sh/ruff-action@v1 + - name: "Formatting" + uses: astral-sh/ruff-action@v1 + with: + args: "format --check" diff --git a/MANIFEST.in b/MANIFEST.in index a783d04..14a8d2f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,21 +1,6 @@ -# Add README, LICENSE and requirements : +# Add README and LICENSE : include README.rst include LICENSE -include requirements.txt # Add trained classifiers -# v0.4.0 -include yasa/classifiers/clf_eeg_lgb_0.4.0.joblib -include yasa/classifiers/clf_eeg+eog_lgb_0.4.0.joblib -include yasa/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib -include yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib - -# v0.5.0 -include yasa/classifiers/clf_eeg_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+demo_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+eog_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+emg_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib -include yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib \ No newline at end of file +recursive-include yasa/classifiers/ *.joblib diff --git a/README.rst b/README.rst index f93f088..015f330 100644 --- a/README.rst +++ b/README.rst @@ -48,6 +48,14 @@ Alternatively, YASA can be installed with conda: conda config --set channel_priority strict conda install yasa +To build and install from source, clone this repository or download the source archive and decompress the files + +.. code-block:: shell + cd yasa + pip install .[test] # install the package + pip install --editable .[test] # or editable install + pytest # test the package + **What are the prerequisites for using YASA?** To use YASA, all you need is: diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 96e1205..0000000 --- a/codecov.yml +++ /dev/null @@ -1,2 +0,0 @@ -codecov: - token: a58a0c62-fb11-4429-977b-65bec01ecb44 diff --git a/docs/contributing.rst b/docs/contributing.rst index 25da5a5..a131c96 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,11 +12,11 @@ Code guidelines Before starting new code, we highly recommend opening an issue on `GitHub `_ to discuss potential changes. -* Please use standard `pep8 `_ and `flake8 `_ Python style guidelines. To test that your code complies with those, you can run: +* Please use standard `pep8 `_ and `flake8 `_ Python style guidelines. YASA uses `ruff `_ for code formatting. Before submitting a PR, please make sure to run the following command in the root folder of YASA: .. code-block:: bash - $ flake8 + $ ruff format --line-length=100 * Use `NumPy style `_ for docstrings. Follow existing examples for simplest guidance. diff --git a/pyproject.toml b/pyproject.toml index 67a6d04..dac517c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,126 @@ -[tool.black] +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "yasa" +description = "YASA: Analysis of polysomnography recordings." +readme = "README.rst" +license = {text = "BSD (3-clause)"} +authors = [ + {name = "Raphael Vallat", email = "raphaelvallat9@gmail.com"}, + {name = "Remington Mallett", email = "mallett.remy@gmail.com"}, +] +maintainers = [ + {name = "Raphael Vallat", email = "raphaelvallat9@gmail.com"}, + {name = "Remington Mallett", email = "mallett.remy@gmail.com"}, +] +classifiers = [ + "Intended Audience :: Science/Research", + "Operating System :: MacOS", + "Operating System :: POSIX", + "Operating System :: Unix", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dynamic = ["version"] +requires-python = ">=3.8" +dependencies = [ + "numpy>=1.18.1", + "scipy", + "pandas", + "matplotlib", + "seaborn", + "mne>=1.3", + "numba>=0.57.1", + "antropy", + "scikit-learn", + "tensorpac>=0.6.5", + "pyriemann>=0.2.7", + "sleepecg>=0.5.0", + "lspopt", + "ipywidgets", + "joblib", + "lightgbm", +] + +[project.optional-dependencies] +test = [ + "pytest>=6", + "pytest-cov", + # Ensure coverage is new enough for `source_pkgs`. + "coverage[toml]>=5.3", + "ruff" +] +docs = [ + "sphinx>7.0.0", + "pydata_sphinx_theme", + "numpydoc", + "sphinx-copybutton", + "sphinx-design", + "sphinx-notfound-page", +] + +[project.urls] +Homepage = "https://github.com/raphaelvallat/yasa/" +Downloads = "https://github.com/raphaelvallat/yasa/" + +[tool.setuptools] +py-modules = ["yasa"] +include-package-data = true + +[tool.setuptools.package-data] +yasa = [ + "classifiers/*.joblib", +] + +[tool.setuptools.packages.find] +namespaces = false +where = ["yasa"] + +[tool.setuptools.dynamic] +version = {attr = "yasa.__version__"} + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--showlocals --durations=10 --maxfail=2 --cov" +doctest_optionflags= ["NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] +filterwarnings = [ + "ignore::UserWarning", + "ignore::RuntimeWarning", + "ignore::FutureWarning", +] +markers = ["slow"] + +[tool.coverage.run] +branch = true +omit = [ + "*/tests/*", +] +source_pkgs = ["yasa"] + +[tool.coverage.paths] +source = ["yasa"] + +[tool.coverage.report] +show_missing = true +# sort = "Cover" + +[tool.ruff] line-length = 100 -target-version = ['py311'] -include = '\.pyi?$' \ No newline at end of file +target-version = "py311" +exclude = [ + "__init__.py", # Skip init files bc they use star imports (breaking rules F403, F405) + "notebooks", # Skip jupyter notebook examples +] + +[tool.ruff.lint] +select = [ + "E4", # Subset of pycodestyle rules + "E7", # Subset of pycodestyle rules + "E9", # Subset of pycodestyle rules + "F", # All Pyflakes rules + "NPY201", +] diff --git a/requirements-test.txt b/requirements-test.txt deleted file mode 100644 index 9b95db4..0000000 --- a/requirements-test.txt +++ /dev/null @@ -1,4 +0,0 @@ -pytest>=7.2.0 -codecov -pytest-cov -pytest-sugar \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 7d857e4..0000000 --- a/requirements.txt +++ /dev/null @@ -1,16 +0,0 @@ -numpy>=1.18.1 -scipy -pandas -mne>=1.3 -numba>=0.57.1 -matplotlib -ipywidgets -seaborn>=0.12.0 -lspopt -tensorpac>=0.6.5 -scikit-learn -pyriemann>=0.2.7 -sleepecg>=0.5.0 -joblib -antropy -lightgbm diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5b99974..0000000 --- a/setup.cfg +++ /dev/null @@ -1,26 +0,0 @@ -[aliases] -test=pytest - -[tool:pytest] -addopts = --showlocals --durations=10 --cov -markers = - slow: mark a test as slow. -filterwarnings = - ignore:the matrix subclass:PendingDeprecationWarning -testpaths = - yasa/tests/ - -[flake8] -max-line-length = 100 -ignore = N806, N803, D107, D200, D205, D400, D401, D412, W504, E203 -exclude = - .git, - __pycache__, - docs, - tests, - __init__.py, - plotting.py, - examples, - notebooks, - setup.py, -statistics=True diff --git a/setup.py b/setup.py deleted file mode 100644 index 307a047..0000000 --- a/setup.py +++ /dev/null @@ -1,76 +0,0 @@ -#! /usr/bin/env python -# -# Copyright (C) 2018 Raphael Vallat - -DESCRIPTION = "YASA: Analysis of polysomnography recordings." -LONG_DESCRIPTION = """YASA (Yet Another Spindle Algorithm) : an open-source Python package to analyze polysomnographic sleep recordings. -""" - -DISTNAME = "yasa" -MAINTAINER = "Raphael Vallat" -MAINTAINER_EMAIL = "raphaelvallat9@gmail.com" -URL = "https://github.com/raphaelvallat/yasa/" -LICENSE = "BSD (3-clause)" -DOWNLOAD_URL = "https://github.com/raphaelvallat/yasa/" -VERSION = "0.6.5" -PACKAGE_DATA = {"yasa.data.icons": ["*.svg"]} - -INSTALL_REQUIRES = [ - "numpy>=1.18.1", - "scipy", - "pandas", - "matplotlib", - "seaborn", - "mne>=1.3", - "numba>=0.57.1", - "antropy", - "scikit-learn", - "tensorpac>=0.6.5", - "pyriemann>=0.2.7", - "sleepecg>=0.5.0", - "lspopt", - "ipywidgets", - "joblib", - "lightgbm", -] - -PACKAGES = [ - "yasa", -] - -CLASSIFIERS = [ - "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "License :: OSI Approved :: BSD License", - "Operating System :: POSIX", - "Operating System :: Unix", - "Operating System :: MacOS", -] - -try: - from setuptools import setup -except ImportError: - from distutils.core import setup - -if __name__ == "__main__": - setup( - name=DISTNAME, - author=MAINTAINER, - author_email=MAINTAINER_EMAIL, - maintainer=MAINTAINER, - maintainer_email=MAINTAINER_EMAIL, - description=DESCRIPTION, - long_description=LONG_DESCRIPTION, - license=LICENSE, - url=URL, - version=VERSION, - download_url=DOWNLOAD_URL, - install_requires=INSTALL_REQUIRES, - include_package_data=True, - packages=PACKAGES, - package_data=PACKAGE_DATA, - classifiers=CLASSIFIERS, - ) diff --git a/yasa/detection.py b/yasa/detection.py index 9d9bcac..6ae0a4c 100644 --- a/yasa/detection.py +++ b/yasa/detection.py @@ -86,7 +86,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch include = np.atleast_1d(np.asarray(include)) assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.in1d(hypno, include).any(), ( + assert np.isin(hypno, include).any(), ( "None of the stages specified " "in `include` are present in " "hypno." ) @@ -110,7 +110,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch # 5) Create sleep stage vector mask if hypno is not None: - mask = np.in1d(hypno, include) + mask = np.isin(hypno, include) else: mask = np.ones(n_samples, dtype=bool) diff --git a/yasa/evaluation.py b/yasa/evaluation.py index 007d4db..661f05d 100644 --- a/yasa/evaluation.py +++ b/yasa/evaluation.py @@ -219,7 +219,7 @@ def __init__(self, ref_hyps, obs_hyps): # Generate some mapping dictionaries to be used later in class methods skm_labels = np.unique(data).tolist() # all unique YASA integer codes in this hypno - skm2yasa_map = {i: l for i, l in enumerate(skm_labels)} # skm order to YASA integers + skm2yasa_map = {i: lab for i, lab in enumerate(skm_labels)} # skm order to YASA integers yasa2yasa_map = ref_hyps[sleep_ids[0]].mapping_int.copy() # YASA integer to YASA string # Set attributes diff --git a/yasa/spectral.py b/yasa/spectral.py index 139d225..8c988a0 100644 --- a/yasa/spectral.py +++ b/yasa/spectral.py @@ -151,7 +151,7 @@ def bandpower( assert hypno.size == npts, "Hypno must have same size as data.shape[1]" assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.in1d( + assert np.isin( hypno, include ).any(), "None of the stages specified in `include` are present in hypno." # Initialize empty dataframe and loop over stages diff --git a/yasa/staging.py b/yasa/staging.py index 7b6df76..a406af6 100644 --- a/yasa/staging.py +++ b/yasa/staging.py @@ -8,6 +8,7 @@ import numpy as np import pandas as pd import antropy as ant +from scipy.integrate import trapezoid import scipy.signal as sp_sig import scipy.stats as sp_stats import matplotlib.pyplot as plt @@ -289,7 +290,7 @@ def fit(self): # Add total power idx_broad = np.logical_and(freqs >= freq_broad[0], freqs <= freq_broad[1]) dx = freqs[1] - freqs[0] - feat["abspow"] = np.trapz(psd[:, idx_broad], dx=dx) + feat["abspow"] = trapezoid(psd[:, idx_broad], dx=dx) # Calculate entropy and fractal dimension features feat["perm"] = np.apply_along_axis(ant.perm_entropy, axis=1, arr=epochs, normalize=True) diff --git a/yasa/tests/test_plotting.py b/yasa/tests/test_plotting.py index 078c059..81c8532 100644 --- a/yasa/tests/test_plotting.py +++ b/yasa/tests/test_plotting.py @@ -1,6 +1,5 @@ """Test the functions in the yasa/plotting.py file.""" -import pytest import unittest import numpy as np import pandas as pd From f9fc8d7712e3c89acc82681945b54b67639462d8 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Fri, 20 Dec 2024 21:06:06 +0100 Subject: [PATCH 02/19] move to src folder + ruff --- {yasa => src/yasa}/__init__.py | 1 + {yasa => src/yasa}/classifiers/__init__.py | 0 .../classifiers/clf_eeg+demo_lgb_0.5.0.joblib | Bin .../clf_eeg+emg+demo_lgb_0.5.0.joblib | Bin .../classifiers/clf_eeg+emg_lgb_0.5.0.joblib | Bin .../clf_eeg+eog+demo_lgb_0.5.0.joblib | Bin .../clf_eeg+eog+emg+demo_lgb_0.4.0.joblib | Bin .../clf_eeg+eog+emg+demo_lgb_0.5.0.joblib | Bin .../clf_eeg+eog+emg_lgb_0.4.0.joblib | Bin .../clf_eeg+eog+emg_lgb_0.5.0.joblib | Bin .../classifiers/clf_eeg+eog_lgb_0.4.0.joblib | Bin .../classifiers/clf_eeg+eog_lgb_0.5.0.joblib | Bin .../classifiers/clf_eeg_lgb_0.4.0.joblib | Bin .../classifiers/clf_eeg_lgb_0.5.0.joblib | Bin {yasa => src/yasa}/detection.py | 211 +++++++-- {yasa => src/yasa}/evaluation.py | 330 ++++++++++--- {yasa => src/yasa}/features.py | 7 +- {yasa => src/yasa}/heart.py | 5 +- {yasa => src/yasa}/hypno.py | 447 +++++++++++++++--- {yasa => src/yasa}/io.py | 4 +- {yasa => src/yasa}/numba.py | 0 {yasa => src/yasa}/others.py | 88 +++- {yasa => src/yasa}/plotting.py | 202 ++++++-- {yasa => src/yasa}/push_pypi.md | 0 {yasa => src/yasa}/sleepstats.py | 153 +++++- {yasa => src/yasa}/spectral.py | 4 +- {yasa => src/yasa}/staging.py | 41 +- {yasa/tests => tests}/__init__.py | 0 {yasa/tests => tests}/test_detection.py | 12 +- {yasa/tests => tests}/test_heart.py | 2 + {yasa/tests => tests}/test_hypno.py | 15 +- {yasa/tests => tests}/test_hypnoclass.py | 10 +- {yasa/tests => tests}/test_io.py | 8 +- {yasa/tests => tests}/test_numba.py | 4 +- {yasa/tests => tests}/test_others.py | 15 +- {yasa/tests => tests}/test_plotting.py | 6 +- {yasa/tests => tests}/test_sleepstats.py | 4 +- {yasa/tests => tests}/test_spectral.py | 11 +- {yasa/tests => tests}/test_staging.py | 6 +- 39 files changed, 1292 insertions(+), 294 deletions(-) rename {yasa => src/yasa}/__init__.py (99%) rename {yasa => src/yasa}/classifiers/__init__.py (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+demo_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+emg_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog_lgb_0.4.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg+eog_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg_lgb_0.4.0.joblib (100%) rename {yasa => src/yasa}/classifiers/clf_eeg_lgb_0.5.0.joblib (100%) rename {yasa => src/yasa}/detection.py (97%) rename {yasa => src/yasa}/evaluation.py (90%) rename {yasa => src/yasa}/features.py (99%) rename {yasa => src/yasa}/heart.py (99%) rename {yasa => src/yasa}/hypno.py (88%) rename {yasa => src/yasa}/io.py (97%) rename {yasa => src/yasa}/numba.py (100%) rename {yasa => src/yasa}/others.py (90%) rename {yasa => src/yasa}/plotting.py (79%) rename {yasa => src/yasa}/push_pypi.md (100%) rename {yasa => src/yasa}/sleepstats.py (77%) rename {yasa => src/yasa}/spectral.py (99%) rename {yasa => src/yasa}/staging.py (97%) rename {yasa/tests => tests}/__init__.py (100%) rename {yasa/tests => tests}/test_detection.py (99%) rename {yasa/tests => tests}/test_heart.py (99%) rename {yasa/tests => tests}/test_hypno.py (99%) rename {yasa/tests => tests}/test_hypnoclass.py (99%) rename {yasa/tests => tests}/test_io.py (99%) rename {yasa/tests => tests}/test_numba.py (94%) rename {yasa/tests => tests}/test_others.py (99%) rename {yasa/tests => tests}/test_plotting.py (98%) rename {yasa/tests => tests}/test_sleepstats.py (97%) rename {yasa/tests => tests}/test_spectral.py (99%) rename {yasa/tests => tests}/test_staging.py (99%) diff --git a/yasa/__init__.py b/src/yasa/__init__.py similarity index 99% rename from yasa/__init__.py rename to src/yasa/__init__.py index a750a04..93f92f7 100644 --- a/yasa/__init__.py +++ b/src/yasa/__init__.py @@ -1,4 +1,5 @@ import logging + from .detection import * from .evaluation import * from .features import * diff --git a/yasa/classifiers/__init__.py b/src/yasa/classifiers/__init__.py similarity index 100% rename from yasa/classifiers/__init__.py rename to src/yasa/classifiers/__init__.py diff --git a/yasa/classifiers/clf_eeg+demo_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+demo_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+demo_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+demo_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+emg+demo_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+emg_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+emg_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+emg_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+emg_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+eog+demo_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib b/src/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib rename to src/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.4.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+eog+emg+demo_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib b/src/yasa/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib rename to src/yasa/classifiers/clf_eeg+eog+emg_lgb_0.4.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+eog+emg_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog_lgb_0.4.0.joblib b/src/yasa/classifiers/clf_eeg+eog_lgb_0.4.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog_lgb_0.4.0.joblib rename to src/yasa/classifiers/clf_eeg+eog_lgb_0.4.0.joblib diff --git a/yasa/classifiers/clf_eeg+eog_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg+eog_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg+eog_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg+eog_lgb_0.5.0.joblib diff --git a/yasa/classifiers/clf_eeg_lgb_0.4.0.joblib b/src/yasa/classifiers/clf_eeg_lgb_0.4.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg_lgb_0.4.0.joblib rename to src/yasa/classifiers/clf_eeg_lgb_0.4.0.joblib diff --git a/yasa/classifiers/clf_eeg_lgb_0.5.0.joblib b/src/yasa/classifiers/clf_eeg_lgb_0.5.0.joblib similarity index 100% rename from yasa/classifiers/clf_eeg_lgb_0.5.0.joblib rename to src/yasa/classifiers/clf_eeg_lgb_0.5.0.joblib diff --git a/yasa/detection.py b/src/yasa/detection.py similarity index 97% rename from yasa/detection.py rename to src/yasa/detection.py index 6ae0a4c..347b4a8 100644 --- a/yasa/detection.py +++ b/src/yasa/detection.py @@ -7,29 +7,29 @@ - License: BSD 3-Clause License """ -import mne import logging +from collections import OrderedDict + +import mne import numpy as np import pandas as pd -from scipy import signal from mne.filter import filter_data -from collections import OrderedDict -from scipy.interpolate import interp1d +from scipy import signal from scipy.fftpack import next_fast_len +from scipy.interpolate import interp1d from sklearn.ensemble import IsolationForest -from .spectral import stft_power +from .io import is_pyriemann_installed, is_tensorpac_installed, set_log_level from .numba import _detrend, _rms -from .io import set_log_level, is_tensorpac_installed, is_pyriemann_installed from .others import ( - moving_transform, - trimbothstd, - get_centered_indices, - sliding_window, _merge_close, _zerocrossings, + get_centered_indices, + moving_transform, + sliding_window, + trimbothstd, ) - +from .spectral import stft_power logger = logging.getLogger("yasa") @@ -458,8 +458,8 @@ def plot_average( **kwargs, ): """Plot the average event (not for REM, spindles & SW only)""" - import seaborn as sns import matplotlib.pyplot as plt + import seaborn as sns df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -485,8 +485,8 @@ def plot_average( def plot_detection(self): """Plot an overlay of the detected events on the signal.""" - import matplotlib.pyplot as plt import ipywidgets as ipy + import matplotlib.pyplot as plt # Define mask sf = self._sf @@ -691,7 +691,11 @@ def spindles_detect( sp : :py:class:`yasa.SpindlesResults` To get the full detection dataframe, use: - >>> sp = spindles_detect(...) + >>> sp = ( + ... spindles_detect( + ... ... + ... ) + ... ) >>> sp.summary() This will give a :py:class:`pandas.DataFrame` where each row is a @@ -699,7 +703,10 @@ def spindles_detect( of this spindle. To get the average spindles parameters per channel and sleep stage: - >>> sp.summary(grp_chan=True, grp_stage=True) + >>> sp.summary( + ... grp_chan=True, + ... grp_stage=True, + ... ) Notes ----- @@ -1148,15 +1155,48 @@ def get_coincidence_matrix(self, scaled=True): Calculate the coincidence of two binary mask: >>> import numpy as np - >>> x = np.array([0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1]) - >>> y = np.array([0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1]) + >>> x = np.array( + ... [ + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 0, + ... 1, + ... ] + ... ) + >>> y = np.array( + ... [ + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... ] + ... ) >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) - >>> (x * y).sum() # Unscaled coincidence + >>> ( + ... x * y + ... ).sum() # Unscaled coincidence 3 - >>> (x * y).sum() / (x.sum() * y.sum()) # Scaled coincidence + >>> (x * y).sum() / ( + ... x.sum() + ... * y.sum() + ... ) # Scaled coincidence 0.12 References @@ -1502,8 +1542,17 @@ def sw_detect( .. code-block:: python import pingouin as pg - mean_direction = pg.circ_mean(sw['PhaseAtSigmaPeak']) - vector_length = pg.circ_r(sw['PhaseAtSigmaPeak']) + + mean_direction = pg.circ_mean( + sw[ + "PhaseAtSigmaPeak" + ] + ) + vector_length = pg.circ_r( + sw[ + "PhaseAtSigmaPeak" + ] + ) 3. ``ndPAC``: the normalized Mean Vector Length (also called the normalized direct PAC, or ndPAC) within a 2-sec epoch centered around the negative peak of the slow-wave. @@ -1563,7 +1612,10 @@ def sw_detect( detected slow-wave and each column is a parameter (= property). To get the average SW parameters per channel and sleep stage: - >>> sw.summary(grp_chan=True, grp_stage=True) + >>> sw.summary( + ... grp_chan=True, + ... grp_stage=True, + ... ) Notes ----- @@ -1592,7 +1644,7 @@ def sw_detect( of the slow-wave. This is only calculated when ``coupling=True`` * ``'Stage'``: Sleep stage (only if hypno was provided) - .. image:: https://raw.githubusercontent.com/raphaelvallat/yasa/master/docs/pictures/slow_waves.png # noqa + .. image:: https://raw.githubusercontent.com/raphaelvallat/yasa/master/docs/pictures/slow_waves.png :width: 500px :align: center :alt: slow-wave @@ -1616,7 +1668,7 @@ def sw_detect( -------- For an example of how to run the detection, please refer to the tutorial: https://github.com/raphaelvallat/yasa/blob/master/notebooks/05_sw_detection.ipynb - """ + """ # noqa: E501 set_log_level(verbose) (data, sf, ch_names, hypno, include, mask, n_chan, n_samples, bad_chan) = _check_data_hypno( @@ -1828,11 +1880,9 @@ def sw_detect( if coupling: # Get phase and amplitude for each centered epoch time_before = time_after = coupling_params["time"] - assert float( - sf * time_before - ).is_integer(), ( - "Invalid time parameter for coupling. Must be a whole number of samples." - ) + assert ( + float(sf * time_before).is_integer() + ), "Invalid time parameter for coupling. Must be a whole number of samples." bef = int(sf * time_before) aft = int(sf * time_after) # Center of each epoch is defined as the negative peak of the SW @@ -2173,15 +2223,48 @@ def get_coincidence_matrix(self, scaled=True): Calculate the coincidence of two binary mask: >>> import numpy as np - >>> x = np.array([0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1]) - >>> y = np.array([0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1]) + >>> x = np.array( + ... [ + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 0, + ... 1, + ... ] + ... ) + >>> y = np.array( + ... [ + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... ] + ... ) >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) - >>> (x * y).sum() # Coincidence + >>> ( + ... x * y + ... ).sum() # Coincidence 3 - >>> (x * y).sum() / (x.sum() * y.sum()) # Scaled coincidence + >>> (x * y).sum() / ( + ... x.sum() + ... * y.sum() + ... ) # Scaled coincidence 0.12 References @@ -2355,7 +2438,9 @@ def rem_detect( Therefore, if passing data from a :py:class:`mne.io.BaseRaw`, make sure to use units="uV" to get the data in micro-Volts, e.g.: - >>> data = raw.get_data(units="uV") # Make sure that data is in uV + >>> data = raw.get_data( + ... units="uV" + ... ) # Make sure that data is in uV sf : float Sampling frequency of the data, in Hz. hypno : array_like @@ -2412,14 +2497,18 @@ def rem_detect( rem : :py:class:`yasa.REMResults` To get the full detection dataframe, use: - >>> rem = rem_detect(...) + >>> rem = rem_detect( + ... ... + ... ) >>> rem.summary() This will give a :py:class:`pandas.DataFrame` where each row is a detected REM and each column is a parameter (= property). To get the average parameters sleep stage: - >>> rem.summary(grp_stage=True) + >>> rem.summary( + ... grp_stage=True + ... ) Notes ----- @@ -2782,8 +2871,8 @@ def plot_average( **kwargs : dict Optional argument that are passed to :py:func:`seaborn.lineplot`. """ - import seaborn as sns import matplotlib.pyplot as plt + import seaborn as sns df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -3026,8 +3115,8 @@ def art_detect( if method in ["cov", "covar", "covariance", "riemann", "potato"]: method = "covar" is_pyriemann_installed() - from pyriemann.estimation import Covariances, Shrinkage from pyriemann.clustering import Potato + from pyriemann.estimation import Covariances, Shrinkage # Must have at least 4 channels to use method='covar' if n_chan <= 4: @@ -3236,10 +3325,33 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): These could be for example the index of the onset of each detected spindle. `grndtrth` refers to the ground-truth (e.g. human-annotated) events. - >>> from yasa import compare_detection - >>> detected = [5, 12, 20, 34, 41, 57, 63] - >>> grndtrth = [5, 12, 18, 26, 34, 41, 55, 63, 68] - >>> compare_detection(detected, grndtrth) + >>> from yasa import ( + ... compare_detection, + ... ) + >>> detected = [ + ... 5, + ... 12, + ... 20, + ... 34, + ... 41, + ... 57, + ... 63, + ... ] + >>> grndtrth = [ + ... 5, + ... 12, + ... 18, + ... 26, + ... 34, + ... 41, + ... 55, + ... 63, + ... 68, + ... ] + >>> compare_detection( + ... detected, + ... grndtrth, + ... ) {'tp': array([ 5, 12, 34, 41, 63]), 'fp': array([20, 57]), 'fn': array([18, 26, 55, 68]), @@ -3257,7 +3369,10 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): detections (and not a detection against a ground-truth), the F1-score is the preferred metric because it is independent of the order. - >>> compare_detection(grndtrth, detected) + >>> compare_detection( + ... grndtrth, + ... detected, + ... ) {'tp': array([ 5, 12, 34, 41, 63]), 'fp': array([18, 26, 55, 68]), 'fn': array([20, 57]), @@ -3270,7 +3385,11 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): with the `max_distance` argument, which defines the lookaround window (in samples) for each event. - >>> compare_detection(detected, grndtrth, max_distance=2) + >>> compare_detection( + ... detected, + ... grndtrth, + ... max_distance=2, + ... ) {'tp': array([ 5, 12, 20, 34, 41, 57, 63]), 'fp': array([], dtype=int64), 'fn': array([26, 68]), @@ -3281,7 +3400,9 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): Finally, if detected is empty, all performance metrics will be set to zero, and a copy of the groundtruth array will be returned as false negatives. - >>> compare_detection([], grndtrth) + >>> compare_detection( + ... [], grndtrth + ... ) {'tp': array([], dtype=int64), 'fp': array([], dtype=int64), 'fn': array([ 5, 12, 18, 26, 34, 41, 55, 63, 68]), diff --git a/yasa/evaluation.py b/src/yasa/evaluation.py similarity index 90% rename from yasa/evaluation.py rename to src/yasa/evaluation.py index 661f05d..34f813c 100644 --- a/yasa/evaluation.py +++ b/src/yasa/evaluation.py @@ -16,7 +16,6 @@ import scipy.stats as sps import sklearn.metrics as skm - logger = logging.getLogger("yasa") __all__ = [ @@ -81,9 +80,29 @@ class EpochByEpochAgreement: Examples -------- >>> import yasa - >>> ref_hyps = [yasa.simulate_hypnogram(tib=600, scorer="Human", seed=i) for i in range(10)] - >>> obs_hyps = [h.simulate_similar(scorer="YASA", seed=i) for i, h in enumerate(ref_hyps)] - >>> ebe = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) + >>> ref_hyps = [ + ... yasa.simulate_hypnogram( + ... tib=600, + ... scorer="Human", + ... seed=i, + ... ) + ... for i in range( + ... 10 + ... ) + ... ] + >>> obs_hyps = [ + ... h.simulate_similar( + ... scorer="YASA", + ... seed=i, + ... ) + ... for i, h in enumerate( + ... ref_hyps + ... ) + ... ] + >>> ebe = yasa.EpochByEpochAgreement( + ... ref_hyps, + ... obs_hyps, + ... ) >>> agr = ebe.get_agreement() >>> agr.head(5).round(2) accuracy balanced_acc kappa mcc precision recall f1 @@ -94,7 +113,9 @@ class EpochByEpochAgreement: 4 0.22 0.21 0.01 0.01 0.21 0.22 0.21 5 0.21 0.17 -0.06 -0.06 0.20 0.21 0.21 - >>> ebe.get_agreement_bystage().head(12).round(3) + >>> ebe.get_agreement_bystage().head( + ... 12 + ... ).round(3) fbeta precision recall support stage sleep_id WAKE 1 0.391 0.371 0.413 189.0 @@ -110,7 +131,9 @@ class EpochByEpochAgreement: N1 1 0.185 0.185 0.185 124.0 2 0.121 0.131 0.112 160.0 - >>> ebe.get_confusion_matrix(sleep_id=1) + >>> ebe.get_confusion_matrix( + ... sleep_id=1 + ... ) YASA WAKE N1 N2 N3 REM Human WAKE 78 24 50 3 34 @@ -122,12 +145,29 @@ class EpochByEpochAgreement: .. plot:: >>> import matplotlib.pyplot as plt - >>> fig, ax = plt.subplots(figsize=(6, 3), constrained_layout=True) - >>> ebe.plot_hypnograms(sleep_id=10) + >>> fig, ax = ( + ... plt.subplots( + ... figsize=( + ... 6, + ... 3, + ... ), + ... constrained_layout=True, + ... ) + ... ) + >>> ebe.plot_hypnograms( + ... sleep_id=10 + ... ) .. plot:: - >>> fig, ax = plt.subplots(figsize=(6, 3)) + >>> fig, ax = ( + ... plt.subplots( + ... figsize=( + ... 6, + ... 3, + ... ) + ... ) + ... ) >>> ebe.plot_hypnograms( >>> sleep_id=8, ax=ax, obs_kwargs={"color": "red", "lw": 2, "ls": "dotted"} >>> ) @@ -136,25 +176,61 @@ class EpochByEpochAgreement: .. plot:: >>> session = 8 - >>> fig, ax = plt.subplots(figsize=(6.5, 2.5), constrained_layout=True) - >>> style_a = dict(alpha=1, lw=2.5, ls="solid", color="gainsboro", label="Michel") - >>> style_b = dict(alpha=1, lw=2.5, ls="solid", color="cornflowerblue", label="Jouvet") + >>> fig, ax = ( + ... plt.subplots( + ... figsize=( + ... 6.5, + ... 2.5, + ... ), + ... constrained_layout=True, + ... ) + ... ) + >>> style_a = dict( + ... alpha=1, + ... lw=2.5, + ... ls="solid", + ... color="gainsboro", + ... label="Michel", + ... ) + >>> style_b = dict( + ... alpha=1, + ... lw=2.5, + ... ls="solid", + ... color="cornflowerblue", + ... label="Jouvet", + ... ) >>> legend_style = dict( >>> title="Scorer", frameon=False, ncol=2, loc="lower center", bbox_to_anchor=(0.5, 0.9) >>> ) >>> ax = ebe.plot_hypnograms( >>> sleep_id=session, ref_kwargs=style_a, obs_kwargs=style_b, legend=legend_style, ax=ax >>> ) - >>> acc = ebe.get_agreement().multiply(100).at[session, "accuracy"] + >>> acc = ( + ... ebe.get_agreement() + ... .multiply(100) + ... .at[ + ... session, + ... "accuracy", + ... ] + ... ) >>> ax.text( >>> 0.01, 1, f"Accuracy = {acc:.0f}%", ha="left", va="bottom", transform=ax.transAxes >>> ) When comparing only 2 hypnograms, use the :py:meth:`~yasa.Hynogram.evaluate` method: - >>> hypno_a = yasa.simulate_hypnogram(tib=90, scorer="RaterA", seed=8) - >>> hypno_b = hypno_a.simulate_similar(scorer="RaterB", seed=9) - >>> ebe = hypno_a.evaluate(hypno_b) + >>> hypno_a = yasa.simulate_hypnogram( + ... tib=90, + ... scorer="RaterA", + ... seed=8, + ... ) + >>> hypno_b = hypno_a.simulate_similar( + ... scorer="RaterB", + ... seed=9, + ... ) + >>> ebe = hypno_a.evaluate( + ... hypno_b + ... ) >>> ebe.get_confusion_matrix() RaterB WAKE N1 N2 N3 RaterA @@ -476,10 +552,32 @@ def get_confusion_matrix(self, sleep_id=None, agg_func=None, **kwargs): Examples -------- >>> import yasa - >>> ref_hyps = [yasa.simulate_hypnogram(tib=90, scorer="Rater1", seed=i) for i in range(3)] - >>> obs_hyps = [h.simulate_similar(scorer="Rater2", seed=i) for i, h in enumerate(ref_hyps)] - >>> ebe = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) - >>> ebe.get_confusion_matrix(sleep_id=2) + >>> ref_hyps = [ + ... yasa.simulate_hypnogram( + ... tib=90, + ... scorer="Rater1", + ... seed=i, + ... ) + ... for i in range( + ... 3 + ... ) + ... ] + >>> obs_hyps = [ + ... h.simulate_similar( + ... scorer="Rater2", + ... seed=i, + ... ) + ... for i, h in enumerate( + ... ref_hyps + ... ) + ... ] + >>> ebe = yasa.EpochByEpochAgreement( + ... ref_hyps, + ... obs_hyps, + ... ) + >>> ebe.get_confusion_matrix( + ... sleep_id=2 + ... ) Rater2 WAKE N1 N2 N3 REM Rater1 WAKE 1 2 23 0 0 @@ -507,7 +605,9 @@ def get_confusion_matrix(self, sleep_id=None, agg_func=None, **kwargs): N3 0 0 16 11 0 REM 0 15 11 18 0 - >>> ebe.get_confusion_matrix(agg_func="sum") + >>> ebe.get_confusion_matrix( + ... agg_func="sum" + ... ) Rater2 WAKE N1 N2 N3 REM Rater1 WAKE 47 2 33 19 54 @@ -628,9 +728,19 @@ def plot_hypnograms(self, sleep_id=None, legend=True, ax=None, ref_kwargs={}, ob -------- .. plot:: - >>> from yasa import simulate_hypnogram - >>> hyp = simulate_hypnogram(scorer="Anthony", seed=19) - >>> ax = hyp.evaluate(hyp.simulate_similar(scorer="Alan", seed=68)).plot_hypnograms() + >>> from yasa import ( + ... simulate_hypnogram, + ... ) + >>> hyp = simulate_hypnogram( + ... scorer="Anthony", + ... seed=19, + ... ) + >>> ax = hyp.evaluate( + ... hyp.simulate_similar( + ... scorer="Alan", + ... seed=68, + ... ) + ... ).plot_hypnograms() """ assert ( sleep_id is None or sleep_id in self._sleep_ids @@ -702,7 +812,9 @@ def summary(self, by_stage=False, **kwargs): A :py:class:`pandas.DataFrame` summarizing agreement scores across the entire dataset with descriptive statistics. - >>> ebe = yasa.EpochByEpochAgreement(...) + >>> ebe = yasa.EpochByEpochAgreement( + ... ... + ... ) >>> agreement = ebe.get_agreement() >>> ebe.summary() @@ -710,7 +822,13 @@ def summary(self, by_stage=False, **kwargs): each column is a descriptive statistic (e.g., mean, standard deviation). To control the descriptive statistics included as columns: - >>> ebe.summary(func=["count", "mean", "sem"]) + >>> ebe.summary( + ... func=[ + ... "count", + ... "mean", + ... "sem", + ... ] + ... ) """ assert self.n_sleeps > 1, "Summary scores can not be computed with only one hypnogram pair." assert isinstance(by_stage, bool), "`by_stage` must be True or False" @@ -817,17 +935,52 @@ class SleepStatsAgreement: >>> >>> # Generate fake reference and observed datasets with similar sleep statistics >>> ref_scorer = "Henri" - >>> obs_scorer = "Piéron" - >>> ref_hyps = [yasa.simulate_hypnogram(tib=600, scorer=ref_scorer, seed=i) for i in range(20)] - >>> obs_hyps = [h.simulate_similar(scorer=obs_scorer, seed=i) for i, h in enumerate(ref_hyps)] + >>> obs_scorer = ( + ... "Piéron" + ... ) + >>> ref_hyps = [ + ... yasa.simulate_hypnogram( + ... tib=600, + ... scorer=ref_scorer, + ... seed=i, + ... ) + ... for i in range( + ... 20 + ... ) + ... ] + >>> obs_hyps = [ + ... h.simulate_similar( + ... scorer=obs_scorer, + ... seed=i, + ... ) + ... for i, h in enumerate( + ... ref_hyps + ... ) + ... ] >>> # Generate sleep statistics from hypnograms using EpochByEpochAgreement - >>> eea = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) + >>> eea = yasa.EpochByEpochAgreement( + ... ref_hyps, + ... obs_hyps, + ... ) >>> sstats = eea.get_sleep_stats() - >>> ref_sstats = sstats.loc[ref_scorer] - >>> obs_sstats = sstats.loc[obs_scorer] + >>> ref_sstats = ( + ... sstats.loc[ + ... ref_scorer + ... ] + ... ) + >>> obs_sstats = ( + ... sstats.loc[ + ... obs_scorer + ... ] + ... ) >>> # Create SleepStatsAgreement instance - >>> ssa = yasa.SleepStatsAgreement(ref_sstats, obs_sstats) - >>> ssa.summary().round(1).head(3) + >>> ssa = yasa.SleepStatsAgreement( + ... ref_sstats, + ... obs_sstats, + ... ) + >>> ssa.summary().round( + ... 1 + ... ).head(3) variable bias_intercept ... uloa_parm interval center lower upper ... center lower upper sleep_stat ... @@ -835,38 +988,77 @@ class SleepStatsAgreement: %N2 -27.3 -49.1 -5.6 ... 12.4 7.2 17.6 %N3 -9.1 -23.8 5.5 ... 20.4 12.6 28.3 - >>> ssa.get_table().head(3)[["bias", "loa"]] + >>> ssa.get_table().head( + ... 3 + ... )[["bias", "loa"]] bias loa sleep_stat %N1 0.25 Bias ± 2.46 * (-0.00 + 1.00x) %N2 -27.34 + 0.55x Bias ± 2.46 * (0.00 + 1.00x) %N3 1.38 Bias ± 2.46 * (0.00 + 1.00x) - >>> ssa.assumptions.head(3) + >>> ssa.assumptions.head( + ... 3 + ... ) unbiased normal constant_bias homoscedastic sleep_stat %N1 True True True False %N2 True True False False %N3 True True True False - >>> ssa.auto_methods.head(3) + >>> ssa.auto_methods.head( + ... 3 + ... ) bias loa ci sleep_stat %N1 parm regr parm %N2 regr regr parm %N3 parm regr parm - >>> ssa.get_table(bias_method="parm", loa_method="parm").head(3)[["bias", "loa"]] + >>> ssa.get_table( + ... bias_method="parm", + ... loa_method="parm", + ... ).head(3)[ + ... ["bias", "loa"] + ... ] bias loa sleep_stat %N1 0.25 -5.55, 6.06 %N2 -0.23 -12.87, 12.40 %N3 1.38 -17.67, 20.44 - >>> new_hyps = [h.simulate_similar(scorer="Kelly", seed=i) for i, h in enumerate(obs_hyps)] - >>> new_sstats = pd.Series(new_hyps).map(lambda h: h.sleep_statistics()).apply(pd.Series) - >>> new_sstats = new_sstats[["N1", "TST", "WASO"]] - >>> new_sstats.round(1).head(5) + >>> new_hyps = [ + ... h.simulate_similar( + ... scorer="Kelly", + ... seed=i, + ... ) + ... for i, h in enumerate( + ... obs_hyps + ... ) + ... ] + >>> new_sstats = ( + ... pd.Series( + ... new_hyps + ... ) + ... .map( + ... lambda h: h.sleep_statistics() + ... ) + ... .apply( + ... pd.Series + ... ) + ... ) + >>> new_sstats = ( + ... new_sstats[ + ... [ + ... "N1", + ... "TST", + ... "WASO", + ... ] + ... ] + ... ) + >>> new_sstats.round( + ... 1 + ... ).head(5) N1 TST WASO 0 42.5 439.5 147.5 1 84.0 550.0 38.5 @@ -874,8 +1066,13 @@ class SleepStatsAgreement: 3 57.0 469.5 120.0 4 71.0 531.0 69.0 - >>> new_stats_calibrated = ssa.calibrate_stats(new_sstats, bias_method="auto") - >>> new_stats_calibrated.round(1).head(5) + >>> new_stats_calibrated = ssa.calibrate_stats( + ... new_sstats, + ... bias_method="auto", + ... ) + >>> new_stats_calibrated.round( + ... 1 + ... ).head(5) N1 TST WASO 0 42.9 433.8 150.0 1 84.4 544.2 41.0 @@ -887,7 +1084,9 @@ class SleepStatsAgreement: >>> import matplotlib.pyplot as plt >>> ax = ssa.plot_discrepancies_heatmap() - >>> ax.set_title("Sleep statistic discrepancies") + >>> ax.set_title( + ... "Sleep statistic discrepancies" + ... ) >>> plt.tight_layout() .. plot:: @@ -908,7 +1107,6 @@ def __init__( verbose=True, bootstrap_kwargs={}, ): - restricted_bootstrap_kwargs = ["confidence_level", "vectorized", "paired"] assert isinstance(ref_data, pd.DataFrame), "`ref_data` must be a pandas DataFrame" @@ -1293,7 +1491,7 @@ def get_table(self, bias_method="auto", loa_method="auto", ci_method="auto", fst "bias_regr": "{bias_intercept_center:.2f} + {bias_slope_center:.2f}x", "loa_parm": "{lloa_parm_center:.2f}, {uloa_parm_center:.2f}", "loa_regr": ( - "Bias \u00B1 {loa_regr_agreement:.2f} " + "Bias \u00b1 {loa_regr_agreement:.2f} " "* ({loa_intercept_center:.2f} + {loa_slope_center:.2f}x)" ), "bias_parm_ci": ("[{bias_parm_lower:.2f}, {bias_parm_upper:.2f}]"), @@ -1461,15 +1659,39 @@ def get_calibration_func(self, sleep_stat): Examples -------- - >>> ssa = yasa.SleepStatsAgreement(...) - >>> calibrate_rem = ssa.get_calibration_func("REM") - >>> new_obs_rem_vals = np.array([50, 40, 30, 20]) - >>> calibrate_rem(new_obs_rem_vals) - >>> calibrate_rem(new_obs_rem_vals) + >>> ssa = yasa.SleepStatsAgreement( + ... ... + ... ) + >>> calibrate_rem = ssa.get_calibration_func( + ... "REM" + ... ) + >>> new_obs_rem_vals = ( + ... np.array( + ... [ + ... 50, + ... 40, + ... 30, + ... 20, + ... ] + ... ) + ... ) + >>> calibrate_rem( + ... new_obs_rem_vals + ... ) + >>> calibrate_rem( + ... new_obs_rem_vals + ... ) array([50, 40, 30, 20]) - >>> calibrate_rem(new_obs_rem_vals, bias_test=False) + >>> calibrate_rem( + ... new_obs_rem_vals, + ... bias_test=False, + ... ) array([42.825, 32.825, 22.825, 12.825]) - >>> calibrate_rem(new_obs_rem_vals, bias_test=False, method="regr") + >>> calibrate_rem( + ... new_obs_rem_vals, + ... bias_test=False, + ... method="regr", + ... ) array([ -9.33878878, -9.86815607, -10.39752335, -10.92689064]) """ assert isinstance(sleep_stat, str), "`sleep_stat` must be a string" diff --git a/yasa/features.py b/src/yasa/features.py similarity index 99% rename from yasa/features.py rename to src/yasa/features.py index cd2773e..42b1e95 100644 --- a/yasa/features.py +++ b/src/yasa/features.py @@ -17,15 +17,16 @@ Use at your own risk. """ -import mne -import yasa import logging + +import antropy as ant +import mne import numpy as np import pandas as pd -import antropy as ant import scipy.signal as sp_sig import scipy.stats as sp_stats +import yasa logger = logging.getLogger("yasa") diff --git a/yasa/heart.py b/src/yasa/heart.py similarity index 99% rename from yasa/heart.py rename to src/yasa/heart.py index fe7447e..2849e6a 100644 --- a/yasa/heart.py +++ b/src/yasa/heart.py @@ -6,12 +6,13 @@ """ import logging + import numpy as np import pandas as pd -from .hypno import hypno_find_periods from .detection import _check_data_hypno -from .io import set_log_level, is_sleepecg_installed +from .hypno import hypno_find_periods +from .io import is_sleepecg_installed, set_log_level logger = logging.getLogger("yasa") diff --git a/yasa/hypno.py b/src/yasa/hypno.py similarity index 88% rename from yasa/hypno.py rename to src/yasa/hypno.py index e782361..1d6c46d 100644 --- a/yasa/hypno.py +++ b/src/yasa/hypno.py @@ -2,17 +2,19 @@ Hypnogram-related functions and class. """ -import mne import logging +import mne + # import warnings import numpy as np import pandas as pd +from pandas.api.types import CategoricalDtype + +from yasa.evaluation import EpochByEpochAgreement from yasa.io import set_log_level from yasa.plotting import plot_hypnogram from yasa.sleepstats import transition_matrix -from yasa.evaluation import EpochByEpochAgreement -from pandas.api.types import CategoricalDtype __all__ = [ "Hypnogram", @@ -81,9 +83,27 @@ class Hypnogram: -------- Create a 2-stages hypnogram - >>> from yasa import Hypnogram - >>> values = ["W", "W", "W", "S", "S", "S", "S", "S", "W", "S", "S", "S"] - >>> hyp = Hypnogram(values, n_stages=2) + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> values = [ + ... "W", + ... "W", + ... "W", + ... "S", + ... "S", + ... "S", + ... "S", + ... "S", + ... "W", + ... "S", + ... "S", + ... "S", + ... ] + >>> hyp = Hypnogram( + ... values, + ... n_stages=2, + ... ) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -153,7 +173,9 @@ class Hypnogram: 'WAKE': 2.0} >>> # Get the state-transition matrix - >>> counts, probs = hyp.transition_matrix() + >>> counts, probs = ( + ... hyp.transition_matrix() + ... ) >>> counts To Stage WAKE SLEEP From Stage @@ -166,9 +188,16 @@ class Hypnogram: Lastly, we set an actual start time to the hypnogram. As a result, the index of the resulting hypnogram is a :py:class:`pandas.DatetimeIndex`. - >>> from yasa import simulate_hypnogram + >>> from yasa import ( + ... simulate_hypnogram, + ... ) >>> hyp = simulate_hypnogram( - ... tib=500, n_stages=5, start="2022-12-15 22:30:00", scorer="S1", seed=42) + ... tib=500, + ... n_stages=5, + ... start="2022-12-15 22:30:00", + ... scorer="S1", + ... seed=42, + ... ) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -412,8 +441,21 @@ def as_annotations(self): Examples -------- - >>> from yasa import Hypnogram - >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> hyp = Hypnogram( + ... [ + ... "W", + ... "W", + ... "LIGHT", + ... "LIGHT", + ... "DEEP", + ... "REM", + ... "WAKE", + ... ], + ... n_stages=4, + ... ) >>> hyp.as_annotations() onset duration value description epoch @@ -448,14 +490,30 @@ def as_int(self): Users can define a custom mapping: - >>> hyp.mapping = {"WAKE": 0, "NREM": 1, "REM": 2} + >>> hyp.mapping = { + ... "WAKE": 0, + ... "NREM": 1, + ... "REM": 2, + ... } Examples -------- Convert a 2-stages hypnogram to a pandas.Series of integers - >>> from yasa import Hypnogram - >>> hyp = Hypnogram(["W", "W", "S", "S", "W", "S"], n_stages=2) + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> hyp = Hypnogram( + ... [ + ... "W", + ... "W", + ... "S", + ... "S", + ... "W", + ... "S", + ... ], + ... n_stages=2, + ... ) >>> hyp.as_int() Epoch 0 0 @@ -468,8 +526,21 @@ def as_int(self): Same with a 4-stages hypnogram - >>> from yasa import Hypnogram - >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> hyp = Hypnogram( + ... [ + ... "W", + ... "W", + ... "LIGHT", + ... "LIGHT", + ... "DEEP", + ... "REM", + ... "WAKE", + ... ], + ... n_stages=4, + ... ) >>> hyp.as_int() Epoch 0 0 @@ -514,9 +585,25 @@ def consolidate_stages(self, new_n_stages): Examples -------- - >>> from yasa import Hypnogram - >>> hyp = Hypnogram(["W", "W", "N1", "N2", "N2", "N2", "N2", "W"], n_stages=5) - >>> hyp_2s = hyp.consolidate_stages(2) + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> hyp = Hypnogram( + ... [ + ... "W", + ... "W", + ... "N1", + ... "N2", + ... "N2", + ... "N2", + ... "N2", + ... "W", + ... ], + ... n_stages=5, + ... ) + >>> hyp_2s = hyp.consolidate_stages( + ... 2 + ... ) >>> print(hyp_2s) Epoch 0 WAKE @@ -593,11 +680,26 @@ def evaluate(self, obs_hyp): Examples -------- - >>> from yasa import simulate_hypnogram - >>> hyp_a = simulate_hypnogram(tib=90, scorer="AASM", seed=8) - >>> hyp_b = hyp_a.simulate_similar(scorer="YASA", seed=9) - >>> ebe = hyp_a.evaluate(hyp_b) - >>> ebe.get_agreement().round(3) + >>> from yasa import ( + ... simulate_hypnogram, + ... ) + >>> hyp_a = simulate_hypnogram( + ... tib=90, + ... scorer="AASM", + ... seed=8, + ... ) + >>> hyp_b = hyp_a.simulate_similar( + ... scorer="YASA", + ... seed=9, + ... ) + >>> ebe = ( + ... hyp_a.evaluate( + ... hyp_b + ... ) + ... ) + >>> ebe.get_agreement().round( + ... 3 + ... ) accuracy 0.550 balanced_acc 0.355 kappa 0.227 @@ -639,10 +741,24 @@ def find_periods(self, threshold="5min", equal_length=False): Let's assume that we have an hypnogram where sleep = 1 and wake = 0, with one value per minute. - >>> from yasa import Hypnogram - >>> val = 11 * ["W"] + 3 * ["S"] + 2 * ["W"] + 9 * ["S"] + ["W", "W"] - >>> hyp = Hypnogram(val, n_stages=2, freq="1min") - >>> hyp.find_periods(threshold="0min") + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> val = ( + ... 11 * ["W"] + ... + 3 * ["S"] + ... + 2 * ["W"] + ... + 9 * ["S"] + ... + ["W", "W"] + ... ) + >>> hyp = Hypnogram( + ... val, + ... n_stages=2, + ... freq="1min", + ... ) + >>> hyp.find_periods( + ... threshold="0min" + ... ) values start length 0 WAKE 0 11 1 SLEEP 11 3 @@ -657,7 +773,9 @@ def find_periods(self, threshold="5min", equal_length=False): Now, we may want to keep only periods that are longer than a specific threshold, for example 5 minutes: - >>> hyp.find_periods(threshold="5min") + >>> hyp.find_periods( + ... threshold="5min" + ... ) values start length 0 WAKE 0 11 1 SLEEP 16 9 @@ -668,9 +786,15 @@ def find_periods(self, threshold="5min", equal_length=False): This function is not limited to binary arrays, e.g. a 5-stages hypnogram at 30-sec resolution: - >>> from yasa import simulate_hypnogram - >>> hyp = simulate_hypnogram(tib=30, seed=42) - >>> hyp.find_periods(threshold="2min") + >>> from yasa import ( + ... simulate_hypnogram, + ... ) + >>> hyp = simulate_hypnogram( + ... tib=30, seed=42 + ... ) + >>> hyp.find_periods( + ... threshold="2min" + ... ) values start length 0 WAKE 0 5 1 N1 5 6 @@ -679,7 +803,10 @@ def find_periods(self, threshold="5min", equal_length=False): Lastly, using ``equal_length=True`` will further divide the periods into segments of the same duration, i.e. the duration defined in ``threshold``: - >>> hyp.find_periods(threshold="5min", equal_length=True) + >>> hyp.find_periods( + ... threshold="5min", + ... equal_length=True, + ... ) values start length 0 N2 11 10 1 N2 21 10 @@ -714,8 +841,14 @@ def plot_hypnogram(self, **kwargs): -------- .. plot:: - >>> from yasa import simulate_hypnogram - >>> ax = simulate_hypnogram(tib=480, seed=88).plot_hypnogram(highlight="REM") + >>> from yasa import ( + ... simulate_hypnogram, + ... ) + >>> ax = simulate_hypnogram( + ... tib=480, seed=88 + ... ).plot_hypnogram( + ... highlight="REM" + ... ) """ return plot_hypnogram(self, **kwargs) @@ -739,11 +872,26 @@ def simulate_similar(self, **kwargs): Examples -------- >>> import pandas as pd - >>> from yasa import Hypnogram + >>> from yasa import ( + ... Hypnogram, + ... ) >>> hyp = Hypnogram( - ... ["W", "S", "W"], n_stages=2, freq="2min", scorer="Human").upsample("30s") - >>> shyp = hyp.simulate_similar(scorer="Simulated", seed=6) - >>> df = pd.concat([hyp.hypno, shyp.hypno], axis=1) + ... ["W", "S", "W"], + ... n_stages=2, + ... freq="2min", + ... scorer="Human", + ... ).upsample("30s") + >>> shyp = hyp.simulate_similar( + ... scorer="Simulated", + ... seed=6, + ... ) + >>> df = pd.concat( + ... [ + ... hyp.hypno, + ... shyp.hypno, + ... ], + ... axis=1, + ... ) >>> print(df) Human Simulated Epoch @@ -832,10 +980,22 @@ def sleep_statistics(self): -------- Sleep statistics for a 2-stage hypnogram with a resolution of 15-seconds - >>> from yasa import Hypnogram + >>> from yasa import ( + ... Hypnogram, + ... ) >>> # Generate a fake hypnogram, where "S" = Sleep, "W" = Wake - >>> values = 10 * ["W"] + 40 * ["S"] + 5 * ["W"] + 40 * ["S"] + 9 * ["W"] - >>> hyp = Hypnogram(values, freq="15s", n_stages=2) + >>> values = ( + ... 10 * ["W"] + ... + 40 * ["S"] + ... + 5 * ["W"] + ... + 40 * ["S"] + ... + 9 * ["W"] + ... ) + >>> hyp = Hypnogram( + ... values, + ... freq="15s", + ... n_stages=2, + ... ) >>> hyp.sleep_statistics() {'TIB': 26.0, 'SPT': 21.25, @@ -850,9 +1010,13 @@ def sleep_statistics(self): Sleep statistics for a 5-stages hypnogram - >>> from yasa import simulate_hypnogram + >>> from yasa import ( + ... simulate_hypnogram, + ... ) >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution - >>> hyp = simulate_hypnogram(tib=480, seed=42) + >>> hyp = simulate_hypnogram( + ... tib=480, seed=42 + ... ) >>> hyp.sleep_statistics() {'TIB': 480.0, 'SPT': 477.5, @@ -981,10 +1145,17 @@ def transition_matrix(self): Examples -------- - >>> from yasa import Hypnogram, simulate_hypnogram + >>> from yasa import ( + ... Hypnogram, + ... simulate_hypnogram, + ... ) >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution - >>> hyp = simulate_hypnogram(tib=480, seed=42) - >>> counts, probs = hyp.transition_matrix() + >>> hyp = simulate_hypnogram( + ... tib=480, seed=42 + ... ) + >>> counts, probs = ( + ... hyp.transition_matrix() + ... ) >>> counts To Stage WAKE N1 N2 N3 REM From Stage @@ -1032,8 +1203,20 @@ def upsample(self, new_freq, **kwargs): -------- Create a 30-sec hypnogram - >>> from yasa import Hypnogram - >>> hyp = Hypnogram(["W", "W", "S", "S", "W"], n_stages=2, start="2022-12-23 23:00") + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> hyp = Hypnogram( + ... [ + ... "W", + ... "W", + ... "S", + ... "S", + ... "W", + ... ], + ... n_stages=2, + ... start="2022-12-23 23:00", + ... ) >>> hyp.hypno Time 2022-12-23 23:00:00 WAKE @@ -1046,7 +1229,11 @@ def upsample(self, new_freq, **kwargs): Upsample to a 15-seconds resolution - >>> hyp_up = hyp.upsample("15s") + >>> hyp_up = ( + ... hyp.upsample( + ... "15s" + ... ) + ... ) >>> hyp_up.hypno Time 2022-12-23 23:00:00 WAKE @@ -1479,8 +1666,40 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): minute, and therefore the sampling frequency of the hypnogram is 1 / 60 sec (~0.016 Hz). >>> import yasa - >>> hypno = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0] - >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="0min") + >>> hypno = [ + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 1, + ... 1, + ... 1, + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... ] + >>> yasa.hypno_find_periods( + ... hypno, + ... sf_hypno=1 / 60, + ... threshold="0min", + ... ) values start length 0 0 0 11 1 1 11 3 @@ -1495,7 +1714,11 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): Now, we may want to keep only periods that are longer than a specific threshold, for example 5 minutes: - >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="5min") + >>> yasa.hypno_find_periods( + ... hypno, + ... sf_hypno=1 / 60, + ... threshold="5min", + ... ) values start length 0 0 0 11 1 1 16 9 @@ -1505,8 +1728,30 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): This function is not limited to binary arrays, e.g. - >>> hypno = [0, 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 0, 0, 0, 1, 0, 1] - >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="2min") + >>> hypno = [ + ... 0, + ... 0, + ... 0, + ... 0, + ... 1, + ... 2, + ... 2, + ... 2, + ... 2, + ... 2, + ... 2, + ... 0, + ... 0, + ... 0, + ... 1, + ... 0, + ... 1, + ... ] + >>> yasa.hypno_find_periods( + ... hypno, + ... sf_hypno=1 / 60, + ... threshold="2min", + ... ) values start length 0 0 0 4 1 2 5 6 @@ -1515,8 +1760,31 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): Lastly, using ``equal_length=True`` will further divide the periods into segments of the same duration, i.e. the duration defined in ``threshold``: - >>> hypno = [0, 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 0, 0, 0, 1, 0, 1] - >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="2min", equal_length=True) + >>> hypno = [ + ... 0, + ... 0, + ... 0, + ... 0, + ... 1, + ... 2, + ... 2, + ... 2, + ... 2, + ... 2, + ... 2, + ... 0, + ... 0, + ... 0, + ... 1, + ... 0, + ... 1, + ... ] + >>> yasa.hypno_find_periods( + ... hypno, + ... sf_hypno=1 / 60, + ... threshold="2min", + ... equal_length=True, + ... ) values start length 0 0 0 2 1 0 2 2 @@ -1674,8 +1942,12 @@ def simulate_hypnogram( Examples -------- - >>> from yasa import simulate_hypnogram - >>> hyp = simulate_hypnogram(tib=5, seed=1) + >>> from yasa import ( + ... simulate_hypnogram, + ... ) + >>> hyp = simulate_hypnogram( + ... tib=5, seed=1 + ... ) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -1697,7 +1969,11 @@ def simulate_hypnogram( 9 N2 Name: Stage, dtype: object - >>> hyp = simulate_hypnogram(tib=5, n_stages=2, seed=1) + >>> hyp = simulate_hypnogram( + ... tib=5, + ... n_stages=2, + ... seed=1, + ... ) >>> hyp.hypno Epoch 0 WAKE @@ -1714,8 +1990,14 @@ def simulate_hypnogram( Add some Unscored epochs. - >>> hyp = simulate_hypnogram(tib=5, n_stages=2, seed=1) - >>> hyp.hypno.iloc[-2:] = "UNS" + >>> hyp = simulate_hypnogram( + ... tib=5, + ... n_stages=2, + ... seed=1, + ... ) + >>> hyp.hypno.iloc[ + ... -2: + ... ] = "UNS" >>> hyp.hypno Epoch 0 WAKE @@ -1736,17 +2018,44 @@ def simulate_hypnogram( >>> import numpy as np >>> import matplotlib.pyplot as plt - >>> from yasa import Hypnogram, hypno_int_to_str + >>> from yasa import ( + ... Hypnogram, + ... hypno_int_to_str, + ... ) >>> url = ( >>> "https://github.com/raphaelvallat/yasa/raw/master/" >>> "notebooks/data_full_6hrs_100Hz_hypno_30s.txt" >>> ) - >>> values_str = hypno_int_to_str(np.loadtxt(url)) - >>> real_hyp = Hypnogram(values_str) - >>> fake_hyp = real_hyp.simulate_similar(seed=2) - >>> fig, (ax1, ax2) = plt.subplots(nrows=2, figsize=(7, 5)) - >>> real_hyp.plot_hypnogram(ax=ax1).set_title("Real hypnogram") - >>> fake_hyp.plot_hypnogram(ax=ax2).set_title("Fake hypnogram") + >>> values_str = hypno_int_to_str( + ... np.loadtxt(url) + ... ) + >>> real_hyp = ( + ... Hypnogram( + ... values_str + ... ) + ... ) + >>> fake_hyp = real_hyp.simulate_similar( + ... seed=2 + ... ) + >>> fig, (ax1, ax2) = ( + ... plt.subplots( + ... nrows=2, + ... figsize=( + ... 7, + ... 5, + ... ), + ... ) + ... ) + >>> real_hyp.plot_hypnogram( + ... ax=ax1 + ... ).set_title( + ... "Real hypnogram" + ... ) + >>> fake_hyp.plot_hypnogram( + ... ax=ax2 + ... ).set_title( + ... "Fake hypnogram" + ... ) >>> plt.tight_layout() """ # Extract yasa.Hypnogram defaults, which will be assumed later but need throughout diff --git a/yasa/io.py b/src/yasa/io.py similarity index 97% rename from yasa/io.py rename to src/yasa/io.py index 1e0aaf5..9d0d2b0 100644 --- a/yasa/io.py +++ b/src/yasa/io.py @@ -1,9 +1,7 @@ -"""Helper functions for YASA (e.g. logger) -""" +"""Helper functions for YASA (e.g. logger)""" import logging - LOGGING_TYPES = dict( DEBUG=logging.DEBUG, INFO=logging.INFO, diff --git a/yasa/numba.py b/src/yasa/numba.py similarity index 100% rename from yasa/numba.py rename to src/yasa/numba.py diff --git a/yasa/others.py b/src/yasa/others.py similarity index 90% rename from yasa/others.py rename to src/yasa/others.py index af80928..92348fb 100644 --- a/yasa/others.py +++ b/src/yasa/others.py @@ -3,9 +3,11 @@ """ import logging + import numpy as np from scipy.interpolate import interp1d -from .numba import _slope_lstsq, _covar, _corr, _rms + +from .numba import _corr, _covar, _rms, _slope_lstsq logger = logging.getLogger("yasa") @@ -240,8 +242,22 @@ def _zerocrossings(x): Examples -------- >>> import numpy as np - >>> from yasa.main import _zerocrossings - >>> a = np.array([4, 2, -1, -3, 1, 2, 3, -2, -5]) + >>> from yasa.main import ( + ... _zerocrossings, + ... ) + >>> a = np.array( + ... [ + ... 4, + ... 2, + ... -1, + ... -3, + ... 1, + ... 2, + ... 3, + ... -2, + ... -5, + ... ] + ... ) >>> _zerocrossings(a) array([1, 3, 6], dtype=int64) """ @@ -323,9 +339,17 @@ def sliding_window(data, sf, window, step=None, axis=-1): With a 1-D array >>> import numpy as np - >>> from yasa import sliding_window + >>> from yasa import ( + ... sliding_window, + ... ) >>> data = np.arange(20) - >>> times, epochs = sliding_window(data, sf=1, window=5) + >>> times, epochs = ( + ... sliding_window( + ... data, + ... sf=1, + ... window=5, + ... ) + ... ) >>> times array([ 0., 5., 10., 15.]) @@ -335,7 +359,12 @@ def sliding_window(data, sf, window, step=None, axis=-1): [10, 11, 12, 13, 14], [15, 16, 17, 18, 19]]) - >>> sliding_window(data, sf=1, window=5, step=1)[1] + >>> sliding_window( + ... data, + ... sf=1, + ... window=5, + ... step=1, + ... )[1] array([[ 0, 1, 2, 3, 4], [ 2, 3, 4, 5, 6], [ 4, 5, 6, 7, 8], @@ -345,15 +374,29 @@ def sliding_window(data, sf, window, step=None, axis=-1): [12, 13, 14, 15, 16], [14, 15, 16, 17, 18]]) - >>> sliding_window(data, sf=1, window=11)[1] + >>> sliding_window( + ... data, + ... sf=1, + ... window=11, + ... )[1] array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) With a N-D array >>> np.random.seed(42) >>> # 4 channels x 20 samples - >>> data = np.random.randint(-100, 100, size=(4, 20)) - >>> epochs = sliding_window(data, sf=1, window=10)[1] + >>> data = np.random.randint( + ... -100, + ... 100, + ... size=(4, 20), + ... ) + >>> epochs = ( + ... sliding_window( + ... data, + ... sf=1, + ... window=10, + ... )[1] + ... ) >>> epochs.shape # shape (n_epochs, n_channels, n_samples) (2, 4, 10) @@ -437,12 +480,31 @@ def get_centered_indices(data, idx, npts_before, npts_after): Examples -------- >>> import numpy as np - >>> from yasa import get_centered_indices + >>> from yasa import ( + ... get_centered_indices, + ... ) >>> np.random.seed(123) - >>> data = np.random.normal(size=100).round(2) - >>> idx = [1., 10., 20., 30., 50., 102] + >>> data = np.random.normal( + ... size=100 + ... ).round(2) + >>> idx = [ + ... 1.0, + ... 10.0, + ... 20.0, + ... 30.0, + ... 50.0, + ... 102, + ... ] >>> before, after = 3, 2 - >>> idx_ep, idx_nomask = get_centered_indices(data, idx, before, after) + >>> ( + ... idx_ep, + ... idx_nomask, + ... ) = get_centered_indices( + ... data, + ... idx, + ... before, + ... after, + ... ) >>> idx_ep array([[ 7, 8, 9, 10, 11, 12], [17, 18, 19, 20, 21, 22], diff --git a/yasa/plotting.py b/src/yasa/plotting.py similarity index 79% rename from yasa/plotting.py rename to src/yasa/plotting.py index 70fab24..f331a23 100644 --- a/yasa/plotting.py +++ b/src/yasa/plotting.py @@ -2,14 +2,14 @@ Plotting functions of YASA. """ +import matplotlib.dates as mdates +import matplotlib.pyplot as plt import mne import numpy as np import pandas as pd import seaborn as sns -import matplotlib.pyplot as plt -import matplotlib.dates as mdates from lspopt import spectrogram_lspopt -from matplotlib.colors import Normalize, ListedColormap +from matplotlib.colors import ListedColormap, Normalize __all__ = ["plot_hypnogram", "plot_spectrogram", "topoplot"] @@ -58,29 +58,81 @@ def plot_hypnogram(hyp, sf_hypno=1 / 30, highlight="REM", fill_color=None, ax=No -------- .. plot:: - >>> from yasa import simulate_hypnogram + >>> from yasa import ( + ... simulate_hypnogram, + ... ) >>> import matplotlib.pyplot as plt - >>> hyp = simulate_hypnogram(tib=300, seed=11) + >>> hyp = simulate_hypnogram( + ... tib=300, seed=11 + ... ) >>> ax = hyp.plot_hypnogram() >>> plt.tight_layout() .. plot:: - >>> from yasa import Hypnogram - >>> values = 4 * ["W", "N1", "N2", "N3", "REM"] + ["ART", "N2", "REM", "W", "UNS"] - >>> hyp = Hypnogram(values, freq="24min").upsample("30s") - >>> ax = hyp.plot_hypnogram(lw=2, fill_color="thistle") + >>> from yasa import ( + ... Hypnogram, + ... ) + >>> values = 4 * [ + ... "W", + ... "N1", + ... "N2", + ... "N3", + ... "REM", + ... ] + [ + ... "ART", + ... "N2", + ... "REM", + ... "W", + ... "UNS", + ... ] + >>> hyp = Hypnogram( + ... values, + ... freq="24min", + ... ).upsample("30s") + >>> ax = hyp.plot_hypnogram( + ... lw=2, + ... fill_color="thistle", + ... ) >>> plt.tight_layout() .. plot:: - >>> from yasa import simulate_hypnogram + >>> from yasa import ( + ... simulate_hypnogram, + ... ) >>> import matplotlib.pyplot as plt - >>> fig, axes = plt.subplots(nrows=2, figsize=(6, 4), constrained_layout=True) - >>> hyp_a = simulate_hypnogram(n_stages=3, seed=99) - >>> hyp_b = simulate_hypnogram(n_stages=3, seed=99, start="2022-01-31 23:30:00") - >>> hyp_a.plot_hypnogram(lw=1, fill_color="whitesmoke", highlight=None, ax=axes[0]) - >>> hyp_b.plot_hypnogram(lw=1, fill_color="whitesmoke", highlight=None, ax=axes[1]) + >>> fig, axes = ( + ... plt.subplots( + ... nrows=2, + ... figsize=( + ... 6, + ... 4, + ... ), + ... constrained_layout=True, + ... ) + ... ) + >>> hyp_a = simulate_hypnogram( + ... n_stages=3, + ... seed=99, + ... ) + >>> hyp_b = simulate_hypnogram( + ... n_stages=3, + ... seed=99, + ... start="2022-01-31 23:30:00", + ... ) + >>> hyp_a.plot_hypnogram( + ... lw=1, + ... fill_color="whitesmoke", + ... highlight=None, + ... ax=axes[0], + ... ) + >>> hyp_b.plot_hypnogram( + ... lw=1, + ... fill_color="whitesmoke", + ... highlight=None, + ... ax=axes[1], + ... ) """ from yasa.hypno import Hypnogram, hypno_int_to_str # Avoiding circular imports @@ -247,12 +299,25 @@ def plot_spectrogram( >>> import numpy as np >>> # In the next 5 lines, we're loading the data from GitHub. >>> import requests - >>> from io import BytesIO - >>> r = requests.get('https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz', stream=True) - >>> npz = np.load(BytesIO(r.raw.read())) - >>> data = npz.get('data')[0, :] + >>> from io import ( + ... BytesIO, + ... ) + >>> r = requests.get( + ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", + ... stream=True, + ... ) + >>> npz = np.load( + ... BytesIO( + ... r.raw.read() + ... ) + ... ) + >>> data = npz.get( + ... "data" + ... )[0, :] >>> sf = 100 - >>> fig = yasa.plot_spectrogram(data, sf) + >>> fig = yasa.plot_spectrogram( + ... data, sf + ... ) 2. Full-night multitaper spectrogram on Cz with the hypnogram on top @@ -262,15 +327,38 @@ def plot_spectrogram( >>> import numpy as np >>> # In the next lines, we're loading the data from GitHub. >>> import requests - >>> from io import BytesIO - >>> r = requests.get('https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz', stream=True) - >>> npz = np.load(BytesIO(r.raw.read())) - >>> data = npz.get('data')[0, :] + >>> from io import ( + ... BytesIO, + ... ) + >>> r = requests.get( + ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", + ... stream=True, + ... ) + >>> npz = np.load( + ... BytesIO( + ... r.raw.read() + ... ) + ... ) + >>> data = npz.get( + ... "data" + ... )[0, :] >>> sf = 100 >>> # Load the 30-sec hypnogram and upsample to data - >>> hypno = np.loadtxt('https://raw.githubusercontent.com/raphaelvallat/yasa/master/notebooks/data_full_6hrs_100Hz_hypno_30s.txt') - >>> hypno = yasa.hypno_upsample_to_data(hypno, 1/30, data, sf) - >>> fig = yasa.plot_spectrogram(data, sf, hypno, cmap='Spectral_r') + >>> hypno = np.loadtxt( + ... "https://raw.githubusercontent.com/raphaelvallat/yasa/master/notebooks/data_full_6hrs_100Hz_hypno_30s.txt" + ... ) + >>> hypno = yasa.hypno_upsample_to_data( + ... hypno, + ... 1 / 30, + ... data, + ... sf, + ... ) + >>> fig = yasa.plot_spectrogram( + ... data, + ... sf, + ... hypno, + ... cmap="Spectral_r", + ... ) """ from yasa.hypno import Hypnogram, hypno_int_to_str # Avoiding circular imports @@ -423,10 +511,31 @@ def topoplot( >>> import yasa >>> import pandas as pd - >>> data = pd.Series([4, 8, 7, 1, 2, 3, 5], - ... index=['F4', 'F3', 'C4', 'C3', 'P3', 'P4', 'Oz'], - ... name='Values') - >>> fig = yasa.topoplot(data, title='My first topoplot') + >>> data = pd.Series( + ... [ + ... 4, + ... 8, + ... 7, + ... 1, + ... 2, + ... 3, + ... 5, + ... ], + ... index=[ + ... "F4", + ... "F3", + ... "C4", + ... "C3", + ... "P3", + ... "P4", + ... "Oz", + ... ], + ... name="Values", + ... ) + >>> fig = yasa.topoplot( + ... data, + ... title="My first topoplot", + ... ) 2. Plot correlation coefficients (values ranging from -1 to 1) @@ -434,10 +543,33 @@ def topoplot( >>> import yasa >>> import pandas as pd - >>> data = pd.Series([-0.5, -0.7, -0.3, 0.1, 0.15, 0.3, 0.55], - ... index=['F3', 'Fz', 'F4', 'C3', 'Cz', 'C4', 'Pz']) - >>> fig = yasa.topoplot(data, vmin=-1, vmax=1, n_colors=8, - ... cbar_title="Pearson correlation") + >>> data = pd.Series( + ... [ + ... -0.5, + ... -0.7, + ... -0.3, + ... 0.1, + ... 0.15, + ... 0.3, + ... 0.55, + ... ], + ... index=[ + ... "F3", + ... "Fz", + ... "F4", + ... "C3", + ... "Cz", + ... "C4", + ... "Pz", + ... ], + ... ) + >>> fig = yasa.topoplot( + ... data, + ... vmin=-1, + ... vmax=1, + ... n_colors=8, + ... cbar_title="Pearson correlation", + ... ) """ # Increase font size while preserving original old_fontsize = plt.rcParams["font.size"] diff --git a/yasa/push_pypi.md b/src/yasa/push_pypi.md similarity index 100% rename from yasa/push_pypi.md rename to src/yasa/push_pypi.md diff --git a/yasa/sleepstats.py b/src/yasa/sleepstats.py similarity index 77% rename from yasa/sleepstats.py rename to src/yasa/sleepstats.py index 99643cf..b841c4f 100644 --- a/yasa/sleepstats.py +++ b/src/yasa/sleepstats.py @@ -49,9 +49,38 @@ def transition_matrix(hypno): Examples -------- >>> import numpy as np - >>> from yasa import transition_matrix - >>> a = [0, 0, 0, 1, 1, 0, 1, 2, 2, 3, 3, 2, 3, 3, 0, 2, 2, 1, 2, 2, 3, 3] - >>> counts, probs = transition_matrix(a) + >>> from yasa import ( + ... transition_matrix, + ... ) + >>> a = [ + ... 0, + ... 0, + ... 0, + ... 1, + ... 1, + ... 0, + ... 1, + ... 2, + ... 2, + ... 3, + ... 3, + ... 2, + ... 3, + ... 3, + ... 0, + ... 2, + ... 2, + ... 1, + ... 2, + ... 2, + ... 3, + ... 3, + ... ] + >>> counts, probs = ( + ... transition_matrix( + ... a + ... ) + ... ) >>> counts 0 1 2 3 Stage @@ -73,7 +102,11 @@ def transition_matrix(hypno): calculated by taking the average of the diagonal values (excluding Wake and N1 sleep): - >>> np.diag(probs.loc[2:, 2:]).mean().round(3) + >>> np.diag( + ... probs.loc[ + ... 2:, 2: + ... ] + ... ).mean().round(3) 0.514 Finally, we can plot the transition matrix using :py:func:`seaborn.heatmap` @@ -83,22 +116,76 @@ def transition_matrix(hypno): >>> import numpy as np >>> import seaborn as sns >>> import matplotlib.pyplot as plt - >>> from yasa import transition_matrix + >>> from yasa import ( + ... transition_matrix, + ... ) >>> # Calculate probability matrix - >>> a = [1, 1, 1, 0, 0, 2, 2, 0, 2, 0, 1, 1, 0, 0] - >>> _, probs = transition_matrix(a) + >>> a = [ + ... 1, + ... 1, + ... 1, + ... 0, + ... 0, + ... 2, + ... 2, + ... 0, + ... 2, + ... 0, + ... 1, + ... 1, + ... 0, + ... 0, + ... ] + >>> _, probs = ( + ... transition_matrix( + ... a + ... ) + ... ) >>> # Start the plot - >>> grid_kws = {"height_ratios": (.9, .05), "hspace": .1} - >>> f, (ax, cbar_ax) = plt.subplots(2, gridspec_kw=grid_kws, - ... figsize=(5, 5)) - >>> sns.heatmap(probs, ax=ax, square=False, vmin=0, vmax=1, cbar=True, - ... cbar_ax=cbar_ax, cmap='YlOrRd', annot=True, fmt='.2f', - ... cbar_kws={"orientation": "horizontal", "fraction": 0.1, - ... "label": "Transition probability"}) - >>> ax.set_xlabel("To sleep stage") + >>> grid_kws = { + ... "height_ratios": ( + ... 0.9, + ... 0.05, + ... ), + ... "hspace": 0.1, + ... } + >>> f, (ax, cbar_ax) = ( + ... plt.subplots( + ... 2, + ... gridspec_kw=grid_kws, + ... figsize=( + ... 5, + ... 5, + ... ), + ... ) + ... ) + >>> sns.heatmap( + ... probs, + ... ax=ax, + ... square=False, + ... vmin=0, + ... vmax=1, + ... cbar=True, + ... cbar_ax=cbar_ax, + ... cmap="YlOrRd", + ... annot=True, + ... fmt=".2f", + ... cbar_kws={ + ... "orientation": "horizontal", + ... "fraction": 0.1, + ... "label": "Transition probability", + ... }, + ... ) + >>> ax.set_xlabel( + ... "To sleep stage" + ... ) >>> ax.xaxis.tick_top() - >>> ax.set_ylabel("From sleep stage") - >>> ax.xaxis.set_label_position('top') + >>> ax.set_ylabel( + ... "From sleep stage" + ... ) + >>> ax.xaxis.set_label_position( + ... "top" + ... ) """ # NOTE: FutureWarning not added here otherwise it would also be shown when calling # yasa.Hypnogram.transition_matrix @@ -201,10 +288,36 @@ def sleep_statistics(hypno, sf_hyp): Examples -------- - >>> from yasa import sleep_statistics - >>> hypno = [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 2, 3, 3, 4, 4, 4, 4, 0, 0] + >>> from yasa import ( + ... sleep_statistics, + ... ) + >>> hypno = [ + ... 0, + ... 0, + ... 1, + ... 1, + ... 1, + ... 2, + ... 2, + ... 2, + ... 3, + ... 3, + ... 3, + ... 2, + ... 3, + ... 3, + ... 4, + ... 4, + ... 4, + ... 4, + ... 0, + ... 0, + ... ] >>> # Assuming that we have one-value per 30-second. - >>> sleep_statistics(hypno, sf_hyp=1/30) + >>> sleep_statistics( + ... hypno, + ... sf_hyp=1 / 30, + ... ) {'TIB': 10.0, 'SPT': 8.0, 'WASO': 0.0, diff --git a/yasa/spectral.py b/src/yasa/spectral.py similarity index 99% rename from yasa/spectral.py rename to src/yasa/spectral.py index 8c988a0..3bf5723 100644 --- a/yasa/spectral.py +++ b/src/yasa/spectral.py @@ -3,13 +3,15 @@ 1D and 2D EEG data. """ -import mne import logging + +import mne import numpy as np import pandas as pd from scipy import signal from scipy.integrate import simpson from scipy.interpolate import RectBivariateSpline + from .io import set_log_level logger = logging.getLogger("yasa") diff --git a/yasa/staging.py b/src/yasa/staging.py similarity index 97% rename from yasa/staging.py rename to src/yasa/staging.py index a406af6..8a3fd7e 100644 --- a/yasa/staging.py +++ b/src/yasa/staging.py @@ -1,18 +1,19 @@ """Automatic sleep staging of polysomnography data.""" -import os -import mne import glob -import joblib import logging +import os + +import antropy as ant +import joblib +import matplotlib.pyplot as plt +import mne import numpy as np import pandas as pd -import antropy as ant -from scipy.integrate import trapezoid import scipy.signal as sp_sig import scipy.stats as sp_stats -import matplotlib.pyplot as plt from mne.filter import filter_data +from scipy.integrate import trapezoid from sklearn.preprocessing import robust_scale from .others import sliding_window @@ -140,17 +141,33 @@ class SleepStaging: >>> import mne >>> import yasa >>> # Load an EDF file using MNE - >>> raw = mne.io.read_raw_edf("myfile.edf", preload=True) + >>> raw = mne.io.read_raw_edf( + ... "myfile.edf", + ... preload=True, + ... ) >>> # Initialize the sleep staging instance - >>> sls = yasa.SleepStaging(raw, eeg_name="C4-M1", eog_name="LOC-M2", - ... emg_name="EMG1-EMG2", - ... metadata=dict(age=29, male=True)) + >>> sls = yasa.SleepStaging( + ... raw, + ... eeg_name="C4-M1", + ... eog_name="LOC-M2", + ... emg_name="EMG1-EMG2", + ... metadata=dict( + ... age=29, + ... male=True, + ... ), + ... ) >>> # Get the predicted sleep stages - >>> hypno = sls.predict() + >>> hypno = ( + ... sls.predict() + ... ) >>> # Get the predicted probabilities >>> proba = sls.predict_proba() >>> # Get the confidence - >>> confidence = proba.max(axis=1) + >>> confidence = ( + ... proba.max( + ... axis=1 + ... ) + ... ) >>> # Plot the predicted probabilities >>> sls.plot_predict_proba() diff --git a/yasa/tests/__init__.py b/tests/__init__.py similarity index 100% rename from yasa/tests/__init__.py rename to tests/__init__.py diff --git a/yasa/tests/test_detection.py b/tests/test_detection.py similarity index 99% rename from yasa/tests/test_detection.py rename to tests/test_detection.py index a9b893e..19f8f47 100644 --- a/yasa/tests/test_detection.py +++ b/tests/test_detection.py @@ -1,15 +1,17 @@ """Test the functions in yasa/spectral.py.""" -import mne -import pytest import unittest -import numpy as np -import pandas as pd from itertools import product + import matplotlib.pyplot as plt +import mne +import numpy as np +import pandas as pd +import pytest from mne.filter import filter_data + +from yasa.detection import art_detect, compare_detection, rem_detect, spindles_detect, sw_detect from yasa.hypno import hypno_str_to_int, hypno_upsample_to_data -from yasa.detection import spindles_detect, sw_detect, rem_detect, art_detect, compare_detection ############################################################################## # DATA LOADING diff --git a/yasa/tests/test_heart.py b/tests/test_heart.py similarity index 99% rename from yasa/tests/test_heart.py rename to tests/test_heart.py index 0f3a383..0aa8dd0 100644 --- a/yasa/tests/test_heart.py +++ b/tests/test_heart.py @@ -1,7 +1,9 @@ """Test the functions in the yasa/heart.py file.""" import unittest + import numpy as np + from yasa.heart import hrv_stage # Load data diff --git a/yasa/tests/test_hypno.py b/tests/test_hypno.py similarity index 99% rename from yasa/tests/test_hypno.py rename to tests/test_hypno.py index bb7cb12..7c5ee98 100644 --- a/yasa/tests/test_hypno.py +++ b/tests/test_hypno.py @@ -1,22 +1,23 @@ """Test the functions in the yasa/hypno.py file.""" -import mne -import pytest import unittest + +import mne import numpy as np import pandas as pd +import pytest from pandas.testing import assert_frame_equal + +from yasa.hypno import hypno_find_periods as hfp from yasa.hypno import ( - hypno_str_to_int, - hypno_int_to_str, - hypno_upsample_to_sf, hypno_fit_to_data, + hypno_int_to_str, + hypno_str_to_int, hypno_upsample_to_data, + hypno_upsample_to_sf, simulate_hypnogram, ) -from yasa.hypno import hypno_find_periods as hfp - hypno = np.array([0, 0, 0, 1, 2, 2, 3, 3, 4]) hypno_txt = np.array(["W", "W", "W", "N1", "N2", "N2", "N3", "N3", "R"]) diff --git a/yasa/tests/test_hypnoclass.py b/tests/test_hypnoclass.py similarity index 99% rename from yasa/tests/test_hypnoclass.py rename to tests/test_hypnoclass.py index 7adf4fd..ed6eeb3 100644 --- a/yasa/tests/test_hypnoclass.py +++ b/tests/test_hypnoclass.py @@ -1,12 +1,14 @@ """Test the class Hypnogram.""" -import mne -import pytest import unittest + +import matplotlib.pyplot as plt +import mne import numpy as np import pandas as pd -import matplotlib.pyplot as plt -from yasa.hypno import simulate_hypnogram, Hypnogram, hypno_str_to_int +import pytest + +from yasa.hypno import Hypnogram, hypno_str_to_int, simulate_hypnogram def create_raw(npts, ch_names=["F4-M1", "F3-M2"], sf=100): diff --git a/yasa/tests/test_io.py b/tests/test_io.py similarity index 99% rename from yasa/tests/test_io.py rename to tests/test_io.py index 486a598..92aec7e 100644 --- a/yasa/tests/test_io.py +++ b/tests/test_io.py @@ -1,13 +1,15 @@ """Test I/O.""" -import pytest import logging import unittest + +import pytest + from yasa.io import ( + is_pyriemann_installed, is_sleepecg_installed, - set_log_level, is_tensorpac_installed, - is_pyriemann_installed, + set_log_level, ) logger = logging.getLogger("yasa") diff --git a/yasa/tests/test_numba.py b/tests/test_numba.py similarity index 94% rename from yasa/tests/test_numba.py rename to tests/test_numba.py index b9533a8..24bec24 100644 --- a/yasa/tests/test_numba.py +++ b/tests/test_numba.py @@ -1,9 +1,11 @@ """Test the functions in the yasa/numba.py file.""" import unittest + import numpy as np from scipy.signal import detrend -from yasa.numba import _corr, _covar, _rms, _slope_lstsq, _detrend + +from yasa.numba import _corr, _covar, _detrend, _rms, _slope_lstsq class TestNumba(unittest.TestCase): diff --git a/yasa/tests/test_others.py b/tests/test_others.py similarity index 99% rename from yasa/tests/test_others.py rename to tests/test_others.py index a5d5830..11b426e 100644 --- a/yasa/tests/test_others.py +++ b/tests/test_others.py @@ -1,20 +1,21 @@ """Test the functions in the yasa/others.py file.""" -import mne import unittest -import numpy as np from itertools import product + +import mne +import numpy as np from mne.filter import filter_data from yasa.hypno import hypno_str_to_int, hypno_upsample_to_data from yasa.others import ( - moving_transform, - trimbothstd, - get_centered_indices, - sliding_window, + _index_to_events, _merge_close, _zerocrossings, - _index_to_events, + get_centered_indices, + moving_transform, + sliding_window, + trimbothstd, ) # Load data diff --git a/yasa/tests/test_plotting.py b/tests/test_plotting.py similarity index 98% rename from yasa/tests/test_plotting.py rename to tests/test_plotting.py index 81c8532..57c327f 100644 --- a/yasa/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,11 +1,13 @@ """Test the functions in the yasa/plotting.py file.""" import unittest + +import matplotlib.pyplot as plt import numpy as np import pandas as pd -import matplotlib.pyplot as plt + from yasa.hypno import simulate_hypnogram -from yasa.plotting import topoplot, plot_hypnogram +from yasa.plotting import plot_hypnogram, topoplot class TestPlotting(unittest.TestCase): diff --git a/yasa/tests/test_sleepstats.py b/tests/test_sleepstats.py similarity index 97% rename from yasa/tests/test_sleepstats.py rename to tests/test_sleepstats.py index cda5373..812c57e 100644 --- a/yasa/tests/test_sleepstats.py +++ b/tests/test_sleepstats.py @@ -1,9 +1,11 @@ """Test the functions in the yasa/sleepstats.py file.""" import unittest + import numpy as np import pandas as pd -from yasa.sleepstats import transition_matrix, sleep_statistics + +from yasa.sleepstats import sleep_statistics, transition_matrix hypno = np.array([0, 0, 0, 1, 2, 2, 3, 3, 2, 2, 2, 0, 0, 0, 2, 2, 4, 4, 0, 0]) diff --git a/yasa/tests/test_spectral.py b/tests/test_spectral.py similarity index 99% rename from yasa/tests/test_spectral.py rename to tests/test_spectral.py index 458b300..11082ba 100644 --- a/yasa/tests/test_spectral.py +++ b/tests/test_spectral.py @@ -1,15 +1,16 @@ """Test the functions in the yasa/spectral.py file.""" -import mne -import pytest import unittest -import numpy as np from itertools import product -from scipy.signal import welch + import matplotlib.pyplot as plt +import mne +import numpy as np +import pytest +from scipy.signal import welch -from yasa.plotting import plot_spectrogram from yasa.hypno import hypno_str_to_int, hypno_upsample_to_data +from yasa.plotting import plot_spectrogram from yasa.spectral import ( bandpower, bandpower_from_psd, diff --git a/yasa/tests/test_staging.py b/tests/test_staging.py similarity index 99% rename from yasa/tests/test_staging.py rename to tests/test_staging.py index 8a07629..6796a7f 100644 --- a/yasa/tests/test_staging.py +++ b/tests/test_staging.py @@ -1,9 +1,11 @@ """Test the functions in yasa/staging.py.""" -import mne import unittest -import numpy as np + import matplotlib.pyplot as plt +import mne +import numpy as np + from yasa.staging import SleepStaging ############################################################################## From 5a56bf34103502c3a31f2afab8d96a10191775d7 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Fri, 20 Dec 2024 21:06:33 +0100 Subject: [PATCH 03/19] Better pyproject + add tests for 3.12 --- .github/workflows/python_tests.yml | 4 ++-- README.rst | 10 +++++++--- docs/conf.py | 4 +++- docs/index.rst | 12 ++++++++++++ pyproject.toml | 28 ++++++++++++++++------------ 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index 69b2679..64bc076 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] # macos-latest - python-version: ["3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.platform }} @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install .[test] + pip install ."[test]" - name: Test with pytest run: | diff --git a/README.rst b/README.rst index 015f330..ff48be8 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,10 @@ .. image:: https://pepy.tech/badge/yasa :target: https://pepy.tech/badge/yasa +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff + ---------------- .. figure:: /docs/pictures/yasa_logo.png @@ -52,9 +56,9 @@ To build and install from source, clone this repository or download the source a .. code-block:: shell cd yasa - pip install .[test] # install the package - pip install --editable .[test] # or editable install - pytest # test the package + pip install ."[test]" # install the package + pip install --editable ."[test]" # or editable install + pytest # test the package **What are the prerequisites for using YASA?** diff --git a/docs/conf.py b/docs/conf.py index d622f17..ccd887d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,9 +3,11 @@ import os import sys import time -import yasa + import sphinx_bootstrap_theme +import yasa + # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. diff --git a/docs/index.rst b/docs/index.rst index 8499106..059d689 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,6 +14,10 @@ .. image:: https://pepy.tech/badge/yasa :target: https://pepy.tech/badge/yasa +.. image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json + :target: https://github.com/astral-sh/ruff + :alt: Ruff + ---------------- .. figure:: /pictures/yasa_logo.png @@ -48,6 +52,14 @@ Alternatively, YASA can be installed with conda: conda config --set channel_priority strict conda install yasa +To build and install from source, clone this repository or download the source archive and decompress the files + +.. code-block:: shell + cd yasa + pip install ."[test]" # install the package + pip install -e ."[test]" # or editable install + pytest # test the package + **What are the prerequisites for using YASA?** To use YASA, all you need is: diff --git a/pyproject.toml b/pyproject.toml index dac517c..a8b1872 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", ] dynamic = ["version"] -requires-python = ">=3.8" +requires-python = ">=3.9" dependencies = [ "numpy>=1.18.1", "scipy", @@ -78,7 +78,7 @@ yasa = [ [tool.setuptools.packages.find] namespaces = false -where = ["yasa"] +where = ["src"] [tool.setuptools.dynamic] version = {attr = "yasa.__version__"} @@ -102,7 +102,7 @@ omit = [ source_pkgs = ["yasa"] [tool.coverage.paths] -source = ["yasa"] +source = ["src"] [tool.coverage.report] show_missing = true @@ -111,16 +111,20 @@ show_missing = true [tool.ruff] line-length = 100 target-version = "py311" +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +lint.select = ["E4", "E7", "E9", "F", "I", "NPY201"] exclude = [ - "__init__.py", # Skip init files bc they use star imports (breaking rules F403, F405) "notebooks", # Skip jupyter notebook examples ] -[tool.ruff.lint] -select = [ - "E4", # Subset of pycodestyle rules - "E7", # Subset of pycodestyle rules - "E9", # Subset of pycodestyle rules - "F", # All Pyflakes rules - "NPY201", -] +[tool.ruff.lint.per-file-ignores] +"__init__.py" = ["F401", "F403"] # Ignore star and unused import violations for __init__.py files + +[tool.ruff.lint.pydocstyle] +convention = "numpy" + +[tool.ruff.format] +docstring-code-format = true +docstring-code-line-length = 20 From d16a16905b21dd2ec1068ad2b7af5648168cbf65 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Fri, 20 Dec 2024 21:13:27 +0100 Subject: [PATCH 04/19] Fix install Windows --- .github/workflows/python_tests.yml | 2 +- README.rst | 4 ++-- docs/index.rst | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index 64bc076..2a7e534 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ."[test]" + pip install ".[test]" - name: Test with pytest run: | diff --git a/README.rst b/README.rst index ff48be8..7a07c31 100644 --- a/README.rst +++ b/README.rst @@ -56,8 +56,8 @@ To build and install from source, clone this repository or download the source a .. code-block:: shell cd yasa - pip install ."[test]" # install the package - pip install --editable ."[test]" # or editable install + pip install ".[test]" # install the package + pip install --editable ".[test]" # or editable install pytest # test the package **What are the prerequisites for using YASA?** diff --git a/docs/index.rst b/docs/index.rst index 059d689..6e7d00b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -56,8 +56,8 @@ To build and install from source, clone this repository or download the source a .. code-block:: shell cd yasa - pip install ."[test]" # install the package - pip install -e ."[test]" # or editable install + pip install ".[test]" # install the package + pip install -e ".[test]" # or editable install pytest # test the package **What are the prerequisites for using YASA?** From 5579bb3e167b986e617e1846fe9564b2d0e6f3eb Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Fri, 20 Dec 2024 21:26:16 +0100 Subject: [PATCH 05/19] fix ruff for some docstring --- pyproject.toml | 2 +- src/yasa/detection.py | 52 +++-------- src/yasa/evaluation.py | 200 ++++++++++++----------------------------- src/yasa/hypno.py | 122 ++++++------------------- src/yasa/others.py | 26 +++--- src/yasa/plotting.py | 44 +++------ src/yasa/sleepstats.py | 46 +++------- src/yasa/staging.py | 10 +-- 8 files changed, 137 insertions(+), 365 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a8b1872..9f8251a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -127,4 +127,4 @@ convention = "numpy" [tool.ruff.format] docstring-code-format = true -docstring-code-line-length = 20 +docstring-code-line-length = 90 diff --git a/src/yasa/detection.py b/src/yasa/detection.py index 347b4a8..a8aaa68 100644 --- a/src/yasa/detection.py +++ b/src/yasa/detection.py @@ -691,11 +691,7 @@ def spindles_detect( sp : :py:class:`yasa.SpindlesResults` To get the full detection dataframe, use: - >>> sp = ( - ... spindles_detect( - ... ... - ... ) - ... ) + >>> sp = spindles_detect(...) >>> sp.summary() This will give a :py:class:`pandas.DataFrame` where each row is a @@ -1188,15 +1184,10 @@ def get_coincidence_matrix(self, scaled=True): >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) - >>> ( - ... x * y - ... ).sum() # Unscaled coincidence + >>> (x * y).sum() # Unscaled coincidence 3 - >>> (x * y).sum() / ( - ... x.sum() - ... * y.sum() - ... ) # Scaled coincidence + >>> (x * y).sum() / (x.sum() * y.sum()) # Scaled coincidence 0.12 References @@ -1543,16 +1534,8 @@ def sw_detect( import pingouin as pg - mean_direction = pg.circ_mean( - sw[ - "PhaseAtSigmaPeak" - ] - ) - vector_length = pg.circ_r( - sw[ - "PhaseAtSigmaPeak" - ] - ) + mean_direction = pg.circ_mean(sw["PhaseAtSigmaPeak"]) + vector_length = pg.circ_r(sw["PhaseAtSigmaPeak"]) 3. ``ndPAC``: the normalized Mean Vector Length (also called the normalized direct PAC, or ndPAC) within a 2-sec epoch centered around the negative peak of the slow-wave. @@ -2256,15 +2239,10 @@ def get_coincidence_matrix(self, scaled=True): >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) - >>> ( - ... x * y - ... ).sum() # Coincidence + >>> (x * y).sum() # Coincidence 3 - >>> (x * y).sum() / ( - ... x.sum() - ... * y.sum() - ... ) # Scaled coincidence + >>> (x * y).sum() / (x.sum() * y.sum()) # Scaled coincidence 0.12 References @@ -2438,9 +2416,7 @@ def rem_detect( Therefore, if passing data from a :py:class:`mne.io.BaseRaw`, make sure to use units="uV" to get the data in micro-Volts, e.g.: - >>> data = raw.get_data( - ... units="uV" - ... ) # Make sure that data is in uV + >>> data = raw.get_data(units="uV") # Make sure that data is in uV sf : float Sampling frequency of the data, in Hz. hypno : array_like @@ -2497,18 +2473,14 @@ def rem_detect( rem : :py:class:`yasa.REMResults` To get the full detection dataframe, use: - >>> rem = rem_detect( - ... ... - ... ) + >>> rem = rem_detect(...) >>> rem.summary() This will give a :py:class:`pandas.DataFrame` where each row is a detected REM and each column is a parameter (= property). To get the average parameters sleep stage: - >>> rem.summary( - ... grp_stage=True - ... ) + >>> rem.summary(grp_stage=True) Notes ----- @@ -3400,9 +3372,7 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): Finally, if detected is empty, all performance metrics will be set to zero, and a copy of the groundtruth array will be returned as false negatives. - >>> compare_detection( - ... [], grndtrth - ... ) + >>> compare_detection([], grndtrth) {'tp': array([], dtype=int64), 'fp': array([], dtype=int64), 'fn': array([ 5, 12, 18, 26, 34, 41, 55, 63, 68]), diff --git a/src/yasa/evaluation.py b/src/yasa/evaluation.py index 34f813c..32ae254 100644 --- a/src/yasa/evaluation.py +++ b/src/yasa/evaluation.py @@ -86,18 +86,14 @@ class EpochByEpochAgreement: ... scorer="Human", ... seed=i, ... ) - ... for i in range( - ... 10 - ... ) + ... for i in range(10) ... ] >>> obs_hyps = [ ... h.simulate_similar( ... scorer="YASA", ... seed=i, ... ) - ... for i, h in enumerate( - ... ref_hyps - ... ) + ... for i, h in enumerate(ref_hyps) ... ] >>> ebe = yasa.EpochByEpochAgreement( ... ref_hyps, @@ -113,9 +109,7 @@ class EpochByEpochAgreement: 4 0.22 0.21 0.01 0.01 0.21 0.22 0.21 5 0.21 0.17 -0.06 -0.06 0.20 0.21 0.21 - >>> ebe.get_agreement_bystage().head( - ... 12 - ... ).round(3) + >>> ebe.get_agreement_bystage().head(12).round(3) fbeta precision recall support stage sleep_id WAKE 1 0.391 0.371 0.413 189.0 @@ -131,9 +125,7 @@ class EpochByEpochAgreement: N1 1 0.185 0.185 0.185 124.0 2 0.121 0.131 0.112 160.0 - >>> ebe.get_confusion_matrix( - ... sleep_id=1 - ... ) + >>> ebe.get_confusion_matrix(sleep_id=1) YASA WAKE N1 N2 N3 REM Human WAKE 78 24 50 3 34 @@ -145,27 +137,21 @@ class EpochByEpochAgreement: .. plot:: >>> import matplotlib.pyplot as plt - >>> fig, ax = ( - ... plt.subplots( - ... figsize=( - ... 6, - ... 3, - ... ), - ... constrained_layout=True, - ... ) - ... ) - >>> ebe.plot_hypnograms( - ... sleep_id=10 + >>> fig, ax = plt.subplots( + ... figsize=( + ... 6, + ... 3, + ... ), + ... constrained_layout=True, ... ) + >>> ebe.plot_hypnograms(sleep_id=10) .. plot:: - >>> fig, ax = ( - ... plt.subplots( - ... figsize=( - ... 6, - ... 3, - ... ) + >>> fig, ax = plt.subplots( + ... figsize=( + ... 6, + ... 3, ... ) ... ) >>> ebe.plot_hypnograms( @@ -176,14 +162,12 @@ class EpochByEpochAgreement: .. plot:: >>> session = 8 - >>> fig, ax = ( - ... plt.subplots( - ... figsize=( - ... 6.5, - ... 2.5, - ... ), - ... constrained_layout=True, - ... ) + >>> fig, ax = plt.subplots( + ... figsize=( + ... 6.5, + ... 2.5, + ... ), + ... constrained_layout=True, ... ) >>> style_a = dict( ... alpha=1, @@ -228,9 +212,7 @@ class EpochByEpochAgreement: ... scorer="RaterB", ... seed=9, ... ) - >>> ebe = hypno_a.evaluate( - ... hypno_b - ... ) + >>> ebe = hypno_a.evaluate(hypno_b) >>> ebe.get_confusion_matrix() RaterB WAKE N1 N2 N3 RaterA @@ -558,26 +540,20 @@ def get_confusion_matrix(self, sleep_id=None, agg_func=None, **kwargs): ... scorer="Rater1", ... seed=i, ... ) - ... for i in range( - ... 3 - ... ) + ... for i in range(3) ... ] >>> obs_hyps = [ ... h.simulate_similar( ... scorer="Rater2", ... seed=i, ... ) - ... for i, h in enumerate( - ... ref_hyps - ... ) + ... for i, h in enumerate(ref_hyps) ... ] >>> ebe = yasa.EpochByEpochAgreement( ... ref_hyps, ... obs_hyps, ... ) - >>> ebe.get_confusion_matrix( - ... sleep_id=2 - ... ) + >>> ebe.get_confusion_matrix(sleep_id=2) Rater2 WAKE N1 N2 N3 REM Rater1 WAKE 1 2 23 0 0 @@ -605,9 +581,7 @@ def get_confusion_matrix(self, sleep_id=None, agg_func=None, **kwargs): N3 0 0 16 11 0 REM 0 15 11 18 0 - >>> ebe.get_confusion_matrix( - ... agg_func="sum" - ... ) + >>> ebe.get_confusion_matrix(agg_func="sum") Rater2 WAKE N1 N2 N3 REM Rater1 WAKE 47 2 33 19 54 @@ -812,9 +786,7 @@ def summary(self, by_stage=False, **kwargs): A :py:class:`pandas.DataFrame` summarizing agreement scores across the entire dataset with descriptive statistics. - >>> ebe = yasa.EpochByEpochAgreement( - ... ... - ... ) + >>> ebe = yasa.EpochByEpochAgreement(...) >>> agreement = ebe.get_agreement() >>> ebe.summary() @@ -935,27 +907,21 @@ class SleepStatsAgreement: >>> >>> # Generate fake reference and observed datasets with similar sleep statistics >>> ref_scorer = "Henri" - >>> obs_scorer = ( - ... "Piéron" - ... ) + >>> obs_scorer = "Piéron" >>> ref_hyps = [ ... yasa.simulate_hypnogram( ... tib=600, ... scorer=ref_scorer, ... seed=i, ... ) - ... for i in range( - ... 20 - ... ) + ... for i in range(20) ... ] >>> obs_hyps = [ ... h.simulate_similar( ... scorer=obs_scorer, ... seed=i, ... ) - ... for i, h in enumerate( - ... ref_hyps - ... ) + ... for i, h in enumerate(ref_hyps) ... ] >>> # Generate sleep statistics from hypnograms using EpochByEpochAgreement >>> eea = yasa.EpochByEpochAgreement( @@ -963,24 +929,14 @@ class SleepStatsAgreement: ... obs_hyps, ... ) >>> sstats = eea.get_sleep_stats() - >>> ref_sstats = ( - ... sstats.loc[ - ... ref_scorer - ... ] - ... ) - >>> obs_sstats = ( - ... sstats.loc[ - ... obs_scorer - ... ] - ... ) + >>> ref_sstats = sstats.loc[ref_scorer] + >>> obs_sstats = sstats.loc[obs_scorer] >>> # Create SleepStatsAgreement instance >>> ssa = yasa.SleepStatsAgreement( ... ref_sstats, ... obs_sstats, ... ) - >>> ssa.summary().round( - ... 1 - ... ).head(3) + >>> ssa.summary().round(1).head(3) variable bias_intercept ... uloa_parm interval center lower upper ... center lower upper sleep_stat ... @@ -988,27 +944,21 @@ class SleepStatsAgreement: %N2 -27.3 -49.1 -5.6 ... 12.4 7.2 17.6 %N3 -9.1 -23.8 5.5 ... 20.4 12.6 28.3 - >>> ssa.get_table().head( - ... 3 - ... )[["bias", "loa"]] + >>> ssa.get_table().head(3)[["bias", "loa"]] bias loa sleep_stat %N1 0.25 Bias ± 2.46 * (-0.00 + 1.00x) %N2 -27.34 + 0.55x Bias ± 2.46 * (0.00 + 1.00x) %N3 1.38 Bias ± 2.46 * (0.00 + 1.00x) - >>> ssa.assumptions.head( - ... 3 - ... ) + >>> ssa.assumptions.head(3) unbiased normal constant_bias homoscedastic sleep_stat %N1 True True True False %N2 True True False False %N3 True True True False - >>> ssa.auto_methods.head( - ... 3 - ... ) + >>> ssa.auto_methods.head(3) bias loa ci sleep_stat %N1 parm regr parm @@ -1018,9 +968,7 @@ class SleepStatsAgreement: >>> ssa.get_table( ... bias_method="parm", ... loa_method="parm", - ... ).head(3)[ - ... ["bias", "loa"] - ... ] + ... ).head(3)[["bias", "loa"]] bias loa sleep_stat %N1 0.25 -5.55, 6.06 @@ -1032,33 +980,17 @@ class SleepStatsAgreement: ... scorer="Kelly", ... seed=i, ... ) - ... for i, h in enumerate( - ... obs_hyps - ... ) + ... for i, h in enumerate(obs_hyps) ... ] - >>> new_sstats = ( - ... pd.Series( - ... new_hyps - ... ) - ... .map( - ... lambda h: h.sleep_statistics() - ... ) - ... .apply( - ... pd.Series - ... ) - ... ) - >>> new_sstats = ( - ... new_sstats[ - ... [ - ... "N1", - ... "TST", - ... "WASO", - ... ] + >>> new_sstats = pd.Series(new_hyps).map(lambda h: h.sleep_statistics()).apply(pd.Series) + >>> new_sstats = new_sstats[ + ... [ + ... "N1", + ... "TST", + ... "WASO", ... ] - ... ) - >>> new_sstats.round( - ... 1 - ... ).head(5) + ... ] + >>> new_sstats.round(1).head(5) N1 TST WASO 0 42.5 439.5 147.5 1 84.0 550.0 38.5 @@ -1070,9 +1002,7 @@ class SleepStatsAgreement: ... new_sstats, ... bias_method="auto", ... ) - >>> new_stats_calibrated.round( - ... 1 - ... ).head(5) + >>> new_stats_calibrated.round(1).head(5) N1 TST WASO 0 42.9 433.8 150.0 1 84.4 544.2 41.0 @@ -1084,9 +1014,7 @@ class SleepStatsAgreement: >>> import matplotlib.pyplot as plt >>> ax = ssa.plot_discrepancies_heatmap() - >>> ax.set_title( - ... "Sleep statistic discrepancies" - ... ) + >>> ax.set_title("Sleep statistic discrepancies") >>> plt.tight_layout() .. plot:: @@ -1659,28 +1587,18 @@ def get_calibration_func(self, sleep_stat): Examples -------- - >>> ssa = yasa.SleepStatsAgreement( - ... ... - ... ) - >>> calibrate_rem = ssa.get_calibration_func( - ... "REM" - ... ) - >>> new_obs_rem_vals = ( - ... np.array( - ... [ - ... 50, - ... 40, - ... 30, - ... 20, - ... ] - ... ) - ... ) - >>> calibrate_rem( - ... new_obs_rem_vals - ... ) - >>> calibrate_rem( - ... new_obs_rem_vals + >>> ssa = yasa.SleepStatsAgreement(...) + >>> calibrate_rem = ssa.get_calibration_func("REM") + >>> new_obs_rem_vals = np.array( + ... [ + ... 50, + ... 40, + ... 30, + ... 20, + ... ] ... ) + >>> calibrate_rem(new_obs_rem_vals) + >>> calibrate_rem(new_obs_rem_vals) array([50, 40, 30, 20]) >>> calibrate_rem( ... new_obs_rem_vals, diff --git a/src/yasa/hypno.py b/src/yasa/hypno.py index 1d6c46d..95ff780 100644 --- a/src/yasa/hypno.py +++ b/src/yasa/hypno.py @@ -173,9 +173,7 @@ class Hypnogram: 'WAKE': 2.0} >>> # Get the state-transition matrix - >>> counts, probs = ( - ... hyp.transition_matrix() - ... ) + >>> counts, probs = hyp.transition_matrix() >>> counts To Stage WAKE SLEEP From Stage @@ -601,9 +599,7 @@ def consolidate_stages(self, new_n_stages): ... ], ... n_stages=5, ... ) - >>> hyp_2s = hyp.consolidate_stages( - ... 2 - ... ) + >>> hyp_2s = hyp.consolidate_stages(2) >>> print(hyp_2s) Epoch 0 WAKE @@ -692,14 +688,8 @@ def evaluate(self, obs_hyp): ... scorer="YASA", ... seed=9, ... ) - >>> ebe = ( - ... hyp_a.evaluate( - ... hyp_b - ... ) - ... ) - >>> ebe.get_agreement().round( - ... 3 - ... ) + >>> ebe = hyp_a.evaluate(hyp_b) + >>> ebe.get_agreement().round(3) accuracy 0.550 balanced_acc 0.355 kappa 0.227 @@ -744,21 +734,13 @@ def find_periods(self, threshold="5min", equal_length=False): >>> from yasa import ( ... Hypnogram, ... ) - >>> val = ( - ... 11 * ["W"] - ... + 3 * ["S"] - ... + 2 * ["W"] - ... + 9 * ["S"] - ... + ["W", "W"] - ... ) + >>> val = 11 * ["W"] + 3 * ["S"] + 2 * ["W"] + 9 * ["S"] + ["W", "W"] >>> hyp = Hypnogram( ... val, ... n_stages=2, ... freq="1min", ... ) - >>> hyp.find_periods( - ... threshold="0min" - ... ) + >>> hyp.find_periods(threshold="0min") values start length 0 WAKE 0 11 1 SLEEP 11 3 @@ -773,9 +755,7 @@ def find_periods(self, threshold="5min", equal_length=False): Now, we may want to keep only periods that are longer than a specific threshold, for example 5 minutes: - >>> hyp.find_periods( - ... threshold="5min" - ... ) + >>> hyp.find_periods(threshold="5min") values start length 0 WAKE 0 11 1 SLEEP 16 9 @@ -789,12 +769,8 @@ def find_periods(self, threshold="5min", equal_length=False): >>> from yasa import ( ... simulate_hypnogram, ... ) - >>> hyp = simulate_hypnogram( - ... tib=30, seed=42 - ... ) - >>> hyp.find_periods( - ... threshold="2min" - ... ) + >>> hyp = simulate_hypnogram(tib=30, seed=42) + >>> hyp.find_periods(threshold="2min") values start length 0 WAKE 0 5 1 N1 5 6 @@ -844,11 +820,7 @@ def plot_hypnogram(self, **kwargs): >>> from yasa import ( ... simulate_hypnogram, ... ) - >>> ax = simulate_hypnogram( - ... tib=480, seed=88 - ... ).plot_hypnogram( - ... highlight="REM" - ... ) + >>> ax = simulate_hypnogram(tib=480, seed=88).plot_hypnogram(highlight="REM") """ return plot_hypnogram(self, **kwargs) @@ -984,13 +956,7 @@ def sleep_statistics(self): ... Hypnogram, ... ) >>> # Generate a fake hypnogram, where "S" = Sleep, "W" = Wake - >>> values = ( - ... 10 * ["W"] - ... + 40 * ["S"] - ... + 5 * ["W"] - ... + 40 * ["S"] - ... + 9 * ["W"] - ... ) + >>> values = 10 * ["W"] + 40 * ["S"] + 5 * ["W"] + 40 * ["S"] + 9 * ["W"] >>> hyp = Hypnogram( ... values, ... freq="15s", @@ -1014,9 +980,7 @@ def sleep_statistics(self): ... simulate_hypnogram, ... ) >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution - >>> hyp = simulate_hypnogram( - ... tib=480, seed=42 - ... ) + >>> hyp = simulate_hypnogram(tib=480, seed=42) >>> hyp.sleep_statistics() {'TIB': 480.0, 'SPT': 477.5, @@ -1150,12 +1114,8 @@ def transition_matrix(self): ... simulate_hypnogram, ... ) >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution - >>> hyp = simulate_hypnogram( - ... tib=480, seed=42 - ... ) - >>> counts, probs = ( - ... hyp.transition_matrix() - ... ) + >>> hyp = simulate_hypnogram(tib=480, seed=42) + >>> counts, probs = hyp.transition_matrix() >>> counts To Stage WAKE N1 N2 N3 REM From Stage @@ -1229,11 +1189,7 @@ def upsample(self, new_freq, **kwargs): Upsample to a 15-seconds resolution - >>> hyp_up = ( - ... hyp.upsample( - ... "15s" - ... ) - ... ) + >>> hyp_up = hyp.upsample("15s") >>> hyp_up.hypno Time 2022-12-23 23:00:00 WAKE @@ -1945,9 +1901,7 @@ def simulate_hypnogram( >>> from yasa import ( ... simulate_hypnogram, ... ) - >>> hyp = simulate_hypnogram( - ... tib=5, seed=1 - ... ) + >>> hyp = simulate_hypnogram(tib=5, seed=1) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -1995,9 +1949,7 @@ def simulate_hypnogram( ... n_stages=2, ... seed=1, ... ) - >>> hyp.hypno.iloc[ - ... -2: - ... ] = "UNS" + >>> hyp.hypno.iloc[-2:] = "UNS" >>> hyp.hypno Epoch 0 WAKE @@ -2026,36 +1978,18 @@ def simulate_hypnogram( >>> "https://github.com/raphaelvallat/yasa/raw/master/" >>> "notebooks/data_full_6hrs_100Hz_hypno_30s.txt" >>> ) - >>> values_str = hypno_int_to_str( - ... np.loadtxt(url) - ... ) - >>> real_hyp = ( - ... Hypnogram( - ... values_str - ... ) - ... ) - >>> fake_hyp = real_hyp.simulate_similar( - ... seed=2 - ... ) - >>> fig, (ax1, ax2) = ( - ... plt.subplots( - ... nrows=2, - ... figsize=( - ... 7, - ... 5, - ... ), - ... ) - ... ) - >>> real_hyp.plot_hypnogram( - ... ax=ax1 - ... ).set_title( - ... "Real hypnogram" - ... ) - >>> fake_hyp.plot_hypnogram( - ... ax=ax2 - ... ).set_title( - ... "Fake hypnogram" + >>> values_str = hypno_int_to_str(np.loadtxt(url)) + >>> real_hyp = Hypnogram(values_str) + >>> fake_hyp = real_hyp.simulate_similar(seed=2) + >>> fig, (ax1, ax2) = plt.subplots( + ... nrows=2, + ... figsize=( + ... 7, + ... 5, + ... ), ... ) + >>> real_hyp.plot_hypnogram(ax=ax1).set_title("Real hypnogram") + >>> fake_hyp.plot_hypnogram(ax=ax2).set_title("Fake hypnogram") >>> plt.tight_layout() """ # Extract yasa.Hypnogram defaults, which will be assumed later but need throughout diff --git a/src/yasa/others.py b/src/yasa/others.py index 92348fb..9bbc087 100644 --- a/src/yasa/others.py +++ b/src/yasa/others.py @@ -343,12 +343,10 @@ def sliding_window(data, sf, window, step=None, axis=-1): ... sliding_window, ... ) >>> data = np.arange(20) - >>> times, epochs = ( - ... sliding_window( - ... data, - ... sf=1, - ... window=5, - ... ) + >>> times, epochs = sliding_window( + ... data, + ... sf=1, + ... window=5, ... ) >>> times array([ 0., 5., 10., 15.]) @@ -390,13 +388,11 @@ def sliding_window(data, sf, window, step=None, axis=-1): ... 100, ... size=(4, 20), ... ) - >>> epochs = ( - ... sliding_window( - ... data, - ... sf=1, - ... window=10, - ... )[1] - ... ) + >>> epochs = sliding_window( + ... data, + ... sf=1, + ... window=10, + ... )[1] >>> epochs.shape # shape (n_epochs, n_channels, n_samples) (2, 4, 10) @@ -484,9 +480,7 @@ def get_centered_indices(data, idx, npts_before, npts_after): ... get_centered_indices, ... ) >>> np.random.seed(123) - >>> data = np.random.normal( - ... size=100 - ... ).round(2) + >>> data = np.random.normal(size=100).round(2) >>> idx = [ ... 1.0, ... 10.0, diff --git a/src/yasa/plotting.py b/src/yasa/plotting.py index f331a23..e4b920c 100644 --- a/src/yasa/plotting.py +++ b/src/yasa/plotting.py @@ -62,9 +62,7 @@ def plot_hypnogram(hyp, sf_hypno=1 / 30, highlight="REM", fill_color=None, ax=No ... simulate_hypnogram, ... ) >>> import matplotlib.pyplot as plt - >>> hyp = simulate_hypnogram( - ... tib=300, seed=11 - ... ) + >>> hyp = simulate_hypnogram(tib=300, seed=11) >>> ax = hyp.plot_hypnogram() >>> plt.tight_layout() @@ -102,15 +100,13 @@ def plot_hypnogram(hyp, sf_hypno=1 / 30, highlight="REM", fill_color=None, ax=No ... simulate_hypnogram, ... ) >>> import matplotlib.pyplot as plt - >>> fig, axes = ( - ... plt.subplots( - ... nrows=2, - ... figsize=( - ... 6, - ... 4, - ... ), - ... constrained_layout=True, - ... ) + >>> fig, axes = plt.subplots( + ... nrows=2, + ... figsize=( + ... 6, + ... 4, + ... ), + ... constrained_layout=True, ... ) >>> hyp_a = simulate_hypnogram( ... n_stages=3, @@ -306,18 +302,10 @@ def plot_spectrogram( ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", ... stream=True, ... ) - >>> npz = np.load( - ... BytesIO( - ... r.raw.read() - ... ) - ... ) - >>> data = npz.get( - ... "data" - ... )[0, :] + >>> npz = np.load(BytesIO(r.raw.read())) + >>> data = npz.get("data")[0, :] >>> sf = 100 - >>> fig = yasa.plot_spectrogram( - ... data, sf - ... ) + >>> fig = yasa.plot_spectrogram(data, sf) 2. Full-night multitaper spectrogram on Cz with the hypnogram on top @@ -334,14 +322,8 @@ def plot_spectrogram( ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", ... stream=True, ... ) - >>> npz = np.load( - ... BytesIO( - ... r.raw.read() - ... ) - ... ) - >>> data = npz.get( - ... "data" - ... )[0, :] + >>> npz = np.load(BytesIO(r.raw.read())) + >>> data = npz.get("data")[0, :] >>> sf = 100 >>> # Load the 30-sec hypnogram and upsample to data >>> hypno = np.loadtxt( diff --git a/src/yasa/sleepstats.py b/src/yasa/sleepstats.py index b841c4f..07305ca 100644 --- a/src/yasa/sleepstats.py +++ b/src/yasa/sleepstats.py @@ -76,11 +76,7 @@ def transition_matrix(hypno): ... 3, ... 3, ... ] - >>> counts, probs = ( - ... transition_matrix( - ... a - ... ) - ... ) + >>> counts, probs = transition_matrix(a) >>> counts 0 1 2 3 Stage @@ -102,11 +98,7 @@ def transition_matrix(hypno): calculated by taking the average of the diagonal values (excluding Wake and N1 sleep): - >>> np.diag( - ... probs.loc[ - ... 2:, 2: - ... ] - ... ).mean().round(3) + >>> np.diag(probs.loc[2:, 2:]).mean().round(3) 0.514 Finally, we can plot the transition matrix using :py:func:`seaborn.heatmap` @@ -136,11 +128,7 @@ def transition_matrix(hypno): ... 0, ... 0, ... ] - >>> _, probs = ( - ... transition_matrix( - ... a - ... ) - ... ) + >>> _, probs = transition_matrix(a) >>> # Start the plot >>> grid_kws = { ... "height_ratios": ( @@ -149,15 +137,13 @@ def transition_matrix(hypno): ... ), ... "hspace": 0.1, ... } - >>> f, (ax, cbar_ax) = ( - ... plt.subplots( - ... 2, - ... gridspec_kw=grid_kws, - ... figsize=( - ... 5, - ... 5, - ... ), - ... ) + >>> f, (ax, cbar_ax) = plt.subplots( + ... 2, + ... gridspec_kw=grid_kws, + ... figsize=( + ... 5, + ... 5, + ... ), ... ) >>> sns.heatmap( ... probs, @@ -176,16 +162,10 @@ def transition_matrix(hypno): ... "label": "Transition probability", ... }, ... ) - >>> ax.set_xlabel( - ... "To sleep stage" - ... ) + >>> ax.set_xlabel("To sleep stage") >>> ax.xaxis.tick_top() - >>> ax.set_ylabel( - ... "From sleep stage" - ... ) - >>> ax.xaxis.set_label_position( - ... "top" - ... ) + >>> ax.set_ylabel("From sleep stage") + >>> ax.xaxis.set_label_position("top") """ # NOTE: FutureWarning not added here otherwise it would also be shown when calling # yasa.Hypnogram.transition_matrix diff --git a/src/yasa/staging.py b/src/yasa/staging.py index 8a3fd7e..05a3f83 100644 --- a/src/yasa/staging.py +++ b/src/yasa/staging.py @@ -157,17 +157,11 @@ class SleepStaging: ... ), ... ) >>> # Get the predicted sleep stages - >>> hypno = ( - ... sls.predict() - ... ) + >>> hypno = sls.predict() >>> # Get the predicted probabilities >>> proba = sls.predict_proba() >>> # Get the confidence - >>> confidence = ( - ... proba.max( - ... axis=1 - ... ) - ... ) + >>> confidence = proba.max(axis=1) >>> # Plot the predicted probabilities >>> sls.plot_predict_proba() From 407609c85df0404741ebfa986fbaf554f00affc3 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:02:46 +0100 Subject: [PATCH 06/19] Disable docstring formatting --- push_pypi.md | 6 + pyproject.toml | 3 +- src/yasa/__init__.py | 1 - src/yasa/detection.py | 159 ++++--------------- src/yasa/evaluation.py | 203 ++++--------------------- src/yasa/features.py | 7 +- src/yasa/heart.py | 5 +- src/yasa/hypno.py | 337 ++++++----------------------------------- src/yasa/io.py | 1 + src/yasa/others.py | 80 ++-------- src/yasa/plotting.py | 176 ++++----------------- src/yasa/push_pypi.md | 7 - src/yasa/sleepstats.py | 123 ++------------- src/yasa/spectral.py | 6 +- src/yasa/staging.py | 32 ++-- 15 files changed, 195 insertions(+), 951 deletions(-) create mode 100644 push_pypi.md delete mode 100644 src/yasa/push_pypi.md diff --git a/push_pypi.md b/push_pypi.md new file mode 100644 index 0000000..24f8346 --- /dev/null +++ b/push_pypi.md @@ -0,0 +1,6 @@ +# Build and upload a new version of YASA + +```bash +python -m build +twine upload dist/yasa- +``` diff --git a/pyproject.toml b/pyproject.toml index 9f8251a..358597d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,6 +117,7 @@ target-version = "py311" lint.select = ["E4", "E7", "E9", "F", "I", "NPY201"] exclude = [ "notebooks", # Skip jupyter notebook examples + "docs", ] [tool.ruff.lint.per-file-ignores] @@ -126,5 +127,5 @@ exclude = [ convention = "numpy" [tool.ruff.format] -docstring-code-format = true +docstring-code-format = false docstring-code-line-length = 90 diff --git a/src/yasa/__init__.py b/src/yasa/__init__.py index 93f92f7..a750a04 100644 --- a/src/yasa/__init__.py +++ b/src/yasa/__init__.py @@ -1,5 +1,4 @@ import logging - from .detection import * from .evaluation import * from .features import * diff --git a/src/yasa/detection.py b/src/yasa/detection.py index a8aaa68..29e4ab0 100644 --- a/src/yasa/detection.py +++ b/src/yasa/detection.py @@ -7,29 +7,29 @@ - License: BSD 3-Clause License """ -import logging -from collections import OrderedDict - import mne +import logging import numpy as np import pandas as pd -from mne.filter import filter_data from scipy import signal -from scipy.fftpack import next_fast_len +from mne.filter import filter_data +from collections import OrderedDict from scipy.interpolate import interp1d +from scipy.fftpack import next_fast_len from sklearn.ensemble import IsolationForest -from .io import is_pyriemann_installed, is_tensorpac_installed, set_log_level +from .spectral import stft_power from .numba import _detrend, _rms +from .io import set_log_level, is_tensorpac_installed, is_pyriemann_installed from .others import ( - _merge_close, - _zerocrossings, - get_centered_indices, moving_transform, - sliding_window, trimbothstd, + get_centered_indices, + sliding_window, + _merge_close, + _zerocrossings, ) -from .spectral import stft_power + logger = logging.getLogger("yasa") @@ -86,7 +86,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch include = np.atleast_1d(np.asarray(include)) assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.isin(hypno, include).any(), ( + assert np.in1d(hypno, include).any(), ( "None of the stages specified " "in `include` are present in " "hypno." ) @@ -110,7 +110,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch # 5) Create sleep stage vector mask if hypno is not None: - mask = np.isin(hypno, include) + mask = np.in1d(hypno, include) else: mask = np.ones(n_samples, dtype=bool) @@ -458,8 +458,8 @@ def plot_average( **kwargs, ): """Plot the average event (not for REM, spindles & SW only)""" - import matplotlib.pyplot as plt import seaborn as sns + import matplotlib.pyplot as plt df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -485,8 +485,8 @@ def plot_average( def plot_detection(self): """Plot an overlay of the detected events on the signal.""" - import ipywidgets as ipy import matplotlib.pyplot as plt + import ipywidgets as ipy # Define mask sf = self._sf @@ -699,10 +699,7 @@ def spindles_detect( of this spindle. To get the average spindles parameters per channel and sleep stage: - >>> sp.summary( - ... grp_chan=True, - ... grp_stage=True, - ... ) + >>> sp.summary(grp_chan=True, grp_stage=True) Notes ----- @@ -1151,36 +1148,8 @@ def get_coincidence_matrix(self, scaled=True): Calculate the coincidence of two binary mask: >>> import numpy as np - >>> x = np.array( - ... [ - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 0, - ... 1, - ... ] - ... ) - >>> y = np.array( - ... [ - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... ] - ... ) + >>> x = np.array([0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1]) + >>> y = np.array([0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1]) >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) @@ -1533,9 +1502,8 @@ def sw_detect( .. code-block:: python import pingouin as pg - - mean_direction = pg.circ_mean(sw["PhaseAtSigmaPeak"]) - vector_length = pg.circ_r(sw["PhaseAtSigmaPeak"]) + mean_direction = pg.circ_mean(sw['PhaseAtSigmaPeak']) + vector_length = pg.circ_r(sw['PhaseAtSigmaPeak']) 3. ``ndPAC``: the normalized Mean Vector Length (also called the normalized direct PAC, or ndPAC) within a 2-sec epoch centered around the negative peak of the slow-wave. @@ -1595,10 +1563,7 @@ def sw_detect( detected slow-wave and each column is a parameter (= property). To get the average SW parameters per channel and sleep stage: - >>> sw.summary( - ... grp_chan=True, - ... grp_stage=True, - ... ) + >>> sw.summary(grp_chan=True, grp_stage=True) Notes ----- @@ -1627,7 +1592,7 @@ def sw_detect( of the slow-wave. This is only calculated when ``coupling=True`` * ``'Stage'``: Sleep stage (only if hypno was provided) - .. image:: https://raw.githubusercontent.com/raphaelvallat/yasa/master/docs/pictures/slow_waves.png + .. image:: https://raw.githubusercontent.com/raphaelvallat/yasa/master/docs/pictures/slow_waves.png # noqa :width: 500px :align: center :alt: slow-wave @@ -1651,7 +1616,7 @@ def sw_detect( -------- For an example of how to run the detection, please refer to the tutorial: https://github.com/raphaelvallat/yasa/blob/master/notebooks/05_sw_detection.ipynb - """ # noqa: E501 + """ set_log_level(verbose) (data, sf, ch_names, hypno, include, mask, n_chan, n_samples, bad_chan) = _check_data_hypno( @@ -2206,36 +2171,8 @@ def get_coincidence_matrix(self, scaled=True): Calculate the coincidence of two binary mask: >>> import numpy as np - >>> x = np.array( - ... [ - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 0, - ... 1, - ... ] - ... ) - >>> y = np.array( - ... [ - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... ] - ... ) + >>> x = np.array([0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 1]) + >>> y = np.array([0, 0, 1, 1, 1, 0, 0, 0, 0, 1, 1]) >>> x * y array([0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1]) @@ -2843,8 +2780,8 @@ def plot_average( **kwargs : dict Optional argument that are passed to :py:func:`seaborn.lineplot`. """ - import matplotlib.pyplot as plt import seaborn as sns + import matplotlib.pyplot as plt df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -3087,8 +3024,8 @@ def art_detect( if method in ["cov", "covar", "covariance", "riemann", "potato"]: method = "covar" is_pyriemann_installed() - from pyriemann.clustering import Potato from pyriemann.estimation import Covariances, Shrinkage + from pyriemann.clustering import Potato # Must have at least 4 channels to use method='covar' if n_chan <= 4: @@ -3297,33 +3234,10 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): These could be for example the index of the onset of each detected spindle. `grndtrth` refers to the ground-truth (e.g. human-annotated) events. - >>> from yasa import ( - ... compare_detection, - ... ) - >>> detected = [ - ... 5, - ... 12, - ... 20, - ... 34, - ... 41, - ... 57, - ... 63, - ... ] - >>> grndtrth = [ - ... 5, - ... 12, - ... 18, - ... 26, - ... 34, - ... 41, - ... 55, - ... 63, - ... 68, - ... ] - >>> compare_detection( - ... detected, - ... grndtrth, - ... ) + >>> from yasa import compare_detection + >>> detected = [5, 12, 20, 34, 41, 57, 63] + >>> grndtrth = [5, 12, 18, 26, 34, 41, 55, 63, 68] + >>> compare_detection(detected, grndtrth) {'tp': array([ 5, 12, 34, 41, 63]), 'fp': array([20, 57]), 'fn': array([18, 26, 55, 68]), @@ -3341,10 +3255,7 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): detections (and not a detection against a ground-truth), the F1-score is the preferred metric because it is independent of the order. - >>> compare_detection( - ... grndtrth, - ... detected, - ... ) + >>> compare_detection(grndtrth, detected) {'tp': array([ 5, 12, 34, 41, 63]), 'fp': array([18, 26, 55, 68]), 'fn': array([20, 57]), @@ -3357,11 +3268,7 @@ def compare_detection(indices_detection, indices_groundtruth, max_distance=0): with the `max_distance` argument, which defines the lookaround window (in samples) for each event. - >>> compare_detection( - ... detected, - ... grndtrth, - ... max_distance=2, - ... ) + >>> compare_detection(detected, grndtrth, max_distance=2) {'tp': array([ 5, 12, 20, 34, 41, 57, 63]), 'fp': array([], dtype=int64), 'fn': array([26, 68]), diff --git a/src/yasa/evaluation.py b/src/yasa/evaluation.py index 32ae254..09c9e1f 100644 --- a/src/yasa/evaluation.py +++ b/src/yasa/evaluation.py @@ -16,6 +16,7 @@ import scipy.stats as sps import sklearn.metrics as skm + logger = logging.getLogger("yasa") __all__ = [ @@ -80,25 +81,9 @@ class EpochByEpochAgreement: Examples -------- >>> import yasa - >>> ref_hyps = [ - ... yasa.simulate_hypnogram( - ... tib=600, - ... scorer="Human", - ... seed=i, - ... ) - ... for i in range(10) - ... ] - >>> obs_hyps = [ - ... h.simulate_similar( - ... scorer="YASA", - ... seed=i, - ... ) - ... for i, h in enumerate(ref_hyps) - ... ] - >>> ebe = yasa.EpochByEpochAgreement( - ... ref_hyps, - ... obs_hyps, - ... ) + >>> ref_hyps = [yasa.simulate_hypnogram(tib=600, scorer="Human", seed=i) for i in range(10)] + >>> obs_hyps = [h.simulate_similar(scorer="YASA", seed=i) for i, h in enumerate(ref_hyps)] + >>> ebe = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) >>> agr = ebe.get_agreement() >>> agr.head(5).round(2) accuracy balanced_acc kappa mcc precision recall f1 @@ -137,23 +122,12 @@ class EpochByEpochAgreement: .. plot:: >>> import matplotlib.pyplot as plt - >>> fig, ax = plt.subplots( - ... figsize=( - ... 6, - ... 3, - ... ), - ... constrained_layout=True, - ... ) + >>> fig, ax = plt.subplots(figsize=(6, 3), constrained_layout=True) >>> ebe.plot_hypnograms(sleep_id=10) .. plot:: - >>> fig, ax = plt.subplots( - ... figsize=( - ... 6, - ... 3, - ... ) - ... ) + >>> fig, ax = plt.subplots(figsize=(6, 3)) >>> ebe.plot_hypnograms( >>> sleep_id=8, ax=ax, obs_kwargs={"color": "red", "lw": 2, "ls": "dotted"} >>> ) @@ -162,56 +136,24 @@ class EpochByEpochAgreement: .. plot:: >>> session = 8 - >>> fig, ax = plt.subplots( - ... figsize=( - ... 6.5, - ... 2.5, - ... ), - ... constrained_layout=True, - ... ) - >>> style_a = dict( - ... alpha=1, - ... lw=2.5, - ... ls="solid", - ... color="gainsboro", - ... label="Michel", - ... ) - >>> style_b = dict( - ... alpha=1, - ... lw=2.5, - ... ls="solid", - ... color="cornflowerblue", - ... label="Jouvet", - ... ) + >>> fig, ax = plt.subplots(figsize=(6.5, 2.5), constrained_layout=True) + >>> style_a = dict(alpha=1, lw=2.5, ls="solid", color="gainsboro", label="Michel") + >>> style_b = dict(alpha=1, lw=2.5, ls="solid", color="cornflowerblue", label="Jouvet") >>> legend_style = dict( >>> title="Scorer", frameon=False, ncol=2, loc="lower center", bbox_to_anchor=(0.5, 0.9) >>> ) >>> ax = ebe.plot_hypnograms( >>> sleep_id=session, ref_kwargs=style_a, obs_kwargs=style_b, legend=legend_style, ax=ax >>> ) - >>> acc = ( - ... ebe.get_agreement() - ... .multiply(100) - ... .at[ - ... session, - ... "accuracy", - ... ] - ... ) + >>> acc = ebe.get_agreement().multiply(100).at[session, "accuracy"] >>> ax.text( >>> 0.01, 1, f"Accuracy = {acc:.0f}%", ha="left", va="bottom", transform=ax.transAxes >>> ) When comparing only 2 hypnograms, use the :py:meth:`~yasa.Hynogram.evaluate` method: - >>> hypno_a = yasa.simulate_hypnogram( - ... tib=90, - ... scorer="RaterA", - ... seed=8, - ... ) - >>> hypno_b = hypno_a.simulate_similar( - ... scorer="RaterB", - ... seed=9, - ... ) + >>> hypno_a = yasa.simulate_hypnogram(tib=90, scorer="RaterA", seed=8) + >>> hypno_b = hypno_a.simulate_similar(scorer="RaterB", seed=9) >>> ebe = hypno_a.evaluate(hypno_b) >>> ebe.get_confusion_matrix() RaterB WAKE N1 N2 N3 @@ -277,7 +219,7 @@ def __init__(self, ref_hyps, obs_hyps): # Generate some mapping dictionaries to be used later in class methods skm_labels = np.unique(data).tolist() # all unique YASA integer codes in this hypno - skm2yasa_map = {i: lab for i, lab in enumerate(skm_labels)} # skm order to YASA integers + skm2yasa_map = {i: l for i, l in enumerate(skm_labels)} # skm order to YASA integers yasa2yasa_map = ref_hyps[sleep_ids[0]].mapping_int.copy() # YASA integer to YASA string # Set attributes @@ -534,25 +476,9 @@ def get_confusion_matrix(self, sleep_id=None, agg_func=None, **kwargs): Examples -------- >>> import yasa - >>> ref_hyps = [ - ... yasa.simulate_hypnogram( - ... tib=90, - ... scorer="Rater1", - ... seed=i, - ... ) - ... for i in range(3) - ... ] - >>> obs_hyps = [ - ... h.simulate_similar( - ... scorer="Rater2", - ... seed=i, - ... ) - ... for i, h in enumerate(ref_hyps) - ... ] - >>> ebe = yasa.EpochByEpochAgreement( - ... ref_hyps, - ... obs_hyps, - ... ) + >>> ref_hyps = [yasa.simulate_hypnogram(tib=90, scorer="Rater1", seed=i) for i in range(3)] + >>> obs_hyps = [h.simulate_similar(scorer="Rater2", seed=i) for i, h in enumerate(ref_hyps)] + >>> ebe = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) >>> ebe.get_confusion_matrix(sleep_id=2) Rater2 WAKE N1 N2 N3 REM Rater1 @@ -702,19 +628,9 @@ def plot_hypnograms(self, sleep_id=None, legend=True, ax=None, ref_kwargs={}, ob -------- .. plot:: - >>> from yasa import ( - ... simulate_hypnogram, - ... ) - >>> hyp = simulate_hypnogram( - ... scorer="Anthony", - ... seed=19, - ... ) - >>> ax = hyp.evaluate( - ... hyp.simulate_similar( - ... scorer="Alan", - ... seed=68, - ... ) - ... ).plot_hypnograms() + >>> from yasa import simulate_hypnogram + >>> hyp = simulate_hypnogram(scorer="Anthony", seed=19) + >>> ax = hyp.evaluate(hyp.simulate_similar(scorer="Alan", seed=68)).plot_hypnograms() """ assert ( sleep_id is None or sleep_id in self._sleep_ids @@ -794,13 +710,7 @@ def summary(self, by_stage=False, **kwargs): each column is a descriptive statistic (e.g., mean, standard deviation). To control the descriptive statistics included as columns: - >>> ebe.summary( - ... func=[ - ... "count", - ... "mean", - ... "sem", - ... ] - ... ) + >>> ebe.summary(func=["count", "mean", "sem"]) """ assert self.n_sleeps > 1, "Summary scores can not be computed with only one hypnogram pair." assert isinstance(by_stage, bool), "`by_stage` must be True or False" @@ -908,34 +818,15 @@ class SleepStatsAgreement: >>> # Generate fake reference and observed datasets with similar sleep statistics >>> ref_scorer = "Henri" >>> obs_scorer = "Piéron" - >>> ref_hyps = [ - ... yasa.simulate_hypnogram( - ... tib=600, - ... scorer=ref_scorer, - ... seed=i, - ... ) - ... for i in range(20) - ... ] - >>> obs_hyps = [ - ... h.simulate_similar( - ... scorer=obs_scorer, - ... seed=i, - ... ) - ... for i, h in enumerate(ref_hyps) - ... ] + >>> ref_hyps = [yasa.simulate_hypnogram(tib=600, scorer=ref_scorer, seed=i) for i in range(20)] + >>> obs_hyps = [h.simulate_similar(scorer=obs_scorer, seed=i) for i, h in enumerate(ref_hyps)] >>> # Generate sleep statistics from hypnograms using EpochByEpochAgreement - >>> eea = yasa.EpochByEpochAgreement( - ... ref_hyps, - ... obs_hyps, - ... ) + >>> eea = yasa.EpochByEpochAgreement(ref_hyps, obs_hyps) >>> sstats = eea.get_sleep_stats() >>> ref_sstats = sstats.loc[ref_scorer] >>> obs_sstats = sstats.loc[obs_scorer] >>> # Create SleepStatsAgreement instance - >>> ssa = yasa.SleepStatsAgreement( - ... ref_sstats, - ... obs_sstats, - ... ) + >>> ssa = yasa.SleepStatsAgreement(ref_sstats, obs_sstats) >>> ssa.summary().round(1).head(3) variable bias_intercept ... uloa_parm interval center lower upper ... center lower upper @@ -965,31 +856,16 @@ class SleepStatsAgreement: %N2 regr regr parm %N3 parm regr parm - >>> ssa.get_table( - ... bias_method="parm", - ... loa_method="parm", - ... ).head(3)[["bias", "loa"]] + >>> ssa.get_table(bias_method="parm", loa_method="parm").head(3)[["bias", "loa"]] bias loa sleep_stat %N1 0.25 -5.55, 6.06 %N2 -0.23 -12.87, 12.40 %N3 1.38 -17.67, 20.44 - >>> new_hyps = [ - ... h.simulate_similar( - ... scorer="Kelly", - ... seed=i, - ... ) - ... for i, h in enumerate(obs_hyps) - ... ] + >>> new_hyps = [h.simulate_similar(scorer="Kelly", seed=i) for i, h in enumerate(obs_hyps)] >>> new_sstats = pd.Series(new_hyps).map(lambda h: h.sleep_statistics()).apply(pd.Series) - >>> new_sstats = new_sstats[ - ... [ - ... "N1", - ... "TST", - ... "WASO", - ... ] - ... ] + >>> new_sstats = new_sstats[["N1", "TST", "WASO"]] >>> new_sstats.round(1).head(5) N1 TST WASO 0 42.5 439.5 147.5 @@ -998,10 +874,7 @@ class SleepStatsAgreement: 3 57.0 469.5 120.0 4 71.0 531.0 69.0 - >>> new_stats_calibrated = ssa.calibrate_stats( - ... new_sstats, - ... bias_method="auto", - ... ) + >>> new_stats_calibrated = ssa.calibrate_stats(new_sstats, bias_method="auto") >>> new_stats_calibrated.round(1).head(5) N1 TST WASO 0 42.9 433.8 150.0 @@ -1589,27 +1462,13 @@ def get_calibration_func(self, sleep_stat): -------- >>> ssa = yasa.SleepStatsAgreement(...) >>> calibrate_rem = ssa.get_calibration_func("REM") - >>> new_obs_rem_vals = np.array( - ... [ - ... 50, - ... 40, - ... 30, - ... 20, - ... ] - ... ) + >>> new_obs_rem_vals = np.array([50, 40, 30, 20]) >>> calibrate_rem(new_obs_rem_vals) >>> calibrate_rem(new_obs_rem_vals) array([50, 40, 30, 20]) - >>> calibrate_rem( - ... new_obs_rem_vals, - ... bias_test=False, - ... ) + >>> calibrate_rem(new_obs_rem_vals, bias_test=False) array([42.825, 32.825, 22.825, 12.825]) - >>> calibrate_rem( - ... new_obs_rem_vals, - ... bias_test=False, - ... method="regr", - ... ) + >>> calibrate_rem(new_obs_rem_vals, bias_test=False, method="regr") array([ -9.33878878, -9.86815607, -10.39752335, -10.92689064]) """ assert isinstance(sleep_stat, str), "`sleep_stat` must be a string" diff --git a/src/yasa/features.py b/src/yasa/features.py index 42b1e95..cd2773e 100644 --- a/src/yasa/features.py +++ b/src/yasa/features.py @@ -17,16 +17,15 @@ Use at your own risk. """ -import logging - -import antropy as ant import mne +import yasa +import logging import numpy as np import pandas as pd +import antropy as ant import scipy.signal as sp_sig import scipy.stats as sp_stats -import yasa logger = logging.getLogger("yasa") diff --git a/src/yasa/heart.py b/src/yasa/heart.py index 2849e6a..fe7447e 100644 --- a/src/yasa/heart.py +++ b/src/yasa/heart.py @@ -6,13 +6,12 @@ """ import logging - import numpy as np import pandas as pd -from .detection import _check_data_hypno from .hypno import hypno_find_periods -from .io import is_sleepecg_installed, set_log_level +from .detection import _check_data_hypno +from .io import set_log_level, is_sleepecg_installed logger = logging.getLogger("yasa") diff --git a/src/yasa/hypno.py b/src/yasa/hypno.py index 95ff780..e782361 100644 --- a/src/yasa/hypno.py +++ b/src/yasa/hypno.py @@ -2,19 +2,17 @@ Hypnogram-related functions and class. """ -import logging - import mne +import logging # import warnings import numpy as np import pandas as pd -from pandas.api.types import CategoricalDtype - -from yasa.evaluation import EpochByEpochAgreement from yasa.io import set_log_level from yasa.plotting import plot_hypnogram from yasa.sleepstats import transition_matrix +from yasa.evaluation import EpochByEpochAgreement +from pandas.api.types import CategoricalDtype __all__ = [ "Hypnogram", @@ -83,27 +81,9 @@ class Hypnogram: -------- Create a 2-stages hypnogram - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> values = [ - ... "W", - ... "W", - ... "W", - ... "S", - ... "S", - ... "S", - ... "S", - ... "S", - ... "W", - ... "S", - ... "S", - ... "S", - ... ] - >>> hyp = Hypnogram( - ... values, - ... n_stages=2, - ... ) + >>> from yasa import Hypnogram + >>> values = ["W", "W", "W", "S", "S", "S", "S", "S", "W", "S", "S", "S"] + >>> hyp = Hypnogram(values, n_stages=2) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -186,16 +166,9 @@ class Hypnogram: Lastly, we set an actual start time to the hypnogram. As a result, the index of the resulting hypnogram is a :py:class:`pandas.DatetimeIndex`. - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> hyp = simulate_hypnogram( - ... tib=500, - ... n_stages=5, - ... start="2022-12-15 22:30:00", - ... scorer="S1", - ... seed=42, - ... ) + ... tib=500, n_stages=5, start="2022-12-15 22:30:00", scorer="S1", seed=42) >>> hyp - Use `.hypno` to get the string values as a pandas.Series @@ -439,21 +412,8 @@ def as_annotations(self): Examples -------- - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> hyp = Hypnogram( - ... [ - ... "W", - ... "W", - ... "LIGHT", - ... "LIGHT", - ... "DEEP", - ... "REM", - ... "WAKE", - ... ], - ... n_stages=4, - ... ) + >>> from yasa import Hypnogram + >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) >>> hyp.as_annotations() onset duration value description epoch @@ -488,30 +448,14 @@ def as_int(self): Users can define a custom mapping: - >>> hyp.mapping = { - ... "WAKE": 0, - ... "NREM": 1, - ... "REM": 2, - ... } + >>> hyp.mapping = {"WAKE": 0, "NREM": 1, "REM": 2} Examples -------- Convert a 2-stages hypnogram to a pandas.Series of integers - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> hyp = Hypnogram( - ... [ - ... "W", - ... "W", - ... "S", - ... "S", - ... "W", - ... "S", - ... ], - ... n_stages=2, - ... ) + >>> from yasa import Hypnogram + >>> hyp = Hypnogram(["W", "W", "S", "S", "W", "S"], n_stages=2) >>> hyp.as_int() Epoch 0 0 @@ -524,21 +468,8 @@ def as_int(self): Same with a 4-stages hypnogram - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> hyp = Hypnogram( - ... [ - ... "W", - ... "W", - ... "LIGHT", - ... "LIGHT", - ... "DEEP", - ... "REM", - ... "WAKE", - ... ], - ... n_stages=4, - ... ) + >>> from yasa import Hypnogram + >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) >>> hyp.as_int() Epoch 0 0 @@ -583,22 +514,8 @@ def consolidate_stages(self, new_n_stages): Examples -------- - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> hyp = Hypnogram( - ... [ - ... "W", - ... "W", - ... "N1", - ... "N2", - ... "N2", - ... "N2", - ... "N2", - ... "W", - ... ], - ... n_stages=5, - ... ) + >>> from yasa import Hypnogram + >>> hyp = Hypnogram(["W", "W", "N1", "N2", "N2", "N2", "N2", "W"], n_stages=5) >>> hyp_2s = hyp.consolidate_stages(2) >>> print(hyp_2s) Epoch @@ -676,18 +593,9 @@ def evaluate(self, obs_hyp): Examples -------- - >>> from yasa import ( - ... simulate_hypnogram, - ... ) - >>> hyp_a = simulate_hypnogram( - ... tib=90, - ... scorer="AASM", - ... seed=8, - ... ) - >>> hyp_b = hyp_a.simulate_similar( - ... scorer="YASA", - ... seed=9, - ... ) + >>> from yasa import simulate_hypnogram + >>> hyp_a = simulate_hypnogram(tib=90, scorer="AASM", seed=8) + >>> hyp_b = hyp_a.simulate_similar(scorer="YASA", seed=9) >>> ebe = hyp_a.evaluate(hyp_b) >>> ebe.get_agreement().round(3) accuracy 0.550 @@ -731,15 +639,9 @@ def find_periods(self, threshold="5min", equal_length=False): Let's assume that we have an hypnogram where sleep = 1 and wake = 0, with one value per minute. - >>> from yasa import ( - ... Hypnogram, - ... ) + >>> from yasa import Hypnogram >>> val = 11 * ["W"] + 3 * ["S"] + 2 * ["W"] + 9 * ["S"] + ["W", "W"] - >>> hyp = Hypnogram( - ... val, - ... n_stages=2, - ... freq="1min", - ... ) + >>> hyp = Hypnogram(val, n_stages=2, freq="1min") >>> hyp.find_periods(threshold="0min") values start length 0 WAKE 0 11 @@ -766,9 +668,7 @@ def find_periods(self, threshold="5min", equal_length=False): This function is not limited to binary arrays, e.g. a 5-stages hypnogram at 30-sec resolution: - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> hyp = simulate_hypnogram(tib=30, seed=42) >>> hyp.find_periods(threshold="2min") values start length @@ -779,10 +679,7 @@ def find_periods(self, threshold="5min", equal_length=False): Lastly, using ``equal_length=True`` will further divide the periods into segments of the same duration, i.e. the duration defined in ``threshold``: - >>> hyp.find_periods( - ... threshold="5min", - ... equal_length=True, - ... ) + >>> hyp.find_periods(threshold="5min", equal_length=True) values start length 0 N2 11 10 1 N2 21 10 @@ -817,9 +714,7 @@ def plot_hypnogram(self, **kwargs): -------- .. plot:: - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> ax = simulate_hypnogram(tib=480, seed=88).plot_hypnogram(highlight="REM") """ return plot_hypnogram(self, **kwargs) @@ -844,26 +739,11 @@ def simulate_similar(self, **kwargs): Examples -------- >>> import pandas as pd - >>> from yasa import ( - ... Hypnogram, - ... ) + >>> from yasa import Hypnogram >>> hyp = Hypnogram( - ... ["W", "S", "W"], - ... n_stages=2, - ... freq="2min", - ... scorer="Human", - ... ).upsample("30s") - >>> shyp = hyp.simulate_similar( - ... scorer="Simulated", - ... seed=6, - ... ) - >>> df = pd.concat( - ... [ - ... hyp.hypno, - ... shyp.hypno, - ... ], - ... axis=1, - ... ) + ... ["W", "S", "W"], n_stages=2, freq="2min", scorer="Human").upsample("30s") + >>> shyp = hyp.simulate_similar(scorer="Simulated", seed=6) + >>> df = pd.concat([hyp.hypno, shyp.hypno], axis=1) >>> print(df) Human Simulated Epoch @@ -952,16 +832,10 @@ def sleep_statistics(self): -------- Sleep statistics for a 2-stage hypnogram with a resolution of 15-seconds - >>> from yasa import ( - ... Hypnogram, - ... ) + >>> from yasa import Hypnogram >>> # Generate a fake hypnogram, where "S" = Sleep, "W" = Wake >>> values = 10 * ["W"] + 40 * ["S"] + 5 * ["W"] + 40 * ["S"] + 9 * ["W"] - >>> hyp = Hypnogram( - ... values, - ... freq="15s", - ... n_stages=2, - ... ) + >>> hyp = Hypnogram(values, freq="15s", n_stages=2) >>> hyp.sleep_statistics() {'TIB': 26.0, 'SPT': 21.25, @@ -976,9 +850,7 @@ def sleep_statistics(self): Sleep statistics for a 5-stages hypnogram - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution >>> hyp = simulate_hypnogram(tib=480, seed=42) >>> hyp.sleep_statistics() @@ -1109,10 +981,7 @@ def transition_matrix(self): Examples -------- - >>> from yasa import ( - ... Hypnogram, - ... simulate_hypnogram, - ... ) + >>> from yasa import Hypnogram, simulate_hypnogram >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution >>> hyp = simulate_hypnogram(tib=480, seed=42) >>> counts, probs = hyp.transition_matrix() @@ -1163,20 +1032,8 @@ def upsample(self, new_freq, **kwargs): -------- Create a 30-sec hypnogram - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> hyp = Hypnogram( - ... [ - ... "W", - ... "W", - ... "S", - ... "S", - ... "W", - ... ], - ... n_stages=2, - ... start="2022-12-23 23:00", - ... ) + >>> from yasa import Hypnogram + >>> hyp = Hypnogram(["W", "W", "S", "S", "W"], n_stages=2, start="2022-12-23 23:00") >>> hyp.hypno Time 2022-12-23 23:00:00 WAKE @@ -1622,40 +1479,8 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): minute, and therefore the sampling frequency of the hypnogram is 1 / 60 sec (~0.016 Hz). >>> import yasa - >>> hypno = [ - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 1, - ... 1, - ... 1, - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... ] - >>> yasa.hypno_find_periods( - ... hypno, - ... sf_hypno=1 / 60, - ... threshold="0min", - ... ) + >>> hypno = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0] + >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="0min") values start length 0 0 0 11 1 1 11 3 @@ -1670,11 +1495,7 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): Now, we may want to keep only periods that are longer than a specific threshold, for example 5 minutes: - >>> yasa.hypno_find_periods( - ... hypno, - ... sf_hypno=1 / 60, - ... threshold="5min", - ... ) + >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="5min") values start length 0 0 0 11 1 1 16 9 @@ -1684,30 +1505,8 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): This function is not limited to binary arrays, e.g. - >>> hypno = [ - ... 0, - ... 0, - ... 0, - ... 0, - ... 1, - ... 2, - ... 2, - ... 2, - ... 2, - ... 2, - ... 2, - ... 0, - ... 0, - ... 0, - ... 1, - ... 0, - ... 1, - ... ] - >>> yasa.hypno_find_periods( - ... hypno, - ... sf_hypno=1 / 60, - ... threshold="2min", - ... ) + >>> hypno = [0, 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 0, 0, 0, 1, 0, 1] + >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="2min") values start length 0 0 0 4 1 2 5 6 @@ -1716,31 +1515,8 @@ def hypno_find_periods(hypno, sf_hypno, threshold="5min", equal_length=False): Lastly, using ``equal_length=True`` will further divide the periods into segments of the same duration, i.e. the duration defined in ``threshold``: - >>> hypno = [ - ... 0, - ... 0, - ... 0, - ... 0, - ... 1, - ... 2, - ... 2, - ... 2, - ... 2, - ... 2, - ... 2, - ... 0, - ... 0, - ... 0, - ... 1, - ... 0, - ... 1, - ... ] - >>> yasa.hypno_find_periods( - ... hypno, - ... sf_hypno=1 / 60, - ... threshold="2min", - ... equal_length=True, - ... ) + >>> hypno = [0, 0, 0, 0, 1, 2, 2, 2, 2, 2, 2, 0, 0, 0, 1, 0, 1] + >>> yasa.hypno_find_periods(hypno, sf_hypno=1/60, threshold="2min", equal_length=True) values start length 0 0 0 2 1 0 2 2 @@ -1898,9 +1674,7 @@ def simulate_hypnogram( Examples -------- - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> hyp = simulate_hypnogram(tib=5, seed=1) >>> hyp @@ -1923,11 +1697,7 @@ def simulate_hypnogram( 9 N2 Name: Stage, dtype: object - >>> hyp = simulate_hypnogram( - ... tib=5, - ... n_stages=2, - ... seed=1, - ... ) + >>> hyp = simulate_hypnogram(tib=5, n_stages=2, seed=1) >>> hyp.hypno Epoch 0 WAKE @@ -1944,11 +1714,7 @@ def simulate_hypnogram( Add some Unscored epochs. - >>> hyp = simulate_hypnogram( - ... tib=5, - ... n_stages=2, - ... seed=1, - ... ) + >>> hyp = simulate_hypnogram(tib=5, n_stages=2, seed=1) >>> hyp.hypno.iloc[-2:] = "UNS" >>> hyp.hypno Epoch @@ -1970,10 +1736,7 @@ def simulate_hypnogram( >>> import numpy as np >>> import matplotlib.pyplot as plt - >>> from yasa import ( - ... Hypnogram, - ... hypno_int_to_str, - ... ) + >>> from yasa import Hypnogram, hypno_int_to_str >>> url = ( >>> "https://github.com/raphaelvallat/yasa/raw/master/" >>> "notebooks/data_full_6hrs_100Hz_hypno_30s.txt" @@ -1981,13 +1744,7 @@ def simulate_hypnogram( >>> values_str = hypno_int_to_str(np.loadtxt(url)) >>> real_hyp = Hypnogram(values_str) >>> fake_hyp = real_hyp.simulate_similar(seed=2) - >>> fig, (ax1, ax2) = plt.subplots( - ... nrows=2, - ... figsize=( - ... 7, - ... 5, - ... ), - ... ) + >>> fig, (ax1, ax2) = plt.subplots(nrows=2, figsize=(7, 5)) >>> real_hyp.plot_hypnogram(ax=ax1).set_title("Real hypnogram") >>> fake_hyp.plot_hypnogram(ax=ax2).set_title("Fake hypnogram") >>> plt.tight_layout() diff --git a/src/yasa/io.py b/src/yasa/io.py index 9d0d2b0..169f2c9 100644 --- a/src/yasa/io.py +++ b/src/yasa/io.py @@ -2,6 +2,7 @@ import logging + LOGGING_TYPES = dict( DEBUG=logging.DEBUG, INFO=logging.INFO, diff --git a/src/yasa/others.py b/src/yasa/others.py index 9bbc087..af80928 100644 --- a/src/yasa/others.py +++ b/src/yasa/others.py @@ -3,11 +3,9 @@ """ import logging - import numpy as np from scipy.interpolate import interp1d - -from .numba import _corr, _covar, _rms, _slope_lstsq +from .numba import _slope_lstsq, _covar, _corr, _rms logger = logging.getLogger("yasa") @@ -242,22 +240,8 @@ def _zerocrossings(x): Examples -------- >>> import numpy as np - >>> from yasa.main import ( - ... _zerocrossings, - ... ) - >>> a = np.array( - ... [ - ... 4, - ... 2, - ... -1, - ... -3, - ... 1, - ... 2, - ... 3, - ... -2, - ... -5, - ... ] - ... ) + >>> from yasa.main import _zerocrossings + >>> a = np.array([4, 2, -1, -3, 1, 2, 3, -2, -5]) >>> _zerocrossings(a) array([1, 3, 6], dtype=int64) """ @@ -339,15 +323,9 @@ def sliding_window(data, sf, window, step=None, axis=-1): With a 1-D array >>> import numpy as np - >>> from yasa import ( - ... sliding_window, - ... ) + >>> from yasa import sliding_window >>> data = np.arange(20) - >>> times, epochs = sliding_window( - ... data, - ... sf=1, - ... window=5, - ... ) + >>> times, epochs = sliding_window(data, sf=1, window=5) >>> times array([ 0., 5., 10., 15.]) @@ -357,12 +335,7 @@ def sliding_window(data, sf, window, step=None, axis=-1): [10, 11, 12, 13, 14], [15, 16, 17, 18, 19]]) - >>> sliding_window( - ... data, - ... sf=1, - ... window=5, - ... step=1, - ... )[1] + >>> sliding_window(data, sf=1, window=5, step=1)[1] array([[ 0, 1, 2, 3, 4], [ 2, 3, 4, 5, 6], [ 4, 5, 6, 7, 8], @@ -372,27 +345,15 @@ def sliding_window(data, sf, window, step=None, axis=-1): [12, 13, 14, 15, 16], [14, 15, 16, 17, 18]]) - >>> sliding_window( - ... data, - ... sf=1, - ... window=11, - ... )[1] + >>> sliding_window(data, sf=1, window=11)[1] array([[ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]]) With a N-D array >>> np.random.seed(42) >>> # 4 channels x 20 samples - >>> data = np.random.randint( - ... -100, - ... 100, - ... size=(4, 20), - ... ) - >>> epochs = sliding_window( - ... data, - ... sf=1, - ... window=10, - ... )[1] + >>> data = np.random.randint(-100, 100, size=(4, 20)) + >>> epochs = sliding_window(data, sf=1, window=10)[1] >>> epochs.shape # shape (n_epochs, n_channels, n_samples) (2, 4, 10) @@ -476,29 +437,12 @@ def get_centered_indices(data, idx, npts_before, npts_after): Examples -------- >>> import numpy as np - >>> from yasa import ( - ... get_centered_indices, - ... ) + >>> from yasa import get_centered_indices >>> np.random.seed(123) >>> data = np.random.normal(size=100).round(2) - >>> idx = [ - ... 1.0, - ... 10.0, - ... 20.0, - ... 30.0, - ... 50.0, - ... 102, - ... ] + >>> idx = [1., 10., 20., 30., 50., 102] >>> before, after = 3, 2 - >>> ( - ... idx_ep, - ... idx_nomask, - ... ) = get_centered_indices( - ... data, - ... idx, - ... before, - ... after, - ... ) + >>> idx_ep, idx_nomask = get_centered_indices(data, idx, before, after) >>> idx_ep array([[ 7, 8, 9, 10, 11, 12], [17, 18, 19, 20, 21, 22], diff --git a/src/yasa/plotting.py b/src/yasa/plotting.py index e4b920c..70fab24 100644 --- a/src/yasa/plotting.py +++ b/src/yasa/plotting.py @@ -2,14 +2,14 @@ Plotting functions of YASA. """ -import matplotlib.dates as mdates -import matplotlib.pyplot as plt import mne import numpy as np import pandas as pd import seaborn as sns +import matplotlib.pyplot as plt +import matplotlib.dates as mdates from lspopt import spectrogram_lspopt -from matplotlib.colors import ListedColormap, Normalize +from matplotlib.colors import Normalize, ListedColormap __all__ = ["plot_hypnogram", "plot_spectrogram", "topoplot"] @@ -58,9 +58,7 @@ def plot_hypnogram(hyp, sf_hypno=1 / 30, highlight="REM", fill_color=None, ax=No -------- .. plot:: - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> import matplotlib.pyplot as plt >>> hyp = simulate_hypnogram(tib=300, seed=11) >>> ax = hyp.plot_hypnogram() @@ -68,67 +66,21 @@ def plot_hypnogram(hyp, sf_hypno=1 / 30, highlight="REM", fill_color=None, ax=No .. plot:: - >>> from yasa import ( - ... Hypnogram, - ... ) - >>> values = 4 * [ - ... "W", - ... "N1", - ... "N2", - ... "N3", - ... "REM", - ... ] + [ - ... "ART", - ... "N2", - ... "REM", - ... "W", - ... "UNS", - ... ] - >>> hyp = Hypnogram( - ... values, - ... freq="24min", - ... ).upsample("30s") - >>> ax = hyp.plot_hypnogram( - ... lw=2, - ... fill_color="thistle", - ... ) + >>> from yasa import Hypnogram + >>> values = 4 * ["W", "N1", "N2", "N3", "REM"] + ["ART", "N2", "REM", "W", "UNS"] + >>> hyp = Hypnogram(values, freq="24min").upsample("30s") + >>> ax = hyp.plot_hypnogram(lw=2, fill_color="thistle") >>> plt.tight_layout() .. plot:: - >>> from yasa import ( - ... simulate_hypnogram, - ... ) + >>> from yasa import simulate_hypnogram >>> import matplotlib.pyplot as plt - >>> fig, axes = plt.subplots( - ... nrows=2, - ... figsize=( - ... 6, - ... 4, - ... ), - ... constrained_layout=True, - ... ) - >>> hyp_a = simulate_hypnogram( - ... n_stages=3, - ... seed=99, - ... ) - >>> hyp_b = simulate_hypnogram( - ... n_stages=3, - ... seed=99, - ... start="2022-01-31 23:30:00", - ... ) - >>> hyp_a.plot_hypnogram( - ... lw=1, - ... fill_color="whitesmoke", - ... highlight=None, - ... ax=axes[0], - ... ) - >>> hyp_b.plot_hypnogram( - ... lw=1, - ... fill_color="whitesmoke", - ... highlight=None, - ... ax=axes[1], - ... ) + >>> fig, axes = plt.subplots(nrows=2, figsize=(6, 4), constrained_layout=True) + >>> hyp_a = simulate_hypnogram(n_stages=3, seed=99) + >>> hyp_b = simulate_hypnogram(n_stages=3, seed=99, start="2022-01-31 23:30:00") + >>> hyp_a.plot_hypnogram(lw=1, fill_color="whitesmoke", highlight=None, ax=axes[0]) + >>> hyp_b.plot_hypnogram(lw=1, fill_color="whitesmoke", highlight=None, ax=axes[1]) """ from yasa.hypno import Hypnogram, hypno_int_to_str # Avoiding circular imports @@ -295,15 +247,10 @@ def plot_spectrogram( >>> import numpy as np >>> # In the next 5 lines, we're loading the data from GitHub. >>> import requests - >>> from io import ( - ... BytesIO, - ... ) - >>> r = requests.get( - ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", - ... stream=True, - ... ) + >>> from io import BytesIO + >>> r = requests.get('https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz', stream=True) >>> npz = np.load(BytesIO(r.raw.read())) - >>> data = npz.get("data")[0, :] + >>> data = npz.get('data')[0, :] >>> sf = 100 >>> fig = yasa.plot_spectrogram(data, sf) @@ -315,32 +262,15 @@ def plot_spectrogram( >>> import numpy as np >>> # In the next lines, we're loading the data from GitHub. >>> import requests - >>> from io import ( - ... BytesIO, - ... ) - >>> r = requests.get( - ... "https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz", - ... stream=True, - ... ) + >>> from io import BytesIO + >>> r = requests.get('https://github.com/raphaelvallat/yasa/raw/master/notebooks/data_full_6hrs_100Hz_Cz%2BFz%2BPz.npz', stream=True) >>> npz = np.load(BytesIO(r.raw.read())) - >>> data = npz.get("data")[0, :] + >>> data = npz.get('data')[0, :] >>> sf = 100 >>> # Load the 30-sec hypnogram and upsample to data - >>> hypno = np.loadtxt( - ... "https://raw.githubusercontent.com/raphaelvallat/yasa/master/notebooks/data_full_6hrs_100Hz_hypno_30s.txt" - ... ) - >>> hypno = yasa.hypno_upsample_to_data( - ... hypno, - ... 1 / 30, - ... data, - ... sf, - ... ) - >>> fig = yasa.plot_spectrogram( - ... data, - ... sf, - ... hypno, - ... cmap="Spectral_r", - ... ) + >>> hypno = np.loadtxt('https://raw.githubusercontent.com/raphaelvallat/yasa/master/notebooks/data_full_6hrs_100Hz_hypno_30s.txt') + >>> hypno = yasa.hypno_upsample_to_data(hypno, 1/30, data, sf) + >>> fig = yasa.plot_spectrogram(data, sf, hypno, cmap='Spectral_r') """ from yasa.hypno import Hypnogram, hypno_int_to_str # Avoiding circular imports @@ -493,31 +423,10 @@ def topoplot( >>> import yasa >>> import pandas as pd - >>> data = pd.Series( - ... [ - ... 4, - ... 8, - ... 7, - ... 1, - ... 2, - ... 3, - ... 5, - ... ], - ... index=[ - ... "F4", - ... "F3", - ... "C4", - ... "C3", - ... "P3", - ... "P4", - ... "Oz", - ... ], - ... name="Values", - ... ) - >>> fig = yasa.topoplot( - ... data, - ... title="My first topoplot", - ... ) + >>> data = pd.Series([4, 8, 7, 1, 2, 3, 5], + ... index=['F4', 'F3', 'C4', 'C3', 'P3', 'P4', 'Oz'], + ... name='Values') + >>> fig = yasa.topoplot(data, title='My first topoplot') 2. Plot correlation coefficients (values ranging from -1 to 1) @@ -525,33 +434,10 @@ def topoplot( >>> import yasa >>> import pandas as pd - >>> data = pd.Series( - ... [ - ... -0.5, - ... -0.7, - ... -0.3, - ... 0.1, - ... 0.15, - ... 0.3, - ... 0.55, - ... ], - ... index=[ - ... "F3", - ... "Fz", - ... "F4", - ... "C3", - ... "Cz", - ... "C4", - ... "Pz", - ... ], - ... ) - >>> fig = yasa.topoplot( - ... data, - ... vmin=-1, - ... vmax=1, - ... n_colors=8, - ... cbar_title="Pearson correlation", - ... ) + >>> data = pd.Series([-0.5, -0.7, -0.3, 0.1, 0.15, 0.3, 0.55], + ... index=['F3', 'Fz', 'F4', 'C3', 'Cz', 'C4', 'Pz']) + >>> fig = yasa.topoplot(data, vmin=-1, vmax=1, n_colors=8, + ... cbar_title="Pearson correlation") """ # Increase font size while preserving original old_fontsize = plt.rcParams["font.size"] diff --git a/src/yasa/push_pypi.md b/src/yasa/push_pypi.md deleted file mode 100644 index b415546..0000000 --- a/src/yasa/push_pypi.md +++ /dev/null @@ -1,7 +0,0 @@ -# Build and upload a new version of YASA - -```bash -python setup.py sdist -python setup.py sdist bdist_wheel --universal -twine upload dist/* -``` diff --git a/src/yasa/sleepstats.py b/src/yasa/sleepstats.py index 07305ca..99643cf 100644 --- a/src/yasa/sleepstats.py +++ b/src/yasa/sleepstats.py @@ -49,33 +49,8 @@ def transition_matrix(hypno): Examples -------- >>> import numpy as np - >>> from yasa import ( - ... transition_matrix, - ... ) - >>> a = [ - ... 0, - ... 0, - ... 0, - ... 1, - ... 1, - ... 0, - ... 1, - ... 2, - ... 2, - ... 3, - ... 3, - ... 2, - ... 3, - ... 3, - ... 0, - ... 2, - ... 2, - ... 1, - ... 2, - ... 2, - ... 3, - ... 3, - ... ] + >>> from yasa import transition_matrix + >>> a = [0, 0, 0, 1, 1, 0, 1, 2, 2, 3, 3, 2, 3, 3, 0, 2, 2, 1, 2, 2, 3, 3] >>> counts, probs = transition_matrix(a) >>> counts 0 1 2 3 @@ -108,64 +83,22 @@ def transition_matrix(hypno): >>> import numpy as np >>> import seaborn as sns >>> import matplotlib.pyplot as plt - >>> from yasa import ( - ... transition_matrix, - ... ) + >>> from yasa import transition_matrix >>> # Calculate probability matrix - >>> a = [ - ... 1, - ... 1, - ... 1, - ... 0, - ... 0, - ... 2, - ... 2, - ... 0, - ... 2, - ... 0, - ... 1, - ... 1, - ... 0, - ... 0, - ... ] + >>> a = [1, 1, 1, 0, 0, 2, 2, 0, 2, 0, 1, 1, 0, 0] >>> _, probs = transition_matrix(a) >>> # Start the plot - >>> grid_kws = { - ... "height_ratios": ( - ... 0.9, - ... 0.05, - ... ), - ... "hspace": 0.1, - ... } - >>> f, (ax, cbar_ax) = plt.subplots( - ... 2, - ... gridspec_kw=grid_kws, - ... figsize=( - ... 5, - ... 5, - ... ), - ... ) - >>> sns.heatmap( - ... probs, - ... ax=ax, - ... square=False, - ... vmin=0, - ... vmax=1, - ... cbar=True, - ... cbar_ax=cbar_ax, - ... cmap="YlOrRd", - ... annot=True, - ... fmt=".2f", - ... cbar_kws={ - ... "orientation": "horizontal", - ... "fraction": 0.1, - ... "label": "Transition probability", - ... }, - ... ) + >>> grid_kws = {"height_ratios": (.9, .05), "hspace": .1} + >>> f, (ax, cbar_ax) = plt.subplots(2, gridspec_kw=grid_kws, + ... figsize=(5, 5)) + >>> sns.heatmap(probs, ax=ax, square=False, vmin=0, vmax=1, cbar=True, + ... cbar_ax=cbar_ax, cmap='YlOrRd', annot=True, fmt='.2f', + ... cbar_kws={"orientation": "horizontal", "fraction": 0.1, + ... "label": "Transition probability"}) >>> ax.set_xlabel("To sleep stage") >>> ax.xaxis.tick_top() >>> ax.set_ylabel("From sleep stage") - >>> ax.xaxis.set_label_position("top") + >>> ax.xaxis.set_label_position('top') """ # NOTE: FutureWarning not added here otherwise it would also be shown when calling # yasa.Hypnogram.transition_matrix @@ -268,36 +201,10 @@ def sleep_statistics(hypno, sf_hyp): Examples -------- - >>> from yasa import ( - ... sleep_statistics, - ... ) - >>> hypno = [ - ... 0, - ... 0, - ... 1, - ... 1, - ... 1, - ... 2, - ... 2, - ... 2, - ... 3, - ... 3, - ... 3, - ... 2, - ... 3, - ... 3, - ... 4, - ... 4, - ... 4, - ... 4, - ... 0, - ... 0, - ... ] + >>> from yasa import sleep_statistics + >>> hypno = [0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 2, 3, 3, 4, 4, 4, 4, 0, 0] >>> # Assuming that we have one-value per 30-second. - >>> sleep_statistics( - ... hypno, - ... sf_hyp=1 / 30, - ... ) + >>> sleep_statistics(hypno, sf_hyp=1/30) {'TIB': 10.0, 'SPT': 8.0, 'WASO': 0.0, diff --git a/src/yasa/spectral.py b/src/yasa/spectral.py index 3bf5723..139d225 100644 --- a/src/yasa/spectral.py +++ b/src/yasa/spectral.py @@ -3,15 +3,13 @@ 1D and 2D EEG data. """ -import logging - import mne +import logging import numpy as np import pandas as pd from scipy import signal from scipy.integrate import simpson from scipy.interpolate import RectBivariateSpline - from .io import set_log_level logger = logging.getLogger("yasa") @@ -153,7 +151,7 @@ def bandpower( assert hypno.size == npts, "Hypno must have same size as data.shape[1]" assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.isin( + assert np.in1d( hypno, include ).any(), "None of the stages specified in `include` are present in hypno." # Initialize empty dataframe and loop over stages diff --git a/src/yasa/staging.py b/src/yasa/staging.py index 05a3f83..7b6df76 100644 --- a/src/yasa/staging.py +++ b/src/yasa/staging.py @@ -1,19 +1,17 @@ """Automatic sleep staging of polysomnography data.""" -import glob -import logging import os - -import antropy as ant -import joblib -import matplotlib.pyplot as plt import mne +import glob +import joblib +import logging import numpy as np import pandas as pd +import antropy as ant import scipy.signal as sp_sig import scipy.stats as sp_stats +import matplotlib.pyplot as plt from mne.filter import filter_data -from scipy.integrate import trapezoid from sklearn.preprocessing import robust_scale from .others import sliding_window @@ -141,21 +139,11 @@ class SleepStaging: >>> import mne >>> import yasa >>> # Load an EDF file using MNE - >>> raw = mne.io.read_raw_edf( - ... "myfile.edf", - ... preload=True, - ... ) + >>> raw = mne.io.read_raw_edf("myfile.edf", preload=True) >>> # Initialize the sleep staging instance - >>> sls = yasa.SleepStaging( - ... raw, - ... eeg_name="C4-M1", - ... eog_name="LOC-M2", - ... emg_name="EMG1-EMG2", - ... metadata=dict( - ... age=29, - ... male=True, - ... ), - ... ) + >>> sls = yasa.SleepStaging(raw, eeg_name="C4-M1", eog_name="LOC-M2", + ... emg_name="EMG1-EMG2", + ... metadata=dict(age=29, male=True)) >>> # Get the predicted sleep stages >>> hypno = sls.predict() >>> # Get the predicted probabilities @@ -301,7 +289,7 @@ def fit(self): # Add total power idx_broad = np.logical_and(freqs >= freq_broad[0], freqs <= freq_broad[1]) dx = freqs[1] - freqs[0] - feat["abspow"] = trapezoid(psd[:, idx_broad], dx=dx) + feat["abspow"] = np.trapz(psd[:, idx_broad], dx=dx) # Calculate entropy and fractal dimension features feat["perm"] = np.apply_along_axis(ant.perm_entropy, axis=1, arr=epochs, normalize=True) From 35fd3f73e0014fd9640283883813834d88ea0ccb Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:03:18 +0100 Subject: [PATCH 07/19] disable py312 --- .github/workflows/python_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index 2a7e534..b070d75 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, windows-latest] # macos-latest - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11"] # lspopt failure on "3.12", see PR187 runs-on: ${{ matrix.platform }} From 3bcf7b7f04cc140d0b983b1a124d75f5b3831b1d Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:07:28 +0100 Subject: [PATCH 08/19] ruff fixes --- src/yasa/__init__.py | 1 + src/yasa/detection.py | 34 +++++++++++++++++----------------- src/yasa/evaluation.py | 3 +-- src/yasa/features.py | 7 ++++--- src/yasa/heart.py | 5 +++-- src/yasa/hypno.py | 8 +++++--- src/yasa/io.py | 1 - src/yasa/others.py | 4 +++- src/yasa/plotting.py | 6 +++--- src/yasa/spectral.py | 6 ++++-- src/yasa/staging.py | 14 ++++++++------ 11 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/yasa/__init__.py b/src/yasa/__init__.py index a750a04..93f92f7 100644 --- a/src/yasa/__init__.py +++ b/src/yasa/__init__.py @@ -1,4 +1,5 @@ import logging + from .detection import * from .evaluation import * from .features import * diff --git a/src/yasa/detection.py b/src/yasa/detection.py index 29e4ab0..18e6204 100644 --- a/src/yasa/detection.py +++ b/src/yasa/detection.py @@ -7,29 +7,29 @@ - License: BSD 3-Clause License """ -import mne import logging +from collections import OrderedDict + +import mne import numpy as np import pandas as pd -from scipy import signal from mne.filter import filter_data -from collections import OrderedDict -from scipy.interpolate import interp1d +from scipy import signal from scipy.fftpack import next_fast_len +from scipy.interpolate import interp1d from sklearn.ensemble import IsolationForest -from .spectral import stft_power +from .io import is_pyriemann_installed, is_tensorpac_installed, set_log_level from .numba import _detrend, _rms -from .io import set_log_level, is_tensorpac_installed, is_pyriemann_installed from .others import ( - moving_transform, - trimbothstd, - get_centered_indices, - sliding_window, _merge_close, _zerocrossings, + get_centered_indices, + moving_transform, + sliding_window, + trimbothstd, ) - +from .spectral import stft_power logger = logging.getLogger("yasa") @@ -86,7 +86,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch include = np.atleast_1d(np.asarray(include)) assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.in1d(hypno, include).any(), ( + assert np.isin(hypno, include).any(), ( "None of the stages specified " "in `include` are present in " "hypno." ) @@ -110,7 +110,7 @@ def _check_data_hypno(data, sf=None, ch_names=None, hypno=None, include=None, ch # 5) Create sleep stage vector mask if hypno is not None: - mask = np.in1d(hypno, include) + mask = np.isin(hypno, include) else: mask = np.ones(n_samples, dtype=bool) @@ -458,8 +458,8 @@ def plot_average( **kwargs, ): """Plot the average event (not for REM, spindles & SW only)""" - import seaborn as sns import matplotlib.pyplot as plt + import seaborn as sns df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -485,8 +485,8 @@ def plot_average( def plot_detection(self): """Plot an overlay of the detected events on the signal.""" - import matplotlib.pyplot as plt import ipywidgets as ipy + import matplotlib.pyplot as plt # Define mask sf = self._sf @@ -2780,8 +2780,8 @@ def plot_average( **kwargs : dict Optional argument that are passed to :py:func:`seaborn.lineplot`. """ - import seaborn as sns import matplotlib.pyplot as plt + import seaborn as sns df_sync = self.get_sync_events( center=center, time_before=time_before, time_after=time_after, filt=filt, mask=mask @@ -3024,8 +3024,8 @@ def art_detect( if method in ["cov", "covar", "covariance", "riemann", "potato"]: method = "covar" is_pyriemann_installed() - from pyriemann.estimation import Covariances, Shrinkage from pyriemann.clustering import Potato + from pyriemann.estimation import Covariances, Shrinkage # Must have at least 4 channels to use method='covar' if n_chan <= 4: diff --git a/src/yasa/evaluation.py b/src/yasa/evaluation.py index 09c9e1f..e67cb7b 100644 --- a/src/yasa/evaluation.py +++ b/src/yasa/evaluation.py @@ -16,7 +16,6 @@ import scipy.stats as sps import sklearn.metrics as skm - logger = logging.getLogger("yasa") __all__ = [ @@ -219,7 +218,7 @@ def __init__(self, ref_hyps, obs_hyps): # Generate some mapping dictionaries to be used later in class methods skm_labels = np.unique(data).tolist() # all unique YASA integer codes in this hypno - skm2yasa_map = {i: l for i, l in enumerate(skm_labels)} # skm order to YASA integers + skm2yasa_map = {i: lab for i, lab in enumerate(skm_labels)} # skm order to YASA integers yasa2yasa_map = ref_hyps[sleep_ids[0]].mapping_int.copy() # YASA integer to YASA string # Set attributes diff --git a/src/yasa/features.py b/src/yasa/features.py index cd2773e..42b1e95 100644 --- a/src/yasa/features.py +++ b/src/yasa/features.py @@ -17,15 +17,16 @@ Use at your own risk. """ -import mne -import yasa import logging + +import antropy as ant +import mne import numpy as np import pandas as pd -import antropy as ant import scipy.signal as sp_sig import scipy.stats as sp_stats +import yasa logger = logging.getLogger("yasa") diff --git a/src/yasa/heart.py b/src/yasa/heart.py index fe7447e..2849e6a 100644 --- a/src/yasa/heart.py +++ b/src/yasa/heart.py @@ -6,12 +6,13 @@ """ import logging + import numpy as np import pandas as pd -from .hypno import hypno_find_periods from .detection import _check_data_hypno -from .io import set_log_level, is_sleepecg_installed +from .hypno import hypno_find_periods +from .io import is_sleepecg_installed, set_log_level logger = logging.getLogger("yasa") diff --git a/src/yasa/hypno.py b/src/yasa/hypno.py index e782361..a6d72db 100644 --- a/src/yasa/hypno.py +++ b/src/yasa/hypno.py @@ -2,17 +2,19 @@ Hypnogram-related functions and class. """ -import mne import logging +import mne + # import warnings import numpy as np import pandas as pd +from pandas.api.types import CategoricalDtype + +from yasa.evaluation import EpochByEpochAgreement from yasa.io import set_log_level from yasa.plotting import plot_hypnogram from yasa.sleepstats import transition_matrix -from yasa.evaluation import EpochByEpochAgreement -from pandas.api.types import CategoricalDtype __all__ = [ "Hypnogram", diff --git a/src/yasa/io.py b/src/yasa/io.py index 169f2c9..9d0d2b0 100644 --- a/src/yasa/io.py +++ b/src/yasa/io.py @@ -2,7 +2,6 @@ import logging - LOGGING_TYPES = dict( DEBUG=logging.DEBUG, INFO=logging.INFO, diff --git a/src/yasa/others.py b/src/yasa/others.py index af80928..effc459 100644 --- a/src/yasa/others.py +++ b/src/yasa/others.py @@ -3,9 +3,11 @@ """ import logging + import numpy as np from scipy.interpolate import interp1d -from .numba import _slope_lstsq, _covar, _corr, _rms + +from .numba import _corr, _covar, _rms, _slope_lstsq logger = logging.getLogger("yasa") diff --git a/src/yasa/plotting.py b/src/yasa/plotting.py index 70fab24..9177617 100644 --- a/src/yasa/plotting.py +++ b/src/yasa/plotting.py @@ -2,14 +2,14 @@ Plotting functions of YASA. """ +import matplotlib.dates as mdates +import matplotlib.pyplot as plt import mne import numpy as np import pandas as pd import seaborn as sns -import matplotlib.pyplot as plt -import matplotlib.dates as mdates from lspopt import spectrogram_lspopt -from matplotlib.colors import Normalize, ListedColormap +from matplotlib.colors import ListedColormap, Normalize __all__ = ["plot_hypnogram", "plot_spectrogram", "topoplot"] diff --git a/src/yasa/spectral.py b/src/yasa/spectral.py index 139d225..3bf5723 100644 --- a/src/yasa/spectral.py +++ b/src/yasa/spectral.py @@ -3,13 +3,15 @@ 1D and 2D EEG data. """ -import mne import logging + +import mne import numpy as np import pandas as pd from scipy import signal from scipy.integrate import simpson from scipy.interpolate import RectBivariateSpline + from .io import set_log_level logger = logging.getLogger("yasa") @@ -151,7 +153,7 @@ def bandpower( assert hypno.size == npts, "Hypno must have same size as data.shape[1]" assert include.size >= 1, "`include` must have at least one element." assert hypno.dtype.kind == include.dtype.kind, "hypno and include must have same dtype" - assert np.in1d( + assert np.isin( hypno, include ).any(), "None of the stages specified in `include` are present in hypno." # Initialize empty dataframe and loop over stages diff --git a/src/yasa/staging.py b/src/yasa/staging.py index 7b6df76..e66218b 100644 --- a/src/yasa/staging.py +++ b/src/yasa/staging.py @@ -1,17 +1,19 @@ """Automatic sleep staging of polysomnography data.""" -import os -import mne import glob -import joblib import logging +import os + +import antropy as ant +import joblib +import matplotlib.pyplot as plt +import mne import numpy as np import pandas as pd -import antropy as ant import scipy.signal as sp_sig import scipy.stats as sp_stats -import matplotlib.pyplot as plt from mne.filter import filter_data +from scipy.integrate import trapezoid from sklearn.preprocessing import robust_scale from .others import sliding_window @@ -289,7 +291,7 @@ def fit(self): # Add total power idx_broad = np.logical_and(freqs >= freq_broad[0], freqs <= freq_broad[1]) dx = freqs[1] - freqs[0] - feat["abspow"] = np.trapz(psd[:, idx_broad], dx=dx) + feat["abspow"] = trapezoid(psd[:, idx_broad], dx=dx) # Calculate entropy and fractal dimension features feat["perm"] = np.apply_along_axis(ant.perm_entropy, axis=1, arr=epochs, normalize=True) From d9d95dfc69ee16a4fc8b83302e6f9fe654e9e240 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:31:22 +0100 Subject: [PATCH 09/19] re-enable macos --- .github/workflows/python_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index b070d75..b36e595 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, windows-latest] # macos-latest + platform: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.9", "3.10", "3.11"] # lspopt failure on "3.12", see PR187 runs-on: ${{ matrix.platform }} From 1c9f166664c1461eb4697e4f369edb9ef8d73813 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:37:29 +0100 Subject: [PATCH 10/19] disable macos --- .github/workflows/python_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index b36e595..e8e4823 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, windows-latest, macos-latest] + platform: [ubuntu-latest, windows-latest] # macos-latest disabled because of lightgbm fail python-version: ["3.9", "3.10", "3.11"] # lspopt failure on "3.12", see PR187 runs-on: ${{ matrix.platform }} From 8b7a19e07b9fbde5e914a2c689a78a618625c46e Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:41:46 +0100 Subject: [PATCH 11/19] fix missing line code block rst --- README.rst | 1 + docs/index.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/README.rst b/README.rst index 7a07c31..c4fa2c2 100644 --- a/README.rst +++ b/README.rst @@ -55,6 +55,7 @@ Alternatively, YASA can be installed with conda: To build and install from source, clone this repository or download the source archive and decompress the files .. code-block:: shell + cd yasa pip install ".[test]" # install the package pip install --editable ".[test]" # or editable install diff --git a/docs/index.rst b/docs/index.rst index 6e7d00b..cd06a17 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,6 +55,7 @@ Alternatively, YASA can be installed with conda: To build and install from source, clone this repository or download the source archive and decompress the files .. code-block:: shell + cd yasa pip install ".[test]" # install the package pip install -e ".[test]" # or editable install From f9e8bd2ab96b29abe829b3c6d63869416a34c987 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:43:47 +0100 Subject: [PATCH 12/19] add tests folder to coverage --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 358597d..2fd8ff6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ omit = [ source_pkgs = ["yasa"] [tool.coverage.paths] -source = ["src"] +source = ["src", "tests"] [tool.coverage.report] show_missing = true From 42fa120b71d0dae37ab12538d92d97047942f4b0 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sat, 21 Dec 2024 13:49:42 +0100 Subject: [PATCH 13/19] remove test folder from coverage --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2fd8ff6..358597d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -102,7 +102,7 @@ omit = [ source_pkgs = ["yasa"] [tool.coverage.paths] -source = ["src", "tests"] +source = ["src"] [tool.coverage.report] show_missing = true From f8cd1612fd8e10845399a1b65e7f0e90a88693d7 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Dec 2024 13:18:24 +0100 Subject: [PATCH 14/19] add libomp for macos-latest --- .github/workflows/python_tests.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index e8e4823..e104c46 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - platform: [ubuntu-latest, windows-latest] # macos-latest disabled because of lightgbm fail + platform: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.9", "3.10", "3.11"] # lspopt failure on "3.12", see PR187 runs-on: ${{ matrix.platform }} @@ -27,6 +27,10 @@ jobs: with: python-version: ${{ matrix.python-version }} + - name: Install libomp (macOS) + if: matrix.os == 'macos-latest' + run: brew install libomp + - name: Install dependencies run: | python -m pip install --upgrade pip From 975ee063731d95d0080cf9bfe14de6499ee6f56b Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Dec 2024 13:24:33 +0100 Subject: [PATCH 15/19] 2nd attempt --- .github/workflows/python_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index e104c46..d7d04ff 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -28,7 +28,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install libomp (macOS) - if: matrix.os == 'macos-latest' + if: ${{ matrix.platform == 'macos-latest' }} run: brew install libomp - name: Install dependencies From 4a639d9b2ba28a3139f5eff99b8662931ddc93ee Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Dec 2024 13:42:50 +0100 Subject: [PATCH 16/19] re-enable lspopt --- .github/workflows/python_tests.yml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python_tests.yml b/.github/workflows/python_tests.yml index d7d04ff..b7c8090 100644 --- a/.github/workflows/python_tests.yml +++ b/.github/workflows/python_tests.yml @@ -12,7 +12,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ["3.9", "3.10", "3.11"] # lspopt failure on "3.12", see PR187 + python-version: ["3.9", "3.10", "3.11", "3.12"] runs-on: ${{ matrix.platform }} diff --git a/pyproject.toml b/pyproject.toml index 358597d..aef6169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=70.0", "wheel"] build-backend = "setuptools.build_meta" [project] From 2b542700425747a8cb22497daf14514982e11074 Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Dec 2024 13:45:28 +0100 Subject: [PATCH 17/19] add setuptools dep --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index aef6169..f2c02bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "tensorpac>=0.6.5", "pyriemann>=0.2.7", "sleepecg>=0.5.0", + "setuptools>=70", "lspopt", "ipywidgets", "joblib", From 319a0fe412fc0119a704672abfddfd3be21440fc Mon Sep 17 00:00:00 2001 From: Raphael Vallat Date: Sun, 22 Dec 2024 13:51:34 +0100 Subject: [PATCH 18/19] fix numpy deprecation --- src/yasa/others.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/yasa/others.py b/src/yasa/others.py index effc459..1be821f 100644 --- a/src/yasa/others.py +++ b/src/yasa/others.py @@ -474,7 +474,7 @@ def get_centered_indices(data, idx, npts_before, npts_after): def rng(x): """Create a range before and after a given value.""" - return np.arange(x - npts_before, x + npts_after + 1, dtype="int") + return np.arange(x[0] - npts_before, x[0] + npts_after + 1, dtype="int") idx_ep = np.apply_along_axis(rng, 1, idx[..., np.newaxis]) # We drop the events for which the indices exceed data From f3c06475df039576ce1b9265d93636219e6f435b Mon Sep 17 00:00:00 2001 From: Remington Mallett Date: Fri, 27 Dec 2024 19:18:31 -0500 Subject: [PATCH 19/19] Merge branch 'master' into modern_packaging * Including manual conflict resolution on [test_]staging.py --- .gitignore | 2 + docs/changelog.rst | 15 +- notebooks/14_automatic_sleep_staging.ipynb | 906 ++++++++++++++++----- src/yasa/hypno.py | 135 +-- src/yasa/staging.py | 84 +- tests/test_hypnoclass.py | 4 +- tests/test_staging.py | 13 +- 7 files changed, 868 insertions(+), 291 deletions(-) diff --git a/.gitignore b/.gitignore index 346afd4..59c1196 100644 --- a/.gitignore +++ b/.gitignore @@ -142,4 +142,6 @@ notebooks/20_catch_errors.ipynb *.pptx # Custom +*/.virtual_documents/ notebooks/debug* +notebooks/my_hypno.csv \ No newline at end of file diff --git a/docs/changelog.rst b/docs/changelog.rst index cb78872..3914562 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -29,18 +29,23 @@ which comes with several pre-built functions (aka methods) and attributes. See f hyp.duration # Total duration of the hypnogram, in minutes hyp.sampling_frequency # Sampling frequency of the hypnogram hyp.mapping # Mapping from strings to integers + hyp.proba # Probability of each sleep stage, if specified # Below are some class methods hyp.sleep_statistics() # Calculate the sleep statistics hyp.plot_hypnogram() # Plot the hypnogram hyp.upsample_to_data() # Upsample to data -Please see the documentation of :py:class:`yasa.Hypnogram` for more details. +This brings along critical changes to several YASA function, for example: -.. important:: - The adoption of object-oriented :py:class:`yasa.Hypnogram` usage brings along critical changes to several YASA function, for example: +* :py:class:`yasa.SleepStaging` now returns a :py:class:`yasa.Hypnogram` instead of a :py:class:`numpy.ndarray`. The probability of each sleep stage for each epoch can now be accessed with :py:attr:`yasa.Hypnogram.proba`. +* :py:func:`yasa.simulate_hypnogram` now returns a :py:class:`yasa.Hypnogram` instead of a :py:class:`numpy.ndarray`. +* The suggested approach to plotting hypnograms is through the :py:meth:`yasa.Hypnogram.plot_hypnogram` method. The old function :py:func:`yasa.plot_hypnogram` still exists, but now *requires* a :py:class:`yasa.Hypnogram` instance as input. + +**Other improvements** - * :py:func:`yasa.simulate_hypnogram` now returns a :py:class:`yasa.Hypnogram` instead of a :py:class:`numpy.ndarray`. - * The suggested approach to plotting hypnograms is through the :py:meth:`yasa.Hypnogram.plot_hypnogram` method. The old function :py:func:`yasa.plot_hypnogram` still exists, but now *requires* a :py:class:`yasa.Hypnogram` instance as input. +* Added helpful string representation (__repr__) to :py:class:`yasa.SleepStaging`. +* :py:func:`yasa.simulate_hypnogram` now returns a :py:class:`yasa.Hypnogram` instead of a :py:class:`numpy.ndarray`. +* The suggested approach to plotting hypnograms is through the :py:meth:`yasa.Hypnogram.plot_hypnogram` method. The old function :py:func:`yasa.plot_hypnogram` still exists, but now *requires* a :py:class:`yasa.Hypnogram` instance as input. ---------------------------------------------------------------------------------------- diff --git a/notebooks/14_automatic_sleep_staging.ipynb b/notebooks/14_automatic_sleep_staging.ipynb index 3aad21e..2ad5e87 100644 --- a/notebooks/14_automatic_sleep_staging.ipynb +++ b/notebooks/14_automatic_sleep_staging.ipynb @@ -49,65 +49,362 @@ { "data": { "text/html": [ + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", - " \n", - " \n", - "\n", - " \n", - " \n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " sub-02_mne_raw.fif\n", + " \n", + " \n", + " \n", + "\n", "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
\n", + " \n", + " \n", + " General\n", + "
Filename(s)\n", " \n", - "
ExperimenterUnknown
ParticipantUnknown
Digitized points15 points
Good channels6 EEG, 2 EOG, 1 EMG
Bad channelsNone
EOG channelsEOG1, EOG2
ECG channelsNot available
Sampling frequency100.00 Hz
Highpass0.00 Hz
Lowpass50.00 Hz
Filenamessub-02_mne_raw.fif
Duration00:48:59 (HH:MM:SS)
\n" + "\n", + " \n", + " MNE object type\n", + " Raw\n", + "\n", + "\n", + " \n", + " Measurement date\n", + " \n", + " 2016-01-15 at 14:01:00 UTC\n", + " \n", + "\n", + "\n", + " \n", + " Participant\n", + " \n", + " Unknown\n", + " \n", + "\n", + "\n", + " \n", + " Experimenter\n", + " \n", + " Unknown\n", + " \n", + "\n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " Acquisition\n", + " \n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Duration\n", + " 00:49:00 (HH:MM:SS)\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Sampling frequency\n", + " 100.00 Hz\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Time points\n", + " 294,000\n", + "\n", + "\n", + "\n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " Channels\n", + " \n", + "\n", + "\n", + "\n", + " \n", + "\n", + " \n", + " EEG\n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + "\n", + " \n", + "\n", + " \n", + " EOG\n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + "\n", + " \n", + "\n", + " \n", + " EMG\n", + " \n", + " \n", + "\n", + " \n", + " \n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Head & sensor digitization\n", + " \n", + " 15 points\n", + " \n", + "\n", + " \n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " Filters\n", + " \n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Highpass\n", + " 0.00 Hz\n", + "\n", + "\n", + "\n", + "\n", + " \n", + " Lowpass\n", + " 50.00 Hz\n", + "\n", + "\n", + "\n", + "" ], "text/plain": [ - "" + "" ] }, "execution_count": 2, @@ -149,8 +446,66 @@ ], "source": [ "# Let's now load the human-scored hypnogram, where each value represents a 30-sec epoch.\n", - "hypno = np.loadtxt('sub-02_hypno_30s.txt', dtype=str)\n", - "hypno" + "hyp = np.loadtxt('sub-02_hypno_30s.txt', dtype=str)\n", + "hyp" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Epoch\n", + "0 WAKE\n", + "1 WAKE\n", + "2 WAKE\n", + "3 WAKE\n", + "4 WAKE\n", + " ... \n", + "93 WAKE\n", + "94 WAKE\n", + "95 WAKE\n", + "96 WAKE\n", + "97 WAKE\n", + "Name: Stage, Length: 98, dtype: category\n", + "Categories (7, object): ['WAKE', 'N1', 'N2', 'N3', 'REM', 'ART', 'UNS']" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Convert it to a Hypnogram instance, which is the preferred way to manipulate hypnograms since v0.7\n", + "hyp = yasa.Hypnogram(hyp, freq=\"30s\")\n", + "# The hypnogram values can be obtained with\n", + "hyp.hypno" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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AAQY26/kbNy4Ufv379cHH3yg+fPnq6SkxOdjH3zwQU2bNk0PPPCAgoJsfykAACCABEwzGDVqlB566CEtWLDA52PuvvtuPfLII5oxY4YfkwEAgPbI9ttVP5eTk6OYmBjV1tZ6n8n5uU8++aTJdkhIiB599NE2TAgAANoLW0tOWVlZk+1evXqptrZWktTQ0NDsMS6XS8ePH292X2FhYWvGAwAA7VjA3K4CAABoTZQcAABgJEoOAAAwEiUHAAAYiZIDAACMRMkBAABGouQAAAAjUXIAAICRKDkAAMBIlBwAAGAkSg4AADASJQcAABiJkgMAAIxEyQEAAEai5AAAACNRcgAAgJEoOQAAwEiUHAAAYCRKDgAAMBIlBwAAGImSAwAAjETJAQAARqLkAAAAI11SyXnrrbe0bNkySdK3336rkpISv4QCAAC4XD6XnIULF+of//iHVq9eff7AoCDNmDHDb8EAAAAuh88l580331R+fr66d+8uSbr++ut1+vRpvwUDAAC4HD6XnK5du6pTp07+zAIAANBqgn39wb59++qjjz6Sw+FQY2OjnnrqKSUmJvozGwAAQIv5XHJWrlypKVOmaO/everWrZuGDx+u9evX+zMbAABAi/lccsLDw7VlyxbV19ersbFRPXr08GcuAACAy+JzyXn33XcvGAsNDdWAAQMUGhraqqEAAAAul88lZ/Hixdq5c6eSkpIkSSUlJXK73aqoqNALL7ygO+64w28hAQAALpXPf10VFRWloqIi7dmzR3v27FFRUZGSk5NVUFCgBQsW+DMjAADAJfO55JSUlCglJcW7nZycrM8++0w33nijLMvySzhJcrlcio+PV0NDg3csLS1NhYWFeuedd5SWlqaQkBA98sgjfssAAADaH59LTrdu3fTKK694t1955RUFBZ0/3OFwtH6yn/nxxx+977T8c7GxsVq9erXmzp3r1/kBAED74/MzOS+++KKysrI0bdo0BQUFqX///nrppZdUX1+vv//97/7MqEWLFumxxx5TVlaWunXr5h2Pi4uTJG3atMmv8yPwlJeXKzMz84Kx2NhYv8/j63G+ZmnpHL8lMjJSq1atarXzNZextecAgNbmc8m58cYbtWvXLp0+fVqWZalnz57efaNHj/ZLuJ+kpKRoxIgRWrFihR5//PEWncPj8cjj8Xi36+rqWise2lh0dHSz47Gx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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Let's plot it\n", + "fig, ax = plt.subplots(1, 1, figsize=(7, 3), constrained_layout=True, dpi=80)\n", + "ax = hyp.plot_hypnogram(fill_color=\"gainsboro\", ax=ax)" ] }, { @@ -164,9 +519,20 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# We first need to specify the channel names and, optionally, the age and sex of the participant\n", "# - \"raw\" is the name of the variable containing the polysomnography data loaded with MNE.\n", @@ -174,38 +540,35 @@ "# - \"eog_name\" is the name of the EOG channel (e.g. LOC-M1). This is optional.\n", "# - \"eog_name\" is the name of the EOG channel (e.g. EMG1-EMG3). This is optional.\n", "# - \"metadata\" is a dictionary containing the age and sex of the participant. This is optional.\n", - "sls = yasa.SleepStaging(raw, eeg_name=\"C4\", eog_name=\"EOG1\", emg_name=\"EMG1\", metadata=dict(age=21, male=False))" + "sls = yasa.SleepStaging(raw, eeg_name=\"C4\", eog_name=\"EOG1\", emg_name=\"EMG1\", metadata=dict(age=21, male=False))\n", + "sls" ] }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 7, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "/Users/raphael/.pyenv/versions/3.8.3/lib/python3.8/site-packages/sklearn/base.py:329: UserWarning: Trying to unpickle estimator LabelEncoder from version 0.24.2 when using version 1.0.2. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:\n", - "https://scikit-learn.org/stable/modules/model_persistence.html#security-maintainability-limitations\n", + "/opt/anaconda3/lib/python3.12/site-packages/sklearn/base.py:376: InconsistentVersionWarning: Trying to unpickle estimator LabelEncoder from version 0.24.2 when using version 1.5.1. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:\n", + "https://scikit-learn.org/stable/model_persistence.html#security-maintainability-limitations\n", " warnings.warn(\n" ] }, { "data": { "text/plain": [ - "array(['W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',\n", - " 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',\n", - " 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'N2', 'N2', 'N2', 'N2',\n", - " 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2',\n", - " 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2',\n", - " 'N2', 'N2', 'N2', 'N3', 'N3', 'N3', 'N3', 'N2', 'N3', 'N3', 'N3',\n", - " 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3',\n", - " 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'N3', 'W', 'W', 'W', 'W',\n", - " 'W', 'W', 'W', 'W'], dtype=object)" + "\n", + " - Use `.hypno` to get the string values as a pandas.Series\n", + " - Use `.as_int()` to get the integer values as a pandas.Series\n", + " - Use `.plot_hypnogram()` to plot the hypnogram\n", + "See the online documentation for more details." ] }, - "execution_count": 5, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -218,21 +581,122 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "Epoch\n", + "0 WAKE\n", + "1 WAKE\n", + "2 WAKE\n", + "3 WAKE\n", + "4 WAKE\n", + " ... \n", + "93 WAKE\n", + "94 WAKE\n", + "95 WAKE\n", + "96 WAKE\n", + "97 WAKE\n", + "Name: Stage, Length: 98, dtype: category\n", + "Categories (7, object): ['WAKE', 'N1', 'N2', 'N3', 'REM', 'ART', 'UNS']" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "y_pred.hypno" + ] + }, + { + "cell_type": "code", + "execution_count": 9, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ - "The overall agreement is 0.837\n" + "The overall agreement is 83.67%\n" ] } ], "source": [ "# What is the accuracy of the prediction, compared to the human scoring\n", - "accuracy = (hypno == y_pred).sum() / y_pred.size\n", - "print(\"The overall agreement is %.3f\" % accuracy)" + "accuracy = 100 * (hyp.hypno == y_pred.hypno).mean()\n", + "print(f\"The overall agreement is {accuracy:.2f}%\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Plot and sleep statistics**" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Plot the predicted hypnogram\n", + "fig, ax = plt.subplots(1, 1, figsize=(7, 3), constrained_layout=True, dpi=80)\n", + "ax = y_pred.plot_hypnogram(fill_color=\"gainsboro\", ax=ax)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'TIB': 49.0,\n", + " 'SPT': 28.0,\n", + " 'WASO': 0.0,\n", + " 'TST': 28.0,\n", + " 'SE': 57.1429,\n", + " 'SME': 100.0,\n", + " 'SFI': 1.0714,\n", + " 'SOL': 17.0,\n", + " 'SOL_5min': 17.0,\n", + " 'Lat_REM': nan,\n", + " 'WAKE': 21.0,\n", + " 'N1': 0.0,\n", + " 'N2': 15.0,\n", + " 'N3': 13.0,\n", + " 'REM': 0.0,\n", + " '%N1': 0.0,\n", + " '%N2': 53.5714,\n", + " '%N3': 46.4286,\n", + " '%REM': 0.0}" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Calculate the summary sleep statistics of the predicted hypnogram\n", + "y_pred.sleep_statistics()" ] }, { @@ -244,9 +708,17 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 12, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/raphael/GitHub/yasa/yasa/staging.py:484: FutureWarning: The `predict_proba` function is deprecated and will be removed in v0.8. The predicted probabilities can now be accessed with `yasa.Hypnogram.proba` instead, e.g `SleepStaging.predict().proba`\n", + " warnings.warn(\n" + ] + }, { "data": { "text/html": [ @@ -271,11 +743,11 @@ " N1\n", " N2\n", " N3\n", - " R\n", - " W\n", + " REM\n", + " WAKE\n", " \n", " \n", - " epoch\n", + " Epoch\n", " \n", " \n", " \n", @@ -286,43 +758,43 @@ " \n", " \n", " 0\n", - " 0.002202\n", - " 0.005040\n", - " 0.000703\n", - " 1.875966e-18\n", - " 0.992055\n", + " 0.002170\n", + " 0.005012\n", + " 0.000683\n", + " 1.772861e-18\n", + " 0.992135\n", " \n", " \n", " 1\n", - " 0.003362\n", - " 0.003284\n", - " 0.001926\n", - " 8.279263e-05\n", - " 0.991345\n", + " 0.002470\n", + " 0.003121\n", + " 0.002585\n", + " 8.013632e-05\n", + " 0.991744\n", " \n", " \n", " 2\n", - " 0.004078\n", - " 0.003225\n", - " 0.000095\n", - " 7.688612e-04\n", - " 0.991833\n", + " 0.003882\n", + " 0.003285\n", + " 0.000097\n", + " 6.435026e-04\n", + " 0.992092\n", " \n", " \n", " 3\n", - " 0.001918\n", - " 0.001771\n", - " 0.000052\n", - " 7.023297e-04\n", - " 0.995557\n", + " 0.001994\n", + " 0.001806\n", + " 0.000051\n", + " 6.712369e-04\n", + " 0.995478\n", " \n", " \n", " 4\n", - " 0.002624\n", - " 0.007565\n", - " 0.000221\n", - " 5.963933e-04\n", - " 0.988994\n", + " 0.002609\n", + " 0.008254\n", + " 0.000255\n", + " 5.924781e-04\n", + " 0.988289\n", " \n", " \n", " ...\n", @@ -334,43 +806,43 @@ " \n", " \n", " 93\n", - " 0.004001\n", - " 0.009041\n", - " 0.004678\n", - " 9.823759e-05\n", - " 0.982182\n", + " 0.003944\n", + " 0.009049\n", + " 0.004683\n", + " 9.824195e-05\n", + " 0.982225\n", " \n", " \n", " 94\n", - " 0.001910\n", - " 0.028894\n", - " 0.136638\n", - " 2.746406e-04\n", - " 0.832283\n", + " 0.002002\n", + " 0.029846\n", + " 0.135356\n", + " 2.641568e-04\n", + " 0.832531\n", " \n", " \n", " 95\n", - " 0.001399\n", - " 0.001958\n", - " 0.000488\n", - " 4.246366e-05\n", - " 0.996112\n", + " 0.001389\n", + " 0.001854\n", + " 0.000503\n", + " 4.100423e-05\n", + " 0.996213\n", " \n", " \n", " 96\n", - " 0.001948\n", - " 0.000891\n", - " 0.000094\n", - " 6.057920e-05\n", - " 0.997007\n", + " 0.001921\n", + " 0.000878\n", + " 0.000088\n", + " 5.482605e-05\n", + " 0.997057\n", " \n", " \n", " 97\n", - " 0.000845\n", - " 0.001049\n", - " 0.000028\n", - " 3.148597e-05\n", - " 0.998046\n", + " 0.000855\n", + " 0.000934\n", + " 0.000024\n", + " 2.945145e-05\n", + " 0.998157\n", " \n", " \n", "\n", @@ -378,48 +850,47 @@ "" ], "text/plain": [ - " N1 N2 N3 R W\n", - "epoch \n", - "0 0.002202 0.005040 0.000703 1.875966e-18 0.992055\n", - "1 0.003362 0.003284 0.001926 8.279263e-05 0.991345\n", - "2 0.004078 0.003225 0.000095 7.688612e-04 0.991833\n", - "3 0.001918 0.001771 0.000052 7.023297e-04 0.995557\n", - "4 0.002624 0.007565 0.000221 5.963933e-04 0.988994\n", + " N1 N2 N3 REM WAKE\n", + "Epoch \n", + "0 0.002170 0.005012 0.000683 1.772861e-18 0.992135\n", + "1 0.002470 0.003121 0.002585 8.013632e-05 0.991744\n", + "2 0.003882 0.003285 0.000097 6.435026e-04 0.992092\n", + "3 0.001994 0.001806 0.000051 6.712369e-04 0.995478\n", + "4 0.002609 0.008254 0.000255 5.924781e-04 0.988289\n", "... ... ... ... ... ...\n", - "93 0.004001 0.009041 0.004678 9.823759e-05 0.982182\n", - "94 0.001910 0.028894 0.136638 2.746406e-04 0.832283\n", - "95 0.001399 0.001958 0.000488 4.246366e-05 0.996112\n", - "96 0.001948 0.000891 0.000094 6.057920e-05 0.997007\n", - "97 0.000845 0.001049 0.000028 3.148597e-05 0.998046\n", + "93 0.003944 0.009049 0.004683 9.824195e-05 0.982225\n", + "94 0.002002 0.029846 0.135356 2.641568e-04 0.832531\n", + "95 0.001389 0.001854 0.000503 4.100423e-05 0.996213\n", + "96 0.001921 0.000878 0.000088 5.482605e-05 0.997057\n", + "97 0.000855 0.000934 0.000024 2.945145e-05 0.998157\n", "\n", "[98 rows x 5 columns]" ] }, - "execution_count": 7, + "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# What are the predicted probabilities of each sleep stage at each epoch?\n", - "sls.predict_proba()" + "proba = sls.predict_proba()\n", + "proba" ] }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 13, "metadata": {}, "outputs": [ { "data": { - "image/png": "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\n", + "image/png": "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", "text/plain": [ - "
" + "
" ] }, - "metadata": { - "needs_background": "light" - }, + "metadata": {}, "output_type": "display_data" } ], @@ -430,35 +901,35 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 14, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "epoch\n", - "0 0.992055\n", - "1 0.991345\n", - "2 0.991833\n", - "3 0.995557\n", - "4 0.988994\n", + "Epoch\n", + "0 0.992135\n", + "1 0.991744\n", + "2 0.992092\n", + "3 0.995478\n", + "4 0.988289\n", " ... \n", - "93 0.982182\n", - "94 0.832283\n", - "95 0.996112\n", - "96 0.997007\n", - "97 0.998046\n", + "93 0.982225\n", + "94 0.832531\n", + "95 0.996213\n", + "96 0.997057\n", + "97 0.998157\n", "Length: 98, dtype: float64" ] }, - "execution_count": 9, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# From the probabilities, we can extract a confidence level (ranging from 0 to 1) for each epoch.\n", - "confidence = sls.predict_proba().max(1)\n", + "confidence = proba.max(1)\n", "confidence" ] }, @@ -471,7 +942,17 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "# The predicted sleep stages can be exported to a CSV file with:\n", + "hyp.hypno.to_csv(\"my_hypno.csv\")" + ] + }, + { + "cell_type": "code", + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -499,7 +980,7 @@ " Confidence\n", " \n", " \n", - " epoch\n", + " Epoch\n", " \n", " \n", " \n", @@ -507,33 +988,33 @@ " \n", " \n", " 0\n", - " W\n", - " 0.992055\n", + " WAKE\n", + " 0.992135\n", " \n", " \n", " 1\n", - " W\n", - " 0.991345\n", + " WAKE\n", + " 0.991744\n", " \n", " \n", " 2\n", - " W\n", - " 0.991833\n", + " WAKE\n", + " 0.992092\n", " \n", " \n", " 3\n", - " W\n", - " 0.995557\n", + " WAKE\n", + " 0.995478\n", " \n", " \n", " 4\n", - " W\n", - " 0.988994\n", + " WAKE\n", + " 0.988289\n", " \n", " \n", " 5\n", - " W\n", - " 0.986805\n", + " WAKE\n", + " 0.987672\n", " \n", " \n", "\n", @@ -541,27 +1022,35 @@ ], "text/plain": [ " Stage Confidence\n", - "epoch \n", - "0 W 0.992055\n", - "1 W 0.991345\n", - "2 W 0.991833\n", - "3 W 0.995557\n", - "4 W 0.988994\n", - "5 W 0.986805" + "Epoch \n", + "0 WAKE 0.992135\n", + "1 WAKE 0.991744\n", + "2 WAKE 0.992092\n", + "3 WAKE 0.995478\n", + "4 WAKE 0.988289\n", + "5 WAKE 0.987672" ] }, - "execution_count": 10, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "# Let's first create a dataframe with the predicted stages and confidence\n", - "df_pred = pd.DataFrame({'Stage': y_pred, 'Confidence': confidence})\n", - "df_pred.head(6)\n", - "\n", + "# We can also add the confidence level:\n", + "df_pred = hyp.hypno.to_frame()\n", + "df_pred[\"Confidence\"] = confidence\n", + "df_pred.head(6)" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [], + "source": [ "# Now export to a CSV file\n", - "# df_pred.to_csv(\"my_hypno.csv\")" + "df_pred.to_csv(\"my_hypno.csv\")" ] }, { @@ -573,33 +1062,38 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 18, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "/Users/raphael/.pyenv/versions/3.8.3/lib/python3.8/site-packages/sklearn/base.py:329: UserWarning: Trying to unpickle estimator LabelEncoder from version 0.24.2 when using version 1.0.2. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:\n", - "https://scikit-learn.org/stable/modules/model_persistence.html#security-maintainability-limitations\n", + "/opt/anaconda3/lib/python3.12/site-packages/sklearn/base.py:376: InconsistentVersionWarning: Trying to unpickle estimator LabelEncoder from version 0.24.2 when using version 1.5.1. This might lead to breaking code or invalid results. Use at your own risk. For more info please refer to:\n", + "https://scikit-learn.org/stable/model_persistence.html#security-maintainability-limitations\n", " warnings.warn(\n" ] }, { "data": { "text/plain": [ - "array(['W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',\n", - " 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W', 'W',\n", - " 'W', 'W', 'N1', 'N2', 'W', 'W', 'N2', 'N2', 'R', 'N2', 'R', 'R',\n", - " 'N2', 'R', 'R', 'N2', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'R', 'N2',\n", - " 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2',\n", - " 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N2', 'N3', 'N2', 'N2',\n", - " 'N3', 'N2', 'N2', 'N3', 'N2', 'N3', 'N2', 'N2', 'N2', 'N3', 'N3',\n", - " 'N3', 'N2', 'N3', 'N2', 'N3', 'N3', 'W', 'N3', 'W', 'W', 'W', 'W',\n", - " 'W', 'W'], dtype=object)" + "Epoch\n", + "0 WAKE\n", + "1 WAKE\n", + "2 WAKE\n", + "3 WAKE\n", + "4 WAKE\n", + " ... \n", + "93 WAKE\n", + "94 WAKE\n", + "95 WAKE\n", + "96 WAKE\n", + "97 WAKE\n", + "Name: Stage, Length: 98, dtype: category\n", + "Categories (7, object): ['WAKE', 'N1', 'N2', 'N3', 'REM', 'ART', 'UNS']" ] }, - "execution_count": 11, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -607,13 +1101,13 @@ "source": [ "# Using just an EEG channel (= no EOG or EMG)\n", "y_pred = yasa.SleepStaging(raw, eeg_name=\"C4\").predict()\n", - "y_pred" + "y_pred.hypno" ] } ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -627,7 +1121,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.8.3" + "version": "3.12.7" } }, "nbformat": 4, diff --git a/src/yasa/hypno.py b/src/yasa/hypno.py index a6d72db..7ad913f 100644 --- a/src/yasa/hypno.py +++ b/src/yasa/hypno.py @@ -48,10 +48,10 @@ class Hypnogram: values : array_like A vector of stage values, represented as strings. See some examples below: - * 2-stages hypnogram (Wake/Sleep): ``["W", "S", "S", "W", "S"]`` - * 3-stages (Wake/NREM/REM): ``pd.Series(["WAKE", "NREM", "NREM", "REM", "REM"])`` - * 4-stages (Wake/Light/Deep/REM): ``np.array(["Wake", "Light", "Deep", "Deep"])`` - * 5-stages (default): ``["N1", "N1", "N2", "N3", "N2", "REM", "W"]`` + * 2-stage hypnogram (Wake/Sleep): ``["W", "S", "S", "W", "S"]`` + * 3-stage (Wake/NREM/REM): ``pd.Series(["WAKE", "NREM", "NREM", "REM", "REM"])`` + * 4-stage (Wake/Light/Deep/REM): ``np.array(["Wake", "Light", "Deep", "Deep"])`` + * 5-stage (default): ``["N1", "N1", "N2", "N3", "N2", "REM", "W"]`` Artefacts ("Art") and unscored ("Uns") epochs are always allowed regardless of the number of stages in the hypnogram. @@ -60,7 +60,7 @@ class Hypnogram: lower/upper/mixed case. Internally, YASA will convert the stages to to full spelling and uppercase (e.g. "w" -> "WAKE"). n_stages : int - Whether ``values`` comes from a 2, 3, 4 or 5-stages hypnogram. Default is 5 stages, meaning + Whether ``values`` comes from a 2, 3, 4 or 5-stage hypnogram. Default is 5-stage, meaning that the following sleep stages are allowed: N1, N2, N3, REM, WAKE. freq : str A pandas frequency string indicating the frequency resolution of the hypnogram. Default is @@ -78,16 +78,20 @@ class Hypnogram: scorer : str An optional string indicating the scorer name. If specified, this will be set as the name of the :py:class:`pandas.Series`, otherwise the name will be set to "Stage". + proba : :py:class:`pandas.DataFrame` + An optional dataframe with the probability of each sleep stage for each epoch in hypnogram. + Each row must sum to 1. This is automatically included if the hypnogram is created with + :py:class:`yasa.SleepStaging`. Examples -------- - Create a 2-stages hypnogram + Create a 2-stage hypnogram >>> from yasa import Hypnogram >>> values = ["W", "W", "W", "S", "S", "S", "S", "S", "W", "S", "S", "S"] >>> hyp = Hypnogram(values, n_stages=2) >>> hyp - + - Use `.hypno` to get the string values as a pandas.Series - Use `.as_int()` to get the integer values as a pandas.Series - Use `.plot_hypnogram()` to plot the hypnogram @@ -162,8 +166,8 @@ class Hypnogram: WAKE 2 2 SLEEP 1 6 - All these methods and properties are also valid with a 5-stages hypnogram. In the example below, - we use the :py:func:`yasa.simulate_hypnogram` to generate a plausible 5-stages hypnogram with a + All these methods and properties are also valid with a 5-stage hypnogram. In the example below, + we use the :py:func:`yasa.simulate_hypnogram` to generate a plausible 5-stage hypnogram with a 30-seconds resolution. A random seed is specified to ensure that we get reproducible results. Lastly, we set an actual start time to the hypnogram. As a result, the index of the resulting hypnogram is a :py:class:`pandas.DatetimeIndex`. @@ -172,7 +176,7 @@ class Hypnogram: >>> hyp = simulate_hypnogram( ... tib=500, n_stages=5, start="2022-12-15 22:30:00", scorer="S1", seed=42) >>> hyp - + - Use `.hypno` to get the string values as a pandas.Series - Use `.as_int()` to get the integer values as a pandas.Series - Use `.plot_hypnogram()` to plot the hypnogram @@ -194,7 +198,7 @@ class Hypnogram: Freq: 30S, Name: S1, Length: 1000, dtype: category Categories (7, object): ['WAKE', 'N1', 'N2', 'N3', 'REM', 'ART', 'UNS'] - The summary sleep statistics will include more items with a 5-stages hypnogram than a 2-stages + The summary sleep statistics will include more items with a 5-stage hypnogram than a 2-stage hypnogram, i.e. the amount and percentage of each sleep stage, the REM latency, etc. >>> hyp.sleep_statistics() @@ -219,10 +223,14 @@ class Hypnogram: '%REM': 8.9713} """ - def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None): + def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None, proba=None): assert isinstance( values, (list, np.ndarray, pd.Series) ), "`values` must be a list, numpy.array or pandas.Series" + assert all(isinstance(val, str) for val in values), ( + "Since v0.7, YASA expects strings to represent sleep stages, e.g. ['WAKE', 'N1', ...]. " + "Please refer to the documentation for more details." + ) assert isinstance(n_stages, int), "`n_stages` must be an integer between 2 and 5." assert n_stages in [2, 3, 4, 5], "`n_stages` must be an integer between 2 and 5." assert isinstance(freq, str), "`freq` must be a pandas frequency string." @@ -231,7 +239,10 @@ def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None): ), "`start` must be either None, a string or a pandas.Timestamp." assert isinstance( scorer, (type(None), str, int) - ), "`scorer` must be either None, or a string or an integer." + ), "`scorer` must be either None, a string or an integer." + assert isinstance( + proba, (pd.DataFrame, type(None)) + ), "`proba` must be either None or a pandas.DataFrame" if n_stages == 2: accepted = ["W", "WAKE", "S", "SLEEP", "ART", "UNS"] mapping = {"WAKE": 0, "SLEEP": 1, "ART": -1, "UNS": -2} @@ -244,10 +255,19 @@ def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None): else: accepted = ["WAKE", "W", "N1", "N2", "N3", "REM", "R", "ART", "UNS"] mapping = {"WAKE": 0, "N1": 1, "N2": 2, "N3": 3, "REM": 4, "ART": -1, "UNS": -2} - assert all([val.upper() in accepted for val in values]), ( - f"{np.unique(values)} do not match the accepted values for a {n_stages} stages " - f"hypnogram: {accepted}" - ) + n_unique_values = len(np.unique(values)) + if not all([val.upper() in accepted for val in values]): + msg = ( + f"{np.unique(values)} do not match the accepted values for a {n_stages}-stage " + f"hypnogram: {accepted}." + ) + if n_unique_values < n_stages: + msg += ( + f"\nIf your hypnogram only has {n_unique_values} possible stages, make sure to " + f"specify `Hypnogram(values, n_stages={n_unique_values})`." + ) + raise ValueError(msg) + if isinstance(values, pd.Series): # Make sure to remove index if the input is a pandas.Series values = values.to_numpy(copy=True) @@ -272,6 +292,19 @@ def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None): fake_dt = pd.date_range(start="2022-12-03 00:00:00", freq=freq, periods=hypno.shape[0]) hypno.index.name = "Epoch" timedelta = fake_dt - fake_dt[0] + # Validate proba + if proba is not None: + assert proba.shape[1] > 0, "`proba` must have at least one column." + assert proba.shape[0] == hypno.shape[0], "`proba` must have the same length as `values`" + assert np.allclose(proba.sum(1), 1), "Each row of `proba` must sum to 1." + in_proba_but_not_labels = np.setdiff1d(proba.columns, labels) + # in_labels_but_not_proba = np.setdiff1d(labels, proba.columns) + assert not len(in_proba_but_not_labels), ( + f"Invalid stages in `proba`: {in_proba_but_not_labels}. The accepted stages are: " + f"{labels}." + ) + # Ensure same order as `labels` + proba = proba.reindex(columns=labels).dropna(how="all", axis=1) # Set attributes self._hypno = hypno self._n_epochs = hypno.shape[0] @@ -284,13 +317,14 @@ def __init__(self, values, n_stages=5, *, freq="30s", start=None, scorer=None): self._labels = labels self._mapping = mapping self._scorer = scorer + self._proba = proba def __repr__(self): # TODO v0.8: Keep only the text between < and > text_scorer = f", scored by {self.scorer}" if self.scorer is not None else "" return ( f"\n" + f"{self.n_stages} unique stages{text_scorer}>\n" " - Use `.hypno` to get the string values as a pandas.Series\n" " - Use `.as_int()` to get the integer values as a pandas.Series\n" " - Use `.plot_hypnogram()` to plot the hypnogram\n" @@ -298,15 +332,7 @@ def __repr__(self): ) def __str__(self): - text_scorer = f", scored by {self.scorer}" if self.scorer is not None else "" - return ( - f"\n" - " - Use `.hypno` to get the string values as a pandas.Series\n" - " - Use `.as_int()` to get the integer values as a pandas.Series\n" - " - Use `.plot_hypnogram()` to plot the hypnogram\n" - "See the online documentation for more details." - ) + return self.__repr__() @property def hypno(self): @@ -395,9 +421,17 @@ def scorer(self): """The scorer name.""" return self._scorer + @property + def proba(self): + """ + If specified, a :py:class:`pandas.DataFrame` with the probability of each sleep stage + for each epoch in hypnogram. + """ + return self._proba + # CLASS METHODS BELOW - def as_annotations(self): + def as_events(self): """ Return a pandas DataFrame summarizing epoch-level information. @@ -409,14 +443,14 @@ def as_annotations(self): Returns ------- - annotations : :py:class:`pandas.DataFrame` + events : :py:class:`pandas.DataFrame` A dataframe containing epoch onset, duration, stage, etc. Examples -------- >>> from yasa import Hypnogram >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) - >>> hyp.as_annotations() + >>> hyp.as_events() onset duration value description epoch 0 0.0 30.0 0 WAKE @@ -443,10 +477,10 @@ def as_int(self): The default mapping from string to integer is: - * 2 stages: {"WAKE": 0, "SLEEP": 1, "ART": -1, "UNS": -2} - * 3 stages: {"WAKE": 0, "NREM": 2, "REM": 4, "ART": -1, "UNS": -2} - * 4 stages: {"WAKE": 0, "LIGHT": 2, "DEEP": 3, "REM": 4, "ART": -1, "UNS": -2} - * 5 stages: {"WAKE": 0, "N1": 1, "N2": 2, "N3": 3, "REM": 4, "ART": -1, "UNS": -2} + * 2-stage: {"WAKE": 0, "SLEEP": 1, "ART": -1, "UNS": -2} + * 3-stage: {"WAKE": 0, "NREM": 2, "REM": 4, "ART": -1, "UNS": -2} + * 4-stage: {"WAKE": 0, "LIGHT": 2, "DEEP": 3, "REM": 4, "ART": -1, "UNS": -2} + * 5-stage: {"WAKE": 0, "N1": 1, "N2": 2, "N3": 3, "REM": 4, "ART": -1, "UNS": -2} Users can define a custom mapping: @@ -454,7 +488,7 @@ def as_int(self): Examples -------- - Convert a 2-stages hypnogram to a pandas.Series of integers + Convert a 2-stage hypnogram to a pandas.Series of integers >>> from yasa import Hypnogram >>> hyp = Hypnogram(["W", "W", "S", "S", "W", "S"], n_stages=2) @@ -468,7 +502,7 @@ def as_int(self): 5 1 Name: Stage, dtype: int16 - Same with a 4-stages hypnogram + Same with a 4-stage hypnogram >>> from yasa import Hypnogram >>> hyp = Hypnogram(["W", "W", "LIGHT", "LIGHT", "DEEP", "REM", "WAKE"], n_stages=4) @@ -490,8 +524,8 @@ def consolidate_stages(self, new_n_stages): """Reduce the number of stages in a hypnogram to match actigraphy or wearables. For example, a standard 5-stage hypnogram (W, N1, N2, N3, REM) could be consolidated - to a hypnogram more common with actigraphy (e.g. 2-stages: [Wake, Sleep] or - 4-stages: [W, Light, Deep, REM]). + to a hypnogram more common with actigraphy (e.g. 2-stage: [Wake, Sleep] or + 4-stage: [W, Light, Deep, REM]). Parameters ---------- @@ -501,10 +535,10 @@ def consolidate_stages(self, new_n_stages): new_n_stages : int Desired number of sleep stages. Must be lower than the current number of stages. - - 5 stages - Wake, N1, N2, N3, REM - - 4 stages - Wake, Light, Deep, REM - - 3 stages - Wake, NREM, REM - - 2 stages - Wake, Sleep + - 5-stage (Wake, N1, N2, N3, REM) + - 4-stage (Wake, Light, Deep, REM) + - 3-stage (Wake, NREM, REM) + - 2-stage (Wake, Sleep) .. note:: Unscored and Artefact are always allowed. @@ -562,6 +596,7 @@ def consolidate_stages(self, new_n_stages): freq=self.freq, start=self.start, scorer=self.scorer, + proba=None, # TODO: Combine stages probability? ) def copy(self): @@ -572,6 +607,7 @@ def copy(self): freq=self.freq, start=self.start, scorer=self.scorer, + proba=self.proba, ) def evaluate(self, obs_hyp): @@ -667,7 +703,7 @@ def find_periods(self, threshold="5min", equal_length=False): Only the two sequences that are longer than 5 minutes (11 minutes and 9 minutes respectively) are kept. Feel free to play around with different values of threshold! - This function is not limited to binary arrays, e.g. a 5-stages hypnogram at 30-sec + This function is not limited to binary arrays, e.g. a 5-stage hypnogram at 30-sec resolution: >>> from yasa import simulate_hypnogram @@ -781,7 +817,7 @@ def sleep_statistics(self): """ Compute standard sleep statistics from an hypnogram. - This function supports a 2, 3, 4 or 5-stages hypnogram. + This function supports a 2, 3, 4 or 5-stage hypnogram. Parameters ---------- @@ -850,10 +886,10 @@ def sleep_statistics(self): 'SOL_5min': 2.5, 'WAKE': 6.0} - Sleep statistics for a 5-stages hypnogram + Sleep statistics for a 5-stage hypnogram >>> from yasa import simulate_hypnogram - >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution + >>> # Generate a 8 hr (= 480 minutes) 5-stage hypnogram with a 30-seconds resolution >>> hyp = simulate_hypnogram(tib=480, seed=42) >>> hyp.sleep_statistics() {'TIB': 480.0, @@ -984,7 +1020,7 @@ def transition_matrix(self): Examples -------- >>> from yasa import Hypnogram, simulate_hypnogram - >>> # Generate a 8 hr (= 480 minutes) 5-stages hypnogram with a 30-seconds resolution + >>> # Generate a 8 hr (= 480 minutes) 5-stage hypnogram with a 30-seconds resolution >>> hyp = simulate_hypnogram(tib=480, seed=42) >>> counts, probs = hyp.transition_matrix() >>> counts @@ -1012,7 +1048,7 @@ def transition_matrix(self): probs.columns = probs.columns.map(self.mapping_int) return counts, probs - def upsample(self, new_freq, **kwargs): + def upsample(self, new_freq): """Upsample hypnogram to a higher frequency. Parameters @@ -1096,6 +1132,7 @@ def upsample(self, new_freq, **kwargs): freq=new_freq, start=self.start, scorer=self.scorer, + proba=None, # NOTE: Do not upsample probability ) def upsample_to_data(self, data, sf=None, verbose=True): @@ -1679,7 +1716,7 @@ def simulate_hypnogram( >>> from yasa import simulate_hypnogram >>> hyp = simulate_hypnogram(tib=5, seed=1) >>> hyp - + - Use `.hypno` to get the string values as a pandas.Series - Use `.as_int()` to get the integer values as a pandas.Series - Use `.plot_hypnogram()` to plot the hypnogram diff --git a/src/yasa/staging.py b/src/yasa/staging.py index e66218b..c15e2a0 100644 --- a/src/yasa/staging.py +++ b/src/yasa/staging.py @@ -3,6 +3,7 @@ import glob import logging import os +import warnings import antropy as ant import joblib @@ -107,9 +108,9 @@ class SleepStaging: In addition with the predicted sleep stages, YASA can also return the predicted probabilities of each sleep stage at each epoch. This can be used to derive a confidence score at each epoch. - .. important:: The predictions should ALWAYS be double-check by a trained - visual scorer, especially for epochs with low confidence. A full - inspection should be performed in the following cases: + .. important:: The predictions should ALWAYS be double-check by a trained visual scorer, + especially for epochs with low confidence. A full inspection should be performed in the + following cases: * Nap data, because the classifiers were exclusively trained on full-night recordings. * Participants with sleep disorders. @@ -125,13 +126,11 @@ class SleepStaging: If you use YASA's default classifiers, these are the main references for the `National Sleep Research Resource `_: - * Dean, Dennis A., et al. "Scaling up scientific discovery in sleep - medicine: the National Sleep Research Resource." Sleep 39.5 (2016): - 1151-1164. + * Dean, Dennis A., et al. "Scaling up scientific discovery in sleep medicine: the National + Sleep Research Resource." Sleep 39.5 (2016): 1151-1164. - * Zhang, Guo-Qiang, et al. "The National Sleep Research Resource: towards - a sleep data commons." Journal of the American Medical Informatics - Association 25.10 (2018): 1351-1358. + * Zhang, Guo-Qiang, et al. "The National Sleep Research Resource: towards a sleep data + commons." Journal of the American Medical Informatics Association 25.10 (2018): 1351-1358. Examples -------- @@ -146,12 +145,15 @@ class SleepStaging: >>> sls = yasa.SleepStaging(raw, eeg_name="C4-M1", eog_name="LOC-M2", ... emg_name="EMG1-EMG2", ... metadata=dict(age=29, male=True)) + >>> # Print some basic info + >>> sls >>> # Get the predicted sleep stages - >>> hypno = sls.predict() + >>> hyp = sls.predict() + >>> hyp.hypno >>> # Get the predicted probabilities - >>> proba = sls.predict_proba() + >>> hyp.proba >>> # Get the confidence - >>> confidence = proba.max(axis=1) + >>> confidence = hyp.proba.max(axis=1) >>> # Plot the predicted probabilities >>> sls.plot_predict_proba() @@ -162,10 +164,10 @@ class SleepStaging: def __init__(self, raw, eeg_name, *, eog_name=None, emg_name=None, metadata=None): # Type check - assert isinstance(eeg_name, str) - assert isinstance(eog_name, (str, type(None))) - assert isinstance(emg_name, (str, type(None))) - assert isinstance(metadata, (dict, type(None))) + assert isinstance(eeg_name, str), "`eeg_name` must be a string." + assert isinstance(eog_name, (str, type(None))), "`eog_name` must be a string or None." + assert isinstance(emg_name, (str, type(None))), "`emg_name` must be a string or None." + assert isinstance(metadata, (dict, type(None))), "`metadata` must be a string or None." # Validate metadata if isinstance(metadata, dict): @@ -176,7 +178,7 @@ def __init__(self, raw, eeg_name, *, eog_name=None, emg_name=None, metadata=None assert metadata["male"] in [0, 1], "male must be 0 or 1." # Validate Raw instance and load data - assert isinstance(raw, mne.io.BaseRaw), "raw must be a MNE Raw object." + assert isinstance(raw, mne.io.BaseRaw), "`raw` must be a MNE Raw object." sf = raw.info["sfreq"] ch_names = np.array([eeg_name, eog_name, emg_name]) ch_types = np.array(["eeg", "eog", "emg"]) @@ -218,6 +220,22 @@ def __init__(self, raw, eeg_name, *, eog_name=None, emg_name=None, metadata=None self.data = data self.metadata = metadata + def __repr__(self): + n_samples = self.data.shape[-1] + duration = (n_samples / self.sf) / 60 + return ( + f"" + ) + + def __str__(self): + n_samples = self.data.shape[-1] + duration = n_samples / self.sf + return ( + f"" + ) + def fit(self): """Extract features from data. @@ -427,9 +445,13 @@ def predict(self, path_to_model="auto"): Returns ------- - pred : :py:class:`numpy.ndarray` - The predicted sleep stages. + pred : :py:class:`yasa.Hypnogram` + The predicted sleep stages. Since YASA v0.7, the predicted sleep stages are now + returned as a :py:class:`yasa.Hypnogram` instance, which also includes the + probability of each sleep stage for each epoch. """ + from yasa.hypno import Hypnogram + if not hasattr(self, "_features"): self.fit() # Load and validate pre-trained classifier @@ -438,10 +460,15 @@ def predict(self, path_to_model="auto"): X = self._features.copy()[clf.feature_name_] # Predict the sleep stages and probabilities self._predicted = clf.predict(X) - proba = pd.DataFrame(clf.predict_proba(X), columns=clf.classes_) - proba.index.name = "epoch" + # Predict the probabilities + classes = clf.classes_.copy() + classes[classes == "W"] = "WAKE" # Compat for yasa.Hypnogram + classes[classes == "R"] = "REM" + proba = pd.DataFrame(clf.predict_proba(X), columns=classes) + proba.index.name = "Epoch" self._proba = proba - return self._predicted.copy() + # Convert to a `yasa.Hypnogram` instance (including `proba`) + return Hypnogram(values=self._predicted.copy(), freq="30s", n_stages=5, proba=proba.copy()) def predict_proba(self, path_to_model="auto"): """ @@ -462,6 +489,12 @@ def predict_proba(self, path_to_model="auto"): proba : :py:class:`pandas.DataFrame` The predicted probability for each sleep stage for each 30-sec epoch of data. """ + warnings.warn( + "The `predict_proba` function is deprecated and will be removed in v0.8. " + "The predicted probabilities can now be accessed with `yasa.Hypnogram.proba` instead, " + "e.g `SleepStaging.predict().proba`", + FutureWarning, + ) if not hasattr(self, "_proba"): self.predict(path_to_model) return self._proba.copy() @@ -483,19 +516,18 @@ def plot_predict_proba( If True, probabilities of the non-majority classes will be set to 0. """ if proba is None and not hasattr(self, "_features"): - raise ValueError("Must call .predict_proba before this function") + raise ValueError("Must call `.predict` before this function") if proba is None: proba = self._proba.copy() else: - assert isinstance(proba, pd.DataFrame), "proba must be a dataframe" + assert isinstance(proba, pd.DataFrame), "`proba` must be a pandas.DataFrame" if majority_only: cond = proba.apply(lambda x: x == x.max(), axis=1) proba = proba.where(cond, other=0) ax = proba.plot(kind="area", color=palette, figsize=(10, 5), alpha=0.8, stacked=True, lw=0) # Add confidence # confidence = proba.max(1) - # ax.plot(confidence, lw=1, color='k', ls='-', alpha=0.5, - # label='Confidence') + # ax.plot(confidence, lw=1, color='k', ls='-', alpha=0.5, label='Confidence') ax.set_xlim(0, proba.shape[0]) ax.set_ylim(0, 1) ax.set_ylabel("Probability") diff --git a/tests/test_hypnoclass.py b/tests/test_hypnoclass.py index ed6eeb3..76ac0c1 100644 --- a/tests/test_hypnoclass.py +++ b/tests/test_hypnoclass.py @@ -69,7 +69,7 @@ def test_2stages_hypno(self): np.testing.assert_array_equal(hyp.as_int(), values_int) hyp.transition_matrix() hyp.find_periods() - hyp.as_annotations() + hyp.as_events() sstats = hyp.sleep_statistics() truth = { "TIB": 60.0, @@ -164,7 +164,7 @@ def test_4stages_hypno(self): assert sstats["TIB"] == 400 assert "%DEEP" in sstats.keys() assert "Lat_REM" in sstats.keys() - assert isinstance(hyp.as_annotations(), pd.DataFrame) + assert isinstance(hyp.as_events(), pd.DataFrame) def test_5stages_hypno(self): """Test 5-stages Hypnogram class""" diff --git a/tests/test_staging.py b/tests/test_staging.py index 6796a7f..d46ecf5 100644 --- a/tests/test_staging.py +++ b/tests/test_staging.py @@ -6,6 +6,7 @@ import mne import numpy as np +from yasa.hypno import Hypnogram from yasa.staging import SleepStaging ############################################################################## @@ -14,7 +15,7 @@ # MNE Raw raw = mne.io.read_raw_fif("notebooks/sub-02_mne_raw.fif", preload=True, verbose=0) -hypno = np.loadtxt("notebooks/sub-02_hypno_30s.txt", dtype=str) +y_true = Hypnogram(np.loadtxt("notebooks/sub-02_hypno_30s.txt", dtype=str)) class TestStaging(unittest.TestCase): @@ -25,12 +26,18 @@ def test_sleep_staging(self): sls = SleepStaging( raw, eeg_name="C4", eog_name="EOG1", emg_name="EMG1", metadata=dict(age=21, male=False) ) + print(sls) + print(str(sls)) sls.get_features() y_pred = sls.predict() + assert isinstance(y_pred, Hypnogram) + assert y_pred.proba is not None proba = sls.predict_proba() - assert y_pred.size == hypno.size + assert y_pred.hypno.size == y_true.hypno.size + assert y_true.duration == y_pred.duration + assert y_true.n_stages == y_pred.n_stages # Check that the accuracy is at least 80% - accuracy = (hypno == y_pred).sum() / y_pred.size + accuracy = (y_true.hypno == y_pred.hypno).mean() assert accuracy > 0.80 # Plot