Transformers 学习总结
Transformers 是什么
Transformers 是由 Hugging Face 开发的一个自然语言处理(NLP)包,支持加载目前流行的各种预训练模型。
随着 BERT、GPT 等语言模型的兴起,越来越多的公司和研究者采用 Transformers 库来构建 NLP 应用。
发展史
自然语言处理发展史
基于语言学规则的方法 -> 基于数学模型和统计的方法语言模型发展史
统计语言模型 -> 神经语言模型 -> 预训练语言模型 -> 大语言模型
2003年,本吉奥(Bengio)提出了神经网络语言模型(NNLM)
2013年,Google 公司提出的 Word2Vec 模型
2017年,Google 提出了基于注意力机制的 Transformer 模型,该模型成为引领后续人工智能技术变革的基础神经网络架构
2018年,研究者提出了 ELMo 模型
2018年,OpenAI 将 ELMo 模型中的 LSTM 更换为 Transformer 提出了 GPT 模型
2018年底,Google 基于 Transformer 模型进一步提出了 BERT 模型
2022年11月30日,OpenAI 公司发布了面向普通消费者的 ChatGPT 模型
Transformer 模型
起源
2017 年 Google 在论文《Attention Is All You Need》中提出了 Transformer 模型用于序列标注,在翻译任务上超过了先前最优秀的循环神经网络模型。
与此同时,Fast AI 在论文《Universal Language Model Fine-tuning for Text Classification》中提出了一种名为 ULMFiT 的迁移学习方法,将在大规模数据上预训练好的 LSTM 模型迁移用于文本分类,只用很少的标注数据就达到了最佳性能。
这些具有开创性的工作促成了两个著名 Transformer 模型的出现:
- GPT
- BERT
通过将 Transformer 架构与无监督学习相结合,我们不再需要对每一个任务都从头开始训练模型,并且几乎在所有 NLP 任务上都远远超过先前的最强基线。
定义
Transformer 是语言模型
旨在准确建模文本序列的生成概率,因此主流的做法就是采用自监督学习的方式在大量原始文本(称为生语料)上进行训练,让模型具备上下文感知的语义表征能力。
预训练任务:
- 因果语言建模(CLM):基于句子的前 𝑛 个词来预测下一个词,因为输出依赖于之前和当前的输入。
- 遮盖语言建模(MLM):基于上下文,即周围的词语,来预测句子中被遮盖掉的词语(Masked Word)。
- 迁移学习(Tansfer Learning):使用特定任务的标注语料以监督学习的方式对模型参数进行微调 (fine-tune),使得模型适配特定任务,从而有效提升模型在下游任务上的性能。
Transformer 是大型模型
大部分 Transformer 模型都为了取得更好的性能而不断增加模型规模(参数量)和增加预训练数据。
延伸
现在也有一些工作致力于在尽可能保持模型性能的情况下大幅减少参数量,达到用“小模型”获得媲美“大模型”的效果,例如模型蒸馏。
模型微调
预训练是一种从头开始训练模型的方式
所有的模型权重先被随机初始化,然后在没有任何先验知识的情况下开始训练。
这个过程不仅需要海量的训练数据,而且时间和经济成本都非常高。
因此,在大部分情况下我们都是将别人训练好的模型权重通过迁移学习的方式应用到自己的模型中,使用标注好的任务语料对模型进行“二次训练”,使模型适配于特定任务,这个过程就称为模型微调(Fine-Tuning)。
这种迁移学习的好处是:
预训练模型很可能已经在一个与微调数据集存在相似处的数据集上进行了训练,通过微调可以充分利用模型在预训练过程中获得的知识。
由于模型已经在大量数据上进行过预训练,因此微调时只需要很少的数据量就可以达到不错的性能。
例如,我们可以选择一个在大规模英文语料上预训练好的模型,使用 arXiv 语料进行微调,以生成一个面向学术/研究领域的模型。这个微调的过程只需要很少的数据,相当于将预训练模型已经获得的知识“迁移”到了新的领域,因此被称为迁移学习。
与从头训练相比,微调模型所需的时间、数据、经济和环境成本都要低得多,并且与预训练相比,微调训练的约束更少,因此迭代尝试不同的微调方案也更快更容易。
实践证明,除非你有海量数据,否则相比训练一个专门的模型,选择一个尽可能接近实际任务的预训练模型进行微调会是更好选择,也就是所谓“站在巨人的肩膀上”。
模型
组成
标准 Transformer 模型主要由编码器(Encoder)和解码器(Decoder)两个模块组成。
编码器:负责接收输入并构建输入的语义表示(语义特征),从而理解输入内容。
解码器:利用编码器输出的语义表示(语义特征)以及前序输出来生成目标序列。
特别地,这两个模块都可以根据任务需求而单独使用。
通常来说,纯编码器模型(Encoder-Only)更适用于需要理解输入的任务,例如句子分类、命名实体识别等。
纯解码器模型(Decoder-Only)更适用于生成式任务,例如文本生成。
编码器-解码器模型(Encoder-Decoder)或序列到序列模型(Sequence-to-Sequence)则适用于需要基于输入的生成式任务,例如翻译、摘要等。
注意力层
Transformer 模型的一个关键特性是它由称为注意力层的特殊层构成(Attention Layers),如先前所述,提出 Transformer 结构的论文就叫《Attention Is All You Need》。顾名思义,注意力层的作用就是让模型在处理文本时,将注意力放在输入句子中的某些词上,并或多或少地忽略其他词。
为解决上下文和多义词的相关问题。
原始架构
Transformer 模型最初是为翻译任务设计的。
在训练过程中,编码器接收句子作为输入,而解码器则接受目标语言的相同句子作为输入。
在编码器中,由于翻译一个词语需要依赖于上下文,因此注意力层可以访问句子中的所有词,而解码器则是按顺序工作,在生成每个词时,注意力层只能访问前面已经翻译出的单词。例如,假设当前翻译模型已经翻译出了三个词,此时会把这三个词作为输入送入解码器,然后解码器会结合编码器的所有输入来预测第四个词。
其中,解码器模块中的第一个注意力层关注解码器所有先前的输出,而第二个注意力层则是使用编码器的输出,因此解码器可以基于整个输入句子来更好地预测当前词。这对于翻译任务非常有用,因为不同语言下的词语顺序可能并不一致(不能逐词翻译),所以源语言句子后部的词语可能反而对目标语言句子前部词语的预测非常重要。
延伸
在编码器或解码器的注意力层中,还会使用注意力掩码(Attention Mask)来遮盖掉某些特殊词以防止模型关注它们,例如在批量处理时为了使所有输入长度相同而添加的特殊填充词(padding)。
架构
虽然各种 Transformer 模型层出不穷,但是按模型架构可以将它们分为三类:
- 纯编码器模型(Encoder-Only):只包含编码器部分,采用双向语言建模,从两个方向理解上下文。适合需要深度理解文本的任务,例如文本分类、命名实体识别等,典型代表如 BERT。
- 纯解码器模型(Decoder-Only):只包含解码器部分,从左到右处理文本。尤其擅长文本生成任务,可以根据提示完成句子、撰写文章,甚至生成代码,典型代表如 GPT、Llama;
- 编码器-解码器模型(Encoder-Decoder):结合了编码器和解码器,使用编码器理解输入,解码器生成输出。擅长序列到序列任务,例如翻译、摘要、问答等,典型代表如 T5、BART。
编码器模型
在每个阶段,注意力层都可以访问到原始输入句子中的所有词,即具有“双向”注意力机制,也被称为自编码模型(Auto-Encoding)。
这些模型通常通过破坏给定的句子(例如随机遮盖其中的词语)然后让模型进行重构来进行预训练,最适合处理那些需要理解整个句子语义的任务,例如句子分类、命名实体识别(词语分类)、抽取式问答等。
代表:
- BERT:双向 Transformer 模型,通过预测句子中被掩码标记的词元以及预测句子之间是否连贯进行预训练,其核心思想是通过随机遮盖某些词元,使模型能够同时训练左右两侧的文本,从而更全面地理解句子内容。BERT 具有较好的通用性,通过微调额外的层或头部就可以将学习到的语言表示应用于其他 NLP 任务;
- DistilBERT:通过知识蒸馏进行预训练,创建了一个更小的模型,该模型推理速度更快,训练所需的计算量也更少。在预训练过程中,DistilBERT 采用三重损失目标:语言建模损失、知识蒸馏损失和余弦距离损失。结果表明,DistilBERT 在内存占用减少 40%、计算速度提高 60% 的情况下,依然可以保持 97% 的性能;
- RoBERTa:BERT 之后的一项研究表明,通过修改预训练方案可以进一步提高性能。 RoBERTa 在更多的训练数据上,以更大的批次训练了更长的时间,并且放弃了 NSP 任务。与 BERT 模型相比,这些改变显著地提高了模型的性能;
- ModernBERT:在 2 万亿个词元上训练的现代化版本 BERT 模型。它对原始架构进行了许多改进,例如引入旋转位置嵌入以支持处理最多 8192 个 token 的序列,取消填充以避免在填充 token 上浪费计算资源,以及引入 GeGLU 层和交替注意力机制等。
解码器模型
在每个阶段,对于给定的词,注意力层只能访问句子中位于它前面的词,即只能迭代地基于已经生成的词来逐个预测后面的词,因此也被称为自回归模型(Auto-Regressive)。
纯解码器模型的预训练通常围绕着预测句子中下一个单词展开。这些模型最适合用于文本生成任务。
代表:
- OpenAI 的 GPT 系列:对纯解码器模型的探索在在很大程度上是由 OpenAI 带头进行的,通过使用更大的训练数据集以及将模型的规模扩大,GPT 模型的性能也在不断提高。例如 GPT-3 具有 1750 亿个参数,除了能生成令人印象深刻的真实篇章之外,还展示了小样本学习的能力。可惜 GPT 系列模型都没有开源;
- Meta 的 Llama 系列:Llama 系列模型是 Meta AI 发布的开源大语言模型,由于对公众开放了模型权重且性能优秀,Llama 成为最受欢迎的开源大语言模型之一,很多研究工作都是以其为基座模型进行微调或继续预训练,衍生出了众多变体,极大地推动了大语言模型技术的发展。最近发布的 Llama 4 引入了一种新的自回归混合专家架构(MoE),利用早期融合技术实现原生多模态处理,从而能够处理文本和图像输入。
- Google 的 Gemma 系列:Gemma 是 Google 发布的开源大语言模型,其技术路线与闭源多模态模型 Gemini 类似。最近发布的 Gemma 3 是一款多模态模型,引入了视觉理解能力,覆盖更广泛的语言,并支持更长的上下文,还对之前的架构进行了一系列改进,包括全局自注意力层交替使用多个局部滑动窗口自注意力层、采用 SigLip 编码器防止信息在高分辨率图像或非正方形宽高比的图像中丢失等;
- 深度求索的 DeepSeek 系列:DeepSeek 是深度求索公司发布的开源大语言模型,以其创新的架构、强大的推理能力和完全开源的理念在全球 AI 领域备受关注。最近发布的 DeepSeek V3 是一个强大的混合专家模型(MoE),拥有 6710 亿个参数,为实现高效推理和低成本训练,采用多头潜在注意力(MLA)和 DeepSeekMoE 架构,并且开创了一种无辅助损失的负载均衡策略,并设置了多词元预测训练目标以提升性能。
- 智谱的 GLM 系列:GLM 是智谱 AI 和清华大学联合开发的开源大语言模型,最近发布的 GLM-5 模型旨在应对复杂系统工程和长时域智能体任务,参数规模扩展至 7440 亿(400 亿活跃参数),预训练数据量也增加到 28.5 万亿个 token。GLM-5 还集成了 DeepSeek 稀疏注意力机制(DSA),在大幅降低部署成本的同时,保持了长时域上下文处理能力。
- 阿里的 Qwen 系列:Qwen 是阿里巴巴开源的多语言大模型系列,自 2023 年 8 月开源以来,已发展成为全球最大的开源模型生态系统之一,截至 2026 年 1 月,其衍生模型数量突破 20 万个,全球下载量突破 10 亿次。最新发布的 Qwen 3.5 作为原生视觉-语言模型,在推理、编程、智能体能力与多模态理解等全方位基准评估中表现优异,该模型采用创新的混合架构,将线性注意力(Gated Delta Networks)与稀疏混合专家(MoE)相结合,实现出色的推理效率,还支持201 种语言与方言。
编码器-解码器模型
编码器-解码器模型(又称序列到序列模型)同时使用 Transformer 架构的编码器和解码器模块。
在每个阶段,编码器的注意力层都可以访问原始输入句子中的所有词,而解码器的注意力层则只能访问输入中给定词之前的词(即已经解码生成的词)。
这些模型的预训练可以采用不同的形式,但通常都涉及重构一个输入已被某种方式篡改的句子。
例如,T5 通过随机遮盖掉输入中的文本片段进行预训练,训练目标则是预测出被遮盖掉的文本。
编码器-解码器模型适合处理那些需要根据给定输入生成新文本的任务,例如摘要、翻译、生成式问答等。
代表:
- 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:由于注意力机制
𝑂(𝑛2)的内存要求,Transformer 模型只能处理一定长度内的文本。
BigBird 通过使用线性扩展的稀疏注意力形式,将可处理的文本长度从大多数模型的 512 扩展到 4096,这对于处理文本摘要等需要捕获长距离依赖的任务特别有用。
大语言模型工作原理
推理是指大语言模型利用训练中积累的知识,根据给定的输入提示逐字逐句地生成生成类似人类语言文本的过程。
具体来说,大语言模型会按照顺序生成的方式,利用从数十亿个参数中学习到的概率来预测和生成序列中的下一个词元(Token),从而生成连贯且与上下文相关的文本。
注意力的作用
注意力机制赋予大语言模型理解上下文并生成连贯响应的能力,在预测下一个词时,句子中的每个词并非都具有相同的权重。
例如,在句子“法国的首都是……”中,“法国”和“首都”这两个词对于确定下一个词是“巴黎”至关重要。
这种识别最相关词以预测下一个词元的方法已被证明非常有效。
简而言之,注意力机制是语言模型能够生成既连贯又具有上下文感知能力的文本的关键,也是使现代语言模型区别于前几代语言模型的关键。
想要了解大语言模型实际能够处理多少上下文信息,就需要引出上下文长度,或者说模型的“注意力跨度”。
上下文长度是指大语言模型一次可以处理的最大词元数量,这会受到模型的架构和尺寸、可用计算资源以及输入和期望输出的复杂性等多个因素的限制。
由于硬件限制以及计算成本,我们不可能向模型中输入无限量的上下文信息,因此发布的各种模型被设计成具有不同的上下文长度,以平衡模型的性能和效率。
此外,当我们向大语言模型传递信息时,会以某种方式组织输入以引导模型生成所需的输出,这被称为提示工程(Prompting)。
由于模型的主要任务就是通过注意力机制分析每个输入词元的重要性来预测下一个词元,因此输入序列的措辞至关重要。
相比口语化的简单任务描述,精心设计的提示(Prompt)可以更容易地引导大语言模型生成符合预期的输出。
两阶段推理过程
大语言模型生成文本的过程主要分为两个阶段:预填充(Prefill)和解码,协同工作,每阶段都发挥重要作用。
预填充准备阶段,所有初始食材都在此阶段进行加工和准备,该阶段包含三个关键步骤:
- 分词(Tokenization):将输入文本转换为模型可以理解的基本语言单元——词元。
- 嵌入转换(Embedding Conversion):将词元转换为能够表示其语义的密集嵌入表示。
- 初始处理:将这些嵌入向量输入模型的神经网络,以深入了解上下文。
这个阶段计算量很大,因为模型需要一次性处理完所有输入的词元,就像人类在回复消息之前,先需要阅读并理解消息中的所有文字。
预填充阶段处理完输入后,就进入实际生成文本的解码阶段。
在这个阶段,模型会逐个生成词元构建完整的输出,称之为自回归过程(每个新词元都依赖于所有先前的词元)。
这一阶段包含了针对每个新词元执行的多个关键步骤:
- 注意力计算:回顾所有先前的词元以理解上下文;
- 概率计算:确定下一个可能出现的词元的概率;
- 词元选择:根据这些概率选择下一个词元;
- 持续性检查:决定是否继续或停止生成。
此阶段会占用大量内存,因为模型需要跟踪所有先前已经生成的词元以及它们之间的关系。
采样策略
在模型生成过程中,就像作家可以选择更具创意还是更精确一样,我们也可以调整模型选择词元的方式。
当模型生成下一个词元时,它首先会得到词汇表中每个词的原始概率(称为logits),然后基于这些概率来选择下一个词元,这个过程包含以下几个步骤。
- 原始概率:可以将其视为模型对每个可能的下一个词的初始直觉。
- 温度控制(Temperature Control):设置较高的值(>1.0)会使选择更随机、更具创造性,而较低的值(<1.0)则会使选择更集中、更具确定性。
- Top-p 采样(核采样):不考虑所有可能的词语,而是只关注那些概率总和达到选定阈值的最可能词语(例如前90%)。
- Top-k 过滤:一种替代方法,只考虑最有可能的 k 个下一个词。
此外,大语言模型面临的一个常见挑战是重复性问题,即生成重复的内容。为了解决这个问题,通常可以采用两种惩罚机制:
- 出现惩罚(Presence Penalty):对任何已出现过的词元,无论其出现频率如何都施加固定惩罚,从而防止模型重复使用相同的词;
- 频度惩罚(Frequency Penalty):根据词元使用频率递增的惩罚机制,一个词出现得越多,再次被选中的可能性就越小。
这些惩罚项会在词元选择的早期阶段就被应用,从而在其他采样策略实施之前就调整原始概率。可以被视为一种温和的引导,鼓励模型探索新的词汇。
最后,考虑到局部最优解未必是全局最优解,如果每次只是简单地选择当前最合适的词元,未必能获得全局质量最好的生成结果,因此还可以使用束搜索(Beam search),同时生成多个词元序列,最后选择总体概率最高的作为最终输出。
- 在每个步骤中,维护多个候选序列(通常为 5-10 个)。
- 对于每个候选词,计算其成为下一个词元的概率;
- 只保留最可能的序列和后续词元组合;
- 重复此过程,直至达到所需长度或停止;
- 选择总体概率最高的序列作为输出。
束搜索通常能生成更连贯、语法更正确的文本,但需要更多的计算资源。
实际挑战与优化
在实际部署大模型时,通常需要考虑以下几个关键指标:
- 首次响应时间(Time to First Token,TTFT):获得首次响应的时间,这主要受预填充阶段的影响,对于用户体验非常重要。
- 输出每词元所需时间(Time Per Output Token,TPOT):用户衡量生成后续词元的速度,这决定了整体生成速度。
- 吞吐量(Throughput):可以同时处理的请求数量。
- 显存使用情况:GPU 显存的消耗量,这通常会成为实际应用中的主要瓶颈。
此外,有效管理上下文长度是大语言模型推理中最具挑战性的问题之一。
虽然更长的上下文可以提供更多信息,但也会带来巨大的成本:
内存使用量通常随上下文长度呈二次方增长,而处理速度则通常随上下文长度呈线性下降。
例如像 Qwen2.5-1M 这样的新模型支持数百万个 token 的上下文窗口,但这也导致推理速度显著降低,因此关键在于找到适合实际场景的最佳平衡点。
为了应对这些挑战,最有效的优化方法之一是 KV 缓存(Key-Value Caching),通过存储和重用中间计算结果来提高推理速度。
这项优化可以减少重复计算,从而提升生成速度,使长上下文生成成为可能。虽然代价是会占用更多内存,但性能提升通常远远超过这一成本。
注意力机制
注意力机制
无论对于哪种 NLP 模型,理解用户输入始终是开展工作的第一步,常规的做法就是首先对输入文本进行分词,然后将每个词元(Token)都转化为对应的词嵌入(Embeddings),这样文本就转换为一个由词嵌入组成的矩阵。
在 Transformer 模型提出之前,对序列 𝑋 的常规编码方式是通过循环神经网络(RNNs)或卷积神经网络(CNNs)来进行。
循环神经网络(例如 LSTM)的方案很简单,每一个词对应的编码结果都基于这个词前面一个词的表示获得,通过递归地计算得到(也就是基于词的上文信息来计算词嵌入)
循环神经网络的序列建模方式虽然与人类阅读类似,但是递归的结构导致其无法并行计算,在处理长序列时还可能会出现梯度爆炸或梯度消失问题,而且循环神经网络本质是一个马尔科夫决策过程,难以学习到全局的结构信息;
卷积神经网络则运用滑动窗口基于局部上下文来编码文本,例如核尺寸为 3 的卷积操作就是使用每一个词自身以及前一个和后一个词来生成该词的语义嵌入:
卷积神经网络能够并行计算,因此速度很快,但是由于是通过窗口来进行编码,所以更侧重于捕获局部信息(窗口内词元之间的交互),难以建模远距离词之间的依赖。
直到 2017 年 Google 在《Attention is All You Need》中提供了第三个方案:
直接使用注意力机制编码整个文本。
相比循环神经网络要逐步递归才能获得全局信息(因此一般使用双向 RNN),而卷积神经网络只能获取局部信息,需要通过堆叠多层来增大感受野,注意力机制一步到位获取了全局信息:
缩放点积注意力
基于注意力思路编码文本并非由 Google 首创,在早期的神经网络工作中许多研究者就已经提出了各种版本的注意力机制。
直到 2017 年 Transformer 模型横空出世,才确定了注意力机制的形式,并被后续的工作所沿用,这种注意力机制的全称为缩放点积注意力(Scaled Dot-product Attention)
注意力机制的工作过程主要包含两个步骤:
- 计算注意力权重:使用某种相似度函数度量每一个查询(query)向量和所有键(key)向量之间的关联程度。
- 更新词嵌入
多头注意力
多头注意力(Multi-head Attention)机制首先通过线性映射将 𝑄,𝐾,𝑉
序列映射到特征空间,每一组线性投影后的向量表示称为一个头(head),然后在每组映射后的序列上再应用上一节中介绍的缩放点积注意力
Transformer 编码器
编码器和解码器都各自包含有多层构件(building blocks)
可以看到:
输入的词首先被转换为词嵌入。由于注意力机制无法捕获词之间的位置关系,因此还通过位置编码(Positional Embeddings,PE)向输入中添加位置信息;
编码器由一堆编码器编码构件(encoder layers)组成,类似于图像领域中的堆叠卷积层。同样地,在解码器中也包含有堆叠的解码构件(decoder layers);
编码器的输出被送入到解码器层中以预测概率最大的下一个词,然后当前的词序列又被送回到解码器中以继续生成下一个词,重复直至出现序列结束符 EOS 或者超过最大输出长度。
前馈网络层
层归一化
位置编码
Transformer 解码器
Transformer 模型中的解码器基于编码器最后一层输出以及已生成的词元序列进行后续内容的生成。
因此,与编码器只有一种注意力子层(多头注意力机制)不同,Transformer 模型的解码器包含两种注意力子层
掩码多头注意力层(Masked multi-head self-attention layer):
在计算注意力时引入掩码操作,确保在每个时间步生成的词仅基于过去的输出和当前的输入(不依赖未来信息),否则解码器就相当于作弊了;
交叉注意力层(Cross attention layer):
以解码器的中间表示作为查询,对编码器的输出 键和值向量执行多头注意力计算。
通过这种方式,交叉注意力层就可以学习到如何关联来自两个不同序列的词,例如两种不同的语言。
需要说明的是,解码器可以访问每个 block 中 编码器的键和值。
注意力机制变体
稀疏注意力机制
多查询注意力
硬件优化注意力机制
新型模型架构
混合专家模型
除了改进注意力机制以外,还有一些研究者尝试引入基于稀疏激活的混合专家架构(Mixture-of-Experts,MoE)对前馈网络模块进行改进,通过将 Transformer 模块中的特定前馈层替换为 MoE 层,使得模型可以在推理过程中仅激活部分参数,从而在不显著提升计算成本的同时实现对模型参数的拓展。
状态空间模型
此外,还有一些研究人员不满足于对 Transformer 模型进行改进,直接基于参数化状态空间模型(State Space Model,SSM)设计出了多种新型模型架构,在提高长文本建模效率的同时还保持了较好的序列建模能力。
尽管计算效率较高,但是状态空间模型的性能相比 Transformer 模型仍有差距。
为此,一些研究者尝试改进状态空间模型以提高语言建模能力。一些代表性模型包括:
- Mamba:一种状态空间模型的变体,主要思想是在状态更新中引入基于当前输入的信息选择(Selection)机制,来确定当前时刻状态如何从前一时刻状态以及当前输入中提取信息。但是由于在状态计算过程中引入了非线性变换,Mamba 无法直接利用快速傅里叶变换实现高效卷积计算。
- RWKV:尝试结合 RNN 和 Transformer 的优点。其主要创新是在每层计算中使用词元偏移(Token Shift)来代替词元表示,并且将 Transformer 中的多头注意力模块和前馈网络模块分别替换为时间混合(Time-mixing)模块和频道混合 (Channel-mixing)模块。类似于 Mamba,RWKV 在解码过程中可以像 RNN 一样只参考前一时刻的状态。
- RetNet:提出多尺度保留(Multi-scale Retention, MSR)机制来代替多头注意力模块,在状态更新的线性映射中引入了输入相关信息来提升序列建模能力,RetNet 还可以通过类似注意力操作的矩阵乘法,对所有词元的状态进行并行化计算,因此同时保留了循环计算和并行计算的优点。
- Hyena:使用长卷积(Long Convolution)模块来替换注意力模块,并借助快速傅里叶变换提高卷积计算效率。
pipelines
开箱即用的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(零训练样本分类)
情感分析
1 | import os |
输出结果:
1 | Loading weights: 100%|██████████| 104/104 [00:00<00:00, 10831.27it/s] |
pipeline 模型会自动完成以下三个步骤:
- 将文本预处理为模型可以理解的格式;
- 将预处理好的文本送入模型;
- 对模型的预测值进行后处理,输出人类可以理解的格式。
pipeline 会自动选择合适的预训练模型来完成任务。
零训练样本分类
零训练样本分类 pipeline 允许我们在不提供任何标注数据的情况下自定义分类标签。
1 | import os |
输出结果:
1 | Loading weights: 100%|██████████| 515/515 [00:00<00:00, 24795.01it/s] |
文本生成
我们首先根据任务需要构建一个模板 (prompt),然后将其送入到模型中来生成后续文本。
1 | import os |
输出结果:
1 | Loading weights: 100%|██████████| 149/149 [00:00<00:00, 15774.43it/s] |
加载专门用于生成中文古诗的 gpt2-chinese-poem 模型:
1 | from transformers import pipeline |
输出结果:
1 | [{'generated_text': '[CLS] 万 叠 春 山 积 雨 晴 , 我 山 硉 地 , 此 际 何 处 著 诗 人 。 试 问 主 人 何 在 , 我 欲 从 之 以 上 古 , 来 者 渺 漫 。 忽 地 远 峰 外 , 隐 隐 碧 云 根 。 酒 痕 斑 , 茶 叶 嫩 , 竹 根 新 。 风 流 太 守 一 老 , 玉 骨 雪 精 神 。 我 爱 元 戎 小 队 , 坐 拥 红 旗 绿 盖 , 相 顾 却 情 亲 。 行 酒 入 幽 径 , 不 必 问 清 浑 。 日 登 东 山 , 流 水 带 平 皋 。 雨 馀 亭 四 面 , 风 毛 发 , 风 梢 烟 梢 滴 。 雨 一 天 半 湖 水 , 凉 , 晴 空 , 楼 月 孤 , 山 一 点 。 湖 一 碧 四 时 一 镜 , 风 十 里 千 顷 , 水 一 湖 千 顷 。 水 。 此 楼 高 , 此 楼 。 此 千 顷 , 湖 一 色 , 是 青 山 一 指 顾 , 是 画 里 无 分 。 我 胸 万 顷 , 一 丝 万 顷 , 风 月 , 恍 然 云 , 君 一 阑 干 千 里 何 有 双 双 两 点 点 两 点 , , 此 湖 一 壶 一 泓 , 只 , 天 白 千 , 如 几 千 层 , 千 里'}, {'generated_text': '[CLS] 万 叠 春 山 积 雨 晴 , 堂 初 破 暑 。 更 阑 各 睡 怯 风 露 , 窗 外 晓 鸠 啼 不 已 。 旧 读 离 骚 今 几 年 , 晚 乃 脱 略 时 餐 眠 。 东 风 吹 过 三 千 里 , 花 落 满 床 衣 领 寒 。 我 老 矣 心 已 灰 , 有 身 无 翼 何 当 飞 。 相 逢 一 笑 莫 相 笑 , 窗 下 笔 床 茶 烟 微 。 君 为 我 少 吟 咏 , 此 语 寄 君 君 所 信 。 诗 成 若 过 江 南 州 , 莫 道 如 今 不 如 去 。 老 人 君 何 是 渠 瘦 。 似 我 , 非 黄 金 。 今 白 头 白 头 底 。 君 青 眼 谁 青 眼 底 。 世 所 逢 若 个 是 , 不 惜 青 。 自 苦 白 。 我 不 见 , 君 不 见 。 为 天 青 眼 , 相 骂 , 人 今 不 平 生 耻 , 山 何 。 不 管 谁 知 古 。 人 白 。 为 吾 何 。 人 何 。 君 羞 向 我 丑 。 世 。 安 。 为 主 , 为 羞 晚 节 。 世 人 何 有 , 我 , 不 耻 , 何 。 万 。 岁 寒 云 自 胡 , 人 非 中 , 一 世 。 不 羞 。 且 深'}] |
遮盖词填充
给定一段部分词语被遮盖掉 (masked) 的文本,使用预训练模型来预测能够填充这些位置的词语。
1 | import os |
输出结果:
1 | 分数:0.1093 | 3d | 这 门 课 程 会 教 你 学 习 3d 模 型 。 |
命名实体识别
命名实体识别 (NER) pipeline 负责从文本中抽取出指定类型的实体,例如人物、地点、组织等等。
1 | import os |
输出结果:
1 | LABEL_0 | 张 三 在 | 分数:0.5609 |
自动问答
自动问答 pipeline 可以根据给定的上下文回答问题
1 | from transformers import pipeline |
输出结果:
1 | No model was supplied, defaulted to distilbert-base-cased-distilled-squad (https://huggingface.co/distilbert-base-cased-distilled-squad) |
这里的自动问答 pipeline 实际上是一个抽取式问答模型,即从给定的上下文中抽取答案,而不是生成答案。
根据形式的不同,自动问答 (QA) 系统可以分为三种:
- 抽取式 QA (extractive QA):假设答案就包含在文档中,因此直接从文档中抽取答案;
- 多选 QA (multiple-choice QA):从多个给定的选项中选择答案,相当于做阅读理解题;
- 无约束 QA (free-form QA):直接生成答案文本,并且对答案文本格式没有任何限制。
自动摘要
自动摘要 pipeline 旨在将长文本压缩成短文本,并且还要尽可能保留原文的主要信息
1 | from transformers import pipeline |
输出结果:
1 | No model was supplied, defaulted to sshleifer/distilbart-cnn-12-6 (https://huggingface.co/sshleifer/distilbart-cnn-12-6) |
与文本生成类似,也可以通过 max_length 或 min_length 参数来控制返回摘要的长度。
底层原理
实际上背后经过了三个步骤:
- 预处理 (preprocessing),将原始文本转换为模型可以接受的输入格式;
- 将处理好的输入送入模型;
- 对模型的输出进行后处理 (postprocessing),将其转换为人类方便阅读的格式。
使用分词器进行预处理
因为神经网络模型无法直接处理文本,因此首先需要通过预处理环节将文本转换为模型可以理解的数字。
具体地,使用每个模型对应的分词器 (tokenizer) 来进行:
- 将输入切分为词语、子词或者符号(例如标点符号),统称为 tokens;
- 根据模型的词表将每个 token 映射到对应的 token 编号(就是一个数字);
- 根据模型的需要,添加一些额外的输入。
我们对输入文本的预处理需要与模型自身预训练时的操作完全一致,只有这样模型才可以正常地工作。
注意,每个模型都有特定的预处理操作,如果对要使用的模型不熟悉,可以通过 Model Hub 查询。
这里我们使用 AutoTokenizer 类和它的 from_pretrained() 函数,它可以自动根据模型 checkpoint 名称来获取对应的分词器。
情感分析 pipeline 的默认 checkpoint 是 distilbert-base-uncased-finetuned-sst-2-english,下面我们手工下载并调用其分词器:
1 | import os |
输出结果:
1 | {'input_ids': tensor([[ 101, 1855, 100, 100, 1816, 1740, 100, 100, 100, 100, 1740, 100, |
可以看到,输出中包含两个键 input_ids 和 attention_maskinput_ids 对应分词之后的 tokens 映射到的数字编号列表attention_mask 则是用来标记哪些 tokens 是被填充的(这里“1”表示是原文,“0”表示是填充字符)。
将预处理好的输入送入模型
预训练模型的下载方式和分词器 (tokenizer) 类似
Transformers 包提供了一个 AutoModel 类和对应的 from_pretrained() 函数。
下面我们手工下载这个 distilbert-base 模型:
1 | from transformers import AutoModel |
预训练模型的本体只包含基础的 Transformer 模块,对于给定的输入,它会输出一些神经元的值,称为 hidden states 或者特征 (features)。
对于 NLP 模型来说,可以理解为是文本的高维语义表示。
这些 hidden states 通常会被输入到其他的模型部分(称为 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 模型的输出维度:
1 | import os |
输出结果:
1 | torch.Size([2, 18, 768]) |
Transformers 模型的输出格式类似 namedtuple 或字典,可以像上面那样通过属性访问,也可以通过键(outputs[“last_hidden_state”]),甚至索引访问(outputs[0])。
对于情感分析任务,很明显我们最后需要使用的是一个文本分类 head。
因此,实际上我们不会使用 AutoModel 类,而是使用 AutoModelForSequenceClassification:
1 | import os |
输出结果:
1 | torch.Size([2, 2]) |
可以看到,对于 batch 中的每一个样本,模型都会输出一个两维的向量(每一维对应一个标签,positive 或 negative)。
对模型输出进行后处理
由于模型的输出只是一些数值,因此并不适合人类阅读。
1 | import os |
输出结果:
1 | tensor([[ 1.4225, -1.1959], |
于是我们可以得到最终的预测结果:
第一个句子: NEGATIVE: 0.9320, POSITIVE: 0.0680
第二个句子: NEGATIVE: 0.2972, POSITIVE: 0.7028
模型与分词器
模型
除了像之前使用 AutoModel 根据 checkpoint 自动加载模型以外也可以直接使用模型对应的 Model 类,例如 BERT 对应的就是 BertModel:
1 | from transformers import BertModel |
在大部分情况下,我们都应该使用 AutoModel 来加载模型。
这样如果使用另一个模型(比如把 BERT 换成 RoBERTa),只需修改 checkpoint,其他代码可以保持不变。
加载模型
所有存储在 HuggingFace Model Hub 上的模型都可以通过 Model.from_pretrained() 来加载权重
参数可以像上面一样是 checkpoint 的名称,也可以是本地路径(预先下载的模型目录),例如:
1 | from transformers import BertModel |
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 模型:
1 | from transformers import AutoModel |
这会在保存路径下创建两个文件:
- config.json:模型配置文件,存储模型结构参数,例如 Transformer 层数、特征空间维度等;
- pytorch_model.bin:又称为 state dictionary,存储模型的权重。
简单来说,配置文件记录模型的结构,模型权重记录模型的参数,这两个文件缺一不可。
我们自己保存的模型同样通过 Model.from_pretrained() 加载,只需要传递保存目录的路径。
分词器
由于神经网络模型不能直接处理文本,因此我们需要先将文本转换为数字,这个过程被称为编码 (Encoding),其包含两个步骤:
- 使用分词器 (tokenizer) 将文本按词、子词、字符切分为 tokens;
- 将所有的 token 映射到对应的 token ID。
分词策略
根据切分粒度的不同,分词策略可以分为以下几种:
- 按词切分 (Word-based)
例如直接利用 Python 的 split() 函数按空格进行分词
这种策略的问题是会将文本中所有出现过的独立片段都作为不同的 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)
高频词直接保留,低频词被切分为更有意义的子词。
这种策略只用一个较小的词表就可以覆盖绝大部分文本,基本不会产生 unknown token。
尤其对于土耳其语等黏着语,几乎所有的复杂长词都可以通过串联多个子词构成。
加载与保存分词器
分词器的加载与保存与模型相似,使用 Tokenizer.from_pretrained() 和 Tokenizer.save_pretrained() 函数。
例如加载并保存 BERT 模型的分词器:
1 | from transformers import BertTokenizer |
同样地,在大部分情况下我们都应该使用 AutoTokenizer 来加载分词器:
1 | from transformers import AutoTokenizer |
调用 Tokenizer.save_pretrained() 函数会在保存路径下创建三个文件:
- special_tokens_map.json:映射文件,里面包含 unknown token 等特殊字符的映射关系;
- tokenizer_config.json:分词器配置文件,存储构建分词器需要的参数;
- vocab.txt:词表,一行一个 token,行号就是对应的 token ID(从 0 开始)。
编码与解码文本
前面说过,文本编码 (Encoding) 过程包含两个步骤:
- 分词:使用分词器按某种策略将文本切分为 tokens;
- 映射:将 tokens 转化为对应的 token IDs。
下面我们首先使用 BERT 分词器来对文本进行分词:
1 | from transformers import AutoTokenizer |
输出结果:['using', 'a', 'transform', '##er', 'network', 'is', 'simple']
BERT 分词器采用的是子词切分策略,它会不断切分词语直到获得词表中的 token,例如 “transformer” 会被切分为 “transform” 和 “##er”。
然后,我们通过 convert_tokens_to_ids() 将切分出的 tokens 转换为对应的 token IDs:
1 | ids = tokenizer.convert_tokens_to_ids(tokens) |
输出结果:[7993, 170, 13809, 23763, 2443, 1110, 3014]
还可以通过 encode() 函数将这两个步骤合并,并且 encode() 会自动添加模型需要的特殊 token,例如 BERT 分词器会分别在序列的首尾添加[CLS]和 [SEP]:
1 | from transformers import AutoTokenizer |
输出结果:[101, 7993, 170, 13809, 23763, 2443, 1110, 3014, 102]
其中 101 和 102 分别是[CLS]和[SEP]对应的 token IDs。
注意,上面这些只是为了演示。
在实际编码文本时,最常见的是直接使用分词器进行处理,这样不仅会返回分词后的 token IDs,还包含模型需要的其他输入。
例如 BERT 分词器还会自动在输入中添加 token_type_ids 和 attention_mask:
1 | from transformers import AutoTokenizer |
输出结果:
1 | {'input_ids': [101, 7993, 170, 13809, 23763, 2443, 1110, 3014, 102], |
文本解码 (Decoding) 与编码相反,负责将 token IDs 转换回原来的字符串。
注意,解码过程不是简单地将 token IDs 映射回 tokens,还需要合并那些被分为多个 token 的单词。
下面我们通过 decode() 函数解码前面生成的 token IDs:
1 | from transformers import AutoTokenizer |
输出结果:
1 | Using a transformer network is simple |
解码文本是一个重要的步骤,在进行文本生成、翻译或者摘要等 Seq2Seq (Sequence-to-Sequence) 任务时都会调用这一函数。
处理多段文本
模型只接受批 (batch) 数据作为输入,即使只有一段文本,也需要将它组成一个只包含一个样本的 batch,例如:
1 | import torch |
输出结果:
1 | Input IDs: |
这里我们通过 [ids] 构建了一个只包含一段文本的 batch,更常见的是送入包含多段文本的 batch:batched_ids = [ids, ids, ids, ...]
注意,上面的代码仅作为演示。
实际场景中,我们应该直接使用分词器对文本进行处理,例如对于上面的例子:
1 | import torch |
输出结果:
1 | Inputs Keys: |
可以看到,分词器输出的结果中不仅包含 token IDs(input_ids),还会包含模型需要的其他输入项。
前面我们之所以只输入 token IDs 模型也能正常运行,是因为它自动地补全了其他的输入项,例如 attention_mask 等。
Padding 操作
按批输入多段文本产生的一个直接问题就是:batch 中的文本有长有短,而输入张量必须是严格的二维矩形,维度为 (batch size,sequence length),即每一段文本编码后的 token IDs 数量必须一样多。
例如下面的 ID 列表是无法转换为张量的:
1 | batched_ids = [ |
我们需要通过 Padding 操作,在短序列的结尾填充特殊的 padding token,使得 batch 中所有的序列都具有相同的长度,例如:
1 | padding_id = 100 |
模型的 padding token ID 可以通过其分词器的 pad_token_id 属性获得。
下面我们尝试将两段文本分别以独立以及 batch 的形式送入到模型中:
1 | import torch |
输出结果:
1 | tensor([[ 1.5694, -1.3895]], 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 的位置,计算结果就不会有问题了:
1 | import torch |
输出结果:
1 | tensor([[ 1.5694, -1.3895]], 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、截断等操作。
编码句子对
除了对单段文本进行编码以外(batch 只是并行地编码多个单段文本),对于 BERT 等包含“句子对”预训练任务的模型,它们的分词器都支持对“句子对”进行编码
添加 Token
实际操作中,我们还经常会遇到输入中需要包含特殊标记符的情况,例如使用 [ENT_START] 和 [ENT_END] 标记出文本中的实体。
由于这些自定义 token 并不在预训练模型原来的词表中,因此直接运用分词器处理就会出现问题。
此外,一些领域的专业词汇,例如使用多个词语的缩写拼接而成的医学术语,同样也不在模型的词表中,因此也会出现上面的问题。
此时我们就需要将这些新 token 添加到模型的词表中,让分词器与模型可以识别并处理这些 token。
添加新 token
Transformers 库提供了两种方式来添加新 token,分别是:
add_tokens()添加普通 token:参数是新 token 列表,如果 token 不在词表中,就会被添加到词表的最后。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。
调整 embedding 矩阵
向词表中添加新 token 后,必须重置模型 embedding 矩阵的大小,也就是向矩阵中添加新 token 对应的 embedding,这样模型才可以正常工作,将 token 映射到对应的 embedding。
调整 embedding 矩阵通过 resize_token_embeddings() 函数来实现
在默认情况下,新添加 token 的 embedding 是随机初始化的。
我们尝试打印出新添加 token 对应的 embedding(新 token 会添加在词表的末尾,因此只需打印出最后两行)。
如果你多次运行上面的代码,就会发现每次打印出的 [ENT_START] 和 [ENT_END] 的 embedding 是不同的。
Token embedding 初始化
如果有充分的语料对模型进行微调或者继续预训练,那么将新添加 token 初始化为随机向量没什么问题。
但是如果训练语料较少,甚至是只有很少语料的 few-shot learning 场景下,这种做法就存在问题。
研究表明,在训练数据不够多的情况下,这些新添加 token 的 embedding 只会在初始值附近小幅波动。
换句话说,即使经过训练,它们的值事实上还是随机的。
直接赋值
因此,在很多情况下,我们需要手工初始化新添加 token 的 embedding,这可以通过直接对 embedding 矩阵赋值来实现。例如我们将上面例子中两个新 token 的 embedding 都初始化为全零向量
初始化为已有 token 的值
更为高级的做法是根据新添加 token 的语义来进行初始化。
必要的Pytorch知识
模型与分词器介绍了 Model 类和 Tokenizers 类,尤其是如何运用分词器对文本进行预处理。
Transformers 库建立在 Pytorch 框架之上(Tensorflow 的版本功能并不完善),虽然官方宣称使用 Transformers 库并不需要掌握 Pytorch 知识,但是实际上我们还是需要通过 Pytorch 的 DataLoader 类来加载数据、使用 Pytorch 的优化器对模型参数进行调整等等。
Pytorch 基础
Pytorch 由 Facebook 人工智能研究院于 2017 年推出。
具有强大的 GPU 加速张量计算功能,并且能够自动进行微分计算,从而可以使用基于梯度的方法对模型参数进行优化。
截至 2022 年 8 月,PyTorch 已经和 Linux 内核、Kubernetes 等并列成为世界上增长最快的 5 个开源社区之一。
现在在 NeurIPS、ICML 等等机器学习顶会中,有超过 80% 研究人员用的都是 PyTorch。
张量
张量 (Tensor) 是深度学习的基础,例如常见的 0 维张量称为标量 (scalar)、1 维张量称为向量 (vector)、2 维张量称为矩阵 (matrix)。
Pytorch 本质上就是一个基于张量的数学计算工具包,它提供了多种方式来创建张量:
- 从数据直接创建
torch.tensor()
1 | import torch |
- 从NumPy数组创建(共享内存)
torch.from_numpy()
1 | np_array = np.array([1, 2, 3]) |
创建特定形状的张量
torch.zeros() 创建全为 0 的张量 torch.zeros(2, 3)
torch.ones() 创建全为 1 的张量 torch.ones(2, 3)
torch.empty() 创建未初始化的张量(内容为内存中的随机值) torch.empty(2, 3)
torch.full() 创建填充指定值的张量 torch.full((2, 3), 5.0)
torch.eye() 创建单位矩阵(对角线为1,其余为0) torch.eye(3)
torch.arange() 创建整数序列的张量 torch.arange(0, 10, 2)
torch.linspace() 创建指定区间内线性间隔的序列 torch.linspace(0, 1, 5)
torch.logspace() 创建指定区间内对数间隔的序列 torch.logspace(0, 1, 5)创建随机张量
torch.rand() 从 [0, 1) 的均匀分布中随机采样 torch.rand(2, 3)
torch.randn() 从标准正态分布中随机采样 torch.randn(2, 3)
torch.randint() 在指定范围内随机生成整数 torch.randint(0, 10, (2, 3))
torch.randperm() 生成一个随机排列的整数序列 torch.randperm(5)关于随机性:为了确保实验结果可重复,通常会在生成随机数前设置一个种子。
torch.manual_seed(42) # 设置随机种子创建与现有张量形状相同的张量
torch.zeros_like() 创建形状相同、全为0的张量 torch.zeros_like(x_data)
torch.ones_like() 创建形状相同、全为1的张量 torch.ones_like(x_data)
torch.rand_like() 创建形状相同、随机均匀分布的张量 torch.rand_like(x_data)
torch.randn_like() 创建形状相同、随机正态分布的张量 torch.randn_like(x_data)配置张量属性
在创建张量时,可以通过参数精细控制其属性。
dtype: 指定数据类型,如 torch.float32、torch.int64。
device: 指定存储设备,如 ‘cpu’ 或 ‘cuda:0’。(另外两个方式:.cuda(),.to("cuda"))
requires_grad: 布尔值,表示是否需要为张量计算梯度(用于自动微分)。
示例:
1 | # 在GPU上创建一个32位浮点型的全1张量,并启用梯度追踪 |
⚠️ 注意事项
torch.tensor() vs torch.Tensor(): torch.tensor() 是推荐的创建方式,而 torch.Tensor() 是类的构造函数,用法上可能有细微差别。
数据复制: torch.tensor() 总是复制数据;torch.as_tensor() 和 torch.from_numpy() 则尽可能共享内存,效率更高。
张量计算
张量的加减乘除是按元素进行计算的
Pytorch 还提供了许多常用的计算函数torch.dot() 计算向量点积torch.mm() 计算矩阵相乘、三角函数和各种数学函数等
自动微分
Pytorch 提供自动计算梯度的功能,可以自动计算一个函数关于一个变量在某一取值下的导数,从而基于梯度对参数进行优化,这就是机器学习中的训练过程。
使用 Pytorch 计算梯度非常容易,只需要执行 tensor.backward(),就会自动通过反向传播 (Back Propogation) 算法完成,后面我们在训练模型时就会用到该函数。
调整张量形状
Pytorch 共提供了 4 种调整张量形状的函数:
- 形状转换
view将张量转换为新的形状,需要保证总的元素个数不变。
进行 view 操作的张量必须是连续的 (contiguous),可以调用 is_conuous 来判断张量是否连续;如果非连续,需要先通过 contiguous 函数将其变为连续的。 - 也可以直接调用 Pytorch 新提供的
reshape函数,它与 view 功能几乎一致,并且能够自动处理非连续张量。 - 转置
transpose交换张量中的两个维度,参数为相应的维度 - 交换维度
permute与transpose函数每次只能交换两个维度不同,permute可以直接设置新的维度排列方式。
广播机制
前面我们都是假设参与运算的两个张量形状相同。
在有些情况下,即使两个张量形状不同,也可以通过广播机制 (broadcasting mechanism) 对其中一个或者同时对两个张量的元素进行复制,使得它们形状相同,然后再执行按元素计算。
索引与切片
与 Python 列表类似,Pytorch 也可以对张量进行索引和切片。
索引值同样是从 0 开始,切片[m:n]的范围是从 m 到 n 前一个元素结束,并且可以对张量的任意一个维度进行索引或切片。
降维与升维
有时为了计算需要对一个张量进行降维或升维。
例如神经网络通常只接受一个批次 (batch) 的样例作为输入,如果只有 1 个输入样例,就需要手工添加一个 batch 维度。具体地:
- 升维 torch.unsqueeze(input, dim, out=None) 在输入张量的 dim 位置插入一维,与索引一样,dim 值也可以为负数;
- 降维 torch.squeeze(input, dim=None, out=None) 在不指定 dim 时,张量中所有形状为 1 的维度都会被删除
加载数据
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
前面的数据集 Dataset 类提供了一种按照索引访问样本的方式。
不过在实际训练模型时,我们都需要先将数据集切分为很多的 mini-batches,然后按批 (batch) 将样本送入模型,并且循环这一过程,每一个完整遍历所有样本的循环称为一个 epoch。
Pytorch 提供了 DataLoader 类专门负责处理这些操作,除了基本的 dataset(数据集)和 batch_size (batch 大小)参数以外,还有以下常用参数:
- shuffle:是否打乱数据集;
- sampler:采样器,也就是一个索引上的迭代器;
- collate_fn:批处理函数,用于对采样出的一个 batch 中的样本进行处理(例如前面提过的 Padding 操作)。
训练模型
Pytorch 所有的模块(层)都是 nn.Module 的子类,神经网络模型本身就是一个模块,它还包含了很多其他的模块。










