checkmlp.py
Gradient checks, and browser vs Python agreement
141 lines. This is the file the build actually runs, copied verbatim at build time.
"""Check the neural model in the browser against the Python reference.
Bit-identical output is not available here and pretending otherwise would be
dishonest: training runs thousands of floating-point operations deep, and
tanh, exp and log differ in their last bits between engines. So this checks
three things instead, which together are a stronger claim than "the numbers
matched once":
1. Each implementation's analytic gradients match its own finite differences.
A wrong derivative — the actual risk in hand-written backprop — fails here
immediately, in whichever language it was written in.
2. The two agree to a tolerance on initialisation and the forward pass, so
they are the same model and not merely two models that both train.
3. Training reduces loss in both, from the same start, by a similar amount.
"""
import json
import math
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "pylib"))
ROOT = os.path.abspath(os.path.join(HERE, ".."))
import quickjs # noqa: E402
import mlp # noqa: E402
from corpora import BERRIES, CODE, JAPANESE # noqa: E402
CORPORA = [("berries", BERRIES), ("code", CODE), ("japanese", JAPANESE)]
INIT_TOL = 1e-9 # same PRNG, same order: only transcendentals can differ
FORWARD_TOL = 1e-9
GRAD_TOL = 1e-4 # finite differences are not exact; this is the usual bar
def js_context():
ctx = quickjs.Context()
ctx.set_memory_limit(1 << 30)
ctx.set_max_stack_size(1 << 22)
ctx.eval("var globalThis = this;")
ctx.eval(open(os.path.join(ROOT, "learn", "mlp.js"), encoding="utf-8").read())
return ctx
def main():
ctx = js_context()
failures = []
for name, text in CORPORA:
vocab = mlp.build_vocab(text)
xs, ys = mlp.make_examples(text, vocab)
model = mlp.Model(vocab, seed=1)
ctx.set("_t", text)
ctx.eval("var vocab = MLP.buildVocab(_t);"
"var ex = MLP.makeExamples(_t, vocab);"
"var m = new MLP.Model(vocab, 1);")
js_vocab = json.loads(ctx.eval("JSON.stringify(vocab)"))
if js_vocab != vocab:
failures.append(f"{name}: vocabulary differs "
f"({len(js_vocab)} vs {len(vocab)})")
continue
js_xs = json.loads(ctx.eval("JSON.stringify(ex.xs)"))
js_ys = json.loads(ctx.eval("JSON.stringify(ex.ys)"))
if js_xs != xs or js_ys != ys:
failures.append(f"{name}: training examples differ")
continue
# 2a. same initial weights
js_C = json.loads(ctx.eval("JSON.stringify(m.C)"))
worst_init = 0.0
for a_row, b_row in zip(model.C, js_C):
for a, b in zip(a_row, b_row):
worst_init = max(worst_init, abs(a - b))
js_W2 = json.loads(ctx.eval("JSON.stringify(m.W2)"))
for a_row, b_row in zip(model.W2, js_W2):
for a, b in zip(a_row, b_row):
worst_init = max(worst_init, abs(a - b))
if worst_init > INIT_TOL:
failures.append(f"{name}: initial weights differ by {worst_init:.2e}")
# 2b. same forward pass
worst_fwd = 0.0
for probe in xs[:25]:
py_probs, _ = model.forward(probe)
ctx.set("_c", json.dumps(probe))
js_probs = json.loads(ctx.eval(
"JSON.stringify(m.forward(JSON.parse(_c)).probs)"))
for a, b in zip(py_probs, js_probs):
worst_fwd = max(worst_fwd, abs(a - b))
if worst_fwd > FORWARD_TOL:
failures.append(f"{name}: forward pass differs by {worst_fwd:.2e}")
# 1. each side checks its own gradients
py_worst, py_fail = mlp.verify_gradients(model, xs[:10], ys[:10])
ctx.eval("var sub = {xs: ex.xs.slice(0,10), ys: ex.ys.slice(0,10)};"
"var gc = m.gradcheck(sub.xs, sub.ys, {checks: 40});")
js_worst = float(ctx.eval("gc.worst"))
js_fail = int(ctx.eval("gc.failures.length"))
if py_fail:
failures.append(f"{name}: python gradients wrong ({len(py_fail)})")
if js_fail:
failures.append(f"{name}: browser gradients wrong ({js_fail})")
# 3. both actually learn
py_before = model.loss(xs[:120], ys[:120])
model.train(xs, ys, 120, 0.5, seed=7)
py_after = model.loss(xs[:120], ys[:120])
ctx.eval("var before = m.loss(ex.xs.slice(0,120), ex.ys.slice(0,120));"
"var rng = new MLP.Rng(7);"
"m.train(ex.xs, ex.ys, 120, 0.5, 32, rng);"
"var after = m.loss(ex.xs.slice(0,120), ex.ys.slice(0,120));")
js_before = float(ctx.eval("before"))
js_after = float(ctx.eval("after"))
if not (py_after < py_before and js_after < js_before):
failures.append(f"{name}: loss did not fall "
f"(py {py_before:.3f}->{py_after:.3f}, "
f"js {js_before:.3f}->{js_after:.3f})")
print(f"PASS {name:<10} grad max rel err py {py_worst:.1e} / "
f"js {js_worst:.1e} init {worst_init:.1e} "
f"fwd {worst_fwd:.1e} loss {py_before:.2f}->{py_after:.2f}")
print()
if failures:
print(f"{len(failures)} problem(s):")
for f in failures:
print(f" - {f}")
return 1
print("Browser model and Python reference agree; both gradient checks pass.")
return 0
if __name__ == "__main__":
sys.exit(main())