Skip to content

Jraph API

Portable jraph: GraphsTuple, batching/padding, and GraphNetwork models on NumPy / JAX / PyTorch / TF.

Segment ops on this module require num_segments (AnyTensor contract). None node/edge/global features are empty pytrees (jraph / jax.tree).

Narrative docs: Overview. Recipes: Examples.

anytensor.jraph

Portable jraph: GraphsTuple, batching/padding, and GNN models on any tensor.

Public names match :mod:jraph. Segment ops require num_segments (AnyTensor contract). Feature nests use :mod:anytensor.tree (jax.tree API).

GraphsTuple

Bases: NamedTuple

An ordered collection of graphs in a sparse format.

A port of :class:jraph.GraphsTuple. nodes, edges and globals may be None or an ArrayTree of features; senders / receivers are integer index arrays (or None when there are no edges). n_node and n_edge are integer vectors with one entry per graph in the batch.

Sender and receiver indices are absolute in the batched node array (offset by the nodes of earlier graphs). See the jraph docs for the full field layout.

__tree_batch__ / __tree_unbatch__ implement graph batching (not fieldwise array concat). :func:anytensor.tree.batch / unbatch are the same functions as :func:~anytensor.jraph.batch / unbatch; this type owns the offsetting logic. Custom node/edge/global objects may define the same methods so feature batching uses their logic.

Source code in anytensor/jraph/graph.py
class GraphsTuple(NamedTuple):
    """An ordered collection of graphs in a sparse format.

    A port of :class:`jraph.GraphsTuple`. ``nodes``, ``edges`` and ``globals``
    may be ``None`` or an ``ArrayTree`` of features; ``senders`` / ``receivers``
    are integer index arrays (or ``None`` when there are no edges). ``n_node``
    and ``n_edge`` are integer vectors with one entry per graph in the batch.

    Sender and receiver indices are **absolute** in the batched node array
    (offset by the nodes of earlier graphs). See the jraph docs for the
    full field layout.

    ``__tree_batch__`` / ``__tree_unbatch__`` implement graph batching (not
    fieldwise array concat). :func:`anytensor.tree.batch` / ``unbatch`` are
    the same functions as :func:`~anytensor.jraph.batch` / ``unbatch``; this
    type owns the offsetting logic. Custom node/edge/global objects may
    define the same methods so feature batching uses their logic.
    """

    nodes: Optional[ArrayTree]
    edges: Optional[ArrayTree]
    receivers: Optional[Any]
    senders: Optional[Any]
    globals: Optional[ArrayTree]
    n_node: Any
    n_edge: Any

    @classmethod
    def __tree_batch__(cls, xs, axis: int = 0):
        """Batch graphs. Senders/receivers are offset; not a fieldwise concat."""
        if axis != 0:
            raise ValueError("GraphsTuple batch only supports axis=0")
        from .utils import _batch_graphs

        return _batch_graphs(xs)

    def __tree_unbatch__(self, axis: int = 0):
        """Unbatch into one :class:`GraphsTuple` per graph (jraph ``unbatch``)."""
        if axis != 0:
            raise ValueError("GraphsTuple unbatch only supports axis=0")
        from .utils import _unbatch_graphs

        return _unbatch_graphs(self)

batch

batch(trees, axis=0)

Batch a sequence of pytrees along axis (leading axis by default).

Same function as :func:anytensor.jraph.batch. If the type defines __tree_batch__(xs, axis=0), that method is used and children are not walked. Otherwise leaves are concatenated with :func:anytensor.concatenate. All-None stays None.

import numpy as np import anytensor.tree as tree tree.batch([np.array([1, 2]), np.array([3])]) array([1, 2, 3])

Source code in anytensor/tree.py
def batch(trees, axis: int = 0):
    """Batch a sequence of pytrees along ``axis`` (leading axis by default).

    Same function as :func:`anytensor.jraph.batch`. If the type defines
    ``__tree_batch__(xs, axis=0)``, that method is used and children are
    **not** walked. Otherwise leaves are concatenated with
    :func:`anytensor.concatenate`. All-``None`` stays ``None``.

    >>> import numpy as np
    >>> import anytensor.tree as tree
    >>> tree.batch([np.array([1, 2]), np.array([3])])
    array([1, 2, 3])
    """
    xs = list(trees)
    if not xs:
        raise ValueError("batch() requires at least one structure")
    return _batch_impl(xs, axis=axis)

unbatch

unbatch(structure, axis=0)

Unbatch structure along axis into unit slices.

Same function as :func:anytensor.jraph.unbatch. __tree_unbatch__(axis=0) on the object wins (GraphsTuple yields one graph per n_node entry). Nested containers recurse. Top-level None cannot infer a batch size.

import numpy as np import anytensor.tree as tree head, tail = tree.unbatch(np.arange(1, 3)) tuple(int(x) for x in head), tuple(int(x) for x in tail) ((1,), (2,))

Source code in anytensor/tree.py
def unbatch(structure, axis: int = 0):
    """Unbatch ``structure`` along ``axis`` into unit slices.

    Same function as :func:`anytensor.jraph.unbatch`. ``__tree_unbatch__(axis=0)``
    on the object wins (``GraphsTuple`` yields one graph per ``n_node`` entry).
    Nested containers recurse. Top-level ``None`` cannot infer a batch size.

    >>> import numpy as np
    >>> import anytensor.tree as tree
    >>> head, tail = tree.unbatch(np.arange(1, 3))
    >>> tuple(int(x) for x in head), tuple(int(x) for x in tail)
    ((1,), (2,))
    """
    if structure is not None and hasattr(type(structure), "__tree_unbatch__"):
        return list(_call_unbatch(structure, axis))
    if structure is None:
        raise ValueError("Cannot unbatch None")
    n = _leading_length(structure, axis)
    return _unbatch_units(structure, n, axis)

