tethergate / TECHNICAL.md
JakCwi's picture
Initial release: code, docs, tests (CC BY-NC 4.0)
d7070c6
|
Raw History Blame Contribute Delete
4.04 kB
# TetherGate — how it works
## The problem
A learned gate scales a residual branch: `out = x + g · branch(x)`. When the loss does not pay for keeping the branch open — it hurts at initialisation, or its benefit comes later — the optimizer slowly pushes `g` to zero and the branch stops contributing. Two drifts cause it:
1. early in training, the control signal `s` drifts below a threshold `theta` that has not moved yet;
2. later, the threshold itself drifts upwards, because the loss-only equilibrium moves towards "closed".
## The mechanism
**Forward** (`TetherGate.forward`, differentiable):
```
g_raw = sigmoid( clamp( beta · (s − theta), arg_min, +8 ) )
g = F + (1 − F) · g_raw
```
- `beta` is fixed; `theta` is the only learned parameter.
- `F` (buffer `floor`) is an affine floor: with `F = 1` the gate is fully open ("asleep") and theta receives no loss gradient; as `F` decays, `g_raw` takes over. Forward and backward use the same formula — the floor is not a backward-only trick.
- In `train()` mode the module updates `ema_s` (buffer) with the mean of `s` (`no_grad`, no Python branching on tensors, so `torch.compile` can trace the forward). The first observation initialises the EMA (it starts as NaN).
- With `detach_signal=True` (default) `s` is detached: the branch cannot learn to steer its own gate.
**Gradient and clamp.** `d g / d theta = −(1 − F) · beta · σ'(a)` for `a = beta·(s − theta)` inside [`arg_min`, +8], and exactly 0 outside it (the clamp passes no gradient). Inside the range the gradient stays alive even close to the floor; outside it the only force on theta is the spring.
**After each optimizer step** (`tethergate_step`, `no_grad`, outside the graph):
```
F = floor_schedule(step) # 1 until ramp_start, then exp or cos decay to f_min
theta* = EMA(s) − logit(g_target) / beta # relative anchor (per gate, or the mean over gates)
k = kappa_sleep if F == 1 else lr · wd
theta ← theta − k · (theta − theta*)
```
- The anchor is **relative**: it follows the signal's own drift, so the spring removes only the common one-sided drift; the gate's mean opening settles near `g_target` regardless of the learning rate (the LR sets the time constant), while the per-token selectivity driven by `s` remains.
- `anchor='global'`: one `theta*` = mean over all gates (gates may differentiate around it). `anchor='local'`: each gate to its own `theta*`.
- Gates whose EMA is still NaN are skipped (also for the floor update).
## Choosing `wd`
Under a steady closing push the optimizer moves theta by about `lr` per step (Adam), while the spring pulls it back by `lr · wd · (theta − theta*)`. Theta then settles about `1/wd` from its anchor, whatever the LR, and the gate argument shifts by about `beta / wd`. With the default `wd = 20` and `beta = 6` that is 0.3: the gate stays near `g_target`. With `wd = 0.5` a sustained push could shift it by 12, i.e. towards the floor; our long production run used `wd = 0.5`, which gives the loss much more freedom — with a small `wd`, monitor the mean gate opening. Stability requires `lr · wd ≪ 1`.
## Floor schedule
- `mode='exp'`: `F = f_min + (1 − f_min) · exp(−(step − ramp_start)/tau)` — approaches `f_min` asymptotically.
- `mode='cos'`: cosine from 1 at `ramp_start` to `f_min` at `ramp_end`.
- Driven by the training step only, never by the loss.
## State, determinism, cost
- State: `theta` (parameter), `floor`, `ema_s` (buffers, saved in `state_dict`).
- Deterministic given the same inputs (no randomness in the module).
- Cost: one sigmoid per gate element; `tethergate_step` reads each EMA as a Python float (one device sync per gate per step).
## Tests
`test_tethergate.py` checks: the forward floor; a live gradient below the floor; zero gradient beyond the clamp and recovery through the spring; `detach_signal`; EMA updates only in `train()`; the spring reaching its target; an equilibrium independent of the LR; global vs local anchor.