Предсказываем популярность статьи на TJ

от автора

Однажды томным вечером, сидя напротив мелькающей ленты tjournal и попивая ромашковый чай, внезапно обнаружил себя за чтением статьи про советскую лампочку, которая освещала чей-то подъезд уже 80 лет. Да, весьма интересно, но все же я предпочитаю статьи про политику достижения ИИ в игре дум, приключения ракет SpaceX и, в конце концов, — с наибольшим кол-вом просмотров. А какие вообще статьи набирают внушительные рейтинги? Посты размером с твит про какую-то политическую акцию или же талмуды с детальным анализом российской киноиндустрии? Ну что же, тогда самое время расчехлять свой Jupyter notebook и выводить формулу идеальной статьи.


Итак, задача стоит такая — предсказать кол-во просмотров статьи, исходя из ее содержания. Для реализации всего этого будем использовать Python 3.*, стандартный сет из библиотек для машинного обучения и обработки данных (pandas, numpy, scipy, scikit-learn), а писать и запускать все это будем в Jupyter notebook. Ссылка на все исходники тут.

Сбор базы

Для начала работы стоит раздобыть данные. Поскольку TJ не имеет своего API, то и собирать статьи будем старым добрым скрапингом голого html с помощью requests и вытаскиванием из него полезных данных с помощью BeautifulSoup. На выходе мы получаем 39116 статей с 2014 года. Однако стоит отметить, что сама статья записывается в виде html блока со всеми присущими ей кучей тэгов, а не как простой текст. Сам скрипт тут.

Feature Engineering

После часа работы скрипта у нас на руках есть почти 39000 статей, и пришло время вытаскивать из них фичи всех степеней важности. Но сначала импортируем все необходимое:

import json import datetime import numpy as np import pandas as pd import snowballstemmer from bs4 import BeautifulSoup import itertools  from scipy.sparse import csr_matrix, hstack from sklearn.feature_extraction.text import TfidfTransformer, CountVectorizer from sklearn.cross_validation import train_test_split from sklearn.decomposition import LatentDirichletAllocation from gensim.models import Word2Vec import lightgbm as lgb from sklearn.metrics import r2_score, mean_absolute_error  import seaborn as sns import  matplotlib.pyplot as plt  %matplotlib inline

Читаем наш датасет и смотрим его содержимое:

dataframe = pd.read_json("data/tj_dataset.json") dataframe.head()

image

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

Начнем с временных фич. И тут все как нельзя просто — вытаскиваем год, месяц, день, часы и минуты, из инженеринга фич тут только день недели:

Словарь с месяцами и функция для вычисления времени

def get_time(x):     time = x.split()[-1]     h, m = time.split(":")     return int(h)*60 + int(m)  m_keys = {     'января': 1,     'февраля': 2,     'марта': 3,     'апреля': 4,     'мая': 5,     'июня': 6,     'июля': 7,     'августа': 8,     'сентября': 9,     'октября': 10,     'ноября': 11,     'декабря': 12, }

dataframe['month'] = dataframe.date.apply(lambda x: m_keys[x.split()[1]]) dataframe['time'] = dataframe.date.apply(get_time) dataframe['day'] = dataframe.date.apply(lambda x: int(x.split()[0])) dataframe['year'] = dataframe.date.apply(lambda x: int(x.split()[2][:-1])) dataframe["weekday"] = dataframe[["year", "month", "day"]].apply(lambda x: datetime.date(*x).weekday(), axis=1)

А вот теперь началась самая творческая часть работы, где можно дать волю фантазии и собрать из текстов все, что душе угодно.

Для начала стоит вытащить из html блока голые куски текста, токенизировать их и пропустить через стеммер полученные слова:

stemmer = snowballstemmer.RussianStemmer() rus_chars = set([chr(i) for i in range(1072, 1104)])  def tokenize_sent(sent):     sent = sent.lower()     sent = "".join([i if i in rus_chars else " " for i in sent])     words = stemmer.stemWords(sent.split())     return words

Недолго думая, к списку фич добавляем кол-во слов в посте и заголовке, кол-во html тэгов, среднее и среднеквадратичное отколнение кол-ва слов в блоках. Посты на tj пестрят картинками, видео, референсами на твиттер и ссылками на другие ресурсы, что также можно использовать:

