""" TFMF LoRA 训练脚本 - 新版自动化入口 这个脚本不会覆盖现有的 train_lora.py, 用于从一个 JSONL 数据目录或单个 JSONL 文件批量加载训练样本, 自动生成 LoRA 适配器并保存到指定输出目录。 格式要求:每条训练数据必须至少包含字段:system, user, assistant 示例: { "system": "...", "user": "...", "assistant": "..." } """ import argparse import glob import json import os import random import sys import torch from datasets import Dataset from peft import LoraConfig, get_peft_model from transformers import ( AutoModelForCausalLM, AutoTokenizer, DataCollatorForSeq2Seq, Trainer, TrainingArguments, ) DEFAULT_MODEL_PATH = "/data/coding/TFMF" DEFAULT_DATA_DIR = "./TFMF/dataset" DEFAULT_OUTPUT_DIR = "./TFMF/lora_adapters/teacher_chinese_auto" DEFAULT_LORA_TARGET_MODULES = [ "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", ] def parse_args(): parser = argparse.ArgumentParser(description="自动化 LoRA 训练脚本(新版本)") parser.add_argument( "--model-path", type=str, default=DEFAULT_MODEL_PATH, help="基座模型目录,例如 /data/coding/TFMF", ) parser.add_argument( "--data-dir", type=str, default=DEFAULT_DATA_DIR, help="训练数据目录,脚本会加载目录下所有 .jsonl 文件", ) parser.add_argument( "--data-path", type=str, default=None, help="单个训练文件路径,优先于 --data-dir", ) parser.add_argument( "--output-dir", type=str, default=DEFAULT_OUTPUT_DIR, help="LoRA 输出目录", ) parser.add_argument("--epochs", type=int, default=3, help="训练轮数") parser.add_argument("--batch-size", type=int, default=8, help="每卡 batch 大小") parser.add_argument( "--gradient-accumulation-steps", type=int, default=2, help="梯度累积步数", ) parser.add_argument("--learning-rate", type=float, default=2e-4, help="学习率") parser.add_argument( "--max-seq-length", type=int, default=2048, help="最大序列长度", ) parser.add_argument("--save-steps", type=int, default=50, help="保存间隔步数") parser.add_argument("--logging-steps", type=int, default=10, help="日志输出步数") parser.add_argument("--warmup-steps", type=int, default=10, help="warmup 步数") parser.add_argument( "--validation-split", type=float, default=0.1, help="验证集比例,0.0 表示不切分", ) parser.add_argument("--seed", type=int, default=42, help="随机种子") parser.add_argument( "--lora-r", type=int, default=32, help="LoRA r 值", ) parser.add_argument( "--lora-alpha", type=int, default=64, help="LoRA alpha 值", ) parser.add_argument( "--lora-dropout", type=float, default=0.05, help="LoRA dropout", ) return parser.parse_args() def set_seed(seed: int): random.seed(seed) torch.manual_seed(seed) if torch.cuda.is_available(): torch.cuda.manual_seed_all(seed) def find_jsonl_files(data_dir: str): if not os.path.isdir(data_dir): raise FileNotFoundError(f"数据目录不存在: {data_dir}") file_paths = sorted(glob.glob(os.path.join(data_dir, "*.jsonl"))) if not file_paths: raise FileNotFoundError(f"未在数据目录中找到 .jsonl 文件: {data_dir}") return file_paths def load_samples_from_file(path: str): if not os.path.isfile(path): raise FileNotFoundError(f"数据文件不存在: {path}") samples = [] with open(path, "r", encoding="utf-8") as f: for line in f: if not line.strip(): continue item = json.loads(line) if not all(k in item for k in ("system", "user", "assistant")): raise ValueError( f"样本缺少必要字段 system/user/assistant: {path}\n行内容: {line[:200]}" ) samples.append(item) return samples def format_sample(sample: dict, tokenizer): messages = [ {"role": "system", "content": sample["system"]}, {"role": "user", "content": sample["user"]}, {"role": "assistant", "content": sample["assistant"]}, ] text = tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=False, ) return {"text": text} def tokenize_samples(examples, tokenizer, max_length): return tokenizer( examples["text"], truncation=True, max_length=max_length, padding=False, ) def build_dataset(samples, tokenizer, max_length): dataset = Dataset.from_list(samples).map( lambda x: format_sample(x, tokenizer), batched=False, ) dataset = dataset.map( lambda x: tokenize_samples(x, tokenizer, max_length), batched=True, remove_columns=["text"], ) dataset = dataset.map(lambda x: {"labels": x["input_ids"]}) return dataset def print_config(args): print("=" * 70) print("LoRA 训练脚本参数:") for name, value in vars(args).items(): print(f" {name}: {value}") print("=" * 70) def main(): args = parse_args() set_seed(args.seed) if args.data_path is None and args.data_dir is None: raise ValueError("请通过 --data-path 或 --data-dir 指定训练数据") print_config(args) if args.data_path is not None: print(f"加载单个 JSONL 文件: {args.data_path}") samples = load_samples_from_file(args.data_path) else: print(f"加载数据目录: {args.data_dir}") file_paths = find_jsonl_files(args.data_dir) samples = [] for path in file_paths: print(f" 加载: {path}") samples.extend(load_samples_from_file(path)) if not samples: raise ValueError("没有加载到任何训练样本,请检查数据文件") random.shuffle(samples) split_index = int(len(samples) * (1 - args.validation_split)) train_samples = samples[:split_index] val_samples = samples[split_index:] if args.validation_split > 0 else [] print(f"总样本数: {len(samples)}") print(f"训练集: {len(train_samples)}") print(f"验证集: {len(val_samples)}") print("加载 tokenizer 和基座模型...") tokenizer = AutoTokenizer.from_pretrained(args.model_path, trust_remote_code=True) if tokenizer.pad_token is None: tokenizer.pad_token = tokenizer.eos_token tokenizer.padding_side = "right" model = AutoModelForCausalLM.from_pretrained( args.model_path, device_map="auto", dtype=torch.bfloat16, trust_remote_code=True, low_cpu_mem_usage=True, ) model.config.use_cache = False print("配置 LoRA...") lora_config = LoraConfig( r=args.lora_r, lora_alpha=args.lora_alpha, target_modules=DEFAULT_LORA_TARGET_MODULES, lora_dropout=args.lora_dropout, bias="none", task_type="CAUSAL_LM", ) model = get_peft_model(model, lora_config) trainable = sum(p.numel() for p in model.parameters() if p.requires_grad) total = sum(p.numel() for p in model.parameters()) print(f"可训练参数: {trainable / 1e6:.2f}M") print(f"总参数: {total / 1e9:.2f}B") print(f"可训练参数占比: {trainable / total * 100:.2f}%") print("构建训练数据集...") train_dataset = build_dataset(train_samples, tokenizer, args.max_seq_length) eval_dataset = build_dataset(val_samples, tokenizer, args.max_seq_length) if val_samples else None os.makedirs(args.output_dir, exist_ok=True) final_output_dir = os.path.join(args.output_dir, "final") os.makedirs(final_output_dir, exist_ok=True) training_args = TrainingArguments( output_dir=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.gradient_accumulation_steps, learning_rate=args.learning_rate, warmup_steps=args.warmup_steps, logging_steps=args.logging_steps, save_steps=args.save_steps, save_total_limit=3, bf16=True, fp16=False, gradient_checkpointing=True, max_grad_norm=0.3, weight_decay=0.01, report_to="none", eval_strategy="steps" if eval_dataset is not None else "no", save_strategy="steps", load_best_model_at_end=False, logging_dir=os.path.join(args.output_dir, "logs"), ) trainer = Trainer( model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset, data_collator=DataCollatorForSeq2Seq(tokenizer, padding=True), ) print("开始训练...") trainer.train() print(f"保存最终 LoRA 适配器到: {final_output_dir}") model.save_pretrained(final_output_dir) tokenizer.save_pretrained(final_output_dir) metadata = { "base_model": args.model_path, "data_source": args.data_path or args.data_dir, "train_samples": len(train_samples), "validation_samples": len(val_samples), "epochs": args.epochs, "batch_size": args.batch_size, "gradient_accumulation_steps": args.gradient_accumulation_steps, "learning_rate": args.learning_rate, "max_seq_length": args.max_seq_length, "lora_r": args.lora_r, "lora_alpha": args.lora_alpha, "lora_dropout": args.lora_dropout, } with open(os.path.join(args.output_dir, "adapter_config.json"), "w", encoding="utf-8") as f: json.dump(metadata, f, indent=2, ensure_ascii=False) total_size = 0 for root, _, files in os.walk(final_output_dir): for fname in files: total_size += os.path.getsize(os.path.join(root, fname)) print(f"最终适配器大小: {total_size / 1024 / 1024:.1f} MB") print("训练完成,请使用 final 目录下的适配器进行加载。") if __name__ == "__main__": main()