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,174 @@
1
+ """Plotting functions for Adjacency matrices."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def _plot_adjacency(adj, *, limit=3, ax=None, **kwargs):
7
+ """Create a heatmap of an Adjacency matrix.
8
+
9
+ Args:
10
+ adj (Adjacency): Adjacency object to plot.
11
+ limit (int): Number of heatmaps to plot if the object contains multiple
12
+ matrices. Default 3.
13
+ ax (matplotlib.axes.Axes, optional): Axis to draw on (single matrix only).
14
+ **kwargs (dict): Forwarded to `seaborn.heatmap`.
15
+ """
16
+ import matplotlib.pyplot as plt
17
+ import seaborn as sns
18
+
19
+ if adj.is_single_matrix:
20
+ if ax is None:
21
+ _, ax = plt.subplots(nrows=1, figsize=(7, 5))
22
+ if adj.labels:
23
+ sns.heatmap(
24
+ adj.squareform(),
25
+ square=True,
26
+ ax=ax,
27
+ xticklabels=adj.labels,
28
+ yticklabels=adj.labels,
29
+ **kwargs,
30
+ )
31
+ else:
32
+ sns.heatmap(adj.squareform(), square=True, ax=ax, **kwargs)
33
+ else:
34
+ if ax is not None:
35
+ print("ax is ignored when plotting multiple images")
36
+ n_subs = np.minimum(len(adj), limit)
37
+ _, a = plt.subplots(nrows=n_subs, figsize=(7, len(adj) * 5))
38
+ for i in range(n_subs):
39
+ if adj.labels:
40
+ sns.heatmap(
41
+ adj[i].squareform(),
42
+ square=True,
43
+ xticklabels=adj.labels[i],
44
+ yticklabels=adj.labels[i],
45
+ ax=a[i],
46
+ **kwargs,
47
+ )
48
+ else:
49
+ sns.heatmap(adj[i].squareform(), square=True, ax=a[i], **kwargs)
50
+ return
51
+
52
+
53
+ def _plot_mds(
54
+ adj,
55
+ *,
56
+ n_components=2,
57
+ metric_mds=True,
58
+ labels=None,
59
+ labels_color=None,
60
+ cmap=None,
61
+ view=(30, 20),
62
+ figsize=None,
63
+ ax=None,
64
+ n_jobs=-1,
65
+ **kwargs,
66
+ ):
67
+ """Plot multidimensional scaling.
68
+
69
+ Args:
70
+ adj (Adjacency): Adjacency object to plot (must be a single distance matrix).
71
+ n_components (int): Number of dimensions to project (2 or 3).
72
+ metric_mds (bool): Perform metric (True) or non-metric (False) scaling.
73
+ Default True.
74
+ labels (list, optional): Overrides the labels stored on `adj`.
75
+ labels_color (list, optional): One color per label.
76
+ cmap (matplotlib.colors.Colormap, optional): Colormap. Default `plt.cm.hot_r`.
77
+ view (tuple): Elevation/azimuth for a 3-D plot. Default (30, 20).
78
+ figsize (list): Figure size. Default [12, 8].
79
+ ax (matplotlib.axes.Axes, optional): Axis to draw on.
80
+ n_jobs (int): Number of parallel jobs.
81
+ **kwargs (dict): Forwarded to `sklearn.manifold.MDS`.
82
+ """
83
+ import matplotlib.pyplot as plt
84
+ from sklearn.manifold import MDS, ClassicalMDS
85
+
86
+ if cmap is None:
87
+ cmap = plt.cm.hot_r
88
+ if figsize is None:
89
+ figsize = [12, 8]
90
+
91
+ if adj.matrix_type != "distance":
92
+ raise ValueError("MDS only works on distance matrices.")
93
+ if not adj.is_single_matrix:
94
+ raise ValueError("MDS only works on single matrices.")
95
+ if n_components not in [2, 3]:
96
+ raise ValueError(f"Cannot plot {n_components}-d image")
97
+ if labels is not None:
98
+ if len(labels) != adj.n_nodes:
99
+ raise ValueError(
100
+ "Make sure labels matches the same shape as Adjacency data"
101
+ )
102
+ else:
103
+ labels = adj.labels
104
+ if labels_color is not None:
105
+ if len(labels) == 0:
106
+ raise ValueError("Make sure that Adjacency object has labels specified.")
107
+ if len(labels) != len(labels_color):
108
+ raise ValueError("Length of labels_color must match self.labels.")
109
+
110
+ # Run MDS (sklearn >= 1.8 API). The classical-MDS starting configuration is
111
+ # built here, at the requested width, and passed to `fit_transform`, which
112
+ # takes precedence over the constructor's `init` — sklearn skips building
113
+ # its own, so this is computed once. Asking the constructor for it instead
114
+ # gives a 2-D start whatever `n_components` says, because it builds its
115
+ # `ClassicalMDS` with that class's own default, and `smacof` then adopts the
116
+ # start's width: a 3-D request would silently come back 2-D. `init` and
117
+ # `n_init` are still named because omitting either warns until they become
118
+ # sklearn's defaults in 1.9/1.10; classical MDS is deterministic, so one run
119
+ # suffices.
120
+ square = adj.squareform()
121
+ init = ClassicalMDS(n_components=n_components, metric="precomputed").fit_transform(
122
+ square
123
+ )
124
+ mds = MDS(
125
+ n_components=n_components,
126
+ metric_mds=metric_mds,
127
+ n_jobs=n_jobs,
128
+ metric="precomputed",
129
+ init="classical_mds",
130
+ n_init=1,
131
+ **kwargs,
132
+ )
133
+ proj = mds.fit_transform(square, init=init)
134
+
135
+ # Create Plot
136
+ if ax is None: # Create axis
137
+ fig = plt.figure(figsize=figsize)
138
+ if n_components == 3:
139
+ ax = fig.add_subplot(111, projection="3d")
140
+ ax.view_init(*view)
141
+ elif n_components == 2:
142
+ ax = fig.add_subplot(111)
143
+
144
+ # Plot dots
145
+ if n_components == 3:
146
+ ax.scatter(proj[:, 0], proj[:, 1], proj[:, 2], s=1, c="k")
147
+ elif n_components == 2:
148
+ ax.scatter(proj[:, 0], proj[:, 1], s=1, c="k")
149
+
150
+ # Plot labels
151
+ if labels_color is None:
152
+ labels_color = ["black"] * len(labels)
153
+ if n_components == 3:
154
+ for (x, y, z), label, color in zip(proj, labels, labels_color):
155
+ ax.text(
156
+ x,
157
+ y,
158
+ z,
159
+ label,
160
+ color="white",
161
+ bbox={"facecolor": color, "alpha": 1, "boxstyle": "round,pad=0.3"},
162
+ )
163
+ else:
164
+ for (x, y), label, color in zip(proj, labels, labels_color):
165
+ ax.text(
166
+ x,
167
+ y,
168
+ label,
169
+ color="white", # color,
170
+ bbox={"facecolor": color, "alpha": 1, "boxstyle": "round,pad=0.3"},
171
+ )
172
+
173
+ ax.xaxis.set_visible(False)
174
+ ax.yaxis.set_visible(False)
@@ -0,0 +1,349 @@
1
+ """Normalize relation matrices and construct owned, metadata-consistent results."""
2
+
3
+ from copy import deepcopy
4
+ from dataclasses import dataclass
5
+ from math import isqrt
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import polars as pl
10
+ from scipy.spatial.distance import squareform
11
+
12
+ from nltools.data.ownership import _copy_graph, _copy_object_frames, _copy_frame
13
+ from nltools.data.validation import _validate_frame
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class _MatrixState:
18
+ """Validated vector storage and its interpretation."""
19
+
20
+ data: np.ndarray
21
+ matrix_type: str
22
+ n_nodes: int
23
+ single: bool
24
+
25
+
26
+ def _normalize_matrix(data, matrix_type=None):
27
+ """Parse square or explicitly flat values without guessing stack axes."""
28
+ kind = matrix_type.lower() if isinstance(matrix_type, str) else matrix_type
29
+ valid = {"distance", "similarity", "directed"}
30
+ if kind is not None and kind not in valid | {x + "_flat" for x in valid}:
31
+ raise ValueError("Invalid matrix_type.")
32
+ if data is None or isinstance(data, list) and not data:
33
+ return _MatrixState(np.array([]), "empty", 0, False)
34
+ from_file = isinstance(data, (str, Path))
35
+ if from_file:
36
+ data = pl.read_csv(data).to_numpy()
37
+ if isinstance(data, pl.DataFrame):
38
+ data = data.to_numpy()
39
+ # Nullable pandas numerics use Object arrays unless explicitly converted.
40
+ import pandas as pd
41
+
42
+ if isinstance(data, pd.DataFrame):
43
+ if not all(pd.api.types.is_numeric_dtype(dtype) for dtype in data.dtypes):
44
+ raise ValueError("Adjacency data must be numeric or boolean.")
45
+ values = data.to_numpy()
46
+ data = (
47
+ data.to_numpy(dtype=float, na_value=np.nan)
48
+ if values.dtype.kind == "O"
49
+ else values
50
+ )
51
+ values = np.asarray(data)
52
+ if values.dtype.kind not in "biufc":
53
+ raise ValueError("Adjacency data must be numeric or boolean.")
54
+ if values.ndim not in (1, 2):
55
+ raise ValueError("Data must be a square matrix or a 1-D/2-D flat array.")
56
+ flat = kind is not None and kind.endswith("_flat")
57
+ if from_file and values.shape[1] == 1 and flat:
58
+ values = values[:, 0]
59
+ if flat or values.ndim == 1 and kind is None:
60
+ kind = kind.removesuffix("_flat") if kind else "distance"
61
+ edges = values.shape[-1]
62
+ if kind == "directed":
63
+ nodes = isqrt(edges)
64
+ valid_length = nodes * nodes == edges
65
+ else:
66
+ nodes = (1 + isqrt(1 + 8 * edges)) // 2
67
+ valid_length = nodes * (nodes - 1) // 2 == edges
68
+ if not valid_length:
69
+ raise ValueError(
70
+ "Flat data length must be triangular or a perfect square for directed matrices."
71
+ )
72
+ if kind == "directed" and nodes == 0 and values.ndim == 1:
73
+ return _MatrixState(np.array([]), "empty", 0, False)
74
+ return _MatrixState(values.copy(), kind, nodes, values.ndim == 1)
75
+ if values.ndim != 2 or values.shape[0] != values.shape[1]:
76
+ raise ValueError(
77
+ "Data must be square; rectangular stacks require an explicit flat matrix_type."
78
+ )
79
+ nodes = values.shape[0]
80
+ if nodes == 0:
81
+ return _MatrixState(np.array([]), "empty", 0, False)
82
+ # Correlation producers can differ across triangles by floating-point roundoff.
83
+ symmetric = (
84
+ np.allclose(values, values.T, rtol=1e-12, atol=1e-12, equal_nan=True)
85
+ if values.dtype.kind in "fc"
86
+ else np.array_equal(values, values.T)
87
+ )
88
+ if kind is None:
89
+ if not symmetric:
90
+ kind = "directed"
91
+ elif np.all(np.diag(values) == 0):
92
+ kind = "distance"
93
+ elif np.all(np.diag(values) == 1):
94
+ kind = "similarity"
95
+ else:
96
+ raise ValueError(
97
+ "Symmetric matrices with other diagonals require an explicit matrix_type."
98
+ )
99
+ if kind != "directed" and not symmetric:
100
+ raise ValueError(
101
+ "Distance and similarity matrices must be symmetric, including NaN positions."
102
+ )
103
+ vector = values.ravel() if kind == "directed" else values[np.triu_indices(nodes, 1)]
104
+ return _MatrixState(vector.copy(), kind, nodes, True)
105
+
106
+
107
+ def _labels_are_nested(labels):
108
+ """Recognize a matrix-by-node label grid by its structure."""
109
+ return bool(labels) and isinstance(labels[0], (list, tuple, np.ndarray))
110
+
111
+
112
+ def _validate_labels(labels, *, n_nodes, n_matrices, single):
113
+ """Accept shared node labels or a stack's explicit label grid."""
114
+ if labels is None:
115
+ return []
116
+ if not isinstance(labels, (list, np.ndarray)):
117
+ raise TypeError("labels must be a list or numpy array.")
118
+ labels = labels.tolist() if isinstance(labels, np.ndarray) else labels
119
+ if not labels:
120
+ return labels
121
+ if _labels_are_nested(labels):
122
+ if (
123
+ single
124
+ or len(labels) != n_matrices
125
+ or any(len(row) != n_nodes for row in labels)
126
+ ):
127
+ raise ValueError("Nested labels must have shape (n_matrices, n_nodes).")
128
+ return labels
129
+ if len(labels) != n_nodes:
130
+ raise ValueError("Node labels must match n_nodes.")
131
+ return labels
132
+
133
+
134
+ def _owned_frame(value, n_matrices):
135
+ """Validate and detach matrix metadata, including Python Object cells."""
136
+ frame = _validate_frame(value, frame_type="Y")
137
+ if frame.width and frame.height != n_matrices:
138
+ raise ValueError(
139
+ f"Y rows ({frame.height}) do not match matrices ({n_matrices})."
140
+ )
141
+ return _copy_frame(frame)
142
+
143
+
144
+ def _initialize(adj, data, *, matrix_type, labels, Y):
145
+ """Initialize one facade from normalized values or an owned source graph."""
146
+ from . import Adjacency
147
+ from nltools.io.h5 import _is_h5_path
148
+
149
+ if isinstance(data, (str, Path)) and _is_h5_path(data):
150
+ from .io import _read_h5
151
+
152
+ data = _read_h5(data)
153
+ matrix_type = None
154
+ if isinstance(data, list) and data:
155
+ if any(isinstance(item, (str, Path)) and _is_h5_path(item) for item in data):
156
+ raise ValueError(
157
+ "Lists of HDF paths are not supported; load each Adjacency first."
158
+ )
159
+ members = [
160
+ item
161
+ if isinstance(item, Adjacency)
162
+ else Adjacency(item, matrix_type=matrix_type)
163
+ for item in data
164
+ ]
165
+ data = members[0].copy()
166
+ for member in members[1:]:
167
+ data = _append(data, member)
168
+ if data.is_single_matrix:
169
+ data = _select(data, [0])
170
+ if isinstance(data, Adjacency):
171
+ if (
172
+ matrix_type is not None
173
+ and matrix_type.lower().removesuffix("_flat") != data.matrix_type
174
+ ):
175
+ raise ValueError("Copy construction cannot reinterpret matrix_type.")
176
+ values = dict(data.__dict__)
177
+ if labels is not None:
178
+ values["labels"] = _validate_labels(
179
+ labels,
180
+ n_nodes=data.n_nodes,
181
+ n_matrices=len(data),
182
+ single=data.is_single_matrix,
183
+ )
184
+ if Y is not None:
185
+ values["_Y"] = _validate_frame(Y, frame_type="Y")
186
+ if values["_Y"].width and values["_Y"].height != len(data):
187
+ raise ValueError("Y rows must match the number of matrices.")
188
+ memo = {id(data): adj}
189
+ _copy_frame(values["_Y"], memo)
190
+ _copy_object_frames(values, memo)
191
+ adj.__dict__.update(deepcopy(values, memo))
192
+ return
193
+ state = _normalize_matrix(data, matrix_type)
194
+ adj.data = state.data
195
+ adj.matrix_type = state.matrix_type
196
+ adj._n_nodes = state.n_nodes
197
+ adj.is_single_matrix = state.single
198
+ adj.issymmetric = state.matrix_type in ("distance", "similarity")
199
+ metadata = {
200
+ "labels": _validate_labels(
201
+ labels, n_nodes=state.n_nodes, n_matrices=len(adj), single=state.single
202
+ ),
203
+ "_Y": _validate_frame(Y, frame_type="Y"),
204
+ }
205
+ if metadata["_Y"].width and metadata["_Y"].height != len(adj):
206
+ raise ValueError("Y rows must match the number of matrices.")
207
+ memo = {}
208
+ _copy_frame(metadata["_Y"], memo)
209
+ _copy_object_frames(metadata, memo)
210
+ adj.__dict__.update(deepcopy(metadata, memo))
211
+
212
+
213
+ def _result(adj, values, *, labels, Y, matrix_type=None):
214
+ """Build an owned result while retaining aliases in the retained metadata graph."""
215
+ state = _normalize_matrix(values, (matrix_type or adj.matrix_type) + "_flat")
216
+ return _copy_graph(
217
+ adj,
218
+ replacements={
219
+ "data": state.data,
220
+ "matrix_type": state.matrix_type,
221
+ "_n_nodes": state.n_nodes,
222
+ "is_single_matrix": state.single,
223
+ "issymmetric": state.matrix_type in ("distance", "similarity"),
224
+ "labels": labels,
225
+ "_Y": Y,
226
+ },
227
+ )
228
+
229
+
230
+ def _select(adj, index):
231
+ """Select matrices with stable scalar versus sequence rank."""
232
+ if isinstance(index, tuple):
233
+ index = list(index)
234
+ positions = np.arange(len(adj))[index]
235
+ single = np.ndim(positions) == 0
236
+ rows = np.atleast_1d(positions).tolist()
237
+ values = (
238
+ adj.data.reshape(len(adj), -1)
239
+ if len(adj) and adj.data.size
240
+ else np.empty((len(adj), adj.data.shape[-1]), dtype=adj.data.dtype)
241
+ )
242
+ values = values[positions] if single else values[rows]
243
+ labels = adj.labels
244
+ if _labels_are_nested(labels):
245
+ labels = labels[int(positions)] if single else [labels[i] for i in rows]
246
+ frame = adj.Y[rows] if adj.Y.width else adj.Y
247
+ if adj.matrix_type == "empty":
248
+ return adj.copy()
249
+ return _result(adj, values, labels=labels, Y=frame)
250
+
251
+
252
+ def _common_labels(adj):
253
+ """Return labels shared by all matrices, or no labels if they disagree."""
254
+ if not _labels_are_nested(adj.labels):
255
+ return adj.labels
256
+ first = adj.labels[0]
257
+ return first if all(row == first for row in adj.labels) else []
258
+
259
+
260
+ def _validate_compatible(left, right, *, labels=False):
261
+ """Require the same relation schema and optionally the same node ordering."""
262
+ if left.n_nodes != right.n_nodes or left.matrix_type != right.matrix_type:
263
+ raise ValueError(
264
+ "Adjacency instances must have matching node counts and matrix types."
265
+ )
266
+ if labels:
267
+
268
+ def rows(adj):
269
+ return (
270
+ adj.labels
271
+ if _labels_are_nested(adj.labels)
272
+ else [adj.labels] * len(adj)
273
+ )
274
+
275
+ if rows(left) != rows(right):
276
+ raise ValueError("Adjacency node labels and ordering must match.")
277
+
278
+
279
+ def _append(left, right):
280
+ """Stack compatible matrices and merge matrix metadata by column name."""
281
+ from . import Adjacency
282
+
283
+ if not isinstance(right, Adjacency):
284
+ raise ValueError("Data must be an Adjacency instance.")
285
+ if left.matrix_type == "empty":
286
+ return right.copy()
287
+ if right.matrix_type == "empty":
288
+ return left.copy()
289
+ _validate_compatible(left, right)
290
+ if left.is_empty:
291
+ return right.copy()
292
+ if right.is_empty:
293
+ return left.copy()
294
+ if bool(left.labels) != bool(right.labels):
295
+ raise ValueError(
296
+ "Both inputs must supply node labels or neither may supply them."
297
+ )
298
+ labels = left.labels
299
+ if left.labels != right.labels or _labels_are_nested(left.labels):
300
+ labels = []
301
+ for adj in (left, right):
302
+ labels.extend(
303
+ adj.labels
304
+ if _labels_are_nested(adj.labels)
305
+ else [adj.labels] * len(adj)
306
+ )
307
+ frames = []
308
+ columns = set(left.Y.columns) | set(right.Y.columns)
309
+ for adj in (left, right):
310
+ frames.append(
311
+ adj.Y
312
+ if adj.Y.width or not columns
313
+ else pl.DataFrame({name: [None] * len(adj) for name in sorted(columns)})
314
+ )
315
+ frame = pl.concat(frames, how="diagonal_relaxed")
316
+ return _result(left, np.vstack([left.data, right.data]), labels=labels, Y=frame)
317
+
318
+
319
+ def _to_square(adj):
320
+ """Export detached square matrices with a zero symmetric diagonal."""
321
+ if adj.matrix_type == "empty":
322
+ return np.empty((0, 0))
323
+
324
+ def expand(row):
325
+ return (
326
+ squareform(row)
327
+ if adj.issymmetric
328
+ else row.reshape(adj.n_nodes, adj.n_nodes).copy()
329
+ )
330
+
331
+ return (
332
+ expand(adj.data) if adj.is_single_matrix else [expand(row) for row in adj.data]
333
+ )
334
+
335
+
336
+ def _distance_to_similarity(adj, metric, beta):
337
+ """Apply the established distance conversion independently per matrix."""
338
+ if adj.matrix_type != "distance":
339
+ raise ValueError("Matrix is not a distance matrix.")
340
+ if metric == "correlation":
341
+ values = 1 - adj.data
342
+ elif metric == "euclidean":
343
+ scales = np.array([np.std(squareform(row)) for row in np.atleast_2d(adj.data)])
344
+ values = np.exp(
345
+ -beta * adj.data / (scales[0] if adj.is_single_matrix else scales[:, None])
346
+ )
347
+ else:
348
+ raise ValueError('metric can only be ["correlation","euclidean"]')
349
+ return _result(adj, values, labels=adj.labels, Y=adj.Y, matrix_type="similarity")