Project-Chronos 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. chronos/__init__.py +16 -0
  2. chronos/_backend_legacy.py +64 -0
  3. chronos/app.py +122 -0
  4. chronos/backend/__init__.py +40 -0
  5. chronos/backend/dispatcher.py +347 -0
  6. chronos/backend/ext/__init__.py +2 -0
  7. chronos/backend/ext/opencl.py +22 -0
  8. chronos/cli.py +62 -0
  9. chronos/data/__init__.py +0 -0
  10. chronos/data/flexible_dataset.py +309 -0
  11. chronos/deps.py +72 -0
  12. chronos/eval/__init__.py +2 -0
  13. chronos/eval/benchmark.py +159 -0
  14. chronos/eval/io_profiler.py +232 -0
  15. chronos/io/__init__.py +13 -0
  16. chronos/io/async_prefetcher.py +220 -0
  17. chronos/io/cluster_layout.py +317 -0
  18. chronos/io/expert_store.py +559 -0
  19. chronos/io/io_simulator.py +40 -0
  20. chronos/io/storage.py +237 -0
  21. chronos/mlx/__init__.py +32 -0
  22. chronos/mlx/attention.py +230 -0
  23. chronos/mlx/expert_store.py +155 -0
  24. chronos/mlx/inference.py +159 -0
  25. chronos/mlx/model.py +181 -0
  26. chronos/mlx/moe.py +102 -0
  27. chronos/model/__init__.py +6 -0
  28. chronos/model/config.py +54 -0
  29. chronos/model/hf_io.py +149 -0
  30. chronos/model/hybrid_attention.py +237 -0
  31. chronos/model/lookahead_router.py +55 -0
  32. chronos/model/model_chronos.py +271 -0
  33. chronos/model/moe_chronos.py +189 -0
  34. chronos/model/temporal_loss.py +124 -0
  35. chronos/router/__init__.py +12 -0
  36. chronos/router/expert_predictor.py +163 -0
  37. chronos/router/intent_classifier.py +193 -0
  38. chronos/router/prefill_scheduler.py +248 -0
  39. chronos/runtime/__init__.py +2 -0
  40. chronos/runtime/cache_manager.py +117 -0
  41. chronos/runtime/inference_engine.py +295 -0
  42. chronos/runtime/metrics.py +75 -0
  43. chronos/serving/__init__.py +13 -0
  44. chronos/serving/vllm_adapter.py +85 -0
  45. chronos/trainer/__init__.py +6 -0
  46. chronos/trainer/chronos_trainer.py +151 -0
  47. chronos/trainer/distill_trainer.py +193 -0
  48. chronos/trainer/dpo_trainer.py +190 -0
  49. chronos/trainer/grpo_trainer.py +288 -0
  50. chronos/trainer/loss_mixin.py +165 -0
  51. chronos/trainer/optim_utils.py +101 -0
  52. chronos/trainer/orpo_trainer.py +189 -0
  53. chronos/trainer/reward.py +130 -0
  54. chronos/trainer/sft_trainer.py +189 -0
  55. chronos/tuning/__init__.py +1 -0
  56. chronos/tuning/_base_tuner.py +284 -0
  57. chronos/tuning/chronos_auto_tuner.py +203 -0
  58. project_chronos-0.1.1.dist-info/METADATA +670 -0
  59. project_chronos-0.1.1.dist-info/RECORD +83 -0
  60. project_chronos-0.1.1.dist-info/WHEEL +5 -0
  61. project_chronos-0.1.1.dist-info/entry_points.txt +7 -0
  62. project_chronos-0.1.1.dist-info/licenses/LICENSE +177 -0
  63. project_chronos-0.1.1.dist-info/top_level.txt +8 -0
  64. train_chronos.py +133 -0
  65. train_chronos_distill.py +95 -0
  66. train_chronos_dpo.py +82 -0
  67. train_chronos_grpo.py +88 -0
  68. train_chronos_orpo.py +81 -0
  69. train_chronos_sft.py +85 -0
  70. ui/__init__.py +3 -0
  71. ui/estimator.py +204 -0
  72. ui/gradio_compat.py +72 -0
  73. ui/i18n.py +540 -0
  74. ui/presets.py +194 -0
  75. ui/tabs/__init__.py +3 -0
  76. ui/tabs/autotune_tab.py +210 -0
  77. ui/tabs/benchmark_tab.py +196 -0
  78. ui/tabs/config_tab.py +318 -0
  79. ui/tabs/data_tab.py +148 -0
  80. ui/tabs/inference_tab.py +496 -0
  81. ui/tabs/iomon_tab.py +37 -0
  82. ui/tabs/pipeline_tab.py +134 -0
  83. ui/tabs/train_tab.py +1688 -0
