Featured image of post 大模型原理:从transformer到llm

大模型原理:从transformer到llm

本文介绍从Transformer架构到大型语言模型(LLM)的发展历程和核心原理,包括注意力机制、预训练语言模型等关键概念。

0 前言

这篇主要作用是学习笔记. 大部分都是来源于该项目datawhalechina/happy-llm: 从零开始的大语言模型原理与实践教程.

该篇仅包括上述项目前四章的部分,主概念。 前四章主要讲述ai的发展, 从最初的transformer架构,到预训练语言模型,,到如今的大语言模型。后三章则侧重于实现,比如如何搭建一个大模型,模型如何训练微调,大模型的评测,大模型Agent等实际应用的发展。后三章目前搁置,等看完另一篇llm入门之后再返回看看。

前三章从比较通俗的角度介绍了相关概念,同时有部分代码和案例辅以说明,适合对ai了解不多的同学。transformer架构分为 embedding,位置编码,编码器和解码器四个部分。

编码器和解码器内部由前馈神经网络和注意力机制组成。

  • embedding用于转化自然语言;
  • 位置编码在于确认各个token的相对位置;
  • 编码器的倾向在于理解输入序列,解码器的倾向在于根据编码器的输出和上下文如何给出正确的结果,因此在现代数据量过多的情况下为了提升效率简化结构,常见的比如gpt,GLM等其实都输入only-decoder结构;
  • 编码器和解码器核心都是注意力机制和前馈神经网络,差别在于用的具体分类不同,比如编码器常用自注意力机制训练,解码器则用掩码注意力训练。
  • 前馈神经网络是最基本的神经网络结构,数据从输入层到输出层中经过若干隐藏层,信息沿着一个方向传播,保证输入维度不变的情况下对输入特征进行非线性变化,从而提取更抽象的特征表示;注意力机制的核心在于比较两个序列中元素的相关度,基于相关度进行加权分配注意力,上文提到的掩码也就是遮掩数据的部分进行训练。
  • 另外还有两个是层归一化和残差连接。这是由于多层网络在训练时会产生损失,归一化核心是为了让不同层输入的取值范围或者分布能够比较一致。残差连接,即下一层的输入不仅是上一层的输出,还包括上一层的输入。

预训练语言模型中则是着重于 1 介绍数据集的作用,随着模型发展,越多的数据集有助于提示模型效果,起到量变导致的质变;2 预训练的方法,比如MLM掩码语言模型适用于文本理解,CLM自回归语言模型适用于文本生成,也有指令微调(区分任务或者区分文本),一些更细节的方案比如如何embedding和encoder拆开提升效率,多层神经网络设置共享变量减少内存等

最后就是介绍了llm的三个阶段,预训练,监督微调sft,强化学习和人类反馈RLHF。简单理解就是

  • 预训练在于提高整体的数据量期望达到量变引起质变的想过,
  • 监督微调则是让模型从多种类型,多种风格的指令中获取泛化的指令遵循能力,也就是能够更好更准确的理解和回复用户的指令。同时模型的多轮对话能力也是由该层控制。
  • 人类反馈强化学习则是更深层次的让llm和人类价值观对其,不仅是输出回答,还要能输出更符合用户视角,用户需要的满意回答。主要由奖励模型和近端策略优化算法实现,后者属于经典的强化学习算法。

后三章其实更偏实践,且待我填坑。

1 基础概念

以 GPT、BERT 为代表的 PLM 是上一阶段 NLP 领域的核心研究成果,以注意力机制为模型架构,通过预训练-微调的阶段思想通过在海量无监督文本上进行自监督预训练,实现了强大的自然语言理解能力。

LLM 是在 PLM 的基础上,通过大量扩大模型参数、预训练数据规模,并引入指令微调、人类反馈强化学习等手段实现的突破性成果。相较于传统 PLM,LLM 具备涌现能力,具有强大的上下文学习能力、指令理解能力和文本生成能力。

自然语言处理

  • 模型:Word2Vec模型,Bert模型
  • 任务:中文分词,子词切分,词性标注,文本分类,实体识别,关系抽取,文本摘要,机器翻译,自动问答,

N-gram 模型: NLP 领域中一种基于统计的语言模型,广泛应用于语音识别、手写识别、拼写纠错、机器翻译和搜索引擎等众多任务。N-gram模型的核心思想是基于马尔可夫假设,即一个词的出现概率仅依赖于它前面的N-1个词。

Word2Vec:一种流行的词嵌入(Word Embedding)技术,由Tomas Mikolov等人在2013年提出。它是一种基于神经网络NNLM的语言模型,旨在通过学习词与词之间的上下文关系来生成词的密集向量表示。Word2Vec的核心思想是利用词在文本中的上下文信息来捕捉词之间的语义关系,从而使得语义相似或相关的词在向量空间中距离较近。

ELMo:(Embeddings from Language Models)实现了一词多义、静态词向量到动态词向量的跨越式转变。首先在大型语料库上训练语言模型,得到词向量模型,然后在特定任务上对模型进行微调,得到更适合该任务的词向量,ELMo首次将预训练思想引入到词向量的生成中,使用双向LSTM结构,能够捕捉到词汇的上下文信息,生成更加丰富和准确的词向量表示。

2 Transformer架构

注意力机制

神经网络的核心架构

  • FNN(前馈神经网络)是最基本的神经网络结构,信息从输入层到输出层单向传递,常用于结构化数据建模,如分类和回归任务。
  • CNN(卷积神经网络)通过卷积和池化操作自动提取局部特征,特别适合处理图像、视频等具有空间结构的数据,在计算机视觉领域应用最广。
  • RNN(循环神经网络)通过循环结构保留时间依赖信息,适合处理序列数据,如自然语言、语音识别和时间序列预测。

在注意力机制横空出世之前,RNN 以及 RNN 的衍生架构 LSTM 是 NLP 领域当之无愧的霸主。

理解注意力机制

注意力机制有三个核心变量:查询值 Query,键值 Key 和 真值 Value。

注意力机制的本质是对两段序列的元素依次进行相似度计算,寻找出一个序列的每个元素对另一个序列的每个元素的相关度,然后基于相关度进行加权,即分配注意力。

$$ attention(Q,K,V) = softmax(\frac{QK^T}{\sqrt{d_k}})V $$
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
'''注意力计算函数'''
def attention(query, key, value, dropout=None):
    '''
    args:
    query: 查询值矩阵
    key: 键值矩阵
    value: 真值矩阵
    '''
    # 获取键向量的维度,键向量的维度和值向量的维度相同
    d_k = query.size(-1) 
    # 计算Q与K的内积并除以根号dk,transpose——相当于转置
    scores = torch.matmul(query, key.transpose(-2, -1)) / math.sqrt(d_k)
    # Softmax 将分数归一化为概率分布,使所有注意力权重之和为 1。
    p_attn = scores.softmax(dim=-1)
    # Dropout 是一种正则化手段,用来随机丢弃部分注意力权重
    if dropout is not None: 
        p_attn = dropout(p_attn)
     # 根据计算结果对value进行加权求和
    return torch.matmul(p_attn, value), p_attn

自注意力,掩码自注意力,多头注意力

自注意力,即是计算本身序列中每个元素对其他元素的注意力分布,即在计算过程中,Q、K、V 都由同一个输入通过不同的参数矩阵计算得到。

掩码自注意力,即 Mask Self-Attention,是指使用注意力掩码的自注意力机制。掩码的作用是遮蔽一些特定位置的 token,模型在学习的过程中,会忽略掉被遮蔽的 token。使用注意力掩码的核心动机是让模型只能使用历史信息进行预测而不能看到未来信息。

1
2
3
4
5
6
7
8
9
# 创建一个上三角矩阵,用于遮蔽未来信息。
# 先通过 full 函数创建一个 1 * seq_len * seq_len 的矩阵
mask = torch.full((1, args.max_seq_len, args.max_seq_len), float("-inf"))
# triu 函数的功能是创建一个上三角矩阵
mask = torch.triu(mask, diagonal=1)

# 此处的 scores 为计算得到的注意力分数,mask 为上文生成的掩码矩阵
scores = scores + mask[:, :seqlen, :seqlen]
scores = F.softmax(scores.float(), dim=-1).type_as(xq)

多头注意力机制(Multi-Head Attention),即同时对一个语料进行多次注意力计算,每次注意力计算都能拟合不同的关系,将最后的多次结果拼接起来作为最后的输出,即可更全面深入地拟合语言信息。

Encoder-Decoder

在 Transformer 中,使用注意力机制的是其两个核心组件——Encoder(编码器)和 Decoder(解码器)。事实上,后续基于 Transformer 架构而来的预训练语言模型基本都是对 Encoder-Decoder 部分进行改进来构建新的模型架构,例如只使用 Encoder 的 BERT、只使用 Decoder 的 GPT 等。

Encoder 和 Decoder 内部传统神经网络的经典结构为——前馈神经网络(FNN)、层归一化(Layer Norm)和残差连接(Residual Connection),然后进一步分析 Encoder 和 Decoder 的内部结构。

Seq2seq模型

Seq2Seq,即序列到序列,是一种经典 NLP 任务。对于 Seq2Seq 任务,一般的思路是对自然语言序列进行编码再解码。具体而言,是指模型输入的是一个自然语言序列,

$$ input = (x_1, x_2, x_3...x_n) $$

输出的是一个可能不等长的自然语言序列 。

$$ output = (y_1, y_2, y_3...y_m) $$

事实上,Seq2Seq 是 NLP 最经典的任务,几乎所有的 NLP 任务都可以视为 Seq2Seq 任务。例如文本分类任务,可以视为输出长度为 1 的目标序列(如在上式中 m = 1);词性标注任务,可以视为输出与输入序列等长的目标序列(如在上式中 m = n)。

前馈神经网络

FNN 是最基础的神经网络结构,数据从输入层经过若干隐藏层,最后到达输出层,信息只沿一个方向传播,不会回传或循环。

在保持输入维度不变的情况下,对输入特征进行非线性变换和重新组合,从而提取更丰富、更抽象的特征表示。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class MLP(nn.Module):
    '''前馈神经网络'''
    def __init__(self, dim: int, hidden_dim: int, dropout: float):
        super().__init__()
        # 定义第一层线性变换,从输入维度到隐藏维度
        self.w1 = nn.Linear(dim, hidden_dim, bias=False)
        # 定义第二层线性变换,从隐藏维度到输入维度
        self.w2 = nn.Linear(hidden_dim, dim, bias=False)
        # 定义dropout层,用于防止过拟合
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # 前向传播函数
        # 首先,输入x通过第一层线性变换和RELU激活函数
        # 最后,通过第二层线性变换和dropout层
        return self.dropout(self.w2(F.relu(self.w1(x))))
    
"""
输入 x
 → 线性层 (dim → hidden_dim)  # 把输入特征从 dim 维映射到更高维的 hidden_dim 空间。
 → ReLU 激活					# 给网络引入非线性能力,让模型能学习复杂函数关系,而不仅仅是线性映射。
 → 线性层 (hidden_dim → dim)  # 再做一次线性变换,把高维特征重新压缩回原始维度 dim。
 → Dropout					 # 在训练时随机“丢弃”一部分神经元(置为 0),防止模型对特定神经元依赖过强,从而提升泛化能力。
 → 输出 y

"""

层归一化

归一化核心是为了让不同层输入的取值范围或者分布能够比较一致。

相较于 Batch Norm 在每一层统计所有样本的均值和方差,Layer Norm 在每个样本上计算其所有层的均值和方差,从而使每个样本的分布达到稳定。

$$ \widetilde{Z_j} = \frac{Z_j - \mu_j}{\sqrt{\sigma^2 + \epsilon}} $$

残差连接

残差连接,即下一层的输入不仅是上一层的输出,还包括上一层的输入。

例如,在 Encoder 中,在第一个子层,输入进入多头自注意力层的同时会直接传递到该层的输出,然后该层的输出会与原输入相加,再进行标准化。在第二个子层也是一样。即:

$$ x = x + MultiHeadSelfAttention(LayerNorm(x)) $$$$ output = x + FNN(LayerNorm(x)) $$

Encoder, Decoder

Transformer 的 Encoder。Encoder 由 N 个 Encoder Layer 组成,每一个 Encoder Layer 包括一个注意力层和一个前馈神经网络。

Decoder 由两个注意力层和一个前馈神经网络组成。第一个注意力层是一个掩码自注意力层,即使用 Mask 的注意力计算,保证每一个 token 只能使用该 token 之前的注意力分数;第二个注意力层是一个多头注意力层,该层将使用第一个注意力层的输出作为 query,使用 Encoder 的输出作为 key 和 value,来计算注意力分数。最后,再经过前馈神经网络.

类型信息可见性特点常用场景
注意力层 (Self-Attention)每个 token 可见整个序列捕捉全局依赖Transformer 编码器
掩码注意力层 (Masked Self-Attention)只能看见前面 token,屏蔽未来保证自回归生成Transformer 解码器 / GPT
多头注意力层 (Multi-Head Attention)并行多个注意力头,每头看不同特征子空间增强模型表达能力所有 Transformer 注意力模块

