在Kaggle上微调Qwen3.5-2B

大家好,本文我将带领大家了解LLM微调的过程以及人们常犯的错误,至少是我自己在Kaggle上进行简单SFT(监督微调)时犯的错误。

整篇博客分为三个部分。第一部分是我为什么选择这样做,即为什么选择SFT、为什么选择这个模型、为什么选择这个数据集。接下来,我将介绍实现过程,我做对了什么,以及一些不该做的事情。到最后,我们应该会得到一份关于"不该做什么"的小指南,这就是我们的第三部分——结论。

1、为什么要做这个

首先,我为什么选择正则表达式?其实是因为我当时正在做另一个项目,具体来说是在一个0.8B的模型上应用SFT,使用ASCII艺术数据来训练它擅长这方面。如果你了解这些微型LLM在结构方面的能力,你就会知道我一败涂地。那个模型只会重复相同的token或者直接结束,是的,我确实应用了masking,但仍然没有成功。

现在我觉得我已经有了一个正常工作的pipeline,所以也许我应该做一些其他有趣的事情,这些事情LLM仍然能够做到,而且相对简单。于是我决定做这个项目:使用Qwen 3.5 2B模型对1.2k自定义数据集进行指令SFT,这个数据集已经发布在我的Hugging Face账户上。

我知道2B模型已经足够好,可以处理正则表达式问题,而且由于它是一个推理模型,所以准确性也相当高。所以虽然我知道它已经能够处理正则表达式问题,但我想看看指令SFT是否能在我已经很强的解决方案基础上获得任何优势。

2、环境配置

2.1 环境和硬件问题

在接触代码之前,我犯了一个错误,浪费了大约3天时间——没有检查dtype和GPU兼容性。听起来很明显,但其实不是。模型是bf16的,而我想在T4上运行,它不支持bf16,而且没有任何地方明确告诉你这就是问题所在。它只是以各种奇怪的方式失败。所以是的,在开始之前一定要检查模型的dtype和你的GPU实际支持什么。

然后是导入和设置,这 honestly 比它应该的更让人烦。如果你在本地GPU上,没问题,但在Kaggle上你就只能用你得到的。我一开始尝试在P100上运行,但遇到了问题,因为它的计算能力是sm_60,而一些较新的库和模型要求期望sm_70+。这种不匹配导致了一堆兼容性问题,所以我不得不切换到T4,它支持sm_75,与较新的设置配合得更好。

2.2 安装依赖

!pip install -q tqdm datasets huggingface_hub
!pip install -q -U transformers accelerate
!pip install -q peft trl bitsandbytes

首先我安装了所需的库,标准的东西如transformers、accelerate、peft、trl和bitsandbytes。这里没什么好想的,除了保持版本更新以避免随机问题。

然后我为T4设置了OS配置,主要是关闭混合精度,因为bf16不受支持,可能会导致兼容性问题。还添加了Hugging Face token设置,因为我要把模型推到hub上。

import os
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
os.environ["ACCELERATE_MIXED_PRECISION"] = "no"

2.3 基础配置

from datasets import load_dataset
from huggingface_hub import login
from transformers import AutoTokenizer, AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer

MODEL_NAME = "Qwen/Qwen3.5-2B"
DATASET_NAME = "ASTRALK/regex_dataset"
HF_TOKEN = ""
HF_REPO = "ASTRALK/qwen3.5-2b_regex"

MAX_SEQ_LEN = 512
LR = 2e-4
EPOCHS = 3

login(token=HF_TOKEN)

在正式开始训练之前,我定义了一些基本参数和设置。这里没什么太深的内容:模型名称、数据集、仓库、最大序列长度、学习率、epochs,以及登录Hugging Face。其中大部分都很标准,所以我没有花太多时间思考。

我唯一真正注意的是MAX_SEQ_LEN=512,因为这取决于你的数据。我确实检查了平均长度,是的,它本可以更小,但由于有足够的差异和一些较长的输出,我保留了512以确保安全,即使这会增加一些padding开销。

Batch size和grad accumulation我故意没有在这里定义,因为我想在靠近训练的地方测试它们,在那里我可以实际看到内存使用情况并据此调整。对于LR和epochs,只是典型的小型SFT设置,没什么特别的。你这里没做什么花哨的东西,只是确保它稳定训练。

2.4 Tokenizer设置

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, token=HF_TOKEN)
if tokenizer.pad_token is None:
    tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"

然后是tokenizer。我设置了padding_side="right",这通常是指令SFT想要的。主要原因是masking,因为你希望模型忽略prompt部分,专注于预测response,而right padding能很好地保持对齐。它对batching也有点帮助,因为序列更一致。我当时没有完全想清楚理论,但实际上这就是有效的方式,可以避免奇怪的masking问题。

