15.3. Раздел учебника: Набор данных для предобучения словесных вложений

Теперь, когда мы разобрали технические детали моделей word2vec и методов приближенного обучения, перейдем к их реализации. В частности, в качестве примера рассмотрим модель skip-gram из Section 15.1 и метод отрицательной выборки из Section 15.2. В этом разделе мы начнем с набора данных для предобучения модели эмбеддингов слов: исходный формат данных будет преобразован в мини-пакеты, которые можно перебирать в процессе обучения.

import collections
import math
import os
import random
import torch
from d2l import torch as d2l

15.3.1. Чтение набора данных

Используемый нами набор данных — Penn Tree Bank (PTB). Этот корпус сформирован из статей The Wall Street Journal и разделён на обучающую, валидационную и тестовую выборки. В исходном формате каждая строка текстового файла представляет собой предложение, слова в котором разделены пробелами. Здесь мы рассматриваем каждое слово как токен.

#@save
d2l.DATA_HUB['ptb'] = (d2l.DATA_URL + 'ptb.zip',
                       '319d85e578af0cdc590547f26231e4e31cdf1e42')

#@save
def read_ptb():
    """Load the PTB dataset into a list of text lines."""
    data_dir = d2l.download_extract('ptb')
    # Read the training set
    with open(os.path.join(data_dir, 'ptb.train.txt')) as f:
        raw_text = f.read()
    return [line.split() for line in raw_text.split('\n')]

sentences = read_ptb()
f'# sentences: {len(sentences)}'

Прочитав обучающую выборку, мы строим словарь для корпуса, в котором любое слово, встречающееся менее 10 раз, заменяется на токен <unk>. Обратите внимание, что исходный набор данных также содержит токены <unk>, обозначающие редкие (неизвестные) слова.

vocab = d2l.Vocab(sentences, min_freq=10)
f'vocab size: {len(vocab)}'

15.3.2. Субсэмплирование

Текстовые данные обычно содержат высокочастотные слова, такие как «the», «a» и «in»: они могут встречаться даже миллиарды раз в очень больших корпусах. Однако эти слова часто встречаются вместе со многими различными словами в контекстных окнах, предоставляя мало полезной информации. Например, рассмотрим слово «chip» в контекстном окне: интуитивно его совместное появление с низкочастотным словом «intel» более полезно при обучении, чем совместное появление с высокочастотным словом «a». Кроме того, обучение на огромном количестве (высокочастотных) слов занимает много времени. Таким образом, при обучении моделей эмбеддингов слов высокочастотные слова можно субдискретизировать (Mikolov et al., 2013). Конкретнее, каждое индексированное слово \(w_i\) в наборе данных будет отброшено с вероятностью

(15.3.1)\[P(w_i) = \max\left(1 - \sqrt{\frac{t}{f(w_i)}}, 0\right),\]

где \(f(w_i)\) — это отношение числа слов \(w_i\) к общему числу слов в наборе данных, а константа \(t\) — гиперпараметр (\(10^{-4}\) в эксперименте). Мы видим, что слово \(w_i\) (высокой частотности) может быть отброшено только тогда, когда его относительная частота \(f(w_i) > t\), и чем выше относительная частота слова, тем больше вероятность его отбрасывания.

#@save
def subsample(sentences, vocab):
    """Subsample high-frequency words."""
    # Exclude unknown tokens ('<unk>')
    sentences = [[token for token in line if vocab[token] != vocab.unk]
                 for line in sentences]
    counter = collections.Counter([
        token for line in sentences for token in line])
    num_tokens = sum(counter.values())

    # Return True if `token` is kept during subsampling
    def keep(token):
        return(random.uniform(0, 1) <
               math.sqrt(1e-4 / counter[token] * num_tokens))

    return ([[token for token in line if keep(token)] for line in sentences],
            counter)

subsampled, counter = subsample(sentences, vocab)

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

d2l.show_list_len_pair_hist(['origin', 'subsampled'], '# tokens per sentence',
                            'count', sentences, subsampled);

Для отдельных токенов частота выборки высокочастотного слова «the» составляет менее 1/20.

def compare_counts(token):
    return (f'# of "{token}": '
            f'before={sum([l.count(token) for l in sentences])}, '
            f'after={sum([l.count(token) for l in subsampled])}')

