rml / Losses and optimizers

Losses and optimizers

rml/losses turns a model's output and a target into one differentiable number, and rml/optim turns that number's gradients into parameter updates. Together with the layers and autograd they make rml's training loop.

On this page
  1. One training step
  2. Reductions
  3. Classification losses
  4. Why binary cross-entropy is a hand-written node
  5. Cross-entropy step by step
  6. Regression losses
  7. Pair losses
  8. Variational pieces
  9. The Optimizer base
  10. The optimizers
  11. Learning-rate schedules
  12. Clipping and accumulation
  13. Who uses it
  14. Tests

In short: every loss is built from autograd ops (two hand-written nodes where composing ops would get the gradient wrong) and ends in a shared Reduce step: mean, sum or none. Classification losses take logits, so log-softmax and log-sigmoid never overflow; cross-entropy adds ignored rows and label smoothing. There are regression losses (MSE, MAE, Huber), pair losses (contrastive, InfoNCE) and the two VAE pieces. Optimizers share one base class that skips parameters without a gradient, keeps per-parameter state in named slots for checkpoints, and calls one virtual Update per parameter: SGD with momentum and Nesterov, Adam, AdamW and RMSProp. Schedules are pure functions of the step; clipping and gradient accumulation are small helpers around the same loop.

The concepts are on /ml-models: loss functions, entropy and KL, variational autoencoders, optimizers, learning-rate schedules, the training loop and gradient accumulation.

One training step

The optimizer header gives the loop: ZeroGrad(), forward, loss.Backward(), Step() (optimizer.h L4–9). The optimizer holds the same Variables as the model, which share storage with it, so updating them in place updates the model.

One training step through nn, losses and optim set_lr from a schedule, then ZeroGrad clears the gradients. The model's forward pass produces logits, a loss reduces them against the targets to one number, and Backward fills each parameter's gradient. Optionally ClipGradNorm scales the gradients down. Step then calls Update for every parameter with a gradient, reading and writing its slots such as Adam's exp_avg and exp_avg_sq. With a GradientAccumulator, Backward runs on k micro-batches before one Step. set_lr(schedule(step)) ZeroGrad() model(x): rml/nn loss: rml/losses per element, then Reduce loss.Backward() fills every p.grad() ClipGradNorm optional, returns the norm Step(): ++step_count, then Update(i, param, grad, n) for each parameter with a gradient; state lives in named slots: momentum, exp_avg, exp_avg_sq, square_avg GradientAccumulator: scale each micro-batch's loss by 1/k and run Backward k times (gradients add), then one Step and ZeroGrad.
The pieces of one step and the module each comes from. A checkpoint saves the model's parameters and the optimizer's NamedState together (rml/io/checkpoint.h L4–11).

Reductions

Every loss ends in Reduce(per_element, reduction, count = 0). kMean, the default, averages so the scale ignores batch size; kSum adds; kNone returns the per-element losses. When count is positive, kMean divides the sum by it instead of by the element count, which is how cross-entropy averages over the rows it did not ignore (reduction.cc L8–19).

Classification losses

These take logits, not probabilities, so the log of a softmax or sigmoid is computed in a form that cannot overflow or take log(0) (classification.h L4–8).

ItemType/SignatureMeaningSource
BinaryCrossEntropyWithLogits(logits, Tensor targets, reduction = kMean)−[y log σ(x) + (1 − y) log(1 − σ(x))] per element, targets in [0, 1] shaped like the logits, computed as max(x, 0) − x·y + log(1 + e−|x|).classification.cc L60–66
CrossEntropyWithLogits(logits [..., C], i32 targets [...], CrossEntropyOptions)−log softmax(logits)[target]; every leading axis is a row. kNone returns the targets' shape.L68–109
CrossEntropyOptionsignore_index = −100, label_smoothing = 0, reduction = kMeanRows whose target is ignore_index contribute nothing and are left out of the mean (padding tokens). Smoothing trains against (1 − ε)·one_hot + ε/C, which stops logits growing without bound.classification.h L24–32
HingeLoss(scores, Tensor targets in {−1, +1}, reduction)max(0, 1 − y·s): zero once an example is classified with margin 1, as in a linear SVM.L111–114
KlDivLoss(log_q, Tensor p, reduction)KL(p ‖ q) = Σc pc(log pc − log qc) along the last axis, with q as log-probabilities and 0 log 0 = 0; kMean averages over rows, so the result is a per-example divergence.L116–124

