guidepost 0.3.1__tar.gz → 0.3.3__tar.gz
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.
- guidepost-0.3.3/MANIFEST.in +4 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/PKG-INFO +2 -11
- {guidepost-0.3.1 → guidepost-0.3.3}/README.md +1 -10
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/guidepost.py +26 -1
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/seriation.py +101 -10
- guidepost-0.3.3/guidepost/static/guidepost.js +23226 -0
- guidepost-0.3.3/guidepost/static/guidepost.js.map +7 -0
- guidepost-0.3.3/guidepost/static/trailmark.js +21 -0
- guidepost-0.3.3/guidepost/static/trailmark.js.map +7 -0
- guidepost-0.3.3/guidepost/version.py +2 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/PKG-INFO +2 -11
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/SOURCES.txt +6 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/setup.py +1 -1
- guidepost-0.3.3/tests/test_custom_sort.py +144 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_list_parsing.py +4 -1
- guidepost-0.3.1/guidepost/version.py +0 -2
- {guidepost-0.3.1 → guidepost-0.3.3}/LICENSE +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/__init__.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/aggregation.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/node_layout.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/trailmark.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost/utils.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/dependency_links.txt +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/requires.txt +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/guidepost.egg-info/top_level.txt +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/pyproject.toml +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/setup.cfg +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_aggregation.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_node_layout.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/tests/test_seriation.py +0 -0
- {guidepost-0.3.1 → guidepost-0.3.3}/tutorials/__init__.py +0 -0
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
# `python -m build` builds the wheel from the sdist, so the compiled frontend
|
|
2
|
+
# bundles must be in the sdist for them to reach the installed wheel. They are
|
|
3
|
+
# gitignored (built by esbuild.config.js), so include them explicitly here.
|
|
4
|
+
recursive-include guidepost/static *.js *.js.map
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: guidepost
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Guidepost. An overview visualization for understanding supercomputer queue data.
|
|
5
5
|
Home-page: https://github.com/cscully-allison/guidepost
|
|
6
6
|
Author: Connor Scully-Allison
|
|
@@ -50,22 +50,13 @@ import pandas as pd
|
|
|
50
50
|
gp = Guidepost()
|
|
51
51
|
gp.load_data(pd.read_parquet("data/jobs_data.parquet"))
|
|
52
52
|
|
|
53
|
-
gp.vis_configs = {
|
|
54
|
-
'x': 'start_time', # x-axis (numeric or datetime)
|
|
55
|
-
'y': 'queue_wait', # y-axis (numeric)
|
|
56
|
-
'color': 'nodes_requested', # cell color (numeric)
|
|
57
|
-
'color_agg': 'avg', # aggregation for color
|
|
58
|
-
'categorical': 'user', # bar chart / filter
|
|
59
|
-
'facet_by': 'partition' # splits the data into groups
|
|
60
|
-
}
|
|
61
|
-
|
|
62
53
|
gp # display in a notebook cell
|
|
63
54
|
```
|
|
64
55
|
|
|
65
56
|
Brush the heatmap or its histograms, then pull the selected rows back into Python:
|
|
66
57
|
|
|
67
58
|
```python
|
|
68
|
-
df = gp.
|
|
59
|
+
df = gp.selection.dataframe
|
|
69
60
|
```
|
|
70
61
|
|
|
71
62
|
Input is a `pandas` DataFrame with at least three numeric and two categorical columns (datetime columns are supported on the x-axis).
|
|
@@ -19,22 +19,13 @@ import pandas as pd
|
|
|
19
19
|
gp = Guidepost()
|
|
20
20
|
gp.load_data(pd.read_parquet("data/jobs_data.parquet"))
|
|
21
21
|
|
|
22
|
-
gp.vis_configs = {
|
|
23
|
-
'x': 'start_time', # x-axis (numeric or datetime)
|
|
24
|
-
'y': 'queue_wait', # y-axis (numeric)
|
|
25
|
-
'color': 'nodes_requested', # cell color (numeric)
|
|
26
|
-
'color_agg': 'avg', # aggregation for color
|
|
27
|
-
'categorical': 'user', # bar chart / filter
|
|
28
|
-
'facet_by': 'partition' # splits the data into groups
|
|
29
|
-
}
|
|
30
|
-
|
|
31
22
|
gp # display in a notebook cell
|
|
32
23
|
```
|
|
33
24
|
|
|
34
25
|
Brush the heatmap or its histograms, then pull the selected rows back into Python:
|
|
35
26
|
|
|
36
27
|
```python
|
|
37
|
-
df = gp.
|
|
28
|
+
df = gp.selection.dataframe
|
|
38
29
|
```
|
|
39
30
|
|
|
40
31
|
Input is a `pandas` DataFrame with at least three numeric and two categorical columns (datetime columns are supported on the x-axis).
|
|
@@ -77,6 +77,16 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
77
77
|
# identifiers the name heuristic misses, or to override an unwanted
|
|
78
78
|
# auto-classification. Mirrors list_columns; pulled before super().
|
|
79
79
|
categorical_columns = kwargs.pop("categorical_columns", None)
|
|
80
|
+
# Optional override for list-column x-axis ordering, replacing the
|
|
81
|
+
# built-in node-layout / seriation heuristics — for ordinal-but-not-
|
|
82
|
+
# hierarchical values the heuristics can't order (e.g. nodes named
|
|
83
|
+
# "1","2","3"). Either a single callable applied to every list column,
|
|
84
|
+
# or a {column: callable} dict for per-column control. Each callable is
|
|
85
|
+
# a full-list transform: it receives the list of distinct category
|
|
86
|
+
# values and returns them reordered. A callable can't be JSON-synced,
|
|
87
|
+
# so like list_columns this is a plain Python attribute (not a trait);
|
|
88
|
+
# it runs in load_data and only its *result* ships to the frontend.
|
|
89
|
+
sort_by = kwargs.pop("sort_by", None)
|
|
80
90
|
super().__init__(*args, **kwargs)
|
|
81
91
|
self._categorical_columns = set(categorical_columns) if categorical_columns else set()
|
|
82
92
|
self._list_columns = list(list_columns) if list_columns else []
|
|
@@ -85,6 +95,20 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
85
95
|
# before the first load don't fail.
|
|
86
96
|
self._effective_list_columns = list(self._list_columns)
|
|
87
97
|
self._list_delimiter = list_delimiter or ","
|
|
98
|
+
# Validate up front so a bad sort_by fails at construction, not silently
|
|
99
|
+
# at render. None | callable | {str: callable} are the only valid shapes.
|
|
100
|
+
if sort_by is not None:
|
|
101
|
+
if isinstance(sort_by, dict):
|
|
102
|
+
bad = [k for k, v in sort_by.items() if not callable(v)]
|
|
103
|
+
if bad:
|
|
104
|
+
raise TypeError(
|
|
105
|
+
"sort_by dict values must be callables; non-callable "
|
|
106
|
+
f"value(s) for column(s): {bad}")
|
|
107
|
+
elif not callable(sort_by):
|
|
108
|
+
raise TypeError(
|
|
109
|
+
"sort_by must be a callable or a {column: callable} dict, "
|
|
110
|
+
f"got {type(sort_by).__name__}")
|
|
111
|
+
self._sort_by = sort_by
|
|
88
112
|
# DuckDB-backed aggregator. Lazily created when the first DataFrame
|
|
89
113
|
# is loaded — keeping it None until then so widgets constructed
|
|
90
114
|
# without data don't pay the import/registration cost.
|
|
@@ -194,7 +218,8 @@ class Guidepost(anywidget.AnyWidget):
|
|
|
194
218
|
# that retains every node); arbitrary categories fall back to spectral
|
|
195
219
|
# seriation (ships `category_score` for the column cap). Both share
|
|
196
220
|
# `category_order` for the kept/ordered sequence.
|
|
197
|
-
ordering = compute_category_ordering(
|
|
221
|
+
ordering = compute_category_ordering(
|
|
222
|
+
o_df, self._effective_list_columns, sort_overrides=self._sort_by)
|
|
198
223
|
for col, info in ordering.items():
|
|
199
224
|
if col not in summary_stats:
|
|
200
225
|
continue
|
|
@@ -15,31 +15,84 @@ Both derive from the same node x node co-occurrence matrix. sklearn/scipy are
|
|
|
15
15
|
imported lazily so the cost is only paid when a list column is present.
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
import warnings
|
|
19
|
+
|
|
18
20
|
import numpy as np
|
|
19
21
|
|
|
20
22
|
from .node_layout import compute_node_layout
|
|
21
23
|
|
|
22
24
|
|
|
23
|
-
def
|
|
24
|
-
"""
|
|
25
|
-
|
|
25
|
+
def _resolve_override(sort_overrides, col):
|
|
26
|
+
"""Resolve the user sort override applicable to *col*, if any. A bare
|
|
27
|
+
callable applies to every column; a dict maps column name -> callable."""
|
|
28
|
+
if sort_overrides is None:
|
|
29
|
+
return None
|
|
30
|
+
if callable(sort_overrides):
|
|
31
|
+
return sort_overrides
|
|
32
|
+
if isinstance(sort_overrides, dict):
|
|
33
|
+
return sort_overrides.get(col)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _apply_override(fn, names):
|
|
38
|
+
"""Run a user override (full-list transform) over the distinct category
|
|
39
|
+
names, coercing the result to the String() keys the frontend uses. Raises
|
|
40
|
+
on a non-iterable return so the caller can fall back to the heuristics."""
|
|
41
|
+
ordered = fn(list(names))
|
|
42
|
+
return [str(k) for k in ordered]
|
|
43
|
+
|
|
26
44
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
45
|
+
def compute_category_ordering(o_df, list_columns, sort_overrides=None):
|
|
46
|
+
"""Dispatch ordering per list column: a user override (if provided) wins,
|
|
47
|
+
else prefer structure-aware node-name layout, else fall back to spectral
|
|
48
|
+
co-occurrence seriation.
|
|
49
|
+
|
|
50
|
+
Returns ``{col: info}`` where *info* is one of:
|
|
51
|
+
- ``{"order"}`` — a user ``sort_overrides`` transform for this column,
|
|
52
|
+
- ``{"order", "hierarchy", "levels"}`` — node names follow a hardware-layout
|
|
53
|
+
convention, or
|
|
54
|
+
- ``{"order", "score"}`` — arbitrary categories (seriation).
|
|
55
|
+
The frontend treats all three uniformly via ``category_order`` and branches
|
|
56
|
+
on the presence of ``category_hierarchy``.
|
|
57
|
+
|
|
58
|
+
*sort_overrides* is ``None``, a single callable applied to every list
|
|
59
|
+
column, or a ``{column: callable}`` dict. Each callable receives the list
|
|
60
|
+
of distinct category values and returns them reordered. A callable that
|
|
61
|
+
raises (or returns a non-iterable) falls back to the heuristics for that
|
|
62
|
+
column with a warning, so a bad override never aborts data loading.
|
|
32
63
|
"""
|
|
33
64
|
if not list_columns:
|
|
34
65
|
return {}
|
|
35
66
|
|
|
67
|
+
# Warn on dict keys that don't name a list column (typo / out of scope):
|
|
68
|
+
# they'd otherwise be silently ignored.
|
|
69
|
+
if isinstance(sort_overrides, dict):
|
|
70
|
+
unknown = [c for c in sort_overrides if c not in list_columns]
|
|
71
|
+
if unknown:
|
|
72
|
+
warnings.warn(
|
|
73
|
+
"sort_by names column(s) that are not list columns and will be "
|
|
74
|
+
f"ignored: {unknown}. Custom sort applies to list columns only.")
|
|
75
|
+
|
|
36
76
|
out = {}
|
|
37
77
|
fallback_cols = []
|
|
38
78
|
for col in list_columns:
|
|
39
79
|
if col not in o_df.columns:
|
|
40
80
|
continue
|
|
41
81
|
names = _distinct_node_names(o_df[col])
|
|
42
|
-
|
|
82
|
+
if not names:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
override = _resolve_override(sort_overrides, col)
|
|
86
|
+
if override is not None:
|
|
87
|
+
try:
|
|
88
|
+
out[col] = {"order": _apply_override(override, names)}
|
|
89
|
+
continue
|
|
90
|
+
except Exception as exc: # noqa: BLE001 - fall back, never abort load
|
|
91
|
+
warnings.warn(
|
|
92
|
+
f"sort_by override for column '{col}' raised {exc!r}; "
|
|
93
|
+
"falling back to the default ordering heuristic.")
|
|
94
|
+
|
|
95
|
+
layout = compute_node_layout(names)
|
|
43
96
|
if layout is not None:
|
|
44
97
|
out[col] = layout
|
|
45
98
|
else:
|
|
@@ -167,11 +220,49 @@ def _seriate_component(members, A, node_list, SpectralEmbedding):
|
|
|
167
220
|
return sorted(members, key=lambda i: node_list[i])
|
|
168
221
|
|
|
169
222
|
sub = A[members][:, members].toarray()
|
|
223
|
+
|
|
224
|
+
# On symmetric structures (e.g. a clique) the smallest non-zero Laplacian
|
|
225
|
+
# eigenvalue is degenerate, so the 1-D spectral coordinate is not unique:
|
|
226
|
+
# the eigensolver returns an arbitrary basis vector from that eigenspace,
|
|
227
|
+
# which varies across runs, platforms, and BLAS threading. There is no
|
|
228
|
+
# canonical order in that case, so fall back to deterministic name order.
|
|
229
|
+
# (EigenVALUES are stable even when the corresponding eigenVECTORS are not,
|
|
230
|
+
# so this test is itself reproducible.)
|
|
231
|
+
if _spectral_order_is_degenerate(sub):
|
|
232
|
+
return sorted(members, key=lambda i: node_list[i])
|
|
233
|
+
|
|
170
234
|
emb = SpectralEmbedding(n_components=1, affinity="precomputed", random_state=0)
|
|
171
235
|
coords = emb.fit_transform(sub)[:, 0]
|
|
172
|
-
|
|
236
|
+
# Round so any residual floating-point noise on near-tied nodes collapses and
|
|
237
|
+
# the name-based secondary key decides their order deterministically.
|
|
238
|
+
local = sorted(
|
|
239
|
+
range(len(members)),
|
|
240
|
+
key=lambda t: (round(float(coords[t]), 9), node_list[members[t]]),
|
|
241
|
+
)
|
|
173
242
|
seq = [members[t] for t in local]
|
|
174
243
|
# Eigenvectors carry a sign ambiguity; canonicalize orientation by name.
|
|
175
244
|
if node_list[seq[0]] > node_list[seq[-1]]:
|
|
176
245
|
seq.reverse()
|
|
177
246
|
return seq
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _spectral_order_is_degenerate(sub, tol=1e-9):
|
|
250
|
+
"""True when the 1-D spectral embedding of ``sub`` is ill-defined.
|
|
251
|
+
|
|
252
|
+
The seriation uses the eigenvector of the smallest non-zero eigenvalue of
|
|
253
|
+
the symmetric normalized Laplacian. When that eigenvalue is (near-)repeated,
|
|
254
|
+
the eigenvector — and hence the node order — is not unique. We detect that
|
|
255
|
+
from the eigenvalue *spectrum*, which ``eigvalsh`` returns deterministically
|
|
256
|
+
regardless of solver/threading, unlike the eigenvectors themselves.
|
|
257
|
+
"""
|
|
258
|
+
deg = sub.sum(axis=1)
|
|
259
|
+
if np.any(deg <= 0):
|
|
260
|
+
return True # an isolated node has no meaningful spectral position
|
|
261
|
+
d_inv_sqrt = 1.0 / np.sqrt(deg)
|
|
262
|
+
laplacian = np.eye(sub.shape[0]) - (d_inv_sqrt[:, None] * sub * d_inv_sqrt[None, :])
|
|
263
|
+
evals = np.linalg.eigvalsh(laplacian) # ascending; evals[0] ~ 0 (connected)
|
|
264
|
+
if len(evals) < 3:
|
|
265
|
+
return False
|
|
266
|
+
# Degenerate iff the 2nd and 3rd smallest eigenvalues coincide, i.e. the
|
|
267
|
+
# smallest non-zero eigenvalue has multiplicity > 1.
|
|
268
|
+
return bool((evals[2] - evals[1]) < tol)
|