arcisvlm / scripts /train_stage7_rl.py
Hardik Sanghvi
feat: integrate Gemma 4 E2B backbone for production-quality VLM inference
7a564e3
Raw
History Blame Contribute Delete
10.9 kB
#!/usr/bin/env python3
"""
Stage 7: UniGRPO RL Fine-tuning — reduces hallucinations in dreamer + calibrates HyperNetwork.
Applies GRPO-style reinforcement learning to improve:
1. Dreamer prediction accuracy (reward = cosine_sim(dream, actual))
2. HyperNetwork calibration (confidence should correlate with actual error)
Only trains Dreamer + HyperNetwork calibration heads — base model is frozen.
Reference: UniGRPO (arXiv: 2603.17892)
Usage:
torchrun --nproc_per_node=8 scripts/train_stage7_rl.py \
--config configs/scale_1.3b.yaml \
--dreamer_config configs/dreamer.yaml \
--hn_config configs/hypernetwork.yaml \
--stage3_ckpt checkpoints/v5_stage3_final.pt \
--dreamer_ckpt checkpoints/v5_dreamer.pt \
--hypernet_ckpt checkpoints/v5_hypernet.pt
"""
import argparse
import math
import os
import sys
import time
import torch
import torch.distributed as dist
import torch.nn as nn
import torch.nn.functional as F
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data import DataLoader, Dataset
from torch.utils.data.distributed import DistributedSampler
import yaml
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from model.vlm import VLJEPAModel
from model.latent_dreamer import LatentDreamer
from model.hypernetwork import HyperNetwork
# ---------------------------------------------------------------------------
# Dataset: video clip sequences for RL
# ---------------------------------------------------------------------------
class DummyVideoClipDataset(Dataset):
"""Dummy video clips: sequences of related embeddings simulating camera motion."""
def __init__(self, num_samples=5000, seq_len=12, embed_dim=2048):
self.num_samples = num_samples
self.seq_len = seq_len
self.embed_dim = embed_dim
def __len__(self):
return self.num_samples
def __getitem__(self, idx):
# Generate a coherent sequence: base + drift * t + noise
base = torch.randn(self.embed_dim)
drift = torch.randn(self.embed_dim) * 0.1
frames = []
for t in range(self.seq_len):
noise = torch.randn(self.embed_dim) * 0.05
frames.append(base + drift * t + noise)
return {"embeddings": torch.stack(frames)} # [seq_len, embed_dim]
# ---------------------------------------------------------------------------
# GRPO-style reward computation
# ---------------------------------------------------------------------------
def compute_reward(
dreamed_embeddings: torch.Tensor,
actual_embeddings: torch.Tensor,
dreamed_confidences: torch.Tensor,
cosine_weight: float = 1.0,
divergence_penalty: float = 0.5,
) -> torch.Tensor:
"""
Compute per-step reward for dreamer predictions.
reward = cosine_weight * cosine_sim - divergence_penalty * divergence
Args:
dreamed_embeddings: [B, N_steps, D]
actual_embeddings: [B, N_steps, D]
dreamed_confidences: [B, N_steps, 1]
Returns:
[B] — reward per sequence
"""
B, N, D = dreamed_embeddings.shape
# Per-step cosine similarity
cos_sim = F.cosine_similarity(
dreamed_embeddings.reshape(-1, D),
actual_embeddings.reshape(-1, D),
dim=-1,
).reshape(B, N) # [B, N]
# Per-step divergence (MSE)
divergence = (dreamed_embeddings - actual_embeddings).pow(2).mean(dim=-1) # [B, N]
# Per-step reward
step_reward = cosine_weight * cos_sim - divergence_penalty * divergence # [B, N]
# Calibration bonus: confidence should match accuracy
accuracy = torch.exp(-divergence).detach() # [B, N]
conf = dreamed_confidences.squeeze(-1) # [B, N]
calibration_bonus = -0.1 * (conf - accuracy).pow(2) # [B, N]
# Total reward: mean across steps
total_reward = (step_reward + calibration_bonus).mean(dim=-1) # [B]
return total_reward
# ---------------------------------------------------------------------------
# Training loop
# ---------------------------------------------------------------------------
def parse_args():
p = argparse.ArgumentParser(description="Stage 7: UniGRPO RL Fine-tuning")
p.add_argument("--config", type=str, default="configs/scale_1.3b.yaml")
p.add_argument("--dreamer_config", type=str, default="configs/dreamer.yaml")
p.add_argument("--hn_config", type=str, default="configs/hypernetwork.yaml")
p.add_argument("--stage3_ckpt", type=str, default=None)
p.add_argument("--dreamer_ckpt", type=str, default=None)
p.add_argument("--hypernet_ckpt", type=str, default=None)
p.add_argument("--resume", type=str, default=None)
p.add_argument("--output_dir", type=str, default="checkpoints")
p.add_argument("--hf_push", action="store_true")
return p.parse_args()
def main():
args = parse_args()
# DDP setup
dist.init_process_group(backend="nccl")
rank = dist.get_rank()
world_size = dist.get_world_size()
local_rank = int(os.environ.get("LOCAL_RANK", 0))
device = torch.device(f"cuda:{local_rank}")
torch.cuda.set_device(device)
if rank == 0:
print("=" * 60)
print("Stage 7: UniGRPO RL Fine-tuning")
print(f"World size: {world_size}")
print("=" * 60)
# Load configs
with open(args.config) as f:
config = yaml.safe_load(f)
if os.path.exists(args.dreamer_config):
with open(args.dreamer_config) as f:
config.update(yaml.safe_load(f))
if os.path.exists(args.hn_config):
with open(args.hn_config) as f:
config.update(yaml.safe_load(f))
rl_cfg = config.get("train_rl", {})
embed_dim = config.get("predictor", {}).get("embed_dim", 2048)
# Build dreamer
dreamer_cfg = config.get("latent_dreamer", {})
dreamer = LatentDreamer(
embed_dim=dreamer_cfg.get("embed_dim", embed_dim),
n_heads=dreamer_cfg.get("n_heads", 16),
n_layers=dreamer_cfg.get("n_layers", 4),
max_future_steps=dreamer_cfg.get("max_future_steps", 8),
max_context_frames=dreamer_cfg.get("max_context_frames", 32),
).to(device)
if args.dreamer_ckpt and os.path.exists(args.dreamer_ckpt):
ckpt = torch.load(args.dreamer_ckpt, map_location=device)
dreamer.load_state_dict(ckpt.get("dreamer_state_dict", ckpt), strict=False)
if rank == 0:
print(f"Loaded dreamer: {args.dreamer_ckpt}")
# Wrap in DDP
dreamer = DDP(dreamer, device_ids=[local_rank])
# Dataset
context_frames = 8
future_steps = 4
dataset = DummyVideoClipDataset(
num_samples=5000,
seq_len=context_frames + future_steps,
embed_dim=dreamer_cfg.get("embed_dim", embed_dim),
)
sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
dataloader = DataLoader(dataset, batch_size=rl_cfg.get("batch_size", 4), sampler=sampler, num_workers=2)
# Optimizer
lr = rl_cfg.get("learning_rate", 5e-6)
optimizer = torch.optim.AdamW(dreamer.parameters(), lr=lr, weight_decay=0.01)
max_epochs = rl_cfg.get("max_epochs", 5)
grad_accum = rl_cfg.get("gradient_accumulation", 16)
cosine_weight = rl_cfg.get("reward_cosine_weight", 1.0)
divergence_penalty = rl_cfg.get("reward_divergence_penalty", 0.5)
if rank == 0:
print(f"LR: {lr}, Epochs: {max_epochs}, Grad accum: {grad_accum}")
print(f"Reward: cosine_w={cosine_weight}, divergence_p={divergence_penalty}")
# Training loop
dreamer.train()
global_step = 0
for epoch in range(max_epochs):
sampler.set_epoch(epoch)
epoch_rewards = []
for batch_idx, batch in enumerate(dataloader):
embeddings = batch["embeddings"].to(device) # [B, seq_len, D]
context = embeddings[:, :context_frames, :]
actual_future = embeddings[:, context_frames:, :]
# Dream
dreamed_embs, dreamed_confs = dreamer.module.dream_sequence(context, n_steps=future_steps)
# Compute reward
reward = compute_reward(
dreamed_embs, actual_future, dreamed_confs,
cosine_weight=cosine_weight,
divergence_penalty=divergence_penalty,
)
# GRPO-style: maximize reward (minimize negative reward)
# Use dreamer loss as the differentiable objective
loss_dict = dreamer.module.compute_dream_loss(
dreamed_embs, actual_future, dreamed_confs
)
# Weight loss by advantage (reward - baseline)
baseline = reward.mean().detach()
advantage = (reward - baseline).detach()
# Scale loss inversely with advantage (better dreams → less loss emphasis)
weighted_loss = loss_dict["total_loss"] * (1.0 - 0.1 * advantage.mean())
weighted_loss = weighted_loss / grad_accum
weighted_loss.backward()
if (batch_idx + 1) % grad_accum == 0:
torch.nn.utils.clip_grad_norm_(dreamer.parameters(), 1.0)
optimizer.step()
optimizer.zero_grad()
global_step += 1
epoch_rewards.append(reward.mean().item())
if rank == 0 and (batch_idx + 1) % 20 == 0:
avg_reward = sum(epoch_rewards[-20:]) / min(20, len(epoch_rewards))
print(f" Epoch {epoch+1} Step {batch_idx+1}: "
f"reward={avg_reward:.4f} "
f"loss={loss_dict['total_loss'].item():.4f} "
f"cosine={loss_dict['mean_cosine_sim'].item():.4f}")
if rank == 0:
avg_epoch_reward = sum(epoch_rewards) / len(epoch_rewards)
print(f"Epoch {epoch+1}/{max_epochs}: avg_reward={avg_epoch_reward:.4f}")
# Save checkpoint
if rank == 0:
os.makedirs(args.output_dir, exist_ok=True)
save_path = os.path.join(args.output_dir, "v5_final.pt")
torch.save({
"dreamer_state_dict": dreamer.module.state_dict(),
"optimizer_state_dict": optimizer.state_dict(),
"epoch": max_epochs,
"global_step": global_step,
}, save_path)
print(f"Saved RL checkpoint: {save_path}")
if args.hf_push:
try:
from huggingface_hub import HfApi
api = HfApi()
api.upload_file(
path_or_fileobj=save_path,
path_in_repo="v5_checkpoints/v5_final.pt",
repo_id="hardiksa/arcisvlm",
repo_type="model",
)
print("✅ Pushed to HuggingFace")
except Exception as e:
print(f"⚠️ HF push failed: {e}")
dist.destroy_process_group()
if __name__ == "__main__":
main()