GAT

GAT(
    attention_query_fn,
    attention_logit_fn,
    node_update_fn=None,
)

Graph Attention Network layer (Veličković et al.). Expects self-edges.

Apply is @cache (same pattern as GraphNetwork). Destination size is :func:~anytensor.shape of nodes.

Source code in anytensor/jraph/models.py
def GAT(
    attention_query_fn: GATAttentionQueryFn,
    attention_logit_fn: GATAttentionLogitFn,
    node_update_fn: Optional[GATNodeUpdateFn] = None,
):
    """Graph Attention Network layer (Veličković et al.). Expects self-edges.

    Apply is ``@cache`` (same pattern as GraphNetwork). Destination size is
    :func:`~anytensor.shape` of nodes.
    """
    if node_update_fn is None:

        def node_update_fn(x):
            y = _leaky_relu(x)
            return reshape(y, (shape(y)[0], -1))

    @cache
    def _ApplyGAT(graph: GraphsTuple) -> GraphsTuple:
        nodes, edges, receivers, senders, _, _, _ = graph
        if nodes is None:
            raise IndexError("GAT requires node features")
        sum_n_node = shape(nodes)[0]
        nodes = attention_query_fn(nodes)
        sent_attributes = take(nodes, senders)
        received_attributes = take(nodes, receivers)
        softmax_logits = attention_logit_fn(sent_attributes, received_attributes, edges)
        weights = utils.segment_softmax(
            softmax_logits, segment_ids=receivers, num_segments=sum_n_node
        )
        messages = sent_attributes * weights
        nodes = utils.segment_sum(messages, receivers, num_segments=sum_n_node)
        nodes = node_update_fn(nodes)
        return graph._replace(nodes=nodes)

    return _ApplyGAT

DeepSets

DeepSets(
    update_node_fn,
    update_global_fn,
    aggregate_nodes_for_globals_fn=segment_sum,
)

DeepSets layer as a configured GraphNetwork.

Source code in anytensor/jraph/models.py
def DeepSets(
    update_node_fn: Callable[[NodeFeatures, Globals], NodeFeatures],
    update_global_fn: Callable[[NodeFeatures], Globals],
    aggregate_nodes_for_globals_fn: AggregateNodesToGlobalsFn = utils.segment_sum,
):
    """DeepSets layer as a configured GraphNetwork."""
    return GraphNetwork(
        update_edge_fn=None,
        update_node_fn=lambda n, s, r, g: update_node_fn(n, g),
        update_global_fn=lambda n, e, g: update_global_fn(n),
        aggregate_nodes_for_globals_fn=aggregate_nodes_for_globals_fn,
    )

GraphConvolution

GraphConvolution(
    update_node_fn,
    aggregate_nodes_fn=segment_sum,
    add_self_edges=False,
    symmetric_normalization=True,
)

GCN layer (Kipf & Welling). No activation after aggregation.

Apply uses the public cache pattern: @cache plus :meth:~anytensor.cache.lookup / :meth:~anytensor.cache.store on "gcn" keyed by senders and the constructor flags so stacked applies and ONNX do not duplicate Shape / Range / Concat.

Source code in anytensor/jraph/models.py
def GraphConvolution(
    update_node_fn: Callable[[NodeFeatures], NodeFeatures],
    aggregate_nodes_fn: AggregateEdgesToNodesFn = utils.segment_sum,
    add_self_edges: bool = False,
    symmetric_normalization: bool = True,
):
    """GCN layer (Kipf & Welling). No activation after aggregation.

    Apply uses the public cache pattern: ``@cache`` plus
    :meth:`~anytensor.cache.lookup` / :meth:`~anytensor.cache.store` on
    ``"gcn"`` keyed by senders and the constructor flags so stacked applies
    and ONNX do not duplicate ``Shape`` / ``Range`` / ``Concat``.
    """

    @cache
    def _ApplyGCN(graph: GraphsTuple) -> GraphsTuple:
        nodes, _, receivers, senders, _, n_node, _ = graph
        nodes = update_node_fn(nodes)
        conv_senders, conv_receivers, total_num_nodes, sender_degree, receiver_degree = (
            _gcn_structure(
                nodes,
                senders,
                receivers,
                n_node,
                add_self_edges=add_self_edges,
                symmetric_normalization=symmetric_normalization,
            )
        )
        if symmetric_normalization:
            one = at_ones((), dtype=tree.leaves(nodes)[0].dtype, like=tree.leaves(nodes)[0])
            nodes = tree.map(
                lambda x: x
                * reshape(
                    rsqrt(maximum(sender_degree, one)),
                    (shape(sender_degree)[0],) + (1,) * (x.ndim - 1),
                ),
                nodes,
            )
            nodes = tree.map(
                lambda x: aggregate_nodes_fn(
                    take(x, conv_senders), conv_receivers, total_num_nodes
                ),
                nodes,
            )
            nodes = tree.map(
                lambda x: x
                * reshape(
                    rsqrt(maximum(receiver_degree, one)),
                    (shape(receiver_degree)[0],) + (1,) * (x.ndim - 1),
                ),
                nodes,
            )
        else:
            nodes = tree.map(
                lambda x: aggregate_nodes_fn(
                    take(x, conv_senders), conv_receivers, total_num_nodes
                ),
                nodes,
            )
        return graph._replace(nodes=nodes)

    return _ApplyGCN

