Однажды томным вечером, сидя напротив мелькающей ленты 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()
Поскольку из доступных данных для тренировки мы имеем только контент поста и дату публикации, то и большая часть инженеринга фич будет крутиться вокруг текстовых данных. Из самого поста можно выудить два типа фич: структурные и смысловые (да-да, названия придумал сам). К структурным относятся те, которые, не поверите, определяют структуру поста: кол-во ссылок на ютуб, на твиттер, кол-во слов в названии поста, среднее кол-во слов в текстовых блоках и т.п. Все значения, которые не удалось выловить (обычно из-за малого кол-ва текстовых данных), просто заменям на -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)
Ну что же, вполне ожидамо — такое обычно лечится либо логарифмом:
dataframe.hits.apply(np.log).hist(bins=200)
Уже намного лучше, хоть немного и смущают эти две горы. Но это еще не все, ведь с каждым месяцем количесво подписчиков на 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])
И вот наши результаты:
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/
Добавить комментарий