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)
133 lines
4.6 KiB
C++
133 lines
4.6 KiB
C++
// 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
|