Press "Enter" to skip to content

Hugging Face的TensorFlow理念

介绍

尽管来自PyTorch和JAX的竞争越来越激烈,但TensorFlow仍然是最常用的深度学习框架。它与这两个库在一些非常重要的方面有所不同。特别是,它与其高级API Keras 和数据加载库 tf.data 非常紧密地集成。

PyTorch工程师们有一种倾向(在这里,可以想象我在开放式办公室里阴沉地盯着对面)认为这是一个需要克服的问题;他们的目标是弄清楚如何让TensorFlow离开他们的路,以便他们可以使用他们习惯的低级训练和数据加载代码。这完全是错误的方法来处理TensorFlow!Keras是一个非常好的高级API。如果你在一个由几个模块组成的项目中将它挤到一边,当你意识到你需要它时,你将不得不自己重新实现大部分功能。

作为经过精心打磨、备受尊敬和极具吸引力的TensorFlow工程师,我们希望使用先进模型的令人难以置信的强大和灵活性,但我们希望用我们熟悉的工具和API来处理它们。本博文将讨论我们在Hugging Face所做的选择,以实现这一目标,并介绍作为TensorFlow程序员可以期待的框架。

插曲:30秒到🤗

有经验的用户可以随意略读或跳过本节,但如果这是你第一次接触Hugging Face和transformers,我应该首先给你一个该库的核心思想的概述:你只需按名称请求一个预训练模型,一行代码即可获得它。最简单的方法是使用TFAutoModel类:

from transformers import TFAutoModel

model = TFAutoModel.from_pretrained("bert-base-cased")

这一行将实例化模型架构并加载权重,给你一个与原始的著名BERT模型完全相同的复制品。但是,这个模型本身不会做太多事情 – 它缺少输出头部或损失函数。实际上,它是一个神经网络的“干才”,在最后一个隐藏层后面就停止了。那么如何在其上添加一个输出头部呢?简单,只需使用不同的AutoModel类。这里我们加载了Vision Transformer(ViT)模型,并添加了一个图像分类头部:

from transformers import TFAutoModelForImageClassification

model_name = "google/vit-base-patch16-224"
model = TFAutoModelForImageClassification.from_pretrained(model_name)

现在我们的model有一个输出头部,并且还可以选择一个适合其新任务的损失函数。如果新的输出头与原始模型不同,那么它的权重将被随机初始化。所有其他权重将从原始模型加载。但是为什么我们要这样做呢?为什么我们要使用现有模型的“干才”,而不是从头开始制作我们需要的模型呢?

事实证明,预训练在大量数据上的大型模型几乎比仅仅随机初始化权重的标准方法更好。这被称为迁移学习,如果你仔细考虑,就会明白这是有道理的 – 良好解决文本任务需要对语言有一定的了解,良好解决视觉任务需要对图像和空间有一定的了解。没有迁移学习,ML是如此地对数据饥渴,这是因为这些基本领域知识必须为每个问题从头开始重新学习,这就需要大量的训练样本。然而,通过使用迁移学习,一个问题可以用一千个训练样本解决,而不使用迁移学习可能需要一百万个训练样本,并且常常具有更高的最终准确性。关于这个主题的更多信息,请查看Hugging Face课程的相关章节!

然而,在使用迁移学习时,非常重要的一点是以与训练期间相同的方式处理模型的输入。这确保了在将其知识迁移到新问题时,模型需要重新学习的内容尽可能少。在transformers中,这种预处理通常由分词器处理。分词器可以以与模型相同的方式加载,使用AutoTokenizer类。确保加载与您要使用的模型匹配的分词器!

from transformers import TFAutoModel, AutoTokenizer

# 确保始终加载匹配的分词器和模型!
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
model = TFAutoModel.from_pretrained("bert-base-cased")

# 让我们加载一些数据并对其进行分词
test_strings = ["这是一个句子!", "这是另一个!"]
tokenized_inputs = tokenizer(test_strings, return_tensors="np", padding=True)

# 现在我们的数据已经被分词,我们可以将其传递给我们的模型,或在fit()中使用它!
outputs = model(tokenized_inputs)

这当然只是库的一小部分示例 – 如果你想了解更多,可以查看我们的notebooks,或者我们的代码示例。在keras.io上还有很多库的其他示例!

