memlnaut-nisps/tests/cpp/test_mlp_init.cpp

99 lines
2.8 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_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