TensorFlow深度学习实战------基于自编码器构建句子向量
-
- [0. 前言](#0. 前言)
- [1. 句子向量](#1. 句子向量)
- [2. 基于自编码器构建句子向量](#2. 基于自编码器构建句子向量)
-
- [2.1 数据处理](#2.1 数据处理)
- [2.2 模型构建与训练](#2.2 模型构建与训练)
- [3. 模型测试](#3. 模型测试)
- 相关链接
0. 前言
在本节中,我们将构建和训练一个基于长短期记忆 (Long Short Term Memory, LSTM)的自编码器,用于生成Reuters-21578 语料库中文档的句子向量。我们已经学习了如何使用词嵌入表示一个词,从而创建表示该词在其上下文中含义的向量。本节中,我们将学习如何为句子构建句子向量,句子是单词的序列,因此句子向量表示一个句子的含义。
1. 句子向量
构建句子向量 (Sentence Vector
) 的最简单方法是将句子中所有单词的向量加总起来,然后除以单词数量。但这种方法将句子视为词袋,未考虑单词的顺序,在这种情况下,"The dog bit the man
" 和 "The man bit the dog
" 具有相同的句子向量。长短期记忆 (Long Short Term Memory, LSTM)设计用于处理序列输入,并考虑单词的顺序,从而能够得到更好、更自然的句子表示。
2. 基于自编码器构建句子向量
2.1 数据处理
(1) 首先,导入所需的库:
python
from sklearn.model_selection import train_test_split
from tensorflow.keras.callbacks import ModelCheckpoint
from tensorflow.keras.layers import Input
from tensorflow.keras.layers import RepeatVector
from tensorflow.keras.layers import LSTM
from tensorflow.keras.layers import Bidirectional
from tensorflow.keras.models import Model
from tensorflow.keras.preprocessing import sequence
from scipy.stats import describe
import collections
import matplotlib.pyplot as plt
import nltk
import numpy as np
import os
from time import gmtime, strftime
from tensorflow.keras.callbacks import TensorBoard
import re
# Needed to run only once
nltk.download('punkt')
nltk.download('reuters')
from nltk.corpus import reuters
(2) 下载完成后使用以下命令解压 Reuters
语料库:
shell
$ unzip ~/nltk_data/corpora/reuters.zip -d ~/nltk_data/corpora
(3) 接下来,由于需要使用 GloVe
嵌入,因此首先下载 glove.6B.zip并解压缩:
shell
$ unzip glove.6B.zip
(4) 把每个文本块(文档)转换为一个句子列表,列表中每个元素表示一个句子。同时,每个句子中的单词在添加时会被规范化。规范化包括移除所有数字并将其替换为数字 9
,然后将单词转换为小写。同时,计算单词频率,得到单词频率表 word_freqs
:
python
def is_number(n):
temp = re.sub("[.,-/]", "",n)
return temp.isdigit()
# parsing sentences and building vocabulary
word_freqs = collections.Counter()
documents = reuters.fileids()
#ftext = open("text.tsv", "r")
sents = []
sent_lens = []
num_read = 0
for i in range(len(documents)):
# periodic heartbeat report
if num_read % 100 == 0:
print("building features from {:d} docs".format(num_read))
# skip docs without specified topic
title_body = reuters.raw(documents[i]).lower()
if len(title_body) == 0:
continue
num_read += 1
# convert to list of word indexes
title_body = re.sub("\n", "", title_body)
for sent in nltk.sent_tokenize(title_body):
for word in nltk.word_tokenize(sent):
if is_number(word):
word = "9"
word = word.lower()
word_freqs[word] += 1
sents.append(sent)
sent_lens.append(len(sent))
(5) 获取关于语料库的信息,用于确定合适的 LSTM
网络常量:
python
print("Total number of sentences are: {:d} ".format(len(sents)))
print ("Sentence distribution min {:d}, max {:d} , mean {:3f}, median {:3f}".
format(np.min(sent_lens), np.max(sent_lens), np.mean(sent_lens), np.median(sent_lens)))
print("Vocab size (full) {:d}".format(len(word_freqs)))
输出的语料库信息如下:
shell
Total number of sentences are: 50470
Sentence distribution min 1, max 3688 , mean 167.072657, median 155.000000
Vocab size (full) 33743
(6) 根据以上信息,为 LSTM
模型设置常量。将 VOCAB_SIZE
设为 5000
,即词汇表包含最常见的 5,000
个单词,这覆盖了语料库中 93%
以上的单词。其余单词视为超出词汇范围 (out of vocabulary
, OOV
) 并用词元 UNK
替代。在预测时,模型未见过的单词也会替换为词元 UNK
。序列长度 SEQUENCE_LEN
设为训练集中句子的中位数长度的一半。长度小于 SEQUENCE_LEN
的句子用 PAD
字符进行填充,而比 SEQUENCE_LEN
长的句子将被截断:
python
VOCAB_SIZE = 5000
EMBED_SIZE = 50
LATENT_SIZE = 512
SEQUENCE_LEN = 50
(7) 由于 LSTM
的输入需要数值型数据,需要建立一个在单词和单词 ID
之间转换的查找表。由于将词汇表大小限制为 5,000
,并且还需要添加两个特殊词元 PAD
和 UNK
,因此查找表中包含了最常出现的 4,998
个单词以及 PAD
和 UNK
词元:
python
# word2id = collections.defaultdict(lambda: 1)
word2id = {}
word2id["PAD"] = 0
word2id["UNK"] = 1
for v, (k, _) in enumerate(word_freqs.most_common(VOCAB_SIZE - 2)):
word2id[k] = v + 2
id2word = {v: k for k, v in word2id.items()}
(8) 网络输入为单词序列,每个单词由一个向量表示。可以使用独热编码 (one-hot encoding
) 来表示每个单词,但这会使输入数据非常庞大。因此,我们使用 50
维的 GloVe
嵌入来编码每个单词。嵌入生成一个形状为 (VOCAB_SIZE, EMBED_SIZE)
的矩阵,其中每一行表示词汇表中一个单词的 GloVe
嵌入,PAD
和 UNK
(分别是 0
和 1
)分别用零和随机均值填充:
python
def lookup_word2id(word):
try:
return word2id[word]
except KeyError:
return word2id["UNK"]
def load_glove_vectors(glove_file, word2id, embed_size):
embedding = np.zeros((len(word2id), embed_size))
fglove = open(glove_file, "rb")
for line in fglove:
cols = line.strip().split()
word = cols[0].decode('utf-8')
if embed_size == 0:
embed_size = len(cols) - 1
if word in word2id:
vec = np.array([float(v) for v in cols[1:]])
embedding[lookup_word2id(word)] = vec
embedding[word2id["PAD"]] = np.zeros((embed_size))
embedding[word2id["UNK"]] = np.random.uniform(-1, 1, embed_size)
return embedding
(9) 接下来,生成嵌入:
python
sent_wids = [[lookup_word2id(w) for w in s.split()] for s in sents]
sent_wids = sequence.pad_sequences(sent_wids, SEQUENCE_LEN)
# load glove vectors into weight matrix
embeddings = load_glove_vectors("glove.6B/glove.6B.{:d}d.txt".format(EMBED_SIZE), word2id, EMBED_SIZE)
print(embeddings.shape)
自编码器模型接受 GloVe
单词向量序列,并学习生成一个与输入序列相似的序列。编码器 LSTM
将序列压缩成一个固定大小的上下文向量,解码器 LSTM
使用该上下文向量来重建原始序列:

(10) 由于输入数据量较大,我们将使用生成器来生成每一批次输入,生成器产生形状为 (BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE)
的张量批次,其中,BATCH_SIZE
为 64
,由于使用的是 50
维的 GloVe
向量,因此 EMBED_SIZE
为 50
。在每个训练 epoch
开始时打乱句子,每个批次包含 64
个句子,每个句子表示为一个 GloVe
单词向量的向量。如果词汇表中的单词没有对应的 GloVe
嵌入,则用零向量表示。构建两个生成器实例,一个用于训练数据,另一个用于测试数据,分别包含原始数据集的 70%
和 30%
:
python
BATCH_SIZE = 64
NUM_EPOCHS = 20
def sentence_generator(X, embeddings, batch_size):
while True:
# loop once per epoch
num_recs = X.shape[0]
indices = np.random.permutation(np.arange(num_recs))
num_batches = num_recs // batch_size
for bid in range(num_batches):
sids = indices[bid * batch_size: (bid + 1) * batch_size]
Xbatch = embeddings[X[sids, :]]
yield Xbatch, Xbatch
# split sentences into training and test
train_size = 0.7
Xtrain, Xtest = train_test_split(sent_wids, train_size=train_size)
print("number of sentences: ", len(sent_wids))
print(Xtrain.shape, Xtest.shape)
# define training and test generators
train_gen = sentence_generator(Xtrain, embeddings, BATCH_SIZE)
test_gen = sentence_generator(Xtest, embeddings, BATCH_SIZE)
2.2 模型构建与训练
定义自编码器,自编码器由编码器 LSTM
和解码器 LSTM
组成。编码器 LSTM 读取形状为 (BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE)
的张量,表示一批次句子。每个句子表示为一个固定长度为 SEQUENCE_LEN
的填充序列,每个单词用一个 50
维的 GloVe
向量表示。编码器 LSTM
的输出维度使用超参数 LATENT_SIZE
定义,代表从训练好的自编码器的编码器部分获得的句子向量的大小,维度为 LATENT_SIZE
的向量空间表示了编码句子含义的潜空间。LSTM
的输出是大小为 LATENT_SIZE
的向量,因此对于一个批次,输出张量的形状是 (BATCH_SIZE, LATENT_SIZE)
。接下来,将该张量输入到 RepeatVector
层,该层会在整个序列中复制这个向量,即该层输出张量形状为 (BATCH_SIZE, SEQUENCE_LEN, LATENT_SIZE)
。这个张量输入到解码器 LSTM
中,其输出维度为 EMBED_SIZE
,因此输出张量的形状为 (BATCH_SIZE, SEQUENCE_LEN, EMBED_SIZE)
,也就是说,与输入张量的形状相同:
python
# define autoencoder network
inputs = Input(shape=(SEQUENCE_LEN, EMBED_SIZE), name="input")
encoded = Bidirectional(LSTM(LATENT_SIZE), merge_mode="sum",
name="encoder_lstm")(inputs)
decoded = RepeatVector(SEQUENCE_LEN, name="repeater")(encoded)
decoded = Bidirectional(LSTM(EMBED_SIZE, return_sequences=True),
merge_mode="sum",
name="decoder_lstm")(decoded)
autoencoder = Model(inputs, decoded)
使用 Adam
优化器和 MSE
损失函数编译模型。选择 MSE
的原因是我们希望重建一个具有相似含义的句子,即在 LATENT_SIZE
维度的嵌入空间中接近原始句子的句子。将损失函数定义为均方误差,并选择 Adam
优化器:
python
autoencoder.compile(optimizer="adam", loss="mse")
训练自编码器 20
个 epoch
:
python
# train
num_train_steps = len(Xtrain) // BATCH_SIZE
num_test_steps = len(Xtest) // BATCH_SIZE
history = autoencoder.fit(train_gen,
steps_per_epoch=num_train_steps,
epochs=NUM_EPOCHS,
validation_data=test_gen,
validation_steps=num_test_steps)
plt.plot(history.history["loss"], label = "training loss")
plt.plot(history.history["val_loss"], label = "validation loss")
plt.xlabel("epochs")
plt.ylabel("Loss")
plt.legend()
plt.show()
下图显示了训练和验证数据的损失变化情况,可以看到随着模型的学习,损失逐渐减少:

由于输入是嵌入矩阵,因此输出也是词嵌入矩阵。由于嵌入空间是连续的,而词汇表是离散的,并不是每个输出嵌入都会对应一个单词。我们能做的就是找到一个最接近输出嵌入的单词,以重建原始文本,所以我们将以不同的方式评估自编码器。
由于自编码器的目标是产生良好的潜表示,我们将使用原始输入和自编码器的输出生成的潜向量进行比较。首先,提取编码器组件提取:
python
# collect autoencoder predictions for test set
test_inputs, test_labels = next(test_gen)
preds = autoencoder.predict(test_inputs)
# extract encoder part from autoencoder
encoder = Model(autoencoder.input,
autoencoder.get_layer("encoder_lstm").output)
3. 模型测试
在测试集上运行自编码器,以返回预测的嵌入。接着,将输入嵌入和预测嵌入都通过编码器,生成各自的句子向量,并使用余弦相似度比较这两个向量。余弦相似度接近 1
表示两个向量高度相似,而余弦相似度接近 0
则表示两个向量相似度较低。
在包含 500
个测试句子的随机子集上测试,并计算源嵌入和自编码器生成的目标嵌入之间的句子向量的余弦相似度值:
python
def compute_cosine_similarity(x, y):
return np.dot(x, y) / (np.linalg.norm(x, 2) * np.linalg.norm(y, 2))
# compute difference between vector produced by original and autoencoded
k = 500
cosims = np.zeros((k))
i = 0
for bid in range(num_test_steps):
xtest, ytest = next(test_gen)
ytest_ = autoencoder.predict(xtest)
Xvec = encoder.predict(xtest)
Yvec = encoder.predict(ytest_)
for rid in range(Xvec.shape[0]):
if i >= k:
break
cosims[i] = compute_cosine_similarity(Xvec[rid], Yvec[rid])
if i <= 10:
print(cosims[i])
i += 1
if i >= k:
break
plt.hist(cosims, bins=10, density=True)
plt.xlabel("cosine similarity")
plt.ylabel("frequency")
plt.show()
前 10
个余弦相似度值如下所示。可以看到,这些向量之间相似较高:
shell
0.9826117753982544
0.983581006526947
0.9853078126907349
0.9853724241256714
0.9793808460235596
0.9805294871330261
0.9780978560447693
0.9855653643608093
0.9836362600326538
0.9835963845252991
0.9832736253738403
下图显示了前 500
个句子的句子向量的余弦相似度值分布的直方图。

这证实了自编码器输入和输出生成的句子向量非常相似,表明生成的句子向量是对句子的良好表示。
相关链接
TensorFlow深度学习实战(1)------神经网络与模型训练过程详解
TensorFlow深度学习实战(2)------使用TensorFlow构建神经网络
TensorFlow深度学习实战(3)------深度学习中常用激活函数详解
TensorFlow深度学习实战(4)------正则化技术详解
TensorFlow深度学习实战(5)------神经网络性能优化技术详解
TensorFlow深度学习实战(6)------回归分析详解
TensorFlow深度学习实战(7)------分类任务详解
TensorFlow深度学习实战(8)------卷积神经网络
TensorFlow深度学习实战(9)------构建VGG模型实现图像分类
TensorFlow深度学习实战(10)------迁移学习详解
TensorFlow深度学习实战(11)------风格迁移详解
TensorFlow深度学习实战(12)------词嵌入技术详解
TensorFlow深度学习实战(13)------神经嵌入详解
TensorFlow深度学习实战(14)------循环神经网络详解
TensorFlow深度学习实战(15)------编码器-解码器架构
TensorFlow深度学习实战(16)------注意力机制详解
TensorFlow深度学习实战(17)------主成分分析详解
TensorFlow深度学习实战(18)------K-means 聚类详解
TensorFlow深度学习实战(19)------受限玻尔兹曼机
TensorFlow深度学习实战(20)------自组织映射详解
TensorFlow深度学习实战(21)------Transformer架构详解与实现
TensorFlow深度学习实战(22)------从零开始实现Transformer机器翻译
TensorFlow深度学习实战(23)------自编码器详解与实现
TensorFlow深度学习实战(24)------卷积自编码器详解与实现