Optimizers¶
Opake ships its own functional optimizer library at
opake.optimizers: Opake-built factories with a
common wrapper-aware update surface (sgd, adam, adamw, radam, lion,
ademamix, adafactor, rmsprop, adagrad, adadelta, schedule_free).
Every factory carries DP-aware behaviour selectable at construction time and
activated by the metadata wrappers (NoisedPytree, SecondMomentNoiseOutput)
landing in update().
All factories return TorchOpt
GradientTransformations, so they compose with TorchOpt's lower-level
transforms and torchopt.apply_updates. DP-aware paths (DP-AdamW-BC, private
second moments, Adagrad's mandatory variance subtraction) are selected by
passing NoisedPytree or SecondMomentNoiseOutput updates.
Why functional optimizers¶
Opake's gradient pipeline is functional: every function takes state in and returns new state out. The optimizer factories follow the same pattern:
import torchopt
from opake.optimizers import adamw
optimizer = adamw(lr=1e-3, weight_decay=0.01)
opt_state = optimizer.init(params)
# Explicit state in -> state out
updates, opt_state = optimizer.update(noisy_grads, opt_state, params=params)
params = torchopt.apply_updates(params, updates)
No hidden mutable state. Every piece of the training loop is explicit.
Complete training loop¶
import torchopt
from opake.dpsgd.clipping import clipped_grad
from opake.dpsgd.noise import gaussian_noise
from opake.optimizers import adamw
from opake.random import key
# Gradient pipeline
grad_fn, clip_state = clipped_grad(
loss_fn, clipping_norm=1.0, argnums=0, batch_argnums=1,
)
noise_fn, noise_state = gaussian_noise(noise_multiplier=noise_multiplier, key=key(42))
# DP-aware AdamW; pass `noise_bias_correction=True` to enable the
# φ-EMA subtraction once the LR is tuned for the workload.
optimizer = adamw(lr=1e-3, weight_decay=0.01)
opt_state = optimizer.init(params)
for batch in dataloader:
grads, clip_state = grad_fn(params, batch, state=clip_state)
noisy_grads, noise_state = noise_fn(grads, noise_state)
updates, opt_state = optimizer.update(noisy_grads, opt_state, params=params)
params = torchopt.apply_updates(params, updates)
Choosing an optimizer¶
Adafactor (adafactor) is the recommended default for DP training.
Its per-tensor relative-step normalization (factored
v_row × v_col) auto-scales effective learning rates per parameter
group, which makes it both LR-robust and naturally resistant to
DP noise inflation of the second moment (see Choosing from measurements
below). Pass NoisedPytree updates from a DP noise mechanism:
from opake.optimizers import adafactor
optimizer = adafactor(lr=5e-4, weight_decay=0.01)
# `update_rms_clip=1.0` (the default) clips one model-wide/global RMS,
# rather than clipping each parameter leaf independently.
AdamW (adamw) is a good alternative when you want first-moment
momentum, but is more LR-sensitive than Adafactor: tuning matters.
Use noise_bias_correction=True only when the LR has been tuned for
the workload (see Choosing from measurements):
from opake.optimizers import adamw
optimizer = adamw(lr=1.5e-4, weight_decay=0.01)
# Plain Adam (no decoupled WD): adamw(..., decoupled_weight_decay=False).
# StableAdamW (model-wide/global RMS-clipped update, not per-leaf):
# adamw(..., update_rms_clip=1.0).
SGD (sgd) is the canonical DP baseline. No second
moment is corrected, but the Opake wrapper accepts NoisedPytree updates so
the training loop stays uniform. E[g + ξ] = g and momentum's variance is
bounded. Good debugging baseline:
RMSprop (rmsprop) is adaptive but cheaper
than Adam (no first moment). noise_bias_correction=True enables
the same flavor of φ-EMA subtraction as AdamW; it is off by default.
Enable it to ablate:
from opake.optimizers import rmsprop
optimizer = rmsprop(lr=1e-2, alpha=0.99, noise_bias_correction=True)
Adagrad (adagrad) is for sparse-gradient
settings. Its accumulator does not decay, so under DP noise v_acc
absorbs t·σ² over training; noise_bias_correction=True
subtracts a matching cumulative term:
Whether the correction helps in practice depends on the workload —
ablate against noise_bias_correction=False.
AdEMAMix, Adafactor, Lion, schedule-free — see the API reference for their DP modes.
The second-moment problem in DP training¶
Adam-style optimizers compute a second moment as \(v_t = \beta_2 v_{t-1} + (1-\beta_2) g_t^2\). Under DP, the optimizer sees noised gradients \(\tilde{g}_t = g_t + \xi_t\), so:
The \(\xi_t^2\) term inflates the denominator, shrinking effective learning rates. The \(2g_t \xi_t\) cross-term averages out but adds variance. Gaussian noise with variance \(\sigma^2\) adds an expected bias of \(\sigma^2\) to every component of \(\hat{v}_t\).
Opake provides two independent corrections, both selected at
update() time. They address the same problem from different
angles and must not be combined in the same call — using both
would double-correct the second moment.
NoisedPytree: bias correction by variance subtraction¶
Idea: the noise variance \(\Phi_t = \sigma_t^2\) is known (we chose it), so we can subtract it from the biased estimate.
For Adam-family optimizers (adamw, ademamix):
This is Algorithm 2 from Chooi et al. (arXiv:2511.07843). Cheap (one extra scalar EMA), no changes to the noise mechanism, works with any i.i.d. Gaussian noise source.
For rmsprop: same idea, but \(\phi\) accumulates with the same EMA
decay \(\alpha\) as \(v\) (and there's no (1−α^t) divide because
RMSprop doesn't bias-correct \(v\) either). Subtracting \(\phi\) from
\(v\) directly gives the unbiased estimate.
For adagrad: cumulative \(\Phi_\text{acc} = \sum_s \sigma_s^2\)
(no decay) matches the cumulative \(v_\text{acc}\). Mandatory for
DP-Adagrad — without it, the denominator runs away with \(t \cdot \sigma^2\)
of accumulated noise variance and learning halts.
When a raw pytree update is passed, each optimizer reduces to its standard
math. NoisedPytree updates supply the realized per-step σ metadata.
from opake.optimizers import adamw
# Without correction — standard AdamW math.
optimizer = adamw(lr=1e-3, weight_decay=0.01)
# With correction — pass NoisedPytree updates from gaussian_noise().
optimizer = adamw(lr=1e-3, weight_decay=0.01, noise_bias_correction=True)
updates, opt_state = optimizer.update(noisy_grads, opt_state, params=params)
noisy_squared_grads: privately-estimated second moments¶
Idea: instead of squaring the noisy gradient (which amplifies noise), use a separately privatized estimate of \(g_t^2\) from a second noise stream.
Private second-moment estimation maintains two independent correlated noise streams — one for \(g_t\) (first moment) and one for \(g_t^2\) (second moment). The optimizer receives both and uses each for its respective EMA:
The extra stream has a configuration-dependent privacy cost; calibrate the complete mechanism.
This mode requires an MF noise mechanism with
mf_gaussian_noise(..., second_moment_strategy=...), so it
applies to DP-FTRL training, not standard DP-SGD with i.i.d. Gaussian
noise.
Choosing from measurements¶
No reproducible cross-workload benchmark is available. Tune the optimizer, learning rate, schedule, and bias correction together.
When to use which¶
The bias-correction (BC) and private-second-moment paths target the same v-update bias by different means; they are alternatives. BC default is off — turn it on once you've tuned LR; see Choosing from measurements above.
| Scenario | Optimizer | Notes |
|---|---|---|
| DP-SGD default (LM fine-tuning) | adafactor |
Recommended default — relative-step normalization makes BC a no-op |
| Adam-family without LR tuning | adamw (BC off) |
BC-off auto-shrinks effective LR; forgiving |
| Adam-family with tuned LR | adamw(noise_bias_correction=True) |
BC reaches min faster at the right LR |
| LR-robust alternative to AdamW | radam |
Rectification gate flattens LR sensitivity to ±0.1% across ±3× |
| Sign-based, lowest memory | lion |
No second moment; sharp LR optimum (~AdamW LR / 10) |
| DP-FTRL without an Adam-family update | sgd |
No second moment to correct |
| DP-FTRL with Adam, private second moments | adamw(...) + SecondMomentNoiseOutput |
Substitutes a privatized g² stream for squaring noised gradients |
| DP-FTRL with Adam, no extra budget | adamw(noise_bias_correction=True) |
BC alternative when the second-moment overhead isn't acceptable |
| Sparse gradients under DP | adagrad |
noise_bias_correction=True is essentially mandatory — without it the un-decaying accumulator absorbs t·σ² |
| RMSprop user under DP | rmsprop |
LR-sensitive; tune carefully (lr=1e-4 worked in our sweep, 5e-4 diverged) |
AdamW With Private Second Moments¶
from opake.dpftrl.noise import band_mf_strategy, mf_gaussian_noise
from opake.optimizers import adamw
# Strategy: momentum=beta1 (Adam's first moment workload)
strategy = band_mf_strategy(bands=8, momentum=0.9)
second_strategy = band_mf_strategy(bands=8, momentum=0.999)
# Noise: passing second_moment_strategy creates two MF streams (g, g²).
noise_fn, noise_state = mf_gaussian_noise(
grad_template, strategy,
n_steps=1000,
noise_multiplier=noise_multiplier,
key=key(42),
second_moment_strategy=second_strategy,
)
# Optimizer: decoupled weight decay, callable LR schedule.
optimizer = adamw(lr=lr_schedule_fn, betas=(0.9, 0.999), weight_decay=0.01)
opt_state = optimizer.init(params)
Training loop¶
for batch in dataloader:
grads, clip_state = grad_fn(params, batch, state=clip_state)
noisy_grads, noise_state = noise_fn(grads, noise_state)
updates, opt_state = optimizer.update(
noisy_grads, opt_state,
params=params,
)
params = torchopt.apply_updates(params, updates)
Accounting¶
Privacy accounting for the paired release uses the same underlying mechanism PLD as the first-moment-only release: the runtime σ allocation is sensitivity-proportional, so the joint Mahalanobis budget collapses to a single sensitivity-1 Gaussian release at the same noise multiplier.
strategy = band_mf_strategy(bands=bands)
mechanism = dpftrl_acc.mf_gaussian(nm, strategy)
process = dpftrl_acc.poisson(mechanism, sample_rate=q, n_steps=n)
Same pattern for DP-SGD: just dpsgd_acc.gaussian(nm) (or
dpsgd_acc.adaclip(dpsgd_acc.gaussian(nm), ...)) — no transformation
wrapper. AUTO-S clipping (auto_clipped_grad(..., second_moment=True))
calibrates against the same plain dpsgd_acc.gaussian(nm) because AUTO-S
contributes no extra threshold-quantile cost.
CLI¶
Works with MF mechanisms supported by mf_gaussian_noise: band_mf, blt,
bisr, bsr, and lambda_cgd. In second-moment mode, pass
second_moment_strategy explicitly.
DP-specific optimizer considerations¶
Why Adam works well with DP¶
In DP training, different parameters receive different signal-to-noise ratios. Parameters with large true gradients (e.g. biases, early layers) get a better ratio than parameters with small true gradients (e.g. deep attention layers). Adam's per-parameter adaptive learning rate naturally compensates for this, scaling up updates where the signal is strong relative to noise and scaling down where it is weak. SGD applies the same learning rate everywhere, which is suboptimal when noise levels vary across parameters.
Why vanilla Adagrad fails under DP¶
Adagrad's cumulative second moment \(v_t = \sum_s g_s^2\) has no decay. Under DP, \(E[g̃_s^2] = g_s^2 + \sigma^2\), so:
The noise term grows linearly forever. After enough steps the
denominator is dominated by accumulated noise; updates become
effectively random and the per-coordinate LR shrinks indefinitely.
adagrad(noise_bias_correction=True, ...) subtracts a parallel
cumulative \(\Phi_\text{acc}\) to counter this. Whether the corrected
denominator is preferable to vanilla Adagrad in practice depends on
the workload — ablate against noise_bias_correction=False rather
than treating BC as a default.
RAdam under DP¶
RAdam (Liu et al., arXiv:1908.03265)
keeps Adam's m/v accumulators but post-multiplies the update by a
variance-rectification factor r_t that depends only on (β₂, t). Below
the rectification threshold (ρ_t ≤ 5, roughly the first
O(1/(1-β₂)) steps) the rule degenerates to SGD-of-momentum — v is
not consumed at all. This makes the warmup phase naturally DP-robust:
the noise in v cannot affect the update because it isn't read.
Once ρ_t > 5, the standard Adam DP-BC story applies — subtract a
β₂-EMA of σ² from v̂ before the sqrt. radam
advances the φ-EMA every step (warmup included) so that at the first
rectified step the correction reflects all prior noise contributions to
v, not just the current step.
Adadelta under DP¶
Vanilla Adadelta (Zeiler 2012) is learning-rate-free: the update is
the per-element ratio RMS[Δx]_{t-1} / RMS[g]_t times the gradient.
Under DP both EMAs accumulate noise:
E[g²]_tinherits the Adam-shapedσ²offset.E[Δx²]_taccumulatescoef_t² · σ²per element becauseΔx_t = -coef_t · g̃_tis linear in the noised gradient.
adadelta maintains two parallel φ-EMAs at the
same decay ρ and subtracts both biases. φ_g is scalar (or
per-group), while φ_dx is per-element because the per-step update-noise
variance varies element-wise even when σ is scalar. This adds one scalar
(or per-group) value and one parameter-shaped value to vanilla Adadelta's
state.
The Adadelta two-EMA derivation has no published prior; it falls out of direct propagation of Gaussian variance through the linear scaling.
Why Adamax isn't shipped¶
Adamax tracks \(v_t = \max(\beta v_{t-1}, |g_t + \xi|)\). The max
operator absorbs the half-normal noise mean (\(\sigma\sqrt{2/\pi} \approx
0.8\sigma\)) and never releases it — even when the true gradient is
tiny, the per-coordinate denominator is permanently floored by
accumulated noise magnitude. No clean DP-aware variant exists.
For non-DP use, from torchopt import adamax directly.
Schedule-free under DP¶
Schedule-free averages optimizer iterates. Its DP variance reduction depends on their covariance, so there is no universal \(\sqrt{n}\) reduction.
from opake.optimizers import adamw, schedule_free
optimizer = schedule_free(
adamw(lr=1e-3, noise_bias_correction=True), beta=0.9
)
opt_state = optimizer.init(params)
# Train as usual: trainer treats `params` as y_t.
for batch in dataloader:
...
updates, opt_state = optimizer.update(noisy_grads, opt_state, params=params)
params = torchopt.apply_updates(params, updates)
# At save / eval time, read the published x_t directly off the state:
eval_params = opt_state.x
Weight decay and privacy¶
Weight decay (L2 regularization) does not consume privacy budget
because it is applied to the model parameters, not the data. AdamW's
decoupled weight decay is implemented as params = params - wd *
params after the gradient step — this is a deterministic function of
the current parameters and does not depend on the training data.
Gradient accumulation¶
Opake does not use gradient accumulation in the HF-style step ÷ K
sense. Instead, clipped_grad processes the entire batch (possibly
in microbatches) and returns the sum of clipped gradients in one
call. You pass the full noisy gradient directly to the optimizer —
no manual accumulation is needed.
Learning rate schedules¶
Standard LR schedules work with DP training. Linear warmup followed by cosine decay is common for DP fine-tuning. Use Opake's scheduling primitives:
from opake.optimizers import adamw
from opake.scheduling import cosine_schedule, with_warmup
decay = cosine_schedule(
init_value=1e-3, end_value=0.0,
transition_steps=900, transition_begin=100,
)
schedule = with_warmup(decay, transition_steps=100)
optimizer = adamw(lr=schedule, weight_decay=0.01, noise_bias_correction=True)
A typical warmup is 5-10% of total training steps. The warmup helps stabilize early updates when the model has not yet adapted to the noisy gradient signal.
For schedule-free training, you don't need an external schedule — the wrapper's averaging is the implicit schedule.
Checkpoint round-tripping¶
The optimizer state is a torchopt chain tuple of dataclasses; flatten
it to a serializable dict via opake.serialization:
from opake.optimizers import adamw
from opake.serialization import from_state_dict, state_dict
opt = adamw(lr=1e-3, weight_decay=0.01, noise_bias_correction=True)
state = opt.init(params)
# ... train ...
# Save
torch.save(state_dict(state), "opt.pt")
# Restore: re-init a template, then build a new state from the saved dict.
template = opt.init(params)
state = from_state_dict(template, torch.load("opt.pt"))
Forward-compatible: paths missing from a saved dict keep the template's value, so optimizers that gain new state fields between releases load cleanly from older checkpoints.
Practical notes¶
Do not clip optimizer updates. Opake clips gradients before the optimizer sees them. Clipping after the optimizer would distort the adaptive state.
DDP compatibility. In distributed training, optimizer states stay
synchronized automatically because optimizer.update is a pure
function and all ranks receive identical noisy gradients after
sum_gradients and noise addition (using the same key on all ranks).
No explicit state synchronization is needed.
NoisedPytree is the unified contract. Every optimizer that has a
noise-aware path reads realized noise_stddev metadata from NoisedPytree
updates. The trainer does not pass per-step optimizer kwargs; it just feeds
the output of the DP noise mechanism into optimizer.update().
API reference¶
See Optimizers API Reference for full factory
signatures, knob descriptions, and the serialization /
schedule_free submodule helpers.