1. 引言
交叉验证主要讨论的是数据集的划分问题。
通常情况下,我们会采用均匀随机抽样的方式将数据集划分成3个部分——训练集、验证集和测试集,这三个集合不能有交集,常见的比例是8:1:1(如同前文我们所作的划分)。这三个数据集的用途分别是:
- 训练集:用来训练模型,去学习模型的权重和偏置这些参数,这些参数可称为学习参数。
- 验证集:用于在训练过程中选择超参数,比如批量大小、学习率、迭代次数等,它并不参与梯度下降,也不参与学习参数的确定。
- 测试集:用于训练完成后评价最终的模型时使用,它既不参与学习参数的确定,也不参数超参数的选择,而仅仅使用于模型的评价。
注:千万不能在训练过程中使用测试集,不论是用于训练还是用于超参数的选择,这会将测试数据无意中提前透露给模型,相当于作弊,使得模型测试时准确率虚高。
而交叉验证与上述不同的地方在于:在手动划分时只分出训练集和测试集,到真正训练时才从训练集中动态抽取一定比例作为验证集,并且在多轮训练中会循环提取不同的训练集和验证集。数据集划分大概如下图:
- 第一轮训练时,将训练集平均分成5份,选1份作为验证集,其余4份作为训练集。
- 第二轮训练时,取另外的1份作为验证集,剩余4份作为训练集。
- ……
- 如此循环,直到每份数据都参与过训练和验证。
这样做的好处在于:模型能更充分的利用数据,更全面的学习到数据的整体特征,减少过拟合风险。
2. 训练过程
2.1 初始化
这一部分同前文训练的预设一样,基本没有什么改变。
%run trainer.py
traindata_path = '/data2/anti_fraud/dataset/train0819.jsonl'
evaldata_path = '/data2/anti_fraud/dataset/eval0819.jsonl'
model_path = '/data2/anti_fraud/models/modelscope/hub/Qwen/Qwen2-1___5B-Instruct'
output_path = '/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_1'
声明要使用的GPU设备。
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
device = 'cuda'
加载模型和tokenizer分词器。
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
train_dataset, eval_dataset = load_dataset(traindata_path, evaldata_path, tokenizer)
2.2 数据处理
这一部分主要是将前文构造训练/测试数据集所构造的训练集和验证集合并,采用sklearn库中的KFold重新按折子进行数据集分割。
import glob
import gc
import numpy as np
from datasets import Dataset, concatenate_datasets
from sklearn.model_selection import KFold
拼接训练集和验证集作为一个数据集。
datasets = concatenate_datasets([train_dataset, eval_dataset])
len(datasets)
21135
创建KFold对象用于按折子划分数据集。
- n_splits=5:表示将数据集划分为5份。
- shuffle=True:表示调用
kf.split
划分数据集前先将顺序打乱。
KFold是由sklearn库提供的k折交叉验证方法,它通过将数据集分成k个相同大小的子集(称为折),每次迭代数据集时,使用其中一个作为验证集,其余4个作为训练集,并重复这个过程k次。
kf = KFold(n_splits=5, shuffle=True)
kf
KFold(n_splits=5, random_state=None, shuffle=True)
用kfold划分数据集时,实际拿到的是数据在数据集中的索引顺序,如下面示例的效果。
indexes = kf.split(np.arange(len(datasets)))
train_indexes, val_indexes = next(indexes)
train_indexes, val_indexes, len(train_indexes), len(val_indexes)
(array([ 0, 2, 3, ..., 21129, 21131, 21134]),
array([ 1, 9, 12, ..., 21130, 21132, 21133]),
16908,
4227)
如上所示,训练集的数量
16908
和验证集的数量4227
比例基本是4:1。
2.3 超参数定义
定义超参构造函数,包括训练参数和Lora微调参数。这里相对于之前作的调整在于:
- 修改评估和保存模型的策略,由每100step改为每个epoch保存一次,原因是前者保存的checkpoint有太多冗余,节省一些磁盘空间。
- 将num_train_epochs调整为2,表示每个折子的数据集训练2遍,k=5时数据总共会训练10遍。
注:当
per_device_train_batch_size=16
时训练过程中会意外发生OOM,所以临时将批次大小per_device_train_batch_size改为8.
def build_arguments(output_path):
train_args = build_train_arguments(output_path)
train_args.eval_strategy='epoch'
train_args.save_strategy='epoch'
train_args.num_train_epochs = 2
train_args.per_device_train_batch_size = 8
lora_config = build_loraconfig()
lora_config.lora_dropout = 0.2
lora_config.r = 16
lora_config.lora_alpha = 32
return train_args, lora_config
Lora配置和前文最后一次训练的配置相同,秩采用16,dropout采用0.2.
2.4 重新定义模型加载
由于训练过程中需要迭代更换不同的训练集和验证集组合,而更换数据集就需要重新创建训练器,传入新的模型实例,相当于从头开始训练。
为了实现后一次训练能在前一次训练结果的基础上继续训练,就需要找到前一次训练的最新checkpoint。所以定义一个find_last_checkpoint
方法,用于从一个目录中查找最新的checkpoint。
# 确定最后的checkpoint目录
def find_last_checkpoint(output_dir):
checkpoint_dirs = glob.glob(os.path.join(output_dir, 'checkpoint-*'))
last_checkpoint_dir = max(checkpoint_dirs, key=os.path.getctime)
return last_checkpoint_dir
find_last_checkpoint("/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0830_1")
'/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0830_1/checkpoint-3522'
- glob.glob 函数可以在指定目录下查找所有匹配
checkpoint-*
模式的子目录- os.path.getctime 返回文件的创建时间(或最近修改时间)
- max 函数根据这些时间找出最后创建的目录,也就是最新的checkpoint。
定义一个新的加载模型的方法,用于从基座模型和指定的checkpoint中加载最新训练的模型,并根据训练目标来设置参数的require_grad属性。
def load_model_with_checkpoint(model_path, checkpoint_path='', device='cuda'):
# 加载模型
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16, trust_remote_code=True).to(device)
# 加载lora权重
if checkpoint_path:
model = PeftModel.from_pretrained(model, model_id=checkpoint_path).to(device)
# 将基础模型的参数设置为不可训练
for param in model.base_model.parameters():
param.requires_grad = False
# 将 LoRA 插入模块的参数设置为可训练
for name, param in model.named_parameters():
if 'lora' in name:
param.requires_grad = True
return model
如上代码逻辑所示,将来自lora的参数都设置为需要梯度
requires_grad = True
,其余原始基座模型的参数设置不可训练requires_grad = False
。
2.5 构建训练过程
在这个训练过程中,除了第一次训练是从0初始化的微调秩矩阵,后面几次训练则都是从指定checkpoint来初始化微调秩,这导致了原先定义的build_trainer方法不通用。所以定义一个新的训练器构建方法,将加载微调参数的逻辑移到外面。
def build_trainer_v2(model, tokenizer, train_args, train_dataset, eval_dataset):
# 开启梯度检查点时,要执行该方法
if train_args.gradient_checkpointing:
model.enable_input_require_grads()
return Trainer(
model=model,
args=train_args,
train_dataset=train_dataset,
eval_dataset=eval_dataset,
data_collator=DataCollatorForSeq2Seq(tokenizer=tokenizer, padding=True),
callbacks=[EarlyStoppingCallback(early_stopping_patience=5)], # 早停回调
)
下面定义交叉训练的主循环。
results = []
last_checkpoint_path = ''
for fold, (train_index, val_index) in enumerate(kf.split(np.arange(len(datasets)))):
train_dataset = datasets.select(train_index)
eval_dataset = datasets.select(val_index)
output_path = f'/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_{fold}'
train_args, lora_config = build_arguments(output_path)
# 第一次训练和后面几次训练所采用的模型加载方法不同
if last_checkpoint_path:
model = load_model_with_checkpoint(model_path, last_checkpoint_path, device)
else:
model = load_model(model_path, device)
model = get_peft_model(model, load_config)
model.print_trainable_parameters()
trainer = build_trainer_v2(model, tokenizer, train_args, train_dataset, eval_dataset)
train_result = trainer.train()
results.append(train_result)
last_checkpoint_path = find_last_checkpoint(output_path)
代码逻辑说明:
- kf.split函数划分了5份数据索引,以这5份数据索引进行5次迭代。
- 使用
datasets.select
基于索引在每次迭代时选择不同的数据作为训练集和验证集。 - 为了避免前次迭代训练的结果被下次迭代的结果给覆盖,每次迭代训练通过fold来拼接不同的输出目录output_path。
- 如果存在last_checkpoint_path,则从checkpoint来加载模型,如果不存在,则使用get_peft_model向模型中插入一个新的Lora微调秩。
- 使用新的build_trainer_v2方法来构建训练器并开始训练。
- 每次迭代完都找出此次训练中最新的checkpoint,作为下次训练的起点。
2.6 开始训练
运行上面的主循环开始训练。
最终可以收集到5次迭代训练的损失数据如下,每次迭代跑2轮数据集,共跑了10轮数据集。
Epoch | Training Loss | Validation Loss |
---|---|---|
1 | 0.0233 | 0.02189 |
2 | 0.0138 | 0.01614 |
3 | 0.008800 | 0.011420 |
4 | 0.004600 | 0.013666 |
5 | 0.003200 | 0.004718 |
6 | 0.003000 | 0.004082 |
7 | 0.007200 | 0.001999 |
8 | 0.000000 | 0.000814 |
9 | 0.004900 | 0.002273 |
10 | 0.010200 | 0.002139 |
对比前面欺诈文本分类微调(七)—— lora单卡二次调优训练进行到2300步左右(大概两遍数据)就开始过拟合(主要现象是验证损失到0.0161就不再下降反而开始升高)。K折交叉训练直到第4次迭代(大概八遍数据)过后才达到损失最低点,第5次迭代才出现了略微的过拟合(相比于第4次),过拟合的现象得到了极大的缓解,验证损失也降到了一个更低的值0.000814,这说明数据相比之前训练来说得到了更充分的使用。
3. 评估测试
由于交叉训练中验证集和训练集都参与了模型学习参数的更新,所以用验证集进行评估已经没有意义。我们直接用测试集进行最后的评估。
第一轮迭代结果的评测:
%run evaluate.py
checkpoint_path='/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_0/checkpoint-4226'
testdata_path = '/data2/anti_fraud/dataset/test0819.jsonl'
evaluate(model_path, checkpoint_path, testdata_path, device, batch=True, debug=True)
progress: 100%|██████████| 2349/2349 [03:19<00:00, 11.75it/s]
tn:1135, fp:32, fn:128, tp:1054
precision: 0.9705340699815838, recall: 0.8917089678510999
第三轮迭代结果的评测:
%run evaluate.py
checkpoint_path='/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_2/checkpoint-4226'
testdata_path = '/data2/anti_fraud/dataset/test0819.jsonl'
evaluate(model_path, checkpoint_path, testdata_path, device, batch=True, debug=True)
progress: 100%|██████████| 2349/2349 [03:21<00:00, 11.64it/s]
tn:1133, fp:34, fn:64, tp:1118
precision: 0.9704861111111112, recall: 0.9458544839255499
第四次迭代结果的评测:
%run evaluate.py
checkpoint_path='/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_3/checkpoint-4226'
testdata_path = '/data2/anti_fraud/dataset/test0819.jsonl'
evaluate(model_path, checkpoint_path, testdata_path, device, batch=True, debug=True)
progress: 100%|██████████| 2349/2349 [03:21<00:00, 11.66it/s]
tn:1128, fp:39, fn:64, tp:1118
precision: 0.9662921348314607, recall: 0.9458544839255499
第五次迭代结果的评测:
%run evaluate.py
checkpoint_path='/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0903_4/checkpoint-4226'
testdata_path = '/data2/anti_fraud/dataset/test0819.jsonl'
evaluate(model_path, checkpoint_path, testdata_path, device, batch=True, debug=True)
progress: 100%|██████████| 2349/2349 [03:22<00:00, 11.58it/s]
tn:1124, fp:43, fn:50, tp:1132
precision: 0.963404255319149, recall: 0.9576988155668359
与之前单卡训练和多卡微调的结果相比,精确率有一点点下降(0.9953->0.9634
),但召回率却有了一个比较大的提升(0.9129->0.9576
),这个测评结果的数据变化与上面损失结果的数据变化基本是一致的。
小结:本文通过引入K折交叉验证方法,循环选择不同的训练集和验证集进行多次迭代训练,将损失降到了一个更低的值,也在很大程度上缓解了[前面每次训练]过程中都出现的过拟合现象。最终在从未见过的测试数据集上进行评测时,召回率指标也有了一个较大的提升。从这个结果来看,K折交叉验证这种方法确实能让模型对数据学习的更充分,有助于模型泛化能力的提升。