Прогнозирование настроений при проверке Yelp в будущем

Обзор тематического моделирования

Тематическое моделирование в НЛП направлено на поиск скрытой семантической структуры в документах. Это вероятностные модели, которые могут помочь вам прочесать огромные объемы необработанного текста и сгруппировать похожие группы документов вместе без присмотра.

Этот пост специально посвящен скрытому распределению Дирихле (LDA), который был методом, предложенным в 2000 году для популяционной генетики и повторно обнаруженным независимо от ML-героя Эндрю Нг и др. в 2003 году. LDA заявляет, что каждый документ в корпусе представляет собой комбинацию фиксированного количества тем. Тема имеет вероятность генерировать различные слова, где слова - это все наблюдаемые слова в корпусе. Затем эти «скрытые» темы обнаруживаются на основе вероятности совпадения слов. Формально это проблема байесовского вывода [1].

Выход LDA

После того, как моделирование тем LDA будет применено к набору документов, вы сможете увидеть слова, составляющие каждую скрытую тему. В моем случае я взял 100 000 отзывов из Yelp Restaurants в 2016 году с использованием набора данных Yelp [2]. Вот два примера тем, обнаруженных через LDA:

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

Преобразование неконтролируемого вывода в контролируемую задачу

Меня больше интересовало, можно ли преобразовать эту скрытую семантическую структуру (сгенерированную неконтролируемым образом) для использования в задаче контролируемой классификации. Предположим на минуту, что я обучил только модель LDA, чтобы найти 3 темы, как указано выше. После обучения я мог взять все 100 000 отзывов и увидеть распределение тем для каждого обзора. Другими словами, одни документы могут на 100% относиться к теме 1, другие - на 33% / 33% / 33% к темам 1/2/3 и т. Д. Эти выходные данные являются просто вектором для каждого обзора, показывающим распределение. Идея здесь состоит в том, чтобы проверить, может ли распределение скрытой семантической информации при просмотре предсказывать положительные и отрицательные настроения.

Цель проекта

С этим вступлением я поставил себе цель:

Конкретно:

  1. Обучите модель LDA на 100000 отзывов о ресторанах за 2016 год
  2. Получение тематических распределений для каждого обзора с использованием модели LDA
  3. Используйте распределение тем непосредственно в качестве векторов признаков в моделях контролируемой классификации (логистическая регрессия, SVC и т. Д.) И получите оценку F1.
  4. Используйте ту же модель LDA 2016 года, чтобы получить распределение тем за 2017 год (модель LDA не видела этих данных!)
  5. Снова запустите модели контролируемой классификации на векторах 2017 года и посмотрите, будет ли это обобщением.

Если контролируемые F1-баллы по невидимым данным обобщают, то мы можем утверждать, что тематическая модель 2016 года выявила скрытую семантическую структуру, которая сохраняется с течением времени в этой области обзора ресторанов.

Подготовка данных

ОБНОВЛЕНИЕ (23.09.19): я добавил README в Репо, в котором показано, как создать MongoDB с использованием исходных данных. Я также включил сценарий предварительной обработки, который позволит вам создать точные обучающие и тестовые DataFrames, которые я использую ниже. Однако я понимаю, что это может потребовать много работы, поэтому я также включил файлы pickle моих Train и Test DataFrames в каталог здесь. Это позволит вам следить за записными книжками непосредственно в репо, а именно здесь, а затем здесь. Если вы хотите просто получить основные моменты / выводы, я выделю все ключевые моменты в оставшейся части этого сообщения в блоге ниже с помощью фрагментов кода.

Предварительная обработка LDA

Я использовал поистине замечательную библиотеку gensim для создания двухграммных представлений обзоров и запуска LDA. Реализация LDA Gensim нуждается в редких обзорах. Удобно, что gensim также предоставляет удобные утилиты для преобразования плотных матриц NumPy или scipy разреженных матриц в требуемую форму.

Я покажу, как я получил необходимое представление с помощью функций gensim. Я начал с pandas DataFrame, содержащего текст каждого обзора в столбце с именем'text’, который можно извлечь в список списка строк, где каждый список представляет собой обзор. Это объект с именемwords в моем примере ниже:

from nltk.corpus import stopwords
stop_words = stopwords.words('english')
stop_words.extend(['come','order','try','go','get','make','drink','plate','dish','restaurant','place','would','really','like','great','service','came','got']) 
def remove_stopwords(texts):
    out = [[word for word in simple_preprocess(str(doc))
            if word not in stop_words]
            for doc in texts]
    return out
def bigrams(words, bi_min=15, tri_min=10):
    bigram = gensim.models.Phrases(words, min_count = bi_min)
    bigram_mod = gensim.models.phrases.Phraser(bigram)
    return bigram_mod
def get_corpus(df):
    df['text'] = strip_newline(df.text)
    words = list(sent_to_words(df.text))
    words = remove_stopwords(words)
    bigram_mod = bigrams(words)
    bigram = [bigram_mod[review] for review in words]
    id2word = gensim.corpora.Dictionary(bigram)
    id2word.filter_extremes(no_below=10, no_above=0.35)
    id2word.compactify()
    corpus = [id2word.doc2bow(text) for text in bigram]
    
    return corpus, id2word, bigram
train_corpus, train_id2word, bigram_train = get_corpus(rev_train)

Для краткости я опускаю несколько шагов дополнительной предварительной обработки (пунктуация, удаление новых строк и т. Д.).

В этом блоке кода действительно есть 2 ключевых элемента:

  1. Класс Gensim's Phrases позволяет группировать связанные фразы в один токен для LDA. Например, обратите внимание, что в списке найденных тем ближе к началу этого сообщения ice_cream был указан как один токен. Таким образом, вывод этой строки bigram = [bigram_mod[review] for review in words] представляет собой список списков, где каждый список представляет собой обзор, а строки в каждом списке представляют собой смесь униграмм и биграмм. Дело в том, что мы применили модель bigram_mod фразового моделирования к каждому обзору.
  2. Когда у вас есть этот список списков униграмм и биграмм, вы можете передать его в класс Dictionary gensim. Будет выведено количество встречаемости каждого слова для каждого отзыва. Я обнаружил, что у меня были лучшие результаты с LDA, когда я дополнительно проделал некоторую обработку, чтобы удалить наиболее распространенные и самые редкие слова в корпусе, как показано в строке 21 приведенного выше блока кода. Наконец, вот что делает doc2bow() из их официальных примеров [3]:

«Функция doc2bow() просто подсчитывает количество вхождений каждого отдельного слова, преобразует слово в его целочисленный идентификатор слова и возвращает результат в виде разреженного вектора. Таким образом, разреженный вектор [(0, 1), (1, 1)] гласит: в документе «Взаимодействие человека с компьютером» слова компьютер (идентификатор 0) и человек (идентификатор 1) появляются один раз; остальные десять словарных слов встречаются (неявно) ноль раз ».

Строка 23 выше дает нам корпус в представлении, необходимом для LDA.

Чтобы проиллюстрировать тип текста, с которым мы имеем дело, вот снимок обзора Yelp:

Выбор количества тем для LDA

Чтобы обучить модель LDA, вам необходимо предоставить фиксированное предполагаемое количество тем в вашем корпусе. Есть несколько способов приблизиться к этому:

  1. Запустите LDA в своем корпусе с разным количеством тем и посмотрите, выглядит ли разумным распределение слов по темам.
  2. Изучите оценки согласованности вашей модели LDA и эффективно проведите поиск по сетке, чтобы выбрать самую высокую согласованность [4].
  3. Создайте несколько моделей LDA с разными значениями тем, а затем посмотрите, как они работают при обучении контролируемой модели классификации. Это относится к моим целям здесь, поскольку моя конечная цель - увидеть, имеют ли распределения тем прогностическую ценность.

Из них: я вообще не доверяю №1 как методу. Кто я такой, чтобы говорить, что разумно или нет в этом контексте? Я полагаюсь на LDA для определения скрытых тематических представлений 100 000 документов, и вполне возможно, что это не обязательно будет интуитивно понятным. Для №2: я разговаривал с некоторыми бывшими профессионалами НЛП, и они отговорили меня полагаться на оценки согласованности, основанные на их опыте работы в отрасли. Подход №3 был бы разумным для моей цели, но в действительности LDA требует нетривиального времени для обучения, даже если я использовал 8-ядерный экземпляр AWS объемом 16 ГБ.

Таким образом, у меня возникла идея, которая, на мой взгляд, является довольно новой - по крайней мере, я не видел, чтобы кто-то делал это в Интернете или в газетах:

