Найти в Дзене
Замечательный предел

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

Чтобы продать что-нибудь ненужное, нужно сначала купить что-нибудь ненужное, а у нас денег нет. (C) Дядя Фёдор. У меня есть полученная с нуля языковая модель преобразователя типа кодер-декодер. Благодаря набору библиотек OpenNMT всё что для этого было нужно — создать файл конфигурации и собрать наборы данных. Теперь благоприятное время для обдумывания, что с ней можно делать и к чему пристроить. Но для начала, всё-таки как она устроена? Ответ на этот вопрос важен, если мы хотим научиться использовать подобные модели за рамками стандартного применения. Лучший способ разобраться в модели — разобрать её на части. И поможет нам в этом такой универсальный инструмент исследователей искусственных интеллектов как torch. Файл модели, полученный с помощью OpenNMT имеет расширение .pt — которое хорошо понимает torch. По-сути этот файл является архивом, внутри которого хранятся файлы с весовыми коэффициентами слоёв Feed-Forward сетей, матрицы встраивания, слои внимания и прочие компоненты. Наиб
Оглавление
Чтобы продать что-нибудь ненужное, нужно сначала купить что-нибудь ненужное, а у нас денег нет. (C) Дядя Фёдор.
FUSION BRAIN: Лаборатория по созданию чудовищных Франкенштейнов
FUSION BRAIN: Лаборатория по созданию чудовищных Франкенштейнов

У меня есть полученная с нуля языковая модель преобразователя типа кодер-декодер. Благодаря набору библиотек OpenNMT всё что для этого было нужно — создать файл конфигурации и собрать наборы данных. Теперь благоприятное время для обдумывания, что с ней можно делать и к чему пристроить. Но для начала, всё-таки как она устроена? Ответ на этот вопрос важен, если мы хотим научиться использовать подобные модели за рамками стандартного применения.

Лучший способ разобраться в модели — разобрать её на части. И поможет нам в этом такой универсальный инструмент исследователей искусственных интеллектов как torch. Файл модели, полученный с помощью OpenNMT имеет расширение .pt — которое хорошо понимает torch. По-сути этот файл является архивом, внутри которого хранятся файлы с весовыми коэффициентами слоёв Feed-Forward сетей, матрицы встраивания, слои внимания и прочие компоненты.

-2

Наибольший интерес для нас представляет файл data.pkl, который по-видимому можно изучить с помощью pickle и содержимое папки data, где и содержится самый жир.

-3

...

-4

Это двоичные файлы и для изучения их содержимого нужные особые подходы.

Напишем программу.

Программа исследования модели с помощью torch
Программа исследования модели с помощью torch
Программа исследования модели с помощью torch - продолжение
Программа исследования модели с помощью torch - продолжение

И посмотрим что она нам выдаст.

-7

Что означают эти данные?

  • vocab: Словарь, который сопоставляет токены (слова/субслов) с числовыми индексами.
  • opt: Вся конфигурация модели, включая архитектуру, обучение и данные.
  • model: Это "мозг" модели — все обучаемые параметры, которые используются для преобразования входных данных в выходные.
  • generator: Вероятно, линейный слой для генерации выходных токенов.

Вот что из себя представляет vocab

-8

-9

Вот что содержится в opt: конфигурация модели (Namespace с гиперпараметрами).

-10

Отсюда можно почерпнуть следующую информацию

1. Видно, что архитектурно модель представляет собой преобразователь (Transformer) типа "кодер-декодер", что подтверждается параметрами:

  • encoder_type='transformer', decoder_type='transformer'
  • 6 слоев в кодере (enc_layers=6) и 6 слоев в декодере (dec_layers=6).
  • Размерность модели (hidden_size=768) и количество голов внимания (heads=8).

2. Встраивание и позиционное кодирование:

Используются общие веса встраиваний (эмбеддингов) для кодера и декодера (share_embeddings=True).

  • Применяется синусоидальное позиционное кодирование (position_encoding_type='SinusoidalInterleaved').
  • Размерность эмбеддингов: word_vec_size=768.

3. Слои преобразователя

