A minimal, educational deep-learning framework built from scratch on top of NumPy.
thorcino reimplements the essential pieces of a PyTorch-style workflow — a
tensor with reverse-mode automatic differentiation, a small set of layers
(including recurrent ones), activation and loss functions, checkpointing and a
data-loading pipeline — in a few hundred lines of readable Python.
The goal is not performance but clarity: every gradient is computed by hand in an explicit backward class, so you can read exactly how backpropagation flows through the computation graph.
Everything you normally need is re-exported from the top-level package, so a whole training run fits in a handful of lines:
import numpy as np
from thorcino import Tensor, Sequential, Linear, MSELoss, SGD, Trainer, TensorDataset, DataLoader
rng = np.random.default_rng(0)
# 1. A noisy dataset for y = 2x + 5
x = np.linspace(-5, 5, 200).reshape(-1, 1)
y = 2 * x + 5 + rng.normal(0, 0.5, x.shape)
loader = DataLoader(TensorDataset(Tensor(x), Tensor(y)), batch_size=32, shuffle=True)
# 2. A one-layer model, a loss and an optimizer
model = Sequential(Linear(1, 1))
trainer = Trainer(model, MSELoss(), SGD(model.parameters, lr=1e-2))
# 3. Train
for epoch in range(50):
loss = trainer.train_epoch(loader)
# 4. Read the recovered coefficients back off the parameters
w, b = model.parameters
print(f"loss={loss:.4f} w={w.data.item():.3f} b={b.data.item():.3f}")
# loss=0.2412 w=2.000 b=4.996 <- recovers 2 and 5; Xavier init and the
# shuffling are unseeded, so the exact digits move a little between runsThe run above can be resumed later: trainer.save(path) pickles the model,
optimizer and scheduler state alongside the metrics, and trainer.load(path)
puts all of it back.
To poke at the autograd engine directly, skip the layers entirely — any
Tensor produced by an operation carries the backward node that made it, and
backward() walks that graph for you:
from thorcino import Tensor
a = Tensor([[1.0, 2.0]])
b = Tensor([[3.0], [4.0]])
c = (a @ b).sum()
c.backward()
print(c.data) # 11.0
print(a.grad) # [[3. 4.]] -> dc/da = bᵀ
print(b.grad) # [[1.] [2.]] -> dc/db = aᵀ
print(type(c._grad_fn)) # <class 'thorcino.autograd.arithmetic.SumBackward'>Wrap a forward pass in no_grad() to skip building the graph entirely — the
same switch Trainer.eval() uses:
from thorcino.grad_mode import no_grad # also: from thorcino.autograd import no_grad
with no_grad():
print((a @ b)._grad_fn) # None -> nothing was recorded- Python >= 3.14
- NumPy >= 2.5.0
- Graphviz >= 0.21
- scikit-learn >= 1.9.0 (dataset generators used by the examples)
- pandas >= 3.0.3
Optional (dev): ipykernel, matplotlib, tabulate, myst_nb (used by the
examples and notebooks), flake8 and pytest (used by CI).
The project uses uv for environment and
dependency management, with hatchling as the build backend.
uv sync --all-groupsThis creates a .venv and installs the runtime and dev dependencies. The lock
file and build artifacts (uv.lock, dist/) are not tracked in git.
Continuous integration (.github/workflows/python-app.yml)
runs the same uv sync --all-groups, then lints with flake8 and runs
pytest. There is no test suite yet, so the pytest step currently tolerates
the "no tests collected" exit code.
The whole library lives under thorcino/ and is organized in a small number of
single-responsibility modules:
thorcino/
├── __init__.py # Flat public API: re-exports the names below
├── tensor.py # Tensor: the numpy-backed frontend + operator overloading
├── grad_mode.py # Global autograd switch: no_grad(), is_grad_enabled()
├── functions.py # Pure numpy math: activations, softmax, loss functions
├── activations.py # Activation layers (Identity, ReLU, Sigmoid, Tanh, GELU, Softmax)
├── losses.py # Loss objects (MSE, CrossEntropy, BinaryCrossEntropy)
├── optimizer.py # Optimizer, SGD, SGD_DL2, SGDM, Adam, AdamW
├── graph.py # ComputationalGraph: graphviz visualisation of a Sequential model
├── consts.py # Parameter role tags (WEIGHTS_ROLE, BIAS_ROLE)
├── utils.py # unbroadcast() helper for gradient reduction
├── layers/ # Network layers
│ ├── layer.py # Layer: abstract base (forward, train/eval, parameters, state)
│ ├── linear.py # Linear
│ ├── dropout.py # Dropout
│ ├── rnn.py # RNN (unrolled recurrence, BPTT via autograd)
│ ├── lstm.py # LSTM (input/forget/output gates + cell state)
│ └── sequential.py # Sequential + graph rendering helpers
├── autograd/ # Reverse-mode automatic differentiation
│ ├── base.py # Function: base class for every backward node
│ ├── arithmetic.py # Add/Sub/Mul/Div/Matmul/Sum/Mean/Max/Pow/Index/… backward
│ ├── activations.py # ReLU/Sigmoid/Tanh/GELU/Softmax backward
│ └── losses.py # MSE/CrossEntropy/BCE backward
├── dataset/ # Data loading pipeline
│ ├── dataset.py # Dataset, TensorDataset, ImageDataset, DataLoader
│ ├── transformation.py # RandomHorizontalFlip, RandomCrop, Compose
│ └── utils.py # image loading helpers
├── artifact/ # Pickled run artifacts
│ └── artifact.py # Artifact, load_artifact
└── training/ # Training loop orchestration
├── trainer.py # Trainer: train_epoch/eval, checkpointing, clip_grad_norm
└── schedulers.py # Schedule, CosineSchedule, CosineRestartSchedule
The design follows a clear frontend / backend split:
Tensoris the frontend. It wraps a NumPy array, overloads the Python operators (+,-,*,/,@, …) and records the operation that produced it in a_grad_fnattribute.- The
autogradpackage is the backend. Every operation has a matching*Backwardclass (aFunction) that knows how to turn an upstream gradient into the gradients of its inputs.
Layers, activations and losses are thin objects that call into functions.py for
the forward pass and attach the corresponding Function for the backward pass.
thorcino/__init__.py flattens most of this into a single import surface — the
tensor, every layer and activation, the losses, the optimizers, the trainer and
the data-loading pieces — so day-to-day use never needs the module paths. A few
newer pieces are not re-exported yet and need theirs: no_grad
(thorcino.grad_mode), CosineRestartSchedule
(thorcino.training.schedulers), LSTM (thorcino.layers.lstm, though
from thorcino import LSTM does work) and the artifact helpers
(thorcino.artifact).
thorcino implements reverse-mode autodiff by building a dynamic graph as
operations execute (define-by-run), then walking it backwards to accumulate
gradients.
Every backward node subclasses Function (thorcino/autograd/base.py):
class Function:
def __init__(self, *tensors):
self.saved_tensors = tensors # inputs needed by backward
self.next_functions = [t._grad_fn for t in tensors] # links to parent nodes
def apply(self, grad_output):
"""Turn the upstream gradient into gradients for each input."""
raise NotImplementedError()saved_tensorsholds the operands captured during the forward pass.next_functionsrecords each operand's own_grad_fn, which is what turns the set of nodes into a traversable graph.apply(grad_output)implements the chain rule for that specific operation and returns one gradient per input.
When you write c = a + b, Tensor.__add__ computes the numeric result with
NumPy and attaches the backward node:
out = Tensor(self.data + other.data)
if is_grad_enabled():
out._grad_fn = AddBackward(self, other)So each output tensor remembers how it was produced. Chaining operations
produces a graph of Function nodes rooted at the final output.
Every operation checks is_grad_enabled() before attaching its _grad_fn, and
the no_grad context manager flips that global off:
from thorcino.grad_mode import no_grad
with no_grad():
preds = model(inputs) # no backward nodes are allocated at allgrad_mode is deliberately dependency-free so thorcino.tensor can import it
without pulling in thorcino.autograd (which imports thorcino.tensor back).
Trainer.eval() runs under no_grad(): for a recurrent model an evaluation pass
would otherwise build — and immediately discard — one node per timestep per
batch. enable_autograd() survives as a no-op for API compatibility; autograd is
on unless a no_grad block is active.
Tensor.backward() (thorcino/tensor.py) drives backpropagation iteratively:
- If no gradient is supplied, it seeds
1.0for a scalar output (and raises for non-scalar outputs, matching PyTorch's behaviour). - It topologically sorts the graph (reverse-postorder DFS), so every node is visited only once all of its consumers have contributed their gradient — no Python recursion, so a long unrolled sequence can't blow the stack.
- It walks that order, calling
_grad_fn.apply(gradient)on each node and accumulating the results both into a per-node buffer (used to keep flowing downstream) and into each input tensor's.grad.
Partial gradients travel as raw NumPy arrays rather than Tensors: wrapping and
adding them would build a second graph while unwinding the first. apply() must
return exactly one gradient per saved tensor — a mismatch raises a RuntimeError
naming the offending backward class.
Broadcasting is handled by unbroadcast() (thorcino/utils.py), which sums a
gradient back down to the shape of the original operand so that broadcasted
operations (e.g. adding a bias vector to a batch) produce correctly-shaped
gradients.
Tensor.zero_grad()resets a tensor's accumulated gradient.Tensor.destroy_graph()walks the graph and drops every_grad_fn, freeing the saved tensors so the graph can be garbage-collected between iterations.
| Category | Backward classes |
|---|---|
| Arithmetic | AddBackward, SubBackward, MulBackward, DivBackward, NegBackward, ScaleBackward, PowBackward, IdentityBackward |
| Linear alg. | MatmulBackward, TransposeBackward |
| Reductions | SumBackward, MeanBackward, MaxBackward |
| Shape | ReshapeBackward, StackBackward, IndexBackward |
| Activations | ReLUBackward, SigmoidBackward, TanhBackward, GELUBackward, SoftmaxBackward |
| Losses | MSELossBackward, CrossEntropyLossBackward, BCELossBackward |
ScaleBackward handles the scalar/ndarray fast paths of * and /;
MaxBackward serves both max() and min() (via a largest flag) and splits
the gradient evenly across ties.
Tensor is a lightweight wrapper around a np.ndarray (always stored as
float32). It exposes:
- Metadata:
data,shape,size,dim,dtype,requires_grad,grad,_grad_fn,role. The optionalroletags a tensor asweightsorbias(seethorcino/consts.py); it drives per-role weight decay in the optimizers and the colour coding in the graph renderer. - Operator overloading:
__add__/__radd__,__sub__/__rsub__,__mul__/__rmul__,__truediv__,__matmul__,__pow__,__neg__,__gt__. Every arithmetic path is autograd-aware, including the scalar andndarrayones — a constant addend attachesIdentityBackward, a scalar factorScaleBackward, andother - selfNegBackward. - Tensor ops:
matmul,reshape(supports-1inference),transpose,sum,mean,max,min, plus__len__and an autograd-aware__getitem__that routes the gradient back to the positions it selected. - Stacking: the
Tensor.stack(tensors, axis=0)static method stacks a list of tensors while keeping them wired into the graph viaStackBackward— unlikeTensor(np.stack(...)), which builds a fresh leaf with no_grad_fn. This is what lets the recurrent layers return one output per time step and still backpropagate through time. - Autograd control:
backward(),zero_grad(),destroy_graph(). - Interop:
numpy()returns the underlying array.
A convenience path in __init__ lets you build a batched tensor from a list of
tensors — Tensor([t1, t2, ...]) stacks their data automatically.
Note that thorcino.tensor imports the backward classes lazily, inside the
method bodies that need them (plus a TYPE_CHECKING-only import for the type
annotations), to break the circular import between the tensor frontend and the
autograd backend — the backward classes need Tensor at runtime.
All layers derive from the abstract Layer base class, which defines
forward(), train() and eval(), makes instances callable, and exposes
parameters, state and set_state(). train()/eval() flip each layer's
training flag and the requires_grad of its parameters. The state accessors
default to empty, so a layer without weights needs no override.
| Layer | Description |
|---|---|
Linear |
Fully-connected layer y = xW + b with Xavier weight initialization and optional bias. |
Dropout |
Inverted dropout with keep-probability scaling; a no-op when training=False. |
RNN |
Vanilla recurrent layer: H_t = φ_h(X_t·W_xh + H_{t−1}·W_hh + b_h). Unrolls over the sequence axis and returns every hidden state stacked along axis=1; BPTT falls out of the autograd graph. Takes an explicit activation_h layer. The hidden state restarts at zero on each call, so sequences are never carried over between batches. |
LSTM |
Long short-term memory cell with input/forget/output gates and a cell state (forget-gate bias initialised to ones), unrolled the same way. out_type='n_to_m' returns every hidden state, 'n_to_1' only the last. Inputs are normalised to (batch, time, feature) first, so a 1-D or 2-D input is read as one scalar per timestep and requires in_feature == 1. |
Sequential |
Chains layers and forwards through them in order; aggregates their parameters and state, and propagates train()/eval(). |
Both recurrent layers are re-exported from the top-level package
(from thorcino import RNN, LSTM). Note that thorcino.layers itself only
re-exports RNN, so importing LSTM from that sub-package needs its module
path (from thorcino.layers.lstm import LSTM).
Every parameterised layer implements state (a dict of copied NumPy arrays under
short keys — 'W'/'b' for Linear, 'W_xh'/'W_hh'/'b_h' for RNN, the
twelve 'W_<gate>'/'W_h<gate>'/'b_<gate>' entries for LSTM) and
set_state(), which copies them back into the existing parameters and asserts
that the shapes already match. Sequential keys its children by position
("l0", "l1", …), so a reload only accepts a model with the same layers in
the same order. This is what Trainer.save()/load() is built on.
Sequential renders .png pictures of the model via thorcino/graph.py (needs
graphviz), through three methods that each take a real input shape including
the batch dimension:
save_arch(path, shape)— one node per layer, annotated with the real input/output shapes it produces.save_forward(path, shape)— the forward data-flow graph.save_backward(path, shape)— the same autograd graph read in reverse.
Each is built from an actual forward/backward pass with the shape you pass in
(e.g. (batch, in_feature), or (batch, seq_len, in_feature) for a recurrent
model), so every node carries the shape it would really have. Tensors are
colour-coded by role (input/weights/bias/hidden). destroy_graph() drops the
cached picture.
Each activation is available both as a pure NumPy function (thorcino/functions.py)
and as an autograd-aware Layer:
| Activation | Notes |
|---|---|
Identity |
x unchanged; useful as an explicit pass-through for the RNN layer's activation slot. |
ReLU |
max(0, x) |
Sigmoid |
Numerically stable (branch on the sign of the input) |
Tanh |
np.tanh |
GELU |
Sigmoid approximation x · σ(1.702·x) |
Softmax |
Max-shifted for stability; configurable dim |
Each wires its backward node only while training, so an eval()-mode
activation returns a tensor detached from the graph. functions.py also provides
a stable log_softmax, used internally by the cross-entropy loss.
| Loss | Input | Notes |
|---|---|---|
MSELoss |
predictions, targets | Mean squared error. |
CrossEntropyLoss |
logits, integer targets | Combines a stable log_softmax with negative log-likelihood; the backward is the classic softmax(logits) − onehot(targets). |
BinaryCrossEntropyLoss |
probabilities, targets | Clips predictions to [1e-7, 1 − 1e-7] to avoid log(0). |
All three derive from the abstract Loss base class, which pairs a forward pass
with the matching grad_fn. Each loss is callable (loss(pred, target)) and
returns a scalar Tensor you can call .backward() on.
| Optimizer | Notes |
|---|---|
SGD |
Plain gradient descent with optional L2 weight decay, applied only to tensors tagged weights. |
SGD_DL2 |
Same, with independent weight_decay and bias_decay terms, so weights and biases can be regularized separately (used by the regularization/L2 example). |
SGDM |
SGD with classic (non-Nesterov) momentum; unlike SGD, its weight_decay applies to every parameter regardless of role. |
Adam |
Adaptive moments with bias correction. |
AdamW |
Adam with decoupled weight decay — the decay shrinks param.data directly instead of entering the gradient, so it never feeds the moment estimates. |
Every optimizer takes model.parameters and a learning rate; step() updates
param.data in place and bumps step_count, and zero_grad() clears
param.grad. For checkpointing, the state property returns the
hyperparameters, the step counter and any per-parameter buffers (momentum, or
Adam's m/v), and set_state() restores them onto the same parameter list in
the same order.
Trainer(trainer.py) wraps a model, loss, optimizer and optional scheduler.train_epoch(dataloader, accumulation_steps=1)runs one epoch and returns the average per-batch loss. It reads the schedule before the epoch's updates run (the schedule is indexed by the epoch it applies to), supports gradient accumulation — the accumulation scale rides on the seed gradient, so the loss tensor and its graph are left untouched — flushes a short trailing group, and optionally clips gradients via thegrad_clip_normconstructor argument.eval(dataloader, record=True)runs underno_grad()and returns(avg_loss, accuracy); accuracy covers both multi-class and binary outputs and is0.0for non-classification (1-D) outputs. It restores whichever mode the caller was in rather than assuming training. Passrecord=Falsefor evaluations that aren't part of the training curve (a sweep over several validation sets, say) so they don't interleave with the periodic ones.- Both log into
trainer.history(train_loss,eval_loss,accuracy,lr), also reachable via thetrain_loss/eval_lossproperties. save(path)pickles a full checkpoint — counters, history, mode, plus model, optimizer and scheduler state — andload(path)restores all of it in place.save_metrics(path)writes epoch, step and history only, for the common case of keeping the curves without the weights.
clip_grad_norm(parameters, max_norm=1.0)rescales every parameter gradient in place so their combined L2 norm stays withinmax_norm, and returns the pre-clip norm.Trainercalls it for you, but it is exported fromthorcino.trainingfor standalone use.Schedule(schedulers.py) is the abstract base for learning-rate schedules, with the samestate/set_state()contract as the optimizers:CosineSchedule(max_lr, min_lr, total_epochs)anneals frommax_lrtomin_lralong a cosine curve, then holds atmin_lr.CosineRestartSchedule(max_lr, min_lr, boundaries)restarts a fullmax_lr → min_lrsweep at every entry ofboundaries, so the epoch just before each boundary is trained at (essentially)min_lr. That is what makes an intermediate checkpoint comparable to a completed run of that budget — the property the scaling-law study relies on. It is not re-exported at the top level:from thorcino.training.schedulers import CosineRestartSchedule.
A thin wrapper over the pickles the training loop writes: load_artifact(path)
reads one file and returns a frozen Artifact dataclass holding its data dict
of NumPy arrays. The scaling-law example builds its whole analysis pass on top of
this — see examples/recurrent/scaling_law/helpers.py.
The module mirrors the PyTorch Dataset / DataLoader pattern.
Dataset— abstract base defining__len__and__getitem__.TensorDataset— wraps in-memory tensors and validates that they share the same length along dimension 0. Rawnp.ndarrays are accepted too and wrapped on the way in.ImageDataset— lazily loads images from disk on access (viaload_jpeg), pairing each with its label. Note thatload_jpeg(dataset/utils.py) is still a stub, so this dataset is scaffolding rather than a working loader.DataLoader— iterates aDatasetin mini-batches, with optional shuffling, and collates each batch by stacking samples along a new leading (batch) axis. A bare list of per-field tensors is accepted as shorthand for aTensorDataset. The final batch is short when the dataset size isn't a multiple ofbatch_size.
Data augmentation transforms live in transformation.py:
RandomHorizontalFlip(p)— flips along the width axis with probabilityp.RandomCrop(height, width, padding)— zero-pads then crops a random window.Compose([...])— chains transforms into a single callable.
The examples split in two families, sharing the dataset/training/plotting
helpers in examples/helpers/.
Regression — examples/regression/
| Example | Description |
|---|---|
linear/ |
Recovers the slope/intercept of 2·x + 5 with a single Linear(1, 1) layer; compares the learned fit against the closed-form lstsq solution. |
quadratic/ |
Recovers the coefficients of x² + 2·x + 2 via feature expansion ([x, x²]) fed into a Linear(2, 1) layer. |
cubic/ |
Same idea one degree further: recovers 1.2·x³ − 2.3·x² + 2·x + 2 with a Linear(3, 1) layer over [x, x², x³]. |
ill-cond/ |
Notebook comparing closed-form OLS vs. SGD on ill-conditioned (near-collinear) features, showing OLS's coefficients blow up while SGD's stay stable. |
variance/ |
Notebook fitting 20 repeated Linear(1, 1) models at each of nine label-noise levels, showing via boxplots how the loss and learned slope/intercept drift and spread as noise grows. |
regularization/ |
Two notebooks on weight decay as variance reduction: L2/ sweeps the decay strength (with separate weights/bias decay via SGD_DL2) and watches the parameter spread shrink; DL2/ isolates decay from noise across repeated trials (work in progress — only the no-decay baseline is implemented). |
The regression/README.md ties the
linear/quadratic/cubic scripts together and explains why the learning rate has to
shrink as the polynomial degree grows.
Recurrent — examples/recurrent/
| Example | Description |
|---|---|
rnn/ |
Full walkthrough of a recurrent network on a next-number-in-sequence task, in two notebooks: scratch.ipynb hand-codes the forward recurrence, BPTT and the SGD update with raw NumPy, then main.ipynb rebuilds the same model on thorcino.layers.RNN + Trainer and renders its architecture and forward/backward graphs with save_arch/save_forward/save_backward. Its README derives the BPTT gradients in full. |
lstm/ |
Binary classification of character sequences against a small BNF grammar (each term is a vowel followed by a consonant), which needs both memory and non-linear temporal dependencies. Includes the 3-dimensional tokenizer (is-vowel, is-consonant, alphabet position) and renders the model architecture. |
scaling_law/ |
A full-factorial scaling study over dataset size × sequence length × update budget, driven by the Experiment harness in helpers.py. |
The scaling-law example is the largest thing built on the framework so far, and exercises most of the checkpointing machinery:
- Each grid cell is trained once to the largest update budget, harvesting a
checkpoint at every smaller budget along the way — so the cost is that of the
largest budget, not the sum of all of them.
CosineRestartScheduleis what makes those intermediate checkpoints comparable to completed shorter runs. - Epoch counts are derived from the updates an epoch really performs
(
updates_per_epochaccounts for the train split and the short final batch), so the budgets plotted are the budgets actually trained. - Three artifacts are written per checkpoint — the full trainer state, the
training metrics, and the scores against a shared validation set — under names
encoding the hyperparameters
(
E<id>__<epoch>_<updates>_<n_sequence>_<sequence_length>__<age>s.pkl), so an analysis pass can group runs by factor without reopening every pickle. - Recovery intersects the three artifact folders: a cell that only reached the backup before an interrupted run is treated as incomplete and recomputed, rather than counted as done with metrics missing.
Its README is still empty — the notebook and helpers.py docstrings are the
documentation for now.
uv run python examples/regression/linear/linear.pyPlanned work is tracked in TODO.md, and includes:
- Trainer: a static constructor that builds a
Trainerstraight from a checkpoint file. - Loader: parallel loading via multithreading; prefetching of the next batch.
- Autograd: cache forward intermediates for reuse in backward; add a debug step that reports which node a backward failure occurred on.
Known gaps in the current tree: dataset/utils.py:load_jpeg is a stub, the
scaling-law README is empty, and there is no test suite (the CI pytest step is
wired up but collects nothing).
MIT © cecinuga