使用PaddlePaddle框架复现InfoXLM模型和相关实验

1. 论文简介

InfoXLM是微软提出的多语言预训练模型。基于互信息等观点提出的训练任务和损失函数,使得该模型在跨语言知识迁移等方面有着比起类似模型(XLM, mBERT等)更好的性能。

原论文的Arxiv链接

2. 项目介绍

在本项目中,我们展示了如下内容:

  1. 使用paddle框架加载转换后的权重

  2. 复现跨语言句子检索任务

本项目是第六期飞桨论文复现赛中第112题的aistudio提交,对应的github提交链接在此

3. Tatoeba Sentence Retrieval任务

该任务不需要训练,只需要使用导出的原始预训练权重即可完成。原文中给出了参考的Tatoeba数据集使用的论文:[1812.10464] Massively Multilingual Sentence Embeddings for Zero-Shot Cross-Lingual Transfer and Beyond (arxiv.org),我们阅读论文后找到了论文开源的数据集合和评测方法,来源:facebookresearch/LASER: Language-Agnostic SEntence Representations (github.com),并且将评测过程使用paddle重现了;

Sentence Retrieval任务是评测跨语言的同样含义的句子(例如一对互译的句子),经过预训练模型编码后,其表征是否有足够的相似度。InfoXLM论文使用了Tatoeba里与XNLI的14个语言 – 英文互译的数据集;每个语言和英语的互译句子有1000句。针对一个语言评测时,我们执行以下操作:

  • 将这1000句话的英文和外语版本(共2000句)encode,取出InfoXLM第7层transformer输出的向量,并沿token的方向取平均,得到1 * 768的向量,并作L2标准化;
  • 对En->X 计算句子取回的准确率:
    • 针对每个英语的句子,计算该句子的向量与X语言1000句的向量的余弦相似度,取出余弦相似度最高的句子;
    • 计算取出的句子确实是对应译文的平均概率,为准确率。
  • 对X-> En 计算句子取回的准确率:
    • 与上述过程类似,针对每个X语言句子计算英语1000句中的相似度

我们已经在git中附带了要用到的Tatoeba数据,位于experiments/sentence_retrieval/datasets。如果您完成了原始权重的转换,您可以通过以下命令行复现上述实验的过程:

python experiments/sentence_retrieval/run_experiments.py

执行后会输出log.txt 和 sentence_retrieval_results.csv,结果如下,表格中所有指标与论文报告(table-2)完全一致。

X en->x x->en
ar 0.686 0.591
bg 0.787 0.787
de 0.951 0.939
el 0.726 0.622
es 0.872 0.882
fr 0.84 0.794
hi 0.883 0.871
ru 0.857 0.838
sw 0.408 0.395
th 0.912 0.850
tr 0.847 0.832
ur 0.733 0.73
vi 0.92 0.896
zh 0.864 0.864
Average 0.806 0.778
那就让我们开始使用aistudio验证该表格吧!

3.1 解压已经准备好的权重

我们提前建立了一个数据集,里面包含了权重和用来实验用的多语种句子的压缩包。具体的权重转换指令在github上有说明。

!unzip /home/aistudio/data/data153417/converted_paddle.zip

3.2 解压句子对数据

句子对数据原始的来源是Taboeta.org,微软的作者沿用了他人基于该数据库构造得出的句子对数据。

!unzip /home/aistudio/data/data153417/datasets.zip

3.3 安装paddleNLP

Aistudio 自带的paddle版本较老,我们直接安装最新的版本进行覆盖,安装位置是 /home/aistudio/external-libraries,这样可以重启notebook之后依赖项不变。

!pip uninstall -y paddlenlp
!mkdir /home/aistudio/external-libraries
!pip install paddlenlp==2.4 -t /home/aistudio/external-libraries --upgrade

# Adding both the paddleNLP and the infoxlm_paddle files to the sys path
import sys
sys.path.append('/home/aistudio/external-libraries')
sys.path.append('/home/aistudio/work')
# 验证版本信息

