9.2. Преобразование исходного текста в данные последовательности¶
На протяжении всей книги мы будем часто работать с текстовыми данными, представленными в виде последовательностей слов, символов или подслов. Для начала нам понадобятся базовые инструменты для преобразования исходного текста в последовательности нужного вида. Типичные конвейеры предобработки выполняют следующие шаги:
Загружаем текст в память в виде строк.
Разбейте строки на токены (например, слова или символы).
Создайте словарь, чтобы сопоставить каждый элемент словаря с числовым индексом.
Преобразуйте текст в последовательности числовых индексов.
import collections
import random
import re
import torch
from d2l import torch as d2l
9.2.1. Чтение набора данных¶
Здесь мы будем работать с книгой Герберта Уэллса «Машина времени»
http://www.gutenberg.org/ebooks/35, содержащей чуть более 30 000 слов.
Хотя в реальных приложениях обычно используются значительно более
крупные наборы данных, этого достаточно для демонстрации конвейера
предварительной обработки. Приведённый ниже метод _download
считывает исходный текст в строку.
class TimeMachine(d2l.DataModule): #@save
"""The Time Machine dataset."""
def _download(self):
fname = d2l.download(d2l.DATA_URL + 'timemachine.txt', self.root,
'090b5e7e70c295757f55df93cb0a180b9691891a')
with open(fname) as f:
return f.read()
data = TimeMachine()
raw_text = data._download()
raw_text[:60]
Для простоты мы игнорируем знаки препинания и регистр символов при предварительной обработке исходного текста.
@d2l.add_to_class(TimeMachine) #@save
def _preprocess(self, text):
return re.sub('[^A-Za-z]+', ' ', text).lower()
text = data._preprocess(raw_text)
text[:60]
9.2.2. Токенизация¶
Токены — это атомарные (неделимые) единицы текста. Каждый шаг по времени соответствует одному токену, однако точное определение того, что именно считается токеном, является дизайнерским решением. Например, мы можем представить предложение «Baby needs a new pair of shoes» в виде последовательности из 7 слов, где множество всех слов образует большой словарь (обычно включающий десятки или сотни тысяч слов). Либо мы можем представить то же самое предложение в виде гораздо более длинной последовательности из 30 символов, используя гораздо меньший словарь (всего существует 256 различных символов ASCII). Ниже мы разбиваем предварительно обработанный текст на последовательность символов.
@d2l.add_to_class(TimeMachine) #@save
def _tokenize(self, text):
return list(text)
tokens = data._tokenize(text)
','.join(tokens[:30])
9.2.3. Словарь¶
Эти токены по-прежнему являются строками. Однако входные данные наших моделей в конечном итоге должны состоять из числовых значений. Далее мы введем класс для построения словарей, то есть объектов, которые сопоставляют каждое уникальное значение токена с уникальным индексом. Сначала мы определяем множество уникальных токенов в нашем обучающем корпусе. Затем мы присваиваем числовой индекс каждому уникальному токену. Для удобства редкие элементы словаря часто исключаются. Всякий раз, когда мы встречаем токен во время обучения или тестирования, который ранее не встречался или был исключен из словаря, мы представляем его с помощью специального токена “<unk>”, обозначающего неизвестное значение.
class Vocab: #@save
"""Vocabulary for text."""
def __init__(self, tokens=[], min_freq=0, reserved_tokens=[]):
# Flatten a 2D list if needed
if tokens and isinstance(tokens[0], list):
tokens = [token for line in tokens for token in line]
# Count token frequencies
counter = collections.Counter(tokens)
self.token_freqs = sorted(counter.items(), key=lambda x: x[1],
reverse=True)
# The list of unique tokens
self.idx_to_token = list(sorted(set(['<unk>'] + reserved_tokens + [
token for token, freq in self.token_freqs if freq >= min_freq])))
self.token_to_idx = {token: idx
for idx, token in enumerate(self.idx_to_token)}
def __len__(self):
return len(self.idx_to_token)
def __getitem__(self, tokens):
if not isinstance(tokens, (list, tuple)):
return self.token_to_idx.get(tokens, self.unk)
return [self.__getitem__(token) for token in tokens]
def to_tokens(self, indices):
if hasattr(indices, '__len__') and len(indices) > 1:
return [self.idx_to_token[int(index)] for index in indices]
return self.idx_to_token[indices]
@property
def unk(self): # Index for the unknown token
return self.token_to_idx['<unk>']
Теперь мы создаём словарь для нашего набора данных, преобразуя последовательность строк в список числовых индексов. Обратите внимание, что мы не потеряли никакой информации и можем легко преобразовать наш набор данных обратно в исходное (строковое) представление.
vocab = Vocab(tokens)
indices = vocab[tokens[:10]]
print('indices:', indices)
print('words:', vocab.to_tokens(indices))
9.2.4. Объединяя всё вместе¶
Используя приведённые выше классы и методы, мы объединяем всё в
следующий метод ``build`` класса ``TimeMachine`` , который возвращает
corpus — список индексов токенов, и vocab — словарь корпуса The
Time Machine. Внесённые нами изменения: (i) мы токенизируем текст на
уровне символов, а не слов, чтобы упростить обучение в последующих
разделах; (ii) corpus — это единый список, а не список списков
токенов, поскольку каждая строка текста в наборе данных The Time
Machine не обязательно является предложением или абзацем.
@d2l.add_to_class(TimeMachine) #@save
def build(self, raw_text, vocab=None):
tokens = self._tokenize(self._preprocess(raw_text))
if vocab is None: vocab = Vocab(tokens)
corpus = [vocab[token] for token in tokens]
return corpus, vocab
corpus, vocab = data.build(raw_text)
len(corpus), len(vocab)
9.2.5. Исследовательская статистика языка¶
Используя реальный корпус и класс Vocab, определённый над словами,
мы можем изучить базовую статистику использования слов в нашем корпусе.
Ниже мы построим словарь из слов, встречающихся в «Машине времени», и
выведем десять наиболее часто встречающихся из них.
words = text.split()
vocab = Vocab(words)
vocab.token_freqs[:10]
Обратите внимание, что десять самых частотных слов не являются особенно описательными. Можно даже предположить, что мы получили бы очень похожий список, если бы выбрали любую книгу наугад. Артикли вроде «the» и «a», местоимения вроде «i» и «my», а также предлоги вроде «of», «to» и «in» встречаются часто, поскольку выполняют общие синтаксические функции. Такие слова, которые распространены, но не обладают высокой описательной способностью, часто называют стоп-словами, и в предыдущих поколениях текстовых классификаторов, основанных на так называемом представлении «мешок слов», их чаще всего фильтровали. Однако они несут смысл, и при работе с современными нейронными моделями на основе RNN и трансформеров их не обязательно удалять. Если посмотреть дальше в списке, можно заметить, что частотность слов быстро убывает. \(10^{\textrm{th}}\)-е по частоте слово встречается менее чем в \(1/5\) раз чаще самого популярного. Частотность слов, как правило, подчиняется степенному распределению (в частности, распределению Ципфа) по мере снижения их ранга. Чтобы лучше понять это, мы построим график частотности слов.
freqs = [freq for token, freq in vocab.token_freqs]
d2l.plot(freqs, xlabel='token: x', ylabel='frequency: n(x)',
xscale='log', yscale='log')
После того как первые несколько слов были выделены как исключения, все остальные слова примерно укладываются в прямую линию на логарифмическом графике. Это явление описывается законом Ципфа, согласно которому частота \(n_i\) \(i^\textrm{th}\) наиболее часто встречающегося слова равна:
что эквивалентно
где \(\alpha\) — показатель степени, характеризующий распределение, а \(c\) — константа. Это уже должно насторожить нас, если мы хотим моделировать слова, опираясь на статистические подсчёты. Ведь мы будем существенно переоценивать частоту «хвоста», то есть редко встречающихся слов. Но как обстоят дела с другими словосочетаниями, например, с двумя последовательными словами (биграммами), тремя последовательными словами (триграммами) и более длинными комбинациями? Давайте посмотрим, ведёт ли себя частота биграмм так же, как частота отдельных слов (униграмм).
bigram_tokens = ['--'.join(pair) for pair in zip(words[:-1], words[1:])]
bigram_vocab = Vocab(bigram_tokens)
bigram_vocab.token_freqs[:10]
Обратим внимание на одну деталь. Из десяти самых частотных пар слов девять состоят исключительно из стоп-слов, и лишь одна пара имеет отношение к содержанию книги — «the time». Кроме того, посмотрим, ведёт ли себя частотность триграмм аналогичным образом.
trigram_tokens = ['--'.join(triple) for triple in zip(
words[:-2], words[1:-1], words[2:])]
trigram_vocab = Vocab(trigram_tokens)
trigram_vocab.token_freqs[:10]
Теперь давайте визуализируем частоту токенов в этих трёх моделях: униграммах, биграммах и триграммах.
bigram_freqs = [freq for token, freq in bigram_vocab.token_freqs]
trigram_freqs = [freq for token, freq in trigram_vocab.token_freqs]
d2l.plot([freqs, bigram_freqs, trigram_freqs], xlabel='token: x',
ylabel='frequency: n(x)', xscale='log', yscale='log',
legend=['unigram', 'bigram', 'trigram'])
Эта диаграмма весьма впечатляет. Во-первых, помимо однословных последовательностей, и многословные последовательности, похоже, подчиняются закону Ципфа, хотя и с меньшим показателем степени \(\alpha\) в (9.2.1), в зависимости от длины последовательности. Во-вторых, количество различных \(n\)-грамм не так велико. Это вселяет надежду на то, что в языке существует довольно много структуры. В-третьих, многие \(n\)-граммы встречаются крайне редко. Это делает некоторые методы неподходящими для языкового моделирования и обосновывает использование моделей глубокого обучения. Мы обсудим это в следующем разделе.
9.2.6. Итоги¶
Текст — одна из самых распространённых форм последовательных данных, с которыми мы сталкиваемся в глубоком обучении. В качестве токенов обычно используют символы, слова и словесные фрагменты (word pieces). Для предварительной обработки текста мы обычно (i) разбиваем текст на токены; (ii) создаём словарь для сопоставления строк токенов с числовыми индексами; и (iii) преобразуем текстовые данные в индексы токенов, чтобы модели могли с ними работать. На практике частота слов, как правило, подчиняется закону Ципфа. Это верно не только для отдельных слов (униграмм), но и для \(n\)-грамм.
9.2.7. Упражнения¶
В эксперименте этого раздела разбейте текст на слова и измените значение аргумента
min_freqобъектаVocab. Охарактеризуйте качественно, как изменения вmin_freqвлияют на размер получившегося словаря.Оцените показатель степени распределения Ципфа для униграмм, биграмм и триграмм в этом корпусе.
Найдите другие источники данных (скачайте стандартный набор данных для машинного обучения, выберите другую книгу из общественного достояния, выполните парсинг веб-сайта и т. д.). Для каждого источника выполните токенизацию данных на уровне слов и на уровне символов. Как соотносятся размеры словарей с корпусом The Time Machine при эквивалентных значениях
min_freq? Оцените показатель степени распределения Ципфа, соответствующий распределениям униграмм и биграмм для этих корпусов. Как они соотносятся со значениями, которые вы наблюдали для корпуса The Time Machine?