GraphMapFeatures

GraphMapFeatures(
    embed_edge_fn=None,
    embed_node_fn=None,
    embed_global_fn=None,
)

Embed nodes, edges, and globals independently.

Apply is @cache (same pattern as GraphNetwork).

Source code in anytensor/jraph/models.py
def GraphMapFeatures(
    embed_edge_fn: Optional[EmbedEdgeFn] = None,
    embed_node_fn: Optional[EmbedNodeFn] = None,
    embed_global_fn: Optional[EmbedGlobalFn] = None,
):
    """Embed nodes, edges, and globals independently.

    Apply is ``@cache`` (same pattern as GraphNetwork).
    """
    identity = lambda x: x
    embed_edges_fn = embed_edge_fn if embed_edge_fn else identity
    embed_nodes_fn = embed_node_fn if embed_node_fn else identity
    embed_globals_fn = embed_global_fn if embed_global_fn else identity

    @cache
    def Embed(graphs_tuple: GraphsTuple) -> GraphsTuple:
        return graphs_tuple._replace(
            nodes=embed_nodes_fn(graphs_tuple.nodes),
            edges=embed_edges_fn(graphs_tuple.edges),
            globals=embed_globals_fn(graphs_tuple.globals),
        )

    return Embed

GraphNetGAT

GraphNetGAT(
    update_edge_fn,
    update_node_fn,
    attention_logit_fn,
    attention_reduce_fn,
    update_global_fn=None,
    aggregate_edges_for_nodes_fn=segment_sum,
    aggregate_nodes_for_globals_fn=segment_sum,
    aggregate_edges_for_globals_fn=segment_sum,
)

GraphNet with required attention on edge features.

Source code in anytensor/jraph/models.py
def GraphNetGAT(
    update_edge_fn: GNUpdateEdgeFn,
    update_node_fn: GNUpdateNodeFn,
    attention_logit_fn: AttentionLogitFn,
    attention_reduce_fn: AttentionReduceFn,
    update_global_fn: Optional[GNUpdateGlobalFn] = None,
    aggregate_edges_for_nodes_fn: AggregateEdgesToNodesFn = utils.segment_sum,
    aggregate_nodes_for_globals_fn: AggregateNodesToGlobalsFn = utils.segment_sum,
    aggregate_edges_for_globals_fn: AggregateEdgesToGlobalsFn = utils.segment_sum,
):
    """GraphNet with required attention on edge features."""
    if (attention_logit_fn is None) or (attention_reduce_fn is None):
        raise ValueError(
            "`None` value not supported for `attention_logit_fn` or "
            "`attention_reduce_fn` in a Graph Attention network."
        )
    return GraphNetwork(
        update_edge_fn=update_edge_fn,
        update_node_fn=update_node_fn,
        update_global_fn=update_global_fn,
        attention_logit_fn=attention_logit_fn,
        attention_reduce_fn=attention_reduce_fn,
        aggregate_edges_for_nodes_fn=aggregate_edges_for_nodes_fn,
        aggregate_nodes_for_globals_fn=aggregate_nodes_for_globals_fn,
        aggregate_edges_for_globals_fn=aggregate_edges_for_globals_fn,
    )

GraphNetwork

GraphNetwork(
    update_edge_fn,
    update_node_fn,
    update_global_fn=None,
    aggregate_edges_for_nodes_fn=segment_sum,
    aggregate_nodes_for_globals_fn=segment_sum,
    aggregate_edges_for_globals_fn=segment_sum,
    attention_logit_fn=None,
    attention_normalize_fn=segment_softmax,
    attention_reduce_fn=None,
)

Returns a method that applies a configured GraphNetwork.

Follows Algorithm 1 of https://arxiv.org/abs/1806.01261, with separate sender/receiver aggregations and optional softmax attention. Same call signature as :func:jraph.GraphNetwork. Apply uses the public cache pattern: @cache (sticky) plus :func:~anytensor.partition_ids (cache.lookup / store on "partition", keyed by n_node / n_edge). Callers write the same @cache apply.

Flattened totals (official sum_n_node / sum_n_edge) are :func:~anytensor.shape of the node / sender axis — not sum(n_node) — so they stay symbolic sizes on ONNX export.

