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/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]}/"