Кодер состоит из 6 идентичных слоев, каждый содержит:

  • Self-Attention (self_attn) с линейными преобразованиями для ключей, значений и запросов (k, v, q).
  • Feed-Forward Network (FFN) с двумя линейными слоями (w_1, w_2) и LayerNorm.
  • Декодер также имеет 6 слоев, но дополнительно включает:
    Self-Attention + Cross-Attention (контекстное внимание к выходу кодера).
    Два LayerNorm для каждого подмодуля.

4. Входные данные и предобработка

  • Используется SentencePiece для токенизации (transforms=['sentencepiece']).
  • Размер словаря: 12000 (src_vocab_size=12000, tgt_vocab_size=12000).
  • Максимальная длина последовательности: 384 токена (src_seq_length=384, tgt_seq_length=384).

5. Обучение

  • Оптимизатор: Adam (optim='adam') с learning rate 2.0 и warmup-шагами (warmup_steps=8000).
  • Регуляризация: dropout (dropout=[0.1]), label smoothing (label_smoothing=0.1).
  • Batch size: 8192 токенов (batch_size=8192, batch_type='tokens').

6. Ключевые тензоры в .pt-файле

  • vocab: Словарь с токенами и их индексами.
  • opt: Конфигурация модели (Namespace с гиперпараметрами).
  • model: Веса модели, включая:
    Эмбеддинги (embeddings.make_embedding.emb_luts.0.weight).
    Позиционные кодировки (pe.pe).
    Веса внимания (self_attn.linear_keys/values/query.weight).
    FFN-слои (feed_forward.w_1/w_2.weight).
    Нормализации (layer_norm.weight/bias).

7. Особенности

  • Многоголовое внимание: 8 голов (heads=8).
  • Размер FFN: 1536 (transformer_ff=1536), что соответствует 4*hidden_size (стандартно для Transformer).
  • Активация: ReLU (pos_ffn_activation_fn='relu').

Обобщая данную информацию можно сказать, что перед нами стандартная Transformer-модель для машинного перевода, обученная с использованием OpenNMT-py. Ее архитектура аналогична оригинальному Transformer от Vaswani et al. (2017), но с параметрами, характерными для современных реализаций (например, hidden_size=768 как у BERT-base). Модель использует общие эмбеддинги и позиционное кодирование, что типично для задач seq2seq.

Вернёмся к программе и её выводу.

В optim ничего не содержится:

-11

Содержимое generator

-12

Содержимое model:
>
Тип: <class 'dict'>
'encoder.embeddings.make_embedding.emb_luts.0.weight': tensor
'encoder.embeddings.make_embedding.pe.pe': tensor
'encoder.transformer.0.self_attn.linear_keys.weight': tensor
'encoder.transformer.0.self_attn.linear_values.weight': tensor
'encoder.transformer.0.self_attn.linear_query.weight': tensor
'encoder.transformer.0.self_attn.final_linear.weight': tensor
'encoder.transformer.0.feed_forward.w_1.weight': tensor
'encoder.transformer.0.feed_forward.w_2.weight': tensor
'encoder.transformer.0.feed_forward.layer_norm.weight': tensor
'encoder.transformer.0.feed_forward.layer_norm.bias': tensor
'encoder.transformer.0.layer_norm.weight': tensor
'encoder.transformer.0.layer_norm.bias': tensor
… (всё тоже самое для слоёв 1, 2, 3, 4, 5)
'encoder.layer_norm.weight': tensor
'encoder.layer_norm.bias': tensor

'decoder.embeddings.make_embedding.emb_luts.0.weight': tensor
'decoder.embeddings.make_embedding.pe.pe': tensor
'decoder.layer_norm.weight': tensor
'decoder.layer_norm.bias': tensor
'decoder.transformer_layers.0.self_attn.linear_keys.weight': tensor
'decoder.transformer_layers.0.self_attn.linear_values.weight': tensor
'decoder.transformer_layers.0.self_attn.linear_query.weight': tensor
'decoder.transformer_layers.0.self_attn.final_linear.weight': tensor
'decoder.transformer_layers.0.feed_forward.w_1.weight': tensor
'decoder.transformer_layers.0.feed_forward.w_2.weight': tensor
'decoder.transformer_layers.0.feed_forward.layer_norm.weight': tensor
'decoder.transformer_layers.0.feed_forward.layer_norm.bias': tensor
'decoder.transformer_layers.0.layer_norm_1.weight': tensor
'decoder.transformer_layers.0.layer_norm_1.bias': tensor
'decoder.transformer_layers.0.context_attn.linear_keys.weight': tensor
'decoder.transformer_layers.0.context_attn.linear_values.weight': tensor
'decoder.transformer_layers.0.context_attn.linear_query.weight': tensor
'decoder.transformer_layers.0.context_attn.final_linear.weight': tensor
'decoder.transformer_layers.0.layer_norm_2.weight': tensor
'decoder.transformer_layers.0.layer_norm_2.bias': tensor
… (всё тоже самое для слоёв 1, 2, 3, 4, 5)

