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/__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 & 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
|