compare_counts('the')

В отличие от этого, слова с низкой частотностью, такие как «join», сохраняются полностью.

compare_counts('join')

После отбора подвыборки мы сопоставляем токены с их индексами в корпусе.

corpus = [vocab[line] for line in subsampled]
corpus[:3]

15.3.3. Извлечение центральных слов и слов контекста

Следующая get_centers_and_contexts функция извлекает все центральные слова и их контекстные слова из corpus. Она случайным образом выбирает целое число от 1 до max_window_size в качестве размера контекстного окна. Для любого центрального слова его контекстными словами являются те слова, расстояние до которых от него не превышает выбранного размера контекстного окна.

#@save
def get_centers_and_contexts(corpus, max_window_size):
    """Return center words and context words in skip-gram."""
    centers, contexts = [], []
    for line in corpus:
        # To form a "center word--context word" pair, each sentence needs to
        # have at least 2 words
        if len(line) < 2:
            continue
        centers += line
        for i in range(len(line)):  # Context window centered at `i`
            window_size = random.randint(1, max_window_size)
            indices = list(range(max(0, i - window_size),
                                 min(len(line), i + 1 + window_size)))
            # Exclude the center word from the context words
            indices.remove(i)
            contexts.append([line[idx] for idx in indices])
    return centers, contexts

Далее мы создадим искусственный набор данных, содержащий два предложения, состоящих из 7 и 3 слов соответственно. Пусть максимальный размер контекстного окна равен 2, и выведем все центральные слова и их контекстные слова.

tiny_dataset = [list(range(7)), list(range(7, 10))]
print('dataset', tiny_dataset)
for center, context in zip(*get_centers_and_contexts(tiny_dataset, 2)):
    print('center', center, 'has contexts', context)

При обучении на наборе данных PTB мы устанавливаем максимальный размер контекстного окна равным 5. Приведённый ниже код извлекает все центральные слова и их контекстные слова из набора данных.

all_centers, all_contexts = get_centers_and_contexts(corpus, 5)
f'# center-context pairs: {sum([len(contexts) for contexts in all_contexts])}'

15.3.4. Отрицательная выборка

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

#@save
class RandomGenerator:
    """Randomly draw among {1, ..., n} according to n sampling weights."""
    def __init__(self, sampling_weights):
        # Exclude
        self.population = list(range(1, len(sampling_weights) + 1))
        self.sampling_weights = sampling_weights
        self.candidates = []
        self.i = 0

    def draw(self):
        if self.i == len(self.candidates):
            # Cache `k` random sampling results
            self.candidates = random.choices(
                self.population, self.sampling_weights, k=10000)
            self.i = 0
        self.i += 1
        return self.candidates[self.i - 1]

Например, мы можем выбрать 10 случайных величин \(X\) среди индексов 1, 2 и 3 с вероятностями выборки \(P(X=1)=2/9, P(X=2)=3/9\) и \(P(X=3)=4/9\) следующим образом.

Для пары из центрального слова и слова контекста мы случайным образом выбираем K (5 в эксперименте) шумовых примеров. Согласно рекомендациям из статьи word2vec, вероятность выбора \(P(w)\) шумового примера \(w\) устанавливается равной его относительной частоте в словаре, возведённой в степень 0.75 (Mikolov et al., 2013).

#@save
def get_negatives(all_contexts, vocab, counter, K):
    """Return noise words in negative sampling."""
    # Sampling weights for words with indices 1, 2, ... (index 0 is the
    # excluded unknown token) in the vocabulary
    sampling_weights = [counter[vocab.to_tokens(i)]**0.75
                        for i in range(1, len(vocab))]
    all_negatives, generator = [], RandomGenerator(sampling_weights)
    for contexts in all_contexts:
        negatives = []
        while len(negatives) < len(contexts) * K:
            neg = generator.draw()
            # Noise words cannot be context words
            if neg not in contexts:
                negatives.append(neg)
        all_negatives.append(negatives)
    return all_negatives

all_negatives = get_negatives(all_contexts, vocab, counter, 5)

15.3.5. Загрузка обучающих примеров мини-пакетами

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

