MIT 6.S191, лабораторная 3: дообучение LLM «в стиле Йоды»
Полный перевод условия третьей лабораторной MIT Introduction to Deep Learning 2026: шаблоны чата и токенизация, генерация следующего токена, дообучение LFM2-1.2B с LoRA под стиль лепрекона и магистра Йоды, оценка стиля методом «LLM как судья» через OpenRouter и Opik, трассировка вызовов.
Перевод условия лабораторной 3 курса MIT 6.S191 (выпуск 2026 года). Карта курса — в путеводителе, к лабораторной относится лекция 6 о больших языковых моделях и лекция 7 об оценке LLM.
Лабораторная 3: дообучение большой языковой модели (LLM)
Заголовок раздела «Лабораторная 3: дообучение большой языковой модели (LLM)»В этой лабораторной вы дообучите большую языковую модель (LLM) с несколькими миллиардами параметров. Мы пройдём несколько фундаментальных понятий LLM, включая токенизацию, шаблоны и дообучение (fine-tuning). Лабораторная даёт полный конвейер дообучения языковой модели генерировать ответы в определённом стиле, и вы изучите не только дообучение языковой модели, но и способы оценки её качества.
В качестве базовой языковой модели для дообучения вы будете использовать LFM2-1.2B от Liquid AI; в качестве модели-«судьи» для оценки — модель Google Gemini 2.5; а в качестве фреймворка для удобной оценки LLM — Opik от Comet ML.
Сначала скачаем пакет MIT deep learning, установим зависимости и импортируем нужные для этой лабораторной пакеты.
# Устанавливаем и импортируем утилиты MIT Deep Learning!pip install mitdeeplearning > /dev/null 2>&1import mitdeeplearning as mdlimport osimport jsonimport numpy as npfrom tqdm import tqdmimport matplotlib.pyplot as plt
import torchfrom torch.nn import functional as Ffrom torch.utils.data import DataLoader
from transformers import AutoTokenizer, AutoModelForCausalLMfrom datasets import load_datasetfrom peft import LoraConfig, get_peft_modelfrom lion_pytorch import LionЧасть 1. Дообучение LLM под стиль
Заголовок раздела «Часть 1. Дообучение LLM под стиль»В первой части лабораторной мы дообучим LLM как чат-бота, который генерирует ответы в определённом стиле. В качестве базовой языковой модели для дообучения мы возьмём модель Liquid AI LFM2-1.2B.
1.1. Шаблоны и токенизация
Заголовок раздела «1.1. Шаблоны и токенизация»1.1.1. Шаблоны
Заголовок раздела «1.1.1. Шаблоны»Языковые модели, работающие как чат-боты, умеют генерировать ответы на вопросы пользователей, — но как они это делают? Нужно дать им способ понимать разговор и отвечать связно — некую структуру: что вход, а что выход.
Шаблоны (templating) — способ оформлять входы и выходы в единообразную структуру, понятную языковой модели. Для этого добавляют специальные токены или маркеры, обозначающие разные части разговора: кто говорит, где начинаются и заканчиваются реплики. Такая структура помогает модели выучить правильный формат ответов и поддерживать связный ход разговора. Без шаблонов модель может не понимать, как правильно оформить выход, и не различать собеседников в разговоре.
Начнём с того, что определим простые шаблоны для чат-бота на основе LFM2 — для реплик, где пользователь задаёт вопрос, а модель отвечает.
# Простой шаблон «вопрос — ответ»template_without_answer = "<|startoftext|><|im_start|>user\n{question}<|im_end|>\n<|im_start|>assistant\n"template_with_answer = template_without_answer + "{answer}<|im_end|>\n"
# Подставим что-нибудь в шаблон и посмотрим, как это выглядитprint(template_with_answer.format(question="What is your name?", answer="My name is Lili!"))1.1.2. Токенизация
Заголовок раздела «1.1.2. Токенизация»Чтобы работать с языком, текст нужно подготовить для модели. По сути, язык можно считать последовательностью «кусочков» текста. Можно разбить текст на отдельные кусочки, а затем сопоставить им числовые токены — всё вместе это процесс токенизации. Затем числовые токены можно подать в языковую модель.
Есть несколько распространённых подходов к токенизации текста на естественном языке:
- Токенизация по словам: разбивает текст на отдельные слова. Это просто, но может давать огромные словари и плохо справляется с незнакомыми словами.
- Токенизация по символам: разбивает текст на отдельные символы. Словарь получается совсем маленьким, но последовательности длинные, а смысл на уровне слов теряется.
- Токенизация по подсловам: разбивает слова на меньшие единицы (подслова) в зависимости от их частоты. Самый популярный и распространённый подход — кодирование парами байтов (BPE), которое итеративно сливает самые частые пары символов. Современные языковые модели обычно используют токенизацию по подсловам: она уравновешивает размер словаря и длину последовательности и хорошо справляется с незнакомыми словами, разбивая их на известные подслова.
В этой лабораторной мы будем использовать токенизатор модели LFM2, основанный на BPE. Загрузим его и посмотрим.
# Загружаем токенизатор Liquid AI LFM2-1.2Bmodel_id = "LiquidAI/LFM2-1.2B"tokenizer = AutoTokenizer.from_pretrained(model_id)
# Насколько велик токенизатор?print(f"Vocab size: {len(tokenizer.get_vocab())}")Нам нужно не только уметь разбивать текст на токены (кодировать), но и превращать токены обратно в текст (декодировать). У нашего токенизатора будут:
- функция
encode, которая превращает текст в токены, и - функция
decode, которая превращает токены обратно в текст, чтобы мы могли прочитать выход модели.
Проверим оба шага и посмотрим на результат, чтобы лучше понять, как это работает.
# Проверим оба шага:text = "Here is some sample text!"print(f"Original text: {text}")
# Токенизируем текстtokens = tokenizer.encode(text, return_tensors="pt")print(f"Encoded tokens: {tokens}")
# Декодируем токеныdecoded_text = tokenizer.decode(tokens[0], skip_special_tokens=True)print(f"Decoded text: {decoded_text}")Отлично. Теперь у нас есть способ входить в пространство токенов и выходить из него.
Чтобы «поговорить» с нашим чат-ботом, нужно использовать токенизатор вместе с шаблоном чата — тогда модель ответит на вопрос пользователя. Определённые ранее шаблоны позволяют составить промпт для модели без ответа.
prompt = template_without_answer.format(question="What is the capital of France? Use one word.")print(prompt)Если подать это в модель, она увидит, что сейчас начинается её реплика, и сгенерирует ответ на вопрос.
1.2. Начинаем работать с LLM
Заголовок раздела «1.2. Начинаем работать с LLM»Теперь, когда мы умеем готовить данные, можно работать с LLM!
LLM вроде LFM2 обучаются на большом корпусе текстов задаче предсказания следующего токена последовательности по предыдущим токенам. Эту задачу обучения называют «предсказанием следующего токена» (next token prediction); можно встретить и названия «каузальное языковое моделирование» или «авторегрессионное языковое моделирование». Модели, обученные так, можно использовать для генерации нового текста, выбирая токены из предсказанного распределения вероятностей следующего токена.
Загрузим модель LFM2 и начнём с ней работать. Мы составим промпт по шаблону чата и токенизируем его. Затем подадим его в модель, чтобы предсказать вероятности следующего токена. Наконец, возьмём следующий токен (пока ещё числовой) и декодируем его в текст.
# Загружаем модель — это может занять несколько минутmodel = AutoModelForCausalLM.from_pretrained(model_id, device_map="auto")### Соединяем всё вместе: подаём промпт модели и получаем ответ ###
# 1. Составляем промпт по шаблону чатаquestion = "What is the capital of France? Use one word."prompt = template_without_answer.format('''TODO''') # TODO
# 2. Токенизируем промптtokens = tokenizer.encode(prompt, return_tensors="pt").to(model.device)
# 3. Пропускаем через модель, чтобы предсказать вероятности следующего токенаwith torch.no_grad(): output = '''TODO''' # TODO
probs = F.softmax(output.logits, dim=-1)
# 4. Берём следующий токен — с максимальной вероятностьюnext_token = torch.argmax(probs[0, -1, :]).item()
# 5. Декодируем следующий токенnext_token_text = '''TODO''' # TODO
print(f"Prompt: {prompt}")print(f"Predicted next token: {next_token_text}")Заметьте: модель не умеет «предсказать ответ на вопрос» — она умеет только предсказать следующий токен последовательности! Для более сложных вопросов одного токена мало — нужно сгенерировать последовательность токенов.
Это можно сделать, повторяя процесс выше шаг за шагом: после каждого шага подавать сгенерированный токен обратно в модель и снова предсказывать следующий токен.
Вместо того чтобы делать это вручную, можно использовать встроенную функцию модели
model.generate()
(её поддерживает библиотека Transformers от Hugging Face): она генерирует max_new_tokens токенов, а затем выход
можно декодировать обратно в текст.
prompt = template_without_answer.format(question="What does MIT stand for?")tokens = tokenizer.encode(prompt, return_tensors="pt").to(model.device)output = model.generate(tokens, max_new_tokens=20)print(tokenizer.decode(output[0]))Теперь у нас есть базовый конвейер генерации текста с помощью LLM!
1.3. Дообучение
Заголовок раздела «1.3. Дообучение»Дообучение (fine-tuning) — техника, позволяющая приспособить предобученную нейросеть к последующей задаче, предметной области или стилю, продолжив обучение модели на новых данных. Продолжая обучение на тщательно подобранном наборе данных, можно изменить поведение, стиль или возможности модели. Дообучение применяется во многих областях, не только в языковом моделировании. Но в языковом моделировании с его помощью можно:
- изменить стиль письма модели;
- улучшить качество на определённых задачах или в определённых областях;
- научить модель новым умениям или знаниям;
- уменьшить нежелательное поведение или смещения.
В этой лабораторной вы дообучите LLM LFM2, чтобы изменить её стиль письма. Вспомните: в лабораторной 1 вы строили модель последовательностей на основе RNN для генерации ирландских народных мелодий. Продолжая ирландскую тему, мы сначала дообучим LLM разговаривать в стиле лепрекона.
В оригинале здесь анимация с танцующими людьми.
Мы подготовили набор данных «вопрос — ответ», в котором вопросы написаны обычным английским языком («базовый» стиль), а ответы — в стиле «лепрекона» (их написала другая LLM). Загрузим набор данных и посмотрим на него.
train_loader, test_loader = mdl.lab3.create_dataloader(style="leprechaun")
sample = train_loader.dataset[44]question = sample['instruction']answer = sample['response']answer_style = sample['response_style']
print(f"Question: {question}\n\n" + f"Original Answer: {answer}\n\n" + f"Answer Style: {answer_style}")1.3.1. Функция чата
Заголовок раздела «1.3.1. Функция чата»Прежде чем начать дообучение, напишем функцию, чтобы было удобно общаться с моделью: и следить за её прогрессом по ходу дообучения, и генерировать ответы на вопросы.
Вспомним основные шаги:
- Составить промпт с вопросом по шаблону.
- Токенизировать текст.
- Пропустить токены через модель, чтобы предсказать вероятности следующего токена.
- Декодировать предсказанные токены обратно в текст.
С помощью этих шагов напишите функцию chat ниже.
def chat(question, max_new_tokens=32, temperature=0.7, only_answer=False): # 1. Составляем промпт по шаблону prompt = template_without_answer.format('''TODO''') # TODO
# 2. Токенизируем текст input_ids = tokenizer('''TODO''', '''TODO''').to(model.device) # TODO
# 3. Пропускаем через модель, чтобы предсказать вероятности следующего токена with torch.no_grad(): outputs = model.generate('''TODO''', do_sample=True, max_new_tokens=max_new_tokens, temperature=temperature) # TODO
# 4. Возвращаем только ответ, если only_answer=True output_tokens = outputs[0] if only_answer: output_tokens = output_tokens[input_ids['input_ids'].shape[1]:]
# 5. Декодируем токены result = tokenizer.decode('''TODO''', skip_special_tokens=True) # TODO
return resultПопробуем поговорить с моделью и проверить, работает ли функция! Вот пример вопроса (продолжаем ирландскую тему); попробуйте и другие вопросы!
# Попробуем поговорить с моделью и проверить, работает ли функция!answer = chat( "What is the capital of Ireland?", only_answer=True, max_new_tokens=32,)
print(answer)
### TODO: поэкспериментируйте с разными вопросами и значениями температуры и посмотрите, как отвечает модель!1.3.2. Эффективное по параметрам дообучение
Заголовок раздела «1.3.2. Эффективное по параметрам дообучение»При дообучении веса модели обновляются, чтобы лучше соответствовать набору данных и/или задаче дообучения. Обновлять все веса языковой модели вроде LFM2-1.2B — примерно с миллиардом параметров — вычислительно дорого. Есть много техник, которые делают дообучение эффективнее.
Мы используем технику LoRA (low-rank adaptation — низкоранговая адаптация), чтобы
сделать дообучение эффективнее. LoRA позволяет очень эффективно дообучать LLM, обновляя лишь небольшую часть параметров
модели: к модели добавляются обучаемые низкоранговые матрицы. Мы не будем вдаваться в детали LoRA, о ней можно
прочитать в статье о LoRA. Чтобы применить LoRA к модели LFM, мы используем
библиотеку peft.
# LoRA — способ очень эффективно дообучать LLM, обновляя лишь небольшую часть параметров модели
def apply_lora(model): # Задаём конфигурацию LoRA lora_config = LoraConfig( r=8, # ранг матриц LoRA task_type="CAUSAL_LM", target_modules=[ "q_proj", "o_proj", "k_proj", "v_proj", "gate_proj", "up_proj", "down_proj" ], )
# Применяем LoRA к модели lora_model = get_peft_model(model, lora_config) return lora_model
model = apply_lora(model)
# Печатаем число обучаемых параметров после применения LoRAtrainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)total_params = sum(p.numel() for p in model.parameters())print(f"number of trainable parameters: {trainable_params}")print(f"total parameters: {total_params}")print(f"percentage of trainable parameters: {trainable_params / total_params * 100:.2f}%")Как это читать (пояснение переводчика): LoRA замораживает матрицу весов размера и учит добавку из двух узких матриц: размера и размера . При и это обучаемых параметров вместо млн — в 128 раз меньше.
1.3.3. Прямой проход и вычисление потери
Заголовок раздела «1.3.3. Прямой проход и вычисление потери»Теперь определим функцию, которая выполняет прямой проход через LLM и вычисляет потерю. Прямой проход даёт логиты —
они отражают распределение вероятностей следующего токена. Потерю можно вычислить, сравнив предсказанные логиты
с истинным следующим токеном — нашей целевой меткой. Заметьте: по сути, это задача классификации! Значит, потерю можно
выразить кросс-энтропией и вычислить функцией PyTorch
nn.functional.cross_entropy.
def forward_and_compute_loss(model, tokens, mask, context_length=512): # Обрезаем до длины контекста tokens = tokens[:, :context_length] mask = mask[:, :context_length]
# Строим вход, выход и маску x = tokens[:, :-1] y = tokens[:, 1:] mask = mask[:, 1:]
# Прямой проход: вычисляем логиты logits = model(x).logits
# Вычисляем потерю loss = F.cross_entropy( logits.view(-1, logits.size(-1)), y.view(-1), reduction="none" )
# Маскируем потерю для токенов, которые не относятся к ответу loss = loss[mask.view(-1)].mean()
return loss1.3.4. Цикл обучения для дообучения
Заголовок раздела «1.3.4. Цикл обучения для дообучения»С этой функцией вычисления потери можно определить цикл обучения, который дообучает модель с LoRA. В нём те же основные компоненты, что мы видели в других лабораторных:
- Взять батч данных из набора (через DataLoader).
- Пропустить данные через модель — выполнить прямой проход и вычислить потерю.
- Обратный проход — обновить веса модели.
Данные в нашем DataLoader — это изначально текст, не оформленный по нашему шаблону «вопрос — ответ». Поэтому на шаге (1) нужно оформить данные по определённому ранее шаблону, а затем токенизировать текст.
Нас интересует ответ модели на вопрос: токены «ответа» — та часть текста, которую мы хотим предсказывать и по которой считаем потерю. Поэтому после токенизации нужно указать модели, какие токены относятся к «ответу», а какие — к «вопросу». Для этого вычислим маску токенов ответа и используем её при вычислении потери.
Наконец, выполним обратный проход, чтобы обновить веса модели.
Соберём всё вместе в цикле обучения ниже.
### Цикл обучения ###
def train(model, dataloader, tokenizer, max_steps=200, context_length=512, learning_rate=1e-4): losses = []
# Применяем LoRA к модели model = '''TODO''' # TODO
optimizer = Lion(model.parameters(), lr=learning_rate)
# Цикл обучения for step, batch in enumerate(dataloader): question = batch["instruction"][0] answer = batch["response_style"][0]
# Оформляем вопрос и ответ по шаблону text = template_with_answer.format('''TODO''', '''TODO''') # TODO
# Токенизируем текст и вычисляем маску ответа ids = tokenizer(text, return_tensors="pt", return_offsets_mapping=True).to(model.device) mask = ids["offset_mapping"][:,:,0] >= text.index(answer)
# Пропускаем токены через модель и вычисляем потерю loss = forward_and_compute_loss('''TODO''') # TODO
# Обратный проход optimizer.zero_grad() loss.backward() optimizer.step()
losses.append(loss.item())
# следим за прогрессом if step % 10 == 0: print(chat("What is the capital of France?", only_answer=True)) print(f"step {step} loss: {torch.mean(torch.tensor(losses)).item()}") losses = []
if step > 0 and step % max_steps == 0: break
return modelПримечание переводчика: template_with_answer содержит именованные поля, поэтому вызывать его нужно с именованными
аргументами: template_with_answer.format(question=question, answer=answer). Маска строится по смещениям символов
(offset_mapping): токен считается частью ответа, если он начинается не раньше позиции, где в тексте начинается ответ.
По замыслу авторов на месте первого TODO вызывается apply_lora(model). Учтите, что в разделе 1.3.2 модель уже
обёрнута LoRA: при повторных запусках ячеек следите, чтобы адаптеры не накладывались несколько раз (надёжнее всего
перед новым обучением заново загрузить базовую модель).
# Вызовите функцию train, чтобы дообучить модель! Подсказка: результаты начнут появляться не раньше чем через 100 шагов.model = train('''TODO''') # TODOПопробуем поговорить с моделью снова и посмотрим, как она изменилась!
print(chat("What is a good story about tennis", only_answer=True, max_new_tokens=200))Часть 2. Оценка LLM, дообученной под стиль
Заголовок раздела «Часть 2. Оценка LLM, дообученной под стиль»Как понять, хорошо ли работает модель? Насколько её стиль похож на стиль лепрекона? Как видно из примера выше, решение о том, хорош ли сгенерированный ответ, может казаться качественным (субъективным), и измерить качество модели трудно.
Для оценки языковых моделей на разных задачах разработаны бенчмарки, но они не всегда отражают качество модели в реальном мире. Например, модель может хорошо проходить бенчмарк, но плохо справляться с более реалистичной задачей. Кроме того, бенчмарки ограничены набором задач, которые они охватывают, и способностями, которые они отражают, и бывают опасения, что данные бенчмарка использовались при обучении модели. Генерация синтетических данных и синтетические задачи — способ обойти эти ограничения; это активная область исследований.
Качественную оценку сгенерированного ответа можно сделать и количественной, если поручить кому-то или чему-то «судить» выходы. В этой лабораторной мы сделаем именно это с помощью техники LLM как судья (LLM as a judge): более крупная LLM оценивает выходы меньшей. Большая LLM выступает судьёй; ей дают системный промпт, описывающий задачу, которую должна решать меньшая LLM, и критерии оценки. «Системный промпт» — способ задать общий контекст и направить поведение LLM. С этим системным промптом LLM-судья может оценивать выходы меньшей LLM, а по этим оценкам мы можем судить, насколько хорошо справляется меньшая модель.
2.1. Хорошо дообучить, ты должен!
Заголовок раздела «2.1. Хорошо дообучить, ты должен!»Наша модель, дообученная на лепреконе, уже неплохо генерирует ответы в стиле лепрекона. Не иначе, ирландское везение.
Сделаем задачу интереснее и возьмём другой стиль — с явными закономерностями, но и с большой изменчивостью и простором для творчества. Мы используем стиль Йоды из «Звёздных войн».
В оригинале здесь анимация с магистром Йодой.
Ваша цель — дообучить модель отвечать в стиле Йоды, с помощью LLM-судьи оценить, насколько выходы вашей чат-модели следуют речи Йоды, а затем использовать эту информацию, чтобы улучшить модель.
# Загружаем набор данных с речью Йоды и дообучаем модель своей функцией обученияtrain_loader, test_loader = mdl.lab3.create_dataloader(style="yoda")model = train('''TODO''') # TODOНачните с того, что задайте системный промпт для LLM-судьи: объясните, что ему предстоит оценивать, насколько выходы вашей чат-модели следуют речи Йоды. Поэкспериментируйте с разными системными промптами и посмотрите, как они влияют на оценку LLM-судьи! Помните: чем лучше LLM-судья, тем лучше вы оцените, насколько хороша ваша модель Йоды, а лучшая оценка поможет вам улучшить модель Йоды.
### LLM как судья ###
'''TODO: поэкспериментируйте с разными системными промптами и посмотрите, как они влияют на оценку LLM-судьи! Вернитесь к этой ячейке, когда сгенерируете какой-нибудь текст своей моделью.'''
system_prompt = """You are an impartial judge that evaluates if text was written by {style}.
An example piece of text from {style} is:{example}
Now, analyze some new text carefully and respond on if it follows thesame style of {style}. Be critical to identify any issues in the text.Then convert your feedback into a number between 0 and 10: 10 if the textis written exactly in the style of {style}, 5 if mixed faithfulness to thestyle, or 0 if the text is not at all written in the style of {style}.
Directly answer with the score formatted in a dictionary.The format of your response should only be the dictionary and nothing else:{{"score": <score between 0 and 10>}}"""
style = "Yoda"example = "The very Republic is threatened, if involved the Sith are. Hard to see, the dark side is. Discover who this assassin is, we must. With this Naboo queen you must stay, Qui-Gon. Protect her. May the Force be with you. A vergence, you say? But you do! Revealed your opinion is. Trained as a Jedi, you request for him? Good, good, young one."
system_prompt = system_prompt.format(style=style, example=example)print("=== System prompt ===")print(system_prompt)Перевод системного промпта (для понимания; модели передаётся английский текст): «Ты беспристрастный судья, который
определяет, написан ли текст в стиле style. Пример текста в стиле style: example. Теперь внимательно проанализируй новый текст
и ответь, следует ли он тому же стилю. Будь критичен и найди любые проблемы в тексте. Затем переведи свой отзыв в число
от 0 до 10: 10 — если текст написан точно в стиле, 5 — если стиль соблюдён частично, 0 — если текст совсем не в этом
стиле. Отвечай сразу оценкой в виде словаря. Ответ должен содержать только словарь и ничего больше:
{"score": <оценка от 0 до 10>}». Пример текста — реплики Йоды из фильма с характерным обратным порядком слов
(«Hard to see, the dark side is» — «Трудно увидеть, тёмная сторона есть»).
2.2. Настраиваем LLM-судью
Заголовок раздела «2.2. Настраиваем LLM-судью»В подходе «LLM как судья» нужна модель крупнее (а значит, способнее), чем наша модель-«исполнитель» — в нашем случае LFM2 1.2B, дообученная под стиль. Загрузить модели крупнее локально в ноутбук нереально, поэтому вы получите опыт работы с такими большими LLM через API сервиса OpenRouter.
Вам нужно зарегистрироваться в OpenRouter и создать API-ключ. Запуск мощных LLM такого масштаба стоит денег — студентам очного курса мы можем начислить кредит на аккаунт OpenRouter для этой лабораторной. Приходите за ним в часы консультаций.
Через интерфейс OpenRouter вы сможете экспериментировать с разными LLM-судьями — для начала мы предлагаем одну из
крупных моделей: Google Gemini 2.5 (google/gemini-2.5-flash). На OpenRouter есть и бесплатные модели (например,
gemma-2-9b-it:free), но при частом использовании они упрутся в ограничения на число запросов.
Мы определили простой класс LLMClient для работы с API OpenRouter. У него есть метод ask, который принимает промпт
пользователя и возвращает ответ модели. Помните, что ответ LLM-судьи зависит от системного промпта, который вы зададите, —
системный промпт критически важен для задания критериев оценки!
OPENROUTER_API_KEY = "" # TODO: впишите сюда свой API-ключ OpenRouterassert OPENROUTER_API_KEY != "", "You must set your OpenRouter API key before running this cell!"
model_name = "google/gemini-2.5-flash"llm = mdl.lab3.LLMClient(model=model_name, api_key=OPENROUTER_API_KEY)2.3. Определяем метрику оценки
Заголовок раздела «2.3. Определяем метрику оценки»Отлично! Мы настроили LLM-судью, но оценку ещё нужно сделать количественной. Для этого определим метрику, которая с помощью LLM-судьи оценивает выходы модели. Это удобно делать с библиотекой Opik от Comet ML — платформой для оценки и сравнения LLM.
В предыдущих лабораторных мы использовали Comet для отслеживания экспериментов, так что аккаунт и API-ключ у вас должны быть. Если нет, зарегистрируйтесь в Comet. Теперь с помощью библиотеки Comet Opik мы определим метрику, которая оценивает выходы модели LLM-судьёй.
Opik даёт каркас для создания собственных метрик-судей, а также множество готовых метрик для типичных задач оценки. Эти
метрики помогают быстро и эффективно оценить качество выходов LLM; среди них — галлюцинации (Hallucination), релевантность
ответа (Answer Relevance), точность и полнота контекста (Context Precision/Recall) и другие. Подробнее о доступных
метриках — в разделе Metrics Overview документации Opik.
В Python SDK Opik есть базовый класс для определения метрик —
base_metric.BaseMetric.
С его помощью вы определите собственную метрику, которая LLM-судьёй оценивает, насколько текст следует речи Йоды.
Заметьте: LLM-судью и метрику можно применять к любому тексту, не только к выходам модели. Это важно помнить, ведь для
сравнения с генерациями модели нам нужны и отрицательный контроль — текст в «базовом» стиле обычного английского, —
и положительный контроль — текст из обучающего набора в стиле речи Йоды.
Задайте критерии оценки в системном промпте и определите функцию score, которая оценивает текст, обращаясь
к LLM-судье.
from opik.evaluation.metrics import base_metric, score_result
class LLMJudgeEvaluator(base_metric.BaseMetric): def __init__(self, judge: mdl.lab3.LLMClient = None, system_prompt: str = None): self.judge = judge self.system_prompt = system_prompt self.prompt_template = "Evaluate this text: {text}"
def score(self, text: str, n_tries=20, **kwargs): """ Оцениваем текст, попросив LLM поставить оценку. """
for attempt in range(n_tries): try: # TODO: оформите текст по шаблону, прежде чем передать его LLM-судье prompt = self.prompt_template.format('''TODO''') # TODO
# TODO: вызовите LLM-судью с системным промптом и шаблоном промпта. res = self.judge.ask( system='''TODO''', user='''TODO''', max_tokens='''TODO''' ) # TODO
# Извлекаем содержимое ответа ассистента из ответа API res = res.choices[0].message.content res_dict = json.loads(res)
max_score = 10 # Максимальная оценка, которую должна выдавать LLM score = res_dict["score"] / max_score # Нормируем score = max(0.0, min(score, 1.0)) # Обрезаем до отрезка от 0 до 1
# Возвращаем объект с оценкой return score_result.ScoreResult(name="StyleScore", value=score)
except Exception as e: if attempt == n_tries - 1: # Последняя попытка raise e # Пробрасываем исключение, если все попытки не удались continue # Пробуем снова, если попытка не последняяСоздайте своего судью Comet Opik через класс LLMJudgeEvaluator и системный промпт.
judge = LLMJudgeEvaluator(llm, system_prompt=system_prompt)2.4. Оцениваем модель оценками LLM-судьи
Заголовок раздела «2.4. Оцениваем модель оценками LLM-судьи»Теперь LLM-судьёй можно оценивать выходы модели. Для оценки текста LLM-судьёй мы будем использовать scoring_function.
Подайте несколько пробных предложений, чтобы «на глаз» проверить LLM-судью.
def scoring_function(text): return judge.score(text).value
test_texts = [ "Tennis is a fun sport. But you must concentrate.", "Fun sport, tennis is. But work hard, you must.", "Hard to see, the dark side is."]
for text in test_texts: score = scoring_function(text) print(f"{text} ==> Score: {score}")Пояснение переводчика: первое предложение — обычный английский («Теннис — весёлый спорт. Но нужно сосредоточиться»), второе — то же самое «по-йодовски» («Весёлый спорт, теннис есть. Но трудиться, ты должен»), третье — настоящая реплика Йоды. Хороший судья должен поставить первому низкую оценку, а второму и третьему — высокие.
Мы оценим, насколько хорошо работает дообученная модель, оценив её выходы, а также текст в базовом стиле (отрицательный контроль) и текст обучающего набора в стиле речи Йоды (положительный контроль).
Сгенерируйте текст своей моделью, задав ей новые вопросы.
# Генерируем текст своей моделью, задавая ей новые вопросы.def generate_samples_from_test(test_loader, num_samples): samples = [] for test_sample in tqdm(test_loader, total=num_samples): test_question = test_sample['instruction'][0] with torch.no_grad(): generated = chat(test_question, only_answer=True, max_new_tokens=100) samples.append(generated) if len(samples) >= num_samples: break return samples
n_samples = 20generated_samples = generate_samples_from_test(test_loader, num_samples=n_samples)Соберём также немного текста в базовом стиле (base_samples) и текста обучающего набора в стиле речи Йоды
(style_samples). Их генерировать не нужно — этот текст уже есть в наборе данных.
base_samples = [sample['response'][0] for i, sample in enumerate(train_loader) if i < n_samples]style_samples = [sample['response_style'][0] for i, sample in enumerate(train_loader) if i < n_samples]Теперь, когда примеры собраны, их можно оценить LLM-судьёй. Мы используем многопроцессную функцию оценки, чтобы оценивать примеры параллельно: каждый пример независим, и все их можно отправить LLM-судье одновременными запросами.
# Создаём многопроцессную функцию оценки, чтобы оценивать примеры параллельно
os.environ["TOKENIZERS_PARALLELISM"] = "false"from multiprocessing import Pool
def compute_scores_in_parallel(samples): with Pool(processes=10) as pool: scores = pool.map(scoring_function, samples) return scores
# Вычисляем и печатаем оценки для текста в базовом стиле, сгенерированного текста и текста обучающего набора в стиле Йодыbase_scores = compute_scores_in_parallel(base_samples)print(f"Base: {np.mean(base_scores):.2f} ± {np.std(base_scores):.2f}")
generated_scores = compute_scores_in_parallel(generated_samples)print(f"Gen: {np.mean(generated_scores):.2f} ± {np.std(generated_scores):.2f}")
style_scores = compute_scores_in_parallel(style_samples)print(f"Train: {np.mean(style_scores):.2f} ± {np.std(style_scores):.2f}")Посмотрите на средние оценки каждого из трёх видов текста — что вы видите?
Можно также нарисовать распределение оценок для каждого из трёх видов текста.
import seaborn as snsimport pandas as pd
# Собираем аккуратную таблицу DataFramedf = pd.DataFrame({ 'Score': [*base_scores, *generated_scores, *style_scores], 'Type': ['Base']*len(base_scores) + ['Generated']*len(generated_scores) + ['Style']*len(style_scores)})
# Рисуем с помощью seabornsns.histplot(data=df, x='Score', hue='Type', multiple="dodge", bins=6, shrink=.8)
plt.title('Distribution of Scores')plt.show()Используйте эти наблюдения, чтобы улучшить модель. Помните, что LLM-судья не идеален, и можно попробовать улучшить и его, чтобы он лучше оценивал выходы модели. Чем лучше LLM-судья, тем лучше вы оцените, насколько хороша ваша модель Йоды, а лучшая оценка поможет её улучшить.
2.5. Мониторинг с помощью оценок
Заголовок раздела «2.5. Мониторинг с помощью оценок»Так же, как мы использовали Opik для метрик оценки при дообучении и тестировании, Opik можно использовать и для мониторинга LLM, когда она уже работает в продакшене. Так легко отслеживать одни и те же метрики и при разработке, и при эксплуатации.
В предыдущих лабораторных мы использовали Comet для отслеживания экспериментов, так что аккаунт и API-ключ у вас должны быть. Если нет, зарегистрируйтесь в Comet. Настроим Opik: зададим API-ключ и назовём проект Opik.
os.environ["OPIK_API_KEY"] = "" # TODO: впишите сюда свой API-ключ OPIK или Cometassert OPIK_API_KEY != "", "You must set your OPIK or Comet API key before running this cell!"
# Задаём имя проекта Opikos.environ["OPIK_PROJECT_NAME"] = "6S191_Lab3"
opik.configure()Примечание переводчика: в этой ячейке оригинала не хватает импорта (import opik и
from opik import opik_context — последний нужен в следующей ячейке), а переменная OPIK_API_KEY в assert не
определена; проверяйте os.environ["OPIK_API_KEY"].
Трассировка (tracing) помогает понять сквозной поток работы LLM-приложения и найти конкретные шаги, которые вызывают проблемы.
В примере ниже мы делаем пробный вызов чат-бота и с помощью декоратора Opik @track записываем данные в интерфейс Opik,
создавая журнал «живых» вызовов приложения. Декоратор @track можно добавить к любой функции, чтобы трассировать не
только вызовы LLM, но и другие шаги конвейера приложения.
@opik.trackdef inference_chat(question, max_new_tokens=32, temperature=0.7, only_answer=False):
# 1. Составляем промпт по шаблону prompt = template_without_answer.format(question=question)
# 2. Токенизируем текст input_ids = tokenizer(prompt, return_tensors="pt").to(model.device)
# 3. Пропускаем через модель, чтобы предсказать вероятности следующего токена with torch.no_grad(): outputs = model.generate(**input_ids, do_sample=True, max_new_tokens=max_new_tokens, temperature=temperature)
# 4. Возвращаем только ответ, если only_answer=True output_tokens = outputs[0] if only_answer: output_tokens = output_tokens[input_ids['input_ids'].shape[1]:]
# 5. Декодируем токены result = tokenizer.decode(output_tokens, skip_special_tokens=True)
# Добавляем к текущей трассе оценки opik_context.update_current_trace( feedback_scores=[ { "name": "Yoda style eval", "value": scoring_function(result) } ] )
return resultТеперь можно сделать пробный вызов модели и увидеть трассу в Opik. После выполнения ячейки ниже появится ссылка на интерфейс Opik, где трассы записаны в ваш проект. Перейдите по ней, чтобы посмотреть трассы на платформе Opik.
# Поговорим с моделью и посмотрим на трассы с оценкойanswer = inference_chat( "Who was the only non-Jedi to wield a lightsaber in the original Star Wars trilogy?", only_answer=True, max_new_tokens=32,)
print(answer)2.6. Заключение
Заголовок раздела «2.6. Заключение»Экспериментируйте и с чат-моделью, и с LLM-судьёй, чтобы улучшить качество речи Йоды. Конкурс по этой лабораторной оценивался по таким критериям:
- Правдоподобие настоящей речи Йоды под вашей чат-моделью: чем лучше ваша модель понимает речь Йоды, тем меньшую кросс-энтропийную потерю она даст на тексте, который действительно написан речью Йоды. В конце этой лабораторной вы оцените правдоподобие отложенного тестового примера настоящей речи Йоды под вашей моделью. Укажите это правдоподобие в отчёте. Это даёт количественную меру для сравнения разных чат-моделей (которые могли работать с разными LLM-судьями).
- Эксперименты и изменения, которые вы попробовали, чтобы улучшить модель: опишите изменения и наблюдаемые результаты.
ВАЖНО: ВЫПОЛНИТЕ ЯЧЕЙКУ НИЖЕ, ЧТОБЫ НАПЕЧАТАТЬ РЕЗУЛЬТАТ, НО НЕ МЕНЯЙТЕ ЕЁ СОДЕРЖИМОЕ.
Заголовок раздела «ВАЖНО: ВЫПОЛНИТЕ ЯЧЕЙКУ НИЖЕ, ЧТОБЫ НАПЕЧАТАТЬ РЕЗУЛЬТАТ, НО НЕ МЕНЯЙТЕ ЕЁ СОДЕРЖИМОЕ.»# НЕ МЕНЯЙТЕ ЭТУ ЯЧЕЙКУ.# ВЫПОЛНИТЕ ЕЁ ПЕРЕД ОТПРАВКОЙ РАБОТЫ НА КОНКУРС.
yoda_test_text = mdl.lab3.yoda_test_texttokens = tokenizer(yoda_test_text, return_tensors="pt").to(model.device)
# Получаем логарифм правдоподобия от моделиwith torch.no_grad(): outputs = model(**tokens) logits = outputs.logits[:, :-1] targets = tokens.input_ids[:, 1:] loss = F.cross_entropy(logits.reshape(-1, logits.size(-1)), targets.reshape(-1))
print(f"Yoda test loglikelihood: {loss.item():.2f}")Примечание переводчика: печатается средняя кросс-энтропия на токен, то есть минус логарифм правдоподобия. Чем меньше число, тем лучше модель «знает» речь Йоды.
Как сдать работу
Заголовок раздела «Как сдать работу»Для участия в конкурсе нужно было загрузить на сайт сдачи лабораторной Large Language Models (ссылка — в ноутбуке):
- Jupyter-ноутбук с кодом, которым получены результаты;
- копию столбчатой диаграммы с оценками LLM-судьи для текста в базовом стиле, сгенерированного текста и текста в настоящем стиле Йоды;
- письменное описание сделанных изменений и проведённых экспериментов;
- письменное обсуждение того, почему и как эти изменения повлияли на качество;
- числовой результат последней ячейки этого ноутбука.
Работы без результата последней ячейки автоматически дисквалифицируются.
Файл называйте в формате [Имя]_[Фамилия]_LLM с расширением (.zip, .ipynb, .pdf и т. д.). ZIP-архив
предпочтительнее отдельных файлов. Если отправляете отдельные файлы, называйте каждый по этому правилу (например,
[Имя]_[Фамилия]_LLM_Report.pdf и т. д.).
Словарик
Заголовок раздела «Словарик»| EN | RU |
|---|---|
| fine-tuning | дообучение |
| chat template, special tokens | шаблон чата, специальные токены |
| tokenization, encode / decode | токенизация, кодирование / декодирование |
| byte-pair encoding (BPE), subword | кодирование парами байтов, подслово |
| next token prediction, causal / autoregressive language modeling | предсказание следующего токена, каузальное / авторегрессионное моделирование языка |
| logits, temperature, sampling | логиты, температура, выбор (сэмплирование) |
| parameter-efficient fine-tuning, LoRA, rank | эффективное по параметрам дообучение, LoRA, ранг |
| loss masking | маскирование потери |
| LLM as a judge, system prompt | LLM как судья, системный промпт |
| negative / positive control | отрицательный / положительный контроль |
| benchmark, synthetic data | бенчмарк, синтетические данные |
| tracing, trace, monitoring | трассировка, трасса, мониторинг |
| log-likelihood | логарифм правдоподобия |