Transformer构建

Embedding - 分词

将 Encoder、Decoder 拼接起来再加入 Embedding 层就可以搭建出完整的 Transformer 模型。

Embedding 层需要将自然语言的输入转化为机器可以处理的向量.

Embedding 层其实是一个存储固定大小的词典的嵌入向量查找表。也就是说,在输入神经网络之前,我们往往会先让自然语言输入通过分词器 tokenizer,分词器的作用是把自然语言输入切分成 token 并转化成一个固定的 index。

1
self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)

位置编码

位置编码,即根据序列中 token 的相对位置对其进行编码,再将位置编码加入词向量编码中。位置编码的方式有很多,Transformer 使用了正余弦函数来进行位置编码(绝对位置编码Sinusoidal),其编码方式为:

$$ PE(pos, 2i) = sin(pos/10000^{2i/d_{model}})\\ PE(pos, 2i+1) = cos(pos/10000^{2i/d_{model}}) $$

上式中,pos 为 token 在句子中的位置,2i 和 2i+1 则是指示了 token 是奇数位置还是偶数位置,从上式中我们可以看出对于奇数位置的 token 和偶数位置的 token,Transformer 采用了不同的函数进行编码。

transformer code

整体流程:

输入文本 → Token Embedding → Positional Encoding → Encoder → Decoder → 输出层(lm_head)

  1. 加载中文模型对词进行预处理;

  2. 对每个 token 进行词向量嵌入(Embedding)— 将每个token ID映射成一个高维向量;

  3. 加入位置编码(Positional Encoding)— 增加序列顺序感;

  4. 输入 Encoder(提取上下文特征);

    • 前馈网络:一般由两层线性+激活函数,增强模型表达能力

    • 多层自注意力:理解词和词之间的关系

  5. Decoder 根据 Encoder 输出 + 已生成的词预测下一个词;

  6. 输出层(线性+Softmax)将结果映射为词表概率。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import torch
import math
from torch import nn
from dataclasses import dataclass
from transformers import BertTokenizer
import torch.nn.functional as F


@dataclass
class ModelArgs:
    n_embd: int  # 嵌入维度
    n_heads: int  # 头数
    dim: int  # 模型维度
    dropout: float
    max_seq_len: int
    vocab_size: int
    block_size: int
    n_layer: int

""" 核心注意力机制 """
class MultiHeadAttention(nn.Module):

    def __init__(self, args: ModelArgs, is_causal=False):
        # 构造函数
        # args: 配置对象
        super().__init__()
        # 隐藏层维度必须是头数的整数倍,因为后面我们会将输入拆成头数个矩阵
        assert args.dim % args.n_heads == 0
        # 每个头的维度,等于模型维度除以头的总数。
        self.head_dim = args.dim // args.n_heads
        self.n_heads = args.n_heads

        # Wq, Wk, Wv 参数矩阵,每个参数矩阵为 n_embd x dim
        # 这里通过三个组合矩阵来代替了n个参数矩阵的组合,其逻辑在于矩阵内积再拼接其实等同于拼接矩阵再内积,
        # 不理解的读者可以自行模拟一下,每一个线性层其实相当于n个参数矩阵的拼接
        self.wq = nn.Linear(args.n_embd, self.n_heads * self.head_dim, bias=False)
        self.wk = nn.Linear(args.n_embd, self.n_heads * self.head_dim, bias=False)
        self.wv = nn.Linear(args.n_embd, self.n_heads * self.head_dim, bias=False)
        # 输出权重矩阵,维度为 dim x dim(head_dim = dim / n_heads)
        self.wo = nn.Linear(self.n_heads * self.head_dim, args.dim, bias=False)
        # 注意力的 dropout
        self.attn_dropout = nn.Dropout(args.dropout)
        # 残差连接的 dropout
        self.resid_dropout = nn.Dropout(args.dropout)
        self.is_causal = is_causal

        # 创建一个上三角矩阵,用于遮蔽未来信息
        # 注意,因为是多头注意力,Mask 矩阵比之前我们定义的多一个维度
        if is_causal:
            mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float("-inf"))
            mask = torch.triu(mask, diagonal=1)
            # 注册为模型的缓冲区
            self.register_buffer("mask", mask)

    def forward(self, q: torch.Tensor, k: torch.Tensor, v: torch.Tensor):

        # 获取批次大小和序列长度,[batch_size, seq_len, dim]
        bsz, seqlen, _ = q.shape

        # 计算查询(Q)、键(K)、值(V),输入通过参数矩阵层,维度为 (B, T, n_embed) x (n_embed, dim) -> (B, T, dim)
        xq, xk, xv = self.wq(q), self.wk(k), self.wv(v)

        # 将 Q、K、V 拆分成多头,维度为 (B, T, n_head, dim // n_head),然后交换维度,变成 (B, n_head, T, dim // n_head)
        # 因为在注意力计算中我们是取了后两个维度参与计算
        # 为什么要先按B*T*n_head*C//n_head展开再互换1、2维度而不是直接按注意力输入展开,是因为view的展开方式是直接把输入全部排开,
        # 然后按要求构造,可以发现只有上述操作能够实现我们将每个头对应部分取出来的目标
        xq = xq.view(bsz, seqlen, self.n_heads, self.head_dim)
        xk = xk.view(bsz, seqlen, self.n_heads, self.head_dim)
        xv = xv.view(bsz, seqlen, self.n_heads, self.head_dim)
        xq = xq.transpose(1, 2)
        xk = xk.transpose(1, 2)
        xv = xv.transpose(1, 2)

        # 注意力计算
        # 计算 QK^T / sqrt(d_k),维度为 (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
        scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)
        # 掩码自注意力必须有注意力掩码
        if self.is_causal:
            assert hasattr(self, 'mask')
            # 这里截取到序列长度,因为有些序列可能比 max_seq_len 短
            scores = scores + self.mask[:, :, :seqlen, :seqlen]
        # 计算 softmax,维度为 (B, nh, T, T)
        scores = F.softmax(scores.float(), dim=-1).type_as(xq)
        # 做 Dropout
        scores = self.attn_dropout(scores)
        # V * Score,维度为(B, nh, T, T) x (B, nh, T, hs) -> (B, nh, T, hs)
        output = torch.matmul(scores, xv)

        # 恢复时间维度并合并头。
        # 将多头的结果拼接起来, 先交换维度为 (B, T, n_head, dim // n_head),再拼接成 (B, T, n_head * dim // n_head)
        # contiguous 函数用于重新开辟一块新内存存储,因为Pytorch设置先transpose再view会报错,
        # 因为view直接基于底层存储得到,然而transpose并不会改变底层存储,因此需要额外存储
        output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)

        # 最终投影回残差流。
        output = self.wo(output)
        output = self.resid_dropout(output)
        return output


