AnyTensor¶
Write a tensor helper once. Run it on NumPy, JAX, PyTorch, or TensorFlow — whichever array the caller already has.
Ordinary math (sum, exp, reshape, …) goes through the
Python Array API via
array-api-compat. Segment /
GNN reductions use thin input-adaptive backends. The public surface is one
namespace: import anytensor as at.
The problem¶
Scientific and ML code rarely lives in one framework forever.
- A method starts in NumPy for prototyping, then moves to JAX for
jit, or PyTorch for training, or TensorFlow because a collaborator’s stack demands it. - Library authors ship a useful op — and then maintain three copies (or abandon everyone who picked a different stack).
- Graph / sparse reductions are especially painful: “softmax over neighbors,” “sum messages per node,” “mean pool by batch” are segment ops. Each ecosystem has its own scatter/segment API, dtype quirks, and empty-slot conventions.
The result is either framework lock-in or a thicket of if torch: … elif jax: …
branches that rot.
AnyTensor’s bet: for the portable layer — math plus segment primitives — you write against array behavior, not against a vendor. Call sites keep their native tensors; AnyTensor dispatches.
Motivation¶
- One implementation for library code. A helper in your package can accept whatever the user trains with. You do not fork the algorithm per backend.
- Segment ops as first-class citizens. GNN-style reductions
(
segment_sum,segment_softmax, …) are not an afterthought; they match the JAX / TF “requirednum_segments” discipline and document empty-slot / NaN semantics. - NumPy as host data, not a demotion target. Mixing a Torch tensor with a NumPy buffer upcasts onto Torch (by reference when possible). Framework tensors stay on their device/dtype world.
- Honest about differences. When TF XLA and NumPy disagree on
inf * tiny, we document it instead of papering over it. - Tests are the contract. Cross-backend and symbolic fuzz, a 100% coverage gate, minimal-NumPy CI, and pytest-run docs examples keep portability claims executable — see Design → How we keep the contract honest.
- Semantic Versioning. No breaking public API or documented semantics changes without a major version bump (Release).
Case study: neighbor softmax from Graph Attention Networks¶
Graph Attention Networks (Veličković et al.,
ICLR 2018) normalize attention per destination node over incoming edges.
The widely used PyTorch Geometric
stack implements that as a sparse softmax over an edge index
(torch_geometric.utils.softmax) —
Torch tensors only, with scatter / segment under the hood.
Stripped to the index-based path, the idea is:
# Conceptual PyG / torch-scatter style (Torch only)
src_max = scatter_max(src, index, dim_size=N)
out = (src - src_max[index]).exp()
out = out / (scatter_add(out, index, dim_size=N)[index] + eps)
That is exactly segment softmax: group by index, stable max-subtract,
normalize. In AnyTensor it is already a primitive — or you can write the same
composition yourself for a slightly richer GAT-style step.
A small portable attention block¶
Below is a minimal “attention over neighbors” helper in the spirit of GAT /
PyG softmax: leaky scores on edges, softmax within each destination node,
then a weighted sum of source features. One function, no import torch
inside.
import anytensor as at
def neighbor_attention(messages, scores, dst_index, num_nodes: int):
"""GAT-style edge attention → node features (portable).
messages: (E, F) — per-edge payloads (e.g. source node features)
scores: (E,) — raw attention logits per edge
dst_index:(E,) — destination node id for each edge
num_nodes: int — number of nodes (required shape-size)
"""
# LeakyReLU(0.2) without pulling in a framework nn module
alpha = at.where(scores > 0, scores, scores * 0.2)
alpha = at.segment_softmax(alpha, dst_index, num_nodes)
# weight messages, then sum into destination nodes
weighted = messages * alpha[:, None]
return at.segment_sum(weighted, dst_index, num_nodes)
Same function, four backends¶
Build a tiny graph once on the host, then run the identical
neighbor_attention on NumPy, JAX, Torch, and TensorFlow arrays.
import numpy as np
# 3 nodes, 4 edges into destinations [0, 0, 1, 2]
messages = np.array(
[[1.0, 0.0],
[0.0, 1.0],
[1.0, 1.0],
[2.0, 0.0]],
dtype=np.float32,
)
scores = np.array([1.0, 1.0, 0.5, 2.0], dtype=np.float32)
dst = np.array([0, 0, 1, 2], dtype=np.int64)
num_nodes = 3
You did not rewrite the algorithm. Callers keep their stack; your library keeps
one source of truth. For the battery-included form, prefer
at.segment_softmax directly (same numerics as the PyG index softmax path).
Compile and jit¶
Eager four-backend use is the easy part. Compilers need extra care (static
num_nodes, fullgraph vs graph breaks). Runnable, pytest-checked recipes
live in Worked examples:
jax.jit(..., static_argnames=("num_nodes",))torch.compile(neighbor_attention, fullgraph=False)(portable);fullgraph=Trueneeds a Torch-only bodytorch.exportvia annn.Modulewhoseforwardcalls the helpertf.function/jit_compile=True- ONNX (recommended deploy path; ORT is well tested): ONNX (
anytensor.export; Lightning / Keras / Flax recipes)
Prefer torch.compile / torch.export over deprecated torch.jit.script /
trace. Those fences are executed in CI via Sybil so they do not rot.
Install¶
pip install "anytensor @ git+https://github.com/swamidass/anytensor.git"
pip install "anytensor[jax]" "anytensor[torch]" "anytensor[tensorflow]"
pip install "anytensor[onnx]" # plus tf2onnx for the TensorFlow export path
# or
pip install "anytensor[all]"
Requires Python ≥3.10. Backend floors: NumPy ≥1.24, JAX ≥0.4.32, PyTorch ≥2.1, TensorFlow ≥2.13.
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)
num_segments is required (JAX convention) — a Python int, jit symbolic
constant, or 0-d integral tensor scalar.
Next¶
- Worked examples — pytest-verified GAT helper + jit/compile/script
- Jraph — portable GraphsTuple / GraphNetwork (follows jraph)
- Hetero — heterogeneous graphs (multi-type nodes/edges), attention, model zoo
- Tree — portable
jax.treenests, pure Python + NumPy (graphs, and any structured record) - Usage — promotion, caller rules,
torch.compile, typing - Design — principles, decisions, and what to expect on edges
- Surprising differences — NaN / ±inf / graph / GPU gotchas
- API reference — generated from docstrings