nnscope 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.
- nnscope/__init__.py +12 -0
- nnscope/adapters.py +233 -0
- nnscope/export.py +168 -0
- nnscope/insights.py +102 -0
- nnscope/py.typed +0 -0
- nnscope/scope.py +231 -0
- nnscope/server.py +94 -0
- nnscope/static/index.html +856 -0
- nnscope/stats.py +112 -0
- nnscope-0.1.0.dist-info/METADATA +164 -0
- nnscope-0.1.0.dist-info/RECORD +13 -0
- nnscope-0.1.0.dist-info/WHEEL +4 -0
- nnscope-0.1.0.dist-info/licenses/LICENSE +21 -0
nnscope/scope.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""The `Scope`: record what a NumPy network does during training and show it live or as a replay."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .adapters import LayerView, cached_activations, extract_layers
|
|
12
|
+
from .insights import InsightEngine
|
|
13
|
+
from .stats import activation_numbers, fnum, histogram, layer_numbers, project_2d, select_neurons, sig
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Scope:
|
|
17
|
+
"""Watch a NumPy neural network learn.
|
|
18
|
+
|
|
19
|
+
>>> scope = nnscope.Scope("my-mlp") # live dashboard at http://127.0.0.1:8765
|
|
20
|
+
>>> for epoch in range(epochs):
|
|
21
|
+
... ... # your own forward / backward / SGD
|
|
22
|
+
... scope.log(epoch, model=model, loss=loss, metrics={"test_loss": test_loss})
|
|
23
|
+
>>> scope.save_html("run.html") # standalone replay, no server needed
|
|
24
|
+
|
|
25
|
+
Parameters
|
|
26
|
+
----------
|
|
27
|
+
name: shown in the dashboard header.
|
|
28
|
+
live: start the local live dashboard (a tiny stdlib HTTP server in a background thread).
|
|
29
|
+
port: first port to try (the next free one is used if taken). ``host`` defaults to localhost only.
|
|
30
|
+
open_browser: open the dashboard in the default browser when the server starts.
|
|
31
|
+
max_neurons: at most this many neurons per layer are drawn (evenly spaced; statistics always use all of them).
|
|
32
|
+
snapshot_every: record a full snapshot (edges, histograms, latent) every N logs. Default: adaptive, keeping at
|
|
33
|
+
most ``max_snapshots`` snapshots spread over the whole run.
|
|
34
|
+
layout: weight orientation, "auto" (default), "in_out" (x @ W) or "out_in" (W @ x).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, name: str = "run", *, live: bool = True, port: int = 8765, host: str = "127.0.0.1",
|
|
38
|
+
open_browser: bool = True, max_neurons: int = 32, snapshot_every: Optional[int] = None,
|
|
39
|
+
max_snapshots: int = 300, max_records: int = 5000, layout: str = "auto", latent_points: int = 1000,
|
|
40
|
+
quiet: bool = False):
|
|
41
|
+
self.name = name
|
|
42
|
+
self.max_neurons, self.layout, self.latent_points, self.quiet = max_neurons, layout, latent_points, quiet
|
|
43
|
+
self.max_snapshots, self.max_records = max_snapshots, max_records
|
|
44
|
+
self._fixed_every = snapshot_every
|
|
45
|
+
self._every = snapshot_every or 1
|
|
46
|
+
self._record_every = 1
|
|
47
|
+
self.structure: Optional[dict] = None
|
|
48
|
+
self.records: List[dict] = []
|
|
49
|
+
self.snapshots: List[dict] = []
|
|
50
|
+
self.insights: List[dict] = []
|
|
51
|
+
self._engine = InsightEngine()
|
|
52
|
+
self._prev_W: Optional[List[np.ndarray]] = None
|
|
53
|
+
self._n = 0
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
self._listeners: List[Any] = []
|
|
56
|
+
self._server = None
|
|
57
|
+
self.url: Optional[str] = None
|
|
58
|
+
self.created = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
59
|
+
if live:
|
|
60
|
+
from .server import start_server
|
|
61
|
+
self._server, self.url = start_server(self, host, port)
|
|
62
|
+
if not quiet:
|
|
63
|
+
print(f"nnscope: live dashboard at {self.url}")
|
|
64
|
+
if open_browser:
|
|
65
|
+
import webbrowser
|
|
66
|
+
threading.Thread(target=webbrowser.open, args=(self.url,), daemon=True).start()
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------ logging
|
|
69
|
+
def log(self, step: Optional[int] = None, *, model: Any = None, loss: Optional[float] = None,
|
|
70
|
+
metrics: Optional[Dict[str, float]] = None, weights: Optional[Sequence] = None,
|
|
71
|
+
biases: Optional[Sequence] = None, grads: Any = None, bias_grads: Optional[Sequence] = None,
|
|
72
|
+
activations: Optional[Sequence] = None, deltas: Optional[Sequence] = None,
|
|
73
|
+
latent: Optional[np.ndarray] = None, labels: Optional[np.ndarray] = None,
|
|
74
|
+
snapshot: Optional[bool] = None) -> None:
|
|
75
|
+
"""Record one training step.
|
|
76
|
+
|
|
77
|
+
Pass either ``model`` (auto-detected) or explicit ``weights`` (+ ``biases``, ``grads``). Optional extras:
|
|
78
|
+
``activations`` = per-layer outputs on a batch, ``deltas`` = per-layer ∂L/∂z on a batch, ``latent`` (+``labels``)
|
|
79
|
+
= features of any layer for the latent scatter. ``snapshot`` forces (True) or skips (False) a full snapshot.
|
|
80
|
+
"""
|
|
81
|
+
step = self._n if step is None else step
|
|
82
|
+
views = extract_layers(model, weights=weights, biases=biases, grads=grads, bias_grads=bias_grads,
|
|
83
|
+
layout=self.layout)
|
|
84
|
+
acts = list(activations) if activations is not None else cached_activations(views)
|
|
85
|
+
acts += [None] * (len(views) - len(acts))
|
|
86
|
+
act_stats = [activation_numbers(a, v.act) for a, v in zip(acts, views)]
|
|
87
|
+
new_structure = self._structure_for(views)
|
|
88
|
+
messages: List[dict] = []
|
|
89
|
+
if self.structure is None or new_structure["signature"] != self.structure["signature"]:
|
|
90
|
+
self.structure, self._prev_W = new_structure, None
|
|
91
|
+
messages.append({"type": "structure", "structure": new_structure})
|
|
92
|
+
|
|
93
|
+
scalars = {"loss": fnum(loss)} if loss is not None else {}
|
|
94
|
+
for k, v in (metrics or {}).items():
|
|
95
|
+
scalars[str(k)] = fnum(v)
|
|
96
|
+
layers = []
|
|
97
|
+
for k, v in enumerate(views):
|
|
98
|
+
nums = layer_numbers(v.W, v.dW, self._prev_W[k] if self._prev_W else None)
|
|
99
|
+
s = act_stats[k]
|
|
100
|
+
if s is not None:
|
|
101
|
+
nums.update(act_mean=s["layer_mean"], frac_active=s["layer_frac_active"], dead=s["dead"],
|
|
102
|
+
dead_kind=s["dead_kind"])
|
|
103
|
+
if deltas is not None and k < len(deltas) and deltas[k] is not None:
|
|
104
|
+
nums["delta_mean_abs"] = fnum(np.mean(np.abs(deltas[k])))
|
|
105
|
+
layers.append(nums)
|
|
106
|
+
record = {"type": "record", "step": step, "t": round(time.time(), 3), "scalars": scalars, "layers": layers}
|
|
107
|
+
|
|
108
|
+
take = snapshot if snapshot is not None else (self._n % self._every == 0)
|
|
109
|
+
snap = self._snapshot(step, views, act_stats, latent, labels) if take else None
|
|
110
|
+
new_insights = self._engine.update(record, self.structure)
|
|
111
|
+
self._prev_W = [v.W.copy() for v in views]
|
|
112
|
+
self._n += 1
|
|
113
|
+
|
|
114
|
+
with self._lock:
|
|
115
|
+
if self._n % self._record_every == 0 or take:
|
|
116
|
+
self.records.append(record)
|
|
117
|
+
messages.append(record)
|
|
118
|
+
if snap is not None:
|
|
119
|
+
self.snapshots.append(snap)
|
|
120
|
+
messages.append(snap)
|
|
121
|
+
for ins in new_insights:
|
|
122
|
+
self.insights.append(ins)
|
|
123
|
+
messages.append({"type": "insight", **ins})
|
|
124
|
+
self._thin()
|
|
125
|
+
self._emit(messages)
|
|
126
|
+
|
|
127
|
+
def _structure_for(self, views: List[LayerView]) -> dict:
|
|
128
|
+
layers = []
|
|
129
|
+
for k, v in enumerate(views):
|
|
130
|
+
layers.append({
|
|
131
|
+
"name": v.name, "in": v.in_features, "out": v.out_features, "act": v.act,
|
|
132
|
+
"params": int(v.W.size + (v.b.size if v.b is not None else 0)),
|
|
133
|
+
"shown_in": select_neurons(v.in_features, self.max_neurons),
|
|
134
|
+
"shown_out": select_neurons(v.out_features, self.max_neurons),
|
|
135
|
+
"has_grad": v.dW is not None, "has_bias": v.b is not None,
|
|
136
|
+
})
|
|
137
|
+
sig_ = tuple((l["in"], l["out"], l["act"]) for l in layers)
|
|
138
|
+
return {"layers": layers, "signature": str(sig_)}
|
|
139
|
+
|
|
140
|
+
def _snapshot(self, step, views, act_stats, latent, labels) -> dict:
|
|
141
|
+
st = self.structure["layers"]
|
|
142
|
+
snap: Dict[str, Any] = {"type": "snapshot", "step": step, "W": [], "G": [], "B": [], "GB": [], "nodes": [],
|
|
143
|
+
"hist": []}
|
|
144
|
+
for k, v in enumerate(views):
|
|
145
|
+
si, so = st[k]["shown_in"], st[k]["shown_out"]
|
|
146
|
+
snap["W"].append(sig(v.W[np.ix_(si, so)]))
|
|
147
|
+
snap["G"].append(sig(v.dW[np.ix_(si, so)]) if v.dW is not None else None)
|
|
148
|
+
snap["B"].append(sig(v.b[so]) if v.b is not None else None)
|
|
149
|
+
snap["GB"].append(sig(v.db[so]) if v.db is not None else None)
|
|
150
|
+
s = act_stats[k]
|
|
151
|
+
snap["nodes"].append({"mean": sig(s["mean"][so]), "std": sig(s["std"][so]),
|
|
152
|
+
"frac": sig(s["frac_active"][so])} if s is not None else None)
|
|
153
|
+
snap["hist"].append({"w": histogram(v.W), "g": histogram(v.dW)})
|
|
154
|
+
if latent is not None:
|
|
155
|
+
xy, explained, idx = project_2d(latent, self.latent_points)
|
|
156
|
+
lab = np.asarray(labels).reshape(-1)[idx] if labels is not None else None
|
|
157
|
+
if lab is not None and lab.dtype.kind not in "iub":
|
|
158
|
+
lab = np.unique(lab, return_inverse=True)[1] if lab.dtype.kind in "OUS" else lab
|
|
159
|
+
snap["latent"] = {"xy": sig(xy), "labels": lab.tolist() if lab is not None else None,
|
|
160
|
+
"explained": sig(explained) if explained is not None else None,
|
|
161
|
+
"dim": int(np.asarray(latent).reshape(len(np.asarray(latent)), -1).shape[1])}
|
|
162
|
+
return snap
|
|
163
|
+
|
|
164
|
+
def _thin(self) -> None:
|
|
165
|
+
"""Keep memory bounded on long runs: drop every other snapshot/record and halve the rate going forward."""
|
|
166
|
+
if len(self.snapshots) > self.max_snapshots and self._fixed_every is None:
|
|
167
|
+
self.snapshots = self.snapshots[::2]
|
|
168
|
+
self._every *= 2
|
|
169
|
+
if len(self.records) > self.max_records:
|
|
170
|
+
self.records = self.records[::2]
|
|
171
|
+
self._record_every *= 2
|
|
172
|
+
|
|
173
|
+
# ------------------------------------------------------------------ outputs
|
|
174
|
+
def data(self) -> dict:
|
|
175
|
+
"""Everything recorded so far, in the format the dashboard reads (JSON-serialisable)."""
|
|
176
|
+
with self._lock:
|
|
177
|
+
return {"name": self.name, "version": __version__, "created": self.created, "structure": self.structure,
|
|
178
|
+
"records": list(self.records), "snapshots": list(self.snapshots), "insights": list(self.insights)}
|
|
179
|
+
|
|
180
|
+
def save_html(self, path: str) -> str:
|
|
181
|
+
"""Write a standalone, offline dashboard of the whole run (timeline replay). Returns the path."""
|
|
182
|
+
from .export import save_html
|
|
183
|
+
return save_html(self.data(), path)
|
|
184
|
+
|
|
185
|
+
def snapshot(self, path: str, step: Optional[int] = None) -> str:
|
|
186
|
+
"""Save an image of the network structure + loss curve at ``step`` (default: latest). ``.svg`` or ``.png``."""
|
|
187
|
+
from .export import snapshot_image
|
|
188
|
+
return snapshot_image(self.data(), path, step)
|
|
189
|
+
|
|
190
|
+
def show(self, height: int = 820):
|
|
191
|
+
"""Inline the live dashboard in a Jupyter notebook."""
|
|
192
|
+
if self.url is None:
|
|
193
|
+
raise RuntimeError("show() needs the live server: create the Scope with live=True")
|
|
194
|
+
from IPython.display import IFrame
|
|
195
|
+
return IFrame(self.url, width="100%", height=height)
|
|
196
|
+
|
|
197
|
+
def wait(self) -> None:
|
|
198
|
+
"""Keep serving the live dashboard until Ctrl+C (useful at the end of a script)."""
|
|
199
|
+
if self._server is None:
|
|
200
|
+
return
|
|
201
|
+
if not self.quiet:
|
|
202
|
+
print(f"nnscope: serving {self.url} (Ctrl+C to stop)")
|
|
203
|
+
try:
|
|
204
|
+
while True:
|
|
205
|
+
time.sleep(0.5)
|
|
206
|
+
except KeyboardInterrupt:
|
|
207
|
+
pass
|
|
208
|
+
self.close()
|
|
209
|
+
|
|
210
|
+
def close(self) -> None:
|
|
211
|
+
if self._server is not None:
|
|
212
|
+
self._emit([{"type": "end"}])
|
|
213
|
+
time.sleep(0.2)
|
|
214
|
+
self._server.shutdown()
|
|
215
|
+
self._server.server_close()
|
|
216
|
+
self._server = None
|
|
217
|
+
|
|
218
|
+
def __enter__(self):
|
|
219
|
+
return self
|
|
220
|
+
|
|
221
|
+
def __exit__(self, *exc):
|
|
222
|
+
self.close()
|
|
223
|
+
|
|
224
|
+
# ------------------------------------------------------------------ live streaming
|
|
225
|
+
def _emit(self, messages: List[dict]) -> None:
|
|
226
|
+
if not messages or not self._listeners:
|
|
227
|
+
return
|
|
228
|
+
with self._lock:
|
|
229
|
+
for q in list(self._listeners):
|
|
230
|
+
for m in messages:
|
|
231
|
+
q.put(m)
|
nnscope/server.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""A tiny live server (stdlib only): the dashboard page, the run history, and a Server-Sent-Events stream."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
|
|
10
|
+
from .export import render_page
|
|
11
|
+
|
|
12
|
+
PUSH_INTERVAL = 0.1 # seconds: batch messages → at most ~10 pushes per second per browser
|
|
13
|
+
HEARTBEAT = 15.0
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _handler_for(scope):
|
|
17
|
+
class Handler(BaseHTTPRequestHandler):
|
|
18
|
+
def log_message(self, *args): # keep the user's console clean
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def _send(self, body: bytes, ctype: str, status: int = 200):
|
|
22
|
+
self.send_response(status)
|
|
23
|
+
self.send_header("Content-Type", ctype)
|
|
24
|
+
self.send_header("Content-Length", str(len(body)))
|
|
25
|
+
self.send_header("Cache-Control", "no-store")
|
|
26
|
+
self.end_headers()
|
|
27
|
+
self.wfile.write(body)
|
|
28
|
+
|
|
29
|
+
def do_GET(self):
|
|
30
|
+
path = self.path.split("?")[0]
|
|
31
|
+
if path in ("/", "/index.html"):
|
|
32
|
+
self._send(render_page(None, live=True).encode("utf-8"), "text/html; charset=utf-8")
|
|
33
|
+
elif path == "/api/history":
|
|
34
|
+
self._send(json.dumps(scope.data()).encode("utf-8"), "application/json")
|
|
35
|
+
elif path == "/api/stream":
|
|
36
|
+
self._stream()
|
|
37
|
+
else:
|
|
38
|
+
self._send(b"not found", "text/plain", 404)
|
|
39
|
+
|
|
40
|
+
def _stream(self):
|
|
41
|
+
self.send_response(200)
|
|
42
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
43
|
+
self.send_header("Cache-Control", "no-cache")
|
|
44
|
+
self.send_header("Connection", "keep-alive")
|
|
45
|
+
self.end_headers()
|
|
46
|
+
q: queue.Queue = queue.Queue()
|
|
47
|
+
with scope._lock:
|
|
48
|
+
scope._listeners.append(q)
|
|
49
|
+
last = time.time()
|
|
50
|
+
try:
|
|
51
|
+
while True:
|
|
52
|
+
batch = []
|
|
53
|
+
try:
|
|
54
|
+
batch.append(q.get(timeout=1.0))
|
|
55
|
+
while True:
|
|
56
|
+
batch.append(q.get_nowait())
|
|
57
|
+
except queue.Empty:
|
|
58
|
+
pass
|
|
59
|
+
if batch:
|
|
60
|
+
self.wfile.write(f"data: {json.dumps(batch)}\n\n".encode("utf-8"))
|
|
61
|
+
self.wfile.flush()
|
|
62
|
+
last = time.time()
|
|
63
|
+
if any(m.get("type") == "end" for m in batch):
|
|
64
|
+
break
|
|
65
|
+
time.sleep(PUSH_INTERVAL)
|
|
66
|
+
elif time.time() - last > HEARTBEAT:
|
|
67
|
+
self.wfile.write(b": ping\n\n")
|
|
68
|
+
self.wfile.flush()
|
|
69
|
+
last = time.time()
|
|
70
|
+
except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, OSError):
|
|
71
|
+
pass
|
|
72
|
+
finally:
|
|
73
|
+
with scope._lock:
|
|
74
|
+
if q in scope._listeners:
|
|
75
|
+
scope._listeners.remove(q)
|
|
76
|
+
|
|
77
|
+
return Handler
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def start_server(scope, host: str = "127.0.0.1", port: int = 8765):
|
|
81
|
+
"""Start serving in a daemon thread on the first free port ≥ `port` (0 = let the OS choose)."""
|
|
82
|
+
handler = _handler_for(scope)
|
|
83
|
+
last_err = None
|
|
84
|
+
for p in ([0] if port == 0 else list(range(port, port + 20)) + [0]):
|
|
85
|
+
try:
|
|
86
|
+
server = ThreadingHTTPServer((host, p), handler)
|
|
87
|
+
break
|
|
88
|
+
except OSError as err:
|
|
89
|
+
last_err = err
|
|
90
|
+
else:
|
|
91
|
+
raise OSError(f"nnscope: no free port found ({last_err})")
|
|
92
|
+
server.daemon_threads = True
|
|
93
|
+
threading.Thread(target=server.serve_forever, daemon=True, name="nnscope-server").start()
|
|
94
|
+
return server, f"http://{host}:{server.server_address[1]}/"
|