到目前为止,你已经了解了一些基本概念和类在transformers中。我上面写的内容是与框架无关的(除了TFAutoModel中的“TF”),但是当你想要实际训练和部署模型时,不同框架之间就会开始产生差异。这就带我们来到本文的主要内容:作为一名TensorFlow工程师,你对transformers应该有什么期望呢?

原则1:所有的TensorFlow模型都应该是Keras模型对象,所有的TensorFlow层都应该是Keras层对象。

这在TensorFlow库中几乎是不言而喻的,但无论如何,都值得强调一下。从用户的角度来看,这种选择的最重要影响是你可以直接在我们的模型上调用Keras的方法,如fit()compile()predict()

例如,假设你的数据已经准备好并进行了标记化,那么在TensorFlow中从一个序列分类模型中获取预测结果就像这样简单:

model = TFAutoModelForSequenceClassification.from_pretrained(my_model)
model.predict(my_data)

如果你想要训练该模型,只需:

model.fit(my_data, my_labels)

然而,这种便利性并不意味着你只能使用我们默认支持的任务。Keras模型可以作为其他模型的层进行组合,所以如果你有一个涉及将五个不同模型拼接在一起的巨大想法,除了你有限的GPU内存之外,没有任何阻碍你的东西。也许你想要将一个预训练语言模型与一个预训练视觉transformer合并,创建一个类似Deepmind最近的Flamingo或者你想要创建下一个病毒式的文本到图像的感觉,就像Dall-E Mini Craiyon一样?下面是一个使用Keras子类化的混合模型的示例:

class HybridVisionLanguageModel(tf.keras.Model):
  def __init__(self):
    super().__init__()
    self.language = TFAutoModel.from_pretrained("gpt2")
    self.vision = TFAutoModel.from_pretrained("google/vit-base-patch16-224")

  def call(self, inputs):
    # 我对这个有一个非常好的想法
    # 这个代码框容纳不下

原则2:默认情况下提供损失函数,但可以轻松更改。

在Keras中,训练模型的标准方法是创建模型,然后使用优化器和损失函数进行compile(),最后使用fit()进行训练。使用transformers加载模型非常简单,但是设置损失函数可能会有些棘手 – 即使对于标准的语言模型训练,你的损失函数可能会出乎意料地复杂,而一些混合模型可能具有极其复杂的损失函数。

我们的解决方案很简单:如果你在compile()中没有指定损失参数,我们会为你提供你可能想要的损失函数。具体来说,我们会为你提供一个与你的基本模型和输出类型相匹配的损失函数 – 如果你在一个基于BERT的掩码语言模型上进行compile()而没有指定损失函数,我们会为你提供一个正确处理填充和掩码的掩码语言建模损失函数,仅在受损标记上计算损失,与原始的BERT训练过程完全匹配。如果出于某种原因,你真的不希望你的模型被编译为任何损失,那么只需在编译时指定loss=None

model = TFAutoModelForQuestionAnswering.from_pretrained("bert-base-cased")
model.compile(optimizer="adam")  # 没有损失参数!
model.fit(my_data, my_labels)

但是同样重要的是,我们希望在你想做一些更复杂的事情时尽快退出。如果你在compile()中指定了损失参数,那么模型将使用该损失而不是默认损失。当然,如果你像上面的HybridVisionLanguageModel一样创建自己的子类模型,那么你可以通过编写call()train_step()方法完全控制模型的功能的每个方面。

哲学实现细节#3:标签是灵活的

过去的一个困惑源是标签应该在哪里传递给模型。将标签传递给Keras模型的标准方法是作为一个单独的参数,或作为一个(输入,标签)元组的一部分:

model.fit(inputs, labels)

在过去,我们要求用户在使用默认损失时将标签传递给输入字典。这样做的原因是该模型计算损失的代码包含在call()前向传递方法中。这种方法虽然可行,但对于Keras模型来说一定不标准,并且导致了一些问题,包括与标准Keras指标的不兼容,更不用说一些用户的困惑了。幸运的是,现在这不再是必需的。我们现在建议按照正常的Keras方式传递标签,尽管出于向后兼容性的原因,旧的方法仍然可行。总的来说,以前很麻烦的许多事情现在应该“只是工作”对于我们的TensorFlow模型-让它们试试吧!

哲学#4:您不应该编写自己的数据流水线,特别是对于常见任务

