Concepts¶
The vocabulary these docs use, in the order the pipeline uses it: a graph, written as an edgelist, goes through a parser and comes out a spec, which you build a module from.
Each term below is a link target, so other pages can point here instead of redefining. This page carries the concept; the API reference carries the exact contract (fields, invariants, what raises).
| Term | In one line |
|---|---|
| Graph | Named nodes and the directed edges between them |
| Edgelist | That graph as a source / target table |
| Node | One named entity, one unit by default |
| Directed acyclic graph (DAG) | A graph with no feedback loop |
| Parser | parse_layered() or parse_adjacency() |
| Spec | The parsed layout, frozen; no parameters |
| Layer | A depth in a LayeredSpec |
| Hop | Every edge arriving at one layer |
| Skip edge | An edge whose endpoints are >1 layer apart |
| Packed indices | Two parallel lists, one entry per unit pair |
| Live edge | A unit pair that exists, so it gets a weight |
| State vector | Every node as one unit, in an AdjacencySpec |
| Attribution | A score per node or feature, after training |
| Knowledge-primed neural network (KPNN) | The domain framing that named the package |
Graph¶
The named nodes and the directed edges between them. This is the
prior: the wiring you already believe in before training, which
kpnn2 turns into an architecture.
Named graph is the emphatic form, used where the point is that the nodes carry domain names rather than positions, so a score on a node is a score on the entity it stands for.
"Graph" in these docs never means PyTorch's autograd graph, and never a graph as data in the GNN sense, where each node carries a feature vector and the batch is graphs. Here the graph is the architecture and the batch is samples. See Why not a GNN?.
Edgelist¶
The graph as a table: a pandas.DataFrame with required columns
source and target, one row per directed edge, in the direction
of computation.
| source | target |
|---|---|
| A | H |
| B | H |
| H | C |
Extra columns are ignored, names are read through str(...), and
the frame is read and never modified. Edgelist and graph are the
same object in two forms — the table you pass in, and the
structure it encodes.
Node¶
One named entity, and the name is yours to choose. By default a
node is one unit of the network. parse_layered(..., widths=) can
give a named node several units, so that one entity owns a small
block rather than a single scalar.
A parser splits nodes into three groups by degree, alphabetical within each:
- input nodes have in-degree 0. This is the feature-axis
order
align_inputs()indexes into. - output nodes have out-degree 0. A terminal node that sits below maximum depth still counts, so this is not the same tuple as the last layer.
- hidden nodes are neither.
DAG¶
A directed acyclic graph: no path leads from a node back to itself.
Depth only exists without feedback, so parse_layered() requires a
DAG and raises on a cycle. parse_adjacency() has no depth to
compute, so cycles and self-loops are ordinary there. A DAG is
valid for both parsers — being a DAG tells you which parsers are
available, not which one to call.
Parser¶
parse_layered() or parse_adjacency(). The one you call fixes
the shape of everything you build afterwards, and the package never
inspects the graph to pick for you: the layout is your choice.
parse_layered()ranks nodes by depth and returns aLayeredSpec: layers, one hop each.parse_adjacency()puts every node in one state vector and returns anAdjacencySpec: no layers, cycles allowed.
Layered vs. Adjacency is the full comparison.
Spec¶
What a parser returns: a LayeredSpec or an AdjacencySpec.
A spec is frozen structure — named nodes, packed indices, and the
metadata to label tensors. It is not a model. There is no
nn.Module, no parameters, and no stored mask. You write the
module around it, and activations, heads, losses, optimizers and
the training loop stay yours.
The word graph stops here. What comes out of the parser is a chosen layout of that graph, not the graph itself, which is why Layered vs. Adjacency is a real decision rather than something the package infers.
A checkpoint is spec.to_dict() plus state_dict, not weights
alone: the weights mean nothing without the wiring they belong to.
Layer¶
A depth in a LayeredSpec. Layer 0 is the inputs, and
spec.layer_nodes[i] is the names at depth i, alphabetical.
There are always at least two layers.
Depth is the longest path from the inputs by default.
parse_layered(..., ranks=) overrides that when nodes belong at
official levels — ontology tiers, say — instead of at whatever
depth the longest path puts them.
An AdjacencySpec has no layers at all: every node is one unit of
a single state vector instead.
Hop¶
Everything that arrives at one layer: every edge whose target is a node of that layer, whichever layer it left.
A hop holds no weights at all — those live in the layer you build
from it. It stores only its edges, as
packed indices, and its columns are its source
layers concatenated: the axis gather_hop_inputs() assembles.
One hop is one layer of the model, so a hop is exactly what a
single PackedLinear — or MaskedLinear(hop.to_mask()), the
dense hatch — computes. spec.hops[i] is the hop into layer
i + 1, which makes len(spec.hops) one less than the number of
layers.
Hops are a LayeredSpec idea. An AdjacencySpec has none.
Feedforward example builds one
PackedLinear per hop, end to end.
Skip edge¶
An original edge whose endpoints are more than one layer apart.
A skip needs no separate mechanism. It is already packed inside
the hop of its target, sitting next to that layer's adjacent
parents, so nothing has to add it back later and nothing can
forget to. At width 1 that is one unit pair; a wider endpoint
is every unit pair of the block. spec.skips is metadata that
says which prior edges span layers; a forward pass never reads
it, and it is empty when no edge skips.
Skip edges works this through on a real graph.
Packed indices¶
Two parallel integer lists, source_index and target_index,
with one entry per live unit pair: pair i says that column
source_index[i] feeds row target_index[i].
At width 1 that is one entry per named edge.
widths= on either parser expands a named edge A -> B into
every unit pair of the block, k_B by k_A.
This is how both specs hold connectivity. The dense alternative is
a mask — hop.to_mask() or spec.to_mask() allocates one on
demand, for MaskedLinear — but nothing stores a mask, because the
dense rectangle grows as the product of its two axes while the
packed lists grow with the number of live pairs.
The saving is storage, not a different kernel. PackedLinear is a
1-D dense weight plus index_add on ordinary dense tensors; there
are no sparse kernels in kpnn2, and none are planned.
PackedLinear is the layer that reads them.
Live edge¶
A unit pair that exists in the graph, and therefore gets its own
trainable weight. A named edge is one live edge when both ends
have width 1, and k_B * k_A live edges when they are wider.
The term earns its keep by contrast. A dense (out, in) rectangle
has a cell for every possible connection, and on a real prior
almost all of them are absent edges pinned to zero. PackedLinear
stores one scalar per live edge and nothing for the rest, which is
the whole reason it exists. PackedLinear is
when that matters.
State vector¶
The AdjacencySpec layout: every node owns a block of units of a
single alphabetical vector, spec.nodes — one unit unless
widths= gives it several — and every edge is index pairs over
it.
There are no layers, so a cycle or a self-loop is nothing special — it is another pair. One update maps the vector to itself, and how many times you apply it is yours to decide. Input nodes have no incoming edges, so writing the inputs into the state at each step is the caller's job, not the package's.
spec.input_index and spec.output_index are where to scatter
inputs into that vector and where to read outputs back out.
Cyclic graph example and
Time-series example both run on it.
Attribution¶
A score saying how much a node or an input feature mattered, produced after training by an attribution method.
The methods are not this package's: run Captum, or your own
gradients, or anything else. kpnn2 does not import Captum. What
map_node_attributions() does is label the node axis of the
tensor that comes back, using the names the spec already holds, and
return it as an xarray.DataArray.
aggregate_node_attributions() can then fold observations (and
optional seeds) to one score per node; that step is optional and
does not run Captum.
The reason this is worth a term: in a dense network only the input features have names, so attribution stops at the inputs. On a named graph the hidden nodes have names too, so the same methods score the internal entities. Mapping attributions is the naming rule in detail.
KPNN¶
A knowledge-primed neural network: the use case that named the package. Prior knowledge, encoded as a graph, constrains the structure of the network, so the model keeps only the connections that known relationships between named entities support.
The payoff is attribution on those named entities.
Biology is the common setting — genes, transcription factors,
kinases, pathways — but nothing in kpnn2 is biological. Any
architecture you can write as named edges works the same way.
Feedforward example builds one
end to end.