如何使用 Transformers 和 Tokenizers 从零开始训练新的语言模型

发布于 2020 年 2 月 14 日
在 GitHub 上更新
Open In Colab

在过去的几个月里,我们对 transformerstokenizers 库进行了多项改进,旨在让 从零开始训练一个新的语言模型 比以往任何时候都更容易。

在这篇文章中,我们将演示如何训练一个“小”模型(84M 参数 = 6 层,768 隐藏层大小,12 个注意力头)——与 DistilBERT 的层数和头数相同——在 世界语 上。然后,我们将在词性标注的下游任务上对模型进行微调。

世界语是一种旨在易于学习的人造语言。我们选择它作为本次演示的原因有几个:

  • 它是一种相对低资源的语言(尽管有约 200 万人使用),因此这个演示比训练另一个英语模型更有趣 😁
  • 其语法高度规则(例如,所有普通名词都以 -o 结尾,所有形容词都以 -a 结尾),因此即使在小数据集上,我们也应该能获得有趣的语言学结果。
  • 最后,这门语言的根本目标是拉近人与人之间的距离(促进世界和平和国际理解),这可以说与 NLP 社区的目标是一致的 💚

注意:你不需要理解世界语就能理解这篇博文,但如果你想学习它,Duolingo 有一个很好的课程,有 28 万活跃学习者。

我们的模型将被命名为……等等…… EsperBERTo 😂

Esperanto flag

1. 找到数据集

首先,让我们找到一份世界语语料库。这里我们将使用 INRIA OSCAR 语料库 的世界语部分。OSCAR 是一个通过对 Common Crawl 网络转储进行语言分类和过滤而获得的大型多语言语料库。

数据集的世界语部分只有 299M,因此我们将与来自 Leipzig Corpora Collection 的世界语子语料库进行拼接,该语料库由新闻、文学和维基百科等不同来源的文本组成。

最终的训练语料库大小为 3 GB,这仍然很小——对于您的模型,预训练的数据越多,结果会越好。

2. 训练一个分词器

我们选择训练一个字节级字节对编码分词器(与 GPT-2 相同),并使用与 RoBERTa 相同的特殊标记。让我们随意将其大小设置为 52,000。

我们建议训练一个字节级 BPE(而不是像 BERT 那样的 WordPiece 分词器),因为它将从单个字节的字母表中开始构建词汇表,因此所有单词都可以分解为标记(不再有 <unk> 标记!)。

#! pip install tokenizers

from pathlib import Path

from tokenizers import ByteLevelBPETokenizer

paths = [str(x) for x in Path("./eo_data/").glob("**/*.txt")]

# Initialize a tokenizer
tokenizer = ByteLevelBPETokenizer()

# Customize training
tokenizer.train(files=paths, vocab_size=52_000, min_frequency=2, special_tokens=[
    "<s>",
    "<pad>",
    "</s>",
    "<unk>",
    "<mask>",
])

# Save files to disk
tokenizer.save_model(".", "esperberto")

这是输出的略微加速的截图

tokenizers 在我们的数据集上,训练大约需要 5 分钟。

🔥🔥 哇,这太快了!⚡️🔥

我们现在既有 vocab.json(按频率排序的最常用标记列表),也有 merges.txt 合并列表。

{
    "<s>": 0,
    "<pad>": 1,
    "</s>": 2,
    "<unk>": 3,
    "<mask>": 4,
    "!": 5,
    "\"": 6,
    "#": 7,
    "$": 8,
    "%": 9,
    "&": 10,
    "'": 11,
    "(": 12,
    ")": 13,
    # ...
}

# merges.txt
l a
Ġ k
o n
Ġ la
t a
Ġ e
Ġ d
Ġ p
# ...

很棒的是,我们的分词器针对世界语进行了优化。与为英语训练的通用分词器相比,更多的原生词被一个单一的、未分割的标记表示。世界语中使用的重音字符,即变音符号——ĉĝĥĵŝŭ——都以原生方式编码。我们还以更高效的方式表示序列。在这个语料库上,编码序列的平均长度比使用预训练的 GPT-2 分词器时小约 30%。

以下是在 tokenizers 中使用它的方法,包括处理 RoBERTa 特殊标记——当然,你也可以直接从 transformers 中使用它。

from tokenizers.implementations import ByteLevelBPETokenizer
from tokenizers.processors import BertProcessing


