Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

352 lines
11 KiB
Python

#!/usr/bin/env python3
"""InfoNCE training loop with LoRA fine-tuning, checkpointing, and wandb logging.
Usage:
uv run python -m training.train --gpu-id 2
Resume from checkpoint:
uv run python -m training.train --gpu-id 2 --resume training/checkpoints/run_001/step_200.pt
"""
import argparse
import logging
import signal
import threading
import time
from pathlib import Path
import torch
import torch.nn.functional as F
from torch.nn.utils import clip_grad_norm_
from torch.utils.data import DataLoader
from transformers import (
get_constant_schedule_with_warmup,
get_cosine_schedule_with_warmup,
)
from training.dataset import QueryChunkDataset, make_collate_fn
from training.evaluate import run_eval
from training.model import load_model_for_training, load_processor, pool_and_normalize
import sys
LOG_PATH = Path("training/train.log")
# Direct file writer that bypasses wandb's stdout/stderr capture
_log_fd = None
def _log(msg: str):
"""Write log line directly to file descriptor (wandb-proof)."""
global _log_fd
if _log_fd is None:
_log_fd = open(LOG_PATH, "w", buffering=1) # line-buffered
ts = time.strftime("%Y-%m-%d %H:%M:%S")
_log_fd.write(f"{ts} {msg}\n")
_log_fd.flush()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
stream=sys.stdout,
)
logger = logging.getLogger(__name__)
def info_nce_loss(
q_emb: torch.Tensor, i_emb: torch.Tensor, temperature: float = 0.07
) -> torch.Tensor:
"""In-batch negatives InfoNCE loss.
q_emb: (B, D) L2-normalized query embeddings
i_emb: (B, D) L2-normalized image embeddings
Diagonal entries are positive pairs.
"""
logits = q_emb @ i_emb.T / temperature # (B, B)
labels = torch.arange(logits.size(0), device=logits.device)
return F.cross_entropy(logits, labels)
def save_checkpoint(
model, optimizer, scheduler, step, config, best_recall_10=0.0, loss_history=None
):
"""Save LoRA weights + optimizer/scheduler state."""
ckpt_dir = Path(config.checkpoint_dir) / config.run_name
ckpt_dir.mkdir(parents=True, exist_ok=True)
path = ckpt_dir / f"step_{step}.pt"
torch.save(
{
"step": step,
"model_state_dict": {k: v.cpu() for k, v in model.state_dict().items()},
"optimizer_state_dict": optimizer.state_dict(),
"scheduler_state_dict": scheduler.state_dict(),
"best_recall_10": best_recall_10,
"loss_history": loss_history or [],
"config": vars(config),
},
path,
)
_log(f"Checkpoint saved: {path}")
def train(config):
import wandb
device = f"cuda:{config.gpu_id}"
# wandb — disable console capture so logger output reaches stdout
wandb.init(
project="wiki-embedding",
name=config.run_name,
config=vars(config),
settings=wandb.Settings(console="off"),
)
# Model
_log(f"Loading model {config.model} on GPU {config.gpu_id}...")
model = load_model_for_training(
config.model,
config.gpu_id,
lora_r=config.lora_r,
lora_alpha=config.lora_alpha,
)
processor = load_processor(
config.model, min_pixels=config.min_pixels, max_pixels=config.max_pixels
)
# Data
dataset = QueryChunkDataset(config.train_jsonl)
collate_fn = make_collate_fn(processor, device=device)
loader = DataLoader(
dataset,
batch_size=config.batch_size,
shuffle=True,
num_workers=0,
collate_fn=collate_fn,
)
# Optimizer + scheduler
optimizer = torch.optim.AdamW(model.parameters(), lr=config.lr, weight_decay=0.01)
if config.scheduler == "cosine":
scheduler = get_cosine_schedule_with_warmup(
optimizer,
num_warmup_steps=config.warmup_steps,
num_training_steps=config.max_steps,
)
else:
scheduler = get_constant_schedule_with_warmup(
optimizer,
num_warmup_steps=config.warmup_steps,
)
# Resume
start_step = 0
best_recall_10 = 0.0
loss_history = []
if config.resume:
_log(f"Resuming from {config.resume}")
ckpt = torch.load(config.resume, map_location=device, weights_only=False)
model.load_state_dict(ckpt["model_state_dict"], strict=False)
optimizer.load_state_dict(ckpt["optimizer_state_dict"])
scheduler.load_state_dict(ckpt["scheduler_state_dict"])
start_step = ckpt["step"]
best_recall_10 = ckpt.get("best_recall_10", 0.0)
loss_history = ckpt.get("loss_history", [])
_log(f"Resumed from step {start_step}")
# Graceful shutdown
shutdown = threading.Event()
original_sigterm = signal.getsignal(signal.SIGTERM)
original_sigint = signal.getsignal(signal.SIGINT)
def _shutdown_handler(signum, frame):
_log(f"Received signal {signum}, shutting down gracefully...")
shutdown.set()
signal.signal(signal.SIGTERM, _shutdown_handler)
signal.signal(signal.SIGINT, _shutdown_handler)
# Train loop
step = start_step
model.train()
skipped_batches = 0
_log(f"Starting training from step {step}, max_steps={config.max_steps}")
try:
while step < config.max_steps:
for batch in loader:
if shutdown.is_set():
_log("Shutdown requested, saving checkpoint...")
save_checkpoint(
model,
optimizer,
scheduler,
step,
config,
best_recall_10,
loss_history,
)
wandb.finish()
return
if batch is None:
skipped_batches += 1
wandb.log({"skipped_batches": skipped_batches}, step=step)
continue
t0 = time.time()
try:
q_inputs, i_inputs = batch
# Forward: query embeddings
q_out = model(**q_inputs, output_hidden_states=True)
q_emb = pool_and_normalize(
q_out.hidden_states[-1], q_inputs["attention_mask"]
)
# Forward: image embeddings
i_out = model(**i_inputs, output_hidden_states=True)
i_emb = pool_and_normalize(
i_out.hidden_states[-1], i_inputs["attention_mask"]
)
loss = info_nce_loss(q_emb, i_emb, temperature=config.temperature)
loss.backward()
clip_grad_norm_(model.parameters(), 1.0)
optimizer.step()
scheduler.step()
optimizer.zero_grad()
except torch.cuda.OutOfMemoryError:
_log(f"WARNING OOM at step {step}, skipping batch")
torch.cuda.empty_cache()
optimizer.zero_grad()
skipped_batches += 1
wandb.log({"skipped_batches": skipped_batches}, step=step)
continue
dt = time.time() - t0
step += 1
loss_val = loss.item()
loss_history.append(loss_val)
lr = scheduler.get_last_lr()[0]
gpu_mem = torch.cuda.memory_allocated(config.gpu_id) / 1e9
wandb.log(
{
"loss": loss_val,
"lr": lr,
"batch_time": dt,
"pairs_per_sec": config.batch_size / dt,
"gpu_mem_gb": gpu_mem,
},
step=step,
)
_log(
f"step={step} loss={loss_val:.4f} lr={lr:.2e} "
f"batch_time={dt:.1f}s pairs/s={config.batch_size / dt:.1f} "
f"gpu_mem={gpu_mem:.1f}GB"
)
# Eval
if step % config.eval_every == 0:
r1, r10, mrr = run_eval(
model,
processor,
config.eval_jsonl,
device,
batch_size=config.batch_size,
)
wandb.log(
{
"recall@1": r1,
"recall@10": r10,
"mrr": mrr,
},
step=step,
)
_log(
f"eval step={step} recall@1={r1:.3f} recall@10={r10:.3f} mrr={mrr:.3f}"
)
if r10 > best_recall_10:
best_recall_10 = r10
# Checkpoint
if step % config.save_every == 0:
save_checkpoint(
model,
optimizer,
scheduler,
step,
config,
best_recall_10,
loss_history,
)
if step >= config.max_steps:
break
finally:
# Restore signal handlers
signal.signal(signal.SIGTERM, original_sigterm)
signal.signal(signal.SIGINT, original_sigint)
# Final checkpoint
save_checkpoint(
model, optimizer, scheduler, step, config, best_recall_10, loss_history
)
wandb.finish()
_log("Training complete!")
def main():
parser = argparse.ArgumentParser(
description="LoRA fine-tuning for Qwen3-VL embeddings"
)
parser.add_argument("--model", default="Qwen/Qwen3-VL-Embedding-2B")
parser.add_argument("--gpu-id", type=int, default=2)
parser.add_argument("--train-jsonl", default="training/data/train.jsonl")
parser.add_argument("--eval-jsonl", default="training/data/eval.jsonl")
parser.add_argument("--checkpoint-dir", default="training/checkpoints")
parser.add_argument("--run-name", default="run_001")
parser.add_argument("--resume", type=str, default=None)
parser.add_argument("--batch-size", type=int, default=4)
parser.add_argument("--lr", type=float, default=2e-5)
parser.add_argument(
"--warmup-steps",
type=int,
default=None,
help="Warmup steps (default: 5% of max_steps)",
)
parser.add_argument("--max-steps", type=int, default=500)
parser.add_argument(
"--scheduler", choices=["cosine", "constant"], default="constant"
)
parser.add_argument("--temperature", type=float, default=0.07)
parser.add_argument("--eval-every", type=int, default=100)
parser.add_argument("--save-every", type=int, default=100)
parser.add_argument("--lora-r", type=int, default=32)
parser.add_argument("--lora-alpha", type=int, default=32)
parser.add_argument(
"--min-pixels",
type=int,
default=128 * 28 * 28,
help="Min image pixels for processor (default: 128*28*28)",
)
parser.add_argument(
"--max-pixels",
type=int,
default=256 * 28 * 28,
help="Max image pixels for processor (default: 256*28*28)",
)
config = parser.parse_args()
if config.warmup_steps is None:
config.warmup_steps = max(1, (config.max_steps + 19) // 20)
train(config)
if __name__ == "__main__":
main()