Некоторые слои представляют собой многомерные матрицы, например:

-13

Другие представляют собой векторы, например:

-14

...

-15

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

-16

encoder.embeddings.make_embedding.emb_luts.0.weight: torch.Size([12000, 768])
encoder.embeddings.make_embedding.pe.pe: torch.Size([5000, 1, 768])
encoder.transformer.0.self_attn.linear_keys.weight: torch.Size([768, 768])
encoder.transformer.0.self_attn.linear_values.weight: torch.Size([768, 768])
encoder.transformer.0.self_attn.linear_query.weight: torch.Size([768, 768])
encoder.transformer.0.self_attn.final_linear.weight: torch.Size([768, 768])
encoder.transformer.0.feed_forward.w_1.weight: torch.Size([1536, 768])
encoder.transformer.0.feed_forward.w_2.weight: torch.Size([768, 1536])
encoder.transformer.0.feed_forward.layer_norm.weight: torch.Size([768])
encoder.transformer.0.feed_forward.layer_norm.bias: torch.Size([768])
encoder.transformer.0.layer_norm.weight: torch.Size([768])
encoder.transformer.0.layer_norm.bias: torch.Size([768])
...
encoder.layer_norm.weight: torch.Size([768])
encoder.layer_norm.bias: torch.Size([768])
decoder.embeddings.make_embedding.emb_luts.0.weight: torch.Size([12000, 768])
decoder.embeddings.make_embedding.pe.pe: torch.Size([5000, 1, 768])
decoder.layer_norm.weight: torch.Size([768])
decoder.layer_norm.bias: torch.Size([768])
decoder.transformer_layers.0.self_attn.linear_keys.weight: torch.Size([768, 768])
decoder.transformer_layers.0.self_attn.linear_values.weight: torch.Size([768, 768])
decoder.transformer_layers.0.self_attn.linear_query.weight: torch.Size([768, 768])
decoder.transformer_layers.0.self_attn.final_linear.weight: torch.Size([768, 768])
decoder.transformer_layers.0.feed_forward.w_1.weight: torch.Size([1536, 768])
decoder.transformer_layers.0.feed_forward.w_2.weight: torch.Size([768, 1536])
decoder.transformer_layers.0.feed_forward.layer_norm.weight: torch.Size([768])
decoder.transformer_layers.0.feed_forward.layer_norm.bias: torch.Size([768])
decoder.transformer_layers.0.layer_norm_1.weight: torch.Size([768])
decoder.transformer_layers.0.layer_norm_1.bias: torch.Size([768])
decoder.transformer_layers.0.context_attn.linear_keys.weight: torch.Size([768, 768])
decoder.transformer_layers.0.context_attn.linear_values.weight: torch.Size([768, 768])
decoder.transformer_layers.0.context_attn.linear_query.weight: torch.Size([768, 768])
decoder.transformer_layers.0.context_attn.final_linear.weight: torch.Size([768, 768])
decoder.transformer_layers.0.layer_norm_2.weight: torch.Size([768])
decoder.transformer_layers.0.layer_norm_2.bias: torch.Size([768])
...

Разберём значение размерностей некоторых тензоров в нашей модели

1. Эмбеддинги токенов

encoder.embeddings.make_embedding.emb_luts.0.weight: torch.Size([12000, 768])

  • 12000: Размер словаря (количество уникальных токенов).
  • 768: Размерность эмбеддинга каждого токена (hidden_size).
  • Что это: Матрица, где каждая строка — это векторное представление токена.

2. Позиционные эмбеддинги (Positional Encoding)

encoder.embeddings.make_embedding.pe.pe: torch.Size([5000, 1, 768])
Это трёхмерный тензор и его логика проще, чем кажется.

