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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nnscope
3
- Version: 0.1.0
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. | Neurons at 0 % active pass no gradient back and have stopped learning. |
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. | Neurons at 0 % active pass no gradient back and have stopped learning. |
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.0"
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 = ["pytest>=7"]
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.0"
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 = ["pytest>=7"]
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"]
@@ -4,7 +4,7 @@
4
4
  scope = nnscope.Scope("my-mlp")
5
5
  scope.log(step, model=model, loss=loss)
6
6
  """
7
- __version__ = "0.1.0"
7
+ __version__ = "0.1.2"
8
8
 
9
9
  from .adapters import LayerView, extract_layers # noqa: E402
10
10
  from .scope import Scope # noqa: E402
@@ -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
- import matplotlib
138
- matplotlib.use("Agg")
139
- import matplotlib.pyplot as plt
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, (ax, bx) = plt.subplots(1, 2, figsize=(13, 5.4), gridspec_kw={"width_ratios": [1.7, 1]})
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(); fig.savefig(p, dpi=150); plt.close(fig)
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
- word = "dead" if kind == "dead" else "saturated"
87
- self._check(f"{word}:{k}", dead / n >= 0.5, step, "warn", "activations",
88
- f"{dead} of {n} {act} units in {names[k]} are {word} on the logged batch "
89
- + ("(never positive → zero gradient forever). Lower the learning rate, use He init or LeakyReLU."
90
- if word == "dead" else "(stuck near ±1 → gradient ≈ 0). Inputs to this layer are too large; "
91
- "normalise inputs or use smaller initial weights."), out)
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 = ThreadingHTTPServer((host, p), handler)
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 &gt; 0; tanh/sigmoid: not saturated).</p>
215
- <p>A neuron with 0 % active passes no gradient back, so it has stopped learning (<b style="color:var(--bad)">dead ReLU</b> or
216
- <b style="color:var(--bad)">saturated</b> unit). Activation stats need activations: they are read automatically from layers that cache
217
- their input (e.g. <code>Linear.x</code>), or pass <code>activations=[a1, a2, …]</code>.</p></div>
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 all = shown.flatMap((n) => S[n]);
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
- S[n].forEach(([s, v], i) => (i ? ctx.lineTo(X(s), Y(v)) : ctx.moveTo(X(s), Y(v)))); ctx.stroke();
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 === "saturated" ? "saturated" : "dead";
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: 10, b: 30 };
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, k) => { ctx.fillStyle = PALETTE[(+u) % PALETTE.length]; ctx.fillRect(P.l + 8, P.t + 8 + k * 16, 10, 10);
782
- ctx.fillStyle = css("--ink"); ctx.fillText(`class ${+u}`, P.l + 24, P.t + 17 + k * 16); });
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