Pull to refresh

Cross-Encoder для улучшения RAG на русском

Level of difficultyMedium
Reading time15 min
Views3.6K

Содержание

  1. RAG (retrieval augmented generation)

  2. Би-энкодер VS кросс-энкодер

  3. Обзор доступных кросс-энкодеров

  4. Архитектура кросс-энкодера (классификационная голова)

  5. Обучающие данные

  6. Процесс обучения

  7. Результаты обучения

  8. Выводы

1. RAG (retrieval augmented generation)

RAG используется часто и позволяет расширить знания LLM (большой языковой модели) с помощью кучи документов и других источников текста. Просто вставляем релевантные части текста, чтобы LLM могла на них опираться. А как найти эти релевантные части текста? Вернее, как выбрать из них наиболее релевантные?

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

Выбор модели для создания векторов (=ембеддингов)

Выбор модели, которая будет делать вектора, у меня проходит обычно так:

Смотрим в encodechka, там находим лучшую модель для STS (semantic textual similarity). Скачиваем её с huggingface и применяем.

Можно сразу на huggingface пойти, там раздел "feature extraction" (это по сути получение векторов для текста).

Иногда можно посмотреть на sentence transformers. Но в реальности редко пользовался этим фреймворком.

Моя практика показала, что оптимальные модели - это:

  1. Лучшая, но тяжеловатая, более 2 ГБ. Русский знает. Это e5-large-v2.

  2. Хорошее качество, небольшой размер - LaBSE-en-ru. Часто это оптимальный выбор.

  3. Если надо маленькую и очень быструю - rubert-tiny2 (чуть больше 100 Мбайт).

Далее расчета косинусного расстояния, это обычно проблем не вызывает.

Но дело в том, что выбирать наиболее релевантные части текста для RAG можно и по другому. Часто я слышал по кросс-энкодеры (Cross-Encoder). Но детально вникнуть не доходили руки. Но вот сейчас необходимый объем материалов собран. В этой статье как раз и поделюсь ими.


2. Би-энкодер VS кросс-энкодер

Би-энкодер

Делаем вектор вопроса и вектор каждой части текста отдельно. Считаем расстояния, выбираем наименьшее. Это би-энкодер. Назван так, потому, что для вычисления расстояния мы делаем два (би) вектора, а затем вычисляем косинусное расстояние между ними. Можно, кстати, другие расстояния использовать, но это скорее экзотика (напр., "расстояние городских кварталов"). Логически выглядит как-то так:

# на входе вопрос и куча текстов (у нас для упрощения два)
question = 'Где зимуют раки?'
text_chunk_1 = 'Машина была зеленая'
text_chunk_2 = 'Раки впадают в спячку, в озерах'

# делаем эмбединги (они же вектора)
# в данном случае их будет ровно три
emb_question = make_emb(question)
emb_text_chunk_1 = make_emb(text_chunk_1)
emb_text_chunk_2 = make_emb(text_chunk_2)

# считаем расстояние между эмбединнгом вопроса и эмбеддингом каждого текста
# цифры условные
# используем sklearn.metrics.pairwise_distances
dist_text_chunk_1 = 0.41
dist_text_chunk_2 = 0.13

# вообщем всё
# наиболее релевантный текст - где меньше расстояние, конечно
final_result_goes_to_LLM = text_chunk_2

Кросс-энкодер

А что если в модель подавать сразу и вопрос и ответ? Вообще, для модели это проще, она одновременно видит обе части и должна сказать нам, насколько вероятно, что это вопрос-ответ. У берт-подобных моделей это похоже на - NSP (next sentense perdiction). Эта задача даже использовалась на стадии pretrain таких моделей. Т.е. качество результат должно быть лучше.

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

Если у нас 10 вопросов и 20 текстов, то би-энкодеру потребуется сделать 30 шт. (10+20) запусков нейросети, а кросс-энкодеру 200 шт. (10*20). Расчет расстояния между векторами для би-энкодера не учитываем, он делается быстро.