@@ -0,0 +1,101 @@
1
+ """
2
+ chronos/trainer/optim_utils.py
3
+
4
+ Shared optimizer + LR-schedule helpers used by every Chronos training stage
5
+ (pretrain, SFT, DPO, ORPO, GRPO, distill) and the WebUI Train tab.
6
+
7
+ Two reasons to centralize this:
8
+
9
+ 1. Decay groups: AdamW with weight_decay applied uniformly to all params
10
+ destabilizes LayerNorm / RMSNorm scales and hurts embeddings. Standard
11
+ recipe is to exclude norms, biases, and embeddings from decay. Doing
12
+ this in one place means every trainer benefits from the same correct
13
+ recipe and we don't get drift.
14
+
15
+ 2. Warmup-then-cosine schedule. minimind's get_lr is pure cosine (effective
16
+ LR at step 0 is already 0.55·base_lr), which destabilizes the first few
17
+ hundred steps with small batches. Linear warmup over the first ~5% of
18
+ steps then cosine decay to 10% of base is the boring-and-correct choice.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ from typing import Iterable, Tuple
24
+
25
+ import torch
26
+ import torch.nn as nn
27
+
28
+
29
+ def _decay_groups(model: nn.Module) -> Tuple[list, list]:
30
+ """Split trainable parameters into (decay, no_decay) lists.
31
+
32
+ No-decay: any parameter whose owning module is a LayerNorm/RMSNorm,
33
+ any 1-D parameter (typically bias / norm scale), and any embedding
34
+ weight (regardless of dim — embeddings are looked-up rows, not
35
+ rotated subspaces, so decay would push *all* tokens toward zero).
36
+ """
37
+ decay, no_decay = [], []
38
+ seen = set()
39
+ for mod in model.modules():
40
+ is_norm = isinstance(mod, nn.LayerNorm) or mod.__class__.__name__.endswith("RMSNorm")
41
+ is_embed = isinstance(mod, nn.Embedding)
42
+ for p in mod.parameters(recurse=False):
43
+ if not p.requires_grad or id(p) in seen:
44
+ continue
45
+ seen.add(id(p))
46
+ if is_norm or is_embed or p.ndim < 2:
47
+ no_decay.append(p)
48
+ else:
49
+ decay.append(p)
50
+ return decay, no_decay
51
+
52
+
53
+ def build_optimizer(
54
+ model: nn.Module,
55
+ lr: float,
56
+ *,
57
+ weight_decay: float = 0.01,
58
+ betas: Tuple[float, float] = (0.9, 0.95),
59
+ eps: float = 1e-8,
60
+ ) -> torch.optim.AdamW:
61
+ """AdamW with the standard transformer recipe applied to a Chronos model."""
62
+ decay, no_decay = _decay_groups(model)
63
+ return torch.optim.AdamW(
64
+ [
65
+ {"params": decay, "weight_decay": float(weight_decay)},
66
+ {"params": no_decay, "weight_decay": 0.0},
67
+ ],
68
+ lr=float(lr), betas=betas, eps=eps,
69
+ )
70
+
71
+
72
+ def get_lr(
73
+ current_step: int,
74
+ total_steps: int,
75
+ base_lr: float,
76
+ *,
77
+ warmup_steps: int | None = None,
78
+ min_lr_ratio: float = 0.1,
79
+ ) -> float:
80
+ """Linear warmup → cosine decay to `min_lr_ratio · base_lr`.
81
+
82
+ `current_step` is 1-indexed (matches every trainer's `enumerate(loader, start=1)`).
83
+ Falls back to a constant `base_lr` when `total_steps <= 0`.
84
+ """
85
+ if total_steps is None or total_steps <= 0:
86
+ return float(base_lr)
87
+ if warmup_steps is None:
88
+ warmup_steps = max(1, min(500, total_steps // 20))
89
+ step = max(1, int(current_step))
90
+ if step <= warmup_steps:
91
+ return float(base_lr) * (step / float(warmup_steps))
92
+ progress = (step - warmup_steps) / max(1, (total_steps - warmup_steps))
93
+ progress = min(1.0, max(0.0, progress))
94
+ cos = 0.5 * (1.0 + math.cos(math.pi * progress))
95
+ return float(base_lr) * (min_lr_ratio + (1.0 - min_lr_ratio) * cos)
96
+
97
+
98
+ def apply_lr(optimizer: torch.optim.Optimizer, lr: float) -> None:
99
+ """Set the learning rate on every param-group in `optimizer`."""
100
+ for g in optimizer.param_groups:
101
+ g["lr"] = float(lr)
@@ -0,0 +1,189 @@
1
+ """
2
+ chronos/trainer/orpo_trainer.py — Stage 4 ORPO trainer.
3
+
4
+ ORPO (Hong et al. 2024) combines supervised NLL on the chosen response
5
+ with an odds-ratio penalty on the rejected response, in a single objective
6
+ that does NOT require a separate reference model. This matters for
7
+ memory-constrained Chronos deployments: DPO needs two full models in
8
+ memory; ORPO needs one.
9
+
10
+ Loss:
11
+ L_NLL = −mean_token log p_θ(y_w | x) # supervised on chosen
12
+ L_OR = −log σ( β · log [ odds(y_w) / odds(y_l) ] )
13
+ where odds(y) = p_θ(y) / (1 - p_θ(y))
14
+ L_ORPO = L_NLL + λ · L_OR
15
+
16
+ Plus Chronos mixin (temporal / lookahead / aux) and router KL anchor.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import time
22
+ from contextlib import nullcontext
23
+
24
+ import torch
25
+ import torch.nn.functional as F
26
+ from torch import optim
27
+ from torch.utils.data import DataLoader
28
+
29
+ import chronos.deps # noqa
30
+ from trainer.trainer_utils import Logger, is_main_process # type: ignore
31
+ from chronos.data.flexible_dataset import StreamingDPODataset as DPODataset
32
+
33
+ from chronos.model.config import ChronosConfig
34
+ from chronos.model.model_chronos import ChronosForCausalLM
35
+ from chronos.model.temporal_loss import lookahead_supervision_loss
36
+ from chronos.trainer.optim_utils import get_lr
37
+ from chronos.trainer.loss_mixin import (
38
+ chronos_loss_term,
39
+ collect_router_probs,
40
+ router_kl_anchor,
41
+ capture_reference_routing,
42
+ )
43
+
44
+
45
+ def _mean_logprob(logits: torch.Tensor, labels: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
46
+ """Mean log-prob of `labels` under `logits`, averaged over masked positions."""
47
+ log_probs = F.log_softmax(logits, dim=-1)
48
+ lp = log_probs.gather(dim=-1, index=labels.unsqueeze(-1)).squeeze(-1) # [B, T]
49
+ lp = (lp * mask).sum(dim=1) / mask.sum(dim=1).clamp_min(1.0)
50
+ return lp
51
+
52
+
53
+ class ChronosORPOTrainer:
54
+ def __init__(self, model: ChronosForCausalLM, config: ChronosConfig, args, tokenizer):
55
+ self.model = model
56
+ self.config = config
57
+ self.args = args
58
+ self.device = args.device
59
+ self.beta = float(getattr(args, "beta", 0.1))
60
+ self.lambda_or = float(getattr(args, "lambda_or", 0.1))
61
+
62
+ device_type = "cuda" if "cuda" in str(self.device) else "cpu"
63
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
64
+ self.autocast_ctx = nullcontext() if device_type == "cpu" \
65
+ else torch.cuda.amp.autocast(dtype=dtype)
66
+ self.scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == "float16"))
67
+ from chronos.trainer.optim_utils import build_optimizer
68
+ self.optimizer = build_optimizer(
69
+ model, lr=args.learning_rate,
70
+ weight_decay=float(getattr(args, "weight_decay", 0.01)),
71
+ )
72
+ self._router_ref = None
73
+
74
+ def set_calibration_batch(self, x: torch.Tensor):
75
+ self._router_ref = capture_reference_routing(self.model, x, self.device)
76
+
77
+ def train_step(self, batch, step, total_steps):
78
+ x_c = batch["x_chosen"].to(self.device)
79
+ x_r = batch["x_rejected"].to(self.device)
80
+ y_c = batch["y_chosen"].to(self.device)
81
+ y_r = batch["y_rejected"].to(self.device)
82
+ m_c = batch["mask_chosen"].to(self.device).float()
83
+ m_r = batch["mask_rejected"].to(self.device).float()
84
+ x = torch.cat([x_c, x_r], dim=0)
85
+ y = torch.cat([y_c, y_r], dim=0)
86
+
87
+ lr = get_lr(step, total_steps, self.args.learning_rate)
88
+ for pg in self.optimizer.param_groups:
89
+ pg["lr"] = lr
90
+
91
+ with self.autocast_ctx:
92
+ outputs, lookahead_probs = self.model(x)
93
+ B2 = x_c.shape[0]
94
+ logits_c = outputs.logits[:B2]
95
+ logits_r = outputs.logits[B2:]
96
+
97
+ lp_c = _mean_logprob(logits_c, y_c, m_c) # [B]
98
+ lp_r = _mean_logprob(logits_r, y_r, m_r)
99
+
100
+ # NLL on chosen
101
+ nll = -lp_c.mean()
102
+
103
+ # Odds-ratio term (numerically stable via log-space)
104
+ # log odds(y) = lp - log(1 - exp(lp)). Use log(1 - exp(lp)) ≈
105
+ # log1p(-exp(lp)) with clamping to avoid NaN at lp ≈ 0.
106
+ def _log_odds(lp):
107
+ one_minus = (1.0 - lp.exp()).clamp_min(1e-6)
108
+ return lp - one_minus.log()
109
+ log_odds_ratio = _log_odds(lp_c) - _log_odds(lp_r)
110
+ l_or = -F.logsigmoid(self.beta * log_odds_ratio).mean()
111
+
112
+ base = nll + self.lambda_or * l_or
113
+
114
+ loss = chronos_loss_term(
115
+ self.model, base, lookahead_probs, self.config,
116
+ aux_loss=outputs.aux_loss,
117
+ )
118
+
119
+ anc_val = 0.0
120
+ lam = float(getattr(self.config, "lambda_router_anchor", 0.0))
121
+ if lam > 0.0 and self._router_ref is not None:
122
+ r4 = collect_router_probs(self.model)
123
+ if r4 is not None and r4.shape[1] > 1:
124
+ cur = r4.mean(dim=2)
125
+ ref = self._router_ref
126
+ T = min(cur.shape[1], ref.shape[1])
127
+ Bm = min(cur.shape[0], ref.shape[0])
128
+ anc = router_kl_anchor(cur[:Bm, :T, :], ref[:Bm, :T, :], lam)
129
+ loss = loss + anc
130
+ anc_val = float(anc.item())
131
+
132
+ la_val = 0.0
133
+ r4 = collect_router_probs(self.model)
134
+ if (r4 is not None and r4.shape[1] > 1
135
+ and lookahead_probs is not None
136
+ and self.config.lookahead_steps > 0):
137
+ la_val = float(lookahead_supervision_loss(
138
+ lookahead_probs, r4.mean(dim=2).detach(),
139
+ self.config.lookahead_steps,
140
+ ).item())
141
+
142
+ loss = loss / self.args.accumulation_steps
143
+
144
+ self.scaler.scale(loss).backward()
145
+
146
+ if step % self.args.accumulation_steps == 0:
147
+ self.scaler.unscale_(self.optimizer)
148
+ torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.args.grad_clip)
149
+ self.scaler.step(self.optimizer)
150
+ self.scaler.update()
151
+ self.optimizer.zero_grad(set_to_none=True)
152
+
153
+ return (
154
+ loss.item() * self.args.accumulation_steps,
155
+ float(nll.item()),
156
+ float(l_or.item()),
157
+ la_val,
158
+ anc_val,
159
+ )
160
+
161
+ def train_epoch(self, epoch, loader, iters, max_steps=None):
162
+ self.model.train()
163
+ total_steps = self.args.epochs * iters
164
+ for step, batch in enumerate(loader, start=1):
165
+ if max_steps is not None and step > max_steps:
166
+ break
167
+ loss, nll, lor, la, anc = self.train_step(
168
+ batch, epoch * iters + step, total_steps,
169
+ )
170
+ if step % self.args.log_interval == 0 or step == iters:
171
+ lr = self.optimizer.param_groups[-1]["lr"]
172
+ Logger(
173
+ f"[ORPO] Epoch[{epoch+1}/{self.args.epochs}]({step}/{iters}) "
174
+ f"loss:{loss:.4f} nll:{nll:.4f} or:{lor:.4f} "
175
+ f"la:{la:.4f} anchor:{anc:.4f} lr:{lr:.2e}"
176
+ )
177
+
178
+ def _save(self, epoch, step):
179
+ os.makedirs(self.args.save_dir, exist_ok=True)
180
+ ckp = os.path.join(self.args.save_dir, f"orpo_{self.config.hidden_size}_moe.pth")
181
+ self.model.eval()
182
+ state = self.model.state_dict()
183
+ torch.save({k: v.half().cpu() for k, v in state.items()}, ckp)
184
+ self.model.train()
185
+
186
+
187
+ def build_orpo_loader(data_path, tokenizer, max_seq_len, batch_size):
188
+ ds = DPODataset(data_path, tokenizer, max_length=max_seq_len)
189
+ return DataLoader(ds, batch_size=batch_size, shuffle=True, num_workers=0)
@@ -0,0 +1,130 @@
1
+ """
2
+ chronos/trainer/reward.py
3
+
4
+ Pluggable reward models for GRPO. Two built-ins:
5
+
6
+ - ``ToyReward`` — length + vocab-purity + repetition-penalty heuristic.
7
+ No external dependencies, used in smoke tests and when no real reward
8
+ model is configured.
9
+ - ``LMRewardModel`` — wraps an HF AutoModel that exposes a ``.get_score(...)``
10
+ method (the minimind convention, see
11
+ ``minimind-master/trainer/trainer_utils.py:160``). Loads lazily so
12
+ importing this file never forces an HF network download.
13
+
14
+ Any callable ``score(prompt: str, response_tokens: List[int], tokenizer) -> float``
15
+ is accepted by ``ChronosGRPOTrainer``; the classes below simply package
16
+ that protocol.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from typing import List, Optional
21
+
22
+
23
+ TOY_VOCAB = {
24
+ "the", "quick", "brown", "fox", "machine", "learning", "model", "expert",
25
+ "router", "cluster", "memory", "cache", "prefetch", "latency", "throughput",
26
+ "tensor", "training", "inference", "decode", "weight", "gradient",
27
+ "softmax", "attention", "layer", "embedding", "vocab", "sequence",
28
+ "loss", "optimizer", "epoch", "batch", "step", "token", "logit",
29
+ "minimind", "chronos", "hybrid", "mixture", "of", "experts", "sparse",
30
+ "dense", "neural", "network", "transformer", "rope", "rotary", "kv",
31
+ }
32
+
33
+
34
+ class ToyReward:
35
+ """Zero-dependency heuristic reward: length + vocab-purity − repetition."""
36
+
37
+ def __init__(self, target_len: int = 24, vocab: Optional[set] = None):
38
+ self.target_len = target_len
39
+ self.vocab = vocab or TOY_VOCAB
40
+
41
+ def __call__(self, prompt: str, response_tokens: List[int], tokenizer) -> float:
42
+ return self.score(prompt, response_tokens, tokenizer)
43
+
44
+ def score(self, prompt: str, response_tokens: List[int], tokenizer) -> float:
45
+ text = tokenizer.decode(response_tokens, skip_special_tokens=True)
46
+ words = text.split()
47
+ if not words:
48
+ return -1.0
49
+ length_reward = min(len(words) / max(self.target_len, 1), 1.0)
50
+ on_vocab = sum(1 for w in words if w.lower() in self.vocab)
51
+ vocab_purity = on_vocab / len(words)
52
+ dupes = sum(1 for a, b in zip(words[:-1], words[1:]) if a == b)
53
+ repetition_penalty = -dupes / max(len(words), 1)
54
+ return length_reward + vocab_purity + repetition_penalty
55
+
56
+
57
+ class LMRewardModel:
58
+ """HF-backed reward model.
59
+
60
+ Wraps any ``AutoModel`` that exposes ``get_score(tokenizer, messages) -> float``
61
+ (the reward-model ABI used by minimind and several open reward-model
62
+ checkpoints). The model is loaded lazily on the first call so that
63
+ importing this module is free.
64
+
65
+ Example::
66
+
67
+ reward = LMRewardModel("path/to/internlm2-1_8b-reward")
68
+ r = reward.score(prompt, response_tokens, tokenizer)
69
+
70
+ The score is clipped to [-3.0, 3.0] to keep GRPO advantages well-scaled,
71
+ matching the minimind convention.
72
+ """
73
+
74
+ def __init__(self, model_path: str, device: str = "cpu",
75
+ dtype_str: str = "float16", score_clip: float = 3.0):
76
+ self.model_path = model_path
77
+ self.device = device
78
+ self.dtype_str = dtype_str
79
+ self.score_clip = float(score_clip)
80
+ self._model = None
81
+ self._tokenizer = None
82
+
83
+ def _load(self):
84
+ if self._model is not None:
85
+ return
86
+ import torch
87
+ from transformers import AutoTokenizer, AutoModel
88
+ dtype = torch.float16 if self.dtype_str == "float16" else torch.float32
89
+ self._tokenizer = AutoTokenizer.from_pretrained(
90
+ self.model_path, trust_remote_code=True,
91
+ )
92
+ self._model = AutoModel.from_pretrained(
93
+ self.model_path, torch_dtype=dtype, trust_remote_code=True,
94
+ ).to(self.device).eval()
95
+
96
+ def __call__(self, prompt: str, response_tokens: List[int], tokenizer) -> float:
97
+ return self.score(prompt, response_tokens, tokenizer)
98
+
99
+ def score(self, prompt: str, response_tokens: List[int], tokenizer) -> float:
100
+ self._load()
101
+ response = tokenizer.decode(response_tokens, skip_special_tokens=True)
102
+ messages = [
103
+ {"role": "user", "content": prompt},
104
+ {"role": "assistant", "content": response},
105
+ ]
106
+ s = self._model.get_score(self._tokenizer, messages)
107
+ return max(min(float(s), self.score_clip), -self.score_clip)
108
+
109
+
110
+ def build_reward_fn(spec: Optional[str]) -> callable:
111
+ """Convert a CLI spec string into a reward callable.
112
+
113
+ Spec forms:
114
+ None | "" | "toy" → ToyReward()
115
+ "lm:/path/to/reward-model" → LMRewardModel(path)
116
+ "lm:/path/to/reward,device=cuda" → LMRewardModel(path, device="cuda")
117
+ """
118
+ if not spec or spec == "toy":
119
+ return ToyReward()
120
+ if spec.startswith("lm:"):
121
+ body = spec[3:]
122
+ parts = body.split(",")
123
+ path = parts[0]
124
+ kwargs = {}
125
+ for kv in parts[1:]:
126
+ if "=" in kv:
127
+ k, v = kv.split("=", 1)
128
+ kwargs[k.strip()] = v.strip()
129
+ return LMRewardModel(path, **kwargs)
130
+ raise ValueError(f"unknown reward spec: {spec!r}")
@@ -0,0 +1,189 @@
1
+ """
2
+ chronos/trainer/sft_trainer.py
3
+
4
+ Chronos-aware supervised fine-tuning trainer.
5
+
6
+ Built around minimind's SFTDataset (jsonl with `conversations` field,
7
+ tokenizer.apply_chat_template + per-assistant-token loss mask) so the data
8
+ pipeline is identical to the one in minimind-master/trainer/train_full_sft.py.
9
+ What we add:
10
+
11
+ 1. The model returns ``(outputs, lookahead_probs)``, which minimind's trainer
12
+ does not know about. We forward it explicitly.
13
+ 2. ``chronos_loss_term`` mixes in λ_balance·aux + λ_temporal·temporal +
14
+ λ_lookahead·lookahead on top of the base CE.
15
+ 3. Optional ``router_kl_anchor`` pulls the current router distribution back
16
+ toward the pretrain reference (default λ=0.01 for SFT — mild drift is
17
+ OK). The reference is captured once at stage start.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import time
23
+ from contextlib import nullcontext
24
+
25
+ import torch
26
+ from torch import optim
27
+ from torch.utils.data import DataLoader
28
+
29
+ import chronos.deps # ensure minimind on sys.path
30
+
31
+ from trainer.trainer_utils import Logger, is_main_process # type: ignore
32
+ from chronos.data.flexible_dataset import StreamingSFTDataset
33
+
34
+ from chronos.model.config import ChronosConfig
35
+ from chronos.model.model_chronos import ChronosForCausalLM
36
+ from chronos.model.temporal_loss import lookahead_supervision_loss
37
+ from chronos.trainer.optim_utils import get_lr
38
+ from chronos.trainer.loss_mixin import (
39
+ chronos_loss_term,
40
+ collect_router_probs,
41
+ router_kl_anchor,
42
+ capture_reference_routing,
43
+ )
44
+
45
+
46
+ class ChronosSFTTrainer:
47
+ """Stage 2 (SFT) trainer for Chronos."""
48
+
49
+ def __init__(self, model: ChronosForCausalLM, config: ChronosConfig, args, tokenizer):
50
+ self.model = model
51
+ self.config = config
52
+ self.args = args
53
+ self.tokenizer = tokenizer
54
+ self.device = args.device
55
+
56
+ device_type = "cuda" if "cuda" in str(self.device) else "cpu"
57
+ dtype = torch.bfloat16 if args.dtype == "bfloat16" else torch.float16
58
+ self.autocast_ctx = (
59
+ nullcontext() if device_type == "cpu"
60
+ else torch.cuda.amp.autocast(dtype=dtype)
61
+ )
62
+ self.scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype == "float16"))
63
+ from chronos.trainer.optim_utils import build_optimizer
64
+ self.optimizer = build_optimizer(
65
+ model, lr=args.learning_rate,
66
+ weight_decay=float(getattr(args, "weight_decay", 0.01)),
67
+ )
68
+
69
+ # Router anchor reference — captured at the start of a stage
70
+ self._router_ref = None
71
+ self._calib_batch = None
72
+
73
+ def set_calibration_batch(self, input_ids: torch.Tensor):
74
+ """Provide a representative input_ids batch; at stage start the
75
+ current (pre-SFT) model's router is frozen as the anchor teacher."""
76
+ self._calib_batch = input_ids
77
+ self._router_ref = capture_reference_routing(self.model, input_ids, self.device)
78
+
79
+ def train_step(self, input_ids, labels, step, total_steps):
80
+ input_ids = input_ids.to(self.device)
81
+ labels = labels.to(self.device)
82
+
83
+ lr = get_lr(step, total_steps, self.args.learning_rate)
84
+ for pg in self.optimizer.param_groups:
85
+ pg['lr'] = lr
86
+
87
+ with self.autocast_ctx:
88
+ outputs, lookahead_probs = self.model(input_ids, labels=labels)
89
+ ce_loss = outputs.loss
90
+ aux_loss = outputs.aux_loss
91
+
92
+ loss = chronos_loss_term(
93
+ self.model, ce_loss, lookahead_probs, self.config,
94
+ aux_loss=aux_loss,
95
+ )
96
+
97
+ # Router KL anchor
98
+ anchor_val = 0.0
99
+ lam = float(getattr(self.config, "lambda_router_anchor", 0.0))
100
+ if lam > 0.0 and self._router_ref is not None:
101
+ router_4d = collect_router_probs(self.model)
102
+ if router_4d is not None and router_4d.shape[1] > 1:
103
+ cur = router_4d.mean(dim=2)
104
+ # Shape-align: calibration batch shape may differ; crop
105
+ # the shorter axis on both sides.
106
+ ref = self._router_ref
107
+ T = min(cur.shape[1], ref.shape[1])
108
+ B = min(cur.shape[0], ref.shape[0])
109
+ anchor = router_kl_anchor(
110
+ cur[:B, :T, :], ref[:B, :T, :], lam,
111
+ )
112
+ loss = loss + anchor
113
+ anchor_val = float(anchor.item())
114
+
115
+ # Standalone lookahead loss for logging
116
+ la_val = 0.0
117
+ router_4d = collect_router_probs(self.model)
118
+ if (router_4d is not None and router_4d.shape[1] > 1
119
+ and lookahead_probs is not None
120
+ and self.config.lookahead_steps > 0):
121
+ teacher = router_4d.mean(dim=2).detach()
122
+ la = lookahead_supervision_loss(
123
+ lookahead_probs, teacher, self.config.lookahead_steps,
124
+ )
125
+ la_val = float(la.item())
126
+
127
+ loss = loss / self.args.accumulation_steps
128
+
129
+ self.scaler.scale(loss).backward()
130
+
131
+ if step % self.args.accumulation_steps == 0:
132
+ self.scaler.unscale_(self.optimizer)
133
+ torch.nn.utils.clip_grad_norm_(self.model.parameters(), self.args.grad_clip)
134
+ self.scaler.step(self.optimizer)
135
+ self.scaler.update()
136
+ self.optimizer.zero_grad(set_to_none=True)
137
+
138
+ return (
139
+ loss.item() * self.args.accumulation_steps,
140
+ ce_loss.item(),
141
+ float(aux_loss.item()) if aux_loss is not None else 0.0,
142
+ la_val,
143
+ anchor_val,
144
+ )
145
+
146
+ def train_epoch(self, epoch, loader, iters, start_step=0, wandb=None, max_steps=None):
147
+ self.model.train()
148
+ start_time = time.time()
149
+ total_steps = self.args.epochs * iters
150
+
151
+ for step, (input_ids, labels) in enumerate(loader, start=start_step + 1):
152
+ if max_steps is not None and step > max_steps:
153
+ break
154
+ loss, ce, aux, la, anc = self.train_step(
155
+ input_ids, labels, epoch * iters + step, total_steps,
156
+ )
157
+ if step % self.args.log_interval == 0 or step == iters:
158
+ elapsed = time.time() - start_time
159
+ eta = elapsed / max(step - start_step, 1) * (iters - step) // 60
160
+ lr = self.optimizer.param_groups[-1]['lr']
161
+ Logger(
162
+ f'[SFT] Epoch[{epoch+1}/{self.args.epochs}]({step}/{iters}) '
163
+ f'loss:{loss:.4f} ce:{ce:.4f} aux:{aux:.4f} '
164
+ f'la:{la:.4f} anchor:{anc:.4f} lr:{lr:.2e} eta:{eta:.1f}min'
165
+ )
166
+ if wandb:
167
+ wandb.log({
168
+ "sft_loss": loss, "sft_ce": ce, "sft_aux": aux,
169
+ "sft_lookahead": la, "sft_anchor": anc, "lr": lr,
170
+ })
171
+
172
+ if (step % self.args.save_interval == 0 or step == iters) and is_main_process():
173
+ self._save(epoch, step)
174
+
175
+ def _save(self, epoch, step):
176
+ os.makedirs(self.args.save_dir, exist_ok=True)
177
+ ckp = os.path.join(
178
+ self.args.save_dir,
179
+ f'sft_{self.config.hidden_size}_moe.pth',
180
+ )
181
+ self.model.eval()
182
+ state = self.model.state_dict()
183
+ torch.save({k: v.half().cpu() for k, v in state.items()}, ckp)
184
+ self.model.train()
185
+
186
+
187
+ def build_sft_loader(data_path, tokenizer, max_seq_len, batch_size):
188
+ ds = StreamingSFTDataset(data_path, tokenizer, max_length=max_seq_len)
189
+ return DataLoader(ds, batch_size=batch_size, shuffle=True, num_workers=0)
@@ -0,0 +1 @@
1
+ from .chronos_auto_tuner import ChronosAutoTuner, ChronosSearchSpaceConfig