Usage¶
Install¶
# from git (NumPy included as a core dependency)
pip install "anytensor @ git+https://github.com/swamidass/anytensor.git"
# optional backends
pip install "anytensor[jax]" "anytensor[torch]" "anytensor[tensorflow]"
# or everything
pip install "anytensor[all]"
From a checkout:
Quick start¶
import anytensor as at
import numpy as np
x = np.arange(12.0).reshape(3, 4)
seg_ids = np.array([0, 0, 1])
y = at.segment_sum(x, seg_ids, num_segments=2)
m = at.segment_mean(x, seg_ids, 2)
Same code on JAX / Torch / TF:
import jax.numpy as jnp
x = jnp.arange(12.0).reshape(3, 4)
seg_ids = jnp.array([0, 0, 1])
y = at.segment_sum(x, seg_ids, 2)
Scalar / NumPy promotion¶
Full reductions return 0-d arrays (not bare Python / NumPy scalars). Binary ops and segment helpers upcast Python scalars and NumPy ndarrays onto a peer JAX / Torch / TF tensor. NumPy is host interchange data — we never demote a framework tensor to NumPy when mixing. Scalars alone still default to NumPy.
NumPy → framework upcast prefers by reference (asarray(..., copy=False))
when the backend can share the buffer. A copy is used only when zero-copy is
impossible (default: warn + copy; set fallback="error" to raise). Use
@promote(..., copy=True) or with at.promote_options(copy=True): when the
host NumPy buffer may be mutated.
Dtype policy is per-operand via @promote:
@promote(x="data", y="data") # result_type — ints widen beside floats
@promote(x="data", indices="index") # indices stay integral
@promote(condition="mask", x="data", y="data")
@promote(x="data", segment_ids="index", num_segments="shape") # size dim
num_segments / total_repeat_length / total_length are shape sizes:
Python int, jit symbolic constant, or 0-d integral tensor scalar — required
(JAX convention), never inferred from ids. Plain ints stay Python (not 0-d
tensors) so jax.jit / tf.function / torch.compile can treat them as static.
| Situation | Behavior |
|---|---|
| Only scalars | NumPy 0-d arrays |
| NumPy + framework tensor | Promote NumPy → framework (never the reverse) |
| Default upcast | Prefer reference (copy=False) |
| Zero-copy impossible | fallback="copy" (warn) or "error" |
| Mutating host buffer | @promote(..., copy=True) or promote_options(copy=True) |
Caller rules¶
Contracts for library authors writing on AnyTensor. These stay portable under
jax.jit / tf.function / torch.compile / ONNX. Why they exist:
Design.
Backend surprises: Surprising differences.
Shape-sizes¶
| Do | Don't |
|---|---|
Pass num_segments on every segment op |
Infer max(ids)+1, omit, or pass None |
Read lengths with at.shape(x)[0] |
int(x.shape[0]) / int(at.shape(x)[0]) under tracing |
| Leave Python ints as Python ints | Wrap a size into a 0-d tensor yourself |
Kind shape is a Python int, a jit symbolic constant, or a 0-d integral
tensor. Omitting a required size, or passing None, is a TypeError.
Partition helpers¶
partition_sum / min / max / softmax are partition_ids then the
matching segment_* helper. Official jraph only wraps partition_softmax
(sum_partitions as the third positional).
| Do | Don't |
|---|---|
total_length=at.shape(logits)[0] (required) |
sum(partitions), omit, or None |
Treat num_segments as shape(partitions)[0] — not an argument |
Pass num_segments= to partition helpers |
jraph: 3rd positional sum_partitions is that alias |
Data-sum the count vector |
GraphNetwork: sum_n_node = shape(nodes)[0], sum_n_edge = shape(senders)[0] |
sum(n_node) / sum(n_edge) |
Single-graph counts: at.full((1,), at.shape(x)[0], dtype=np.int32, like=x) |
Host int(N) fill that bakes on export |
On ONNX those totals are dim_params (Shape of the aligned tensor), not
ReduceSum of the partition vector.
Cache¶
Pattern (library apply and caller apply are the same): put @at.cache
on the function you apply, then call partition_ids / GraphNetwork /
GraphConvolution / multi_update_all. For derived structure, use
cache.lookup / cache.store — the same pair GraphConvolution uses.
@at.cache
def apply(graph):
total = at.shape(graph.nodes)[0]
ids = at.partition_ids(graph.n_node, total)
extra = (True,) # flags that change the derived tensor
packed = at.cache.lookup("structure", graph.senders, extra)
if packed is None:
packed = (graph.senders, total)
at.cache.store("structure", graph.senders, packed, extra)
return ids, packed
Off by default. @cache is sticky (enable() until disable()).
with cache(): is scoped (drops on exit unless already sticky). Entries
are weak (GC drops them). There is no process-wide id() cache: tensors
are unhashable, in-place edits would stale ids, and tracers wrap a new
object every compile.
| Form | Behavior |
|---|---|
@cache on apply |
Sticky enable() — GraphNetwork, GCN, GAT, GraphMapFeatures, hetero multi_update_all / zoo |
cache.lookup(ns, obj, extra) / store(...) |
Key (id(obj),) + tuple(extra); no-op when the cache is off |
with cache(): |
Scoped; drops on exit unless sticky |
cache.enable() / disable() |
Turn the cache on or off |
cache.purge(ns, obj) |
Drop one object from one namespace |
partition_ids is lookup / store on "partition" (one expansion per
partition vector; shape(ids)[0] is the flattened total). Other partition
helpers call it. Wrong-size hits (both lengths host Python ints) purge,
warn, and recompute; tracing skips the check.
GraphConvolution stores self-edges / N / degrees with the same
lookup / store on "gcn" and extra=(add_self_edges, symmetric_normalization).
Hetero dest size is shape(dst_nodes)[0] (incidence is already
senders / receivers); @cache still enables the map so a message_fn
that calls partition_ids shares it. Do not key by the graph object.
Batch/unbatch still data-sum n_node for offsets (eager, like jraph pad).
import anytensor as at
import numpy as np
logits = np.array([1.0, 2.0, 3.0])
partitions = np.array([2, 1])
total_length = at.shape(logits)[0]
other_logits = logits * 2
with at.cache():
y = at.partition_softmax(logits, partitions, total_length)
z = at.partition_softmax(other_logits, partitions, total_length)
Export¶
Opt-in anytensor.export is a recipe, not a stable library API (not in
anytensor.__all__). Details: ONNX.
| Do | Don't |
|---|---|
from anytensor import export |
Treat export helpers as a frozen public contract |
Keep lengths as at.shape so ONNX gets dim_params |
Python ints or data sums that bake N |
Embed weights (as_torch_module / Lightning nn.Parameter, or as_tensorflow_fn) |
Extra feeds, outer tf.constant, or jax2tf |
Stack @cache apply (GN / GCN / hetero) |
Expect a fresh partition / GCN structure subgraph per layer |
Segment helpers¶
| Function | Role |
|---|---|
segment_sum / min / max |
Reduce along axis 0 by segment id |
segment_count / mean / variance |
Counts and moments |
segment_normalize / segment_softmax |
Per-segment normalize / softmax |
segment_min_or_constant / segment_max_or_constant |
Empty segments → constant |
partition_sum / min / max / softmax |
Reduce / softmax over contiguous partition lengths (total_length required; num_segments is shape(partitions)[0]; each calls partition_ids) |
partition_ids |
Expand partition lengths to segment ids (the cache chokepoint; other partition helpers call this) |
cache |
@cache apply / lookup+store / context / enable+disable — GraphNetwork, GCN, GAT, hetero use this same pattern |
Einops (rearrange, einsum, reduce, …) is re-exported for convenience.
partition_sum / min / max / softmax are partition_ids plus the
matching segment_* helper. Use segment_* if you already have ids. Caller contracts (required
total_length, cache forms, ONNX dim_params): Caller rules.
Torch compile / export¶
Prefer torch.compile (training / runtime) or torch.export (AOT /
serialization). PyTorch has deprecated torch.jit.script / torch.jit.trace;
do not use them in new code.
torch.compile: portable helpers typically needfullgraph=False(Dynamo graph-breaks on@promote/ array-api-compat). A single fused graph needs a Torch-only body.torch.export: pass annn.Modulewhoseforwardcalls the portable helper — bare functions are rejected. See Worked examples.
anytensor.enable_torchscript() remains for legacy torch.jit.script call
sites that still hit segment_sum / min / max; it is not the recommended
path. It does not import Torch and does not care about import order: a helper
registered with module_if_loaded("torch", …) enables the divert as soon as
Torch is imported.
ONNX (dynamic shapes)¶
Recommend ONNX for deployment: ONNX Runtime is well tested as a serving
engine. AnyTensor does not run ops on ORT. The recipes live in the opt-in
anytensor.export subpackage (not in anytensor.__all__, not a stable
library API). Export the same AnyTensor function after it is running on
Torch or TensorFlow tensors. Derive num_segments from at.shape(nodes)[0]
so N stays symbolic. Do/don't for lengths, weights, and stacked graphs:
Caller rules.
| Start | Recipe |
|---|---|
| Lightning | nn.Parameter on the module + to_onnx_torch(..., dynamic_shapes=) (best named initializers) |
| Keras | as_tensorflow_fn / to_onnx_tensorflow(..., params=) so constants are created inside the trace |
| Flax | numpy_leaves(params) then as_torch_module(fn, params) (preferred) or the Keras row — not jax2tf, not extra inputs |
Helpers (from anytensor import export): ONNX.
Typing¶
Public APIs use jaxtyping annotations
(Shaped / Integer / helpers like SegmentValues) plus an ArrayT
TypeVar so operands stay on one backend type — without importing Torch /
JAX / TF for typing.
Runtime checking is off by default. Annotations are for editors, static checkers, and docs. To opt in::
from jaxtyping import install_import_hook
install_import_hook("anytensor", "beartype.beartype") # before importing anytensor
import anytensor as at
# or: at.enable_typecheck() before other anytensor submodule imports
Install beartype via anytensor[typecheck], anytensor[all], or the dev
group. Set JAXTYPING_DISABLE=1 to force runtime checks off.
NaN helpers / specials¶
is_nan/is_finite/is_inf(aliasesisnan/isfinite/isinf)fill_nan(aliasnan_fill),fill_nan_mask→(filled, mask)- Array API
nan_to_num, element-wiseequal_nan inf(x)/ninf(x)/nan(x)/pi(x)/e(x),dtype(...),finfo/iinfonewaxisisNone
Nested structures and graphs have their own sections: Tree, Jraph, Hetero.