Source code in anytensor/jraph/models.py
def GraphNetwork(
    update_edge_fn: Optional[GNUpdateEdgeFn],
    update_node_fn: Optional[GNUpdateNodeFn],
    update_global_fn: Optional[GNUpdateGlobalFn] = None,
    aggregate_edges_for_nodes_fn: AggregateEdgesToNodesFn = utils.segment_sum,
    aggregate_nodes_for_globals_fn: AggregateNodesToGlobalsFn = utils.segment_sum,
    aggregate_edges_for_globals_fn: AggregateEdgesToGlobalsFn = utils.segment_sum,
    attention_logit_fn: Optional[AttentionLogitFn] = None,
    attention_normalize_fn: Optional[AttentionNormalizeFn] = utils.segment_softmax,
    attention_reduce_fn: Optional[AttentionReduceFn] = None,
):
    """Returns a method that applies a configured GraphNetwork.

    Follows Algorithm 1 of https://arxiv.org/abs/1806.01261, with separate
    sender/receiver aggregations and optional softmax attention. Same call
    signature as :func:`jraph.GraphNetwork`. Apply uses the public cache
    pattern: ``@cache`` (sticky) plus :func:`~anytensor.partition_ids`
    (``cache.lookup`` / ``store`` on ``"partition"``, keyed by ``n_node`` /
    ``n_edge``). Callers write the same ``@cache`` apply.

    Flattened totals (official ``sum_n_node`` / ``sum_n_edge``) are
    :func:`~anytensor.shape` of the node / sender axis — not
    ``sum(n_node)`` — so they stay symbolic sizes on ONNX export.
    """
    not_both_supplied = lambda x, y: (x != y) and ((x is None) or (y is None))
    if not_both_supplied(attention_reduce_fn, attention_logit_fn):
        raise ValueError(
            "attention_logit_fn and attention_reduce_fn must both be supplied."
        )

    @cache
    def _ApplyGraphNet(graph: GraphsTuple) -> GraphsTuple:
        nodes, edges, receivers, senders, globals_, n_node, n_edge = graph
        node_leaves = tree.leaves(nodes)
        # Official jraph uses ``sum(n_node)`` / ``sum(n_edge)``. That is a data
        # reduction (``ReduceSum`` in ONNX) and ``int()`` of it bakes a host
        # constant. ``shape(nodes)[0]`` is the same integer when the
        # GraphsTuple invariant holds, and is a dim_param on export.
        if node_leaves:
            sum_n_node = shape(node_leaves[0])[0]
        else:
            # No node tensor to read. Eager-only host int (cannot be a
            # symbolic node axis).
            sum_n_node = int(np_sum_n_node(n_node))
        sum_n_edge = 0 if senders is None else shape(senders)[0]
        # ``int(size)`` is rewritten by TF Autograph into a graph op, so a
        # symbolic leading dim looks "concrete". Only compare nest lengths
        # when the size is already a Python int (eager NumPy / JAX / TF).
        if (
            node_leaves
            and type(sum_n_node) is int
            and not utils._tree_all(  # noqa: SLF001
                tree.map(lambda n: n.shape[0] == sum_n_node, nodes)
            )
        ):
            raise ValueError(
                "All node arrays in nest must contain the same number of nodes."
            )

        sent_attributes = _take_index(nodes, senders)
        received_attributes = _take_index(nodes, receivers)
        if globals_ is not None and n_edge is not None and sum_n_edge is not None:
            global_edge_attributes = tree.map(
                lambda g: _repeat_by(g, n_edge, sum_n_edge), globals_
            )
        else:
            global_edge_attributes = None

        if update_edge_fn:
            edges = update_edge_fn(
                edges, sent_attributes, received_attributes, global_edge_attributes
            )

        if attention_logit_fn:
            logits = attention_logit_fn(
                edges, sent_attributes, received_attributes, global_edge_attributes
            )
            tree_calculate_weights = functools.partial(
                attention_normalize_fn, segment_ids=receivers, num_segments=sum_n_node
            )
            weights = tree.map(tree_calculate_weights, logits)
            edges = attention_reduce_fn(edges, weights)

        if update_node_fn:
            sent_attributes = (
                None
                if edges is None
                else tree.map(
                    lambda e: aggregate_edges_for_nodes_fn(e, senders, sum_n_node), edges
                )
            )
            received_attributes = (
                None
                if edges is None
                else tree.map(
                    lambda e: aggregate_edges_for_nodes_fn(e, receivers, sum_n_node),
                    edges,
                )
            )
            if globals_ is not None:
                global_attributes = tree.map(
                    lambda g: _repeat_by(g, n_node, sum_n_node), globals_
                )
            else:
                global_attributes = None
            nodes = update_node_fn(
                nodes, sent_attributes, received_attributes, global_attributes
            )

        if update_global_fn:
            n_graph = shape(n_node)[0]
            node_gr_idx = partition_ids(n_node, sum_n_node)
            edge_gr_idx = (
                None
                if senders is None
                else partition_ids(n_edge, sum_n_edge)
            )
            node_attributes = (
                None
                if nodes is None
                else tree.map(
                    lambda n: aggregate_nodes_for_globals_fn(n, node_gr_idx, n_graph),
                    nodes,
                )
            )
            edge_attributes = (
                None
                if edges is None or edge_gr_idx is None
                else tree.map(
                    lambda e: aggregate_edges_for_globals_fn(e, edge_gr_idx, n_graph),
                    edges,
                )
            )
            globals_ = update_global_fn(node_attributes, edge_attributes, globals_)
        return GraphsTuple(
            nodes=nodes,
            edges=edges,
            receivers=receivers,
            senders=senders,
            globals=globals_,
            n_node=n_node,
            n_edge=n_edge,
        )

    return _ApplyGraphNet

InteractionNetwork

InteractionNetwork(
    update_edge_fn,
    update_node_fn,
    aggregate_edges_for_nodes_fn=segment_sum,
    include_sent_messages_in_node_update=False,
)

Interaction network (Battaglia et al.) as a configured GraphNetwork.

Source code in anytensor/jraph/models.py
def InteractionNetwork(
    update_edge_fn: InteractionUpdateEdgeFn,
    update_node_fn: Union[InteractionUpdateNodeFn, InteractionUpdateNodeFnNoSentEdges],
    aggregate_edges_for_nodes_fn: AggregateEdgesToNodesFn = utils.segment_sum,
    include_sent_messages_in_node_update: bool = False,
):
    """Interaction network (Battaglia et al.) as a configured GraphNetwork."""
    wrapped_update_edge_fn = lambda e, s, r, g: update_edge_fn(e, s, r)
    if include_sent_messages_in_node_update:
        wrapped_update_node_fn = lambda n, s, r, g: update_node_fn(n, s, r)
    else:
        wrapped_update_node_fn = lambda n, s, r, g: update_node_fn(n, r)
    return GraphNetwork(
        update_edge_fn=wrapped_update_edge_fn,
        update_node_fn=wrapped_update_node_fn,
        aggregate_edges_for_nodes_fn=aggregate_edges_for_nodes_fn,
    )

