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