| |
|
|
| """ |
| SFT (Supervised Fine-Tuning) 全量微调 |
| 加载预训练权重 → 在对话数据上训练 → 保存SFT权重 |
| """ |
|
|
| import os |
| import json |
| import math |
| import time |
| import random |
| from dataclasses import dataclass |
| from typing import List, Dict, Optional |
|
|
| import torch |
| import torch.nn as nn |
| from torch.nn import functional as F |
| from torch.utils.data import Dataset, DataLoader |
|
|
| os.environ["TIKTOKEN_CACHE_DIR"] = "./tiktoken_cache" |
| import tiktoken |
|
|
| from config import GPTConfig, get_model_config |
| from model import GPT |
|
|
|
|
| |
| |
| |
|
|
| @dataclass |
| class SFTConfig: |
| |
| pretrained_path: str = "log/model_00999_CampGPT_X.pt" |
| sft_data_path: str = "sft_dataset.json" |
| output_dir: str = "sft_output" |
| |
| |
| num_epochs: int = 100 |
| batch_size: int = 8 |
| max_seq_len: int = 512 |
| learning_rate: float = 2e-5 |
| min_lr: float = 2e-6 |
| warmup_steps: int = 10 |
| weight_decay: float = 0.01 |
| grad_clip: float = 1.0 |
| grad_accum_steps: int = 1 |
| |
| |
| system_prompt: str = "You are a helpful university assistant that answers questions about student policies and regulations." |
| |
| |
| |
| user_token: str = "<|user|>" |
| assistant_token: str = "<|assistant|>" |
| end_token: str = "<|end|>" |
| |
| |
| log_every: int = 10 |
| save_every_epoch: bool = False |
| eval_ratio: float = 0.05 |
|
|
|
|
| |
| |
| |
|
|
| class ChatTokenizer: |
| """ |
| 对话格式处理器 |
| 格式: <|user|>question<|end|><|assistant|>answer<|end|> |
| |
| 只对 assistant 的回答部分计算 loss (labels masking) |
| """ |
| |
| def __init__(self, config: SFTConfig): |
| self.enc = tiktoken.get_encoding("gpt2") |
| self.config = config |
| self.vocab_size = self.enc.n_vocab |
| |
| |
| |
| self.user_token_id = 50256 |
| self.assistant_token_id = 50255 |
| self.end_token_id = 50254 |
| |
| |
| |
| self.user_prefix = "\n\n### User:\n" |
| self.assistant_prefix = "\n\n### Assistant:\n" |
| self.turn_end = "\n\n" |
| |
| self.user_prefix_ids = self.enc.encode(self.user_prefix) |
| self.assistant_prefix_ids = self.enc.encode(self.assistant_prefix) |
| self.turn_end_ids = self.enc.encode(self.turn_end) |
| |
| def encode_conversation(self, messages: List[Dict], max_len: int) -> Dict: |
| """ |
| 将对话编码为 input_ids + labels |
| 只在 assistant 回复部分计算 loss |
| |
| Returns: |
| input_ids: [token_ids] |
| labels: [token_ids], user部分为 -100 (忽略) |
| """ |
| input_ids = [] |
| labels = [] |
| |
| |
| if self.config.system_prompt: |
| sys_text = f"### System:\n{self.config.system_prompt}\n\n" |
| sys_ids = self.enc.encode(sys_text) |
| input_ids.extend(sys_ids) |
| labels.extend([-100] * len(sys_ids)) |
| |
| for msg in messages: |
| role = msg["role"] |
| content = msg["content"] |
| |
| if role == "user": |
| prefix_ids = self.user_prefix_ids |
| content_ids = self.enc.encode(content) |
| end_ids = self.turn_end_ids |
| |
| turn_ids = prefix_ids + content_ids + end_ids |
| input_ids.extend(turn_ids) |
| labels.extend([-100] * len(turn_ids)) |
| |
| elif role == "assistant": |
| prefix_ids = self.assistant_prefix_ids |
| content_ids = self.enc.encode(content) |
| end_ids = self.turn_end_ids |
| |
| turn_ids = prefix_ids + content_ids + end_ids |
| input_ids.extend(turn_ids) |
| |
| turn_labels = ([-100] * len(prefix_ids)) + content_ids + end_ids |
| labels.extend(turn_labels) |
| |
| |
| if len(input_ids) > max_len: |
| input_ids = input_ids[:max_len] |
| labels = labels[:max_len] |
| |
| return { |
| "input_ids": input_ids, |
| "labels": labels, |
| } |
| |
| def decode(self, token_ids: List[int]) -> str: |
| """解码 token ids 为文本""" |
| |
| filtered = [t for t in token_ids if t < self.enc.n_vocab] |
| return self.enc.decode(filtered) |
|
|
|
|
| |
| |
| |
|
|
| class SFTDataset(Dataset): |
| """SFT 对话数据集""" |
| |
| def __init__(self, data: List[Dict], tokenizer: ChatTokenizer, max_len: int): |
| self.data = data |
| self.tokenizer = tokenizer |
| self.max_len = max_len |
| |
| def __len__(self): |
| return len(self.data) |
| |
| def __getitem__(self, idx): |
| item = self.data[idx] |
| messages = item["messages"] |
| encoded = self.tokenizer.encode_conversation(messages, self.max_len) |
| return encoded |
|
|
|
|
| def sft_collate_fn(batch, max_len, pad_id=0): |
| """ |
| 动态 padding + 生成 attention mask |
| """ |
| batch_input_ids = [] |
| batch_labels = [] |
| |
| max_batch_len = min(max(len(b["input_ids"]) for b in batch), max_len) |
| |
| for b in batch: |
| ids = b["input_ids"][:max_batch_len] |
| labs = b["labels"][:max_batch_len] |
| |
| pad_len = max_batch_len - len(ids) |
| ids = ids + [pad_id] * pad_len |
| labs = labs + [-100] * pad_len |
| |
| batch_input_ids.append(ids) |
| batch_labels.append(labs) |
| |
| return { |
| "input_ids": torch.tensor(batch_input_ids, dtype=torch.long), |
| "labels": torch.tensor(batch_labels, dtype=torch.long), |
| } |
|
|
|
|
| |
| |
| |
|
|
| class SFTTrainer: |
| """SFT 全量微调训练器""" |
| |
| def __init__(self, sft_config: SFTConfig): |
| self.sft_config = sft_config |
| self.device = "cuda" if torch.cuda.is_available() else "cpu" |
| self.device_type = "cuda" if self.device.startswith("cuda") else "cpu" |
| |
| |
| self._load_model() |
| self._load_data() |
| self._setup_optimizer() |
| |
| def _load_model(self): |
| """加载预训练权重""" |
| print(f"[SFT] Loading pretrained model from {self.sft_config.pretrained_path}") |
| |
| checkpoint = torch.load(self.sft_config.pretrained_path, map_location="cpu") |
| model_config = checkpoint["config"] |
| |
| |
| self.model_config = model_config |
| self.model = GPT(model_config) |
| |
| |
| state_dict = checkpoint["model"] |
| |
| cleaned = {} |
| for k, v in state_dict.items(): |
| k = k.replace("module.", "").replace("_orig_mod.", "") |
| cleaned[k] = v |
| |
| self.model.load_state_dict(cleaned, strict=True) |
| self.model.to(self.device) |
| |
| |
| self.model.set_gradient_checkpointing(False) |
| |
| pretrain_step = checkpoint.get("step", "unknown") |
| pretrain_loss = checkpoint.get("val_loss", "unknown") |
| print(f"[SFT] Loaded pretrained model (step={pretrain_step}, val_loss={pretrain_loss})") |
| print(f"[SFT] Model params: {sum(p.numel() for p in self.model.parameters()):,}") |
| |
| def _load_data(self): |
| """加载并划分 SFT 数据""" |
| print(f"[SFT] Loading data from {self.sft_config.sft_data_path}") |
| |
| with open(self.sft_config.sft_data_path, "r", encoding="utf-8") as f: |
| raw_data = json.load(f) |
| |
| |
| random.seed(42) |
| random.shuffle(raw_data) |
| |
| val_size = max(1, int(len(raw_data) * self.sft_config.eval_ratio)) |
| val_data = raw_data[:val_size] |
| train_data = raw_data[val_size:] |
| |
| self.tokenizer = ChatTokenizer(self.sft_config) |
| |
| self.train_dataset = SFTDataset(train_data, self.tokenizer, self.sft_config.max_seq_len) |
| self.val_dataset = SFTDataset(val_data, self.tokenizer, self.sft_config.max_seq_len) |
| |
| from functools import partial |
| collate = partial(sft_collate_fn, max_len=self.sft_config.max_seq_len) |
| |
| self.train_loader = DataLoader( |
| self.train_dataset, |
| batch_size=self.sft_config.batch_size, |
| shuffle=True, |
| collate_fn=collate, |
| drop_last=True, |
| ) |
| self.val_loader = DataLoader( |
| self.val_dataset, |
| batch_size=self.sft_config.batch_size, |
| shuffle=False, |
| collate_fn=collate, |
| ) |
| |
| print(f"[SFT] Train: {len(train_data)} samples, Val: {len(val_data)} samples") |
| |
| |
| sample = self.train_dataset[0] |
| print(f"[SFT] Sample input length: {len(sample['input_ids'])} tokens") |
| print(f"[SFT] Sample text preview: {self.tokenizer.decode(sample['input_ids'][:100])}...") |
| |
| def _setup_optimizer(self): |
| """配置优化器""" |
| self.optimizer = self.model.configure_optimizers( |
| weight_decay=self.sft_config.weight_decay, |
| learning_rate=self.sft_config.learning_rate, |
| device_type=self.device_type, |
| ) |
| |
| |
| steps_per_epoch = len(self.train_loader) // self.sft_config.grad_accum_steps |
| self.total_steps = steps_per_epoch * self.sft_config.num_epochs |
| |
| print(f"[SFT] Steps per epoch: {steps_per_epoch}, Total steps: {self.total_steps}") |
| |
| def _get_lr(self, step): |
| """Cosine 学习率调度""" |
| cfg = self.sft_config |
| if step < cfg.warmup_steps: |
| return cfg.learning_rate * (step + 1) / cfg.warmup_steps |
| if step >= self.total_steps: |
| return cfg.min_lr |
| decay_ratio = (step - cfg.warmup_steps) / (self.total_steps - cfg.warmup_steps) |
| coeff = 0.5 * (1.0 + math.cos(math.pi * decay_ratio)) |
| return cfg.min_lr + coeff * (cfg.learning_rate - cfg.min_lr) |
| |
| def _compute_loss(self, input_ids, labels): |
| """计算带 label masking 的 loss""" |
| logits, _ = self.model(input_ids) |
| |
| |
| shift_logits = logits[:, :-1, :].contiguous() |
| shift_labels = labels[:, 1:].contiguous() |
| |
| loss = F.cross_entropy( |
| shift_logits.view(-1, shift_logits.size(-1)), |
| shift_labels.view(-1), |
| ignore_index=-100, |
| ) |
| return loss |
| |
| @torch.no_grad() |
| def evaluate(self): |
| """验证集评估""" |
| self.model.eval() |
| total_loss = 0 |
| total_tokens = 0 |
| |
| for batch in self.val_loader: |
| input_ids = batch["input_ids"].to(self.device) |
| labels = batch["labels"].to(self.device) |
| |
| with torch.autocast(device_type=self.device_type, dtype=torch.bfloat16): |
| loss = self._compute_loss(input_ids, labels) |
| |
| |
| valid_tokens = (labels[:, 1:] != -100).sum().item() |
| total_loss += loss.item() * valid_tokens |
| total_tokens += valid_tokens |
| |
| avg_loss = total_loss / max(total_tokens, 1) |
| self.model.train() |
| return avg_loss |
| |
| def train(self): |
| """主训练循环""" |
| cfg = self.sft_config |
| os.makedirs(cfg.output_dir, exist_ok=True) |
| |
| log_file = os.path.join(cfg.output_dir, "sft_log.txt") |
| |
| print(f"\n{'='*60}") |
| print(f" SFT Training Start") |
| print(f" Epochs: {cfg.num_epochs}") |
| print(f" Effective batch: {cfg.batch_size * cfg.grad_accum_steps}") |
| print(f" Learning rate: {cfg.learning_rate}") |
| print(f" Total steps: {self.total_steps}") |
| print(f"{'='*60}\n") |
| |
| self.model.train() |
| global_step = 0 |
| best_val_loss = float("inf") |
| |
| for epoch in range(cfg.num_epochs): |
| epoch_loss = 0 |
| epoch_tokens = 0 |
| self.optimizer.zero_grad() |
| |
| for micro_step, batch in enumerate(self.train_loader): |
| input_ids = batch["input_ids"].to(self.device) |
| labels = batch["labels"].to(self.device) |
| |
| with torch.autocast(device_type=self.device_type, dtype=torch.bfloat16): |
| loss = self._compute_loss(input_ids, labels) |
| |
| loss_scaled = loss / cfg.grad_accum_steps |
| loss_scaled.backward() |
| |
| valid_tokens = (labels[:, 1:] != -100).sum().item() |
| epoch_loss += loss.item() * valid_tokens |
| epoch_tokens += valid_tokens |
| |
| |
| if (micro_step + 1) % cfg.grad_accum_steps == 0: |
| norm = torch.nn.utils.clip_grad_norm_( |
| self.model.parameters(), cfg.grad_clip |
| ) |
| |
| lr = self._get_lr(global_step) |
| for pg in self.optimizer.param_groups: |
| pg["lr"] = lr |
| |
| self.optimizer.step() |
| self.optimizer.zero_grad() |
| global_step += 1 |
| |
| if global_step % cfg.log_every == 0: |
| avg = epoch_loss / max(epoch_tokens, 1) |
| print(f" epoch {epoch+1} step {global_step:4d} | " |
| f"loss {loss.item():.4f} | avg {avg:.4f} | " |
| f"lr {lr:.2e} | norm {norm:.4f}") |
| |
| with open(log_file, "a") as f: |
| f.write(f"{global_step} train {loss.item():.4f}\n") |
| |
| |
| val_loss = self.evaluate() |
| avg_train = epoch_loss / max(epoch_tokens, 1) |
| print(f"\n[Epoch {epoch+1}/{cfg.num_epochs}] " |
| f"train_loss={avg_train:.4f} val_loss={val_loss:.4f}") |
| |
| with open(log_file, "a") as f: |
| f.write(f"epoch_{epoch+1} val {val_loss:.4f}\n") |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if val_loss <= best_val_loss: |
| best_path = os.path.join(cfg.output_dir, "sft_best.pt") |
| torch.save({ |
| "model": self.model.state_dict(), |
| "config": self.model_config, |
| "sft_config": cfg, |
| "epoch": epoch + 1, |
| "global_step": global_step, |
| "val_loss": val_loss, |
| "chat_template": { |
| "user_prefix": self.tokenizer.user_prefix, |
| "assistant_prefix": self.tokenizer.assistant_prefix, |
| "turn_end": self.tokenizer.turn_end, |
| "system_prompt": cfg.system_prompt, |
| }, |
| }, best_path) |
| print(f" Saved best: {best_path} (val_loss={val_loss:.4f})") |
| |
| print(f"\n[SFT] Training complete! Best val_loss: {best_val_loss:.4f}") |
| |
| |
| self._test_generation() |
| |
| def _test_generation(self): |
| """训练后快速测试生成""" |
| print(f"\n{'='*40} Generation Test {'='*40}") |
| |
| test_questions = [ |
| "What are the requirements for applying for a scholarship?", |
| "How do I transfer to a different major?", |
| "What happens if I fail a course?", |
| ] |
| |
| self.model.eval() |
| enc = tiktoken.get_encoding("gpt2") |
| |
| for q in test_questions: |
| prompt_text = "" |
| if self.sft_config.system_prompt: |
| prompt_text += f"### System:\n{self.sft_config.system_prompt}\n\n" |
| prompt_text += f"### User:\n{q}\n\n### Assistant:\n" |
| |
| prompt_ids = enc.encode(prompt_text) |
| prompt_t = torch.tensor([prompt_ids], dtype=torch.long, device=self.device) |
| |
| with torch.no_grad(): |
| with torch.autocast(device_type=self.device_type, dtype=torch.bfloat16): |
| generated = self.model.generate( |
| prompt_t, max_new_tokens=150, |
| temperature=0.7, top_k=50, top_p=0.9 |
| ) |
| |
| output = enc.decode(generated[0].tolist()) |
| |
| if "### Assistant:" in output: |
| answer = output.split("### Assistant:")[-1].strip() |
| |
| if "###" in answer: |
| answer = answer[:answer.index("###")].strip() |
| else: |
| answer = output[len(prompt_text):] |
| |
| print(f"\nQ: {q}") |
| print(f"A: {answer[:300]}") |
| |
| print(f"{'='*80}\n") |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| config = SFTConfig() |
| |
| |
| import sys |
| for arg in sys.argv[1:]: |
| if "=" in arg: |
| key, val = arg.split("=", 1) |
| key = key.lstrip("-") |
| if hasattr(config, key): |
| field_type = type(getattr(config, key)) |
| setattr(config, key, field_type(val)) |
| print(f" Override: {key} = {val}") |
| |
| trainer = SFTTrainer(config) |
| trainer.train() |