Menu
Version 0.0.2
GradieX — C++ Library Reference
The engine lives entirely in headers under core/. There is no library to
link and no build step of its own: include a header and compile.
v0.0.2 is the middle release: an architecture rewrite that made the engine fast and, deliberately, much smaller. Most of what it lists as missing was present in v0.0.1 — mini-batching, dropout, L2, a validation split, seeded runs, multi-class softmax and model save/load — and v0.0.3 has since restored mini-batching and moved the engine to float. Of the three, this is the only release whose Python bindings build. See the version comparison.
Building
C++20 is required. core/initializer.h includes <numbers>,
so C++17 will not compile.
clang++ -std=c++20 -O2 main.cpp -o main
./main
No other flags are needed. The #pragma GCC unroll directives in
core/activations.h are accepted by both GCC and Apple clang; if your
compiler warns about them, add -Wno-unknown-pragmas.
Overview
A network is a chain of HiddenLayer objects followed by an
OutputLayer. Each layer stores its weights as one flat
std::vector<double> rather than a vector of vectors, so a layer's
weights are contiguous in memory.
Activation, initializer and loss behaviour are supplied as plain function pointers, not as virtual methods or template parameters. Each layer holds five of them: an activation, its derivative, an initializer, a loss, and the loss derivative.
The order of operations for one training step is:
forwardPass(x)
for each hidden layer: calculateOutputs()
for the output layer: calculateOutputs()
backwardPass(y)
output layer: computeErrorSignal(y), computeGradients()
hidden layers, last to first:
computeErrorSignal(next width, next deltas, next weights)
computeGradients()
all layers: updateWeightsAndBiases(learningRate)
Note that no weights are updated until every error signal has been computed. This matters: a hidden layer's error signal reads the next layer's weights, so updating a layer before propagating through it would corrupt every layer behind it.
Namespaces and Headers
| Header | Namespace | Contents |
|---|---|---|
core/activations.h | core::functions::activations | Activation functions and their derivatives |
core/initializer.h | core::functions::initializers | Weight initializers, shared RNG |
core/loss.h | core::functions::loss | Loss functions and their derivatives |
core/layer.h | core | Layer, HiddenLayer, OutputLayer |
core/network.h | core | Network |
Including core/network.h pulls in everything.
Function Pointer Types
core::functions::activations::ActivationFunction-
Applies an activation elementwise overdouble* __restrict__ (*)(const double* __restrict__ input, double* __restrict__ vectorScratchPad, size_t size)sizevalues, writing intovectorScratchPadand returning it. The scratch pad is allocated once by the layer and reused, so the hot path performs no allocation. The__restrict__qualifiers promise the compiler that input and output do not alias. core::functions::activations::ActivationFunctionDerivative-
Same signature. Takes the pre-activation values
(
z), not the activated outputs. core::functions::initializers::initFunction-
Fillsdouble* (*)(double* __restrict__ vectorScratchPad, size_t size, size_t size2)size * size2values. Forxavierandhethe two arguments are the fan-in and fan-out respectively. core::functions::loss::LossFunction-
Returns a single scalar. Note the argument order: target first, prediction second.double (*)(const double* __restrict__ actual, const double* __restrict__ predicted, size_t size) core::functions::loss::LossFunctionDerivative-
Writes the gradient with respect todouble* (*)(const double* __restrict__ actual, const double* __restrict__ predicted, double* __restrict__ vectorScratchPad, size_t size)predictedinto the scratch pad and returns it.
class core::Layer
Defined in core/layer.h. The base class for both layer kinds. All
members are public.
Constructors
Layer() noexcept = default- Leaves the object empty. Warning: the
five function pointer members are indeterminate after this constructor,
not null. See Known Bugs, item 6.
Layer(int width, int inputSize, ActivationFunction, ActivationFunctionDerivative, initFunction, LossFunction, LossFunctionDerivative)- Sizes every internal buffer. Does not initialize the weights; call
initializeWeightsAndBiases()for that.
Data Members
| Member | Type | Size | Meaning |
|---|---|---|---|
layerWidth | int | — | Number of neurons |
inputSize | int | — | Fan-in |
layerInputs | vector<double> | inputSize | Copy of the input from the most recent forward pass. Needed by
computeGradients(). |
neuronOutputsUnactivated | vector<double> | layerWidth | Pre-activations, z |
neuronOutputsActivated | vector<double> | layerWidth | Activations, a |
neuronDeltas | vector<double> | layerWidth | Error signals, dL/dz |
neuronWeights | vector<double> | layerWidth * inputSize | Flattened weight matrix. See Weight Storage Layout. |
neuronBiases | vector<double> | layerWidth | One bias per neuron |
neuronWeightGradients | vector<double> | layerWidth * inputSize | Gradients, same layout as the weights |
neuronBiasGradients | vector<double> | layerWidth | Bias gradients |
scratchPad1 | vector<double> | inputSize * layerWidth | Scratch for the loss derivative |
scratchPad2 | vector<double> | inputSize * layerWidth | Scratch for the activation derivative |
activationFunction | ActivationFunction | ||
activationFunctionDerivative | ActivationFunctionDerivative | ||
initialiser | initFunction | Note the British spelling | |
lossFunction | LossFunction | Stored on every layer, but only used on the output layer | |
lossFunctionDerivative | LossFunctionDerivative | ||
The scratch pads are only ever written up to layerWidth elements. The
declared size of inputSize * layerWidth is a known defect; see
Known Bugs, item 7.
Member Functions
void initializeWeightsAndBiases()- Calls the stored initializer over the whole weight array with
(inputSize, layerWidth)as fan-in and fan-out, then sets every bias to zero. void calculateOutputs(const double* __restrict__ input)- Copies
inputintolayerInputs, computesz[n] = sum_i w[n][i] * input[i] + b[n]intoneuronOutputsUnactivated, then applies the activation intoneuronOutputsActivated.inputmust point to at leastinputSizedoubles. This is not checked. void computeGradients()- Requires
neuronDeltasto be current. WritesdL/dw[n][i] = delta[n] * layerInputs[i]anddL/db[n] = delta[n].Gradients are assigned, not accumulated, so this class cannot express mini-batching as written.
void updateWeightsAndBiases(double learningRate)- Applies
w -= learningRate * dL/dwand the same for biases. Plain SGD; no momentum, no weight decay. void print() const- Writes the layer's geometry, pre-activations, activations, deltas, weights
and biases to
std::cout. For debugging.
class core::HiddenLayer
class HiddenLayer : public Layer, inheriting the constructors via
using Layer::Layer.
void computeErrorSignal(int nextLayerWidth, const double* nextLayerDeltas, const double* nextLayerWeights)-
Computes this layer's error signal from the next layer's:
The three arguments describe the layer after this one. Because the next layer's fan-in equals this layer's width,delta[n] = ( sum over m of nextDeltas[m] * nextWeights[m][n] ) * f'(z[n])nextLayerWeightsis indexed asnextWeights[m * this->layerWidth + n]— note that the stride isthis->layerWidth, notthis->inputSize.
class core::OutputLayer
class OutputLayer : public Layer, inheriting the constructors.
void computeErrorSignal(const double* __restrict__ target)-
Computes
by calling the loss derivative intodelta[n] = dL/da[n] * f'(z[n])scratchPad1and the activation derivative intoscratchPad2, then multiplying elementwise.targetmust point to at leastlayerWidthdoubles. This is not checked.Warningthis elementwise product cannot express softmax, whose Jacobian is a dense matrix. See Known Bugs, item 2.
class core::Network
Defined in core/network.h. All members are public.
Data Members
| Member | Type | Meaning |
|---|---|---|
numHiddenLayers | int | Slot count fixed at construction |
numOutputLayers | int | Defaults to 1. Values above 1 are not usable; see Known Bugs, item 3. |
learningRate | double | Defaults to 0.01. May be changed between steps. |
hiddenLayers | vector<HiddenLayer*> | Owned by the network; freed in the destructor |
outputLayers | vector<OutputLayer*> | Owned by the network; freed in the destructor |
Network owns raw pointers
and defines a destructor but no copy constructor or copy assignment operator.
Copying a Network therefore double-frees every layer. Pass it by
reference, or hold it in a std::unique_ptr.
Member Functions
Network()- Zero hidden layers, one output slot, learning rate 0.01. The layer vectors
are left empty, so the slots must not be used.
Network(int numHiddenLayers, int numOutputLayers, double learningRate)- Resizes both layer vectors to the given counts. Every slot is a null
pointer until filled.
void addHiddenLayer(int layerIndex, int width, int inputSize, ActivationFunction, ActivationFunctionDerivative, initFunction, LossFunction, LossFunctionDerivative)- Allocates a
HiddenLayerwithnewand stores it atlayerIndex.WarninglayerIndexis not bounds-checked. An out-of-range index writes past the end of the vector. void addOutputLayer(int layerIndex, ...)- As above, for the output layer. Same lack of bounds checking.
void initializeAllWeightsAndBiases()- Calls
initializeWeightsAndBiases()on every slot.Warningdereferences every slot without a null check, so this crashes if any slot was never filled.
void forwardPass(const double* inputs)- Runs each hidden layer in order, then each output layer, feeding each
layer's activations to the next. Read the result from
outputLayers[0]->neuronOutputsActivated. void backwardPass(std::vector<double>& targetOutputs)- Computes error signals and gradients for the output layer, then for the
hidden layers from last to first, then applies the weight update to every
layer. The argument is a non-const reference but is not modified.
void runEpoch(std::vector<double>& inputs, std::vector<double>& targetOutputs)- One
forwardPassfollowed by onebackwardPass. Despite the name this processes a single sample, not an epoch. double computeLoss(const std::vector<double>& targetOutputs) const- Returns the sum of squared differences between the output activations and
the targets.
Warning
this ignores the layer's configured loss function and always computes squared error. Training on
binaryCrossEntropyand reading the loss from here reports a different quantity from the one being minimised. See Known Bugs, item 1. void train(std::vector<std::vector<double>>& inputs, std::vector<std::vector<double>>& targetOutputs, int numEpochs)- Per-sample SGD. For each epoch, visits every sample in fixed order, calls
runEpoch, accumulatescomputeLoss, and prints the mean tostd::cout. Throwsstd::invalid_argumentif the two containers differ in length; returns immediately ifinputsis empty.There is no way to suppress the per-epoch printing, no shuffling, and no return value. The Python binding reimplements this loop; see Network.train.
void printAllLayers()- Calls
print()on every layer. ~Network()deletes every layer in both vectors.
Activation Functions
Namespace core::functions::activations. All are elementwise except
softmax. Each derivative takes the pre-activation z.
| Function | Derivative | Definition |
|---|---|---|
sigmoid | sigmoidDerivative | 1 / (1 + exp(-x)), derivative s(x)(1 - s(x)) |
relu | reluDerivative | max(0, x), derivative 1 if x > 0 else 0 |
leakyRelu | leakyReluDerivative | x if x > 0 else 0.01 * x |
tanh | tanhDerivative | std::tanh(x), derivative 1 - tanh(x)^2 |
swish | swishDerivative | x * sigmoid(x), derivative
s + x*s*(1-s) |
gelu | geluDerivative | tanh approximation:
0.5*x*(1 + tanh(sqrt(2/pi)*(x + 0.044715*x^3))) |
softmax | none | exp(z_i - max z) / sum exp(z_j - max z).
No derivative is implemented, so a softmax layer cannot be
trained. See Known Bugs, item 2. |
softmax subtracts the row maximum before exponentiating, so it does not
overflow. sigmoid does not branch on the sign of its argument
and evaluates exp(-x) directly.
Weight Initializers
Namespace core::functions::initializers.
double* zeros(double* pad, size_t size, size_t size2)- Sets
size * size2values to zero. double* xavier(double* pad, size_t fanIn, size_t fanOut)- Normal distribution with standard deviation
sqrt(2 / (fanIn + fanOut)). Suited tosigmoidandtanh. double* he(double* pad, size_t fanIn, size_t fanOut)- Normal distribution with standard deviation
sqrt(2 / fanIn). Suited toreluand its relatives. std::mt19937& get_generator()- Returns the shared generator, a function-local
staticseeded once fromstd::random_device.Warningthere is no way to set the seed, so no run is reproducible. See Known Bugs, item 8.
Loss Functions
Namespace core::functions::loss. Remember the argument order:
(actual, predicted, size).
| Function | Derivative | Notes |
|---|---|---|
mse | mseDerivative | Mean squared error; averaged over size |
mae | maeDerivative | Mean absolute error. The subgradient at zero is taken as 0. |
huber | huberDerivative | Huber loss with delta fixed at 1.0 as a
constexpr; not configurable |
binaryCrossEntropy | binaryCrossEntropyDerivative | Predictions clamped into
[1e-12, 1 - 1e-12]; averaged over size |
crossEntropy | crossEntropyDerivative | Categorical cross entropy; summed, not averaged, which is the
usual convention for a one-hot target. Predictions floored at
1e-12. |
crossEntropy is intended to pair with a softmax output. Because no
softmax derivative exists, that pairing cannot currently be trained, and
crossEntropyDerivative divides by the prediction — which
approaches zero for a confidently wrong answer.
Weight Storage Layout
neuronWeights is one flat array in row-major order. The weight
connecting input i to neuron n is at:
neuronWeights[n * inputSize + i]
so one neuron's weights are contiguous. neuronWeightGradients uses the
same layout.
This matters when reading another layer's weights. In
HiddenLayer::computeErrorSignal the stride over the next layer's
matrix is that layer's fan-in, which equals this layer's width:
nextLayerWeights[m * this->layerWidth + n]
Using inputSize there instead of layerWidth is a natural
mistake and produces a network that trains plausibly and is silently wrong.
Complete Example
A two-input, one-hidden-layer network trained on a linearly separable set.
#include <iostream>
#include <vector>
#include "core/network.h"
int main()
{
namespace act = core::functions::activations;
namespace ini = core::functions::initializers;
namespace loss = core::functions::loss;
std::vector<std::vector<double>> X = {
{0.1, 0.2}, {0.8, 0.9}, {0.2, 0.1}, {0.9, 0.7}
};
std::vector<std::vector<double>> Y = {
{0.0}, {1.0}, {0.0}, {1.0}
};
// 1 hidden layer, 1 output layer, learning rate 0.05
core::Network net(1, 1, 0.05);
net.addHiddenLayer(0, /* width */ 4, /* inputSize */ 2,
act::tanh, act::tanhDerivative,
ini::xavier,
loss::mse, loss::mseDerivative);
net.addOutputLayer(0, /* width */ 1, /* inputSize */ 4,
act::sigmoid, act::sigmoidDerivative,
ini::xavier,
loss::mse, loss::mseDerivative);
net.initializeAllWeightsAndBiases();
net.train(X, Y, 2000);
net.forwardPass(X[1].data());
std::cout << "prediction: "
<< net.outputLayers[0]->neuronOutputsActivated[0]
<< "\n";
return 0;
}
Two things to note. The output layer's inputSize must equal the
hidden layer's width; nothing checks this. And the activation and its
derivative are passed as two separate arguments, so an inconsistent pair such as
act::sigmoid with act::reluDerivative compiles without
complaint.
Test Suite
./tests/run_tests.sh # plain build
./tests/run_tests.sh --asan # with AddressSanitizer and UBSan
./tests/run_tests.sh --asan Grad # only tests whose name contains "Grad"
The suite covers core/layer.h and core/network.h separately
and exits non-zero on failure.
Several tests fail by design. They are executable specifications for the
bugs listed below, each reporting through FAIL_WITH_NOTE with the
defect and the fix. A green run is the goal, not the current state.
Known Bugs and Limitations
computeLossignores the configured loss. It always sums squared error, so the reported loss is not the quantity being minimised unless you configuredmse.- Softmax cannot be trained.
softmaxhas a forward implementation but no derivative. Its Jacobian is a dense matrix, which the scalar-in / scalar-out derivative signature cannot express. The correct fix is to fuse it with categorical cross entropy, where the chain collapses todL/dz = p - y, and to add an entry point that accepts gradients already with respect toz. - Only the first output layer back-propagates.
backwardPassreadsoutputLayers[0]when propagating into the hidden stack, so withnumOutputLayers > 1the other heads' gradients are silently discarded. addHiddenLayerandaddOutputLayerdo not bounds-checklayerIndex. An out-of-range index writes past the end of the layer vector.initializeAllWeightsAndBiasesdereferences unfilled slots. Constructing aNetworkwith n hidden slots and filling fewer than n crashes.- The default
Layerconstructor leaves function pointers indeterminate.Layer() noexcept = defaultgives no member initialisers, soactivationFunctionand the rest hold garbage rather than null. CallingcalculateOutputs()on such an object jumps to an arbitrary address. The fix is= nullptrinitialisers on all five. - The scratch pads are the wrong size.
Both are declared
inputSize * widthbut onlylayerWidthelements are ever used. That overallocates by a factor ofinputSize— and is simultaneously too short wheninputSizeis zero. They should bewidth. - No seed control.
get_generator()seeds once fromstd::random_deviceand cannot be reseeded, so weight initialisation is never reproducible. - Hidden layers carry an unused loss function.
Every
Layerstores a loss and its derivative, but only the output layer uses them. The constructor still requires both. - Activations and their derivatives are independent arguments. Nothing checks that they match, so a mismatched pair compiles and trains incorrectly. The Python binding avoids this by selecting matched pairs by name.
Networkis unsafe to copy. It owns raw pointers and has a destructor but no copy constructor or assignment operator.- Gradients are assigned, not accumulated.
computeGradientsoverwrites, andbackwardPassapplies the update immediately, so mini-batching cannot be expressed without changing both.
Not Supported
Optimizers other than plain SGD (no momentum, no Adam, no learning rate
schedule), mini-batching, L2 weight decay, dropout, train/validation split,
early stopping, per-epoch loss history, model save and load, a trainable
softmax output, convolution, recurrence, attention, embeddings, skip
connections, batch or layer normalization, sparse input, streaming datasets,
GPU, threading, and SIMD. The inner loops are scalar double
arithmetic.
See TODO.md in the repository root for measured performance work in
priority order.