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.
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).
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
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 |
CrossEntropyOptions | ignore_index = −100, label_smoothing = 0, reduction = kMean | Rows 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
- 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). - Take
LogSoftmaxover the class axis, flatten it, and gather the target entries withIndexSelect; negate (L98–101). - With smoothing ε, the loss against the uniform part is −meanc log pc, so each row becomes (1 − ε)·nll − ε·meanc log pc (L102–106).
- 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
| Item | Per element | Why | Source |
|---|---|---|---|
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
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
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:
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
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
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
| Constructor | Optimizer(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 |
Step | void() | 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, params | Schedules call set_lr before each step. | optimizer.h L35–43 | |
AddSlot, State, IsFresh | int(string), float*(int, size_t), bool(int, size_t) const | A 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 |
Update | virtual 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 |
NamedState | vector<pair<string, Tensor>>() const | "step" as a scalar, then "<slot>.<parameter index>" for every buffer created so far. | L57–68 |
LoadState | Status(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
| Optimizer | Options (defaults) | Update for each element | Slots | Source |
|---|---|---|---|---|
Sgd | lr 0.01, momentum 0, dampening 0, weight_decay 0, nesterov false | g = 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. | momentum | sgd.cc L14–38 |
Adam | lr 1e-3, beta1 0.9, beta2 0.999, eps 1e-8, weight_decay 0 | g = grad + wd·p; m = β₁m + (1 − β₁)g; v = β₂v + (1 − β₂)g²; p −= lr·(m / (1 − β₁ᵗ)) / (√(v / (1 − β₂ᵗ)) + eps). | exp_avg, exp_avg_sq | adam.cc L18–51 |
AdamW | as Adam, weight_decay 0.01 | p −= lr·wd·p first, then Adam's update on the raw gradient: decay decoupled from the adaptive scaling. | as Adam | L53–60 |
RmsProp | lr 0.01, alpha 0.99, eps 1e-8, weight_decay 0, momentum 0 | g = 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).
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
WarmupCosine | float(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 |
StepDecay | float(step, base_lr, step_size, gamma = 0.1) | base_lr·gamma⌊step / step_size⌋. | L33–36 |
OneCycle | float(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 |
Clipping and accumulation
| Item | Type/Signature | Meaning | Source |
|---|---|---|---|
ClipGradNorm | float(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 |
ClipGradValue | void(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
| File | Tests | What they pin down |
|---|---|---|
classification_test.cc | 12 | BCE 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.cc | 6 | MSE and MAE averages; Huber quadratic inside δ and linear outside, and half the MSE with a large δ; gradients; mismatched shapes die. |
contrastive_test.cc | 4 | Contrastive loss pulls similar and pushes dissimilar pairs only within the margin; aligned InfoNCE pairs cost less than shuffled ones; gradients of both. |
variational_test.cc | 6 | The 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.cc | 15 | Plain, 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.cc | 6 | Warmup 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.cc | 4 | Norm clipping scales every gradient together, leaves small ones alone and skips parameters without a gradient; value clipping clamps each element. |
accumulation_test.cc | 1 | Micro-batches give the same update as the full batch. |