文本预处理

文本是一类序列数据,一篇文章可以看作是字符或单词的序列,本节将介绍文本数据的常见预处理步骤,预处理通常包括四个步骤:

  1. 读入文本
  2. 分词
  3. 建立字典,将每个词映射到一个唯一的索引(index)
  4. 将文本从词的序列转换为索引的序列,方便输入模型

读入文本

我们用一部英文小说,即H. G. Well的Time Machine,作为示例,展示文本预处理的具体过程。

import collections
import re


def read_time_machine():
    with open('/home/kesci/input/timemachine7163/timemachine.txt', 'r') as f:
        lines = [re.sub('[^a-z]+', ' ', line.strip().lower()) for line in f]
    return lines


lines = read_time_machine()
print('# sentences %d' % len(lines))
# sentences 3221

分词

我们对每个句子进行分词,也就是将一个句子划分成若干个词(token),转换为一个词的序列。

def tokenize(sentences, token='word'):
    """Split sentences into word or char tokens"""
    if token == 'word':
        return [sentence.split(' ') for sentence in sentences]
    elif token == 'char':
        return [list(sentence) for sentence in sentences]
    else:
        print('ERROR: unkown token type '+token)

tokens = tokenize(lines)
tokens[0:2]
[['the', 'time', 'machine', 'by', 'h', 'g', 'wells', ''], ['']]

建立字典

为了方便模型处理,我们需要将字符串转换为数字。因此我们需要先构建一个字典(vocabulary),将每个词映射到一个唯一的索引编号。

class Vocab(object):
    def __init__(self, tokens, min_freq=0, use_special_tokens=False):
        counter = count_corpus(tokens)  # : 
        self.token_freqs = list(counter.items())
        self.idx_to_token = []
        if use_special_tokens:
            # padding, begin of sentence, end of sentence, unknown
            self.pad, self.bos, self.eos, self.unk = (0, 1, 2, 3)
            self.idx_to_token += ['', '', '', '']
        else:
            self.unk = 0
            self.idx_to_token += ['']
        self.idx_to_token += [token for token, freq in self.token_freqs
                        if freq >= min_freq and token not in self.idx_to_token]
        self.token_to_idx = dict()
        for idx, token in enumerate(self.idx_to_token):
            self.token_to_idx[token] = idx

    def __len__(self):
        return len(self.idx_to_token)

    def __getitem__(self, tokens):
        if not isinstance(tokens, (list, tuple)):
            return self.token_to_idx.get(tokens, self.unk)
        return [self.__getitem__(token) for token in tokens]

    def to_tokens(self, indices):
        if not isinstance(indices, (list, tuple)):
            return self.idx_to_token[indices]
        return [self.idx_to_token[index] for index in indices]

def count_corpus(sentences):
    tokens = [tk for st in sentences for tk in st]
    return collections.Counter(tokens)  # 返回一个字典,记录每个词的出现次数

我们看一个例子,这里我们尝试用Time Machine作为语料构建字典

vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[0:10])
[('', 0), ('the', 1), ('time', 2), ('machine', 3), ('by', 4), ('h', 5), ('g', 6), ('wells', 7), ('i', 8), ('traveller', 9)]

将词转为索引

使用字典,我们可以将原文本中的句子从单词序列转换为索引序列

for i in range(8, 10):
    print('words:', tokens[i])
    print('indices:', vocab[tokens[i]])
words: ['the', 'time', 'traveller', 'for', 'so', 'it', 'will', 'be', 'convenient', 'to', 'speak', 'of', 'him', '']
indices: [1, 2, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 0]
words: ['was', 'expounding', 'a', 'recondite', 'matter', 'to', 'us', 'his', 'grey', 'eyes', 'shone', 'and']
indices: [20, 21, 22, 23, 24, 16, 25, 26, 27, 28, 29, 30]

用现有工具进行分词

我们前面介绍的分词方式非常简单,它至少有以下几个缺点:

  1. 标点符号通常可以提供语义信息,但是我们的方法直接将其丢弃了
  2. 类似“shouldn’t", "doesn’t"这样的词会被错误地处理
  3. 类似"Mr.", "Dr."这样的词会被错误地处理

我们可以通过引入更复杂的规则来解决这些问题,但是事实上,有一些现有的工具可以很好地进行分词,我们在这里简单介绍其中的两个:spaCyNLTK

下面是一个简单的例子:

text = "Mr. Chen doesn't agree with my suggestion."

spaCy:

import spacy
nlp = spacy.load('en_core_web_sm')
doc = nlp(text)
print([token.text for token in doc])
['Mr.', 'Chen', 'does', "n't", 'agree', 'with', 'my', 'suggestion', '.']

NLTK:

from nltk.tokenize import word_tokenize
from nltk import data
data.path.append('/home/kesci/input/nltk_data3784/nltk_data')
print(word_tokenize(text))
['Mr.', 'Chen', 'does', "n't", 'agree', 'with', 'my', 'suggestion', '.']

语言模型

