Содержание курса 02 / 37

02 / 37 · Основы работы с текстом

Текст как данные

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

На этой странице 10

Каждая модель этого курса потребляет числа. Текст приходит строкой, и первая задача — решить, какие куски этой строки станут единицами, которые видит модель. Решение выглядит канцелярским, но оно фиксирует словарь, разреженность каждого следующего представления и некоторые ошибки, которые не устранит никакое обучение. Эта заметка проходит путь от сырых байтов до списка очищенных единиц и заканчивается вопросом, с которого начинается следующая заметка: что делать, когда единицы — целые слова, а словарь разрастается.

Обозначения. Документ dd — одна строка. Токен — одна вырезанная из неё единица. Словарь VV — множество различных токенов по всему корпусу, ∣V∣|V| — его размер. Через N(t,d)N(t, d) обозначаем число вхождений токена tt в dd.

Вся заметка на одном фрагменте — от байтов файла до единиц, которые посчитала бы модель мешка слов (bag of words):

Рисунок

От байтов к очищенным единицам

Шаги предобработки текстаСемь шагов сверху вниз. Байты 43 61 66 65 CC 81 27 73 20 6D 65 6E 75 73; декодирование даёт 13 кодовых точек, среди них отдельное комбинируемое ударение; NFC собирает e и ударение в é; сегментация даёт Café, s, menus и отбрасывает апостроф и пробел; нижний регистр даёт café; удаление стоп-слов убирает s; лемматизация даёт café, menu. Первые три шага помечены как одна каноническая запись того же текста, последние четыре — как выбор под задачу.тот же текст,одна каноническаязапись: всегдаединицы и очистка:теряют информацию,выбираютсяпод задачубайтыто, что хранит файл43616665CC812773206D656E7573декодирование UTF-814 байт → 13 кодовых точекCafeU+0301's␣menusнормализация NFCe + U+0301 → éCafé's␣menusсегментация \w+отбрасывает ' и пробелCafé's␣menusнижний регистрCafé = cafécafésmenusудаление стоп-словубирает s (и not)cafésmenusлемматизацияmenus → menucafémenuШаги предобработки текстаСемь шагов сверху вниз. Байты 43 61 66 65 CC 81 27 73 20 6D 65 6E 75 73; декодирование даёт 13 кодовых точек, среди них отдельное комбинируемое ударение; NFC собирает e и ударение в é; сегментация даёт Café, s, menus и отбрасывает апостроф и пробел; нижний регистр даёт café; удаление стоп-слов убирает s; лемматизация даёт café, menu. Первые три шага помечены как одна каноническая запись того же текста, последние четыре — как выбор под задачу.тот же текст в одной записи: всегдаединицы и очистка: под задачубайтыто, что хранит файл43616665CC812773206D656E7573декодирование UTF-814 байт → 13 кодовых точекCafeU+0301's␣menusнормализация NFCe + U+0301 → éCafé's␣menusсегментация \w+отбрасывает ' и пробелCafé's␣menusнижний регистрCafé = cafécafésmenusудаление стоп-словубирает s (и not)cafésmenusлемматизацияmenus → menucafémenu
Фрагмент «Café's menus», в котором é записано как e плюс комбинируемое ударение, проходит по шагам этой заметки. Первые три шага меняют только запись текста: 14 байт декодируются в 13 кодовых точек, а нормализация NFC собирает e + U+0301 в одну кодовую точку é, и слово становится равным любой другой своей записи. Последние четыре шага определяют единицы и выбрасывают информацию: сегментация по \w+ отбрасывает апостроф и пробел, приведение к нижнему регистру сливает Café и café, удаление стоп-слов убирает s, лемматизация сводит menus к menu. Зачёркнутые клетки отброшены, закрашенные изменены этим шагом. Какие из последних четырёх шагов применять, зависит от задачи.

Что такое строка#

Текстовый файл — последовательность байтов. Кодировка (encoding) отображает байты в кодовые точки (code points) — целые числа, которые Unicode присваивает символам. UTF-8 имеет переменную ширину: латинские буквы ASCII занимают один байт, большинство кириллических — два, большинство иероглифов CJK — три, эмодзи — четыре. Поэтому «длина в байтах», «длина в кодовых точках» и «длина в том, что читатель воспринимает как символы» — три разных числа.

Третье число отличается от второго, потому что Unicode допускает несколько способов записать один и тот же видимый знак. Буква é — это либо одна кодовая точка U+00E9, либо пара U+0065 (e) и U+0301 (комбинируемый знак акута, combining acute accent). Поэтому две строки, которые выглядят одинаково, могут оказаться неравными при сравнении, считаться разными словами и получить разные векторы. Формы нормализации Unicode (normalization forms) фиксируют канонический выбор:

ФормаЧто делаетТипичное применение
NFCСобирает символ в готовую кодовую точку (precomposed), если она естьХранение, сравнение
NFDРаскладывает на базовый символ и комбинируемые знакиУдаление диакритики
NFKCNFC плюс совместимое свёртывание (compatibility folding): fi → fi, полноширинное A → A, верхний индекс ² → 2Поиск, обучение токенизатора
NFKDNFD плюс совместимое свёртываниеПоиск без учёта диакритики

