๐ŸŽ“ A1Learner
๐Ÿ”

PyTorch A Custom Gradient, Checked

A straight-through estimator is a custom function with a forward that rounds and a backward that copies. The copy pretends rounding never happened, so a gradient can flow through a step that has none.

Plain rounding blocks the gradient

Rounding snaps 0.3 down and 0.7 up. Its gradient prints zero for both inputs, so no learning signal survives.

import torch

x = torch.tensor([0.3, 0.7], requires_grad=True)
y = torch.round(x)
print("Rounded:", y.tolist())
y.sum().backward()
print("Gradient from round:", x.grad.tolist())

The estimator passes the gradient through

RoundSTE rounds in forward, exactly like plain rounding. Its backward returns the incoming gradient unchanged, so both inputs receive one.

class RoundSTE(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        return torch.round(x)

    @staticmethod
    def backward(ctx, grad_output):
        return grad_output


x = torch.tensor([0.3, 0.7], requires_grad=True)
y = RoundSTE.apply(x)
print("Rounded:", y.tolist())
y.sum().backward()
print("Gradient through the estimator:", x.grad.tolist())

One step of learning signal

The rounded prediction misses its target of one. The gradient for w still prints minus two, so an optimizer can move w.

w = torch.tensor(0.2, requires_grad=True)
score = w * torch.tensor(1.0)
pred = RoundSTE.apply(score)
loss = (pred - torch.tensor(1.0)) ** 2
loss.backward()
print("Prediction:", pred.item())
print("Loss:", loss.item())
print("Gradient for w:", w.grad.item())

Example

import torch


class RoundSTE(torch.autograd.Function):
    @staticmethod
    def forward(ctx, x):
        return torch.round(x)

    @staticmethod
    def backward(ctx, grad_output):
        return grad_output


torch.manual_seed(0)
X = torch.tensor([-2.0, -1.0, 1.0, 2.0])
targets = torch.tensor([0.0, 0.0, 1.0, 1.0])

w = torch.tensor(0.1, requires_grad=True)
b = torch.tensor(0.0, requires_grad=True)
optimizer = torch.optim.Adam([w, b], lr=0.1)
loss_fn = torch.nn.MSELoss()

for step in range(60):
    raw = w * X + b
    pred = RoundSTE.apply(raw)
    loss = loss_fn(pred, targets)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
    if step in (0, 20, 40, 59):
        print(f"Step {step}: loss {loss.item():.4f}")

with torch.no_grad():
    final_pred = RoundSTE.apply(w * X + b)
print("Learned w:", round(w.item(), 3))
print("Learned b:", round(b.item(), 3))
print("Final predictions:", [int(v) for v in final_pred.tolist()])
print("Targets:", [int(v) for v in targets.tolist()])

Key points

  • Rounding gives a gradient of zero almost everywhere, so plain rounding cannot train
  • The estimator keeps rounding in forward and replaces only the backward rule
  • In backward, the estimator returns the incoming gradient unchanged
  • Threshold model: a model whose answer flips when a score crosses a fixed line
  • In short, the forward rounds honestly and the backward lies usefully

In this example, RoundSTE rounds each raw score in forward and copies the gradient in backward. The data holds four inputs with targets zero, zero, one, and one. The model is one weight and one bias, so each raw score is w times x plus b. Adam trains the pair for 60 steps at rate 0.1. The loss prints 0.5000 at step 0 and 0.2500 at step 20. By step 40 the loss prints 0.0000, and it stays there. The learned w prints 0.239 and the learned b prints 0.471. The final predictions print as 0, 0, 1, 1 and match the targets. Plain rounding could never take these steps, because its gradient is zero.

Note

The training result is the check here, because the estimator gradient is invented by design

Try it Yourself

Edit the code, then press Run.

Running is simulated ยท Python Playground

Exercise

Why can the estimator train when plain rounding cannot?