import paddle
print(paddle.__version__)
import paddlenlp
print(paddlenlp.__version__)
小插曲,fp16 fp32 的兼容问题

我们保存的权重是float16的,直接加载会导致一些小问题,在这里我们手动进行float32的转换。

import paddle

# 加载原来权重
weights = paddle.load("./converted_paddle/model_state.pdparams")

# 转换成float32
weights_fp32 = {
    i: j.astype(paddle.float32) for i,j in weights.items()
}

# 保存成float32
paddle.save(weights_fp32, "./converted_paddle/model_state32.pdparams")

把文件命名改一下,方便直接使用from_pretrained函数自动加载infoxlm模型。

!mv ./converted_paddle/model_state.pdparams ./converted_paddle/model_state16.pdparams
!mv ./converted_paddle/model_state32.pdparams ./converted_paddle/model_state.pdparams

现在我们来真正加载InfoXLM模型,模型的具体实现在/work/infoxlm_paddle/modeling.py/work/infoxlm_paddle/tokenizer.py中,复现过程在github中有说明。

from infoxlm_paddle import InfoXLMModel, InfoXLMTokenizer

model = InfoXLMModel.from_pretrained(
    "./converted_paddle/"
)
# 打印一个层的权重标准差出来看看,如果是0.1529就对了。
print(model.embeddings.word_embeddings.weight.std()[0]) 

InfoXLM的tokenizer非常简洁,使用一个预训练的sentencepiece模型对全局进行分词处理即可,所以我们使用手动的方式进行初始化。

未来当InfoXLM的实现被合入PaddleNLP之后,我们就可以和之前一样使用from_pretrained函数进行更方便的初始化了。

tokenizer = InfoXLMTokenizer(
    sentencepiece_model_file="./converted_paddle/sentencepiece.bpe.model", 
    do_lower_case=False, 
    remove_space=True
)

读取Tatoeba句子对(sentence pairs)数据的一些函数

import os

# 句子对源数据的位置
DATASET_FOLDER = "/home/aistudio/work/sentence_retrieval/datasets"

language_mapping = {
    "ar": "ara",
    "bg": "bul",
    "de": "deu",
    "el": "ell",
    "en": "eng",
    "es": "spa",
    "fr": "fra",
    "hi": "hin",
    "ru": "rus",
    "sw": "swh",
    "th": "tha",
    "tr": "tur",
    "ur": "urd",
    "vi": "vie",
    "zh": "cmn",
}


def get_language_pair_filenames(languageA, languageB):
    '''
    用来定位句子对文件名,languageA 或者 B里面必须有一个是en,另一个可以是上面language_mapping中的键,例如ar
    返回languageA, languageB的句子对文件名
    
    >> get_language_pair_filenames("en", "ar")
    >> ("/home/aistudio/work/sentence_retrieval/datasets/tatoeba.ara-eng.ara.ara", 
        "/home/aistudio/work/sentence_retrieval/datasets/tatoeba.ara-eng.ara.eng")
    '''
    if languageA == "en":
        languageA = "eng"
        languageB = language_mapping[languageB]
    else:
        languageB = language_mapping[languageA]
        languageA = "eng"
    filenames = [
        os.path.join(DATASET_FOLDER, f"tatoeba.{languageB}-{languageA}.{languageA}"),
        os.path.join(DATASET_FOLDER, f"tatoeba.{languageB}-{languageA}.{languageB}"),
    ]
    return filenames


def read_language_pairs(filenames):
    '''
    输入两个文件名,读取句子对文件,并按照成对的方式(languageA sentence, languageB sentence)的方式yield出来
    >> for line1, line2 in read_language_pairs("/home/aistudio/work/sentence_retrieval/datasets/tatoeba.ara-eng.ara.ara", 
        "/home/aistudio/work/sentence_retrieval/datasets/tatoeba.ara-eng.ara.eng"):
            print(line1, line2)
    '''
    with open(filenames[0], "r", encoding="utf-8") as f1, open(
        filenames[1], "r", encoding="utf-8"
    ) as f2:
        for line1, line2 in zip(f1, f2):
            yield line1.strip(), line2.strip()

