Word2Vec — Скип-грамм

За некоторыми исключениями, модели машинного обучения не принимают необработанный текст в качестве входных данных. Последовательности слов сначала должны быть каким-то образом закодированы. Мы могли бы представить каждое предложение как мешок слов (BOW). Сначала мы находим все уникальные слова в текстовом корпусе. Затем мы сопоставляем каждое предложение с вектором, длина которого равна длине словаря (т. е. количеству уникальных слов), так что значения индексов, соответствующих словам, присутствующим в предложении, равны 1, а значения всех остальные индексы оставляют равными 0.

При таком подходе есть две проблемы:

  1. Вектор очень разреженный (т.е. большинство значений равны 0)
  2. Мы теряем информацию, относящуюся к контексту (то есть порядок слов в предложении).

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

Встраивания слов можно вычислить путем обучения модели машинного обучения под названием Word2Vec. Существует два варианта Word2Vec — skip-gram и CBOW. Вариант с пропуском грамма берет целевое слово и пытается предсказать слова окружающего контекста, тогда как вариант CBOW (непрерывный пакет слов) берет набор слов контекста и пытается предсказать целевое слово. В этом посте мы рассмотрим вариант skip-gram.

Предположим, у нас есть следующее предложение:

The wide road shimmered in the hot sun.

Размер окна определяет диапазон слов по обе стороны от target_word, которые можно рассматривать как context word, в отличие от количества контекстных слов.

Алгоритм

Для каждого слова t = 1... T мы предсказываем окружающие слова в окне «радиуса» m. Мы обучаем модель машинного обучения, чтобы максимизировать вероятность любого контекстного слова с учетом текущего центрального слова.

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

где P(w_{t+j}|w_{t}) можно сформулировать как функцию Softmax.

где

Последнее читается как вероятность выходного слова o при наличии центрального слова c. Напомним, что знаменатель в функции Softmax используется для нормализации результата, чтобы получить вероятность (т.е. число в диапазоне от 0 до 1).

Архитектура

Нейронная сеть со скип-граммой состоит из одного скрытого слоя. Ввод представляет собой набор слов (BOW) со значением 1 в позиции центрального слова. Результатом является вероятность нахождения определенного слова в каждой позиции контекстного окна. В следующем примере мы предполагаем, что используем размер окна 1. Как мы видим, изначально модель предсказывает, что слово shimmered следует за словом wide в предложении, а слово road предшествует слову wide в предложении. .

Важно отметить, что мы не писали строчные буквы. Таким образом, у нас есть The и the. Однако на практике вы бы это сделали.

Используя обратное распространение, модель корректирует веса до тех пор, пока ошибка не будет сведена к минимуму. Как мы видим, после нескольких итераций он правильно предсказывает, что последующее слово после wide будет road.

питон

Разберем отдельные участки кода из подробного учебника по word2vec от Google.

Предположим, у нас есть то же предложение, что и раньше.

sentence = "The wide road shimmered in the hot sun"

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

tokens = list(sentence.lower().split())
print(len(tokens))
8

Затем мы сопоставляем слова с числами.

vocab, index = {}, 1  # start indexing from 1
vocab['<pad>'] = 0  # add a padding token
for token in tokens:
  if token not in vocab:
    vocab[token] = index
    index += 1
vocab_size = len(vocab)
inverse_vocab = {index: token for token, index in vocab.items()}
print(inverse_vocab)
{0: '<pad>', 1: 'the', 2: 'wide', 3: 'road', 4: 'shimmered', 5: 'in', 6: 'hot', 7: 'sun'}

Затем мы создаем одномерный вектор.

example_sequence = [vocab[word] for word in tokens]
print(example_sequence)
[1, 2, 3, 4, 5, 1, 6, 7]

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

window_size = 2
positive_skip_grams, _ = tf.keras.preprocessing.sequence.skipgrams(
      example_sequence,
      vocabulary_size=vocab_size,
      window_size=window_size,
      negative_samples=0)
for target, context in positive_skip_grams[:5]:
  print(f"({target}, {context}): ({inverse_vocab[target]}, {inverse_vocab[context]})")
(5, 4): (in, shimmered)
(1, 6): (the, hot)
(1, 5): (the, in)
(1, 3): (the, road)
(6, 5): (hot, in)

Используя первую пару целевых и контекстных слов, мы генерируем num_ns = 4 отрицательных обучающих выборок. Образец является отрицательным (т. е. ему присвоена метка 0), если контекстное слово не найдено внутри контекстного окна.

# Get target and context words for one positive skip-gram.
target_word, context_word = positive_skip_grams[0]

# Set the number of negative samples per positive context.
num_ns = 4

