VLAlert / training /pretrain /trainer_v2.py
AsianPlayer's picture
Add VLAlert code
1e05592 verified
Raw
History Blame Contribute Delete
26.9 kB
#!/usr/bin/env python3
"""
改进的多任务训练器(修正版)
修复/增强点:
1) Scheduler/ warmup 步数按 optimizer update steps(考虑 gradient_accumulation_steps)
2) eval_steps / save_steps 按 global_step(update step) 触发,而不是按 epoch
3) 支持最后不足 accumulation 的尾 batch 也执行一次 optimizer.step()
4) 支持 AMP(bf16 / fp16),fp16 使用 GradScaler
5) optimizer 仅包含 requires_grad=True 参数(适配 LoRA)
6) 训练/验证指标写入 jsonl,便于可视化与复现实验
7) 尝试启用 use_reentrant=False 的 gradient checkpointing(更推荐的实现)
"""
import os
import math
import json
from dataclasses import asdict
from datetime import datetime
from pathlib import Path
from contextlib import nullcontext
import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader
from transformers import get_linear_schedule_with_warmup
from tqdm import tqdm
import numpy as np
try:
import wandb
except Exception:
wandb = None
from model_loader import (
load_model_and_processor,
prepare_model_inputs
)
from config import PretrainConfig
class FocalLoss(nn.Module):
"""
Focal Loss for accident detection (保留,当前训练主损失仍使用 outputs.loss)
"""
def __init__(self, alpha=0.25, gamma=2.0):
super().__init__()
self.alpha = alpha
self.gamma = gamma
def forward(self, inputs, targets):
ce_loss = F.cross_entropy(inputs, targets, reduction='none')
p_t = torch.exp(-ce_loss)
focal_loss = self.alpha * (1 - p_t) ** self.gamma * ce_loss
return focal_loss.mean()
class MultiTaskTrainer:
"""
多任务训练器
- 支持单帧任务 + 序列任务(取决于 batch 是否包含 "single_frame"/"sequence")
- 使用 LM loss (outputs.loss) 作为主要训练信号
"""
def __init__(
self,
config: PretrainConfig,
train_loader: DataLoader,
val_loader: DataLoader,
pretrained_lora_path: str | None = None,
):
self.config = config
self.train_loader = train_loader
self.val_loader = val_loader
self.pretrained_lora_path = pretrained_lora_path
# 设备
self.device = torch.device(config.training.device)
print(f"使用设备: {self.device}")
# 训练统计
self.global_step = 0 # optimizer update steps
self.best_val_loss = float("inf")
# 输出目录
self.output_dir = Path(self.config.training.output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
# 日志落盘(jsonl)
self.train_log_path = self.output_dir / "train_metrics.jsonl"
self.val_log_path = self.output_dir / "val_metrics.jsonl"
# Loss functions(保留)
self.ce_loss = nn.CrossEntropyLoss()
self.focal_loss = FocalLoss(alpha=0.25, gamma=2.0)
# Task weights(你的事故任务 + 兼容 bdd_* 默认 1.0)
self.task_weights = {
"scene_understanding": self.config.data.task1_weight,
"binary_detection": self.config.data.task2_weight,
"accident_description": self.config.data.task3_weight,
"sequence_prediction": self.config.data.task3_weight,
# bdd_attributes / bdd_detection / bdd_drivable / bdd_risk 等,缺省走 1.0
}
# Curriculum state(保留:注意你目前并未实际重建 dataloader)
self.current_stage = 0
self.stage_epochs = [1, 2, 2]
# AMP 配置
self.use_bf16 = bool(getattr(self.config.training, "bf16", False))
self.use_fp16 = bool(getattr(self.config.training, "fp16", False)) and (not self.use_bf16)
self.autocast_dtype = torch.bfloat16 if self.use_bf16 else (torch.float16 if self.use_fp16 else None)
self.scaler = torch.cuda.amp.GradScaler(enabled=self.use_fp16)
# 初始化
self._init_model()
self._init_optimizer()
# wandb
if self.config.training.use_wandb:
if wandb is None:
print("⚠️ wandb 未安装或不可用,但 use_wandb=True;将跳过 wandb 记录。")
self.config.training.use_wandb = False
else:
wandb.init(
project=self.config.training.wandb_project,
name=self.config.training.wandb_run_name or f"{self.config.model.model_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
config=asdict(self.config),
)
# ------------------------- utils -------------------------
def _autocast_ctx(self):
if self.autocast_dtype is None:
return nullcontext()
return torch.cuda.amp.autocast(dtype=self.autocast_dtype)
def _write_jsonl(self, path: Path, record: dict):
record = dict(record)
record.setdefault("time", datetime.now().isoformat(timespec="seconds"))
with open(path, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
def _move_batch_to_device(self, inputs: dict):
"""
将 processor 输出的 BatchFeature/dict 移动到 device,并对浮点张量对齐到模型 dtype。
"""
moved = {}
for k, v in inputs.items():
if torch.is_tensor(v):
if v.is_floating_point():
moved[k] = v.to(self.device, dtype=self.model.dtype)
else:
moved[k] = v.to(self.device)
else:
moved[k] = v
return moved
# ------------------------- init model/optim -------------------------
def _init_model(self):
print("=" * 60)
print("加载模型...")
self.model, self.processor = load_model_and_processor(self.config.model)
# 可选:Stage B 加载 Stage A 的 LoRA adapter 继续训练
if self.pretrained_lora_path:
try:
from peft import PeftModel
self.model = PeftModel.from_pretrained(
self.model,
self.pretrained_lora_path,
is_trainable=True
)
print(f"✓ 已加载LoRA权重: {self.pretrained_lora_path}")
except Exception as e:
print(f"⚠️ 加载LoRA权重失败(将继续使用当前模型的LoRA状态): {e}")
self.model.to(self.device)
# tokenizer pad_token
if self.processor.tokenizer.pad_token is None:
self.processor.tokenizer.pad_token = self.processor.tokenizer.eos_token
self.processor.tokenizer.pad_token_id = self.processor.tokenizer.eos_token_id
# 尽量启用更推荐的 checkpoint 实现(避免 use_reentrant 默认变化/限制)
if hasattr(self.model, "gradient_checkpointing_enable"):
try:
self.model.gradient_checkpointing_enable(
gradient_checkpointing_kwargs={"use_reentrant": False}
)
except TypeError:
# 旧版本 transformers/pytorch 不支持 kwargs
try:
self.model.gradient_checkpointing_enable()
except Exception:
pass
try:
if hasattr(self.model, "config"):
self.model.config.use_cache = False
except Exception:
pass
print(f"✓ 模型加载完成: {self.config.model.model_name}")
print("=" * 60)
def _init_optimizer(self):
# 只优化 requires_grad=True 的参数(LoRA 正常情况下只有 adapter 参数是 True)
trainable_params = [p for p in self.model.parameters() if p.requires_grad]
if len(trainable_params) == 0:
raise RuntimeError("没有可训练参数(requires_grad=True 为 0)。请检查 LoRA/冻结策略。")
self.optimizer = torch.optim.AdamW(
trainable_params,
lr=self.config.training.learning_rate,
weight_decay=self.config.training.weight_decay
)
# Scheduler:关键修复 —— total steps 必须按 optimizer update steps 计算
grad_acc = max(1, int(self.config.training.gradient_accumulation_steps))
if len(self.train_loader) == 0:
raise RuntimeError("train_loader 为空(len=0),请检查数据是否正确生成。")
updates_per_epoch = math.ceil(len(self.train_loader) / grad_acc)
num_training_steps = updates_per_epoch * int(self.config.training.num_epochs)
num_warmup_steps = int(num_training_steps * float(self.config.training.warmup_ratio))
self.scheduler = get_linear_schedule_with_warmup(
self.optimizer,
num_warmup_steps=num_warmup_steps,
num_training_steps=num_training_steps
)
print("✓ 优化器初始化完成")
print(f" batches/epoch: {len(self.train_loader)}")
print(f" grad_accum: {grad_acc}")
print(f" updates/epoch: {updates_per_epoch}")
print(f" total update steps: {num_training_steps}")
print(f" warmup steps: {num_warmup_steps}")
# ------------------------- label construction helpers -------------------------
def _concat_answers_to_prompt_inputs(self, prompt_inputs, labels_text):
"""
Fallback:在无法用 processor 重新编码 full_texts 时,把答案 tokens 拼接到 prompt input_ids 后面并构造 labels。
"""
tokenizer = self.processor.tokenizer
pad_id = tokenizer.pad_token_id
eos_id = tokenizer.eos_token_id
input_ids = prompt_inputs["input_ids"]
attention_mask = prompt_inputs["attention_mask"]
if input_ids.dim() != 2 or attention_mask.dim() != 2:
raise ValueError("prompt_inputs 必须包含二维的 input_ids 和 attention_mask")
B, L = input_ids.shape
prompt_lens = attention_mask.sum(dim=1).tolist()
answer_ids_list = [
tokenizer.encode(ans, add_special_tokens=False) + ([eos_id] if eos_id is not None else [])
for ans in labels_text
]
max_full_len = max(int(pl) + len(ans_ids) for pl, ans_ids in zip(prompt_lens, answer_ids_list))
new_input_ids = input_ids.new_full((B, max_full_len), pad_id)
new_attention_mask = attention_mask.new_zeros((B, max_full_len))
new_labels = input_ids.new_full((B, max_full_len), -100)
for i, (pl, ans_ids) in enumerate(zip(prompt_lens, answer_ids_list)):
pl = int(pl)
ans_tensor = torch.tensor(ans_ids, device=input_ids.device, dtype=input_ids.dtype)
seq = torch.cat([input_ids[i, :pl], ans_tensor], dim=0)
seq_len = seq.size(0)
new_input_ids[i, :seq_len] = seq
new_attention_mask[i, :seq_len] = 1
new_labels[i, :seq_len] = seq
new_labels[i, :pl] = -100
out = {}
for k, v in prompt_inputs.items():
if k in ("input_ids", "attention_mask", "labels"):
continue
if torch.is_tensor(v) and v.dim() == 2 and v.shape[0] == B and v.shape[1] == L:
continue
out[k] = v
out["input_ids"] = new_input_ids
out["attention_mask"] = new_attention_mask
out["labels"] = new_labels
return out
def prepare_inputs_and_labels(self, batch_data):
"""
单帧任务:labels 与模型真实 input_ids 对齐(包含视觉 token)
"""
images = batch_data["images"]
user_prompts = batch_data["user_prompts"]
labels_text = batch_data["labels"]
task_types = batch_data["task"]
prompt_inputs = prepare_model_inputs(
self.processor,
self.config.model.model_type,
images,
user_prompts,
self.device
)
prompt_texts = prompt_inputs.pop("__prompt_texts__", None)
if prompt_texts is None:
raise RuntimeError("prepare_model_inputs 未返回 __prompt_texts__,无法构造对齐 labels")
full_texts = [
prompt + answer + self.processor.tokenizer.eos_token
for prompt, answer in zip(prompt_texts, labels_text)
]
try:
prompt_encodings = self.processor(
text=prompt_texts,
images=images,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512
)
full_inputs = self.processor(
text=full_texts,
images=images,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512
)
labels = full_inputs["input_ids"].clone()
for i in range(labels.size(0)):
prompt_len = int(prompt_encodings["attention_mask"][i].sum().item())
labels[i, :prompt_len] = -100
labels[full_inputs["attention_mask"] == 0] = -100
full_inputs["labels"] = labels
full_inputs = self._move_batch_to_device(full_inputs)
return full_inputs, labels_text, task_types
except Exception:
fallback_inputs = self._concat_answers_to_prompt_inputs(prompt_inputs, labels_text)
fallback_inputs = self._move_batch_to_device(fallback_inputs)
return fallback_inputs, labels_text, task_types
def prepare_sequence_inputs_and_labels(self, batch_data):
"""
序列任务:images 是 List[List[PIL]] 或等价格式
"""
images_list = batch_data["image_sequences"]
user_prompts = batch_data["user_prompts"]
labels_text = batch_data["labels"]
task_types = batch_data["task"]
prompt_inputs = prepare_model_inputs(
self.processor,
self.config.model.model_type,
images_list,
user_prompts,
self.device
)
prompt_texts = prompt_inputs.pop("__prompt_texts__", None)
if prompt_texts is None:
raise RuntimeError("prepare_model_inputs 未返回 __prompt_texts__,无法构造对齐 labels")
full_texts = [
prompt + answer + self.processor.tokenizer.eos_token
for prompt, answer in zip(prompt_texts, labels_text)
]
try:
prompt_encodings = self.processor(
text=prompt_texts,
images=images_list,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512
)
full_inputs = self.processor(
text=full_texts,
images=images_list,
return_tensors="pt",
padding=True,
truncation=True,
max_length=512
)
labels = full_inputs["input_ids"].clone()
for i in range(labels.size(0)):
prompt_len = int(prompt_encodings["attention_mask"][i].sum().item())
labels[i, :prompt_len] = -100
labels[full_inputs["attention_mask"] == 0] = -100
full_inputs["labels"] = labels
full_inputs = self._move_batch_to_device(full_inputs)
return full_inputs, labels_text, task_types
except Exception:
fallback_inputs = self._concat_answers_to_prompt_inputs(prompt_inputs, labels_text)
fallback_inputs = self._move_batch_to_device(fallback_inputs)
return fallback_inputs, labels_text, task_types
# ------------------------- loss / forward -------------------------
def compute_loss(self, batch):
"""
计算 batch 总 loss(支持单帧 + 序列)
"""
total_loss = 0.0
task_losses = {}
n_tasks = 0
with self._autocast_ctx():
if "single_frame" in batch:
sf_data = batch["single_frame"]
inputs, _, task_types = self.prepare_inputs_and_labels(sf_data)
outputs = self.model(**inputs)
loss = outputs.loss
task_type = task_types[0]
weight = float(self.task_weights.get(task_type, 1.0))
total_loss = total_loss + (loss * weight)
task_losses[task_type] = float(loss.detach().item())
n_tasks += 1
if "sequence" in batch:
seq_data = batch["sequence"]
inputs, _, task_types = self.prepare_sequence_inputs_and_labels(seq_data)
outputs = self.model(**inputs)
loss = outputs.loss
task_type = task_types[0]
weight = float(self.task_weights.get(task_type, 1.0))
total_loss = total_loss + (loss * weight)
task_losses[task_type] = float(loss.detach().item())
n_tasks += 1
if n_tasks > 0:
total_loss = total_loss / n_tasks
return total_loss, task_losses
# ------------------------- eval / save -------------------------
@torch.no_grad()
def evaluate(self, epoch: int):
self.model.eval()
val_loss_sum = 0.0
val_task_losses = {}
for batch in tqdm(self.val_loader, desc="Validation"):
loss, task_losses = self.compute_loss(batch)
val_loss_sum += float(loss.detach().item())
for t, v in task_losses.items():
val_task_losses.setdefault(t, []).append(v)
val_loss = val_loss_sum / max(1, len(self.val_loader))
avg_task_losses = {t: float(np.mean(vs)) for t, vs in val_task_losses.items()}
record = {"step": self.global_step, "epoch": epoch, "val/loss": float(val_loss)}
for t, v in avg_task_losses.items():
record[f"val/{t}"] = float(v)
self._write_jsonl(self.val_log_path, record)
if self.config.training.use_wandb and wandb is not None:
wandb.log(record, step=self.global_step)
return val_loss, avg_task_losses
def _rotate_checkpoints(self):
limit = int(getattr(self.config.training, "save_total_limit", 0) or 0)
if limit <= 0:
return
ckpts = sorted(
[p for p in self.output_dir.glob("checkpoint-*") if p.is_dir()],
key=lambda p: int(p.name.split("-")[-1]) if p.name.split("-")[-1].isdigit() else 0
)
if len(ckpts) <= limit:
return
for p in ckpts[:-limit]:
try:
for sub in sorted(p.rglob("*"), reverse=True):
if sub.is_file():
sub.unlink()
elif sub.is_dir():
sub.rmdir()
p.rmdir()
except Exception:
pass
def save_checkpoint(self, tag: str, is_best: bool = False):
if is_best:
save_dir = self.output_dir / "best_model"
else:
save_dir = self.output_dir / tag
save_dir.mkdir(parents=True, exist_ok=True)
# 保存模型(LoRA/PEFT 会保存 adapter 权重)
if hasattr(self.model, "save_pretrained"):
self.model.save_pretrained(save_dir)
else:
torch.save(self.model.state_dict(), save_dir / "pytorch_model.bin")
# 保存 processor
self.processor.save_pretrained(save_dir)
# 保存 trainer state
torch.save({
"epoch_tag": tag,
"global_step": self.global_step,
"optimizer_state_dict": self.optimizer.state_dict(),
"scheduler_state_dict": self.scheduler.state_dict(),
"best_val_loss": self.best_val_loss,
}, save_dir / "trainer_state.pt")
print(f"✓ 保存checkpoint: {save_dir}")
if not is_best:
self._rotate_checkpoints()
# ------------------------- train loop -------------------------
def train(self):
print("\n" + "=" * 60)
print("开始训练")
print("=" * 60)
grad_acc = max(1, int(self.config.training.gradient_accumulation_steps))
logging_steps = max(1, int(self.config.training.logging_steps))
# 注意:这里的 eval_steps / save_steps 现在是按 global_step(update step) 生效
eval_steps = int(self.config.training.eval_steps) if self.config.training.eval_steps else 0
save_steps = int(self.config.training.save_steps) if self.config.training.save_steps else 0
total_epochs = int(self.config.training.num_epochs)
# logging window stats
window_task_sum = {}
window_task_cnt = {}
window_loss_sum = 0.0
window_updates = 0
for epoch in range(total_epochs):
self.model.train()
print(f"\n{'='*60}")
print(f"Epoch {epoch+1}/{total_epochs}")
print(f"Curriculum Stage: {self.current_stage} ({['easy','medium','hard','all'][min(self.current_stage,3)]})")
print("=" * 60)
self.optimizer.zero_grad(set_to_none=True)
pbar = tqdm(self.train_loader, desc=f"Epoch {epoch+1}/{total_epochs}")
for step, batch in enumerate(pbar):
loss, task_losses = self.compute_loss(batch)
# 梯度累积:缩放 loss
loss_for_backward = loss / grad_acc
if self.scaler.is_enabled():
self.scaler.scale(loss_for_backward).backward()
else:
loss_for_backward.backward()
# accumulate task stats(记录未缩放 loss)
for t, v in task_losses.items():
window_task_sum[t] = window_task_sum.get(t, 0.0) + float(v)
window_task_cnt[t] = window_task_cnt.get(t, 0) + 1
# 是否该更新(包含尾 batch flush)
do_update = ((step + 1) % grad_acc == 0) or ((step + 1) == len(self.train_loader))
if not do_update:
continue
# clip grad
if self.scaler.is_enabled():
self.scaler.unscale_(self.optimizer)
torch.nn.utils.clip_grad_norm_(
self.model.parameters(),
float(self.config.training.max_grad_norm)
)
# optimizer.step + scheduler.step(顺序很重要:先 optimizer 再 scheduler)
if self.scaler.is_enabled():
self.scaler.step(self.optimizer)
self.scaler.update()
else:
self.optimizer.step()
self.scheduler.step()
self.optimizer.zero_grad(set_to_none=True)
# global step(update step)
self.global_step += 1
# window logging
window_updates += 1
window_loss_sum += float(loss.detach().item())
if self.global_step % logging_steps == 0:
avg_loss = window_loss_sum / max(1, window_updates)
log_dict = {
"step": self.global_step,
"epoch": epoch,
"train/loss": float(avg_loss),
"train/lr": float(self.scheduler.get_last_lr()[0]),
}
for t in window_task_sum:
log_dict[f"train/{t}"] = float(window_task_sum[t] / max(1, window_task_cnt.get(t, 1)))
# GPU memory(可选但很实用)
if torch.cuda.is_available():
log_dict["train/gpu_mem_alloc_mb"] = float(torch.cuda.memory_allocated() / 1024 / 1024)
log_dict["train/gpu_mem_reserved_mb"] = float(torch.cuda.memory_reserved() / 1024 / 1024)
self._write_jsonl(self.train_log_path, log_dict)
if self.config.training.use_wandb and wandb is not None:
wandb.log(log_dict, step=self.global_step)
pbar.set_postfix({
"loss": f"{avg_loss:.4f}",
"lr": f"{log_dict['train/lr']:.2e}"
})
# reset window
window_task_sum, window_task_cnt = {}, {}
window_loss_sum, window_updates = 0.0, 0
# save by steps
if save_steps > 0 and (self.global_step % save_steps == 0):
self.save_checkpoint(tag=f"checkpoint-{self.global_step}", is_best=False)
# eval by steps(Stage A 验证集很大,eval_steps 不要设太小)
if eval_steps > 0 and (self.global_step % eval_steps == 0):
val_loss, val_task_losses = self.evaluate(epoch)
print("\nValidation Results:")
print(f" Overall Loss: {val_loss:.4f}")
for t, v in val_task_losses.items():
print(f" {t}: {v:.4f}")
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self.save_checkpoint(tag="best_model", is_best=True)
print(f"✓ 新的最佳模型! Val Loss: {val_loss:.4f}")
# 每个 epoch 结束强制 eval(更稳妥)
val_loss, val_task_losses = self.evaluate(epoch)
print("\n[Epoch End] Validation Results:")
print(f" Overall Loss: {val_loss:.4f}")
for t, v in val_task_losses.items():
print(f" {t}: {v:.4f}")
if val_loss < self.best_val_loss:
self.best_val_loss = val_loss
self.save_checkpoint(tag="best_model", is_best=True)
print(f"✓ 新的最佳模型! Val Loss: {val_loss:.4f}")
# Curriculum stage 更新(仍保留提示;如要真正生效应重建 dataloader)
if self.current_stage < 3:
if epoch + 1 == sum(self.stage_epochs[: self.current_stage + 1]):
self.current_stage += 1
print(f"\n>>> Curriculum升级到 Stage {self.current_stage} <<<\n")
# 最终保存
self.save_checkpoint(tag=f"checkpoint-{self.global_step}", is_best=False)
print("\n" + "=" * 60)
print("训练完成!")
print(f"最佳验证Loss: {self.best_val_loss:.4f}")
print(f"模型保存在: {self.output_dir}")
print("=" * 60)
if self.config.training.use_wandb and wandb is not None:
wandb.finish()