Почему такие размеры?

  • 5000: Максимальная длина последовательности, для которой предварительно вычислены позиционные эмбеддинги.
    В конфиге (opt.src_seq_length=384) указана рабочая длина, но 5000 — это "запас" на случай более длинных текстов (хотя вряд ли они будут использоваться).
    Число выбрано с запасом, чтобы покрыть все возможные позиции в любом реальном сценарии.
  • 1: Фиктивная размерность для совместимости с batch-обработкой.
    Формат (pos, batch, hidden_size), но здесь batch=1, так как позиционные эмбеддинги не зависят от батча.
  • 768: Та же размерность, что и у токенных эмбеддингов (для поэлементного сложения).

Как это работает?

  1. Для токена на позиции i берётся вектор из pe.pe[i, 0, :] (размер 768).
  2. Он добавляется к эмбеддингу токена:
-17

Почему не [384, 768]?

  • 384 — это рабочий лимит для входных данных, но позиционные эмбеддинги вычисляются один раз для всех возможных позиций (до 5000).
  • Так сделано для универсальности (например, если модель будет использоваться в других задачах с большей длиной последовательности).

3. Пример для других тензоров

  • Self-Attention веса:
    encoder.transformer.0.self_attn.linear_keys.weight: torch.Size([768, 768])
    Это матрица проекции для ключей (преобразует вход (768,) в вектор ключа (768,)). Аналогичнодля linear_values и linear_query.
  • LayerNorm параметры:
    encoder.transformer.0.layer_norm.weight: torch.Size([768])
    Это learnable масштабирующие коэффициенты (по одному на каждую из 768 размерностей).

4. Почему 5000, а не другое число?

Исторически в Transformer-моделях используют запас по длине:

  • Оригинальный Transformer (Vaswani et al., 2017): до 512 позиций.
  • BERT: до 512.
  • Ваша модель: 5000 — "на всякий случай" (например, для документов).

На практике при длине текста > opt.src_seq_length (384) он будет обрезаться.

Итог

  • pe.pe: [5000, 1, 768] — это таблица синусоидальных позиционных эмбеддингов с запасом по длине.
  • Первая размерность (5000) — "максимальная теоретическая позиция", вторая (1) — для батча, третья (768) — размерность.
  • В реальности используются только первые seq_len векторов (например, 384).

Если нужно точно воспроизвести forward pass, позиционные эмбеддинги применяются так:

-18

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

-19

И вот что мы получаем:

-20

Получим позиционные встраивания:

-21

Вывод программы:

-22

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

-23

Вывод программы:

-24

...

-25

Тут похоже где-то закралась ошибка, потому что размерность должна быть [5, 768], а у нас появилось лишнее измерение.

token_embed получается из матрицы эмбеддингов с помощью индексации по test_ids. Если test_ids содержит 5 токенов, то token_embed будет иметь размерность [5, 768].

Размерность pos_embed:

  • pos_embed извлекается из модели как часть позиционных эмбеддингов.

pos_embed = model['encoder.embeddings.make_embedding.pe.pe'][position, :, :]

Здесь position — это тензор размером [5, 1] (5 позиций, каждая представлена как одно число). После индексации по position, pos_embed имеет размерность [5, 1, 768].

Далее мы выполняем сложение:

final_embed = token_embed + pos_embed.squeeze(1)

Однако, если pos_embed не был правильно преобразован (например, .squeeze(1) не удалил лишнее измерение), то результат сложения может иметь неправильную размерность. Это объясняет, почему final_embed имеет размерность [5, 5, 768].

Исправим код:

-26

И вот что мы получаем теперь:

-27

Это даже на вид выглядит лучше. Но что мы имеем за этими цифрами?

Информативность встраиваний

  1. token_embed:
    Эти встраивания представляют семантику каждого токена. Они обучены так, чтобы отражать смысл слов или подслов (в случае SentencePiece). Например, токен "Привет" будет иметь эмбеддинг, который отличается от эмбеддинга для "мир".
  2. pos_embed:
    Эти встраивания добавляют информацию о позиции токенов в последовательности. Они важны для моделей, таких как Transformer, которые не имеют встроенной информации о порядке токенов. Позиционные эмбеддинги обычно основаны на синусах и косинусах (или других функциях), чтобы кодировать относительное положение токенов.
  3. final_embed:
    Финальные эмбеддинги объединяют семантическую информацию token_embed) и информацию о позиции (pos_embed). Это делает их информативными для входа в модель Seq2Seq, так как они содержат как смысл токенов, так и их порядок.

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

