Skip to content

07 — A real architecture, and three things wrong with it

Three modalities, three kinds of network, one head. Convolutions with residuals, a transformer stack, a recurrent cell, and a bottleneck where they meet — some of it inside one node and some of it across several, which is the choice a graph exists to let you make.

Every section has the same four parts, and the fourth is the one the other notebooks were missing:

  1. the problematic architecture and configuration
  2. the symptoms, read off what the framework measured
  3. the solution, and why that one
  4. the healthy architecture and configuration, run and shown to be healthy
import torch
import somatize.torch # noqa: F401
from somatize import Graph, Node, Opaque, Recorder, Store
from somatize.health import alerts, diagnose, overlaid, profile, seen
from somatize.record import gantt, progress
from somatize.torch import Audit, Trainer, architecture, parameters
torch.manual_seed(0)
store = Store(__import__("tempfile").mkdtemp())
DIM, STEPS, VOCAB, ROWS, OUT = 24, 16, 60, 64, 4

Inside one node: the convolutional trunk is a stem plus three residual blocks, all of it audio’s business. Nobody outside it needs to know.

Across nodes: the three modalities are three nodes, because they are three things that can be placed, cached, sent to another machine and diagnosed apart. That is what a node is for.

class Residual(torch.nn.Module):
"""Norm, non-linearity, convolution, and the input added back on."""
def __init__(self, channels, norm="batch"):
super().__init__()
self.norm = (
torch.nn.BatchNorm1d(channels) if norm == "batch" else torch.nn.Identity()
)
self.act = torch.nn.GELU()
self.conv = torch.nn.Conv1d(channels, channels, 3, padding=1)
def forward(self, x):
return x + self.conv(self.act(self.norm(x)))
class Audio(Node):
"""A convolutional trunk — a whole architecture inside one node.
`keeps` is how the trunk turns a sequence into a vector, which is the one
architectural decision in here that throws information away.
"""
def __init__(self, channels=DIM, blocks=3, norm="batch", keeps="mean"):
self.stem = torch.nn.Conv1d(1, channels, 5, padding=2)
self.body = torch.nn.Sequential(*[Residual(channels, norm) for _ in range(blocks)])
self.keeps = keeps
def forward(self, said, ctx):
h = self.body(self.stem(said["audio"]))
if self.keeps == "mean":
return Opaque(h.mean(-1))
return Opaque(torch.cat([h.mean(-1), h.max(-1).values], dim=1))
def parameters(self):
return list(self.stem.parameters()) + list(self.body.parameters())
class Text(Node):
"""An embedding and a stack of transformer layers."""
def __init__(self, dim=DIM, layers=4):
self.emb = torch.nn.Embedding(VOCAB, dim)
self.body = torch.nn.TransformerEncoder(
torch.nn.TransformerEncoderLayer(dim, 4, 48, batch_first=True), num_layers=layers
)
def forward(self, said, ctx):
return Opaque(self.body(self.emb(said["text"])).mean(1))
def parameters(self):
return list(self.emb.parameters()) + list(self.body.parameters())
class Vitals(Node):
"""A recurrent cell over a short series."""
def __init__(self, dim=DIM, kind="gru"):
self.cell = (torch.nn.GRU if kind == "gru" else torch.nn.RNN)(
4, dim, batch_first=True
)
def forward(self, said, ctx):
return Opaque(self.cell(said["vitals"])[0][:, -1])
def parameters(self):
return list(self.cell.parameters())
class Fuse(Node):
"""Where the three meet, through a bottleneck."""
def __init__(self, wide=3 * DIM, squeeze=16):
self.down = torch.nn.Linear(wide, squeeze)
self.act = torch.nn.GELU()
self.up = torch.nn.Linear(squeeze, OUT)
def forward(self, said, ctx):
return Opaque(self.up(self.act(self.down(torch.cat(list(said.values()), dim=1)))))
def parameters(self):
return list(self.down.parameters()) + list(self.up.parameters())
def built(*, blocks=3, layers=4, squeeze=16, keeps="mean+max"):
wide = (2 * DIM if keeps != "mean" else DIM) + 2 * DIM
return Graph.somatize(
(
Audio(blocks=blocks, keeps=keeps).named("audio")
| Text(layers=layers).named("text")
| Vitals().named("vitals")
)
>> Fuse(wide=wide, squeeze=squeeze).named("fuse")
)