Функции для вытаскивания структурных фич

def get_p_data(article):     html = BeautifulSoup(article, "lxml")     data = html.findAll("p")     return [i.text for i in data]  def get_p_count(article):     html = BeautifulSoup(article, "lxml")     data = html.findAll("p")     return len(data)  def get_p_sizes(article):     html = BeautifulSoup(article, "lxml")     data = html.findAll("p")     return [len(preprocess_sent(i.text)) for i in data]  def get_tags_count(article):     html = BeautifulSoup(article, "lxml")     data = html.findAll()     return len(data)  def mean_p(x):     if not len(x):         return -9999     return np.mean(x)  def std_p(x):     if not len(x):         return -9999     return np.std(x)

dataframe['p_list'] = dataframe.article.apply(get_p_data) dataframe['text_chained'] = dataframe.p_list.apply(lambda x: "\n".join(x))  dataframe['p_list_tokenized'] = dataframe.p_list.apply(lambda x: [tokenize_sent(i) for i in x]) dataframe['p_list_tokenized_joined'] = dataframe.p_list_tokenized.apply(lambda x: list(itertools.chain(*x))) dataframe['title_tokenized'] = dataframe.title.apply(tokenize_sent)  dataframe['images_count'] = dataframe.article.apply(lambda x: x.count("wrapper-image")) dataframe['wide_labels_count'] = dataframe.article.apply(lambda x: x.count("wrapper-wide")) dataframe['link_widget_count'] = dataframe.article.apply(lambda x: x.count("link-widget")) dataframe['links_count'] = dataframe.article.apply(lambda x: x.count("a href")) - \     (dataframe.images_count + dataframe.link_widget_count) dataframe['youtube_count'] = dataframe.article.apply(lambda x: x.count("wrapper-video")) dataframe['tweets_count'] = dataframe.article.apply(lambda x: x.count("wrapper-tweet")) dataframe["tags_count"] = dataframe.article.apply(get_tags_count) dataframe["p_count"] = dataframe.p_list.apply(len) dataframe["text_sizes"] = dataframe.p_list_tokenized.apply(lambda x: [len(i) for i in x]) dataframe["text_sizes_mean"] = dataframe.text_sizes.apply(mean_p) dataframe["text_sizes_std"] = dataframe.text_sizes.apply(std_p) dataframe["text_words_count"] = dataframe.p_list_tokenized.apply(lambda x: len(list(itertools.chain(*x)))) dataframe["title_words_count"] = dataframe.title_tokenized.apply(lambda x: len(x))

Вот с семантическими фичами дело обстоит сложнее. И первое, что приходит на ум: tf-idf — классика в обработке текстовых данных. Но один лишь tf-idf не внушает доверия, так что подключим еще Latent Dirihlet Allocation (LDA) для уменьшения размерности поста до 10-50-и элементного вектора. На всякий случай попробуем взять три LDA по 10, 20 и 50 топиков соответственно.

count_vec = CountVectorizer(tokenizer = tokenize_sent, min_df=10, max_df=0.95) text_count_vec = count_vec.fit_transform(dataframe.text_chained) text_tfidf_vec = TfidfTransformer().fit_transform(text_count_vec).toarray()  lda_features = [] topic_counts = [10, 20, 50] for topics in topic_counts:     lda = LatentDirichletAllocation(topics, n_jobs=7, learning_method="batch")     feats = lda.fit_transform(text_count_vec)     lda_features.append(feats)  lda_features = np.concatenate(lda_features, axis=1)

Можно ли что-то еще интересное добавить? Разумеется, word2vec:

w2v = Word2Vec(dataframe.p_list_tokenized_joined, size=300, workers=7, min_count=5) w2v_dict = {key:w2v.wv.syn0[val.index] for key, val in w2v.wv.vocab.items()}  vectors = list(w2v_dict.values()) words = list(w2v_dict.keys()) words_set = set(words)

На выходе у нас есть куча векторов слов по 300 элементов в каждом. Теперь нужно их как-то использовать для векторизации поста. Для этого можно использовать 2 техники: вычисление среднего вектора среди всех векторов слов и более сложная, с использованием кластеров.

С первой все понятно — есть матрица c векторами слов, просто берем среднее значение по первой оси, алгоритм обработки пустых постов примерно такой же, как выше :