RelationNetwork

RelationNetwork(
    update_edge_fn,
    update_global_fn,
    aggregate_edges_for_globals_fn=segment_sum,
)

Relation network as a configured GraphNetwork.

Source code in anytensor/jraph/models.py
def RelationNetwork(
    update_edge_fn: Callable[[SenderFeatures, ReceiverFeatures], EdgeFeatures],
    update_global_fn: Callable[[EdgeFeatures], NodeFeatures],
    aggregate_edges_for_globals_fn: AggregateEdgesToGlobalsFn = utils.segment_sum,
):
    """Relation network as a configured GraphNetwork."""
    return GraphNetwork(
        update_edge_fn=lambda e, s, r, g: update_edge_fn(s, r),
        update_node_fn=None,
        update_global_fn=lambda n, e, g: update_global_fn(e),
        attention_logit_fn=None,
        aggregate_edges_for_globals_fn=aggregate_edges_for_globals_fn,
    )

batch_np

batch_np(graphs)

NumPy implementation of :func:batch (host arrays).

Source code in anytensor/jraph/utils.py
def batch_np(graphs: Sequence[GraphsTuple]) -> GraphsTuple:
    """NumPy implementation of :func:`batch` (host arrays)."""
    return batch([_graph_to_numpy(g) for g in graphs])

concatenated_args

concatenated_args(update=None, *, axis=-1)

Decorator that concatenates update_fn arguments along axis.

Source code in anytensor/jraph/utils.py
def concatenated_args(update: Optional[Callable] = None, *, axis: int = -1):
    """Decorator that concatenates update_fn arguments along ``axis``."""

    def _decorate(f):
        @functools.wraps(f)
        def wrapper(*args, **kwargs):
            combined = tree.leaves(args) + tree.leaves(kwargs)
            combined = [c for c in combined if c is not None]
            return f(concatenate(combined, axis=axis))

        return wrapper

    if update:
        return _decorate(update)
    return _decorate

dynamically_batch

dynamically_batch(
    graphs_tuple_iterator, n_node, n_edge, n_graph
)

Yield padded batches from an iterator of graphs (jraph algorithm).

Source code in anytensor/jraph/utils.py
def dynamically_batch(
    graphs_tuple_iterator: Iterator[GraphsTuple], n_node: int, n_edge: int, n_graph: int
) -> Generator[GraphsTuple, None, None]:
    """Yield padded batches from an iterator of graphs (jraph algorithm)."""
    if n_graph < 2:
        raise ValueError(
            "The number of graphs in a batch size must be greater or "
            f"equal to `2` for padding with graphs, got {n_graph}."
        )
    valid_batch_size = (n_node - 1, n_edge, n_graph - 1)
    accumulated_graphs: list[GraphsTuple] = []
    num_accumulated_nodes = 0
    num_accumulated_edges = 0
    num_accumulated_graphs = 0
    for element in graphs_tuple_iterator:
        if not isinstance(element, GraphsTuple):
            raise RuntimeError("dynamically_batch iterator must yield GraphsTuple")
        element_nodes, element_edges, element_graphs = _get_graph_size(element)
        if _is_over_batch_size(element, valid_batch_size):
            if accumulated_graphs:
                yield pad_with_graphs(batch_np(accumulated_graphs), n_node, n_edge, n_graph)
            graph_size = dict(zip(_NUMBER_FIELDS, (element_nodes, element_edges, element_graphs)))
            batch_size = dict(zip(_NUMBER_FIELDS, valid_batch_size))
            raise RuntimeError(
                "Found graph bigger than batch size. Valid Batch "
                f"Size: {batch_size}, Graph Size: {graph_size}"
            )
        if not accumulated_graphs:
            accumulated_graphs = [element]
            num_accumulated_nodes = element_nodes
            num_accumulated_edges = element_edges
            num_accumulated_graphs = element_graphs
            continue
        if (
            (num_accumulated_graphs + element_graphs > n_graph - 1)
            or (num_accumulated_nodes + element_nodes > n_node - 1)
            or (num_accumulated_edges + element_edges > n_edge)
        ):
            yield pad_with_graphs(batch_np(accumulated_graphs), n_node, n_edge, n_graph)
            accumulated_graphs = [element]
            num_accumulated_nodes = element_nodes
            num_accumulated_edges = element_edges
            num_accumulated_graphs = element_graphs
        else:
            accumulated_graphs.append(element)
            num_accumulated_nodes += element_nodes
            num_accumulated_edges += element_edges
            num_accumulated_graphs += element_graphs
    if accumulated_graphs:
        yield pad_with_graphs(batch_np(accumulated_graphs), n_node, n_edge, n_graph)

get_edge_padding_mask

get_edge_padding_mask(padded_graph)

Boolean mask, True for real edges.

Source code in anytensor/jraph/utils.py
def get_edge_padding_mask(padded_graph: GraphsTuple):
    """Boolean mask, True for real edges."""
    n_padding_edge = get_number_of_padding_with_graphs_edges(padded_graph)
    if padded_graph.senders is None:
        raise ValueError("`padded_graph` must have senders to infer edge count")
    total_num_edges = padded_graph.senders.shape[0]
    return _get_mask(n_padding_edge, total_num_edges, like=padded_graph.senders)

get_fully_connected_graph

get_fully_connected_graph(
    n_node_per_graph,
    n_graph,
    node_features=None,
    global_features=None,
    add_self_edges=True,
)

Fully connected graphs (optionally without self-edges). n_graph is static.