Why binary cross-entropy is a hand-written node

Composing max, abs and log1p from autograd ops gets the gradient wrong at x = 0, where abs has a kink that cancels in the true derivative. So SigmoidCrossEntropy is a Function whose forward loop is the stable formula and whose backward is σ(x) − y directly; the target gets no gradient (L29–56). A test checks that gradient at zero (GradientIsSigmoidMinusTargetIncludingAtZero). How a custom Function plugs into the graph is on Autograd.

Cross-entropy step by step

  1. For each row, check the target is in range or equal to ignore_index, and record the flat index row·C + target (0 for an ignored row) and a keep mask of 1 or 0 (L80–96).
  2. Take LogSoftmax over the class axis, flatten it, and gather the target entries with IndexSelect; negate (L98–101).
  3. With smoothing ε, the loss against the uniform part is −meanc log pc, so each row becomes (1 − ε)·nll − ε·meanc log pc (L102–106).
  4. Multiply by the mask and reduce, passing the number of kept rows (at least 1) as the mean's count (L107–108).

Worked example. Three rows with targets [2, −100, 0]: the middle row gathers index 0, is zeroed by the mask, and the mean divides by 2, not 3. With label smoothing 0.1 and C = 4, the effective target for a row whose label is 2 is 0.025 on classes 0, 1 and 3 and 0.925 on class 2.

Regression losses

ItemPer elementWhySource
MseLoss(prediction, target, reduction)(p − y)²The maximum-likelihood loss under Gaussian noise; large errors dominate.regression.cc L21–23
MaeLoss(prediction, target, reduction)|p − y|Laplace noise: robust to outliers, minimized by the median rather than the mean.L25–27
HuberLoss(prediction, target, delta = 1, reduction)½q² + δ(|r| − q) with q = min(|r|, δ)Quadratic inside δ and linear beyond, with value and slope continuous. δ must be positive.L29–37

Each treats the target as a constant and checks that the shapes match (L12–17). Huber is written with a clamp rather than a branch: for |r| = 3 and δ = 1, q = 1 and the loss is 0.5 + 2 = 2.5, the linear tail; for |r| = 0.5, q = 0.5 and the loss is 0.125, half the squared error.

Pair losses

ItemType/SignatureMeaningSource
ContrastiveLoss(a [N, D], b [N, D], Tensor same [N], margin = 1, reduction)Hadsell, Chopra and LeCun (2006): d² for a matching pair, max(0, margin − d)² otherwise, with d = ‖a − b‖. Dissimilar pairs farther apart than the margin cost nothing.contrastive.cc L30–40
InfoNceLoss(anchors [N, D], positives [N, D], temperature = 0.1)The CLIP and SimCLR objective: L2-normalize both, take the [N, N] cosine similarities divided by the temperature, and apply cross-entropy with target i for row i, so every other row in the batch is a negative.L42–53

Both add 10−12 inside a square root, which keeps the gradient finite for a zero vector or a pair at distance zero (L23–26, L34–35). InfoNCE reuses cross-entropy, so it always takes the mean over the batch.

Variational pieces

A variational autoencoder's encoder outputs a mean and a log variance per latent dimension, defining q(z | x) = N(mean, exp(log_var)); training maximizes the ELBO, a reconstruction term estimated from one sample minus KL(q ‖ N(0, I)) (variational.h L9–14). rml provides the two pieces the autoencoder lacks:

ItemType/SignatureMeaningSource
GaussianKlToStandardNormal(mean, log_var, reduction = kMean)(exp(log_var) + mean² − 1 − log_var) / 2 per element, zero exactly at mean = 0, log_var = 0. For a [batch, latent] input, kSum divided by the batch size is the per-example KL the ELBO subtracts.variational.cc L21–26
ReparameterizedSample(mean, log_var, Generator&)z = mean + exp(log_var / 2)·ε with ε ~ N(0, I) as a constant, so dz/dmean = 1 and dz/dlog_var = (z − mean)/2 and gradients pass through the sampling step.L28–33

The Optimizer base