Функция для вычисления среднего вектора

def get_mean_vec(sent):     matrix = [w2v_dict[i] for i in sent if i in words_set]     if not matrix:         return np.full((300,), -9999)     return np.mean(matrix, axis=0)

Со второй нужно построить некоторое кол-во кластеров по всем векторам слов и затем присвоить каждому слову в тексте принадлежность к тому или иному кластеру, т.е. получится что-то вроде CountVectorizer-а на стероидах. Так же, как и в случае с LDA, попробуем взять несколько KMeans с разным кол-вом кластеров:

kmeans = [] clusters_counts = [10, 50, 100, 500, 1000] for clusters in clusters_counts:     kmeans.append(KMeans(clusters, precompute_distances=True, n_jobs=7).fit(vectors))

Функция для вычисления вектора поста по кластерам

word_mappings = [] for kmean in kmeans:     labels = kmean.labels_     word_mappings.append(dict(zip(words, labels)))  def get_centroids_vec(sent):     words = [i for i in sent if i in words_set]     if not words:         return np.full((sum(clusters_counts),), -9999)     result_total = np.asarray([])     for cnt, mapper in zip(clusters_counts, word_mappings):         result = np.zeros((cnt, ))         for word in words:             result[mapper[word]] += 1         result_total = np.concatenate([result_total, result])     return result_total

mean_texts = np.asarray([get_mean_vec(s) for s in dataframe.p_list_tokenized_joined.tolist()]) mean_titles = np.asarray([get_mean_vec(s) for s in dataframe.title_tokenized.tolist()]) clusters_texts = np.asarray([get_centroids_vec(s) for s in dataframe.p_list_tokenized_joined.tolist()]) clusters_titles = np.asarray([get_centroids_vec(s) for s in dataframe.title_tokenized.tolist()])

Все фичи собрали, осталось только скомпоновать их вместе в одну большую матрицу:

feats = ["time", "day", "weekday", "month",          "images_count", "wide_labels_count",          "link_widget_count", "links_count",          "youtube_count", "tweets_count",          "tags_count", "p_count", "text_sizes_mean",          "text_sizes_std", "text_words_count",          "title_words_count"] X = dataframe[feats].as_matrix()  preprocessed_df = np.concatenate([X, mean_texts, mean_titles, lda_features, clusters_texts, clusters_titles], axis=1)  full_df = hstack([csr_matrix(preprocessed_df), text_tfidf_vec]).tocsr()

Тренировка модели

Поскольку у нас здесь есть кол-во просмотров и рейтинг, то тут прям напрашивается задача регрессии. А если у нас регрессия, то стоит проверить целевые значения на предмет скоса их распределения направо-налево:

dataframe.hits.hist(bins=200)

hits histogram

Ну что же, вполне ожидамо — такое обычно лечится либо логарифмом:

dataframe.hits.apply(np.log).hist(bins=200)

log hits histogram

Уже намного лучше, хоть немного и смущают эти две горы. Но это еще не все, ведь с каждым месяцем количесво подписчиков на tj возрастает, а это значит, что возрастает и кол-во просмотров. Т.е. нужно как-то нормализовать кол-во просмотров, при этом минимизируя влияние выбросов. Для нормализации одного поста можно взять среднее и среднеквадратичное отклонение из предыдущих 200 постов, скалировать текущее значение, при этом убрав все, что находится за первой и 99-ой перцентилями:

hits_y = dataframe.hits  step = 200 new_hits = [] for i in range(step, dataframe.shape[0]):     hits = hits_y.iloc[i-step: i+1]     perc_high = np.percentile(hits, 99)     perc_low = np.percentile(hits, 1)     hits = hits[(hits > perc_low)&(hits < perc_high)]     mean = hits.mean()     std = hits.std()     val = max(min(hits_y.iloc[i], perc_high), perc_low)     new_hits.append((val - mean)/std)  hits_y = np.asarray(new_hits) full_df = full_df[step:]  min_val = hits_y.min() hits_log = np.log(hits_y - min_val+1e-6)