Gensim также предоставляет класс иерархического процесса Дирихле (HDP) [5]. HDP похож на LDA, за исключением того, что он пытается узнать правильное количество тем из данных; то есть вам не нужно указывать фиксированное количество тем. Я подумал, что несколько раз проведу HDP с моими 100 000 обзоров и посмотрю, сколько тем он изучает. В моем случае это всегда было 20 тем, поэтому я пошел с этим.

Чтобы получить интуитивное представление о HDP: я нашел несколько источников в Интернете, которые сказали, что это больше всего похоже на китайский ресторанный процесс. Это блестяще объяснил Эдвин Чен здесь [6] и прекрасно визуализировал в [7] здесь. Вот визуализация из [7]:

В этом примере нам нужно присвоить теме 8. С вероятностью 3/8 8 попадет в тему C1, с вероятностью 4/8 8 попадет в тему C2 и с вероятностью 1/8 будет создана новая тема C3. Таким образом открывается ряд тем. Таким образом, чем больше кластер, тем больше вероятность того, что кто-то присоединится к этому кластеру. Я чувствовал, что это так же разумно, как и любой другой метод выбора фиксированного номера темы для LDA. Если у кого-то с более серьезным опытом Байесовского вывода есть мысли по этому поводу, пожалуйста, взвесьте!

ОБНОВЛЕНИЕ [13.04.2020] - Эдуардо Коронадо любезно предоставил более точную информацию о HDP в комментариях:

«Это правда, что непараметрические свойства HDP позволяют нам изучать темы на основе данных, однако смеси процесса Дирихле уже это делают. Основным преимуществом HDP является то, что они позволяют отдельным корпусам (группам) иметь общую статистическую силу при моделировании - в этом случае разделяют общий набор потенциально бесконечных тем. Так что это продолжение смесей Dirichlet Process ».

Создание модели LDA

Вот код для запуска LDA с Gensim:

import gensim

with warnings.catch_warnings():
    warnings.simplefilter('ignore')
    lda_train = gensim.models.ldamulticore.LdaMulticore(
                           corpus=train_corpus,
                           num_topics=20,
                           id2word=train_id2word,
                           chunksize=100,
                           workers=7, # Num. Processing Cores - 1
                           passes=50,
                           eval_every = 1,
                           per_word_topics=True)
    lda_train.save('lda_train.model')

Включив флаг eval_every, мы можем обрабатывать корпус фрагментами: в моем случае фрагменты из 100 документов работали достаточно хорошо для сходимости. Количество проходов - это отдельные проходы по всему корпусу.

Как только это будет сделано, вы сможете просмотреть слова, составляющие каждую тему, следующим образом:

lda_train.print_topics(20, num_words=15)[:10]

С помощью этого кода вы увидите 10 из 20 тем и 15 основных слов для каждой.

Преобразование тем в векторы признаков

А теперь самое интересное. Мы собираемся использовать модель LDA, чтобы получить распределение этих 20 тем для каждого обзора. Этот 20-вектор будет нашим вектором признаков для контролируемой классификации, при этом цель контролируемого обучения состоит в том, чтобы определить положительные или отрицательные настроения.

Обратите внимание, что я думаю, что этот подход к контролируемой классификации с использованием векторов тематической модели не очень распространен. Когда я это делал, я не знал ни одного примера в Интернете, чтобы люди пытались это сделать, хотя позже, когда я закончил, я обнаружил эту статью, где это было сделано в 2008 году [8]. Пожалуйста, дайте мне знать, если есть другие примеры!

Конечная цель состоит не только в том, чтобы увидеть, как это работает при разделении текущих данных в CV-тренинге / тесте, но и в том, затронули ли темы что-то фундаментальное, что преобразуется в невидимые тестовые данные в будущем (в моем случае данные за год потом).

Вот что я сделал, чтобы собирать векторы функций для каждого обзора:

train_vecs = []
for i in range(len(rev_train)):
    top_topics = (
        lda_train.get_document_topics(train_corpus[i],
                                      minimum_probability=0.0)
    )
    topic_vec = [top_topics[i][1] for i in range(20)]
    topic_vec.extend([rev_train.iloc[i].real_counts])
    topic_vec.extend([len(rev_train.iloc[i].text)])
    train_vecs.append(topic_vec)

