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
|
@@ -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}")
|