[feat] add InterleaveThinker planner GRPO path

This commit is contained in:
Mac Lee
2026-06-19 20:28:15 +00:00
parent be0bacbe35
commit 2cf9aa0d09
13 changed files with 816 additions and 184 deletions
@@ -2712,3 +2712,154 @@ Conclusion:
- Next full-integration stage: add a planner-policy RL path so the
InterleaveThinker planner can be optimized with the same FastVideo GRPO loop,
using planner-specific rollouts and rewards instead of only critic RL/SFT.
## Stage 10 Execution: Planner GRPO Path
Status: validation complete; ready to commit and push.
Goal:
- Let `InterleaveThinkerPlannerModel` participate in the same FastVideo
managed GRPO loop as the critic actor.
- Keep Qwen response-token policy optimization shared between planner and
critic wrappers.
- Add a planner-specific reward scorer for valid
`<think>...</think><answer>{"execution_plan": [...]}</answer>` outputs, with
optional external/fixture plan scores.
- Add a public planner GRPO YAML using a trainable planner student and frozen
planner reference model.
Planned implementation:
- Move or duplicate the Qwen GRPO actor update onto `Qwen3VLActorBase` so both
planner and critic actors can train over response-token logprobs.
- Add `InterleaveThinkerPlannerModel.generate_interleave_responses(...)`
returning rollouts compatible with `InterleaveThinkerRLMethod`:
`response`, parsed plan metadata, `old_logprobs`, `response_mask`,
`sample_index`, `generation_index`, and `group_key`.
- Add `planner_rl` dataset normalization that can consume prompt-only planner
RL files without requiring an assistant response.
- Add `InterleavePlannerRewardScorer` under
`fastvideo/train/methods/rl/rewards/`.
- Add `examples/train/configs/rl/interleave_thinker/planner_grpo.yaml`.
- Add focused unit tests for:
- planner RL data normalization;
- planner reward parsing/scoring;
- planner rollout generation with old logprobs;
- planner public YAML parsing;
- shared Qwen actor policy update behavior remains intact.
Validation plan:
- Local syntax checks and `git diff --check`.
- Modal focused pytest for planner model/data/reward/method/GRPO tests.
- Modal pre-commit on changed files.
- If feasible after unit validation, run a one-step Modal smoke with real
`InterleaveThinker/InterleaveThinker-Planner-8B` student and frozen
reference planner using format-only planner rewards.
Implemented changes:
- Shared Qwen actor update:
- Moved the response-token GRPO actor update into
`Qwen3VLActorBase.train_interleave_rollouts(...)`.
- Moved `_grpo_logprob_batch(...)` and weighted actor-metric aggregation into
`qwen_actor.py`.
- Removed the duplicate critic-local GRPO update; the critic now inherits the
shared Qwen actor path.
- Planner rollouts:
- Added `InterleaveThinkerPlannerModel.generate_interleave_responses(...)`.
- Planner rollouts now include `response`, parsed plan metadata, `steps`,
`sample_index`, `generation_index`, `group_key`, `old_logprobs`, and
`response_mask`.
- Planner image prompt construction now accepts the upstream `images` list as
an image-source alias.
- Planner RL data:
- Added `planner_rl` to `InterleaveDatasetKind`.
- Added `planner_rl.jsonl` default filename.
- Added `load_planner_rl_records(...)` and
`normalize_planner_rl_record(...)`.
- Planner RL records can be prompt-only and do not require an assistant SFT
response.
- Planner rewards:
- Added `InterleavePlannerRewardScorer`.
- Added `extract_interleave_plan_payload(...)`,
`interleave_planner_format_reward(...)`, and
`score_interleave_planner_rewards(...)`.
- Exported planner reward utilities from
`fastvideo.train.methods.rl.rewards`.
- `InterleaveThinkerRLMethod` now accepts optional
`method.reward_scorer` for scorer injection; when absent, it keeps the
existing critic reward scorer behavior.
- Public config:
- Added
`examples/train/configs/rl/interleave_thinker/planner_grpo.yaml` with a
trainable LoRA planner student, frozen planner reference, planner reward
scorer, and GRPO/reference-KL knobs.
- Tests:
- Added planner RL data normalization coverage.
- Added planner reward parsing/scoring coverage.
- Added planner RL rollout generation coverage with old logprobs.
- Added planner GRPO public YAML parse coverage.
- Reused existing critic GRPO tests to verify the shared Qwen refactor.
Validation completed:
- Local lightweight checks:
- `python -m py_compile` passed for the changed InterleaveThinker model,
reward, method, and test modules.
- `git diff --check` passed.
- Local pre-commit applied yapf/ruff formatting; local mypy still cannot run
in this hyphenated worktree path and reports
`fastvideo-interleavethinker is not a valid Python package name`.
Modal mypy is authoritative.
- Modal focused pytest:
- First app URL:
`https://modal.com/apps/hao-ai-lab/main/ap-aHGkQGhbijME5oLBFIdZOH`
- Result: `36 passed`, `1 failed`.
- Failure was a test fake issue: the fake planner processor returned one
decoded response even when generation returned two sequences.
- Fixed by making the fake `batch_decode(...)` return one response per
sequence.
- Modal focused pytest after fake fix:
- App URL:
`https://modal.com/apps/hao-ai-lab/main/ap-CUch3nJsv7MWZfzik5y1wP`
- Result: `37 passed, 14 warnings in 19.09s`.
- Modal pre-commit before local formatter application:
- App URL:
`https://modal.com/apps/hao-ai-lab/main/ap-TM7tfdNJ9ZfGReagJGpw8P`
- Result: yapf and ruff modified files; codespell and mypy passed.
- Applied the same formatter changes locally.
- Modal focused pytest after formatter changes:
- App URL:
`https://modal.com/apps/hao-ai-lab/main/ap-wccYJm9hNWdmEqyLZZfcnv`
- Result: `37 passed, 14 warnings in 15.25s`.
- Final Modal pre-commit:
- App URL:
`https://modal.com/apps/hao-ai-lab/main/ap-xdYf7BFSVVyZqKx3Qdd0Yt`
- Result: yapf, ruff, codespell, mypy, filename, and suggestion hooks
passed; PyMarkdown/actionlint skipped with no files to check.
- Real one-step planner GRPO smoke:
- App URL:
`https://modal.com/apps/hao-ai-lab/main/ap-PDBijC8opxsMiMU0Uc064A`
- Result:
- Loaded the trainable LoRA student
`InterleaveThinker/InterleaveThinker-Planner-8B`.
- Enabled PEFT LoRA with rank 4 and alpha 8 on
`['q_proj', 'k_proj', 'v_proj', 'o_proj']`.
- Loaded the frozen planner reference
`InterleaveThinker/InterleaveThinker-Planner-8B`.
- Generated two planner rollouts, computed old and reference response-token
logprobs, scored with `InterleavePlannerRewardScorer`, and completed one
GRPO update step with `kl_coef=0.01`.
- The job printed `INTERLEAVE_PLANNER_RL_SMOKE_OK`.
Conclusion:
- FastVideo now has planner SFT, critic SFT, critic GRPO, and planner GRPO
paths for the InterleaveThinker Qwen actors.
- The planner and critic share the same Qwen response-token GRPO actor update.
- The planner and critic GRPO paths both have real one-step Modal smoke
validation against actual InterleaveThinker 8B checkpoints.
- Next action: commit this Stage 10 planner GRPO integration and push it to
`origin/interleavethinker-fastvideo`.
@@ -0,0 +1,101 @@
# InterleaveThinker planner GRPO bridge.
#
# This config trains the Qwen3-VL planner policy with FastVideo's managed GRPO
# loop. It uses a lightweight planner reward by default: valid
# <think>...</think><answer>{"execution_plan": [...]}</answer> format, plus an
# optional scalar plan score when present in the dataset.
models:
student:
_target_: fastvideo.train.models.interleave_thinker.InterleaveThinkerPlannerModel
init_from: InterleaveThinker/InterleaveThinker-Planner-8B
processor_from: Qwen/Qwen3-VL-8B-Instruct
load_backend: true
trainable: true
dataset_kind: planner_rl
image_dir: data/InterleaveThinker/Train-Data
torch_dtype: auto
attn_implementation: flash_attention_2
freeze_vision_tower: true
freeze_multi_modal_projector: true
enable_gradient_checkpointing: true
max_prompt_length: 16384
max_response_length: 4096
lora:
enable: true
rank: 16
alpha: 32
target_modules:
- q_proj
- k_proj
- v_proj
- o_proj
reference:
_target_: fastvideo.train.models.interleave_thinker.InterleaveThinkerPlannerModel
init_from: InterleaveThinker/InterleaveThinker-Planner-8B
processor_from: Qwen/Qwen3-VL-8B-Instruct
load_backend: true
trainable: false
dataset_kind: planner_rl
image_dir: data/InterleaveThinker/Train-Data
torch_dtype: auto
attn_implementation: flash_attention_2
freeze_vision_tower: true
freeze_multi_modal_projector: true
enable_gradient_checkpointing: false
max_prompt_length: 16384
max_response_length: 4096
method:
_target_: fastvideo.train.methods.rl.interleave_thinker.InterleaveThinkerRLMethod
reward_scorer:
_target_: fastvideo.train.methods.rl.rewards.InterleavePlannerRewardScorer
format_weight: 1.0
fallback_plan_reward: 0.0
num_generations: 8
num_batches_per_step: 1
temperature: 1.0
top_p: 1.0
max_new_tokens: 2048
clip_range: 0.2
kl_coef: 0.01
micro_batch_size_per_device_for_update: 1
advantage_eps: 0.0001
advantage_clip: 5.0
max_grad_norm: 1.0
terminal_progress: true
training:
distributed:
num_gpus: 8
sp_size: 1
tp_size: 4
hsdp_replicate_dim: 1
hsdp_shard_dim: 8
data:
data_path: data/InterleaveThinker/Train-Data/planner_rl.jsonl
preprocessed_data_type: text_only
train_batch_size: 16
dataloader_num_workers: 0
seed: 42
optimizer:
learning_rate: 2.0e-6
betas: [0.9, 0.999]
weight_decay: 0.0
lr_scheduler: constant
lr_warmup_steps: 0
loop:
max_train_steps: 1000
gradient_accumulation_steps: 1
checkpoint:
output_dir: outputs/interleave_thinker_planner_grpo
training_state_checkpointing_steps: 50
checkpoints_total_limit: 3
tracker:
project_name: InterleaveThinker-GRPO
run_name: interleave_thinker_planner_grpo_fastvideo
@@ -86,14 +86,7 @@ class InterleaveThinkerRLMethod(TrainingMethod):
)
self._max_grad_norm = self._read_float("max_grad_norm", 0.0)
self._terminal_progress = bool(self.method_config.get("terminal_progress", True))
self._reward_scorer = InterleaveThinkerRewardScorer(
format_weight=self._read_float("format_weight", 0.5),
judge_accuracy_weight=self._read_float("judge_accuracy_weight", 0.2),
semantic_weight=self._read_float("semantic_weight", 0.6),
quality_weight=self._read_float("quality_weight", 0.2),
fallback_edit_reward=self._read_float("fallback_edit_reward", 0.5),
edit_scorer=self._build_edit_scorer(self.method_config.get("edit_scorer")),
)
self._reward_scorer = self._build_reward_scorer()
self._student_optimizer: torch.optim.Optimizer | None = None
self._student_lr_scheduler: Any | None = None
self._init_optimizer_and_scheduler()
@@ -222,6 +215,23 @@ class InterleaveThinkerRLMethod(TrainingMethod):
return instantiate(dict(raw))
raise TypeError("method.edit_scorer must be a callable or a mapping with _target_")
def _build_reward_scorer(self) -> Any:
raw = self.method_config.get("reward_scorer")
if raw is not None:
if callable(raw):
return raw
if isinstance(raw, Mapping):
return instantiate(dict(raw))
raise TypeError("method.reward_scorer must be a callable or a mapping with _target_")
return InterleaveThinkerRewardScorer(
format_weight=self._read_float("format_weight", 0.5),
judge_accuracy_weight=self._read_float("judge_accuracy_weight", 0.2),
semantic_weight=self._read_float("semantic_weight", 0.6),
quality_weight=self._read_float("quality_weight", 0.2),
fallback_edit_reward=self._read_float("fallback_edit_reward", 0.5),
edit_scorer=self._build_edit_scorer(self.method_config.get("edit_scorer")),
)
def _freeze_reference_model(self) -> None:
if self.reference is None:
return
@@ -7,15 +7,20 @@ from typing import TYPE_CHECKING
from fastvideo.train.methods.rl.rewards.interleave_thinker import (
EditScoreProvider,
InterleavePlannerRewardResult,
InterleavePlannerRewardScorer,
InterleaveThinkerAnswer,
InterleaveThinkerEditRequest,
InterleaveThinkerEditScore,
InterleaveThinkerRewardResult,
InterleaveThinkerRewardScorer,
extract_interleave_answer,
extract_interleave_plan_payload,
interleave_format_reward,
interleave_planner_format_reward,
interleave_judge_accuracy_reward,
normalize_interleave_response,
score_interleave_planner_rewards,
score_interleave_thinker_rewards,
)
@@ -104,6 +109,8 @@ __all__ = [
"EditScoreProvider",
"GeminiInterleaveImageScorer",
"GeminiNanoBananaEditScorer",
"InterleavePlannerRewardResult",
"InterleavePlannerRewardScorer",
"InterleaveThinkerAnswer",
"InterleaveThinkerEditRequest",
"InterleaveThinkerEditScore",
@@ -114,9 +121,12 @@ __all__ = [
"RewardScorer",
"build_multi_reward_scorer",
"extract_interleave_answer",
"extract_interleave_plan_payload",
"interleave_format_reward",
"interleave_planner_format_reward",
"interleave_judge_accuracy_reward",
"normalize_interleave_response",
"score_interleave_planner_rewards",
"score_interleave_thinker_rewards",
"select_first_frame",
]
@@ -87,6 +87,24 @@ class InterleaveThinkerRewardResult:
}
@dataclass(frozen=True, slots=True)
class InterleavePlannerRewardResult:
"""One scored InterleaveThinker planner rollout."""
overall: float
format_reward: float
planner_score: float
index: int
def as_dict(self) -> dict[str, float]:
return {
"overall": float(self.overall),
"format_reward": float(self.format_reward),
"planner_score": float(self.planner_score),
"idx": float(self.index),
}
EditScoreProvider = Callable[[InterleaveThinkerEditRequest], InterleaveThinkerEditScore | Mapping[str, Any] | None]
@@ -141,6 +159,40 @@ def interleave_format_reward(response: str) -> float:
return 1.0 if extract_interleave_answer(normalized) is not None else 0.0
def extract_interleave_plan_payload(response: str) -> dict[str, Any] | None:
"""Extract the planner ``execution_plan`` answer payload."""
match = _ANSWER_PATTERN.search(normalize_interleave_response(response))
if not match:
return None
payload = _jsonish_loads(match.group(1))
if payload is None:
return None
raw_steps = payload.get("execution_plan")
if not isinstance(raw_steps, Sequence) or isinstance(raw_steps, str | bytes) or not raw_steps:
return None
for step in raw_steps:
if not isinstance(step, Mapping):
return None
has_prompt = _non_empty_optional_text(step.get("prompt")) is not None
has_instruction = _non_empty_optional_text(step.get("instruction")) is not None
has_auxiliary = _non_empty_optional_text(step.get("auxiliary_text")) is not None
if not (has_prompt or has_instruction or has_auxiliary):
return None
return payload
def interleave_planner_format_reward(response: str) -> float:
"""Return 1 when a planner response has valid reasoning and plan JSON."""
normalized = normalize_interleave_response(response)
think_match = _THINK_PATTERN.search(normalized)
answer_match = _ANSWER_PATTERN.search(normalized)
if think_match is None or answer_match is None:
return 0.0
if think_match.start() >= answer_match.start():
return 0.0
return 1.0 if extract_interleave_plan_payload(normalized) is not None else 0.0
def interleave_judge_accuracy_reward(
predicted_previous_step_success: bool | None,
ground_truth_previous_step_success: bool | None,
@@ -161,6 +213,15 @@ def _coerce_float(value: Any, *, default: float = 0.0) -> float:
return float(value)
def _non_empty_optional_text(value: Any) -> str | None:
if value is None:
return None
text = str(value).strip()
if not text or text.lower() in {"none", "null"}:
return None
return text
def _mapping_get_bool(mapping: Mapping[str, Any], *keys: str) -> bool | None:
for key in keys:
if key in mapping:
@@ -244,6 +305,24 @@ def _normalized_edit_rewards(
)
def _planner_score_from_item(
item: Mapping[str, Any],
*,
fallback: float,
) -> float:
for key in ("planner_score", "plan_score", "reward", "score"):
if key in item:
return _coerce_float(item.get(key), default=fallback)
raw = item.get("ground_truth", item.get("evaluation", {}))
if isinstance(raw, str):
raw = _jsonish_loads(raw) or {}
if isinstance(raw, Mapping):
for key in ("planner_score", "plan_score", "reward", "score"):
if key in raw:
return _coerce_float(raw.get(key), default=fallback)
return fallback
class InterleaveThinkerRewardScorer:
"""Batch scorer for InterleaveThinker critic outputs.
@@ -303,10 +382,8 @@ class InterleaveThinkerRewardScorer:
previous_quality=previous_quality,
fallback=self.fallback_edit_reward,
)
non_format_reward = (
self.judge_accuracy_weight * judge_reward + self.semantic_weight * semantic_reward +
self.quality_weight * quality_reward
) / inner_total
non_format_reward = (self.judge_accuracy_weight * judge_reward + self.semantic_weight * semantic_reward +
self.quality_weight * quality_reward) / inner_total
overall = self.format_weight * format_reward + (1.0 - self.format_weight) * non_format_reward
results.append(
InterleaveThinkerRewardResult(
@@ -344,6 +421,64 @@ class InterleaveThinkerRewardScorer:
return tensors
class InterleavePlannerRewardScorer:
"""Batch scorer for InterleaveThinker planner outputs.
This scorer is intentionally lightweight: by default it rewards valid
planner response format and can blend in an externally supplied scalar plan
score from each rollout for stronger supervision.
"""
def __init__(
self,
*,
format_weight: float = 1.0,
fallback_plan_reward: float = 0.0,
) -> None:
self.format_weight = float(format_weight)
self.fallback_plan_reward = float(fallback_plan_reward)
if not 0.0 <= self.format_weight <= 1.0:
raise ValueError("format_weight must be in [0, 1]")
def __call__(
self,
reward_inputs: Sequence[Mapping[str, Any]],
) -> list[InterleavePlannerRewardResult]:
results: list[InterleavePlannerRewardResult] = []
for index, item in enumerate(reward_inputs):
response = str(item.get("response", "") or "")
format_reward = interleave_planner_format_reward(response)
planner_score = _planner_score_from_item(
item,
fallback=self.fallback_plan_reward,
)
overall = self.format_weight * format_reward + (1.0 - self.format_weight) * planner_score
results.append(
InterleavePlannerRewardResult(
overall=float(overall),
format_reward=float(format_reward),
planner_score=float(planner_score),
index=index,
))
return results
def as_tensors(
self,
reward_inputs: Sequence[Mapping[str, Any]],
*,
device: torch.device | str = "cpu",
) -> dict[str, torch.Tensor]:
results = self(reward_inputs)
names = ["overall", "format_reward", "planner_score"]
tensors: dict[str, torch.Tensor] = {}
for name in names:
tensors[name] = torch.tensor([getattr(result, name) for result in results],
device=device,
dtype=torch.float32)
tensors["avg"] = tensors["overall"]
return tensors
def score_interleave_thinker_rewards(
reward_inputs: Sequence[Mapping[str, Any]],
**kwargs: Any,
@@ -353,16 +488,30 @@ def score_interleave_thinker_rewards(
return [result.as_dict() for result in scorer(reward_inputs)]
def score_interleave_planner_rewards(
reward_inputs: Sequence[Mapping[str, Any]],
**kwargs: Any,
) -> list[dict[str, float]]:
"""Convenience wrapper for planner rollout rewards."""
scorer = InterleavePlannerRewardScorer(**kwargs)
return [result.as_dict() for result in scorer(reward_inputs)]
__all__ = [
"EditScoreProvider",
"InterleavePlannerRewardResult",
"InterleaveThinkerAnswer",
"InterleaveThinkerEditRequest",
"InterleaveThinkerEditScore",
"InterleaveThinkerRewardResult",
"InterleavePlannerRewardScorer",
"InterleaveThinkerRewardScorer",
"extract_interleave_answer",
"extract_interleave_plan_payload",
"interleave_format_reward",
"interleave_planner_format_reward",
"interleave_judge_accuracy_reward",
"normalize_interleave_response",
"score_interleave_planner_rewards",
"score_interleave_thinker_rewards",
]
@@ -14,11 +14,13 @@ from fastvideo.train.models.interleave_thinker.data import (
load_critic_rl_records,
load_critic_sft_records,
load_interleave_dataset,
load_planner_rl_records,
load_planner_sft_records,
normalize_critic_rl_record,
normalize_critic_sft_record,
normalize_ground_truth,
normalize_interleave_dataset_record,
normalize_planner_rl_record,
normalize_planner_sft_record,
resolve_interleave_image_path,
validate_image_path,
@@ -49,11 +51,13 @@ __all__ = [
"load_critic_rl_records",
"load_critic_sft_records",
"load_interleave_dataset",
"load_planner_rl_records",
"load_planner_sft_records",
"normalize_critic_rl_record",
"normalize_critic_sft_record",
"normalize_ground_truth",
"normalize_interleave_dataset_record",
"normalize_planner_rl_record",
"normalize_planner_sft_record",
"resolve_interleave_image_path",
"validate_image_path",
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from collections.abc import Mapping
from typing import Any, TYPE_CHECKING
import torch
@@ -12,14 +12,10 @@ from fastvideo.train.models.interleave_thinker.qwen_actor import (
Qwen3VLActorBase,
_PlaceholderActorModule as _PlaceholderActorModule,
batch_to_items,
coerce_logprob_vector,
coerce_response_mask,
first_string,
pad_1d_tensors,
rollout_group_key,
)
from fastvideo.train.models.interleave_thinker.data import InterleaveDatasetKind
from fastvideo.train.methods.rl.common.grpo import compute_grpo_loss
if TYPE_CHECKING:
from fastvideo.train.utils.lora import LoraConfig
@@ -143,89 +139,6 @@ class InterleaveThinkerCriticModel(Qwen3VLActorBase):
rollouts.append(rollout)
return rollouts
def train_interleave_rollouts(
self,
**kwargs: Any,
) -> tuple[dict[str, torch.Tensor], dict[str, float]]:
self._require_backend()
rollouts = kwargs["rollouts"]
advantages = kwargs["advantages"].detach().to(self.device).float()
optimizer = kwargs.get("optimizer")
lr_scheduler = kwargs.get("lr_scheduler")
clip_range = float(kwargs.get("clip_range", 0.2) or 0.0)
kl_coef = float(kwargs.get("kl_coef", 0.0) or 0.0)
update_micro_batch_size = kwargs.get("update_micro_batch_size")
if update_micro_batch_size is None:
update_micro_batch_size = len(rollouts)
update_micro_batch_size = max(1, int(update_micro_batch_size or 1))
max_grad_norm = float(kwargs.get("max_grad_norm", 0.0) or 0.0)
grad_accum = max(1, int(kwargs.get("gradient_accumulation_steps", 1) or 1))
if optimizer is None:
raise RuntimeError("InterleaveThinkerCriticModel.train_interleave_rollouts() requires an optimizer")
optimizer.zero_grad(set_to_none=True)
self.transformer.train()
if int(advantages.shape[0]) != len(rollouts):
raise ValueError("advantages count must match rollout count")
loss_results = []
token_counts: list[float] = []
loss_scale = max(1, grad_accum)
for start in range(0, len(rollouts), update_micro_batch_size):
end = min(start + update_micro_batch_size, len(rollouts))
current_logprobs, old_logprobs, response_masks, reference_logprobs = self._grpo_logprob_batch(
rollouts[start:end])
result = compute_grpo_loss(
current_logprobs=current_logprobs,
old_logprobs=old_logprobs,
advantages=advantages[start:end],
response_mask=response_masks,
clip_range=clip_range,
reference_logprobs=reference_logprobs,
kl_coef=kl_coef,
)
(result.total_loss / loss_scale).backward()
loss_results.append(result)
token_counts.append(float(result.token_count.detach().cpu()))
if max_grad_norm > 0.0:
torch.nn.utils.clip_grad_norm_(
[p for p in self.transformer.parameters() if p.requires_grad],
max_grad_norm,
)
optimizer.step()
if lr_scheduler is not None:
lr_scheduler.step()
total_tokens = max(1.0, sum(token_counts))
total_loss = _weighted_result_mean(loss_results, "total_loss", token_counts, self.device, total_tokens)
policy_loss = _weighted_result_mean(loss_results, "policy_loss", token_counts, self.device, total_tokens)
kl_loss = _weighted_result_mean(loss_results, "kl_loss", token_counts, self.device, total_tokens)
approx_kl = _weighted_result_mean(loss_results, "approx_kl", token_counts, self.device, total_tokens)
clipped_fraction = _weighted_result_mean(
loss_results,
"clipped_fraction",
token_counts,
self.device,
total_tokens,
)
mean_ratio = _weighted_result_mean(loss_results, "mean_ratio", token_counts, self.device, total_tokens)
return (
{
"total_loss": total_loss.detach()
},
{
"actor/policy_loss": float(policy_loss.detach().cpu()),
"actor/kl_loss": float(kl_loss.detach().cpu()),
"actor/approx_kl": float(approx_kl.detach().cpu()),
"actor/clipped_fraction": float(clipped_fraction.detach().cpu()),
"actor/mean_ratio": float(mean_ratio.detach().cpu()),
"actor/mean_advantage": float(advantages.mean().detach().cpu()),
"actor/response_tokens": float(total_tokens / max(1, len(rollouts))),
},
)
def build_messages(
self,
item: Mapping[str, Any],
@@ -236,67 +149,6 @@ class InterleaveThinkerCriticModel(Qwen3VLActorBase):
)
return self.build_text_image_messages(prompt, _item_image_paths(item))
def _response_nll(
self,
rollout: Mapping[str, Any],
) -> tuple[torch.Tensor, int]:
return self.response_nll_from_messages(
self.build_messages(rollout),
str(rollout.get("response", "") or ""),
)
def _grpo_logprob_batch(
self,
rollouts: Sequence[Mapping[str, Any]],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor | None]:
current_logprob_rows: list[torch.Tensor] = []
old_logprob_rows: list[torch.Tensor] = []
mask_rows: list[torch.Tensor] = []
reference_rows: list[torch.Tensor] = []
has_reference = False
for rollout in rollouts:
response = str(rollout.get("response", "") or "")
current_logprobs, default_mask = self.response_logprobs_from_messages(
self.build_messages(rollout),
response,
)
expected_len = int(current_logprobs.numel())
old_logprobs = coerce_logprob_vector(
rollout.get("old_logprobs", rollout.get("old_logprob")),
expected_len=expected_len,
device=self.device,
name="old_logprobs",
)
if old_logprobs is None:
old_logprobs = current_logprobs.detach()
response_mask = coerce_response_mask(
rollout.get("response_mask"),
expected_len=expected_len,
device=self.device,
)
if response_mask is None:
response_mask = default_mask
reference_logprobs = coerce_logprob_vector(
rollout.get("reference_logprobs", rollout.get("ref_logprobs")),
expected_len=expected_len,
device=self.device,
name="reference_logprobs",
)
current_logprob_rows.append(current_logprobs)
old_logprob_rows.append(old_logprobs)
mask_rows.append(response_mask)
if reference_logprobs is None:
reference_rows.append(current_logprobs.detach())
else:
has_reference = True
reference_rows.append(reference_logprobs)
return (
pad_1d_tensors(current_logprob_rows),
pad_1d_tensors(old_logprob_rows),
pad_1d_tensors(mask_rows),
pad_1d_tensors(reference_rows) if has_reference else None,
)
def backward(
self,
loss: torch.Tensor,
@@ -317,19 +169,4 @@ def _item_image_paths(item: Mapping[str, Any], ) -> list[str]:
return image_paths[:2]
def _weighted_result_mean(
results: Sequence[Any],
field: str,
weights: Sequence[float],
device: torch.device,
total_weight: float,
) -> torch.Tensor:
if not results:
return torch.zeros((), device=device)
values = []
for result, weight in zip(results, weights, strict=True):
values.append(getattr(result, field).detach() * float(weight))
return torch.stack(values).sum() / float(total_weight)
__all__ = ["INTERLEAVE_CRITIC_PROMPT", "InterleaveThinkerCriticModel", "_PlaceholderActorModule"]
@@ -9,10 +9,11 @@ import os
from pathlib import Path
from typing import Any, Literal
InterleaveDatasetKind = Literal["planner_sft", "critic_sft", "critic_rl"]
InterleaveDatasetKind = Literal["planner_sft", "planner_rl", "critic_sft", "critic_rl"]
DEFAULT_FILENAMES: dict[InterleaveDatasetKind, str] = {
"planner_sft": "planner_sft.json",
"planner_rl": "planner_rl.jsonl",
"critic_sft": "critic_sft.json",
"critic_rl": "critic_rl.jsonl",
}
@@ -80,6 +81,20 @@ def load_planner_sft_records(
)
def load_planner_rl_records(
data_path: str | os.PathLike[str],
*,
image_dir: str | os.PathLike[str] = "",
validate_image_files: bool = False,
) -> list[dict[str, Any]]:
return load_interleave_dataset(
data_path,
kind="planner_rl",
image_dir=image_dir,
validate_image_files=validate_image_files,
)
def load_critic_sft_records(
data_path: str | os.PathLike[str],
*,
@@ -122,6 +137,8 @@ def normalize_interleave_dataset_record(
)
if kind == "planner_sft":
return normalize_planner_sft_record(normalized)
if kind == "planner_rl":
return normalize_planner_rl_record(normalized)
if kind == "critic_sft":
return normalize_critic_sft_record(normalized)
if kind == "critic_rl":
@@ -140,6 +157,29 @@ def normalize_planner_sft_record(record: Mapping[str, Any]) -> dict[str, Any]:
raise ValueError("planner_sft record requires an assistant message")
normalized.setdefault("instruction", user_message)
normalized.setdefault("response", assistant_message)
images = _string_sequence(normalized.get("images"))
if images:
normalized.setdefault("input_image_paths", images)
return normalized
def normalize_planner_rl_record(record: Mapping[str, Any]) -> dict[str, Any]:
normalized = dict(record)
if "messages" in normalized:
messages = _require_messages(normalized, where="planner_rl")
normalized.setdefault("instruction", _first_message_content(messages, "user"))
instruction = _first_text(
normalized.get("instruction"),
normalized.get("text_input"),
normalized.get("origin_prompt"),
normalized.get("prompt"),
)
if not instruction:
raise ValueError("planner_rl record requires instruction, text_input, origin_prompt, or prompt")
normalized["instruction"] = instruction
images = _string_sequence(normalized.get("images"))
if images:
normalized.setdefault("input_image_paths", images)
return normalized
@@ -199,8 +239,7 @@ def normalize_ground_truth(record: Mapping[str, Any]) -> dict[str, Any]:
if not isinstance(raw, Mapping):
raw = {}
success = _optional_bool(
raw.get("success", raw.get("previous_step_success", record.get("previous_step_success"))))
success = _optional_bool(raw.get("success", raw.get("previous_step_success", record.get("previous_step_success"))))
if success is None:
raise ValueError("critic_rl ground_truth requires boolean success or previous_step_success")
return {
@@ -380,12 +419,14 @@ __all__ = [
"load_critic_rl_records",
"load_critic_sft_records",
"load_interleave_dataset",
"load_planner_rl_records",
"load_planner_sft_records",
"looks_like_uri",
"normalize_critic_rl_record",
"normalize_critic_sft_record",
"normalize_ground_truth",
"normalize_interleave_dataset_record",
"normalize_planner_rl_record",
"normalize_planner_sft_record",
"resolve_interleave_image_path",
"validate_image_path",
@@ -15,6 +15,7 @@ import torch
from fastvideo.train.models.interleave_thinker.qwen_actor import (
Qwen3VLActorBase,
batch_to_items,
rollout_group_key,
)
from fastvideo.train.models.interleave_thinker.data import InterleaveDatasetKind
@@ -193,6 +194,45 @@ class InterleaveThinkerPlannerModel(Qwen3VLActorBase):
})
return outputs
@torch.no_grad()
def generate_interleave_responses(
self,
batch: dict[str, Any],
**kwargs: Any,
) -> list[dict[str, Any]]:
num_generations = max(1, int(kwargs.get("num_generations", 1) or 1))
temperature = float(kwargs.get("temperature", 1.0) or 1.0)
top_p = float(kwargs.get("top_p", 1.0) or 1.0)
max_new_tokens = int(kwargs.get("max_new_tokens") or self.max_response_length)
rollouts: list[dict[str, Any]] = []
for item_idx, item in enumerate(batch_to_items(batch)):
messages = self.build_messages(item)
decoded = self.generate_qwen_responses(
messages,
num_generations=num_generations,
temperature=temperature,
top_p=top_p,
max_new_tokens=max_new_tokens,
)
for generation_idx, response in enumerate(decoded):
parsed = extract_interleave_plan(response)
rollout = dict(item)
rollout["response"] = response
rollout["plan"] = parsed
rollout["steps"] = list(parsed.steps) if parsed is not None else []
rollout.setdefault("sample_index", item_idx)
rollout.setdefault("generation_index", generation_idx)
rollout.setdefault("group_key", _planner_group_key(rollout, item_idx))
old_logprobs, response_mask = self.response_logprobs_from_messages(
messages,
response,
)
rollout["old_logprobs"] = old_logprobs.detach().cpu().tolist()
rollout["response_mask"] = response_mask.detach().cpu().tolist()
rollouts.append(rollout)
return rollouts
def generate_interleave_plan(
self,
instruction: str,
@@ -298,7 +338,7 @@ def _planner_instruction(item: Mapping[str, Any]) -> str:
def _planner_image_paths(item: Mapping[str, Any]) -> list[str]:
paths: list[str] = []
for key in ("input_image_paths", "image_paths"):
for key in ("input_image_paths", "image_paths", "images"):
value = item.get(key)
if isinstance(value, Sequence) and not isinstance(value, str | bytes):
paths.extend(str(path) for path in value if path)
@@ -309,6 +349,17 @@ def _planner_image_paths(item: Mapping[str, Any]) -> list[str]:
return paths
def _planner_group_key(
rollout: Mapping[str, Any],
index: int,
) -> str:
for key in ("group_key", "problem_id", "instruction", "text_input", "origin_prompt", "prompt"):
value = rollout.get(key)
if value is not None:
return str(value)
return rollout_group_key(rollout, index)
__all__ = [
"INTERLEAVE_GUIDANCE_PLANNER_PROMPT",
"INTERLEAVE_PLANNER_PROMPT",
@@ -16,6 +16,7 @@ from torch.utils.data import DataLoader, Dataset
from fastvideo.logger import init_logger
from fastvideo.pipelines import TrainingBatch
from fastvideo.train.methods.rl.common.grpo import compute_grpo_loss
from fastvideo.train.models.base import ModelBase
from fastvideo.train.models.interleave_thinker.data import (
InterleaveDatasetKind,
@@ -374,6 +375,141 @@ class Qwen3VLActorBase(ModelBase):
if was_training:
self.transformer.train()
def train_interleave_rollouts(
self,
**kwargs: Any,
) -> tuple[dict[str, torch.Tensor], dict[str, float]]:
self._require_backend()
rollouts = kwargs["rollouts"]
advantages = kwargs["advantages"].detach().to(self.device).float()
optimizer = kwargs.get("optimizer")
lr_scheduler = kwargs.get("lr_scheduler")
clip_range = float(kwargs.get("clip_range", 0.2) or 0.0)
kl_coef = float(kwargs.get("kl_coef", 0.0) or 0.0)
update_micro_batch_size = kwargs.get("update_micro_batch_size")
if update_micro_batch_size is None:
update_micro_batch_size = len(rollouts)
update_micro_batch_size = max(1, int(update_micro_batch_size or 1))
max_grad_norm = float(kwargs.get("max_grad_norm", 0.0) or 0.0)
grad_accum = max(1, int(kwargs.get("gradient_accumulation_steps", 1) or 1))
if optimizer is None:
raise RuntimeError(f"{type(self).__name__}.train_interleave_rollouts() requires an optimizer")
optimizer.zero_grad(set_to_none=True)
self.transformer.train()
if int(advantages.shape[0]) != len(rollouts):
raise ValueError("advantages count must match rollout count")
loss_results = []
token_counts: list[float] = []
loss_scale = max(1, grad_accum)
for start in range(0, len(rollouts), update_micro_batch_size):
end = min(start + update_micro_batch_size, len(rollouts))
current_logprobs, old_logprobs, response_masks, reference_logprobs = self._grpo_logprob_batch(
rollouts[start:end])
result = compute_grpo_loss(
current_logprobs=current_logprobs,
old_logprobs=old_logprobs,
advantages=advantages[start:end],
response_mask=response_masks,
clip_range=clip_range,
reference_logprobs=reference_logprobs,
kl_coef=kl_coef,
)
(result.total_loss / loss_scale).backward()
loss_results.append(result)
token_counts.append(float(result.token_count.detach().cpu()))
if max_grad_norm > 0.0:
torch.nn.utils.clip_grad_norm_(
[p for p in self.transformer.parameters() if p.requires_grad],
max_grad_norm,
)
optimizer.step()
if lr_scheduler is not None:
lr_scheduler.step()
total_tokens = max(1.0, sum(token_counts))
total_loss = _weighted_result_mean(loss_results, "total_loss", token_counts, self.device, total_tokens)
policy_loss = _weighted_result_mean(loss_results, "policy_loss", token_counts, self.device, total_tokens)
kl_loss = _weighted_result_mean(loss_results, "kl_loss", token_counts, self.device, total_tokens)
approx_kl = _weighted_result_mean(loss_results, "approx_kl", token_counts, self.device, total_tokens)
clipped_fraction = _weighted_result_mean(
loss_results,
"clipped_fraction",
token_counts,
self.device,
total_tokens,
)
mean_ratio = _weighted_result_mean(loss_results, "mean_ratio", token_counts, self.device, total_tokens)
return (
{
"total_loss": total_loss.detach()
},
{
"actor/policy_loss": float(policy_loss.detach().cpu()),
"actor/kl_loss": float(kl_loss.detach().cpu()),
"actor/approx_kl": float(approx_kl.detach().cpu()),
"actor/clipped_fraction": float(clipped_fraction.detach().cpu()),
"actor/mean_ratio": float(mean_ratio.detach().cpu()),
"actor/mean_advantage": float(advantages.mean().detach().cpu()),
"actor/response_tokens": float(total_tokens / max(1, len(rollouts))),
},
)
def _grpo_logprob_batch(
self,
rollouts: Sequence[Mapping[str, Any]],
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor | None]:
current_logprob_rows: list[torch.Tensor] = []
old_logprob_rows: list[torch.Tensor] = []
mask_rows: list[torch.Tensor] = []
reference_rows: list[torch.Tensor] = []
has_reference = False
for rollout in rollouts:
response = str(rollout.get("response", "") or "")
current_logprobs, default_mask = self.response_logprobs_from_messages(
self.build_messages(rollout),
response,
)
expected_len = int(current_logprobs.numel())
old_logprobs = coerce_logprob_vector(
rollout.get("old_logprobs", rollout.get("old_logprob")),
expected_len=expected_len,
device=self.device,
name="old_logprobs",
)
if old_logprobs is None:
old_logprobs = current_logprobs.detach()
response_mask = coerce_response_mask(
rollout.get("response_mask"),
expected_len=expected_len,
device=self.device,
)
if response_mask is None:
response_mask = default_mask
reference_logprobs = coerce_logprob_vector(
rollout.get("reference_logprobs", rollout.get("ref_logprobs")),
expected_len=expected_len,
device=self.device,
name="reference_logprobs",
)
current_logprob_rows.append(current_logprobs)
old_logprob_rows.append(old_logprobs)
mask_rows.append(response_mask)
if reference_logprobs is None:
reference_rows.append(current_logprobs.detach())
else:
has_reference = True
reference_rows.append(reference_logprobs)
return (
pad_1d_tensors(current_logprob_rows),
pad_1d_tensors(old_logprob_rows),
pad_1d_tensors(mask_rows),
pad_1d_tensors(reference_rows) if has_reference else None,
)
def compute_interleave_sft_loss(
self,
batch: Mapping[str, Any],
@@ -687,6 +823,21 @@ def pad_1d_tensors(
return padded
def _weighted_result_mean(
results: Sequence[Any],
field: str,
weights: Sequence[float],
device: torch.device,
total_weight: float,
) -> torch.Tensor:
if not results:
return torch.zeros((), device=device)
values = []
for result, weight in zip(results, weights, strict=True):
values.append(getattr(result, field).detach() * float(weight))
return torch.stack(values).sum() / float(total_weight)
def first_string(
item: Mapping[str, Any],
*keys: str,
@@ -11,6 +11,7 @@ from fastvideo.train.models.interleave_thinker import (
load_critic_rl_records,
load_critic_sft_records,
load_interleave_dataset,
load_planner_rl_records,
load_planner_sft_records,
normalize_critic_rl_record,
resolve_interleave_image_path,
@@ -44,6 +45,27 @@ def test_load_planner_sft_records_normalizes_sharegpt_images(tmp_path: Path) ->
assert records[0]["instruction"] == "draw a cat step by step"
assert records[0]["response"] == '<answer>{"execution_plan": []}</answer>'
assert records[0]["images"] == [str(image_dir / "planner/cat_step.png")]
assert records[0]["input_image_paths"] == [str(image_dir / "planner/cat_step.png")]
def test_load_planner_rl_records_accepts_prompt_only_rows(tmp_path: Path) -> None:
image_dir = tmp_path / "images"
data_path = tmp_path / "planner_rl.jsonl"
data_path.write_text(
json.dumps({
"text_input": "draw a cat in three clear steps",
"images": ["planner/start.png"],
"plan_score": 0.75,
}) + "\n",
encoding="utf-8",
)
records = load_planner_rl_records(data_path, image_dir=image_dir)
assert records[0]["instruction"] == "draw a cat in three clear steps"
assert records[0]["images"] == [str(image_dir / "planner/start.png")]
assert records[0]["input_image_paths"] == [str(image_dir / "planner/start.png")]
assert records[0]["plan_score"] == 0.75
def test_load_critic_sft_records_adds_image_pair_aliases(tmp_path: Path) -> None:
@@ -38,13 +38,15 @@ class _FakeProcessor:
):
del tokenize, add_generation_prompt, return_dict, return_tensors
assert messages[0]["role"] == "user"
has_assistant = any(message["role"] == "assistant" for message in messages)
length = 5 if has_assistant else 3
return {
"input_ids": torch.arange(1, 4).unsqueeze(0),
"attention_mask": torch.ones(1, 3, dtype=torch.long),
"input_ids": torch.arange(1, length + 1).unsqueeze(0),
"attention_mask": torch.ones(1, length, dtype=torch.long),
}
def batch_decode(self, sequences, **kwargs):
del sequences, kwargs
del kwargs
return [
"""
<think>plan</think>
@@ -54,6 +56,7 @@ class _FakeProcessor:
]}
</answer>
"""
for _ in sequences
]
@@ -67,8 +70,23 @@ class _FakeQwen(torch.nn.Module):
def generate(self, **kwargs):
self.generate_kwargs = kwargs
input_ids = kwargs["input_ids"]
num_return_sequences = int(kwargs["num_return_sequences"])
suffix = torch.tensor([[9, 10]], dtype=input_ids.dtype)
return torch.cat([input_ids, suffix], dim=1)
return torch.cat([input_ids.repeat(num_return_sequences, 1), suffix.repeat(num_return_sequences, 1)], dim=1)
def forward(self, **kwargs):
input_ids = kwargs["input_ids"]
vocab_size = max(16, int(input_ids.max().detach().cpu()) + 1)
logits = torch.zeros(
*input_ids.shape,
vocab_size,
dtype=self.weight.dtype,
device=input_ids.device,
)
for idx in range(input_ids.shape[1] - 1):
next_token = input_ids[:, idx + 1]
logits[:, idx].scatter_(1, next_token[:, None], self.weight.expand(input_ids.shape[0], 1))
return SimpleNamespace(logits=logits)
def test_extract_interleave_plan_accepts_json_answer_block():
@@ -168,6 +186,32 @@ def test_interleave_thinker_planner_fake_backend_generates_parseable_plan():
assert model.transformer.generate_kwargs["num_return_sequences"] == 1
def test_interleave_thinker_planner_fake_backend_generates_rl_rollouts():
model = _FakeBackendPlanner(load_backend=False)
model.processor = _FakeProcessor()
model.transformer = _FakeQwen()
rollouts = model.generate_interleave_responses(
{
"items": [{
"instruction": "Show how to draw a cat step by step."
}]
},
num_generations=2,
temperature=0.7,
top_p=0.9,
max_new_tokens=12,
)
assert len(rollouts) == 2
assert all(rollout["plan"] is not None for rollout in rollouts)
assert rollouts[0]["group_key"] == rollouts[1]["group_key"]
assert rollouts[0]["group_key"] == "Show how to draw a cat step by step."
assert len(rollouts[0]["old_logprobs"]) == 2
assert rollouts[0]["response_mask"] == [1.0, 1.0]
assert model.transformer.generate_kwargs["num_return_sequences"] == 2
def test_interleave_thinker_planner_config_parses_public_yaml():
cfg = load_run_config("examples/train/configs/interleave_thinker/planner_smoke.yaml")
@@ -176,3 +220,20 @@ def test_interleave_thinker_planner_config_parses_public_yaml():
assert cfg.models["student"]["init_from"] == "InterleaveThinker/InterleaveThinker-Planner-8B"
assert cfg.models["student"]["processor_from"] == "Qwen/Qwen3-VL-8B-Instruct"
assert cfg.models["student"]["trainable"] is False
def test_interleave_thinker_planner_grpo_config_parses_public_yaml():
cfg = load_run_config("examples/train/configs/rl/interleave_thinker/planner_grpo.yaml")
assert cfg.models["student"]["_target_"] == (
"fastvideo.train.models.interleave_thinker.InterleaveThinkerPlannerModel")
assert cfg.models["student"]["dataset_kind"] == "planner_rl"
assert cfg.models["student"]["lora"]["enable"] is True
assert cfg.models["reference"]["_target_"] == (
"fastvideo.train.models.interleave_thinker.InterleaveThinkerPlannerModel")
assert cfg.models["reference"]["trainable"] is False
assert cfg.method["_target_"] == "fastvideo.train.methods.rl.interleave_thinker.InterleaveThinkerRLMethod"
assert cfg.method["reward_scorer"]["_target_"] == (
"fastvideo.train.methods.rl.rewards.InterleavePlannerRewardScorer")
assert cfg.method["kl_coef"] == 0.01
assert cfg.training.data.data_path.endswith("planner_rl.jsonl")
@@ -1,10 +1,14 @@
import pytest
from fastvideo.train.methods.rl.rewards import (
InterleavePlannerRewardScorer,
InterleaveThinkerEditScore,
InterleaveThinkerRewardScorer,
extract_interleave_answer,
extract_interleave_plan_payload,
interleave_format_reward,
interleave_planner_format_reward,
score_interleave_planner_rewards,
score_interleave_thinker_rewards,
)
@@ -96,3 +100,43 @@ def test_reward_scorer_uses_injected_edit_scorer_and_json_string_ground_truth():
"success": True
},
}])[0].overall >= 0.0
def _planner_response(prompt="a clean cat sketch"):
prompt = prompt.replace('"', '\\"')
return f"""
<think>Plan the sequence.</think>
<answer>
{{"execution_plan": [
{{"step_number": 1, "step_name": "Sketch", "instruction": "Draw a cat", "prompt": "{prompt}", "auxiliary_text": null}}
]}}
</answer>
"""
def test_planner_reward_accepts_execution_plan_answer_block():
payload = extract_interleave_plan_payload(_planner_response())
assert payload is not None
assert interleave_planner_format_reward(_planner_response()) == 1.0
assert interleave_planner_format_reward("<answer>{}</answer>") == 0.0
def test_planner_reward_scorer_blends_format_and_scalar_plan_score():
scorer = InterleavePlannerRewardScorer(format_weight=0.25, fallback_plan_reward=0.1)
result = scorer([{
"response": _planner_response(),
"plan_score": 0.9,
}])[0]
wrapped = score_interleave_planner_rewards([{
"response": _planner_response(),
"ground_truth": {
"score": 0.5
},
}], format_weight=0.5)[0]
assert result.format_reward == 1.0
assert result.planner_score == pytest.approx(0.9)
assert result.overall == pytest.approx(0.25 * 1.0 + 0.75 * 0.9)
assert wrapped["overall"] == pytest.approx(0.5 * 1.0 + 0.5 * 0.5)