第二天-文本預處理,語言模型,循環(huán)神經(jīng)網(wǎng)絡

文本預處理

文本是一類序列數(shù)據(jù),一篇文章可以看作是字符或單詞的序列,本節(jié)將介紹文本數(shù)據(jù)的常見預處理步驟,預處理通常包括四個步驟:

  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:10]
[['the', 'time', 'machine', 'by', 'h', 'g', 'wells', ''],
 [''],
 [''],
 [''],
 [''],
 ['i'],
 [''],
 [''],
 ['the',
  'time',
  'traveller',
  'for',
  'so',
  'it',
  'will',
  'be',
  'convenient',
  'to',
  'speak',
  'of',
  'him',
  ''],
 ['was',
  'expounding',
  'a',
  'recondite',
  'matter',
  'to',
  'us',
  'his',
  'grey',
  'eyes',
  'shone',
  'and']]

建立字典

為了方便模型處理,我們需要將字符串轉換為數(shù)字。因此我們需要先構建一個字典(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)  # 返回一個字典,記錄每個詞的出現(xiàn)次數(shù)

我們看一個例子,這里我們嘗試用Time Machine作為語料構建字典

vocab = Vocab(tokens)
print(list(vocab.token_to_idx.items())[0:20])
[('', 0), ('the', 1), ('time', 2), ('machine', 3), ('by', 4), ('h', 5), ('g', 6), ('wells', 7), ('i', 8), ('traveller', 9), ('for', 10), ('so', 11), ('it', 12), ('will', 13), ('be', 14), ('convenient', 15), ('to', 16), ('speak', 17), ('of', 18), ('him', 19)]

將詞轉為索引

使用字典,我們可以將原文本中的句子從單詞序列轉換為索引序列

for i in range(0, 10):
    print('words:', tokens[i])
    print('indices:', vocab[tokens[i]])
words: ['the', 'time', 'machine', 'by', 'h', 'g', 'wells', '']
indices: [1, 2, 3, 4, 5, 6, 7, 0]
words: ['']
indices: [0]
words: ['']
indices: [0]
words: ['']
indices: [0]
words: ['']
indices: [0]
words: ['i']
indices: [8]
words: ['']
indices: [0]
words: ['']
indices: [0]
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]

用現(xiàn)有工具進行分詞

我們前面介紹的分詞方式非常簡單,它至少有以下幾個缺點:

  1. 標點符號通??梢蕴峁┱Z義信息,但是我們的方法直接將其丟棄了
  2. 類似“shouldn't", "doesn't"這樣的詞會被錯誤地處理
  3. 類似"Mr.", "Dr."這樣的詞會被錯誤地處理

我們可以通過引入更復雜的規(guī)則來解決這些問題,但是事實上,有一些現(xiàn)有的工具可以很好地進行分詞,我們在這里簡單介紹其中的兩個: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))

語言模型

一段自然語言文本可以看作是一個離散時間序列,給定一個長度為T的詞的序列w_1, w_2, \ldots, w_T,語言模型的目標就是評估該序列是否合理,即計算該序列的概率:

P(w_1, w_2, \ldots, w_T).

本節(jié)我們介紹基于統(tǒng)計的語言模型,主要是n元語法(n-gram)。在后續(xù)內(nèi)容中,我們將會介紹基于神經(jīng)網(wǎng)絡的語言模型。

語言模型

假設序列w_1, w_2, \ldots, w_T中的每個詞是依次生成的,我們有

\begin{align*} P(w_1, w_2, \ldots, w_T) &= \prod_{t=1}^T P(w_t \mid w_1, \ldots, w_{t-1})\\ &= P(w_1)P(w_2 \mid w_1) \cdots P(w_T \mid w_1w_2\cdots w_{T-1}) \end{align*}

例如,一段含有4個詞的文本序列的概率

P(w_1, w_2, w_3, w_4) = P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_1, w_2, w_3).

語言模型的參數(shù)就是詞的概率以及給定前幾個詞情況下的條件概率。設訓練數(shù)據(jù)集為一個大型文本語料庫,如維基百科的所有條目,詞的概率可以通過該詞在訓練數(shù)據(jù)集中的相對詞頻來計算,例如,w_1的概率可以計算為:

\hat P(w_1) = \frac{n(w_1)}{n}

其中n(w_1)為語料庫中以w_1作為第一個詞的文本的數(shù)量,n為語料庫中文本的總數(shù)量。

類似的,給定w_1情況下,w_2的條件概率可以計算為:

\hat P(w_2 \mid w_1) = \frac{n(w_1, w_2)}{n(w_1)}

其中n(w_1, w_2)為語料庫中以w_1作為第一個詞,w_2作為第二個詞的文本的數(shù)量。

n元語法

序列長度增加,計算和存儲多個詞共同出現(xiàn)的概率的復雜度會呈指數(shù)級增加。n元語法通過馬爾可夫假設簡化模型,馬爾科夫假設是指一個詞的出現(xiàn)只與前面n個詞相關,即n階馬爾可夫鏈(Markov chain of order n),如果n=1,那么有P(w_3 \mid w_1, w_2) = P(w_3 \mid w_2)?;?img class="math-inline" src="https://math.jianshu.com/math?formula=n-1" alt="n-1" mathimg="1">階馬爾可夫鏈,我們可以將語言模型改寫為

P(w_1, w_2, \ldots, w_T) = \prod_{t=1}^T P(w_t \mid w_{t-(n-1)}, \ldots, w_{t-1}) .

以上也叫n元語法(n-grams),它是基于n - 1階馬爾可夫鏈的概率語言模型。例如,當n=2時,含有4個詞的文本序列的概率就可以改寫為:

\begin{align*} P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_1, w_2, w_3)\\ &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_2) P(w_4 \mid w_3) \end{align*}

n分別為1、2和3時,我們將其分別稱作一元語法(unigram)、二元語法(bigram)和三元語法(trigram)。例如,長度為4的序列w_1, w_2, w_3, w_4在一元語法、二元語法和三元語法中的概率分別為

\begin{aligned} P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2) P(w_3) P(w_4) ,\\ P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_2) P(w_4 \mid w_3) ,\\ P(w_1, w_2, w_3, w_4) &= P(w_1) P(w_2 \mid w_1) P(w_3 \mid w_1, w_2) P(w_4 \mid w_2, w_3) . \end{aligned}

n較小時,n元語法往往并不準確。例如,在一元語法中,由三個詞組成的句子“你走先”和“你先走”的概率是一樣的。然而,當n較大時,n元語法需要計算并存儲大量的詞頻和多詞相鄰頻率。

思考:n元語法可能有哪些缺陷?

  1. 參數(shù)空間過大
  2. 數(shù)據(jù)稀疏

語言模型數(shù)據(jù)集

讀取數(shù)據(jù)集

with open('/home/kesci/input/jaychou_lyrics4703/jaychou_lyrics.txt') as f:
    corpus_chars = f.read()
print(len(corpus_chars))
print(corpus_chars[: 40])
corpus_chars = corpus_chars.replace('\n', ' ').replace('\r', ' ')
corpus_chars = corpus_chars[: 10000]
63282
想要有直升機
想要和你飛到宇宙去
想要和你融化在一起
融化在宇宙里
我每天每天每

建立字符索引

idx_to_char = list(set(corpus_chars)) # 去重,得到索引到字符的映射
char_to_idx = {char: i for i, char in enumerate(idx_to_char)} # 字符到索引的映射
vocab_size = len(char_to_idx)
print(vocab_size)

corpus_indices = [char_to_idx[char] for char in corpus_chars]  # 將每個字符轉化為索引,得到一個索引的序列
sample = corpus_indices[: 20]
print('chars:', ''.join([idx_to_char[idx] for idx in sample]))
print('indices:', sample)
1027
chars: 想要有直升機 想要和你飛到宇宙去 想要和
indices: [824, 903, 535, 895, 691, 561, 239, 824, 903, 559, 849, 19, 179, 32, 746, 1000, 239, 824, 903, 559]

定義函數(shù)load_data_jay_lyrics,在后續(xù)章節(jié)中直接調用。

def load_data_jay_lyrics():
    with open('/home/kesci/input/jaychou_lyrics4703/jaychou_lyrics.txt') as f:
        corpus_chars = f.read()
    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]
    return corpus_indices, char_to_idx, idx_to_char, vocab_size

時序數(shù)據(jù)的采樣

在訓練中我們需要每次隨機讀取小批量樣本和標簽。與之前章節(jié)的實驗數(shù)據(jù)不同的是,時序數(shù)據(jù)的一個樣本通常包含連續(xù)的字符。假設時間步數(shù)為5,樣本序列為5個字符,即“想”“要”“有”“直”“升”。該樣本的標簽序列為這些字符分別在訓練集中的下一個字符,即“要”“有”“直”“升”“機”,即X=“想要有直升”,Y=“要有直升機”。

