Word2vec является практически единственным алгоритмом deep learning, который сравнительно легко можно запустить на обычном ПК (а не на видеокартах) и который строит распределенное представление слов за приемлемое время, по крайней мере так считают на Kaggle. Прочитав здесь про то, какие фокусы можно делать с тренированной моделью, я понял, что такую штуку просто обязан попробовать. Проблема только одна, я преимущественно работаю на языке R, а вот официальную реализацию word2vec под R мне найти не удалось, думаю её просто нет.

Зато есть исходники word2vec на C и описание на сайте Google, а в R есть возможность использовать внешние библиотеки на C, C++ и Fortran. Кстати, самые быстрые библиотеки R сделаны именно на C и С++. Еще есть R-обертка tmcn.word2vec, которая находится в стадии разработки. Её автор,
Jian Li (сайт на китайском) сделал что-то вроде демоверсии для китайского языка (с английским тоже работает, с русским пока не пробовал). Проблемы с этой версией следующие:
  • Во-первых, все параметры зашиты в C-коде;
  • Во-вторых, автор сделал только одну функцию для работы с обученной моделью – distance, которая оценивает сходство слов и выводит 20 вариантов с максимальным значением;
  • В-третьих, мне не удалось собрать пакет под x64 Windows. На win32 пакет ставится без проблем.

Оценив всё это «богатство», я решил сделать свой вариант R-интерфейса к word2vec. Сказать по правде, не очень хорошо знаю С, приходилось писать только простенькие программы, поэтому за основу я решил взять исходники Jian Li, потому что они точно компилируются под Windows, иначе бы не было пакета. Если что-то не будет работать, их всегда можно сверить с оригиналом.

Подготовка


Для того чтобы компилировать C-код для R под Windows нужно дополнительно установить Rtools. Этот набор инструментов содержит компилятор gcс, который запускается под Cygwin. После установки Rtools нужно проверить переменную PATH. Там должно быть что-то вроде:
D:\Rtools\bin;D:\Rtools\gcc-4.6.3\bin;D:\R\bin

Под OS X никаких Rtools не требуется. Нужен установленный компилятор, наличие которого проверяется командой gcc --version. Если его нет, нужно установить Xcode и через Xcode — Command Line Tools.

Про вызов С-библиотек из R нужно знать следующее:
  1. Все значения при вызове функции передаются в виде указателей и нужно позаботиться о том, чтобы в явном виде прописать их тип. Надежнее всего работает передача параметров типа char с последующим преобразованием в нужный тип уже в C;
  2. Вызываемая функция не возвращает значение, т.е. должна быть типа void;
  3. В C-код нужно добавить инструкцию #include <R.h>, а если есть сложная математика, то еще и #include <R.math>;
  4. Если нужно что-то вывести на консоль R, вместо printf() лучше использовать Rprintf(). Правда у меня printf() тоже работает.

Для начала я решил сделать что-то очень простое, типа Hello, World! Но так, чтобы туда передавалось какое-либо значение. Rstudio, которой я обычно пользуюсь, позволяет писать C и C++ код и всё правильно подсвечивает. Написав и сохранив код в hello.c я вызвал командную строку, перешел в нужный каталог и запустил компилятор следующей командой:
> R --arch x64 CMD SHLIB hello.c

Под win32 ключ архитектуры не нужен:
> R CMD SHLIB hello.c

В результате, в каталоге появилось два файла, hello.o (его можно смело удалить) и библиотека hello.dll. (На OS X вместо dll получится файл с расширением so). Вызов полученной функции hello в R осуществляется следующим кодом:
dyn.load("hello.dll")
hellof <- function(n) {
    .C("hello", as.integer(n))
}
hellof(5)