context_class = tf.reshape(tf.constant(context_word, dtype="int64"), (1, 1))
negative_sampling_candidates, _, _ = tf.random.log_uniform_candidate_sampler(
    true_classes=context_class,  # class that should be sampled as 'positive'
    num_true=1,  # each positive skip-gram has 1 positive context class
    num_sampled=num_ns,  # number of negative context words to sample
    unique=True,  # all the negative samples should be unique
    range_max=vocab_size,  # pick index of the samples from [0, vocab_size]
    seed=SEED,  # seed for reproducibility
    name="negative_sampling"  # name of this operation
)
print(negative_sampling_candidates)
print([inverse_vocab[index.numpy()] for index in negative_sampling_candidates])
# Add a dimension so you can use concatenation (in the next step).
negative_sampling_candidates = tf.expand_dims(negative_sampling_candidates, 1)

# Concatenate a positive context word with negative sampled words.
context = tf.concat([context_class, negative_sampling_candidates], 0)

# Label the first context word as `1` (positive) followed by `num_ns` `0`s (negative).
label = tf.constant([1] + [0]*num_ns, dtype="int64")

# Reshape the target to shape `(1,)` and context and label to `(num_ns+1,)`.
target = tf.squeeze(target_word)
context = tf.squeeze(context)
label = tf.squeeze(label)
print(f"target_index    : {target}")
print(f"target_word     : {inverse_vocab[target_word]}")
print(f"context_indices : {context}")
print(f"context_words   : {[inverse_vocab[c.numpy()] for c in context]}")
print(f"label           : {label}")
target_index    : 6
target_word     : hot
context_indices : [7 2 1 4 3]
context_words   : ['sun', 'wide', 'the', 'shimmered', 'road']
label           : [1 0 0 0 0]

На следующей диаграмме показана процедура создания обучающего примера:

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

def generate_training_data(sequences, window_size, num_ns, vocab_size, seed):
  # Elements of each training example are appended to these lists.
  targets, contexts, labels = [], [], []

  # Build the sampling table for `vocab_size` tokens.
  sampling_table = tf.keras.preprocessing.sequence.make_sampling_table(vocab_size)

  # Iterate over all sequences (sentences) in the dataset.
  for sequence in tqdm.tqdm(sequences):

    # Generate positive skip-gram pairs for a sequence (sentence).
    positive_skip_grams, _ = tf.keras.preprocessing.sequence.skipgrams(
          sequence,
          vocabulary_size=vocab_size,
          sampling_table=sampling_table,
          window_size=window_size,
          negative_samples=0)

    # Iterate over each positive skip-gram pair to produce training examples
    # with a positive context word and negative samples.
    for target_word, context_word in positive_skip_grams:
      context_class = tf.expand_dims(
          tf.constant([context_word], dtype="int64"), 1)
      negative_sampling_candidates, _, _ = tf.random.log_uniform_candidate_sampler(
          true_classes=context_class,
          num_true=1,
          num_sampled=num_ns,
          unique=True,
          range_max=vocab_size,
          seed=seed,
          name="negative_sampling")

      # Build context and label vectors (for one target word)
      negative_sampling_candidates = tf.expand_dims(
          negative_sampling_candidates, 1)

      context = tf.concat([context_class, negative_sampling_candidates], 0)
      label = tf.constant([1] + [0]*num_ns, dtype="int64")

      # Append each element from the training example to global lists.
      targets.append(target_word)
      contexts.append(context)
      labels.append(label)

  return targets, contexts, labels

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

path_to_file = tf.keras.utils.get_file('shakespeare.txt', 'https://storage.googleapis.com/download.tensorflow.org/data/shakespeare.txt')
text_ds = tf.data.TextLineDataset(path_to_file).filter(lambda x: tf.cast(tf.strings.length(x), bool))
def custom_standardization(input_data):
  lowercase = tf.strings.lower(input_data)
  return tf.strings.regex_replace(lowercase, '[%s]' % re.escape(string.punctuation), '')
vocab_size = 4096
sequence_length = 10
vectorize_layer = layers.TextVectorization(
standardize=custom_standardization,
max_tokens=vocab_size,
output_mode='int',
output_sequence_length=sequence_length)
vectorize_layer.adapt(text_ds.batch(1024))
inverse_vocab = vectorize_layer.get_vocabulary()
text_vector_ds = text_ds.batch(1024).prefetch(AUTOTUNE).map(vectorize_layer).unbatch()
sequences = list(text_vector_ds.as_numpy_iterator())
for seq in sequences[:5]:
  print(f"{seq} => {[inverse_vocab[i] for i in seq]}")
[ 89 270   0   0   0   0   0   0   0   0] => ['first', 'citizen', '', '', '', '', '', '', '', '']
[138  36 982 144 673 125  16 106   0   0] => ['before', 'we', 'proceed', 'any', 'further', 'hear', 'me', 'speak', '', '']
[34  0  0  0  0  0  0  0  0  0] => ['all', '', '', '', '', '', '', '', '', '']
[106 106   0   0   0   0   0   0   0   0] => ['speak', 'speak', '', '', '', '', '', '', '', '']
[ 89 270   0   0   0   0   0   0   0   0] => ['first', 'citizen', '', '', '', '', '', '', '', '']

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

