Programming Assignment:

Готовим LDA по рецептам

Как вы уже знаете, в тематическом моделировании делается предположение о том, что для определения тематики порядок слов в документе не важен; об этом гласит гипотеза «мешка слов». Сегодня мы будем работать с несколько нестандартной для тематического моделирования коллекцией, которую можно назвать «мешком ингредиентов», потому что на состоит из рецептов блюд разных кухонь. Тематические модели ищут слова, которые часто вместе встречаются в документах, и составляют из них темы. Мы попробуем применить эту идею к рецептам и найти кулинарные «темы». Эта коллекция хороша тем, что не требует предобработки. Кроме того, эта задача достаточно наглядно иллюстрирует принцип работы тематических моделей.

Для выполнения заданий, помимо часто используемых в курсе библиотек, потребуются модули json и gensim. Первый входит в дистрибутив Anaconda, второй можно поставить командой

pip install gensim

Построение модели занимает некоторое время. На ноутбуке с процессором Intel Core i7 и тактовой частотой 2400 МГц на построение одной модели уходит менее 10 минут.

Загрузка данных

Коллекция дана в json-формате: для каждого рецепта известны его id, кухня (cuisine) и список ингредиентов, в него входящих. Загрузить данные можно с помощью модуля json (он входит в дистрибутив Anaconda):


In [1]:
import json

In [2]:
with open("recipes.json") as f:
    recipes = json.load(f)

In [3]:
print(recipes[0])


{u'cuisine': u'greek', u'id': 10259, u'ingredients': [u'romaine lettuce', u'black olives', u'grape tomatoes', u'garlic', u'pepper', u'purple onion', u'seasoning', u'garbanzo beans', u'feta cheese crumbles']}

Составление корпуса


In [4]:
from gensim import corpora, models
import numpy as np


D:\MyPrograms\Anaconda2\lib\site-packages\gensim\utils.py:862: UserWarning: detected Windows; aliasing chunkize to chunkize_serial
  warnings.warn("detected Windows; aliasing chunkize to chunkize_serial")

Наша коллекция небольшая, и целиком помещается в оперативную память. Gensim может работать с такими данными и не требует их сохранения на диск в специальном формате. Для этого коллекция должна быть представлена в виде списка списков, каждый внутренний список соответствует отдельному документу и состоит из его слов. Пример коллекции из двух документов:

[["hello", "world"], ["programming", "in", "python"]]

Преобразуем наши данные в такой формат, а затем создадим объекты corpus и dictionary, с которыми будет работать модель.


In [5]:
texts = [recipe["ingredients"] for recipe in recipes]
dictionary = corpora.Dictionary(texts)   # составляем словарь
corpus = [dictionary.doc2bow(text) for text in texts]  # составляем корпус документов

In [6]:
print(texts[0])
print(corpus[0])


[u'romaine lettuce', u'black olives', u'grape tomatoes', u'garlic', u'pepper', u'purple onion', u'seasoning', u'garbanzo beans', u'feta cheese crumbles']
[(0, 1), (1, 1), (2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), (8, 1)]

У объекта dictionary есть полезная переменная dictionary.token2id, позволяющая находить соответствие между ингредиентами и их индексами.

Обучение модели

Вам может понадобиться документация LDA в gensim.

Задание 1. Обучите модель LDA с 40 темами, установив количество проходов по коллекции 5 и оставив остальные параметры по умолчанию.

Затем вызовите метод модели show_topics, указав количество тем 40 и количество токенов 10, и сохраните результат (топы ингредиентов в темах) в отдельную переменную. Если при вызове метода show_topics указать параметр formatted=True, то топы ингредиентов будет удобно выводить на печать, если formatted=False, будет удобно работать со списком программно. Выведите топы на печать, рассмотрите темы, а затем ответьте на вопрос:

Сколько раз ингредиенты "salt", "sugar", "water", "mushrooms", "chicken", "eggs" встретились среди топов-10 всех 40 тем? При ответе не нужно учитывать составные ингредиенты, например, "hot water".

Передайте 6 чисел в функцию save_answers1 и загрузите сгенерированный файл в форму.

У gensim нет возможности фиксировать случайное приближение через параметры метода, но библиотека использует numpy для инициализации матриц. Поэтому, по утверждению автора библиотеки, фиксировать случайное приближение нужно командой, которая написана в следующей ячейке. Перед строкой кода с построением модели обязательно вставляйте указанную строку фиксации random.seed.


