【1】LLM篇 事无巨细!手把手带你用transformer做一个中英翻译模型从原理到实战一网打尽
0.前言
提前给llm模型这个入门写了,主要是因为前几天实习跟team leader吵架,我直接选择开润,在学校里当俩月学生,五六月份就要去另外一家企业当码奴了(企业说我毕业我才能毕业)。所以这段时间很空闲,正好我自己也回顾一下,把语言模型的博客直接更新了。我是打算这样讲,从一个完整的句子,预处理(分词),然后这个句子在大模型中经历了什么(模型训练),最后怎么用(模型预测),这样的流水线来讲,不是直接和你讲transformer原理,那个太老土了,根本入不了门的,你看了也白看可以这样说。
1.分词器(tokenizer)
你有没有想过,为啥语言模型能认识那么多中文,英文,甚至是一些奇奇怪怪的语言。其实并不是语言模型认识,对于一个模型来说,它的输入只有张量,它只认识数字。这样不就很好解决了,就像分类模型一样,猫设为0,狗设为1。这样不就可以分类啦!其实主题思想是一样的。但是现在就出现了一个问题,诶。那么一句话那么多字我该咋分呢。比如low,lower,new,newest。这些词划分成1, 2 ,3...显然是有点不合理的。因为字与字,词与词之间多或少有点关联。又或者说,世界上那么多词,我每一个都给一个毫无意义的序号,是不是有点不对诶?
这就要讲到分词和分词的细腻程度。一般来说,现在分词主要分为三类:Character-based Tokenization,Word-based Tokenization和Subword-based Tokenization。
1.词级别分词示例 (Word-based Tokenization)
原始文本: "I love natural language processing. 我爱自然语言处理"
分词结果: ['I', 'love', 'natural', 'language', 'processing', '我',爱,'自',然',语',言',处',理']
2.字符级别分词示例 (Character-based Tokenization)
原始文本: "I love NLP"
分词结果: ['I', ' ', 'l', 'o', 'v', 'e', ' ', 'N', 'L', 'P']
3.子词级别分词示例 (Subword-based Tokenization)
原始文本: "I love natural language processing"
【BPE算法分词结果】
['I', ' lov', 'e', ' natur', 'al', ' langu', 'age', ' process', 'ing']
这一眼看过去就知道优缺点了,第一个肯定适合中文,但是英文是不是不太合适,词语的时态无法表示(v+ing,+ed肯定是对应不同的时态)。
第二个这个更是离谱了,直接让词语变得没有含义了,pass,pass。但是其实也有好处,就是没见过的生僻词,就可以用偏旁和字来构造
第三个是现在NLP任务最常用的,大人小孩都喜欢。它平衡词表和序列长度,同时保留语义信息而且学习了词语的形态规律。。。反正哪哪都好那我们就对 子词级别分词 来举个例子 BPE算法
1.1 BPE(Byte Pair Encoding)算法
BPE 的哲学是:词汇表不是固定的,而是通过统计学习出来的。它从单个字符开始,反复合并出现频率最高的相邻字符对,直到达到目标词汇表大小。
假设全世界的英文单词就五个词,而且全世界对这五个次的使用频率基本就这个比值。那么用bpe怎么划分?
low (出现 5 次)
lower (出现 2 次)
newest (出现 6 次)
widest (出现 3 次)
high (出现 4 次)
第一步:初始化为字符级
先在每个单词末尾加一个特殊的结束符 </w>(表示单词边界),然后拆成单个字符:
| 单词 | 字符序列 | 出现次数 |
|---|---|---|
| low | l o w </w> | 5 |
| lower | l o w e r </w> | 2 |
| newest | n e w e s t </w> | 6 |
| widest | w i d e s t </w> | 3 |
| high | h i g h </w> | 4 |
#这时候我们可以给词语划分成这样
#是不是很像以字符来划分,别着急,接着看
l, o, w, </w>, e, r, n, s, t, i, d, h, g
第二步:统计相邻字符对的出现频率
计算所有相邻字符对的出现次数(考虑出现次数加权):
| 字符对 | 出现在哪些单词(次数) | 总频次 |
|---|---|---|
| l o | low(5) + lower(2) | 7 |
| o w | low(5) + lower(2) | 7 |
| w </w> | low(5) | 5 |
| w e | lower(2) + newest(6) | 8 |
| e r | lower(2) | 2 |
| r </w> | lower(2) | 2 |
| n e | newest(6) | 6 |
| e w | newest(6) | 6 |
| w e | newest(6) | 6 |
| e s | newest(6) + widest(3) | 9 |
| s t | newest(6) + widest(3) | 9 |
| t </w> | newest(6) + widest(3) | 9 |
| w i | widest(3) | 3 |
| i d | widest(3) | 3 |
| d e | widest(3) | 3 |
| h i | high(4) | 4 |
| i g | high(4) | 4 |
| g h | high(4) | 4 |
| h </w> | high(4) | 4 |
第一轮合并:找到频率最高的对
最高频是 e s ,s t,t </w> 。假设我们先合并 e和s:
新子词:es
更新所有出现 es 的地方:
-
newest的n e w e s t→n e w es t -
widest的w i d e s t→w i d es t
这时候在划分是不是就成这样了
low: l o w </w> (5)
lower: l o w e r </w> (2)
newest: n e w es t </w> (6)
widest: w i d es t </w> (3)
high: h i g h </w> (4)
第三步:然后按照这个接着统计,继续合并,直到达到目标词汇表大小
假设我们要词汇表大小 = 20,继续合并:
合并 es t → est
合并 n e → ne
合并 l o → lo
合并 o w → ow
合并 w </w> → w</w>(这是单词末尾的 w)
合并 h i → hi
合并 i g → ig
合并 g h → gh
最终统计称这个玩意,是不是保留了字符级,又保留了子词级别呢。
字符级: l, o, w, </w>, e, r, n, s, t, i, d, h, g
子词级: es, wes, est, ne, lo, ow, w</w>, hi, ig, gh
这时候我们对上面那5个字来划分一下
low → lo w</w>
lower → lo w e r</w>
newest → ne wes t</w>
widest → w i d est</w>
high → hi gh</w>
这时候,你再给这些l, o, w, </w>, e按照顺序设置映射表,0, 1,2.....就可以啦。这样你就会得到两个东西,一个是用于分词的小模型,和一个词表。后续任何的句子输入,首先被切割,然后再通过查词表赋予id序号。
所以下次再见到tokenizer,就要知道什么是分词器是什么东西啦。
1.2 代码里如何实现
首先你现在你的环境里面安装库
pip install sentencepiece
#当然还有其他的bep分词工具,你可以自己看着来:
pip install tokenizers #huggingface
pip install youtokentome #YouTokenToMe
pip install tiktoken #OpenAI
然后就可以用这个直接使用进行划分,数据集就是用一个纯文本或者.en/.ch结尾的文件,里面每一行一句话的就可以。测试的时候我打算用 “中国人民很快就要迎来伟大的复兴”来测试,看看它是怎么分词的
import sentencepiece as spm
def train_spm(input_file, model_prefix, vocab_size, model_type='bpe', character_coverage=1.0):
"""
args:
input_file: 语料文件路径(每行一句)
model_prefix: 输出模型前缀(将生成 .model 和 .vocab 文件)
vocab_size: 词表大小
model_type: 模型类型 ('bpe', 'unigram', 'char', 'word')
character_coverage: 字符覆盖率(中文建议 0.9995,英文 1.0)
"""
spm.SentencePieceTrainer.Train(
f'--input={input_file} '
f'--model_prefix={model_prefix} '
f'--vocab_size={vocab_size} '
f'--model_type={model_type} '
f'--character_coverage={character_coverage} '
'--pad_id=0 --unk_id=1 --bos_id=2 --eos_id=3'
)
# ================= 测试示例=================
def test():
# 加载中文模型并测试
sp = spm.SentencePieceProcessor()
sp.Load('chn.model')
text = "中国人民很快就要迎来伟大的复兴"
print("分词结果:", sp.EncodeAsPieces(text))
print("ID序列: ", sp.EncodeAsIds(text))
# 解码测试
ids = [509, 259, 1265, 3887, 4324, 4070, 2569]
print("解码文本:", sp.DecodeIds(ids))
if __name__ == "__main__":
test()
# ================= 训练英文模型 =================
# train_spm(
# input_file='../data/corpus.en', # 英文语料路径
# model_prefix='eng', # 输出文件前缀
# vocab_size=32000, # 词表大小
# model_type='bpe', # 使用 BPE
# character_coverage=1.0 # 英文字符集小,全覆盖
# )
# ================= 训练中文模型 =================
# train_spm(
# input_file='../data/corpus.ch', # 中文语料路径
# model_prefix='chn',
# vocab_size=32000,
# model_type='bpe',
# character_coverage=0.9995 # 中文用高覆盖率,罕见字会映射为 <unk>
# )
运行结果:

2. 数据处理
对于训练一个神经网络来说最终要的无非就两个东西。用于训练的数据,用于训练的标签。和简单的分类一样,在训练神经网络的时候你输入一张狗的图片,你就要在损失函数里告诉神经网络这个张图是狗,让它进行梯度更新。大模型一样,你让它做一个翻译,首先你要给一个英文句子的token吧,然后它预测的结果肯定是中文token,你就需要把正确的中文token给模型,让他进行反向传播。
2.1 token长度对齐
是不是有点疑惑,我不是已经分词了吗,每一句话里面的词语已经对应了id,不应该直接开始训练了吗?这时候问你一个问题。既然大模型是根据之前的词语一个词一个词的预测。
假如大模型现在输出一句话 ‘ 我喜欢你 ’ 和另外一句话 ‘ 这是你 ’。第一句话如果结束了,第二句话没有结束,但是结尾都是你,大模型怎么知道结束了没呢?所以这个就要引入一些特殊的token,用于判别句子开头,结尾,空格,填充......等等等这些东西。
其次,我们训练模型的时候,都是并行运算,按照一个batch一个batch来算的。你做图像分类的时候都知道需要resize到某一个尺寸的图片,那语言模型也一样,在每一个batch都需要句子里面的token长度一样,所以分词之后还需要用<pad>这个token来进行填充。下面是四个常见的特殊token。
-
<bos>:标记句子开头(beginning of sentence),帮助模型识别句首。 -
<eos>:标记句子结束(end of sentence),让模型明确输出边界。 -
<pad>:填充 token,用于将一个 batch 内所有序列补齐到相同长度,便于并行计算。 -
<unk>:未知词 token,当遇到未登录词时使用。
综上来说,现在我举一个例子,更好的帮助你理解。
假设词汇表已经定义了特殊 token id:<pad>=0, <unk>=1, <bos>=2, <eos>=3
两句话: 上号来打几把
不是哥们,我中路杀完了你还在家呆着呢
第一句话分词结果是 【231, 5132, 4548, 484, 156】
第二句话分词结果是 【255, 848, 7788, 5411, 4451, 12, 527, 66】
首先先增加bos和eos
【2, 231, 5132, 4548, 484, 156,3】
【2, 255, 848, 7788, 5411, 4451, 12, 527, 66, 3】
这时候发现长度不一样,对第一句话补充pad
【2, 231, 5132, 4548, 484, 156, 3, 0, 0,0】
【2, 255, 848, 7788, 5411, 4451, 12, 527, 66,3】
这样一个batch就准备好了!但是并非所有的模型都是这样的处理方式。根据你的任务来制定你的前处理事什么样子的,比如说GPT,他就是only_decode架构,它的输入只有一句话,因此句子和句子中间还有一个特殊的token,<sep>用于当做句子和句子之间的分隔符。
2.2 labels和train_data对齐
对于简单的分类来说,一张图片对应一个类别,这就算label和train对齐了。但是在语言模型来说,它的label和train是在token维度上对齐的,也就是一个token一个token对齐的,模型在训练和推理是两个逻辑,现在我们按照训练来说。模型输入bos,这时候就应该预测bos的下一个词。那是不是很好理解了。
假设现在
【2, 255, 848, 7788, 5411, 4451, 12, 527, 66,3】这是模型的输入
那它的输出是不是应该往后移动一个
2预测255,2和255预测844,2和255和844预测7788 ......
最后是不是应该输入去掉最后一个,labe去掉第一个词
输入:【2 , 255, 848 , 7788, 5411, 4451, 12 ,527, 66】
输出:【255, 848, 7788, 5411, 4451, 12 , 527, 66 ,3 】
这样错位对应
第一个2预测的真值是255,这样就可以送入损失函数。
当然,transformer他是并发的训练的,需要用到不同的mask,后面到模型结构的时候我来说。截止到这你只需要理解谁去预测谁。现在你用脑子想想分类模型的图片和label对应在transformer中,是谁和谁?
3.总结
下一章我开始讲英文输入到transformer的编码器中,它需要什么的mask,数据是什么样子的流动。以及中文输入到解码器中,训练和预测的是他们的逻辑是什么。
现在总结一下今天学习的东西:大量的文本去训练分词模型,得到一个词表,一个分词器。一句话输入的时候,分词器把句子分为一段一段的token,然后把这一段一段的token再对应词表上的id。
然后再这个id前面签上特殊token的id,拼成一句话 【bos】+【src】+【eos】+【pad】* n 这个就是原始的token,训练的时候输入是【bos】+【src】+【eos】+【pad】* (n-1),label是【src】+【eos】+【pad】* n。如果不理解的话去想想一下分类模型需要什么,其实都大差不差,只是在预测的细腻程度上不同罢了。
应该很快就会把下一篇文章写出来,毕竟现在不用实习课也少,没事就胡扯胡扯。下次再见!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐


所有评论(0)