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,26 @@
1
+ """The one validation check the nltools estimators share.
2
+
3
+ `_Glm` and `_Ridge` are independent estimators with different input contracts, so
4
+ they share this small private function instead of a common base class.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sklearn.exceptions import NotFittedError
10
+
11
+
12
+ def _check_is_fitted(model: object) -> None:
13
+ """Raise if `model` has not been fitted yet.
14
+
15
+ Args:
16
+ model (object): Estimator carrying an `is_fitted_` flag.
17
+
18
+ Raises:
19
+ NotFittedError: If the model has not been fitted yet. `NotFittedError`
20
+ subclasses both `ValueError` and `AttributeError`.
21
+ """
22
+ if not model.is_fitted_:
23
+ raise NotFittedError(
24
+ f"This {type(model).__name__} instance is not fitted yet. "
25
+ "Call 'fit' with appropriate arguments before using this estimator."
26
+ )
@@ -0,0 +1,32 @@
1
+ """nltools.plotting — visualization utilities for neuroimaging analysis.
2
+
3
+ `component_viewer` is the one function users call directly. Everything else
4
+ here is the drawing internal behind a data-class method — `BrainData.plot_surf`,
5
+ `Roc.plot`, `Adjacency.plot_silhouette` and friends — organized into focused
6
+ submodules:
7
+
8
+ - **brain**: surface plots and flatmaps
9
+ - **adjacency**: adjacency matrix visualizations (stacked, silhouette, distance)
10
+ - **prediction**: model output plots (ROC, decision margin, regression, probability)
11
+ - **decomposition**: ICA/PCA component viewer
12
+ """
13
+
14
+ from .brain import ( # noqa: F401
15
+ _plot_surf,
16
+ _plot_flatmap,
17
+ )
18
+ from .adjacency import ( # noqa: F401
19
+ _plot_stacked_adjacency,
20
+ _plot_mean_label_distance,
21
+ _plot_between_label_distance,
22
+ _plot_silhouette,
23
+ )
24
+ from .prediction import ( # noqa: F401
25
+ _plot_predicted_versus_actual,
26
+ _plot_decision_margin,
27
+ _plot_class_probability,
28
+ _plot_roc,
29
+ )
30
+ from .decomposition import component_viewer
31
+
32
+ __all__ = ["component_viewer"]
@@ -0,0 +1,421 @@
1
+ """Adjacency matrix visualization — stacked plots, distance, and silhouette."""
2
+
3
+ import matplotlib.pyplot as plt
4
+ import numpy as np
5
+ import polars as pl
6
+ import seaborn as sns
7
+
8
+ from nltools.algorithms.inference import (
9
+ one_sample_permutation_test,
10
+ two_sample_permutation_test,
11
+ )
12
+
13
+
14
+ def _polars_to_pandas(df):
15
+ """Convert a polars DataFrame to pandas without requiring pyarrow."""
16
+ import pandas as pd
17
+
18
+ return pd.DataFrame(df.to_dict(as_series=False))
19
+
20
+
21
+ def _as_square_ndarray(distance):
22
+ """Accept np.ndarray, polars DataFrame, or pandas DataFrame and return a square float ndarray."""
23
+ if isinstance(distance, np.ndarray):
24
+ arr = distance
25
+ elif isinstance(distance, pl.DataFrame):
26
+ arr = distance.to_numpy()
27
+ elif hasattr(distance, "values") and hasattr(distance, "shape"):
28
+ arr = np.asarray(distance.values)
29
+ else:
30
+ arr = np.asarray(distance)
31
+ if arr.ndim != 2 or arr.shape[0] != arr.shape[1]:
32
+ raise ValueError(f"distance must be a square matrix; got shape {arr.shape}")
33
+ return arr.astype(float)
34
+
35
+
36
+ def _within_between_values(distance_arr, labels_arr, label):
37
+ """Return (within, between) 1-D arrays for one label."""
38
+ mask_in = labels_arr == label
39
+ block = distance_arr[np.ix_(mask_in, mask_in)]
40
+ within = block[np.triu_indices(mask_in.sum(), k=1)]
41
+ between = distance_arr[np.ix_(mask_in, ~mask_in)].ravel()
42
+ return within, between
43
+
44
+
45
+ def _stacked_adjacency_matrix(adjacency1, adjacency2, normalize=True):
46
+ """Build the stacked matrix with adjacency1 in the upper triangle, adjacency2 lower.
47
+
48
+ The mapping is fixed regardless of `normalize` so toggling normalization never
49
+ swaps which input appears in which triangle. When `normalize` is True each
50
+ triangle is mean-centered and scaled by the max absolute triangle value (guarded
51
+ against a zero max) so the two datasets share a comparable range.
52
+
53
+ Args:
54
+ adjacency1: Adjacency instance rendered in the upper triangle.
55
+ adjacency2: Adjacency instance rendered in the lower triangle.
56
+ normalize: Mean-center and scale each triangle before stacking. Default True.
57
+
58
+ Returns:
59
+ Square np.ndarray with the two triangles combined.
60
+ """
61
+ if normalize:
62
+ upper_src = (adjacency1 - adjacency1.mean()).squareform()
63
+ lower_src = (adjacency2 - adjacency2.mean()).squareform()
64
+ else:
65
+ upper_src = adjacency1.squareform()
66
+ lower_src = adjacency2.squareform()
67
+
68
+ upper = np.triu(upper_src, k=1)
69
+ lower = np.tril(lower_src, k=-1)
70
+
71
+ if normalize:
72
+ upper_scale = np.max(np.abs(upper))
73
+ lower_scale = np.max(np.abs(lower))
74
+ if upper_scale > 0:
75
+ upper = upper / upper_scale
76
+ if lower_scale > 0:
77
+ lower = lower / lower_scale
78
+
79
+ return upper + lower
80
+
81
+
82
+ def _plot_stacked_adjacency(adjacency1, adjacency2, normalize=True, **kwargs):
83
+ """Create stacked adjacency to illustrate similarity.
84
+
85
+ `adjacency1` is drawn in the upper triangle and `adjacency2` in the lower,
86
+ consistently whether or not `normalize` is set.
87
+
88
+ Args:
89
+ adjacency1 (Adjacency): Adjacency instance shown in the upper triangle.
90
+ adjacency2 (Adjacency): Adjacency instance shown in the lower triangle.
91
+ normalize (bool): Normalize matrices before stacking. Default True.
92
+ **kwargs (dict): Forwarded to `seaborn.heatmap`.
93
+
94
+ Returns:
95
+ matplotlib.axes.Axes: Axes holding the stacked heatmap.
96
+ """
97
+ from nltools.data import Adjacency
98
+
99
+ if not isinstance(adjacency1, Adjacency) or not isinstance(adjacency2, Adjacency):
100
+ raise ValueError("This function requires Adjacency() instances as input.")
101
+
102
+ dist = _stacked_adjacency_matrix(adjacency1, adjacency2, normalize=normalize)
103
+ return sns.heatmap(
104
+ dist, xticklabels=False, yticklabels=False, square=True, **kwargs
105
+ )
106
+
107
+
108
+ def _plot_mean_label_distance(
109
+ distance,
110
+ labels,
111
+ *,
112
+ ax=None,
113
+ permutation_test=False,
114
+ n_permute=5000,
115
+ fontsize=18,
116
+ **kwargs,
117
+ ):
118
+ """Violin plot of within- vs between-label distances.
119
+
120
+ Args:
121
+ distance (np.ndarray | pl.DataFrame | pd.DataFrame): Square pairwise distance
122
+ matrix.
123
+ labels (array-like): Group label for each row/column (length N).
124
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
125
+ permutation_test (bool): If True, run a two-sample permutation test per group.
126
+ Default False.
127
+ n_permute (int): Number of permutations. Default 5000.
128
+ fontsize (int): Font size for the axis label and title. Default 18.
129
+ **kwargs (dict): Forwarded to `seaborn.violinplot`.
130
+
131
+ Returns:
132
+ pl.DataFrame | tuple[pl.DataFrame, dict]: A long-format frame with columns
133
+ `Distance`, `Type`, `Group`. If `permutation_test=True`, a tuple
134
+ `(long_df, stats)` where `stats` maps each group label to its
135
+ permutation-test result.
136
+ """
137
+ arr = _as_square_ndarray(distance)
138
+ labels_arr = np.asarray(labels)
139
+ if labels_arr.shape[0] != arr.shape[0]:
140
+ raise ValueError("Labels must be same length as distance matrix")
141
+
142
+ rows = []
143
+ for group in np.unique(labels_arr):
144
+ within, between = _within_between_values(arr, labels_arr, group)
145
+ rows.append(
146
+ pl.DataFrame(
147
+ {"Distance": within, "Type": ["Within"] * len(within), "Group": group}
148
+ )
149
+ )
150
+ rows.append(
151
+ pl.DataFrame(
152
+ {
153
+ "Distance": between,
154
+ "Type": ["Between"] * len(between),
155
+ "Group": group,
156
+ }
157
+ )
158
+ )
159
+ out = pl.concat(rows, how="vertical")
160
+
161
+ f = sns.violinplot(
162
+ x="Group",
163
+ y="Distance",
164
+ hue="Type",
165
+ data=_polars_to_pandas(out),
166
+ split=True,
167
+ inner="quartile",
168
+ palette={"Within": "lightskyblue", "Between": "red"},
169
+ ax=ax,
170
+ **kwargs,
171
+ )
172
+ f.set_ylabel("Average Distance", fontsize=fontsize)
173
+ f.set_title("Average Group Distance", fontsize=fontsize)
174
+
175
+ if permutation_test:
176
+ stats = {}
177
+ for group in np.unique(labels_arr):
178
+ within = out.filter(
179
+ (pl.col("Group") == group) & (pl.col("Type") == "Within")
180
+ )["Distance"].to_numpy()
181
+ between = out.filter(
182
+ (pl.col("Group") == group) & (pl.col("Type") == "Between")
183
+ )["Distance"].to_numpy()
184
+ stats[str(group)] = two_sample_permutation_test(
185
+ within, between, n_permute=n_permute
186
+ )
187
+ return out, stats
188
+ return out
189
+
190
+
191
+ def _plot_between_label_distance(
192
+ distance,
193
+ labels,
194
+ *,
195
+ ax=None,
196
+ permutation_test=True,
197
+ n_permute=5000,
198
+ **kwargs,
199
+ ):
200
+ """Heatmap of average pairwise distance between every label pair.
201
+
202
+ Args:
203
+ distance (np.ndarray | pl.DataFrame | pd.DataFrame): Square pairwise distance
204
+ matrix.
205
+ labels (array-like): Group label for each row/column (length N).
206
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
207
+ permutation_test (bool): If True, also compute mean-difference and p-value
208
+ matrices. Default True.
209
+ n_permute (int): Number of permutations. Default 5000.
210
+ **kwargs (dict): Forwarded to `seaborn.heatmap`.
211
+
212
+ Returns:
213
+ tuple[pl.DataFrame, ...]: `(long_df, within_mean_df)` without
214
+ `permutation_test`, or `(long_df, within_mean_df, mean_diff_df, p_df)`
215
+ with it. All frames are polars DataFrames. `long_df` has columns
216
+ `Distance`, `Group`, `Comparison`. The three square-matrix-like frames are
217
+ long format with columns `label1`, `label2`, and a value column so they
218
+ can be pivoted to a matrix if needed.
219
+ """
220
+ arr = _as_square_ndarray(distance)
221
+ labels_arr = np.asarray(labels)
222
+ if labels_arr.shape[0] != arr.shape[0]:
223
+ raise ValueError("Labels must be same length as distance matrix")
224
+
225
+ unique = np.unique(labels_arr)
226
+
227
+ long_rows = []
228
+ for i in unique:
229
+ mask_i = labels_arr == i
230
+ for j in unique:
231
+ mask_j = labels_arr == j
232
+ if i == j:
233
+ vals = arr[np.ix_(mask_i, mask_i)][np.triu_indices(mask_i.sum(), k=1)]
234
+ else:
235
+ vals = arr[np.ix_(mask_i, mask_j)].ravel()
236
+ long_rows.append(
237
+ pl.DataFrame(
238
+ {
239
+ "Distance": vals,
240
+ "Group": i,
241
+ "Comparison": j,
242
+ }
243
+ )
244
+ )
245
+ long_df = pl.concat(long_rows, how="vertical")
246
+
247
+ within_mean_df = (
248
+ long_df.group_by(["Group", "Comparison"])
249
+ .agg(pl.col("Distance").mean().alias("mean_distance"))
250
+ .rename({"Group": "label1", "Comparison": "label2"})
251
+ )
252
+
253
+ if ax is None:
254
+ _, ax = plt.subplots(1)
255
+
256
+ within_matrix = _long_to_matrix(
257
+ within_mean_df, "label1", "label2", "mean_distance", unique
258
+ )
259
+
260
+ if permutation_test:
261
+ mean_diff_rows = []
262
+ p_rows = []
263
+ for i in unique:
264
+ within_i = long_df.filter(
265
+ (pl.col("Group") == i) & (pl.col("Comparison") == i)
266
+ )["Distance"].to_numpy()
267
+ for j in unique:
268
+ between_ij = long_df.filter(
269
+ (pl.col("Group") == i) & (pl.col("Comparison") == j)
270
+ )["Distance"].to_numpy()
271
+ if i == j or len(within_i) == 0 or len(between_ij) == 0:
272
+ mean_diff_rows.append({"label1": i, "label2": j, "mean_diff": 0.0})
273
+ p_rows.append({"label1": i, "label2": j, "p": 1.0})
274
+ continue
275
+ s = two_sample_permutation_test(
276
+ within_i, between_ij, n_permute=n_permute
277
+ )
278
+ mean_diff_rows.append(
279
+ {"label1": i, "label2": j, "mean_diff": float(s["mean_diff"])}
280
+ )
281
+ p_rows.append({"label1": i, "label2": j, "p": float(s["p"])})
282
+ mean_diff_df = pl.DataFrame(mean_diff_rows)
283
+ p_df = pl.DataFrame(p_rows)
284
+
285
+ mean_diff_matrix = _long_to_matrix(
286
+ mean_diff_df, "label1", "label2", "mean_diff", unique
287
+ )
288
+ p_matrix = _long_to_matrix(p_df, "label1", "label2", "p", unique)
289
+
290
+ sns.heatmap(mean_diff_matrix, ax=ax, square=True, **kwargs)
291
+ sns.heatmap(
292
+ mean_diff_matrix,
293
+ mask=p_matrix > 0.05,
294
+ square=True,
295
+ linewidth=2,
296
+ annot=True,
297
+ ax=ax,
298
+ cbar=False,
299
+ )
300
+ return long_df, within_mean_df, mean_diff_df, p_df
301
+
302
+ sns.heatmap(within_matrix, ax=ax, square=True, **kwargs)
303
+ return long_df, within_mean_df
304
+
305
+
306
+ def _long_to_matrix(long_df, row_col, col_col, value_col, order):
307
+ """Pivot a long-format polars frame into a numpy matrix using *order* for row/col order."""
308
+ wide = long_df.pivot(
309
+ values=value_col, index=row_col, on=col_col, aggregate_function="first"
310
+ )
311
+ out = np.zeros((len(order), len(order)), dtype=float)
312
+ wide_dict = {row[row_col]: row for row in wide.iter_rows(named=True)}
313
+ for i, r in enumerate(order):
314
+ row = wide_dict.get(r, {})
315
+ for j, c in enumerate(order):
316
+ val = row.get(c)
317
+ out[i, j] = float(val) if val is not None else 0.0
318
+ return out
319
+
320
+
321
+ def _plot_silhouette(
322
+ distance,
323
+ labels,
324
+ *,
325
+ ax=None,
326
+ permutation_test=True,
327
+ n_permute=5000,
328
+ colors=None,
329
+ figsize=(6, 4),
330
+ ):
331
+ """Silhouette plot indicating between- vs within-label distance.
332
+
333
+ Uses the simplified silhouette definition from the original nltools
334
+ implementation: within(i) = mean distance to other points in the same
335
+ cluster; between(i) = mean distance to all points in other clusters
336
+ (not the strict Rousseeuw min-over-clusters). Score is
337
+ (between - within) / max(between, within).
338
+
339
+ Args:
340
+ distance (np.ndarray | pl.DataFrame | pd.DataFrame): Square pairwise distance
341
+ matrix.
342
+ labels (array-like): Cluster label for each row/column (length N).
343
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
344
+ permutation_test (bool): If True, run a one-sample permutation test per cluster
345
+ on positive-mean silhouette scores. Default True.
346
+ n_permute (int): Number of permutations. Default 5000.
347
+ colors (list, optional): RGB triplets, one per cluster. Default: seaborn
348
+ `'hls'` palette.
349
+ figsize (tuple): Figure size. Default (6, 4).
350
+
351
+ Returns:
352
+ pl.DataFrame: Frame with columns `label` and `mean_silhouette`. If
353
+ `permutation_test` is True, adds a `p` column (1.0 for clusters with
354
+ non-positive mean).
355
+ """
356
+ arr = _as_square_ndarray(distance)
357
+ labels_arr = np.asarray(labels)
358
+ if labels_arr.shape[0] != arr.shape[0]:
359
+ raise ValueError("Labels must be same length as distance matrix")
360
+
361
+ unique = np.unique(labels_arr)
362
+ n_clusters = len(unique)
363
+ n = arr.shape[0]
364
+
365
+ if colors is None:
366
+ colors = sns.color_palette("hls", n_clusters)
367
+
368
+ sil = np.zeros(n, dtype=float)
369
+ for idx in range(n):
370
+ same = (labels_arr == labels_arr[idx]) & (np.arange(n) != idx)
371
+ other = labels_arr != labels_arr[idx]
372
+ within_mean = arr[idx, same].mean() if same.any() else 0.0
373
+ between_mean = arr[idx, other].mean() if other.any() else 0.0
374
+ denom = max(within_mean, between_mean)
375
+ sil[idx] = (between_mean - within_mean) / denom if denom > 0 else 0.0
376
+
377
+ with sns.axes_style("white"):
378
+ if ax is None:
379
+ _, ax = plt.subplots(1, figsize=figsize)
380
+
381
+ x_lower = 10
382
+ label_x_positions = []
383
+ for ci, cluster in enumerate(unique):
384
+ cluster_vals = np.sort(sil[labels_arr == cluster])
385
+ size = cluster_vals.shape[0]
386
+ x_upper = x_lower + size
387
+ color = colors[ci]
388
+ with sns.axes_style("white"):
389
+ plt.fill_between(
390
+ np.arange(x_lower, x_upper),
391
+ 0,
392
+ cluster_vals,
393
+ facecolor=color,
394
+ edgecolor=color,
395
+ )
396
+ label_x_positions.append(np.mean([x_lower, x_upper]))
397
+ x_lower = x_upper + 3
398
+
399
+ ax.set_xticks(label_x_positions)
400
+ ax.set_xticklabels(unique)
401
+ ax.set_title("Silhouettes", fontsize=18)
402
+ ax.set_xlim([5, 10 + n + n_clusters * 3])
403
+
404
+ rows = []
405
+ for cluster in unique:
406
+ cluster_vals = sil[labels_arr == cluster]
407
+ rows.append({"label": cluster, "mean_silhouette": float(cluster_vals.mean())})
408
+ out = pl.DataFrame(rows)
409
+
410
+ if permutation_test:
411
+ p_values = []
412
+ for cluster in unique:
413
+ cluster_vals = sil[labels_arr == cluster]
414
+ if cluster_vals.mean() > 0:
415
+ stats = one_sample_permutation_test(cluster_vals, n_permute=n_permute)
416
+ p_values.append(float(stats["p"]))
417
+ else:
418
+ p_values.append(1.0)
419
+ out = out.with_columns(pl.Series("p", p_values))
420
+
421
+ return out