targets, contexts, labels = generate_training_data(sequences=sequences, window_size=2, num_ns=4, vocab_size=vocab_size, seed=SEED)
targets = np.array(targets)
contexts = np.array(contexts)[:,:,0]
labels = np.array(labels)
BATCH_SIZE = 1024
BUFFER_SIZE = 10000
dataset = tf.data.Dataset.from_tensor_slices(((targets, contexts), labels))
dataset = dataset.shuffle(BUFFER_SIZE).batch(BATCH_SIZE, drop_remainder=True)
dataset = dataset.cache().prefetch(buffer_size=AUTOTUNE)

Мы определяем класс для модели Word2Vec.

class Word2Vec(tf.keras.Model):
  def __init__(self, vocab_size, embedding_dim):
    super(Word2Vec, self).__init__()
    self.target_embedding = layers.Embedding(vocab_size,
                                      embedding_dim,
                                      input_length=1,
                                      name="w2v_embedding")
    self.context_embedding = layers.Embedding(vocab_size,
                                       embedding_dim,
                                       input_length=num_ns+1)

  def call(self, pair):
    target, context = pair
    # target: (batch, dummy?)  # The dummy axis doesn't exist in TF2.7+
    # context: (batch, context)
    if len(target.shape) == 2:
      target = tf.squeeze(target, axis=1)
    # target: (batch,)
    word_emb = self.target_embedding(target)
    # word_emb: (batch, embed)
    context_emb = self.context_embedding(context)
    # context_emb: (batch, context, embed)
    dots = tf.einsum('be,bce->bc', word_emb, context_emb)
    # dots: (batch, context)
    return dots

Мы будем представлять каждое слово в словаре, используя 128 измерений. Мы создаем экземпляр нашего класса Word2Vec. Мы компилируем модель, используя категориальную кроссэнтропию для нашей функции потерь.

embedding_dim = 128
word2vec = Word2Vec(vocab_size, embedding_dim)
word2vec.compile(optimizer='adam', loss=tf.keras.losses.CategoricalCrossentropy(from_logits=True), metrics=['accuracy'])

Наконец, мы обучаем модель.

word2vec.fit(dataset, epochs=20)

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

weights = word2vec.get_layer('w2v_embedding').get_weights()[0]

Как мы видим, мы представляем каждое из 4096 слов словаря с помощью вектора вложения длиной 128.

weights.shape
(4096, 128)

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

weights[0]
array([ 0.02083263,  0.00343355,  0.03133059,  0.04064811,  0.02139286,         0.01668987, -0.01700681,  0.03104338,  0.00513292,  0.01149722,         0.00156037,  0.04110433, -0.02908002, -0.02072917, -0.04493903,        -0.03360658,  0.02354895,  0.02986685,  0.01450031, -0.00434611,         0.02604233,  0.00688297, -0.00568321,  0.02448267, -0.04282743,         0.01752845,  0.02333864, -0.03737045, -0.03860588,  0.03164918,        -0.03887875,  0.03344462, -0.04599243, -0.00912831, -0.03298129,        -0.02165511,  0.00222781, -0.01334076,  0.03560077, -0.01657902,        -0.04948949,  0.00923187,  0.03645227,  0.00624547, -0.00375736,         0.03080207, -0.03460135,  0.00123183,  0.0317348 , -0.03172968,        -0.01598473,  0.03343581,  0.03939797,  0.01271281,  0.01737561,        -0.04787338,  0.03081578,  0.02194339,  0.00668417,  0.0198779 ,        -0.03545182,  0.03608498,  0.03983852,  0.01381046,  0.02620314,        -0.01378284,  0.04695277, -0.0301432 , -0.01917797,  0.03523597,         0.03922388,  0.02773141,  0.00329931,  0.02588192,  0.03493189,        -0.02089679,  0.04374716, -0.03882134, -0.02024856,  0.04483554,        -0.03621026, -0.04145117, -0.03030737, -0.02996567, -0.00220994,         0.0392569 ,  0.03163559, -0.02619413,  0.04448912, -0.01938783,         0.02185104,  0.01294803, -0.01223926, -0.02752018,  0.02359452,         0.01469387,  0.01765844, -0.00813044, -0.04376047, -0.01028157,         0.00078993, -0.01525372, -0.0381612 , -0.00429031,  0.01438124,         0.03173996,  0.02320362, -0.03639726, -0.01158337,  0.04985858,         0.03488507,  0.0025389 ,  0.03290978,  0.02607682,  0.04781124,         0.00342916, -0.03108559,  0.0361053 ,  0.02612146, -0.00554097,        -0.03796817,  0.03855484, -0.03623279,  0.0217861 , -0.01969334,        -0.03057173,  0.03088465, -0.02974273], dtype=float32)

Заключение

Вложения слов — это широко используемое представление слов, которое фиксирует сходство между данным словом и другими словами в корпусе. В отличие от Bag of Words (BOW), вложения слов имеют дополнительное преимущество в том, что они плотные. Вложения слов можно получить, обучив модель Word2Vec и взглянув на веса. Существует два варианта модели Word2Vec: skip-gram и CBOW.