class LayerNorm(nn.Module):
    ''' Layer Norm 层'''

    def __init__(self, features, eps=1e-6):
        super().__init__()
        # 线性矩阵做映射
        self.a_2 = nn.Parameter(torch.ones(features))
        self.b_2 = nn.Parameter(torch.zeros(features))
        self.eps = eps

    def forward(self, x):
        # 在统计每个样本所有维度的值,求均值和方差
        mean = x.mean(-1, keepdim=True)  # mean: [bsz, max_len, 1]
        std = x.std(-1, keepdim=True)  # std: [bsz, max_len, 1]
        # 注意这里也在最后一个维度发生了广播
        return self.a_2 * (x - mean) / (std + self.eps) + self.b_2


class MLP(nn.Module):
    '''前馈神经网络'''

    def __init__(self, dim: int, hidden_dim: int, dropout: float):
        super().__init__()
        # 定义第一层线性变换,从输入维度到隐藏维度
        self.w1 = nn.Linear(dim, hidden_dim, bias=False)
        # 定义第二层线性变换,从隐藏维度到输入维度
        self.w2 = nn.Linear(hidden_dim, dim, bias=False)
        # 定义dropout层,用于防止过拟合
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        # 前向传播函数
        # 首先,输入x通过第一层线性变换和RELU激活函数
        # 最后,通过第二层线性变换和dropout层
        return self.dropout(self.w2(F.relu(self.w1(x))))


class EncoderLayer(nn.Module):
    def __init__(self, args):
        super().__init__()
        # 一个 Layer 中有两个 LayerNorm,分别在 Attention 之前和 MLP 之前
        self.attention_norm = LayerNorm(args.n_embd)
        # Encoder 不需要掩码,传入 is_causal=False
        self.attention = MultiHeadAttention(args, is_causal=False)
        self.fnn_norm = LayerNorm(args.n_embd)
        self.feed_forward = MLP(args.dim, args.dim, args.dropout)

    def forward(self, x):
        # Layer Norm
        x = self.attention_norm(x)
        # 自注意力
        h = x + self.attention.forward(x, x, x)
        # 经过前馈神经网络
        out = h + self.feed_forward.forward(self.fnn_norm(h))
        return out


class Encoder(nn.Module):
    '''Encoder 块'''

    def __init__(self, args):
        super(Encoder, self).__init__()
        # 一个 Encoder 由 N 个 Encoder Layer 组成
        self.layers = nn.ModuleList([EncoderLayer(args) for _ in range(args.n_layer)])
        self.norm = LayerNorm(args.n_embd)

    def forward(self, x):
        "分别通过 N 层 Encoder Layer"
        for layer in self.layers:
            x = layer(x)
        return self.norm(x)


class DecoderLayer(nn.Module):
    '''Decoder 层'''

    def __init__(self, args):
        super().__init__()
        # 一个 Layer 中有三个 LayerNorm,分别在 Mask Attention 之前、Self Attention 之前和 MLP 之前
        self.attention_norm_1 = LayerNorm(args.n_embd)
        # Decoder 的第一个部分是 Mask Attention,传入 is_causal=True
        self.mask_attention = MultiHeadAttention(args, is_causal=True)
        self.attention_norm_2 = LayerNorm(args.n_embd)
        # Decoder 的第二个部分是 类似于 Encoder 的 Attention,传入 is_causal=False
        self.attention = MultiHeadAttention(args, is_causal=False)
        self.ffn_norm = LayerNorm(args.n_embd)
        # 第三个部分是 MLP
        self.feed_forward = MLP(args.dim, args.dim, args.dropout)

    def forward(self, x, enc_out):
        # Layer Norm
        x = self.attention_norm_1(x)
        # 掩码自注意力
        x = x + self.mask_attention.forward(x, x, x)
        # 多头注意力
        x = self.attention_norm_2(x)
        h = x + self.attention.forward(x, enc_out, enc_out)
        # 经过前馈神经网络
        out = h + self.feed_forward.forward(self.ffn_norm(h))
        return out


class Decoder(nn.Module):
    '''解码器'''

    def __init__(self, args):
        super(Decoder, self).__init__()
        # 一个 Decoder 由 N 个 Decoder Layer 组成
        self.layers = nn.ModuleList([DecoderLayer(args) for _ in range(args.n_layer)])
        self.norm = LayerNorm(args.n_embd)

    def forward(self, x, enc_out):
        "Pass the input (and mask) through each layer in turn."
        for layer in self.layers:
            x = layer(x, enc_out)
        return self.norm(x)


class PositionalEncoding(nn.Module):
    '''位置编码模块'''

    def __init__(self, args):
        super(PositionalEncoding, self).__init__()
        # Dropout 层
        # self.dropout = nn.Dropout(p=args.dropout)

        # block size 是序列的最大长度
        pe = torch.zeros(args.block_size, args.n_embd)
        position = torch.arange(0, args.block_size).unsqueeze(1)
        # 计算 theta
        div_term = torch.exp(
            torch.arange(0, args.n_embd, 2) * -(math.log(10000.0) / args.n_embd)
        )
        # 分别计算 sin、cos 结果
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer("pe", pe)

    def forward(self, x):
        # 将位置编码加到 Embedding 结果上
        x = x + self.pe[:, : x.size(1)].requires_grad_(False)
        return x


