PackedMultiheadAttention ¶
PackedMultiheadAttention(
source_index: object,
target_index: object,
query_features: int,
key_features: int,
embed_dim: int,
num_heads: int,
dropout: float = 0.0,
bias: bool = True,
kdim: int | None = None,
vdim: int | None = None,
batch_first: bool = True,
add_self_loops: bool = False,
*,
identity: str | None = None,
generator: Generator | None = None,
chunk_size: int | None = None,
)
Bases: Module
Multi-head attention in which each node attends only to its sources.
Prior knowledge decides who may attend to whom; the data
decides how much. Every node is one token, a vector of
embed_dim numbers that stands for the node inside your
model. For each node, the layer looks at the nodes with an
edge into it (its sources), weighs them by how well their
tokens match its own, and returns a weighted mix of what they
pass on. The weights are recomputed for every sample: with a
gene-regulatory prior, a target gene can weigh its regulators
differently in each cell, depending on their state and its
own. Two nodes without an edge exchange nothing in this layer.
Information flows along the edge arrow, from source to target,
as in PackedLinear. In attention terms, the target asks and
its sources answer. The target's token gives a query (what it
looks for). Each source's token gives a key (what it offers
to be matched against) and a value (what it passes on). Each
query-key pair, that is, each live edge, gets a score. A
softmax over the edges into one node turns their scores into
weights that are non-negative and sum to 1, so the sources
compete: a higher weight for one leaves less for the others.
num_heads such weightings run side by side, each on its own
slice of the channels, so different heads can weigh the same
sources differently.
Compare PackedLinear, which learns one weight per edge,
fixed after training and the same for every sample. Here
nothing is learned per edge. The learned parameters are four
embed_dim x embed_dim projections shared by every node and
edge (q_proj, k_proj, v_proj, out_proj), so
their number does not depend on the graph. The graph only
decides which pairs are scored.
One call moves information one edge along the graph: a node's output depends only on its own token and those of its direct sources. Stack layers in your own encoder to reach nodes further upstream. This layer is that one building block, not a Transformer. You build the tokens (for example a projection of each measured value plus a learned embedding per node) and write the rest around the layer: normalization, residual connections, a feed-forward network, and the readout.
Masking nn.MultiheadAttention with the graph computes the
same mix for every node that has a source. But it scores every
pair of tokens, an (n, n) matrix per head and sample (400
million scores for 20,000 nodes), and then discards the pairs
without an edge. This layer scores only the live edges, so its
memory and time grow with their number, nnz, rather than
with n squared.
Build it with from_adjacency on a parse_adjacency spec:
one token per node (per unit, when widths= gives a node
several). It reads the indices and sequence lengths off the
spec, so the query and key sides cannot be swapped by hand. The
constructor below takes packed indices directly, for pairs that
come from no spec, such as a bipartite map in which pathway
tokens (queries) attend to gene tokens (keys).
Call it like nn.MultiheadAttention (it is not a subclass):
layer(query, key, value), passing the same tokens three
times for attention within one graph. Two defaults differ from
MHA: batch_first=True and need_weights=False. With
need_weights=True the layer returns one weight per live
edge, not a dense (L, S) matrix.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
|
torch.Tensor, numpy.ndarray, or sequence of int
|
Where each live edge starts. Entry |
required |
|
torch.Tensor, numpy.ndarray, or sequence of int
|
Where each live edge ends. Entry |
required |
|
int or numpy integer
|
How many tokens attend: the sequence length of |
required |
|
int or numpy integer
|
How many tokens can be attended to: the sequence length of
|
required |
|
int or numpy integer
|
How many numbers describe each token: the width of
|
required |
|
int or numpy integer
|
How many attention patterns run side by side. Each head
scores the same live edges from its own
|
required |
|
float
|
Regularization on the edge weights, in training mode only:
each weight is zeroed with this probability and the rest
are scaled by |
0.0
|
|
bool
|
Whether the four projections learn an additive bias.
|
True
|
|
int, numpy integer, or None
|
Exists so code written for |
None
|
|
int, numpy integer, or None
|
Same as |
None
|
|
bool
|
Where the batch axis sits. |
True
|
|
bool
|
Let every token also attend to itself. Without a self-loop,
a node's own token shapes how it weighs its sources
(through its query) but is not part of the mix, and a node
with no incoming edge, such as an input node of an
|
False
|
|
str or None
|
Labels the graph this layer belongs to, so
|
None
|
|
Generator or None
|
A reproducible start without touching the global random
state: the Xavier-uniform draws for the four projections
use this generator. |
None
|
|
int, numpy integer, or None
|
Memory control for large graphs; it does not change what
is computed. |
None
|
Attributes:
| Name | Type | Description |
|---|---|---|
query_features |
int
|
Number of tokens that attend (sequence length of
|
key_features |
int
|
Number of tokens that can be attended to (sequence length
of |
embed_dim |
int
|
Token width. |
num_heads |
int
|
Number of attention heads. |
head_dim |
int
|
Channels per head, |
nnz |
int
|
Number of live edges scored, counting any pair added by
|
dropout |
float
|
Dropout probability on the edge weights. |
batch_first |
bool
|
Layout flag; default |
add_self_loops |
bool
|
Whether missing self-loops were added at construction. |
source_index |
Tensor
|
Int64 buffer: the key / value position of each live edge,
length |
target_index |
Tensor
|
Int64 buffer: the query position of each live edge, length
|
q_proj, k_proj, v_proj, out_proj |
Linear
|
The four learned |
identity |
str | None
|
The constructor |
chunk_size |
int | None
|
Edge-chunk length, or |
Raises:
| Type | Description |
|---|---|
Kpnn2Error
|
At construction, if the indices are empty, not 1-D
integers, mismatched in length, out of range, or
duplicated as |
See Also
PackedLinear : Same packed pairs, but one trained weight per
edge, the same for every sample, instead of attention
weights computed per sample.
MaskedLinear : Dense (out, in) weight; the default layer
whenever that square fits.
AdjacencySpec : Graph-wide packed edges; pass it to
from_adjacency.
torch.nn.MultiheadAttention : Dense equivalent, and the
reference for call shape, heads, and training.
Notes
One forward pass, for one sample and one head with
d = embed_dim // num_heads channels:
- Project every token:
q = q_proj(query),k = k_proj(key),v = v_proj(value), each split intonum_headsslices ofdchannels. - Score each live edge
efrom sourcesto targett:score[e] = dot(q[t], k[s]) / sqrt(d). - Turn the scores of the edges into each target into weights
with a softmax over those edges only. Keys masked by
key_padding_masktake no part. In training mode,dropoutthen zeroes weights at random and scales the rest by1 / (1 - dropout). - Mix:
mixed[t]is the sum ofweight[e] * v[s]over the edgeseintot. - Join the heads' slices of
mixedback intoembed_dimchannels and applyout_proj.
A query with no live keys, because it has no incoming edge and
no self-loop or because key_padding_mask removed all of
them, mixes to zeros rather than NaN, so its output is the bias
of out_proj (zeros when bias=False). Input nodes of an
AdjacencySpec are exactly that case unless
add_self_loops=True. With it, an input node's only key is
itself, so its softmax weight is 1 and it passes on its own
value.
No (n, n) or (L, S) score matrix is ever allocated and
torch.sparse is not imported; cost scales with nnz, not
with query_features * key_features. chunk_size=None
(the default) gathers all live pairs at once, and training
keeps those (..., nnz, heads, head_dim) gathers for
backward. A positive chunk_size gathers chunked live pairs
and rematerializes those (..., chunk, heads, head_dim)
tensors in backward, so training does not save a full
(..., nnz, heads, head_dim) gather. The packed softmax
(..., nnz, heads) is still stored.
forward documents the accepted layouts, packed
need_weights, attn_mask / is_causal, and
key_padding_mask.
Index buffers stay integer after .half() / bfloat16 /
.double(); projection weights follow the module floating
dtype like nn.Linear. torch.autocast is unsupported:
forward disables it and casts query, key, and value to
the parameter dtype. They must be floating-point tensors; an
integer, bool, or complex input raises instead of being
cast. Cast the module with .to(dtype=...) (or .half()
/ .double()) instead.
state_dict adds index_digest, a 1-D CPU uint8
tensor of length 32: the SHA-256 of the packed indices plus
query_features, key_features, embed_dim, and
num_heads, so a reshape or a different head split cannot
collide. It is not a registered buffer, and a missing digest
is not an error, even with strict=True. identity is
stored the same way when the constructor was given one: a
rename that leaves the packed index pattern unchanged is
caught when callers pass spec.fingerprint. chunk_size
is not part of state_dict or index_digest.
copy.deepcopy works.
Examples:
An input x feeds a two-node feedback loop between a and
b, which feeds an output y. All four nodes form one
four-token sequence:
>>> import pandas as pd
>>> import torch
>>> import kpnn2
>>> edgelist = pd.DataFrame(
... {
... "source": ["x", "a", "b", "a"],
... "target": ["a", "b", "a", "y"],
... }
... )
>>> spec = kpnn2.parse_adjacency(edgelist)
>>> attn = kpnn2.PackedMultiheadAttention.from_adjacency(
... spec,
... embed_dim=8,
... num_heads=2,
... add_self_loops=True,
... identity=spec.fingerprint,
... )
>>> attn.nnz, attn.head_dim
(8, 4)
>>> tokens = torch.randn(2, spec.state_dim, 8)
>>> output, weights = attn(tokens, tokens, tokens)
>>> tuple(output.shape)
(2, 4, 8)
>>> weights is None
True
With need_weights=True each sample gets one weight per live
edge: the four graph edges, then the four added self-loops.
>>> _, packed = attn(
... tokens,
... tokens,
... tokens,
... need_weights=True,
... )
>>> tuple(packed.shape)
(2, 8)
>>> for s, t in zip(
... attn.source_index.tolist(),
... attn.target_index.tolist(),
... ):
... print(spec.nodes[s], "->", spec.nodes[t])
a -> b
a -> y
b -> a
x -> a
a -> a
b -> b
x -> x
y -> y
Methods:¶
from_adjacency
classmethod
¶
from_adjacency(
spec: AdjacencySpec,
embed_dim: int,
num_heads: int,
*,
dropout: float = 0.0,
bias: bool = True,
batch_first: bool = True,
add_self_loops: bool = False,
identity: str | None = None,
generator: Generator | None = None,
chunk_size: int | None = None,
) -> PackedMultiheadAttention
Build attention over the nodes of an AdjacencySpec.
This is the recommended way to build this layer on an
AdjacencySpec: one token per unit of the state vector,
and the query at each edge's target attends to the key at
its source. It reads the packed indices off spec and
uses spec.state_dim as both sequence lengths, so the
query and key sides cannot be swapped by hand. On this
square layout a hand-written swap is always silent: the
constructor accepts it and every node attends along
reversed edges. The result is the layer the constructor
builds from spec.source_index, spec.target_index,
spec.state_dim, spec.state_dim, embed_dim, and
num_heads, with the same init draws. It keeps no
reference to spec. Options after num_heads are
keyword-only; kdim and vdim, which can only equal
embed_dim, are left out.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
|
AdjacencySpec
|
Spec from |
required |
|
int or numpy integer
|
Same as the |
required |
|
int or numpy integer
|
Same as the |
required |
|
float
|
Same as the |
0.0
|
|
bool
|
Same as the |
True
|
|
bool
|
Same as the |
True
|
|
bool
|
Same as the |
False
|
|
str or None
|
Same as the |
None
|
|
Generator or None
|
Same as the |
None
|
|
int, numpy integer, or None
|
Same as the |
None
|
Returns:
| Type | Description |
|---|---|
PackedMultiheadAttention
|
New layer with |
Raises:
| Type | Description |
|---|---|
Kpnn2Error
|
If |
See Also
PackedLinear.from_adjacency : One trainable scalar per edge on the same packed pairs.
Examples:
>>> import pandas as pd
>>> import kpnn2
>>> spec = kpnn2.parse_adjacency(
... pd.DataFrame(
... {
... "source": ["x", "a", "b", "a"],
... "target": ["a", "b", "a", "y"],
... }
... )
... )
>>> attn = kpnn2.PackedMultiheadAttention.from_adjacency(
... spec,
... embed_dim=8,
... num_heads=2,
... identity=spec.fingerprint,
... )
>>> attn.query_features, attn.key_features, attn.nnz
(4, 4, 4)
reset_parameters ¶
reset_parameters(
generator: Generator | None = None,
) -> None
Xavier-uniform projections, like nn.MultiheadAttention.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
|
Generator or None
|
Isolated RNG for the four Xavier draws. |
None
|
forward ¶
forward(
query: Tensor,
key: Tensor,
value: Tensor,
key_padding_mask: Tensor | None = None,
need_weights: bool = False,
attn_mask: Tensor | None = None,
average_attn_weights: bool = True,
is_causal: bool = False,
) -> tuple[torch.Tensor, torch.Tensor | None]
Let each query attend to its live keys and mix their values.
For attention within one graph, pass the same tokens as
query, key, and value: every node then attends
to its sources. Pass different tensors when queries and
keys are different token sets, as in a bipartite map built
with the constructor. The arguments are those of
nn.MultiheadAttention.forward, so an encoder written
for it can call this layer; the class docstring lists the
differences.
With batch_first=True (the default), query,
key, and value are (..., seq, embed_dim):
unbatched 2-D (seq, embed_dim), batched 3-D
(batch, seq, embed_dim), or more leading axes. With
batch_first=False, unbatched 2-D is still
(seq, embed_dim); batched 3-D is
(seq, batch, embed_dim), transposed to batch-first for
the packed kernel and transposed back. The batch axes of
all three must match.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
|
Tensor
|
The tokens that attend: |
required |
|
Tensor
|
The tokens matched against the queries:
|
required |
|
Tensor
|
What each key position passes on, in the shape of
|
required |
|
Tensor or None
|
Keys to leave out of a given sample, for example a
feature that sample did not measure: |
None
|
|
bool
|
Also return the weight of every live edge, for example
to draw edge thickness on the prior graph. Must be a
|
False
|
|
Tensor or None
|
Must be |
None
|
|
bool
|
With |
True
|
|
bool
|
Must be |
False
|
Returns:
| Name | Type | Description |
|---|---|---|
output |
Tensor
|
The updated tokens, one per query position, in the
shape and layout of |
weights |
Tensor or None
|
|
Raises:
| Type | Description |
|---|---|
Kpnn2Error
|
If |