Architecture
This document explains how Gorch’s automatic differentiation works and how to
extend it. The design is intentionally simple: every differentiable operation
stores its inputs and a hand-written local gradient rule, and backward()
walks the resulting graph in reverse.
Package layout
| Package | Responsibility |
|---|---|
gorch/tensor/ |
Tensor, the reverse-mode graph, and all differentiable ops |
gorch/nn/ |
Module, layers, activations, losses, functional helpers |
gorch/optim/ |
Gradient optimizers, Kalman filter, RLS, Levenberg–Marquardt |
gorch/utils/ |
Datasets and data loading |
The autograd graph
Tensor
A Tensor wraps a NumPy array in .value and exposes:
requires_grad: boolgrad: Tensor | None— populated by.backward()(None initially)_grad_fn: Backward | None— the node that created this tensor (None for leaves)
*Backward nodes
Each differentiable operation creates a node, e.g. AddBackward(x, y), which:
- Stores its inputs in
self.input = [x, y, ...]. - Implements
backward(gradient) -> list, returning one gradient per input (Noneentries are allowed for inputs that don’t need a gradient).
When Tensor.backward() runs, it seeds the output with gradient 1, then walks
_grad_fn backward, chaining gradients:
# tensor.py (simplified)
self.grad = gradient.transpose()
if self._grad_fn is not None:
grads = self._grad_fn.backward(gradient)
for input, g in zip(self._grad_fn.input, grads):
if g is not None and input.requires_grad:
input.backward(g)
The gradient convention (important)
Gradients are carried transposed through the graph:
Tensor.backwardstoresself.grad = gradient.transpose(), so on a leaf the stored.grad.valuematches the leaf’s.valueshape.- Inside a node’s
backward(gradient),gradientis the upstream gradient in transposed space — its shape is the output shape reversed (output.shape[::-1]). - The node must therefore return each input’s gradient in transposed space too,
i.e. with shape
input.shape[::-1].
Example — SqrtBackward:
class SqrtBackward:
def __init__(self, tensor):
self.input = [tensor]
def backward(self, gradient):
(tensor,) = self.input
# dy/dx = 0.5 / sqrt(x); transpose so the result matches
# the input's transposed shape, then wrap in a Tensor.
grad_x = 0.5 / tensor.value.sqrt()
return [gorch.Tensor((gradient.transpose().value * grad_x).transpose())]
Nodes that must deal with broadcasting (e.g. AddBackward, MulBackward)
reduce the gradient with sum(axis=...) back onto the input’s shape — still in
transposed space.
Adding a new operation
To add a differentiable operation foo, four places must be touched:
gorch/tensor/backward.py— add aFooBackwardnode implementingbackward(gradient), following the transposed-gradient convention above, and add it to__all__.-
gorch/tensor/methods.py— add a module-levelfoo(tensor, ...)that computes the forward NumPy result, builds aTensorwithrequires_gradset, and attaches the node:def foo(tensor): if not isinstance(tensor, gorch.Tensor): raise ValueError("Input must be a Tensor") out = gorch.Tensor(np.foo(tensor.value), requires_grad=tensor.requires_grad) if tensor.requires_grad: out._grad_fn = FooBackward(tensor) return outAlso add
footomethods.__all__and — if it is a reduction withaxis/keepdims— make surenp.*is used inside so Python builtins are not shadowed (methods.pydeliberately redefinessum,max,min, …). gorch/tensor/tensor.py— add a convenience method onTensorthat delegates to the module-level function.gorch/tensor/__init__.py— addfooto__all__so it is re-exported atgorch.foo.
Non-differentiable ops (e.g. diag, var, median, argmax) simply return
requires_grad=False and never attach a node.
Then verify with a finite-difference test:
from tests._helpers import check_gradient, check_binary_gradient
check_gradient(gorch.foo, np.random.standard_normal((3, 4)))
nn and optim
Module.parameters()/children()discover sub-modules viainspect.getmembers, so parameters and sub-modules must be plain instance attributes set in__init__(this is howSequentialstores its layers as0,1, …).Optimizersubclasses implementstep()and inheritzero_grad()from the base class; new optimizers must be exported fromgorch/optim/__init__.py.- The control tools use the functional helpers:
jacobian(model, x)returnsd(model(x))/d(parameters), of shape(batch * out_dim, n_params).vectorize_parameters/devectorize_parametersconvert between a model and a flat parameter vector (devectorize_parametersadds its argument).
Limitations
backward()only works on scalar outputs unless an explicitgradientTensoris supplied.- Grads are stored transposed during the pass (see above); the load-bearing
convention is that a leaf’s
.grad.valuematches the leaf’s.valueshape. - No GPU support, no sparse tensors — this is a teaching library.