Skip to content

Transform metadata under autodiff¤

Quax gives a cotangent the primal's pytree structure, static fields included, so your metadata comes back through jax.grad unchanged. Usually that is what you want. Sometimes it is wrong: a length differentiates to a per-length gradient, a probability to a log-odds, a quantity in one frame to a covector in the dual.

To make metadata transform, move it off the pytree and into the JAX type. This guide assumes you have read Quax and hijax and decided the cost is worth it.

Check whether you need to build anything¤

If units are your case, the type already exists. Use it:

import jax
import jax.numpy as jnp
import quax
from quax.examples.hijax import Unitful
from quax.examples.unitful import meters

length = Unitful(jnp.asarray(3.0), meters)


def squared(value):
    return quax.quaxify(lambda a: a * a)(value).array


print(squared(length))  # 9.0
print(jax.grad(squared)(length).units)  # ((m, -1),)

The gradient is per metre, not in metres. Compare quax.examples.unitful, the same physics as a pytree, which returns {m: 1}.

Add an operation to a type that already exists¤

If your type is there but an operation is missing, add an entry to its register_rules mapping rather than writing a rule by hand. Negation is multiplication by -1, which the existing primitives already cover:

import quax.examples.hijax as hijax_units
from quax.experimental.hijax import register_rules

register_rules(
    hijax_units.Unitful,
    {jax.lax.neg_p: lambda q, **kw: hijax_units.mul(q, jnp.asarray(-1.0))},
)

print(quax.quaxify(lambda a: -a)(length).units)  # ((m, 1),)

If the operation is not expressible with the existing primitives, you need a new HiPrim first — a typing rule, an implementation, and a rule for each transformation you will apply to it. Weigh that against what hijax costs before committing.

Build a type of your own¤

Three steps. Copy the structure from quax.examples.hijax, which is the reference implementation for all of this.

Write the hijax type. Subclass HiType with the three lowering methods lo_ty, lower_val and raise_val — which say which arrays your type is made of, and how values convert — then add to_tangent_aval and to_ct_aval. Those last two are the point of the exercise: they are separate methods, so your cotangent type need not be your primal type. If the tangent type is itself a hijax type, also implement vspace_zero and vspace_add, which autodiff uses to instantiate and accumulate cotangents.

Wrap it in a HiValue. A hijax value cannot be a quax.Value itself, so hold one as the single leaf of a HiValue. Give the subclass a friendly constructor and whatever read-only properties your users need; the base class supplies the rest. Do not test equinox.is_array_like in that constructor to decide whether to wrap — a hijax tracer forwards .shape and .dtype from its type, so it passes that check and gets wrapped twice. Test for your own hi value class instead.

Map the primitives. Pass register_rules a {primitive: function} mapping and it generates the dispatch rules, including the mixed operand combinations a binary primitive needs. Where a primitive's parameters do not line up with the hijax function's arguments, adapt with a lambda, as the negation entry above does.

Avoid two traps¤

Do not construct your hi value class under a trace. Calling it directly works eagerly, but under jit it hides a tracer inside a container JAX treats as a constant, and the error surfaces much later somewhere unrelated. Apply a primitive instead — a wrap-style constructor primitive is the usual way.

Do not read its attributes under a trace either. Traced code holds a tracer of your hijax type, not an instance of it, so attribute access raises AttributeError. It is legal only inside a primitive's implementation and the type's own methods. Everywhere else, including your autodiff rules, go through primitives.