第一章:自然语言处理
自然语言处理的发展大致上可以分为两个阶段:
第一阶段:不懂语法怎么理解语言?
20 世纪 50 年代到 70 年代,人们对自然语言处理的认识都局限在人类学习语言的方式上,用了二十多年时间苦苦探寻让计算机理解语言的方法,最终却一无所获。
当时的学术界普遍认为,要让计算机处理自然语言必须先让其理解语言,因此分析语句和获取语义成为首要任务,而这主要依靠语言学家人工总结文法规则来实现。特别是 20 世纪 60 年代,基于乔姆斯基形式语言(Chomsky Formal languages)的编译器取得了很大进展,更加鼓舞了研究者通过概括语法规则来处理自然语言的信心。
图 1-1 诺姆·乔姆斯基(Noam Chomsky)
但是与规范严谨的程序语言不同,自然语言复杂又灵活,是一种上下文有关文法(Context-Sensitive Grammars,CSGs),因此仅靠人工编写文法规则根本无法覆盖,而且随着编写的规则数量越来越多、形式越来越复杂,规则与规则之间还可能会存在矛盾。因此这一阶段自然语言处理的研究可以说进入了误区。
第二阶段:只要看的足够多,就能处理语言
正如人类是通过空气动力学而不是简单模仿鸟类造出了飞机,计算机处理自然语言也未必需要理解语言。
图 1-2 处理语言需要先理解语言吗?
20 世纪 70 年代,随着统计语言学的提出,基于数学模型和统计方法的自然语言处理方法开始兴起。当时的代表性方法是“通信系统加隐马尔可夫模型”,其输入和输出都是一维且保持原有次序的符号序列,可以处理语音识别、词性分析等任务,但是这种方法在面对输出为二维树形结构的句法分析以及符号次序有很大变化的机器翻译等任务时就束手无策了。
20 世纪 80 年代以来,随着硬件计算能力的提高以及海量互联网数据的出现,越来越多的统计机器学习方法被应用到自然语言处理领域,例如一些研究者引入基于有向图的统计模型来处理复杂的句法分析任务。2005 年 Google 公司基于统计方法的翻译系统更是全面超过了基于规则的 SysTran 系统。
图 1-3 杰弗里·辛顿(Geoffrey Hinton)
2006 年,随着辛顿(Hinton)证明深度信念网络(Deep Belief Networks,DBN)可以通过逐层预训练策略有效地进行训练,基于神经网络和反向传播算法(Back Propagation)的深度学习方法开始兴起。许多之前由于缺乏数据、计算能力以及有效优化方法而被忽视的神经网络模型得到了复兴。例如 1997 年就已提出的长短时记忆网络(Long Short Term Memory,LSTM)模型在重新被启用后在许多任务上大放异彩。
延伸即使在 Transformer 模型几乎“一统江湖”的今天,LSTM 模型依然占有一席之地。2024 年 5 月 8 日,LSTM 提出者和奠基者 Sepp Hochreiter 公布了 LSTM 模型的改良版本——xLSTM,在性能和扩展方面都得到了显著提升。论文的所属机构中还出现了一家叫做 NXAI 的公司,Sepp Hochreiter 表示:“借助 xLSTM,我们缩小了与现有最先进大语言模型的差距。借助 NXAI,我们已开始构建欧洲自己的大语言模型。”
随着越来越多研究者将注意力转向深度学习方法,诸如卷积神经网络(Convolutional Neural Networks,CNN)等模型被广泛地应用到各种自然语言处理任务中。2017 年,Google 公司提出了 Attention 注意力模型,论文中提出的 Transformer 结构更是引领了后续神经网络语言模型的发展。
得益于抛弃了让计算机简单模仿人类的思路,这一阶段自然语言处理研究出现了蓬勃发展。今天可以说已经没有人再会质疑统计方法在自然语言处理上的可行性。
1.2 统计语言模型发展史
要让计算机处理自然语言,首先需要为自然语言建立数学模型,这种模型被称为“统计语言模型”,其核心思想是判断一个文字序列是否构成人类能理解并且有意义的句子。这个问题曾经困扰了学术界很多年。
统计语言模型
20 世纪 70 年代之前,研究者尝试从文字序列是否合乎文法、含义是否正确的角度来建立语言模型。最终,随着人工编写出的规则数量越来越多、形式越来越复杂,对语言模型的研究陷入瓶颈。直到 20 世纪 70 年代中期,IBM 实验室的贾里尼克(Jelinek)为了研究语音识别问题换了一个思路,用一个简单的统计模型就解决了这个问题。
图 1-4 佛里德里克·贾里尼克(Frederick Jelinek)
贾里尼克的想法是要判断一个文字序列 w1,w2,…,wn 是否合理,就计算这个句子 S 出现的概率 P(S),出现概率越大句子就越合理:
(1.1)P(S)=P(w1,w2,...,wn)=P(w1)P(w2|w1)P(w3|w1,w2)...P(wn|w1,w2,...,wn−1)
其中,词语 wn 出现的概率取决于在句子中出现在它之前的所有词(理论上也可以引入出现在它之后的词语)。但是,随着文本长度的增加,条件概率 P(wn∣w1,w2,…,wn−1) 会变得越来越难以计算,因而在实际计算时会假设每个词语 wi 仅与它前面的 N−1 个词语有关,即:
P(wi|w1,w2,...,wi−1)=P(wi|wi−N+1,wi−N+2,...,wi−1)
这种假设被称为马尔可夫(Markov)假设,对应的语言模型被称为 N 元(N-gram)模型。例如当 N=2 时,词语 wi 出现的概率只与它前面的词语 wi−1 有关,被称为二元(Bigram)模型;而 N=1 时,模型实际上就是一个上下文无关模型。由于 N 元模型的空间和时间复杂度都几乎是 N 的指数函数,因此实际应用中比较常见的是取 N=3 的三元模型。
延伸即使是使用三元、四元甚至是更高阶的语言模型,依然无法覆盖所有的语言现象。在自然语言中,上下文之间的关联性可能跨度非常大,例如从一个段落跨到另一个段落,这是马尔可夫假设解决不了的。此时就需要使用 LSTM、Transformer 等模型来捕获词语之间的远距离依赖(Long Distance Dependency)了。
NNLM 模型
2003 年,本吉奥(Bengio)提出了神经网络语言模型(Neural Network Language Model,NNLM)。可惜它生不逢时,由于神经网络在当时并不被人们看好,在之后的十年中 NNLM 模型都没有引起很大关注。
图 1-5 约书亚·本吉奥(Yoshua Bengio)
直到 2013 年,随着越来越多的研究者使用深度学习模型来处理自然语言,NNLM 模型才被重新发掘,并成为使用神经网络建模语言的经典范例。NNLM 模型的思路与统计语言模型保持一致,它通过输入词语前面的 N−1 个词语来预测当前词。模型结构如图 1-6 所示。
图 1-6 神经网络语言模型(NNLM)的结构
具体来说,NNLM 模型首先从词表 C 中查询得到前面 N−1 个词语对应的词向量 C(wt−n+1),…,C(wt−2),C(wt−1),然后将这些词向量拼接后输入到带有激活函数的隐藏层中,通过 Softmax 函数预测当前词语的概率。特别地,包含所有词向量的词表矩阵 C 也是模型的参数,需要通过学习获得。因此 NNLM 模型不仅能够能够根据上文预测当前词语,同时还能够给出所有词语的词向量(Word Embedding)。
Word2Vec 模型
真正将神经网络语言模型发扬光大的是 2013 年 Google 公司提出的 Word2Vec 模型。Word2Vec 模型提供的词向量在很长一段时间里都是自然语言处理方法的标配,即使是后来出现的 Glove 模型也难掩它的光芒。
Word2Vec 的模型结构和 NNLM 基本一致,只是训练方法有所不同,分为 CBOW (Continuous Bag-of-Words) 和 Skip-gram 两种,如图 1-7 所示。
图 1-7 Word2Vec 模型的训练方法
其中 CBOW 使用周围的词语 w(t−2),w(t−1),w(t+1),w(t+2) 来预测当前词 w(t),而 Skip-gram 则正好相反,它使用当前词 w(t) 来预测它的周围词语。
可以看到,与严格按照统计语言模型结构设计的 NNLM 模型不同,Word2Vec 模型在结构上更加自由,训练目标也更多地是为获得词向量服务。特别是同时通过上文和下文来预测当前词语的 CBOW 训练方法打破了语言模型“只通过上文来预测当前词”的固定思维,为后续一系列神经网络语言模型的发展奠定了基础。
然而,有一片乌云一直笼罩在 Word2Vec 模型的上空——多义词问题。一词多义是语言灵活性和高效性的体现,但是 Word2Vec 模型却无法处理多义词,一个词语无论表达何种语义,Word2Vec 模型都只能提供相同的词向量,即将多义词编码到了完全相同的参数空间。实际上在 20 世纪 90 年代初,雅让斯基(Yarowsky)就给出了一个简洁有效的解决方案——运用词语之间的互信息(Mutual Information)。
图 1-8 大卫·雅让斯基(David Yarowsky)
具体来说,对于多义词,可以使用文本中与其同时出现的互信息最大的词语集合来表示不同的语义。例如对于“苹果”,当表示水果时,周围出现的一般就是“超市”、“香蕉”等词语;而表示“苹果公司”时,周围出现的一般就是“手机”、“平板”等词语,如图 1-9 所示。
图 1-9 运用互信息解决多义词问题
因此,在判断多义词究竟表达何种语义时,只需要查看哪个语义对应集合中的词语在上下文中出现的更多就可以了,即通过上下文来判断语义。
延伸1948 年,香农(Claude Elwood Shannon)在他著名的论文《通信的数学原理》中提出了“信息熵”(Information Entropy)的概念,解决了信息的度量问题,并且量化出信息的作用。上面提到的互信息就来自于信息论。如果你对此感兴趣,可以阅读《信息的度量和作用:信息论基本概念》。
后来自然语言处理的标准流程就是先将 Word2Vec 模型提供的词向量作为模型的输入,然后通过 LSTM、CNN 等模型结合上下文对句子中的词语重新进行编码,以获得包含上下文信息的词语表示。
ELMo 模型
为了更好地解决多义词问题,2018 年研究者提出了 ELMo 模型(Embeddings from Language Models)。与 Word2Vec 模型只能提供静态词向量不同,ELMo 模型会根据上下文动态地调整词语的词向量。
具体来说,ELMo 模型首先对语言模型进行预训练,使得模型掌握编码文本的能力;然后在实际使用时,对于输入文本中的每一个词语,都提取模型各层中对应的词向量拼接起来作为新的词向量。ELMo 模型采用双层双向 LSTM 作为编码器,如图 1-10 所示,从两个方向编码词语的上下文信息,相当于将编码层直接封装到了语言模型中。
图 1-10 ELMo 模型的结构
训练完成后 ELMo 模型不仅学习到了词向量,还训练好了一个双层双向的 LSTM 编码器。对于输入文本中的词语,可以从第一层 LSTM 中得到包含句法信息的词向量,从第二层 LSTM 中得到包含语义信息的词向量,最终通过加权求和得到每一个词语最终的词向量。
但是 ELMo 模型存在两个缺陷:首先它使用 LSTM 模型作为编码器,而不是当时已经提出的编码能力更强的 Transformer 模型;其次 ELMo 模型直接通过拼接来融合双向抽取特征的做法也略显粗糙。
不久之后,将 ELMo 模型中的 LSTM 更换为 Transformer 的 GPT 模型就出现了。但是 GPT 模型再次追随了 NNLM 的脚步,只通过词语的上文进行预测,这在很大程度上限制了模型的应用场景。例如对于文本分类、阅读理解等任务,如果不把词语的下文信息也嵌入到词向量中就会白白丢掉很多信息。
BERT 模型
2018 年底随着 BERT 模型(Bidirectional Encoder Representations from Transformers)的出现,这一阶段神经网络语言模型的发展终于出现了一位集大成者,发布时 BERT 模型在 11 个任务上都取得了最好性能。
BERT 模型采用和 GPT 模型类似的两阶段框架,首先对语言模型进行预训练,然后通过微调来完成下游任务。但是,BERT 不仅像 GPT 模型一样采用 Transformer 作为编码器,而且采用了类似 ELMo 模型的双向语言模型结构,如图 1-11 所示。因此 BERT 模型不仅编码能力强大,而且对各种下游任务,BERT 模型都可以通过简单地改造输出部分来完成。
图 1-11 BERT 模型的结构
但是 BERT 模型的优点同样也是它的缺陷,由于 BERT 模型采用双向语言模型结构,因而无法直接用于生成文本。
可以看到,从 2003 年 NNLM 模型提出时的无人问津,到 2018 年底 BERT 模型横扫自然语言处理领域,神经网络语言模型的发展也经历了一波三折。在此期间,研究者一直在不断地对前人的工作进行改进,这才取得了 BERT 模型的成功。BERT 模型的出现并非一蹴而就,它不仅借鉴了 ELMo、GPT 等模型的结构与框架,而且延续了 Word2Vec 模型提出的 CBOW 训练方式的思想,可以看作是这一阶段语言模型发展的集大成者。
在 BERT 模型取得成功之后,研究者并没有停下脚步,在 BERT 模型的基础上又提出了诸如 MASS、ALBERT、RoBERTa 等改良模型。其中具有代表性的就是微软提出的 UNILM 模型(UNIfied pretrained Language Model),它把 BERT 模型的 MASK 机制运用到了一个很高的水平,如图 1-12 所示。
图 1-12 UNILM 模型的结构
具体来说,UNILM 模型通过给 Transformer 中的 Self-Attention 机制添加不同的 MASK 矩阵,在不改变 BERT 模型结构的基础上同时实现了双向、单向和序列到序列(Sequence-to-Sequence,Seq2Seq)语言模型,是一种对 BERT 模型进行扩展的优雅方案。
大语言模型
除了优化模型结构,研究者发现扩大模型规模也可以提高性能。在保持模型结构以及预训练任务基本不变的情况下,仅仅通过扩大模型规模就可以显著增强模型能力,尤其当规模达到一定程度时,模型甚至展现出了能够解决未见过复杂问题的涌现(Emergent Abilities)能力。例如 175B 规模的 GPT-3 模型只需要在输入中给出几个示例,就能通过上下文学习(In-context Learning)完成各种小样本(Few-Shot)任务,而这是 1.5B 规模的 GPT-2 模型无法做到的。
图 1-13 近年来发布的一些大语言模型(10B 规模以上)
在规模扩展定律(Scaling Laws)被证明对语言模型有效之后,研究者基于 Transformer 结构不断加深模型深度,构建出了许多大语言模型,如图 1-13 所示。
一个标志性的事件是 2022 年 11 月 30 日 OpenAI 公司发布了面向普通消费者的 ChatGPT 模型(Chat Generative Pre-trained Transformer),它能够记住先前的聊天内容真正像人类一样交流,甚至能撰写诗歌、论文、文案、代码等。发布后,ChatGPT 模型引起了巨大轰动,上线短短 5 天注册用户数就超过 100 万。2023 年一月末,ChatGPT 活跃用户数量已经突破 1 亿,成为史上增长最快的消费者应用。
下面本章将按照模型规模介绍一些可供开发者使用的大语言模型。首先是数百亿参数的大语言模型:
- Flan-T5(11B):指令微调(Instruction Tuning)研究领域的代表性模型,通过扩大任务数量、扩大模型规模以及在思维链提示(Chain-of-Thought Prompting)数据上进行微调探索了指令微调技术的应用;
- CodeGen 以及 CodeGen2(11B):为生成代码而设计的自回归(Autoregressive)语言模型,是探索大语言模型代码生成能力的一个代表性模型;
- mT0(13B):多语言(Multilingual)大语言模型的代表,使用多语言提示在多语言任务上进行了微调;
- Baichuan 以及 Baichuan-2(7B):百川智能公司开发的大语言模型,支持中英双语,在多个中英文基准测试上取得优异性能;
- PanGu-(13B):华为公司开发的中文大语言模型,在零样本(Zero-Shot)和小样本(Few-Shot)设置下展现出了优异的性能;
α
- Qwen(72B):阿里巴巴公司开源的多语言大模型,在语言理解、推理、数学等方面均展现出了优秀的模型能力,还为代码、数学和多模态设计了专业化版本 Code-Qwen、Math-Qwen、Qwen-VL 等可供用户使用;
- LLaMA 以及 LLaMA-2(65B):在一系列指令遵循(Instruction Following)任务中展现出卓越性能。由于 LLaMA 模型的开放性和有效性,吸引了许多研究者在其之上指令微调或继续预训练不同的模型版本,例如 Stanford Alpaca 模型、Vicuna 模型等,如图 1-14 所示。
- Mixtral(46.7B):稀疏混合专家模型架构的大语言模型,这也是较早对外公开的 MoE 架构的语言模型,其处理速度和资源消耗与 12.9B 参数的模型相当,在 MT-bench 基准上取得了与 GPT-3.5 相当的性能表现;
图 1-14 LLaMA 模型家族
然后是数千亿计参数规模的大语言模型:
- OPT(175B)以及指令微调版本 OPT-IML:致力于开放共享,使得研究者可以对大规模模型进行可复现的研究;
- BLOOM 以及 BLOOMZ(176B):跨语言泛化(Cross-Lingual Generalization)研究领域的代表性模型,具有多语言建模的能力;
- GLM:双语大语言模型,其小规模中文聊天版本 ChatGLM2-6B 在中文任务研究中十分流行,在效率和容量方面有许多改进,支持量化(Quantization)、32K 长度的上下文、快速推理等。
第二章:Transformer 模型
正如上一章所说,自从 BERT 和 GPT 模型取得重大成功之后, Transformer 结构已经替代了循环神经网络 (RNN) 和卷积神经网络 (CNN),成为了当前 NLP 模型的标配。
本章将简单介绍 Transformer 模型的定义及发展,希望它可以成为你探究 Transformer 的地图。
起源与发展
2017 年 Google 在《Attention Is All You Need》中提出了 Transformer 结构用于序列标注,在翻译任务上超过了之前最优秀的循环神经网络模型;与此同时,Fast AI 在《Universal Language Model Fine-tuning for Text Classification》中提出了一种名为 ULMFiT 的迁移学习方法,将在大规模数据上预训练好的 LSTM 模型迁移用于文本分类,只用很少的标注数据就达到了最佳性能。
这些具有开创性的工作促成了两个著名 Transformer 模型的出现:
- GPT (the Generative Pretrained Transformer);
- BERT (Bidirectional Encoder Representations from Transformers)。
通过将 Transformer 结构与无监督学习相结合,我们不再需要对每一个任务都从头开始训练模型,并且几乎在所有 NLP 任务上都远远超过先前的最强基准。
GPT 和 BERT 被提出之后,NLP 领域出现了越来越多基于 Transformer 结构的模型,其中比较有名有:
虽然新的 Transformer 模型层出不穷,它们采用不同的预训练目标在不同的数据集上进行训练,但是依然可以按模型结构将它们大致分为三类:
- 纯 Encoder 模型(例如 BERT),又称自编码 (auto-encoding) Transformer 模型;
- 纯 Decoder 模型(例如 GPT),又称自回归 (auto-regressive) Transformer 模型;
- Encoder-Decoder 模型(例如 BART、T5),又称 Seq2Seq (sequence-to-sequence) Transformer 模型。
本章下面会对这三种模型框架进行更详细的介绍。
什么是 Transformer
语言模型
Transformer 模型本质上都是预训练语言模型,大都采用自监督学习 (Self-supervised learning) 的方式在大量生语料上进行训练,也就是说,训练这些 Transformer 模型完全不需要人工标注数据。
自监督学习是一种训练目标可以根据模型的输入自动计算的训练方法。
例如下面两个常用的预训练任务:
- 基于句子的前 n 个词来预测下一个词,因为输出依赖于过去和当前的输入,因此该任务被称为因果语言建模 (causal language modeling);
- 基于上下文(周围的词语)来预测句子中被遮盖掉的词语 (masked word),因此该任务被称为遮盖语言建模 (masked language modeling)。
这些语言模型虽然可以对训练过的语言产生统计意义上的理解,例如可以根据上下文预测被遮盖掉的词语,但是如果直接拿来完成特定任务,效果往往并不好。
回忆一下,“因果语言建模”就是上一章中说的统计语言模型,只使用前面的词来预测当前词,由 NNLM 首次运用;而“遮盖语言建模”实际上就是 Word2Vec 模型提出的 CBOW。
因此,我们通常还会采用迁移学习 (transfer learning) 方法,使用特定任务的标注语料,以有监督学习的方式对预训练模型参数进行微调 (fine-tune),以取得更好的性能。
大模型与碳排放
除了 DistilBERT 等少数模型,大部分 Transformer 模型都为了取得更好的性能而不断地增加模型大小(参数量)和增加预训练数据。下图展示了近年来模型大小的变化趋势:
但是,从头训练一个预训练语言模型,尤其是大模型,需要海量的数据,不仅时间和计算成本非常高,对环境的影响也很大:
可以想象,如果每一次研究者或是公司想要使用语言模型,都需要基于海量数据从头训练,将耗费巨大且不必要的全球成本,因此共享语言模型非常重要。只要在预训练好的模型权重上构建模型,就可以大幅地降低计算成本和碳排放。
现在也有一些工作致力于在尽可能保持模型性能的情况下大幅减少参数量,达到用“小模型”获得媲美“大模型”的效果(例如模型蒸馏)。
迁移学习
前面已经讲过,预训练是一种从头开始训练模型的方式:所有的模型权重都被随机初始化,然后在没有任何先验知识的情况下开始训练:
这个过程不仅需要海量的训练数据,而且时间和经济成本都非常高。
因此,大部分情况下,我们都不会从头训练模型,而是将别人预训练好的模型权重通过迁移学习应用到自己的模型中,即使用自己的任务语料对模型进行“二次训练”,通过微调参数使模型适用于新任务。
这种迁移学习的好处是:
- 预训练时模型很可能已经见过与我们任务类似的数据集,通过微调可以激发出模型在预训练过程中获得的知识,将基于海量数据获得的统计理解能力应用于我们的任务;
- 由于模型已经在大量数据上进行过预训练,微调时只需要很少的数据量就可以达到不错的性能;
- 换句话说,在自己任务上获得优秀性能所需的时间和计算成本都可以很小。
例如,我们可以选择一个在大规模英文语料上预训练好的模型,使用 arXiv 语料进行微调,以生成一个面向学术/研究领域的模型。这个微调的过程只需要很少的数据:我们相当于将预训练模型已经获得的知识“迁移”到了新的领域,因此被称为迁移学习。
与从头训练相比,微调模型所需的时间、数据、经济和环境成本都要低得多,并且与完整的预训练相比,微调训练的约束更少,因此迭代尝试不同的微调方案也更快、更容易。实践证明,即使是对于自定义任务,除非你有大量的语料,否则相比训练一个专门的模型,基于预训练模型进行微调会是一个更好的选择。
在绝大部分情况下,我们都应该尝试找到一个尽可能接近我们任务的预训练模型,然后微调它,也就是所谓的“站在巨人的肩膀上”。
Transformer 的结构
标准的 Transformer 模型主要由两个模块构成:
- Encoder(左边):负责理解输入文本,为每个输入构造对应的语义表示(语义特征);
- Decoder(右边):负责生成输出,使用 Encoder 输出的语义表示结合其他输入来生成目标序列。
这两个模块可以根据任务的需求而单独使用:
- 纯 Encoder 模型:适用于只需要理解输入语义的任务,例如句子分类、命名实体识别;
- 纯 Decoder 模型:适用于生成式任务,例如文本生成;
- Encoder-Decoder 模型或 Seq2Seq 模型:适用于需要基于输入的生成式任务,例如翻译、摘要。
本章后面会具体地介绍每一种框架。
注意力层
Transformer 模型的标志就是采用了注意力层 (Attention Layers) 的结构,前面也说过,提出 Transformer 结构的论文名字就叫《Attention Is All You Need》。顾名思义,注意力层的作用就是让模型在处理文本时,将注意力只放在某些词语上。
例如要将英文“You like this course”翻译为法语,由于法语中“like”的变位方式因主语而异,因此需要同时关注相邻的词语“You”。同样地,在翻译“this”时还需要注意“course”,因为“this”的法语翻译会根据相关名词的极性而变化。对于复杂的句子,要正确翻译某个词语,甚至需要关注离这个词很远的词。
同样的概念也适用于其他 NLP 任务:虽然词语本身就有语义,但是其深受上下文的影响,同一个词语出现在不同上下文中可能会有完全不同的语义(例如“我买了一个苹果”和“我买了一个苹果手机”中的“苹果”)。
我们在上一章中已经讨论过多义词的问题,这也是 Word2Vec 这些静态模型所解决不了的。
原始结构
Transformer 模型本来是为了翻译任务而设计的。在训练过程中,Encoder 接受源语言的句子作为输入,而 Decoder 则接受目标语言的翻译作为输入。在 Encoder 中,由于翻译一个词语需要依赖于上下文,因此注意力层可以访问句子中的所有词语;而 Decoder 是顺序地进行解码,在生成每个词语时,注意力层只能访问前面已经生成的单词。
例如,假设翻译模型当前已经预测出了三个词语,我们会把这三个词语作为输入送入 Decoder,然后 Decoder 结合 Encoder 所有的源语言输入来预测第四个词语。
实际训练中为了加快速度,会将整个目标序列都送入 Decoder,然后在注意力层中通过 Mask 遮盖掉未来的词语来防止信息泄露。例如我们在预测第三个词语时,应该只能访问到已生成的前两个词语,如果 Decoder 能够访问到序列中的第三个(甚至后面的)词语,就相当于作弊了。
原始的 Transformer 模型结构如下图所示,Encoder 在左,Decoder 在右:
其中,Decoder 中的第一个注意力层关注 Decoder 过去所有的输入,而第二个注意力层则是使用 Encoder 的输出,因此 Decoder 可以基于整个输入句子来预测当前词语。这对于翻译任务非常有用,因为同一句话在不同语言下的词语顺序可能并不一致(不能逐词翻译),所以出现在源语言句子后部的词语反而可能对目标语言句子前部词语的预测非常重要。
在 Encoder/Decoder 的注意力层中,我们还会使用 Attention Mask 遮盖掉某些词语来防止模型关注它们,例如为了将数据处理为相同长度而向序列中添加的填充 (padding) 字符。
Transformer 家族
虽然新的 Transformer 模型层出不穷,但是它们依然可以被归纳到以下三种结构中:
Encoder 分支
纯 Encoder 模型只使用 Transformer 模型中的 Encoder 模块,也被称为自编码 (auto-encoding) 模型。在每个阶段,注意力层都可以访问到原始输入句子中的所有词语,即具有“双向 (Bi-directional)”注意力。
纯 Encoder 模型通常通过破坏给定的句子(例如随机遮盖其中的词语),然后让模型进行重构来进行预训练,最适合处理那些需要理解整个句子语义的任务,例如句子分类、命名实体识别(词语分类)、抽取式问答。
BERT 是第一个基于 Transformer 结构的纯 Encoder 模型,它在提出时横扫了整个 NLP 界,在流行的 GLUE 基准上超过了当时所有的最强模型。随后的一系列工作对 BERT 的预训练目标和架构进行调整以进一步提高性能。目前,纯 Encoder 模型依然在 NLP 行业中占据主导地位。
下面简略介绍一下 BERT 模型及它的常见变体:
- BERT:通过预测文本中被遮盖的词语和判断一个文本是否跟随另一个来进行预训练,前一个任务被称为遮盖语言建模 (Masked Language Modeling, MLM),后一个任务被称为下句预测 (Next Sentence Prediction, NSP);
- DistilBERT:尽管 BERT 性能优异,但它的模型大小使其难以部署在低延迟需求的环境中。 通过在预训练期间使用知识蒸馏 (knowledge distillation) 技术,DistilBERT 在内存占用减少 40%、计算速度提高 60% 的情况下,依然可以保持 97% 的性能;
- RoBERTa:BERT 之后的一项研究表明,通过修改预训练方案可以进一步提高性能。 RoBERTa 在更多的训练数据上,以更大的批次训练了更长的时间,并且放弃了 NSP 任务。与 BERT 模型相比,这些改变显著地提高了模型的性能;
- XLM:跨语言语言模型 (XLM) 探索了构建多语言模型的多个预训练目标,包括来自 GPT 的自回归语言建模和来自 BERT 的 MLM,还将 MLM 拓展到多语言输入,提出了翻译语言建模 (Translation Language Modeling, TLM)。XLM 在多个多语言 NLU 基准和翻译任务上都取得了最好的性能;
- XLM-RoBERTa:跟随 XLM 和 RoBERTa,XLM-RoBERTa (XLM-R) 通过升级训练数据来改进多语言预训练。其基于 Common Crawl 创建了一个 2.5 TB 的语料,然后运用 MLM 训练编码器,由于没有平行对照文本,因此移除了 XLM 的 TLM 目标。最终,该模型大幅超越了 XLM 和多语言 BERT 变体;
- ALBERT:ALBERT 通过三处变化使得 Encoder 架构更高效:首先将词嵌入维度与隐藏维度解耦以减少模型参数;其次所有模型层共享参数;最后将 NSP 任务替换为句子排序预测(判断句子顺序是否被交换)。这些变化使得可以用更少的参数训练更大的模型,并在 NLU 任务上取得了优异的性能;
- ELECTRA:MLM 在每个训练步骤中只有被遮盖掉词语的表示会得到更新。ELECTRA 使用了一种双模型方法来解决这个问题:第一个模型继续按标准 MLM 工作;第二个模型(鉴别器)则预测第一个模型的输出中哪些词语是被遮盖的,这使得训练效率提高了 30 倍。下游任务使用时,鉴别器也参与微调;
- DeBERTa:DeBERTa 模型引入了两处架构变化。首先将词语的内容与相对位置分离,使得自注意力层 (Self-Attention) 层可以更好地建模邻近词语对的依赖关系;此外在解码头的 softmax 层之前添加了绝对位置嵌入。DeBERTa 是第一个在 SuperGLUE 基准上击败人类的模型。
Decoder 分支
纯 Decoder 模型只使用 Transformer 模型中的 Decoder 模块。在每个阶段,对于给定的词语,注意力层只能访问句子中位于它之前的词语,即只能迭代地基于已经生成的词语来逐个预测后面的词语,因此也被称为自回归 (auto-regressive) 模型。
纯 Decoder 模型的预训练通常围绕着预测句子中下一个单词展开。纯 Decoder 模型适合处理那些只涉及文本生成的任务。
对 Transformer Decoder 模型的探索在在很大程度上是由 OpenAI 带头进行的,通过使用更大的数据集进行预训练,以及将模型的规模扩大,纯 Decoder 模型的性能也在不断提高。
下面就简要介绍一些常见的生成模型:
- GPT:结合了 Transformer Decoder 架构和迁移学习,通过根据上文预测下一个单词的预训练任务,在 BookCorpus 数据集上进行了预训练。GPT 模型在分类等下游任务上取得了很好的效果;
- GPT-2:受简单且可扩展的预训练方法的启发,OpenAI 通过扩大原始模型和训练集创造了 GPT-2,它能够生成篇幅较长且语义连贯的文本;
- CTRL:GPT-2 虽然可以根据模板 (prompt) 续写文本,但是几乎无法控制生成序列的风格。条件 Transformer 语言模型 (Conditional Transformer Language, CTRL) 通过在序列开头添加特殊的“控制符”以控制生成文本的风格,这样只需要调整控制符就可以生成多样化的文本;
- GPT-3:将 GPT-2 进一步放大 100 倍,GPT-3 具有 1750 亿个参数。除了能生成令人印象深刻的真实篇章之外,还展示了小样本学习 (few-shot learning) 的能力。这个模型目前没有开源;
- GPT-Neo / GPT-J-6B:由于 GPT-3 没有开源,因此一些旨在重新创建和发布 GPT-3 规模模型的研究人员组成了 EleutherAI,训练出了类似 GPT 的 GPT-Neo 和 GPT-J-6B 。当前公布的模型具有 1.3、2.7、60 亿个参数,在性能上可以媲美较小版本的 GPT-3 模型。
Encoder-Decoder 分支
Encoder-Decoder 模型(又称 Seq2Seq 模型)同时使用 Transformer 架构的两个模块。在每个阶段,Encoder 的注意力层都可以访问初始输入句子中的所有单词,而 Decoder 的注意力层则只能访问输入中给定词语之前的词语(即已经解码生成的词语)。
Encoder-Decoder 模型可以使用 Encoder 或 Decoder 模型的目标来完成预训练,但通常会包含一些更复杂的任务。例如,T5 通过随机遮盖掉输入中的文本片段进行预训练,训练目标则是预测出被遮盖掉的文本。Encoder-Decoder 模型适合处理那些需要根据给定输入来生成新文本的任务,例如自动摘要、翻译、生成式问答。
下面简单介绍一些在自然语言理解 (NLU) 和自然语言生成 (NLG) 领域的 Encoder-Decoder 模型:
- T5:将所有 NLU 和 NLG 任务都转换为 Seq2Seq 形式统一解决(例如,文本分类就是将文本送入 Encoder,然后 Decoder 生成文本形式的标签)。T5 通过 MLM 及将所有 SuperGLUE 任务转换为 Seq2Seq 任务来进行预训练。最终,具有 110 亿参数的大版本 T5 在多个基准上取得了最优性能。
- BART:同时结合了 BERT 和 GPT 的预训练过程。将输入句子通过遮盖词语、打乱句子顺序、删除词语、文档旋转等方式破坏后传给 Encoder 编码,然后要求 Decoder 能够重构出原始的文本。这使得模型可以灵活地用于 NLU 或 NLG 任务,并且在两者上都实现了最优性能。
- M2M-100:语言对之间可能存在共享知识可以用来处理小众语言之间的翻译。M2M-100 是第一个可以在 100 种语言之间进行翻译的模型,并且对小众的语言也能生成高质量的翻译。该模型使用特殊的前缀标记来指示源语言和目标语言。
- BigBird:由于注意力机制 的内存要求,Transformer 模型只能处理一定长度内的文本。 BigBird 通过使用线性扩展的稀疏注意力形式,将可处理的文本长度从大多数模型的 512 扩展到 4096,这对于处理文本摘要等需要捕获长距离依赖的任务特别有用。
O(n2)
第三章:注意力机制
正如在前两章所说,自从 2017 年 Google 发布《Attention is All You Need》之后,各种基于 Transformer 的模型和方法层出不穷。尤其是 2018 年,OpenAI 发布的 GPT 和 Google 发布的 BERT 模型在几乎所有 NLP 任务上都取得了远超先前最强基准的性能,将 Transformer 模型的热度推上了新的高峰。
Transformer 模型之所以如此强大,是因为它抛弃了之前广泛采用的循环网络和卷积网络,而采用了一种特殊的结构——注意力机制 (Attention) 来建模文本。
本章将向大家介绍目前最常见的 Multi-Head Attention,并使用 Pytorch 框架实现一个 Transformer block。
如果你不熟悉 Pytorch 可以跳过本章的代码部分,后学借助于 Transformers 库,我们可以非常方便地调用任何 Transformer 模型,而不必像本章一样手工编写。
Attention
NLP 神经网络模型的本质就是对输入文本进行编码,常规的做法是首先对句子进行分词,然后将每个词语 (token) 都转化为对应的词向量 (token embeddings),这样文本就转换为一个由词语向量组成的矩阵 X=(x1,x2,…,xn),其中 xi 就表示第 i 个词语的词向量,维度为 d,故 X∈Rn×d。
在 Transformer 模型提出之前,对 token 序列 X 的常规编码方式是通过循环网络 (RNNs) 和卷积网络 (CNNs)。
- RNN(例如 LSTM)的方案很简单,每一个词语 xt 对应的编码结果 yt 通过递归地计算得到:
yt=f(yt−1,xt)
RNN 的序列建模方式虽然与人类阅读类似,但是递归的结构导致其无法并行计算,因此速度较慢。而且 RNN 本质是一个马尔科夫决策过程,难以学习到全局的结构信息;
- CNN 则通过滑动窗口基于局部上下文来编码文本,例如核尺寸为 3 的卷积操作就是使用每一个词自身以及前一个和后一个词来生成嵌入式表示:
yt=f(xt−1,xt,xt+1)
CNN 能够并行地计算,因此速度很快,但是由于是通过窗口来进行编码,所以更侧重于捕获局部信息,难以建模长距离的语义依赖。
Google《Attention is All You Need》提供了第三个方案:直接使用 Attention 机制编码整个文本。相比 RNN 要逐步递归才能获得全局信息(因此一般使用双向 RNN),而 CNN 实际只能获取局部信息,需要通过层叠来增大感受野,Attention 机制一步到位获取了全局信息:
yt=f(xt,A,B)
其中 A,B 是另外的词语序列(矩阵),如果取 A=B=X 就称为 Self-Attention,即直接将 xt 与自身序列中的每个词语进行比较,最后算出 yt。
Scaled Dot-product Attention
虽然 Attention 有许多种实现方式,但是最常见的还是 Scaled Dot-product Attention。
Scaled Dot-product Attention 共包含 2 个主要步骤:
- 计算注意力权重:使用某种相似度函数度量每一个 query 向量和所有 key 向量之间的关联程度。对于长度为 m 的 Query 序列和长度为 n 的 Key 序列,该步骤会生成一个尺寸为 m×n 的注意力分数矩阵。
特别地,Scaled Dot-product Attention 使用点积作为相似度函数,这样相似的 queries 和 keys 会具有较大的点积。
由于点积可以产生任意大的数字,这会破坏训练过程的稳定性。因此注意力分数还需要乘以一个缩放因子来标准化它们的方差,然后用一个 softmax 标准化。这样就得到了最终的注意力权重 wij,表示第 i 个 query 向量与第 j 个 key 向量之间的关联程度。
- 更新 token embeddings:将权重 wij 与对应的 value 向量 v1,…,vn 相乘以获得第 i 个 query 向量更新后的语义表示 xi′=∑jwijvj。
形式化表示为:
(4)Attention(Q,K,V)=softmax(QK⊤dk)V
其中 Q∈Rm×dk,K∈Rn×dk,V∈Rn×dv 分别是 query、key、value 向量序列。如果忽略 softmax 激活函数,实际上它就是三个 m×dk,dk×n,n×dv 矩阵相乘,得到一个 m×dv 的矩阵,也就是将 m×dk 的序列 Q 编码成了一个新的 m×dv 的序列。
将上面的公式拆开来看更加清楚:
(5)Attention(qt,K,V)=∑s=1n1Zexp(⟨qt,ks⟩dk)vs
其中 Z 是归一化因子,K,V 是一一对应的 key 和 value 向量序列,Scaled Dot-product Attention 就是通过 qt 这个 query 与各个 ks 内积并 softmax 的方式来得到 qt 与各个 vs 的相似度,然后加权求和,得到一个 dv 维的向量。其中因子 dk 起到调节作用,使得内积不至于太大。
下面我们通过 Pytorch 来手工实现 Scaled Dot-product Attention:
首先需要将文本分词为词语 (token) 序列,然后将每一个词语转换为对应的词向量 (embedding)。Pytorch 提供了 torch.nn.Embedding 层来完成该操作,即构建一个从 token ID 到 token embedding 的映射表:
from torch import nn
from transformers import AutoConfig
from transformers import AutoTokenizer
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
text = "time flies like an arrow"
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
print(inputs.input_ids)
config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
print(token_emb)
inputs_embeds = token_emb(inputs.input_ids)
print(inputs_embeds.size()) tensor([[ 2051, 10029, 2066, 2019, 8612]])
Embedding(30522, 768)
torch.Size([1, 5, 768]) 为了演示方便,这里我们通过设置 add_special_tokens=False 去除了分词结果中的 [CLS] 和 [SEP]。
可以看到,BERT-base-uncased 模型对应的词表大小为 30522,每个词语的词向量维度为 768。Embedding 层把输入的词语序列映射到了尺寸为 [batch_size, seq_len, hidden_dim] 的张量。
接下来就是创建 query、key、value 向量序列 Q,K,V,并且使用点积作为相似度函数来计算注意力分数:
import torch
from math import sqrt
Q = K = V = inputs_embeds
dim_k = K.size(-1)
scores = torch.bmm(Q, K.transpose(1,2)) / sqrt(dim_k)
print(scores.size())
torch.Size([1, 5, 5]) 这里 Q,K 的序列长度都为 5,因此生成了一个 5×5 的注意力分数矩阵,接下来就是应用 Softmax 标准化注意力权重:
import torch.nn.functional as F
weights = F.softmax(scores, dim=-1)
print(weights.sum(dim=-1))
tensor([[1., 1., 1., 1., 1.]], grad_fn=<SumBackward1>) 最后将注意力权重与 value 序列相乘:
attn_outputs = torch.bmm(weights, V)
print(attn_outputs.shape)`
torch.Size([1, 5, 768]) 至此就实现了一个简化版的 Scaled Dot-product Attention。可以将上面这些操作封装为函数以方便后续调用:
import torch
import torch.nn.functional as F
from math import sqrt
def scaled_dot_product_attention(query, key, value, query_mask=None, key_mask=None, mask=None):
dim_k = query.size(-1)
scores = torch.bmm(query, key.transpose(1, 2)) / sqrt(dim_k)
if query_mask is not None and key_mask is not None:
mask = torch.bmm(query_mask.unsqueeze(-1), key_mask.unsqueeze(1))
if mask is not None:
scores = scores.masked_fill(mask == 0, -float("inf"))
weights = F.softmax(scores, dim=-1)
return torch.bmm(weights, value) 上面的代码还考虑了 Q,K,V 序列的 Mask。填充 (padding) 字符不应该参与计算,因此将对应的注意力分数设置为 −∞,这样 softmax 之后其对应的注意力权重就为 0 了(e−∞=0)。
注意!上面的做法会带来一个问题:当 Q 和 K 序列相同时,注意力机制会为上下文中的相同单词分配非常大的分数(点积为 1),而在实践中,相关词往往比相同词更重要。例如对于上面的例子,只有关注“time”和“arrow”才能够确认“flies”的含义。
因此,多头注意力 (Multi-head Attention) 出现了!
Multi-head Attention
Multi-head Attention 首先通过线性映射将 Q,K,V 序列映射到特征空间,每一组线性投影后的向量表示称为一个头 (head),然后在每组映射后的序列上再应用 Scaled Dot-product Attention:
每个注意力头负责关注某一方面的语义相似性,多个头就可以让模型同时关注多个方面。因此与简单的 Scaled Dot-product Attention 相比,Multi-head Attention 可以捕获到更加复杂的特征信息。
形式化表示为:
(6)headi=Attention(QWiQ,KWiK,VWiV)MultiHead(Q,K,V)=Concat(head1,...,headh)
其中 WiQ∈Rdk×d~k,WiK∈Rdk×d~k,WiV∈Rdv×d~v 是映射矩阵,h 是注意力头的数量。最后,将多头的结果拼接起来就得到最终 m×hd~v 的结果序列。所谓的“多头” (Multi-head),其实就是多做几次 Scaled Dot-product Attention,然后把结果拼接。
下面我们首先实现一个注意力头:
from torch import nn
class AttentionHead(nn.Module):
def __init__(self, embed_dim, head_dim):
super().__init__()
self.q = nn.Linear(embed_dim, head_dim)
self.k = nn.Linear(embed_dim, head_dim)
self.v = nn.Linear(embed_dim, head_dim)
def forward(self, query, key, value, query_mask=None, key_mask=None, mask=None):
attn_outputs = scaled_dot_product_attention(
self.q(query), self.k(key), self.v(value), query_mask, key_mask, mask)
return attn_outputs 每个头都会初始化三个独立的线性层,负责将 Q,K,V 序列映射到尺寸为 [batch_size, seq_len, head_dim] 的张量,其中 head_dim 是映射到的向量维度。
实践中一般将 head_dim 设置为 embed_dim 的因数,这样 token 嵌入式表示的维度就可以保持不变,例如 BERT 有 12 个注意力头,因此每个头的维度被设置为 768/12=64。
最后只需要拼接多个注意力头的输出就可以构建出 Multi-head Attention 层了(这里在拼接后还通过一个线性变换来生成最终的输出张量):
class MultiHeadAttention(nn.Module):
def __init__(self, config):
super().__init__()
embed_dim = config.hidden_size
num_heads = config.num_attention_heads
head_dim = embed_dim // num_heads
self.heads = nn.ModuleList(
[AttentionHead(embed_dim, head_dim) for _ in range(num_heads)]
)
self.output_linear = nn.Linear(embed_dim, embed_dim)
def forward(self, query, key, value, query_mask=None, key_mask=None, mask=None):
x = torch.cat([
h(query, key, value, query_mask, key_mask, mask) for h in self.heads
], dim=-1)
x = self.output_linear(x)
return x 这里使用 BERT-base-uncased 模型的参数初始化 Multi-head Attention 层,并且将之前构建的输入送入模型以验证是否工作正常:
from transformers import AutoConfig
from transformers import AutoTokenizer
model_ckpt = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(model_ckpt)
text = "time flies like an arrow"
inputs = tokenizer(text, return_tensors="pt", add_special_tokens=False)
config = AutoConfig.from_pretrained(model_ckpt)
token_emb = nn.Embedding(config.vocab_size, config.hidden_size)
inputs_embeds = token_emb(inputs.input_ids)
multihead_attn = MultiHeadAttention(config)
query = key = value = inputs_embeds
attn_output = multihead_attn(query, key, value)
print(attn_output.size())
torch.Size([1, 5, 768]) Transformer Encoder
回忆一下上一章中介绍过的标准 Transformer 结构,Encoder 负责将输入的词语序列转换为词向量序列,Decoder 则基于 Encoder 的隐状态来迭代地生成词语序列作为输出,每次生成一个词语。
其中,Encoder 和 Decoder 都各自包含有多个 building blocks。下图展示了一个翻译任务的例子:
可以看到:
- 输入的词语首先被转换为词向量。由于注意力机制无法捕获词语之间的位置关系,因此还通过 positional embeddings 向输入中添加位置信息;
- Encoder 由一堆 encoder layers (blocks) 组成,类似于图像领域中的堆叠卷积层。同样地,在 Decoder 中也包含有堆叠的 decoder layers;
- Encoder 的输出被送入到 Decoder 层中以预测概率最大的下一个词,然后当前的词语序列又被送回到 Decoder 中以继续生成下一个词,重复直至出现序列结束符 EOS 或者超过最大输出长度。
The Feed-Forward Layer
Transformer Encoder/Decoder 中的前馈子层实际上就是两层全连接神经网络,它单独地处理序列中的每一个词向量,也被称为 position-wise feed-forward layer。常见做法是让第一层的维度是词向量大小的 4 倍,然后以 GELU 作为激活函数。
下面实现一个简单的 Feed-Forward Layer:
class FeedForward(nn.Module):
def __init__(self, config):
super().__init__()
self.linear_1 = nn.Linear(config.hidden_size, config.intermediate_size)
self.linear_2 = nn.Linear(config.intermediate_size, config.hidden_size)
self.gelu = nn.GELU()
self.dropout = nn.Dropout(config.hidden_dropout_prob)
def forward(self, x):
x = self.linear_1(x)
x = self.gelu(x)
x = self.linear_2(x)
x = self.dropout(x)
return x 将前面注意力层的输出送入到该层中以测试是否符合我们的预期:
feed_forward = FeedForward(config)
ff_outputs = feed_forward(attn_output)
print(ff_outputs.size())
torch.Size([1, 5, 768]) 至此创建完整 Transformer Encoder 的所有要素都已齐备,只需要再加上 Skip Connections 和 Layer Normalization 就大功告成了。
Layer Normalization
Layer Normalization 负责将一批 (batch) 输入中的每一个都标准化为均值为零且具有单位方差;Skip Connections 则是将张量直接传递给模型的下一层而不进行处理,并将其添加到处理后的张量中。
向 Transformer Encoder/Decoder 中添加 Layer Normalization 目前共有两种做法:
- Post layer normalization:Transformer 论文中使用的方式,将 Layer normalization 放在 Skip Connections 之间。 但是因为梯度可能会发散,这种做法很难训练,还需要结合学习率预热 (learning rate warm-up) 等技巧;
- Pre layer normalization:目前主流的做法,将 Layer Normalization 放置于 Skip Connections 的范围内。这种做法通常训练过程会更加稳定,并且不需要任何学习率预热。
本章采用第二种方式来构建 Transformer Encoder 层:
class TransformerEncoderLayer(nn.Module):
def __init__(self, config):
super().__init__()
self.layer_norm_1 = nn.LayerNorm(config.hidden_size)
self.layer_norm_2 = nn.LayerNorm(config.hidden_size)
self.attention = MultiHeadAttention(config)
self.feed_forward = FeedForward(config)
def forward(self, x, mask=None):
# Apply layer normalization and then copy input into query, key, value
hidden_state = self.layer_norm_1(x)
# Apply attention with a skip connection
x = x + self.attention(hidden_state, hidden_state, hidden_state, mask=mask)
# Apply feed-forward layer with a skip connection
x = x + self.feed_forward(self.layer_norm_2(x))
return x 同样地,这里将之前构建的输入送入到该层中进行测试:
encoder_layer = TransformerEncoderLayer(config)
print(inputs_embeds.shape)
print(encoder_layer(inputs_embeds).size())
torch.Size([1, 5, 768])
torch.Size([1, 5, 768]) 结果符合预期!至此,本章就构建出了一个几乎完整的 Transformer Encoder 层。
Positional Embeddings
前面讲过,由于注意力机制无法捕获词语之间的位置信息,因此 Transformer 模型还使用 Positional Embeddings 添加了词语的位置信息。
Positional Embeddings 基于一个简单但有效的想法:使用与位置相关的值模式来增强词向量。
如果预训练数据集足够大,那么最简单的方法就是让模型自动学习位置嵌入。下面本章就以这种方式创建一个自定义的 Embeddings 模块,它同时将词语和位置映射到嵌入式表示,最终的输出是两个表示之和:
class Embeddings(nn.Module):
def __init__(self, config):
super().__init__()
self.token_embeddings = nn.Embedding(config.vocab_size,
config.hidden_size)
self.position_embeddings = nn.Embedding(config.max_position_embeddings,
config.hidden_size)
self.layer_norm = nn.LayerNorm(config.hidden_size, eps=1e-12)
self.dropout = nn.Dropout()
def forward(self, input_ids):
# Create position IDs for input sequence
seq_length = input_ids.size(1)
position_ids = torch.arange(seq_length, dtype=torch.long).unsqueeze(0)
# Create token and position embeddings
token_embeddings = self.token_embeddings(input_ids)
position_embeddings = self.position_embeddings(position_ids)
# Combine token and position embeddings
embeddings = token_embeddings + position_embeddings
embeddings = self.layer_norm(embeddings)
embeddings = self.dropout(embeddings)
return embeddings embedding_layer = Embeddings(config)
print(embedding_layer(inputs.input_ids).size())
torch.Size([1, 5, 768]) 除此以外,Positional Embeddings 还有一些替代方案:
绝对位置表示:使用由调制的正弦和余弦信号组成的静态模式来编码位置。 当没有大量训练数据可用时,这种方法尤其有效;
相对位置表示:在生成某个词语的词向量时,一般距离它近的词语更为重要,因此也有工作采用相对位置编码。因为每个词语的相对嵌入会根据序列的位置而变化,这需要在模型层面对注意力机制进行修改,而不是通过引入嵌入层来完成,例如 DeBERTa 等模型。
下面将所有这些层结合起来构建完整的 Transformer Encoder:
class TransformerEncoder(nn.Module):
def __init__(self, config):
super().__init__()
self.embeddings = Embeddings(config)
self.layers = nn.ModuleList([TransformerEncoderLayer(config)
for _ in range(config.num_hidden_layers)]) def forward(self, x, mask=None):
x = self.embeddings(x)
for layer in self.layers:
x = layer(x, mask=mask)
return x 同样地,我们对该层进行简单的测试:
encoder = TransformerEncoder(config)
print(encoder(inputs.input_ids).size()) torch.Size([1, 5, 768]) Transformer Decoder
Transformer Decoder 与 Encoder 最大的不同在于 Decoder 有两个注意力子层,如下图所示:
Masked multi-head self-attention layer:确保在每个时间步生成的词语仅基于过去的输出和当前预测的词,否则 Decoder 相当于作弊了;
Encoder-decoder attention layer:以解码器的中间表示作为 queries,对 encoder stack 的输出 key 和 value 向量执行 Multi-head Attention。通过这种方式,Encoder-Decoder Attention Layer 就可以学习到如何关联来自两个不同序列的词语,例如两种不同的语言。 解码器可以访问每个 block 中 Encoder 的 keys 和 values。
与 Encoder 中的 Mask 不同,Decoder 的 Mask 是一个下三角矩阵:
seq_len = inputs.input_ids.size(-1)
mask = torch.tril(torch.ones(seq_len, seq_len)).unsqueeze(0)
print(mask[0]) tensor([[1., 0., 0., 0., 0.],
[1., 1., 0., 0., 0.],
[1., 1., 1., 0., 0.],
[1., 1., 1., 1., 0.],
[1., 1., 1., 1., 1.]]) 这里使用 PyTorch 自带的 tril() 函数来创建下三角矩阵,然后同样地,通过 Tensor.masked_fill() 将所有零替换为负无穷大来防止注意力头看到未来的词语而造成信息泄露:
scores.masked_fill(mask == 0, -float("inf")) tensor([[[26.8082, -inf, -inf, -inf, -inf],
[-0.6981, 26.9043, -inf, -inf, -inf],
[-2.3190, 1.2928, 27.8710, -inf, -inf],
[-0.5897, 0.3497, -0.3807, 27.5488, -inf],
[ 0.5275, 2.0493, -0.4869, 1.6100, 29.0893]]],
grad_fn=<MaskedFillBackward0>) 本章对 Decoder 只做简单的介绍,如果你想更深入的了解可以参考 Andrej Karpathy 实现的 minGPT。
本章的所有代码已经整理于 Github:
https://gist.github.com/jsksxs360/3ae3b176352fa78a4fca39fff0ffe648
Transformer推理
假设一次处理一个序列(即批处理大小为 1)。从一个输入序列生成一个输出标记
解码器本身不会输出 token,而是输出 logits(数量与词汇表大小相同)。顺便说一下,输出 logits 的最后一层通常被称为语言模型头(language model head)或 LM 头。从 logits 中推导出 token 是称为(token)搜索策略、 生成策略或解码策略的启发式算法的工作。常见的解码策略包括:
- 贪婪解码,它简单地选择具有最大 logit 的 token,可能在使用如重复惩罚等转换来修改 logits 之后。
- 采样解码,它使用 logits 作为多项式分布进行采样。换句话说,我们从词汇表中通过采样选择一个 token。我们采样的分布可以先使用简单的转换进行扭曲,如温度缩放、top-k 和 top-p 等。
- 更复杂的启发式算法,如束搜索、对比解码¹等。
为了简化,我们将解码策略视为模型的一部分(图 2)。其中将一系列标记作为输入并返回相应输出标记的此类实体通常称为 推理引擎 。
那如果生成多个标记呢?使用基于 Transformer 的解码器从输入文本序列(通常称为 提示 )生成文本(通常称为 补全 )基本上包括以下步骤:
- 将模型权重加载到 GPU
- 在 CPU 上对提示进行分词,并将分词张量传输到 GPU
- 通过将分词后的提示输入网络来生成补全的第一个 token。
这个单步阶段通常称为 初始化阶段 。
- 将生成的标记附加到输入标记序列中,并将其用作生成补全的第二标记的新输入。然后,重复此过程,直到生成停止序列(例如单个序列结束(EOS)标记)或达到配置的最大序列长度。
- 将完成的 token 传输到 CPU 并解码以获取您生成的文本
第四章:开箱即用的 pipelines
通过前三章的介绍,相信你已经对自然语言处理 (NLP) 以及 Transformer 模型有了一定的了解。从本章开始将正式进入正题——Transformers 库的组件以及使用方法。
本章将通过一些封装好的 pipelines 向大家展示 Transformers 库的强大能力。
开箱即用的 pipelines
Transformers 库将目前的 NLP 任务归纳为几下几类:
- 文本分类:例如情感分析、句子对关系判断等;
- 对文本中的词语进行分类:例如词性标注 (POS)、命名实体识别 (NER) 等;
- 文本生成:例如填充预设的模板 (prompt)、预测文本中被遮掩掉 (masked) 的词语;
- 从文本中抽取答案:例如根据给定的问题从一段文本中抽取出对应的答案;
- 根据输入文本生成新的句子:例如文本翻译、自动摘要等。
Transformers 库最基础的对象就是 pipeline() 函数,它封装了预训练模型和对应的前处理和后处理环节。我们只需输入文本,就能得到预期的答案。目前常用的 pipelines 有:
-
feature-extraction(获得文本的向量化表示) -
fill-mask(填充被遮盖的词、片段) -
ner(命名实体识别) -
question-answering(自动问答) -
sentiment-analysis(情感分析) -
summarization(自动摘要) -
text-generation(文本生成) -
translation(机器翻译) -
zero-shot-classification(零训练样本分类)
下面我们以常见的几个 NLP 任务为例,展示如何调用这些 pipeline 模型。
情感分析
借助情感分析 pipeline,我们只需要输入文本,就可以得到其情感标签(积极/消极)以及对应的概率:
from transformers import pipeline
classifier = pipeline("sentiment-analysis")
result = classifier("I've been waiting for a HuggingFace course my whole life.")
print(result)
results = classifier(
["I've been waiting for a HuggingFace course my whole life.", "I hate this so much!"]
)
print(results) No model was supplied, defaulted to distilbert-base-uncased-finetuned-sst-2-english (https://huggingface.co/distilbert-base-uncased-finetuned-sst-2-english)
[{'label': 'POSITIVE', 'score': 0.9598048329353333}]
[{'label': 'POSITIVE', 'score': 0.9598048329353333}, {'label': 'NEGATIVE', 'score': 0.9994558691978455}] pipeline 模型会自动完成以下三个步骤:
- 将文本预处理为模型可以理解的格式;
- 将预处理好的文本送入模型;
- 对模型的预测值进行后处理,输出人类可以理解的格式。
pipeline 会自动选择合适的预训练模型来完成任务。例如对于情感分析,默认就会选择微调好的英文情感模型 distilbert-base-uncased-finetuned-sst-2-english。
Transformers 库会在创建对象时下载并且缓存模型,只有在首次加载模型时才会下载,后续会直接调用缓存好的模型。
零训练样本分类
零训练样本分类 pipeline 允许我们在不提供任何标注数据的情况下自定义分类标签。
from transformers import pipeline
classifier = pipeline("zero-shot-classification")
result = classifier(
"This is a course about the Transformers library",
candidate_labels=["education", "politics", "business"],
)
print(result) No model was supplied, defaulted to facebook/bart-large-mnli (https://huggingface.co/facebook/bart-large-mnli)
{'sequence': 'This is a course about the Transformers library',
'labels': ['education', 'business', 'politics'],
'scores': [0.8445973992347717, 0.11197526752948761, 0.043427325785160065]} 可以看到,pipeline 自动选择了预训练好的 facebook/bart-large-mnli 模型来完成任务。
文本生成
我们首先根据任务需要构建一个模板 (prompt),然后将其送入到模型中来生成后续文本。注意,由于文本生成具有随机性,因此每次运行都会得到不同的结果。
这种模板被称为前缀模板 (Prefix Prompt),了解更多详细信息可以查看《Prompt 方法简介》。
from transformers import pipeline
generator = pipeline("text-generation")
results = generator("In this course, we will teach you how to")
print(results)
results = generator(
"In this course, we will teach you how to",
num_return_sequences=2,
max_length=50
)
print(results) No model was supplied, defaulted to gpt2 (https://huggingface.co/gpt2)
[{'generated_text': "In this course, we will teach you how to use data and models that can be applied in any real-world, everyday situation. In most cases, the following will work better than other courses I've offered for an undergrad or student. In order"}]
[{'generated_text': 'In this course, we will teach you how to make your own unique game called "Mono" from scratch by doing a game engine, a framework and the entire process starting with your initial project. We are planning to make some basic gameplay scenarios and'}, {'generated_text': 'In this course, we will teach you how to build a modular computer, how to run it on a modern Windows machine, how to install packages, and how to debug and debug systems. We will cover virtualization and virtualization without a programmer,'}] 可以看到,pipeline 自动选择了预训练好的 gpt2 模型来完成任务。我们也可以指定要使用的模型。对于文本生成任务,我们可以在 Model Hub 页面左边选择 Text Generation tag 查询支持的模型。例如,我们在相同的 pipeline 中加载 distilgpt2 模型:
from transformers import pipeline
generator = pipeline("text-generation", model="distilgpt2")
results = generator(
"In this course, we will teach you how to",
max_length=30,
num_return_sequences=2,
)
print(results) [{'generated_text': 'In this course, we will teach you how to use React in any form, and how to use React without having to worry about your React dependencies because'},
{'generated_text': 'In this course, we will teach you how to use a computer system in order to create a working computer. It will tell you how you can use'}] 还可以通过左边的语言 tag 选择其他语言的模型。例如加载专门用于生成中文古诗的 gpt2-chinese-poem 模型:
from transformers import pipeline
generator = pipeline("text-generation", model="uer/gpt2-chinese-poem")
results = generator(
"[CLS] 万 叠 春 山 积 雨 晴 ,",
max_length=40,
num_return_sequences=2,
)
print(results) [{'generated_text': '[CLS] 万 叠 春 山 积 雨 晴 , 孤 舟 遥 送 子 陵 行 。 别 情 共 叹 孤 帆 远 , 交 谊 深 怜 一 座 倾 。 白 日 风 波 身 外 幻'},
{'generated_text': '[CLS] 万 叠 春 山 积 雨 晴 , 满 川 烟 草 踏 青 行 。 何 人 唤 起 伤 春 思 , 江 畔 画 船 双 橹 声 。 桃 花 带 雨 弄 晴 光'}] 遮盖词填充
给定一段部分词语被遮盖掉 (masked) 的文本,使用预训练模型来预测能够填充这些位置的词语。
与前面介绍的文本生成类似,这个任务其实也是先构建模板然后运用模型来完善模板,称为填充模板 (Cloze Prompt)。了解更多详细信息可以查看《Prompt 方法简介》。
from transformers import pipeline
unmasker = pipeline("fill-mask")
results = unmasker("This course will teach you all about <mask> models.", top_k=2)
print(results) No model was supplied, defaulted to distilroberta-base (https://huggingface.co/distilroberta-base)
[{'sequence': 'This course will teach you all about mathematical models.',
'score': 0.19619858264923096,
'token': 30412,
'token_str': ' mathematical'},
{'sequence': 'This course will teach you all about computational models.',
'score': 0.04052719101309776,
'token': 38163,
'token_str': ' computational'}] 可以看到,pipeline 自动选择了预训练好的 distilroberta-base 模型来完成任务。
命名实体识别
命名实体识别 (NER) pipeline 负责从文本中抽取出指定类型的实体,例如人物、地点、组织等等。
from transformers import pipeline
ner = pipeline("ner", grouped_entities=True)
results = ner("My name is Sylvain and I work at Hugging Face in Brooklyn.")
print(results) No model was supplied, defaulted to dbmdz/bert-large-cased-finetuned-conll03-english (https://huggingface.co/dbmdz/bert-large-cased-finetuned-conll03-english)
[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.97960186, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.99321055, 'word': 'Brooklyn', 'start': 49, 'end': 57}] 可以看到,模型正确地识别出了 Sylvain 是一个人物,Hugging Face 是一个组织,Brooklyn 是一个地名。
这里通过设置参数 grouped_entities=True,使得 pipeline 自动合并属于同一个实体的多个子词 (token),例如这里将“Hugging”和“Face”合并为一个组织实体,实际上 Sylvain 也进行了子词合并,因为分词器会将 Sylvain 切分为 S、##yl 、##va 和 ##in 四个 token。
自动问答
自动问答 pipeline 可以根据给定的上下文回答问题,例如:
from transformers import pipeline
question_answerer = pipeline("question-answering")
answer = question_answerer(
question="Where do I work?",
context="My name is Sylvain and I work at Hugging Face in Brooklyn",
)
print(answer) No model was supplied, defaulted to distilbert-base-cased-distilled-squad (https://huggingface.co/distilbert-base-cased-distilled-squad)
{'score': 0.6949771046638489, 'start': 33, 'end': 45, 'answer': 'Hugging Face'} 可以看到,pipeline 自动选择了在 SQuAD 数据集上训练好的 distilbert-base 模型来完成任务。这里的自动问答 pipeline 实际上是一个抽取式问答模型,即从给定的上下文中抽取答案,而不是生成答案。
根据形式的不同,自动问答 (QA) 系统可以分为三种:
- 抽取式 QA (extractive QA):假设答案就包含在文档中,因此直接从文档中抽取答案;
- 多选 QA (multiple-choice QA):从多个给定的选项中选择答案,相当于做阅读理解题;
- 无约束 QA (free-form QA):直接生成答案文本,并且对答案文本格式没有任何限制。
自动摘要
自动摘要 pipeline 旨在将长文本压缩成短文本,并且还要尽可能保留原文的主要信息,例如:
from transformers import pipeline
summarizer = pipeline("summarization")
results = summarizer(
"""
America has changed dramatically during recent years. Not only has the number of
graduates in traditional engineering disciplines such as mechanical, civil,
electrical, chemical, and aeronautical engineering declined, but in most of
the premier American universities engineering curricula now concentrate on
and encourage largely the study of engineering science. As a result, there
are declining offerings in engineering subjects dealing with infrastructure,
the environment, and related issues, and greater concentration on high
technology subjects, largely supporting increasingly complex scientific
developments. While the latter is important, it should not be at the expense
of more traditional engineering.
Rapidly developing economies such as China and India, as well as other
industrial countries in Europe and Asia, continue to encourage and advance
the teaching of engineering. Both China and India, respectively, graduate
six and eight times as many traditional engineers as does the United States.
Other industrial countries at minimum maintain their output, while America
suffers an increasingly serious decline in the number of engineering graduates
and a lack of well-educated engineers.
"""
)
print(results) No model was supplied, defaulted to sshleifer/distilbart-cnn-12-6 (https://huggingface.co/sshleifer/distilbart-cnn-12-6)
[{'summary_text': ' America has changed dramatically during recent years . The number of engineering graduates in the U.S. has declined in traditional engineering disciplines such as mechanical, civil, electrical, chemical, and aeronautical engineering . Rapidly developing economies such as China and India, as well as other industrial countries in Europe and Asia, continue to encourage and advance engineering .'}]
可以看到,pipeline 自动选择了预训练好的 distilbart-cnn-12-6 模型来完成任务。与文本生成类似,我们也可以通过 max_length 或 min_length 参数来控制返回摘要的长度。
这些 pipeline 背后做了什么?
这些简单易用的 pipeline 模型实际上封装了许多操作,下面我们就来了解一下它们背后究竟做了啥。以第一个情感分析 pipeline 为例,我们运行下面的代码
from transformers import pipeline
classifier = pipeline("sentiment-analysis")
result = classifier("I've been waiting for a HuggingFace course my whole life.")
print(result) 就会得到结果:
[{'label': 'POSITIVE', 'score': 0.9598048329353333}] 实际上它的背后经过了三个步骤:
- 预处理 (preprocessing),将原始文本转换为模型可以接受的输入格式;
- 将处理好的输入送入模型;
- 对模型的输出进行后处理 (postprocessing),将其转换为人类方便阅读的格式。
使用分词器进行预处理
因为神经网络模型无法直接处理文本,因此首先需要通过预处理环节将文本转换为模型可以理解的数字。具体地,我们会使用每个模型对应的分词器 (tokenizer) 来进行:
- 将输入切分为词语、子词或者符号(例如标点符号),统称为 tokens;
- 根据模型的词表将每个 token 映射到对应的 token 编号(就是一个数字);
- 根据模型的需要,添加一些额外的输入。
我们对输入文本的预处理需要与模型自身预训练时的操作完全一致,只有这样模型才可以正常地工作。注意,每个模型都有特定的预处理操作,如果对要使用的模型不熟悉,可以通过 Model Hub 查询。这里我们使用 AutoTokenizer 类和它的 from_pretrained() 函数,它可以自动根据模型 checkpoint 名称来获取对应的分词器。
情感分析 pipeline 的默认 checkpoint 是 distilbert-base-uncased-finetuned-sst-2-english,下面我们手工下载并调用其分词器:
from transformers import AutoTokenizer
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
raw_inputs = [
"I've been waiting for a HuggingFace course my whole life.",
"I hate this so much!",
]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")
print(inputs) {
'input_ids': tensor([
[ 101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102],
[ 101, 1045, 5223, 2023, 2061, 2172, 999, 102, 0, 0,
0, 0, 0, 0, 0, 0]
]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0]
])
} 可以看到,输出中包含两个键 input_ids 和 attention_mask,其中 input_ids 对应分词之后的 tokens 映射到的数字编号列表,而 attention_mask 则是用来标记哪些 tokens 是被填充的(这里“1”表示是原文,“0”表示是填充字符)。
先不要关注 padding、truncation 这些参数,以及 attention_mask 项,后面我们会详细介绍:)。
将预处理好的输入送入模型
预训练模型的下载方式和分词器 (tokenizer) 类似,Transformers 包提供了一个 AutoModel 类和对应的 from_pretrained() 函数。下面我们手工下载这个 distilbert-base 模型:
from transformers import AutoModel
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
model = AutoModel.from_pretrained(checkpoint) 预训练模型的本体只包含基础的 Transformer 模块,对于给定的输入,它会输出一些神经元的值,称为 hidden states 或者特征 (features)。对于 NLP 模型来说,可以理解为是文本的高维语义表示。这些 hidden states 通常会被输入到其他的模型部分(称为 head),以完成特定的任务,例如送入到分类头中完成文本分类任务。
其实前面我们举例的所有 pipelines 都具有类似的模型结构,只是模型的最后一部分会使用不同的 head 以完成对应的任务。![]()
Transformers 库封装了很多不同的结构,常见的有:
Model(返回 hidden states)ForCausalLM(用于条件语言模型)ForMaskedLM(用于遮盖语言模型)ForMultipleChoice(用于多选任务)ForQuestionAnswering(用于自动问答任务)ForSequenceClassification(用于文本分类任务)ForTokenClassification(用于 token 分类任务,例如 NER)
Transformer 模块的输出是一个维度为 (Batch size, Sequence length, Hidden size) 的三维张量,其中 Batch size 表示每次输入的样本(文本序列)数量,即每次输入多少个句子,上例中为 2;Sequence length 表示文本序列的长度,即每个句子被分为多少个 token,上例中为 16;Hidden size 表示每一个 token 经过模型编码后的输出向量(语义表示)的维度。
预训练模型编码后的输出向量的维度通常都很大,例如 Bert 模型 base 版本的输出为 768 维,一些大模型的输出维度为 3072 甚至更高。
我们可以打印出这里使用的 distilbert-base 模型的输出维度:
from transformers import AutoTokenizer, AutoModel
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModel.from_pretrained(checkpoint)
raw_inputs = [
"I've been waiting for a HuggingFace course my whole life.",
"I hate this so much!",
]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")
outputs = model(**inputs)
print(outputs.last_hidden_state.shape) torch.Size([2, 16, 768]) Transformers 模型的输出格式类似 namedtuple 或字典,可以像上面那样通过属性访问,也可以通过键(outputs["last_hidden_state"]),甚至索引访问(outputs[0])。
对于情感分析任务,很明显我们最后需要使用的是一个文本分类 head。因此,实际上我们不会使用 AutoModel 类,而是使用 AutoModelForSequenceClassification:
from transformers import AutoTokenizer
from transformers import AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
raw_inputs = [
"I've been waiting for a HuggingFace course my whole life.",
"I hate this so much!",
]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")
outputs = model(**inputs)
print(outputs.logits.shape) torch.Size([2, 2]) 可以看到,对于 batch 中的每一个样本,模型都会输出一个两维的向量(每一维对应一个标签,positive 或 negative)。
对模型输出进行后处理
由于模型的输出只是一些数值,因此并不适合人类阅读。例如我们打印出上面例子的输出:
from transformers import AutoTokenizer
from transformers import AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
raw_inputs = [
"I've been waiting for a HuggingFace course my whole life.",
"I hate this so much!",
]
inputs = tokenizer(raw_inputs, padding=True, truncation=True, return_tensors="pt")
outputs = model(**inputs)
print(outputs.logits) tensor([[-1.5607, 1.6123],
[ 4.1692, -3.3464]], grad_fn=<AddmmBackward0>) 模型对第一个句子输出 [−1.5607,1.6123],对第二个句子输出 [4.1692,−3.3464],它们并不是概率值,而是模型最后一层输出的 logits 值。要将他们转换为概率值,还需要让它们经过一个 SoftMax 层,例如:
import torch
predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
print(predictions) tensor([[4.0195e-02, 9.5980e-01],
[9.9946e-01, 5.4418e-04]], grad_fn=<SoftmaxBackward0>) 所有 Transformers 模型都会输出 logits 值,因为训练时的损失函数通常会自动结合激活函数(例如 SoftMax)与实际的损失函数(例如交叉熵 cross entropy)。
这样模型的预测结果就是容易理解的概率值:第一个句子 [0.0402,0.9598],第二个句子 [0.9995,0.0005]。最后,为了得到对应的标签,可以读取模型 config 中提供的 id2label 属性:
print(model.config.id2label) {0: 'NEGATIVE', 1: 'POSITIVE'} 于是我们可以得到最终的预测结果:
- 第一个句子: NEGATIVE: 0.0402, POSITIVE: 0.9598
- 第二个句子: NEGATIVE: 0.9995, POSITIVE: 0.0005
第五章:模型与分词器
本章我们将介绍 Transformers 库中的两个重要组件:模型和分词器。
5.1 模型
除了像之前使用 AutoModel 根据 checkpoint 自动加载模型以外,我们也可以直接使用模型对应的 Model 类,例如 BERT 对应的就是 BertModel:
from transformers import BertModel
model = BertModel.from_pretrained("bert-base-cased") 注意,在大部分情况下,我们都应该使用 AutoModel 来加载模型。这样如果我们想要使用另一个模型(比如把 BERT 换成 RoBERTa),只需修改 checkpoint,其他代码可以保持不变。
加载模型
所有存储在 HuggingFace Model Hub 上的模型都可以通过 Model.from_pretrained() 来加载权重,参数可以像上面一样是 checkpoint 的名称,也可以是本地路径(预先下载的模型目录),例如:
from transformers import BertModel
model = BertModel.from_pretrained("./models/bert/") Model.from_pretrained() 会自动缓存下载的模型权重,默认保存到 ~/.cache/huggingface/transformers,我们也可以通过 HF_HOME 环境变量自定义缓存目录。
由于 checkpoint 名称加载方式需要连接网络,因此在大部分情况下我们都会采用本地路径的方式加载模型。部分模型的 Hub 页面中会包含很多文件,我们通常只需要下载模型对应的 config.json 和 pytorch_model.bin,以及分词器对应的 tokenizer.json、tokenizer_config.json 和 vocab.txt。
保存模型
保存模型通过调用 Model.save_pretrained() 函数实现,例如保存加载的 BERT 模型:
from transformers import AutoModel
model = AutoModel.from_pretrained("bert-base-cased")
model.save_pretrained("./models/bert-base-cased/") 这会在保存路径下创建两个文件:
- config.json:模型配置文件,存储模型结构参数,例如 Transformer 层数、特征空间维度等;
- pytorch_model.bin:又称为 state dictionary,存储模型的权重。
简单来说,配置文件记录模型的结构,模型权重记录模型的参数,这两个文件缺一不可。我们自己保存的模型同样通过 Model.from_pretrained() 加载,只需要传递保存目录的路径。
5.2 分词器
由于神经网络模型不能直接处理文本,因此我们需要先将文本转换为数字,这个过程被称为编码 (Encoding),其包含两个步骤:
- 使用分词器 (tokenizer) 将文本按词、子词、字符切分为 tokens;
- 将所有的 token 映射到对应的 token ID。
分词策略
根据切分粒度的不同,分词策略可以分为以下几种:
- 按词切分 (Word-based)
例如直接利用 Python 的
split()函数按空格进行分词:tokenized_text = "Jim Henson was a puppeteer".split() print(tokenized_text)['Jim', 'Henson', 'was', 'a', 'puppeteer']这种策略的问题是会将文本中所有出现过的独立片段都作为不同的 token,从而产生巨大的词表。而实际上很多词是相关的,例如 “dog” 和 “dogs”、“run” 和 “running”,如果给它们赋予不同的编号就无法表示出这种关联性。
词表就是一个映射字典,负责将 token 映射到对应的 ID(从 0 开始)。神经网络模型就是通过这些 token ID 来区分每一个 token。
当遇到不在词表中的词时,分词器会使用一个专门的 [UNK] token 来表示它是 unknown 的。显然,如果分词结果中包含很多 [UNK] 就意味着丢失了很多文本信息,因此一个好的分词策略,应该尽可能不出现 unknown token。
- 按字符切分 (Character-based)
这种策略把文本切分为字符而不是词语,这样就只会产生一个非常小的词表,并且很少会出现词表外的 tokens。
但是从直觉上来看,字符本身并没有太大的意义,因此将文本切分为字符之后就会变得不容易理解。这也与语言有关,例如中文字符会比拉丁字符包含更多的信息,相对影响较小。此外,这种方式切分出的 tokens 会很多,例如一个由 10 个字符组成的单词就会输出 10 个 tokens,而实际上它们只是一个词。
因此现在广泛采用的是一种同时结合了按词切分和按字符切分的方式——按子词切分 (Subword tokenization)。
- *按子词切分 (Subword) **
高频词直接保留,低频词被切分为更有意义的子词。例如 “annoyingly” 是一个低频词,可以切分为 “annoying” 和 “ly”,这两个子词不仅出现频率更高,而且词义也得以保留。下图展示了对 “Let’s do tokenization!“ 按子词切分的结果:
可以看到,“tokenization” 被切分为了 “token” 和 “ization”,不仅保留了语义,而且只用两个 token 就表示了一个长词。这种策略只用一个较小的词表就可以覆盖绝大部分文本,基本不会产生 unknown token。尤其对于土耳其语等黏着语,几乎所有的复杂长词都可以通过串联多个子词构成。
加载与保存分词器
分词器的加载与保存与模型相似,使用 Tokenizer.from_pretrained() 和 Tokenizer.save_pretrained() 函数。例如加载并保存 BERT 模型的分词器:
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("bert-base-cased")
tokenizer.save_pretrained("./models/bert-base-cased/") 同样地,在大部分情况下我们都应该使用 AutoTokenizer 来加载分词器:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
tokenizer.save_pretrained("./models/bert-base-cased/") 调用 Tokenizer.save_pretrained() 函数会在保存路径下创建三个文件:
- special_tokens_map.json:映射文件,里面包含 unknown token 等特殊字符的映射关系;
- tokenizer_config.json:分词器配置文件,存储构建分词器需要的参数;
- vocab.txt:词表,一行一个 token,行号就是对应的 token ID(从 0 开始)。
编码与解码文本
前面说过,文本编码 (Encoding) 过程包含两个步骤:
- 分词:使用分词器按某种策略将文本切分为 tokens;
- 映射:将 tokens 转化为对应的 token IDs。
下面我们首先使用 BERT 分词器来对文本进行分词:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
sequence = "Using a Transformer network is simple"
tokens = tokenizer.tokenize(sequence)
print(tokens) ['using', 'a', 'transform', '##er', 'network', 'is', 'simple'] 可以看到,BERT 分词器采用的是子词切分策略,它会不断切分词语直到获得词表中的 token,例如 “transformer” 会被切分为 “transform” 和 “##er”。
然后,我们通过 convert_tokens_to_ids() 将切分出的 tokens 转换为对应的 token IDs:
ids = tokenizer.convert_tokens_to_ids(tokens)
print(ids) [7993, 170, 13809, 23763, 2443, 1110, 3014]
还可以通过 encode() 函数将这两个步骤合并,并且 encode() 会自动添加模型需要的特殊 token,例如 BERT 分词器会分别在序列的首尾添加 [CLS] 和 [SEP]:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
sequence = "Using a Transformer network is simple"
sequence_ids = tokenizer.encode(sequence)
print(sequence_ids) [101, 7993, 170, 13809, 23763, 2443, 1110, 3014, 102]
其中 101 和 102 分别是 [CLS] 和 [SEP] 对应的 token IDs。
注意,上面这些只是为了演示。在实际编码文本时,最常见的是直接使用分词器进行处理,这样不仅会返回分词后的 token IDs,还包含模型需要的其他输入。例如 BERT 分词器还会自动在输入中添加 token_type_ids 和 attention_mask:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
tokenized_text = tokenizer("Using a Transformer network is simple")
print(tokenized_text) {'input_ids': [101, 7993, 170, 13809, 23763, 2443, 1110, 3014, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1]} 文本解码 (Decoding) 与编码相反,负责将 token IDs 转换回原来的字符串。注意,解码过程不是简单地将 token IDs 映射回 tokens,还需要合并那些被分为多个 token 的单词。下面我们通过 decode() 函数解码前面生成的 token IDs:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
decoded_string = tokenizer.decode([7993, 170, 11303, 1200, 2443, 1110, 3014])
print(decoded_string)
decoded_string = tokenizer.decode([101, 7993, 170, 13809, 23763, 2443, 1110, 3014, 102])
print(decoded_string) Using a transformer network is simple
[CLS] Using a Transformer network is simple [SEP] 解码文本是一个重要的步骤,在进行文本生成、翻译或者摘要等 Seq2Seq (Sequence-to-Sequence) 任务时都会调用这一函数。
5.3 处理多段文本
现实场景中,我们往往会同时处理多段文本,而且模型也只接受批 (batch) 数据作为输入,即使只有一段文本,也需要将它组成一个只包含一个样本的 batch,例如:
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequence = "I've been waiting for a HuggingFace course my whole life."
tokens = tokenizer.tokenize(sequence)
ids = tokenizer.convert_tokens_to_ids(tokens)
# input_ids = torch.tensor(ids), This line will fail.
input_ids = torch.tensor([ids])
print("Input IDs:\n", input_ids)
output = model(input_ids)
print("Logits:\n", output.logits) Input IDs:
tensor([[ 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607,
2026, 2878, 2166, 1012]])
Logits:
tensor([[-2.7276, 2.8789]], grad_fn=<AddmmBackward0>) 这里我们通过 [ids] 构建了一个只包含一段文本的 batch,更常见的是送入包含多段文本的 batch:
batched_ids = [ids, ids, ids, ...]
注意,上面的代码仅作为演示。实际场景中,我们应该直接使用分词器对文本进行处理,例如对于上面的例子:
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequence = "I've been waiting for a HuggingFace course my whole life."
tokenized_inputs = tokenizer(sequence, return_tensors="pt")
print("Inputs Keys:\n", tokenized_inputs.keys())
print("\nInput IDs:\n", tokenized_inputs["input_ids"])
output = model(**tokenized_inputs)
print("\nLogits:\n", output.logits) Inputs Keys:
dict_keys(['input_ids', 'attention_mask'])
Input IDs:
tensor([[ 101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172,
2607, 2026, 2878, 2166, 1012, 102]])
Logits:
tensor([[-1.5607, 1.6123]], grad_fn=<AddmmBackward0>) 可以看到,分词器输出的结果中不仅包含 token IDs(input_ids),还会包含模型需要的其他输入项。前面我们之所以只输入 token IDs 模型也能正常运行,是因为它自动地补全了其他的输入项,例如 attention_mask 等,后面我们会具体介绍。
由于分词器自动在序列的首尾添加了 [CLS] 和 [SEP] token,所以上面两个例子中模型的输出是有差异的。因为 DistilBERT 预训练时是包含 [CLS] 和 [SEP] 的,所以下面的例子才是正确的使用方法。
Padding 操作
按批输入多段文本产生的一个直接问题就是:batch 中的文本有长有短,而输入张量必须是严格的二维矩形,维度为 (batch size,sequence length),即每一段文本编码后的 token IDs 数量必须一样多。例如下面的 ID 列表是无法转换为张量的:
batched_ids = [
[200, 200, 200],
[200, 200]
] 我们需要通过 Padding 操作,在短序列的结尾填充特殊的 padding token,使得 batch 中所有的序列都具有相同的长度,例如:
padding_id = 100
batched_ids = [
[200, 200, 200],
[200, 200, padding_id],
] 模型的 padding token ID 可以通过其分词器的 pad_token_id 属性获得。下面我们尝试将两段文本分别以独立以及 batch 的形式送入到模型中:
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequence1_ids = [[200, 200, 200]]
sequence2_ids = [[200, 200]]
batched_ids = [
[200, 200, 200],
[200, 200, tokenizer.pad_token_id],
]
print(model(torch.tensor(sequence1_ids)).logits)
print(model(torch.tensor(sequence2_ids)).logits)
print(model(torch.tensor(batched_ids)).logits) tensor([[ 1.5694, -1.3895]], grad_fn=<AddmmBackward0>)
tensor([[ 0.5803, -0.4125]], grad_fn=<AddmmBackward0>)
tensor([[ 1.5694, -1.3895],
[ 1.3374, -1.2163]], grad_fn=<AddmmBackward0>) 问题出现了,使用 padding token 填充的序列的结果竟然与其单独送入模型时不同!这是因为模型默认会编码输入序列中的所有 token 以建模完整的上下文,因此这里会将填充的 padding token 也一同编码进去,从而生成不同的语义表示。
因此,在进行 Padding 操作时,我们必须明确告知模型哪些 token 是我们填充的,它们不应该参与编码。这就需要使用到 Attention Mask 了,在前面的例子中相信你已经多次见过它了。
Attention Mask
Attention Mask 是一个尺寸与 input IDs 完全相同,且仅由 0 和 1 组成的张量,0 表示对应位置的 token 是填充符,不参与计算。当然,一些特殊的模型结构也会借助 Attention Mask 来遮蔽掉指定的 tokens。
对于上面的例子,如果我们通过 attention_mask 标出填充的 padding token 的位置,计算结果就不会有问题了:
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequence1_ids = [[200, 200, 200]]
sequence2_ids = [[200, 200]]
batched_ids = [
[200, 200, 200],
[200, 200, tokenizer.pad_token_id],
]
batched_attention_masks = [
[1, 1, 1],
[1, 1, 0],
]
print(model(torch.tensor(sequence1_ids)).logits)
print(model(torch.tensor(sequence2_ids)).logits)
outputs = model(
torch.tensor(batched_ids),
attention_mask=torch.tensor(batched_attention_masks))
print(outputs.logits) tensor([[ 1.5694, -1.3895]], grad_fn=<AddmmBackward0>)
tensor([[ 0.5803, -0.4125]], grad_fn=<AddmmBackward0>)
tensor([[ 1.5694, -1.3895],
[ 0.5803, -0.4125]], grad_fn=<AddmmBackward0>) 正如前面强调的那样,在实际使用时,我们应该直接使用分词器对文本进行处理,它不仅会向 token 序列中添加模型需要的特殊字符(例如 [CLS],[SEP]),还会自动生成对应的 Attention Mask。
目前大部分 Transformer 模型只能接受长度不超过 512 或 1024 的 token 序列,因此对于长序列,有以下三种处理方法:
- 使用一个支持长文的 Transformer 模型,例如 Longformer 和 LED(最大长度 4096);
- 设定最大长度
max_sequence_length以截断输入序列:sequence = sequence[:max_sequence_length]。 - 将长文切片为短文本块 (chunk),然后分别对每一个 chunk 编码。在后面的快速分词器中,我们会详细介绍。
直接使用分词器
正如前面所说,在实际使用时,我们应该直接使用分词器来完成包括分词、转换 token IDs、Padding、构建 Attention Mask、截断等操作。例如:
from transformers import AutoTokenizer
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"So have I!"
]
model_inputs = tokenizer(sequences)
print(model_inputs) {'input_ids': [
[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102],
[101, 2061, 2031, 1045, 999, 102]],
'attention_mask': [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]]
} 可以看到,分词器的输出包含了模型需要的所有输入项。对于 DistilBERT 模型,就是 input IDs(input_ids)和 Attention Mask(attention_mask)。
Padding 操作通过 padding 参数来控制:
-
padding="longest": 将序列填充到当前 batch 中最长序列的长度; -
padding="max_length":将所有序列填充到模型能够接受的最大长度,例如 BERT 模型就是 512。
from transformers import AutoTokenizer
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"So have I!"
]
model_inputs = tokenizer(sequences, padding="longest")
print(model_inputs)
model_inputs = tokenizer(sequences, padding="max_length")
print(model_inputs) {'input_ids': [
[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102],
[101, 2061, 2031, 1045, 999, 102, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
'attention_mask': [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]
}
{'input_ids': [
[101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172, 2607, 2026, 2878, 2166, 1012, 102, 0, 0, 0, 0, 0, 0, 0, 0, ...],
[101, 2061, 2031, 1045, 999, 102, 0, 0, 0, ...]],
'attention_mask': [
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, ...],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]]
} 截断操作通过 truncation 参数来控制,如果 truncation=True,那么大于模型最大接受长度的序列都会被截断,例如对于 BERT 模型就会截断长度超过 512 的序列。此外,也可以通过 max_length 参数来控制截断长度:
from transformers import AutoTokenizer
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"So have I!"
]
model_inputs = tokenizer(sequences, max_length=8, truncation=True)
print(model_inputs) {'input_ids': [
[101, 1045, 1005, 2310, 2042, 3403, 2005, 102],
[101, 2061, 2031, 1045, 999, 102]],
'attention_mask': [
[1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1]]
} 分词器还可以通过 return_tensors 参数指定返回的张量格式:设为 pt 则返回 PyTorch 张量;tf 则返回 TensorFlow 张量,np 则返回 NumPy 数组。例如:
from transformers import AutoTokenizer
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"So have I!"
]
model_inputs = tokenizer(sequences, padding=True, return_tensors="pt")
print(model_inputs)
model_inputs = tokenizer(sequences, padding=True, return_tensors="np")
print(model_inputs) {'input_ids': tensor([
[ 101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172,
2607, 2026, 2878, 2166, 1012, 102],
[ 101, 2061, 2031, 1045, 999, 102, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])
}
{'input_ids': array([
[ 101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662,
12172, 2607, 2026, 2878, 2166, 1012, 102],
[ 101, 2061, 2031, 1045, 999, 102, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0]]),
'attention_mask': array([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])
} 实际使用分词器时,我们通常会同时进行 padding 操作和截断操作,并设置返回格式为 Pytorch 张量,这样就可以直接将分词结果送入模型:
from transformers import AutoTokenizer, AutoModelForSequenceClassification
checkpoint = "distilbert-base-uncased-finetuned-sst-2-english"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModelForSequenceClassification.from_pretrained(checkpoint)
sequences = [
"I've been waiting for a HuggingFace course my whole life.",
"So have I!"
]
tokens = tokenizer(sequences, padding=True, truncation=True, return_tensors="pt")
print(tokens)
output = model(**tokens)
print(output.logits) {'input_ids': tensor([
[ 101, 1045, 1005, 2310, 2042, 3403, 2005, 1037, 17662, 12172,
2607, 2026, 2878, 2166, 1012, 102],
[ 101, 2061, 2031, 1045, 999, 102, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])}
tensor([[-1.5607, 1.6123],
[-3.6183, 3.9137]], grad_fn=<AddmmBackward0>) 在 padding=True, truncation=True 设置下,同一个 batch 中的序列都会 padding 到相同的长度,并且大于模型最大接受长度的序列会被自动截断。
编码句子对
除了对单段文本进行编码以外(batch 只是并行地编码多个单段文本),对于 BERT 等包含“句子对”预训练任务的模型,它们的分词器都支持对“句子对”进行编码,例如:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
inputs = tokenizer("This is the first sentence.", "This is the second one.")
print(inputs)
tokens = tokenizer.convert_ids_to_tokens(inputs["input_ids"])
print(tokens) {'input_ids': [101, 2023, 2003, 1996, 2034, 6251, 1012, 102, 2023, 2003, 1996, 2117, 2028, 1012, 102],
'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1],
'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]'] 此时分词器会使用 [SEP] token 拼接两个句子,输出形式为“[CLS] sentence1 [SEP] sentence2 [SEP]”的 token 序列,这也是 BERT 模型预期的“句子对”输入格式。
返回结果中除了前面我们介绍过的 input_ids 和 attention_mask 之外,还包含了一个 token_type_ids 项,用于标记哪些 token 属于第一个句子,哪些属于第二个句子。如果我们将上面例子中的 token_type_ids 项与 token 序列对齐:
['[CLS]', 'this', 'is', 'the', 'first', 'sentence', '.', '[SEP]', 'this', 'is', 'the', 'second', 'one', '.', '[SEP]']
[ 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1] 就可以看到第一个句子“[CLS] sentence1 [SEP]”所有 token 的 type ID 都为 0,而第二个句子“sentence2 [SEP]”对应的 token type ID 都为 1。
如果我们选择其他模型,分词器的输出不一定会包含 token_type_ids 项(例如 DistilBERT 模型)。分词器只需保证输出格式与模型预训练时的输入一致即可。
实际使用时,我们不需要去关注编码结果中是否包含 token_type_ids 项,分词器会根据 checkpoint 自动调整输出格式,例如:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sentence1_list = ["First sentence.", "This is the second sentence.", "Third one."]
sentence2_list = ["First sentence is short.", "The second sentence is very very very long.", "ok."]
tokens = tokenizer(
sentence1_list,
sentence2_list,
padding=True,
truncation=True,
return_tensors="pt"
)
print(tokens)
print(tokens['input_ids'].shape) {'input_ids': tensor([
[ 101, 2034, 6251, 1012, 102, 2034, 6251, 2003, 2460, 1012, 102, 0,
0, 0, 0, 0, 0, 0],
[ 101, 2023, 2003, 1996, 2117, 6251, 1012, 102, 1996, 2117, 6251, 2003,
2200, 2200, 2200, 2146, 1012, 102],
[ 101, 2353, 2028, 1012, 102, 7929, 1012, 102, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'token_type_ids': tensor([
[0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])
}
torch.Size([3, 18]) 可以看到分词器成功地输出了形式为“[CLS] sentence1 [SEP] sentence2 [SEP]”的 token 序列,并且将三个序列都 padding 到了相同的长度。
5.4 添加 Token
实际操作中,我们还经常会遇到输入中需要包含特殊标记符的情况,例如使用 [ENT_START] 和 [ENT_END] 标记出文本中的实体。由于这些自定义 token 并不在预训练模型原来的词表中,因此直接运用分词器处理就会出现问题。
例如直接使用 BERT 分词器处理下面的句子:
from transformers import AutoTokenizer
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sentence = 'Two [ENT_START] cars [ENT_END] collided in a [ENT_START] tunnel [ENT_END] this morning.'
print(tokenizer.tokenize(sentence)) ['two', '[', 'en', '##t', '_', 'start', ']', 'cars', '[', 'en', '##t', '_', 'end', ']', 'collided', 'in', 'a', '[', 'en', '##t', '_', 'start', ']', 'tunnel', '[', 'en', '##t', '_', 'end', ']', 'this', 'morning', '.'] 由于分词器无法识别 [ENT_START] 和 [ENT_END] ,因此将它们都当作未知字符处理,例如“[ENT_END]”被切分成了 '[', 'en', '##t', '_', 'end', ']' 六个 token。
此外,一些领域的专业词汇,例如使用多个词语的缩写拼接而成的医学术语,同样也不在模型的词表中,因此也会出现上面的问题。此时我们就需要将这些新 token 添加到模型的词表中,让分词器与模型可以识别并处理这些 token。
添加新 token
Transformers 库提供了两种方式来添加新 token,分别是:
-
add_tokens()添加普通 token:参数是新 token 列表,如果 token 不在词表中,就会被添加到词表的最后。checkpoint = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(checkpoint) num_added_toks = tokenizer.add_tokens(["new_token1", "my_new-token2"]) print("We have added", num_added_toks, "tokens")We have added 2 tokens为了防止 token 已经包含在词表中,我们还可以预先对新 token 列表进行过滤:
new_tokens = ["new_token1", "my_new-token2"] new_tokens = set(new_tokens) - set(tokenizer.vocab.keys()) tokenizer.add_tokens(list(new_tokens)) -
add_special_tokens()添加特殊 token:参数是包含特殊 token 的字典,键值只能从bos_token,eos_token,unk_token,sep_token,pad_token,cls_token,mask_token,additional_special_tokens中选择。同样地,如果 token 不在词表中,就会被添加到词表的最后。添加后,还可以通过特殊属性来访问这些 token,例如tokenizer.cls_token就指向 cls token。checkpoint = "bert-base-uncased" tokenizer = AutoTokenizer.from_pretrained(checkpoint) special_tokens_dict = {"cls_token": "[MY_CLS]"} num_added_toks = tokenizer.add_special_tokens(special_tokens_dict) print("We have added", num_added_toks, "tokens") assert tokenizer.cls_token == "[MY_CLS]"We have added 1 tokens我们也可以使用
add_tokens()添加特殊 token,只需要额外设置参数special_tokens=True:checkpoint="bert-base-uncased"
tokenizer=AutoTokenizer.from_pretrained(checkpoint)
num_added_toks=tokenizer.add_tokens(["[NEW_tok1]", "[NEW_tok2]"])
num_added_toks=tokenizer.add_tokens(["[NEW_tok3]", "[NEW_tok4]"], special_tokens=True)
print("We have added", num_added_toks, "tokens")
print(tokenizer.tokenize('[NEW_tok1] Hello [NEW_tok2] [NEW_tok3] World [NEW_tok4]!'))We have added 2 tokens
['[new_tok1]', 'hello', '[new_tok2]', '[NEW_tok3]', 'world', '[NEW_tok4]', '!']特殊 token 的标准化 (normalization) 与普通 token 有一些不同,比如不会被小写。
这里我们使用的是不区分大小写的 BERT 模型,因此分词后添加的普通 token [NEW_tok1] 和 [NEW_tok2] 都被处理为了小写,而添加的特殊 token [NEW_tok3] 和 [NEW_tok4] 则保持大写。
对于前面的例子,很明显实体标记符 [ENT_START] 和 [ENT_END] 属于特殊 token,因此按添加特殊 token 的方式进行:
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
num_added_toks = tokenizer.add_tokens(["[NEW_tok1]", "[NEW_tok2]"])
num_added_toks = tokenizer.add_tokens(["[NEW_tok3]", "[NEW_tok4]"], special_tokens=True)
print("We have added", num_added_toks, "tokens")
print(tokenizer.tokenize('[NEW_tok1] Hello [NEW_tok2] [NEW_tok3] World [NEW_tok4]!')) We have added 2 tokens
['two', '[ENT_START]', 'cars', '[ENT_END]', 'collided', 'in', 'a', '[ENT_START]', 'tunnel', '[ENT_END]', 'this', 'morning', '.']
可以看到,分词器成功地将 [ENT_START] 和 [ENT_END] 识别为 token,并且保持大写。
调整 embedding 矩阵
向词表中添加新 token 后,必须重置模型 embedding 矩阵的大小,也就是向矩阵中添加新 token 对应的 embedding,这样模型才可以正常工作,将 token 映射到对应的 embedding。
调整 embedding 矩阵通过 resize_token_embeddings() 函数来实现,例如对于前面的例子:
from transformers import AutoTokenizer, AutoModel
checkpoint = "bert-base-uncased"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
model = AutoModel.from_pretrained(checkpoint)
print('vocabulary size:', len(tokenizer))
num_added_toks = tokenizer.add_tokens(['[ENT_START]', '[ENT_END]'], special_tokens=True)
print("After we add", num_added_toks, "tokens")
print('vocabulary size:', len(tokenizer))
model.resize_token_embeddings(len(tokenizer))
print(model.embeddings.word_embeddings.weight.size())
# Randomly generated matrix
print(model.embeddings.word_embeddings.weight[-2:, :]) vocabulary size: 30522
After we add 2 tokens
vocabulary size: 30524
torch.Size([30524, 768])
tensor([[-0.0325, -0.0224, 0.0044, ..., -0.0088, -0.0078, -0.0110],
[-0.0005, -0.0167, -0.0009, ..., 0.0110, -0.0282, -0.0013]],
grad_fn=<SliceBackward0>) 可以看到,在添加 [ENT_START] 和 [ENT_END] 之后,分词器的词表大小从 30522 增加到了 30524,模型 embedding 矩阵的大小也成功调整为了 30524×768。
在默认情况下,新添加 token 的 embedding 是随机初始化的。
我们尝试打印出新添加 token 对应的 embedding(新 token 会添加在词表的末尾,因此只需打印出最后两行)。如果你多次运行上面的代码,就会发现每次打印出的 [ENT_START] 和 [ENT_END] 的 embedding 是不同的。
5.5 Token embedding 初始化
如果有充分的语料对模型进行微调或者继续预训练,那么将新添加 token 初始化为随机向量没什么问题。但是如果训练语料较少,甚至是只有很少语料的 few-shot learning 场景下,这种做法就存在问题。研究表明,在训练数据不够多的情况下,这些新添加 token 的 embedding 只会在初始值附近小幅波动。换句话说,即使经过训练,它们的值事实上还是随机的。
直接赋值
因此,在很多情况下,我们需要手工初始化新添加 token 的 embedding,这可以通过直接对 embedding 矩阵赋值来实现。例如我们将上面例子中两个新 token 的 embedding 都初始化为全零向量:
import torch
with torch.no_grad():
model.embeddings.word_embeddings.weight[-2:, :] = torch.zeros([2, model.config.hidden_size], requires_grad=True)
print(model.embeddings.word_embeddings.weight[-2:, :]) tensor([[0., 0., 0., ..., 0., 0., 0.],
[0., 0., 0., ..., 0., 0., 0.]], grad_fn=<SliceBackward0>) 注意,初始化 embedding 的过程并不可导,因此这里通过 torch.no_grad() 暂停梯度的计算。
现实场景中,更为常见的做法是使用已有 token 的 embedding 来初始化新添加 token。例如对于上面的例子,我们可以将 [ENT_START] 和 [ENT_END] 的值都初始化为“entity” token 对应的 embedding。
import torch
token_id = tokenizer.convert_tokens_to_ids('entity')
token_embedding = model.embeddings.word_embeddings.weight[token_id]
print(token_id)
with torch.no_grad():
for i in range(1, num_added_toks+1):
model.embeddings.word_embeddings.weight[-i:, :] = token_embedding.clone().detach().requires_grad_(True)
print(model.embeddings.word_embeddings.weight[-2:, :]) 9178
tensor([[-0.0039, -0.0131, -0.0946, ..., -0.0223, 0.0107, -0.0419],
[-0.0039, -0.0131, -0.0946, ..., -0.0223, 0.0107, -0.0419]],
grad_fn=<SliceBackward0>) 因为 token ID 就是 token 在 embedding 矩阵中的索引,因此这里我们直接通过 weight[token_id] 取出“entity”对应的 embedding。
可以看到最终结果符合我们的预期,[ENT_START] 和 [ENT_END] 被初始化为相同的 embedding。
初始化为已有 token 的值
更为高级的做法是根据新添加 token 的语义来进行初始化。例如将值初始化为 token 语义描述中所有 token 的平均值,假设新 token ti 的语义描述为 wi,1,wi,2,…,wi,n,那么初始化 ti 的 embedding 为: E(ti)=1n∑j=1nE(wi,j)
这里 E 表示预训练模型的 embedding 矩阵。对于上面的例子,我们可以分别为 [ENT_START] 和 [ENT_END] 编写对应的描述,然后再对它们的值进行初始化:
descriptions = ['start of entity', 'end of entity']
with torch.no_grad():
for i, token in enumerate(reversed(descriptions), start=1):
tokenized = tokenizer.tokenize(token)
print(tokenized)
tokenized_ids = tokenizer.convert_tokens_to_ids(tokenized)
new_embedding = model.embeddings.word_embeddings.weight[tokenized_ids].mean(axis=0)
model.embeddings.word_embeddings.weight[-i, :] = new_embedding.clone().detach().requires_grad_(True)
print(model.embeddings.word_embeddings.weight[-2:, :]) ['end', 'of', 'entity']
['start', 'of', 'entity']
tensor([[-0.0340, -0.0144, -0.0441, ..., -0.0016, 0.0318, -0.0151],
[-0.0060, -0.0202, -0.0312, ..., -0.0084, 0.0193, -0.0296]],
grad_fn=<SliceBackward0>) 可以看到,这里成功地将 [ENT_START] 的 embedding 初始化为“start”、“of”、“entity”三个 token 的平均值,将 [ENT_END] 初始化为“end”、“of”、“entity”的平均值。
第六章:必要的 Pytorch 知识
在上一章中,我们介绍了 Model 类和 Tokenizers 类,尤其是如何运用分词器对文本进行预处理。
Transformers 库建立在 Pytorch 框架之上(Tensorflow 的版本功能并不完善),虽然官方宣称使用 Transformers 库并不需要掌握 Pytorch 知识,但是实际上我们还是需要通过 Pytorch 的 DataLoader 类来加载数据、使用 Pytorch 的优化器对模型参数进行调整等等。
因此,本章将介绍 Pytorch 的一些基础概念以及后续可能会使用到的类,让大家可以快速上手使用 Transformers 库建立模型。
1. Pytorch 基础
Pytorch 由 Facebook 人工智能研究院于 2017 年推出,具有强大的 GPU 加速张量计算功能,并且能够自动进行微分计算,从而可以使用基于梯度的方法对模型参数进行优化。截至 2022 年 8 月,PyTorch 已经和 Linux 内核、Kubernetes 等并列成为世界上增长最快的 5 个开源社区之一。现在在 NeurIPS、ICML 等等机器学习顶会中,有超过 80% 研究人员用的都是 PyTorch。
为了确保商业化和技术治理之间的相互独立,2022 年 9 月 12 日 PyTorch 官方宣布正式加入 Linux 基金会,PyTorch 基金会董事会包括 Meta、AMD、亚马逊、谷歌、微软、Nvidia 等公司。
张量
张量 (Tensor) 是深度学习的基础,例如常见的 0 维张量称为标量 (scalar)、1 维张量称为向量 (vector)、2 维张量称为矩阵 (matrix)。Pytorch 本质上就是一个基于张量的数学计算工具包,它提供了多种方式来创建张量:
>>> import torch
>>> torch.empty(2, 3) # empty tensor (uninitialized), shape (2,3)
tensor([[2.7508e+23, 4.3546e+27, 7.5571e+31],
[2.0283e-19, 3.0981e+32, 1.8496e+20]])
>>> torch.rand(2, 3) # random tensor, each value taken from [0,1)
tensor([[0.8892, 0.2503, 0.2827],
[0.9474, 0.5373, 0.4672]])
>>> torch.randn(2, 3) # random tensor, each value taken from standard normal distribution
tensor([[-0.4541, -1.1986, 0.1952],
[ 0.9518, 1.3268, -0.4778]])
>>> torch.zeros(2, 3, dtype=torch.long) # long integer zero tensor
tensor([[0, 0, 0],
[0, 0, 0]])
>>> torch.zeros(2, 3, dtype=torch.double) # double float zero tensor
tensor([[0., 0., 0.],
[0., 0., 0.]], dtype=torch.float64)
>>> torch.arange(10)
tensor([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]) 也可以通过 torch.tensor() 或者 torch.from_numpy() 基于已有的数组或 Numpy 数组创建张量:
>>> array = [[1.0, 3.8, 2.1], [8.6, 4.0, 2.4]]
>>> torch.tensor(array)
tensor([[1.0000, 3.8000, 2.1000],
[8.6000, 4.0000, 2.4000]])
>>> import numpy as np
>>> array = np.array([[1.0, 3.8, 2.1], [8.6, 4.0, 2.4]])
>>> torch.from_numpy(array)
tensor([[1.0000, 3.8000, 2.1000],
[8.6000, 4.0000, 2.4000]], dtype=torch.float64) 注意:上面这些方式创建的张量会存储在内存中并使用 CPU 进行计算,如果想要调用 GPU 计算,需要直接在 GPU 中创建张量或者将张量送入到 GPU 中:
>>> torch.rand(2, 3).cuda()
tensor([[0.0405, 0.1489, 0.8197],
[0.9589, 0.0379, 0.5734]], device='cuda:0')
>>> torch.rand(2, 3, device="cuda")
tensor([[0.0405, 0.1489, 0.8197],
[0.9589, 0.0379, 0.5734]], device='cuda:0')
>>> torch.rand(2, 3).to("cuda")
tensor([[0.9474, 0.7882, 0.3053],
[0.6759, 0.1196, 0.7484]], device='cuda:0') 在后续章节中,我们经常会将编码后的文本张量通过 to(device) 送入到指定的 GPU 或 CPU 中。
张量计算
张量的加减乘除是按元素进行计算的,例如:
>>> x = torch.tensor([1, 2, 3], dtype=torch.double)
>>> y = torch.tensor([4, 5, 6], dtype=torch.double)
>>> print(x + y)
tensor([5., 7., 9.], dtype=torch.float64)
>>> print(x - y)
tensor([-3., -3., -3.], dtype=torch.float64)
>>> print(x * y)
tensor([ 4., 10., 18.], dtype=torch.float64)
>>> print(x / y)
tensor([0.2500, 0.4000, 0.5000], dtype=torch.float64) Pytorch 还提供了许多常用的计算函数,如 torch.dot() 计算向量点积、torch.mm() 计算矩阵相乘、三角函数和各种数学函数等:
>>> x.dot(y)
tensor(32., dtype=torch.float64)
>>> x.sin()
tensor([0.8415, 0.9093, 0.1411], dtype=torch.float64)
>>> x.exp()
tensor([ 2.7183, 7.3891, 20.0855], dtype=torch.float64) 除了数学运算,Pytorch 还提供了多种张量操作函数,如聚合 (aggregation)、拼接 (concatenation)、比较、随机采样、序列化等,详细使用方法可以参见 Pytorch 官方文档。
对张量进行聚合(如求平均、求和、最大值和最小值等)或拼接操作时,可以指定进行操作的维度 (dim)。例如,计算张量的平均值,在默认情况下会计算所有元素的平均值。:>>> x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.double) >>> x.mean() tensor(3.5000, dtype=torch.float64)更常见的情况是需要计算某一行或某一列的平均值,此时就需要设定计算的维度,例如分别对第 0 维和第 1 维计算平均值:
>>>x.mean(dim=0)
tensor([2.5000, 3.5000, 4.5000], dtype=torch.float64)
>>>x.mean(dim=1)
tensor([2., 5.], dtype=torch.float64)注意,上面的计算自动去除了多余的维度,因此结果从矩阵变成了向量,如果要保持维度不变,可以设置
keepdim=True:
>>>x.mean(dim=0, keepdim=True)
tensor([[2.5000, 3.5000, 4.5000]], dtype=torch.float64)
>>>x.mean(dim=1, keepdim=True)
tensor([[2.],
[5.]], dtype=torch.float64)拼接
torch.cat操作类似,通过指定拼接维度,可以获得不同的拼接结果:
>>>x=torch.tensor([[1, 2, 3], [ 4, 5, 6]], dtype=torch.double)
>>>y=torch.tensor([[7, 8, 9], [10, 11, 12]], dtype=torch.double)
>>>torch.cat((x, y), dim=0)
tensor([[ 1., 2., 3.],
[ 4., 5., 6.],
[ 7., 8., 9.],
[10., 11., 12.]], dtype=torch.float64)
>>>torch.cat((x, y), dim=1)
tensor([[ 1., 2., 3., 7., 8., 9.],
[ 4., 5., 6., 10., 11., 12.]], dtype=torch.float64)
组合使用这些操作就可以写出复杂的数学计算表达式。例如对于
z=(x+y)×(y−2)
当 x=2,y=3 时,很容易计算出 z=5。使用 Pytorch 来实现这一计算过程与 Python 非常类似,唯一的不同是数据使用张量进行保存:
>>> x = torch.tensor([2.])
>>> y = torch.tensor([3.])
>>> z = (x + y) * (y - 2)
>>> print(z)
tensor([5.])
使用 Pytorch 进行计算的好处是更高效的执行速度,尤其当张量存储的数据很多时,而且还可以借助 GPU 进一步提高计算速度。下面以计算三个矩阵相乘的结果为例,我们分别通过 CPU 和 NVIDIA Tesla V100 GPU 来进行:
import torch
import timeit
M = torch.rand(1000, 1000)
print(timeit.timeit(lambda: M.mm(M).mm(M), number=5000))
N = torch.rand(1000, 1000).cuda()
print(timeit.timeit(lambda: N.mm(N).mm(N), number=5000))
77.78975469999999
1.6584811117500067
可以看到使用 GPU 能够明显地提高计算效率。
自动微分
Pytorch 提供自动计算梯度的功能,可以自动计算一个函数关于一个变量在某一取值下的导数,从而基于梯度对参数进行优化,这就是机器学习中的训练过程。使用 Pytorch 计算梯度非常容易,只需要执行 tensor.backward(),就会自动通过反向传播 (Back Propogation) 算法完成,后面我们在训练模型时就会用到该函数。
注意,为了计算一个函数关于某一变量的导数,Pytorch 要求显式地设置该变量是可求导的,即在张量生成时,设置 requires_grad=True。我们对上面计算 z=(x+y)×(y−2) 的代码进行简单修改,就可以计算当 x=2,y=3 时,dzdx 和 dzdy 的值。
>>> x = torch.tensor([2.], requires_grad=True)
>>> y = torch.tensor([3.], requires_grad=True)
>>> z = (x + y) * (y - 2)
>>> print(z)
tensor([5.], grad_fn=<MulBackward0>)
>>> z.backward()
>>> print(x.grad, y.grad)
tensor([1.]) tensor([6.])
很容易手工求解 dzdx=y−2,dzdy=x+2y−2,当 x=2,y=3 时,dzdx=1 和 dzdy=6,与 Pytorch 代码计算结果一致。
调整张量形状
有时我们需要对张量的形状进行调整,Pytorch 共提供了 4 种调整张量形状的函数,分别为:
- 形状转换
view将张量转换为新的形状,需要保证总的元素个数不变,例如:>>>x=torch.tensor([1, 2, 3, 4, 5, 6])
>>>print(x, x.shape)
tensor([1, 2, 3, 4, 5, 6]) torch.Size([6])
>>>x.view(2, 3)# shape adjusted to (2, 3)
tensor([[1, 2, 3],
[4, 5, 6]])
>>>x.view(3, 2)# shape adjusted to (3, 2)
tensor([[1, 2],
[3, 4],
[5, 6]])
>>>x.view(-1, 3)# -1 means automatic inference
tensor([[1, 2, 3],
[4, 5, 6]])进行 view 操作的张量必须是连续的 (contiguous),可以调用
is_conuous来判断张量是否连续;如果非连续,需要先通过contiguous函数将其变为连续的。也可以直接调用 Pytorch 新提供的reshape函数,它与view功能几乎一致,并且能够自动处理非连续张量。 - 转置
transpose交换张量中的两个维度,参数为相应的维度:>>>x=torch.tensor([[1, 2, 3], [4, 5, 6]])
>>>x
tensor([[1, 2, 3],
[4, 5, 6]])
>>>x.transpose(0, 1)
tensor([[1, 4],
[2, 5],
[3, 6]]) - 交换维度
permute与transpose函数每次只能交换两个维度不同,permute可以直接设置新的维度排列方式:>>>x=torch.tensor([[[1, 2, 3], [4, 5, 6]]])
>>>print(x, x.shape)
tensor([[[1, 2, 3],
[4, 5, 6]]]) torch.Size([1, 2, 3])
>>>x=x.permute(2, 0, 1)
>>>print(x, x.shape)
tensor([[[1, 4]],
[[2, 5]],
[[3, 6]]]) torch.Size([3, 1, 2])
广播机制
前面我们都是假设参与运算的两个张量形状相同。在有些情况下,即使两个张量形状不同,也可以通过广播机制 (broadcasting mechanism) 对其中一个或者同时对两个张量的元素进行复制,使得它们形状相同,然后再执行按元素计算。
例如,我们生成两个形状不同的张量:
>>> x = torch.arange(1, 4).view(3, 1)
>>> y = torch.arange(4, 6).view(1, 2)
>>> print(x)
tensor([[1],
[2],
[3]])
>>> print(y)
tensor([[4, 5]])
它们形状分别为 (3,1) 和 (1,2),如果要进行按元素运算,必须将它们都扩展为形状 (3,2) 的张量。具体地,就是将 x 的第 1 列复制到第 2 列,将 y 的第 1 行复制到第 2、3 行。实际上,我们可以直接进行运算,Pytorch 会自动执行广播:
>>> print(x + y)
tensor([[5, 6],
[6, 7],
[7, 8]])
索引与切片
与 Python 列表类似,Pytorch 也可以对张量进行索引和切片。索引值同样是从 0 开始,切片 [m:n] 的范围是从 m 到 n 前一个元素结束,并且可以对张量的任意一个维度进行索引或切片。例如:
>>> x = torch.arange(12).view(3, 4)
>>> x
tensor([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
>>> x[1, 3] # element at row 1, column 3
tensor(7)
>>> x[1] # all elements in row 1
tensor([4, 5, 6, 7])
>>> x[1:3] # elements in row 1 & 2
tensor([[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
>>> x[:, 2] # all elements in column 2
tensor([ 2, 6, 10])
>>> x[:, 2:4] # elements in column 2 & 3
tensor([[ 2, 3],
[ 6, 7],
[10, 11]])
>>> x[:, 2:4] = 100 # set elements in column 2 & 3 to 100
>>> x
tensor([[ 0, 1, 100, 100],
[ 4, 5, 100, 100],
[ 8, 9, 100, 100]])
降维与升维
有时为了计算需要对一个张量进行降维或升维。例如神经网络通常只接受一个批次 (batch) 的样例作为输入,如果只有 1 个输入样例,就需要手工添加一个 batch 维度。具体地:
- 升维
torch.unsqueeze(input, dim, out=None)在输入张量的 dim 位置插入一维,与索引一样,dim 值也可以为负数; - 降维
torch.squeeze(input, dim=None, out=None)在不指定 dim 时,张量中所有形状为 1 的维度都会被删除,例如 会变成 ;当给定 dim 时,只会删除给定的维度(形状必须为 1),例如对于 ,squeeze(input, dim=0)会保持张量不变,只有squeeze(input, dim=1)形状才会变成 。(A, 1, B, 1, C)
(A, B, C)
(A, 1, B)
(A, B)
下面是一些示例:
>>> a = torch.tensor([1, 2, 3, 4])
>>> a.shape
torch.Size([4])
>>> b = torch.unsqueeze(a, dim=0)
>>> print(b, b.shape)
tensor([[1, 2, 3, 4]]) torch.Size([1, 4])
>>> b = a.unsqueeze(dim=0) # another way to unsqueeze tensor
>>> print(b, b.shape)
tensor([[1, 2, 3, 4]]) torch.Size([1, 4])
>>> c = b.squeeze()
>>> print(c, c.shape)
tensor([1, 2, 3, 4]) torch.Size([4])
2. 加载数据
Pytorch 提供了 DataLoader 和 Dataset 类(或 IterableDataset)专门用于处理数据,它们既可以加载 Pytorch 预置的数据集,也可以加载自定义数据。其中数据集类 Dataset(或 IterableDataset)负责存储样本以及它们对应的标签;数据加载类 DataLoader 负责迭代地访问数据集中的样本。
Dataset
数据集负责存储数据样本,所有的数据集类都必须继承自 Dataset 或 IterableDataset。具体地,Pytorch 支持两种形式的数据集:
- 映射型 (Map-style) 数据集
继承自
Dataset类,表示一个从索引到样本的映射(索引可以不是整数),这样我们就可以方便地通过dataset[idx]来访问指定索引的样本。这也是目前最常见的数据集类型。映射型数据集必须实现__getitem__()函数,其负责根据指定的 key 返回对应的样本。一般还会实现__len__()用于返回数据集的大小。DataLoader 在默认情况下会创建一个生成整数索引的索引采样器 (sampler) 用于遍历数据集。因此,如果我们加载的是一个非整数索引的映射型数据集,还需要手工定义采样器。
- 迭代型 (Iterable-style) 数据集
继承自
IterableDataset,表示可迭代的数据集,它可以通过iter(dataset)以数据流 (steam) 的形式访问,适用于访问超大数据集或者远程服务器产生的数据。 迭代型数据集必须实现__iter__()函数,用于返回一个样本迭代器 (iterator)。注意:如果在 DataLoader 中开启多进程(num_workers > 0),那么在加载迭代型数据集时必须进行专门的设置,否则会重复访问样本。例如:
fromtorch.utils.dataimportIterableDataset, DataLoader
classMyIterableDataset(IterableDataset):
def__init__(self, start, end):
super(MyIterableDataset).__init__()
assertend>start
self.start=start
self.end=end
def__iter__(self):
returniter(range(self.start, self.end))
ds=MyIterableDataset(start=3, end=7)# [3, 4, 5, 6]
# Single-process loading
print(list(DataLoader(ds, num_workers=0)))
# Directly doing multi-process loading
print(list(DataLoader(ds, num_workers=2)))[tensor([3]), tensor([4]), tensor([5]), tensor([6])]
[tensor([3]), tensor([3]), tensor([4]), tensor([4]), tensor([5]), tensor([5]), tensor([6]), tensor([6])]可以看到,当 DataLoader 采用 2 个进程时,由于每个进程都获取到了单独的数据集拷贝,因此会重复访问每一个样本。要避免这种情况,就需要在 DataLoader 中设置
worker_init_fn来自定义每一个进程的数据集拷贝:fromtorch.utils.dataimportget_worker_info
defworker_init_fn(worker_id):
worker_info=get_worker_info()
dataset=worker_info.dataset# the dataset copy in this worker process
overall_start=dataset.start
overall_end=dataset.end
# configure the dataset to only process the split workload
per_worker=int(math.ceil((overall_end-overall_start)/float(worker_info.num_workers)))
worker_id=worker_info.id
dataset.start=overall_start+worker_id*per_worker
dataset.end=min(dataset.start+per_worker, overall_end)
# Worker 0 fetched [3, 4]. Worker 1 fetched [5, 6].
print(list(DataLoader(ds, num_workers=2, worker_init_fn=worker_init_fn)))
# With even more workers
print(list(DataLoader(ds, num_workers=20, worker_init_fn=worker_init_fn)))[tensor([3]), tensor([5]), tensor([4]), tensor([6])]
[tensor([3]), tensor([4]), tensor([5]), tensor([6])]
下面我们以加载一个图像分类数据集为例,看看如何创建一个自定义的映射型数据集:
import os
import pandas as pd
from torchvision.io import read_image
from torch.utils.data import Dataset
class CustomImageDataset(Dataset):
def __init__(self, annotations_file, img_dir, transform=None, target_transform=None):
self.img_labels = pd.read_csv(annotations_file)
self.img_dir = img_dir
self.transform = transform
self.target_transform = target_transform
def __len__(self):
return len(self.img_labels)
def __getitem__(self, idx):
img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
image = read_image(img_path)
label = self.img_labels.iloc[idx, 1]
if self.transform:
image = self.transform(image)
if self.target_transform:
label = self.target_transform(label)
return image, label
可以看到,我们实现了 __init__()、__len__() 和 __getitem__() 三个函数,其中:
-
__init__()初始化数据集参数,这里设置了图像的存储目录、标签(通过读取标签 csv 文件)以及样本和标签的数据转换函数; -
__len__()返回数据集中样本的个数; -
__getitem__()映射型数据集的核心,根据给定的索引idx返回样本。这里会根据索引从目录下通过read_image读取图片和从 csv 文件中读取图片标签,并且返回处理后的图像和标签。
DataLoaders
前面的数据集 Dataset 类提供了一种按照索引访问样本的方式。不过在实际训练模型时,我们都需要先将数据集切分为很多的 mini-batches,然后按批 (batch) 将样本送入模型,并且循环这一过程,每一个完整遍历所有样本的循环称为一个 epoch。
训练模型时,我们通常会在每次 epoch 循环开始前随机打乱样本顺序以缓解过拟合。
Pytorch 提供了 DataLoader 类专门负责处理这些操作,除了基本的 dataset(数据集)和 batch_size (batch 大小)参数以外,还有以下常用参数:
-
shuffle:是否打乱数据集; -
sampler:采样器,也就是一个索引上的迭代器; -
collate_fn:批处理函数,用于对采样出的一个 batch 中的样本进行处理(例如前面提过的 Padding 操作)。
例如,我们按照 batch = 64 遍历 Pytorch 自带的图像分类 FashionMNIST 数据集(每个样本是一张 28×28 的灰度图,以及分类标签),并且打乱数据集:
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
training_data = datasets.FashionMNIST(
root="data",
train=True,
download=True,
transform=ToTensor()
)
test_data = datasets.FashionMNIST(
root="data",
train=False,
download=True,
transform=ToTensor()
)
train_dataloader = DataLoader(training_data, batch_size=64, shuffle=True)
test_dataloader = DataLoader(test_data, batch_size=64, shuffle=True)
train_features, train_labels = next(iter(train_dataloader))
print(f"Feature batch shape: {train_features.size()}")
print(f"Labels batch shape: {train_labels.size()}")
img = train_features[0].squeeze()
label = train_labels[0]
print(img.shape)
print(f"Label: {label}")
Feature batch shape: torch.Size([64, 1, 28, 28])
Labels batch shape: torch.Size([64])
torch.Size([28, 28])
Label: 8
数据加载顺序和 Sampler 类
对于迭代型数据集来说,数据的加载顺序直接由用户控制,用户可以精确地控制每一个 batch 中返回的样本,因此不需要使用 Sampler 类。
对于映射型数据集来说,由于索引可以不是整数,因此我们可以通过 Sampler 对象来设置加载时的索引序列,即设置一个索引上的迭代器。如果设置了 shuffle 参数,DataLoader 就会自动创建一个顺序或乱序的 sampler,我们也可以通过 sampler 参数传入一个自定义的 Sampler 对象。
常见的 Sampler 对象包括序列采样器 SequentialSampler 和随机采样器 RandomSampler,它们都通过传入待采样的数据集来创建:
from torch.utils.data import DataLoader
from torch.utils.data import SequentialSampler, RandomSampler
from torchvision import datasets
from torchvision.transforms import ToTensor
training_data = datasets.FashionMNIST(
root="data",
train=True,
download=True,
transform=ToTensor()
)
test_data = datasets.FashionMNIST(
root="data",
train=False,
download=True,
transform=ToTensor()
)
train_sampler = RandomSampler(training_data)
test_sampler = SequentialSampler(test_data)
train_dataloader = DataLoader(training_data, batch_size=64, sampler=train_sampler)
test_dataloader = DataLoader(test_data, batch_size=64, sampler=test_sampler)
train_features, train_labels = next(iter(train_dataloader))
print(f"Feature batch shape: {train_features.size()}")
print(f"Labels batch shape: {train_labels.size()}")
test_features, test_labels = next(iter(test_dataloader))
print(f"Feature batch shape: {test_features.size()}")
print(f"Labels batch shape: {test_labels.size()}")
Feature batch shape: torch.Size([64, 1, 28, 28])
Labels batch shape: torch.Size([64])
Feature batch shape: torch.Size([64, 1, 28, 28])
Labels batch shape: torch.Size([64])
批处理函数 collate_fn
批处理函数 collate_fn 负责对每一个采样出的 batch 中的样本进行处理。默认的 collate_fn 会进行如下操作:
- 添加一个新维度作为 batch 维;
- 自动地将 NumPy 数组和 Python 数值转换为 PyTorch 张量;
- 保留原始的数据结构,例如输入是字典的话,它会输出一个包含同样键 (key) 的字典,但是将值 (value) 替换为 batched 张量(如何可以转换的话)。
例如,如果样本是包含 3 通道的图像和一个整数型类别标签,即 (image, class_index),那么默认的 collate_fn 会将这样的一个元组列表转换为一个包含 batched 图像张量和 batched 类别标签张量的元组。
我们也可以传入手工编写的 collate_fn 函数以对数据进行自定义处理,例如前面我们介绍过的 padding 操作。
3. 训练模型
Pytorch 所有的模块(层)都是 nn.Module 的子类,神经网络模型本身就是一个模块,它还包含了很多其他的模块。
构建模型
我们还是以前面加载的 FashionMNIST 数据库为例,构建一个神经网络模型来完成图像分类。模型同样继承自 nn.Module 类,通过 __init__() 初始化模型中的层和参数,在 forward() 中定义模型的操作,例如:
import torch
from torch import nn
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class NeuralNetwork(nn.Module):
def __init__(self):
super(NeuralNetwork, self).__init__()
self.flatten = nn.Flatten()
self.linear_relu_stack = nn.Sequential(
nn.Linear(28*28, 512),
nn.ReLU(),
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, 10),
nn.Dropout(p=0.2)
)
def forward(self, x):
x = self.flatten(x)
logits = self.linear_relu_stack(x)
return logits
model = NeuralNetwork().to(device)
print(model)
Using cpu device
NeuralNetwork(
(flatten): Flatten(start_dim=1, end_dim=-1)
(linear_relu_stack): Sequential(
(0): Linear(in_features=784, out_features=512, bias=True)
(1): ReLU()
(2): Linear(in_features=512, out_features=256, bias=True)
(3): ReLU()
(4): Linear(in_features=256, out_features=10, bias=True)
(5): Dropout(p=0.2, inplace=False)
)
)
可以看到,我们构建的模型首先将二维图像通过 Flatten 层压成一维向量,然后经过两个带有 ReLU 激活函数的全连接隐藏层,最后送入到一个包含 10 个神经元的分类器以完成 10 分类任务。我们还通过在最终输出前添加 Dropout 层来缓解过拟合。
最终我们构建的模型会输出一个 10 维向量(每一维对应一个类别的预测值),与先前介绍过的 pipeline 模型一样,这里输出的是 logits 值,我们需要再接一个 Softmax 层来计算最终的概率值。下面我们构建一个包含四个伪二维图像的 mini-batch 来进行预测:
import torch
from torch import nn
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class NeuralNetwork(nn.Module):
def __init__(self):
super(NeuralNetwork, self).__init__()
self.flatten = nn.Flatten()
self.linear_relu_stack = nn.Sequential(
nn.Linear(28*28, 512),
nn.ReLU(),
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, 10),
nn.Dropout(p=0.2)
)
def forward(self, x):
x = self.flatten(x)
logits = self.linear_relu_stack(x)
return logits
model = NeuralNetwork().to(device)
X = torch.rand(4, 28, 28, device=device)
logits = model(X)
pred_probab = nn.Softmax(dim=1)(logits)
print(pred_probab.size())
y_pred = pred_probab.argmax(-1)
print(f"Predicted class: {y_pred}")
Using cpu device
torch.Size([4, 10])
Predicted class: tensor([3, 8, 3, 3])
可以看到,模型成功输出了维度为 (4,10) 的预测结果(每个样本输出一个 10 维的概率向量)。最后我们通过 argmax 操作,将输出的概率向量转换为对应的标签。
优化模型参数
在准备好数据、搭建好模型之后,我们就可以开始训练和测试(验证)模型了。正如前面所说,模型训练是一个迭代的过程,每一轮 epoch 迭代中模型都会对输入样本进行预测,然后对预测结果计算损失 (loss),并求 loss 对每一个模型参数的偏导,最后使用优化器更新所有的模型参数。
损失函数 (Loss function) 用于度量预测值与答案之间的差异,模型的训练过程就是最小化损失函数。Pytorch 实现了很多常见的损失函数,例如用于回归任务的均方误差 (Mean Square Error) nn.MSELoss、用于分类任务的负对数似然 (Negative Log Likelihood) nn.NLLLoss、同时结合了 nn.LogSoftmax 和 nn.NLLLoss 的交叉熵损失 (Cross Entropy) nn.CrossEntropyLoss 等。优化器 (Optimization) 使用特定的优化算法(例如随机梯度下降),通过在每一个训练阶段 (step) 减少(基于一个 batch 样本计算的)模型损失来调整模型参数。Pytorch 实现了很多优化器,例如 SGD、ADAM、RMSProp 等。
每一轮迭代 (Epoch) 实际上包含了两个步骤:
- 训练循环 (The Train Loop) 在训练集上进行迭代,尝试收敛到最佳的参数;
- 验证/测试循环 (The Validation/Test Loop) 在测试/验证集上进行迭代以检查模型性能有没有提升。
具体地,在训练循环中,优化器通过以下三个步骤进行优化:
- 调用
optimizer.zero_grad()重设模型参数的梯度。默认情况下梯度会进行累加,为了防止重复计算,在每个训练阶段开始前都需要清零梯度; - 通过
loss.backwards()反向传播预测结果的损失,即计算损失对每一个参数的偏导; - 调用
optimizer.step()根据梯度调整模型的参数。
下面我们选择交叉熵作为损失函数、选择 AdamW 作为优化器,完整的训练循环和测试循环实现如下:
import torch
from torch import nn
from torch.utils.data import DataLoader
from torchvision import datasets
from torchvision.transforms import ToTensor
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
training_data = datasets.FashionMNIST(
root="data",
train=True,
download=True,
transform=ToTensor()
)
test_data = datasets.FashionMNIST(
root="data",
train=False,
download=True,
transform=ToTensor()
)
learning_rate = 1e-3
batch_size = 64
epochs = 3
train_dataloader = DataLoader(training_data, batch_size=batch_size)
test_dataloader = DataLoader(test_data, batch_size=batch_size)
class NeuralNetwork(nn.Module):
def __init__(self):
super(NeuralNetwork, self).__init__()
self.flatten = nn.Flatten()
self.linear_relu_stack = nn.Sequential(
nn.Linear(28*28, 512),
nn.ReLU(),
nn.Linear(512, 256),
nn.ReLU(),
nn.Linear(256, 10),
nn.Dropout(p=0.2)
)
def forward(self, x):
x = self.flatten(x)
logits = self.linear_relu_stack(x)
return logits
model = NeuralNetwork().to(device)
def train_loop(dataloader, model, loss_fn, optimizer):
size = len(dataloader.dataset)
model.train()
for batch, (X, y) in enumerate(dataloader, start=1):
X, y = X.to(device), y.to(device)
# Compute prediction and loss
pred = model(X)
loss = loss_fn(pred, y)
# Backpropagation
optimizer.zero_grad()
loss.backward()
optimizer.step()
if batch % 100 == 0:
loss, current = loss.item(), batch * len(X)
print(f"loss: {loss:>7f} [{current:>5d}/{size:>5d}]")
def test_loop(dataloader, model, loss_fn):
size = len(dataloader.dataset)
num_batches = len(dataloader)
test_loss, correct = 0, 0
model.eval()
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
test_loss += loss_fn(pred, y).item()
correct += (pred.argmax(dim=-1) == y).type(torch.float).sum().item()
test_loss /= num_batches
correct /= size
print(f"Test Error: \n Accuracy: {(100*correct):>0.1f}%, Avg loss: {test_loss:>8f} \n")
loss_fn = nn.CrossEntropyLoss()
optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)
for t in range(epochs):
print(f"Epoch {t+1}\n-------------------------------")
train_loop(train_dataloader, model, loss_fn, optimizer)
test_loop(test_dataloader, model, loss_fn)
print("Done!")
Using cpu device
Epoch 1
-------------------------------
loss: 0.935758 [ 6400/60000]
loss: 0.991128 [12800/60000]
loss: 0.655021 [19200/60000]
loss: 0.938772 [25600/60000]
loss: 0.480326 [32000/60000]
loss: 0.526776 [38400/60000]
loss: 1.046211 [44800/60000]
loss: 0.749002 [51200/60000]
loss: 0.550378 [57600/60000]
Test Error:
Accuracy: 83.7%, Avg loss: 0.441249
Epoch 2
-------------------------------
loss: 0.596351 [ 6400/60000]
loss: 0.614368 [12800/60000]
loss: 0.588207 [19200/60000]
loss: 0.698899 [25600/60000]
loss: 0.433412 [32000/60000]
loss: 0.533789 [38400/60000]
loss: 0.772370 [44800/60000]
loss: 0.486120 [51200/60000]
loss: 0.534202 [57600/60000]
Test Error:
Accuracy: 85.4%, Avg loss: 0.396990
Epoch 3
-------------------------------
loss: 0.547906 [ 6400/60000]
loss: 0.591556 [12800/60000]
loss: 0.537591 [19200/60000]
loss: 0.722009 [25600/60000]
loss: 0.319590 [32000/60000]
loss: 0.504153 [38400/60000]
loss: 0.797246 [44800/60000]
loss: 0.553834 [51200/60000]
loss: 0.400079 [57600/60000]
Test Error:
Accuracy: 87.2%, Avg loss: 0.355058
Done!
可以看到,通过 3 轮迭代 (Epoch),模型在训练集上的损失逐步下降、在测试集上的准确率逐步上升,证明优化器成功地对模型参数进行了调整,而且没有出现过拟合。
注意:一定要在预测之前调用 model.eval() 方法将 dropout 层和 batch normalization 层设置为评估模式,否则会产生不一致的预测结果。
4. 保存及加载模型
在之前的文章中,我们介绍过模型类 Model 的保存以及加载方法,但如果我们只是将预训练模型作为一个模块(例如作为编码器),那么最终的完整模型就是一个自定义 Pytorch 模型,它的保存和加载就必须使用 Pytorch 预设的接口。
保存和加载模型权重
Pytorch 模型会将所有参数存储在一个状态字典 (state dictionary) 中,可以通过 Model.state_dict() 加载。Pytorch 通过 torch.save() 保存模型权重:
import torch
import torchvision.models as models
model = models.vgg16(pretrained=True)
torch.save(model.state_dict(), 'model_weights.pth')
为了加载保存的权重,我们首先需要创建一个结构完全相同的模型实例,然后通过 Model.load_state_dict() 函数进行加载:
model = models.vgg16() # we do not specify pretrained=True, i.e. do not load default weights
model.load_state_dict(torch.load('model_weights.pth'))
model.eval()
保存和加载完整模型
上面存储模型权重的方式虽然可以节省空间,但是加载前需要构建一个结构完全相同的模型实例来承接权重。如果我们希望在存储权重的同时,也一起保存模型结构,就需要将整个模型传给 torch.save() :
import torch
import torchvision.models as models
model = models.vgg16(pretrained=True)
torch.save(model, 'model.pth')
这样就可以直接从保存的文件中加载整个模型(包括权重和结构):
model = torch.load('model.pth')
代码
本章核心代码存储于:How-to-use-Transformers/train_model_FashionMNIST.py
第七章:微调预训练模型
在上一篇《必要的 Pytorch 知识》中,我们介绍了使用 Transformers 库必须要掌握的 Pytorch 知识。 本文我们将正式上手微调一个句子对分类模型,并且保存验证集上最好的模型权重。
1. 加载数据集
我们以同义句判断任务为例(每次输入两个句子,判断它们是否为同义句),带大家构建我们的第一个 Transformers 模型。我们选择蚂蚁金融语义相似度数据集 AFQMC 作为语料,它提供了官方的数据划分,训练集 / 验证集 / 测试集分别包含 34334 / 4316 / 3861 个句子对,标签 0 表示非同义句,1 表示同义句:
{"sentence1": "还款还清了,为什么花呗账单显示还要还款", "sentence2": "花呗全额还清怎么显示没有还款", "label": "1"}
Dataset
Pytorch 通过 Dataset 类和 DataLoader 类处理数据集和加载样本。同样地,这里我们首先继承 Dataset 类构造自定义数据集,以组织样本和标签。AFQMC 样本以 json 格式存储,因此我们使用 json 库按行读取样本,并且以行号作为索引构建数据集。
from torch.utils.data import Dataset
import json
class AFQMC(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt') as f:
for idx, line in enumerate(f):
sample = json.loads(line.strip())
Data[idx] = sample
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = AFQMC('data/afqmc_public/train.json')
valid_data = AFQMC('data/afqmc_public/dev.json')
print(train_data[0]) {'sentence1': '蚂蚁借呗等额还款可以换成先息后本吗', 'sentence2': '借呗有先息到期还本吗', 'label': '0'}
可以看到,我们编写的 AFQMC 类成功读取了数据集,每一个样本都以字典形式保存,分别以 sentence1、sentence2 和 label 为键存储句子对和标签。
如果数据集非常巨大,难以一次性加载到内存中,我们也可以继承 IterableDataset 类构建迭代型数据集:
from torch.utils.data import IterableDataset
import json
class IterableAFQMC(IterableDataset):
def __init__(self, data_file):
self.data_file = data_file
def __iter__(self):
with open(self.data_file, 'rt') as f:
for line in f:
sample = json.loads(line.strip())
yield sample
train_data = IterableAFQMC('data/afqmc_public/train.json')
print(next(iter(train_data))) {'sentence1': '蚂蚁借呗等额还款可以换成先息后本吗', 'sentence2': '借呗有先息到期还本吗', 'label': '0'}
DataLoader
接下来就需要通过 DataLoader 库按批 (batch) 加载数据,并且将样本转换成模型可以接受的输入格式。对于 NLP 任务,这个环节就是将每个 batch 中的文本按照预训练模型的格式进行编码(包括 Padding、截断等操作)。
我们通过手工编写 DataLoader 的批处理函数 collate_fn 来实现。首先加载分词器,然后对每个 batch 中的所有句子对进行编码,同时把标签转换为张量格式:
import torch
from torch.utils.data import DataLoader
from transformers import AutoTokenizer
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
def collote_fn(batch_samples):
batch_sentence_1, batch_sentence_2 = [], []
batch_label = []
for sample in batch_samples:
batch_sentence_1.append(sample['sentence1'])
batch_sentence_2.append(sample['sentence2'])
batch_label.append(int(sample['label']))
X = tokenizer(
batch_sentence_1,
batch_sentence_2,
padding=True,
truncation=True,
return_tensors="pt"
)
y = torch.tensor(batch_label)
return X, y
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=collote_fn)
batch_X, batch_y = next(iter(train_dataloader))
print('batch_X shape:', {k: v.shape for k, v in batch_X.items()})
print('batch_y shape:', batch_y.shape)
print(batch_X)
print(batch_y) batch_X shape: {
'input_ids': torch.Size([4, 39]),
'token_type_ids': torch.Size([4, 39]),
'attention_mask': torch.Size([4, 39])
}
batch_y shape: torch.Size([4])
{'input_ids': tensor([
[ 101, 5709, 1446, 5543, 3118, 802, 736, 3952, 3952, 2767, 1408, 102,
3952, 2767, 1041, 966, 5543, 4500, 5709, 1446, 1408, 102, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0],
[ 101, 872, 8024, 2769, 6821, 5709, 1446, 4638, 7178, 6820, 2130, 749,
8024, 6929, 2582, 720, 1357, 3867, 749, 102, 1963, 3362, 1357, 3867,
749, 5709, 1446, 722, 1400, 8024, 1355, 4495, 4638, 6842, 3621, 2582,
720, 1215, 102],
[ 101, 1963, 862, 2990, 7770, 955, 1446, 677, 7361, 102, 6010, 6009,
955, 1446, 1963, 862, 712, 1220, 2990, 7583, 2428, 102, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0],
[ 101, 2582, 3416, 2990, 7770, 955, 1446, 7583, 2428, 102, 955, 1446,
2990, 4157, 7583, 2428, 1416, 102, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0]]),
'token_type_ids': tensor([
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])}
tensor([1, 0, 1, 1]) 可以看到,DataLoader 按照我们设置的 batch size 每次对 4 个样本进行编码,并且通过设置 padding=True 和 truncation=True 来自动对每个 batch 中的样本进行补全和截断。这里我们选择 BERT 模型作为 checkpoint,所以每个样本都被处理成了“[CLS] sen1 [SEP] sen2 [SEP]”的形式。
这种只在一个 batch 内进行补全的操作被称为动态补全 (Dynamic padding),Hugging Face 也提供了 DataCollatorWithPadding 类来进行,如果感兴趣可以自行了解。
2. 训练模型
构建模型
对于分类任务,可以直接使用我们前面介绍过的 AutoModelForSequenceClassification 类来完成。但是在实际操作中,除了使用预训练模型编码文本外,我们通常还会进行许多自定义操作,因此在大部分情况下我们都需要自己编写模型。
最简单的方式是首先利用 Transformers 库加载 BERT 模型,然后接一个全连接层完成分类:
from torch import nn
from transformers import AutoModel
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class BertForPairwiseCLS(nn.Module):
def __init__(self):
super(BertForPairwiseCLS, self).__init__()
self.bert_encoder = AutoModel.from_pretrained(checkpoint)
self.dropout = nn.Dropout(0.1)
self.classifier = nn.Linear(768, 2)
def forward(self, x):
bert_output = self.bert_encoder(**x)
cls_vectors = bert_output.last_hidden_state[:, 0, :]
cls_vectors = self.dropout(cls_vectors)
logits = self.classifier(cls_vectors)
return logits
model = BertForPairwiseCLS().to(device)
print(model) Using cpu device
NeuralNetwork(
(bert_encoder): BertModel(
(embeddings): BertEmbeddings(...)
(encoder): BertEncoder(...)
(pooler): BertPooler(
(dense): Linear(in_features=768, out_features=768, bias=True)
(activation): Tanh()
)
)
(dropout): Dropout(p=0.1, inplace=False)
(classifier): Linear(in_features=768, out_features=2, bias=True)
) 这里模型首先将输入送入到 BERT 模型中,将每一个 token 都编码为维度为 768 的向量,然后从输出序列中取出第一个 [CLS] token 的编码表示作为整个句子对的语义表示,再送入到一个线性全连接层中预测两个类别的分数。这种方式简单粗暴,但是相当于在 Transformers 模型外又包了一层,因此无法再调用 Transformers 库预置的模型函数。
更为常见的写法是继承 Transformers 库中的预训练模型来创建自己的模型。例如这里我们可以继承 BERT 模型(BertPreTrainedModel 类)来创建一个与上面模型结构完全相同的分类器:
from torch import nn
from transformers import AutoConfig
from transformers import BertPreTrainedModel, BertModel
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class BertForPairwiseCLS(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(768, 2)
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
cls_vectors = bert_output.last_hidden_state[:, 0, :]
cls_vectors = self.dropout(cls_vectors)
logits = self.classifier(cls_vectors)
return logits
config = AutoConfig.from_pretrained(checkpoint)
model = BertForPairwiseCLS.from_pretrained(checkpoint, config=config).to(device)
print(model) 注意,此时我们的模型是 Transformers 预训练模型的子类,因此需要通过预置的 from_pretrained 函数来加载模型参数。这种方式也使得我们可以更灵活地操作模型细节,例如这里 Dropout 层就可以直接加载 BERT 模型自带的参数值,而不用像上面一样手工赋值。
为了确保模型的输出符合我们的预期,我们尝试将一个 Batch 的数据送入模型:
outputs = model(batch_X)
print(outputs.shape) torch.Size([4, 2])
可以看到模型输出了一个 4×2 的张量,符合我们的预期(每个样本输出 2 维的 logits 值分别表示两个类别的预测分数,batch 内共 4 个样本)。
优化模型参数
正如之前介绍的那样,在训练模型时,我们将每一轮 Epoch 分为训练循环和验证/测试循环。在训练循环中计算损失、优化模型的参数,在验证/测试循环中评估模型的性能:
from tqdm.auto import tqdm
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_step_num = (epoch-1)*len(dataloader)
model.train()
for step, (X, y) in enumerate(dataloader, start=1):
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_step_num + step):>7f}')
progress_bar.update(1)
return total_loss
def test_loop(dataloader, model, mode='Test'):
assert mode in ['Valid', 'Test']
size = len(dataloader.dataset)
correct = 0
model.eval()
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
correct /= size
print(f"{mode} Accuracy: {(100*correct):>0.1f}%\n") 最后,将”训练循环”和”验证/测试循环”组合成 Epoch,就可以进行模型的训练和验证了。
与 Pytorch 类似,Transformers 库同样实现了很多的优化器,并且相比 Pytorch 固定学习率,Transformers 库的优化器会随着训练过程逐步减小学习率(通常会产生更好的效果)。例如我们前面使用过的 AdamW 优化器:
from transformers import AdamW
optimizer = AdamW(model.parameters(), lr=5e-5) 默认情况下,优化器会线性衰减学习率,对于上面的例子,学习率会线性地从 5e-5 降到 0。为了正确地定义学习率调度器,我们需要知道总的训练步数 (step),它等于训练轮数 (Epoch number) 乘以每一轮中的步数(也就是训练 dataloader 的大小):
from transformers import get_scheduler
epochs = 3
num_training_steps = epochs * len(train_dataloader)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=num_training_steps,
)
print(num_training_steps) 25752
完整的训练过程如下(下面结果均为使用 Tesla V100 GPU 训练):
from transformers import AdamW, get_scheduler
learning_rate = 1e-5
epoch_num = 3
loss_fn = nn.CrossEntropyLoss()
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
test_loop(valid_dataloader, model, mode='Valid')
print("Done!") Using cuda device
Epoch 1/3
-------------------------------
loss: 0.552296: 100%|█████████| 8584/8584 [07:16<00:00, 19.65it/s]
Valid Accuracy: 72.1%
Epoch 2/3
-------------------------------
loss: 0.501410: 100%|█████████| 8584/8584 [07:16<00:00, 19.66it/s]
Valid Accuracy: 73.0%
Epoch 3/3
-------------------------------
loss: 0.450708: 100%|█████████| 8584/8584 [07:15<00:00, 19.70it/s]
Valid Accuracy: 74.1%
Done! 保存和加载模型
在大多数情况下,我们还需要根据验证集上的表现来调整超参数以及选出最好的模型,最后再将选出的模型应用于测试集以评估性能。这里我们在测试循环时返回计算出的准确率,然后对上面的 Epoch 训练代码进行小幅的调整,以保存验证集上准确率最高的模型:
def test_loop(dataloader, model, mode='Test'):
assert mode in ['Valid', 'Test']
size = len(dataloader.dataset)
correct = 0
model.eval()
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
correct /= size
print(f"{mode} Accuracy: {(100*correct):>0.1f}%\n")
return correct
total_loss = 0.
best_acc = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
valid_acc = test_loop(valid_dataloader, model, mode='Valid')
if valid_acc > best_acc:
best_acc = valid_acc
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_acc_{(100*valid_acc):0.1f}_model_weights.bin')
print("Done!")
Using cuda device
Epoch 1/3
-------------------------------
loss: 0.556518: 100%|█████████| 8584/8584 [07:51<00:00, 18.20it/s]
Valid Accuracy: 71.8%
saving new weights...
Epoch 2/3
-------------------------------
loss: 0.506202: 100%|█████████| 8584/8584 [07:15<00:00, 19.71it/s]
Valid Accuracy: 72.0%
saving new weights...
Epoch 3/3
-------------------------------
loss: 0.455851: 100%|█████████| 8584/8584 [07:16<00:00, 19.68it/s]
Valid Accuracy: 74.1%
saving new weights...
Done!
可以看到,随着训练的进行,在验证集上的准确率逐步提升(71.8% -> 72.0% -> 74.1%)。因此,3 轮 Epoch 训练结束后,会在目录下保存下所有三轮模型的权重:
epoch_1_valid_acc_71.8_model_weights.bin
epoch_2_valid_acc_72.0_model_weights.bin
epoch_3_valid_acc_74.1_model_weights.bin
至此,我们手工构建的文本分类模型的训练过程就完成了,完整的训练代码如下:
import random
import os
import numpy as np
import json
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoConfig
from transformers import BertPreTrainedModel, BertModel
from transformers import AdamW, get_scheduler
from tqdm.auto import tqdm
def seed_everything(seed=1029):
random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
# some cudnn methods can be random even after fixing the seed
# unless you tell it to be deterministic
torch.backends.cudnn.deterministic = True
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
seed_everything(42)
learning_rate = 1e-5
batch_size = 4
epoch_num = 3
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
class AFQMC(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt') as f:
for idx, line in enumerate(f):
sample = json.loads(line.strip())
Data[idx] = sample
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = AFQMC('data/afqmc_public/train.json')
valid_data = AFQMC('data/afqmc_public/dev.json')
def collote_fn(batch_samples):
batch_sentence_1, batch_sentence_2 = [], []
batch_label = []
for sample in batch_samples:
batch_sentence_1.append(sample['sentence1'])
batch_sentence_2.append(sample['sentence2'])
batch_label.append(int(sample['label']))
X = tokenizer(
batch_sentence_1,
batch_sentence_2,
padding=True,
truncation=True,
return_tensors="pt"
)
y = torch.tensor(batch_label)
return X, y
train_dataloader = DataLoader(train_data, batch_size=batch_size, shuffle=True, collate_fn=collote_fn)
valid_dataloader= DataLoader(valid_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
class BertForPairwiseCLS(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(768, 2)
self.post_init()
def forward(self, x):
outputs = self.bert(**x)
cls_vectors = outputs.last_hidden_state[:, 0, :]
cls_vectors = self.dropout(cls_vectors)
logits = self.classifier(cls_vectors)
return logits
config = AutoConfig.from_pretrained(checkpoint)
model = BertForPairwiseCLS.from_pretrained(checkpoint, config=config).to(device)
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_step_num = (epoch-1)*len(dataloader)
model.train()
for step, (X, y) in enumerate(dataloader, start=1):
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_step_num + step):>7f}')
progress_bar.update(1)
return total_loss
def test_loop(dataloader, model, mode='Test'):
assert mode in ['Valid', 'Test']
size = len(dataloader.dataset)
correct = 0
model.eval()
with torch.no_grad():
for X, y in dataloader:
X, y = X.to(device), y.to(device)
pred = model(X)
correct += (pred.argmax(1) == y).type(torch.float).sum().item()
correct /= size
print(f"{mode} Accuracy: {(100*correct):>0.1f}%\n")
return correct
loss_fn = nn.CrossEntropyLoss()
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_acc = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
valid_acc = test_loop(valid_dataloader, model, mode='Valid')
if valid_acc > best_acc:
best_acc = valid_acc
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_acc_{(100*valid_acc):0.1f}_model_weights.bin')
print("Done!")
这里我们还通过 seed_everything 函数手工设置训练过程中所有的随机数种子为 42,从而使得模型结果可以复现,而不是每次运行代码都是不同的结果。
最后,我们加载验证集上最优的模型权重,汇报其在测试集上的性能。由于 AFQMC 公布的测试集上并没有标签,无法评估性能,这里我们暂且用验证集代替进行演示:
model.load_state_dict(torch.load('epoch_3_valid_acc_74.1_model_weights.bin'))
test_loop(valid_dataloader, model, mode='Test')
Test Accuracy: 74.1%
注意:前面我们只保存了模型的权重,因此如果要单独调用上面的代码,需要首先实例化一个结构完全一样的模型,再通过 model.load_state_dict() 函数加载权重。
最终在测试集(这里用了验证集)上的准确率为 74.1%,与前面汇报的一致,也验证了模型参数的加载过程是正确的。
代码
出于演示需要,本文将所有代码都写在同一个文件中,但是如果模型结构复杂或者逻辑操作很多,这种方式就会大大降低代码可读性。因此,我们从一开始就应该养成切分代码的好习惯,尤其对于深度学习项目而言,合理规划的项目结构能够使得其他研究人员更加容易地复现出汇报的结果。
本文整理后的代码存储在 Github,大家在构建自己的项目时也可以参考该框架:
How-to-use-Transformers/src/pairwise_cls_similarity_afqmc/
与 Transformers 库类似,我们将模型损失的计算也包含进模型本身,这样在训练循环中我们就可以直接使用模型返回的损失进行反向传播。
这里我们同时加载 BERT 和 RoBERTa 权重来构建分类器,分别通过运行 run_simi_bert.sh 和 run_simi_roberta.sh 脚本进行训练。如果要进行测试或者将预测结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 BERT 在测试集(验证集)上的准确率为 73.61%,RoBERTa 为 73.84%(Nvidia Tesla V100, batch=16)。
第八章:快速分词器
通过前面章节的介绍,我们已经对 Transformers 库有了基本的了解,并且上手微调了一个句子对分类模型。从本章开始,我们将通过一系列的实例向大家展示如何使用 Transformers 库来完成目前主流的 NLP 任务。
在开始之前,我们先回顾一下在第五章《模型与分词器》中已经介绍过的分词器 (Tokenizer),进一步了解分词器的一些高级功能。
1. 快速分词器
前面我们已经介绍过如何使用分词器将文本编码为 token IDs,以及反过来将 token IDs 解码回文本。
实际上,Hugging Face 共提供了两种分分词器:
- 慢速分词器:Transformers 库自带,使用 Python 编写;
- 快速分词器:Tokenizers 库提供,使用 Rust 编写。
特别地,快速分词器除了能进行编码和解码之外,还能够追踪原文到 token 之间的映射,这对于处理序列标注、自动问答等任务非常重要。
快速分词器只有在并行处理大量文本时才能发挥出速度优势,在处理单个句子时甚至可能慢于慢速分词器。
我们一直推荐使用的 AutoTokenizer 类除了能根据 checkpoint 自动加载对应分词器以外,默认就会选择快速分词器,因此在大部分情况下都应该使用 AutoTokenizer 类来加载分词器。
再看分词结果
其实,分词器返回的是 BatchEncoding 对象,它是基于 Python 字典的子类,因此我们之前可以像字典一样来解析分词结果。我们可以通过 Tokenizer 或 BatchEncoding 对象的 is_fast 属性来判断使用的是哪种分词器:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
example = "Hello world!"
encoding = tokenizer(example)
print(type(encoding))
print('tokenizer.is_fast:', tokenizer.is_fast)
print('encoding.is_fast:', encoding.is_fast)
<class 'transformers.tokenization_utils_base.BatchEncoding'>
tokenizer.is_fast: True
encoding.is_fast: True
对于快速分词器,BatchEncoding 对象还提供了一些额外的方法。例如,我们可以直接通过 tokens() 函数来获取切分出的 token:
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("bert-base-cased")
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
encoding = tokenizer(example)
print(encoding.tokens())
['[CLS]', 'My', 'name', 'is', 'S', '##yl', '##va', '##in', 'and', 'I', 'work', 'at', 'Hu', '##gging', 'Face', 'in', 'Brooklyn', '.', '[SEP]']
追踪映射
在上面的例子中,索引为 5 的 token 是“##yl”,它是词语“Sylvain”的一个部分,因此在映射回原文时不应该被单独看待。我们可以通过 word_ids() 函数来获取每一个 token 对应的词语索引:
print(encoding.word_ids())
[None, 0, 1, 2, 3, 3, 3, 3, 4, 5, 6, 7, 8, 8, 9, 10, 11, 12, None]
可以看到,特殊 token [CLS] 和 [SEP] 被映射到 None,其他 token 都被映射到对应的来源词语。这可以为很多任务提供帮助,例如对于序列标注任务,就可以运用这个映射将词语的标签转换到 token 的标签;对于遮蔽语言建模 (Masked Language Modeling, MLM),就可以实现全词遮盖 (whole word masking),将属于同一个词语的 token 全部遮盖掉。
注意:词语索引取决于模型对于 word 的定义,例如“I’ll”到底算是一个词语还是两个词语,与分词器采用的预分词 (pre-tokenization) 操作有关。一些分词器直接采用空格切分,因此”I’ll“会被视为一个词语,还有一些分词器会进一步按照标点符号进行切分,那么 ‘I’ll’ 就会被视为两个词语。
快速分词器通过偏移量列表追踪文本、词语和 token 之间的映射,因此可以很容易地在这三者之间互相转换:
- 词语/token ⇒ 文本:通过
word_to_chars()、token_to_chars()函数来实现,返回词语/token 在原文中的起始和结束偏移量。例如,前面例子中索引为 5 的 token 是 ‘##yl’,它对应的词语索引为 3,因此我们可以方便的从从原文中抽取出对应的 token 片段和词语片段:
token_index=5
print('the 5th token is:', encoding.tokens()[token_index])
start, end=encoding.token_to_chars(token_index)
print('corresponding text span is:', example[start:end])
word_index=encoding.word_ids()[token_index]# 3
start, end=encoding.word_to_chars(word_index)
print('corresponding word span is:', example[start:end])the 5th token is: ##yl
corresponding text span is: yl
corresponding word span is: Sylvain - 词语 ⇔ token:前面的例子中我们使用
word_ids()获取了整个 token 序列对应的词语索引。实际上,词语和 token 之间可以直接通过索引直接映射,分别通过token_to_word()和word_to_tokens()来实现:token_index=5
print('the 5th token is:', encoding.tokens()[token_index])
corresp_word_index=encoding.token_to_word(token_index)
print('corresponding word index is:', corresp_word_index)
start, end=encoding.word_to_chars(corresp_word_index)
print('the word is:', example[start:end])
start, end=encoding.word_to_tokens(corresp_word_index)
print('corresponding tokens are:', encoding.tokens()[start:end])the 5th token is: ##yl
corresponding word index is: 3
the word is: Sylvain
corresponding tokens are: ['S', '##yl', '##va', '##in'] - 文本 ⇒ 词语/token:通过
char_to_word()和char_to_token()方法来实现:chars='My name is Sylvain'
print('characters of "{}" ars: {}'.format(chars, list(chars)))
print('corresponding word index: ')
fori, cinenumerate(chars):
print('"{}": {} '.format(c, encoding.char_to_word(i)), end="")
print('\ncorresponding token index: ')
fori, cinenumerate(chars):
print('"{}": {} '.format(c, encoding.char_to_token(i)), end="")characters of "My name is Sylvain" ars: ['M', 'y', ' ', 'n', 'a', 'm', 'e', ' ', 'i', 's', ' ', 'S', 'y', 'l', 'v', 'a', 'i', 'n']
corresponding word index:
"M": 0 "y": 0 " ": None "n": 1 "a": 1 "m": 1 "e": 1 " ": None "i": 2 "s": 2 " ": None "S": 3 "y": 3 "l": 3 "v": 3 "a": 3 "i": 3 "n": 3
corresponding token index:
"M": 1 "y": 1 " ": None "n": 2 "a": 2 "m": 2 "e": 2 " ": None "i": 3 "s": 3 " ": None "S": 4 "y": 5 "l": 5 "v": 6 "a": 6 "i": 7 "n": 7由于空格会被 BERT 的分词器过滤掉,因此对应的词语或 token 索引都为 None。
下面,我们将以序列标注和问答任务为例,展示如何在实际任务中运用快速分词器。
2. 序列标注任务
在《开箱即用的 pipelines》中我们已经介绍过,序列标注任务在 Transformers 库中被称为 token 分类任务,典型的如命名实体识别 (NER),负责识别出文本中哪些片段是实体。
pipeline 的输出
前面我们讲过,NER pipeline 模型实际上封装了三个过程:
- 对文本进行编码;
- 将输入送入模型;
- 对模型输出进行后处理。
前两个步骤在所有 pipeline 模型中都是一样的,只有第三个步骤——对模型输出进行后处理,则是根据任务类型而不同。token 分类 pipeline 模型在默认情况下会加载 dbmdz/bert-large-cased-finetuned-conll03-english NER 模型,我们直接打印出它的输出:
from transformers import pipeline
token_classifier = pipeline("token-classification")
results = token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
print(results)
[{'entity': 'I-PER', 'score': 0.99938285, 'index': 4, 'word': 'S', 'start': 11, 'end': 12},
{'entity': 'I-PER', 'score': 0.99815494, 'index': 5, 'word': '##yl', 'start': 12, 'end': 14},
{'entity': 'I-PER', 'score': 0.99590707, 'index': 6, 'word': '##va', 'start': 14, 'end': 16},
{'entity': 'I-PER', 'score': 0.99923277, 'index': 7, 'word': '##in', 'start': 16, 'end': 18},
{'entity': 'I-ORG', 'score': 0.9738931, 'index': 12, 'word': 'Hu', 'start': 33, 'end': 35},
{'entity': 'I-ORG', 'score': 0.976115, 'index': 13, 'word': '##gging', 'start': 35, 'end': 40},
{'entity': 'I-ORG', 'score': 0.9887976, 'index': 14, 'word': 'Face', 'start': 41, 'end': 45},
{'entity': 'I-LOC', 'score': 0.9932106, 'index': 16, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
可以看到,模型成功地将“Sylvain”对应的 token 识别为人物,“Hugging Face”对应的 token 识别为机构,以及“Brooklyn”识别为地点。我们还可以通过设置参数 grouped_entities=True 让模型自动合属于同一个实体的 token:
from transformers import pipeline
token_classifier = pipeline("token-classification", grouped_entities=True)
results = token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
print(results)
[{'entity_group': 'PER', 'score': 0.9981694, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.9796019, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.9932106, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
实际上,NER pipeline 模型提供了多种组合 token 形成实体的策略,可以通过 aggregation_strategy 参数进行设置:
- simple:默认策略,以实体对应所有 token 的平均分数作为得分,例如“Sylvain”的分数就是“S”、“##yl”、“##va”和“##in”四个 token 分数的平均;
- first:将第一个 token 的分数作为实体的分数,例如“Sylvain”的分数就是 token “S”的分数;
- max:将 token 中最大的分数作为整个实体的分数;
- average:对应词语(注意不是 token)的平均分数作为整个实体的分数,例如“Hugging Face”就是“Hugging”(0.975)和 “Face”(0.98879)的平均值 0.9819,而 simple 策略得分为 0.9796。
from transformers import pipeline
token_classifier = pipeline("token-classification", aggregation_strategy="max")
results = token_classifier("My name is Sylvain and I work at Hugging Face in Brooklyn.")
print(results)
[{'entity_group': 'PER', 'score': 0.99938285, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.9824563, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.9932106, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
构造模型输出
下面,我们将通过 AutoModelForTokenClassification 类来构造一个 token 分类模型,并且手工地对模型的输出进行后处理,获得与 pipeline 模型相同的结果。这里我们同样将 checkpoint 设为 dbmdz/bert-large-cased-finetuned-conll03-english:
from transformers import AutoTokenizer, AutoModelForTokenClassification
model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)
print(inputs["input_ids"].shape)
print(outputs.logits.shape)
torch.Size([1, 19])
torch.Size([1, 19, 9])
可以看到,模型的输入是一个长度为 19 的 token 序列,输出尺寸为 1×19×9,即模型对每个 token 都会输出一个包含 9 个 logits 值的向量(9 分类)。我们可以通过 model.config.id2label 属性来查看这 9 个标签:
print(model.config.id2label)
{0: 'O', 1: 'B-MISC', 2: 'I-MISC', 3: 'B-PER', 4: 'I-PER', 5: 'B-ORG', 6: 'I-ORG', 7: 'B-LOC', 8: 'I-LOC'}
这里使用的是 IOB 标签格式,“B-XXX”表示某一种标签的开始,“I-XXX”表示某一种标签的中间,“O”表示非标签。因此,该模型识别的实体类型共有 4 种:miscellaneous、person、organization 和 location。
在实际应用中, IOB 标签格式又分为两种:
- IOB1:如上图绿色所示,只有在分隔类别相同的连续 token 时才会使用 B-XXX 标签,例如右图中的“Alice”和“Bob”是连续的两个人物,因此“Bob”的起始 token 标签为“B-PER”,而“Alice”的起始 token 为“I-PER”;
- IOB2:如上图粉色所示,不管任何情况下,起始 token 的标签都为“B-XXX”,后续 token 的标签都为“I-XXX”,因此右图中“Alice”和“Bob”的起始 token 都为“B-PER”。
从 pipeline 的输出结果可以看到,模型采用的是 IOB1 格式,因此“Sylvain”对应的 4 个 token “S”、“##yl”、“##va”和“##in”预测的标签都为“I-PER”。
与文本分类任务一样,我们可以通过 softmax 函数进一步将 logits 值转换为概率值,并且通过 argmax 函数来获取每一个 token 的预测结果:
from transformers import AutoTokenizer, AutoModelForTokenClassification
model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)
import torch
probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
predictions = outputs.logits.argmax(dim=-1)[0].tolist()
print(predictions)
results = []
tokens = inputs.tokens()
for idx, pred in enumerate(predictions):
label = model.config.id2label[pred]
if label != "O":
results.append(
{"entity": label, "score": probabilities[idx][pred], "word": tokens[idx]}
)
print(results)
[0, 0, 0, 0, 4, 4, 4, 4, 0, 0, 0, 0, 6, 6, 6, 0, 8, 0, 0]
[{'entity': 'I-PER', 'score': 0.9993828535079956, 'word': 'S'},
{'entity': 'I-PER', 'score': 0.9981549382209778, 'word': '##yl'},
{'entity': 'I-PER', 'score': 0.995907187461853, 'word': '##va'},
{'entity': 'I-PER', 'score': 0.9992327690124512, 'word': '##in'},
{'entity': 'I-ORG', 'score': 0.9738931059837341, 'word': 'Hu'},
{'entity': 'I-ORG', 'score': 0.9761149883270264, 'word': '##gging'},
{'entity': 'I-ORG', 'score': 0.9887976050376892, 'word': 'Face'},
{'entity': 'I-LOC', 'score': 0.9932106137275696, 'word': 'Brooklyn'}]
可以看到,这样就已经和 pipeline 模型的输出非常相似了,只不过 pipeline 模型还会返回 token 或者组合实体在原文中的起始和结束位置。
前面我们已经介绍过,快速分词器可以追踪从文本到 token 的映射,只需要给分词器传递 return_offsets_mapping=True 参数,就可以获取从 token 到原文的映射(特殊 token 对应的原文位置为 (0, 0)。):
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
offset_mapping = inputs_with_offsets["offset_mapping"]
print(offset_mapping)
[(0, 0), (0, 2), (3, 7), (8, 10), (11, 12), (12, 14), (14, 16), (16, 18), (19, 22), (23, 24), (25, 29), (30, 32), (33, 35), (35, 40), (41, 45), (46, 48), (49, 57), (57, 58), (0, 0)]
借助于这个映射,我们可以进一步完善模型的输出结果:
from transformers import AutoTokenizer, AutoModelForTokenClassification
model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)
import torch
probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
predictions = outputs.logits.argmax(dim=-1)[0].tolist()
results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]
for idx, pred in enumerate(predictions):
label = model.config.id2label[pred]
if label != "O":
start, end = offsets[idx]
results.append(
{
"entity": label,
"score": probabilities[idx][pred],
"word": tokens[idx],
"start": start,
"end": end,
}
)
print(results)
[{'entity': 'I-PER', 'score': 0.9993828535079956, 'word': 'S', 'start': 11, 'end': 12},
{'entity': 'I-PER', 'score': 0.9981549382209778, 'word': '##yl', 'start': 12, 'end': 14},
{'entity': 'I-PER', 'score': 0.995907187461853, 'word': '##va', 'start': 14, 'end': 16},
{'entity': 'I-PER', 'score': 0.9992327690124512, 'word': '##in', 'start': 16, 'end': 18},
{'entity': 'I-ORG', 'score': 0.9738931059837341, 'word': 'Hu', 'start': 33, 'end': 35},
{'entity': 'I-ORG', 'score': 0.9761149883270264, 'word': '##gging', 'start': 35, 'end': 40},
{'entity': 'I-ORG', 'score': 0.9887976050376892, 'word': 'Face', 'start': 41, 'end': 45},
{'entity': 'I-LOC', 'score': 0.9932106137275696, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
这样我们手工构建的结果就与 pipeline 的输出完全一致了!只需要再实现组合实体功能就完成所有的后处理步骤了。
组合实体
我们以前面介绍的 simple 合并策略为例,将连续的标签为“I-XXX”的多个 token 进行合并(或者以“B-XXX”开头,后面接多个“I-XXX”的 token 序列),直到遇到
- “O”:表示该 token 为非实体;
- “B-XXX”或“I-YYY”或“B-YYY”:表示出现了新的实体。
然后对组合后 token 的概率值求平均作为实体的分数:
from transformers import AutoTokenizer, AutoModelForTokenClassification
model_checkpoint = "dbmdz/bert-large-cased-finetuned-conll03-english"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForTokenClassification.from_pretrained(model_checkpoint)
example = "My name is Sylvain and I work at Hugging Face in Brooklyn."
inputs = tokenizer(example, return_tensors="pt")
outputs = model(**inputs)
import torch
probabilities = torch.nn.functional.softmax(outputs.logits, dim=-1)[0].tolist()
predictions = outputs.logits.argmax(dim=-1)[0].tolist()
import numpy as np
results = []
inputs_with_offsets = tokenizer(example, return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]
idx = 0
while idx < len(predictions):
pred = predictions[idx]
label = model.config.id2label[pred]
if label != "O":
label = label[2:] # Remove the B- or I-
start, end = offsets[idx]
all_scores = [probabilities[idx][pred]]
# Grab all the tokens labeled with I-label
while (
idx + 1 < len(predictions)
and model.config.id2label[predictions[idx + 1]] == f"I-{label}"
):
all_scores.append(probabilities[idx + 1][predictions[idx + 1]])
_, end = offsets[idx + 1]
idx += 1
score = np.mean(all_scores).item()
word = example[start:end]
results.append(
{
"entity_group": label,
"score": score,
"word": word,
"start": start,
"end": end,
}
)
idx += 1
print(results)
[{'entity_group': 'PER', 'score': 0.9981694370508194, 'word': 'Sylvain', 'start': 11, 'end': 18},
{'entity_group': 'ORG', 'score': 0.9796018997828165, 'word': 'Hugging Face', 'start': 33, 'end': 45},
{'entity_group': 'LOC', 'score': 0.9932106137275696, 'word': 'Brooklyn', 'start': 49, 'end': 57}]
这样我们就得到了与 pipeline 模型完全一致的组合实体预测结果。
3. 抽取式问答任务
除了序列标注以外,抽取式问答是另一个需要使用到分词器高级功能的任务。与 NER 任务类似,自动问答需要根据问题从原文中标记(抽取)出答案片段。
pipeline 的输出
同样地,我们首先通过 QA pipeline 模型来完成问答任务:
from transformers import pipeline
question_answerer = pipeline("question-answering")
context = """
Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch, and TensorFlow — with a seamless integration between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back Transformers?"
results = question_answerer(question=question, context=context)
print(results)
{'score': 0.9741130471229553, 'start': 76, 'end': 104, 'answer': 'Jax, PyTorch, and TensorFlow'}
可以看到 pipeline 会输出答案片段的概率、文本以及在原文中的位置。下面我们将手工构建 QA 模型,并且通过对输出进行处理获得与 pipeline 一样的结果。
构造模型输出
我们首先通过 AutoModelForQuestionAnswering 类来手工构建一个问答模型,并且将 checkpoint 设为 distilbert-base-cased-distilled-squad。按照模型预训练时的输入格式,我们将问题和上下文通过特殊分隔符 [SEP] 连接成一个整体,如下图所示:
标准的问答模型会使用两个指针分别预测答案片段的起始 token 和结束 token 的索引(例子中分别是 21 和 24)。因此,模型会返回两个张量,分别对应答案起始 token 和结束 token 的 logits 值:
from transformers import AutoTokenizer, AutoModelForQuestionAnswering
model_checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
context = """
Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch, and TensorFlow — with a seamless integration between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back Transformers?"
inputs = tokenizer(question, context, return_tensors="pt")
outputs = model(**inputs)
print(inputs["input_ids"].shape)
start_logits = outputs.start_logits
end_logits = outputs.end_logits
print(start_logits.shape, end_logits.shape)
torch.Size([1, 65])
torch.Size([1, 65]) torch.Size([1, 65])
在上面的例子中,因为模型的输入包含 65 个 token,所以输出也是两个长度为 65 的张量。同样地,我们也可以通过 softmax 函数将这些 logits 值转换为概率值。
注意!因为答案是在上下文中抽取,所以在计算前我们需要先排除掉输入中那些不属于上下文的 token 索引。
我们的输入格式为“[CLS] question [SEP] context [SEP]”,所以需要构建 Mask 遮蔽掉问题文本以及 [SEP]。
考虑到某些模型使用 [CLS] 来标记答案是否在上下文中,这里我们会保留 [CLS]。
下面我们手工构建一个 Mask 张量,将需要遮盖 token 索引的 logits 值替换为一个大的负值(例如 -10000),然后再应用 softmax 函数:
from transformers import AutoTokenizer, AutoModelForQuestionAnswering
model_checkpoint = "distilbert-base-cased-distilled-squad"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForQuestionAnswering.from_pretrained(model_checkpoint)
context = """
Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch, and TensorFlow — with a seamless integration between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back Transformers?"
inputs = tokenizer(question, context, return_tensors="pt")
outputs = model(**inputs)
start_logits = outputs.start_logits
end_logits = outputs.end_logits
import torch
sequence_ids = inputs.sequence_ids()
mask = [i != 1 for i in sequence_ids]
mask[0] = False # Unmask the [CLS] token
mask = torch.tensor(mask)[None]
start_logits[mask] = -10000
end_logits[mask] = -10000
start_probabilities = torch.nn.functional.softmax(start_logits, dim=-1)[0]
end_probabilities = torch.nn.functional.softmax(end_logits, dim=-1)[0]
接下来最简单的做法就是使用 argmax 函数取出 start_probabilities 和 end_probabilities 中最大的索引分别作为答案的起始和结束位置。但是这样做起始索引可能会大于结束索引,因此我们换一种方式,计算所有可能的答案片段的概率,然后将概率最高的片段作为答案:
P(indexstart,indexend),indexstart≤indexend
具体的,我们假设“答案从 indexstart 开始”与“答案以 indexend 结束”为相互独立的事件,因此答案片段从 indexstart 开始到 indexend 结束的概率为:
P(indexstart,indexend)=Pstart(indexstart)×Pend(indexend)
因此,我们首先通过构建矩阵计算所有的概率值,然后将 $\text{index}{start} > \text{index}{end}$ 对应的值赋为 0 来遮蔽掉这些不应该出现的情况,这可以使用 Pytorch 自带的 torch.triu() 函数来完成,它会返回一个 2 维张量的上三角部分:
scores = start_probabilities[:, None] * end_probabilities[None, :]
scores = torch.triu(scores)
最后,我们只需取出矩阵 scores 中最大的值对应的索引作为答案。由于 PyTorch 会返回展平张量中的索引,因此我们还需要将索引换算为对应的 indexstart 和 indexend (通过整除和求模运算):
max_index = scores.argmax().item()
start_index = max_index // scores.shape[1]
end_index = max_index % scores.shape[1]
inputs_with_offsets = tokenizer(question, context, return_offsets_mapping=True)
offsets = inputs_with_offsets["offset_mapping"]
start, _ = offsets[start_index]
_, end = offsets[end_index]
result = {
"answer": context[start:end],
"start": start,
"end": end,
"score": float(scores[start_index, end_index]),
}
print(result)
{'answer': 'Jax, PyTorch, and TensorFlow', 'start': 76, 'end': 104, 'score': 0.9741137027740479}
这样我们就得到了与 pipeline 模型完全相同的输出结果!
处理长文本
问答模型可能遇到的另一个问题是:如果上下文非常长,在与问题拼接后就可能会超过模型可接受的最大长度,例如默认 QA pipeline 的最大输入长度只有 384。
最简单粗暴的办法就是直接截去超过最大长度的 token,由于我们只希望对上下文进行剪裁,因此可以使用 only_second 截断策略:
inputs = tokenizer(question, long_context, max_length=384, truncation="only_second")
但是万一答案恰好在被截去的部分中,模型就无法预测出最优的结果了。
幸运的是,自动问答 pipeline 采取了一种将超过最大长度的上下文切分为文本块 (chunk) 的方式,即使答案出现在长文末尾也依然能够成功地抽取出来:
from transformers import pipeline
question_answerer = pipeline("question-answering")
long_context = """
Transformers: State of the Art NLP
Transformers provides thousands of pretrained models to perform tasks on texts such as classification, information extraction, question answering, summarization, translation, text generation and more in over 100 languages. Its aim is to make cutting-edge NLP easier to use for everyone.
Transformers provides APIs to quickly download and use those pretrained models on a given text, fine-tune them on your own datasets and then share them with the community on our model hub. At the same time, each python module defining an architecture is fully standalone and can be modified to enable quick research experiments.
Why should I use transformers?
1. Easy-to-use state-of-the-art models:
- High performance on NLU and NLG tasks.
- Low barrier to entry for educators and practitioners.
- Few user-facing abstractions with just three classes to learn.
- A unified API for using all our pretrained models.
- Lower compute costs, smaller carbon footprint:
2. Researchers can share trained models instead of always retraining.
- Practitioners can reduce compute time and production costs.
- Dozens of architectures with over 10,000 pretrained models, some in more than 100 languages.
3. Choose the right framework for every part of a model's lifetime:
- Train state-of-the-art models in 3 lines of code.
- Move a single model between TF2.0/PyTorch frameworks at will.
- Seamlessly pick the right framework for training, evaluation and production.
4. Easily customize a model or an example to your needs:
- We provide examples for each architecture to reproduce the results published by its original authors.
- Model internals are exposed as consistently as possible.
- Model files can be used independently of the library for quick experiments.
Transformers is backed by the three most popular deep learning libraries — Jax, PyTorch and TensorFlow — with a seamless integration between them. It's straightforward to train your models with one before loading them for inference with the other.
"""
question = "Which deep learning libraries back Transformers?"
results = question_answerer(question=question, context=long_context)
print(results)
{'score': 0.9697490930557251, 'start': 1884, 'end': 1911, 'answer': 'Jax, PyTorch and TensorFlow'}
实际上,无论快速或慢速分词器都提供了按 chunk 切分文本的功能,只需要在截断文本时再添加额外的参数 return_overflowing_tokens=True。考虑到如果截断的位置不合理,也可能无法抽取出正确的答案,因此还可以通过设置步长参数 stride 控制文本块重叠部分的长度。例如:
sentence = "This sentence is not too long but we are going to split it anyway."
inputs = tokenizer(
sentence, truncation=True, return_overflowing_tokens=True, max_length=6, stride=2
)
for ids in inputs["input_ids"]:
print(tokenizer.decode(ids))
[CLS] This sentence is not [SEP]
[CLS] is not too long [SEP]
[CLS] too long but we [SEP]
[CLS] but we are going [SEP]
[CLS] are going to split [SEP]
[CLS] to split it anyway [SEP]
[CLS] it anyway. [SEP]
可以看到在 max_length=6, stride=2 设置下,切分出的文本块最多只能包含 6 个 token,并且文本块之间有 2 个 token 重叠。如果我们进一步打印编码结果就会发现,除了常规的 token ID 和注意力 Mask 以外,还有一个 overflow_to_sample_mapping 项,它负责记录每一个文本块对应原文中的句子索引,例如:
sentence = "This sentence is not too long but we are going to split it anyway."
inputs = tokenizer(
sentence, truncation=True, return_overflowing_tokens=True, max_length=6, stride=2
)
print(inputs.keys())
print(inputs["overflow_to_sample_mapping"])
sentences = [
"This sentence is not too long but we are going to split it anyway.",
"This sentence is shorter but will still get split.",
]
inputs = tokenizer(
sentences, truncation=True, return_overflowing_tokens=True, max_length=6, stride=2
)
print(inputs["overflow_to_sample_mapping"])
dict_keys(['input_ids', 'attention_mask', 'overflow_to_sample_mapping'])
[0, 0, 0, 0, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1]
对单个句子进行切分时,因为所有的 chunk 都来源于同一个句子,因此 mapping 中对应的句子索引都为 0;而如果同时对多个句子进行切分时,可以看到其中第一个句子对应的句子索引为 0,第二个句子为 1。
QA pipeline 默认会按照预训练时的设置将最大输入长度设为 384,将步长设为 128,我们也可以在调用 pipeline 时通过参数 max_seq_len 和 stride 进行调整。
下面我们采用相同的设置对前面示例中的长文本进行分词,考虑到编码结果中除了模型需要的 token IDs 和注意力 Mask 以外,还会包含文本到 token 的映射以及 overflow_to_sample_mapping 项,这里我们只有一个句子,因此就不保留这个 map 了:
inputs = tokenizer(
question,
long_context,
stride=128,
max_length=384,
padding="longest",
truncation="only_second",
return_overflowing_tokens=True,
return_offsets_mapping=True,
)
_ = inputs.pop("overflow_to_sample_mapping")
offsets = inputs.pop("offset_mapping")
inputs = inputs.convert_to_tensors("pt")
print(inputs["input_ids"].shape)
torch.Size([2, 384])
可以看到,长文本被切分成了 2 个文本块,因此模型对应的输出也会是 2 组起始和结束 logits 值的集合:
outputs = model(**inputs)
start_logits = outputs.start_logits
end_logits = outputs.end_logits
print(start_logits.shape, end_logits.shape)
torch.Size([2, 384]) torch.Size([2, 384])
继续按照之前做的那样,在运用 softmax 转换为概率之前,我们先将非上下文的部分以及填充的 padding token 都通过 Mask 遮掩掉:
sequence_ids = inputs.sequence_ids()
# Mask everything apart from the tokens of the context
mask = [i != 1 for i in sequence_ids]
# Unmask the [CLS] token
mask[0] = False
# Mask all the [PAD] tokens
mask = torch.logical_or(torch.tensor(mask)[None], (inputs["attention_mask"] == 0))
start_logits[mask] = -10000
end_logits[mask] = -10000
start_probabilities = torch.nn.functional.softmax(start_logits, dim=-1)
end_probabilities = torch.nn.functional.softmax(end_logits, dim=-1)
注意:上面的代码中,logical_or 函数首先通过广播机制将 mask 向量从 (1,384) 扩展成了 (2,384),然后再与 attention_mask 张量进行计算。这是因为两个 chunk 中非上下文的部分的一致的,如果不一致就必须针对每一个文本块单独构建 mask。
同样地,对于每一个 chunk,我们对 chunk 中所有可能的文本片段都计算其为答案的概率,再从中取出概率最大的文本片段,最后将 token 索引映射回原文本作为输出:
candidates = []
for start_probs, end_probs in zip(start_probabilities, end_probabilities):
scores = start_probs[:, None] * end_probs[None, :]
idx = torch.triu(scores).argmax().item()
start_idx = idx // scores.shape[0]
end_idx = idx % scores.shape[0]
score = scores[start_idx, end_idx].item()
candidates.append((start_idx, end_idx, score))
for candidate, offset in zip(candidates, offsets):
start_token, end_token, score = candidate
start_char, _ = offset[start_token]
_, end_char = offset[end_token]
answer = long_context[start_char:end_char]
result = {"answer": answer, "start": start_char, "end": end_char, "score": score}
print(result)
{'answer': '', 'start': 0, 'end': 0, 'score': 0.6493748426437378}
{'answer': 'Jax, PyTorch and TensorFlow', 'start': 1884, 'end': 1911, 'score': 0.9697459936141968}
可以看到最终的输出与前面 pipeline 模型的输出是一致的,这也验证了我们对模型输出的处理是正确的。
第九章:序列标注任务
我们的第一个实战任务是序列标注 (Sequence Labeling/Tagging),其目标是为文本中的每一个 token 分配一个标签,因此 Transformers 库也将其称为 token 分类任务。常见的序列标注任务有命名实体识别 NER (Named Entity Recognition) 和词性标注 POS (Part-Of-Speech tagging)。
命名实体识别 NER 旨在识别出文本中诸如人物、地点、组织等实体,即为所有的 token 都打上实体标签(包含“非实体”)。词性标注 POS 旨在为文本中的每一个词语标注上对应的词性,例如名词、动词、形容词等。
下面我们以 NER 为例,运用 Transformers 库手工构建一个基于 BERT 的模型来完成任务。
1. 准备数据
我们选择 1998 年人民日报语料库作为数据集,该语料库标注了大量的语言学信息,可以同时用于分词、NER 等任务。这里我们直接使用处理好的 NER 语料 china-people-daily-ner-corpus.tar.gz。
该语料已经划分好了训练集、验证集和测试集,分别对应 example.train、example.dev 和 example.test 文件,包含 20864 / 2318 / 4636 个句子。语料采用我们在《快速分词器》中介绍过的 IOB2 格式进行标注,一行对应一个字:
海 O
钓 O
比 O
赛 O
地 O
点 O
在 O
厦 B-LOC
门 I-LOC
与 O
金 B-LOC
门 I-LOC
之 O
间 O
的 O
海 O
域 O
。 O
回顾一下,在 IOB2 格式中,”B-XXX”表示某一类标签的开始,”I-XXX”表示某一类标签的中间,”O”表示非标签。人民日报语料中标注有人物 (PER)、地点 (LOC) 和组织 (ORG) 三种实体类型,因此共有 7 种标签:
- “O”:非实体;
- “B-PER/I-PER”:人物实体的起始/中间;
- “B-LOC/I-LOC”:地点实体的起始/中间;
- “B-ORG/I-ORG”:组织实体的起始/中间。
构建数据集
与之前一样,我们首先编写继承自 Dataset 类的自定义数据集用于组织样本和标签。数据集中句子之间采用空行分隔,因此我们首先通过 '\n\n' 切分出句子,然后按行读取句子中每一个字和对应的标签,如果标签以 B 或者 I 开头,就表示出现了实体。
from torch.utils.data import Dataset
categories = set()
class PeopleDaily(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f.read().split('\n\n')):
if not line:
breaksentence, labels = '', []
for i, item in enumerate(line.split('\n')):
char, tag = item.split(' ')
sentence += char
if tag.startswith('B'):
labels.append([i, i, char, tag[2:]]) # Remove the B- or I-
categories.add(tag[2:])
elif tag.startswith('I'):
labels[-1][1] = i
labels[-1][2] += char
Data[idx] = {
'sentence': sentence,
'labels': labels
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
下面我们通过读取文件构造数据集,并打印出一个训练样本:
train_data = PeopleDaily('data/china-people-daily-ner-corpus/example.train')
valid_data = PeopleDaily('data/china-people-daily-ner-corpus/example.dev')
test_data = PeopleDaily('data/china-people-daily-ner-corpus/example.test')
print(train_data[0])
{'sentence': '海钓比赛地点在厦门与金门之间的海域。', 'labels': [[7, 8, '厦门', 'LOC'], [10, 11, '金门', 'LOC']]}
可以看到我们的自定义数据集成功地抽取出了句子中的实体标签(包括实体在原文中的位置以及标签)。
数据预处理
接着,我们就需要通过 DataLoader 库来按 batch 加载数据,并且将文本以及标签都转换为模型可以接受的输入形式。前面我们已经通过 categories 搜集了数据集中的所有实体标签,因此很容易建立标签映射字典:
id2label = {0:'O'}
for c in list(sorted(categories)):
id2label[len(id2label)] = f"B-{c}"
id2label[len(id2label)] = f"I-{c}"
label2id = {v: k for k, v in id2label.items()}
print(id2label)
print(label2id)
{0: 'O', 1: 'B-LOC', 2: 'I-LOC', 3: 'B-ORG', 4: 'I-ORG', 5: 'B-PER', 6: 'I-PER'}
{'O': 0, 'B-LOC': 1, 'I-LOC': 2, 'B-ORG': 3, 'I-ORG': 4, 'B-PER': 5, 'I-PER': 6}
与《快速分词器》中的操作类似,我们需要通过快速分词器提供的映射函数,将实体标签从原文映射到切分出的 token 上。
下面以处理第一个样本为例。我们首先通过 char_to_token() 函数将实体标签从原文位置映射到切分后的 token 索引,并且通过上面构建好的映射字典将实体标签转换为实体编号。
from transformers import AutoTokenizer
import numpy as np
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
sentence = '海钓比赛地点在厦门与金门之间的海域。'
labels = [[7, 8, '厦门', 'LOC'], [10, 11, '金门', 'LOC']]
encoding = tokenizer(sentence, truncation=True)
tokens = encoding.tokens()
label = np.zeros(len(tokens), dtype=int)
for char_start, char_end, word, tag in labels:
token_start = encoding.char_to_token(char_start)
token_end = encoding.char_to_token(char_end)
label[token_start] = label2id[f"B-{tag}"]
label[token_start+1:token_end+1] = label2id[f"I-{tag}"]
print(tokens)
print(label)
print([id2label[id] for id in label])
['[CLS]', '海', '钓', '比', '赛', '地', '点', '在', '厦', '门', '与', '金', '门', '之', '间', '的', '海', '域', '。', '[SEP]']
[0 0 0 0 0 0 0 0 1 2 0 1 2 0 0 0 0 0 0 0]
['O', 'O', 'O', 'O', 'O', 'O', 'O', 'O', 'B-LOC', 'I-LOC', 'O', 'B-LOC', 'I-LOC', 'O', 'O', 'O', 'O', 'O', 'O', 'O']
不过在实际编写 DataLoader 的批处理函数 collate_fn() 时,我们处理的就不再是一个而是多个样本,因此需要对上面的操作进行扩展。而且由于最终会通过交叉熵损失来优化模型参数,我们还需要将 [CLS]、[SEP]、[PAD] 等特殊 token 对应的标签设为 -100,以便在计算损失时忽略它们:
import torch
from torch.utils.data import DataLoader
from transformers import AutoTokenizer
import numpy as np
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
def collote_fn(batch_samples):
batch_sentence, batch_tags = [], []
for sample in batch_samples:
batch_sentence.append(sample['sentence'])
batch_tags.append(sample['labels'])
batch_inputs = tokenizer(
batch_sentence,
padding=True,
truncation=True,
return_tensors="pt"
)
batch_label = np.zeros(batch_inputs['input_ids'].shape, dtype=int)
for s_idx, sentence in enumerate(batch_sentence):
encoding = tokenizer(sentence, truncation=True)
batch_label[s_idx][0] = -100
batch_label[s_idx][len(encoding.tokens())-1:] = -100
for char_start, char_end, _, tag in batch_tags[s_idx]:
token_start = encoding.char_to_token(char_start)
token_end = encoding.char_to_token(char_end)
batch_label[s_idx][token_start] = label2id[f"B-{tag}"]
batch_label[s_idx][token_start+1:token_end+1] = label2id[f"I-{tag}"]
return batch_inputs, torch.tensor(batch_label)
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
test_dataloader = DataLoader(test_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
batch_X, batch_y = next(iter(train_dataloader))
print('batch_X shape:', {k: v.shape for k, v in batch_X.items()})
print('batch_y shape:', batch_y.shape)
print(batch_X)
print(batch_y)
batch_X shape: {
'input_ids': torch.Size([4, 65]),
'token_type_ids': torch.Size([4, 65]),
'attention_mask': torch.Size([4, 65])
}
batch_y shape: torch.Size([4, 65])
{'input_ids': tensor([
[ 101, 7716, 6645, 1298, 6432, 8024, 1762, 125, 3299, 4638, 3189, 3315,
6913, 6435, 6612, 677, 8024, 704, 1744, 7339, 6820, 3295, 7566, 1044,
6814, 5401, 1744, 7339, 8124, 1146, 722, 1914, 8024, 852, 3297, 5303,
4638, 5310, 2229, 793, 3221, 1927, 1164, 511, 102, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0],
[ 101, 3341, 5632, 1744, 2157, 4906, 2825, 914, 6822, 1355, 2245, 704,
2552, 510, 704, 1744, 1093, 689, 1920, 2110, 510, 704, 1744, 2456,
3332, 4777, 4955, 7368, 510, 1266, 3175, 769, 1920, 5023, 1296, 855,
4638, 683, 2157, 5440, 2175, 749, 7987, 1366, 4638, 821, 689, 8024,
2900, 1139, 749, 7987, 1366, 1355, 2245, 2773, 4526, 704, 4638, 679,
6639, 722, 1905, 511, 102],
[ 101, 3173, 1814, 2773, 3159, 510, 673, 3983, 2275, 2773, 3159, 510,
7942, 3817, 4518, 924, 1310, 2773, 510, 4721, 3333, 2773, 3159, 100,
100, 2218, 3221, 711, 749, 924, 1310, 1157, 1157, 6414, 4495, 1762,
3031, 5074, 7027, 4638, 7484, 1462, 2048, 1036, 511, 102, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0],
[ 101, 5401, 2102, 4767, 3779, 1062, 1385, 5307, 6814, 1939, 1213, 2894,
3011, 8024, 6821, 3613, 793, 855, 2233, 5018, 1061, 511, 102, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0]]),
'token_type_ids': tensor(...),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]])}
tensor([[-100, 5, 6, 6, 0, 0, 0, 0, 0, 0, 1, 2,
0, 0, 0, 0, 0, 3, 4, 4, 0, 0, 0, 0,
0, 3, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100],
[-100, 0, 0, 3, 4, 4, 4, 4, 4, 4, 4, 4,
4, 0, 3, 4, 4, 4, 4, 4, 0, 3, 4, 4,
4, 4, 4, 4, 0, 3, 4, 4, 4, 0, 0, 0,
0, 0, 0, 0, 0, 0, 1, 2, 0, 0, 0, 0,
0, 0, 0, 1, 2, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, -100],
[-100, 1, 2, 0, 0, 0, 1, 2, 2, 0, 0, 0,
1, 2, 2, 0, 0, 0, 0, 1, 2, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100],
[-100, 3, 4, 4, 4, 4, 4, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100]])
可以看到,DataLoader 按照我们设置的 batch size 每次对 4 个样本进行编码,并且将 token 序列填充到了相同的长度。样本标签中实体对应的索引都转换为了实体编号,特殊 token 对应的索引都被设置为 -100。
注意:由于我们在 DataLoader 中设置参数 shuffle=True 打乱训练集,因此每一次遍历样本的顺序都是随机的。随机遍历训练集会使得每次训练后得到的模型参数都不同,导致实验结果难以复现,因此大部分研究者会采用伪随机序列来进行实验。即通过设置随机种子来生成随机序列,只要种子相同,生成的随机序列就是相同的。例如只要你将种子设置为 7,就可以得到与上面完全相同的结果。
importtorch
importrandom
importnumpyasnp
importos
seed=7
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
random.seed(seed)
np.random.seed(seed)
os.environ['PYTHONHASHSEED']=str(seed)
2. 训练模型
构建模型
对于序列标注任务,可以直接使用 Transformers 库封装好的 AutoModelForTokenClassification 类,只需通过 num_labels 参数传入分类标签数量即可快速实现一个 token 分类器,或者是传入标签到编号的映射(更推荐),例如:
from transformers import AutoModelForTokenClassification
model = AutoModelForTokenClassification.from_pretrained(
model_checkpoint,
id2label=id2label,
label2id=label2id,
)
考虑到这种方式不够灵活,因此与《微调预训练模型》中一样,本文采用继承 Transformers 库预训练模型的方式来手工构建模型:
from torch import nn
from transformers import AutoConfig
from transformers import BertPreTrainedModel, BertModel
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class BertForNER(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(768, len(id2label))
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
sequence_output = bert_output.last_hidden_state
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output)
return logits
config = AutoConfig.from_pretrained(checkpoint)
model = BertForNER.from_pretrained(checkpoint, config=config).to(device)
print(model)
Using cpu device
BertForNER(
(bert): BertModel(...)
(dropout): Dropout(p=0.1, inplace=False)
(classifier): Linear(in_features=768, out_features=7, bias=True)
)
可以看到,我们构建的模型首先运用 BERT 模型将每一个 token 都编码为语义向量,然后将输出序列送入到一个包含 7 个神经元的线性全连接层中对每一个 token 进行分类。
为了测试模型的操作是否符合预期,我们尝试将一个 batch 的数据送入模型:
outputs = model(batch_X)
print(outputs.shape)
torch.Size([4, 65, 7])
对于 batch 内 4 个都被填充到长度为 65 的样本,模型对每个 token 都应该输出一个 7 维的向量(对应 7 种实体标签的预测 logits 值),因此这里模型的输出尺寸 4×65×7 完全符合预期。
优化模型参数
与之前一样,我们将每一轮 Epoch 分为“训练循环”和“验证/测试循环”,在训练循环中计算损失、优化模型参数,在验证/测试循环中评估模型性能。下面我们首先实现训练循环。
但是,与文本分类任务对于每个样本只输出一个预测向量不同,token 分类任务会输出一个预测向量的序列(因为对每个 token 都进行了一次分类),因此在使用交叉熵计算模型损失时,不能像之前一样直接将模型的预测结果与标签送入到 CrossEntropyLoss 中进行计算。
对于高维输出(例如 2D 图像需要按像素计算交叉熵),CrossEntropyLoss 需要将输入维度调整为 (batch,C,d1,d2,…,dK),其中 C 是类别个数,K 是输入的维度。对于 token 分类任务,就是在 token 序列维度上计算交叉熵(Keras 称时间步),因此下面我们先通过 pred.permute(0, 2, 1) 交换后两维,将模型输出维度从(batch,seq,7) 调整为 (batch,7,seq),然后再计算损失。
from tqdm.auto import tqdm
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, (X, y) in enumerate(dataloader, start=1):
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred.permute(0, 2, 1), y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
验证/测试循环负责评估模型的性能。这里我们借助 seqeval 库进行评估,seqeval 是一个专门用于序列标注评估的 Python 库,支持 IOB、IOB、IOBES 等多种标注格式以及多种评估策略,例如:
from seqeval.metrics import classification_report
from seqeval.scheme import IOB2
y_true = [['O', 'O', 'O', 'B-LOC', 'I-LOC', 'I-LOC', 'B-LOC', 'O'], ['B-PER', 'I-PER', 'O']]
y_pred = [['O', 'O', 'B-LOC', 'I-LOC', 'I-LOC', 'I-LOC', 'B-LOC', 'O'], ['B-PER', 'I-PER', 'O']]
print(classification_report(y_true, y_pred, mode='strict', scheme=IOB2))
precision recall f1-score support
LOC 0.50 0.50 0.50 2
PER 1.00 1.00 1.00 1
micro avg 0.67 0.67 0.67 3
macro avg 0.75 0.75 0.75 3
weighted avg 0.67 0.67 0.67 3
可以看到,对于第一个地点实体,模型虽然预测正确了其中 2 个 token 的标签,但是仍然判为识别错误,只有当预测的起始和结束位置都正确时才算识别正确。
在验证/测试循环中,我们首先将预测结果和正确标签都先转换为 seqeval 库接受的格式,并且过滤掉标签值为 -100 的特殊 token,然后送入到 seqeval 提供的 classification_report 函数中计算 P / R / F1 等指标:
from seqeval.metrics import classification_report
from seqeval.scheme import IOB2
def test_loop(dataloader, model):
true_labels, true_predictions = [], []
model.eval()
with torch.no_grad():
for X, y in tqdm(dataloader):
X, y = X.to(device), y.to(device)
pred = model(X)
predictions = pred.argmax(dim=-1).cpu().numpy().tolist()
labels = y.cpu().numpy().tolist()
true_labels += [[id2label[int(l)] for l in label if l != -100] for label in labels]
true_predictions += [
[id2label[int(p)] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
print(classification_report(true_labels, true_predictions, mode='strict', scheme=IOB2))
最后,将“训练循环”和“验证/测试循环”组合成 Epoch 就可以训练和验证模型了。与之前一样,我们使用 AdamW 优化器,并且通过 get_scheduler() 函数定义学习率调度器:
from transformers import AdamW, get_scheduler
learning_rate = 1e-5
epoch_num = 3
loss_fn = nn.CrossEntropyLoss()
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
test_loop(valid_dataloader, model)
print("Done!")
Using cuda device
Epoch 1/3
-------------------------------
loss: 0.051314: 100%|██████████| 5216/5216 [04:30<00:00, 19.25it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 33.77it/s]
precision recall f1-score support
LOC 0.95 0.95 0.95 1951
ORG 0.91 0.89 0.90 984
PER 0.98 0.98 0.98 884
micro avg 0.95 0.94 0.94 3819
macro avg 0.95 0.94 0.94 3819
weighted avg 0.95 0.94 0.94 3819
Epoch 2/3
-------------------------------
loss: 0.033487: 100%|██████████| 5216/5216 [04:30<00:00, 19.29it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 33.89it/s]
precision recall f1-score support
LOC 0.97 0.95 0.96 1951
ORG 0.93 0.92 0.92 984
PER 0.99 0.98 0.98 884
micro avg 0.96 0.95 0.96 3819
macro avg 0.96 0.95 0.96 3819
weighted avg 0.96 0.95 0.96 3819
Epoch 3/3
-------------------------------
loss: 0.024727: 100%|██████████| 5216/5216 [04:31<00:00, 19.23it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 34.05it/s]
precision recall f1-score support
LOC 0.97 0.97 0.97 1951
ORG 0.93 0.92 0.92 984
PER 0.99 0.98 0.99 884
micro avg 0.96 0.96 0.96 3819
macro avg 0.96 0.96 0.96 3819
weighted avg 0.96 0.96 0.96 3819
Done!
保存模型
在实际应用中,我们会根据每一轮模型在验证集上的性能来调整超参数以及选出最好的权重,最后将选出的模型应用于测试集以评估最终的性能。因此,我们首先在上面的验证/测试循环中返回 seqeval 库计算出的指标,然后在每一个 Epoch 中根据 macro-F1/micro-F1 指标保存在验证集上最好的模型:
def test_loop(dataloader, model):
true_labels, true_predictions = [], []
model.eval()
with torch.no_grad():
for X, y in tqdm(dataloader):
X, y = X.to(device), y.to(device)
pred = model(X)
predictions = pred.argmax(dim=-1).cpu().numpy().tolist()
labels = y.cpu().numpy().tolist()
true_labels += [[id2label[int(l)] for l in label if l != -100] for label in labels]
true_predictions += [
[id2label[int(p)] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
print(classification_report(true_labels, true_predictions, mode='strict', scheme=IOB2))
return classification_report(
true_labels,
true_predictions,
mode='strict',
scheme=IOB2,
output_dict=True
)
total_loss = 0.
best_f1 = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
metrics = test_loop(valid_dataloader, model)
valid_macro_f1, valid_micro_f1 = metrics['macro avg']['f1-score'], metrics['micro avg']['f1-score']
valid_f1 = metrics['weighted avg']['f1-score']
if valid_f1 > best_f1:
best_f1 = valid_f1
print('saving new weights...\n')
torch.save(
model.state_dict(),
f'epoch_{t+1}_valid_macrof1_{(100*valid_macro_f1):0.3f}_microf1_{(100*valid_micro_f1):0.3f}_weights.bin'
)
print("Done!")
Using cuda device
Epoch 1/3
-------------------------------
loss: 0.051314: 100%|██████████| 5216/5216 [04:30<00:00, 19.25it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 33.77it/s]
precision recall f1-score support
LOC 0.95 0.95 0.95 1951
ORG 0.91 0.89 0.90 984
PER 0.98 0.98 0.98 884
micro avg 0.95 0.94 0.94 3819
macro avg 0.95 0.94 0.94 3819
weighted avg 0.95 0.94 0.94 3819
saving new weights...
Epoch 2/3
-------------------------------
loss: 0.033487: 100%|██████████| 5216/5216 [04:30<00:00, 19.29it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 33.89it/s]
precision recall f1-score support
LOC 0.97 0.95 0.96 1951
ORG 0.93 0.92 0.92 984
PER 0.99 0.98 0.98 884
micro avg 0.96 0.95 0.96 3819
macro avg 0.96 0.95 0.96 3819
weighted avg 0.96 0.95 0.96 3819
saving new weights...
Epoch 3/3
-------------------------------
loss: 0.024727: 100%|██████████| 5216/5216 [04:31<00:00, 19.23it/s]
100%|██████████████████████████| 580/580 [00:17<00:00, 34.05it/s]
precision recall f1-score support
LOC 0.97 0.97 0.97 1951
ORG 0.93 0.92 0.92 984
PER 0.99 0.98 0.99 884
micro avg 0.96 0.96 0.96 3819
macro avg 0.96 0.96 0.96 3819
weighted avg 0.96 0.96 0.96 3819
saving new weights...
Done!
可以看到,随着训练的进行,模型在验证集上的 F1 值在不断提升。因此,3 轮 Epoch 结束后,会在目录下保存 3 个模型权重:
epoch_1_valid_macrof1_94.340_microf1_94.399_weights.bin
epoch_2_valid_macrof1_95.641_microf1_95.728_weights.bin
epoch_3_valid_macrof1_95.878_microf1_96.049_weights.bin
至此,我们手工构建的 NER 模型的训练过程就完成了,完整的训练代码如下:
import os
import numpy as np
import random
from tqdm.auto import tqdm
from seqeval.metrics import classification_report
from seqeval.scheme import IOB2
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoConfig
from transformers import BertPreTrainedModel, BertModel
from transformers import AdamW, get_scheduler
learning_rate = 1e-5
batch_size = 4
epoch_num = 3
seed = 7
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
random.seed(seed)
np.random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
categories = set()
class PeopleDaily(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f.read().split('\n\n')):
if not line:
breaksentence, labels = '', []
for i, item in enumerate(line.split('\n')):
char, tag = item.split(' ')
sentence += char
if tag.startswith('B'):
labels.append([i, i, char, tag[2:]]) # Remove the B- or I-
categories.add(tag[2:])
elif tag.startswith('I'):
labels[-1][1] = i
labels[-1][2] += char
Data[idx] = {
'sentence': sentence,
'labels': labels
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = PeopleDaily('data/china-people-daily-ner-corpus/example.train')
valid_data = PeopleDaily('data/china-people-daily-ner-corpus/example.dev')
test_data = PeopleDaily('data/china-people-daily-ner-corpus/example.test')
id2label = {0:'O'}
for c in list(sorted(categories)):
id2label[len(id2label)] = f"B-{c}"
id2label[len(id2label)] = f"I-{c}"
label2id = {v: k for k, v in id2label.items()}
def collote_fn(batch_samples):
batch_sentence, batch_labels = [], []
for sample in batch_samples:
batch_sentence.append(sample['sentence'])
batch_labels.append(sample['labels'])
batch_inputs = tokenizer(
batch_sentence,
padding=True,
truncation=True,
return_tensors="pt"
)
batch_label = np.zeros(batch_inputs['input_ids'].shape, dtype=int)
for s_idx, sentence in enumerate(batch_sentence):
encoding = tokenizer(sentence, truncation=True)
batch_label[s_idx][0] = -100
batch_label[s_idx][len(encoding.tokens())-1:] = -100
for char_start, char_end, _, tag in batch_labels[s_idx]:
token_start = encoding.char_to_token(char_start)
token_end = encoding.char_to_token(char_end)
batch_label[s_idx][token_start] = label2id[f"B-{tag}"]
batch_label[s_idx][token_start+1:token_end+1] = label2id[f"I-{tag}"]
return batch_inputs, torch.tensor(batch_label)
train_dataloader = DataLoader(train_data, batch_size=batch_size, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
test_dataloader = DataLoader(test_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
class BertForNER(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(768, len(id2label))
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
sequence_output = bert_output.last_hidden_state
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output)
return logits
config = AutoConfig.from_pretrained(checkpoint)
model = BertForNER.from_pretrained(checkpoint, config=config).to(device)
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, (X, y) in enumerate(dataloader, start=1):
X, y = X.to(device), y.to(device)
pred = model(X)
loss = loss_fn(pred.permute(0, 2, 1), y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
def test_loop(dataloader, model):
true_labels, true_predictions = [], []
model.eval()
with torch.no_grad():
for X, y in tqdm(dataloader):
X, y = X.to(device), y.to(device)
pred = model(X)
predictions = pred.argmax(dim=-1).cpu().numpy().tolist()
labels = y.cpu().numpy().tolist()
true_labels += [[id2label[int(l)] for l in label if l != -100] for label in labels]
true_predictions += [
[id2label[int(p)] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
print(classification_report(true_labels, true_predictions, mode='strict', scheme=IOB2))
return classification_report(
true_labels,
true_predictions,
mode='strict',
scheme=IOB2,
output_dict=True
)
loss_fn = nn.CrossEntropyLoss()
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_f1 = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
metrics = test_loop(valid_dataloader, model)
valid_macro_f1, valid_micro_f1 = metrics['macro avg']['f1-score'], metrics['micro avg']['f1-score']
valid_f1 = metrics['weighted avg']['f1-score']
if valid_f1 > best_f1:
best_f1 = valid_f1
print('saving new weights...\n')
torch.save(
model.state_dict(),
f'epoch_{t+1}_valid_macrof1_{(100*valid_macro_f1):0.3f}_microf1_{(100*valid_micro_f1):0.3f}_weights.bin'
)
print("Done!")
3. 测试模型
训练完成后,我们加载在验证集上性能最优的模型权重,汇报其在测试集上的性能,并且将模型的预测结果保存到文件中。
处理模型输出
模型的输出是一个由预测向量组成的列表,每个向量对应一个 token 的预测结果,只需要在输出 logits 值上运用 softmax 函数就可以获得实体类别的预测概率。与《快速分词器》中类似,我们首先从输出中取出“B-”或“I-”开头的 token,然后将这些 token 组合成实体,最后将实体对应的 token 的平均概率作为实体的概率。
下面我们以处理单个句子为例,加载训练好的 NER 模型来识别句子中的实体:
sentence = '日本外务省3月18日发布消息称,日本首相岸田文雄将于19至21日访问印度和柬埔寨。'
model.load_state_dict(
torch.load('epoch_3_valid_macrof1_95.878_microf1_96.049_weights.bin', map_location=torch.device(device))
)
model.eval()
results = []
with torch.no_grad():
inputs = tokenizer(sentence, truncation=True, return_tensors="pt", return_offsets_mapping=True)
offsets = inputs.pop('offset_mapping').squeeze(0)
inputs = inputs.to(device)
pred = model(inputs)
probabilities = torch.nn.functional.softmax(pred, dim=-1)[0].cpu().numpy().tolist()
predictions = pred.argmax(dim=-1)[0].cpu().numpy().tolist()
pred_label = []
idx = 0
while idx < len(predictions):
pred = predictions[idx]
label = id2label[pred]
if label != "O":
label = label[2:] # Remove the B- or I-
start, end = offsets[idx]
all_scores = [probabilities[idx][pred]]
# Grab all the tokens labeled with I-label
while (
idx + 1 < len(predictions) andid2label[predictions[idx + 1]] == f"I-{label}"
):
all_scores.append(probabilities[idx + 1][predictions[idx + 1]])
_, end = offsets[idx + 1]
idx += 1
score = np.mean(all_scores).item()
start, end = start.item(), end.item()
word = sentence[start:end]
pred_label.append(
{
"entity_group": label,
"score": score,
"word": word,
"start": start,
"end": end,
}
)
idx += 1
print(pred_label)
[
{'entity_group': 'ORG', 'score': 0.9994237422943115, 'word': '日本外务省', 'start': 0, 'end': 5},
{'entity_group': 'LOC', 'score': 0.9989436864852905, 'word': '日本', 'start': 16, 'end': 18},
{'entity_group': 'PER', 'score': 0.9996790438890457, 'word': '岸田文雄', 'start': 20, 'end': 24},
{'entity_group': 'LOC', 'score': 0.9996350705623627, 'word': '印度', 'start': 34, 'end': 36},
{'entity_group': 'LOC', 'score': 0.9995178381601969, 'word': '柬埔寨', 'start': 37, 'end': 40}
]
可以看到模型成功地将“日本外务省”识别为组织 (ORG),将“岸田文雄”识别为人物 (PER),将“日本”、“印度”、“柬埔寨”识别为地点 (LOC)。
保存预测结果
最后,我们简单扩展上面的代码以处理整个测试集,不仅像之前“验证/测试循环”中那样评估模型在测试集上的性能,并且将模型的预测结果以 json 格式存储到文件中:
import json
model.load_state_dict(
torch.load('epoch_3_valid_macrof1_95.878_microf1_96.049_weights.bin', map_location=torch.device('cpu'))
)
model.eval()
with torch.no_grad():
print('evaluating on test set...')
true_labels, true_predictions = [], []
for X, y in tqdm(test_dataloader):
X, y = X.to(device), y.to(device)
pred = model(X)
predictions = pred.argmax(dim=-1).cpu().numpy().tolist()
labels = y.cpu().numpy().tolist()
true_labels += [[id2label[int(l)] for l in label if l != -100] for label in labels]
true_predictions += [
[id2label[int(p)] for (p, l) in zip(prediction, label) if l != -100]
for prediction, label in zip(predictions, labels)
]
print(classification_report(true_labels, true_predictions, mode='strict', scheme=IOB2))
results = []
print('predicting labels...')
for s_idx in tqdm(range(len(test_data))):
example = test_data[s_idx]
inputs = tokenizer(example['sentence'], truncation=True, return_tensors="pt")
inputs = inputs.to(device)
pred = model(inputs)
probabilities = torch.nn.functional.softmax(pred, dim=-1)[0].cpu().numpy().tolist()
predictions = pred.argmax(dim=-1)[0].cpu().numpy().tolist()
pred_label = []
inputs_with_offsets = tokenizer(example['sentence'], return_offsets_mapping=True)
tokens = inputs_with_offsets.tokens()
offsets = inputs_with_offsets["offset_mapping"]
idx = 0
while idx < len(predictions):
pred = predictions[idx]
label = id2label[pred]
if label != "O":
label = label[2:] # Remove the B- or I-
start, end = offsets[idx]
all_scores = [probabilities[idx][pred]]
# Grab all the tokens labeled with I-label
while (
idx + 1 < len(predictions) andid2label[predictions[idx + 1]] == f"I-{label}"
):
all_scores.append(probabilities[idx + 1][predictions[idx + 1]])
_, end = offsets[idx + 1]
idx += 1
score = np.mean(all_scores).item()
word = example['sentence'][start:end]
pred_label.append(
{
"entity_group": label,
"score": score,
"word": word,
"start": start,
"end": end,
}
)
idx += 1
results.append(
{
"sentence": example['sentence'],
"pred_label": pred_label,
"true_label": example['labels']
}
)
with open('test_data_pred.json', 'wt', encoding='utf-8') as f:
for exapmle_result in results:
f.write(json.dumps(exapmle_result, ensure_ascii=False) + '\n')
Using cuda device
evaluating on test set...
100%|████████████| 1159/1159 [00:35<00:00, 32.25it/s]
precision recall f1-score support
LOC 0.96 0.96 0.96 3658
ORG 0.90 0.92 0.91 2185
PER 0.98 0.98 0.98 1864
micro avg 0.95 0.95 0.95 7707
macro avg 0.95 0.95 0.95 7707
weighted avg 0.95 0.95 0.95 7707
predicting labels...
100%|████████████| 4636/4636 [00:34<00:00, 135.78it/s]
可以看到,模型最终在测试集上的宏/微 F1 值都达到 95% 左右。考虑到我们只使用了基础版本的 BERT 模型,并且只训练了 3 轮,这已经是一个不错的结果了。
我们打开保存预测结果的 test_data_pred.json,其中每一行对应一个样本,sentence 对应原文,pred_label 对应预测出的实体,true_label 对应标注实体信息。
{
"sentence": "我们变而以书会友,以书结缘,把欧美、港台流行的食品类图谱、画册、工具书汇集一堂。",
"pred_label": [
{"entity_group": "LOC", "score": 0.9954637885093689, "word": "欧", "start": 15, "end": 16},
{"entity_group": "LOC", "score": 0.9948422312736511, "word": "美", "start": 16, "end": 17},
{"entity_group": "LOC", "score": 0.9960285425186157, "word": "港", "start": 18, "end": 19},
{"entity_group": "LOC", "score": 0.9940919280052185, "word": "台", "start": 19, "end": 20}
],
"true_label": [
[15, 15, "欧", "LOC"],
[16, 16, "美", "LOC"],
[18, 18, "港", "LOC"],
[19, 19, "台", "LOC"]
]
}
...
至此,我们运用 Transformers 库进行 NER 任务就全部完成了!
代码
与之前一样,我们按照功能将代码拆分成模块并且存放在不同的文件中,整理后的代码存储在 Github:
How-to-use-Transformers/src/sequence_labeling_ner_cpd/
与 Transformers 库类似,我们将模型损失的计算也包含进模型本身,这样在训练循环中我们就可以直接使用模型返回的损失进行反向传播。
为了简化数据处理,这里我们并没有将 [CLS]、[SEP]、[PAD] 等特殊 token 对应的标签设为 -100,而是维持原始的 0 值,然后在计算损失时借助 Attention Mask 来排除填充位置:
active_loss = attention_mask.view(-1) == 1
active_logits = logits.view(-1, self.num_labels)[active_loss]
active_labels = labels.view(-1)[active_loss]
loss = loss_fct(active_logits, active_labels)
最后通过 view() 将 batch 中的多个向量序列连接为一个序列,这样就可以直接使用交叉熵函数计算损失,而不必像我们上面那样进行维度调整。
除了本章介绍的纯基于 BERT 的 NER 模型,我们还实现了一个带有 CRF 层的 BERT+CRF 模型,分别通过运行 run_ner_softmax.sh 和 run_ner_crf.sh 脚本进行训练。如果要进行测试或者将预测结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 BERT 模型在测试集上的准确率为 95.10%,BERT+CRF 为 95.37%(Nvidia Tesla V100, batch=4)。
第十章:翻译任务
本章我们将运用 Transformers 库来完成翻译任务。翻译是典型的序列到序列 (sequence-to-sequence, Seq2Seq) 任务,即对于每一个输入序列都会输出一个对应的序列。翻译在任务形式上与许多其他任务很接近,例如:
- 文本摘要 (Summarization):将长文本压缩为短文本,并且还要尽可能保留核心内容。
- 风格转换 (Style transfer):将文本转换为另一种书写风格,例如将文言文转换为白话文、将古典英语转换为现代英语;
- 生成式问答 (Generative question answering):对于给定的问题,基于上下文生成对应的答案。
理论上我们也可以将本章的操作应用于完成这些 Seq2Seq 任务。
翻译任务通常需要大量的对照语料用于训练,如果我们有足够多的训练数据就可以从头训练一个翻译模型,但是微调预训练好的模型会更快,例如将 mT5、mBART 等多语言模型微调到特定的语言对。
本章我们将微调一个 Marian 翻译模型进行汉英翻译,该模型已经基于 Opus 语料对汉英翻译任务进行了预训练,因此可以直接用于翻译。而通过我们的微调,可以进一步提升该模型在特定语料上的性能。
1. 准备数据
我们选择 translation2019zh 语料作为数据集,它共包含中英文平行语料 520 万对,可以用于训练中英翻译模型。Github 仓库中只提供了 Google Drive 链接,我们也可以通过和鲸社区或者百度云盘下载。
本文我们将基于该语料,微调一个预训练好的汉英翻译模型。
该语料已经划分好了训练集和验证集,分别包含 516 万和 3.9 万个样本,语料以 json 格式提供,一行是一个中英文对照句子对:
{"english": "In Italy, there is no real public pressure for a new, fairer tax system.", "chinese": "在意大利,公众不会真的向政府施压,要求实行新的、更公平的税收制度。"}
构建数据集
与之前一样,我们首先编写继承自 Dataset 类的自定义数据集类用于组织样本和标签。考虑到 translation2019zh 并没有提供测试集,而且使用五百多万条样本进行训练耗时过长,这里我们只抽取训练集中的前 22 万条数据,并从中划分出 2 万条数据作为验证集,然后将 translation2019zh 中的验证集作为测试集:
from torch.utils.data import Dataset, random_split
import json
max_dataset_size = 220000
train_set_size = 200000
valid_set_size = 20000
class TRANS(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
if idx >= max_dataset_size:
breaksample = json.loads(line.strip())
Data[idx] = sample
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
data = TRANS('data/translation2019zh/translation2019zh_train.json')
train_data, valid_data = random_split(data, [train_set_size, valid_set_size])
test_data = TRANS('data/translation2019zh/translation2019zh_valid.json')
下面我们输出数据集的大小并且打印出一个训练样本:
print(f'train set size: {len(train_data)}')
print(f'valid set size: {len(valid_data)}')
print(f'test set size: {len(test_data)}')
print(next(iter(train_data)))
train set size: 200000
valid set size: 20000
test set size: 39323
{'english': "We're going to order some chicks for the kids and to replan the shop stock, and we chose to go with the colored ones that look cute.", 'chinese': '我们打算为儿童们定购一些小鸡,来补充商店存货,我们选择了这些被染了色的小鸡,它们看起来真的很可爱。'}
数据预处理
接下来我们就需要通过 DataLoader 库来按 batch 加载数据,将文本转换为模型可以接受的 token IDs。对于翻译任务,我们需要运用分词器同时对源文本和目标文本进行编码,这里我们选择 Helsinki-NLP 提供的汉英翻译模型 opus-mt-zh-en 对应的分词器:
from transformers import AutoTokenizer
model_checkpoint = "Helsinki-NLP/opus-mt-zh-en"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
你也可以尝试别的语言,Helsinki-NLP 提供了超过了一千种模型用于在不同语言之间进行翻译,只需要将 model_checkpoint 设置为对应的语言即可。如果你正在使用多语言模型的分词器,例如 mBART、mBART-50、M2M100,就需要通过设置 tokenizer.src_lang 和 tokenizer.tgt_lang 来手工设定源/目标语言。
默认情况下分词器会采用源语言的设定来编码文本(对于英翻译模型 opus-mt-zh-en 而言就是中文),要编码目标语言则需要使用 text_targets 参数:
zh_sentence = train_data[0]["chinese"]
en_sentence = train_data[0]["english"]
inputs = tokenizer(zh_sentence)
targets = tokenizer(text_target=en_sentence)
如果你忘记使用 text_targets 参数,就会使用源语言分词器对目标语言进行编码,产生糟糕的分词结果:
wrong_targets = tokenizer(en_sentence)
print(tokenizer.convert_ids_to_tokens(inputs["input_ids"]))
print(tokenizer.convert_ids_to_tokens(targets["input_ids"]))
print(tokenizer.convert_ids_to_tokens(wrong_targets["input_ids"]))
['▁我们', '打算', '为儿童', '们', '定', '购', '一些', '小', '鸡', ',', '来', '补充', '商店', '存货', ',', '我们', '选择', '了', '这些', '被', '染', '了', '色', '的小', '鸡', ',', '它们', '看起来', '真的很', '可爱', '。', '</s>']
['▁We', "'", 're', '▁going', '▁to', '▁order', '▁some', '▁chicks', '▁for', '▁the', '▁kids', '▁and', '▁to', '▁re', 'plan', '▁the', '▁shop', '▁stock', ',', '▁and', '▁we', '▁chose', '▁to', '▁go', '▁with', '▁the', '▁color', 'ed', '▁ones', '▁that', '▁look', '▁cute', '.', '</s>']
['▁We', "'", 're', '▁going', '▁to', '▁', 'or', 'der', '▁some', '▁ch', 'ick', 's', '▁for', '▁the', '▁k', 'id', 's', '▁and', '▁to', '▁re', 'p', 'lan', '▁the', '▁', 'sh', 'op', '▁', 'st', 'ock', ',', '▁and', '▁we', '▁', 'cho', 'se', '▁to', '▁go', '▁with', '▁the', '▁c', 'olo', 'red', '▁', 'ones', '▁that', '▁look', '▁', 'cu', 'te', '.', '</s>']
可以看到,由于中文分词器无法很好地识别英文单词,用它编码英文文本会生成更多的 token,例如这里将“order”切分为了“or”和“der”,将“chicks”切分为了“ch”、“ick”、“s”等等。
对于翻译任务,标签序列就是目标语言的 token ID 序列。与序列标注任务类似,我们会在预测标签序列与答案标签序列之间计算损失来调整模型参数,因此我们同样需要将填充的 pad 字符设置为 -100,以便在使用交叉熵计算序列损失时将它们忽略:
import torch
max_input_length = 128
max_target_length = 128
inputs = [train_data[s_idx]["chinese"] for s_idx in range(4)]
targets = [train_data[s_idx]["english"] for s_idx in range(4)]
model_inputs = tokenizer(
inputs,
padding=True,
max_length=max_input_length,
truncation=True,
return_tensors="pt"
)
labels = tokenizer(
text_target=targets,
padding=True,
max_length=max_target_length,
truncation=True,
return_tensors="pt"
)["input_ids"]
end_token_index = torch.where(labels == tokenizer.eos_token_id)[1]
for idx, end_idx in enumerate(end_token_index):
labels[idx][end_idx+1:] = -100
print('batch_X shape:', {k: v.shape for k, v in model_inputs.items()})
print('batch_y shape:', labels.shape)
print(model_inputs)
print(labels)
batch_X shape: {
'input_ids': torch.Size([4, 36]),
'attention_mask': torch.Size([4, 36])
}
batch_y shape: torch.Size([4, 43])
{'input_ids': tensor([
[ 335, 3321, 20836, 2505, 3410, 13425, 617, 1049, 12245, 2,
272, 2369, 25067, 28865, 2, 230, 1507, 55, 288, 266,
19981, 55, 5144, 9554, 12245, 2, 896, 13699, 15710, 15249,
9, 0, 65000, 65000, 65000, 65000],
[ 5786, 23552, 36, 8380, 16532, 378, 675, 2878, 272, 702,
22092, 11, 20819, 4085, 309, 3428, 43488, 9, 0, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000],
[ 103, 27772, 32598, 241, 930, 2, 8714, 4349, 9460, 69,
10866, 272, 2733, 1499, 18852, 8390, 11, 25, 384, 5520,
35761, 1924, 1251, 1499, 9, 0, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000],
[ 7, 706, 5634, 24065, 16505, 13502, 176, 10252, 53105, 2,
3021, 5980, 31854, 2509, 9, 91, 646, 3976, 40408, 6305,
11, 7440, 1020, 7471, 56880, 5980, 31854, 34, 46037, 17267,
514, 43792, 6455, 20889, 17, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]])}
tensor([[ 140, 21, 129, 717, 8, 278, 239, 53363, 14, 3,
6801, 6, 8, 1296, 38776, 3, 18850, 7985, 2, 6,
107, 21883, 8, 374, 27, 3, 20969, 250, 6048, 19,
1217, 20511, 5, 0, -100, -100, -100, -100, -100, -100,
-100, -100, -100],
[ 3822, 28838, 847, 115, 12, 17059, 8, 603, 649, 4,
33030, 46, 3, 315, 557, 3, 21347, 5, 0, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100],
[ 379, 41237, 480, 26, 2127, 10, 158, 1963, 19, 3,
24463, 2564, 18, 1509, 8, 41272, 158, 28930, 25, 58,
43, 32192, 10532, 42, 172, 105, 5, 0, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100],
[48532, 3, 42162, 22, 29, 95, 1568, 15398, 7, 41830,
59, 14375, 2104, 168, 5101, 6347, 3708, 7497, 36852, 6,
29594, 199, 27, 12, 1960, 32215, 18162, 27015, 4, 8706,
1029, 20, 4098, 54, 8273, 8, 8996, 3, 41372, 102,
50348, 243, 0]])
我们使用的 Marian 模型会在分词结果的结尾加上特殊 token '</s>',因此这里通过 tokenizer.eos_token_id 定位其在 token ID 序列中的索引,然后将其之后的 pad 字符设置为 -100。
标签序列的格式需要依据模型而定,例如如果你使用的是 T5 模型,模型的输入还需要包含指明任务类型的前缀 (prefix),对于翻译任务就需要在输入前添加 translate: Chinese to English:。
与我们之前任务中使用的纯 Encoder 模型不同,Seq2Seq 任务对应的模型采用的是 Encoder-Decoder 框架:Encoder 负责编码输入序列,Decoder 负责循环地逐个生成输出 token。因此,对于每一个样本,我们还需要额外准备 decoder input IDs 作为 Decoder 的输入。decoder input IDs 是标签序列的移位,在序列的开始位置增加了一个特殊的“序列起始符”。
在训练过程中,模型会基于 decoder input IDs 和 attention mask 来确保在预测某个 token 时不会使用到该 token 及其之后的 token 的信息。即 Decoder 在预测某个目标 token 时,只能基于“整个输入序列”和“当前已经预测出的 token”信息来进行预测,如果提前看到要预测的 token(甚至更后面的 token),就相当于是“作弊”了。因此在训练时,会通过特殊的“三角形” Mask 来遮掩掉预测 token 及其之后的 token 的信息。
如果对这一块感到困惑,可以参考苏剑林的博文《从语言模型到Seq2Seq:Transformer如戏,全靠Mask》。
考虑到不同模型的移位操作可能存在差异,我们通过模型自带的 prepare_decoder_input_ids_from_labels 函数来完成。完整的批处理函数为:
import torch
from torch.utils.data import DataLoader
from transformers import AutoModelForSeq2SeqLM
max_length = 128
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
def collote_fn(batch_samples):
batch_inputs, batch_targets = [], []
for sample in batch_samples:
batch_inputs.append(sample['chinese'])
batch_targets.append(sample['english'])
batch_data = tokenizer(
batch_inputs,
text_target=batch_targets,
padding=True,
max_length=max_length,
truncation=True,
return_tensors="pt"
)
batch_data['decoder_input_ids'] = model.prepare_decoder_input_ids_from_labels(batch_data['labels'])
end_token_index = torch.where(batch_data['labels'] == tokenizer.eos_token_id)[1]
for idx, end_idx in enumerate(end_token_index):
batch_data['labels'][idx][end_idx+1:] = -100
return batch_data
train_dataloader = DataLoader(train_data, batch_size=32, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=32, shuffle=False, collate_fn=collote_fn)
注意,由于本文直接使用 Transformers 库自带的 AutoModelForSeq2SeqLM 函数来构建模型,因此我们需要将每一个 batch 中的数据处理为该模型可接受的格式:一个包含 'input_ids'、'attention_mask'、'labels' 和 'decoder_input_ids' 键的字典。
正如前面所述,编码目标语言需要使用 text_targets 参数,因此需要调用两次分词器以分别处理源语言和目标语言。实际上分词器可以并行地处理源/目标语言,只需同时传入两种语言的序列,分词器就会自动在输出中以 'labels' 键返回目标语言的 token ID 序列。因此这里我们只调用了一次分词器就同时处理好了中文和英文序列。
下面我们尝试打印出一个 batch 的数据,以验证是否处理正确:
batch = next(iter(train_dataloader))
print(batch.keys())
print('batch shape:', {k: v.shape for k, v in batch.items()})
print(batch)
dict_keys(['input_ids', 'attention_mask', 'decoder_input_ids', 'labels'])
batch shape: {
'input_ids': torch.Size([4, 57]),
'attention_mask': torch.Size([4, 57]),
'decoder_input_ids': torch.Size([4, 37]),
'labels': torch.Size([4, 37])
}
{'input_ids': tensor([
[ 4385, 257, 6095, 11065, 4028, 142, 2, 14025, 16036, 2059,
2677, 9, 0, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000],
[ 335, 675, 6072, 160, 2353, 12746, 6875, 10851, 13, 3067,
49431, 13, 21639, 11180, 188, 23811, 3127, 59374, 12746, 16,
10801, 10459, 2, 2754, 101, 62987, 3975, 6875, 10851, 2326,
13, 16106, 39781, 6875, 10851, 2326, 13, 41743, 3975, 6875,
10851, 2326, 13, 3067, 49431, 2326, 13, 4011, 21639, 2326,
13, 23811, 3127, 59374, 408, 9, 0],
[ 7, 10900, 2702, 2997, 5257, 4145, 3277, 9239, 2437, 2,
1222, 11929, 2, 36, 4776, 4998, 2992, 2061, 16, 5029,
2061, 27060, 1297, 2, 10900, 2702, 28874, 5029, 4205, 16,
11959, 4205, 29858, 9, 0, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000],
[ 7, 690, 840, 31, 11, 2847, 2, 61232, 1862, 2989,
4870, 1548, 55902, 1058, 348, 4316, 1371, 14036, 9, 0,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0]]),
'decoder_input_ids': tensor([
[65000, 1738, 209, 30, 1294, 30, 54, 43574, 22, 2,
183, 3, 1483, 4, 1540, 7418, 5, 0, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000],
[65000, 140, 281, 520, 2293, 4, 37984, 102, 7, 2,
26398, 1632, 2, 32215, 102, 2, 22188, 1403, 6, 3825,
7, 2, 286, 1282, 2687, 586, 55450, 37984, 501, 2,
7684, 177, 37984, 501, 2, 825, 1181],
[65000, 50295, 53923, 54326, 22, 4471, 2, 513, 26103, 4,
3275, 2707, 2907, 6, 10, 12, 4405, 625, 10, 10813,
4, 50295, 53923, 1906, 15486, 10813, 5032, 6, 13962, 5032,
12620, 5, 0, 65000, 65000, 65000, 65000],
[65000, 1008, 840, 28, 41223, 4688, 30, 37855, 250, 10204,
4, 2407, 5, 0, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000]]),
'labels': tensor([
[ 1738, 209, 30, 1294, 30, 54, 43574, 22, 2, 183,
3, 1483, 4, 1540, 7418, 5, 0, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100],
[ 140, 281, 520, 2293, 4, 37984, 102, 7, 2, 26398,
1632, 2, 32215, 102, 2, 22188, 1403, 6, 3825, 7,
2, 286, 1282, 2687, 586, 55450, 37984, 501, 2, 7684,
177, 37984, 501, 2, 825, 1181, 0],
[50295, 53923, 54326, 22, 4471, 2, 513, 26103, 4, 3275,
2707, 2907, 6, 10, 12, 4405, 625, 10, 10813, 4,
50295, 53923, 1906, 15486, 10813, 5032, 6, 13962, 5032, 12620,
5, 0, -100, -100, -100, -100, -100],
[ 1008, 840, 28, 41223, 4688, 30, 37855, 250, 10204, 4,
2407, 5, 0, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100]])}
可以看到,DataLoader 按照我们设置的 batch size,每次对 4 个样本进行编码,并且填充 token 对应的标签都被设置为 -100。我们构建的 Decoder 的输入 decoder input IDs 尺寸与标签序列完全相同,且通过向后移位在序列头部添加了特殊的“序列起始符”,例如第一个样本:
'labels':
[ 1738, 209, 30, 1294, 30, 54, 43574, 22, 2, 183,
3, 1483, 4, 1540, 7418, 5, 0, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100, -100, -100]
'decoder_input_ids':
[65000, 1738, 209, 30, 1294, 30, 54, 43574, 22, 2,
183, 3, 1483, 4, 1540, 7418, 5, 0, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000, 65000,
65000, 65000, 65000, 65000, 65000, 65000, 65000]
至此,数据预处理部分就全部完成了!
在大部分情况下,即使我们在 batch 数据中没有包含 decoder input IDs,模型也能正常训练,它会自动调用模型的 prepare_decoder_input_ids_from_labels 函数来构造 decoder_input_ids。
2. 训练模型
本文直接使用 Transformers 库自带的 AutoModelForSeq2SeqLM 类来构建模型,并且在批处理函数中还调用了模型自带的 prepare_decoder_input_ids_from_labels 函数,因此下面只需要实现 Epoch 中的”训练循环”和”验证/测试循环”。
这里之所以没有像前面章节中那样自己编写模型,是因为翻译模型的结构较为复杂,要完整地完成编码、解码过程需要借助许多辅助函数。我们可以想象,如果我们同样通过继承 PreTrainedModel 类来实现翻译模型,那么其结构大致为:
fromtorchimportnn
fromtransformersimportAutoConfig
fromtransformers.models.marianimportMarianPreTrainedModel, MarianModel
classMarianForMT(MarianPreTrainedModel):
def__init__(self, config):
super().__init__(config)
self.model=MarianModel(config)
target_vocab_size=config.decoder_vocab_size
self.register_buffer("final_logits_bias", torch.zeros((1, target_vocab_size)))
self.lm_head=nn.Linear(config.d_model, target_vocab_size, bias=False)
self.post_init()
defforward(self, x):
output=self.model(**x)
sequence_output=output.last_hidden_state
lm_logits=self.lm_head(sequence_output)+self.final_logits_bias
returnlm_logits
defother_func(self):
passconfig=AutoConfig.from_pretrained(checkpoint)
model=MarianForMT.from_pretrained(checkpoint, config=config).to(device)
(model)
Using cpu device
MarianForMT(
(model): MarianModel(
(shared): Embedding(65001, 512, padding_idx=65000)
(encoder): MarianEncoder(...)
(decoder): MarianDecoder(...)
)
(lm_head): Linear(in_features=512, out_features=65001, bias=False)
)即模型会首先运用 Marian 模型的 Encoder 对输入 token 序列进行编码,然后通过 Decoder 基于我们构建的 Decoder 输入解码出对应的目标向量序列,最后将输出序列送入到一个包含 65001 个神经元的线性全连接层中进行分类,预测每个向量对应的是词表中的哪个词。
为了测试模型的操作是否符合预期,我们尝试将一个 batch 的数据送入模型:
outputs=model(batch_X)
(outputs.shape)
torch.Size([4, 37, 65001])可以看到,模型的输出尺寸 4×37×65001 与我们构造的 Decoder 输入
decoder_input_ids尺寸完全一致。
优化模型参数
使用 AutoModelForSeq2SeqLM 构造的模型已经封装好了对应的损失函数,并且计算出的损失会直接包含在模型的输出 outputs 中,可以直接通过 outputs.loss 获得,因此训练循环为:
from tqdm.auto import tqdm
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, batch_data in enumerate(dataloader, start=1):
batch_data = batch_data.to(device)
outputs = model(**batch_data)
loss = outputs.loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
与序列标注任务类似,翻译任务的输出同样是一个预测向量序列,因此在使用交叉熵计算模型损失时,要么对维度进行调整,要么通过 view() 将 batch 中的多个向量序列连接为一个序列。因为我们已经将填充 token 对应的标签设为了 -100,所以模型实际上是借助 view() 调整输出张量的尺寸来计算损失的:
loss_fct=CrossEntropyLoss()
loss=loss_fct(lm_logits.view(-1, self.config.decoder_vocab_size), labels.view(-1))
验证/测试循环负责评估模型的性能。对于翻译任务,经典的评估指标是 Kishore Papineni 等人在《BLEU: a Method for Automatic Evaluation of Machine Translation》中提出的 BLEU 值,用于度量两个词语序列之间的一致性,但是其并不会衡量语义连贯性或者语法正确性。
由于计算 BLEU 值需要输入分好词的文本,而不同的分词方式会对结果造成影响,因此现在更常用的评估指标是 SacreBLEU,它对分词的过程进行了标准化。SacreBLEU 直接以未分词的文本作为输入,并且对于同一个输入可以接受多个目标作为参考。虽然我们使用的 translation2019zh 语料对于每一个句子只有一个参考,也需要将其包装为一个句子列表,例如:
from sacrebleu.metrics import BLEU
predictions = [
"This plugin lets you translate web pages between several languages automatically."
]
bad_predictions_1 = ["This This This This"]
bad_predictions_2 = ["This plugin"]
references = [
[
"This plugin allows you to automatically translate web pages between several languages."
]
]
bleu = BLEU()
print(bleu.corpus_score(predictions, references).score)
print(bleu.corpus_score(bad_predictions_1, references).score)
print(bleu.corpus_score(bad_predictions_2, references).score)
46.750469682990165
1.683602693167689
0.0
BLEU 值的范围从 0 到 100,越高越高。可以看到,对于一些槽糕的翻译结果,例如包含大量重复词语或者长度过短的预测结果,会计算出非常低的 BLEU 值。
SacreBLEU 默认会采用 mteval-v13a.pl 分词器对文本进行分词,但是它无法处理中文、日文等非拉丁系语言。对于中文就需要设置参数 tokenize='zh' 手动使用中文分词器,否则会计算出不正确的 BLEU 值:
from sacrebleu.metrics import BLEU
predictions = [
"我在苏州大学学习计算机,苏州大学很美丽。"
]
references = [
[
"我在环境优美的苏州大学学习计算机。"
]
]
bleu = BLEU(tokenize='zh')
print(f'BLEU: {bleu.corpus_score(predictions, references).score}')
bleu = BLEU()
print(f'wrong BLEU: {bleu.corpus_score(predictions, references).score}')
BLEU: 45.340106118883256
wrong BLEU: 0.0
使用 AutoModelForSeq2SeqLM 构造的模型同样对 Decoder 的解码过程进行了封装,我们只需要调用模型的 generate() 函数就可以自动地逐个生成预测 token。例如,我们可以直接调用预训练好的 Marian 模型进行翻译:
import torch
from transformers import AutoTokenizer
from transformers import AutoModelForSeq2SeqLM
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
model_checkpoint = "Helsinki-NLP/opus-mt-zh-en"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
sentence = '我叫张三,我住在苏州。'
sentence_inputs = tokenizer(sentence, return_tensors="pt").to(device)
sentence_generated_tokens = model.generate(
sentence_inputs["input_ids"],
attention_mask=sentence_inputs["attention_mask"],
max_length=128
)
sentence_decoded_pred = tokenizer.decode(sentence_generated_tokens[0], skip_special_tokens=True)
print(sentence_decoded_pred)
Using cpu device
My name is Zhang San, and I live in Suzhou.
在 generate() 生成 token ID 之后,我们通过分词器自带的 tokenizer.batch_decode() 函数将 batch 中所有的 token ID 序列都转换为文本,因此翻译多个句子也没有问题:
sentences = ['我叫张三,我住在苏州。', '我在环境优美的苏州大学学习计算机。']
sentences_inputs = tokenizer(
sentences,
padding=True,
max_length=128,
truncation=True,
return_tensors="pt"
).to(device)
sentences_generated_tokens = model.generate(
sentences_inputs["input_ids"],
attention_mask=sentences_inputs["attention_mask"],
max_length=128
)
sentences_decoded_preds = tokenizer.batch_decode(sentences_generated_tokens, skip_special_tokens=True)
print(sentences_decoded_preds)
[
'My name is Zhang San, and I live in Suzhou.',
"I'm studying computers at Suzhou University in a beautiful environment."
]
在“验证/测试循环”中,我们首先通过 model.generate() 函数获取预测结果,然后将预测结果和正确标签都处理为 SacreBLEU 接受的文本列表形式(这里我们将标签序列中的 -100 替换为 pad token ID 以便于分词器解码),最后送入到 SacreBLEU 中计算 BLEU 值:
from sacrebleu.metrics import BLEU
bleu = BLEU()
def test_loop(dataloader, model):
preds, labels = [], []
model.eval()
for batch_data in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_length,
).cpu().numpy()
label_tokens = batch_data["labels"].cpu().numpy()
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
preds += [pred.strip() for pred in decoded_preds]
labels += [[label.strip()] for label in decoded_labels]
return bleu.corpus_score(preds, labels).score
为了方便后续保存验证集上最好的模型,这里我们还在验证/测试循环中返回模型计算出的 BLEU 值。
保存模型
与之前一样,我们会根据模型在验证集上的性能来调整超参数以及选出最好的模型权重,然后将选出的模型应用于测试集以评估最终的性能。这里我们继续使用 AdamW 优化器,并且通过 get_scheduler() 函数定义学习率调度器:
from transformers import AdamW, get_scheduler
learning_rate = 2e-5
epoch_num = 3
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_bleu = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, t+1, total_loss)
valid_bleu = test_loop(valid_dataloader, model, mode='Valid')
print(f"BLEU: {valid_bleu:>0.2f}\n")
if valid_bleu > best_bleu:
best_bleu = valid_bleu
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_bleu_{valid_bleu:0.2f}_model_weights.bin')
print("Done!")
在开始训练之前,我们先评估一下没有微调的模型在测试集上的性能。这个过程比较耗时,你可以在它执行的时候喝杯咖啡:)
test_data = TRANS('data/translation2019zh/translation2019zh_valid.json')
test_dataloader = DataLoader(test_data, batch_size=32, shuffle=False, collate_fn=collote_fn)
test_loop(test_dataloader, model)
Using cuda device
100%|█████████████████████████| 615/615 [19:08<00:00, 1.87s/it]
Test BLEU: 42.61
可以看到预训练模型在测试集上的 BLEU 值为 42.61,即使不进行微调,也已经具有不错的汉英翻译能力。
下面我们正式开始训练(微调)模型,完整的训练代码如下:
import random
import os
import numpy as np
import torch
from torch.utils.data import Dataset, DataLoader, random_split
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from transformers import AdamW, get_scheduler
from sacrebleu.metrics import BLEU
from tqdm.auto import tqdm
import json
def seed_everything(seed=1029):
random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
seed_everything(42)
max_dataset_size = 220000
train_set_size = 200000
valid_set_size = 20000
max_length = 128
batch_size = 32
learning_rate = 1e-5
epoch_num = 3
class TRANS(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
if idx >= max_dataset_size:
breaksample = json.loads(line.strip())
Data[idx] = sample
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
data = TRANS('data/translation2019zh/translation2019zh_train.json')
train_data, valid_data = random_split(data, [train_set_size, valid_set_size])
test_data = TRANS('data/translation2019zh/translation2019zh_valid.json')
model_checkpoint = "Helsinki-NLP/opus-mt-zh-en"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
def collote_fn(batch_samples):
batch_inputs, batch_targets = [], []
for sample in batch_samples:
batch_inputs.append(sample['chinese'])
batch_targets.append(sample['english'])
batch_data = tokenizer(
batch_inputs,
text_target=batch_targets,
padding=True,
max_length=max_length,
truncation=True,
return_tensors="pt"
)
batch_data['decoder_input_ids'] = model.prepare_decoder_input_ids_from_labels(batch_data['labels'])
end_token_index = torch.where(batch_data['labels'] == tokenizer.eos_token_id)[1]
for idx, end_idx in enumerate(end_token_index):
batch_data['labels'][idx][end_idx+1:] = -100
return batch_data
train_dataloader = DataLoader(train_data, batch_size=batch_size, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
test_dataloader = DataLoader(test_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, batch_data in enumerate(dataloader, start=1):
batch_data = batch_data.to(device)
outputs = model(**batch_data)
loss = outputs.loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
bleu = BLEU()
def test_loop(dataloader, model):
preds, labels = [], []
model.eval()
for batch_data in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_length,
).cpu().numpy()
label_tokens = batch_data["labels"].cpu().numpy()
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
preds += [pred.strip() for pred in decoded_preds]
labels += [[label.strip()] for label in decoded_labels]
return bleu.corpus_score(preds, labels).score
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_bleu = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, t+1, total_loss)
valid_bleu = test_loop(valid_dataloader, model)
print(f"BLEU: {valid_bleu:>0.2f}\n")
if valid_bleu > best_bleu:
best_bleu = valid_bleu
print('saving new weights...\n')
torch.save(
model.state_dict(),
f'epoch_{t+1}_valid_bleu_{valid_bleu:0.2f}_model_weights.bin'
)
print("Done!")
Using cuda device
Epoch 1/3
-------------------------------
loss: 2.570799: 100%|██████████| 6250/6250 [11:19<00:00, 9.20it/s]
100%|██████████| 625/625 [10:51<00:00, 1.04s/it]
BLEU: 53.38
saving new weights...
Epoch 2/3
-------------------------------
loss: 2.498721: 100%|██████████| 6250/6250 [11:21<00:00, 9.17it/s]
100%|██████████| 625/625 [11:08<00:00, 1.07s/it]
BLEU: 53.38
Epoch 3/3
-------------------------------
loss: 2.454356: 100%|██████████| 6250/6250 [11:21<00:00, 9.17it/s]
100%|██████████| 625/625 [10:51<00:00, 1.04s/it]
BLEU: 53.38
Done!
可以看到,随着训练的进行,模型在训练集上的损失一直在下降,但是在验证集上的 BLEU 值却并没有提升,在第一轮后就稳定在 53.38。因此,3 轮训练结束后,目录下只保存了第一轮训练后的模型权重:
epoch_1_valid_bleu_53.38_model_weights.bin
至此,我们对中英翻译 Marian 模型的训练(微调)过程就完成了。
3. 测试模型
训练完成后,我们加载在验证集上性能最优的模型权重,汇报其在测试集上的性能,并且将模型的预测结果保存到文件中。
由于 AutoModelForSeq2SeqLM 对整个解码过程进行了封装,我们只需要调用 generate() 函数就可以自动通过 beam search 找到最佳的 token ID 序列,因此我们只需要再使用分词器将 token ID 序列转换为文本就可以获得翻译结果:
test_data = TRANS('translation2019zh/translation2019zh_valid.json')
test_dataloader = DataLoader(test_data, batch_size=32, shuffle=False, collate_fn=collote_fn)
import json
model.load_state_dict(torch.load('epoch_1_valid_bleu_53.38_model_weights.bin'))
model.eval()
with torch.no_grad():
print('evaluating on test set...')
sources, preds, labels = [], [], []
for batch_data in tqdm(test_dataloader):
batch_data = batch_data.to(device)
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_length,
).cpu().numpy()
label_tokens = batch_data["labels"].cpu().numpy()
decoded_sources = tokenizer.batch_decode(
batch_data["input_ids"].cpu().numpy(),
skip_special_tokens=True,
use_source_tokenizer=True
)
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
sources += [source.strip() for source in decoded_sources]
preds += [pred.strip() for pred in decoded_preds]
labels += [[label.strip()] for label in decoded_labels]
bleu_score = bleu.corpus_score(preds, labels).score
print(f"Test BLEU: {bleu_score:>0.2f}\n")
results = []
print('saving predicted results...')
for source, pred, label in zip(sources, preds, labels):
results.append({
"sentence": source,
"prediction": pred,
"translation": label[0]
})
with open('test_data_pred.json', 'wt', encoding='utf-8') as f:
for exapmle_result in results:
f.write(json.dumps(exapmle_result, ensure_ascii=False) + '\n')
Using cuda device
evaluating on test set...
100%|██████████| 1229/1229 [21:18<00:00, 1.04s/it]
Test BLEU: 54.87
可以看到,经过微调,模型在测试集上的 BLEU 值从 42.61 上升到 54.87,证明了我们对模型的微调是成功的。
我们打开保存预测结果的 test_data_pred.json,其中每一行对应一个样本,sentence 对应原文,prediction 对应模型的翻译结果,translation 对应标注的翻译文本。
{
"sentence": "▁大连是中国最美丽的城市之一。",
"prediction": "Dalian is one of China's most beautiful cities.",
"translation": "E. g. Dalian is one of the most beautiful cities in China."
}
...
至此,我们使用 Transformers 库进行翻译任务就全部完成了!
4. 关于解码
在本文中,我们使用 AutoModelForSeq2SeqLM 模型自带的 generate() 函数,通过柱搜索 (Beam search) 解码出翻译结果(使用模型默认解码参数)。实际上所有 Transformers 库中的生成模型都可以通过 generate() 函数来完成解码,只需要向其传递不同的参数。
下面我们将简单介绍目前常用的几种解码策略。
自回归语言生成
我们先回顾一下自回归 (auto-regressive) 语言生成的过程。自回归语言生成假设每个词语序列的概率都可以分解为一系列条件词语概率的乘积:
P(w1:T∣W0)=∏t=1TP(wt∣w1:t−1,W0),w1:0=∅
这样就可以迭代地基于上下文 W0 以及已经生成的词语序列 w1:t−1 来预测序列中的下一个词 wt,因此被称为自回归 (auto-regressive)。生成序列的长度 T 通常不是预先确定的,而是当生成出休止符(EOS token)时结束迭代。
Transformers 库中所有的生成模型都提供了用于自回归生成的 generate() 函数,例如 GPT2、XLNet、OpenAi-GPT、CTRL、TransfoXL、XLM、Bart、T5 等等。
下面我们将介绍目前常用的四种解码方式:
- 贪心搜索 (Greedy Search)
- 柱搜索 (Beam search)
- Top-K 采样 (Top-K sampling)
- Top-p 采样 (Top-p sampling)。
为了方便,我们将统一使用 GPT-2 模型来进行展示。
from transformers import AutoTokenizer, AutoModelForCausalLM
tokenizer = AutoTokenizer.from_pretrained("gpt2")
# add the EOS token as PAD token to avoid warnings
model = AutoModelForCausalLM.from_pretrained("gpt2", pad_token_id=tokenizer.eos_token_id)
贪心搜索
贪心搜索 (Greedy Search) 在每轮迭代时,即在时间步 t,简单地选择概率最高的下一个词作为当前词,即 wt=argmaxwP(w∣w1:t−1)。下图展示了一个贪心搜索的例子:
可以看到,从起始词语“The”开始,贪心算法不断地选择概率最高的下一个词直至结束,最后生成词语序列 (“The” “nice” “woman”),其整体概率为 0.5×0.4=0.2。
下面我们使用 GPT-2 模型结合贪心算法来为上下文 (“I”, “enjoy”, “walking”, “with”, “my”, “cute”, “dog”) 生成后续序列:
# encode context the generation is conditioned on
input_ids = tokenizer.encode('I enjoy walking with my cute dog', return_tensors='pt')
# generate text until the output length (which includes the context length) reaches 50
greedy_output = model.generate(input_ids, max_length=50)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(greedy_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with my dog. I'm not sure if I'll ever be able to walk with my dog.
I'm not sure if I'll
模型成功地生成了一个短文本,但是它似乎开始不停地重复。这是一个语言生成中常见的问题,特别是在贪心搜索和柱搜索中经常会出现。
贪心搜索最大的问题是由于每次都只选择当前概率最大的词,相当于是区部最优解,因此生成的序列往往并不是全局最优的。例如在上图中,词语序列 (“The”, “dog”, “has”) 的概率是 0.4×0.9=0.36,而这个序列无法通过贪心算法得到。
柱搜索
柱搜索 (Beam search) 在每个时间步都保留 num_beams 个最可能的词,最终选择整体概率最大的序列作为结果。下图展示了一个 num_beams=2 的例子:
可以看到,在第一个时间步,柱搜索同时保留了概率最大的前 2 个序列:概率为 0.4 的 (”The“, ”dog“) 和概率为 0.5 的 (”The“, ”nice“);在第二个时间步,柱搜索通过计算继续保留概率最大的前 2 个序列:概率为 0.4×0.9=0.36 的 (”The“, ”dog“, ”has“) 和概率为 0.5×0.4=0.2 的 (”The“, ”nice“, ”woman“);最终选择概率最大的序列 (”The“, ”dog“, ”has“) 作为结果。
柱搜索虽然通过在每个时间步保留多个分支来缓解贪心算法局部最优解的问题,但是它依然不能保证找到全局最优解。
下面我们同样运用 GPT-2 模型结合柱搜索来生成文本,只需要设置参数 num_beams > 1 以及 early_stopping=True,这样只要所有柱搜索保留的分支都到达休止符 EOS token,生成过程就结束。
# activate beam search and early_stopping
beam_output = model.generate(
input_ids,
max_length=50,
num_beams=5,
early_stopping=True
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I'm not sure if I'll ever be able to walk with him again. I'm not sure if I'll
虽然柱搜索得到的序列更加流畅,但是输出中依然出现了重复片段。最简单的解决方法是引入 n-grams 惩罚,其在每个时间步都手工将那些会产生重复 n-gram 片段的词的概率设为 0。例如,我们额外设置参数 no_repeat_ngram_size=2 就能使生成序列中不会出现重复的 2-gram 片段:
# set no_repeat_ngram_size to 2
beam_output = model.generate(
input_ids,
max_length=50,
num_beams=5,
no_repeat_ngram_size=2,
early_stopping=True
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(beam_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time for me to take a break
不过 n-grams 惩罚虽然能够缓解“重复”问题,却也要谨慎使用。例如对于一篇关于”New York“文章就不能使用 2-gram 惩罚,否则”New York“在全文中就只能出现一次了。
柱搜索会在每个时间步都保留当前概率最高的前 num_beams 个序列,因此我们还可以通过设置参数 num_return_sequences(<= num_beams)来返回概率靠前的多个序列:
# set return_num_sequences > 1
beam_outputs = model.generate(
input_ids,
max_length=50,
num_beams=5,
no_repeat_ngram_size=2,
num_return_sequences=3,
early_stopping=True
)
# now we have 3 output sequences
print("Output:\n" + 100 * '-')
for i, beam_output in enumerate(beam_outputs):
print("{}: {}\n\n".format(i, tokenizer.decode(beam_output, skip_special_tokens=True)))
Output:
----------------------------------------------------------------------------------------------------
0: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time for me to take a break
1: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with him again.
I've been thinking about this for a while now, and I think it's time for me to get back to
2: I enjoy walking with my cute dog, but I'm not sure if I'll ever be able to walk with her again.
I've been thinking about this for a while now, and I think it's time for me to take a break
由于柱大小只被设为 5,因此最终获得的 3 个序列看上去非常接近。
有趣的是,人类语言似乎并不遵循下一个词是最高概率的分布,换句话说,真实的人类语言具有高度的随机性,是不可预测的。下图展示了人类语言与柱搜索在每个时间步词语概率的对比:
因此,柱搜索更适用于机器翻译或摘要等生成序列长度大致可预测的任务,而在对话生成、故事生成等开放式文本生成任务 (open-ended generation) 上表现不佳。虽然通过 n-gram 或者其他惩罚项可以缓解“重复”问题,但是如何控制”不重复”和“重复”之间的平衡又非常困难。
所以,对于开放式文本生成任务,我们需要引入更多的随机性——这就是采样方法。
随机采样
采样 (sampling) 最基本的形式就是从当前上下文的条件概率分布中随机地选择一个词作为下一个词,即:
wt∼P(w∣w1:t−1)
对于前面图中的例子,一个基于采样的生成过程可能为(采样生成的结果不是唯一的):
这里“car”是从条件概率分布 P(w∣“The”) 中采样得到,而“drives”是从分布 P(w∣“The”, “car”) 中采样得到。
在 Transformers 库中,我们只需要在 generate() 中设置 do_sample=True 并且令 top_k=0 禁用 Top-K 采样就可以实现随机采样:
# set seed to reproduce results. Feel free to change the seed though to get different results
torch.manual_seed(0)
# activate sampling and deactivate top_k by setting top_k sampling to 0
sample_output = model.generate(
input_ids,
do_sample=True,
max_length=50,
top_k=0
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog along the seven mile loop along Green Bay's Skagit River. In the central part of Monroe County about 100 miles east of sheboygan, it is almost deserted. But along the way there are often carefully
看上去还不错,但是细读的话会发现不是很连贯,这也是采样生成文本的通病:模型经常会生成前后不连贯的片段。一种解决方式是通过降低 softmax 的温度 (temperature) 使得分布 P(w∣w1:t−1) 更尖锐,即进一步增加高概率词出现的可能性和降低低概率词出现的可能性。例如对上面的例子应用降温:
这样在第一个时间步,条件概率变得更加尖锐,几乎不可能会选择到“car”。我们只需要在 generate() 中设置 temperature 来就可以实现对分布的降温:
# set seed to reproduce results. Feel free to change the seed though to get different results
torch.manual_seed(0)
# use temperature to decrease the sensitivity to low probability candidates
sample_output = model.generate(
input_ids,
do_sample=True,
max_length=50,
top_k=0,
temperature=0.6
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog, but it's pretty much impossible to get the best out of my dog.
Pinky is a bit of a big she-wolf, but she is pretty much the most adorable of all the wolves.
可以看到生成的文本更加连贯了。降温操作实际上是在减少分布的随机性,当我们把 temperature 设为 0 时就等同于贪心解码。
Top-K 采样
类似于柱搜索,Top-K 采样在每个时间步都保留最可能的 K 个词,然后在这 K 个词上重新分配概率质量。例如我们对上面的示例进行 Top-K 采样,这里设置 K=6 在每个时间步都将采样池控制在 6 个词。:
可以看到,6 个最可能的词(记为 Vtop-K)虽然仅包含第一个时间步中整体概率质量的大约 23,但是几乎包含了第二个时间步中所有的概率质量。尽管如此,它还是成功地消除了第二步中那些奇怪的候选词(例如“not”、“the”、“small”、“told”)。
下面我们通过在 generate() 中设置 top_k=10 来进行 Top-K 采样:
# set seed to reproduce results. Feel free to change the seed though to get different results
torch.manual_seed(0)
# set top_k to 10
sample_output = model.generate(
input_ids,
do_sample=True,
max_length=50,
top_k=10
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog, but it's a bit of a pain in the ass to see that the dog does not get to walk with me.
I think my dog is just fine. But he needs some time to get used
Top-K 采样的一个问题是它无法动态调整每个时间步从概率分布 P 中过滤出来的单词数量,这会导致有些词可能是从非常尖锐的分布(上图中右侧)中采样的,而其他单词则可能是从平坦的分布(上图中左侧)中采样的,从而无法保证生成序列整体的质量。
Top-p (nucleus) 采样
Top-p 对 Top-K 进行了改进,每次只从累积概率超过 p 的最小的可能词集中进行选择,然后在这组词语中重新分配概率质量。这样,每个时间步的词语集合的大小就可以根据下一个词的条件概率分布动态增加和减少。下图展示了一个 Top-p 采样的例子:
这里我们设置 p=0.92,Top-p 采样在每个时间步会在整体概率质量超过 92% 的最小单词集合(定义为 Vtop-p)中进行选择。上图左边的例子中,Top-p 采样出了 9 个最可能的词语,而在右边的例子中,只选了 3 个最可能的词,整体概率质量就已经超过了 92%。可以看到,当下一个词难以预测时(例如 P(w∣“The”)),Top-p 采样会保留很多可能的词,而当下一个词相对容易预测时(例如 P(w∣“The”, “car”)),Top-p 就只会保留很少的词。
我们只需要在 generate() 中设置 0 < top_p < 1 就可以激活 Top-p 采样了:
# set seed to reproduce results. Feel free to change the seed though to get different results
torch.manual_seed(0)
# deactivate top_k sampling and sample only from 92% most likely words
sample_output = model.generate(
input_ids,
do_sample=True,
max_length=50,
top_p=0.92,
top_k=0
)
print("Output:\n" + 100 * '-')
print(tokenizer.decode(sample_output[0], skip_special_tokens=True))
Output:
----------------------------------------------------------------------------------------------------
I enjoy walking with my cute dog along the Tokyo highway," said Beranito, 47, the man who moved to the new apartment in 2013 with his wife. "I liked to sit next to him on the roof when I was doing programming.
虽然理论上 Top-p 采样比 Top-K 采样更灵活,但是两者在实际应用中都被广泛采用,Top-p 甚至可以与 Top-K 共同工作,这可以在排除低概率词的同时还允许进行一些动态选择。
最后,与贪心搜索类似,为了获得多个独立采样的结果,我们设置 num_return_sequences > 1,并且同时结合 Top-p 和 Top-K 采样:
# set seed to reproduce results. Feel free to change the seed though to get different results
torch.manual_seed(0)
# set top_k = 50 and set top_p = 0.95 and num_return_sequences = 3
sample_outputs = model.generate(
input_ids,
do_sample=True,
max_length=50,
top_k=50,
top_p=0.95,
num_return_sequences=3
)
print("Output:\n" + 100 * '-')
for i, sample_output in enumerate(sample_outputs):
print("{}: {}\n\n".format(i, tokenizer.decode(sample_output, skip_special_tokens=True)))
Output:
----------------------------------------------------------------------------------------------------
0: I enjoy walking with my cute dog, and she is the perfect animal companion to me. She helps me get on my feet as often as possible. I love sharing my love and care with all of my dogs. I have the highest respect for them
1: I enjoy walking with my cute dog when he is around my neck," she said. "I'm just doing it. It's not something that's easy for me to do when I'm the leader."
The family is just beginning to see
2: I enjoy walking with my cute dog, and we are both very pleased by his behavior. He seems to be extremely curious about the world around him, and just as he is searching for his place of origin he is able to spot and find it easily
代码
与之前一样,我们按照功能将翻译模型的代码拆分成模块并且存放在不同的文件中,整理后的代码存储在 Github:
How-to-use-Transformers/src/seq2seq_translation/
运行 run_translation_marian.sh 脚本即可进行训练。如果要进行测试或者将模型输出的翻译结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 Marian 模型在测试集上的 BLEU 值为 52.96%(Nvidia Tesla V100, batch=32)。
第十一章:文本摘要任务
本文我们将运用 Transformers 库来完成文本摘要任务。与我们上一章进行的翻译任务一样,文本摘要同样是一个 Seq2Seq 任务,旨在尽可能保留文本语义的情况下将长文本压缩为短文本。
虽然 Hugging Face 已经提供了很多文本摘要模型,但是它们大部分只能处理英文,因此本文将微调一个多语言文本摘要模型用于完成中文摘要:为新浪微博短新闻生成摘要。
文本摘要可以看作是将长文本“翻译”为捕获关键信息的短文本,因此大部分文本摘要模型同样采用 Encoder-Decoder 框架。当然,也有一些非 Encoder-Decoder 框架的摘要模型,例如 GPT 家族也可以通过小样本学习 (few-shot) 进行文本摘要。
下面是一些目前流行的可用于文本摘要的模型:
- GPT-2:虽然是自回归 (auto-regressive) 语言模型,但是可以通过在输入文本的末尾添加
TL;DR来使 GPT-2 生成摘要; - PEGASUS:与大部分语言模型通过预测被遮掩掉的词语来进行训练不同,PEGASUS 通过预测被遮掩掉的句子来进行训练。由于预训练目标与摘要任务接近,因此 PEGASUS 在摘要任务上的表现很好;
- T5:将各种 NLP 任务都转换到 text-to-text 框架来完成的通用 Transformer 架构,要进行摘要任务只需在输入文本前添加
summarize:前缀; - mT5:T5 的多语言版本,在多语言通用爬虫语料库 mC4 上预训练,覆盖 101 种语言;
- BART:包含一个 Encoder 和一个 Decoder stack 的 Transformer 架构,训练目标是重构损坏的输入,同时还结合了 BERT 和 GPT-2 的预训练方案;
- mBART-50:BART 的多语言版本,在 50 种语言上进行了预训练。
T5 模型通过模板前缀 (prompt prefix) 将各种 NLP 任务都转换到 text-to-text 框架进行预训练,例如摘要任务的前缀就是 summarize:,模型以前缀作为条件生成符合模板的文本,这使得一个模型就可以完成多种 NLP 任务:
在本文中,我们将专注于微调多语言 mT5 模型用于中文摘要任务,mT5 模型不使用前缀,但是具备 T5 模型大部分的多功能性。
1. 准备数据
我们选择大规模中文短文本摘要语料库 LCSTS 作为数据集,该语料基于新浪微博短新闻构建,规模超过 200 万。这里我们直接从和鲸社区或百度云盘下载用户处理好的 LCSTS 语料。
我们简单地将新闻的标题作为摘要来微调 mT5 模型以完成文本摘要任务。
该语料已经划分好了训练集、验证集和测试集,分别包含 2400591 / 10666 / 1106 个样本,一行是一个“标题!=!正文”的组合:
媒体融合关键是以人为本!=!受众在哪里,媒体就应该在哪里,媒体的体制、内容、技术就应该向哪里转变。媒体融合关键是以人为本,即满足大众的信息需求,为受众提供更优质的服务。这就要求媒体在融合发展的过程中,既注重技术创新,又注重用户体验。
构建数据集
与之前一样,我们首先编写继承自 Dataset 类的自定义数据集用于组织样本和标签。考虑到使用 LCSTS 两百多万条样本进行训练耗时过长,这里我们只抽取训练集中的前 20 万条数据:
from torch.utils.data import Dataset
max_dataset_size = 200000
class LCSTS(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
if idx >= max_dataset_size:
breakitems = line.strip().split('!=!')
assert len(items) == 2
Data[idx] = {
'title': items[0],
'content': items[1]
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = LCSTS('data/lcsts_tsv/data1.tsv')
valid_data = LCSTS('data/lcsts_tsv/data2.tsv')
test_data = LCSTS('data/lcsts_tsv/data3.tsv')
下面我们输出数据集的尺寸,并且打印出一个训练样本:
print(f'train set size: {len(train_data)}')
print(f'valid set size: {len(valid_data)}')
print(f'test set size: {len(test_data)}')
print(next(iter(train_data)))
train set size: 200000
valid set size: 10666
test set size: 1106
{'title': '修改后的立法法全文公布', 'content': '新华社受权于18日全文播发修改后的《中华人民共和国立法法》,修改后的立法法分为“总则”“法律”“行政法规”“地方性法规、自治条例和单行条例、规章”“适用与备案审查”“附则”等6章,共计105条。'}
数据预处理
接下来,我们就需要通过 DataLoader 库按 batch 加载数据,将文本转换为模型可以接受的 token IDs。与翻译任务类似,我们需要运用分词器对原文和摘要都进行编码,这里我们选择 BUET CSE NLP Group 提供的 mT5 摘要模型:
from transformers import AutoTokenizer
model_checkpoint = "csebuetnlp/mT5_multilingual_XLSum"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
我们先尝试使用 mT5 tokenizer 对文本进行分词:
inputs = tokenizer("我叫张三,在苏州大学学习计算机。")
print(inputs)
print(tokenizer.convert_ids_to_tokens(inputs.input_ids))
{'input_ids': [259, 3003, 27333, 8922, 2092, 261, 1083, 117707, 9792, 24920, 123553, 306, 1], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}
['▁', '我', '叫', '张', '三', ',', '在', '苏州', '大学', '学习', '计算机', '。', '</s>']
特殊的 Unicode 字符 ▁ 以及序列结束 token </s> 表明 mT5 模型采用的是基于 Unigram 切分算法的 SentencePiece 分词器。Unigram 对于处理多语言语料库特别有用,它使得 SentencePiece 可以在不知道重音、标点符号以及没有空格分隔字符(例如中文)的情况下对文本进行分词。
与翻译任务类似,摘要任务的输入和标签都是文本,这里我们同样使用分词器提供的 as_target_tokenizer() 函数来并行地对输入和标签进行分词,并且同样将标签序列中填充的 pad 字符设置为 -100 以便在计算交叉熵损失时忽略它们,以及通过模型自带的 prepare_decoder_input_ids_from_labels 函数对标签进行移位操作以准备好 decoder input IDs:
import torch
from torch.utils.data import DataLoader
from transformers import AutoModelForSeq2SeqLM
max_input_length = 512
max_target_length = 64
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
def collote_fn(batch_samples):
batch_inputs, batch_targets = [], []
for sample in batch_samples:
batch_inputs.append(sample['content'])
batch_targets.append(sample['title'])
batch_data = tokenizer(
batch_inputs,
padding=True,
max_length=max_input_length,
truncation=True,
return_tensors="pt"
)
with tokenizer.as_target_tokenizer():
labels = tokenizer(
batch_targets,
padding=True,
max_length=max_target_length,
truncation=True,
return_tensors="pt"
)["input_ids"]
batch_data['decoder_input_ids'] = model.prepare_decoder_input_ids_from_labels(labels)
end_token_index = torch.where(labels == tokenizer.eos_token_id)[1]
for idx, end_idx in enumerate(end_token_index):
labels[idx][end_idx+1:] = -100
batch_data['labels'] = labels
return batch_data
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
由于本文直接使用 Transformers 库自带的 AutoModelForSeq2SeqLM 函数来构建模型,因此我们将每一个 batch 中的数据都处理为该模型可接受的格式:一个包含 'attention_mask'、'input_ids'、'labels' 和 'decoder_input_ids' 键的字典。
下面我们尝试打印出一个 batch 的数据,以验证是否处理正确:
batch = next(iter(train_dataloader))
print(batch.keys())
print('batch shape:', {k: v.shape for k, v in batch.items()})
print(batch)
dict_keys(['input_ids', 'attention_mask', 'decoder_input_ids', 'labels'])
batch shape: {
'input_ids': torch.Size([4, 78]),
'attention_mask': torch.Size([4, 78]),
'decoder_input_ids': torch.Size([4, 23]),
'labels': torch.Size([4, 23])
}
{'input_ids': tensor([
[ 259, 46420, 1083, 73451, 493, 3582, 14219, 98660, 111234,
9455, 10139, 261, 11688, 56462, 7031, 71079, 31324, 94274,
2037, 203743, 9911, 16834, 1107, 6929, 31063, 306, 2372,
891, 261, 221805, 1455, 31571, 118447, 493, 56462, 7031,
71079, 124732, 3937, 23224, 2037, 203743, 9911, 199662, 22064,
31063, 261, 7609, 5705, 18988, 160700, 154547, 43803, 40678,
3519, 306, 1, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0],
[ 259, 101737, 36059, 261, 157186, 47685, 8854, 124583, 218664,
5705, 8363, 7216, 30921, 27032, 59754, 127646, 62558, 98901,
261, 8868, 4110, 5705, 73334, 25265, 26553, 4153, 261,
7274, 58402, 5435, 12914, 591, 2991, 162028, 22151, 4925,
157186, 34499, 101737, 36059, 14520, 11201, 89746, 11017, 261,
3763, 8868, 157186, 47685, 8854, 150259, 90707, 4417, 35388,
3751, 2037, 3763, 194391, 81024, 261, 124025, 239583, 72939,
306, 4925, 28216, 11242, 51563, 3094, 261, 157186, 142783,
8868, 51191, 43239, 3763, 306, 1],
[ 259, 13732, 5705, 165437, 36814, 29650, 261, 120834, 201540,
64493, 36814, 69169, 306, 13381, 5859, 14456, 21562, 16408,
201540, 9692, 1374, 116772, 35988, 2188, 36079, 214133, 261,
13505, 9127, 2542, 161781, 101017, 261, 101737, 36059, 7321,
14219, 7519, 21929, 460, 100987, 261, 9903, 5848, 72308,
101017, 261, 2123, 19394, 164872, 5162, 125883, 21562, 43138,
37575, 15937, 66211, 5162, 3377, 848, 27349, 2446, 198562,
154832, 261, 11883, 65386, 353, 106219, 261, 27674, 939,
76364, 5507, 31568, 9809, 54172, 1],
[ 259, 77554, 1193, 74380, 493, 590, 487, 538, 495,
198437, 8041, 6907, 219169, 122000, 10220, 28426, 6994, 36236,
74380, 30733, 306, 40921, 218505, 1083, 5685, 14469, 2884,
1637, 198437, 17723, 94708, 22695, 306, 12267, 1374, 13733,
1543, 224495, 164497, 17286, 143553, 30464, 198437, 17723, 113940,
176540, 143553, 306, 36017, 1374, 13733, 13342, 88397, 94708,
22695, 261, 1083, 5685, 14469, 10458, 9692, 4070, 13342,
115813, 27385, 306, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'attention_mask': tensor([
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1],
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0]]),
'decoder_input_ids': tensor([
[ 0, 259, 11688, 56462, 7031, 71079, 73451, 3592, 3751,
9911, 17938, 16834, 1107, 6929, 31063, 63095, 291, 1,
0, 0, 0, 0, 0],
[ 0, 259, 157186, 47685, 8854, 107850, 14520, 11201, 89746,
11017, 10973, 2219, 239583, 72939, 108358, 267, 1597, 43239,
11242, 51563, 3094, 1, 0],
[ 0, 259, 13732, 2123, 19394, 94689, 2029, 26544, 17684,
4074, 33119, 62428, 76364, 1, 0, 0, 0, 0,
0, 0, 0, 0, 0],
[ 0, 447, 487, 538, 495, 198437, 8041, 6907, 86248,
74380, 100644, 12267, 338, 225859, 261, 40921, 353, 3094,
53737, 1083, 16311, 58407, 23616]]),
'labels': tensor([
[ 259, 11688, 56462, 7031, 71079, 73451, 3592, 3751, 9911,
17938, 16834, 1107, 6929, 31063, 63095, 291, 1, -100,
-100, -100, -100, -100, -100],
[ 259, 157186, 47685, 8854, 107850, 14520, 11201, 89746, 11017,
10973, 2219, 239583, 72939, 108358, 267, 1597, 43239, 11242,
51563, 3094, 1, -100, -100],
[ 259, 13732, 2123, 19394, 94689, 2029, 26544, 17684, 4074,
33119, 62428, 76364, 1, -100, -100, -100, -100, -100,
-100, -100, -100, -100, -100],
[ 447, 487, 538, 495, 198437, 8041, 6907, 86248, 74380,
100644, 12267, 338, 225859, 261, 40921, 353, 3094, 53737,
1083, 16311, 58407, 23616, 1]])}
可以看到,DataLoader 按照我们设置的 batch_size=4 对样本进行编码,并且填充 pad token 对应的标签都被设置为 -100。我们构建的 Decoder 的输入 decoder input IDs 尺寸与标签序列完全相同,且通过向后移位在序列头部添加了特殊的“序列起始符”,例如第一个样本:
'labels':
[ 259, 11688, 56462, 7031, 71079, 73451, 3592, 3751, 9911,
17938, 16834, 1107, 6929, 31063, 63095, 291, 1, -100,
-100, -100, -100, -100, -100]
'decoder_input_ids':
[ 0, 259, 11688, 56462, 7031, 71079, 73451, 3592, 3751,
9911, 17938, 16834, 1107, 6929, 31063, 63095, 291, 1,
0, 0, 0, 0, 0]
至此,数据预处理部分就全部完成了!
在大部分情况下,即使我们在 batch 数据中没有包含 decoder input IDs,模型也能正常训练,它会自动调用模型的 prepare_decoder_input_ids_from_labels 函数来构造 decoder_input_ids。
2. 训练模型
本文直接使用 Transformers 库自带的 AutoModelForSeq2SeqLM 函数来构建模型,因此下面只需要实现 Epoch 中的”训练循环”和”验证/测试循环”。
这里我们同样没有自己编写模型,因为 Seq2Seq 模型的结构都较为复杂(包含编码解码以及彼此交互的各种操作),如果自己实现需要编写大量的辅助函数。
优化模型参数
使用 AutoModelForSeq2SeqLM 构造的模型已经封装好了对应的损失函数,并且计算出的损失会直接包含在模型的输出 outputs 中,可以直接通过 outputs.loss 获得,因此训练循环为:
from tqdm.auto import tqdm
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, batch_data in enumerate(dataloader, start=1):
batch_data = batch_data.to(device)
outputs = model(**batch_data)
loss = outputs.loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
验证/测试循环负责评估模型的性能。对于文本摘要任务,常用评估指标是 ROUGE 值 (short for Recall-Oriented Understudy for Gisting Evaluation),它可以度量两个词语序列之间的词语重合率。ROUGE 值的召回率表示参考摘要在多大程度上被生成摘要覆盖,如果我们只比较词语,那么召回率就是:
Recall=Number of overlapping wordsTotal number of words in reference summary
准确率则表示生成的摘要中有多少词语与参考摘要相关:
Precision=Number of overlapping wordsTotal number of words in generated summary
最后再基于准确率和召回率来计算 F1 值。实际操作中,我们可以通过 rouge 库来方便地计算这些 ROUGE 值,例如 ROUGE-1 度量 uni-grams 的重合情况,ROUGE-2 度量 bi-grams 的重合情况,而 ROUGE-L 则通过在生成摘要和参考摘要中寻找最长公共子串来度量最长的单词匹配序列,例如:
from rouge import Rouge
generated_summary = "I absolutely loved reading the Hunger Games"
reference_summary = "I loved reading the Hunger Games"
rouge = Rouge()
scores = rouge.get_scores(
hyps=[generated_summary], refs=[reference_summary]
)[0]
print(scores)
{
'rouge-1': {'r': 1.0, 'p': 0.8571428571428571, 'f': 0.9230769181065088},
'rouge-2': {'r': 0.8, 'p': 0.6666666666666666, 'f': 0.7272727223140496},
'rouge-l': {'r': 1.0, 'p': 0.8571428571428571, 'f': 0.9230769181065088}
}
rouge 库默认使用空格进行分词,因此无法处理中文、日文等语言,最简单的办法是按字进行切分,当然也可以使用分词器分词后再进行计算,否则会计算出不正确的 ROUGE 值:
from rouge import Rouge
generated_summary = "我在苏州大学学习计算机,苏州大学很美丽。"
reference_summary = "我在环境优美的苏州大学学习计算机。"
rouge = Rouge()
TOKENIZE_CHINESE = lambda x: ' '.join(x)
# from transformers import AutoTokenizer
# model_checkpoint = "csebuetnlp/mT5_multilingual_XLSum"
# tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
# TOKENIZE_CHINESE = lambda x: ' '.join(
# tokenizer.convert_ids_to_tokens(tokenizer(x).input_ids, skip_special_tokens=True)
# )
scores = rouge.get_scores(
hyps=[TOKENIZE_CHINESE(generated_summary)],
refs=[TOKENIZE_CHINESE(reference_summary)]
)[0]
print('ROUGE:', scores)
scores = rouge.get_scores(
hyps=[generated_summary],
refs=[reference_summary]
)[0]
print('wrong ROUGE:', scores)
ROUGE: {
'rouge-1': {'r': 0.75, 'p': 0.8, 'f': 0.7741935433922998},
'rouge-2': {'r': 0.5625, 'p': 0.5625, 'f': 0.562499995},
'rouge-l': {'r': 0.6875, 'p': 0.7333333333333333, 'f': 0.7096774143600416}
}
wrong ROUGE: {
'rouge-1': {'r': 0.0, 'p': 0.0, 'f': 0.0},
'rouge-2': {'r': 0.0, 'p': 0.0, 'f': 0.0},
'rouge-l': {'r': 0.0, 'p': 0.0, 'f': 0.0}
}
在上一章中我们说过,AutoModelForSeq2SeqLM 模型对 Decoder 的解码过程也进行了封装,我们只需要调用模型的 generate() 函数就可以自动地逐个生成预测 token。例如,我们可以直接调用预训练好的 mT5 摘要模型生成摘要(使用柱搜索解码,num_beams=4,并且不允许出现 2-gram 重复):
import torch
from transformers import AutoTokenizer
from transformers import AutoModelForSeq2SeqLM
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
model_checkpoint = "csebuetnlp/mT5_multilingual_XLSum"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
article_text = """
受众在哪里,媒体就应该在哪里,媒体的体制、内容、技术就应该向哪里转变。
媒体融合关键是以人为本,即满足大众的信息需求,为受众提供更优质的服务。
这就要求媒体在融合发展的过程中,既注重技术创新,又注重用户体验。
"""
input_ids = tokenizer(
article_text,
return_tensors="pt",
truncation=True,
max_length=512
)
generated_tokens = model.generate(
input_ids["input_ids"],
attention_mask=input_ids["attention_mask"],
max_length=32,
no_repeat_ngram_size=2,
num_beams=4
)
summary = tokenizer.decode(
generated_tokens[0],
skip_special_tokens=True,
clean_up_tokenization_spaces=False
)
print(summary)
Using cpu device
媒体融合发展是当下中国面临的一大难题。
当然了,摘要多个句子也没有问题:
article_texts = [
"""
受众在哪里,媒体就应该在哪里,媒体的体制、内容、技术就应该向哪里转变。
媒体融合关键是以人为本,即满足大众的信息需求,为受众提供更优质的服务。
这就要求媒体在融合发展的过程中,既注重技术创新,又注重用户体验。
""",
"""
新华社受权于18日全文播发修改后的《中华人民共和国立法法》,
修改后的立法法分为“总则”“法律”“行政法规”“地方性法规、
自治条例和单行条例、规章”“适用与备案审查”“附则”等6章,共计105条。
"""
]
input_ids = tokenizer(
article_texts,
padding=True,
return_tensors="pt",
truncation=True,
max_length=512
)
generated_tokens = model.generate(
input_ids["input_ids"],
attention_mask=input_ids["attention_mask"],
max_length=32,
no_repeat_ngram_size=2,
num_beams=4
)
summarys = tokenizer.batch_decode(
generated_tokens,
skip_special_tokens=True,
clean_up_tokenization_spaces=False
)
print(summarys)
[
'媒体融合发展是当下中国面临的一大难题。',
'中国官方新华社周一(18日)全文播发修改后的《中华人民共和国立法法》。'
]
在验证/测试循环中,我们首先通过 model.generate() 函数获取预测结果,然后将预测结果和正确标签都处理为 rouge 库接受的文本列表格式(这里我们将标签序列中的 -100 替换为 pad token ID 以便于分词器解码),最后送入到 rouge 库计算各项 ROUGE 值:
import numpy as np
from rouge import Rouge
rouge = Rouge()
def test_loop(dataloader, model):
preds, labels = [], []
model.eval()
for batch_data in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_target_length,
num_beams=4,
no_repeat_ngram_size=2,
).cpu().numpy()
if isinstance(generated_tokens, tuple):
generated_tokens = generated_tokens[0]
label_tokens = batch_data["labels"].cpu().numpy()
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
preds += [' '.join(pred.strip()) for pred in decoded_preds]
labels += [' '.join(label.strip()) for label in decoded_labels]
scores = rouge.get_scores(hyps=preds, refs=labels, avg=True)
result = {key: value['f'] * 100 for key, value in scores.items()}
result['avg'] = np.mean(list(result.values()))
print(f"Rouge1: {result['rouge-1']:>0.2f} Rouge2: {result['rouge-2']:>0.2f} RougeL: {result['rouge-l']:>0.2f}\n")
return result
为了方便后续保存验证集上最好的模型,我们还在验证/测试循环中返回评估出的 ROUGE 值。
保存模型
与之前一样,我们会根据模型在验证集上的性能来调整超参数以及选出最好的模型,然后将选出的模型应用于测试集以评估最终的性能。这里我们继续使用 AdamW 优化器,并且通过 get_scheduler() 函数定义学习率调度器:
from transformers import AdamW, get_scheduler
learning_rate = 2e-5
epoch_num = 10
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_avg_rouge = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, t+1, total_loss)
valid_rouge = test_loop(valid_dataloader, model)
print(valid_rouge)
rouge_avg = valid_rouge['avg']
if rouge_avg > best_avg_rouge:
best_avg_rouge = rouge_avg
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_rouge_{rouge_avg:0.4f}_model_weights.bin')
print("Done!")
在开始训练之前,我们先评估一下没有经过微调的模型在 LCSTS 测试集上的性能。
test_data = LCSTS('lcsts_tsv/data3.tsv')
test_dataloader = DataLoader(test_data, batch_size=32, shuffle=False, collate_fn=collote_fn)
test_loop(test_dataloader, model)
Using cuda device
100%|███████████| 35/35 [01:07<00:00, 1.92s/it]
Rouge1: 23.71 Rouge2: 12.20 RougeL: 20.78
可以看到预训练模型在我们测试集上的 ROUGE-1、ROUGE-2、ROUGE-L 值分别为 23.71、12.2 和 20.78,说明该模型具备文本摘要的能力,但是在“短文本新闻摘要”任务上表现不佳。然后,我们正式开始训练,完整代码如下:
import torch
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from transformers import AdamW, get_scheduler
from tqdm.auto import tqdm
from rouge import Rouge
import random
import numpy as np
import os
max_dataset_size = 200000
max_input_length = 512
max_target_length = 32
train_batch_size = 8
test_batch_size = 8
learning_rate = 2e-5
epoch_num = 3
beam_size = 4
no_repeat_ngram_size = 2
seed = 5
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
random.seed(seed)
np.random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class LCSTS(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
if idx >= max_dataset_size:
breakitems = line.strip().split('!=!')
assert len(items) == 2
Data[idx] = {
'title': items[0],
'content': items[1]
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = LCSTS('lcsts_tsv/data1.tsv')
valid_data = LCSTS('lcsts_tsv/data2.tsv')
test_data = LCSTS('lcsts_tsv/data3.tsv')
model_checkpoint = "csebuetnlp/mT5_multilingual_XLSum"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
model = AutoModelForSeq2SeqLM.from_pretrained(model_checkpoint)
model = model.to(device)
def collote_fn(batch_samples):
batch_inputs, batch_targets = [], []
for sample in batch_samples:
batch_inputs.append(sample['content'])
batch_targets.append(sample['title'])
batch_data = tokenizer(
batch_inputs,
padding=True,
max_length=max_input_length,
truncation=True,
return_tensors="pt"
)
with tokenizer.as_target_tokenizer():
labels = tokenizer(
batch_targets,
padding=True,
max_length=max_target_length,
truncation=True,
return_tensors="pt"
)["input_ids"]
batch_data['decoder_input_ids'] = model.prepare_decoder_input_ids_from_labels(labels)
end_token_index = torch.where(labels == tokenizer.eos_token_id)[1]
for idx, end_idx in enumerate(end_token_index):
labels[idx][end_idx+1:] = -100
batch_data['labels'] = labels
return batch_data
train_dataloader = DataLoader(train_data, batch_size=train_batch_size, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=test_batch_size, shuffle=False, collate_fn=collote_fn)
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, batch_data in enumerate(dataloader, start=1):
batch_data = batch_data.to(device)
outputs = model(**batch_data)
loss = outputs.loss
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
rouge = Rouge()
def test_loop(dataloader, model, mode='Test'):
assert mode in ['Valid', 'Test']
preds, labels = [], []
model.eval()
for batch_data in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_target_length,
num_beams=beam_size,
no_repeat_ngram_size=no_repeat_ngram_size,
).cpu().numpy()
if isinstance(generated_tokens, tuple):
generated_tokens = generated_tokens[0]
label_tokens = batch_data["labels"].cpu().numpy()
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
preds += [' '.join(pred.strip()) for pred in decoded_preds]
labels += [' '.join(label.strip()) for label in decoded_labels]
scores = rouge.get_scores(hyps=preds, refs=labels, avg=True)
result = {key: value['f'] * 100 for key, value in scores.items()}
result['avg'] = np.mean(list(result.values()))
print(f"{mode} Rouge1: {result['rouge-1']:>0.2f} Rouge2: {result['rouge-2']:>0.2f} RougeL: {result['rouge-l']:>0.2f}\n")
return result
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_avg_rouge = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, t+1, total_loss)
valid_rouge = test_loop(valid_dataloader, model, mode='Valid')
rouge_avg = valid_rouge['avg']
if rouge_avg > best_avg_rouge:
best_avg_rouge = rouge_avg
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_rouge_{rouge_avg:0.4f}_model_weights.bin')
print("Done!")
Epoch 1/3
-------------------------------
loss: 3.544795: 100%|██████████| 6250/6250 [41:40<00:00, 2.50it/s]
100%|██████████████████████████| 334/334 [06:18<00:00, 1.13s/it]
Rouge1: 33.47 Rouge2: 20.87 RougeL: 30.50
saving new weights...
Epoch 2/3
-------------------------------
loss: 3.448048: 100%|██████████| 6250/6250 [41:38<00:00, 2.50it/s]
100%|██████████████████████████| 334/334 [06:13<00:00, 1.12s/it]
Rouge1: 33.87 Rouge2: 21.18 RougeL: 30.85
saving new weights...
Epoch 3/3
-------------------------------
loss: 3.398337: 100%|██████████| 6250/6250 [41:40<00:00, 2.50it/s]
100%|██████████████████████████| 334/334 [06:11<00:00, 1.11s/it]
Rouge1: 33.95 Rouge2: 21.24 RougeL: 30.93
saving new weights...
Done!
可以看到,随着训练的进行,模型在验证集上 ROUGE 值稳步提升。因此 3 轮 Epoch 结束后,会在目录下保存 3 个模型权重:
epoch_1_valid_rouge_28.2808_model_weights.bin
epoch_2_valid_rouge_28.6322_model_weights.bin
epoch_3_valid_rouge_28.7044_model_weights.bin
至此,我们对 mT5 摘要模型的训练(微调)过程就完成了。
3. 测试模型
训练完成后,我们加载在验证集上性能最优的模型权重,汇报其在测试集上的性能,并且将模型的预测结果保存到文件中。
由于 AutoModelForSeq2SeqLM 对整个解码过程进行了封装,我们只需要调用 generate() 函数就可以自动通过 beam search 找到最佳的 token ID 序列,因此最后只需要再使用分词器将 token ID 序列转换为文本就可以获得生成的摘要:
test_data = LCSTS('data/lcsts_tsv/data3.tsv')
test_dataloader = DataLoader(test_data, batch_size=32, shuffle=False, collate_fn=collote_fn)
import json
model.load_state_dict(torch.load('epoch_1_valid_rouge_6.6667_model_weights.bin'))
model.eval()
with torch.no_grad():
print('evaluating on test set...')
sources, preds, labels = [], [], []
for batch_data in tqdm(test_dataloader):
batch_data = batch_data.to(device)
generated_tokens = model.generate(
batch_data["input_ids"],
attention_mask=batch_data["attention_mask"],
max_length=max_target_length,
num_beams=beam_size,
no_repeat_ngram_size=no_repeat_ngram_size,
).cpu().numpy()
if isinstance(generated_tokens, tuple):
generated_tokens = generated_tokens[0]
label_tokens = batch_data["labels"].cpu().numpy()
decoded_sources = tokenizer.batch_decode(
batch_data["input_ids"].cpu().numpy(),
skip_special_tokens=True,
use_source_tokenizer=True
)
decoded_preds = tokenizer.batch_decode(generated_tokens, skip_special_tokens=True)
label_tokens = np.where(label_tokens != -100, label_tokens, tokenizer.pad_token_id)
decoded_labels = tokenizer.batch_decode(label_tokens, skip_special_tokens=True)
sources += [source.strip() for source in decoded_sources]
preds += [pred.strip() for pred in decoded_preds]
labels += [label.strip() for label in decoded_labels]
scores = rouge.get_scores(
hyps=[' '.join(pred) for pred in preds],
refs=[' '.join(label) for label in labels],
avg=True
)
rouges = {key: value['f'] * 100 for key, value in scores.items()}
rouges['avg'] = np.mean(list(rouges.values()))
print(f"Test Rouge1: {rouges['rouge-1']:>0.2f} Rouge2: {rouges['rouge-2']:>0.2f} RougeL: {rouges['rouge-l']:>0.2f}\n")
results = []
print('saving predicted results...')
for source, pred, label in zip(sources, preds, labels):
results.append({
"document": source,
"prediction": pred,
"summarization": label
})
with open('test_data_pred.json', 'wt', encoding='utf-8') as f:
for exapmle_result in results:
f.write(json.dumps(exapmle_result, ensure_ascii=False) + '\n')
Using cuda device
evaluating on test set...
100%|██████████████████████████| 35/35 [00:42<00:00, 1.22s/it]
Test Rouge1: 33.71 Rouge2: 20.30 RougeL: 30.42
saving predicted results...
可以看到,经过我们的微调,模型在测试集上的 ROUGE-1、ROUGE-2 和 ROUGE-L 值分别从 23.71、12.2、20.78 提升到了 33.71、20.30、30.42,证明了我们对模型的微调是成功的。
我们打开保存预测结果的 test_data_pred.json,其中每一行对应一个样本,document 对应原文,prediction 对应模型生成的摘要,summarization 对应参考摘要。
{
"document": "本文总结了十个可穿戴产品的设计原则,而这些原则,同样也是笔者认为是这个行业最吸引人的地方:1.为人们解决重复性问题;2.从人开始,而不是从机器开始;3.要引起注意,但不要刻意;4.提升用户能力,而不是取代人",
"prediction": "可穿戴产品设计原则",
"summarization": "可穿戴技术十大设计原则"
}
...
至此,我们使用 Transformers 库进行文本摘要任务就全部完成了!
代码
与之前一样,我们按照功能将文本摘要模型的代码拆分成模块并且存放在不同的文件中,整理后的代码存储在 Github: How-to-use-Transformers/src/seq2seq_summarization/
运行 run_summarization_mt5.sh 脚本即可进行训练。如果要进行测试或者将模型输出的翻译结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 mT5 模型在测试集上的 ROUGE-1、ROUGE-2 和 ROUGE-L 值分别为 33.55、20.23 和 30.37(Nvidia Tesla V100, batch=32)。
第十二章:抽取式问答
本文我们将运用 Transformers 库来完成抽取式问答任务。自动问答 (Question Answering, QA) 是经典的 NLP 任务,需要模型基于给定的上下文回答问题。
根据回答方式的不同可以分为:
- 抽取式 (extractive) 问答:从上下文中截取片段作为回答,类似于我们前面介绍的序列标注任务;
- 生成式 (generative) 问答:生成一个文本片段作为回答,类似于我们前面介绍的翻译和摘要任务。
抽取式问答模型通常采用纯 Encoder 框架(例如 BERT),它更适用于处理事实性问题,例如“谁发明了 Transformer 架构?”,这些问题的答案通常就包含在上下文中;而生成式问答模型则通常采用 Encoder-Decoder 框架(例如 T5、BART),它更适用于处理开放式问题,例如“天空为什么是蓝色的?”,这些问题的答案通常需要结合上下文语义再进行抽象表达。
本文我们将微调一个 BERT 模型来完成抽取式问答任务:对于给定的问题,从上下文中抽取出概率最大的文本片段作为答案。
如果你对生成式问答感兴趣,可以参考 Hugging Face 提供的基于 ELI5 数据库的 Demo。
1. 准备数据
我们选择由哈工大讯飞联合实验室构建的中文阅读理解语料库 CMRC 2018 作为数据集,该语料是一个类似于 SQuAD 的抽取式数据集,对于每个问题都从原文中截取片段 (span) 作为答案,可以从 Github 下载。
其中 cmrc2018_train.json、cmrc2018_dev.json 和 cmrc2018_trial.json 分别对应训练集、验证集和测试集。对于每篇文章,CMRC 2018 都标注了一些问题以及对应的答案(包括答案的文本和位置),例如:
{
"context": "《战国无双3》()是由光荣和ω-force开发的战国无双系列的正统第三续作。本作以三大故事为主轴,分别是以武田信玄等人为主的《关东三国志》,织田信长等人为主的《战国三杰》,石田三成等人为主的《关原的年轻武者》,丰富游戏内的剧情。此部份专门介绍角色,欲知武器情报、奥义字或擅长攻击类型等,请至战国无双系列1.由于乡里大辅先生因故去世,不得不寻找其他声优接手。从猛将传 and Z开始。2.战国无双 编年史的原创男女主角亦有专属声优。此模式是任天堂游戏谜之村雨城改编的新增模式。...",
"qas": [{
"question": "《战国无双3》是由哪两个公司合作开发的?",
"id": "DEV_0_QUERY_0",
"answers": [{
"text": "光荣和ω-force",
"answer_start": 11
}, {
"text": "光荣和ω-force",
"answer_start": 11
}, {
"text": "光荣和ω-force",
"answer_start": 11
}]
}, {
"question": "男女主角亦有专属声优这一模式是由谁改编的?",
"id": "DEV_0_QUERY_1",
"answers": [{
"text": "村雨城",
"answer_start": 226
}, {
"text": "村雨城",
"answer_start": 226
}, {
"text": "任天堂游戏谜之村雨城",
"answer_start": 219
}]
}, ...
]
}
一个问题可能对应有多个参考答案,在训练时我们任意选择其中一个作为标签,在验证/测试时,我们则将预测答案和所有参考答案都送入打分函数来评估模型的性能。
构建数据集
与之前一样,我们首先编写继承自 Dataset 类的自定义数据集用于组织样本和标签。原始数据集中一个样本对应一个上下文,这里我们将它调整为一个问题一个样本,参考答案则处理为包含 text 和 answer_start 字段的字典,分别存储答案文本和位置:
from torch.utils.data import Dataset
import json
class CMRC2018(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'r', encoding='utf-8') as f:
json_data = json.load(f)
idx = 0
for article in json_data['data']:
title = article['title']
context = article['paragraphs'][0]['context']
for question in article['paragraphs'][0]['qas']:
q_id = question['id']
ques = question['question']
text = [ans['text'] for ans in question['answers']]
answer_start = [ans['answer_start'] for ans in question['answers']]
Data[idx] = {
'id': q_id,
'title': title,
'context': context,
'question': ques,
'answers': {
'text': text,
'answer_start': answer_start
}
}
idx += 1
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = CMRC2018('data/cmrc2018/cmrc2018_train.json')
valid_data = CMRC2018('data/cmrc2018/cmrc2018_dev.json')
test_data = CMRC2018('data/cmrc2018/cmrc2018_trial.json')
下面我们输出数据集的尺寸,并且打印出一个验证样本:
print(f'train set size: {len(train_data)}')
print(f'valid set size: {len(valid_data)}')
print(f'test set size: {len(test_data)}')
print(next(iter(valid_data)))
train set size: 10142
valid set size: 3219
test set size: 1002
{
'id': 'DEV_0_QUERY_0',
'title': '战国无双3',
'context': '《战国无双3》()是由光荣和ω-force开发的战国无双系列的正统第三续作。本作以三大故事为主轴,分别是以武田信玄等人为主的《关东三国志》,织田信长等人为主的《战国三杰》,石田三成等人为主的《关原的年轻武者》,丰富游戏内的剧情。此部份专门介绍角色,欲知武器情报、奥义字或擅长攻击类型等,请至战国无双系列1.由于乡里大辅先生因故去世,不得不寻找其他声优接手。从猛将传 and Z开始。2.战国无双 编年史的原创男女主角亦有专属声优。此模式是任天堂游戏谜之村雨城改编的新增模式。本作中共有20张战场地图(不含村雨城),后来发行的猛将传再新增3张战场地图。但游戏内战役数量繁多,部分地图会有兼用的状况,战役虚实则是以光荣发行的2本「战国无双3 人物真书」内容为主,以下是相关介绍。(注:前方加☆者为猛将传新增关卡及地图。)合并本篇和猛将传的内容,村雨城模式剔除,战国史模式可直接游玩。主打两大模式「战史演武」&「争霸演武」。系列作品外传作品',
'question': '《战国无双3》是由哪两个公司合作开发的?',
'answers': {
'text': ['光荣和ω-force', '光荣和ω-force', '光荣和ω-force'],
'answer_start': [11, 11, 11]
}
}
可以数据集处理为了我们预期的格式,因为参考答案可以有多个,所以答案文本 text 和位置 answer_start 都是列表。
数据预处理
接下来,我们就需要通过 DataLoader 库按 batch 加载数据,将文本转换为模型可以接受的 token IDs,并且构建对应的标签,标记答案在上下文中起始和结束位置。本文使用 BERT 模型来完成任务,因此我们首先加载对应的分词器:
from transformers import AutoTokenizer
checkpoint = 'bert-base-chinese'
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
正如我们之前在快速分词器中介绍过的那样,对于抽取式问答任务,我们会将问题和上下文编码为下面的形式:
[CLS] question [SEP] context [SEP]
标签是答案在上下文中起始/结束 token 的索引,模型的任务就是预测每个 token 为答案片段的起始/结束的概率,即为每个 token 预测一个起始 logit 值和结束 logit 值。例如对于下面的文本,理想标签为:
我们在问答 pipeline 中就讨论过,由于问题与上下文拼接后的 token 序列可能超过模型的最大输入长度,因此我们可以将上下文切分为短文本块 (chunk) 来处理,同时为了避免答案被截断,我们使用滑窗使得切分出的文本块之间有重叠。
如果对分块操作感到陌生,可以参见快速分词器中的处理长文本小节,下面只做简单回顾。
下面我们尝试编码第一个训练样本,将拼接后的最大序列长度设为 300,滑窗大小设为 50,只需要给分词器传递以下参数:
-
max_length:设置编码后的最大序列长度(这里设为 300); -
truncation="only_second":只截断第二个输入,这里上下文是第二个输入; -
stride:两个相邻文本块之间的重合 token 数量(这里设为 50); -
return_overflowing_tokens=True:允许分词器返回重叠 token。
context = train_data[0]["context"]
question = train_data[0]["question"]
inputs = tokenizer(
question,
context,
max_length=300,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
)
for ids in inputs["input_ids"]:
print(tokenizer.decode(ids))
[CLS] 范 廷 颂 是 什 么 时 候 被 任 为 主 教 的 ? [SEP] 范 廷 颂 枢 机 ( , ) , 圣 名 保 禄 · 若 瑟 ( ) , 是 越 南 罗 马 天 主 教 枢 机 。 1963 年 被 任 为 主 教 ; 1990 年 被 擢 升 为 天 主 教 河 内 总 教 区 宗 座 署 理 ; 1994 年 被 擢 升 为 总 主 教 , 同 年 年 底 被 擢 升 为 枢 机 ; 2009 年 2 月 离 世 。 范 廷 颂 于 1919 年 6 月 15 日 在 越 南 宁 平 省 天 主 教 发 艳 教 区 出 生 ; 童 年 时 接 受 良 好 教 育 后 , 被 一 位 越 南 神 父 带 到 河 内 继 续 其 学 业 。 范 廷 颂 于 1940 年 在 河 内 大 修 道 院 完 成 神 学 学 业 。 范 廷 颂 于 1949 年 6 月 6 日 在 河 内 的 主 教 座 堂 晋 铎 ; 及 后 被 派 到 圣 女 小 德 兰 孤 儿 院 服 务 。 1950 年 代 , 范 廷 颂 在 河 内 堂 区 创 建 移 民 接 待 中 心 以 收 容 到 河 内 避 战 的 难 民 。 1954 年 , 法 越 战 争 结 束 , 越 南 民 主 共 和 国 建 都 河 内 , 当 时 很 多 天 主 教 神 职 人 员 逃 至 越 南 的 南 方 , 但 范 廷 颂 仍 然 留 在 河 内 。 翌 年 [SEP]
[CLS] 范 廷 颂 是 什 么 时 候 被 任 为 主 教 的 ? [SEP] 越 战 争 结 束 , 越 南 民 主 共 和 国 建 都 河 内 , 当 时 很 多 天 主 教 神 职 人 员 逃 至 越 南 的 南 方 , 但 范 廷 颂 仍 然 留 在 河 内 。 翌 年 管 理 圣 若 望 小 修 院 ; 惟 在 1960 年 因 捍 卫 修 院 的 自 由 、 自 治 及 拒 绝 政 府 在 修 院 设 政 治 课 的 要 求 而 被 捕 。 1963 年 4 月 5 日 , 教 宗 任 命 范 廷 颂 为 天 主 教 北 宁 教 区 主 教 , 同 年 8 月 15 日 就 任 ; 其 牧 铭 为 「 我 信 天 主 的 爱 」 。 由 于 范 廷 颂 被 越 南 政 府 软 禁 差 不 多 30 年 , 因 此 他 无 法 到 所 属 堂 区 进 行 牧 灵 工 作 而 专 注 研 读 等 工 作 。 范 廷 颂 除 了 面 对 战 争 、 贫 困 、 被 当 局 迫 害 天 主 教 会 等 问 题 外 , 也 秘 密 恢 复 修 院 、 创 建 女 修 会 团 体 等 。 1990 年 , 教 宗 若 望 保 禄 二 世 在 同 年 6 月 18 日 擢 升 范 廷 颂 为 天 主 教 河 内 总 教 区 宗 座 署 理 以 填 补 该 教 区 总 主 教 的 空 缺 。 1994 年 3 月 23 日 [SEP]
[CLS] 范 廷 颂 是 什 么 时 候 被 任 为 主 教 的 ? [SEP] 若 望 保 禄 二 世 在 同 年 6 月 18 日 擢 升 范 廷 颂 为 天 主 教 河 内 总 教 区 宗 座 署 理 以 填 补 该 教 区 总 主 教 的 空 缺 。 1994 年 3 月 23 日 , 范 廷 颂 被 教 宗 若 望 保 禄 二 世 擢 升 为 天 主 教 河 内 总 教 区 总 主 教 并 兼 天 主 教 谅 山 教 区 宗 座 署 理 ; 同 年 11 月 26 日 , 若 望 保 禄 二 世 擢 升 范 廷 颂 为 枢 机 。 范 廷 颂 在 1995 年 至 2001 年 期 间 出 任 天 主 教 越 南 主 教 团 主 席 。 2003 年 4 月 26 日 , 教 宗 若 望 保 禄 二 世 任 命 天 主 教 谅 山 教 区 兼 天 主 教 高 平 教 区 吴 光 杰 主 教 为 天 主 教 河 内 总 教 区 署 理 主 教 ; 及 至 2005 年 2 月 19 日 , 范 廷 颂 因 获 批 辞 去 总 主 教 职 务 而 荣 休 ; 吴 光 杰 同 日 真 除 天 主 教 河 内 总 教 区 总 主 教 职 务 。 范 廷 颂 于 2009 年 2 月 22 日 清 晨 在 河 内 离 世 , 享 年 89 岁 ; 其 葬 礼 于 同 月 26 日 上 午 在 天 主 教 河 内 总 教 区 总 主 教 座 堂 [SEP]
[CLS] 范 廷 颂 是 什 么 时 候 被 任 为 主 教 的 ? [SEP] 职 务 。 范 廷 颂 于 2009 年 2 月 22 日 清 晨 在 河 内 离 世 , 享 年 89 岁 ; 其 葬 礼 于 同 月 26 日 上 午 在 天 主 教 河 内 总 教 区 总 主 教 座 堂 举 行 。 [SEP]
可以看到,对上下文的分块使得这个样本被切分为了 4 个新样本。
对于包含答案的样本,标签就是起始和结束 token 的索引;对于不包含答案或只有部分答案的样本,对应的标签都为 start_position = end_position = 0(即 [CLS])。因此我们还需要设置分词器参数 return_offsets_mapping=True,这样就可以运用快速分词器提供的 offset mapping 映射得到对应的 token 索引。
例如我们处理前 4 个训练样本:
contexts = [train_data[idx]["context"] for idx in range(4)]
questions = [train_data[idx]["question"] for idx in range(4)]
inputs = tokenizer(
questions,
contexts,
max_length=300,
truncation="only_second",
stride=50,
return_overflowing_tokens=True,
return_offsets_mapping=True
)
print(inputs.keys())
print(f"The 4 examples gave {len(inputs['input_ids'])} features.")
print(f"Here is where each comes from: {inputs['overflow_to_sample_mapping']}.")
dict_keys([
'input_ids',
'token_type_ids',
'attention_mask',
'offset_mapping',
'overflow_to_sample_mapping'
])
The 4 examples gave 14 features.
Here is where each comes from: [0, 0, 0, 0, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3].
由于我们设置 return_overflowing_tokens 和 return_offsets_mapping,因此编码结果中除了 input IDs、token type IDs 和 attention mask 以外,还返回了记录 token 到原文映射的 offset_mapping,以及记录分块样本到原始样本映射的 overflow_to_sample_mapping。这里 4 个样本共被分块成了 14 个新样本,其中前 4 个新样本来自于原始样本 0,接着 3 个新样本来自于样本 1 …等等。
获得这两个映射之后,我们就可以方便地将答案文本的在原文中的起始/结束位置映射到每个块的 token 索引,以构建答案标签 start_positions 和 end_positions。这里我们简单地选择答案列表中的第一个作为参考答案:
answers = [train_data[idx]["answers"] for idx in range(4)]
start_positions = []
end_positions = []
for i, offset in enumerate(inputs["offset_mapping"]):
sample_idx = inputs["overflow_to_sample_mapping"][i]
answer = answers[sample_idx]
start_char = answer["answer_start"][0]
end_char = answer["answer_start"][0] + len(answer["text"][0])
sequence_ids = inputs.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
print(start_positions)
print(end_positions)
[47, 0, 0, 0, 53, 0, 0, 100, 0, 0, 0, 0, 61, 0]
[48, 0, 0, 0, 70, 0, 0, 124, 0, 0, 0, 0, 106, 0]
注意:为了找到 token 序列中上下文的索引范围,我们可以直接使用 token type IDs,但是一些模型(例如 DistilBERT)的分词器并不会输出该项,因此这里使用快速分词器返回 BatchEncoding 自带的 sequence_ids() 函数。
下面我们做个简单的验证,例如对于第一个新样本,可以看到处理后的答案标签为 (47, 48),我们将对应的 token 解码并与标注答案进行对比:
idx = 0
sample_idx = inputs["overflow_to_sample_mapping"][idx]
answer = answers[sample_idx]["text"][0]
start = start_positions[idx]
end = end_positions[idx]
labeled_answer = tokenizer.decode(inputs["input_ids"][idx][start : end + 1])
print(f"Theoretical answer: {answer}, labels give: {labeled_answer}")
Theoretical answer: 1963年, labels give: 1963 年
注意:如果使用 XLNet 等模型,padding 操作会在序列左侧进行,并且问题和上下文也会调换,[CLS] 也可能不在 0 位置。
训练批处理函数
最后,我们合并上面的这些操作,编写对应于训练集的批处理函数。由于分块后大部分的样本长度都差不多,因此没必要再进行动态 padding,这里我们简单地将所有新样本都填充到设置的最大长度。
from torch.utils.data import DataLoader
max_length = 384
stride = 128
def train_collote_fn(batch_samples):
batch_question, batch_context, batch_answers = [], [], []
for sample in batch_samples:
batch_question.append(sample['question'])
batch_context.append(sample['context'])
batch_answers.append(sample['answers'])
batch_data = tokenizer(
batch_question,
batch_context,
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding='max_length',
return_tensors="pt"
)
offset_mapping = batch_data.pop('offset_mapping')
sample_mapping = batch_data.pop('overflow_to_sample_mapping')
start_positions = []
end_positions = []
for i, offset in enumerate(offset_mapping):
sample_idx = sample_mapping[i]
answer = batch_answers[sample_idx]
start_char = answer['answer_start'][0]
end_char = answer['answer_start'][0] + len(answer['text'][0])
sequence_ids = batch_data.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
return batch_data, torch.tensor(start_positions), torch.tensor(end_positions)
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=train_collote_fn)
我们尝试打印出一个 batch 的数据,以验证是否处理正确,并且计算分块后新数据集的大小:
import torch
batch_X, batch_Start, batch_End = next(iter(train_dataloader))
print('batch_X shape:', {k: v.shape for k, v in batch_X.items()})
print('batch_Start shape:', batch_Start.shape)
print('batch_End shape:', batch_End.shape)
print(batch_X)
print(batch_Start)
print(batch_End)
print('train set size: ', )
print(len(train_data), '->', sum([batch_data['input_ids'].shape[0] for batch_data, _, _ in train_dataloader]))
batch_X shape: {
'input_ids': torch.Size([8, 384]),
'token_type_ids': torch.Size([8, 384]),
'attention_mask': torch.Size([8, 384])
}
batch_Start shape: torch.Size([8])
batch_End shape: torch.Size([8])
{'input_ids': tensor([
[ 101, 100, 6858, ..., 0, 0, 0],
[ 101, 784, 720, ..., 1184, 7481, 102],
[ 101, 784, 720, ..., 3341, 8024, 102],
...,
[ 101, 7716, 5335, ..., 0, 0, 0],
[ 101, 1367, 7063, ..., 5638, 1867, 102],
[ 101, 1367, 7063, ..., 0, 0, 0]]), 'token_type_ids': tensor([[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 1, 1, 1],
[0, 0, 0, ..., 1, 1, 1],
...,
[0, 0, 0, ..., 0, 0, 0],
[0, 0, 0, ..., 1, 1, 1],
[0, 0, 0, ..., 0, 0, 0]]), 'attention_mask': tensor([[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 1, 1, 1],
[1, 1, 1, ..., 1, 1, 1],
...,
[1, 1, 1, ..., 0, 0, 0],
[1, 1, 1, ..., 1, 1, 1],
[1, 1, 1, ..., 0, 0, 0]])
}
tensor([ 98, 10, 0, 0, 62, 0, 132, 0])
tensor([100, 35, 0, 0, 65, 0, 140, 0])
train set size:
10142 -> 18960
可以看到,DataLoader 按照我们设置的 batch_size=4 对样本进行编码,并且成功生成了分别对应答案起始/结束索引的答案标签 start_positions 和 end_positions 。经过分块操作后,4 个原始样本被切分成了 8 个新样本,整个训练集的大小从 10142 增长到了 18960。
分块操作使得每一个 batch 处理后的大小参差不齐,每次送入模型的样本数并不一致,这虽然可以正常训练,但可能会影响模型最终的精度。更好地方式是为分块后的新样本重新建立一个 Dataset,然后按批加载新的数据集:
fromtransformersimportdefault_data_collator
train_dataloader=DataLoader(
new_train_dataset,
shuffle=True,
collate_fn=default_data_collator,
batch_size=8,
)
验证/测试批处理函数
对于验证/测试集,我们关注的不是预测出的标签序列,而是最终的答案文本,这就需要:
- 记录每个原始样本被分块成了哪几个新样本,从而合并对应的预测结果;
- 在 offset mapping 中标记问题的对应 token,从而在后处理阶段可以区分哪些位置的 token 来自于上下文。
因此,对应于验证集/测试集的批处理函数为:
def test_collote_fn(batch_samples):
batch_id, batch_question, batch_context = [], [], []
for sample in batch_samples:
batch_id.append(sample['id'])
batch_question.append(sample['question'])
batch_context.append(sample['context'])
batch_data = tokenizer(
batch_question,
batch_context,
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
return_tensors="pt"
)
offset_mapping = batch_data.pop('offset_mapping').numpy().tolist()
sample_mapping = batch_data.pop('overflow_to_sample_mapping')
example_ids = []
for i in range(len(batch_data['input_ids'])):
sample_idx = sample_mapping[i]
example_ids.append(batch_id[sample_idx])
sequence_ids = batch_data.sequence_ids(i)
offset = offset_mapping[i]
offset_mapping[i] = [
o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
]
return batch_data, offset_mapping, example_ids
valid_dataloader = DataLoader(valid_data, batch_size=8, shuffle=False, collate_fn=test_collote_fn)
同样地,我们打印出一个 batch 编码后的数据,并且计算分块后新数据集的大小:
batch_X, offset_mapping, example_ids = next(iter(valid_dataloader))
print('batch_X shape:', {k: v.shape for k, v in batch_X.items()})
print(example_ids)
print('valid set size: ')
print(len(valid_data), '->', sum([batch_data['input_ids'].shape[0] for batch_data, _, _ in valid_dataloader]))
batch_X shape: {
'input_ids': torch.Size([16, 384]),
'token_type_ids': torch.Size([16, 384]),
'attention_mask': torch.Size([16, 384])
}
['DEV_0_QUERY_0', 'DEV_0_QUERY_0', 'DEV_0_QUERY_1', 'DEV_0_QUERY_1', 'DEV_0_QUERY_2', 'DEV_0_QUERY_2', 'DEV_1_QUERY_0', 'DEV_1_QUERY_0', 'DEV_1_QUERY_1', 'DEV_1_QUERY_1', 'DEV_1_QUERY_2', 'DEV_1_QUERY_2', 'DEV_1_QUERY_3', 'DEV_1_QUERY_3', 'DEV_2_QUERY_0', 'DEV_2_QUERY_0']
valid set size:
3219 -> 6254
可以看到,我们成功构建了记录每个分块对应样本 ID 的 example_id。经过分块操作后,整个测试集的样本数量从 3219 增长到了 6254。
至此,数据预处理部分就全部完成了!
2. 训练模型
对于抽取式问答任务,可以直接使用 Transformers 库自带的 AutoModelForQuestionAnswering 函数来构建模型。考虑到这种方式不够灵活,因此与序列标注任务一样,本文采用继承 Transformers 库预训练模型的方式来手工构建模型:
from torch import nn
from transformers import AutoConfig
from transformers import BertPreTrainedModel, BertModel
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class BertForExtractiveQA(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.num_labels = config.num_labels
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
sequence_output = bert_output.last_hidden_state
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output)
start_logits, end_logits = logits.split(1, dim=-1)
start_logits = start_logits.squeeze(-1).contiguous()
end_logits = end_logits.squeeze(-1).contiguous()
return start_logits, end_logits
config = AutoConfig.from_pretrained(checkpoint)
model = BertForExtractiveQA.from_pretrained(checkpoint, config=config).to(device)
print(model)
BertForExtractiveQA(
(bert): BertModel(...)
(dropout): Dropout(p=0.1, inplace=False)
(classifier): Linear(in_features=768, out_features=2, bias=True)
)
可以看到,我们构建的模型首先运用 BERT 模型将每一个 token 都编码为语义向量,然后将输出序列送入到一个包含 2 个神经元的线性全连接层中,分别表示每个 token 为答案起始、结束位置的分数,最后我们通过 tensor.split() 函数把输出拆分为起始、结束位置的预测值。
为了测试模型的操作是否符合预期,我们尝试将一个 batch 的数据送入模型:
seed_everything(5)
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=train_collote_fn)
batch_X, _, _ = next(iter(train_dataloader))
start_outputs, end_outputs = model(batch_X)
print('batch_X shape:', {k: v.shape for k, v in batch_X.items()})
print('start_outputs shape', start_outputs.shape)
print('end_outputs shape', end_outputs.shape)
batch_X shape: {
'input_ids': torch.Size([8, 384]),
'token_type_ids': torch.Size([8, 384]),
'attention_mask': torch.Size([8, 384])
}
start_outputs shape torch.Size([8, 384])
end_outputs shape torch.Size([8, 384])
对于 batch 内 8 个都被填充到长度为 384 的文本块,模型对每个 token 都应该输出 1 个 logits 值,对应该 token 为答案起始/结束位置的分数,因此这里模型的起始/结束输出尺寸 8×384 完全符合预期。
训练循环
与之前一样,我们将每一轮 Epoch 分为“训练循环”和“验证/测试循环”,在训练循环中计算损失、优化模型参数,在验证/测试循环中评估模型性能。下面我们首先实现训练循环。
如果换一个角度,我们判断每个 token 是否为答案的起始/结束位置,其实就是在整个序列所有的 L 个 token 上选出一个 token 作为答案的起始/结束,相当是在进行一个 L 分类问题。因此这里我们分别在起始和结束的输出上运用交叉熵来计算损失,然后取两个损失的平均值作为模型的整体损失:
from tqdm.auto import tqdm
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, (X, start_pos, end_pos) in enumerate(dataloader, start=1):
X, start_pos, end_pos = X.to(device), start_pos.to(device), end_pos.to(device)
start_pred, end_pred = model(X)
start_loss = loss_fn(start_pred, start_pos)
end_loss = loss_fn(end_pred, end_pos)
loss = (start_loss + end_loss) / 2
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
后处理
因为最终是根据预测出的答案文本来评估模型的性能,所以在编写“验证/测试循环”之前,我们先讨论一下抽取式问答模型的后处理——怎么将模型的输出转换为答案文本。
之前在快速分词器章节中已经介绍过,对每个样本,问答模型都会输出两个张量,分别对应答案起始/结束位置的 logits 值,我们回顾一下之前的后处理过程:
- 遮盖掉除上下文之外的其他 token 的起始/结束 logits 值;
- 通过 softmax 函数将起始/结束 logits 值转换为概率值;
- 通过计算概率值的乘积估计每一对
(start_token, end_token)为答案的分数; - 输出合理的(例如
start_token要小于end_token)分数最大的对作为答案。
本文我们会稍微做一些调整:
- 首先,我们只关心答案文本并不关心其概率,因此这里跳过 softmax 函数,直接基于 logits 值来估计答案分数,这样就从原来计算概率值的乘积变成计算 logits 值的和(因为 );
log(ab)=log(a)+log(b)
- 其次,为了减少计算量,我们不再为所有可能的
(start_token, end_token)对打分,而是只计算 logits 值最高的前 n_best 个 token 组成的对。
由于我们的 BERT 模型还没有进行微调,因此这里我们选择一个已经预训练好的问答模型 Chinese RoBERTa-Base Model for QA 进行演示,对验证集上的前 12 个样本进行处理:
valid_data = CMRC2018('data/cmrc2018/cmrc2018_dev.json')
small_eval_set = [valid_data[idx] for idx in range(12)]
trained_checkpoint = "uer/roberta-base-chinese-extractive-qa"
tokenizer = AutoTokenizer.from_pretrained(trained_checkpoint)
eval_set = DataLoader(small_eval_set, batch_size=4, shuffle=False, collate_fn=test_collote_fn)
import torch
device = 'cuda' if torch.cuda.is_available() else 'cpu'
from transformers import AutoModelForQuestionAnswering
trained_model = AutoModelForQuestionAnswering.from_pretrained(trained_checkpoint).to(device)
接下来,与之前任务中的验证/测试循环一样,在 torch.no_grad() 上下文管理器下,使用模型对所有分块后的新样本进行预测,并且汇总预测出的起始/结束 logits 值:
start_logits = []
end_logits = []
trained_model.eval()
for batch_data, _, _ in eval_set:
batch_data = batch_data.to(device)
with torch.no_grad():
outputs = trained_model(**batch_data)
start_logits.append(outputs.start_logits.cpu().numpy())
end_logits.append(outputs.end_logits.cpu().numpy())
import numpy as np
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
在将预测结果转换为文本之前,我们还需要知道每个样本被分块为了哪几个新样本,从而汇总对应的预测结果,因此下面先构造一个记录样本 ID 到新样本索引的映射:
all_example_ids = []
all_offset_mapping = []
for _, offset_mapping, example_ids in eval_set:
all_example_ids += example_ids
all_offset_mapping += offset_mapping
import collections
example_to_features = collections.defaultdict(list)
for idx, feature_id in enumerate(all_example_ids):
example_to_features[feature_id].append(idx)
print(example_to_features)
defaultdict(<class 'list'>, {
'DEV_0_QUERY_0': [0, 1], 'DEV_0_QUERY_1': [2, 3], 'DEV_0_QUERY_2': [4, 5], 'DEV_1_QUERY_0': [6, 7],
'DEV_1_QUERY_1': [8, 9], 'DEV_1_QUERY_2': [10, 11], 'DEV_1_QUERY_3': [12, 13], 'DEV_2_QUERY_0': [14, 15],
'DEV_2_QUERY_1': [16, 17], 'DEV_2_QUERY_2': [18, 19], 'DEV_3_QUERY_0': [20], 'DEV_3_QUERY_1': [21]
})
接下来我们只需要遍历数据集中的样本,首先汇总由其分块出的新样本的预测结果,然后取出每个新样本最高的前 n_best 个起始/结束 logits 值,最后评估对应的 token 片段为答案的分数(这里我们还通过限制答案的最大长度来进一步减小计算量):
n_best = 20
max_answer_length = 30
theoretical_answers = [
{"id": ex["id"], "answers": ex["answers"]} for ex in small_eval_set
]
predicted_answers = []
for example in small_eval_set:
example_id = example["id"]
context = example["context"]
answers = []
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = all_offset_mapping[feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
if offsets[start_index] is None or offsets[end_index] is None:
continueif (end_index < start_index or end_index - start_index + 1 > max_answer_length):
continueanswers.append(
{
"start": offsets[start_index][0],
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
}
)
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({
"id": example_id,
"prediction_text": best_answer["text"],
"answer_start": best_answer["start"]
})
else:
predicted_answers.append({
"id": example_id,
"prediction_text": "",
"answer_start": 0
})
下面我们同步打印出预测和标注的答案来进行对比:
for pred, label in zip(predicted_answers, theoretical_answers):
print(pred['id'])
print('pred:', pred['prediction_text'])
print('label:', label['answers']['text'])
DEV_0_QUERY_0
pred: 光荣和ω-force
label: ['光荣和ω-force', '光荣和ω-force', '光荣和ω-force']
DEV_0_QUERY_1
pred: 任天堂游戏谜之村雨城
label: ['村雨城', '村雨城', '任天堂游戏谜之村雨城']
...
可以看到,由于 Chinese RoBERTa-Base Model for QA 模型本身的预训练数据就包含了 CMRC 2018,因此模型的预测结果非常好。
在成功获取到预测的答案片段之后,就可以对模型的性能进行评估了。这里我们对 CMRC 2018 自带的评估脚本进行修改,使其支持本文模型的输出格式。请将下面的代码存放在 cmrc2018_evaluate.py 文件中,后续直接使用其中的 evaluate 函数进行评估。
import re
import sys
from transformers import AutoTokenizer
model_checkpoint = "bert-base-cased"
tokenizer = AutoTokenizer.from_pretrained(model_checkpoint)
tokenize = lambda x: tokenizer(x).tokens()[1:-1]
# import nltk
# tokenize = lambda x: nltk.word_tokenize(x)
# split Chinese with English
def mixed_segmentation(in_str, rm_punc=False):
in_str = str(in_str).lower().strip()
segs_out = []
temp_str = ""
sp_char = ['-',':','_','*','^','/','\\','~','`','+','=',
',','。',':','?','!','“','”',';','’','《','》','……','·','、',
'「','」','(',')','-','~','『','』']
for char in in_str:
if rm_punc and char in sp_char:
continueif re.search(r'[\u4e00-\u9fa5]', char) or char in sp_char:
if temp_str != "":
# ss = nltk.word_tokenize(temp_str)
ss = tokenize(temp_str)
segs_out.extend(ss)
temp_str = ""
segs_out.append(char)
else:
temp_str += char
#handling last part
if temp_str != "":
# ss = nltk.word_tokenize(temp_str)
ss = tokenize(temp_str)
segs_out.extend(ss)
return segs_out
# remove punctuation
def remove_punctuation(in_str):
in_str = str(in_str).lower().strip()
sp_char = ['-',':','_','*','^','/','\\','~','`','+','=',
',','。',':','?','!','“','”',';','’','《','》','……','·','、',
'「','」','(',')','-','~','『','』']
out_segs = []
for char in in_str:
if char in sp_char:
continueelse:
out_segs.append(char)
return ''.join(out_segs)
# find longest common string
def find_lcs(s1, s2):
m = [[0 for i in range(len(s2)+1)] for j in range(len(s1)+1)]
mmax = 0
p = 0
for i in range(len(s1)):
for j in range(len(s2)):
if s1[i] == s2[j]:
m[i+1][j+1] = m[i][j]+1
if m[i+1][j+1] > mmax:
mmax=m[i+1][j+1]
p=i+1
return s1[p-mmax:p], mmax
def calc_f1_score(answers, prediction):
f1_scores = []
for ans in answers:
ans_segs = mixed_segmentation(ans, rm_punc=True)
prediction_segs = mixed_segmentation(prediction, rm_punc=True)
lcs, lcs_len = find_lcs(ans_segs, prediction_segs)
if lcs_len == 0:
f1_scores.append(0)
continueprecision = 1.0*lcs_len/len(prediction_segs)
recall = 1.0*lcs_len/len(ans_segs)
f1 = (2*precision*recall)/(precision+recall)
f1_scores.append(f1)
return max(f1_scores)
def calc_em_score(answers, prediction):
em = 0
for ans in answers:
ans_ = remove_punctuation(ans)
prediction_ = remove_punctuation(prediction)
if ans_ == prediction_:
em = 1
breakreturn em
def evaluate(predictions, references):
f1 = 0
em = 0
total_count = 0
skip_count = 0
pred = dict([(data['id'], data['prediction_text']) for data in predictions])
ref = dict([(data['id'], data['answers']['text']) for data in references])
for query_id, answers in ref.items():
total_count += 1
if query_id not in pred:
sys.stderr.write('Unanswered question: {}\n'.format(query_id))
skip_count += 1
continueprediction = pred[query_id]
f1 += calc_f1_score(answers, prediction)
em += calc_em_score(answers, prediction)
f1_score = 100.0 * f1 / total_count
em_score = 100.0 * em / total_count
return {
'avg': (em_score + f1_score) * 0.5,
'f1': f1_score,
'em': em_score,
'total': total_count,
'skip': skip_count
}
最后,我们将上面的预测结果送入 evaluate 函数进行评估:
from cmrc2018_evaluate import evaluate
result = evaluate(predicted_answers, theoretical_answers)
print(f"F1: {result['f1']:>0.2f} EM: {result['em']:>0.2f} AVG: {result['avg']:>0.2f}\n")
F1: 92.63 EM: 75.00 AVG: 83.81
测试循环
熟悉了后处理操作之后,编写验证/测试循环就很简单了,只需对上面的这些步骤稍作整合即可:
import collections
from cmrc2018_evaluate import evaluate
n_best = 20
max_answer_length = 30
def test_loop(dataloader, dataset, model):
all_example_ids = []
all_offset_mapping = []
for _, offset_mapping, example_ids in dataloader:
all_example_ids += example_ids
all_offset_mapping += offset_mapping
example_to_features = collections.defaultdict(list)
for idx, feature_id in enumerate(all_example_ids):
example_to_features[feature_id].append(idx)
start_logits = []
end_logits = []
model.eval()
for batch_data, _, _ in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
outputs = model(**batch_data)
start_logits.append(outputs.start_logits.cpu().numpy())
end_logits.append(outputs.end_logits.cpu().numpy())
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
theoretical_answers = [
{"id": dataset[s_idx]["id"], "answers": dataset[s_idx]["answers"]} for s_idx in range(len(dataset))
]
predicted_answers = []
for s_idx in tqdm(range(len(dataset))):
example_id = dataset[s_idx]["id"]
context = dataset[s_idx]["context"]
answers = []
# Loop through all features associated with that example
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = all_offset_mapping[feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
if offsets[start_index] is None or offsets[end_index] is None:
continueif (end_index < start_index or end_index-start_index+1 > max_answer_length):
continueanswers.append({
"start": offsets[start_index][0],
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
})
# Select the answer with the best score
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({
"id": example_id,
"prediction_text": best_answer["text"],
"answer_start": best_answer["start"]
})
else:
predicted_answers.append({
"id": example_id,
"prediction_text": "",
"answer_start": 0
})
result = evaluate(predicted_answers, theoretical_answers)
print(f"F1: {result['f1']:>0.2f} EM: {result['em']:>0.2f} AVG: {result['avg']:>0.2f}\n")
return result
为了方便后续保存验证集上最好的模型,这里我们还在验证/测试循环中返回对模型预测的评估结果。
保存模型
与之前一样,我们会根据模型在验证集上的性能来调整超参数以及选出最好的模型权重,然后将选出的模型应用于测试集以评估最终的性能。这里我们继续使用 AdamW 优化器,并且通过 get_scheduler() 函数定义学习率调度器:
from transformers import AdamW, get_scheduler
learning_rate = 1e-5
epoch_num = 3
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_avg_score = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, t+1, total_loss)
valid_scores = test_loop(valid_dataloader, valid_data, model, mode='Valid')
avg_score = valid_scores['avg']
if avg_score > best_avg_score:
best_avg_score = avg_score
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_avg_{avg_score:0.4f}_model_weights.bin')
print("Done!")
下面,我们正式开始训练,完整的训练代码如下:
import os
import random
import numpy as np
import torch
from torch.utils.data import Dataset, DataLoader
from torch import nn
from transformers import AutoTokenizer, AutoConfig
from transformers import BertPreTrainedModel, BertModel
from transformers import AdamW, get_scheduler
import json
import collections
import sys
from tqdm.auto import tqdm
sys.path.append('./')
from cmrc2018_evaluate import evaluate
max_length = 384
stride = 128
n_best = 20
max_answer_length = 30
batch_size = 4
learning_rate = 1e-5
epoch_num = 3
def seed_everything(seed=1029):
random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
seed_everything(7)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class CMRC2018(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'r', encoding='utf-8') as f:
json_data = json.load(f)
idx = 0
for article in json_data['data']:
title = article['title']
context = article['paragraphs'][0]['context']
for question in article['paragraphs'][0]['qas']:
q_id = question['id']
ques = question['question']
text = [ans['text'] for ans in question['answers']]
answer_start = [ans['answer_start'] for ans in question['answers']]
Data[idx] = {
'id': q_id,
'title': title,
'context': context,
'question': ques,
'answers': {
'text': text,
'answer_start': answer_start
}
}
idx += 1
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = CMRC2018('data/cmrc2018/cmrc2018_train.json')
valid_data = CMRC2018('data/cmrc2018/cmrc2018_dev.json')
test_data = CMRC2018('data/cmrc2018/cmrc2018_trial.json')
checkpoint = 'bert-base-chinese'
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
def train_collote_fn(batch_samples):
batch_question, batch_context, batch_answers = [], [], []
for sample in batch_samples:
batch_question.append(sample['question'])
batch_context.append(sample['context'])
batch_answers.append(sample['answers'])
batch_data = tokenizer(
batch_question,
batch_context,
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding='max_length',
return_tensors="pt"
)
offset_mapping = batch_data.pop('offset_mapping')
sample_mapping = batch_data.pop('overflow_to_sample_mapping')
start_positions = []
end_positions = []
for i, offset in enumerate(offset_mapping):
sample_idx = sample_mapping[i]
answer = batch_answers[sample_idx]
start_char = answer['answer_start'][0]
end_char = answer['answer_start'][0] + len(answer['text'][0])
sequence_ids = batch_data.sequence_ids(i)
# Find the start and end of the context
idx = 0
while sequence_ids[idx] != 1:
idx += 1
context_start = idx
while sequence_ids[idx] == 1:
idx += 1
context_end = idx - 1
# If the answer is not fully inside the context, label is (0, 0)
if offset[context_start][0] > start_char or offset[context_end][1] < end_char:
start_positions.append(0)
end_positions.append(0)
else:
# Otherwise it's the start and end token positions
idx = context_start
while idx <= context_end and offset[idx][0] <= start_char:
idx += 1
start_positions.append(idx - 1)
idx = context_end
while idx >= context_start and offset[idx][1] >= end_char:
idx -= 1
end_positions.append(idx + 1)
return batch_data, torch.tensor(start_positions), torch.tensor(end_positions)
def test_collote_fn(batch_samples):
batch_id, batch_question, batch_context = [], [], []
for sample in batch_samples:
batch_id.append(sample['id'])
batch_question.append(sample['question'])
batch_context.append(sample['context'])
batch_data = tokenizer(
batch_question,
batch_context,
max_length=max_length,
truncation="only_second",
stride=stride,
return_overflowing_tokens=True,
return_offsets_mapping=True,
padding="max_length",
return_tensors="pt"
)
offset_mapping = batch_data.pop('offset_mapping').numpy().tolist()
sample_mapping = batch_data.pop('overflow_to_sample_mapping')
example_ids = []
for i in range(len(batch_data['input_ids'])):
sample_idx = sample_mapping[i]
example_ids.append(batch_id[sample_idx])
sequence_ids = batch_data.sequence_ids(i)
offset = offset_mapping[i]
offset_mapping[i] = [
o if sequence_ids[k] == 1 else None for k, o in enumerate(offset)
]
return batch_data, offset_mapping, example_ids
train_dataloader = DataLoader(train_data, batch_size=batch_size, shuffle=True, collate_fn=train_collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=batch_size, shuffle=False, collate_fn=test_collote_fn)
test_dataloader = DataLoader(test_data, batch_size=batch_size, shuffle=False, collate_fn=test_collote_fn)
print('train set size: ', )
print(len(train_data), '->', sum([batch_data['input_ids'].shape[0] for batch_data, _, _ in train_dataloader]))
print('valid set size: ')
print(len(valid_data), '->', sum([batch_data['input_ids'].shape[0] for batch_data, _, _ in valid_dataloader]))
print('test set size: ')
print(len(test_data), '->', sum([batch_data['input_ids'].shape[0] for batch_data, _, _ in test_dataloader]))
class BertForExtractiveQA(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.num_labels = config.num_labels
self.bert = BertModel(config, add_pooling_layer=False)
self.dropout = nn.Dropout(config.hidden_dropout_prob)
self.classifier = nn.Linear(config.hidden_size, config.num_labels)
self.post_init()
def forward(self, x):
bert_output = self.bert(**x)
sequence_output = bert_output.last_hidden_state
sequence_output = self.dropout(sequence_output)
logits = self.classifier(sequence_output)
start_logits, end_logits = logits.split(1, dim=-1)
start_logits = start_logits.squeeze(-1).contiguous()
end_logits = end_logits.squeeze(-1).contiguous()
return start_logits, end_logits
config = AutoConfig.from_pretrained(checkpoint)
config.num_labels = 2
model = BertForExtractiveQA.from_pretrained(checkpoint, config=config).to(device)
def train_loop(dataloader, model, loss_fn, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = (epoch-1) * len(dataloader)
model.train()
for batch, (X, start_pos, end_pos) in enumerate(dataloader, start=1):
X, start_pos, end_pos = X.to(device), start_pos.to(device), end_pos.to(device)
start_pred, end_pred = model(X)
start_loss = loss_fn(start_pred, start_pos)
end_loss = loss_fn(end_pred, end_pos)
loss = (start_loss + end_loss) / 2
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + batch):>7f}')
progress_bar.update(1)
return total_loss
def test_loop(dataloader, dataset, model):
all_example_ids = []
all_offset_mapping = []
for _, offset_mapping, example_ids in dataloader:
all_example_ids += example_ids
all_offset_mapping += offset_mapping
example_to_features = collections.defaultdict(list)
for idx, feature_id in enumerate(all_example_ids):
example_to_features[feature_id].append(idx)
start_logits = []
end_logits = []
model.eval()
for batch_data, _, _ in tqdm(dataloader):
batch_data = batch_data.to(device)
with torch.no_grad():
pred_start_logits, pred_end_logit = model(batch_data)
start_logits.append(pred_start_logits.cpu().numpy())
end_logits.append(pred_end_logit.cpu().numpy())
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
theoretical_answers = [
{"id": dataset[s_idx]["id"], "answers": dataset[s_idx]["answers"]} for s_idx in range(len(dataset))
]
predicted_answers = []
for s_idx in tqdm(range(len(dataset))):
example_id = dataset[s_idx]["id"]
context = dataset[s_idx]["context"]
answers = []
# Loop through all features associated with that example
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = all_offset_mapping[feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
if offsets[start_index] is None or offsets[end_index] is None:
continueif (end_index < start_index or end_index-start_index+1 > max_answer_length):
continueanswers.append({
"start": offsets[start_index][0],
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
})
# Select the answer with the best score
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({
"id": example_id,
"prediction_text": best_answer["text"],
"answer_start": best_answer["start"]
})
else:
predicted_answers.append({
"id": example_id,
"prediction_text": "",
"answer_start": 0
})
result = evaluate(predicted_answers, theoretical_answers)
print(f"F1: {result['f1']:>0.2f} EM: {result['em']:>0.2f} AVG: {result['avg']:>0.2f}\n")
return result
loss_fn = nn.CrossEntropyLoss()
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_avg_score = 0.
for t in range(epoch_num):
print(f"Epoch {t+1}/{epoch_num}\n-------------------------------")
total_loss = train_loop(train_dataloader, model, loss_fn, optimizer, lr_scheduler, t+1, total_loss)
valid_scores = test_loop(valid_dataloader, valid_data, model)
avg_score = valid_scores['avg']
if avg_score > best_avg_score:
best_avg_score = avg_score
print('saving new weights...\n')
torch.save(model.state_dict(), f'epoch_{t+1}_valid_avg_{avg_score:0.4f}_model_weights.bin')
print("Done!")
Using cuda device
train set size:
10142 -> 18960
valid set size:
3219 -> 6254
test set size:
1002 -> 1961
Epoch 1/3
-------------------------------
loss: 1.473110: 100%|█████████████| 2536/2536 [08:13<00:00, 5.14it/s]
100%|█████████████████████████████| 805/805 [00:50<00:00, 15.86it/s]
100%|█████████████████████████████| 3219/3219 [00:00<00:00, 4216.22it/s]
F1: 82.96 EM: 63.84 AVG: 73.40
saving new weights...
Epoch 2/3
-------------------------------
loss: 1.178375: 100%|█████████████| 2536/2536 [08:13<00:00, 5.14it/s]
100%|█████████████████████████████| 805/805 [00:51<00:00, 15.78it/s]
100%|█████████████████████████████| 3219/3219 [00:00<00:00, 4375.79it/s]
F1: 84.97 EM: 65.52 AVG: 75.24
saving new weights...
Epoch 3/3
-------------------------------
loss: 1.010483: 100%|█████████████| 2536/2536 [08:13<00:00, 5.14it/s]
100%|█████████████████████████████| 805/805 [00:51<00:00, 15.77it/s]
100%|█████████████████████████████| 3219/3219 [00:00<00:00, 4254.01it/s]
F1: 83.84 EM: 63.40 AVG: 73.62
Done!
可以看到,随着训练的进行,模型在验证集上的性能先升后降。因此,3 轮训练结束后,目录下只保存了前两轮训练后的模型权重:
epoch_1_valid_avg_73.3993_model_weights.bin
epoch_2_valid_avg_75.2441_model_weights.bin
至此,我们对 BERT 摘要模型的训练就完成了。
3. 测试模型
训练完成后,我们加载在验证集上性能最优的模型权重,汇报其在测试集上的性能,并且将模型的预测结果保存到文件中。
model.load_state_dict(torch.load('epoch_2_valid_avg_75.2441_model_weights.bin'))
model.eval()
with torch.no_grad():
print('evaluating on test set...')
all_example_ids = []
all_offset_mapping = []
for _, offset_mapping, example_ids in test_dataloader:
all_example_ids += example_ids
all_offset_mapping += offset_mapping
example_to_features = collections.defaultdict(list)
for idx, feature_id in enumerate(all_example_ids):
example_to_features[feature_id].append(idx)
start_logits = []
end_logits = []
model.eval()
for batch_data, _, _ in tqdm(test_dataloader):
batch_data = batch_data.to(device)
pred_start_logits, pred_end_logit = model(batch_data)
start_logits.append(pred_start_logits.cpu().numpy())
end_logits.append(pred_end_logit.cpu().numpy())
start_logits = np.concatenate(start_logits)
end_logits = np.concatenate(end_logits)
theoretical_answers = [
{"id": test_data[s_idx]["id"], "answers": test_data[s_idx]["answers"]} for s_idx in range(len(test_dataloader))
]
predicted_answers = []
save_resluts = []
for s_idx in tqdm(range(len(test_data))):
example_id = test_data[s_idx]["id"]
context = test_data[s_idx]["context"]
title = test_data[s_idx]["title"]
question = test_data[s_idx]["question"]
labels = test_data[s_idx]["answers"]
answers = []
for feature_index in example_to_features[example_id]:
start_logit = start_logits[feature_index]
end_logit = end_logits[feature_index]
offsets = all_offset_mapping[feature_index]
start_indexes = np.argsort(start_logit)[-1 : -n_best - 1 : -1].tolist()
end_indexes = np.argsort(end_logit)[-1 : -n_best - 1 : -1].tolist()
for start_index in start_indexes:
for end_index in end_indexes:
if offsets[start_index] is None or offsets[end_index] is None:
continueif (end_index < start_index or end_index-start_index+1 > max_answer_length):
continueanswers.append({
"start": offsets[start_index][0],
"text": context[offsets[start_index][0] : offsets[end_index][1]],
"logit_score": start_logit[start_index] + end_logit[end_index],
})
if len(answers) > 0:
best_answer = max(answers, key=lambda x: x["logit_score"])
predicted_answers.append({
"id": example_id,
"prediction_text": best_answer["text"],
"answer_start": best_answer["start"]
})
save_resluts.append({
"id": example_id,
"title": title,
"context": context,
"question": question,
"answers": labels,
"prediction_text": best_answer["text"],
"answer_start": best_answer["start"]
})
else:
predicted_answers.append({
"id": example_id,
"prediction_text": "",
"answer_start": 0
})
save_resluts.append({
"id": example_id,
"title": title,
"context": context,
"question": question,
"answers": labels,
"prediction_text": "",
"answer_start": 0
})
eval_result = evaluate(predicted_answers, theoretical_answers)
print(f"F1: {eval_result['f1']:>0.2f} EM: {eval_result['em']:>0.2f} AVG: {eval_result['avg']:>0.2f}\n")
print('saving predicted results...')
with open('test_data_pred.json', 'wt', encoding='utf-8') as f:
for example_result in save_resluts:
f.write(json.dumps(example_result, ensure_ascii=False) + '\n')
evaluating on test set...
100%|█████████████████████████████| 251/251 [00:15<00:00, 15.95it/s]
100%|█████████████████████████████| 1002/1002 [00:00<00:00, 3243.72it/s]
F1: 69.10 EM: 31.47 AVG: 50.29
saving predicted results...
可以看到,最终问答模型在测试集上取得了 F1 值 69.10、EM 值 31.47 的结果。考虑到我们只使用了基础版本的 BERT 模型,并且只训练了 3 轮,这已经是一个不错的结果了。
我们打开保存预测结果的 test_data_pred.json,其中每一行对应一个样本,sentence 对应原文,pred_label 对应预测出的实体,true_label 对应标注实体信息。
{
"id": "TRIAL_800_QUERY_0",
"title": "泡泡战士",
"context": "基于《跑跑卡丁车》与《泡泡堂》上所开发的游戏,由韩国Nexon开发与发行。中国大陆由盛大游戏运营,这是Nexon时隔6年再次授予盛大网络其游戏运营权。台湾由游戏橘子运营。玩家以水枪、小枪、锤子或是水炸弹泡封敌人(玩家或NPC),即为一泡封,将水泡击破为一踢爆。若水泡未在时间内踢爆,则会从水泡中释放或被队友救援(即为一救援)。每次泡封会减少生命数,生命数耗完即算为踢爆。重生者在一定时间内为无敌状态,以踢爆数计分较多者获胜,规则因模式而有差异。以2V2、4V4随机配对的方式,玩家可依胜场数爬牌位(依序为原石、铜牌、银牌、金牌、白金、钻石、大师) ,可选择经典、热血、狙击等模式进行游戏。若游戏中离,则4分钟内不得进行配对(每次中离+4分钟)。开放时间为暑假或寒假期间内不定期开放,8人经典模式随机配对,采计分方式,活动时间内分数越多,终了时可依该名次获得奖励。",
"question": "生命数耗完即算为什么?",
"answers": {
"text": ["踢爆"],
"answer_start": [127]
},
"prediction_text": "踢爆",
"answer_start": 182
}
...
至此,我们使用 Transformers 库进行抽取式问答任务就全部完成了!
代码
与之前一样,我们按照功能将代码拆分成模块并且存放在不同的文件中,整理后的代码存储在 Github: How-to-use-Transformers/src/sequence_labeling_extractiveQA_cmrc/
与 Transformers 库类似,我们将模型损失的计算也包含进模型本身,这样在训练循环中我们就可以直接使用模型返回的损失进行反向传播。
运行 run_extractiveQA.sh 脚本即可进行训练。如果要进行测试或者将模型输出的翻译结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 BERT 模型在测试集上的 F1 和 EM 值分别为 67.96 和 31.84 (Nvidia Tesla V100, batch=4)。
第十三章:Prompting 情感分析
本文我们将运用 Transformers 库来完成情感分析任务,并且使用当前流行的 Prompting 方法。Prompting 方法的核心思想就是借助模板将问题转换为与预训练任务类似的形式来处理。
例如要判断标题“American Duo Wins Opening Beach Volleyball Match”的新闻类别,就可以应用模板“This is a [MASK] News: x”将其转换为“This is a [MASK] News: American Duo Wins Opening Beach Volleyball Match”,然后送入到包含 MLM (Mask Language Modeling) 预训练任务的模型中预测 [MASK] 对应的词,最后将词映射到新闻类别(比如“Sports”对应“体育”类)。
如果你对 Prompting 概念不是很清楚,强烈建议先阅读一下《Prompt 方法简介》。
下面我们以情感分析任务为例,运用 Transformers 库手工构建一个基于 Prompt 的模型来完成任务。
13.1 准备数据
这里我们选择中文情感分析语料库 ChnSentiCorp 作为数据集,其包含各类网络评论接近一万条,可以从百度 ERNIE 示例仓库或者本仓库下载。
语料已经划分好了训练集、验证集、测试集(分别包含 9600、1200、1200 条评论),一行是一个样本,使用 TAB 分隔评论和对应的标签,“0”表示消极,“1”表示积极。例如:
选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般 1
...
构建数据集
与之前一样,我们首先编写继承自 Dataset 类的自定义数据集用于组织样本和标签。
from torch.utils.data import Dataset
class ChnSentiCorp(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
items = line.strip().split('\t')
assert len(items) == 2
Data[idx] = {
'comment': items[0],
'label': items[1]
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = ChnSentiCorp('data/ChnSentiCorp/train.txt')
valid_data = ChnSentiCorp('data/ChnSentiCorp/dev.txt')
test_data = ChnSentiCorp('data/ChnSentiCorp/test.txt')
下面我们输出数据集的尺寸,并且打印出一个训练样本:
print(f'train set size: {len(train_data)}')
print(f'valid set size: {len(valid_data)}')
print(f'test set size: {len(test_data)}')
print(next(iter(train_data)))
train set size: 9600
valid set size: 1200
test set size: 1200
{
'comment': '选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般',
'label': '1'
}
最常见的 Prompting 方法就是借助模板将问题转换为 MLM 任务来解决。这里我们定义模板形式为“总体上来说很 [MASK]。x”,其中 x 表示评论文本,并且规定如果 [MASK] 被预测为“好”就判定情感为“积极”,如果预测为“差”就判定为“消极”,即“积极”和“消极”标签对应的 label word 分别为“好”和“差”。
可以看到,MLM 任务与序列标注任务很相似,也是对 token 进行分类,并且类别是整个词表,不同之处在于 MLM 任务只需要对文中特殊的 [MASK] token 进行标注。因此在处理数据时我们需要:
- 记录下模板中所有 token 位置,以便在模型的输出序列中将它们的表示取出。
[MASK]
- 记录下 label word 对应的 token ID,因为我们实际上只关心模型在这些词语上的预测结果。
下面我们首先编写模板和 verbalizer 对应的函数:
def get_prompt(x):
prompt = f'总体上来说很[MASK]。{x}'
return {
'prompt': prompt,
'mask_offset': prompt.find('[MASK]')
}
def get_verbalizer(tokenizer):
return {
'pos': {'token': '好', 'id': tokenizer.convert_tokens_to_ids("好")},
'neg': {'token': '差', 'id': tokenizer.convert_tokens_to_ids("差")}
}
这里由于模板中只包含一个 [MASK] token,因此我们直接通过 str.find() 函数获取其位置,如果模板中包含多个 [MASK] token,就需要把他们的位置都记录下来。verbalizer 记录了从标签到对应 label word 的映射,这里我们通过 tokenizer.convert_tokens_to_ids() 来获取 label word 对应的 token ID。例如,第一个样本转换后的模板为:
from transformers import AutoTokenizer
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
comment = '这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
print('verbalizer:', get_verbalizer(tokenizer))
prompt_data = get_prompt(comment)
prompt, mask_offset = prompt_data['prompt'], prompt_data['mask_offset']
encoding = tokenizer(prompt, truncation=True)
tokens = encoding.tokens()
mask_idx = encoding.char_to_token(mask_offset)
print('prompt:', prompt)
print('prompt tokens:', tokens)
print('mask idx:', mask_idx)
verbalizer: {'pos': {'token': '好', 'id': 1962}, 'neg': {'token': '差', 'id': 2345}}
prompt: 总体上来说很[MASK]。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。
prompt tokens: ['[CLS]', '总', '体', '上', '来', '说', '很', '[MASK]', '。', '这', '个', '宾', '馆', '比', '较', '陈', '旧', '了', ',', '特', '价', '的', '房', '间', '也', '很', '一', '般', '。', '总', '体', '来', '说', '一', '般', '。', '[SEP]']
mask idx: 7
可以看到,BERT 分词器正确地将“[MASK]”识别为一个 token,并且记录下 [MASK] token 在序列中的索引。
但是这种做法要求我们能够从词表中找到合适的 label word 来代表每一个类别,并且 label word 只能包含一个 token,而很多时候这是无法实现的。因此,另一种常见做法是为每个类别构建一个可学习的虚拟 token(又称伪 token),然后运用类别描述来初始化虚拟 token 的表示,最后使用这些虚拟 token 来扩展模型的 MLM 头。
例如,这里我们可以为“积极”和“消极”构建专门的虚拟 token “[POS]”和“[NEG]”,并且设置对应的类别描述为“好的、优秀的、正面的评价、积极的态度”和“差的、糟糕的、负面的评价、消极的态度”。下面我们扩展一下上面的 verbalizer 函数,添加一个 vtype 参数来区分两种 verbalizer 类型:
def get_verbalizer(tokenizer, vtype):
assert vtype in ['base', 'virtual']
return {
'pos': {'token': '好', 'id': tokenizer.convert_tokens_to_ids("好")},
'neg': {'token': '差', 'id': tokenizer.convert_tokens_to_ids("差")}
} if vtype == 'base' else {
'pos': {
'token': '[POS]', 'id': tokenizer.convert_tokens_to_ids("[POS]"),
'description': '好的、优秀的、正面的评价、积极的态度'
},
'neg': {
'token': '[NEG]', 'id': tokenizer.convert_tokens_to_ids("[NEG]"),
'description': '差的、糟糕的、负面的评价、消极的态度'
}
}
vtype = 'virtual'
# add label words
if vtype == 'virtual':
tokenizer.add_special_tokens({'additional_special_tokens': ['[POS]', '[NEG]']})
print('verbalizer:', get_verbalizer(tokenizer, vtype=vtype))
verbalizer: {
'pos': {'token': '[POS]', 'id': 21128, 'description': '好的、优秀的、正面的评价、积极的态度'},
'neg': {'token': '[NEG]', 'id': 21129, 'description': '差的、糟糕的、负面的评价、消极的态度'}
}
注意:“[POS]”和“[NEG]”是我们新添加的 token,因此如模型与分词器中介绍的那样,我们首先需要通过 tokenizer.add_special_tokens() 将这两个 token 添加进模型的词表,然后才能获取它们的 token ID。
Prompting 方法实际输入的是转换后的模板,而不是原始文本,因此我们首先使用模板函数 get_prompt() 来更新数据集:
class ChnSentiCorp(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
items = line.strip().split('\t')
assert len(items) == 2
prompt_data = get_prompt(items[0])
Data[idx] = {
'comment': items[0],
'prompt': prompt_data['prompt'],
'mask_offset': prompt_data['mask_offset'],
'label': items[1]
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
在实际应用场景下,模板的转换过程可能比文本中的要复杂得多(可能非常耗时),因此这里我们将其放置于数据集函数而不是 DataLoader 中,使得数据集返回的就是转换后的样本。
同样地,我们通过 print(next(iter(train_data))) 打印出一个训练样本:
{
'comment': '选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般',
'prompt': '总体上来说很[MASK]。选择珠江花园的原因就是方便,有电动扶梯直接到达海边,周围餐馆、食廊、商场、超市、摊位一应俱全。酒店装修一般,但还算整洁。 泳池在大堂的屋顶,因此很小,不过女儿倒是喜欢。 包的早餐是西式的,还算丰富。 服务吗,一般',
'mask_offset': 6,
'label': '1'
}
可以看到输出的是转换后的模板,并且标记出了 [MASK] 在文本中的位置,符合我们的预期。
数据预处理
与之前一样,接下来我们就通过 DataLoader 库来按批(batch)加载数据,将文本转换为模型可以接受的 token IDs。
import torch
from torch.utils.data import DataLoader
from transformers import AutoTokenizer
vtype = 'base'
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
if vtype == 'virtual':
tokenizer.add_special_tokens({'additional_special_tokens': ['[POS]', '[NEG]']})
verbalizer = get_verbalizer(tokenizer, vtype='base')
pos_id, neg_id = verbalizer['pos']['id'], verbalizer['neg']['id']
def collote_fn(batch_samples):
batch_sentences, batch_mask_idxs, batch_labels = [], [], []
for sample in batch_samples:
batch_sentences.append(sample['prompt'])
encoding = tokenizer(sample['prompt'], truncation=True)
mask_idx = encoding.char_to_token(sample['mask_offset'])
assert mask_idx is not None
batch_mask_idxs.append(mask_idx)
batch_labels.append(int(sample['label']))
batch_inputs = tokenizer(
batch_sentences,
max_length=max_length,
padding=True,
truncation=True,
return_tensors="pt"
)
label_word_id = [neg_id, pos_id]
return {
'batch_inputs': batch_inputs,
'batch_mask_idxs': batch_mask_idxs,
'label_word_id': label_word_id,
'labels': batch_labels
}
train_dataloader = DataLoader(train_data, batch_size=4, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
test_dataloader = DataLoader(test_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
batch_data = next(iter(train_dataloader))
print('batch_X shape:', {k: v.shape for k, v in batch_data['batch_inputs'].items()})
print(batch_data['batch_inputs'])
print(batch_data['batch_mask_idxs'])
print(batch_data['label_word_id'])
print(batch_data['labels'])
batch_X shape: {
'input_ids': torch.Size([4, 201]),
'token_type_ids': torch.Size([4, 201]),
'attention_mask': torch.Size([4, 201])
}
{
'input_ids': tensor([
[ 101, 2600, 860, 677, 3341, 6432, 2523, 103, 511, 6862, 2428, 2923,
...],
[ 101, 2600, 860, 677, 3341, 6432, 2523, 103, 511, 3193, 2218, 2682,
...],
[ 101, 2600, 860, 677, 3341, 6432, 2523, 103, 511, 2523, 4788, 4638,
...],
[ 101, 2600, 860, 677, 3341, 6432, 2523, 103, 511, 3119, 1168, 6573,
...]
]),
'token_type_ids': tensor([...]),
'attention_mask': tensor([...])
}
[7, 7, 7, 7]
[2345, 1962]
[0, 1, 1, 1]
可以看到,DataLoader 按照我们设置的 batch size 每次对 4 个样本进行编码,将 token 序列填充到了相同的长度。这里由于我们对所有样本都添加相同的“前缀”,因此 [MASK] token 的索引都为 7。
这里我们设置 verbalizer 为普通类型,因此 label_word_id 为 [2345, 1962],分别是“差”和“好”对应的 token ID。你也可以通过设置 vtype = 'virtual' 获取虚拟 token 类型的 verbalizer,此时模板不会有变化,但是 label_word_id 会变为 [21129, 21128],分别对应我们添加的“[NEG]”和“[POS]” token。
13.2 训练模型
构建模型
对于 MLM 任务,可以直接使用 Transformers 库封装好的 AutoModelForMaskedLM 类。由于 BERT 已经在 MLM 任务上进行了预训练,因此借助模板我们甚至可以在不微调的情况下 (Zero-shot) 直接使用模型来预测情感极性。例如对我们的第一个样本:
import torch
from transformers import AutoModelForMaskedLM
checkpoint = "bert-base-chinese"
model = AutoModelForMaskedLM.from_pretrained(checkpoint)
text = "总体上来说很[MASK]。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。"
inputs = tokenizer(text, return_tensors="pt")
token_logits = model(**inputs).logits
# Find the location of [MASK] and extract its logits
mask_token_index = torch.where(inputs["input_ids"] == tokenizer.mask_token_id)[1]
mask_token_logits = token_logits[0, mask_token_index, :]
# Pick the [MASK] candidates with the highest logits
top_5_tokens = torch.topk(mask_token_logits, 5, dim=1).indices[0].tolist()
for token in top_5_tokens:
print(f"'>>> {text.replace(tokenizer.mask_token, tokenizer.decode([token]))}'")
'>>> 总体上来说很好。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很棒。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很差。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很般。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
'>>> 总体上来说很赞。这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般。'
可以看到,BERT 模型成功地将 [MASK] token 预测成了我们预期的表意词“好”。这里我们还打印出了其他几个大概率的预测词,大部分都具有积极的情感(“好”、“棒”、“赞”)。
当然,这种方式不够灵活,因此像之前章节中一样,本文采用继承 Transformers 库预训练模型的方式来手工构建模型:
from torch import nn
from transformers.activations import ACT2FN
from transformers import AutoConfig
from transformers import BertPreTrainedModel, BertModel
def batched_index_select(input, dim, index):
for i in range(1, len(input.shape)):
if i != dim:
index = index.unsqueeze(i)
expanse = list(input.shape)
expanse[0] = -1
expanse[dim] = -1
index = index.expand(expanse)
return torch.gather(input, dim, index)
class BertPredictionHeadTransform(nn.Module):
def __init__(self, config):
super().__init__()
self.dense = nn.Linear(config.hidden_size, config.hidden_size)
if isinstance(config.hidden_act, str):
self.transform_act_fn = ACT2FN[config.hidden_act]
else:
self.transform_act_fn = config.hidden_act
self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states = self.dense(hidden_states)
hidden_states = self.transform_act_fn(hidden_states)
hidden_states = self.LayerNorm(hidden_states)
return hidden_states
class BertLMPredictionHead(nn.Module):
def __init__(self, config):
super().__init__()
self.transform = BertPredictionHeadTransform(config)
self.decoder = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
self.bias = nn.Parameter(torch.zeros(config.vocab_size))
self.decoder.bias = self.bias
def forward(self, hidden_states):
hidden_states = self.transform(hidden_states)
hidden_states = self.decoder(hidden_states)
return hidden_states
class BertOnlyMLMHead(nn.Module):
def __init__(self, config):
super().__init__()
self.predictions = BertLMPredictionHead(config)
def forward(self, sequence_output: torch.Tensor) -> torch.Tensor:
prediction_scores = self.predictions(sequence_output)
return prediction_scores
class BertForPrompt(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.cls = BertOnlyMLMHead(config)
# Initialize weights and apply final processing
self.post_init()
def get_output_embeddings(self):
return self.cls.predictions.decoder
def set_output_embeddings(self, new_embeddings):
self.cls.predictions.decoder = new_embeddings
def forward(self, batch_inputs, batch_mask_idxs, label_word_id, labels=None):
bert_output = self.bert(**batch_inputs)
sequence_output = bert_output.last_hidden_state
batch_mask_reps = batched_index_select(sequence_output, 1, batch_mask_idxs.unsqueeze(-1)).squeeze(1)
prediction_scores = self.cls(batch_mask_reps)
loss = None
if labels is not None:
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(prediction_scores, labels)
return loss, prediction_scores[:, label_word_id]
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
config = AutoConfig.from_pretrained(checkpoint)
model = BertForPrompt.from_pretrained(checkpoint, config=config).to(device)
if vtype == 'virtual':
model.resize_token_embeddings(len(tokenizer))
print(f"initialize embeddings of {verbalizer['pos']['token']} and {verbalizer['neg']['token']}")
with torch.no_grad():
pos_tokenized = tokenizer(verbalizer['pos']['description'])
pos_tokenized_ids = tokenizer.convert_tokens_to_ids(pos_tokenized)
neg_tokenized = tokenizer(verbalizer['neg']['description'])
neg_tokenized_ids = tokenizer.convert_tokens_to_ids(neg_tokenized)
new_embedding = model.bert.embeddings.word_embeddings.weight[pos_tokenized_ids].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[pos_id, :] = new_embedding.clone().detach().requires_grad_(True)
new_embedding = model.bert.embeddings.word_embeddings.weight[neg_tokenized_ids].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[neg_id, :] = new_embedding.clone().detach().requires_grad_(True)
print(model)
Using cpu device
initialize embeddings of [POS] and [NEG]
BertForPrompt(
(bert): BertModel()
(cls): BertOnlyMLMHead(
(predictions): BertLMPredictionHead(
(transform): BertPredictionHeadTransform(
(dense): Linear(in_features=768, out_features=768, bias=True)
(transform_act_fn): GELUActivation()
(LayerNorm): LayerNorm((768,), eps=1e-12, elementwise_affine=True)
)
(decoder): Linear(in_features=768, out_features=21128, bias=True)
)
)
)
这里为了能够加载预训练好的 MLM head 参数,我们按照 Transformers 库中的模型结构来构建 BertForPrompt 模型。可以看到,BERT 自带的 MLM head 由两个部分组成:首先对所有 token 进行一个 768×768 的非线性映射(包括激活函数和 LayerNorm),然后使用一个 768×21128 的线性映射预测词表中每个 token 的分数。
如果采用虚拟 label word,我们除了向模型词表中添加“[POS]”和“[NEG]” token 以外, 还按照我们在 verbalizer 中设置的描述来初始化这两个 token 的嵌入。这里我们首先运用分词器将描述文本转换为对应的 token 列表 t1,t2,…,tn,然后初始化对应的表示为这些 token 嵌入的平均 1n∑i=1nE(ti),E 就是模型的 token embedding 矩阵。
由于 BERT 原始词表包含 21128 个 token,因此最终 MLM head 进行的是一个 21128 类的分类任务。如果我们设置 vtype = 'virtual' 采用虚拟 token 版本的 verbalizer,就会向词表中添加“[POS]”和“[NEG]”使得词表大小增加到 21130,此时再次运行上面的代码,就会看到输出张量大小 out_features 变为 21130 了。
注意向模型词表中添加 token 包含两个步骤:
- 通过
tokenizer.add_special_tokens()向分词器中添加 token。这样分词器就能够在分词时将这些词分为独立的 token;- 通过
model.resize_token_embeddings()扩展模型的词表大小。这两个步骤缺一不可!否则运行时就会出现错误。这里我们判断如果采用虚拟 label word,就调整模型词表大小。
注意与之前相比,本次我们构建的 BertForPrompt 模型中增加了两个特殊的函数:
get_output_embeddings()和set_output_embeddings(),负责调整模型的 MLM head。如果删除这两个函数,那么在调用
model.resize_token_embeddings()时,就仅仅会调整模型词表的大小,而不会调整 MLM head,即运行上面的代码输出的张量维度依然是 21128。如果你不需要预测新添加 token 在 mask 位置的概率,那么即使删除这两个函数,代码也能正常运行,但是对于本文这种需要预测的情况就不行了。因此,在绝大部分情况下,你都应该添加这两个函数。
为了让模型适配我们的任务,这里首先通过 batched_index_select 函数从 BERT 的输出序列中抽取出 [MASK] token 对应的表示,在运用 MLM head 预测出该 [MASK] token 对应词表中每个 token 的分数之后,我们只返回类别对应 label words 的分数用于分类。
为了测试模型的操作是否符合预期,我们尝试将一个 batch 的数据送入模型:
def to_device(batch_data):
new_batch_data = {}
for k, v in batch_data.items():
if k == 'batch_inputs':
new_batch_data[k] = {
k_: v_.to(device) for k_, v_ in v.items()
}
elif k == 'label_word_id':
new_batch_data[k] = v
else:
new_batch_data[k] = torch.tensor(v).to(device)
return new_batch_data
batch_data = next(iter(train_dataloader))
batch_data = to_device(batch_data)
_, outputs = model(**batch_data)
print(outputs.shape)
torch.Size([4, 2])
模型对每个样本都应该输出“消极”和“积极”两个类别对应 label word 的预测 logits 值,因此这里模型的输出尺寸 4×2 符合预期。
优化模型参数
与之前一样,我们将每一轮 Epoch 分为“训练循环”和“验证/测试循环”,在训练循环中计算损失、优化模型参数,在验证/测试循环中评估模型性能。下面我们首先实现训练循环。
因为对标签词的预测实际上就是对类别的预测,所以这里模型的输出与同义句判断任务中介绍过的普通文本分类模型完全一致,损失也同样是通过在类别预测和答案标签之间计算交叉熵:
from tqdm.auto import tqdm
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_step_num = epoch * len(dataloader)
model.train()
for step, batch_data in enumerate(dataloader, start=1):
batch_data = to_device(batch_data)
outputs = model(**batch_data)
loss = outputs[0]
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_step_num + step):>7f}')
progress_bar.update(1)
return total_loss
验证/测试循环负责评估模型的性能。对于分类任务最常见的就是通过精确率、召回率、F1值 (P / R / F1) 指标来评估每个类别的预测性能,然后再通过宏/微 F1 值 (Macro-F1/Micro-F1) 来评估整体分类性能。
这里我们借助机器学习包 sklearn 提供的 classification_report 函数来输出这些指标,例如:
from sklearn.metrics import classification_report
y_true = [1, 1, 0, 1, 2, 1, 0, 2, 1, 1, 0, 1, 0]
y_pred = [1, 0, 0, 1, 2, 0, 1, 1, 1, 0, 0, 1, 0]
print(classification_report(y_true, y_pred, output_dict=False))
precision recall f1-score support
0 0.50 0.75 0.60 4
1 0.67 0.57 0.62 7
2 1.00 0.50 0.67 2
accuracy 0.62 13
macro avg 0.72 0.61 0.63 13
weighted avg 0.67 0.62 0.62 13
因此在验证/测试循环中,我们只需要汇总模型对所有样本的预测结果和答案标签,然后送入到 classification_report 中计算各项分类指标:
from sklearn.metrics import classification_report
def test_loop(dataloader, model):
true_labels, predictions = [], []
model.eval()
with torch.no_grad():
for batch_data in tqdm(dataloader):
true_labels += batch_data['labels']
batch_data = to_device(batch_data)
outputs = model(**batch_data)
pred = outputs[1]
predictions += pred.argmax(dim=-1).cpu().numpy().tolist()
metrics = classification_report(true_labels, predictions, output_dict=True)
pos_p, pos_r, pos_f1 = metrics['1']['precision'], metrics['1']['recall'], metrics['1']['f1-score']
neg_p, neg_r, neg_f1 = metrics['0']['precision'], metrics['0']['recall'], metrics['0']['f1-score']
macro_f1, micro_f1 = metrics['macro avg']['f1-score'], metrics['weighted avg']['f1-score']
print(f"pos: {pos_p*100:>0.2f} / {pos_r*100:>0.2f} / {pos_f1*100:>0.2f}, neg: {neg_p*100:>0.2f} / {neg_r*100:>0.2f} / {neg_f1*100:>0.2f}")
print(f"Macro-F1: {macro_f1*100:>0.2f} Micro-F1: {micro_f1*100:>0.2f}\n")
return metrics
为了方便后续保存验证集上最好的模型,这里我们还返回了评估结果。
保存模型
与之前一样,我们会根据模型在验证集上的性能来调整超参数以及选出最好的模型权重,然后将选出的模型应用于测试集以评估最终的性能。这里我们继续使用 AdamW 优化器,并且通过 get_scheduler() 函数定义学习率调度器:
from transformers import AdamW, get_scheduler
learning_rate = 1e-5
epoch_num = 3
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_f1_score = 0.
for epoch in range(epoch_num):
print(f"Epoch {epoch+1}/{epoch_num}\n" + 30 * "-")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, epoch, total_loss)
valid_scores = test_loop(valid_dataloader, model)
macro_f1, micro_f1 = valid_scores['macro avg']['f1-score'], valid_scores['weighted avg']['f1-score']
f1_score = (macro_f1 + micro_f1) / 2
if f1_score > best_f1_score:
best_f1_score = f1_score
print('saving new weights...\n')
torch.save(
model.state_dict(),
f'epoch_{epoch+1}_valid_macrof1_{(macro_f1*100):0.3f}_microf1_{(micro_f1*100):0.3f}_model_weights.bin'
)
print("Done!")
在开始训练之前,我们先评估一下没有微调的 BERT 模型在测试集上的性能。
test_data = ChnSentiCorp('data/ChnSentiCorp/test.txt')
test_dataloader = DataLoader(test_data, batch_size=4, shuffle=False, collate_fn=collote_fn)
test_loop(test_dataloader, model)
100%|█████████████████████████████████| 300/300 [01:46<00:00, 2.82it/s]
pos: 53.05 / 100.00 / 69.33, neg: 100.00 / 9.12 / 16.72
Macro-F1: 43.02 Micro-F1: 43.37
可以看到,得益于 Prompt 方法,不经微调的 BERT 模型也已经具有初步的情感分析能力,在测试集上的 Macro-F1 和 Micro-F1 值分别为 43.02 和 43.37。有趣的是,“积极”类别的召回率和“消极”类别的准确率都为 100%,这说明 BERT 对大部分样本都倾向于判断为“积极”类(可能预训练时看到的积极性文本更多吧)。
注意如果采用虚拟 label word(设置
vtype = 'virtual'),模型是无法直接进行预测的。在扩展了词表之后,MLM head 的参数矩阵尺寸也会进行调整,新加入的参数都是随机初始化的,此时必须进行微调才能让 MLM head 正常工作。
下面,我们正式开始训练,完整的训练代码如下:
import os
import random
import numpy as np
import torch
from torch import nn
from torch.utils.data import Dataset, DataLoader
from transformers import AutoTokenizer, AutoConfig
from transformers import BertPreTrainedModel, BertModel
from transformers.activations import ACT2FN
from transformers import AdamW, get_scheduler
from sklearn.metrics import classification_report
from tqdm.auto import tqdm
vtype = 'base'
max_length = 512
batch_size = 4
learning_rate = 1e-5
epoch_num = 3
def seed_everything(seed=1029):
random.seed(seed)
os.environ['PYTHONHASHSEED'] = str(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
torch.backends.cudnn.deterministic = True
def get_prompt(x):
prompt = f'总体上来说很[MASK]。{x}'
return {
'prompt': prompt,
'mask_offset': prompt.find('[MASK]')
}
def get_verbalizer(tokenizer, vtype):
assert vtype in ['base', 'virtual']
return {
'pos': {'token': '好', 'id': tokenizer.convert_tokens_to_ids("好")},
'neg': {'token': '差', 'id': tokenizer.convert_tokens_to_ids("差")}
} if vtype == 'base' else {
'pos': {
'token': '[POS]', 'id': tokenizer.convert_tokens_to_ids("[POS]"),
'description': '好的、优秀的、正面的评价、积极的态度'
},
'neg': {
'token': '[NEG]', 'id': tokenizer.convert_tokens_to_ids("[NEG]"),
'description': '差的、糟糕的、负面的评价、消极的态度'
}
}
seed_everything(12)
device = 'cuda' if torch.cuda.is_available() else 'cpu'
print(f'Using {device} device')
class ChnSentiCorp(Dataset):
def __init__(self, data_file):
self.data = self.load_data(data_file)
def load_data(self, data_file):
Data = {}
with open(data_file, 'rt', encoding='utf-8') as f:
for idx, line in enumerate(f):
items = line.strip().split('\t')
assert len(items) == 2
prompt_data = get_prompt(items[0])
Data[idx] = {
'prompt': prompt_data['prompt'],
'mask_offset': prompt_data['mask_offset'],
'label': items[1]
}
return Data
def __len__(self):
return len(self.data)
def __getitem__(self, idx):
return self.data[idx]
train_data = ChnSentiCorp('data/ChnSentiCorp/train.txt')
valid_data = ChnSentiCorp('data/ChnSentiCorp/dev.txt')
test_data = ChnSentiCorp('data/ChnSentiCorp/test.txt')
checkpoint = "bert-base-chinese"
tokenizer = AutoTokenizer.from_pretrained(checkpoint)
if vtype == 'virtual':
tokenizer.add_special_tokens({'additional_special_tokens': ['[POS]', '[NEG]']})
verbalizer = get_verbalizer(tokenizer, vtype=vtype)
pos_id, neg_id = verbalizer['pos']['id'], verbalizer['neg']['id']
def collote_fn(batch_samples):
batch_sentences, batch_mask_idxs, batch_labels = [], [], []
for sample in batch_samples:
batch_sentences.append(sample['prompt'])
encoding = tokenizer(sample['prompt'], truncation=True)
mask_idx = encoding.char_to_token(sample['mask_offset'])
assert mask_idx is not None
batch_mask_idxs.append(mask_idx)
batch_labels.append(int(sample['label']))
batch_inputs = tokenizer(
batch_sentences,
max_length=max_length,
padding=True,
truncation=True,
return_tensors="pt"
)
label_word_id = [neg_id, pos_id]
return {
'batch_inputs': batch_inputs,
'batch_mask_idxs': batch_mask_idxs,
'label_word_id': label_word_id,
'labels': batch_labels
}
train_dataloader = DataLoader(train_data, batch_size=batch_size, shuffle=True, collate_fn=collote_fn)
valid_dataloader = DataLoader(valid_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
test_dataloader = DataLoader(test_data, batch_size=batch_size, shuffle=False, collate_fn=collote_fn)
def batched_index_select(input, dim, index):
for i in range(1, len(input.shape)):
if i != dim:
index = index.unsqueeze(i)
expanse = list(input.shape)
expanse[0] = -1
expanse[dim] = -1
index = index.expand(expanse)
return torch.gather(input, dim, index)
class BertPredictionHeadTransform(nn.Module):
def __init__(self, config):
super().__init__()
self.dense = nn.Linear(config.hidden_size, config.hidden_size)
if isinstance(config.hidden_act, str):
self.transform_act_fn = ACT2FN[config.hidden_act]
else:
self.transform_act_fn = config.hidden_act
self.LayerNorm = nn.LayerNorm(config.hidden_size, eps=config.layer_norm_eps)
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
hidden_states = self.dense(hidden_states)
hidden_states = self.transform_act_fn(hidden_states)
hidden_states = self.LayerNorm(hidden_states)
return hidden_states
class BertLMPredictionHead(nn.Module):
def __init__(self, config):
super().__init__()
self.transform = BertPredictionHeadTransform(config)
self.decoder = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
self.bias = nn.Parameter(torch.zeros(config.vocab_size))
self.decoder.bias = self.bias
def forward(self, hidden_states):
hidden_states = self.transform(hidden_states)
hidden_states = self.decoder(hidden_states)
return hidden_states
class BertOnlyMLMHead(nn.Module):
def __init__(self, config):
super().__init__()
self.predictions = BertLMPredictionHead(config)
def forward(self, sequence_output: torch.Tensor) -> torch.Tensor:
prediction_scores = self.predictions(sequence_output)
return prediction_scores
class BertForPrompt(BertPreTrainedModel):
def __init__(self, config):
super().__init__(config)
self.bert = BertModel(config, add_pooling_layer=False)
self.cls = BertOnlyMLMHead(config)
# Initialize weights and apply final processing
self.post_init()
def get_output_embeddings(self):
return self.cls.predictions.decoder
def set_output_embeddings(self, new_embeddings):
self.cls.predictions.decoder = new_embeddings
def forward(self, batch_inputs, batch_mask_idxs, label_word_id, labels=None):
bert_output = self.bert(**batch_inputs)
sequence_output = bert_output.last_hidden_state
batch_mask_reps = batched_index_select(sequence_output, 1, batch_mask_idxs.unsqueeze(-1)).squeeze(1)
pred_scores = self.cls(batch_mask_reps)[:, label_word_id]
loss = None
if labels is not None:
loss_fn = nn.CrossEntropyLoss()
loss = loss_fn(pred_scores, labels)
return loss, pred_scores
config = AutoConfig.from_pretrained(checkpoint)
model = BertForPrompt.from_pretrained(checkpoint, config=config).to(device)
if vtype == 'virtual':
model.resize_token_embeddings(len(tokenizer))
print(f"initialize embeddings of {verbalizer['pos']['token']} and {verbalizer['neg']['token']}")
with torch.no_grad():
pos_tokenized = tokenizer(verbalizer['pos']['description'])
pos_tokenized_ids = tokenizer.convert_tokens_to_ids(pos_tokenized)
neg_tokenized = tokenizer(verbalizer['neg']['description'])
neg_tokenized_ids = tokenizer.convert_tokens_to_ids(neg_tokenized)
new_embedding = model.bert.embeddings.word_embeddings.weight[pos_tokenized_ids].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[pos_id, :] = new_embedding.clone().detach().requires_grad_(True)
new_embedding = model.bert.embeddings.word_embeddings.weight[neg_tokenized_ids].mean(axis=0)
model.bert.embeddings.word_embeddings.weight[neg_id, :] = new_embedding.clone().detach().requires_grad_(True)
def to_device(batch_data):
new_batch_data = {}
for k, v in batch_data.items():
if k == 'batch_inputs':
new_batch_data[k] = {
k_: v_.to(device) for k_, v_ in v.items()
}
elif k == 'label_word_id':
new_batch_data[k] = v
else:
new_batch_data[k] = torch.tensor(v).to(device)
return new_batch_data
def train_loop(dataloader, model, optimizer, lr_scheduler, epoch, total_loss):
progress_bar = tqdm(range(len(dataloader)))
progress_bar.set_description(f'loss: {0:>7f}')
finish_batch_num = epoch * len(dataloader)
model.train()
for step, batch_data in enumerate(dataloader, start=1):
batch_data = to_device(batch_data)
outputs = model(**batch_data)
loss = outputs[0]
optimizer.zero_grad()
loss.backward()
optimizer.step()
lr_scheduler.step()
total_loss += loss.item()
progress_bar.set_description(f'loss: {total_loss/(finish_batch_num + step):>7f}')
progress_bar.update(1)
return total_loss
def test_loop(dataloader, model):
true_labels, predictions = [], []
model.eval()
with torch.no_grad():
for batch_data in tqdm(dataloader):
true_labels += batch_data['labels']
batch_data = to_device(batch_data)
outputs = model(**batch_data)
pred = outputs[1]
predictions += pred.argmax(dim=-1).cpu().numpy().tolist()
metrics = classification_report(true_labels, predictions, output_dict=True)
pos_p, pos_r, pos_f1 = metrics['1']['precision'], metrics['1']['recall'], metrics['1']['f1-score']
neg_p, neg_r, neg_f1 = metrics['0']['precision'], metrics['0']['recall'], metrics['0']['f1-score']
macro_f1, micro_f1 = metrics['macro avg']['f1-score'], metrics['weighted avg']['f1-score']
print(f"pos: {pos_p*100:>0.2f} / {pos_r*100:>0.2f} / {pos_f1*100:>0.2f}, neg: {neg_p*100:>0.2f} / {neg_r*100:>0.2f} / {neg_f1*100:>0.2f}")
print(f"Macro-F1: {macro_f1*100:>0.2f} Micro-F1: {micro_f1*100:>0.2f}\n")
return metrics
optimizer = AdamW(model.parameters(), lr=learning_rate)
lr_scheduler = get_scheduler(
"linear",
optimizer=optimizer,
num_warmup_steps=0,
num_training_steps=epoch_num*len(train_dataloader),
)
total_loss = 0.
best_f1_score = 0.
for epoch in range(epoch_num):
print(f"Epoch {epoch+1}/{epoch_num}\n" + 30 * "-")
total_loss = train_loop(train_dataloader, model, optimizer, lr_scheduler, epoch, total_loss)
valid_scores = test_loop(valid_dataloader, model)
macro_f1, micro_f1 = valid_scores['macro avg']['f1-score'], valid_scores['weighted avg']['f1-score']
f1_score = (macro_f1 + micro_f1) / 2
if f1_score > best_f1_score:
best_f1_score = f1_score
print('saving new weights...\n')
torch.save(
model.state_dict(),
f'epoch_{epoch+1}_valid_macrof1_{(macro_f1*100):0.3f}_microf1_{(micro_f1*100):0.3f}_model_weights.bin'
)
print("Done!")
Using cuda device
Epoch 1/3
------------------------------
loss: 0.249279: 100%|█████████████████| 2400/2400 [01:42<00:00, 23.43it/s]
100%|█████████████████████████████████| 300/300 [00:03<00:00, 89.46it/s]
pos: 89.83 / 95.28 / 92.47, neg: 95.10 / 89.46 / 92.19
Macro-F1: 92.33 Micro-F1: 92.33
saving new weights...
Epoch 2/3
------------------------------
loss: 0.183906: 100%|█████████████████| 2400/2400 [01:42<00:00, 23.33it/s]
100%|█████████████████████████████████| 300/300 [00:03<00:00, 89.00it/s]
pos: 94.94 / 94.94 / 94.94, neg: 95.06 / 95.06 / 95.06
Macro-F1: 95.00 Micro-F1: 95.00
saving new weights...
Epoch 3/3
------------------------------
loss: 0.138809: 100%|█████████████████| 2400/2400 [01:42<00:00, 23.38it/s]
100%|█████████████████████████████████| 300/300 [00:03<00:00, 89.19it/s]
pos: 95.37 / 93.76 / 94.56, neg: 94.00 / 95.55 / 94.77
Macro-F1: 94.66 Micro-F1: 94.67
Done!
可以看到,随着训练的进行,模型在验证集上的 Macro-F1 和 Micro-F1 值先升后降。因此 3 轮 Epoch 结束后,会在目录下保存 2 个模型权重:
epoch_1_valid_macrof1_92.331_microf1_92.329_model_weights.bin
epoch_2_valid_macrof1_94.999_microf1_95.000_model_weights.bin
至此,我们对 Prompt 情感分析模型的训练就完成了。
13.3 测试模型
训练完成后,我们加载在验证集上性能最优的模型权重,汇报其在测试集上的性能。
import json
model.load_state_dict(torch.load('epoch_2_valid_macrof1_94.999_microf1_95.000_model_weights.bin'))
model.eval()
with torch.no_grad():
print('evaluating on test set...')
true_labels, predictions, probs = [], [], []
for batch_data in tqdm(test_dataloader):
true_labels += batch_data['labels']
batch_data = to_device(batch_data)
outputs = model(**batch_data)
pred = outputs[1]
predictions += pred.argmax(dim=-1).cpu().numpy().tolist()
probs += torch.nn.functional.softmax(pred, dim=-1)
save_resluts = []
for s_idx in tqdm(range(len(test_data))):
save_resluts.append({
"comment": test_data[s_idx]['comment'],
"label": true_labels[s_idx],
"pred": predictions[s_idx],
"prob": {'neg': probs[s_idx][0].item(), 'pos': probs[s_idx][1].item()}
})
metrics = classification_report(true_labels, predictions, output_dict=True)
pos_p, pos_r, pos_f1 = metrics['1']['precision'], metrics['1']['recall'], metrics['1']['f1-score']
neg_p, neg_r, neg_f1 = metrics['0']['precision'], metrics['0']['recall'], metrics['0']['f1-score']
macro_f1, micro_f1 = metrics['macro avg']['f1-score'], metrics['weighted avg']['f1-score']
print(f"pos: {pos_p*100:>0.2f} / {pos_r*100:>0.2f} / {pos_f1*100:>0.2f}, neg: {neg_p*100:>0.2f} / {neg_r*100:>0.2f} / {neg_f1*100:>0.2f}")
print(f"Macro-F1: {macro_f1*100:>0.2f} Micro-F1: {micro_f1*100:>0.2f}\n")
print('saving predicted results...')
with open('test_data_pred.json', 'wt', encoding='utf-8') as f:
for example_result in save_resluts:
f.write(json.dumps(example_result, ensure_ascii=False) + '\n')
evaluating on test set...
100%|█████████████████████████████████| 300/300 [00:03<00:00, 89.36it/s]
100%|█████████████████████████████████| 1200/1200 [00:00<00:00, 68281.48it/s]
pos: 96.49 / 95.07 / 95.77, neg: 95.01 / 96.45 / 95.73
Macro-F1: 95.75 Micro-F1: 95.75
saving predicted results...
可以看到,经过微调,模型在测试集上的 Macro-F1 值从 43.02 提升到 95.75,Micro-F1 值从 43.37 提升到 95.75,证明了我们对模型的微调是成功的。
我们打开保存预测结果的 test_data_pred.json,其中每一行对应一个样本,comment 对应评论,label 对应标注标签,pred 对应预测出的标签,prediction 对应具体预测出的概率值。
{
"comment": "这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般",
"label": 1,
"pred": 0,
"prob": {
"neg": 0.9691997766494751,
"pos": 0.030800189822912216
}
}
...
至此,我们使用 Transformers 库进行 Prompt 情感分析就全部完成了!
当然,我们也可以通过设置 vtype = 'virtual' 使用虚拟 label words 来训练模型,最终在测试集上的性能为:
pos: 96.14 / 94.24 / 95.18, neg: 94.21 / 96.11 / 95.15
Macro-F1: 95.17 Micro-F1: 95.17可以看到,在该任务上,使用虚拟 label words 取得了与基础 verbalizer 类似的性能。
13.4 封装预测函数
我们训练模型的目的是为了能够给其他人提供服务。尤其对于不熟悉深度学习的普通开发者而言,需要的只是一个能够完成特定任务的接口。因此在大多数情况下,我们都应该将模型的预测过程封装为一个端到端 (End-to-End) 的函数:输入文本,输出结果:
def predict(model, tokenizer, comment, verbalizer):
prompt_data = get_prompt(comment)
prompt = prompt_data['prompt']
encoding = tokenizer(prompt, truncation=True)
mask_idx = encoding.char_to_token(prompt_data['mask_offset'])
assert mask_idx is not None
inputs = tokenizer(
prompt,
max_length=max_length,
padding=True,
truncation=True,
return_tensors="pt"
)
inputs = {
'batch_inputs': inputs,
'batch_mask_idxs': [mask_idx],
'label_word_id': [verbalizer['neg']['id'], verbalizer['pos']['id']]
}
inputs = to_device(inputs)
model.eval()
with torch.no_grad():
outputs = model(**inputs)
logits = outputs[1]
prob = torch.nn.functional.softmax(logits, dim=-1)
pred = logits.argmax(dim=-1)[0].item()
prob = prob[0][pred].item()
return pred, prob
下面我们尝试输出模型对测试集前 5 条数据的预测结果:
model.load_state_dict(torch.load('epoch_2_valid_macrof1_94.999_microf1_95.000_model_weights.bin'))
for i in range(5):
data = test_data[i]
pred, prob = predict(model, tokenizer, data['comment'], verbalizer)
print(f"{data['comment']}\nlabel: {data['label']}\tpred: {pred}\tprob: {prob}")
这个宾馆比较陈旧了,特价的房间也很一般。总体来说一般
label: 1 pred: 0 prob: 0.9692065715789795
怀着十分激动的心情放映,可是看着看着发现,在放映完毕后,出现一集米老鼠的动画片!开始还怀疑是不是赠送的个别现象,可是后来发现每张DVD后面都有!真不知道生产商怎么想的,我想看的是猫和老鼠,不是米老鼠!如果厂家是想赠送的话,那就全套米老鼠和唐老鸭都赠送,只在每张DVD后面添加一集算什么??简直是画蛇添足!!
label: 0 pred: 0 prob: 0.9736887216567993
还稍微重了点,可能是硬盘大的原故,还要再轻半斤就好了。其他要进一步验证。贴的几种膜气泡较多,用不了多久就要更换了,屏幕膜稍好点,但比没有要强多了。建议配赠几张膜让用用户自己贴。
label: 0 pred: 0 prob: 0.9987130165100098
交通方便;环境很好;服务态度很好 房间较小
label: 1 pred: 1 prob: 0.9942231774330139
不错,作者的观点很颠覆目前中国父母的教育方式,其实古人们对于教育已经有了很系统的体系了,可是现在的父母以及祖父母们更多的娇惯纵容孩子,放眼看去自私的孩子是大多数,父母觉得自己的孩子在外面只要不吃亏就是好事,完全把古人几千年总结的教育古训抛在的九霄云外。所以推荐准妈妈们可以在等待宝宝降临的时候,好好学习一下,怎么把孩子教育成一个有爱心、有责任心、宽容、大度的人。
label: 1 pred: 1 prob: 0.9959742426872253
可以看到,模型成功地输出了预测的类别和概率。
13.5 代码
本文的代码已整理至 train_model_prompt_senti.py
与之前一样,我们也按照标准项目结构将 Prompt 模型的代码按功能拆分成模块,整理后的代码存储在: How-to-use-Transformers/src/text_cls_prompt_senti_chnsenticorp/
运行 run_prompt_senti_bert.sh 脚本即可进行训练。
bash run_prompt_senti_bert.sh
如果要进行测试或者将模型预测结果保存到文件,只需把脚本中的 --do_train 改成 --do_test 或 --do_predict。
经过 3 轮训练,最终 BERT 模型在测试集上的结果为:
==> Nvidia GeForce RTX 3090, batch=4, vtype=base
POS: 96.48 / 94.74 / 95.60, NEG: 94.69 / 96.45 / 95.56
micro_F1 - 95.5835 macro_f1 - 95.5833
==> Nvidia GeForce RTX 3090, batch=4, vtype=virtual
POS: 96.79 / 94.08 / 95.41, NEG: 94.09 / 96.79 / 95.42
micro_F1 - 95.4166 macro_f1 - 95.4167
第十四章:大语言模型技术简介
正如1.2节统计语言发展史所述,在规模扩展定律(Scaling Laws)被证明对语言模型有效之后,研究者构建出了许多大语言模型。尤其是 2022 年底面向普通消费者的 ChatGPT 模型的出现,正式标志着自然语言处理进入大语言模型时代。
本章将简要梳理大语言模型的技术要点以及构建过程,方便读者快速了解如何训练以及使用大语言模型。
14.1 大语言模型技术概览
大语言模型能够取得成功主要依赖以下几项关键技术:
规模扩展:只有规模达到一定程度模型才会展现出上下文学习、思维链推理等小规模模型不具备的能力。早期的研究主要关注参数规模,例如 OpenAI、Google 等公司提出了一系列分析参数、数据、算力等因素对性能影响的扩展定律(Scaling Laws),并且通过 GPT、PaLM 等模型进行了验证。考虑到使用超大规模数据(如 2T 或 3T 词元)训练十亿级别的模型(如 2B 或 7B)仍然无法达到模型的最大数据容量,最近的工作专注于加大对高质量数据的规模扩展。
数据工程:大语言模型的训练方式实际上非常简单,即通过在海量文本上进行下一个词预测的优化,使得模型学习到丰富的语义知识,进而通过文本补全的方式解决各种下游任务,因此模型能力本质上来源于所见过的训练数据。目前数据工程主要关注三个方面:(1)拓宽数据来源;(2)数据清洗;(3)设计有效的数据配比与数据课程,加强对于数据语义信息的利用效率。这三个方面的数据工程技术直接决定了最后大语言模型的性能水平。
高效预训练:由于参数规模巨大,大语言模型需要使用各种并行策略以及效率优化方法进行训练,包括 3D 并行(数据并行、流水线并行、张量并行)、ZeRO 内存冗余消除技术等,代表性的分布式训练软件包括 DeepSpeed [4] 和 Megatron-LM [5],它们能够有效支持千卡甚至万卡的联合训练。此外,在正式训练前通常会开展基于小模型的沙盒测试实验以确定最终的训练策略,并且还需要关注优化技巧以提升训练稳定性和优化效率,如混合精度训练。
延伸研究各种训练策略的效果并进行消融实验的成本非常高昂,学术界难以获得充分的算力来系统性研究大语言模型。虽然工业界不断推出开源大模型, 但是对训练过程的开源程度还不够充分,无法了解到许多重要的训练细节。大语言模型非常依赖于工程方法的优化,但是这些技术的理论支撑还比较缺乏。
能力激发:为了提升模型的任务求解能力,需要设计合适的指令微调以及提示策略进行激发或诱导。在指令微调方面,可以使用自然语言表达的任务描述以及期望的任务输出对模型进行微调,从而增强模型的通用任务求解能力,提升在未见任务上的泛化能力。在提示学习方面,需要设计合适的提示策略去诱导大语言模型生成正确的问题答案,例如上下文学习、思维链推理等。
相关现有的研究大多认为指令微调无法向大语言模型注入新的知识,而是训练大语言模型学会利用自身所掌握的知识与信息进行任务的求解。
人类对齐:由于大语言模型可能会生成有偏见、泄露隐私甚至对有害的内容,在实践应用中需要保证大语言模型能够较好地符合人类的价值观。代表性的做法是 OpenAI 公司提出的基于人类反馈的强化学习算法 RLHF(Reinforcement Learning from Human Feedback),将人类偏好引入到大模型的对齐过程中。但是由于强化学习算法的优化过程较为复杂,最近提出了许多监督微调的对齐方式,例如 DPO 算法。最近,OpenAI 公司还发布了“超级对齐”(Super-alignment)项目,研究如何监管具有强人工智能的算法。
相关基于人类反馈的强化学习算法(RLHF)的具体做法是:首先训练能够区分模型输出质量好坏的奖励模型,进而使用强化学习算法来指导语言模型对输出行为进行调整,让大语言模型能够生成符合人类预期的输出。
工具使用:由于大语言模型在非自然语言形式任务上的能力较为有限,因此可以让模型学会使用各种工具的调用方式,利用合适的工具去实现特定的功能需求,例如可以利用计算器进行精确的数值计算、利用搜索引擎检索最新的时效信息等。在技术路径上,工具调用能力主要是通过指令微调以及提示学习两种途径实现。
14.2 大语言模型的构建过程
大语言模型的训练过程可以分为大规模预训练和指令微调与人类对齐两个阶段。
14.2.1 大规模预训练
如1.2节统计语言发展史所述,在 BERT 等传统预训练模型中采用的模型架构以及训练任务还比较多样。随着 GPT 模型的成功,“解码器架构 + 预测下一个词”的有效性得到了充分验证,已经成为当前主要的技术路径。
预训练大语言模型需要准备大规模的文本数据,并且进行严格的清洗。由于大语言模型的能力基础主要来源于预训练数据,因此数据的收集(高质量、多源化)与清洗对于模型性能具有重要影响。目前的开源模型大多采用 2∼3T 规模的词元进行预训练,并且正在进一步扩大规模。
预训练过程对于算力的需求量极高,百亿规模的模型一般需要百卡规模的算力集群(如 A100-80G)联合训练数月时间,而千亿模型则需要千卡甚至万卡规模的算力集群。此外,实施过程中涉及到大量经验性技术,如数据如何配比、如何调整学习率、如何及早发现模型的异常行为等,这些细节很多并没有公开发表的经验可循,因此需要研发人员具有丰富的训练经验和异常处理能力。
14.2.2 指令微调与人类对齐
由于预训练任务形式所限,预训练后的大语言模型更擅长进行文本补全,并不适合直接解决具体的任务,因此通常还需要对大语言模型进行微调与对齐,使之具备更好的任务求解能力。
目前广泛使用的微调技术是指令微调(Instruction Tuning),又称监督微调(Supervised Fine-tuning, SFT),即通过使用任务输入与输出的配对数据进行训练, 使得语言模型掌握通过问答形式进行任务求解的能力。一般来说,指令微调很难教会大语言模型预训练阶段没有学习到的知识与能力,它主要起到了对于模型能力的激发作用。
与预训练相比,指令微调需要的指令数据规模要小的多,通常数十万到百万规模的指令微调数据就能够有效地激发语言模型的通用任务求解能力,部分工作甚至认为数千条或者数万条高质量指令数据也能达到不错的微调效果。因此,若干台单机八卡(A100-80G)的服务器就能在一天或数天的时间内完成百亿模型的指令微调。这个过程还可以加入多轮次的对话数据来增强模型的人机对话能力。
除了提升任务的解决能力外,还需要将大语言模型与人类的期望、需求以及价值观对齐(Alignment)。代表性方法是 OpenAI 公司提出的基于人类反馈的强化学习对齐方法 RLHF,在指令微调后使用强化学习加强模型的对齐能力。RLHF 算法需要训练一个符合人类价值观的奖励模型(Reward Model),为此需要标注人员针对大语言模型所生成的多条输出进行偏好排序,然后使用偏好数据训练奖励模型。由于强化学习需要维护多个辅助模型进行训练,计算资源消耗通常会多于指令微调, 但是也远小于预训练。目前还有很多工作试图简化对齐过程,通过去除奖励模型或其他使用 SFT 方式来达到与 RLHF 相似的效果。
14.2.3 常用的预训练数据集
常用的预训练语料库可以划分为网页、书籍、维基百科、代码以及混合型数据集。
网页:网页是大语言模型训练语料中最主要的数据来源,包含新闻报道、博客文章、论坛讨论等各种内容,这些广泛且多元的数据为大语言模型深入理解人类语言提供了重要资源。常用的网页语料库有:
- Common Crawl:一个规模庞大、非结构化、多语言的网页数据集,从 2008 年至今一直在定期更新,总数据量达到 PB 级别。一般仅提取特定时间段或者符合特殊要求的子集进行使用。但是该数据集充斥着噪声和低质量数据,在使用前必须进行有效的数据清洗,常用的自动清洗工具有 CCNet 等。
- C4:Google 构建,基于 2019 年 4 月的 Common Crawl 语料,包括超过 365M 个互联网域,超过 156B 词元,数据量约 800GB。使用该数据集的代表模型有 UL2 和 LLaMA。
- RedPajama-Data:Together AI 构建,包含了来自 Common Crawl 的 100B 份文档,包含英语、法语、西班牙语、德语和意大利语,经过过滤和去重得到约 30T 词元。提供 40 余种预先标注好的数据注释,方便用户根据实际需求筛选数据集。
- RefinedWeb:TII 构建,在 2008 年到 2023 年 6 月 Common Crawl 数据上通过筛选和去重构建,共约 5T 词元,开源部分有 600B 词元。是开源大语言模型 Falcon 的主要训练数据集。
- OpenWebText:OpenAI WebText 数据集(GPT-2、GPT-3 和 InstructGPT 等均基于该数据集训练)的复现开源版本,首先从 Reddit 上提取网页链接,经过去重、过滤等处理,最终保留来自约 8M 份文档的 38GB 文本数据。
相关在上述网页数据集中,中文网页占比通常非常低,因此不足以训练中文大语言模型。下面介绍一些具有代表性的中文网页数据集。
- ChineseWebText:中科院自动化所构建,从 Common Crawl 数据中精心筛选的中文数据集。汇集了 2021 年至 2023 年间的网页快照,总计 1.42TB 数据量。还特别发布了一个 600GB 大小的中文数据子集,并配套推出了一 款名为 EvalWeb 的数据清洗工具
- WanJuan:上海人工智能实验室构建,由网页、书籍等数据组成,约 500M 个文档,数据大小超过 1TB。将多种格式的数据进行了统 一,并进行了细粒度的清洗和去重。
书籍:书籍是人类知识与文化的重要载体,并且内容主要是长文本,能够帮助语言模型学习语言的长程依赖关系,并深入理解语言的内在逻辑与表达习惯。书籍的语言表达通常更为严谨,整体上质量较高,并且能够覆盖多元化的知识体系。常用的书籍语料库有:
- BookCorpusOpen:University of Toronto & MIT 构建的 BookCorpus 数据集(被 GPT、GPT-2、LLaMA 等模型使用)的镜像版本,包含了共计 17,868 本书籍。
- arXiv Dataset:arXiv 官方发布的论文数据集,广泛涵盖了物理、数学和计算机科学等领域的论文,共包含约 1.7M 篇预印本文章,总数据量约为 1.1TB。
- S2ORC:Allen Institute for AI 基于学术搜索引擎 Semantic Scholar 上的论文构建,论文经过了清洗、过滤并被处理成适合预训练的格式。该数据集还有一个衍生数据集 peS2o,v2 版本共计包含了约 42B 词元。
维基百科:维基百科(Wikipedia)是一个综合性的在线百科全书,提供了高质量的知识信息文章。维基百科数据具有以下几个特点:(1)专业性:维基百科条目通常具有良好的结构性和权威性,不仅对于各种专业术语和概念进行了阐释,还揭示了它们在不同领域的应用和联系;(2)多语性:维基百科支持英语、汉语、 法语、德语等一共 300 多种语言,是一个宝贵的多语言平行语料库;(3)实时性:维基百科的内容在不断更新,对于知识信息的实时性维护较为及时,并且会定期发布其数据库的打包副本。
代码:代码具有高度结构化与专业性,引入包含代码的数据集可以增强模型的结构化推理能力与长程逻辑关系,能够提升模型理解和生成编程语言的能力。现有的工作主要从互联网上爬取具有开源许可的代码,两个主要来源是公共代码仓库(例如 GitHub)和代码相关的问答平台(例如 StackOverflow)。常用的代码语料库有:
- BigQuery:谷歌构建的企业数据仓库,包含了众多领域的公共数据集,其中的代码类数据覆盖各种编程语言。CodeGen 即抽取了其中的公开代码数据子集进行训练。
- The Stack:Hugging Face 构建,涵盖了 30 种编程语言,数据来源于 GHArchive 项目中的 GitHub 活跃仓库,经过数据筛选、过滤以及许可证检测等处理后,最终数据量约为 3TB。v1.2 版本已扩展到 358 种编程语言,数据量约为 6TB。
- StarCoder:BigCode 基于 The Stack v1.2 进一步处理后的代码数据集,筛选出了 86 种语言,同时还进行了人工抽样审核以确认数据为人类编写的正常代码,最终数据总量约为 783GB。
混合型数据集:为了便于研发人员使用,很多研究机构对于多种来源的数据集合进行了混合,发布了一系列包括多来源的文本数据集合。这些混合数据集往往融合了新闻、社交媒体内容、维基百科条目等各种类型的文本,减少了重复清洗数据、选择数据的繁重工程。常用的混合语料库有:
- The Pile:EleutherAI 构建,数据包括书籍、网站、代码、科学论文和社交媒体等。由 22 个多样化的高质量子集混合而成,包括OpenWebText、维基百科等,最终总数据量约为 825GB。GPT-J、CodeGen、Megatron-Turing NLG 等模型都用到该数据集。
- ROOTS:BigScience 构建,约 62% 的数据来源于整理好的自然语言处理数据集及相关文档、利用 Common Crawl 收集的网页数据以及 GitHub 代码数据,约 38% 的数据来源于一个网页爬虫项目 OSCAR,并对其进行了过滤、去重和个人信息移除。包含 46 种自然语言(英语占比约为 30%)以及 13 种编程语言(Java、PHP 和 C++ 占比过半),总数据量约为 1.6TB。
- Dolma:Allen Institute for AI 构建,包括来自 Common Crawl 的网页、Semantic Scholar 学术论文、GitHub 代码、书籍、Reddit 的社交媒体帖子以及维基百科数据,由来自大约 200TB 原始文本的 3T 个词元组成。v1.6 的版本文件大小约为 5.4TB。开源大语言模型 OLMo 即使用该数据训练。
14.2.4 常用微调数据集
微调主要涉及指令微调(有监督微调)和对齐微调,下面将列举一些可用于微调的数据集。
指令微调数据集:按照指令实例的构建方法可以将指令微调数据集分为自然语言处理任务数据集、日常对话数据集和合成数据集。(1)自然语言处理任务数据集一般是在有监督的多任务训练数据集(包含多个自然语言处理任务实例)上通过人工编写任务描述来构建;(2)日常对话数据集则是基于真实用户对话构建,其中查询主要由真实用户提出、回复则由人类标注或者语言模型生成,对话类型通常包括开放式生成、问答、头脑风暴和聊天;(3)合成数据集则通常是使用大语言模型基于预定义的规则或方法进行构建。一些具有代表性的指令微调数据集如表 14-2 所示。常用的指令微调数据集有:
- P3(Public Pool of Prompts):BigScience 构建,由超过 270 个自然语言处理任务数据集和 2000 多种提示整合而成(每个任务可能不止一种提示),全面涵盖多选问答、提取式问答、情感分类、文本摘要、自然语言推断等任务。其子集被用来训练 T0 模型。
- FLAN:Google 构建,v2 版本主要由 Muffin、NIV2、T0-SF 和 CoT 四个子集构成。其中 Muffin 由 v1 版本的 62 个任务和新加入的 26 个任务组成(包括对话数据和代码合成数据);T0-SF 则是从 T0 模型的数据中抽取出来,同时确保与 Muffin 不重叠;NIV2 指的是数据集 Natural-Instructions v2;CoT 则是为了增强模型的推理能力而加入的九种不同推理任务的组合。FLAN-v2 对每项任务都设置了最大上限,防止某些任务在采样中占主导地位。FLAN 论文显示使用 52% Muffin、15% T0-SF、3% CoT 以及 30% NIV2 这一混合比例通常能够使得模型具有较好表现。
- ShareGPT:TechCrunch 发布的对话数据集,数据来源于开源平台 ShareGPT,语种主要为英语和其他西方语言,其中查询来自于用户的真实提问或指令,回复则是 ChatGPT 对此生成的回答。
- OpenAssistant:LAION-AI 人工构建的多语言对话语料库,共有 91,829 条用户提示,69,614 条回复,包含 35 种语言并且附有人工标注的质量评级(例如回复的有用性、无害性等)。
- Dolly:Databricks 构建的对话数据集,包含 15000 个人类生成的数据实例,主题涉及 InstructGPT 论文中提到的 7 个领域,包括头脑风暴、分类、封闭/开放式质量保证、生成、信息提取等。
- Self-Instruct-52K:University of Washington 使用 Self-Instruct 方法生成的英语指令数据集,包含 52K 条指令以及 82K 个实例输入和输出。最初由人工收集创建了 175 个种子任务,每个任务包括 1 个指令和 1 个包含输入输出的实例。然后,每次随机抽取 8 个指令作为示例,引导 GPT-3 模型生成新的指令以及对应的输入和输出,经过滤后添加到数据集中。迭代上述过程,最终获得了 52K 条指令和 82K 个实例数据。
- Alpaca-52K:同样基于 Self-Instruct 方法进行构建的,在 Self-Instruct-52K 的 175 个种子任务上利用 OpenAI 的 text-davinci-003 模型获得了 52K 个不重复的指令,并根据指令和输入生成输出,每条指令仅对应于一个输入输出实例(输入可选,最终数据中只有 40% 具有输入)。
相关如果你不了解 Self-Instruct 方法不用着急,本教程会在第十六章《使用大语言模型》中进行详细介绍。
人类对齐数据集:对齐目标一般聚焦于有用性、诚实性和无害性三个方面,下面将介绍几个代表性的对齐微调数据集,它们各自针对上 述对齐目标进行了标注。
- HH-RLHF:Anthropic 构建,关注大语言模型的有用性和无害性。包含约 169K 个开放式对话,涉及人类向智能助手寻求帮助、建议或请求完成任务等情景。信息助手将为每个查询提供两个回复,一个回复被选择而另一个被拒绝。有用性相关数据中,被认为更有用的回复将被选择;而无害性相关数据中,被认为更有害的回复将被选择。
- SHP:Standfordnlp 构建,关注模型的有用性。包含 385K 个数据实例,对从烹饪到法律建议等 18 个不同主题领域中问题/指令的人类偏好进行标注,每个实例都基于寻求帮助的 Reddit 帖子构建的,包含问题以及帖子下两个排名较高的评论,其中一个被 Reddit 用户认为更有用,另一个被认为不太有帮助。
- Stack Exchange Preferences:HuggingFace 构建,关注模型的有用性。涵盖来自编程问答社区 Stack Overflow 的约 10M 个问题和答案,每个实例均包含一个问题以及不少于两个候选答案,每个答案都附有一个根据投票数计算出的分数并附带是否被选中的标签。
- Sandbox Alignment Data:Google 构建,致力于运用模型自身的反馈机制标注数据,关注模型的有用性、诚实性、无害性。数据源自模拟人类社交互动场景的 SANDBOX 虚拟环境,在该环境中,多个大语言模型根据问题给出回复然后互相“交流”,并根据彼此的反馈来不断修正和完善自己的回复。该数据集涵盖 169K 个实例,每个实例均包含一个查询、多个回复选项以及由其他模型给出的相应评分。
14.3 开发大语言模型
开发大语言模型是一项复杂的工程,涉及到包括并行策略以及效率优化方法在内的各种工程技巧,因此一些公司以及研究机构推出了专用于开发大语言模型的代码库以推动该领域的发展。下面将介绍具有代表性的两个代码库。
14.3.1 DeepSpeed 库
DeepSpeed 由微软公司开发,是一个旨在加速模型训练的高性能库,被广泛用于大语言模型的分布式训练。
DeepSpeed 为分布式训练提供了各种优化技术支持,如内存优化(ZeRO 技术、梯度检查点)、数据并行、混合精度训练等,使得整个训练过程变得更加高效和稳定。为了更适配用户需求,DeepSpeed 针对模型生成和强化学习分别开发了特制的优化框架:DeepSpeed-MII 和 DeepSpeed-Chat。
DeepSpeed-MII:通过提高吞吐量、降低延迟等方式来降低大模型解码生成的运行成本。DeepSpeed-MII 首先实现了块状键值缓存和连续批处理技术加速文本生成过程,然后又提出了 SplitFuse 技术将提示和生成结果进行动态分解以进一步改善连续批处理和系统吞吐量。目前已支持包括 LLaMA 、Mistral 、Falcon、 Mixtral 和 Qwen 在内的多个模型。
DeepSpeed-Chat:用于训练类 ChatGPT 模型的开发工具,完整集成了包括基于人类反馈的强化学习(RLHF)算法在内的训练过程。它具有三个主要功能:(1)简化了类 ChatGPT 模型的训练和生成过程,用户可以用简单的脚本实现多个训练步骤,并且提供了用于测试对话式交互的 API;(2)复现了 InstructGPT 的训练过程,包括有监督微调、奖励模型训练和基于人类反馈的强化学习,还提供了数据抽象和混合功能;(3)将训练和生成集成到了统一框架中,实现了在 RLHF 中训练和生成模式之间的无缝切换。
14.3.2 Megatron-LM 库
Megatron-LM 是由 NVIDIA 公司开发的一款专门为训练大语言模型而设计的代码库,旨在解决大型模型训练过程中所遇到的一系列技术挑战,包括显存限制、 计算效率以及不同的并行策略带来的通信问题。
Megatron-LM 引入了一系列分布式训练的优化技巧,支持多种并行策略,包括(1)数据并行,通过在每个工作节点复制模型,并将输入数据切分多份分配给多个节点,定期同步所有梯度来提升 GPU 的使用效率;(2)模型并行,包括张量并行和流水线并行,通过在多个工作节点上分配模型和计算来克服单个 GPU 容量限制的问题。此外,Megatron-LM 还支持混合精度训练和 FlashAttention 功能。这些优 化技术可以在很大程度上提高训练效率和速度,实现跨 GPU 的高效分布式训练。
第十五章:预训练大语言模型
如14.2节大语言模型的构建过程所述,大语言模型的构建过程可以分为预训练和微调两个阶段。通过在大规模语料上进行预训练,大语言模型可以获得通用的语言理解与生成能力,并且学习到较为广泛的世界知识。
本章将按顺序依次介绍预训练中的各个步骤,包含原始数据的收集、数据预处理、分词、以及预训练过程中的数据调度方法。
15.1 数据准备
现有的大语言模型主要将各种公开的文本数据进行混合作为预训练语料,如图 15-1 所示。预训练数据可以分为通用文本数据和专用文本数据,其中通用文本数据规模较大,涵盖了网页、书籍和对话等内容,用以增强模型的语言建模能力;专用文本数据则是为了进一步提升大语言模型在特定任务上的表现,如多语数据、科学数据和代码数据等。
图 15-1 代表性大语言模型的预训练数据分布
常用的专用文本数据分为三种:
- 多语文本:多语文本数据可以帮助模型更好地建立多语言间的语义关联,增强模型的多语理解与生成能力,为跨语言理解与对话任务提供支持。此外,多语言数据还能有效增加数据的多样性,从而有助于提升模型的综合性能。
- 科学文本:科学文本数据可以增强大语言模型对科学知识的理解,从而提高科学问答与推理等任务的性能。常用方法是收集 arXiv 论文、科学教材、数学网页等。但是由于科学文本数据中包含数学公式、蛋白质序列等特殊符号,通常需要采用特定的分词和预处理技术将数据转化为大语言模型能够处理的统一格式。
- 代码:代码语料可以提高其模型生成的程序质量,其来源主要是 Stack Exchange 等编程问答社区的数据以及 GitHub 等开源项目仓库。由于代码主要以结构化的编程语言形式呈现,在代码数据上训练能够提升模型的结构化语义理解与逻辑推理能力。同时,代码中的函数调用关系还有助于增强模型的工具使用与学习能力。将推理任务格式化为代码通常可以得到更准确的结果。
15.1.1 数据预处理
在收集了丰富的文本数据之后,就需要对数据进行预处理,消除低质量、冗余、无关甚可能有害的数据。一般来说,需要构建并使用系统化的数据处理框架,例如开源库 Data-Juicer。典型的数据预处理流程如图 15-2 所示,包括质量过滤、敏感内容过滤、数据去重等步骤。
图 15-2 典型的预训练数据预处理流程
步骤一:质量过滤
在质量过滤方面,目前主要使用以下两种数据清洗方法:
- 基于启发式规则的方法:通过精心设计的规则有针对性地识别和剔除低质量数据。例如(1)为了训练特定目标语言的模型可以过滤掉其他语言的文本。(2)使用单词比率等统计特征来衡量文本质量。还可以利用困惑度(Perplexity)等文本生成评估指标来检测和删除表达不自然的句子,以及训练 FastText 分类器来检测有毒或仇恨言论。(3)制定精准的清洗规则,结合关键词集合进行过滤。
延伸
常用的统计指标过滤规则有:对于网页数据,过滤任何具有超过 100 个重复单词或句子的文档,以及过滤符号和词元比大于 0.1 的文档;对于论坛数据,过滤掉任何点赞数少于 3 的用户评论。
常用的关键词过滤规则有:对于维基百科数据,过滤掉任何拥有少于 25 个 UTF-8 单词的页面;对于网页数据,过滤掉 HTML 标签,以及过滤掉任何不含有 the, be, to, of, and, that, have, with 词汇的文档;对于所有数据,过滤掉电话号码,邮箱地址、IP 地址等隐私信息。
- 基于分类器的方法:训练判别数据质量的文本分类器进行数据清洗。具体来说,可以选取部分代表性的数据进行质量标注以训练分类器,例如将维基百科等高质量数据作为正样本、将筛选出含有不良内容或低质量数据的样本作为负样本。文本过滤的粒度可以是文档级别也可以是句子级别。为了减少误筛,可以使用多个分类器进行联合过滤或召回,还可以针对不同的评估维度训练不同的分类器。
目前常用方法包括轻量级模型(如 FastText)、可微调的预训练模型(如 BERT)以及闭源大语言模型 API(如 GPT-4)。轻量级模型效率较高,但是分类的准确率和精度受限于模型能力;预训练模型可以针对性微调, 但是通用性和泛化性具有限制;闭源大语言模型的能力较强, 但是无法灵活针对任务进行适配而且成本较高。
延伸
过滤效率也是预处理需要考虑的核心要素。基于启发式的方法效率较高,能够迅速过滤 10M 乃至 100M 级别的庞大文档集,而基于分类器的方法虽然精度更高,但是效率较低。因此可以结合多种策略平衡效率与准确性,例如首先利用启发式规则进行初步筛选,然后再采用分类器方法进行精细过滤。此外,还可以同时应用多种分类器,例如使用更为有效但是资源消耗更高的分类器在轻量级分类器粗滤后的数据上再次进行选择。
步骤二:敏感内容过滤
在敏感内容过滤方面,目前主要关注对有毒内容和隐私信息的过滤方法。为了精确过滤含有有毒内容的文本,通常采用基于分类器的过滤方法,例如基于 Jigsaw 评论数据集训练分类器。而过滤隐私内容则主要使用启发式方法(如关键字识别),例如 Dolma 采用规则方法来过滤邮箱地址、IP 地址以及电话号码,如果文档中的隐私信息少于五条则使用使用特定的词元进行替换(如“[EMAIL_ADDRESS]”),如果隐私信息达到六条则直接删除整个文档。
步骤三:数据去重
数据去重同样是一个重要步骤。由于大语言模型具有较强的数据拟合与记忆能力,很容易习得训练数据中的重复模式,可能导致对这些模式的过度学习。研究发现,预训练语料中出现的重复低质量数据可能诱导模型在生成时频繁输出类似数据。此外,这些数据也可能导致训练过程不稳定(训练损失震荡)甚至导致训练崩溃。目前数据去重主要关注计算粒度以及匹配方法两个方面:
- 计算粒度:去重可以在句子、文档等多种粒度上进行。在句子级别上,可以删除包含重复单词和短语的句子;在文档级别上,可以依靠单词或 n 元词组的重叠等表层特征来衡量文档的重叠比率;数据集级别往往采用多阶段、多粒度的方式来进行。一般首先在数据集和文档级别进行去重(去除高度相似甚至完全一致文档),然后在句子级别实现更为精细的去重,例如当两个句子公共子串的长度过长时直接删除某一个句子。
- 匹配方法:在去重过程中,可以使用精确匹配算法(即每个字符完全相同)或近似匹配算法(基于相似度)。对于精确匹配,通常使用后缀数组来匹配最小长度的完全相同子串;对于近似匹配,可以采用局部敏感哈希(Locality-Sensitive Hashing, LSH)算法,如最小哈希(MinHash) 来实现。为了平衡去重效率和效果,实际操作通常会结合多种匹配方法,例如在文档层面采用近似匹配而在句子层面采用精确匹配。
延伸
MinHash 是一种估计两个集合之间相似度的技术,可以迅速判断文档间的相似性。其核心思想是通过哈希处理集合元素,并选择最小的哈希值作为集合表示,然后通过比较两个集合的最小哈希值估算相似度。 为了提升估计精确度还可以采用不同的哈希函数为每个集合生成多个 MinHash 值。
MinHash 技术只需比较那些更为简洁、易于对比的哈希值,可以避免对集合中所有元素进行逐一比较,使得其在处理超大型集合时具有较好的计算效率。
现有的研究证明,预训练数据的数量和质量都对训练效果具有重大影响。
如14.1节大语言模型技术概览中介绍的那样,早期的研究工作认为增加模型参数更为重要,而最近的研究表明扩展训练数据量对于提升大语言模型的性能同样非常关键,整体上语言模型的性能会随着训练数据量的增加而提升。
在获取充足的预训练数据后,数据质量直接决定了模型的实际性能。通过提升数据质量,语言模型就能展现出与更大规模模型相匹敌甚至更为优异的性能。相反,使用大量低质量数据会导致模型训练过程不稳定,容易造成模型训练不收敛等问题。而且如果模型在包含事实性错误的、过时的数据上进行训练,那么它在处理相关主题时就可能会产生不准确或虚假的信息,这种现象被称为“幻觉”(Hallucination)。
此外,已有研究普遍认为重复数据对于模型训练及最终性能会带来不良影响,例如导致模型训练损失出现“双下降”现象(先下降然后升高再下降)。而且重复数据可能会降低模型利用上下文信息的能力,从而削弱模型在上下文学习中的泛化能力。因此通常的建议是对于预训练数据进行精细的去重操作。
最后,数据中包含有偏、有毒、隐私的内容将会对于模型造成严重的不良影响。例如如果训练数据中包含有毒内容,模型就可能会产生侮辱性、攻击性或其他有害的输出;而在含有隐私内容的数据上训练可能会导致模型在输出中泄露或利用个人数据。
步骤四:分词
分词(Tokenization)步骤负责将原始文本分割成模型可识别和建模的词元序列,作为大语言模型的输入数据。如5.2.1节分词策略中介绍的那样,当前基于 Transformer 的语言模型大多采用子词分词器(Subword Tokenizer)进行分词,常见方法有 BPE 分词、WordPiece 分词和 Unigram 分词三种。
虽然直接使用已有的分词器较为方便(例如 GPT-3 模型使用了 GPT-2 的分词器),但是使用为语料专门设计的分词器会更加有效,尤其对于混合了多领域、多语言和多种格式的语料。最近的工作通常使用 SentencePiece 代码库定制化分词器。
15.1.2 数据调度
完成数据预处理之后,需要设计合适的调度策略来安排这些多来源的数据。数据调度(Data Scheduling)主要关注两个方面:各个数据源的混合比例以及各数据源用于训练的顺序(称为数据课程,Data Curriculum),具体的数据调度流程如图 15-3 所示。
图 15-3 预训练大语言模型时的数据调度示意图
数据混合
由于数据源与大语言模型某些特定能力的学习具有紧密的联系,因此设置合适的数据混合比例非常重要。
数据混合通常设置的是预训练数据的整体分布,在预训练期间,将根据混合比例从不同数据源中采样数据,也可以在不同训练阶段采用 不同的混合比例。如图 15-1 所示,代表性大语言模型 LLaMA 的预训练数据主要包括超过 80% 的网页数据、来自 GitHub 和 StackExchange 的 6.5% 代码密集型数据、4.5% 的书籍数据,以及来自 arXiv 的 2.5% 科学数据,这个数据配比可以作为训练大语言模型的一个重要参考。
注意即使是训练专业模型(例如代码模型 CodeGen)时,依然需要混合一定比例的网页数据来提供或者保留通用的语义知识。
在实践中,数据混合通常是根据经验确定的,下面汇总了几种常见的数据混合策略。
- 增加数据源的多样性:多样化的数据(如网页、书籍、代码等)能够改进大语言模型在下游任务中的综合表现。
- 优化数据混合:除了手动设置数据混合配比外,还可以使用可学习的方法来优化数据组成。实践中通常采用不同的数据混合从头开始训练几个小型语言模型(例如 1.3B 规模),然后选择获得最理想性能的数据混合配比。但是这个方法假设以类似方式训练出的小模型与大模型会表现出相似的模型能力或行为,在实际中并不总是成立。
- 优化特定能力:可以通过增加特定数据源的比例来增强某些对应的模型能力,例如使用更多的数学和代码数据可以增强大语言模型的数学推理和编程能力。常见做法是采用多阶段训练方法,例如在连续的两个阶段分别安排通用数据和任务特定数据,这种在多个阶段使用不同数据源混合配比进行训练的方法被称为“数据课程”。
数据课程
除了设置数据混合配比,在训练过程中对数据顺序进行合适的安排也很重要。一种实用方法是基于专门构建的评测基准监控大语言模型关键能力的学习过程,然后在预训练期间动态调整数据的混合配比。
由于预训练阶段需要耗费大量的计算资源,目前针对数据课程的研究工作主要集中在继续预训练(Continual Pre-training)。研究表明,为了学习某些特定的技能,按照技能依赖顺序编排对应数据集的学习方法(例如从基本技能到目标技能)比直接在相关语料上学习效果更好。
下面将以三种常见能力为例,介绍具体的数据课程在继续预训练中的应用。
- 代码能力:为了提高模型的代码生成能力,研究人员基于 LLaMA-2 开发了 CodeLLaMA,采用的数据课程为:2T 通用词元 → 500B 代码密集型词元。CodeLLaMA 还提供了一个面向 Python 语言的特定代码大模型 CodeLLaMA-Python,采用的数据课程为:2T 通用词元 → 500B 代码相关词元 → 100B Python 代码相关词元。
- 数学能力:Llemma 选择 CodeLLaMA 作为基座,进一步在包含论文、数学和代码的混合数据集合上进行继续预训练,采用的数据课程为:2T 通用词元 → 500B 代码相关词元 → 50∼200B 数学相关词元。特别地,Llemma 在继续预训练数据中还包含 5%的通用领域数据,这可以看做一种模型能力的“正则化”技术,加强对于基座模型通用能力的保持。
- 长文本能力:很多工作通过继续预训练有效扩展了大语言模型的上下文窗口,主要是针对 RoPE 中的位置嵌入编码进行修改。例如 CodeLLaMA 将 LLaMA-2 的上下文窗口从 4K 扩展到了 100K,采用的数据课程为:4K 上下文窗口的 2.5T 词元 → 16K 上下文窗口的 20B 词元。
15.2 模型架构
15.2.1 主流架构
如 2.4 节 Transformer 家族中介绍的那样,语言模型大致可以分为三种架构:以 BERT 为代表的编码器(Encoder-only)架构、以 GPT 为代表的解码 器(Decoder-only)架构和以 T5 为代表的编码器-解码器(Encoder-decoder)架构。近年来,随着 GPT 系列模型取得重大成功,解码器架构已经成为了大语言模型的主流架构。特别地,解码器架构还可以细分为因果解码器(Causal Decoder)和前缀解码器(Prefix Decoder),默认情况下指的就是因果解码器架构。
这几种架构的对比如图 15-4 所示,其中蓝色、绿色、黄色和灰色矩形分别表示前缀词元之间的注意力、前缀词元和目标词元之间的注意力、目标词元之间的注意力以及掩码注意力。
图 15-4 三种常见架构的注意力模式比较示意图
编码器-解码器架构是一种经典的模型结构。如图 15-4 所示,该架构在编码器端采用了双向自注意力机制对输入信息进行编码,而在解码器端则使用了交叉注意力与掩码自注意力机制,进而通过自回归的方式对输出进行生成。目前只有如 Flan-T5 等少数大语言模型是基于编码器-解码器架构构建的。
因果解码器架构没有显式地区分输入和输出部分。如图 15-4 所示,该架构采用单向掩码注意力机制,使得每个输入的词元只关注序列中位于它前面的词元和它本身,进而自回归地预测输出的词元。目前绝大部分大语言模型都采用因果解码器架构,最具有代表性的模型就是 OpenAI 推出的 GPT 系列模型。
前缀解码器架构参考了编码器-解码器的设计。如图 5.6 所示,该架构对于输入(前缀)部分使用双向注意力进行编码,对于输出部分利用单向的掩码注意力利用该词元本身和前面的词元进行自回归地预测。当前基于前缀解码器架构的代表性大语言模型包括 GLM-130B 和 U-PaLM。
15.2.2 长上下文模型
实际应用中,大语言模型对于长文本的处理需求日益凸显。为此,多家机构已推出了具有超长上下文窗口的大语言模型或 API,例如支持 128K 上下文窗口的 GPT-4 Turbo、具有 200K 上下文窗口的 Claude-2.1等。目前,增强大语言模型长文本建模能力的研究主要集中在扩展位置编码和调整上下文窗口两个方向。
扩展位置编码
大语言模型的上下文建模能力通常受到训练集中文本数据长度分布的限制。一旦超出这个分布范围,模型的位置编码往往无法得到充分训练而导致模型处理长文本的性能下降。因此,需要对于位置编码进行扩展。
目前主流的位置编码方法 RoPE 在未经特殊修改的情况下并不具备良好的外推能力。因此,很多研究工作在 RoPE 的基础上进行了重要改进,旨在提升其在不经过训练或继续训练的情况下对于长文本的建模能力,例如通过位置内插、位置截断等方式修改位置索引来调整所有子空间的旋转角度,从而保证其不超过原始上下文窗口所允许的最大值。
延伸某些特定的位置编码在超出原始上下文窗口的文本上也能表现出较好的建模能力,这种能力被称为外推(Extrapolation),例如 T5 偏置、ALiBi 以及 xPos 等方法都展现出了不同程度的外推能力。
尽管外推能力使得模型在长文本上继续生成流畅的文本,但模型对长文本本身的理解能力可能无法达到与短文本相同的水平。为了真正增强长文本建模能力,通常还需要在更长的文本上进行一定训练。
调整上下文窗口
除了使用扩展位置编码来拓宽上下文窗口外,另一种行之有效的策略是采用受限的注意力机制来调整原始的上下文窗口,从而实现对更长文本的有效建模。常见的三种方法为并行上下文窗口、Λ 形上下文窗口和词元选择,如图 15-5 所示,其中白色表示被掩盖的词元、蓝色表示进行注意力计算的词元,块上面的数字表示位置编码的相对位置。
图 15-5 三种调整上下文窗口方法的示意图
- 并行上下文窗口:将输入文本划分为若干个片段,每个片段都进行独立的编码处理,并共享相同的位置编码信息。在生成阶段,通过调整注意力掩码,使得后续生成的词元能够访问到前序的所有词元。然而,该方法无法有效地区分不同段落之间的顺序关系,在某些特定任务上可能表现不佳。
- Λ 形上下文窗口:有选择性地关注每个查询的邻近词元以及序列起始的词元,同时忽略超出这一范围的其他词元。然而,由于无法有效利用被忽略的词元信息,这种方法无法充分利用所有的上下文信息。
- 词元选择:挑选出最重要的 k 个词元,以实现对于完整注意力的有效拟合。词元选择方法可以通过查询与词元的相似度和查询词元所在分块的相似度实现。其中(1)查询与词元相似度方法根据位置索引和上下文窗口,将词元划分为窗口内的近距离词元和窗口外的远距离词元。对于上下文窗口外的远距离词元,利用外部存储保存它们的键值对,并采用 k 近邻搜索方法来获取当前生成所需的最相关词元;(2)查询与分块相似度方法将序列划分为不同的长度固定的分块,并从分块序列中选择出最相关的部分分块。
15.3 模型预训练
15.3.1 预训练任务
目前,大语言模型常用的预训练任务可以分为三类:语言建模(Language Modeling, LM)、去噪自编码(Denoising Autoencoding, DAE)以及混合去噪器(Mixture-of-Denoisers, MoD),如图 15-6 所示。
图 15-6 语言建模和去噪自编码的输入输出对比
语言建模(Language Modeling, LM)
语言建模任务是最广泛采用的预训练任务,其核心在于“预测下一个词元”,经常被用于训练基于解码器的大语言模型, 例如 GPT-3、PaLM 等。形式化来说,给定一个词元序列 u={u1,…,uT},语言建模任务旨在基于序列当前位置之前的词元序列 u<t,采用自回归的方式对目标词元 ui 进行预测。在训练过程中,模型根据式 (15.1) 所示的似然函数进行优化:
(15.1)LLM(u)=∑t=1TlogP(ut∣u<t)
可以发现,语言建模任务与人类生成语言数据(如口语表达、书面写作等)的方式十分相似,都是基于前序内容生成(或预测)后续的内容。尽管这种方式形式简单,但当预训练数据足够丰富时,大语言模型便能够学习到自然语言的生成规律与表达模式。
此外,语言建模任务还可以看作是一种多任务学习过程。例如,在预测前缀“这部电影剧情饱满,演员表演得也很棒,非常”中的“好看”时,模型实际上在进行情感分析任务;而预测句子前缀“小明有三块糖,给了小红两块糖,还剩下”中的“一块糖”时,则是在进行数学算术任务。
语言建模的一个变种是前缀语言建模(Prefix Language Modeling)任务,该任务专门为采用前缀解码器架构的模型设计。在训练阶段, 每个文本序列 u 会根据随机选择的位置 k(1≤k≤T) 切分为前缀 uprefix={u1,…,uk} 和后缀 usuffix={uk+1,…,uT} 两个部分,然后仅将后缀中词元的损失计入总损失,其目标函数可以表示为式 (15.2):
(15.2)LPrefix(u)=logP(usuffix∣uprefix)=∑t=k+1TlogP(ut∣u<t)
但是,由于前缀语言建模任务并未将所有词元的损失都纳入计算,当使用相同规模的数据集进行训练时,采用前缀语言建模训练的模型在性能上通常会稍逊于使用标准语言建模任务训练的模型。
语言建模的另一个重要变种是中间填充任务,该任务通过重新调整输入序列的顺序,旨在训练模型对于中间缺失信息的填充能力。具体来说,输入序列 u 会被划分为三个部分:前缀 uprefix、中间部分 umiddle 和后缀 usuffix,然后将中间部分移至序列末尾,最后要求模型自回归地对新序列 uprefix⊕usuffix⊕umiddle 进行预测。这样模型就能够学习到填充中间缺失信息的能力。其训练函数可表示为式 (15.3):
(15.3)LFIM(u)=logP(uprefix)+logP(usuffix∣uprefix)+logP(umiddle∣uprefix,usuffix)
中间填充任务经常被用于训练代码预训练模型以提升模型在代码补全等实际应用场景中的表现。
去噪自编码(Denoising Autoencoding, DAE)
去噪自编码任务是另一种常见的语言模型预训 练任务,广泛应用于 BERT、T5 等预训练语言模型中。在去噪自编码任务中,输入文本经过一系列随机替换或删除操作,形成损坏的文本 u∖u~,然后要求模型根据损坏的文本恢复出被替换或删除的词元片段 u~,其训练目标可以表示为式 (15.4):
(15.4)LDAE(u)=logP(u~∣u∖u~)
与语言建模相比,去噪自编码任务的实现更为复杂,需要设定额外的优化策略,如词元替换策略、替换片段长度、替换词元比例等。这些策略的选择会直接影响模型的训练效果。目前使用去噪自编码进行预训练的大语言模型较为有限,代表性模型包括 Flan-T5。
混合去噪器(Mixture-of-Denoisers, MoD)
混合去噪器,又称 UL2 损失,通过将语言建模和去噪自编码的目标均视为不同类型的去噪任务,对预训练任务进行了统一建模。具体来说,混合去噪器定义了三种去噪器:S-去噪器、R-去噪器和 X-去噪器。
S-去噪器与前缀语言建模的目标相同(如式 15.2 所示),旨在学习基于给定前缀信息生成合理后缀文本的能力。而 R-去噪器和 X-去噪器 与去噪自编码任务的优化目标更为相似(如式 15.4 所示),二者仅在被掩盖片段的跨度和损坏比例上有所区别。混合去噪器被应用于训练 UL2 和 PaLM-2 等大语言模型。
15.3.2 优化参数设置
与传统神经网络的优化类似,大语言模型通常使用批次梯度下降算法来进行模型参数的调优。同时,通过调整学习率以及优化器中的梯度修正策略,可以进一步提升训练的稳定性。为了防止模型对数据产生过度拟合,训练中还需要引入一系列正则化方法。
基于批次数据的训练
在大语言模型预训练中,通常将批次大小(Batch Size)设置为较大的数值,例如 1M 到 4M 个词元以提高训练的稳定性和吞吐量。现在很多工作都采用了动态批次调整策略,即在训练过程中逐渐增加批次大小,最终达到百万级别。例如,GPT-3 的批次大小从 32K 个词元逐渐增加到 3.2M 个词元。研究表明,动态调整批次大小的策略可以有效地稳定大语言模型的训练过程。
学习率
现有的大语言模型在预训练阶段通常采用相似的学习率调整策略,包括预热阶段和衰减阶段。预热阶段一般占整个训练步骤的 0.1% 至 0.5%,然后学习率便开始进行衰减。
在训练初始阶段,由于参数是随机初始化的,梯度通常也比较大,因此需要使用较小的学习率使得训练较为稳定。训练中通常采用线性预热策略来逐步调整学习率,即学习率将从一个非常小的数值(例如 0 或 1×10−8)线性平稳增加,直到达到预设的最大阈值(通常设定在 5×10−5 到 1×10−4 之间),例如,GPT-3 的最大阈值被设定为 6×10−5、LLaMA 的最大阈值被设定为 1.5×10−4。
学习率达到最大阈值之后就会开始逐渐衰减,以避免在较优点附近来回震荡,最后一般会衰减到最大阈值的 10%。常见的衰减策略有线性衰减、余弦衰减、平方根倒数衰减,它们的学习率变化如图 15-7 所示。
图 15-7 学习率线性衰减、余弦衰减和平方根倒数衰减示意图
优化器
已有大语言模型通常采用 Adam 及其变种 AdamW 作为优化器。Adam 在优化中引入了三个超参数,在大模型训练中通常采用以下设置:β1= 0.9,β2= 0.95 和 ϵ = 10−8。此外,谷歌公司提出了 Adafactor 优化器,引入了特殊设计可以在训练过程中节省显存,被用于 PaLM 和 Flan-T5 等大语言模型的训练。Adafactor 常见的超参数设置如下:β1 = 0.9,β2 = 1.0−k−0.8,其中 k 表示训练步数。
稳定优化技术
在大语言模型的训练过程中,经常会遇到训练不稳定的问题,可以使用下面几种稳定训练技术:
- 梯度裁剪:训练中一种常见的现象是损失的突增。为了解决这一问题,可以采取梯度裁剪(Gradient Clipping),把梯度限制在一个较小的区间内。当梯度的模长超过给定的阈值后,便按照这个阈值进行截断。在大语言模型训练中,这个阈值通常设置为 1.0。
- 训练恢复:为了避免训练过程的异常情况,一种常用的策略是每隔固定的步数设置一些模型存档点。当模型发生了训练异常时,便可以选择前一个存档点重启训练过程。
- 权重衰减:引入正则化技术来稳定训练过程以提高模型的泛化能力。AdamW 优化器中采用了权重衰减(Weight Decay)方法,在每次更新模型参数的时候引入衰减系数,这个系数通常设置为 0.1。
15.3.3 可扩展的训练技术
大语言模型的训练主要面临着两个技术问题:一是如何提高训练效率;二是如何将庞大的模型有效地加载到不同的处理器中。常见的高效训练技术包括 3D 并行训练、零冗余优化器和混合精度训练,如图 15-8 所示。
图 15-8 数据并行、ZeRO、张量并行和流水线并行的示意图
3D 并行训练
3D 并行策略是三种并行训练技术的组合,即数据并行(Data Parallelism)、流水线并行(Pipeline Parallelism)和张量并行(Tensor Parallelism)。
- 数据并行:将模型参数和优化器状态复制到多个 GPU 上,然后将训练数据平均分配给这些 GPU,当所有 GPU 都执行前向传播和反向传播以获取梯度后,再将不同 GPU 的梯度进行平均,以得到整体的梯度来统一更新所有 GPU 上的模型参数。如图 15-8 (a) 所示,四条数据被分成两份, 由两张卡分别计算,这样便等效于执行了批次为 4 的梯度更新。
由于梯度在不同 GPU 上是独立计算的,数据并行机制展现出高度的可扩展性,可以通过增加 GPU 数量来提高训练效率。数据并行技术的实现相对简便,目前 TensorFlow、PyTorch 等深度学习库均已内置了对数据并行策略的支持。
- 流水线并行:将大语言模型不同层的参数分配到不同的 GPU 上。如图 15-8 (d) 中,Transformer 的第 1-2 层部署在 GPU1,将 3-4 层部署在 GPU2。但是 GPU1 在前向传播后需要等待 GPU2 反向传播的结果才能进行梯度传播,即整个流程是“GPU1 前向,GPU2 前向,GPU2 反向,GPU1 反向”,并不能达到真正的并行效果。因此流水线并行通常需要配合梯度累积技术进行优化,例如 GPU1 前向传播完第一个批次后,可以不用等待,继续传播第二个和后续的批次。
延伸
梯度累积(Gradient Accumulation)的主要思想是在计算一个批次的梯度后不立刻更新模型参数,而是累积几个批次后再更新,这样便可以在不增加显存消耗的情况下模拟更大的批次。
- 张量并行:同样将大模型参数加载到多个 GPU 上,但是分配粒度更细,进一步分解了模型的参数张量(即参数矩阵),以便更高效地利用多个 GPU 的并行计算能力。例如对于矩阵乘法操作 WH,参数矩阵 W 可以按列分成两个子矩阵 W1 和 W2,进而原式可以表示为 [W1H,W2H],然后将参数矩阵 W1 和 W2 放置在两张不同的 GPU 上以并行地执行矩阵乘法,最后通过跨 GPU 通信将两个 GPU 的输出组合成最终结果。
目前张量并行已经在多个开源库中得到支持,例如 Megatron-LM 支持对参数矩阵按行按列分块进行张量并行。
零冗余优化器
零冗余优化器(Zero Redundancy Optimizer, ZeRO)技术由 DeepSpeed 代码库提出,用于解决数据并行中的模型冗余问题。从图 15-8 (a) 中可以看到,每个 GPU 都需要存储大语言模型的相同副本,包括模型参数和优化器参数等。但是对于每个 GPU,在模型传播到某一层时,其他层的模型和优化器参数并不参与计算,这导致了严重的显存冗余现象。
ZeRO 技术仅在每个 GPU 上保留部分模型参数和优化器参数,当需要时再从其它 GPU 中读取。如图 15-8 (b) 所示,模型被均分在两张 GPU 上,当需要使用第一层计算时,两张卡分别从对方获取相应的模型参数进行计算,使用完之后便可以释放相应显存。
延伸PyTorch 中也实现了与 ZeRO 相似的技术,称为完全分片数据并行(Fully Sharded Data Parallel, FSDP)。
混合精度训练
早期的预训练语言模型(例如 BERT)主要使用单精度浮点数(FP32)表示模型参数并进行优化计算。近年来,为了训练超大规模参数的语言模型,研发人员提出了混合精度训练(Mixed Precision Training)技术,通过同时使用半精度浮点数(2 个字节)和单精度浮点数(4 个字节)进行运算,以实现显存开销减半、训练效率翻倍的效果。
具体来说,为了保证表示精度,需要保留原始 32 位模型的参数副本。但在训练过程中,会先将这些 32 位参数转换为 16 位参数,随后以 16 位精度执行前向传播和反向传播等操作,最后在参数更新时再对 32 位模型进行优化。由于前向传播和反向传播占用了绝大部分的优化时间,混合精度训练能够显著提升模型的训练效率。
常见的半精度浮点数表示方式为 FP16,其包含 1 位符号位、5 位指数位和 10 位尾数位,表示范围为 −65504 到 65504。谷歌后来提出了新的半精度浮点数表示 BF16,其包含 1 位符 号位、8 位指数位和 7 位尾数位,表示范围可达 1038 数量级。
目前较为主流的 GPU(例如 Nvidia A100)都支持 16 位计算单元运算,因此混合精度训练能够被硬件很好地支持。
第十六章:使用大语言模型
在上一章中我们讨论了预训练阶段的技术细节,本章将关注大语言模型构建过程的第二个阶段——微调(包括指令微调与人类对齐),以及最终的实际使用方法。
16.1 指令微调
如 14.2.2 节指令微调与人类对齐中所述,指令微调首先收集或构建自然语言形式的指令实例,然后通过有监督的方式对大语言模型的参数进行微调,因此又被称为有监督微调(Supervised Fine-tuning, SFT)或多任务提示训练(Multitask Prompted Training)。指令微调后的大语言模型能够具有较强的指令遵循能力,从而可以通过零样本学习的方式解决多种下游任务。
本节将首先介绍指令数据的构建方法和相应的训练策略,然后介绍低资源场景下的参数高效微调方法。
16.1.1 指令数据的构建
指令格式化的数据实例一般包括任务描述(也称为指令)、任务输入-任务输出以及可选的示例。本教程已经在14.2.4 节常用微调数据集中介绍了一些代表性的指令数据集。下面将介绍三种构建格式化指令数据的方法。
基于现有的 NLP 任务数据集构建
已有的 NLP 任务数据集是非常重要的数据资源,可以用于构造指令数据集。这些集合中的数据一般包括输入和输出两个部分,例如在中英翻译任务中,输入是“大语言模型…”、输出则是“Large language models…”。因此,生成指令化数据的关键步骤就是为“输入-输出”添加任务描述信息(指令),用于指导模型理解任务目标。例如对于前面的翻译任务,就可以添加指令“请把这个中文句子翻译成英文”。
相关研究表明在现有 NLP 数据集的“输入-输出”数据中添加合适的任务描述是提升大模型指令跟随能力的关键。如果去除任务描述,仅使用“输入-输出”数据对模型进行微调,性能会出现显著下降。
图 16-1 现有 NLP 数据集的指令格式化示意图
为此,研究者开发了众包平台 PromptSource,能够高效地支持标注人员为不同数据集创建、共享及验证任务描述,如图 16-1 所示。此外,还可以设计特定的任务描述来翻转现有实例的“输入-输出”对,例如,对于“问题-答案”对,既可以基于问题预测答案,也可以基于答案生成问题,比如使用指令“请基于以下答案生成一个问题:”。
延伸如 14.2.4 节常用微调数据集中所述,谷歌按照一定的比例混合了 FLAN、P3、SuperNatural Instructions 等集合以及思维链数据,推出了包含约 20M 条实例的指令微调数据集 FLAN v2,该数据集目前是微调大语言模型的首选指令集合。
基于日常对话数据构建
指令格式化已有的 NLP 数据集虽然能够获得大量实例,但是数据多样性比较小而且也不能很好匹配人类的真实需求。因此另一种方式是使用用户在日常对话中的实际需求作为任务描述,例如 InstructGPT 将用户提交给 OpenAI API 的查询作为任务描述。
最近研究者开源了一些由人工精心标注的日常对话指令集,例如 Dolly 和 OpenAssistant。此外,研究者还尝试通过自行构建的开放平台收集大量的用户对话请求作为输入数据,并使用 ChatGPT 或 GPT-4 生成回复作为输出,ShareGPT 数据集便是一个代表。
基于合成数据构建
为了减轻人工收集与标注数据的负担,研究者进一步提出半自动化的数据合成方法,将已有的高质量指令数据作为上下文学习示例输入大语言模型, 然后生成大量多样化的任务描述和输入-输出数据,如图 16-2 所示。代表性方法 Self-Instruct 仅需要使用 100 多个人工撰写的实例作为初始任务池,然后随机选择数据作为示例,就可以通过大语言模型生成新的指令微调数据。
图 16-2 合成指令数据示意图
考虑到 Self-Instruct 生成的实例可能过于简单或缺乏多样性,研究者还提出了改进的指令数据合成方法 Evol-Instruct,该方法通过基于深度和广度的演化来提高实例的复杂性和多样性。
延伸研究表明一定数量的高质量指令就可以激活大语言模型的对话能力。例如 Alpaca 使用了 52K 条 合成数据来指令微调 LLaMA (7B),在 179 条日常对话数据的评测中到达了接近 text-davinci-003 的效果。LIMA 仅使用了一千条人工标注的高质量指令数据来微调 LLaMA (65B),就在 300 条日常对话测试中取得了较好的模型表现。
因此,指令的质量比数量更为重要。指令微调中应优先使用人工标注的多样性指令数据。在实践中可以使用 ChatGPT、GPT-4 等闭源大语言模型来合成、重写、筛选现有指令。例如 Alpagasus 从原始的 52K 条 Alpaca 数据中筛选出 GPT-4 评分较高的 9 千条进行指令微调训练,便可达到与原始 52K 条数据接近的效果。
16.1.2 参数高效微调方法 LoRA
通过指令微调,大语言模型能够更好地遵循和执行人类指令。但是,由于大语言模型的参数量巨大,进行全参数微调需要较多的算力资源。因此研究者提出了参数高效微调(Parameter-efficient Fine-tuning),也称为轻量化微调(Lightweight Fine-tuning),在减少训练参数量的同时使得模型性能能够与全量微调相媲美。
其中,低秩适配(Low-Rank Adaptation, LoRA)是大语言模型最常用的参数高效微调方法,其通过在模型的参数矩阵上添加低秩分解矩阵来近似每层的参数更新,如图 16-3 所示。
图 16-3 LoRA 微调示意图
给定一个参数矩阵 W,其更新过程可以一般性地表达为如式 (16.1) 所示:
(16.1)W=W0+ΔW
其中 W0 是原始参数矩阵,ΔW 是更新的梯度矩阵。LoRA 的基本思想是冻结原始矩阵 W0∈RH×H,通过低秩分解矩阵 A∈RH×R 和 B∈RH×R 来近似参数更新矩阵 ΔW=A⋅B⊤,其中 R≪H 是减小后的秩。在微调期间,原始的矩阵参数 ΔW 不会被更新,低秩分解矩阵 A 和 B 则是可训练参数用于适配下游任务。
在前向传播过程中,原始计算中间状态 h=W0⋅x 的公式修改为:
(16.2)h=W0⋅x+A⋅B⊤⋅x
在训练完成后,进一步将原始参数矩阵 W0 和训练得到的权重 A 和 B 进行合并:W=W0+A⋅B⊤,得到更新后的参数矩阵。因此,LoRA 微调得到的模型在解码过程中不会增加额外的开销。
延伸LoRA 微调方法还存在一些变种。例如 AdaLoRA 引入了动态低秩适应技术,在训练过程中动态调整每个参数矩阵需要训练的秩同时控制训练的参数总量。QLoRA 将原始的参数矩阵量化为 4 比特,而低秩参数部分仍使用 16 比特进行训练,在保持微调效果的同时进一步节省了显存开销。
16.2 人类对齐
经过预训练和指令微调后,大语言模型具备了解决各种任务的通用能力和指令遵循能力,但是受到数据质量、数据来源以及具体创作者等多方面的影响,也可能生成有偏见的、冒犯的以及事实错误的文本内容。因此,如何确保大语言模型的行为与人类价值观、人类真实意图和社会伦理相一致成为了一个重要问题,称为人类对齐(Human Alignment)。
16.2.1 基于人类反馈的强化学习
由于有用性(Helpfulness)、诚实性(Honesty)和无害性(Harmlessness)等对齐标准难以通过形式化的优化目标进行建模,因此研究者提出了基于人类反馈的强化学习(Reinforcement Learning from Human Feedback, RLHF),引入人类反馈对大语言模型的行为进行指导。
RLHF 首先使用收集到的人类偏好数据训练奖励模型,最后基于奖励模型使用强化学习算法(例如 Proximal Policy Optimization, PPO)微调大语言模型。RLHF 的完整工作流程可以分为监督微调、奖励模型训练、强化学习微调三个阶段,如图 16-4 所示。
图 16-4 RLHF 的工作流程
阶段一、监督微调
为了让待对齐语言模型具有较好的指令遵循能力,首先需要收集高质量的指令数据进行监督微调,即 16.1 节中介绍的指令微调。
阶段二、奖励模型训练
第二步是使用人类反馈数据训练奖励模型。具体来说,首先使用模型对任务指令生成一定数量的候选输出;然后邀请标注员对输出文本进行偏好标注,可以采用多种形式,最常用的是对候选文本进行排序标注(可以有效减少多个标注员之间的不一致情况);最后使用人工标注的偏好数据训练奖励模型,使其能够建模人类偏好。例如在 InstructGPT 中,标注员将模型生成的输出按照最佳到最差的顺序进行排序,然后训练奖励模型来预测这个排序。
延伸在现有工作中,主要有两种人类反馈数据的形式:基于评分的人类反馈和基于排序的人类反馈。
最直接的标注方式是根据预设的标准邀请标注人员对模型的输出进行打分,例如遵守某条规则记 1 分而违反某条规则扣 1 分。除了人工标注外,还可以使用经过对齐的大语言模型对于特定对齐标准进行标注。
排序是另一种典型的人类偏好标注形式。最简单的方式是要求标注人员根据自身偏好对大语言模型的输出进行全排序,但是这种方 式需要花费较多的人力成本。目前更常见的做法是引入竞技比赛中衡量参与者相对实力的 Elo 评分系统,通过对模型输出进行两两比较,进而计算每个输出的综合得分并获得最终的输出排序。
奖励模型的训练方式主要包括如下三种形式:
- 打分式:对给定的输入问题,为相应的输出赋予反馈分数,通常是离散的数值,用于表示输出与人类偏好的契合程度。一般采用均方误差(Mean Square Error, MSE)作为打分式训练方法的目标函数。
- 对比式:仅需对两条输出进行排序,排序在前的输出被视为正例(更符合人类偏好),另一条输出则被视为负例。这种标注方式不仅降低了标注难度,还提高了不同标注者之间的一致性。通常采用对比学习方法训练奖励模型。
- 排序式:可以被视为对比式训练方法的增强形式。对于一个给定的输入,对多个模型回复进行排序。通过标注的顺序,可以获得回复之间的相对优劣关系,即哪些回复更符合人类价值观。通常采用与对比式方法类似的学习策略训练奖励模型。
步骤三:强化学习训练
待对齐语言模型担任策略实施者(称为策略模型),它接收提示作为输入并返回输出文本,其动作空间是词汇表中的所有词元,状态指的是当前已生成的词元序列。奖励模型则根据当前语言模型的状态提供相应的奖励分数,用于指导策略模型的优化。为了避免语言模型明显偏离初始模型,通常会在优化目标中加入一个惩罚项,如当前语言模型与初始语言模型生成结果之间的 KL 散度。这个对齐过程可以进行多次迭代,从而更好地对齐大语言模型。
延伸目前 RLHF 中最常使用的是近端策略优化(Proximal Policy Optimization, PPO)算法,在策略梯度(Policy Gradient)算法的基础上,使用优势估计来更加准确的评估决策轨迹能获得的奖励,并且使用重要性采样来进行离线策略训练。此外,为了保证重要性采样的稳定性,PPO 算法通过在目标函数中加入梯度裁剪以及相关的惩罚项来减小采样误差。
为了能够实现上述优化过程,PPO 算法在策略模型和奖励模型的基础上,还引入了参考模型和评价模型。
16.2.2 非强化学习的对齐方法
尽管 RLHF 是一种有效的对齐技术,但是它也存在一 些局限性:首先,RLHF 训练过程中需要同时维护和更新多个模型,包括策略模型、奖励模型、参考模型以及评价模型,不仅会占用大量的内存,而且算法的执行过程也相对复杂;其次,RLHF 中常用的 PPO 算法在优化过程中稳定性不佳,对超参数的取值较为敏感,进一步增加了模型训练的难度和不确定性。
为此,研究人员提出了一系列直接基于监督微调的对齐方法,以避免复杂的强化学习算法所带来的种种问题。
非强化学习的对齐方法旨在利用高质量的对齐数据集,通过特定的监督学习算法对大语言模型进行微调。这类方法需要建立精心构造的高质量对齐数据集,利用其中蕴含的人类价值观信息来指导模型正确地响应人类指令或规避生成不安全内容。与指令微调方法不同,基于监督微调的对齐方法需要在优化过程中使得模型能够区分对齐的数据和未对齐的数据(或者对齐质量的高低),进而直接从这些数据中学习到人类期望的行为模式。
对齐数据的收集
为了构建有效的对齐数据集,一些方法尝试利用训练好的奖励模型对众多候选输出进行打分或者排序,筛选出最符合人类偏好的数据;其他方法则利用经过对齐的大语言模型(例如 ChatGPT)来构造训练数据。
代表性监督对齐算法 DPO
直接偏好优化(Direct Preference Optimization, DPO)是一种不需要强化学习的对齐算法,其主要思想是建立决策函数与奖励函数之间的关系,以规避奖励建模的过程。这样,大语言模型就能够通过与强化学习等价的形式学习到人类的价值观和偏好。
16.2.3 SFT 和 RLHF 的进一步讨论
如 16.1 节所述,指令微调是一种基于格式化的指令示例数据(即任务描述与期望输出相配对的数据)对大语言模型进行训练的过程,也被称为监督微调(Supervised Fine-Tuning, SFT),而 RLHF 算法的第一个步骤就是监督微调(SFT)。本节将深入探讨这两者之间的联系与差异。
学习方式
在学习方式方面,RLHF 首先学习一个奖励模型,然后利用该奖励模型通过强化学习算法(如 PPO)来改进大语言模型。而 SFT 则采用了 Teacher-Forcing 的方法,直接优化模型对实例输出的预测概率。从本质上说,SFT 这种词元级别的训练方式是一种“行为克隆”,它利用教师的行为数据(即每个步骤的目标词元)作为监督标签,来直接训练大语言模型模仿教师的行为。
SFT 的优缺点
SFT 在实现上简单、灵活、可拓展性较强,还可以用于构建很多特定功能,例如帮助大语言模型建立聊天机器人的身份。但是其作用在于“解锁”大语言模型的能力,而非向大语言模型“注入”新能力。因此,试图通过 SFT 激发大语言模型的非内生能力时,可能会出现一些负面问题。当待学习的标注指令数据超出了大语言模型的知识或能力范围,例如训练大语言模型回答关于模型未知事实的问题时,可能会加重模型的幻觉(Hallucination)行为。此外,作为一种基于行为克隆的学习方法,SFT 旨在模仿构建标注数据的教师的行为,而无法在这一过程中进行有效的行为探索。因此,高质量的指令数据(而非数量)是影响大语言模型训练的主要因素。
RLHF 的优缺点
在数据标注阶段,RLHF 相比 SFT 具有两项潜在优势:首先,RLHF 算法的标注员主要为训练过程提供偏好标注数据,而不是直接生成示例数据,因此它可以减少标注员之间的不一致;其次,与编写示例数据相比,偏好标注更为简单易行,标注员甚至可以评估超出自己创作水平模型的输出质量,使得模型能够探索标注员能力之外的状态空间,而不用受限于给定的教师示例。
在模型学习阶段,RLHF 通过对比模型的输出数据(区分“好”输出与“坏” 输出)来指导大语言模型学习正确的生成策略,它不再强迫大语言模型模仿教师的示例数据,因此可以缓解 SFT 所导致的幻觉问题。然而,RLHF 也继承了经典强化学习算法的缺点,如样本学习效率低和训练过程不稳定等问题。 因此,RLHF 需要依赖经过 SFT 的模型作为策略模型的初始模型,从而快速达到较好的表现。此外,RLHF 的过程通常会持续多轮,其中涉及了很多重要细节的设定(例如提示选择、奖励模型训练、PPO 的超参数设置以及训练过程中对超参数的调整),都会影响整个模型的性能,对于复现提出了较大挑战。
总的来说,SFT 特别适合预训练后增强模型的性能,具有实现简单、快速高效等优点;而 RLHF 可在此基础上规避可能的有害行为并进一步提高模型性能,但是实现较为困难,不易进行高效优化。
16.3 使用大语言模型
16.3.1 解码加速算法
大语言模型是通过文本生成的方式进行工作的。在自回归架构中,模型针对输入内容 (即提示文本)逐个单词生成输出内容的文本,这个过程被称为解码。因此如 10.4 节中介绍的那样,需要束搜索、Top-k 采样、Top-p 采样等解码策略来生成输出内容。但是由于自回归算法的序列化生成的特点,解码算法存在效率较低的问题。目前一般通过系统级优化方法以及解码策略优化方法来缓解这一问题。
系统级优化
一些研究工作提出了系统级优化方法来实现减少访存量的目的。
- FlashAttention:一种针对原始注意力模块的优化方案,可以大幅减少注意力计算中的访存量,其核心思路是尽可能减少对于中间结果的保存,进而直接得到最终结果。FlashAttention 通过矩阵分块和算子融合等方法,将中间结果一直保留在缓存中,直到获得最终结果后再写回显存中,从而减少了显存读写量。
延伸
FlashAttention 有效地减少了访存量,同时也降低了峰值显存的占用量。使用了 FlashAttention 的 LLaMA-2 (7B) 在序列长度为 2048、批次大小为 8 的情况下,注意力操作的时间仅需传统注意力的十分之一。
- PagedAttention:针对键值缓存拼接和注意力计算的优化方案,能够有效降低这两个运算部分的访存量。在键值缓存拼接方面,PagedAttention 引入了操作系统中显存分页管理的方法,预先将显存划分成若干块给之后的键值缓存“预留空间”,从而显著减少了拼接时反复分配显存的操作。在注意力计算方面,PagedAttention 使用算子融合的方法将查询向量与多个分页的键值缓存并行地进行计算,从而减少其访存量。
- 批次管理优化:一个代表性的方法连续批处理(Continuous Batching)技术,通过启发式算法选择部分请求进行全量解码操作,或者选择一些请求进行单步增量解码操作,从而在一步操作中能够容纳更多的请求(相当于提高批次大小)。此外,DeepSpeed-MII 提出了动态分割技术,将全量解码部分进一步拆分为多个子操作,可以在一次计算中选择一些请求同时进行全量解码和增量解码操作,进而获得更大的批次和更高的解码吞吐量。
通过批次管理优化技术细粒度地分割不同请求的处理阶段,使得不同请求的处理过程可以同时进行,从而实现更为高效的线上服务。
解码策略优化
还有许多研究工作提出了针对自回归解码策略的改进方法以提高解码效率。
- 推测解码:首先使用相对较小但解码更为高效的模型(例如 n 元统计模型或者小型预训练模型)自回归地生成若干个词元,然后再由大模型对这个片段进行一次验证(大模型一次验证与一次解码的时间消耗相当)来判断是否每个词元都是当前步骤概率最高的输出,随后大小模型持续迭代此过程直到解码结束。
推测解码不会降低大模型解码的质量, 实验测试表明能够带来约两倍左右的解码加速,是目前使用较多的解码策略优化方案。
- 级联解码:使用不同规模的模型来处理难易度不同的请求。引入一系列效率从高到低的模型,然后将请求依次给这些模型进行生成,再由一个专门训练的二分类模型来判断生成结果是否符合要求,如果结果可靠就不需要再给之后的模型进行生成。
- 非自回归解码:机器翻译领域的研究人员提出了非自回归解码机制,可以基于输入并行地一次性生成所有的词元。但是这种方法的生成质量往往与自回归方法有一定差距,因此有研究工作提出了半自回归解码,每一次生成一组词元(例如 3 至 10 个),再以这些词元作为输入继续生成下一组。
然而,现有的大模型都是预测下一个词进行预训练的, 无法直接进行非(半)自回归生成。为此,Medusa 在 Vicuna 模型的基础上,额外训练了两个预测头来分别预测第二个词和第三个词,因此可以一次生成三个词元。
注意
尽管非(半)自回归策略在效率上有所提升,但仍然不能达到自回归解码的效果,因此其很少单独使用。通常用于推测解码中生成候选片段。
16.3.2 低资源部署策略
由于大模型参数量巨大,在解码阶段需要占用大量的显存资源,因而在实际应用中的部署代价非常高。为此,研究者提出了模型量化(Model Quantization)技术来减少大模型的显存占用。
量化是指从浮点数到整数的映射过程,比较常用的是 8 比特整数量化,即 INT8 量化。神经网络模型通常有两种类型的数据需要进行量化,分别为权重量化(也称模型参数量化)和激活值量化。量化的过程可以表示为一个函数,该函数将连续的输入映射到离散的输出集合。一般来说,这个过程涉及到四舍五入或截断等近似操作。作为上述变换的逆过程,反量化(Dequantization)对应地从量化值中恢复原始值。
大语言模型相关的量化方法大致上可以分为两大类:量化感知训练(Quantization-Aware Training, QAT)和训练后量化(Post-Training Quantization, PTQ)。其中量化感知训练方法需要更新权重进而完成模型量化,而训练后量化方法则无需更新模型权重。一般来说,训练后量化方法需要更少的算力开销,实践中应用更为广泛。
针对大语言模型的量化研究受到了学术界的广泛关注,涌现了一批经验性的研究工作。下面针对目前的研究结论进行简要汇总:
- INT8 权重量化通常对于大语言模型性能的影响较小,更低精度权重量化的效果取决于具体的量化方法。在大多数情况下,INT8 权重量化可以有效地减小显存占用而不显著影响模型性能。对于 INT4(或 INT3)权重量化,现有的方法通常使用不同策略(例如激活感知缩放)来减少性能下降。
低比特权重量化对于大语言模型的影响通常较小。因此,在相同显存开销的情况下,建议优先使用参数规模较大的语言模型,而不是表示精度较高的语言模型。例如量化精度为 4 比特的 60GB 语言模型在性能上往往会优于量化精度 8 比特的 30GB 语言模型。
- 轻量化微调方法可以用于补偿量化大语言模型的性能损失。大语言模型在超低比特权重量化时(如 2 比特量化),可能会出现预测精度的大幅下 降,此时可以通过轻量化微调(如 LoRA )的方式来进行性能补偿。具体来说,对不微调的模型权重进行低比特量化,而对于微调的适配器参数则使用 16 比特浮点数表示并使用 LoRA 算法进行微调。在推理时,量化部分的模型权重会先反量化为 16 比特浮点数,再与适配器权重相加进行融合使用。此外,QLoRA 更为针对性地设计了面向量化模型的性能补偿方法,在轻量化微调的同时还考虑了显存优化。实验表明,QLoRA 在基于 4 比特量化模型的微调后,能够获得与 16 比特模型全参数微调以及 LoRA 微调相似的效果。
16.3.3 模型蒸馏和模型剪枝
除了模型量化之外,模型蒸馏和模型剪枝也是常用的模型压缩方法,它们通过直接精简模型结构以减少参数数量。
模型蒸馏
模型蒸馏(Model Distillation)的目标是将复杂模型(称为教师模型)包含的知识迁移到简单模型(称为学生模型)中。以分类问题 为例,模型蒸馏的核心思想是引入额外的损失函数(称为蒸馏损失函数),训练学生模型的输出尽可能接近教师模型的输出。在实际应用中,蒸馏损失函数通常与分类损失函数(交叉熵损失函数)联合用于训练学生模型。
传统的模型蒸馏方法包括两种:基于反馈的知识蒸馏方法和基于特征的知识蒸馏方法。如图 16-5 所示。
图 16-5 基于反馈的知识蒸馏和基于特征的知识蒸馏
基于反馈的知识蒸馏主要关注教师模型最后一层输出的 logits,这些 logits 经过 softmax 变换后作为学生模型的“软标签”进行学习,优化目标是让学生模型输出的 logits 去近似教师模型输出的 logits,从而学习到教师模型的特有知识;而基于特征的知识蒸馏则关注教师模型的中间层输出的激活值,并使用这些激活值作为监督信息训练学生模型。相较于最终的预测分布,中间层特征提供了更为丰富的模型信息,有助于在模型蒸馏过程中实现更为有效的知识迁移。然而,这种方法也存在一些技术难点,如消除架构不一致的影响、目标层自动化选择等。
面向大语言模型的知识蒸馏旨在将大语言模型(教师模型)包含的知识迁移到小模型(学生模型)中。根据大语言模型权重是否可以获得,可以分别使用白盒模型蒸馏方法和黑盒模型蒸馏方法。其中,白盒模型蒸馏方法可以获取模型的权重来指导学生模型,典型的方法为 MINILLM;而黑盒模型蒸馏方法无法获得模型权重,只能使用大语言模型的输出信息来训练小模型,目前主要关注于蒸馏大模型的关键 能力,如上下文学习能力、思维链推理能力以及指令遵循能力。
相关以思维链推理能力的蒸馏为例,可以利用大语言模型生成输入样本的思维链推理过程,作为标签以外的额外补充信息来帮助引导小模型学习。这种方法通过引入大模型对于问题的求解思路来提供额外的监督信息,从而让学生模型同时学习预测标签以及大模型生成的推理过程解释。
模型剪枝
模型剪枝(Model Pruning)的目标是在尽可能不损失模型性能的情况下,努力消减模型的参数数量。
传统模型剪枝方法一般可以被分为结构化剪枝和非结构化剪枝。结构化剪枝(Structured Pruning)旨在去除对于性能影响较小的模型组件,可以删除神经元、通道甚至中间层。其核心思想是在尽量保持模型预测精度的条件下,去除那些对于结果影响不大的结构单元,如注意力机制中的注意力头、前馈层权重中的特定维度等。而非结构化剪枝(Unstructured Pruning) 则主要关注去除模型权重矩阵中不重要的数值(不修改模型结构)。一般来说,非结构化剪枝会创建一个包含 0/1 的掩码矩阵,并将其与原始的权重相乘,其中 0 所在位置的权重则不会在模型的计算中产生作用。当剪枝完成后,那些被剪枝掉的位置只会存储数值 0,从而节省存储空间。
延伸在实际应用中,非结构化剪枝一般可以实现更高的剪枝比率,但是不会显著加速模型的计算过程,因为被掩码的部分可能仍然需要参与计算。而结构化剪枝通过去除结构单元,可以显著减少所需的矩阵乘法次数,实现模型的压缩和加速。
与传统的模型剪枝类似,大语言模型的剪枝方法分为结构化和非结构化剪枝两类。其中,非结构化剪枝一般容易获得更高的压缩率,典型工作包括 SparseGPT,其可以实现 60% 模型参数的剪枝并较好保持困惑度。大模型结构化剪枝的研究也取得了较好的模型压缩效果,例如 LLM-prune 在 LLaMA (7B) 上剪枝了 20% 的参数并保持 93.6% 的预测精度、Sheared LLaMA 则将 LLaMA-2 (7B) 剪枝到 2.7B 参数规模并保持 87.8% 的预测精度。
16.3.4 提示学习
使用大语言模型解决实际任务常用的方法是设计合适的提示(Prompting),通过自然语言接口与大模型进行交互。目前,提示的设计主要依靠人工设计和自动优化两种策略来实现。为了更好地解决未见过的任务,一种典型的提示方法是上下文学习(In-context Learning, ICL),它将任务描述与示例以自然语言文本形式加入到提示中。此外,思维链提示(Chain-of-Thought, CoT)作为一种增强技术,将一系列中间推理步骤加入 到提示中,以增强复杂推理任务的解决效果。
关于提示学习的详细介绍请见附录A。
16.3.5 大模型应用
本节将围绕具有代表性的自然语言处理和信息检索领域介绍大模型的应用。
自然语言处理
下面简要介绍一下大语言模型在三大经典任务上的应用,包括序列标注、关系抽取以及文本生成,这些任务构成了许多自然语言处理系统和应用的基础,如图 16-6 所示。
图 16-6 大语言模型应用于自然语言处理任务
对于序列标注任务,大语言模型只需通过上下文学习或构建特殊提示就能解决,而无须使用 B-I-O 标记。例如,仅需要给出提示(如“请识别出句子中包含的实体”)或任务示例(如“输入文本‘中华人民共和国今天成立了’,请抽取出其所包含的命名实体:‘中华人民共和国’”)即可自动抽取出实体。
对于关系抽取任务,大语言模型同样能够借助特定提示方法(如上下文学习等)来完成。但是,当关系标签规模较为庞大时,这些知识信息难以完全通过上下文学习的方式注入到大语言模型中,可能会导致效果较差。此时可以结合大语言模型和小模型,例如先利用小模型进行候选关系的初筛,再利用大模型进一步从初筛后的候选关系中推理出最合适关系;也可以采用大语言模型对数据进行初步标注,从而丰富可用于训练的小模型的标注数据。
文本生成任务,只需通过适当的提示方法,大语言模型就能展现出接近人类的表现。然而,大语言模型难以有效处理低资源语言或领域下的文本生成任务,例如马拉地语到英语的翻译。
信息检索
大语言模型拥有强大的语言理解、推理与生成能力,能够助力构建更为智能的信息检索系统;而信息检索技术能够高效地从外界获取所需要的相关信息,可以为大语言模型提供更为精确、可靠的上下文信息。如图 16-7 所示。
图 16-8 大语言模型应用于信息检索
在大语言模型提升信息检索任务方面,主要包括利用大语言模型进行信息检索和大语言模型增强的信息检索模型。
- 利用大语言模型进行信息检索:现代信息检索系统通常采用“检索-重排序”的流水线框架。利用大语言模型改进信息检索系统的工作可以分为两类。第一类在检索数据上微调大语言模型以构建检索器或重排序器,利用大语言模型的语义理解能力提高检索效果;第二类通过设计特殊的指令(例如“请判断下述查询和文档的相关程度”),直接引导大语言模型生成两者的相关程度(如相关度分类或者打分),用于对候选文档集合进行排序。出于效率考虑,大多数工作将大语言模型应用于重排序阶段。
- 大语言模型增强的信息检索模型:大语言模型出色的语义理解与生成能力可以为信息检索模型补充相关性信息。一类方法通过构造特殊的提示,使得大语言模型能够充当标注者的角色,以较低成本完成大规模训练数据的标注工作,为传统检索模型补充高质量标注数据;第二类方法同样通过设计特殊的提示,利用大语言模型对输入查询进行改写,辅助信息检索模型精准理解用户的需求。此外,还可以利用大语言模型对查询进行详细解释和扩充。
在检索增强的大语言模型方面,考虑到大语言模型受限于训练数据的时效性和局限性,研究者提出了检索增强生成(Retrieval-Augmented Generation, RAG)技术。该技术旨在通过信息检索系统从外部知识库中获取相关信息,为大语言模型提供时效性强、领域相关的外部知识。然而,检索返回的结果可能由于检索质量、呈现格式、输入长度等问题,导致大语言模型不能很好地利用这些信息。此时,可以通过输入优化、指令微调和预训练策略进行缓解。
- 输入优化策略:在检索增强生成的场景中,大语言模型主要面临两个挑战。首先,当处理包含多个参考文档的长文本时,其信息利用能力往往会下降;其次,检索到的结果中可能包含与任务无关的文档,这可能会干扰模型对关键信息的识别和处理。为此,可以使用过滤、压缩、摘要等技术,在文档和词元两个层级优化模型输入。
- 指令微调策略:构造面向检索文档利用的指令数据,通过对大语言模型进行微调,提升其对文档信息的处理和理解能力。首先,需要确保模型能够平等地关注输入中不同位置(如开头、中间、结尾)的内容,以缓解某些位置的信息容易被忽略的问题;其次,当存在不相关信息时,大语言模型应尽可能地避免被这些信息干扰。
- 预训练策略:如果大语言模型最终主要用于信息检索任务,还可以在预训练阶段采用特殊的学习任务对其检索生成能力进行针对性的加强。一种常见的策略是将语料库中文档的标题或第一段文字作为查询,其余内容作为期望的生成结果。也有一些工作将每段话分为长度相等的两部分,其中前半部分作为查询,后半部分作为期望的生成结果。