1. 引言
前文数据校正与增强进行了数据增强,本文将使用增强后的数据对模型进行进一步训练,以便得到能同时预测出分类标签、欺诈者、分类原因多个信息的模型。
为此,我们需要对整个训练过程进行调整,包括:
- 交叉训练逻辑封装
- 数据序列化的改造
- 评测方法改造
2. 交叉训练封装
首先,我们将前文 交叉训练验证的代码封装为一个脚本trainer_cross.py
,方便复用。内容如下:
import glob
import gc
import numpy as np
from datasets import Dataset, concatenate_datasets
from sklearn.model_selection import KFold
from trainer import *
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
def load_model_v2(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
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)], # 早停回调
)
def train_kfold(model_path, output_base_path, datasets, build_args_func, fold_num=5, device='cuda', last_checkpoint_path=''):
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
kf = KFold(n_splits=fold_num, shuffle=True)
results = []
for fold, (train_index, val_index) in enumerate(kf.split(np.arange(len(datasets)))):
print(f"fold={fold} start, train_index={train_index}, val_index={val_index}")
train_dataset = datasets.select(train_index)
eval_dataset = datasets.select(val_index)
print(f"train data: {len(train_dataset)}, eval: {len(eval_dataset)}")
output_path = f'{output_base_path}_{fold}'
train_args, lora_config = build_args_func(output_path)
if last_checkpoint_path:
model = load_model_v2(model_path, last_checkpoint_path, device)
print(f"fold={fold}, load model from checkpoint: {last_checkpoint_path}")
else:
model = AutoModelForCausalLM.from_pretrained(model_path, torch_dtype=torch.bfloat16).to(device)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()
trainer = build_trainer_v2(model, tokenizer, train_args, train_dataset, eval_dataset)
train_result = trainer.train()
print(f"fold={fold}, result = {train_result}")
results.append(train_result)
last_checkpoint_path = find_last_checkpoint(output_path)
return results
其中,各个方法的作用释义如下:
- find_last_checkpoint:用于从一个目录下查找最新的checkpoint。
- load_model_v2:加载模型和微调的checkpoint,并将lora权重设置为可训练,非lora权重设置为不可训练。
- build_trainer_v2:构造训练器
- train_kfold:封装K折交叉训练验证的主循环逻辑,循环的每个批次为不同的数据集
train_kfold
是此脚本最终对外公开的方法,它开放了如下参数以便灵活调整训练过程:
- model_path:基座模型路径;
- output_base_path:输出模型的基础路径,K折交叉训练会以此路径为基础,来构造每一折数据的输出路径;
- datasets:经过预处理后的数据集;
- build_args_func:构造训练参数的方法,根据output_path来构造训练参数和Lora参数;
- fold_num: 数据集要分割的折数;
- device: 训练的GPU设备;
- last_checkpoint_path: 最近一次训练的checkpoint路径,当接着上一次的训练结果继续训练时传此参数。
3. 数据加载改造
当输出数据改变后,模型的预期输出不再仅仅是一个分类标签,还需要包括欺诈者和分类原因。因此,我们加载数据和数据序列化的方式需要作相应调整。
改造数据预处理函数,扩展with_reason参数,参数值定义:
- true:表示预期结果除了is_fraud字段外,还包含fraud_speaker和reason字段。
- false:表示预期结果不包含fraud_speaker和reason字段。
代码如下(有变化的仅仅是if with_reason
的判断分支)。
def preprocess(item, tokenizer, with_reason=False, max_length=2048):
system_message = "You are a helpful assistant."
user_message = item['instruction'] + '\n' + item['input']
if with_reason:
output = {"is_fraud":item["label"], "fraud_speaker":item["fraud_speaker"], "reason":item["reason"]}
else:
output = {"is_fraud":item["label"]}
assistant_message = json.dumps(output, ensure_ascii=False)
input_ids, attention_mask, labels = [], [], []
instruction = tokenizer(f"<|im_start|>system\n{system_message}<|im_end|>\n<|im_start|>user\n{user_message}<|im_end|>\n<|im_start|>assistant\n", add_special_tokens=False)
response = tokenizer(assistant_message, add_special_tokens=False)
input_ids = instruction["input_ids"] + response["input_ids"] + [tokenizer.pad_token_id]
attention_mask = instruction["attention_mask"] + response["attention_mask"] + [1]
# -100是一个特殊的标记,用于指示指令部分的token不应参与损失计算
labels = [-100] * len(instruction["input_ids"]) + response["input_ids"] + [tokenizer.pad_token_id]
# 对输入长度做一个限制保护,超出截断
return {
"input_ids": input_ids[:max_length],
"attention_mask": attention_mask[:max_length],
"labels": labels[:max_length]
}
相应对外的load_dataset方法也扩展with_reason参数,目的兼容之前的单独分类标签训练,支持带原因和不带原因两种加载数据的模式。
def load_one_dataset(data_path, tokenizer, with_reason:bool):
df = load_jsonl(data_path)
ds = Dataset.from_pandas(df)
return ds.map(
lambda x: preprocess(x, tokenizer, with_reason=with_reason),
remove_columns=ds.column_names)
def load_dataset(train_path, eval_path, tokenizer, with_reason=False):
train_dataset = load_one_dataset(train_path, tokenizer, with_reason)
eval_dataset = load_one_dataset(eval_path, tokenizer, with_reason)
return train_dataset, eval_dataset
4. 开始训练
4.1 初始化
初始化改为引入新封装的脚本trainer_cross.py
。
%run trainer_cross.py
数据路径、模型路径、设备定义基本和之前保持一致。
traindata_path = '/data2/anti_fraud/dataset/train0902.jsonl'
evaldata_path = '/data2/anti_fraud/dataset/eval0902.jsonl'
model_path = '/data2/anti_fraud/models/modelscope/hub/Qwen/Qwen2-1___5B-Instruct'
output_base_path = '/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0913'
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
device = 'cuda'
加载数据集,使用concatenate_datasets
方法将训练集和验证集合并为一个数据集。
tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True)
train_dataset, eval_dataset = load_dataset(traindata_path, evaldata_path, tokenizer, with_reason=True, lazy=False)
datasets = concatenate_datasets([train_dataset, eval_dataset])
4.2 训练
定义参数构造的方法,用于构造训练参数和Lora参数,具体参数值保持与之前相同。
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
调用train_kfold方法开始训练:
results = train_kfold(model_path, output_base_path, datasets, build_args_func=build_arguments, fold_num=5, last_checkpoint_path=last_checkpoint_path)
总共进行了5折数据10轮训练,每折数据进行了两轮训练,相应的训练损失和验证损失数据如下:
Epoch | Training Loss | Validation Loss |
---|---|---|
1 | 0.780100 | 0.825167 |
2 | 0.696700 | 0.813522 |
3 | 0.785400 | 0.738886 |
4 | 0.666200 | 0.731676 |
5 | 0.679400 | 0.619393 |
6 | 0.558900 | 0.610776 |
7 | 0.582100 | 0.503672 |
8 | 0.429700 | 0.490893 |
9 | 0.483300 | 0.394778 |
10 | 0.308000 | 0.372799 |
4.3 评测
根据验证损失数据,基于前文支持分类原因评测改造的脚本,采用微调效果最好的最后一轮checkpoint进行评测。
%run evaluate_v2.py
testdata_path = '/data2/anti_fraud/dataset/test0902.jsonl'
checkpoint_path = '/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0913_4/checkpoint-5454'
evaluate_v2(model_path, checkpoint_path, testdata_path, device, debug=True)
三个字段的评测指标分别如下:
字段 | 指标 |
---|---|
is_fraud | precision: 0.9422, recall: 0.9434, accuracy: 0.9419 |
fraud_speaker | accuracy: 0.9175 |
reason | precision: 0.3596, recall: 0.3708, f1-score: 0.3571 |
经过训练后,三个字段的指标都有不同程度的提高,分别为:
- is_fraud: precision从
0.6232
提升到0.9422
,表明模型在欺诈文本分类任务上的精确率有明显提高,这能减少欺诈文本误报的次数; - fraud_speaker: accuracy从
0.6327
提升到0.9175
,表明模型能有效的识别哪些说话者可能涉及欺诈; - reason: 召回率从
0.2324
提升到0.3708
,f1-score从0.2638
提升到0.3571
。
可以看到,is_fraud和fraud_speaker两个字段的准确率提升是比较明显的,而reason字段的召回率也有一定程度的提升,但分数没有那么高。
猜想原因可能在于训练不充分,因为从上面训练的损失数据中能看到一个现象:整个10轮训练下来,不论是训练损失还是验证损失都还在持续下降,这说明训练还未完成。
5. 再次训练
调整输出目录,并定义最近一次训练结构的checkpoint路径:
output_base_path = '/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0924'
last_checkpoint_path = '/data2/anti_fraud/models/Qwen2-1___5B-Instruct_ft_0913_4/checkpoint-5454'
将折子数调整为10, 其它都和上面相同,基于指定的checkpoint继续训练:
results = train_kfold(model_path, output_base_path, datasets, build_args_func=build_arguments, fold_num=10, last_checkpoint_path=last_checkpoint_path)
总共进行了10折数据20轮训练,每折数据进行了两轮训练,相应的训练损失和验证损失数据如下:
Epoch | Training Loss | Validation Loss |
---|---|---|
1 | 0.439300 | 0.365142 |
2 | 0.204100 | 0.351331 |
3 | 0.327500 | 0.268683 |
4 | 0.202400 | 0.246474 |
5 | 0.253900 | 0.192165 |
6 | 0.133200 | 0.165728 |
7 | 0.1677 | 0.1145 |
8 | 0.1555 | 0.1024 |
9 | 0.1431 | 0.08527 |
10 | 0.1329 | 0.07053 |
11 | 0.1231 | 0.06223 |
12 | 0.1139 | 0.0571 |
13 | 0.1066 | 0.05086 |
14 | 0.1008 | 0.04274 |
15 | 0.09484 | 0.04154 |
16 | 0.070900 | 0.038615 |
17 | 0.069700 | 0.033552 |
18 | 0.068800 | 0.029461 |
19 | 0.059300 | 0.026729 |
20 | 0.068200 | 0.023861 |
从训练结果来看,损失数据一直在不断的下降,这里用最后第20轮的checkpoint进行评测(代码省略),三个字段的评测指标分别如下:
字段 | 指标 |
---|---|
is_fraud | 0.9372, recall: 0.9414, accuracy: 0.9383 |
fraud_speaker | accuracy: 0.9152 |
reason | precision: 0.3664, recall: 0.3705, f1-score: 0.3601 |
结果是有些失望的,虽然损失在不断下降,但各项评测指标基本都没有什么改善。
原因猜测可能有以下几个:
- 数据量不足,2-3万条训练数据相对于文本生成任务来说还是太少,可能还不足以明显改善生成文本的相似度。
- Lora参数矩阵(r=16)较小,不足以储存足够的信息特征。
补充:后续尝试过将Lora矩阵的秩r调到64,并训练了10折20轮,但测评结果依然与上面的结果相似,没有明显改善。
由于分类原因的相似度指标并不是我们必需的,这里受精力和数据量的限制,暂时没继续往下训练,但欺诈者字段的准确率已经达到预期,带欺诈者和分类原因的response格式已经能够正常生成,如下所示:
{"is_fraud": true, "fraud_speaker": "小灿", "reason": "小灿要求吴某某登录一个网站,并根据其指示进行国际黄金的操作,这种行为很可能是典型的投资诈骗手段。"}
{"is_fraud": true, "fraud_speaker": "李小龙", "reason": "李小龙要求支付一笔费用以帮助其亲戚获得释放,但没有提供任何具体的细节或证明其关系。使用 relative/convicted等模糊词汇来联系律师,并要求立即支付费用,这种行为具有明显的诈骗特征。"}
{"is_fraud": true, "fraud_speaker": "朱立", "reason": "朱立提供的投资方案中,明显存在通过吸引投资者大量投入资金来获取高额回报的行为。这种高回报承诺通常是不现实且具有欺骗性的,极有可能构成经济诈骗。具体表现为:- 98元、598元和998元的投资计划承诺回报远远超过正常市场回报率,这违反了普遍的投资原则和常识。\- 投资98元的方案回报5万到10万元,这样的回报率过高,容易让人怀疑其真实性。\- 同样,其他投资方案也承诺极高的回报,如598元投资回报50万元,998元投资回报200万元,这些回报率远超正常市场水平,极易诱导投资者上当。\n"}
小结:本文通过对数据加载的改造和交叉训练过程的封装,完成了一次针对带分类原因的欺诈文本分类任务的训练,并通过评测方法的改造实现了对不同类型字段的结果评测。从损失数据和评测结果来看,要改善生成文本的精确率和召回率,可能还需要更多更丰富的数据,后续腾出时间再研究。
参考阅读
- Lora单卡二次调优
- 交叉训练验证
- 数据校正与增强
- 支持分类原因评测改造