6.2. Извлечение признаков

Модуль sklearn.feature_extraction может быть использован для извлечения признаков в формате, поддерживаемом алгоритмами машинного обучения, из наборов данных, состоящих из таких форматов, как текст и изображение.

Примечание

Извлечение признаков сильно отличается от Отбор признаков (Feature selection): первое заключается в преобразовании произвольных данных, таких как текст или изображения, в числовые признаки, пригодные для машинного обучения. Второе - это техника машинного обучения, применяемая к этим признакам.

6.2.1. Загрузка признаков из словаря

Класс DictVectorizer можно использовать для преобразования массивов признаков, представленных в виде словаря стандартного объекта Python dict, в представление NumPy/SciPy, используемое модели scikit-learn.

Хотя Python dict не особенно быстр в обработке, его преимущества заключаются в удобстве использования, разреженности (отсутствующие признаки не нужно хранить) и хранении имен признаков в дополнение к значениям.

DictVectorizer реализует так называемое one-of-K или “one-hot” кодирование для категориальных (они же номинальные, дискретные) признаков. Категориальные признаки - это пары “атрибут-значение”, где значение ограничено списком дискретных возможностей без упорядочивания (например, идентификаторы тем, типы объектов, теги, имена…).

В следующем примере “city” - это категориальный атрибут, а “temperature” - традиционная числовая характеристика:

>>> 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 также является полезным преобразованием представления для обучения классификаторов последовательности в моделях обработки естественного языка, которые обычно работают путем извлечения окон признаков вокруг определенного интересующего слова.

Например, предположим, что у нас есть первый алгоритм, который извлекает теги части речи (Part of Speech - PoS), которые мы хотим использовать в качестве дополнительных тегов для обучения классификатора последовательности (например, чанками). Таким окном признаков, извлеченных вокруг слова “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
<1x6 sparse matrix of type '<... 'numpy.float64'>'
    with 6 stored elements in Compressed Sparse ... format>
>>> 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'], ...)

Как вы можете себе представить, если извлекать такой контекст вокруг каждого отдельного слова в корпусе документов, то результирующая матрица будет очень широкой (множество однократных признаков), причем большинство из них будет иметь нулевое значение большую часть времени. Чтобы результирующая структура данных могла поместиться в памяти, класс DictVectorizer по умолчанию использует матрицу scipy.sparse, а не numpy.ndarray.

6.2.2. Хеширование признаков

Класс FeatureHasher - это высокоскоростной векторизатор с малым объемом памяти, использующий технику, известную как feature hashing, или “трюк с хэшированием”.

Вместо того чтобы строить хэш-таблицу признаков, встречающихся в процессе обучения, как это делают векторизаторы, экземпляр 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-to-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.

Обратите внимание на использование генератора понимания, который вносит ленивое извлечение признаков: лексемы обрабатываются только по требованию Объекта хеширования.

Детали реализации Click for more details

FeatureHasher использует подписанный 32-битный вариант MurmurHash3. В результате (а также из-за ограничений в scipy.sparse) максимальное количество поддерживаемых функций в настоящее время составляет \(2^{31} - 1\).

В оригинальной формулировке трюка хэширования Вайнбергера и др. использовались две отдельные хэш-функции \(h\) и \(\xi\) для определения индекса столбца и знака признака, соответственно.

Данная реализация работает в предположении, что знаковый бит MurmurHash3 не зависит от других его битов.

Поскольку для преобразования хэш-функции в индекс столбца используется простой модуляр, в качестве параметра n_features целесообразно использовать степень двойки;

В противном случае признаки не будут равномерно отображаться на столбцы.

6.2.3. Извлечение текстовых признаков

6.2.3.1. Представление “мешка слов”

Анализ текста - одна из основных областей применения алгоритмов машинного обучения. Однако исходные данные, представляющие собой последовательность символов, не могут быть напрямую переданы алгоритмам, поскольку большинство из них ожидают числовые векторы признаков с фиксированным размером, а не исходные текстовые документы переменной длины.

Чтобы решить эту проблему, scikit-learn предоставляет утилиты для наиболее распространенных способов извлечения числовых признаков из текстового содержимого, а именно:

  • токинизация строк и присвоение целочисленного идентификатора каждой возможной лексеме, например, с использованием пробелов и знаков препинания в качестве разделителей лексем.

  • подсчет вхождений лексем в каждый документ.

  • нормализация и взвешивание с уменьшением важности лексем, которые встречаются в большинстве образцов/документов.

В этой схеме признаки и образцы определяются следующим образом:

  • каждая индивидуальная частота встречаемости лексем (нормализованная или нет) рассматривается как признак.

  • вектор всех частот встречаемости лексем для данного документа рассматривается как многомерная выборка.

