一. Glove 詞嵌入原理
GloVe是一種學習詞嵌入的方法,它希望擬合給定上下文單詞i時單詞j出現的次數。使用的誤差函數為:
其中N是詞匯表大小,是線性層參數,
?是詞嵌入。f(x)是權重項,用于平衡不同頻率的單詞對誤差的影響,并消除log0時式子不成立情況。
GloVe作者提供了官方的預訓練詞嵌入(https://nlp.stanford.edu/projects/glove/?)。預訓練的GloVe有好幾個版本,按數據來源,可以分成:
- 維基百科+gigaword(6B)
- 爬蟲(42B)
- 爬蟲(840B)
- 推特(27B)
按照詞嵌入向量的大小分,又可以分成50維,100維,200維等不同維度。
預訓練GloVe的文件格式非常簡明,一行代表一個單詞向量,每行先是一個單詞,再是若干個浮點數,表示該單詞向量的每一個元素。
在Pytorch里,我們不必自己去下載解析GloVe,而是可以直接調用Pytorch庫自動下載解析GloVe。首先我們要安裝Pytorch的NLP庫-- torchtext。
如上所述,GloVe版本可以由其數據來源和向量維數確定,在構建GloVe類時,要提供這兩個參數,我們選擇的是6B token,維度100的GloVe
調用glove.get_vecs_by_tokens
,我們能夠把token轉換成GloVe里的向量。
import torch
from torchtext.vocab import GloVe
glove = GloVe(name='6B', dim=100)
# Get vectors
tensor = glove.get_vecs_by_tokens(['', '1998', '199999998', ',', 'cat'], True)
print(tensor)
PyTorch提供的這個函數非常方便。如果token不在GloVe里的話,該函數會返回一個全0向量。如果你運行上面的代碼,可以觀察到一些有趣的事:空字符串和199999998這樣的不常見數字不在詞匯表里,而1998這種常見的數字以及標點符號都在詞匯表里。
GloVe
類內部維護了一個矩陣,即每個單詞向量的數組。因此,GloVe
需要一個映射表來把單詞映射成向量數組的下標。glove.itos
和glove.stoi
完成了下標與單詞字符串的相互映射。比如用下面的代碼,我們可以知道詞匯表的大小,并訪問詞匯表的前幾個單詞:
myvocab = glove.itos
print(len(myvocab))
print(myvocab[0], myvocab[1], myvocab[2], myvocab[3])
最后,我們來通過一個實際的例子認識一下詞嵌入的意義。詞嵌入就是向量,向量的關系常常與語義關系對應。利用詞嵌入的相對關系,我們能夠回答“x1之于y1,相當于x2之于誰?”這種問題。比如,男人之于女人,相當于國王之于王后。設我們要找的向量為y2,我們想讓x1-y1=x2-y2,即找出一個和x2-(x1-y1)最相近的向量y2出來。這一過程可以用如下的代碼描述:
def get_counterpart(x1, y1, x2):x1_id = glove.stoi[x1]y1_id = glove.stoi[y1]x2_id = glove.stoi[x2]#print("x1:",x1,"y1:",y1,"x2:",x2)x1, y1, x2 = glove.get_vecs_by_tokens([x1, y1, x2],True)#print("x1:",x1,"y1:",y1,"x2:",x2)target = x2 - x1 + y1max_sim =0 max_id = -1for i in range(len(myvocab)):vector = glove.get_vecs_by_tokens([myvocab[i]],True)[0]cossim = torch.dot(target, vector)if cossim > max_sim and i not in {x1_id, y1_id, x2_id}:max_sim = cossimmax_id = ireturn myvocab[max_id]
print(get_counterpart('man', 'woman', 'king'))
print(get_counterpart('more', 'less', 'long'))
print(get_counterpart('apple', 'red', 'banana'))
運行結果:?
queen
short
yellow
二.基于GloVe的情感分析
情感分析任務與數據集
和貓狗分類類似,情感分析任務是一種比較簡單的二分類NLP任務:給定一段話,輸出這段話的情感是積極的還是消極的。
比如下面這段話:
I went and saw this movie last night after being coaxed to by a few friends of mine. I'll admit that I was reluctant to see it because from what I knew of Ashton Kutcher he was only able to do comedy. I was wrong. Kutcher played the character of Jake Fischer very well, and Kevin Costner played Ben Randall with such professionalism. ......
這是一段影評,大意說,這個觀眾本來不太想去看電影,因為他認為演員Kutcher只能演好喜劇。但是,看完后,他發現他錯了,所有演員都演得非常好。這是一段積極的評論。
1. 讀取數據集:
import os
from torchtext.data import get_tokenizerdef read_imdb(dir='aclImdb', split = 'pos', is_train=True):subdir = 'train' if is_train else 'test'dir = os.path.join(dir, subdir, split)lines = []for file in os.listdir(dir):with open(os.path.join(dir, file), 'rb') as f:line = f.read().decode('utf-8')lines.append(line)return lineslines = read_imdb()
print('Length of the file:', len(lines))
print('lines[0]:', lines[0])
tokenizer = get_tokenizer('basic_english')
tokens = tokenizer(lines[0])
print('lines[0] tokens:', tokens)
output:?
2.獲取經GloVe預處理的數據
在這個作業里,模型其實很簡單,輸入序列經過詞嵌入,送入單層RNN,之后輸出結果。作業最難的是如何把token轉換成GloVe詞嵌入。
torchtext其實還提供了一些更方便的NLP工具類(Field,Vectors),用于管理向量。但是,這些工具需要一定的學習成本,后續學習pytorch時再學習。
Pytorch通常用nn.Embedding來表示詞嵌入層。nn.Embedding其實就是一個矩陣,每一行都是一個詞嵌入,每一個token都是整型索引,表示該token再詞匯表里的序號。有了索引,有了矩陣就可以得到token的詞嵌入了。但是有些token在詞匯表中并不存在,我們得對輸入做處理,把詞匯表里沒有的token轉換成<unk>這個表示未知字符的特殊token。同時為了對齊序列的長度,我們還得添加<pad>這個特殊字符。而用glove直接生成的nn.Embedding里沒有<unk>和<pad>字符。如果使用nn.Embedding的話,我們要編寫非常復雜的預處理邏輯。
為此,我們可以用GloVe類的get_vecs_by_tokens直接獲取token的詞嵌入,以代替nn.Embedding。回憶一下前文提到的get_vecs_by_tokens的使用結果,所有沒有出現的token都會被轉換成零向量。這樣,我們就不必操心數據預處理的事了。get_vecs_by_tokens應該發生在數據讀取之后,可以直接被寫在Dataset的讀取邏輯里
from torch.utils.data import DataLoader, Dataset
from torchtext.data import get_tokenizer
from torchtext.vocab import GloVeclass IMDBDataset(Dataset):def __init__(self, is_train=True, dir = 'aclImdb'):super().__init__()self.tokenizer = get_tokenizer('basic_english')pos_lines = read_imdb(dir, 'pos', is_train)neg_lines = read_imdb(dir, 'neg', is_train)self.pos_length = len(pos_lines)self.neg_length = len(neg_lines)self.lines = pos_lines+neg_linesdef __len__(self):return self.pos_length + self.neg_lengthdef __getitem__(self, index):sentence = self.tokenizer(self.lines[index])x = glove.get_vecs_by_tokens(sentence)label = 1 if index < self.pos_length else 0return x, label
數據預處理的邏輯都在__getitem__
里。每一段字符串會先被token化,之后由GLOVE.get_vecs_by_tokens
得到詞嵌入數組。?
3.對齊輸入
使用一個batch的序列數據時常常會碰到序列不等長的問題。實際上利用Pytorch Dataloader的collate_fn機制有更簡潔的實現方法。
from torch.nn.utils.rnn import pad_sequencedef get_dataloader(dir='aclImdb'):def collate_fn(batch):x, y = zip(*batch)x_pad = pad_sequence(x, batch_first=True)y = torch.Tensor(y)return x_pad, ytrain_dataloader = DataLoader(IMDBDataset(True, dir),batch_size=32,shuffle=True,collate_fn=collate_fn)test_dataloader = DataLoader(IMDBDataset(False, dir),batch_size=32,shuffle=True,collate_fn=collate_fn)return train_dataloader, test_dataloader
PyTorch DataLoader在獲取Dataset的一個batch的數據時,實際上會先吊用Dataset.__getitem__獲取若干個樣本,再把所有樣本拼接成一個batch,比如用__getitem__獲取四個[4,3,10,10]這一個batch,可是序列數據通常長度不等,__getitem__
可能會獲得[10, 100]
,?[15, 100]
這樣不等長的詞嵌入數組。
為了解決這個問題,我們要手動編寫把所有張量拼成一個batch的函數。這個函數就是DataLoader
的collate_fn
函數。我們的collate_fn
應該這樣編寫:
def collate_fn(batch):x, y = zip(*batch)x_pad = pad_sequence(x, batch_first=True)y = torch.Tensor(y)return x_pad, y
collate_fn
的輸入batch
是每次__getitem__
的結果的數組。比如在我們這個項目中,第一次獲取了一個長度為10的積極的句子,__getitem__
返回(Tensor[10, 100], 1)
;第二次獲取了一個長度為15的消極的句子,__getitem__
返回(Tensor[15, 100], 0)
。那么,輸入batch
的內容就是:
[(Tensor[10, 100], 1), (Tensor[15, 100], 0)]
我們可以用x, y = zip(*batch)
把它巧妙地轉換成兩個元組:
x = (Tensor[10, 100], Tensor[15, 100])
y = (1, 0)
之后,PyTorch的pad_sequence
可以把不等長序列的數組按最大長度填充成一整個batch張量。也就是說,經過這個函數后,x_pad
變成了:
x_pad = Tensor[2, 15, 100]
pad_sequence
的batch_first
決定了batch
是否在第一維。如果它為False
,則結果張量的形狀是[15, 2, 100]
。
pad_sequence
還可以決定填充內容,默認填充0。在我們這個項目中,被填充的序列已經是詞嵌入了,直接用全零向量表示<pad>
沒問題。
有了collate_fn
,構建DataLoader
就很輕松了:
DataLoader(IMDBDataset(True, dir),batch_size=32,shuffle=True,collate_fn=collate_fn)
注意,使用shuffle=True
可以令DataLoader
隨機取數據構成batch。由于我們的Dataset
十分工整,前一半的標簽是1,后一半是0,必須得用隨機的方式去取數據以提高訓練效率。?
4.模型
import torch.nn as nn
GLOVE_DIM = 100
GLOVE = GloVe(name = '6B', dim=GLOVE_DIM)
class RNN(torch.nn.Module):def __init__(self, hidden_units=64, dropout_rate = 0.5):super().__init__()self.drop = nn.Dropout(dropout_rate)self.rnn = nn.GRU(GLOVE_DIM, hidden_units, 1, batch_first=True)self.linear = nn.Linear(hidden_units,1)self.sigmoid = nn.Sigmoid()def forward(self, x:torch.Tensor):# x: [batch, max_word_length, embedding_length]emb = self.drop(x)output,_ = self.rnn(emb)output = output[:, -1]output = self.linear(output)output = self.sigmoid(output)return output
這里要注意一下,PyTorch的RNN會返回整個序列的輸出。而在預測分類概率時,我們只需要用到最后一輪RNN計算的輸出。因此,要用output[:, -1]
取最后一次的輸出。
5. 訓練、測試、推理?
train_dataloader, test_dataloader = get_dataloader()
model = RNN()optimizer = torch.optim.Adam(model.parameters(),lr=0.001)
citerion = torch.nn.BCELoss()for epoch in range(100):loss_sum = 0dataset_len = len(train_dataloader.dataset)for x, y in train_dataloader:batchsize = y.shape[0]hat_y = model(x)hat_y = hat_y.squeeze(-1)loss = citerion(hat_y, y)optimizer.zero_grad()loss.backward()torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)optimizer.step()loss_sum += loss * batchsizeprint(f'Epoch{epoch}. loss :{loss_sum/dataset_len}')torch.save(model.state_dict(),'rnn.pth')
output:?
model.load_state_dict(torch.load('rnn.pth'))
accuracy = 0
dataset_len = len(test_dataloader.dataset)
model.eval()
for x, y in test_dataloader:with torch.no_grad():hat_y = model(x)hat_y.squeeze_(1)predictions = torch.where(hat_y>0.5,1,0)score = torch.sum(torch.where(predictions==y,1,0))accuracy += score.item()
accuracy /= dataset_lenprint(f'Accuracy:{accuracy}')
Accuracy:0.90516
tokenizer = get_tokenizer('basic_english')
article = "U.S. stock indexes fell Tuesday, driven by expectations for tighter Federal Reserve policy and an energy crisis in Europe. Stocks around the globe have come under pressure in recent weeks as worries about tighter monetary policy in the U.S. and a darkening economic outlook in Europe have led investors to sell riskier assets."x = GLOVE.get_vecs_by_tokens(tokenizer(article)).unsqueeze(0)
with torch.no_grad():hat_y = model(x)
hat_y = hat_y.squeeze_().item()
result = 'positive' if hat_y > 0.5 else 'negative'
print(result)
negative