L24 word2vec
词嵌入基础
我们在“循环神经网络的从零开始实现”一节中使用 one-hot 向量表示单词,虽然它们构造起来很容易,但通常并不是一个好选择。一个主要的原因是,one-hot 词向量无法准确表达不同词之间的相似度,如我们常常使用的余弦相似度。
Word2Vec 词嵌入工具的提出正是为了解决上面这个问题,它将每个词表示成一个定长的向量,并通过在语料库上的预训练使得这些向量能较好地表达不同词之间的相似和类比关系,以引入一定的语义信息。基于两种概率模型的假设,我们可以定义两种 Word2Vec 模型:
- Skip-Gram 跳字模型:假设背景词由中心词生成,即建模 P(wo∣wc)P(w_o\mid w_c)P(wo∣wc),其中 wcw_cwc 为中心词,wow_owo 为任一背景词;
- CBOW (continuous bag-of-words) 连续词袋模型:假设中心词由背景词生成,即建模 P(wc∣Wo)P(w_c\mid \mathcal{W}_o)P(wc∣Wo),其中 Wo\mathcal{W}_oWo 为背景词的集合。
在这里我们主要介绍 Skip-Gram 模型的实现,CBOW 实现与其类似,读者可之后自己尝试实现。后续的内容将大致从以下四个部分展开:
- PTB 数据集
- Skip-Gram 跳字模型
- 负采样近似
- 训练模型
import collections
import math
import random
import sys
import time
import os
import numpy as np
import torch
from torch import nn
import torch.utils.data as Data
PTB 数据集
简单来说,Word2Vec 能从语料中学到如何将离散的词映射为连续空间中的向量,并保留其语义上的相似关系。那么为了训练 Word2Vec 模型,我们就需要一个自然语言语料库,模型将从中学习各个单词间的关系,这里我们使用经典的 PTB 语料库进行训练。PTB (Penn Tree Bank) 是一个常用的小型语料库,它采样自《华尔街日报》的文章,包括训练集、验证集和测试集。我们将在PTB训练集上训练词嵌入模型。
载入数据集
数据集训练文件 ptb.train.txt 示例:
aer banknote berlitz calloway centrust cluett fromstein gitano guterman ...
pierre N years old will join the board as a nonexecutive director nov. N
mr. is chairman of n.v. the dutch publishing group
...
with open('/home/kesci/input/ptb_train1020/ptb.train.txt', 'r') as f:
lines = f.readlines() # 该数据集中句子以换行符为分割
raw_dataset = [st.split() for st in lines] # st是sentence的缩写,单词以空格为分割
print('# sentences: %d' % len(raw_dataset))
# 对于数据集的前3个句子,打印每个句子的词数和前5个词
# 句尾符为 '' ,生僻词全用 '' 表示,数字则被替换成了 'N'
for st in raw_dataset[:3]:
print('# tokens:', len(st), st[:5])
# sentences: 42068
# tokens: 24 ['aer', 'banknote', 'berlitz', 'calloway', 'centrust']
# tokens: 15 ['pierre', '<unk>', 'N', 'years', 'old']
# tokens: 11 ['mr.', '<unk>', 'is', 'chairman', 'of']
建立词语索引
counter = collections.Counter([tk for st in raw_dataset for tk in st]) # tk是token的缩写
counter = dict(filter(lambda x: x[1] >= 5, counter.items())) # 只保留在数据集中至少出现5次的词
idx_to_token = [tk for tk, _ in counter.items()]
token_to_idx = {tk: idx for idx, tk in enumerate(idx_to_token)}
dataset = [[token_to_idx[tk] for tk in st if tk in token_to_idx]
for st in raw_dataset] # raw_dataset中的单词在这一步被转换为对应的idx
num_tokens = sum([len(st) for st in dataset])
'# tokens: %d' % num_tokens
'# tokens: 887100'
二次采样
文本数据中一般会出现一些高频词,如英文中的“the”“a”和“in”。通常来说,在一个背景窗口中,一个词(如“chip”)和较低频词(如“microprocessor”)同时出现比和较高频词(如“the”)同时出现对训练词嵌入模型更有益。因此,训练词嵌入模型时可以对词进行二次采样。 具体来说,数据集中每个被索引词 wiw_iwi 将有一定概率被丢弃,该丢弃概率为
$$
P(w_i)=\max(1-\sqrt{\frac{t}{f(w_i)}},0)
$$
其中 f(wi)f(w_i)f(wi) 是数据集中词 wiw_iwi 的个数与总词数之比,常数 ttt 是一个超参数(实验中设为 10−410^{−4}10−4)。可见,只有当 f(wi)>tf(w_i)>tf(wi)>t 时,我们才有可能在二次采样中丢弃词 wiw_iwi,并且越高频的词被丢弃的概率越大。具体的代码如下:
def discard(idx):
'''
@params:
idx: 单词的下标
@return: True/False 表示是否丢弃该单词
'''
return random.uniform(0, 1) < 1 - math.sqrt(
1e-4 / counter[idx_to_token[idx]] * num_tokens)
subsampled_dataset = [[tk for tk in st if not discard(tk)] for st in dataset]
print('# tokens: %d' % sum([len(st) for st in subsampled_dataset]))
def compare_counts(token):
return '# %s: before=%d, after=%d' % (token, sum(
[st.count(token_to_idx[token]) for st in dataset]), sum(
[st.count(token_to_idx[token]) for st in subsampled_dataset]))
print(compare_counts('the'))
print(compare_counts('join'))
# tokens: 375995
# the: before=50770, after=2161
# join: before=45, after=45
提取中心词和背景词
def get_centers_and_contexts(dataset, max_window_size):
'''
@params:
dataset: 数据集为句子的集合,每个句子则为单词的集合,此时单词已经被转换为相应数字下标
max_window_size: 背景词的词窗大小的最大值
@return:
centers: 中心词的集合
contexts: 背景词窗的集合,与中心词对应,每个背景词窗则为背景词的集合
'''
centers, contexts = [], []
for st in dataset:
if len(st) < 2: # 每个句子至少要有2个词才可能组成一对“中心词-背景词”
continue
centers += st
for center_i in range(len(st)):
window_size = random.randint(1, max_window_size) # 随机选取背景词窗大小
indices = list(range(max(0, center_i - window_size),
min(len(st), center_i + 1 + window_size)))
indices.remove(center_i) # 将中心词排除在背景词之外
contexts.append([st[idx] for idx in indices])
return centers, contexts
all_centers, all_contexts = get_centers_and_contexts(subsampled_dataset, 5)
tiny_dataset = [list(range(7)), list(range(7, 10))]
print('dataset', tiny_dataset)
for center, context in zip(*get_centers_and_contexts(tiny_dataset, 2)):
print('center', center, 'has contexts', context)
dataset [[0, 1, 2, 3, 4, 5, 6], [7, 8, 9]]
center 0 has contexts [1, 2]
center 1 has contexts [0, 2, 3]
center 2 has contexts [0, 1, 3, 4]
center 3 has contexts [2, 4]
center 4 has contexts [3, 5]
center 5 has contexts [4, 6]
center 6 has contexts [5]
center 7 has contexts [8]
center 8 has contexts [7, 9]
center 9 has contexts [7, 8]
注:数据批量读取的实现需要依赖负采样近似的实现,故放于负采样近似部分进行讲解。
Skip-Gram 跳字模型
在跳字模型中,每个词被表示成两个 ddd 维向量,用来计算条件概率。假设这个词在词典中索引为 iii ,当它为中心词时向量表示为 vi∈Rd\boldsymbol{v}_i\in\mathbb{R}^dvi∈Rd,而为背景词时向量表示为 ui∈Rd\boldsymbol{u}_i\in\mathbb{R}^dui∈Rd 。设中心词 wcw_cwc 在词典中索引为 ccc,背景词 wow_owo 在词典中索引为 ooo,我们假设给定中心词生成背景词的条件概率满足下式:
$$
P(w_o\mid w_c)=\frac{\exp(\boldsymbol{u}_o^\top \boldsymbol{v}c)}{\sum{i\in\mathcal{V}}\exp(\boldsymbol{u}_i^\top \boldsymbol{v}_c)}
$$
PyTorch 预置的 Embedding 层
embed = nn.Embedding(num_embeddings=10, embedding_dim=4)
print(embed.weight)
x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long)
print(embed(x))
Parameter containing:
tensor([[-0.7417, -1.9469, -0.5745, 1.4267],
[ 1.1483, 1.4781, 0.3064, -0.2893],
[ 0.6840, 2.4566, -0.1872, -2.2061],
[ 0.3386, 1.3820, -0.3142, 0.2427],
[ 0.4802, -0.6375, -0.4730, 1.2114],
[ 0.7130, -0.9774, 0.5321, 1.4228],
[-0.6726, -0.5829, -0.4888, -0.3290],
[ 0.3152, -0.6827, 0.9950, -0.3326],
[-1.4651, 1.2344, 1.9976, -1.5962],
[ 0.0872, 0.0130, -2.1396, -0.6361]], requires_grad=True)
tensor([[[ 1.1483, 1.4781, 0.3064, -0.2893],
[ 0.6840, 2.4566, -0.1872, -2.2061],
[ 0.3386, 1.3820, -0.3142, 0.2427]],
[[ 0.4802, -0.6375, -0.4730, 1.2114],
[ 0.7130, -0.9774, 0.5321, 1.4228],
[-0.6726, -0.5829, -0.4888, -0.3290]]], grad_fn=<EmbeddingBackward>)
PyTorch 预置的批量乘法
X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
print(torch.bmm(X, Y).shape)
torch.Size([2, 1, 6])
Skip-Gram 模型的前向计算
def skip_gram(center, contexts_and_negatives, embed_v, embed_u):
'''
@params:
center: 中心词下标,形状为 (n, 1) 的整数张量
contexts_and_negatives: 背景词和噪音词下标,形状为 (n, m) 的整数张量
embed_v: 中心词的 embedding 层
embed_u: 背景词的 embedding 层
@return:
pred: 中心词与背景词(或噪音词)的内积,之后可用于计算概率 p(w_o|w_c)
'''
v = embed_v(center) # shape of (n, 1, d)
u = embed_u(contexts_and_negatives) # shape of (n, m, d)
pred = torch.bmm(v, u.permute(0, 2, 1)) # bmm((n, 1, d), (n, d, m)) => shape of (n, 1, m)
return pred
负采样近似
由于 softmax 运算考虑了背景词可能是词典 V\mathcal{V}V 中的任一词,对于含几十万或上百万词的较大词典,就可能导致计算的开销过大。我们将以 skip-gram 模型为例,介绍负采样 (negative sampling) 的实现来尝试解决这个问题。
负采样方法用以下公式来近似条件概率 P(wo∣wc)=exp(uo⊤vc)∑i∈Vexp(ui⊤vc)P(w_o\mid w_c)=\frac{\exp(\boldsymbol{u}_o^\top \boldsymbol{v}_c)}{\sum_{i\in\mathcal{V}}\exp(\boldsymbol{u}_i^\top \boldsymbol{v}_c)}P(wo∣wc)=∑i∈Vexp(ui⊤vc)exp(uo⊤vc):
$$
P(w_o\mid w_c)=P(D=1\mid w_c,w_o)\prod_{k=1,w_k\sim P(w)}^K P(D=0\mid w_c,w_k)
$$
其中 P(D=1∣wc,wo)=σ(uo⊤vc)P(D=1\mid w_c,w_o)=\sigma(\boldsymbol{u}_o^\top\boldsymbol{v}_c)P(D=1∣wc,wo)=σ(uo⊤vc),σ(⋅)\sigma(\cdot)σ(⋅) 为 sigmoid 函数。对于一对中心词和背景词,我们从词典中随机采样 KKK 个噪声词(实验中设 K=5K=5K=5)。根据 Word2Vec 论文的建议,噪声词采样概率 P(w)P(w)P(w) 设为 www 词频与总词频之比的 0.750.750.75 次方。
def get_negatives(all_contexts, sampling_weights, K):
'''
@params:
all_contexts: [[w_o1, w_o2, ...], [...], ... ]
sampling_weights: 每个单词的噪声词采样概率
K: 随机采样个数
@return:
all_negatives: [[w_n1, w_n2, ...], [...], ...]
'''
all_negatives, neg_candidates, i = [], [], 0
population = list(range(len(sampling_weights)))
for contexts in all_contexts:
negatives = []
while len(negatives) < len(contexts) * K:
if i == len(neg_candidates):
# 根据每个词的权重(sampling_weights)随机生成k个词的索引作为噪声词。
# 为了高效计算,可以将k设得稍大一点
i, neg_candidates = 0, random.choices(
population, sampling_weights, k=int(1e5))
neg, i = neg_candidates[i], i + 1
# 噪声词不能是背景词
if neg not in set(contexts):
negatives.append(neg)
all_negatives.append(negatives)
return all_negatives
sampling_weights = [counter[w]**0.75 for w in idx_to_token]
all_negatives = get_negatives(all_contexts, sampling_weights, 5)
注:除负采样方法外,还有层序 softmax (hiererarchical softmax) 方法也可以用来解决计算量过大的问题,请参考原书10.2.2节。
批量读取数据
class MyDataset(torch.utils.data.Dataset):
def __init__(self, centers, contexts, negatives):
assert len(centers) == len(contexts) == len(negatives)
self.centers = centers
self.contexts = contexts
self.negatives = negatives
def __getitem__(self, index):
return (self.centers[index], self.contexts[index], self.negatives[index])
def __len__(self):
return len(self.centers)
def batchify(data):
'''
用作DataLoader的参数collate_fn
@params:
data: 长为batch_size的列表,列表中的每个元素都是__getitem__得到的结果
@outputs:
batch: 批量化后得到 (centers, contexts_negatives, masks, labels) 元组
centers: 中心词下标,形状为 (n, 1) 的整数张量
contexts_negatives: 背景词和噪声词的下标,形状为 (n, m) 的整数张量
masks: 与补齐相对应的掩码,形状为 (n, m) 的0/1整数张量
labels: 指示中心词的标签,形状为 (n, m) 的0/1整数张量
'''
max_len = max(len(c) + len(n) for _, c, n in data)
centers, contexts_negatives, masks, labels = [], [], [], []
for center, context, negative in data:
cur_len = len(context) + len(negative)
centers += [center]
contexts_negatives += [context + negative + [0] * (max_len - cur_len)]
masks += [[1] * cur_len + [0] * (max_len - cur_len)] # 使用掩码变量mask来避免填充项对损失函数计算的影响
labels += [[1] * len(context) + [0] * (max_len - len(context))]
batch = (torch.tensor(centers).view(-1, 1), torch.tensor(contexts_negatives),
torch.tensor(masks), torch.tensor(labels))
return batch
batch_size = 512
num_workers = 0 if sys.platform.startswith('win32') else 4
dataset = MyDataset(all_centers, all_contexts, all_negatives)
data_iter = Data.DataLoader(dataset, batch_size, shuffle=True,
collate_fn=batchify,
num_workers=num_workers)
for batch in data_iter:
for name, data in zip(['centers', 'contexts_negatives', 'masks',
'labels'], batch):
print(name, 'shape:', data.shape)
break
centers shape: torch.Size([512, 1])
contexts_negatives shape: torch.Size([512, 60])
masks shape: torch.Size([512, 60])
labels shape: torch.Size([512, 60])
训练模型
损失函数
应用负采样方法后,我们可利用最大似然估计的对数等价形式将损失函数定义为如下
$$
\sum_{t=1}^T\sum_{-m\le j\le m,j\ne 0} [-\log P(D=1\mid w{(t)},w{(t+j)})-\sum_{k=1,w_k\sim P(w)^K}\log P(D=0\mid w^{(t)},w_k)]
$$
根据这个损失函数的定义,我们可以直接使用二元交叉熵损失函数进行计算:
class SigmoidBinaryCrossEntropyLoss(nn.Module):
def __init__(self):
super(SigmoidBinaryCrossEntropyLoss, self).__init__()
def forward(self, inputs, targets, mask=None):
'''
@params:
inputs: 经过sigmoid层后为预测D=1的概率
targets: 0/1向量,1代表背景词,0代表噪音词
@return:
res: 平均到每个label的loss
'''
inputs, targets, mask = inputs.float(), targets.float(), mask.float()
res = nn.functional.binary_cross_entropy_with_logits(inputs, targets, reduction="none", weight=mask)
res = res.sum(dim=1) / mask.float().sum(dim=1)
return res
loss = SigmoidBinaryCrossEntropyLoss()
pred = torch.tensor([[1.5, 0.3, -1, 2], [1.1, -0.6, 2.2, 0.4]])
label = torch.tensor([[1, 0, 0, 0], [1, 1, 0, 0]]) # 标签变量label中的1和0分别代表背景词和噪声词
mask = torch.tensor([[1, 1, 1, 1], [1, 1, 1, 0]]) # 掩码变量
print(loss(pred, label, mask))
def sigmd(x):
return - math.log(1 / (1 + math.exp(-x)))
print('%.4f' % ((sigmd(1.5) + sigmd(-0.3) + sigmd(1) + sigmd(-2)) / 4)) # 注意1-sigmoid(x) = sigmoid(-x)
print('%.4f' % ((sigmd(1.1) + sigmd(-0.6) + sigmd(-2.2)) / 3))
模型初始化
embed_size = 100
net = nn.Sequential(nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size),
nn.Embedding(num_embeddings=len(idx_to_token), embedding_dim=embed_size))
训练模型
def train(net, lr, num_epochs):
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print("train on", device)
net = net.to(device)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
for epoch in range(num_epochs):
start, l_sum, n = time.time(), 0.0, 0
for batch in data_iter:
center, context_negative, mask, label = [d.to(device) for d in batch]
pred = skip_gram(center, context_negative, net[0], net[1])
l = loss(pred.view(label.shape), label, mask).mean() # 一个batch的平均loss
optimizer.zero_grad()
l.backward()
optimizer.step()
l_sum += l.cpu().item()
n += 1
print('epoch %d, loss %.2f, time %.2fs'
% (epoch + 1, l_sum / n, time.time() - start))
train(net, 0.01, 5)
train on cpu
epoch 1, loss 0.61, time 221.30s
epoch 2, loss 0.42, time 227.70s
epoch 3, loss 0.38, time 240.50s
epoch 4, loss 0.36, time 253.79s
epoch 5, loss 0.34, time 238.51s
注:由于本地CPU上训练时间过长,故只截取了运行的结果,后同。大家可以自行在网站上训练。
测试模型
def get_similar_tokens(query_token, k, embed):
'''
@params:
query_token: 给定的词语
k: 近义词的个数
embed: 预训练词向量
'''
W = embed.weight.data
x = W[token_to_idx[query_token]]
# 添加的1e-9是为了数值稳定性
cos = torch.matmul(W, x) / (torch.sum(W * W, dim=1) * torch.sum(x * x) + 1e-9).sqrt()
_, topk = torch.topk(cos, k=k+1)
topk = topk.cpu().numpy()
for i in topk[1:]: # 除去输入词
print('cosine sim=%.3f: %s' % (cos[i], (idx_to_token[i])))
get_similar_tokens('chip', 3, net[0])
cosine sim=0.446: intel
cosine sim=0.427: computer
cosine sim=0.427: computers
参考
- Dive into Deep Learning. Ch14.1-14.4.
- 动手学深度学习. Ch10.1-10.3.
- Dive-into-DL-PyTorch on GitHub
L24 word2vec的更多相关文章
- word2vec 中的数学原理详解
word2vec 是 Google 于 2013 年开源推出的一个用于获取 word vector 的工具包,它简单.高效,因此引起了很多人的关注.由于 word2vec 的作者 Tomas Miko ...
- Java豆瓣电影爬虫——使用Word2Vec分析电影短评数据
在上篇实现了电影详情和短评数据的抓取.到目前为止,已经抓了2000多部电影电视以及20000多的短评数据. 数据本身没有规律和价值,需要通过分析提炼成知识才有意义.抱着试试玩的想法,准备做一个有关情感 ...
- word2vec参数调整 及lda调参
一.word2vec调参 ./word2vec -train resultbig.txt -output vectors.bin -cbow 0 -size 200 -window 5 -neg ...
- [Algorithm & NLP] 文本深度表示模型——word2vec&doc2vec词向量模型
深度学习掀开了机器学习的新篇章,目前深度学习应用于图像和语音已经产生了突破性的研究进展.深度学习一直被人们推崇为一种类似于人脑结构的人工智能算法,那为什么深度学习在语义分析领域仍然没有实质性的进展呢? ...
- Word2Vec 使用总结
word2vec 是google 推出的做词嵌入(word embedding)的开源工具. 简单的说,它在给定的语料库上训练一个模型,然后会输出所有出现在语料库上的单词的向量表示,这个向量称为&qu ...
- Word2vec多线程(tensorflow)
workers = [] for _ in xrange(opts.concurrent_steps): t = threading.Thread(target=self._train_thread_ ...
- Word2vec 模型载入(tensorflow)
opts = Options() with tf.Graph().as_default(), tf.Session() as session: model = Word2Vec(opts, sessi ...
- Forward-backward梯度求导(tensorflow word2vec实例)
考虑不可分的例子 通过使用basis functions 使得不可分的线性模型变成可分的非线性模型 最常用的就是写出一个目标函数 并且使用梯度下降法 来计算 梯度的下降法的梯度 ...
- Tensorflow word2vec编译运行
Word2vec 更完整版本(非demo)的代码在 tensorflow/models/embedding/ 首先需要安装bazel 来进行编译 bazel可以下载最新的binary安装文件, ...
随机推荐
- [kmp,不要过多调用strlen!!!] Codeforces 1200E Compress Words
题目:http://codeforces.com/contest/1200/problem/E Compress Words time limit per test 1 second memory l ...
- SpringBoot项目中应用Jedis和一些常见配置
优雅的使用Jedis Redis的Java客户端有很多,Jedis是其中使用比较广泛和性能比较稳定的一个.并且其API和RedisAPI命名风格类似,推荐大家使用 在项目中引入Jedis 可以通过Ma ...
- C 实战练习题目3
题目:一个整数,它加上100后是一个完全平方数,再加上168又是一个完全平方数,请问该数是多少? 程序分析: 假设该数为 x. 1.则:x + 100 = n2, x + 100 + 168 = m2 ...
- OpenCV-Python 特征匹配 + 单应性查找对象 | 四十五
目标 在本章节中,我们将把calib3d模块中的特征匹配和findHomography混合在一起,以在复杂图像中找到已知对象. 基础 那么我们在上一环节上做了什么?我们使用了queryImage,找到 ...
- 使用Keras进行深度学习:(一)Keras 入门
欢迎大家关注我们的网站和系列教程:http://www.tensorflownews.com/,学习更多的机器学习.深度学习的知识! Keras是Python中以CNTK.Tensorflow或者Th ...
- iOS UITableView优化
一.Cell 复用 在可见的页面会重复绘制页面,每次刷新显示都会去创建新的 Cell,非常耗费性能. 解决方案:创建一个静态变量 reuseID,防止重复创建(提高性能),使用系统的缓存池功能. s ...
- Java程序员必读的9本书
本文列出的9本书在Java程序员界都是被认为很棒的书.当一个程序员开始初学Java时,他的第一个问题应该是如何选择一本书来作为指导学习Java.这个问题也就表明,相对于其他的教程和博客,Java书籍还 ...
- java程序:转化金额
在处理财务账款时,需要将转账金额写成大写的.也就是说,如果要转账123456.00元,则需要写成“壹拾贰万叁仟肆佰伍拾陆元整”.所以常常需要通过程序控制自动进行转换.本实例实现了小写金额到大写金额的转 ...
- 【PHP源码】PHP 函数调用
title: [PHP 源码]PHP 函数调用 date: 2020-03-30 23:25:00 updated: 2020-04-04 19:57:00 tags: PHP 源码 想法 我以前对于 ...
- Sql练习201908200916
表结构: 一条sql修改多条数据(Sql server),oracle,mysql请自行尝试: ; go 完成.