Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
5.3 KiB
Python
127 lines
5.3 KiB
Python
"""A tiny learned judge of rated clips, for features that steer by its gradient.
|
|
|
|
Three small MLPs, each trained on its own random pairs of rated descriptors to
|
|
rank the liked one above the disliked one (Bradley-Terry: only the ORDER must
|
|
be right, which is what a handful of ratings can support). Their gradients are
|
|
averaged and scaled by how much they agree on the direction: where they
|
|
disagree the judge is guessing, and the push shrinks toward zero. A single
|
|
judge in v4's first version walked confidently into a "static character"
|
|
collapse; the disagreement damping is the fix for that, kept here.
|
|
|
|
Rebuilt from the rated rows whenever they change, never updated in place: the
|
|
rows are the record, a judge is a pure function of them.
|
|
"""
|
|
|
|
import random
|
|
|
|
import torch
|
|
import torch.nn as nn
|
|
import torch.nn.functional as F
|
|
|
|
DIM = 512
|
|
MIN_SAMPLES = 10
|
|
ENSEMBLE = 3
|
|
BATCH = 16
|
|
# ponytail: one fixed training budget over the whole buffer instead of v4's
|
|
# replay (20 steps per rating as it arrived); same objective, bounded cost.
|
|
STEPS = 400
|
|
BUFFER = 100
|
|
|
|
|
|
def describe(video):
|
|
"""Any-shaped picture latent -> [DIM], differentiably (adaptive pooling, so
|
|
clips of any length or size share one judge)."""
|
|
c = video.float().reshape(1, 1, -1)
|
|
return F.adaptive_avg_pool1d(c, DIM).reshape(-1)
|
|
|
|
|
|
class Judge(nn.Module):
|
|
def __init__(self, seed=0):
|
|
super().__init__()
|
|
gen = torch.Generator().manual_seed(seed)
|
|
|
|
def net():
|
|
layers = nn.Sequential(nn.Linear(DIM, 128), nn.SiLU(), nn.Linear(128, 64),
|
|
nn.SiLU(), nn.Linear(64, 1))
|
|
for p in layers.parameters():
|
|
with torch.no_grad():
|
|
p.copy_(torch.randn(p.shape, generator=gen) * (1.0 / max(1, p.shape[-1])) ** 0.5)
|
|
return layers
|
|
|
|
self.nets = nn.ModuleList(net() for _ in range(ENSEMBLE))
|
|
|
|
@classmethod
|
|
def train_on(cls, descriptors, rewards, seed=0):
|
|
"""-> a trained Judge, or None under MIN_SAMPLES or with no two ratings
|
|
that differ (nothing to rank)."""
|
|
pairs = list(zip(descriptors, rewards))[-BUFFER:]
|
|
if len(pairs) < MIN_SAMPLES or len({r for _d, r in pairs}) < 2:
|
|
return None
|
|
pick = random.Random(seed)
|
|
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
rs = [float(r) for _d, r in pairs]
|
|
n = len(rs)
|
|
# ComfyUI runs the whole prompt under inference_mode: weights or inputs made there cannot be trained.
|
|
with torch.inference_mode(False), torch.enable_grad():
|
|
judge = cls(seed).to(device)
|
|
xs = torch.stack([d.float().reshape(-1).clone() for d, _r in pairs]).to(device)
|
|
opt = torch.optim.Adam(judge.parameters(), lr=2e-3)
|
|
for _ in range(STEPS):
|
|
opt.zero_grad()
|
|
loss = None
|
|
for net in judge.nets:
|
|
win, lose = [], []
|
|
for _ in range(BATCH):
|
|
i, j = pick.sample(range(n), 2)
|
|
if rs[i] == rs[j]:
|
|
continue
|
|
if rs[i] < rs[j]:
|
|
i, j = j, i
|
|
win.append(i)
|
|
lose.append(j)
|
|
if win:
|
|
term = -F.logsigmoid(net(xs[win]) - net(xs[lose])).mean()
|
|
loss = term if loss is None else loss + term
|
|
if loss is not None:
|
|
loss.backward()
|
|
opt.step()
|
|
judge.cpu()
|
|
judge.requires_grad_(False)
|
|
return judge
|
|
|
|
def gradient(self, video):
|
|
"""d(score)/d(video), same shape, scaled by the members' agreement."""
|
|
device = video.device
|
|
with torch.inference_mode(False), torch.enable_grad():
|
|
if next(self.parameters()).device != device:
|
|
self.to(device) # moved here, not under inference_mode: its copies must stay differentiable
|
|
x = torch.empty(video.shape, dtype=torch.float32, device=device)
|
|
x.copy_(video)
|
|
x.requires_grad_(True)
|
|
d = describe(x)
|
|
grads = [torch.autograd.grad(net(d.unsqueeze(0)).sum(), x, retain_graph=True)[0]
|
|
for net in self.nets]
|
|
unit = torch.stack([F.normalize(g.flatten(), dim=0) for g in grads])
|
|
n = len(grads)
|
|
agreement = ((unit @ unit.T).sum() - n) / (n * (n - 1)) if n > 1 else torch.ones(())
|
|
return (torch.stack(grads).mean(0) * agreement.clamp(0.0, 1.0)).to(video.dtype)
|
|
|
|
def nudge(self, video, amount):
|
|
"""video + this judge's gradient, sized to `amount` x the video's own
|
|
norm: the raw gradient reaches each value through a pooling window, so
|
|
used as-is it would be far too small to matter."""
|
|
grad = self.gradient(video)
|
|
gn = grad.float().norm()
|
|
if not bool(gn > 0):
|
|
return video
|
|
return video + grad * (amount * video.float().norm() / gn).to(video.dtype)
|
|
|
|
def score(self, video):
|
|
"""The members' mean score for a picture: higher = more like what was liked."""
|
|
device = video.device
|
|
if next(self.parameters()).device != device:
|
|
self.to(device)
|
|
with torch.no_grad():
|
|
d = describe(video.float()).unsqueeze(0)
|
|
return float(sum(net(d) for net in self.nets)) / len(self.nets)
|