🔪 Sharp Edges 🔪#

pytensor-ml is a package for defining, training, and deploying deep learning models. It is not the only deep learning framework, and shares many similarities with Torch, Keras, Optax, Equinox, and Flax. Users coming from these packages might be surprised by some of the design choices we have made in layer and optimizer implementations. The purpose of this page is to highlight our choices and keep users from stepping on too many rakes.

pytensor-ml is built on top of PyTensor, a static graph library derived from Theano. As a result, it has idiosyncrasies that users coming from other frameworks will not be used to. This page serves to highlight these as well.

This page is not exhaustive. We suggest new users start with our example gallery.

Compile with this library’s function#

When making a model in pytensor-ml, you use PyTensor to construct a static computational graph that can be analyzed, manipulated, and optimized. PyTensor graphs represent pure functions with no side-effects. The interested reader is referred to the PyTensor documentation for more details.

This design is awkward for deep learning, where we want to track internal state like running statistics, random generator entropy, and optimizer step count. PyTensor’s escape hatch for this case is shared variables and updates. The good news is that these automatically track state for you inside the training function. The bad news is that PyTensor requires they be manually threaded into pytensor.function() when compiling a graph into an executable program.

To help users, pytensor-ml ships with its own separate function() implementation. This wraps pytensor.function() and automatically handles the threading of these updates. If you use the plain PyTensor version, internal state will not advance and your training function will be silently broken.

import pytensor

from pytensor_ml.optim import adam, compile_train
from pytensor_ml.pytensorf import function

step = pytensor.function([X, y], loss, updates=adam(1e-3)(loss, parameters))  # threads none
step = function([X, y], loss, updates=adam(1e-3)(loss, parameters))           # threads all three
step = compile_train(loss, adam(1e-3), inputs=[X, y])                         # and collects the parameters

With the plain call:

  • Dropout draws one mask and reuses it for the life of the run.

  • Every schedule stays pinned at step zero. Under a warmup that is a rate of exactly zero, and the parameters never move at all.

  • Batch norm’s running statistics never leave their initial values. The network trains against batch statistics, then predicts against mean=0, var=1.

Position in a chain decides what a transform means#

A chain() has no head. Every stage reads updates[parameter] - parameter, and what that difference is depends on where the stage sits: ahead of a rule it is a gradient, behind one it is the step the rule chose. The same call means two different things in the two positions.

chain(clip_by_global_norm(1.0), adam(1e-3))   # bounds the gradient
chain(adam(1e-3), clip_by_global_norm(1.0))   # bounds the step

Only the first stops an exploding gradient. Adam normalizes its step to roughly the learning rate whatever the gradient was. A clip behind it almost never fires, and the spike still lands in the moment estimates.

Both placements of add_weight_decay() are real optimizers: ahead of the rule it adds the penalty to the gradient, which is coupled L2, and behind it subtracts from the step, which is AdamW. Placed behind a terminal scale() it decays by the learning rate; placed after that scale it decays by the full coefficient, roughly a thousand times more at a rate of 1e-3.

Clipping cannot rescue a step that has already gone non-finite#

clip_by_global_norm() bounds a step’s size. It is not a guard. One infinite coordinate makes the global norm infinite. The scale factor becomes max_norm / inf, which is zero, and every healthy parameter is multiplied by it. The poisoned coordinate becomes inf * 0, which is NaN. You lose the whole step and one parameter, and nothing raises.

chain(clip_by_global_norm(1.0), adam(1e-3))                   # bounds a large gradient
apply_if_finite(chain(clip_by_global_norm(1.0), adam(1e-3)))  # survives an infinite one

apply_if_finite() is the guard for a batch that overflows. Clipping bounds a batch that is merely large.

Optimizer state is matched by name#

Serializing a model here means freezing the PyTensor graph into a JSON description of its structure, and writing the values held by its shared variables into a separate .safetensors archive. Nothing in either file records object identity, so the two halves are rejoined on load by name: each variable’s name selects the archive entry to load into it.