除了transformers,一个巨大的预训练模型开放仓库,还有🤗datasets,一个巨大的开放数据集仓库-文本、视觉、音频等。这些数据集可以轻松转换为TensorFlow张量和Numpy数组,使其易于用作训练数据。以下是一个快速示例,展示了我们如何对数据集进行标记化并将其转换为Numpy数组。如常,确保您的分词器与您想要训练的模型匹配,否则事情会变得非常奇怪!

from datasets import load_dataset
from transformers import AutoTokenizer, TFAutoModelForSequenceClassification
from tensorflow.keras.optimizers import Adam

dataset = load_dataset("glue", "cola")  # 简单的文本分类数据集
dataset = dataset["train"]  # 暂时只使用训练集

# 加载我们的分词器并对数据进行分词
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
tokenized_data = tokenizer(dataset["text"], return_tensors="np", padding=True)
labels = np.array(dataset["label"]) # 标签已经是一个由0和1组成的数组

# 加载和编译我们的模型
model = TFAutoModelForSequenceClassification.from_pretrained("bert-base-cased")
# 对于微调transformers,较低的学习率通常更好
model.compile(optimizer=Adam(3e-5))

model.fit(tokenized_data, labels)

这种方法在工作时非常好,但对于较大的数据集,您可能会发现它开始成为一个问题。为什么呢?因为标记化的数组和标签必须完全加载到内存中,并且因为Numpy不能处理“不规则”的数组,所以每个标记化的样本都必须填充到整个数据集中最长样本的长度。这将使您的数组更大,并且所有这些填充标记也会减慢训练速度!

作为一个TensorFlow工程师,这通常是您转向tf.data的地方,以创建一个将数据从存储中流式传输而不是全部加载到内存中的流水线。但是,这很麻烦,所以我们来帮助您。首先,让我们使用map()方法将分词器列添加到数据集中。请记住,默认情况下,我们的数据集是基于磁盘的-只有在您将它们转换为数组之后才会加载到内存中!

def tokenize_dataset(data):
    # 返回的字典的键将被添加到数据集作为列
    return tokenizer(data["text"])

dataset = dataset.map(tokenize_dataset)

现在我们的数据集具有了我们想要的列,但是如何在其上进行训练呢?简单-用tf.data.Dataset包装它,所有的问题都会解决-数据是按需加载的,并且填充仅应用于批次而不是整个数据集,这意味着我们需要更少的填充标记:

tf_dataset = model.prepare_tf_dataset(
    dataset,
    batch_size=16,
    shuffle=True
)

model.fit(tf_dataset)

为什么prepare_tf_dataset()是模型的一个方法?很简单:因为您的模型知道哪些列是有效的输入,并自动过滤掉数据集中不是有效输入名称的列!如果您更想对要创建的tf.data.Dataset有更精确的控制,可以使用较低级别的Dataset.to_tf_dataset()。

哲学 #5:XLA很棒!

XLA是TensorFlow和JAX共享的即时编译器。它将线性代数代码转换为更优化的版本,运行更快,占用更少的内存。它非常酷,我们尽可能支持它。对于在TPU上运行模型来说,它非常重要,但它也可以为GPU甚至CPU提供速度提升!要使用它,只需使用jit_compile=True参数对模型进行compile()(这适用于所有Keras模型,不仅限于Hugging Face模型):

model.compile(optimizer="adam", jit_compile=True)

我们最近在这个领域取得了一些重大改进。最重要的是,我们已经更新了我们的generate()代码以使用XLA – 这是一个从语言模型中迭代生成文本输出的函数。这导致了巨大的性能提升 – 我们的传统TF代码比PyTorch慢得多,但新代码比它快得多,并且与JAX的速度相似!有关更多信息,请参阅我们关于XLA生成的博文。

但XLA不仅对生成有用!我们还进行了一些修复,以确保您可以使用XLA训练模型,结果我们的TF模型在语言模型训练等任务上达到了类似JAX的速度。

然而,需要明确XLA的主要限制:XLA期望输入形状是静态的。这意味着如果您的任务涉及可变的序列长度,您将需要为每个不同的输入形状运行新的XLA编译,这可能会抵消性能优势!您可以在我们的TensorFlow笔记本和上面的XLA生成博文中看到我们如何处理这些问题的一些示例。

哲学 #6:部署与训练同等重要

