惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
W
WeLiveSecurity
O
OpenAI News
N
News and Events Feed by Topic
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Webroot Blog
Webroot Blog
Google Online Security Blog
Google Online Security Blog
云风的 BLOG
云风的 BLOG
N
News | PayPal Newsroom
H
Hacker News: Front Page
博客园_首页
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
The Last Watchdog
The Last Watchdog
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
H
Heimdal Security Blog
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Schneier on Security
宝玉的分享
宝玉的分享
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Y
Y Combinator Blog
Cyberwarzone
Cyberwarzone
Microsoft Security Blog
Microsoft Security Blog
C
CXSECURITY Database RSS Feed - CXSecurity.com
GbyAI
GbyAI
Cloudbric
Cloudbric
TaoSecurity Blog
TaoSecurity Blog
人人都是产品经理
人人都是产品经理
P
Palo Alto Networks Blog
M
MIT News - Artificial intelligence
G
GRAHAM CLULEY
C
Check Point Blog
Apple Machine Learning Research
Apple Machine Learning Research
Last Week in AI
Last Week in AI
T
Troy Hunt's Blog
L
Lohrmann on Cybersecurity
www.infosecurity-magazine.com
www.infosecurity-magazine.com
P
Proofpoint News Feed
Blog — PlanetScale
Blog — PlanetScale
量子位
博客园 - 聂微东
S
Securelist
博客园 - 三生石上(FineUI控件)
F
Full Disclosure
G
Google Developers Blog
L
LINUX DO - 热门话题
P
Proofpoint News Feed
AI
AI
PCI Perspectives
PCI Perspectives

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как дообучить LLM. Рассказываю шаг за шагом
Максим Максимов · 2026-06-06 · via Все публикации подряд на Хабре

Средний

15 мин

14K

Всем привет, меня зовут Максимов Максим. Я Team Lead в R&D-лаборатории компании red_mad_robot и автор Telegram‑канала Максим Максимов // IT, AI. Сегодня мы погрузимся в тему дообучения больших языковых моделей (LLM). Вначале я дам небольшую вводную, а далее на практике разберём, как дообучить LLM извлекать информацию из текста в формате JSON по заданной схеме.

Поехали!

Введение

Современные LLM, такие как GPT, Grok, DeepSeek, Qwen, Claude, из коробки способны решать множество задач. Появляется вопрос: зачем нам дообучать какие‑то LLM, если можно просто взять готовое решение и использовать в своей задаче? Ответ такой: зарубежные провайдеры предоставляют LLM по API (модель находится на внешних серверах), что может не соответствовать, например, 152-ФЗ или правилам защиты корпоративных данных. Здесь в свет выходят open source модели, которые можно скачать с Hugging Face и запустить на своём оборудовании.

Но не всё так просто.

Для работы LLM требуется мощное оборудование, которое стоит дорого. В open source выложено множество хороших моделей, способных решать самые разные задачи. Чаще всего: чем больше LLM, тем больше задач она может решить. Большие LLM тяжело запустить на небольшом железе, поэтому можно взять модель поменьше. Правда, не факт, что она решит вашу узкую задачу из коробки — тогда её можно попробовать дообучить.

Можно выделить следующие основные шаги обучения LLM:

Основные этапы обучения LLM