2.5 模型加载(QLoRA + 4-bit)

bnb = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.float16,
    bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
    MODEL_NAME,
    quantization_config=bnb,
    device_map={"": 0},
    token=HF_TOKEN,
    torch_dtype=torch.float16,
)
model.config.pad_token_id = tokenizer.pad_token_id
model.config.use_cache = False

在tokenization之后,下一个任务是决定如何加载模型。由于我想要高效,我选择了4-bit,感觉这是最佳平衡点。不是完美的精度,但仍然足够好,同时实际上能在T4上运行而不会有问题。

所以我用这个配置使用bitsandbytes加载模型,这基本上是QLoRA,意味着基础模型被压缩和冻结,只训练小的adapter。我确实明确指定了float16,因为T4根本不支持bf16,所以即使原始模型是bf16的,你也必须强制fp16,否则要么会崩溃,要么行为怪异。另外,使用NF4进行量化很重要,因为它保持权重分布更接近原始,而double quant只是帮助挤出更多内存。

2.6 LoRA配置

lora_config = LoraConfig(
    r=16,
    lora_alpha=32,
    target_modules=[
        "q_proj", "k_proj", "v_proj", "o_proj",
        "gate_proj", "up_proj", "down_proj"
    ],
    lora_dropout=0.05,
    bias="none",
    task_type="CAUSAL_LM",
)

现在我使用了LoraConfig,我之前以为大多数参数只是标准的,但事实并非如此。像rlora_alpha这样的参数控制你给adapter多少容量,所以更高的值意味着更好的模式学习,但也有更高的过拟合风险,特别是在像这个约1.2k数据集的情况下。Dropout只是为了稍微减少这种情况。

实际上很重要的一点是target_modules,因为它决定了模型被允许改变的地方,所以这不是你随便复制的东西。这里我同时使用了注意力层(qkvo)和MLP层(gateupdown),这基本上给了模型足够的灵活性,不仅仅是检测模式,还能正确转换它们。对于正则表达式这类任务,输出必须精确,而不仅仅是差不多,这一点很重要。

2.7 数据集格式化

dataset = load_dataset(DATASET_NAME, split="train", token=HF_TOKEN)
def format_example(ex):
    return {
        "messages": [
            {"role": "user", "content": f"{ex['instruction']}\n\n{ex['input']}"},
            {"role": "assistant", "content": ex["output"]},
        ]
    }
dataset = dataset.map(format_example, num_proc=4, remove_columns=dataset.column_names)

下一部分是弄清楚如何实际输入数据,因为我做的是指令SFT,你不能只是传递原始列然后期望它能工作。所以我将所有内容转换成这种用户和assistant消息格式,其中指令加输入给user,输出变成assistant。

我一开始没怎么想这个,感觉只是格式化,但其实很重要,因为基础模型已经期望聊天式数据,所以这样能保持对齐而不是让它困惑。

另外,我之前跳过了数据集部分。它是一个自定义的约1.2k示例的数据集,主要是instruction-input-output,而且故意多样性很低,因为任务是正则表达式相关的。这也意味着很容易过拟合。

2.8 更多配置

import math
GRAD_ACC = 2
BATCH = 8
steps_per_epoch = math.ceil(len(dataset) / (BATCH * GRAD_ACC))
total_optimizer_steps = steps_per_epoch * EPOCHS
WARMUP_STEPS = int(0.03 * total_optimizer_steps)

对于训练,我做了通常的步骤计算,将warmup保持在3%左右以避免早期不稳定。Batch size是8,因为在T4上这使用了大约90%的VRAM,GRAD_ACC=2是为了模拟稍大的batch。老实说,你也可以保持为1。最后一步只是将所有内容转换成消息格式并删除旧列。简单的步骤,但如果这里出错,trainer会开始表现怪异。

2.9 训练配置

sft_config = SFTConfig(
    output_dir="/kaggle/working/checkpoints",
    per_device_train_batch_size=BATCH,
    gradient_accumulation_steps=GRAD_ACC,
    num_train_epochs=EPOCHS,
    learning_rate=LR,
    lr_scheduler_type="cosine",
    warmup_steps=WARMUP_STEPS,
    fp16=False,
    bf16=False,
    max_grad_norm=1.0,
    logging_steps=10,
    save_strategy="epoch",
    report_to="none",
    max_length=MAX_SEQ_LEN,
    packing=False,
    gradient_checkpointing=True,
    gradient_checkpointing_kwargs={"use_reentrant": False},
    optim="paged_adamw_8bit",
    completion_only_loss=True,
)
trainer = SFTTrainer(
    model=model,
    args=sft_config,
    train_dataset=dataset,
    peft_config=lora_config,
    processing_class=tokenizer,
)

