#!/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