Основные этапы обучения LLM

  • Pre‑training: это этап, на котором LLM обучают на большом корпусе сырого текста, количество которого может достигать триллионов токенов. Здесь LLM инициализируется случайными весами и её обучают задаче генерации следующего токена. На этом этапе LLM уже способна отвечать осмысленно и даже решать какие‑то задачи. Но проблема в том, что она не будет следовать инструкциям, а будет просто генерировать текст. Например, если после Pretrain задать LLM какой‑то вопрос, она может не ответить на него, а начать генерировать ещё вопросы или говорить по теме вопроса, но не отвечать на него конкретно. Для того чтобы обучить LLM следовать инструкциям, существует этап Fine‑tuning.

  • Fine‑tuning: на этом этапе мы учим нашу модель следовать инструкциям или выполнять определённые действия (например, вызывать функции или же генерировать JSON по входному тексту и схеме). На этом этапе требуется меньшее количество данных, но более качественных, чтобы модель уловила суть задачи и мы смогли улучшить обобщение на схожих данных. Также стоит отметить, что на этом этапе чаще всего обучается не вся модель, а используются методы заморозки и обучения только небольшой части весов модели, например LoRA.

  • Alignment, или выравнивание. Это ещё один этап дообучения LLM. На этом этапе модель учат быть безопасной и полезной — не генерировать опасную или запрещённую информацию. Чаще всего здесь применяются методы RL.

В этой статье мы сосредоточимся на этапе Fine‑tuning. А именно, возьмём open source LLM, которая уже прошла этап Pretrain, и дообучим её решать задачу генерации структурированного ответа по заданной JSON‑схеме.

При Fine‑tuning LLM возникает множество нюансов. Один из них — забывание моделью прошлых знаний, изученных на этапе Pretrain. Существуют способы борьбы с забыванием. Например, регуляризация либо же дополнение обучающих данных во время Fine‑tuning данными, которые использовались на этапе Pretrain. В статье мы не будем бороться с забыванием, но во время обучения будем отслеживать, насколько сильно модель забывает прошлые знания, используя бенчмарк MMLU.

Давайте переходить к эксперименту.

Описание эксперимента

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

Задача

Задача, которую мы будем решать, — это генерация JSON по входному тексту и схеме. То есть в LLM я буду отправлять текст и схему, и на выходе хочу, чтобы она извлекла из текста информацию в JSON по схеме.

Обучение LLM структурированному выводу

Обучение LLM структурированному выводу

Метрики

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

Во время обучения модели я хочу оценивать 2 вещи:

  1. Качество извлечения JSON из текста. А именно будем проверять, что JSON соответствует входной схеме, а также то, что извлечённый текст валиден (что модель не вытащила лишнее). Для оценки валидности будем использовать расстояние Левенштейна.

\begin{aligned} &\text{SchemaValid} = \frac{1}{N}\sum_{i=1}^{N}\mathbb{1}\!\left[\,\operatorname{valid}(\hat{y}_i,\, S_i)\,\right] \\[12pt] &\text{LevSim} = \frac{1}{N}\sum_{i=1}^{N}\left(1 - \frac{\operatorname{lev}(\hat{y}_i,\, y_i)}{|\hat{y}_i| + |y_i|}\right) \\[14pt] \end{aligned}

где

  • N — число примеров в тесте;

  • ŷ, y — ответ модели и эталон;

  • S — входная JSON‑схема;

  • lev — расстояние Левенштейна;

  • 1[·] — индикаторная функция (на выходе 0 или 1).

2. Также будем оценивать забывание прошлых знаний модели во время дообучения под новую задачу. Для этого будем использовать бенчмарк MMLU, в котором необходимо по входному вопросу выбрать правильный вариант ответа из нескольких предложенных.

\begin{aligned} &\text{Acc} = \frac{1}{M}\sum_{j=1}^{M}\mathbb{1}\!\left[\,\hat{a}_j = a_j\,\right] \\[14pt] \end{aligned}

где

  • M — число вопросов из MMLU;

  • â_j, a_j — предсказанный и верный вариант ответа;

  • 1[·] — индикаторная функция (на выходе 0 или 1).

Данные

В качестве обучающей выборки я буду использовать open‑source датасет, который нашёл на Hugging Face. А именно — scrapegraphai/scrapegraphai-100k. Это датасет из 100 тыс. реальных примеров извлечения структурированных данных, где каждый пример состоит из текста веб‑страницы, JSON‑схемы и ответа модели.

