Skip to content

Commit 994836b

Browse files
Relicense Python package to BSD-3-Clause
Add a dedicated BSD-3-Clause LICENSE for the Python package and update packaging metadata/classifiers to match, including shipping the license file in builds. Refresh the README for PyPI with clearer positioning, usage, performance, and compatibility notes. Also update setup.py to source the C core from the repo’s c/ directory and add SPDX/copyright headers across Python and Cython modules.
1 parent 817b7d2 commit 994836b

10 files changed

Lines changed: 188 additions & 82 deletions

File tree

python/LICENSE

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
BSD 3-Clause License
2+
3+
Copyright (c) 2020-2026, Christian Gaser
4+
Structural Brain Mapping Group, Jena University Hospital
5+
All rights reserved.
6+
7+
Redistribution and use in source and binary forms, with or without
8+
modification, are permitted provided that the following conditions are met:
9+
10+
1. Redistributions of source code must retain the above copyright notice, this
11+
list of conditions and the following disclaimer.
12+
13+
2. Redistributions in binary form must reproduce the above copyright notice,
14+
this list of conditions and the following disclaimer in the documentation
15+
and/or other materials provided with the distribution.
16+
17+
3. Neither the name of the copyright holder nor the names of its
18+
contributors may be used to endorse or promote products derived from
19+
this software without specific prior written permission.
20+
21+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

python/README.md

Lines changed: 120 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,89 @@
1-
# tfce (Python)
1+
# tfce
22

3-
Exact threshold-free cluster enhancement, and the permutation machinery around it,
4-
as a plain Python package.
3+
**Exact threshold-free cluster enhancement, and the permutation inference around it.**
54