In [7]:
np.random.seed(76543)
# здесь код для построения модели:
lda_1 = models.LdaModel(corpus, id2word=dictionary, num_topics=40, passes=5)

In [8]:
topics = lda_1.show_topics(num_topics=40, num_words=10, formatted=False)

In [9]:
c_salt, c_sugar, c_water, c_mushrooms, c_chicken, c_eggs = 0, 0, 0, 0, 0, 0
for _, top_words in lda_1.print_topics(num_topics=40, num_words=10):
    c_salt      += top_words.count(u'salt')
    c_sugar     += top_words.count(u'sugar')
    c_water     += top_words.count(u'water')
    c_mushrooms += top_words.count(u'mushrooms')
    c_chicken   += top_words.count(u'chicken')
    c_eggs      += top_words.count(u'eggs')

In [10]:
def save_answers1(c_salt, c_sugar, c_water, c_mushrooms, c_chicken, c_eggs):
    with open("cooking_LDA_pa_task1.txt", "w") as fout:
        fout.write(" ".join([str(el) for el in [c_salt, c_sugar, c_water, c_mushrooms, c_chicken, c_eggs]]))

In [11]:
print(c_salt, c_sugar, c_water, c_mushrooms, c_chicken, c_eggs)
save_answers1(c_salt, c_sugar, c_water, c_mushrooms, c_chicken, c_eggs)


(27, 12, 14, 2, 11, 4)

Фильтрация словаря

В топах тем гораздо чаще встречаются первые три рассмотренных ингредиента, чем последние три. При этом наличие в рецепте курицы, яиц и грибов яснее дает понять, что мы будем готовить, чем наличие соли, сахара и воды. Таким образом, даже в рецептах есть слова, часто встречающиеся в текстах и не несущие смысловой нагрузки, и поэтому их не желательно видеть в темах. Наиболее простой прием борьбы с такими фоновыми элементами — фильтрация словаря по частоте. Обычно словарь фильтруют с двух сторон: убирают очень редкие слова (в целях экономии памяти) и очень частые слова (в целях повышения интерпретируемости тем). Мы уберем только частые слова.


In [12]:
import copy
dictionary2 = copy.deepcopy(dictionary)

Задание 2. У объекта dictionary2 есть переменная dfs — это словарь, ключами которого являются id токена, а элементами — число раз, сколько слово встретилось во всей коллекции. Сохраните в отдельный список ингредиенты, которые встретились в коллекции больше 4000 раз. Вызовите метод словаря filter_tokens, подав в качестве первого аргумента полученный список популярных ингредиентов. Вычислите две величины: dict_size_before и dict_size_after — размер словаря до и после фильтрации.

Затем, используя новый словарь, создайте новый корпус документов, corpus2, по аналогии с тем, как это сделано в начале ноутбука. Вычислите две величины: corpus_size_before и corpus_size_after — суммарное количество ингредиентов в корпусе (для каждого документа вычислите число различных ингредиентов в нем и просуммируйте по всем документам) до и после фильтрации.

Передайте величины dict_size_before, dict_size_after, corpus_size_before, corpus_size_after в функцию save_answers2 и загрузите сгенерированный файл в форму.


In [13]:
frequent_words = list()
for el in dictionary2.dfs:
    if dictionary2.dfs[el] > 4000:
        frequent_words.append(el)
print(frequent_words)


[0, 4, 9, 12, 17, 21, 29, 45, 48, 54, 100, 117]

In [14]:
dict_size_before = len(dictionary2.dfs)
dictionary2.filter_tokens(frequent_words)
dict_size_after  = len(dictionary2.dfs)

In [15]:
corpus2 = [dictionary2.doc2bow(text) for text in texts]

In [16]:
corpus_size_before = 0
for i in corpus:
    corpus_size_before += len(i)
    
corpus_size_after = 0
for i in corpus2:
    corpus_size_after += len(i)

In [17]:
def save_answers2(dict_size_before, dict_size_after, corpus_size_before, corpus_size_after):
    with open("cooking_LDA_pa_task2.txt", "w") as fout:
        fout.write(" ".join([str(el) for el in [dict_size_before, dict_size_after, corpus_size_before, corpus_size_after]]))