Так как для обучения я буду использовать небольшие ресурсы Colab, возьму небольшую подвыборку из данного датасета в количестве 1000 примеров.

Датасет scrapegraphai/scrapegraphai-100k

Датасет scrapegraphai/scrapegraphai-100k

В качестве тестовых данных я использую другой открытый датасет — paraloq/json_data_extraction. Это синтетический бенчмарк, сгенерированный Google Gemini Pro и покрывающий 8 доменов — от медицины и e‑commerce до производства. Взял его, потому что он не связан с обучающим датасетом и собран по‑другому, — это хороший способ проверить обобщающую способность LLM.

Датасет paraloq/json_data_extraction

Датасет paraloq/json_data_extraction

Для оценки забывания я использую MMLU (Massive Multitask Language Understanding) — стандартный бенчмарк общих знаний из 14 тыс. вопросов с выбором одного из 4 вариантов, охватывающий 57 доменов — от математики и физики до права, медицины и истории. Он хорошо подходит, чтобы замерить, не теряет ли модель прежние знания во время дообучения.

Из‑за ограничений Colab я беру не весь бенчмарк, а по 5 вопросов из каждого из 57 доменов (итого 285 вопросов) — этого достаточно, чтобы отследить динамику забывания.

Датасет MMLU

Датасет MMLU

Модель

В качестве модели я выбрал Qwen2.5-0.5B. Это самая младшая модель серии Qwen2.5 (0.5 млрд параметров, контекст 32K). Её размер позволяет дообучать модель на бесплатных ресурсах Colab вместе с LoRA.

Модель Qwen2.5-0.5B

Модель Qwen2.5-0.5B

Обучение

Для обучения я буду использовать подход LoRA с заморозкой части весов. LoRA (Low‑Rank Adaptation) — это метод, при котором основные веса модели замораживаются, а обучаются только небольшие дополнительные матрицы низкого ранга, встроенные в слои. За счёт этого мы обучаем лишь малую долю параметров вместо всей модели, и обучение помещается в память Colab.

LoRA

LoRA

Тестирование

Тестировать буду в три этапа. Сначала измерю метрики на необученной модели — это будет точка отсчёта. Затем во время обучения буду отслеживать метрики, чтобы видеть динамику. В конце оценю обученную модель на тестовой выборке и сравню её с необученной. Так мы поймём, какой результат дало дообучение и насколько модель забыла прежние знания.

Ход эксперимента

Далее переходим к практике. Ниже я буду описывать каждый проделанный шаг и Python‑код его реализации.

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

Также я ввёл ограничение на размер входного текста — до 4096 токенов, чтобы уместить эксперимент в бесплатный GPU в Colab (при большем размере выдавался OOM). Таким образом, я отфильтровал тексты, которые больше данного размера.

Подготовка данных
import json, pandas as pd
from jsonschema import Draft7Validator
from transformers import AutoTokenizer

MODEL_NAME = "Qwen/Qwen2.5-0.5B"
MAX_TOKENS = 4096
N_TRAIN, N_TEST = 1000, 200
SEED = 42

SYSTEM = (
    "You are an information extraction engine. Given a source text and a JSON Schema, "
    "extract the relevant information and return a single JSON object that strictly conforms "
    "to the schema. Output only the JSON, with no extra commentary, markdown, or code fences."
)
INSTRUCTION = (
    "Extract the information from the text below into a JSON object that strictly conforms "
    "to the given JSON Schema. Use only information present in the text. Return only the JSON object."
)

tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)

def as_obj(x):
    return x if isinstance(x, (dict, list)) else json.loads(x)

def to_chat(text, schema, target):
    user = (f"{INSTRUCTION}\n\n"
            f"### JSON Schema\n{json.dumps(schema, ensure_ascii=False)}\n\n"
            f"### Text\n{str(text).strip()}")
    return {"messages": [
        {"role": "system",    "content": SYSTEM},
        {"role": "user",      "content": user},
        {"role": "assistant", "content": json.dumps(target, ensure_ascii=False)},
    ]}

