memlnaut-nisps/nisps/ml/mlp.hpp
monkey-w1n5t0n f57cddc278 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

644 lines
29 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// nisps/ml/mlp.hpp — four-layer MLP (three hidden + output), written ONCE
// against a storage policy (docs/specs/plans/one-core-engine-refactor.md P2).
//
// ARCHITECTURE
// MLPCore<Storage>
// ┌──────┐ Linear+Bias ┌────────┐ ReLU ┌────────┐ ReLU ┌────────┐ Sigmoid
// │ NIn │ ─────────────▶ │ NH1 │ ──────▶ │ NH2 │ ──────▶ │ NH3 │ ──────▶ NOut
// └──────┘ └────────┘ └────────┘ └────────┘
// Layer 0 (NIn → NH1) ReLU
// Layer 1 (NH1 → NH2) ReLU
// Layer 2 (NH2 → NH3) ReLU
// Layer 3 (NH3 → NOut) Sigmoid
//
// The topology (4 layers, ReLU×3 + Sigmoid) is fixed; the DIMENSIONS come
// from the storage policy:
//
// * `MLP<NIn, NH1, NH2, NH3, NOut, NMaxExamples, NMaxIterTrain>` — alias
// over `MLPCore<FixedStorage<...>>`. All buffers template-sized
// std::array, zero heap. This is the firmware model and preserves the
// pre-P2 class's exact compile-time surface (`kInput`, `kHidden1..3`,
// `kOutput`, `kNumLayers`, `weight_count()` — all constexpr).
// * `MLPCore<DynamicStorage>` — runtime-shaped (WASM/native-test/VCV
// only; heap at construction time, never per-call). Compile-time
// excluded from RP2350 builds.
//
// BIT-PARITY CONTRACT: for identical shapes and seeds the two storage models
// produce bit-identical results — the algorithm code below is shared and
// float op order is storage-independent. Enforced by
// tests/cpp/test_mlp_storage_parity.cpp.
//
// FLAT WEIGHT LAYOUT (`get_weights` / `set_weights`)
// [layer0_weights ...] [layer1_weights ...] [layer2_weights ...] [layer3_weights ...]
// [layer0_biases ...] [layer1_biases ...] [layer2_biases ...] [layer3_biases ...]
//
// CONCEPT SATISFACTION
// The class satisfies `nisps::MLEngine`: set_input, process, outputs,
// add_example, train (no-arg overload returning float), move_weights,
// draw_weights, reset, seed. Plus diagnostics: eval_loss, layer_stats,
// get/set_weights, weight_count, infer_batch, loss_history.
#pragma once
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <span>
#include "../core/concepts.hpp"
#include "../core/perf.hpp"
#include "../core/rng.hpp"
#include "generated/ml_defaults.hpp"
#include "activations.hpp"
#include "init.hpp"
#include "loss.hpp"
#include "rl.hpp"
#include "stats.hpp"
#include "storage.hpp"
#include "training.hpp"
namespace nisps::ml {
// Per-instance training-hyperparameter config (S26, docs/specs/recon/
// simplification-audit-2026-07.md): the ONE learning_rate/max_iterations/
// min_error default used to be duplicated identically across all nine
// schemas/modes/*.json (unread at runtime), hardcoded again in this file's
// no-arg train() overload, again in manifold's wasm-iml.ts TS default
// parameters, and a FOURTH time (diverging: 0.1/200/0.00001) in vcv/src/
// iml.hpp. Default member initialisers below pull the single generated
// constant (schemas/ml_defaults.json -> nisps/ml/generated/ml_defaults.hpp)
// so every MLPCore instance — firmware, WASM handle, VCV adapter — starts
// pre-configured identically; `set_train_config` makes it runtime-overridable,
// same as the codebase-wide decision requires.
//
// Note on layering: ml_defaults.hpp is generated into nisps/ml/generated/, NOT
// alongside schema_types.hpp in nisps/modes/generated/ where the rest of the
// codegen output lives. Training hyperparameters are an ML fact, not a mode
// fact, and nisps/ml sits below nisps/modes — emitting them there would make
// this file include upward. The TS side has no equivalent layering to respect
// and keeps all generated output in one directory.
struct TrainConfig {
float learning_rate = ::nisps::ml::generated::kMlTrainDefaults.learning_rate;
std::size_t max_iterations = ::nisps::ml::generated::kMlTrainDefaults.max_iterations;
float min_error = ::nisps::ml::generated::kMlTrainDefaults.min_error;
};
// Activation of layer L in the fixed 4-layer topology.
template <std::size_t L>
inline constexpr Activation kLayerActivation =
(L == 3u) ? Activation::Sigmoid : Activation::ReLU;
template <typename Storage>
class MLPCore : public Storage {
public:
static constexpr std::size_t kNumLayers = kMlpNumLayers;
// ---------------------------------------------------------------
// Lifecycle. Extra arguments are forwarded to the storage policy —
// FixedStorage takes none (`MLP m(seed)`), DynamicStorage takes its
// runtime dimensions (`MLPCore<DynamicStorage> m(seed, n_in, hidden,
// n_out, ...)`).
// ---------------------------------------------------------------
template <typename... StorageArgs>
explicit MLPCore(std::uint64_t seed, StorageArgs&&... storage_args) noexcept
: Storage(static_cast<StorageArgs&&>(storage_args)...), rng_(seed) {
// Default-init weights with spread=1 (Xavier-like). The IML
// interface caller is expected to draw_weights() with its own
// spread before the first inference; this default simply gives a
// non-degenerate starting state for tests that skip an explicit
// draw.
if (!storage_ok_()) return;
draw_weights(1.f);
clear_dataset_();
loss_history_count_ = 0u;
}
// ---------------------------------------------------------------
// Inference API (concept: set_input / process / outputs)
// ---------------------------------------------------------------
NISPS_FORCE_INLINE void set_input(std::size_t i, float v) noexcept {
if (!storage_ok_()) return;
if (i < this->n_in()) this->input_buf()[i] = v;
}
NISPS_HOT void process() noexcept {
if (!storage_ok_()) return;
forward_(this->input_buf());
// Mirror final activation into the output buffer so callers can
// read a stable span.
const auto a = this->template act_l<3u>();
auto out = this->output_buf();
const std::size_t n_out = this->n_out();
for (std::size_t i = 0; i < n_out; ++i) out[i] = a[i];
}
NISPS_FORCE_INLINE std::span<const float> outputs() const noexcept {
return this->output_buf();
}
// ---------------------------------------------------------------
// Dataset / Training (concept: add_example / train)
// ---------------------------------------------------------------
// FIFO ring buffer; oldest example evicted when full. No allocation.
void add_example(std::span<const float> features,
std::span<const float> labels) noexcept {
if (!storage_ok_()) return;
const std::size_t n_in = this->n_in();
const std::size_t n_out = this->n_out();
if (features.size() < n_in || labels.size() < n_out) return;
std::size_t slot;
if (dataset_count_ < this->max_examples()) {
slot = dataset_count_++;
} else {
// Buffer full: overwrite the slot pointed at by head_ (oldest)
// and advance head_ to the next-oldest.
slot = dataset_head_;
dataset_head_ = (dataset_head_ + 1u) % this->max_examples();
}
auto dsf = this->ds_features();
auto dsl = this->ds_labels();
const std::size_t f_off = slot * n_in;
const std::size_t l_off = slot * n_out;
for (std::size_t i = 0; i < n_in; ++i) dsf[f_off + i] = features[i];
for (std::size_t i = 0; i < n_out; ++i) dsl[l_off + i] = labels[i];
}
// Concept-required no-arg overload. Reads the runtime-configurable
// `train_config_` (default-initialised from the single generated default;
// see `TrainConfig` above) rather than hardcoding numbers here.
float train() noexcept {
return train(train_config_.learning_rate, train_config_.max_iterations,
train_config_.min_error, std::span<const float>{});
}
// Runtime knob for the no-arg train() overload (S26). Does not affect the
// explicit-argument train() below, which stays the always-available
// explicit path.
void set_train_config(float lr, std::size_t max_iter, float min_err) noexcept {
train_config_.learning_rate = lr;
train_config_.max_iterations = max_iter;
train_config_.min_error = min_err;
}
const TrainConfig& train_config() const noexcept { return train_config_; }
// Full per-sample training (RMSProp — see training.hpp). `sample_weights`,
// if non-empty, must size to the
// current example count and sum to 1.0 (caller's responsibility — we
// do NOT renormalize).
//
// Returns final epoch loss. Records per-iteration loss in the loss
// history (bounded by max_iter_train()).
float train(float lr,
std::size_t max_iter,
float min_err,
std::span<const float> sample_weights = {}) noexcept {
loss_history_count_ = 0u;
if (!storage_ok_()) return 0.f;
if (dataset_count_ == 0u) return 0.f;
const bool weighted = !sample_weights.empty();
const float uniform_w = 1.f / static_cast<float>(dataset_count_);
auto loss_hist = this->loss_hist_buf();
float epoch_loss = 0.f;
for (std::size_t iter = 0; iter < max_iter; ++iter) {
epoch_loss = 0.f;
// Per-sample forward → loss → backprop+update. The order
// is the dataset insertion order; we do not shuffle (matches
// the legacy `Train()` exactly — `TrainBatch` shuffles, but
// we're not implementing batch yet).
for (std::size_t s = 0; s < dataset_count_; ++s) {
const float w = weighted ? sample_weights[s] : uniform_w;
// Forward pass on sample s.
std::span<const float> x = sample_features_(s);
forward_(x);
// Per-sample loss (NOT scaled by 1/N — the meml-ues fix).
// The eval scratch of the final layer doubles as the loss-
// derivative buffer (mse_per_sample fully overwrites it;
// eval_loss never runs concurrently).
auto deriv = this->template eval_act_l<3u>();
const float sample_loss = mse_per_sample(
sample_labels_(s),
std::span<const float>(this->template act_l<3u>()),
deriv);
// Aggregate weighted loss.
epoch_loss += w * sample_loss;
// Backprop with the same w as the gradient scaler.
backprop_(x, deriv, w);
// Apply gradient (per-sample, RMSProp).
apply_grad_<3u>(lr);
apply_grad_<2u>(lr);
apply_grad_<1u>(lr);
apply_grad_<0u>(lr);
}
if (loss_history_count_ < this->max_iter_train()) {
loss_hist[loss_history_count_++] = epoch_loss;
}
if (epoch_loss < min_err) break;
}
return epoch_loss;
}
// Train ONE step toward a COMPUTED target vector (not a stored label) —
// the geometric-dislike hook (docs/adr/rl-feedback-design.md §4). The
// dataset is untouched. A negative `lr` trains AWAY from the target (the
// upstream cold-start fallback). `out_mask` (1 = active) zeroes the
// loss-derivative of inactive output dims before backprop — the solo/
// focus gate; empty ⇒ all active. Returns the sample loss.
float train_targets(std::span<const float> input,
std::span<const float> target,
float lr,
std::span<const std::uint8_t> out_mask = {}) noexcept {
if (!storage_ok_()) return 0.f;
const std::size_t n_out = this->n_out();
if (input.size() < this->n_in() || target.size() < n_out) return 0.f;
forward_(input);
auto deriv = this->template eval_act_l<3u>();
const float loss = mse_per_sample(
target,
std::span<const float>(this->template act_l<3u>()),
deriv);
if (!out_mask.empty()) {
for (std::size_t j = 0; j < n_out; ++j) {
const bool active = (j < out_mask.size() && out_mask[j] != 0u);
if (!active) deriv[j] = 0.f;
}
}
backprop_(input, deriv, 1.f);
apply_grad_<3u>(lr);
apply_grad_<2u>(lr);
apply_grad_<1u>(lr);
apply_grad_<0u>(lr);
return loss;
}
// ---------------------------------------------------------------
// RL ops (concept: move_weights / draw_weights)
// ---------------------------------------------------------------
void move_weights(float speed, float spread,
std::span<const std::uint8_t> output_pin_mask = {}) noexcept {
if (!storage_ok_()) return;
move_weights_layer(this->template weights_l<0u>(), this->template biases_l<0u>(),
this->template fan_in_l<0u>(), speed, spread, /*final=*/false, {}, rng_);
move_weights_layer(this->template weights_l<1u>(), this->template biases_l<1u>(),
this->template fan_in_l<1u>(), speed, spread, /*final=*/false, {}, rng_);
move_weights_layer(this->template weights_l<2u>(), this->template biases_l<2u>(),
this->template fan_in_l<2u>(), speed, spread, /*final=*/false, {}, rng_);
move_weights_layer(this->template weights_l<3u>(), this->template biases_l<3u>(),
this->template fan_in_l<3u>(), speed, spread, /*final=*/true,
output_pin_mask, rng_);
}
void draw_weights(float spread) noexcept {
if (!storage_ok_()) return;
draw_weights_layer(this->template weights_l<0u>(), this->template biases_l<0u>(),
this->template fan_in_l<0u>(), spread, rng_);
draw_weights_layer(this->template weights_l<1u>(), this->template biases_l<1u>(),
this->template fan_in_l<1u>(), spread, rng_);
draw_weights_layer(this->template weights_l<2u>(), this->template biases_l<2u>(),
this->template fan_in_l<2u>(), spread, rng_);
draw_weights_layer(this->template weights_l<3u>(), this->template biases_l<3u>(),
this->template fan_in_l<3u>(), spread, rng_);
clear_grad_<0u>();
clear_grad_<1u>();
clear_grad_<2u>();
clear_grad_<3u>();
}
// Zero the RMSProp running squared-gradient averages (upstream
// `MLP<T>::ResetOptimizerState`, MLP.h:205). Note `draw_weights()`
// deliberately does NOT call this: upstream's `DrawWeights` leaves the
// optimiser state alone, so a randomise gesture keeps the step-size
// statistics it had. Only a full `reset()` clears them.
void reset_optimizer_state() noexcept {
if (!storage_ok_()) return;
clear_sq_grad_<0u>();
clear_sq_grad_<1u>();
clear_sq_grad_<2u>();
clear_sq_grad_<3u>();
}
// Concept reset: clear weights, dataset, loss history and optimiser
// state. Seed is intentionally NOT reset (use `seed()` for that).
void reset() noexcept {
if (!storage_ok_()) return;
clear_dataset_();
reset_optimizer_state();
loss_history_count_ = 0u;
// Re-init weights from current rng state with default spread.
draw_weights(1.f);
auto in = this->input_buf();
auto out = this->output_buf();
for (std::size_t i = 0; i < in.size(); ++i) in[i] = 0.f;
for (std::size_t i = 0; i < out.size(); ++i) out[i] = 0.f;
}
void seed(std::uint64_t s) noexcept { rng_.seed(s); }
// ---------------------------------------------------------------
// Diagnostics
// ---------------------------------------------------------------
// Average MSE across the training set without updating weights or the
// cached activations (runs through the mutable eval scratch).
float eval_loss() const noexcept {
if (!storage_ok_()) return 0.f;
if (dataset_count_ == 0u) return 0.f;
const float inv_n = 1.f / static_cast<float>(dataset_count_);
const std::size_t n_out = this->n_out();
auto dsl = this->ds_labels();
float total = 0.f;
for (std::size_t s = 0; s < dataset_count_; ++s) {
forward_eval_layer_<0u>(sample_features_(s));
forward_eval_layer_<1u>(this->template eval_act_l<0u>());
forward_eval_layer_<2u>(this->template eval_act_l<1u>());
forward_eval_layer_<3u>(this->template eval_act_l<2u>());
const auto ao = this->template eval_act_l<3u>();
float sse = 0.f;
const float inv_o = 1.f / static_cast<float>(n_out);
const std::size_t l_off = s * n_out;
for (std::size_t j = 0; j < n_out; ++j) {
const float d = dsl[l_off + j] - ao[j];
sse += d * d * inv_o;
}
total += sse * inv_n;
}
return total;
}
LayerStats layer_stats(std::size_t layer_idx) const noexcept {
if (!storage_ok_()) return {};
switch (layer_idx) {
case 0: return compute_layer_stats(this->template weights_l<0u>(),
this->template biases_l<0u>());
case 1: return compute_layer_stats(this->template weights_l<1u>(),
this->template biases_l<1u>());
case 2: return compute_layer_stats(this->template weights_l<2u>(),
this->template biases_l<2u>());
case 3: return compute_layer_stats(this->template weights_l<3u>(),
this->template biases_l<3u>());
default: return {};
}
}
// Returns a span into a storage-owned scratch buffer that holds a copy
// of the flat weights+biases. The buffer is regenerated on each call,
// so don't hold onto the span across mutations.
std::span<const float> get_weights() noexcept {
if (!storage_ok_()) return {};
auto flat = this->flat_buf();
std::size_t k = 0u;
// Weights, layer-major.
for (float v : this->template weights_l<0u>()) flat[k++] = v;
for (float v : this->template weights_l<1u>()) flat[k++] = v;
for (float v : this->template weights_l<2u>()) flat[k++] = v;
for (float v : this->template weights_l<3u>()) flat[k++] = v;
// Biases.
for (float v : this->template biases_l<0u>()) flat[k++] = v;
for (float v : this->template biases_l<1u>()) flat[k++] = v;
for (float v : this->template biases_l<2u>()) flat[k++] = v;
for (float v : this->template biases_l<3u>()) flat[k++] = v;
return std::span<const float>(flat.data(), k);
}
void set_weights(std::span<const float> w) noexcept {
if (!storage_ok_()) return;
if (w.size() < this->weight_count()) return;
std::size_t k = 0u;
for (float& v : this->template weights_l<0u>()) v = w[k++];
for (float& v : this->template weights_l<1u>()) v = w[k++];
for (float& v : this->template weights_l<2u>()) v = w[k++];
for (float& v : this->template weights_l<3u>()) v = w[k++];
for (float& v : this->template biases_l<0u>()) v = w[k++];
for (float& v : this->template biases_l<1u>()) v = w[k++];
for (float& v : this->template biases_l<2u>()) v = w[k++];
for (float& v : this->template biases_l<3u>()) v = w[k++];
}
// Run inference on N points (each n_in-sized) and write N output vectors
// (each n_out-sized) into `outs`. NO heap. Modifies the internal cached
// activations as a side effect.
void infer_batch(std::span<const float> points,
std::span<float> outs) noexcept {
if (!storage_ok_()) return;
const std::size_t n_in = this->n_in();
const std::size_t n_out = this->n_out();
const std::size_t n = points.size() / n_in;
if (outs.size() < n * n_out) return;
auto in = this->input_buf();
auto out = this->output_buf();
for (std::size_t i = 0; i < n; ++i) {
const std::size_t in_off = i * n_in;
for (std::size_t j = 0; j < n_in; ++j) in[j] = points[in_off + j];
process();
const std::size_t out_off = i * n_out;
for (std::size_t j = 0; j < n_out; ++j) outs[out_off + j] = out[j];
}
}
std::span<const float> loss_history() const noexcept {
return std::span<const float>(this->loss_hist_buf().data(), loss_history_count_);
}
std::size_t example_count() const noexcept { return dataset_count_; }
void clear_examples() noexcept { clear_dataset_(); }
private:
// ---------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------
// DynamicStorage construction can fail (arena allocation); FixedStorage
// cannot. The check is compile-time `true` for storages without a
// `valid()` member, so the fixed/firmware path carries no branch.
NISPS_FORCE_INLINE bool storage_ok_() const noexcept {
if constexpr (requires(const Storage& s) { { s.valid() } -> std::convertible_to<bool>; }) {
return this->valid();
} else {
return true;
}
}
template <std::size_t L>
NISPS_HOT NISPS_FORCE_INLINE void forward_layer_(std::span<const float> in) noexcept {
const std::size_t fan_in = this->template fan_in_l<L>();
const std::size_t fan_out = this->template fan_out_l<L>();
auto w = this->template weights_l<L>();
auto b = this->template biases_l<L>();
auto pa = this->template pre_act_l<L>();
auto a = this->template act_l<L>();
for (std::size_t node = 0; node < fan_out; ++node) {
const std::size_t row = node * fan_in;
float sum = b[node];
for (std::size_t j = 0; j < fan_in; ++j) {
sum += w[row + j] * in[j];
}
pa[node] = sum;
a[node] = activate<kLayerActivation<L>>(sum);
}
}
NISPS_HOT NISPS_FORCE_INLINE
void forward_(std::span<const float> in) noexcept {
forward_layer_<0u>(in);
forward_layer_<1u>(this->template act_l<0u>());
forward_layer_<2u>(this->template act_l<1u>());
forward_layer_<3u>(this->template act_l<2u>());
}
// Backprop one layer: compute the incoming-error vector for the previous
// layer into delta_l<L>() and accumulate per-weight/per-bias gradients.
template <std::size_t L>
NISPS_HOT NISPS_FORCE_INLINE
void backprop_layer_(std::span<const float> input,
std::span<const float> upstream_err,
float sample_weight) noexcept {
const std::size_t fan_in = this->template fan_in_l<L>();
const std::size_t fan_out = this->template fan_out_l<L>();
auto w = this->template weights_l<L>();
auto pa = this->template pre_act_l<L>();
auto gw = this->template grad_w_l<L>();
auto gb = this->template grad_b_l<L>();
auto delta_in = this->template delta_l<L>();
for (std::size_t j = 0; j < fan_in; ++j) delta_in[j] = 0.f;
for (std::size_t node = 0; node < fan_out; ++node) {
const float err_signal =
upstream_err[node] *
activate_deriv_pre<kLayerActivation<L>>(pa[node]) * sample_weight;
const std::size_t row = node * fan_in;
for (std::size_t j = 0; j < fan_in; ++j) {
gw[row + j] += err_signal * input[j];
delta_in[j] += err_signal * w[row + j];
}
gb[node] += err_signal;
}
}
// Backprop with sample_weight applied to every error signal (so the
// accumulated gradient is already weighted). No weight update happens
// here — caller does it after each sample.
NISPS_HOT NISPS_FORCE_INLINE
void backprop_(std::span<const float> input,
std::span<const float> output_deriv,
float sample_weight) noexcept {
backprop_layer_<3u>(this->template act_l<2u>(), output_deriv, sample_weight);
backprop_layer_<2u>(this->template act_l<1u>(), this->template delta_l<3u>(), 1.f);
backprop_layer_<1u>(this->template act_l<0u>(), this->template delta_l<2u>(), 1.f);
backprop_layer_<0u>(input, this->template delta_l<1u>(), 1.f);
}
// Apply accumulated gradient to weights+biases via RMSProp (clip, advance
// the running squared-gradient average, normalise the step — see
// training.hpp for the ported formula and why it is not SGD). Resets the
// accumulators to zero for the next sample/iteration; the squared-gradient
// averages PERSIST, which is the whole point of the optimiser.
template <std::size_t L>
NISPS_FORCE_INLINE void apply_grad_(float lr) noexcept {
auto w = this->template weights_l<L>();
auto b = this->template biases_l<L>();
auto gw = this->template grad_w_l<L>();
auto gb = this->template grad_b_l<L>();
auto sw = this->template sq_grad_w_l<L>();
auto sb = this->template sq_grad_b_l<L>();
const std::size_t nw = gw.size();
const std::size_t nb = gb.size();
for (std::size_t i = 0; i < nw; ++i) {
w[i] -= rmsprop_step(gw[i], sw[i], lr);
gw[i] = 0.f;
}
for (std::size_t i = 0; i < nb; ++i) {
b[i] -= rmsprop_step(gb[i], sb[i], lr);
gb[i] = 0.f;
}
}
template <std::size_t L>
NISPS_FORCE_INLINE void clear_grad_() noexcept {
auto gw = this->template grad_w_l<L>();
auto gb = this->template grad_b_l<L>();
for (std::size_t i = 0; i < gw.size(); ++i) gw[i] = 0.f;
for (std::size_t i = 0; i < gb.size(); ++i) gb[i] = 0.f;
}
template <std::size_t L>
NISPS_FORCE_INLINE void clear_sq_grad_() noexcept {
auto sw = this->template sq_grad_w_l<L>();
auto sb = this->template sq_grad_b_l<L>();
for (std::size_t i = 0; i < sw.size(); ++i) sw[i] = 0.f;
for (std::size_t i = 0; i < sb.size(); ++i) sb[i] = 0.f;
}
// const forward pass for diagnostics — writes into the mutable eval
// scratch, never the real caches.
template <std::size_t L>
NISPS_FORCE_INLINE void forward_eval_layer_(std::span<const float> in) const noexcept {
const std::size_t fan_in = this->template fan_in_l<L>();
const std::size_t fan_out = this->template fan_out_l<L>();
auto w = this->template weights_l<L>();
auto b = this->template biases_l<L>();
auto out = this->template eval_act_l<L>();
for (std::size_t node = 0; node < fan_out; ++node) {
const std::size_t row = node * fan_in;
float sum = b[node];
for (std::size_t j = 0; j < fan_in; ++j) sum += w[row + j] * in[j];
out[node] = activate<kLayerActivation<L>>(sum);
}
}
NISPS_FORCE_INLINE std::span<const float> sample_features_(std::size_t s) const noexcept {
const std::size_t n_in = this->n_in();
return this->ds_features().subspan(s * n_in, n_in);
}
NISPS_FORCE_INLINE std::span<const float> sample_labels_(std::size_t s) const noexcept {
const std::size_t n_out = this->n_out();
return this->ds_labels().subspan(s * n_out, n_out);
}
void clear_dataset_() noexcept {
dataset_count_ = 0u;
dataset_head_ = 0u;
}
// ---------------------------------------------------------------
// Members (shape-independent; everything sized lives in Storage)
// ---------------------------------------------------------------
std::size_t dataset_count_ = 0u;
std::size_t dataset_head_ = 0u;
std::size_t loss_history_count_ = 0u;
TrainConfig train_config_{};
Rng rng_;
};
// The classic fixed-architecture MLP — the firmware model and the default
// everywhere a compile-time shape is known. `MLP<NIn, 10, 10, 14, NOut>`
// matches the legacy firmware default [10, 10, 14].
template <std::size_t NIn,
std::size_t NHidden1,
std::size_t NHidden2,
std::size_t NHidden3,
std::size_t NOut,
std::size_t NMaxExamples = kDefaultMaxExamples,
std::size_t NMaxIterTrain = 4096u>
using MLP = MLPCore<
FixedStorage<NIn, NHidden1, NHidden2, NHidden3, NOut, NMaxExamples, NMaxIterTrain>>;
// MLP satisfies the MLEngine concept. We keep a static_assert in the test
// suite (test_mlp_concept_satisfied) — see test_mlp_init.cpp.
} // namespace nisps::ml