Working name. A small, readable reverse-mode automatic-differentiation library for inventing neural-net architectures, grown out of the autodiff example in pyccolo (
pyccolo/examples/autodiff.py).
A NumPy-backed autograd you can actually read, that scales down to "explain backprop on one slide" and up, eventually, to training real models. The reverse-mode core is already correct and tiny; pycograd is about building the framework around it without losing the legibility.
The starting point is the pyccolo autodiff example, which already has:
- A reverse-mode tape node (
Var) wrapping a NumPy array, with operator overloading, broadcasting (gradients reduced back over broadcast axes), and VJP rules for the common elementwise / reduction / linear-algebra / shape ops. - Indexed reads (gather forward, scatter-add backward),
where/clip,var/std,max/minwith subgradients,concatenate,detach. value_and_grad/grad, a plain-SGDgradient_descent, and demos that train logistic regression, an MLP, an MLP+LayerNorm+Dropout, and a single-head Transformer block — each gradient-checked against finite differences.- Transparent interception of
numpy/mathcalls via pyccolo'sbefore_call(so you write ordinarynp.exp(x)and it differentiates), helper instrumentation on demand, and a pipescript|>integration.
So the autograd core ships working. Everything below is the surrounding system.
pyccolo's before_call interception is a great teaching device — it shows that
operator overloading alone can't make np.exp(x) differentiable and that a
tracer can fix that transparently. But it is the wrong foundation for a real
library: it only works inside an instrumented function under the tracer, and it
adds per-call overhead.
pycograd's Tensor (the productized Var) should instead implement NumPy's own
extension protocols — __array_ufunc__ and __array_function__ — so a Tensor
is differentiable everywhere, with no tracer required. (The example deliberately
sets __array_ufunc__ = None to motivate pyccolo; pycograd reverses that.)
pyccolo can remain an optional "transparent mode" for niceties the protocols
can't reach — routing scalar math.* through tensor ops, differentiating through
un-annotated helper bodies, and the pipescript |> syntax — but nothing in the
core should depend on it.
The existing demos (logistic regression → MLP → LayerNorm/Dropout → Transformer) become the first integration tests / examples and the bar for "don't regress."
Phased so each layer rests on the one before it. Each item is tagged with how crucial it is to the goal (developing novel architectures and/or training at scale) and a rough feasibility / effort. Note the tension: the single most crucial thing for scale (hardware + performance) is intentionally last because it is gated by the foundation.
- Carve out into
pycograd. LiftVar+ the VJP rules out of the pyccolo example into a standalone package (pycograd.tensor,pycograd.ops), with its own tests/CI. Crucial: prerequisite. Feasibility: high (days). - A real
Tensortype via NumPy protocols. Implement__array_ufunc__/__array_function__sonp.*on aTensordispatches to our VJPs natively; drop the__array_ufunc__ = Nonefail-loud stance. Crucial: foundational. Feasibility: medium (the protocols are fiddly but well-documented). - dtype & a device seam. The array-backend seam is done:
device("cupy")swaps the array library the tape, primitives, and optimizers compute with (NumPy default, CuPy for GPU), so a net trains on-device unchanged. Still open: dtype — stop forcingfloat64, track dtype, defaultfloat32, supportbf16/float16. Crucial: high (memory + speed + the GPU on-ramp). Feasibility: medium. - Keep pyccolo as optional transparent mode (math.* routing, helper
instrumentation, pipescript
|>), gated behind an extra. Crucial: low. Feasibility: high (already built).
- Module / pytree parameter abstraction. Named, nested params + state; stop threading bare arrays positionally (the Transformer demo takes 14 positional args). Crucial: highest for developing architectures. Feasibility: high (pure Python).
- Real optimizers. Adam/AdamW, SGD+momentum, weight decay, gradient
clipping, LR schedules,
zero_grad. Crucial: high. Feasibility: high. - Fused, numerically-stable primitives. Landed.
log_softmax,logsumexp,softmax,cross_entropy, the activation family, and the normalization layers are first-class ops inpycograd/functional.py:layer_norm,rms_norm,group_norm,instance_norm, andbatch_norm(with running stats, state-in/state-out(y, new_mean, new_var)).batch_norm's running stats ride a new mutable-bufferleaf (Param.mutable+buffer[...]ParamDict.update_buffers): non-trainable, optimizer-skipped, advanced out-of-band. Also landed:scaled_dot_product_attention,multi_head_attention,embedding,linear,dropout, andconv_transpose2d/upsample_nearest2d. Open: a forward scatter-add primitive (x.at[i].add(...), Phase 2) — until thenconv_transpose2dis built from an einsum input-dilation + flipped conv rather than a col2im scatter.
- Broader op coverage.
einsum(general contractions / attention variants),gather/scatter+ embeddings,sort/argsort/topk,cumsum, padding, one-hot. Crucial: high for novel architectures. Feasibility: medium (einsumis real work). - Convolution / pooling. Conv1d/2d, pooling. Crucial: high (CNNs). Feasibility: medium — naive is easy but slow; fast conv = im2col/FFT.
- RNG management. Landed (
pycograd/random.py): a pure, splittable JAX-style key API —key/split/fold_inplusbernoulli/uniform/normal/randint, built on numpy's counter-basedSeedSequence+Philox.dropouttakes an explicitkey(orrng) and no longer falls back to a hidden global. Keys are sampled host-side (a per-backend RNG seam is still open);vmapover a per-row key is not supported (host RNG can't consume a batched-tracer key), but a single key already gives independent per-sample masks over a(B, …)batch. - Training glue. Data loading/batching/shuffling, checkpoint save/load (params + optimizer state), basic metrics/logging. Crucial: medium. Feasibility: high (orthogonal).
- Gradient checkpointing. Landed (
pycograd/checkpoint.py):checkpoint(f)wraps a segment so its intermediate activations are dropped on the forward and rematerialized in backward by re-runningf-- trading ~one extra forward for a peak-memory drop from "every segment at once" to "one segment at a time". A single boundary node stands in foroutputs = f(inputs); its value is the flat concatenation of the output leaves and the user-visible outputs are realslice+reshapeviews, so the multi-output cotangent join rides the existing VJPs. Backward lifts the saved input/weight values into fresh leaves, re-runsf, contracts each output leaf with its cotangent, and scatters the recomputed grads onto the boundary's parents. Works with positionalgrad/value_and_gradand the ambientweights.gradpath (ambient weights enter by globals and the live binding is gone by backward time, so checkpoint discovers the weightVars a segment touches via a small active-bindings registry and re-binds them for the remat), over arbitrary pytree outputs, and nests. It also saves memory undervmap: checkpoint lowers the batch into the boundary (vmap(checkpoint(f)) == checkpoint(vmap(f))— physicalize the batched inputs, build the boundary one level down, re-wrap the batched outputs), sograd(vmap(checkpoint(f))), the per-samplevmap(grad(checkpoint(f)))(including per-sample gradients of a shared param), and nestedvmap(vmap(...))all rematerialize the batched activations. Under a livejvpthe inputs are tangent tracers and aVarboundary can't be built without dropping the tangent axis, so checkpoint is transparent there (correct grads, no memory saving in that case); reverse-over-reverse of a checkpointed segment (grad(grad)/jacrevof its gradient) raises with a pointer to the forward-over-reversejacfwd(grad)Hessian. Constraint:fmust be deterministic in its inputs+weights (RNG/dropout-in-freplay is a follow-up). - Explicit tape lifetime. Free graphs deterministically;
no_gradcontext; guard against the tape/_INSTRUMENTEDcaches growing unbounded. Crucial: medium. Feasibility: medium. - In-place / scatter updates.
__setitem__, masked assignment, scatter-add for embeddings / KV-caches / efficient optimizer steps. Prefer a functionalx.at[i].add(...)form to sidestep aliasing hazards (true in-place + autograd needs version tracking). Crucial: medium. Feasibility: medium. - Mixed-precision training. Loss scaling, autocast. Crucial: medium-high for scale. Feasibility: medium.
- Higher-order gradients. Landed.
Var.backwardhas a differentiable path: when an enclosing transform is live it accumulates cotangents as level-connectedVars (a_VJP_FORrule table ridingbind) instead of mutating numpy.grad, while a single top-levelgrad/training run keeps the original raw path byte-for-byte (gated bytrace.num_transform_levels()). This gives Hessians/HVPs via forward-over-reverse (jvp(grad(f)),jacfwd(grad(f))) and literal reverse-over-reverse (gradof a scalarized innergrad,jacrev(grad(f))) — reverse and forward Hessians agree and match finite differences; gradient-penalty losses work. Per-sample Hessians/HVPs also compose withvmap—vmap(jacfwd(grad(f)))/vmap(jacrev(grad(f)))and batchedjvp(grad(f))give per-example(B,n,n)/(B,n)results (realized as reverse-over-reverse over one batched forward, sidestepping the_unbroadcast/_KEEP_BATCH_AXESseam). Constraints, as in JAX: the per-examplefis scalar-output and single-array (multi-arg/shared-param per-sample Hessians not yet wired);gradrequires scalar output (grad(grad(f))on a non-scalar inner is ill-posed and raises a clear error). - Forward-mode /
jvp. Landed (pycograd/forward.py):jvp(f, primals, tangents)jacfwdas aJVPTracelevel ridingbind; composes withvmapboth ways and with itself (jvp(jvp(f)), second-order forward).
vmap(auto-batching) / per-sample gradients. Landed — including the composable v2. A trace-level interpreter stack (pycograd/trace.py:bindover a stack ofTrace/Tracerlevels, fed by pyccolo's operator/subscript interception) lets transforms be simultaneously live. So beyond single-levelvmap/grad(vmap(f)), these all now work:vmap(vmap(f))(aBatchTracerper level, batch axis materialized and moved-to-front per level), per-sample gradients of a shared parameter (vmap(grad(f))returns(B, *w.shape)via a batched-cotangent backward / the shared param tiled per example), and gather both from batched data (vmap(lambda x, i: x[i])) and from a shared table (vmap(lambda i: E[i]), ridingVar's scatter-add backward). The samebind/Tracemachinery is the substrate for the trace-and-compile work below; the one piece deliberately left on the old path isShapedArray/eval_shape(it still rides the backend swap — reframing it as aTraceris a natural, separate follow-up). Forward-modejvp+ higher-order grad (next bullet) would slot in as anotherTracelevel.
- GPU / accelerator backend. The execution model is CPU-bound and allocates a
Python
Varper op (~2–4 orders of magnitude too slow/heavy for real training). A CuPy backend (its NumPy-mirroring API means much "just works") gets GPU at moderate effort. Crucial: highest for scale. Feasibility: medium for CuPy; the per-op Python overhead is not fixed by this. - Graph capture + compilation / fusion. Eliminate per-op Python overhead by
tracing to a graph IR and compiling/fusing (XLA-style). This is a fundamentally
different execution model from the eager tape and likely a separate engine.
Crucial: highest for throughput. Feasibility: low (research-grade). The
abstract-shape engine (
shapes.py) is a down payment: its per-primitiveabstract_evalrules already size every node without data, carry symbolic dims (_dims.Dim) for data-dependent shapes, and now run as a first-classAbstractTracelevel on the same stack (ShapedArrayis aTracer) — so a capture tracer is just anotherTrace/Tracerreusing these rules, withvmap's batch axis one more dim in the same algebra. - Distributed / multi-device. Data / model / pipeline parallelism, collectives. Crucial: only for truly large scale. Feasibility: very low; pointless before the above.
- A PyTorch-compatible API surface. Legibility over familiarity.
- Beating PyTorch/JAX on speed. The point is a system you can fully read.
- Being a hard dependency of pyccolo or pipescript — the relationship is reversed: pycograd may optionally use pyccolo for transparent mode.
- Eager tape vs. trace-and-compile: Phase 4 (and
vmap) may not be reachable from the eager design without a second execution mode. Decide early whether pycograd is "always eager, readable" or grows a compiled path. - How much of the pyccolo transparent-interception story to keep front-and-center
vs. relegate to an optional extra once
Tensorworks natively with NumPy. - Backend abstraction: design the array seam now (Phase 0) so CuPy/other backends drop in later without touching the VJP rules.