def fits(rec):
    return len(tokenizer.apply_chat_template(rec["messages"], tokenize=True)) <= MAX_TOKENS

Далее было отобрано 1000 семплов с валидным JSON для обучающей выборки из scrapegraphai-100k.

Подготовка обучающей выборки
df = pd.read_parquet("hf://datasets/scrapegraphai/scrapegraphai-100k/data/train.parquet")
df = df[df["response_is_valid"]].sample(frac=1, random_state=SEED)

train = []
for _, r in df.iterrows():
    if len(train) >= N_TRAIN:
        break
    try:
        schema, target = as_obj(r["schema"]), as_obj(r["response"])
        if not str(r["content"]).strip():
            continue
        Draft7Validator(schema).validate(target)
        rec = to_chat(r["content"], schema, target)
        if fits(rec):
            train.append(rec)
    except Exception:
        continue

Также отобрано 200 строк данных для теста из датасета paraloq/json_data_extraction

Подготовка тестовой выборки
dt = pd.read_parquet("hf://datasets/paraloq/json_data_extraction/data.parquet").sample(frac=1, random_state=SEED)

test = []
for _, r in dt.iterrows():
    if len(test) >= N_TEST:
        break
    try:
        rec = to_chat(r["text"], as_obj(r["schema"]), as_obj(r["item"]))
        if fits(rec):
            test.append(rec)
    except Exception:
        continue

Подготовим датасет для тестирования забывания прошлых знаний LLM во время обучения. Из датасета MMLU я возьму подвыборку.

Подготовка датасета для оценки забывания знаний
import json, random
from datasets import load_dataset, get_dataset_config_names

LETTERS = ["A", "B", "C", "D"]
N_PER_SUBJECT = 5
SEED = 42

subjects = [c for c in get_dataset_config_names("cais/mmlu") if c not in {"all", "auxiliary_train"}]

def to_prompt(question, choices):
    opts = "\n".join(f"{L}. {c}" for L, c in zip(LETTERS, choices))
    return ("Answer the following multiple choice question. "
            "Respond with only the letter (A, B, C, or D) of the correct answer.\n\n"
            f"Question: {question}\n{opts}\nAnswer:")

mmlu = []
for subj in subjects:
    ds = load_dataset("cais/mmlu", subj, split="test")
    idx = list(range(len(ds)))
    random.Random(SEED).shuffle(idx)
    for i in idx[:N_PER_SUBJECT]:
        ex = ds[i]
        mmlu.append({
            "subject": subj,
            "answer_letter": LETTERS[ex["answer"]],
            "messages": [{"role": "user", "content": to_prompt(ex["question"], ex["choices"])}],
        })

Сохраним данные в JSONL формат.

Сохранение данных в JSONL
def save_jsonl(path, rows):
    with open(path, "w", encoding="utf-8") as f:
        for x in rows:
            f.write(json.dumps(x, ensure_ascii=False) + "\n")

save_jsonl("train.jsonl", train)
save_jsonl("test.jsonl",  test)
save_jsonl("mmlu_probe.jsonl", mmlu)

Далее произведём обучение LLM в Colab.

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

Подготовка Colab
# !pip install -q -U transformers peft trl datasets accelerate
# !pip install -q jsonschema python-Levenshtein matplotlib

import os, gc, json, random, re
from contextlib import contextmanager
from collections import defaultdict

import numpy as np
import torch
import Levenshtein
import matplotlib.pyplot as plt
from tqdm.auto import tqdm

from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, Trainer, TrainingArguments
from peft import LoraConfig, get_peft_model
from jsonschema import Draft7Validator

SEED = 42
random.seed(SEED); np.random.seed(SEED)
torch.manual_seed(SEED); torch.cuda.manual_seed_all(SEED)

