1. 詞嵌入(word2vec)
自然語言是一套用來表達(dá)含義的復(fù)雜系統(tǒng)栏笆。在這套系統(tǒng)中垃你,詞是表義的基本單元。顧名思義缭嫡,詞向量是用來表示詞的向量缔御,也可被認(rèn)為是詞的特征向量或表征。把詞映射為實(shí)數(shù)域向量的技術(shù)也叫詞嵌入(word embedding)妇蛀。
* 為何不采用One-short
雖然one-hot詞向量構(gòu)造起來很容易耕突,但通常并不是一個(gè)好選擇。一個(gè)主要的原因是评架,one-hot詞向量無法準(zhǔn)確表達(dá)不同詞之間的相似度眷茁,如我們常常使用的余弦相似度。對(duì)于向量 x,y∈?d 纵诞,它們的余弦相似度是它們之間夾角的余弦值上祈。
兩個(gè)向量間的余弦值可以通過使用歐幾里得點(diǎn)積公式求出:
由于任何兩個(gè)不同詞的one-hot向量的余弦相似度都為0,多個(gè)不同詞之間的相似度難以通過one-hot向量準(zhǔn)確地體現(xiàn)出來。相似性范圍從-1到1:-1意味著兩個(gè)向量指向的方向正好截然相反雇逞,1表示它們的指向是完全相同的,0通常表示它們之間是獨(dú)立的茁裙,而在這之間的值則表示中間的相似性或相異性塘砸。
對(duì)于文本匹配,屬性向量x和y通常是文檔中的詞頻向量晤锥。余弦相似性掉蔬,可以被看作是在比較過程中把文件長(zhǎng)度正規(guī)化的方法。
word2vec工具的提出正是為了解決上面這個(gè)問題矾瘾。它將每個(gè)詞表示成一個(gè)定長(zhǎng)的向量女轿,并使得這些向量能較好地表達(dá)不同詞之間的相似和類比關(guān)系。word2vec工具包含了兩個(gè)模型壕翩,即跳字模型(skip-gram)和連續(xù)詞袋模型(continuous bag of words蛉迹,CBOW)。CBOW對(duì)小型數(shù)據(jù)庫(kù)比較合適放妈,而Skip-Gram在大型語料中表現(xiàn)更好北救。
-
Skip-Gram 跳字模型:假設(shè)背景詞由中心詞生成,即建模 芜抒,其中 為中心詞珍策, 為任一背景詞;
CBOW (continuous bag-of-words) 連續(xù)詞袋模型:假設(shè)中心詞由背景詞生成宅倒,即建模 攘宙,其中 為背景詞的集合。
Skip-Gram 模型的實(shí)現(xiàn)與CBOW 實(shí)現(xiàn)類似拐迁,大致從以下四個(gè)部分展開:
- PTB 數(shù)據(jù)集
- Skip-Gram 跳字模型
- 負(fù)采樣近似
- 訓(xùn)練模型
這篇文章對(duì)Word2Vec的數(shù)學(xué)原理講解的不錯(cuò)
1. PTB 數(shù)據(jù)集
簡(jiǎn)單來說误窖,Word2Vec 能從語料中學(xué)到如何將離散的詞映射為連續(xù)空間中的向量痘番,并保留其語義上的相似關(guān)系。那么為了訓(xùn)練 Word2Vec 模型,我們就需要一個(gè)自然語言語料庫(kù)蔓同,模型將從中學(xué)習(xí)各個(gè)單詞間的關(guān)系,這里我們使用經(jīng)典的 PTB 語料庫(kù)進(jìn)行訓(xùn)練沧侥。PTB (Penn Tree Bank) 是一個(gè)常用的小型語料庫(kù)瞎疼,它采樣自《華爾街日?qǐng)?bào)》的文章,包括訓(xùn)練集割卖、驗(yàn)證集和測(cè)試集前酿。我們將在PTB訓(xùn)練集上訓(xùn)練詞嵌入模型。
載入數(shù)據(jù)集
數(shù)據(jù)集訓(xùn)練文件 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() # 該數(shù)據(jù)集中句子以換行符為分割
raw_dataset = [st.split() for st in lines] # st是sentence的縮寫鹏溯,單詞以空格為分割
print('# sentences: %d' % len(raw_dataset))
# 對(duì)于數(shù)據(jù)集的前3個(gè)句子罢维,打印每個(gè)句子的詞數(shù)和前5個(gè)詞
# 句尾符為 '' ,生僻詞全用 '' 表示丙挽,數(shù)字則被替換成了 'N'
for st in raw_dataset[:3]:
print('# tokens:', len(st), st[:5])
建立詞語索引
counter = collections.Counter([tk for st in raw_dataset for tk in st]) # tk是token的縮寫肺孵,連續(xù)兩個(gè)for匀借,先執(zhí)行st in raw_dataset,去除sentence平窘,再執(zhí)行tk in st吓肋,取出sentence中的token詞。counter用來存儲(chǔ)
counter = dict(filter(lambda x: x[1] >= 5, counter.items())) # 只保留在數(shù)據(jù)集中至少出現(xiàn)5次的詞
idx_to_token = [tk for tk, _ in counter.items()]#返回詞典對(duì)象
token_to_idx = {tk: idx for idx, tk in enumerate(idx_to_token)}#對(duì)于一個(gè)可迭代的(iterable)/可遍歷的對(duì)象(如列表瑰艘、字符串)是鬼,enumerate將其組成一個(gè)索引序列,利用它可以同時(shí)獲得索引和值紫新。Counter(計(jì)數(shù)器)是對(duì)字典的補(bǔ)充均蜜,用于追蹤值的出現(xiàn)次數(shù)。
dataset = [[token_to_idx[tk] for tk in st if tk in token_to_idx]
for st in raw_dataset] # raw_dataset中的單詞在這一步被轉(zhuǎn)換為對(duì)應(yīng)的idx
num_tokens = sum([len(st) for st in dataset])
'# tokens: %d' % num_tokens
二次采樣
文本數(shù)據(jù)中一般會(huì)出現(xiàn)一些高頻詞芒率,如英文中的“the”“a”和“in”囤耳。通常來說,在一個(gè)背景窗口中敲董,一個(gè)詞(如“chip”)和較低頻詞(如“microprocessor”)同時(shí)出現(xiàn)比和較高頻詞(如“the”)同時(shí)出現(xiàn)對(duì)訓(xùn)練詞嵌入模型更有益紫皇。因此,訓(xùn)練詞嵌入模型時(shí)可以對(duì)詞進(jìn)行二次采樣腋寨。 具體來說聪铺,數(shù)據(jù)集中每個(gè)被索引詞 將有一定概率被丟棄,該丟棄概率為
其中 是數(shù)據(jù)集中詞 的個(gè)數(shù)與總詞數(shù)之比萄窜,常數(shù) 是一個(gè)超參數(shù)(實(shí)驗(yàn)中設(shè)為 )铃剔。可見查刻,只有當(dāng) 時(shí)键兜,我們才有可能在二次采樣中丟棄詞 ,并且越高頻的詞被丟棄的概率越大穗泵。具體的代碼如下:
def discard(idx):
'''
@params:
idx: 單詞的下標(biāo)
@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'))
提取中心詞和背景詞
def get_centers_and_contexts(dataset, max_window_size):
'''
@params:
dataset: 數(shù)據(jù)集為句子的集合普气,每個(gè)句子則為單詞的集合,此時(shí)單詞已經(jīng)被轉(zhuǎn)換為相應(yīng)數(shù)字下標(biāo)
max_window_size: 背景詞的詞窗大小的最大值
@return:
centers: 中心詞的集合
contexts: 背景詞窗的集合佃延,與中心詞對(duì)應(yīng)现诀,每個(gè)背景詞窗則為背景詞的集合
'''
centers, contexts = [], []
for st in dataset:
if len(st) < 2: # 每個(gè)句子至少要有2個(gè)詞才可能組成一對(duì)“中心詞-背景詞”
continue
centers += st
for center_i in range(len(st)):
window_size = random.randint(1, max_window_size) # 隨機(jī)選取背景詞窗大小
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)
Skip-Gram 跳字模型
在跳字模型中,每個(gè)詞被表示成兩個(gè) 維向量履肃,用來計(jì)算條件概率仔沿。假設(shè)這個(gè)詞在詞典中索引為 ,當(dāng)它為中心詞時(shí)向量表示為 尺棋,而為背景詞時(shí)向量表示為 封锉。設(shè)中心詞 在詞典中索引為 ,背景詞 在詞典中索引為 ,我們假設(shè)給定中心詞生成背景詞的條件概率滿足下式:
PyTorch 預(yù)置的 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))
PyTorch 預(yù)置的批量乘法
X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
print(torch.bmm(X, Y).shape)
Skip-Gram 模型的前向計(jì)算
def skip_gram(center, contexts_and_negatives, embed_v, embed_u):
'''
@params:
center: 中心詞下標(biāo)成福,形狀為 (n, 1) 的整數(shù)張量
contexts_and_negatives: 背景詞和噪音詞下標(biāo)碾局,形狀為 (n, m) 的整數(shù)張量
embed_v: 中心詞的 embedding 層
embed_u: 背景詞的 embedding 層
@return:
pred: 中心詞與背景詞(或噪音詞)的內(nèi)積,之后可用于計(jì)算概率 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
負(fù)采樣近似
由于 softmax 運(yùn)算考慮了背景詞可能是詞典 中的任一詞奴艾,對(duì)于含幾十萬或上百萬詞的較大詞典擦俐,就可能導(dǎo)致計(jì)算的開銷過大。我們將以 skip-gram 模型為例握侧,介紹負(fù)采樣 (negative sampling) 的實(shí)現(xiàn)來嘗試解決這個(gè)問題。
負(fù)采樣方法用以下公式來近似條件概率 :
其中 嘿期, 為 sigmoid 函數(shù)品擎。對(duì)于一對(duì)中心詞和背景詞,我們從詞典中隨機(jī)采樣 個(gè)噪聲詞(實(shí)驗(yàn)中設(shè) )备徐。根據(jù) Word2Vec 論文的建議萄传,噪聲詞采樣概率 設(shè)為 詞頻與總詞頻之比的 次方。
*注:除負(fù)采樣方法外蜜猾,還有層序 softmax (hiererarchical softmax) 方法也可以用來解決計(jì)算量過大的問題
批量讀取數(shù)據(jù)
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的參數(shù)collate_fn
@params:
data: 長(zhǎng)為batch_size的列表秀菱,列表中的每個(gè)元素都是__getitem__得到的結(jié)果
@outputs:
batch: 批量化后得到 (centers, contexts_negatives, masks, labels) 元組
centers: 中心詞下標(biāo),形狀為 (n, 1) 的整數(shù)張量
contexts_negatives: 背景詞和噪聲詞的下標(biāo)蹭睡,形狀為 (n, m) 的整數(shù)張量
masks: 與補(bǔ)齊相對(duì)應(yīng)的掩碼衍菱,形狀為 (n, m) 的0/1整數(shù)張量
labels: 指示中心詞的標(biāo)簽,形狀為 (n, m) 的0/1整數(shù)張量
'''
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來避免填充項(xiàng)對(duì)損失函數(shù)計(jì)算的影響
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
訓(xùn)練模型
損失函數(shù)
應(yīng)用負(fù)采樣方法后肩豁,我們可利用最大似然估計(jì)的對(duì)數(shù)等價(jià)形式將損失函數(shù)定義為如下
根據(jù)這個(gè)損失函數(shù)的定義脊串,我們可以直接使用二元交叉熵?fù)p失函數(shù)進(jìn)行計(jì)算:
class SigmoidBinaryCrossEntropyLoss(nn.Module):
def __init__(self):
super(SigmoidBinaryCrossEntropyLoss, self).__init__()
def forward(self, inputs, targets, mask=None):
'''
@params:
inputs: 經(jīng)過sigmoid層后為預(yù)測(cè)D=1的概率
targets: 0/1向量,1代表背景詞清钥,0代表噪音詞
@return:
res: 平均到每個(gè)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]]) # 標(biāo)簽變量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))
訓(xùn)練模型
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() # 一個(gè)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)
測(cè)試模型
def get_similar_tokens(query_token, k, embed):
'''
@params:
query_token: 給定的詞語
k: 近義詞的個(gè)數(shù)
embed: 預(yù)訓(xùn)練詞向量
'''
W = embed.weight.data
x = W[token_to_idx[query_token]]
# 添加的1e-9是為了數(shù)值穩(wěn)定性
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])
參考
- Dive into Deep Learning. Ch14.1-14.4.
- 動(dòng)手學(xué)深度學(xué)習(xí). Ch10.1-10.3.
- Dive-into-DL-PyTorch on GitHub