3.4 具体的任务计算方法

在这里我们将使用刚才加载的infoxlm模型对句子对进行推理,得到infoxlm模型中间层对句子的表征信息。类似InfoXLM, mBERT,XLM-R一类的多语种encoder模型,一般而言我们希望模型能对两个语言不同、但语义一致的句子有相对接近的表征。这样我们可能可以使用一种语言的数据进行下游任务的微调,同时在别的语言上得到保留较高的迁移推理能力。

按照论文的方法,我们取出了infoxlm模型第七层表征信息,并且沿token方向进行了平均和归一化。

import os
import sys
import paddle
import numpy as np

model.eval()

def get_sentence_embedding(text):
    '''
    计算句子在infoxlm模型中的表征信息
    取出第七层encoder的结果,沿token方向平均,并且归一化,使得这个向量有统一的大小。
    >> get_sentence_embedding("This is a sentence")
    >> Tensor([.....])
    '''
    encoded_input = tokenizer([text])[0]
    ptensor = paddle.to_tensor(encoded_input["input_ids"]).unsqueeze(0)
    encoder_outputs, _ = model(
        ptensor, output_hidden_states=True
    )
    emb = encoder_outputs[7].mean(axis=1)
    result = emb.detach().squeeze(0).numpy()
    return result / np.linalg.norm(result)


class SentenceRetrieval(object):
    def __init__(self, languageA, languageB):
        self.languageA = languageA
        self.languageB = languageB
        self.sentencesA = []
        self.sentencesB = []
        self.embeddingsA = []
        self.embeddingsB = []

    def add_sentence(self, language, sentence):
        '''
        加入句子的编码信息
        >> sr.add_sentence("en", "This is an English sentence")
        '''
        embedding = get_sentence_embedding(sentence)
        if language == self.languageA:
            self.sentencesA.append(sentence)
            self.embeddingsA.append(embedding)
        elif language == self.languageB:
            self.sentencesB.append(sentence)
            self.embeddingsB.append(embedding)
        else:
            raise ValueError(
                f"language must be either {self.languageA} or {self.languageB}"
            )

    def stack(self):
        '''
        把之前加入的句子的表征向量变成一个numpy.ndarray,方便搜索
        '''
        # convert embeddingsA and embeddingsB to numpy array
        embeddingsA = np.array(self.embeddingsA)
        embeddingsB = np.array(self.embeddingsB)
        return embeddingsA, embeddingsB

    def add_sentence_pair(self, sentenceA, sentenceB):
        '''
        往类里加入句子对
        '''
        self.add_sentence(self.languageA, sentenceA)
        self.add_sentence(self.languageB, sentenceB)

    def match_and_calculate(self, from_lang, to_lang):
        '''
        为一种语言(from_lang)的句子,逐一找到表征最接近的另外一个语言的句子(to_lang)
        >> sr.match_and_calculate("en", "ar")
        '''

        # 确定哪个embedding是搜索范围
        if from_lang == self.languageA and to_lang == self.languageB:
            _, embedding_targets = self.stack()
            embedding_lookup = self.embeddingsA
        elif from_lang == self.languageB and to_lang == self.languageA:
            embedding_targets, _ = self.stack()
            embedding_lookup = self.embeddingsB
        
        # 开始匹配
        matched = []
        for idx, sentence_emb in enumerate(embedding_lookup):
            # 为embedding_lookup中每个句子的向量找到embedding_targets里面最接近的向量,并记录下行数
            sim = np.dot(sentence_emb, embedding_targets.T) / (
                np.linalg.norm(sentence_emb) * np.linalg.norm(embedding_targets)
            )
            matched.append(np.argmax(sim))
        
        # 计算准确率,Tatoeba数据中,行数一致的句子语义就是一致的,所以我们只需要看刚才搜索的时候,有多少次
        # 搜索的最终结果返回了相同的行数即可
        matched = np.array(matched)
        correct = np.sum(matched == np.arange(len(matched)))
        accuracy = correct / len(matched)
        print(f"{from_lang} -> {to_lang} accuracy: {accuracy:.3f}")
        return accuracy

    def evaluate(self, logger=None):
        '''
        一个方便的函数同时计算两个方向的准确率
        '''
        ab = self.match_and_calculate(self.languageA, self.languageB)
        ba = self.match_and_calculate(self.languageB, self.languageA)
        if logger is not None:
            logger.info(f"{self.languageA} -> {self.languageB} accuracy: {ab:.3f}")
            logger.info(f"{self.languageB} -> {self.languageA} accuracy: {ba:.3f}")
        else:
            print(f"average accuracy: {(ab+ba)/2:.3f}")
        return (ab + ba) / 2, ab, ba

    def load_dataset(self):
        '''
        加载数据
        '''
        filenames = get_language_pair_filenames(self.languageA, self.languageB)
        for i, j in read_language_pairs(filenames):
            self.add_sentence_pair(i, j)