class Transformer(nn.Module):
    '''整体模型'''

    def __init__(self, args):
        super().__init__()
        # 必须输入词表大小和 block size
        assert args.vocab_size is not None
        assert args.block_size is not None
        self.args = args
        self.transformer = nn.ModuleDict(dict(
            wte=nn.Embedding(args.vocab_size, args.n_embd), # 词嵌入
            wpe=PositionalEncoding(args), # 位置编码
            drop=nn.Dropout(args.dropout), # dropout
            encoder=Encoder(args), # 编码器
            decoder=Decoder(args), # 解码器
        ))
        # 最后的线性层,输入是 n_embd,输出是词表大小
        self.lm_head = nn.Linear(args.n_embd, args.vocab_size, bias=False)

        # 初始化所有的权重
        self.apply(self._init_weights)

        # 查看所有参数的数量
        print("number of parameters: %.2fM" % (self.get_num_params() / 1e6,))

    '''统计所有参数的数量'''

    def get_num_params(self, non_embedding=False):
        # non_embedding: 是否统计 embedding 的参数
        n_params = sum(p.numel() for p in self.parameters())
        # 如果不统计 embedding 的参数,就减去
        if non_embedding:
            n_params -= self.transformer.wte.weight.numel()
        return n_params

    '''初始化权重'''

    def _init_weights(self, module):
        # 线性层和 Embedding 层初始化为正则分布
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)

    '''前向计算函数'''

    def forward(self, idx, targets=None):
        # 输入为 idx,维度为 (batch size, sequence length, 1);targets 为目标序列,用于计算 loss
        device = idx.device
        b, t = idx.size()
        assert t <= self.args.block_size, f"不能计算该序列,该序列长度为 {t}, 最大序列长度只有 {self.args.block_size}"

        # 通过 self.transformer
        # 首先将输入 idx 通过 Embedding 层,得到维度为 (batch size, sequence length, n_embd)
        print("idx:", idx.size())
        # 通过 Embedding 层
        tok_emb = self.transformer.wte(idx)
        print("tok_emb:", tok_emb.size())
        # 然后通过位置编码
        pos_emb = self.transformer.wpe(tok_emb)
        # 再进行 Dropout
        x = self.transformer.drop(pos_emb)
        # 然后通过 Encoder
        print("x after wpe:", x.size())
        enc_out = self.transformer.encoder(x)
        print("enc_out:", enc_out.size())
        # 再通过 Decoder
        x = self.transformer.decoder(x, enc_out)
        print("x after decoder:", x.size())

        if targets is not None:
            # 训练阶段,如果我们给了 targets,就计算 loss
            # 先通过最后的 Linear 层,得到维度为 (batch size, sequence length, vocab size)
            logits = self.lm_head(x)
            # 再跟 targets 计算交叉熵
            loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=-1)
        else:
            # 推理阶段,我们只需要 logits,loss 为 None
            # 取 -1 是只取序列中的最后一个作为输出
            logits = self.lm_head(x[:, [-1], :])  # note: using list [-1] to preserve the time dim
            loss = None

        return logits, loss


def main():
    args = ModelArgs(100, 10, 100, 0.1, 512, 1000, 1000, 2)
    text = "我喜欢快乐地学习大模型"
    # 加载中文模型,进行预处理
    tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
    # inputs_tokens 为字典,包含inputs_ids(词token id映射序列)和attention_mask
    inputs_token = tokenizer(
        text,
        return_tensors='pt',
        max_length=args.max_seq_len,
        truncation=True,
        padding='max_length'
    )
    # 将词表大小写入模型参数中
    args.vocab_size = tokenizer.vocab_size
    # 创建模型
    transformer = Transformer(args)
    inputs_id = inputs_token['input_ids']
    """
    输入 token 
    → 嵌入层 (Embedding)
        --- 将每个token ID映射成一个高维向量
    → 位置编码 (Positional Encoding)
        --- 增加序列顺序感
    → 多层自注意力 + 前馈网络 (Transformer Blocks) 
        --- 多层自注意力:理解词和词之间的关系
        --- 前馈网络:一般由两层线性+激活函数,增强模型表达能力
    → 线性层映射到 vocab_size 维度
        --- 映射回词表维度
    → 输出 logits
    """
    logits, loss = transformer.forward(inputs_id)
    print("logits: ", logits)
    # 取预测结果,解码
    predicted_ids = torch.argmax(logits, dim=-1).item()
    output = tokenizer.decode(predicted_ids)
    print("outputs: ", output)


if __name__ == "__main__":
    print("开始")
    main()

3 预训练语言模型

“PLM” 是 “Pre-trained Language Model” 的缩写

模块主要输入主要机制主要输出核心作用
编码器 (Encoder)输入序列(如英文句子)自注意力(Self-Attention)每个词的上下文语义表示理解输入语义
解码器 (Decoder)上下文表示 + 已生成的部分输出Masked Self-Attention + Encoder-Decoder Attention下一个词的概率分布根据语义生成输出

Encoder-only PLM

Bert

自 BERT 推出以来,预训练+微调的模式开始成为自然语言处理任务的主流.

  • transformer架构: BERT 正沿承了 Transformer 的思想,在 Transformer 的模型基座上进行优化,通过将 Encoder 结构进行堆叠,扩大模型参数,打造了在 NLU 任务上独居天分的模型架构;
  • 预训练+微调范式:

模型整体既是由 Embedding、Encoder 加上 prediction_heads 组成; prediction_heads用于将多维度的隐藏状态通过线性层转换到分类维度, prediction_heads 其实就是线性层加上激活函数,一般而言,最后一个线性层的输出维度和任务的类别数相等.

相较于基本沿承 Transformer 的模型架构,BERT 更大的创新点在于其提出的两个新的预训练任务上——MLM 和 NSP(Next Sentence Prediction,下一句预测)。

预训练-微调范式的核心优势在于,通过将预训练和微调分离,完成一次预训练的模型可以仅通过微调应用在几乎所有下游任务上,只要微调的成本较低,即使预训练成本是之前的数倍甚至数十倍,模型仍然有更大的应用价值。预训练数据的核心要求即是需要极大的数据规模(数亿 token)。

**MLM,也就是掩码语言模型作为新的预训练任务。**相较于模拟人类写作的 LM,MLM 模拟的是“完形填空”。MLM 的思路也很简单,在一个文本序列中随机遮蔽部分 token,然后将所有未被遮蔽的 token 输入模型,要求模型根据输入预测被遮蔽的 token。由于模型可以利用被遮蔽的 token 的上文和下文一起理解语义来预测被遮蔽的 token,因此通过这样的任务,模型可以拟合双向语义,也就能够更好地实现文本的理解。

**NSP,即下一个句子预测。**NSP 的核心思想是要求模型判断一个句对的两个句子是否是连续的上下文,例如问答匹配、自然语言推理等。这样的任务都需要模型在句级去拟合关系,判断两个句子之间的关系,而不仅是 MLM 在 token 级拟合的语义关系。

BERT 的一个重大意义就是正式确立了预训练-微调的两阶段思想,即在海量无监督语料上进行预训练来获得通用的文本理解与生成能力,再在对应的下游任务上进行微调。该种思想的一个重点在于,预训练得到的强大能力能否通过低成本的微调快速迁移到对应的下游任务上。

所谓微调,其实和训练时更新模型参数的策略一致,只不过在特定的任务、更少的训练数据、更小的 batch_size 上进行训练,更新参数的幅度更小。

RoBERTa

优化一:去掉 NSP 预训练任务.

去掉NSP, 为MLM从静态遮蔽改为动态遮蔽.

优化二:更大规模的预训练数据和预训练步长

更大的预训练数据、更长的序列长度和更多的训练 Epoch,需要预训练阶段更多的算力资源。

优化三:更大的 bpe 词表

