自然語言處理: 第七章GPT的搭建
理論基礎(chǔ)
在以transformer架構(gòu)為框架的大模型遍地開花后,大模型的方向基本分成了三類分別是:
- decoder-only架構(gòu) , 其中以GPT系列為代表
- encoder-only架構(gòu),其中以BERT系列為代表
- encoder-decoder架構(gòu),標(biāo)準(zhǔn)的transformer架構(gòu)以BART和T5為代表
大模型的使用方法如下: 分解成pre-train 和fine-tuning ,其中pre-train是收集大量的高質(zhì)量的文本(或者其他多模態(tài)的輸入)去讓模型擁有文本理解的泛化能力,而fine-tuing則是對應(yīng)各自的下游任務(wù)將pre-train好的model在下游任務(wù)中作微調(diào),從而適應(yīng)不同的任務(wù)頭。
那么為什么基于transformer的架構(gòu)為什么需要可以分成上面的三個分支呢?除了最基本的encoder-decoder架構(gòu),這種能普遍處理好各種任務(wù)外,那么decoder-only 和 encoder-only的區(qū)別在哪?下面以BERT和GPT為代表來分別解釋這兩種架構(gòu)的代表,而其中最主要的區(qū)別就是二者的預(yù)訓(xùn)練目標(biāo)的區(qū)別: 我們由之前Seq2Seq的模型知道,
- BERT全稱是Bidirectional Encoder Representation from Transformers,可以看到它是一個雙向的模型,而編碼器的作用主要是將輸入的全部文本信息壓縮至一個定長的向量,然后再給下游任務(wù)作fine_tuning,所以BERT這種Encoder-only的架構(gòu)的預(yù)訓(xùn)練任務(wù)更像是一個填空題,以下圖的例子為例,BERT的任務(wù)就是給一個完整的文本, 一(二)三四五,上山(打)老虎,需要去預(yù)測括號里的內(nèi)容,而且BERT本身是一個雙向的網(wǎng)絡(luò),所以在預(yù)測括號里的內(nèi)容時候,他是已經(jīng)看過全文的,所以這種encoder-only的架構(gòu)它更具有推理和理解上下文的能力,所以用來做文本分類,關(guān)系抽取與命名實體識別的任務(wù)有更好的效果,這種預(yù)訓(xùn)練的模式叫做MLM(masked language model)。
- 而GPT作為decoder-only,它擁有更好的文本生成能力,它的預(yù)訓(xùn)練任務(wù)就更加貼合我們傳統(tǒng)理解的NLP任務(wù),同樣如下圖的例子,GPT的預(yù)訓(xùn)練過程是老虎沒打到,(抓到小松鼠),通過上文去預(yù)測下文,所以它是一個單向的,也就是更像一個問答題,所以它具有更好的文本生成能力所以就更適合用來作聊天機器人。
因此,GPT的生成式預(yù)訓(xùn)練如,內(nèi)容如下: 輸入是上文,輸出是下文,并且是單向的decoder結(jié)構(gòu),所以相比于傳統(tǒng)的transformer結(jié)構(gòu),GPT結(jié)構(gòu)更加的輕量了。除此之外還需要注意的是,在訓(xùn)練階段由于保證運行效率,直接就由文本在前端加一個 <sos>,
但是在inference階段需要沒生成一個字,連同之前的上文一起再輸入給下一次作為輸入。
因此這種decorder-only的結(jié)構(gòu),除了去除了encoder結(jié)構(gòu)之外,自身的decoder基本跟transfor的decoder結(jié)構(gòu)一致,但是去掉了encoder-decoder的self-attention這部分,transformer基本的結(jié)構(gòu)可以參考上文第六章Transformer- 現(xiàn)代大模型的基石: 分解的結(jié)構(gòu)如下圖:
還有一種解釋是是從對模型的期望來解釋BERT 和GPT的區(qū)別,根據(jù)前文我們可以知道BERT的預(yù)訓(xùn)練的模式是作填空題,所以它本身并不具備生成文字內(nèi)容的能力,但是它具有更好的理解上下文的能力,所以對應(yīng)不同的任務(wù),只需要BERT + Head(任務(wù)頭) 就可以針對不同的任務(wù),所以這就導(dǎo)致了BERT更適合成為專才。而GPT由于預(yù)訓(xùn)練是做的問答題,而其實所有的NLP任務(wù)都可以看成是問答的任務(wù),比如說機器翻譯,你只要給GPT下一個prompt 請幫我將下列句子翻譯成英文
這樣GPT就可以翻譯成英文了。對于其他任務(wù)也是一樣的,只需要下對應(yīng)的prompt,所以GPT是更像一個通才,無需加單獨的任務(wù)頭,便可以完成不同的任務(wù)。
代碼實現(xiàn)
1. 多頭注意力
class ScaledDotProductAttention(nn.Module):
def __init__(self):
super(ScaledDotProductAttention, self).__init__()
def forward(self, Q, K, V, attn_mask):
# Q K V [batch_size, n_heads, len_q/k/v, dim_q=k/v] (dim_q=dim_k)
# 計算注意力分?jǐn)?shù)(原始權(quán)重)[batch_size,n_heads,len_q,len_k]
scores = torch.matmul(Q, K.transpose(-1, -2)) / np.sqrt(d_k)
# 使用注意力掩碼,將attn_mask中值為1的位置的權(quán)重替換為極小值
# attn_mask [batch_size,n_heads,len_q,len_k],形狀和scores相同
scores.masked_fill_(attn_mask.to(torch.bool), -1e9)
# 對注意力分?jǐn)?shù)進(jìn)行softmax
weights = nn.Softmax(dim=-1)(scores)
# 計算上下文向量(也就是注意力的輸出), 是上下文信息的緊湊表示
context = torch.matmul(weights, V)
return context, weights # 返回上下文向量和注意力分?jǐn)?shù)
# 定義多頭注意力類
d_embedding = 512 # Embedding Size
n_heads = 8 # number of heads in Multi-Head Attention
batch_size = 3 # 每一批數(shù)據(jù)量
class MultiHeadAttention(nn.Module):
def __init__(self):
super(MultiHeadAttention, self).__init__()
self.W_Q = nn.Linear(d_embedding, d_k * n_heads) # Q的線性變換層
self.W_K = nn.Linear(d_embedding, d_k * n_heads) # K的線性變換層
self.W_V = nn.Linear(d_embedding, d_v * n_heads) # V的線性變換層
self.linear = nn.Linear(n_heads * d_v, d_embedding)
self.layer_norm = nn.LayerNorm(d_embedding)
def forward(self, Q, K, V, attn_mask):
# Q K V [batch_size,len_q/k/v,embedding_dim]
residual, batch_size = Q, Q.size(0) # 保留殘差連接
# 將輸入進(jìn)行線性變換和重塑,以便后續(xù)處理
# q_s k_s v_s: [batch_size,n_heads.,len_q/k/v,d_q=k/v]
q_s = self.W_Q(Q).view(batch_size, -1, n_heads, d_k).transpose(1,2)
k_s = self.W_K(K).view(batch_size, -1, n_heads, d_k).transpose(1,2)
v_s = self.W_V(V).view(batch_size, -1, n_heads, d_v).transpose(1,2)
# 將注意力掩碼復(fù)制到多頭 [batch_size,n_heads,len_q,len_k]
attn_mask = attn_mask.unsqueeze(1).repeat(1, n_heads, 1, 1)
# 使用縮放點積注意力計算上下文和注意力權(quán)重
context, weights = ScaledDotProductAttention()(q_s, k_s, v_s, attn_mask)
# 重塑上下文向量并進(jìn)行線性變換,[batch_size,len_q,n_heads * dim_v]
context = context.transpose(1, 2).contiguous().view(batch_size, -1, n_heads * d_v)
output = self.linear(context)
# 與輸入(Q)進(jìn)行殘差鏈接,并進(jìn)行層歸一化后輸出[batch_size, len_q, embedding_dim]
output = self.layer_norm(output + residual)
return output, weights # 返回層歸一化的輸出和注意力權(quán)重
2. 逐位置前饋網(wǎng)絡(luò)
# 定義逐位置前向傳播網(wǎng)絡(luò)類
class PoswiseFeedForwardNet(nn.Module):
def __init__(self):
super(PoswiseFeedForwardNet, self).__init__()
# 定義一維卷積層1,用于將輸入映射到更高維度
self.conv1 = nn.Conv1d(in_channels=d_embedding, out_channels=2048, kernel_size=1)
# 定義一維卷積層2,用于將輸入映射回原始維度
self.conv2 = nn.Conv1d(in_channels=2048, out_channels=d_embedding, kernel_size=1)
# 定義層歸一化
self.layer_norm = nn.LayerNorm(d_embedding)
def forward(self, inputs):
# inputs: [batch_size, len_q, embedding_dim]
residual = inputs # 保留殘差連接
# 在卷積層1后使用ReLU激活函數(shù)
output = nn.ReLU()(self.conv1(inputs.transpose(1, 2)))
# 使用卷積層2進(jìn)行降維
output = self.conv2(output).transpose(1, 2)
# 與輸入進(jìn)行殘差鏈接,并進(jìn)行層歸一化,[batch_size, len_q, embedding_dim]
output = self.layer_norm(output + residual)
return output # 返回層歸一化后的輸出加上殘差連接的結(jié)果
3. 正弦位置編碼表
def get_sin_enc_table(n_position, embedding_dim):
# 根據(jù)位置和維度信息,初始化正弦位置編碼表
sinusoid_table = np.zeros((n_position, embedding_dim))
# 遍歷所有位置和維度,計算角度值
for pos_i in range(n_position):
for hid_j in range(embedding_dim):
angle = pos_i / np.power(10000, 2 * (hid_j // 2) / embedding_dim)
sinusoid_table[pos_i, hid_j] = angle
# 計算正弦和余弦值
sinusoid_table[:, 0::2] = np.sin(sinusoid_table[:, 0::2]) # dim 2i 偶數(shù)維
sinusoid_table[:, 1::2] = np.cos(sinusoid_table[:, 1::2]) # dim 2i+1 奇數(shù)維
return torch.FloatTensor(sinusoid_table)
4. 填充位置掩碼
# 生成填充注意力掩碼的函數(shù),用于在多頭自注意力計算中忽略填充部分
def get_attn_pad_mask(seq_q, seq_k):
batch_size, len_q = seq_q.size()
batch_size, len_k = seq_k.size()
# 生成布爾類型張量[batch_size,1,len_k(=len_q)]
pad_attn_mask = seq_k.data.eq(0).unsqueeze(1) #<PAD> Token的編碼值為0
# 變形為何注意力分?jǐn)?shù)相同形狀的張量 [batch_size,len_q,len_k]
pad_attn_mask = pad_attn_mask.expand(batch_size, len_q, len_k)
return pad_attn_mask # 形狀[batch_size,len_q,len_k]
5. 后續(xù)位置掩碼
# 生成后續(xù)注意力掩碼的函數(shù),用于在多頭自注意力計算中忽略未來信息
def get_attn_subsequent_mask(seq):
# 獲取輸入序列的形狀 [batch_size, seq_len(len_q), seq_len(len_k)]
attn_shape = [seq.size(0), seq.size(1), seq.size(1)]
# 使用numpy創(chuàng)建一個上三角矩陣(triu = triangle upper)
subsequent_mask = np.triu(np.ones(attn_shape), k=1)
# 將numpy數(shù)組轉(zhuǎn)換為PyTorch張量,并將數(shù)據(jù)類型設(shè)置為byte(布爾值)
subsequent_mask = torch.from_numpy(subsequent_mask).byte()
return subsequent_mask # [batch_size, seq_len(len_q), seq_len(len_k)]
6. 解碼器
# 構(gòu)建解碼器層
class DecoderLayer(nn.Module):
def __init__(self):
super(DecoderLayer, self).__init__()
self.self_attn = MultiHeadAttention() # 多頭自注意力層
self.feed_forward = PoswiseFeedForwardNet() # 位置前饋神經(jīng)網(wǎng)絡(luò)層
self.norm1 = nn.LayerNorm(d_embedding) # 第一個層歸一化
self.norm2 = nn.LayerNorm(d_embedding) # 第二個層歸一化
def forward(self, dec_inputs, attn_mask=None):
# 使用多頭自注意力處理輸入
attn_output, _ = self.self_attn(dec_inputs, dec_inputs, dec_inputs, attn_mask)
# 將注意力輸出與輸入相加并進(jìn)行第一個層歸一化
norm1_outputs = self.norm1(dec_inputs + attn_output)
# 將歸一化后的輸出輸入到位置前饋神經(jīng)網(wǎng)絡(luò)
ff_outputs = self.feed_forward(norm1_outputs)
# 將前饋神經(jīng)網(wǎng)絡(luò)輸出與第一次歸一化后的輸出相加并進(jìn)行第二個層歸一化
dec_outputs = self.norm2(norm1_outputs + ff_outputs)
return dec_outputs
# 構(gòu)建解碼器
n_layers = 6 # 設(shè)置Encoder/Decoder的層數(shù)
class Decoder(nn.Module):
def __init__(self, corpus):
super(Decoder, self).__init__()
self.src_emb = nn.Embedding(corpus.vocab_size, d_embedding) # 詞嵌入層(參數(shù)為詞典維度)
self.pos_emb = nn.Embedding(corpus.seq_len, d_embedding) # 位置編碼層(參數(shù)為序列長度)
self.layers = nn.ModuleList([DecoderLayer() for _ in range(n_layers)]) # 初始化N個解碼器層
def forward(self, dec_inputs):
positions = torch.arange(len(dec_inputs), device=dec_inputs.device).unsqueeze(-1) # 位置信息
inputs_embedding = self.src_emb(dec_inputs) + self.pos_emb(positions) # 詞嵌入與位置編碼相加
attn_mask = get_attn_subsequent_mask(inputs_embedding).to(dec_inputs.device) # 生成自注意力掩碼
dec_outputs = inputs_embedding # 初始化解碼器輸入,這是第一層解碼器層的輸入
for layer in self.layers:
# 每個解碼器層接收前一層的輸出作為輸入,并生成新的輸出
# 對于第一層解碼器層,其輸入是dec_outputs,即詞嵌入和位置編碼的和
# 對于后續(xù)的解碼器層,其輸入是前一層解碼器層的輸出
dec_outputs = layer(dec_outputs, attn_mask) # 將輸入數(shù)據(jù)傳遞給解碼器層
return dec_outputs # 返回最后一個解碼器層的輸出,作為整個解碼器的輸出
7. GPT
class GPT(nn.Module):
def __init__(self, corpus):
super(GPT, self).__init__()
self.corpus = corpus
self.decoder = Decoder(corpus) # 解碼器,用于學(xué)習(xí)文本生成能力
self.projection = nn.Linear(d_embedding, corpus.vocab_size) # 全連接層,輸出預(yù)測結(jié)果
def forward(self, dec_inputs):
dec_outputs = self.decoder(dec_inputs) # 將輸入數(shù)據(jù)傳遞給解碼器
logits = self.projection(dec_outputs) # 傳遞給全連接層以生成預(yù)測
return logits #返回預(yù)測結(jié)果
def decode(self, input_str, strategy='greedy', **kwargs):
if strategy == 'greedy': # 貪心解碼函數(shù)
return generate_text_greedy_search(self, input_str, **kwargs)
elif strategy == 'beam_search': # 集束解碼函數(shù)
return generate_text_beam_search(self, input_str, **kwargs)
else:
raise ValueError(f"Unknown decoding strategy: {strategy}")
8. Greedy_search & Beam_search
def generate_text_beam_search(model, input_str, max_len=5, beam_width=5, repetition_penalty=1.2):
# 將模型設(shè)置為評估(測試)模式,關(guān)閉dropout和batch normalization等訓(xùn)練相關(guān)的層
model.eval()
# 讓NLTK工具幫忙分一下詞
input_str = word_tokenize(input_str)
# 將輸入字符串中的每個token轉(zhuǎn)換為其在詞匯表中的索引, 如果輸入的詞不再詞表里面,就忽略這個詞
input_tokens = [model.corpus.vocab[token] for token in input_str if token in model.corpus.vocab]
# 檢查輸入的有意義的詞匯長度是否為0
if len(input_tokens) == 0:
return
# 創(chuàng)建一個列表,用于存儲候選序列,初始候選序列只包含輸入tokens
candidates = [(input_tokens, 0.0)]
# 創(chuàng)建一個列表,用于存儲所有生成的序列及其得分
final_results = []
# 禁用梯度計算,以節(jié)省內(nèi)存并加速測試過程
with torch.no_grad():
# 生成最多max_len個tokens
for _ in range(max_len):
# 創(chuàng)建一個新的候選列表,用于存儲當(dāng)前時間步生成的候選序列
new_candidates = []
# 遍歷當(dāng)前候選序列
for candidate, candidate_score in candidates:
# 將當(dāng)前候選序列轉(zhuǎn)換為torch張量并將其傳遞給模型
device = "cuda" if torch.cuda.is_available() else "cpu"
inputs = torch.LongTensor(candidate).unsqueeze(0).to(device)
outputs = model(inputs)
# 只關(guān)心最后一個時間步(即最新生成的token)的logits
logits = outputs[:, -1, :]
# 應(yīng)用重復(fù)懲罰:為已經(jīng)生成的詞匯應(yīng)用懲罰,降低它們再次被選擇的概率
for token in set(candidate):
logits[0, token] /= repetition_penalty
# 將<pad>標(biāo)記的得分設(shè)置為一個很大的負(fù)數(shù),以避免選擇它
logits[0, model.corpus.vocab["<pad>"]] = -1e9
# 找到具有最高分?jǐn)?shù)的前beam_width個tokens
scores, next_tokens = torch.topk(logits, beam_width, dim=-1)
# 遍歷生成的tokens及其得分
for score, next_token in zip(scores.squeeze(), next_tokens.squeeze()):
# 將生成的token添加到當(dāng)前候選序列
new_candidate = candidate + [next_token.item()]
# 更新候選序列得分
new_score = candidate_score - score.item()
# 如果生成的token是EOS(結(jié)束符),將其添加到最終結(jié)果中
if next_token.item() == model.corpus.vocab["<eos>"]:
final_results.append((new_candidate, new_score))
else:
# 將新生成的候選序列添加到新候選列表中
new_candidates.append((new_candidate, new_score))
# 從新候選列表中選擇得分最高的beam_width個序列
candidates = sorted(new_candidates, key=lambda x: x[1], reverse=True)[:beam_width]
# 選擇得分最高的候選序列,如果final_results為空,選擇當(dāng)前得分最高的候選序列
if final_results:
best_candidate, _ = sorted(final_results, key=lambda x: x[1])[0]
else:
best_candidate, _ = sorted(candidates, key=lambda x: x[1])[0]
# 將輸出 token 轉(zhuǎn)換回文本字符串
output_str = " ".join([model.corpus.idx2word[token] for token in best_candidate])
return output_str
def generate_text_greedy_search(model, input_str, max_len=5):
# 將模型設(shè)置為評估(測試)模式,關(guān)閉dropout和batch normalization等訓(xùn)練相關(guān)的層
model.eval()
# 使用NLTK工具進(jìn)行詞匯切分
input_str = word_tokenize(input_str)
# 將輸入字符串中的每個token轉(zhuǎn)換為其在詞匯表中的索引, 如果輸入的詞不在詞表里面,就忽略這個詞
input_tokens = [model.corpus.vocab[token] for token in input_str if token in model.corpus.vocab]
# 檢查輸入的有意義的詞匯長度是否為0
if len(input_tokens) == 0:
return
# 創(chuàng)建一個列表,用于存儲生成的詞匯
output_tokens = input_tokens
# 禁用梯度計算,以節(jié)省內(nèi)存并加速測試過程
with torch.no_grad():
# 生成最多max_len個tokens
for _ in range(max_len):
# 將當(dāng)前生成的tokens轉(zhuǎn)換為torch張量并將其傳遞給模型
device = "cuda" if torch.cuda.is_available() else "cpu"
inputs = torch.LongTensor(output_tokens).unsqueeze(0).to(device)
outputs = model(inputs)
# 只關(guān)心最后一個時間步(即最新生成的token)的logits
logits = outputs[:, -1, :]
# 找到具有最高分?jǐn)?shù)的token
_, next_token = torch.topk(logits, 1, dim=-1)
# 如果生成的token是EOS(結(jié)束符),則停止生成
if next_token.item() == model.corpus.vocab["<eos>"]:
break
# 否則,將生成的token添加到生成的詞匯列表中
output_tokens.append(next_token.item())
# 將輸出 tokens 轉(zhuǎn)換回文本字符串
output_str = " ".join([model.corpus.idx2word[token] for token in output_tokens])
return output_str
結(jié)果
本次實驗設(shè)置了三個對照組,分別是baseline(N_head = 8 , n_layer = 6), N_head = 32 , n_layer = 18,可以看到訓(xùn)練10000個step之后得loss分別如下圖:
從收斂程度上來看,18層layer得transformer 完全沒有收斂,這個可能是因為深度神經(jīng)網(wǎng)絡(luò)的梯度消失,所以我們設(shè)置的網(wǎng)絡(luò)如果沒有殘差鏈接的話,盡量不要太深。然后再看多頭,可以看到頭的數(shù)量好像也不是越多越好,但是其實二者都收斂了,具體結(jié)果我們可以結(jié)合一下inference的結(jié)果看看。
可以看到兩種解碼得方式,greedy_search在大部分時候由于設(shè)置了懲罰項所以現(xiàn)在大部分時候是兩個單詞無限循環(huán),相比之下beam_search得結(jié)果就好得多,更像一句話。
其次對比一下三個對照組得結(jié)果,正如loss的結(jié)果一樣,深層次GPT架構(gòu)無論是beam_search還是greedy_search翻譯的結(jié)果都非常的差,出現(xiàn)了很多標(biāo)點,這應(yīng)該就是沒有收斂的結(jié)果。然后對比下不同的head數(shù)量,這里看上去也是n_head越少的效果越好。
文章來源:http://www.zghlxwxcb.cn/news/detail-636628.html
文章來源地址http://www.zghlxwxcb.cn/news/detail-636628.html
到了這里,關(guān)于自然語言處理: 第七章GPT的搭建的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!