# Tatoeba任务中,我们计算infoxlm模型编码英文句子和编码其他语言句子的相似度
all_X_languages = [
    "ar","bg","de","el","es",
    "fr","hi","ru","sw","th",
    "tr","ur","vi","zh",
]

langB = "en"
X_TO_ENGS = []
ENGS_TO_X = []
# 遍历所有其他语言,计算编码相似度
for langA in all_X_languages:
    sr = SentenceRetrieval(langA, langB)
    sr.load_dataset()
    avg, x_to_eng, eng_to_x = sr.evaluate()
    X_TO_ENGS.append(x_to_eng)
    ENGS_TO_X.append(eng_to_x)

# 组装计算结果
import pandas as pd
dataframe = pd.DataFrame({"X": all_X_languages, "en->x": ENGS_TO_X, "x->en": X_TO_ENGS})

3.4 效果评估

可以看到InfoXLM的预训练模型确实能对所有语言编码,并且把1)不同语言里,2)语义接近的句子,编码在表征空间里相对靠近的位置上。上述的精度数据和论文表格完全一致。

dataframe

4. XNLI 分类任务

InfoXLM 模型因为在训练过程中加入了TLM等新的预训练任务,所以模型本身已经能对不同语言之间的单词和句子产生一定的关联编码能力。XNLI是一个用来展示预训练语言模型在多个语言之间迁移推理能力的任务。在这个任务里面,我们针对英文数据集进行一些文本尝试的推理任务微调,随后即在15个不同的语言上进行迁移测试。在英文测试集里面的性能与其他语言之间的性能差距越小,则越能说明预训练的有效性。

在这里,我们尝试复现论文中这个只有10.3%的差距(优于XLM,mBERT等模型)。

from paddlenlp.datasets import load_dataset

# 包装一下xnli的数据库,使得训练和推理可以成批完成
class XNLI_Dataset(object):
    def __init__(self, lang, split):
        self.lang = lang
        self.split = split
        self.data = load_dataset("xnli", lang, splits=[split])

    def __getitem__(self, index):
        return self.data[index]

    def collate(self, start, end=None):
        if end is None:
            end = start + 1
        raw_data = self.data[start:end]
        # collected into sentence pairs
        # (premise, hypothesis, label)
        premises = [i["premise"] for i in raw_data]
        hypotheses = [i["hypothesis"] for i in raw_data]
        labels = [i["label"] for i in raw_data]
        return premises, hypotheses, labels

    def __len__(self):
        return len(self.data)

    def get_batch_iterator(self, batch_size):
        total_batches = len(self) // batch_size + 1
        for i in range(total_batches):
            start = i * batch_size
            end = start + batch_size
            yield self.collate(start, end)