Three channels about a person and four things to predict. Two of the four are in the shape of the audio — its first half against its second — and not in its level. That distinction is the whole of Problem 1: a trunk that reduces a sequence to its mean has thrown those two away before anything downstream ever sees them.

def people(how_many=6, seed=0):
torch.manual_seed(seed)
made = []
for _ in range(how_many):
said = {
"audio": torch.randn(ROWS, 1, 32),
"text": torch.randint(0, VOCAB, (ROWS, STEPS)),
"vitals": torch.randn(ROWS, STEPS, 4),
}
shape = said["audio"][:, 0, :16].mean(1) - said["audio"][:, 0, 16:].mean(1)
made.append(
(
{one: Opaque(what) for one, what in said.items()},
torch.stack(
[
3.0 * shape,
3.0 * shape.abs(),
0.5 * said["vitals"].mean((1, 2)),
0.5 * (said["text"].float().mean(1) / VOCAB),
],
dim=1,
),
)
)
return made
DATA = people()
ONE = DATA[0][0]
def scored(g, data=None):
with torch.no_grad():
data = data or DATA
return sum(
float(torch.nn.functional.mse_loss(g.forward(one), y)) for one, y in data
) / len(data)
def fit(g, run, *, steps=200, lr=3e-4, auditing=True, data=None):
t = Trainer(
g,
objective=torch.nn.functional.mse_loss,
optimizer=torch.optim.Adam(parameters(g), lr=lr),
auditing=Audit(inside=True, every=4) if auditing else None,
watching=Recorder(store, run=run, summarising=["loss"]),
)
data = data or DATA
for which in range(steps):
t.step(data[which % len(data)])
return g

The architecture inside each node, traced by running the graph once. A skip connection is an edge, a repeated block is a ×N, a composite everybody recognises is one box, and the shape on every layer is what makes the bottleneck visible.

healthy = built()
architecture(healthy, ONE)
{'audio': Inside(6 layers, 6 edges, traced),
'text': Inside(2 layers, 1 edges, traced),
'vitals': Inside(1 layers, 0 edges, traced),
'fuse': Inside(3 layers, 2 edges, traced)}
healthy.figure(inside=architecture(healthy, ONE))

Figure from cell 8

Three branches side by side inside a wave — they run at once — and the bottleneck at the join.

Every kind has its own silhouette, because a Linear, a convolution, a recurrent cell and a non-linearity are four different kinds of thing and four identical rectangles with different words in them make the reader do the sorting a picture was supposed to have done:

drawn as
a convolutiona parallelogram — a window sliding along
a recurrent cella box with a tab: it feeds itself
an attention blocka box with its corners cut, and a line saying what is in it
a normalisationa capsule: no capacity, and thin
a non-linearitypointed at both ends: nothing lives in it
anything that changes the widthtapered the way it really goes

That last one is what makes a bottleneck look like one instead of like three boxes with different numbers written on them.

And every number says what it is. 4 batch · 16 steps · 24 dim is a shape somebody can read; 4×16×24 is three numbers and a puzzle. The batch is the one that can be checked rather than assumed — the caller knows how many rows went in — and a layer that did not change the shape keeps the names of the one that did, so a BatchNorm1d in a convolutional trunk says ch and len and not steps and dim.

A TransformerEncoderLayer is one box because read as its fourteen leaves it is fourteen things. When the inside of one is the question, depth= opens it — and depth counts composites opened rather than names, because that layer sits three names deep inside a TransformerEncoder and asking for one level of detail should not have to know that.