Тест показал, что всё работает правильно и для экспериментов с word2vec осталось подготовить данные. Я решил взять их на Kaggle из задачи «Bag of Words Meets Bags of Popcorn». Там есть обучающая, тестовая и неразмеченная выборки, которые в сумме содержат сто тысяч ревю фильмов из IMDB. Загрузив эти файлы, я убрал из них HTML-теги, специальные символы, цифры, знаки препинания, стоп-слова и токенизировал. Подробности обработки опускаю, я про них уже писал.

Word2vec принимает данные для обучения в виде текстового файла с одной длинной строкой, содержащей слова, разделенные пробелами (выяснил это, анализируя примеры работы с word2vec из официальной документации). Склеил наборы данных в одну строку и сохранил её в текстовом файле.

Модель


В варианте Jian Li — это два файла word2vec.h и word2vec.c. В первом содержится основной код, который в главном совпадает с оригинальным word2vec.c. Во втором — обертка для вызова функции TrainModel(). Первое, что я решил сделать — вытащить все параметры модели в R-код. Нужно было отредактировать R-скрипт и обертку в word2vec.c, получилась вот такая конструкция:
dyn.load("word2vec.dll")
word2vec <- function(train_file, output_file, 
                     binary,
                     cbow,
                     num_threads,
                     num_features,
                     window,
                     min_count,
                     sample)
{
	//...здесь вспомогательный код и проверки...
    
    OUT <- .C("CWrapper_word2vec", 
              train_file = as.character(train_file), 
              output_file = as.character(output_file),
              binary = as.character(binary), //... аналогично другие параметры
              )
 	//...здесь вывод диагностики из выходного потока OUT...
}
word2vec("train_data.txt", "model.bin", 
         binary=1, # output format, 1-binary, 0-txt
         cbow=0, # skip-gram (0) or continuous bag of words (1)
         num_threads = 1, # num of workers
         num_features = 300, # word vector dimensionality
         window = 10, # context / window size
         min_count = 40, # minimum word count
         sample = 1e-3 # downsampling of frequent words
         )

Несколько слов про параметры:
binary — выходной формат модели;
cbow — какой алгоритм использовать для обучения skip-gram или мешок слов (cbow). Skip-gram работает медленнее, но дает лучший результат на редких словах;
num_threads — количество потоков процессора, задействованных при построении модели;
num_features — размерность пространства слов (или вектора для каждого слова), рекомендуется от десятков до сотен;
window — как много слов из контекста обучающий алгоритм должен принимать во внимание;
min_count — ограничивает размер словаря для значимых слов. Слова, которые не встречаются в тексте больше указанного количества, игнорируются. Рекомендованное значение — от десяти до ста;
sample — нижняя граница частоты встречаемости слов в тексте, рекомендуется от .00001 до .01.

Компилировал следующей командой с рекомендованными в makefile ключами:
>R --arch x64 CMD SHLIB -lm -pthread -O3 -march=native -Wall -funroll-loops -Wno-unused-result word2vec.c

Компилятор выдал некоторое количество предупреждений, но ничего серьезного, заветная word2vec.dll появилась в рабочем каталоге. Без проблем загрузил её в R функцией dyn.load(«word2vec.dll») и запустил одноименную функцию. Думаю, полезным является только ключ pthread. Без остальных можно обойтись (часть из них прописана в конфигурации Rtools).

Результат:
Всего в моем файле оказалось 11.5 млн. слов, словарь — 19133 слова, время построения модели 6 минут на компьютере с Intel Core i7. Чтобы проверить, работают ли мои параметры, я поменял значение num_threads с единицы на шесть. Можно было бы и не смотреть на мониторинг ресурсов, время построения модели сократилось до полутора минут. То есть эта штука умеет обрабатывать одиннадцать миллионов слов за минуты.

Оценка сходства


В distance я практически ничего менять не стал, только вытащил параметр количества возвращаемых значений. Затем скомпилировал библиотеку, загрузил её в R и проверил на двух словах «bad» и «good», учитывая, что имею дело с положительными и отрицательными ревю:
Word: bad  Position in vocabulary: 15
         Word   CosDist
