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 ADDED
@@ -0,0 +1,12 @@
1
+ """nnscope: watch your NumPy neural network learn.
2
+
3
+ import nnscope
4
+ scope = nnscope.Scope("my-mlp")
5
+ scope.log(step, model=model, loss=loss)
6
+ """
7
+ __version__ = "0.1.0"
8
+
9
+ from .adapters import LayerView, extract_layers # noqa: E402
10
+ from .scope import Scope # noqa: E402
11
+
12
+ __all__ = ["Scope", "extract_layers", "LayerView", "__version__"]
nnscope/adapters.py ADDED
@@ -0,0 +1,233 @@
1
+ """Turn "whatever the user has" into a uniform list of dense layers.
2
+
3
+ nnscope never runs your network. It only *reads* arrays you already have: weights, biases, their gradients and
4
+ (optionally) activations. This module finds them, either from explicit lists or by duck-typing common NumPy model
5
+ layouts, and normalises every weight matrix to the ``(in_features, out_features)`` orientation used for ``x @ W``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from dataclasses import dataclass
11
+ from typing import Any, List, Optional, Sequence
12
+
13
+ import numpy as np
14
+
15
+ WEIGHT_ATTRS = ("W", "weight", "weights", "kernel", "w")
16
+ BIAS_ATTRS = ("b", "bias", "biases")
17
+ WEIGHT_GRAD_ATTRS = ("dW", "grad_W", "W_grad", "dweight", "grad_weight", "weight_grad", "dw")
18
+ BIAS_GRAD_ATTRS = ("db", "grad_b", "b_grad", "dbias", "grad_bias", "bias_grad")
19
+ INPUT_CACHE_ATTRS = ("x", "input", "inputs", "a_prev", "x_cache", "cache_x")
20
+ ACTIVATION_NAMES = {
21
+ "relu": "relu", "leakyrelu": "leaky_relu", "tanh": "tanh", "sigmoid": "sigmoid", "logistic": "sigmoid",
22
+ "softmax": "softmax", "elu": "elu", "gelu": "gelu", "softplus": "softplus", "sine": "sine", "sin": "sine",
23
+ "linear": "linear", "identity": "linear", "swish": "swish", "silu": "swish",
24
+ }
25
+
26
+ HELP = """nnscope could not find dense-layer weights. Pass them explicitly, for example
27
+
28
+ scope.log(step, weights=[W1, W2], biases=[b1, b2], grads=[dW1, dW2], loss=loss)
29
+
30
+ or give a model object with a `.layers` list whose layers expose `W`/`b` (and `dW`/`db`),
31
+ a `.params` dict like {"W1": ..., "b1": ...}, or a list of (W, b) pairs."""
32
+
33
+
34
+ @dataclass
35
+ class LayerView:
36
+ """One dense layer, normalised so that ``W.shape == (in_features, out_features)``."""
37
+
38
+ W: np.ndarray
39
+ b: Optional[np.ndarray] = None
40
+ dW: Optional[np.ndarray] = None
41
+ db: Optional[np.ndarray] = None
42
+ act: Optional[str] = None # activation applied to this layer's output ("relu", "tanh", ...)
43
+ name: str = ""
44
+ source: Any = None # the original layer object, if any (used to find cached inputs)
45
+
46
+ @property
47
+ def in_features(self) -> int:
48
+ return int(self.W.shape[0])
49
+
50
+ @property
51
+ def out_features(self) -> int:
52
+ return int(self.W.shape[1])
53
+
54
+
55
+ def _arr(x: Any) -> Optional[np.ndarray]:
56
+ return np.asarray(x) if isinstance(x, np.ndarray) or (x is not None and hasattr(x, "__array__")) else None
57
+
58
+
59
+ def _first_attr(obj: Any, names: Sequence[str], ndim: Optional[int] = None) -> Optional[np.ndarray]:
60
+ for n in names:
61
+ v = getattr(obj, n, None)
62
+ a = _arr(v) if v is not None else None
63
+ if a is not None and a.dtype.kind in "fiu" and (ndim is None or a.ndim == ndim):
64
+ return a
65
+ return None
66
+
67
+
68
+ def activation_name(obj: Any) -> Optional[str]:
69
+ """Recognise an activation layer by its class name (ReLU, Tanh, Sigmoid, ...)."""
70
+ key = re.sub(r"[^a-z]", "", type(obj).__name__.lower())
71
+ return ACTIVATION_NAMES.get(key)
72
+
73
+
74
+ # ------------------------------------------------------------------ collection from different model layouts
75
+ def _from_layer_objects(layers: Sequence[Any]) -> List[LayerView]:
76
+ views: List[LayerView] = []
77
+ for obj in layers:
78
+ if hasattr(obj, "layers") and not isinstance(obj, (list, tuple)): # nested Sequential
79
+ views.extend(_from_layer_objects(list(obj.layers)))
80
+ continue
81
+ W = _first_attr(obj, WEIGHT_ATTRS, ndim=2)
82
+ if W is not None:
83
+ views.append(LayerView(
84
+ W=W, b=_first_attr(obj, BIAS_ATTRS), dW=_first_attr(obj, WEIGHT_GRAD_ATTRS, ndim=2),
85
+ db=_first_attr(obj, BIAS_GRAD_ATTRS), name=type(obj).__name__, source=obj))
86
+ continue
87
+ act = activation_name(obj)
88
+ if act and views and views[-1].act is None:
89
+ views[-1].act = act
90
+ return views
91
+
92
+
93
+ _KEY = re.compile(r"^(?P<kind>[A-Za-z]+)[_.]?(?P<idx>\d+)$")
94
+
95
+
96
+ def _from_dict(params: dict, grads: Optional[dict] = None) -> List[LayerView]:
97
+ """CS231n-style dicts: {"W1": ..., "b1": ..., "W2": ...} (+ an optional grads dict with the same keys)."""
98
+ layers: dict = {}
99
+ for key, val in params.items():
100
+ m = _KEY.match(str(key))
101
+ if not m:
102
+ continue
103
+ kind, idx = m.group("kind"), int(m.group("idx"))
104
+ slot = layers.setdefault(idx, {})
105
+ if kind in WEIGHT_ATTRS:
106
+ slot["W"], slot["kW"] = np.asarray(val), key
107
+ elif kind in BIAS_ATTRS:
108
+ slot["b"], slot["kb"] = np.asarray(val), key
109
+ views = []
110
+ for idx in sorted(layers):
111
+ slot = layers[idx]
112
+ if "W" not in slot or slot["W"].ndim != 2:
113
+ continue
114
+ dW = _arr(grads.get(slot["kW"])) if grads else None
115
+ db = _arr(grads.get(slot.get("kb"))) if grads and slot.get("kb") else None
116
+ views.append(LayerView(W=slot["W"], b=slot.get("b"), dW=dW, db=db, name=f"layer {idx}"))
117
+ return views
118
+
119
+
120
+ def _from_sequence(items: Sequence[Any]) -> List[LayerView]:
121
+ """A list of arrays ([W1, b1, W2, b2] or [W1, W2]), of (W, b) pairs, or of layer objects."""
122
+ if items and not any(isinstance(it, (np.ndarray, tuple, list)) for it in items):
123
+ return _from_layer_objects(items)
124
+ views: List[LayerView] = []
125
+ for it in items:
126
+ if isinstance(it, (tuple, list)):
127
+ W = np.asarray(it[0])
128
+ b = np.asarray(it[1]) if len(it) > 1 and it[1] is not None else None
129
+ views.append(LayerView(W=W, b=b))
130
+ else:
131
+ a = np.asarray(it)
132
+ if a.ndim == 2:
133
+ views.append(LayerView(W=a))
134
+ elif a.ndim == 1 and views and views[-1].b is None:
135
+ views[-1].b = a
136
+ return views
137
+
138
+
139
+ # ------------------------------------------------------------------ orientation
140
+ def _chains(mats: List[np.ndarray], in_out: bool) -> bool:
141
+ if in_out:
142
+ return all(mats[k].shape[1] == mats[k + 1].shape[0] for k in range(len(mats) - 1))
143
+ return all(mats[k].shape[0] == mats[k + 1].shape[1] for k in range(len(mats) - 1))
144
+
145
+
146
+ def detect_layout(views: List[LayerView]) -> str:
147
+ """Return "in_out" (x @ W, W is (in, out)) or "out_in" (W @ x, W is (out, in))."""
148
+ mats = [v.W for v in views]
149
+ if len(mats) >= 2:
150
+ io, oi = _chains(mats, True), _chains(mats, False)
151
+ if io and not oi:
152
+ return "in_out"
153
+ if oi and not io:
154
+ return "out_in"
155
+ votes = 0 # ambiguous → let the biases decide
156
+ for v in views:
157
+ if v.b is not None and v.W.shape[0] != v.W.shape[1]:
158
+ n = v.b.size
159
+ votes += (n == v.W.shape[1]) - (n == v.W.shape[0])
160
+ return "out_in" if votes < 0 else "in_out"
161
+
162
+
163
+ def normalise(views: List[LayerView], layout: str = "auto") -> List[LayerView]:
164
+ if layout not in ("auto", "in_out", "out_in"):
165
+ raise ValueError('layout must be "auto", "in_out" or "out_in"')
166
+ if layout == "auto":
167
+ layout = detect_layout(views)
168
+ if layout == "out_in":
169
+ for v in views:
170
+ v.W = v.W.T
171
+ if v.dW is not None:
172
+ v.dW = v.dW.T
173
+ for k, v in enumerate(views):
174
+ if not v.name:
175
+ v.name = f"layer {k + 1}"
176
+ for attr in ("b", "db"):
177
+ a = getattr(v, attr)
178
+ if a is not None:
179
+ setattr(v, attr, np.asarray(a).reshape(-1))
180
+ if v.dW is not None and v.dW.shape != v.W.shape:
181
+ v.dW = v.dW.T if v.dW.T.shape == v.W.shape else None
182
+ return views
183
+
184
+
185
+ # ------------------------------------------------------------------ public entry point
186
+ def extract_layers(model: Any = None, *, weights: Optional[Sequence] = None, biases: Optional[Sequence] = None,
187
+ grads: Any = None, bias_grads: Optional[Sequence] = None, layout: str = "auto") -> List[LayerView]:
188
+ """Collect dense layers from ``model`` or from explicit arrays. Raises ``TypeError`` with usage help if none are found."""
189
+ if weights is not None:
190
+ views = [LayerView(W=np.asarray(W)) for W in weights]
191
+ for k, v in enumerate(views):
192
+ if biases is not None and k < len(biases) and biases[k] is not None:
193
+ v.b = np.asarray(biases[k])
194
+ if isinstance(grads, (list, tuple)) and k < len(grads) and grads[k] is not None:
195
+ v.dW = np.asarray(grads[k])
196
+ if bias_grads is not None and k < len(bias_grads) and bias_grads[k] is not None:
197
+ v.db = np.asarray(bias_grads[k])
198
+ elif model is None:
199
+ raise TypeError(HELP)
200
+ elif hasattr(model, "layers") and not isinstance(model, dict):
201
+ views = _from_layer_objects(list(model.layers))
202
+ elif isinstance(getattr(model, "params", None), dict):
203
+ g = grads if isinstance(grads, dict) else getattr(model, "grads", None)
204
+ views = _from_dict(model.params, g if isinstance(g, dict) else None)
205
+ elif isinstance(model, dict):
206
+ views = _from_dict(model, grads if isinstance(grads, dict) else None)
207
+ elif isinstance(model, (list, tuple)):
208
+ views = _from_sequence(model)
209
+ if isinstance(grads, (list, tuple)):
210
+ for v, g in zip(views, grads):
211
+ v.dW = np.asarray(g) if g is not None else None
212
+ else:
213
+ raise TypeError(HELP)
214
+ views = [v for v in views if v.W.ndim == 2]
215
+ if not views:
216
+ raise TypeError(HELP)
217
+ return normalise(views, layout)
218
+
219
+
220
+ def cached_activations(views: List[LayerView]) -> List[Optional[np.ndarray]]:
221
+ """Best-effort: the output of hidden layer k is the cached *input* of layer k+1 (e.g. HW-style ``Linear.x``).
222
+
223
+ Returns one entry per layer (the last one, the network output, is usually unknown → None).
224
+ """
225
+ out: List[Optional[np.ndarray]] = [None] * len(views)
226
+ for k in range(len(views) - 1):
227
+ nxt = views[k + 1].source
228
+ if nxt is None:
229
+ continue
230
+ x = _first_attr(nxt, INPUT_CACHE_ATTRS)
231
+ if x is not None and x.ndim == 2 and x.shape[1] == views[k].out_features:
232
+ out[k] = x
233
+ return out
nnscope/export.py ADDED
@@ -0,0 +1,168 @@
1
+ """Standalone HTML replay and static images (SVG without dependencies, PNG via matplotlib if installed)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ BLUE, ORANGE, MID = (8, 119, 189), (245, 147, 34), (232, 234, 235)
10
+ BOOT_MARKER = "/*__NNSCOPE_BOOT__*/"
11
+
12
+
13
+ def _template() -> str:
14
+ try:
15
+ from importlib.resources import files
16
+ return files("nnscope").joinpath("static/index.html").read_text(encoding="utf-8")
17
+ except (ImportError, AttributeError): # Python < 3.9 fallback
18
+ return (Path(__file__).parent / "static" / "index.html").read_text(encoding="utf-8")
19
+
20
+
21
+ def render_page(data: Optional[dict], live: bool) -> str:
22
+ boot = {"live": live, "data": data}
23
+ payload = json.dumps(boot, separators=(",", ":")).replace("</", "<\\/")
24
+ return _template().replace(BOOT_MARKER, f"window.NNSCOPE = {payload};", 1)
25
+
26
+
27
+ def save_html(data: dict, path: str) -> str:
28
+ p = Path(path)
29
+ p.parent.mkdir(parents=True, exist_ok=True)
30
+ p.write_text(render_page(data, live=False), encoding="utf-8")
31
+ return str(p)
32
+
33
+
34
+ # ------------------------------------------------------------------ static snapshot image
35
+ def _color(v: float) -> str:
36
+ t = max(-1.0, min(1.0, v))
37
+ c = BLUE if t >= 0 else ORANGE
38
+ a = abs(t)
39
+ return "#%02x%02x%02x" % tuple(int(MID[i] + (c[i] - MID[i]) * a) for i in range(3))
40
+
41
+
42
+ def _pick_snapshot(data: dict, step: Optional[int]) -> Optional[dict]:
43
+ snaps = data.get("snapshots") or []
44
+ if not snaps:
45
+ return None
46
+ if step is None:
47
+ return snaps[-1]
48
+ return min(snaps, key=lambda s: abs(s["step"] - step))
49
+
50
+
51
+ def _layout(structure: dict, x0: float, x1: float, y0: float, y1: float):
52
+ layers = structure["layers"]
53
+ cols = [layers[0]["shown_in"]] + [l["shown_out"] for l in layers]
54
+ xs = [x0 + (x1 - x0) * c / max(1, len(cols) - 1) for c in range(len(cols))]
55
+ pos = []
56
+ for c, shown in enumerate(cols):
57
+ n = len(shown)
58
+ gap = min(24.0, (y1 - y0) / max(n, 1))
59
+ top = (y0 + y1) / 2 - gap * (n - 1) / 2
60
+ pos.append([(xs[c], top + gap * j) for j in range(n)])
61
+ return pos, xs
62
+
63
+
64
+ def _loss_series(data: dict):
65
+ series = {}
66
+ for r in data.get("records", []):
67
+ for k, v in r["scalars"].items():
68
+ if v is not None and math.isfinite(v):
69
+ series.setdefault(k, []).append((r["step"], v))
70
+ return series
71
+
72
+
73
+ def snapshot_svg(data: dict, step: Optional[int] = None) -> str:
74
+ structure, snap = data.get("structure"), _pick_snapshot(data, step)
75
+ if structure is None or snap is None:
76
+ raise ValueError("nothing to draw yet: call scope.log(...) at least once")
77
+ W, H = 1100, 480
78
+ out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" '
79
+ 'font-family="Helvetica, Arial, sans-serif" font-size="12">',
80
+ f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
81
+ f'<text x="24" y="28" font-size="16" fill="#222">{data.get("name", "run")} — step {snap["step"]}</text>']
82
+ pos, xs = _layout(structure, 70, 650, 70, H - 50)
83
+ for l, Wl in enumerate(snap["W"]):
84
+ m = max((abs(v) for row in Wl for v in row), default=1.0) or 1.0
85
+ for i, row in enumerate(Wl):
86
+ for j, w in enumerate(row):
87
+ (xa, ya), (xb, yb) = pos[l][i], pos[l + 1][j]
88
+ xm = (xa + xb) / 2
89
+ out.append(f'<path d="M{xa:.1f},{ya:.1f} C{xm:.1f},{ya:.1f} {xm:.1f},{yb:.1f} {xb:.1f},{yb:.1f}" fill="none" '
90
+ f'stroke="{_color(1 if w >= 0 else -1)}" stroke-opacity="{0.15 + 0.85 * abs(w) / m:.2f}" '
91
+ f'stroke-width="{0.3 + 3.5 * abs(w) / m:.2f}"/>')
92
+ for c, col in enumerate(pos):
93
+ node = snap["nodes"][c - 1] if c > 0 and c - 1 < len(snap["nodes"]) else None
94
+ for j, (x, y) in enumerate(col):
95
+ v = node["mean"][j] if node else 0.0
96
+ out.append(f'<circle cx="{x:.1f}" cy="{y:.1f}" r="6.5" fill="{_color(v)}" stroke="#555" stroke-width="0.8"/>')
97
+ layer = structure["layers"][max(c - 1, 0)]
98
+ n = layer["in"] if c == 0 else layer["out"]
99
+ shown = len(col)
100
+ label = ("input" if c == 0 else (layer.get("act") or "linear")) + f" · {n}"
101
+ out.append(f'<text x="{xs[c]:.1f}" y="56" text-anchor="middle" fill="#555">{label}</text>')
102
+ if shown < n:
103
+ out.append(f'<text x="{xs[c]:.1f}" y="{H - 28}" text-anchor="middle" fill="#999">+{n - shown} not drawn</text>')
104
+ # loss chart
105
+ cx0, cy0, cw, ch = 720, 70, 350, 330
106
+ out.append(f'<rect x="{cx0}" y="{cy0}" width="{cw}" height="{ch}" fill="#fafafa" stroke="#ddd"/>')
107
+ series = _loss_series(data)
108
+ vals = [v for s in series.values() for _, v in s]
109
+ if vals:
110
+ use_log = min(vals) > 0 and max(vals) / min(vals) > 100
111
+ f = (lambda v: math.log10(v)) if use_log else (lambda v: v)
112
+ lo, hi = min(f(v) for v in vals), max(f(v) for v in vals)
113
+ hi = hi if hi > lo else lo + 1
114
+ steps = [s for ser in series.values() for s, _ in ser]
115
+ s0, s1 = min(steps), max(steps) if max(steps) > min(steps) else min(steps) + 1
116
+ palette = ["#222222", "#f59322", "#0877bd", "#2ca02c", "#9467bd", "#8c564b"]
117
+ for k, (name, ser) in enumerate(series.items()):
118
+ pts = " ".join(f"{cx0 + cw * (s - s0) / (s1 - s0):.1f},{cy0 + ch - ch * (f(v) - lo) / (hi - lo):.1f}" for s, v in ser)
119
+ col = palette[k % len(palette)]
120
+ out.append(f'<polyline points="{pts}" fill="none" stroke="{col}" stroke-width="1.8"/>')
121
+ out.append(f'<text x="{cx0 + 8}" y="{cy0 + 18 + 16 * k}" fill="{col}">{name}</text>')
122
+ out.append(f'<text x="{cx0}" y="{cy0 + ch + 18}" fill="#777">step {s0} … {s1}{" (log scale)" if use_log else ""}</text>')
123
+ out.append(f'<text x="{cx0}" y="{cy0 - 10}" fill="#555">loss &amp; metrics</text>')
124
+ out.append("</svg>")
125
+ return "\n".join(out)
126
+
127
+
128
+ def snapshot_image(data: dict, path: str, step: Optional[int] = None) -> str:
129
+ p = Path(path)
130
+ p.parent.mkdir(parents=True, exist_ok=True)
131
+ if p.suffix.lower() == ".svg":
132
+ p.write_text(snapshot_svg(data, step), encoding="utf-8")
133
+ return str(p)
134
+ if p.suffix.lower() != ".png":
135
+ raise ValueError("snapshot path must end in .svg or .png")
136
+ try:
137
+ import matplotlib
138
+ matplotlib.use("Agg")
139
+ import matplotlib.pyplot as plt
140
+ from matplotlib.patches import PathPatch
141
+ from matplotlib.path import Path as MPath
142
+ except ImportError:
143
+ raise ImportError("PNG snapshots need matplotlib: pip install 'nnscope[png]' (or save as .svg)") from None
144
+ structure, snap = data.get("structure"), _pick_snapshot(data, step)
145
+ if structure is None or snap is None:
146
+ 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
+ pos, xs = _layout(structure, 0.0, 1.0, 0.0, 1.0)
149
+ for l, Wl in enumerate(snap["W"]):
150
+ m = max((abs(v) for row in Wl for v in row), default=1.0) or 1.0
151
+ for i, row in enumerate(Wl):
152
+ for j, w in enumerate(row):
153
+ (xa, ya), (xb, yb) = pos[l][i], pos[l + 1][j]
154
+ xm = (xa + xb) / 2
155
+ path = MPath([(xa, ya), (xm, ya), (xm, yb), (xb, yb)], [MPath.MOVETO, MPath.CURVE4, MPath.CURVE4, MPath.CURVE4])
156
+ ax.add_patch(PathPatch(path, fill=False, lw=0.3 + 2.5 * abs(w) / m, alpha=0.15 + 0.85 * abs(w) / m,
157
+ color=_color(1 if w >= 0 else -1)))
158
+ for c, col in enumerate(pos):
159
+ node = snap["nodes"][c - 1] if c > 0 and c - 1 < len(snap["nodes"]) else None
160
+ ax.scatter([x for x, _ in col], [y for _, y in col], s=45, zorder=3, edgecolors="#555",
161
+ c=[_color(node["mean"][j] if node else 0.0) for j in range(len(col))])
162
+ ax.set_xlim(-0.05, 1.05); ax.set_ylim(1.05, -0.05); ax.axis("off")
163
+ ax.set_title(f"{data.get('name', 'run')} — step {snap['step']}")
164
+ for name, ser in _loss_series(data).items():
165
+ bx.plot([s for s, _ in ser], [v for _, v in ser], label=name)
166
+ 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)
168
+ return str(p)
nnscope/insights.py ADDED
@@ -0,0 +1,102 @@
1
+ """Plain-language diagnostics: turn the logged numbers into hints a student can act on.
2
+
3
+ Each rule fires once when its condition appears and re-arms after the condition clears, so the feed reads like a
4
+ training diary ("step 120: layer 2 has 12 of 16 dead ReLUs") rather than a stream of repeats.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Dict, List, Optional
9
+
10
+ import numpy as np
11
+
12
+
13
+ def _fmt_ratio(x: float) -> str:
14
+ return f"{x:,.0f}" if x >= 10 else f"{x:.1f}"
15
+
16
+
17
+ class InsightEngine:
18
+ def __init__(self, patience: int = 3, warmup: int = 5):
19
+ self.patience = patience # a condition must hold for this many logs in a row before it is reported
20
+ self.warmup = warmup # rules about learning speed wait this many logs (early steps are special)
21
+ self.active: Dict[str, int] = {} # key → consecutive logs the condition held
22
+ self.clear: Dict[str, int] = {} # key → consecutive logs it has been clear (re-arm needs several)
23
+ self.reported: set = set()
24
+ self.best_test: Optional[tuple] = None # (loss, step, train_loss at that step)
25
+ self.n = 0
26
+
27
+ def _check(self, key: str, cond: bool, step, level: str, panel: str, message: str, out: List[dict], immediate=False,
28
+ clear: Optional[bool] = None):
29
+ """Report `key` once when `cond` holds; re-arm only after `clear` (default: not cond) held for 3×patience logs.
30
+
31
+ The separate clear condition and the re-arm delay (hysteresis) stop a value hovering at a threshold from
32
+ re-firing the same hint over and over.
33
+ """
34
+ if not cond:
35
+ self.active.pop(key, None)
36
+ is_clear = (not cond) if clear is None else clear
37
+ self.clear[key] = self.clear.get(key, 0) + 1 if is_clear else 0
38
+ if self.clear[key] >= 3 * self.patience:
39
+ self.reported.discard(key)
40
+ return
41
+ self.clear[key] = 0
42
+ self.active[key] = self.active.get(key, 0) + 1
43
+ if key not in self.reported and (immediate or self.active[key] >= self.patience):
44
+ self.reported.add(key)
45
+ out.append({"step": step, "level": level, "kind": key.split(":")[0], "panel": panel, "message": message})
46
+
47
+ def update(self, record: dict, structure: dict) -> List[dict]:
48
+ out: List[dict] = []
49
+ self.n += 1
50
+ step, layers, scalars = record["step"], record["layers"], record["scalars"]
51
+ names = [f"layer {k + 1}" for k in range(len(layers))]
52
+
53
+ loss = scalars.get("loss")
54
+ self._check("nan", loss is not None and not np.isfinite(loss), step, "error", "loss",
55
+ "The loss is NaN/inf: training diverged. Lower the learning rate or check for log(0) / division by zero.",
56
+ out, immediate=True)
57
+
58
+ g = [l.get("g_mean_abs") for l in layers]
59
+ if len(g) >= 2 and all(v is not None for v in g) and g[-1] and g[-1] > 0:
60
+ ratio = g[-1] / max(g[0], 1e-30)
61
+ self._check("vanishing", ratio > 1e3, step, "warn", "health", clear=ratio < 3e2, message=
62
+ f"Vanishing gradients: layer 1 receives {_fmt_ratio(ratio)}× smaller gradients than layer {len(g)}. "
63
+ "Early layers barely learn. Saturating activations (sigmoid/tanh) or poor initialisation are the "
64
+ "usual causes; try ReLU/tanh or He/Xavier init.", out=out)
65
+ self._check("exploding", max(v for v in g) > 1e2, step, "warn", "health",
66
+ "Exploding gradients: mean |∂L/∂W| is above 100. Expect unstable jumps in the loss; lower the learning "
67
+ "rate or clip gradients.", out)
68
+
69
+ ratios = [l.get("update_ratio") for l in layers if l.get("update_ratio") is not None]
70
+ if ratios and self.n > self.warmup:
71
+ med = float(np.median(ratios))
72
+ self._check("lr_high", med > 0.1, step, "warn", "health", clear=med < 0.03, message=
73
+ f"Weights keep changing by ~{med:.0%} between logs (update ratio ‖ΔW‖/‖W‖). Per single SGD step a "
74
+ "healthy ratio is about 0.1%, so even when logging once per epoch this is high: the learning rate is "
75
+ "probably too large (watch for a noisy or rising loss).", out=out)
76
+ self._check("lr_low", med < 1e-6, step, "info", "health",
77
+ f"Weights barely move (update ratio {med:.1e}). The learning rate may be too small, or training has converged.",
78
+ out)
79
+
80
+ for k, l in enumerate(layers):
81
+ act = (structure["layers"][k].get("act") if k < len(structure["layers"]) else None) or "unit"
82
+ n, dead = structure["layers"][k]["out"], l.get("dead")
83
+ kind = l.get("dead_kind") or "dead"
84
+ if dead is None or n <= 1:
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)
92
+
93
+ test_key = next((k for k in scalars if ("test" in k or "val" in k) and "loss" in k), None)
94
+ if test_key and loss is not None and scalars[test_key] is not None and np.isfinite(scalars[test_key]):
95
+ t = scalars[test_key]
96
+ if self.best_test is None or t < self.best_test[0]:
97
+ self.best_test = (t, step, loss)
98
+ b_loss, b_step, b_train = self.best_test
99
+ self._check("overfit", t > 1.15 * b_loss and loss < 0.9 * b_train, step, "warn", "loss",
100
+ f"Overfitting: {test_key} has risen {t / b_loss - 1:.0%} above its minimum at step {b_step} while the "
101
+ "training loss keeps falling. Early stopping at that step, more data, or weight decay would help.", out)
102
+ return out
nnscope/py.typed ADDED
File without changes