Классическое объяснение 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, но сказали людям об этом :).

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

Комментарии (5)


  1. perfect_genius
    05.06.2019 16:33

    Леонардо да Винчи тоже оставлял «дефекты» в своих чертежах, нарочно. Может, авторы хотели, чтобы к ним обратились за разъяснением.


  1. roryorangepants
    05.06.2019 17:38

    с отрицательной выборкой

    В оригинале другое.
    с вложениями слов

    Никто так не говорит.
    той мере, в какой совпадают названия переменных

    Действительно, gensim — единственная известная мне верная реализация на C.

    В оригинале другое.
    Один вектор для этого слова в фокусе, а второй для слова в контексте

    Кому-нибудь понятно, что имел в виду переводчик?

    Дальше читал в оригинале.


    1. maxood
      06.06.2019 15:04
      +1

      с вложениями слов

      Да, звучит смешно. Перевод оторванный от контекста (words embedding in a continuous vector space) и сделанный с ошибкой — embedding = встраивание. По-русски я б написал «векторное представление слов».


  1. QtRoS
    06.06.2019 08:55

    Я прекрасно понимаю автора, поскольку несколько раз пытался разобраться и воспроизвести, например в рамках курса по tensorflow (или про deep learning в целом на примере tensorflow) от Udacity. Да, что-то похожее получалось, но не то.
    Но справедливости ради хочу заметить, что описание реализации с двумя векторами мне попадалось, если нужно будет — поищу пруф. Тоже тогда недоумевал, почему такие детали скрыты...


  1. maxood
    06.06.2019 11:42

    2-3 года назад хотел досконально разобраться в алгоритме, в результате написал свой вариант — github.com/maxoodf/word2vec