healthy.figure(inside=architecture(healthy, ONE, depth=1))

Figure from cell 11


The problematic architecture and configuration

Section titled “The problematic architecture and configuration”

Three branches of twenty-four each, meeting through two numbers.

pinched = built(squeeze=2)
fit(pinched, "1-problem")
print("loss :", round(scored(pinched), 4))
print("flags:", diagnose(store, run="1-problem") or "nothing tripped")
loss : 0.3539
flags: nothing tripped

No flag at all. Every gradient arrives, nothing is dead, nothing saturates. The network is in perfect health and it is costing about a quarter of the loss, which is exactly the kind of thing no health check is looking for. The diagnosis is in the picture:

pinched.figure(inside=architecture(pinched, ONE))

Figure from cell 15

72 → 2 → 4. Everything the convolutional trunk, the transformer and the recurrent cell computed has to pass through two numbers. The shapes on the layers are what say so, which is why they are written on the box.

Widen the join to something the three branches can get through. Nothing else changes — not the trunk, not the stack, not the cell.

The healthy architecture and configuration

Section titled “The healthy architecture and configuration”
opened = built(squeeze=16)
fit(opened, "1-healthy")
print(f"squeezed to two : {scored(pinched):.4f}")
print(f"squeezed to sixteen : {scored(opened):.4f}")
print("flags:", diagnose(store, run="1-healthy") or "nothing tripped")
squeezed to two : 0.3542
squeezed to sixteen : 0.3079
flags: nothing tripped
opened.figure(inside=architecture(opened, ONE))

Figure from cell 18


The problematic architecture and configuration

Section titled “The problematic architecture and configuration”

The architecture from above, trained a thousand times faster.

hot = built()
fit(hot, "2-problem", lr=0.3)
print("loss:", round(scored(hot), 4))
loss: 0.4227
alerts(store, run="2-problem")
2-problem — 35 finding(s)
⚠ VANISHING
audio
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.stem
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.stem
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.0.norm
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.0.norm
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.0.conv
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.0.conv
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.1.norm
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.1.norm
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.1.conv
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.1.conv
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.2.norm
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.2.norm
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
audio.body.2.conv
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
audio.body.2.conv
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text.emb
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text.emb
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text.body.layers.0
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text.body.layers.0
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text.body.layers.1
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text.body.layers.1
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text.body.layers.2
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text.body.layers.2
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
text.body.layers.3
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
text.body.layers.3
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
vitals
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
vitals
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
vitals.cell
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ STALLED
vitals.cell
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it
⚠ VANISHING
fuse.down
this node is barely being trained — look at the depth profile, not at this node alone: it is the early layers that go quiet first
⚠ SATURATED
fuse.down
most of the output is pinned where the derivative is nothing
⚠ STALLED
fuse.down
the update is tiny next to the weights; the rate is too low for this node, or nothing is reaching it

Not one flag: a wreck, and in several places at once. Where is the question a graph of four nodes actually raises, and the graph answers it:

overlaid(hot, store, run="2-problem", inside=architecture(hot, ONE))

Figure from cell 24

The rate. What makes that a reading rather than a guess is that the same number — the update against the weights it moves — says STALLED at the other end and sits near 1e-3 when a run is healthy.

The healthy architecture and configuration

Section titled “The healthy architecture and configuration”
cooled = built()
fit(cooled, "2-healthy")
print(f"at 0.3 : {scored(hot):.4f} in {len(diagnose(store, run='2-problem'))} places")
print(f"at 3e-4 : {scored(cooled):.4f} {diagnose(store, run='2-healthy') or 'nothing tripped'}")
profile(store, run="2-healthy", of="update_ratio")
at 0.3 : 0.4227 in 17 places
at 3e-4 : 0.3167 nothing tripped

Figure from cell 26


The problematic architecture and configuration

Section titled “The problematic architecture and configuration”