DEVICE = "cuda" if torch.cuda.is_available() else "cpu"

Инициализируем токенайзер и LLM.

Инициализация модели и токенайзера
MODEL_NAME = "Qwen/Qwen2.5-0.5B"
MAX_LEN = 4096


tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
tokenizer.pad_token = tokenizer.eos_token
tokenizer.padding_side = "right"


model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, torch_dtype=torch.float16, device_map={"": 0})
model.config.use_cache = False
model.config.pad_token_id = tokenizer.pad_token_id


print(f"{sum(p.numel() for p in model.parameters())/1e6:.0f}M параметров")

Загрузим наши датасеты. Во время обучения я буду использовать валидационную выборку, чтобы отслеживать прогресс обучения и в случае необходимости отлавливать переобучение. Для валидационной выборки я использую 10% от обучающей выборки.

Подготовка train, val, test
raw = load_dataset("json", data_files={"train": "train.jsonl", "test": "test.jsonl"})
split = raw["train"].train_test_split(test_size=0.1, seed=SEED)


train_ds, val_ds, test_ds = split["train"], split["test"], raw["test"]
mmlu = [json.loads(l) for l in open("mmlu_probe.jsonl", encoding="utf-8") if l.strip()]


print("train:", len(train_ds), "| val:", len(val_ds), "| test:", len(test_ds), "| mmlu:", len(mmlu))
# train: 900 | val: 100 | test: 200 | mmlu: 285 

Таким образом, в обучающей выборке у нас 900 примеров, в валидационной 100, в тестовой 200. Для оценки забывания взято 285 примеров.

Далее реализуем подсчет метрик для оценки качества извлечения JSON (о которых я писал выше).

Реализация подсчета метрик извлечения JSON
def extract_schema(messages):
    user = next(m["content"] for m in messages if m["role"] == "user")
    return json.loads(user.split("### JSON Schema", 1)[1].split("### Text", 1)[0].strip())


def gold(messages):
    return next(m["content"] for m in messages if m["role"] == "assistant")


def _first_span(t):
    start = next((i for i, c in enumerate(t) if c in "{["), None)
    if start is None: return None
    stack, pairs = [], {"}": "{", "]": "["}
    for j in range(start, len(t)):
        c = t[j]
        if c in "{[": stack.append(c)
        elif c in "}]":
            if not stack or stack.pop() != pairs[c]: return None
            if not stack: return t[start:j+1]
    return None


def parse_json(text):
    t = re.sub(r"^```(?:json)?|```$", "", text.strip()).strip()
    for cand in (t, _first_span(t)):
        if cand is None: continue
        try:
            obj = json.loads(cand)
            return obj, json.dumps(obj, sort_keys=True, ensure_ascii=False)
        except Exception:
            pass
    return None, ""


def score(preds, examples):
    n = len(preds); valid = 0; lev = []
    for p, ex in zip(preds, examples):
        _, g_canon = parse_json(gold(ex["messages"]))
        p_obj, p_canon = parse_json(p)
        if p_obj is not None:
            try:
                Draft7Validator(extract_schema(ex["messages"])).validate(p_obj); valid += 1
            except Exception:
                pass
        lev.append(Levenshtein.ratio(p_canon if p_obj is not None else p.strip(), g_canon))
    return {"schema_valid": valid/n, "levenshtein": sum(lev)/n}

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

Реализация оценки модели на MMLU
LETTERS = ["A", "B", "C", "D"]

@contextmanager
def left_padding(tok):
    old = tok.padding_side; tok.padding_side = "left"
    try: yield
    finally: tok.padding_side = old

