[Перевод] Всё, что вы знали о word2vec, неправда

habr.png

Классическое объяснение 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], что мы видим:
Но все эти реализации ошибочны.
Оригинальная реализация word2vec на C работает иначе и кардинально отличается от этой. Те, кто профессионально внедряет системы с вложениями слов из word2vec, делают одно из следующих действий:

  1. Напрямую вызывают исходную реализацию C.
  2. Используют реализацию 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, но сказали людям об этом :).

Это научный обман? Не знаю, трудный вопрос. Но честно говоря, я невероятно зол. Наверное, я больше никогда не смогу серьёзно относиться к объяснению алгоритмов в машинном обучении: в следующий раз я сразу пойду смотреть исходники.

© Habrahabr.ru