pytorch-word2vec的实例实现
word2vec的實例實現
實現詞嵌入word2vec中的跳字模型和近似訓練中的負采樣以及二次采樣(subsampling),在語料庫上訓練詞嵌入模型的實現。
首先導入實驗所需的包或模塊。
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 import osprint(torch.__version__)處理數據集
PTB(Penn Tree Bank)是一個常用的小型語料庫 。它采樣自《華爾街日報》的文章,包括訓練集、驗證集和測試集。我們將在PTB訓練集上訓練詞嵌入模型。該數據集的每一行作為一個句子。句子中的每個詞由空格隔開。
assert 'ptb.train.txt' in os.listdir("../ref/data/ptb")with open('../ref/data/ptb/ptb.train.txt', 'r') as f:lines = f.readlines()# st是sentence的縮寫raw_dataset = [st.split() for st in lines]'# sentences: %d' % len(raw_dataset) # 輸出 '# sentences: 42068'輸出:
'# sentences: 42068'對于數據集的前5個句子,打印每個句子的詞數和前5個詞。這個數據集中句尾符為"",生僻詞全用"“表示,數字則被替換成了"N”。
for st in raw_dataset[:5]:print('# tokens:', len(st), st[:5]) # tokens: 24 ['aer', 'banknote', 'berlitz', 'calloway', 'centrust'] # tokens: 15 ['pierre', '<unk>', 'N', 'years', 'old'] # tokens: 11 ['mr.', '<unk>', 'is', 'chairman', 'of'] # tokens: 23 ['rudolph', '<unk>', 'N', 'years', 'old'] # tokens: 34 ['a', 'form', 'of', 'asbestos', 'once']建立詞語索引
只保留在數據集中至少出現5次的詞。
# tk是token的縮寫 counter = collections.Counter([tk for st in raw_dataset for tk in st]) counter = dict(filter(lambda x: x[1] >= 5, counter.items()))然后將詞映射到整數索引。
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] num_tokens = sum([len(st) for st in dataset]) '# tokens: %d' % num_tokens # 輸出 '# tokens: 887100'二次采樣
文本數據中一般會出現一些高頻詞,如英文中的“the”“a”和“in”。通常來說,在一個背景窗口中,一個詞(如“chip”)和較低頻詞(如“microprocessor”)同時出現比和較高頻詞(如“the”)同時出現對訓練詞嵌入模型更有益。因此,訓練詞嵌入模型時可以對詞進行二次采樣 [2]。 具體來說,數據集中每個被索引詞wiw_iwi?將有一定概率被丟棄,該丟棄概率為
P(wi)=max?(1?tf(wi),0)P(w_i) = \max\left(1 - \sqrt{\frac{t}{f(w_i)}}, 0\right)P(wi?)=max(1?f(wi?)t??,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):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] '# tokens: %d' % sum([len(st) for st in subsampled_dataset]) # '# tokens: 375875'二次采樣后去掉了一半左右的詞。下面比較一個詞在二次采樣前后出現在數據集中的次數。可見高頻詞“the”的采樣率不足1/20。
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]))compare_counts('the') # '# the: before=50770, after=2013'低頻詞“join”則完整地保留了下來。
compare_counts('join') # '# join: before=45, after=45'提取中心詞和背景詞
我們將與中心詞距離不超過背景窗口大小的詞作為它的背景詞。下面定義函數提取出所有中心詞和它們的背景詞。它每次在整數1和max_window_size(最大背景窗口)之間隨機均勻采樣一個整數作為背景窗口大小。
def get_centers_and_contexts(dataset, max_window_size):centers, contexts = [], []for st in dataset:if len(st) < 2: # 每個句子至少要有2個詞才可能組成一對“中心詞-背景詞”continuecenters += stfor 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創建一個人工數據集,其中含有詞數分別為7和3的兩個句子。設最大背景窗口為2,打印所有中心詞和它們的背景詞。
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] center 1 has contexts [0, 2] center 2 has contexts [0, 1, 3, 4] center 3 has contexts [2, 4] center 4 has contexts [3, 5] center 5 has contexts [3, 4, 6] center 6 has contexts [5] center 7 has contexts [8, 9] center 8 has contexts [7, 9] center 9 has contexts [8]設最大背景窗口大小為5。提取數據集中所有的中心詞及其背景詞。
all_centers, all_contexts = get_centers_and_contexts(subsampled_dataset, 5)使用負采樣來進行近似訓練。對于一對中心詞和背景詞,我們隨機采樣KKK個噪聲詞(實驗中設K=5K=5K=5)。根據word2vec論文的建議,噪聲詞采樣概率P(w)P(w)P(w)設為www詞頻與總詞頻之比的0.75次方
def get_negatives(all_contexts, sampling_weights, K):all_negatives, neg_candidates, i = [], [], 0population = list(range(len(sampling_weights)))for contexts in all_contexts:negatives = []while len(negatives) < len(contexts) * K: #K negative for one backgroundif i == len(neg_candidates):# 根據每個詞的權重(sampling_weights)隨機生成k個詞的索引作為噪聲詞。# 為了高效計算,可以將k設得稍大一點i, neg_candidates = 0, random.choices(population, sampling_weights, k=int(1e5)) #參數weights設置相對權重,它的值是一個列表,設置之后,每一個成員被抽取到的概率就被確定了。neg, i = neg_candidates[i], i + 1# 噪聲詞不能是背景詞if neg not in set(contexts):negatives.append(neg) #select negative from 10**5 candidatesall_negatives.append(negatives) return all_negativessampling_weights = [counter[w]**0.75 for w in idx_to_token] all_negatives = get_negatives(all_contexts, sampling_weights, 5)這里展示一下K與背景詞數量、干擾詞數量的關系:
K = 5 print(" {} * len of ({}) = len of({})".format(K, all_contexts[0], all_negatives[0]))讀取數據
從數據集中提取所有中心詞all_centers,以及每個中心詞對應的背景詞all_contexts和噪聲詞all_negatives。我們先定義一個Dataset類。
class MyDataset(torch.utils.data.Dataset):def __init__(self, centers, contexts, negatives):assert len(centers) == len(contexts) == len(negatives)#trigger for exceptionself.centers = centersself.contexts = contextsself.negatives = negativesdef __getitem__(self, index):return (self.centers[index], self.contexts[index], self.negatives[index])def __len__(self):return len(self.centers)通過隨機小批量來讀取它們。在一個小批量數據中,第iii個樣本包括一個中心詞以及它所對應的nin_ini?個背景詞和mim_imi?個噪聲詞。
- 由于每個樣本的背景窗口大小可能不一樣,其中背景詞與噪聲詞個數之和ni+min_i+m_ini?+mi?也會不同。
- 在構造小批量時,我們將每個樣本的背景詞和噪聲詞連結在一起,并添加填充項0直至連結后的長度相同,即長度均為max?ini+mi\max_i n_i+m_imaxi?ni?+mi?(max_len變量)。
- 為了避免填充項對損失函數計算的影響,我們構造了掩碼變量masks,其每一個元素分別與連結后的背景詞和噪聲詞contexts_negatives中的元素一一對應。
- 當contexts_negatives變量中的某個元素為填充項時,相同位置的掩碼變量masks中的元素取0,否則取1。
- 為了區分正類和負類,我們還需要將contexts_negatives變量中的背景詞和噪聲詞區分開來。
- 依據掩碼變量的構造思路,我們只需創建與contexts_negatives變量形狀相同的標簽變量labels,并將與背景詞(正類)對應的元素設1,其余清0。
下面我們實現這個小批量讀取函數batchify。它的小批量輸入data是一個長度為批量大小的列表,其中每個元素分別包含中心詞center、背景詞context和噪聲詞negative。該函數返回的小批量數據符合我們需要的格式,例如,包含了掩碼變量。
def batchify(data):"""用作DataLoader的參數collate_fn: 輸入是個長為batchsize的list, list中的每個元素都是Dataset類調用__getitem__得到的結果"""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)]labels += [[1] * len(context) + [0] * (max_len - len(context))]return (torch.tensor(centers).view(-1, 1), torch.tensor(contexts_negatives),torch.tensor(masks), torch.tensor(labels))用剛剛定義的batchify函數指定DataLoader實例中小批量的讀取方式,然后打印讀取的第一個批量中各個變量的形狀。
batch_size = 512 num_workers = 0 if sys.platform.startswith('win32') else 4dataset = MyDataset(all_centers, all_contexts, all_negatives) data_iter = Data.DataLoader(dataset, batch_size, shuffle=True, #set a dataset forcollate_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])跳字模型
通過使用嵌入層和小批量乘法來實現跳字模型。它們也常常用于實現其他自然語言處理的應用。
嵌入層
獲取詞嵌入的層稱為嵌入層,在PyTorch中可以通過創建nn.Embedding實例得到。嵌入層的權重是一個矩陣,其行數為詞典大小(num_embeddings),列數為每個詞向量的維度(embedding_dim)。我們設詞典大小為20,詞向量的維度為4。
embed = nn.Embedding(num_embeddings=20, embedding_dim=4) embed.weight輸出:
Parameter containing: tensor([[ 1.4661, -0.0863, -0.7256, -0.4591],[-0.1848, 0.1527, -0.9891, 1.3739],[ 1.6892, -0.8399, -0.1476, -0.6153],[ 1.6853, -0.0610, 1.8559, 0.6242],[-1.5009, 0.2730, -0.3688, 0.5599],[-1.3175, 0.9324, 0.0477, 0.6728],[ 0.0316, 0.9806, -0.6857, 1.2622],[-1.4576, -0.9973, -0.1076, 0.0197],[-1.4366, -0.8724, -0.0563, 1.2543],[-1.5040, 0.4426, -0.6406, 0.0243],[-1.4785, 1.2112, -0.2328, 1.2140],[-2.2098, -0.3141, 0.3318, -1.0206],[ 1.2869, 0.9700, 2.3408, 0.2634],[-0.7229, -0.1446, 0.5023, 1.4011],[ 0.5258, 0.7519, 0.5948, 1.2724],[-0.4999, -1.9596, -1.9181, 1.3039],[-0.5907, 0.0956, -1.3246, -0.1742],[-1.0380, -0.1586, -0.8281, 0.1813],[ 0.7077, 0.4264, 1.7595, 1.1582],[-0.5711, 0.0796, 0.7719, -0.5790]], requires_grad=True)嵌入層的輸入為詞的索引。輸入一個詞的索引iii,嵌入層返回權重矩陣的第iii行作為它的詞向量。下面我們將形狀為(2, 3)的索引輸入進嵌入層,由于詞向量的維度為4,我們得到形狀為(2, 3, 4)的詞向量。
x = torch.tensor([[1, 2, 3], [4, 5, 6]], dtype=torch.long) embed(x)輸出:
tensor([[[-0.1848, 0.1527, -0.9891, 1.3739],[ 1.6892, -0.8399, -0.1476, -0.6153],[ 1.6853, -0.0610, 1.8559, 0.6242]],[[-1.5009, 0.2730, -0.3688, 0.5599],[-1.3175, 0.9324, 0.0477, 0.6728],[ 0.0316, 0.9806, -0.6857, 1.2622]]], grad_fn=<EmbeddingBackward>)小批量乘法
可以使用小批量乘法運算bmm對兩個小批量中的矩陣一一做乘法。
- 假設第一個小批量中包含nnn個形狀為a×ba\times ba×b的矩陣X1,…,Xn\boldsymbol{X}_1, \ldots, \boldsymbol{X}_nX1?,…,Xn?
- 第二個小批量中包含nnn個形狀為b×cb\times cb×c的矩陣Y1,…,Yn\boldsymbol{Y}_1, \ldots, \boldsymbol{Y}_nY1?,…,Yn?。
- 這兩個小批量的矩陣乘法輸出為nnn個形狀為a×ca\times ca×c的矩陣X1Y1,…,XnYn\boldsymbol{X}_1\boldsymbol{Y}_1, \ldots, \boldsymbol{X}_n\boldsymbol{Y}_nX1?Y1?,…,Xn?Yn?。
因此,給定兩個形狀分別為(nnn, aaa, bbb)和(nnn, bbb, ccc)的Tensor,小批量乘法輸出的形狀為(nnn, aaa, ccc)。
X = torch.ones((2, 1, 4)) Y = torch.ones((2, 4, 6)) torch.bmm(X, Y).shape輸出:
X = torch.ones((2, 1, 4)) Y = torch.ones((2, 4, 6)) torch.bmm(X, Y).shape跳字模型前向計算
在前向計算中,跳字模型的輸入包含中心詞索引center以及連結的背景詞與噪聲詞索引contexts_and_negatives。其中center變量的形狀為(批量大小, 1),而contexts_and_negatives變量的形狀為(批量大小, max_len)。這兩個變量先通過詞嵌入層分別由詞索引變換為詞向量,再通過小批量乘法得到形狀為(批量大小, 1, max_len)的輸出。輸出中的每個元素是中心詞向量與背景詞向量或噪聲詞向量的內積。
def skip_gram(center, contexts_and_negatives, embed_v, embed_u):v = embed_v(center)#(batch_size, 1)u = embed_u(contexts_and_negatives)#(batch_size, max_len)pred = torch.bmm(v, u.permute(0, 2, 1))return pred簡單說一下Tensor.permute的作用:
permute就是更改Tensor的維度,下面的例子將Tensor的二、三維度做了交換
輸出
tensor([[[ 0.4602, 0.1497, -0.4955, -0.6957, 0.0581],[-0.5020, -0.6797, -0.6404, 2.0036, 0.5790],[-0.0533, -0.4460, -0.2509, 0.1712, -1.3488]],[[-0.3313, 0.7201, 0.2478, -1.6327, -0.4580],[ 0.5060, -0.1724, 2.7267, 0.1494, -0.3988],[-1.8063, -0.5025, -0.3524, 0.4211, 1.5029]]]) tensor([[[ 0.4602, -0.5020, -0.0533],[ 0.1497, -0.6797, -0.4460],[-0.4955, -0.6404, -0.2509],[-0.6957, 2.0036, 0.1712],[ 0.0581, 0.5790, -1.3488]],[[-0.3313, 0.5060, -1.8063],[ 0.7201, -0.1724, -0.5025],[ 0.2478, 2.7267, -0.3524],[-1.6327, 0.1494, 0.4211],[-0.4580, -0.3988, 1.5029]]])訓練模型
定義二元交叉熵損失函數
根據負采樣中損失函數的定義,我們可以使用二元交叉熵損失函數,
?log?P(w(t+j)∣w(t))=?log?P(D=1∣w(t),w(t+j))?∑k=1,wk~P(w)Klog?P(D=0∣w(t),wk)=?log?σ(uit+j?vit)?∑k=1,wk~P(w)Klog?(1?σ(uhk?vit))=?log?σ(uit+j?vit)?∑k=1,wk~P(w)Klog?σ(?uhk?vit).\begin{aligned} -\log P(w^{(t+j)} \mid w^{(t)}) =& -\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)\ \\ =&- \log \sigma\left(\boldsymbol{u}_{i_{t+j}}^\top \boldsymbol{v}_{i_t}\right) - \sum{k=1,\ w_k \sim P(w)}^K \log\left(1-\sigma\left(\boldsymbol{u}_{h_k}^\top \boldsymbol{v}_{i_t}\right)\right)\\\ =&- \log \sigma\left(\boldsymbol{u}_{i_{t+j}}^\top \boldsymbol{v}_{i_t}\right) - \sum{k=1,\ w_k \sim P(w)}^K \log\sigma\left(-\boldsymbol{u}_{h_k}^\top \boldsymbol{v}_{i_t}\right). \end{aligned} ?logP(w(t+j)∣w(t))==?=??logP(D=1∣w(t),w(t+j))?k=1,?wk?~P(w)∑K?logP(D=0∣w(t),wk?)??logσ(uit+j???vit??)?∑k=1,?wk?~P(w)Klog(1?σ(uhk???vit??))?logσ(uit+j???vit??)?∑k=1,?wk?~P(w)Klogσ(?uhk???vit??).?
下面定義SigmoidBinaryCrossEntropyLoss。
class SigmoidBinaryCrossEntropyLoss(nn.Module):def __init__(self): # none mean sumsuper(SigmoidBinaryCrossEntropyLoss, self).__init__()def forward(self, inputs, targets, mask=None):"""input – Tensor shape: (batch_size, len)target – Tensor of the same shape as input"""inputs, targets, mask = inputs.float(), targets.float(), mask.float()res = nn.functional.binary_cross_entropy_with_logits(inputs, targets, reduction="none", weight=mask)return res.mean(dim=1)loss = SigmoidBinaryCrossEntropyLoss()我們可以通過掩碼變量指定小批量中參與損失函數計算的部分預測值和標簽:
- 當掩碼為1時,相應位置的預測值和標簽將參與損失函數的計算;
- 當掩碼為0時,相應位置的預測值和標簽則不參與損失函數的計算。
我們之前提到,掩碼變量可用于避免填充項對損失函數計算的影響。
pred = torch.tensor([[1.5, 0.3, -1, 2], [1.1, -0.6, 2.2, 0.4]]) # 標簽變量label中的1和0分別代表背景詞和噪聲詞 label = torch.tensor([[1, 0, 0, 0], [1, 1, 0, 0]]) mask = torch.tensor([[1, 1, 1, 1], [1, 1, 1, 0]]) # 掩碼變量 loss(pred, label, mask) * mask.shape[1] / mask.float().sum(dim=1)輸出:
tensor([0.8740, 1.2100])下面將從零開始實現二元交叉熵損失函數的計算,并根據掩碼變量mask計算掩碼為1的預測值和標簽的損失。
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))輸出:
0.8740 1.2100發現結果和使用pytorch內置函數是一樣的
初始化模型參數
分別構造中心詞和背景詞的嵌入層,并將超參數詞向量維度embed_size設置成100。
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, 0for 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])# 使用掩碼變量mask來避免填充項對損失函數計算的影響l = (loss(pred.view(label.shape), label, mask) *mask.shape[1] / mask.float().sum(dim=1)).mean() # 一個batch的平均lossoptimizer.zero_grad()l.backward()optimizer.step()l_sum += l.cpu().item()n += 1print('epoch %d, loss %.2f, time %.2fs'% (epoch + 1, l_sum / n, time.time() - start)) train(net, 0.01, 10)輸出:
train on cuda epoch 1, loss 1.97, time 7.79s epoch 2, loss 0.63, time 7.28s epoch 3, loss 0.45, time 7.43s epoch 4, loss 0.40, time 7.62s epoch 5, loss 0.37, time 7.46s epoch 6, loss 0.35, time 7.39s epoch 7, loss 0.34, time 7.64s epoch 8, loss 0.33, time 7.60s epoch 9, loss 0.32, time 7.34s epoch 10, loss 0.32, time 7.38s應用詞嵌入模型
根據兩個詞向量的余弦相似度表示詞與詞之間在語義上的相似度
def get_similar_tokens(query_token, k, embed):W = embed.weight.datax = 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('news', 10, net[0])輸出:
cosine sim=0.424: hoffman cosine sim=0.398: daughter cosine sim=0.396: tokyo cosine sim=0.394: attributed cosine sim=0.376: slew cosine sim=0.373: steps cosine sim=0.370: pleased cosine sim=0.369: daily cosine sim=0.362: reviewing cosine sim=0.361: wednesday輸出了與”news“最相關的10個詞,按照相關度排序
總結
以上是生活随笔為你收集整理的pytorch-word2vec的实例实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 双路服务器单路运行,单路还是双路?看需求
- 下一篇: 从单亲家庭内向小男生到哈佛耶鲁全奖,百万