与 BERT 使用的 WordPiece 算法不同,RoBERTa 使用了 BPE 作为 Tokenizer 的编码策略。BPE,即 Byte Pair Encoding,字节对编码,是指以子词对作为分词的单位。越大的词表也会带来模型参数的增加。

RoBERTa 成功地在 BERT 架构的基础上刷新了多个下游任务的 SOTA,也一度成为 BERT 系模型最热门的预训练模型。RoBERTa 的成功也证明了更大的预训练数据、更大的预训练步长的重要意义,这也是 LLM 诞生的基础之一。

ALBERT

ALBERT 成功地以更小规模的参数实现了超越 BERT 的能力, 虽然 ALBERT 所提出的一些改进思想并没有在后续研究中被广泛采用,但其降低模型参数的方法及提出的新预训练任务 SOP 仍然对 NLP 领域提供了重要的参考意义。

优化一:将 Embedding 参数进行分解

Embedding 层的参数矩阵维度为 V∗H,此处的 V 为词表大小 30K,H 即为隐藏层大小 1024,也就是 Embedding 层参数达到了 30M。

ALBERT 对 Embedding 层的参数矩阵进行了分解,让 Embedding 层的输出维度和隐藏层维度解绑,也就是在 Embedding 层的后面加入一个线性矩阵进行维度变换。ALBERT 设置了 Embedding 层的输出为 128,因此在 Embedding 层后面加入了一个 $128*1024$ 的线性矩阵来将 Embedding 层的输出再升维到隐藏层大小。也就是说,Embedding 层的参数从 $V*H$ 降低到了 $V*E + E*H$,当 E 的大小远小于 H 时,该方法对 Embedding 层参数的优化就会很明显。

优化二:跨层进行参数共享

ALBERT 提出,可以让各个 Encoder 层共享模型参数,来减少模型的参数量。

将24个Encoder层变成1个Encoder层, 虽然各层共享权重,但计算时仍然要通过 24次 Encoder Layer 的计算,也就是说训练和推理时的速度相较 BERT 还会更慢, 训练效率也只略微优于 BERT.

优化三:提出 SOP 预训练任务

在传统的 NSP 任务中,正例是由两个连续句子组成的句对,而负例则是从任意两篇文档中抽取出的句对,模型可以较容易地判断正负例,并不能很好地学习深度语义。而 SOP 任务提出的改进是,正例同样由两个连续句子组成,但负例是将这两个的顺序反过来。也就是说,模型不仅要拟合两个句子之间的关系,更要学习其顺序关系,这样就大大提升了预训练的难度。

ALBERT 通过实验证明,SOP 预训练任务对模型效果有显著提升。使用 MLM + SOP 预训练的模型效果优于仅使用 MLM 预训练的模型更优于使用 MLM + NSP 预训练的模型。

Encoder-Decoder PLM

T5

T5 基于 Transformer 架构,包含编码器和解码器两个部分,使用自注意力机制和多头注意力捕捉全局依赖关系,利用相对位置编码处理长序列中的位置信息,并在每层中包含前馈神经网络进一步处理特征。

T5 模型的预训练任务是一个关键的组成部分,它能使模型能够学习到丰富的语言表示,语言表示能力可以在后续的微调过程中被迁移到各种下游任务。训练所使用的数据集是一个大规模的文本数据集,包含了各种各样的文本数据,如维基百科、新闻、书籍等等。其中包括多张输入格式,清洗数据,多任务与训练,微调等.

T5模型的一个核心理念是**“大一统思想”**,即所有的 NLP 任务都可以统一为文本到文本的任务,这一思想在自然语言处理领域具有深远的影响。其设计理念是将所有不同类型的NLP任务(如文本分类、翻译、文本生成、问答等)转换为一个统一的格式:输入和输出都是纯文本。

对于不同的NLP任务,每次输入前都会加上一个任务描述前缀,明确指定当前任务的类型。这不仅帮助模型在预训练阶段学习到不同任务之间的通用特征,也便于在微调阶段迅速适应具体任务。例如,任务前缀可以是“summarize: ”用于摘要任务,或“translate English to German: ”用于翻译任务。

Decoder-Only PLM

GPT

GPT,即 Generative Pre-Training Language Model,GPT 的整体结构和 BERT 是有一些类似的,只是相较于 BERT 的 Encoder,选择使用了 Decoder 来进行模型结构的堆叠。由于 Decoder-Only 结构也天生适用于文本生成任务,所以相较于更贴合 NLU 任务设计的 BERT,GPT 和 T5 的模型设计更契合于 NLG 任务和 Seq2Seq 任务。

**过程: **

输入的 input_ids 首先通过 Embedding 层,再经过 Positional Embedding 进行位置编码。不同于 BERT 选择了可训练的全连接层作为位置编码,GPT 沿用了 Transformer 的经典 Sinusoidal 位置编码,即通过三角函数进行绝对位置编码,通过 Embedding 层和 Positional Embedding 层编码成 hidden_states 之后,就可以进入到解码器(Decoder),第一代 GPT 模型和原始 Transformer 模型类似,选择了 12层解码器层,但是在解码器层的内部,相较于 Transformer 原始 Decoder 层的双注意力层设计,GPT 的 Decoder 层反而更像 Encoder 层一点。由于不再有 Encoder 的编码输入,Decoder 层仅保留了一个带掩码的注意力层,并且将 LayerNorm 层从 Transformer 的注意力层之后提到了注意力层之前。hidden_states 输入 Decoder 层之后,会先进行 LayerNorm,再进行掩码注意力计算,然后经过残差连接和再一次 LayerNorm 进入到 MLP 中并得到最后输出。

由于不存在 Encoder 的编码结果,Decoder 层中的掩码注意力也是自注意力计算。也就是对一个输入的 hidden_states,会通过三个参数矩阵来生成 query、key 和 value,而不再是像 Transformer 中的 Decoder 那样由 Encoder 输出作为 key 和 value。后续的注意力计算过程则和 BERT 类似,只是在计算得到注意力权重之后,通过掩码矩阵来遮蔽了未来 token 的注意力权重,从而限制每一个 token 只能关注到它之前 token 的注意力,来实现掩码自注意力的计算。

CLM

Decoder-Only 的模型结构往往更适合于文本生成任务,因此,Decoder-Only 模型往往选择了最传统也最直接的预训练任务——**因果语言模型,Casual Language Model,**下简称 CLM。

CLM 可以看作 N-gram 语言模型的一个直接扩展。N-gram 语言模型是基于前 N 个 token 来预测下一个 token,CLM 则是基于一个自然语言序列的前面所有 token 来预测下一个 token,通过不断重复该过程来实现目标文本序列的生成。也就是说,CLM 是一个经典的补全形式。

