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/__init__.py +63 -0
- tmapper/_shortest_path.py +22 -0
- tmapper/app/__init__.py +10 -0
- tmapper/app/launcher.py +35 -0
- tmapper/app/streamlit_app.py +1240 -0
- tmapper/cknngraph.py +73 -0
- tmapper/cycle_cluster.py +111 -0
- tmapper/cycle_cluster_conn.py +112 -0
- tmapper/cycle_count.py +150 -0
- tmapper/cycle_count2p.py +72 -0
- tmapper/cycle_cutter.py +47 -0
- tmapper/cycle_overlap.py +71 -0
- tmapper/cycle_path_decomp.py +104 -0
- tmapper/cycles_to_paths.py +40 -0
- tmapper/filtergraph.py +107 -0
- tmapper/graph_utils.py +283 -0
- tmapper/knngraph.py +61 -0
- tmapper/labeling.py +45 -0
- tmapper/modularity.py +74 -0
- tmapper/path_traffic.py +35 -0
- tmapper/plotting.py +463 -0
- tmapper/sample_data.py +19 -0
- tmapper/sampledata/EL_temp.csv +57710 -0
- tmapper/tcm_distance.py +63 -0
- tmapper/tknndigraph.py +190 -0
- tmapper_py-0.1.0.dist-info/METADATA +108 -0
- tmapper_py-0.1.0.dist-info/RECORD +31 -0
- tmapper_py-0.1.0.dist-info/WHEEL +5 -0
- tmapper_py-0.1.0.dist-info/entry_points.txt +2 -0
- tmapper_py-0.1.0.dist-info/licenses/LICENSE +28 -0
- tmapper_py-0.1.0.dist-info/top_level.txt +1 -0
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)
|
tmapper/cycle_cluster.py
ADDED
|
@@ -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
|
tmapper/cycle_count2p.py
ADDED
|
@@ -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)]
|
tmapper/cycle_cutter.py
ADDED
|
@@ -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
|
tmapper/cycle_overlap.py
ADDED
|
@@ -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
|