blockperm 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of blockperm might be problematic. Click here for more details.

@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dongjin Choi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: blockperm
3
+ Version: 0.1.0
4
+ Summary: Block permutation test for cross-validated decoding when labels follow recording blocks
5
+ Author: Dongjin Choi
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jinchoi7651507/block-permutation-decoding
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy>=2.0
12
+ Requires-Dist: scikit-learn>=1.4
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # blockperm: a block permutation test for cross-validated decoding
18
+
19
+ `block_permutation_test_score` tests a cross-validated decoding score when the labels follow recording blocks (for example conditions presented in blocks of consecutive trials) and training and test trials may come from the same blocks. It has the call pattern of scikit-learn's `permutation_test_score`:
20
+
21
+ ```python
22
+ from blockperm import block_permutation_test_score
23
+
24
+ score, null_scores, p = block_permutation_test_score(
25
+ pipeline, X, y, blocks=block_id, cv=KFold(5, shuffle=True, random_state=0), n_permutations=1000, random_state=0)
26
+ ```
27
+
28
+ ## What it does
29
+ - Each permutation permutes the labels of whole blocks between blocks, so the labels of training and test trials move together and every block keeps its trials.
30
+ - The whole cross-validation is rerun for every permutation, including every step fitted to the labels. Put scaling, feature or neuron selection and the model in one scikit-learn `Pipeline`.
31
+ - `strata` restricts the permutation to blocks within the same stratum (for example within a run or a session).
32
+ - `draw` replaces the permutation with a user-given generator of block-label vectors, for example pseudosessions drawn from the task's own block generator.
33
+ - With few blocks the function enumerates every distinct arrangement (`exact="auto"`) and warns about the smallest attainable p value. With two blocks per class there are six arrangements, but for a two-class decoder an arrangement and its class swap give the same score, so p cannot fall below about one in three.
34
+
35
+ ## When it is valid
36
+ - The enumerated null is exact, and the sampled p value valid, when the observed assignment was drawn uniformly from the set the function permutes over: all arrangements of the block labels, or all arrangements within `strata` if the design randomized within strata (for example within runs). Permuting across a unit the design did not randomize over can reject too often. For a constrained randomization (counterbalancing, limits per run), pass the design's own generator as `draw`.
37
+ - With a fixed block order, the test assumes that blocks are exchangeable under the null. Slow drift that follows the order can break this assumption, and then the test can reject or be conservative. A design with random block order, or several blocks per condition in random order, avoids the problem.
38
+ - With `GroupKFold` and blocks of equal size, the block-to-fold map depends on the tie order of `numpy.argsort`, which differs between platforms (arm64 and x86 gave different maps for 24 blocks with the same versions). For a result that reruns identically elsewhere, pass explicit folds (a list of train/test index pairs) or a shuffled `GroupKFold` with a seed (scikit-learn 1.6 or later), and record the folds.
39
+
40
+ ## Why not the defaults
41
+ In the analyses this function accompanies, three rules that toolboxes run were tested on labels with no information, assigned to recording blocks, with training and test trials sharing blocks:
42
+ - trial-level permutation with the cross-validation rerun (scikit-learn `permutation_test_score`, MVPA-Light);
43
+ - trial-level permutation within runs (the CoSMoMVPA tutorial rule);
44
+ - permutation of training labels only, with test labels kept (the PyMVPA tutorial rule).
45
+
46
+ All three rejected far more often than 5%. Permuting block labels and rerunning the whole cross-validation stayed near 5% in random block designs. With a fixed block order the result depends on the data. In the simulated fixed orders the test was conservative (0–2.4% with shuffled 5-fold). In resting EEG with fixed orders aligned with slow drift it rejected often (first half against second half in most recordings, a pairs-of-blocks order in 29%), and it stayed near 5% for an order orthogonal to a linear trend. There the blocks were not exchangeable (label = time; see "When it is valid"). With labels that vary trial by trial, the first two rules stayed near 5%, and the training-label rule was somewhat above it (10–12%); the large excess arises when labels follow blocks. `examples/fake_block_labels.py` shows the effect on a synthetic drifting recording.
47
+
48
+ ## One-line changes per tool
49
+ - **scikit-learn:** call `block_permutation_test_score` with `blocks=`. Passing `groups=` to `permutation_test_score` restricts the shuffle to within groups; with labels constant within blocks, every permutation then returns the observed labels.
50
+ - **CoSMoMVPA / PyMVPA:** permute chunk-level labels across chunks (one label per chunk, all trials of a chunk together) within the unit the design randomized over (within runs if blocks were randomized within runs; `strata=` here), and rerun the cross-validation.
51
+ - **PyMVPA training-label permutation:** permute the labels of all trials, training and test, block by block.
52
+
53
+ ## Install and test
54
+ Python 3.12 with numpy, scipy and scikit-learn (tested with numpy 2.5.4, scipy 1.18.1, scikit-learn 1.7.2); tested on macOS (arm64). No special hardware is needed; one CPU core is enough.
55
+
56
+ ```sh
57
+ pip install blockperm # from PyPI; or, in this folder, pip install ".[test]" (with pytest), or copy blockperm.py into a project
58
+ python -m pytest tests # a few minutes on one core; the warnings it prints are expected
59
+ python examples/fake_block_labels.py
60
+ ```
61
+
62
+ On a laptop (Apple arm64, 2026-11-10) the install took about 15 s with a network connection, the tests about 1.5 min and the example about 1.5 min. In this folder alone the tests end with "8 passed, 4 skipped" (the equivalence tests need the reference file named below); inside the paper's repository they end with "12 passed". The example prints:
63
+
64
+ ```
65
+ labels with no information, 40 recordings, p < 0.05:
66
+ permutation_test_score (trial labels permuted): 14 (35%)
67
+ block_permutation_test_score (block labels permuted): 1 (2%)
68
+ ```
69
+
70
+ The test `test_equivalence_with_reference` checks that, with `exact=False`, the same `random_state` Generator and the same folds, this function returns the same observed score, null scores and p value (except that it returns no null when the observed score is undefined) as the reference implementation used in the analyses (`analyses/step2-comparison/code/sim.py` in the paper's repository; another location can be given with `REFERENCE_SIM_DIR`), for shuffled 5-fold, re-split stratified 5-fold, GroupKFold by block and a user `draw`; it is skipped when that file is not present. With the default `exact="auto"`, designs with no more arrangements than `n_permutations` are enumerated instead (the null then has one score per arrangement).
71
+
72
+ ## Tutorial, MATLAB and Octave
73
+ - `docs/TUTORIAL.md`: a step-by-step guide (folds, strata, pseudosessions, few blocks, fixed block orders, checking a pipeline with labels that carry no information).
74
+ - `matlab/`: a MATLAB and Octave port, `block_permutation_test.m`, which takes a function returning the cross-validated score for a label vector. This Python function is the reference; the port is checked against it in `matlab/crosscheck/` (`matlab/README.md`).
75
+ - On PyPI: `pip install blockperm` (version 0.1.0, the version used in the paper).
76
+
77
+ ## Not included
78
+ No `n_jobs` and no fit parameters (unlike `permutation_test_score`); the permutations run one after another. License: MIT (`LICENSE`).
@@ -0,0 +1,62 @@
1
+ # blockperm: a block permutation test for cross-validated decoding
2
+
3
+ `block_permutation_test_score` tests a cross-validated decoding score when the labels follow recording blocks (for example conditions presented in blocks of consecutive trials) and training and test trials may come from the same blocks. It has the call pattern of scikit-learn's `permutation_test_score`:
4
+
5
+ ```python
6
+ from blockperm import block_permutation_test_score
7
+
8
+ score, null_scores, p = block_permutation_test_score(
9
+ pipeline, X, y, blocks=block_id, cv=KFold(5, shuffle=True, random_state=0), n_permutations=1000, random_state=0)
10
+ ```
11
+
12
+ ## What it does
13
+ - Each permutation permutes the labels of whole blocks between blocks, so the labels of training and test trials move together and every block keeps its trials.
14
+ - The whole cross-validation is rerun for every permutation, including every step fitted to the labels. Put scaling, feature or neuron selection and the model in one scikit-learn `Pipeline`.
15
+ - `strata` restricts the permutation to blocks within the same stratum (for example within a run or a session).
16
+ - `draw` replaces the permutation with a user-given generator of block-label vectors, for example pseudosessions drawn from the task's own block generator.
17
+ - With few blocks the function enumerates every distinct arrangement (`exact="auto"`) and warns about the smallest attainable p value. With two blocks per class there are six arrangements, but for a two-class decoder an arrangement and its class swap give the same score, so p cannot fall below about one in three.
18
+
19
+ ## When it is valid
20
+ - The enumerated null is exact, and the sampled p value valid, when the observed assignment was drawn uniformly from the set the function permutes over: all arrangements of the block labels, or all arrangements within `strata` if the design randomized within strata (for example within runs). Permuting across a unit the design did not randomize over can reject too often. For a constrained randomization (counterbalancing, limits per run), pass the design's own generator as `draw`.
21
+ - With a fixed block order, the test assumes that blocks are exchangeable under the null. Slow drift that follows the order can break this assumption, and then the test can reject or be conservative. A design with random block order, or several blocks per condition in random order, avoids the problem.
22
+ - With `GroupKFold` and blocks of equal size, the block-to-fold map depends on the tie order of `numpy.argsort`, which differs between platforms (arm64 and x86 gave different maps for 24 blocks with the same versions). For a result that reruns identically elsewhere, pass explicit folds (a list of train/test index pairs) or a shuffled `GroupKFold` with a seed (scikit-learn 1.6 or later), and record the folds.
23
+
24
+ ## Why not the defaults
25
+ In the analyses this function accompanies, three rules that toolboxes run were tested on labels with no information, assigned to recording blocks, with training and test trials sharing blocks:
26
+ - trial-level permutation with the cross-validation rerun (scikit-learn `permutation_test_score`, MVPA-Light);
27
+ - trial-level permutation within runs (the CoSMoMVPA tutorial rule);
28
+ - permutation of training labels only, with test labels kept (the PyMVPA tutorial rule).
29
+
30
+ All three rejected far more often than 5%. Permuting block labels and rerunning the whole cross-validation stayed near 5% in random block designs. With a fixed block order the result depends on the data. In the simulated fixed orders the test was conservative (0–2.4% with shuffled 5-fold). In resting EEG with fixed orders aligned with slow drift it rejected often (first half against second half in most recordings, a pairs-of-blocks order in 29%), and it stayed near 5% for an order orthogonal to a linear trend. There the blocks were not exchangeable (label = time; see "When it is valid"). With labels that vary trial by trial, the first two rules stayed near 5%, and the training-label rule was somewhat above it (10–12%); the large excess arises when labels follow blocks. `examples/fake_block_labels.py` shows the effect on a synthetic drifting recording.
31
+
32
+ ## One-line changes per tool
33
+ - **scikit-learn:** call `block_permutation_test_score` with `blocks=`. Passing `groups=` to `permutation_test_score` restricts the shuffle to within groups; with labels constant within blocks, every permutation then returns the observed labels.
34
+ - **CoSMoMVPA / PyMVPA:** permute chunk-level labels across chunks (one label per chunk, all trials of a chunk together) within the unit the design randomized over (within runs if blocks were randomized within runs; `strata=` here), and rerun the cross-validation.
35
+ - **PyMVPA training-label permutation:** permute the labels of all trials, training and test, block by block.
36
+
37
+ ## Install and test
38
+ Python 3.12 with numpy, scipy and scikit-learn (tested with numpy 2.5.4, scipy 1.18.1, scikit-learn 1.7.2); tested on macOS (arm64). No special hardware is needed; one CPU core is enough.
39
+
40
+ ```sh
41
+ pip install blockperm # from PyPI; or, in this folder, pip install ".[test]" (with pytest), or copy blockperm.py into a project
42
+ python -m pytest tests # a few minutes on one core; the warnings it prints are expected
43
+ python examples/fake_block_labels.py
44
+ ```
45
+
46
+ On a laptop (Apple arm64, 2026-11-10) the install took about 15 s with a network connection, the tests about 1.5 min and the example about 1.5 min. In this folder alone the tests end with "8 passed, 4 skipped" (the equivalence tests need the reference file named below); inside the paper's repository they end with "12 passed". The example prints:
47
+
48
+ ```
49
+ labels with no information, 40 recordings, p < 0.05:
50
+ permutation_test_score (trial labels permuted): 14 (35%)
51
+ block_permutation_test_score (block labels permuted): 1 (2%)
52
+ ```
53
+
54
+ The test `test_equivalence_with_reference` checks that, with `exact=False`, the same `random_state` Generator and the same folds, this function returns the same observed score, null scores and p value (except that it returns no null when the observed score is undefined) as the reference implementation used in the analyses (`analyses/step2-comparison/code/sim.py` in the paper's repository; another location can be given with `REFERENCE_SIM_DIR`), for shuffled 5-fold, re-split stratified 5-fold, GroupKFold by block and a user `draw`; it is skipped when that file is not present. With the default `exact="auto"`, designs with no more arrangements than `n_permutations` are enumerated instead (the null then has one score per arrangement).
55
+
56
+ ## Tutorial, MATLAB and Octave
57
+ - `docs/TUTORIAL.md`: a step-by-step guide (folds, strata, pseudosessions, few blocks, fixed block orders, checking a pipeline with labels that carry no information).
58
+ - `matlab/`: a MATLAB and Octave port, `block_permutation_test.m`, which takes a function returning the cross-validated score for a label vector. This Python function is the reference; the port is checked against it in `matlab/crosscheck/` (`matlab/README.md`).
59
+ - On PyPI: `pip install blockperm` (version 0.1.0, the version used in the paper).
60
+
61
+ ## Not included
62
+ No `n_jobs` and no fit parameters (unlike `permutation_test_score`); the permutations run one after another. License: MIT (`LICENSE`).
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: blockperm
3
+ Version: 0.1.0
4
+ Summary: Block permutation test for cross-validated decoding when labels follow recording blocks
5
+ Author: Dongjin Choi
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://github.com/jinchoi7651507/block-permutation-decoding
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: numpy>=2.0
12
+ Requires-Dist: scikit-learn>=1.4
13
+ Provides-Extra: test
14
+ Requires-Dist: pytest; extra == "test"
15
+ Dynamic: license-file
16
+
17
+ # blockperm: a block permutation test for cross-validated decoding
18
+
19
+ `block_permutation_test_score` tests a cross-validated decoding score when the labels follow recording blocks (for example conditions presented in blocks of consecutive trials) and training and test trials may come from the same blocks. It has the call pattern of scikit-learn's `permutation_test_score`:
20
+
21
+ ```python
22
+ from blockperm import block_permutation_test_score
23
+
24
+ score, null_scores, p = block_permutation_test_score(
25
+ pipeline, X, y, blocks=block_id, cv=KFold(5, shuffle=True, random_state=0), n_permutations=1000, random_state=0)
26
+ ```
27
+
28
+ ## What it does
29
+ - Each permutation permutes the labels of whole blocks between blocks, so the labels of training and test trials move together and every block keeps its trials.
30
+ - The whole cross-validation is rerun for every permutation, including every step fitted to the labels. Put scaling, feature or neuron selection and the model in one scikit-learn `Pipeline`.
31
+ - `strata` restricts the permutation to blocks within the same stratum (for example within a run or a session).
32
+ - `draw` replaces the permutation with a user-given generator of block-label vectors, for example pseudosessions drawn from the task's own block generator.
33
+ - With few blocks the function enumerates every distinct arrangement (`exact="auto"`) and warns about the smallest attainable p value. With two blocks per class there are six arrangements, but for a two-class decoder an arrangement and its class swap give the same score, so p cannot fall below about one in three.
34
+
35
+ ## When it is valid
36
+ - The enumerated null is exact, and the sampled p value valid, when the observed assignment was drawn uniformly from the set the function permutes over: all arrangements of the block labels, or all arrangements within `strata` if the design randomized within strata (for example within runs). Permuting across a unit the design did not randomize over can reject too often. For a constrained randomization (counterbalancing, limits per run), pass the design's own generator as `draw`.
37
+ - With a fixed block order, the test assumes that blocks are exchangeable under the null. Slow drift that follows the order can break this assumption, and then the test can reject or be conservative. A design with random block order, or several blocks per condition in random order, avoids the problem.
38
+ - With `GroupKFold` and blocks of equal size, the block-to-fold map depends on the tie order of `numpy.argsort`, which differs between platforms (arm64 and x86 gave different maps for 24 blocks with the same versions). For a result that reruns identically elsewhere, pass explicit folds (a list of train/test index pairs) or a shuffled `GroupKFold` with a seed (scikit-learn 1.6 or later), and record the folds.
39
+
40
+ ## Why not the defaults
41
+ In the analyses this function accompanies, three rules that toolboxes run were tested on labels with no information, assigned to recording blocks, with training and test trials sharing blocks:
42
+ - trial-level permutation with the cross-validation rerun (scikit-learn `permutation_test_score`, MVPA-Light);
43
+ - trial-level permutation within runs (the CoSMoMVPA tutorial rule);
44
+ - permutation of training labels only, with test labels kept (the PyMVPA tutorial rule).
45
+
46
+ All three rejected far more often than 5%. Permuting block labels and rerunning the whole cross-validation stayed near 5% in random block designs. With a fixed block order the result depends on the data. In the simulated fixed orders the test was conservative (0–2.4% with shuffled 5-fold). In resting EEG with fixed orders aligned with slow drift it rejected often (first half against second half in most recordings, a pairs-of-blocks order in 29%), and it stayed near 5% for an order orthogonal to a linear trend. There the blocks were not exchangeable (label = time; see "When it is valid"). With labels that vary trial by trial, the first two rules stayed near 5%, and the training-label rule was somewhat above it (10–12%); the large excess arises when labels follow blocks. `examples/fake_block_labels.py` shows the effect on a synthetic drifting recording.
47
+
48
+ ## One-line changes per tool
49
+ - **scikit-learn:** call `block_permutation_test_score` with `blocks=`. Passing `groups=` to `permutation_test_score` restricts the shuffle to within groups; with labels constant within blocks, every permutation then returns the observed labels.
50
+ - **CoSMoMVPA / PyMVPA:** permute chunk-level labels across chunks (one label per chunk, all trials of a chunk together) within the unit the design randomized over (within runs if blocks were randomized within runs; `strata=` here), and rerun the cross-validation.
51
+ - **PyMVPA training-label permutation:** permute the labels of all trials, training and test, block by block.
52
+
53
+ ## Install and test
54
+ Python 3.12 with numpy, scipy and scikit-learn (tested with numpy 2.5.4, scipy 1.18.1, scikit-learn 1.7.2); tested on macOS (arm64). No special hardware is needed; one CPU core is enough.
55
+
56
+ ```sh
57
+ pip install blockperm # from PyPI; or, in this folder, pip install ".[test]" (with pytest), or copy blockperm.py into a project
58
+ python -m pytest tests # a few minutes on one core; the warnings it prints are expected
59
+ python examples/fake_block_labels.py
60
+ ```
61
+
62
+ On a laptop (Apple arm64, 2026-11-10) the install took about 15 s with a network connection, the tests about 1.5 min and the example about 1.5 min. In this folder alone the tests end with "8 passed, 4 skipped" (the equivalence tests need the reference file named below); inside the paper's repository they end with "12 passed". The example prints:
63
+
64
+ ```
65
+ labels with no information, 40 recordings, p < 0.05:
66
+ permutation_test_score (trial labels permuted): 14 (35%)
67
+ block_permutation_test_score (block labels permuted): 1 (2%)
68
+ ```
69
+
70
+ The test `test_equivalence_with_reference` checks that, with `exact=False`, the same `random_state` Generator and the same folds, this function returns the same observed score, null scores and p value (except that it returns no null when the observed score is undefined) as the reference implementation used in the analyses (`analyses/step2-comparison/code/sim.py` in the paper's repository; another location can be given with `REFERENCE_SIM_DIR`), for shuffled 5-fold, re-split stratified 5-fold, GroupKFold by block and a user `draw`; it is skipped when that file is not present. With the default `exact="auto"`, designs with no more arrangements than `n_permutations` are enumerated instead (the null then has one score per arrangement).
71
+
72
+ ## Tutorial, MATLAB and Octave
73
+ - `docs/TUTORIAL.md`: a step-by-step guide (folds, strata, pseudosessions, few blocks, fixed block orders, checking a pipeline with labels that carry no information).
74
+ - `matlab/`: a MATLAB and Octave port, `block_permutation_test.m`, which takes a function returning the cross-validated score for a label vector. This Python function is the reference; the port is checked against it in `matlab/crosscheck/` (`matlab/README.md`).
75
+ - On PyPI: `pip install blockperm` (version 0.1.0, the version used in the paper).
76
+
77
+ ## Not included
78
+ No `n_jobs` and no fit parameters (unlike `permutation_test_score`); the permutations run one after another. License: MIT (`LICENSE`).
@@ -0,0 +1,10 @@
1
+ LICENSE
2
+ README.md
3
+ blockperm.py
4
+ pyproject.toml
5
+ blockperm.egg-info/PKG-INFO
6
+ blockperm.egg-info/SOURCES.txt
7
+ blockperm.egg-info/dependency_links.txt
8
+ blockperm.egg-info/requires.txt
9
+ blockperm.egg-info/top_level.txt
10
+ tests/test_blockperm.py
@@ -0,0 +1,5 @@
1
+ numpy>=2.0
2
+ scikit-learn>=1.4
3
+
4
+ [test]
5
+ pytest
@@ -0,0 +1 @@
1
+ blockperm
@@ -0,0 +1,230 @@
1
+ """Block permutation test for cross-validated decoding when labels follow recording blocks.
2
+
3
+ `block_permutation_test_score` has the call pattern of scikit-learn's `permutation_test_score`. It differs in what
4
+ it permutes: the labels of whole blocks are permuted between blocks (training and held-out trials together), and
5
+ the whole cross-validation, including every step fitted to the labels, is rerun for every permutation. A label
6
+ vector can instead be drawn from a user-given generator (for example pseudosessions drawn from the task's own
7
+ block generator).
8
+
9
+ This is the block permutation rule of the accompanying analyses. The enumerated null is exact, and the sampled p
10
+ value valid, when the observed assignment was drawn uniformly from the set the function permutes over: all
11
+ arrangements of the block labels, or all arrangements within strata if the design randomised within strata. For a
12
+ constrained randomisation (counterbalancing, limits per run), pass the design's own generator as `draw`. For a
13
+ fixed block order the test rests on an exchangeability model, not on the randomisation.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import numbers
19
+ import warnings
20
+ from collections import Counter
21
+
22
+ import numpy as np
23
+ from sklearn.base import clone, is_classifier
24
+ from sklearn.metrics import check_scoring
25
+ from sklearn.model_selection import check_cv
26
+
27
+ __all__ = ["block_permutation_test_score", "block_labels_of", "n_distinct_arrangements"]
28
+
29
+
30
+ def block_labels_of(y, blocks):
31
+ """Per-block labels (in the order of np.unique(blocks)) and the position of each trial's block.
32
+
33
+ Raises ValueError if a block holds more than one label."""
34
+ y = np.asarray(y)
35
+ blocks = np.asarray(blocks)
36
+ if y.shape[0] != blocks.shape[0]:
37
+ raise ValueError("y and blocks must have the same length")
38
+ ub = np.unique(blocks)
39
+ pos = np.searchsorted(ub, blocks)
40
+ lab = []
41
+ for i in range(len(ub)):
42
+ v = np.unique(y[pos == i])
43
+ if len(v) != 1:
44
+ raise ValueError("labels must be constant within blocks (block %r holds %d labels)" % (ub[i], len(v)))
45
+ lab.append(v[0])
46
+ return np.array(lab), pos
47
+
48
+
49
+ def n_distinct_arrangements(block_labels, strata=None):
50
+ """Number of distinct block-label vectors reachable by permuting labels between blocks (within strata).
51
+ Both arguments are per block (one value per block, in the order of np.unique(blocks))."""
52
+ block_labels = np.asarray(block_labels)
53
+ strata = None if strata is None else np.asarray(strata)
54
+ groups = [np.arange(len(block_labels))] if strata is None else [np.where(strata == s)[0] for s in np.unique(strata)]
55
+ n = 1
56
+ for g in groups:
57
+ c = Counter(block_labels[g].tolist())
58
+ k = math.factorial(len(g))
59
+ for v in c.values():
60
+ k //= math.factorial(v)
61
+ n *= k
62
+ return n
63
+
64
+
65
+ def _cv_score(estimator, X, y, splits, scorer, error_score):
66
+ scores = []
67
+ for tr, te in splits:
68
+ if is_classifier(estimator) and len(np.unique(y[tr])) < 2:
69
+ return error_score
70
+ m = clone(estimator).fit(X[tr], y[tr])
71
+ scores.append(scorer(m, X[te], y[te]))
72
+ return float(np.mean(scores))
73
+
74
+
75
+ def _splits(cv, X, y, groups):
76
+ with warnings.catch_warnings(): # splitters that ignore groups (KFold, StratifiedKFold) warn
77
+ warnings.filterwarnings("ignore", message="The groups parameter is ignored")
78
+ return list(cv.split(X, y, groups))
79
+
80
+
81
+ def block_permutation_test_score(estimator, X, y, *, blocks, cv=None, n_permutations=1000, scoring=None,
82
+ random_state=None, strata=None, draw=None, groups="blocks", exact="auto",
83
+ error_score=np.nan):
84
+ """Evaluate the significance of a cross-validated score by permuting labels between blocks.
85
+
86
+ Parameters
87
+ ----------
88
+ estimator : scikit-learn estimator (a Pipeline with every label-fitted step: scaling, selection, model)
89
+ X : array (n_trials, n_features)
90
+ y : array (n_trials,), constant within each block
91
+ blocks : array (n_trials,), block identity of each trial (a recording block of consecutive trials)
92
+ cv : as in scikit-learn (int, splitter or iterable); default 5-fold. A splitter is re-applied to every
93
+ permuted label vector (as scikit-learn does), so label-stratified folds follow the permuted labels.
94
+ n_permutations : number of permutations (ignored when the exact enumeration is used)
95
+ scoring : as in scikit-learn; default the estimator's score (accuracy for classifiers). The score is the mean
96
+ over folds, as in `permutation_test_score`.
97
+ random_state : int, numpy Generator, RandomState or None (passed to numpy.random.default_rng unless a
98
+ Generator); the permutations use this stream only, so the same Generator and folds reproduce the same null
99
+ strata : optional array (n_trials,), constant within blocks: labels are permuted only between blocks of the
100
+ same stratum. Use the unit the design randomised over (for example within runs if blocks were randomised
101
+ within runs); permuting across that unit can reject too often.
102
+ draw : optional callable(rng) -> per-block label vector (order of np.unique(blocks)); replaces the
103
+ permutation, for example to draw pseudosessions from the task's generator. `strata` and `exact` are
104
+ ignored when `draw` is given.
105
+ groups : "blocks" (default) passes the block identity to `cv.split` as `groups`, so GroupKFold-type splitters
106
+ hold out whole blocks; None passes nothing; an array is passed as given.
107
+ exact : "auto" enumerates all distinct block-label arrangements when there are no more of them than
108
+ `n_permutations` (the null then has one score per arrangement, the observed one included, and its length
109
+ is the number of arrangements, not `n_permutations`); True forces the enumeration (at most 100,000
110
+ arrangements); False always samples with replacement.
111
+ error_score : number used as the score of a cross-validation with a one-class training fold (classifiers).
112
+ Other fit errors are raised. NaN null scores count as exceedances (conservative); an undefined observed
113
+ score returns p = NaN with a warning.
114
+
115
+ Returns
116
+ -------
117
+ score : float, observed cross-validated score
118
+ permutation_scores : array, scores under the null (empty when the observed score is undefined; the
119
+ reference implementation returned its full null in that case, with p NaN as here)
120
+ pvalue : float, (1 + #{null >= score} + #{null NaN}) / (n_null + 1) when sampling; when enumerating,
121
+ (1 + #{other arrangements with score >= observed or NaN}) / (number of arrangements), the observed
122
+ arrangement counted once with its observed score (also when the cross-validation is not deterministic).
123
+ """
124
+ X = np.asarray(X)
125
+ y = np.asarray(y)
126
+ blocks = np.asarray(blocks)
127
+ if X.shape[0] != y.shape[0] or blocks.shape[0] != y.shape[0]:
128
+ raise ValueError("X, y and blocks must have the same number of trials")
129
+ if not (isinstance(exact, (bool, np.bool_)) or (isinstance(exact, str) and exact == "auto")):
130
+ raise ValueError('exact must be "auto", True or False')
131
+ exact = exact if isinstance(exact, str) else bool(exact)
132
+ if not isinstance(n_permutations, numbers.Integral) or isinstance(n_permutations, bool) or n_permutations < 1:
133
+ raise ValueError("n_permutations must be an integer of at least 1")
134
+ if isinstance(groups, str) and groups != "blocks":
135
+ raise ValueError('groups must be "blocks", None or an array with one value per trial')
136
+ if not isinstance(error_score, numbers.Real) or isinstance(error_score, (bool, np.bool_)):
137
+ raise ValueError("error_score must be a number (NaN by default)")
138
+ lab, pos = block_labels_of(y, blocks)
139
+ rng = random_state if isinstance(random_state, np.random.Generator) else np.random.default_rng(random_state)
140
+ cv = check_cv(cv, y, classifier=is_classifier(estimator))
141
+ scorer = check_scoring(estimator, scoring=scoring)
142
+ g = blocks if isinstance(groups, str) else groups
143
+ if strata is not None:
144
+ if np.asarray(strata).shape[0] != y.shape[0]:
145
+ raise ValueError("strata must have one value per trial")
146
+ st_lab, _ = block_labels_of(np.asarray(strata), blocks)
147
+ else:
148
+ st_lab = None
149
+
150
+ score = _cv_score(estimator, X, y, _splits(cv, X, y, g), scorer, error_score)
151
+ if not np.isfinite(score):
152
+ warnings.warn("the observed score is undefined (a training fold with one class, or a scorer that returned "
153
+ "NaN); p is NaN and no null was computed", stacklevel=2)
154
+ return score, np.array([], float), float("nan")
155
+
156
+ n_arr = n_distinct_arrangements(lab, st_lab) if draw is None else None
157
+ use_exact = draw is None and (exact is True or (exact == "auto" and n_arr <= n_permutations))
158
+ if draw is None and n_arr < 20:
159
+ warnings.warn("only %d distinct block-label arrangements: with exact enumeration p cannot fall below %.3g, "
160
+ "and not below %.3g when swapping the two classes leaves the score unchanged (a refit two-class "
161
+ "classifier with balanced blocks)%s"
162
+ % (n_arr, 1.0 / n_arr, 2.0 / n_arr, "" if use_exact else "; consider exact=True"), stacklevel=2)
163
+
164
+ if use_exact:
165
+ if n_arr > 100_000:
166
+ raise ValueError("too many arrangements to enumerate (%d)" % n_arr)
167
+ arrangements = _enumerate(lab, st_lab)
168
+ obs = next(i for i, a in enumerate(arrangements) if np.array_equal(a, lab))
169
+ null = np.array([score if i == obs else
170
+ _cv_score(estimator, X, a[pos], _splits(cv, X, a[pos], g), scorer, error_score)
171
+ for i, a in enumerate(arrangements)])
172
+ others = np.delete(null, obs)
173
+ ge = 1 + np.sum(others[~np.isnan(others)] >= score - 1e-12) + np.isnan(others).sum()
174
+ return score, null, float(ge / len(null))
175
+
176
+ null = []
177
+ for _ in range(n_permutations):
178
+ if draw is not None:
179
+ lp = np.asarray(draw(rng))
180
+ if lp.shape != lab.shape:
181
+ raise ValueError("draw must return one label per block")
182
+ elif st_lab is None:
183
+ lp = rng.permutation(lab)
184
+ else:
185
+ lp = lab.copy()
186
+ for s in np.unique(st_lab):
187
+ i = np.where(st_lab == s)[0]
188
+ lp[i] = lab[rng.permutation(i)]
189
+ yp = lp[pos]
190
+ null.append(_cv_score(estimator, X, yp, _splits(cv, X, yp, g), scorer, error_score))
191
+ null = np.array(null, float)
192
+ p = (1 + np.sum(null[~np.isnan(null)] >= score - 1e-12) + np.isnan(null).sum()) / (len(null) + 1)
193
+ return score, null, float(p)
194
+
195
+
196
+ def _multiset_permutations(items):
197
+ """Distinct orderings of a list with repeated values, each once (no n! intermediate list)."""
198
+ counts = Counter(items)
199
+ keys = sorted(counts)
200
+ n = len(items)
201
+ cur = []
202
+
203
+ def rec():
204
+ if len(cur) == n:
205
+ yield tuple(cur)
206
+ return
207
+ for k in keys:
208
+ if counts[k]:
209
+ counts[k] -= 1
210
+ cur.append(k)
211
+ yield from rec()
212
+ cur.pop()
213
+ counts[k] += 1
214
+ yield from rec()
215
+
216
+
217
+ def _enumerate(lab, st_lab):
218
+ """All distinct block-label vectors reachable by permuting within strata (observed one included)."""
219
+ idx_groups = [np.arange(len(lab))] if st_lab is None else [np.where(st_lab == s)[0] for s in np.unique(st_lab)]
220
+ per_group = [list(_multiset_permutations(lab[i].tolist())) for i in idx_groups]
221
+ out = []
222
+ def rec(k, cur):
223
+ if k == len(idx_groups):
224
+ out.append(cur.copy())
225
+ return
226
+ for arr in per_group[k]:
227
+ cur[idx_groups[k]] = arr
228
+ rec(k + 1, cur)
229
+ rec(0, lab.copy())
230
+ return out
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "blockperm"
7
+ version = "0.1.0"
8
+ description = "Block permutation test for cross-validated decoding when labels follow recording blocks"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{name = "Dongjin Choi"}]
13
+ requires-python = ">=3.12"
14
+ dependencies = ["numpy>=2.0", "scikit-learn>=1.4"]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/jinchoi7651507/block-permutation-decoding"
18
+
19
+ [project.optional-dependencies]
20
+ test = ["pytest"]
21
+
22
+ [tool.setuptools]
23
+ py-modules = ["blockperm"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,226 @@
1
+ """Tests of block_permutation_test_score (no study data; synthetic recordings only)."""
2
+ import os
3
+ import sys
4
+ import warnings
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pytest
9
+ from sklearn.linear_model import LogisticRegression
10
+ from sklearn.model_selection import KFold, StratifiedKFold, GroupKFold, permutation_test_score
11
+ from sklearn.pipeline import Pipeline
12
+ from sklearn.preprocessing import StandardScaler
13
+
14
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
15
+ from blockperm import block_permutation_test_score, block_labels_of, n_distinct_arrangements # noqa: E402
16
+
17
+ _HERE = Path(__file__).resolve()
18
+ _CANDIDATES = [_HERE.parents[2] / "analyses" / "step2-comparison" / "code", # public repository layout
19
+ _HERE.parents[3] / "results" / "step2-comparison" / "code"] # working-repository layout
20
+ REFERENCE = Path(os.environ.get("REFERENCE_SIM_DIR", next((c for c in _CANDIDATES if (c / "sim.py").exists()), _CANDIDATES[0])))
21
+
22
+
23
+ def reference_sim():
24
+ """The reference implementation used in the analyses (read only; no bytecode written next to it)."""
25
+ old = sys.dont_write_bytecode
26
+ sys.dont_write_bytecode = True
27
+ sys.path.insert(0, str(REFERENCE))
28
+ try:
29
+ import sim
30
+ finally:
31
+ sys.dont_write_bytecode = old
32
+ return sim
33
+
34
+
35
+ def lr():
36
+ return Pipeline([("scale", StandardScaler()), ("lr", LogisticRegression(C=1.0, max_iter=1000))])
37
+
38
+
39
+ def drifting_counts(n_trials, n_units, rng, tau=20.0, sd=0.6):
40
+ """Poisson counts with one slow shared AR(1) factor (no label signal)."""
41
+ a = np.exp(-1.0 / tau)
42
+ z = np.empty(n_trials)
43
+ z[0] = rng.standard_normal()
44
+ e = rng.standard_normal(n_trials) * np.sqrt(1 - a * a)
45
+ for t in range(1, n_trials):
46
+ z[t] = a * z[t - 1] + e[t]
47
+ load = rng.standard_normal(n_units)
48
+ return rng.poisson(np.exp(np.log(5.0) + sd * z[:, None] * load[None, :])).astype(float)
49
+
50
+
51
+ def random_block_design(n_blocks, block_len, rng):
52
+ blocks = np.repeat(np.arange(n_blocks), block_len)
53
+ lab = np.zeros(n_blocks, int)
54
+ lab[rng.choice(n_blocks, n_blocks // 2, replace=False)] = 1
55
+ return blocks, lab[blocks]
56
+
57
+
58
+ @pytest.mark.skipif(not (REFERENCE / "sim.py").exists(), reason="reference sim.py not found")
59
+ @pytest.mark.parametrize("split", ["main", "tool", "block", "draw"])
60
+ def test_equivalence_with_reference(split):
61
+ """Same data, folds and random stream (exact=False): identical observed score, null scores and p as the
62
+ reference implementation, for shuffled 5-fold, re-split stratified 5-fold, GroupKFold by block and a user draw."""
63
+ sim = reference_sim()
64
+ rng = np.random.default_rng(11)
65
+ blocks, y = random_block_design(12, 20, rng)
66
+ X = sim.simulate2(240, rng, n_units=40, tau_s=20.0)
67
+ est = sim.pipeline(k=20)
68
+ draw = None
69
+ if split in ("main", "draw"):
70
+ fl = sim.folds("main", X, y, blocks, seed=5)
71
+ cv_new, groups = fl, None
72
+ if split == "draw":
73
+ draw = lambda g: g.permutation(np.array([0, 1] * 6))
74
+ elif split == "tool":
75
+ fl = sim.Resplit(StratifiedKFold(5), blocks)
76
+ cv_new, groups = StratifiedKFold(5), "blocks"
77
+ else:
78
+ fl = sim.folds("block", X, y, blocks, seed=5)
79
+ cv_new, groups = GroupKFold(5), "blocks"
80
+ obs, null, p = sim.block_permutation_test_score(est, X, y, blocks, fl, 40, np.random.default_rng(3), draw=draw)
81
+ s2, null2, p2 = block_permutation_test_score(est, X, y, blocks=blocks, cv=cv_new, n_permutations=40,
82
+ random_state=np.random.default_rng(3), groups=groups, exact=False,
83
+ draw=draw)
84
+ assert s2 == pytest.approx(obs, abs=1e-12)
85
+ np.testing.assert_allclose(null2, null, atol=1e-12)
86
+ assert p2 == pytest.approx(p, abs=1e-12)
87
+
88
+
89
+ def test_exact_p_is_valid_over_all_arrangements():
90
+ """6 blocks, 3 per label: 20 arrangements. Taking each in turn as the observed one, the share with p <= alpha
91
+ is at most alpha (the exact randomisation property), at alpha 0.05, 0.10 and 0.25."""
92
+ rng = np.random.default_rng(8)
93
+ blocks = np.repeat(np.arange(6), 20)
94
+ X = drifting_counts(120, 8, rng)
95
+ cv = KFold(5, shuffle=True, random_state=0)
96
+ from blockperm import _enumerate
97
+ arrs = _enumerate(np.array([0, 0, 0, 1, 1, 1]), None)
98
+ assert len(arrs) == 20
99
+ with warnings.catch_warnings():
100
+ warnings.simplefilter("ignore")
101
+ ps = np.array([block_permutation_test_score(lr(), X, a[blocks], blocks=blocks, cv=cv, groups=None,
102
+ exact=True)[2] for a in arrs])
103
+ assert np.all(ps >= 1 / 20 - 1e-12)
104
+ for alpha in (0.05, 0.10, 0.25):
105
+ assert np.mean(ps <= alpha + 1e-12) <= alpha + 1e-12
106
+
107
+
108
+ def test_exact_p_not_below_one_over_n_with_random_folds():
109
+ """Shuffled folds without a seed differ between calls; the observed arrangement still counts once."""
110
+ rng = np.random.default_rng(9)
111
+ blocks = np.repeat(np.arange(6), 20)
112
+ y = np.array([0, 1, 0, 1, 0, 1])[blocks]
113
+ X = drifting_counts(120, 8, rng)
114
+ with warnings.catch_warnings():
115
+ warnings.simplefilter("ignore")
116
+ for _ in range(3):
117
+ _, null, p = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=KFold(5, shuffle=True),
118
+ groups=None, exact=True)
119
+ assert len(null) == 20 and p >= 1 / 20 - 1e-12
120
+
121
+
122
+ def test_size_under_drift_and_contrast_with_trial_permutation():
123
+ """Random block assignment with slow drift, no signal: the block test stays near 5%; scikit-learn's
124
+ trial-level permutation_test_score with shuffled 5-fold rejects far more often (the failure it replaces)."""
125
+ reps, n_perm = 60, 49
126
+ rej_block, rej_trial = 0, 0
127
+ for r in range(reps):
128
+ rng = np.random.default_rng(1000 + r)
129
+ blocks, y = random_block_design(12, 20, rng)
130
+ X = drifting_counts(240, 20, rng)
131
+ cv = KFold(5, shuffle=True, random_state=r)
132
+ _, _, p = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=cv, n_permutations=n_perm,
133
+ random_state=r, groups=None, exact=False)
134
+ _, _, pt = permutation_test_score(lr(), X, y, cv=cv, n_permutations=n_perm, random_state=r)
135
+ rej_block += p < 0.05
136
+ rej_trial += pt < 0.05
137
+ assert rej_block / reps <= 0.14 # 99% binomial upper limit for 5% at n = 60 is about 0.13
138
+ assert rej_trial / reps >= 0.30
139
+
140
+
141
+ def test_exact_enumeration_small_design():
142
+ rng = np.random.default_rng(2)
143
+ blocks, y = random_block_design(4, 30, rng)
144
+ X = drifting_counts(120, 10, rng)
145
+ with warnings.catch_warnings(record=True) as w:
146
+ warnings.simplefilter("always")
147
+ s, null, p = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=GroupKFold(4), n_permutations=1000,
148
+ random_state=0)
149
+ assert any("distinct block-label arrangements" in str(x.message) for x in w)
150
+ assert len(null) == 6 # 4 blocks, 2 per label: 4!/(2!2!) arrangements
151
+ assert p >= 1 / 6 - 1e-12
152
+ assert np.any(np.isclose(null, s)) # the observed arrangement is one of them
153
+
154
+
155
+ def test_strata_restrict_permutations():
156
+ rng = np.random.default_rng(4)
157
+ blocks = np.repeat(np.arange(8), 15)
158
+ lab = np.array([0, 1, 0, 1, 1, 0, 1, 0])
159
+ y = lab[blocks]
160
+ strata = (blocks >= 4).astype(int) # two halves, 2 + 2 blocks per label in each
161
+ assert n_distinct_arrangements(lab, None) == 70
162
+ assert n_distinct_arrangements(lab, np.array([0, 0, 0, 0, 1, 1, 1, 1])) == 36
163
+ X = drifting_counts(120, 10, rng)
164
+ _, null, _ = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=KFold(5, shuffle=True, random_state=0),
165
+ n_permutations=36, random_state=0, strata=strata, groups=None,
166
+ exact=True)
167
+ assert len(null) == 36
168
+
169
+
170
+ def test_draw_generator_is_used():
171
+ rng = np.random.default_rng(5)
172
+ blocks, y = random_block_design(10, 20, rng)
173
+ X = drifting_counts(200, 10, rng)
174
+ fixed = np.array([0, 1] * 5)
175
+ cv = KFold(5, shuffle=True, random_state=0)
176
+ _, null, _ = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=cv, n_permutations=5,
177
+ random_state=0, draw=lambda g: fixed, groups=None)
178
+ ref, _, _ = block_permutation_test_score(lr(), X, fixed[blocks], blocks=blocks, cv=cv, n_permutations=1,
179
+ random_state=0, groups=None, exact=False)
180
+ np.testing.assert_allclose(null, ref)
181
+
182
+
183
+ def test_input_errors():
184
+ blocks = np.repeat(np.arange(6), 10)
185
+ y = np.repeat([0, 1, 0, 1, 0, 1], 10)
186
+ y_bad = y.copy()
187
+ y_bad[3] = 1 - y_bad[3]
188
+ X = np.random.default_rng(0).standard_normal((60, 4))
189
+ with pytest.raises(ValueError, match="constant within blocks"):
190
+ block_permutation_test_score(lr(), X, y_bad, blocks=blocks)
191
+ with pytest.raises(ValueError, match="one label per block"):
192
+ block_permutation_test_score(lr(), X, y, blocks=blocks, draw=lambda g: np.zeros(3), n_permutations=2)
193
+ with pytest.raises(ValueError, match="groups must be"):
194
+ block_permutation_test_score(lr(), X, y, blocks=blocks, groups="block")
195
+ with pytest.raises(ValueError, match="exact must be"):
196
+ block_permutation_test_score(lr(), X, y, blocks=blocks, exact="yes")
197
+ with pytest.raises(ValueError, match="at least 1"):
198
+ block_permutation_test_score(lr(), X, y, blocks=blocks, n_permutations=1.5)
199
+ with pytest.raises(ValueError, match="one value per trial"):
200
+ block_permutation_test_score(lr(), X, y, blocks=blocks, strata=np.zeros(5))
201
+ with pytest.raises(ValueError, match="at least 1"):
202
+ block_permutation_test_score(lr(), X, y, blocks=blocks, n_permutations=0)
203
+ with pytest.raises(ValueError, match="error_score"):
204
+ block_permutation_test_score(lr(), X, y, blocks=blocks, error_score="raise")
205
+ with pytest.raises(ValueError, match="same number of trials"):
206
+ block_permutation_test_score(lr(), X[:50], y, blocks=blocks)
207
+
208
+
209
+ def test_single_class_training_fold_counts_as_exceedance():
210
+ """Folds hold out blocks {2, 3} and {0, 1}. Observed labels 0101 give two classes in every training fold;
211
+ the arrangements 0011 and 1100 leave one class in training, score NaN and count as exceedances. An observed
212
+ arrangement with a one-class training fold gives p = NaN."""
213
+ blocks = np.repeat(np.arange(4), 10)
214
+ lab, _ = block_labels_of(np.repeat([0, 0, 1, 1], 10), blocks)
215
+ assert list(lab) == [0, 0, 1, 1]
216
+ i01, i23 = np.where(blocks < 2)[0], np.where(blocks >= 2)[0]
217
+ folds = [(i01, i23), (i23, i01)]
218
+ X = np.random.default_rng(1).standard_normal((40, 3))
219
+ y = np.repeat([0, 1, 0, 1], 10)
220
+ s, null, p = block_permutation_test_score(lr(), X, y, blocks=blocks, cv=folds, groups=None, random_state=0)
221
+ assert np.isfinite(s) and np.isnan(null).sum() == 2 and len(null) == 6
222
+ ge = np.sum(null[~np.isnan(null)] >= s - 1e-12) + np.isnan(null).sum()
223
+ assert p == pytest.approx(ge / len(null))
224
+ _, _, p_bad = block_permutation_test_score(lr(), X, np.repeat([0, 0, 1, 1], 10), blocks=blocks, cv=folds,
225
+ groups=None, random_state=0)
226
+ assert np.isnan(p_bad)