1. 引言
经过前文对数据的校正与增强后,我们的预期生成结果中不再仅仅是分类标签,还多了欺诈者和分类原因。这样之前模型评测和批量评测两篇文章所封装的evaluate.py脚本就不再满足,需要对脚本进行改造,以支持新输出内容的评测。
新的预期结果中共包含三个信息,由于三个信息的特点不同,需要为每个字段制定不同的评测方式:
- is_fraud: 属于二分类,继续采用精确率和召回率作为评测指标。
- fraud_speaker: 属于多分类,多分类没有混淆矩阵,可以使用sklearn包提供的accuracy_score计算准确率作为评测指标。
- reason: 属于分文生成,由于没有准确值,可以通过rouge_score计算文本串的相似度作为评测指标。
2. rouge指标调研
ROUGE(Recall-Oriented Understudy for Gisting Evaluation)指标是用于评估文本摘要质量的一种常用指标。它通过比较生成的摘要与参考摘要之间的重叠词语或短语来衡量它们之间的相似度。Rouge包含多个子指标:
- ROUGE-1: 评估生成文本(hypothesis)与参考文本(reference)之间的单词级别的重叠;
- ROUGE-2: 评估生成文本和参考文本相邻两个词(或字)的重叠情况,它关注的是两个文本中二元组(bigrams)的匹配,以此类推可以推广到ROUGE-N;
- ROUGE-L:与前两者不同的是,此指标关注的是生成文本与参考文本之间的最长公共子序列(Longest Common Subsequence, LCS),通过最长公共子序列来衡量两个文本之间的相似性,最长公共子序列是指在不改变顺序的情况下,两个序列中都存在的元素的最长序列。
下面来实测一下。
from rouge import Rouge
def rouge_score(predictions, realitys):
rouge = Rouge()
return rouge.get_scores(predictions, realitys, avg=True)
先用英文测试。
predictions = "you are a programer."
realitys = "you are the programer."
scores = rouge_score(predictions, realitys)
scores
{'rouge-1': {'r': 0.75, 'p': 0.75, 'f': 0.749999995},
'rouge-2': {'r': 0.3333333333333333,
'p': 0.3333333333333333,
'f': 0.3333333283333334},
'rouge-l': {'r': 0.75, 'p': 0.75, 'f': 0.749999995}}
- 用rouge-1衡量时,predictions和realitys中有[
you
,are
,programer
]三个单词相同,生成文本和参考文本都是共4个单词,所以精确率和召回率都是75%; - 用rouge-2衡量时,predictions和realitys中只有
you are
一个二元组相同,生成文本和参考文本都有4个单词(即3个二元组),所以精确率和召回率都是33%; - 用rouge-l衡量时,predictions和realitys中的最长公共子序列是
['you', 'are', 'programer']
,生成文本和参考文本的总长度都是4,所以精确率和召回率都是75%;
下面再用中文测试。
predictions = "你是一个程序员。"
realitys = "你就是那个程序员。"
scores = rouge_score(predictions, realitys)
scores
{'rouge-1': {'r': 0.0, 'p': 0.0, 'f': 0.0},
'rouge-2': {'r': 0.0, 'p': 0.0, 'f': 0.0},
'rouge-l': {'r': 0.0, 'p': 0.0, 'f': 0.0}}
所有指标的精确率、召回率、f1-score都是0。
原因在于:rouge默认只支持按英文标准的空格符号来进行分词,由于上面的中文文本中没有空格,所以被当成一个词来进行重叠度计算,没有任何重叠所以输出是0。
需要引入jieba中文分词库,并封装一个tokenize方法专门用于对文本分词。
import jieba
def tokenize(text):
return ' '.join(list(jieba.cut(text, cut_all=False)))
上面的tokenize方法中,先使用jieba.cut方法进行分词,再将分词后的序列用空格连接,转换成与英文文本相同的分隔符,以便rouge库能自动分词。
测试分词效果。
tokenized_predictions = tokenize(predictions)
tokenized_realitys = tokenize(realitys)
tokenized_predictions, tokenized_realitys
('你 是 一个 程序员 .', '你 就是 那个 程序员 .')
注:jieba分词的详细介绍和用法,请参考:jieba分词初探
对分词后的中文序列计算rouge分数。
scores = rouge_score(tokenized_predictions, tokenized_realitys)
scores
{'rouge-1': {'r': 0.5, 'p': 0.5, 'f': 0.4999999950000001},
'rouge-2': {'r': 0.0, 'p': 0.0, 'f': 0.0},
'rouge-l': {'r': 0.5, 'p': 0.5, 'f': 0.4999999950000001}}
4个词中有
你
,程序员
两个词相同,单词重叠率为50%,所以rouge-1的分数为50%。
上面的例子中能够看出,rouge-1和rouge-l的两个指标更贴合我们的场景,但这两个指标有何不同呢?我们换一个例子来看。
tokenized_predictions = tokenize("他首先是一名老师,然后是一名程序员。")
tokenized_realitys = tokenize("他首先是一名程序员,然后是一名老师。")
print(tokenized_predictions, "\n", tokenized_realitys)
scores = rouge_score(tokenized_predictions, tokenized_realitys)
scores
他 首先 是 一名 老师 , 然后 是 一名 程序员 。
他 首先 是 一名 程序员 , 然后 是 一名 老师 。
{'rouge-1': {'r': 1.0, 'p': 1.0, 'f': 0.999999995},
'rouge-2': {'r': 0.7777777777777778,
'p': 0.7777777777777778,
'f': 0.7777777727777778},
'rouge-l': {'r': 0.7777777777777778,
'p': 0.7777777777777778,
'f': 0.7777777727777778}}
在上面这个例子中,预测序列和参考序列的单词完全相同,只是顺序不同,rouge-1不考虑单词顺序,最长子序列
['他', '首先', '是', '一名', '老师', '然后', '是', '一名', '程序员']
囊括了所有的词, 所以得出的精确率和召回率均是100%;而rouge-L关注了单词的顺序,所以最长子序列['他', '首先', '是', '一名', '然后', '是', '一名']
只有7个词,而参考序列和预测序列的长度是9个词,所以精确率和召回率为77.7%。
由此可见,如果不考虑序列中词的顺序,则选择rouge-1,反之,如果考虑序列中词的顺序,则选择rouge-L。实际场景中,文本的顺序一般都会影响文本所表达的含义,所以为了尽可能准确,我们的场景选择rouge-L。
3. 脚本改造
首先,引入原始的评测脚本evaluate.py
。
%run evaluate.py
封装用于计算中文rouge分数的方法rouge_zh_score
, 它接受两个文本串集合pred_dataset和real_dataset作为参数,返回集合中两两序列相似度的平均分数。
def rouge_zh_score(pred_dataset, real_dataset):
# 首先,去掉两个集合中的空串,
filtered_pairs = [(p, r) for p, r in zip(pred_dataset, real_dataset) if p.strip() != "" and r.strip() != ""]
filtered_predictions, filtered_realitys = zip(*filtered_pairs) if filtered_pairs else ([], [])
# 进行中文分词,因为Rouge分数的计算依赖于分词后的单词序列。
predictions = [tokenize(text) for text in filtered_predictions]
realitys = [tokenize(text) for text in filtered_realitys]
# 计算两个序列的rouge分数
score = rouge_score(predictions, realitys)
return score['rouge-l']
注1:两个集合是按下标两两配对的,当出现空串时需要在两个集合中同时去掉相应位置的元素,目的是保持两个集合的配对关系不变。
zip
方法的作用是将两个集合两两配对,空串去完后还需要用*
操作符将配对的集合解包还原为两个集合。
注2:需要去掉空串的原因在于,在计算 ROUGE 分数时,当任意一方字符串为空,将会导致“Hypothesis is empty.”的错误。
测试下rouge_zh_score方法的效果。
predictions = ["你是一个程序员。", "他首先是一名老师,然后是一名程序员。"]
realitys = ["你就是那个程序员。", "他首先是一名程序员,然后是一名老师。"]
rouge_zh_score(predictions, realitys)
{'r': 0.6888888888888889, 'p': 0.6888888888888889, 'f': 0.6888888838888889}
通过上面测试可以发现,
rouge.get_scores
方法既支持字符串形式,也支持集合形式,当传的是一个list集合时,它会自动计算集合中所有文本相似度分数的平均值,这是通过参数avg=True
来指定的。
下面改造批量评测的run_test_batch方法,返回的结果不再仅仅是labels集合,还包括了speakers和reasons集合。
from typing import List, Dict
def make_str(data):
return ','.join(map(str, data)) if isinstance(data, list) else data
def make_results(dataset):
return {
"labels": [item.get('label', item.get('is_fraud', False)) for item in dataset],
"speakers": [make_str(item.get('fraud_speaker', '')) for item in dataset],
"reasons": [item.get('reason', '') for item in dataset],
}
def run_test_batch_v2(model, tokenizer, test_data: List[Dict], batch_size: int = 8, device='cuda'):
real_outputs, pred_outputs = [], []
instruction = test_data[0].get('instruction', None)
pbar = tqdm(total=len(test_data), desc=f'progress')
for i in range(0, len(test_data), batch_size):
batch_data = test_data[i:i + batch_size]
batch_inputs = [item['input'] for item in batch_data]
predictions = predict_batch(model, tokenizer, batch_inputs, device, instruction=instruction)
real_outputs.extend(batch_data)
pred_outputs.extend(predictions)
pbar.update(len(batch_data))
pbar.close()
return make_results(real_outputs), make_results(pred_outputs)
扩展一个evaluate_v2方法,此方法中区分字段走不同的指标计算:
- is_fraud: 通过sklean包的混淆矩阵来计算精确率和召回率;
- fraud_speaker: 使用sklearn包提供的accuracy_score方法来计算准确率;
- reason: 以上面封装的中文rouge分数作为评测指标;
from sklearn.metrics import accuracy_score
def evaluate_with_model_v2(model, tokenizer, testdata_path, device='cuda', debug=False):
dataset = load_jsonl(testdata_path)
real_result, pred_result = run_test_batch_v2(model, tokenizer, dataset, device=device)
precision, recall, accuracy = precision_recall(real_result["labels"], pred_result["labels"], debug=debug)
print(f"is_fraud字段指标: \nprecision: {precision}, recall: {recall}, accuracy: {accuracy}")
accuracy = accuracy_score(real_result["speakers"], pred_result["speakers"])
print(f"fraud_speaker字段指标: \naccuracy: {accuracy}")
score = rouge_zh_score(pred_result["reasons"], real_result["reasons"])
print(f"reason字段指标: \nprecision: {score['p']}, recall: {score['r']}, f1-score: {score['f']}")
return real_result, pred_result
def evaluate_v2(model_path, checkpoint_path, testdata_path, device='cuda', debug=False):
model, tokenizer = load_model(model_path, checkpoint_path, device)
return evaluate_with_model_v2(model, tokenizer, testdata_path, device, debug)
4. 基座模型测评
先对基座模型进行评测,后续则以基座模型的评测结果作为微调效果的评判依据。
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
device = 'cuda'
testdata_path = '/data2/anti_fraud/dataset/test0902.jsonl'
model_path = '/data2/anti_fraud/models/modelscope/hub/Qwen/Qwen2-1___5B-Instruct'
real_result, pred_result = evaluate_v2(model_path, '', testdata_path, device, debug=True)
invalid json: 不存在经济诈骗。
分析结果:不存在经济诈骗。在提供的对话中,发言者4只是分享了一个关于商业需求预测的观点,并没有提到任何欺诈行为或企图欺骗他人的情况。因此,可以确定该对话不存在经济诈骗。
经过分析,该对话中不存在明显的经济诈骗行为。发言人的言论主要围绕着旅游统计数据的准确性进行讨论,并提出了可能存在的误差来源。因此,可以判断此对话不存在经济诈骗行为。
invalid json: 不存在经济诈骗。
invalid json: 不存在经济诈骗。
invalid json: 不存在经济诈骗。
……
以上分析结果表明,该对话中不存在明显的经济诈骗行为。所有发言者的言论都是关于产品的生产和销售情况的讨论,并且没有涉及到欺诈或非法活动。因此,可以得出结论:此对话不存在经济诈骗行为。
progress: 100%|██████████| 3031/3031 [17:55<00:00, 2.82it/s]
Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
tn:590, fp:903, fn:44, tp:1494
is_fraud字段指标:
precision: 0.623279098873592, recall: 0.9713914174252276, accuracy: 0.6875618607720224
fraud_speaker字段指标:
accuracy: 0.6374133949191686
Loading model cost 0.776 seconds.
Prefix dict has been built successfully.
reason字段指标:
precision: 0.33552894583785237, recall: 0.23240362412953733, f1-score: 0.2638127489291967
注:由于基座模型遵循指令的能力较弱,所以在预测时输出了很多json格式错误,为避免篇幅太长,上面省略和截掉了很多重复的错误的信息,并且在遇到错误时统一使用默认的{“is_fraud”: false}作为返回值。
指标收集如下:
字段 | 指标 |
---|---|
is_fraud | precision: 0.6232, recall: 0.9713, accuracy=0.6875 |
fraud_speaker | accuracy: 0.6374 |
reason | precision: 0.3355, recall: 0.2324, f1-score: 0.2638 |
从上面的指标来看,只有is_fraud字段的 召回率0.9673
比较高,但其它指标(如is_fraud字段的精确率,fraud_speaker字段的准确率,reason字段的各项指标)都比较低。
小结:本文通过对rouge指标的调研测试,了解了rouge指标中的rouge-1和rouge-L子指标的区别,并对评测脚本进行了改造升级,以支持分类原因和欺诈者字段的生成质量评测,为后续分类原因和欺诈者字段的训练微调提供了一个效果评测的基准。