In [18]:
print(dict_size_before, dict_size_after, corpus_size_before, corpus_size_after)
save_answers2(dict_size_before, dict_size_after, corpus_size_before, corpus_size_after)


(6714, 6702, 428249, 343665)

Сравнение когерентностей

Задание 3. Постройте еще одну модель по корпусу corpus2 и словарю dictionary2, остальные параметры оставьте такими же, как при первом построении модели. Сохраните новую модель в другую переменную (не перезаписывайте предыдущую модель). Не забудьте про фиксирование seed!

Затем воспользуйтесь методом top_topics модели, чтобы вычислить ее когерентность. Передайте в качестве аргумента соответствующий модели корпус. Метод вернет список кортежей (топ токенов, когерентность), отсортированных по убыванию последней. Вычислите среднюю по всем темам когерентность для каждой из двух моделей и передайте в функцию save_answers3.


In [19]:
np.random.seed(76543)
lda_2 = models.LdaModel(corpus2, id2word=dictionary2, num_topics = 40, passes = 5)

In [20]:
top_topics_1 = lda_1.top_topics(corpus)
top_topics_2 = lda_2.top_topics(corpus2)

In [21]:
def topics_mean(all_topics):
    return np.mean([one_topics[1] for one_topics in all_topics])

coherence_1 = topics_mean(top_topics_1)
coherence_2 = topics_mean(top_topics_2)

In [22]:
def save_answers3(coherence_1, coherence_2):
    with open("cooking_LDA_pa_task3.txt", "w") as fout:
        fout.write(" ".join(["%3f"%el for el in [coherence_1, coherence_2]]))

In [23]:
print(coherence_1, coherence_2)
save_answers3(coherence_1, coherence_2)


(-7.1360905945630337, -7.7764436466728784)

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

Изучение влияния гиперпараметра alpha

В этом разделе мы будем работать со второй моделью, то есть той, которая построена по сокращенному корпусу.

Пока что мы посмотрели только на матрицу темы-слова, теперь давайте посмотрим на матрицу темы-документы. Выведите темы для нулевого (или любого другого) документа из корпуса, воспользовавшись методом get_document_topics второй модели:


In [24]:
lda_1.get_document_topics(corpus2[0])


Out[24]:
[(5, 0.12812500000000129),
 (16, 0.12812500000000099),
 (20, 0.12812500000000274),
 (22, 0.12812500000000651),
 (23, 0.12812499999998142),
 (24, 0.12812500000000379),
 (31, 0.12812500000000301)]

Также выведите содержимое переменной .alpha второй модели:


In [25]:
lda_1.alpha


Out[25]:
array([ 0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,
        0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,
        0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,
        0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,
        0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025,  0.025])

У вас должно получиться, что документ характеризуется небольшим числом тем. Попробуем поменять гиперпараметр alpha, задающий априорное распределение Дирихле для распределений тем в документах.

Задание 4. Обучите третью модель: используйте сокращенный корпус (corpus2 и dictionary2) и установите параметр alpha=1, passes=5. Не забудьте про фиксацию seed! Выведите темы новой модели для нулевого документа; должно получиться, что распределение над множеством тем практически равномерное. Чтобы убедиться в том, что во второй модели документы описываются гораздо более разреженными распределениями, чем в третьей, посчитайте суммарное количество элементов, превосходящих 0.01, в матрицах темы-документы обеих моделей. Другими словами, запросите темы модели для каждого документа с параметром minimum_probability=0.01 и просуммируйте число элементов в получаемых массивах. Передайте две суммы (сначала для модели с alpha по умолчанию, затем для модели в alpha=1) в функцию save_answers4.


In [26]:
np.random.seed(76543)

lda_3 = models.ldamodel.LdaModel(corpus2, id2word=dictionary2, num_topics=40, passes=5, alpha = 1)

In [27]:
lda_3.get_document_topics(corpus2[0])