TensorFlow拥有丰富的生态系统,特别是在模型部署方面,其他更加研究导向的框架所缺乏的。我们正在积极努力让您能够使用这些工具来部署整个模型进行推理。我们特别感兴趣支持TF ServingTFX。如果您对此感兴趣,请查看我们关于使用TF Serving部署模型的博文!

然而,在部署NLP模型时的一个主要障碍是输入仍然需要进行分词,这意味着仅仅部署模型是不够的。在许多部署场景中,依赖于tokenizers可能会很麻烦,因此我们正在努力使将分词嵌入到模型本身成为可能,从而使您能够仅使用单个模型构件来处理从输入字符串到输出预测的整个流程。目前,我们仅支持最常见的模型,如BERT,但这是一个活跃的研究领域!不过,如果您想尝试一下,可以使用以下代码片段:

# 这是一个新功能,所以请确保更新到最新版本的transformers!
# 您还需要pip安装tensorflow_text

import tensorflow as tf
from transformers import TFAutoModel, TFBertTokenizer


class EndToEndModel(tf.keras.Model):
    def __init__(self, checkpoint):
        super().__init__()
        self.tokenizer = TFBertTokenizer.from_pretrained(checkpoint)
        self.model = TFAutoModel.from_pretrained(checkpoint)

    def call(self, inputs):
        tokenized = self.tokenizer(inputs)
        return self.model(**tokenized)

model = EndToEndModel(checkpoint="bert-base-cased")

test_inputs = [
    "这是一个测试句子!",
    "这是另一个!",
]
model.predict(test_inputs)  # 将字符串直接传递给模型!

结论:我们是一个开源项目,这意味着社区至关重要

制作了一个很酷的模型?分享出来吧!一旦您创建了一个账户并设置了您的凭证,只需简单的几步就可以完成:

model_name = "google/vit-base-patch16-224"
model = TFAutoModelForImageClassification.from_pretrained(model_name)

model.fit(my_data, my_labels)

model.push_to_hub("my-new-model")

您还可以使用PushToHubCallback在更长的训练过程中定期上传检查点!无论如何,您都将获得一个模型页面和一个自动生成的模型卡片,最重要的是,任何人都可以使用您的模型进行预测,或者将其作为进一步训练的起点,使用与加载任何现有模型相同的API:

model_name = "你的用户名/我的新模型"
model = TFAutoModelForImageClassification.from_pretrained(model_name)

我认为的一个事实是,Hugging Face 没有区分大型知名基础模型和由单个用户微调的模型,这展示了 Hugging Face 的核心信念 – 用户的力量可以创造伟大的事物。机器学习从来不应该只是少数几家公司拥有的闭源模型的结果;它应该是一系列开放的工具、工件、实践和知识的集合,不断扩展、测试、批评和建立,一个集市而不是一座大教堂。如果你有新的想法、新的方法,或者你训练出了一个有很好结果的新模型,让大家都知道吧!

另外,在类似的思路下,有什么你觉得缺失的东西吗?有什么瑕疵?令人烦恼的地方?应该是直观的但却不是?告诉我们吧!如果你愿意拿起(比喻性的)铁锹并开始修复它,那就更好了,但即使你没有时间或技能来改进代码库,也不要害羞地发表意见。通常情况下,核心维护者可能会忽视一些问题,因为用户没有提出来,所以不要假设我们一定知道某件事!如果它困扰着你,请在论坛上提问,或者如果你相当确定它是一个错误或缺失的重要功能,那就提交一个问题。

这些问题中很多都是小细节,没错,但用一个(相当笨拙的)短语来说,伟大的软件是由成千上万个小提交组成的。正是通过用户和维护者的不断集体努力,开源软件才会不断改进。机器学习将成为2020年代的一个重要社会问题,开源软件和社区的力量将决定它是成为一个开放和民主的力量,接受批评和重新评估,还是被巨大的黑匣子模型所主导,它们的所有者不允许外部人员(甚至是那些模型作出决策的人)查看它们宝贵的专有权重。所以不要害羞 – 如果有什么问题,如果你有更好的想法,如果你想为我们做贡献但又不知道从哪里开始,请告诉我们!

(如果你能在你的酷炫新功能合并后制作一个用来戏弄 PyTorch 团队的梗图,那就更好了。)

Leave a Reply

Your email address will not be published. Required fields are marked *