nltools 0.6.0.dev0__py3-none-any.whl
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.
- nltools/__init__.py +55 -0
- nltools/algorithms/__init__.py +90 -0
- nltools/algorithms/alignment/__init__.py +21 -0
- nltools/algorithms/alignment/procrustes.py +565 -0
- nltools/algorithms/alignment/srm.py +758 -0
- nltools/algorithms/backends.py +1059 -0
- nltools/algorithms/corrections.py +177 -0
- nltools/algorithms/decoding.py +327 -0
- nltools/algorithms/inference/__init__.py +50 -0
- nltools/algorithms/inference/bootstrap.py +1386 -0
- nltools/algorithms/inference/correlation.py +373 -0
- nltools/algorithms/inference/intersubject.py +422 -0
- nltools/algorithms/inference/isc.py +1554 -0
- nltools/algorithms/inference/matrix.py +602 -0
- nltools/algorithms/inference/one_sample.py +288 -0
- nltools/algorithms/inference/random.py +122 -0
- nltools/algorithms/inference/timeseries.py +347 -0
- nltools/algorithms/inference/two_sample.py +212 -0
- nltools/algorithms/inference/utils.py +58 -0
- nltools/algorithms/inference/validation.py +282 -0
- nltools/algorithms/neighborhoods.py +207 -0
- nltools/algorithms/outliers.py +308 -0
- nltools/algorithms/regression.py +83 -0
- nltools/algorithms/signal.py +303 -0
- nltools/algorithms/similarity.py +234 -0
- nltools/algorithms/validation.py +151 -0
- nltools/cross_validation.py +72 -0
- nltools/data/__init__.py +30 -0
- nltools/data/adjacency/__init__.py +875 -0
- nltools/data/adjacency/io.py +111 -0
- nltools/data/adjacency/modeling.py +569 -0
- nltools/data/adjacency/plotting.py +174 -0
- nltools/data/adjacency/state.py +349 -0
- nltools/data/adjacency/stats.py +596 -0
- nltools/data/adjacency/utils.py +79 -0
- nltools/data/atlases/__init__.py +23 -0
- nltools/data/atlases/labeling.py +158 -0
- nltools/data/atlases/loading.py +76 -0
- nltools/data/atlases/registry.py +96 -0
- nltools/data/atlases/reporting.py +456 -0
- nltools/data/braindata/__init__.py +2170 -0
- nltools/data/braindata/analysis.py +1381 -0
- nltools/data/braindata/bootstrap.py +398 -0
- nltools/data/braindata/io.py +896 -0
- nltools/data/braindata/modeling.py +594 -0
- nltools/data/braindata/plotting.py +501 -0
- nltools/data/braindata/prediction.py +1250 -0
- nltools/data/braindata/utils.py +348 -0
- nltools/data/braindata/validation.py +197 -0
- nltools/data/braindata/viewer.js +266 -0
- nltools/data/braindata/viewer.py +770 -0
- nltools/data/combine.py +27 -0
- nltools/data/designmatrix/__init__.py +1032 -0
- nltools/data/designmatrix/append.py +518 -0
- nltools/data/designmatrix/diagnostics.py +248 -0
- nltools/data/designmatrix/io.py +356 -0
- nltools/data/designmatrix/plotting.py +291 -0
- nltools/data/designmatrix/regressors.py +463 -0
- nltools/data/designmatrix/transforms.py +200 -0
- nltools/data/designmatrix/utils.py +350 -0
- nltools/data/ownership.py +129 -0
- nltools/data/results.py +291 -0
- nltools/data/roc/__init__.py +398 -0
- nltools/data/simulator/__init__.py +927 -0
- nltools/data/simulator/haxby.py +124 -0
- nltools/data/validation.py +83 -0
- nltools/datasets.py +218 -0
- nltools/io/__init__.py +10 -0
- nltools/io/events.py +67 -0
- nltools/io/h5.py +246 -0
- nltools/mask.py +403 -0
- nltools/models/__init__.py +11 -0
- nltools/models/glm.py +543 -0
- nltools/models/results.py +49 -0
- nltools/models/ridge.py +1303 -0
- nltools/models/validation.py +26 -0
- nltools/plotting/__init__.py +32 -0
- nltools/plotting/adjacency.py +421 -0
- nltools/plotting/brain.py +669 -0
- nltools/plotting/decomposition.py +111 -0
- nltools/plotting/prediction.py +110 -0
- nltools/resources/covariates_example.csv +161 -0
- nltools/resources/onsets_example.csv +40 -0
- nltools/templates/__init__.py +51 -0
- nltools/templates/config.py +144 -0
- nltools/templates/fetch.py +260 -0
- nltools/templates/matching.py +183 -0
- nltools/templates/paths.py +106 -0
- nltools/templates/registry.py +25 -0
- nltools/utils.py +230 -0
- nltools/version.py +13 -0
- nltools-0.6.0.dev0.dist-info/METADATA +95 -0
- nltools-0.6.0.dev0.dist-info/RECORD +95 -0
- nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
- nltools-0.6.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,927 @@
|
|
|
1
|
+
"""Tools to simulate multivariate brain and grid data for testing analysis pipelines."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import numpy as np
|
|
5
|
+
import nibabel as nib
|
|
6
|
+
from nibabel.affines import voxel_sizes
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from nilearn.image.resampling import coord_transform
|
|
9
|
+
from nilearn.masking import apply_mask, unmask
|
|
10
|
+
from scipy.stats import multivariate_normal, binom, ttest_1samp
|
|
11
|
+
from nltools.data import BrainData
|
|
12
|
+
from nltools.algorithms.corrections import fdr
|
|
13
|
+
from nltools.templates import get_brainspace
|
|
14
|
+
import csv
|
|
15
|
+
from copy import deepcopy
|
|
16
|
+
from sklearn.utils import check_random_state
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _grid_center_world(mask):
|
|
20
|
+
"""Return the world (MNI) millimeter coordinate of a mask's grid center."""
|
|
21
|
+
i, j, k = (np.array(mask.shape) // 2).tolist()
|
|
22
|
+
return [float(v) for v in coord_transform(i, j, k, mask.affine)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Simulator:
|
|
26
|
+
"""Simulate fMRI data with realistic spatial and temporal characteristics.
|
|
27
|
+
|
|
28
|
+
This class provides methods for generating synthetic fMRI data with
|
|
29
|
+
controlled signal patterns, including Gaussian blobs, multi-subject
|
|
30
|
+
datasets, and various noise structures. Useful for testing analysis
|
|
31
|
+
pipelines and power analyses.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
brain_mask (str | nibabel.Nifti1Image, optional): Path to a NIfTI brain mask
|
|
35
|
+
file, a nibabel image, or None to use the default template mask.
|
|
36
|
+
output_dir (str, optional): Directory for saving generated data. Defaults to
|
|
37
|
+
the current working directory.
|
|
38
|
+
random_state (int | np.random.RandomState, optional): Seed or RandomState for
|
|
39
|
+
reproducibility.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
brain_mask (nibabel.Nifti1Image): The brain mask image used for simulation.
|
|
43
|
+
output_dir (str): Output directory path.
|
|
44
|
+
random_state (np.random.RandomState): Random state for reproducible simulations.
|
|
45
|
+
data (BrainData | nibabel.Nifti1Image): Most recently simulated data; set by
|
|
46
|
+
the `create_*` methods.
|
|
47
|
+
y (pl.DataFrame | np.ndarray): Outcome values paired with `data`; set by the
|
|
48
|
+
`create_*` methods.
|
|
49
|
+
rep_id (pl.DataFrame | list): Repetition/subject id per observation; set by
|
|
50
|
+
the `create_*` methods.
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
```python
|
|
54
|
+
from nltools.data.simulator import Simulator
|
|
55
|
+
|
|
56
|
+
sim = Simulator(random_state=42)
|
|
57
|
+
# Create a dataset with signal in specific regions
|
|
58
|
+
data = sim.create_data(levels=[1, -1, 1, -1], sigma=1, reps=10)
|
|
59
|
+
```
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self, *, brain_mask=None, output_dir=None, random_state=None
|
|
64
|
+
): # no scoring param
|
|
65
|
+
# self.resource_folder = os.path.join(os.getcwd(),'resources')
|
|
66
|
+
if output_dir is None:
|
|
67
|
+
self.output_dir = os.path.join(os.getcwd())
|
|
68
|
+
else:
|
|
69
|
+
self.output_dir = output_dir
|
|
70
|
+
|
|
71
|
+
if isinstance(brain_mask, str):
|
|
72
|
+
brain_mask = nib.load(brain_mask)
|
|
73
|
+
elif brain_mask is None:
|
|
74
|
+
brain_mask = nib.load(get_brainspace().mask)
|
|
75
|
+
elif not isinstance(brain_mask, nib.nifti1.Nifti1Image):
|
|
76
|
+
raise ValueError("brain_mask is not a string or a nibabel instance")
|
|
77
|
+
self.brain_mask = brain_mask
|
|
78
|
+
self.random_state = check_random_state(random_state)
|
|
79
|
+
|
|
80
|
+
def gaussian(self, mu, sigma, i_tot):
|
|
81
|
+
"""Create a 3D gaussian signal normalized to a given intensity.
|
|
82
|
+
|
|
83
|
+
Geometry is millimeters: `mu` is a world (MNI) coordinate and `sigma` a
|
|
84
|
+
physical width, both converted to voxel units through the brain mask's
|
|
85
|
+
affine, so the same request describes the same blob on any grid.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
mu (array-like): Center of the gaussian `[x, y, z]` in world (MNI)
|
|
89
|
+
millimeters.
|
|
90
|
+
sigma (float | array-like): Standard deviation in millimeters — a scalar
|
|
91
|
+
for an isotropic blob or one width per axis `[sx, sy, sz]`.
|
|
92
|
+
i_tot (float): Total activation; the gaussian is rescaled so its sum
|
|
93
|
+
within the brain mask equals this value.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
np.ndarray: 3-D array the shape of the brain mask.
|
|
97
|
+
|
|
98
|
+
Note:
|
|
99
|
+
`sigma` is converted per axis with `nibabel.affines.voxel_sizes`, so the
|
|
100
|
+
millimeter widths map onto world axes only for an axis-aligned affine. On
|
|
101
|
+
an oblique affine the blob's principal axes follow the voxel grid.
|
|
102
|
+
"""
|
|
103
|
+
affine = self.brain_mask.affine
|
|
104
|
+
mu_voxel = np.asarray(
|
|
105
|
+
coord_transform(
|
|
106
|
+
float(mu[0]), float(mu[1]), float(mu[2]), np.linalg.inv(affine)
|
|
107
|
+
),
|
|
108
|
+
dtype=float,
|
|
109
|
+
)
|
|
110
|
+
sigma_voxel = np.broadcast_to(
|
|
111
|
+
np.asarray(sigma, dtype=float), (3,)
|
|
112
|
+
) / voxel_sizes(affine)
|
|
113
|
+
|
|
114
|
+
x, y, z = np.mgrid[
|
|
115
|
+
0 : self.brain_mask.shape[0],
|
|
116
|
+
0 : self.brain_mask.shape[1],
|
|
117
|
+
0 : self.brain_mask.shape[2],
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
# Need an (N, 3) array of (x, y) pairs.
|
|
121
|
+
xyz = np.column_stack([x.flat, y.flat, z.flat])
|
|
122
|
+
|
|
123
|
+
covariance = np.diag(sigma_voxel**2)
|
|
124
|
+
g = multivariate_normal.pdf(xyz, mean=mu_voxel, cov=covariance)
|
|
125
|
+
|
|
126
|
+
# Reshape back to a 3D grid.
|
|
127
|
+
g = g.reshape(x.shape).astype(float)
|
|
128
|
+
|
|
129
|
+
# select only the regions within the brain mask
|
|
130
|
+
g = np.multiply(self.brain_mask.get_fdata(), g)
|
|
131
|
+
# adjust total intensity of gaussian
|
|
132
|
+
g = np.multiply(i_tot / np.sum(g), g)
|
|
133
|
+
|
|
134
|
+
return g
|
|
135
|
+
|
|
136
|
+
def sphere(self, radius, center):
|
|
137
|
+
"""Create a sphere of a given radius at a world coordinate in the brain mask.
|
|
138
|
+
|
|
139
|
+
Delegates to `nltools.mask.create_sphere`, so the radius is millimeters and
|
|
140
|
+
the center is a world (MNI) coordinate resolved through the mask's affine.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
radius (int | float): Radius of the sphere in millimeters.
|
|
144
|
+
center (array-like): Center of the sphere `[x, y, z]` in world (MNI)
|
|
145
|
+
millimeters.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
np.ndarray: 3-D array the shape of the brain mask, 1 inside the sphere and
|
|
149
|
+
0 elsewhere.
|
|
150
|
+
"""
|
|
151
|
+
from nltools.mask import create_sphere
|
|
152
|
+
|
|
153
|
+
drawn = create_sphere(
|
|
154
|
+
[float(c) for c in center], radius=radius, mask=self.brain_mask
|
|
155
|
+
)
|
|
156
|
+
return np.asarray(drawn.dataobj, dtype=float)
|
|
157
|
+
|
|
158
|
+
def normal_noise(self, mu, sigma):
|
|
159
|
+
"""Produce a normal noise distribution for all points in the brain mask.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
mu (float): Mean of the noise (usually 0).
|
|
163
|
+
sigma (float): Standard deviation of the noise.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
np.ndarray: 3-D array the shape of the brain mask filled with noise inside
|
|
167
|
+
the mask.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
vlength = int(np.sum(self.brain_mask.get_fdata()))
|
|
171
|
+
if sigma != 0:
|
|
172
|
+
n = self.random_state.normal(mu, sigma, vlength)
|
|
173
|
+
else:
|
|
174
|
+
# float, not a list of Python ints: an int64 array makes nibabel
|
|
175
|
+
# warn and silently downcast the image to int32.
|
|
176
|
+
n = np.full(vlength, float(mu))
|
|
177
|
+
m = unmask(n, self.brain_mask)
|
|
178
|
+
|
|
179
|
+
# return the 3D numpy matrix of zeros containing the brain mask filled with noise produced over a normal distribution
|
|
180
|
+
return m.get_fdata()
|
|
181
|
+
|
|
182
|
+
def to_nifti(self, m):
|
|
183
|
+
"""Convert a numpy array to a NIfTI image with the brain mask's affine.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
m (np.ndarray): 3-D (or 4-D) array to convert.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
nibabel.Nifti1Image: The array as a float32 image.
|
|
190
|
+
"""
|
|
191
|
+
if not (isinstance(m, np.ndarray) and len(m.shape) >= 3): # try 4D
|
|
192
|
+
# if not (type(m) == np.ndarray and len(m.shape) == 3):
|
|
193
|
+
raise ValueError(
|
|
194
|
+
"ERROR: need 3D np.ndarray matrix to create the nifti file"
|
|
195
|
+
)
|
|
196
|
+
m = m.astype(np.float32)
|
|
197
|
+
ni = nib.Nifti1Image(m, affine=self.brain_mask.affine)
|
|
198
|
+
return ni
|
|
199
|
+
|
|
200
|
+
def n_spheres(self, radius, center=None):
|
|
201
|
+
"""Generate a set of spheres in the brain mask space.
|
|
202
|
+
|
|
203
|
+
Delegates to `nltools.mask.create_sphere`, so radii are millimeters and
|
|
204
|
+
centers are world (MNI) coordinates resolved through the mask's affine.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
radius (int | float | list): Sphere radius in millimeters, or one radius
|
|
208
|
+
per sphere.
|
|
209
|
+
center (list, optional): Sphere center `[x, y, z]` in world (MNI)
|
|
210
|
+
millimeters, or one center per sphere `[[x1, y1, z1], ...]`. None
|
|
211
|
+
places every sphere at the world coordinate of the mask's grid center.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
np.ndarray: 3-D binary array the shape of the brain mask holding the union
|
|
215
|
+
of the requested spheres.
|
|
216
|
+
"""
|
|
217
|
+
from nltools.mask import create_sphere
|
|
218
|
+
|
|
219
|
+
if center is None:
|
|
220
|
+
n_requested = (
|
|
221
|
+
len(radius) if isinstance(radius, (list, tuple, np.ndarray)) else 1
|
|
222
|
+
)
|
|
223
|
+
center = [_grid_center_world(self.brain_mask)] * n_requested
|
|
224
|
+
|
|
225
|
+
drawn = create_sphere(center, radius=radius, mask=self.brain_mask)
|
|
226
|
+
return np.asarray(drawn.dataobj, dtype=float)
|
|
227
|
+
|
|
228
|
+
def create_data(
|
|
229
|
+
self, levels, sigma, *, radius=10, center=None, reps=1, output_dir=None
|
|
230
|
+
):
|
|
231
|
+
"""Create simulated data with discrete intensity levels.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
levels (list): Intensities or class labels, one per image in a repetition.
|
|
235
|
+
sigma (float): Standard deviation of the added noise.
|
|
236
|
+
radius (int | float | list): Sphere radius in millimeters, or one radius
|
|
237
|
+
per sphere. Default 10.0.
|
|
238
|
+
center (list, optional): Sphere center `[x, y, z]` in world (MNI)
|
|
239
|
+
millimeters, or one center per sphere `[[x1, y1, z1], ...]`. None
|
|
240
|
+
(the default) places every sphere at the world coordinate of the
|
|
241
|
+
mask's grid center.
|
|
242
|
+
reps (int): Number of repetitions (e.g. trials or subjects). Default 1.
|
|
243
|
+
output_dir (str, optional): Directory to write `data.nii.gz`, `y.csv`, and
|
|
244
|
+
`rep_id.csv` into. If None, nothing is written.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
BrainData: The simulated images with `Y` set to the levels.
|
|
248
|
+
"""
|
|
249
|
+
import polars as pl
|
|
250
|
+
|
|
251
|
+
# Create reps
|
|
252
|
+
nlevels = len(levels)
|
|
253
|
+
y = levels
|
|
254
|
+
rep_id = [1] * len(levels)
|
|
255
|
+
for i in range(reps - 1):
|
|
256
|
+
y = y + levels
|
|
257
|
+
rep_id.extend([i + 2] * nlevels)
|
|
258
|
+
|
|
259
|
+
# Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list)
|
|
260
|
+
A = self.n_spheres(radius, center)
|
|
261
|
+
|
|
262
|
+
# for each intensity
|
|
263
|
+
A_list = []
|
|
264
|
+
for i in y:
|
|
265
|
+
A_list.append(np.multiply(A, i))
|
|
266
|
+
|
|
267
|
+
# generate a different gaussian noise profile for each mask
|
|
268
|
+
mu = 0 # values centered around 0
|
|
269
|
+
N_list = []
|
|
270
|
+
for i in range(len(y)):
|
|
271
|
+
N_list.append(self.normal_noise(mu, sigma))
|
|
272
|
+
|
|
273
|
+
# add noise and signal together, then convert to nifti files
|
|
274
|
+
NF_list = []
|
|
275
|
+
for i in range(len(y)):
|
|
276
|
+
NF_list.append(self.to_nifti(np.add(N_list[i], A_list[i])))
|
|
277
|
+
NF_list = BrainData(NF_list)
|
|
278
|
+
|
|
279
|
+
# Assign variables to object
|
|
280
|
+
self.data = NF_list
|
|
281
|
+
self.y = pl.DataFrame({"y": y})
|
|
282
|
+
self.rep_id = pl.DataFrame({"rep_id": rep_id})
|
|
283
|
+
|
|
284
|
+
dat = self.data
|
|
285
|
+
dat.Y = self.y
|
|
286
|
+
|
|
287
|
+
# Write Data to files if requested
|
|
288
|
+
if output_dir is not None and isinstance(output_dir, str):
|
|
289
|
+
NF_list.write(os.path.join(output_dir, "data.nii.gz"))
|
|
290
|
+
self.y.write_csv(os.path.join(output_dir, "y.csv"), include_header=False)
|
|
291
|
+
self.rep_id.write_csv(
|
|
292
|
+
os.path.join(output_dir, "rep_id.csv"), include_header=False
|
|
293
|
+
)
|
|
294
|
+
return dat
|
|
295
|
+
|
|
296
|
+
def create_cov_data(
|
|
297
|
+
self, cor, cov, sigma, *, mask=None, reps=1, n_sub=1, output_dir=None
|
|
298
|
+
):
|
|
299
|
+
"""Create continuous simulated data with covariance within a single region.
|
|
300
|
+
|
|
301
|
+
Results are stored on `self.data` (a 4-D `nibabel.Nifti1Image`), `self.y`, and
|
|
302
|
+
`self.rep_id`.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
cor (float): Covariance between each voxel and the outcome `y`.
|
|
306
|
+
cov (float): Covariance between voxels.
|
|
307
|
+
sigma (float): Standard deviation of the added noise.
|
|
308
|
+
mask (nibabel.Nifti1Image, optional): Region where activations are placed.
|
|
309
|
+
Defaults to a 20 mm sphere at the mask's grid center.
|
|
310
|
+
reps (int): Number of repetitions per subject. Default 1.
|
|
311
|
+
n_sub (int): Number of subjects to simulate. Default 1.
|
|
312
|
+
output_dir (str, optional): Directory to write the image, `y.csv`, and
|
|
313
|
+
`rep_id.csv` into. If None, nothing is written.
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
if mask is None:
|
|
317
|
+
# Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list)
|
|
318
|
+
A = self.n_spheres(20, None) # parameters are (radius, center)
|
|
319
|
+
mask = nib.Nifti1Image(A.astype(np.float32), affine=self.brain_mask.affine)
|
|
320
|
+
|
|
321
|
+
# Create n_reps with cov for each voxel within sphere
|
|
322
|
+
# Build covariance matrix with each variable correlated with y amount 'cor' and each other amount 'cov'
|
|
323
|
+
# apply_mask on a single 3-D mask returns a 1-D vector; the logic below
|
|
324
|
+
# (flat_sphere.shape[1], np.where(...)[1]) assumes a 2-D (1, n_vox)
|
|
325
|
+
# array, so normalize to 2-D (matching create_ncov_data, which wraps its
|
|
326
|
+
# masks in a list and gets a 2-D result).
|
|
327
|
+
flat_sphere = np.atleast_2d(apply_mask(mask, self.brain_mask))
|
|
328
|
+
|
|
329
|
+
n_vox = np.sum(flat_sphere == 1)
|
|
330
|
+
cov_matrix = np.ones([n_vox + 1, n_vox + 1]) * cov
|
|
331
|
+
cov_matrix[0, :] = cor # set covariance with y
|
|
332
|
+
cov_matrix[:, 0] = cor # set covariance with all other voxels
|
|
333
|
+
np.fill_diagonal(cov_matrix, 1) # set diagonal to 1
|
|
334
|
+
mv_sim = self.random_state.multivariate_normal(
|
|
335
|
+
np.zeros([n_vox + 1]), cov_matrix, size=reps
|
|
336
|
+
)
|
|
337
|
+
y = mv_sim[:, 0]
|
|
338
|
+
self.y = y
|
|
339
|
+
mv_sim = mv_sim[:, 1:]
|
|
340
|
+
new_dat = np.ones([mv_sim.shape[0], flat_sphere.shape[1]])
|
|
341
|
+
new_dat[:, np.where(flat_sphere == 1)[1]] = mv_sim
|
|
342
|
+
self.data = unmask(
|
|
343
|
+
np.add(
|
|
344
|
+
new_dat, self.random_state.standard_normal(size=new_dat.shape) * sigma
|
|
345
|
+
),
|
|
346
|
+
self.brain_mask,
|
|
347
|
+
) # add noise scaled by sigma
|
|
348
|
+
self.rep_id = [1] * len(y)
|
|
349
|
+
if n_sub > 1:
|
|
350
|
+
self.y = list(self.y)
|
|
351
|
+
for s in range(1, n_sub):
|
|
352
|
+
self.data = nib.concat_images(
|
|
353
|
+
[
|
|
354
|
+
self.data,
|
|
355
|
+
unmask(
|
|
356
|
+
np.add(
|
|
357
|
+
new_dat,
|
|
358
|
+
self.random_state.standard_normal(size=new_dat.shape)
|
|
359
|
+
* sigma,
|
|
360
|
+
),
|
|
361
|
+
self.brain_mask,
|
|
362
|
+
),
|
|
363
|
+
],
|
|
364
|
+
axis=3,
|
|
365
|
+
) # add noise scaled by sigma
|
|
366
|
+
noise_y = list(y + self.random_state.randn(len(y)) * sigma)
|
|
367
|
+
self.y = self.y + noise_y
|
|
368
|
+
self.rep_id = self.rep_id + [s + 1] * len(mv_sim[:, 0])
|
|
369
|
+
self.y = np.array(self.y)
|
|
370
|
+
|
|
371
|
+
# # Old method in 4 D space - much slower
|
|
372
|
+
# x,y,z = np.where(A==1)
|
|
373
|
+
# cov_matrix = np.ones([len(x)+1,len(x)+1]) * cov
|
|
374
|
+
# cov_matrix[0,:] = cor # set covariance with y
|
|
375
|
+
# cov_matrix[:,0] = cor # set covariance with all other voxels
|
|
376
|
+
# np.fill_diagonal(cov_matrix,1) # set diagonal to 1
|
|
377
|
+
# mv_sim = self.random_state.multivariate_normal(np.zeros([len(x)+1]),cov_matrix, size=reps) # simulate data from multivariate covar
|
|
378
|
+
# self.y = mv_sim[:,0]
|
|
379
|
+
# mv_sim = mv_sim[:,1:]
|
|
380
|
+
# A_4d = np.resize(A,(reps,A.shape[0],A.shape[1],A.shape[2]))
|
|
381
|
+
# for i in range(len(x)):
|
|
382
|
+
# A_4d[:,x[i],y[i],z[i]]=mv_sim[:,i]
|
|
383
|
+
# A_4d = np.rollaxis(A_4d,0,4) # reorder shape of matrix so that time is in 4th dimension
|
|
384
|
+
# self.data = self.to_nifti(np.add(A_4d,self.random_state.standard_normal(size=A_4d.shape)*sigma)) # add noise scaled by sigma
|
|
385
|
+
# self.rep_id = ??? # need to add this later
|
|
386
|
+
|
|
387
|
+
# Write Data to files if requested
|
|
388
|
+
if output_dir is not None:
|
|
389
|
+
if isinstance(output_dir, str):
|
|
390
|
+
if not os.path.isdir(output_dir):
|
|
391
|
+
os.makedirs(output_dir)
|
|
392
|
+
self.data.to_filename(
|
|
393
|
+
os.path.join(
|
|
394
|
+
output_dir,
|
|
395
|
+
"maskdata_cor"
|
|
396
|
+
+ str(cor)
|
|
397
|
+
+ "_cov"
|
|
398
|
+
+ str(cov)
|
|
399
|
+
+ "_sigma"
|
|
400
|
+
+ str(sigma)
|
|
401
|
+
+ ".nii.gz",
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
with open(os.path.join(output_dir, "y.csv"), "w", newline="") as y_file:
|
|
405
|
+
wr = csv.writer(y_file, quoting=csv.QUOTE_ALL)
|
|
406
|
+
wr.writerow(self.y)
|
|
407
|
+
|
|
408
|
+
with open(
|
|
409
|
+
os.path.join(output_dir, "rep_id.csv"), "w", newline=""
|
|
410
|
+
) as rep_id_file:
|
|
411
|
+
wr = csv.writer(rep_id_file, quoting=csv.QUOTE_ALL)
|
|
412
|
+
wr.writerow(self.rep_id)
|
|
413
|
+
|
|
414
|
+
def create_ncov_data(
|
|
415
|
+
self, cor, cov, sigma, *, masks=None, reps=1, n_sub=1, output_dir=None
|
|
416
|
+
):
|
|
417
|
+
"""Create continuous simulated data with covariance across multiple regions.
|
|
418
|
+
|
|
419
|
+
Results are stored on `self.data` (a 4-D `nibabel.Nifti1Image`), `self.y`, and
|
|
420
|
+
`self.rep_id`.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
cor (float | list[float]): Covariance between each region's voxels and the
|
|
424
|
+
outcome `y`; one value per region.
|
|
425
|
+
cov (float | list[list[float]]): Covariance between voxels; a scalar for a
|
|
426
|
+
single region or a region-by-region matrix.
|
|
427
|
+
sigma (float): Standard deviation of the added noise.
|
|
428
|
+
masks (nibabel.Nifti1Image | list[nibabel.Nifti1Image], optional): Region(s)
|
|
429
|
+
where activations are placed. Defaults to a 20 mm sphere at the mask's
|
|
430
|
+
grid center.
|
|
431
|
+
reps (int): Number of repetitions per subject. Default 1.
|
|
432
|
+
n_sub (int): Number of subjects to simulate. Default 1.
|
|
433
|
+
output_dir (str, optional): Directory to write the image, `y.csv`, and
|
|
434
|
+
`rep_id.csv` into. If None, nothing is written.
|
|
435
|
+
"""
|
|
436
|
+
|
|
437
|
+
if masks is None:
|
|
438
|
+
# Initialize Spheres with options for multiple radii and centers of the spheres (or just an int and a 3D list)
|
|
439
|
+
A = self.n_spheres(20, None) # parameters are (radius, center)
|
|
440
|
+
masks = nib.Nifti1Image(A.astype(np.float32), affine=self.brain_mask.affine)
|
|
441
|
+
|
|
442
|
+
if type(masks) is nib.nifti1.Nifti1Image:
|
|
443
|
+
masks = [masks]
|
|
444
|
+
if type(cor) is float or type(cor) is int:
|
|
445
|
+
cor = [cor]
|
|
446
|
+
if type(cov) is float or type(cov) is int:
|
|
447
|
+
cov = [[cov]]
|
|
448
|
+
if not len(cor) == len(masks):
|
|
449
|
+
raise ValueError(
|
|
450
|
+
"cor matrix has incompatible dimensions for mask list of length "
|
|
451
|
+
+ str(len(masks))
|
|
452
|
+
)
|
|
453
|
+
if (
|
|
454
|
+
not len(cov) == len(masks)
|
|
455
|
+
or len(masks) == 0
|
|
456
|
+
or not len(cov[0]) == len(masks)
|
|
457
|
+
):
|
|
458
|
+
raise ValueError(
|
|
459
|
+
"cov matrix has incompatible dimensions for mask list of length "
|
|
460
|
+
+ str(len(masks))
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
# Create n_reps with cov for each voxel within sphere
|
|
464
|
+
# Build covariance matrix with each variable correlated with y amount 'cor' and each other amount 'cov'
|
|
465
|
+
flat_masks = apply_mask(masks, self.brain_mask)
|
|
466
|
+
|
|
467
|
+
n_vox = np.sum(
|
|
468
|
+
flat_masks == 1, axis=1
|
|
469
|
+
) # this is a list, each entry contains number voxels for given mask
|
|
470
|
+
if 0 in n_vox:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
"one or more processing mask does not fit inside the brain mask"
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
cov_matrix = np.zeros(
|
|
476
|
+
[np.sum(n_vox) + 1, np.sum(n_vox) + 1]
|
|
477
|
+
) # one big covariance matrix
|
|
478
|
+
for i, nv in enumerate(n_vox):
|
|
479
|
+
cstart = np.sum(n_vox[:i]) + 1
|
|
480
|
+
cstop = cstart + nv
|
|
481
|
+
cov_matrix[0, cstart:cstop] = cor[i] # set covariance with y
|
|
482
|
+
cov_matrix[cstart:cstop, 0] = cor[i] # set covariance with all other voxels
|
|
483
|
+
for j in range(len(masks)):
|
|
484
|
+
rstart = np.sum(n_vox[:j]) + 1
|
|
485
|
+
rstop = rstart + nv
|
|
486
|
+
cov_matrix[cstart:cstop, rstart:rstop] = cov[i][
|
|
487
|
+
j
|
|
488
|
+
] # set covariance of this mask's voxels with each of other masks
|
|
489
|
+
np.fill_diagonal(cov_matrix, 1) # set diagonal to 1
|
|
490
|
+
|
|
491
|
+
# these operations happen in one vector that we'll later split into the separate regions
|
|
492
|
+
mv_sim_l = self.random_state.multivariate_normal(
|
|
493
|
+
np.zeros([np.sum(n_vox) + 1]), cov_matrix, size=reps
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
self.y = mv_sim_l[:, 0]
|
|
497
|
+
mv_sim = mv_sim_l[:, 1:]
|
|
498
|
+
new_dats = np.ones([mv_sim.shape[0], flat_masks.shape[1]])
|
|
499
|
+
|
|
500
|
+
for rep in range(reps):
|
|
501
|
+
for mask_i in range(len(masks)):
|
|
502
|
+
start = int(np.sum(n_vox[:mask_i]))
|
|
503
|
+
stop = int(start + n_vox[mask_i])
|
|
504
|
+
new_dats[rep, np.where(flat_masks[mask_i, :] == 1)] = mv_sim[
|
|
505
|
+
rep, start:stop
|
|
506
|
+
]
|
|
507
|
+
|
|
508
|
+
noise = self.random_state.standard_normal(size=new_dats.shape[1]) * sigma
|
|
509
|
+
self.data = unmask(
|
|
510
|
+
np.add(new_dats, noise), self.brain_mask
|
|
511
|
+
) # append 3d simulated data to list
|
|
512
|
+
self.rep_id = [1] * len(self.y)
|
|
513
|
+
|
|
514
|
+
if n_sub > 1:
|
|
515
|
+
self.y = list(self.y)
|
|
516
|
+
y = list(self.y)
|
|
517
|
+
for s in range(1, n_sub):
|
|
518
|
+
# ask Luke about this new version
|
|
519
|
+
noise = (
|
|
520
|
+
self.random_state.standard_normal(size=new_dats.shape[1]) * sigma
|
|
521
|
+
)
|
|
522
|
+
next_subj = unmask(np.add(new_dats, noise), self.brain_mask)
|
|
523
|
+
self.data = nib.concat_images([self.data, next_subj], axis=3)
|
|
524
|
+
|
|
525
|
+
y += list(self.y + self.random_state.randn(len(self.y)) * sigma)
|
|
526
|
+
self.rep_id += [s + 1] * len(mv_sim[:, 0])
|
|
527
|
+
self.y = np.array(y)
|
|
528
|
+
|
|
529
|
+
if output_dir is not None:
|
|
530
|
+
if type(output_dir) is str:
|
|
531
|
+
if not os.path.isdir(output_dir):
|
|
532
|
+
os.makedirs(output_dir)
|
|
533
|
+
self.data.to_filename(
|
|
534
|
+
os.path.join(
|
|
535
|
+
output_dir,
|
|
536
|
+
"simulated_data_"
|
|
537
|
+
+ str(sigma)
|
|
538
|
+
+ "sigma_"
|
|
539
|
+
+ str(n_sub)
|
|
540
|
+
+ "subj.nii.gz",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
with open(os.path.join(output_dir, "y.csv"), "w", newline="") as y_file:
|
|
544
|
+
wr = csv.writer(y_file, quoting=csv.QUOTE_ALL)
|
|
545
|
+
wr.writerow(self.y)
|
|
546
|
+
|
|
547
|
+
with open(
|
|
548
|
+
os.path.join(output_dir, "rep_id.csv"), "w", newline=""
|
|
549
|
+
) as rep_id_file:
|
|
550
|
+
wr = csv.writer(rep_id_file, quoting=csv.QUOTE_ALL)
|
|
551
|
+
wr.writerow(self.rep_id)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
#: Multiple-comparison corrections `SimulateGrid` implements. `None` applies no
|
|
555
|
+
#: correction; `'fdr'` requires `threshold_type='q'`.
|
|
556
|
+
_SUPPORTED_CORRECTIONS = (None, "fdr")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _validate_correction(correction):
|
|
560
|
+
"""Raise `ValueError` for an unsupported `correction`.
|
|
561
|
+
|
|
562
|
+
Args:
|
|
563
|
+
correction: Value passed as `SimulateGrid`'s `correction` argument.
|
|
564
|
+
|
|
565
|
+
Raises:
|
|
566
|
+
ValueError: If `correction` is outside `_SUPPORTED_CORRECTIONS`.
|
|
567
|
+
"""
|
|
568
|
+
if correction not in _SUPPORTED_CORRECTIONS:
|
|
569
|
+
raise ValueError(
|
|
570
|
+
f"correction must be one of {_SUPPORTED_CORRECTIONS}; got {correction!r}."
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
class SimulateGrid:
|
|
575
|
+
"""Simulate 2D grid data for testing statistical methods.
|
|
576
|
+
|
|
577
|
+
Creates a 2D grid (e.g., 100x100 pixels) with optional embedded signal
|
|
578
|
+
regions and Gaussian noise. Useful for testing multiple comparison
|
|
579
|
+
correction methods, threshold selection, and visualization of
|
|
580
|
+
statistical maps.
|
|
581
|
+
|
|
582
|
+
Args:
|
|
583
|
+
grid_width (int): Width/height of the square grid. Default 100.
|
|
584
|
+
signal_width (int): Width of the embedded signal region. Default 20.
|
|
585
|
+
n_subjects (int): Number of simulated subjects. Default 20.
|
|
586
|
+
sigma (float): Standard deviation of the Gaussian noise. Default 1.
|
|
587
|
+
signal_amplitude (float, optional): Amplitude of the embedded signal. If None,
|
|
588
|
+
no signal is added.
|
|
589
|
+
random_state (int | np.random.RandomState, optional): Seed or RandomState for
|
|
590
|
+
reproducibility.
|
|
591
|
+
|
|
592
|
+
Attributes:
|
|
593
|
+
data (np.ndarray): Simulated data of shape `(grid_width, grid_width, n_subjects)`.
|
|
594
|
+
signal_mask (np.ndarray | None): Binary grid marking the signal region, or None
|
|
595
|
+
when no signal was added.
|
|
596
|
+
t_values (np.ndarray | None): T-statistic map after `fit()`.
|
|
597
|
+
p_values (np.ndarray | None): P-value map after `fit()`.
|
|
598
|
+
thresholded (np.ndarray | None): Thresholded statistical map after
|
|
599
|
+
`threshold_simulation()`.
|
|
600
|
+
isfit (bool): Whether `fit()` has been called.
|
|
601
|
+
|
|
602
|
+
Examples:
|
|
603
|
+
```python
|
|
604
|
+
from nltools.data.simulator import SimulateGrid
|
|
605
|
+
|
|
606
|
+
sim = SimulateGrid(signal_amplitude=0.5, random_state=42)
|
|
607
|
+
sim.fit()
|
|
608
|
+
sim.plot_grid_simulation(threshold=0.05, threshold_type="q", correction="fdr")
|
|
609
|
+
```
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
def __init__(
|
|
613
|
+
self,
|
|
614
|
+
*,
|
|
615
|
+
grid_width=100,
|
|
616
|
+
signal_width=20,
|
|
617
|
+
n_subjects=20,
|
|
618
|
+
sigma=1,
|
|
619
|
+
signal_amplitude=None,
|
|
620
|
+
random_state=None,
|
|
621
|
+
):
|
|
622
|
+
self.isfit = False
|
|
623
|
+
self.thresholded = None
|
|
624
|
+
self.threshold = None
|
|
625
|
+
self.threshold_type = None
|
|
626
|
+
self.correction = None
|
|
627
|
+
self.t_values = None
|
|
628
|
+
self.p_values = None
|
|
629
|
+
self.n_subjects = n_subjects
|
|
630
|
+
self.sigma = sigma
|
|
631
|
+
self.grid_width = grid_width
|
|
632
|
+
self.random_state = check_random_state(random_state)
|
|
633
|
+
self.data = self._create_noise()
|
|
634
|
+
|
|
635
|
+
if signal_amplitude is not None:
|
|
636
|
+
self.add_signal(
|
|
637
|
+
signal_amplitude=signal_amplitude, signal_width=signal_width
|
|
638
|
+
)
|
|
639
|
+
else:
|
|
640
|
+
self.signal_amplitude = None
|
|
641
|
+
self.signal_mask = None
|
|
642
|
+
|
|
643
|
+
def _create_noise(self):
|
|
644
|
+
"""Generate simulated data using object parameters.
|
|
645
|
+
|
|
646
|
+
Returns:
|
|
647
|
+
np.ndarray: Simulated noise using object parameters.
|
|
648
|
+
"""
|
|
649
|
+
return (
|
|
650
|
+
self.random_state.randn(self.grid_width, self.grid_width, self.n_subjects)
|
|
651
|
+
* self.sigma
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
def add_signal(self, signal_width=20, signal_amplitude=1):
|
|
655
|
+
"""Add a square signal region, centered in the grid, to `self.data`.
|
|
656
|
+
|
|
657
|
+
Args:
|
|
658
|
+
signal_width (int): Width of the signal box in pixels. Default 20.
|
|
659
|
+
signal_amplitude (float): Intensity added inside the box. Default 1.
|
|
660
|
+
"""
|
|
661
|
+
if signal_width >= self.grid_width:
|
|
662
|
+
raise ValueError("Signal width must be smaller than total grid.")
|
|
663
|
+
|
|
664
|
+
self.signal_amplitude = signal_amplitude
|
|
665
|
+
self.create_mask(signal_width)
|
|
666
|
+
signal = np.repeat(
|
|
667
|
+
np.expand_dims(self.signal_mask, axis=2), self.n_subjects, axis=2
|
|
668
|
+
)
|
|
669
|
+
self.data = deepcopy(self.data) + signal * self.signal_amplitude
|
|
670
|
+
|
|
671
|
+
def create_mask(self, signal_width):
|
|
672
|
+
"""Create the binary `signal_mask` marking a centered square of the grid.
|
|
673
|
+
|
|
674
|
+
Args:
|
|
675
|
+
signal_width (int): Width of the signal box in pixels.
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
mask = np.zeros((self.grid_width, self.grid_width))
|
|
679
|
+
mask[
|
|
680
|
+
int(np.floor((self.grid_width / 2) - (signal_width / 2))) : int(
|
|
681
|
+
np.ceil((self.grid_width / 2) + (signal_width / 2))
|
|
682
|
+
),
|
|
683
|
+
int(np.floor((self.grid_width / 2) - (signal_width / 2))) : int(
|
|
684
|
+
np.ceil((self.grid_width / 2) + (signal_width / 2))
|
|
685
|
+
),
|
|
686
|
+
] = 1
|
|
687
|
+
self.signal_width = signal_width
|
|
688
|
+
self.signal_mask = mask
|
|
689
|
+
|
|
690
|
+
def _run_ttest(self, data):
|
|
691
|
+
"""Run a one-sample t-test on data (helper function)."""
|
|
692
|
+
flattened = data.reshape(self.grid_width * self.grid_width, self.n_subjects)
|
|
693
|
+
t, p = ttest_1samp(flattened.T, 0)
|
|
694
|
+
t = np.reshape(t, (self.grid_width, self.grid_width))
|
|
695
|
+
p = np.reshape(p, (self.grid_width, self.grid_width))
|
|
696
|
+
return (t, p)
|
|
697
|
+
|
|
698
|
+
def fit(self):
|
|
699
|
+
"""Run a one-sample t-test on self.data."""
|
|
700
|
+
if self.isfit:
|
|
701
|
+
raise ValueError("Can't fit because ttest has already been run.")
|
|
702
|
+
self.t_values, self.p_values = self._run_ttest(self.data)
|
|
703
|
+
self.isfit = True
|
|
704
|
+
|
|
705
|
+
def _threshold_simulation(self, t, p, threshold, threshold_type, correction=None):
|
|
706
|
+
"""Threshold a simulation (helper function).
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
threshold (float): threshold to apply to simulation
|
|
710
|
+
threshold_type (str): type of threshold to use can be a specific t-value, p-value, or FDR-corrected q-value ['t', 'p', 'q']
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
np.ndarray: Thresholded data.
|
|
714
|
+
|
|
715
|
+
Raises:
|
|
716
|
+
ValueError: If `correction` is unsupported (see `_validate_correction`),
|
|
717
|
+
or `correction='fdr'` is paired with a `threshold_type` other than
|
|
718
|
+
`'q'`.
|
|
719
|
+
"""
|
|
720
|
+
_validate_correction(correction)
|
|
721
|
+
if correction == "fdr":
|
|
722
|
+
if threshold_type != "q":
|
|
723
|
+
raise ValueError("Must specify a q value when using fdr")
|
|
724
|
+
|
|
725
|
+
thresholded = deepcopy(t)
|
|
726
|
+
if threshold_type == "t":
|
|
727
|
+
thresholded[np.abs(t) < threshold] = 0
|
|
728
|
+
elif threshold_type == "p":
|
|
729
|
+
thresholded[p > threshold] = 0
|
|
730
|
+
elif threshold_type == "q":
|
|
731
|
+
fdr_threshold = fdr(p.flatten(), q=threshold)
|
|
732
|
+
if fdr_threshold < 0:
|
|
733
|
+
thresholded = np.zeros(thresholded.shape)
|
|
734
|
+
else:
|
|
735
|
+
thresholded[p > fdr_threshold] = 0
|
|
736
|
+
else:
|
|
737
|
+
raise ValueError("Threshold type must be ['t','p','q']")
|
|
738
|
+
return thresholded
|
|
739
|
+
|
|
740
|
+
def threshold_simulation(self, threshold, threshold_type, correction=None):
|
|
741
|
+
"""Threshold the fitted simulation and store `thresholded` plus hit rates.
|
|
742
|
+
|
|
743
|
+
Args:
|
|
744
|
+
threshold (float): Threshold value to apply.
|
|
745
|
+
threshold_type (str): `'t'` (absolute t-value), `'p'` (p-value), or `'q'`
|
|
746
|
+
(FDR-corrected q-value; requires `correction='fdr'`).
|
|
747
|
+
correction (str, optional): Multiple-comparison correction; `'fdr'` or None.
|
|
748
|
+
"""
|
|
749
|
+
|
|
750
|
+
if not self.isfit:
|
|
751
|
+
raise ValueError("Must fit model before thresholding.")
|
|
752
|
+
|
|
753
|
+
if correction == "fdr":
|
|
754
|
+
self.corrected_threshold = fdr(self.p_values.flatten())
|
|
755
|
+
|
|
756
|
+
self.correction = correction
|
|
757
|
+
self.thresholded = self._threshold_simulation(
|
|
758
|
+
self.t_values, self.p_values, threshold, threshold_type, correction
|
|
759
|
+
)
|
|
760
|
+
self.threshold = threshold
|
|
761
|
+
self.threshold_type = threshold_type
|
|
762
|
+
|
|
763
|
+
self.fp_percent = self._calc_false_positives(self.thresholded)
|
|
764
|
+
if self.signal_mask is not None:
|
|
765
|
+
self.tp_percent = self._calc_true_positives(self.thresholded)
|
|
766
|
+
|
|
767
|
+
def _calc_false_positives(self, thresholded):
|
|
768
|
+
"""Calculate percent of grid containing false positives.
|
|
769
|
+
|
|
770
|
+
Args:
|
|
771
|
+
thresholded (np.array): thresholded grid
|
|
772
|
+
Returns:
|
|
773
|
+
float: Percentage of grid that contains false positives.
|
|
774
|
+
"""
|
|
775
|
+
|
|
776
|
+
if self.signal_mask is None:
|
|
777
|
+
fp_percent = np.sum(thresholded != 0) / (self.grid_width**2)
|
|
778
|
+
else:
|
|
779
|
+
fp_percent = np.sum(thresholded[self.signal_mask != 1] != 0) / (
|
|
780
|
+
self.grid_width**2 - self.signal_width**2
|
|
781
|
+
)
|
|
782
|
+
return fp_percent
|
|
783
|
+
|
|
784
|
+
def _calc_true_positives(self, thresholded):
|
|
785
|
+
"""Calculate percent of mask containing true positives.
|
|
786
|
+
|
|
787
|
+
Args:
|
|
788
|
+
thresholded (np.array): thresholded grid
|
|
789
|
+
Returns:
|
|
790
|
+
float: Percentage of grid that contains true positives.
|
|
791
|
+
"""
|
|
792
|
+
|
|
793
|
+
if self.signal_mask is None:
|
|
794
|
+
raise ValueError("No mask exists, run add_signal() first.")
|
|
795
|
+
tp_percent = np.sum(thresholded[self.signal_mask == 1] != 0) / (
|
|
796
|
+
self.signal_width**2
|
|
797
|
+
)
|
|
798
|
+
return tp_percent
|
|
799
|
+
|
|
800
|
+
def _calc_false_discovery_rate(self, thresholded):
|
|
801
|
+
"""Calculate percent of activated voxels that are false positives.
|
|
802
|
+
|
|
803
|
+
Args:
|
|
804
|
+
thresholded (np.array): thresholded grid
|
|
805
|
+
Returns:
|
|
806
|
+
float: Percentage of activated voxels that are false positives.
|
|
807
|
+
"""
|
|
808
|
+
if self.signal_mask is None:
|
|
809
|
+
raise ValueError("No mask exists, run add_signal() first.")
|
|
810
|
+
fp_percent = np.sum(thresholded[self.signal_mask == 0] > 0) / np.sum(
|
|
811
|
+
thresholded > 0
|
|
812
|
+
)
|
|
813
|
+
return fp_percent
|
|
814
|
+
|
|
815
|
+
def run_multiple_simulations(
|
|
816
|
+
self, threshold, threshold_type, n_simulations=100, correction=None
|
|
817
|
+
):
|
|
818
|
+
"""Run repeated simulations to estimate the false positive rate.
|
|
819
|
+
|
|
820
|
+
Stores per-simulation results on `multiple_thresholded`, `multiple_fp`, and
|
|
821
|
+
`fpr` (plus `multiple_tp` and `multiple_fdr` when a signal is present).
|
|
822
|
+
|
|
823
|
+
Args:
|
|
824
|
+
threshold (float): Threshold value to apply to each simulation.
|
|
825
|
+
threshold_type (str): `'t'`, `'p'`, or `'q'` (see `threshold_simulation`).
|
|
826
|
+
n_simulations (int): Number of simulations to run. Default 100.
|
|
827
|
+
correction (str, optional): Multiple-comparison correction; `'fdr'` or None.
|
|
828
|
+
"""
|
|
829
|
+
|
|
830
|
+
if self.signal_mask is None:
|
|
831
|
+
simulations = [
|
|
832
|
+
self._run_ttest(self._create_noise()) for _ in range(n_simulations)
|
|
833
|
+
]
|
|
834
|
+
else:
|
|
835
|
+
signal = (
|
|
836
|
+
np.repeat(
|
|
837
|
+
np.expand_dims(self.signal_mask, axis=2), self.n_subjects, axis=2
|
|
838
|
+
)
|
|
839
|
+
* self.signal_amplitude
|
|
840
|
+
)
|
|
841
|
+
simulations = [
|
|
842
|
+
self._run_ttest(self._create_noise() + signal)
|
|
843
|
+
for _ in range(n_simulations)
|
|
844
|
+
]
|
|
845
|
+
|
|
846
|
+
self.multiple_thresholded = [
|
|
847
|
+
self._threshold_simulation(
|
|
848
|
+
s[0], s[1], threshold, threshold_type, correction=correction
|
|
849
|
+
)
|
|
850
|
+
for s in simulations
|
|
851
|
+
]
|
|
852
|
+
self.multiple_fp = np.array(
|
|
853
|
+
[self._calc_false_positives(x) for x in self.multiple_thresholded]
|
|
854
|
+
)
|
|
855
|
+
self.fpr = np.mean(np.array(list(self.multiple_fp)) > 0)
|
|
856
|
+
if self.signal_mask is not None:
|
|
857
|
+
self.multiple_tp = np.array(
|
|
858
|
+
[self._calc_true_positives(x) for x in self.multiple_thresholded]
|
|
859
|
+
)
|
|
860
|
+
self.multiple_fdr = np.array(
|
|
861
|
+
[self._calc_false_discovery_rate(x) for x in self.multiple_thresholded]
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
def plot_grid_simulation(
|
|
865
|
+
self, threshold, threshold_type, n_simulations=100, correction=None
|
|
866
|
+
):
|
|
867
|
+
"""Plot the t-map, its thresholded version, and the false positive distribution.
|
|
868
|
+
|
|
869
|
+
Fits and thresholds the simulation first if needed, then calls
|
|
870
|
+
`run_multiple_simulations`. Adds a signal-recovery histogram when a signal is
|
|
871
|
+
present.
|
|
872
|
+
|
|
873
|
+
Args:
|
|
874
|
+
threshold (float): Threshold value to apply.
|
|
875
|
+
threshold_type (str): `'t'`, `'p'`, or `'q'` (see `threshold_simulation`).
|
|
876
|
+
n_simulations (int): Number of simulations to run. Default 100.
|
|
877
|
+
correction (str, optional): Multiple-comparison correction; `'fdr'` or None.
|
|
878
|
+
"""
|
|
879
|
+
if not self.isfit:
|
|
880
|
+
self.fit()
|
|
881
|
+
if self.thresholded is None:
|
|
882
|
+
self.threshold_simulation(
|
|
883
|
+
threshold=threshold,
|
|
884
|
+
threshold_type=threshold_type,
|
|
885
|
+
correction=correction,
|
|
886
|
+
)
|
|
887
|
+
self.run_multiple_simulations(
|
|
888
|
+
threshold=threshold,
|
|
889
|
+
threshold_type=threshold_type,
|
|
890
|
+
n_simulations=n_simulations,
|
|
891
|
+
correction=correction,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
if self.signal_mask is None:
|
|
895
|
+
_, a = plt.subplots(ncols=3, figsize=(15, 5))
|
|
896
|
+
else:
|
|
897
|
+
_, a = plt.subplots(ncols=4, figsize=(18, 5))
|
|
898
|
+
a[3].hist(self.multiple_tp)
|
|
899
|
+
a[3].set_ylabel("Frequency", fontsize=18)
|
|
900
|
+
a[3].set_xlabel("Percent Signal Recovery", fontsize=18)
|
|
901
|
+
a[3].set_title("Average Signal Recovery", fontsize=18)
|
|
902
|
+
|
|
903
|
+
a[0].imshow(self.t_values)
|
|
904
|
+
a[0].set_title("Random Noise", fontsize=18)
|
|
905
|
+
a[0].axes.get_xaxis().set_visible(False)
|
|
906
|
+
a[0].axes.get_yaxis().set_visible(False)
|
|
907
|
+
a[1].imshow(self.thresholded)
|
|
908
|
+
a[1].set_title(f"Threshold: {threshold_type} = {threshold}", fontsize=18)
|
|
909
|
+
a[1].axes.get_xaxis().set_visible(False)
|
|
910
|
+
a[1].axes.get_yaxis().set_visible(False)
|
|
911
|
+
a[2].plot(
|
|
912
|
+
binom.pmf(
|
|
913
|
+
np.arange(0, n_simulations, 1),
|
|
914
|
+
n_simulations,
|
|
915
|
+
np.mean(self.multiple_fp > 0),
|
|
916
|
+
)
|
|
917
|
+
)
|
|
918
|
+
a[2].axvline(
|
|
919
|
+
x=np.mean(self.fpr) * n_simulations,
|
|
920
|
+
color="r",
|
|
921
|
+
linestyle="dashed",
|
|
922
|
+
linewidth=2,
|
|
923
|
+
)
|
|
924
|
+
a[2].set_title(f"False Positive Rate = {self.fpr:.2f}", fontsize=18)
|
|
925
|
+
a[2].set_ylabel("Probability", fontsize=18)
|
|
926
|
+
a[2].set_xlabel("False Positive Rate", fontsize=18)
|
|
927
|
+
plt.tight_layout()
|