def convert_example(example, tokenizer, max_seq_len=128):
    '''
    工具函数,将文本tokenize成矩阵方便传入。
    '''
    encoded_inputs = tokenizer(
        text=example["premise"],
        text_pair=example["hypothesis"],
        max_seq_len=max_seq_len,
        pad_to_max_seq_len=True,
    )
    encoded_inputs["label"] = int(example["label"])
    return encoded_inputs

加载已经训练好的权重

由于直接训练一个xnli的推理权重需要较多计算资源,在这里我们也直接提供了微调后的InfoXLM权重。按照之前的处理方法,我们需要将权重手动变成float32,并放置在合适的地方方便加载

!mkdir -p finetuned_paddle
!unzip finetuned_paddle/finetuned_paddle.zip
import paddle

weights = paddle.load("./model_state.pdparams")
weights_fp32 = {
    i: j.astype(paddle.float32) for i,j in weights.items()
}

paddle.save(weights_fp32, "./model_state.pdparams")

!mv model_config.json finetuned_paddle/
!mv sentencepiece.bpe.model finetuned_paddle/
!mv model_state.pdparams finetuned_paddle/

开始推理XNLI的数据集

# to test the task specific models

import os
import paddle

from paddle.metric import Accuracy
from paddlenlp.datasets import load_dataset
from infoxlm_paddle import (
    InfoXLMTokenizer,
    InfoXLMModel,
    InfoXLMForSequenceClassification,
)

# 加载模型并设定推理模式
finetuned_xnli = InfoXLMForSequenceClassification.from_pretrained(
    "./finetuned_paddle"
)
finetuned_xnli.eval()
# 计算准确率的metric
metric = Accuracy()


@paddle.no_grad()
def eval_accuracy(dataset, model, batch_size=8):
    '''
    使用训练好的模型进行推理,返回准确率
    '''
    model.eval()
    metric.reset()
    # 遍历数据
    for pre, hyp, lbl in dataset.get_batch_iterator(batch_size):
        encoded_inputs = tokenizer(pre, hyp, padding=True)
        input_token_ids = paddle.to_tensor(encoded_inputs["input_ids"])
        logits = model(input_token_ids)
        correct = metric.compute(logits, paddle.to_tensor(lbl))
        metric.update(correct)
    acc = metric.accumulate()
    return acc

all_X_languages = [
    "ar","bg","de","el","es",
    "fr","hi","ru","sw","th",
    "tr","ur","vi","zh",
]
# 对所有语言进行推理测试
for lang in all_X_languages:
    print(f"Evaluating {lang} test set")
    dataset = XNLI_Dataset(lang, "test")
    acc = eval_accuracy(dataset, finetuned_xnli)
    print(f"{lang} acc: {acc:.4f}")


# 计算英文数据集的水平作为参考:
print(f"English set accuracy")
dataset = XNLI_Dataset("en", "test")
acc = eval_accuracy(dataset, finetuned_xnli)
print(f"acc: {acc:.4f}")

可见上述的精度与论文中是接近的,跨语言的推理性能损失仅为10.3%,远低于XLM和mBERT

5. 复现总结

这次使用Paddle套件复现InfoXLM的过程相对容易,因为以下两个原因:

  1. InfoXLM模型本身和Roberta的结构非常接近,可以借用之前的实现;同时分词器tokenizer并没有过多的特殊设计,仅为一个sentenpiece的分词器。PaddleNLP中已经有较多类似模型的开源代码可以参考;

  2. 在训练、加载数据库等工具链路的上,PaddleNLP已经能满足大多数需求,XNLI数据集可以一行代码就拉取下来;

当然,仅仅复现了下游任务的训练和推理过程是不够的,后期我也会尝试在paddle里复现InfoXLM中全新设计的Cross-Lingual Contrastive Learning, Momentum Contrast等训练过程,方便大家在paddle中按照自己的大规模语料库微调InfoXLM模型。

此文章为搬运
原项目链接

Logo

学大模型,用大模型上飞桨星河社区!每天8点V100G算力免费领!免费领取ERNIE 4.0 100w Token >>>

更多推荐