1    terrible 0.5778409
2    horrible 0.5541780
3       lousy 0.5527389
4       awful 0.5206609
5   laughably 0.4910716
6   atrocious 0.4841466
7      horrid 0.4808238
8        good 0.4805901
9       worse 0.4726501
10 horrendous 0.4579800

Word: good  Position in vocabulary: 6
        Word   CosDist
1     decent 0.5678578
2       nice 0.5364762
3      great 0.5197815
4        bad 0.4805902
5  excellent 0.4554003
6         ok 0.4365533
7    alright 0.4361723
8     really 0.4153538
9      liked 0.4061105
10      fine 0.4004776

Всё снова получилось. Интересно, что от bad до good дистанция больше чем от good до bad если считать в словах. Ну, как говорится «от любви до ненависти...» ближе чем наоборот. Алгоритм рассчитывает сходство как косинус угла между векторами по следующей формуле (картинка из вики):

А значит, имея обученную модель, можно рассчитать дистанцию без С, и вместо сходства оценить, например, различия. Для этого нужно построить модель в текстовом формате (binary=0), загрузить её в R при помощи read.table() и написать некоторое количество кода, что я и сделал. Код без обработки исключений:
similarity <- function(word1, word2, model) {
    size <- ncol(model)-1
    vec1 <- model[model$word==word1,2:size]
    vec2 <- model[model$word==word2,2:size]
    sim <- sum(vec1 * vec2)
    sim <- sim/(sqrt(sum(vec1^2))*sqrt(sum(vec2^2)))
    return(sim)
}
difference <- function(string, model) {
    words <- tokenize(string)
    num_words <- length(words)
    diff_mx <- matrix(rep(0,num_words^2), nrow=num_words, ncol=num_words)
    for (i in 1:num_words) {
        for (j in 1:num_words) {
            sim <- similarity(words[i],words[j],model)
            if(i!=j) {
                diff_mx[i,j]=sim
            }
        }
    }
    return(words[which.min(rowSums(diff_mx))])
}

Здесь строится квадратная матрица размером количество слов в запросе на количество слов. Дальше для каждой пары несовпадающих слов рассчитывается сходство. Потом значения суммируются по строкам, находится строка с минимальной суммой. Номер строки соответствует позиции «лишнего» слова в запросе. Работу можно ускорить, если считать только половину матрицы. Пара примеров:
> difference("squirrel deer human dog cat", model)
[1] "human"
> difference("bad red good nice awful", model)
[1] "red"

Аналогии


Поиск аналогий позволяет решать задачки типа «мужчина относится к женщина как король относится к ?». Специальная функция word-analogy есть только в оригинальном коде Google, поэтому с ней пришлось повозиться. Я написал обертку для вызова функции из R, убрал из кода бесконечный цикл и заменил стандартные потоки ввода-вывода на передачу параметров. Затем скомпилировал в библиотеку и сделал несколько экспериментов. Штука с королем-королевой у меня не получилась, видимо одиннадцати миллионов слов маловато (авторы word2vec рекомендуют в районе миллиарда). Несколько удачных примеров:
> analogy("model300.bin", "man woman king", 3)
      Word   CosDist
1   throne 0.4466286
2     lear 0.4268206
3 princess 0.4251665

> analogy("model300.bin", "man woman husband", 3)
        Word   CosDist
1       wife 0.6323696
2 unfaithful 0.5626401
3    married 0.5268299

> analogy("model300.bin", "man woman boy", 3)
     Word   CosDist
1    girl 0.6313665
2  mother 0.4309490
3 teenage 0.4272232

Кластеризация