В целом работает так:

# на входе вопрос и куча текстов - тоже самое, что и выше
question = 'Где зимуют раки?'
text_chunk_1 = 'Машина была зеленая'
text_chunk_2 = 'Раки впадают в спячку, в озерах'

# одновременно грузим в модель пару "вопрос-ответ". Таких пар у нас две
# считаем вероятность того, что это логичная пара
# идея в том, что качество кросс-энкодера должно быть лучше
# цифры условные
score_question_text_chunk_1 = 0.12
score_question_text_chunk_2 = 0.91

# наиболее релевантный текст - где больше вероятность
final_result_goes_to_LLM = text_chunk_2

Отлично! В некоторых задачах, можно пожертвовать скоростью, но попробовать обеспечить хороший прирост качества.

Конечно, кросс-энкодер не ранжирует миллион текстов, т.к. это будет очень медленно. Однако он может работать как вторая стадия ранжирования. А на первой стадии можно использовать быстрый би-энкодер, снизив пространства выбора до десятков/сотен текстов. Пример такого кода тут. Стадийный подход показывает свою эффективность, т.к. одним инструментом ("end-to-end") сделать всё бывает сложно.

А где взять кросс-энкодер? Чтобы коммерческая лицензия, русский язык и был готов сразу к работе без допиливания.

"В наше время нейросетей точно должен быть какой-то вариант" - подумал я и начал искать.


3. Обзор доступных кросс-энкодеров

Технически любая берт-подобная нейросетевая модель может это делать. Ниже примерный псевдокод:

# на вход две строки (в нашем случае это пара: вопрос-ответ)
# хотим понять, насколько потенциальный ответ релевантен вопросу
question = 'Где зимуют раки?'
text_chunk_1 = 'Машина была зеленая'

# токенизируем и добавляем спец. токены
tokenized_pair = [CLS, Где, зимуют, раки, SEP, Машина, была, зеленая, SEP]

# подаем в модель
# модель нам возвращает вероятность релевантности ответа вопросу
pair_probability = bert(tokenized_pair) 
pair_probability
# -> 0.013% низкая, т.к. ответ явно не связан с вопросом

Далее начались поиски такой модели. Encodechka содержит бенчмарки би-энкодеров. Это не подходит.

Искал на huggingface и sentense transformers. Кратко - не нашел ничего, что хоть немного подходило бы под мои требования. На русском многие модели работают сильно хуже. А кросс-энкодера, который бы тренировался для русского - не нашел. Всё мое разочарование ниже в коде (код кстати реальный, можете проверить):

from transformers import AutoTokenizer, AutoModelForSequenceClassification
check_point = 'cross-encoder/ms-marco-MiniLM-L-12-v2'
model = AutoModelForSequenceClassification.from_pretrained(check_point)
tokenizer = AutoTokenizer.from_pretrained(check_point)
questions = ['где можно взять лодку на прокат?', 
             'где можно взять лодку на прокат?']
answers = ['взятие тракторов для пользования не предусмотрено', 
            'небольшой катер обычно берут в аренду у берега']
features = tokenizer(questions, answers, 
                     padding=True, truncation=True, return_tensors="pt")
scores = model(**features).logits
print(scores.tolist())

# [[7.5914], 
#  [7.1993]]

# что это вообще такое!?)
# либо я что-то не понимаю, либо модель делает абсолютно неверный выбор
# первая пара ей кажется более логичной (вопрос про лодку, а ответ про трактор)

# вообще как-то странно сделано
# получается, что решается задача регрессии, а не классификации...
# т.е. получить степень уверенности модели нельзя, только сортировку
model.num_labels
# 1

Постепенно приходил к выводу, что надо брать за основу берт-подобную модель и уже далее доделывать и дотренировывать её самому.

Вдохновила также модель сбера ai-lab/ESGify_ru (ссылка). Там решалась другая задача, но было явно видно, что можно допиливать модели для себя (они сделали сильную голову-классификатор для multi-label классификации, 48 классов ESG рисков).