Косинусное сходство измеряет угол между двумя векторами. Оно вычисляется по формуле:

где:
где:
-29

Значение косинусного сходства находится в диапазоне от -1 до 1:

  • 1 : Векторы полностью совпадают.
  • 0 : Векторы ортогональны (нет схожести).
  • -1 : Векторы противоположны.

Представление предложения:

  • Каждое предложение представлено последовательностью эмбеддингов (final_embed), где каждый токен имеет свой вектор размерности [768].
  • Чтобы сравнить два предложения, нужно свести их последовательности эмбеддингов к одному вектору. Это можно сделать путём усреднения векторов всех токенов в предложении.

Ограничения подхода

  1. Усреднение может потерять информацию:
    Усреднение эмбеддингов теряет информацию о порядке токенов. Например, предложения "Мама мыла раму" и "Раму мыла мама" будут иметь одинаковые усреднённые эмбеддинги, хотя их смысл различен.
  2. Контекст не учитывается полностью:
    final_embed кодирует только начальные эмбеддинги (токенные + позиционные). Для полноценного учёта контекста лучше использовать выходы модели после прохождения через все слои Transformer.
  3. Сравнение длинных предложений:
    Если предложения имеют разную длину, усреднение может привести к искажению результатов.
  4. Не всегда семантически точное:
    Косинусное сходство работает хорошо для задач, где важна общая схожесть (например, поиск дубликатов), но может быть недостаточно точным для сложных семантических сравнений.

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

1. Что такое центрирование данных?

Центрирование данных означает вычитание среднего значения из каждого элемента вектора. Это делается для того, чтобы "перенести" данные в начало координат, устранив смещение (bias) в данных. Формально:

centered_vector=original_vector−mean(original_vector)

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

2. Почему центрирование влияет на косинусное сходство?

Косинусное сходство измеряет угол между двумя векторами, но оно не учитывает их абсолютные значения. Однако, если векторы имеют большое смещение (например, все значения вектора сдвинуты на одну и ту же константу), это может исказить результат. Центрирование устраняет это смещение, что делает сравнение более точным.

Напишем программу.

-30

Продолжение

-31

Получаем такие результаты:

torch.Size([768])

['▁Мама', '▁мы', 'ла', '▁ра', 'му']

[4404, 300, 135, 4197, 470]

Мама мыла раму

['▁Мама', '▁мы', 'ла', '▁окна']

[4404, 300, 135, 7110]

Мама мыла окна

['▁З', 'дра', 'в', 'ству', 'й', ',', '▁мир', '!']

[1504, 3204, 126, 1044, 74, 4, 755, 46]

Здравствуй, мир!

Косинусное сходство: 1.0

Косинусное сходство: 0.9876282215118408

Косинусное сходство: 0.9412614107131958

Здесь у нас результат сравнения предложения «Мама мыла раму» с двумя остальными. И как-то совсем не впечатляет — практически нет никакого различия. Если извлечённые из модели встраивания достаточно хороши (а это должно быть так, ведь модель неплохо справлялась в процессе обучения с огромным набором данных) и вычисления самой метрики косинусного сходства верно, значит дело в чём то другом. Здесь нужно сделать обоснованное предположение и проверить его опытным путём.

Например, можно предположить, что какая-то часть получаемых векторов вместо полезной информации содержит случайный шум. И это так значительно искажает картину, что все векторы становятся похожи друг на друга. Но откуда ему там взяться? Возможно, это связано с максимальной длиной последовательности. Если она превышает максимальную длину последовательностей, на которых обучалась модель, то всё что выше этих индексов может содержать шум. Также, может быть такой шум содержится в позициях, которые находятся далеко за пределами длины сравниваемых предложений. Что ещё.

a) Позиционные эмбеддинги могут быть шумными

Наш код использует позиционные эмбеддинги (pos_embed), которые добавляются к токенным эмбеддингам. Эти эмбеддинги часто генерируются с помощью функций (например, синусов и косинусов) и имеют фиксированную размерность (в нашем случае [5000, 768]). Однако:

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

