Классическое объяснение word2vec как архитектуры Skip-gram с отрицательной выборкой в оригинальной научной статье и бесчисленных блог-постах выглядит так:
while(1) {
1. vf = vector of focus word
2. vc = vector of focus word
3. train such that (vc . vf = 1)
4. for(0 <= i <= negative samples):
vneg = vector of word *not* in context
train such that (vf . vneg = 0)
}
Действительно, если погуглить [word2vec skipgram], что мы видим:
- Страница Википедии, которая описывает алгоритм на высоком уровне
- Страница Tensorflow с тем же объяснением
- Блог Towards Data Science c описанием того же алгоритма, и список продолжается.
Но все эти реализации ошибочны.
Оригинальная реализация word2vec на C работает иначе и кардинально отличается от этой. Те, кто профессионально внедряет системы с вложениями слов из word2vec, делают одно из следующих действий:
- Напрямую вызывают исходную реализацию C.
- Используют реализацию
gensim
, которая транслитерируется из исходника C в той мере, в какой совпадают названия переменных.
Действительно, gensim
— единственная известная мне верная реализация на C.
Реализация на C
Реализация на C фактически поддерживает два вектора для каждого слова. Один вектор для этого слова в фокусе, а второй для слова в контексте. (Кажется знакомым? Верно, разработчики GloVe позаимствовали идею из word2vec, не упомянув об этом факте!)
Реализация в коде C исключительно грамотная:
- Массив
syn0
содержит векторное вложение слова, если оно попадается как слово в фокусе. Здесь случайная инициализация.https://github.com/tmikolov/word2vec/blob/20c129af10659f7c50e86e3be406df663beff438/word2vec.c#L369 for (a = 0; a < vocab_size; a++) for (b = 0; b < layer1_size; b++) { next_random = next_random * (unsigned long long)25214903917 + 11; syn0[a * layer1_size + b] = (((next_random & 0xFFFF) / (real)65536) - 0.5) / layer1_size; }
- Другой массив
syn1neg
, содержит вектор слова, когда оно встречается как контекстное слово. Здесь инициализация нулём. - Во время обучения (Skip-gram, отрицательная выборка, хотя другие случаи примерно такие же) мы сначала выбираем слово фокуса. Оно сохраняется на протяжении всего обучения на положительных и отрицательных примерах. Градиенты вектора фокуса накапливаются в буфере и применяются к фокусному слову после обучения и на положительных, и на отрицательных примерах.
if (negative > 0) for (d = 0; d < negative + 1; d++) { // if we are performing negative sampling, in the 1st iteration, // pick a word from the context and set the dot product target to 1 if (d == 0) { target = word; label = 1; } else { // for all other iterations, pick a word randomly and set the dot //product target to 0 next_random = next_random * (unsigned long long)25214903917 + 11; target = table[(next_random >> 16) % table_size]; if (target == 0) target = next_random % (vocab_size - 1) + 1; if (target == word) continue; label = 0; } l2 = target * layer1_size; f = 0; // find dot product of original vector with negative sample vector // store in f for (c = 0; c < layer1_size; c++) f += syn0[c + l1] * syn1neg[c + l2]; // set g = sigmoid(f) (roughly, the actual formula is slightly more complex) if (f > MAX_EXP) g = (label - 1) * alpha; else if (f < -MAX_EXP) g = (label - 0) * alpha; else g = (label - expTable[(int)((f + MAX_EXP) * (EXP_TABLE_SIZE / MAX_EXP / 2))]) * alpha; // 1. update the vector syn1neg, // 2. DO NOT UPDATE syn0 // 3. STORE THE syn0 gradient in a temporary buffer neu1e for (c = 0; c < layer1_size; c++) neu1e[c] += g * syn1neg[c + l2]; for (c = 0; c < layer1_size; c++) syn1neg[c + l2] += g * syn0[c + l1]; } // Finally, after all samples, update syn1 from neu1e https://github.com/tmikolov/word2vec/blob/20c129af10659f7c50e86e3be406df663beff438/word2vec.c#L541 // Learn weights input -> hidden for (c = 0; c < layer1_size; c++) syn0[c + l1] += neu1e[c];
Почему случайная и нулевая инициализация?
Ещё раз, поскольку это вообще не объясняется в оригинальных статьях и нигде в интернете, я могу только предполагать.
Гипотеза заключается в том, что когда отрицательные образцы поступают со всего текста и не взвешиваются по частоте, вы можете выбрать любое слово, и чаще всего слово, вектор которого вообще не обучен. Если у этого вектора есть значение, то оно случайным образом сместит действительно важное слово в фокусе.
Суть в том, чтобы установить все отрицательные примеры на ноль, так что на представление другого вектора повлияют только векторы, которые встречаются более-менее часто.
На самом деле, это довольно хитроумно, и я раньше никогда не задумывался, насколько важны стратегии инициализации.
Почему я это пишу
Я потратил два месяца своей жизни, пытаясь воспроизвести word2vec по описанию в оригинальной научной публикации и бесчисленных статьях в интернете, но не получилось. Я не смог достичь тех же результатов, что и word2vec, хотя старался изо всех сил.
Я не мог представить, что авторы публикации буквально сфабриковали алгоритм, который не работает, в то время как реализация делает нечто совершенно иное.
В конце концов, я решил изучить исходники. Три дня я пребывал в уверенности, что неправильно понимаю код, поскольку буквально все в интернете говорили об иной реализации.
Понятия не имею, почему оригинальная публикация и статьи в интернете ничего не говорят о реальном механизме работы word2vec, поэтому решил сам опубликовать эту информацию.
Это также объясняет радикальный выбор GloVe установить отдельные векторы для отрицательного контекста — они просто сделали то, что делает word2vec, но сказали людям об этом :).
Это научный обман? Не знаю, трудный вопрос. Но честно говоря, я невероятно зол. Наверное, я больше никогда не смогу серьёзно относиться к объяснению алгоритмов в машинном обучении: в следующий раз я сразу пойду смотреть исходники.
Автор: m1rko