【ChatGPT前世今生】前置知识Seq2Seq入门理解
- 1、环境准备与依赖包安装
- 2、数据集准备
- 3、数据集预处理与读取
- 4、定义Seq2Seq模型的基础类
- 5、预处理训练数据集
- 6、定义训练过程
- 7、定义验证过程
- 8、执行训练与验证过程
- 9、展示模型的结果,进行进一步分析
最近一段时间,ChatGPT非常热门,但是,要理解ChatGPT的工作原理,得追溯至Transformer、Seq2Seq、Word2Vec这些早期的自然语言处理研究成果,本文主要回顾Seq2Seq。
Seq2Seq,其英文原称就是Sequence to Sequence,翻译过来就是“序列到序列”,其文献可以追溯至发表在NIPS 2014的《Sequence to Sequence Learning with Neural Networks》,该文章的谷歌学术引用已经多于2.1万次,可见其在NLP领域的重要性,此后近9年的研究工作,可以说都或多或少受到了该论文思维的影响。
我们现在回过头来看,其实Seq2Seq的思想真的很直观,就是把语言生成任务建模为序列到序列的任务,输入是一个序列,输出也是一个序列。其在提出之初,主要是用于翻译任务,后来广泛用到对话生成、摘要生成等文本生成任务当中。这种结构的显著特点是:输入序列和输出序列的长度是可变的,且没有直接的一一对应的关系,如上图所示。
下面将以翻译任务,简单介绍Seq2Seq的工作原理。
1、环境准备与依赖包安装
参考博客:
- 【Python学习】纯终端命令开始你的Anaconda安装与Python环境管理
- 【Python学习】Windows10开始你的Anaconda安装与Python环境管理
- 【Python编程】服务器长期开启jupyter notebook远程连接服务
- Pytorch安装指南
在conda创建的python环境当中安装pytorch以及wget:
conda install pytorch torchvision torchaudio cpuonly -c pytorch
pip install wget
导入re、torch等依赖包
%matplotlib inline
from __future__ import unicode_literals, print_function, division
from io import open
import unicodedata
import string
import re
import random
import torch
import torch.nn as nn
from torch import optim
import torch.nn.functional as F
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
/home/phd-chen.yirong/anaconda3/envs/py38_llm/lib/python3.8/site-packages/tqdm/auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html
from .autonotebook import tqdm as notebook_tqdm
2、数据集准备
通过以下链接下载数据集并且解压到与本文件相同的路径
https://download.pytorch.org/tutorial/data.zip
# 下载数据集文件
import wget
data_url = "https://download.pytorch.org/tutorial/data.zip"
wget.download(data_url, "./data.zip")
100% [..........................................................................] 2882130 / 2882130
'./data (1).zip'
解压数据文件:
# 解压数据集文件
import zipfile
def unzip_file(zip_src, dst_dir):
# zip_src源文件夹
# dst_dir目标文件夹
r = zipfile.is_zipfile(zip_src)
if r:
fz = zipfile.ZipFile(zip_src, 'r')
for file in fz.namelist():
fz.extract(file, dst_dir)
else:
print('This is not zip')
zip_data_path = "./data.zip" # 在这里修改需要解压的文件夹
unzip_file(zip_src=zip_data_path, dst_dir="./")
3、数据集预处理与读取
众所周知,NLP任务是需要有一个词表的,我们需要将句子转换为词序列,再将词序列映射为向量,然后用这个词向量进行各种任务运算,词向量的工作原理会在另一篇博客展开讨论。我们现在需要知道的是:
- 1、给定一个语料库(指由若干个句子序列组成的数据集,可以是翻译数据集,可以是对话数据集等等),我们首先要基于该数据集进行分词,然后进行词频统计,得到一个词表(词及其编号),如果是翻译任务,则有两个不同的词表分别对应待翻译语言以及翻译后的语言,通常如下所示:
SOS: 0
EOS: 1
a: 2
...
这个词表,实际上就是我们在执行NLP任务时的搜索空间,每执行一步生成,实际上就是在词表当中检索最可能生成的一个词。特别地,词表可能很大,通常会由几万个单词组成,因此其对应的分类器也会非常大,每次需要从数万个单词当中挑出最可能的一个或若干个单词作为输出。
- 2、然后,针对每一个句子,需要先根据词表把其转换为由编号组成的序列,类似于
[0, 8, 20, 100, ..., 1]
的形式,然后再根据编号获得每个词对应的向量表示,将向量序列输入到Seq2Seq模型当中,进行模型的运算推理,并返回推理后的结果,例如某一步推理得到的预测向量,根据这个向量检索最接近的词向量,用该词向量对应的单词作为输出。
以下代码为词表类的实现代码,其中类的成员word2index、index2word分别可以通过单词查询对应的编号,以及通过编号查询对应的单词,那怕是ChatGPT,实际上也是需要进行这两步的。
SOS_token = 0
EOS_token = 1
class Lang:
def __init__(self, name):
self.name = name
self.word2index = {}
self.word2count = {}
self.index2word = {0: "SOS", 1: "EOS"}
self.n_words = 2 # Count SOS and EOS
def addSentence(self, sentence):
for word in sentence.split(' '):
self.addWord(word)
def addWord(self, word):
if word not in self.word2index:
self.word2index[word] = self.n_words
self.word2count[word] = 1
self.index2word[self.n_words] = word
self.n_words += 1
else:
self.word2count[word] += 1
以下过程是一些正则化处理过程,实际上就是进行数据清洗,简化任务
# Turn a Unicode string to plain ASCII, thanks to
# https://stackoverflow.com/a/518232/2809427
def unicodeToAscii(s):
return ''.join(
c for c in unicodedata.normalize('NFD', s)
if unicodedata.category(c) != 'Mn'
)
# Lowercase, trim, and remove non-letter characters
def normalizeString(s):
s = unicodeToAscii(s.lower().strip())
s = re.sub(r"([.!?])", r" \1", s)
s = re.sub(r"[^a-zA-Z.!?]+", r" ", s)
return s
通过readLangs函数实现词表的构建,其包括:读入语料库/数据集,分词,更新词表。
def readLangs(lang1, lang2, reverse=False):
print("Reading lines...")
# Read the file and split into lines
lines = open('data/%s-%s.txt' % (lang1, lang2), encoding='utf-8').\
read().strip().split('\n')
# Split every line into pairs and normalize
pairs = [[normalizeString(s) for s in l.split('\t')] for l in lines]
# Reverse pairs, make Lang instances
if reverse:
pairs = [list(reversed(p)) for p in pairs]
input_lang = Lang(lang2)
output_lang = Lang(lang1)
else:
input_lang = Lang(lang1)
output_lang = Lang(lang2)
return input_lang, output_lang, pairs
MAX_LENGTH = 10
eng_prefixes = (
"i am ", "i m ",
"he is", "he s ",
"she is", "she s ",
"you are", "you re ",
"we are", "we re ",
"they are", "they re "
)
def filterPair(p):
return len(p[0].split(' ')) < MAX_LENGTH and \
len(p[1].split(' ')) < MAX_LENGTH and \
p[1].startswith(eng_prefixes)
def filterPairs(pairs):
return [pair for pair in pairs if filterPair(pair)]
def prepareData(lang1, lang2, reverse=False):
input_lang, output_lang, pairs = readLangs(lang1, lang2, reverse)
print("Read %s sentence pairs" % len(pairs))
pairs = filterPairs(pairs)
print("Trimmed to %s sentence pairs" % len(pairs))
print("Counting words...")
for pair in pairs:
input_lang.addSentence(pair[0])
output_lang.addSentence(pair[1])
print("Counted words:")
print(input_lang.name, input_lang.n_words)
print(output_lang.name, output_lang.n_words)
return input_lang, output_lang, pairs
input_lang, output_lang, pairs = prepareData('eng', 'fra', True)
print(random.choice(pairs))
Reading lines...
Read 135842 sentence pairs
Trimmed to 10599 sentence pairs
Counting words...
Counted words:
fra 4345
eng 2803
['je suis musicienne .', 'i m a musician .']
4、定义Seq2Seq模型的基础类
广义来说,一切序列到序列的任务,都可以通过模型Seq2Seq来实现,但是具体到最初的模型,Seq2Seq的具体实现是基于RNN算法/模型的。
更通俗一点,Seq2Seq框架通常包含:处理输入序列的模块(通常称为编码器,Encoder),生成输出序列的模块(通常称为解码器,Decoder)。
我们在这里不赘述RNN算法的具体计算步骤,感兴趣的同学可以百度一下。下面的模型的实现主要采用了RNN的一种变体:GRU,但它仍然属于RNN算法的一种。
本文实现的Seq2Seq主要包括:EncoderRNN模块以及AttnDecoderRNN,其中EncoderRNN由词嵌入层(self.embedding)和一层GRU(self.gru)组成;AttnDecoderRNN由词嵌入层(self.embedding)、注意力计算层(self.attn、self.attn_combine)、GRU层(self.gru)以及输出的词分类层(self.out)组成。
本文通过一个可训练的线性层来实现编码器的输出和解码器的输入之间的注意力权重计算,同时也通过另一个可训练的线性层来实现将注意力加权到解码器的输入,得到最终的解码器的输入。这个实际上相当于注意力权重是可以通过训练得到的,解码器最终的输入由编码器的输出、注意力权重,以及对这两部分的加权的数值共同决定。
解码器最终的输入
=
[
编码器的输出
,
注意力权
重
可训练
]
∗
[
加权数值矩阵
]
可训练
解码器最终的输入=[编码器的输出, 注意力权重_{可训练}]*[加权数值矩阵]_{可训练}
解码器最终的输入=[编码器的输出,注意力权重可训练]∗[加权数值矩阵]可训练
当然,给输入序列加注意力有很多种实现方式,并不局限于以上方法
# 编码器:处理输入序列
class EncoderRNN(nn.Module):
def __init__(self, input_size, hidden_size):
super(EncoderRNN, self).__init__()
self.hidden_size = hidden_size # 词向量的维度
self.embedding = nn.Embedding(input_size, hidden_size) # input_size表示输入序列对应的语言的词表的大小
self.gru = nn.GRU(hidden_size, hidden_size)
def forward(self, input, hidden):
embedded = self.embedding(input).view(1, 1, -1)
output = embedded
output, hidden = self.gru(output, hidden)
return output, hidden
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
# 解码器:解码获得输出序列
class DecoderRNN(nn.Module):
def __init__(self, hidden_size, output_size):
super(DecoderRNN, self).__init__()
self.hidden_size = hidden_size # 词向量的维度
self.embedding = nn.Embedding(output_size, hidden_size) # output_size表示输出序列对应的语言的词表的大小
self.gru = nn.GRU(hidden_size, hidden_size)
self.out = nn.Linear(hidden_size, output_size)
self.softmax = nn.LogSoftmax(dim=1)
def forward(self, input, hidden):
output = self.embedding(input).view(1, 1, -1)
output = F.relu(output)
output, hidden = self.gru(output, hidden)
output = self.softmax(self.out(output[0]))
return output, hidden
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
# 加注意力的解码器
class AttnDecoderRNN(nn.Module):
def __init__(self, hidden_size, output_size, dropout_p=0.1, max_length=MAX_LENGTH):
super(AttnDecoderRNN, self).__init__()
self.hidden_size = hidden_size
self.output_size = output_size
self.dropout_p = dropout_p
self.max_length = max_length
self.embedding = nn.Embedding(self.output_size, self.hidden_size)
self.attn = nn.Linear(self.hidden_size * 2, self.max_length)
self.attn_combine = nn.Linear(self.hidden_size * 2, self.hidden_size)
self.dropout = nn.Dropout(self.dropout_p)
self.gru = nn.GRU(self.hidden_size, self.hidden_size)
self.out = nn.Linear(self.hidden_size, self.output_size)
def forward(self, input, hidden, encoder_outputs):
embedded = self.embedding(input).view(1, 1, -1)
embedded = self.dropout(embedded)
attn_weights = F.softmax(
self.attn(torch.cat((embedded[0], hidden[0]), 1)), dim=1)
attn_applied = torch.bmm(attn_weights.unsqueeze(0),
encoder_outputs.unsqueeze(0))
output = torch.cat((embedded[0], attn_applied[0]), 1)
output = self.attn_combine(output).unsqueeze(0)
output = F.relu(output)
output, hidden = self.gru(output, hidden)
output = F.log_softmax(self.out(output[0]), dim=1)
return output, hidden, attn_weights
def initHidden(self):
return torch.zeros(1, 1, self.hidden_size, device=device)
5、预处理训练数据集
定义原始输入文本的处理函数,将句子(字符串)转换为模型可以理解的数字序列(张量)。
def indexesFromSentence(lang, sentence):
return [lang.word2index[word] for word in sentence.split(' ')]
def tensorFromSentence(lang, sentence):
indexes = indexesFromSentence(lang, sentence)
indexes.append(EOS_token)
return torch.tensor(indexes, dtype=torch.long, device=device).view(-1, 1)
def tensorsFromPair(pair):
input_tensor = tensorFromSentence(input_lang, pair[0])
target_tensor = tensorFromSentence(output_lang, pair[1])
return (input_tensor, target_tensor)
6、定义训练过程
一个神经网络模型的训练过程,实际上就是:
- 不断读取数据样本,将数据样本转换为模型所需要的输入格式;
- 步骤1:模型根据输入的样本计算输出值;
- 步骤2:调用损失函数(某种计算距离的公式,例如标准差)计算模型输出值和真实值的差距(俗称损失值,loss);
- 步骤3:利用损失值驱动优化算法(优化器)去对模型的所有参数进行更新,重复以上步骤若干次。
总的来说,每一步训练过程需要:样本(模型的输入和参考答案)、模型、优化器、损失函数。
经过训练,模型的参数会不断更新,最终得到一个训练后的模型,这就是神经网络模型的训练过程。更加具体而言,其中的损失函数、优化器又会有很多具体的实现,下面使用的损失函数是通过criterion参数传入的,模型分为编码器和解码器两部分,分别通过encoder、decoder传入,编码器和解码器的优化器通过encoder_optimizer、decoder_optimizer传入。
teacher_forcing_ratio = 0.5
def train(input_tensor, target_tensor, encoder, decoder, encoder_optimizer, decoder_optimizer, criterion, max_length=MAX_LENGTH):
encoder_hidden = encoder.initHidden()
encoder_optimizer.zero_grad()
decoder_optimizer.zero_grad()
input_length = input_tensor.size(0)
target_length = target_tensor.size(0)
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)
loss = 0
for ei in range(input_length):
encoder_output, encoder_hidden = encoder(
input_tensor[ei], encoder_hidden)
encoder_outputs[ei] = encoder_output[0, 0]
decoder_input = torch.tensor([[SOS_token]], device=device)
decoder_hidden = encoder_hidden
use_teacher_forcing = True if random.random() < teacher_forcing_ratio else False
if use_teacher_forcing:
# Teacher forcing: Feed the target as the next input
for di in range(target_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
loss += criterion(decoder_output, target_tensor[di])
decoder_input = target_tensor[di] # Teacher forcing
else:
# Without teacher forcing: use its own predictions as the next input
for di in range(target_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
topv, topi = decoder_output.topk(1)
decoder_input = topi.squeeze().detach() # detach from history as input
loss += criterion(decoder_output, target_tensor[di])
if decoder_input.item() == EOS_token:
break
loss.backward()
encoder_optimizer.step()
decoder_optimizer.step()
return loss.item() / target_length
训练过程的辅助函数:
import time
import math
def asMinutes(s):
m = math.floor(s / 60)
s -= m * 60
return '%dm %ds' % (m, s)
def timeSince(since, percent):
now = time.time()
s = now - since
es = s / (percent)
rs = es - s
return '%s (- %s)' % (asMinutes(s), asMinutes(rs))
定义训练过程的显示函数:
def trainIters(encoder, decoder, n_iters, print_every=1000, plot_every=100, learning_rate=0.01):
start = time.time()
plot_losses = []
print_loss_total = 0 # Reset every print_every
plot_loss_total = 0 # Reset every plot_every
encoder_optimizer = optim.SGD(encoder.parameters(), lr=learning_rate) # 优化器,通常指定待优化的参数以及学习率模式
decoder_optimizer = optim.SGD(decoder.parameters(), lr=learning_rate) # 优化器,通常指定待优化的参数以及学习率模式
training_pairs = [tensorsFromPair(random.choice(pairs))
for i in range(n_iters)]
criterion = nn.NLLLoss() # 损失函数,用于计算模型输出和真实答案之间的差值
for iter in range(1, n_iters + 1):
'''执行训练过程,n_iters:表示训练步数
'''
training_pair = training_pairs[iter - 1]
input_tensor = training_pair[0]
target_tensor = training_pair[1]
# 执行train函数,进行训练并且返回损失值
loss = train(input_tensor, target_tensor, encoder,
decoder, encoder_optimizer, decoder_optimizer, criterion)
print_loss_total += loss
plot_loss_total += loss
if iter % print_every == 0:
print_loss_avg = print_loss_total / print_every
print_loss_total = 0
print('%s (%d %d%%) %.4f' % (timeSince(start, iter / n_iters),
iter, iter / n_iters * 100, print_loss_avg))
if iter % plot_every == 0:
plot_loss_avg = plot_loss_total / plot_every
plot_losses.append(plot_loss_avg)
plot_loss_total = 0
showPlot(plot_losses)
import matplotlib.pyplot as plt
plt.switch_backend('agg')
import matplotlib.ticker as ticker
import numpy as np
def showPlot(points):
plt.figure()
fig, ax = plt.subplots()
# this locator puts ticks at regular intervals
loc = ticker.MultipleLocator(base=0.2)
ax.yaxis.set_major_locator(loc)
plt.plot(points)
7、定义验证过程
def evaluate(encoder, decoder, sentence, max_length=MAX_LENGTH):
with torch.no_grad():
input_tensor = tensorFromSentence(input_lang, sentence)
input_length = input_tensor.size()[0]
encoder_hidden = encoder.initHidden()
encoder_outputs = torch.zeros(max_length, encoder.hidden_size, device=device)
for ei in range(input_length):
encoder_output, encoder_hidden = encoder(input_tensor[ei],
encoder_hidden)
encoder_outputs[ei] += encoder_output[0, 0]
decoder_input = torch.tensor([[SOS_token]], device=device) # SOS
decoder_hidden = encoder_hidden
decoded_words = []
decoder_attentions = torch.zeros(max_length, max_length)
for di in range(max_length):
decoder_output, decoder_hidden, decoder_attention = decoder(
decoder_input, decoder_hidden, encoder_outputs)
decoder_attentions[di] = decoder_attention.data
topv, topi = decoder_output.data.topk(1)
if topi.item() == EOS_token:
decoded_words.append('<EOS>')
break
else:
decoded_words.append(output_lang.index2word[topi.item()])
decoder_input = topi.squeeze().detach()
return decoded_words, decoder_attentions[:di + 1]
def evaluateRandomly(encoder, decoder, n=10):
for i in range(n):
pair = random.choice(pairs)
print('>', pair[0])
print('=', pair[1])
output_words, attentions = evaluate(encoder, decoder, pair[0])
output_sentence = ' '.join(output_words)
print('<', output_sentence)
print('')
8、执行训练与验证过程
训练过程实际上就是不停地调整encoder1和attn_decoder1的所有参数,使得模型的每一次输出尽可能拟合标准答案。
这其中涉及到优化算法,打印的是:
训练所需时间,当前所处迭代步数,当前已经执行的步数的百分比,平均损失值
%matplotlib inline
hidden_size = 256
encoder1 = EncoderRNN(input_lang.n_words, hidden_size).to(device)
attn_decoder1 = AttnDecoderRNN(hidden_size, output_lang.n_words, dropout_p=0.1).to(device)
trainIters(encoder1, attn_decoder1, 75000, print_every=5000)
1m 34s (- 22m 8s) (5000 6%) 2.8433
3m 7s (- 20m 16s) (10000 13%) 2.2873
4m 39s (- 18m 36s) (15000 20%) 1.9658
6m 13s (- 17m 5s) (20000 26%) 1.7078
7m 46s (- 15m 32s) (25000 33%) 1.5310
9m 20s (- 14m 0s) (30000 40%) 1.3317
10m 54s (- 12m 28s) (35000 46%) 1.2264
12m 29s (- 10m 55s) (40000 53%) 1.1010
14m 3s (- 9m 22s) (45000 60%) 0.9866
15m 39s (- 7m 49s) (50000 66%) 0.8691
17m 14s (- 6m 16s) (55000 73%) 0.7777
18m 48s (- 4m 42s) (60000 80%) 0.7417
20m 22s (- 3m 8s) (65000 86%) 0.6802
21m 55s (- 1m 33s) (70000 93%) 0.6146
23m 29s (- 0m 0s) (75000 100%) 0.5637
evaluateRandomly(encoder1, attn_decoder1)
> je vais me doucher .
= i m going to take a shower .
< i m getting to get . . <EOS>
> vous etes tres intelligent .
= you re very intelligent .
< you re very intelligent . <EOS>
> nous sommes ponctuels .
= we re punctual .
< we re punctual . <EOS>
> je ne suis pas assez bon pour vous .
= i m not good enough for you .
< i m not good enough for you . . .
> je suis reellement fier de toi .
= i m really proud of you .
< i m really proud of you . <EOS>
> il ne m a jamais frappe auparavant .
= he s never hit me before .
< he s never not before before . <EOS>
> je n en suis pas certain .
= i m not certain .
< i m not certain of it . <EOS>
> vous ne cooperez pas .
= you re not cooperating .
< you re not cooperating . <EOS>
> tu es etourdi .
= you re forgetful .
< you re forgiven . <EOS>
> vous etes fort raffinee .
= you re very sophisticated .
< you re very sophisticated . <EOS>
%matplotlib inline
output_words, attentions = evaluate(
encoder1, attn_decoder1, "je suis trop froid .")
plt.matshow(attentions.numpy())
9、展示模型的结果,进行进一步分析
主要是进行注意力分析,感兴趣的同学可以百度一下什么是注意力,实际上就是展示了模型通过训练,在进行翻译任务时,预测某个词是,对输入的句子的各个单词分配的权重。简单来说,预测某一个位置的词时,对于输入的序列的各个词的注意力(分配的权重),是不一样的。
%matplotlib inline
def showAttention(input_sentence, output_words, attentions):
# Set up figure with colorbar
fig = plt.figure()
ax = fig.add_subplot(111)
cax = ax.matshow(attentions.numpy(), cmap='bone')
fig.colorbar(cax)
# Set up axes
ax.set_xticklabels([''] + input_sentence.split(' ') +
['<EOS>'], rotation=90)
ax.set_yticklabels([''] + output_words)
# Show label at every tick
ax.xaxis.set_major_locator(ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(ticker.MultipleLocator(1))
plt.show()
def evaluateAndShowAttention(input_sentence):
output_words, attentions = evaluate(
encoder1, attn_decoder1, input_sentence)
print('input =', input_sentence)
print('output =', ' '.join(output_words))
showAttention(input_sentence, output_words, attentions)
evaluateAndShowAttention("elle a cinq ans de moins que moi .")
evaluateAndShowAttention("elle est trop petit .")
evaluateAndShowAttention("je ne crains pas de mourir .")
evaluateAndShowAttention("c est un jeune directeur plein de talent .")
input = elle a cinq ans de moins que moi .
output = she is five years younger than i am <EOS>
input = elle est trop petit .
output = she s too short . <EOS>
input = je ne crains pas de mourir .
output = i m not dying of dying . <EOS>
input = c est un jeune directeur plein de talent .
output = he s a talented young s . <EOS>
参考:
https://pytorch.org/tutorials/intermediate/seq2seq_translation_tutorial.html