【NLP】GloVe的Python实现
作者 | Peng Yan
編譯 | VK
來源 | Towards Data Science
作為NLP數據科學家,我經常閱讀詞向量、RNN和Transformer的論文。
閱讀論文很有趣,給我一種錯覺,我已經掌握了各種各樣的技巧。但是,在復現它們時,困難就出現了。
據我所知,許多NLP學習者都遇到了和我一樣的情況。因此,我決定開始一系列的文章,重點是實現經典的NLP方法。我還為此創建了一個GitHub存儲庫:https://github.com/pengyan510/nlp-paper-implementation
本帖是本系列的第一篇,它以GloVe原稿論文為基礎,再現GloVe模型。如前所述,重點純粹是實現。有關基礎理論的更多信息,請參閱原始論文。
根據論文的研究,GloVe模型是用一臺機器訓練的。發布的代碼是用C編寫的,這對NLP學習者來說可能有些陌生。
因此,我對模型進行了一個全面的Python實現,它與僅使用一臺機器訓練大量詞匯表的目標一致。以下各節逐步了解實現細節。完整的代碼在這里。
第0步:準備
訓練數據集
對于這個項目,我使用Text8數據集作為訓練數據。為了得到它,我們可以使用gensim下載程序:
import?gensim.downloader?as?apidataset?=?api.load("text8")數據集是一個列表列表,其中每個子列表都是表示句子的單詞列表。我們只需要所有單詞的列表,所以用itertools將其扁平化:
import?itertoolscorpus?=?list(itertools.chain.from_iterable(dataset))好吧,現在我們有訓練語料庫了。
存儲參數
在機器學習模型上工作時,通常需要配置的參數范圍很廣,如數據文件路徑、批處理大小、字嵌入大小等,如果管理不好,這些參數會產生大量開銷。
根據我的經驗,我發現最好的方法是將所有的文件存儲在一個名稱為yaml的文件中配置yaml。在代碼中,還添加加載函數以從yaml文件加載配置,如下所示:
def?load_config():config_filepath?=?"config.yaml's?file?path"with?config_filepath.open()?as?f:config_dict?=?yaml.load(f,?Loader=yaml.FullLoader)config?=?argparse.Namespace()for?key,?value?in?config_dict.items():setattr(config,?key,?value)return?config我們可以在配置文件配置batch大小, 學習率,而不是硬編碼的值,這也使得代碼變得更好。
這就是所有的準備工作。讓我們繼續進行GloVe模型的實現!
第1步:計算共現對(Cooccurring Pairs)
創建詞匯
為了計算共現的token,我們首先需要確定詞匯。以下是詞匯的一些要求:
它是一組出現在語料庫中的token。
每個token都映射到一個整數。
如果token不屬于主體,則應將其表示為未知token,或“unk”。
對于計算共現,只需要一個子集token,例如最頻繁的前k個token。
為了以結構化的方式滿足這些需求,創建了詞匯類。該類有四個字段:
token2index:將token映射到索引的dict。索引從0開始,每次添加以前未看到的token時,索引都會增加1。
index2token:將索引映射到token的dict。
token_counts:一個列表,其中第i個值是索引i的token計數。
_unk_token:用作未知token索引的整數。默認值為-1。
它還定義了以下方法:
add(token):在詞匯表中添加新的token。如果以前未看到,則會生成新索引。token的計數也會更新。
get_uindex(token):返回token的索引。
get_utoken(index):返回與索引相對應的token。
get_topk_subset(k):創建一個新詞匯表,其中是出現最頻繁的前k個token。
shuffle():隨機所有token,以便token和索引之間的映射是隨機的。當我們實際計算共現對時,需要這個方法的原因將在后面被揭示。
我們現在可以查看代碼:
@dataclass class?Vocabulary:token2index:?dict?=?field(default_factory=dict)index2token:?dict?=?field(default_factory=dict)token_counts:?list?=?field(default_factory=list)_unk_token:?int?=?field(init=False,?default=-1)def?add(self,?token):if?token?not?in?self.token2index:index?=?len(self)self.token2index[token]?=?indexself.index2token[index]?=?tokenself.token_counts.append(0)self.token_counts[self.token2index[token]]?+=?1def?get_topk_subset(self,?k):tokens?=?sorted(list(self.token2index.keys()),key=lambda?token:?self.token_counts[self[token]],reverse=True)return?type(self)(token2index={token:?index?for?index,?token?in?enumerate(tokens[:k])},index2token={index:?token?for?index,?token?in?enumerate(tokens[:k])},token_counts=[self.token_counts[self.token2index[token]]?for?token?in?tokens[:k]])def?shuffle(self):new_index?=?[_?for?_?in?range(len(self))]random.shuffle(new_index)new_token_counts?=?[None]?*?len(self)for?token,?index?in?zip(list(self.token2index.keys()),?new_index):new_token_counts[index]?=?self.token_counts[self[token]]self.token2index[token]?=?indexself.index2token[index]?=?tokenself.token_counts?=?new_token_countsdef?get_index(self,?token):return?self[token]def?get_token(self,?index):if?not?index?in?self.index2token:raise?Exception("Invalid?index.")return?self.index2token[index]@propertydef?unk_token(self):return?self._unk_tokendef?__getitem__(self,?token):if?token?not?in?self.token2index:return?self._unk_tokenreturn?self.token2index[token]def?__len__(self):return?len(self.token2index)對于類實現,我使用Python的dataclass特性。
有了這個特性,我只需要用類型注釋定義字段,__init__()方法就會自動為我生成。我還可以在定義字段時為它們設置默認值。
例如,通過設置default_factory=dict, token2index默認為空dict。有關dataclass的更多信息,請參閱官方文檔:https://docs.python.org/3/library/dataclasses.html
現在我們有了詞匯類,剩下的問題是:我們如何使用它?基本上有兩個用例:
從語料庫中創建一個詞匯表,它由前k個最常見的token組成。
在計算共現對時,使用創建的詞匯表將語料庫(token列表)轉換為整數索引。
我創建了另一個類Vectorizer來協調這兩個用例。它只有一個字段vocab,它指的是從語料庫中創建的詞匯。它有兩種方法:
from_corpus(corpus, vocab_size):這是一個類方法。首先,通過添加語料庫中的所有token來創建詞匯表。然后選擇詞匯量最大最頻繁的token來創建新的詞匯表。這個詞匯表被隨機并用于實例化Vectorizer。隨機的原因將在后面解釋。
vectorize(corpus):將給定的語料庫(一個token列表)轉換為一個索引列表。
完整代碼如下:
@dataclass class?Vectorizer:vocab:?Vocabulary@classmethoddef?from_corpus(cls,?corpus,?vocab_size):vocab?=?Vocabulary()for?token?in?corpus:vocab.add(token)vocab_subset?=?vocab.get_topk_subset(vocab_size)vocab_subset.shuffle()return?cls(vocab_subset)def?vectorize(self,?corpus):return?[self.vocab[token]?for?token?in?corpus]掃描上下文窗口
現在我們有了將所有單詞轉換成索引的vectorizer,剩下的任務是掃描所有上下文窗口并計算所有可能的共現對。
由于共現矩陣是稀疏的,所以使用Counter模塊來計算。鍵是(單詞i的索引,單詞j的索引),其中單詞j出現在單詞i的上下文中。值是表示個數。但是,如果使用此策略,可能會出現兩個問題。
問題1:如果我們在一次掃描中計算所有共現對,我們很可能會耗盡內存,因為distinct (word i’s index, word j's index)的值可能是巨大的。
解決方案:我們可以在多個掃描中計算共現對。在每次掃描中,我們將單詞i的索引限制在一個很小的范圍內,這樣就大大減少了不同對的數量。
假設詞匯表有100000個不同的token。如果我們在一次掃描中對所有對進行計數,則不同對的數量可能高達101?。
相反,我們可以在10次掃描中計算所有對。在第一次掃描中,我們將單詞i的索引限制在0到9999之間;在第二次掃描中,我們將其限制在10000到19999之間;在第三次掃描中,我們將其限制在20000到29999之間,依此類推。
每次掃描完成后,我們把計數保存到磁盤上。現在在每一次掃描中,不同對的數目可以達到10?,這是原始數目的十分之一。
這種方法背后的思想是,我們不是在一次掃描中計算整個共現矩陣,而是將矩陣分成10個較小的矩形,然后依次計算它們。下面的圖片將這個想法形象化。
左:一次掃描計數右:多次掃描計數
這種方法是可伸縮的,因為隨著詞匯表大小的增加,我們總是可以增加掃描次數以減少內存使用。
主要缺點是如果使用一臺機器,運行時間也會增加。然而,由于掃描之間沒有依賴關系,它們可以很容易地與Spark并行。但這超出了我們的范圍。
同時,在這一點上,詞匯混亂的原因可以被發現。當我們用最頻繁的token創建詞匯表時,這些token的索引是有序的。
索引0對應最頻繁的token,索引1對應第二頻繁的token,依此類推。如果我們繼續以100000個token為例,在第一次掃描中,我們將計算10000個最頻繁的token對,不同的token對的數量將是巨大的。
而在剩下的掃描中,不同對的數量會少得多。這會導致掃描之間的內存使用不平衡。通過對詞匯表進行隨機,不同的詞匯對在掃描中均勻分布,內存使用平衡。
問題2:從解決方案繼續到問題1,如何將每次掃描的計數保存到磁盤?最明顯的方法是在掃描之間將(單詞i的索引,單詞j的索引,count)三元組寫入共享文本文件。但是在以后的訓練中使用這個文件會帶來太多的開銷。
解決方案:有一個python庫h5py,它為HDF5二進制格式提供Pythonic接口。它使你能夠存儲大量的數字數據,并且可以像處理真正的NumPy數組一樣輕松地對它們進行操作。
有關該庫的更多詳細信息,請查看其文檔:https://docs.h5py.org/en/stable/
和前面一樣,我創建了一個CooccurrenceEntries類,它進行計數并將結果保存到磁盤。該類有兩個字段:
vectorizer:從語料庫創建的向量器實例。
vectorized_corpus:一個單詞索引列表。這是使用vectorizer對原始語料庫(單詞列表)進行向量化的結果。
主要有兩種方法:
setup(corpus,vectorizer):這是一個用于創建CooccurrenceEntries實例的類方法。通過調用vectorizer的vectorize方法生成向量化的語料庫。
build(window_size, num_partitions, chunk_size, output_directory=“.” ):此方法統計num_partitions掃描中的共現對,并將結果寫入輸出目錄。chunk_size參數用于使用HDF5格式將數據保存為塊。分塊保存的原因將在模型訓練部分討論。簡而言之,它用于更快地生成訓練批。
具體實施如下:
@dataclass class?CooccurrenceEntries:vectorized_corpus:?listvectorizer:?Vectorizer@classmethoddef?setup(cls,?corpus,?vectorizer):return?cls(vectorized_corpus=vectorizer.vectorize(corpus),vectorizer=vectorizer)def?validate_index(self,?index,?lower,?upper):is_unk?=?index?==?self.vectorizer.vocab.unk_tokenif?lower?<?0:return?not?is_unkreturn?not?is_unk?and?index?>=?lower?and?index?<=?upperdef?build(self,window_size,num_partitions,chunk_size,output_directory="."):partition_step?=?len(self.vectorizer.vocab)?//?num_partitionssplit_points?=?[0]while?split_points[-1]?+?partition_step?<=?len(self.vectorizer.vocab):split_points.append(split_points[-1]?+?partition_step)split_points[-1]?=?len(self.vectorizer.vocab)for?partition_id?in?tqdm(range(len(split_points)?-?1)):index_lower?=?split_points[partition_id]index_upper?=?split_points[partition_id?+?1]?-?1cooccurr_counts?=?Counter()for?i?in?tqdm(range(len(self.vectorized_corpus))):if?not?self.validate_index(self.vectorized_corpus[i],index_lower,index_upper):continuecontext_lower?=?max(i?-?window_size,?0)context_upper?=?min(i?+?window_size?+?1,?len(self.vectorized_corpus))for?j?in?range(context_lower,?context_upper):if?i?==?j?or?not?self.validate_index(self.vectorized_corpus[j],-1,-1):continuecooccurr_counts[(self.vectorized_corpus[i],?self.vectorized_corpus[j])]?+=?1?/?abs(i?-?j)cooccurr_dataset?=?np.zeros((len(cooccurr_counts),?3))for?index,?((i,?j),?cooccurr_count)?in?enumerate(cooccurr_counts.items()):cooccurr_dataset[index]?=?(i,?j,?cooccurr_count)if?partition_id?==?0:file?=?h5py.File(os.path.join(output_directory,"cooccurrence.hdf5"),"w")dataset?=?file.create_dataset("cooccurrence",(len(cooccurr_counts),?3),maxshape=(None,?3),chunks=(chunk_size,?3))prev_len?=?0else:prev_len?=?dataset.len()dataset.resize(dataset.len()?+?len(cooccurr_counts),?axis=0)dataset[prev_len:?dataset.len()]?=?cooccurr_datasetfile.close()with?open(os.path.join(output_directory,?"vocab.pkl"),?"wb")?as?file:pickle.dump(self.vectorizer.vocab,?file)通過Vocabulary, Vectorizer, CooccurrenceEntri的抽象,計算共現對并保存到磁盤的代碼很簡單:
vectorizer?=?Vectorizer.from_corpus(corpus=corpus,vocab_size=config.vocab_size ) cooccurrence?=?CooccurrenceEntries.setup(corpus=corpus,vectorizer=vectorizer ) cooccurrence.build(window_size=config.window_size,num_partitions=config.num_partitions,chunk_size=config.chunk_size,output_directory=config.cooccurrence_dir )?第2步:訓練GloVe模型
從HDF5數據集加載批處理
我們首先需要從HDF5數據集中批量加載數據。由于可以像存儲在NumPy矩陣中一樣檢索數據,因此最簡單的方法是使用PyTorch數據加載器。
但是,加載每個batch需要以dataset[i]的形式調用許多次,其中dataset是h5py.Dataset實例。這涉及到許多IO調用,并且可能非常慢。
解決方法是加載h5py.Dataset一塊一塊地調入內存。每個加載的塊在內存中都是一個純粹的NumPy數組,因此我們可以使用PyTorch的Dataloader在其上迭代批處理。現在所需的IO調用數等于塊的數量,塊的數量要小得多。
這種方法的一個缺點是不可能完全隨機,因為永遠不會生成包含來自不同塊的數據的批。為了獲得更多的隨機性,我們可以按隨機順序加載塊,并將DataLoader的shuffle參數設置為True。
為加載批處理創建HDF5DataLoader類。它有五個字段:
filepath:HDF5文件的路徑。
dataset_name:h5py.Dataset名稱。
batch_size:訓練批大小。
device:訓練設備,可以是cpu或gpu。
dataset:h5py.Dataset文件中的實例。
它有兩種方法:
open():此方法打開HDF5文件并定位數據集。不會發生讀取。
iter_batches():此方法以隨機順序加載塊,并創建PyTorch數據加載程序來迭代其中的批。
代碼如下所示。需要注意的一點是,CooccurrenceDataset只是PyTorch數據集的一個子類,用于索引數據。
@dataclass class?HDF5DataLoader:filepath:?strdataset_name:?strbatch_size:?intdevice:?strdataset:?h5py.Dataset?=?field(init=False)def?iter_batches(self):chunks?=?list(self.dataset.iter_chunks())random.shuffle(chunks)for?chunk?in?chunks:chunked_dataset?=?self.dataset[chunk]dataloader?=?torch.utils.data.DataLoader(dataset=CooccurrenceDataset(token_ids=torch.from_numpy(chunked_dataset[:,:2]).long(),cooccurr_counts=torch.from_numpy(chunked_dataset[:,2]).float()),batch_size=self.batch_size,shuffle=True,pin_memory=True)for?batch?in?dataloader:batch?=?[_.to(self.device)?for?_?in?batch]yield?batch@contextlib.contextmanagerdef?open(self):with?h5py.File(self.filepath,?"r")?as?file:self.dataset?=?file[self.dataset_name]yield編碼GloVe模型
用PyTorch實現GloVe模型非常簡單。我們定義了兩個權矩陣和兩個偏置向量。請注意,我們在創建嵌入時設置sparse=True,因為梯度更新本質上是稀疏的。在forward()中,返回平均batch損失。
class?GloVe(nn.Module):def?__init__(self,?vocab_size,?embedding_size,?x_max,?alpha):super().__init__()self.weight?=?nn.Embedding(num_embeddings=vocab_size,embedding_dim=embedding_size,sparse=True)self.weight_tilde?=?nn.Embedding(num_embeddings=vocab_size,embedding_dim=embedding_size,sparse=True)self.bias?=?nn.Parameter(torch.randn(vocab_size,dtype=torch.float,))self.bias_tilde?=?nn.Parameter(torch.randn(vocab_size,dtype=torch.float,))self.weighting_func?=?lambda?x:?(x?/?x_max).float_power(alpha).clamp(0,?1)def?forward(self,?i,?j,?x):loss?=?torch.mul(self.weight(i),?self.weight_tilde(j)).sum(dim=1)loss?=?(loss?+?self.bias[i]?+?self.bias_tilde[j]?-?x.log()).square()loss?=?torch.mul(self.weighting_func(x),?loss).mean()return?loss訓練GloVe模型
模型訓練遵循標準的PyTorch訓練程序。唯一的區別是,我們使用定制的HDF5Loader來生成批處理,而不是PyTorch的DataLoader。以下是訓練代碼:
dataloader?=?HDF5DataLoader(filepath=os.path.join(config.cooccurrence_dir,?"cooccurrence.hdf5"),dataset_name="cooccurrence",batch_size=config.batch_size,device=config.device ) model?=?GloVe(vocab_size=config.vocab_size,embedding_size=config.embedding_size,x_max=config.x_max,alpha=config.alpha ) model.to(config.device) optimizer?=?torch.optim.Adagrad(model.parameters(),lr=config.learning_rate ) with?dataloader.open():model.train()losses?=?[]for?epoch?in?tqdm(range(config.num_epochs)):epoch_loss?=?0for?batch?in?tqdm(dataloader.iter_batches()):loss?=?model(batch[0][:,?0],batch[0][:,?1],batch[1])epoch_loss?+=?loss.detach().item()loss.backward()optimizer.step()optimizer.zero_grad()losses.append(epoch_loss)print(f"Epoch?{epoch}:?loss?=?{epoch_loss}")torch.save(model.state_dict(),?config.output_filepath)實施完畢!
接下來,讓我們訓練模型,看看結果!
第3步:結果
對于Text8數據集,訓練一個epoch大約需要80分鐘。我訓練了20個epoch的模型,需要一天多的時間才能完成。學習曲線看起來很有希望,如果繼續訓練,損失似乎會進一步減少。
學習曲線圖
我們也可以做一些單詞相似性的任務來看看詞向量的行為。
這里我使用了gensim中的KeyedVectors類,它允許你在不編寫最近鄰或余弦相似性代碼的情況下執行此操作:https://github.com/pengyan510/nlp-paper-implementation/blob/master/glove/src/evaluate.py
相似性評估代碼在這里。有關KeyedVectors的詳細信息,請參閱文檔:https://radimrehurek.com/gensim/models/keyedvectors.html#what-can-i-do-with-word-vectors
運行一些簡單的相似性任務將顯示以下結果:
正如我們所看到的,其中有些是有意義的,比如“computer”和“game”,“united”和“states”;有些則不是。在一個更大的數據集上進行更多epoch的訓練應該會改善結果。
結尾
GloVe論文寫得很好,容易看懂。然而,在實現過程中,有很多陷阱和困難,特別是當你考慮到內存問題時。
經過相當多的努力,我們最終得到了一個令人滿意的解決方案,可以在一臺機器上進行訓練。
正如我在開始時所說,我將繼續實現更多的NLP論文,并與大家分享
感謝閱讀!
往期精彩回顧適合初學者入門人工智能的路線及資料下載機器學習及深度學習筆記等資料打印機器學習在線手冊深度學習筆記專輯《統計學習方法》的代碼復現專輯 AI基礎下載機器學習的數學基礎專輯溫州大學《機器學習課程》視頻 本站qq群851320808,加入微信群請掃碼: 與50位技術專家面對面20年技術見證,附贈技術全景圖總結
以上是生活随笔為你收集整理的【NLP】GloVe的Python实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 搜狗浏览器怎么实现图标旋转 搜狗浏览器实
- 下一篇: win7电脑删除文件特别慢怎么办