| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| """ |
| 多任务 VLM 预训练器(Qwen2.5-VL 系列友好) |
| - 采用聊天模板对齐:仅对 assistant 回复部分计算损失(labels 其余位置置为 -100) |
| - 统一处理单帧与序列任务:images 以 list[PIL.Image] 形式传入处理器 |
| - 混合精度:优先 bf16(若可用),否则 fp16;包含 NaN/Inf 保护与梯度裁剪 |
| - 日志/保存/验证:仅在完成一次 optimizer.step() 后按步距触发,避免重复触发 |
| |
| 依赖: |
| - transformers >= 4.44(Qwen2-VL: AutoModelForImageTextToText / Qwen2VLProcessor) |
| - torch >= 2.1 |
| - 数据管道需提供 batch 结构,见 prepare_batch_inputs() 的说明 |
| """ |
|
|
| import os |
| import json |
| from typing import Dict, List, Tuple, Optional |
|
|
| import torch |
| import torch.nn as nn |
| from torch.utils.data import DataLoader |
| from tqdm import tqdm |
| from PIL import Image |
|
|
| from config import PretrainConfig |
| from model_loader import load_model_and_processor |
|
|
|
|
| def _to_pil(t: torch.Tensor) -> Image.Image: |
| """ |
| 将张量 [3, H, W] (0~1 标准化前张量) 转为 PIL Image。 |
| 若输入已是 PIL 则直接返回。 |
| """ |
| if isinstance(t, Image.Image): |
| return t |
| assert t.ndim == 3 and t.shape[0] == 3, "expect CHW tensor" |
| |
| mean = torch.tensor([0.485, 0.456, 0.406], dtype=t.dtype, device=t.device).view(3, 1, 1) |
| std = torch.tensor([0.229, 0.224, 0.225], dtype=t.dtype, device=t.device).view(3, 1, 1) |
| |
| x = t * std + mean |
| x = x.clamp(0, 1).cpu() |
| import torchvision.transforms as T |
| return T.ToPILImage()(x) |
|
|
|
|
| class MultiTaskTrainer: |
| """ |
| 多任务预训练器 |
| |
| 期望 DataLoader yield 的 batch 结构(示例): |
| batch = { |
| "single_frame": { |
| "images": Tensor[B, 3, H, W] 或 List[PIL], |
| "task": List[str], # "environment" | "accident_detection" |
| "labels": List[str], # 文本答案(例如 'Yes'/'No' 或结构化描述) |
| }, |
| "sequence": { |
| "sequences": Tensor[B, T, 3, H, W] 或 List[List[PIL]], |
| "masks": Tensor[B, T] (1=有效帧), |
| "labels": List[str], # 文本答案 |
| } |
| } |
| """ |
|
|
| def __init__(self, config: PretrainConfig, train_loader: DataLoader, val_loader: DataLoader): |
| self.config = config |
| self.train_loader = train_loader |
| self.val_loader = val_loader |
|
|
| print("=" * 60) |
| print("初始化模型...") |
| self.model, self.processor = load_model_and_processor(config.model) |
| self.device = torch.device(config.training.device) |
| self.model.to(self.device) |
|
|
| |
| tok = self.processor.tokenizer |
| if tok.pad_token_id is None: |
| tok.pad_token = tok.eos_token |
|
|
| |
| optim_type = getattr(config.training, "optimizer_type", "adamw").lower() |
| lr = config.training.learning_rate |
| wd = config.training.weight_decay |
| if optim_type == "adamw": |
| self.optimizer = torch.optim.AdamW(self.model.parameters(), lr=lr, weight_decay=wd) |
| else: |
| raise ValueError(f"Unsupported optimizer_type: {optim_type}") |
|
|
| |
| self.total_steps = ( |
| len(train_loader) * config.training.num_epochs |
| ) // max(1, config.training.gradient_accumulation_steps) |
| warmup_steps = int(self.total_steps * config.training.warmup_ratio) |
|
|
| from transformers import get_cosine_schedule_with_warmup |
| self.scheduler = get_cosine_schedule_with_warmup( |
| self.optimizer, num_warmup_steps=warmup_steps, num_training_steps=self.total_steps |
| ) |
|
|
| |
| self.use_bf16 = bool(getattr(config.training, "bf16", False)) and torch.cuda.is_available() |
| self.use_fp16 = bool(getattr(config.training, "fp16", False)) and torch.cuda.is_available() and not self.use_bf16 |
| self.autocast_dtype: Optional[torch.dtype] = torch.bfloat16 if self.use_bf16 else (torch.float16 if self.use_fp16 else None) |
| self.scaler = torch.amp.GradScaler("cuda") if self.use_fp16 else None |
|
|
| |
| self.global_step = 0 |
| self.best_val_loss = float("inf") |
|
|
| |
| if getattr(config.training, "gradient_checkpointing", False): |
| if hasattr(self.model, "gradient_checkpointing_enable"): |
| self.model.gradient_checkpointing_enable() |
|
|
| print(f"✓ 模型加载完成") |
| print(f"✓ 优化器: {optim_type}") |
| print(f"✓ 总训练步数: {self.total_steps}") |
| print("=" * 60) |
|
|
| |
| def construct_prompt(self, task: str) -> str: |
| """ |
| 返回 user 提示文本,答案只由 labels 提供。 |
| """ |
| if task == "environment": |
| return ( |
| "Analyze this dashcam image and describe the driving environment. " |
| "Provide the weather condition, road type, and lighting condition in the format: " |
| "'Weather: [weather], Road: [road_type], Light: [light_condition]'." |
| ) |
| elif task == "accident_detection": |
| return ( |
| "Look at this dashcam image. Is there an accident happening in this frame? " |
| "Answer only 'Yes' or 'No'." |
| ) |
| elif task == "sequence_prediction": |
| return ( |
| "You are viewing a sequence of dashcam frames in chronological order. " |
| "Based on this sequence, determine if an accident will occur and describe it. " |
| "Format your answer as: 'Accident: [Yes/No]. Description: [description]'." |
| ) |
| else: |
| raise ValueError(f"Unknown task: {task}") |
|
|
| |
| def prepare_batch_inputs(self, batch: Dict) -> Tuple[List[List[Image.Image]], List[str], List[str]]: |
| """ |
| 归一化 batch 成 3 个并行列表: |
| images_list: List[ List[PIL.Image] ] # 每条样本是若干帧(单帧也用长度为1的列表) |
| prompts_list: List[str] # user 提示 |
| labels_list: List[str] # assistant 文本答案 |
| """ |
| images_list: List[List[Image.Image]] = [] |
| prompts_list: List[str] = [] |
| labels_list: List[str] = [] |
|
|
| if "single_frame" in batch: |
| sf = batch["single_frame"] |
| imgs = sf["images"] |
| |
| if isinstance(imgs, torch.Tensor): |
| for i in range(imgs.shape[0]): |
| images_list.append([_to_pil(imgs[i])]) |
| prompts_list.append(self.construct_prompt(sf["task"][i])) |
| labels_list.append(sf["labels"][i]) |
| else: |
| for i in range(len(imgs)): |
| images_list.append([imgs[i] if isinstance(imgs[i], Image.Image) else _to_pil(imgs[i])]) |
| prompts_list.append(self.construct_prompt(sf["task"][i])) |
| labels_list.append(sf["labels"][i]) |
|
|
| if "sequence" in batch: |
| seq = batch["sequence"] |
| seqs = seq["sequences"] |
| masks = seq.get("masks", None) |
|
|
| if isinstance(seqs, torch.Tensor): |
| B, T = seqs.shape[0], seqs.shape[1] |
| for i in range(B): |
| if masks is not None: |
| valid_idx = (masks[i] == 1).nonzero(as_tuple=False).flatten().tolist() |
| else: |
| valid_idx = list(range(T)) |
| frames = [ _to_pil(seqs[i, j]) for j in valid_idx ] |
| if len(frames) == 0 and T > 0: |
| frames = [ _to_pil(seqs[i, 0]) ] |
| images_list.append(frames) |
| prompts_list.append(self.construct_prompt("sequence_prediction")) |
| labels_list.append(seq["labels"][i]) |
| else: |
| for i in range(len(seqs)): |
| frames = seqs[i] |
| frames_pil = [ f if isinstance(f, Image.Image) else _to_pil(f) for f in frames ] |
| if masks is not None: |
| m = masks[i] |
| if isinstance(m, torch.Tensor): |
| frames_pil = [frames_pil[j] for j in (m == 1).nonzero(as_tuple=False).flatten().tolist()] |
| if len(frames_pil) == 0 and len(frames) > 0: |
| frames_pil = [frames_pil[0]] |
| images_list.append(frames_pil) |
| prompts_list.append(self.construct_prompt("sequence_prediction")) |
| labels_list.append(seq["labels"][i]) |
|
|
| return images_list, prompts_list, labels_list |
|
|
| |
| def _build_texts_for_sample(self, images: List[Image.Image], prompt: str, answer: str) -> Tuple[str, str]: |
| """ |
| 基于聊天模板,返回 (prompt_only_text, full_text) |
| 训练时 full_text = user(msg) + assistant(answer);计算损失时屏蔽 user 区段。 |
| """ |
| |
| msgs_prompt_only = [ |
| { |
| "role": "user", |
| "content": [{"type": "image", "image": img} for img in images] + [{"type": "text", "text": prompt}], |
| } |
| ] |
| |
| msgs_full = [ |
| { |
| "role": "user", |
| "content": [{"type": "image", "image": img} for img in images] + [{"type": "text", "text": prompt}], |
| }, |
| { |
| "role": "assistant", |
| "content": [{"type": "text", "text": answer}], |
| }, |
| ] |
|
|
| |
| t_prompt = self.processor.apply_chat_template( |
| msgs_prompt_only, tokenize=False, add_generation_prompt=False |
| ) |
| t_full = self.processor.apply_chat_template( |
| msgs_full, tokenize=False, add_generation_prompt=False |
| ) |
| return t_prompt, t_full |
|
|
| def _encode_batch_with_labels( |
| self, images_list: List[List[Image.Image]], prompts_list: List[str], labels_list: List[str] |
| ) -> Dict[str, torch.Tensor]: |
| """ |
| 对一批样本: |
| 1) 生成 prompt-only 文本与 full 文本 |
| 2) 分别走 processor(text=..., images=...) 得到两套 input_ids |
| 3) 根据 prompt-only 的非 pad 长度,构建 full 的 labels(prompt 部分置 -100) |
| 返回可直接喂给 model(**inputs) 的字典(已放到正确的 device) |
| """ |
| texts_prompt_only, texts_full = [], [] |
| for imgs, p, a in zip(images_list, prompts_list, labels_list): |
| t_p, t_f = self._build_texts_for_sample(imgs, p, a) |
| texts_prompt_only.append(t_p) |
| texts_full.append(t_f) |
|
|
| |
| enc_full = self.processor( |
| text=texts_full, |
| images=images_list, |
| padding=True, |
| truncation=True, |
| return_tensors="pt", |
| ) |
| enc_prompt = self.processor( |
| text=texts_prompt_only, |
| images=images_list, |
| padding=True, |
| truncation=True, |
| return_tensors="pt", |
| ) |
|
|
| input_ids = enc_full["input_ids"] |
| attn_mask = enc_full["attention_mask"] |
| B, L = input_ids.shape |
| pad_id = self.processor.tokenizer.pad_token_id |
|
|
| |
| prompt_lens = (enc_prompt["input_ids"] != pad_id).sum(dim=1).tolist() |
|
|
| |
| labels = input_ids.clone() |
| for i in range(B): |
| plen = min(prompt_lens[i], L) |
| labels[i, :plen] = -100 |
| |
| if torch.all(labels[i] == -100): |
| last_idx = plen - 1 if plen > 0 else L - 1 |
| labels[i, last_idx] = input_ids[i, last_idx] |
|
|
| |
| for k in enc_full.keys(): |
| if isinstance(enc_full[k], torch.Tensor): |
| enc_full[k] = enc_full[k].to(self.device, non_blocking=True) |
| labels = labels.to(self.device, non_blocking=True) |
| enc_full["labels"] = labels |
| return enc_full |
|
|
| |
| def train_epoch(self, epoch: int) -> float: |
| self.model.train() |
| running_loss = 0.0 |
| n_batches = 0 |
|
|
| pbar = tqdm(self.train_loader, desc=f"Epoch {epoch+1}") |
| grad_accum = max(1, self.config.training.gradient_accumulation_steps) |
| max_norm = getattr(self.config.training, "max_grad_norm", 1.0) |
|
|
| |
| self.optimizer.zero_grad(set_to_none=True) |
|
|
| for step_in_epoch, batch in enumerate(pbar): |
| images_list, prompts_list, labels_list = self.prepare_batch_inputs(batch) |
| inputs = self._encode_batch_with_labels(images_list, prompts_list, labels_list) |
|
|
| |
| loss = None |
| if self.autocast_dtype is not None: |
| with torch.amp.autocast("cuda", dtype=self.autocast_dtype): |
| outputs = self.model(**inputs) |
| loss = outputs.loss / grad_accum |
| else: |
| outputs = self.model(**inputs) |
| loss = outputs.loss / grad_accum |
|
|
| |
| if not torch.isfinite(loss): |
| print(f"[WARN] Non-finite loss detected (forward): {loss.item()}. Skip this micro-batch.") |
| self.optimizer.zero_grad(set_to_none=True) |
| continue |
|
|
| |
| if self.scaler is not None: |
| self.scaler.scale(loss).backward() |
| else: |
| loss.backward() |
|
|
| |
| do_step = ((step_in_epoch + 1) % grad_accum == 0) |
| if do_step: |
| |
| if self.scaler is not None: |
| self.scaler.unscale_(self.optimizer) |
| torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm) |
|
|
| |
| found_inf = False |
| for p in self.model.parameters(): |
| if p.grad is not None and (torch.isnan(p.grad).any() or torch.isinf(p.grad).any()): |
| found_inf = True |
| break |
| if found_inf: |
| print("[WARN] Found NaN/Inf gradients. Skipping step and zeroing grads.") |
| self.optimizer.zero_grad(set_to_none=True) |
| if self.scaler is not None: |
| self.scaler.update() |
| continue |
|
|
| |
| if self.scaler is not None: |
| self.scaler.step(self.optimizer) |
| self.scaler.update() |
| else: |
| self.optimizer.step() |
| self.scheduler.step() |
| self.optimizer.zero_grad(set_to_none=True) |
|
|
| |
| self.global_step += 1 |
|
|
| |
| if self.global_step % max(1, self.config.training.logging_steps) == 0: |
| avg_loss = (running_loss + loss.item() * grad_accum) / max(1, (n_batches + 1)) |
| print(f"\nStep {self.global_step}: loss={avg_loss:.4f}, lr={self.scheduler.get_last_lr()[0]:.2e}") |
|
|
| if self.global_step % max(1, self.config.training.save_steps) == 0: |
| self.save_checkpoint(f"step_{self.global_step}") |
|
|
| if self.global_step % max(1, self.config.training.eval_steps) == 0: |
| val_loss = self.validate() |
| print(f"\nValidation loss: {val_loss:.4f}") |
| if val_loss < self.best_val_loss: |
| self.best_val_loss = val_loss |
| self.save_checkpoint("best") |
| self.model.train() |
|
|
| |
| running_loss += loss.item() * grad_accum |
| n_batches += 1 |
|
|
| pbar.set_postfix({ |
| "loss": f"{loss.item():.4f}", |
| "lr": f"{self.scheduler.get_last_lr()[0]:.2e}", |
| }) |
|
|
| return running_loss / max(1, n_batches) |
|
|
| @torch.no_grad() |
| def validate(self) -> float: |
| self.model.eval() |
| total_loss = 0.0 |
| n_batches = 0 |
|
|
| for batch in tqdm(self.val_loader, desc="Validating"): |
| images_list, prompts_list, labels_list = self.prepare_batch_inputs(batch) |
| inputs = self._encode_batch_with_labels(images_list, prompts_list, labels_list) |
|
|
| if self.autocast_dtype is not None: |
| with torch.amp.autocast("cuda", dtype=self.autocast_dtype): |
| outputs = self.model(**inputs) |
| loss = outputs.loss |
| else: |
| outputs = self.model(**inputs) |
| loss = outputs.loss |
|
|
| |
| if not torch.isfinite(loss): |
| continue |
| total_loss += loss.item() |
| n_batches += 1 |
|
|
| return total_loss / max(1, n_batches) |
|
|
| def save_checkpoint(self, name: str): |
| save_dir = os.path.join(self.config.training.output_dir, name) |
| os.makedirs(save_dir, exist_ok=True) |
|
|
| |
| self.model.save_pretrained(save_dir) |
| self.processor.save_pretrained(save_dir) |
|
|
| |
| torch.save( |
| { |
| "global_step": self.global_step, |
| "best_val_loss": self.best_val_loss, |
| "optimizer_state": self.optimizer.state_dict(), |
| "scheduler_state": self.scheduler.state_dict(), |
| }, |
| os.path.join(save_dir, "training_state.pt"), |
| ) |
| print(f"✓ Checkpoint saved: {save_dir}") |
|
|
| def train(self): |
| print("=" * 60) |
| print("开始训练") |
| print("=" * 60) |
|
|
| for epoch in range(self.config.training.num_epochs): |
| print(f"\nEpoch {epoch+1}/{self.config.training.num_epochs}") |
|
|
| train_loss = self.train_epoch(epoch) |
| print(f"Epoch {epoch+1} - Train Loss: {train_loss:.4f}") |
|
|
| |
| val_loss = self.validate() |
| print(f"Epoch {epoch+1} - Val Loss: {val_loss:.4f}") |
|
|
| if val_loss < self.best_val_loss: |
| self.best_val_loss = val_loss |
| self.save_checkpoint("best") |
|
|
| self.save_checkpoint(f"epoch_{epoch+1}") |
|
|
| print("\n" + "=" * 60) |
| print("训练完成!") |
| print(f"最佳验证损失: {self.best_val_loss:.4f}") |
| print("=" * 60) |
|
|