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

235 lines
12 KiB
Python

"""WorkflowRLMethod — end-to-end RL over a cross-model workflow.
Train BOTH stages of the T2I→I2V workflow (``flux-t2i`` and ``wan-i2v`` — two separate models) from one
final-video reward. The rollout spans two instances: roll out the T2I diffusion loop with SDE capture →
decode an image → condition and roll out the I2V diffusion loop with SDE capture → decode a video → score
it. One group-relative advantage from the final video drives a FlowGRPO PPO update on each stage's
transformer, with two independent WeightSyncPlans on two different instances.
The interesting part is credit assignment: the reward is at the end (the video) yet credits an earlier
model (the T2I generator), so the T2I stage learns to produce images that lead to better videos. This
shows "rollout == serve + capture" holds for a multi-instance workflow with no new primitive — the method
drives the same shared loops the engine serves and threads the artifact between them as the serve-time
Workflow does.
"""
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, flow_sde_step_with_logprob
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
def _flowgrpo_ppo_step(dit: Any,
ref_dit: Any,
behavior: Any,
emb: Any,
advantage: float,
*,
lr: float,
ppo_clip: float,
beta: float,
noise_scale: float,
sde_step: Any = None) -> tuple[float, float, float, float]:
"""One FlowGRPO PPO pass over an SDE trajectory: per-step ratio + KL, then move the policy toward
the max-likelihood velocity of each realized sample (advantage-weighted). Returns
(ppo_loss, kl, ratio, grad_norm). Shared by both stages. ``sde_step`` is the platform-resolved SDE
kernel (pins the recompute to the rollout's kernel, C2); defaults to the numpy reference."""
sde_step = sde_step or flow_sde_step_with_logprob
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 = dit(prev, emb, st)
_, logp_cur, mean_cur, eff_std = sde_step(prev, v_cur, st, sn, prev_sample=sample, noise_scale=noise_scale)
v_ref = ref_dit(prev, emb, st)
_, _, mean_ref, _ = sde_step(prev, v_ref, st, sn, prev_sample=sample, noise_scale=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 - ppo_clip, 1.0 + 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 = flow_sde_ml_velocity(prev, sample, st, sn, noise_scale=noise_scale)
_, gn = dit.mse_grad_step(prev, emb, st, v_target, 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)
class WorkflowRLMethod(TrainingMethod):
name = "workflow_rl"
consistency = ConsistencyLevel.C2
def __init__(self,
t2i_student,
i2v_student,
t2i_reference,
i2v_reference,
*,
num_samples_per_prompt: int = 4,
rollout_steps: int = 4,
t2i_lr: float = 0.02,
i2v_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,
**kw):
super().__init__(t2i_student, lr=t2i_lr, **kw) # base "student" = the T2I stage
self.t2i, self.i2v = t2i_student, i2v_student
self.t2i_ref, self.i2v_ref = t2i_reference, i2v_reference
self.K = num_samples_per_prompt
self.rollout_steps = rollout_steps
self.t2i_lr, self.i2v_lr = t2i_lr, i2v_lr
self.beta, self.adv_clip_max, self.ppo_clip = beta, adv_clip_max, ppo_clip
self.sde_noise_scale = sde_noise_scale
# one WeightSyncPlan per stage's generator — on two DIFFERENT instances (independent versions)
self.t2i_sync = WeightSyncPlan(role=WeightRole.STUDENT, components=("transformer", ))
self.i2v_sync = WeightSyncPlan(role=WeightRole.STUDENT, components=("transformer", ))
self.t2i_ref.component("transformer").copy_from(self.t2i.component("transformer"))
self.i2v_ref.component("transformer").copy_from(self.i2v.component("transformer"))
def manages_optimization(self) -> bool:
return True
def get_grad_clip_targets(self, iteration: int = 0) -> dict:
return {
"t2i.transformer": self.t2i.component("transformer"),
"i2v.transformer": self.i2v.component("transformer")
}
def _sde_params(self, **extra: Any) -> DiffusionParams:
return DiffusionParams(num_steps=self.rollout_steps,
guidance_scale=1.0,
sde_rollout=True,
sde_noise_scale=self.sde_noise_scale,
**extra)
def _rollout_t2i(self, prompt: Any, seed: Any) -> tuple[Any, Any, Any]:
emb = cached_text_encode(self.t2i, prompt)
neg = cached_text_encode(self.t2i, "")
req = make_request(TaskType.T2I,
self.t2i.card.model_id,
prompt,
diffusion=self._sde_params(num_frames=1, seed=seed))
res = rollout_loop(self.t2i,
"t2i_denoise",
req,
slots={
"text_embeds": emb,
"neg_text_embeds": neg
},
profile=ExecutionProfile.ROLLOUT)
image = self.t2i.component("vae").decode(res.outputs["latents"])
return res, emb, image
def _rollout_i2v(self, prompt: Any, image: Any, seed: Any) -> tuple[Any, Any, Any]:
emb = cached_text_encode(self.i2v, prompt)
neg = cached_text_encode(self.i2v, "")
cond = self.i2v.component("vae").encode(np.asarray(image, dtype="float32")) # image conditioning
emb = (np.asarray(emb, dtype="float32") + float(np.tanh(np.mean(cond)))).astype("float32")
req = make_request(TaskType.I2V,
self.i2v.card.model_id,
prompt,
diffusion=self._sde_params(num_frames=81, seed=seed))
res = rollout_loop(self.i2v,
"i2v_denoise",
req,
slots={
"text_embeds": emb,
"neg_text_embeds": neg
},
profile=ExecutionProfile.ROLLOUT)
video = self.i2v.component("vae").decode(res.outputs["latents"])
return res, emb, video
@staticmethod
def _reward(video: Any) -> float:
return float(np.tanh(-np.std(np.asarray(video, dtype="float64")))) # lower-spread video = better
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))))
t2i_loss, i2v_loss, t2i_gn, i2v_gn, ratios, all_reward = [], [], [], [], [], []
for pi, prompt in enumerate(prompts):
samples = []
for k in range(self.K):
seed = seeds[pi] * 1000 + k
res_t, emb_t, image = self._rollout_t2i(prompt, seed) # stage 1 rollout (SDE)
res_i, emb_i, video = self._rollout_i2v(prompt, image, seed) # stage 2 rollout (SDE)
samples.append((res_t, emb_t, res_i, emb_i, self._reward(video)))
rewards = np.array([s[4] for s in samples], dtype="float64")
adv = np.clip((rewards - rewards.mean()) / (rewards.std() + 1e-4), -self.adv_clip_max,
self.adv_clip_max) # one final-video advantage
all_reward.extend(rewards.tolist())
for k, (res_t, emb_t, res_i, emb_i, _r) in enumerate(samples):
a = float(adv[k])
# the SAME final advantage credits BOTH stages (end-to-end across the model boundary)
pt, _kt, rt, gt = _flowgrpo_ppo_step(self.t2i.component("transformer"),
self.t2i_ref.component("transformer"),
res_t.behavior,
emb_t,
a,
lr=self.t2i_lr,
ppo_clip=self.ppo_clip,
beta=self.beta,
noise_scale=self.sde_noise_scale,
sde_step=self.t2i.platform.kernels.get(FLOW_SDE_STEP))
pi_, _ki, ri, gi = _flowgrpo_ppo_step(self.i2v.component("transformer"),
self.i2v_ref.component("transformer"),
res_i.behavior,
emb_i,
a,
lr=self.i2v_lr,
ppo_clip=self.ppo_clip,
beta=self.beta,
noise_scale=self.sde_noise_scale,
sde_step=self.i2v.platform.kernels.get(FLOW_SDE_STEP))
t2i_loss.append(pt)
i2v_loss.append(pi_)
t2i_gn.append(gt)
i2v_gn.append(gi)
ratios.extend([rt, ri])
t2i_ver = self.t2i_sync.apply(self.t2i.component("transformer"), self.t2i.component("transformer"), self.t2i)
i2v_ver = self.i2v_sync.apply(self.i2v.component("transformer"), self.i2v.component("transformer"), self.i2v)
def m(xs: Any) -> float:
return float(np.mean(xs)) if xs else 0.0
metrics = {
"reward_mean": m(all_reward),
"grad_norm/t2i": m(t2i_gn),
"grad_norm/i2v": m(i2v_gn),
"ppo_ratio_mean": m(ratios) if ratios else 1.0,
"t2i_weights_version": t2i_ver,
"i2v_weights_version": i2v_ver,
}
return {"t2i_pg_loss": m(t2i_loss), "i2v_pg_loss": m(i2v_loss)}, metrics
def train_step(self, batch: dict, iteration: int) -> tuple[dict, dict]:
return self.managed_train_step(batch, iteration)
def build_workflow_rl(t2i_card, i2v_card, **kw) -> WorkflowRLMethod:
"""Four roles: trainable T2I + I2V students and their frozen references (two instances each side)."""
return WorkflowRLMethod(new_instance(t2i_card), new_instance(i2v_card), new_instance(t2i_card),
new_instance(i2v_card), **kw)