#!/usr/bin/env python
# coding: utf-8
#
# ##
Открытый курс по машинному обучению
# Автор материала: Максим Самсонов (@mvsamsonov)
# # Подробно о CountVectorizer, TfIdfVectorizer и базовой обработке текстов
# Алгоритмы машинного обучения работают с числовыми и категориальными данными. Если же у нас текст, его надо как-то привести к числовому виду. Для этого текст проходит предобработку, в которой можно выделить следующие стадии:
#
# 1. Токенизация: выделение из текста слов (токенов), например с помощью регулярных выражений.
# 2. Отсечение стоп-слов: слова, которые встречаются слишком часто, не несут никакой информации, только зашумляют данные, и их можно удалить. Это можно сделать с помощью словаря стоп-слов или просто отсекая слова, которые встречаются в обрабатываемых текстах чаще всего.
# 3. Стемминг/лемматизация: приведение слов к начальной форме, чтобы игрорировать различия во временах, числах, падежах и прочее. При стемминге используется алгоритм, отсекающий суффиксы (который, например, преобразует books в book, leaves в leav, а was в wa), при лемматизации - словарь, который позволит выполнить эту работу более точно (но за большее время, и, собственно, нужен словарь).
# 4. Векторизация: собственно то, ради чего всё затевалось, преобразование набора слов в набор чисел. Двумя простейшими подходами являются bag-of-words, при котором просто считается, как часто встретилось каждое слово, и TF-IDF (term frequency - inverse document frequency), при котором больший вес даётся тем словам, которые встречаются в обрабатываемых текстах более редко.
#
# Второй и третий этапы можно пропускать.
# ## Токенизация
# Токенизация часто делается автоматически в рамках векторизации. Но рассмотрим, как её можно делать отдельно. Если бы тексты не содержали пунктуации, можно было бы просто воспользоваться методом split, но с пунтуацией всё чуть сложнее. Воспользуемся проверенной временем библиотекой NLTK.
# In[ ]:
import nltk
nltk.download('punkt')
# In[ ]:
tokenized = nltk.word_tokenize("It's a picture (painting) of goddess Ma'at")
tokenized
# Как мы видим, NLTK рассматривает пунктуацию как отдельные токены (что в каких-то случаях может быть полезно). Интересно, что имя с апострофом правильно распозналось как одно слово, а "it's" корректно разбилось на два токена.
# ## Стоп-слова
# Произведём отсечение стоп-слов по словарю.
# In[ ]:
stopwords = nltk.corpus.stopwords.words('english')
stopwords[:10]
# In[ ]:
[w for w in tokenized if w.lower() not in stopwords]
# Отфильтровались местоимение, артикль и предлог.
# ## Стемминг
# Стемминг просто отсекает то, что посчитает изменяемым окончанием слова.
#
# В качестве примера рассмотрим стеммер Портера (алгоритм, предложенный Мартином Портером ещё в 1979 году).
# In[ ]:
stemmer = nltk.stem.PorterStemmer()
[stemmer.stem(w) for w in ["plays", "played", "playing"]]
# Как видим, разные формы слова успешно приводятся к начальной форме. Применим стеммер к тексту.
# In[ ]:
text = "When you have eliminated all which is impossible, then whatever remains, however improbable, must be the truth."
[stemmer.stem(w) for w in nltk.word_tokenize(text)]
# Как мы видим, стеммер просто откидывает суффиксы. То, что получается в результате, не всегда является словом. Но раз наша цель привести текст к числам, то почему бы и нет. Конечно, при этом разные слова могут вдруг оказаться одинаковыми. Будет ли от стемминга больше вреда или пользы - зависит от задачи.
#
# Стеммер Портера является одним из первых и простейших алгоритмов стемминга. Есть и другие, например, Snowball.
# ## Лемматизация
# В отличие от стемминга, при лемматизации происходит не откидывание суффикса, а приведение слова к начальной форме с помощью словаря. Рассмотрим пример.
# In[ ]:
from nltk.stem import WordNetLemmatizer
lemm = WordNetLemmatizer()
[lemm.lemmatize(w) for w in ['wolves', 'women', 'eliminated', 'went']]
# Как видим, данный lemmatizer отлично справляется с существительными, но не справляется с глаголами. Это происходит потому что он по умолчанию считает всё существительными. Если явно подсказать ему, что это глагол, то он справится тоже:
# In[ ]:
[lemm.lemmatize(w, pos='v') for w in ['eliminated', 'went']]
# ## Bag-of-words (CountVectorizer)
# Рассмотрим теперь самый простой способ приведения текста к набору чисел. Для каждого слова посчитаем, как часто оно встречается в тексте. Результаты запишем в таблицу. Строки будут представлять тексты, столбцы -- слова. Если на пересечении строки с столбца стоит число 5, значит данное слово встретилось в данном тексте 5 раз. В большинстве ячеек будут нули. Поэтому хранить это всё удобнее в виде разреженных матриц (т.е. хранить только ненулевые значения).
#
# Таким образом, при построении "мешка слов" можно выделить следующие действия:
# 1. Токенизация.
# 2. Построение словаря: собираем все слова, которые встречались в текстах и пронумеровываем их (по алфавиту, например).
# 3. Построение разреженной матрицы.
#
# В sklearn алгоритм приведения текста в bag-of-words реализован в виде класса CountVectorizer. Рассмотрим пример.
# In[ ]:
from sklearn.feature_extraction.text import CountVectorizer
count_vectorizer = CountVectorizer()
texts = ["It is a capital mistake to theorize before one has data.",
"One begins to twist facts to suit theories, instead of theories to suit facts."]
bow = count_vectorizer.fit_transform(texts)
bow.shape
# Результат содержит 2 строки (для 2 текстов) и 18 столбцов (для 18 разных слов). Посмотрим словарь:
# In[ ]:
count_vectorizer.vocabulary_
# Как видим, ни стемминга, ни лемматизации по умолчанию не производится.
#
# Результат преобразования:
# In[ ]:
bow.todense()
# Т.о. "before" только в первом тексте, "begins" только во втором, (...) , "to" один раз в первом и 3 раза во втором, "twist" только во втором.
#
# ### Стоп-слова и другие методы отсечения лишнего
#
# #### Стоп-слова
# Можно легко включить отсечение стоп-слов:
# In[ ]:
count_vectorizer = CountVectorizer(stop_words='english')
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# In[ ]:
bow.todense()
# #### Параметр max_df
#
# Помимо стоп-слов есть и другие способы отсечения лишнего. Например, можно откидывать слова, которые встречаются слишком часто, с помощью параметра max_df. Установив max_df=2 мы откинем, все слова, которые встречаются более, чем в 2 документах.
# In[ ]:
texts = ["Soft kitty lyrics:", "Soft kitty, warm kitty", "Little ball of fur", "Happy kitty, sleepy kitty", "Purr, purr, purr"]
count_vectorizer = CountVectorizer(max_df=2)
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# Как видим, в словаре нет "kitty", т.к. оно встречается аж в 3 текстах, а мы поставили max_df=2.
#
# max_df может быть вещественным числом, тогда оно интерпретируется как доля документов.
#
# #### Параметр min_df
# С другой стороны, если слово встречается очень редко, оно скорее всего тоже не представляет интереса. Такие слова можно откидывать с помощью параметра min_df:
# In[ ]:
count_vectorizer = CountVectorizer(min_df=2)
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# Как видим, в словаре остались только слова, которые встретились не менее, чем в 2 документах.
# ### Token pattern
# Построим bag of words для простого предложения и посмотрим словарь.
# In[ ]:
count_vectorizer = CountVectorizer()
bow = count_vectorizer.fit_transform(["I am a cat"])
count_vectorizer.vocabulary_
# Что-то не так. Слов было 4, а тут только 2.
#
# По умолчанию CountVectorizer считает словами только слова длины не менее 2. Для того чтобы это изменить, используется параметр token_pattern. По умолчанию он равен регулярному выражению '(?u)\b\w\w+\b' (\b обозначает границы слов, \w обозначает букву, \w+ - непустую последовательность букв). Значит, удалив первую \w, получим то, что нужно:
# In[ ]:
count_vectorizer = CountVectorizer(token_pattern=r'(?u)\b\w+\b')
bow = count_vectorizer.fit_transform(["I am a cat"])
count_vectorizer.vocabulary_
# ### Биграммы, триграммы, n-граммы
# По умолчанию bag-of-words (как следует из названия) представляет собой просто мешок слов. То есть для него предложения "It's not good, it's bad!" и "It's not bad, it's good!" абсолютно эквивалентны. Понятно, что при этом теряется много информации. Можно рассматривать не только отдельные слова, а последовательности длиной из 2 слов (биграммы), из 3 слов (триграммы) или в общем случае из n слов (n-граммы). На практике обычно задаётся диапазон от 1 до n.
#
# Рассмотрим пример:
# In[ ]:
texts = ["Soft kitty lyrics:", "Soft kitty, warm kitty", "Little ball of fur", "Happy kitty, sleepy kitty", "Purr, purr, purr"]
count_vectorizer = CountVectorizer(ngram_range=(1,2))
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# На практике вряд ли есть большой смысл выделять последовательности более, чем из 5 слов, но n-граммы для n равного 3 или 4 вполне полезны.
# ### Ограничение количества признаков
# Понятно, что с ростом n количество выделенных n-грамм быстро растёт. Для ограничения количества признаков можно использовать параметр max_features. В этом случае будет создано не более max_features признаков (будут выбраны самые часто встречающиеся слова и последовательности слов). Например:
# In[ ]:
count_vectorizer = CountVectorizer(ngram_range=(1,3), max_features=10)
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# n-грамы выбираются из интервала от 1 до 3, из было бы больше 30, но мы оставляем только 10 самых важных.
# ### Слова или символы
# До сих пор мы в качестве элементов текста рассматривали слова. Но иногда бывает полезно рассматривать текст как последовательность отдельных букв. Если мы объединим эту идею с n-граммов, то получится, что нас интересует, насколько часто в тексте встречаются отдельные буквы, сочетания из двух букв, трёх букв, и т.д.
#
# Чтобы переключиться "в режим отдельных букв" используется параметр analyzer='char'. Очевидно, что количество вариантов будет относительно большим даже для небольшого текста, поэтому тут особенно важно не забыть ограничить max_features.
# In[ ]:
count_vectorizer = CountVectorizer(ngram_range=(1,6), analyzer='char', max_features=10)
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
# Несмотря на то, что разрешалось использовать последовательности длиной от 1 до 6, длина самых частых оказалась от 1 до 3.
# ### Слова И символы
# На практике бывает полезно попробовать рассматривать как слова, так и буквы. Или даже использовать оба варианта вместе: построить представления на основе букв, на основе слов и объединить их с помощью hstack.
# ## TF-IDF
# У подхода bag-of-words есть существенный недостаток. Если слово встречается 5 раз в конкретном документе, но и в других документах тоже встречается часто, то его наличие в документе не особо-то о чём-то говорит. Если же слово 5 раз встречается в конкретном документе, но в других документах встречается редко, то его наличие (да ещё и многократное) позволяет хорошо отличать этот документ от других. Однако с точки зрения bag-of-words различий не будет: в обеих ячейках будет просто число 5.
#
# Отчасти это решается исключением стоп-слов (и слишком часто встречающихся слов), но лишь отчасти. Другой идеей является отмасштабировать получившуюся таблицу с учётом "редкости" слова в наборе документов (т.е. с учётом информативности слова).
#
# \begin{equation*}
# tfidf = tf * idf \\
# idf = log \frac {N + 1}{N_w + 1} + 1
# \end{equation*}
#
# Здесь tf это частота слова в тексте (то же самое, что в bag of words), N - общее число документов, Nw - число документов, содержащих данное слово.
#
# То есть для каждого слова считается отношение общего количества документов к количеству документов, содержащих данное слово (для частых слов оно будет ближе к 1, для редких слов оно будет стремиться к числу, равному количеству документов), и на логарифм от этого числа умножается исходное значение bag-of-words (к числителю и знаменателю прибавляется единичка, чтобы не делить на 0, и к логарифму тоже прибавляется единичка, но это уже технические детали). После этого в sklearn ещё проводится L2-нормализация каждой строки.
#
# В sklearn есть два класса для поддержки TF-IDF: TfidfVectorizer и TfidfTransformer, рассмотрим их.
# ### TfidfVectorizer
# Этот класс применяется аналогично CountVectorizer:
# In[ ]:
from sklearn.feature_extraction.text import TfidfVectorizer
texts = ["Soft kitty lyrics:", "Soft kitty, warm kitty", "Little ball of fur", "Happy kitty, sleepy kitty", "Purr, purr, purr"]
tfidf_vectorizer = TfidfVectorizer()
tfidf = tfidf_vectorizer.fit_transform(texts)
tfidf_vectorizer.vocabulary_
# Словарь содержит те же 10 значений, которые были бы и для CountVectorizer. Но значения в таблице другие:
# In[ ]:
tfidf.todense()
# сравним результат с результатом работы CountVectorizer:
# In[ ]:
CountVectorizer().fit_transform(texts).todense()
# Ненулевые значения находятся на тех же местах, но отмасштабированы в зависимости от частоты слов.
#
# Рассмотрим для примера первую строку. В ней три ненулевых значения, с индексами 3, 5 и 9 (kitty, lyrics и soft). Все три слова встречаются в тексте по одному разу, поэтому в bag-of-words для каждого из них стоит значение 1. Но kitty встречается ещё в двух документах, поэтому tfidf для неё лишь 0.46; слово lyrics встречается только в этом документе, поэтому у него значение выше: 0.69; soft встречается ещё в одном документе, у него 0.56 (меньше 0.69, но больше 0.46).
# ### Параметр sublinear_tf
# Большая часть параметров у CountVectorizer и TfidfVectorizer одинакова. Но у TfidfVectorizer есть один важный дополнительный параметр.
#
# Как видно из формулы tfidf = tf * idf, если слово будет встречаться не один, а два раза, то tfidf вырастет в два раза. Если слово будет встречаться не один, а 10 раз, то tfidf вырастет в 10 раз. На итоговой строке, конечно, производится нормализация, так что значение всё равно останется в пределах единицы, но за счёт других значений в этой строке. В качестве примера добавим в первую строку ещё пару слов lyrics:
# In[ ]:
texts = ["Soft kitty lyrics lyrics lyrics:",
"Soft kitty, warm kitty", "Little ball of fur", "Happy kitty, sleepy kitty", "Purr, purr, purr"]
TfidfVectorizer().fit_transform(texts).todense()[0]
# Значение tfidf выросло с 0.69015927 до 0.94400181, а остальные два упали почти в 2 раза.
#
# Вопрос - хотим ли мы таких драматических изменений. Если не хотим, то можно использовать параметр sublinear_tf=True. При его использовании вместо tf будет браться 1 + log(tf). То есть по-прежнему с ростом tf будет расти и tfidf, но уже не так радикально (и соответственно остальные значения будут уменьшаться не так быстро):
# In[ ]:
TfidfVectorizer(sublinear_tf=True).fit_transform(texts).todense()[0]
# Для некоторых задач это может дать прирост в качестве.
# ### TfidfTransformer
# Как мы видели, tfidf строится на основе tf, т.е. bag-of-words. Что если у нас уже есть готовый bag-of-words, обязательно ли строить tfidf на сыром тексте или воспользоваться готовым промежуточным результатом?
#
# TfidfTransformer строит tfidf на основе результата работы CountVectorizer. В качестве примера построим CountVectorizer:
# In[ ]:
texts = ["Soft kitty lyrics:", "Soft kitty, warm kitty", "Little ball of fur", "Happy kitty, sleepy kitty", "Purr, purr, purr"]
count_vectorizer = CountVectorizer()
bow = count_vectorizer.fit_transform(texts)
bow.todense()
# и применим преобразование:
# In[ ]:
from sklearn.feature_extraction.text import TfidfTransformer
tfidf = TfidfTransformer().fit_transform(bow)
tfidf.todense()
# Получилась та же матрица, что и при использовании TfidfVectorizer напрямую.
#
# Параметры, отвечающие за построение tf, настраиваются в CountVectorizer; а параметры, специфичные для TF-IDF (такие как sublinear_tf), настраиваются уже в TfidfTransformer.
# ## Краткие итоги
#
# * Для работы с текстами надо как-то преобразовывать их к числовому представлению
# * Классическими и самыми простыми средствами для этого являются bag-of-words и tf-idf
# * В sklearn есть удобные классы-векторайзеры, реализующие bag-of-words и tf-idf (и трансформер для преобразования от bag-of-words к tf-idf)
# * Они реализуют токенизацию, векторизацию и при желании отсечение стоп-слов
# * У векторайзеров много параметров, позволяющих добиваться наиболее удачного представления текста. Знание этих параметров (и понимание того, как они работают) может сильно повысить эффективность обучения и качество результата
# * Отдельно токенизацию, стемминг и прочее можно делать, например, средствами NLTK