Out[27]:
[(0, 0.021279629403707753),
 (1, 0.021276595744681662),
 (2, 0.021286183136532921),
 (3, 0.022008930955540032),
 (4, 0.021276595744680854),
 (5, 0.021302920821811417),
 (6, 0.021553046238824002),
 (7, 0.024310040780289029),
 (8, 0.021284580554432248),
 (9, 0.021369251252549352),
 (10, 0.021896374463066602),
 (11, 0.021310192350720236),
 (12, 0.021303215826730174),
 (13, 0.021276595744680854),
 (14, 0.021276595744688022),
 (15, 0.027059746670671483),
 (16, 0.021594479354485226),
 (17, 0.042603221939357581),
 (18, 0.021444189436621405),
 (19, 0.076787391410991945),
 (20, 0.021312724535934118),
 (21, 0.021276728469833025),
 (22, 0.021318508255410037),
 (23, 0.021314581389251627),
 (24, 0.021276595744680854),
 (25, 0.044850616032016559),
 (26, 0.021303505038848373),
 (27, 0.021352072074014572),
 (28, 0.043082541051111561),
 (29, 0.034935426987594415),
 (30, 0.021278290340131078),
 (31, 0.021276595744693233),
 (32, 0.022056827066347762),
 (33, 0.02138225116229724),
 (34, 0.021376839368448509),
 (35, 0.021781441319244393),
 (36, 0.021388310896115672),
 (37, 0.021362338229236832),
 (38, 0.021277625783365433),
 (39, 0.021296402936362067)]

In [28]:
def sum_doc_topics(model, corpus):
    return sum([len(model.get_document_topics(i, minimum_probability=0.01)) for i in corpus])

count_lda_2 = sum_doc_topics(lda_2,corpus2)
count_lda_3 = sum_doc_topics(lda_3,corpus2)

In [29]:
def save_answers4(count_model_2, count_model_3):
    with open("cooking_LDA_pa_task4.txt", "w") as fout:
        fout.write(" ".join([str(el) for el in [count_model_2, count_model_3]]))

In [30]:
print(count_lda_2, count_lda_3)
save_answers4(count_lda_2, count_lda_3)


(198126, 1590960)

Таким образом, гиперпараметр alpha влияет на разреженность распределений тем в документах. Аналогично гиперпараметр eta влияет на разреженность распределений слов в темах.

LDA как способ понижения размерности

Иногда, распределения над темами, найденные с помощью LDA, добавляют в матрицу объекты-признаки как дополнительные, семантические, признаки, и это может улучшить качество решения задачи. Для простоты давайте просто обучим классификатор рецептов на кухни на признаках, полученных из LDA, и измерим точность (accuracy).

Задание 5. Используйте модель, построенную по сокращенной выборке с alpha по умолчанию (вторую модель). Составьте матрицу $\Theta = p(t|d)$ вероятностей тем в документах; вы можете использовать тот же метод get_document_topics, а также вектор правильных ответов y (в том же порядке, в котором рецепты идут в переменной recipes). Создайте объект RandomForestClassifier со 100 деревьями, с помощью функции cross_val_score вычислите среднюю accuracy по трем фолдам (перемешивать данные не нужно) и передайте в функцию save_answers5.


In [31]:
from sklearn.ensemble import RandomForestClassifier
from sklearn.cross_validation import cross_val_score


D:\MyPrograms\Anaconda2\lib\site-packages\sklearn\cross_validation.py:44: DeprecationWarning: This module was deprecated in version 0.18 in favor of the model_selection module into which all the refactored classes and functions are moved. Also note that the interface of the new CV iterators are different from that of this module. This module will be removed in 0.20.
  "This module will be removed in 0.20.", DeprecationWarning)

In [32]:
X = np.zeros((len(recipes), 40))
y = [recipe['cuisine'] for recipe in recipes]

for i in range(len(recipes)):
    for top in lda_2.get_document_topics(corpus2[i]):
        X[i, top[0]] = top[1]

In [33]:
RFC = RandomForestClassifier(n_estimators = 100)
estimator = cross_val_score(RFC, X, y, cv=3).mean()

In [34]:
def save_answers5(accuracy):
     with open("cooking_LDA_pa_task5.txt", "w") as fout:
        fout.write(str(accuracy))

In [35]:
print(estimator)
save_answers5(estimator)


0.557450691336

Для такого большого количества классов это неплохая точность. Вы можете попробовать обучать RandomForest на исходной матрице частот слов, имеющей значительно большую размерность, и увидеть, что accuracy увеличивается на 10–15%. Таким образом, LDA собрал не всю, но достаточно большую часть информации из выборки, в матрице низкого ранга.

LDA — вероятностная модель

Матричное разложение, использующееся в LDA, интерпретируется как следующий процесс генерации документов.

