This is a lightweight Feedforward and Recurrent Neural Network library written in modern C++ with a primary goal: to be an educational tool. It is built entirely from scratch with zero external dependencies (except for optional charting), making it easy to compile, run, and understand.
While not focused on high performance, it provides a clean implementation of the core mechanics of training and inference, including advanced features like Backpropagation Through Time (BPTT), AdamW/NadamW optimizers, and post-training temperature calibration.
All classes, structures, and functions of the core neural network library are wrapped in the myoddweb::nn namespace.
To use the library, you can import the namespace:
using namespace myoddweb::nn;Or reference the types explicitly:
myoddweb::nn::NeuralNetworkOptions options = ...
myoddweb::nn::NeuralNetwork nn(options);- linear
- sigmoid
- tanh
- relu
- leakyRelu
- PRelu
- selu
- swish
- gelu
- quickGelu
- mish
- elu
- softmax
- None
- SGD
- Adam
- AdamW
- Nadam
- NadamW
- Adagrad
- RMSProp
- Lion
- Nesterov
- AdaDelta
- AMSGrad
- LAMB
Python bindings are available for the myoddweb::nn library, allowing you to configure, train, and run models natively in Python. The bindings are powered by pybind11.
For detailed API documentation, prerequisites, and instructions on how to build and run the module, see python/README.md.
Standalone Python examples are located in python/examples/:
- XOR Classification (
python/examples/xor.py): Classic non-linearly separable XOR problem using Feed-Forward layers and Sigmoid activation. - Multi-Output Layer (
python/examples/multi_output.py): Parallel multi-output model performing joint classification (Sigmoid) and regression (Tanh). - General Example (
python/examples/example.py): Comprehensive demonstration of configuration options, progress monitoring callbacks, and model serialization. - Reinforcement Learning (
python/examples/tic_tac_toe.py): Policy-gradient (REINFORCE) agent learning Tic-Tac-Toe via advantage rewards and playing against a Random opponent.
import neuralnetwork as nn
# 1. Configure the network architecture
topology = [3, 2, 1]
# 2. Set options (learning rate, epochs, etc.)
options = nn.NeuralNetworkOptions.create(topology) \
.with_batch_size(1) \
.with_learning_rate(0.1) \
.with_number_of_epoch(1000) \
.build()
# 3. Create the model
net = nn.NeuralNetwork(options)
# 4. Train the network
inputs = [[0.0, 0.0, 1.0], [0.0, 1.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0]]
outputs = [[0.0], [1.0], [1.0], [0.0]]
net.train(inputs, outputs)
# 5. Predict
prediction = net.think([1.0, 0.0, 1.0])
print(f"Prediction: {prediction[0]:.4f}")The following sections describe the various configuration options available when building a network using NeuralNetworkOptions.
Hidden Layers
The hidden layer configuration allows you to define the architecture of your network's trunk.
- Layer type:
FF: Standard feed-forward layer.Elman: Simple recurrent layer.Gru: Gated recurrent unit layer.Lstm: Long Short-Term Memory layer.AttentionPool: Additive (Bahdanau-style) attention pooling over a precedingGru/Lstmlayer's BPTT window (see "Attention Pooling" below).Tcn: Dilated causal 1D convolution ("Temporal Convolutional Network" block) over a window of preceding timesteps (see "TCN" below).SelfAttention: Multi-head causal self-attention plus a position-wise feed-forward sub-block (see "Self-Attention" below).
- Layer size: Number of neurons in the hidden layer.
- Activation: The activation object (method, alpha, and temperature).
- Weight Decay: Regularization strength.
- Dropout: Percentage of neurons to randomly drop during training (0.0 to 1.0).
- Optimiser: Each layer can optionally have its own optimizer configuration.
std::vector<unsigned> topology = {2, 8, 8, 8, 8, 1};
std::vector<LayerDetails> hidden_layers = {
LayerDetails(Layer::Architecture::Lstm, 8, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
LayerDetails(Layer::Architecture::Lstm, 8, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
LayerDetails(Layer::Architecture::FF, 8, activation(activation::method::relu, 0.01), 0.2, 0.05, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
LayerDetails(Layer::Architecture::FF, 8, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
};
auto options = NeuralNetworkOptions::create(topology)
.with_clip_threshold(2.0)
.with_hidden_layers(hidden_layers)
.with_enable_bptt(true)
.with_bptt_max_ticks(60)
.build();Multi Output Layers allow the network to split from a central trunk into multiple independent paths (branches), each with its own hidden layers and output configuration.
// Trunk topology: 3 inputs, 4 hidden and 5 total outputs (2 + 3)
std::vector<unsigned> topology = { 3, 4, 5 };
std::vector<MultiOutputLayerDetails> multi_output_layer_details;
// Branch 1: Shallow path, 2 outputs
MultiOutputLayerDetails b1
(
{ LayerDetails(Layer::Architecture::FF, 8, activation(activation::method::tanh, 0.01), 0.0, 0.01, OptimiserType::NadamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0) },
OutputLayerDetails(2, activation(activation::method::tanh, 0.01), ErrorCalculation::type::mse, EvaluationConfig(0.0, 0.0, 1.0, 0.0, false, 1.0, 1e-12, 0.0, { 0.5 }, 0.0, 0.0), 0.0, OptimiserType::NadamW, 0.95)
);
multi_output_layer_details.push_back(b1);
// Branch 2: Deeper path, 3 outputs (Softmax with Label Smoothing)
MultiOutputLayerDetails b2
(
{
LayerDetails(Layer::Architecture::FF, 16, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::NadamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
LayerDetails(Layer::Architecture::FF, 8, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::NadamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0)
},
OutputLayerDetails(3, activation(activation::method::softmax, 1.0), ErrorCalculation::type::cross_entropy, EvaluationConfig(0.0, 0.0, 1.0, 0.0, false, 1.0, 1e-12, 0.1, { 0.5 }, 0.0, 0.0), 0.0, OptimiserType::NadamW, 0.95)
);
multi_output_layer_details.push_back(b2);
auto options = NeuralNetworkOptions::create(topology)
.with_hidden_layers({ LayerDetails(Layer::Architecture::Gru, 4, activation(activation::method::tanh, 0.01), 0.0, 0.01, OptimiserType::NadamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0) })
.with_output_layer_details(multi_output_layer_details)
.build();You can use residual layers to "jump" connections across layers:
auto options = NeuralNetworkOptions::create(topology)
.with_residual_layer_jump(2)
.build();Norm-based gradient clipping is enabled by default to prevent exploding gradients, especially in RNNs:
auto options = NeuralNetworkOptions::create(topology)
.with_clip_threshold(1.5)
.build();When training recurrent networks (RNN, GRU, LSTM), the order of samples is critical for learning temporal dependencies. The library provides two levels of shuffling:
-
shuffle-training-data(Global Shuffling): If set totrue, the raw input samples are randomized before sequences are formed.-
WARNING: This should be set to
falsewhen using recurrent layers, as it destroys the chronological order of the data, making it impossible for the network to learn time-based patterns.
-
WARNING: This should be set to
-
shuffle-bptt-batches(Sequence Shuffling): If set totrue, the library first creates contiguous "blocks" of data (of sizebptt_max_ticks) where the internal chronological order is preserved. It then shuffles the order of these blocks.- RECOMMENDED: This is the preferred way to shuffle recurrent data. It ensures the GRU/LSTM sees valid timelines within each batch while preventing the model from over-fitting to the global sequence of the dataset.
-
bptt-supervise-last-step-only(Last Step Supervision Only): If set totrue, only the final time step ($t = \text{bptt_max_ticks} - 1$ ) of each sequence block is supervised with target outputs during training.-
USE CASE: Ideal for sequence-to-one forecasting tasks (e.g. predicting the next price or direction after observing
$T$ historical ticks). The recurrent layer consumes all$T$ input ticks to warm up its hidden state context, but loss and gradient backpropagation are calculated exclusively from the final prediction step.
-
USE CASE: Ideal for sequence-to-one forecasting tasks (e.g. predicting the next price or direction after observing
auto options = NeuralNetworkOptions::create(topology)
.with_shuffle_training_data(false) // Keep chronological for RNNs
.with_shuffle_bptt_batches(true) // Shuffle blocks for better generalization
.with_enable_bptt(true)
.with_bptt_max_ticks(24)
.with_bptt_supervise_last_step_only(true) // Supervise only the final tick of each sequence
.build();The library supports various strategies to manage learning rate dynamics:
- Warmup: Linearly (or geometrically) increases the rate from a starting value to the target rate over a percentage of the total epochs.
- Exponential Decay: Reduces the learning rate by a fixed decay factor after each epoch.
- Smooth Cosine Boosts (Restarts): Periodically boosts the learning rate using a smooth cosine staircase to help the model escape local minima.
-
Cosine Annealing with Warm Restarts (SGDR): Anneals the learning rate along a half-cosine curve with configurable initial cycle period (
$T_0$ ), geometric period multiplier ($T_{\text{mult}}$ ), minimum learning rate floor ($\eta_{\min}$ ), and peak restart decay factor ($\gamma$ ). -
Adaptive Learning Rate: Dynamically adjusts the learning rate based on recent error trends. It detects states like
Plateauing,Improving, orExplodingand adjusts the rate accordingly.
// Example 1: Warmup with Cosine Annealing and Warm Restarts (SGDR)
auto options_sgdr = NeuralNetworkOptions::create(topology)
.with_learning_rate(0.001)
.with_learning_rate_warmup(0.0001, 0.05) // Start at 0.0001, reach target at 5% of training
.with_cosine_annealing_warm_restarts(true, 20, 2.0, 0.00001, 0.9) // T_0=20, T_mult=2.0, eta_min=1e-5, gamma=0.9
.build();
// Example 2: Warmup with Exponential Decay, Boosts, and Error-Adaptive Rates
auto options_adaptive = NeuralNetworkOptions::create(topology)
.with_learning_rate(0.001)
.with_learning_rate_warmup(0.0001, 0.05) // Start at 0.0001, reach target at 5% of training
.with_learning_rate_decay_rate(0.985) // Decay factor applied per epoch
.with_learning_rate_boost_rate(0.2, 0.1) // Boost by 10% every 20% of training epochs
.with_adaptive_learning_rates(true) // Enable dynamic error-based adjustment
.build();Individual layers can have dropout applied via LayerDetails. During training, neurons are randomly deactivated according to the dropout rate, and the remaining activations are scaled by 1 / (1 - rate) to maintain the expected sum. Dropout is automatically disabled during inference (think).
LayerDetails hl(Layer::Architecture::FF, 64, activation(activation::method::relu, 0.01), 0.25, 0.0, OptimiserType::None, 0.0, false, 0, 0, 0, 0, 0, 0, 0); // 25% dropoutBy default, every run of this library is non-deterministic: weight initialization, dropout masking, and data/BPTT-batch shuffling are all drawn from std::random_device, so two "identical" configs will diverge run to run. NeuralNetworkOptions::with_seed(seed) pins all three to a single uint32_t seed, so that an identical config plus an identical seed reproduces byte-identical training runs — a prerequisite for trusting any single-variable comparison between configs, rather than a coin flip against run-to-run noise.
auto options = NeuralNetworkOptions::create(topology)
.with_seed(42) // opt-in; omit (or pass std::nullopt) for today's non-deterministic behaviour
.build();The seed is opt-in and fully backward compatible: with_seed(std::nullopt) (the default) preserves the exact non-deterministic behaviour of every prior release. When set, a single base seed derives independent, non-colliding sub-seeds per layer, per weight, and per neuron via a fast splitmix64-style mixer (Rng::derive) — so, unlike naively reusing one raw seed everywhere, no two weights or dropout streams collapse onto the same value. The seed is persisted by NeuralNetworkSerializer::save/load, along with the trained gain/bias values.
Known limitation: dropout determinism is keyed off (seed, layer, neuron, batch_index, timestep), not an epoch counter. With shuffling enabled (the default), the training example occupying a given batch slot changes every epoch, so this is not an issue in practice. If shuffling is disabled for a run (shuffle_training_data(false) with unique/non-shuffled data), the same physical sample will receive the same dropout mask on every epoch.
Gru and Lstm hidden layers can opt into recurrent-state Layer Normalization via the trailing use_layer_normalisation flag on LayerDetails (false when disabled, true when enabled). It normalizes the state each layer actually carries across timesteps — the blended hidden state for Gru, the cell state for Lstm — with its own learnable per-neuron gain (initialized to 1.0) and bias (initialized to 0.0), targeting the unstable activation scale that recurrent nets can build up over a long BPTT window. It is not available on FF/Elman layers. The flag, like the rest of a hidden layer's configuration, is persisted by NeuralNetworkSerializer::save/load, along with the trained gain/bias values.
LayerDetails hl(Layer::Architecture::Gru, 32, activation(activation::method::tanh, 0.0), 0.0, 0.01, OptimiserType::AdamW, 0.95, true, 0, 0, 0, 0, 0, 0, 0); // Layer Normalisation enabledRecurrent layers (Gru/Lstm/Elman) compress a whole BPTT window into a single fixed-size hidden state, and every downstream layer normally only ever sees the last timestep of it. An AttentionPool hidden layer instead sits immediately after a Gru or Lstm layer, consumes the full T-timestep hidden-state sequence, learns a per-timestep additive (Bahdanau-style) attention weight, and produces a single pooled context vector — letting the network learn which past ticks matter most instead of always using the final one.
AttentionPool layers have some structural constraints, all enforced (panic on violation) by Layer::create_hidden_layer:
- Must immediately follow a
GruorLstmhidden layer (notElman, notFF). LayerDetails'sizemust equal the preceding recurrent layer's hidden size — pooling never changes dimensionality.use_layer_normalisationmust befalse.attention_hidden_size(the internal scoring-projection width) must be non-zero.- Residual connections (
residual_layer_number >= 0) are not supported. - Requires
with_enable_bptt(true).
std::vector<LayerDetails> hidden_layers = {
LayerDetails(Layer::Architecture::Gru, 32, activation(activation::method::tanh, 0.0), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
LayerDetails(Layer::Architecture::AttentionPool, 32, activation(activation::method::linear, 0.0), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 16, 0, 0, 0, 0, 0, 0), // 16-wide attention scoring projection
};
auto options = NeuralNetworkOptions::create(topology)
.with_hidden_layers(hidden_layers)
.with_enable_bptt(true)
.with_bptt_max_ticks(24)
.build();The layer's own trained weights (the scoring projection and scoring vector) are persisted by NeuralNetworkSerializer::save/load, along with the attention_hidden_size configuration.
Known limitation: because AttentionPool's own backward pass has to fully chain-rule its attention math itself (rather than a simple weight-matrix multiply), it relies on the same "direct gradient injection" mechanism already used for recurrent-layer stacking. When the layer directly above AttentionPool (typically the output layer) also uses that mechanism, gradient flow through it is affected by the same pre-existing identity-proxy limitation described in the [1.1.21] Known Issues — matching the layer-size requirement above (layer.size equal to the preceding recurrent layer's hidden size) keeps this shape-compatible enough for gradients to flow, but this is inherited scope, not fixed by this feature.
A Tcn hidden layer applies a dilated causal 1D convolution: for every output timestep t it gathers the kernel_size dilated input taps {X[t - j*dilation] : j = 0..kernel_size-1} (zero-padded where t - j*dilation < 0) into one flat vector and applies a single dense affine map + activation — letting the network look at a whole window of past ticks at once through widening receptive fields, rather than threading a single compressed hidden state forward the way Elman/Gru/Lstm do. It is always strictly causal (it never looks ahead) with no configuration flag to disable this. Unlike AttentionPool, a Tcn layer may follow any preceding layer type — including being the very first hidden layer — and may change channel width between its input and output. Stacking a dilation schedule (e.g. 1, 2, 4, 8) is done the same way Gru/Lstm layers are already stacked: add multiple Tcn entries to hidden_layers, each with a different dilation.
Tcn layers have some structural constraints, all enforced (panic on violation) by Layer::create_hidden_layer or NeuralNetworkOptions::build():
kernel_sizemust be non-zero.dilationmust be non-zero.use_layer_normalisationmust befalse(not supported).- Requires
with_enable_bptt(true). - The layer's receptive field (
1 + (kernel_size - 1) * dilation) must not exceedbptt_max_ticks.
Unlike AttentionPool, a Tcn layer accepts the library's existing external residual-connection mechanism (with_residual_layer_jump) — a jump of 1 gives the classic per-block TCN skip connection.
std::vector<LayerDetails> hidden_layers = {
LayerDetails(Layer::Architecture::Tcn, 32, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 3, 1, 0, 0, 0, 0), // kernel_size=3, dilation=1
LayerDetails(Layer::Architecture::Tcn, 32, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 3, 2, 0, 0, 0, 0), // kernel_size=3, dilation=2
};
auto options = NeuralNetworkOptions::create(topology)
.with_hidden_layers(hidden_layers)
.with_residual_layer_jump(1)
.with_enable_bptt(true)
.with_bptt_max_ticks(24)
.build();The layer's own trained weights (reusing the same dense weight/bias arrays every hidden layer already has) are persisted by NeuralNetworkSerializer::save/load, along with the kernel_size/dilation configuration.
A SelfAttentionLayer hidden layer (Layer::Architecture::SelfAttention) is a small Transformer encoder block: it adds a fixed (non-learned) sinusoidal positional encoding to the preceding layer's T-timestep window, computes Q/K/V projections, runs causally-masked scaled dot-product attention independently per head, projects the concatenated heads back to width, adds that as a residual (optionally through LayerNorm), runs a position-wise feed-forward sub-block, and adds that as a second residual (optionally through a second LayerNorm). These two residuals are internal to the block's own math and always on - they are unrelated to the external residual_layer_number/residual_projector mechanism (also supported here, forwarded to the base class exactly like FF/Gru/Lstm/Tcn).
SelfAttention layers have some structural constraints, all enforced (panic on violation) by Layer::create_hidden_layer or NeuralNetworkOptions::build():
number_of_headsmust be non-zero and must evenly divideLayerDetails'size.feed_forward_hidden_sizemust be non-zero.LayerDetails'sizemust equal the size of the layer it attends over (no dimension-changing projection in v1).use_layer_normalisationIS supported (deliberate divergence fromAttentionPool, which forbids it) - it controls the two internal LayerNorms described above.- Requires
with_enable_bptt(true)andbptt_max_ticks() > 1.
Unlike AttentionPool, a SelfAttention layer has no previous-architecture restriction - it may be the very first hidden layer, or follow anything.
std::vector<LayerDetails> hidden_layers = {
LayerDetails(Layer::Architecture::SelfAttention, 32, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, true, 0, 0, 0, 4, 64, 0, 0), // 4 heads, feed_forward_hidden_size=64, LayerNorm enabled
};
auto options = NeuralNetworkOptions::create(topology)
.with_hidden_layers(hidden_layers)
.with_enable_bptt(true)
.with_bptt_max_ticks(24)
.build();The layer's own trained weights (Q/K/V/output projections, the feed-forward sub-block's two dense layers, and - when enabled - both LayerNorms' gain/bias) are persisted by NeuralNetworkSerializer::save/load, along with the number_of_heads/feed_forward_hidden_size configuration.
SelfAttentionLayer and LayerDetails report is_recurrent() == true to ensure correct gradient propagation in recurrent pipelines. Multi-threaded execution is handled through encapsulated worker tasks (self_attention_forward_task, self_attention_finish_hidden_gradients_task, self_attention_grad_calc_task) and thread-local gradient accumulators, maintaining exact determinism across any thread count.
Known limitation: unlike every other layer in this library, SelfAttention's per-batch-item scratch (the per-head attention-score matrix) scales O(T^2) in the window length T, not linearly - budget bptt_max_ticks accordingly for this layer type.
An EmbeddingLayer (Layer::Architecture::Embedding) maps discrete categorical integer IDs (e.g., day-of-week, ticker asset ID, market regime category) into continuous dense embedding vectors of dimension
For
-
vocabulary_size: The maximum number of discrete categories$V$ (IDs in range$[0, V-1]$ ). -
embedding_dimension: The dense embedding vector dimension$D$ . -
size: Must equal$K \times D$ .
// Example: 2 categorical inputs mapped to 8-dimensional embeddings -> layer size = 16
std::vector<LayerDetails> hidden_layers = {
LayerDetails(Layer::Architecture::Embedding, 16, activation(activation::method::linear, 0.0), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 100, 8), // vocab_size=100, embed_dim=8
LayerDetails(Layer::Architecture::FF, 32, activation(activation::method::relu, 0.01), 0.0, 0.01, OptimiserType::AdamW, 0.95, false, 0, 0, 0, 0, 0, 0, 0),
};
auto options = NeuralNetworkOptions::create({ 2, 16, 32, 1 })
.with_hidden_layers(hidden_layers)
.build();The embedding lookup weights (NeuralNetworkSerializer::save/load.
Run-to-run noise (best epoch, peak accuracy, trajectory shape) makes it hard to tell whether a change to the network genuinely helped or the run just got lucky. SWA reduces that variance, and often yields a small free accuracy improvement, by periodically snapshotting the trained weight values once training has reached its stable plateau and averaging them together into the final model — no separate ensemble to store or run at inference time.
Once training reaches swa_start_percent of number_of_epoch, a snapshot of the current weights is folded into a running average every swa_update_percent of number_of_epoch (same cadence semantics as update_training_monitor_percent). At the end of train(), if at least one snapshot was taken, the averaged weights replace the network's trained weights before final metrics/temperature calibration are computed — so the deployed model is the averaged one. If SWA is disabled, or no snapshot ever fires (e.g. very short training runs), this is a no-op.
auto options = NeuralNetworkOptions::create(topology)
.with_stochastic_weight_averaging(StochasticWeightAveragingDetails(true, 0.75, 0.02)) // enabled, start at 75% of epochs, update every 2%
// or using the helper overload:
// .with_stochastic_weight_averaging(true, 0.75, 0.02)
.build();These fields are encapsulated in StochasticWeightAveragingDetails and persisted by NeuralNetworkSerializer::save/load.
The Lookahead optimizer wrapper (Zhang et al., 2019) wraps any inner base optimizer (AdamW, SGD, RAdam, Lion, NadamW, etc.) by maintaining two sets of weights: fast weights and slow weights.
The fast weights are updated iteratively by the base optimizer for synchronisation_period). Every slow_weights_step_size), and the fast weights are synchronized back to the updated slow weights:
This stabilizes training, reduces variance across noisy minibatches, and improves convergence across diverse loss surfaces.
auto options = NeuralNetworkOptions::create(topology)
.with_lookahead(LookaheadDetails(true, 5, 0.5)) // enabled, sync every 5 steps, slow step size 0.5
// or using the helper overload:
// .with_lookahead(true, 5, 0.5)
.build();Lookahead is fully orthogonal to other training strategies and can seamlessly co-exist with Stochastic Weight Averaging (SWA), Cosine Annealing with Warm Restarts, and residual connections. The configuration is encapsulated in LookaheadDetails and persisted by NeuralNetworkSerializer::save/load.
These options control the overall execution of the training process:
number_of_epoch: Total number of training iterations over the dataset.batch_size: Number of samples processed before internal gradient updates are applied.number_of_threads: Controls multi-threaded execution for GEMM and layer operations.progress_callback: A lambda or function called after each epoch to monitor error metrics and progress.has_bias: Global toggle to enable or disable bias neurons for all layers.log_training_info: Toggle to enable or disable printing training statistics/configurations to the log output at the start of training (defaults totrue).
auto options = NeuralNetworkOptions::create(topology)
.with_number_of_epoch(5000)
.with_batch_size(32)
.with_number_of_threads(8)
.with_has_bias(true)
.with_log_training_info(true)
.with_progress_callback([](NeuralNetworkHelper& helper) {
Logger::info("Epoch: ", helper.epoch(), " Error: ", helper.error());
return true; // Return false to stop training early
})
.build();For classification tasks using Softmax, the network automatically optimizes the inference temperature (
auto options = NeuralNetworkOptions::create({ 3, 4, 1 })
.with_output_layer_details(1, activation(activation::method::sigmoid, 0.1), ErrorCalculation::type::mse, OptimiserType::AdamW, 0.95)
.with_learning_rate(0.01)
.with_number_of_epoch(1000)
.build();
NeuralNetwork nn(options);
nn.train(training_inputs, training_outputs);
auto output = nn.think({0, 0, 1}); NeuralNetworkSerializer::save(nn, "model.nn");
auto loaded_nn = NeuralNetworkSerializer::load("model.nn");huber_losshuber_direction_lossmaemsermsedirectional_accuracycross_entropybce_lossdirectional_confidence_scoreprediction_coveragequantile_loss(Pinball loss for single or multi-quantile regression)sharpe_ratio_loss(Negative Sharpe ratio loss for trading return optimization)sortino_ratio_loss(Negative Sortino ratio loss penalizing downside volatility)
You can calculate error metrics for the network's predictions using the calculate_forecast_metrics or calculate_forecast_metrics_all_layers methods:
// Calculate forecast metrics for the default output layer.
// The 'in_sample' parameter defaults to true (evaluating on training data). Pass false to evaluate on validation/testing data.
std::vector<NeuralNetworkHelperMetrics> metrics = nn.calculate_forecast_metrics({ ErrorCalculation::type::rmse }, /*in_sample=*/true);
// Each NeuralNetworkHelperMetrics provides:
// - error(): the metric ratio or loss value (double)
// - error_type(): the evaluated ErrorCalculation::type
// - numerator(): optional count of matching/confident occurrences (std::optional<size_t>) for ratio-based metrics
// - denominator(): optional total count of evaluated samples (std::optional<size_t>) for ratio-based metrics
// For continuous loss metrics (e.g., MSE, MAE, Huber), numerator() and denominator() return std::nullopt.
// 'force_checking_indexes' can be configured model-wide via NeuralNetworkOptions::with_force_checking_indexes(true/false)
// (default: false) or overridden per-call via std::optional<bool> force_checking_indexes (default: std::nullopt).
// When resolved to true with in_sample = false, it evaluates against checking_indexes() (validation set) even after training completes.
auto all_metrics = nn.calculate_forecast_metrics_all_layers(
{ ErrorCalculation::type::directional_accuracy, ErrorCalculation::type::prediction_coverage },
/*in_sample=*/false,
/*force_checking_indexes=*/true
);In addition to traditional supervised training over fixed datasets (train), the library supports on-policy Reinforcement Learning via policy gradients (REINFORCE) using train_with_advantages.
-
Supervised Learning (
train) minimises empirical loss with respect to static target labels across fixed epochs, schedules learning rates, and caches epoch validation errors. -
Reinforcement Learning (
train_with_advantages) performs a single on-policy update over trajectories collected during interaction with an environment. -
Advantage Scaling: For each sample
$b$ in a batch, the output layer's prediction delta ($\hat{y} - y_{target}$ ) is scaled directly by the scalar advantage$A_b$ : $$\nabla_\theta \mathcal{L}{RL} = A_b \cdot \nabla\theta \mathcal{L}_{CE}$$- A positive advantage (
$A > 0$ ) increases the probability of taking the chosen action. - A negative advantage (
$A < 0$ ) penalises the chosen action and decreases its probability. - A zero advantage (
$A = 0$ ) leaves the weights unchanged.
- A positive advantage (
- Hidden layer gradients are computed via standard backpropagation from the advantage-scaled output gradients, updating all upstream layers proportionally.
- Sub-batches are chunked and executed according to
options.batch_size(). - Model weights and optimiser velocity/momentum states persist across consecutive updates, allowing the agent to continuously learn online or episode-by-episode.
Note
train_with_advantages requires a single output layer (multi-output architectures are not supported). Typically, a Softmax output head paired with CrossEntropy loss is used for discrete action spaces, with one-hot encoded action targets.
#include "neuralnetwork.h"
#include "neuralnetworkoptions.h"
using namespace myoddweb::nn;
// 1. Configure a policy network (e.g., 9 board inputs, 36 hidden units, 9 discrete actions)
NeuralNetworkOptions options = NeuralNetworkOptions::create({ 9, 36, 9 })
.with_hidden_layers({
LayerDetails(Layer::Architecture::FF, 36, activation(activation::method::relu, 0.0), 0.0, 0.0, OptimiserType::Adam, 0.9, false, 0, 0, 0, 0, 0, 0, 0)
})
.with_output_layer_details(
OutputLayerDetails(9, activation(activation::method::softmax, 0.0, 1.0), ErrorCalculation::type::cross_entropy, EvaluationConfig(), 0.0, OptimiserType::Adam, 0.9)
)
.with_learning_rate(0.01)
.with_batch_size(16)
.build();
NeuralNetwork nn(options);
// 2. Collect states, chosen one-hot actions, and outcome advantages from an episode:
std::vector<std::vector<double>> states = { /* state at step 0 */, /* state at step 1 */ };
std::vector<std::vector<double>> actions = { /* one-hot action 0 */, /* one-hot action 1 */ };
std::vector<double> advantages = { 1.0, 1.0 }; // e.g. +1.0 for win, -1.0 for loss, +0.2 for draw
// 3. Trigger reinforcement learning policy update
nn.train_with_advantages(states, actions, advantages);import neuralnetwork as nn
# 1. Build policy network
options = (
nn.NeuralNetworkOptions.create([9, 36, 9])
.with_hidden_layers([
nn.LayerDetails(nn.LayerArchitecture.FF, 36, nn.Activation(nn.ActivationMethod.Relu, 0.0),
0.0, 0.0, nn.OptimiserType.Adam, 0.9)
])
.with_output_layer_details(
nn.OutputLayerDetails(9, nn.Activation(nn.ActivationMethod.Softmax, 0.0, 1.0),
nn.ErrorCalculationType.CrossEntropy, nn.EvaluationConfig(),
0.0, nn.OptimiserType.Adam, 0.9)
)
.with_learning_rate(0.01)
.with_batch_size(16)
.build()
)
net = nn.NeuralNetwork(options)
# 2. Collect trajectory and compute advantages
states = [[0.0] * 9] # Initial empty board
actions = [[0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0]] # Chose centre cell (4)
advantages = [1.0] # Positive reward / advantage
# 3. Update network policy
net.train_with_advantages(states, actions, advantages)See python/examples/tic_tac_toe.py for a complete working implementation where an agent learns Tic-Tac-Toe and plays against a Random opponent.
To achieve high throughput during training and inference, this library leverages Advanced Vector Extensions 2 (AVX2) intrinsics for core mathematical operations (GEMM, dot products, and optimizer updates).
To enable these optimizations, ensure your compiler is configured to target the AVX2 instruction set:
- MSVC (Visual Studio): Set
Enable Enhanced Instruction SettoAdvanced Vector Extensions 2 (/arch:AVX2)in the project properties. - GCC / Clang: Use the
-mavx2 -mfmaflags during compilation.
For more information on AVX2, see the Intel Intrinsics Guide or Wikipedia.
\include\neuralnetwork\: The stand-alone core C++ neural network library (including/layers/,/helpers/, and/common/subdirectories).\examples\: Standalone example implementations, runner (main.cpp), and the main Visual Studio solution (neuralnetwork.sln).\tests\: Comprehensive unit test suite.\python\: Pybind11-based Visual Studio 2022 solution (neuralnetwork_py.sln) and Python usage examples in\python\examples\.
- Open
examples/neuralnetwork.slnin Visual Studio 2022. - Select
neuralnetwork(to run the examples) orneuralnetwork_tests(to run unit tests) as the startup project. - Build and run using the IDE.
- Language: C++17/C++20
- Build Tool: Visual Studio 2022
- Dependencies: Zero external dependencies for core logic.