Алгоритмы машинного обучения работают с числовыми и категориальными данными. Если же у нас текст, его надо как-то привести к числовому виду. Для этого текст проходит предобработку, в которой можно выделить следующие стадии:
Второй и третий этапы можно пропускать.
Токенизация часто делается автоматически в рамках векторизации. Но рассмотрим, как её можно делать отдельно. Если бы тексты не содержали пунктуации, можно было бы просто воспользоваться методом split, но с пунтуацией всё чуть сложнее. Воспользуемся проверенной временем библиотекой NLTK.
import nltk
nltk.download('punkt')
tokenized = nltk.word_tokenize("It's a picture (painting) of goddess Ma'at")
tokenized
Как мы видим, NLTK рассматривает пунктуацию как отдельные токены (что в каких-то случаях может быть полезно). Интересно, что имя с апострофом правильно распозналось как одно слово, а "it's" корректно разбилось на два токена.
Произведём отсечение стоп-слов по словарю.
stopwords = nltk.corpus.stopwords.words('english')
stopwords[:10]
[w for w in tokenized if w.lower() not in stopwords]
Отфильтровались местоимение, артикль и предлог.
Стемминг просто отсекает то, что посчитает изменяемым окончанием слова.
В качестве примера рассмотрим стеммер Портера (алгоритм, предложенный Мартином Портером ещё в 1979 году).
stemmer = nltk.stem.PorterStemmer()
[stemmer.stem(w) for w in ["plays", "played", "playing"]]
Как видим, разные формы слова успешно приводятся к начальной форме. Применим стеммер к тексту.
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.
В отличие от стемминга, при лемматизации происходит не откидывание суффикса, а приведение слова к начальной форме с помощью словаря. Рассмотрим пример.
from nltk.stem import WordNetLemmatizer
lemm = WordNetLemmatizer()
[lemm.lemmatize(w) for w in ['wolves', 'women', 'eliminated', 'went']]
Как видим, данный lemmatizer отлично справляется с существительными, но не справляется с глаголами. Это происходит потому что он по умолчанию считает всё существительными. Если явно подсказать ему, что это глагол, то он справится тоже:
[lemm.lemmatize(w, pos='v') for w in ['eliminated', 'went']]
Рассмотрим теперь самый простой способ приведения текста к набору чисел. Для каждого слова посчитаем, как часто оно встречается в тексте. Результаты запишем в таблицу. Строки будут представлять тексты, столбцы -- слова. Если на пересечении строки с столбца стоит число 5, значит данное слово встретилось в данном тексте 5 раз. В большинстве ячеек будут нули. Поэтому хранить это всё удобнее в виде разреженных матриц (т.е. хранить только ненулевые значения).
Таким образом, при построении "мешка слов" можно выделить следующие действия:
В sklearn алгоритм приведения текста в bag-of-words реализован в виде класса CountVectorizer. Рассмотрим пример.
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 разных слов). Посмотрим словарь:
count_vectorizer.vocabulary_
Как видим, ни стемминга, ни лемматизации по умолчанию не производится.
Результат преобразования:
bow.todense()
count_vectorizer = CountVectorizer(stop_words='english')
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
bow.todense()
Помимо стоп-слов есть и другие способы отсечения лишнего. Например, можно откидывать слова, которые встречаются слишком часто, с помощью параметра max_df. Установив max_df=2 мы откинем, все слова, которые встречаются более, чем в 2 документах.
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:
count_vectorizer = CountVectorizer(min_df=2)
bow = count_vectorizer.fit_transform(texts)
count_vectorizer.vocabulary_
Как видим, в словаре остались только слова, которые встретились не менее, чем в 2 документах.
Построим bag of words для простого предложения и посмотрим словарь.
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, получим то, что нужно:
count_vectorizer = CountVectorizer(token_pattern=r'(?u)\b\w+\b')
bow = count_vectorizer.fit_transform(["I am a cat"])
count_vectorizer.vocabulary_
По умолчанию bag-of-words (как следует из названия) представляет собой просто мешок слов. То есть для него предложения "It's not good, it's bad!" и "It's not bad, it's good!" абсолютно эквивалентны. Понятно, что при этом теряется много информации. Можно рассматривать не только отдельные слова, а последовательности длиной из 2 слов (биграммы), из 3 слов (триграммы) или в общем случае из n слов (n-граммы). На практике обычно задаётся диапазон от 1 до n.
Рассмотрим пример:
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 признаков (будут выбраны самые часто встречающиеся слова и последовательности слов). Например:
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.
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.
У подхода 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, рассмотрим их.
Этот класс применяется аналогично CountVectorizer:
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. Но значения в таблице другие:
tfidf.todense()
сравним результат с результатом работы CountVectorizer:
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).
Большая часть параметров у CountVectorizer и TfidfVectorizer одинакова. Но у TfidfVectorizer есть один важный дополнительный параметр.
Как видно из формулы tfidf = tf * idf, если слово будет встречаться не один, а два раза, то tfidf вырастет в два раза. Если слово будет встречаться не один, а 10 раз, то tfidf вырастет в 10 раз. На итоговой строке, конечно, производится нормализация, так что значение всё равно останется в пределах единицы, но за счёт других значений в этой строке. В качестве примера добавим в первую строку ещё пару слов lyrics:
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, но уже не так радикально (и соответственно остальные значения будут уменьшаться не так быстро):
TfidfVectorizer(sublinear_tf=True).fit_transform(texts).todense()[0]
Для некоторых задач это может дать прирост в качестве.
Как мы видели, tfidf строится на основе tf, т.е. bag-of-words. Что если у нас уже есть готовый bag-of-words, обязательно ли строить tfidf на сыром тексте или воспользоваться готовым промежуточным результатом?
TfidfTransformer строит tfidf на основе результата работы CountVectorizer. В качестве примера построим CountVectorizer:
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()
и применим преобразование:
from sklearn.feature_extraction.text import TfidfTransformer
tfidf = TfidfTransformer().fit_transform(bow)
tfidf.todense()
Получилась та же матрица, что и при использовании TfidfVectorizer напрямую.
Параметры, отвечающие за построение tf, настраиваются в CountVectorizer; а параметры, специфичные для TF-IDF (такие как sublinear_tf), настраиваются уже в TfidfTransformer.