BERT 之所以可以采用预训练+微调的范式取得重大突破,正是因为其选择的 MLM、NSP 可以在海量无监督语料上直接训练——而很明显,CLM 是更直接的预训练任务,其天生和人类书写自然语言文本的习惯相契合,也和下游任务直接匹配,相对于 MLM 任务更加直接,可以在任何自然语言文本上直接应用。因此,CLM 也可以使用海量的自然语言语料进行大规模的预训练。

GPT-1 是 GPT 系列的开山之作,也是第一个使用 Decoder-Only 的预训练模型。

GPT-2 的核心改进是大幅增加了预训练数据集和模型体量。GPT-2 的另一个重大突破是以 zero-**shot(零样本学习)**为主要目标,也就是不对模型进行微调,直接要求模型解决任务。

GPT-3 则是更进一步展示了 OpenAI“力大砖飞”的核心思路,也是 LLM 的开创之作。在 GPT-2 的基础上,OpenAI 进一步增大了模型体量和预训练数据量,整体参数量达 175B,是当之无愧的“大型语言模型”。在模型结构上,基本没有大的改进,只是由于巨大的模型体量使用了稀疏注意力机制来取代传统的注意力机制。之所以说 GPT-3 是 LLM 的开创之作,除去其巨大的体量带来了涌现能力的凸显外,还在于其提出了 few-shot 的重要思想。few-shot 是对 zero-shot 的一个折中,旨在提供给模型少样的示例来教会它完成任务。few-shot 一般会在 prompt(也就是模型的输入)中增加 3~5个示例,来帮助模型理解。

LLaMa

LLaMA 的全称是 Large Language Model Meta AI ,即大语言模型(Meta AI 研发 ).

LLaMA模型的整体结构与GPT系列模型类似,只是在模型规模和预训练数据集上有所不同。

GPT 追求商业级的通用智能与对齐安全,采用私有高质量数据、强化学习(RLHF)和多专家结构(MoE)以实现强泛化和稳健对话能力;而 LLaMA 注重研究开放与效率,依靠公开数据、自回归预训练与直接偏好优化(DPO)等方法,强调轻量化、可复现和开源生态。前者以“对齐人类价值”为核心,后者以“可被人类研究”为目标,因此 GPT 更像黑盒的工程奇迹,LLaMA 则是可验证的科研基石。

GLM

GLM全称是General Language Model,即通用语言模型 ,是由清华大学的KEG实验室和智谱AI公司联合研发的预训练语言模型。核心思路是在传统 CLM 预训练任务基础上,加入 MLM 思想,从而构建一个在 NLG 和 NLU 任务上都具有良好表现的统一模型。

MLM:全称是 Masked Language Model,即掩码语言模型. 适用于编码器中的文本理解

CLM:全称是 Causal Language Model,即自回归语言模型. 适用于解码器中的文本生成

GLM系列模型融合了自回归和自编码器的训练目标,采用了独特的位置编码、旋转位置嵌入(RoPE )等技术,具备文本生成、知识问答、阅读理解、代码生成等多种能力,在多个中文自然语言处理任务评估基准上表现出色。

所谓自编码思想,其实也就是 MLM 的任务学习思路,在输入文本中随机删除连续的 tokens,要求模型学习被删除的 tokens;所谓自回归思想,其实就是传统的 CLM 任务学习思路,也就是要求模型按顺序重建连续 tokens。

GLM 预训练任务更多的优势还是展现在预训练模型时代,迈入 LLM 时代后,针对于超大规模、体量的预训练,CLM 展现出远超 MLM 的优势。虽然从 LLM 的整体发展路径来看,GLM 预训练任务似乎是一个失败的尝试,但通过精巧的设计将 CLM 与 MLM 融合,并第一时间产出了中文开源的原生 LLM,其思路仍然存在较大的借鉴意义。

4 大语言模型

从 NLP 的定义与主要任务出发,介绍了引发 NLP 领域重大变革的核心思想——注意力机制与 Transformer 架构。随着 Transformer 架构的横空出世,NLP 领域逐步进入预训练-微调范式,以 Transformer 为基础的、通过预训练获得强大文本表示能力的预训练语言模型层出不穷,将 NLP 的各种经典任务都推进到了一个新的高度。

随着2022年底 ChatGPT 再一次刷新 NLP 的能力上限,大语言模型(Large Language Model,LLM)开始接替传统的预训练语言模型(Pre-trained Language Model,PLM) 成为 NLP 的主流方向,基于 LLM 的全新研究范式也正在刷新被 BERT 发扬光大的预训练-微调范式,NLP 由此迎来又一次翻天覆地的变化。

LLM是什么

LLM基础介绍

LLM,即 Large Language Model,中文名为大语言模型或大型语言模型,是一种相较传统语言模型参数量更多、在更大规模语料上进行预训练的语言模型。

一般认为,GPT-3(1750亿参数)是 LLM 的开端,基于 GPT-3 通过 **预训练(Pretraining)、监督微调(Supervised Fine-Tuning,SFT)、强化学习与人类反馈(Reinforcement Learning with Human Feedback,RLHF)**三阶段训练得到的 ChatGPT 更是主导了 LLM 时代的到来。

LLM的能力

  1. 涌现能力(Emergent Abilities) — 涌现能力是指同样的模型架构与预训练任务下,某些能力在小型模型中不明显,但在大型模型中特别突出。(可理解为量变产生之变)
  2. 上下文学习(In-context Learning)— 上下文学习是指允许语言模型在提供自然语言指令或多个任务示例的情况下,通过理解上下文并生成相应输出的方式来执行任务,而无需额外的训练或参数更新。
  3. 指令遵循(Instruction Following)— 经过指令微调的 LLM 能够理解并遵循未见过的指令,并根据任务指令执行任务,而无需事先见过具体示例,这展示了其强大的泛化能力。
  4. 逐步推理(Step by Step Reasoning)— LLM 通过采用思维链(Chain-of-Thought,CoT)推理策略,可以利用包含中间推理步骤的提示机制来解决这些任务

