15.6. Эмбеддинг подслов

В английском языке такие слова, как «helps», «helped» и «helping», являются словоформами одного и того же слова «help». Отношение между «dog» и «dogs» такое же, как между «cat» и «cats», а отношение между «boy» и «boyfriend» — такое же, как между «girl» и «girlfriend». В других языках, таких как французский и испанский, многие глаголы имеют более 40 словоформ, тогда как в финском языке существительные могут иметь до 15 падежей. В лингвистике морфология изучает образование слов и их взаимосвязи. Однако внутренняя структура слов не исследовалась ни в word2vec, ни в GloVe.

15.6.1. Модель fastText

Вспомним, как слова представлены в word2vec. В моделях skip-gram и непрерывного мешка слов (continuous bag-of-words) различные формы одного и того же слова напрямую представлены разными векторами без общих параметров. Чтобы использовать морфологическую информацию, модель fastText предложила подход эмбеддинга подслов, где подслово — это символьный \(n\)-грамм (Bojanowski et al., 2017). Вместо обучения векторных представлений на уровне слов fastText можно рассматривать как skip-gram на уровне подслов, где каждое центральное слово представлено суммой векторов его подслов.

Проиллюстрируем, как в модели fastText получают подслова для каждого центрального слова, на примере слова «where». Сначала добавим специальные символы «<» и «>» в начало и конец слова, чтобы отличать префиксы и суффиксы от других подслов. Затем извлечем из слова символьные \(n\)-граммы. Например, при \(n=3\) мы получаем все подслова длины 3: «<wh», «whe», «her», «ere», «re>», а также специальное подслово «<where>».

В модели fastText для любого слова \(w\) обозначим через \(\mathcal{G}_w\) объединение всех его подслов длины от 3 до 6 и специального подслова. Словарь представляет собой объединение подслов всех слов. Обозначив через \(\mathbf{z}_g\) вектор подслова \(g\) в словаре, вектор \(\mathbf{v}_w\) для слова \(w\), выступающего в качестве центрального слова в модели skip-gram, вычисляется как сумма векторов его подслов:

(15.6.1)\[\mathbf{v}_w = \sum_{g\in\mathcal{G}_w} \mathbf{z}_g.\]

Остальная часть fastText совпадает с моделью skip-gram. По сравнению с моделью skip-gram, словарь в fastText больше, что приводит к увеличению количества параметров модели. Кроме того, для вычисления представления слова необходимо суммировать векторы всех его подслов, что повышает вычислительную сложность. Однако благодаря общим параметрам подслов среди слов со схожей структурой редкие слова и даже слова, отсутствующие в словаре, могут получать более качественные векторные представления в fastText.

15.6.2. Кодирование пар байтов

В fastText все извлечённые подслова должны иметь заданную длину, например, от \(3\) до \(6\), поэтому размер словаря не может быть задан заранее. Чтобы допустить подслова переменной длины в словаре фиксированного размера, мы можем применить алгоритм сжатия, называемый байтовым кодированием пар (BPE), для извлечения подслов (Sennrich et al., 2015).

Алгоритм байтового кодирования пар (Byte Pair Encoding, BPE) выполняет статистический анализ обучающего набора данных для выявления часто встречающихся символов внутри слов, таких как последовательности символов произвольной длины. Начиная с односимвольных единиц, BPE итеративно объединяет наиболее часто встречающуюся пару соседних символов, образуя новые, более длинные символы. Обратите внимание, что для повышения эффективности пары, пересекающие границы слов, не рассматриваются. В результате полученные символы можно использовать в качестве подслов для сегментации слов. BPE и его варианты применяются для формирования входных представлений в популярных моделях предобучения в области обработки естественного языка, таких как GPT-2 (Radford et al., 2019) и RoBERTa (Liu et al., 2019). В дальнейшем мы подробно рассмотрим принцип работы алгоритма байтового кодирования пар.

Сначала мы инициализируем словарь символов, включив в него все строчные буквы английского алфавита, специальный символ конца слова '_' и специальный символ неизвестного значения '[UNK]'.

import collections

symbols = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
           'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
           '_', '[UNK]']

Поскольку мы не рассматриваем пары символов, пересекающие границы слов, нам требуется лишь словарь raw_token_freqs, отображающий слова в их частоты (количество вхождений) в наборе данных. Обратите внимание, что к каждому слову добавляется специальный символ '_', что позволяет легко восстановить последовательность слов (например, «a taller man») из последовательности выходных символов (например, «a_ tall er_ man»). Поскольку процесс слияния начинается со словаря, состоящего только из отдельных символов и специальных символов, между каждой парой соседних символов внутри каждого слова вставляется пробел (ключи словаря token_freqs). Иными словами, пробел служит разделителем между символами внутри слова.

