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)
570 lines
25 KiB
C++
570 lines
25 KiB
C++
// 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
|