🔥 4. Learn in a Node¶
This page writes a node that learns with backpropagation (BP) inside itself, from a stream whose target changes while it runs. There is no training phase and no inference phase: the node predicts and learns from each new batch as it comes.
The program¶
A teacher node sends a batch from \(y = a x + b\) every 10 ms, with the a the host writes; the host changes a while the network runs. The learner follows its loss: it falls, jumps when the target changes, and falls again.
t = 0.48 s: loss = 0.000090 (learned a = 2.0)
t = 0.49 s: loss = 3.405589 (a changed at 0.48 s)
t = 0.97 s: loss = 0.000095 (learned a = -1.0)
the learner's weight = -0.988, bias = 0.498
The learner¶
class Learner(Node):
"""Follows the gradient flow dW/dt = -rate * dL/dW, learning once from each
new batch for the time since the last one, and sends its loss on each batch
before learning from it."""
MAX_STEP = 0.01 # seconds: a longer explicit Euler step could diverge
def __init__(self, rate: float) -> None:
# Updating more often than the teacher sends would only re-read its batch.
super().__init__(period=0.005, seed=0)
self.rate = rate # a setting, not state
self.model = nn.Linear(1, 1) # has state_dict: in the state by itself
# seed reproduces only self.generator: initialize the module from it.
nn.init.normal_(self.model.weight, std=0.1, generator=self.generator)
nn.init.zeros_(self.model.bias)
self.last = -1 # the time (ns) of the batch learned from last
self.elapsed = 0.0 # seconds since then
self.register_state("last") # plain values that change: register them
self.register_state("elapsed")
self.prev = Port(recv=BATCH_SPEC)
self.loss = Port(send=SCALAR, initial=torch.tensor(math.inf)) # no batch yet
@override
def on_update(self, dt: float) -> None:
super().on_update(dt)
self.elapsed += dt
batch, t = self.prev.recv_with_time()
if t is None or t.ns == self.last: # nothing sent yet, or nothing new
return
self.last = t.ns
x, y = batch["x"], batch["y"]
with torch.no_grad(): # the loss on an unseen batch needs no graph
self.loss.send(nn.functional.mse_loss(self.model(x), y))
# How far to learn is set by the time, not by the number of batches: move
# by the elapsed time, in n equal steps no longer than MAX_STEP.
n = max(1, math.ceil(self.elapsed / self.MAX_STEP))
for _ in range(n):
loss = nn.functional.mse_loss(self.model(x), y) # records the graph
loss.backward()
with torch.no_grad(): # the step itself is not part of any graph
for p in self.model.parameters():
assert p.grad is not None
p -= self.elapsed / n * self.rate * p.grad
p.grad = None
self.elapsed = 0.0
- The module is state.
self.modelis amujou.Stateful, as everynn.Moduleis, so it is in the node's state by itself, as a tensor attribute is - The seed.
seed=0seedsself.generator, the node's own random generator. The default initialization ofnn.Lineardraws from the global generator that every node shares, so initialize the weights again fromself.generator - Plain values join the state with
register_state.lastandelapsedchange while the node runs, but they are neither tensors nor modules: registered, they are saved and restored with the rest - New or not.
recv_with_time()returns the batch with the time it was sent. Its time isNoneuntil the teacher first sends, and it stays the same until the teacher sends again, so the learner learns once from each batch - Gradients as in plain PyTorch. The hooks record gradients as plain PyTorch does:
loss.backward()fillsp.grad, and the parameters are updated in place insidetorch.no_grad(). Values computed only to send (the loss before learning) need no graph either - By the time, not by the batch. The learner follows the gradient flow \(\frac{dW}{dt} = -\text{rate}\,\frac{\partial L}{\partial W}\) over the time since it last learned (
elapsed), not a fixed step per batch: how far it learns depends on time, not on how often it runs. A longelapsedis split into steps no longer thanMAX_STEP, so that explicit Euler does not diverge
BP stays inside the node. A port sends a copy, detached from the graph, so no gradient crosses nodes.
Settings of torch¶
# The nodes compute on the run's worker threads, and torch adds threads of its
# own for each: keep those few (a setting of the whole process, made by the host).
torch.set_num_threads(1)
The host sets the settings of the whole process, such as the number of torch's threads, before the run. A node changes only settings of its own thread, and sets them back before its hook returns (see Threads and Devices).
The run¶
teacher, learner = Teacher(), Learner(rate=5.0)
teacher.next.connect(learner.prev)
net = mujou.Group(teacher=teacher, learner=learner)
with mujou.run(net, inputs={teacher.a: torch.tensor(A_BEFORE)}) as run:
# How fast the learner learns depends on time, not on how often it runs:
# follow its loss, giving each phase up to 5 s of real time.
loss, t = run.wait_for(learner.loss, learned, timeout=5.0)
assert t is not None # the loss on a batch, not the initial inf
print(f"t = {float(t):.2f} s: loss = {loss.item():.6f} (learned a = {A_BEFORE})")
# The world changes: the same learner keeps learning, with no new phase.
changed_at = run.write({teacher.a: torch.tensor(A_AFTER)})
loss, t = run.wait_for(learner.loss, lambda loss: loss.item() > 1e-2, timeout=5.0)
assert t is not None
print(
f"t = {float(t):.2f} s: loss = {loss.item():.6f} (a changed at {float(changed_at):.2f} s)"
)
loss, t = run.wait_for(learner.loss, learned, timeout=5.0)
assert t is not None
print(f"t = {float(t):.2f} s: loss = {loss.item():.6f} (learned a = {A_AFTER})")
# After the run, the nodes' attributes are the host's to read.
weight, bias = learner.model.weight.item(), learner.model.bias.item()
print(f"the learner's weight = {weight:.3f}, bias = {bias:.3f}")
run.wait_for follows the loss port until the condition holds. After the run, the nodes' attributes are the host's to read: learner.model is the module the learner learned.
Next¶
- A rule that learns across nodes, with a target that travels along the path: Update Rules
- PC-ALM, the nodes of
mujou.pc: 5. Learn with PC-ALM