ItemType/SignatureMeaningSource
ConstructorOptimizer(vector<Variable> params, float lr)Every parameter must be a defined leaf that requires grad, contiguous f32; lr must be non-negative. All are RML_CHECKs.optimizer.cc L18–26
Stepvoid()Increments step_count first, then calls Update for each parameter whose gradient is defined, with a contiguous copy of the gradient; parameters unused in this step's graph are left alone.L28–36
ZeroGrad, lr, set_lr, step_count, paramsSchedules call set_lr before each step.optimizer.h L35–43
AddSlot, State, IsFreshint(string), float*(int, size_t), bool(int, size_t) constA subclass declares named per-parameter buffers; State creates a zero buffer on first use, and IsFresh says whether it has been created yet.L42–55
Updatevirtual void(size_t index, float* param, const float* grad, int64_t n)The one method a subclass writes: update n values in place. step_count() already counts this step.optimizer.h L61–63
NamedStatevector<pair<string, Tensor>>() const"step" as a scalar, then "<slot>.<parameter index>" for every buffer created so far.L57–68
LoadStateStatus(span<const pair<string, Tensor>>)Restores that list, cloning each tensor; an unknown slot, a non-numeric or out-of-range index, or a dtype or shape that does not fit returns InvalidArgumentError.L70–95

NamedState is what a checkpoint stores under optim. keys next to the model's model. keys, so training resumes exactly where it stopped (checkpoint.h L22–32; see Model files). An Adam optimizer over two parameters that has stepped once saves step, exp_avg.0, exp_avg.1, exp_avg_sq.0 and exp_avg_sq.1.

The optimizers

OptimizerOptions (defaults)Update for each elementSlotsSource
Sgdlr 0.01, momentum 0, dampening 0, weight_decay 0, nesterov falseg = grad + wd·p. Without momentum p −= lr·g. With it, b = g on the first step and μb + (1 − dampening)g after; p −= lr·(g + μb) for Nesterov, else lr·b.momentumsgd.cc L14–38
Adamlr 1e-3, beta1 0.9, beta2 0.999, eps 1e-8, weight_decay 0g = grad + wd·p; m = β₁m + (1 − β₁)g; v = β₂v + (1 − β₂)g²; p −= lr·(m / (1 − β₁ᵗ)) / (√(v / (1 − β₂ᵗ)) + eps).exp_avg, exp_avg_sqadam.cc L18–51
AdamWas Adam, weight_decay 0.01p −= lr·wd·p first, then Adam's update on the raw gradient: decay decoupled from the adaptive scaling.as AdamL53–60
RmsProplr 0.01, alpha 0.99, eps 1e-8, weight_decay 0, momentum 0g = grad + wd·p; v = αv + (1 − α)g²; d = g / (√v + eps); with momentum b = μb + d and p −= lr·b, else p −= lr·d.square_avg, momentum (only created when momentum > 0)rmsprop.cc L15–39

The headers say why each exists. Momentum averages gradients over about 1/(1 − momentum) steps, damping oscillation across a ravine and speeding travel along it (sgd.h L7–13). Adam is momentum with a per-parameter step size; its L2 weight decay is weakened by the adaptive scaling for parameters with large gradients, which is why AdamW decouples it and why transformer training uses AdamW (adam.h L7–14). RMSProp divides each gradient by a running RMS so every parameter moves at a similar rate (rmsprop.h L4–11). Constructors check their ranges: Nesterov needs momentum > 0 and no dampening, Adam's betas lie in [0, 1), RMSProp's alpha too, and every eps is positive.

Worked example. Adam's first step: with m and v starting at zero, the corrected m̂ = g and v̂ = g², so the step is lr·g / (|g| + eps), almost exactly lr·sign(g). A parameter at 1 with gradient 100 and another with gradient −0.001 both move by 0.01 at lr = 0.01, to 0.99 and 1.01; that is the test FirstStepHasMagnitudeLr (optimizer_test.cc L84–92).

Learning-rate schedules

Schedules are pure functions of the step, 0 for the first, and the caller writes optimizer.set_lr(Schedule(step, ...)) before each Step() (schedule.h L4–6). Both curved schedules use one helper that moves from a start to an end value along half a cosine (schedule.cc L13–17).