Names are therefore load-bearing at the boundary and inert everywhere else. During training the updates dict is keyed by the variable itself, so two parameters called W are two different objects and both train correctly. The collision only appears at the first save_state(), possibly hours in. Two transforms of the same kind in one chain derive their state names the same way. Give one of them a namespace:

chain(trace(0.9), trace(0.5), sgd(0.1))                              # collides
chain(trace(0.9, namespace="fast"), trace(0.5, namespace="slow"), sgd(0.1))

reduce_on_plateau decides per step, not per epoch#

A compiled step is not only the forward pass. The loss, the gradients taken from it, the moment estimates an optimizer keeps, the clock a schedule reads, and any policy that adjusts the rate are all nodes in one graph. On compile, this entire graph is fused into a single function. Nothing is left over to run between calls. In pytensor-ml, schedulers and their internal states (called “clocks”), are symbolic.

Contrast this to Torch’s scheduler. These are Python objects that run when called and read whatever they are passed. They can be called at whatever cadence you wish. pytensor-ml schedulers, on the other hand, have to be expressible as graph nodes that are evaluated once per call. The graph has no notion of an epoch, which lives in the DataLoader outside it, so patience counts steps.

Torch’s ReduceLROnPlateau(patience=10) waits ten epochs; this waits ten steps. A configuration transcribed across cuts the rate roughly steps_per_epoch times too aggressively, on per-batch noise. Two defaults compound it: min_scale=0.0 lets a noisy loss cut the rate into the ground, and cooldown=0 lets the counter start toward the next cut immediately after one.

accumulation_size recovers torch’s cadence by deciding on the mean of a window rather than on one batch. Set it to the number of steps in an epoch, and note that it multiplies the wait: patience=2 with a window of 4 cuts on step 12, not step 3.

An epoch is never a first-class concept here, but it is arithmetic on steps, and these counts take a symbolic value as readily as a Python one. A count that depends on the batch can therefore be written in terms of it, which is the reason there is no epoch_size argument to keep in sync with your loader:

steps_per_epoch = n_samples // X.shape[0]
rule = reduce_on_plateau(
    adam(rate), rate, patience=10 * steps_per_epoch, accumulation_size=steps_per_epoch
)

A count whose value is known when the graph is built is checked there. One that is not carries its check into the graph instead, and that check is best effort: jax drops assertions, and a rewrite that eliminates the count eliminates its check along with it. A nonsensical count can reach a running model without complaint.

A schedule’s third argument is an endpoint, not a factor#

Optax’s cosine_decay_schedule(init, decay_steps, alpha) takes alpha as a fraction of the initial rate. Here the third positional argument is the rate the schedule arrives at. The optax spelling therefore builds a schedule that ramps up, and nothing warns, because an endpoint above the starting rate is a legitimate warmup.

cosine_schedule(3e-4, 10_000, 0.1)     # ends at 0.1, ramping up
cosine_schedule(3e-4, 10_000, 3e-5)    # ends at 3e-5, what optax's alpha=0.1 means

There is no warmup helper for the same reason: an endpoint above the start already is one, and join_schedules() composes the phases.

Hyperparameters are keyword-only#

A layer takes its name first and every hyperparameter by keyword. There are no positional hyperparameters anywhere in the library, so the torch and keras spelling raises rather than binding a hyperparameter to name and leaving the real one at its default:

Dropout(0.1)              # TypeError: Dropout's `name` must be a string ... Dropout(p=0.1)
Conv2D("conv", 1, 16, 3)  # TypeError: takes 2 positional arguments but 5 were given

Dropout("drop", p=0.1)    # torch's nn.Dropout(0.1)
Conv2D("conv", in_channels=1, out_channels=16, kernel_size=3)

Only the first can tell you which parameter you meant. Once the name slot holds a string the extra arguments are anonymous, and the error is the one Python writes.

