Edge weights and constraints¶
This notebook explains the optional edge-level metadata columns
initial_weight and constraint on edge2torch edgelists.
The required edgelist columns are still source and target. These optional
columns do not change how nodes, inputs, outputs, or graph connectivity are
inferred. They only control how selected edge weights are initialized or
constrained in the compiled PyTorch model.
Optional edge list columns¶
initial_weight defines the initial effective weight for an edge.
constraint defines how the edge weight behaves during training. Supported
values are:
| Value | Meaning |
|---|---|
unconstrained |
Trainable edge weight; may become positive or negative. |
positive |
Trainable edge weight constrained to remain positive. |
negative |
Trainable edge weight constrained to remain negative. |
fixed |
Non-trainable edge weight fixed to initial_weight. |
For constrained trainable edges, edge2torch learns an unconstrained latent parameter and transforms it into the effective edge weight during the forward pass. Positive and negative constraints are implemented with the softplus function:
$$ \operatorname{softplus}(\theta) = \log(1 + e^\theta) $$
The softplus transform is a smooth positive alternative to ReLU: it is always greater than zero, but remains differentiable around zero.

For a positive constraint, the effective edge weight is
$$ w = \operatorname{softplus}(\theta) $$
For a negative constraint, the effective edge weight is
$$ w = -\operatorname{softplus}(\theta) $$
where $\theta$ is the trainable latent parameter and $w$ is the effective edge weight used by the compiled model.
Both the initial_weight and the constraint column are sparse and
row-wise optional:
- missing
initial_weightmeans default PyTorch-style initialization for that edge - missing
constraintmeansunconstrained constraint="fixed"requires aninitial_weightin the same row
The columns can therefore be used independently. For example, one edge can have a custom initial weight, another edge can have only a sign constraint, and a third edge can use the default behavior.
Imports¶
import numpy as np
import pandas as pd
import torch
from IPython.display import display
import edge2torch as e2t
Define an edgelist with sparse edge metadata¶
The following graph has four edges. Each edge demonstrates a different metadata case:
- a positive-constrained edge initialized to
0.25 - a negative-constrained edge initialized to
-0.50 - a fixed edge with value
0.75 - an unconstrained edge with default initialization
edgelist = pd.DataFrame(
{
"source": [
"feature_a",
"feature_b",
"hidden_pos",
"hidden_neg",
],
"target": [
"hidden_pos",
"hidden_neg",
"output",
"output",
],
"initial_weight": [
0.25,
-0.50,
0.75,
np.nan,
],
"constraint": [
"positive",
"negative",
"fixed",
None,
],
}
)
display(edgelist)
| source | target | initial_weight | constraint | |
|---|---|---|---|---|
| 0 | feature_a | hidden_pos | 0.25 | positive |
| 1 | feature_b | hidden_neg | -0.50 | negative |
| 2 | hidden_pos | output | 0.75 | fixed |
| 3 | hidden_neg | output | NaN | None |
Compile the graph¶
The metadata columns are consumed by compile_graph() automatically when they
are present. No extra API arguments are needed.
model, artifact = e2t.compile_graph(
edgelist=edgelist,
backend="state_update",
)
artifact.backend
'state_update'
After compilation, named topology lives on the artifact. Missing constraints in the edgelist mean unconstrained; missing initial weights mean default initialization. The compiled model's execution plan is private.
artifact.interpretation_sites
{'step_1': ['feature_a', 'feature_b', 'hidden_neg', 'hidden_pos', 'output'],
'step_2': ['feature_a', 'feature_b', 'hidden_neg', 'hidden_pos', 'output'],
'step_3': ['feature_a', 'feature_b', 'hidden_neg', 'hidden_pos', 'output']}
Inspect named edge weights¶
The constrained layer stores trainable latent parameters internally and computes
effective weights during the forward pass. edge_weights(model) maps those
values back to the original named graph edges, without inspecting a dense
matrix or depending on the backend.
weights = e2t.edge_weights(model)
display(weights)
def edge_weight(table, source, target):
matched = table[(table["source"] == source) & (table["target"] == target)]
return float(matched["weight"].iloc[0])
| source | target | weight | |
|---|---|---|---|
| 0 | feature_a | hidden_pos | 0.250000 |
| 1 | feature_b | hidden_neg | -0.500000 |
| 2 | hidden_pos | output | 0.750000 |
| 3 | hidden_neg | output | 0.067628 |
The table has one row per original edgelist edge, in the same order, with
columns source, target, and weight. weight is the effective
forward-pass value, including softplus and fixed transforms.
The explicitly initialized edges have the requested effective values:
feature_a -> hidden_posstarts at0.25feature_b -> hidden_negstarts at-0.50hidden_pos -> outputis fixed at0.75
The edge hidden_neg -> output has no initial_weight, so it uses the
default initialization.
Constraints during optimization¶
Positive and negative constraints are enforced by parameterization. The optimizer updates latent parameters, while the effective edge weights are computed as constrained values during the forward pass.
For example:
- positive edges use a positive transform of a trainable latent parameter
- negative edges use the negative of that transform
- fixed edges are stored as non-trainable buffers
The following small optimizer step demonstrates that the constrained and fixed edges keep their intended behavior.
x = torch.tensor(
[
[1.0, 2.0],
[0.5, 1.5],
]
)
fixed_before = edge_weight(weights, "hidden_pos", "output")
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
for _ in range(5):
optimizer.zero_grad()
output = model(x)
loss = output.pow(2).sum()
loss.backward()
optimizer.step()
weights_after = e2t.edge_weights(model)
checks = pd.DataFrame(
{
"source": [
"feature_a",
"feature_b",
"hidden_pos",
],
"target": [
"hidden_pos",
"hidden_neg",
"output",
],
"condition": [
"positive after optimization",
"negative after optimization",
"fixed value preserved",
],
"value": [
edge_weight(weights_after, "feature_a", "hidden_pos"),
edge_weight(weights_after, "feature_b", "hidden_neg"),
edge_weight(weights_after, "hidden_pos", "output"),
],
"passes": [
edge_weight(weights_after, "feature_a", "hidden_pos") > 0,
edge_weight(weights_after, "feature_b", "hidden_neg") < 0,
np.isclose(
edge_weight(weights_after, "hidden_pos", "output"),
fixed_before,
),
],
}
)
display(checks)
| source | target | condition | value | passes | |
|---|---|---|---|---|---|
| 0 | feature_a | hidden_pos | positive after optimization | 0.248085 | True |
| 1 | feature_b | hidden_neg | negative after optimization | -0.501080 | True |
| 2 | hidden_pos | output | fixed value preserved | 0.750000 | True |
Reset behavior¶
Calling reset_parameters() restores explicit edgelist-defined initial weights.
Edges without explicit initial_weight are reinitialized using the default
initializer.
model.state_linear.reset_parameters()
display(e2t.edge_weights(model))
| source | target | weight | |
|---|---|---|---|
| 0 | feature_a | hidden_pos | 0.250000 |
| 1 | feature_b | hidden_neg | -0.500000 |
| 2 | hidden_pos | output | 0.750000 |
| 3 | hidden_neg | output | -0.131157 |
Feedforward skip edges¶
The feedforward backend may internally expand skip edges through pseudo nodes. The edge metadata still belongs to the original logical edge.
For a skipped edge, edge2torch assigns the original edge metadata to the final
internal edge into the original target node. Internal pseudo-routing edges remain
default-initialized and unconstrained because pseudo-node activations are
overwritten as pass-through copies.
edge_weights() reports the original named edges only. Skip-edge weights come
from that last hop; pseudo-node names do not appear in the table.
skip_edgelist = pd.DataFrame(
{
"source": [
"feature_a",
"feature_a",
"hidden",
"middle",
],
"target": [
"hidden",
"output",
"middle",
"output",
],
"initial_weight": [
0.20,
0.75,
0.30,
-0.40,
],
"constraint": [
"positive",
"fixed",
"positive",
"negative",
],
}
)
feedforward_model, feedforward_artifact = e2t.compile_graph(
edgelist=skip_edgelist,
backend="feedforward",
)
display(e2t.edge_weights(feedforward_model))
| source | target | weight | |
|---|---|---|---|
| 0 | feature_a | hidden | 0.20 |
| 1 | feature_a | output | 0.75 |
| 2 | hidden | middle | 0.30 |
| 3 | middle | output | -0.40 |
edge_weights() keeps the original feature_a -> output skip edge and
reports that fixed weight of 0.75. Feedforward skip-edge expansion is
internal; the public table does not include compiler routing edges.
Practical guidance¶
Use initial_weight when a prior value is meaningful or when reproducible
edge-specific initialization matters.
Use constraint when the allowed domain of an edge weight is part of the model
definition. Typical examples include nonnegative effects, nonpositive effects,
or fixed known coefficients.
Do not use positive or negative if the sign is only a weak guess. In that
case, either leave the edge unconstrained or use the value only as an
initial_weight.
Inspect trained edge weights with edge_weights(model). It returns one row per
original named edge and works for both backends, including skip edges and
models wrapped by customize_model().
Edge constraints apply to connection weights before any activation function. If you interpret positive and negative constraints as directional effects, that interpretation is clearest when downstream activations are monotonic.