Почитав документацию я понял, что оказывается в word2vec есть встроенная K-Means кластеризация. И чтобы ей воспользоваться достаточно «вытащить» в R еще один параметр — classes. Это количество кластеров, если оно больше нуля, word2vec выдаст текстовый файл формата слово — номер кластера. Триста кластеров оказалось мало чтобы получить что-то вменяемое. Эвристика от разработчиков: размер словаря поделенный на 5. Соответственно выбрал 3000. Приведу несколько удачных кластеров (удачных в том смысле, что я понимаю, почему эти слова рядом):
           word   id
335       humor 2952
489     serious 2952
872      clever 2952
1035     humour 2952
1796 references 2952
1916     satire 2952
2061  slapstick 2952
2367     quirky 2952
2810      crude 2952
2953      irony 2952
3125 outrageous 2952
3296      farce 2952
3594      broad 2952
4870  silliness 2952
4979       edgy 2952

        word  id
1025     cat 241
3242   mouse 241
11189 minnie 241

           word  id
1089       army 322
1127   military 322
1556    mission 322
1558    soldier 322
3254       navy 322
3323     combat 322
3902    command 322
3975       unit 322
4270    colonel 322
4277  commander 322
7821    platoon 322
7853    marines 322
8691      naval 322
9762        pow 322
10391        gi 322
12452     corps 322
15839  infantry 322
16697     diver 322

С помощью кластеризации нетрудно сделать сентимент-анализ. Для этого нужно построить «мешок кластеров» — матрицу размером количество ревю на максимальное количество кластеров. В каждой ячейки такой матрицы должно быть количество попаданий слов из ревю в заданный кластер. Я не пробовал, но проблем здесь не вижу. Говорят, что точность для ревю из IMDB получается такой же или немного меньше, чем если это делать через «Мешок слов».

Фразы


Word2vec умеет работать с фразами, вернее с устойчивыми сочетаниями слов. Для этого в оригинальном коде есть процедура word2phrase. Её задача – найти часто встречающиеся сочетания слов и заменить пробел между ними на нижнее подчеркивание. Файл, который получается после первого прохода содержит двойки слов. Если его снова отправить в word2phrase, появятся тройки и четверки. Результат потом можно использовать для тренировки word2vec.
Сделал вызов этой процедуры из R по аналогии с word2vec:
word2phrase("train_data.txt", 
            "train_phrase.txt",
            min_count=5,   
            threshold=100)

Параметр min_count позволяет не рассматривать словосочетания, встречающиеся мене заданного значения, threshold управляет чувствительностью алгоритма, чем больше значение, тем меньше фраз будет найдено. После второго прохода у меня получилось около шести тысяч сочетаний. Чтобы посмотреть на сами фразы я сначала сделал модель в текстовом формате, вытащил оттуда столбец слов и отфильтровал по нижнему подчеркиванию. Вот фрагмент для примера:
[5887] "works_perfectly"                     "four_year_old"                       "multi_million_dollar"               
[5890] "fresh_faced"                         "return_living_dead"                  "seemed_forced"                      
[5893] "freddie_prinze_jr"                   "re_lucky"                            "puerto_rico"                        
[5896] "every_sentence"                      "living_hell"                         "went_straight"                      
[5899] "supporting_cast_including"           "action_set_pieces"                   "space_shuttle"     

Выбрал несколько фраз для distance():
> distance("p_model300_2.bin", "crouching_tiger_hidden_dragon", 10)
Word: crouching_tiger_hidden_dragon  Position in vocabulary: 15492
                 Word   CosDist
1           tsui_hark 0.6041993
2             ang_lee 0.5996884
3  martial_arts_films 0.5541546
4      kung_fu_hustle 0.5381692
5        blockbusters 0.5305687
6           kill_bill 0.5279162
7          grindhouse 0.5242150
8             churned 0.5224440
9             budgets 0.5141657
10           john_woo 0.5046486

> distance("p_model300_2.bin", "academy_award_winning", 10)
Word: academy_award_winning  Position in vocabulary: 15780
                   Word   CosDist
