diff --git a/runtime/training/optimizers/__init__.py b/runtime/training/optimizers/__init__.py
index 1880713..8da13df 100644
--- a/runtime/training/optimizers/__init__.py
+++ b/runtime/training/optimizers/__init__.py
@@ -24,6 +24,7 @@
lion,
prodigy,
prodigy_plus_schedulefree,
+ simplified_ademamix,
soap,
soap_sf,
)
@@ -40,6 +41,7 @@
"lion": lion.build,
"prodigy": prodigy.build,
"prodigy_plus_schedulefree": prodigy_plus_schedulefree.build,
+ "simplified_ademamix": simplified_ademamix.build,
"soap": soap.build,
"soap_sf": soap_sf.build,
}
diff --git a/runtime/training/optimizers/simplified_ademamix.py b/runtime/training/optimizers/simplified_ademamix.py
new file mode 100644
index 0000000..28e2e83
--- /dev/null
+++ b/runtime/training/optimizers/simplified_ademamix.py
@@ -0,0 +1,28 @@
+"""Simplified AdEMAMix optimizer build wrapper.
+
+Simplified AdEMAMix (Morwani et al., 2025, arxiv 2502.02431): one momentum
+buffer that sums raw gradients plus alpha * current gradient. A regular
+optimizer with a configurable lr_scheduler, but its lr is ~(1 - beta1) times
+the AdamW lr (1e-6 at beta1=0.99 for AdamW 1e-4). Implemented in
+utils/optimizer_utils.py `class SimplifiedAdEMAMix`.
+"""
+
+from __future__ import annotations
+
+
+def build(args, params, lr: float, weight_decay: float):
+ from utils.optimizer_utils import create_optimizer
+
+ return create_optimizer(
+ optimizer_type="simplified_ademamix",
+ params=params,
+ learning_rate=lr,
+ weight_decay=weight_decay,
+ betas=(
+ float(getattr(args, "ademamix_beta1", 0.99)),
+ float(getattr(args, "ademamix_beta2", 0.999)),
+ ),
+ alpha=float(getattr(args, "ademamix_alpha", 0.0)),
+ beta1_warmup=int(getattr(args, "ademamix_beta1_warmup", 0) or 0),
+ min_beta1=float(getattr(args, "ademamix_min_beta1", 0.9)),
+ )
diff --git a/runtime/training/schedulers/__init__.py b/runtime/training/schedulers/__init__.py
index 14fd380..ec0d68e 100644
--- a/runtime/training/schedulers/__init__.py
+++ b/runtime/training/schedulers/__init__.py
@@ -21,6 +21,9 @@
cosine_cycles,
cosine_with_restart,
cosine_with_warmup,
+ polynomial,
+ rex,
+ rex_annealing_warm_restarts,
)
__all__ = ["BUILDERS", "build_scheduler", "validate_schema_consistency"]
@@ -33,6 +36,9 @@
"cosine_cycles": cosine_cycles.build,
"cosine_with_restart": cosine_with_restart.build,
"cosine_with_warmup": cosine_with_warmup.build,
+ "polynomial": polynomial.build,
+ "rex": rex.build,
+ "rex_annealing_warm_restarts": rex_annealing_warm_restarts.build,
}
# schema allows "none" but BUILDERS doesn't list it; validate_schema_consistency exempts it
diff --git a/runtime/training/schedulers/polynomial.py b/runtime/training/schedulers/polynomial.py
new file mode 100644
index 0000000..c5dd245
--- /dev/null
+++ b/runtime/training/schedulers/polynomial.py
@@ -0,0 +1,52 @@
+"""Polynomial decay scheduler with linear warmup.
+
+Matches transformers' get_polynomial_decay_schedule_with_warmup: after warmup,
+lr = min_lr + (base_lr - min_lr) * (1 - progress) ** power. power=1 is linear
+decay; power>1 drops faster early and flattens out near the end.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+
+logger = logging.getLogger(__name__)
+
+
+def build(args, optimizer, total_steps: Optional[int]):
+ if total_steps is None or int(total_steps) <= 0:
+ logger.warning("polynomial scheduler needs a known total_steps, falling back to none")
+ return None
+
+ import torch
+
+ total_steps = int(total_steps)
+ warmup_steps = int(getattr(args, "lr_scheduler_warmup_steps", 0) or 0)
+ warmup_steps = max(0, min(warmup_steps, total_steps))
+ power = float(getattr(args, "lr_scheduler_power", 1.0) or 1.0)
+ eta_min = max(0.0, float(getattr(args, "lr_scheduler_eta_min", 0.0) or 0.0))
+ if power <= 0.0:
+ raise ValueError("polynomial: lr_scheduler_power must be > 0")
+
+ def make_lambda(base_lr: float):
+ min_factor = min(1.0, eta_min / base_lr) if base_lr > 0 else 0.0
+
+ def lr_lambda(step: int) -> float:
+ if warmup_steps > 0 and step < warmup_steps:
+ return float(step) / float(warmup_steps)
+ decay_steps = max(1, total_steps - warmup_steps)
+ progress = min(1.0, max(0.0, float(step - warmup_steps) / float(decay_steps)))
+ return min_factor + (1.0 - min_factor) * (1.0 - progress) ** power
+
+ return lr_lambda
+
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
+ optimizer,
+ lr_lambda=[make_lambda(group["lr"]) for group in optimizer.param_groups],
+ )
+ logger.info(
+ "LR schedule: polynomial (total_steps=%s, warmup_steps=%s, power=%s, eta_min=%s)",
+ total_steps, warmup_steps, power, eta_min,
+ )
+ return scheduler
diff --git a/runtime/training/schedulers/rex.py b/runtime/training/schedulers/rex.py
new file mode 100644
index 0000000..af9a8fd
--- /dev/null
+++ b/runtime/training/schedulers/rex.py
@@ -0,0 +1,56 @@
+"""REX scheduler (Chen et al., 2021, "REX: Revisiting Budgeted Training with an
+Improved Schedule", arxiv 2107.04197) with optional linear warmup.
+
+After warmup, with z = 1 - progress:
+ factor = z / ((1 - d) + d * z)
+d = 0.5 is the paper's curve; larger d (LoRA Easy Training Scripts uses 0.9) holds
+the peak lr longer and drops more sharply at the end.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from training.schedulers.rex_annealing_warm_restarts import rex_factor
+
+
+logger = logging.getLogger(__name__)
+
+
+def build(args, optimizer, total_steps: Optional[int]):
+ if total_steps is None or int(total_steps) <= 0:
+ logger.warning("rex scheduler needs a known total_steps, falling back to none")
+ return None
+
+ import torch
+
+ total_steps = int(total_steps)
+ warmup_steps = int(getattr(args, "lr_scheduler_warmup_steps", 0) or 0)
+ warmup_steps = max(0, min(warmup_steps, total_steps))
+ eta_min = max(0.0, float(getattr(args, "lr_scheduler_eta_min", 0.0) or 0.0))
+ d = float(getattr(args, "lr_scheduler_rex_d", 0.9))
+ if not 0.0 < d <= 1.0:
+ raise ValueError("rex: lr_scheduler_rex_d must be in (0, 1]")
+
+ def make_lambda(base_lr: float):
+ min_factor = min(1.0, eta_min / base_lr) if base_lr > 0 else 0.0
+
+ def lr_lambda(step: int) -> float:
+ if warmup_steps > 0 and step < warmup_steps:
+ return float(step) / float(warmup_steps)
+ decay_steps = max(1, total_steps - warmup_steps)
+ progress = min(1.0, max(0.0, float(step - warmup_steps) / float(decay_steps)))
+ return min_factor + (1.0 - min_factor) * rex_factor(progress, d)
+
+ return lr_lambda
+
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
+ optimizer,
+ lr_lambda=[make_lambda(group["lr"]) for group in optimizer.param_groups],
+ )
+ logger.info(
+ "LR schedule: rex (total_steps=%s, warmup_steps=%s, d=%s, eta_min=%s)",
+ total_steps, warmup_steps, d, eta_min,
+ )
+ return scheduler
diff --git a/runtime/training/schedulers/rex_annealing_warm_restarts.py b/runtime/training/schedulers/rex_annealing_warm_restarts.py
new file mode 100644
index 0000000..33f8965
--- /dev/null
+++ b/runtime/training/schedulers/rex_annealing_warm_restarts.py
@@ -0,0 +1,104 @@
+"""REX annealing with warm restarts (RAWR).
+
+Port of RexAnnealingWarmRestarts from derrian-distro/LoRA_Easy_Training_scripts_Backend,
+rewritten as a stateless LambdaLR (cycle position derived from the step index, so
+resume needs no extra scheduler state). Each cycle:
+ - warmup: linear ramp from min_lr to the cycle peak over lr_scheduler_warmup_steps;
+ - decay: REX curve min_lr + (peak - min_lr) * z / ((1 - d) + d * z), z = 1 - progress.
+After each restart the peak is multiplied by gamma and the decay part of the cycle
+by cycle_multiplier. The first cycle length is chosen so lr_scheduler_cycle_count
+cycles fill total_steps.
+"""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+
+logger = logging.getLogger(__name__)
+
+
+def rex_factor(progress: float, d: float) -> float:
+ z = 1.0 - min(1.0, max(0.0, progress))
+ return z / ((1.0 - d) + d * z)
+
+
+def cycle_lengths(total_steps: int, cycles: int, multiplier: float, warmup: int) -> list[int]:
+ """Lengths (warmup included) of `cycles` cycles covering ~total_steps."""
+ geometric = sum(multiplier ** k for k in range(cycles))
+ decay0 = max(1, round((total_steps - cycles * warmup) / geometric))
+ lengths = [decay0 + warmup]
+ while len(lengths) < cycles:
+ lengths.append(max(1, round((lengths[-1] - warmup) * multiplier)) + warmup)
+ return lengths
+
+
+def build(args, optimizer, total_steps: Optional[int]):
+ if total_steps is None or int(total_steps) <= 0:
+ logger.warning("rex_annealing_warm_restarts needs a known total_steps, falling back to none")
+ return None
+
+ import torch
+
+ total_steps = int(total_steps)
+ cycles = max(1, int(getattr(args, "lr_scheduler_cycle_count", 3) or 1))
+ multiplier = float(getattr(args, "lr_scheduler_cycle_multiplier", 1.0) or 1.0)
+ gamma = float(getattr(args, "lr_scheduler_gamma", 0.9))
+ d = float(getattr(args, "lr_scheduler_rex_d", 0.9))
+ eta_min = max(0.0, float(getattr(args, "lr_scheduler_eta_min", 0.0) or 0.0))
+ warmup = max(0, int(getattr(args, "lr_scheduler_warmup_steps", 0) or 0))
+ if multiplier <= 0.0:
+ raise ValueError("rex_annealing_warm_restarts: lr_scheduler_cycle_multiplier must be > 0")
+ if not 0.0 < d <= 1.0:
+ raise ValueError("rex_annealing_warm_restarts: lr_scheduler_rex_d must be in (0, 1]")
+ if gamma <= 0.0:
+ raise ValueError("rex_annealing_warm_restarts: lr_scheduler_gamma must be > 0")
+ # Warmup has to leave room for decay in every cycle.
+ max_warmup = max(0, total_steps // cycles - 1)
+ if warmup > max_warmup:
+ logger.warning(
+ "rex_annealing_warm_restarts: warmup_steps=%s doesn't fit %s cycles in %s steps, "
+ "clamping to %s", warmup, cycles, total_steps, max_warmup,
+ )
+ warmup = max_warmup
+ lengths = cycle_lengths(total_steps, cycles, multiplier, warmup)
+
+ def locate(step: int) -> tuple[int, int, int]:
+ """(cycle index, step within cycle, cycle length); continues past the planned cycles."""
+ cycle, start, length = 0, 0, lengths[0]
+ while step >= start + length:
+ start += length
+ cycle += 1
+ if cycle < len(lengths):
+ length = lengths[cycle]
+ else:
+ length = max(1, round((length - warmup) * multiplier)) + warmup
+ return cycle, step - start, length
+
+ def make_lambda(base_lr: float):
+ min_factor = min(1.0, eta_min / base_lr) if base_lr > 0 else 0.0
+
+ def lr_lambda(step: int) -> float:
+ cycle, pos, length = locate(max(0, step))
+ peak = gamma ** cycle
+ if peak <= min_factor:
+ return min_factor
+ span = peak - min_factor
+ if warmup > 0 and pos < warmup:
+ return min_factor + span * pos / warmup
+ progress = (pos - warmup) / max(1, length - warmup)
+ return min_factor + span * rex_factor(progress, d)
+
+ return lr_lambda
+
+ scheduler = torch.optim.lr_scheduler.LambdaLR(
+ optimizer,
+ lr_lambda=[make_lambda(group["lr"]) for group in optimizer.param_groups],
+ )
+ logger.info(
+ "LR schedule: rex_annealing_warm_restarts (total_steps=%s, cycles=%s, lengths=%s, "
+ "multiplier=%s, gamma=%s, d=%s, warmup_steps=%s, eta_min=%s)",
+ total_steps, cycles, lengths, multiplier, gamma, d, warmup, eta_min,
+ )
+ return scheduler
diff --git a/studio/domain/training.py b/studio/domain/training.py
index 3570aa3..9879771 100644
--- a/studio/domain/training.py
+++ b/studio/domain/training.py
@@ -445,6 +445,9 @@ class TrainingConfig(BaseModel):
"cosine_with_warmup",
"cosine_cycles",
"constant_then_cosine",
+ "polynomial",
+ "rex",
+ "rex_annealing_warm_restarts",
] = Field(
"none",
description="Learning rate schedule (none = constant; Prodigy / PPSF / Automagic / SOAP-SF are fixed to none)",
@@ -467,22 +470,46 @@ class TrainingConfig(BaseModel):
)
lr_scheduler_eta_min: float = Field(
1e-6, ge=0.0,
- description="Learning rate floor: cosine schedules stop decreasing once they reach this value; usually much smaller than the initial lr (e.g. 1e-4 initial with 1e-6 floor)",
+ description="Learning rate floor: cosine / polynomial / rex / RAWR schedules stop decreasing once they reach this value; usually much smaller than the initial lr (e.g. 1e-4 initial with 1e-6 floor)",
json_schema_extra=_meta(
"training",
- show_when="lr_scheduler==cosine||lr_scheduler==cosine_with_restart||lr_scheduler==cosine_with_warmup",
+ show_when="lr_scheduler==cosine||lr_scheduler==cosine_with_restart||lr_scheduler==cosine_with_warmup||lr_scheduler==polynomial||lr_scheduler==rex||lr_scheduler==rex_annealing_warm_restarts",
advanced=True,
),
)
lr_scheduler_warmup_steps: int = Field(
100, ge=0,
- description="cosine_with_warmup warmup steps",
- json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_with_warmup", advanced=True),
+ description="Warmup steps for cosine_with_warmup / polynomial / rex (linear ramp from 0 to the peak lr); for RAWR the warmup repeats at the start of every cycle. 0 = no warmup",
+ json_schema_extra=_meta(
+ "training",
+ show_when="lr_scheduler==cosine_with_warmup||lr_scheduler==polynomial||lr_scheduler==rex||lr_scheduler==rex_annealing_warm_restarts",
+ advanced=True,
+ ),
+ )
+ lr_scheduler_power: float = Field(
+ 1.0, gt=0.0,
+ description="polynomial: decay power. 1 = linear decay to the floor; >1 drops faster early and flattens near the end; <1 holds the lr longer and drops at the end",
+ json_schema_extra=_meta("training", show_when="lr_scheduler==polynomial"),
+ )
+ lr_scheduler_rex_d: float = Field(
+ 0.9, gt=0.0, le=1.0,
+ description="rex / RAWR curve shape d: factor = z / ((1 - d) + d·z), z = remaining fraction. 0.5 = the REX paper; 0.9 (LoRA Easy Training Scripts default) holds the peak longer and drops more sharply at the end",
+ json_schema_extra=_meta("training", show_when="lr_scheduler==rex||lr_scheduler==rex_annealing_warm_restarts", advanced=True),
+ )
+ lr_scheduler_cycle_multiplier: float = Field(
+ 1.0, gt=0.0,
+ description="RAWR: each next cycle is this many times longer than the previous one (1 = equal cycles, 2 = doubling). Cycle lengths are fitted so all cycles fill the run",
+ json_schema_extra=_meta("training", show_when="lr_scheduler==rex_annealing_warm_restarts"),
+ )
+ lr_scheduler_gamma: float = Field(
+ 0.9, gt=0.0, le=1.0,
+ description="RAWR: peak lr multiplier after each restart (1 = every cycle restarts at the full lr; 0.9 = each peak 10% lower)",
+ json_schema_extra=_meta("training", show_when="lr_scheduler==rex_annealing_warm_restarts"),
)
lr_scheduler_cycle_count: int = Field(
3, ge=1, le=100,
- description="Number of peaks for cosine_cycles; total training steps are divided evenly into this many periods",
- json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles"),
+ description="Number of peaks for cosine_cycles / RAWR; the cycles together fill the whole run (cosine_cycles splits it evenly)",
+ json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles||lr_scheduler==rex_annealing_warm_restarts"),
)
lr_scheduler_cycle_max_lr: Optional[float] = Field(
None, gt=0.0,
@@ -514,9 +541,9 @@ class TrainingConfig(BaseModel):
description="constant_then_cosine: LR at the very end of training",
json_schema_extra=_meta("training", show_when="lr_scheduler==constant_then_cosine"),
)
- optimizer_type: Literal["adamw", "adamw8bit", "automagic", "came", "lion", "prodigy", "prodigy_plus_schedulefree", "soap", "soap_sf"] = Field(
+ optimizer_type: Literal["adamw", "adamw8bit", "automagic", "came", "lion", "prodigy", "prodigy_plus_schedulefree", "simplified_ademamix", "soap", "soap_sf"] = Field(
"adamw",
- description="Optimizer. adamw standard baseline; adamw8bit same as AdamW but with state quantized to int8 (state VRAM ~1/4 of AdamW, hyperparameters carry over directly, requires bitsandbytes); automagic adaptive per-parameter lr (recommended lr=1e-6); came confidence-guided + factored second moment (lower state VRAM than AdamW, lr on the same scale as AdamW); lion roughly half AdamW's VRAM (recommended lr=AdamW lr / 3); prodigy / prodigy_plus_schedulefree adaptively estimates lr (set lr to 1.0); soap Adam-in-Shampoo-eigenbasis second-order preconditioning (fits faster, lr on the same scale as AdamW); soap_sf SOAP + Schedule-Free (lr_scheduler fixed to none)",
+ description="Optimizer. adamw standard baseline; adamw8bit same as AdamW but with state quantized to int8 (state VRAM ~1/4 of AdamW, hyperparameters carry over directly, requires bitsandbytes); automagic adaptive per-parameter lr (recommended lr=1e-6); came confidence-guided + factored second moment (lower state VRAM than AdamW, lr on the same scale as AdamW); lion roughly half AdamW's VRAM (recommended lr=AdamW lr / 3); prodigy / prodigy_plus_schedulefree adaptively estimates lr (set lr to 1.0); simplified_ademamix one raw-gradient momentum + current-gradient mix (lr ≈ AdamW lr × (1 − β1), e.g. 1e-6 at β1=0.99); soap Adam-in-Shampoo-eigenbasis second-order preconditioning (fits faster, lr on the same scale as AdamW); soap_sf SOAP + Schedule-Free (lr_scheduler fixed to none)",
json_schema_extra=_meta("training"),
)
prodigy_d_coef: float = Field(
@@ -573,6 +600,34 @@ class TrainingConfig(BaseModel):
description="Lion β2 (momentum accumulation coefficient)",
json_schema_extra=_meta("training", show_when="optimizer_type==lion", advanced=True),
)
+ # ------------------------- Simplified AdEMAMix-specific fields -------------------------
+ # Morwani et al. 2025 (arxiv 2502.02431). The momentum sums raw gradients
+ # (no 1-β1 factor), so lr is ~(1-β1) times the AdamW lr.
+ ademamix_beta1: float = Field(
+ 0.99, ge=0.0, lt=1.0,
+ description="Simplified AdEMAMix β1 (momentum decay; higher = longer gradient memory, and lr must shrink by (1 − β1))",
+ json_schema_extra=_meta("training", show_when="optimizer_type==simplified_ademamix", advanced=True),
+ )
+ ademamix_beta2: float = Field(
+ 0.999, ge=0.0, lt=1.0,
+ description="Simplified AdEMAMix β2 (second-moment EMA decay)",
+ json_schema_extra=_meta("training", show_when="optimizer_type==simplified_ademamix", advanced=True),
+ )
+ ademamix_alpha: float = Field(
+ 0.0, ge=0.0,
+ description="Simplified AdEMAMix α: weight of the current gradient added on top of the momentum (0 = momentum only)",
+ json_schema_extra=_meta("training", show_when="optimizer_type==simplified_ademamix", advanced=True),
+ )
+ ademamix_beta1_warmup: int = Field(
+ 0, ge=0,
+ description="Simplified AdEMAMix: steps over which β1 ramps up from the starting β1 to β1 (0 = off). Stabilizes the start with a high β1",
+ json_schema_extra=_meta("training", show_when="optimizer_type==simplified_ademamix", advanced=True),
+ )
+ ademamix_min_beta1: float = Field(
+ 0.9, ge=0.0, lt=1.0,
+ description="Simplified AdEMAMix: starting β1 for the β1 warmup",
+ json_schema_extra=_meta("training", show_when="optimizer_type==simplified_ademamix", advanced=True),
+ )
automagic_variant: Literal["v1", "v2"] = Field(
"v1",
description="v1: per-element lr mask (classic, recommended); v2: per-param scalar lr + fused backward (experimental, saves VRAM; incompatible with grad_accum / grad_clip / fp16)",
diff --git a/studio/web/src/components/SchemaForm.test.tsx b/studio/web/src/components/SchemaForm.test.tsx
index cdd0811..976918f 100644
--- a/studio/web/src/components/SchemaForm.test.tsx
+++ b/studio/web/src/components/SchemaForm.test.tsx
@@ -40,7 +40,7 @@ const schema: SchemaResponse = {
properties: {
optimizer_type: {
type: 'string',
- enum: ['adamw', 'prodigy', 'prodigy_plus_schedulefree', 'automagic'],
+ enum: ['adamw', 'prodigy', 'prodigy_plus_schedulefree', 'automagic', 'simplified_ademamix'],
default: 'adamw',
group: 'training',
description: 'Optimizer',
@@ -357,4 +357,21 @@ describe('SchemaForm rule takeover confirm (R6)', () => {
expect.objectContaining({ optimizer_type: 'automagic', learning_rate: 1e-6 }),
)
})
+
+ it('switching to simplified_ademamix suggests an lr scaled by (1 - beta1)', () => {
+ const onChange = vi.fn()
+ const values: ConfigData = { ...base }
+ render()
+ const optimizerSelect = screen
+ .getAllByRole('combobox')
+ .find((el) =>
+ Array.from((el as HTMLSelectElement).options).some((o) => o.value === 'adamw'),
+ ) as HTMLSelectElement
+ fireEvent.change(optimizerSelect, { target: { value: 'simplified_ademamix' } })
+ expect(screen.getByText('ruleImpact.simplifiedAdemamixLr')).toBeInTheDocument()
+ fireEvent.click(screen.getByText('ruleImpact.ok'))
+ expect(onChange).toHaveBeenCalledWith(
+ expect.objectContaining({ optimizer_type: 'simplified_ademamix', learning_rate: 1e-6 }),
+ )
+ })
})
diff --git a/studio/web/src/components/SchemaForm.tsx b/studio/web/src/components/SchemaForm.tsx
index cf382df..44a9e23 100644
--- a/studio/web/src/components/SchemaForm.tsx
+++ b/studio/web/src/components/SchemaForm.tsx
@@ -148,6 +148,17 @@ export default function SchemaForm({
reason: t('ruleImpact.automagicLr'),
})
}
+ // Simplified AdEMAMix sums raw gradients, so its step is ~1/(1-β1) times AdamW's:
+ // an AdamW-scale lr (1e-4) diverges; the equivalent at β1=0.99 is 1e-6.
+ if (
+ triggerField === 'optimizer_type' && next.optimizer_type === 'simplified_ademamix'
+ && Number(next.learning_rate) > 1e-5
+ ) {
+ writes.push({
+ field: 'learning_rate', from: next.learning_rate, to: 1e-6,
+ reason: t('ruleImpact.simplifiedAdemamixLr'),
+ })
+ }
return writes
}
diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json
index 1ea2feb..349734f 100644
--- a/studio/web/src/i18n/locales/en.json
+++ b/studio/web/src/i18n/locales/en.json
@@ -378,7 +378,10 @@
"cosineWithRestart": "Cosine with warm restarts",
"cosineWithWarmup": "Cosine with warmup",
"cosineCycles": "Cosine with equal cycles",
- "constantThenCosine": "Constant with cosine decay"
+ "constantThenCosine": "Constant with cosine decay",
+ "polynomial": "Polynomial decay",
+ "rex": "REX",
+ "rexAnnealingWarmRestarts": "REX with warm restarts (RAWR)"
},
"optimizerType": {
"adamw": "AdamW",
@@ -387,7 +390,8 @@
"came": "CAME",
"lion": "Lion",
"prodigy": "Prodigy",
- "prodigyPlusSchedulefree": "Prodigy + Schedule-Free"
+ "prodigyPlusSchedulefree": "Prodigy + Schedule-Free",
+ "simplifiedAdemamix": "Simplified AdEMAMix"
},
"timestepSampling": {
"logitNormal": "Logit Normal",
@@ -497,20 +501,24 @@
"batch_size": "Batch size",
"grad_checkpoint": "Gradient checkpointing (saves VRAM, roughly +1/3 compute)",
"grad_accum": "Gradient accumulation steps (effective batch = batch_size × grad_accum)",
- "learning_rate": "Learning rate. Automagic recommends 1e-6 (auto-applied on switch); Lion recommends AdamW lr / 3; Prodigy / PPSF require 1.0",
- "lr_scheduler": "How the learning rate changes over the run. Constant: it stays the same. Cosine: it eases down to the end. Cosine with warmup: a ramp-up first, then a smooth decay. Cosine with warm restarts: decays and restarts, each cycle longer than the last. Cosine with equal cycles: N identical cosine cycles, each can have its own peak. Constant with cosine decay: flat learning rate, then a smooth decay at the end. Prodigy, PPSF, Automagic and SOAP-SF always use Constant.",
+ "learning_rate": "Learning rate. Automagic recommends 1e-6 (auto-applied on switch); Lion recommends AdamW lr / 3; Simplified AdEMAMix recommends AdamW lr × (1 − β1), i.e. 1e-6 at β1=0.99 (auto-applied on switch); Prodigy / PPSF require 1.0",
+ "lr_scheduler": "How the learning rate changes over the run. Constant: it stays the same. Cosine: it eases down to the end. Cosine with warmup: a ramp-up first, then a smooth decay. Cosine with warm restarts: decays and restarts, each cycle longer than the last. Cosine with equal cycles: N identical cosine cycles, each can have its own peak. Constant with cosine decay: flat learning rate, then a smooth decay at the end. Polynomial decay: warmup, then (1 − progress)^power down to the floor (power 1 = linear). REX: warmup, then holds near the peak longer than linear and drops sharply at the end. REX with warm restarts (RAWR): several REX cycles with a warmup at the start of each; every next peak is lower by gamma and cycles can grow longer. Prodigy, PPSF, Automagic and SOAP-SF always use Constant.",
"lr_scheduler_t0": "First cosine_with_restart cycle length (unit: step)",
"lr_scheduler_t_mult": "cosine_with_restart cycle-length multiplier relative to the previous cycle (>1 = lengthening cycles)",
- "lr_scheduler_eta_min": "LR decay floor for cosine schedules; should be much smaller than the initial lr (e.g. 1e-6 for an initial lr of 1e-4)",
- "lr_scheduler_warmup_steps": "cosine_with_warmup warmup steps",
- "lr_scheduler_cycle_count": "Number of equal-length cosine peaks. Default: 3.",
+ "lr_scheduler_eta_min": "LR decay floor for cosine / polynomial / REX / RAWR schedules; should be much smaller than the initial lr (e.g. 1e-6 for an initial lr of 1e-4)",
+ "lr_scheduler_warmup_steps": "Warmup steps for cosine_with_warmup / polynomial / REX (linear ramp from 0 to the peak lr); for RAWR the warmup repeats at the start of every cycle. 0 = no warmup",
+ "lr_scheduler_power": "Polynomial decay power. 1 = linear decay to the floor; >1 drops faster early and flattens near the end; <1 holds the lr longer and drops at the end",
+ "lr_scheduler_rex_d": "REX / RAWR curve shape d. 0.5 is the curve from the REX paper; 0.9 (LoRA Easy Training Scripts default) holds the peak longer and drops more sharply at the end",
+ "lr_scheduler_cycle_multiplier": "RAWR: each next cycle is this many times longer than the previous one (1 = equal cycles, 2 = doubling). Lengths are fitted so all cycles fill the run",
+ "lr_scheduler_gamma": "RAWR: peak lr multiplier after each restart (1 = every cycle restarts at the full lr; 0.9 = each peak 10% lower)",
+ "lr_scheduler_cycle_count": "Number of cycles (peaks) for cosine with equal cycles / RAWR; together they fill the whole run. Default: 3.",
"lr_scheduler_cycle_max_lr": "Maximum LR shared by all peaks. Leave empty to use learning_rate.",
"lr_scheduler_cycle_min_lr": "Minimum LR shared by all peaks. Per-peak values override it.",
"lr_scheduler_cycle_max_lrs": "Optional maximum LR for each peak, comma-separated. The value count must match the peak count.",
"lr_scheduler_cycle_min_lrs": "Optional minimum LR for each peak, comma-separated. The value count must match the peak count.",
"lr_scheduler_decay_start_ratio": "Training fraction after which the final cosine decay starts. 0.6667 means the last third.",
"lr_scheduler_decay_min_lr": "Learning rate at the end of the final cosine decay.",
- "optimizer_type": "Optimizer. adamw is the baseline; automagic adapts per-parameter lr (recommend lr=1e-6); came uses confidence-guided factored second moments (less state VRAM than AdamW, lr at AdamW scale); lion uses ~half the state of AdamW (recommend lr = AdamW lr / 3); prodigy / prodigy_plus_schedulefree self-estimate lr (fill 1.0)",
+ "optimizer_type": "Optimizer. adamw is the baseline; automagic adapts per-parameter lr (recommend lr=1e-6); came uses confidence-guided factored second moments (less state VRAM than AdamW, lr at AdamW scale); lion uses ~half the state of AdamW (recommend lr = AdamW lr / 3); prodigy / prodigy_plus_schedulefree self-estimate lr (fill 1.0); simplified_ademamix keeps one long momentum of raw gradients mixed with the current gradient (same VRAM as AdamW; lr = AdamW lr × (1 − β1), e.g. 1e-6)",
"prodigy_d_coef": "Overall scaling coefficient on Prodigy's estimated d; larger = larger effective lr. Raise (2.0+) when underfitting; lower (0.5) when overfitting or on small datasets",
"prodigy_safeguard_warmup": "Prevent d from being inflated by early high gradients during Prodigy warmup; default on for stability",
"came_beta1": "CAME β1 (update momentum EMA decay)",
@@ -521,6 +529,11 @@
"came_clip_threshold": "CAME update RMS clipping threshold (same as Adafactor's d)",
"lion_beta1": "Lion β1 (momentum interpolation coefficient)",
"lion_beta2": "Lion β2 (momentum accumulation coefficient)",
+ "ademamix_beta1": "Simplified AdEMAMix β1 (momentum decay; higher = longer gradient memory, and lr must shrink by (1 − β1))",
+ "ademamix_beta2": "Simplified AdEMAMix β2 (second-moment EMA decay)",
+ "ademamix_alpha": "Simplified AdEMAMix α: weight of the current gradient added on top of the momentum (0 = momentum only)",
+ "ademamix_beta1_warmup": "Steps over which β1 ramps up from the starting β1 to β1 (0 = off). Stabilizes the start with a high β1",
+ "ademamix_min_beta1": "Starting β1 for the β1 warmup (used only when β1 warmup > 0)",
"automagic_min_lr": "Automagic per-parameter learning-rate floor",
"automagic_max_lr": "Automagic per-parameter learning-rate ceiling",
"automagic_lr_bump": "Automagic learning-rate adjustment step",
@@ -3736,7 +3749,8 @@
"title": "Linked parameters will be adjusted",
"intro": "Setting {{field}} to {{value}} will also adjust these modified parameters:",
"ok": "Apply",
- "automagicLr": "Automagic starts from a per-parameter learning rate; the recommended initial value is 1e-6 (starting at AdamW magnitude makes adaptation ~100× slower to converge)"
+ "automagicLr": "Automagic starts from a per-parameter learning rate; the recommended initial value is 1e-6 (starting at AdamW magnitude makes adaptation ~100× slower to converge)",
+ "simplifiedAdemamixLr": "Simplified AdEMAMix accumulates raw gradients, so its step is ~1/(1 − β1) times AdamW's; at β1=0.99 the AdamW-equivalent of 1e-4 is 1e-6"
},
"baseModel": {
"purpose": {
diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json
index 61fadfe..57e393d 100644
--- a/studio/web/src/i18n/locales/ru.json
+++ b/studio/web/src/i18n/locales/ru.json
@@ -390,7 +390,8 @@
"title": "Связанные параметры будут скорректированы",
"intro": "Значение {{field}} = {{value}} также изменит эти параметры:",
"ok": "Применить",
- "automagicLr": "Automagic стартует с отдельной скорости обучения на каждый параметр; рекомендуемое начальное значение 1e-6 (если начать с величины уровня AdamW, адаптация сходится примерно в 100 раз дольше)"
+ "automagicLr": "Automagic стартует с отдельной скорости обучения на каждый параметр; рекомендуемое начальное значение 1e-6 (если начать с величины уровня AdamW, адаптация сходится примерно в 100 раз дольше)",
+ "simplifiedAdemamixLr": "Simplified AdEMAMix копит «сырые» градиенты, поэтому его шаг примерно в 1/(1 − β1) раз больше, чем у AdamW; при β1=0.99 аналог 1e-4 из AdamW — это 1e-6"
},
"snapshot": {
"title": "Снимок конфига тренировки",
@@ -985,7 +986,10 @@
"cosineWithRestart": "Cosine with warm restarts",
"cosineWithWarmup": "Cosine with warmup",
"cosineCycles": "Cosine with equal cycles",
- "constantThenCosine": "Constant with cosine decay"
+ "constantThenCosine": "Constant with cosine decay",
+ "polynomial": "Polynomial decay",
+ "rex": "REX",
+ "rexAnnealingWarmRestarts": "REX с перезапусками (RAWR)"
},
"timestepSampling": {
"dualPeak": "Dual Peak — два пика для стиля",
@@ -1009,7 +1013,8 @@
"came": "CAME",
"lion": "Lion",
"prodigy": "Prodigy",
- "prodigyPlusSchedulefree": "Prodigy + Schedule-Free"
+ "prodigyPlusSchedulefree": "Prodigy + Schedule-Free",
+ "simplifiedAdemamix": "Simplified AdEMAMix"
},
"lossWeighting": {
"none": "Нет",
@@ -1153,20 +1158,24 @@
"batch_size": "Размер батча",
"grad_checkpoint": "Градиентный чекпоинтинг (экономит видеопамять, примерно +1/3 вычислений)",
"grad_accum": "Число шагов накопления градиента (эффективный батч = batch_size × grad_accum)",
- "learning_rate": "Скорость обучения. Для automagic рекомендуется 1e-6 (подставляется автоматически); для lion — lr AdamW делённая на 3; prodigy и PPSF требуют 1.0",
- "lr_scheduler": "Как меняется скорость обучения по ходу тренировки. Constant — не меняется. Cosine — плавно снижается к концу. Cosine with warmup — сначала разгон, потом плавный спад. Cosine with warm restarts — спад с перезапусками, каждый цикл длиннее предыдущего. Cosine with equal cycles — N одинаковых косинусных циклов, у каждого можно задать свой пик. Constant with cosine decay — постоянная lr, а в конце плавный спад. Prodigy, PPSF, Automagic и SOAP-SF всегда используют Constant.",
+ "learning_rate": "Скорость обучения. Для automagic рекомендуется 1e-6 (подставляется автоматически); для lion — lr AdamW делённая на 3; для Simplified AdEMAMix — lr AdamW × (1 − β1), то есть 1e-6 при β1=0.99 (подставляется автоматически); prodigy и PPSF требуют 1.0",
+ "lr_scheduler": "Как меняется скорость обучения по ходу тренировки. Constant — не меняется. Cosine — плавно снижается к концу. Cosine with warmup — сначала разгон, потом плавный спад. Cosine with warm restarts — спад с перезапусками, каждый цикл длиннее предыдущего. Cosine with equal cycles — N одинаковых косинусных циклов, у каждого можно задать свой пик. Constant with cosine decay — постоянная lr, а в конце плавный спад. Polynomial decay — разгон, затем спад по (1 − прогресс)^степень до нижнего предела (степень 1 — линейно). REX — разгон, затем lr дольше держится у пика, чем при линейном спаде, и резко падает в конце. REX с перезапусками (RAWR) — несколько циклов REX, в начале каждого разгон; каждый следующий пик ниже в gamma раз, циклы могут удлиняться. Prodigy, PPSF, Automagic и SOAP-SF всегда используют Constant.",
"lr_scheduler_t0": "Длина первого цикла cosine_with_restart (в шагах)",
"lr_scheduler_t_mult": "Во сколько раз каждый следующий цикл cosine_with_restart длиннее предыдущего (>1 — циклы растут)",
- "lr_scheduler_eta_min": "Нижний предел затухания для косинусных расписаний; должен быть сильно меньше стартовой lr (например 1e-6 при старте 1e-4)",
- "lr_scheduler_warmup_steps": "Длина разогрева для cosine_with_warmup, в шагах",
- "lr_scheduler_cycle_count": "Число равных косинусных циклов (пиков). По умолчанию 3.",
+ "lr_scheduler_eta_min": "Нижний предел затухания для cosine / polynomial / REX / RAWR; должен быть сильно меньше стартовой lr (например 1e-6 при старте 1e-4)",
+ "lr_scheduler_warmup_steps": "Длина разогрева для cosine_with_warmup / polynomial / REX, в шагах (линейный рост от 0 до пика); у RAWR разогрев повторяется в начале каждого цикла. 0 — без разогрева",
+ "lr_scheduler_power": "Степень спада polynomial. 1 — линейный спад до нижнего предела; >1 — быстрее падает в начале и выполаживается к концу; <1 — дольше держит lr и падает в конце",
+ "lr_scheduler_rex_d": "Форма кривой REX / RAWR (d). 0.5 — кривая из статьи REX; 0.9 (по умолчанию в LoRA Easy Training Scripts) дольше держит пик и резче падает в конце",
+ "lr_scheduler_cycle_multiplier": "RAWR: во сколько раз каждый следующий цикл длиннее предыдущего (1 — одинаковые, 2 — каждый вдвое длиннее). Длины подбираются так, чтобы все циклы заняли всю тренировку",
+ "lr_scheduler_gamma": "RAWR: множитель пика lr после каждого перезапуска (1 — каждый цикл с полной lr; 0.9 — каждый пик на 10% ниже)",
+ "lr_scheduler_cycle_count": "Число циклов (пиков) для Cosine with equal cycles и RAWR; вместе они занимают всю тренировку. По умолчанию 3.",
"lr_scheduler_cycle_max_lr": "Общий максимум LR для всех пиков. Оставьте пустым, чтобы использовать learning_rate.",
"lr_scheduler_cycle_min_lr": "Общий минимум LR для всех пиков. Индивидуальные значения имеют приоритет.",
"lr_scheduler_cycle_max_lrs": "Максимумы LR для каждого пика через запятую. Число значений должно совпадать с числом пиков.",
"lr_scheduler_cycle_min_lrs": "Минимумы LR для каждого пика через запятую. Число значений должно совпадать с числом пиков.",
"lr_scheduler_decay_start_ratio": "Доля обучения, после которой начинается финальное плавное снижение. 0.6667 означает последнюю треть.",
"lr_scheduler_decay_min_lr": "Скорость обучения в конце плавного снижения.",
- "optimizer_type": "Оптимизатор. adamw — база; automagic подбирает скорость на каждый параметр (рекомендуется lr=1e-6); came использует факторизованные вторые моменты с оценкой уверенности (памяти меньше, чем у AdamW, lr того же порядка); lion расходует примерно вдвое меньше состояния (рекомендуется lr AdamW делённая на 3); prodigy и prodigy_plus_schedulefree сами оценивают lr (ставьте 1.0)",
+ "optimizer_type": "Оптимизатор. adamw — база; automagic подбирает скорость на каждый параметр (рекомендуется lr=1e-6); came использует факторизованные вторые моменты с оценкой уверенности (памяти меньше, чем у AdamW, lr того же порядка); lion расходует примерно вдвое меньше состояния (рекомендуется lr AdamW делённая на 3); prodigy и prodigy_plus_schedulefree сами оценивают lr (ставьте 1.0); simplified_ademamix — один длинный момент из «сырых» градиентов плюс текущий градиент (память как у AdamW; lr = lr AdamW × (1 − β1), например 1e-6)",
"prodigy_d_coef": "Общий множитель к оценке d у Prodigy; больше — выше фактическая скорость. Поднимать (2.0+) при недообучении, снижать (0.5) при переобучении и на маленьких датасетах",
"prodigy_safeguard_warmup": "Не давать большим градиентам в начале раздуть оценку d у Prodigy; по умолчанию включено ради устойчивости",
"came_beta1": "CAME β1 (затухание момента обновления)",
@@ -1177,6 +1186,11 @@
"came_clip_threshold": "Порог обрезки RMS обновления в CAME (то же, что d в Adafactor)",
"lion_beta1": "Lion β1 (коэффициент интерполяции момента)",
"lion_beta2": "Lion β2 (коэффициент накопления момента)",
+ "ademamix_beta1": "Simplified AdEMAMix β1 (затухание момента; больше — длиннее память градиентов, и lr нужно уменьшать в (1 − β1) раз)",
+ "ademamix_beta2": "Simplified AdEMAMix β2 (затухание второго момента)",
+ "ademamix_alpha": "Simplified AdEMAMix α: вес текущего градиента поверх момента (0 — только момент)",
+ "ademamix_beta1_warmup": "За сколько шагов β1 растёт от стартового значения до β1 (0 — выключено). Стабилизирует начало при высоком β1",
+ "ademamix_min_beta1": "Стартовое β1 для разогрева β1 (работает только при разогреве β1 > 0)",
"automagic_min_lr": "Automagic: нижний предел скорости на параметр",
"automagic_max_lr": "Automagic: верхний предел скорости на параметр",
"automagic_lr_bump": "Automagic: шаг подстройки скорости",
@@ -1383,6 +1397,10 @@
"lr_scheduler_t_mult": "Множитель периода",
"lr_scheduler_eta_min": "Нижний предел скорости обучения",
"lr_scheduler_warmup_steps": "Шагов прогрева",
+ "lr_scheduler_power": "Степень спада",
+ "lr_scheduler_rex_d": "Форма кривой REX (d)",
+ "lr_scheduler_cycle_multiplier": "Множитель длины цикла",
+ "lr_scheduler_gamma": "Множитель пика (gamma)",
"lr_scheduler_cycle_count": "Число циклов",
"lr_scheduler_cycle_max_lr": "Максимум lr циклов",
"lr_scheduler_cycle_min_lr": "Минимум lr циклов",
@@ -1401,6 +1419,11 @@
"came_clip_threshold": "Порог обрезки (CAME)",
"lion_beta1": "Lion β1",
"lion_beta2": "Lion β2",
+ "ademamix_beta1": "AdEMAMix β1",
+ "ademamix_beta2": "AdEMAMix β2",
+ "ademamix_alpha": "AdEMAMix α",
+ "ademamix_beta1_warmup": "Разогрев β1 (шагов)",
+ "ademamix_min_beta1": "Стартовое β1",
"automagic_min_lr": "Минимальный lr",
"automagic_max_lr": "Максимальный lr",
"automagic_lr_bump": "Шаг подстройки",
diff --git a/studio/web/src/lib/schema.ts b/studio/web/src/lib/schema.ts
index b4a913d..c7d1e1c 100644
--- a/studio/web/src/lib/schema.ts
+++ b/studio/web/src/lib/schema.ts
@@ -132,6 +132,9 @@ export const SCHEMA_ENUM_LABEL_KEYS: Record> = {
cosine_with_warmup: 'schema.enums.lrScheduler.cosineWithWarmup',
cosine_cycles: 'schema.enums.lrScheduler.cosineCycles',
constant_then_cosine: 'schema.enums.lrScheduler.constantThenCosine',
+ polynomial: 'schema.enums.lrScheduler.polynomial',
+ rex: 'schema.enums.lrScheduler.rex',
+ rex_annealing_warm_restarts: 'schema.enums.lrScheduler.rexAnnealingWarmRestarts',
},
optimizer_type: {
adamw: 'schema.enums.optimizerType.adamw',
@@ -141,6 +144,7 @@ export const SCHEMA_ENUM_LABEL_KEYS: Record> = {
lion: 'schema.enums.optimizerType.lion',
prodigy: 'schema.enums.optimizerType.prodigy',
prodigy_plus_schedulefree: 'schema.enums.optimizerType.prodigyPlusSchedulefree',
+ simplified_ademamix: 'schema.enums.optimizerType.simplifiedAdemamix',
},
timestep_sampling: {
logit_normal: 'schema.enums.timestepSampling.logitNormal',
diff --git a/tests/test_optimizer_utils.py b/tests/test_optimizer_utils.py
index 4e7806f..8d35cbb 100644
--- a/tests/test_optimizer_utils.py
+++ b/tests/test_optimizer_utils.py
@@ -22,6 +22,7 @@
Automagic,
Automagic2,
Lion,
+ SimplifiedAdEMAMix,
create_automagic,
create_automagic_v2,
create_came,
@@ -452,6 +453,72 @@ def test_create_lion_rejects_invalid_betas() -> None:
create_optimizer("lion", model.parameters(), learning_rate=1e-4, betas=(1.0, 0.99))
+def _reference_simplified_ademamix_step(p, grad, state, *, lr, beta1, beta2, alpha, eps, wd):
+ """One step of DepenM/Simplified-AdEMAMix (bias_correction1=False, bias_correction2=True)."""
+ state["m"] = beta1 * state["m"] + grad
+ state["v"] = beta2 * state["v"] + (1 - beta2) * grad * grad
+ state["den"] = beta2 * state["den"] + (1 - beta2)
+ denom = state["v"].sqrt() + eps * state["den"] ** 0.5
+ update = (alpha * grad + state["m"]) / denom * state["den"] ** 0.5
+ update = update + wd * p
+ return p - lr * update
+
+
+def test_simplified_ademamix_matches_reference_formula() -> None:
+ torch.manual_seed(0)
+ p = nn.Parameter(torch.randn(4, 3))
+ ref = p.detach().clone()
+ ref_state = {"m": torch.zeros_like(ref), "v": torch.zeros_like(ref), "den": 0.0}
+ optim = create_optimizer(
+ "simplified_ademamix", [p], learning_rate=1e-3,
+ betas=(0.99, 0.999), weight_decay=0.01, alpha=2.0,
+ )
+ assert isinstance(optim, SimplifiedAdEMAMix)
+ for _ in range(3):
+ grad = torch.randn_like(ref)
+ p.grad = grad.clone()
+ optim.step()
+ ref = _reference_simplified_ademamix_step(
+ ref, grad, ref_state, lr=1e-3, beta1=0.99, beta2=0.999, alpha=2.0, eps=1e-8, wd=0.01,
+ )
+ assert torch.allclose(p.detach(), ref, atol=1e-6)
+ assert optim.state[p]["step"] == 3
+
+
+def test_simplified_ademamix_beta1_warmup_starts_from_min_beta1() -> None:
+ p = nn.Parameter(torch.zeros(2))
+ optim = SimplifiedAdEMAMix([p], lr=1e-3, betas=(0.99, 0.999), beta1_warmup=10, min_beta1=0.5)
+ p.grad = torch.ones(2)
+ optim.step()
+ p.grad = torch.ones(2)
+ optim.step()
+ # step 1 uses beta1 close to min_beta1, not 0.99: m = beta1_at_step2 * 1 + 1
+ m = optim.state[p]["exp_avg"][0].item()
+ assert 1.5 <= m < 1.99
+
+
+def test_simplified_ademamix_respects_param_group_weight_decay_and_resumes() -> None:
+ a = nn.Parameter(torch.ones(2))
+ b = nn.Parameter(torch.ones(2))
+ groups = [{"params": [a], "weight_decay": 0.1}, {"params": [b], "weight_decay": 0.0}]
+ optim = create_optimizer("simplified_ademamix", groups, learning_rate=1e-2, weight_decay=0.1)
+ a.grad = torch.zeros(2)
+ b.grad = torch.zeros(2)
+ optim.step()
+ assert torch.all(a < 1.0)
+ assert torch.allclose(b.detach(), torch.ones(2))
+
+ restored = create_optimizer("simplified_ademamix", groups, learning_rate=1e-2, weight_decay=0.1)
+ restored.load_state_dict(optim.state_dict())
+ assert restored.state[a]["step"] == 1
+ assert restored.state[a]["den_sum"] == pytest.approx(optim.state[a]["den_sum"])
+
+
+def test_simplified_ademamix_rejects_invalid_betas() -> None:
+ with pytest.raises(ValueError, match="Invalid beta1"):
+ create_optimizer("simplified_ademamix", [nn.Parameter(torch.ones(1))], learning_rate=1e-6, betas=(1.0, 0.999))
+
+
def test_monitor_metrics_uses_plain_lr_for_adamw() -> None:
"""AdamW-style optimizers keep the historical monitor lr unchanged."""
model = nn.Linear(4, 4)
diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py
index c96d632..b7627d2 100644
--- a/tests/test_plugin_registry.py
+++ b/tests/test_plugin_registry.py
@@ -33,11 +33,11 @@ def test_adapter_builders_dict_has_lokr_loha_lora() -> None:
assert set(BUILDERS) == {"lokr", "loha", "lora", "ortho", "tlora"}
-def test_optimizer_builders_dict_has_9_variants() -> None:
+def test_optimizer_builders_dict_has_10_variants() -> None:
from training.optimizers import BUILDERS, VALIDATORS
assert set(BUILDERS) == {
"adamw", "adamw8bit", "automagic", "came", "lion", "prodigy",
- "prodigy_plus_schedulefree", "soap", "soap_sf",
+ "prodigy_plus_schedulefree", "simplified_ademamix", "soap", "soap_sf",
}
assert set(VALIDATORS) == {
"adamw8bit", "automagic", "prodigy_plus_schedulefree", "soap_sf",
@@ -85,6 +85,25 @@ def test_adamw8bit_validate_rejects_missing_bitsandbytes(monkeypatch) -> None:
adamw8bit.validate(argparse.Namespace())
+def test_simplified_ademamix_build_passes_schema_fields() -> None:
+ torch = pytest.importorskip("torch")
+ from training.optimizers import build_optimizer
+
+ param = torch.nn.Parameter(torch.ones(2))
+ args = argparse.Namespace(
+ optimizer_type="simplified_ademamix",
+ ademamix_beta1=0.95, ademamix_beta2=0.99, ademamix_alpha=1.5,
+ ademamix_beta1_warmup=20, ademamix_min_beta1=0.8,
+ )
+ optimizer = build_optimizer(args, [param], 1e-6, 0.0)
+ group = optimizer.param_groups[0]
+ assert type(optimizer).__name__ == "SimplifiedAdEMAMix"
+ assert group["betas"] == (0.95, 0.99)
+ assert group["alpha"] == 1.5
+ assert group["beta1_warmup"] == 20
+ assert group["min_beta1"] == 0.8
+
+
def test_scheduler_builders_dict_excludes_none() -> None:
from training.schedulers import BUILDERS, SCHEMA_ONLY_OPTIONS
assert set(BUILDERS) == {
@@ -93,6 +112,9 @@ def test_scheduler_builders_dict_excludes_none() -> None:
"cosine_cycles",
"cosine_with_restart",
"cosine_with_warmup",
+ "polynomial",
+ "rex",
+ "rex_annealing_warm_restarts",
}
assert SCHEMA_ONLY_OPTIONS == {"none"}
@@ -182,6 +204,110 @@ def test_cosine_with_warmup_zero_warmup_starts_at_base_lr() -> None:
assert optimizer.param_groups[0]["lr"] == pytest.approx(0.5 * (1.0 + math.cos(math.pi * 0.25)))
+def _scheduler_lrs(name: str, steps: int, total_steps: int, **extra) -> list[float]:
+ torch = pytest.importorskip("torch")
+ from training.schedulers import build_scheduler
+
+ param = torch.nn.Parameter(torch.tensor([1.0]))
+ optimizer = torch.optim.SGD([param], lr=1.0)
+ args = argparse.Namespace(lr_scheduler=name, **extra)
+ scheduler = build_scheduler(args, optimizer, total_steps=total_steps)
+ lrs = [optimizer.param_groups[0]["lr"]]
+ for _ in range(steps):
+ optimizer.step()
+ scheduler.step()
+ lrs.append(optimizer.param_groups[0]["lr"])
+ return lrs
+
+
+def test_polynomial_warmup_then_power_decay_to_floor() -> None:
+ lrs = _scheduler_lrs(
+ "polynomial", 10, 10,
+ lr_scheduler_warmup_steps=2, lr_scheduler_power=2.0, lr_scheduler_eta_min=0.1,
+ )
+ assert lrs[0] == pytest.approx(0.0)
+ assert lrs[1] == pytest.approx(0.5)
+ assert lrs[2] == pytest.approx(1.0)
+ # step 6: progress = 4/8 -> 0.1 + 0.9 * 0.5**2
+ assert lrs[6] == pytest.approx(0.1 + 0.9 * 0.25)
+ assert lrs[10] == pytest.approx(0.1)
+
+
+def test_polynomial_power_one_is_linear() -> None:
+ lrs = _scheduler_lrs(
+ "polynomial", 4, 4,
+ lr_scheduler_warmup_steps=0, lr_scheduler_power=1.0, lr_scheduler_eta_min=0.0,
+ )
+ assert lrs == pytest.approx([1.0, 0.75, 0.5, 0.25, 0.0])
+
+
+def test_rex_matches_paper_formula() -> None:
+ lrs = _scheduler_lrs(
+ "rex", 4, 4, lr_scheduler_warmup_steps=0, lr_scheduler_eta_min=0.0, lr_scheduler_rex_d=0.5,
+ )
+ expected = []
+ for step in range(5):
+ z = 1.0 - step / 4
+ expected.append(z / (0.5 + 0.5 * z))
+ assert lrs == pytest.approx(expected)
+ # REX holds above linear decay until the very end
+ assert all(lr >= 1.0 - i / 4 for i, lr in enumerate(lrs))
+
+
+def test_rex_d_controls_curve_shape() -> None:
+ paper = _scheduler_lrs("rex", 4, 4, lr_scheduler_warmup_steps=0, lr_scheduler_eta_min=0.0, lr_scheduler_rex_d=0.5)
+ sharp = _scheduler_lrs("rex", 4, 4, lr_scheduler_warmup_steps=0, lr_scheduler_eta_min=0.0, lr_scheduler_rex_d=0.9)
+ # z=0.5: 0.5 / (0.1 + 0.45)
+ assert sharp[2] == pytest.approx(0.5 / 0.55)
+ assert sharp[2] > paper[2]
+ assert sharp[4] == pytest.approx(0.0)
+
+
+def _rawr_lrs(total: int, **extra) -> list[float]:
+ params = dict(
+ lr_scheduler_cycle_count=2, lr_scheduler_cycle_multiplier=1.0, lr_scheduler_gamma=0.5,
+ lr_scheduler_rex_d=0.5, lr_scheduler_eta_min=0.0, lr_scheduler_warmup_steps=0,
+ )
+ params.update(extra)
+ return _scheduler_lrs("rex_annealing_warm_restarts", total, total, **params)
+
+
+def test_rawr_restarts_with_gamma_scaled_peak() -> None:
+ lrs = _rawr_lrs(8)
+ # two cycles of 4 steps: REX(d=0.5) inside each, second peak scaled by gamma
+ rex = [z / (0.5 + 0.5 * z) for z in (1.0, 0.75, 0.5, 0.25)]
+ assert lrs[:4] == pytest.approx(rex)
+ assert lrs[4:8] == pytest.approx([0.5 * v for v in rex])
+
+
+def test_rawr_warmup_repeats_each_cycle_and_respects_floor() -> None:
+ lrs = _rawr_lrs(12, lr_scheduler_warmup_steps=2, lr_scheduler_eta_min=0.1, lr_scheduler_gamma=1.0)
+ # cycle = 2 warmup + 4 decay; warmup ramps from the floor
+ assert lrs[0] == pytest.approx(0.1)
+ assert lrs[1] == pytest.approx(0.55)
+ assert lrs[2] == pytest.approx(1.0)
+ assert lrs[6] == pytest.approx(0.1)
+ assert lrs[8] == pytest.approx(1.0)
+ assert min(lrs) >= 0.1 - 1e-9
+
+
+def test_rawr_cycle_multiplier_fills_total_steps() -> None:
+ from training.schedulers.rex_annealing_warm_restarts import cycle_lengths
+
+ lengths = cycle_lengths(700, 3, 2.0, 0)
+ assert lengths == [100, 200, 400]
+ assert sum(cycle_lengths(1000, 4, 1.5, 10)) == pytest.approx(1000, abs=3)
+
+
+def test_rex_warmup_and_floor() -> None:
+ lrs = _scheduler_lrs(
+ "rex", 6, 6, lr_scheduler_warmup_steps=2, lr_scheduler_eta_min=0.2,
+ )
+ assert lrs[1] == pytest.approx(0.5)
+ assert lrs[2] == pytest.approx(1.0)
+ assert lrs[6] == pytest.approx(0.2)
+
+
def test_cosine_with_warmup_multi_param_group_respects_eta_min() -> None:
torch = pytest.importorskip("torch")
from training.schedulers import build_scheduler
diff --git a/tests/test_studio_configs.py b/tests/test_studio_configs.py
index 067e7b8..7471792 100644
--- a/tests/test_studio_configs.py
+++ b/tests/test_studio_configs.py
@@ -103,8 +103,10 @@ def test_schema_carries_ui_metadata(client: TestClient) -> None:
assert props["came_clip_threshold"]["show_when"] == "optimizer_type==came"
assert "automagic" not in props["learning_rate"]["disable_when"]
assert props["lr_scheduler"]["disable_when"] == "optimizer_type==automagic||optimizer_type==prodigy||optimizer_type==prodigy_plus_schedulefree||optimizer_type==soap_sf"
- assert props["lr_scheduler_warmup_steps"]["show_when"] == "lr_scheduler==cosine_with_warmup"
- assert props["lr_scheduler_cycle_count"]["show_when"] == "lr_scheduler==cosine_cycles"
+ assert props["lr_scheduler_warmup_steps"]["show_when"] == "lr_scheduler==cosine_with_warmup||lr_scheduler==polynomial||lr_scheduler==rex||lr_scheduler==rex_annealing_warm_restarts"
+ assert props["lr_scheduler_power"]["show_when"] == "lr_scheduler==polynomial"
+ assert props["ademamix_beta1"]["show_when"] == "optimizer_type==simplified_ademamix"
+ assert props["lr_scheduler_cycle_count"]["show_when"] == "lr_scheduler==cosine_cycles||lr_scheduler==rex_annealing_warm_restarts"
assert props["lr_scheduler_decay_start_ratio"]["show_when"] == "lr_scheduler==constant_then_cosine"
assert props["automagic_min_lr"]["show_when"] == "optimizer_type==automagic"
assert props["automagic_max_lr"]["show_when"] == "optimizer_type==automagic"
diff --git a/utils/optimizer_utils.py b/utils/optimizer_utils.py
index 5d7c6f8..4edf795 100644
--- a/utils/optimizer_utils.py
+++ b/utils/optimizer_utils.py
@@ -20,6 +20,8 @@
to save memory + confidence guidance (instability EMA) to suppress the update noise
introduced by the factorization approximation.
Derived from the official implementation yangluo7/CAME (MIT, Copyright (c) 2023 Yang Luo).
+10. SimplifiedAdEMAMix - single raw-gradient momentum + current-gradient mix
+ (Morwani et al., 2025, arxiv 2502.02431). Adapted from DepenM/Simplified-AdEMAMix.
"""
from __future__ import annotations
@@ -27,6 +29,7 @@
from contextlib import contextmanager
import inspect
import logging
+import math
from typing import List, Dict, Any, Optional, Iterator
import torch
@@ -244,11 +247,21 @@ def create_optimizer(
**kwargs,
)
+ elif optimizer_type == "simplified_ademamix":
+ return create_simplified_ademamix(
+ params=params,
+ lr=learning_rate,
+ betas=betas,
+ weight_decay=weight_decay,
+ eps=eps,
+ **kwargs,
+ )
+
else:
raise ValueError(
f"Unknown optimizer type: {optimizer_type}. "
f"Choose from: adamw, automagic, automagic_v2, came, lion, prodigy, "
- f"prodigy_plus_schedulefree, soap, soap_sf"
+ f"prodigy_plus_schedulefree, simplified_ademamix, soap, soap_sf"
)
@@ -1097,6 +1110,151 @@ def create_lion(
return optimizer
+def _ademamix_beta1_warmup(step: int, beta_end: float, beta_start: float, warmup: int) -> float:
+ """Half-life-linear beta1 warmup from the AdEMAMix paper (linear in the EMA half-life)."""
+ if warmup <= 0 or step >= warmup:
+ return beta_end
+
+ def half_life(beta: float, eps: float = 1e-8) -> float:
+ return math.log(0.5) / math.log(beta + eps) - 1
+
+ def from_half_life(t: float) -> float:
+ return math.pow(0.5, 1 / (t + 1))
+
+ a = step / float(warmup)
+ return from_half_life((1.0 - a) * half_life(beta_start) + a * half_life(beta_end))
+
+
+class SimplifiedAdEMAMix(Optimizer):
+ """Simplified AdEMAMix (Morwani et al., 2025).
+
+ Paper: "Connections between Schedule-Free Optimizers, AdEMAMix, and
+ Accelerated SGD Variants" https://arxiv.org/abs/2502.02431
+
+ Reference implementation: https://github.com/DepenM/Simplified-AdEMAMix
+ (adapted from apple/ml-ademamix). A single momentum buffer accumulates raw
+ gradients (no 1-beta1 factor), mixed with alpha * current gradient:
+ m = beta1 * m + g
+ update = (alpha * g + m) / (sqrt(v) + eps)
+ Without numerator bias correction the update is ~1/(1-beta1) larger than
+ Adam's, so lr has to be scaled down by roughly (1 - beta1).
+ """
+
+ def __init__(
+ self,
+ params,
+ lr: float = 1e-6,
+ betas: tuple[float, float] = (0.99, 0.999),
+ alpha: float = 0.0,
+ beta1_warmup: int = 0,
+ min_beta1: float = 0.9,
+ eps: float = 1e-8,
+ weight_decay: float = 0.0,
+ bias_correction1: bool = False,
+ bias_correction2: bool = True,
+ ) -> None:
+ if lr < 0.0:
+ raise ValueError(f"Invalid learning rate: {lr}")
+ if eps < 0.0:
+ raise ValueError(f"Invalid eps: {eps}")
+ if not 0.0 <= betas[0] < 1.0:
+ raise ValueError(f"Invalid beta1: {betas[0]}")
+ if not 0.0 <= betas[1] < 1.0:
+ raise ValueError(f"Invalid beta2: {betas[1]}")
+ if weight_decay < 0.0:
+ raise ValueError(f"Invalid weight_decay: {weight_decay}")
+ if alpha < 0.0:
+ raise ValueError(f"Invalid alpha: {alpha}")
+ defaults = dict(
+ lr=lr, betas=betas, alpha=alpha, beta1_warmup=int(beta1_warmup or 0),
+ min_beta1=min_beta1, eps=eps, weight_decay=weight_decay,
+ bias_correction1=bias_correction1, bias_correction2=bias_correction2,
+ )
+ super().__init__(params, defaults)
+
+ @torch.no_grad()
+ def step(self, closure=None):
+ loss = None
+ if closure is not None:
+ with torch.enable_grad():
+ loss = closure()
+
+ for group in self.param_groups:
+ lr = group["lr"]
+ weight_decay = group["weight_decay"]
+ eps = group["eps"]
+ beta1_final, beta2 = group["betas"]
+ alpha = group["alpha"]
+ for p in group["params"]:
+ if p.grad is None:
+ continue
+ grad = p.grad
+ if grad.is_sparse:
+ raise RuntimeError("SimplifiedAdEMAMix does not support sparse gradients")
+
+ state = self.state[p]
+ if len(state) == 0:
+ state["step"] = 0
+ state["exp_avg"] = torch.zeros_like(p, memory_format=torch.preserve_format)
+ state["exp_avg_sq"] = torch.zeros_like(p, memory_format=torch.preserve_format)
+ state["num_sum"] = 0.0
+ state["den_sum"] = 0.0
+ exp_avg, exp_avg_sq = state["exp_avg"], state["exp_avg_sq"]
+
+ state["step"] += 1
+ beta1 = _ademamix_beta1_warmup(
+ state["step"], beta1_final, group["min_beta1"], group["beta1_warmup"],
+ )
+
+ exp_avg.mul_(beta1).add_(grad)
+ state["num_sum"] = beta1 * state["num_sum"] + 1.0
+ exp_avg_sq.mul_(beta2).addcmul_(grad, grad, value=1.0 - beta2)
+ state["den_sum"] = beta2 * state["den_sum"] + (1.0 - beta2)
+
+ denom = exp_avg_sq.sqrt().add_(eps * math.sqrt(state["den_sum"]))
+ update = exp_avg.add(grad, alpha=alpha) if alpha != 0.0 else exp_avg.clone()
+ update.div_(denom)
+ if group["bias_correction1"]:
+ update.div_(state["num_sum"])
+ if group["bias_correction2"]:
+ update.mul_(math.sqrt(state["den_sum"]))
+ if weight_decay != 0.0:
+ update.add_(p, alpha=weight_decay)
+
+ p.add_(update, alpha=-lr)
+
+ return loss
+
+
+def create_simplified_ademamix(
+ params: Iterator[nn.Parameter],
+ lr: float,
+ betas: tuple = (0.99, 0.999),
+ weight_decay: float = 0.0,
+ eps: float = 1e-8,
+ **kwargs,
+) -> Optimizer:
+ # Without numerator bias correction the step is ~1/(1-beta1) times Adam's
+ # (100x at beta1=0.99), so an AdamW-scale lr diverges almost immediately.
+ adam_equivalent = lr / max(1e-12, 1.0 - float(betas[0]))
+ if not kwargs.get("bias_correction1", False) and adam_equivalent >= 1e-3:
+ logger.warning(
+ "SimplifiedAdEMAMix lr=%.2e with beta1=%s behaves like AdamW lr~%.2e; "
+ "scale lr by (1 - beta1), e.g. AdamW 1e-4 -> %.0e",
+ lr, betas[0], adam_equivalent, 1e-4 * (1.0 - float(betas[0])),
+ )
+ param_list = params if _is_param_groups(params) else list(params)
+ optimizer = SimplifiedAdEMAMix(
+ param_list, lr=lr, betas=betas, weight_decay=weight_decay, eps=eps, **kwargs,
+ )
+ print(
+ f"Creating SimplifiedAdEMAMix optimizer (lr={lr}, betas={betas}, "
+ f"weight_decay={weight_decay}, {kwargs})"
+ )
+ print(" [OK] SimplifiedAdEMAMix optimizer created")
+ return optimizer
+
+
class CAME(Optimizer):
"""Confidence-guided Adaptive Memory Efficient optimizer (Luo et al., 2023).