| |
| """ |
| 改进的多任务训练器 |
| 融合最新VLM领域适应研究: |
| 1. Curriculum Learning |
| 2. Dynamic Task Weighting |
| 3. Contrastive Learning for accident detection |
| """ |
|
|
| import os |
| 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 wandb |
| from pathlib import Path |
| import json |
| from datetime import datetime |
| import numpy as np |
|
|
| from model_loader import ( |
| load_model_and_processor, |
| prepare_model_inputs |
| ) |
| from config import PretrainConfig |
|
|
|
|
| class FocalLoss(nn.Module): |
| """ |
| Focal Loss for accident detection |
| 处理正负样本不平衡问题 |
| """ |
| def __init__(self, alpha=0.25, gamma=2.0): |
| super().__init__() |
| self.alpha = alpha |
| self.gamma = gamma |
| |
| def forward(self, inputs, targets): |
| """ |
| Args: |
| inputs: predicted logits [B, num_classes] |
| targets: target labels [B] |
| """ |
| 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: |
| """ |
| 多任务训练器 |
| 支持Curriculum Learning和动态任务权重 |
| """ |
| |
| def __init__(self, config: PretrainConfig, train_loader: DataLoader, val_loader: DataLoader, pretrained_lora_path: str = 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._init_model() |
| |
| |
| self._init_optimizer() |
| |
| |
| self.ce_loss = nn.CrossEntropyLoss() |
| self.focal_loss = FocalLoss(alpha=0.25, gamma=2.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": 1.0, |
| "bdd_detection": 1.0, |
| "bdd_drivable": 1.0, |
| "bdd_risk": 1.0 |
| } |
| |
| |
| self.current_stage = 0 |
| self.stage_epochs = [1, 2, 2] |
| |
| |
| self.global_step = 0 |
| self.best_val_loss = float('inf') |
| self.train_losses = [] |
| |
| |
| if config.training.use_wandb: |
| wandb.init( |
| project=config.training.wandb_project, |
| name=config.training.wandb_run_name or f"{config.model.model_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}", |
| config=vars(config) |
| ) |
| |
| def _init_model(self): |
| """初始化模型""" |
| print("=" * 60) |
| print("加载模型...") |
| |
| self.model, self.processor = load_model_and_processor(self.config.model) |
| |
| |
| if self.pretrained_lora_path: |
| print(f"\n加载预训练LoRA权重: {self.pretrained_lora_path}") |
| |
| |
| lora_path = Path(self.pretrained_lora_path) |
| if not lora_path.exists(): |
| print(f"❌ LoRA权重不存在: {lora_path}") |
| raise FileNotFoundError(f"LoRA权重不存在: {lora_path}") |
| |
| |
| try: |
| from peft import PeftModel |
| self.model = PeftModel.from_pretrained( |
| self.model, |
| self.pretrained_lora_path, |
| is_trainable=True |
| ) |
| print("✓ 预训练LoRA权重加载成功") |
| print("✓ LoRA权重设置为可训练") |
| except Exception as e: |
| print(f"❌ LoRA权重加载失败: {e}") |
| raise |
| |
| self.model.to(self.device) |
| |
| |
| 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 |
| |
| print(f"✓ 模型加载完成: {self.config.model.model_name}") |
| if self.pretrained_lora_path: |
| print(f"✓ 从预训练LoRA继续训练: {self.pretrained_lora_path}") |
| print("=" * 60) |
| |
| def _init_optimizer(self): |
| """初始化优化器和scheduler""" |
| |
| self.optimizer = torch.optim.AdamW( |
| self.model.parameters(), |
| lr=self.config.training.learning_rate, |
| weight_decay=self.config.training.weight_decay |
| ) |
| |
| |
| num_training_steps = len(self.train_loader) * self.config.training.num_epochs |
| num_warmup_steps = int(num_training_steps * 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(f"✓ 优化器初始化完成") |
| print(f" 总步数: {num_training_steps}") |
| print(f" warmup: {num_warmup_steps}") |
| |
| def _move_batch_to_device(self, inputs): |
| """将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 |
|
|
| 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(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 as e: |
| |
| fallback_inputs = self._concat_answers_to_prompt_inputs(prompt_inputs, labels_text) |
| return fallback_inputs, labels_text, task_types |
|
|
| def prepare_sequence_inputs_and_labels(self, batch_data): |
| """ |
| 准备序列任务的模型输入和labels(与单帧同逻辑,但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) |
| return fallback_inputs, labels_text, task_types |
|
|
| |
| def compute_loss(self, batch): |
| """ |
| 计算batch的总loss |
| 支持单帧和序列任务 |
| """ |
| total_loss = 0 |
| task_losses = {} |
| n_tasks = 0 |
| |
| |
| if "single_frame" in batch: |
| sf_data = batch["single_frame"] |
| |
| |
| inputs, labels_text, task_types = self.prepare_inputs_and_labels(sf_data) |
| |
| |
| outputs = self.model(**inputs) |
| loss = outputs.loss |
| |
| |
| task_type = task_types[0] |
| weighted_loss = loss * self.task_weights.get(task_type, 1.0) |
| |
| total_loss += weighted_loss |
| task_losses[task_type] = loss.item() |
| n_tasks += 1 |
| |
| |
| if "sequence" in batch: |
| seq_data = batch["sequence"] |
|
|
| |
| inputs, labels_text, task_types = self.prepare_sequence_inputs_and_labels(seq_data) |
|
|
| |
| outputs = self.model(**inputs) |
| loss = outputs.loss |
|
|
| |
| task_type = task_types[0] |
| weighted_loss = loss * self.task_weights.get(task_type, 1.0) |
|
|
| total_loss += weighted_loss |
| task_losses[task_type] = loss.item() |
| n_tasks += 1 |
|
|
| |
| if n_tasks > 0: |
| total_loss = total_loss / n_tasks |
| |
| return total_loss, task_losses |
| |
| def train_epoch(self, epoch): |
| """训练一个epoch""" |
| self.model.train() |
| epoch_loss = 0 |
| epoch_task_losses = {} |
| |
| pbar = tqdm(self.train_loader, desc=f"Epoch {epoch+1}/{self.config.training.num_epochs}") |
| |
| for step, batch in enumerate(pbar): |
| |
| loss, task_losses = self.compute_loss(batch) |
| |
| |
| loss = loss / self.config.training.gradient_accumulation_steps |
| loss.backward() |
| |
| |
| for task, task_loss in task_losses.items(): |
| if task not in epoch_task_losses: |
| epoch_task_losses[task] = [] |
| epoch_task_losses[task].append(task_loss) |
| |
| |
| if (step + 1) % self.config.training.gradient_accumulation_steps == 0: |
| |
| torch.nn.utils.clip_grad_norm_( |
| self.model.parameters(), |
| self.config.training.max_grad_norm |
| ) |
| |
| |
| self.optimizer.step() |
| self.scheduler.step() |
| self.optimizer.zero_grad() |
| self.global_step += 1 |
| |
| |
| if self.global_step % self.config.training.logging_steps == 0: |
| avg_task_losses = { |
| task: np.mean(losses) |
| for task, losses in epoch_task_losses.items() |
| } |
| |
| log_dict = { |
| "train/loss": loss.item() * self.config.training.gradient_accumulation_steps, |
| "train/lr": self.scheduler.get_last_lr()[0], |
| "train/step": self.global_step, |
| "train/epoch": epoch |
| } |
| |
| for task, avg_loss in avg_task_losses.items(): |
| log_dict[f"train/{task}"] = avg_loss |
| |
| if self.config.training.use_wandb: |
| wandb.log(log_dict, step=self.global_step) |
| |
| pbar.set_postfix({ |
| "loss": f"{loss.item() * self.config.training.gradient_accumulation_steps:.4f}", |
| "lr": f"{self.scheduler.get_last_lr()[0]:.2e}" |
| }) |
| |
| epoch_loss += loss.item() |
| |
| return epoch_loss / len(self.train_loader) |
| |
| @torch.no_grad() |
| def evaluate(self): |
| """验证""" |
| self.model.eval() |
| val_loss = 0 |
| val_task_losses = {} |
| |
| for batch in tqdm(self.val_loader, desc="Validation"): |
| loss, task_losses = self.compute_loss(batch) |
| val_loss += loss.item() |
| |
| for task, task_loss in task_losses.items(): |
| if task not in val_task_losses: |
| val_task_losses[task] = [] |
| val_task_losses[task].append(task_loss) |
| |
| val_loss /= len(self.val_loader) |
| |
| avg_task_losses = { |
| task: np.mean(losses) |
| for task, losses in val_task_losses.items() |
| } |
| |
| return val_loss, avg_task_losses |
| |
| def save_checkpoint(self, epoch, is_best=False): |
| """保存checkpoint""" |
| |
| if is_best: |
| save_dir = Path(self.config.training.output_dir) / "best_model" |
| else: |
| save_dir = Path(self.config.training.output_dir) / f"checkpoint-{epoch}" |
| |
| save_dir.mkdir(parents=True, exist_ok=True) |
| |
| |
| if hasattr(self.model, "save_pretrained"): |
| self.model.save_pretrained(save_dir) |
| else: |
| torch.save(self.model.state_dict(), save_dir / "pytorch_model.bin") |
| |
| |
| self.processor.save_pretrained(save_dir) |
| |
| |
| torch.save({ |
| "epoch": epoch, |
| "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}") |
| |
| def train(self): |
| """主训练循环 - 支持Curriculum Learning""" |
| print("\n" + "=" * 60) |
| print("开始训练") |
| print("=" * 60) |
| |
| |
| |
| |
| |
| |
| |
| total_epochs = self.config.training.num_epochs |
| |
| for epoch in range(total_epochs): |
| 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) |
| |
| |
| train_loss = self.train_epoch(epoch) |
| |
| |
| if (epoch + 1) % self.config.training.eval_steps == 0 or epoch == total_epochs - 1: |
| val_loss, val_task_losses = self.evaluate() |
| |
| print(f"\nValidation Results:") |
| print(f" Overall Loss: {val_loss:.4f}") |
| for task, loss in val_task_losses.items(): |
| print(f" {task}: {loss:.4f}") |
| |
| |
| if self.config.training.use_wandb: |
| log_dict = {"val/loss": val_loss, "val/epoch": epoch} |
| for task, loss in val_task_losses.items(): |
| log_dict[f"val/{task}"] = loss |
| wandb.log(log_dict, step=self.global_step) |
| |
| |
| if val_loss < self.best_val_loss: |
| self.best_val_loss = val_loss |
| self.save_checkpoint(epoch, is_best=True) |
| print(f"✓ 新的最佳模型! Val Loss: {val_loss:.4f}") |
| |
| |
| if (epoch + 1) % self.config.training.save_steps == 0: |
| self.save_checkpoint(epoch) |
| |
| |
| 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(total_epochs - 1) |
| |
| print("\n" + "=" * 60) |
| print("训练完成!") |
| print(f"最佳验证Loss: {self.best_val_loss:.4f}") |
| print(f"模型保存在: {self.config.training.output_dir}") |
| print("=" * 60) |
| |
| if self.config.training.use_wandb: |
| wandb.finish() |