4. Архитектура кросс-энкодера (классификационная голова)

Сначала покажу код модели:

class Model(nn.Module):
    def __init__(self):
        super().__init__()
        self.labse  = AutoModel.from_pretrained('cointegrated/LaBSE-en-ru')
        self.tokenizer = AutoTokenizer.from_pretrained('cointegrated/LaBSE-en-ru', 
                                                       use_fast=True)
        n_dim = 768
        self.cls = nn.Sequential(OrderedDict(
              [
                ('dropout', torch.nn.Dropout(.0)),

                ('fc_1' , nn.Linear(n_dim, n_dim*2)),
                ('relu_1' , nn.GELU()),
                ('layernorm_1' , nn.LayerNorm(n_dim*2, eps=1e-12)),

                ('fc_2' , nn.Linear(n_dim*2, n_dim)),
                ('relu_2' , nn.GELU()),
                ('layernorm_2' , nn.LayerNorm(n_dim, eps=1e-12)),

                ('fc_3' , nn.Linear(n_dim, 2, bias=False)),
              ]
))
    def forward(self, text):
        token = self.tokenizer(text, padding=True, 
                               truncation=True, return_tensors='pt').to(device)
        model_output = self.labse(**token)
        result = self.cls(model_output.pooler_output)
        return result

В целом идея простая: берем любую берт-подобную модель и навешиваем сверху голову в виде бинарного классификатора.

От нейросети ждем бинарный ответ на вопрос: "логично ли что этот текст может служить ответом к этому вопросу? (да/нет)". Хотя ответ, конечно, модель дает в виде цифр (raw logits). Потом мы переводим их в вероятности (функцией softmax). А далее просто берем наибольшую вероятность из двух (это и есть бинарность ответа).

Неплохое видео про обучение би- и кросс- энкодеров тут.

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

Какую модель взять за базовую?

Пробовал две модели rubert-tiny2 и LaBSE-en-ru (далее просто labse). Обе хороши в русском. Ruberty быстрее. Обе с коммерческой лицензией (для labse смотрел тут, для rubert тут). По способности к дообучению и общему впечатлению выиграл labse. Интересно, что у меня labse часто как дефолтная модель для любой задачи векторизации текстов.

Кстати, у rubert модели сильно больше контекст - 2048 токенов. Для RAG это достаточно важный параметр. У labse - 512 токенов. Каждый токен содержит примерно 2-4 русские буквы (наглядно токенизацию смотреть тут). Наш labse будет обрезать входящий текст, если он превысит 512 токенов. Это достаточно неприятно. Текст вопроса вряд ли будет огромным, но вот текст ответа вполне может быть. И мы тут можем потерять информацию(

В итоге я выбрал LaBSE-en-ru (labse) как базовую модель.

Какой ембеддинг из модели использовать?

Вообще говоря есть несколько вариантов:

  • ембеддинг CLS токена

  • pooler output - это линейная проекция ембеддинга CLS токена

  • усреднение ембеддингов всех токенов фразы (обычно mean pooling)

Тут я долго думал и несколько раз менял мнение. Остановился на варианте pooler output.

Во-первый, pooler output тренировался вместе с самой моделью, как раз для задач классификации всей фразы (у нас фраза - это пара "вопрос-ответ"). Т.е. pooler output для этого и задумывался.

Во-вторых, так указано в примере на huggingface в карточке модели - embeddings = model_output.pooler_output. Думаю это не случайно.

Технически разницы нет, все эти три варианта - это вектора с размерностью 768 (по простому - 768 чисел float32).

Нужны ли дропауты (torch.nn.Dropout)?

Дропауты - это слои модели, которые зануляют определенный процент чисел. Это мотивирует модель искать разные закономерности, а не просто заучивать данные.

В модели ai-lab/ESGify_ru дропауты сделаны перед последним слоем. Интуитивно это кажется слишком сложно для модели, перед самым финалом обнулять случайно часть цифр. Поэтому дропаут я вставил перед первым слоем своего классификатора. И даже его, я включал не сразу, а только в конце обучения. Хотя в самой модели labse дропауты есть и включены в разных местах модели изначально

Сколько и какие слои нужны?

Слои конечно линейные нужны. Их еще называют полносвязные (nn.Linear).

ведь, чем больше слоев, тем лучше правда?)
ведь, чем больше слоев, тем лучше правда?)

