A from-scratch neural network framework written in C++17 on top of xtensor, built for educational purposes — to understand how forward/backward propagation, optimizers, and training loops work without a black-box autodiff engine.
This README is both a getting-started guide and an honest, line-by-line audit of the current codebase. The framework compiles and "runs" today, but several of its core mechanics (matrix multiplication, backprop, batching, Adam) are stubbed out incorrectly, so a network trained with it currently will not learn correctly. The checklist below documents every problem found, why it matters, and the concrete fix — in the order you'd need to fix them to get a working MNIST classifier, and eventually a minimal LLM.
- Build & Run
- Project Map
- Problem Checklist: Core Engine (blockers)
- Problem Checklist: Layers & Activations
- Problem Checklist: Loss Functions
- Problem Checklist: Optimizers
- Problem Checklist: Data Pipeline
- Problem Checklist: Persistence & Build
- Roadmap: MNIST Classifier
- Roadmap: Minimal LLM (character/token transformer)
- Suggested Fix Order
Requires xtensor and xtl.
# Ubuntu/Debian (used by this repo's CI)
sudo apt-get install xtensor-dev
cmake -S . -B build
make -C build/
./build/mainIf you installed xtensor/xtl somewhere non-standard, point CMakeLists.txt's find_package(... PATHS ...) at that directory (see #22).
| File | Purpose |
|---|---|
include/tensor_load.hpp |
Tensor typedef (xt::xarray<double>) and includes |
include/layers.hpp |
Layer base class, Linear (dense) layer |
include/activations.hpp |
Tanh, Sigmoid, Relu, Softmax |
include/initialization.hpp |
Glorot, He, LSUV weight-init strategies |
include/lossfunctions.hpp |
MSE, MAE, MAS |
include/optimizer.hpp |
SGD, Adam |
include/data.hpp |
Batch, BatchIterator |
include/neuralnetwork.hpp |
NeuralNet — stacks layers, runs forward/backward |
include/train.hpp |
Training loop + ASCII progress bar |
src/main.cpp |
Example: 3 Linear layers + Tanh, trained with Adam |
These break correctness for any network with more than one layer, so nothing else matters until they're fixed.
-
1.
Linear::forwardre-randomizes its own weights on every call.layers.hpp:41callsinitialize()at the top offorward(). Every forward pass — including every one inside training — throws away whatever the optimizer just learned and replacesweights/biaswith fresh random values. The network can never converge; it's random every step. Fix: callinitialize()once (in the constructor, or lazily on first call only), never insideforward().Linear(double input_size, double output_size) : input_class_size(input_size), output_class_size(output_size) { initialize(); // once } Tensor forward(Tensor inputs) override { last_input = inputs; // cache for backward, see #3 return xt::linalg::dot(inputs, params.weights) + params.bias; }
-
2.
Linearuses elementwise*instead of matrix multiplication.outputs = inputs * params.weights(and the equivalent lines inbackward) is xtensor's elementwise/broadcast multiply, notY = XW + b. For anything but a diagonal special case this produces the wrong shape or wrong values. Fix: usext::linalg::dot(a, b)(requires#include <xtensor-blas/xlinalg.hpp>and linkingxtensor::optimize/BLAS, or a hand-written matmul if you want to avoid the BLAS dependency). Updateforwardandbackward(weight-grad, bias-grad, and the returned upstream gradient) to usedotthroughout. -
3.
NeuralNetdoesn't cache per-layer activations, sobackwardfeeds every layer the network's original input.neuralnetwork.hpp:21-32calls(*iter)->backward(res, inputs)for every layer using the sameinputstensor. Backprop's chain rule requires each layer to see the input it individually received during forward (i.e. the previous layer's output), not the network's raw input. With 3 stackedLinearlayers (as inmain.cpp), layers 2 and 3 currently receive the wrong "input" and produce wrong gradients. Fix: cache activations duringforward, then unwind them duringbackward:Tensor forward(Tensor& inputs) { cache.clear(); cache.push_back(inputs); for (auto& layer : layers_class) { inputs = layer->forward(inputs); cache.push_back(inputs); } return inputs; } Tensor backward(Tensor& grad) { Tensor res = grad; for (int i = (int)layers_class.size() - 1; i >= 0; --i) res = layers_class[i]->backward(res, cache[i]); // cache[i] = that layer's input return res; } private: std::vector<Tensor> cache;
This also removes the need to pass
inputsintoNeuralNet::backwardfromtrain.hppat all. -
4. Bias gradient is summed over the wrong axis.
layers.hpp:59:xt::sum(grad, 1)sums over the feature axis. The bias gradient should sum the loss gradient over the batch axis (axis 0), producing one value per output feature — the same shape asbias. Fix:params.grad_biases = xt::sum(grad, {0});
-
5.
Softmax::backwardadmits (in its own comment) it's an approximation and is mathematically wrong.activations.hpp:66:softmax_output * (1 - softmax_output)is the sigmoid derivative shape, not softmax's Jacobian. Softmax's true derivative is a full Jacobian matrix, which is expensive and rarely needed directly. Fix: don't backprop throughSoftmaxon its own — pair it with cross-entropy loss (see #9) and use the well-known simplificationd(loss)/d(logits) = predicted_probs - one_hot_targets. Compute that directly in the loss'sgrad()and skip callingSoftmax::backwardin the chain (i.e. treat "softmax + cross-entropy" as one fused output layer, which is standard practice even in real frameworks). -
6.
Softmax::forward's division isn't numerically stable and may not broadcast correctly.exp_values / exp_values_sumdivides by a reduced tensor without subtracting the row-wise max first, so large logits overflowexp(). Also confirmxt::sum(..., 1)keeps a broadcastable shape (usext::sum(x, {1}, xt::keep_dims)if not). Fix:Tensor softmax(Tensor& x) { auto row_max = xt::amax(x, {1}, xt::keep_dims); auto shifted = xt::exp(x - row_max); auto row_sum = xt::sum(shifted, {1}, xt::keep_dims); return shifted / row_sum; }
-
7. Weight-initialization strategies exist but are never used.
Glorot,He,LSUV(initialization.hpp) are fully separate fromLinear::initialize(), which just does rawxt::random::randn. For deeper nets (needed for MNIST accuracy, and essential for anything transformer-shaped) unscaled init causes vanishing/exploding activations. Fix: letLinearaccept an initializer strategy and call it ininitialize(), e.g.Linear(in, out, std::make_unique<He>()). -
8.
He::initializeis declared withoutpublic:.initialization.hpp:22:class He { Tensor initialize(...) ...— members of aclassdefault toprivate, soHe{}.initialize(...)doesn't compile from outside.GlorotandLSUVcorrectly mark their methodspublic:;Heis missing it. Fix: addpublic:beforeHe'sinitialize. -
9. No
Conv2D/pooling layers. OnlyLinear(fully-connected) exists. An MLP is enough to get decent MNIST accuracy (~97%+ once the bugs above are fixed), but a CNN needsConv2D,MaxPool/AvgPool, and aFlattenlayer, none of which exist yet. Needed only if you want CNN-level accuracy or want to build image-input LLM components later. -
10. No
LayerNorm/BatchNorm. Not needed for a small MNIST MLP, but required for any transformer/LLM work (see the LLM roadmap below) and helps deeper MLPs train stably.
-
11. No cross-entropy loss.
lossfunctions.hpponly hasMSE,MAE, andMAS. Classification tasks (MNIST, next-token prediction for an LLM) should train against categorical cross-entropy, not MSE — MSE works but converges slower and gives worse-calibrated probabilities. Fix: add aCrossEntropyclass whosegrad()implements the fused softmax+cross-entropy gradient from #5:class CrossEntropy : public Lossfunctions { public: double loss(Tensor predicted, Tensor actual) override { auto clipped = xt::clip(predicted, 1e-12, 1.0); return -xt::sum(actual * xt::log(clipped))() / predicted.shape()[0]; } Tensor grad(Tensor predicted, Tensor actual) override { return (predicted - actual) / predicted.shape()[0]; // softmax already applied upstream } };
-
12.
Lossfunctionsbase class methods fall off the end without returning a value.lossfunctions.hpp:16-22:loss()/grad()print "Error Not Implemented" but declare a non-voidreturn type with noreturnstatement — undefined behavior if ever called (e.g. through a base-class pointer, or viaMAE/MAS, which don't overridegrad()at all and would silently hit this UB path if.grad()is called on them). Fix: make the base class methods= 0(pure virtual) so any missing override is a compile error instead of runtime UB, and implementgrad()forMAE/MAS(or dropMASas a loss — "mean accuracy score" is a metric, not a differentiable loss, and shouldn't share the loss interface). -
13.
MAS("Mean Accuracy Score") is really an evaluation metric, not a loss — it has no gradient and can't be used to train. Fine to keep as an eval utility, just don't wire it into theTrainloop expecting gradients; rename it (e.g.Accuracy) so it isn't confused with a trainable loss.
-
14.
Adamshares one moment-estimateTensoracross every parameter in the network.optimizer.hpp:55:v_dw/s_dware single member tensors, reused across the loop over all layers' weights and biases (optimizer.hpp:34-48). Each parameter needs its own running first/second moment estimate — reusing one tensor means layer 2's bias update corrupts layer 1's weight statistics (and, since weights/biases differ in shape, will produce shape mismatches or silently wrong broadcasting). Fix: keep astd::vector<Tensor>(or astd::mapkeyed by parameter identity) ofv/sstate, one entry per parameter tensor, sized/initialized to zero on first use and indexed by position inparams_and_grads(). -
15. The weights-vs-bias branch in
Adam::stepnever triggers.optimizer.hpp:36: checksstd::get<0>(tuple) == "weights", butNeuralNet::params_and_grads()(neuralnetwork.hpp:36) pushes the string"weight"(singular). The string never matches, so every parameter — weights included — takes theelsebranch meant for biases, and second-moment ("weights"-branch) update math is dead code. Fix: either fix the string mismatch, or better, drop the type-branching entirely — the correct Adam update is the same formula for every parameter (s = beta2*s + (1-beta2)*grad^2); there's no legitimate reason weights and biases need different formulas. -
16. Second-moment update computes
grad^grad, notgrad^2.optimizer.hpp:37:pow(std::get<2>(tuple), std::get<2>(tuple))raises the gradient to the power of itself, which is not the Adam formula and isn't even well-defined for negative gradients. Fix:xt::square(grad)orxt::pow(grad, 2).
-
17.
BatchIteratordoesn't actually slice the data — every "batch" is the full dataset.data.hpp:32-37: for each computedstartoffset it setsbatch.inputs = inputs; batch.targets = targets;— the whole tensor, unsliced.batch_sizeis stored but never used to index intoinputs/targets. Training currently repeats a full-batch gradient stepceil(N/batch_size)times per epoch instead of doing minibatch SGD. Fix: slice withxt::view:std::vector<Batch> initialize(Tensor inputs, Tensor targets) override { std::vector<Batch> batches; int n = static_cast<int>(inputs.shape()[0]); std::vector<int> idx(n); std::iota(idx.begin(), idx.end(), 0); if (shuffle) std::shuffle(idx.begin(), idx.end(), std::mt19937{std::random_device{}()}); for (int start = 0; start < n; start += batch_size) { int end = std::min(start + batch_size, n); std::vector<int> chunk(idx.begin() + start, idx.begin() + end); Batch b; b.inputs = xt::view(inputs, xt::keep(chunk), xt::all()); b.targets = xt::view(targets, xt::keep(chunk), xt::all()); batches.push_back(b); } return batches; }
-
18.
inputs.size()(used for the number of batch starts) counts total elements, not rows.data.hpp:26:xt::arange(0, inputs.size(), batch_size)—size()on a 2D tensor isrows * cols, notrows. Combined with #17 this makes batch counts meaningless. Once #17 is fixed withinputs.shape()[0], this is naturally fixed too. -
19. No real dataset loader.
tensor_load.hpponly defines theTensortypedef — there's no IDX/CSV/PNG reader, no normalization helper, no train/validation split utility. You'll need to write (or vendor) an MNIST IDX-file parser before you can load real data (see the MNIST roadmap below). -
20.
DataIterator::initializematerializes all batches into memory up front (std::vector<Batch>), rather than yielding them lazily. Fine for MNIST (60k × 784 doubles ≈ 375MB, borderline — considerfloatinstead ofdoubleforTensor), but won't scale to LLM-sized token datasets. Worth switching to a generator/iterator pattern before doing LLM-scale training.
-
21. No model save/load. The existing README already flags this: "This model weights are not saved." There is no serialization anywhere in the codebase. You cannot stop training and resume, or save a trained MNIST/LLM model to disk and reload it for inference. Fix: add a simple binary (or JSON) serializer for
params_and_grads()— write each tensor's shape + rawdoublebuffer, and a loader that reconstructsLinearlayers with matching shapes.xtensortensors expose.shape()and contiguous data via.data()/iterators, which is enough for a straightforward flat binary format. -
22.
CMakeLists.txthardcodes one developer's local macOS paths.find_package(xtl REQUIRED PATHS /Users/harshaarya17/xtl)and the equivalentxtensorline won't exist on other machines. CI works around this becauseapt-get install xtensor-devputs headers in a standard system path thatfind_packagealso checks by default — but any contributor without that exact/Users/harshaarya17/...layout, and not doing an apt install, has to hand-edit the file. Fix: drop the hardcodedPATHS, or make them optional/environment-driven, e.g.find_package(xtensor REQUIRED)plus a documented-Dxtensor_DIR=...override for non-standard installs. -
23.
Tensor = xt::xarray<double>everywhere. Fine for the current toy example; for MNIST-scale (60k images) or LLM-scale (embedding tables, attention matrices) data,doubledoubles your memory footprint versusfloatfor no real accuracy benefit. Worth switching toxt::xarray<float>before scaling up.
Once the Core Engine blockers (#1–#4) are fixed, an MLP-based MNIST classifier is very achievable with this framework. Suggested path:
- Fix #1–#4 (matmul, weight persistence across forward calls, activation caching, bias-grad axis) — nothing trains correctly without these.
- Fix #14–#16 (Adam per-parameter state) or just use
SGDinitially — it's simpler to reason about while validating the fixes above. - Fix #17–#18 (real minibatching) — MNIST needs actual minibatch SGD to train in reasonable time/memory.
- Write an IDX file loader (#19) for the MNIST dataset files (
train-images-idx3-ubyte,train-labels-idx1-ubyte, etc.) — read the big-endian header, load pixels into a(60000, 784)Tensor, normalize to[0,1], and one-hot encode the 10 labels into(60000, 10). - Add
CrossEntropyloss (#11) and fixSoftmax(#5–#6) — train with softmax output + cross-entropy loss, the standard setup for classification. - Wire up
He/Glorotinit (#7–#8) — a 784→128→64→10 MLP needs proper init to train reliably. - Build the network in
main.cpp, e.g.:std::vector<std::unique_ptr<Layer>> layers; layers.push_back(std::make_unique<Linear>(784, 128)); layers.push_back(std::make_unique<Relu>()); layers.push_back(std::make_unique<Linear>(128, 64)); layers.push_back(std::make_unique<Relu>()); layers.push_back(std::make_unique<Linear>(64, 10)); layers.push_back(std::make_unique<Softmax>());
- Add save/load (#21) so a trained model can be reused for inference without retraining.
- Add an accuracy metric over a held-out test split (10k MNIST test images) to actually measure whether it's working —
MAS/Accuracy(#13) applied toargmax(predicted)vsargmax(actual). - (Optional, higher accuracy) Add
Conv2D/MaxPool/Flatten(#9) for a small CNN instead of an MLP.
This is a much bigger lift — Gekko ML today is a plain feedforward-layer framework with hand-written per-layer backward passes (no general autodiff graph), so a lot of new machinery is needed. Treat this as a project roadmap, not a "flip a switch" checklist:
- Everything in the MNIST roadmap first — it exercises the same core engine fixes (#1–#20) an LLM depends on.
- Tokenizer — start with a simple character-level or byte-pair-encoding tokenizer (not in scope for xtensor; plain C++ string processing) mapping text → integer token IDs.
- Embedding layer — a new
Layertype: a learnable(vocab_size, d_model)weight matrix,forwarddoes a row-gather (xt::view/fancy indexing) by token ID instead of a matmul;backwardscatter-adds gradients back into the rows that were looked up. - Positional encoding — either fixed sinusoidal encoding added to embeddings, or a second learnable
(max_seq_len, d_model)embedding table (simpler to implement first). - LayerNorm (#10) — new
Layer: normalize across the feature axis per token, with learnable scale/shift, needed before/after attention and the feed-forward block (pre-norm transformer block is the modern default and easier to get stable). - Multi-head self-attention — the biggest new component:
Q/K/Vlinear projections, scaled dot-product attention (softmax(QK^T / sqrt(d_k)) V), a causal mask (upper-triangular-infmask so token t can't attend to future tokens) for autoregressive generation, multiple heads concatenated and projected back tod_model. This needs batched matrix multiply over a 3rd (sequence) dimension, whichxt::linalg::dotdoesn't do for batches out of the box — you'll likely need a small batched-matmul helper looping over batch/head dims. - Feed-forward block — two
Linearlayers with aRelu/GELU in between, applied per-token (this part reuses existingLinear/Reluonce #1–#4 are fixed). - Residual connections —
output = sublayer(x) + x; requires each block'sbackwardto also add the identity gradient, not just the sublayer's. - Transformer block — compose attention + feed-forward + LayerNorm + residuals into one reusable unit, stack N of them in
NeuralNet. - Output head — a final
Linear(d_model, vocab_size)+ softmax + cross-entropy (#11) over the next-token prediction target. - Sequence-batched data pipeline — replace/extend
BatchIterator(#17, #20) to produce(batch, seq_len)integer token tensors with lazy loading, since token datasets don't fit in memory the way MNIST does. - Autoregressive sampling loop — a generation function that repeatedly runs
forwardon the growing sequence, applies temperature/top-k sampling to the final logits, and appends the sampled token (not part of training, but required to actually use the model afterward). - Save/load (#21), gradient clipping, and a learning-rate schedule (warmup + decay) — needed for transformer training stability at any real scale.
- Performance: this is a single-threaded, CPU, double-precision, header-only framework with no batched/strided matmul optimizations beyond what xtensor+BLAS gives you for free. Expect this to work for a toy LLM (small vocab, short context, a few layers, a tiny corpus) as a learning exercise — not a compute-competitive training stack. If you outgrow that, this codebase is a good place to understand the pieces before moving to a GPU-backed framework.
If you're doing this incrementally, this order minimizes rework:
- Core engine: #1 → #2 → #3 → #4
- Optimizer: #16 → #15 → #14 (or just use
SGDuntil the network is verified correct, then swap in a fixedAdam) - Data: #18 → #17 → #20
- Loss/activation: #6 → #5 → #11 → #12 → #13
- Init: #8 → #7
- Build/portability: #22, #23
- Persistence: #21
- Then follow the MNIST roadmap, and only after that's working, the LLM roadmap.
- Model weights are not currently saved (see #21).
- Axis/broadcasting conventions in xtensor: https://www.sharpsightlabs.com/blog/numpy-axes-explained/#numpy-axes-quick-explanation
- The initializers in
include/initialization.hppare based on ideas from three research papers (Glorot & Bengio 2010; He et al. 2015; Mishkin & Matas 2016 for LSUV) — worth reading once you wire them in (#7), to understand why each scale formula looks the way it does.