raw_token_freqs = {'fast_': 4, 'faster_': 3, 'tall_': 5, 'taller_': 4}
token_freqs = {}
for token, freq in raw_token_freqs.items():
    token_freqs[' '.join(list(token))] = raw_token_freqs[token]
token_freqs

Определим следующую get_max_freq_pair функцию, которая возвращает наиболее часто встречающуюся пару соседних символов в слове, где слова берутся из ключей входного словаря token_freqs.

def get_max_freq_pair(token_freqs):
    pairs = collections.defaultdict(int)
    for token, freq in token_freqs.items():
        symbols = token.split()
        for i in range(len(symbols) - 1):
            # Key of `pairs` is a tuple of two consecutive symbols
            pairs[symbols[i], symbols[i + 1]] += freq
    return max(pairs, key=pairs.get)  # Key of `pairs` with the max value

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

def merge_symbols(max_freq_pair, token_freqs, symbols):
    symbols.append(''.join(max_freq_pair))
    new_token_freqs = dict()
    for token, freq in token_freqs.items():
        new_token = token.replace(' '.join(max_freq_pair),
                                  ''.join(max_freq_pair))
        new_token_freqs[new_token] = token_freqs[token]
    return new_token_freqs

Теперь мы итеративно применяем алгоритм кодирования пар байтов к ключам словаря token_freqs. На первой итерации наиболее часто встречающейся парой соседних символов являются 't' и 'a', поэтому алгоритм объединяет их, создавая новый символ 'ta'. На второй итерации алгоритм продолжает объединять 'ta' и 'l', в результате чего образуется ещё один новый символ 'tal'.

num_merges = 10
for i in range(num_merges):
    max_freq_pair = get_max_freq_pair(token_freqs)
    token_freqs = merge_symbols(max_freq_pair, token_freqs, symbols)
    print(f'merge #{i + 1}:', max_freq_pair)

После 10 итераций алгоритма байтового кодирования пар мы видим, что список symbols теперь содержит на 10 символов больше, которые были итеративно объединены из других символов.

print(symbols)

Для того же набора данных, указанного в ключах словаря raw_token_freqs, каждое слово в наборе данных теперь разбивается на подслова «fast_», «fast», «er_», «tall_» и «tall» в результате работы алгоритма байтового кодирования пар. Например, слова «faster_» и «taller_» разбиваются на «fast er_» и «tall er_» соответственно.

print(list(token_freqs.keys()))

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

def segment_BPE(tokens, symbols):
    outputs = []
    for token in tokens:
        start, end = 0, len(token)
        cur_output = []
        # Segment token with the longest possible subwords from symbols
        while start < len(token) and start < end:
            if token[start: end] in symbols:
                cur_output.append(token[start: end])
                start = end
                end = len(token)
            else:
                end -= 1
        if start < len(token):
            cur_output.append('[UNK]')
        outputs.append(' '.join(cur_output))
    return outputs

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

tokens = ['tallest_', 'fatter_']
print(segment_BPE(tokens, symbols))

15.6.3. Итоги

  • Модель fastText предлагает подход к эмбеддингу подслов. Основываясь на модели skip-gram из word2vec, она представляет центральное слово как сумму векторов его подслов.

  • Кодирование пар байтов (byte pair encoding) выполняет статистический анализ обучающего набора данных для выявления часто встречающихся символов внутри слов. Будучи жадным алгоритмом, кодирование пар байтов итеративно объединяет наиболее часто встречающуюся пару соседних символов.

  • Эмбеддинг подслов может повысить качество представлений редких слов и слов, отсутствующих в словаре.

15.6.4. Упражнения

  1. В качестве примера, в английском языке существует около \(3\times 10^8\) возможных \(6\)-грамм. В чем заключается проблема при слишком большом количестве подслов? Как решить эту проблему? Подсказка: обратитесь к концу раздела 3.2 статьи fastText (Bojanowski et al., 2017).

  2. Как спроектировать модель эмбеддинга подслов на основе модели непрерывного мешка слов?

  3. Чтобы получить словарь размера \(m\), сколько операций слияния необходимо выполнить, если начальный размер словаря символов равен \(n\)?

  4. Как обобщить идею байтового кодирования пар для извлечения фраз?

Обсуждения