Source code in anytensor/jraph/utils.py
def get_fully_connected_graph(
    n_node_per_graph: int,
    n_graph: int,
    node_features: Optional[ArrayTree] = None,
    global_features: Optional[ArrayTree] = None,
    add_self_edges: bool = True,
) -> GraphsTuple:
    """Fully connected graphs (optionally without self-edges). ``n_graph`` is static."""
    if node_features is not None:
        leaves = tree.leaves(node_features)
        if leaves and int(np.asarray(leaves[0].shape[0])) != n_node_per_graph * n_graph:
            raise ValueError(
                "Number of nodes is not equal to num_nodes_per_graph * n_graph."
            )
    if global_features is not None:
        leaves = tree.leaves(global_features)
        if leaves and int(np.asarray(leaves[0].shape[0])) != n_graph:
            raise ValueError("The number of globals is not equal to n_graph.")

    like = None
    if node_features is not None:
        fl = tree.leaves(node_features)
        if fl:
            like = fl[0]
    tmp_senders, tmp_receivers = np.meshgrid(
        np.arange(n_node_per_graph), np.arange(n_node_per_graph)
    )
    if not add_self_edges:
        tmp_senders = np.stack(
            [np.roll(row, -i) for i, row in enumerate(tmp_senders)]
        )[:, 1:]
        tmp_receivers = tmp_receivers[:, 1:]
    tmp_senders = tmp_senders.reshape(-1)
    tmp_receivers = tmp_receivers.reshape(-1)
    senders = []
    receivers = []
    n_edge = []
    for graph_idx in range(n_graph):
        offset = graph_idx * n_node_per_graph
        senders.append(tmp_senders + offset)
        receivers.append(tmp_receivers + offset)
        n_edge.append(len(tmp_senders))

    def _as(idx):
        arr = np.concatenate(idx, axis=0) if idx else np.array([], dtype=np.int32)
        if like is None:
            return arr
        return array_namespace(like).asarray(arr)

    n_node_arr: Any = np.array([n_node_per_graph] * n_graph, dtype=np.int32)
    n_edge_arr: Any = np.array(n_edge if n_edge else [0], dtype=np.int32)
    if like is not None:
        xp = array_namespace(like)
        n_node_arr = xp.asarray(n_node_arr)
        n_edge_arr = xp.asarray(n_edge_arr)
    return GraphsTuple(
        nodes=node_features,
        edges=None,
        n_node=n_node_arr,
        n_edge=n_edge_arr,
        senders=_as(senders),
        receivers=_as(receivers),
        globals=global_features,
    )

get_graph_padding_mask

get_graph_padding_mask(padded_graph)

Boolean mask, True for real graphs.

Source code in anytensor/jraph/utils.py
def get_graph_padding_mask(padded_graph: GraphsTuple):
    """Boolean mask, True for real graphs."""
    n_padding_graph = get_number_of_padding_with_graphs_graphs(padded_graph)
    total_num_graphs = padded_graph.n_node.shape[0]
    return _get_mask(n_padding_graph, total_num_graphs, like=padded_graph.n_node)

get_node_padding_mask

get_node_padding_mask(padded_graph)

Boolean mask, True for real nodes. Needs node features (static length).

Source code in anytensor/jraph/utils.py
def get_node_padding_mask(padded_graph: GraphsTuple):
    """Boolean mask, True for real nodes. Needs node features (static length)."""
    n_padding_node = get_number_of_padding_with_graphs_nodes(padded_graph)
    leaves = tree.leaves(padded_graph.nodes)
    if not leaves:
        raise ValueError("`padded_graph` must have at least one array of node features")
    total_num_nodes = leaves[0].shape[0]
    return _get_mask(n_padding_node, total_num_nodes, like=leaves[0])

get_number_of_padding_with_graphs_edges

get_number_of_padding_with_graphs_edges(padded_graph)