tokenizer = ByteLevelBPETokenizer(
    "./models/EsperBERTo-small/vocab.json",
    "./models/EsperBERTo-small/merges.txt",
)
tokenizer._tokenizer.post_processor = BertProcessing(
    ("</s>", tokenizer.token_to_id("</s>")),
    ("<s>", tokenizer.token_to_id("<s>")),
)
tokenizer.enable_truncation(max_length=512)

print(
    tokenizer.encode("Mi estas Julien.")
)
# Encoding(num_tokens=7, ...)
# tokens: ['<s>', 'Mi', 'Ġestas', 'ĠJuli', 'en', '.', '</s>']

3. 从零开始训练语言模型

更新: 相关 Colab 笔记本直接使用我们新的 Trainer,而不是通过脚本。随意选择你喜欢的方法。

我们现在将使用 transformers 库中的 run_language_modeling.py 脚本来训练我们的语言模型(该脚本已从 run_lm_finetuning.py 更名为此,因为它现在更无缝地支持从头开始训练)。只需记住将 --model_name_or_path 设置为 None,以便从头开始训练,而不是从现有模型或检查点开始。

我们将训练一个类 RoBERTa 模型,它是 BERT 模型的变体,进行了一些修改(更多详情请查阅文档)。

由于模型是类 BERT 模型,我们将对其进行掩码语言建模任务的训练,即预测如何填充数据集中我们随机掩码的任意标记。这由示例脚本处理。

我们只需要做两件事

  • 实现一个简单的 Dataset 子类,从我们的文本文件中加载数据
    • 根据您的用例,您甚至可能不需要编写自己的 Dataset 子类,如果提供的示例(TextDatasetLineByLineTextDataset)之一有效——但根据您的语料库的特点,您可能需要添加许多自定义调整。
  • 选择并尝试不同的超参数集。

以下是我们 EsperantoDataset 的一个简单版本。

from torch.utils.data import Dataset

class EsperantoDataset(Dataset):
    def __init__(self, evaluate: bool = False):
        tokenizer = ByteLevelBPETokenizer(
            "./models/EsperBERTo-small/vocab.json",
            "./models/EsperBERTo-small/merges.txt",
        )
        tokenizer._tokenizer.post_processor = BertProcessing(
            ("</s>", tokenizer.token_to_id("</s>")),
            ("<s>", tokenizer.token_to_id("<s>")),
        )
        tokenizer.enable_truncation(max_length=512)
        # or use the RobertaTokenizer from `transformers` directly.

        self.examples = []

        src_files = Path("./data/").glob("*-eval.txt") if evaluate else Path("./data/").glob("*-train.txt")
        for src_file in src_files:
            print("🔥", src_file)
            lines = src_file.read_text(encoding="utf-8").splitlines()
            self.examples += [x.ids for x in tokenizer.encode_batch(lines)]

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

    def __getitem__(self, i):
        # We’ll pad at the batch level.
        return torch.tensor(self.examples[i])

如果您的数据集非常大,您可以选择动态加载和标记化示例,而不是将其作为预处理步骤。

这是我们传递给脚本的超参数和参数的特定集合

    --output_dir ./models/EsperBERTo-small-v1
    --model_type roberta
    --mlm
    --config_name ./models/EsperBERTo-small
    --tokenizer_name ./models/EsperBERTo-small
    --do_train
    --do_eval
    --learning_rate 1e-4
    --num_train_epochs 5
    --save_total_limit 2
    --save_steps 2000
    --per_gpu_train_batch_size 16
    --evaluate_during_training
    --seed 42

像往常一样,选择您的 GPU(s) 可以容纳的最大批量大小。

🔥🔥🔥 让我们开始训练吧!! 🔥🔥🔥

在这里您可以查看我们 针对一组特定超参数 的 Tensorboard

tb

我们的示例脚本默认以 Tensorboard 格式记录,位于 runs/ 下。然后,要查看您的面板,只需运行 tensorboard dev upload --logdir runs – 这将设置 tensorboard.dev,这是一个由 Google 管理的托管版本,可让您与任何人共享您的 ML 实验。

4. 检查语言模型是否真正训练成功

除了查看训练和评估损失下降之外,检查我们的语言模型是否学到任何有趣东西的最简单方法是通过 FillMaskPipeline

管道是对分词器和模型的简单封装,'fill-mask' 管道将允许您输入一个包含掩码标记(这里是 <mask>)的序列,并返回最可能的填充序列列表及其概率。

from transformers import pipeline

