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。

需要引入jieba中文分词库,并封装一个tokenize方法专门用于对文本分词。

import jieba

def tokenize(text):
    return ' '.join(list(jieba.cut(text, cut_all=False)))

测试分词效果。

tokenized_predictions = tokenize(predictions)
tokenized_realitys = tokenize(realitys)
tokenized_predictions, tokenized_realitys

    ('你 是 一个 程序员 .', '你 就是 那个 程序员 .')

对分词后的中文序列计算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}}

上面的例子中能够看出,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,反之,如果考虑序列中词的顺序,则选择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']

测试下rouge_zh_score方法的效果。

predictions = ["你是一个程序员。", "他首先是一名老师,然后是一名程序员。"]
realitys = ["你就是那个程序员。", "他首先是一名程序员,然后是一名老师。"]
rouge_zh_score(predictions, realitys)
    {'r': 0.6888888888888889, 'p': 0.6888888888888889, 'f': 0.6888888838888889}

下面改造批量评测的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

指标收集如下:

从上面的指标来看,只有is_fraud字段的 召回率0.9673比较高,但其它指标(如is_fraud字段的精确率,fraud_speaker字段的准确率,reason字段的各项指标)都比较低。

小结:本文通过对rouge指标的调研测试,了解了rouge指标中的rouge-1和rouge-L子指标的区别,并对评测脚本进行了改造升级,以支持分类原因和欺诈者字段的生成质量评测,为后续分类原因和欺诈者字段的训练微调提供了一个效果评测的基准。

参考阅读

09-26 10:06