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

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

Примечание

Извлечение признаков сильно отличается от Выбор признаков: первое состоит в преобразовании произвольных данных, таких как текст или изображения, в числовые признаки, пригодные для машинного обучения. Второе — это техника машинного обучения, применяемая к этим признакам.

7.2.1. Загрузка признаков из словарей#

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

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

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

  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.

'headers' следует точному стандарту; другие фильтры не всегда корректны. Хеширование признаков#

Класс 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\).

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

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

Ссылки

Ссылки

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

7.2.3.1. Представление "Мешок слов"#

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

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

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

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

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

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

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

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

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

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

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

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

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

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

7.2.3.3. Общее использование Vectorizer#

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

  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

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

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

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

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

В нашем предоставленном списке стоп-слов 'english' есть несколько известных проблем. Он не претендует на универсальное решение, так как некоторые задачи могут требовать более индивидуального подхода. См. [NQY18] для получения дополнительной информации.

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

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

Ссылки

7.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, к количеству '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#

Рассмотрим пример со следующими частотами. Первый термин присутствует в 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

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

Теперь, если мы повторим это вычисление для оставшихся 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-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)

  with 19 stored elements and shape (4, 9)>

Хотя нормализация tf-idf часто очень полезна, могут быть случаи, когда бинарные маркеры встречаемости могут предложить лучшие признаки. Это можно достичь, используя binary оценок. CountVectorizer. В частности, некоторые оценщики, такие как Наивный байесовский классификатор Бернулли явно моделируют дискретные булевы случайные переменные. Также очень короткие тексты, вероятно, имеют зашумлённые значения tf-idf, в то время как информация о бинарном вхождении более стабильна.

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

Примеры

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

Устранение неполадок при декодировании текста#

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

  • Определите фактическую кодировку текста. Файл может поставляться с заголовком или 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 и кодировки символов в целом, см. статью Джоэла Спольски Абсолютный минимум, который каждый разработчик программного обеспечения должен знать о Unicode.

7.2.3.7. Приложения и примеры#

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

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

В неконтролируемая настройка его можно использовать для группировки похожих документов с помощью алгоритмов кластеризации, таких как K-means:

Наконец, можно обнаружить основные темы корпуса, ослабив жёсткое ограничение кластеризации, например, используя Неотрицательная матричная факторизация (NMF или NNMF):

7.2.3.8. Ограничения представления Bag of Words#

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

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

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

  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.

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

Приведённая схема векторизации проста, но тот факт, что она содержит отображение в памяти из строковых токенов в целочисленные индексы признаков (the 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)

  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)

  with 19 stored elements and shape (4, 1048576)>

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

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

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

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

Выполнение масштабирования вне ядра с HashingVectorizer#

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

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

3 Alexis Metaireau Классификация текстовых документов вне памяти.

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

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

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

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

7.2.4.1. Извлечение патчей#

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

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

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

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

../_images/sphx_glr_plot_coin_ward_segmentation_001.png

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

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

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