语言模型(language model)是自然语言处理的重要技术。自然语言处理中最常见的数据是文本数据。我们可以把一段自然语言文本看作一段离散的时间序列。假设一段长度为大语言模型 tokens 如何计算 语言学中的token_pytorch的文本中的词依次为大语言模型 tokens 如何计算 语言学中的token_pytorch_02,那么在离散的时间序列中,大语言模型 tokens 如何计算 语言学中的token_pytorch_03大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_04)可看作在时间步(time step)大语言模型 tokens 如何计算 语言学中的token_深度学习_05的输出或标签。给定一个长度为大语言模型 tokens 如何计算 语言学中的token_pytorch的词的序列大语言模型 tokens 如何计算 语言学中的token_pytorch_02,语言模型将计算该序列的概率:

大语言模型 tokens 如何计算 语言学中的token_pytorch_08

语言模型可用于提升语音识别和机器翻译的性能。例如,在语音识别中,给定一段“厨房里食油用完了”的语音,有可能会输出“厨房里食油用完了”和“厨房里石油用完了”这两个读音完全一样的文本序列。如果语言模型判断出前者的概率大于后者的概率,我们就可以根据相同读音的语音输出“厨房里食油用完了”的文本序列。在机器翻译中,如果对英文“you go first”逐词翻译成中文的话,可能得到“你走先”“你先走”等排列方式的文本序列。如果语言模型判断出“你先走”的概率大于其他排列方式的文本序列的概率,我们就可以把“you go first”翻译成“你先走”。

既然语言模型很有用,那该如何计算它呢?假设序列大语言模型 tokens 如何计算 语言学中的token_pytorch_02中的每个词是依次生成的,我们有

大语言模型 tokens 如何计算 语言学中的token_pytorch_10

例如,一段含有4个词的文本序列的概率

大语言模型 tokens 如何计算 语言学中的token_深度学习_11

为了计算语言模型,我们需要计算词的概率,以及一个词在给定前几个词的情况下的条件概率,即语言模型参数。设训练数据集为一个大型文本语料库,如维基百科的所有条目。词的概率可以通过该词在训练数据集中的相对词频来计算。例如,大语言模型 tokens 如何计算 语言学中的token_pytorch_12可以计算为大语言模型 tokens 如何计算 语言学中的token_深度学习_13在训练数据集中的词频(词出现的次数)与训练数据集的总词数之比。因此,根据条件概率定义,一个词在给定前几个词的情况下的条件概率也可以通过训练数据集中的相对词频计算。例如,大语言模型 tokens 如何计算 语言学中的token_pytorch_14可以计算为大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_15两词相邻的频率与大语言模型 tokens 如何计算 语言学中的token_深度学习_13词频的比值,因为该比值即大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_17大语言模型 tokens 如何计算 语言学中的token_pytorch_12之比;而大语言模型 tokens 如何计算 语言学中的token_pytorch_19同理可以计算为大语言模型 tokens 如何计算 语言学中的token_深度学习_13大语言模型 tokens 如何计算 语言学中的token_神经网络_21大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_22三词相邻的频率与大语言模型 tokens 如何计算 语言学中的token_深度学习_13大语言模型 tokens 如何计算 语言学中的token_神经网络_21两词相邻的频率的比值。以此类推。

当序列长度增加时,计算和存储多个词共同出现的概率的复杂度会呈指数级增加。大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25元语法通过马尔可夫假设(虽然并不一定成立)简化了语言模型的计算。这里的马尔可夫假设是指一个词的出现只与前面大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25个词相关,即大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25阶马尔可夫链(Markov chain of order 大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25)。如果大语言模型 tokens 如何计算 语言学中的token_深度学习_29,那么有大语言模型 tokens 如何计算 语言学中的token_数据挖掘_30。如果基于大语言模型 tokens 如何计算 语言学中的token_数据挖掘_31阶马尔可夫链,我们可以将语言模型改写为

大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_32

以上也叫大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25元语法(大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25-grams)。它是基于大语言模型 tokens 如何计算 语言学中的token_pytorch_35阶马尔可夫链的概率语言模型。当大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25分别为1、2和3时,我们将其分别称作一元语法(unigram)、二元语法(bigram)和三元语法(trigram)。例如,长度为4的序列大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_37在一元语法、二元语法和三元语法中的概率分别为

大语言模型 tokens 如何计算 语言学中的token_数据挖掘_38

大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25较小时,大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25元语法往往并不准确。例如,在一元语法中,由三个词组成的句子“你走先”和“你先走”的概率是一样的。然而,当大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25较大时,大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25元语法需要计算并存储大量的词频和多词相邻频率。

import torch
import random
import zipfile
with zipfile.ZipFile(
        r'D:\研一课件\深度学习\pytorch\动手学深度学习\Dive-into-DL-PyTorch-master\data\jaychou_lyrics.txt.zip'
) as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')
# 查看前40个字符是什么
corpus_chars[:40]
'想要有直升机\n想要和你飞到宇宙去\n想要和你融化在一起\n融化在宇宙里\n我每天每天每'

这个数据集有6万多个字符。为了打印方便,我们把换行符替换成空格,然后仅使用前1万个字符来训练模型。

corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
# \n是换行,\r回车
corpus_chars = corpus_chars[:10000]

建立字符索引

我们将每个字符映射成一个从0开始的连续整数,又称索引,来方便之后的数据处理。为了得到索引,我们将数据集里所有不同字符取出来,然后将其逐一映射到索引来构造词典。接着,打印vocab_size,即词典中不同字符的个数,又称词典大小。

idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
vocab_size = len(char_to_idx)
vocab_size # 1027
1027

之后,将训练数据集中每个字符转化为索引,并打印前20个字符及其对应的索引。

corpus_indices = [char_to_idx[char] for char in corpus_chars]  #生成所以文本字符对应的index
sample = corpus_indices[:20]
print('chars:', ''.join([idx_to_char[idx] for idx in sample]))
print('indices:', sample)
chars: 想要有直升机 想要和你飞到宇宙去 想要和
indices: [176, 235, 907, 151, 81, 929, 2, 176, 235, 484, 356, 803, 90, 249, 651, 284, 2, 176, 235, 484]

以上代码封装在d2lzh_pytorch包里的load_data_jay_lyrics函数中,以方便后面章节调用。调用该函数后会依次得到corpus_indices、char_to_idx、idx_to_char和vocab_size这4个变量。

时序数据的采样

在训练中我们需要每次随机读取小批量样本和标签。与之前章节的实验数据不同的是,时序数据的一个样本通常包含连续的字符。假设时间步数为5,样本序列为5个字符,即“想”“要”“有”“直”“升”。该样本的标签序列为这些字符分别在训练集中的下一个字符,即“要”“有”“直”“升”“机”,即大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_43=“想要有直升”,大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_44=“要有直升机”。

现在我们考虑序列“想要有直升机,想要和你飞到宇宙去”,如果时间步数为5,有以下可能的样本和标签:

  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“想要有直升”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“要有直升机”
  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“要有直升机”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“有直升机,”
  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“有直升机,”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“直升机,想”
  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“要和你飞到”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“和你飞到宇”
  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“和你飞到宇”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“你飞到宇宙”
  • 大语言模型 tokens 如何计算 语言学中的token_pytorch_45:“你飞到宇宙”,大语言模型 tokens 如何计算 语言学中的token_神经网络_46:“飞到宇宙去”

可以看到,如果序列的长度为大语言模型 tokens 如何计算 语言学中的token_pytorch,时间步数为大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25,那么一共有大语言模型 tokens 如何计算 语言学中的token_深度学习_59个合法的样本,但是这些样本有大量的重合,我们通常采用更加高效的采样方式。我们有两种方式对时序数据进行采样,分别是随机采样和相邻采样。

随机采样

下面的代码每次从数据里随机采样一个小批量。其中批量大小batch_size是每个小批量的样本数,num_steps是每个样本所包含的时间步数。
在随机采样中,每个样本是原始序列上任意截取的一段序列,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。

# 本函数已保存在d2lzh_pytorch包中方便以后使用
def data_iter_random(corpus_indices, batch_size, num_steps, device=None):
    # 减1是因为输出的索引x是相应输入的索引y加1
    num_examples = (len(corpus_indices) - 1) // num_steps
    epoch_size = num_examples // batch_size
    example_indices = list(range(num_examples))
    random.shuffle(example_indices)

    # 返回从pos开始的长为num_steps的序列
    def _data(pos):  #pos是一个整数
        return corpus_indices[pos:pos + num_steps]

    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    for i in range(epoch_size):
        # 每次读取batch_size个随机样本
        i = i * batch_size
        batch_indices = example_indices[i:i + batch_size]
        X = [_data(j * num_steps) for j in batch_indices]
        Y = [_data(j * num_steps + 1) for j in batch_indices]
        yield torch.tensor(X, dtype=torch.float32,
                           device=device), torch.tensor(Y,
                                                        dtype=torch.float32,
                                                        device=device)

让我们输入一个从0到29的连续整数的人工序列。设批量大小和时间步数分别为2和6。打印随机采样每次读取的小批量样本的输入X和标签Y。可见,相邻的两个随机小批量在原始序列上的位置不一定相毗邻。

my_seq = list(range(30))
for X, Y in data_iter_random(my_seq, batch_size=2, num_steps=6):
    print('X: ', X, '\nY:', Y, '\n')
X:  tensor([[ 0.,  1.,  2.,  3.,  4.,  5.],
        [12., 13., 14., 15., 16., 17.]]) 
Y: tensor([[ 1.,  2.,  3.,  4.,  5.,  6.],
        [13., 14., 15., 16., 17., 18.]]) 

X:  tensor([[18., 19., 20., 21., 22., 23.],
        [ 6.,  7.,  8.,  9., 10., 11.]]) 
Y: tensor([[19., 20., 21., 22., 23., 24.],
        [ 7.,  8.,  9., 10., 11., 12.]])

相邻采样

在相邻采样中,相邻的两个随机小批量在原始序列上的位置相毗邻。

# 本函数已保存在d2lzh_pytorch包中方便以后使用
def data_iter_consecutive(corpus_indices, batch_size, num_steps, device=None):
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    corpus_indices = torch.tensor(corpus_indices, dtype=torch.float32, device=device)
    data_len = len(corpus_indices)
    batch_len = data_len // batch_size
    indices = corpus_indices[0: batch_size*batch_len].view(batch_size, batch_len)
    epoch_size = (batch_len - 1) // num_steps
    for i in range(epoch_size):
        i = i * num_steps
        X = indices[:, i: i + num_steps]
        Y = indices[:, i + 1: i + num_steps + 1]
        yield X, Y