現(xiàn)在我們考慮序列“想要有直升機,想要和你飛到宇宙去”,如果時間步數(shù)為5,有以下可能的樣本和標簽:

  • X:“想要有直升”,Y:“要有直升機”
  • X:“要有直升機”,Y:“有直升機,”
  • X:“有直升機,”,Y:“直升機,想”
  • ...
  • X:“要和你飛到”,Y:“和你飛到宇”
  • X:“和你飛到宇”,Y:“你飛到宇宙”
  • X:“你飛到宇宙”,Y:“飛到宇宙去”

可以看到,如果序列的長度為T,時間步數(shù)為n,那么一共有T-n個合法的樣本,但是這些樣本有大量的重合,我們通常采用更加高效的采樣方式。我們有兩種方式對時序數(shù)據(jù)進行采樣,分別是隨機采樣和相鄰采樣。

隨機采樣

下面的代碼每次從數(shù)據(jù)里隨機采樣一個小批量。其中批量大小batch_size是每個小批量的樣本數(shù),num_steps是每個樣本所包含的時間步數(shù)。
在隨機采樣中,每個樣本是原始序列上任意截取的一段序列,相鄰的兩個隨機小批量在原始序列上的位置不一定相毗鄰。

import torch
import random
def data_iter_random(corpus_indices, batch_size, num_steps, device=None):
    # 減1是因為對于長度為n的序列,X最多只有包含其中的前n - 1個字符
    num_examples = (len(corpus_indices) - 1) // num_steps  # 下取整,得到不重疊情況下的樣本個數(shù)
    example_indices = [i * num_steps for i in range(num_examples)]  # 每個樣本的第一個字符在corpus_indices中的下標
    random.shuffle(example_indices)

    def _data(i):
        # 返回從i開始的長為num_steps的序列
        return corpus_indices[i: i + num_steps]
    if device is None:
        device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
    
    for i in range(0, num_examples, batch_size):
        # 每次選出batch_size個隨機樣本
        batch_indices = example_indices[i: i + batch_size]  # 當前batch的各個樣本的首字符的下標
        X = [_data(j) for j in batch_indices]
        Y = [_data(j + 1) for j in batch_indices]
        yield torch.tensor(X, device=device), torch.tensor(Y, device=device)

測試一下這個函數(shù),我們輸入從0到29的連續(xù)整數(shù)作為一個人工序列,設批量大小和時間步數(shù)分別為2和6,打印隨機采樣每次讀取的小批量樣本的輸入X和標簽Y。

my_seq = list(range(10))
for X, Y in data_iter_random(my_seq, batch_size=2, num_steps=2):
    print('X: ', X, '\nY:', Y, '\n')
X:  tensor([[4, 5],
        [6, 7]]) 
Y: tensor([[5, 6],
        [7, 8]]) 

X:  tensor([[2, 3],
        [0, 1]]) 
Y: tensor([[3, 4],
        [1, 2]]) 

相鄰采樣

在相鄰采樣中,相鄰的兩個隨機小批量在原始序列上的位置相毗鄰。

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_len = len(corpus_indices) // batch_size * batch_size  # 保留下來的序列的長度
    corpus_indices = corpus_indices[: corpus_len]  # 僅保留前corpus_len個字符
    indices = torch.tensor(corpus_indices, device=device)
    indices = indices.view(batch_size, -1)  # resize成(batch_size, )
    batch_num = (indices.shape[1] - 1) // num_steps
    for i in range(batch_num):
        i = i * num_steps
        X = indices[:, i: i + num_steps]
        Y = indices[:, i + 1: i + num_steps + 1]
        yield X, Y

同樣的設置下,打印相鄰采樣每次讀取的小批量樣本的輸入X和標簽Y。相鄰的兩個隨機小批量在原始序列上的位置相毗鄰。

for X, Y in data_iter_consecutive(my_seq, batch_size=2, num_steps=2):
    print('X: ', X, '\nY:', Y, '\n')
X:  tensor([[0, 1],
        [5, 6]]) 
Y: tensor([[1, 2],
        [6, 7]]) 

X:  tensor([[2, 3],
        [7, 8]]) 
Y: tensor([[3, 4],
        [8, 9]]) 

循環(huán)神經(jīng)網(wǎng)絡

