| from __future__ import annotations |
|
|
| import argparse |
| import json |
| from pathlib import Path |
|
|
| import torch |
| from peft import LoraConfig, TaskType, get_peft_model |
| from torch.utils.data import Dataset |
| from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments |
|
|
|
|
| class ChatJsonlDataset(Dataset): |
| def __init__(self, path: Path, tokenizer, max_length: int) -> None: |
| self.rows = [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] |
| self.tokenizer = tokenizer |
| self.max_length = max_length |
|
|
| def __len__(self) -> int: |
| return len(self.rows) |
|
|
| def __getitem__(self, index: int) -> dict[str, torch.Tensor]: |
| row = self.rows[index] |
| messages = row["messages"] |
| prompt_messages = messages[:-1] |
| assistant_message = messages[-1] |
|
|
| prompt = self.tokenizer.apply_chat_template( |
| prompt_messages, |
| tokenize=False, |
| add_generation_prompt=True, |
| ) |
| full_text = prompt + assistant_message["content"] + self.tokenizer.eos_token |
|
|
| full = self.tokenizer( |
| full_text, |
| truncation=True, |
| max_length=self.max_length, |
| padding=False, |
| return_tensors="pt", |
| ) |
| prompt_tokens = self.tokenizer( |
| prompt, |
| truncation=True, |
| max_length=self.max_length, |
| padding=False, |
| return_tensors="pt", |
| ) |
|
|
| input_ids = full["input_ids"][0] |
| attention_mask = full["attention_mask"][0] |
| labels = input_ids.clone() |
| labels[: prompt_tokens["input_ids"].shape[-1]] = -100 |
| return { |
| "input_ids": input_ids, |
| "attention_mask": attention_mask, |
| "labels": labels, |
| } |
|
|
|
|
| class DataCollator: |
| def __init__(self, tokenizer) -> None: |
| self.tokenizer = tokenizer |
|
|
| def __call__(self, features: list[dict[str, torch.Tensor]]) -> dict[str, torch.Tensor]: |
| max_len = max(feature["input_ids"].shape[0] for feature in features) |
| batch = {"input_ids": [], "attention_mask": [], "labels": []} |
| for feature in features: |
| pad_len = max_len - feature["input_ids"].shape[0] |
| batch["input_ids"].append( |
| torch.nn.functional.pad(feature["input_ids"], (0, pad_len), value=self.tokenizer.pad_token_id) |
| ) |
| batch["attention_mask"].append(torch.nn.functional.pad(feature["attention_mask"], (0, pad_len), value=0)) |
| batch["labels"].append(torch.nn.functional.pad(feature["labels"], (0, pad_len), value=-100)) |
| return {key: torch.stack(value) for key, value in batch.items()} |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description="LoRA fine-tune MiniCPM for Jawbreaker JSON scam analysis.") |
| parser.add_argument("--model-id", default="openbmb/MiniCPM4.1-8B") |
| parser.add_argument("--train-file", type=Path, default=Path("training/data/train.jsonl")) |
| parser.add_argument("--dev-file", type=Path, default=Path("training/data/dev.jsonl")) |
| parser.add_argument("--output-dir", type=Path, default=Path("training/output/jawbreaker-minicpm-lora")) |
| parser.add_argument("--max-length", type=int, default=768) |
| parser.add_argument("--epochs", type=float, default=1.0) |
| parser.add_argument("--batch-size", type=int, default=1) |
| parser.add_argument("--grad-accum", type=int, default=16) |
| parser.add_argument("--learning-rate", type=float, default=2e-4) |
| parser.add_argument("--warmup-ratio", type=float, default=0.0) |
| parser.add_argument("--weight-decay", type=float, default=0.0) |
| parser.add_argument("--lr-scheduler-type", default="linear") |
| parser.add_argument("--lora-r", type=int, default=16) |
| parser.add_argument("--lora-alpha", type=int, default=32) |
| parser.add_argument("--lora-dropout", type=float, default=0.05) |
| parser.add_argument("--trust-remote-code", action=argparse.BooleanOptionalAction, default=True) |
| parser.add_argument("--push-to-hub", action="store_true") |
| parser.add_argument("--hub-model-id", default=None) |
| return parser.parse_args() |
|
|
|
|
| def main() -> None: |
| args = parse_args() |
| tokenizer = AutoTokenizer.from_pretrained(args.model_id, trust_remote_code=args.trust_remote_code) |
| if tokenizer.pad_token_id is None: |
| tokenizer.pad_token = tokenizer.eos_token |
|
|
| model = AutoModelForCausalLM.from_pretrained( |
| args.model_id, |
| dtype="auto", |
| device_map="auto", |
| trust_remote_code=args.trust_remote_code, |
| ) |
| model.config.use_cache = False |
|
|
| lora_config = LoraConfig( |
| r=args.lora_r, |
| lora_alpha=args.lora_alpha, |
| lora_dropout=args.lora_dropout, |
| bias="none", |
| task_type=TaskType.CAUSAL_LM, |
| target_modules=[ |
| "q_proj", |
| "k_proj", |
| "v_proj", |
| "o_proj", |
| "gate_proj", |
| "up_proj", |
| "down_proj", |
| ], |
| ) |
| model = get_peft_model(model, lora_config) |
| model.print_trainable_parameters() |
|
|
| train_dataset = ChatJsonlDataset(args.train_file, tokenizer, args.max_length) |
| eval_dataset = ChatJsonlDataset(args.dev_file, tokenizer, args.max_length) |
|
|
| training_args = TrainingArguments( |
| output_dir=str(args.output_dir), |
| num_train_epochs=args.epochs, |
| per_device_train_batch_size=args.batch_size, |
| per_device_eval_batch_size=args.batch_size, |
| gradient_accumulation_steps=args.grad_accum, |
| learning_rate=args.learning_rate, |
| warmup_ratio=args.warmup_ratio, |
| weight_decay=args.weight_decay, |
| lr_scheduler_type=args.lr_scheduler_type, |
| logging_steps=10, |
| eval_strategy="steps", |
| eval_steps=50, |
| save_steps=100, |
| save_total_limit=2, |
| bf16=torch.cuda.is_available(), |
| report_to="none", |
| push_to_hub=args.push_to_hub, |
| hub_model_id=args.hub_model_id, |
| ) |
|
|
| trainer = Trainer( |
| model=model, |
| args=training_args, |
| train_dataset=train_dataset, |
| eval_dataset=eval_dataset, |
| data_collator=DataCollator(tokenizer), |
| ) |
| trainer.train() |
| trainer.save_model(str(args.output_dir)) |
| tokenizer.save_pretrained(str(args.output_dir)) |
|
|
| if args.push_to_hub: |
| trainer.push_to_hub() |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|