The healthy architecture, on the data as it stands. It trains, it is healthy, and there are three branches in it.

from somatize.data import contribution, leaned, leaning, shares # noqa: E402
said = contribution(cooled, DATA, objective=torch.nn.functional.mse_loss)
leaned(said)

Figure from cell 28

IGNORED_INPUT(vitals): the recurrent branch is worth about two per cent of what matters. It is being trained, it has no flags, and the model would score the same if it were not there.

print("health:", diagnose(store, run="2-healthy") or "nothing tripped")
print("data :", leaning(said))
print("shares:", {one: f"{share:.0%}" for one, share in shares(said).items()})
health: nothing tripped
data : {'vitals': ['IGNORED_INPUT(vitals)']}
shares: {'audio': '69%', 'text': '30%', 'vitals': '2%'}

Not in the network. There are three honest possibilities and only one of them is code:

  1. the vitals do not carry the answer — a finding about the data, and the end of a question rather than a bug;
  2. they carry it and the cell cannot reach it — which the health checks would have shown, and do not;
  3. another branch is a shortcut that makes the vitals unnecessary.

The first is testable in one line: put a signal in the vitals that is really there, and see whether the same architecture picks it up.

The healthy architecture and configuration

Section titled “The healthy architecture and configuration”
def rebalanced(how_many=6, seed=0):
"""The same data with two of the four answers genuinely in the vitals."""
torch.manual_seed(seed)
made = []
for _ in range(how_many):
said = {
"audio": torch.randn(ROWS, 1, 32),
"text": torch.randint(0, VOCAB, (ROWS, STEPS)),
"vitals": torch.randn(ROWS, STEPS, 4),
}
shape = said["audio"][:, 0, :16].mean(1) - said["audio"][:, 0, 16:].mean(1)
made.append(
(
{one: Opaque(what) for one, what in said.items()},
torch.stack(
[
3.0 * shape,
3.0 * shape.abs(),
3.0 * said["vitals"].mean((1, 2)),
3.0 * said["vitals"].std((1, 2)),
],
dim=1,
),
)
)
return made
REAL = rebalanced()
again = built()
fit(again, "3-healthy", data=REAL)
now = contribution(again, REAL, objective=torch.nn.functional.mse_loss)
print("was:", leaning(said), {one: f"{s:.0%}" for one, s in shares(said).items()})
print("now:", leaning(now) or "nothing tripped", {one: f"{s:.0%}" for one, s in shares(now).items()})
was: {'vitals': ['IGNORED_INPUT(vitals)']} {'audio': '69%', 'text': '30%', 'vitals': '2%'}
now: nothing tripped {'audio': '78%', 'text': '5%', 'vitals': '17%'}
leaned(now)

Figure from cell 33

The same architecture, the same optimizer, the same rate. What changed is the data — and that is the answer, which no amount of looking at the network was ever going to give.


Both were tried and neither is a section above, which is worth writing down rather than quietly replacing with an example that works.

A plain RNN in place of the GRU, over sixty-four steps with the answer in the first three. It scored 0.0623 against the gated cell’s 0.0592 and raised no flag at all — the gradient through the plain cell was 5.2e-2 against 6.3e-2. Not a difference anybody should act on.

Dropping the normalisation from the residual trunk. The un-normalised version scored better — 0.0050 against 0.0103 — and its activations grew less through the stack. Three blocks is not deep enough for it to matter, and saying it does anyway would be a lesson about somebody else’s network.

A framework that always finds something has stopped being evidence. What these two say is at this size, on this data, it made no difference, and that is an answer.

progress(store, run="2-healthy")

Figure from cell 37

gantt(store, run="2-healthy", forward=0)

Figure from cell 38

Three branches starting together and ending apart — that is the wave, and the one that finishes last is the only one worth making faster.

overlaid(cooled, store, run="2-healthy", inside=architecture(cooled, ONE))

Figure from cell 40

No outline is red. That is what the end of one of these cycles looks like.