Files
SolitaryThinker 21e59268d8 [misc] v2: full pre-commit clean (ruff UP038/SIM/UP031 + mypy annotations)
Sweep all of v2/ through pre-commit (was previously only run on changed
files). Fixes surfaced across untouched modules:

- ruff: isinstance-tuple -> X | Y (UP038), try/except/pass ->
  contextlib.suppress (SIM105), negated-return (SIM103), %-format ->
  f-string (UP031).
- mypy: add annotations for no-untyped-call + var-annotated across recipes,
  training methods, torch/toy backends, and serving.
- yapf reflow of the SF-Wan KV-cache call sites (semantics unchanged).

yapf/ruff/codespell/mypy all pass; v2 tests 237 passed / 5 pre-existing
(bucket_c torch-absent on CPU venv).
2026-06-19 22:16:28 +00:00

259 lines
13 KiB
Python

"""JointMultiExpertRL — N-way joint RL over an arbitrary set of experts.
Generalizes ``UnifiedRLMethod`` (the N=2 case: one refiner LM + one generator) to N refiner LMs + one
generator, with no substrate change: the card already holds N components/loops, WeightSyncPlan is
per-component (one plan per expert), and ``get_grad_clip_targets`` already returns a dict. Only this
method's body changes — a loop over the expert list. One reward → one group-relative advantage → N
policy-gradient updates (N token-PG refiners + one FlowGRPO PPO generator). The refiners' refinements
compose into the diffusion conditioning, so the single reward depends on all of them.
Credit assignment (a reward-shaping choice, not an architectural one):
* ``credit="shared"`` — one advantage applied to every expert (faithful to UniRL); each refiner sees
the others as noise (shared-reward multi-agent variance).
* ``credit="per_expert"`` — each refiner gets an advantage from its OWN reward term (clean
decomposition). The generator always uses the total reward's advantage.
"""
from __future__ import annotations
from typing import Any
import numpy as np
from v2._enums import ConsistencyLevel, ExecutionProfile
from v2.loop.sampler import flow_sde_ml_velocity as _ml_velocity
from v2.recipes.common import cached_text_encode
from v2.platform import FLOW_SDE_STEP
from v2.request import DiffusionParams, TaskType, make_request
from v2.training.rollout import rollout_loop
from v2.training.weight_sync import WeightRole, WeightSyncPlan
from v2.training.methods.base import TrainingMethod, new_instance
class JointMultiExpertRL(TrainingMethod):
name = "joint_multi_rl"
consistency = ConsistencyLevel.C2
student_loop_id = "diffusion_denoise"
def __init__(self,
student_instance,
reference_instance,
*,
refiner_ids,
generator_id="transformer",
target_actions,
joint_generator: bool = True,
credit: str = "shared",
num_samples_per_prompt: int = 4,
num_skip_refinement: int = 1,
rollout_steps: int = 4,
refiner_lr: float = 0.4,
dit_lr: float = 0.02,
beta: float = 0.01,
adv_clip_max: float = 5.0,
ppo_clip: float = 1e-2,
sde_noise_scale: float = 0.7,
action_bonus: float = 2.0,
**kw):
super().__init__(student_instance, lr=dit_lr, **kw)
self.reference = reference_instance
self.refiner_ids = list(refiner_ids)
self.generator_id = generator_id
self.targets = dict(target_actions)
self.joint_generator = joint_generator
self.credit = credit
self.K = num_samples_per_prompt
self.num_skip = max(0, min(num_skip_refinement, self.K - 1))
self.rollout_steps = rollout_steps
self.refiner_lr, self.dit_lr = refiner_lr, dit_lr
self.beta, self.adv_clip_max, self.ppo_clip = beta, adv_clip_max, ppo_clip
self.sde_noise_scale, self.action_bonus = sde_noise_scale, action_bonus
# one WeightSyncPlan PER trainable expert — independent versions + cache scopes
self.sync = {rid: WeightSyncPlan(role=WeightRole.STUDENT, components=(rid, )) for rid in self.refiner_ids}
if joint_generator:
self.sync[generator_id] = WeightSyncPlan(role=WeightRole.STUDENT, components=(generator_id, ))
for rid in self.refiner_ids: # frozen reference per refiner
self.reference.component(rid).copy_from(self.student.component(rid))
self.reference.component(generator_id).copy_from(self.student.component(generator_id))
def refiner(self, rid: Any) -> Any:
return self.student.component(rid)
@property
def dit(self):
return self.student.component(self.generator_id)
def manages_optimization(self) -> bool:
return True
def get_grad_clip_targets(self, iteration: int = 0) -> dict:
t = {rid: self.refiner(rid) for rid in self.refiner_ids}
if self.joint_generator:
t[self.generator_id] = self.dit
return t
def _compose(self, base_emb: Any, actions: Any) -> Any:
"""Stack every refiner's chosen offset into the diffusion conditioning."""
emb = base_emb
for rid in self.refiner_ids:
emb = self.refiner(rid).refined_embed(emb, actions[rid])
return emb
def _reward(self, latent: Any, actions: Any) -> tuple[float, dict[Any, float]]:
"""One reward; returns (total, per_expert). The diffusion signal is shared; each refiner adds
its own bonus when it picks its target (so per-expert reward isolates that refiner's credit)."""
diff = float(np.tanh(-np.std(np.asarray(latent, dtype="float64"))))
per, total = {}, diff
for rid in self.refiner_ids:
hit = self.action_bonus if (actions is not None and actions.get(rid) == self.targets.get(rid)) else 0.0
total += hit
per[rid] = diff + hit
return total, per
def _rollout_sample(self, prompt: Any, emb: Any, neg_emb: Any, seed: Any) -> Any:
req = make_request(TaskType.T2V,
self.student.card.model_id,
prompt,
diffusion=DiffusionParams(num_steps=self.rollout_steps,
seed=seed,
guidance_scale=1.0,
sde_rollout=self.joint_generator,
sde_noise_scale=self.sde_noise_scale))
slots = {
"text_embeds": np.asarray(emb, dtype="float32"),
"neg_text_embeds": np.asarray(neg_emb, dtype="float32")
}
return rollout_loop(self.student, self.student_loop_id, req, slots=slots, profile=ExecutionProfile.ROLLOUT)
def _dit_ppo(self, behavior: Any, emb: Any, advantage: Any) -> tuple[float, float, float, float]:
ref_dit = self.reference.component(self.generator_id)
# Pin the log-prob recompute to the SAME SDE kernel the rollout used (C2 kernel-pinning).
sde_step = self.student.platform.kernels.get(FLOW_SDE_STEP)
ppo_losses, kls, ratios, gnorms = [], [], [], []
for rec in behavior or []:
if "sde_logprob" not in rec:
continue
prev, sample = rec["prev"], rec["sample"]
st, sn = float(rec["sigma_t"]), float(rec["sigma_next"])
v_cur = self.dit(prev, emb, st)
_, logp_cur, mean_cur, eff_std = sde_step(prev,
v_cur,
st,
sn,
prev_sample=sample,
noise_scale=self.sde_noise_scale)
v_ref = ref_dit(prev, emb, st)
_, _, mean_ref, _ = sde_step(prev, v_ref, st, sn, prev_sample=sample, noise_scale=self.sde_noise_scale)
ratio = float(np.exp(np.clip(logp_cur - float(rec["sde_logprob"]), -10.0, 10.0)))
clipped = float(np.clip(ratio, 1.0 - self.ppo_clip, 1.0 + self.ppo_clip))
ppo_losses.append(max(-advantage * ratio, -advantage * clipped))
kls.append(float(np.mean((mean_cur - mean_ref)**2) / (2.0 * max(eff_std**2, 1e-8))))
ratios.append(ratio)
v_target = _ml_velocity(prev, sample, st, sn, noise_scale=self.sde_noise_scale) # PG direction
_, gn = self.dit.mse_grad_step(prev, emb, st, v_target, self.dit_lr * float(np.clip(advantage, -1.0, 1.0)))
gnorms.append(gn)
def m(xs: Any) -> float:
return float(np.mean(xs)) if xs else 0.0
return m(ppo_losses), m(kls), m(ratios), m(gnorms)
@staticmethod
def _advantage(rewards: Any, clip_max: Any) -> Any:
r = np.asarray(rewards, dtype="float64")
return np.clip((r - r.mean()) / (r.std() + 1e-4), -clip_max, clip_max)
def managed_train_step(self, batch: dict, iteration: int) -> tuple[dict, dict]:
self.iteration = iteration
prompts = batch["prompts"]
seeds = batch.get("seeds", list(range(len(prompts))))
neg_base = cached_text_encode(self.student, "")
refiner_pg: dict[Any, list[Any]] = {rid: [] for rid in self.refiner_ids}
refiner_gn: dict[Any, list[Any]] = {rid: [] for rid in self.refiner_ids}
dit_losses, kls, ratios, dit_gn, all_reward = [], [], [], [], []
for pi, prompt in enumerate(prompts):
base = cached_text_encode(self.student, prompt)
rng = np.random.default_rng((seeds[pi] + 1) * 7919 + iteration)
actions, logps = {}, {}
for rid in self.refiner_ids: # each refiner samples its action
actions[rid], logps[rid] = self.refiner(rid).sample_refinement(rng)
composed = self._compose(base, actions)
samples = []
for k in range(self.K):
refined = k >= self.num_skip
emb = composed if refined else base
acts = actions if refined else None
res = self._rollout_sample(prompt, emb, neg_base, seed=seeds[pi] * 1000 + k)
total, per = self._reward(np.asarray(res.outputs["latents"], dtype="float32"), acts)
samples.append((res, emb, refined, total, per))
totals = [s[3] for s in samples]
adv_total = self._advantage(totals, self.adv_clip_max)
all_reward.extend(totals)
# --- N refiner token-PG updates (one per expert) ------------------------ #
for rid in self.refiner_ids:
if self.credit == "per_expert":
adv = self._advantage([s[4][rid] for s in samples], self.adv_clip_max)
else:
adv = adv_total
refined_adv = [adv[k] for k in range(self.K) if samples[k][2]]
lm_adv = float(np.mean(refined_adv)) if refined_adv else 0.0
kl = self.refiner(rid).kl_to(self.reference.component(rid))
refiner_gn[rid].append(self.refiner(rid).pg_step(actions[rid], lm_adv, self.refiner_lr))
refiner_pg[rid].append(-lm_adv * logps[rid] + self.beta * kl)
# --- generator FlowGRPO PPO (joint), credited by the total reward ------- #
if self.joint_generator:
for k in range(self.K):
if not samples[k][2]:
continue
ppo, kl, ratio, gn = self._dit_ppo(samples[k][0].behavior, samples[k][1], float(adv_total[k]))
dit_losses.append(ppo + self.beta * kl)
kls.append(kl)
ratios.append(ratio)
dit_gn.append(gn)
# publish every expert independently (N+? distinct versions + cache scopes)
versions = {
rid: self.sync[rid].apply(self.refiner(rid), self.refiner(rid), self.student)
for rid in self.refiner_ids
}
if self.joint_generator:
versions[self.generator_id] = self.sync[self.generator_id].apply(self.dit, self.dit, self.student)
def m(xs: Any) -> float:
return float(np.mean(xs)) if xs else 0.0
metrics = {
"reward_mean": m(all_reward),
"n_experts": float(len(self.refiner_ids) + (1 if self.joint_generator else 0)),
"credit_mode": self.credit,
}
loss_map = {}
for rid in self.refiner_ids:
metrics[f"grad_norm/{rid}"] = m(refiner_gn[rid])
metrics[f"target_prob/{rid}"] = float(self.refiner(rid)._probs()[self.targets[rid]])
metrics[f"weights_version/{rid}"] = versions[rid]
loss_map[f"pg_loss/{rid}"] = m(refiner_pg[rid])
if self.joint_generator:
metrics[f"grad_norm/{self.generator_id}"] = m(dit_gn)
metrics["dit_pg_loss"] = m(dit_losses)
metrics["ppo_ratio_mean"] = m(ratios) if ratios else 1.0
metrics[f"weights_version/{self.generator_id}"] = versions[self.generator_id]
loss_map["dit_pg_loss"] = m(dit_losses)
return loss_map, metrics
def train_step(self, batch: dict, iteration: int) -> tuple[dict, dict]:
return self.managed_train_step(batch, iteration)
def build_joint_multi_rl(card, *, refiner_ids, target_actions, **kw) -> JointMultiExpertRL:
"""Two roles: a trainable student (N refiners + generator) and a frozen reference (all experts)."""
return JointMultiExpertRL(new_instance(card),
new_instance(card),
refiner_ids=refiner_ids,
target_actions=target_actions,
**kw)