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).