本節(jié)介紹循環(huán)神經(jīng)網(wǎng)絡,下圖展示了如何基于循環(huán)神經(jīng)網(wǎng)絡實現(xiàn)語言模型。我們的目的是基于當前的輸入與過去的輸入序列,預測序列的下一個字符。循環(huán)神經(jīng)網(wǎng)絡引入一個隱藏變量H,用H_{t}表示H在時間步t的值。H_{t}的計算基于X_{t}H_{t-1},可以認為H_{t}記錄了到當前字符為止的序列信息,利用H_{t}對序列的下一個字符進行預測。

Image Name

循環(huán)神經(jīng)網(wǎng)絡的構造

我們先看循環(huán)神經(jīng)網(wǎng)絡的具體構造。假設\boldsymbol{X}_t \in \mathbb{R}^{n \times d}是時間步t的小批量輸入,\boldsymbol{H}_t \in \mathbb{R}^{n \times h}是該時間步的隱藏變量,則:

\boldsymbol{H}_t = \phi(\boldsymbol{X}_t \boldsymbol{W}_{xh} + \boldsymbol{H}_{t-1} \boldsymbol{W}_{hh} + \boldsymbol_h).

其中,\boldsymbol{W}_{xh} \in \mathbb{R}^{d \times h},\boldsymbol{W}_{hh} \in \mathbb{R}^{h \times h},\boldsymbol_{h} \in \mathbb{R}^{1 \times h},\phi函數(shù)是非線性激活函數(shù)。由于引入了\boldsymbol{H}_{t-1} \boldsymbol{W}_{hh},H_{t}能夠捕捉截至當前時間步的序列的歷史信息,就像是神經(jīng)網(wǎng)絡當前時間步的狀態(tài)或記憶一樣。由于H_{t}的計算基于H_{t-1},上式的計算是循環(huán)的,使用循環(huán)計算的網(wǎng)絡即循環(huán)神經(jīng)網(wǎng)絡(recurrent neural network)。

在時間步t,輸出層的輸出為:

\boldsymbol{O}_t = \boldsymbol{H}_t \boldsymbol{W}_{hq} + \boldsymbol_q.

其中\boldsymbol{W}_{hq} \in \mathbb{R}^{h \times q},\boldsymbol_q \in \mathbb{R}^{1 \times q}。

從零開始實現(xiàn)循環(huán)神經(jīng)網(wǎng)絡

我們先嘗試從零開始實現(xiàn)一個基于字符級循環(huán)神經(jīng)網(wǎng)絡的語言模型,這里我們使用周杰倫的歌詞作為語料,首先我們讀入數(shù)據(jù):

import torch
import torch.nn as nn
import time
import math
import sys
sys.path.append("/home/kesci/input")
import d2l_jay9460 as d2l
(corpus_indices, char_to_idx, idx_to_char, vocab_size) = d2l.load_data_jay_lyrics()
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

one-hot向量

我們需要將字符表示成向量,這里采用one-hot向量。假設詞典大小是N,每次字符對應一個從0N-1的唯一的索引,則該字符的向量是一個長度為N的向量,若字符的索引是i,則該向量的第i個位置為1,其他位置為0。下面分別展示了索引為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.])

我們每次采樣的小批量的形狀是(批量大小, 時間步數(shù))。下面的函數(shù)將這樣的小批量變換成數(shù)個形狀為(批量大小, 詞典大?。┑木仃?,矩陣個數(shù)等于時間步數(shù)。也就是說,時間步t的輸入為\boldsymbol{X}_t \in \mathbb{R}^{n \times d},其中n為批量大小,d為詞向量大小,即one-hot向量長度(詞典大小)。

def to_onehot(X, 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])

初始化模型參數(shù)

num_inputs, num_hiddens, num_outputs = vocab_size, 256, vocab_size
# num_inputs: d
# num_hiddens: h, 隱藏單元的個數(shù)是超參數(shù)
# 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)

    # 隱藏層參數(shù)
    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))
    # 輸出層參數(shù)
    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)

定義模型

函數(shù)rnn用循環(huán)的方式依次完成循環(huán)神經(jīng)網(wǎng)絡每個時間步的計算。

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,)

函數(shù)init_rnn_state初始化隱藏變量,這里的返回值是一個元組。

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

做個簡單的測試來觀察輸出結果的個數(shù)(時間步數(shù)),以及第一個時間步的輸出層輸出的形狀和隱藏狀態(tài)的形狀。

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)
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])

裁剪梯度

