Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
- `hypyp.sync`: when a backend of a `priority` list can neither run on the machine nor be computed by the metric, and no other GPU backend of the list can be used, the warning now says so (for example "'plv' has no Metal implementation") instead of "No GPU backend available"
- `hypyp.sync`: a metric whose `_backend` was set to a backend it does not implement used to compute in NumPy without notice. It still computes in NumPy, now with a warning. A `_backend` that is not the name of a backend raises a `ValueError` naming the metric and the backends it implements (#306)
- Tests: the six Metal tests that compared NumPy with NumPy are replaced by tests of the fallback itself (warning, backend and result), the dispatch of every metric to every backend it implements is checked without a GPU, and the four tests that run a Metal kernel now assert that the Metal method was called (#300)
- `import hypyp.analyses` failed when the import of torch, numba, cupy or the Metal bindings raised anything other than `ImportError`, for example the `OSError` of a missing shared library. The backend is now reported as unavailable, with a warning that gives the original error. An installed package that fails with an `ImportError` used to be skipped silently and now gets the same warning; a package that is not installed is still skipped silently. A package that imports but is broken further down is not covered
- `hypyp.sync.get_metric` rejected the names `envelope_corr`, `pow_corr` and `imaginary_coh`, which `compute_sync` accepts and the documentation uses. It now accepts them; the mapping is `hypyp.sync.METRIC_ALIASES`, and a metric registered in `METRICS` under one of these names takes precedence
- `hypyp.sync`: the warnings for a missing numba or torch suggested a `poetry install` command that no longer exists; they now give `pip install "hypyp[numba]"` and `pip install "hypyp[torch]"`
- `hypyp.fnirs.Recording.load_raw` raised a `TypeError` for a `Raw` object without `subject_info`, as built in memory with `mne.io.RawArray`, and a `KeyError` when `subject_info` had no `his_id`. Both now get the random label that was intended
- `hypyp.fnirs.Study.compute_wtcs_shuffle` raised a `TypeError` when given `with_intra=False`, so the keyword arguments of `compute_wtcs` could not be reused. That redundant argument is now accepted; `with_intra=True` is still refused
- `XDFImport(select_matches=[...])`: an integer that is not the id of a stream raised a bare `KeyError`; it now raises the same `ValueError` as an unknown stream name. A numpy integer is recognised as a stream id
- `compute_sync` and `pair_connectivity` reported every `ValueError` as `Metric type "..." not supported`, so an unknown `optimization` was announced as an unsupported metric. Only an unknown metric name gets that message now; any other error keeps its own (#306)
- `pair_connectivity` crashed with an `IndexError` when the frequencies were given as a list and the data had a single epoch, a single channel per participant or a single frequency. Results for data without such a dimension are unchanged
- `utils.normalizing` raised an `UnboundLocalError` for a `type` name other than `'Zscore'` or `'Logratio'`; it now raises a `ValueError` naming the two valid types
Expand Down
4 changes: 3 additions & 1 deletion hypyp/fnirs/recording.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,9 @@ def preprocess_step_choices(self):

def _fill_subject_label(self):
if self.subject_label == "":
self.subject_label = self.mne_raw.info["subject_info"]["his_id"]
# subject_info is None for a Raw built in memory, and his_id is optional
subject_info = self.mne_raw.info["subject_info"] or {}
self.subject_label = subject_info.get("his_id") or ""

if self.subject_label == "":
self.subject_label = generate_random_label(10)
Expand Down
9 changes: 8 additions & 1 deletion hypyp/fnirs/study.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,18 @@ def get_dyads_shuffle(self) -> List[Dyad]:
# TODO add as argument the number of shuffle dyads
def compute_wtcs_shuffle(self, *args, **kwargs):
"""
Wraps the `compute_wtcs` of all the dyads_shuffle. Arguments are directly passed to the dyads_shuffle method
Wraps the `compute_wtcs` of all the dyads_shuffle. Arguments are directly passed to the dyads_shuffle method,
which is always called with `with_intra=False`. Passing `with_intra=False` here is accepted and changes nothing.

Returns:
self: the Study object itself. Useful for chaining operations
"""
# with_intra=False is passed below; drop a redundant one from the caller,
# so that the keyword arguments of compute_wtcs can be reused as they are
if kwargs.get("with_intra") is False:
kwargs = {
key: value for key, value in kwargs.items() if key != "with_intra"
}
self.dyads_shuffled = self.get_dyads_shuffle()
for dyad_shuffle in self.dyads_shuffled:
dyad_shuffle.compute_wtcs(*args, **kwargs, with_intra=False)
Expand Down
15 changes: 14 additions & 1 deletion hypyp/sync/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@
"powcorr": PowCorr,
}

# Alternative names of the metrics, accepted by get_metric and compute_sync
METRIC_ALIASES = {
"envelope_corr": "envcorr",
"pow_corr": "powcorr",
"imaginary_coh": "imcoh",
}

__all__ = [
# Base classes and utilities
"BaseMetric",
Expand All @@ -94,6 +101,7 @@
"PowCorr",
# Registry
"METRICS",
"METRIC_ALIASES",
"get_metric",
]

Expand All @@ -108,7 +116,8 @@ def get_metric(
----------
mode : str
Name of the connectivity metric. One of: 'plv', 'ccorr', 'accorr',
'coh', 'imcoh', 'pli', 'wpli', 'envcorr', 'powcorr'.
'coh', 'imcoh', 'pli', 'wpli', 'envcorr', 'powcorr'. The aliases
'imaginary_coh', 'envelope_corr' and 'pow_corr' are accepted too.
optimization : str, optional
Optimization strategy. Options: None, 'auto', 'numba', 'torch',
'metal', 'cuda_kernel'. See BaseMetric for fallback behavior.
Expand All @@ -134,6 +143,10 @@ def get_metric(
... priority=['numba', 'metal']) # custom priority
"""
mode_lower = mode.lower()
# a name registered in METRICS wins, so an alias never hides a metric
# that a user registered under that name
if mode_lower not in METRICS:
mode_lower = METRIC_ALIASES.get(mode_lower, mode_lower)
if mode_lower not in METRICS:
available = ", ".join(METRICS.keys())
raise ValueError(f"Unknown metric mode '{mode}'. Available: {available}")
Expand Down
30 changes: 26 additions & 4 deletions hypyp/sync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,45 @@


# Check optional dependency availability
def _is_absent(exc: BaseException, package: str) -> bool:
"""True if `exc` only says that the optional `package` is not installed.

Any other failure (a missing shared library, an incompatible version, an
error while probing a device) means the package is installed but
unusable, which deserves a warning instead of a silent fallback.
"""
return isinstance(exc, ModuleNotFoundError) and exc.name == package


try:
import torch

TORCH_AVAILABLE = True
MPS_AVAILABLE = torch.backends.mps.is_available()
CUDA_AVAILABLE = torch.cuda.is_available()
except ImportError:
except Exception as exc:
TORCH_AVAILABLE = False
MPS_AVAILABLE = False
CUDA_AVAILABLE = False
if not _is_absent(exc, "torch"):
warnings.warn(
"torch is installed but could not be loaded "
f"({type(exc).__name__}: {exc}). The torch backends are disabled.",
UserWarning,
)

try:
import numba

NUMBA_AVAILABLE = True
except ImportError:
except Exception as exc:
NUMBA_AVAILABLE = False
if not _is_absent(exc, "numba"):
warnings.warn(
"numba is installed but could not be loaded "
f"({type(exc).__name__}: {exc}). The numba backend is disabled.",
UserWarning,
)

# Custom kernel backends
from .kernels import METAL_AVAILABLE, CUPY_AVAILABLE
Expand Down Expand Up @@ -593,7 +615,7 @@ def _resolve_optimization(
return "numba", "cpu"
warnings.warn(
"numba not installed, falling back to numpy. "
"Install with: poetry install --with optim_numba",
'Install with: pip install "hypyp[numba]"',
UserWarning,
stacklevel=3,
)
Expand All @@ -604,7 +626,7 @@ def _resolve_optimization(
return cls._resolve_torch()
warnings.warn(
"torch not installed, falling back to numpy. "
"Install with: poetry install --with optim_torch",
'Install with: pip install "hypyp[torch]"',
UserWarning,
stacklevel=3,
)
Expand Down
22 changes: 20 additions & 2 deletions hypyp/sync/kernels/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,38 @@
(e.g., PLI, wPLI — non-linear per-timepoint operations).
"""

import warnings

# A package that is not installed is skipped silently; one that is installed
# but fails to load (a missing shared library, for instance) disables the
# backend with a warning instead of making `import hypyp` fail.

# Metal availability (Apple Silicon via PyObjC)
try:
import Metal as _Metal

METAL_AVAILABLE = True
except ImportError:
except Exception as exc:
METAL_AVAILABLE = False
if not (isinstance(exc, ModuleNotFoundError) and exc.name == "Metal"):
warnings.warn(
"The Metal bindings are installed but could not be loaded "
f"({type(exc).__name__}: {exc}). The Metal backend is disabled.",
UserWarning,
)

# CUDA availability (NVIDIA via CuPy)
try:
import cupy as _cp

CUPY_AVAILABLE = True
except ImportError:
except Exception as exc:
CUPY_AVAILABLE = False
if not (isinstance(exc, ModuleNotFoundError) and exc.name == "cupy"):
warnings.warn(
"cupy is installed but could not be loaded "
f"({type(exc).__name__}: {exc}). The CUDA kernel backend is disabled.",
UserWarning,
)

__all__ = ["METAL_AVAILABLE", "CUPY_AVAILABLE"]
10 changes: 7 additions & 3 deletions hypyp/xdf/xdf_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,9 +203,13 @@ def select_streams_by_matches(self, keyword_matches: list):
for keyword_match in keyword_matches:
# match stream_id
found_idx = None
if type(keyword_match) == int:
stream_id = keyword_match
found_idx = self.map_id_to_idx[stream_id]
# a numpy integer is a stream id too; a bool is not
if isinstance(keyword_match, (int, np.integer)) and not isinstance(
keyword_match, bool
):
stream_id = int(keyword_match)
# None for an unknown id, reported below like an unknown name
found_idx = self.map_id_to_idx.get(stream_id)

# match stream name
else:
Expand Down
60 changes: 60 additions & 0 deletions tests/test_fnirs.py
Original file line number Diff line number Diff line change
Expand Up @@ -821,3 +821,63 @@ def test_download_demos():


# def test_load_lionirs():


def _raw_array_without_subject_info():
info = mne.create_info(["Foo 1", "Foo 2"], sfreq=10, ch_types="eeg")
return mne.io.RawArray(np.zeros((2, 100)), info)


def test_load_raw_without_subject_info_gets_random_label():
raw = _raw_array_without_subject_info()
assert raw.info["subject_info"] is None

recording = Recording().load_raw(raw, preprocess=False)
assert len(recording.subject_label) == 10

# a second recording gets its own label
other = Recording().load_raw(_raw_array_without_subject_info(), preprocess=False)
assert other.subject_label != recording.subject_label


def test_load_raw_subject_info_without_his_id_gets_random_label():
raw = _raw_array_without_subject_info()
raw.info["subject_info"] = dict(id=1)

recording = Recording().load_raw(raw, preprocess=False)
assert len(recording.subject_label) == 10


def test_load_raw_keeps_given_label_and_his_id():
raw = _raw_array_without_subject_info()
assert (
Recording(subject_label="s1").load_raw(raw, preprocess=False).subject_label
== "s1"
)

raw.info["subject_info"] = dict(his_id="from_file")
assert Recording().load_raw(raw, preprocess=False).subject_label == "from_file"


def test_study_wtc_shuffle_accepts_redundant_with_intra():
from pandas.testing import assert_frame_equal

dyads = [Dyad(*get_test_recordings()) for _ in range(3)]
wtcs_kwargs = dict(ch_match=get_test_ch_match_one())

reference = Study(dyads).compute_wtcs_shuffle(**wtcs_kwargs)
assert len(reference.dyads_shuffled) == 6

# the keyword arguments of compute_wtcs can be reused as they are
study = Study(dyads).compute_wtcs_shuffle(**wtcs_kwargs, with_intra=False)
assert len(study.dyads_shuffled) == 6
for shuffled, expected in zip(study.dyads_shuffled, reference.dyads_shuffled):
assert len(expected.wtcs) == 1
assert len(expected.df) > 0
assert shuffled.label == expected.label
assert_frame_equal(shuffled.df, expected.df)
np.testing.assert_array_equal(shuffled.wtcs[0].W, expected.wtcs[0].W)

# with_intra=True is still refused, as before
with pytest.raises(TypeError, match="with_intra"):
Study(dyads).compute_wtcs_shuffle(**wtcs_kwargs, with_intra=True)
Loading
Loading