🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎
📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃
🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝
📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】 深度学习【DL】
🖍foreword
✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。
如果你对这个系列感兴趣的话,可以关注订阅哟👋
文章目录
在本文的开头段落中,我们提到了一个名为 GitHub Copilot 的复杂应用程序,它使用类似 GPT 的转换器来执行代码自动完成,这一功能在使用新语言或框架编程或学习编码或自动生成时特别有用样板代码。其他为此使用 AI 模型的产品包括TabNine和 Kite。后来,在第 5 章中,我们仔细研究了如何使用 GPT 模型生成高质量的文本。在本章中,我们将结束循环并构建我们自己的类似 GPT 的模型来生成 Python 源代码!我们将生成的模型称为 CodeParrot。
到目前为止,我们主要致力于数据受限的应用程序,其中标记的训练数据量是有限的。在这些情况下,迁移学习帮助我们构建了高性能模型。我们在第 9 章中将迁移学习发挥到了极致,几乎没有使用任何训练数据。
在本章中,我们将转向另一个极端,看看当我们淹没在我们可能想要的所有数据中时我们能做些什么。我们将探索预训练步骤本身,并学习如何从头开始训练变压器。在解决这个问题时,我们将研究一些我们尚未考虑的培训方面,例如:
-
收集和处理非常大的数据集
-
为我们的数据集创建自定义标记器
-
在多个 GPU 上大规模训练模型
为了有效地训练具有数十亿参数的大型模型,我们需要特殊的分布式训练工具。尽管Trainer
from
Transformers 支持分布式训练,但我们将借此机会展示一个名为 Accelerate 的强大 PyTorch 库。我们最终会接触到当今使用的一些最大的 NLP 模型——但首先,我们需要找到一个足够大的数据集。
警告
与本书其他代码中的代码(可以在单个 GPU 上使用 Jupyter notebook 运行)不同,本章中的训练代码旨在作为具有多个 GPU 的脚本运行。如果您想训练自己的 CodeParrot 版本,我们建议您运行 Transformers 存储库中提供的脚本。
大型数据集以及在哪里可以找到它们
在许多领域,您实际上可能手头有大量数据,从法律文件到生物医学数据集再到编程代码库。在大多数情况下,这些数据集是未标记的,并且它们的大尺寸意味着它们通常只能通过使用启发式方法或使用在收集过程中存储的随附元数据来标记。
然而,一个非常大的语料库即使没有标记或只是启发式标记也很有用。我们在第 9 章中看到了一个例子 ,我们使用数据集的未标记部分来微调语言模型以适应领域。当可用数据有限时,这种方法通常会提高性能。从头开始训练而不是微调现有模型的决定主要取决于微调语料库的大小以及可用的预训练模型与语料库之间的领域差异。
使用预训练模型会强制您使用模型的相应标记器,但使用在来自另一个域的语料库上训练的标记器通常不是最佳选择。例如,在法律文件、其他语言甚至完全不同的序列(如音符或 DNA 序列)上使用 GPT 的预训练标记器将导致标记化效果不佳(我们将很快看到)。
随着您可以访问的训练数据量越来越接近用于预训练的数据量,如果有必要的计算资源可用,考虑从头开始训练模型和标记器变得很有趣。在我们进一步讨论不同的预训练目标之前,我们首先需要建立一个适合预训练的大型语料库。构建这样的语料库会带来一系列挑战,我们将在下一节中探讨这些挑战。
构建大规模语料库的挑战
预训练后的模型质量很大程度上反映了预训练语料库的质量。特别是,该模型将继承预训练语料库中的任何缺陷。因此,在我们尝试创建自己的语料库之前,最好了解与构建大型语料库进行预训练相关的一些常见问题和挑战。
随着数据集变得越来越大,您可以完全控制或至少准确了解其中的内容的机会会减少。一个非常大的数据集很可能不会由专门的创建者组装,他们一次制作一个示例,同时了解并了解完整的管道和机器学习模型将应用于的任务。相反,通过收集作为其他活动的副作用而生成的数据,更有可能以自动或半自动的方式创建一个非常大的数据集。例如,它可能包含公司存储的所有文件(例如,合同、采购订单等)、用户活动日志或从互联网收集的数据。
大规模数据集大多是通过高度自动化创建的,这会产生几个重要的后果。一个重要的考虑因素是对其内容和创建方式的控制有限,因此在有偏见和低质量数据上训练模型的风险增加。最近对分别用于训练 BERT 和 T5 的著名大型数据集(如 BookCorpus 和 C4)的调查发现(除其他外):1
-
C4 语料库的很大一部分是机器翻译的,而不是人工翻译的。
-
由于 C4 中的停用词过滤导致非裔美国人英语的不同擦除导致此类内容的代表性不足。
-
在大型文本语料库中,通常很难在包括(通常太多)性或其他露骨内容和完全删除所有提及性或性别的内容之间找到中间立场。作为一个令人惊讶的结果,一个相当常见的词,如“sex”(它可以具有中性和明确的含义)对于在 C4 上训练的分词器来说是完全未知的,因为这个词在语料库中完全不存在。
-
BookCorpus 中存在许多侵犯版权的事件,可能在其他大规模数据集中也是如此。2
-
BookCorpus 中的类型偏向于“浪漫”小说。
这些发现可能与在这些语料库上训练的模型的下游使用不兼容。例如,如果该模型旨在用作言情小说写作工具或构建游戏,那么 BookCorpus 中言情小说的过度表现可能是可以接受的。
让我们通过比较来自 GPT 和 GPT-2 的文本生成来说明模型被数据扭曲的概念。GPT 主要在 BookCorpus 上进行训练,而 GPT-2 则在从 Reddit 链接的网页、博客和新闻文章上进行训练。我们将在同一提示下比较两个模型的相似大小版本,因此主要区别在于预训练数据集,我们将使用text-generation
管道来调查模型输出:
-
from transformers
import pipeline, set_seed
-
-
generation_gpt =
pipeline(
"text-generation", model=
"openai-gpt")
-
generation_gpt2 =
pipeline(
"text-generation", model=
"gpt2")
接下来,让我们创建一个简单的函数来计算每个模型中的参数数量:
-
def
model_size(
model):
-
return
sum(t.numel()
for t
in model.parameters())
-
-
print(
f"GPT size: {model_size(generation_gpt.model)/1000**2:.1f}M parameters")
-
print(
f"GPT2 size: {model_size(generation_gpt2.model)/1000**2:.1f}M parameters")
GPT size: 116.5M parameters GPT2 size: 124.4M parameters
最初的 GPT 模型与最小的 GPT-2 模型大小差不多。现在我们可以从每个模型生成三个不同的补全,每个补全都有相同的输入提示:
-
def
enum_pipeline_ouputs(pipe, prompt, num_return_sequences):
-
out =
pipe(prompt, num_return_sequences=num_return_sequences,
-
clean_up_tokenization_spaces=True)
-
return
"\n".
join(f
"{i+1}." + s[
"generated_text"] for i, s in
enumerate(out))
-
-
prompt =
"\nWhen they came back"
-
print(
"GPT completions:\n" +
enum_pipeline_ouputs(generation_gpt, prompt,
3))
-
print(
"")
-
print(
"GPT-2 completions:\n" +
enum_pipeline_ouputs(generation_gpt2, prompt,
3))
GPT completions: 1. When they came back. " we need all we can get, " jason said once they had settled into the back of the truck without anyone stopping them. " after getting out here, it 'll be up to us what to find. for now 2. When they came back. his gaze swept over her body. he 'd dressed her, too, in the borrowed clothes that she 'd worn for the journey. " i thought it would be easier to just leave you there. " a woman like 3. When they came back to the house and she was sitting there with the little boy. " don't be afraid, " he told her. she nodded slowly, her eyes wide. she was so lost in whatever she discovered that tom knew her mistake GPT-2 completions: 1. When they came back we had a big dinner and the other guys went to see what their opinion was on her. I did an hour and they were happy with it. 2. When they came back to this island there had been another massacre, but he could not help but feel pity for the helpless victim who had been left to die, and that they had failed that day. And so was very, very grateful indeed. 3. When they came back to our house after the morning, I asked if she was sure. She said, "Nope." The two kids were gone that morning. I thought they were back to being a good friend. When Dost
通过从两个模型中抽取少量输出,我们已经可以看到 GPT 生成中独特的“浪漫”偏差,这通常会想象一个女人和男人之间浪漫互动的对话。另一方面,GPT-2 接受了与 Reddit 文章链接的网络文本的训练,并且在其几代人中大多采用中性的“他们”,其中包含“类似博客”或与冒险相关的元素。
一般来说,在数据集上训练的任何模型都将反映其训练数据中的人口和事件的语言偏见和过度或不足的代表性。考虑到与模型交互的目标受众,模型行为中的这些偏差很重要;对于一些有用的指南,我们建议您参考 Google 的一篇论文,该论文提供了数据集开发的框架。3
构建自定义代码数据集
为了稍微简化任务,我们将只专注于为 Python 编程语言构建代码生成模型。4我们首先需要一个包含 Python 源代码的大型预训练语料库。幸运的是,有一个每个软件工程师都知道的自然资源:GitHub!著名的代码共享网站拥有 数 TB的代码存储库,这些存储库可以公开访问,并且可以根据各自的许可证下载和使用。在本书写作之时,GitHub 拥有超过 2000 万个代码库。其中许多是用户创建的小型或测试存储库,用于学习、未来的辅助项目或测试目的。
-
通过 GitHub REST API,就像我们在第 9 章下载 Transformers 存储库的所有 GitHub 问题时看到的那样
-
通过Google BigQuery等公共数据集清单
由于 REST API 是速率受限的,并且我们需要大量数据用于预训练语料库,因此我们将使用 Google BigQuery 提取所有 Python 存储库。该 bigquery-public-data.github_repos.contents
表包含所有小于 10 MB 的 ASCII 文件的副本。根据GitHub 的 License API确定,项目还需要是开源 的。
小费
Google BigQuery 数据集不包含星号或下游使用信息。对于这些属性,我们可以使用 GitHub REST API 或像Libraries.io这样的服务来监控开源包。事实上,GitHub 的一个团队最近发布了一个名为CodeSearchNet的数据集,该数据集使用来自 Libraries.io 的信息过滤了至少一个下游任务中使用的存储库。
让我们看看使用 Google BigQuery 创建代码数据集需要什么。
使用 Google BigQuery 创建数据集
我们将首先从 Google BigQuery 的快照中提取 GitHub 公共存储库中的所有 Python 文件。出于可重复性的考虑,并且如果 BigQuery 的免费使用政策在未来发生变化,我们还将在 Hugging Face Hub 上共享此数据集。导出这些文件的步骤改编自 TransCoder 实现,如下所示:5
-
创建一个 Google Cloud 帐户(免费试用就足够了)。
-
在您的帐户下创建一个 Google BigQuery 项目。
-
在这个项目中,创建一个数据集。
-
在此数据集中,创建一个将存储 SQL 请求结果的表。
-
准备并运行以下 SQL 查询
github_repos
(要保存查询结果,请选择更多 > 查询选项,选中“为查询结果设置目标表”框,并指定表名):-
SELECT
-
f.repo_name, f.path, c.copies, c.size, c.content, l.license
-
FROM
-
`bigquery- public-data.github_repos.files` AS f
-
JOIN
-
`bigquery- public-data.github_repos.contents` AS c
-
ON
-
f.id = c.id
-
JOIN
-
`bigquery- public-data.github_repos.licenses` AS l
-
ON
-
f.repo_name = l.repo_name
-
WHERE
-
NOT c. binary
-
AND ((f.path LIKE '%.py')
-
AND (c.size BETWEEN 1024
-
AND 1048575))
-
该命令处理大约 2.6 TB 的数据以提取 2680 万个文件。结果是一个大约 50 GB 压缩 JSON 文件的数据集,每个文件都包含 Python 文件的源代码。我们过滤以删除空文件和__init__.py等不包含太多有用信息的小文件。我们还过滤掉了大于 1 MB 的文件,并下载了所有文件的许可证,以便我们以后可以根据许可证过滤训练数据。
接下来,我们将结果下载到我们的本地机器。如果您在家中尝试此操作,请确保您有足够的可用带宽和至少 50 GB 的可用磁盘空间。将结果表传输到本地计算机的最简单方法是遵循以下两步过程:
-
将结果导出到 Google Cloud:
-
在 Google Cloud Storage (GCS) 中创建存储桶和文件夹。
-
通过选择导出 > 导出到 GCS 将表导出到此存储桶,导出格式为 JSON 和 gzip 压缩。
-
-
要将存储桶下载到您的机器,请使用gsutil 库:
-
安装。
gsutil
_pip install gsutil
-
gsutil
使用您的 Google 帐户进行配置:gsutil config
。 -
在您的机器上复制您的存储桶:
-
$ gsutil -m -o
-
"GSUtil:parallel_process_count=1" cp -r gs: //<name_of_bucket>
-
-
或者,您可以使用以下命令直接从 Hugging Face Hub 下载数据集:
$ git clone https://huggingface.co/datasets/transformersbook/codeparrot
是否过滤噪音?
任何人都可以创建 GitHub 存储库,因此项目的质量各不相同。关于我们希望系统如何在现实环境中运行,需要做出一些有意识的选择。在训练数据集中有一些噪声将使我们的系统在推理时对噪声输入更加鲁棒,但也会使其预测更加随机。根据预期用途和整个系统集成,您可以选择或多或少的噪声数据并添加预过滤和后过滤操作。
出于本章的教育目的和保持数据准备代码的简洁性,我们不会根据星级或使用情况进行过滤,只会抓取 GitHub BigQuery 数据集中的所有 Python 文件。然而,数据准备是至关重要的一步,您应该确保尽可能多地清理数据集。在我们的案例中,需要考虑的几件事是是否平衡数据集中的编程语言;过滤低质量数据(例如,通过 GitHub 星星或来自其他存储库的引用);删除重复的代码示例;考虑版权信息;调查文档、注释或文档字符串中使用的语言;并删除个人识别信息,例如密码或密钥。
使用 50 GB 数据集可能具有挑战性;它需要足够的磁盘空间,并且必须小心不要用完 RAM。在下一节中,我们将了解 数据集如何帮助处理在小型机器上处理大型数据集的这些限制。
使用大型数据集
加载非常大的数据集通常是一项具有挑战性的任务,尤其是当数据大于机器的 RAM 时。对于大规模的预训练数据集,这是很常见的情况。在我们的示例中,我们有 50 GB 的压缩数据和大约 200 GB 的未压缩数据,这些数据很难提取并加载到标准尺寸的笔记本电脑或台式电脑的 RAM 内存中。
值得庆幸的是,Datasets 的设计从一开始就是为了克服这个问题,它具有两个特定功能,可以让您摆脱 RAM 和硬盘空间的限制:内存映射和流式传输。
内存映射
为了克服 RAM 限制,Datasets 使用了一种默认激活的零复制和零开销内存映射机制。基本上,每个数据集都缓存在驱动器上的一个文件中,该文件直接反映了 RAM 内存中的内容。Datasets不是将数据集加载到 RAM 中,而是 打开一个指向该文件的只读指针并将其用作 RAM 的替代品,基本上将硬盘驱动器用作 RAM 内存的直接扩展。
到目前为止,我们主要使用数据集来访问 Hugging Face Hub 上的远程数据集。在这里,我们将直接加载我们本地存储在codeparrot
存储库中的 50 GB 压缩 JSON 文件。由于 JSON 文件是压缩的,我们首先需要解压缩它们,Datasets 会为我们处理这些文件。请小心,因为这需要大约 180 GB 的可用磁盘空间!但是,它将几乎不使用 RAM。通过设置 delete_extracted=True
数据集的下载配置,我们可以确保尽快删除所有不再需要的文件:
-
from datasets
import load_dataset, DownloadConfig
-
-
download_config = DownloadConfig(delete_extracted=
True)
-
dataset = load_dataset(
"./codeparrot", split=
"train",
-
download_config=download_config)
在后台,Datasets 通过将所有压缩的 JSON 文件加载到单个优化的缓存文件中来提取并读取它们。让我们看看这个数据集一旦加载有多大:
-
import psutil
-
-
print(
f"Number of python files code in dataset : {len(dataset)}")
-
ds_size =
sum(os.stat(f[
"filename"]).st_size
for f
in dataset.cache_files)
-
# os.stat.st_size is expressed in bytes, so we convert to GB
-
print(
f"Dataset size (cache file) : {ds_size / 2**30:.2f} GB")
-
# Process.memory_info is expressed in bytes, so we convert to MB
-
print(
f"RAM used: {psutil.Process(os.getpid()).memory_info().rss >> 20} MB")
Number of python files code in dataset : 18695559 Dataset size (cache file) : 183.68 GB RAM memory used: 4924 MB
正如我们所看到的,数据集比我们典型的 RAM 内存大得多,但我们仍然可以加载和访问它,而且我们实际上使用的内存量非常有限。
您可能想知道这是否会使我们的训练受 I/O 限制。在实践中,与模型处理计算相比,NLP 数据的加载通常非常轻量,因此这很少成为问题。此外,零复制/零开销格式在后台使用 Apache Arrow,这使得访问任何元素都非常高效。根据硬盘驱动器的速度和批量大小,迭代 数据集通常可以以十分之几 GB/s 到几 GB/s 的速率完成。这很好,但是如果您无法释放足够的磁盘空间来在本地存储完整的数据集怎么办?每个人都知道当您收到完整磁盘警告并且需要通过寻找要删除的隐藏文件来痛苦地尝试回收几 GB 时的无助感。幸运的是,如果您使用 Datasets 的流式传输功能,则无需在本地存储完整的数据 集!
Streaming
一些数据集(达到 1 TB 或更多)即使在标准硬盘驱动器上也难以容纳。在这种情况下,扩展您正在使用的服务器的另一种方法是流式传输数据集。对于许多可以逐行读取的压缩或未压缩文件格式的数据集,这也是可能的,例如 JSON 行、CSV 或文本(原始或 zip、gzip 或 zstandard 压缩)。让我们直接从压缩的 JSON 文件加载我们的数据集,而不是从它们创建缓存文件:
streamed_dataset = load_dataset('./codeparrot', split="train", streaming=True)
如您所见,加载数据集是即时的!在流模式下,压缩的 JSON 文件将被打开并即时读取。我们的数据集现在是一个IterableDataset
对象。这意味着我们不能访问它的随机元素,例如streamed_dataset[1264]
,但我们需要按顺序读取它,例如使用 next(iter(streamed_dataset))
. 仍然可以使用类似 的方法shuffle()
,但这些方法将通过获取示例缓冲区并在此缓冲区内进行洗牌来操作(缓冲区的大小是可调整的)。当几个文件作为原始文件提供时(比如我们这里的 184 个文件),shuffle()
也会随机化文件的顺序以进行迭代。
如我们所见,流数据集的样本与非流数据集的样本相同:
-
iterator = iter(
streamed_dataset)
-
-
print(
dataset[
0] == next(
iterator))
-
print(
dataset[
1] == next(
iterator))
True True
使用流数据集的主要兴趣在于加载此数据集不会在驱动器上创建缓存文件或需要任何(大量)RAM 内存。当请求一批新的示例时,原始原始文件被提取并即时读取,并且只有该批次被加载到内存中。这将我们数据集的内存占用从 180 GB 减少到 50 GB。但是我们可以更进一步——我们可以引用 Hub 上的数据集,而不是指向本地数据集,然后直接下载样本,而无需在本地下载原始文件:
-
remote_dataset = load_dataset('transformersbook/codeparrot', split=
"train",
-
streaming=True)
该数据集的行为与前一个完全相同,但在幕后动态下载示例。通过这样的设置,我们可以在(几乎)任意小的服务器上使用任意大的数据集。让我们将带有训练和验证拆分的数据集推送到 Hugging Face Hub 并通过流媒体访问它。
将数据集添加到 Hugging Face Hub
将我们的数据集推送到 Hugging Face Hub 将使我们能够:
-
从我们的培训服务器轻松访问它。
-
了解流数据集如何与 Hub 中的数据集无缝协作。
-
与社区分享,包括你,亲爱的读者!
要上传数据集,我们首先需要通过在终端中运行以下命令并提供相关凭据来登录我们的 Hugging Face 帐户:
$ huggingface-cli login
这相当于notebook_login()
我们在前几章中使用的辅助函数。完成后,我们可以直接在 Hub 上创建一个新数据集并上传压缩的 JSON 文件。为简化起见,我们将创建两个存储库:一个用于训练拆分,一个用于验证拆分。我们可以通过运行repo create
CLI 的命令来做到这一点,如下所示:
-
$ huggingface-cli repo create --
type dataset --organization transformersbook \
-
codeparrot-train
-
$ huggingface-cli repo create --
type dataset --organization transformersbook \
-
codeparrot-valid
在这里,我们已经指定存储库应该是一个数据集(与用于存储权重的模型存储库相反),以及我们希望存储存储库的组织。如果您在个人帐户下运行此代码,则可以省略该--organization
标志。接下来,我们需要将这些空存储库克隆到我们的本地机器,将 JSON 文件复制到它们,并将更改推送到 Hub。我们将从我们拥有的 184 个 JSON 文件中取出最后一个压缩的 JSON 文件作为验证文件(即,大约占我们数据集的 0.5%)。执行以下命令将存储库从 Hub 克隆到本地计算机:
-
$ git clone
https:/
/huggingface.co/datasets
/transformersbook/codeparrot-train
-
$ git clone
https:/
/huggingface.co/datasets
/transformersbook/codeparrot-valid
接下来,将除最后一个 GitHub 文件之外的所有文件复制为训练集:
-
$
cd codeparrot-train
-
$
cp ../codeparrot/*.json.gz 。
-
$
rm ./file-000000000183.json.gz
然后提交文件并将它们推送到 Hub:
-
$ git add .
-
$ git commit -m
"Adding dataset files"
-
$ git push
现在,重复验证集的过程:
-
$
cd ../codeparrot-valid
-
$
cp ../codeparrot/file-000000000183.json.gz .
-
$
mv ./file-000000000183.json.gz ./file-000000000183_validation.json.gz
-
$ git add .
-
$ git commit -m
"Adding dataset files"
-
$ git push
由于计算了所有文件的哈希,因此该git add .
步骤可能需要几分钟。上传所有文件也需要一些时间。但是,由于这将使我们能够在本章后面使用流式传输,因此这不会浪费时间,而且这一步将使我们在其余实验中走得更快。请注意,我们_validation
为验证文件名添加了后缀。这将使我们能够稍后将其加载为验证拆分。
就是这样!我们的两个数据集拆分以及完整数据集现在位于 Hugging Face Hub 上,网址如下:
-
transformersbook/codeparrot-train · Datasets at Hugging Face
-
transformersbook/codeparrot-valid · Datasets at Hugging Face
笔记
添加 README 卡片来解释数据集的创建方式并提供尽可能多的有用信息是一种很好的做法。一个有据可查的数据集更有可能对其他人以及您未来的自己有用。您可以阅读 Datasets README 指南,详细了解如何编写好的数据集文档。您也可以稍后使用 Web 编辑器直接在 Hub 上修改您的 README 卡。
构建分词器
现在我们已经收集并加载了我们的大型数据集,让我们看看如何有效地处理数据以提供给我们的模型。在前面的章节中,我们使用了伴随我们使用的模型的标记器。这是有道理的,因为这些模型是使用通过分词器中定义的特定预处理管道传递的数据进行预训练的。使用预训练模型时,坚持为预训练选择相同的预处理设计选择非常重要。否则,模型可能会被馈送出分布模式或未知令牌。
然而,当我们训练一个新模型时,使用为另一个数据集准备的标记器可能不是最优的。以下是我们在使用现有分词器时可能遇到的问题类型的一些示例:
-
T5 分词器是在我们之前遇到的 C4语料库上训练的,但是使用了广泛的停用词过滤步骤来创建它。因此,T5 分词器从未见过常见的英文单词,例如“sex”。
-
CamemBERT 分词器还针对非常大的文本语料库进行了训练,但仅包含法语文本( OSCAR语料库的法语子集)。因此,它不知道常见的英语单词,例如“being”。
-
from transformers import AutoTokenizer
-
-
def
tok_list(tokenizer, string):
-
input_ids =
tokenizer(string, add_special_tokens=False)[
"input_ids"]
-
return [tokenizer.
decode(tok) for tok in input_ids]
-
-
tokenizer_T5 = AutoTokenizer.
from_pretrained(
"t5-base")
-
tokenizer_camembert = AutoTokenizer.
from_pretrained(
"camembert-base")
-
print(
f'T5 tokens for
"sex": {tok_list(
tokenizer_T5,
"sex")}')
-
print(
f'CamemBERT tokens for
"being": {tok_list(
tokenizer_camembert,
"being")}')
T5 tokens for "sex": ['', 's', 'ex'] CamemBERT tokens for "being": ['be', 'ing']
在许多情况下,将这些短而常见的单词分成子部分效率低下,因为这会增加模型的输入序列长度(上下文有限)。因此,了解用于训练分词器的数据集的域和预处理非常重要。标记器和模型可以对数据集中的偏差进行编码,这些偏差会对模型的下游行为产生影响。为了为我们的数据集创建一个最佳的分词器,我们需要自己训练一个。让我们看看如何做到这一点。
笔记
训练模型涉及从给定的权重集开始,并在设计目标上使用误差信号的反向传播来最小化模型的损失并为模型找到最佳权重集以执行训练目标定义的任务。另一方面,训练分词器不涉及反向传播或权重。这是一种创建从文本字符串到可以被模型摄取的整数列表的最佳映射的方法。在今天的分词器中,最佳的字符串到整数的转换涉及一个由原子字符串列表组成的词汇表和一个相关的方法,用于将文本字符串转换、规范化、剪切或映射到具有该词汇表的索引列表。这个索引列表就是我们神经网络的输入。
分词器模型
正如您在第 4 章中看到的,分词器是一个处理管道,由四个步骤组成:规范化、预分词、分词器模型和后处理。可以在数据上训练的分词器管道部分是分词器模型。正如我们在 第 2 章中讨论的那样,可以使用几种子词标记化算法,例如 BPE、WordPiece 和 Unigram。
BPE 从基本单元(单个字符)列表开始,并通过逐步创建新标记的过程来创建词汇表,这些新标记是通过合并最常同时出现的基本单元并将它们添加到词汇表中而形成的。重复此过程,直到达到预定义的词汇量。
Unigram 从另一端开始,用语料库中的所有单词和潜在的子词初始化其基本词汇表。然后它逐步移除或拆分不太有用的标记以获得越来越小的词汇表,直到达到目标词汇表大小。WordPiece 是 Unigram 的前身,其官方实现从未被 Google 开源。
这些不同算法对下游性能的影响因任务而异,总体而言,很难确定一种算法是否明显优于其他算法。BPE 和 Unigram 在大多数情况下都具有合理的性能,但是让我们看一下在评估时要考虑的一些方面。
测量标记器性能
-
子词生育率,它计算每个标记词产生的子词的平均数量
-
连续词的比例,指在一个语料库中被分词的词被分成至少两个子词的比例
-
覆盖指标,例如标记化语料库中未知单词或很少使用的标记的比例
此外,通常会估计对拼写错误或噪声的鲁棒性,以及此类域外示例的模型性能,因为这在很大程度上取决于标记化过程。
这些度量对分词器的性能给出了一组不同的看法,但它们往往忽略了分词器与模型的交互。例如,可以通过将所有可能的词包含在词汇表中来最小化子词生育率,但这会为模型产生非常大的词汇表。
最后,各种标记化方法的性能通常最好通过使用模型的下游性能作为最终指标来估计。例如,早期 BPE 方法的良好性能通过使用这些标记器和词汇而不是基于字符或单词的标记化训练的模型显示机器翻译任务的改进性能来证明。
让我们看看如何构建我们自己的针对 Python 代码优化的分词器。
Python 的分词器
我们的用例需要一个自定义标记器:标记 Python 代码。预标记化问题值得对编程语言进行一些讨论。如果我们拆分空格并删除它们,我们将丢失所有缩进信息,这在 Python 中对程序的语义很重要(只需考虑while
循环或 if-then-else
语句)。另一方面,换行符没有意义,可以在不影响语义的情况下添加或删除。类似地,在标点符号上进行拆分,例如下划线,用于从多个子部分组成单个变量名,可能不像在自然语言中那样有意义。因此,使用自然语言预分词器对代码进行分词似乎不是最理想的。
让我们看看集线器上提供的集合中是否有任何可能对我们有用的标记器。我们想要一个保留空格的标记器,所以一个好的候选者可能是一个字节级的标记器,就像 GPT-2 中的那个。让我们加载这个分词器并探索它的分词属性:
-
from transformers
import AutoTokenizer
-
-
python_code =
r"""def say_hello():
-
print("Hello, World!")
-
-
# Print it
-
say_hello()
-
"""
-
tokenizer = AutoTokenizer.from_pretrained(
"gpt2")
-
print(tokenizer(python_code).tokens())
['def', 'Ġsay', '_', 'hello', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', '("', 'Hello', ',', 'ĠWorld', '!"', ')', 'Ġ#', 'ĠPrint', 'Ġit', 'Ċ', 'Ċ', 'say', '_', 'hello', '()', 'Ċ']
笔记
Python 有一个内置
tokenize
模块,可以将 Python 代码字符串拆分为有意义的单元(代码操作、注释、缩进和缩进等)。使用这种方法的一个问题是这个预标记器是基于 Python 的,因此通常相当慢并且受到 Python 全局解释器锁 (GIL) 的限制。另一方面,Transformers 库中的大多数分词器都是由分词器库提供的,并且是用 Rust 编码的。Rust 标记器的训练和使用速度要快很多数量级,因此考虑到我们语料库的大小,我们可能会想要使用它们。
这是一个非常奇怪的输出,所以让我们通过运行标记器管道的各个子模块来尝试了解这里发生了什么。首先让我们看看在这个分词器中应用了什么规范化:
print(tokenizer.backend_tokenizer.normalizer)
None
正如我们所见,GPT-2 分词器没有使用规范化。它直接在 原始 Unicode输入上工作,无需任何规范化步骤。现在让我们看一下 预标记:
print(tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(python_code))
[('def', (0, 3)), ('Ġsay', (3, 7)), ('_', (7, 8)), ('hello', (8, 13)), ( '():',
(13, 16)), ('YYYY', (16, 20)), ('打印', (20, 26)), ('("', (26, 28)), ('Hello',
(28, 33)), (',', (33, 34)), ('GWorld', (34, 40)), ('!")', (40, 43)), ('G#' , (43,
45)), ('GPrint', (45, 51)), ('Git', (51, 54)), ('C', (54, 55)), ('C', (55, 56) ),
('say', (56, 59)), ('_', (59, 60)), ('hello', (60, 65)), ('()', (65, 67)), ( 'C',
(67, 68))]
所有这些Ġ
符号是什么,令牌附带的数字是什么?让我们解释一下,看看我们是否能更好地理解这个分词器是如何工作的。
让我们从数字开始。 Tokenizers 有一个非常有用的功能,用于在字符串和标记之间切换,称为偏移跟踪。跟踪输入字符串上的所有操作,以便准确知道标记化后的标记对应于输入字符串的哪一部分。这些数字只是表明每个标记在原始字符串中的来源;例如, 'hello'
第一行中的单词对应于原始字符串中的第 8 到 13 个字符。如果在规范化步骤中删除了某些字符,我们仍然能够将每个标记与原始字符串中的相应部分相关联。
标记化文本的另一个奇怪特征是看起来很奇怪的字符,例如Ċ
和Ġ
。字节级意味着此标记器适用于字节而不是 Unicode 字符。每个 Unicode 字符由 1 到 4 个字节组成,具体取决于字符。字节的好处在于,虽然 Unicode 字母表中有 143,859 个 Unicode 字符,但字节字母表中只有 256 个元素,您可以将每个 Unicode 字符表示为这些字节的序列。如果我们处理字节,我们可以将所有由 UTF-8 世界组成的字符串表示为这个由 256 个值组成的字母表中的较长字符串。也就是说,我们可以有一个模型使用只有 256 个单词的字母表,并且能够处理任何 Unicode 字符串。让我们看一下某些字符的字节表示形式:
-
a, e =
u"a",
u"€"
-
byte =
ord(a.encode(
"utf-8"))
-
print(
f'`{a}` is encoded as `{a.encode("utf-8")}` with a single byte: {byte}')
-
byte = [
ord(
chr(i))
for i
in e.encode(
"utf-8")]
-
print(
f'`{e}` is encoded as `{e.encode("utf-8")}` with three bytes: {byte}')
`a` is encoded as `b'a'` with a single byte: 97 `€` is encoded as `b'\xe2\x82\xac'` with three bytes: [226, 130, 172]
此时您可能想知道:为什么要在字节级别上工作?回想一下我们在第 2 章中 关于字符和单词标记之间权衡的讨论。我们可以决定从 143,859 个 Unicode 字符构建我们的词汇表,但我们也想在我们的词汇表中包含单词——即 Unicode 字符的组合,所以这个(已经非常大的)大小只是总大小的下限的词汇。这将使我们的模型的嵌入层非常大,因为它包含每个词汇标记的一个向量。
在另一个极端,如果我们只使用 256 字节的值作为我们的词汇表,输入序列将被分割成许多小片段(每个字节构成 Unicode 字符),因此我们的模型将不得不处理长输入并花费从单独的字节重建 Unicode 字符,然后从这些字符重建单词,具有强大的计算能力。有关此开销的详细研究,请参阅 ByT5 模型版本随附的论文。6
一个中间的解决方案是通过使用最常见的字节组合扩展 256 个单词的词汇表来构建一个中等大小的词汇表。这是 BPE 算法采用的方法。这个想法是通过迭代合并词汇表中最常同时出现的一对标记来创建新的词汇标记,从而逐步构建一个预定义大小的词汇表。例如,如果t
和 h
经常一起出现,就像在英语中一样,我们将th
在词汇表中添加一个标记来模拟这对标记,而不是将它们分开。和标记保存在词汇表t
中h
以标记它们不一起出现的实例。从基本单元的基本词汇开始,我们可以有效地对任何字符串进行建模。
警告
注意不要将“字节对编码”中的“字节”与“字节级”中的“字节”混淆。Byte-Pair Encoding 这个名字来源于 Philip Gage 在 1994 年提出的一种数据压缩技术,最初是对字节进行操作。7与此名称可能表示的不同,NLP 中的标准 BPE 算法通常在 Unicode 字符串而不是字节上运行(尽管有一种新类型的 BPE 专门在字节上运行,称为字节级 BPE)。如果我们以字节为单位读取 Unicode 字符串,我们就可以重用一个简单的 BPE 子字拆分算法。
在 NLP 中使用典型的 BPE 算法时只有一个问题。这些算法被设计为使用干净的 Unicode 字符串作为输入,而不是字节,并期望输入中的常规 ASCII 字符,没有空格或控制字符。但是在 256 个首字节对应的 Unicode 字符中,有很多控制字符(换行符、制表符、转义符、换行符和其他不可打印的字符)。为了克服这个问题,GPT-2 标记器首先将所有 256 个输入字节映射到标准 BPE 算法可以轻松消化的 Unicode 字符串——也就是说,我们将我们的 256 个基本值映射到所有对应于标准可打印的 Unicode 字符串Unicode 字符。
这些 Unicode 字符每个都用 1 个字节或更多字节编码并不是很重要;重要的是我们最后有 256 个单个值,形成我们的基本词汇表,并且这 256 个值由我们的 BPE 算法正确处理。让我们看一些使用 GPT-2 标记器的映射示例。我们可以按如下方式访问整个映射:
-
from transformers.models.gpt2.tokenization_gpt2
import bytes_to_unicode
-
-
byte_to_unicode_map = bytes_to_unicode()
-
unicode_to_byte_map =
dict((v, k)
for k, v
in byte_to_unicode_map.items())
-
base_vocab =
list(unicode_to_byte_map.keys())
-
-
print(
f'Size of our base vocabulary: {len(base_vocab)}')
-
print(
f'First element: `{base_vocab[0]}`, last element: `{base_vocab[-1]}`')
Size of our base vocabulary: 256 First element: `!`, last element: `Ń`
我们可以在表 10-1中查看一些常见的字节值和相关映射的 Unicode 字符。
表 10-1。BPE 中的字符映射示例
我们本可以使用更明确的转换,例如将换行符映射到NEWLINE
字符串,但 BPE 算法通常设计用于处理字符。出于这个原因,使用开箱即用的 BPE 算法,为每个字节字符保留一个 Unicode 字符更容易处理。现在我们已经了解了 Unicode 编码的黑魔法,我们可以更好地理解我们的标记化转换:
print(tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(python_code))
[('def', (0, 3)), ('Ġsay', (3, 7)), ('_', (7, 8)), ('hello', (8, 13)), ('():', (13, 16)), ('ĊĠĠĠ', (16, 20)), ('Ġprint', (20, 26)), ('("', (26, 28)), ('Hello', (28, 33)), (',', (33, 34)), ('ĠWorld', (34, 40)), ('!")', (40, 43)), ('Ġ#', (43, 45)), ('ĠPrint', (45, 51)), ('Ġit', (51, 54)), ('Ċ', (54, 55)), ('Ċ', (55, 56)), ('say', (56, 59)), ('_', (59, 60)), ('hello', (60, 65)), ('()', (65, 67)), ('Ċ', (67, 68))]
我们可以识别换行符,我们现在知道映射到Ċ
,而空格映射到Ġ
。我们还看到:
-
空间,特别是连续空间,是守恒的(例如, 中的三个空间
'ĊĠĠĠ'
)。 -
连续的空格被视为一个单词。
-
单词前面的每个空格都附加到后续单词(例如 in
'Ġsay'
)并被视为其一部分。
现在让我们尝试 BPE 模型。正如我们已经提到的,它负责将单词拆分为子单元,直到所有子单元都属于预定义的词汇表。
我们的 GPT-2 分词器的词汇表包含 50,257 个单词:
-
具有 256 个字节值的基本词汇表
-
50,000 个通过重复合并最常同时出现的代币创建的额外代币
-
添加到词汇表以表示文档边界的特殊字符
我们可以通过查看标记器的长度属性轻松检查:
print(f"Size of the vocabulary: {len(tokenizer)}")
Size of the vocabulary: 50257
在我们的输入代码上运行完整的管道会给我们以下输出:
print(tokenizer(python_code).tokens())
['def', 'Ġsay', '_', 'hello', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', '("', 'Hello', ',', 'ĠWorld', '!"', ')', 'Ġ#', 'ĠPrint', 'Ġit', 'Ċ', 'Ċ', 'say', '_', 'hello', '()', 'Ċ']
正如我们所看到的,BPE 分词器保留了大部分单词,但会将我们缩进的多个空格分割成几个连续的空格。发生这种情况是因为这个标记器没有专门针对代码进行训练,而是主要针对连续空格很少的文本进行训练。因此,BPE 模型不包括缩进词汇表中的特定标记。这是分词器模型不太适合数据集域的情况。正如我们之前讨论的,解决方案是在目标语料库上重新训练分词器。所以让我们开始吧!
训练分词器
让我们在我们的语料库切片上重新训练我们的字节级 BPE 标记器,以获得更好地适应 Python 代码的词汇表。重新训练Transformers提供的分词器很简单。我们只需要:
-
指定我们的目标词汇量。
-
准备一个迭代器来提供要处理的输入字符串列表,以训练标记器的模型。
-
调用
train_new_from_iterator()
方法。
与通常期望从训练语料库中记住大量特定细节的深度学习模型不同,标记器实际上只是经过训练以提取主要统计数据。简而言之,标记器只是经过训练,知道哪些字母组合在我们的语料库中最常见。
因此,您不一定需要在非常大的语料库上训练分词器;语料库只需要代表您的领域并且足够大,以便标记器提取具有统计意义的度量。但是根据词汇量的大小和语料库中的确切文本,分词器最终可能会存储 意外的单词。例如,当查看 GPT-2 分词器词汇表中最长的单词时,我们可以看到这一点:
-
tokens =
sorted(tokenizer.vocab.items(), key=
lambda x:
len(x[
0]), reverse=
True)
-
print([
f'{tokenizer.convert_tokens_to_string(t)}'
for t, _
in tokens[:
8]]);
['ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', ' =================================================================', ' ---------------------------------------------------------------- ', '................................................................', 'ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', ' ---------------------------------------------------------------- ', '================================================================', '________________________________________________________________']
这些标记看起来像很可能在论坛上使用的分隔线。这是有道理的,因为 GPT-2 是在以 Reddit 为中心的语料库上训练的。现在让我们看看最后添加到词汇表中的单词,以及最不常见的单词:
-
tokens =
sorted(tokenizer.vocab.items(), key=
lambda x: x[
1], reverse=
True)
-
print([
f'{tokenizer.convert_tokens_to_string(t)}'
for t, _
in tokens[:
12]]);
['<|endoftext|>', ' gazed', ' informants', ' Collider', ' regress', 'ominated', ' amplification', 'Compar', '..."', ' (/', 'Commission', ' Hitman']
第一个标记<|endoftext|>
是用于指定文本序列结尾的特殊标记,是在 BPE 词汇表构建后添加的。对于这些标记中的每一个,我们的模型都必须学习相关的词嵌入,并且我们可能不希望嵌入矩阵包含太多嘈杂的词。还要注意一些非常时间和空间特定的世界知识(例如,专有名词,如Hitman
和Commission
)是如何嵌入在我们的建模方法中的非常低的级别,这些单词被授予带有词汇表中相关向量的单独标记。BPE 标记器创建此类特定标记也可能表明目标词汇量太大或语料库包含特殊标记。
让我们在我们的语料库上训练一个新的分词器并检查它的学习词汇。由于我们只需要一个能够合理代表我们的数据集统计信息的语料库,让我们从我们的语料库中选择大约 1-2 GB 的数据,或大约 100,000 个文档:
-
from tqdm.auto
import tqdm
-
-
length =
100000
-
dataset_name =
'transformersbook/codeparrot-train'
-
dataset = load_dataset(dataset_name, split=
"train", streaming=
True)
-
iter_dataset =
iter(dataset)
-
-
def
batch_iterator(
batch_size=10):
-
for _
in tqdm(
range(
0, length, batch_size)):
-
yield [
next(iter_dataset)[
'content']
for _
in
range(batch_size)]
-
-
new_tokenizer = tokenizer.train_new_from_iterator(batch_iterator(),
-
vocab_size=
12500,
-
initial_alphabet=base_vocab)
让我们研究一下我们的 BPE 算法创建的第一个和最后一个词,看看我们的词汇表有多相关。我们跳过 256 字节的标记,然后查看之后添加的第一个标记:
-
tokens =
sorted(new_tokenizer.vocab.items(), key=
lambda x: x[
1], reverse=
False)
-
print([
f'{tokenizer.convert_tokens_to_string(t)}'
for t, _
in tokens[
257:
280]]);
[' ', ' ', ' ', ' ', 'se', 'in', ' ', 're', 'on', 'te', '\n ', '\n ', 'or', 'st', 'de', '\n ', 'th', 'le', ' =', 'lf', 'self', 'me', 'al']
在这里,我们可以看到各种标准级别的缩进和空白标记,以及简短的常见 Python 关键字,如self
、or
和 in
。这是一个很好的迹象,表明我们的 BPE 算法正在按预期工作。现在让我们看看最后一句话:
print([f'{new_tokenizer.convert_tokens_to_string(t)}' for t,_ in tokens[-12:]]);
['def', 'Ġs', 'ay', '_', 'hello', '():', 'ĊĠĠĠ', 'Ġprint', '("', 'Hello', ',', 'ĠWor', 'ld', '!")', 'Ġ#', 'ĠPrint', 'Ġit', 'Ċ', 'Ċ', 's', 'ay', '_', 'hello', '()', 'Ċ']
这里还有一些比较常见的词,比如 recv,还有一些比较吵的词可能来自评论。
我们还可以对 Python 代码的简单示例进行标记,以查看标记器在一个简单示例中的表现:
print(new_tokenizer(python_code).tokens())
['def', 'Gs', 'ay', '_', 'hello', '():', 'CGGGG', 'Print', '("', 'Hello', ',',
'GWor', 'ld', '!")', 'G#', 'GPrint', 'Git', 'C', 'C', 's', 'ay', '_', 'hello',
'()', 'C']
尽管它们不是代码关键字,但看到常见的英语单词喜欢World
或被say
我们的分词器拆分还是有点烦人,因为我们希望它们在语料库中出现得相当频繁。让我们检查一下是否所有的 Python 保留关键字都在词汇表中:
-
import keyword
-
-
print(
f'There are in total {len(keyword.kwlist)} Python keywords.')
-
for keyw
in keyword.kwlist:
-
if keyw
not
in new_tokenizer.vocab:
-
print(
f'No, keyword `{keyw}` is not in the vocabulary')
There are in total 35 Python keywords. No, keyword `await` is not in the vocabulary No, keyword `finally` is not in the vocabulary No, keyword `nonlocal` is not in the vocabulary
似乎几个相当频繁的关键字,例如finally
,也不在词汇表中。让我们尝试使用更大的数据集样本来构建更大的词汇表。例如,我们可以构建一个包含 32,768 个单词的词汇表(8 的倍数更适合一些高效的 GPU/TPU 计算)并在两倍大的语料库切片上训练分词器:
-
length
=
200000
-
new_tokenizer_larger
= tokenizer.train_new_from_iterator
(batch_iterator
(
)
,
-
vocab_size
=
32768
, initial_alphabet
=base_vocab
)
我们不希望添加更多文档时最常见的标记发生太大变化,但让我们看看最后一个标记:
-
tokens =
sorted(new_tokenizer_larger.vocab.items(), key=
lambda x: x[
1],
-
reverse=
False)
-
print([
f'{tokenizer.convert_tokens_to_string(t)}'
for t, _
in tokens[-
12:]]);
['lineEdit', 'spik', 'BC', 'pective', 'OTA', 'theus', 'FLUSH', 'exutils',
'00000002'、'DIVISION'、'CursorPosition'、'InfoBar']
简短的检查在这里没有显示任何常规的编程关键字,这是有希望的。让我们尝试使用新的更大的分词器对我们的示例代码示例进行分 词:
print(new_tokenizer_larger(python_code).tokens())
['def', 'Ġsay', '_', 'hello', '():', 'ĊĠĠĠ', 'Ġprint', '("', 'Hello', ',',
'GWorld', '!")', 'G#', 'GPrint', 'Git', 'C', 'C', 'say', '_', 'hello', '()',
'C']
这里的缩进也很方便地保存在词汇表中,我们看到常见的英语单词,如Hello
,World
和say
也包含在单个标记中。这似乎更符合我们对模型在下游任务中可能看到的数据的预期。让我们研究一下常见的 Python 关键字,就像我们之前所做的那样:
-
for keyw
in keyword.kwlist:
-
if keyw
not
in new_tokenizer_larger.vocab:
-
print(
f'No, keyword `{keyw}` is not in the vocabulary')
No, keyword `nonlocal` is not in the vocabulary
我们仍然缺少 nonlocal关键字,但它在实践中也很少使用,因为它使语法更加复杂。将其排除在词汇表之外似乎是合理的。在这个手动检查之后,我们更大的分词器似乎很适合我们的任务——但正如我们之前提到的,在不衡量模型性能的情况下客观地评估分词器的性能是一项具有挑战性的任务。我们将继续这个并训练一个模型,看看它在实践中的效果如何。
在集线器上保存自定义标记器
现在我们的分词器已经训练好了,我们应该保存它。保存它并能够在以后从任何地方访问它的最简单方法是将其推送到 Hugging Face Hub。当我们使用单独的训练服务器时,这将特别有用。
要创建一个私有模型存储库并将我们的分词器作为第一个文件保存在其中,我们可以直接使用分push_to_hub()
词器的方法。由于我们已经使用 验证了我们的帐户 huggingface-cli login
,因此我们可以简单地按如下方式推送标记器:
-
model_ckpt =
"codeparrot"
-
org =
"transformersbook"
-
new_tokenizer_larger.push_to_hub(model_ckpt, organization=org)
如果您不想推送到某个组织,您可以简单地省略该organization
参数。这将在您的命名空间中创建一个名为 的存储库codeparrot
,然后任何人都可以通过运行以下命令加载该存储库:
-
reloaded_tokenizer = AutoTokenizer.from_pretrained(org +
"/" + model_ckpt)
-
print(reloaded_tokenizer(python_code).tokens())
['def', 'Ġsay', '_', 'hello', '():', 'ĊĠĠĠ', 'Ġprint', '("', 'Hello', ',',
'GWorld', '!")', 'G#', 'GPrint', 'Git', 'C', 'C', 'say', '_', 'hello', '()',
'C']
从 Hub 加载的分词器的行为与我们刚刚看到的完全一样。我们还可以在 Hub上调查其文件和保存的词汇。为了重现性,让我们也保存我们较小的标记器:
new_tokenizer.push_to_hub(model_ckpt+ "-small-vocabulary", organization=org)
这是为特定用例构建标记器的深入研究。接下来,我们将最终创建一个新模型并从头开始对其进行训练。
从头开始训练模型
这是您可能一直在等待的部分:模型训练。在本节中,我们将决定哪种架构最适合该任务,初始化一个没有预训练权重的新模型,设置一个自定义数据加载类,并创建一个可扩展的训练循环。在总决赛中,我们将分别训练具有 1.11 亿和 15 亿参数的小型和大型 GPT-2 模型!但是,让我们不要超越自己。首先,我们需要决定哪种架构最适合代码自动完成。
小费
在本节中,我们将实现一个比平常更长的脚本来在分布式基础设施上训练模型。因此,您不应单独运行每个代码片段,而应下载 Transformers 存储库中提供的脚本。按照随附的说明在您的硬件上使用Accelerate执行脚本。
预训练目标的故事
现在我们已经获得了大规模的预训练语料库和高效的分词器,我们可以开始考虑如何预训练一个 Transformer 模型。有了这样一个由如图 10-1所示的代码片段组成的大型代码库,我们可以处理多个任务。我们选择哪一个会影响我们对预训练目标的选择。让我们来看看三个常见的任务。
图 10-1。可以在我们的数据集中找到的 Python 函数示例
因果语言建模
文本数据的一项自然任务是为模型提供代码示例的开头,并要求它生成可能的补全。这是一个自我监督的训练目标,我们可以在没有注释的情况下使用数据集。这应该敲响警钟:这是我们在第 5 章中遇到 的因果语言建模任务。一个直接相关的下游任务是代码自动完成,所以我们肯定会把这个模型放在候选名单上。仅解码器架构(例如 GPT 系列模型)通常最适合此任务, 如图 10-2所示。
图 10-2。在因果语言建模中,未来的标记被掩盖,模型必须预测它们;通常,诸如 GPT 之类的解码器模型用于此类任务
掩蔽语言建模
一个相关但略有不同的任务是提供一个带有噪声代码样本的模型,例如用随机或屏蔽字替换的代码指令,并要求它重建原始的干净样本,如图 10-3 所示。这也是一个自我监督的训练目标,通常称为掩码语言建模或去噪目标. 很难考虑与去噪直接相关的下游任务,但去噪通常是一个很好的预训练任务,可以学习后期下游任务的一般表示。我们在前几章中使用的许多模型(如 BERT 和 XLM-RoBERTa)都是以这种方式预训练的。因此,可以将在大型语料库上训练掩码语言模型与使用有限数量的标记示例在下游任务上微调模型相结合。
图 10-3。在屏蔽语言建模中,一些输入标记要么被屏蔽,要么被替换,模型的任务是预测原始标记;这是变压器模型编码器分支的基础架构
序列到序列的训练
另一项任务是使用启发式(如正则表达式)将注释或文档字符串与代码分开,并构建可用作注释数据集的(代码、注释)对的大规模数据集。然后,训练任务是一个有监督的训练目标,其中一个类别(代码或评论)用作模型的输入,而另一个类别(评论或代码)用作标签。这是一个使用(输入,标签)对 的监督学习的例子,如图 10-4 所示. 有了一个庞大、干净、多样的数据集以及一个容量足够大的模型,我们可以尝试训练一个模型来学习在代码中转录注释,反之亦然。与此监督训练任务直接相关的下游任务是从代码生成文档或从文档生成代码,这取决于我们如何设置输入/输出。在此设置中,一个序列被转换为另一个序列,这就是 T5、BART 和 PEGASUS 等编码器-解码器架构大放异彩的地方。
图 10-4。使用编码器-解码器架构进行序列到序列任务,其中使用启发式方法将输入分成注释/代码对:模型将一个元素作为输入并需要生成另一个元素
由于我们要构建代码自动完成模型,我们将选择第一个目标并为任务选择 GPT 架构。所以让我们初始化一个新的 GPT-2 模型!
初始化模型
这是本书第一次不使用该 from_pretrained()
方法加载模型而是初始化新模型。但是,我们将加载 的配置,gpt2-xl
以便我们使用相同的超参数,并且只为新的分词器调整词汇量。然后,我们使用以下方法初始化具有此配置的新模型from_config()
:
-
from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
-
-
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
-
config = AutoConfig.from_pretrained(
"gpt2-xl", vocab_size=
len(tokenizer))
-
model = AutoModelForCausalLM.from_config(
config)
让我们检查一下模型实际上有多大:
print(f'GPT-2 (xl) size: {model_size(model)/1000**2:.1f}M parameters')
GPT-2 (xl) size: 1529.6M parameters
这是1.5B参数模型!这是一个很大的容量,但我们也有一个很大的数据集。一般来说,只要数据集相当大,大型语言模型的训练效率就会更高。让我们将新初始化的模型保存在models/ 文件夹中并将其推送到 Hub:
-
model.save_pretrained("models/"
+ model_ckpt, push_to_hub
=
True,
-
organization
=org)
鉴于检查点的大小 (> 5 GB),将模型推送到 Hub 可能需要几分钟。由于这个模型非常大,我们还将创建一个较小的版本,我们可以对其进行训练,以确保在扩大规模之前一切正常。我们将以标准 GPT-2 尺寸为基础:
-
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
-
config_small = AutoConfig.from_pretrained(
"gpt2", vocab_size=
len(tokenizer))
-
model_small = AutoModelForCausalLM.from_config(config_small)
print(f'GPT-2 size: {model_size(model_small)/1000**2:.1f}M parameters')
GPT-2 size: 111.0M parameters
让我们也将其保存到 Hub 以方便共享和重用:
-
model_small.save_pretrained("models/"
+ model_ckpt
+ "-small", push_to_hub
=
True,
-
organization
=org)
现在我们有两个可以训练的模型,我们需要确保我们可以在训练期间有效地为它们提供输入数据。
实现数据加载器
为了能够以最大效率进行训练,我们将希望为我们的模型提供填充其上下文的序列。例如,如果我们模型的上下文长度是 1,024 个标记,我们总是希望在训练期间提供 1,024 个标记序列。但是我们的一些代码示例可能比 1,024 个令牌更短或更长。用完整序列喂料批次sequence_length
对于我们的模型,因此我们应该删除最后一个不完整的序列或填充它。但是,这将使我们的训练效率稍低,并迫使我们处理填充和屏蔽填充标记标签。我们对计算的限制比对数据的限制要多得多,因此我们将在这里采用简单有效的方法。我们可以使用一个小技巧来确保我们不会丢失太多的尾段:我们可以标记几个示例,然后将它们连接起来,用特殊的序列结束标记分隔,以获得一个非常长的序列。最后,我们将这个序列分成大小相等的块,如图 10-5所示。使用这种方法,我们最后最多会丢失一小部分数据。
图 10-5。通过在分块之前将多个标记化示例与 EOS 标记连接起来,为因果语言建模准备不同长度的序列
例如,我们可以通过将输入字符串字符长度定义为:
input_characters = number_of_sequences * sequence_length * characters_per_token
在哪里:
-
input_characters
是输入到我们的标记器的字符串中的字符数。 -
number_of_sequences
是我们希望从分词器中获得的(截断的)序列数,(例如,100)。 -
sequence_length
是标记器返回的每个序列的标记数,(例如,1,024)。 -
characters_per_token
是我们首先需要估计的每个输出标记的平均字符数。
如果我们输入一个带有input_characters
字符的字符串,我们将获得平均number_of_sequences
输出序列,并且我们可以通过删除最后一个序列轻松计算我们丢失了多少输入数据。如果number_of_sequences=100
这意味着我们堆叠大约 100 个序列并且最多丢失最后一个元素,这可能太短或太长。这对应于我们最多丢失 1% 的数据集。同时,这种方法确保我们不会通过切断大部分文件结尾来引入偏差。
让我们首先估计数据集中每个标记的平均字符长度:
-
examples, total_characters, total_tokens =
500,
0,
0
-
dataset = load_dataset(
'transformersbook/codeparrot-train', split=
'train',
-
streaming=
True)
-
-
for _, example
in tqdm(
zip(
range(examples),
iter(dataset)), total=examples):
-
total_characters +=
len(example[
'content'])
-
total_tokens +=
len(tokenizer(example[
'content']).tokens())
-
-
characters_per_token = total_characters / total_tokens
print(characters_per_token)
3.6233025034779565
有了这些,我们就拥有了创建自己的 IterableDataset
(PyTorch 提供的辅助类)为模型准备恒定长度输入所需的一切。我们只需要继承IterableDataset
并设置__iter__()
产生下一个元素的函数,使用我们刚刚介绍的逻辑:
-
import torch
-
from torch.utils.data
import IterableDataset
-
-
class
ConstantLengthDataset(
IterableDataset):
-
-
def
__init__(
self, tokenizer, dataset, seq_length=1024,
-
num_of_sequences=1024, chars_per_token=3.6):
-
self.tokenizer = tokenizer
-
self.concat_token_id = tokenizer.eos_token_id
-
self.dataset = dataset
-
self.seq_length = seq_length
-
self.input_characters = seq_length * chars_per_token * num_of_sequences
-
-
def
__iter__(
self):
-
iterator =
iter(self.dataset)
-
more_examples =
True
-
while more_examples:
-
buffer, buffer_len = [],
0
-
while
True:
-
if buffer_len >= self.input_characters:
-
m=
f"Buffer full: {buffer_len}>={self.input_characters:.0f}"
-
print(m)
-
break
-
try:
-
m=
f"Fill buffer: {buffer_len}<{self.input_characters:.0f}"
-
print(m)
-
buffer.append(
next(iterator)[
"content"])
-
buffer_len +=
len(buffer[-
1])
-
except StopIteration:
-
iterator =
iter(self.dataset)
-
-
all_token_ids = []
-
tokenized_inputs = self.tokenizer(buffer, truncation=
False)
-
for tokenized_input
in tokenized_inputs[
"input_ids'"]:
-
for tokenized_input
in tokenized_inputs:
-
all_token_ids.extend(tokenized_input + [self.concat_token_id])
-
-
for i
in
range(
0,
len(all_token_ids), self.seq_length):
-
input_ids = all_token_ids[i : i + self.seq_length]
-
if
len(input_ids) == self.seq_length:
-
yield torch.tensor(input_ids)
该__iter__()
函数建立一个字符串缓冲区,直到它包含足够的字符。缓冲区中的所有元素都被标记化并与 EOS 标记连接,然后长序列 in all_token_ids
被分块为seq_length
-size 的切片。通常,我们需要注意掩码来堆叠不同长度的填充序列,并确保在训练期间忽略填充。我们通过只提供相同(最大)长度的序列来解决这个问题,所以我们在这里不需要掩码,只返回input_ids
. 让我们测试我们的可迭代数据集:
-
shuffled_dataset = dataset.shuffle(buffer_size=
100)
-
constant_length_dataset = ConstantLengthDataset(tokenizer, shuffled_dataset,
-
num_of_sequences=
10)
-
dataset_iterator =
iter(constant_length_dataset)
-
-
lengths = [
len(b)
for _, b
in
zip(
range(
5), dataset_iterator)]
-
print(
f"Lengths of the sequences: {lengths}")
Fill buffer: 0<36864 Fill buffer: 3311<36864 Fill buffer: 9590<36864 Fill buffer: 22177<36864 Fill buffer: 25530<36864 Fill buffer: 31098<36864 Fill buffer: 32232<36864 Fill buffer: 33867<36864 Buffer full: 41172>=36864 Lengths of the sequences: [1024, 1024, 1024, 1024, 1024]
很好,这按预期工作,我们得到了模型的恒定长度输入。现在我们有了模型的可靠数据源,是时候构建实际的训练循环了。
定义训练循环
我们现在拥有编写训练循环的所有元素。训练我们自己的语言模型的一个明显限制是我们将使用的 GPU 的内存限制。即使在现代显卡上,您也无法在合理的时间内以 GPT-2 规模训练模型。在本教程中,我们将实现数据并行,这将帮助我们利用多个 GPU 进行训练。幸运的是,我们可以使用
Accelerate 使我们的代码具有可扩展性。Accelerate库旨在简化分布式训练以及更改用于训练的底层硬件。我们也可以使用Trainer
分布式训练,但Accelerate 让我们可以完全控制训练循环,这正是我们想要在这里探索的。 Accelerate 提供了一个简单的 API,可以使训练脚本以混合精度和任何类型的分布式设置(单 GPU、多 GPU 和 TPU)运行。然后,相同的代码可以在本地机器上无缝运行以进行调试,或者在强大的训练集群上无缝运行以进行最终训练。您只需对原生 PyTorch 训练循环进行少量更改:
-
import torch
-
import torch.nn.functional
as F
-
from datasets
import load_dataset
-
from accelerate
import Accelerator
-
-
device = 'cpu'
-
accelerator =
Accelerator()
-
-
model = torch.nn.
Transformer().to(device)
-
model = torch.nn.
Transformer()
-
optimizer = torch.optim.
Adam(model.parameters())
-
dataset = load_dataset('my_dataset')
-
data = torch.utils.data.DataLoader(dataset, shuffle=True)
-
model, optimizer,
data = accelerator.prepare(model, optimizer, data)
-
-
model.train()
-
for epoch
in range(
10):
-
for source, targets
in
data:
-
source = source.to(device)
-
targets = targets.to(device)
-
optimizer.zero_grad()
-
output = model(source)
-
loss =
F.cross_entropy(output, targets)
-
loss.backward()
-
accelerator.backward(loss)
-
optimizer.step()
更改的核心部分是对 的调用prepare()
,它确保模型、优化器和数据加载器都准备好并分布在基础设施上。PyTorch 训练循环的这些细微更改使您能够轻松地跨不同基础架构扩展训练。考虑到这一点,让我们开始构建我们的训练脚本并定义一些辅助函数。首先,我们设置用于训练的超参数并将它们包装在 aNamespace
中以便于访问:
-
from argparse
import Namespace
-
-
# Commented parameters correspond to the small model
-
config = {
"train_batch_size":
2,
# 12
-
"valid_batch_size":
2,
# 12
-
"weight_decay":
0.1,
-
"shuffle_buffer":
1000,
-
"learning_rate":
2e-4,
# 5e-4
-
"lr_scheduler_type":
"cosine",
-
"num_warmup_steps":
750,
# 2000
-
"gradient_accumulation_steps":
16,
# 1
-
"max_train_steps":
50000,
# 150000
-
"max_eval_steps":
-1,
-
"seq_length":
1024,
-
"seed":
1,
-
"save_checkpoint_steps":
50000}
# 15000
-
-
args = Namespace(**config)
接下来,我们为训练设置日志记录。由于我们是从头开始训练模型,因此训练运行需要一段时间并且需要昂贵的基础设施。因此,我们要确保所有相关信息都已存储并易于访问。该setup_logging()
方法设置了三个级别的日志记录:使用标准 Python Logger、 TensorBoard和 Weights & Biases 。根据您的偏好和用例,您可以在此处添加或删除日志框架:
-
from torch.utils.tensorboard
import SummaryWriter
-
import logging
-
import wandb
-
-
def
setup_logging(
project_name):
-
logger = logging.getLogger(__name__)
-
logging.basicConfig(
-
format=
"%(asctime)s - %(levelname)s - %(name)s - %(message)s",
-
datefmt=
"%m/%d/%Y %H:%M:%S", level=logging.INFO, handlers=[
-
logging.FileHandler(
f"log/debug_{accelerator.process_index}.log"),
-
logging.StreamHandler()])
-
if accelerator.is_main_process:
# We only want to set up logging once
-
wandb.init(project=project_name, config=args)
-
run_name = wandb.run.name
-
tb_writer = SummaryWriter()
-
tb_writer.add_hparams(
vars(args), {
'0':
0})
-
logger.setLevel(logging.INFO)
-
datasets.utils.logging.set_verbosity_debug()
-
transformers.utils.logging.set_verbosity_info()
-
else:
-
tb_writer =
None
-
run_name =
''
-
logger.setLevel(logging.ERROR)
-
datasets.utils.logging.set_verbosity_error()
-
transformers.utils.logging.set_verbosity_error()
-
return logger, tb_writer, run_name
每个工作人员都有一个唯一的accelerator.process_index
,我们使用它FileHandler
来将每个工作人员的日志写入一个单独的文件。我们还使用了accelerator.is_main_process
仅true
用于主要工作人员的属性。我们确保不会多次初始化 TensorBoard 和 Weights & Biases 记录器,并降低其他工作人员的记录级别。我们返回自动生成的 unique wandb.run.name
,稍后我们用它来命名 Hub 上的实验分支。
我们还将定义一个函数来使用 TensorBoard 和 Weights & Biases 记录指标。我们再次使用 accelerator.is_main_process
这里来确保我们只记录一次指标,而不是为每个工人记录:
-
def
log_metrics(step, metrics):
-
logger.
info(f
"Step {step}: {metrics}")
-
if accelerator.is_main_process:
-
wandb.
log(metrics)
-
[tb_writer.
add_scalar(k, v, step) for k, v in metrics.
items()]
接下来,让我们编写一个函数,使用我们全新的 ConstantLengthDataset
类为训练和验证集创建数据加载器:
-
from torch
.utils
.data
.dataloader import DataLoader
-
-
def
create_dataloaders(dataset_name):
-
train_data =
load_dataset(dataset_name+
'-train', split=
"train",
-
streaming=True)
-
train_data = train_data.
shuffle(buffer_size=args.shuffle_buffer,
-
seed=args.seed)
-
valid_data =
load_dataset(dataset_name+
'-valid', split=
"validation",
-
streaming=True)
-
-
train_dataset =
ConstantLengthDataset(tokenizer, train_data,
-
seq_length=args.seq_length)
-
valid_dataset =
ConstantLengthDataset(tokenizer, valid_data,
-
seq_length=args.seq_length)
-
-
train_dataloader=
DataLoader(train_dataset, batch_size=args.train_batch_size)
-
eval_dataloader=
DataLoader(valid_dataset, batch_size=args.valid_batch_size)
-
return train_dataloader, eval_dataloader
最后,我们将数据集包装在 a 中DataLoader
,它也处理批处理。 Accelerate 将负责将批次分配给每个工人。
我们需要实现的另一个方面是优化。我们将在主循环中设置优化器和学习率计划,但我们在这里定义一个辅助函数来区分应该接收权重衰减的参数。通常,偏差和 LayerNorm 权重不受权重衰减的影响:
-
def
get_grouped_params(
model, no_decay=["bias", "LayerNorm.weight"]):
-
params_with_wd, params_without_wd = [], []
-
for n, p
in model.named_parameters():
-
if
any(nd
in n
for nd
in no_decay):
-
params_without_wd.append(p)
-
else:
-
params_with_wd.append(p)
-
return [{
'params': params_with_wd,
'weight_decay': args.weight_decay},
-
{
'params': params_without_wd,
'weight_decay':
0.0}]
最后,我们想不时在验证集上评估模型,所以让我们添加一个我们可以调用的评估函数来计算评估集上的损失和困惑:
-
def
evaluate():
-
model.
eval()
-
losses = []
-
for step, batch in
enumerate(eval_dataloader):
-
with torch.
no_grad():
-
outputs =
model(batch, labels=batch)
-
loss = outputs.loss.
repeat(args.valid_batch_size)
-
losses.
append(accelerator.
gather(loss))
-
if args.max_eval_steps >
0 and step >= args.max_eval_steps: break
-
loss = torch.
mean(torch.
cat(losses))
-
try:
-
perplexity = torch.
exp(loss)
-
except OverflowError:
-
perplexity = torch.
tensor(
float(
"inf"))
-
return loss.
item(), perplexity.
item()
困惑度衡量模型的输出概率分布对目标标记的预测程度。因此,较低的困惑度对应于更好的性能。请注意,我们可以通过对从模型输出中获得的交叉熵损失求幂来计算困惑度。尤其是在开始训练的时候loss仍然很高,在计算perplexity的时候有可能出现数值溢出。在这些情况下,我们捕获了这个错误并将困惑度设置为无穷大。
在我们将它们全部放在训练脚本中之前,我们将使用另外一个函数。如您所知,Hugging Face Hub 在后台使用 Git 来存储和版本化模型和数据集。使用Repository
来自huggingface_hub库的类,您可以以编程方式访问存储库并拉取、分支、提交或推送。我们将在我们的脚本中使用它来在训练期间不断地将模型检查点推送到 Hub。
现在我们已经准备好所有这些辅助函数,我们准备编写训练脚本的核心:
-
set_seed(args.seed)
-
-
# Accelerator
-
accelerator = Accelerator()
-
samples_per_step = accelerator.state.num_processes * args.train_batch_size
-
-
# Logging
-
logger, tb_writer, run_name = setup_logging(project_name.split(
"/")[
1])
-
logger.info(accelerator.state)
-
-
# Load model and tokenizer
-
if accelerator.is_main_process:
-
hf_repo = Repository(
"./", clone_from=project_name, revision=run_name)
-
model = AutoModelForCausalLM.from_pretrained(
"./", gradient_checkpointing=
True)
-
tokenizer = AutoTokenizer.from_pretrained(
"./")
-
-
# Load dataset and dataloader
-
train_dataloader, eval_dataloader = create_dataloaders(dataset_name)
-
-
# Prepare the optimizer and learning rate scheduler
-
optimizer = AdamW(get_grouped_params(model), lr=args.learning_rate)
-
lr_scheduler = get_scheduler(name=args.lr_scheduler_type, optimizer=optimizer,
-
num_warmup_steps=args.num_warmup_steps,
-
num_training_steps=args.max_train_steps,)
-
def
get_lr():
-
return optimizer.param_groups[
0][
'lr']
-
-
# Prepare everything with our `accelerator` (order of args is not important)
-
model, optimizer, train_dataloader, eval_dataloader = accelerator.prepare(
-
model, optimizer, train_dataloader, eval_dataloader)
-
-
# Train model
-
model.train()
-
completed_steps =
0
-
for step, batch
in
enumerate(train_dataloader, start=
1):
-
loss = model(batch, labels=batch).loss
-
log_metrics(step, {
'lr': get_lr(),
'samples': step*samples_per_step,
-
'steps': completed_steps,
'loss/train': loss.item()})
-
loss = loss / args.gradient_accumulation_steps
-
accelerator.backward(loss)
-
if step % args.gradient_accumulation_steps ==
0:
-
optimizer.step()
-
lr_scheduler.step()
-
optimizer.zero_grad()
-
completed_steps +=
1
-
if step % args.save_checkpoint_steps ==
0:
-
logger.info(
'Evaluating and saving model checkpoint')
-
eval_loss, perplexity = evaluate()
-
log_metrics(step, {
'loss/eval': eval_loss,
'perplexity': perplexity})
-
accelerator.wait_for_everyone()
-
unwrapped_model = accelerator.unwrap_model(model)
-
if accelerator.is_main_process:
-
unwrapped_model.save_pretrained(
"./")
-
hf_repo.push_to_hub(commit_message=
f'step {step}')
-
model.train()
-
if completed_steps >= args.max_train_steps:
-
break
-
-
# Evaluate and save the last checkpoint
-
logger.info(
'Evaluating and saving model after training')
-
eval_loss, perplexity = evaluate()
-
log_metrics(step, {
'loss/eval': eval_loss,
'perplexity': perplexity})
-
accelerator.wait_for_everyone()
-
unwrapped_model = accelerator.unwrap_model(model)
-
if accelerator.is_main_process:
-
unwrapped_model.save_pretrained(
"./")
-
hf_repo.push_to_hub(commit_message=
f'final model')
这是一个相当大的代码块,但请记住,这是在分布式基础架构上训练一个花哨的大型语言模型所需的所有代码。让我们稍微解构一下脚本并突出显示最重要的部分:
模型保存
run_name
我们从模型存储库中运行脚本,并在开始时检查一个以我们从 Weights & Biases 获得的 命名的新分支 。稍后,我们在每个检查点提交模型并将其推送到 Hub。通过该设置,每个实验都在一个新分支上,每个提交都代表一个模型检查点。请注意,我们需要调用wait_for_everyone()
并unwrap_model()
确保模型在存储时正确同步。
优化
对于模型优化,我们AdamW
在线性预热期后使用余弦学习率计划。对于超参数,我们严格遵循 GPT-3 论文中描述的类似大小模型的参数。8
评估
每次保存时,我们都会在评估集上评估模型,即每次save_checkpoint_steps
训练后。除了验证损失,我们还记录了验证困惑。
即使我们在最新的 GPU 上运行,所需的批量大小也不适合 GPU 的内存。因此,我们实现了梯度累积,它在几个反向传递中收集梯度,并在累积足够的梯度后进行优化。在第 6 章中,我们看到了如何使用Trainer
. 对于大型模型,即使是单个批次也不完全适合单个 GPU。使用一种称为 梯度检查点的方法,我们可以用一些内存占用来换取大约 20% 的训练减速。9这使我们甚至可以在单个 GPU 中安装大型模型。
一个可能仍然有点模糊的方面是在多个 GPU 上训练模型意味着什么。根据模型的大小和数据量,有几种方法可以以分布式方式训练模型。Accelerate使用的方法 称为 DataDistributedParallelism (DDP)。这种方法的主要优点是它允许您使用不适合任何单个 GPU 的更大批量大小更快地训练模型。该过程如图 10-6 所示。
图 10-6。具有四个 GPU 的 DDP 中的处理步骤图示
让我们逐步浏览管道:
-
每个工作人员由一个 GPU 组成。在 Accelerate 中,有一个数据加载器在主进程上运行,它准备成批的数据并将它们发送给所有工作人员。
-
每个 GPU 接收一批数据,并使用模型的本地副本计算前向和后向传递的损失和相应的累积梯度。
-
来自每个节点的梯度使用 reduce模式进行平均,然后将平均梯度发送回每个工作人员。
-
使用优化器分别在每个节点上应用梯度。尽管这看起来像是多余的工作,但它避免了在节点之间传输大型模型的副本。我们需要至少更新一次模型,如果没有这种方法,其他节点都需要等到他们收到更新的版本。
-
更新完所有模型后,我们重新开始,主要工人准备新批次。
这种简单的模式允许我们通过扩展可用 GPU 的数量来极快地训练大型模型,而无需太多额外的逻辑。然而,有时这还不够。例如,如果模型不适合单个 GPU,您可能需要更复杂的 并行策略。现在我们已经拥有了培训所需的所有部件,是时候开始工作了!正如您将在下一节中看到的,这非常简单。
训练Run
我们将训练脚本保存在名为 codeparrot_training.py的文件中,以便我们可以在训练服务器上执行它。为了让生活更轻松,我们将它与 包含所有必需 Python 依赖项的requirements.txt文件一起添加到Hub上的模型存储库中。请记住,集线器上的模型本质上是 Git 存储库,因此我们可以克隆存储库,添加我们想要的任何文件,然后将它们推送回集线器。在训练服务器上,我们可以使用以下几个命令启动训练:
-
$ git
clone https://huggingface.co/transformersbook/codeparrot
-
$
cd codeparrot
-
$ pip install -r requirements.txt
-
$ wandb login
-
$ accelerate config
-
$ accelerate launch codeparrot_training.py
就是这样——我们的模型现在正在训练!请注意,这 wandb login
将提示您使用权重和偏差进行身份验证以进行日志记录。该accelerate config
命令将指导您设置基础设施;您可以在表 10-2中看到用于此实验的设置。我们在所有实验中使用一个a2-megagpu-16g 实例,这是一个具有 16 个 A100 GPU 的工作站,每个 GPU 具有 40 GB 内存。
环境 | Value |
---|---|
计算环境? |
多 GPU |
几台机器? |
1 |
DeepSpeed? | No |
有多少进程? |
16 |
使用FP16? |
Yes |
对于小型和大型模型,使用这些设置在该基础架构上运行训练脚本分别需要大约 24 小时和 7 天。如果您训练自己的自定义模型,请确保您的代码在较小的基础架构上顺利运行,以确保昂贵的长期运行也顺利进行。完整的训练运行成功完成后,您可以使用以下命令将 Hub 上的实验分支合并回主分支:
-
$ git checkout main
-
$ git merge <RUN_NAME>
-
$ git push
自然,RUN_NAME
应该是您要合并的 Hub 上的实验分支的名称。现在我们有了一个训练有素的模型,让我们看看如何研究它的性能。
结果与分析
在焦急地监视日志一周后,您可能会看到类似于 图 10-7所示的损失和困惑曲线。训练损失和验证困惑度不断下降,损失曲线在对数尺度上看起来几乎是线性的。我们还看到,大型模型在处理的标记方面收敛得更快,尽管整体训练需要更长的时间。
图 10-7。训练损失和验证困惑作为小型和大型 CodeParrot 模型的已处理标记的函数
那么,直接从 GPU 烤箱中拿出来的新鲜出炉的语言模型,我们可以做什么呢?好吧,我们可以用它来为我们编写一些代码。我们可以进行两种类型的分析:定性分析和定量分析。在前者中,我们查看具体示例,并尝试更好地了解模型在哪些情况下成功以及在哪些情况下失败。在后一种情况下,我们在大量测试用例上统计评估模型的性能。在本节中,我们将探讨如何使用我们的模型。首先,我们将看一些示例,然后我们将简要讨论如何系统地、更稳健地评估模型。首先,让我们将小模型包装在管道中并使用它来继续一些代码输入:
-
from transformers
import pipeline, set_seed
-
-
model_ckpt =
'transformersbook/codeparrot-small'
-
generation =
pipeline(
'text-generation', model=model_ckpt, device=
0)
现在我们可以使用生成管道从给定的提示生成候选完成。默认情况下,管道将生成代码直到预定义的最大长度,并且输出可以包含多个函数或类。因此,为了保持输出简洁,我们将实现一个first_block()
函数,该函数使用正则表达式来提取函数或类的第一次出现。下面的complete_code()
函数应用此逻辑打印出 CodeParrot生成的完成:
-
import re
-
from transformers
import set_seed
-
-
def
first_block(
string):
-
return re.split(
'\nclass|\ndef|\n#|\n@|\nprint|\nif', string)[
0].rstrip()
-
-
def
complete_code(
pipe, prompt, max_length=64, num_completions=4, seed=1):
-
set_seed(seed)
-
gen_kwargs = {
"temperature":
0.4,
"top_p":
0.95,
"top_k":
0,
"num_beams":
1,
-
"do_sample":
True,}
-
code_gens = generation(prompt, num_return_sequences=num_completions,
-
max_length=max_length, **gen_kwargs)
-
code_strings = []
-
for code_gen
in code_gens:
-
generated_code = first_block(code_gen[
'generated_text'][
len(prompt):])
-
code_strings.append(generated_code)
-
print((
'\n'+
'='*
80 +
'\n').join(code_strings))
让我们从一个简单的例子开始,让模型为我们编写一个计算矩形面积的函数:
-
prompt =
'''def area_of_rectangle(a: float, b: float):
-
"""Return the area of the rectangle."""'''
-
complete_code(generation, prompt)
return math.sqrt(a * b) ================================================================================ return a * b / 2.0 ================================================================================ return a * b ================================================================================ return a * b / a
这看起来很不错!尽管并非所有世代都是正确的,但正确的解决方案就在那里。现在,该模型还可以解决从 HTML 字符串中提取 URL 的更复杂任务吗?让我们来看看:
-
prompt =
'''def get_urls_from_html(html):
-
"""Get all embedded URLs in a HTML string."""'''
-
complete_code(generation, prompt)
if not html: return [] return [url for url in re.findall(r'<a href="(/[^/]+/[^"]+?)">', html)] ================================================================================ return [url for url in re.findall(r'<a href="(.*?)"', html) if url] ================================================================================ return [url for url in re.findall(r'<a href="(/.*)",', html)] ================================================================================ return re.findall(r'<a href="(.*?)" class="url"[^>]*>', html)
虽然在第二次尝试时没有完全正确,但其他三代都是正确的。我们可以在 Hugging Face 主页上测试该功能:
-
import requests
-
-
def
get_urls_from_html(
html):
-
return [url
for url
in re.findall(
r'<a href="(.*?)"', html)
if url]
-
-
print(
" | ".join(get_urls_from_html(requests.get(
'https://hf.co/').text)))
https://github.com/huggingface/transformers | /allenai | /facebook | /asteroid-team | /google | /amazon | /speechbrain | /microsoft | /grammarly | /models | /inference-api | /distilbert-base-uncased | /dbmdz/bert-large-cased-finetuned-conll03-english | https://huggingface.co/transformers | https://arxiv.org/abs/1811.06031 | https://arxiv.org/abs/1803.10631 | https://transformer.huggingface.co/ | /coref | https://medium.com/huggingface/distilbert-8cf3380435b5
我们可以看到所有以开头的URLhttps
都是外部页面,而其他的都是主网站的子页面。这正是我们想要的。最后,让我们加载大模型,看看是否可以使用它来将函数从纯 Python 转换为 NumPy:
-
model_ckpt =
'transformersbook/codeparrot'
-
generation = pipeline(
'text-generation', model=model_ckpt, device=
0)
-
-
prompt =
'''# a function in native python:
-
def mean(a):
-
return sum(a)/len(a)
-
-
# the same function using numpy:
-
import numpy as np
-
def mean(a):'''
-
complete_code(generation, prompt, max_length=
64)
Setting `pad_token_id` to `eos_token_id`:0 for open-end generation. return np.mean(a) ================================================================================ return np.mean(a) ================================================================================ return np.mean(a) ================================================================================ return np.mean(a)
那行得通!让我们看看我们是否也可以使用 CodeParrot 模型来帮助我们构建 Scikit-learn 模型:
-
prompt =
'''X = np.random.randn(100, 100)
-
y = np.random.randint(0, 1, 100)
-
-
# fit random forest classifier with 20 estimators'''
-
complete_code(generation, prompt, max_length=
96)
Setting `pad_token_id` to `eos_token_id`:0 for open-end generation. reg = DummyRegressor() forest = RandomForestClassifier(n_estimators=20) forest.fit(X, y) ================================================================================ clf = ExtraTreesClassifier(n_estimators=100, max_features='sqrt') clf.fit(X, y) ================================================================================ clf = RandomForestClassifier(n_estimators=20, n_jobs=n_jobs, random_state=1) clf.fit(X, y) ================================================================================ clf = RandomForestClassifier(n_estimators=20) clf.fit(X, y)
尽管在第二次尝试中它尝试训练一个 额外的树分类器,但它生成了我们在其他情况下所要求的内容。
在第 5 章中,我们探讨了一些衡量生成文本质量的指标。其中包括经常用于该目的的 BLEU 分数。虽然这个指标通常有局限性,但它特别不适合我们的用例。BLEU 分数测量参考文本和生成文本之间的n- gram 重叠。在编写代码时,我们在变量和类方面有很大的自由度,一个程序的成功并不取决于命名方案,只要它是一致的。然而,BLEU 分数会惩罚偏离参考命名的一代,这实际上可能几乎不可能预测(即使对于人类编码器)。
在软件开发中,有很多更好、更可靠的方法来衡量代码的质量,例如单元测试。这就是所有 OpenAI Codex 模型的评估方式:通过一组单元测试为编码任务运行多个代码生成,并计算通过测试的生成分数。10为了获得适当的性能度量,我们应该将相同的评估方案应用于我们的模型,但这超出了本章的范围。您可以在模型随附的博客文章中找到有关 CodeParrot 如何在 HumanEval 基准测试中执行的详细信息。
结论
让我们退后一步,思考一下我们在本章中取得的成就。我们着手为 Python 创建一个代码自动完成功能。首先,我们构建了一个自定义的、适合预训练大型语言模型的大规模数据集。然后我们创建了一个自定义标记器,它能够使用该数据集有效地编码 Python 代码。最后,在 Accelerate 的帮助下,我们将所有内容放在一起并编写了一个训练脚本,以便在不到 200 行代码的多 GPU 基础设施上从头开始训练 GPT-2 模型的大小版本。调查模型输出,我们看到它可以生成合理的代码延续,我们讨论了如何系统地评估模型。
您现在不仅知道如何微调 Hub 上的许多预训练模型,还知道如何在有足够的可用数据和计算资源时从头开始预训练自定义模型。您现在已准备好使用转换器处理几乎所有 NLP 用例。所以问题是:下一步去哪里?在下一章和最后一章中,我们将了解该领域目前的发展方向,以及 NLP 转换器模型之外可以解决哪些令人兴奋的新应用和领域。
转载:https://blog.csdn.net/sikh_0529/article/details/127786494