for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=6):
    print('X: ', X, '\nY:', Y, '\n')
X:  tensor([[ 0.,  1.,  2.,  3.,  4.,  5.],
        [15., 16., 17., 18., 19., 20.]]) 
Y: tensor([[ 1.,  2.,  3.,  4.,  5.,  6.],
        [16., 17., 18., 19., 20., 21.]]) 

X:  tensor([[ 6.,  7.,  8.,  9., 10., 11.],
        [21., 22., 23., 24., 25., 26.]]) 
Y: tensor([[ 7.,  8.,  9., 10., 11., 12.],
        [22., 23., 24., 25., 26., 27.]])

循环神经网络

本节介绍循环神经网络,下图展示了如何基于循环神经网络实现语言模型。我们的目的是基于当前的输入与过去的输入序列,预测序列的下一个字符。循环神经网络引入一个隐藏变量大语言模型 tokens 如何计算 语言学中的token_神经网络_60,用大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61表示大语言模型 tokens 如何计算 语言学中的token_神经网络_60在时间步大语言模型 tokens 如何计算 语言学中的token_深度学习_05的值。大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61的计算基于大语言模型 tokens 如何计算 语言学中的token_神经网络_65大语言模型 tokens 如何计算 语言学中的token_pytorch_66,可以认为大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61记录了到当前字符为止的序列信息,利用大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61对序列的下一个字符进行预测。

大语言模型 tokens 如何计算 语言学中的token_深度学习_69

循环神经网络的构造

我们先看循环神经网络的具体构造。假设大语言模型 tokens 如何计算 语言学中的token_pytorch_70是时间步大语言模型 tokens 如何计算 语言学中的token_深度学习_05的小批量输入,大语言模型 tokens 如何计算 语言学中的token_神经网络_72是该时间步的隐藏变量,则:

大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_73

其中,大语言模型 tokens 如何计算 语言学中的token_深度学习_74大语言模型 tokens 如何计算 语言学中的token_深度学习_75大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_76大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_77函数是非线性激活函数。由于引入了大语言模型 tokens 如何计算 语言学中的token_深度学习_78大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61能够捕捉截至当前时间步的序列的历史信息,就像是神经网络当前时间步的状态或记忆一样。由于大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_61的计算基于大语言模型 tokens 如何计算 语言学中的token_pytorch_66,上式的计算是循环的,使用循环计算的网络即循环神经网络(recurrent neural network)。

在时间步大语言模型 tokens 如何计算 语言学中的token_深度学习_05,输出层的输出为:

大语言模型 tokens 如何计算 语言学中的token_pytorch_83

其中大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_84大语言模型 tokens 如何计算 语言学中的token_神经网络_85

从零开始实现循环神经网络

我们先尝试从零开始实现一个基于字符级循环神经网络的语言模型,这里我们使用周杰伦的歌词作为语料,首先我们读入数据:

import time
import random
import zipfile
import math
import numpy as np
import torch
from torch import nn, optim
import torch.nn.functional as F
import d2lzh_pytorch as d2l
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
with zipfile.ZipFile(
        r'D:\研一课件\深度学习\pytorch\动手学深度学习\Dive-into-DL-PyTorch-master\data\jaychou_lyrics.txt.zip'
) as zin:
    with zin.open('jaychou_lyrics.txt') as f:
        corpus_chars = f.read().decode('utf-8')
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[0:10000]
idx_to_char = list(set(corpus_chars))
char_to_idx = dict([(char, i) for i, char in enumerate(idx_to_char)])
vocab_size = len(char_to_idx)
corpus_indices = [char_to_idx[char] for char in corpus_chars]

one-hot向量

我们需要将字符表示成向量,这里采用one-hot向量。假设词典大小是大语言模型 tokens 如何计算 语言学中的token_深度学习_86,每次字符对应一个从大语言模型 tokens 如何计算 语言学中的token_神经网络_87大语言模型 tokens 如何计算 语言学中的token_深度学习_88的唯一的索引,则该字符的向量是一个长度为大语言模型 tokens 如何计算 语言学中的token_深度学习_86的向量,若字符的索引是大语言模型 tokens 如何计算 语言学中的token_pytorch_90,则该向量的第大语言模型 tokens 如何计算 语言学中的token_pytorch_90个位置为大语言模型 tokens 如何计算 语言学中的token_神经网络_92,其他位置为大语言模型 tokens 如何计算 语言学中的token_神经网络_87。下面分别展示了索引为0和2的one-hot向量,向量长度等于词典大小。

def one_hot(x, n_class, dtype=torch.float32):
    result = torch.zeros(x.shape[0], n_class, dtype=dtype, device=x.device)  # shape: (n, n_class)
    result.scatter_(1, x.long().view(-1, 1), 1)  # result[i, x[i, 0]] = 1
    return result
    
x = torch.tensor([0, 2])
x_one_hot = one_hot(x, vocab_size)
print(x_one_hot)
print(x_one_hot.shape)
print(x_one_hot.sum(axis=1))
tensor([[1., 0., 0.,  ..., 0., 0., 0.],
        [0., 0., 1.,  ..., 0., 0., 0.]])
torch.Size([2, 1027])
tensor([1., 1.])