У модели ESGify_ru было 2 слоя в классификаторе. Я подумал и решил сделать 3 слоя.

Был еще вопрос с размером слоев. У модели ESGify_ru формула: 768 -> 512 -> 48 (multilabel classification 48 labels на конце). Т.е. видно, что идет последовательное сокращение размера слоя.

Я решил сделать по другому: 768 -> 768*2 -> 768 -> 2 (у нас, бинарная классификация на конце). Я даю модели пространство для размышления. Вдохновился MLP слоем из nanogpt Andrej Karpathy (ссылка). Вообще там декодер трансформера создавали, но я решил рискнуть :)

И еще я убрал bias (смещения) финального третьего слоя классификатора. Вроде как это логично. Странно, но у модели ESGify_ru так не сделали (ссылка). Может быть я чего-то не знаю...

LayerNorm VS BatchNorm

Идея нормализации между батчами, говорят, работает неплохо (видео). Но идея смешения данных между разными примерами в батче - странная (расчет mean и std берет данные из разных примеров в рамках одного батча). Поэтому сделал LayerNorm.

Суть этих слоев - нормализация данных. Берем ряд чисел, вычитаем mean, делим на std, а далее, используя обучаемые параметры нейросети, делаем shift и scale (по сути восстанавливаем, то что убрали ранее, умножаем на новый std, плюсуем новый mean). Но теперь это обучаемые параметры.

Главное понять на каких слоях "куба данных" это происходит. Наглядно видно тут. LayerNorm считает статистику для каждого токена в каждом батче отдельно. Что для меня понятнее.

Нелинейность

Беспроигрышный вариант - ReLU. Его я делать не стал) Превращать все отрицательные значения в ноль - ощущается грубо, хотя и быстро. Решил попробовать GELU в надежде сделать классификатор очень умным. Это спорное решение.

Есть еще функции активации сигмоида, гиперболический тангенс, CeLU. Некоторые из них сразу нормируют данные, например сигмоида переводит любое число в интервал 0 до 1. Но там могут начать возникать проблемы затухающего градиента. Все-таки у нас 12 слоев энкодера трансформера, pooler layer и еще 3 слоя в классификаторе. Уже серьезная сеть.

Инициализация

Почти вся нейросеть инициализируется весами из labse. Они отлично натренированы.

А вот все слои моего классификатора инициализируется случайными числами, причем дефолтными методами библиотеки torch. Мне не понравился подход, когда по умолчанию bias линейных слоев инициализируется НЕ нулями. Детальнее можете посмотреть документацию torch. Я все же решил их занулить, так спокойнее:

# in-place реинициализация слоев модели в моем классификаторе
# третий слой (fc_3) этого не требует, т.к. там bias=False
torch.nn.init.zeros_(base_model.cls.fc_1.bias)
torch.nn.init.zeros_(base_model.cls.fc_2.bias)

Прочее

Eps (эпсилон) в LayerNorm (маленькое число для защиты от деления на ноль) - поставил как в энкодерных слоях базовой модели - labse, для унификации. Оно меньше, будет слабее влиять на результат, что и требуется (дефолтное в torch = 1e-5, в labse = 1e-12).


5. Обучающие данные

В отношении данных в идеале хотел так:

  • Данные должны быть на русском языке

  • Препроцессинг данных требоваться не должен (очистка, преобразование)

  • Данные должны быть уже размечены

  • Данные должны быть максимально общие (general domain), т.к. наша модель будет выполнять роль базовой для дальнейшего дообучения на конкретные задачи

  • Данных должно быть много

