memlnaut-nisps/tests/cpp/test_mlp_loss.cpp

126 lines
4.6 KiB
C++
Raw Normal View History

feat(nisps/ml): MLP library with fixed-architecture template + spread-aware RL (meml-wmh) Stream 2 of the clean-slate rewrite: nisps/ml/ replaces src/memlp/ with a header-only, heap-free MLP that satisfies nisps::core::MLEngine. Files (nisps/ml/): - activations.hpp — ReLU (leaky 0.01 for parity), sigmoid, tanh - loss.hpp — MSE per-sample (fixes meml-ues double-scaling: returns the sample's MSE without an extra 1/N multiplication; the training loop averages explicitly) - init.hpp — uniform/Xavier/spread-aware weight init - training.hpp — gradient clip helper (±10.0 matches legacy) - rl.hpp — move_weights with per-layer Xavier scaling, weight decay (10% * spread), gaussian noise via the deterministic Rng (matches the legacy JS sum-of-three-uniforms shape); draw_weights also spread-aware - stats.hpp — per-layer mean/max/dead/saturating diagnostics - mlp.hpp — 4-layer (3 hidden + sigmoid output) MLP class with std::array-backed weights, biases, gradient accumulators, dataset ring buffer (default 128 examples), loss history (default 4096 iters). Bias is a separate per-layer parameter — no input-vector mutation. Flat get_weights/set_weights layout: weights all layers (row-major, layer order), then biases all layers. Tests (tests/cpp/, all 50 passing under -Wall -Wextra -Werror -Wpedantic): - test_mlp_init.cpp — deterministic seeding, spread regimes, static_assert MLEngine concept satisfied - test_mlp_inference.cpp — golden hand-computed forward pass match, sigmoid output range, set_input bounds - test_mlp_training.cpp — XOR convergence (loss < 0.01 in <2k iters), ring-buffer eviction - test_mlp_loss.cpp — meml-ues regression test: reported loss equals hand-computed average MSE without extra 1/N scaling; sample weights honoured - test_mlp_rl.cpp — move_weights respects output_pin_mask (final-layer rows + biases preserved); spread regimes; grad clear after draw_weights - test_mlp_serialize.cpp — get_weights/set_weights round-trip preserves inference exactly; eval_loss is non-mutating; infer_batch matches individual inference Verification: - Clean build, no warnings - 50 tests pass (22 prior + 28 new) - No std::vector / new / malloc in nisps/ml/ - All float literals .f-suffixed in code (comments excepted)
2026-04-29 14:55:43 +02:00
// 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