memlnaut-nisps/nisps/ml/training.hpp

96 lines
4.4 KiB
C++
Raw Permalink Normal View History

fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// nisps/ml/training.hpp — gradient clipping and the RMSProp weight update.
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
//
// The MLP class owns the training loop because it knows the dataset layout
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// and weight buffers. This header provides the pieces that are pure scalar
// arithmetic over one accumulated gradient:
// - kGradClip: ±10.0, matches upstream Layer.h's gradientClipValue.
// - clip_gradient(): scalar clipper, applied before the update.
// - rmsprop_step(): the optimiser step — returns the amount to SUBTRACT
// from the weight and advances that weight's running
// squared-gradient average in place.
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
//
// The full training loop (forward, loss, backprop, weight update) is
// implemented inline in mlp.hpp because everything it touches is either a
// member array or layer-templated. Splitting it across translation units
// would require type-erasing the layers, which we don't want.
//
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// OPTIMISER: RMSProp, ported from upstream memlp `Layer.h`
// ---------------------------------------------------------------------
// (github.com/MusicallyEmbodiedML/memlp @ ea777502 — the commit
// MEMLNaut-NISPS `upstream/main` pins). Upstream applies RMSProp EVERYWHERE:
// `Layer.h:239 ApplyAccumulatedGradients`, the `m_sq_grad_avg` running
// average at `Layer.h:601`, `StaticMLP.h:268` "Mini-batch RMSProp training".
//
// This file previously shipped SGD only and called the difference an
// optimiser-choice research question. It was not one. RMSProp divides each
// step by the running gradient magnitude, so an upstream `lr` is a
// NORMALISED step size, whereas under SGD the same number multiplies the raw
// gradient. Every learning rate we ported from upstream — most visibly
// `feedback.hpp`'s `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312` —
// therefore landed in an optimiser that interprets it completely
// differently, which is why the geometric dislike measured 5.1e-5 of
// movement against an intended 0.5 (`tests/cpp/ml_bench.cpp` D1).
//
// The constants and the clamp order below are upstream's, exactly:
// grad = clip(accumulated_grad) (±10)
// sq = min(0.9*sq + 0.1*grad², 1e6)
// adj = min(lr / (sqrt(sq) + 1e-6), 1.0)
// w -= adj * grad
// Note the adjusted-LR clamp is one-sided, matching upstream's
// `std::min(adj_lr, maxAdjustedLR)`. A NEGATIVE lr (the "train away from
// this target" path in `MLPCore::train_targets`, used by the geometric
// dislike's cold-start fallback) is therefore left unclamped in magnitude,
// exactly as upstream leaves it.
//
// The per-weight squared-gradient average is new persistent STATE. It lives
// in the storage policy (`storage.hpp` FixedStorage / `dynamic_storage.hpp`
// DynamicStorage) alongside the gradient accumulators, so the firmware's
// zero-heap contract holds. It is optimiser state, not model state: it is
// NOT part of `weight_count()` / `get_weights()` / `set_weights()`, matching
// upstream, and `MLPCore::reset_optimizer_state()` (upstream
// `MLP<T>::ResetOptimizerState`) zeroes it.
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
#pragma once
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
#include <cmath>
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
#include "../core/perf.hpp"
namespace nisps::ml {
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// Per-element gradient clip threshold. Matches upstream's gradientClipValue
// in Layer.h::ApplyAccumulatedGradients.
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
inline constexpr float kGradClip = 10.f;
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// RMSProp constants — upstream Layer.h:242-244 and the local constants at
// the top of ApplyAccumulatedGradients.
inline constexpr float kRmsPropDecay = 0.9f;
inline constexpr float kRmsPropDecayInv = 0.1f;
inline constexpr float kRmsPropEpsilon = 1.e-6f;
inline constexpr float kMaxSqGradAvg = 1.e6f;
inline constexpr float kMaxAdjustedLr = 1.f;
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
NISPS_FORCE_INLINE float clip_gradient(float g) noexcept {
if (g > kGradClip) return kGradClip;
if (g < -kGradClip) return -kGradClip;
return g;
}
fix(ml): port RMSProp — ported learning rates were landing in SGD Upstream memlp (github.com/MusicallyEmbodiedML/memlp @ ea777502, the commit upstream/main pins) applies gradients with RMSProp everywhere: Layer.h:239 ApplyAccumulatedGradients, the m_sq_grad_avg running average at Layer.h:601, StaticMLP.h:268. nisps/ml/training.hpp shipped SGD only and filed the difference as an optimiser-choice research question. It was not one. RMSProp divides each step by the running gradient magnitude, so an upstream lr is a NORMALISED step; under SGD the same number multiplies the raw gradient. Every learning rate ported from upstream therefore landed in an optimiser that reads it differently — most visibly feedback.hpp's `geo_lr_ = 0.001f // upstream InterfaceRL.hpp:312`, an RMSProp LR pasted into a single SGD step. rmsprop_step() ports Layer.h:239 exactly: clip at +/-10, sq = min(0.9*sq + 0.1*g^2, 1e6), adj = min(lr/(sqrt(sq)+1e-6), 1.0), w -= adj*g. The adjusted-LR clamp stays one-sided as upstream's std::min is, so the negative lr used by train_targets' "train away from this target" path behaves as it does upstream. The per-weight squared-gradient average is new persistent state and lives in the storage policies (FixedStorage arrays / DynamicStorage arena) so nisps/ stays allocation-free and the firmware's zero-heap contract holds. It is optimiser state, not model state: excluded from weight_count()/get_weights()/set_weights(), matching upstream, and cleared by MLPCore::reset_optimizer_state() (upstream ResetOptimizerState). draw_weights() deliberately does NOT clear it — upstream's DrawWeights doesn't either. Measured with tests/cpp/ml_bench.cpp: D1 one geometric dislike moves the mapping 1.6e-2, up from 5.3e-5 (~295x), and repeated presses now CONVERGE on the intended 0.5 push (0.12 at 10, 0.56 at 100) instead of creeping linearly forever. A4 geometric-vs-Diffuse gap narrows from ~4100x to ~14x in one press. U4 the upstream-LR positive path actually trains now (range_util 0.71 at 100 ticks/gesture, was 0.016 — it was inert under SGD). Not fixed by this, and now tracked as ALIGNMENT defect 6d: the dose asymmetry. lurch_max is still ~1.08 against a [0,1] output range. Golden vector stages 2 and 3 re-captured; stages 0 and 1 are pre-training and did not move, which is the cross-check that only the update rule changed. manifold/public/nisps.wasm rebuilt so parity-check compares like with like — it FAILED at up to 5e-2 against the stale artifact and PASSES at 2.4e-7 against a fresh one. parity-check.sh only builds the WASM when it is missing, never when it is stale; noted in MAP.md and filed separately. ALIGNMENT defect 6 resolved (moved to Recently resolved); 6b's optimiser cross-reference updated; new defect 6d for the positive-training dose. Gates: build-cpp-tests 138 tests / ctest 4/4, parity-check PASS, lint-cpp clean, manifold typecheck clean.
2026-07-25 11:11:23 +02:00
// One RMSProp update for a single weight or bias. `sq_avg` is that element's
// running squared-gradient average and is advanced in place. Returns the
// value to SUBTRACT from the parameter (upstream writes `w -= adj_lr * g`).
NISPS_FORCE_INLINE float rmsprop_step(float grad, float& sq_avg, float lr) noexcept {
const float g = clip_gradient(grad);
float sq = (kRmsPropDecay * sq_avg) + (kRmsPropDecayInv * g * g);
if (sq > kMaxSqGradAvg) sq = kMaxSqGradAvg;
sq_avg = sq;
float adj_lr = lr / (std::sqrt(sq) + kRmsPropEpsilon);
if (adj_lr > kMaxAdjustedLr) adj_lr = kMaxAdjustedLr; // one-sided, as upstream
return adj_lr * g;
}
feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
} // namespace nisps::ml