Если нужен англоязычный кросс-энкодер - то его без труда можно найти.

Форматы вопроса и части текста

В процессе RAG мы оцениваем релевантность двух объектов: вопрос и часть текста.

Вообще говоря, вопрос от пользователя в RAG может быть в форме:

  • просто вопроса, это классика ("где зимуют раки?")

  • ключевых слов, тегов, набор релевантных слов ("раки, зимовка, ракообразные")

  • саммари текста (т.е. вопросом является краткое содержание текста)

Часть текста обычно просто содержит много разного текста. Тут все относительно просто.

По идее, для получения хорошего кросс-энкодера надо тренировать его на всех трех видах вопросов. У меня получилось найти подходящие данные только для случая "просто вопросов". Остальное на развитие.

Источники данных

  • Можно взять готовые датасеты на русском, примерно подходящие по формату

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

  • Можно переводить английские датасеты (их обычно больше и изначальное качество лучше). Однако перевод на русский понизит качество и не позволит учесть специфику русского языка

  • Можно обучать на английских датасетах в надежде на transfer learning, т.е. на то, что и на русском нейросеть тоже чему-то научится. В целом идея интересная, т.к. labse изначально многоязычный (семантика любых языков переводится в единое векторное пространство). Данный подход может сработать.

Пока я выбрал самый простой путь. Взять готовые датасеты. Это позволит быстро получить результат и оценить жизнеспособность подхода (а-ля MVP). Далее можно уже будет расширять и усложнять.

from datasets import load_dataset

dataset_1 = load_dataset("RussianNLP/russian_super_glue", name='danetqa')
dataset_2 = load_dataset("RussianNLP/russian_super_glue", name='muserc')
dataset_3 = load_dataset("xquad", 'xquad.ru')

# load_dataset("sberquad") - тут была проблема со скачиванием, его не использовал

Взял три датасета и привел их к формату двух столбцов (вопрос-ответ). Причем все строчки - это позитивные примеры. Т.е. каждая строка отражает логичную пару.

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

Есть еще проблема с тем, что в данных может быть много вопросов к одному тексту. И есть вероятность, что эти строчки попадут в разные выборки (train/test) или по ним сгенерируется некорректный негативный пример. Но думаю критической роли это пока не играет.

Данные я сразу разделяю на train/validate в соотношении 80%/20%.

Насколько я понял, лицензия датасета russian_super_glue - MIT (ссылка).

С датасетом xquad сложнее, там, вроде как, cc-by-sa-4.0 (ссылка). Это требует указывать авторство и сохранять тип лицензии на продукт (ссылка), но допускает коммерческое использование. Поэтому, как я понял, на мою модель действует лицензия cc-by-sa-4.0. Хотелось бы полный MIT/Apache 2.0. Возможно в будущем заменю этот датасет.


6. Процесс обучения

Нужна gpu (видеокарта). В модели labse 130 млн. обучаемых весов. Весит модель более 500 Мбайт. На google colab сильно ограничено время gpu, поэтому обучал на kaggle (там GPU T4 х2, 30 часов в неделю). Можно конечно обучать и на cpu, но это очень долго. Cpu я использовал в том же kaggle как песочницу для разработки и тестирования архитектуры.

При написании кода вдохновлялся обучением из rubert, rubert-2, nanogpt. Отдельное спасибо авторам за полезные наработки.

Ниже интересные моменты из моего подхода к обучению:

Проблема несбалансированности классов

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

Метрики качества модели

Метрика улучшения качества одна - validation loss. Каждый раз, когда модель улучшает ее - сохраняем эту лучшую версию на диск.

В процессе обучения смотрю также на train loss и на accuracy. Но это скорее для общего понимания происходящего.

Ключевые настройки обучения

Интересно, что таких настроек не так много. Они показаны ниже. Указал некоторые комментарии по применению.

