大模型写唐诗微调--LoRA + 唐诗数据集

06. 尝试微调 LLM:让它会写唐诗 | Kaggle

06.尝试微调 LLM:让它会写唐诗 github

GitHub - chinese-poetry/chinese-poetry:中国诗词数据集

chinese-poetry/全唐诗 at master · chinese-poetry/chinese-poetry · GitHub

使用 AutoModelForCausalLM  因果语言建模:是指给定之前的词或字符序列,模型预测文本序列中下一个词或字符的任务,广泛应用于生成式任务,如对话系统、文本续写、摘要生成、创意写作、代码生成等。

使用唐诗数据集,配合LoRA进行微调。

目录

1. 环境与参数配置

加载预训练模型model from_pretrained和分词器tokenizer

量化配置nf4_config    生成参数配置 generation_config   随机种子设置

2. 获取生成结果  evaluate函数 生成结果 prompt格式 -> inputs -> tokenizer -> generate -> decode

3. 微调前的效果 用evaluate跑 微调前的结果

4. 准备微调的训练数据

用generate函数 把对话数据转化为 输入 IDs、标签labels和注意力掩码"attention_mask"的字典

5. 训练相关的参数设置  训练参数配置:批次大小 LoRA微调与分布式训练

6. trainer训练  Trainer进行训练;保存模型参数

7. 模型测试  训练好的模型跑测试集

8. 比较与之前的输出


1. 环境与参数配置

import torch
from transformers import (
    AutoModelForCausalLM,  # 自动加载因果语言模型
    AutoTokenizer,         # 自动加载对应的分词器
    BitsAndBytesConfig,    # 量化配置
    GenerationConfig       # 文本生成配置
)
import logging             # 日志管理

# 设置模型名称 - 使用MediaTek的Breeze 7B指令微调模型
model_name = "MediaTek-Research/Breeze-7B-Instruct-v0_1"

量化配置:使用4-bit量化来大幅减少内存占用,使得7B模型可以在消费级GPU上运行

nf4_config = BitsAndBytesConfig(
    load_in_4bit=True,                    # 启用4-bit量化加载
    bnb_4bit_quant_type="nf4",            # 使用NF4量化类型(Normal Float 4)
    bnb_4bit_use_double_quant=True,       # 启用双重量化,进一步压缩模型大小
    bnb_4bit_compute_dtype=torch.bfloat16 # 计算时使用bfloat16精度,平衡性能和内存
)

从HuggingFace加载预训练的语言模型 from_pretrained

model = AutoModelForCausalLM.from_pretrained(
        model_name,
        cache_dir="./cache",              # 指定缓存目录
        quantization_config=nf4_config,   # 应用量化配置
        low_cpu_mem_usage=True,           # 减少CPU内存使用
        trust_remote_code=True,           # 信任远程代码(如果需要)
        device_map="auto"                 # 自动分配设备(CPU/GPU)
    )

加载与模型对应的分词器 tokenizer

# 日志级别为ERROR,减少不必要的输出
logging.getLogger('transformers').setLevel(logging.ERROR)
    
# 加载与模型对应的分词器
tokenizer = AutoTokenizer.from_pretrained(
        model_name,
        add_eos_token=True,               # 自动添加结束符
        cache_dir="./cache",
        trust_remote_code=True            # 信任远程代码
)
    
# 设置填充token与结束token相同(常见做法)
tokenizer.pad_token = tokenizer.eos_token

配置文本生成的参数 generation_config,控制生成文本的质量和多样性

max_len = 128  # 生成文本的最大长度

generation_config = GenerationConfig(
    do_sample=True,           # 使用采样而非贪婪解码(生成更多样的文本)
    temperature=0.1,          # 温度参数:较低值=更确定性,较高值=更多样性
    num_beams=1,              # 束搜索大小:1=不使用束搜索(与do_sample=True配合)
    top_p=0.3,                # 核采样参数:只考虑概率累积前30%的token
    top_k=50,                 # 只考虑前50个最可能的token
    no_repeat_ngram_size=3,   # 避免重复的3-gram,提高文本多样性
    pad_token_id=tokenizer.pad_token_id,  # 使用分词器的填充token ID
    eos_token_id=tokenizer.eos_token_id,  # 使用分词器的结束token ID
    max_new_tokens=max_len,   # 最大生成token数量
)

