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/modularity.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Port of tmapper_tools/Qasym.m and calMod.m from the MATLAB toolbox."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def qasym(A, C):
|
|
7
|
+
"""Modularity measure of an asymmetric, weighted network.
|
|
8
|
+
|
|
9
|
+
Port of MATLAB's ``Qasym.m``. Adapted from the canonical modularity
|
|
10
|
+
definition (Fortunato 2010, Physics Reports) for asymmetric networks
|
|
11
|
+
with weighted edges.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
A : array_like, shape (N, N)
|
|
16
|
+
Adjacency matrix, which may or may not be symmetric.
|
|
17
|
+
C : array_like, shape (N,)
|
|
18
|
+
Community assignment of each node.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
float
|
|
23
|
+
Modularity Q. Returns 0 for a zero-edge network (neither modular
|
|
24
|
+
nor non-modular).
|
|
25
|
+
"""
|
|
26
|
+
A = np.asarray(A, dtype=float)
|
|
27
|
+
N_edges = A.sum() # "2m" in other notations
|
|
28
|
+
|
|
29
|
+
if N_edges == 0:
|
|
30
|
+
return 0.0
|
|
31
|
+
|
|
32
|
+
k_source = A.sum(axis=1, keepdims=True) # source degree
|
|
33
|
+
k_sink = A.sum(axis=0, keepdims=True) # sink degree
|
|
34
|
+
P_ij = k_source @ k_sink / N_edges # null model
|
|
35
|
+
|
|
36
|
+
C = np.asarray(C).ravel()
|
|
37
|
+
same_community = C[:, None] == C[None, :]
|
|
38
|
+
|
|
39
|
+
return float(np.sum((A - P_ij) * same_community) / N_edges)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cal_mod(W, m0):
|
|
43
|
+
"""Modularity score of a (typically symmetric) network given a
|
|
44
|
+
community assignment.
|
|
45
|
+
|
|
46
|
+
Port of MATLAB's ``calMod.m``.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
W : array_like, shape (N, N)
|
|
51
|
+
Graph adjacency matrix.
|
|
52
|
+
m0 : array_like, shape (N,)
|
|
53
|
+
Categorical node labels / community assignment.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
float
|
|
58
|
+
Modularity score. Returns 0 for a zero-edge network (neither
|
|
59
|
+
modular nor non-modular).
|
|
60
|
+
"""
|
|
61
|
+
W = np.asarray(W, dtype=float)
|
|
62
|
+
s = W.sum() # sum of degrees of nodes (assuming symmetric)
|
|
63
|
+
|
|
64
|
+
if s == 0:
|
|
65
|
+
return 0.0
|
|
66
|
+
|
|
67
|
+
gamma = 1 # scaling parameter
|
|
68
|
+
B = (W - gamma * (W.sum(axis=1, keepdims=True) @ W.sum(axis=0, keepdims=True)) / s) / s
|
|
69
|
+
B = (B + B.T) / 2 # symmetrize
|
|
70
|
+
|
|
71
|
+
m0 = np.asarray(m0).ravel()
|
|
72
|
+
same_community = m0[:, None] == m0[None, :]
|
|
73
|
+
|
|
74
|
+
return float(B[same_community].sum())
|
tmapper/path_traffic.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Port of tmapper_tools/pathtraffic.m from the MATLAB toolbox."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def path_traffic(allpath, nodesize):
|
|
7
|
+
"""Compute traffic statistics (based on node size) along each path.
|
|
8
|
+
|
|
9
|
+
Port of MATLAB's ``pathtraffic.m``. Note: matches MATLAB's ``std()``
|
|
10
|
+
convention of dividing by ``N-1`` (sample standard deviation), not
|
|
11
|
+
numpy's default ``N``.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
allpath : sequence of sequence of int
|
|
16
|
+
Each element is a path of node indices.
|
|
17
|
+
nodesize : array_like
|
|
18
|
+
Size of each node, indexed the same way as the indices in
|
|
19
|
+
``allpath``.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
traf_mean, traf_med, traf_min, traf_max, traf_std : numpy.ndarray
|
|
24
|
+
One value per path in ``allpath``.
|
|
25
|
+
"""
|
|
26
|
+
nodesize = np.asarray(nodesize, dtype=float)
|
|
27
|
+
path_nodesize = [nodesize[np.asarray(p, dtype=int)] for p in allpath]
|
|
28
|
+
|
|
29
|
+
traf_mean = np.array([p.mean() for p in path_nodesize])
|
|
30
|
+
traf_med = np.array([np.median(p) for p in path_nodesize])
|
|
31
|
+
traf_min = np.array([p.min() for p in path_nodesize])
|
|
32
|
+
traf_max = np.array([p.max() for p in path_nodesize])
|
|
33
|
+
traf_std = np.array([p.std(ddof=1) if len(p) > 1 else 0.0 for p in path_nodesize])
|
|
34
|
+
|
|
35
|
+
return traf_mean, traf_med, traf_min, traf_max, traf_std
|
tmapper/plotting.py
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
"""Port of tmapper_tools/plottmgraph.m and plotgraphtcm.m from the MATLAB
|
|
2
|
+
toolbox."""
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import networkx as nx
|
|
6
|
+
from scipy import stats
|
|
7
|
+
|
|
8
|
+
from .labeling import find_node_label
|
|
9
|
+
from .tcm_distance import tcm_distance
|
|
10
|
+
from ._shortest_path import all_pairs_distance
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _rescale(x, lo, hi):
|
|
14
|
+
"""Linearly map x's [min, max] to [lo, hi], matching MATLAB's rescale."""
|
|
15
|
+
x = np.asarray(x, dtype=float)
|
|
16
|
+
xmin, xmax = x.min(), x.max()
|
|
17
|
+
return lo + (x - xmin) / (xmax - xmin) * (hi - lo)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_positions(pos):
|
|
21
|
+
"""Rescale a layout so the farthest node sits at radius 1."""
|
|
22
|
+
pts = np.array(list(pos.values()))
|
|
23
|
+
max_r = np.max(np.linalg.norm(pts, axis=1))
|
|
24
|
+
scale = 1.0 / max_r if max_r > 0 else 1.0
|
|
25
|
+
return {n: xy * scale for n, xy in pos.items()}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _expand_center(pos, gamma):
|
|
29
|
+
"""Log-radial de-clumping: remap each node's radius r -> log1p(gamma*r).
|
|
30
|
+
|
|
31
|
+
Force-directed layouts (spring/ForceAtlas2) tend to pack the bulk of
|
|
32
|
+
nodes into a dense central clump with only a few outliers stretching
|
|
33
|
+
the frame. Since log1p(gamma*r)/r is largest at small r and flattens
|
|
34
|
+
out at large r, this pushes the dense core outward proportionally
|
|
35
|
+
more than the already-spread-out periphery, without disturbing
|
|
36
|
+
angular position or relative radial order.
|
|
37
|
+
"""
|
|
38
|
+
out = {}
|
|
39
|
+
for n, xy in pos.items():
|
|
40
|
+
r = np.linalg.norm(xy)
|
|
41
|
+
if r > 0:
|
|
42
|
+
rn = np.log1p(gamma * r)
|
|
43
|
+
out[n] = xy * (rn / r)
|
|
44
|
+
else:
|
|
45
|
+
out[n] = xy
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _prepare_node_size(nodemembers, nodesizerange, nodesizemode):
|
|
50
|
+
"""Shared node-size computation for :func:`plot_tmgraph` and
|
|
51
|
+
:func:`plot_tmgraph_interactive`: member count -> optional
|
|
52
|
+
rank/log transform -> rescaled to ``nodesizerange``."""
|
|
53
|
+
n_nodes = len(nodemembers)
|
|
54
|
+
nodesize = np.array([len(m) for m in nodemembers], dtype=float)
|
|
55
|
+
buniform = len(set(nodesize.tolist())) == 1
|
|
56
|
+
if not buniform:
|
|
57
|
+
if nodesizemode == "rank":
|
|
58
|
+
nodesize = stats.rankdata(nodesize, method="average")
|
|
59
|
+
elif nodesizemode == "log":
|
|
60
|
+
nodesize = np.log10(nodesize)
|
|
61
|
+
# 'original' (or anything else): leave nodesize as-is, matching
|
|
62
|
+
# MATLAB's switch with no matching/default case
|
|
63
|
+
nodesize = _rescale(nodesize, nodesizerange[0], nodesizerange[1])
|
|
64
|
+
else:
|
|
65
|
+
span = nodesizerange[1] - nodesizerange[0]
|
|
66
|
+
nodesize = np.full(n_nodes, nodesizerange[0] + span / n_nodes)
|
|
67
|
+
return nodesize
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _compute_layout(g, nodelist, center_expand):
|
|
71
|
+
"""Shared layout computation for :func:`plot_tmgraph` and
|
|
72
|
+
:func:`plot_tmgraph_interactive`: igraph's DrL layout (falls back to
|
|
73
|
+
spring_layout for the trivial 1-node case), normalized to the unit
|
|
74
|
+
circle and optionally de-clumped. See :func:`_expand_center`."""
|
|
75
|
+
n_nodes = len(nodelist)
|
|
76
|
+
if n_nodes > 1:
|
|
77
|
+
import igraph as ig
|
|
78
|
+
|
|
79
|
+
idx_of = {node: i for i, node in enumerate(nodelist)}
|
|
80
|
+
edges = [(idx_of[u], idx_of[v]) for u, v in g.edges()]
|
|
81
|
+
g_ig = ig.Graph(n=n_nodes, edges=edges, directed=g.is_directed())
|
|
82
|
+
coords = np.array(g_ig.layout_drl().coords)
|
|
83
|
+
pos = {nodelist[i]: coords[i] for i in range(n_nodes)}
|
|
84
|
+
else:
|
|
85
|
+
pos = nx.spring_layout(g, weight=None, seed=0)
|
|
86
|
+
|
|
87
|
+
pos = _normalize_positions(pos)
|
|
88
|
+
if center_expand:
|
|
89
|
+
pos = _expand_center(pos, center_expand)
|
|
90
|
+
return pos
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def plot_tmgraph(
|
|
94
|
+
g,
|
|
95
|
+
x_label=None,
|
|
96
|
+
nodemembers=None,
|
|
97
|
+
*,
|
|
98
|
+
ax=None,
|
|
99
|
+
nodesizerange=(1, 10),
|
|
100
|
+
nodesizemode="log",
|
|
101
|
+
colorlabel="x_label",
|
|
102
|
+
cmap="jet",
|
|
103
|
+
labelmethod="mode",
|
|
104
|
+
nodeclim=None,
|
|
105
|
+
nodescatter=False,
|
|
106
|
+
center_expand=4.0,
|
|
107
|
+
):
|
|
108
|
+
"""Plot a temporal mapper graph (without a recurrence plot).
|
|
109
|
+
|
|
110
|
+
Port of MATLAB's ``plottmgraph.m``.
|
|
111
|
+
|
|
112
|
+
Parameters
|
|
113
|
+
----------
|
|
114
|
+
g : networkx.Graph or networkx.DiGraph
|
|
115
|
+
The graph to plot.
|
|
116
|
+
x_label : array_like, optional
|
|
117
|
+
A label for each member of each node, indexed the same way as the
|
|
118
|
+
indices inside ``nodemembers``. Defaults to a constant array of
|
|
119
|
+
ones sized to the number of unique members across all nodes.
|
|
120
|
+
nodemembers : sequence of sequence of int, optional
|
|
121
|
+
``nodemembers[n]`` gives the indices belonging to node n. Defaults
|
|
122
|
+
to one singleton member per node (0..N-1).
|
|
123
|
+
ax : matplotlib.axes.Axes, optional
|
|
124
|
+
Axes to draw into. A new figure/axes is created if not given.
|
|
125
|
+
nodesizerange : (float, float), default (1, 10)
|
|
126
|
+
(min, max) marker size.
|
|
127
|
+
nodesizemode : {'log', 'rank', 'original'}, default 'log'
|
|
128
|
+
How to transform node sizes before rescaling to ``nodesizerange``.
|
|
129
|
+
colorlabel : str, default 'x_label'
|
|
130
|
+
Colorbar label.
|
|
131
|
+
cmap : str or matplotlib.colors.Colormap, default 'jet'
|
|
132
|
+
Colormap for node coloring.
|
|
133
|
+
labelmethod : {'mode', 'mean', 'median', 'none'} or callable, default 'mode'
|
|
134
|
+
See :func:`tmapper.labeling.find_node_label`.
|
|
135
|
+
nodeclim : (float, float), optional
|
|
136
|
+
Color axis limits. Defaults to (min(x_label), max(x_label)).
|
|
137
|
+
nodescatter : bool, default False
|
|
138
|
+
Whether to overlay a scatter plot on top of the graph nodes.
|
|
139
|
+
center_expand : float, default 4.0
|
|
140
|
+
Log-radial de-clumping strength applied to the layout after
|
|
141
|
+
normalizing it to the unit circle (0 disables it). Force-directed
|
|
142
|
+
layouts tend to pack most nodes into a dense central clump with a
|
|
143
|
+
few outliers stretching the frame; this spreads the dense core
|
|
144
|
+
outward without disturbing the outer structure. See
|
|
145
|
+
:func:`_expand_center`.
|
|
146
|
+
|
|
147
|
+
Returns
|
|
148
|
+
-------
|
|
149
|
+
ax : matplotlib.axes.Axes
|
|
150
|
+
cbar : matplotlib.colorbar.Colorbar
|
|
151
|
+
node_collection : matplotlib.collections.PathCollection
|
|
152
|
+
The drawn graph nodes.
|
|
153
|
+
scatter_collection : matplotlib.collections.PathCollection or None
|
|
154
|
+
The scatter overlay, if ``nodescatter`` is True.
|
|
155
|
+
"""
|
|
156
|
+
import matplotlib.pyplot as plt
|
|
157
|
+
|
|
158
|
+
nodelist = list(g.nodes())
|
|
159
|
+
n_nodes = len(nodelist)
|
|
160
|
+
|
|
161
|
+
if nodemembers is None:
|
|
162
|
+
nodemembers = [[i] for i in range(n_nodes)]
|
|
163
|
+
|
|
164
|
+
if x_label is None:
|
|
165
|
+
all_members = sorted({m for members in nodemembers for m in members})
|
|
166
|
+
x_label = np.ones(len(all_members))
|
|
167
|
+
x_label = np.asarray(x_label, dtype=float)
|
|
168
|
+
|
|
169
|
+
if nodeclim is None:
|
|
170
|
+
nodeclim = (float(np.min(x_label)), float(np.max(x_label)))
|
|
171
|
+
|
|
172
|
+
# -- define node size and labels/colors
|
|
173
|
+
nodesize = _prepare_node_size(nodemembers, nodesizerange, nodesizemode)
|
|
174
|
+
nodelabel = find_node_label(nodemembers, x_label, labelmethod=labelmethod)
|
|
175
|
+
|
|
176
|
+
# -- plotting
|
|
177
|
+
if ax is None:
|
|
178
|
+
_, ax = plt.subplots()
|
|
179
|
+
|
|
180
|
+
pos = _compute_layout(g, nodelist, center_expand)
|
|
181
|
+
|
|
182
|
+
xs = np.array([pos[n][0] for n in nodelist])
|
|
183
|
+
ys = np.array([pos[n][1] for n in nodelist])
|
|
184
|
+
|
|
185
|
+
# Edges visible but behind (zorder=1); nodes outlined and drawn in
|
|
186
|
+
# front (zorder=3), smallest last, so small nodes aren't buried
|
|
187
|
+
# under large ones and stay crisp regardless of edge clutter.
|
|
188
|
+
edge_collection = nx.draw_networkx_edges(
|
|
189
|
+
g, pos, ax=ax, alpha=0.6, edge_color="#888888", width=0.8,
|
|
190
|
+
arrowsize=6, nodelist=nodelist,
|
|
191
|
+
)
|
|
192
|
+
for obj in (edge_collection if isinstance(edge_collection, list) else [edge_collection]):
|
|
193
|
+
if obj is not None:
|
|
194
|
+
obj.set_zorder(1)
|
|
195
|
+
|
|
196
|
+
order = np.argsort(nodesize, kind="stable")[::-1] # largest first, smallest drawn last (on top)
|
|
197
|
+
node_collection = ax.scatter(
|
|
198
|
+
xs[order], ys[order], s=(nodesize[order] ** 2), c=nodelabel[order], cmap=cmap,
|
|
199
|
+
vmin=nodeclim[0] if nodeclim[1] != nodeclim[0] else None,
|
|
200
|
+
vmax=nodeclim[1] if nodeclim[1] != nodeclim[0] else None,
|
|
201
|
+
edgecolors="black", linewidths=0.5, zorder=3,
|
|
202
|
+
)
|
|
203
|
+
ax.set_aspect("equal")
|
|
204
|
+
ax.axis("off")
|
|
205
|
+
|
|
206
|
+
sm = plt.cm.ScalarMappable(cmap=cmap)
|
|
207
|
+
sm.set_array(nodelabel)
|
|
208
|
+
if nodeclim[1] != nodeclim[0]:
|
|
209
|
+
sm.set_clim(nodeclim[0], nodeclim[1])
|
|
210
|
+
cbar = plt.colorbar(sm, ax=ax)
|
|
211
|
+
cbar.set_label(colorlabel)
|
|
212
|
+
|
|
213
|
+
scatter_collection = None
|
|
214
|
+
if nodescatter:
|
|
215
|
+
order = np.argsort(nodesize, kind="stable")
|
|
216
|
+
scatter_collection = ax.scatter(
|
|
217
|
+
xs[order], ys[order], s=(nodesize[order] ** 2),
|
|
218
|
+
c=nodelabel[order], cmap=cmap,
|
|
219
|
+
vmin=nodeclim[0] if nodeclim[1] != nodeclim[0] else None,
|
|
220
|
+
vmax=nodeclim[1] if nodeclim[1] != nodeclim[0] else None,
|
|
221
|
+
edgecolors="k", linewidths=0.2,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return ax, cbar, node_collection, scatter_collection
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def plot_tmgraph_interactive(
|
|
228
|
+
g,
|
|
229
|
+
x_label=None,
|
|
230
|
+
nodemembers=None,
|
|
231
|
+
*,
|
|
232
|
+
nodesizerange=(6, 40),
|
|
233
|
+
nodesizemode="log",
|
|
234
|
+
colorlabel="x_label",
|
|
235
|
+
cmap="jet",
|
|
236
|
+
labelmethod="mode",
|
|
237
|
+
nodeclim=None,
|
|
238
|
+
center_expand=4.0,
|
|
239
|
+
title="",
|
|
240
|
+
output_path=None,
|
|
241
|
+
):
|
|
242
|
+
"""Plot a temporal mapper graph as a draggable/zoomable/hoverable
|
|
243
|
+
standalone HTML page (via pyvis/vis.js), instead of a static image.
|
|
244
|
+
|
|
245
|
+
Uses the same layout, node-size, and coloring logic as
|
|
246
|
+
:func:`plot_tmgraph` (so the two should look like the same network),
|
|
247
|
+
with physics disabled -- nodes stay exactly where the layout put
|
|
248
|
+
them, but can still be dragged, and hovering shows each node's size
|
|
249
|
+
and color-label value.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
g : networkx.Graph or networkx.DiGraph
|
|
254
|
+
The graph to plot.
|
|
255
|
+
x_label : array_like, optional
|
|
256
|
+
See :func:`plot_tmgraph`.
|
|
257
|
+
nodemembers : sequence of sequence of int, optional
|
|
258
|
+
See :func:`plot_tmgraph`.
|
|
259
|
+
nodesizerange : (float, float), default (6, 40)
|
|
260
|
+
(min, max) node radius in pixels (pyvis/vis.js sizes nodes by
|
|
261
|
+
radius, unlike matplotlib's area-based marker size -- so this
|
|
262
|
+
default is not directly comparable to :func:`plot_tmgraph`'s).
|
|
263
|
+
nodesizemode : {'log', 'rank', 'original'}, default 'log'
|
|
264
|
+
See :func:`plot_tmgraph`.
|
|
265
|
+
colorlabel : str, default 'x_label'
|
|
266
|
+
Legend label (rendered as a small embedded colorbar image).
|
|
267
|
+
cmap : str or matplotlib.colors.Colormap, default 'jet'
|
|
268
|
+
Colormap for node coloring -- anything matplotlib accepts,
|
|
269
|
+
including a discrete :class:`~matplotlib.colors.ListedColormap`
|
|
270
|
+
for categorical labels.
|
|
271
|
+
labelmethod : {'mode', 'mean', 'median', 'none'} or callable, default 'mode'
|
|
272
|
+
See :func:`tmapper.labeling.find_node_label`.
|
|
273
|
+
nodeclim : (float, float), optional
|
|
274
|
+
Color axis limits. Defaults to (min(x_label), max(x_label)).
|
|
275
|
+
center_expand : float, default 4.0
|
|
276
|
+
See :func:`plot_tmgraph`.
|
|
277
|
+
title : str, default ''
|
|
278
|
+
Page heading (e.g. summarize the construction parameters here).
|
|
279
|
+
output_path : str, optional
|
|
280
|
+
If given, also write the standalone HTML page to this path.
|
|
281
|
+
Default None -- no file is written; use the returned ``html``
|
|
282
|
+
string instead (e.g. pass it directly to
|
|
283
|
+
``streamlit.components.v1.html(html, height=900)`` rather than
|
|
284
|
+
writing to disk and reading it back).
|
|
285
|
+
|
|
286
|
+
Returns
|
|
287
|
+
-------
|
|
288
|
+
net : pyvis.network.Network
|
|
289
|
+
The populated network.
|
|
290
|
+
html : str
|
|
291
|
+
The standalone HTML page as a string (already includes the
|
|
292
|
+
embedded colorbar legend) -- identical to what gets written to
|
|
293
|
+
``output_path`` when one is given.
|
|
294
|
+
"""
|
|
295
|
+
import base64
|
|
296
|
+
from io import BytesIO
|
|
297
|
+
|
|
298
|
+
import matplotlib.pyplot as plt
|
|
299
|
+
import matplotlib.colors as mcolors
|
|
300
|
+
from pyvis.network import Network
|
|
301
|
+
|
|
302
|
+
nodelist = list(g.nodes())
|
|
303
|
+
n_nodes = len(nodelist)
|
|
304
|
+
|
|
305
|
+
if nodemembers is None:
|
|
306
|
+
nodemembers = [[i] for i in range(n_nodes)]
|
|
307
|
+
|
|
308
|
+
if x_label is None:
|
|
309
|
+
all_members = sorted({m for members in nodemembers for m in members})
|
|
310
|
+
x_label = np.ones(len(all_members))
|
|
311
|
+
x_label = np.asarray(x_label, dtype=float)
|
|
312
|
+
|
|
313
|
+
if nodeclim is None:
|
|
314
|
+
nodeclim = (float(np.min(x_label)), float(np.max(x_label)))
|
|
315
|
+
|
|
316
|
+
nodesize = _prepare_node_size(nodemembers, nodesizerange, nodesizemode)
|
|
317
|
+
nodelabel = find_node_label(nodemembers, x_label, labelmethod=labelmethod)
|
|
318
|
+
pos = _compute_layout(g, nodelist, center_expand)
|
|
319
|
+
|
|
320
|
+
# -- per-node hex colors via matplotlib's colormap machinery, so this
|
|
321
|
+
# accepts the exact same `cmap`/`nodeclim` (continuous or discrete)
|
|
322
|
+
# as plot_tmgraph.
|
|
323
|
+
cmap_obj = plt.get_cmap(cmap) if isinstance(cmap, str) else cmap
|
|
324
|
+
vmin, vmax = nodeclim if nodeclim[1] != nodeclim[0] else (nodeclim[0] - 0.5, nodeclim[0] + 0.5)
|
|
325
|
+
norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
|
|
326
|
+
node_colors_hex = [mcolors.to_hex(cmap_obj(norm(v))) for v in nodelabel]
|
|
327
|
+
|
|
328
|
+
net = Network(
|
|
329
|
+
height="900px", width="100%", directed=g.is_directed(), bgcolor="#ffffff",
|
|
330
|
+
cdn_resources="in_line", notebook=False, heading=title,
|
|
331
|
+
)
|
|
332
|
+
net.set_options('{"physics": {"enabled": false}}')
|
|
333
|
+
|
|
334
|
+
SCALE = 800 # pyvis coordinates are pixels; our layout is normalized to roughly [-1.x, 1.x]
|
|
335
|
+
order = np.argsort(nodesize, kind="stable")[::-1] # largest first, smallest added last (drawn on top)
|
|
336
|
+
for i in order:
|
|
337
|
+
i = int(i)
|
|
338
|
+
n = nodelist[i]
|
|
339
|
+
x, y = pos[n]
|
|
340
|
+
net.add_node(
|
|
341
|
+
i,
|
|
342
|
+
label="",
|
|
343
|
+
title=f"node {n} | members={int(len(nodemembers[i]))} | {colorlabel}={nodelabel[i]:.3g}",
|
|
344
|
+
x=float(x * SCALE), y=float(-y * SCALE), # flip y: vis.js y-axis points down
|
|
345
|
+
size=float(nodesize[i]), color=node_colors_hex[i], physics=False,
|
|
346
|
+
)
|
|
347
|
+
idx_of = {n: i for i, n in enumerate(nodelist)}
|
|
348
|
+
edge_kwargs = {"arrows": "to"} if g.is_directed() else {}
|
|
349
|
+
for u, v in g.edges():
|
|
350
|
+
net.add_edge(idx_of[u], idx_of[v], color="#bbbbbb", width=1, **edge_kwargs)
|
|
351
|
+
|
|
352
|
+
html = net.generate_html(notebook=False)
|
|
353
|
+
|
|
354
|
+
# pyvis's own template renders "<h1>{{heading}}</h1>" twice
|
|
355
|
+
# unconditionally -- drop the second copy.
|
|
356
|
+
if title:
|
|
357
|
+
heading_block = f"<h1>{title}</h1>"
|
|
358
|
+
first = html.find(heading_block)
|
|
359
|
+
second = html.find(heading_block, first + 1)
|
|
360
|
+
if second != -1:
|
|
361
|
+
html = html[:second] + html[second + len(heading_block):]
|
|
362
|
+
|
|
363
|
+
# -- embed a small matplotlib colorbar as the legend, reusing the
|
|
364
|
+
# exact cmap/norm above so it always matches the node colors.
|
|
365
|
+
fig_cb, ax_cb = plt.subplots(figsize=(4, 0.5))
|
|
366
|
+
cbar = fig_cb.colorbar(
|
|
367
|
+
plt.cm.ScalarMappable(cmap=cmap_obj, norm=norm), cax=ax_cb, orientation="horizontal"
|
|
368
|
+
)
|
|
369
|
+
cbar.set_label(colorlabel)
|
|
370
|
+
buf = BytesIO()
|
|
371
|
+
fig_cb.savefig(buf, format="png", dpi=130, bbox_inches="tight", transparent=True)
|
|
372
|
+
plt.close(fig_cb)
|
|
373
|
+
legend_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
|
|
374
|
+
legend_html = (
|
|
375
|
+
f'<div style="text-align:center;">'
|
|
376
|
+
f'<img src="data:image/png;base64,{legend_b64}"></div>'
|
|
377
|
+
)
|
|
378
|
+
anchor = "</h1>" if title else "<body>"
|
|
379
|
+
html = html.replace(anchor, anchor + "\n" + legend_html, 1)
|
|
380
|
+
|
|
381
|
+
if output_path is not None:
|
|
382
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
383
|
+
f.write(html)
|
|
384
|
+
|
|
385
|
+
return net, html
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def plot_tmgraph_tcm(g, x_label, t, nodemembers, **kwargs):
|
|
389
|
+
"""Plot a temporal mapper graph alongside its geodesic recurrence plot.
|
|
390
|
+
|
|
391
|
+
Port of MATLAB's ``plotgraphtcm.m``. Calls :func:`plot_tmgraph`
|
|
392
|
+
internally for the left subplot (all ``**kwargs`` are passed through),
|
|
393
|
+
so it accepts the same styling parameters.
|
|
394
|
+
|
|
395
|
+
Parameters
|
|
396
|
+
----------
|
|
397
|
+
g : networkx.Graph or networkx.DiGraph
|
|
398
|
+
The (simplified) graph to plot.
|
|
399
|
+
x_label : array_like
|
|
400
|
+
A label for each time point in the time series.
|
|
401
|
+
t : array_like
|
|
402
|
+
Actual time (or a time index) associated with each time point,
|
|
403
|
+
used as the recurrence plot's axis labels.
|
|
404
|
+
nodemembers : sequence of sequence of int
|
|
405
|
+
``nodemembers[n]`` gives the original time-point indices
|
|
406
|
+
belonging to node n.
|
|
407
|
+
**kwargs
|
|
408
|
+
Passed through to :func:`plot_tmgraph`.
|
|
409
|
+
|
|
410
|
+
Returns
|
|
411
|
+
-------
|
|
412
|
+
ax1, ax2 : matplotlib.axes.Axes
|
|
413
|
+
The network plot and recurrence plot axes.
|
|
414
|
+
cbar, cbar2 : matplotlib.colorbar.Colorbar
|
|
415
|
+
node_collection, scatter_collection
|
|
416
|
+
As returned by :func:`plot_tmgraph`.
|
|
417
|
+
D_geo : numpy.ndarray
|
|
418
|
+
The recurrence plot matrix.
|
|
419
|
+
"""
|
|
420
|
+
import matplotlib.pyplot as plt
|
|
421
|
+
import matplotlib.dates as mdates
|
|
422
|
+
|
|
423
|
+
nodesize = np.array([len(m) for m in nodemembers])
|
|
424
|
+
bsinglemember = np.all(nodesize == 1)
|
|
425
|
+
|
|
426
|
+
# constrained_layout resolves spacing between subplots/colorbars
|
|
427
|
+
# automatically, avoiding overlap between cbar's label and ax2's ylabel.
|
|
428
|
+
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(11, 4.5), constrained_layout=True)
|
|
429
|
+
|
|
430
|
+
ax1, cbar, node_collection, scatter_collection = plot_tmgraph(
|
|
431
|
+
g, x_label, nodemembers, ax=ax1, **kwargs
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
if bsinglemember:
|
|
435
|
+
D_geo = all_pairs_distance(g, list(g.nodes()), weight=None)
|
|
436
|
+
else:
|
|
437
|
+
D_geo = tcm_distance(g, nodemembers)
|
|
438
|
+
|
|
439
|
+
t = np.asarray(t)
|
|
440
|
+
is_datetime = np.issubdtype(t.dtype, np.datetime64)
|
|
441
|
+
t_num = mdates.date2num(t) if is_datetime else t.astype(float)
|
|
442
|
+
|
|
443
|
+
im = ax2.imshow(
|
|
444
|
+
D_geo, cmap="hot",
|
|
445
|
+
extent=[t_num.min(), t_num.max(), t_num.max(), t_num.min()],
|
|
446
|
+
aspect="equal", interpolation="nearest", # matches MATLAB's imagesc (flat, unsmoothed cells)
|
|
447
|
+
)
|
|
448
|
+
cbar2 = plt.colorbar(im, ax=ax2)
|
|
449
|
+
cbar2.set_label("path length")
|
|
450
|
+
|
|
451
|
+
if is_datetime:
|
|
452
|
+
for axis in (ax2.xaxis, ax2.yaxis):
|
|
453
|
+
locator = mdates.AutoDateLocator()
|
|
454
|
+
axis.set_major_locator(locator)
|
|
455
|
+
axis.set_major_formatter(mdates.ConciseDateFormatter(locator))
|
|
456
|
+
ax2.set_xlabel("time")
|
|
457
|
+
ax2.set_ylabel("time")
|
|
458
|
+
else:
|
|
459
|
+
ax2.set_xlabel("time (s)")
|
|
460
|
+
ax2.set_ylabel("time (s)")
|
|
461
|
+
ax2.set_title("geodesic recurrence plot")
|
|
462
|
+
|
|
463
|
+
return ax1, ax2, cbar, cbar2, node_collection, scatter_collection, D_geo
|
tmapper/sample_data.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Locating the bundled sample dataset."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
SAMPLE_DATA_FILE = "EL_temp.csv"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def sample_data_path():
|
|
9
|
+
"""Path to the bundled East Lansing daily weather CSV.
|
|
10
|
+
|
|
11
|
+
Ships inside the package, so this resolves the same way whether tmapper
|
|
12
|
+
was pip-installed or is being run from a clone -- code that hard-codes
|
|
13
|
+
``"sampledata/EL_temp.csv"`` only works from the repository root.
|
|
14
|
+
|
|
15
|
+
Note this is the *full* historical record (57709 daily rows). Building a
|
|
16
|
+
network needs a slice of it: the pairwise distance matrix is O(N^2), so
|
|
17
|
+
the whole thing would ask for tens of GB. See the Quickstart.
|
|
18
|
+
"""
|
|
19
|
+
return Path(__file__).resolve().parent / "sampledata" / SAMPLE_DATA_FILE
|