Глубокое обучение на R, тренируем word2vec

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