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)
This commit is contained in:
parent
55e7bc9654
commit
825ed6ad33
14 changed files with 1734 additions and 0 deletions
|
|
@ -51,6 +51,12 @@ if(NOT EMSCRIPTEN)
|
|||
${NISPS_TEST_DIR}/test_ring_buffer.cpp
|
||||
${NISPS_TEST_DIR}/test_rng.cpp
|
||||
${NISPS_TEST_DIR}/test_math.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_init.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_inference.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_training.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_loss.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_rl.cpp
|
||||
${NISPS_TEST_DIR}/test_mlp_serialize.cpp
|
||||
)
|
||||
target_link_libraries(nisps_core_tests PRIVATE nisps_core)
|
||||
|
||||
|
|
|
|||
91
nisps/ml/activations.hpp
Normal file
91
nisps/ml/activations.hpp
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// nisps/ml/activations.hpp — activation functions used by the MLP.
|
||||
//
|
||||
// Three activations are needed for the firmware default: ReLU (hidden layers),
|
||||
// sigmoid (output layer), and tanh (alternative). Each entry exposes the
|
||||
// activation and its derivative-given-pre-activation; we keep both around
|
||||
// because the backprop path multiplies by the derivative of the
|
||||
// pre-activation, which is cheaper to compute on the inner product than on
|
||||
// the post-activation in some cases.
|
||||
//
|
||||
// Notes on the leak in ReLU:
|
||||
// The legacy firmware uses leaky ReLU with slope 0.01 everywhere it claims
|
||||
// to use "ReLU". We preserve that behavior for parity — a true zero-slope
|
||||
// ReLU would silently change training dynamics in ways that aren't
|
||||
// documented. The leak slope is a `static const float` so it lives in SRAM
|
||||
// per Chris's rule.
|
||||
//
|
||||
// Sigmoid:
|
||||
// We use the exact std::exp version here (NOT fast_sigmoid). The MLP
|
||||
// training loop multiplies by the sigmoid derivative, and the ~1.2% bias
|
||||
// in fast_sigmoid would compound during training. fast_sigmoid is fine for
|
||||
// inference-only paths but not for the gradient path. The ML output
|
||||
// activation is the place where we want monotonic-AND-accurate.
|
||||
//
|
||||
// All literals carry the .f suffix; constants used inside loops live in
|
||||
// `static const float` so they are hoisted to SRAM on RP2350.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
|
||||
#include "../core/perf.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Match the legacy firmware leaky-ReLU slope. 0.01 matches PyTorch default
|
||||
// and what `src/memlp/Utils.h::kReLUSlope` had before.
|
||||
inline constexpr float kReluLeakSlope = 0.01f;
|
||||
|
||||
NISPS_FORCE_INLINE float relu(float x) noexcept {
|
||||
return (x > 0.f) ? x : kReluLeakSlope * x;
|
||||
}
|
||||
NISPS_FORCE_INLINE float relu_deriv_pre(float pre_activation) noexcept {
|
||||
return (pre_activation > 0.f) ? 1.f : kReluLeakSlope;
|
||||
}
|
||||
|
||||
NISPS_FORCE_INLINE float sigmoid(float x) noexcept {
|
||||
// Saturate inputs to avoid expf overflow / underflow noise. Mirrors
|
||||
// nisps::exact_sigmoid clamps from core/math.hpp.
|
||||
if (x > 40.f) return 1.f;
|
||||
if (x < -40.f) return 0.f;
|
||||
return 1.f / (1.f + std::exp(-x));
|
||||
}
|
||||
NISPS_FORCE_INLINE float sigmoid_deriv_pre(float pre_activation) noexcept {
|
||||
const float s = sigmoid(pre_activation);
|
||||
return s * (1.f - s);
|
||||
}
|
||||
|
||||
NISPS_FORCE_INLINE float tanh_act(float x) noexcept {
|
||||
return std::tanh(x);
|
||||
}
|
||||
NISPS_FORCE_INLINE float tanh_deriv_pre(float pre_activation) noexcept {
|
||||
const float t = std::tanh(pre_activation);
|
||||
return 1.f - t * t;
|
||||
}
|
||||
|
||||
// Activation kind, dispatched at compile time per layer (see Layer template
|
||||
// in mlp.hpp). We don't use a runtime tag because activation choice is
|
||||
// architectural, not per-call.
|
||||
enum class Activation : int {
|
||||
ReLU = 0,
|
||||
Sigmoid = 1,
|
||||
Tanh = 2,
|
||||
};
|
||||
|
||||
template <Activation A>
|
||||
NISPS_FORCE_INLINE float activate(float x) noexcept {
|
||||
if constexpr (A == Activation::ReLU) return relu(x);
|
||||
if constexpr (A == Activation::Sigmoid) return sigmoid(x);
|
||||
if constexpr (A == Activation::Tanh) return tanh_act(x);
|
||||
return x; // unreachable
|
||||
}
|
||||
|
||||
template <Activation A>
|
||||
NISPS_FORCE_INLINE float activate_deriv_pre(float pre) noexcept {
|
||||
if constexpr (A == Activation::ReLU) return relu_deriv_pre(pre);
|
||||
if constexpr (A == Activation::Sigmoid) return sigmoid_deriv_pre(pre);
|
||||
if constexpr (A == Activation::Tanh) return tanh_deriv_pre(pre);
|
||||
return 1.f; // unreachable
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
72
nisps/ml/init.hpp
Normal file
72
nisps/ml/init.hpp
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// nisps/ml/init.hpp — weight initialization strategies.
|
||||
//
|
||||
// Three strategies are exposed:
|
||||
// - `uniform_init`: w ~ U[-1, 1]. Matches the legacy
|
||||
// `utils::gen_rand<T>()` baseline; produces high-magnitude pre-activations
|
||||
// that drive sigmoid output saturation.
|
||||
// - `xavier_init`: w ~ U[-1, 1] / sqrt(fan_in). Centered pre-activations,
|
||||
// better for sigmoid output layers.
|
||||
// - `spread_init`: linearly interpolates per-layer scale between the two.
|
||||
// scale = (1 - spread) + spread / sqrt(fan_in). spread=0 ⇒ uniform,
|
||||
// spread=1 ⇒ Xavier. This is the playground knob.
|
||||
//
|
||||
// All three operate on a flat row-major weight buffer of size fan_in*fan_out.
|
||||
// Biases are initialized separately and ALWAYS to zero — the legacy code
|
||||
// initialized biases to zero, and the playground spread parameter never
|
||||
// touches biases. We keep that.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
|
||||
#include "../core/rng.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Compute the spread-aware weight scale for one layer.
|
||||
// spread=0 → 1.0 (uniform [-1, 1])
|
||||
// spread=1 → 1/sqrt(fan_in) (Xavier)
|
||||
inline float spread_scale(float spread, std::size_t fan_in) noexcept {
|
||||
if (fan_in == 0u) return 1.f;
|
||||
const float inv_sqrt = 1.f / std::sqrt(static_cast<float>(fan_in));
|
||||
return (1.f - spread) + spread * inv_sqrt;
|
||||
}
|
||||
|
||||
// Initialize one layer's weights (flat row-major) and biases.
|
||||
// weights: span of size fan_in * fan_out
|
||||
// biases: span of size fan_out (zeroed)
|
||||
// spread: see spread_scale
|
||||
// rng: state advanced; caller owns it
|
||||
inline void spread_init(std::span<float> weights,
|
||||
std::span<float> biases,
|
||||
std::size_t fan_in,
|
||||
float spread,
|
||||
Rng& rng) noexcept {
|
||||
const float scale = spread_scale(spread, fan_in);
|
||||
for (std::size_t i = 0; i < weights.size(); ++i) {
|
||||
weights[i] = rng.next_float_signed() * scale;
|
||||
}
|
||||
for (std::size_t i = 0; i < biases.size(); ++i) {
|
||||
biases[i] = 0.f;
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience aliases for the endpoints. Cheap to build on top of
|
||||
// spread_init; useful for tests that want to assert a specific regime.
|
||||
inline void uniform_init(std::span<float> weights,
|
||||
std::span<float> biases,
|
||||
std::size_t fan_in,
|
||||
Rng& rng) noexcept {
|
||||
spread_init(weights, biases, fan_in, 0.f, rng);
|
||||
}
|
||||
|
||||
inline void xavier_init(std::span<float> weights,
|
||||
std::span<float> biases,
|
||||
std::size_t fan_in,
|
||||
Rng& rng) noexcept {
|
||||
spread_init(weights, biases, fan_in, 1.f, rng);
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
62
nisps/ml/loss.hpp
Normal file
62
nisps/ml/loss.hpp
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// nisps/ml/loss.hpp — MSE loss with a deliberate fix for the meml-ues
|
||||
// double-scaling bug.
|
||||
//
|
||||
// THE BUG (in `src/memlp/Loss.h::MSE` and `src/memlp/MLP.cpp::Train`):
|
||||
// The legacy MSE computed loss as
|
||||
// sum((diff^2) / NOut) * sampleSizeReciprocal in Loss.h
|
||||
// and then `Train()` did
|
||||
// current_iteration_cost_function *= sampleSizeReciprocal; // ← second
|
||||
// So the unweighted average loss was scaled by 1/N twice — once in MSE()
|
||||
// and once in the outer training loop. Loss values for the same training
|
||||
// set get reported 1/N too small, which is misleading for diagnostics.
|
||||
//
|
||||
// THE FIX:
|
||||
// Loss is computed PER-SAMPLE here. The training loop is responsible for
|
||||
// averaging across samples once. There is no implicit per-sample weight in
|
||||
// the MSE function — that is the caller's policy decision.
|
||||
//
|
||||
// For a single sample: mse = (1/NOut) * sum_j (label_j - pred_j)^2
|
||||
// Derivative wrt pred_j: -(2/NOut) * (label_j - pred_j)
|
||||
//
|
||||
// When sample weights are supplied to `train()` they are applied at the
|
||||
// sample level (loss_total = sum_i weight_i * mse_i), and the gradient
|
||||
// per-sample is scaled by weight_i. Sample weights must sum to 1.0 — the
|
||||
// caller normalizes (matches legacy contract).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
|
||||
#include "../core/perf.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Compute MSE loss for one sample and write the per-output derivative into
|
||||
// `loss_deriv`. Returns the scalar loss for this sample.
|
||||
//
|
||||
// loss = (1/NOut) * sum_j (label_j - pred_j)^2
|
||||
// d(loss)/d(pred_j) = -(2/NOut) * (label_j - pred_j)
|
||||
//
|
||||
// `loss_deriv` and `pred` must both have size NOut. `label` likewise.
|
||||
//
|
||||
// NB: We deliberately do NOT multiply by any sampleSizeReciprocal here. The
|
||||
// caller is responsible for averaging across samples (and scaling by sample
|
||||
// weights, if any) — see training.hpp.
|
||||
NISPS_FORCE_INLINE float mse_per_sample(std::span<const float> label,
|
||||
std::span<const float> pred,
|
||||
std::span<float> loss_deriv) noexcept {
|
||||
const std::size_t n = pred.size();
|
||||
if (n == 0u) return 0.f;
|
||||
const float inv_n = 1.f / static_cast<float>(n);
|
||||
|
||||
float accum = 0.f;
|
||||
for (std::size_t j = 0; j < n; ++j) {
|
||||
const float diff = label[j] - pred[j]; // sign chosen so deriv is wrt pred
|
||||
accum += diff * diff * inv_n;
|
||||
loss_deriv[j] = -2.f * inv_n * diff;
|
||||
}
|
||||
return accum;
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
570
nisps/ml/mlp.hpp
Normal file
570
nisps/ml/mlp.hpp
Normal file
|
|
@ -0,0 +1,570 @@
|
|||
// nisps/ml/mlp.hpp — fixed-architecture MLP, four layers (three hidden +
|
||||
// output). All buffers are template-sized std::array; zero heap allocation
|
||||
// in inference, training, and the dataset path.
|
||||
//
|
||||
// ARCHITECTURE
|
||||
// MLP<NIn, NHidden1, NHidden2, NHidden3, NOut, NMaxExamples = 128>
|
||||
// ┌──────┐ 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
|
||||
//
|
||||
// We support exactly three hidden layers. The legacy firmware default is
|
||||
// [10, 10, 14], so the MVP signature directly matches `MLP<NIn, 10, 10, 14,
|
||||
// NOut>`. Variable layer count is deferred — see architecture.md.
|
||||
//
|
||||
// MEMORY MODEL
|
||||
// Per layer L_k with fan_in = N_in[k], fan_out = N_out[k]:
|
||||
// std::array<float, fan_in*fan_out> weights // row-major
|
||||
// std::array<float, fan_out> biases
|
||||
// std::array<float, fan_out> pre_activation // cached for backprop
|
||||
// std::array<float, fan_out> activation // cached for backprop
|
||||
// std::array<float, fan_in*fan_out> grad_w_accum // for backprop
|
||||
// std::array<float, fan_out> grad_b_accum
|
||||
//
|
||||
// Per MLP:
|
||||
// std::array<float, NIn> input_buffer (current set_input values)
|
||||
// std::array<float, NOut> output (post-final-activation; outputs())
|
||||
// std::array<float, NMaxExamples * NIn> dataset_features
|
||||
// std::array<float, NMaxExamples * NOut> dataset_labels
|
||||
// std::size_t dataset_count, dataset_head (FIFO ring buffer)
|
||||
// std::array<float, NMaxIter> loss_history (max iters from train())
|
||||
// Rng rng_
|
||||
// std::array<float, NOut> bp_err_buf, bp_delta_buf (backprop scratch)
|
||||
//
|
||||
// FLAT WEIGHT LAYOUT (`get_weights` / `set_weights`)
|
||||
// [layer0_weights ...] [layer1_weights ...] [layer2_weights ...] [layer3_weights ...]
|
||||
// [layer0_biases ...] [layer1_biases ...] [layer2_biases ...] [layer3_biases ...]
|
||||
// Documented in detail near `weight_count()`.
|
||||
//
|
||||
// CONCEPT SATISFACTION
|
||||
// The class satisfies `nisps::MLEngine`:
|
||||
// set_input, process, outputs, add_example, train (no-arg overload
|
||||
// returning float), move_weights(speed, spread), draw_weights(spread),
|
||||
// reset, seed.
|
||||
// Plus diagnostics required by the broader API (see Stream 2 brief in
|
||||
// architecture.md): eval_loss, layer_stats, get/set_weights, weight_count,
|
||||
// infer_batch, loss_history.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "../core/concepts.hpp"
|
||||
#include "../core/perf.hpp"
|
||||
#include "../core/rng.hpp"
|
||||
#include "activations.hpp"
|
||||
#include "init.hpp"
|
||||
#include "loss.hpp"
|
||||
#include "rl.hpp"
|
||||
#include "stats.hpp"
|
||||
#include "training.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Layer<FanIn, FanOut, Act>. Stores its weights, biases, and the work
|
||||
// buffers needed for forward + backprop. Header-only, all sizes compile-
|
||||
// time. Each method is small; the compiler will inline through.
|
||||
template <std::size_t FanIn, std::size_t FanOut, Activation Act>
|
||||
struct Layer {
|
||||
static constexpr std::size_t kFanIn = FanIn;
|
||||
static constexpr std::size_t kFanOut = FanOut;
|
||||
static constexpr Activation kAct = Act;
|
||||
|
||||
std::array<float, FanIn * FanOut> weights{};
|
||||
std::array<float, FanOut> biases{};
|
||||
// Cached during forward(); consumed during backprop().
|
||||
std::array<float, FanOut> pre_activation{};
|
||||
std::array<float, FanOut> activation{};
|
||||
// Gradient accumulators — used per-sample for SGD weight update.
|
||||
std::array<float, FanIn * FanOut> grad_w{};
|
||||
std::array<float, FanOut> grad_b{};
|
||||
|
||||
NISPS_FORCE_INLINE float& w(std::size_t node, std::size_t in) noexcept {
|
||||
return weights[node * FanIn + in];
|
||||
}
|
||||
NISPS_FORCE_INLINE float w(std::size_t node, std::size_t in) const noexcept {
|
||||
return weights[node * FanIn + in];
|
||||
}
|
||||
|
||||
// Forward: compute pre_activation and activation given an input span.
|
||||
NISPS_HOT NISPS_FORCE_INLINE
|
||||
void forward(std::span<const float, FanIn> input) noexcept {
|
||||
for (std::size_t node = 0; node < FanOut; ++node) {
|
||||
const std::size_t row = node * FanIn;
|
||||
float sum = biases[node];
|
||||
for (std::size_t j = 0; j < FanIn; ++j) {
|
||||
sum += weights[row + j] * input[j];
|
||||
}
|
||||
pre_activation[node] = sum;
|
||||
activation[node] = activate<Act>(sum);
|
||||
}
|
||||
}
|
||||
|
||||
// Compute incoming-error vector for the previous layer:
|
||||
// delta_in[j] = sum_node (err_signal[node] * w[node, j])
|
||||
// Where err_signal[node] = upstream_err[node] * d/dpre activation.
|
||||
// Also accumulates per-weight and per-bias gradients (no LR yet).
|
||||
NISPS_HOT NISPS_FORCE_INLINE
|
||||
void backprop_accumulate(std::span<const float, FanIn> input,
|
||||
std::span<const float, FanOut> upstream_err,
|
||||
std::span<float, FanIn> delta_in,
|
||||
float sample_weight) noexcept {
|
||||
for (std::size_t j = 0; j < FanIn; ++j) delta_in[j] = 0.f;
|
||||
|
||||
for (std::size_t node = 0; node < FanOut; ++node) {
|
||||
const float err_signal =
|
||||
upstream_err[node] * activate_deriv_pre<Act>(pre_activation[node]) * sample_weight;
|
||||
const std::size_t row = node * FanIn;
|
||||
for (std::size_t j = 0; j < FanIn; ++j) {
|
||||
grad_w[row + j] += err_signal * input[j];
|
||||
delta_in[j] += err_signal * weights[row + j];
|
||||
}
|
||||
grad_b[node] += err_signal;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply accumulated gradient to weights+biases with clipping. Resets
|
||||
// the accumulators to zero for the next sample/iteration.
|
||||
NISPS_FORCE_INLINE
|
||||
void apply_grad(float lr) noexcept {
|
||||
for (std::size_t i = 0; i < FanIn * FanOut; ++i) {
|
||||
const float g = clip_gradient(grad_w[i]);
|
||||
weights[i] -= lr * g;
|
||||
grad_w[i] = 0.f;
|
||||
}
|
||||
for (std::size_t i = 0; i < FanOut; ++i) {
|
||||
const float g = clip_gradient(grad_b[i]);
|
||||
biases[i] -= lr * g;
|
||||
grad_b[i] = 0.f;
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_FORCE_INLINE
|
||||
void clear_grad() noexcept {
|
||||
for (std::size_t i = 0; i < FanIn * FanOut; ++i) grad_w[i] = 0.f;
|
||||
for (std::size_t i = 0; i < FanOut; ++i) grad_b[i] = 0.f;
|
||||
}
|
||||
};
|
||||
|
||||
// MLP<NIn, NHidden1, NHidden2, NHidden3, NOut, NMaxExamples = 128>
|
||||
//
|
||||
// MaxIterTrain caps the loss-history buffer; if a caller asks for more
|
||||
// iterations they will be honored at runtime, but only the first
|
||||
// kMaxIterTrain are recorded for inspection. 4096 fits the playground's
|
||||
// upper bound and costs 16 KiB.
|
||||
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 = 128u,
|
||||
std::size_t NMaxIterTrain = 4096u>
|
||||
class MLP {
|
||||
public:
|
||||
static constexpr std::size_t kInput = NIn;
|
||||
static constexpr std::size_t kHidden1 = NHidden1;
|
||||
static constexpr std::size_t kHidden2 = NHidden2;
|
||||
static constexpr std::size_t kHidden3 = NHidden3;
|
||||
static constexpr std::size_t kOutput = NOut;
|
||||
static constexpr std::size_t kMaxExamples = NMaxExamples;
|
||||
static constexpr std::size_t kMaxIterTrain = NMaxIterTrain;
|
||||
static constexpr std::size_t kNumLayers = 4u;
|
||||
|
||||
using Layer0 = Layer<NIn, NHidden1, Activation::ReLU>;
|
||||
using Layer1 = Layer<NHidden1, NHidden2, Activation::ReLU>;
|
||||
using Layer2 = Layer<NHidden2, NHidden3, Activation::ReLU>;
|
||||
using Layer3 = Layer<NHidden3, NOut, Activation::Sigmoid>;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------
|
||||
explicit MLP(std::uint64_t seed) noexcept : rng_(seed) {
|
||||
// Default-init weights with spread=1 (Xavier-like). The IML
|
||||
// interface caller is expected to draw_weights() with the
|
||||
// playground spread before the first inference; this default
|
||||
// simply gives us a non-degenerate starting state for tests
|
||||
// that skip an explicit draw.
|
||||
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 (i < NIn) input_[i] = v;
|
||||
}
|
||||
|
||||
NISPS_HOT void process() noexcept {
|
||||
forward_(std::span<const float, NIn>(input_));
|
||||
// Mirror final activation into the output buffer so callers can
|
||||
// read a stable span.
|
||||
const auto& a = layer3_.activation;
|
||||
for (std::size_t i = 0; i < NOut; ++i) output_[i] = a[i];
|
||||
}
|
||||
|
||||
NISPS_FORCE_INLINE std::span<const float> outputs() const noexcept {
|
||||
return std::span<const float>(output_.data(), NOut);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Dataset / Training (concept: add_example / train)
|
||||
// ---------------------------------------------------------------
|
||||
// FIFO ring buffer; oldest example evicted when full. No allocation.
|
||||
// We don't track logical insertion order during training because SGD
|
||||
// doesn't care — the iteration order over the buffer is arbitrary.
|
||||
void add_example(std::span<const float> features,
|
||||
std::span<const float> labels) noexcept {
|
||||
if (features.size() < NIn || labels.size() < NOut) return;
|
||||
|
||||
std::size_t slot;
|
||||
if (dataset_count_ < NMaxExamples) {
|
||||
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) % NMaxExamples;
|
||||
}
|
||||
const std::size_t f_off = slot * NIn;
|
||||
const std::size_t l_off = slot * NOut;
|
||||
for (std::size_t i = 0; i < NIn; ++i) ds_features_[f_off + i] = features[i];
|
||||
for (std::size_t i = 0; i < NOut; ++i) ds_labels_ [l_off + i] = labels[i];
|
||||
}
|
||||
|
||||
// Concept-required no-arg overload. Default learning rate matches the
|
||||
// playground's "sane RL training" knob; max_iter and min_err follow.
|
||||
float train() noexcept {
|
||||
return train(1.f, 1000u, 0.001f, std::span<const float>{});
|
||||
}
|
||||
|
||||
// Full SGD training. `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
|
||||
// `loss_history_` (bounded by kMaxIterTrain).
|
||||
float train(float lr,
|
||||
std::size_t max_iter,
|
||||
float min_err,
|
||||
std::span<const float> sample_weights = {}) noexcept {
|
||||
loss_history_count_ = 0u;
|
||||
if (dataset_count_ == 0u) return 0.f;
|
||||
|
||||
const bool weighted = !sample_weights.empty();
|
||||
const float uniform_w = 1.f / static_cast<float>(dataset_count_);
|
||||
|
||||
float epoch_loss = 0.f;
|
||||
for (std::size_t iter = 0; iter < max_iter; ++iter) {
|
||||
epoch_loss = 0.f;
|
||||
// SGD: 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, NIn> x = sample_features_(s);
|
||||
forward_(x);
|
||||
|
||||
// Per-sample loss (NOT scaled by 1/N — the meml-ues fix).
|
||||
std::array<float, NOut> deriv{};
|
||||
const float sample_loss = mse_per_sample(
|
||||
sample_labels_(s),
|
||||
std::span<const float>(layer3_.activation.data(), NOut),
|
||||
std::span<float>(deriv.data(), NOut));
|
||||
|
||||
// Aggregate weighted loss.
|
||||
epoch_loss += w * sample_loss;
|
||||
|
||||
// Backprop with the same w as the gradient scaler.
|
||||
backprop_(x, std::span<const float, NOut>(deriv), w);
|
||||
|
||||
// Apply gradient (per-sample, SGD).
|
||||
layer3_.apply_grad(lr);
|
||||
layer2_.apply_grad(lr);
|
||||
layer1_.apply_grad(lr);
|
||||
layer0_.apply_grad(lr);
|
||||
}
|
||||
|
||||
if (loss_history_count_ < NMaxIterTrain) {
|
||||
loss_history_[loss_history_count_++] = epoch_loss;
|
||||
}
|
||||
|
||||
if (epoch_loss < min_err) break;
|
||||
}
|
||||
return epoch_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 {
|
||||
move_weights_layer(std::span<float>(layer0_.weights), std::span<float>(layer0_.biases),
|
||||
Layer0::kFanIn, speed, spread, /*final=*/false, {}, rng_);
|
||||
move_weights_layer(std::span<float>(layer1_.weights), std::span<float>(layer1_.biases),
|
||||
Layer1::kFanIn, speed, spread, /*final=*/false, {}, rng_);
|
||||
move_weights_layer(std::span<float>(layer2_.weights), std::span<float>(layer2_.biases),
|
||||
Layer2::kFanIn, speed, spread, /*final=*/false, {}, rng_);
|
||||
move_weights_layer(std::span<float>(layer3_.weights), std::span<float>(layer3_.biases),
|
||||
Layer3::kFanIn, speed, spread, /*final=*/true, output_pin_mask, rng_);
|
||||
}
|
||||
|
||||
void draw_weights(float spread) noexcept {
|
||||
draw_weights_layer(std::span<float>(layer0_.weights), std::span<float>(layer0_.biases),
|
||||
Layer0::kFanIn, spread, rng_);
|
||||
draw_weights_layer(std::span<float>(layer1_.weights), std::span<float>(layer1_.biases),
|
||||
Layer1::kFanIn, spread, rng_);
|
||||
draw_weights_layer(std::span<float>(layer2_.weights), std::span<float>(layer2_.biases),
|
||||
Layer2::kFanIn, spread, rng_);
|
||||
draw_weights_layer(std::span<float>(layer3_.weights), std::span<float>(layer3_.biases),
|
||||
Layer3::kFanIn, spread, rng_);
|
||||
layer0_.clear_grad();
|
||||
layer1_.clear_grad();
|
||||
layer2_.clear_grad();
|
||||
layer3_.clear_grad();
|
||||
}
|
||||
|
||||
// Concept reset: clear weights, dataset, and loss history. Seed is
|
||||
// intentionally NOT reset (use `seed()` for that).
|
||||
void reset() noexcept {
|
||||
clear_dataset_();
|
||||
loss_history_count_ = 0u;
|
||||
// Re-init weights from current rng state with default spread.
|
||||
draw_weights(1.f);
|
||||
for (std::size_t i = 0; i < NIn; ++i) input_[i] = 0.f;
|
||||
for (std::size_t i = 0; i < NOut; ++i) output_[i] = 0.f;
|
||||
}
|
||||
|
||||
void seed(std::uint64_t s) noexcept { rng_.seed(s); }
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Diagnostics
|
||||
// ---------------------------------------------------------------
|
||||
// Forward pass + MSE on a single (input, label) implied by current
|
||||
// input_ and the most recent training labels — i.e. "what would the
|
||||
// loss be on the current input if the label were the current output?"
|
||||
// For now we report the average loss across the training set without
|
||||
// updating weights. Useful for non-destructive evaluation.
|
||||
float eval_loss() const noexcept {
|
||||
if (dataset_count_ == 0u) return 0.f;
|
||||
const float inv_n = 1.f / static_cast<float>(dataset_count_);
|
||||
// We need a non-const forward pass to use the cached buffers; since
|
||||
// eval_loss is logically const, fork a local computation that
|
||||
// doesn't touch member buffers. That means recomputing through the
|
||||
// layer weights against scratch arrays — no allocation, just stack.
|
||||
float total = 0.f;
|
||||
for (std::size_t s = 0; s < dataset_count_; ++s) {
|
||||
std::array<float, NHidden1> a1{};
|
||||
std::array<float, NHidden2> a2{};
|
||||
std::array<float, NHidden3> a3{};
|
||||
std::array<float, NOut> ao{};
|
||||
|
||||
const std::size_t f_off = s * NIn;
|
||||
forward_const_layer<NIn, NHidden1, Activation::ReLU>(
|
||||
std::span<const float, NIn>(ds_features_.data() + f_off, NIn),
|
||||
layer0_.weights, layer0_.biases, a1);
|
||||
forward_const_layer<NHidden1, NHidden2, Activation::ReLU>(
|
||||
std::span<const float, NHidden1>(a1), layer1_.weights, layer1_.biases, a2);
|
||||
forward_const_layer<NHidden2, NHidden3, Activation::ReLU>(
|
||||
std::span<const float, NHidden2>(a2), layer2_.weights, layer2_.biases, a3);
|
||||
forward_const_layer<NHidden3, NOut, Activation::Sigmoid>(
|
||||
std::span<const float, NHidden3>(a3), layer3_.weights, layer3_.biases, ao);
|
||||
|
||||
float sse = 0.f;
|
||||
const float inv_o = 1.f / static_cast<float>(NOut);
|
||||
const std::size_t l_off = s * NOut;
|
||||
for (std::size_t j = 0; j < NOut; ++j) {
|
||||
const float d = ds_labels_[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 {
|
||||
switch (layer_idx) {
|
||||
case 0: return compute_layer_stats(layer0_.weights, layer0_.biases);
|
||||
case 1: return compute_layer_stats(layer1_.weights, layer1_.biases);
|
||||
case 2: return compute_layer_stats(layer2_.weights, layer2_.biases);
|
||||
case 3: return compute_layer_stats(layer3_.weights, layer3_.biases);
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Flat layout: layer0 weights, layer1 weights, layer2 weights, layer3
|
||||
// weights, then layer0..3 biases. The split is `weights first all
|
||||
// layers, then biases all layers` so callers serializing weights can
|
||||
// pre-compute offsets without consulting layer-specific tables.
|
||||
static constexpr std::size_t weight_count() noexcept {
|
||||
return NIn * NHidden1 + NHidden1 * NHidden2 + NHidden2 * NHidden3 + NHidden3 * NOut
|
||||
+ NHidden1 + NHidden2 + NHidden3 + NOut;
|
||||
}
|
||||
|
||||
// Returns a span into a member-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 {
|
||||
std::size_t k = 0u;
|
||||
// Weights, layer-major.
|
||||
for (float v : layer0_.weights) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer1_.weights) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer2_.weights) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer3_.weights) flat_weight_buf_[k++] = v;
|
||||
// Biases.
|
||||
for (float v : layer0_.biases) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer1_.biases) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer2_.biases) flat_weight_buf_[k++] = v;
|
||||
for (float v : layer3_.biases) flat_weight_buf_[k++] = v;
|
||||
return std::span<const float>(flat_weight_buf_.data(), weight_count());
|
||||
}
|
||||
|
||||
void set_weights(std::span<const float> w) noexcept {
|
||||
if (w.size() < weight_count()) return;
|
||||
std::size_t k = 0u;
|
||||
for (float& v : layer0_.weights) v = w[k++];
|
||||
for (float& v : layer1_.weights) v = w[k++];
|
||||
for (float& v : layer2_.weights) v = w[k++];
|
||||
for (float& v : layer3_.weights) v = w[k++];
|
||||
for (float& v : layer0_.biases) v = w[k++];
|
||||
for (float& v : layer1_.biases) v = w[k++];
|
||||
for (float& v : layer2_.biases) v = w[k++];
|
||||
for (float& v : layer3_.biases) v = w[k++];
|
||||
}
|
||||
|
||||
// Run inference on N points (each NIn-sized) and write N output vectors
|
||||
// (each NOut-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 {
|
||||
const std::size_t n = points.size() / NIn;
|
||||
if (outs.size() < n * NOut) return;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const std::size_t in_off = i * NIn;
|
||||
for (std::size_t j = 0; j < NIn; ++j) input_[j] = points[in_off + j];
|
||||
process();
|
||||
const std::size_t out_off = i * NOut;
|
||||
for (std::size_t j = 0; j < NOut; ++j) outs[out_off + j] = output_[j];
|
||||
}
|
||||
}
|
||||
|
||||
std::span<const float> loss_history() const noexcept {
|
||||
return std::span<const float>(loss_history_.data(), loss_history_count_);
|
||||
}
|
||||
|
||||
std::size_t example_count() const noexcept { return dataset_count_; }
|
||||
|
||||
void clear_examples() noexcept { clear_dataset_(); }
|
||||
|
||||
private:
|
||||
// ---------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------
|
||||
NISPS_HOT NISPS_FORCE_INLINE
|
||||
void forward_(std::span<const float, NIn> in) noexcept {
|
||||
layer0_.forward(in);
|
||||
layer1_.forward(std::span<const float, NHidden1>(layer0_.activation));
|
||||
layer2_.forward(std::span<const float, NHidden2>(layer1_.activation));
|
||||
layer3_.forward(std::span<const float, NHidden3>(layer2_.activation));
|
||||
}
|
||||
|
||||
// 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, NIn> input,
|
||||
std::span<const float, NOut> output_deriv,
|
||||
float sample_weight) noexcept {
|
||||
std::array<float, NHidden3> d3{};
|
||||
std::array<float, NHidden2> d2{};
|
||||
std::array<float, NHidden1> d1{};
|
||||
std::array<float, NIn> d0{};
|
||||
|
||||
layer3_.backprop_accumulate(
|
||||
std::span<const float, NHidden3>(layer2_.activation),
|
||||
output_deriv,
|
||||
std::span<float, NHidden3>(d3),
|
||||
sample_weight);
|
||||
layer2_.backprop_accumulate(
|
||||
std::span<const float, NHidden2>(layer1_.activation),
|
||||
std::span<const float, NHidden3>(d3),
|
||||
std::span<float, NHidden2>(d2),
|
||||
1.f); // weight already in d3
|
||||
layer1_.backprop_accumulate(
|
||||
std::span<const float, NHidden1>(layer0_.activation),
|
||||
std::span<const float, NHidden2>(d2),
|
||||
std::span<float, NHidden1>(d1),
|
||||
1.f);
|
||||
layer0_.backprop_accumulate(
|
||||
input,
|
||||
std::span<const float, NHidden1>(d1),
|
||||
std::span<float, NIn>(d0),
|
||||
1.f);
|
||||
}
|
||||
|
||||
// const forward pass for diagnostics. Doesn't touch layer caches.
|
||||
template <std::size_t Fi, std::size_t Fo, Activation A>
|
||||
static NISPS_FORCE_INLINE void forward_const_layer(
|
||||
std::span<const float, Fi> in,
|
||||
const std::array<float, Fi*Fo>& w,
|
||||
const std::array<float, Fo>& b,
|
||||
std::array<float, Fo>& out) noexcept {
|
||||
for (std::size_t node = 0; node < Fo; ++node) {
|
||||
const std::size_t row = node * Fi;
|
||||
float sum = b[node];
|
||||
for (std::size_t j = 0; j < Fi; ++j) sum += w[row + j] * in[j];
|
||||
out[node] = activate<A>(sum);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_FORCE_INLINE std::span<const float, NIn> sample_features_(std::size_t s) const noexcept {
|
||||
return std::span<const float, NIn>(ds_features_.data() + s * NIn, NIn);
|
||||
}
|
||||
NISPS_FORCE_INLINE std::span<const float> sample_labels_(std::size_t s) const noexcept {
|
||||
return std::span<const float>(ds_labels_.data() + s * NOut, NOut);
|
||||
}
|
||||
|
||||
void clear_dataset_() noexcept {
|
||||
dataset_count_ = 0u;
|
||||
dataset_head_ = 0u;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Members
|
||||
// ---------------------------------------------------------------
|
||||
Layer0 layer0_{};
|
||||
Layer1 layer1_{};
|
||||
Layer2 layer2_{};
|
||||
Layer3 layer3_{};
|
||||
|
||||
std::array<float, NIn> input_{};
|
||||
std::array<float, NOut> output_{};
|
||||
|
||||
std::array<float, NMaxExamples * NIn> ds_features_{};
|
||||
std::array<float, NMaxExamples * NOut> ds_labels_{};
|
||||
std::size_t dataset_count_ = 0u;
|
||||
std::size_t dataset_head_ = 0u;
|
||||
|
||||
std::array<float, weight_count()> flat_weight_buf_{};
|
||||
std::array<float, NMaxIterTrain> loss_history_{};
|
||||
std::size_t loss_history_count_ = 0u;
|
||||
|
||||
Rng rng_;
|
||||
};
|
||||
|
||||
// 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
|
||||
112
nisps/ml/rl.hpp
Normal file
112
nisps/ml/rl.hpp
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
// nisps/ml/rl.hpp — reinforcement-style weight perturbation primitives.
|
||||
//
|
||||
// `move_weights` and `draw_weights` are the two playground RL operations
|
||||
// applied to network weights:
|
||||
// - `draw_weights(spread)`: re-randomize all weights using the spread-
|
||||
// aware scale (see init.hpp). This is the "thumbs-down really hard"
|
||||
// button.
|
||||
// - `move_weights(speed, spread, output_pin_mask)`: per-call weight decay
|
||||
// followed by gaussian noise injection. This is the "thumbs-down a
|
||||
// little" feedback. The pin mask, if supplied, freezes weights feeding
|
||||
// specific OUTPUT nodes — only on the FINAL layer.
|
||||
//
|
||||
// PARITY CONTRACT WITH LEGACY JS PLAYGROUND
|
||||
// These match `playground/_archive/js/nisps/mlp.js::moveWeights` and
|
||||
// `drawWeights` semantically. Specifically:
|
||||
// * Per-layer scale: layer_scale = (1 - spread) + spread / sqrt(fan_in)
|
||||
// * Weight decay: each weight gets multiplied by (1 - 0.1 * spread)
|
||||
// BEFORE noise is added. spread=0 ⇒ no decay,
|
||||
// spread=1 ⇒ ~10% decay per call.
|
||||
// * Noise: gaussian via rng.next_float_gaussian(speed *
|
||||
// layer_scale). The legacy code uses a sum-of-three-
|
||||
// uniforms gaussian, and our `Rng` matches that
|
||||
// shape (see core/rng.hpp).
|
||||
// * Output pin mask: 1 byte per output node. If mask[i]==1, all weights
|
||||
// AND the bias feeding output node i in the final
|
||||
// layer are skipped. Hidden layers are unaffected by
|
||||
// the mask.
|
||||
// * Biases: included in the noise injection, unaffected by
|
||||
// decay (matching the legacy JS behavior — decay was
|
||||
// applied to weights only there).
|
||||
//
|
||||
// The functions are layer-scoped; the MLP class (mlp.hpp) iterates through
|
||||
// its layers, calling these for each.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <span>
|
||||
|
||||
#include "../core/rng.hpp"
|
||||
#include "init.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Per-call weight-decay coefficient. spread=0 ⇒ 1.0 (no decay), spread=1 ⇒
|
||||
// 0.9 (10% decay per call). See architecture spread §3 in CLAUDE.md.
|
||||
inline float weight_decay_factor(float spread) noexcept {
|
||||
return 1.f - 0.1f * spread;
|
||||
}
|
||||
|
||||
// Apply RL perturbation to one layer's weights+biases. `is_final_layer`
|
||||
// gates the application of `output_pin_mask`. `output_pin_mask`, if
|
||||
// non-empty, must be sized fan_out.
|
||||
//
|
||||
// Effects, applied in order per weight w:
|
||||
// w *= (1 - 0.1 * spread)
|
||||
// w += gaussian(speed * layer_scale)
|
||||
// Biases:
|
||||
// b += gaussian(speed * layer_scale)
|
||||
// Skip rule on final layer if mask[node]==1: leave w/b untouched.
|
||||
inline void move_weights_layer(std::span<float> weights,
|
||||
std::span<float> biases,
|
||||
std::size_t fan_in,
|
||||
float speed,
|
||||
float spread,
|
||||
bool is_final_layer,
|
||||
std::span<const std::uint8_t> output_pin_mask,
|
||||
Rng& rng) noexcept {
|
||||
const float layer_scale = spread_scale(spread, fan_in);
|
||||
const float noise_stddev = speed * layer_scale;
|
||||
const float decay = weight_decay_factor(spread);
|
||||
|
||||
const std::size_t fan_out = biases.size();
|
||||
|
||||
for (std::size_t node = 0; node < fan_out; ++node) {
|
||||
const bool skip =
|
||||
is_final_layer && !output_pin_mask.empty() && output_pin_mask[node] != 0u;
|
||||
|
||||
// Bias perturbation.
|
||||
if (!skip) {
|
||||
biases[node] += rng.next_float_gaussian(noise_stddev);
|
||||
}
|
||||
|
||||
// Per-weight decay+noise. We always advance the RNG even on skip
|
||||
// so that the random stream is independent of pin-mask state — this
|
||||
// makes parity tests deterministic regardless of which outputs are
|
||||
// pinned. Otherwise toggling a pin would shift the entire
|
||||
// downstream noise sequence, which would be surprising.
|
||||
const std::size_t row_off = node * fan_in;
|
||||
for (std::size_t j = 0; j < fan_in; ++j) {
|
||||
const float noise = rng.next_float_gaussian(noise_stddev);
|
||||
if (!skip) {
|
||||
float& w = weights[row_off + j];
|
||||
w = w * decay + noise;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-randomize one layer's weights using the spread-aware scale. Biases are
|
||||
// reset to zero, matching `init.hpp::spread_init`.
|
||||
inline void draw_weights_layer(std::span<float> weights,
|
||||
std::span<float> biases,
|
||||
std::size_t fan_in,
|
||||
float spread,
|
||||
Rng& rng) noexcept {
|
||||
spread_init(weights, biases, fan_in, spread, rng);
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
76
nisps/ml/stats.hpp
Normal file
76
nisps/ml/stats.hpp
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// nisps/ml/stats.hpp — per-layer weight-health diagnostics.
|
||||
//
|
||||
// Mirrors the `nisps_mlp_get_layer_stats` WASM binding semantics: for each
|
||||
// layer, return mean(|w|), max(|w|), dead fraction, and saturating fraction.
|
||||
// These are used by the playground "weight health" panel.
|
||||
//
|
||||
// Definitions:
|
||||
// - mean_abs: average of |w| across all weights+biases of the layer
|
||||
// - max_abs: max of |w| across all weights+biases
|
||||
// - dead_frac: fraction of weights with |w| < kDeadThresh
|
||||
// - saturating_frac: fraction with |w| > kSaturatingThresh
|
||||
//
|
||||
// Thresholds are chosen to match the JS engine's heuristic targets:
|
||||
// dead: 0.001 (a weight smaller than this contributes negligibly)
|
||||
// sat: 3.0 (weights larger than this typically push sigmoid to its
|
||||
// rails — a smell during normal training but normal during
|
||||
// RL exploration)
|
||||
//
|
||||
// We intentionally include biases in mean/max but not in dead/sat fractions
|
||||
// — biases are scarce relative to weights and including them would skew the
|
||||
// fractions, especially at startup when biases are zero.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <span>
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
inline constexpr float kDeadThresh = 0.001f;
|
||||
inline constexpr float kSaturatingThresh = 3.0f;
|
||||
|
||||
struct LayerStats {
|
||||
float mean_abs = 0.f;
|
||||
float max_abs = 0.f;
|
||||
float dead_frac = 0.f;
|
||||
float saturating_frac = 0.f;
|
||||
};
|
||||
|
||||
inline LayerStats compute_layer_stats(std::span<const float> weights,
|
||||
std::span<const float> biases) noexcept {
|
||||
LayerStats out;
|
||||
const std::size_t total = weights.size() + biases.size();
|
||||
if (total == 0u) return out;
|
||||
|
||||
float sum = 0.f;
|
||||
float max_abs = 0.f;
|
||||
std::size_t dead = 0u;
|
||||
std::size_t sat = 0u;
|
||||
|
||||
for (float w : weights) {
|
||||
const float a = std::fabs(w);
|
||||
sum += a;
|
||||
if (a > max_abs) max_abs = a;
|
||||
if (a < kDeadThresh) ++dead;
|
||||
if (a > kSaturatingThresh) ++sat;
|
||||
}
|
||||
for (float b : biases) {
|
||||
const float a = std::fabs(b);
|
||||
sum += a;
|
||||
if (a > max_abs) max_abs = a;
|
||||
}
|
||||
|
||||
out.mean_abs = sum / static_cast<float>(total);
|
||||
out.max_abs = max_abs;
|
||||
|
||||
const float w_count = static_cast<float>(weights.size());
|
||||
if (w_count > 0.f) {
|
||||
out.dead_frac = static_cast<float>(dead) / w_count;
|
||||
out.saturating_frac = static_cast<float>(sat) / w_count;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
37
nisps/ml/training.hpp
Normal file
37
nisps/ml/training.hpp
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// nisps/ml/training.hpp — gradient-clipping helper and per-layer SGD update.
|
||||
//
|
||||
// The MLP class owns the training loop because it knows the dataset layout
|
||||
// and weight buffers. This header provides:
|
||||
// - kGradClip: ±10.0, matches the legacy firmware/Layer.h clamp.
|
||||
// - clip_gradient(): scalar clipper, applied to each accumulated gradient
|
||||
// before the weight update.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// Optimizer choice: this MVP ships SGD only. RMSProp is planned (the legacy
|
||||
// firmware uses it for `TrainBatch`) but the playground was using plain SGD
|
||||
// until very recently and the XOR-convergence benchmark in the test suite
|
||||
// is the clearer target. RMSProp can land as a follow-up — see the bd
|
||||
// issue notes. For now `train()` is SGD with optional sample-weight
|
||||
// scaling and gradient clipping.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "../core/perf.hpp"
|
||||
|
||||
namespace nisps::ml {
|
||||
|
||||
// Per-element gradient clip threshold. Matches the legacy firmware's
|
||||
// gradientClipValue in Layer.h::ApplyAccumulatedGradients.
|
||||
inline constexpr float kGradClip = 10.f;
|
||||
|
||||
NISPS_FORCE_INLINE float clip_gradient(float g) noexcept {
|
||||
if (g > kGradClip) return kGradClip;
|
||||
if (g < -kGradClip) return -kGradClip;
|
||||
return g;
|
||||
}
|
||||
|
||||
} // namespace nisps::ml
|
||||
114
tests/cpp/test_mlp_inference.cpp
Normal file
114
tests/cpp/test_mlp_inference.cpp
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// tests/cpp/test_mlp_inference.cpp — verify that the forward pass computes
|
||||
// the expected linear+activation chain. We construct an MLP with known
|
||||
// weights via set_weights(), run inference, and compare against a hand-
|
||||
// computed result.
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
using TinyMLP = nisps::ml::MLP<2, 2, 2, 2, 1, 8, 32>;
|
||||
|
||||
// Manually compute the forward pass using the same activation rules as the
|
||||
// MLP. ReLU on hidden layers (leaky 0.01), sigmoid on output.
|
||||
float manual_forward(float x0, float x1,
|
||||
const std::array<float, 4>& w0, const std::array<float, 2>& b0,
|
||||
const std::array<float, 4>& w1, const std::array<float, 2>& b1,
|
||||
const std::array<float, 4>& w2, const std::array<float, 2>& b2,
|
||||
const std::array<float, 2>& w3, const std::array<float, 1>& b3) {
|
||||
auto leaky_relu = [](float v) { return v > 0.f ? v : 0.01f * v; };
|
||||
auto sig = [](float v) {
|
||||
if (v > 40.f) return 1.f;
|
||||
if (v < -40.f) return 0.f;
|
||||
return 1.f / (1.f + std::exp(-v));
|
||||
};
|
||||
|
||||
// Layer 0: 2 → 2, ReLU
|
||||
float a0_0 = leaky_relu(b0[0] + w0[0] * x0 + w0[1] * x1);
|
||||
float a0_1 = leaky_relu(b0[1] + w0[2] * x0 + w0[3] * x1);
|
||||
// Layer 1: 2 → 2, ReLU
|
||||
float a1_0 = leaky_relu(b1[0] + w1[0] * a0_0 + w1[1] * a0_1);
|
||||
float a1_1 = leaky_relu(b1[1] + w1[2] * a0_0 + w1[3] * a0_1);
|
||||
// Layer 2: 2 → 2, ReLU
|
||||
float a2_0 = leaky_relu(b2[0] + w2[0] * a1_0 + w2[1] * a1_1);
|
||||
float a2_1 = leaky_relu(b2[1] + w2[2] * a1_0 + w2[3] * a1_1);
|
||||
// Layer 3: 2 → 1, Sigmoid
|
||||
return sig(b3[0] + w3[0] * a2_0 + w3[1] * a2_1);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_forward_matches_handcomputed) {
|
||||
TinyMLP m(0ull);
|
||||
|
||||
// Set weights to a known pattern: first weight 0.5, second 0.25, etc.
|
||||
// (we construct a flat buffer matching the documented layout).
|
||||
constexpr std::size_t WC = TinyMLP::weight_count();
|
||||
std::array<float, WC> flat{};
|
||||
// Weights layer by layer:
|
||||
// L0: 4 weights; L1: 4; L2: 4; L3: 2 → 14 weights
|
||||
// Biases: L0: 2; L1: 2; L2: 2; L3: 1 → 7 biases
|
||||
// Total: 21 (sanity check)
|
||||
NISPS_EXPECT(WC == 21u);
|
||||
|
||||
float val = 0.1f;
|
||||
for (std::size_t i = 0; i < WC; ++i) {
|
||||
flat[i] = val;
|
||||
val += 0.05f;
|
||||
if (val > 0.7f) val = -0.6f;
|
||||
}
|
||||
m.set_weights(std::span<const float>(flat));
|
||||
|
||||
// Extract layer slices for the manual check.
|
||||
std::array<float, 4> w0{flat[0], flat[1], flat[2], flat[3]};
|
||||
std::array<float, 4> w1{flat[4], flat[5], flat[6], flat[7]};
|
||||
std::array<float, 4> w2{flat[8], flat[9], flat[10], flat[11]};
|
||||
std::array<float, 2> w3{flat[12], flat[13]};
|
||||
std::array<float, 2> b0{flat[14], flat[15]};
|
||||
std::array<float, 2> b1{flat[16], flat[17]};
|
||||
std::array<float, 2> b2{flat[18], flat[19]};
|
||||
std::array<float, 1> b3{flat[20]};
|
||||
|
||||
const float x0 = 0.3f, x1 = 0.7f;
|
||||
m.set_input(0, x0);
|
||||
m.set_input(1, x1);
|
||||
m.process();
|
||||
auto out = m.outputs();
|
||||
NISPS_EXPECT(out.size() == 1u);
|
||||
|
||||
const float expected = manual_forward(x0, x1, w0, b0, w1, b1, w2, b2, w3, b3);
|
||||
NISPS_EXPECT_NEAR(out[0], expected, 1e-5);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_forward_outputs_in_unit_range) {
|
||||
// Sigmoid output guarantees [0, 1].
|
||||
using M = nisps::ml::MLP<3, 10, 10, 14, 126, 16, 64>;
|
||||
M m(42ull);
|
||||
m.draw_weights(0.5f);
|
||||
for (int t = 0; t < 100; ++t) {
|
||||
m.set_input(0, static_cast<float>(t) * 0.013f);
|
||||
m.set_input(1, static_cast<float>(t) * 0.027f - 0.5f);
|
||||
m.set_input(2, static_cast<float>(t) * 0.041f);
|
||||
m.process();
|
||||
for (float v : m.outputs()) {
|
||||
NISPS_EXPECT(v >= 0.f);
|
||||
NISPS_EXPECT(v <= 1.f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_set_input_out_of_range_silently_ignored) {
|
||||
TinyMLP m(0ull);
|
||||
// Should not crash; index 99 simply does nothing.
|
||||
m.set_input(99u, 1.234f);
|
||||
m.set_input(0, 0.5f);
|
||||
m.set_input(1, 0.5f);
|
||||
m.process();
|
||||
auto out = m.outputs();
|
||||
NISPS_EXPECT(out.size() == 1u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
98
tests/cpp/test_mlp_init.cpp
Normal file
98
tests/cpp/test_mlp_init.cpp
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// tests/cpp/test_mlp_init.cpp — exercises MLP construction, deterministic
|
||||
// seeding, the spread parameter, and the static_assert that the class
|
||||
// satisfies the MLEngine concept.
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/core/concepts.hpp"
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Compact alias used across the test suite.
|
||||
using SmallMLP = nisps::ml::MLP<2, 8, 8, 8, 4, 16, 64>;
|
||||
|
||||
// Hard ground-truth: the class satisfies MLEngine. Compile-time check.
|
||||
static_assert(nisps::MLEngine<SmallMLP>,
|
||||
"MLP<...> must satisfy nisps::MLEngine concept");
|
||||
|
||||
NISPS_TEST(mlp_same_seed_same_init_weights) {
|
||||
SmallMLP a(123ull);
|
||||
SmallMLP b(123ull);
|
||||
|
||||
auto wa = a.get_weights();
|
||||
auto wb = b.get_weights();
|
||||
NISPS_EXPECT(wa.size() == wb.size());
|
||||
for (std::size_t i = 0; i < wa.size(); ++i) {
|
||||
NISPS_EXPECT(wa[i] == wb[i]);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_diff_seed_diff_init_weights) {
|
||||
SmallMLP a(1ull);
|
||||
SmallMLP b(2ull);
|
||||
|
||||
auto wa = a.get_weights();
|
||||
auto wb = b.get_weights();
|
||||
int distinct = 0;
|
||||
for (std::size_t i = 0; i < wa.size(); ++i) {
|
||||
if (wa[i] != wb[i]) ++distinct;
|
||||
}
|
||||
// With ~200+ weights, almost all should differ.
|
||||
NISPS_EXPECT(distinct > static_cast<int>(wa.size()) / 2);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_seed_method_resets_rng) {
|
||||
SmallMLP a(5ull);
|
||||
a.seed(42ull);
|
||||
a.draw_weights(0.5f);
|
||||
|
||||
SmallMLP b(99ull);
|
||||
b.seed(42ull);
|
||||
b.draw_weights(0.5f);
|
||||
|
||||
auto wa = a.get_weights();
|
||||
auto wb = b.get_weights();
|
||||
for (std::size_t i = 0; i < wa.size(); ++i) {
|
||||
NISPS_EXPECT(wa[i] == wb[i]);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_weight_count_matches_topology) {
|
||||
using M = nisps::ml::MLP<3, 10, 10, 14, 126>;
|
||||
// Layer fan_in*fan_out: 3*10 + 10*10 + 10*14 + 14*126 = 30+100+140+1764 = 2034
|
||||
// Biases: 10+10+14+126 = 160
|
||||
// Total: 2194
|
||||
NISPS_EXPECT(M::weight_count() == 2194u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_spread_zero_uniform_in_minus_one_one_range) {
|
||||
// spread=0 → weights drawn from U[-1,1]. The max |w| should be ≤ 1.
|
||||
SmallMLP m(7ull);
|
||||
m.draw_weights(0.f);
|
||||
auto w = m.get_weights();
|
||||
float maxabs = 0.f;
|
||||
for (float v : w) {
|
||||
const float a = v >= 0.f ? v : -v;
|
||||
if (a > maxabs) maxabs = a;
|
||||
}
|
||||
NISPS_EXPECT(maxabs <= 1.f);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_spread_one_xavier_smaller_range) {
|
||||
// spread=1 → weights scaled by 1/sqrt(fan_in). For fan_in≥2, weights
|
||||
// should be strictly smaller in magnitude than the spread=0 case.
|
||||
SmallMLP m(7ull);
|
||||
m.draw_weights(1.f);
|
||||
auto w = m.get_weights();
|
||||
float maxabs = 0.f;
|
||||
for (float v : w) {
|
||||
const float a = v >= 0.f ? v : -v;
|
||||
if (a > maxabs) maxabs = a;
|
||||
}
|
||||
// 1/sqrt(2) ≈ 0.707 — the smallest fan_in is 2 (input). So max possible
|
||||
// is ≈ 0.707 (drawn from rng_signed, which is < 1).
|
||||
NISPS_EXPECT(maxabs < 1.f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
125
tests/cpp/test_mlp_loss.cpp
Normal file
125
tests/cpp/test_mlp_loss.cpp
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// tests/cpp/test_mlp_loss.cpp — regression test for the meml-ues
|
||||
// double-scaling bug.
|
||||
//
|
||||
// Setup: one training example (label = current network output exactly).
|
||||
// Expected loss = 0.
|
||||
//
|
||||
// Then: one example with label = output + small_delta.
|
||||
// Expected loss = (1/NOut) * sum(small_delta^2). NOT scaled additionally
|
||||
// by 1/N (here N=1 so any extra 1/N would still be 1, but the per-element
|
||||
// derivative path is what would expose double-scaling).
|
||||
//
|
||||
// We compare the value reported by `train()` after a single iteration
|
||||
// (with lr=0 — so no weight update — but the loss should be reported)
|
||||
// against the expected formula.
|
||||
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/ml/loss.hpp"
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
// Direct test of mse_per_sample.
|
||||
NISPS_TEST(mse_per_sample_matches_formula) {
|
||||
std::array<float, 4> label = {0.5f, 0.25f, 0.75f, 0.f};
|
||||
std::array<float, 4> pred = {0.4f, 0.30f, 0.70f, 0.1f};
|
||||
std::array<float, 4> deriv{};
|
||||
|
||||
const float l = nisps::ml::mse_per_sample(
|
||||
std::span<const float>(label),
|
||||
std::span<const float>(pred),
|
||||
std::span<float>(deriv));
|
||||
|
||||
// (0.1^2 + 0.05^2 + 0.05^2 + 0.1^2) / 4 = (0.01 + 0.0025 + 0.0025 + 0.01) / 4 = 0.025/4
|
||||
const float expected = (0.01f + 0.0025f + 0.0025f + 0.01f) * 0.25f;
|
||||
NISPS_EXPECT_NEAR(l, expected, 1e-7);
|
||||
|
||||
// Derivatives: -(2/N) * (label - pred). For element 0: -(2/4) * (0.5 - 0.4) = -0.05
|
||||
NISPS_EXPECT_NEAR(deriv[0], -0.05f, 1e-7);
|
||||
NISPS_EXPECT_NEAR(deriv[3], 0.05f, 1e-7); // -(2/4) * (0 - 0.1) = +0.05
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_train_loss_not_double_scaled_single_sample) {
|
||||
// 1 example. The reported epoch_loss should equal the per-sample MSE
|
||||
// exactly (with sample_weight=1/N=1.0). If the legacy double-scaling
|
||||
// bug crept in, we'd see it scaled by an extra 1/1 = 1.0 (invisible)
|
||||
// — so we use TWO samples and check the relationship.
|
||||
using M = nisps::ml::MLP<1, 2, 2, 2, 2, 4, 8>;
|
||||
M m(11ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
std::array<float, 1> f1{0.3f};
|
||||
std::array<float, 2> l1{0.7f, 0.2f};
|
||||
std::array<float, 1> f2{0.6f};
|
||||
std::array<float, 2> l2{0.1f, 0.9f};
|
||||
m.add_example(std::span<const float>(f1), std::span<const float>(l1));
|
||||
m.add_example(std::span<const float>(f2), std::span<const float>(l2));
|
||||
|
||||
// Compute the expected loss manually: average of per-sample MSE.
|
||||
// Run inference on each, get pred, compute MSE manually.
|
||||
auto compute_mse = [&](std::array<float, 1>& f, std::array<float, 2>& lab) {
|
||||
m.set_input(0, f[0]);
|
||||
m.process();
|
||||
auto out = m.outputs();
|
||||
float sse = 0.f;
|
||||
for (std::size_t j = 0; j < 2u; ++j) {
|
||||
const float d = lab[j] - out[j];
|
||||
sse += d * d;
|
||||
}
|
||||
return sse * 0.5f; // (1/NOut) sum of squared diff
|
||||
};
|
||||
const float mse1 = compute_mse(f1, l1);
|
||||
const float mse2 = compute_mse(f2, l2);
|
||||
const float expected_epoch_loss = 0.5f * (mse1 + mse2); // avg over 2 samples
|
||||
|
||||
// train() with lr=0 leaves weights unchanged but reports the per-iter
|
||||
// loss. We use max_iter=1 to grab exactly one epoch_loss.
|
||||
const float reported = m.train(/*lr=*/0.f, /*max_iter=*/1u, /*min_err=*/-1.f);
|
||||
|
||||
NISPS_EXPECT_NEAR(reported, expected_epoch_loss, 1e-5);
|
||||
|
||||
// If the legacy bug were present, reported would be expected/2 (extra
|
||||
// 1/N multiplication). That would fail at 1e-5 tolerance for any
|
||||
// non-tiny mse. Sanity-confirm:
|
||||
NISPS_EXPECT(reported > expected_epoch_loss * 0.9f);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_train_with_sample_weights_uses_them) {
|
||||
using M = nisps::ml::MLP<1, 2, 2, 2, 2, 4, 8>;
|
||||
M m(13ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
std::array<float, 1> f1{0.1f};
|
||||
std::array<float, 2> l1{0.5f, 0.5f};
|
||||
std::array<float, 1> f2{0.9f};
|
||||
std::array<float, 2> l2{0.5f, 0.5f};
|
||||
m.add_example(std::span<const float>(f1), std::span<const float>(l1));
|
||||
m.add_example(std::span<const float>(f2), std::span<const float>(l2));
|
||||
|
||||
// Concentrate all weight on sample 0.
|
||||
std::array<float, 2> sw{1.f, 0.f};
|
||||
|
||||
auto compute_mse = [&](std::array<float, 1>& f, std::array<float, 2>& lab) {
|
||||
m.set_input(0, f[0]);
|
||||
m.process();
|
||||
auto out = m.outputs();
|
||||
float sse = 0.f;
|
||||
for (std::size_t j = 0; j < 2u; ++j) {
|
||||
const float d = lab[j] - out[j];
|
||||
sse += d * d;
|
||||
}
|
||||
return sse * 0.5f;
|
||||
};
|
||||
const float mse1 = compute_mse(f1, l1);
|
||||
|
||||
const float reported = m.train(0.f, 1u, -1.f, std::span<const float>(sw));
|
||||
// Under uniform weights this would be 0.5*(mse1+mse2). With sw={1,0}
|
||||
// it should equal mse1.
|
||||
NISPS_EXPECT_NEAR(reported, mse1, 1e-5);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
133
tests/cpp/test_mlp_rl.cpp
Normal file
133
tests/cpp/test_mlp_rl.cpp
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// tests/cpp/test_mlp_rl.cpp — verify draw_weights and move_weights
|
||||
// behavior:
|
||||
// - draw_weights with spread=0 vs spread=1 produces different weight
|
||||
// magnitude regimes.
|
||||
// - move_weights perturbs all weights in unmasked layers.
|
||||
// - move_weights with output_pin_mask preserves the corresponding
|
||||
// final-layer weight rows AND biases exactly.
|
||||
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
using SmallMLP = nisps::ml::MLP<2, 4, 4, 4, 6, 8, 32>;
|
||||
|
||||
NISPS_TEST(mlp_draw_weights_spread_zero_full_range) {
|
||||
SmallMLP m(0ull);
|
||||
m.draw_weights(0.f);
|
||||
auto w = m.get_weights();
|
||||
float maxabs = 0.f;
|
||||
for (float v : w) {
|
||||
const float a = v >= 0.f ? v : -v;
|
||||
if (a > maxabs) maxabs = a;
|
||||
}
|
||||
// spread=0 ⇒ U[-1,1] (no Xavier scale). Most weights will fall in
|
||||
// (0.5, 1.0) band — we expect at least one above 0.5.
|
||||
NISPS_EXPECT(maxabs > 0.5f);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_draw_weights_spread_one_xavier_compressed) {
|
||||
SmallMLP m(0ull);
|
||||
m.draw_weights(1.f);
|
||||
auto w = m.get_weights();
|
||||
float maxabs = 0.f;
|
||||
for (float v : w) {
|
||||
const float a = v >= 0.f ? v : -v;
|
||||
if (a > maxabs) maxabs = a;
|
||||
}
|
||||
// Xavier scale: 1/sqrt(fan_in). Smallest fan_in = 2 → scale ~0.707.
|
||||
// Largest weight magnitude bounded by that.
|
||||
NISPS_EXPECT(maxabs < 0.71f);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_move_weights_changes_unpinned_weights) {
|
||||
SmallMLP m(99ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
// Snapshot weights.
|
||||
std::array<float, SmallMLP::weight_count()> before{};
|
||||
{
|
||||
auto w = m.get_weights();
|
||||
for (std::size_t i = 0; i < w.size(); ++i) before[i] = w[i];
|
||||
}
|
||||
|
||||
m.move_weights(0.1f, 0.5f);
|
||||
|
||||
auto after = m.get_weights();
|
||||
int distinct = 0;
|
||||
for (std::size_t i = 0; i < after.size(); ++i) {
|
||||
if (before[i] != after[i]) ++distinct;
|
||||
}
|
||||
// Most weights should have changed (gaussian noise + decay).
|
||||
NISPS_EXPECT(distinct > static_cast<int>(after.size()) / 2);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_move_weights_pin_mask_skips_final_outputs) {
|
||||
SmallMLP m(33ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
auto w_before = m.get_weights();
|
||||
std::array<float, SmallMLP::weight_count()> before{};
|
||||
for (std::size_t i = 0; i < w_before.size(); ++i) before[i] = w_before[i];
|
||||
|
||||
// NOut = 6. Pin nodes 0, 2, 4.
|
||||
std::array<std::uint8_t, 6> mask{1, 0, 1, 0, 1, 0};
|
||||
m.move_weights(0.1f, 0.5f, std::span<const std::uint8_t>(mask));
|
||||
|
||||
auto after = m.get_weights();
|
||||
|
||||
// Layout: weights = [L0(8) L1(16) L2(16) L3(24)] then biases [L0(4) L1(4) L2(4) L3(6)].
|
||||
constexpr std::size_t L0_W = 2*4; // 8
|
||||
constexpr std::size_t L1_W = 4*4; // 16
|
||||
constexpr std::size_t L2_W = 4*4; // 16
|
||||
constexpr std::size_t L3_W = 4*6; // 24
|
||||
constexpr std::size_t L3_W_OFF = L0_W + L1_W + L2_W;
|
||||
constexpr std::size_t BIAS_OFF = L0_W + L1_W + L2_W + L3_W;
|
||||
constexpr std::size_t L3_B_OFF = BIAS_OFF + 4 + 4 + 4; // 76 + 0 → biases start
|
||||
|
||||
// Final layer weights row-major: [node*4 + j] for j ∈ [0,4).
|
||||
// Pinned nodes 0, 2, 4 → rows 0, 2, 4 should be preserved.
|
||||
for (std::size_t node : {0u, 2u, 4u}) {
|
||||
for (std::size_t j = 0; j < 4u; ++j) {
|
||||
const std::size_t idx = L3_W_OFF + node * 4u + j;
|
||||
NISPS_EXPECT(before[idx] == after[idx]);
|
||||
}
|
||||
// Bias too.
|
||||
NISPS_EXPECT(before[L3_B_OFF + node] == after[L3_B_OFF + node]);
|
||||
}
|
||||
// Unpinned nodes 1, 3, 5 → rows changed (most weights distinct).
|
||||
int unpinned_changed = 0;
|
||||
for (std::size_t node : {1u, 3u, 5u}) {
|
||||
for (std::size_t j = 0; j < 4u; ++j) {
|
||||
const std::size_t idx = L3_W_OFF + node * 4u + j;
|
||||
if (before[idx] != after[idx]) ++unpinned_changed;
|
||||
}
|
||||
}
|
||||
// 12 unpinned weights; gaussian noise w/ stddev > 0 → almost all change.
|
||||
NISPS_EXPECT(unpinned_changed >= 10);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_draw_weights_clears_grad_accumulators) {
|
||||
// After draw_weights, calling train() shouldn't see stale gradients.
|
||||
// Smoke test: draw → train → loss should drop normally.
|
||||
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 64>;
|
||||
M m(0ull);
|
||||
m.draw_weights(0.5f);
|
||||
std::array<float, 2> f{0.3f, 0.7f};
|
||||
std::array<float, 1> l{0.5f};
|
||||
m.add_example(std::span<const float>(f), std::span<const float>(l));
|
||||
const float l1 = m.train(0.5f, 50u, -1.f);
|
||||
m.draw_weights(0.5f);
|
||||
const float l2 = m.train(0.5f, 50u, -1.f);
|
||||
// Both should be finite and >= 0. We don't assert about ordering; the
|
||||
// point is that no NaNs leak through stale grads.
|
||||
NISPS_EXPECT(l1 >= 0.f && l1 < 100.f);
|
||||
NISPS_EXPECT(l2 >= 0.f && l2 < 100.f);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
146
tests/cpp/test_mlp_serialize.cpp
Normal file
146
tests/cpp/test_mlp_serialize.cpp
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
// tests/cpp/test_mlp_serialize.cpp — round-trip get_weights → set_weights.
|
||||
//
|
||||
// Serializing weights, restoring them in a fresh MLP, and running inference
|
||||
// must produce bit-identical outputs. This is the gate for cross-platform
|
||||
// state transfer (firmware ↔ browser).
|
||||
|
||||
#include <array>
|
||||
#include <vector>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
using TestMLP = nisps::ml::MLP<3, 8, 8, 10, 5, 16, 32>;
|
||||
|
||||
NISPS_TEST(mlp_get_set_weights_roundtrip_preserves_inference) {
|
||||
TestMLP a(123ull);
|
||||
a.draw_weights(0.4f);
|
||||
|
||||
// Snapshot weights.
|
||||
auto w = a.get_weights();
|
||||
std::vector<float> snap(w.begin(), w.end());
|
||||
|
||||
TestMLP b(0ull); // different seed → different starting weights
|
||||
b.set_weights(std::span<const float>(snap.data(), snap.size()));
|
||||
|
||||
// Inference on identical input should produce identical outputs.
|
||||
const float in[3] = {0.2f, -0.3f, 0.5f};
|
||||
for (std::size_t i = 0; i < 3u; ++i) {
|
||||
a.set_input(i, in[i]);
|
||||
b.set_input(i, in[i]);
|
||||
}
|
||||
a.process();
|
||||
b.process();
|
||||
|
||||
auto oa = a.outputs();
|
||||
auto ob = b.outputs();
|
||||
NISPS_EXPECT(oa.size() == ob.size());
|
||||
for (std::size_t i = 0; i < oa.size(); ++i) {
|
||||
NISPS_EXPECT(oa[i] == ob[i]);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_weight_count_matches_get_weights_size) {
|
||||
TestMLP m(0ull);
|
||||
auto w = m.get_weights();
|
||||
NISPS_EXPECT(w.size() == TestMLP::weight_count());
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_get_weights_contains_layer_concatenation) {
|
||||
// Verify the documented flat layout: weights all layers, then biases
|
||||
// all layers. We do this by setting a known pattern via set_weights
|
||||
// and reading it back.
|
||||
TestMLP m(0ull);
|
||||
constexpr std::size_t WC = TestMLP::weight_count();
|
||||
std::vector<float> pattern(WC);
|
||||
for (std::size_t i = 0; i < WC; ++i) {
|
||||
pattern[i] = static_cast<float>(i) * 0.001f - 0.5f;
|
||||
}
|
||||
m.set_weights(std::span<const float>(pattern.data(), pattern.size()));
|
||||
auto w = m.get_weights();
|
||||
NISPS_EXPECT(w.size() == WC);
|
||||
for (std::size_t i = 0; i < WC; ++i) {
|
||||
NISPS_EXPECT_NEAR(w[i], pattern[i], 1e-7);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_set_weights_too_short_is_ignored) {
|
||||
TestMLP m(7ull);
|
||||
auto before = m.get_weights();
|
||||
std::vector<float> snap(before.begin(), before.end());
|
||||
|
||||
// Pass a too-short buffer.
|
||||
std::array<float, 2> tiny{1.f, 2.f};
|
||||
m.set_weights(std::span<const float>(tiny));
|
||||
|
||||
auto after = m.get_weights();
|
||||
// Weights should be unchanged.
|
||||
for (std::size_t i = 0; i < snap.size(); ++i) {
|
||||
NISPS_EXPECT(snap[i] == after[i]);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_eval_loss_does_not_modify_state) {
|
||||
TestMLP m(5ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
std::array<float, 3> f{0.1f, 0.2f, 0.3f};
|
||||
std::array<float, 5> l{0.5f, 0.5f, 0.5f, 0.5f, 0.5f};
|
||||
m.add_example(std::span<const float>(f), std::span<const float>(l));
|
||||
|
||||
// Snapshot weights.
|
||||
auto w_before = m.get_weights();
|
||||
std::vector<float> snap(w_before.begin(), w_before.end());
|
||||
|
||||
const float L = m.eval_loss();
|
||||
NISPS_EXPECT(L >= 0.f);
|
||||
|
||||
auto w_after = m.get_weights();
|
||||
for (std::size_t i = 0; i < snap.size(); ++i) {
|
||||
NISPS_EXPECT(snap[i] == w_after[i]);
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_infer_batch_matches_individual_inference) {
|
||||
TestMLP m(42ull);
|
||||
m.draw_weights(0.5f);
|
||||
|
||||
constexpr std::size_t N = 5;
|
||||
constexpr std::size_t NI = 3;
|
||||
constexpr std::size_t NO = 5;
|
||||
std::array<float, N * NI> points{};
|
||||
for (std::size_t i = 0; i < N * NI; ++i) {
|
||||
points[i] = static_cast<float>(i) * 0.07f - 0.3f;
|
||||
}
|
||||
std::array<float, N * NO> outs{};
|
||||
m.infer_batch(std::span<const float>(points), std::span<float>(outs));
|
||||
|
||||
// Compare against individual inference.
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
for (std::size_t j = 0; j < NI; ++j) {
|
||||
m.set_input(j, points[i * NI + j]);
|
||||
}
|
||||
m.process();
|
||||
auto o = m.outputs();
|
||||
for (std::size_t j = 0; j < NO; ++j) {
|
||||
NISPS_EXPECT(o[j] == outs[i * NO + j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_layer_stats_reasonable_after_init) {
|
||||
TestMLP m(0ull);
|
||||
m.draw_weights(0.5f);
|
||||
for (std::size_t L = 0; L < 4; ++L) {
|
||||
const auto s = m.layer_stats(L);
|
||||
NISPS_EXPECT(s.mean_abs > 0.f);
|
||||
NISPS_EXPECT(s.max_abs >= s.mean_abs);
|
||||
NISPS_EXPECT(s.dead_frac >= 0.f && s.dead_frac <= 1.f);
|
||||
NISPS_EXPECT(s.saturating_frac >= 0.f && s.saturating_frac <= 1.f);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
92
tests/cpp/test_mlp_training.cpp
Normal file
92
tests/cpp/test_mlp_training.cpp
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
// tests/cpp/test_mlp_training.cpp — convergence test on XOR.
|
||||
//
|
||||
// XOR is the classic minimum non-linear problem: a 2-layer linear net
|
||||
// cannot solve it; an MLP with one hidden layer (and a non-linear
|
||||
// activation) can. Our 4-layer MLP with sigmoid output is more than enough.
|
||||
//
|
||||
// We check loss < 0.01 within a generous iteration budget. If this test
|
||||
// regresses to taking >1000 iterations, something is wrong with the
|
||||
// gradient or weight-update path.
|
||||
|
||||
#include <array>
|
||||
|
||||
#include "test_helpers.hpp"
|
||||
|
||||
#include "../../nisps/ml/mlp.hpp"
|
||||
|
||||
namespace {
|
||||
|
||||
NISPS_TEST(mlp_xor_converges) {
|
||||
// Modest network: 2 inputs, [4, 4, 4] hidden, 1 output.
|
||||
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 1024>;
|
||||
M m(7ull);
|
||||
m.draw_weights(1.f); // Xavier-ish; needed for sigmoid output to start reasonable
|
||||
|
||||
// XOR truth table.
|
||||
std::array<std::array<float, 2>, 4> X = {{
|
||||
{0.f, 0.f},
|
||||
{0.f, 1.f},
|
||||
{1.f, 0.f},
|
||||
{1.f, 1.f},
|
||||
}};
|
||||
std::array<std::array<float, 1>, 4> Y = {{
|
||||
{0.f},
|
||||
{1.f},
|
||||
{1.f},
|
||||
{0.f},
|
||||
}};
|
||||
|
||||
for (std::size_t i = 0; i < 4u; ++i) {
|
||||
m.add_example(std::span<const float>(X[i]), std::span<const float>(Y[i]));
|
||||
}
|
||||
NISPS_EXPECT(m.example_count() == 4u);
|
||||
|
||||
// Train. Higher LR + more iterations is fine — the test is "did it
|
||||
// converge AT ALL within a generous budget".
|
||||
const float final_loss = m.train(/*lr=*/0.5f, /*max_iter=*/2000u, /*min_err=*/0.01f);
|
||||
NISPS_EXPECT(final_loss < 0.01f);
|
||||
|
||||
// Sanity: outputs should be near labels for each input.
|
||||
for (std::size_t i = 0; i < 4u; ++i) {
|
||||
m.set_input(0, X[i][0]);
|
||||
m.set_input(1, X[i][1]);
|
||||
m.process();
|
||||
const float o = m.outputs()[0];
|
||||
NISPS_EXPECT_NEAR(o, Y[i][0], 0.2);
|
||||
}
|
||||
|
||||
// Loss history should record at least one entry.
|
||||
NISPS_EXPECT(m.loss_history().size() >= 1u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_train_with_no_examples_returns_zero) {
|
||||
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 8>;
|
||||
M m(0ull);
|
||||
const float loss = m.train(0.5f, 100u, 0.001f);
|
||||
NISPS_EXPECT(loss == 0.f);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_clear_examples_works) {
|
||||
using M = nisps::ml::MLP<2, 4, 4, 4, 1, 4, 8>;
|
||||
M m(0ull);
|
||||
std::array<float, 2> f{0.f, 1.f};
|
||||
std::array<float, 1> l{0.5f};
|
||||
m.add_example(std::span<const float>(f), std::span<const float>(l));
|
||||
NISPS_EXPECT(m.example_count() == 1u);
|
||||
m.clear_examples();
|
||||
NISPS_EXPECT(m.example_count() == 0u);
|
||||
}
|
||||
|
||||
NISPS_TEST(mlp_dataset_ring_buffer_evicts_oldest) {
|
||||
// NMaxExamples=4, add 6 examples; oldest 2 should be evicted.
|
||||
using M = nisps::ml::MLP<1, 2, 2, 2, 1, 4, 8>;
|
||||
M m(0ull);
|
||||
for (int i = 0; i < 6; ++i) {
|
||||
std::array<float, 1> f{static_cast<float>(i)};
|
||||
std::array<float, 1> l{static_cast<float>(i) * 0.1f};
|
||||
m.add_example(std::span<const float>(f), std::span<const float>(l));
|
||||
}
|
||||
NISPS_EXPECT(m.example_count() == 4u);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
Loading…
Reference in a new issue