赞
踩
目录
AGI 之 【Hugging Face】 的【从零训练Transformer模型】之一 [ 如何寻找大型数据集 ] / [ 构建词元分析器 ] 的简单整理
AGI,即通用人工智能(Artificial General Intelligence),是一种具备人类智能水平的人工智能系统。它不仅能够执行特定的任务,而且能够理解、学习和应用知识于广泛的问题解决中,具有较高的自主性和适应性。AGI的能力包括但不限于自我学习、自我改进、自我调整,并能在没有人为干预的情况下解决各种复杂问题。
- AGI能做的事情非常广泛:
跨领域任务执行:AGI能够处理多领域的任务,不受限于特定应用场景。
自主学习与适应:AGI能够从经验中学习,并适应新环境和新情境。
创造性思考:AGI能够进行创新思维,提出新的解决方案。
社会交互:AGI能够与人类进行复杂的社会交互,理解情感和社会信号。
- 关于AGI的未来发展前景,它被认为是人工智能研究的最终目标之一,具有巨大的变革潜力:
技术创新:随着机器学习、神经网络等技术的进步,AGI的实现可能会越来越接近。
跨学科整合:实现AGI需要整合计算机科学、神经科学、心理学等多个学科的知识。
伦理和社会考量:AGI的发展需要考虑隐私、安全和就业等伦理和社会问题。
增强学习和自适应能力:未来的AGI系统可能利用先进的算法,从环境中学习并优化行为。
多模态交互:AGI将具备多种感知和交互方式,与人类和其他系统交互。
Hugging Face作为当前全球最受欢迎的开源机器学习社区和平台之一,在AGI时代扮演着重要角色。它提供了丰富的预训练模型和数据集资源,推动了机器学习领域的发展。Hugging Face的特点在于易用性和开放性,通过其Transformers库,为用户提供了方便的模型处理文本的方式。随着AI技术的发展,Hugging Face社区将继续发挥重要作用,推动AI技术的发展和应用,尤其是在多模态AI技术发展方面,Hugging Face社区将扩展其模型和数据集的多样性,包括图像、音频和视频等多模态数据。
- 在AGI时代,Hugging Face可能会通过以下方式发挥作用:
模型共享:作为模型共享的平台,Hugging Face将继续促进先进的AGI模型的共享和协作。
开源生态:Hugging Face的开源生态将有助于加速AGI技术的发展和创新。
工具和服务:提供丰富的工具和服务,支持开发者和研究者在AGI领域的研究和应用。
伦理和社会责任:Hugging Face注重AI伦理,将推动负责任的AGI模型开发和应用,确保技术进步同时符合伦理标准。
AGI作为未来人工智能的高级形态,具有广泛的应用前景,而Hugging Face作为开源社区,将在推动AGI的发展和应用中扮演关键角色。
(注意:以下代码运行,可能需要科学上网)
Transformer 模型是由 Vaswani 等人于 2017 年提出的,是一种用于序列到序列任务(如机器翻译)的神经网络架构。其核心是自注意力机制,能够高效地捕捉序列中各个位置之间的依赖关系。与传统的 RNN 和 LSTM 模型相比,Transformer 具有更好的并行计算能力和处理长距离依赖关系的能力。
Hugging Face 提供了多种工具和库,帮助开发者简化 NLP 模型的开发和使用:
pip install transformers
安装,并通过 import transformers
导入。- from transformers import BertModel, BertTokenizer
-
- model_name = "bert-base-uncased"
- model = BertModel.from_pretrained(model_name)
- tokenizer = BertTokenizer.from_pretrained(model_name)
- model.save_pretrained("path/to/save")
- tokenizer.save_pretrained("path/to/save")
pip install datasets
安装,并通过 import datasets
导入。- from datasets import load_dataset
-
- dataset = load_dataset("glue", "mrpc")
创建和处理自定义数据集:
- data = {"text": ["Hello, world!", "Hugging Face is great!"], "label": [0, 1]}
- dataset = datasets.Dataset.from_dict(data)
pip install tokenizers
安装,并通过 import tokenizers
导入。- from transformers import BertTokenizer
-
- tokenizer = BertTokenizer.from_pretrained("bert-base-uncased")
- tokens = tokenizer("Hello, world!", return_tensors="pt")
datasets
库加载。dataset = load_dataset("imdb")
tokens = tokenizer(["Hello, world!", "Hugging Face is great!"], padding=True, truncation=True)
DataLoader
和 Dataset
。- from torch.utils.data import DataLoader
-
- dataloader = DataLoader(dataset["train"], batch_size=8, shuffle=True)
- from transformers import TrainingArguments
-
- training_args = TrainingArguments(
- output_dir="./results",
- learning_rate=2e-5,
- per_device_train_batch_size=8,
- per_device_eval_batch_size=8,
- num_train_epochs=3,
- weight_decay=0.01,
- )
Trainer
API:- from transformers import Trainer, TrainingArguments
-
- trainer = Trainer(
- model=model,
- args=training_args,
- train_dataset=dataset["train"],
- eval_dataset=dataset["validation"],
- )
-
- trainer.train()
- results = trainer.evaluate()
- print(results)
- from sklearn.model_selection import GridSearchCV
- # 示例代码略
- model.save_pretrained("path/to/model")
- tokenizer.save_pretrained("path/to/tokenizer")
pipeline
API:- from transformers import pipeline
-
- classifier = pipeline("sentiment-analysis", model="path/to/model")
- result = classifier("I love Hugging Face!")
- print(result)
- inputs = tokenizer("Hugging Face is great!", return_tensors="pt")
- outputs = model(**inputs)
- print(outputs)
- from transformers import BertForSequenceClassification
-
- model = BertForSequenceClassification.from_pretrained("bert-base-uncased")
- # 加载数据、预处理、训练、评估
- from transformers import BertForTokenClassification
-
- model = BertForTokenClassification.from_pretrained("bert-base-cased", num_labels=9)
- # 加载数据、预处理、训练、评估
官方文档:
论坛和 GitHub:
一个叫作GitHub Copilot的复杂应用程序,它使用类似GPT的Transformer模型来实现代码自动补全,这样的特性在学习和使用一门新的编程语言或框架完成编程任务,或自动生成模板代码时特别有用。像TabNine(https://tabnine.com)和Kite(https://kite.com)也基于AI模型来做类似的事情。
到目前为止,我们都是在有数据约束的情况下进行研究,能使用的标注数据是很有限的。在这些情况下,得益于迁移学习的帮助,我们才构建出了高性能的模型。在第9章中,我们将迁移学习使用到了极致,几乎没有使用任何训练数据就完成了任务。
接下来,我们将带领大家走向另一个极端,也就是当需要的数据应有尽有时,可以完成哪些事情。我们将探索预训练的步骤,并学习如何从头开始训练一个Transformer模型。在解决这个问题的过程中,我们还将研究一些之前没有考虑过的与训练相关的问题,比如下面这些:
- 收集并处理一个庞大的数据集。
- 为数据集创建一个自定义词元分析器。
- 在多GPU上完成大规模训练任务。
为了更有效地训练具有数十亿参数的大型模型,我们需要使用专有工具来进行分布式训练。虽然Hugging Face Transformer的Trainer也支持分布式训练,但我们想借此机会让你了解一个叫作Hugging Face Accelerate的PyTorch库。最终我们会接触到一些目前业界使用的较大型的NLP模型,但在此之前,我们需要找到一个足够大的数据集。下面我们从如何找到这样的数据集开始介绍。
在很多领域,你手头可能真的有大量的数据,从法律文件到生物医学数据集,再到编程代码库。大多数情况下,这些数据集是无标注的,如此庞大的规模也意味着只能使用启发式方法,或者使用在收集过程中附带的元数据来标注它们。
其实,一个非常庞大的语料库即使没有标注或只有启发式标注,也是有用的。在之前的介绍过这样一个例子,我们为了领域自适应而使用数据集中的未标注部分微调了一个语言模型。在数据有限的情况下,这种方法往往可以带来性能上的增益。如何判定从头开始训练一个模型或者对现有模型进行微调,是由用于微调的语料库大小,以及可用的预训练模型和语料库之间的领域差异来决定的。
如果你使用预训练模型,那就必须使用与之对应的词元分析器,但使用这样的在另一个领域的语料库上训练过的词元分析器往往是次优的。比如,在法律文件、其他语言、甚至完全不同的序列(如音符或DNA序列)上使用GPT的预训练词元分析器会导致词元混乱(后面我们很快会介绍这种情况)。
随着你能获得的训练数据量越来越接近用于预训练的数据量,在资源和预算允许的情况下,考虑从头开始训练模型和词元分析器是很有必要的。在我们进一步讨论不同的预训练目标之前,我们首先需要构建一个适合预训练的大型语料库。构建这样的语料库也有其一系列挑战。
预训练模型的质量很大程度上可以反映出预训练语料库的质量,预训练语料库中的任何缺陷都会被继承到预训练模型中。所以,在构建预训练语料库之前,最好先了解一些与构建大型语料库相关的常见问题与挑战。
随着数据集的规模不断变大,我们对其中的内容就会渐渐失去掌控力。一个非常庞大的数据集很可能不是由某个人一次性构建出来的,因为需要考虑到整条生产pipeline和模型被应用的场景,大型数据集更有可能是通过收集其他系统的数据,以自动或者半自动的方式来构建的。比如,来自一个公司存储的所有文件(合同、采购订单等)、用户操作日志或者来自互联网的数据。
Y. Zhu et al., “Aligning Books and Movies: Towards Story-Like Visual Explanations by Watching Movies and Reading Books”(https://arxiv.org/abs/1506.06724),(2015);J. Dodge et al., “Documenting the English Colossal Clean Crawled Corpus”(https://arxiv.org/abs/2104.08758),(2021).
大规模数据集几乎都是在高度自动化的流程中被构建出来的,因此我们对它的内容和构建方式能做的干预都很有限。更进一步,使用这种存在误差的低质量数据来训练模型就会增加风险。最近对两个著名的大规模数据集(BookCorpus和C4,分别用来训练BERT和T5)的调研发现它们有以下这些特点:
- C4语料库的大部分语料是机翻的,不是人工翻译的。
- C4中对非裔英语中的停用词(stopword)进行了不同程度的删除,导致此类语料的表现力不足。
- 在大型文本语料库中,很难找到关于性与性别内容保留或删除的折中点。例如出现“性”这样的词,它有很多意思,既有中性的,也有违规的。这个词对于在C4上训练的词元分析器来说完全是未知的,因为这个词在语料库中完全没有。
J. Bandy and N. Vincent,“Addressing Documentation Debt in Machine Learning Research: A Retrospective Datasheet for BookCorpus”(https://arxiv.org/abs/2105.05241),(2021).
- BookCorpus中有很多侵犯版权的情况,这些情况在其他大规模数据集中可能也存在。
- BookCorpus数据集语料的体裁风格倾向于言情小说。
也许这些特点与训练出来的模型的应用场景并不冲突。例如,由于BookCorpus中的言情小说的比例较高,经它训练出的模型如果被用于言情小说自动写作工具或开发游戏,都是可以接受的。
下面我们通过比较GPT和GPT-2的文本生成,来介绍模型被数据所偏斜的概念。GPT是基于BookCorpus训练的,而GPT-2是基于网页、博客和Reddit上的新闻训练出来的。我们将基于同一个提示来比较大小相似的两个模型版本,因此最主要的区别还是预训练数据集,我们将使用text-generation pipeline来研究模型的输出:
- # 导入Hugging Face库中的pipeline函数和set_seed函数
- from transformers import pipeline, set_seed
-
- # 使用pipeline函数创建一个文本生成的pipeline,指定模型为"openai-gpt"
- generation_gpt = pipeline("text-generation", model="openai-gpt")
-
- # 使用pipeline函数创建另一个文本生成的pipeline,指定模型为"gpt2"
- generation_gpt2 = pipeline("text-generation", model="gpt2")
运行结果:
下一步我们创建一个简单的函数来分别计算两个模型中的参数数量:
- # 定义一个函数,用于计算模型参数的数量
- def model_size(model):
- # 使用生成器表达式计算模型参数的总和
- # t.numel() 返回张量t中元素的总数
- # sum() 函数将所有张量的元素总数相加
- return sum(t.numel() for t in model.parameters())
-
- # 打印GPT模型的参数数量,单位为百万(M)
- # generation_gpt.model 是pipeline返回的模型
- # model_size(generation_gpt.model) 调用model_size函数计算GPT模型的参数数量
- # 1000**2 将参数数量转换为百万
- # .1f 格式化输出,保留一位小数
- print(f"GPT size: {model_size(generation_gpt.model)/1000**2:.1f}M parameters")
-
- # 打印GPT2模型的参数数量,单位为百万(M)
- # generation_gpt2.model 是pipeline返回的模型
- # model_size(generation_gpt2.model) 调用model_size函数计算GPT2模型的参数数量
- 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模型的大小相近。我们给它们输入相同的提示,尝试使用它们分别生成三段不同的补充句:
- # 定义一个函数,用于枚举pipeline的输出结果
- def enum_pipeline_ouputs(pipe, prompt, num_return_sequences):
- # 使用pipeline生成文本,传入prompt和num_return_sequences参数
- # clean_up_tokenization_spaces=True 表示清理token化后的空格
- out = pipe(prompt, num_return_sequences=num_return_sequences,
- clean_up_tokenization_spaces=True)
-
- # 将输出结果转换为有序列表的形式
- # enumerate(out) 遍历输出结果,enumerate() 函数返回每个元素的索引和值
- # f"{i+1}." + s["generated_text"] 格式化字符串,添加序号和生成的文本
- return "\n".join(f"{i+1}." + s["generated_text"] for i, s in enumerate(out))
-
- # 定义prompt,即文本生成的起始文本
- prompt = "\nWhen they came back"
-
- # 打印GPT模型生成的文本
- # generation_gpt 是之前创建的pipeline对象
- # enum_pipeline_ouputs(generation_gpt, prompt, 3) 调用函数生成文本并格式化输出
- print("GPT completions:\n" + enum_pipeline_ouputs(generation_gpt, prompt, 3))
-
- # 打印一个空行,用于分隔不同模型的输出
- print("")
-
- # 打印GPT-2模型生成的文本
- # generation_gpt2 是之前创建的pipeline对象
- # enum_pipeline_ouputs(generation_gpt2, prompt, 3) 调用函数生成文本并格式化输出
- print("GPT-2 completions:\n" + enum_pipeline_ouputs(generation_gpt2, prompt, 3))
运行结果:
- GPT completions:
- 1.
- When they came back from the doctor's office, she had to drag him from the back of the car. he was so upset he barely noticed the two of them leave.
- they didn't speak to each other the rest of the day, but
- 2.
- When they came back that afternoon and found a pile of broken glass in the middle of the front lawn, but it wouldn't budge.
- " someone broke into us yesterday, " i said, looking down at the splintered pile in surprise. " i
- 3.
- When they came back.
- she sat through lunch with her mother, whose eyes were red and bloodshot, and her mother was very tired. her mother knew most of the local townspeople and knew the routine of the county. they all knew one another,
- GPT-2 completions:
- 1.
- When they came back, they were wearing little things. Someone had wrapped the tape around their ankles, and they had to have said something that was strange.
- "They said, 'Oh, well, this is what our lives were like
- 2.
- When they came back from the airport they heard gunshots, and after they left a man who was trying to talk said they heard screaming in the back of the car.
-
- They went back to the scene and that's when they found that he
- 3.
- When they came back to the house, they found that their uncle's body had been found floating in the water and that the father had been buried there. They began to question whether his body was the son of God, but were unable to tell
观察两个模型输出的内容,可以看出GPT模型明显向言情方向偏斜,内容非常像是两个恋爱中的男女之间的对话。而GPT-2一部分是在Reddit中链接的文章与网络文本上进行训练的,其中的“他们”一般是中性词,而内容则包含“博客式”与冒险相关的元素。
B. Hutchinson et al., “Towards Accountability for Machine Learning Datasets:Practices from Software Engineering and Infrastructure”(https://arxiv.org/abs/2010.13561),(2020).
一般来说,任何经过文本数据集训练的模型都会继承其训练数据中的语言偏见,以及对人群和事件的过度表达或表达不足。对于与模型互动的目标受众来说,模型中的这些偏见是需要被考虑的。Google有一篇论文对此事进行了详尽描述,该论文还包含了一个用于数据集开发的框架。
以上我们介绍了在构建大型文本数据集时会面临的一个典型挑战。有了这个意识,下面我们来构建自己的数据集。
通过比较,GitHub Copilot支持十几种编程语言。
为了简化操作,本节我们将致力于为Python语言构建一个代码生成模型 。为了达到这个目的,首先我们需要一个由Python源代码组成的大型预训练语料库。如何找到这样的语料库呢?幸运的是,GitHub为我们提供了这样的天然资源,几乎每个软件工程师都能想到。这个著名的代码共享网站拥有海量的代码仓库,并且这些代码仓库很多是公开的,根据不同的license可以下载作不同的用途。在本书写作时,GitHub已经托管了超过2000万个代码仓库,许多代码仓库是用户为了学习、研究业余项目或测试而创建的小型仓库。
访问GitHub仓库主要通过两种方式:
由于GitHub给REST API限制了下载速度,而我们的预训练语料库又需要庞大的数据量,所以我们使用Google BigQuery来获取Python代码仓库。Google BigQuery的bigquery-public-data.github_repos.contents表包含了所有小于10MB的仓库副本,其中的项目满足GitHub License API,且必须开源。
Google BigQuery的数据集中不包含项目的star信息或fork信息,出于这个原因,我们可以使用GitHub的REST API或Libraries.io(https://libraries.io)来获取更多的仓库信息。最近GitHub官方发布了一个叫作CodeSearchNet(https://oreil.ly/daE43)的数据集,它利用Libraries.io过滤了一些被fork的代码仓库。
下面我们看看如何使用Google BigQuery来构建Python代码数据集。
用Google BigQuery构建数据集
M.-A. Lachaux et al., “Unsupervised Translation of Programming Languages”(https://arxiv.org/abs/2006.03511),(2020).
首先,我们从Google BigQuery快照中提取GitHub公共仓库中所有的Python文件,为了后面能够重现这些步骤,也为了防止未来Google BigQuery的免费政策发生改变,此数据集还将在Hugging Face Hub上传并分享。导出这些文件的步骤是从TransCoder的实现(https://oreil.ly/vih2m)修改而来的,如下所示 :
这条SQL底层处理了大约2.6TB的数据,提取了2680万个Python代码文件,形成了一个包含了压缩JSON文件的大约50GB的数据集。在此过程中过滤掉了空文件和较小文件,比如常见的init.py文件,因为它们包含的信息用处不大。其次还过滤了大于1MB的文件,且下载了所有文件的license,以方便使用的时候能根据license来过滤训练数据。
下一步,我们将构建的数据集下载到本地机器。如果你在家尝试这样的操作,请确保有足够的带宽和不少于50GB的磁盘空间。使用下列两个步骤下载数据集:
1.将结果导出到Google Cloud:
2.借助gsutil库(https://oreil.ly/JzgRk),将bucket下载到本地机器:
是否过滤噪声
因为GitHub的使用门槛很低,人人都可以创建代码仓库,所以项目的质量存在较大的差异。如果我们想让模型训练出来后能以一个理想的方式运行,就需要对仓库做一些主观上的选择。在训练数据集中加入一些噪声会使我们的模型在推理时显得更有稳健性,但同时也增加了预测的不确定性。我们需要根据模型的应用场景,选择设置合适的噪声数据,并增加相应的预过滤和后过滤操作。
出于本章的演示目的,并保持数据准备部分的代码简洁性,我们将不会根据项目star和用途来做进一步的过滤,仅获取GitHub BigQuery数据集中的Python文件。然而,数据准备是一个很关键的步骤,应该尽量确保数据集的纯粹性。在本节的案例中需要考虑的是:是否需要平衡数据集中的编程语言;过滤低质量的数据(比如,根据star数量或fork标记);删除重复的代码文件;考虑版权信息;探查文档或评论等字符串中的使用的语言;删除个人标识,如密码或key信息。
处理50GB的数据集是一个不小的挑战,需要足够大的磁盘空间与内存。
加载一个非常大的数据集是具有挑战性的,特别是当数据集大于机器的内存的时候。对于一个大规模预训练数据集来说,这其实是一个很常见的情况。在我们的例子中,有50GB的压缩数据和大约200GB的未压缩数据,使用常规尺寸的笔记本电脑或台式计算机的内存是很难完成加载任务的。
值得庆幸的是,Hugging Face Datasets库针对此问题做了相关设计,它具有两个特殊的功能,得以让开发者摆脱内存和磁盘空间的限制:内存映射和流式加载。
3.1 内存映射
为了克服内存的制约,Hugging Face Datasets库使用了一种默认开启的零拷贝和零开销的内存映射机制。数据集以文件形式存储于磁盘上,但不是直接加载到内存中,Hugging Face Datasets使用一个只读指针来操作该文件,这样就既保证了读取效率,又不会使内存承担过大的压力。
下面我们将直接加载存储在本地的codeparrot资源库中的50GB压缩JSON文件。因为JSON文件是压缩的,所以先要对其进行解压,Hugging Face Datasets库可以解决这个问题。不过需要注意,这个过程需要大概180GB的磁盘空间,几乎不会使用内存。另外在下载数据集的时候配置delete extracted=True,可以帮助我们及时删除不再需要的文件:
- # 从Hugging Face的datasets库导入load_dataset函数和DownloadConfig类
- from datasets import load_dataset, DownloadConfig
-
- # 创建一个DownloadConfig对象,用于配置数据集下载时的行为
- download_config = DownloadConfig(delete_extracted=True)
-
- # 使用load_dataset函数加载名为"codeparrot"的数据集
- # split参数指定加载数据集的哪一部分,这里指定为"train",即训练集
- # download_config参数传入之前创建的DownloadConfig对象,用于控制下载行为
- # 如果数据集需要下载,delete_extracted=True会删除下载后解压的文件夹,只保留数据集文件
- dataset = load_dataset("./codeparrot", split="train", download_config=download_config)
Hugging Face Datasets库底层通过在一个优化过的缓存文件中加载所有压缩的JSON文件,然后进行读取操作。我们来看看这个数据集加载后有多大:
- # 导入 psutil 和 os 模块
- # psutil 用于获取系统和进程信息
- # os 用于与操作系统交互,比如获取文件状态
- import psutil, os
-
- # 打印数据集中 Python 文件的数量
- # dataset 变量应是之前加载的数据集对象
- print(f"Number of python files code in dataset: {len(dataset)}")
-
- # 计算数据集缓存文件的总大小
- # dataset.cache_files 返回数据集使用的缓存文件的列表
- # os.stat 用于获取文件状态,包括文件大小
- # st_size 获取文件大小,单位是字节
- # 通过列表推导式计算所有文件大小的总和
- ds_size = sum(os.stat(f["filename"]).st_size for f in dataset.cache_files)
-
- # 将数据集大小从字节转换为GB(1GB = 2^30字节)
- print(f"Dataset size (cache file): {ds_size / 2**30:.2f} GB")
-
- # 使用 psutil 获取当前进程的内存使用情况
- # os.getpid() 返回当前进程的ID
- # psutil.Process(os.getpid()) 创建一个进程对象,代表当前Python进程
- # memory_info() 方法返回内存使用情况的详细信息
- # rss (Resident Set Size) 表示进程使用的非交换内存大小,单位是字节
- # 将内存使用量从字节转换为MB(1MB = 2^20字节)
- print(f"RAM used: {psutil.Process(os.getpid()).memory_info().rss >> 20} MB")
到这里,可能许多人会提出一个问题,上面的这种操作是否会使训练过程中有I/O瓶颈。在实践当中,与其他领域相比,NLP领域加载的数据是非常轻量的,因此这很难成为一个问题。此外,底层使用了Apache Arrow实现,零拷贝的方式使得访问任何数据非常高效。只要你的磁盘速度不是特别差,基本也可以达到GB/s的速度来读取数据集。但是,有一个问题是无法避免的,那就是没有足够的磁盘空间。这个问题很多人都会遇到,因为有时候确实无法腾出那么多资源出来。不过不用担心,Hugging Face Datasets库提供了流式加载功能,这样就不需要将整个数据集存储到本地磁盘上了。
3.2 流式加载
有些更加庞大的数据集(≥1TB)使用一整个标准硬盘也很难容纳。遇到这种情况的时候,除了单纯地扩存储资源外,还可以将数据集进行流式处理。Hugging Face Datasets库能逐行读取一些压缩或未压缩的文件格式,比如JSON、CSV、文本(原始文本,或经zip、gzip、Zstd压缩过的文本)。下面我们使用它直接从压缩的JSON文件流式加载数据集,这个过程并不会产生额外的文件:
- # 导入datasets库中的load_dataset函数
- from datasets import load_dataset
-
- # 使用load_dataset函数加载名为'./codeparrot'的数据集的'train'部分
- # streaming=True参数指示函数以流式传输的方式加载数据集,这意味着数据集会逐块加载
- # 而不是一次性全部加载到内存中,这在处理大型数据集时非常有用
- streamed_dataset = load_dataset('./codeparrot', split="train", streaming=True)
实际操作的时候可以发现,加载数据集在一瞬间就可以完成。在流式加载模式下,压缩的JSON文件被飞速加载成为一个IterableDataset对象,看此对象名称可以知道,只能顺序读取,不能随机访问。所以只能使用next(iter(streamed dataset))来读取内容,而不是像读取数组内容那样使用streamed dataset[1264]。此外,这里也可以使用Python的洗牌算法shuffle(),通过建立一个缓冲区(大小可调)来对内容进行随机排序后再迭代。
与内存映射读取的内容相比,它们其实是一样的:
- # 创建一个迭代器对象,用于遍历流式加载的数据集
- # iter函数将streamed_dataset转换为一个可迭代对象
- iterator = iter(streamed_dataset)
-
- # 使用迭代器的next方法获取数据集中的第一个元素
- # dataset[0]访问数据集中的第一个元素
- # 打印比较结果,检查迭代器的输出是否与直接从数据集中索引的结果相同
- print(dataset[0] == next(iterator))
-
- # 再次使用next函数从迭代器获取下一个元素
- # dataset[1]访问数据集中的第二个元素
- # 打印比较结果,检查迭代器的输出是否与直接从数据集中索引的结果相同
- print(dataset[2] == next(iterator)) # 注意:这里应该是dataset[2],因为已经使用next(iterator)获取了第一个元素
使用流式读取数据集的最大好处是,它不会在磁盘上创建额外的(缓存)文件,也不需要大量的内存,使用这种方式可以节约大量的存储和内存资源(从180GB减少到50GB)。我们还可以做得更好,不必下载数据集,而是直接引用Hugging Face Hub上的数据集,再用流式加载数据集:
- # 加载名为'transformersbook/codeparrot'的远程数据集,指定只加载训练集部分
- # split="train" 参数用于指定加载数据集的哪一部分,这里是训练集
- # streaming=True 参数表示以流式方式加载数据集,这通常用于处理大型数据集,可以节省内存
- # 注意:流式加载时,数据集不会一次性加载到内存中,而是按需加载数据批次
- remote_dataset = load_dataset('transformersbook/codeparrot', split="train", streaming=True)
这种方式处理数据集与传统方式效果完全一样,有了这个技术,就可以在一个小型机器上使用任意大的数据集。下面我们将数据集与训练和验证过程推送到Hugging Face Hub上,再通过流式加载访问它们。
将数据集推送到Hugging Face Hub后,我们能够完成下面这些事:
●在任何机器上都能轻松访问它。
●获知流式加载数据集的时候是如何与Hugging Face Hub上的数据集无缝衔接的。
●与技术社区分享你的成果。
在上传数据集之前,需要先登录Hugging Face账户,在终端运行下面的命令,并输入你的用户名与密码:
之前的章节中,相同的操作在Jupyter notebook环境中使用notebook_login()辅助函数来登录。登录完成之后,就可以在Hugging Face Hub创建一个新的数据集,并上传压缩后的JSON文件。为了简化说明,这里创建两个仓库:一个用于训练集,另一个用于验证集。可以使用huggingface-cli的repo create命令来完成此事项,如下所示:
接下来,需要指定仓库类型为数据集类型(与存储权重的模型仓库不同),以及选定项目组织。如果是在个人账户下,则可不设定。然后,将创建的仓库克隆到本地,再把JSON文件复制到仓库目录中,再将仓库变更推送到Hugging Face Hub上即可(和GitHub使用方式如出一辙):
将184个JSON压缩的JSON文件的最后一个当成验证集(约占整个数据集的0.5%),去掉最后一个文件,将其余文件作为训练集,执行下面的命令完成:
提交并推送到Hugging Face Hub:
对验证集重复这一过程:
git后台会计算每个文件的hash值,所以在“git add.”这一步可能会需要较多时间,由于文件较大,最后在推送的时候也需要较多时间。需要注意,验证集添加了“_validation”后缀。
到这里,分割出的训练集和验证集,以及完整数据集都已经在Hugging Face Hub上准备完成了,链接如下:
议在仓库中添加README.md文件,并尽可能地介绍数据集的构建过程与其他有用的信息。一个文档完善的数据集对自己以及他人都会起到帮助作用。可以参考Hugging Face Datasets库的README.md文件(https://oreil.ly/Tv9bq),了解如何写好一个README.md。后续也可以在网页上直接修改你的README.md文件。
现在我们已经了解了如何构建和加载大型数据集,下面来看看如何有效地处理数据,并馈送到模型。本书前面的章节中介绍和使用了与模型配套的词元分析器。在当时的场景下,这是有意义的。因为那是预训练模型,我们不得不使用它的原始词元分析器,以保持与预训练模型一致的预处理设计,否则就会引起一些问题。
然而,当我们要训练一个新模型时,使用为其他数据集准备的词元分析器并不是最优方案。下面是使用这类词元分析器可能会面临的问题:
●T5词元分析器是在C4语料库(https://oreil.ly/wsYIC)上训练的,其中使用了大量的停用词过滤步骤。因此,T5词元分析器连常见的英文单词都不能识别,比如“sex”。
●CamemBERT词元分析器也是在一个非常庞大的语料库上训练的,但只包含法语文本[OSCAR语料库的法语子集(https://oreil.ly/hgO5J)]。因此,它完全不能识别英文单词,比如“being”。
在实践当中,测试这些词元分析器的特性是比较容易的:
- # 从transformers库导入AutoTokenizer类
- from transformers import AutoTokenizer
-
- # 定义一个函数tok_list,它将一个tokenizer和一个字符串作为输入
- # 这个函数将返回一个由原始token组成的列表,不包括特殊token
- def tok_list(tokenizer, string):
- # 使用tokenizer对输入字符串进行编码,add_special_tokens=False表示不添加特殊token
- input_ids = tokenizer(string, add_special_tokens=False)["input_ids"]
-
- # 使用列表推导式和tokenizer的decode方法将input_ids解码回字符串形式
- # 这里的decode函数将每个input_id转换回对应的token
- return [tokenizer.decode(tok) for tok in input_ids]
-
- # 创建一个T5模型的tokenizer实例,使用的是预训练的"t5-base"模型的tokenizer
- tokenizer_T5 = AutoTokenizer.from_pretrained("t5-base")
-
- # 创建一个CamemBERT模型的tokenizer实例,使用的是预训练的"camembert-base"模型的tokenizer
- tokenizer_camembert = AutoTokenizer.from_pretrained("camembert-base")
- # 使用之前定义的tok_list函数和T5模型的tokenizer
- # 打印字符串"sex"被T5 tokenizer编码后解码得到的token列表
- print(f'T5 tokens for "sex": {tok_list(tokenizer_T5,"sex")}')
-
- # 使用之前定义的tok_list函数和CamemBERT模型的tokenizer
- # 打印字符串"being"被CamemBERT tokenizer编码后解码得到的token列表
- print(f'CamemBERT tokens for "being": {tok_list(tokenizer_camembert,"being")}')
运行结果:
T5 tokens for "sex": ['', 's', 'ex'] CamemBERT tokens for "being": ['be', 'ing']
在很多情况下,将这种短小且很常见的词再进行拆分是比较低效的,因为这会增加模型的输入序列长度(上下文大小有限)。因此,有必要了解清楚用于训练词元分析器的数据集所属领域与预处理情况。词元分析器和模型可以对数据集中对模型下游行为有影响的偏置项进行编码。为了给数据集构建一个最佳的词元分析器,只能通过自训练来完成,下面我们来看看如何操作。
训练一个模型需要从一组给定的权重开始,并使用来自误差信号的反向传递,在设计目标上使模型的损失最小化,并为模型找到一组最佳的权重来完成训练目标所定义的任务。事实上,训练词元分析器并不需要反向传递或权重,它只是一种构建文本字符串到id列表的最佳映射的方法。在当今的词元分析器中,从字符串到id的最优转换涉及一个由原子字符串列表组成的词表,以及转换、规范化、切分,或将文本字符串映射到有该词表的索引列表的方法。然后将该索引列表作为神经网络的输入。
我们在之前介绍过,词元分析器是一个由四个步骤组成的pipeline:规范化、预词元化、词元分析器模型和后处理。其中可在数据上进行训练的是词元分析器模型。本书第2章介绍过几种针对子词的词元化算法,如BPE,WordPiece和Unigram。
BPE算法从单个词表的基本单元开始,通过创建新的词元的过程来构建词表,这些新的词元是由高频出现的基本单元拼接起来再添加进词表中。不断重复这个过程,直到达到期望的词表大小。
Unigram算法则另辟蹊径,它将语料库中的所有词汇和潜在的子词初始化为基础词表。然后,逐步删除或拆分那些用处不大的词元,最终获得符合要求的小批量词表。而WordPiece是Unigram的前身,尚未被Google开源。
这些算法对下游性能的影响因任务类型而异,因此很难说清孰优孰劣。总的来说,BPE和Unigram算法在大多数时候都表现出较合理的性能,下面我们看看在评估其性能时需要考量的一些点。
在实践当中,词元分析器的性能和优化点是很难被度量的。这里有一些可能视作度量指标的点:
此外,专注于拼写错误或噪声的稳健性,以及模型应用于外域的性能表现也是时常被提及的,因为这些实际上很大程度取决于词元化过程的性能。
以上这些指标从其他角度来度量词元分析器的性能,但它们往往忽略了词元分析器与模型的交互问题。比如,子词产生率可以通过词表中所有可能的词来最小化,但这样做的结果也会为模型产生一个巨大的词表。
所以,词元分析器性能的最好通过模型的下游性能来评估。例如,我们说早期BPE算法的性能优良,那是因为使用词元分析器和词表的模型性能略高于基于字符或单词的词元化训练出的模型性能。
下面我们来看看如何构建针对Python代码而优化的词元分析器。
这里将自定义一个词元分析器来对Python代码做词元化操作。在处理编程语言的时候,预词元化的问题值得讨论。如果我们在空白处拆分并删除它们,则将失去所有的缩进信息,但这在Python语言中是很重要的语法组成部分(比如while循环或者if-then-else语句,没有缩进将很难阅读)。另外,换行是没有任何意义的,可以在不影响语义的情况下考虑增删。类似地,在标点符号上进行拆分也不是很好的做法,比如下划线,有时候下划线是用来组成变量的,它和其他领域的下划线意义差别很大。因此,直接使用基于自然语言的预词元化分析器并不是最佳选择。
让我们看看在Hub上是否提供了对我们有用的词元分析器。这里需要一个能保留空格的词元分析器,所以我们的目标是找到一个处理字节级别的词元分析器,比如GPT-2的那个。下面加载此词元分析器来尝试进行词元化操作:
- # 从transformers库导入AutoTokenizer类
- from transformers import AutoTokenizer
-
- # 定义一个多行字符串,其中包含Python代码
- # r前缀表示原始字符串,忽略字符串中的转义字符
- # 三个引号用于多行字符串,允许字符串跨越多行
- python_code = r"""def say_hello():
- print("Hello, World!")
- # Print it
- say_hello()
- """
-
- # 创建一个GPT-2模型的tokenizer实例,使用的是预训练的"gpt2"模型的tokenizer
- tokenizer = AutoTokenizer.from_pretrained("gpt2")
-
- # 使用tokenizer对python_code字符串进行编码
- # .tokens()方法将返回编码后的token列表,这些token是模型能理解的内部表示
- # 这允许我们查看tokenizer如何处理Python代码
- print(tokenizer(python_code).tokens())
运行结果:
['def', 'Ġsay', '_', 'hello', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', '("', 'Hello', ',', 'ĠWorld', '!"', ')', 'Ċ', '#', 'ĠPrint', 'Ġit', 'Ċ', 'say', '_', 'hello', '()', 'Ċ']
Python有一个内置的tokenize模块,可以将Python代码拆分成一些有意义的单元(代码、注释、缩进等)。使用这个方法的一个问题是,这种预词元化分析器是基于Python的,因此会很慢,而且会受到Python全局解释器锁(Global Interpreter Lock,GIL)的约束。另一方面,Hugging Face Transformers库中的大部分词元分析器是由Tokenizers库提供的,且是使用Rust编写的。基于Rust的词元分析器在训练中和实际应用中的速度要比Python领先多个数量级。
以上代码的输出相当奇怪,我们可以通过运行词元分析器pipeline的各个子模块来了解它的底层发生了什么。首先,查看词元分析器中运用了什么规范化处理:
- # 打印tokenizer的底层normalizer对象
- # normalizer是用于文本标准化的函数,它在tokenization之前对文本进行清洗和标准化
- # 这通常包括转换为小写、去除多余的空格和标点符号等操作
- # 不同的tokenizer可能使用不同的normalizer实现
- print(tokenizer.backend_tokenizer.normalizer)
运行结果:
None
结果显示,在GPT-2中并没有运用规范化操作。它直接处理输入的原始Unicode字符,并无任何规范化步骤。下面我们来看看预词元化的情况:
- # 调用tokenizer的pre_tokenizer组件的pre_tokenize_str方法
- # pre_tokenizer是在主tokenizer之前运行的,用于进行初步的文本处理
- # 这通常包括分割文本到行或句子,移除多余的空格等
- # pre_tokenize_str是pre_tokenizer的一个方法,接受一个字符串并返回预处理后的结果
- # 这里我们传入了python_code字符串,以查看它如何被预处理
- 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, 44)), ('#', (44, 45)), ('ĠPrint', (45, 51)), ('Ġit', (51, 54)), ('Ċ', (54, 55)), ('say', (55, 58)), ('_', (58, 59)), ('hello', (59, 64)), ('()', (64, 66)), ('Ċ', (66, 67))]
从结果中我们可以发现很多的“Ġ”符号,它是什么意思?伴随词元的数字又是什么意思?我们这里对其作出解释,目的是让大家更好地理解这个词元分析器的工作原理。
先解释词元中的数字。Hugging Face Tokenizers库有一个很实用的功能,用于在字符串和词元之间切换,它叫作偏移量追踪,词元分析器对输入的字符串的所有操作都会被其追踪,如此就能确切获知词元化后第一个词元对应于输入字符串的哪个部分。所以这些数字表示每个词元在原始字符串中的位置信息。比如,单词"hello"对应于原始输入字符串的第8~13个字符。如果在规范化步骤中删除了一些字符,则我们仍能找到每个词元在原始字符串中的位置。
被词元化的文本中的“Ċ”、“Ġ”符号看起来很奇怪,这是因为词元分析器工作在字节级别,而不是处理的Unicode字符(每个Unicode字符由1~4个字节组成)。这里使用字节是因为字节码表中仅有256个元素,而Unicode字符却多达143 859个,每个Unicode字符又可以通过字节序列表示。这样一来,就可以将所有UTF-8编码的字符串用256个元素编码表达,而模型也只需要处理256个元素,并不需要直接处理Unicode字符,降低了复杂性。下面我们看看一些常见字符用这种方式是如何表达的:
- # 定义两个Unicode字符,a和e,分别赋值为普通字符'a'和带欧元符号的字符'€'
- a, e = u"a", u"€"
-
- # 使用ord函数获取字符a的Unicode编码值
- # encode("utf-8")将字符a编码为UTF-8格式的字节串
- # ord函数返回该字节串的第一个字节的ASCII码值
- byte = ord(a.encode("utf-8"))
-
- # 打印字符a及其UTF-8编码和对应的字节值
- # f-string格式化输出,`{a}`和`{a.encode("utf-8")}`分别插入变量a和其编码结果
- # `{byte}`插入计算得到的字节值
- print(f'`{a}` is encoded as `{a.encode("utf-8")}` with a single byte: {byte}')
-
- # 使用encode("utf-8")将字符e编码为UTF-8格式的字节串
- # 由于欧元符号'€'是一个多字节字符,encode后的结果是一个字节列表
- # 使用列表推导式和ord函数将每个字节转换成对应的ASCII码值
- byte = [ord(chr(i)) for i in e.encode("utf-8")]
-
- # 打印字符e及其UTF-8编码和对应的字节列表
- # f-string格式化输出,`{e}`和`{e.encode("utf-8")}`分别插入变量e和其编码结果
- # `{byte}`插入计算得到的字节列表
- 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字符来建立词表其实没有任何问题,但是这样会使得模型的嵌入层变得非常大,因为它需要包含每个词汇词元的向量。
L. Xue et al., “ByT5:Towards a Token-Free Future with Pre-Trained Byte-to-Byte Models”(https://arxiv.org/abs/2105.13626),(2021).
而如果只使用256个字节作为词表,那么输入的序列就会被分割成许多小单元(由字节构成的Unicode字符),因此模型就不得不处理较长的输入内容,并增加一些开销在使用字节重建Unicode字符上,再使用Unicode重建内容。关于这种开销的更详细研究,请参阅ByT5模型的论文。
有一个介于它们之间的解决方案,即通过最常见的字节组合来扩充256个字节,构建出一个中等规模的词表。这其实就是BPE算法所使用的方法。其思路是通过迭代合并词表中最频繁出现的词元对,来创建新的词汇词元,从而逐步构建一个预期规模的词表。例如,如果t和h频繁地一起出现(英文中有很多),就可以在词表中添加一个th词元来模拟这对词元组合,而不是将它们分开。t和h的词元也被保留在词表中,以便在它们不同时出现时进行词元化。使用这些包含基本单元的词表,我们就可以高效地对任意字符串进行建模。
P. Gage,“A New Algorithm for Data Compression,”The C Users Journal 12,no. 2(1994): 23-38,https://dx.doi.org/10.14569/IJACSA.2012.030803.
需要注意,不要将BPE(Byte-Pair Encoding)中的“byte”与“byte-level”中的“byte”混为一谈。BPE来自Philip Gage在1994年提出的一种数据压缩技术,最初是操作字节 ,但目前的标准BPE算法却是操作Unicode字符串(尽管有一种新的BPE算法也专注于字节操作,叫作字节级BPE)。如果需要将Unicode字符串读取为字节来处理,那么我们可以重新使用一个简单的BPE子词切分算法。
在NLP领域中使用典型的BPE算法有一个问题需要注意。这些算法通常被设计为使用纯粹的Unicode字符串作为输入,而不是字节内容,并且要求在输入中使用常规的ASCII字符,没有空格或控制字符。但是在对应于256个初始字节的Unicode字符中,也存在许多控制字符(换行符、制表符、转译符,以及其他不可打印的字符)。为了克服这个问题,GPT-2的词元分析器首先将256个输入字节映射为Unicode字符串,这样这些字符串就很容易被标准的BPE算法处理了,也就是说,将256个基本元素映射为Unicode字符串,都对应于可打印的标准Unicode字符。
不管这些Unicode字符使用多少个字节来编码,这都不重要,重要的是最后有256个单个值来形成基础词表,且都能被BPE算法兼容。下面我们看看GPT-2的词元分析器与这种映射关系的例子,可以使用以下方式来访问所有的映射:
- # 从transformers库中的gpt2模型的tokenization_gpt2模块导入bytes_to_unicode函数
- from transformers.models.gpt2.tokenization_gpt2 import bytes_to_unicode
-
- # 调用bytes_to_unicode函数获取字节到Unicode字符的映射
- # GPT-2使用的是Byte Pair Encoding (BPE),其中每个token可以由一个字节或多个字节表示
- # 这个映射将字节序列映射到对应的Unicode字符
- byte_to_unicode_map = bytes_to_unicode()
-
- # 创建一个字典,将byte_to_unicode_map的值和键互换,形成Unicode到字节的映射
- # 这允许我们通过Unicode字符查找对应的字节序列
- unicode_to_byte_map = dict((v, k) for k, v in byte_to_unicode_map.items())
-
- # 从创建的映射中提取所有的Unicode字符,并存储到base_vocab列表中
- # 这个列表代表了GPT-2模型的基础词汇表
- base_vocab = list(unicode_to_byte_map.keys())
-
- # 打印基础词汇表的大小
- print(f'Size of our base vocabulary: {len(base_vocab)}')
-
- # 打印基础词汇表的第一个元素和最后一个元素
- # 使用`[0]`和`[-1]`索引分别访问列表的第一个和最后一个元素
- print(f'First element: `{base_vocab[0]}`, last element: `{base_vocab[-1]}`')
运行结果:
Size of our base vocabulary: 256 First element: `!`, last element: `Ń`
我们可以在下图中查看一些常见的字节值和映射的Unicode字符。
此外,我们还可以使用更加易懂的映射方式,比如将换行符直接映射为NEWLINE字符串,但BPE算法通常是基于字符设计的。因此,让每个字节字符持有一个Unicode字符,使用BPE算法就会更加方便。现在我们了解了Unicode编码的技巧,就可以更好地理解词元化转换过程了:
- # 导入pandas库,pandas是一个数据分析和操作的强大库
- import pandas as pd
-
- # 从transformers库中的gpt2模型的tokenization_gpt2模块导入bytes_to_unicode函数
- from transformers.models.gpt2.tokenization_gpt2 import bytes_to_unicode
-
- # 调用bytes_to_unicode函数获取字节到Unicode字符的映射
- # 这个映射是GPT-2使用的Byte Pair Encoding (BPE) 分词器的一部分
- byte_to_unicode_map = bytes_to_unicode()
-
- # 创建一个字典,将byte_to_unicode_map的键值对颠倒,形成Unicode到字节的映射
- # 这允许我们通过Unicode字符查找对应的字节值
- unicode_to_byte_map = dict((v, k) for k, v in byte_to_unicode_map.items())
-
- # 从创建的映射中提取所有的Unicode字符,并存储到base_vocab列表中
- # 这个列表代表了GPT-2模型的基础词汇表的一部分
- base_vocab = list(unicode_to_byte_map.keys())
-
- # 打印基础词汇表的大小
- print(f'Size of our base vocabulary: {len(base_vocab)}')
-
- # 创建一个包含不同字符及其相关信息的列表
- # 每个子列表包含:描述、字符表示、字节值、通过BPE映射得到的字符
- examples = [
- ['Regular characters', '`a` and `?`', f'{ord("a")} and {ord("?")}' , f'`{byte_to_unicode_map[ord("a")]}` and `{byte_to_unicode_map[ord("?")]}`'],
- ['Nonprintable control character (carriage return)', '`U+000D`', f'13', f'`{byte_to_unicode_map[13]}`'],
- ['A space', '` `', f'{ord(" ")}', f'`{byte_to_unicode_map[ord(" ")]}`'],
- ['A nonbreakable space', '`\xa0`', '160', f'`{byte_to_unicode_map[ord(chr(160))]}`'],
- ['A newline character', '`\n`', '10', f'`{byte_to_unicode_map[ord(chr(10))]}`'],
- ]
-
- # 使用pandas创建一个DataFrame,用于组织和展示examples列表中的数据
- # DataFrame是一个二维表格型数据结构,类似于Excel中的表格
- # columns参数定义了DataFrame的列名
- df = pd.DataFrame(examples, columns=['Description', 'Character', 'Bytes', 'Mapped bytes'])
- df
运行结果:
- # 如果 tokenizer 有 pre_tokenizer 属性,pre_tokenize_str 方法将对输入的字符串进行预分词处理
- # 这通常包括分割文本到行、去除多余的空格、处理特殊字符等
- # 然后返回预分词后的结果
- pre_tokenized_output = tokenizer.backend_tokenizer.pre_tokenizer.pre_tokenize_str(python_code)
-
- # 打印预分词后的结果
- print(pre_tokenized_output)
运行结果:
[('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, 44)), ('#', (44, 45)), ('ĠPrint', (45, 51)), ('Ġit', (51, 54)), ('Ċ', (54, 55)), ('say', (55, 58)), ('_', (58, 59)), ('hello', (59, 64)), ('()', (64, 66)), ('Ċ', (66, 67))]
从上面的结果中,我们可以看出换行符,因为换行符被映射为了“Ċ”,而空格被映射为了“Ġ”。我们还可以看到:
下面我们使用BPE算法来做一个实验。在实验之前,回顾一下之前提到的,BPE模型会将单词拆分为子单元,直到所有的子单元能在预定义的词表中找到。
比如,GPT-2的词元分析器的词表包含50257个词:
可以通过查看词元分析器的长度来获得词表的大小:
- # 使用 Python 的 f-string 格式化特性来创建一个字符串
- # f-string 允许在字符串中直接嵌入表达式,并通过花括号{}包裹
- # len(tokenizer) 调用 len 函数获取 tokenizer 词汇表的大小
- # 这里的 len 函数是针对分词器对象的,它返回分词器能识别的token总数
- print(f"Size of the vocabulary: {len(tokenizer)}")
运行结果:
Size of the vocabulary: 50257
最后运行完整的pipeline代码,会得到下列输出:
- # 打印编码后的token列表
- # f-string格式化输出,展示编码后的token列表
- print(f"Encoded tokens: {tokenizer(python_code).tokens()}")
运行结果:
Encoded tokens: ['def', 'Ġsay', '_', 'hello', '():', 'Ċ', 'Ġ', 'Ġ', 'Ġ', 'Ġprint', '("', 'Hello', ',', 'ĠWorld', '!"', ')', 'Ċ', '#', 'ĠPrint', 'Ġit', 'Ċ', 'say', '_', 'hello', '()', 'Ċ']
从结果可以看出,BPE的词元分析器保留了大部分单词,但是会将缩进分割为几个连续的空格。出现这种情况的原因是,它的词元分析器并不是在代码上进行训练的,而原始语料是连续空格很少的文本。因此,BPE模型中没有为缩进单独表示的词元。这就是一个词元分析器与数据集不匹配的情况。解决方案是在目标语料库上重新训练词元分析器,下面我们看看如何操作。
我们使用语料库的部分数据来重新训练字节级的BPE词元分析器,来让它提升对Python代码的泛化能力。重新训练Hugging Face Transformers库提供的词元分析器非常简单,只需要做以下操作:
●指定目标词表大小。
●准备一个迭代器来提供输入字符串列表,以训练词元分析器模型。
●调用train_new_from_iterator()方法。
传统的深度学习模型通常需要从训练语料库中记忆许多细节,与之不同的是,词元分析器实际上被训练来提取主要的统计类数据。简而言之,词元分析器被训练来找到哪些字母组合在语料库中出现得最频繁。
因此,要达到这个目的,就不一定要在大型语料库上进行训练,需要语料库具有足够的代表性,并且要足够大,让词元分析器获取到具有统计意义的特征。但是,由于词表大小和语料库的一些原因,词元分析器可能会存储一些意料之外的词。例如,查看GPT-2的词元分析器中最长的词,可以看到:
- # 获取分词器的词汇表,并将其转换成一个可迭代的项列表
- # vocab.items() 返回词汇表中所有的键值对 (token, index),其中 token 是字符串,index 是整数
- tokens = sorted(tokenizer.vocab.items(), key=lambda x: len(x[0]), reverse=True)
-
- # 使用列表推导式和 tokenizer 的 convert_tokens_to_string 方法
- # convert_tokens_to_string 将 token 列表转换为一个可读的字符串
- # 这里我们首先通过 sorted 获取了词汇表中索引从0到7的 token
- # 然后通过列表推导式将这些 token 转换为字符串形式
- print([f'{tokenizer.convert_tokens_to_string([t])}' for t, _ in tokens[:8]])
运行结果:
['ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', ' =================================================================', ' ----------------------------------------------------------------', '________________________________________________________________', '================================================================', '----------------------------------------------------------------', 'ÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂÃÂ', '................................................................']
这些词元看起来很像是论坛上的分隔线。这其实是合理的,因为GPT-2是在以Reddit为核心的语料库上训练的。下面看看词表中最不频繁出现的词:
- # 获取分词器的词汇表,并将其转换成一个可迭代的项列表
- # vocab.items() 返回词汇表中所有的键值对 (token, index),其中 token 是字符串,index 是整数
- tokens = sorted(tokenizer.vocab.items(), key=lambda x: x[1], reverse=True)
-
- # 使用列表推导式和 tokenizer 的 convert_tokens_to_string 方法
- # convert_tokens_to_string 将 token 列表转换为一个可读的字符串
- # 这里我们首先通过 sorted 获取了词汇表中索引从0到11的 token
- # 然后通过列表推导式将这些 token 转换为字符串形式
- print([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~2GB的数据(或者大概100 000个文档)即可:
- # 导入 tqdm 的自动导入功能,tqdm 是一个快速,可扩展的Python进度条库
- from tqdm.auto import tqdm
-
- # 设置数据集的预期长度
- length = 100000
-
- # 指定要加载的数据集名称和分割类型
- # 'transformersbook/codeparrot-train' 是数据集在 Hugging Face Hub 上的路径
- dataset_name = 'transformersbook/codeparrot-train'
- # 使用 load_dataset 函数加载数据集的 "train" 分割部分,streaming=True 表示以流式传输方式加载
- dataset = load_dataset(dataset_name, split="train", streaming=True)
-
- # 创建数据集的迭代器
- iter_dataset = iter(dataset)
-
- # 定义一个生成器函数 batch_iterator,用于生成数据集的批次
- def batch_iterator(batch_size=10):
- # 使用 tqdm 包装 for 循环,显示进度条
- for _ in tqdm(range(0, length, batch_size)):
- # 每个批次生成 batch_size 个样本
- # next(iter_dataset)['content'] 获取数据集中的下一个样本的 'content' 字段
- yield [next(iter_dataset)['content'] for _ in range(batch_size)]
-
- # 创建一个新的 tokenizer 实例
- # tokenizer.train_new_from_iterator 是一个假设的方法,实际使用时应替换为正确的方法名
- # 这里假设我们已经有了一个 tokenizer 实例和一个 base_vocab 列表
- new_tokenizer = tokenizer.train_new_from_iterator(batch_iterator(),
- vocab_size=12500, # 设置词汇表大小
- initial_alphabet=base_vocab) # 设置初始字母表
运行结果:
这里需要研究一下BPE算法创建的第一个和最后一个词,来看看词表的关联性。直接跳过前面256个字节的词元,观察其后添加的词元:
- # 获取分词器的词汇表,并将其转换成一个可迭代的项列表
- # vocab.items() 返回词汇表中所有的键值对 (token, index),其中 token 是字符串,index 是整数
- tokens = sorted(new_tokenizer.vocab.items(), key=lambda x: x[1], reverse=False)
-
- # 使用列表推导式和 tokenizer 的 convert_tokens_to_string 方法
- # convert_tokens_to_string 将 token 列表转换为一个可读的字符串
- # 这里我们首先通过 sorted 获取了词汇表中索引从257到280的 token
- # 然后通过列表推导式将这些 token 转换为字符串形式
- print([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算法是符合预期的。下面来看看最后一个词:
- # 使用列表推导式和 new_tokenizer 的 convert_tokens_to_string 方法
- print([f'{new_tokenizer.convert_tokens_to_string([t])}' for t, _ in tokens[-12:]])
运行结果:
[' capt', ' embedded', ' regarding', 'Bundle', '355', ' recv', ' dmp', ' vault', ' Mongo', ' possibly', 'implementation', 'Matches']
这里出现了一些常见的词汇,例如recv(https://oreil.ly/tliPP),以及一些可能来自代码注释的噪声词汇。下面使用词元分析器对Python代码进行词元化:
- # 打印编码后的token列表
- print(new_tokenizer(python_code).tokens())
运行结果:
['def', 'Ġs', 'ay', '_', 'hello', '():', 'ĊĠĠĠ', 'Ġprint', '("', 'Hello', ',', 'ĠWor', 'ld', '!")', 'Ċ', '#', 'ĠPrint', 'Ġit', 'Ċ', 's', 'ay', '_', 'hello', '()', 'Ċ']
尽管这其中许多不是Python的关键字,但词元分析器将World和say这样的词还是做了拆分,而其实这样的词在语料库中经常出现。下面检查一下Python的所有关键字是否都在词表中:
- # 导入 Python 标准库中的 keyword 模块
- # keyword 模块提供了一个包含所有 Python 关键字的列表
- import keyword
-
- # 打印 Python 关键字的总数
- # keyword.kwlist 是一个字符串列表,包含了所有的 Python 关键字
- print(f'There are in total {len(keyword.kwlist)} Python keywords.')
-
- # 遍历所有的 Python 关键字
- for keyw in keyword.kwlist:
- # 检查当前关键字是否不在 new_tokenizer 的词汇表中
- # new_tokenizer.vocab 应该是分词器的词汇表,它是一个字典,包含 token 到索引的映射
- 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
上面的结果显示,Python中有些经常使用的关键字却不在词表中,比如finally。可以尝试使用更大的数据集样本来构建一个更大的词表。例如,构建一个具备32 768个词的词表(8的倍数对于一些GPU/TPU兼容性更好),并在两倍于它的语料上训练词元分析器:
- # 设置较大的数据集预期长度,这里假设数据集足够大,可以容纳这么多样本
- length = 200000
-
- # 创建一个新的更大词汇表的 tokenizer 实例
- # 这里假设 tokenizer 对象已经存在,且具有 train_new_from_iterator 方法
- # train_new_from_iterator 方法用于从迭代器生成的文本数据中训练一个新的 tokenizer
- # vocab_size=32768 设置新 tokenizer 的词汇表大小为 32768,这个数字应该根据需求和资源限制来确定
- # initial_alphabet=base_vocab 指定新 tokenizer 的初始字母表,base_vocab 应该是一个包含基础 tokens 的列表
- new_tokenizer_larger = tokenizer.train_new_from_iterator(batch_iterator(),
- vocab_size=32768, initial_alphabet=base_vocab)
运行结果:
我们并不想看到使用了更多的语料之后,那些问题还会出现,来看看使用这种方式产生的词元情况:
- # 假设 new_tokenizer_larger 是已经训练好的分词器实例
- # 获取分词器的词汇表,并将其转换成一个可迭代的项列表
- # sorted 函数对词汇表进行排序,按词汇表的索引 (index) 进行升序排列 (reverse=False)
- tokens = sorted(new_tokenizer_larger.vocab.items(), key=lambda x: x[1], reverse=False)
-
- # 使用列表推导式和 new_tokenizer_larger 的 convert_tokens_to_string 方法
- # convert_tokens_to_string 方法将 token 列表转换为一个可读的字符串
- # 这里我们首先通过 sorted 获取了词汇表中索引最后的 12 个 token
- # 然后通过列表推导式将这些 token 转换为字符串形式
- # 注意:每个 token 需要作为一个单元素列表传递给 convert_tokens_to_string 方法
- print([f'{new_tokenizer_larger.convert_tokens_to_string([t])}' for t, _ in tokens[-12:]])
运行结果:
[" '<?", 'Functional', ' Images', 'encoders', ' bibrec', ' OPTIONAL', ' rdclass', 'SocketAddressTag', '资金', 'DEPLOYMENT', '经纪公司代码', ")'],"]
以上结果中并没有出现任何Python的关键字,这可能是我们想要的效果,让我们用这个词元分析器来对示例代码进行词元化:
- # 假设 new_tokenizer_larger 是已经训练好的分词器实例
- # python_code 是待分词的字符串,例如 Python 代码
- # 使用 new_tokenizer_larger 对 python_code 进行分词
- # new_tokenizer_larger(python_code) 返回的是分词后的输出,其中包含 tokens 方法
- # tokens 方法返回分词器生成的 token 列表
- print(new_tokenizer_larger(python_code).tokens())
运行结果:
['def', 'Ġsay', '_', 'hello', '():', 'ĊĠĠĠ', 'Ġprint', '("', 'Hello', ',', 'ĠWorld', '!")', 'Ċ', '#', 'ĠPrint', 'Ġit', 'Ċ', 'say', '_', 'hello', '()', 'Ċ']
效果的确好了很多,缩进也能正确地表示了,Hello、World和say这样的常见单词也不会被拆分了,而是成为了单个词元。我们再来查看一下Python的所有关键字是否都在词表中:
- # 遍历所有 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(https://oreil.ly/IHAMu)仍然不在词表中,但其实它在实际编程当中也很少使用,它会让语法变得复杂,所以它不在词表中也似乎是合理的。在检查之后我们发现,目前的词元分析器已经非常接近我们的目标了,但如果不测试模型整体的性能,单独评估词元分析器的性能是一件很有挑战性的事。下面我们将在这个词元分析器的基础上继续训练一个模型,查看它的实际效果。
通过比较代码示例词元化后的序列长度,可以得出这个新的词元分析器比标准GPT-2的词元分析器的性能高一倍左右。新的词元分析器使用的词元数量大约只有其一半,这样就能得到两倍有效的模型上下文。现在使用新的词元分析器在1024的上下文窗口上训练新模型,相当于用之前的词元分析器在2048的上下文窗口训练同一个模型,并且新方法速度更快,内存使用效率更高。
现在词元分析器已经训练完毕,如何进行保存呢?最简单的方法是将其推送到Hugging Face Hub进行托管,便于以后维护与使用,后文在介绍使用训练服务器的时候会再次用到它。
使用词元分析器的push_to_hub()方法可以帮助我们创建一个私有模型仓库,并将词元分析器保存到其中。因为目前已经使用huggingface-clilogin完成了登录,所以可以直接推送词元分析器到仓库中,如下所示:
- # 定义模型检查点名称和组织名称
- model_ckpt = "codeparrot" # 模型检查点的名称,用于在 Hugging Face Hub 上标识模型
- org = "transformersbook" # 组织名称,用于指定上传到哪个 Hugging Face 组织下
-
- # 将新训练的分词器推送到 Hugging Face Hub
- # push_to_hub 方法将分词器上传到 Hugging Face Hub,模型检查点名称和组织名称用于标识上传的位置
- new_tokenizer_larger.push_to_hub(model_ckpt, organization=org)
当然如果你不想推送到某个组织,则可以忽略掉organization这个参数。该操作会在你的命名空间中创建一个名为codeparrot的资源仓库,之后就可以跟其他人分享了:
- # 重新加载之前上传到 Hugging Face Hub 的分词器
- # 使用 AutoTokenizer 从 Hugging Face Hub 下载分词器
- # org + "/" + model_ckpt 拼接成模型的完整路径
- reloaded_tokenizer = AutoTokenizer.from_pretrained(org + "/" + model_ckpt)
-
- # 使用重新加载的分词器对 python_code 进行分词
- # reloaded_tokenizer(python_code) 将待分词的字符串转换为分词器的输入格式
- # .tokens() 方法返回分词后的 token 列表
- # 打印分词结果
- print(reloaded_tokenizer(python_code).tokens())
运行结果:
现在就可以像之前那样从Hub(https://oreil.ly/vcLeo)上直接加载词元分析器了,并且也可以研究它的文件和词表,这里也将小的词元分析器推送到Hub:
- # 定义模型检查点名称,并添加后缀以表示小词汇表版本
- # model_ckpt 是模型检查点的基础名称
- # "-small-vocabulary" 是添加的后缀,用于区分不同版本的模型
- model_ckpt_with_suffix = model_ckpt + "-small-vocabulary"
-
- # 将新训练的分词器推送到 Hugging Face Hub
- # 使用 push_to_hub 方法将分词器上传到 Hugging Face Hub
- # model_ckpt_with_suffix 是上传模型的名称,组织名称用于指定上传到哪个 Hugging Face 组织下
- new_tokenizer.push_to_hub(model_ckpt_with_suffix, organization=org)
后面我们会针对一个用例构建词元分析器,并展开深入研究。接下来就让我们见证一个模型的诞生历程吧。
Package Version
------------------------- --------------
aiohttp 3.9.5
aiosignal 1.3.1
alembic 1.13.2
anyio 4.4.0
argon2-cffi 23.1.0
argon2-cffi-bindings 21.2.0
arrow 1.3.0
asttokens 2.4.1
async-lru 2.0.4
attrs 23.2.0
Babel 2.15.0
beautifulsoup4 4.12.3
bleach 6.1.0
certifi 2024.7.4
cffi 1.16.0
charset-normalizer 3.3.2
colorama 0.4.6
coloredlogs 15.0.1
colorlog 6.8.2
comm 0.2.2
contourpy 1.2.1
cycler 0.12.1
datasets 2.20.0
debugpy 1.8.2
decorator 5.1.1
defusedxml 0.7.1
dill 0.3.8
executing 2.0.1
faiss-cpu 1.8.0.post1
fastjsonschema 2.20.0
filelock 3.15.4
flatbuffers 24.3.25
fonttools 4.53.1
fqdn 1.5.1
frozenlist 1.4.1
fsspec 2024.5.0
gdown 5.2.0
greenlet 3.0.3
h11 0.14.0
httpcore 1.0.5
httpx 0.27.0
huggingface-hub 0.23.4
humanfriendly 10.0
idna 3.7
intel-openmp 2021.4.0
ipykernel 6.29.5
ipython 8.26.0
ipywidgets 8.1.3
isoduration 20.11.0
jedi 0.19.1
Jinja2 3.1.4
joblib 1.4.2
json5 0.9.25
jsonpointer 3.0.0
jsonschema 4.23.0
jsonschema-specifications 2023.12.1
jupyter 1.0.0
jupyter_client 8.6.2
jupyter-console 6.6.3
jupyter_core 5.7.2
jupyter-events 0.10.0
jupyter-lsp 2.2.5
jupyter_server 2.14.2
jupyter_server_terminals 0.5.3
jupyterlab 4.2.3
jupyterlab_pygments 0.3.0
jupyterlab_server 2.27.2
jupyterlab_widgets 3.0.11
kiwisolver 1.4.5
Mako 1.3.5
MarkupSafe 2.1.5
matplotlib 3.9.1
matplotlib-inline 0.1.7
mistune 3.0.2
mkl 2021.4.0
mpmath 1.3.0
multidict 6.0.5
multiprocess 0.70.16
nbclient 0.10.0
nbconvert 7.16.4
nbformat 5.10.4
nest-asyncio 1.6.0
networkx 3.3
nlpaug 1.1.11
notebook 7.2.1
notebook_shim 0.2.4
numpy 1.26.4
onnx 1.16.1
onnxruntime 1.18.1
optuna 3.6.1
overrides 7.7.0
packaging 24.1
pandas 2.2.2
pandocfilters 1.5.1
parso 0.8.4
pillow 10.4.0
pip 24.1.2
platformdirs 4.2.2
prometheus_client 0.20.0
prompt_toolkit 3.0.47
protobuf 5.27.2
psutil 6.0.0
pure-eval 0.2.2
pyarrow 16.1.0
pyarrow-hotfix 0.6
pycparser 2.22
Pygments 2.18.0
pyparsing 3.1.2
pyreadline3 3.4.1
PySocks 1.7.1
python-dateutil 2.9.0.post0
python-json-logger 2.0.7
pytz 2024.1
pywin32 306
pywinpty 2.0.13
PyYAML 6.0.1
pyzmq 26.0.3
qtconsole 5.5.2
QtPy 2.4.1
referencing 0.35.1
regex 2024.5.15
requests 2.32.3
rfc3339-validator 0.1.4
rfc3986-validator 0.1.1
rpds-py 0.19.0
scikit-learn 1.5.1
scikit-multilearn 0.2.0
scipy 1.14.0
Send2Trash 1.8.3
sentencepiece 0.2.0
setuptools 70.0.0
six 1.16.0
sniffio 1.3.1
soupsieve 2.5
SQLAlchemy 2.0.31
stack-data 0.6.3
sympy 1.13.0
tbb 2021.13.0
terminado 0.18.1
threadpoolctl 3.5.0
tinycss2 1.3.0
tokenizers 0.13.3
torch 2.3.1+cu121
torchaudio 2.3.1+cu121
torchvision 0.18.1+cu121
tornado 6.4.1
tqdm 4.66.4
traitlets 5.14.3
transformers 4.24.0
types-python-dateutil 2.9.0.20240316
typing_extensions 4.12.2
tzdata 2024.1
uri-template 1.3.0
urllib3 2.2.2
wcwidth 0.2.13
webcolors 24.6.0
webencodings 0.5.1
websocket-client 1.8.0
wheel 0.43.0
widgetsnbextension 4.0.11
xxhash 3.4.1
yarl 1.9.4
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。