7.2 时序数据#
在「第7.1节 RNN原理:循环神经网络的结构与序列建模」内容中,我们详细介绍了RNN模型的原理及使用场景,即对时序特征进行特征提取,因此在本节内容中我们将通过两个实际的案例来介绍RNN的具体使用方式。不过在正式介绍之前我们需要明白一点的是,所谓时序数据并不是一定要具有时间上的概念,只要是包含前后的先后顺序并且打乱这个顺序就改变了样本的属性,那么这样的数据便都可以被称之为时序数据。
7.2.1 时序图片#
虽然对于图像处理来说采用卷积操作是最合理的一种方式,但我们仍旧可以将一张图片看成是时序数据并通过RNN来对其进行特征提取并完成后续的分类任务,而构造成时序数据的方法便是将其按照行或列进行分割。

如图7-5所示,左侧为原始图片,右侧为被垂直分割成4部分后的图片。此时我们可以发现,对于图7-5右侧的图片来说,其分割后的每一列都可以看成是每个时刻对应的状态,并且如果列与列之间的顺序发生了改变,那么将会改变该图片对应的原始属性。因此,在按照这样的分割方式操作后,每张图片均可以看成是一个序列样本。当然,除了以垂直的方式进行分割外也可以按照水平的方式进行分割。
以FashionMNIST数据集为例,其原始形状为$28\times28$,如果我们以垂直方式对其进行分割,那么便可以通过一个包含有28个时刻,每个时刻为一个28维的向量来对其进行表示。
7.2.2 基于RNN的图片分类#
在清楚了时序图片的构造方式后,下面我们再来介绍如何通过RNN来完成FashionMNIST数据集的分类任务。以下完整示例代码可以参见Code/Chapter07/C02_RNNImgCla/FashionMNISTRNN.py文件。
1. 前向传播
由于torchvision中的datasets模块已经将FashionMNIST数据集处理成了[batch_size, 1, width, high]的形式,所以我们只需要压缩掉通道这个维度,然后将width和high分别理解成步长和输入维度即可,并不需要进行特殊处理。因此我们直接定义相应的前向传播过程,示例代码如下所示:
1 class FashionMNISTRNN(nn.Module):
2 def __init__(self, input_size=28, hidden_size=128,
3 num_layers=2, num_classes=10):
4 super(FashionMNISTRNN, self).__init__()
5 self.rnn = nn.RNN(input_size, hidden_size,nonlinearity='relu',
6 num_layers=num_layers, batch_first=True)
7 self.classifier = nn.Sequential(LayerNormalization(hidden_size),
8 nn.Linear(hidden_size, hidden_size),nn.ReLU(inplace=True),
9 nn.Linear(hidden_size, num_classes))
10
11 def forward(self, x, labels=None):
12 x = x.squeeze(1)
13 x, _ = self.rnn(x)
14 logits = self.classifier(x[:, -1].squeeze(1))
15 if labels is not None:
16 loss_fct = nn.CrossEntropyLoss(reduction='mean')
17 loss = loss_fct(logits, labels)
18 return loss, logits
19 else:
20 return logits在上述代码中,第2~3行用于指定相关的模型参数。第5~6行为实例化一个RNN模型,其中nonlinearity用于指定RNN中的激活函数。第7~9行为最后的分类层,其中LayerNormalization为「第6.4节 LayerNorm原理:层归一化与 BatchNorm 的区别」中介绍到的层归一化方法。第11~20行便是整个前向传播计算过程,其中第12行表示将[batch_size, 1, width, high]压缩成[batch_size, width, high],第13行是RNN的计算结果此时x的形状为[batch_size, time_steps, hidden_size],第14行是取最后一个时刻的输出并压缩成[batch_size, hidden_size]的形状。
此时,我们可以通过如下代码来测试上述模块:
1 if __name__ == '__main__':
2 model = FashionMNISTRNN()
3 x = torch.rand([32, 1, 28, 28])
4 y = model(x)
5 print(y.shape)在上述代码运行结束后便可以得到如下所示结果:
1 torch.Size([32, 10])2. 模型训练
在这里我们将继续使用「第4.5节 AlexNet网络:结构原理、参数量与 PyTorch 实现」中介绍到的FashionMNIST数据集因此不再对相关内容进行赘述。在前面各项工作都准备完毕之后便可以进一步实现模型的训练过程。由于这部分代码在之前也已经多次介绍过程,因此这里也不再赘述,各位读者直接参考源码即可。
最后,在对网络模型进行训练时将会得到类似如下的输出结果:
1 Epochs[1/5]--batch[0/938]--Acc: 0.0156--loss: 2.3238
2 Epochs[1/5]--batch[50/938]--Acc: 0.4688--loss: 1.1348
3 Epochs[1/5]--batch[100/938]--Acc: 0.5625--loss: 1.0453
4 Epochs[1/5]--batch[150/938]--Acc: 0.7188--loss: 0.8871
5 ......
6 Epochs[5/5]--batch[800/938]--Acc: 0.8438--loss: 0.4105
7 Epochs[5/5]--batch[850/938]--Acc: 0.7969--loss: 0.5822
8 Epochs[5/5]--batch[900/938]--Acc: 0.875--loss: 0.3218
9 Epochs[5/5]--Acc on test 0.8622从上述结果可以看出,使用RNN模型来对FashionMNIST数据集进行分类在5轮迭代后在测试集上也能取得不错的效果,但是相较于卷积网络还是稍有差距。
7.2.3 时序文本#
在第7.1节内容,我们多次提到文本数据是一种最直观的时序数据,因为同样的字以不同的顺序出现便表示不同的含义,所以在对文本数据进行特征提取时一定需要考虑其时序性。由于文本数据不能直接输入到模型中,因此我们需要一种向量化手段来将文本转换为向量。在深度学习中,一种最简单的文本向量化表示方法就是采用One-hot来进行转换,见「第3.5.4节 Softmax回归原理:从逻辑回归到多分类模型」内容。
具体地,对于文本数据来说:①首先将训练集中的所有文本进行切分(Tokenize)处理,切分的粒度可以是词粒度也可以是字粒度,此处我们先以字粒度进行介绍;②然后以每个词在训练集中出现的词频排序,并选择前K个词作为词表;③最后,以每个词在词表中的序号用One-hot的形式来对其进行表示。
例如,现在有两个样本其每个字在词表中出现的顺序分别为[0,6,7]和[2,5,1],且词表的长度为10,即索引为0~9,则其通过One-hot表示后的结果为:
1 if __name__ == '__main__':
2 x = torch.randint(0, 10, [2, 3], dtype=torch.long)
3 x_one_hot = torch.nn.functional.one_hot(x, num_classes=10)
4 print(x, x_one_hot)
5
6 tensor([[0, 6, 7],[2, 5, 1]])
7 tensor([[[1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
8 [0, 0, 0, 0, 0, 0, 1, 0, 0, 0],
9 [0, 0, 0, 0, 0, 0, 0, 1, 0, 0]],
10 [[0, 0, 1, 0, 0, 0, 0, 0, 0, 0],
11 [0, 0, 0, 0, 0, 1, 0, 0, 0, 0],
12 [0, 1, 0, 0, 0, 0, 0, 0, 0, 0]]])在上述代码中,第2行是随机生成两个样本在词表中的索引值。第3行是将每个样本的索引值转换为对应的One-hot表示形式,即最终转换后的结果如第7~12行所示,其中num_classes表示词表的长度。此时可以看出,原始样本中的每个字都使用了一个长度为10的One-hot向量来对其进行表示。
同时,由于对于文本数据来说每个样本的长度都不尽相同,因此需要进行特殊处理。根据RNN模型的原理可知,我们需要保证在每个小批量内部所有样本长度相同(不同小批量间可以不同),因此一种常见的处理方式便是以其中最长的样本为标准对其它样本进行填充(Padding)处理。当然我们也可以任意指定一个长度,对于超出该长度的部分进行截断处理,小于该长度的部分进行填充处理。
7.2.4 基于RNN的文本分类#
下面,我们以一个新闻文本分类任务为例进行介绍。该数据集为今日头条网站上的新闻标题,包含有15个类别分别是:故事、文化、娱乐、体育、财经、房产、汽车、教育、科技、军事、旅游、国际、股票、三农和游戏,同时有训练集、验证集和测试集3个部分。
经过预处理后的数据样本形式如下:
1 故宫如何修文物?文物医院下月向公众开放_!_1
2 深圳房价是沈阳6倍就是因为经济?错!_!_5
3 不负春光,樱花树下;温暖你我,温暖龙岩_!_10在上述示例中,_!_为分隔符,左侧为原始新闻标题,右侧为类别标签。
接下来,我们从零开始构建整个数据集,然后通过RNN模型来完成后续的分类任务。
1. 构建词表
首先我们需要根据原始训练集来构建词表,同时为了方便后续代码复用我们这里单独定义一个函数来完成单句文本的切分处理,示例代码如下所示:
1 def tokenize(text):
2 words = " ".join(text).split() # 字粒度
3 return words上述代码的作用便是将一句文本切分成字粒度并放在一个列表中,如:
1 上联:一夜春风去,怎么对下联?
2 ['上', '联', ':', '一', '夜', '春', '风', '去', ',', '怎', '么', '对', '下', '联', '?']进一步,定义一个Vocab类来完成词表的构建,示例代码如下所示:
1 class Vocab(object):
2 UNK = '[UNK]' # 0
3 PAD = '[PAD]' # 1
4 def __init__(self, top_k=2000, data=None):
5 counter = Counter()
6 self.stoi = {Vocab.UNK: 0, Vocab.PAD: 1}
7 self.itos = [Vocab.UNK, Vocab.PAD]
8 for text in data:
9 token = tokenize(text)
10 counter.update(token)
11 top_k_words = counter.most_common(top_k - 2)
12 for i, word in enumerate(top_k_words):
13 self.stoi[word[0]] = i + 2 # 2表示已有了UNK和PAD
14 self.itos.append(word[0])在上述代码中,第2~3是定义两个特殊字符,用于表示词表中未出现(当测试数据中出现了一个词表中为包含的词时便用[UNK]进行表示)的词和用于填充词。第4行中top_k表示指定词表长度,data表示传入的原始语料,形式为一个列表其中每个元素为一条文本。第5行用于初始化一个计数器。第6~7行为将默认的两个特殊字符放入词表中。第8行是遍历每一条文本进行分割,并通过counter来对每个字进行频率统计。第11行是取取前top_k-2个词。第12~14行为根据取前top_k个词来构建词表。
最后,通过上述代码便可以返回得到一个词表,其用法如下所示:
1 if __name__ == '__main__':
2 vocab = Vocab(2000,data)
3 print(vocab.itos[0]) # 通过索引返回得到词表中对应的词;
4 print(vocab.stoi['[UNK]']) # 通过单词返回得到词表中对应的索引其输出结果分别为:
1 '[UNK]'
2 02. 序列填充
进一步,我们需要实现一个函数来根据指定参数对文本序列进行截断或填充处理,示例代码如下所示:
1 def pad_sequence(sequences, batch_first=False, max_len=None, padding_value=0):
2 if max_len is None:
3 max_len = max([s.size(0) for s in sequences])
4 out_tensors = []
5 for tensor in sequences:
6 if tensor.size(0) < max_len:
7 padding_content = [padding_value] * (max_len - tensor.size(0))
8 tensor = torch.cat([tensor, torch.tensor(padding_content)], dim=0)
9 else:
10 tensor = tensor[:max_len]
11 out_tensors.append(tensor)
12 out_tensors = torch.stack(out_tensors, dim=1)
13 if batch_first:
14 return out_tensors.transpose(0, 1)
15 return out_tensors在上述代码中,第1行sequences表示不同长度的序列,batch_first表示是否将batch_size这个维度放到最前面,max_len表示指定的序列长度,padding_value表示指定的填充值。第2~3行表示如果max_len=None,则取该小批量样本中最长的样本为标准。第5~11行为遍历每个样本,并对其中过短过过长的样本进行填充或截取处理。第12行表示将列表out_tensors中的所有样本按列进行堆叠。第13~15则是返回最后的结果。
上述代码示例用法如下:
1 if __name__ == '__main__':
2 a = torch.tensor([1, 2, 4, 6, 7])
3 b = torch.tensor([1, 0, 1])
4 out = pad_sequence([a, b], batch_first=True)
5 out = pad_sequence([a, b], batch_first=True, max_len=2)
6 out = pad_sequence([a, b], batch_first=True, max_len=8)在上述代码中,第4行表示以两个样本中最长的进行填充。第5~6行表示以指定长度进行进行填充。
输出结果如下所示:
1 tensor([[1, 2, 4, 6, 7],
2 [1, 5, 1, 0, 0]])
3 tensor([[1, 2],
4 [1, 5]])
5 tensor([[1, 2, 4, 6, 7, 0, 0, 0],
6 [1, 5, 1, 0, 0, 0, 0, 0]])4. 载入原始数据
接着,我们定义一个TouTiaoNews类来完成整个数据集的构建。首先定义一个初始化方法并完成原始数据的载入,示例代码如下所示:
1 class TouTiaoNews(object):
2 DATA_DIR = os.path.join(DATA_HOME, 'toutiao')
3 FILE_PATH = [os.path.join(DATA_DIR, 'toutiao_train.txt'),
4 os.path.join(DATA_DIR, 'toutiao_val.txt'),
5 os.path.join(DATA_DIR, 'toutiao_test.txt')]
6 def __init__(self, top_k=2000,max_sen_len=None,
7 batch_size=4,is_sample_shuffle=True):
8 self.top_k = top_k
9 raw_data_train, _ = self.load_raw_data(self.FILE_PATH[0])
10 self.vocab = Vocab(top_k=self.top_k, data=raw_data_train)
11 self.max_sen_len = max_sen_len
12 self.batch_size = batch_size
13 self.is_sample_shuffle = is_sample_shuffle
14
15 @staticmethod
16 def load_raw_data(file_path=None):
17 samples, labels = [], []
18 with open(file_path, encoding='utf-8') as f:
19 for line in f:
20 line = line.strip('\n').split('_!_')
21 samples.append(line[0])
22 labels.append(line[1])
23 return samples, labels在上述代码中,第2~5行是定义各类文件的路径。第8~10行是载入原始的训练语料然后构造词表。第16~23行则是用于载入原始语料,其中@staticmethod用于声明load_raw_data为一个静态方法,即其内部不需要使用self类成员。
5. 构造样本
此时我们定义一个函数来分别用于在训练集、验证集和测试集上构造训练样本,示例代码如下所示:
1 def data_process(self, file_path):
2 samples, labels = self.load_raw_data(file_path)
3 data = []
4 for i in tqdm(range(len(samples)), ncols=80):
5 tokens = tokenize(samples[i])
6 token_ids = [self.vocab[token] for token in tokens]
7 token_ids_tensor = torch.tensor(token_ids, dtype=torch.long)
8 l = torch.tensor(int(labels[i]), dtype=torch.long)
9 data.append((token_ids_tensor, l))
10 return data山上述代码中,第4~9行是循环遍历每一条数据,其中第4行中tadm用于显示for循环的执行进度条,第5行是将新闻标题进行分词处理,第6行是将每个词转换为词表中的索引,第7~8行是分别将输入和标签转换为tensor,第9行是将样本和标签对保存到一个列表中。
上述代码运行时将日志等级调整为logging.DEBUG模式,便可以得到类似如下输出结果
1 ## 原始输入样本为: 上联:一夜春风去,怎么对下联?
2 ## 分割后的样本为: ['上', '联', ':', '一', '夜', '春', '风', '去', ',', '怎', '么', '对', '下', '联', '?']
3 ## 向量化后样本为: [19, 30, 12, 6, 710, 507, 216, 132, 2, 32, 5, 48, 42, 30, 3]同时,我们还需要定义一个函数来作为在构造DataLoader的参数,用于对每个小批量数据样本进行填充处理,示例代码如下所示:
1 def generate_batch(self, data_batch):
2 batch_sentence, batch_label = [], []
3 for (sen, label) in data_batch:
4 batch_sentence.append(sen)
5 batch_label.append(label)
6 batch_sentence = pad_sequence(batch_sentence,
7 padding_value=self.vocab.stoi[self.vocab.PAD],
8 batch_first=True,max_len=self.max_sen_len)
9 batch_label = torch.tensor(batch_label, dtype=torch.long)
10 return batch_sentence, batch_label在上述代码中,第3~5行用于分离输入和标签。第6~8行用于对一个小批量中的文本序列进行填充处理。当然,generate_batch这个函数内部还可以定义其它操作,它的主要作用是用于对一个小批量中的样本进行处理,后续我们还会碰到稍微复杂点的处理情况。
6. 构造迭代器
在前期所有工作准备就绪后,我们最后再定义一个类方法来构造训练集、验证集和测试集对应的迭代器,示例代码如下所示:
1 def load_train_val_test_data(self, is_train=False):
2 if not is_train:
3 test_data = self.data_process(self.FILE_PATH[2])
4 test_iter = DataLoader(test_data, batch_size=self.batch_size,
5 shuffle=False, collate_fn=self.generate_batch)
6 return test_iter
7 train_data = self.data_process(self.FILE_PATH[0])
8 val_data = self.data_process(self.FILE_PATH[1])
9 train_iter = DataLoader(train_data, batch_size=self.batch_size,
10 shuffle=self.is_sample_shuffle,collate_fn=self.generate_batch)
11 val_iter = DataLoader(val_data, batch_size=self.batch_size,
12 shuffle=False, collate_fn=self.generate_batch)
13 return train_iter, val_iter在上述代码中,第2~5行用于载入测试集对应的迭代器。第7~13行则是返回验证集和验证集对应的迭代器,其中generate_batch方法是作为参数在DataLoader内部被使用。
最后,我们可以通过如下方式来载入相应的迭代器:
1 if __name__ == '__main__':
2 toutiao_news = TouTiaoNews(top_k=500,batch_size=4,max_sen_len=10)
3 test_iter = toutiao_news.load_train_val_test_data(is_train=False)
4 for x,y in test_iter:
5 print(x,y)输出结果如下所示:
1 tensor([[232, 0, 0, 361, 145, 0, 471, 96, 2, 426],
2 [ 0, 0, 27, 36, 0, 187, 403, 3, 187, 403],
3 [480, 0, 74, 84, 9, 0, 0, 95, 0, 112],
4 [ 10, 0, 0, 313, 2, 0, 239, 0, 42, 0]])
5 tensor([ 2, 1, 5, 10])由于上面在构建数据集的时候只取了前500个词建立词表,所以在构建训练样本时自然有大量的词不存在于词表中,因此上述结果中包含了许多0(代表[UNK]);同时由于每个样本的长度都大于10,所以也没有出现样本填充的情况。后续,我们只需要在输入RNN之前将上述结果转换成One-hot编码形式即可完成后续分类任务。以上数据集构建完整示例代码可以参见Code/utils/data_helper.py文件。
7. 模型训练
在完成数据集的构建之后,我们便可以开始构造整个RNN模型。总体来说整个RNN模型的构建过程与第7.2.2节中介绍的并无本质差异,仅仅只是多了一步One-hot变换,所以不再赘述各位读者可以直接参见Code/Chapter07/C03_RNNNewsCla/NewsRNN.py文件。同时,由于在实际训练过程中我们发现模型极易出现梯度爆炸的现象,所以在训练过程中加入了「第6.2节 梯度裁剪:训练不稳定时如何控制梯度爆炸」中介绍的梯度裁剪方法,并且需要配合使用较小的学习率。具体训练部分代码与之前介绍无异,因此也不再赘述,各位读者直接阅读源码即可。
最后,模型训练时将会输出如下所示结果:
1 Epochs[1/50]--batch[0/2093]--Acc: 0.125--loss: 2.7097
2 Epochs[1/50]--batch[50/2093]--Acc: 0.1172--loss: 2.5915
3 Epochs[1/50]--batch[100/2093]--Acc: 0.0859--loss: 2.6101
4 Epochs[1/50]--batch[150/2093]--Acc: 0.1484--loss: 2.5399
5 ......
6 Epochs[5/50]--batch[1950/2093]--Acc: 0.5859--loss: 1.3352
7 Epochs[5/50]--batch[2000/2093]--Acc: 0.5547--loss: 1.3547
8 Epochs[5/50]--batch[2050/2093]--Acc: 0.6328--loss: 1.2467
9 Epochs[5/50]--Acc on val 0.5881
10 Epochs[50/50]--Acc on val 0.7996从上述结果可以看出,在迭代50轮之后模型在验证集上的准确率达到了0.8左右。
7.2.5 小结#
在本节内容中,我们首先介绍了时序数据的泛化含义;然后以图片分类数据集为例介绍了如何将一张图片看做是时序数据并以此完成了基于RNN模型的图片分类任务;最后详细介绍了如何使用RNN模型来完成文本分类任务,包括词表的构建、序列填充、样本构造和数据集迭代器构建等。这里值得一提的是,在上述两个分类任务中我们都是直接取RNN特征提取后最后一个时刻的输出作为输入样本的特征表示并完成后续分类任务,在实际情况中通用也可以用所有时刻输出的均值或求和来作为输入样本的特征表示。在下一节内容中,我们将开始介绍RNN模型的变体LSTM模型以及其如何缓解RNN中存在的长距离依赖问题。