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.
Files changed (95) hide show
  1. nltools/__init__.py +55 -0
  2. nltools/algorithms/__init__.py +90 -0
  3. nltools/algorithms/alignment/__init__.py +21 -0
  4. nltools/algorithms/alignment/procrustes.py +565 -0
  5. nltools/algorithms/alignment/srm.py +758 -0
  6. nltools/algorithms/backends.py +1059 -0
  7. nltools/algorithms/corrections.py +177 -0
  8. nltools/algorithms/decoding.py +327 -0
  9. nltools/algorithms/inference/__init__.py +50 -0
  10. nltools/algorithms/inference/bootstrap.py +1386 -0
  11. nltools/algorithms/inference/correlation.py +373 -0
  12. nltools/algorithms/inference/intersubject.py +422 -0
  13. nltools/algorithms/inference/isc.py +1554 -0
  14. nltools/algorithms/inference/matrix.py +602 -0
  15. nltools/algorithms/inference/one_sample.py +288 -0
  16. nltools/algorithms/inference/random.py +122 -0
  17. nltools/algorithms/inference/timeseries.py +347 -0
  18. nltools/algorithms/inference/two_sample.py +212 -0
  19. nltools/algorithms/inference/utils.py +58 -0
  20. nltools/algorithms/inference/validation.py +282 -0
  21. nltools/algorithms/neighborhoods.py +207 -0
  22. nltools/algorithms/outliers.py +308 -0
  23. nltools/algorithms/regression.py +83 -0
  24. nltools/algorithms/signal.py +303 -0
  25. nltools/algorithms/similarity.py +234 -0
  26. nltools/algorithms/validation.py +151 -0
  27. nltools/cross_validation.py +72 -0
  28. nltools/data/__init__.py +30 -0
  29. nltools/data/adjacency/__init__.py +875 -0
  30. nltools/data/adjacency/io.py +111 -0
  31. nltools/data/adjacency/modeling.py +569 -0
  32. nltools/data/adjacency/plotting.py +174 -0
  33. nltools/data/adjacency/state.py +349 -0
  34. nltools/data/adjacency/stats.py +596 -0
  35. nltools/data/adjacency/utils.py +79 -0
  36. nltools/data/atlases/__init__.py +23 -0
  37. nltools/data/atlases/labeling.py +158 -0
  38. nltools/data/atlases/loading.py +76 -0
  39. nltools/data/atlases/registry.py +96 -0
  40. nltools/data/atlases/reporting.py +456 -0
  41. nltools/data/braindata/__init__.py +2170 -0
  42. nltools/data/braindata/analysis.py +1381 -0
  43. nltools/data/braindata/bootstrap.py +398 -0
  44. nltools/data/braindata/io.py +896 -0
  45. nltools/data/braindata/modeling.py +594 -0
  46. nltools/data/braindata/plotting.py +501 -0
  47. nltools/data/braindata/prediction.py +1250 -0
  48. nltools/data/braindata/utils.py +348 -0
  49. nltools/data/braindata/validation.py +197 -0
  50. nltools/data/braindata/viewer.js +266 -0
  51. nltools/data/braindata/viewer.py +770 -0
  52. nltools/data/combine.py +27 -0
  53. nltools/data/designmatrix/__init__.py +1032 -0
  54. nltools/data/designmatrix/append.py +518 -0
  55. nltools/data/designmatrix/diagnostics.py +248 -0
  56. nltools/data/designmatrix/io.py +356 -0
  57. nltools/data/designmatrix/plotting.py +291 -0
  58. nltools/data/designmatrix/regressors.py +463 -0
  59. nltools/data/designmatrix/transforms.py +200 -0
  60. nltools/data/designmatrix/utils.py +350 -0
  61. nltools/data/ownership.py +129 -0
  62. nltools/data/results.py +291 -0
  63. nltools/data/roc/__init__.py +398 -0
  64. nltools/data/simulator/__init__.py +927 -0
  65. nltools/data/simulator/haxby.py +124 -0
  66. nltools/data/validation.py +83 -0
  67. nltools/datasets.py +218 -0
  68. nltools/io/__init__.py +10 -0
  69. nltools/io/events.py +67 -0
  70. nltools/io/h5.py +246 -0
  71. nltools/mask.py +403 -0
  72. nltools/models/__init__.py +11 -0
  73. nltools/models/glm.py +543 -0
  74. nltools/models/results.py +49 -0
  75. nltools/models/ridge.py +1303 -0
  76. nltools/models/validation.py +26 -0
  77. nltools/plotting/__init__.py +32 -0
  78. nltools/plotting/adjacency.py +421 -0
  79. nltools/plotting/brain.py +669 -0
  80. nltools/plotting/decomposition.py +111 -0
  81. nltools/plotting/prediction.py +110 -0
  82. nltools/resources/covariates_example.csv +161 -0
  83. nltools/resources/onsets_example.csv +40 -0
  84. nltools/templates/__init__.py +51 -0
  85. nltools/templates/config.py +144 -0
  86. nltools/templates/fetch.py +260 -0
  87. nltools/templates/matching.py +183 -0
  88. nltools/templates/paths.py +106 -0
  89. nltools/templates/registry.py +25 -0
  90. nltools/utils.py +230 -0
  91. nltools/version.py +13 -0
  92. nltools-0.6.0.dev0.dist-info/METADATA +95 -0
  93. nltools-0.6.0.dev0.dist-info/RECORD +95 -0
  94. nltools-0.6.0.dev0.dist-info/WHEEL +4 -0
  95. 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()