@torch.no_grad()
def generate(examples, batch_size=24, max_new_tokens=1024):
    model.eval()
    cache = model.config.use_cache;
    model.config.use_cache = True
    out = []
    try:
        with left_padding(tokenizer):
            for i in tqdm(range(0, len(examples), batch_size), desc="generate"):
                batch = examples[i:i+batch_size]
                prompts = [tokenizer.apply_chat_template(
                    [m for m in ex["messages"] if m["role"] != "assistant"],
                    tokenize=False, add_generation_prompt=True) for ex in batch]
                enc = tokenizer(prompts, return_tensors="pt", padding=True,
                                truncation=True, max_length=MAX_LEN).to(model.device)
                gen = model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False,
                                     use_cache=True, pad_token_id=tokenizer.pad_token_id)
                out += tokenizer.batch_decode(gen[:, enc["input_ids"].shape[1]:], skip_special_tokens=True)
    finally:
        model.config.use_cache = cache
    return out

@torch.no_grad()
def eval_mmlu(data, batch_size=4):
    model.eval()
    letter_ids = [tokenizer(" " + L, add_special_tokens=False).input_ids[0] for L in LETTERS]
    corr = total = 0
    with left_padding(tokenizer):
        for i in tqdm(range(0, len(data), batch_size), desc="mmlu"):
            batch = data[i:i+batch_size]
            enc = tokenizer([ex["messages"][0]["content"] for ex in batch], return_tensors="pt",
                            padding=True, truncation=True, max_length=MAX_LEN).to(model.device)
            logits = model(**enc).logits[:, -1, :][:, letter_ids]
            for ex, pi in zip(batch, logits.argmax(-1).tolist()):
                corr += int(LETTERS[pi] == ex["answer_letter"]); total += 1
    return corr / total

Далее — я проведу замеры модели на тестовой выборке и MMLU. Это необходимо для того, чтобы зафиксировать метрики до обучения и отследить, какой будет прогресс во время и после обучения LLM.

Оценка LLM до обучения
test_examples = [test_ds[i] for i in range(len(test_ds))]


base_json = score(generate(test_examples), test_examples)
base_mmlu = eval_mmlu(mmlu)


baseline = {**base_json, "mmlu": base_mmlu}
print(baseline)
# {'schema_valid': 0.265,'levenshtein': 0.4112473125422889, 'mmlu': 0.45964912280701753} 

Получаем значения:

  • schema_valid — 0.27

  • levenshtein — 0.41

  • mmlu — 0.46

Зафиксируем их и после обучения сравним с обученной моделью.

Инициализируем LoRA веса для нашей модели с помощью библиотеки peft. У меня возникала ошибка во время инициализации LoRA с torchao. Решить её помогло удаление этой библиотеки.

Инициализация LoRA
# ! pip uninstall torchao
model.gradient_checkpointing_enable()
model.enable_input_require_grads()

lora = LoraConfig(
    r=16, lora_alpha=32, lora_dropout=0.05, bias="none", task_type="CAUSAL_LM",
    target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
)
model = get_peft_model(model, lora)

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

Токенизация данных
def tokenize_and_mask(example):
    msgs = example["messages"]
    full   = tokenizer.apply_chat_template(msgs, tokenize=False)
    prefix = tokenizer.apply_chat_template(
        [m for m in msgs if m["role"] != "assistant"], tokenize=False, add_generation_prompt=True)
    ids = tokenizer(full, add_special_tokens=False, truncation=True, max_length=MAX_LEN)["input_ids"]
    n = min(len(tokenizer(prefix, add_special_tokens=False)["input_ids"]), len(ids))
    labels = [-100] * n + ids[n:]            # loss только на ответе ассистента
    return {"input_ids": ids, "labels": labels}

train_tok = train_ds.map(tokenize_and_mask, remove_columns=train_ds.column_names)
val_tok   = val_ds.map(tokenize_and_mask,   remove_columns=val_ds.column_names)

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

Реализация функции подсчета метрик
VAL_PROBE = [val_ds[i] for i in range(min(40, len(val_ds)))]
history = [{"step": 0, "schema_valid": baseline["schema_valid"],
            "levenshtein": baseline["levenshtein"], "mmlu": baseline["mmlu"]}]

