{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "a5520c44",
   "metadata": {},
   "source": [
    "# Как оценить результат обучения\n",
    "Лекция 02 · Новый самостоятельный блокнот\n",
    "\n",
    "Начинаем с пустого ядра. Переменные и ячейки прошлого занятия не нужны: заново загрузим модель и сохранённый адаптер. Обучать его повторно не будем.\n",
    "\n",
    "**На занятии создаём свой блокнот:** создайте папку `lecture02`, откройте её в Jupyter, создайте Python-блокнот и сохраните как `evaluation.ipynb`. Из `lecture02-adapter.zip` положите папку `pirate-adapter` прямо рядом с ним. Внутри нужны `adapter_config.json` и `adapter_model.safetensors`: это результат уже выполненного обучения. До первого запуска другие файлы не нужны.\n",
    "\n",
    "Выберите подготовленное окружение курса: Python 3.11, torch 2.5.1+cu121, transformers 4.57.1, peft 0.17.1, sentencepiece 0.2.1, CUDA. Если создаёте окружение заново, установите зависимости до запуска ячеек. Для другого оборудования окружение и устройство выбираются отдельно.\n",
    "\n",
    "Этот полный блокнот — справочный материал со всеми ячейками по порядку. На лекции переносим код со слайдов в свой новый блокнот. Qwen (около 3 ГБ) и, когда он понадобится, переводчик (около 307 МБ) скачиваются кодом в кэш Hugging Face. Позже, на шаге с контрольными вопросами, сохраните `cases.json` рядом с вашим блокнотом. `03_evaluate.executed.ipynb` содержит авторский запуск."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "77efa451",
   "metadata": {},
   "source": [
    "**Как читать сообщения Jupyter.** Полосы загрузки файлов и строка об используемом устройстве — обычный ход работы. Предупреждения библиотек могут отображаться на красном фоне, хотя ячейка успешно завершилась; ниже разберём конкретные сообщения там, где они возникают. Traceback с исключением означает остановку выполнения — это уже ошибка, которую нужно устранить перед продолжением.\n",
    "\n",
    "**Задача:** выяснить, что изменилось после обучения, предложить одно исправление и проверить границы вывода. Критерии: содержание, выполнение инструкции, язык и уместность стиля."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3107d013",
   "metadata": {},
   "source": [
    "## 1. Рабочее место готово\n",
    "`tokenizer` превращает текст в токены и обратно. `base` — исходная Qwen. `model` — та же основа с пиратским адаптером после 400 шагов. `eval()` включает режим использования модели."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1ba2d33e",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "# Выбираем обычную HTTP-загрузку файлов Hugging Face без ускорителя Xet.\n",
    "# Это нужно задать до импорта transformers; на веса и ответы настройка не влияет.\n",
    "os.environ[\"HF_HUB_DISABLE_XET\"] = \"1\"\n",
    "\n",
    "import torch\n",
    "# Auto-классы выбирают подходящую архитектуру по имени и конфигурации модели.\n",
    "from transformers import AutoTokenizer, AutoModelForCausalLM\n",
    "# PEFT подключает небольшую обученную добавку LoRA к исходной модели.\n",
    "from peft import PeftModel\n",
    "\n",
    "model_id = \"Qwen/Qwen2.5-1.5B-Instruct\"\n",
    "# Загружаем правила преобразования текста в токены; файлы попадут в кэш.\n",
    "tokenizer = AutoTokenizer.from_pretrained(model_id)\n",
    "# CausalLM — модель, которая продолжает текст, предсказывая следующий токен.\n",
    "# from_pretrained загружает готовые веса; bfloat16 уменьшает расход памяти.\n",
    "base = AutoModelForCausalLM.from_pretrained(\n",
    "    model_id, dtype=torch.bfloat16\n",
    ").to(\"cuda\")  # Переносим модель на видеокарту.\n",
    "\n",
    "# Берём LoRA из папки рядом с нашим новым блокнотом.\n",
    "# eval() включает режим предсказаний, отключая учебные слои вроде Dropout.\n",
    "model = PeftModel.from_pretrained(base, \"pirate-adapter\").eval()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "33f8230c",
   "metadata": {},
   "source": [
    "## 2. Получим один ответ\n",
    "Общая функция `ask` подготавливает диалог и генерирует строку. В опытах будем вызывать `ask_before` для исходной Qwen и `ask_after` для Qwen с обученным адаптером. Обе используют одинаковые настройки: без случайного выбора, до 160 новых токенов. Необязательный `system` позволяет передать инструкцию, не меняя веса."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "776288d3",
   "metadata": {},
   "outputs": [],
   "source": [
    "# На входе запрос и необязательная инструкция; на выходе — строка ответа.\n",
    "def ask(prompt, system=None):\n",
    "    messages = [{\"role\": \"user\", \"content\": prompt}]\n",
    "    if system:\n",
    "        # Если инструкция передана, ставим её перед сообщением пользователя.\n",
    "        messages.insert(0, {\"role\": \"system\", \"content\": system})\n",
    "    # Оформляем диалог по правилам Qwen и превращаем его в тензоры токенов.\n",
    "    # add_generation_prompt отмечает место, с которого отвечает ассистент.\n",
    "    inputs = tokenizer.apply_chat_template(\n",
    "        messages, tokenize=True, add_generation_prompt=True,\n",
    "        return_dict=True, return_tensors=\"pt\"\n",
    "    ).to(\"cuda\")  # Вход и модель должны находиться на одной видеокарте.\n",
    "    # Не строим граф градиентов для обучения: при генерации он не нужен.\n",
    "    # inference_mode экономит память; веса здесь не обучаются.\n",
    "    with torch.inference_mode():\n",
    "        # Генерируем до 160 новых токенов, без случайного выбора (sampling).\n",
    "        # pad_token_id задаёт токен заполнения; у Qwen берём токен конца текста.\n",
    "        output = model.generate(**inputs, max_new_tokens=160,\n",
    "            do_sample=False, pad_token_id=tokenizer.eos_token_id)\n",
    "    # generate вернул запрос вместе с продолжением: отрезаем токены запроса.\n",
    "    # decode превращает оставшиеся токены в текст без служебных меток.\n",
    "    return tokenizer.decode(output[0, inputs[\"input_ids\"].shape[-1]:],\n",
    "                            skip_special_tokens=True)\n",
    "\n",
    "# Два понятных вызова для всех следующих опытов.\n",
    "def ask_before(prompt, system=None):\n",
    "    # Временно отключаем LoRA: ответ исходной, не дообученной нами Qwen.\n",
    "    with model.disable_adapter():\n",
    "        return ask(prompt, system=system)\n",
    "\n",
    "def ask_after(prompt, system=None):\n",
    "    # Здесь LoRA активна: ответ модели после нашего обучения.\n",
    "    return ask(prompt, system=system)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "48c11446",
   "metadata": {},
   "source": [
    "При первом **вызове** ask может появиться: `The following generation flags are not valid and may be ignored: ['temperature', 'top_p', 'top_k']`. В конфигурации Qwen сохранены параметры случайного выбора, а мы задали `do_sample=False`. Поэтому они не участвуют в этом запуске. Это предупреждение, а не сбой: если ниже появился ответ и нет Traceback, ячейка выполнена. Само объявление функции ещё не генерирует ответ."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2a73561f",
   "metadata": {},
   "source": [
    "Вопрос по-русски: «При нормальном атмосферном давлении при какой температуре замерзает чистая вода по шкале Цельсия? Одно короткое предложение». Ожидаем 0 °C. Ограничение на ответ проверим отдельно."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "83481391",
   "metadata": {},
   "outputs": [],
   "source": [
    "question = (\"At normal atmospheric pressure, at what temperature does pure water \"\n",
    "            \"freeze in degrees Celsius? Give one short sentence.\")\n",
    "answer = ask_after(question)  # Спрашиваем модель ПОСЛЕ обучения, с нашей LoRA.\n",
    "print(answer)  # Показываем сохранённый ответ; print сам модель не запускает."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d7abef82",
   "metadata": {},
   "source": [
    "## 3. Английский ответ неудобно обсуждать\n",
    "Может ли уже загруженная исходная модель перевести его? Проверим через `ask_before`. Внутри этой функции `disable_adapter()` временно отключает LoRA. После вызова адаптер снова включается; вторую копию весов не загружаем.\n",
    "\n",
    "Запрос: «Переведи на русский. Верни только перевод». Проверим число 0, условие про давление и образные выражения. Не предполагаем заранее ни успех, ни неудачу."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "023eddf5",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Перевод поручаем исходной Qwen ДО нашего обучения — это ask_before.\n",
    "qwen_translation = ask_before(\n",
    "    \"Translate into Russian. Return only the translation:\\n\" + answer\n",
    ")\n",
    "print(qwen_translation)  # Печатаем перевод; исходная строка answer сохранена."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b123030b",
   "metadata": {},
   "source": [
    "## 4. Готовая модель именно для перевода\n",
    "Нам нужен небольшой локальный инструмент EN → RU. На Hugging Face выбираем задачу **Translation**, проверяем языки и пример вызова. Один кандидат — [Helsinki-NLP/opus-mt-en-ru](https://huggingface.co/Helsinki-NLP/opus-mt-en-ru): английский → русский, около 307 МБ весов. Это удобный кандидат для нашей задачи, не победитель универсального рейтинга.\n",
    "\n",
    "Файлы заранее не готовим: `from_pretrained` скачает их по имени репозитория и сохранит в кэш. `revision` закрепляет конкретную версию файлов. Для нашего PyTorch 2.5 выбрана версия с `model.safetensors`; веса из основной ветки в другом формате требуют более новой версии PyTorch. Это свойство выбранного репозитория, а не универсальная настройка всех моделей.\n",
    "\n",
    "`AutoModelForSeq2SeqLM` загружает модель преобразования входной последовательности в выходную: здесь английского текста в русский. После загрузки `pipeline` объединяет модель и токенизатор в один удобный вызов. Для другой модели сначала смотрим её карточку: направление языков, подходящий класс и пример запуска."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "8eddc97e",
   "metadata": {},
   "outputs": [],
   "source": [
    "from transformers import AutoTokenizer, AutoModelForSeq2SeqLM, pipeline\n",
    "\n",
    "# Имя публичного репозитория на Hugging Face, а не путь к папке на компьютере.\n",
    "translator_id = \"Helsinki-NLP/opus-mt-en-ru\"\n",
    "# Фиксируем версию с весами safetensors, совместимую с окружением курса.\n",
    "translator_revision = \"3ff4dbd98515ba253f08f183e0de0b7ccf189ee3\"\n",
    "\n",
    "# Скачиваем токенизатор; повторный запуск использует уже сохранённый кэш.\n",
    "translator_tokenizer = AutoTokenizer.from_pretrained(\n",
    "    translator_id, revision=translator_revision\n",
    ")\n",
    "# Seq2SeqLM преобразует входной текст в выходной; здесь это перевод EN → RU.\n",
    "translator_model = AutoModelForSeq2SeqLM.from_pretrained(\n",
    "    translator_id, revision=translator_revision, use_safetensors=True\n",
    ")\n",
    "# Объединяем токенизатор и модель; device=0 выбирает первую видеокарту.\n",
    "translator = pipeline(\"translation\", model=translator_model,\n",
    "                      tokenizer=translator_tokenizer, device=0)\n",
    "# pipeline возвращает список результатов; берём перевод первой строки.\n",
    "print(translator(answer)[0][\"translation_text\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d826e1b8",
   "metadata": {},
   "source": [
    "**Что появляется при загрузке.** Полосы `Downloading` показывают скачивание в кэш. `Device set to use cuda:0` сообщает, что выбрана первая видеокарта. `Recommended: pip install sacremoses` — рекомендация необязательного нормализатора текста: в проверенном окружении перевод работает и без него. При необходимости пакет устанавливается отдельной ячейкой `%pip install sacremoses`.\n",
    "\n",
    "На Windows возможно предупреждение о недоступных `symlinks`: кэш использует обычные копии файлов и может занять больше места; это не ошибка перевода. Если отсутствует виджет прогресса (`IProgress not found`), загрузка может показывать текстовый индикатор. Ошибки соединения, отсутствующего SentencePiece или Traceback — отдельные проблемы, их нельзя игнорировать. SentencePiece входит в подготовленное окружение курса."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0d105db4",
   "metadata": {},
   "source": [
    "## 5. Оригинал и перевод в двух полях\n",
    "`SimpleNamespace` собирает именованные поля: `.en` хранит вход, `.ru` — перевод. Вход T — короткий английский текст. Числа, JSON и уже русский ответ переводить не нужно."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "0495d644",
   "metadata": {},
   "outputs": [],
   "source": [
    "from types import SimpleNamespace  # Простой объект с полями, доступными через точку.\n",
    "\n",
    "def T(text):\n",
    "    ru = translator(text)[0][\"translation_text\"]  # Запускаем перевод, берём текст.\n",
    "    return SimpleNamespace(en=text, ru=ru)  # Возвращаем исходную строку и перевод."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e368ae08",
   "metadata": {},
   "outputs": [],
   "source": [
    "result = T(answer)  # Один раз переводим сохранённый ответ.\n",
    "print(result.en)  # Печатаем исходную английскую строку.\n",
    "print(result.ru)  # Печатаем перевод из того же результата, без нового вызова модели."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "0bfdfe67",
   "metadata": {},
   "source": [
    "Перевод помогает читать. Содержание, формат, язык и стиль проверяем по оригиналу: перевод может изменить манеру или потерять деталь. На ответе про воду отдельно оцените факт и просьбу об одном предложении. Инструмент готов — возвращаемся к оценке."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "3b624d50",
   "metadata": {},
   "source": [
    "## 6. Несколько разных требований\n",
    "Теперь нам нужен набор контрольных вопросов. Скачайте `cases.json` по ссылке на слайде 19 и сохраните прямо рядом с вашим `evaluation.ipynb`. В файле есть оригиналы, русские подписи и критерии. Возьмём три разработческих вопроса: арифметика, длина списка и совет по-русски. Это маленькая демонстрация метода, не общая оценка модели. В дополнительных данных есть прежний полный авторский прогон 24 вопросов.\n",
    "\n",
    "На точных задачах нужны именно `42` и `3`, без приветствия. Русский совет придётся читать: его полезность не проверяется сравнением с одной строкой."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5a0498b2",
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "# Читаем файл рядом с блокнотом и превращаем JSON-текст в Python-список.\n",
    "cases = json.loads(Path(\"cases.json\").read_text(encoding=\"utf-8\"))\n",
    "# Выбираем три разработческих вопроса по их идентификаторам.\n",
    "dev = [q for q in cases if q[\"id\"] in [\"D06\", \"D07\", \"D09\"]]\n",
    "for q in dev:\n",
    "    # Показываем понятный запрос и критерий до запуска модели.\n",
    "    print(q[\"id\"], q[\"prompt_ru\"], \"Ожидаем:\", q[\"reference\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "9ac1877a",
   "metadata": {},
   "source": [
    "## 7. Сравним с исходной версией\n",
    "`ask_before` и `ask_after` вызывают две версии модели. `before` и `after` — словари, в которых сохраняем ответы: например, `after[\"D07\"]` возвращает уже полученную строку и не запускает модель. Запрос и настройки одинаковы; меняется только адаптер. Сохраняем каждую строку, включая неудачные ответы."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ae2e17f0",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Словари ответов: before — ДО обучения, after — ПОСЛЕ обучения.\n",
    "before, after = {}, {}\n",
    "for q in dev:\n",
    "    before[q[\"id\"]] = ask_before(q[\"prompt\"])  # Исходная Qwen, без нашей LoRA.\n",
    "    after[q[\"id\"]] = ask_after(q[\"prompt\"])  # Та же Qwen с обученной LoRA.\n",
    "    print(q[\"id\"], \"— обе версии готовы\")  # Одна пара ответов на тот же запрос."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dd49c947",
   "metadata": {},
   "source": [
    "D07 — длина списка из трёх элементов. Для чтения переведём английские объяснения. Верное значение и лишние слова проверяем в `.en`. Если ответ состоит из одной цифры, читаем её напрямую."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "05705a15",
   "metadata": {},
   "outputs": [],
   "source": [
    "a = T(before[\"D07\"])  # Переводим ответ исходной модели на вопрос D07.\n",
    "b = T(after[\"D07\"])  # Переводим ответ с адаптером на тот же вопрос.\n",
    "# Показываем и оригиналы, и переводы; \\n переводит вывод на новую строку.\n",
    "print(\"Исходная модель:\", a.en, \"\\nПеревод:\", a.ru)\n",
    "print(\"С адаптером:\", b.en, \"\\nПеревод:\", b.ru)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "188501e1",
   "metadata": {},
   "source": [
    "## 8. Автоматизируем однозначное требование\n",
    "`.strip()` убирает крайние пробелы. Полное совпадение требует и верного числа, и отсутствия лишнего текста. При несовпадении читаем ответ и определяем причину.\n",
    "\n",
    "Знаменатель **2**, а не 3: русский совет этим правилом не оценивается. Полученная доля — не качество всей модели."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "03d00c68",
   "metadata": {},
   "outputs": [],
   "source": [
    "expected = {\"D06\": \"42\", \"D07\": \"3\"}\n",
    "for name, answers in [(\"Исходная\", before), (\"С адаптером\", after)]:\n",
    "    # strip убирает крайние пробелы; сравнение проверяет всю строку.\n",
    "    # sum считает совпадения: True даёт 1, False — 0.\n",
    "    passed = sum(answers[key].strip() == value for key, value in expected.items())\n",
    "    # len(expected) — число задач, к которым применимо наше правило.\n",
    "    print(name, passed, \"/\", len(expected), \"точных ответов\")\n",
    "\n",
    "# Свободный русский ответ этим правилом не оценён — выводим его для чтения.\n",
    "print(\"Совет по-русски — читаем отдельно:\")\n",
    "print(after[\"D09\"])"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "dfb699e7",
   "metadata": {},
   "source": [
    "## 9. Одно исправление\n",
    "Если приветствие появляется даже при просьбе «только число», уточним приоритеты системной инструкцией. Веса не меняем. Гипотеза: нарушений строгого формата станет меньше. Факты проверяем отдельно.\n",
    "\n",
    "Перевод инструкции: «Точно выполни просьбу, используй язык запроса, сохраняй фактическую точность. Лёгкая пиратская речь допустима, если не мешает формату. Если просят только число или JSON, верни только их, без приветствия и другого текста».\n",
    "\n",
    "В учебном протоколе инструкция заранее фиксирована. Не редактируйте её после просмотра отложенных ответов."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5e37b4c9",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Единая системная инструкция для следующего опыта; русский смысл — выше.\n",
    "format_instruction = (\"Follow the user request exactly. Use the language of the user request. \"\n",
    "       \"Keep factual claims accurate. Use light pirate wording only when it does not \"\n",
    "       \"conflict with the requested format. If only a number or JSON is requested, \"\n",
    "       \"return only that number or JSON, with no greeting or extra text.\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ee495c57",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Снова спрашиваем модель ПОСЛЕ обучения. Меняется только system-инструкция.\n",
    "# Прежние ответы этой же модели уже сохранены в after.\n",
    "after_with_instruction = {}\n",
    "for q in dev:\n",
    "    after_with_instruction[q[\"id\"]] = ask_after(\n",
    "        q[\"prompt\"], system=format_instruction\n",
    "    )\n",
    "    # Печатаем два ответа рядом: видим и улучшения, и новые ошибки.\n",
    "    print(q[\"id\"], q[\"prompt_ru\"])\n",
    "    print(\"После обучения:\", after[q[\"id\"]])\n",
    "    print(\"После обучения + инструкция:\", after_with_instruction[q[\"id\"]])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "fb9d1101",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Тот же критерий: целиком правильное число, без лишнего текста.\n",
    "for name, answers in [(\"После обучения\", after),\n",
    "                      (\"После обучения + инструкция\", after_with_instruction)]:\n",
    "    # expected содержит эталоны D06 → 42 и D07 → 3.\n",
    "    passed = sum(answers[key].strip() == value for key, value in expected.items())\n",
    "    print(name, passed, \"/\", len(expected), \"точных ответов\")\n",
    "\n",
    "# Счётчик не объясняет каждую ошибку: прочитайте пары ответов выше.\n",
    "# В D07 сравните само число и наличие лишних слов — это разные свойства."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d4cf9f84",
   "metadata": {},
   "source": [
    "## 10. Перенеслось ли изменение?\n",
    "На знакомых вопросах мы сравнили `after` с `after_with_instruction`: одну обученную модель без и с нашей дополнительной инструкцией. Теперь проверим ту же `format_instruction` на других задачах: умножении, длине строки и объяснении словаря по-русски. Не подбираем её текст по этим ответам.\n",
    "\n",
    "Снова вызываем только `ask_after`. В `test_after` сохраняем ответы без нашей дополнительной инструкции, в `test_after_with_instruction` — с ней. После просмотра для следующего независимого опыта понадобится новый набор.\n",
    "\n",
    "Это воспроизведение подготовленного опыта. Вопросы уже известны автору курса. Для собственной работы заранее составьте другие вопросы и закройте их до фиксации решения."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c2f0e402",
   "metadata": {},
   "outputs": [],
   "source": [
    "# Теперь берём другие вопросы: умножение, длину строки и объяснение словаря.\n",
    "heldout = [q for q in cases if q[\"id\"] in [\"T06\", \"T07\", \"T09\"]]\n",
    "test_after, test_after_with_instruction = {}, {}\n",
    "for q in heldout:\n",
    "    # Два вызова ask_after: обе версии ПОСЛЕ обучения, меняем только инструкцию.\n",
    "    test_after[q[\"id\"]] = ask_after(q[\"prompt\"])\n",
    "    test_after_with_instruction[q[\"id\"]] = ask_after(\n",
    "        q[\"prompt\"], system=format_instruction\n",
    "    )\n",
    "    print(q[\"id\"], q[\"prompt_ru\"])  # Показываем запрос и обе исходные строки.\n",
    "    print(\"После обучения:\", test_after[q[\"id\"]])\n",
    "    print(\"После обучения + инструкция:\", test_after_with_instruction[q[\"id\"]])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b0d65d58",
   "metadata": {},
   "outputs": [],
   "source": [
    "test_expected = {\"T06\": \"72\", \"T07\": \"3\"}\n",
    "for name, answers in [(\"После обучения\", test_after),\n",
    "                      (\"После обучения + инструкция\", test_after_with_instruction)]:\n",
    "    # Считаем полные совпадения, как на разработческой части, но с новыми эталонами.\n",
    "    passed = sum(answers[key].strip() == value for key, value in test_expected.items())\n",
    "    print(name, passed, \"/\", len(test_expected), \"точных ответов\")  # Явный знаменатель."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d980d6e9",
   "metadata": {},
   "source": [
    "## 11. Сохраним свидетельства\n",
    "Запишем оригиналы и две попытки перевода первого ответа. Файл сохраняется локально рядом с блокнотом."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c8a3a3a2",
   "metadata": {},
   "outputs": [],
   "source": [
    "results = {\"first\": {\"prompt\": question, \"answer\": answer,\n",
    "                     \"qwen_ru\": qwen_translation, \"dedicated_ru\": result.ru},\n",
    "           \"before\": before, \"after\": after,\n",
    "           \"after_with_instruction\": after_with_instruction,\n",
    "           \"test_after\": test_after,\n",
    "           \"test_after_with_instruction\": test_after_with_instruction,\n",
    "           \"instruction\": format_instruction}\n",
    "# dumps превращает результаты в JSON, сохраняя кириллицу и добавляя отступы.\n",
    "# write_text записывает этот текст в файл рядом с нашим блокнотом.\n",
    "Path(\"practice-results.json\").write_text(\n",
    "    json.dumps(results, ensure_ascii=False, indent=2), encoding=\"utf-8\"\n",
    ")\n",
    "print(\"Сохранено: practice-results.json\")  # Сообщаем имя созданного файла."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a8a19387",
   "metadata": {},
   "source": [
    "### Ваш вывод\n",
    "1. Для задачи … мы проверяли …, сравнивая … при одинаковых … .\n",
    "2. На разработческих вопросах … из …; на отложенных … из … .\n",
    "3. Улучшение видно в …; оставшаяся ошибка видна в … .\n",
    "4. Пока нельзя утверждать …, потому что … .\n",
    "\n",
    "Отдельно оцените полезность русских ответов и уместность стиля. Если свойство не оценено, так и напишите. Для своей работы подготовьте сценарий, вопросы и критерии до новых запусков."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (LLM course)",
   "language": "python",
   "name": "python3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