# повышаем пока не будет CUDA out of memory :)
# увеличивает скорость обучения, т.к. 1 батч обрабатывается на gpu за 1 проход
# усредняет обновление весов модели по нескольким примерам, что хорошо
# иногда даже применяют gradient accumulation
# это позволяет имитировать батч больше, чем допускает gpu (усреднение весов)
# технически реализуется задержкой обновления весов. Я решил это не применять
batch_size = 8

# тренируем пока тренируется (улучшается выбранная метрика качества)
iterations = 500

# это сколько раз я измеряю метрику в процессе обучения
# чем больше тем лучше
# т.к. позволяет чаше проверять и сохранять удачные состояния нейросети
# НО! это замедляет процесс обучение (чаще надо считать метрику)
eval_times = 50

# это сколько я беру батчей для оценки метрики
# опять же лучше брать вообще весь датасет (повышает точность метрики)
# но это очень долго
eval_iters = 20

# увеличивает/уменьшает влияния обучения на веса модели
# один из ключевых параметров тренировки
# ... но я так и не понял как его по научному устанавливать) см. ниже
learning_rate = 5e-5

Есть совет по определению learning rate в видео (ссылка 00:45:40 finding a good initial learning rate). На глаз, 5e-3 уже сильно быстро начинает прыгать loss. 5e-7 как то совсем медленно, никогда не видел, чтобы так ставили.

Вот и получается, что в среднем learning_rate должен быть около 5e-5.

Понравилась еще идея шедулера - CosineAnnealingWarmRestarts. Там скорость обучения периодически взбадривает модель, потом постепенно затухает. И так много раз. Это звучит логично. Возможно стоит добавить потом.

Заморозка весов модели

В labse 130 млн. параметров. Они отлично обучены, оптимальны и все с ними хорошо. Главное - аккуратно с ними обращаться.

В моем классификаторе около 2 млн. параметров. Они случайно инициализированы. Их надо сильно обучать, с нуля.

Поэтому я решил сделать три стадии обучения:

  1. Учим только веса классификатора. Остальные веса замораживаем. Это позволяет не бояться, что мы испортим веса labse.

  2. Учим всю модель. Была надежда, что классификатор станет такой умный, что labse трогать не потребуется. Но нет. Классификатора не достаточно. Тренировка всей модели приносит значительные положительные результаты.

  3. Эксперимент с регуляризацией (dropout классификатора и weight decay в оптимизаторе). На стадиях 1 и 2 dropout был только в слоях labse, weight decay - стандартный 0.01. На стадии 3 я планировал ощутимо увеличить регуляризацию, чтобы добавить модели обобщения.

    В начале каждой стадии обучения есть warmup период, когда я снижаю на 2 порядка learning rate. Это позволяет оптимизатору набрать статистику и привыкнуть к данным и к модели и не испортить при этом веса.

Соотношение размера градиентов и данных

Сначала пытался смотреть и контролировать соотношение размера градиентов и весов (mean по каждому слою в модели). Логика в том, что размер обновления весов должен быть ощутим. Если mean от градиента в миллиарды раз меньше, чем mean данных, то обучение будет проходить тяжело.

В итоге отказался от активного контроля этого показателя. Причина - сложно интерпретировать и влиять. Вот пример данных. Модель только что инициализирована:

Пример контроля
По логике такая модель вообще не может научиться ни чему. Градиенты сильно маленькие. А она учится...
По логике такая модель вообще не может научиться ни чему. Градиенты сильно маленькие. А она учится...

Поэтому в обучении всецело полагался на оптимизатор torch.optim.AdamW. Он как-то работал в этих условиях и весьма успешно. Умный)

Прочее

Еще я использую нормализацию градиента. По идее это должно стабилизировать обучение. Но не думаю, что значительно повлияло.

torch.nn.utils.clip_grad_norm_(pac.model.parameters(), max_norm = 1)

В остальном процесс обучения стандартный. Forward, backward, обновление весов.


7. Результаты обучения