Таким образом, корпус документов может быть представлен матрицей с одной строкой на документ и одним столбцом на токен (например, слово), встречающийся в корпусе.

Мы называем векторизацией общий процесс превращения коллекции текстовых документов в числовые векторы признаков. Эта конкретная стратегия (токенизация, подсчет и нормализация) называется мешком слов (Bag of Words) или представлением “мешка n-грамм”. Документы описываются встречающимися словами, при этом полностью игнорируется информация об относительном положении слов в документе.

6.2.3.2. Разреженность

Поскольку в большинстве документов обычно используется очень небольшое подмножество слов, используемых в корпусе, в результирующей матрице многие значения признаков будут нулевыми (как правило, более 99 %).

Например, коллекция из 10 000 коротких текстовых документов (таких как электронные письма) будет использовать словарь размером порядка 100 000 уникальных слов в целом, в то время как каждый документ будет использовать от 100 до 1000 уникальных слов в отдельности.

Для того чтобы иметь возможность хранить такую матрицу в памяти, а также для ускорения алгебраических операций матрица/вектор, реализации обычно используют разреженное представление, такое как реализации, доступные в пакете scipy.sparse.

6.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
<4x9 sparse matrix of type '<... 'numpy.int64'>'
    with 19 stored elements in Compressed Sparse ... format>

В конфигурации по умолчанию строка токенизируется путем выделения слов, состоящих как минимум из 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

Таким образом, слова, которые не встречались в обучающем корпусе, будут полностью игнорироваться при последующих обращениях к методу преобразования:

>>> 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]...)

6.2.3.4. Использование стоп-слов

Стоп-слова - это слова типа “and”, “the”, “him”, которые считаются неинформативными для представления содержания текста, и которые могут быть удалены, чтобы избежать их интерпретации как сигнала для предсказания.

Иногда, однако, похожие слова полезны для предсказания, например при классификации стиля письма или личности.

Существует несколько известных проблем в нашем списке стоп-слов на английском языке. Он не стремится быть общим, “универсальным” решением, поскольку для некоторых задач может потребоваться более индивидуальное решение. Более подробную информацию см. в [NQY18].

Пожалуйста, будьте внимательны при выборе списка стоп-слов.

Популярные списки стоп-слов могут включать слова, которые являются высокоинформативными для некоторых задач, например компьютер.

Также следует убедиться, что список стоп-слов прошел ту же предварительную обработку и токенизацию, что и список, используемый в векторизаторе.

Слово we’ve разделяется на we и ve токенизатором CountVectorizer по умолчанию, поэтому если we’ve есть в stop_words, а ve нет, то ve будет отделено от we’ve в преобразованном тексте. Наши векторизаторы будут пытаться выявлять и предупреждать о некоторых видах несоответствий.

6.2.3.5. Tf-idf взвешивание терминов

В большом корпусе текстов некоторые слова будут встречаться очень часто (например, “the”, “a”, “is” в английском языке) и, следовательно, нести очень мало значимой информации о реальном содержании документа. Если бы мы подали данные прямого подсчета непосредственно в классификатор, эти очень частые термины затмили бы частоты более редких, но более интересных терминов.

Для того чтобы перевзвесить признаки подсчета в значения с плавающей точкой, пригодные для использования классификатором, очень часто используется преобразование tf-idf.

Tf означает частоту терминов (term-frequency), а tf-idf означает частоту терминов, умноженную на обратную частоту документов (inverse document-frequency): \(\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’s TfidfTransformer и TfidfVectorizer, несколько отличаются от стандартной нотации учебника, которая определяет idf как

\(\text{idf}(t) = \log{\frac{n}{1+\text{df}(t)}}.\)

В TfidfTransformer и TfidfVectorizer с smooth_idf=False, счетчик “1” добавляется к 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. Click for more details

Рассмотрим пример со следующими подсчетами. Первый член присутствует в 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
<6x3 sparse matrix of type '<... 'numpy.float64'>'
    with 9 stored elements in Compressed Sparse ... format>

>>> 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\)

Теперь, если мы повторим это вычисление для оставшихся 2 терминов в документе, мы получим

\(\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-idfs для документа 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)
<4x9 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

Хотя tf-idf нормализация часто очень полезна, могут быть случаи, когда бинарные маркеры встречаемости могут предложить лучшие характеристики.

Этого можно достичь, используя параметр binary в CountVectorizer. В частности, некоторые модели, такие как Бернулли Наивный Байес, явно моделируют дискретные булевые случайные величины.

Кроме того, очень короткие тексты, скорее всего, будут иметь зашумленные значения tf-idf, в то время как информация о бинарных вхождениях более стабильна.

Как обычно, лучший способ настроить параметры извлечения признаков - использовать кросс-валидированный поиск по сетке, например, объединив экстрактор признаков с классификатором:

6.2.3.6. Декодирование текстовых файлов