LLM的特点

  • 多语言支持 — 多语言、跨语言模型曾经是 NLP 的一个重要研究方向,但 LLM 由于需要使用到海量的语料进行预训练,训练语料往往本身就是多语言的,因此 LLM 天生即具有多语言、跨语言能力,只不过随着训练语料和指令微调的差异,在不同语言上的能力有所差异。

  • 长文本处理 — 由于能够处理多长的上下文文本,在一定程度上决定了模型的部分能力上限,LLM 往往比传统 PLM 更看重长文本处理能力。

    LLM 大部分采用了旋转位置编码(Rotary Positional Encoding,RoPE)(或者同样具有外推能力的 AliBi)作为位置编码,具有一定的长度外推能力,也就是在推理时能够处理显著长于训练长度的文本。

  • 拓展多模态 — LLM 的强大能力也为其带来了跨模态的强大表现。随着 LLM 的不断改进,通过为 LLM 增加额外的参数来进行图像表示,从而利用 LLM 的强大能力打造支持文字、图像双模态的模型,已经是一个成功的方法。

    通过引入 Adapter 层和图像编码器,并针对性地在图文数据上进行有监督微调,模型能够具备不错的图文问答甚至生成能力。

  • 挥之不去的幻觉 — 幻觉,是指 LLM 根据 Prompt 杜撰生成虚假、错误信息的表现。

    目前也有很多研究提供了削弱幻觉的一些方法,如 Prompt 里进行限制、通过 RAG(检索增强生成)来指导生成等,但都还只能一定程度减弱幻觉而无法彻底根除。

如何训练llm

Q: 如何训练一个llm, 训练llm和训练传统预训练模型的区别是什么

Pretrain

Pretrain,即预训练,是训练 LLM 最核心也是工程量最大的第一步。同样是使用海量无监督文本对随机初始化的模型参数进行训练。正如我们在第三章中所见,目前主流的 LLM 几乎都采用了 Decoder-Only 的类 GPT 架构(LLaMA 架构),它们的预训练任务也都沿承了 GPT 模型的经典预训练任务——因果语言模型(Causal Language Model,CLM)。

LLM 的核心特点即在于其具有远超传统预训练模型的参数量,同时在更海量的语料上进行预训练。

分布式训练框架的核心思路是数据并行和模型并行。所谓数据并行,是指训练模型的尺寸可以被单个 GPU 内存容纳,但是由于增大训练的 batch_size 会增大显存开销,无法使用较大的 batch_size 进行训练;同时,训练数据量非常大,使用单张 GPU 训练时长难以接受。

预训练数据的处理与清洗也是 LLM 预训练的一个重要环节。诸多研究证明,预训练数据的质量往往比体量更加重要。预训练数据处理一般包括以下流程:

  1. 文档准备。由于海量预训练语料往往是从互联网上获得,一般需要从爬取的网站来获得自然语言文档。文档准备主要包括 URL 过滤(根据网页 URL 过滤掉有害内容)、文档提取(从 HTML 中提取纯文本)、语言选择(确定提取的文本的语种)等。
  2. 语料过滤。语料过滤的核心目的是去除低质量、无意义、有毒有害的内容,例如乱码、广告等。语料过滤一般有两种方法:基于模型的方法,即通过高质量语料库训练一个文本分类器进行过滤;基于启发式的方法,一般通过人工定义 web 内容的质量指标,计算语料的指标值来进行过滤。
  3. 语料去重。实验表示,大量重复文本会显著影响模型的泛化能力,因此,语料去重即删除训练语料中相似度非常高的文档,也是必不可少的一个步骤。去重一般基于 hash 算法计算数据集内部或跨数据集的文档相似性,将相似性大于指定阈值的文档去除;也可以基于子串在序列级进行精确匹配去重。

SFT

SFT(Supervised Fine-Tuning,有监督微调)。所谓有监督微调,其实就是我们在第三章中讲过的预训练-微调中的微调,稍有区别的是,对于能力有限的传统预训练模型,我们需要针对每一个下游任务单独对其进行微调以训练模型在该任务上的表现。而面对能力强大的 LLM,我们往往不再是在指定下游任务上构造有监督数据进行微调,而是选择训练模型的“通用指令遵循能力”,也就是一般通过指令微调的方式来进行 SFT。

SFT 的主要目标是让模型从多种类型、多种风格的指令中获得泛化的指令遵循能力,也就是能够理解并回复用户的指令。因此,类似于 Pretrain,SFT 的数据质量和数据配比也是决定模型指令遵循能力的重要因素。

首先是指令数据量及覆盖范围。为了使 LLM 能够获得泛化的指令遵循能力,即能够在未训练的指令上表现良好,需要收集大量类别各异的用户指令和对应回复对 LLM 进行训练。一般来说,在单个任务上 500~1000 的训练样本就可以获得不错的微调效果。但是,为了让 LLM 获得泛化的指令遵循能力,在多种任务指令上表现良好,需要在训练数据集中覆盖多种类型的任务指令,同时也需要相对较大的训练数据量,表现良好的开源 LLM SFT 数据量一般在数 B token 左右。

指令微调本质上仍然是对模型进行 CLM 训练,只不过要求模型对指令进行理解和回复而不是简单地预测下一个 token,所以模型预测的结果不仅是 output,而应该是 input + output,只不过 input 部分不参与 loss 的计算,但回复指令本身还是以预测下一个 token 的形式来实现的。

模型是否支持多轮对话,与预训练是没有关系的。事实上,模型的多轮对话能力完全来自于 SFT 阶段。如果要使模型支持多轮对话,我们需要在 SFT 时将训练数据构造成多轮对话格式,让模型能够利用之前的知识来生成回答。

RLHF

模型是否支持多轮对话,与预训练是没有关系的。事实上,模型的多轮对话能力完全来自于 SFT 阶段。如果要使模型支持多轮对话,我们需要在 SFT 时将训练数据构造成多轮对话格式,让模型能够利用之前的知识来生成回答。

RLHF,全称是 Reinforcement Learning from Human Feedback,即人类反馈强化学习,是利用强化学习来训练 LLM 的关键步骤。

从功能上出发,我们可以将 LLM 的训练过程分成预训练与对齐(alignment)两个阶段。预训练的核心作用是赋予模型海量的知识,而所谓对齐,其实就是让模型与人类价值观一致,从而输出人类希望其输出的内容。SFT 是让 LLM 和人类的指令对齐,从而具有指令遵循能力;而 RLHF 则是从更深层次令 LLM 和人类价值观对齐,令其达到安全、有用、无害的核心标准。

RLHF 的思路是,引入强化学习的技术,通过实时的人类反馈令 LLM 能够给出更令人类满意的回复。RLHF 分为两个步骤:训练 RM 和 PPO 训练。

RM,Reward Model,即奖励模型。

PPO,Proximal Policy Optimization,近端策略优化算法,是一种经典的 RL 算法。

5 动手搭建大模型

Meta(原Facebook)于2023年2月发布第一款基于Transformer结构的大型语言模型LLaMA,并于同年7月发布同系列模型LLaMA2。

- 参考链接

datawhalechina/happy-llm: 从零开始的大语言模型原理与实践教程

Licensed under CC BY-NC-SA 4.0