Самое сложное позади, осталось только выбрать модель, поставить ее на обучение и молиться на правильный выбор гиперпараметров. Обычно над выбором модели долго не заморачиваются и сразу берут xgboost, что имеет смысл, ибо он уже завоевал свою популярность на kaggle и повсеместно используется как новичками, так и гуру. Но однажды, случайно наткнувшись на сравнение бенчмарков двух реализаций градиентного бустинга, меня привлек внимание LigthGBM от Microsoft своими аппетитными результатами тестов, которые и заставили отложить старичка xgboost как запасной вариант.

В целом, тренировка проходит так же, как и на xgboost — тыкаем туда-сюда max_depth и регуляризацию для избежания оверфита, подбираем наилучшее кол-во деревьев под допустимо минимальный learning_rate.

Датасет для LigthGBM необходимо для начала привести в местный формат:

Названия всех фич

features = (feats +     list(itertools.chain(*[[f"lda_{lda}_{i}" for i in range(lda)] for lda in topic_counts])) +      [f"mean_matrix_body_{i}" for i in range(300)] +      [f"mean_matrix_title_{i}" for i in range(300)] +      list(itertools.chain(*[[f"clusters_body_{i}_{j}" for j in range(i)] for i in clusters_counts])) +     list(itertools.chain(*[[f"clusters_title_{i}_{j}" for j in range(i)] for i in clusters_counts])) +     [f"tf_idf_{i}" for i in range(text_tfidf_vec.shape[1])])

X_train, X_test, y_train, y_test = train_test_split(full_df, hits_log, test_size=0.2, random_state=753)  train_data = lgb.Dataset(X_train, label=y_train, feature_name=features) test_data = train_data.create_valid(X_test, label=y_test)

Параметры градиентного бустинга (получены после долгих часов тренировок):

param = {'num_trees': 100000,          'application':'regression',          'learning_rate': 0.01, 'num_threads': 7,           'max_depth': 10,          'lambda_l2': 1e-3} param['metric'] = 'mae'

Непосредственно сама тренировка:

bst = lgb.train(param, train_data, param['num_trees'], valid_sets=[test_data], early_stopping_rounds=200)

Важной особенностью деревьев является то, что есть возможность оценить важность фич:

importance = sorted(zip(features, bst.feature_importance()), key=lambda x: x[1], reverse=True) for imp in importance[:20]: print("Feature '{}', importance={}".format(*imp))

Feature 'tags_count', importance=546 Feature 'month', importance=510 Feature 'weekday', importance=431 Feature 'images_count', importance=377 Feature 'lda_20_6', importance=332 Feature 'time', importance=316 Feature 'wide_labels_count', importance=291 Feature 'lda_50_48', importance=227 Feature 'text_sizes_std', importance=215 Feature 'mean_matrix_title_134', importance=174 Feature 'title_words_count', importance=161 Feature 'text_sizes_mean', importance=160 Feature 'lda_20_15', importance=149 Feature 'mean_matrix_body_262', importance=142 Feature 'mean_matrix_body_194', importance=141 Feature 'mean_matrix_title_115', importance=138 Feature 'mean_matrix_body_61', importance=136 Feature 'mean_matrix_title_13', importance=136 Feature 'tf_idf_1427', importance=132 Feature 'lda_10_1', importance=130

Гистограмма с важностями фич

importance = pd.DataFrame([{"imp": imp, "feat": feat} for feat, imp in importance]) sns.barplot(x="imp", y="feat", data=importance.iloc[:20])

features

И вот наши результаты:

predictions = bst.predict(X_test) print("r2 score = {}".format(r2_score(y_test, predictions))) print("mae error = {}".format(mean_absolute_error(y_test, predictions)))

r2 score = 0.3166155559972065 mae error = 0.4209721870443455

Итоги

C такими параметрами и фичами точность по r2 score получилась 0.317, а по absolute mean error — 0.42. Хороший ли это результат? Вполне неплохо даже с учетом того, что существуют еще пути для улучшения модели. Например, попробовать нейросети (как реккурентные, так и сверточные, с Embedding слоем и весами word2vec), к LDA добавить еще PCA, NMF и прочую декомпозицию, в конце концов, попробовать выделить другие фичи из разряда "отношение кол-ва пунктуации к кол-ву переносов строк". Но дальнейшие изыскания уже оставляю вам.

ссылка на оригинал статьи https://habrahabr.ru/post/327072/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *