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,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
|