Для документа $d$ длины $n_d$:

  1. Из априорного распределения Дирихле с параметром alpha сгенерировать распределение над множеством тем: $\theta_d \sim Dirichlet(\alpha)$
  2. Для каждого слова $w = 1, \dots, n_d$:
    1. Сгенерировать тему из дискретного распределения $t \sim \theta_{d}$
    2. Сгенерировать слово из дискретного распределения $w \sim \phi_{t}$.

Подробнее об этом в Википедии.

В контексте нашей задачи получается, что, используя данный генеративный процесс, можно создавать новые рецепты. Вы можете передать в функцию модель и число ингредиентов и сгенерировать рецепт :)


In [36]:
def generate_recipe(model, num_ingredients):
    theta = np.random.dirichlet(model.alpha)
    for i in range(num_ingredients):
        t = np.random.choice(np.arange(model.num_topics), p=theta)
        topic = model.show_topic(t, topn=model.num_terms)
        topic_distr = [x[1] for x in topic]
        terms = [x[0] for x in topic]
        w = np.random.choice(terms, p=topic_distr)
        print w

In [37]:
print(generate_recipe(lda_1, 5))
print('\n')
print(generate_recipe(lda_2, 5))
print('\n')
print(generate_recipe(lda_3, 5))


condensed reduced fat reduced sodium cream of chicken soup
eggplant
plum tomatoes
garlic
butter-margarine blend
None


chicken breasts
coriander
chicken breasts
oil
coriander
None


grated parmesan cheese
red wine vinegar
shrimp
zucchini
chiles
None

Интерпретация построенной модели

Вы можете рассмотреть топы ингредиентов каждой темы. Большиснтво тем сами по себе похожи на рецепты; в некоторых собираются продукты одного вида, например, свежие фрукты или разные виды сыра.

Попробуем эмпирически соотнести наши темы с национальными кухнями (cuisine). Построим матрицу $A$ размера темы $x$ кухни, ее элементы $a_{tc}$ — суммы $p(t|d)$ по всем документам $d$, которые отнесены к кухне $c$. Нормируем матрицу на частоты рецептов по разным кухням, чтобы избежать дисбаланса между кухнями. Следующая функция получает на вход объект модели, объект корпуса и исходные данные и возвращает нормированную матрицу $A$. Ее удобно визуализировать с помощью seaborn.


In [38]:
import pandas
import seaborn
from matplotlib import pyplot as plt
%matplotlib inline

In [39]:
def compute_topic_cuisine_matrix(model, corpus, recipes):
    # составляем вектор целевых признаков
    targets = list(set([recipe["cuisine"] for recipe in recipes]))
    # составляем матрицу
    tc_matrix = pandas.DataFrame(data=np.zeros((model.num_topics, len(targets))), columns=targets)
    for recipe, bow in zip(recipes, corpus):
        recipe_topic = model.get_document_topics(bow)
        for t, prob in recipe_topic:
            tc_matrix[recipe["cuisine"]][t] += prob
    # нормируем матрицу
    target_sums = pandas.DataFrame(data=np.zeros((1, len(targets))), columns=targets)
    for recipe in recipes:
        target_sums[recipe["cuisine"]] += 1
    return pandas.DataFrame(tc_matrix.values/target_sums.values, columns=tc_matrix.columns)

In [40]:
def plot_matrix(tc_matrix):
    plt.figure(figsize=(10, 10))
    seaborn.heatmap(tc_matrix, square=True)

In [41]:
# Визуализируйте матрицу
plot_matrix(compute_topic_cuisine_matrix(lda_1, corpus, recipes))



In [42]:
plot_matrix(compute_topic_cuisine_matrix(lda_2, corpus2, recipes))



In [43]:
plot_matrix(compute_topic_cuisine_matrix(lda_3, corpus2, recipes))


Чем темнее квадрат в матрице, тем больше связь этой темы с данной кухней. Мы видим, что у нас есть темы, которые связаны с несколькими кухнями. Такие темы показывают набор ингредиентов, которые популярны в кухнях нескольких народов, то есть указывают на схожесть кухонь этих народов. Некоторые темы распределены по всем кухням равномерно, они показывают наборы продуктов, которые часто используются в кулинарии всех стран.

Жаль, что в датасете нет названий рецептов, иначе темы было бы проще интерпретировать...

Заключение

В этом задании вы построили несколько моделей LDA, посмотрели, на что влияют гиперпараметры модели и как можно использовать построенную модель.