A NumPy-backed automatic differentiation engine with broadcasting-aware gradients, built from first principles.
A few months back I followed Andrej Karpathy's Micrograd tutorial and built out an autograd engine from scratch. I found myself coming back to that project as a great learning experience, and decided that I wanted to expand and go further than Karpathy's tutorial -- including NumPy-backed tensors instead of scalars, broadcasting support, more activation functions, optimizer abstractions, etc. What you see here is a passion project designed to further my skills and help learn the inner workings of autograd engines.
The demo trains a model on a hand-generated two-moons dataset built with NumPy. The model follows a linear (2, 16), ReLU, linear (16, 16), ReLU, linear (16, 1) sequence and achieves a final loss of 0.0070. The early loss oscillation is expected with a momentum of 0.9.
git clone "https://github.com/Tyler-Simas/Thorngrad"
cd thorngrad
pip install -r requirements.txtpython -m examples.train_mlp_classifier
pytest -v
from thorngrad_pkg.tensor import Tensor
from thorngrad_pkg.nn import Linear, ReLU, Sequential
from thorngrad_pkg.optim import SGD
model = Sequential(Linear(2, 16), ReLU(), Linear(16, 1))
optimizer = SGD(model.parameters(), lr=0.1, momentum=0.9)
pred = model(x)
loss = ((pred - y) ** 2).mean()
loss.backward()
optimizer.step()
Real ML workloads operate on matrices, not individual scalars. This requires solving broadcasting-aware gradient accumulation, which scalar autograd does not encounter.
If
The incoming gradient must be summed back down along every dimension that was artificially stretched during the forward pass, or the shapes will not match.
For
The softmax Jacobian is dense — every output depends on every input:
where
In code, this is computed as a dot product (sum(g * s)) followed by a broadcast-subtract and elementwise multiply — an
For
Weights are initialized as:
scaling down the variance of each weight in proportion to the number of inputs being summed, so output magnitude stays roughly stable regardless of layer width — preventing vanishing/exploding activations from the very first forward pass.
Rather than a single file (as in Karpathy's scalar-based tutorial), the engine is split into tensor.py (core data structure + graph mechanics), ops.py (arithmetic), functional.py (activations), nn.py (layers/models), and optim.py (training). This keeps each file testable in isolation and mirrors how production frameworks separate concerns.
.
├── examples/
│ ├── decision_boundary.png
│ ├── loss_curve.png
│ └── train_mlp_classifier.py
├── tests/
│ ├── test_functional.py
│ ├── test_nn.py
│ ├── test_optim.py
│ └── test_tensor.py
├── thorngrad_pkg/
│ ├── __init__.py
│ ├── functional.py
│ ├── nn.py
│ ├── ops.py
│ ├── optim.py
│ └── tensor.py
├── .gitignore
├── conftest.py
├── README.md
└── requirements.txt
Two strategies are used: PyTorch cross-checks for exact correctness, and end-to-end convergence tests for optim.py. There are a total of 112 tests divided amongst the four files.
- Operations:
- add
- subtract
- mul
- div
- matmul
- pow
- sum
- mean
- reshape
- Activations:
- ReLU
- sigmoid
- softmax
- Linear/Sequential
- SGD with momentum
- Adam
- More losses (cross-entropy)
- GPU support
- Conv layers
Inspired by Andrej Karpathy's micrograd, without his excellent teaching none of this would have been possible.

