File size: 28,399 Bytes
bb23b91 f8d1cb3 bb23b91 21a3a16 bb23b91 21a3a16 bb23b91 21a3a16 bb23b91 f8d1cb3 bb23b91 f8d1cb3 bb23b91 21a3a16 bb23b91 f8d1cb3 bb23b91 f8d1cb3 bb23b91 f8d1cb3 bb23b91 f8d1cb3 bb23b91 f8d1cb3 21a3a16 bb23b91 21a3a16 bb23b91 f8d1cb3 21a3a16 bb23b91 21a3a16 f8d1cb3 bb23b91 21a3a16 bb23b91 f8d1cb3 bb23b91 f8d1cb3 bb23b91 21a3a16 f8d1cb3 bb23b91 f8d1cb3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 | # coding=utf-8
# Copyright 2024 The Google Research Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Transformer LM trainer."""
import functools
from clu import periodic_actions
from flax.training import common_utils
from flax.training import train_state
import jax
from jax import numpy as jnp
import numpy as np
import optax
import ml_collections
from train import model
def get_state(config, net, initial_variables):
"""Get the train state given an experiment config, a model and initial variables.
Args:
config: A ConfigDict containing the configuration for the experiment.
net: The model to use for training.
initial_variables: The initial variables for the model.
Returns:
A tuple containing the train state and the learning rate schedule.
"""
# Learning rate schedule
lr_scheduler_fn = functools.partial(
lr_scheduler,
learning_rate=config.learning_rate,
warmup_tokens=config.warmup_tokens,
final_tokens=config.max_steps,
config=config,
)
# Optimizer
optim_fn = optax.adamw(
lr_scheduler_fn, weight_decay=config.weight_decay, b1=0.9, b2=0.95
)
# Clip the gradients to prevent exploding gradients.
optimizer = optax.chain(optax.clip_by_global_norm(1), optim_fn)
# Initialize the train state
state = train_state.TrainState.create(
apply_fn=net.apply, params=initial_variables["params"],
tx=optimizer
)
return state, lr_scheduler_fn
def lr_scheduler(
n_tokens: int, learning_rate: float, warmup_tokens: int, final_tokens: int,
config: ml_collections.ConfigDict,
) -> float:
"""Learning rate scheduler, adapted from Mikhail Grankin.
The learning rate schedule is cosine decay with a warmup period.
The learning rate starts at 0 and linearly increases to the given learning
rate over the warmup period. After the warmup period, the learning rate
decays according to a cosine schedule, with the given learning rate as the
maximum value.
Args:
n_tokens: The number of tokens processed so far.
learning_rate: The initial learning rate.
warmup_tokens: The number of tokens to warm up over.
final_tokens: The total number of tokens to process.
config: A ConfigDict containing the configuration for the learning rate
schedule.
Returns:
The learning rate at the given point in the schedule.
"""
# Decay the learning rate based on our progress.
progress = (n_tokens - warmup_tokens) / max(
1, final_tokens - warmup_tokens,
)
lr_mult = jnp.where(
n_tokens < warmup_tokens,
# Linear warmup.
n_tokens / jnp.fmax(1, warmup_tokens),
# Cosine learning rate decay.
jnp.fmax(config.end_lr_factor, 0.5 * (1.0 + jnp.cos(np.pi * progress))),
)
return learning_rate * lr_mult
def get_metrics_report_progress(config, workdir, writer):
"""
Get the metrics for reporting progress during training.
Args:
config: The configuration for the experiment.
workdir: The directory for storing the logs.
writer: The writer object for recording the metrics.
Returns:
hooks: List of hooks for tracking progress.
report_progress: Object for reporting progress.
train_metrics: List of training metrics.
"""
hooks = []
# Initialize the report progress object
report_progress = periodic_actions.ReportProgress(
num_train_steps=config.max_steps, writer=writer)
# Add metrics for profiling if the process index is 0
if jax.process_index() == 0:
hooks += [report_progress,
periodic_actions.Profile(logdir=workdir, num_profile_steps=5)]
# Initialize the list of training metrics
train_metrics = []
return hooks, report_progress, train_metrics
def get_input_start_index(batch, config):
inputs = jax.tree_util.tree_map(np.asarray, batch[0])
puzzles = jax.tree_util.tree_map(np.asarray, batch[1])
start_index = jax.tree_util.tree_map(np.asarray, batch[2])
levels = jax.tree_util.tree_map(np.asarray, batch[3])
cand_targets = jax.tree_util.tree_map(np.asarray, batch[4])
return inputs, puzzles, start_index, levels, cand_targets
def train_one_step(p_train_step, config, state, step, dropout_rngs, train_data_iter):
"""
Single step of the training loop.
Args:
p_train_step: The training step function.
config: The experiment configuration.
state: The train state.
step: The step number.
dropout_rngs: The dropout random number generator.
train_data_iter: The iterator for the train data.
Returns:
The updated train state and train metrics.
"""
with jax.profiler.StepTraceAnnotation("train", step_num=step):
# Get the next batch from the iterator
batch = next(train_data_iter)
# Extract the inputs, start index and difficulty level from the batch
inputs, _, start_index, levels, cand_targets = get_input_start_index(batch, config)
# Shard across the devices
inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
start_index = common_utils.shard(jax.tree_util.tree_map(np.asarray, start_index))
levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
# Explicit reshape (not common_utils.shard) so a zero-width slot dim
# (K=0 no-latent baseline: cand_targets is (bs, 0, 81)) shards without
# the ambiguous -1 inference. Identical to shard() when K>0.
cand_targets = np.asarray(cand_targets)
_nd = jax.local_device_count()
cand_targets = cand_targets.reshape(
(_nd, cand_targets.shape[0] // _nd) + cand_targets.shape[1:])
# Run the training step
state, metrics, _ = p_train_step(
state, inputs, start_index, levels, cand_targets, dropout_rng=dropout_rngs
)
return state, metrics
def _shard_train_batch(batch, config):
"""Unpack a dataset batch and shard it the way train_one_step does."""
inputs, _, start_index, levels, cand_targets = get_input_start_index(
batch, config)
inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
start_index = common_utils.shard(
jax.tree_util.tree_map(np.asarray, start_index))
levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
cand_targets = np.asarray(cand_targets)
nd = jax.local_device_count()
cand_targets = cand_targets.reshape(
(nd, cand_targets.shape[0] // nd) + cand_targets.shape[1:])
return inputs, start_index, levels, cand_targets
def apply_grads(state, grads):
"""Replicated optimizer step. Used after averaging per-depth grads."""
return state.apply_gradients(grads=grads)
def train_depth_mix_step(grad_step_for, p_apply, config, state, step,
dropout_rngs, mix_ds, n_depths):
"""One optimizer step whose CE is the mean over depths 1..n_depths.
Depth d is run with d latent passes on a batch whose value tokens are
stage-(d-1) instances, drawn from `mix_ds` (a SudokuDataset, not tf.data).
Gradients are summed and divided by n_depths, then applied once, so the
update is joint rather than a serial overwrite.
"""
n_depths = int(max(n_depths, 1))
bs = int(config.minibatch_size)
acc = None
metrics_list = []
with jax.profiler.StepTraceAnnotation("train", step_num=step):
for d in range(1, n_depths + 1):
batch = mix_ds.sample_instance_batch(bs, d - 1)
inputs, start_index, levels, cand_targets = _shard_train_batch(
batch, config)
grads, metrics, _ = grad_step_for(d)(
state, inputs, start_index, levels, cand_targets,
dropout_rng=dropout_rngs)
acc = (grads if acc is None
else jax.tree_util.tree_map(lambda a, b: a + b, acc, grads))
metrics_list.append(metrics)
acc = jax.tree_util.tree_map(lambda a: a / n_depths, acc)
state = p_apply(state, acc)
def _mean_metric(key):
vals = [m[key] for m in metrics_list if key in m]
if not vals:
return None
return sum(vals) / float(len(vals))
metrics = dict(metrics_list[-1])
for key in ("loss", "ce_loss", "aux_loss", "leak_loss", "latent_sigma"):
v = _mean_metric(key)
if v is not None:
metrics[key] = v
return state, metrics
def build_latent_state(inputs, start_index, levels, config, num_passes,
apply_fn, rngs=None, trunc_passes=0):
"""Build the continuous latent thoughts (Coconut/ATC-style recurrence).
Pass 0 seeds z_1 from the last-layer hidden at the last clue token; pass j
reads the hidden at latent slot j to produce z_{j+1}. Slot j is active for
an example only when j < k, with k = clip(level - 2, 1, K) (difficulty-
matched latent budget). By default gradients flow through all passes
(full BPTT). If trunc_passes=T>0, the forward chain is unchanged but
thoughts older than the last T CoTs are stop-gradient'd, so the loss
only backprops through those last T recurrent steps.
Returns (latent_vals, slot_pos, active_full).
"""
num_slots = int(config.num_latent_slots)
bs = inputs.shape[0]
bidx = jnp.arange(bs)
si3 = 3 * start_index.reshape(-1) # (bs,)
slot_pos = si3[:, None] + jnp.arange(num_slots)[None, :] # (bs, K)
if getattr(config, "cand_slot_mode", "level") == "depth":
# Every slot the recurrence fills is active, uniformly over the batch.
k = jnp.full((bs,), max(min(num_passes, num_slots), 1), dtype=jnp.int32)
else:
k = jnp.clip(levels.reshape(-1) - 2, 1, num_slots) # (bs,)
active_full = jnp.arange(num_slots)[None, :] < k[:, None] # (bs, K)
latent_vals = jnp.zeros((bs, num_slots, config.emb_dim), dtype=config.dtype)
for j in range(num_passes):
act_j = active_full & (jnp.arange(num_slots)[None, :] < j)
_, hidden, _ = apply_fn(inputs, latent_vals, slot_pos, act_j, rngs)
src = si3 - 1 + j # j=0: last clue token; j>0: latent slot j-1
z = hidden[bidx, src]
# Truncated BPTT: keep the full forward chain, but detach CoTs
# before the last T so earlier thoughts are treated as constants.
if trunc_passes > 0 and j < num_passes - trunc_passes:
z = jax.lax.stop_gradient(z)
latent_vals = latent_vals.at[:, j].set(z.astype(config.dtype))
return latent_vals, slot_pos, active_full
def build_latent_state_noisy(inputs, start_index, levels, config, num_passes,
apply_fn, sample_slot, rng, trunc_passes=0):
"""One sample: noise h1, pass, noise h2, pass, ..., then readout."""
num_slots = int(config.num_latent_slots)
bs = inputs.shape[0]
bidx = jnp.arange(bs)
si3 = 3 * start_index.reshape(-1)
slot_pos = si3[:, None] + jnp.arange(num_slots)[None, :]
if getattr(config, "cand_slot_mode", "level") == "depth":
k = jnp.full((bs,), max(min(num_passes, num_slots), 1), dtype=jnp.int32)
else:
k = jnp.clip(levels.reshape(-1) - 2, 1, num_slots)
active_full = jnp.arange(num_slots)[None, :] < k[:, None]
latent_vals = jnp.zeros((bs, num_slots, config.emb_dim), dtype=config.dtype)
keys = jax.random.split(rng, num_passes)
last_sigma = jnp.zeros((), jnp.float32)
for j in range(num_passes):
act_j = active_full & (jnp.arange(num_slots)[None, :] < j)
drop_k, noise_k = jax.random.split(keys[j])
_, hidden, _ = apply_fn(
inputs, latent_vals, slot_pos, act_j, {"dropout": drop_k})
# Same read position as the deterministic recurrence and the gate:
# j=0 is the last clue token, j>0 is latent slot j-1's hidden.
z = hidden[bidx, si3 - 1 + j]
if trunc_passes > 0 and j < num_passes - trunc_passes:
z = jax.lax.stop_gradient(z)
mu, log_sigma = sample_slot(z, j)
log_sigma = jnp.clip(log_sigma, -12.0, 2.0)
eps = jax.random.normal(noise_k, z.shape, jnp.float32)
z = (mu + jnp.exp(log_sigma) * eps).astype(config.dtype)
last_sigma = jnp.exp(log_sigma).mean()
latent_vals = latent_vals.at[:, j].set(z)
return latent_vals, slot_pos, active_full, last_sigma
def train_step(state, batch, start_index, levels, cand_targets, config,
hyperparams, learning_rate_fn, num_passes, dropout_rng=None,
backtrack=False, inst_slot=-1, apply_update=True):
"""One step of the training loop.
Args:
state: Train state.
batch: Input batch (bs, 3*81 + K) with latent placeholder slots.
start_index: Number of clue cells per example.
levels: Puzzle difficulty level (3..8) per example.
config: Model config.
hyperparams: Hyperparameter dictionary.
learning_rate_fn: Learning rate function.
num_passes: Number of latent recurrence passes (= curriculum stage).
dropout_rng: RNG used for dropout.
inst_slot: Candidate-mask slot matching the stage whose instances supply
this batch's value tokens (= curriculum stage - 1). Only the leak
penalty reads it. -1 derives it from num_passes.
Returns:
A new train state, train metrics, and computed model predictions.
"""
num_slots = int(config.num_latent_slots)
# Extract inputs and labels from the batch
inputs = batch[:, :-1]
label = batch[:, 1:]
# Update dropout_rng
dropout_rng = jax.random.fold_in(dropout_rng, state.step)
# Stochastic latents draw from their own stream. Only split when the arm is
# on, so the deterministic control keeps its exact dropout sequence.
n_mc = int(getattr(hyperparams, "latent_mc_samples", 0))
use_noise = bool(getattr(config, "latent_noise", False)) and n_mc > 0
noise_rng = None
if use_noise:
dropout_rng, noise_rng = jax.random.split(dropout_rng)
mc_objective = str(getattr(hyperparams, "latent_mc_objective", "mean"))
dropout_rng_dict = {"dropout": dropout_rng}
def loss_fn(params):
"""Compute the loss function."""
net = model.TransformerLMHeadModel(config)
def apply_fn(x, lv, lp, la, rngs):
return net.apply({"params": params}, x, latent_values=lv,
latent_positions=lp, latent_active=la,
rngs=rngs)
# M independent samples of (noise h1, pass, noise h2, ..., readout).
# Mean CE.
sigma_mean = jnp.zeros((), jnp.float32)
if use_noise:
def _sample_slot(z, slot):
return net.apply(
{"params": params}, z, slot,
method=model.TransformerLMHeadModel.latent_noise_dist_slot)
def _one_draw(key):
build_key, read_key = jax.random.split(key)
def apply_draw(x, lv, lp, la, rngs):
return apply_fn(x, lv, lp, la, rngs)
latent_vals, slot_pos, active_full, sig = (
build_latent_state_noisy(
inputs, start_index, levels, config, num_passes,
apply_draw, _sample_slot, build_key,
trunc_passes=int(getattr(hyperparams, "bptt_trunc", 0))))
lg, _, cl = apply_draw(
inputs, latent_vals, slot_pos, active_full,
{"dropout": read_key})
return lg, cl, sig
pred_logits_mc, cand_logits_mc, sig_mc = jax.vmap(_one_draw)(
jax.random.split(noise_rng, n_mc))
pred_logits = pred_logits_mc.mean(axis=0)
cand_logits = cand_logits_mc.mean(axis=0)
sigma_mean = sig_mc.mean()
else:
latent_vals, slot_pos, active_full = build_latent_state(
inputs, start_index, levels, config, num_passes, apply_fn,
rngs=dropout_rng_dict,
trunc_passes=int(getattr(hyperparams, "bptt_trunc", 0)))
pred_logits, _, cand_logits = apply_fn(
inputs, latent_vals, slot_pos, active_full, dropout_rng_dict)
pred_logits_mc = pred_logits[None]
label_one_hot = jax.nn.one_hot(label, num_classes=config.vocab_size)
# The variables label_one_hot and pred_logits both are 3-dimensional tensors with
# first axis corresponding to batch size, second correspondingn to sequence length
# and third corresponding to the row/column/value at a particular cell
assert label_one_hot.shape == pred_logits.shape, ("one hot label shape",
label_one_hot.shape,
label.shape,
pred_logits.shape)
# Calculate the cross-entropy loss along the last axis, per MC draw.
# ce_loss is (M, bs, L); M is 1 on the deterministic path, so the
# arithmetic below reduces to the original single-forward loss exactly.
pred_logits_sol = pred_logits[:, :, :]
label_one_hot_sol = label_one_hot[:, :, :]
ce_loss = optax.softmax_cross_entropy(
logits=pred_logits_mc, labels=label_one_hot_sol[None]
)
# Apply masking to the loss: supervise only the solution region,
# which now starts K latent slots after the clue block.
pos = jnp.arange(label.shape[1])[None, :] # (1, L)
base = 3 * jnp.asarray(start_index).reshape(-1, 1) + num_slots
mask = pos >= base
# Split the supervised tokens by role. The solution region is a stream of
# (row, col, value) triples, so the value token of empty cell t sits at
# base+3t+2; ce_loss[i] scores the token at i+1, hence the shift below.
# Two of every three supervised tokens are locations, and they demand the
# solver's particular linearization of a wave whose cells are actually
# order-interchangeable -- pressure the promotion gate (a teacher-forced,
# value-only CE(q||p)) never measures. Down-weighting them spends the
# capacity on the value distribution instead.
is_value = ((pos + 1 - base) % 3) == 2
loc_weight = float(getattr(hyperparams, "loc_loss_weight", 1.0))
value_weight = float(getattr(hyperparams, "value_loss_weight", 1.0))
token_w = jnp.where(is_value, value_weight, loc_weight) * mask
# Per-token mean (not per-example sum): keeps the LM CE on the same
# O(1) scale as the per-digit BCE below, so aux_cand_weight~1 actually
# balances the two instead of the candidate signal being swamped.
# The denominator stays the *unweighted* supervised count so that
# re-weighting moves the location gradient without silently rescaling the
# value gradient too, and weights of 1.0 reproduce the plain mean exactly.
ce_denom = jnp.maximum(mask.sum(), 1.0)
# Per-draw scalar first, then reduce over the draws. "mean" is the
# Monte-Carlo estimate of E_z[CE]; "min" charges only the best draw,
# which is what lets separate draws specialize instead of all being
# pulled to the same target.
per_draw_ce = (ce_loss * token_w[None]).sum(axis=(1, 2)) / ce_denom
if use_noise and mc_objective == "min":
avg_ce_loss = per_draw_ce.min()
else:
avg_ce_loss = per_draw_ce.mean()
# ---- Leak penalty: probability mass placed outside the candidate set --
# The gate is CE(q||p) <= log|S|, and CE(q||p) = -log(in-set mass)
# + H(q) + KL(q||p_renormalized). Measured on a stage-3 checkpoint the
# leak term is what grows with curriculum depth (0.03 nats at stage 3,
# 0.07 at stage 4, 0.13 at stage 5) while the within-set shape error
# stays flat, so it accounts for the whole deepening deficit. Nothing
# else in this objective penalizes it: the LM CE only ever rewards the
# one sampled digit, and the candidate head that used to supply the set
# boundary is off (aux_cand_weight=0) on the instance arm. This term
# minimizes exactly the measured quantity, -log(sum of p over S).
leak_weight = float(getattr(hyperparams, "leak_weight", 0.0))
if leak_weight > 0.0:
slot = inst_slot if inst_slot >= 0 else num_passes - 1
slot = int(min(max(slot, 0), num_slots - 1))
# At a value position i the row/col tokens sit at i-1 and i, so the
# cell this position is answering is inputs[i-1]*9 + inputs[i].
prev_tok = jnp.concatenate(
[jnp.zeros((inputs.shape[0], 1), inputs.dtype),
inputs[:, :-1]], axis=1)
cell = jnp.clip(prev_tok.astype(jnp.int32) * 9
+ inputs.astype(jnp.int32), 0, 80)
bits_at = jnp.take_along_axis(
cand_targets[:, slot, :].astype(jnp.int32), cell, axis=1)
dv = jnp.arange(config.vocab_size)
# Token ids are the digits themselves, so digit d lives at index d.
in_set = (
((bits_at[:, :, None] >> jnp.clip(dv - 1, 0, 8)[None, None, :])
& 1) > 0) & ((dv >= 1) & (dv <= 9))[None, None, :]
# Shifted softmax in fp32: p <= 1 and Z >= 1, so both logs are safe
# without a logsumexp import.
lg = pred_logits_sol.astype(jnp.float32)
pe = jnp.exp(lg - lg.max(axis=-1, keepdims=True))
z_all = pe.sum(axis=-1)
z_in = (pe * in_set).sum(axis=-1)
leak = jnp.log(z_all) - jnp.log(jnp.maximum(z_in, 1e-30))
# Only real empty cells: clue cells are sentinel-zeroed in the masks.
leak_m = mask & is_value & (bits_at > 0)
leak_loss = ((leak * leak_m).sum()
/ jnp.maximum(leak_m.sum(), 1.0))
else:
leak_loss = jnp.zeros((), dtype=jnp.float32)
# ---- Auxiliary multi-candidate BCE loss on the latent slots ----
# cand_targets: (bs, K, 81) int bitmask (bit d-1 set <=> digit d is a
# candidate at that slot's stage; slot j already aligned to the puzzle's
# k=level-2 budget in the data pipeline). Expand to (bs,K,81,9) multi-hot.
aux_weight = float(getattr(hyperparams, "aux_cand_weight", 1.0))
if aux_weight == 0.0:
# Instance arm: the superposition lives in the varying targets, not
# in a set head. Drop the whole BCE graph so nothing but the LM CE
# shapes the latents.
zero = jnp.zeros((), dtype=pred_logits.dtype)
total = avg_ce_loss + leak_weight * leak_loss
return total, (pred_logits, avg_ce_loss, zero, leak_loss,
sigma_mean)
bits = jnp.arange(9)
cand_multi_hot = ((cand_targets[..., None].astype(jnp.int32)
>> bits) & 1).astype(cand_logits.dtype) # (bs,K,81,9)
# Positive-weighted BCE. Candidate masks are sparse (~1-3 of 9 digits
# "on"), so plain BCE collapses to predicting all-zeros. Up-weighting
# the positive (candidate-present) term by pos_weight counteracts the
# imbalance and forces the head to actually predict the candidate set.
pos_weight = float(getattr(hyperparams, "aux_pos_weight", 5.0))
log_p = jax.nn.log_sigmoid(cand_logits) # log sigmoid(x)
log_1mp = jax.nn.log_sigmoid(-cand_logits) # log(1 - sigmoid(x))
bce = -(pos_weight * cand_multi_hot * log_p
+ (1.0 - cand_multi_hot) * log_1mp) # (bs,K,81,9)
# Only the empty cells are supervised: clue cells were sentinel-zeroed
# in the data pipeline, so any cell whose target row is all-zero is a
# clue and must not contribute. This makes the effective target
# (#empty cells) x 9 per puzzle rather than 81 x 9.
cell_mask = (cand_targets > 0).astype(cand_logits.dtype) # (bs, K, 81)
# Delta weighting: consecutive stages are near-duplicates (at K=12 only
# ~14% of cells change per stage, so ~97% of the target bits are copies
# of the previous slot). Weighting the unchanged cells below 1 stops the
# objective from being satisfied by echoing slot j-1. Slot 0 has no
# predecessor, so it is fully weighted.
delta_bg = float(getattr(hyperparams, "aux_delta_bg", 1.0))
if delta_bg != 1.0:
changed = jnp.concatenate(
[jnp.ones_like(cand_targets[:, :1], dtype=bool),
cand_targets[:, 1:] != cand_targets[:, :-1]], axis=1)
cell_w = cell_mask * (delta_bg + (1.0 - delta_bg)
* changed.astype(cand_logits.dtype))
else:
cell_w = cell_mask
cell_denom = jnp.maximum(cell_w.sum(axis=2), 1e-6) # (bs, K)
bce_per_cell = bce.mean(axis=3) # (bs, K, 81), mean over 9 digits
bce_per_slot = ((bce_per_cell * cell_w).sum(axis=2)
/ cell_denom) # (bs, K), mean over empty cells
if backtrack:
# Strict stage-replay: supervise ONLY the stage-t readout = the last
# active latent slot per example (index k-1), whose candidate target
# is that stage's grid. Earlier slots and the LM CE are dropped, so
# this step purely re-derives "apply f exactly t times -> stage-t
# readout." Injection above still spans all active slots, so the
# recurrence reaching slot k-1 is intact.
k_per = active_full.sum(axis=1) # (bs,)
last_idx = (k_per - 1)[:, None] # (bs, 1)
slot_sel = ((jnp.arange(num_slots)[None, :] == last_idx)
& active_full) # (bs, K)
slot_active = slot_sel.astype(bce_per_slot.dtype)
else:
slot_active = active_full.astype(bce_per_slot.dtype) # (bs, K)
aux_denom = jnp.maximum(slot_active.sum(), 1.0)
avg_aux_loss = (bce_per_slot * slot_active).sum() / aux_denom
# Backtrack (replay) steps train only from the readout (no LM CE).
ce_term = 0.0 if backtrack else avg_ce_loss
leak_term = 0.0 if backtrack else leak_weight * leak_loss
total_loss = ce_term + aux_weight * avg_aux_loss + leak_term
return total_loss, (pred_logits, avg_ce_loss, avg_aux_loss, leak_loss,
sigma_mean)
# Compute the learning rate and perform gradient descent
step = state.step
lr = learning_rate_fn(step)
(loss, aux), grads = jax.value_and_grad(loss_fn,
has_aux=True)(state.params)
pred_logits, ce_loss, aux_loss, leak_loss, sigma_mean = aux
grads = jax.lax.pmean(grads, "batch")
metrics = {
"step": step, "loss": loss, "learning_rate": lr,
"ce_loss": ce_loss,
"aux_loss": aux_loss,
"leak_loss": leak_loss,
# Mean of the learned per-slot sigma. This is the number that tells us
# whether the noise survives training or is driven to zero.
"latent_sigma": sigma_mean,
"pred_logits": pred_logits, "weights": inputs.shape[0]
}
if apply_update:
new_state = state.apply_gradients(grads=grads)
return new_state, metrics, pred_logits
return grads, metrics, pred_logits
|