def preprocess_logits_for_metrics(logits, labels):
    if isinstance(logits, tuple): logits = logits[0]
    return logits.argmax(dim=-1)

def compute_metrics(eval_pred):
    gc_on = model.is_gradient_checkpointing
    if gc_on: model.gradient_checkpointing_disable()
    
    jm = score(generate(VAL_PROBE, batch_size=8, max_new_tokens=512), VAL_PROBE)
    macc = eval_mmlu(mmlu, batch_size=4)
    
    if gc_on: model.gradient_checkpointing_enable()
    
    out = {"schema_valid": jm["schema_valid"], "levenshtein": jm["levenshtein"], "mmlu": macc}
    history.append({"step": trainer.state.global_step, **out})
    return out

Теперь инициализируем параметры обучения. Для эксперимента я обучу модель на 1 эпохе. Eval буду запускать каждые 25 шагов, чтобы отслеживать изменение метрик и видеть прогресс (или регресс?).

Настройка параметров обучения
class Collator:
    def __call__(self, feats):
        m = max(len(f["input_ids"]) for f in feats); pad = tokenizer.pad_token_id
        ids = [f["input_ids"] + [pad]*(m-len(f["input_ids"])) for f in feats]
        att = [[1]*len(f["input_ids"]) + [0]*(m-len(f["input_ids"])) for f in feats]
        lab = [f["labels"] + [-100]*(m-len(f["labels"])) for f in feats]
        return {"input_ids": torch.tensor(ids), "attention_mask": torch.tensor(att), "labels": torch.tensor(lab)}

args = TrainingArguments(
    output_dir="qwen-lora-json",
    num_train_epochs=1,
    per_device_train_batch_size=1, 
    gradient_accumulation_steps=8, 
    per_device_eval_batch_size=1,
    learning_rate=2e-4, 
    lr_scheduler_type="cosine", 
    warmup_ratio=0.03, 
    fp16=True,
    eval_strategy="steps", 
    eval_steps=25,
    save_strategy="steps", 
    save_steps=25, 
    save_total_limit=2, 
    eval_accumulation_steps=1,
    logging_steps=10,
    load_best_model_at_end=True, 
    metric_for_best_model="eval_loss", 
    greater_is_better=False,
    remove_unused_columns=False, 
    report_to="none",
)

trainer = Trainer(
    model=model, args=args,
    train_dataset=train_tok, eval_dataset=val_tok,
    data_collator=Collator(),
    compute_metrics=compute_metrics,
    preprocess_logits_for_metrics=preprocess_logits_for_metrics,
)

И наконец заветная строка — запускаем обучение

trainer.train()

Обучение заняло около часа.

Результаты получились следующие:

Ход дообучения Qwen2.5-0.5B

Ход дообучения Qwen2.5-0.5B

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

Далее проведем финальное тестирование — оценим нашу обученную модель на тестовых данных.

final = {**score(generate(test_examples), test_examples), "mmlu": eval_mmlu(mmlu)}

print(f"{'метрика':<14}{'до':>9}{'после':>9}{'Δ':>9}")
for k in ["schema_valid", "levenshtein", "mmlu"]:
    print(f"{k:<14}{baseline[k]:>9.3f}{final[k]:>9.3f}{final[k]-baseline[k]:>+9.3f}")
Результаты эксперимента

Результаты эксперимента

Вывод можно сделать следующий: даже на небольшом объёме данных и 1 эпохе обучения небольшая Qwen научилась лучше извлекать информацию из текста в формате JSON по заданной схеме. Возможно, увеличив количество данных и эпох обучения, можно было бы получить результат лучше. Экспериментируйте =)

Конец

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

Спасибо за внимание!

Подписывайтесь на мой Telegram‑канал, в котором я также рассказываю интересные вещи об IT и AI технологиях.