Обучение заняло примерно 70 минут.

Стадия обучения 1. Учим голову (классификатор)

Эта стадия прошла нормально. Голову подучили. Главный вывод - обучением только головы не обойтись. Надо учить всю модель. И главное теперь веса модели и головы примерно одинаково обучены, можно учить их одновременно. В этом и был смысл данной стадии.

Вообще для меня это было странно, я думал классификатор просто поймет labse и дальнейшее обучение самого labse не потребуется. Т.к. трогать веса labse это ответственное дело, можно все испортить.

История лосса и его график
Классификатор учится, но все-таки не может сам решить задачу. Лосс упал с 0.84 до 0.63. Далее обучение почти не идет. Вернее лосс даже растет
Классификатор учится, но все-таки не может сам решить задачу. Лосс упал с 0.84 до 0.63. Далее обучение почти не идет. Вернее лосс даже растет
сглаженный график лосса для наглядности
сглаженный график лосса для наглядности

Стадия обучения 2. Учим всю модель

Хорошо прошла. Модель смогла хорошо научиться и обобщить знания.

В целом это показывает, что данная задача и эти данные являются относительно простыми для нейрости.

История лосса и его график
Лосс падает практически до нуля на валидационной выборке, это хорошо. Точность почти 100%
Лосс падает практически до нуля на валидационной выборке, это хорошо. Точность почти 100%
сглаженный график лосса для наглядности
сглаженный график лосса для наглядности

Стадия обучения 3. Эксперимент с регуляризацией

Решение повышать регуляризацию только на стадии 3 - спорное. В конце данной стадии модель все же улучшила метрику качества и эту версию весов я считаю финальной. В целом регуляризация должна была добавить модели обобщения и устойчивости к новым данным.

Думаю, что dropout можно было включать в классификаторе сразу на стадиях 1 и 2. А не делать отдельную стадию обучения с ними.

История лосса и его график
последняя часть принтов loss. В самом конце модель все таки улучшила метрику!!!
последняя часть принтов loss. В самом конце модель все таки улучшила метрику!!!
сглаженный график лосса для наглядности
сглаженный график лосса для наглядности

Субъективное ощущение от модели

Вспомним наш эксперимент с моделью cross-encoder/ms-marco-MiniLM-L-12-v2. Она позиционируется как готовая, но на русском языке делает явные ошибки. Попробуем дать этот пример в нашу модель:

questions = ['где можно взять лодку на прокат?', 
             'где можно взять лодку на прокат?']
answers = ['взятие тракторов для пользования не предусмотрено', 
            'небольшой катер обычно берут в аренду у берега']

pac.model(list(zip(questions, answers))).argmax(1)
# tensor([0, 1], device='cuda:0')
# наша модель поняла, что первая пара - не логичная
# это конечно всего лишь один пример, но уже приятно

pac.model(list(zip(questions, answers)))
# tensor([[-0.1967, -1.1687],
#         [-3.2954,  2.5626]], grad_fn=<MmBackward0>)
# кроме того наша модель точно знает, что вторая пара - логичная
# а вот насчет первой пары... средняя степень уверенности
# но тем не менее, достаточно явно перевешывает НЕлогичность такой пары

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


8. Выводы

В целом я понял следующее:

  • вполне возможно спроектировать и натренировать нейросеть для решения прикладной задачи

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

  • одна из основных сложностей - необходимость качественных обучающих данных в достаточном объеме

  • kaggle дает 30 часов gpu еженедельно, это круто!

  • при выборе модели и датасетов сразу стоит смотреть на лицензии

В статей постарался минимизировать объем кода, kaggle notebook доступен по ссылке, в нем весь процесс обучения и подготовки данных.

На huggingface пока загружать не стал. Ряд моментов требуют более глубокой проработки. Плюс лицензии не та, какую я хотел (cc-by-sa-4.0).

Tags:
Hubs:
Total votes 14: ↑14 and ↓0+14
Comments9

Articles