我们每次采样的小批量的形状是(批量大小, 时间步数)。下面的函数将这样的小批量变换成数个形状为(批量大小, 词典大小)的矩阵,矩阵个数等于时间步数。也就是说,时间步大语言模型 tokens 如何计算 语言学中的token_深度学习_05的输入为大语言模型 tokens 如何计算 语言学中的token_pytorch_70,其中大语言模型 tokens 如何计算 语言学中的token_大语言模型 tokens 如何计算_25为批量大小,大语言模型 tokens 如何计算 语言学中的token_深度学习_97为词向量大小,即one-hot向量长度(词典大小)。

def to_onehot(X, n_class):  
    # X shape: (batch, seq_len), output: seq_len elements of (batch, n_class)
    return [one_hot(X[:, i], n_class) for i in range(X.shape[1])]

X = torch.arange(10).view(2, 5)
inputs = to_onehot(X, vocab_size)
print(len(inputs), inputs[0].shape)
5 torch.Size([2, 1027])

初始化模型参数

下面我们初始化模型参数。注意隐藏单元个数 num_hiddens是一个超参数。

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
# num_inputs: d
# num_hiddens: h, 隐藏单元的个数是超参数
# num_outputs: q
def get_params():
    def _one(shape):
        param = torch.zeros(shape, device=device, dtype=torch.float32)
        nn.init.normal_(param, 0, 0.01)
        return torch.nn.Parameter(param)

    # 隐藏层参数
    W_xh = _one((num_inputs, num_hiddens))
    W_hh = _one((num_hiddens, num_hiddens))
    b_h = torch.nn.Parameter(torch.zeros(num_hiddens, device=device))
    # 输出层参数
    W_hq = _one((num_hiddens, num_outputs))
    b_q = torch.nn.Parameter(torch.zeros(num_outputs, device=device))
    return (W_xh, W_hh, b_h, W_hq, b_q)

定义模型

函数rnn用循环的方式依次完成循环神经网络每个时间步的计算。

def rnn(inputs, state, params):
    # inputs和outputs皆为num_steps个形状为(batch_size, vocab_size)的矩阵
    W_xh, W_hh, b_h, W_hq, b_q = params
    H, = state
    outputs = []
    for X in inputs:
        H = torch.tanh(torch.matmul(X, W_xh) + torch.matmul(H, W_hh) + b_h)
        Y = torch.matmul(H, W_hq) + b_q
        outputs.append(Y)
    return outputs, (H,)

函数init_rnn_state初始化隐藏变量,这里的返回值是一个元组。

def init_rnn_state(batch_size, num_hiddens, device):
    return (torch.zeros((batch_size, num_hiddens), device=device), )

做个简单的测试来观察输出结果的个数(时间步数),以及第一个时间步的输出层输出的形状和隐藏状态的形状。

print(X)
print(X.shape)
print(num_hiddens)
print(vocab_size)
state = init_rnn_state(X.shape[0], num_hiddens, device)
inputs = to_onehot(X.to(device), vocab_size)
params = get_params()
outputs, state_new = rnn(inputs, state, params)
print(len(inputs), inputs[0].shape)
print(len(outputs), outputs[0].shape)
print(len(state), state[0].shape)
print(len(state_new), state_new[0].shape)
tensor([[0, 1, 2, 3, 4],
        [5, 6, 7, 8, 9]])
torch.Size([2, 5])
256
1027
5 torch.Size([2, 1027])
5 torch.Size([2, 1027])
1 torch.Size([2, 256])
1 torch.Size([2, 256])

裁剪梯度

循环神经网络中较容易出现梯度衰减或梯度爆炸,这会导致网络几乎无法训练。裁剪梯度(clip gradient)是一种应对梯度爆炸的方法。假设我们把所有模型参数的梯度拼接成一个向量 大语言模型 tokens 如何计算 语言学中的token_神经网络_98,并设裁剪的阈值是大语言模型 tokens 如何计算 语言学中的token_神经网络_99。裁剪后的梯度

大语言模型 tokens 如何计算 语言学中的token_pytorch_100

大语言模型 tokens 如何计算 语言学中的token_pytorch_101范数不超过大语言模型 tokens 如何计算 语言学中的token_神经网络_99

def grad_clipping(params, theta, device):
    norm = torch.tensor([0.0], device=device)
    for param in params:
        norm += (param.grad.data ** 2).sum()
    norm = norm.sqrt().item()
    if norm > theta:
        for param in params:
            param.grad.data *= (theta / norm)

定义预测函数

以下函数基于前缀prefix(含有数个字符的字符串)来预测接下来的num_chars个字符。这个函数稍显复杂,其中我们将循环神经单元rnn设置成了函数参数,这样在后面小节介绍其他循环神经网络时能重复使用这个函数。

def predict_rnn(prefix, num_chars, rnn, params, init_rnn_state,
                num_hiddens, vocab_size, device, idx_to_char, char_to_idx):
    state = init_rnn_state(1, num_hiddens, device)
    output = [char_to_idx[prefix[0]]]   # output记录prefix加上预测的num_chars个字符
    for t in range(num_chars + len(prefix) - 1):
        # 将上一时间步的输出作为当前时间步的输入
        X = to_onehot(torch.tensor([[output[-1]]], device=device), vocab_size)
        # 计算输出和更新隐藏状态
        (Y, state) = rnn(X, state, params)
        # 下一个时间步的输入是prefix里的字符或者当前的最佳预测字符
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(Y[0].argmax(dim=1).item())
    return ''.join([idx_to_char[i] for i in output])

