This article encapsulates a relatively complete Word2Vec implementation, with the model part implemented using TensorFlow. The purpose of this article is not just to reinvent the Word2Vec wheel, but to use this example to become familiar with TensorFlow’s coding style and to test the effectiveness of a new softmax loss I designed, preparing for future research on language models.
Differences
For the basic mathematical principles of Word2Vec, please refer to
the article "Incredible
Word2Vec: 1. Mathematical Principles". The main models in this
article are still CBOW or Skip-Gram, but the loss design is different.
This article still uses a full softmax structure rather than
Hierarchical Softmax or Negative Sampling schemes. However, when
training the softmax, a cross-entropy loss
based on random negative sampling is used. This loss is different
from the existing nce_loss and
sampled_softmax_loss; for now, let’s name it random
softmax loss.
Additionally, in a softmax structure, the form is generally \text{softmax}(Wx+b). Considering that the shape of the W matrix is actually the same as the shape of the word vector matrix, this article considers a model where the softmax layer and the word vector layer share weights (in this case, b is set directly to 0). This model is equivalent to the original Word2Vec negative sampling scheme and is similar to the word co-occurrence matrix factorization of GloVe word vectors. However, because cross-entropy loss is used, it theoretically converges faster, and the training results still retain the predictive probability meaning of softmax. (In contrast, after training existing Word2Vec negative sampling models, the final output values of the model are meaningless; only the word vectors are meaningful.) At the same time, because parameters are shared, the word vectors are updated more thoroughly. Readers are encouraged to test this scheme.
Therefore, this article actually implements four model combinations: CBOW/Skip-Gram, with or without shared softmax layer parameters. Readers can choose which to use.
Origin of the Loss
As mentioned earlier, one of the main purposes of this article is to test the effectiveness of the new loss. Below is a brief introduction to the origin and form of this loss. This starts with why softmax is difficult to train.
Assume the number of labels (which in this article is the vocabulary size) is n, then: \begin{aligned} (p_1, p_2, \dots, p_n) &= \text{softmax}(z_1, z_2, \dots, z_n) \\ &= \left(\frac{e^{z_1}}{Z}, \frac{e^{z_2}}{Z}, \dots, \frac{e^{z_n}}{Z}\right) \end{aligned} Here Z = e^{z_1} + e^{z_2} + \dots + e^{z_n}. If the correct category label is t, using cross-entropy as the loss gives: L = -\log \frac{e^{z_t}}{Z} The gradient is: \nabla L = -\nabla z_t + \nabla (\log Z) = -\nabla z_t + \frac{\nabla Z}{Z} Because of the existence of Z, every time gradient descent is performed, the complete Z must be calculated to compute \nabla Z. This means the computational complexity for a single sample iteration is \mathcal{O}(n). For cases where n is large, this is unacceptable, so approximation schemes are sought. (Hierarchical softmax is one such scheme, but it is complex to implement, its results are usually slightly worse than ordinary softmax, and while it speeds up training, it is actually slower if you need to find the label with the maximum probability during prediction.)
Let’s further calculate \nabla L: \begin{aligned} \nabla L &= -\nabla z_t + \frac{\sum_i e^{z_i} \nabla z_i}{Z} \\ &= -\nabla z_t + \sum_i \frac{e^{z_i}}{Z} \nabla z_i \\ &= -\nabla z_t + \sum_i p_i \nabla z_i \\ &= -\nabla z_t + \text{E}(\nabla z_i) \end{aligned} In other words, the final gradient consists of two terms: one is the gradient of the correct label, and the other is the mean of the gradients of all labels. These two terms have opposite signs and can be understood as a "tug-of-war." The computational load is mainly concentrated in the second term because it requires traversing all labels to calculate the specific mean. However, since the mean itself has probabilistic significance, can we simply randomly select a few labels to calculate this gradient mean instead of calculating all gradients? If so, the computational load per update step would be fixed and would not increase rapidly as the number of labels grows.
However, doing this requires randomly selecting labels according to
their probabilities, which is not easy to code. A more ingenious method
is to avoid calculating the gradient directly and instead modify the
loss function. This leads to the loss used in this article:
For each "sample-label" pair, randomly
select nb_negative labels, combine them with the original
label to form nb_negative + 1 labels, and calculate the
softmax and cross-entropy directly within these labels. After selecting
such a loss and calculating the gradient, you will find that it
naturally becomes the gradient mean selected according to
probability.
Code Implementation
I feel the code is quite concise, with a single file containing
everything. The training output mimics Word2Vec in gensim.
The model code is located on GitHub:
https://github.com/bojone/tf_word2vec/blob/master/Word2Vec.py
Usage reference:
from Word2Vec import *
import pymongo
db = pymongo.MongoClient().travel.articles
class texts:
def __iter__(self):
for t in db.find().limit(30000):
yield t['words']
# Build and train the model
wv = Word2Vec(texts(), model='cbow', nb_negative=16, shared_softmax=True, epochs=2)
# Save the model to the 'myvec' folder in the current directory
wv.save_model('myvec')
# After training, you can call it like this:
wv = Word2Vec() # Create an empty model
wv.load_model('myvec') # Load the model from the 'myvec' folder
A few points to clarify:
1. The training input consists of segmented sentences, which can be a list or an iterator (
class+__iter__). Note that it cannot be a generator (function+yield), which is consistent with the requirements of thegensimversion of Word2Vec. This is because a generator can only be traversed once, while training Word2Vec requires multiple passes over the data.2. The model does not support incremental training; that is, once the model is trained, it cannot be updated with additional documents. (It’s not impossible, but it’s unnecessary and doesn’t add much value.)
3. Training the model requires TensorFlow, and GPU acceleration is recommended. After training is complete, reloading and using the model does not require TensorFlow, only NumPy.
4. Regarding the number of iterations, 1 to 2 epochs are usually sufficient, and 10 to 30 negative samples are enough. Other parameters, such as
batch_size, can be adjusted through experimentation.
Simple Comparative Experiments
In TensorFlow, the two existing losses for approximating softmax
training are nce_loss and
sampled_softmax_loss. Here is a simple comparison. We
trained the same model on a travel domain corpus (over 20,000 articles)
and compared the results. The model used was CBOW, the softmax layer did
not share weights with the word vector layer, and all other parameters
used the same default values.
random_softmax_loss
Time taken: 8 minutes 19 seconds (2 iterations,
batch_size of 8000).
Similarity test results:
>>> import pandas as pd >>> pd.Series(wv.most_similar(u'Fruit')) 0 (Food, 0.767908) 1 (Dried fish, 0.762363) 2 (Coconut, 0.750326) 3 (Beverage, 0.722811) 4 (Food, 0.719381) 5 (Beef jerky, 0.715441) 6 (Pineapple, 0.715354) 7 (Ham sausage, 0.714509) 8 (Jackfruit, 0.712546) 9 (Raisins, 0.709274) dtype: object >>> pd.Series(wv.most_similar(u'Nature')) 0 (Humanities, 0.645445) 1 (Harmony, 0.634387) 2 (Inclusion, 0.61829) 3 (Mother Nature, 0.601749) 4 (Natural environment, 0.588165) 5 (Integration, 0.579027) 6 (Broad, 0.574943) 7 (Interpretation, 0.550352) 8 (Wildness, 0.548001) 9 (Wild interest, 0.545887) dtype: object >>> pd.Series(wv.most_similar(u'Guangzhou')) 0 (Shanghai, 0.749281) 1 (Wuhan, 0.730211) 2 (Shenzhen, 0.703333) 3 (Changsha, 0.683243) 4 (Fuzhou, 0.68216) 5 (Hefei, 0.673027) 6 (Beijing, 0.669859) 7 (Chongqing, 0.653501) 8 (Haikou, 0.647563) 9 (Tianjin, 0.642161) dtype: object >>> pd.Series(wv.most_similar(u'Scenery')) 0 (View, 0.825557) 1 (Beautiful view, 0.763399) 2 (Vista, 0.734687) 3 (Landscape, 0.727672) 4 (Landscape, 0.57638) 5 (Lakes and mountains, 0.573512) 6 (Mountain view, 0.555502) 7 (Beautiful beyond words, 0.552739) 8 (Mingshi, 0.535922) 9 (Along the way, 0.53485) dtype: object >>> pd.Series(wv.most_similar(u'Restaurant')) 0 (Diner, 0.768179) 1 (Food stall, 0.731749) 2 (Hotpot shop, 0.729214) 3 (Food stall, 0.726048) 4 (Restaurant, 0.722667) 5 (Noodle shop, 0.715188) 6 (Food stall, 0.709883) 7 (Famous shop, 0.708996) 8 (Songhelou, 0.705759) 9 (Branch, 0.705749) dtype: object >>> pd.Series(wv.most_similar(u'Hotel')) 0 (Marriott, 0.722409) 1 (Hilton, 0.713292) 2 (Five-star, 0.697638) 3 (Five-star, 0.696659) 4 (Gloria, 0.694978) 5 (Intime, 0.693179) 6 (Grand Hotel, 0.692239) 7 (Guesthouse, 0.67907) 8 (Sheraton, 0.668638) 9 (Holiday Inn, 0.662169) dtype: object
nce_loss
Time taken: 4 minutes (2 iterations, batch_size of
8000). However, the similarity test results were unbearable. Considering
the time taken was shorter, to be fair, I increased the iterations to 4
while keeping other parameters the same. The similarity results were
still a mess, for example:
>>> pd.Series(wv.most_similar(u'Fruit')) 0 (mouth, 0.940704) 1 (can, 0.940106) 2 (100, 0.939276) 3 (change, 0.938824) 4 (second, 0.938155) 5 (:, 0.938088) 6 (see, 0.937939) 7 (bad, 0.937616) 8 (and, 0.937535) 9 ((, 0.937383) dtype: object
I began to wonder if I was using it incorrectly. So I adjusted again,
increasing nb_negative to 1000 and setting iterations back
to 3. This took 9 minutes 17 seconds. The final loss was an order of
magnitude smaller than before, and the similarity results became
somewhat plausible, but still not particularly good:
>>> pd.Series(wv.most_similar(u'Fruit')) 0 (Specialty, 0.984775) 1 (Seafood, 0.981409) 2 (Such things, 0.981158) 3 (Food, 0.980803) 4 (., 0.980371) 5 (Vegetables, 0.979822) 6 (&, 0.979713) 7 (Mango, 0.979599) 8 (can, 0.979486) 9 (For example, 0.978958) dtype: object >>> pd.Series(wv.most_similar(u'Nature')) 0 (with, 0.985322) 1 (located, 0.984874) 2 (these, 0.983769) 3 (madam, 0.983499) 4 (in, 0.983473) 5 (of, 0.983456) 6 (will, 0.983432) 7 (former residence, 0.983328) 8 (those, 0.983089) 9 (here, 0.983046) dtype: object
sampled_softmax_loss
Based on previous experience, I directly set nb_negative
to 1000 and iterations to 3. This took 8 minutes 38 seconds. The
similarity results were:
>>> pd.Series(wv.most_similar(u'Fruit')) 0 (Snacks, 0.69762) 1 (Food, 0.651911) 2 (Chocolate, 0.64101) 3 (Grape, 0.636065) 4 (Biscuit, 0.62631) 5 (Bread, 0.613488) 6 (Melon, 0.604927) 7 (Food, 0.602576) 8 (Dry goods, 0.601015) 9 (Pineapple, 0.598993) dtype: object >>> pd.Series(wv.most_similar(u'Nature')) 0 (Humanities, 0.577503) 1 (Mother Nature, 0.537344) 2 (Landscape, 0.526281) 3 (Pastoral, 0.526062) 4 (Unique, 0.526009) 5 (Harmony, 0.503326) 6 (Charming, 0.498782) 7 (Infinite, 0.491521) 8 (Beautiful, 0.482407) 9 (Scene, 0.479687) dtype: object >>> pd.Series(wv.most_similar(u'Guangzhou')) 0 (Shenzhen, 0.771525) 1 (Shanghai, 0.739744) 2 (Dongguan, 0.726057) 3 (Shenyang, 0.687548) 4 (Fuzhou, 0.654641) 5 (Beijing, 0.650491) 6 (EMU, 0.644898) 7 (Take the EMU, 0.635638) 8 (Haikou, 0.631551) 9 (Changchun, 0.628518) dtype: object >>> pd.Series(wv.most_similar(u'Scenery')) 0 (View, 0.8393) 1 (Vista, 0.731151) 2 (Landscape, 0.730255) 3 (Beautiful view, 0.666185) 4 (Snow scene, 0.554452) 5 (Landscape, 0.530444) 6 (Lakes and mountains, 0.529671) 7 (Mountain view, 0.511195) 8 (Road conditions, 0.490073) 9 (Picturesque, 0.483742) dtype: object >>> pd.Series(wv.most_similar(u'Restaurant')) 0 (Diner, 0.766124) 1 (Restaurant, 0.687775) 2 (Diner, 0.666957) 3 (Hotel, 0.664034) 4 (Sichuan flavor, 0.659254) 5 (Restaurant, 0.658057) 6 (Food stall, 0.656883) 7 (Simple meal, 0.650861) 8 (Gonghechun, 0.650256) 9 (Restaurant, 0.644265) dtype: object >>> pd.Series(wv.most_similar(u'Hotel')) 0 (Guesthouse, 0.685888) 1 (Grand Hotel, 0.678389) 2 (Four-star, 0.638032) 3 (Five-star, 0.633661) 4 (Hanting, 0.619405) 5 (Home Inn, 0.614918) 6 (Lobby, 0.612269) 7 (Resort, 0.610618) 8 (Four-star, 0.609796) 9 (Tianyu, 0.598987) dtype: object
Summary
Although this experiment is not very rigorous, it can be said that for the same training time and similarity task, random softmax feels comparable to sampled softmax, while nce loss performs the worst. Further compressing iterations and adjusting parameters showed similar results. Readers are welcome to test further. Since the random softmax in this article performs different sampling for each sample, it requires fewer negative samples and the sampling is more thorough.
As for comparisons on other tasks, they will have to wait for future practice. After all, this isn’t for a paper, and I’m too lazy to do more
Future Work
One question is: why create a new loss if it’s comparable to
sampled_softmax? The reason is simple: when I look at the
paper and formulas for sampled_softmax, I always feel it’s
not quite "beautiful" and the theory isn’t elegant enough. Of course,
based on the results, I’m just being too much of a perfectionist. This
article can be considered a product of that perfectionism, as well as a
way to practice TensorFlow.
Additionally, the article "Recording a Semi-supervised Sentiment Analysis" shows that language models have significant potential in pre-training models and implementing semi-supervised tasks. Even word vectors are just the first layer of parameters pre-trained by a language model. Therefore, I want to take some time to delve deeper into similar content. This article is one of the preparations for that.
When reposting, please include the original address: https://kexue.fm/archives/4402
For more detailed reposting matters, please refer to: Scientific Space FAQ