这个SFTConfig部分基本上是所有训练行为的固定位置。大多数参数都很标准,如batch size、grad accumulation、epochs、learning rate、cosine scheduler和warmup。那里没什么特别的。

对我来说重要的部分是让它在T4上干净地工作,所以我保持fp16和bf16都关闭,因为混合精度在这种设置下可能会表现怪异,并使用gradient_checkpointing=True来节省内存。还使用了paged_adamw_8bit,因为它与QLoRA配合得很好,能保持内存使用在控制之下。

然后放入SFTTrainer,包括模型、数据集、LoRA配置和tokenizer。理论上看起来很直接,但这就是大多数烦人问题出现的地方。你找到的许多配置要么过时,要么不能很好地配合工作,所以花了一些时间才能找到在T4上正常运行的配置,不会因dtype问题、checkpointing冲突或trainer不匹配而崩溃。

2.10 训练 + 保存

trainer.train()
print("Merging LoRA")
merged_model = trainer.model.merge_and_unload()
save_path = "/kaggle/working/final_model"
merged_model.save_pretrained(save_path)
tokenizer.save_pretrained(save_path)
print(f"Saved to {save_path}")
merged_model.push_to_hub(HF_REPO, commit_message="sft merged")
tokenizer.push_to_hub(HF_REPO)
print("ok")

在所有设置之后,这部分只是运行训练并让它完成。实际上相当快,在T4上花了大约20分钟,loss从大约2.03降到0.54,对于这个规模的数据集来说是一个不错的下降。这里没什么花哨的,只是确认设置正在工作,没有在某处默默失败。

训练后,最后一步是使用merge_and_unload()将LoRA合并回基础模型,然后保存它。这部分很重要,因为否则你只是保存adapter而不是一个独立的模型。然后我把模型和tokenizer都推送到Hugging Face,这样它就可以直接使用了,不需要再次设置整个训练环境。

3、测试

最后,这部分只是测试模型,这 honestly 才是真正重要的地方,因为loss下降并不意味着你的模型能工作。你可以有一个漂亮的loss曲线,但仍然得到垃圾输出,所以你必须实际运行它看看它做了什么。如果这里有问题,那么你基本上要从数据集到配置重新调试一切,所以这一步决定训练是否值得。

我之前遇到的一个问题,不是在这里,但值得一提,是关于tokenizer,特别是添加自定义特殊token。我在另一个项目中尝试过,当时做ASCII艺术,添加了token来强制执行结构,如开始、结束、行等。看起来是个好主意,甚至在训练期间帮助了结构,但在推理时完全崩溃了。模型不断提前预测结束token或者只是重复符号。即使在masking和调整temperature之后,它仍然表现不正常,所以是的,添加自定义token听起来不错,但如果处理不当很容易搞砸事情。

from transformers import pipeline, AutoTokenizer

HF_REPO = "ASTRALK/qwen3.5-2b_regex"
tokenizer = AutoTokenizer.from_pretrained(HF_REPO)
pipe = pipeline(
    "text-generation",
    model=HF_REPO,
    tokenizer=tokenizer,
    device_map="auto",
)
prompt = (
    "### Instruction:\n"
    "Write the regular expression for the following requirement.\n\n"
    "### Input:\n"
    "write a regex code to find indian phone number\n\n"
    "### Response:\n"
)
out = pipe(
    prompt,
    max_new_tokens=1000,
    do_sample=True,
    eos_token_id=tokenizer.eos_token_id,
    pad_token_id=tokenizer.pad_token_id,
)
print(out[0]["generated_text"][len(prompt):])

对于测试,我只是使用了一个简单的pipeline设置,并传递了相同的指令式prompt。这里没什么花哨的,只是检查模型是否真的学会了模式。结果比基础模型好。以前它会生成很多不必要的文本,但现在它给出更直接的输出,并且用更少的token完成相同的任务。

4、结束

这基本上就是整个过程。对我来说主要的收获是,小细节比它们看起来的重要得多。Dtype不匹配、错误的GPU支持、tokenization选择、LoRA目标模块和训练配置对齐,这些都能决定成败。

模型本身已经很强了,所以这不是让它从零开始具备正则表达式能力。这是关于看看指令SFT是否能在已经知道任务的模型基础上提供任何真正的改进。而对于Hugging Face上的小型自定义数据集来说,这才是真正的实验。


原文链接: Fine-tuning an LLM on Kaggle: what I did, what broke, and what not to do

汇智网翻译整理,转载请标明出处