Текст состоит из символов, а файлы - из байтов. Эти байты представляют символы в соответствии с некоторой кодировкой (encoding). Чтобы работать с текстовыми файлами в Python, их байты должны быть декодированы (decoded) в набор символов, называемый 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).

Устранение неполадок при декодировании текста Click for more details

Если у вас возникли проблемы с декодированием текста, попробуйте сделать следующее:

  • Выясните фактическую кодировку текста. Возможно, файл содержит заголовок или README, в котором указана кодировка, или существует какая-то стандартная кодировка, которую можно предположить, исходя из того, откуда взят текст.

  • Вы можете узнать, какая кодировка у файла, используя команду UNIX file. Модуль Python chardet поставляется со сценарием 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 и кодировки символов в целом см. в книге Джоэла Спольски Absolute Minimum Every Software Developer Must Know About Unicode.

Применение и примеры ——————–

Представление мешка слов довольно упрощенное, но удивительно полезное на практике.

В частности, в обучении с учителем оно может быть успешно объединено с быстрыми и масштабируемыми линейными моделями для обучения классификаторов документов, например:

В обучении без учителя это можно использовать для группировки похожих документов вместе, применяя алгоритмы кластеризации, такие как K-means (K-средних):

Наконец, можно выявить основные темы корпуса, ослабив жесткие ограничения кластеризации, например, с помощью Факторизация неотрицательных матриц (Non-negative matrix factorization - NMF или NNMF):

6.2.3.7. Ограничения представления мешка слов

Коллекция униграмм (чем и является мешок слов) не может охватить фразы и многословные выражения, фактически игнорируя любую зависимость от порядка слов. Кроме того, модель мешка слов не учитывает возможные опечатки или производные слова.

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'])
<1x4 sparse matrix of type '<... 'numpy.int64'>'
   with 4 stored elements in Compressed Sparse ... format>
>>> 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'])
<1x5 sparse matrix of type '<... 'numpy.int64'>'
    with 5 stored elements in Compressed Sparse ... format>
>>> ngram_vectorizer.get_feature_names_out()
array(['jumpy', 'mpy f', 'py fo', 'umpy ', 'y fox'], ...)

Вариант char_wb с учетом границ слов особенно интересен для языков, использующих пробелы для разделения слов, поскольку в этом случае он генерирует значительно менее шумные признаки, чем необработанный вариант char.

Для таких языков он может повысить точность предсказания и скорость сходимости классификаторов, обученных с использованием таких признаков, сохраняя при этом устойчивость к опечаткам и деривации слов.

Хотя при извлечении n-грамм вместо отдельных слов можно сохранить некоторую информацию о локальном позиционировании, мешок слов и мешок n-грамм уничтожают большую часть внутренней структуры документа и, следовательно, большую часть смысла, заложенного в этой внутренней структуре.

Для решения более широкой задачи понимания естественного языка необходимо учитывать локальную структуру предложений и абзацев. Таким образом, многие подобные модели будут отнесены к задачам “Структурированного вывода” (“Structured output”), которые в настоящее время выходят за рамки scikit-learn.

6.2.3.8. Векторизация большого корпуса текстов с помощью трюка хэширования

Приведенная выше схема векторизации проста, но тот факт, что она содержит в памяти отображение от строковых лексем до целочисленных индексов признаков (атрибут vocabulary_), вызывает несколько проблем при работе с большими наборами данных:

  • чем больше корпус, тем больше растет словарный запас, а значит, и потребление памяти,

  • для подгонки требуется выделение промежуточных структур данных, размер которых пропорционален размеру исходного набора данных.

  • построение карты слов требует полного прохода по набору данных, поэтому невозможно подгонять текстовые классификаторы строго онлайн.

  • упаковка и распаковка векторизаторов с большим vocabulary_ может быть очень медленным (обычно намного медленнее, чем упаковка/распаковка плоских структур данных, таких как массив NumPy того же размера),

  • нелегко разделить работу по векторизации на параллельные подзадачи, поскольку атрибут vocabulary_ должен быть общим состоянием с мелкозернистым барьером синхронизации: отображение от строки лексем к индексу признака зависит от порядка первого появления каждой лексемы, следовательно, должно быть общим, что потенциально вредит производительности параллельных рабочих, делая их медленнее, чем последовательный вариант.

Преодолеть эти ограничения можно, объединив “трюк с хэшированием” (Хеширование признаков), реализованный классом FeatureHasher, и функции предварительной обработки и токенизации текста класса CountVectorizer.

Эта комбинация реализована в HashingVectorizer, классе-трансформере, который в основном API совместим с CountVectorizer. HashingVectorizer является stateless, что означает, что вам не нужно вызывать fit на нем:

>>> from sklearn.feature_extraction.text import HashingVectorizer
>>> hv = HashingVectorizer(n_features=10)
>>> hv.transform(corpus)
<4x10 sparse matrix of type '<... 'numpy.float64'>'
    with 16 stored elements in Compressed Sparse ... format>

Видно, что на выходе вектора было извлечено 16 ненулевых лексем: это меньше, чем 19 ненулевых лексем, извлеченных ранее CountVectorizer на том же тестовом примере. Расхождение происходит из-за коллизий хэш-функций из-за низкого значения параметра n_features.

В реальных условиях параметр n_features можно оставить по умолчанию равным 2 ** 20 (примерно один миллион возможных признаков). Если проблема заключается в объеме памяти или размера последующих моделей, выбор меньшего значения, например 2 ** 18, может помочь, не внося слишком много дополнительных коллизий в типичные задачи классификации текста.

Обратите внимание, что размерность не влияет на процессорное время обучения алгоритмов, работающих с матрицами CSR (LinearSVC(dual=True), Perceptron, SGDClassifier, PassiveAggressive), но влияет на алгоритмы, работающие с матрицами CSC (LinearSVC(dual=False), Lasso(), и т.д.).

Попробуем еще раз с настройками по умолчанию:

>>> hv = HashingVectorizer()
>>> hv.transform(corpus)
<4x1048576 sparse matrix of type '<... 'numpy.float64'>'
    with 19 stored elements in Compressed Sparse ... format>

Мы больше не получаем коллизий, но это происходит за счет гораздо большей размерности выходного пространства. Конечно, другие термины, кроме 19 использованных здесь, могут по-прежнему сталкиваться друг с другом.

HashingVectorizer также имеет следующие ограничения:

  • невозможно инвертировать модель (нет метода inverse_transform), а также получить доступ к исходному строковому представлению признаков из-за одностороннего характера хэш-функции, выполняющей кодирование.

  • не обеспечивает взвешивание IDF, так как это внесло бы в модель statefulness. При необходимости к нему можно добавить TfidfTransformer в конвейере.

Выполнение внеядерного масштабирования с помощью HashingVectorizer. Click for more details

Интересным моментом в использовании HashingVectorizer является возможность выполнять “внеядерное” масштабирование. Это означает, что мы можем учиться на данных, которые не помещаются в основной памяти компьютера.

Стратегия реализации внеядерного масштабирования заключается в том, чтобы передавать данные в модель мини-пакетами. Каждый мини-пакет векторизуется с помощью HashingVectorizer, чтобы гарантировать, что входное пространство модели всегда имеет одинаковую размерность. Таким образом, объем памяти, используемой в любой момент времени, ограничен размером мини-пакета. Хотя количество данных, которые можно получить с помощью такого подхода, не ограничено, с практической точки зрения время обучения часто ограничивается процессорным временем, которое вы хотите потратить на выполнение задачи.

Полноценный пример масштабирования вне ядра в задаче классификации текста смотрите в Out-of-core classification of text documents.

6.2.3.9. Настройка классов векторизатора

Можно настроить поведение, передав конструктору векторизатора вызываемый класс:

>>> 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 вместо передачи пользовательских функций.

Советы и рекомендации Click for more details

Некоторые советы и рекомендации:

  • Если документы предварительно токинизированы внешним пакетом, то храните их в файлах (или строках) с токенами, разделенными пробелами, и передавайте 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']
    

    для других стилей предварительной обработки; примеры включают стемминг, лемматизацию или нормализацию числовых лексем, последнее проиллюстрировано в:

Настройка векторизатора также может быть полезна при работе с азиатскими языками, в которых не используются явные разделители слов, такие как пробельные символы.

6.2.4. Извлечение признаков изображения

6.2.4.1. Извлечение шума (Patch)

Функция extract_patches_2d извлекает шума из изображения, хранящегося в виде двумерного массива или трехмерного с информацией о цвете по третьей оси. Для восстановления изображения из всех его шумов используйте функцию reconstruct_from_patches_2d. Например, сгенерируем картинку 4х4 пикселя с 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)

6.2.4.2. Граф связности изображения

Некоторые модели в scikit-learn могут использовать информацию о связности между признаками или образцами. Например, кластеризация Ward (Иерархическая кластеризация) может объединять в кластеры только соседние пиксели изображения, формируя таким образом связные участки:

../_images/sphx_glr_plot_coin_ward_segmentation_001.png

Для этого в оценках используется матрица “связности”, указывающая, какие образцы являются связными.

Функция img_to_graph возвращает такую матрицу из 2D или 3D изображения. Аналогично, функция grid_to_graph строит матрицу связности для изображений, учитывая форму этих изображений.

Эти матрицы можно использовать для наложения связности в оценках, использующих информацию о связности, таких как кластеризация Ward (Иерархическая кластеризация), а также для построения предварительно вычисленных ядер или матриц сходства.