From f7364728412a74234e4d236c1c126708906e6450 Mon Sep 17 00:00:00 2001 From: dualchimerra Date: Thu, 24 Sep 2026 15:55:44 +0200 Subject: [PATCH] feat(training): dual-peak timestep sampling, cyclic LR schedules, queue ETA Timestep sampling - New `dual_peak` mode backed by the standalone runtime/dual_peak_sampling package: a mixture of two log-SNR components with independent position, width and weight. Wired into the baseline and InfoNoise samplers and exposed in the schema as fields gated on timestep_sampling==dual_peak. LR schedulers - `cosine_cycles`: training is split into N equal cosine cycles, with a shared max/min LR or per-cycle lists. - `constant_then_cosine`: hold the LR, then cosine-decay over the final fraction of training (decay_start_ratio, decay_min_lr). - Warmup/restart fields are now shown only for the schedulers that use them. Web - FloatListField for per-cycle LR lists; ru/en/zh strings. - Projects and Queue show a training-finish forecast derived from queue order and live monitor progress (lib/queueEstimates). Projects can be sorted by queue position, finish time, updated, created or title. Tests cover the sampler, both schedulers, the schema bridge and the forecast. Co-Authored-By: Claude Opus 5.5 --- runtime/dual_peak_sampling/README.md | 122 +++++++++++++ runtime/dual_peak_sampling/__init__.py | 6 + runtime/dual_peak_sampling/core.py | 126 +++++++++++++ runtime/dual_peak_sampling/pyproject.toml | 21 +++ runtime/dual_peak_sampling/tests/test_core.py | 130 +++++++++++++ runtime/training/schedulers/__init__.py | 10 +- .../schedulers/constant_then_cosine.py | 60 ++++++ runtime/training/schedulers/cosine_cycles.py | 107 +++++++++++ .../training/timestep_samplers/baseline.py | 16 +- .../training/timestep_samplers/dual_peak.py | 27 +++ .../training/timestep_samplers/infonoise.py | 7 + runtime/training/timestep_sampling.py | 10 + studio/domain/training.py | 171 +++++++++++++++++- .../src/components/DualPeakSampling.test.tsx | 67 +++++++ studio/web/src/components/Field.test.tsx | 28 +++ studio/web/src/components/Field.tsx | 74 ++++++++ studio/web/src/i18n/locales/en.json | 40 +++- studio/web/src/i18n/locales/ru.json | 44 ++++- studio/web/src/i18n/locales/zh.json | 37 +++- studio/web/src/lib/queueEstimates.test.ts | 46 +++++ studio/web/src/lib/queueEstimates.ts | 69 +++++++ studio/web/src/lib/schema.test.ts | 4 +- studio/web/src/lib/schema.ts | 8 +- studio/web/src/pages/Projects.tsx | 106 ++++++++++- studio/web/src/pages/Queue.tsx | 58 ++++++ tests/test_argparse_bridge.py | 13 ++ tests/test_dual_peak_sampler.py | 109 +++++++++++ tests/test_plugin_registry.py | 63 ++++++- tests/test_studio_configs.py | 43 ++++- 29 files changed, 1596 insertions(+), 26 deletions(-) create mode 100644 runtime/dual_peak_sampling/README.md create mode 100644 runtime/dual_peak_sampling/__init__.py create mode 100644 runtime/dual_peak_sampling/core.py create mode 100644 runtime/dual_peak_sampling/pyproject.toml create mode 100644 runtime/dual_peak_sampling/tests/test_core.py create mode 100644 runtime/training/schedulers/constant_then_cosine.py create mode 100644 runtime/training/schedulers/cosine_cycles.py create mode 100644 runtime/training/timestep_samplers/dual_peak.py create mode 100644 studio/web/src/components/DualPeakSampling.test.tsx create mode 100644 studio/web/src/lib/queueEstimates.test.ts create mode 100644 studio/web/src/lib/queueEstimates.ts create mode 100644 tests/test_dual_peak_sampler.py diff --git a/runtime/dual_peak_sampling/README.md b/runtime/dual_peak_sampling/README.md new file mode 100644 index 0000000..beebbad --- /dev/null +++ b/runtime/dual_peak_sampling/README.md @@ -0,0 +1,122 @@ +# Dual Peak Sampling + +Experimental, configurable timestep sampling for flow-matching training. +The reusable package depends only on PyTorch, not on AnimaTrainHub, NumPy, +Pydantic, a model, or a GPU. It does **not** promise better style fidelity. + +## Standalone use + +This whole directory can become the root of a separate repository unchanged. +From that directory, run `pip install .` (or `pip install -e '.[test]'` for +development), then `python -m pytest`. No package installation is needed inside +AnimaTrainHub: its runtime directory already exposes this package. + +```python +import torch +from dual_peak_sampling import DualPeakConfig, sample_timesteps, density + +config = DualPeakConfig(peak1_position=0.525, peak2_position=0.85) +generator = torch.Generator(device="cpu").manual_seed(42) +t = sample_timesteps(4096, "cpu", config, generator=generator) +x = torch.linspace(0.001, 0.999, 10000, dtype=torch.float64) +p = density(x, config) # analytic curve for plotting, NOT loss weights +``` + +`sample_timesteps` returns float32 `[batch_size]`, on the requested device. +Without an explicit generator it uses the global PyTorch RNG, compatible with +the trainer's seed and RNG checkpoint restoration. CPU and CUDA are supported; +matching seeds do not imply identical results across devices/PyTorch versions. +There is no internal RNG, learned state, or sample history. + +## Distribution and agreed preset + +Convention: `x_t = (1-t)*data + t*noise`; `t=0` is clean, `t=1` is noise. +On a 1000-step display, `t=0.85` is 850. Do not confuse this flow noise +coefficient with noise-to-signal sigma `t/(1-t)`, or a width in log-SNR units. + +| Component | Location | Width in log-SNR | Relative weight | +|---|---|---|---| +| First peak | component mode t=0.525 | 0.55 | 0.15 | +| Second peak | component mode t=0.850 | 1.20 | 0.35 | +| Broad Style-Friendly background | log-SNR mean=-3.2 | 2.20 | 0.45 | +| Uniform coverage | entire interval | — | 0.05 | + +For each sample, choose **one** component with normalized weights, then draw +from that component. Do not average component samples: that produces a different +distribution. Increasing one weight reduces the normalized shares of the others; +weights represent probability mass, not independent absolute peak heights. +Weights can be zero, but cannot all be zero. They need not add to one. + +For a normal log-SNR component: + +``` +lambda ~ Normal(mean, width**2) +t = sigmoid(-lambda/2) +mean = -2 * (logit(position) - (width/2)**2 * (2*position - 1)) +``` + +The last equation places the **individual component's density mode** at +`position`, including the change-of-variable factor. Peak widths are restricted +to `(0, 2.8]` so that each peak remains unimodal; the broad background allows +widths up to 6 and is specified by mean, not mode. + +The sum has different maxima: with defaults, approximately **540 and 870**. +The broad background contributes over the whole interval, not only the tail. +Expected masses before optional trainer shifts (rounded): + +| t interval | Probability | +|---|---| +| 0–0.3 | 2.09% | +| 0.3–0.6 | 21.46% | +| 0.6–0.9 | 58.64% | +| 0.9–1 | 17.80% | + +The sampler clamps results to `[1e-4, 1-1e-4]` for numerical safety. `density` +describes the continuous mixture before that clamp, excluding its endpoint atoms. +Arbitrary settings can merge the two peaks; two visible maxima are not guaranteed. + +Inspired by the [Anima style experiment](https://note.com/kuon_noise/n/n82be977f167d) +and [Style-Friendly SNR](https://arxiv.org/abs/2411.14793). This is a custom +mixture, not an official implementation or a proven universal style optimum. + +## AnimaTrainHub integration + +Select **Dual Peak — два пика для стиля** in the timestep sampling section +(advanced settings). YAML/CLI mode: `dual_peak`. All package parameters are +prefixed with `dual_peak_` in the trainer: + +```yaml +timestep_sampling: dual_peak +dual_peak_peak1_position: 0.525 +dual_peak_peak1_width: 0.55 +dual_peak_peak1_weight: 0.15 +dual_peak_peak2_position: 0.85 +dual_peak_peak2_width: 1.20 +dual_peak_peak2_weight: 0.35 +dual_peak_background_mean: -3.2 +dual_peak_background_width: 2.20 +dual_peak_background_weight: 0.45 +dual_peak_uniform_weight: 0.05 + +# Preserve the agreed curve for a controlled comparison: +timestep_schedule_shift: 1.0 +timestep_shift_resolution_aware: false +infonoise_enabled: false +leap_enabled: false +loss_weighting: none +``` + +These are sampler settings, not a complete training configuration. Existing +configs/default sampling are not changed. `timestep_shift` is ignored in this +mode; schedule and resolution shifts still transform the final distribution. +InfoNoise uses this configuration only until its adaptive distribution is ready. +Leap uses it only for ordinary (non-Leap) training steps. Loss weighting changes +the relative training contribution after sampling, not the sampled histogram. + +CLI example: `--timestep-sampling dual_peak --dual-peak-peak2-position 0.85`. +Only the small adapter in `training/timestep_samplers/dual_peak.py` knows about +the trainer's prefixed arguments. To extract the package, keep that adapter in +the trainer and replace the bundled directory with the installed dependency. + +Before publishing a separate repository, choose a license and the final package +name/version. Nothing is published or uploaded by this integration. diff --git a/runtime/dual_peak_sampling/__init__.py b/runtime/dual_peak_sampling/__init__.py new file mode 100644 index 0000000..c393743 --- /dev/null +++ b/runtime/dual_peak_sampling/__init__.py @@ -0,0 +1,6 @@ +"""Portable, PyTorch-only dual-peak flow-matching timestep sampler.""" + +from .core import DualPeakConfig, density, sample_timesteps + +__version__ = "0.1.0" +__all__ = ["DualPeakConfig", "density", "sample_timesteps"] diff --git a/runtime/dual_peak_sampling/core.py b/runtime/dual_peak_sampling/core.py new file mode 100644 index 0000000..0354ff4 --- /dev/null +++ b/runtime/dual_peak_sampling/core.py @@ -0,0 +1,126 @@ +"""Independent of AnimaTrainHub: t=0 is data, t=1 is noise. + +Sample a mixture, NOT an average of samples (which would change its density). +The two peak locations describe individual components, not the final mixture. +""" + +from __future__ import annotations + +from dataclasses import dataclass, fields +import math + +import torch + + +@dataclass(frozen=True) +class DualPeakConfig: + """Widths are standard deviations in log-SNR; weights are relative masses. + + Peak widths <= 2.8 keep each peak component unimodal in t, making the + location parameter an actual mode, not a possible minimum between modes. + The broad background is an unconstrained log-SNR normal component. + """ + + peak1_position: float = 0.525 + peak1_width: float = 0.55 + peak1_weight: float = 0.15 + peak2_position: float = 0.850 + peak2_width: float = 1.20 + peak2_weight: float = 0.35 + background_mean: float = -3.2 + background_width: float = 2.20 + background_weight: float = 0.45 + uniform_weight: float = 0.05 + + def __post_init__(self) -> None: + for field in fields(self): + value = getattr(self, field.name) + if not math.isfinite(value): + raise ValueError(f"{field.name} must be finite") + for name in ("peak1_position", "peak2_position"): + if not 0.0 < getattr(self, name) < 1.0: + raise ValueError(f"{name} must be between 0 and 1 (exclusive)") + for name in ("peak1_width", "peak2_width"): + if not 0.0 < getattr(self, name) <= 2.8: + raise ValueError(f"{name} must be in (0, 2.8]") + if not 0.0 < self.background_width <= 6.0: + raise ValueError("background_width must be in (0, 6]") + if not -12.0 <= self.background_mean <= 6.0: + raise ValueError("background_mean must be in [-12, 6]") + for name in ("peak1_weight", "peak2_weight", "background_weight", "uniform_weight"): + if not 0.0 <= getattr(self, name) <= 1.0: + raise ValueError(f"{name} must be in [0, 1]") + if sum(self.weights) <= 0.0: + raise ValueError("At least one mixture weight must be positive") + + @property + def weights(self) -> tuple[float, float, float, float]: + return (self.peak1_weight, self.peak2_weight, + self.background_weight, self.uniform_weight) + + @property + def probabilities(self) -> tuple[float, ...]: + total = sum(self.weights) + return tuple(weight / total for weight in self.weights) + + @property + def components(self) -> tuple[tuple[float, float], ...]: + def logsnr_mean(position: float, width: float) -> float: + # d/dt log p(t) = 0 at position, including the transform Jacobian. + return -2.0 * (math.log(position) - math.log1p(-position) + - (width / 2.0) ** 2 * (2.0 * position - 1.0)) + + return ( + (logsnr_mean(self.peak1_position, self.peak1_width), self.peak1_width), + (logsnr_mean(self.peak2_position, self.peak2_width), self.peak2_width), + (self.background_mean, self.background_width), + ) + + +DEFAULT_CONFIG = DualPeakConfig() + + +@torch.no_grad() +def sample_timesteps( + batch_size: int, + device: torch.device | str = "cpu", + config: DualPeakConfig = DEFAULT_CONFIG, + *, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Return float32 [batch_size] on device, clamped to [1e-4, 1-1e-4]. + + Uses the caller's/global PyTorch RNG; never seeds it or uses Python/NumPy + randomness. For an explicit generator, its device must match `device`. + Float32 is intentional even inside mixed-precision training: bf16 would + round some high timesteps to exactly 1. No CPU/GPU synchronization needed. + """ + if isinstance(batch_size, bool) or not isinstance(batch_size, int) or batch_size < 0: + raise ValueError("batch_size must be a non-negative integer") + p1, p2, p_background, _ = config.probabilities + (m1, s1), (m2, s2), (mb, sb) = config.components + options = dict(device=device, dtype=torch.float32, generator=generator) + choose = torch.rand(batch_size, **options) + z = torch.randn(batch_size, **options) + mean = torch.where(choose < p1, m1, torch.where(choose < p1 + p2, m2, torch.full_like(choose, mb))) + width = torch.where(choose < p1, s1, torch.where(choose < p1 + p2, s2, torch.full_like(choose, sb))) + biased = torch.sigmoid(-0.5 * (mean + width * z)) + uniform = torch.rand(batch_size, **options) + t = torch.where(choose < p1 + p2 + p_background, biased, uniform) + return t.clamp(1e-4, 1.0 - 1e-4) + + +def density(t: torch.Tensor, config: DualPeakConfig = DEFAULT_CONFIG) -> torch.Tensor: + """Analytic density BEFORE safety clamping, for plots and distribution tests. + + Accepts floating tensors strictly inside (0, 1). Not a loss weight. + The safety clamp in sample_timesteps adds tiny endpoint point masses. + """ + if not t.is_floating_point() or not bool(((t > 0) & (t < 1)).all()): + raise ValueError("density expects floating t strictly inside (0, 1)") + lam = 2.0 * (torch.log1p(-t) - torch.log(t)) + result = torch.full_like(t, config.probabilities[3]) + for weight, (mean, width) in zip(config.probabilities[:3], config.components): + normal = torch.exp(-0.5 * ((lam - mean) / width).square()) + result = result + weight * normal / (width * math.sqrt(2.0 * math.pi)) * 2.0 / (t * (1.0 - t)) + return result diff --git a/runtime/dual_peak_sampling/pyproject.toml b/runtime/dual_peak_sampling/pyproject.toml new file mode 100644 index 0000000..3e8d37d --- /dev/null +++ b/runtime/dual_peak_sampling/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "dual-peak-sampling" +version = "0.1.0" +description = "Configurable dual-peak timestep sampling for flow-matching training" +readme = "README.md" +requires-python = ">=3.10" +dependencies = ["torch>=2.0"] + +[project.optional-dependencies] +test = ["pytest>=7"] + +[tool.setuptools] +packages = ["dual_peak_sampling"] +package-dir = {dual_peak_sampling = "."} + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/runtime/dual_peak_sampling/tests/test_core.py b/runtime/dual_peak_sampling/tests/test_core.py new file mode 100644 index 0000000..aa5cb9f --- /dev/null +++ b/runtime/dual_peak_sampling/tests/test_core.py @@ -0,0 +1,130 @@ +"""Portable tests: run after installing this directory as a package.""" + +from dataclasses import replace +import math + +import pytest +import torch + +from dual_peak_sampling import DualPeakConfig, density, sample_timesteps + + +def test_default_distribution_matches_agreed_curve(): + t = sample_timesteps(200_000, generator=torch.Generator().manual_seed(314)) + for lo, hi, expected in ( + (0, .3, .02093), (.3, .6, .21464), + (.6, .9, .58642), (.9, 1, .17800), + (.45, .6, .16832), (.8, .9, .28897), + ): + measured = float(((t >= lo) & (t < hi)).float().mean()) + assert abs(measured - expected) < .005 + + +def test_density_normalization_and_actual_mixture_peaks(): + t = torch.linspace(.00001, .99999, 100_000, dtype=torch.float64) + p = density(t) + assert abs(float(torch.trapezoid(p, t)) - 1.0) < 1e-4 + peaks = t[1:-1][(p[1:-1] > p[:-2]) & (p[1:-1] > p[2:])] + assert peaks.numel() == 2 + assert abs(float(peaks[0]) - .540) < .003 + assert abs(float(peaks[1]) - .870) < .003 + + +@pytest.mark.parametrize("position,width", [(.2, .55), (.525, .55), (.85, 1.2), (.7, 2.8)]) +def test_individual_peak_location_is_mode_not_median(position, width): + config = DualPeakConfig(peak1_position=position, peak1_width=width, + peak1_weight=1, peak2_weight=0, + background_weight=0, uniform_weight=0) + t = torch.linspace(.0001, .9999, 20000, dtype=torch.float64) + assert abs(float(t[density(t, config).argmax()]) - position) < .0001 + + +def test_independent_second_peak_and_width_controls(): + base = DualPeakConfig(peak1_weight=0, peak2_weight=1, + background_weight=0, uniform_weight=0) + for position, width in ((.65, .5), (.85, 1.2), (.9, 2)): + config = replace(base, peak2_position=position, peak2_width=width) + t = torch.linspace(.0001, .9999, 20000, dtype=torch.float64) + assert abs(float(t[density(t, config).argmax()]) - position) < .0001 + + +def test_zero_weights_and_normalization(): + config = DualPeakConfig(peak1_weight=0, peak2_weight=0, + background_weight=0, uniform_weight=.1) + t = sample_timesteps(30000, config=config, generator=torch.Generator().manual_seed(1)) + assert abs(float(t.mean()) - .5) < .01 + torch.testing.assert_close(density(torch.tensor([.1, .4, .9]), config), torch.ones(3)) + original = DualPeakConfig() + scaled = replace(original, peak1_weight=.3, peak2_weight=.7, + background_weight=.9, uniform_weight=.1) + torch.testing.assert_close( + sample_timesteps(256, config=original, generator=torch.Generator().manual_seed(7)), + sample_timesteps(256, config=scaled, generator=torch.Generator().manual_seed(7)), + rtol=0, atol=0, + ) + + +def test_pure_background_matches_logsnr_normal(): + config = DualPeakConfig(peak1_weight=0, peak2_weight=0, + background_weight=1, uniform_weight=0) + t = sample_timesteps(60000, config=config, generator=torch.Generator().manual_seed(8)) + lam = 2 * torch.log((1-t)/t) + assert abs(float(lam.mean()) - config.background_mean) < .04 + assert abs(float(lam.std()) - config.background_width) < .04 + + +@pytest.mark.parametrize("changes", [ + {"peak1_position": 0}, {"peak2_position": 1}, + {"peak1_width": 0}, {"peak2_width": 3}, {"background_width": -1}, + {"background_width": 7}, {"background_mean": -13}, + {"peak1_weight": -1}, {"uniform_weight": 2}, + {"peak2_position": math.nan}, {"background_mean": math.inf}, + {"uniform_weight": math.nan}, + {"peak1_weight": 0, "peak2_weight": 0, "background_weight": 0, "uniform_weight": 0}, +]) +def test_invalid_config_rejected(changes): + with pytest.raises(ValueError): + DualPeakConfig(**changes) + + +def test_reproducible_with_external_rng_state_restore(): + generator = torch.Generator().manual_seed(22) + sample_timesteps(13, generator=generator) + state = generator.get_state() + expected = sample_timesteps(512, generator=generator) + generator.set_state(state) + torch.testing.assert_close(expected, sample_timesteps(512, generator=generator), rtol=0, atol=0) + + +def test_empty_singleton_and_dtype_under_autocast(): + assert sample_timesteps(0).shape == (0,) + assert sample_timesteps(1).shape == (1,) + with torch.autocast("cpu", dtype=torch.bfloat16): + t = sample_timesteps(1000, config=DualPeakConfig(background_mean=-12, background_width=6)) + assert t.dtype == torch.float32 + assert not t.requires_grad + assert bool(torch.isfinite(t).all()) + assert bool(((t >= 1e-4) & (t <= 1-1e-4)).all()) + + +def test_float32_even_with_changed_global_default_dtype(): + previous = torch.get_default_dtype() + try: + torch.set_default_dtype(torch.float64) + assert sample_timesteps(16).dtype == torch.float32 + finally: + torch.set_default_dtype(previous) + + +@pytest.mark.parametrize("batch_size", [-1, 1.5, True]) +def test_invalid_batch_size(batch_size): + with pytest.raises(ValueError): + sample_timesteps(batch_size) + + +@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA unavailable") +def test_cuda(): + with torch.autocast("cuda", dtype=torch.bfloat16): + t = sample_timesteps(1024, "cuda", generator=torch.Generator(device="cuda").manual_seed(1)) + assert t.is_cuda and t.dtype == torch.float32 and t.shape == (1024,) + assert bool(((t > 0) & (t < 1)).all()) diff --git a/runtime/training/schedulers/__init__.py b/runtime/training/schedulers/__init__.py index 69ba7ea..aab289b 100644 --- a/runtime/training/schedulers/__init__.py +++ b/runtime/training/schedulers/__init__.py @@ -14,14 +14,22 @@ from typing import Callable, Optional -from training.schedulers import cosine, cosine_with_restart, cosine_with_warmup +from training.schedulers import ( + constant_then_cosine, + cosine, + cosine_cycles, + cosine_with_restart, + cosine_with_warmup, +) __all__ = ["BUILDERS", "build_scheduler", "validate_schema_consistency"] # "none" 不在 BUILDERS —— build_scheduler 显式判一下返回 None。 BUILDERS: dict[str, Callable] = { + "constant_then_cosine": constant_then_cosine.build, "cosine": cosine.build, + "cosine_cycles": cosine_cycles.build, "cosine_with_restart": cosine_with_restart.build, "cosine_with_warmup": cosine_with_warmup.build, } diff --git a/runtime/training/schedulers/constant_then_cosine.py b/runtime/training/schedulers/constant_then_cosine.py new file mode 100644 index 0000000..0e7d4a0 --- /dev/null +++ b/runtime/training/schedulers/constant_then_cosine.py @@ -0,0 +1,60 @@ +"""Constant learning rate followed by one final cosine decay.""" + +from __future__ import annotations + +import logging +import math +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("constant_then_cosine requires total_steps; falling back to none") + return None + + import torch + + total_steps = int(total_steps) + decay_start = float( + getattr(args, "lr_scheduler_decay_start_ratio", 2.0 / 3.0) + ) + min_lr = float(getattr(args, "lr_scheduler_decay_min_lr", 0.0) or 0.0) + if not 0.0 <= decay_start < 1.0: + raise ValueError( + "constant_then_cosine: lr_scheduler_decay_start_ratio must be in [0, 1)" + ) + if min_lr < 0.0: + raise ValueError("constant_then_cosine: lr_scheduler_decay_min_lr must be >= 0") + + lambdas = [] + for group in optimizer.param_groups: + base_lr = float(group["lr"]) + if min_lr > base_lr: + raise ValueError( + "constant_then_cosine: lr_scheduler_decay_min_lr " + f"({min_lr}) exceeds parameter-group LR ({base_lr})" + ) + min_factor = min_lr / base_lr if base_lr > 0.0 else 0.0 + + def lr_lambda(step: int, *, _min_factor=min_factor) -> float: + progress = min(1.0, max(0.0, float(step) / float(total_steps))) + if progress <= decay_start: + return 1.0 + decay_progress = (progress - decay_start) / (1.0 - decay_start) + cosine = 0.5 * (1.0 + math.cos(math.pi * decay_progress)) + return _min_factor + (1.0 - _min_factor) * cosine + + lambdas.append(lr_lambda) + + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambdas) + logger.info( + "LR scheduler: constant_then_cosine " + "(total_steps=%s, decay_start_ratio=%s, min_lr=%s)", + total_steps, + decay_start, + min_lr, + ) + return scheduler diff --git a/runtime/training/schedulers/cosine_cycles.py b/runtime/training/schedulers/cosine_cycles.py new file mode 100644 index 0000000..e0267fb --- /dev/null +++ b/runtime/training/schedulers/cosine_cycles.py @@ -0,0 +1,107 @@ +"""Equal-length cosine cycles with configurable LR bounds per cycle.""" + +from __future__ import annotations + +import logging +import math +from typing import Optional + + +logger = logging.getLogger(__name__) + + +def _float_list(args, name: str) -> list[float]: + value = getattr(args, name, None) + if value is None: + return [] + return [float(item) for item in value] + + +def _cycle_values( + *, + count: int, + common: float, + overrides: list[float], + name: str, +) -> list[float]: + if not overrides: + return [common] * count + if len(overrides) != count: + raise ValueError( + f"cosine_cycles: {name} must contain exactly {count} values; " + f"got {len(overrides)}" + ) + return overrides + + +def build(args, optimizer, total_steps: Optional[int]): + """Build hard-restart cosine cycles of equal length. + + ``cycle_max_lrs`` / ``cycle_min_lrs`` are absolute learning rates. Empty + lists fall back to the common values. A missing common maximum preserves + each optimizer parameter group's own base LR. + """ + if total_steps is None or int(total_steps) <= 0: + logger.warning("cosine_cycles requires total_steps; falling back to none") + return None + + import torch + + total_steps = int(total_steps) + cycle_count = int(getattr(args, "lr_scheduler_cycle_count", 3) or 3) + if cycle_count < 1: + raise ValueError("cosine_cycles: lr_scheduler_cycle_count must be >= 1") + + common_max = getattr(args, "lr_scheduler_cycle_max_lr", None) + common_max = None if common_max is None else float(common_max) + common_min = float(getattr(args, "lr_scheduler_cycle_min_lr", 0.0) or 0.0) + max_overrides = _float_list(args, "lr_scheduler_cycle_max_lrs") + min_overrides = _float_list(args, "lr_scheduler_cycle_min_lrs") + + lambdas = [] + resolved_for_log: list[tuple[list[float], list[float]]] = [] + for group in optimizer.param_groups: + base_lr = float(group["lr"]) + max_lrs = _cycle_values( + count=cycle_count, + common=base_lr if common_max is None else common_max, + overrides=max_overrides, + name="lr_scheduler_cycle_max_lrs", + ) + min_lrs = _cycle_values( + count=cycle_count, + common=common_min, + overrides=min_overrides, + name="lr_scheduler_cycle_min_lrs", + ) + for index, (min_lr, max_lr) in enumerate(zip(min_lrs, max_lrs), start=1): + if max_lr <= 0.0: + raise ValueError(f"cosine_cycles: cycle {index} max LR must be > 0") + if min_lr < 0.0: + raise ValueError(f"cosine_cycles: cycle {index} min LR must be >= 0") + if min_lr > max_lr: + raise ValueError( + f"cosine_cycles: cycle {index} min LR ({min_lr}) exceeds " + f"max LR ({max_lr})" + ) + + def lr_lambda(step: int, *, _base=base_lr, _max=max_lrs, _min=min_lrs) -> float: + progress = min(1.0, max(0.0, float(step) / float(total_steps))) + scaled = progress * cycle_count + cycle_index = min(int(scaled), cycle_count - 1) + phase = min(1.0, max(0.0, scaled - cycle_index)) + cosine = 0.5 * (1.0 + math.cos(math.pi * phase)) + lr = _min[cycle_index] + (_max[cycle_index] - _min[cycle_index]) * cosine + return lr / _base if _base > 0.0 else 0.0 + + lambdas.append(lr_lambda) + resolved_for_log.append((max_lrs, min_lrs)) + + scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda=lambdas) + logger.info( + "LR scheduler: cosine_cycles (total_steps=%s, cycles=%s, group_bounds=%s)", + total_steps, + cycle_count, + resolved_for_log, + ) + return scheduler diff --git a/runtime/training/timestep_samplers/baseline.py b/runtime/training/timestep_samplers/baseline.py index 52fc24f..6651981 100644 --- a/runtime/training/timestep_samplers/baseline.py +++ b/runtime/training/timestep_samplers/baseline.py @@ -1,14 +1,18 @@ """Baseline timestep 采样器:包装 training.timestep_sampling.sample_t。 非自适应;record / maybe_refresh 是 no-op。 -覆盖 7 种 mode:logit_normal / uniform / logit_normal_low / mode / -mixed_uniform_low / mixed_uniform_logit / style_friendly。 +覆盖 8 种 mode:logit_normal / uniform / logit_normal_low / mode / +mixed_uniform_low / mixed_uniform_logit / style_friendly / dual_peak。 """ from __future__ import annotations +from dataclasses import asdict + import torch +from dual_peak_sampling import DualPeakConfig +from training.timestep_samplers.dual_peak import config_from_args from training.timestep_sampling import sample_t @@ -23,6 +27,7 @@ def __init__( timestep_schedule_shift: float = 1.0, style_snr_mean: float = -6.0, style_snr_sigma: float = 2.0, + dual_peak_config: DualPeakConfig | None = None, ): self.mode = mode self.shift = shift @@ -30,6 +35,10 @@ def __init__( self.timestep_schedule_shift = timestep_schedule_shift self.style_snr_mean = style_snr_mean self.style_snr_sigma = style_snr_sigma + self.dual_peak_config = ( + (dual_peak_config or DualPeakConfig()) + if (mode or "logit_normal").lower() == "dual_peak" else None + ) def sample(self, bs: int, device, *, token_counts=None) -> torch.Tensor: return sample_t( @@ -41,6 +50,7 @@ def sample(self, bs: int, device, *, token_counts=None) -> torch.Tensor: timestep_schedule_shift=self.timestep_schedule_shift, style_snr_mean=self.style_snr_mean, style_snr_sigma=self.style_snr_sigma, + dual_peak_config=self.dual_peak_config, ) def record(self, t: torch.Tensor, raw_mse: torch.Tensor) -> None: @@ -58,6 +68,7 @@ def status(self) -> dict: "timestep_schedule_shift": self.timestep_schedule_shift, "style_snr_mean": self.style_snr_mean, "style_snr_sigma": self.style_snr_sigma, + **({"dual_peak": asdict(self.dual_peak_config)} if self.dual_peak_config is not None else {}), } # ─── pause/resume(ADR 0006 Addendum 1):无状态采样器是真的无状态,no-op ─── @@ -79,6 +90,7 @@ def build(args, total_steps) -> BaselineTimestepSampler: # 显式 None 检查而非 `or`:mean 的合法值里有 0.0,sigma 没有但保持同一写法 style_snr_mean=_float_or(getattr(args, "style_snr_mean", None), -6.0), style_snr_sigma=_float_or(getattr(args, "style_snr_sigma", None), 2.0), + dual_peak_config=config_from_args(args), ) diff --git a/runtime/training/timestep_samplers/dual_peak.py b/runtime/training/timestep_samplers/dual_peak.py new file mode 100644 index 0000000..db17699 --- /dev/null +++ b/runtime/training/timestep_samplers/dual_peak.py @@ -0,0 +1,27 @@ +"""AnimaTrainHub config adapter; the reusable package knows nothing about Studio.""" + +from dataclasses import fields +import logging + +from dual_peak_sampling import DualPeakConfig + +logger = logging.getLogger(__name__) + + +def config_from_args(args) -> DualPeakConfig | None: + if str(getattr(args, "timestep_sampling", "")).lower() != "dual_peak": + return None + values = {} + for field in fields(DualPeakConfig): + value = getattr(args, f"dual_peak_{field.name}", None) + if value is not None: + values[field.name] = float(value) + config = DualPeakConfig(**values) + if getattr(args, "infonoise_enabled", False): + logger.warning("Dual Peak: InfoNoise uses this curve only until its adaptive distribution is ready.") + if getattr(args, "leap_enabled", False): + logger.warning("Dual Peak: the curve applies only to non-Leap steps; Leap samples its own timesteps.") + if (getattr(args, "timestep_schedule_shift", 1.0) not in (None, 1.0) + or getattr(args, "timestep_shift_resolution_aware", False)): + logger.warning("Dual Peak: schedule/resolution shifts are enabled and will move the configured peaks.") + return config diff --git a/runtime/training/timestep_samplers/infonoise.py b/runtime/training/timestep_samplers/infonoise.py index d3807e5..61c0599 100644 --- a/runtime/training/timestep_samplers/infonoise.py +++ b/runtime/training/timestep_samplers/infonoise.py @@ -38,6 +38,9 @@ import numpy as np import torch +from dual_peak_sampling import DualPeakConfig +from training.timestep_samplers.dual_peak import config_from_args + logger = logging.getLogger(__name__) @@ -63,6 +66,7 @@ def __init__( baseline_timestep_schedule_shift: float = 1.0, baseline_style_snr_mean: float = -6.0, baseline_style_snr_sigma: float = 2.0, + baseline_dual_peak_config: DualPeakConfig | None = None, ): if not 0.0 < p_onset < 1.0: raise ValueError(f"p_onset 必须 ∈ (0,1),得到 {p_onset}") @@ -81,6 +85,7 @@ def __init__( self.baseline_timestep_schedule_shift = baseline_timestep_schedule_shift self.baseline_style_snr_mean = baseline_style_snr_mean self.baseline_style_snr_sigma = baseline_style_snr_sigma + self.baseline_dual_peak_config = baseline_dual_peak_config self._internal_step = 0 sigma_min = t_min / (1.0 - t_min) @@ -125,6 +130,7 @@ def _sample_baseline(self, bs: int, device) -> torch.Tensor: timestep_schedule_shift=self.baseline_timestep_schedule_shift, style_snr_mean=self.baseline_style_snr_mean, style_snr_sigma=self.baseline_style_snr_sigma, + dual_peak_config=self.baseline_dual_peak_config, ) def record(self, t: torch.Tensor, raw_mse: torch.Tensor): @@ -312,6 +318,7 @@ def build(args, total_steps: Optional[int]) -> InfoNoiseScheduler: gate_pivot_c=float(getattr(args, "infonoise_gate_pivot_c", 0.15) or 0.0), baseline_shift=float(getattr(args, "timestep_shift", 3.0) or 3.0), baseline_mode=str(getattr(args, "timestep_sampling", "logit_normal") or "logit_normal"), + baseline_dual_peak_config=config_from_args(args), baseline_mix_low_prob=float(getattr(args, "timestep_mix_low_prob", 0.0) or 0.0), baseline_timestep_schedule_shift=float(getattr(args, "timestep_schedule_shift", 1.0) or 1.0), # style_friendly 作为热身 baseline 时也要拿到用户设的窗口(0.0 是合法 mean) diff --git a/runtime/training/timestep_sampling.py b/runtime/training/timestep_sampling.py index 4494474..580c708 100644 --- a/runtime/training/timestep_sampling.py +++ b/runtime/training/timestep_sampling.py @@ -11,6 +11,8 @@ import torch +from dual_peak_sampling import DualPeakConfig, sample_timesteps + from training.families.anima import ANIMA_SPEC as _ANIMA_SPEC @@ -23,10 +25,13 @@ def sample_t( timestep_schedule_shift: float = 1.0, style_snr_mean: float = -6.0, style_snr_sigma: float = 2.0, + dual_peak_config: DualPeakConfig | None = None, ) -> torch.Tensor: """采样 Flow Matching 时间步 t ∈ (0, 1)。 mode: + dual_peak — Portable custom style mixture; independent peak positions, + widths and weights. Ignores shift; see dual_peak_config. logit_normal — SD3/Anima 默认,偏向中间 t;shift>1 推向高噪声端 uniform — 均匀采样,对细节端和结构端覆盖更均衡 logit_normal_low — logit-normal 反向 shift,偏向低噪声/细节端 @@ -47,6 +52,11 @@ def sample_t( """ mode = (mode or "logit_normal").lower() + if mode == "dual_peak": + # Like style_friendly, positions are explicit: ignore timestep_shift. + t = sample_timesteps(bs, device, dual_peak_config or DualPeakConfig()) + return _apply_timestep_schedule_shift(t, timestep_schedule_shift) + if mode == "style_friendly": t = sample_t_style_friendly(bs, device, mean=style_snr_mean, sigma=style_snr_sigma) return _apply_timestep_schedule_shift(t, timestep_schedule_shift) diff --git a/studio/domain/training.py b/studio/domain/training.py index 75b26c4..6ca32ad 100644 --- a/studio/domain/training.py +++ b/studio/domain/training.py @@ -418,7 +418,14 @@ class TrainingConfig(BaseModel): disable_hint="此优化器自动管理学习率", ), ) - lr_scheduler: Literal["none", "cosine", "cosine_with_restart", "cosine_with_warmup"] = Field( + lr_scheduler: Literal[ + "none", + "cosine", + "cosine_with_restart", + "cosine_with_warmup", + "cosine_cycles", + "constant_then_cosine", + ] = Field( "none", description="学习率调度(none = 常数;Prodigy / PPSF / Automagic / SOAP-SF 固定为 none)", json_schema_extra=_meta( @@ -441,13 +448,52 @@ class TrainingConfig(BaseModel): lr_scheduler_eta_min: float = Field( 1e-6, ge=0.0, description="学习率衰减下限:cosine 调度到此值后不再下降;通常远小于初始 lr(如初始 1e-4 配 1e-6)", - json_schema_extra=_meta("training", show_when="lr_scheduler!=none", advanced=True), + json_schema_extra=_meta( + "training", + show_when="lr_scheduler==cosine||lr_scheduler==cosine_with_restart||lr_scheduler==cosine_with_warmup", + advanced=True, + ), ) lr_scheduler_warmup_steps: int = Field( 100, ge=0, description="cosine_with_warmup 预热步数", json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_with_warmup", advanced=True), ) + lr_scheduler_cycle_count: int = Field( + 3, ge=1, le=100, + description="cosine_cycles 的峰数;总训练步数会被等分为这么多个周期", + json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles"), + ) + lr_scheduler_cycle_max_lr: Optional[float] = Field( + None, gt=0.0, + description="所有周期共用的最高 LR;留空时使用 learning_rate", + json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles"), + ) + lr_scheduler_cycle_min_lr: float = Field( + 0.0, ge=0.0, + description="所有周期共用的最低 LR;逐周期列表中的值会覆盖它", + json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles"), + ) + lr_scheduler_cycle_max_lrs: list[float] = Field( + default_factory=list, + description="Каждому пику свой максимум LR через запятую; пусто = общий максимум. Число значений должно совпадать с числом пиков", + json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles", advanced=True), + ) + lr_scheduler_cycle_min_lrs: list[float] = Field( + default_factory=list, + description="Каждому пику свой минимум LR через запятую; пусто = общий минимум. Число значений должно совпадать с числом пиков", + json_schema_extra=_meta("training", show_when="lr_scheduler==cosine_cycles", advanced=True), + ) + lr_scheduler_decay_start_ratio: float = Field( + 2.0 / 3.0, ge=0.0, lt=1.0, + description="constant_then_cosine: доля обучения, после которой начинается плавное снижение; 0.6667 = последняя треть", + json_schema_extra=_meta("training", show_when="lr_scheduler==constant_then_cosine"), + ) + lr_scheduler_decay_min_lr: float = Field( + 0.0, ge=0.0, + description="constant_then_cosine: LR в самом конце обучения", + 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( "adamw", description="优化器。adamw 标准基线;adamw8bit 同 AdamW 但状态量化到 int8(state 显存约 AdamW 的 1/4,超参照搬不用换算,需装 bitsandbytes);automagic 自适应每参数 lr(推荐 lr=1e-6);came 置信度引导 + 分解二阶矩(state 显存低于 AdamW,lr 同 AdamW 量级);lion 显存约 AdamW 一半(推荐 lr=AdamW lr / 3);prodigy / prodigy_plus_schedulefree 自适应估 lr(lr 填 1.0);soap Adam-in-Shampoo-eigenbasis 二阶预条件(拟合更快,lr 同 AdamW 量级);soap_sf SOAP + Schedule-Free(lr_scheduler 固定 none)", @@ -713,6 +759,7 @@ class TrainingConfig(BaseModel): "mixed_uniform_logit", "krea2_shift", "style_friendly", + "dual_peak", ] = Field( "logit_normal", description="采样分布。logit_normal 偏中段(SD3/Anima 默认);krea2_shift 按每图 token 数动态 shift;uniform 等概率;mode 单峰偏移;mixed_* 混合 uniform 与偏置端(比例由 timestep_mix_low_prob 控制);style_friendly 直接在 log-SNR 轴正态采样、把训练集中到风格成形的高噪声窗口(arXiv 2411.14793,风格 LoRA 专用,忽略 timestep_shift)", @@ -732,7 +779,7 @@ class TrainingConfig(BaseModel): json_schema_extra=_meta( "timestep_sampling", # style_friendly 的偏移完全由 style_snr_mean 给定,两者叠加会双重偏移 - show_when="timestep_sampling!=uniform&×tep_sampling!=style_friendly", + show_when="timestep_sampling!=uniform&×tep_sampling!=style_friendly&×tep_sampling!=dual_peak", alt_description="InfoNoise 开启时作为热身阶段的 baseline shift,正式阶段由自适应 CDF 接管;Leap 启用时 leap 路径恒用 U(0,1),本字段仅作用于 (1-leap_ratio) 比例的标准 step", alt_description_when="infonoise_enabled==true||leap_enabled==true", advanced=True, @@ -743,7 +790,7 @@ class TrainingConfig(BaseModel): description="mixed_* 模式下走偏置端的样本比例:0 = 全 uniform;典型 0.15-0.30", json_schema_extra=_meta( "timestep_sampling", - show_when="timestep_sampling!=uniform", + show_when="timestep_sampling!=uniform&×tep_sampling!=dual_peak", alt_description="InfoNoise 开启 + mixed_* baseline 时,热身阶段混合比例,正式阶段由自适应 CDF 接管;Leap 启用时 leap 路径恒用 U(0,1),本字段仅作用于 (1-leap_ratio) 比例的标准 step", alt_description_when="infonoise_enabled==true||leap_enabled==true", advanced=True, @@ -782,6 +829,76 @@ class TrainingConfig(BaseModel): advanced=True, ), ) + dual_peak_peak1_position: float = Field( + 0.525, gt=0.0, lt=1.0, allow_inf_nan=False, + description="First component peak in flow t: 0.525 = timestep 525/1000. The mixture maximum can move.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_peak1_width: float = Field( + 0.55, gt=0.0, le=2.8, allow_inf_nan=False, + description="First peak width (log-SNR standard deviation). Larger = wider, lower peak at the same weight.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_peak1_weight: float = Field( + 0.15, ge=0.0, le=1.0, allow_inf_nan=False, + description="First peak relative weight. All four weights are normalized together; zero disables this component.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_peak2_position: float = Field( + 0.85, gt=0.0, lt=1.0, allow_inf_nan=False, + description="Second component peak in flow t: 0.85 = timestep 850/1000. Defaults put the mixture maximum near 870.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_peak2_width: float = Field( + 1.2, gt=0.0, le=2.8, allow_inf_nan=False, + description="Second peak width (log-SNR standard deviation). Independent of the first peak.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_peak2_weight: float = Field( + 0.35, ge=0.0, le=1.0, allow_inf_nan=False, + description="Second peak relative weight. Normalized with the other three weights.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_background_mean: float = Field( + -3.2, ge=-12.0, le=6.0, allow_inf_nan=False, + description="Broad Style-Friendly component log-SNR mean. Lower moves it toward high noise. This covers the whole interval, not only the right tail.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_background_width: float = Field( + 2.2, gt=0.0, le=6.0, allow_inf_nan=False, + description="Broad Style-Friendly component log-SNR standard deviation.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_background_weight: float = Field( + 0.45, ge=0.0, le=1.0, allow_inf_nan=False, + description="Broad background relative weight. Zero removes the broad component.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) + dual_peak_uniform_weight: float = Field( + 0.05, ge=0.0, le=1.0, allow_inf_nan=False, + description="Uniform coverage relative weight. Default 0.05, normalized with other weights.", + json_schema_extra=_meta( + "timestep_sampling", show_when="timestep_sampling==dual_peak", advanced=True, + ), + ) timestep_shift_resolution_aware: bool = Field( False, description="按每图 token 数对采样后的 t 做分辨率修正(SD3 式 s=sqrt(该图 token 数/基准档 token 数)," @@ -1209,6 +1326,52 @@ def _enforce_disable_rules(self) -> "TrainingConfig": raise ValueError("A cross-field constraint is not satisfied — " + "; ".join(lines)) return self + @model_validator(mode="after") + def _validate_dual_peak_weights(self) -> "TrainingConfig": + if self.timestep_sampling == "dual_peak" and ( + self.dual_peak_peak1_weight + self.dual_peak_peak2_weight + + self.dual_peak_background_weight + self.dual_peak_uniform_weight + ) <= 0.0: + raise ValueError("dual_peak: at least one mixture weight must be positive") + return self + + @model_validator(mode="after") + def _validate_custom_lr_schedulers(self) -> "TrainingConfig": + if self.lr_scheduler == "cosine_cycles": + count = self.lr_scheduler_cycle_count + for name, values in ( + ("lr_scheduler_cycle_max_lrs", self.lr_scheduler_cycle_max_lrs), + ("lr_scheduler_cycle_min_lrs", self.lr_scheduler_cycle_min_lrs), + ): + if values and len(values) != count: + raise ValueError( + f"{name} must be empty or contain exactly {count} values" + ) + + common_max = self.lr_scheduler_cycle_max_lr or self.learning_rate + max_lrs = self.lr_scheduler_cycle_max_lrs or [common_max] * count + min_lrs = self.lr_scheduler_cycle_min_lrs or [self.lr_scheduler_cycle_min_lr] * count + for index, (min_lr, max_lr) in enumerate(zip(min_lrs, max_lrs), start=1): + if min_lr < 0.0 or max_lr <= 0.0: + raise ValueError( + f"cosine_cycles cycle {index}: min LR must be >= 0 and max LR > 0" + ) + if min_lr > max_lr: + raise ValueError( + f"cosine_cycles cycle {index}: min LR ({min_lr}) " + f"cannot exceed max LR ({max_lr})" + ) + + if ( + self.lr_scheduler == "constant_then_cosine" + and self.lr_scheduler_decay_min_lr > self.learning_rate + ): + raise ValueError( + "lr_scheduler_decay_min_lr cannot exceed learning_rate for " + "constant_then_cosine" + ) + return self + @model_validator(mode="after") def _validate_detail_inv_t_range(self) -> "TrainingConfig": """detail_inv_t 加权曲线的 min 必须 <= max;fail-fast 取代历史的静默 swap。""" diff --git a/studio/web/src/components/DualPeakSampling.test.tsx b/studio/web/src/components/DualPeakSampling.test.tsx new file mode 100644 index 0000000..2cc9a1c --- /dev/null +++ b/studio/web/src/components/DualPeakSampling.test.tsx @@ -0,0 +1,67 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SchemaResponse } from '../api/client' +import i18n from '../i18n' +import { schemaEnumLabel } from '../lib/schema' +import SchemaForm from './SchemaForm' + +const parameters = { + peak1_position: 0.525, peak1_width: 0.55, peak1_weight: 0.15, + peak2_position: 0.85, peak2_width: 1.2, peak2_weight: 0.35, + background_mean: -3.2, background_width: 2.2, background_weight: 0.45, + uniform_weight: 0.05, +} + +const schema: SchemaResponse = { + groups: [{ key: 'timestep_sampling', label: 'Timestep sampling' }], + schema: { properties: { + timestep_sampling: { + type: 'string', enum: ['logit_normal', 'style_friendly', 'dual_peak'], + default: 'logit_normal', group: 'timestep_sampling', advanced: true, + }, + timestep_shift: { + type: 'number', default: 3, group: 'timestep_sampling', advanced: true, + show_when: 'timestep_sampling!=uniform&×tep_sampling!=style_friendly&×tep_sampling!=dual_peak', + }, + ...Object.fromEntries(Object.entries(parameters).map(([key, value]) => [ + `dual_peak_${key}`, { + type: 'number', default: value, group: 'timestep_sampling', advanced: true, + show_when: 'timestep_sampling==dual_peak', + }, + ])), + } }, +} + +afterEach(async () => { await i18n.changeLanguage('zh') }) + +describe('Dual Peak sampling controls', () => { + it.each(['ru', 'en', 'zh'])('has a translated option and parameter help in %s', async (lang) => { + await i18n.changeLanguage(lang) + expect(schemaEnumLabel('timestep_sampling', 'dual_peak', i18n.t)).toContain('Dual Peak') + for (const key of Object.keys(parameters)) { + expect(i18n.exists(`schema.descriptions.dual_peak_${key}`, { lng: lang, fallbackLng: false })).toBe(true) + } + }) + + it('shows custom controls only for dual_peak, preserves values and edits the second peak', async () => { + await i18n.changeLanguage('ru') + const onChange = vi.fn() + const values = { + timestep_sampling: 'dual_peak', + ...Object.fromEntries(Object.entries(parameters).map(([k, v]) => [`dual_peak_${k}`, v])), + } + const { rerender } = render() + expect(screen.getByRole('option', { name: 'Dual Peak — два пика для стиля' })).toBeInTheDocument() + expect(screen.queryByText('Timestep Shift')).not.toBeInTheDocument() + expect(screen.getAllByRole('textbox')).toHaveLength(10) + const secondPeak = screen.getByDisplayValue('0.85') + fireEvent.change(secondPeak, { target: { value: '0.82' } }) + fireEvent.blur(secondPeak) + expect(onChange).toHaveBeenCalledWith({ ...values, dual_peak_peak2_position: 0.82 }) + onChange.mockClear() + rerender() + expect(screen.queryByText('Dual Peak Peak1 Position')).not.toBeInTheDocument() + expect(screen.getByText('Timestep Shift')).toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) +}) diff --git a/studio/web/src/components/Field.test.tsx b/studio/web/src/components/Field.test.tsx index 043b8c9..b079c3d 100644 --- a/studio/web/src/components/Field.test.tsx +++ b/studio/web/src/components/Field.test.tsx @@ -298,6 +298,34 @@ describe('Field string-list input (sample_prompts 换行)', () => { }) }) +const floatListProp: SchemaProperty = { + type: 'array', + items: { type: 'number' }, + default: [], + group: 'training', + description: '', +} + +describe('Field float-list input', () => { + it('parses decimal and scientific-notation learning rates', async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render( + , + ) + + const input = screen.getByRole('textbox') as HTMLInputElement + await user.type(input, '1e-4, 8e-5, 0.00006') + fireEvent.blur(input) + expect(onChange).toHaveBeenLastCalledWith([1e-4, 8e-5, 6e-5]) + }) +}) + describe('Field code input', () => { it('renders object values as formatted JSON and commits parsed objects on blur', () => { const onChange = vi.fn() diff --git a/studio/web/src/components/Field.tsx b/studio/web/src/components/Field.tsx index 451f01f..a85c805 100644 --- a/studio/web/src/components/Field.tsx +++ b/studio/web/src/components/Field.tsx @@ -208,6 +208,21 @@ export default function Field({ ) } + // float-list (e.g. per-cycle learning rates) ------------------------- + if (kind === 'float-list') { + return ( + + ) + } + // code ---------------------------------------------------------------- if (kind === 'code') { return ( @@ -474,6 +489,65 @@ function IntListField({ ) } +/** Decimal-number list. Scientific notation is accepted, which is important + * for LR values such as 1e-4 and 5e-5. */ +function FloatListField({ + label, helpNode, value, defaultValue, onChange, disabled = false, hintNode, +}: IntListFieldProps) { + const fmt = (v: unknown) => + Array.isArray(v) ? (v as number[]).join(', ') : v === null || v === undefined ? '' : String(v) + const [raw, setRaw] = useState(() => fmt(value)) + const inputRef = useRef(null) + const placeholder = fmt(defaultValue) + + useEffect(() => { + if (document.activeElement !== inputRef.current) setRaw(fmt(value)) + }, [value]) + + const commit = () => { + const nums = raw + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0) + .map((s) => Number(s)) + .filter((n) => Number.isFinite(n)) + if (nums.length === 0) { + const fallback = Array.isArray(defaultValue) ? defaultValue : [] + onChange(fallback) + setRaw(fmt(fallback)) + return + } + onChange(nums) + setRaw(nums.join(', ')) + } + + return ( +
+
+ {label}{hintNode} +
+ setRaw(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + commit() + } + }} + disabled={disabled} + className="input input-mono" style={fieldStyle(disabled)} + placeholder={placeholder} + /> + {helpNode} +
+ ) +} + interface NumberFieldProps { label: string kind: 'int' | 'float' diff --git a/studio/web/src/i18n/locales/en.json b/studio/web/src/i18n/locales/en.json index f5597c9..32e474e 100644 --- a/studio/web/src/i18n/locales/en.json +++ b/studio/web/src/i18n/locales/en.json @@ -310,7 +310,9 @@ "none": "Constant", "cosine": "Cosine", "cosineWithRestart": "Cosine with restart", - "cosineWithWarmup": "Cosine with warmup" + "cosineWithWarmup": "Cosine with warmup", + "cosineCycles": "Custom cosine cycles", + "constantThenCosine": "Constant, then cosine decay" }, "optimizerType": { "adamw": "AdamW", @@ -326,7 +328,8 @@ "uniform": "Uniform", "logitNormalLow": "Logit Normal Low", "mode": "Mode", - "styleFriendly": "Style-Friendly SNR" + "styleFriendly": "Style-Friendly SNR", + "dualPeak": "Dual Peak — custom style" }, "lossWeighting": { "none": "None", @@ -382,6 +385,16 @@ "infonoise_enabled": "Reset noise_enhancement / loss_weighting / loss_type / schedule_shift to defaults to enable (schema mutex)" }, "descriptions": { + "dual_peak_peak1_position": "First component peak in flow t: 0.525 = timestep 525/1000. The mixture maximum can move.", + "dual_peak_peak1_width": "First peak width (log-SNR standard deviation). Larger = wider, lower peak at the same weight.", + "dual_peak_peak1_weight": "First peak relative weight. All four weights are normalized together; zero disables this component.", + "dual_peak_peak2_position": "Second component peak in flow t: 0.85 = timestep 850/1000. Defaults put the mixture maximum near 870.", + "dual_peak_peak2_width": "Second peak width (log-SNR standard deviation). Independent of the first peak.", + "dual_peak_peak2_weight": "Second peak relative weight. Normalized with the other three weights.", + "dual_peak_background_mean": "Broad Style-Friendly component log-SNR mean. Lower moves it toward high noise. This covers the whole interval, not only the right tail.", + "dual_peak_background_width": "Broad Style-Friendly component log-SNR standard deviation.", + "dual_peak_background_weight": "Broad background relative weight. Zero removes the broad component.", + "dual_peak_uniform_weight": "Uniform coverage relative weight. Default 0.05, normalized with other weights.", "model_family": "Model family. Decides which model implementation training uses; other fields show/hide by family capability. Switching recalculates model paths and resets family-specific defaults", "transformer_path": "Main diffusion model weights (.safetensors)", "vae_path": "VAE weights (.safetensors)", @@ -424,6 +437,13 @@ "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_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)", "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", @@ -458,7 +478,7 @@ "noise_offset": "DC bias strength (0-0.2, 0=off). Shifts noise mean away from 0, giving the model exposure to extreme brightness scenes (pure black / pure white / high contrast). Typical range 0.05-0.1; below 0.05 the noise is nearly identical to baseline, above 0.1 the initial loss rises substantially", "pyramid_noise_iters": "Number of pyramid noise levels (0-6, 0=off). Each level injects at spatial // 2^(k+1). Effective strength is controlled by pyramid_noise_discount — iters alone determines which frequency bands are covered; layer count makes little difference when discount is low", "pyramid_noise_discount": "Per-level decay coefficient (0.1-0.9). Key knob for low-frequency strength: this implementation normalizes overall noise std to 1, so discount sets the low-frequency share. 0.1-0.4 leaves noise close to standard Gaussian after normalization (equivalent to off); 0.5-0.7 significantly alters low-frequency structure", - "timestep_sampling": "Sampling distribution. logit_normal biases the middle (SD3/Anima default); uniform is equal probability; mode is a single-peak shift; mixed_* blends uniform with a biased end (ratio controlled by timestep_mix_low_prob)", + "timestep_sampling": "Sampling distribution. dual_peak has two configurable peaks and a broad style background; style_friendly emphasizes a high-noise window; logit_normal is the Anima default; uniform covers all t equally. dual_peak ignores timestep_shift, but schedule and resolution shifts still move the curve.", "timestep_shift": "Internal distribution shift for logit-normal / mode: >1 biases the high-noise end (coarse structure), <1 biases the low-noise end (details)", "timestep_mix_low_prob": "Ratio of samples sent to the biased end in mixed_* modes: 0 = all uniform; typical 0.15-0.30", "infonoise_enabled": "[InfoNoise] Enable adaptive timestep sampling: dynamically adjusts the t distribution during training based on information content, focusing on more useful training intervals", @@ -596,7 +616,7 @@ "epochs": "aim for 2000–3000 total steps", "mixed_precision": "bf16", "num_workers": "0 on Windows — required", - "timestep_sampling": "style_friendly for a style LoRA", + "timestep_sampling": "For style: style_friendly or experimental dual_peak with two peaks", "style_snr_mean": "-6 is the paper recipe; try -4 in an A/B if line work matters", "style_snr_sigma": "2.0", "timestep_shift": "3.0; larger pushes toward light and composition, smaller toward fine texture", @@ -712,6 +732,11 @@ "sort_updated": "Recently active", "sort_created": "Newest created", "sort_title": "By name", + "sort_queue": "Queued first", + "sort_finish": "By training finish", + "trainingNow": "Training now", + "queuePosition": "Queued · #{{position}}", + "finishUnknown": "estimating", "archivedToggle": "Archived ({{n}})", "archivedToggleHint": "Toggle: show only archived projects", "archiveProjectTitle": "Archive project (recoverable)", @@ -729,6 +754,13 @@ "evaluatingHint": "Training finished; metrics are being computed against the validation set for each checkpoint (sampling + CLIP / DINO). This disappears when done. See the Eval tab in task detail for details." }, "queue": { + "summaryTitle": "Training queue now", + "summaryCount_one": "{{count}} task", + "summaryCount_other": "{{count}} tasks", + "summaryBreakdown": "Running: {{running}} · waiting: {{waiting}}", + "summaryFinish": "Queue finishes", + "summaryTaskFinish": "Estimated finish", + "summaryUnknown": "Estimating", "title": "GPU task queue", "description": "Train / reg AI / test render · one at a time, next auto-starts on completion", "titleJobs": "Data task queue", diff --git a/studio/web/src/i18n/locales/ru.json b/studio/web/src/i18n/locales/ru.json index bff467c..628a860 100644 --- a/studio/web/src/i18n/locales/ru.json +++ b/studio/web/src/i18n/locales/ru.json @@ -503,6 +503,11 @@ "sort_updated": "Недавно активные", "sort_created": "Новые", "sort_title": "По названию", + "sort_queue": "Сначала в очереди", + "sort_finish": "По времени окончания", + "trainingNow": "Тренируется сейчас", + "queuePosition": "В очереди · №{{position}}", + "finishUnknown": "время уточняется", "archivedToggle": "В архиве ({{n}})", "archivedToggleHint": "Показать только архивные проекты", "archiveProjectTitle": "Архивировать проект (обратимо)", @@ -659,7 +664,26 @@ "noRuns": "Прогонов пока нет." }, "schema": { + "enums": { + "lrScheduler": { + "cosineCycles": "Настраиваемые косинусные циклы", + "constantThenCosine": "Постоянная LR → плавное снижение" + }, + "timestepSampling": { + "dualPeak": "Dual Peak — два пика для стиля" + } + }, "descriptions": { + "dual_peak_peak1_position": "Положение первого пика: 0.525 = 525 из 1000. Это пик отдельной составляющей; максимум общей кривой может немного сместиться.", + "dual_peak_peak1_width": "Ширина первого пика. Больше — шире и ниже при том же весе. Это ширина в log-SNR, а не положение по оси t.", + "dual_peak_peak1_weight": "Вес первого пика. Четыре веса автоматически приводятся к сумме 1. Ноль отключает эту составляющую.", + "dual_peak_peak2_position": "Положение второго пика: 0.85 = 850 из 1000. Со стандартным широким фоном максимум общей кривой находится примерно на 870.", + "dual_peak_peak2_width": "Ширина второго пика. Настраивается независимо от первого. Больше — шире и ниже при том же весе.", + "dual_peak_peak2_weight": "Вес второго пика. Больше — выше его доля после пересчёта всех четырёх весов.", + "dual_peak_background_mean": "Центр широкого фона в log-SNR. Меньше — ближе к шумному краю. Фон заполняет промежуток между пиками и правый склон, но действует по всей оси.", + "dual_peak_background_width": "Ширина широкого фона. По умолчанию 2.2 — как у style-friendly в нашем сравнении.", + "dual_peak_background_weight": "Вес широкого фона. Ноль убирает его; высоты и положения общих максимумов при этом изменятся.", + "dual_peak_uniform_weight": "Вес равномерного покрытия всей оси. По умолчанию 0.05; доля зависит от суммы всех четырёх весов.", "text_encoder_cache": "Кеширует выход текстового энкодера: одинаковые капшены кодируются один раз, а не каждый шаг.", "bucket_min_reso": "Наименьшая сторона корзины. Пусто — выводится из resolution и aspect_ratio_limit.", "bucket_max_reso": "Наибольшая сторона корзины. Пусто — выводится из resolution и aspect_ratio_limit.", @@ -756,6 +780,13 @@ "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_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)", "prodigy_d_coef": "Общий множитель к оценке d у Prodigy; больше — выше фактическая скорость. Поднимать (2.0+) при недообучении, снижать (0.5) при переобучении и на маленьких датасетах", "prodigy_safeguard_warmup": "Не давать большим градиентам в начале раздуть оценку d у Prodigy; по умолчанию включено ради устойчивости", @@ -790,7 +821,7 @@ "noise_offset": "Сила постоянного смещения (0–0.2, 0 — выключено). Сдвигает среднее шума от нуля, показывая модели крайние по яркости сцены (чёрное, белое, высокий контраст). Обычно 0.05–0.1; ниже 0.05 почти неотличимо от базы, выше 0.1 заметно поднимает стартовый лосс", "pyramid_noise_iters": "Число уровней пирамидального шума (0–6, 0 — выключено). Каждый уровень добавляется на масштабе в 2^(k+1) раз меньше. Фактическую силу задаёт pyramid_noise_discount — число уровней лишь определяет охват частот и при малом discount почти ничего не меняет", "pyramid_noise_discount": "Коэффициент затухания по уровням (0.1–0.9). Главная ручка силы низких частот: реализация нормирует общий разброс шума к 1, поэтому discount задаёт долю низких частот. 0.1–0.4 после нормировки почти неотличимо от обычного гауссова шума; 0.5–0.7 заметно меняет низкочастотную структуру", - "timestep_sampling": "Распределение выборки. logit_normal смещено к середине (по умолчанию для SD3/Anima); uniform — равновероятно; mode — одногорбое смещение; mixed_* смешивает равномерное со смещённым краем (доля задаётся timestep_mix_low_prob)", + "timestep_sampling": "Распределение выборки. dual_peak — два настраиваемых пика с широким фоном для стиля; style_friendly — широкое окно на шумном краю; logit_normal — базовый вариант Anima; uniform — равномерно; mixed_* — смешанные варианты. В dual_peak обычный timestep_shift не действует, но schedule shift и поправка на разрешение сдвигают кривую.", "timestep_shift": "Сдвиг распределения внутри logit-normal / mode: больше 1 — к сильному шуму (крупная структура), меньше 1 — к слабому шуму (детали)", "timestep_mix_low_prob": "Доля сэмплов, уходящих в смещённый край в режимах mixed_*: 0 — всё равномерно; обычно 0.15–0.30", "infonoise_enabled": "InfoNoise: адаптивная выборка шагов времени — по ходу тренировки распределение t подстраивается по информативности и концентрируется на полезных участках", @@ -868,7 +899,7 @@ "epochs": "целиться в 2000–3000 шагов суммарно", "mixed_precision": "bf16", "num_workers": "0 на Windows — обязательно", - "timestep_sampling": "style_friendly для стилевой лоры", + "timestep_sampling": "Для стиля: style_friendly или экспериментальный dual_peak с двумя пиками", "style_snr_mean": "-6 — рецепт статьи; сравните с -4, если важна линия", "style_snr_sigma": "2.0", "timestep_shift": "3.0; больше — в сторону света и композиции, меньше — в мелкую фактуру", @@ -1051,6 +1082,15 @@ "saveFailed": "Не удалось сохранить триггер-слово: {{error}}" }, "queue": { + "summaryTitle": "Очередь тренировок сейчас", + "summaryCount_one": "{{count}} задача", + "summaryCount_few": "{{count}} задачи", + "summaryCount_many": "{{count}} задач", + "summaryCount_other": "{{count}} задач", + "summaryBreakdown": "Выполняется: {{running}} · ожидает: {{waiting}}", + "summaryFinish": "Очередь завершится", + "summaryTaskFinish": "Примерное окончание", + "summaryUnknown": "Время уточняется", "note": "Заметка", "noteAdd": "Добавить заметку", "noteEdit": "Изменить заметку", diff --git a/studio/web/src/i18n/locales/zh.json b/studio/web/src/i18n/locales/zh.json index b114d32..34ee9f7 100644 --- a/studio/web/src/i18n/locales/zh.json +++ b/studio/web/src/i18n/locales/zh.json @@ -310,7 +310,9 @@ "none": "常数", "cosine": "Cosine", "cosineWithRestart": "Cosine 重启", - "cosineWithWarmup": "Cosine 预热" + "cosineWithWarmup": "Cosine 预热", + "cosineCycles": "自定义 Cosine 周期", + "constantThenCosine": "常数后 Cosine 衰减" }, "optimizerType": { "adamw": "AdamW", @@ -326,7 +328,8 @@ "uniform": "Uniform", "logitNormalLow": "Logit Normal Low", "mode": "Mode", - "styleFriendly": "Style-Friendly SNR(风格档)" + "styleFriendly": "Style-Friendly SNR(风格档)", + "dualPeak": "Dual Peak — 自定义双峰" }, "lossWeighting": { "none": "无", @@ -382,6 +385,16 @@ "infonoise_enabled": "需先把 noise_enhancement / loss_weighting / loss_type / schedule_shift 全部置默认才能启用(schema 互斥)" }, "descriptions": { + "dual_peak_peak1_position": "第一个分量峰的位置:0.525 = 525/1000。混合后的峰可能略有偏移。", + "dual_peak_peak1_width": "第一个峰的宽度(log-SNR 标准差)。权重不变时越大越宽、越低。", + "dual_peak_peak1_weight": "第一个峰的相对权重。四个权重自动归一化;0 关闭此分量。", + "dual_peak_peak2_position": "第二个分量峰的位置:0.85 = 850/1000。默认混合曲线峰值约为 870。", + "dual_peak_peak2_width": "第二个峰的宽度(log-SNR 标准差),独立于第一个峰。", + "dual_peak_peak2_weight": "第二个峰的相对权重,与其他三个权重一起归一化。", + "dual_peak_background_mean": "宽背景分量的 log-SNR 均值。越小越偏高噪声,覆盖整个区间而不只是右侧尾部。", + "dual_peak_background_width": "宽背景分量的 log-SNR 标准差,默认 2.2。", + "dual_peak_background_weight": "宽背景的相对权重。0 关闭;混合峰的位置和高度也会改变。", + "dual_peak_uniform_weight": "全区间均匀覆盖的相对权重,默认 0.05,与其他权重归一化。", "model_family": "模型族。决定训练走哪套模型实现;其余字段按族能力自动显隐。切换将重算模型路径并重置族相关默认值", "transformer_path": "主扩散模型权重(.safetensors)", "vae_path": "VAE 权重(.safetensors)", @@ -424,6 +437,13 @@ "lr_scheduler_t_mult": "cosine_with_restart 每次重启后周期相对上轮的倍数(>1 周期递增)", "lr_scheduler_eta_min": "学习率衰减下限:cosine 调度到此值后不再下降;通常远小于初始 lr(如初始 1e-4 配 1e-6)", "lr_scheduler_warmup_steps": "cosine_with_warmup 预热步数", + "lr_scheduler_cycle_count": "等长 Cosine 峰的数量,默认 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": "最终 Cosine 衰减开始时的训练比例;0.6667 表示最后三分之一。", + "lr_scheduler_decay_min_lr": "最终 Cosine 衰减结束时的 LR。", "optimizer_type": "优化器。adamw 标准基线;automagic 自适应每参数 lr(推荐 lr=1e-6);came 置信度引导 + 分解二阶矩(state 显存低于 AdamW,lr 同 AdamW 量级);lion 显存约 AdamW 一半(推荐 lr=AdamW lr / 3);prodigy / prodigy_plus_schedulefree 自适应估 lr(lr 填 1.0)", "prodigy_d_coef": "Prodigy 估出的 d 整体缩放系数;越大有效 lr 越大。欠拟合时调高(2.0+),过拟合 / 小数据集时调低(0.5)", "prodigy_safeguard_warmup": "Prodigy warmup 期间防止 d 被初期高梯度推高;默认开启更稳", @@ -458,7 +478,7 @@ "noise_offset": "DC 偏置强度(0-0.2,0=关闭)。让噪声 mean 偏离 0,让模型有机会学习生成极端亮度场景(pure black / pure white / 强对比)。典型范围 0.05-0.1;0.05 以下噪声场跟 baseline 几乎一样,超过 0.1 起点 loss 会显著偏高", "pyramid_noise_iters": "金字塔噪声层数(0-6,0=关闭)。每层在 spatial // 2^(k+1) 尺度注入。实际效果强度由 pyramid_noise_discount 决定 —— iters 单独决定覆盖的频段范围,discount 低时层数多少差异很小", "pyramid_noise_discount": "每层相对衰减系数(0.1-0.9)。控制低频强度的核心参数:本实现把整体噪声 std 归一化到 1,所以 discount 决定低频占比。0.1-0.4 归一化后噪声接近标准高斯,等价于关闭;0.5-0.7 显著改变低频结构", - "timestep_sampling": "采样分布。logit_normal 偏中段(SD3/Anima 默认);uniform 等概率;mode 单峰偏移;mixed_* 混合 uniform 与偏置端(比例由 timestep_mix_low_prob 控制)", + "timestep_sampling": "采样分布。dual_peak 为可调双峰加宽背景;style_friendly 偏高噪声风格窗口;logit_normal 是 Anima 默认;uniform 等概率。dual_peak 忽略 timestep_shift,但 schedule shift 和分辨率修正仍会移动曲线。", "timestep_shift": "logit-normal / mode 内部的分布偏移:>1 偏向高噪声端(粗结构),<1 偏向低噪声端(细节)", "timestep_mix_low_prob": "mixed_* 模式下走偏置端的样本比例:0 = 全 uniform;典型 0.15-0.30", "schedule_shift": "采样后对 t 做的额外 σ schedule 偏移:1.0 = 无偏移;越大整体偏向高噪声端。与 timestep_shift 区别:作用于最终 t 而非 logit-normal 内部", @@ -593,6 +613,11 @@ "sort_updated": "最近活跃", "sort_created": "最新创建", "sort_title": "按名称", + "sort_queue": "队列中的优先", + "sort_finish": "按训练结束时间", + "trainingNow": "正在训练", + "queuePosition": "队列第 {{position}} 位", + "finishUnknown": "正在估算", "archivedToggle": "已归档({{n}})", "archivedToggleHint": "切换:只显示已归档项目", "archiveProjectTitle": "归档项目(可恢复)", @@ -610,6 +635,12 @@ "evaluatingHint": "训练已结束,正在用验证集对各 checkpoint 算指标(出图 + CLIP / DINO),完成后这里消失。明细看任务详情的「评估」tab。" }, "queue": { + "summaryTitle": "当前训练队列", + "summaryCount": "{{count}} 个任务", + "summaryBreakdown": "运行中:{{running}} · 等待:{{waiting}}", + "summaryFinish": "队列预计结束", + "summaryTaskFinish": "预计结束", + "summaryUnknown": "正在估算", "title": "GPU 任务队列", "description": "训练 / 正则 AI / 测试出图 · 同一时刻仅运行一个,完成后自动启动下一个", "titleJobs": "数据任务队列", diff --git a/studio/web/src/lib/queueEstimates.test.ts b/studio/web/src/lib/queueEstimates.test.ts new file mode 100644 index 0000000..6f7b2f7 --- /dev/null +++ b/studio/web/src/lib/queueEstimates.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { Task } from '../api/client' +import { buildTrainingForecast, typicalTrainingDuration } from './queueEstimates' + +function task(over: Partial): Task { + return { + id: 1, + name: 'training', + config_name: 'train', + task_type: 'train', + status: 'pending', + priority: 0, + created_at: 1, + started_at: null, + finished_at: null, + pid: null, + exit_code: null, + output_dir: null, + error_msg: null, + ...over, + } +} + +describe('queue training forecast', () => { + it('uses the median completed duration for waiting tasks', () => { + const tasks = [ + task({ id: 1, status: 'done', started_at: 10, finished_at: 110 }), + task({ id: 2, status: 'done', started_at: 10, finished_at: 310 }), + task({ id: 3, status: 'done', started_at: 10, finished_at: 210 }), + ] + expect(typicalTrainingDuration(tasks)).toBe(200) + }) + + it('orders by scheduler priority and adds estimates cumulatively', () => { + const tasks = [ + task({ id: 1, status: 'done', started_at: 0.1, finished_at: 100.1 }), + task({ id: 2, status: 'running', started_at: 900, created_at: 1 }), + task({ id: 3, priority: 1, created_at: 3 }), + task({ id: 4, priority: 0, created_at: 2 }), + ] + const result = buildTrainingForecast(tasks, { step: 50, total_steps: 100, speed: 1 }, 1000) + + expect(result.map(({ task }) => task.id)).toEqual([2, 3, 4]) + expect(result.map(({ finishesAt }) => finishesAt)).toEqual([1050, 1150, 1250]) + }) +}) diff --git a/studio/web/src/lib/queueEstimates.ts b/studio/web/src/lib/queueEstimates.ts new file mode 100644 index 0000000..09c742c --- /dev/null +++ b/studio/web/src/lib/queueEstimates.ts @@ -0,0 +1,69 @@ +import type { MonitorState, Task } from '../api/client' + +export interface TaskForecast { + task: Task + finishesAt: number | null +} + +const isTrainingTask = (task: Task) => (task.task_type ?? 'train') === 'train' + +export function typicalTrainingDuration(tasks: Task[]): number | null { + const durations = tasks + .filter((task) => isTrainingTask(task) && task.status === 'done' && task.started_at && task.finished_at) + .map((task) => task.finished_at! - task.started_at!) + .filter((seconds) => seconds > 0) + .sort((a, b) => a - b) + + if (!durations.length) return null + const middle = Math.floor(durations.length / 2) + return durations.length % 2 + ? durations[middle] + : (durations[middle - 1] + durations[middle]) / 2 +} + +export function buildTrainingForecast( + tasks: Task[], + monitor: MonitorState | null, + now = Date.now() / 1000, +): TaskForecast[] { + const live = tasks + .filter((task) => isTrainingTask(task) && (task.status === 'running' || task.status === 'pending')) + .sort((a, b) => { + if (a.status !== b.status) return a.status === 'running' ? -1 : 1 + return b.priority - a.priority || a.created_at - b.created_at + }) + const typical = typicalTrainingDuration(tasks) + let cursor = now + let canEstimate = true + + return live.map((task) => { + let remaining: number | null = typical + if (task.status === 'running') { + if ( + monitor?.speed && monitor.speed > 0 && + monitor.step != null && monitor.total_steps != null && + monitor.total_steps >= monitor.step + ) { + remaining = (monitor.total_steps - monitor.step) / monitor.speed + } else if (typical != null && task.started_at) { + remaining = Math.max(0, typical - (now - task.started_at)) + } + } + + if (!canEstimate || remaining == null) { + canEstimate = false + return { task, finishesAt: null } + } + cursor += remaining + return { task, finishesAt: cursor } + }) +} + +export function latestProjectFinish(tasks: Task[], projectId: number): number | null { + let latest: number | null = null + for (const task of tasks) { + if (!isTrainingTask(task) || task.project_id !== projectId || !task.finished_at) continue + latest = latest == null ? task.finished_at : Math.max(latest, task.finished_at) + } + return latest +} diff --git a/studio/web/src/lib/schema.test.ts b/studio/web/src/lib/schema.test.ts index a87fe3e..30ee779 100644 --- a/studio/web/src/lib/schema.test.ts +++ b/studio/web/src/lib/schema.test.ts @@ -33,13 +33,13 @@ describe('controlKind', () => { expect(controlKind({ type: 'string' } as SchemaProperty)).toBe('string') }) - it('maps integer arrays (e.g. resolution) to int-list, string arrays to string-list', () => { + it('maps integer, float, and string arrays to matching list controls', () => { expect( controlKind({ type: 'array', items: { type: 'integer' } } as SchemaProperty) ).toBe('int-list') expect( controlKind({ type: 'array', items: { type: 'number' } } as SchemaProperty) - ).toBe('int-list') + ).toBe('float-list') expect( controlKind({ type: 'array', items: { type: 'string' } } as SchemaProperty) ).toBe('string-list') diff --git a/studio/web/src/lib/schema.ts b/studio/web/src/lib/schema.ts index cd72678..7204d3a 100644 --- a/studio/web/src/lib/schema.ts +++ b/studio/web/src/lib/schema.ts @@ -14,6 +14,7 @@ export type ControlKind = | 'code' | 'string-list' | 'int-list' + | 'float-list' /** * 推断字段的控件类型。优先用 schema 里 control 自定义元字段,否则按 @@ -47,7 +48,9 @@ export function controlKind(prop: SchemaProperty): ControlKind { if (type === 'number') return 'float' if (type === 'array') { const itemType = prop.items?.type - return itemType === 'integer' || itemType === 'number' ? 'int-list' : 'string-list' + if (itemType === 'integer') return 'int-list' + if (itemType === 'number') return 'float-list' + return 'string-list' } return 'string' } @@ -125,6 +128,8 @@ export const SCHEMA_ENUM_LABEL_KEYS: Record> = { cosine: 'schema.enums.lrScheduler.cosine', cosine_with_restart: 'schema.enums.lrScheduler.cosineWithRestart', cosine_with_warmup: 'schema.enums.lrScheduler.cosineWithWarmup', + cosine_cycles: 'schema.enums.lrScheduler.cosineCycles', + constant_then_cosine: 'schema.enums.lrScheduler.constantThenCosine', }, optimizer_type: { adamw: 'schema.enums.optimizerType.adamw', @@ -141,6 +146,7 @@ export const SCHEMA_ENUM_LABEL_KEYS: Record> = { logit_normal_low: 'schema.enums.timestepSampling.logitNormalLow', mode: 'schema.enums.timestepSampling.mode', style_friendly: 'schema.enums.timestepSampling.styleFriendly', + dual_peak: 'schema.enums.timestepSampling.dualPeak', }, loss_weighting: { none: 'schema.enums.lossWeighting.none', diff --git a/studio/web/src/pages/Projects.tsx b/studio/web/src/pages/Projects.tsx index 4d36098..f7af7ce 100644 --- a/studio/web/src/pages/Projects.tsx +++ b/studio/web/src/pages/Projects.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' -import { api, type BundleImportResult, type ProjectSummary } from '../api/client' +import { api, type BundleImportResult, type ProjectSummary, type Task } from '../api/client' import PageHeader from '../components/PageHeader' import PathPicker from '../components/PathPicker' import UploadProgressBar from '../components/UploadProgressBar' @@ -10,10 +10,22 @@ import { useDialog } from '../components/Dialog' import { useToast } from '../components/Toast' import { useEventStream } from '../lib/useEventStream' import { useUploadProgress } from '../lib/useUploadProgress' +import { useMonitorProgress } from '../lib/useMonitorProgress' +import { buildTrainingForecast, latestProjectFinish } from '../lib/queueEstimates' + +type ProjectSort = 'queue' | 'finish' | 'updated' | 'created' | 'title' + +function formatProjectTime(ts: number): string { + return new Intl.DateTimeFormat(undefined, { + day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', + }).format(new Date(ts * 1000)) +} export default function ProjectsPage() { const { t } = useTranslation() const [items, setItems] = useState([]) + const [tasks, setTasks] = useState([]) + const [sort, setSort] = useState('queue') const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [creating, setCreating] = useState(false) @@ -28,8 +40,12 @@ export default function ProjectsPage() { const refresh = async () => { try { - const list = await api.listProjects() + const [list, queue] = await Promise.all([ + api.listProjects(), + api.listQueue().catch(() => [] as Task[]), + ]) setItems(list) + setTasks(queue) setError(null) } catch (e) { setError(String(e)) @@ -41,9 +57,51 @@ export default function ProjectsPage() { useEffect(() => { void refresh() }, []) useEventStream((evt) => { - if (evt.type === 'project_state_changed') void refresh() + if (evt.type === 'project_state_changed' || evt.type === 'task_state_changed') void refresh() }) + const runningTaskId = tasks.find((task) => task.status === 'running')?.id ?? null + const { state: monitor } = useMonitorProgress(runningTaskId) + const forecast = useMemo(() => buildTrainingForecast(tasks, monitor), [tasks, monitor]) + const forecastByProject = useMemo(() => { + const map = new Map() + forecast.forEach(({ task, finishesAt }, order) => { + if (task.project_id != null && !map.has(task.project_id)) { + map.set(task.project_id, { order, finishesAt, status: task.status }) + } + }) + return map + }, [forecast]) + const sortedItems = useMemo(() => [...items].sort((a, b) => { + const aq = forecastByProject.get(a.id) + const bq = forecastByProject.get(b.id) + if (sort === 'queue') { + if (aq && bq) return aq.order - bq.order + if (aq) return -1 + if (bq) return 1 + return b.updated_at - a.updated_at + } + if (sort === 'finish') { + if (aq || bq) { + if (!aq) return 1 + if (!bq) return -1 + if (aq.finishesAt != null && bq.finishesAt != null) return aq.finishesAt - bq.finishesAt + if (aq.finishesAt != null) return -1 + if (bq.finishesAt != null) return 1 + return aq.order - bq.order + } + const af = latestProjectFinish(tasks, a.id) + const bf = latestProjectFinish(tasks, b.id) + if (af != null && bf != null) return bf - af + if (af != null) return -1 + if (bf != null) return 1 + return b.updated_at - a.updated_at + } + if (sort === 'created') return b.created_at - a.created_at + if (sort === 'title') return a.title.localeCompare(b.title) + return b.updated_at - a.updated_at + }), [items, tasks, forecastByProject, sort]) + const handleCreate = async (form: NewProjectForm) => { setBusy(true) try { @@ -162,6 +220,26 @@ export default function ProjectsPage() {
{error}
)} + {!loading && items.length > 0 && ( +
+ + +
+ )} + {loading ? (
{[1, 2, 3].map(i => ( @@ -178,12 +256,13 @@ export default function ProjectsPage() {
) : (
- {items.map((p) => ( + {sortedItems.map((p) => ( openProject(p)} onDelete={(e) => handleDelete(p, e)} + queueInfo={forecastByProject.get(p.id)} /> ))}
@@ -298,10 +377,12 @@ function ProjectCard({ project: p, onClick, onDelete, + queueInfo, }: { project: ProjectSummary onClick: () => void onDelete: (e: React.MouseEvent) => void + queueInfo?: { order: number; finishesAt: number | null; status: Task['status'] } }) { const { t } = useTranslation() @@ -329,6 +410,21 @@ function ProjectCard({

)} + {queueInfo && ( +
+ + {queueInfo.status === 'running' + ? t('projects.trainingNow') + : t('projects.queuePosition', { position: queueInfo.order + 1 })} + + + {queueInfo.finishesAt + ? `≈ ${formatProjectTime(queueInfo.finishesAt)}` + : t('projects.finishUnknown')} + +
+ )} +
{/* active version 名(直接版本,无前缀文本) */} {p.active_version_label ? ( diff --git a/studio/web/src/pages/Queue.tsx b/studio/web/src/pages/Queue.tsx index 65b47f9..57b70f8 100644 --- a/studio/web/src/pages/Queue.tsx +++ b/studio/web/src/pages/Queue.tsx @@ -11,6 +11,7 @@ import { useDialog } from '../components/Dialog' import { useToast } from '../components/Toast' import { useEventStream } from '../lib/useEventStream' import { useMonitorProgress } from '../lib/useMonitorProgress' +import { buildTrainingForecast } from '../lib/queueEstimates' /** 备注输入上限 —— 与后端 _MAX_NOTE_LEN 对齐(超了后端截断,这里先拦住)。 */ const MAX_NOTE_LEN = 500 @@ -62,6 +63,12 @@ function fmtDurationShort(ms: number): string { return `${(ms / 3600e3).toFixed(1)}h` } +function fmtFinishTime(ts: number): string { + return new Intl.DateTimeFormat(undefined, { + day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', + }).format(new Date(ts * 1000)) +} + export default function QueuePage() { const { t } = useTranslation() const [tasks, setTasks] = useState([]) @@ -174,6 +181,15 @@ export default function QueuePage() { // 切换时 hook 自动清状态 + 重拉 /api/state 冷启动;不需要本组件再写清理逻辑。 const { state: monitor } = useMonitorProgress(runningTaskId) + const queueForecast = useMemo( + () => buildTrainingForecast(tasks, monitor), + [tasks, monitor], + ) + const waitingCount = queueForecast.filter(({ task }) => task.status === 'pending').length + const queueFinish = queueForecast.length + ? queueForecast[queueForecast.length - 1].finishesAt + : null + const sorted = useMemo(() => [...tasks].sort((a, b) => b.id - a.id), [tasks]) const prevCount = useCallback((taskId: number): number => { @@ -398,6 +414,48 @@ export default function QueuePage() { } >
+ {loaded && queueForecast.length > 0 && ( +
+
+
+
+ {t('queue.summaryTitle')} +
+
+ {t('queue.summaryCount', { count: queueForecast.length })} +
+
+ {t('queue.summaryBreakdown', { + running: queueForecast.length - waitingCount, + waiting: waitingCount, + })} +
+
+
+
{t('queue.summaryFinish')}
+
+ {queueFinish ? `≈ ${fmtFinishTime(queueFinish)}` : t('queue.summaryUnknown')} +
+
+
+
+ {queueForecast.map(({ task, finishesAt }) => ( + + {task.name} + {task.status === 'running' && {t('status.running')}} + + ))} +
+
+ )} {/* ADR §4.1 队列挂起 banner — 仅 held=true 时显示,sticky 顶部。 */} {holdState?.held && (
None: assert ns.lr_scheduler_eta_min == 1e-7 +def test_training_config_cli_cosine_cycles_float_lists() -> None: + parser = bridge.build_parser(TrainingConfig) + ns = parser.parse_args([ + "--lr-scheduler", "cosine_cycles", + "--lr-scheduler-cycle-count", "3", + "--lr-scheduler-cycle-max-lrs", "1e-4", "8e-5", "6e-5", + "--lr-scheduler-cycle-min-lrs", "5e-5", "4e-5", "0", + ]) + assert ns.lr_scheduler == "cosine_cycles" + assert ns.lr_scheduler_cycle_max_lrs == [1e-4, 8e-5, 6e-5] + assert ns.lr_scheduler_cycle_min_lrs == [5e-5, 4e-5, 0.0] + + def test_training_config_yaml_round_trip() -> None: """走完 CLI → YAML 合并这一条路径,确认 yaml_dict 字段都能被读进 args。""" args = bridge.namespace_from_config( diff --git a/tests/test_dual_peak_sampler.py b/tests/test_dual_peak_sampler.py new file mode 100644 index 0000000..67631af --- /dev/null +++ b/tests/test_dual_peak_sampler.py @@ -0,0 +1,109 @@ +"""Schema -> CLI/YAML -> registry -> real sample_t integration.""" + +from dataclasses import asdict, fields +from types import SimpleNamespace + +import pytest +import torch +import yaml +from pydantic import ValidationError + +from dual_peak_sampling import DualPeakConfig, sample_timesteps +from studio.schema import TrainingConfig +from studio.infrastructure.argparse_bridge import build_parser +from training.timestep_sampling import sample_t, apply_resolution_shift +from training.timestep_samplers import build_timestep_sampler +from training.timestep_samplers.protocol import TimestepSamplerProtocol + + +def test_schema_defaults_match_portable_package_and_roundtrip(): + cfg = TrainingConfig(timestep_sampling="dual_peak") + for field in fields(DualPeakConfig): + key = f"dual_peak_{field.name}" + assert getattr(cfg, key) == getattr(DualPeakConfig(), field.name) + assert TrainingConfig.model_fields[key].json_schema_extra["show_when"] == "timestep_sampling==dual_peak" + restored = TrainingConfig.model_validate(yaml.safe_load(yaml.safe_dump(cfg.model_dump()))) + assert restored == cfg + assert "timestep_sampling!=dual_peak" in TrainingConfig.model_fields["timestep_shift"].json_schema_extra["show_when"] + assert TrainingConfig().timestep_sampling == "logit_normal" + + +def test_cli_to_registry_preserves_all_custom_parameters(): + expected = DualPeakConfig(peak1_position=.48, peak1_width=.7, peak1_weight=.2, + peak2_position=.82, peak2_width=1.4, peak2_weight=.4, + background_mean=0, background_width=2.4, + background_weight=.3, uniform_weight=0) + argv = ["--timestep-sampling", "dual_peak"] + for name, value in asdict(expected).items(): + argv.extend(["--dual-peak-" + name.replace("_", "-"), str(value)]) + args = build_parser(TrainingConfig, add_config_arg=False).parse_args(argv) + sampler = build_timestep_sampler(args, 100) + assert isinstance(sampler, TimestepSamplerProtocol) + assert sampler.status()["dual_peak"] == asdict(expected) + torch.manual_seed(31) + actual = sampler.sample(1024, "cpu") + torch.manual_seed(31) + torch.testing.assert_close(actual, sample_timesteps(1024, config=expected), rtol=0, atol=0) + + +def test_shift_is_ignored_but_schedule_and_resolution_shifts_compose(): + torch.manual_seed(7) + plain = sample_t(512, "cpu", mode="dual_peak", shift=.5) + torch.manual_seed(7) + other_shift = sample_t(512, "cpu", mode="dual_peak", shift=9) + torch.testing.assert_close(plain, other_shift, rtol=0, atol=0) + torch.manual_seed(7) + shifted = sample_t(512, "cpu", mode="dual_peak", timestep_schedule_shift=2) + expected = (2 * plain / (1 + plain)).clamp(1e-4, 1-1e-4) + torch.testing.assert_close(shifted, expected) + torch.testing.assert_close(apply_resolution_shift(plain, [1024]*512, 1024), plain) + + +def test_infonoise_warmup_uses_custom_config(): + args = SimpleNamespace(timestep_sampling="dual_peak", infonoise_enabled=True, + dual_peak_peak1_weight=0, dual_peak_peak2_weight=0, + dual_peak_background_weight=0, dual_peak_uniform_weight=1) + sampler = build_timestep_sampler(args, 100) + assert sampler.baseline_dual_peak_config.uniform_weight == 1 + torch.manual_seed(4) + actual = sampler.sample(512, "cpu") + torch.manual_seed(4) + expected = sample_timesteps(512, config=sampler.baseline_dual_peak_config) + torch.testing.assert_close(actual, expected, rtol=0, atol=0) + + +def test_stateless_resume_uses_trainer_global_rng(): + args = SimpleNamespace(timestep_sampling="dual_peak") + first = build_timestep_sampler(args, 100) + first.sample(5, "cpu") + rng = torch.get_rng_state() + state = first.state_dict() + expected = first.sample(64, "cpu") + resumed = build_timestep_sampler(args, 100) + resumed.load_state_dict(state) + torch.set_rng_state(rng) + torch.testing.assert_close(resumed.sample(64, "cpu"), expected, rtol=0, atol=0) + + +def test_overrides_warn_without_changing_user_settings(caplog): + args = SimpleNamespace(timestep_sampling="dual_peak", leap_enabled=True, + timestep_schedule_shift=2, timestep_shift_resolution_aware=True) + sampler = build_timestep_sampler(args, 100) + assert "non-Leap" in caplog.text + assert "move the configured peaks" in caplog.text + assert sampler.timestep_schedule_shift == 2 + assert args.leap_enabled and args.timestep_shift_resolution_aware + + +@pytest.mark.parametrize("changes", [ + {"dual_peak_peak1_position": 0}, {"dual_peak_peak2_position": 1}, + {"dual_peak_peak1_width": 0}, {"dual_peak_peak2_width": 3}, + {"dual_peak_background_mean": float("nan")}, {"dual_peak_uniform_weight": -1}, + {"dual_peak_peak1_weight": 0, "dual_peak_peak2_weight": 0, + "dual_peak_background_weight": 0, "dual_peak_uniform_weight": 0}, +]) +def test_schema_and_runtime_reject_invalid_custom_config(changes): + with pytest.raises(ValidationError): + TrainingConfig(timestep_sampling="dual_peak", **changes) + with pytest.raises(ValueError): + build_timestep_sampler(SimpleNamespace(timestep_sampling="dual_peak", **changes), 100) diff --git a/tests/test_plugin_registry.py b/tests/test_plugin_registry.py index c9b8141..7d95ffe 100644 --- a/tests/test_plugin_registry.py +++ b/tests/test_plugin_registry.py @@ -103,7 +103,13 @@ def test_adamw8bit_validate_rejects_missing_bitsandbytes(monkeypatch) -> None: def test_scheduler_builders_dict_excludes_none() -> None: from training.schedulers import BUILDERS, SCHEMA_ONLY_OPTIONS - assert set(BUILDERS) == {"cosine", "cosine_with_restart", "cosine_with_warmup"} + assert set(BUILDERS) == { + "constant_then_cosine", + "cosine", + "cosine_cycles", + "cosine_with_restart", + "cosine_with_warmup", + } assert SCHEMA_ONLY_OPTIONS == {"none"} @@ -255,6 +261,61 @@ def test_cosine_with_warmup_clamps_negative_eta_min() -> None: scheduler.step() +def test_cosine_cycles_supports_per_peak_min_and_max_lr() -> None: + torch = pytest.importorskip("torch") + from training.schedulers import build_scheduler + + param = torch.nn.Parameter(torch.tensor([1.0])) + optimizer = torch.optim.SGD([param], lr=1e-3) + args = argparse.Namespace( + lr_scheduler="cosine_cycles", + lr_scheduler_cycle_count=3, + lr_scheduler_cycle_max_lr=None, + lr_scheduler_cycle_min_lr=0.0, + lr_scheduler_cycle_max_lrs=[1e-3, 8e-4, 6e-4], + lr_scheduler_cycle_min_lrs=[5e-4, 4e-4, 1e-5], + ) + scheduler = build_scheduler(args, optimizer, total_steps=12) + + lrs = [optimizer.param_groups[0]["lr"]] + for _ in range(12): + optimizer.step() + scheduler.step() + lrs.append(optimizer.param_groups[0]["lr"]) + + assert lrs[0] == pytest.approx(1e-3) + assert lrs[4] == pytest.approx(8e-4) + assert lrs[8] == pytest.approx(6e-4) + assert lrs[12] == pytest.approx(1e-5) + assert min(lrs[0:4]) >= 5e-4 + assert min(lrs[4:8]) >= 4e-4 + + +def test_constant_then_cosine_stays_flat_then_decays() -> None: + torch = pytest.importorskip("torch") + from training.schedulers import build_scheduler + + param = torch.nn.Parameter(torch.tensor([1.0])) + optimizer = torch.optim.SGD([param], lr=1e-3) + args = argparse.Namespace( + lr_scheduler="constant_then_cosine", + lr_scheduler_decay_start_ratio=2.0 / 3.0, + lr_scheduler_decay_min_lr=1e-5, + ) + scheduler = build_scheduler(args, optimizer, total_steps=12) + + lrs = [optimizer.param_groups[0]["lr"]] + for _ in range(12): + optimizer.step() + scheduler.step() + lrs.append(optimizer.param_groups[0]["lr"]) + + assert lrs[:9] == pytest.approx([1e-3] * 9) + assert lrs[9] < 1e-3 + assert lrs[9] > lrs[10] > lrs[11] > lrs[12] + assert lrs[12] == pytest.approx(1e-5) + + def test_ppsf_zero_prodigy_steps_disables_freeze(monkeypatch) -> None: """ppsf_prodigy_steps=0 keeps the upstream PPSF meaning: never freeze d.""" from training.optimizers import prodigy_plus_schedulefree as ppsf diff --git a/tests/test_studio_configs.py b/tests/test_studio_configs.py index c357308..6bbb8b6 100644 --- a/tests/test_studio_configs.py +++ b/tests/test_studio_configs.py @@ -39,7 +39,9 @@ def test_schema_is_complete() -> None: "transformer_path", "data_dir", "lora_type", "lora_rank", "epochs", "tlora_min_rank", "tlora_alpha_rank_scale", "tlora_use_ortho", "optimizer_type", "prodigy_d_coef", "prodigy_safeguard_warmup", - "lr_scheduler_warmup_steps", + "lr_scheduler_warmup_steps", "lr_scheduler_cycle_count", + "lr_scheduler_cycle_max_lrs", "lr_scheduler_cycle_min_lrs", + "lr_scheduler_decay_start_ratio", "lr_scheduler_decay_min_lr", "lion_beta1", "lion_beta2", "came_beta1", "came_beta2", "came_beta3", "came_eps1", "came_eps2", "came_clip_threshold", @@ -64,6 +66,8 @@ def test_schema_is_complete() -> None: scheduler_annotation = fields["lr_scheduler"].annotation scheduler_options = getattr(scheduler_annotation, "__args__", ()) assert "cosine_with_warmup" in scheduler_options + assert "cosine_cycles" in scheduler_options + assert "constant_then_cosine" in scheduler_options # optimizer_type Literal 包含 Lion / PPSF optimizer_annotation = fields["optimizer_type"].annotation # Literal 的 __args__ 包含所有合法值 @@ -110,6 +114,8 @@ def test_schema_carries_ui_metadata(client: TestClient) -> None: 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_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" assert props["automagic_variant"]["show_when"] == "optimizer_type==automagic" @@ -129,6 +135,41 @@ def test_extra_fields_are_silently_ignored() -> None: assert not hasattr(cfg, "learning_ratee") +def test_cosine_cycles_validates_per_peak_lr_lists() -> None: + cfg = TrainingConfig.model_validate({ + "lr_scheduler": "cosine_cycles", + "learning_rate": 1e-4, + "lr_scheduler_cycle_count": 3, + "lr_scheduler_cycle_max_lrs": [1e-4, 8e-5, 6e-5], + "lr_scheduler_cycle_min_lrs": [5e-5, 4e-5, 0.0], + }) + assert cfg.lr_scheduler_cycle_count == 3 + + with pytest.raises(Exception, match="exactly 3 values"): + TrainingConfig.model_validate({ + "lr_scheduler": "cosine_cycles", + "lr_scheduler_cycle_count": 3, + "lr_scheduler_cycle_max_lrs": [1e-4, 8e-5], + }) + + with pytest.raises(Exception, match="cannot exceed max LR"): + TrainingConfig.model_validate({ + "lr_scheduler": "cosine_cycles", + "lr_scheduler_cycle_count": 1, + "lr_scheduler_cycle_max_lrs": [1e-5], + "lr_scheduler_cycle_min_lrs": [2e-5], + }) + + +def test_constant_then_cosine_min_cannot_exceed_base_lr() -> None: + with pytest.raises(Exception, match="cannot exceed learning_rate"): + TrainingConfig.model_validate({ + "lr_scheduler": "constant_then_cosine", + "learning_rate": 1e-4, + "lr_scheduler_decay_min_lr": 2e-4, + }) + + def test_ppsf_rejects_non_none_scheduler() -> None: """PPSF + lr_scheduler != none 应该在 pydantic 层就被拒。""" payload = TrainingConfig().model_dump(mode="python")