8.2. Извлечение признаков
Модуль sklearn.feature_extraction можно использовать для извлечения признаков из наборов данных, состоящих из таких форматов, как текст и изображения, и преобразования их в формат, поддерживаемый алгоритмами машинного обучения.
Примечание
Извлечение признаков существенно отличается от отбора признаков: первое заключается в преобразовании произвольных данных, таких как текст или изображения, в числовые признаки, пригодные для машинного обучения. Второе — это метод машинного обучения, применяемый к этим признакам.
8.2.1. Загрузка признаков из словарей
Класс DictVectorizer можно использовать для преобразования массивов признаков, представленных в виде списков стандартных объектов Python dict, в представление NumPy/SciPy, используемое оценщиками scikit-learn.
Хотя Python dict обрабатывается не особенно быстро, он удобен в использовании, является разреженным (отсутствующие признаки не нужно хранить) и хранит названия признаков наряду со значениями.
DictVectorizer реализует так называемое кодирование «один из K» или «one-hot» для категориальных (также называемых номинальными, дискретными) признаков. Категориальные признаки — это пары «атрибут-значение», в которых значение ограничено списком дискретных вариантов без определённого порядка (например, идентификаторы тем, типы объектов, теги, имена…).
В следующем примере «город» — это категориальный атрибут, а «температура» — обычный числовой признак:
>>> measurements = [
... {'city': 'Dubai', 'temperature': 33.},
... {'city': 'London', 'temperature': 12.},
... {'city': 'San Francisco', 'temperature': 18.},
... ]
>>> from sklearn.feature_extraction import DictVectorizer
>>> vec = DictVectorizer()
>>> vec.fit_transform(measurements).toarray()
array([[ 1., 0., 0., 33.],
[ 0., 1., 0., 12.],
[ 0., 0., 1., 18.]])
>>> vec.get_feature_names_out()
array(['city=Dubai', 'city=London', 'city=San Francisco', 'temperature'], ...)
DictVectorizer принимает несколько строковых значений для одного признака, например несколько категорий для фильма.
Предположим, что база данных классифицирует каждый фильм по нескольким категориям (необязательно) и году его выпуска.
>>> movie_entry = [{'category': ['thriller', 'drama'], 'year': 2003},
... {'category': ['animation', 'family'], 'year': 2011},
... {'year': 1974}]
>>> vec.fit_transform(movie_entry).toarray()
array([[0.000e+00, 1.000e+00, 0.000e+00, 1.000e+00, 2.003e+03],
[1.000e+00, 0.000e+00, 1.000e+00, 0.000e+00, 2.011e+03],
[0.000e+00, 0.000e+00, 0.000e+00, 0.000e+00, 1.974e+03]])
>>> vec.get_feature_names_out()
array(['category=animation', 'category=drama', 'category=family',
'category=thriller', 'year'], ...)
>>> vec.transform({'category': ['thriller'],
... 'unseen_feature': '3'}).toarray()
array([[0., 0., 0., 1., 0.]])
DictVectorizer также удобно использовать для преобразования представлений при обучении классификаторов последовательностей в моделях обработки естественного языка, которые обычно извлекают окна признаков вокруг интересующего слова.
Например, предположим, что у нас есть алгоритм, извлекающий теги частей речи (PoS), которые мы хотим использовать как дополнительные теги для обучения классификатора последовательностей (например, чанкерa). Следующий словарь может представлять собой окно признаков, извлечённых вокруг слова «sat» в предложении «The cat sat on the mat.»:
>>> pos_window = [
... {
... 'word-2': 'the',
... 'pos-2': 'DT',
... 'word-1': 'cat',
... 'pos-1': 'NN',
... 'word+1': 'on',
... 'pos+1': 'PP',
... },
... # in a real application one would extract many such dictionaries
... ]
Это описание можно векторизовать в разреженную двумерную матрицу, подходящую для передачи классификатору (возможно, предварительно передав её в TfidfTransformer для нормализации):
>>> vec = DictVectorizer()
>>> pos_vectorized = vec.fit_transform(pos_window)
>>> pos_vectorized
<Compressed Sparse...dtype 'float64'
with 6 stored elements and shape (1, 6)>
>>> pos_vectorized.toarray()
array([[1., 1., 1., 1., 1., 1.]])
>>> vec.get_feature_names_out()
array(['pos+1=PP', 'pos-1=NN', 'pos-2=DT', 'word+1=on', 'word-1=cat',
'word-2=the'], ...)
Как можно представить, если извлекать такой контекст вокруг каждого отдельного слова корпуса документов, результирующая матрица будет очень широкой (с большим количеством признаков one-hot), большинство из которых почти всегда будут иметь нулевые значения. Чтобы результирующая структура данных помещалась в память, класс DictVectorizer по умолчанию использует матрицу scipy.sparse вместо numpy.ndarray.
8.2.2. Хеширование признаков
Класс FeatureHasher — это быстрый векторизатор с низким потреблением памяти, использующий метод, известный как хеширование признаков или «трюк хеширования». Вместо создания хеш-таблицы обнаруженных при обучении признаков, как это делают векторизаторы, экземпляры FeatureHasher применяют к признакам хеш-функцию, чтобы напрямую определить индекс их столбца в матрицах выборок. Это повышает скорость и снижает потребление памяти за счёт возможности анализа: хешер не запоминает, как выглядели входные признаки, и не имеет метода inverse_transform.
Поскольку хеш-функция может приводить к коллизиям между (несвязанными) признаками, используется знаковая хеш-функция, а знак хеш-значения определяет знак значения, сохранённого в выходной матрице для признака. Благодаря этому коллизии скорее взаимно компенсируются, а не накапливают ошибку, и математическое ожидание значения любого выходного признака равно нулю. Этот механизм включён по умолчанию с помощью alternate_sign=True и особенно полезен при небольшом размере хеш-таблицы (n_features < 10000). Для больших хеш-таблиц его можно отключить, чтобы передавать выходные данные таким оценщикам, как MultinomialNB, или таким селекторам признаков, как chi2, которым необходимы неотрицательные входные данные.
FeatureHasher принимает отображения (например, Python dict и его варианты из модуля collections), пары (feature, value) или строки — в зависимости от параметра конструктора input_type. Отображения рассматриваются как списки пар (feature, value), а отдельные строки имеют неявное значение 1, поэтому ['feat1', 'feat2', 'feat3'] интерпретируется как [('feat1', 1), ('feat2', 1), ('feat3', 1)]. Если один признак встречается в выборке несколько раз, соответствующие значения суммируются (то есть ('feat', 2) и ('feat', 3.5) превращаются в ('feat', 5.5)). Результат работы FeatureHasher всегда представляет собой матрицу scipy.sparse в формате CSR.
Хеширование признаков можно применять для классификации документов, однако, в отличие от CountVectorizer, FeatureHasher не разбивает слова и не выполняет никакой другой предварительной обработки, кроме кодирования Unicode в UTF-8; см. раздел Векторизация большого текстового корпуса с помощью трюка хеширования ниже, где описан объединённый токенизатор-хешер.
В качестве примера рассмотрим задачу обработки естественного языка на уровне слов, для которой нужно извлечь признаки из пар (token, part_of_speech). Для извлечения признаков можно использовать генераторную функцию Python:
def token_features(token, part_of_speech):
if token.isdigit():
yield "numeric"
else:
yield "token={}".format(token.lower())
yield "token,pos={},{}".format(token, part_of_speech)
if token[0].isupper():
yield "uppercase_initial"
if token.isupper():
yield "all_uppercase"
yield "pos={}".format(part_of_speech)
Затем raw_X, который будет передан в FeatureHasher.transform, можно сформировать следующим образом:
raw_X = (token_features(tok, pos_tagger(tok)) for tok in corpus)
и передать хешеру с помощью:
hasher = FeatureHasher(input_type='string') X = hasher.transform(raw_X)
чтобы получить матрицу scipy.sparse X.
Обратите внимание на использование генераторного выражения, которое обеспечивает ленивое извлечение признаков: токены обрабатываются только по запросу хешера.
Подробности реализации
FeatureHasher использует знаковый 32-разрядный вариант MurmurHash3. В результате (а также из-за ограничений scipy.sparse) максимальное поддерживаемое число признаков на данный момент составляет \(2^{31} - 1\).
В первоначальной формулировке трюка хеширования, предложенной Weinberger и др., использовались две отдельные хеш-функции \(h\) и \(\xi\) для определения соответственно индекса столбца и знака признака. Текущая реализация основана на предположении, что знаковый бит MurmurHash3 не зависит от остальных его битов.
Поскольку для преобразования хеш-функции в индекс столбца используется простая операция взятия остатка, рекомендуется задавать параметр n_features степенью двойки; иначе признаки будут неравномерно распределяться по столбцам.
Литература
Литература
- Kilian Weinberger, Anirban Dasgupta, John Langford, Alex Smola и Josh Attenberg (2009). Хеширование признаков для масштабного многозадачного обучения. Proc. ICML.
8.2.3. Извлечение текстовых признаков
8.2.3.1. Представление «мешок слов»
Анализ текста — важная область применения алгоритмов машинного обучения. Однако исходные данные — последовательность символов — нельзя напрямую передать самим алгоритмам, поскольку большинство из них ожидает числовые векторы признаков фиксированного размера, а не текстовые документы переменной длины.
Для решения этой задачи scikit-learn предоставляет средства для наиболее распространённых способов извлечения числовых признаков из текстовых данных, а именно:
- токенизация строк и присвоение целочисленного идентификатора каждому возможному токену, например с использованием пробелов и знаков препинания в качестве разделителей токенов.
- подсчёт вхождений токенов в каждый документ.
- нормализация и взвешивание токенов с уменьшением значимости тех из них, которые встречаются в большинстве образцов / документов.
В этой схеме признаки и образцы определяются следующим образом:
- каждая частота вхождения отдельного токена (нормализованная или нет) рассматривается как признак.
- вектор всех частот токенов для заданного документа считается многомерным образцом.
Таким образом, корпус документов можно представить в виде матрицы, где каждой строке соответствует документ, а каждому столбцу — токен (например, слово), встречающийся в корпусе.
Векторизацией называется общий процесс преобразования коллекции текстовых документов в числовые векторы признаков. Эта конкретная стратегия (токенизация, подсчёт и нормализация) называется представлением «мешок слов» или «мешок n-грамм». Документы описываются вхождениями слов, при этом информация об относительном положении слов в документе полностью игнорируется.
8.2.3.2. Разреженность
Поскольку в большинстве документов обычно используется лишь очень небольшое подмножество слов из корпуса, в результирующей матрице будет много нулевых значений признаков (обычно более 99%).
Например, в коллекции из 10 000 коротких текстовых документов (таких как электронные письма) будет использоваться словарь размером около 100 000 уникальных слов в целом, тогда как в каждом документе по отдельности будет использоваться от 100 до 1000 уникальных слов.
Чтобы иметь возможность хранить такую матрицу в памяти, а также ускорить алгебраические операции над матрицами / векторами, реализации обычно используют разреженное представление, например реализации, доступные в пакете scipy.sparse.
8.2.3.3. Типичное использование векторизатора
CountVectorizer выполняет и токенизацию, и подсчёт вхождений в одном классе:
>>> from sklearn.feature_extraction.text import CountVectorizer
У этой модели много параметров, однако значения по умолчанию вполне разумны (подробности см. в справочной документации):
>>> vectorizer = CountVectorizer() >>> vectorizer CountVectorizer()
Воспользуемся им, чтобы токенизировать текст и подсчитать вхождения слов в минималистичном корпусе текстовых документов:
>>> corpus = [ ... 'This is the first document.', ... 'This is the second second document.', ... 'And the third one.', ... 'Is this the first document?', ... ] >>> X = vectorizer.fit_transform(corpus) >>> X <Compressed Sparse...dtype 'int64' with 19 stored elements and shape (4, 9)>
Конфигурация по умолчанию токенизирует строку, извлекая слова длиной не менее 2 букв. Функцию, выполняющую этот шаг, можно запросить явно:
>>> analyze = vectorizer.build_analyzer()
>>> analyze("This is a text document to analyze.") == (
... ['this', 'is', 'text', 'document', 'to', 'analyze'])
True
Каждому термину, обнаруженному анализатором во время обучения, присваивается уникальный целочисленный индекс, соответствующий столбцу результирующей матрицы. Получить такое соответствие столбцов можно следующим образом:
>>> vectorizer.get_feature_names_out()
array(['and', 'document', 'first', 'is', 'one', 'second', 'the',
'third', 'this'], ...)
>>> X.toarray()
array([[0, 1, 1, 1, 0, 0, 1, 0, 1],
[0, 1, 0, 1, 0, 2, 1, 0, 1],
[1, 0, 0, 0, 1, 0, 1, 1, 0],
[0, 1, 1, 1, 0, 0, 1, 0, 1]]...)
Обратное соответствие имени признака индексу столбца хранится в атрибуте vocabulary_ векторизатора:
>>> vectorizer.vocabulary_.get('document')
1
Поэтому слова, которые не встречались в обучающем корпусе, будут полностью игнорироваться при последующих вызовах метода transform:
>>> vectorizer.transform(['Something completely new.']).toarray() array([[0, 0, 0, 0, 0, 0, 0, 0, 0]]...)
Обратите внимание, что в предыдущем корпусе первый и последний документы содержат совершенно одинаковые слова, поэтому кодируются одинаковыми векторами. В частности, мы теряем информацию о том, что последний документ имеет вопросительную форму. Чтобы сохранить часть информации о локальном порядке слов, можно извлекать 2-граммы слов в дополнение к 1-граммам (отдельным словам):
>>> bigram_vectorizer = CountVectorizer(ngram_range=(1, 2),
... token_pattern=r'\b\w+\b', min_df=1)
>>> analyze = bigram_vectorizer.build_analyzer()
>>> analyze('Bi-grams are cool!') == (
... ['bi', 'grams', 'are', 'cool', 'bi grams', 'grams are', 'are cool'])
True
Словарь, извлечённый этим векторизатором, становится гораздо больше и позволяет устранить неоднозначности, закодированные в локальных позиционных шаблонах:
>>> X_2 = bigram_vectorizer.fit_transform(corpus).toarray()
>>> X_2
array([[0, 0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0],
[0, 0, 1, 0, 0, 1, 1, 0, 0, 2, 1, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0],
[1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 1, 1, 0, 0, 0],
[0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1]]...)
В частности, вопросительная форма «Is this» встречается только в последнем документе:
>>> feature_index = bigram_vectorizer.vocabulary_.get('is this')
>>> X_2[:, feature_index]
array([0, 0, 0, 1]...)
8.2.3.4. Использование стоп-слов
Стоп-слова — это слова вроде «and», «the», «him», которые считаются неинформативными при представлении содержания текста и могут быть удалены, чтобы избежать их ошибочной интерпретации как полезных для предсказания признаков. Однако иногда похожие слова полезны для предсказания, например при классификации стиля письма или личности.
В предоставляемом нами списке стоп-слов для английского языка есть несколько известных проблем. Он не претендует на роль универсального решения, подходящего для всех задач, поскольку некоторые задачи могут требовать более индивидуального подхода. Подробнее см. в [NQY18].
Тщательно выбирайте список стоп-слов. Популярные списки стоп-слов могут включать слова, весьма информативные для некоторых задач, например computer.
Также убедитесь, что к списку стоп-слов применены те же предварительная обработка и токенизация, что и к данным в векторизаторе. Токенизатор CountVectorizer по умолчанию разделяет слово we’ve на we и ve, поэтому, если we’ve входит в stop_words, а ve — нет, то ve из we’ve сохранится в преобразованном тексте. Наши векторизаторы попытаются обнаружить некоторые виды несоответствий и выдать предупреждение.
Литература
J. Nothman, H. Qin и R. Yurchak (2018). «Списки стоп-слов в пакетах свободного программного обеспечения с открытым исходным кодом». В Трудах семинара по программному обеспечению с открытым исходным кодом для обработки естественного языка.
8.2.3.5. Взвешивание термов по tf–idf
В большом текстовом корпусе некоторые слова встречаются очень часто (например, «the», «a», «is» в английском языке), а значит, содержат очень мало значимой информации о фактическом содержании документа. Если передать классификатору непосредственно данные о количестве вхождений, очень частые термины затмят частоты более редких, но интересных терминов.
Для преобразования признаков-частот в значения с плавающей точкой, пригодные для использования классификатором, обычно применяют преобразование tf–idf.
Tf означает частоту терма, а tf–idf — частоту терма, умноженную на обратную частоту документа: \(\text{tf-idf(t,d)}=\text{tf(t,d)} \times \text{idf(t)}\).
При использовании настроек TfidfTransformer по умолчанию TfidfTransformer(norm='l2', use_idf=True, smooth_idf=True, sublinear_tf=False) частота терма — число его вхождений в заданный документ — умножается на компонент idf, который вычисляется следующим образом:
\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\),
где \(n\) — общее число документов в наборе документов, а \(\text{df}(t)\) — число документов в наборе, содержащих терм \(t\). Затем результирующие векторы tf-idf нормализуются по евклидовой норме:
\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\).
Изначально эта схема взвешивания термов была разработана для информационного поиска (как функция ранжирования результатов поисковых систем), но она также хорошо зарекомендовала себя в классификации и кластеризации документов.
В следующих разделах приводятся дополнительные объяснения и примеры, показывающие, как именно вычисляется tf-idf и чем значения tf-idf, вычисляемые в scikit-learn с помощью TfidfTransformer и TfidfVectorizer, немного отличаются от стандартной записи в учебниках, где idf определяется как
\(\text{idf}(t) = \log{\frac{n}{1+\text{df}(t)}}.\)
В TfidfTransformer и TfidfVectorizer с параметром smooth_idf=False единица добавляется к idf, а не к знаменателю idf:
\(\text{idf}(t) = \log{\frac{n}{\text{df}(t)}} + 1\)
Эта нормализация реализована в классе TfidfTransformer:
>>> from sklearn.feature_extraction.text import TfidfTransformer >>> transformer = TfidfTransformer(smooth_idf=False) >>> transformer TfidfTransformer(smooth_idf=False)
Подробные сведения обо всех параметрах см. в справочной документации.
Числовой пример матрицы tf-idf
Рассмотрим пример со следующими частотами. Первый терм присутствует в 100% случаев и потому не очень интересен. Два других признака встречаются менее чем в 50% случаев, поэтому, вероятно, лучше характеризуют содержание документов:
>>> counts = [[3, 0, 1],
... [2, 0, 0],
... [3, 0, 0],
... [4, 0, 0],
... [3, 2, 0],
... [3, 0, 2]]
...
>>> tfidf = transformer.fit_transform(counts)
>>> tfidf
<Compressed Sparse...dtype 'float64'
with 9 stored elements and shape (6, 3)>
>>> tfidf.toarray()
array([[0.81940995, 0. , 0.57320793],
[1. , 0. , 0. ],
[1. , 0. , 0. ],
[1. , 0. , 0. ],
[0.47330339, 0.88089948, 0. ],
[0.58149261, 0. , 0.81355169]])
Каждая строка нормализуется так, чтобы её евклидова норма была равна единице:
\(v_{norm} = \frac{v}{||v||_2} = \frac{v}{\sqrt{v{_1}^2 + v{_2}^2 + \dots + v{_n}^2}}\)
Например, можно вычислить tf-idf первого терма в первом документе массива counts следующим образом:
\(n = 6\)
\(\text{df}(t)_{\text{term1}} = 6\)
\(\text{idf}(t)_{\text{term1}} = \log \frac{n}{\text{df}(t)} + 1 = \log(1)+1 = 1\)
\(\text{tf-idf}_{\text{term1}} = \text{tf} \times \text{idf} = 3 \times 1 = 3\)
Повторив вычисление для двух оставшихся термов в документе, получим
\(\text{tf-idf}_{\text{term2}} = 0 \times (\log(6/1)+1) = 0\)
\(\text{tf-idf}_{\text{term3}} = 1 \times (\log(6/2)+1) \approx 2.0986\)
и вектор исходных значений tf-idf:
\(\text{tf-idf}_{\text{raw}} = [3, 0, 2.0986].\)
Затем, применив евклидову норму (L2), получим следующие значения tf-idf для документа 1:
\(\frac{[3, 0, 2.0986]}{\sqrt{\big(3^2 + 0^2 + 2.0986^2\big)}} = [ 0.819, 0, 0.573].\)
Кроме того, параметр smooth_idf=True по умолчанию добавляет «1» к числителю и знаменателю, как если бы в коллекции был ещё один документ, содержащий каждый терм ровно один раз; это позволяет избежать деления на ноль:
\(\text{idf}(t) = \log{\frac{1 + n}{1+\text{df}(t)}} + 1\)
При таком изменении tf-idf третьего терма в документе 1 становится равным 1.8473:
\(\text{tf-idf}_{\text{term3}} = 1 \times \log(7/3)+1 \approx 1.8473\)
А нормализованное по L2 значение tf-idf становится равным
\(\frac{[3, 0, 1.8473]}{\sqrt{\big(3^2 + 0^2 + 1.8473^2\big)}} = [0.8515, 0, 0.5243]\):
>>> transformer = TfidfTransformer()
>>> transformer.fit_transform(counts).toarray()
array([[0.85151335, 0. , 0.52433293],
[1. , 0. , 0. ],
[1. , 0. , 0. ],
[1. , 0. , 0. ],
[0.55422893, 0.83236428, 0. ],
[0.63035731, 0. , 0.77630514]])
Веса каждого признака, вычисленные вызовом метода fit, сохраняются в атрибуте модели:
>>> transformer.idf_ array([1., 2.25, 1.84])
Поскольку tf-idf очень часто используется для текстовых признаков, существует также класс TfidfVectorizer, объединяющий все возможности CountVectorizer и TfidfTransformer в одной модели:
>>> from sklearn.feature_extraction.text import TfidfVectorizer >>> vectorizer = TfidfVectorizer() >>> vectorizer.fit_transform(corpus) <Compressed Sparse...dtype 'float64' with 19 stored elements and shape (4, 9)>
Хотя нормализация tf-idf часто бывает очень полезна, в некоторых случаях бинарные индикаторы вхождений могут оказаться более подходящими признаками. Этого можно добиться с помощью параметра binary класса CountVectorizer. В частности, некоторые оцениватели, например наивный байесовский классификатор Бернулли, явно моделируют дискретные булевы случайные переменные. Кроме того, в очень коротких текстах значения tf-idf могут быть зашумлены, тогда как информация о бинарных вхождениях более стабильна.
Как обычно, лучший способ подбора параметров извлечения признаков — поиск по сетке с перекрёстной проверкой, например, объединение экстрактора признаков и классификатора в конвейер:
Примеры
- Классификация текстовых документов с использованием разреженных признаков: кодирование признаков с помощью разреженной матрицы «документ–терм» с весами Tf-idf.
- Сравнение FeatureHasher и DictVectorizer: сравнение эффективности различных экстракторов признаков.
-
Кластеризация текстовых документов с помощью k-means: кластеризация документов и сравнение с
HashingVectorizer. -
Пример конвейера для извлечения признаков из текста и оценки: настройка гиперпараметров
TfidfVectorizerв составе конвейера.
8.2.3.6. Декодирование текстовых файлов
Текст состоит из символов, а файлы — из байтов. Эти байты представляют символы в соответствии с некоторой кодировкой. Чтобы работать с текстовыми файлами в Python, их байты необходимо декодировать в набор символов под названием Unicode. К распространённым кодировкам относятся ASCII, Latin-1 (Западная Европа), KOI8-R (русский язык), а также универсальные кодировки UTF-8 и UTF-16. Существуют и многие другие.
Примечание
Кодировку также могут называть «набором символов», однако этот термин менее точен: для одного набора символов может существовать несколько кодировок.
Средства извлечения текстовых признаков в scikit-learn умеют декодировать текстовые файлы, но только если вы укажете, в какой кодировке они записаны. Для этой цели в CountVectorizer предусмотрен параметр encoding. Для современных текстовых файлов правильной кодировкой, скорее всего, будет UTF-8, поэтому она используется по умолчанию (encoding="utf-8").
Если, однако, загружаемый текст на самом деле закодирован не в UTF-8, возникнет ошибка UnicodeDecodeError. Чтобы векторизаторы не сообщали об ошибках декодирования, для параметра decode_error можно задать значение "ignore" или "replace". Подробнее см. документацию функции Python bytes.decode (введите help(bytes.decode) в приглашении Python).
Устранение проблем с декодированием текста
Если у вас возникли проблемы с декодированием текста, попробуйте следующее:
- Узнайте фактическую кодировку текста. В файле может быть заголовок или файл README с указанием кодировки либо можно предположить стандартную кодировку, исходя из источника текста.
- Тип кодировки в целом можно определить с помощью команды UNIX
file. В состав модуля Pythonchardetвходит скриптchardetect.py, который попытается определить конкретную кодировку, хотя нельзя полагаться на точность его предположения. - Можно попробовать UTF-8, игнорируя ошибки. Байтовые строки можно декодировать с помощью
bytes.decode(errors='replace'), чтобы заменить все ошибки декодирования бессмысленным символом, или установитьdecode_error='replace'в векторизаторе. Это может снизить полезность признаков. - Реальный текст может поступать из разных источников, использовавших различные кодировки, или даже быть небрежно декодирован в кодировке, отличной от исходной. Такое часто встречается в текстах, полученных из Интернета. Пакет Python ftfy может автоматически исправлять некоторые виды ошибок декодирования. Поэтому можно попробовать декодировать неизвестный текст как
latin-1, а затем использоватьftfyдля исправления ошибок. - Если текст представляет собой смесь кодировок, которую слишком сложно разобрать (как в наборе данных 20 Newsgroups), можно использовать простую однобайтовую кодировку, например
latin-1. Часть текста может отображаться некорректно, но по крайней мере одна и та же последовательность байтов всегда будет представлять один и тот же признак.
Например, следующий фрагмент использует chardet (не входит в поставку scikit-learn и должен быть установлен отдельно), чтобы определить кодировку трёх текстов. Затем он векторизует тексты и выводит обученный словарь. Результат здесь не показан.
>>> import chardet >>> text1 = b"Sei mir gegr\xc3\xbc\xc3\x9ft mein Sauerkraut" >>> text2 = b"holdselig sind deine Ger\xfcche" >>> text3 = b"\xff\xfeA\x00u\x00f\x00 \x00F\x00l\x00\xfc\x00g\x00e\x00l\x00n\x00 \x00d\x00e\x00s\x00 \x00G\x00e\x00s\x00a\x00n\x00g\x00e\x00s\x00,\x00 \x00H\x00e\x00r\x00z\x00l\x00i\x00e\x00b\x00c\x00h\x00e\x00n\x00,\x00 \x00t\x00r\x00a\x00g\x00 \x00i\x00c\x00h\x00 \x00d\x00i\x00c\x00h\x00 \x00f\x00o\x00r\x00t\x00" >>> decoded = [x.decode(chardet.detect(x)['encoding']) ... for x in (text1, text2, text3)] >>> v = CountVectorizer().fit(decoded).vocabulary_ >>> for term in v: print(v)
(В зависимости от версии chardet первый текст может быть определён неверно.)
Введение в Unicode и кодировки символов в целом см. в статье Джоэла Спольски «Абсолютный минимум знаний о Unicode, необходимый каждому разработчику программного обеспечения».
8.2.3.7. Применение и примеры
Представление «мешок слов» довольно простое, но на практике оно на удивление полезно.
В частности, в обучении с учителем его можно успешно сочетать с быстрыми и масштабируемыми линейными моделями для обучения классификаторов документов, например:
В обучении без учителя это представление можно использовать для группировки похожих документов с помощью алгоритмов кластеризации, например K-means:
Наконец, основные темы корпуса можно выявить, ослабив ограничение на жёсткое отнесение кластерам, например, используя неотрицательную матричную факторизацию (NMF или NNMF):
8.2.3.8. Ограничения представления «мешок слов»
Коллекция униграмм (то есть представление «мешок слов») не может учитывать фразы и многословные выражения, фактически игнорируя зависимость от порядка слов. Кроме того, модель «мешок слов» не учитывает возможные опечатки и словообразовательные формы.
На помощь приходят n-граммы! Вместо простой коллекции униграмм (n=1) можно использовать коллекцию биграмм (n=2), подсчитывая вхождения пар соседних слов.
В качестве альтернативы можно рассмотреть коллекцию символьных n-грамм — представление, устойчивое к опечаткам и словообразовательным формам.
Например, допустим, что мы работаем с корпусом из двух документов: ['words', 'wprds']. Во втором документе слово ‘words’ написано с ошибкой. При простом представлении «мешок слов» эти документы считались бы очень разными и отличались бы по обоим возможным признакам. Однако представление с символьными 2-граммами обнаружило бы совпадение по 4 из 8 признаков, что может помочь выбранному классификатору принять более точное решение:
>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(2, 2))
>>> counts = ngram_vectorizer.fit_transform(['words', 'wprds'])
>>> ngram_vectorizer.get_feature_names_out()
array([' w', 'ds', 'or', 'pr', 'rd', 's ', 'wo', 'wp'], ...)
>>> counts.toarray().astype(int)
array([[1, 1, 1, 0, 1, 1, 1, 0],
[1, 1, 0, 1, 1, 1, 0, 1]])
В приведённом выше примере используется анализатор char_wb, который создаёт n-граммы только из символов внутри границ слов (с пробелом с каждой стороны). Анализатор char, напротив, создаёт n-граммы, проходящие через границы слов:
>>> ngram_vectorizer = CountVectorizer(analyzer='char_wb', ngram_range=(5, 5)) >>> ngram_vectorizer.fit_transform(['jumpy fox']) <Compressed Sparse...dtype 'int64' with 4 stored elements and shape (1, 4)> >>> ngram_vectorizer.get_feature_names_out() array([' fox ', ' jump', 'jumpy', 'umpy '], ...) >>> ngram_vectorizer = CountVectorizer(analyzer='char', ngram_range=(5, 5)) >>> ngram_vectorizer.fit_transform(['jumpy fox']) <Compressed Sparse...dtype 'int64' with 5 stored elements and shape (1, 5)> >>> ngram_vectorizer.get_feature_names_out() array(['jumpy', 'mpy f', 'py fo', 'umpy ', 'y fox'], ...)
Вариант char_wb, учитывающий границы слов, особенно интересен для языков, в которых слова разделяются пробелами, поскольку в этом случае он создаёт значительно меньше шумных признаков, чем исходный вариант char. Для таких языков он может повысить как точность предсказания, так и скорость сходимости классификаторов, обученных с использованием таких признаков, сохраняя при этом устойчивость к опечаткам и словообразовательным формам.
Хотя извлечение n-грамм вместо отдельных слов позволяет сохранить часть информации о локальном расположении, представления «мешок слов» и «мешок n-грамм» разрушают большую часть внутренней структуры документа, а значит, и большую часть смысла, заключённого в этой структуре.
Для решения более широкой задачи понимания естественного языка необходимо учитывать локальную структуру предложений и абзацев. Многие такие модели формулируются как задачи со «структурированным выходом», которые в настоящее время выходят за рамки scikit-learn.
8.2.3.9. Векторизация большого текстового корпуса с помощью хеширования
Описанная выше схема векторизации проста, однако наличие хранящегося в памяти сопоставления строковых токенов целочисленным индексам признаков (атрибут vocabulary_) вызывает несколько проблем при работе с большими наборами данных:
- чем больше корпус, тем больше становится словарь, а значит, увеличивается и потребление памяти,
- обучение требует выделения промежуточных структур данных, размер которых пропорционален размеру исходного набора данных.
- для создания сопоставления слов необходимо полностью обработать набор данных, поэтому обучать текстовые классификаторы строго в онлайн-режиме невозможно.
- сериализация и десериализация векторизаторов с большим значением
vocabulary_могут выполняться очень медленно (обычно гораздо медленнее, чем сериализация и десериализация плоских структур данных, например массива NumPy того же размера), - нельзя легко разделить векторизацию на параллельные подзадачи, поскольку атрибут
vocabulary_пришлось бы хранить в общем состоянии с мелкозернистой точкой синхронизации: сопоставление строкового токена индексу признака зависит от порядка первого появления каждого токена, поэтому его пришлось бы сделать общим, что потенциально ухудшило бы производительность параллельных рабочих процессов настолько, что они стали бы медленнее последовательного варианта.
Этих ограничений можно избежать, объединив «хеширование» (Хеширование признаков), реализованное классом FeatureHasher, с возможностями предварительной обработки и токенизации текста класса CountVectorizer.
Такое сочетание реализовано в HashingVectorizer — классе преобразователя, API которого в основном совместим с API класса CountVectorizer. HashingVectorizer не хранит состояния, то есть для него не нужно вызывать fit:
>>> from sklearn.feature_extraction.text import HashingVectorizer >>> hv = HashingVectorizer(n_features=10) >>> hv.transform(corpus) <Compressed Sparse...dtype 'float64' with 16 stored elements and shape (4, 10)>
Как видно, в векторном представлении извлечено 16 ненулевых признаков-токенов — меньше, чем 19 ненулевых значений, извлечённых ранее классом CountVectorizer из того же тестового корпуса. Расхождение вызвано коллизиями хеш-функции из-за низкого значения параметра n_features.
В реальных задачах параметр n_features можно оставить со значением по умолчанию — 2 ** 20 (примерно один миллион возможных признаков). Если размер занимаемой памяти или последующих моделей представляет проблему, может помочь выбор меньшего значения, например 2 **
18, без возникновения слишком большого числа дополнительных коллизий в типичных задачах классификации текста.
Обратите внимание: размерность не влияет на время обучения на CPU алгоритмов, работающих с матрицами CSR (LinearSVC(dual=True), Perceptron, SGDClassifier), но влияет на время работы алгоритмов с матрицами CSC (LinearSVC(dual=False), Lasso() и др.).
Попробуем ещё раз, используя настройки по умолчанию:
>>> hv = HashingVectorizer() >>> hv.transform(corpus) <Compressed Sparse...dtype 'float64' with 19 stored elements and shape (4, 1048576)>
Коллизий больше нет, однако за это приходится платить значительно большей размерностью пространства выходных данных. Разумеется, другие термины, помимо использованных здесь 19, всё ещё могут сталкиваться друг с другом.
У класса HashingVectorizer есть также следующие ограничения:
- невозможно выполнить обратное преобразование модели (нет метода
inverse_transform) и получить исходное строковое представление признаков из-за однонаправленного характера хеш-функции, выполняющей сопоставление. - взвешивание IDF не поддерживается, поскольку оно добавило бы модели состояние. При необходимости к нему можно добавить
TfidfTransformerв конвейере.
Масштабирование данных, не помещающихся в память, с помощью HashingVectorizer
Интересное применение HashingVectorizer — возможность обрабатывать данные, не помещающиеся в оперативную память. Это позволяет обучаться на данных, которые не помещаются в основную память компьютера.
Один из способов организовать такую обработку — передавать данные оценщику потоками небольших пакетов. Каждый пакет векторизуется с помощью HashingVectorizer, что гарантирует неизменность размерности пространства входных данных оценщика. Таким образом, объём используемой в каждый момент времени памяти ограничен размером одного пакета. Хотя этот подход позволяет обрабатывать неограниченный объём данных, на практике время обучения часто ограничивается временем CPU, которое можно выделить на задачу.
Полноценный пример обработки данных, не помещающихся в память, в задаче классификации текста см. в разделе Классификация текстовых документов с обработкой данных, не помещающихся в память.
8.2.3.10. Настройка классов векторизаторов
Поведение можно настроить, передав вызываемый объект конструктору векторизатора:
>>> def my_tokenizer(s): ... return s.split() ... >>> vectorizer = CountVectorizer(tokenizer=my_tokenizer) >>> vectorizer.build_analyzer()(u"Some... punctuation!") == ( ... ['some...', 'punctuation!']) True
В частности, выделим следующие параметры:
-
preprocessor: вызываемый объект, принимающий на вход целый документ (в виде одной строки) и возвращающий, возможно, преобразованную версию документа, также в виде одной строки. Его можно использовать для удаления HTML-тегов, перевода всего документа в нижний регистр и т. д. -
tokenizer: вызываемый объект, который принимает результат работы предварительного обработчика, разбивает его на токены и возвращает их список. -
analyzer: вызываемый объект, заменяющий предварительный обработчик и токенизатор. Стандартные анализаторы вызывают предварительный обработчик и токенизатор, а пользовательские анализаторы пропускают эти этапы. Извлечение n-грамм и фильтрация стоп-слов выполняются на уровне анализатора, поэтому пользовательскому анализатору может потребоваться реализовать эти этапы самостоятельно.
(Пользователи Lucene могут узнать эти названия, но помните, что понятия scikit-learn не всегда напрямую соответствуют понятиям Lucene.)
Чтобы предварительный обработчик, токенизатор и анализаторы учитывали параметры модели, можно унаследовать класс и переопределить фабричные методы build_preprocessor, build_tokenizer и build_analyzer вместо передачи пользовательских функций.
Советы и рекомендации
- Если документы предварительно токенизированы внешним пакетом, сохраните их в файлах (или строках), разделив токены пробелами, и передайте
analyzer=str.split -
Расширенный анализ на уровне токенов, например стемминг, лемматизация, разделение составных слов, фильтрация по частям речи и т. д., не входит в кодовую базу scikit-learn, но его можно добавить, настроив токенизатор или анализатор. Вот
CountVectorizerс токенизатором и лемматизатором из NLTK:>>> from nltk import word_tokenize >>> from nltk.stem import WordNetLemmatizer >>> class LemmaTokenizer: ... def __init__(self): ... self.wnl = WordNetLemmatizer() ... def __call__(self, doc): ... return [self.wnl.lemmatize(t) for t in word_tokenize(doc)] ... >>> vect = CountVectorizer(tokenizer=LemmaTokenizer())
(Обратите внимание: пунктуация при этом не отфильтровывается.)
Следующий пример преобразует некоторые варианты британского написания в американские:
>>> import re >>> def to_british(tokens): ... for t in tokens: ... t = re.sub(r"(...)our$", r"\1or", t) ... t = re.sub(r"([bt])re$", r"\1er", t) ... t = re.sub(r"([iy])s(e$|ing|ation)", r"\1z\2", t) ... t = re.sub(r"ogue$", "og", t) ... yield t ... >>> class CustomVectorizer(CountVectorizer): ... def build_tokenizer(self): ... tokenize = super().build_tokenizer() ... return lambda doc: list(to_british(tokenize(doc))) ... >>> print(CustomVectorizer().build_analyzer()(u"color colour")) [...'color', ...'color']
Другие способы предварительной обработки — например стемминг, лемматизация или нормализация числовых токенов — показаны в следующих примерах:
Настройка векторизатора также может быть полезна при работе с азиатскими языками, в которых не используются явные разделители слов, например пробелы.
8.2.4. Извлечение признаков изображений
8.2.4.1. Извлечение фрагментов
Функция extract_patches_2d извлекает фрагменты из изображения, хранящегося в виде двумерного массива или трёхмерного массива, где информация о цвете находится вдоль третьей оси. Чтобы восстановить изображение по всем его фрагментам, используйте reconstruct_from_patches_2d. Например, создадим изображение размером 4x4 пикселя с 3 цветовыми каналами (например, в формате RGB):
>>> import numpy as np
>>> from sklearn.feature_extraction import image
>>> one_image = np.arange(4 * 4 * 3).reshape((4, 4, 3))
>>> one_image[:, :, 0] # R channel of a fake RGB picture
array([[ 0, 3, 6, 9],
[12, 15, 18, 21],
[24, 27, 30, 33],
[36, 39, 42, 45]])
>>> patches = image.extract_patches_2d(one_image, (2, 2), max_patches=2,
... random_state=0)
>>> patches.shape
(2, 2, 2, 3)
>>> patches[:, :, :, 0]
array([[[ 0, 3],
[12, 15]],
[[15, 18],
[27, 30]]])
>>> patches = image.extract_patches_2d(one_image, (2, 2))
>>> patches.shape
(9, 2, 2, 3)
>>> patches[4, :, :, 0]
array([[15, 18],
[27, 30]])
Теперь попробуем восстановить исходное изображение по фрагментам, усреднив перекрывающиеся области:
>>> reconstructed = image.reconstruct_from_patches_2d(patches, (4, 4, 3)) >>> np.testing.assert_array_equal(one_image, reconstructed)
Класс PatchExtractor работает так же, как extract_patches_2d, но поддерживает несколько изображений на входе. Он реализован как преобразователь scikit-learn, поэтому его можно использовать в конвейерах. См.:
>>> five_images = np.arange(5 * 4 * 4 * 3).reshape(5, 4, 4, 3) >>> patches = image.PatchExtractor(patch_size=(2, 2)).transform(five_images) >>> patches.shape (45, 2, 2, 3)
8.2.4.2. Граф связности изображения
Некоторые оценщики scikit-learn могут использовать информацию о связности признаков или объектов. Например, кластеризация Уорда (Иерархическая кластеризация) может объединять только соседние пиксели изображения, формируя таким образом связные фрагменты:
Для этого оценщики используют матрицу «связности», которая указывает, какие объекты связаны между собой.
Функция img_to_graph возвращает такую матрицу для двумерного или трёхмерного изображения. Аналогично, grid_to_graph строит матрицу связности изображений на основе их размеров.
Эти матрицы можно использовать для задания связности в оценщиках, использующих информацию о связности, например при кластеризации Уорда (Иерархическая кластеризация), а также для построения предварительно вычисленных ядер или матриц сходства.
© 2007–2026 The scikit-learn developers
Licensed under the 3-clause BSD License.
https://scikit-learn.org/1.9/modules/feature_extraction.html