Add KL anchor to BC reference policy in trainer
Self-play drift fix: regularize loss with KL(current || BC_reference). Config: training.kl_anchor_ckpt + training.kl_anchor_beta. Loaded once at trainer init, frozen. KL computed over legal actions only. Hypothesis: appropriate beta keeps pretrained competence during self-play finetune, escaping the c9 catastrophic forgetting.
This commit is contained in:
@@ -67,6 +67,13 @@ class TrainingConfig(StrictModel):
|
|||||||
# head learn faster relative to policy loss. Useful when value_prediction_error
|
# head learn faster relative to policy loss. Useful when value_prediction_error
|
||||||
# is large but loss/value is tiny because of the normalization.
|
# is large but loss/value is tiny because of the normalization.
|
||||||
value_loss_weight: float = 1.0
|
value_loss_weight: float = 1.0
|
||||||
|
# Optional KL anchor to a reference (e.g. behavior-cloned) policy. The
|
||||||
|
# reference network is loaded once at trainer start and frozen; on every
|
||||||
|
# gradient step we add `kl_anchor_beta * KL(current || reference)` to the
|
||||||
|
# loss. Anchors self-play training to the pretrained policy and prevents
|
||||||
|
# catastrophic forgetting / drift to weak self-play equilibria.
|
||||||
|
kl_anchor_ckpt: str | None = None
|
||||||
|
kl_anchor_beta: float = 0.0
|
||||||
|
|
||||||
@field_validator(
|
@field_validator(
|
||||||
"games_per_iter",
|
"games_per_iter",
|
||||||
|
|||||||
@@ -83,6 +83,28 @@ class IsMctsTrainer:
|
|||||||
self.metrics_path = self.run_dir / "metrics.jsonl"
|
self.metrics_path = self.run_dir / "metrics.jsonl"
|
||||||
self.rng = random.Random(config.run.seed)
|
self.rng = random.Random(config.run.seed)
|
||||||
|
|
||||||
|
# Optional KL anchor: load a frozen reference network whose policy we
|
||||||
|
# use to regularize updates (KL(current || reference) added to loss).
|
||||||
|
# Prevents drift from a BC pretrained baseline during self-play.
|
||||||
|
self.kl_anchor_ref: AlphaZeroNet | None = None
|
||||||
|
self.kl_anchor_beta = float(config.training.kl_anchor_beta)
|
||||||
|
if config.training.kl_anchor_ckpt and self.kl_anchor_beta > 0.0:
|
||||||
|
ref_path = Path(config.training.kl_anchor_ckpt)
|
||||||
|
if not ref_path.exists():
|
||||||
|
raise FileNotFoundError(f"kl_anchor_ckpt not found: {ref_path}")
|
||||||
|
ref_payload = torch.load(ref_path, map_location=self.device, weights_only=False)
|
||||||
|
self.kl_anchor_ref = AlphaZeroNet.from_config(
|
||||||
|
self.input_dim, self.action_size, config
|
||||||
|
).to(self.device)
|
||||||
|
self.kl_anchor_ref.load_state_dict(ref_payload["network"])
|
||||||
|
self.kl_anchor_ref.eval()
|
||||||
|
for p in self.kl_anchor_ref.parameters():
|
||||||
|
p.requires_grad = False
|
||||||
|
print(
|
||||||
|
f"[trainer] KL anchor: ref={ref_path} beta={self.kl_anchor_beta}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_device(self, device: torch.device | str) -> torch.device:
|
def _resolve_device(self, device: torch.device | str) -> torch.device:
|
||||||
token = str(device)
|
token = str(device)
|
||||||
if token == "auto":
|
if token == "auto":
|
||||||
@@ -263,6 +285,22 @@ class IsMctsTrainer:
|
|||||||
value_loss = nn.functional.mse_loss(value_pred / v_scale, value_target / v_scale)
|
value_loss = nn.functional.mse_loss(value_pred / v_scale, value_target / v_scale)
|
||||||
value_weight = float(self.config.training.value_loss_weight)
|
value_weight = float(self.config.training.value_loss_weight)
|
||||||
loss = policy_loss + value_weight * value_loss
|
loss = policy_loss + value_weight * value_loss
|
||||||
|
|
||||||
|
# KL anchor: KL(current || reference) over legal actions only.
|
||||||
|
# Encourages current policy to stay close to the reference (BC) policy.
|
||||||
|
kl_anchor_loss = 0.0
|
||||||
|
if self.kl_anchor_ref is not None and self.kl_anchor_beta > 0.0:
|
||||||
|
with torch.no_grad():
|
||||||
|
ref_logits, _ref_value = self.kl_anchor_ref(info, legal)
|
||||||
|
ref_log_probs = torch.log_softmax(ref_logits, dim=-1)
|
||||||
|
# KL(current || ref) = sum_a p_cur(a) * (log p_cur(a) - log p_ref(a))
|
||||||
|
cur_probs = log_probs.exp()
|
||||||
|
legal_f = legal.float()
|
||||||
|
kl_per_action = cur_probs * (log_probs - ref_log_probs) * legal_f
|
||||||
|
kl = kl_per_action.sum(dim=-1).mean()
|
||||||
|
loss = loss + self.kl_anchor_beta * kl
|
||||||
|
kl_anchor_loss = float(kl.item())
|
||||||
|
|
||||||
self.optimizer.zero_grad(set_to_none=True)
|
self.optimizer.zero_grad(set_to_none=True)
|
||||||
loss.backward()
|
loss.backward()
|
||||||
if self.config.optimization.grad_clip > 0:
|
if self.config.optimization.grad_clip > 0:
|
||||||
@@ -271,6 +309,9 @@ class IsMctsTrainer:
|
|||||||
self.config.optimization.grad_clip,
|
self.config.optimization.grad_clip,
|
||||||
)
|
)
|
||||||
self.optimizer.step()
|
self.optimizer.step()
|
||||||
|
# Stash auxiliary loss for the IterationMetrics path; we return only
|
||||||
|
# the three primary scalars for backward compatibility.
|
||||||
|
self._last_kl_anchor_loss = kl_anchor_loss
|
||||||
return float(policy_loss.item()), float(value_loss.item()), float(loss.item())
|
return float(policy_loss.item()), float(value_loss.item()), float(loss.item())
|
||||||
|
|
||||||
def _evaluate(self, iteration: int) -> dict[str, float | int]:
|
def _evaluate(self, iteration: int) -> dict[str, float | int]:
|
||||||
|
|||||||
Reference in New Issue
Block a user