tmapper-py 0.1.0__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.
@@ -0,0 +1,104 @@
1
+ """Port of tmapper_tools/CyclePathDecomp.m from the MATLAB toolbox.
2
+
3
+ The MATLAB original's diagonal cluster-block overlay (``addDiagBlock.m``)
4
+ is not ported literally -- it's tightly coupled to MATLAB axes internals
5
+ (``ax.Children(end).CData``) with no Python equivalent. The same visual
6
+ annotation is reproduced directly here with matplotlib patches, reusing
7
+ :func:`tmapper.graph_utils.find_blocks`.
8
+ """
9
+
10
+ import numpy as np
11
+ import networkx as nx
12
+
13
+ from .cycle_count2p import cycle_count2p
14
+ from .cycle_cluster import cycle_cluster
15
+ from .cycle_cluster_conn import cycle_cluster_conn
16
+ from .cycles_to_paths import cycles_to_paths
17
+ from .graph_utils import find_blocks
18
+
19
+
20
+ def _add_diag_block(ax, cluster_labels, *, reordermat=True):
21
+ from matplotlib.patches import Rectangle
22
+
23
+ cluster_labels = np.asarray(cluster_labels)
24
+ order = np.argsort(cluster_labels, kind="stable") if reordermat else np.arange(len(cluster_labels))
25
+ sorted_labels = cluster_labels[order]
26
+
27
+ for label in np.unique(sorted_labels):
28
+ starts, _, sizes = find_blocks((sorted_labels == label).astype(int))
29
+ for s, size in zip(starts, sizes):
30
+ ax.add_patch(Rectangle((s - 0.5, s - 0.5), size, size,
31
+ fill=False, edgecolor="k", linewidth=2))
32
+
33
+
34
+ def cycle_path_decomp(dg, *, clusterthres=0.5, plotmat=True, plotmds=False,
35
+ plothist=False, reordermat=True):
36
+ """Cycle-path decomposition of a directed graph.
37
+
38
+ Port of MATLAB's ``CyclePathDecomp.m``. Cycles on the graph are
39
+ first computed, then clustered such that cycles with ``clusterthres``
40
+ amount of overlap belong to the same cluster. Boundaries between
41
+ clusters of cycles (nodes where one cycle enters into another) are
42
+ used to cut cycles into paths, which are then clustered themselves.
43
+
44
+ Parameters
45
+ ----------
46
+ dg : networkx.DiGraph
47
+ The graph to decompose.
48
+ clusterthres : float, default 0.5
49
+ Overlap threshold for cycle/path clustering.
50
+ plotmat : bool, default True
51
+ Whether to plot the overlap matrices (with cluster-block
52
+ outlines) for diagnostics.
53
+ plotmds : bool, default False
54
+ Whether to plot a 2D classical-MDS projection of cycles/paths.
55
+ plothist : bool, default False
56
+ Whether to plot linkage-distance histograms.
57
+ reordermat : bool, default True
58
+ Whether to reorder the overlap matrices by cluster assignment.
59
+
60
+ Returns
61
+ -------
62
+ allbd : list
63
+ All boundary points between cycle clusters (used to cut cycles
64
+ into paths).
65
+ pclusters_nodes : list of list
66
+ Nodes in each path cluster.
67
+ pclusters_interior : list of list
68
+ Interior (non-boundary) nodes of each path cluster.
69
+ pclusters_boundary : list of set
70
+ Boundary nodes of each path cluster.
71
+ pcluster_conn_dir : list of list of set
72
+ Directed boundary connectivity between path clusters.
73
+ pclusters_intcrtpts : list of list
74
+ Interior critical points of each path cluster.
75
+ allupath : list of list
76
+ All unique decomposed paths.
77
+ Tp : numpy.ndarray
78
+ Cluster assignment of each path in ``allupath``.
79
+ """
80
+ A = nx.to_numpy_array(dg, nodelist=list(dg.nodes()), weight=None)
81
+ _, _, _, allcycles = cycle_count2p(A)
82
+
83
+ T, ax1 = cycle_cluster(allcycles, clusterthres, plotmat=plotmat, plotmds=plotmds,
84
+ plothist=plothist, reordermat=reordermat, return_ax=True)
85
+ _, _, _, clusters_boundary, _, _, _ = cycle_cluster_conn(dg, allcycles, T)
86
+
87
+ if plotmat and ax1 is not None:
88
+ _add_diag_block(ax1, T, reordermat=reordermat)
89
+ print(f"# cycle clusters = {int(T.max()) if len(T) else 0}")
90
+
91
+ allbd = sorted(set().union(*clusters_boundary)) if clusters_boundary else []
92
+ allupath = cycles_to_paths(allcycles, allbd)
93
+
94
+ Tp, ax2 = cycle_cluster(allupath, clusterthres, plotmat=plotmat, plotmds=plotmds,
95
+ plothist=plothist, reordermat=reordermat, return_ax=True)
96
+ (_, pcluster_conn_dir, pclusters_nodes, pclusters_boundary,
97
+ pclusters_interior, _, pclusters_intcrtpts) = cycle_cluster_conn(dg, allupath, Tp)
98
+
99
+ if plotmat and ax2 is not None:
100
+ _add_diag_block(ax2, Tp, reordermat=reordermat)
101
+ print(f"# path clusters = {int(Tp.max()) if len(Tp) else 0}")
102
+
103
+ return (allbd, pclusters_nodes, pclusters_interior, pclusters_boundary,
104
+ pcluster_conn_dir, pclusters_intcrtpts, allupath, Tp)
@@ -0,0 +1,40 @@
1
+ """Port of tmapper_tools/Cycles2Paths.m from the MATLAB toolbox."""
2
+
3
+ from .cycle_cutter import cycle_cutter
4
+
5
+
6
+ def cycles_to_paths(allcycles, cutpts):
7
+ """Cut a set of cycles to obtain a set of unique paths connecting a
8
+ set of cutting points (nodes).
9
+
10
+ Port of MATLAB's ``Cycles2Paths.m``.
11
+
12
+ Parameters
13
+ ----------
14
+ allcycles : sequence of sequence of int
15
+ A list of cycle paths.
16
+ cutpts : sequence of int
17
+ Node indices at which each cycle should be cut (cutting points).
18
+
19
+ Returns
20
+ -------
21
+ list of list
22
+ Unique paths linking the cutting points, sorted by (length, then
23
+ lexicographically within each length).
24
+ """
25
+ if not allcycles:
26
+ return []
27
+
28
+ allpath = []
29
+ for x in allcycles:
30
+ allpath.extend(cycle_cutter(x, cutpts))
31
+
32
+ pathlen = [len(p) for p in allpath]
33
+ upathlen = sorted(set(pathlen))
34
+
35
+ allupath = []
36
+ for L in upathlen:
37
+ rows = sorted({tuple(p) for p, l in zip(allpath, pathlen) if l == L})
38
+ allupath.extend(list(r) for r in rows)
39
+
40
+ return allupath
tmapper/filtergraph.py ADDED
@@ -0,0 +1,107 @@
1
+ """Port of tmapper_tools/filtergraph.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+ import networkx as nx
5
+
6
+ from ._shortest_path import all_pairs_distance
7
+
8
+
9
+ def filtergraph(g, d, *, reciprocal=True):
10
+ """Contract a graph ``g`` into a simplified graph (a Mapper-style shape
11
+ graph) by merging nodes that are within geodesic distance ``d`` of one
12
+ another.
13
+
14
+ Port of MATLAB's ``filtergraph.m``. This is the Mapper-style contraction
15
+ step: nodes of ``g`` within geodesic distance ``d`` of each other are
16
+ connected in an intermediate graph, and its connected components become
17
+ the nodes of the simplified graph.
18
+
19
+ Parameters
20
+ ----------
21
+ g : networkx.Graph or networkx.DiGraph
22
+ Graph to simplify.
23
+ d : float
24
+ Threshold under which original nodes are collapsed together. Must
25
+ be a positive real number.
26
+ reciprocal : bool, default True
27
+ Whether to require both the path length from x to y *and* from y
28
+ to x to be under ``d`` (True), or either direction (False).
29
+
30
+ Returns
31
+ -------
32
+ g_simp : networkx.DiGraph
33
+ The simplified graph -- the attractor transition network.
34
+ members : list of list
35
+ ``members[n]`` is the list of original node labels (from ``g``)
36
+ belonging to new node n. If ``g``'s nodes are the default 0..N-1
37
+ integers straight from :func:`tknndigraph`, these are *positions*
38
+ into your original data, not real time labels.
39
+ nodesize : numpy.ndarray, shape (n_new,)
40
+ Number of original-graph members in each new node.
41
+ D_simp : numpy.ndarray, shape (n_new, n_new)
42
+ Shortest "distance" (from the original graph) between each pair
43
+ of new nodes' member sets.
44
+ """
45
+ if not isinstance(g, (nx.Graph, nx.DiGraph)):
46
+ raise ValueError("g must be a networkx Graph or DiGraph.")
47
+ if not (np.isscalar(d) and np.isreal(d) and d > 0):
48
+ raise ValueError("d must be a positive real scalar.")
49
+
50
+ nodelist = list(g.nodes())
51
+ Nn = len(nodelist)
52
+ idx_of = {node: i for i, node in enumerate(nodelist)}
53
+
54
+ A = nx.to_numpy_array(g, nodelist=nodelist) # weighted adjacency (weight 1 if unweighted)
55
+ D = all_pairs_distance(g, nodelist) # geodesic distance, inf if unreachable
56
+
57
+ # -- connectivity within a distance threshold
58
+ if reciprocal:
59
+ A_ = (D < d) & (D.T < d)
60
+ else:
61
+ A_ = (D < d) | (D.T < d)
62
+ np.fill_diagonal(A_, False) # remove self-loops
63
+
64
+ # -- create graph out of nodes within said threshold, find connected components
65
+ g_ = nx.from_numpy_array(A_)
66
+ components = list(nx.connected_components(g_))
67
+ # sort components by their smallest member index, for deterministic/stable ordering
68
+ components = sorted(components, key=min)
69
+
70
+ idx_newnodes = np.empty(Nn, dtype=int)
71
+ for new_idx, comp in enumerate(components):
72
+ for i in comp:
73
+ idx_newnodes[i] = new_idx
74
+ n_new = len(components)
75
+
76
+ # -- group original nodes by new-node label via a stable sort, so each
77
+ # group is a contiguous run. This lets the block min/mean below use
78
+ # ufunc.reduceat instead of an O(n_new^2) Python loop over Nn-sized
79
+ # boolean masks (the previous approach was O(n_new^2 * Nn) and became
80
+ # the dominant cost at realistic graph sizes).
81
+ order = np.argsort(idx_newnodes, kind="stable")
82
+ group_start = np.searchsorted(idx_newnodes[order], np.arange(n_new))
83
+ group_bounds = np.append(group_start, Nn)
84
+
85
+ members = [
86
+ [nodelist[i] for i in order[group_bounds[n]:group_bounds[n + 1]]]
87
+ for n in range(n_new)
88
+ ]
89
+ nodesize = np.array([len(m) for m in members])
90
+
91
+ D_sorted = D[np.ix_(order, order)]
92
+ A_sorted = A[np.ix_(order, order)]
93
+
94
+ # -- define distance between new nodes: shortest path between member sets
95
+ D_row = np.minimum.reduceat(D_sorted, group_start, axis=0)
96
+ D_simp = np.minimum.reduceat(D_row, group_start, axis=1)
97
+
98
+ # -- construct simplified graph: A_simp(n,m) = average connectivity between blocks
99
+ A_row_sum = np.add.reduceat(A_sorted, group_start, axis=0)
100
+ A_col_sum = np.add.reduceat(A_row_sum, group_start, axis=1)
101
+ group_sizes = np.diff(group_bounds)
102
+ A_simp = A_col_sum / np.outer(group_sizes, group_sizes)
103
+
104
+ g_simp = nx.from_numpy_array(A_simp, create_using=nx.DiGraph)
105
+ g_simp.remove_edges_from(nx.selfloop_edges(g_simp)) # OmitSelfLoops
106
+
107
+ return g_simp, members, nodesize, D_simp
tmapper/graph_utils.py ADDED
@@ -0,0 +1,283 @@
1
+ """Small graph/data utilities. Ports of tmapper_tools/nodesize.m,
2
+ nodemeasure.m, normgeo.m, normtcm.m, members2tidx.m,
3
+ subgraphFromMembers.m, symDyn2digraph.m, digraph2graph.m, and
4
+ findtaskn.m from the MATLAB toolbox.
5
+
6
+ weightedAdj.m, zerodiag.m, and toVec.m are not ported: they are pure
7
+ MATLAB-object-compatibility shims with no standalone meaning in Python
8
+ (nx.to_numpy_array, np.fill_diagonal, and flat numpy arrays already
9
+ cover that ground natively).
10
+ """
11
+
12
+ import numpy as np
13
+ import networkx as nx
14
+
15
+
16
+ def node_size(nodemembers):
17
+ """Number of members in each node. Port of ``nodesize.m``."""
18
+ return np.array([len(m) for m in nodemembers])
19
+
20
+
21
+ def node_measure(nodemembers):
22
+ """Number of members in each node, normalized to sum to 1. Port of
23
+ ``nodemeasure.m``."""
24
+ n = node_size(nodemembers).astype(float)
25
+ return n / n.sum()
26
+
27
+
28
+ def normalize_geodesic(geod, nsize=None, *, exclude_diag=False):
29
+ """Normalize a geodesic distance matrix by node measure.
30
+
31
+ Port of ``normgeo.m``. Note: unlike the MATLAB original, an omitted
32
+ ``nsize`` defaults to a uniform *vector* of ones (equal-size nodes),
33
+ which is clearly the intended behavior from the docstring -- the
34
+ MATLAB version's ``ones(N_nodes)`` (a matrix, not ``ones(N_nodes,1)``)
35
+ appears to be an unexercised bug in that rarely-hit default-value path.
36
+
37
+ Parameters
38
+ ----------
39
+ geod : array_like, shape (N, N)
40
+ Geodesic distance matrix.
41
+ nsize : array_like, shape (N,), optional
42
+ Size of each node. Defaults to a uniform vector of ones.
43
+ exclude_diag : bool, default False
44
+ Whether to exclude the diagonal when computing the normalizing
45
+ factor.
46
+
47
+ Returns
48
+ -------
49
+ geod_n : numpy.ndarray, shape (N, N)
50
+ Normalized geodesic distance matrix.
51
+ nm : numpy.ndarray, shape (N,)
52
+ Node measure (probability), sums to 1.
53
+ """
54
+ geod = np.array(geod, dtype=float, copy=True)
55
+ n_nodes = geod.shape[0]
56
+
57
+ if nsize is None:
58
+ nsize = np.ones(n_nodes)
59
+ nsize = np.asarray(nsize, dtype=float)
60
+
61
+ # -- handle inf
62
+ if np.any(np.isinf(geod)):
63
+ finite = geod[np.isfinite(geod)]
64
+ geod[np.isinf(geod)] = finite.max() if finite.size else 0.0
65
+
66
+ # -- weight nodes and geodesics
67
+ nm = nsize / nsize.sum()
68
+ geod_n = np.outer(nm, nm) * geod ** 2
69
+
70
+ # -- normalizing factor: the sum of weighted geodesics
71
+ if exclude_diag:
72
+ mask = ~np.eye(n_nodes, dtype=bool)
73
+ normfactor = np.sqrt(geod_n[mask].sum())
74
+ else:
75
+ normfactor = np.sqrt(geod_n.sum())
76
+
77
+ # -- normalize
78
+ if normfactor != 0:
79
+ geod_n = geod / normfactor
80
+
81
+ return geod_n, nm
82
+
83
+
84
+ def normalize_tcm(tcm, *, normtype="max", infreplace="max"):
85
+ """Normalize a temporal connectivity matrix by its maximal finite
86
+ value (or its norm). Port of ``normtcm.m``.
87
+
88
+ Parameters
89
+ ----------
90
+ tcm : array_like
91
+ Temporal connectivity matrix.
92
+ normtype : {'max', 'norm'}, default 'max'
93
+ How to compute the normalizing factor: the matrix's maximum
94
+ finite value, or its (Frobenius/vector) norm.
95
+ infreplace : {'max', 'nan'}, default 'max'
96
+ How to handle inf entries before normalizing: replace with the
97
+ matrix's maximum finite value, or with NaN.
98
+
99
+ Returns
100
+ -------
101
+ numpy.ndarray
102
+ """
103
+ tcm = np.array(tcm, dtype=float, copy=True)
104
+
105
+ if infreplace == "max":
106
+ finite = tcm[np.isfinite(tcm)]
107
+ if finite.size:
108
+ tcm[np.isinf(tcm)] = finite.max()
109
+ elif infreplace == "nan":
110
+ tcm[np.isinf(tcm)] = np.nan
111
+ else:
112
+ raise ValueError(f"Unknown infreplace: {infreplace!r}")
113
+
114
+ if normtype == "max":
115
+ normfactor = np.nanmax(tcm)
116
+ elif normtype == "norm":
117
+ normfactor = np.linalg.norm(tcm.ravel())
118
+ else:
119
+ raise ValueError(f"Unknown normtype: {normtype!r}")
120
+
121
+ if normfactor != 0:
122
+ return tcm / normfactor
123
+ return tcm
124
+
125
+
126
+ def members_to_tidx(members, tidx):
127
+ """Translate positional-index members to real ``tidx`` values.
128
+
129
+ Port of ``members2tidx.m``. Use this when ``tidx`` is non-contiguous
130
+ or offset (e.g. real timestamps with gaps): :func:`filtergraph`'s
131
+ ``members`` output gives positional indices into your original data,
132
+ not ``tidx`` values.
133
+
134
+ Parameters
135
+ ----------
136
+ members : sequence of sequence of int
137
+ Positional indices into the original data, e.g. from
138
+ :func:`filtergraph`.
139
+ tidx : array_like
140
+ The same ``tidx`` array originally passed to :func:`tknndigraph`.
141
+
142
+ Returns
143
+ -------
144
+ list of numpy.ndarray
145
+ """
146
+ tidx = np.asarray(tidx)
147
+ return [tidx[np.asarray(list(m), dtype=int)] for m in members]
148
+
149
+
150
+ def subgraph_from_members(g_simp, members, include_members):
151
+ """Extract the subgraph of a transition network restricted to a
152
+ subset of original time points. Port of ``subgraphFromMembers.m``.
153
+
154
+ Parameters
155
+ ----------
156
+ g_simp : networkx.Graph or networkx.DiGraph
157
+ The simplified transition network.
158
+ members : sequence of sequence of int
159
+ ``members[n]`` gives the original time-point indices belonging
160
+ to node n (in the same node order as ``g_simp.nodes()``).
161
+ include_members : iterable of int
162
+ The subset of original time points to keep.
163
+
164
+ Returns
165
+ -------
166
+ g_sub : networkx.Graph or networkx.DiGraph
167
+ The induced subgraph containing only nodes with at least one
168
+ member in ``include_members``.
169
+ members_sub : list of list
170
+ Members of each node of ``g_sub``, restricted to
171
+ ``include_members``.
172
+ sub_orig_nodeidx : list of int
173
+ Positional indices (into the original ``g_simp.nodes()``) of the
174
+ nodes kept in ``g_sub``.
175
+ """
176
+ include_set = set(include_members)
177
+ members_sub_all = [sorted(set(m) & include_set) for m in members]
178
+ sub_orig_nodeidx = [i for i, m in enumerate(members_sub_all) if len(m) > 0]
179
+
180
+ nodelist = list(g_simp.nodes())
181
+ sub_nodes = [nodelist[i] for i in sub_orig_nodeidx]
182
+ g_sub = g_simp.subgraph(sub_nodes).copy()
183
+ members_sub = [members_sub_all[i] for i in sub_orig_nodeidx]
184
+
185
+ return g_sub, members_sub, sub_orig_nodeidx
186
+
187
+
188
+ def sym_dyn_to_digraph(sym_dyn):
189
+ """Construct a directed graph from a single time series of symbolic
190
+ dynamics. Port of ``symDyn2digraph.m``.
191
+
192
+ Parameters
193
+ ----------
194
+ sym_dyn : array_like of int
195
+ A vector of N labels (purely nominal -- numeric differences
196
+ between labels are irrelevant), one per time point.
197
+
198
+ Returns
199
+ -------
200
+ dg : networkx.DiGraph
201
+ One node per unique state in ``sym_dyn``; an edge wherever a
202
+ transition between two states is observed.
203
+ dwelltime : numpy.ndarray
204
+ Number of time points belonging to each state (in the same
205
+ order as ``dg.nodes()``).
206
+ nodemembers : list of numpy.ndarray
207
+ ``nodemembers[n]`` gives the time-point indices where the
208
+ system was in ``dg``'s n-th state.
209
+ """
210
+ sym_dyn = np.asarray(sym_dyn)
211
+ tidx = np.arange(len(sym_dyn))
212
+
213
+ state_names, state_idx = np.unique(sym_dyn, return_inverse=True)
214
+ dwelltime = np.bincount(state_idx, minlength=len(state_names))
215
+
216
+ dg = nx.DiGraph()
217
+ dg.add_nodes_from(state_names.tolist())
218
+
219
+ if len(sym_dyn) > 1:
220
+ changed = np.diff(sym_dyn) != 0
221
+ tidx_trans = tidx[:-1][changed]
222
+ state_s = sym_dyn[tidx_trans]
223
+ state_t = sym_dyn[tidx_trans + 1]
224
+ trans = np.unique(np.stack([state_s, state_t], axis=1), axis=0) if tidx_trans.size else np.empty((0, 2))
225
+ dg.add_edges_from((s, t) for s, t in trans.tolist())
226
+
227
+ nodemembers = [tidx[state_idx == n] for n in range(len(state_names))]
228
+
229
+ return dg, dwelltime, nodemembers
230
+
231
+
232
+ def digraph_to_graph(dg):
233
+ """Convert a directed graph to an undirected graph, averaging the
234
+ weights of the two edges between each pair of nodes. Port of
235
+ ``digraph2graph.m``.
236
+
237
+ Parameters
238
+ ----------
239
+ dg : networkx.DiGraph
240
+ The directed graph to symmetrize.
241
+
242
+ Returns
243
+ -------
244
+ networkx.Graph
245
+ """
246
+ nodelist = list(dg.nodes())
247
+ A = nx.to_numpy_array(dg, nodelist=nodelist, weight="weight")
248
+ A_sym = (A + A.T) / 2
249
+ g = nx.from_numpy_array(A_sym)
250
+ return nx.relabel_nodes(g, {i: nodelist[i] for i in range(len(nodelist))})
251
+
252
+
253
+ def find_blocks(indicator):
254
+ """Find the start, end, and size of contiguous True/1 runs
255
+ ("blocks") in a 0/1 indicator array. Port of ``findtaskn.m``.
256
+
257
+ Parameters
258
+ ----------
259
+ indicator : array_like
260
+ A 1D array of 0s/1s (or booleans).
261
+
262
+ Returns
263
+ -------
264
+ block_start : numpy.ndarray
265
+ 0-indexed position of the first sample of each block.
266
+ block_end : numpy.ndarray
267
+ 0-indexed position of the last sample of each block.
268
+ block_size : numpy.ndarray
269
+ Number of samples in each block.
270
+ """
271
+ ind = np.asarray(indicator).astype(int).ravel()
272
+
273
+ padded_start = np.concatenate([[0], ind])
274
+ block_start = np.flatnonzero(np.diff(padded_start) == 1)
275
+
276
+ padded_end = np.concatenate([ind, [0]])
277
+ block_end = np.flatnonzero(np.diff(padded_end) == -1)
278
+
279
+ if len(block_start) != len(block_end):
280
+ raise ValueError("Block structure incomplete.")
281
+
282
+ block_size = block_end - block_start + 1
283
+ return block_start, block_end, block_size
tmapper/knngraph.py ADDED
@@ -0,0 +1,61 @@
1
+ """Port of tmapper_tools/knngraph.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+ import networkx as nx
5
+ from scipy.spatial.distance import cdist
6
+
7
+
8
+ def knngraph(X_or_D, k, *, reciprocal=True):
9
+ """Construct an undirected graph based on k-nearest neighbors: each
10
+ point is a node, linked to its k nearest neighbors.
11
+
12
+ Port of MATLAB's ``knngraph.m``. A standalone graph builder with no
13
+ temporal component (unlike :func:`tknndigraph`).
14
+
15
+ Parameters
16
+ ----------
17
+ X_or_D : array_like, shape (N, d) or (N, N)
18
+ Either raw coordinates (N points in d-dim space) or a precomputed
19
+ (N, N) distance matrix. Auto-detected: treated as a distance
20
+ matrix only if square *and* symmetric.
21
+ k : int
22
+ Number of nearest neighbors. Must be a positive integer strictly
23
+ less than N.
24
+ reciprocal : bool, default True
25
+ Whether to require a k-NN link to be mutual (both directions) to
26
+ be kept.
27
+
28
+ Returns
29
+ -------
30
+ networkx.Graph
31
+ Unweighted undirected graph, one node per input point.
32
+ """
33
+ X_or_D = np.asarray(X_or_D, dtype=float)
34
+ if X_or_D.ndim != 2:
35
+ raise ValueError("X_or_D must be a 2D array.")
36
+ if not (np.isscalar(k) and float(k) == round(float(k)) and k >= 1):
37
+ raise ValueError("k must be a positive integer scalar.")
38
+ k = int(k)
39
+
40
+ nr, nc = X_or_D.shape
41
+ if nr != nc or not np.allclose(X_or_D, X_or_D.T):
42
+ D = cdist(X_or_D, X_or_D)
43
+ else:
44
+ D = X_or_D.copy()
45
+ Nn = D.shape[0]
46
+
47
+ if k >= Nn:
48
+ raise ValueError(f"k must be smaller than the number of points ({Nn}).")
49
+
50
+ order = np.argsort(D, axis=1, kind="stable")
51
+ A = np.zeros((Nn, Nn), dtype=bool)
52
+ rows = np.repeat(np.arange(Nn), k)
53
+ cols = order[:, 1:1 + k].ravel() # skip column 0: self (distance 0)
54
+ A[rows, cols] = True
55
+
56
+ if reciprocal:
57
+ A = A & A.T
58
+ else:
59
+ A = A | A.T
60
+
61
+ return nx.from_numpy_array(A)
tmapper/labeling.py ADDED
@@ -0,0 +1,45 @@
1
+ """Port of tmapper_tools/findnodelabel.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+ from scipy import stats
5
+
6
+
7
+ def find_node_label(members, x_label, *, labelmethod="mode"):
8
+ """Aggregate a per-time-point label to a per-node label.
9
+
10
+ Port of MATLAB's ``findnodelabel.m``.
11
+
12
+ Parameters
13
+ ----------
14
+ members : sequence of sequence of int
15
+ ``members[n]`` gives the positional indices belonging to node n.
16
+ x_label : array_like
17
+ Label/value for each time point (indexed the same way as the
18
+ indices inside ``members``).
19
+ labelmethod : {'mode', 'mean', 'median', 'none'} or callable, default 'mode'
20
+ How to aggregate each node's members' x_label values. A callable
21
+ is applied as ``labelmethod(x_label[members[n]])`` per node and
22
+ must return a scalar. 'none' gives every node the same label (0).
23
+
24
+ Returns
25
+ -------
26
+ numpy.ndarray, shape (len(members),)
27
+ The aggregated label for each node.
28
+ """
29
+ x_label = np.asarray(x_label)
30
+
31
+ if callable(labelmethod):
32
+ return np.array([labelmethod(x_label[list(m)]) for m in members])
33
+
34
+ if labelmethod == "mode":
35
+ # scipy.stats.mode returns the smallest value among ties, matching
36
+ # MATLAB's mode().
37
+ return np.array([stats.mode(x_label[list(m)], keepdims=False).mode for m in members])
38
+ elif labelmethod == "mean":
39
+ return np.array([np.mean(x_label[list(m)]) for m in members])
40
+ elif labelmethod == "median":
41
+ return np.array([np.median(x_label[list(m)]) for m in members])
42
+ elif labelmethod == "none":
43
+ return np.zeros(len(members))
44
+ else:
45
+ raise ValueError(f"Unknown labelmethod: {labelmethod!r}")