1           nominations 0.4570983
2         ever_produced 0.4558123
3  francis_ford_coppola 0.4547777
4     producer_director 0.4545878
5          set_standard 0.4512480
6         participation 0.4503479
7     won_academy_award 0.4477891
8          michael_mann 0.4464636
9           huge_budget 0.4424854
10    directorial_debut 0.4406852


На этом я эксперименты пока завершил. Одно важное замечание, word2vec «общается» с памятью напрямую, в результате R может работать нестабильно и аварийно завершать сессию. Иногда это связано с выводом диагностических сообщений от ОС, которые R не может корректно обработать. Если ошибок в коде нет, то помогает перезапустить интерпретатор или Rstudio.

R-код, исходники на C и скомпилированные под x64 Windows dll в моем репозитарии.

UPD:
В результате спора с ServPonomarev и последующего анализа кода word2vec, удалось выяснить, что алгоритм обучается строками по 1000 слов, по которым движется окно в плюс/минус 5 слов. При обнаружении символа EOL, который алгоритм преобразует в специальное слово с нулевым номеров в словаре, движение окна останавливается и продолжается уже в новой строке. Представление слов, разделенных EOL, в модели будет отличаться от представления этих же слов, разделенных пробелом. Вывод: если исходный текст — это совокупность документов, а также фраз или абзацев, разделенных переводом строки, не стоит избавляться от этой дополнительной информации, т.е. оставить символы EOL в обучающей выборке. К сожалению, проиллюстрировать это примерами весьма сложно.

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


  1. ServPonomarev
    29.05.2015 10:20

    Word2vec принимает данные для обучения в виде текстового файла с одной длинной строкой, содержащей слова, разделенные пробелами (выяснил это, анализируя примеры работы с word2vec из официальной документации). Склеил наборы данных в одну строку и сохранил её в текстовом файле.


    Неверно. Перевод строки используется как разделитель документов. Соответственно — одна строка, один документ. Это очень полезно, если текст предварительно побит на фразы или составлен из большого количества независимых друг от друга текстов.


    1. khmelkoff Автор
      29.05.2015 17:40

      Извините, не нарочно ответил ниже.

      Я попробовал сейчас на скорую руку просто ввести два абзаца в строку обучающей выборки, ничего заметно не изменилось. Пространство слов из word2vec — это матрица, первый столбец — слова из словаря, начиная со второго по последний — значения компонент вектора. Есть вероятность, что эти значения меняются для слов, которые находятся между переводом строки, но это сложно увидеть. Если получу подтверждение, напишу update.


      1. ServPonomarev
        29.05.2015 22:53

        Смотрите в код, и будет Вам счастье.


        1. khmelkoff Автор
          30.05.2015 10:09

          Да, счастья бы не помешало.

          Вот так модель считывает слова из файла:

          void ReadWord(char *word, FILE *fin) {
            int a = 0, ch;
            while (!feof(fin)) {
              ch = fgetc(fin);
              if (ch == 13) continue;
              if ((ch == ' ') || (ch == '\t') || (ch == '\n')) {
                if (a > 0) {
                  if (ch == '\n') ungetc(ch, fin);
                  break;
                }
                if (ch == '\n') {
                  strcpy(word, (char *)"</s>");
                  return;
                } else continue;
              }
              word[a] = ch;
              a++;
              if (a >= MAX_STRING - 1) a--;   // Truncate too long words
            }
            word[a] = 0;
          }
          

          Из кода видно, что EOL может и не быть в файле, а значит моя фраза абсолютно корректна.
          Теперь, что будет, если EOL в файле есть.
          Он заменяется на специальное слово:
          </s>
          Это слово не участвует в расчете частоты, поэтому не выбрасывается из словаря и всегда находится в его начале. Если EOL в тексте несколько, меняется распределение слов, находящихся рядом с EOL. Вот расчет дистанции, если EOL только один:
          Word: </s>  Position in vocabulary: 0
                    Word   CosDist
          1       factor 0.3524520
          2  frightening 0.3380254
          3        admit 0.3336470
          4        scary 0.3289483
          5     scariest 0.3148671
          6         prom 0.3142520
          7         time 0.3083785
          8   paranormal 0.3076151
          9       cheesy 0.3073355
          10   impressed 0.3042146
          

          А вот, если три (текст тот же самый, просто я добавил три EOL):
          Word: </s>  Position in vocabulary: 0
          1       yelling 0.3524468
          2       toddler 0.3464943
          3       janitor 0.3398002
          4       running 0.3376773
          5         drunk 0.3368221
          6         bunch 0.3361016
          7            iq 0.3348956
          8  pathetically 0.3328072
          9  unbelievably 0.3261482
          10     retarded 0.3218134
          

          А теперь попробуйте из этого что-то понять про абзацы внутри текста.
          Удачи!


          1. ServPonomarev
            30.05.2015 15:28
            +2

            Найдите место в коде, где идёт непосредственное обучение, и увидите, что по EOL движение по окну прекращается. Впрочем, если Вам важно показать свою правоту — спорить не буду.


            1. khmelkoff Автор
              01.06.2015 10:58

              Спасибо, Вы правы. Нашел, написал update.


              1. ServPonomarev
                01.06.2015 12:17

                На самом деле разница очень заметна. Всё зависит от корпуса, если у Вас любые тексты, то разбиение их на абзацы/предложения даёт слабый эффект, поскольку тематика текста не сильно изменяется, и соответсвенно, сохраняется при пересечении границы фраз.

                Однако, если Вы используете специальные источники информации — в виде коротких, несвязанных друг с другом реплик, то тематика при пересечении границы реплики меняется кардинально, и, соответственно, эффект ощутим.

                Такж эффект ощутим, если Вы учитесь с коротким окном — для предсказания грамматических аттрибутов слов и знаков препинания.


  1. khmelkoff Автор
    29.05.2015 10:34

    Да, спасибо. Пробовал и так, не нашел на что это влияет. В примере от разработчиков одна большая строка на 80 мегабайт: mattmahoney.net/dc/text8.zip


    1. buriy
      30.05.2015 21:37
      +1

      Влияет на качество. Совсем немного, но влияет. Влияние зависит от задачи, конечно, и его трудно измерить без эталона, а эталона у нас нет.
      Разница в том, что без перевода строк слова из соседних предложений будут «соседними» и будут влиять друг на друга. Хорошо это или плохо, по-вашему?


      1. khmelkoff Автор
        01.06.2015 10:59

        Да, спасибо. Уже написал update.


  1. zodiak
    29.05.2015 10:45

    Как и автор топика некоторое время назад заинтересовался той самой задачкой на Kaggle. Только на Python + Gensim. И вот тут меня заклинило и не могу понять один нюанс, т.ч. если кто-то знает — подскажите пожалуйста.
    В чем разница между моделями Word2Vec (есть и в gensim и в Google-реализации), Phrase2Vec (есть в gensim, в Google-реализации есть что-то похожее: Word2Phrase), Doc2Vec (есть только в gensim)


  1. khmelkoff Автор
    29.05.2015 11:04

    С сайта gensim: The training algorithms were originally ported from the C package code.google.com/p/word2vec and extended with additional functionality.

    Единственное, чего не понял, почему они не используют оригинальную кластеризацию из word2vec и предлагают взять K-Means из scikit-learn вот в этом примере.


    1. kmike
      30.05.2015 01:29

      В gensim своя реализация word2vec, там не используется код из оригинальной библиотеки. Поэтому, чтоб не плодить велосипеды и не реализовывать еще и k-means, кластеризации в gensim нет. Она там и не нужна, раз можно взять готовую проверенную реализацию из scikit-learn.


  1. akrot
    29.05.2015 16:29

    Очень грамотно написанная статья!


    1. khmelkoff Автор
      29.05.2015 17:28

      Спасибо!