Один мой друг рассказывал, что в студенческие годы удивлял одногруппников тем, что мог почти безошибочно определить писателя по отрывку из его произведения. Попробуем что-то похожее сделать средствами машинного обучения. В качестве данных взят training set из уже завершённого соревнования Spooky Author Identification (https://www.kaggle.com/c/spooky-author-identification/data).
Ценность задачи может состоять, например, в том, что похожими методами можно определить автора текста, писавшего под псевдонимом (если известен примерный круг "подозреваемых").
import numpy as np
import pandas as pd
import matplotlib
from matplotlib import pyplot as plt
%matplotlib inline
import re
import string
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import log_loss, accuracy_score
from sklearn.model_selection import GridSearchCV
from scipy.sparse import csr_matrix, hstack
from sklearn.naive_bayes import MultinomialNB
from sklearn.model_selection import ShuffleSplit
from sklearn.model_selection import learning_curve
from sklearn.model_selection import validation_curve
Загрузим данные и посмотрим на них.
data = pd.read_csv('../../data/spooky_authors.csv')
data.shape
data.head()
np.unique(data['author'])
Набор данных содержит три поля:
Посмотрим, сбалансированы ли классы.
data.groupby('author')['id'].count().plot(kind='bar')
Количество данных по разным авторам несколько отличается, но не слишком сильно.
Вытащим из текста слова и посмотрим, отличаются ли у авторов количество слов в предложении, средняя длина слова, количество пунктуации и т.д.
words = data['text'].apply(lambda t: [w.lower() for w in re.findall(r'\b[^\W\d_]+\b', t)])
Сначала посмотрим на количество слов в предложении.
data['length'] = words.apply(lambda ws: len(ws))
data.boxplot(column='length',by='author')
Пока видно только различие по аномально длинным предложениям. Выбросы достигают 800+ слов, но только единичные предложения выходят за 200 слов, а основная масса лежит где-то в пределах пятидесяти.
data['length'].describe()
Построим такой же график, но исключив крайние выбросы.
data[data['length'] < 200].boxplot(column='length',by='author')
Теперь отличия более заметны (например, короткие предложения чаще встречаются у Эдгара По, а у Лавкрафта самое узкое расределение, т.е. очень длинные и очень короткие предложения скорее всего не его).
Посмотрим теперь на количество уникальных слов в предложении.
data['unique'] = words.apply(lambda ws: len(set(ws)) / len(ws))
data.boxplot(column='unique',by='author')
И вновь у Эдгара По больше крайних значений, а у Лавкрафта самое узкое распределение.
Посмотрим на среднюю длину слова в предложении.
data['word_length'] = words.apply(lambda ws: np.mean([len(w) for w in ws]))
data.boxplot(column='word_length',by='author')
И вновь можно сказать, что самые низкие значения характерны для По, а у Лавкрафта практически нет ни очень высоких, ни очень низких значений.
Наконец, посмотрим, какая часть символов является пунктуацией.
data['punctuation'] = data['text'].apply(lambda t: sum(map(lambda letter: letter in string.punctuation, t)) / len(t))
data.boxplot(column='punctuation',by='author')
Из-за выбросов график трудно интерпретировать, посмотрим ближе значения, не превышающие 0.2
data[data['punctuation'] < 0.2].boxplot(column='punctuation',by='author')
Теперь отличия видны лучше. Наибольшее количество пунктуации характерно для По, наименьшее - для Лавкрафта.
Матрица корреляций этих признаков скорее всего будет не очень информативной, но раз она несколько раз упоминается в плане и критериях оценки, то построим её.
data[['length', 'unique', 'word_length', 'punctuation']].corr()
pd.plotting.scatter_matrix(data[['length', 'unique', 'word_length', 'punctuation']], alpha = 0.3, figsize = (14,8), diagonal = 'kde');
Как и ожидалось, заметная корреляция только одна: отрицательная корреляция между длиной предложения и количеством уникальных слов в нём. Это логично: чем длиннее предложение, тем вероятнее в нём будут повторяться слова. Если бы значение корреляции было близко к -1, один из признаков стоило бы откинуть, но поскольку только -0.63, то пусть остаются оба.
Перейдём собственно к обучению. Первым делом надо ответить на следующие вопросы:
В качестве метрики качества можно было бы выбрать accuracy score или log-loss. Accuracy score для данной задачи была бы вполне применима (классы более-менее сбалансированы, и у нас нет предпочтений, в какую сторону менее болезненно было бы ошибаться), тем более, что у accuracy score очень хорошая интерпретируемость. Но log-loss является более тонкой метрикой, позволяющей оптимизировать не только правильность ответа, но и степень уверенности в нём.
Т.о. в качестве метрики качества будет взят log-loss (но accuracy score будем тоже выводить, просто для информации).
Что касается использования TF-IDF и выбора алгоритма ответ прост: мы попробуем разные варианты и посмотрим, что работает лучше. Линейные методы должны работать неплохо для разреженных данных большой размерности (которые у нас получатся после векторизации). Байсовские методы тоже должны неплохо справляться с такими задачами.
Кроме того, мы сначала попробуем обучать только на самих текстах, а потом посмотрим, дадут ли прирост проанализированные фичи вроде длины предложения и количества пунктуации. Скорее всего, эти признаки смогут немного улучшить модель, т.к. такие вещи как длина предложения, длина слов, знаки препинания отражают стиль писателя наравне с используемыми словами (и как мы видели выше у разных писателей на самом деле эти параметры немного отличаются).
Кросс-валидацию будем проводить сразу вместе с поиском оптимальных параметров через GridSearchCV. По умолчанию GridSearchCV использует StratifiedKFold (то есть все во фолды попадёт примерно равное количество представителей каждого класса) и количество фолдов, равное 3. Эти умолчания представляются разумными, так что оставим их.
Для надёжности также выделим отдельно validation set чтобы проверять на нём промежуточные результаты и test set, на котором проверим итоговую модель в самом конце.
Итак, разобьём данные на train, validation и test.
X = data.drop(['id', 'author'], axis=1)
author_encoder = LabelEncoder()
y = author_encoder.fit_transform(data['author'])
X_train_val, X_test, y_train_val, y_test = train_test_split(X, y, test_size=0.2, random_state=17)
X_train, X_val, y_train, y_val = train_test_split(X_train_val, y_train_val, test_size=0.2, random_state=17)
text_train = X_train['text']
text_val = X_val['text']
text_train_val = X_train_val['text']
text_test = X_test['text']
print(X.shape, y.shape)
print(X_train_val.shape, X_test.shape, y_train_val.shape, y_test.shape)
print(X_train.shape, X_val.shape, y_train.shape, y_val.shape)
Напишем небольшую функцию для вывода результатов обучения:
def print_results(clf, x_val, y_val):
y_pred_val_proba = clf.predict_proba(x_val)
y_pred_val_labels = clf.predict(x_val)
print("Validation set log loss: {} (accuracy: {})".format(log_loss(y_val, y_pred_val_proba), accuracy_score(y_val, y_pred_val_labels)))
Подготовим векторайзеры и преобразуем данные.
count_vectorizer = CountVectorizer()
count_train = count_vectorizer.fit_transform(text_train)
count_val = count_vectorizer.transform(text_val)
tfidf_vectorizer = TfidfVectorizer()
tfidf_train = tfidf_vectorizer.fit_transform(text_train)
tfidf_val = tfidf_vectorizer.transform(text_val)
Начнём с самого простого варианта: CountVectorizer и логистическая регрессия.
logit_count_clf = LogisticRegression().fit(count_train, y_train)
print_results(logit_count_clf, count_val, y_val)
Уже неплохо. Попробуем настроить регуляризацию.
logit_count_grid = GridSearchCV(LogisticRegression(random_state=17), {'C': [0.1, 0.3, 1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_count_grid.fit(count_train, y_train)
print(logit_count_grid.best_params_, ', cross validation score:', -logit_count_grid.best_score_)
print_results(logit_count_grid, count_val, y_val)
Значение по умолчанию оказалось оптимальным.
Попробуем TF-IDF.
logit_tfidf_clf = LogisticRegression(random_state=17).fit(tfidf_train, y_train)
print_results(logit_tfidf_clf, tfidf_val, y_val)
Результат существенно хуже (видно, что accuracy изменилась несущественно, но уверены в предсказаниях мы намного меньше).
Возможно, дело в регуляризации.
logit_tfidf_grid = GridSearchCV(LogisticRegression(random_state=17), {'C': [0.1, 0.3, 1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_tfidf_grid.fit(tfidf_train, y_train)
print(logit_tfidf_grid.best_params_, ', cross validation score:', -logit_tfidf_grid.best_score_)
print_results(logit_tfidf_grid, tfidf_val, y_val)
После настройки регуляризации результат намного лучше (и чуть лучше, чем без TF-IDF).
Проверим, изменится ли результат, если применить логарифмическое преобразование к TF.
tfidf_log_vectorizer = TfidfVectorizer(sublinear_tf=True)
tfidf_log_train = tfidf_log_vectorizer.fit_transform(text_train)
tfidf_log_val = tfidf_log_vectorizer.transform(text_val)
logit_tfidf_log_grid = GridSearchCV(LogisticRegression(random_state=17), {'C': [0.1, 0.3, 1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_tfidf_log_grid.fit(tfidf_log_train, y_train)
print(logit_tfidf_log_grid.best_params_, ', cross validation score:', -logit_tfidf_log_grid.best_score_)
print_results(logit_tfidf_log_grid, tfidf_log_val, y_val)
Неа.
Попробуем, не будет ли результат лучше, если использовать биграммы.
tfidf_12_vectorizer = TfidfVectorizer(ngram_range=(1, 2))
tfidf_12_train = tfidf_12_vectorizer.fit_transform(text_train)
tfidf_12_val = tfidf_12_vectorizer.transform(text_val)
logit_tfidf_12_grid = GridSearchCV(LogisticRegression(random_state=17), {'C': [0.1, 0.3, 1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_tfidf_12_grid.fit(tfidf_12_train, y_train)
print(logit_tfidf_12_grid.best_params_, ', cross validation score:', -logit_tfidf_12_grid.best_score_)
print_results(logit_tfidf_12_grid, tfidf_12_val, y_val)
Немного лучше. А если триграммы?
tfidf_13_vectorizer = TfidfVectorizer(ngram_range=(1, 3))
tfidf_13_train = tfidf_13_vectorizer.fit_transform(text_train)
tfidf_13_val = tfidf_13_vectorizer.transform(text_val)
logit_tfidf_13_grid = GridSearchCV(LogisticRegression(random_state=17), {'C': [1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_tfidf_13_grid.fit(tfidf_13_train, y_train)
print(logit_tfidf_13_grid.best_params_, ', cross validation score:', -logit_tfidf_13_grid.best_score_)
print_results(logit_tfidf_13_grid, tfidf_13_val, y_val)
Оставим биграммы. И попробуем добавить наши дополнительные фичи.
tfidf_12_ext_train = hstack([tfidf_12_train, X_train[['length', 'unique', 'word_length', 'punctuation']]]).tocsr()
tfidf_12_ext_val = hstack([tfidf_12_val, X_val[['length', 'unique', 'word_length', 'punctuation']]]).tocsr()
logit_tfidf_12_ext_grid = GridSearchCV(LogisticRegression(), {'C': [1, 3, 10, 30, 100, 300]}, scoring='neg_log_loss')
logit_tfidf_12_ext_grid.fit(tfidf_12_ext_train, y_train)
print(logit_tfidf_12_ext_grid.best_params_, -logit_tfidf_12_ext_grid.best_score_)
print_results(logit_tfidf_12_ext_grid, tfidf_12_ext_val, y_val)
Прирост не то чтобы очень большой, но есть.
Построим кривую обучения (log loss в зависимости от размера обучающей выборки).
def plot_learning_curve(estimator, title, X, y, ylim=None, cv=None, n_jobs=1, train_sizes=np.linspace(.1, 1.0, 5)):
plt.figure()
plt.title(title)
if ylim is not None:
plt.ylim(*ylim)
plt.xlabel("Training examples")
plt.ylabel("Score")
train_sizes, train_scores, test_scores = learning_curve(
estimator, X, y, cv=cv, n_jobs=n_jobs, train_sizes=train_sizes, scoring='neg_log_loss')
train_scores_mean = -np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
test_scores_mean = -np.mean(test_scores, axis=1)
test_scores_std = np.std(test_scores, axis=1)
plt.grid()
plt.fill_between(train_sizes, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.1,
color="r")
plt.fill_between(train_sizes, test_scores_mean - test_scores_std,
test_scores_mean + test_scores_std, alpha=0.1, color="g")
plt.plot(train_sizes, train_scores_mean, 'o-', color="r",
label="Training score")
plt.plot(train_sizes, test_scores_mean, 'o-', color="g",
label="Cross-validation score")
plt.legend(loc="best")
return plt
tfidf_12_vectorizer_train_val = tfidf_12_vectorizer = TfidfVectorizer(ngram_range=(1, 2))
tfidf_12_train_val = tfidf_12_vectorizer_train_val.fit_transform(text_train_val)
tfidf_12_ext_train_val = hstack([tfidf_12_train_val, X_train_val[['length', 'unique', 'word_length', 'punctuation']]]).tocsr()
title = "Learning Curves (Logistic Regression)"
cv = ShuffleSplit(n_splits=4, test_size=0.2, random_state=0)
plot_learning_curve(LogisticRegression(C=100), title, tfidf_12_ext_train_val, y_train_val, ylim=(0, 0.8), cv=cv, n_jobs=4)
Кривая, соответствующая кросс-валидации, спускается вниз, но находится очень далеко от кривой, соответствующей обучению. Это говорит о том, что у нас большое недообучение и может помочь больше данных.
Построим кривую валидации (для разных параметров C).
param_range = np.logspace(-1, 3, 5)
train_scores, test_scores = validation_curve(
LogisticRegression(C=100), tfidf_12_ext_train_val, y_train_val, param_name="C", param_range=param_range,
cv=4, scoring="neg_log_loss", n_jobs=4)
train_scores_mean = -np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
test_scores_mean = -np.mean(test_scores, axis=1)
test_scores_std = np.std(test_scores, axis=1)
plt.title("Validation Curve with Logistic Regression")
plt.xlabel("C")
plt.ylabel("Score")
plt.ylim(0.0, 1.1)
lw = 2
plt.semilogx(param_range, train_scores_mean, label="Training score",
color="darkorange", lw=lw)
plt.fill_between(param_range, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.2,
color="darkorange", lw=lw)
plt.semilogx(param_range, test_scores_mean, label="Cross-validation score",
color="navy", lw=lw)
plt.fill_between(param_range, test_scores_mean - test_scores_std,
test_scores_mean + test_scores_std, alpha=0.2,
color="navy", lw=lw)
plt.legend(loc="best")
plt.show()
Кривая валидации спускается вниз, но после C=100 снова поднимается наверх и начинается переобучение. Т.о. выбранное через GridSearch значение оптимально.
Попробуем теперь байесовские методы.
Как и в случае логистической регрессии начнём с самого простого варианта: CountVectorizer + MultinomialNB без настройки параметров.
nb_count_clf = MultinomialNB().fit(count_train, y_train)
print_results(logit_count_clf, count_val, y_val)
Результат очень похож на результаты логистической регрессии. Попробуем настроить параметры alpha
nb_count_grid = GridSearchCV(MultinomialNB(), {'alpha': [0.01, 0.01, 0.1, 1]}, scoring='neg_log_loss')
nb_count_grid.fit(count_train, y_train)
print(nb_count_grid.best_params_, ', cross validation score:', -nb_count_grid.best_score_)
print_results(nb_count_grid, count_val, y_val)
Попробуем TF-IDF
nb_tfidf_grid = GridSearchCV(MultinomialNB(), {'alpha': [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1]}, scoring='neg_log_loss')
nb_tfidf_grid.fit(tfidf_train, y_train)
print(nb_tfidf_grid.best_params_, ', cross validation score:', -nb_tfidf_grid.best_score_)
print_results(nb_tfidf_grid, tfidf_val, y_val)
Результат лучше. Попробуем биграммы.
nb_tfidf_12_grid = GridSearchCV(MultinomialNB(), {'alpha': [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1]}, scoring='neg_log_loss')
nb_tfidf_12_grid.fit(tfidf_12_train, y_train)
print(nb_tfidf_12_grid.best_params_, ', cross validation score:', -nb_tfidf_12_grid.best_score_)
print_results(nb_tfidf_12_grid, tfidf_12_val, y_val)
И триграммы.
nb_tfidf_13_grid = GridSearchCV(MultinomialNB(), {'alpha': [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1]}, scoring='neg_log_loss')
nb_tfidf_13_grid.fit(tfidf_13_train, y_train)
print(nb_tfidf_13_grid.best_params_, ', cross validation score:', -nb_tfidf_13_grid.best_score_)
print_results(nb_tfidf_13_grid, tfidf_13_val, y_val)
Как и в случае логистической регрессии, триграммы работают хуже, оставим биграммы.
Добавим наши фичи.
nb_tfidf_12_ext_grid = GridSearchCV(MultinomialNB(), {'alpha': [0.001, 0.003, 0.01, 0.03, 0.1, 0.3, 1]}, scoring='neg_log_loss')
nb_tfidf_12_ext_grid.fit(tfidf_12_ext_train, y_train)
print(nb_tfidf_12_ext_grid.best_params_, -nb_tfidf_12_ext_grid.best_score_)
print_results(nb_tfidf_12_ext_grid, tfidf_12_ext_val, y_val)
Log-loss чуть лучше, почти достиг 0.41.
Таким образом, лучше всего сработала байесовская модель, обученная на TF-IDF с биграммами и с добавлением новых признаков.
Построим теперь предсказания на тестовых данных.
Обучим самую удачную модель на train + validation.
tfidf_12_test = tfidf_12_vectorizer.transform(text_test)
tfidf_12_ext_test = hstack([tfidf_12_test, X_test[['length', 'unique', 'word_length', 'punctuation']]]).tocsr()
nb = MultinomialNB(alpha=0.01)
nb.fit(tfidf_12_ext_train_val, y_train_val)
print_results(nb, tfidf_12_ext_test, y_test)
Т.о. финальный результат на тестовой выборке: 0.376486. Результат на тествой выборке получился даже лучше, чем на валидации (0.410010, возможно, из-за того, что обучали на выборке большего размера). Тестовая выборка была получена с рандомизацией при помощи train_test_split.
Итак, обучение прошло успешно, переобучения не произошло.
Как упоминалось в начале, данная модель может использоваться для определения авторов книг и статей, желавших остаться анонимными. Также, в более широком смысле, такие методы могут использоваться для любой классификации текста (например, для классификации заданных через сайт вопросов по темам).
В качестве дальнейших улучшений мне видится: