%matplotlib inline
from IPython.display import set_matplotlib_formats
set_matplotlib_formats('pdf', 'svg')
from sklearn.datasets import fetch_20newsgroups
import numpy as np
import gensim.parsing.preprocessing as gp
from sklearn import feature_extraction, metrics
from sklearn import naive_bayes, linear_model, svm
from sklearn.preprocessing import Binarizer
Набор данных 20 newsgroups состоит из множества usenet-постов из 20 тем. Задача заключается в опредении, к какой теме относится пост. Из постов удалены заголовки, подписи и цитаты (на семинаре мы этого не делали, поэтому сейчас результаты будут пореалистичнее). Набор данных встроен в sklearn.
train_data = fetch_20newsgroups(subset='train',remove=('headers', 'footers', 'quotes'))
test_data = fetch_20newsgroups(subset='test', remove=('headers', 'footers', 'quotes'))
Выведем пример текста.
text = train_data.data[0]
print(text)
Так называются искомые темы:
print(train_data.target_names)
В этом туториале мы будем использовать bag-of-words представления и их производные. В базовом виде каждый текст $t$ представляется в виде вектора:
\begin{equation*} \vec{v}(t) = [c(w_1), c(w_2), ..., c(w_{|V|}] \end{equation*} где $c(w_i)$ обозначает количество, сколько раз уникальное слово $w_i$ встретилось в тексте (счетчик слова $w_i$), а $|V|$ - общее количество уникальных слов (размер словаря). Словарь наполняется словами из всех текстов и опционально фильтруется. Bag-of-words векторы в подавляющем большинстве случаев разрежены - то есть, практически все их элементы равны нулю.
Если для каждого слова использовать one-hot-кодировку, то bag-of-words - это векторная сумма последовательности кодировок слов из текста.
Чтобы получить BpW-представление, нужно извлечь собственно слова из текста, т.е. провести токенизацию текста. Вообще говоря, слово - условное понятие. Под ним можно подразумевать слова, знаки препинания, группы слов (например, все нецензурные слова можно считать вместе, а не по отдельности) и вообще произвольные счетные признаки.
В обработке естественных языков преобработка (нормализация) текстов играет ключевую роль. Под ней подразумевается различная фильтрация лишних деталей, общие преобразования. Мы воспользуемся встроенной преобработкой из библиотеки gensim и оценим эффекты некоторых стадий.
Переведем текст в единый регистр, удалим html теги, пунктуацию, числа и стоп-слова (часто встречающиеся слова, которые без контекста практически не имеют смысла (пример https://gist.github.com/sebleier/554280))
def gensim_preprocessing1(documents):
filters = [lambda s: s.lower(), gp.strip_tags, gp.strip_punctuation, gp.strip_numeric, gp.remove_stopwords]
return [gp.preprocess_string(doc,filters) for doc in documents]
tokens_train = gensim_preprocessing1(train_data.data)
Выведем полученный список токенов из первого текста
print(tokens_train[0])
tokens_test = gensim_preprocessing1(test_data.data)
Теперь построим из списков токенов векторы bow. Для этого воспользуемся классом CountVectorizer из sklearn. Вообще, sklearn предоставляет свою ограниченную токенизацию и преобработку, но поскольку мы сделали её сами, заменим соответствующие шаги на ничего не делающие. Также для скорости ограничим словарь 30к самых частых слов (отметим, что мы выбросили стоп-слова).
count_vectorizer = feature_extraction.text.CountVectorizer(preprocessor=lambda x:x,
tokenizer=lambda x:x,max_features=30000)
# tfidf = feature_extraction.text.TfidfTransformer()
# binarizer = Binarizer()
Натренируем векторизатор (т.е. дадим ему тексты, из которых он выяснит 30к самых частых слов и назначит им номера) и преобразуем тренировочные данные в векторы.
X_train = count_vectorizer.fit_transform(tokens_train)
feature_names = count_vectorizer.get_feature_names()
X_test = count_vectorizer.transform(tokens_test)
#X_test = binarizer.transform(X_test)
print(X_train.shape)
Выведем вектор первого текста. Поскольку в нем 30000 элементов, хранить их все было бы очень затратно. Поэтому хранятся в памяти только ненулевые элементы и их номера. Все векторы вместе образуют sparse-матрицу (X_train и X_test)
print(X_train[0])
Используя хранимое в CountVectorizer отображение номеров на слова (feature_names), выведем счетчики слов первого текста
for i in X_train[0].indices:
print(feature_names[i], X_train[0,i])
Натренируем на полученных векторах наивный Байесовский классификатор. Используемая реализация NB использует следующую формулу для определения класса: \begin{equation*} P(C|w_1, ..., w_{|V|}) = Z P(C)P(w_1, ..., w_{|V|}|C) = Z P(C)\prod_{i = 1,~C(w_i) \ne 0}^{|V|} P(w_i|C) \end{equation*} Z - нормализующая константа, чтобы вероятности классов суммировались в 1. $P(w_i|C)$ - вероятность появления слова $w_i$ в тексте этого класса, она оценивается как: \begin{equation*} P(w_i|C) = \frac{\sum{c(w_i) + \alpha}}{\sum_j [\sum{c(w_j) + \alpha}]} \end{equation*} где $\sum{c(w_i)}$ общее количество раз, которое данное слово встретилось во всех текстах класса $C$, $\alpha$ - сглаживающая константа, благодаря которой у нас нет нулевых вероятностей.
multi_nb = naive_bayes.MultinomialNB()
multi_nb.fit(X_train, train_data.target)
m_pred = multi_nb.predict(X_test)
print("Multinb: ", metrics.accuracy_score(test_data.target, m_pred))
print(metrics.classification_report(test_data.target, m_pred, target_names = test_data.target_names))
# confusion_matrix = metrics.confusion_matrix(test_data.target, m_pred)
# for i, row in enumerate(confusion_matrix):
# print(test_data.target_names[i])
# for j, col in enumerate(row):
# print(test_data.target_names[j], ":", col, end=' ')
# print('')
Результаты не очень, попробуем добавить стеммизацию (грубую обработку слов по морфологическим правилам, которая сводит большинство форм одного слова в одну), а также удалить все короткие слова.
def gensim_preprocessing2(documents):
filters = [lambda s: s.lower(), gp.strip_tags, gp.strip_punctuation,
gp.strip_numeric, gp.remove_stopwords, gp.strip_short, gp.stem_text]
return [gp.preprocess_string(doc,filters) for doc in documents]
tokens_stem_train = gensim_preprocessing2(train_data.data)
tokens_stem_test = gensim_preprocessing2(test_data.data)
print(tokens_stem_train[0])
cv_stem = feature_extraction.text.CountVectorizer(preprocessor=lambda x:x,
tokenizer=lambda x:x,max_features=30000)
X_train_stem = cv_stem.fit_transform(tokens_stem_train)
X_test_stem = cv_stem.transform(tokens_stem_test)
multi_nb_stem = naive_bayes.MultinomialNB()
multi_nb_stem.fit(X_train_stem, train_data.target)
m_pred = multi_nb_stem.predict(X_test_stem)
print("Multinb: ", metrics.accuracy_score(test_data.target, m_pred))
print(metrics.classification_report(test_data.target, m_pred, target_names = test_data.target_names))
Результат практически не изменился. Добавим ещё 30000 атрибутов
cv_stem2 = feature_extraction.text.CountVectorizer(preprocessor=lambda x:x,
tokenizer=lambda x:x,max_features=60000)
X_train_stem2 = cv_stem2.fit_transform(tokens_stem_train)
X_test_stem2 = cv_stem2.transform(tokens_stem_test)
multi_nb_stem2 = naive_bayes.MultinomialNB()
multi_nb_stem2.fit(X_train_stem2, train_data.target)
m_pred = multi_nb_stem2.predict(X_test_stem2)
print("Multinb: ", metrics.accuracy_score(test_data.target, m_pred))
print(metrics.classification_report(test_data.target, m_pred, target_names = test_data.target_names))
Взвесим слова при помощи idf-весов (обратная документная частота). Воспользуемся схемой $idf_i = log \frac{N}{df_i}$, где $df_i$ - количество документов, в которых встретилось слово, а $N$ общее количество документов. Таким образом, слова более уникальные для документов имеют больший вес, общераспространенные - меньший. После это мы получаем tf-idf представление вида. Представление также нормализовано по длине, чтобы сгладить эффект более длинных текстов: \begin{equation*} \vec{v}(t) = [idf_1*c(w_1), idf_2*c(w_2), ..., idf_{|V|}*c(w_{|V|})] \end{equation*}
tfidf = feature_extraction.text.TfidfTransformer()
X_train_idf = tfidf.fit_transform(X_train_stem)
X_test_idf = tfidf.transform(X_test_stem)
nb3 = naive_bayes.MultinomialNB()
nb3.fit(X_train_idf, train_data.target)
nb3_pred = nb3.predict(X_test_idf)
print("Multinb: ", metrics.accuracy_score(test_data.target, nb3_pred))
print(metrics.classification_report(test_data.target, nb3_pred, target_names = test_data.target_names))
Натренируем также логистическую регрессию на этих же данных.
logr = linear_model.LogisticRegression()
logr.fit(X_train_idf, train_data.target)
logr_pred = logr.predict(X_test_idf)
print("Logr: ", metrics.accuracy_score(test_data.target, logr_pred))
print(metrics.classification_report(test_data.target, logr_pred, target_names = test_data.target_names))
Для каждого класса также выведем наиболее важные в положительном и отрицательном смысле слова, с точки зрения логистической регресии. На каждый класс натренирована линейная функция и выбирается тот класс, для которого соответствующая функция вернула максимальное значение (см. softmax-регрессия).
print(logr.coef_.shape)
words = cv_stem.get_feature_names()
for i in range(20):
indices = logr.coef_[i].argsort()
least = indices[:10]
most = indices[-10:]
print(train_data.target_names[i])
print('Most important(+)', ' '.join([words[j] for j in most]))
print('Most important(-)', ' '.join([words[j] for j in least]))