Both value functions cost about eight seconds a run, and one has ten times fewer parameters than the other — so it is not the training math, and the whole per-call cost is somewhere else. The key file writes at roughly 3 MB/s on the rental, which makes the checkpoint write the first suspect, but that is a guess until the three phases are timed apart. Also times loading the x0 snapshot, which is a full packed latent read from the same disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
15 KiB
Python
312 lines
15 KiB
Python
"""Online value function for reward-guided sampling.
|
|
|
|
Trains a small ENSEMBLE of MLPs incrementally on user-rated generations (pairwise ranking,
|
|
not absolute-reward regression — see train_on). At inference, provides an ensemble-pessimistic
|
|
∂reward/∂conditioning as a per-step steering gradient: gradient-ascent-style steering is prone
|
|
to walking into regions a single undertrained value function scores confidently but wrongly
|
|
(confirmed live in this project — an earlier version collapsed to a degenerate "static
|
|
character" attractor). Independently bootstrap-trained ensemble members disagree exactly in
|
|
those blind-spot regions; that disagreement damps the steering step toward zero instead of
|
|
trusting it. See [[project_ltxav_manipulation]] (the original collapse) and
|
|
[[research_2026_training_free_candidates]] (why plain gradient ascent on a small reward model
|
|
is the class of method this replaces, not a novel invention).
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
|
|
class OnlineValueFunction(nn.Module):
|
|
MIN_SAMPLES = 10
|
|
BUFFER_SIZE = 100
|
|
BATCH_SIZE = 16
|
|
TRAIN_STEPS = 20
|
|
ENSEMBLE_SIZE = 3 # tiny nets — 3x forward/backward is still near-zero vs a diffusion step
|
|
|
|
def __init__(self, hidden_dim):
|
|
super().__init__()
|
|
self.hidden_dim = hidden_dim
|
|
mid = min(256, hidden_dim // 4)
|
|
|
|
def _make_net():
|
|
return nn.Sequential(
|
|
nn.Linear(hidden_dim, mid),
|
|
nn.SiLU(),
|
|
nn.Linear(mid, 64),
|
|
nn.SiLU(),
|
|
nn.Linear(64, 1),
|
|
)
|
|
|
|
self.nets = nn.ModuleList([_make_net() for _ in range(self.ENSEMBLE_SIZE)])
|
|
self._optimizer = None
|
|
self.buffer_c = [] # compressed conditioning tensors [hidden_dim]
|
|
self.buffer_r = [] # reward floats
|
|
self.n_trained = 0
|
|
|
|
@property
|
|
def optimizer(self):
|
|
if self._optimizer is None:
|
|
self._optimizer = torch.optim.Adam(self.parameters(), lr=2e-3)
|
|
return self._optimizer
|
|
|
|
@staticmethod
|
|
def compress(conditioning):
|
|
"""[1, seq, D] or [seq, D] → [D] mean vector."""
|
|
c = conditioning.float()
|
|
if c.dim() == 3:
|
|
c = c.squeeze(0)
|
|
return c.mean(dim=0)
|
|
|
|
def forward(self, c):
|
|
"""Ensemble MEAN prediction. Used by search() and anywhere only a score (not a
|
|
gradient) is needed — those call sites are unaffected by the ensemble change."""
|
|
return torch.stack([net(c) for net in self.nets], dim=0).mean(dim=0)
|
|
|
|
def _ensemble_grad(self, c):
|
|
"""Per-member reward + gradient w.r.t. c (must already require grad), reduced to
|
|
(mean_reward, mean_grad, agreement). agreement in [0,1] is the mean pairwise cosine
|
|
similarity between members' gradient DIRECTIONS — high when the ensemble agrees which
|
|
way improves the input, low when they don't (the blind-spot signal)."""
|
|
compressed = self.compress(c)
|
|
rewards, grads = [], []
|
|
for net in self.nets:
|
|
r = net(compressed.unsqueeze(0))
|
|
g, = torch.autograd.grad(r, c, retain_graph=True)
|
|
rewards.append(r.detach())
|
|
grads.append(g)
|
|
mean_reward = torch.stack(rewards).mean()
|
|
mean_grad = torch.stack(grads).mean(dim=0)
|
|
n = len(grads)
|
|
if n > 1:
|
|
unit = torch.stack([F.normalize(g.flatten().float(), dim=0) for g in grads])
|
|
sim = unit @ unit.T
|
|
agreement = float(((sim.sum() - n) / (n * (n - 1))).clamp(0.0, 1.0).item())
|
|
else:
|
|
agreement = 1.0
|
|
return mean_reward, mean_grad, agreement
|
|
|
|
def train_on(self, conditioning, reward):
|
|
"""Add a new (conditioning, reward) sample, then train each ensemble member via
|
|
pairwise ranking on its OWN independently-sampled pairs from the buffer: Bradley-Terry
|
|
loss -log(sigmoid(V(winner) - V(loser))), same objective DPO derives its update from.
|
|
Only needs the ORDER between two rated generations to be right, not the absolute
|
|
reward magnitude — more robust than MSE regression at 10-100 samples, and
|
|
correct-by-construction for hand-tuned reward gaps that don't perfectly agree with
|
|
intent (e.g. two "great" ratings a tier apart in reward but not in quality — ranking
|
|
loss only needs both to beat a worse rating, never needs their gap to mean anything).
|
|
Reward numbers/labels are untouched by this — see _v2_reward_admissible in
|
|
conditioning.py for what's allowed to reach this buffer.
|
|
|
|
Independent per-member pair sampling (bootstrap-style), on top of independent random
|
|
init, is what gives the ensemble a meaningful disagreement signal later — members that
|
|
saw identical data every step would converge to near-identical blind spots too."""
|
|
with torch.inference_mode(False), torch.enable_grad():
|
|
c = self.compress(conditioning.clone()).detach().cpu()
|
|
self.buffer_c.append(c)
|
|
self.buffer_r.append(float(reward))
|
|
if len(self.buffer_c) > self.BUFFER_SIZE:
|
|
self.buffer_c = self.buffer_c[-self.BUFFER_SIZE:]
|
|
self.buffer_r = self.buffer_r[-self.BUFFER_SIZE:]
|
|
if len(self.buffer_c) < 2:
|
|
return
|
|
device = next(self.parameters()).device
|
|
n = len(self.buffer_c)
|
|
for _ in range(self.TRAIN_STEPS):
|
|
self.optimizer.zero_grad()
|
|
total_loss = None
|
|
for net in self.nets:
|
|
idx_win, idx_lose = [], []
|
|
for _ in range(self.BATCH_SIZE):
|
|
i, j = random.sample(range(n), 2)
|
|
if self.buffer_r[i] == self.buffer_r[j]:
|
|
continue # tie — no ranking signal, skip
|
|
if self.buffer_r[i] < self.buffer_r[j]:
|
|
i, j = j, i # i = winner (higher reward), j = loser
|
|
idx_win.append(i)
|
|
idx_lose.append(j)
|
|
if not idx_win:
|
|
continue # this member's draw was all ties; try again next step
|
|
c_win = torch.stack([self.buffer_c[i] for i in idx_win]).to(device)
|
|
c_lose = torch.stack([self.buffer_c[i] for i in idx_lose]).to(device)
|
|
margin = net(c_win) - net(c_lose)
|
|
member_loss = -F.logsigmoid(margin).mean()
|
|
total_loss = member_loss if total_loss is None else total_loss + member_loss
|
|
if total_loss is None:
|
|
continue
|
|
total_loss.backward()
|
|
self.optimizer.step()
|
|
self.n_trained += 1
|
|
|
|
def gradient(self, conditioning):
|
|
"""∂reward/∂conditioning — same shape as input, scaled by ensemble agreement
|
|
(near 0 when members disagree on direction, near 1 when they concur)."""
|
|
mlp_device = next(self.parameters()).device
|
|
orig_device = conditioning.device
|
|
with torch.inference_mode(False), torch.enable_grad():
|
|
# new_empty inside inference_mode(False) creates a normal (non-inference) tensor.
|
|
# copy_ fills it from the inference tensor without inheriting the flag.
|
|
c_fresh = torch.empty(conditioning.shape, dtype=torch.float32, device=mlp_device)
|
|
c_fresh.copy_(conditioning)
|
|
c_fresh.requires_grad_(True)
|
|
_, mean_grad, agreement = self._ensemble_grad(c_fresh)
|
|
return (mean_grad * agreement).to(orig_device, conditioning.dtype)
|
|
|
|
def ascend(self, conditioning):
|
|
"""Gradient ascent on conditioning until reward plateaus. Self-terminating, no user
|
|
params. Each step is scaled by ensemble agreement — low agreement (an OOD/blind-spot
|
|
region none of the bootstrap-trained members actually learned) shrinks the step toward
|
|
zero, which combined with the plateau-based early exit means ascent naturally stalls
|
|
instead of confidently walking into a spurious high-score region — the failure mode
|
|
that produced the documented "static character" collapse under a single value function."""
|
|
if not self.is_ready():
|
|
return conditioning
|
|
mlp_device = next(self.parameters()).device
|
|
orig_device = conditioning.device
|
|
orig_dtype = conditioning.dtype
|
|
step_size = conditioning.float().norm().item() * 0.005
|
|
# Cap total displacement at 8% of original norm — prevents stripping action/content
|
|
max_displacement = conditioning.float().norm().item() * 0.08
|
|
prev_reward = -float("inf")
|
|
with torch.inference_mode(False), torch.enable_grad():
|
|
c_orig = torch.empty(conditioning.shape, dtype=torch.float32, device=mlp_device)
|
|
c_orig.copy_(conditioning)
|
|
c = c_orig.clone()
|
|
for _ in range(50):
|
|
c = c.detach().requires_grad_(True)
|
|
reward, grad, agreement = self._ensemble_grad(c)
|
|
reward_val = reward.item()
|
|
step = F.normalize(grad.float(), dim=-1) * agreement
|
|
c = c.detach() + step_size * step
|
|
# Project back if displacement exceeds cap
|
|
delta = c - c_orig
|
|
disp = delta.norm()
|
|
if disp > max_displacement:
|
|
c = c_orig + delta * (max_displacement / disp)
|
|
if reward_val - prev_reward < 1e-3:
|
|
break
|
|
prev_reward = reward_val
|
|
return c.detach().to(orig_device, orig_dtype)
|
|
|
|
def search(self, conditioning, n=16, eps_scale=1.0):
|
|
"""Monte Carlo conditioning search: score N random perturbations, return the best.
|
|
|
|
eps_scale widens (>1) or tightens (<1) the perturbation radius. The path planner uses
|
|
a wider radius to push a disliked config region toward a different conditioning, and the
|
|
caller skips this search entirely to LOCK a liked region's conditioning in place."""
|
|
if not self.is_ready():
|
|
return conditioning
|
|
mlp_device = next(self.parameters()).device
|
|
orig_device = conditioning.device
|
|
orig_dtype = conditioning.dtype
|
|
eps = conditioning.float().norm().item() * 0.025 * float(eps_scale)
|
|
with torch.inference_mode(False), torch.no_grad():
|
|
c_base = torch.empty(conditioning.shape, dtype=torch.float32, device=mlp_device)
|
|
c_base.copy_(conditioning)
|
|
best = c_base
|
|
best_score = float(self.forward(self.compress(c_base).unsqueeze(0)).item())
|
|
for _ in range(n):
|
|
noise = F.normalize(torch.randn_like(c_base).flatten(), dim=0).reshape(c_base.shape)
|
|
candidate = c_base + eps * noise
|
|
score = float(self.forward(self.compress(candidate).unsqueeze(0)).item())
|
|
if score > best_score:
|
|
best_score = score
|
|
best = candidate
|
|
return best.detach().to(orig_device, orig_dtype)
|
|
|
|
def is_ready(self):
|
|
return len(self.buffer_c) >= self.MIN_SAMPLES
|
|
|
|
def parameter_count(self):
|
|
return sum(p.numel() for p in self.parameters())
|
|
|
|
def save(self, path):
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
buf = torch.stack(self.buffer_c) if self.buffer_c else torch.zeros(0, self.hidden_dim)
|
|
torch.save({
|
|
"hidden_dim": self.hidden_dim,
|
|
"state_dict": self.state_dict(),
|
|
"buffer_c": buf,
|
|
"buffer_r": list(self.buffer_r),
|
|
"n_trained": self.n_trained,
|
|
}, path)
|
|
|
|
@classmethod
|
|
def load(cls, path):
|
|
data = torch.load(path, map_location="cpu", weights_only=False)
|
|
vf = cls(hidden_dim=data["hidden_dim"])
|
|
try:
|
|
vf.load_state_dict(data["state_dict"])
|
|
except Exception:
|
|
# Pre-ensemble checkpoint (single "net.*" instead of "nets.<i>.*"). Warm-start
|
|
# every ensemble member from the one saved net rather than discarding trained
|
|
# weights outright — they'll diverge from there as independent bootstrap training
|
|
# proceeds. If even this remapping fails, members stay at fresh random init; either
|
|
# way the buffer below (the actually irreplaceable part — real accumulated ratings)
|
|
# is restored unconditionally, regardless of whether the weights loaded.
|
|
legacy_sd = data["state_dict"]
|
|
if all(k.startswith("net.") for k in legacy_sd):
|
|
remapped = {k[len("net."):]: v for k, v in legacy_sd.items()}
|
|
for net in vf.nets:
|
|
try:
|
|
net.load_state_dict(remapped)
|
|
except Exception:
|
|
pass
|
|
buf = data["buffer_c"]
|
|
vf.buffer_c = [buf[i] for i in range(len(buf))]
|
|
vf.buffer_r = list(data["buffer_r"])
|
|
vf.n_trained = data.get("n_trained", 0)
|
|
return vf
|
|
|
|
@classmethod
|
|
def load_or_create(cls, path, hidden_dim=None):
|
|
if os.path.exists(path):
|
|
try:
|
|
return cls.load(path)
|
|
except Exception:
|
|
pass
|
|
return cls(hidden_dim=hidden_dim) if hidden_dim is not None else None
|
|
|
|
|
|
def compress_packed_latent(x, hidden_dim):
|
|
"""Pool a packed AV latent [B,1,N] (LTXAV's flattened video/audio stream layout — see
|
|
samplers._get_latent_shapes) down to a fixed-size [hidden_dim] vector, differentiably.
|
|
|
|
N varies per generation (scene length/resolution), unlike text conditioning's fixed
|
|
encoder width, so adaptive pooling (not a plain mean) is what makes a single value
|
|
function usable across differently-shaped runs. Idempotent on already-compressed input
|
|
(shape [hidden_dim]) so the same call works whether fed a live raw latent (inference,
|
|
where autograd needs to flow through this op to produce a full-shape gradient) or a
|
|
pre-compressed snapshot loaded back from disk (training, where the raw latent was never
|
|
persisted — only this vector was, to keep the sidecar file small)."""
|
|
if x.dim() == 1 and x.shape[0] == hidden_dim:
|
|
return x
|
|
c = x.float()
|
|
if c.dim() == 1:
|
|
c = c.view(1, 1, -1)
|
|
elif c.dim() == 2:
|
|
c = c.unsqueeze(1)
|
|
elif c.dim() > 3:
|
|
c = c.reshape(c.shape[0], 1, -1)
|
|
pooled = F.adaptive_avg_pool1d(c, hidden_dim) # [B,1,hidden_dim]
|
|
return pooled.mean(dim=0).reshape(-1) # average over batch (usually B=1, a no-op) -> [hidden_dim]
|
|
|
|
|
|
class LatentValueFunction(OnlineValueFunction):
|
|
"""OnlineValueFunction trained on the SAMPLER'S OWN predicted output (a packed-latent
|
|
x0_hat estimate) instead of the input text conditioning — same tiny-MLP mechanism, just
|
|
fed a different, more direct signal. See [[research_2026_training_free_candidates]] /
|
|
NoiseTilt for the idea this borrows (reward gradient computed from the model's own
|
|
prediction) and [[project_embed_guidance]] for the conditioning-space sibling this
|
|
complements. Cost stays the same as embed_guidance: one backward pass through a few
|
|
hundred params, no extra diffusion-model forward pass."""
|
|
|
|
DEFAULT_HIDDEN_DIM = 512
|
|
|
|
def compress(self, x):
|
|
return compress_packed_latent(x, self.hidden_dim)
|
|
|
|
|