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.
tmapper/cknngraph.py ADDED
@@ -0,0 +1,73 @@
1
+ """Port of tmapper_tools/cknngraph.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 cknngraph(X_or_D, k, delta, *, average=False):
9
+ """Construct a graph based on continuous k-nearest neighbors (Berry &
10
+ Sauer, 2019).
11
+
12
+ Port of MATLAB's ``cknngraph.m``. Connects points x, y iff::
13
+
14
+ D(x, y) < delta * sqrt(D(x, x_k) * D(y, y_k))
15
+
16
+ where ``D(~, ~)`` is the distance and ``~_k`` is the k-th nearest
17
+ neighbor of ``~``.
18
+
19
+ Parameters
20
+ ----------
21
+ X_or_D : array_like, shape (N, d) or (N, N)
22
+ Either raw coordinates (N points in d-dim space) or a precomputed
23
+ (N, N) distance matrix. Auto-detected: treated as a distance
24
+ matrix only if square *and* symmetric.
25
+ k : int
26
+ Used to normalize distance by local point density. Must be a
27
+ positive integer strictly less than N.
28
+ delta : float
29
+ Threshold for linking two nodes (must be a positive real
30
+ number).
31
+ average : bool, default False
32
+ Whether ``x_k`` is the average distance over the first k nearest
33
+ neighbors (True) or just the k-th nearest neighbor (False).
34
+
35
+ Returns
36
+ -------
37
+ networkx.Graph
38
+ Unweighted undirected graph, one node per input point.
39
+ """
40
+ X_or_D = np.asarray(X_or_D, dtype=float)
41
+ if X_or_D.ndim != 2:
42
+ raise ValueError("X_or_D must be a 2D array.")
43
+ if not (np.isscalar(k) and float(k) == round(float(k)) and k >= 1):
44
+ raise ValueError("k must be a positive integer scalar.")
45
+ k = int(k)
46
+ if not (np.isscalar(delta) and np.isreal(delta) and delta > 0):
47
+ raise ValueError("delta must be a positive real scalar.")
48
+
49
+ nr, nc = X_or_D.shape
50
+ if nr != nc or not np.allclose(X_or_D, X_or_D.T):
51
+ D = cdist(X_or_D, X_or_D)
52
+ else:
53
+ D = X_or_D.copy()
54
+ Nn = D.shape[0]
55
+
56
+ if k >= Nn:
57
+ raise ValueError(f"k must be smaller than the number of points ({Nn}).")
58
+
59
+ # -- find nearest neighbors and compute distance
60
+ D_sorted = np.sort(D, axis=1)
61
+ if average:
62
+ Dk = D_sorted[:, 1:1 + k].mean(axis=1) # col 0 is distance to self
63
+ else:
64
+ Dk = D_sorted[:, k]
65
+
66
+ # -- normalize D by local density
67
+ D_norm = D / np.sqrt(np.outer(Dk, Dk))
68
+
69
+ # -- construct graph from adjacency matrix
70
+ A = D_norm < delta
71
+ np.fill_diagonal(A, False)
72
+
73
+ return nx.from_numpy_array(A)
@@ -0,0 +1,111 @@
1
+ """Port of tmapper_tools/CycleCluster.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+ from scipy.cluster.hierarchy import linkage, fcluster
5
+ from scipy.spatial.distance import squareform
6
+
7
+
8
+ def _classical_mds(dist, n_components=2):
9
+ """Classical (Torgerson) multidimensional scaling, matching MATLAB's
10
+ ``cmdscale``. ``dist`` is a square distance matrix."""
11
+ dist = np.asarray(dist, dtype=float)
12
+ n = dist.shape[0]
13
+ J = np.eye(n) - np.ones((n, n)) / n
14
+ B = -0.5 * J @ (dist ** 2) @ J
15
+ eigvals, eigvecs = np.linalg.eigh(B)
16
+ order = np.argsort(eigvals)[::-1]
17
+ eigvals, eigvecs = eigvals[order], eigvecs[:, order]
18
+ eigvals = np.clip(eigvals[:n_components], 0, None)
19
+ return eigvecs[:, :n_components] * np.sqrt(eigvals)
20
+
21
+
22
+ def cycle_cluster(allcycles, thres, *, plotmat=True, plotmds=False, plothist=True,
23
+ reordermat=True, ax=None, return_ax=False):
24
+ """Cluster cycles based on the fraction of overlap between them
25
+ (shared nodes / union of nodes).
26
+
27
+ Port of MATLAB's ``CycleCluster.m``.
28
+
29
+ Parameters
30
+ ----------
31
+ allcycles : sequence of sequence of int
32
+ ``allcycles[n]`` is the path of one cycle.
33
+ thres : float
34
+ Cut-off threshold (0-1) for single-linkage clustering: if
35
+ overlap > thres, two cycles belong to the same cluster.
36
+ plotmat : bool, default True
37
+ Whether to plot the overlap matrix.
38
+ plotmds : bool, default False
39
+ Whether to plot a 2D classical-MDS projection of the cycles.
40
+ plothist : bool, default True
41
+ Whether to plot the histogram of linkage distances.
42
+ reordermat : bool, default True
43
+ Whether to reorder the overlap matrix by cluster assignment.
44
+ ax : matplotlib.axes.Axes, optional
45
+ Axes for the overlap-matrix plot (only used if ``plotmat``).
46
+ A new figure is created if not given.
47
+ return_ax : bool, default False
48
+ If True, also return the axes used for the overlap-matrix plot
49
+ (None if ``plotmat`` is False), so a caller can add further
50
+ annotations to it (e.g. :func:`cycle_path_decomp`'s cluster-block
51
+ outlines).
52
+
53
+ Returns
54
+ -------
55
+ numpy.ndarray
56
+ 1-indexed cluster label for each cycle (matching MATLAB's
57
+ ``cluster()`` convention).
58
+ ax : matplotlib.axes.Axes or None
59
+ Only returned if ``return_ax`` is True.
60
+ """
61
+ Nc = len(allcycles)
62
+
63
+ if Nc <= 1:
64
+ cluster_idx = np.ones(Nc, dtype=int)
65
+ return (cluster_idx, None) if return_ax else cluster_idx
66
+
67
+ prct_overlap = np.zeros((Nc, Nc))
68
+ sets = [set(c) for c in allcycles]
69
+ for ii in range(Nc):
70
+ for jj in range(Nc):
71
+ prct_overlap[ii, jj] = len(sets[ii] & sets[jj]) / len(sets[ii] | sets[jj])
72
+
73
+ if plotmds:
74
+ import matplotlib.pyplot as plt
75
+ Y = _classical_mds(1 - prct_overlap, 2)
76
+ plt.figure()
77
+ plt.scatter(Y[:, 0], Y[:, 1])
78
+
79
+ Z = linkage(squareform(1 - prct_overlap, checks=False), method="complete")
80
+
81
+ if plothist:
82
+ import matplotlib.pyplot as plt
83
+ plt.figure()
84
+ plt.hist(1 - Z[:, 2], bins=np.linspace(0, 1, 21))
85
+ ylim = plt.ylim()
86
+ plt.plot([thres, thres], ylim)
87
+ plt.xlabel("overlap")
88
+ plt.ylabel("count")
89
+
90
+ cluster_idx = fcluster(Z, t=1 - thres, criterion="distance")
91
+
92
+ if plotmat:
93
+ import matplotlib.pyplot as plt
94
+ if ax is None:
95
+ _, ax = plt.subplots()
96
+ if reordermat:
97
+ order = np.argsort(cluster_idx, kind="stable")
98
+ im = ax.imshow(prct_overlap[np.ix_(order, order)], origin="lower", vmin=0, vmax=1)
99
+ ax.set_xlabel("cycle/path index (reordered)")
100
+ ax.set_ylabel("cycle/path index (reordered)")
101
+ else:
102
+ im = ax.imshow(prct_overlap, origin="lower", vmin=0, vmax=1)
103
+ ax.set_xlabel("cycle/path index")
104
+ ax.set_ylabel("cycle/path index")
105
+ cbar = plt.colorbar(im, ax=ax)
106
+ cbar.set_label("fraction of overlap")
107
+ ax.set_aspect("equal")
108
+ else:
109
+ ax = None
110
+
111
+ return (cluster_idx, ax) if return_ax else cluster_idx
@@ -0,0 +1,112 @@
1
+ """Port of tmapper_tools/CycleClusterConn.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def cycle_cluster_conn(dg, allcycles, cluster_idx):
7
+ """Connectivity between M clusters of N cycles.
8
+
9
+ Port of MATLAB's ``CycleClusterConn.m``.
10
+
11
+ Parameters
12
+ ----------
13
+ dg : networkx.DiGraph
14
+ The directed graph the cycles/paths live on.
15
+ allcycles : sequence of sequence of int
16
+ ``allcycles[n]`` is the path of one cycle.
17
+ cluster_idx : array_like of int
18
+ Cluster label of each cycle (e.g. from :func:`cycle_cluster`).
19
+
20
+ Returns
21
+ -------
22
+ cluster_conn : list of list of set
23
+ ``cluster_conn[i][j]`` (== ``cluster_conn[j][i]``) contains
24
+ nodes on the boundary between loop-clusters i and j.
25
+ cluster_conn_dir : list of list of set
26
+ ``cluster_conn_dir[i][j]`` contains nodes in the boundary of
27
+ cluster j that receive a link from at least one node of cluster
28
+ i (excluding shared nodes).
29
+ clusters_nodes : list of list
30
+ All nodes in each cluster (first-seen order).
31
+ clusters_boundary : list of set
32
+ Boundary nodes of each cluster.
33
+ clusters_interior : list of list
34
+ Nodes of each cluster that are not on its boundary.
35
+ clusters_crtpts : list of list
36
+ Critical points (nodes with more than one source or target) in
37
+ each cluster.
38
+ clusters_intcrtpts : list of list
39
+ Critical points in each cluster that are not on its boundary.
40
+ """
41
+ cluster_idx = np.asarray(cluster_idx)
42
+ clusters = np.unique(cluster_idx)
43
+ n_clusters = len(clusters)
44
+
45
+ out_degree = dict(dg.out_degree())
46
+ in_degree = dict(dg.in_degree())
47
+ crtpts = {n for n in dg.nodes() if out_degree[n] > 1 or in_degree[n] > 1}
48
+
49
+ clusters_nodes = [None] * n_clusters
50
+ clusters_crtpts = [None] * n_clusters
51
+ clusters_intcrtpts = [None] * n_clusters
52
+ clusters_inneighbors = [None] * n_clusters
53
+ clusters_outneighbors = [None] * n_clusters
54
+ clusters_boundary = [None] * n_clusters
55
+ clusters_interior = [None] * n_clusters
56
+
57
+ for ii, c in enumerate(clusters):
58
+ cycles_ii = [allcycles[k] for k in range(len(allcycles)) if cluster_idx[k] == c]
59
+
60
+ seen, seen_set = [], set()
61
+ for cyc in cycles_ii:
62
+ for node in cyc:
63
+ if node not in seen_set:
64
+ seen_set.add(node)
65
+ seen.append(node)
66
+ clusters_nodes[ii] = seen
67
+
68
+ crtpts_ii = [n for n in seen if n in crtpts]
69
+ clusters_crtpts[ii] = crtpts_ii
70
+
71
+ innbg, outnbg = set(), set()
72
+ for cp in crtpts_ii:
73
+ innbg |= set(dg.predecessors(cp))
74
+ outnbg |= set(dg.successors(cp))
75
+ innbg -= seen_set
76
+ outnbg -= seen_set
77
+
78
+ bd = set()
79
+ for n in innbg:
80
+ bd |= set(dg.successors(n))
81
+ for n in outnbg:
82
+ bd |= set(dg.predecessors(n))
83
+ bd &= seen_set
84
+
85
+ clusters_inneighbors[ii] = innbg
86
+ clusters_outneighbors[ii] = outnbg
87
+ clusters_boundary[ii] = bd
88
+ clusters_interior[ii] = [n for n in seen if n not in bd]
89
+ clusters_intcrtpts[ii] = [n for n in crtpts_ii if n not in bd]
90
+
91
+ cluster_conn = [[None] * n_clusters for _ in range(n_clusters)]
92
+ cluster_conn_dir = [[None] * n_clusters for _ in range(n_clusters)]
93
+ cluster_conn_in = [[None] * n_clusters for _ in range(n_clusters)]
94
+ cluster_conn_out = [[None] * n_clusters for _ in range(n_clusters)]
95
+
96
+ for ii in range(n_clusters):
97
+ for jj in range(n_clusters):
98
+ cluster_conn[ii][jj] = clusters_boundary[ii] & set(clusters_nodes[jj])
99
+ cluster_conn_in[ii][jj] = clusters_inneighbors[jj] & set(clusters_nodes[ii])
100
+ cluster_conn_out[ii][jj] = clusters_outneighbors[ii] & set(clusters_nodes[jj])
101
+
102
+ for ii in range(n_clusters):
103
+ for jj in range(n_clusters):
104
+ bd = set()
105
+ for nn in cluster_conn[ii][jj]:
106
+ preds = set(dg.predecessors(nn))
107
+ if preds & cluster_conn_in[ii][jj]:
108
+ bd.add(nn)
109
+ cluster_conn_dir[ii][jj] = bd & cluster_conn[ii][jj]
110
+
111
+ return (cluster_conn, cluster_conn_dir, clusters_nodes, clusters_boundary,
112
+ clusters_interior, clusters_crtpts, clusters_intcrtpts)
tmapper/cycle_count.py ADDED
@@ -0,0 +1,150 @@
1
+ """Port of tmapper_tools/CycleCount.m from the MATLAB toolbox.
2
+
3
+ CycleCount.m is itself a third-party algorithm (not original to the
4
+ tmapper toolbox), reproduced here under its original license:
5
+
6
+ Authors: P.-L. Giscard, N. Kriege, R. Wilson, Septembre 2016
7
+
8
+ Copyright (c) 2017, Pierre-Louis Giscard
9
+ All rights reserved.
10
+
11
+ Redistribution and use in source and binary forms, with or without
12
+ modification, are permitted provided that the following conditions
13
+ are met:
14
+
15
+ * Redistributions of source code must retain the above
16
+ copyright notice, this list of conditions and the following
17
+ disclaimer.
18
+ * Redistributions in binary form must reproduce the above
19
+ copyright notice, this list of conditions and the following
20
+ disclaimer in the documentation and/or other materials
21
+ provided with the distribution.
22
+ * Neither the name of the University of York nor the names of
23
+ its contributors may be used to endorse or promote products
24
+ derived from this software without specific prior written
25
+ permission.
26
+
27
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
28
+ "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
29
+ LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
30
+ FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
31
+ COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
32
+ INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
33
+ BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
34
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
35
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
36
+ LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
37
+ ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
38
+ POSSIBILITY OF SUCH DAMAGE.
39
+ """
40
+
41
+ import numpy as np
42
+
43
+
44
+ def cycle_count(A, L0):
45
+ """Count all simple cycles of length up to ``L0`` (inclusive) on a
46
+ graph whose adjacency matrix is ``A``, via the combinatorial-sieve
47
+ algorithm of Giscard, Kriege & Wilson (2016).
48
+
49
+ Parameters
50
+ ----------
51
+ A : array_like, shape (N, N)
52
+ Adjacency matrix of the graph (directed or undirected, weighted
53
+ or unweighted).
54
+ L0 : int
55
+ Maximum length of the simple cycles to count.
56
+
57
+ Returns
58
+ -------
59
+ numpy.ndarray, shape (L0,)
60
+ ``primes[i]`` is the number of simple cycles of length ``i + 1``,
61
+ for ``i`` in ``0..L0-1``.
62
+ """
63
+ A = np.asarray(A, dtype=float).copy()
64
+ L0 = int(L0)
65
+
66
+ primes = np.zeros(L0, dtype=complex)
67
+ primes[0] += np.trace(A) # self-loops (length-1 cycles)
68
+ np.fill_diagonal(A, 0)
69
+ A = _clean_matrix(A)
70
+
71
+ directed = not np.array_equal(A, A.T)
72
+ if directed:
73
+ A_undir = ((A != 0) | (A.T != 0)).astype(float)
74
+ else:
75
+ A_undir = (A != 0).astype(float)
76
+
77
+ size = A.shape[0]
78
+ if L0 > size:
79
+ L0 = size
80
+
81
+ allowed_vert = np.ones(size, dtype=bool)
82
+ for i in range(size - 1):
83
+ allowed_vert[i] = False
84
+ neighbourhood = np.zeros(size)
85
+ neighbourhood[i] = 1
86
+ neighbourhood = neighbourhood + A_undir[i, :]
87
+ primes = _recursive_subgraphs(A, A_undir, L0, [i], allowed_vert, primes, neighbourhood)
88
+
89
+ return primes.real
90
+
91
+
92
+ def _recursive_subgraphs(A, A_undir, L0, subgraph, allowed_vert, primes, neighbourhood):
93
+ """Find all connected induced subgraphs of size up to L0 containing
94
+ ``subgraph``, accumulating their contribution into ``primes``."""
95
+ allowed_vert = allowed_vert.copy() # this frame's mutations stay local
96
+
97
+ L = len(subgraph)
98
+ neighbours_number = int(np.count_nonzero(neighbourhood)) - L
99
+ primes = _prime_count(A, L0, subgraph, neighbours_number, primes)
100
+
101
+ if L == L0:
102
+ return primes
103
+
104
+ neighbours = np.flatnonzero(neighbourhood.astype(bool) & allowed_vert)
105
+ for v in neighbours:
106
+ new_subgraph = subgraph + [int(v)]
107
+ allowed_vert[v] = False # persists across the rest of this loop
108
+ new_neighbourhood = neighbourhood + A_undir[v, :]
109
+ primes = _recursive_subgraphs(A, A_undir, L0, new_subgraph, allowed_vert, primes, new_neighbourhood)
110
+
111
+ return primes
112
+
113
+
114
+ def _prime_count(A, L0, subgraph, neighbours_number, primes):
115
+ """Combinatorial-sieve contribution of one connected induced subgraph."""
116
+ subgraph_size = len(subgraph)
117
+ idx = np.ix_(subgraph, subgraph)
118
+ x = A[idx]
119
+
120
+ xeig = np.linalg.eigvals(x)
121
+ xS = xeig.astype(complex) ** subgraph_size
122
+ mk = min(L0, neighbours_number + subgraph_size)
123
+ binomial_coeff = 1.0
124
+
125
+ for k in range(subgraph_size, mk):
126
+ primes[k - 1] += ((-1) ** k / k) * binomial_coeff * ((-1) ** subgraph_size) * xS.sum()
127
+ xS = xS * xeig
128
+ binomial_coeff = binomial_coeff * (subgraph_size - k + neighbours_number) / (1 - subgraph_size + k)
129
+
130
+ primes[mk - 1] += ((-1) ** mk / mk) * binomial_coeff * ((-1) ** subgraph_size) * xS.sum()
131
+ return primes
132
+
133
+
134
+ def _matrix_cleaning(A):
135
+ """Remove sources (no incoming edge) then sinks (no outgoing edge)."""
136
+ no_incoming = ~A.any(axis=0)
137
+ A = A[~no_incoming][:, ~no_incoming]
138
+ no_outgoing = ~A.any(axis=1)
139
+ A = A[~no_outgoing][:, ~no_outgoing]
140
+ return A
141
+
142
+
143
+ def _clean_matrix(A):
144
+ """Recursively remove isolated vertices, sinks, and sources until
145
+ the matrix is invariant under such removal -- i.e. every remaining
146
+ vertex sustains at least one cycle."""
147
+ cleaned = A
148
+ while cleaned.shape[0] != _matrix_cleaning(cleaned).shape[0]:
149
+ cleaned = _matrix_cleaning(cleaned)
150
+ return cleaned
@@ -0,0 +1,72 @@
1
+ """Port of tmapper_tools/CycleCount2p.m and reorgCycles.m from the
2
+ MATLAB toolbox."""
3
+
4
+ import numpy as np
5
+ import networkx as nx
6
+
7
+
8
+ def cycle_count2p(A, *, simple=True):
9
+ """Estimate the number of cycles of different lengths by finding the
10
+ smallest cycle passing through every pair of vertices.
11
+
12
+ Port of MATLAB's ``CycleCount2p.m``. Only counts unique cycles, and
13
+ by default only unique simple cycles (no repeated nodes other than
14
+ the start/end).
15
+
16
+ Parameters
17
+ ----------
18
+ A : array_like, shape (N, N)
19
+ Adjacency matrix for a simple directed graph.
20
+ simple : bool, default True
21
+ Whether to only count simple cycles.
22
+
23
+ Returns
24
+ -------
25
+ cyc_count : numpy.ndarray
26
+ Number of cycles per unique length (shortest to longest).
27
+ cyc_len : numpy.ndarray
28
+ The unique cycle lengths themselves (shortest to longest).
29
+ cyc_path : list of numpy.ndarray
30
+ ``cyc_path[m]`` is an (Nm, Lm) array of the Nm cycles of length
31
+ ``cyc_len[m]``.
32
+ all_cycles : list of list
33
+ Every cycle's path, flattened into a single list.
34
+ """
35
+ g = nx.from_numpy_array(np.asarray(A), create_using=nx.DiGraph)
36
+ n = g.number_of_nodes()
37
+
38
+ all_closed = []
39
+ for s in range(n):
40
+ for t in range(s + 1, n):
41
+ if not (nx.has_path(g, s, t) and nx.has_path(g, t, s)):
42
+ continue
43
+ spf = nx.shortest_path(g, s, t) # forward path
44
+ spb = nx.shortest_path(g, t, s) # backward path
45
+ cycle = spf + spb[1:-1]
46
+ if not simple or len(set(cycle)) == len(cycle):
47
+ root_idx = cycle.index(min(cycle))
48
+ all_closed.append(cycle[root_idx:] + cycle[:root_idx]) # start at smallest node
49
+
50
+ cyc_length = np.array([len(c) for c in all_closed], dtype=int)
51
+ cyc_len = np.unique(cyc_length) if cyc_length.size else np.array([], dtype=int)
52
+
53
+ cyc_path = []
54
+ cyc_count = []
55
+ for L in cyc_len:
56
+ rows = sorted({tuple(c) for c, l in zip(all_closed, cyc_length) if l == L})
57
+ cyc_path.append(np.array(rows, dtype=int))
58
+ cyc_count.append(len(rows))
59
+ cyc_count = np.array(cyc_count, dtype=int)
60
+
61
+ all_cycles = [list(row) for block in cyc_path for row in block]
62
+
63
+ return cyc_count, cyc_len, cyc_path, all_cycles
64
+
65
+
66
+ def reorg_cycles(cyc_path):
67
+ """Unpack the ``cyc_path`` output of :func:`cycle_count2p` into a flat
68
+ list where each element is a single cycle's path.
69
+
70
+ Port of MATLAB's ``reorgCycles.m``.
71
+ """
72
+ return [list(row) for block in cyc_path for row in np.asarray(block)]
@@ -0,0 +1,47 @@
1
+ """Port of tmapper_tools/CycleCutter.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def cycle_cutter(cyc, node_name):
7
+ """Cut a cycle into multiple paths at given nodes.
8
+
9
+ Port of MATLAB's ``CycleCutter.m``.
10
+
11
+ Parameters
12
+ ----------
13
+ cyc : sequence
14
+ A single cycle's path (node names, usually integers).
15
+ node_name : sequence or scalar
16
+ Names of nodes that are the cutting points of the cycle. Names
17
+ not contained in the cycle are ignored.
18
+
19
+ Returns
20
+ -------
21
+ list of list
22
+ Each element is a path from one cutting point to the next.
23
+ Wraps around: if there are ``M`` cutting points, this list has
24
+ ``M`` elements, and the last one wraps from the last cutting
25
+ point, through the end of ``cyc``, back through the start, up to
26
+ (and including) the first cutting point.
27
+ """
28
+ cyc = list(cyc)
29
+
30
+ if np.isscalar(node_name):
31
+ node_set = {node_name}
32
+ else:
33
+ node_set = set(node_name)
34
+
35
+ node_idx = [i for i, v in enumerate(cyc) if v in node_set]
36
+ if not node_idx:
37
+ return [cyc]
38
+
39
+ n_path = len(node_idx)
40
+ nodepath = [None] * n_path
41
+
42
+ for n in range(n_path - 1):
43
+ nodepath[n] = cyc[node_idx[n]: node_idx[n + 1] + 1]
44
+
45
+ nodepath[n_path - 1] = cyc[node_idx[-1]:] + cyc[:node_idx[0] + 1]
46
+
47
+ return nodepath
@@ -0,0 +1,71 @@
1
+ """Port of tmapper_tools/CyclePathOverlap.m from the MATLAB toolbox."""
2
+
3
+ import numpy as np
4
+
5
+
6
+ def cycle_path_overlap(c, *, cycle=True, overlap_type="edge", grpvar=None):
7
+ """Calculate the overlap between cycles or paths.
8
+
9
+ Port of MATLAB's ``CyclePathOverlap.m``.
10
+
11
+ Parameters
12
+ ----------
13
+ c : sequence of sequence of int
14
+ ``c[n]`` is a path of node indices.
15
+ cycle : bool, default True
16
+ Whether the paths in ``c`` are cycles (adds a wraparound edge
17
+ from the last node back to the first when ``overlap_type``
18
+ is 'edge').
19
+ overlap_type : {'edge', 'node'}, default 'edge'
20
+ Whether to calculate overlap between edges or between nodes.
21
+ grpvar : array_like of int, optional
22
+ A grouping variable for the paths. By default, each path is its
23
+ own group.
24
+
25
+ Returns
26
+ -------
27
+ CO : numpy.ndarray, shape (P, P)
28
+ Percent overlap between every pair of groups (1 on the
29
+ diagonal).
30
+ grpnames : numpy.ndarray
31
+ The unique group labels, in the same order as ``CO``'s rows.
32
+ """
33
+ if overlap_type == "edge":
34
+ def as_items(x):
35
+ x = list(x)
36
+ edges = list(zip(x, x[1:] + [x[0]]))
37
+ if not cycle:
38
+ edges = edges[:-1] # drop the wraparound edge
39
+ return edges
40
+ elif overlap_type == "node":
41
+ def as_items(x):
42
+ return list(x)
43
+ else:
44
+ raise ValueError(f"Unknown type: {overlap_type!r}")
45
+
46
+ c_repr = [as_items(x) for x in c]
47
+
48
+ Nc = len(c_repr)
49
+ if grpvar is None:
50
+ grpvar = np.arange(Nc)
51
+ grpvar = np.asarray(grpvar)
52
+ grpnames = np.unique(grpvar)
53
+ Ngrp = len(grpnames)
54
+
55
+ groups = []
56
+ for name in grpnames:
57
+ items = set()
58
+ for idx in np.flatnonzero(grpvar == name):
59
+ items |= set(c_repr[idx])
60
+ groups.append(items)
61
+
62
+ CO = np.full((Ngrp, Ngrp), np.nan)
63
+ for ii in range(Ngrp):
64
+ for jj in range(ii):
65
+ inter = len(groups[ii] & groups[jj])
66
+ union = len(groups[ii] | groups[jj])
67
+ CO[ii, jj] = inter / union
68
+ CO[jj, ii] = CO[ii, jj]
69
+ np.fill_diagonal(CO, 1)
70
+
71
+ return CO, grpnames