为确保深度学习实验的可重复性:

CPU GPU随机种子;CuDNN 自动优化器关闭,启用确定性模式

seed = 42
torch.backends.cudnn.deterministic = True # 启用 CuDNN(NVIDIA 的深度学习加速库)的确定性模式
torch.backends.cudnn.benchmark = False # 关闭 CuDNN 的自动优化器
torch.manual_seed(seed) # CPU 随机种子
if torch.cuda.is_available():
    torch.cuda.manual_seed_all(seed) # GPU 随机种子

2. 获取生成结果 evaluate函数

设置prompt为 ChatML格式,专门用于指令微调模型。

<<SYS>> 包含系统角色设定     [INST] 和 [/INST] 包裹用户输入 {instruction} 和 {input_text}。

prompt格式 -> inputs -> tokenizer -> generate -> decode 并把格式化的部分去掉。

def evaluate(instruction, generation_config, max_len, input_text="", verbose=True):
    # 构建完整的输入提示词 - 使用特定的指令格式
    prompt = f"""\
    [INST] <<SYS>>
    You are a helpful assistant and good at writing Tang poem. 你是一個樂於助人的助手且擅長寫唐詩。
    <</SYS>>

    {instruction}
    {input_text}
    [/INST]"""

    # 将提示词转换为模型所需的 token 格式
    inputs = tokenizer(prompt, return_tensors="pt")
    input_ids = inputs["input_ids"].cuda()  # 将输入移到GPU上
    
    # 使用模型生成回复
    generation_output = model.generate(
        input_ids=input_ids,
        generation_config=generation_config,
        return_dict_in_generate=True,
        output_scores=True,
        max_new_tokens=max_len,
    )
    
    # 解码并处理生成的回复
    for s in generation_output.sequences:
        output = tokenizer.decode(s)  # 将token解码为文本
        
        # 清理输出文本:移除指令标记和助手标识
        output = output.split("[/INST]")[1]  # 只取[/INST]之后的部分(模型回复)
        output = output.replace("</s>", "").replace("<s>", "")  # 移除特殊token
        output = output.replace("Assistant:", "").replace("Assistant", "")  # 移除助手标识
        output = output.strip()  # 去除首尾空白
        
        if verbose:
            print(output)
    
    return output

3. 微调前的效果

# 测试样例
test_tang_list = [
    '相見時難別亦難,東風無力百花殘。',
    '重帷深下莫愁堂,臥後清宵細細長。',
    '芳辰追逸趣,禁苑信多奇。'
]

# 获取每个样例的模型输出
demo_before_finetune = []
for tang in test_tang_list:
    demo_before_finetune.append(
        f'模型輸入:\n以下是一首唐詩的第一句話,請用你的知識判斷並完成整首詩。{tang}\n\n模型輸出:\n' +
        evaluate('以下是一首唐詩的第一句話,請用你的知識判斷並完成整首詩。', generation_config, max_len, tang, verbose=False)
    )

# 打印并将输出存储到文本文件
for idx in range(len(demo_before_finetune)):
    print(f"Example {idx + 1}:")
    print(demo_before_finetune[idx])
    print("-" * 80)

可以发现,模型只会重复输入中的一些文字,不能很好的完成任务。

比如说“相见时难别亦难,东风无力百花残”  只是在重复 “相见难,别难,东风,百花” 这些字词。

4. 准备微调的训练数据 generate函数

数据链接   git clone https://github.com/CheeEn-Yu/GenAI-Hw5.git

将输入和输出文本转换为模型可读取的 tokens。

参数输入:- data_point: 包含对话中 "instruction"、"input" 和 "output" 字段的字典。

输出:- 包含模型输入 IDs、标签labels和注意力掩码"attention_mask"的字典。 