我们先测试一下predict_rnn函数。我们将根据前缀“分开”创作长度为10个字符(不考虑前缀长度)的一段歌词。因为模型参数为随机值,所以预测结果也是随机的。

predict_rnn('分开', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size,
            device, idx_to_char, char_to_idx)
'分开安娇虽模决秃转惹活手'

困惑度

我们通常使用困惑度(perplexity)来评价语言模型的好坏。回忆一下前面交叉熵损失函数的定义。困惑度是对交叉熵损失函数做指数运算后得到的值。特别地,

  • 最佳情况下,模型总是把标签类别的概率预测为1,此时困惑度为1;
  • 最坏情况下,模型总是把标签类别的概率预测为0,此时困惑度为正无穷;
  • 基线情况下,模型总是预测所有类别的概率都相同,此时困惑度为类别个数。

显然,任何一个有效模型的困惑度必须小于类别个数。在本例中,困惑度必须小于词典大小vocab_size

定义模型训练函数

跟之前章节的模型训练函数相比,这里的模型训练函数有以下几点不同:

  1. 使用困惑度评价模型。
  2. 在迭代模型参数前裁剪梯度。
  3. 对时序数据采用不同采样方法将导致隐藏状态初始化的不同。
def train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                          vocab_size, device, corpus_indices, idx_to_char,
                          char_to_idx, is_random_iter, num_epochs, num_steps,
                          lr, clipping_theta, batch_size, pred_period,
                          pred_len, prefixes):
    if is_random_iter:
        data_iter_fn = d2l.data_iter_random
    else:
        data_iter_fn = d2l.data_iter_consecutive
    params = get_params()
    loss = nn.CrossEntropyLoss()

    for epoch in range(num_epochs):
        if not is_random_iter:  # 如使用相邻采样,在epoch开始时初始化隐藏状态
            state = init_rnn_state(batch_size, num_hiddens, device)
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = data_iter_fn(corpus_indices, batch_size, num_steps, device)
        for X, Y in data_iter:
            if is_random_iter:  # 如使用随机采样,在每个小批量更新前初始化隐藏状态
                state = init_rnn_state(batch_size, num_hiddens, device)
            else:  # 否则需要使用detach函数从计算图分离隐藏状态
                for s in state:
                    s.detach_()
            # inputs是num_steps个形状为(batch_size, vocab_size)的矩阵
            inputs = to_onehot(X, vocab_size)
            # outputs有num_steps个形状为(batch_size, vocab_size)的矩阵
            (outputs, state) = rnn(inputs, state, params)
            # 拼接之后形状为(num_steps * batch_size, vocab_size)
            outputs = torch.cat(outputs, dim=0)
            # Y的形状是(batch_size, num_steps),转置后再变成形状为
            # (num_steps * batch_size,)的向量,这样跟输出的行一一对应
            y = torch.flatten(Y.T)
            # 使用交叉熵损失计算平均分类误差
            l = loss(outputs, y.long())
            
            # 梯度清0
            if params[0].grad is not None:
                for param in params:
                    param.grad.data.zero_()
            l.backward()
            grad_clipping(params, clipping_theta, device)  # 裁剪梯度
            d2l.sgd(params, lr, 1)  # 因为误差已经取过均值,梯度不用再做平均
            l_sum += l.item() * y.shape[0]
            n += y.shape[0]

        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            for prefix in prefixes:
                print(' -', predict_rnn(prefix, pred_len, rnn, params, init_rnn_state,
                    num_hiddens, vocab_size, device, idx_to_char, char_to_idx))

训练模型并创作歌词

现在我们可以训练模型了。首先,设置模型超参数。我们将根据前缀“分开”和“不分开”分别创作长度为50个字符(不考虑前缀长度)的一段歌词。我们每过50个迭代周期便根据当前训练的模型创作一段歌词。

num_epochs, num_steps, batch_size, lr, clipping_theta = 250, 35, 32, 1e2, 1e-2
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']

下面采用随机采样训练模型并创作歌词。

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, device, corpus_indices, idx_to_char,
                      char_to_idx, True, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)
epoch 50, perplexity 72.709166, time 1.95 sec
 - 分开 我想要这 快 用双个人 快知的可爱  哼什么 我 我这 你 你着我的 快 你着我  你 你有我  
 - 不分开  想你你有  一知我有 我想你的可爱 像哼我有多  爱有你有 我 你这我  你的你有 我 你这我 
epoch 100, perplexity 10.803959, time 1.90 sec
 - 分开 一只用双截棍 哼哼哈兮 快使用双截棍 哼哼哈兮 我该好好生活 我知道好 你知我 别不我 你不就这样
 - 不分开永 我想你这辈你 后知我遇见你是一场悲剧 我想我这辈子注定一个人演  我 靠这你很单我 想散 你想很
epoch 150, perplexity 3.071530, time 2.31 sec
 - 分开 一直用 一步两步三步四步望著天 看星星 一颗两颗三颗四颗 连成线背著背默默许下心愿 看远方的星如果
 - 不分开吗 我后你爸 你我我妈 这样看吗 就是没空 不果有  你知没有 我想多烦着我 不知不觉 我跟了这节奏
