tree-math: mathematical operations for JAX pytrees

tree-math makes it easy to implement numerical algorithms that work on
JAX pytrees, such as
iterative methods for optimization and equation solving. It does so by providing
a wrapper class tree_math.Vector that defines array operations such as
infix arithmetic and dot-products on pytrees as if they were vectors.

Why tree-math

In a library like SciPy, numerical algorithms are typically written to handle
fixed-rank arrays, e.g., scipy.integrate.solve_ivp
requires inputs of shape (n,). This is convenient for implementors of
numerical methods, but not for users, because 1d arrays are typically not the
best way to keep track of state for non-trivial functions (e.g., neural networks
or PDE solvers).

tree-math provides an alternative to flattening and unflattening these more
complex data structures (“pytrees”) for use in numerical algorithms. Instead,
the numerical algorithm itself can be written in way to handle arbitrary
collections of arrays stored in pytrees. This avoids unnecessary memory copies,
and gives the user more control over the memory layouts used in computation.
In practice, this can often makes a big difference for computational efficiency
as well, which is why support for flexible data structures is so prevalent
inside libraries that use JAX.

Installation

tree-math is implemented in pure Python, and only depends upon JAX.

You can install it from PyPI: pip install tree-math.

User guide

tree-math is simple to use. Just pass arbitrary pytree objects into
tree_math.Vector to create an a object that arithmetic as if all leaves of
the pytree were flattened and concatenated together:

>>> import tree_math as tm
>>> import jax.numpy as jnp
>>> v = tm.Vector({'x': 1, 'y': jnp.arange(2, 4)})
>>> v
tree_math.Vector({'x': 1, 'y': DeviceArray([2, 3], dtype=int32)})
>>> v + 1
tree_math.Vector({'x': 2, 'y': DeviceArray([3, 4], dtype=int32)})
>>> v.sum()
DeviceArray(6, dtype=int32)

You can also find a few functions defined on vectors in tree_math.numpy, which
implements a very restricted subset of jax.numpy. If you’re interested in more
functionality, please open an issue to discuss before sending a pull request.
(In the long term, this separate module might disappear if we can support
Vector objects directly inside jax.numpy.)

Vector objects are pytrees themselves, which means the are compatible with JAX
transformations like jit, vmap and grad, and control flow like
while_loop and cond.

When you’re done manipulating vectors, you can pull out the underlying pytrees
from the .tree property:

>>> v.tree
{'x': 1, 'y': DeviceArray([2, 3], dtype=int32)}

As an alternative to manipulating Vector objects directly, you can also use
the functional transformations wrap and unwrap (see the “Example usage”
below).

One important difference between tree_math and jax.numpy is that dot
products in tree_math default to full precision on all platforms, rather
than defaulting to bfloat16 precision on TPUs. This is useful for writing most
numerical algorithms, and will likely be JAX’s default behavior
in the future.

In the near-term, we also plan to add a Matrix class that will make it
possible to use tree-math for numerical algorithms such as
L-BFGS which use matrices
to represent stacks of vectors.

Example usage

Here is how we could write the preconditioned conjugate gradient
method. Notice how similar the implementation is to the pseudocode from
Wikipedia
,
unlike the implementation in JAX:

import functools
from jax import lax
import tree_math as tm
import tree_math.numpy as tnp

@functools.partial(tm.wrap, vector_argnames=['b', 'x0'])
def cg(A, b, x0, M=lambda x: x, maxiter=5, tol=1e-5, atol=0.0):
  """jax.scipy.sparse.linalg.cg, written with tree_math."""
  A = tm.unwrap(A)
  M = tm.unwrap(M)

  atol2 = tnp.maximum(tol**2 * (b @ b), atol**2)

  def cond_fun(value):
    x, r, gamma, p, k = value
    return (r @ r > atol2) & (k < maxiter)

  def body_fun(value):
    x, r, gamma, p, k = value
    Ap = A(p)
    alpha = gamma / (p.conj() @ Ap)
    x_ = x + alpha * p
    r_ = r - alpha * Ap
    z_ = M(r_)
    gamma_ = r_.conj() @ z_
    beta_ = gamma_ / gamma
    p_ = z_ + beta_ * p
    return x_, r_, gamma_, p_, k + 1

  r0 = b - A(x0)
  p0 = z0 = M(r0)
  gamma0 = r0 @ z0
  initial_value = (x0, r0, gamma0, p0, 0)

  x_final, *_ = lax.while_loop(cond_fun, body_fun, initial_value)
  return x_final

GitHub

View Github