循環(huán)神經(jīng)網(wǎng)絡中較容易出現(xiàn)梯度衰減或梯度爆炸,這會導致網(wǎng)絡幾乎無法訓練。裁剪梯度(clip gradient)是一種應對梯度爆炸的方法。假設我們把所有模型參數(shù)的梯度拼接成一個向量 \boldsymbol{g},并設裁剪的閾值是\theta。裁剪后的梯度

\min\left(\frac{\theta}{\|\boldsymbol{g}\|}, 1\right)\boldsymbol{g}

L_2范數(shù)不超過\theta。

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)

定義預測函數(shù)

以下函數(shù)基于前綴prefix(含有數(shù)個字符的字符串)來預測接下來的num_chars個字符。這個函數(shù)稍顯復雜,其中我們將循環(huán)神經(jīng)單元rnn設置成了函數(shù)參數(shù),這樣在后面小節(jié)介紹其他循環(huán)神經(jīng)網(wǎng)絡時能重復使用這個函數(shù)。

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)
        # 計算輸出和更新隱藏狀態(tài)
        (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函數(shù)。我們將根據(jù)前綴“分開”創(chuàng)作長度為10個字符(不考慮前綴長度)的一段歌詞。因為模型參數(shù)為隨機值,所以預測結果也是隨機的。

predict_rnn('分開', 10, rnn, params, init_rnn_state, num_hiddens, vocab_size,
            device, idx_to_char, char_to_idx)
'分開玫東香代丘恨妥雕微蕃'

困惑度

我們通常使用困惑度(perplexity)來評價語言模型的好壞。回憶一下“softmax回歸”一節(jié)中交叉熵損失函數(shù)的定義。困惑度是對交叉熵損失函數(shù)做指數(shù)運算后得到的值。特別地,

  • 最佳情況下,模型總是把標簽類別的概率預測為1,此時困惑度為1;
  • 最壞情況下,模型總是把標簽類別的概率預測為0,此時困惑度為正無窮;
  • 基線情況下,模型總是預測所有類別的概率都相同,此時困惑度為類別個數(shù)。

顯然,任何一個有效模型的困惑度必須小于類別個數(shù)。在本例中,困惑度必須小于詞典大小vocab_size。

定義模型訓練函數(shù)

跟之前章節(jié)的模型訓練函數(shù)相比,這里的模型訓練函數(shù)有以下幾點不同:

  1. 使用困惑度評價模型。
  2. 在迭代模型參數(shù)前裁剪梯度。
  3. 對時序數(shù)據(jù)采用不同采樣方法將導致隱藏狀態(tài)初始化的不同。
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開始時初始化隱藏狀態(tài)
            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:  # 如使用隨機采樣,在每個小批量更新前初始化隱藏狀態(tài)
                state = init_rnn_state(batch_size, num_hiddens, device)
            else:  # 否則需要使用detach函數(shù)從計算圖分離隱藏狀態(tài)
                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)  # 因為誤差已經(jīng)取過均值,梯度不用再做平均
            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))

訓練模型并創(chuàng)作歌詞

現(xiàn)在我們可以訓練模型了。首先,設置模型超參數(shù)。我們將根據(jù)前綴“分開”和“不分開”分別創(chuàng)作長度為50個字符(不考慮前綴長度)的一段歌詞。我們每過50個迭代周期便根據(jù)當前訓練的模型創(chuàng)作一段歌詞。

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

下面采用隨機采樣訓練模型并創(chuàng)作歌詞。

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)

接下來采用相鄰采樣訓練模型并創(chuàng)作歌詞。

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)

循環(huán)神經(jīng)網(wǎng)絡的簡介實現(xiàn)

定義模型

我們使用Pytorch中的nn.RNN來構造循環(huán)神經(jīng)網(wǎng)絡。在本節(jié)中,我們主要關注nn.RNN的以下幾個構造函數(shù)參數(shù):

  • 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決定了輸入的形狀,我們使用默認的參數(shù)False,對應的輸入形狀是 (num_steps, batch_size, input_size)。

forward函數(shù)的參數(shù)為:

  • 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函數(shù)的返回值是:

  • 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.

現(xiàn)在我們構造一個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)

我們定義一個完整的基于循環(huán)神經(jīng)網(wǎng)絡的語言模型。

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

類似的,我們需要實現(xiàn)一個預測函數(shù),與前面的區(qū)別在于前向計算和初始化隱藏狀態(tài)。

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)  # 前向計算不需要傳入模型參數(shù)
        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)

接下來實現(xiàn)訓練函數(shù),這里只使用了相鄰采樣。

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函數(shù)從計算圖分離隱藏狀態(tài)
                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)
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容