epoch 200, perplexity 1.613822, time 2.15 sec
 - 分开 一直用老哭在举一 她的让午心比阳光射进教堂的角度 能知道你前世是狼人的漫言 传说就成了永垂不朽的诗
 - 不分开吗 然后将过去 你慢温习 让我爱上你 那场悲剧 是你完美演出的一场戏 宁愿心碎哭泣 再狠狠忘记 你爱
epoch 250, perplexity 1.306236, time 1.54 sec
 - 分开 一愿心老哭 三什躺停 温暖了日出 白色蜡烛 温暖了空屋 白色蜡烛 温暖了空屋 白色蜡烛 温暖了空屋
 - 不分开吗 我叫你爸 你打我妈 这样对吗干嘛这样 何必让酒牵鼻子走 瞎 说底你的肩车 我 想我胸口睡著 像这

接下来采用相邻采样训练模型并创作歌词。

train_and_predict_rnn(rnn, get_params, init_rnn_state, num_hiddens,
                      vocab_size, device, corpus_indices, idx_to_char,
                      char_to_idx, False, num_epochs, num_steps, lr,
                      clipping_theta, batch_size, pred_period, pred_len,
                      prefixes)
epoch 50, perplexity 60.453539, time 1.67 sec
 - 分开 我想要这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我不能这不 我
 - 不分开 你不要你 你有了空 我有我的可写 我知在这生 你不的美 在我的外 在果我人 我想我这 你有我有 我
epoch 100, perplexity 7.257783, time 1.69 sec
 - 分开 一子她 一颗四颗三步四颗 连成线背著背 默你 回子再考倒我 说散 你想很久了吧? 我不你的黑色幽西
 - 不分开只 你已经 穿你载睛三着 所有人停留 几学人 有皮箱 装沉却三 回头一碗热粥 配上几斤的牛 一直安老
epoch 150, perplexity 2.055131, time 1.66 sec
 - 分开 我不要 爱怎么没有一 悲伤到看着都鹿的骨头 秃鹰盘旋 装水螂空 藤边再 废你在囱重一 悲我 你是到
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生
epoch 200, perplexity 1.310121, time 1.70 sec
 - 分开 问候的黑旧笑 唱着歌拿我妈出气 我真的看不下去 以为我较细汉 从小到大只有妈妈的温暖  为什么我爸
 - 不分开觉我会想透 平知不一个永慢然 我办女神 整颗心悬在半空在 默必下钟三铁阳衫 他们儿子我习惯 从小杂迷
epoch 250, perplexity 1.211576, time 1.67 sec
 - 分开 问候是 我怎么 说壶么 我给了暴督自我 别发抖 快给我抬起头 有话去对医药箱说 别怪我 别怪我 说
 - 不分开觉 你已经离开我 不知不觉 我跟了这节奏 后知后觉 又过了一个秋 后知后觉 我该好好生活 我该好好生

循环神经网络的简介实现

定义模型

我们使用Pytorch中的nn.RNN来构造循环神经网络。在本节中,我们主要关注nn.RNN的以下几个构造函数参数:

  • input_size - The number of expected features in the input x
  • hidden_size – The number of features in the hidden state h
  • nonlinearity – The non-linearity to use. Can be either ‘tanh’ or ‘relu’. Default: ‘tanh’
  • batch_first – If True, then the input and output tensors are provided as (batch_size, num_steps, input_size). Default: False

这里的batch_first决定了输入的形状,我们使用默认的参数False,对应的输入形状是 (num_steps, batch_size, input_size)。

forward函数的参数为:

  • input of shape (num_steps, batch_size, input_size): tensor containing the features of the input sequence.
  • h_0 of shape (num_layers * num_directions, batch_size, hidden_size): tensor containing the initial hidden state for each element in the batch. Defaults to zero if not provided. If the RNN is bidirectional, num_directions should be 2, else it should be 1.

forward函数的返回值是:

  • output of shape (num_steps, batch_size, num_directions * hidden_size): tensor containing the output features (h_t) from the last layer of the RNN, for each t.
  • h_n of shape (num_layers * num_directions, batch_size, hidden_size): tensor containing the hidden state for t = num_steps.

现在我们构造一个nn.RNN实例,并用一个简单的例子来看一下输出的形状。

rnn_layer = nn.RNN(input_size=vocab_size, hidden_size=num_hiddens)
num_steps, batch_size = 35, 2
X = torch.rand(num_steps, batch_size, vocab_size)
state = None
Y, state_new = rnn_layer(X, state)
print(Y.shape, state_new.shape)
torch.Size([35, 2, 256]) torch.Size([1, 2, 256])

我们定义一个完整的基于循环神经网络的语言模型。

class RNNModel(nn.Module):
    def __init__(self, rnn_layer, vocab_size):
        super(RNNModel, self).__init__()
        self.rnn = rnn_layer
        self.hidden_size = rnn_layer.hidden_size * (2 if rnn_layer.bidirectional else 1) 
        self.vocab_size = vocab_size
        self.dense = nn.Linear(self.hidden_size, vocab_size)

    def forward(self, inputs, state):
        # inputs.shape: (batch_size, num_steps)
        X = to_onehot(inputs, vocab_size)
        X = torch.stack(X)  # X.shape: (num_steps, batch_size, vocab_size)
        hiddens, state = self.rnn(X, state)
        hiddens = hiddens.view(-1, hiddens.shape[-1])  # hiddens.shape: (num_steps * batch_size, hidden_size)
        output = self.dense(hiddens)
        return output, state