Every layer’s name is optional and falls back to its class, so BatchNorm(n_in=4) is named BatchNorm and a stack of unnamed layers offers several variables of one name. The serialization boundary numbers those apart, keying them BatchNorm_1_scale, BatchNorm_2_scale, … in the order the variables are passed. Collecting them from the same graph gives the same order every time, so an unnamed stack round-trips across processes and across rebuilds – but a checkpoint saved from one ordering will not load under another:

shared = collect_shared_variables(network(X))
save_state(shared, "weights.safetensors")
load_state(collect_shared_variables(network(X)), "weights.safetensors")   # same order, loads

Name the layers you care about and the numbering never touches them; a name that appears once is its own key. The ordinal numbers the layer, not the variable, so two unnamed Linear layers give Linear_1_W, Linear_1_b, Linear_2_W, Linear_2_b. Shared state no layer built – a step counter, an optimizer’s moments – carries no layer to number, so a repeat there is numbered at the end of its own name instead.

The only positional argument a layer takes is its name. The two that wrap other layers take those instead, and their name by keyword: Recurrent is Recurrent(cell, name=...) and Bidirectional is Bidirectional(forward, backward, name=...).

Convolution inputs are channels-last#

Following jax, flax, and keras, convolutional inputs are (batch, *spatial, channels). The reason for this is that convolution is lowered to an im2col gather followed by one matmul, which wants the reduction axis last.

The rank is checked when the layer is called, and the error names the shape it wanted. The channel count is not, and is discovered only when the matmul compares its operands. A torch-shaped (batch, channels, height, width) batch with its channels declared correctly fails as Incompatible shared dimension for dot product: (240, 288), (27, 16), numbers that appear nowhere in your code. When the trailing spatial extent happens to equal the declared channel count, nothing fails at all and the graph convolves over the wrong axes.

Kernels follow the inputs: they are (*kernel_size, in_channels, out_channels), not torch’s (out, in, *kernel_size). im2col flattens the patch and its channels into the reduction axis, which leaves the output channels last.

Normalization is channels-last too#

Batch norm takes statistics over every axis but the last, so a channels-first image gives per-width-position statistics written into a per-channel slot. As with convolution it builds whenever the trailing axis equals the declared n_in, and otherwise fails as Incompatible Elemwise input shapes [(32,), (3,)], which names neither the layer nor the axis at fault.

Linear weights are the transpose of torch’s#

A batch enters a matmul as (batch, n_in), so the weight consuming it must be (n_in, n_out) and the product reads left to right: data in, features out. Linear stores exactly that and computes X @ W, where torch stores (out, in) because nn.Linear computes x @ W.T.

A state_dict copied across without transposing raises only when n_in != n_out. For a square projection, which covers every attention q/k/v/out projection and every recurrent W_hh, it loads clean and computes the transposed map.

Pooling strides by its kernel; convolution strides by one#

MaxPool2D(kernel_size=3) steps 3 at a time. Conv2D(kernel_size=3) steps 1. Reading the two signatures side by side this looks like a mistake, but it matches torch: pooling tiles, convolution slides.

A reversed recurrent layer stays aligned with its input#

Keras and flax return a backward pass last-step-first. Porting one, you would expect to flip it back. Here output step t always corresponds to input step t, in both directions, and flipping it yourself is what misaligns a bidirectional concatenation.

Backends do not always compute the same answer#

MLX computes in float32 on Metal. A float64 graph is demoted throughout, the declared output dtype no longer describes the result, and get_value() on a shared variable returns an mlx.core.array rather than a numpy one after any update touches it.

Max pooling routes a tied window’s whole gradient to the first tap on every backend except mlx, which splits it evenly. Ties are routine: any window a rectifier has clamped entirely to zero is one. The total gradient is conserved either way, so nothing downstream notices. Two runs of the same script under the same seed, one on a Mac and one on Linux, diverge from the first backward pass.

Random streams differ across backends. Between numpy and jax that is expected. Between the python and numba linkers it is not: bernoulli, the draw Dropout uses, disagrees while uniform and normal match. On jax the shared generator does not advance at all, and two functions compiled from the same graph replay one stream.