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 нужно знать следующее:
- Все значения при вызове функции передаются в виде указателей и нужно позаботиться о том, чтобы в явном виде прописать их тип. Надежнее всего работает передача параметров типа char с последующим преобразованием в нужный тип уже в C;
- Вызываемая функция не возвращает значение, т.е. должна быть типа void;
- В C-код нужно добавить инструкцию #include <R.h>, а если есть сложная математика, то еще и #include <R.math>;
- Если нужно что-то вывести на консоль 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 в обучающей выборке. К сожалению, проиллюстрировать это примерами весьма сложно.