Number of padding edges (the dummy graph's n_edge).

Source code in anytensor/jraph/utils.py
def get_number_of_padding_with_graphs_edges(padded_graph: GraphsTuple):
    """Number of padding edges (the dummy graph's ``n_edge``)."""
    n_pad = get_number_of_padding_with_graphs_graphs(padded_graph)
    return padded_graph.n_edge[-n_pad]

get_number_of_padding_with_graphs_graphs

get_number_of_padding_with_graphs_graphs(padded_graph)

Number of padding graphs (dummy + trailing empty). Not for unpadded graphs.

Source code in anytensor/jraph/utils.py
def get_number_of_padding_with_graphs_graphs(padded_graph: GraphsTuple):
    """Number of padding graphs (dummy + trailing empty). Not for unpadded graphs."""
    n_node = padded_graph.n_node
    xp = array_namespace(n_node)
    reversed_empty = _flip0(n_node) == 0
    return xp.argmin(reversed_empty) + 1

get_number_of_padding_with_graphs_nodes

get_number_of_padding_with_graphs_nodes(padded_graph)

Number of padding nodes (the dummy graph's n_node).

Source code in anytensor/jraph/utils.py
def get_number_of_padding_with_graphs_nodes(padded_graph: GraphsTuple):
    """Number of padding nodes (the dummy graph's ``n_node``)."""
    n_pad = get_number_of_padding_with_graphs_graphs(padded_graph)
    return padded_graph.n_node[-n_pad]

pad_with_graphs

pad_with_graphs(graph, n_node, n_edge, n_graph=2)

Pad with a dummy graph (padding nodes/edges) plus empty graphs.

Not compilable (padding sizes are data-dependent). Requires n_graph >= 2.

Source code in anytensor/jraph/utils.py
def pad_with_graphs(
    graph: GraphsTuple, n_node: int, n_edge: int, n_graph: int = 2
) -> GraphsTuple:
    """Pad with a dummy graph (padding nodes/edges) plus empty graphs.

    Not compilable (padding sizes are data-dependent). Requires ``n_graph >= 2``.
    """
    if n_graph < 2:
        raise ValueError(
            f"n_graph is {n_graph}, which is smaller than minimum value of 2."
        )
    pad_n_node = int(n_node - _sum_n_node(graph))
    pad_n_edge = int(n_edge - _sum_n_edge(graph))
    pad_n_graph = int(n_graph - _n_graphs(graph))
    if pad_n_node <= 0 or pad_n_edge < 0 or pad_n_graph <= 0:
        raise RuntimeError(
            "Given graph is too large for the given padding. difference: "
            f"n_node {pad_n_node}, n_edge {pad_n_edge}, n_graph {pad_n_graph}"
        )
    pad_n_empty_graph = pad_n_graph - 1
    like = graph.n_node
    idx_like = _like_index(graph)

    def pad_nodes(leaf):
        return _zeros_like_leading(leaf, pad_n_node)

    def pad_edges(leaf):
        return _zeros_like_leading(leaf, pad_n_edge)

    def pad_globals(leaf):
        return _zeros_like_leading(leaf, pad_n_graph)

    pad_senders = zeros((pad_n_edge,), dtype=idx_like.dtype, like=idx_like)
    padding_graph = GraphsTuple(
        n_node=concatenate(
            [
                astype(full((1,), pad_n_node, dtype=like.dtype, like=like), like.dtype),
                zeros((pad_n_empty_graph,), dtype=like.dtype, like=like),
            ],
            axis=0,
        ),
        n_edge=concatenate(
            [
                astype(full((1,), pad_n_edge, dtype=like.dtype, like=like), like.dtype),
                zeros((pad_n_empty_graph,), dtype=like.dtype, like=like),
            ],
            axis=0,
        ),
        nodes=_map_features(pad_nodes, graph.nodes),
        edges=_map_features(pad_edges, graph.edges),
        globals=_map_features(pad_globals, graph.globals),
        senders=pad_senders,
        receivers=zeros((pad_n_edge,), dtype=idx_like.dtype, like=idx_like),
    )
    return batch([graph, padding_graph])

partition_softmax

partition_softmax(logits, partitions, sum_partitions)

Softmax within contiguous partitions of lengths partitions.

Official jraph takes sum_partitions as the third positional and allows omitting it. AnyTensor requires it (core name total_length; shape(logits)[0], not a data sum(partitions)). num_segments is not an argument — it is shape(partitions)[0]. Calls core :func:~anytensor.partition_softmax, which expands ids through :func:~anytensor.partition_ids (cache as needed).

Source code in anytensor/jraph/utils.py
def partition_softmax(logits, partitions, sum_partitions):
    """Softmax within contiguous partitions of lengths ``partitions``.

    Official jraph takes ``sum_partitions`` as the third positional and allows
    omitting it. AnyTensor **requires** it (core name ``total_length``;
    ``shape(logits)[0]``, not a data ``sum(partitions)``). ``num_segments`` is
    not an argument — it is ``shape(partitions)[0]``. Calls core
    :func:`~anytensor.partition_softmax`, which expands ids through
    :func:`~anytensor.partition_ids` (cache as needed).
    """
    return _partition_softmax(logits, partitions, sum_partitions)

segment_max

segment_max(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Max within segments. num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_max(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Max within segments. ``num_segments`` is required."""
    del unique_indices
    return _segment_max(
        data, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

segment_max_or_constant

segment_max_or_constant(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    constant=0.0,
    sorted=False,
)

Segment max with a finite fill for empty segments.

Source code in anytensor/jraph/utils.py
def segment_max_or_constant(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    constant: float = 0.0,
    sorted: bool = False,
):
    """Segment max with a finite fill for empty segments."""
    del unique_indices
    return _segment_max_or_constant(
        data,
        segment_ids,
        num_segments,
        constant=constant,
        sorted=_sorted_flag(indices_are_sorted, sorted),
    )

segment_mean

segment_mean(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Mean within segments. num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_mean(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Mean within segments. ``num_segments`` is required."""
    del unique_indices
    return _segment_mean(
        data, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

segment_min

segment_min(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Min within segments. num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_min(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Min within segments. ``num_segments`` is required."""
    del unique_indices
    return _segment_min(
        data, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

segment_min_or_constant

segment_min_or_constant(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    constant=0.0,
    sorted=False,
)

Segment min with a finite fill for empty segments.

Source code in anytensor/jraph/utils.py
def segment_min_or_constant(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    constant: float = 0.0,
    sorted: bool = False,
):
    """Segment min with a finite fill for empty segments."""
    del unique_indices
    return _segment_min_or_constant(
        data,
        segment_ids,
        num_segments,
        constant=constant,
        sorted=_sorted_flag(indices_are_sorted, sorted),
    )

segment_normalize

segment_normalize(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    eps=1e-08,
    sorted=False,
)

Z-score normalize within segments (jraph semantics). num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_normalize(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    eps=1e-8,
    sorted: bool = False,
):
    """Z-score normalize within segments (jraph semantics). ``num_segments`` is required."""
    del unique_indices
    flag = _sorted_flag(indices_are_sorted, sorted)
    means = take(segment_mean(data, segment_ids, num_segments, sorted=flag), segment_ids)
    variances = take(
        segment_variance(data, segment_ids, num_segments, sorted=flag), segment_ids
    )
    scale = rsqrt(maximum(variances, eps))
    return (data - means) * scale

segment_softmax

segment_softmax(
    logits,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Softmax within segments. num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_softmax(
    logits,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Softmax within segments. ``num_segments`` is required."""
    del unique_indices
    return _segment_softmax(
        logits, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

segment_sum

segment_sum(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Sum within segments. num_segments is required (AnyTensor).

Source code in anytensor/jraph/utils.py
def segment_sum(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Sum within segments. ``num_segments`` is required (AnyTensor)."""
    del unique_indices
    return _segment_sum(
        data, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

segment_variance

segment_variance(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted=False,
    unique_indices=False,
    sorted=False,
)

Variance within segments. num_segments is required.

Source code in anytensor/jraph/utils.py
def segment_variance(
    data,
    segment_ids,
    num_segments,
    indices_are_sorted: bool = False,
    unique_indices: bool = False,
    sorted: bool = False,
):
    """Variance within segments. ``num_segments`` is required."""
    del unique_indices
    return _segment_variance(
        data, segment_ids, num_segments, sorted=_sorted_flag(indices_are_sorted, sorted)
    )

sparse_matrix_to_graphs_tuple

sparse_matrix_to_graphs_tuple(
    senders, receivers, values, n_node
)

COO sparse matrix → graph (values repeat senders/receivers).

Source code in anytensor/jraph/utils.py
def sparse_matrix_to_graphs_tuple(senders, receivers, values, n_node) -> GraphsTuple:
    """COO sparse matrix → graph (values repeat senders/receivers)."""
    values_np = np.asarray(values)
    if values_np.size == 0:
        senders_out = np.array([], dtype=np.int32)
        receivers_out = np.array([], dtype=np.int32)
        n_edge = np.array([0])
    else:
        senders_out = np.repeat(np.asarray(senders), values_np)
        receivers_out = np.repeat(np.asarray(receivers), values_np)
        n_edge = np.array([int(values_np.sum())])
    return GraphsTuple(
        nodes=None,
        edges=None,
        receivers=receivers_out,
        senders=senders_out,
        globals=None,
        n_node=np.asarray(n_node),
        n_edge=n_edge,
    )

unbatch_np

unbatch_np(graph)

NumPy implementation of :func:unbatch.

Source code in anytensor/jraph/utils.py
def unbatch_np(graph: GraphsTuple) -> List[GraphsTuple]:
    """NumPy implementation of :func:`unbatch`."""
    return unbatch(_graph_to_numpy(graph))

unpad_with_graphs

unpad_with_graphs(padded_graph)

Remove dummy + empty padding graphs. Not compilable.

Source code in anytensor/jraph/utils.py
def unpad_with_graphs(padded_graph: GraphsTuple) -> GraphsTuple:
    """Remove dummy + empty padding graphs. Not compilable."""
    n_padding_graph = int(np.asarray(get_number_of_padding_with_graphs_graphs(padded_graph)))
    n_padding_node = int(np.asarray(get_number_of_padding_with_graphs_nodes(padded_graph)))
    n_padding_edge = int(np.asarray(get_number_of_padding_with_graphs_edges(padded_graph)))

    def remove_node_padding(arr):
        if n_padding_node == 0:
            return arr
        return arr[:-n_padding_node]

    def remove_edge_padding(arr):
        if n_padding_edge == 0:
            return arr
        return arr[:-n_padding_edge]

    def remove_graph_padding(arr):
        return arr[:-n_padding_graph]

    return GraphsTuple(
        n_node=remove_graph_padding(padded_graph.n_node),
        n_edge=remove_graph_padding(padded_graph.n_edge),
        nodes=_map_features(remove_node_padding, padded_graph.nodes),
        edges=_map_features(remove_edge_padding, padded_graph.edges),
        globals=_map_features(remove_graph_padding, padded_graph.globals),
        senders=remove_edge_padding(padded_graph.senders)
        if padded_graph.senders is not None
        else None,
        receivers=remove_edge_padding(padded_graph.receivers)
        if padded_graph.receivers is not None
        else None,
    )

with_zero_out_padding_outputs

with_zero_out_padding_outputs(graph_net)

Wrap a graph-to-graph fn so padded outputs are zeroed.

Source code in anytensor/jraph/utils.py
def with_zero_out_padding_outputs(graph_net: Callable[[GraphsTuple], GraphsTuple]):
    """Wrap a graph-to-graph fn so padded outputs are zeroed."""

    @functools.wraps(graph_net)
    def wrapper(graph: GraphsTuple) -> GraphsTuple:
        return zero_out_padding(graph_net(graph))

    return wrapper

zero_out_padding

zero_out_padding(graph)

Multiply padding nodes/edges/globals by zero (overflow guard).

Source code in anytensor/jraph/utils.py
def zero_out_padding(graph: GraphsTuple) -> GraphsTuple:
    """Multiply padding nodes/edges/globals by zero (overflow guard)."""
    edge_mask = get_edge_padding_mask(graph)
    node_mask = get_node_padding_mask(graph)
    global_mask = get_graph_padding_mask(graph)

    def _apply(mask):
        def fn(x):
            m = _expand_trailing_dimensions(mask, x)
            xp = array_namespace(x)
            m = xp.astype(m, x.dtype) if hasattr(xp, "astype") else astype(m, x.dtype)
            return m * x

        return fn

    return graph._replace(
        nodes=_map_features(_apply(node_mask), graph.nodes),
        edges=_map_features(_apply(edge_mask), graph.edges),
        globals=_map_features(_apply(global_mask), graph.globals),
    )