fill_mask = pipeline(
    "fill-mask",
    model="./models/EsperBERTo-small",
    tokenizer="./models/EsperBERTo-small"
)

# The sun <mask>.
# =>

result = fill_mask("La suno <mask>.")

# {'score': 0.2526160776615143, 'sequence': '<s> La suno brilis.</s>', 'token': 10820}
# {'score': 0.0999930202960968, 'sequence': '<s> La suno lumis.</s>', 'token': 23833}
# {'score': 0.04382849484682083, 'sequence': '<s> La suno brilas.</s>', 'token': 15006}
# {'score': 0.026011141017079353, 'sequence': '<s> La suno falas.</s>', 'token': 7392}
# {'score': 0.016859788447618484, 'sequence': '<s> La suno pasis.</s>', 'token': 4552}

好的,简单的语法/语法行得通。让我们尝试一个稍微更有趣的提示

fill_mask("Jen la komenco de bela <mask>.")

# This is the beginning of a beautiful <mask>.
# =>

# {
#     'score':0.06502299010753632
#     'sequence':'<s> Jen la komenco de bela vivo.</s>'
#     'token':1099
# }
# {
#     'score':0.0421181358397007
#     'sequence':'<s> Jen la komenco de bela vespero.</s>'
#     'token':5100
# }
# {
#     'score':0.024884626269340515
#     'sequence':'<s> Jen la komenco de bela laboro.</s>'
#     'token':1570
# }
# {
#     'score':0.02324388362467289
#     'sequence':'<s> Jen la komenco de bela tago.</s>'
#     'token':1688
# }
# {
#     'score':0.020378097891807556
#     'sequence':'<s> Jen la komenco de bela festo.</s>'
#     'token':4580
# }

Jen la komenco de bela tago”,确实如此!

通过更复杂的提示,您可以探究您的语言模型是否捕获了更多的语义知识,甚至某种(统计的)常识推理。

5. 在下游任务上微调你的语言模型

我们现在可以在词性标注的下游任务上对我们新的世界语语言模型进行微调。

如前所述,世界语是一种高度规则的语言,单词结尾通常决定了语法词性。使用以 CoNLL-2003 格式标注的世界语词性标注数据集(参见下面的示例),我们可以使用 transformers 库中的 run_ner.py 脚本。

词性标注与 NER 任务一样,都是标记分类任务,所以我们可以直接使用完全相同的脚本。

conll

同样,这是本次微调的托管 Tensorboard。我们使用每 GPU 64 的批次大小训练 3 个 epoch。

训练和评估损失收敛到很小的残差值,因为这项任务相当容易(语言是规则的)——但能够端到端地训练它仍然很有趣 😃。

这次,我们使用 TokenClassificationPipeline

from transformers import TokenClassificationPipeline, pipeline


MODEL_PATH = "./models/EsperBERTo-small-pos/"

nlp = pipeline(
    "ner",
    model=MODEL_PATH,
    tokenizer=MODEL_PATH,
)
# or instantiate a TokenClassificationPipeline directly.

nlp("Mi estas viro kej estas tago varma.")

# {'entity': 'PRON', 'score': 0.9979867339134216, 'word': ' Mi'}
# {'entity': 'VERB', 'score': 0.9683094620704651, 'word': ' estas'}
# {'entity': 'VERB', 'score': 0.9797462821006775, 'word': ' estas'}
# {'entity': 'NOUN', 'score': 0.8509314060211182, 'word': ' tago'}
# {'entity': 'ADJ', 'score': 0.9996201395988464, 'word': ' varma'}

看起来成功了!🔥

对于更具挑战性的 NER 数据集,@stefan-it 建议我们可以在 WikiANN 的银标准数据集上进行训练

6. 分享你的模型 🎉

最后,当你有一个不错的模型时,请考虑与社区分享它

  • 使用 CLI 上传您的模型:transformers-cli upload
  • 编写一个 README.md 模型卡片并将其添加到 model_cards/ 目录下的存储库中。您的模型卡片最好包含
    • 模型描述,
    • 训练参数(数据集、预处理、超参数),
    • 评估结果,
    • 预期用途和限制
    • 任何其他有用的信息!🤓

大功告成!

➡️ 您的模型在 https://huggingface.co/models 上拥有一个页面,每个人都可以使用 AutoModel.from_pretrained("username/model_name") 加载它。

tb

如果您想查看不同语言的模型,请访问 https://huggingface.co/models

all models

谢谢!

社区

注册登录 发表评论