b) Токенные эмбеддинги могут быть разреженными

Матрица токенных эмбеддингов (token_embedding_matrix) имеет размерность [12000, 768], где 12000 — это размер словаря. Однако:

  • Не все токены в словаре одинаково часто встречаются в данных. Редкие токены могут иметь плохо обученные эмбеддинги, которые содержат больше шума.
  • Если ваше предложение содержит редкие токены (например, <unk> или специальные символы), их эмбеддинги могут исказить результаты.

c) Усреднение включает шумовые компоненты

Когда мы усредняем эмбеддинги всех токенов (torch.mean(final_embed, dim=0)), шумовые компоненты (например, от позиционных эмбеддингов или редких токенов) также учитываются. Ограничение размерности до max_seq_len помогает исключить эти шумовые компоненты.

2. Как проверить гипотезу о шумовых значениях?

Чтобы подтвердить, что значения за пределами max_seq_len действительно являются шумовыми, вы можете выполнить следующие шаги:

a) Визуализация эмбеддингов

Построить графики значений эмбеддингов для разных позиций. Например:

-32

Вот что мы там получаем:

-33

График показывает, что информация в эмбеддингах распределена неравномерно. Начальные позиции содержат более "сглаженные" значения, тогда как последние позиции более вариативны.

Начальная часть (0–100):

  • Значения имеют отрицательный тренд, достигая минимума около позиции 50.
  • Это может указывать на то, что первые позиции вектора кодируют специфическую информацию, например, общие или базовые признаки текста.
  1. Средняя часть (100–300):
    Значения постепенно увеличиваются и стабилизируются около нуля.
    Это может быть область, где модель кодирует более нейтральные или сбалансированные признаки текста.
  2. Конечная часть (300–500):
    Значения становятся более хаотичными, с большим разбросом.
    Это может указывать на то, что последние позиции вектора содержат более детализированную или контекстуальную информацию, которая варьируется в зависимости от входных данных. Либо эта часть — шум, не несущий полезной информации.

Отбросим конечную часть и посмотрим как изменятся результаты. Для этого поменяем значение max_seq_len с 768 на 384:

Косинусное сходство: 1.0 (сравнение первого предложения с самим собой)
Косинусное сходство: 0.9688886404037476 (сравнение первого предложения со вторым)
Косинусное сходство: 0.8486628532409668 (сравнение первого предложения с третьим)

Немного лучше, но всё равно слабо.

Уменьшим max_seq_len с 384 на 192:

Косинусное сходство: 1.0
Косинусное сходство: 0.9068751931190491
Косинусное сходство: 0.6292324662208557

Это уже хотя бы более-менее.

Вот как это выглядит:

-34

Попробуем другой набор предложений

-35
Косинусное сходство: 1.0000001192092896
Косинусное сходство: 0.9975077509880066
Косинусное сходство: 0.9101594090461731

Снова результат не впечатляет.

Уменьшим max_seq_len с 192 на 96:

Результат:

Косинусное сходство: 1.0
Косинусное сходство: 0.9798181056976318
Косинусное сходство: 0.09695033729076385

Первое предложение теперь довольно хорошо отличается от второго.

Уменьшим max_seq_len с 96 на 48:

Результат:

Косинусное сходство: 1.0
Косинусное сходство: 0.9700007438659668
Косинусное сходство: -0.2247588336467743

Различение второго предложения практически не изменилось. А третье предложение ушло в минус, т. е. оно не просто в этой части вектора не схоже с первым, но и немного противоположно ему. Поэтому вернёмся на max_seq_len равную 96, потому что, похоже это значение в данном случае близко к оптимальному.

Посмотрим как выглядят все три предложения на этом участке вектора.

Первое предложение:

-36

Второе предложение:

-37

Третье предложение:

-38

Не совсем понятно какие именно аспекты текста и в каком соотношении вошли в значения векторов встраивания от 0 до 96 — синтаксис, семантика, контекст. Но это похоже не просто сравнение токенов.

Добавим, например ещё несколько предложений.

-39

Сравним предложение из строки 44 со всеми остальными:

Косинусное сходство: 1.0000001192092896
Косинусное сходство: 0.018891803920269012
Косинусное сходство: -0.3524119257926941
Косинусное сходство: 0.047187309712171555
Косинусное сходство: 0.7123786807060242

Теперь сравним предложение в строке 47 со всеми остальными.

Косинусное сходство: 0.047187309712171555
Косинусное сходство: 0.9851077198982239
Косинусное сходство: 0.08987197279930115
Косинусное сходство: 1.0000001192092896
Косинусное сходство: -0.16039106249809265

В этот раз наиболее похожими оказались кисейная занавеска, которая распростёрлась на всё панорамное окно и панорамное окно, которое скрывает барышню за кисейной занавеской.

Вот как выглядит предложение из строки 44:

-40

А так выглядит предложение из строки 48:

-41

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

-42

где:

  • pos — позиция токена,
  • d_{\text{model}} — размерность эмбеддинга.

До позиции ~60 значения кажутся более хаотичными и менее структурированными. Это может быть связано с токенами: встраивания токенов могут иметь случайную структуру, особенно если предложения короткие или содержат редкие слова.

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

Если брать слишком много позиций (например, >96), значения могут начать вносить больше шума, так как:

  • Позиционные эмбеддинги для длинных последовательностей могут стать менее информативными.
  • Токены в конце предложения могут быть менее важными для семантики всего предложения.

Посмотрим, нужно ли в данном случае позиционное кодирование и как изменятся результаты без него. Для этого в функции def get_embed(ids, model): меняем строку

final_embed = token_embed + pos_embed.squeeze(1) на final_embed = token_embed:

-43

Результат сравнения строки 44 со всеми остальными:

Косинусное сходство: 1.0
Косинусное сходство: 0.7376084923744202
Косинусное сходство: 0.1594211310148239
Косинусное сходство: 0.7645357847213745
Косинусное сходство: 0.833320677280426

Тоже неплохо по схожести порядок между предложениями не изменился, хотя числа и другие.

Вот как это выглядит без позиционного кодирования:

-44

Посмотрим на сами позиционные встраивания

-45

Результат:

-46

Какой вариант лучше оставить: с позиционным кодированием или без него?

Результаты показывают, что оба варианта дают похожее ранжирование предложений по уровню их сходства. Однако выбор между ними зависит от целей и особенностей задачи:

a) С позиционным кодированием

  • Позиционное кодирование добавляет информацию о порядке токенов, что важно для многих задач обработки естественного языка (например, если порядок слов влияет на смысл предложения).
  • В нашем случае результаты с позиционным кодированием выглядят более "разделёнными" (например, значения косинусного сходства ближе к -1 или +1), что может быть полезно для лучшего различения семантически далёких предложений.
  • Если нам важна точность сравнения, особенно для предложений с разным порядком слов, стоит оставить позиционное кодирование.

b) Без позиционного кодирования

  • Без позиционного кодирования модель фокусируется только на семантике токенов, игнорируя их порядок. Это может быть полезно, если порядок слов в предложениях не имеет значения (например, при анализе мешка слов — bag of words).
  • Результаты без позиционного кодирования выглядят менее "разделёнными", но они всё ещё сохраняют правильное ранжирование. Если нужна простота и вы готовы пожертвовать некоторой точностью, этот вариант тоже подходит.

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

Здесь мы использовали лишь несколько компонентов модели, таких как:

  • Токенные эмбеддинги (encoder.embeddings.make_embedding.emb_luts.0.weight),
  • Позиционные эмбеддинги (encoder.embeddings.make_embedding.pe.pe).

Мы можем извлечь эти компоненты и сохранить их в отдельный файл:

-47

Исходный файл :

-48

Полученный файл:

-49

Почти в 7 раз компактнее.

Что внутри:

-50
-51

Проверка сохранённой модели.

-52

Продолжение

-53

Результат:

['▁При', 'вет', ',', '▁мир', '!']
[602, 3035, 4, 755, 46]
torch.Size([5, 768])
torch.Size([768])
torch.Size([768])
torch.Size([768])

На этом пока что всё. Более полная реализация работы с преобразователем с помощью torch будет в следующей статье.

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

В подготовке статьи помимо меня принимали участие:
A.I. Qwen, A.I. DeepSeek, A.I. ChatGPT.