标签掩码labels分为(1)用户提示部分:用-100标记,表示在训练时不需要计算这些token的损失(模型不应该学习预测它已经看到的内容(用户输入);

(2)模型回复部分:使用实际的token ID

设置prompt 包含 系统角色设定、{instruction} 和 {input_text}  同evaluate函数

计算用户prompt的token数量;还有full_tokens对应完整的输入输出 prompt + output

def generate_training_data(data_point):
    # 构建完整的输入提示词 - 使用与推理时相同的格式
    prompt = f"""\
    [INST] <<SYS>>
    You are a helpful assistant and good at writing Tang poem. 你是一個樂於助人的助手且擅長寫唐詩。
    <</SYS>>

    {data_point["instruction"]}
    {data_point["input"]}
    [/INST]"""

    # 计算用户提示词的 token 数量(不包括模型回复部分)
    len_user_prompt_tokens = (
        len(
            tokenizer(
                prompt,
                truncation=True,
                max_length=CUTOFF_LEN + 1,
                padding="max_length",
            )["input_ids"]
        ) - 1  # 减去1是因为tokenizer可能会添加特殊token
    )

    # 将完整的输入和输出转换为 tokens(提示词 + 期望的输出)
    full_tokens = tokenizer(
        prompt + " " + data_point["output"] + "</s>",  # 添加结束符
        truncation=True,
        max_length=CUTOFF_LEN + 1,
        padding="max_length",
    )["input_ids"][:-1]  # 去掉最后一个可能的多余token

    return {
        "input_ids": full_tokens,  # 完整的token序列
        "labels": [-100] * len_user_prompt_tokens + full_tokens[len_user_prompt_tokens:],
        "attention_mask": [1] * len(full_tokens),  # 注意力掩码(全为1)
    }

5. 训练相关的参数设置

设置用于训练的数据量   训练数据越多,模型见到的诗句越多样化,生成质量越好,但训练时间越长

# 1040条数据:微调约25分钟,完整运行约50分钟 # 5000条数据:微调约100分钟,完整运行约120分钟

num_train_data = 1040  # 设置用于训练的数据量,最大值为5000

路径与日志保存设置

# ==================== 路径设置 ====================
output_dir = "./output"  # 作业结果输出目录
ckpt_dir = "./exp1"     # 模型checkpoint保存目录
cache_dir = "./cache"   # 缓存目录路径
dataset_dir = "./GenAI-Hw5/Tang_training_data.json"  # 训练数据集路径

# ==================== 日志和保存设置 ====================
logging_steps = 20      # 每隔20步输出一次日志
save_steps = 65         # 每隔65步保存一次模型
save_total_limit = 3    # 最多保留3个模型checkpoint(自动删除旧的)
report_to = "none"      # 不上报实验指标(避免外部依赖)

训练参数与批次大小设置

# ==================== 训练参数 ====================
num_epoch = 1           # 训练的总Epoch数(轮次)
LEARNING_RATE = 3e-4    # 学习率(控制参数更新幅度)
from_ckpt = False       # 是否从checkpoint加载模型权重
ckpt_name = None        # 加载特定checkpoint的文件名
CUTOFF_LEN = 256        # 文本截断的最大长度(防止内存溢出)

# ==================== 批次大小设置 ====================
MICRO_BATCH_SIZE = 4    # 微批次大小(每次前向传播处理的样本数)
BATCH_SIZE = 16         # 有效批次大小(梯度累积后的总批次大小)
GRADIENT_ACCUMULATION_STEPS = BATCH_SIZE // MICRO_BATCH_SIZE  # 梯度累积步数 = 4

LoRA微调与分布式训练

# ==================== LoRA参数 ====================
LORA_R = 8              # LoRA的秩(rank),控制低秩近似的维度
LORA_ALPHA = 16         # LoRA的缩放参数,控制LoRA更新的幅度
LORA_DROPOUT = 0.05     # LoRA层的Dropout率(防止过拟合)
TARGET_MODULES = [      # 应用LoRA的目标模块(这些层的权重会被微调)
    "q_proj", "up_proj", "o_proj", "k_proj", 
    "down_proj", "gate_proj", "v_proj"
]

# ==================== 分布式训练设置 ====================
VAL_SET_SIZE = 0        # 验证集大小(0表示不使用验证集)
device_map = "auto"     # 自动分配设备(CPU/GPU)
world_size = int(os.environ.get("WORLD_SIZE", 1))  # 获取分布式训练的世界大小
ddp = world_size != 1   # 判断是否使用分布式数据处理

if ddp:
    device_map = {"": int(os.environ.get("LOCAL_RANK") or 0)}
    GRADIENT_ACCUMULATION_STEPS = GRADIENT_ACCUMULATION_STEPS // world_size

6. trainer训练

加载模型,设置保存参数的位置 ckpt_dir

os.environ["TOKENIZERS_PARALLELISM"] = "false" # 禁用并行性以避免报错
# 创建指定的输出目录
os.makedirs(output_dir, exist_ok=True) # 保存输出
os.makedirs(ckpt_dir, exist_ok=True) # 保存参数权重

# 从 checkpoint 加载模型权重
if from_ckpt:
    model = PeftModel.from_pretrained(model, ckpt_name)

model = prepare_model_for_kbit_training(model) # 准备模型

使用 LoraConfig 配置 LORA 模型

批次训练时,需要将不同长度的序列填充到相同长度设置 pad_token_id=0 确保填充部分不被模型关注

config = LoraConfig(
    r=LORA_R,                    # LoRA的秩(rank)
    lora_alpha=LORA_ALPHA,       # LoRA的缩放参数
    target_modules=TARGET_MODULES, # 要应用LoRA的目标模块
    lora_dropout=LORA_DROPOUT,   # LoRA层的dropout率
    bias="none",                 # 偏置参数处理方式
    task_type="CAUSAL_LM",       # 任务类型
)
model = get_peft_model(model, config)

# 将 tokenizer 的填充 token 设置为 0
tokenizer.pad_token_id = 0

加载并处理训练数据 json格式 取前num_train_data条数据(如1040条);

分为训练集和验证集;

对每个样本应用generate_training_data函数并shuffle打乱

# 加载并处理训练数据
with open(dataset_dir, "r", encoding="utf-8") as f:
    data_json = json.load(f)
with open("tmp_dataset.json", "w", encoding="utf-8") as f:
    json.dump(data_json[:num_train_data], f, indent=2, ensure_ascii=False)

data = load_dataset('json', data_files="tmp_dataset.json", download_mode="force_redownload")

# 将训练数据分为训练集和验证集(若 VAL_SET_SIZE 大于 0)
if VAL_SET_SIZE > 0:
    train_val = data["train"].train_test_split(
        test_size=VAL_SET_SIZE, shuffle=True, seed=42
    )
    train_data = train_val["train"].shuffle().map(generate_training_data)
    val_data = train_val["test"].shuffle().map(generate_training_data)
else:
    train_data = data['train'].shuffle().map(generate_training_data)
    val_data = None

Trainer参数:数据加载和批次处理;学习率调度;日志记录 和 checkpoint保存;

保存训练好的LoRA适配器权重。

# 使用 Transformers Trainer 进行模型训练
trainer = transformers.Trainer(
    model=model,                # 要训练的模型
    train_dataset=train_data,   # 训练数据集
    eval_dataset=val_data,      # 验证数据集(可选)
    args=transformers.TrainingArguments(
        per_device_train_batch_size=MICRO_BATCH_SIZE,        # 每个设备的批次大小
        gradient_accumulation_steps=GRADIENT_ACCUMULATION_STEPS, # 梯度累积步数
        warmup_steps=50,                                     # 热身步数
        num_train_epochs=num_epoch,                          # 训练轮数
        learning_rate=LEARNING_RATE,                         # 学习率
        fp16=True,                                           # 使用混合精度训练
        logging_steps=logging_steps,                         # 日志输出间隔
        save_strategy="steps",                               # 按步数保存
        save_steps=save_steps,                               # 保存间隔
        output_dir=ckpt_dir,                                 # 输出目录
        save_total_limit=save_total_limit,                   # 最多保存的checkpoint数
        ddp_find_unused_parameters=False if ddp else None,   # DDP设置
        report_to=report_to,                                 # 报告目标
    ),
    data_collator=transformers.DataCollatorForLanguageModeling(tokenizer, mlm=False),
)

# 禁用模型的缓存功能
model.config.use_cache = False

# 开始模型训练
trainer.train()

# 将训练好的模型保存到指定目录
model.save_pretrained(ckpt_dir)

7. 模型测试

GenAI-Hw5/Tang_testing_data.json 测试数据

training 时保存的 ckpt_dir ,从中找出最后一个 checkpoint (最后训练的参数结果)但可能有些过拟合

# 查找所有可用的 checkpoints
ckpts = []
for ckpt in os.listdir(ckpt_dir):
    if ckpt.startswith("checkpoint-"):
        ckpts.append(ckpt)

# 列出所有的 checkpoints
ckpts = sorted(ckpts, key=lambda ckpt: int(ckpt.split("-")[-1]))

# 选择最后一个(训练的最好 但有过拟合风险)
ckpt_name = os.path.join(ckpt_dir, ckpts[-1])

释放显存 防止后续推理空间不够。

import gc

# 删除模型和 tokenizer 对象
del model
del tokenizer

# 调用垃圾回收机制,强制释放未使用的内存
gc.collect()

# 清理 GPU 缓存
torch.cuda.empty_cache()

生成的参数设置generation_config(关于温度和多样性等);把ckpt参数加载到model

# 两个路径
test_data_path = "GenAI-Hw5/Tang_testing_data.json"  # 测试数据集的路径
output_path = os.path.join(output_dir, "results.txt")  # 生成结果的输出路径

# 从 checkpoint 加载已保存的模型权重
model = PeftModel.from_pretrained(model, ckpt_name, device_map={'': 0})

results = []
max_len = 128  # 生成回复的最大长度

# 设置生成配置,包括随机度、束搜索等参数
generation_config = GenerationConfig(
    do_sample=True,              # 启用采样生成(而非贪婪搜索),增加输出多样性
    num_beams=1,                 # 束搜索大小,1表示不使用束搜索(与do_sample=True配合使用)
    pad_token_id=2,              # 设置填充token的ID,确保生成时正确处理序列填充
    temperature=0.1,             # 温度参数:控制生成随机性,值越小(0.1)生成的回复越稳定和确定性
    top_p=0.3,                   # Top-p(核采样)概率阈值:只考虑累积概率前30%的token,控制生成多样性
    top_k=5,                     # Top-k采样值:每步只从概率最高的5个token中采样,增加多样性并避免重复词汇
    no_repeat_ngram_size=3,      # 禁止重复的N-gram大小:避免生成重复的3-gram片段,提高文本质量
)

文件读取json.load 与写入write,evaluate预测并输出结果;

# 读取测试数据集
with open(test_data_path, "r", encoding="utf-8") as f:
    test_datas = json.load(f)

# 对每个测试样例生成预测,并保存结果
with open(output_path, "w", encoding="utf-8") as f:
    for (i, test_data) in enumerate(test_datas):
        predict = evaluate(test_data["instruction"], generation_config, max_len, test_data["input"], verbose=False)
        f.write(f"{i+1}. " + test_data["input"] + predict + "\n")
        print(f"{i+1}. " + test_data["input"] + predict)

以下为测试的输入输出,有一些文乎的感觉。

8. 比较与之前的输出

# 使用之前的测试例子
test_tang_list = [
    '相見時難別亦難,東風無力百花殘。',
    '重帷深下莫愁堂,臥後清宵細細長。',
    '芳辰追逸趣,禁苑信多奇。'
]

# 使用微调后的模型进行推理,直接输出结果
for idx, tang in enumerate(test_tang_list):
    print(f"Example {idx + 1}:")
    print(f'模型輸入:\n以下是一首唐詩的第一句話,請用你的知識判斷並完成整首詩。{tang}')
    print('\n模型輸出:')
    output = evaluate('以下是一首唐詩的第一句話,請用你的知識判斷並完成整首詩。', generation_config, max_len, tang, verbose=False)
    print(output)
    print("-" * 80)

可以看到和最开始有着很大的差别,最起码不是复读机了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值