6-
This is **not a reimplementation**. The transform is the same C max-tree the
7-
MATLAB toolbox runs — compiled straight from the repository root into a Cython
8-
extension — so the two give bit-identical answers, and there is only ever one
9-
implementation of TFCE in this repository to get right.
5+
[![PyPI](https://img.shields.io/pypi/v/tfce.svg)](https://pypi.org/project/tfce/)
6+
[![Python](https://img.shields.io/pypi/pyversions/tfce.svg)](https://pypi.org/project/tfce/)
7+
[![License](https://img.shields.io/pypi/l/tfce.svg)](https://github.com/ChristianGaser/tfce/blob/master/python/LICENSE)
108

11-
```python
12-
import numpy as np
13-
import tfce
9+
TFCE combines focal effects of large height with broad effects of large extent, and needs **no
10+
cluster-forming threshold** — the arbitrary choice that cluster-based inference forces on you, and
11+
that the result can depend on heavily.
1412

15-
# volume: (nx, ny, nz) or (nx, ny, nz, n_permutations)
16-
t = tfce.tfce(stat_map, E=0.5, H=2.0)
13+
```bash
14+
pip install tfce
15+
```
1716

18-
# surface: faces are 1-based, as GIFTI stores them
19-
adj = tfce.adjacency_from_faces(faces, n_vertices)
20-
t = tfce.tfce(surf_map, adjacency=adj, E=1.0, H=2.0)
17+
Wheels for Linux, macOS and Windows. No compiler needed.
2118

22-
# a block of permutations, one per thread
23-
t = tfce.tfce(perms, E=0.5, H=2.0, n_jobs=-1)
24-
```
19+
---
2520

26-
## Why exact matters
21+
## Exact, not stepped
2722

28-
The TFCE of an element is an integral,
23+
The TFCE of an element is an integral:
2924

30-
```
25+
```text
3126
TFCE(v) = ∫ e_v(h)^E · h^H dh
3227
```
3328

34-
and implementations normally approximate it by stepping `h` over a grid and
35-
summing. That costs a step size, a discretisation error that depends on it, and an
36-
accuracy parameter the caller has to guess. This one builds the **max-tree** (the
37-
component tree) with union-find, and because the extent function `e_v(h)` is
38-
piecewise constant it integrates each piece in closed form. There is nothing to
39-
tune.
29+
over the extent `e_v(h)` of the cluster containing `v` at height `h`. Implementations normally
30+
approximate it by stepping `h` over a grid and summing. That costs a step size `dh`, a discretisation
31+
error that depends on it, and an accuracy parameter you have to guess.
32+
33+
This one doesn't. It builds the **max-tree** (the component tree) with union-find, and because
34+
`e_v(h)` is piecewise constant, integrates each piece in closed form. The answer is the integral, not
35+
a sample of it. **There is nothing to tune.**
4036

41-
The difference is not academic. Against nilearn's stepped transform on a 60×72×60
42-
volume:
37+
The difference is not academic. Against a stepped implementation on a 60×72×60 volume:
4338

44-
| n_steps | max error vs exact |
39+
| steps | error vs exact |
4540
| --- | --- |
4641
| 50 | 3.4% |
47-
| 100 *(nilearn's default)* | **1.7%** |
42+
| 100 | **1.7%** |
4843
| 200 | 0.8% |
4944
| 400 | 0.4% |
5045

51-
The error halves every time the steps double — first order, exactly as a step-size
52-
approximation must. The exact transform has no such term, and is also faster:
46+
The error halves every time the steps double — first order, exactly as a step-size approximation must
47+
behave. The exact transform has no such term.
48+
49+
## Quick start
5350

54-
| | one volume | 16 permutations |
51+
```python
52+
import numpy as np
53+
import tfce
54+
55+
# a volume: (nx, ny, nz)
56+
t = tfce.tfce(stat_map, E=0.5, H=2.0)
57+
58+
# a surface: faces are 1-based, as GIFTI stores them
59+
adj = tfce.adjacency_from_faces(faces, n_vertices)
60+
t = tfce.tfce(surf_map, adjacency=adj, E=1.0, H=2.0)
61+
62+
# a block of permutations, one per thread
63+
# (nx, ny, nz, n_perm) -> (nx, ny, nz, n_perm)
64+
t = tfce.tfce(perms, E=0.5, H=2.0, n_jobs=-1)
65+
```
66+
67+
Volumes take `connectivity=6 | 18 | 26` (26 is the default, and what fslmaths and the MATLAB toolbox
68+
use). Surfaces take the mesh, so the neighbourhood is whatever the mesh says it is.
69+
70+
Everything is arrays in, arrays out. No image objects, no file I/O, no design parsing — those belong
71+
in a layer above, so the core can be dropped anywhere.
72+
73+
## Speed
74+
75+
Permutations are independent, so they are TFCE'd a block at a time, one per thread, with the GIL
76+
released. On the same 60×72×60 volume:
77+
78+
| | one map | 16 permutations |
5579
| --- | --- | --- |
56-
| nilearn (100 steps) | 0.38 s | 9.1 s (569 ms/perm) |
57-
| exact max-tree | **0.03 s** (14×) | **0.13 s** (8 ms/perm, **72×**) |
80+
| stepped (100 steps) | 0.38 s | 9.1 s 569 ms/perm |
81+
| **tfce** | **0.03 s** (14×) | **0.13 s** 8 ms/perm (**72×**) |
5882

59-
## Using it inside nilearn
83+
## Using it with nilearn
6084

61-
nilearn already has `permuted_ols(..., tfce=True)`. Its TFCE is
62-
`nilearn.mass_univariate._utils.calculate_tfce`, and `tfce.nilearn_compat`
63-
provides a drop-in with the same signature:
85+
nilearn has `permuted_ols(..., tfce=True)`, and its TFCE is a stepped approximation. `tfce` ships a
86+
drop-in with the same signature:
6487

6588
```python
6689
from nilearn.mass_univariate import _utils
@@ -69,49 +92,73 @@ import tfce.nilearn_compat as tc
6992
_utils.calculate_tfce = tc.calculate_tfce # now exact, and much faster
7093
```
7194

72-
One thing to know: nilearn builds its neighbourhood with
73-
`generate_binary_structure(3, 1)`, which is **6-connectivity**. The MATLAB toolbox
74-
and fslmaths use **26**. The drop-in reads the neighbourhood out of the
75-
`bin_struct` it is handed, so it reproduces whichever one the caller meant, and
76-
`tfce.tfce(..., connectivity=6|18|26)` lets you say so directly.
95+
It reads the neighbourhood out of the `bin_struct` it is handed, so it reproduces whichever
96+
connectivity the caller meant.
97+
98+
## Fewer permutations
99+
100+
Counting exceedances cannot report a p-value below `1/n_perm`. That floor — not the statistic — is
101+
what forces a permutation test to run many thousands of permutations, and it caps FDR too, since FDR
102+
is computed from the uncorrected p-values. `tfce.tails` removes it:
77103

78-
## What's here
104+
- **Gamma** fit to the null of the *maximum*, for FWE-corrected p-values.
105+
- **Generalised Pareto** fit to each element's own tail, for the uncorrected ones — with the *shape*
106+
pooled across elements, since they all carry the same statistic under the same design and so differ
107+
in scale, not in shape.
79108

80-
| module | what it is |
109+
From 1000 permutations this recovers `p ≈ 1e-4` with the median right, where plain counting returns
110+
zero for 91% of elements and tells you nothing at all.
111+
112+
## What's in the box
113+
114+
| | |
81115
| --- | --- |
82-
| `tfce.core` | the exact transform: arrays in, arrays out |
116+
| `tfce.core` | the exact transform arrays in, arrays out |
83117
| `tfce.tails` | Gamma and Generalised Pareto tail approximations |
84118
| `tfce.glm` | the permuted GLM, which never forms the permuted data |
85-
| `tfce.nilearn_compat` | drop-in for `calculate_tfce` |
119+
| `tfce.nilearn_compat` | drop-in for nilearn's `calculate_tfce` |
86120

87-
The core is deliberately framework-free — numpy and scipy, nothing else. No image
88-
objects, no file I/O, no design parsing. Those belong in a layer above, so that
89-
the core can be vendored anywhere (nilearn included) without dragging a dependency
90-
tree behind it. `nibabel` is an optional extra, wanted only by I/O.
121+
Dependencies are deliberately thin: **numpy and scipy**, nothing else. `nibabel` is an optional extra,
122+
wanted only by I/O.
91123

92-
### Fewer permutations
124+
## Trust
93125

94-
Counting exceedances cannot report a p-value below `1/n_perm`. That floor — not
95-
the statistic — is what forces a permutation test to run many thousands of
96-
permutations, and it caps FDR too, since FDR is computed from the uncorrected
97-
p-values. `tfce.tails` removes it: a **Gamma** fit to the null of the maximum for
98-
the FWE-corrected p-values, and a **Generalised Pareto** fit to each element's own
99-
tail for the uncorrected ones, with the *shape* pooled across elements (they all
100-
carry the same statistic under the same design, so they differ in scale, not in
101-
shape).
126+
This is not a reimplementation. It is the same C core that the
127+
[MATLAB TFCE toolbox](https://github.com/ChristianGaser/tfce) has been running for years, with a
128+
Cython binding instead of a MEX one — so the two give **bit-identical** results, and the test suite
129+
holds them to it. It ships a validation suite of its own: the exactness of the max-tree is established
130+
against an *independent* stepped implementation, which must converge onto it at first order.
102131

103-
## Install
132+
## Citing
104133

105-
```bash
106-
pip install -e . # needs a C compiler; the C core sits at ../
107-
pytest # 49 checks
108-
```
134+
If you use this, please cite the method:
135+
136+
> Smith SM, Nichols TE (2009). *Threshold-free cluster enhancement: addressing problems of smoothing,
137+
> threshold dependence and localisation in cluster inference.* NeuroImage 44:83–98.
138+
> [doi:10.1016/j.neuroimage.2008.03.061](https://doi.org/10.1016/j.neuroimage.2008.03.061)
139+
140+
The tail approximations are from:
141+
142+
> Winkler AM, Ridgway GR, Douaud G, Nichols TE, Smith SM (2016). *Faster permutation inference in
143+
> brain imaging.* NeuroImage 141:502–516.
144+
> [doi:10.1016/j.neuroimage.2016.05.068](https://doi.org/10.1016/j.neuroimage.2016.05.068)
109145
110146
## Status
111147

112-
Early. The transform, the tails and the permuted GLM are here and tested. The
113-
design layer — turning a **BIDS Stats Model** into a design matrix, contrasts and,
114-
crucially, an *exchangeability* structure — is not, and is the real work: BIDS-SM
115-
specifies `X`, `Formula`, `Contrasts` and `GroupBy`, but has **no** concept of
116-
exchangeability blocks or variance groups, so what may be permuted with what has
117-
to be defined on top of it rather than read out of it.
148+
Early, but the parts that are here are tested and are the parts that matter. The transform, the tail
149+
approximations and the permuted GLM are done. Not yet done: the **design layer** — turning a
150+
[BIDS Stats Model](https://bids-standard.github.io/stats-models/) into a design matrix, contrasts and,
151+
crucially, an *exchangeability* structure. That last one is the real work, because BIDS-SM specifies
152+
`X`, `Formula`, `Contrasts` and `GroupBy` but has **no** concept of exchangeability blocks, so what may
153+
be permuted with what has to be defined on top of it rather than read out of it.
154+
155+
## Links
156+
157+
- **Source, issues, MATLAB toolbox:** <https://github.com/ChristianGaser/tfce>
158+
- **Licence:** BSD-3-Clause. Permissive on purpose: `nilearn` is BSD-3 and
159+
`nipreps` is Apache-2.0, and neither can take on a GPL dependency. Nothing here
160+
is derived from SPM, so nothing here has to be GPL. (The MATLAB/SPM toolbox in
161+
the same repository *is* GPL, because it genuinely is derived from SPM — see
162+
[LICENSE.md](https://github.com/ChristianGaser/tfce/blob/master/LICENSE.md).)
163+
164+
Developed by Christian Gaser, Structural Brain Mapping Group, Jena University Hospital.

python/pyproject.toml

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,28 @@ build-backend = "setuptools.build_meta"
55
[project]
66
name = "tfce"
77
version = "0.1.0"
8-
description = "Exact threshold-free cluster enhancement, and the permutation inference around it"
8+
# Shown in PyPI search results, so it has to say what this is and why it is not
9+
# just another TFCE: the integral is evaluated exactly, with no step size.
10+
description = "Exact threshold-free cluster enhancement (TFCE) with no step size, and the permutation inference around it"
911
readme = "README.md"
1012
requires-python = ">=3.9"
11-
license = { text = "GPL-2.0-or-later" }
13+
14+
# BSD-3-Clause, NOT the GPL of the MATLAB toolbox in the same repository.
15+
#
16+
# Nothing here is derived from SPM: neither this package nor the C core it
17+
# compiles in (../c) contains SPM, SnPM or PALM code, so none of it inherits their
18+
# GPL. It is BSD so that permissive projects can depend on it without a second
19+
# thought -- nilearn is BSD-3, nipreps is Apache-2.0, and both can take BSD-3 with
20+
# no friction. BSD-3 is also GPLv2-compatible, so the MATLAB toolbox, which
21+
# compiles the very same C core, stays GPL-2.0-or-later exactly as it always was.
22+
license = { text = "BSD-3-Clause" }
1223
authors = [{ name = "Christian Gaser", email = "christian.gaser@uni-jena.de" }]
1324
keywords = ["neuroimaging", "TFCE", "permutation", "statistics", "fMRI", "VBM"]
1425

1526
classifiers = [
1627
"Development Status :: 4 - Beta",
1728
"Intended Audience :: Science/Research",
18-
"License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)",
29+
"License :: OSI Approved :: BSD License",
1930
"Programming Language :: Python :: 3",
2031
"Programming Language :: C",
2132
"Topic :: Scientific/Engineering :: Medical Science Apps.",
@@ -37,6 +48,9 @@ Homepage = "https://github.com/ChristianGaser/tfce"
3748
Source = "https://github.com/ChristianGaser/tfce"
3849
Issues = "https://github.com/ChristianGaser/tfce/issues"
3950

51+
[tool.setuptools]
52+
license-files = ["LICENSE"]
53+
4054
[tool.setuptools.packages.find]
4155
where = ["src"]
4256

python/setup.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""
24
Build the exact TFCE max-tree as a Python extension.
35
@@ -25,7 +27,7 @@
2527
from Cython.Build import cythonize
2628

2729
HERE = os.path.dirname(os.path.abspath(__file__))
28-
ROOT = os.path.abspath(os.path.join(HERE, os.pardir))
30+
CORE = os.path.abspath(os.path.join(HERE, os.pardir, "c"))
2931
VENDOR = os.path.join("src", "tfce", "_c")
3032

3133
CORE_FILES = [
@@ -43,17 +45,17 @@ def vendor_core():
4345
os.makedirs(dest, exist_ok=True)
4446

4547
for name in CORE_FILES:
46-
src = os.path.join(ROOT, name)
48+
src = os.path.join(CORE, name)
4749
dst = os.path.join(dest, name)
4850

4951
if os.path.exists(src):
50-
# a git checkout: the root is the source of truth, always
52+
# a git checkout: ../c is the source of truth, always
5153
shutil.copyfile(src, dst)
5254
elif not os.path.exists(dst):
5355
raise RuntimeError(
54-
f"{name} is neither at the repository root nor vendored in "
55-
f"{VENDOR}. A source distribution should carry it; a checkout "
56-
f"should have it one directory up."
56+
f"{name} is neither in {CORE} nor vendored in {VENDOR}. "
57+
f"A source distribution should carry it; a checkout should have "
58+
f"it in the c/ folder beside python/."
5759
)
5860

5961

python/src/tfce/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""Exact TFCE and permutation inference.
24
35
The core is deliberately framework-free: arrays in, arrays out, numpy and scipy

python/src/tfce/_maxtree.pyx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# cython: language_level=3
22
# cython: boundscheck=False
33
# cython: wraparound=False
4+
#
5+
# SPDX-License-Identifier: BSD-3-Clause
6+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
47
"""
58
Cython binding to the exact TFCE max-tree.
69

python/src/tfce/core.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""
24
Exact threshold-free cluster enhancement.
35

python/src/tfce/glm.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""
24
The permuted GLM.
35

python/src/tfce/nilearn_compat.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""
24
Drop-in replacement for nilearn's TFCE.
35

python/src/tfce/tails.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
# SPDX-License-Identifier: BSD-3-Clause
2+
# Copyright (c) 2020-2026, Christian Gaser. See LICENSE.
13
"""
24
Tail approximations: how a permutation test stops needing so many permutations.
35

0 commit comments

Comments
 (0)