类似的,我们需要实现一个预测函数,与前面的区别在于前向计算和初始化隐藏状态。

def predict_rnn_pytorch(prefix, num_chars, model, vocab_size, device, idx_to_char,
                      char_to_idx):
    state = None
    output = [char_to_idx[prefix[0]]]  # output记录prefix加上预测的num_chars个字符
    for t in range(num_chars + len(prefix) - 1):
        X = torch.tensor([output[-1]], device=device).view(1, 1)
        (Y, state) = model(X, state)  # 前向计算不需要传入模型参数
        if t < len(prefix) - 1:
            output.append(char_to_idx[prefix[t + 1]])
        else:
            output.append(Y.argmax(dim=1).item())
    return ''.join([idx_to_char[i] for i in output])

使用权重为随机值的模型来预测一次。

model = RNNModel(rnn_layer, vocab_size).to(device)
predict_rnn_pytorch('分开', 10, model, vocab_size, device, idx_to_char, char_to_idx)
'分开盲呼蛦内弄盲呼圈弄弄'

接下来实现训练函数,这里只使用了相邻采样。

def train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                                corpus_indices, idx_to_char, char_to_idx,
                                num_epochs, num_steps, lr, clipping_theta,
                                batch_size, pred_period, pred_len, prefixes):
    loss = nn.CrossEntropyLoss()
    optimizer = torch.optim.Adam(model.parameters(), lr=lr)
    model.to(device)
    for epoch in range(num_epochs):
        l_sum, n, start = 0.0, 0, time.time()
        data_iter = d2l.data_iter_consecutive(corpus_indices, batch_size, num_steps, device) # 相邻采样
        state = None
        for X, Y in data_iter:
            if state is not None:
                # 使用detach函数从计算图分离隐藏状态
                if isinstance (state, tuple): # LSTM, state:(h, c)  
                    state[0].detach_()
                    state[1].detach_()
                else: 
                    state.detach_()
            (output, state) = model(X, state) # output.shape: (num_steps * batch_size, vocab_size)
            y = torch.flatten(Y.T)
            l = loss(output, y.long())
            
            optimizer.zero_grad()
            l.backward()
            grad_clipping(model.parameters(), clipping_theta, device)
            optimizer.step()
            l_sum += l.item() * y.shape[0]
            n += y.shape[0]
        

        if (epoch + 1) % pred_period == 0:
            print('epoch %d, perplexity %f, time %.2f sec' % (
                epoch + 1, math.exp(l_sum / n), time.time() - start))
            for prefix in prefixes:
                print(' -', predict_rnn_pytorch(
                    prefix, pred_len, model, vocab_size, device, idx_to_char,
                    char_to_idx))

训练模型。

num_epochs, batch_size, lr, clipping_theta = 250, 32, 1e-3, 1e-2
pred_period, pred_len, prefixes = 50, 50, ['分开', '不分开']
train_and_predict_rnn_pytorch(model, num_hiddens, vocab_size, device,
                            corpus_indices, idx_to_char, char_to_idx,
                            num_epochs, num_steps, lr, clipping_theta,
                            batch_size, pred_period, pred_len, prefixes)
epoch 50, perplexity 15.065489, time 0.87 sec
 - 分开始在人 不过 我不要 我不到 我不你的让我 你的可爱女人 坏坏的让我疯狂的可爱女人 坏坏的让我疯狂的
 - 不分开 我想你你不我 我不能不想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 我不要再想 
epoch 100, perplexity 1.366576, time 0.89 sec
 - 分开始想我 不能为回忆对到 干什么 有点去布 满脸过外叹息  像是一只饿昏的老斑鸠 印地安老斑鸠 腿短毛
 - 不分开 爱你 的没有 我不能再想 我不 我不 我不要 想要我的难熬  穿过云层 我试著努力向你奔跑 爱才送
epoch 150, perplexity 1.072260, time 0.89 sec
 - 分开始想在 爸和你怎么每天都能可爱女人 坏坏的让我疯狂的可爱女人 漂亮的让我面红的可爱女人 温柔的让我心
 - 不分开不想太  我不要 不要 一个人 后开 我给你的怒火 我想揍这样的着我 别双截棍 哼哼哈兮 快使用双截
epoch 200, perplexity 1.035550, time 0.88 sec
 - 分开始想在 没有你说我不 不要再 你身我 我的认声语沉  我被你说抽离  什么不妥 有多烦恼多 我不想汉
 - 不分开有多烦我 我的我较细汉 从小到大只有妈妈的温暖  为什么我爸爸 那么凶 如果真的我有一双翅膀 二双翅
epoch 250, perplexity 1.051731, time 0.91 sec
 - 分开 想你 爸不会痛吗不要将要 别爱写听 满了黄金葛在你了中节只属后开中融化在一定中现我的回忆 有有在的
 - 不分开有话你对没有的不会  没没有你在  有什么不妥 有话就想那 妈窝我的笑每原 我不想 你不再不要 你不