K-формы нормализации теряют информацию: ² и 2 становятся одним токеном, что правильно для поиска и неправильно для формулы. Это первый пример правила, которое проходит через всю заметку: шаг очистки — решение о модели, а не бесплатное упрощение. Современный токенизатор подслов (subword) нормализует текст прежде всего остального, и два семейства из следующей заметки делают это по-разному: SentencePiece по умолчанию применяет NFKC, а токенизатор WordPiece у BERT раскладывает текст по NFD, удаляет комбинируемые знаки (strip_accents) и приводит его к нижнему регистру, так что Café и cafe становятся одним токеном.

Сегментация: как разрезать строку на единицы#

Простейшие единицы — слова. В ходу два определения «слова», и они расходятся в обращении с пунктуацией:

import re
from nltk.tokenize import wordpunct_tokenize

text = "What TV shows or books help you read people's body language?"
re.findall(r"\w+", text)
# ['What', 'TV', 'shows', 'or', 'books', 'help', 'you', 'read',
#  'people', 's', 'body', 'language']
wordpunct_tokenize(text)
# ['What', 'TV', 'shows', 'or', 'books', 'help', 'you', 'read',
#  'people', "'", 's', 'body', 'language', '?']

Регулярное выражение \w+ оставляет непрерывные последовательности букв, цифр и подчёркиваний и отбрасывает всё остальное, поэтому вопросительный знак исчезает, а притяжательная форма распадается на people и s. Токенизатор, учитывающий пунктуацию, оставляет ' и ? отдельными токенами. Ни один вариант не ошибочен. Для классификатора тем вопросительный знак не несёт сигнала; для языковой модели, которая должна порождать вопросы, это самый важный токен предложения.

Кроме того, сегментация на слова предполагает, что слова разделены пробелами. Китайский, японский и тайский так не пишутся, немецкий и финский склеивают слова в длинные составные, а #deeplearning — один токен в твите и два слова для читателя. Желание хорошо обрабатывать такие случаи — один из мотивов методов подслов из следующей заметки.

Очистка: информация в обмен на меньший словарь#

После сегментации у нас есть список токенов для каждого документа. Несколько преобразований отображают много форм токена в одну. Каждое из них уменьшает ∣V∣|V|, объединяет статистику форм, которые, вероятно, значат одно и то же, и выбрасывает то, что их различало.

Приведение к нижнему регистру. Cat, cat и CAT становятся одним токеном. Теряется сигнал, который отличает Apple от apple и US от us.

Удаление стоп-слов. Служебные слова (the, is, of, not, …) выбрасываются по фиксированному списку. В модели мешка слов они доминируют в счётчиках, не неся информации о теме. Стандартный английский список в NLTK содержит 198 слов, включая not, no и nor. Их удаление превращает «not good» в «good» — известный способ сломать классификатор тональности.

Стемминг. Процедура на правилах отрезает аффиксы: studies → studi, running → run, кошками → кошк. Результат не обязан быть словом. Стемминг быстр и сливает формы агрессивно, в том числе те, которые должны остаться разными (у universe и university общая основа Портера univers).

Лемматизация. Каждая форма отображается в свою словарную форму — лемму: studies → study, went → go, better → good, дети → ребёнок. Для этого нужны решение о части речи и морфологический словарь, поэтому лемматизация медленнее и зависит от языка, зато результат — настоящее слово, а неправильные формы обрабатываются верно.

Сильнее всего два подхода различаются на неправильной морфологии и по стоимости. Лемматизатору нужна часть речи: WordNet-лемматизатор из NLTK считает каждое слово существительным, если не указано иное, поэтому went и better возвращаются без изменений, пока его не вызовут с pos="v" и pos="a". В таблице дана лемма для правильной части речи:

ФормаОснова ПортераЛемма WordNet
studiesstudistudy
wentwentgo
betterbettergood
peoplepeoplpeople

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

Разобранный пример#

Возьмём предложение выше и прогоним его через обычный конвейер: нижний регистр, разбиение по \w+, удаление стоп-слов, затем лемматизация или стемминг.

ШагРезультат
Нижний регистр + разбиениеwhat tv shows or books help you read people s body language
Удаление стоп-словtv shows books help read people body language
Лемматизацияtv show book help read people body language
Стеммингtv show book help read peopl bodi languag

Двенадцать токенов превратились в восемь; shows и books свелись к единственному числу, так что документ со словом «book» и документ со словом «books» теперь делят один признак.

На целом корпусе эти решения двигают размер словаря. 120 000 обучающих текстов AG News, разбитых правилом scikit-learn по умолчанию (последовательности из двух и более словесных символов), содержат 88 057 различных типов слов (word types); одно лишь приведение к нижнему регистру сокращает их число до 65 006. Если отбросить типы, которые встречаются меньше чем в четырёх документах (min_df = 4), остаётся 29 350: 55% словаря в нижнем регистре, 35 656 типов, встречаются не более чем в трёх документах — слишком редко, чтобы модель чему-то на них научилась. Ни в одном из трёх подсчётов стоп-слова не удаляются и лемматизация не применяется.