В мини-батче \(i^\textrm{th}\) пример включает центральное слово, его \(n_i\) контекстные слова и \(m_i\) шумовые слова. Из-за различных размеров контекстного окна \(n_i+m_i\) варьируется для разных \(i\). Поэтому для каждого примера мы объединяем его контекстные и шумовые слова в переменной contexts_negatives и дополняем нулями до тех пор, пока длина объединения не достигнет \(\max_i n_i+m_i\) (max_len). Чтобы исключить дополнения из вычисления функции потерь, мы определяем переменную маски masks. Существует взаимно однозначное соответствие между элементами в masks и элементами в contexts_negatives, где нули (в противном случае единицы) в masks соответствуют дополнениям в contexts_negatives.

Чтобы различать положительные и отрицательные примеры, мы отделяем контекстные слова от шумовых в contexts_negatives с помощью логической переменной labels. Аналогично masks, существует взаимно однозначное соответствие между элементами labels и элементами contexts_negatives, где единицы (в противном случае нули) в labels соответствуют контекстным словам (положительным примерам) в contexts_negatives.

Реализация описанной выше идеи представлена в следующей batchify функции. Её входной параметр data — это список длиной, равной размеру батча, каждый элемент которого является примером, состоящим из центрального слова center, его контекстных слов context и шумовых слов negative. Функция возвращает мини-батч, который может быть использован для вычислений в процессе обучения, например, содержащий переменную маски.

#@save
def batchify(data):
    """Return a minibatch of examples for skip-gram with negative sampling."""
    max_len = max(len(c) + len(n) for _, c, n in data)
    centers, contexts_negatives, masks, labels = [], [], [], []
    for center, context, negative in data:
        cur_len = len(context) + len(negative)
        centers += [center]
        contexts_negatives += [context + negative + [0] * (max_len - cur_len)]
        masks += [[1] * cur_len + [0] * (max_len - cur_len)]
        labels += [[1] * len(context) + [0] * (max_len - len(context))]
    return (torch.tensor(centers).reshape((-1, 1)), torch.tensor(
        contexts_negatives), torch.tensor(masks), torch.tensor(labels))

Давайте проверим эту функцию на мини-батче из двух примеров.

x_1 = (1, [2, 2], [3, 3, 3, 3])
x_2 = (1, [2, 2, 2], [3, 3])
batch = batchify((x_1, x_2))

names = ['centers', 'contexts_negatives', 'masks', 'labels']
for name, data in zip(names, batch):
    print(name, '=', data)

15.3.6. Собираем всё вместе

Наконец, мы определяем функцию load_data_ptb, которая считывает набор данных PTB и возвращает итератор данных и словарь.

#@save
def load_data_ptb(batch_size, max_window_size, num_noise_words):
    """Download the PTB dataset and then load it into memory."""
    num_workers = d2l.get_dataloader_workers()
    sentences = read_ptb()
    vocab = d2l.Vocab(sentences, min_freq=10)
    subsampled, counter = subsample(sentences, vocab)
    corpus = [vocab[line] for line in subsampled]
    all_centers, all_contexts = get_centers_and_contexts(
        corpus, max_window_size)
    all_negatives = get_negatives(
        all_contexts, vocab, counter, num_noise_words)

    class PTBDataset(torch.utils.data.Dataset):
        def __init__(self, centers, contexts, negatives):
            assert len(centers) == len(contexts) == len(negatives)
            self.centers = centers
            self.contexts = contexts
            self.negatives = negatives

        def __getitem__(self, index):
            return (self.centers[index], self.contexts[index],
                    self.negatives[index])

        def __len__(self):
            return len(self.centers)

    dataset = PTBDataset(all_centers, all_contexts, all_negatives)

    data_iter = torch.utils.data.DataLoader(dataset, batch_size, shuffle=True,
                                      collate_fn=batchify,
                                      num_workers=num_workers)
    return data_iter, vocab

Давайте выведем на экран первый мини-батч итератора данных.

data_iter, vocab = load_data_ptb(512, 5, 5)
for batch in data_iter:
    for name, data in zip(names, batch):
        print(name, 'shape:', data.shape)
    break

15.3.7. Итоги

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

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

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

  1. Как изменится время выполнения кода в этом разделе, если не использовать субдискретизацию?

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

  3. Какие другие гиперпараметры в коде этого раздела могут влиять на скорость загрузки данных?

Обсуждения