nnscope 0.1.0__tar.gz → 0.1.2__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.
- {nnscope-0.1.0 → nnscope-0.1.2}/PKG-INFO +2 -2
- {nnscope-0.1.0 → nnscope-0.1.2}/README.md +1 -1
- {nnscope-0.1.0 → nnscope-0.1.2}/pyproject.toml +5 -2
- {nnscope-0.1.0 → nnscope-0.1.2}/pyproject.toml.orig +5 -2
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/__init__.py +1 -1
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/export.py +9 -5
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/insights.py +13 -6
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/server.py +13 -2
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/static/index.html +18 -12
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/stats.py +3 -2
- {nnscope-0.1.0 → nnscope-0.1.2}/LICENSE +0 -0
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/adapters.py +0 -0
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/py.typed +0 -0
- {nnscope-0.1.0 → nnscope-0.1.2}/src/nnscope/scope.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nnscope
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Watch your NumPy neural network learn: a live, replayable dashboard for weights, gradients, activations and loss.
|
|
5
5
|
Keywords: neural-network,numpy,visualization,backpropagation,education,deep-learning,dashboard
|
|
6
6
|
Author: michaelks
|
|
@@ -112,7 +112,7 @@ scope.log(step, model=model, loss=loss,
|
|
|
112
112
|
| **Insights** | Plain-language diagnoses, each at the step where it appeared. Click to jump there. | Vanishing/exploding gradients, dead or saturated units, learning rate too high/low, overfitting. |
|
|
113
113
|
| **Gradient flow** | Mean \|∂L/∂W\| per layer, input → output, log scale. | Bars shrinking towards the input = vanishing gradients (e.g. deep sigmoid nets). |
|
|
114
114
|
| **Layer health** | ‖W‖, ‖∇W‖ and the update ratio ‖ΔW‖/‖W‖ per layer over time, plus W and ∇W histograms. | Update ratio ≈ 10⁻³ per SGD step is healthy; ≈ 10⁻¹ is too high. |
|
|
115
|
-
| **Activations** | Per neuron: mean activation and fraction of samples where it is active. |
|
|
115
|
+
| **Activations** | Per neuron: mean activation and fraction of samples where it is active. | A ReLU at 0 % active is *dead*: no gradient flows through it, so its own weights stop learning (it can revive only if earlier layers change). A LeakyReLU at 0 % is just *inactive* and still learns slowly. |
|
|
116
116
|
| **Latent space** | Any layer's features (PCA if wider than 2), coloured by label, along the timeline. | Classes starting mixed and ending linearly separable: the network is building a representation. |
|
|
117
117
|
|
|
118
118
|
Use the **timeline** at the bottom (or ← → and space) to replay training. **Export** saves the network (SVG/PNG), the loss curve,
|
|
@@ -86,7 +86,7 @@ scope.log(step, model=model, loss=loss,
|
|
|
86
86
|
| **Insights** | Plain-language diagnoses, each at the step where it appeared. Click to jump there. | Vanishing/exploding gradients, dead or saturated units, learning rate too high/low, overfitting. |
|
|
87
87
|
| **Gradient flow** | Mean \|∂L/∂W\| per layer, input → output, log scale. | Bars shrinking towards the input = vanishing gradients (e.g. deep sigmoid nets). |
|
|
88
88
|
| **Layer health** | ‖W‖, ‖∇W‖ and the update ratio ‖ΔW‖/‖W‖ per layer over time, plus W and ∇W histograms. | Update ratio ≈ 10⁻³ per SGD step is healthy; ≈ 10⁻¹ is too high. |
|
|
89
|
-
| **Activations** | Per neuron: mean activation and fraction of samples where it is active. |
|
|
89
|
+
| **Activations** | Per neuron: mean activation and fraction of samples where it is active. | A ReLU at 0 % active is *dead*: no gradient flows through it, so its own weights stop learning (it can revive only if earlier layers change). A LeakyReLU at 0 % is just *inactive* and still learns slowly. |
|
|
90
90
|
| **Latent space** | Any layer's features (PCA if wider than 2), coloured by label, along the timeline. | Classes starting mixed and ending linearly separable: the network is building a representation. |
|
|
91
91
|
|
|
92
92
|
Use the **timeline** at the bottom (or ← → and space) to replay training. **Export** saves the network (SVG/PNG), the loss curve,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nnscope"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Watch your NumPy neural network learn: a live, replayable dashboard for weights, gradients, activations and loss."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -40,7 +40,10 @@ Issues = "https://github.com/Ashurali/nnscope/issues"
|
|
|
40
40
|
Changelog = "https://github.com/Ashurali/nnscope/blob/master/CHANGELOG.md"
|
|
41
41
|
|
|
42
42
|
[dependency-groups]
|
|
43
|
-
dev = [
|
|
43
|
+
dev = [
|
|
44
|
+
"matplotlib>=3.9.4",
|
|
45
|
+
"pytest>=7",
|
|
46
|
+
]
|
|
44
47
|
|
|
45
48
|
[build-system]
|
|
46
49
|
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nnscope"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.2"
|
|
4
4
|
description = "Watch your NumPy neural network learn: a live, replayable dashboard for weights, gradients, activations and loss."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -33,7 +33,10 @@ Issues = "https://github.com/Ashurali/nnscope/issues"
|
|
|
33
33
|
Changelog = "https://github.com/Ashurali/nnscope/blob/master/CHANGELOG.md"
|
|
34
34
|
|
|
35
35
|
[dependency-groups]
|
|
36
|
-
dev = [
|
|
36
|
+
dev = [
|
|
37
|
+
"matplotlib>=3.9.4",
|
|
38
|
+
"pytest>=7",
|
|
39
|
+
]
|
|
37
40
|
|
|
38
41
|
[build-system]
|
|
39
42
|
requires = ["uv_build>=0.12.3,<0.13.0"]
|
|
@@ -134,9 +134,10 @@ def snapshot_image(data: dict, path: str, step: Optional[int] = None) -> str:
|
|
|
134
134
|
if p.suffix.lower() != ".png":
|
|
135
135
|
raise ValueError("snapshot path must end in .svg or .png")
|
|
136
136
|
try:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
137
|
+
# A standalone Figure + Agg canvas: never touches pyplot's global state or the user's backend
|
|
138
|
+
# (switching the backend would silently break inline plots in a Jupyter notebook).
|
|
139
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
140
|
+
from matplotlib.figure import Figure
|
|
140
141
|
from matplotlib.patches import PathPatch
|
|
141
142
|
from matplotlib.path import Path as MPath
|
|
142
143
|
except ImportError:
|
|
@@ -144,7 +145,9 @@ def snapshot_image(data: dict, path: str, step: Optional[int] = None) -> str:
|
|
|
144
145
|
structure, snap = data.get("structure"), _pick_snapshot(data, step)
|
|
145
146
|
if structure is None or snap is None:
|
|
146
147
|
raise ValueError("nothing to draw yet: call scope.log(...) at least once")
|
|
147
|
-
fig
|
|
148
|
+
fig = Figure(figsize=(13, 5.4))
|
|
149
|
+
FigureCanvasAgg(fig)
|
|
150
|
+
ax, bx = fig.subplots(1, 2, gridspec_kw={"width_ratios": [1.7, 1]})
|
|
148
151
|
pos, xs = _layout(structure, 0.0, 1.0, 0.0, 1.0)
|
|
149
152
|
for l, Wl in enumerate(snap["W"]):
|
|
150
153
|
m = max((abs(v) for row in Wl for v in row), default=1.0) or 1.0
|
|
@@ -164,5 +167,6 @@ def snapshot_image(data: dict, path: str, step: Optional[int] = None) -> str:
|
|
|
164
167
|
for name, ser in _loss_series(data).items():
|
|
165
168
|
bx.plot([s for s, _ in ser], [v for _, v in ser], label=name)
|
|
166
169
|
bx.set_xlabel("step"); bx.set_title("loss & metrics"); bx.grid(alpha=0.3); bx.legend()
|
|
167
|
-
fig.tight_layout()
|
|
170
|
+
fig.tight_layout()
|
|
171
|
+
fig.savefig(p, dpi=150)
|
|
168
172
|
return str(p)
|
|
@@ -83,12 +83,19 @@ class InsightEngine:
|
|
|
83
83
|
kind = l.get("dead_kind") or "dead"
|
|
84
84
|
if dead is None or n <= 1:
|
|
85
85
|
continue
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
86
|
+
if kind == "saturated":
|
|
87
|
+
why = ("(stuck near ±1, so their gradient ≈ 0). Inputs to this layer are too large; normalise the inputs or use "
|
|
88
|
+
"smaller initial weights.")
|
|
89
|
+
elif kind == "inactive":
|
|
90
|
+
why = ("(never positive). Unlike ReLU they still pass a small gradient (α·δ), so they can recover, but slowly.")
|
|
91
|
+
else:
|
|
92
|
+
why = ("(never positive, so no gradient flows through them and their own weights stop learning). "
|
|
93
|
+
+ ("In the first layer the inputs never change, so they will not come back. " if k == 0 else
|
|
94
|
+
"They can only come back if the earlier layers' outputs change. ")
|
|
95
|
+
+ "Typical causes: too large first updates (lower the learning rate or warm it up) or poor init; "
|
|
96
|
+
"LeakyReLU keeps a small gradient flowing.")
|
|
97
|
+
self._check(f"{kind}:{k}", dead / n >= 0.5, step, "warn", "activations",
|
|
98
|
+
f"{dead} of {n} {act} units in {names[k]} are {kind} on the logged batch {why}", out)
|
|
92
99
|
|
|
93
100
|
test_key = next((k for k in scalars if ("test" in k or "val" in k) and "loss" in k), None)
|
|
94
101
|
if test_key and loss is not None and scalars[test_key] is not None and np.isfinite(scalars[test_key]):
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
|
|
4
4
|
import json
|
|
5
5
|
import queue
|
|
6
|
+
import sys
|
|
6
7
|
import threading
|
|
7
8
|
import time
|
|
8
9
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
@@ -13,6 +14,17 @@ PUSH_INTERVAL = 0.1 # seconds: batch messages → at most ~10 pushes per se
|
|
|
13
14
|
HEARTBEAT = 15.0
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
class _QuietServer(ThreadingHTTPServer):
|
|
18
|
+
"""Don't print a traceback when a browser closes a tab or reloads mid-request: that is normal, not an error."""
|
|
19
|
+
|
|
20
|
+
daemon_threads = True
|
|
21
|
+
|
|
22
|
+
def handle_error(self, request, client_address):
|
|
23
|
+
if isinstance(sys.exc_info()[1], (ConnectionError, TimeoutError)):
|
|
24
|
+
return
|
|
25
|
+
super().handle_error(request, client_address)
|
|
26
|
+
|
|
27
|
+
|
|
16
28
|
def _handler_for(scope):
|
|
17
29
|
class Handler(BaseHTTPRequestHandler):
|
|
18
30
|
def log_message(self, *args): # keep the user's console clean
|
|
@@ -83,12 +95,11 @@ def start_server(scope, host: str = "127.0.0.1", port: int = 8765):
|
|
|
83
95
|
last_err = None
|
|
84
96
|
for p in ([0] if port == 0 else list(range(port, port + 20)) + [0]):
|
|
85
97
|
try:
|
|
86
|
-
server =
|
|
98
|
+
server = _QuietServer((host, p), handler)
|
|
87
99
|
break
|
|
88
100
|
except OSError as err:
|
|
89
101
|
last_err = err
|
|
90
102
|
else:
|
|
91
103
|
raise OSError(f"nnscope: no free port found ({last_err})")
|
|
92
|
-
server.daemon_threads = True
|
|
93
104
|
threading.Thread(target=server.serve_forever, daemon=True, name="nnscope-server").start()
|
|
94
105
|
return server, f"http://{host}:{server.server_address[1]}/"
|
|
@@ -212,9 +212,11 @@
|
|
|
212
212
|
<div class="help" id="help-acts">
|
|
213
213
|
<p>For each hidden layer, one cell per drawn neuron: the top strip is its <b>mean activation</b> on the logged batch, the bottom strip the
|
|
214
214
|
<b>fraction of samples where it is active</b> (ReLU: output > 0; tanh/sigmoid: not saturated).</p>
|
|
215
|
-
<p>A
|
|
216
|
-
<
|
|
217
|
-
|
|
215
|
+
<p>A ReLU at 0 % active is <b style="color:var(--bad)">dead</b>: no gradient flows through it, so its own weights stop learning.
|
|
216
|
+
It can only come back if its <i>inputs</i> change, which is impossible in the first layer, where the inputs are the data. A LeakyReLU
|
|
217
|
+
unit at 0 % is merely <b>inactive</b>: it still passes the small gradient α·δ and can recover. A tanh/sigmoid unit stuck near ±1 is
|
|
218
|
+
<b style="color:var(--bad)">saturated</b> (gradient ≈ 0). Activation stats need activations: they are read automatically from layers that
|
|
219
|
+
cache their input (e.g. <code>Linear.x</code>), or pass <code>activations=[a1, a2, …]</code>.</p></div>
|
|
218
220
|
<div id="acts"></div>
|
|
219
221
|
</section>
|
|
220
222
|
|
|
@@ -584,9 +586,11 @@ function drawLoss() {
|
|
|
584
586
|
}
|
|
585
587
|
if (!names.length) { ctx.fillStyle = css("--faint"); ctx.font = "13px system-ui"; ctx.textAlign = "center"; ctx.fillText("waiting for scope.log(..., loss=...)", w / 2, h / 2); return; }
|
|
586
588
|
const shown = names.filter((n) => !UI.hidden.has(n));
|
|
587
|
-
const
|
|
589
|
+
const useLog = UI.logScale && shown.some((n) => S[n].some(([, v]) => v > 0));
|
|
590
|
+
// On a log scale, points ≤ 0 (e.g. a training error that reaches exactly 0) are skipped instead of forcing linear.
|
|
591
|
+
const pts = (n) => (useLog ? S[n].filter(([, v]) => v > 0) : S[n]);
|
|
592
|
+
const all = shown.flatMap(pts);
|
|
588
593
|
if (!all.length) return;
|
|
589
|
-
const useLog = UI.logScale && all.every(([, v]) => v > 0);
|
|
590
594
|
const f = useLog ? Math.log10 : (v) => v;
|
|
591
595
|
let lo = Math.min(...all.map(([, v]) => f(v))), hi = Math.max(...all.map(([, v]) => f(v)));
|
|
592
596
|
if (hi - lo < 1e-9) { lo -= 0.5; hi += 0.5; }
|
|
@@ -603,7 +607,7 @@ function drawLoss() {
|
|
|
603
607
|
names.forEach((n, k) => {
|
|
604
608
|
if (UI.hidden.has(n)) return;
|
|
605
609
|
ctx.strokeStyle = k ? SERIES[k % SERIES.length] : css("--ink"); ctx.lineWidth = 1.8; ctx.beginPath();
|
|
606
|
-
|
|
610
|
+
pts(n).forEach(([s, v], i) => (i ? ctx.lineTo(X(s), Y(v)) : ctx.moveTo(X(s), Y(v)))); ctx.stroke();
|
|
607
611
|
});
|
|
608
612
|
const cs = curSnap();
|
|
609
613
|
if (cs) { ctx.strokeStyle = css("--warn"); ctx.setLineDash([4, 3]); ctx.beginPath(); ctx.moveTo(X(cs.step), P.t); ctx.lineTo(X(cs.step), h - P.b); ctx.stroke(); ctx.setLineDash([]); }
|
|
@@ -730,7 +734,7 @@ function drawActs() {
|
|
|
730
734
|
box.innerHTML = rows.map((r) => `<div class="act-row"><div class="nm" id="actnm${r.k}"></div><canvas data-a="${r.k}"></canvas></div>`).join("");
|
|
731
735
|
}
|
|
732
736
|
rows.forEach(({ l, k, nd, rec }) => {
|
|
733
|
-
const dead = rec && rec.dead ? rec.dead : 0, kind = rec && rec.dead_kind
|
|
737
|
+
const dead = rec && rec.dead ? rec.dead : 0, kind = (rec && rec.dead_kind) || "dead";
|
|
734
738
|
$("actnm" + k).innerHTML = `<b>layer ${k + 1}</b> · ${l.act || "linear"} · ${l.out} units` +
|
|
735
739
|
(rec && rec.frac_active !== undefined && rec.frac_active !== null ? ` · ${Math.round(rec.frac_active * 100)}% active on average` : "") +
|
|
736
740
|
(dead ? ` · <span class="dead">${dead} ${kind}</span>` : "");
|
|
@@ -757,7 +761,7 @@ function drawLatent() {
|
|
|
757
761
|
cv.style.display = s ? "" : "none";
|
|
758
762
|
if (!s) return;
|
|
759
763
|
const { ctx, w, h } = setupCanvas(cv, Math.min(420, Math.max(260, cv.parentElement.clientWidth * 0.75)));
|
|
760
|
-
const { xy, labels, explained, dim } = s.latent, P = { l: 34, r: 10, t:
|
|
764
|
+
const { xy, labels, explained, dim } = s.latent, P = { l: 34, r: 10, t: 22, b: 30 };
|
|
761
765
|
const xs = xy.map((p) => p[0]), ys = xy.map((p) => p[1]);
|
|
762
766
|
const pad = (a, b) => { const d = (b - a) || 1; return [a - 0.06 * d, b + 0.06 * d]; };
|
|
763
767
|
const [x0, x1] = pad(Math.min(...xs), Math.max(...xs)), [y0, y1] = pad(Math.min(...ys), Math.max(...ys));
|
|
@@ -775,11 +779,13 @@ function drawLatent() {
|
|
|
775
779
|
const ax = explained ? [`PC1 (${Math.round(explained[0] * 100)}% var)`, `PC2 (${Math.round(explained[1] * 100)}% var)`] : ["z1", "z2"];
|
|
776
780
|
ctx.fillText(`${ax[0]} · ${dim}-D features · step ${s.step}`, P.l + (w - P.l - P.r) / 2, h - 8);
|
|
777
781
|
ctx.save(); ctx.translate(12, P.t + (h - P.t - P.b) / 2); ctx.rotate(-Math.PI / 2); ctx.fillText(ax[1], 0, 0); ctx.restore();
|
|
778
|
-
if (cats) {
|
|
782
|
+
if (cats) { // legend outside the plot area, above it, so it never hides points
|
|
779
783
|
const uniq = [...new Set(labels)].sort((a, b) => a - b);
|
|
780
|
-
ctx.textAlign = "left";
|
|
781
|
-
uniq.forEach((u
|
|
782
|
-
ctx.fillStyle =
|
|
784
|
+
ctx.textAlign = "left"; let lx = P.l;
|
|
785
|
+
uniq.forEach((u) => {
|
|
786
|
+
ctx.fillStyle = PALETTE[(+u) % PALETTE.length]; ctx.fillRect(lx, 4, 10, 10);
|
|
787
|
+
ctx.fillStyle = css("--ink"); ctx.fillText(`class ${+u}`, lx + 14, 13); lx += ctx.measureText(`class ${+u}`).width + 30;
|
|
788
|
+
});
|
|
783
789
|
}
|
|
784
790
|
}
|
|
785
791
|
|
|
@@ -64,7 +64,8 @@ def layer_numbers(W: np.ndarray, dW: Optional[np.ndarray], prev_W: Optional[np.n
|
|
|
64
64
|
def activation_numbers(A: Optional[np.ndarray], act: Optional[str]) -> Optional[dict]:
|
|
65
65
|
"""Per-neuron statistics of a batch of activations A (batch, units).
|
|
66
66
|
|
|
67
|
-
* relu-like: a unit is *active* when its output > 0; a unit never active on the batch is *dead
|
|
67
|
+
* relu-like: a unit is *active* when its output > 0; a unit never active on the batch is *dead* (no gradient flows
|
|
68
|
+
through it). For leaky_relu such a unit is only *inactive*: it still passes the small gradient α·δ.
|
|
68
69
|
* tanh / sigmoid: a unit is *active* when it is not saturated; one saturated on every sample is flagged *saturated*.
|
|
69
70
|
"""
|
|
70
71
|
if A is None:
|
|
@@ -79,7 +80,7 @@ def activation_numbers(A: Optional[np.ndarray], act: Optional[str]) -> Optional[
|
|
|
79
80
|
kind = "saturated"
|
|
80
81
|
else:
|
|
81
82
|
active = A > 0 if act in (None, "relu", "leaky_relu", "elu", "gelu", "swish", "softplus") else np.abs(A) > 1e-8
|
|
82
|
-
kind = "dead"
|
|
83
|
+
kind = "inactive" if act == "leaky_relu" else "dead"
|
|
83
84
|
frac = active.mean(axis=0)
|
|
84
85
|
dead = int(np.sum(frac == 0))
|
|
85
86
|
return {
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|