Почему предобработка зависит от задачи#

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

  • Тональность. Слова отрицания — стоп-слова. «not done» и «done» схлопываются в один и тот же мешок. Заметка о TF-IDF показывает стандартное частичное решение — подсчёт биграмм.
  • Распознавание именованных сущностей. Заглавная буква — самый сильный отдельный признак имени. Заметка о NER использует модель с учётом регистра (cased) именно поэтому.
  • Языковое моделирование и генерация. Модель должна воспроизводить все различия, которые нужны её выходу, — пунктуацию, регистр, словоизменение, — поэтому удалять их нельзя. Байтовый токенизатор подслов сохраняет каждый символ и оставляет модели самой выучить, какие вариации важны. Не всякий токенизатор подслов обратим: uncased WordPiece у BERT приводит к нижнему регистру и удаляет диакритику, а токенизатор, в базовом словаре которого нет какого-то символа, отображает его в неизвестный токен.
  • Поиск. Стемминг повышает полноту (run находит running) ценой точности; выгоден ли такой обмен, зависит от того, что ищут пользователи.

Практическое правило: чистить то, что доказуемо вредит (HTML-теги, повторяющиеся пробелы, артефакты кодировки, точные дубликаты), и сохранять всё, что нужно читателю, чтобы понять текст, — если только эксперимент не показывает, что без этого модель работает лучше.

Ограничения#

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

Что нужно запомнить#

  • Текстовый файл хранит байты; декодирование по кодировке превращает их в строку кодовых точек (str в Python); то, что читатель воспринимает как один символ, — графемный кластер — может состоять из нескольких кодовых точек. Форма нормализации выбирает одну каноническую запись для визуально одинаковых строк. Сравнивать и считать можно только нормализованный текст.
  • Сегментация определяет единицы. Регулярное выражение и токенизатор с учётом пунктуации расходятся в обращении с пунктуацией и клитиками; правильный выбор зависит от того, несёт ли пунктуация сигнал для задачи.
  • Нижний регистр, удаление стоп-слов, стемминг и лемматизация уменьшают словарь, сливая формы, и каждое слияние выбрасывает различие, которое нужно какой-то задаче.
  • Стемминг основан на правилах и быстр, лемматизация основана на словаре и верна на неправильных формах; для морфологически богатых языков лемматизация предпочтительнее, если её стоимость приемлема.
  • Предобработка — часть модели. Её нужно выбирать под задачу и измерять её эффект.

Ноутбук#

Сопровождающий ноутбук — week01_word_embeddings.ipynb. Номера ячеек считают все ячейки, включая текстовые, начиная с 1. Приведённые числа взяты из прогона, сохранённого в курсе; у ячеек, изменённых с тех пор под текущие версии библиотек, сохранённого выхода нет.

  • Ячейки 4–6 загружают файл вопросов Quora, по одному на строку, и печатают строку 50 — пример, использованный выше.
  • Ячейки 9–13 определяют два токенизатора слов (\w+ и wordpunct_tokenize) и показывают, что они расходятся на апострофе и вопросительном знаке.
  • Ячейки 14–17 приводят каждую строку к нижнему регистру и токенизируют её; ячейки 18–22 удаляют английские стоп-слова NLTK и печатают строку 50 до и после этого.
  • Ячейки 23–31 сравнивают WordNet-лемматизатор и стеммер Портера на одних и тех же токенах. Ячейка 26 сводит неправильные формы в таблицу; ячейка 31 печатает оба результата для строки 50 — две последние строки разобранного примера (ноутбук разбивает текст функцией wordpunct_tokenize, поэтому ' и ? там сохраняются). Ячейка 28 вызывает лемматизатор без части речи.
  • Ячейки 33–38 переходят к набору для классификации AG News (120 000 обучающих и 7600 тестовых текстов, четыре класса) и строят распределение классов, которое оказывается сбалансированным; к нему возвращается заметка об оценке.
  • Ячейки 44–46 обучают на текстах AG News векторизатор TF-IDF с min_df=4, без удаления стоп-слов и лемматизации; ячейка 46 печатает размер словаря 29 350. Два других размера словаря из разобранного примера, 88 057 и 65 006, в ноутбуке не считаются: они получены тем же векторизатором без отсечения по частоте, первый — с lowercase=False.

Дальше#

Теперь у нас есть список единиц для каждого документа, и эти единицы — слова. Остаются две проблемы. У слова, которое не встречалось при обучении, нет записи в словаре, поэтому модель может отобразить его лишь в единственный символ «неизвестно». А словарь уровня слов для реального корпуса насчитывает сотни тысяч записей, большинство из которых встречались считанные разы. Следующая заметка заменяет слова выученными единицами-подсловами, которые ограничивают размер словаря и дают новому слову представление из известных частей; если в базовом словаре есть все 256 байтов, представление есть у любой строки.

Литература#