Ключевой бит - это minimum_probability=0.0 в строке 3. Это гарантирует, что мы зафиксируем случаи, когда обзор представлен с 0% в некоторых темах, а представление для каждого обзора будет составлять в сумме 100%.

Строки 5 и 6 - это две добавленные мной вручную функции.

Таким образом, одно наблюдение для проверки контролируемой классификации теперь выглядит так:

Первые 20 пунктов представляют собой распределение по 20 найденным темам для каждого обзора.

Обучение контролируемого классификатора

Теперь мы готовы тренироваться! Здесь я использую 100 000 обзоров ресторанов за 2016 год и их вектор функций распределения тематических моделей + две функции, разработанные вручную:

X = np.array(train_vecs)
y = np.array(rev_train.target)

kf = KFold(5, shuffle=True, random_state=42)
cv_lr_f1, cv_lrsgd_f1, cv_svcsgd_f1,  = [], [], []

for train_ind, val_ind in kf.split(X, y):
    # Assign CV IDX
    X_train, y_train = X[train_ind], y[train_ind]
    X_val, y_val = X[val_ind], y[val_ind]
    
    # Scale Data
    scaler = StandardScaler()
    X_train_scale = scaler.fit_transform(X_train)
    X_val_scale = scaler.transform(X_val)

    # Logisitic Regression
    lr = LogisticRegression(
        class_weight= 'balanced',
        solver='newton-cg',
        fit_intercept=True
    ).fit(X_train_scale, y_train)

    y_pred = lr.predict(X_val_scale)
    cv_lr_f1.append(f1_score(y_val, y_pred, average='binary'))
    
    # Logistic Regression SGD
    sgd = linear_model.SGDClassifier(
        max_iter=1000,
        tol=1e-3,
        loss='log',
        class_weight='balanced'
    ).fit(X_train_scale, y_train)
    
    y_pred = sgd.predict(X_val_scale)
    cv_lrsgd_f1.append(f1_score(y_val, y_pred, average='binary'))
    
    # SGD Modified Huber
    sgd_huber = linear_model.SGDClassifier(
        max_iter=1000,
        tol=1e-3,
        alpha=20,
        loss='modified_huber',
        class_weight='balanced'
    ).fit(X_train_scale, y_train)
    
    y_pred = sgd_huber.predict(X_val_scale)
    cv_svcsgd_f1.append(f1_score(y_val, y_pred, average='binary'))

print(f'Logistic Regression Val f1: {np.mean(cv_lr_f1):.3f} +- {np.std(cv_lr_f1):.3f}')
print(f'Logisitic Regression SGD Val f1: {np.mean(cv_lrsgd_f1):.3f} +- {np.std(cv_lrsgd_f1):.3f}')
print(f'SVM Huber Val f1: {np.mean(cv_svcsgd_f1):.3f} +- {np.std(cv_svcsgd_f1):.3f}')

Пара замечаний по этому поводу:

  1. Я остановился на сравнении стандартной логистической регрессии, стохастического градиентного спуска с логарифмическими потерями и стохастического градиентного спуска с модифицированными потерями Хубера.
  2. Я использую 5-кратное резюме, поэтому в каждом прогоне 1/5 обзоров используется как данные для проверки, а остальные 4/5 - как данные для обучения. Это повторяется для каждого фолда, и в конце результаты f1 усредняются.
  3. Мои занятия несбалансированы. В частности, в наборе данных обзора Yelp непропорционально много отзывов с рейтингом 4 и 5 звезд. Строка class_weight='balanced' в моделях приблизительно соответствует заниженной выборке, чтобы исправить это. См. [9] для обоснования этого выбора.
  4. Я также ограничил анализ только ресторанами, у которых общее количество отзывов в наборе данных превышает 25-й процентиль. Это было скорее ограничением для ускорения работы, поскольку исходный набор данных состоял примерно из 4 миллионов отзывов.

Результаты обучения за 2016 год

Вот результаты f1:

Сначала я рассмотрю более низкие значения 0,53 и 0,62 f1 с помощью логической регрессии. Когда я впервые начал тренироваться, я пытался предсказать индивидуальные оценки в отзывах: 1,2,3,4 или 5 звезд. Как видите, это не удалось. Я был немного разочарован первоначальной оценкой 0,53, поэтому вернулся, чтобы изучить свои первоначальные диаграммы EDA, чтобы увидеть, могу ли я что-нибудь заметить в данных. Я запускал этот график ранее:

Это показывает диапазон IQR подсчета слов по рейтингу. Поскольку основной диапазон IQR был довольно компактным, я решил попробовать повторно запустить предварительную обработку LDA и модель, ограниченную только (примерно) диапазоном IQR. Внесение этого изменения увеличило мою оценку логистической регрессии на f1 до 0,62 для классификации 1,2,3,4,5 звезды. Все еще не очень хорошо.

На этом этапе я решил посмотреть, что произойдет, если я избавлюсь от трех звезд и сгруппирую 1,2 звезды как «плохие» и 4,5 звезды как «хорошие» оценки. Как вы увидите на графике выше, это действующее чудо! Теперь логистическая регрессия дала оценку 0,869 f1.

Модифицированная потеря Хубера

Когда я их запускал, я заметил опцию потери «измененного хубера» в реализации Stochastic Gradient Descent в SKLearn [10]. В этом случае штраф за ошибку намного хуже, чем при использовании шарнира (SVC) или потери журнала:

Я все еще не понимаю, почему это сработало так хорошо, но сначала я подумал, что эти штрафные санкции заставили SGD (помните, обновления веса 1 к 1) быстро научиться. Регуляризация и здесь очень помогла. Альфа в строке 42 в приведенном выше коде является параметром регуляризации (подумайте, как в случае регуляризации Ridge или Lasso), и это помогло мне поднять мой показатель f1 до 0,936.

Применение модели к невидимым данным

На данный момент я был очень взволнован этими результатами, но хотел узнать, что произойдет с совершенно невидимыми данными.

Конкретно:

  1. Возьмите модель LDA из обзоров 2016 года и возьмите векторы функций на тестовых данных. Важно отметить, что для этого можно использовать ту же модель 2016 года!
  2. Повторно запустите модели на тестовых векторах.

Все, что требуется, - это создать биграммы для тестового корпуса, а затем добавить их в метод извлечения тестовых векторов, как и раньше:

def get_bigram(df):

    df['text'] = strip_newline(df.text)
    words = list(sent_to_words(df.text))
    words = remove_stopwords(words)
    bigram = bigrams(words)
    bigram = [bigram[review] for review in words]
    return bigram
  
bigram_test = get_bigram(rev_test)

test_corpus = [train_id2word.doc2bow(text) for text in bigram_test]

test_vecs = []
for i in range(len(rev_test)):
    top_topics = (
            lda_train.get_document_topics(test_corpus[i],
                                          minimum_probability=0.0)
    topic_vec = [top_topics[i][1] for i in range(20)]
    topic_vec.extend([rev_test.iloc[i].real_counts])
    topic_vec.extend([len(rev_test.iloc[i].text)])
    test_vecs.append(topic_vec)

Наконец, результаты:

Как это ни шокирует меня, это обобщает!

Я был в восторге от этого результата, так как считаю, что этот подход может работать в целом для любой компании, пытающейся обучить классификатор таким образом. В конце я также проверил гипотезу, используя mlxtend [11], и окончательный результат действительно статистически значим.

Будущая работа

Я намерен немного расширить это в будущем и оставлю вас с этим:

Я также разместил весь код для этого и обученную модель LDA на моем GitHub здесь.

Спасибо за прочтение!

Источники

[1] https://en.wikipedia.org/wiki/Latent_Dirichlet_allocation
[2] https://www.yelp.com/dataset
[3] https: // radimrehurek.com/gensim/tut1.html
[4] https://radimrehurek.com/gensim/models/coherencemodel.html
[5] https://radimrehurek.com/ gensim / models / hdpmodel.html
[6] http://blog.echen.me/2012/03/20/infinite-mixture-models-with-nonparametric-bayes-and-the-dirichlet- процесс /
[7] http://gerin.perso.math.cnrs.fr/ChineseRestaurant.html
[8] http://gibbslda.sourceforge.net/fp224-phan .pdf
[9] http://blog.madhukaraphatak.com/class-imbalance-part-2/
[10] https://scikit-learn.org/stable/ модули / сгенерированные / sklearn.linear_model.SGDClassifier.html
[11] http://rasbt.github.io/mlxtend/user_guide/evaluate/mcnemar/