ItemType/SignatureMeaningSource
WarmupCosinefloat(step, base_lr, warmup_steps, total_steps, min_lr = 0)base_lr·(step + 1)/warmup for the first warmup_steps, then a cosine from base_lr to min_lr at total_steps, holding min_lr after. Warmup keeps Adam's first, poorly informed steps small.L21–31
StepDecayfloat(step, base_lr, step_size, gamma = 0.1)base_lr·gamma⌊step / step_size⌋.L33–36
OneCyclefloat(step, max_lr, total_steps, {pct_start 0.3, div_factor 25, final_div_factor 1e4})Smith (2018): cosine up from max_lr/25 to max_lr over the first 30% of steps, then cosine down to max_lr/25/10⁴.L38–48
WarmupCosine and OneCycle learning rates by step Top: WarmupCosine with base rate 1, 10 warmup steps, 110 total steps and a minimum of 0.1. The rate rises linearly from 0.1 to 1 by step 9, then falls along a cosine to 0.1 at step 110 and stays there. Bottom: OneCycle with a maximum of 1 over 100 steps. The rate starts at 0.04, rises along a cosine to 1 at step 29, then falls along a cosine to 0.000004 at step 99. WarmupCosine(step, 1, 10, 110, 0.1) 1 0 0 10 110 step 60: 0.55 holds 0.1 after step 110 OneCycle(step, 1, 100) 1 0 0 29 99 starts at 0.04, peaks at step 29 ends at 4e-6 Curves computed from schedule.cc's formulas with the arguments shown.
The two curved schedules with the arguments their tests use (schedule_test.cc L8–47; the top curve sets min_lr = 0.1, where the test's step-60 check uses 0 and gets 0.5).

Clipping and accumulation

ItemType/SignatureMeaningSource
ClipGradNormfloat(span<const Variable> params, float max_norm)Sums squares over every defined gradient in double, and if the global L2 norm exceeds max_norm, scales all gradients by max_norm / (norm + 10−6), keeping the update's direction. Returns the norm before clipping, worth logging since spikes in it precede divergence.clip.cc L13–28
ClipGradValuevoid(span<const Variable>, float clip)Clamps each gradient element to [−clip, clip], which changes the direction.L30–35
GradientAccumulator(Optimizer&, int micro_batches); loss_scale(), bool Step()Backward adds into grad(), so scaling each micro-batch's loss by 1/k and stepping on every k-th call gives exactly the gradient of the mean loss over all k. Step() counts a micro-batch and, on the k-th, steps the optimizer, zeroes the gradients and returns true.accumulation.h L4–14, .cc L13–19

Who uses it

rml/io's checkpoint is the one library user of rml/optim, through Optimizer* arguments that may be null to save only the model. The training demos in examples/ are the rest: the same nineteen directories that use rml/nn pick their losses and optimizers here. The inference stack never trains, so nothing on The inference stack uses either module.

Tests

FileTestsWhat they pin down
classification_test.cc12BCE against its definition and its gradient σ(x) − y, including at 0; cross-entropy as −log softmax at the target, ignored rows left out of the mean, label smoothing mixing in the uniform target, leading axes as rows, gradients; hinge zero beyond the margin; KL against the distributions module and its gradient; bad targets die.
regression_test.cc6MSE and MAE averages; Huber quadratic inside δ and linear outside, and half the MSE with a large δ; gradients; mismatched shapes die.
contrastive_test.cc4Contrastive loss pulls similar and pushes dissimilar pairs only within the margin; aligned InfoNCE pairs cost less than shuffled ones; gradients of both.
variational_test.cc6The KL against its closed form, non-negative, numerical gradients; the reparameterized sample's mean and variance, and its gradients with a fixed noise draw; mismatched shapes die.
optimizer_test.cc15Plain, momentum, Nesterov and weight-decayed SGD steps; RMSProp's first step; Adam's first step of magnitude lr and agreement with a reference loop; AdamW's decoupled decay; skipped parameters without gradients; every optimizer converging on a quadratic; state round-tripping through NamedState, and LoadState rejecting mismatches; Nesterov without momentum, and parameters that do not require grad, die.
schedule_test.cc6Warmup then cosine, with and without warmup, monotone after warmup; step decay; one-cycle rising to max at step 29 of 100 and ending at max/25/10⁴; bad arguments die.
clip_test.cc4Norm clipping scales every gradient together, leaves small ones alone and skips parameters without a gradient; value clipping clamps each element.
accumulation_test.cc1Micro-batches give the same update as the full batch.