Всем привет, меня зовут Максимов Максим. Я 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

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

Датасет 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

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

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

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

Далее переходим к практике. Ниже я буду описывать каждый проделанный шаг и 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 технологиях.

Комментарии (16)


  1. vvlrff
    06.06.2026 15:59

    Добрый день! А зачем дообучать, когда провайдеры - в данном случае openai completions позволяют возвращать ответ в заданной схеме?


    1. maksimov_m Автор
      06.06.2026 15:59

      Привет! В начале статьи описывал, что существует проблема - LLM популярных провайдеров находятся на внешних серверах. Для чувствительных данных такой формат работы может не подойти. Одно из решений - разворачивать что-то свое. И вот здесь, если мощного железа нет, а задача очень специфичная, дообучение может помочь решить задачу (а может и нет).


      1. vvlrff
        06.06.2026 15:59

        Я изначально подразумевал, что речь идет про self-hosted решения. Например в качестве провайдера vllm, и если обучать, то все равно разворачивать придется модель, чтобы была доступна по api. Вот и пытаюсь понять смысл


    1. EvgeniyRasyuk
      06.06.2026 15:59

      видимо не всегда можно сливать данные провайдеру


  1. fuwiak
    06.06.2026 15:59

      Ответ такой: зарубежные провайдеры предоставляют LLM по API (модель находится на внешних серверах), что может не соответствовать, например, 152-ФЗ или правилам защиты корпоративных данных

    1)Автор вы знаете что русские провайдеры уже предоставляют по апи большинство зарубежный моделей и по 152-ФЗ все у них точно чики бомбони по этому поводу?
    Зачем это упоминать в контексте fine tune моделей?
    2) LoRA — это не равно полноценному fine-tuning.
    3) Один запуск, одна эпоха, небольшой датасет и слабая методология оценки — какие серьёзные выводы из этого вообще можно сделать?

    Здесь скорее студенческий Colab-эксперимент: нет нормальной инженерной рамки — отсутствует мониторинг, трекинг обучения и деплоя, непонятно, на каком серьёзном железе это запускать и как масштабировать. Вы точно Team Lead?





    1. maksimov_m Автор
      06.06.2026 15:59

      Спасибо за комментарий. По поводу существования таких провайдеров знаю. Привел пример c PII как одну из ситуаций, когда все же хочется развернуть что-то у себя и не обращаться к внешним сервисам.

      В статье я не упоминал, что делаю полный fine-tuning. Как раз написал, что обучение будет происходить методом LoRA.

      Цель статьи была больше показать начинающим из каких этапов состоит обучение LLM, и в частности как можно произвести fine tuning. Пример с обучением структурированному выводу взят как демонстрация.


      1. SerjV
        06.06.2026 15:59

        "по 152-ФЗ все у них точно чики бомбони" - тут пропущен флейм про отличие бумажной и фактической безопасностей, а также нефлеймовый момент том, что комплаенс по персданным (про что собственно и есть ФЗ №152-ФЗ от 27.07.2006) и размещение обработки данных в контролируемом контуре - вообще решают разные задачи (хотя и имеют общие методы их решения).


    1. morginalium8
      06.06.2026 15:59

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

      1. у ру-провайдеров все ок с 152-ФЗ. да, это так - сервера в россии, данные за рубеж не уходят. вот только из-за дефицита железа в россии такие сервера стоят довольно дорого. а значит и модельки, которые на них крутятся дешевыми быть не могут. алиса, например, стоит сопоставимо с соннет, но по качеству в разы хуже. в мтс облаке все еще 'лучше' - прошлогодняя qwen-qwq стоит в 1000 (!) раз дороже аналога на openrouter.

      2. LoRA, QLoRA и DoRA - отличные и полноценные методы дообучение. и зачастую они даже стабильнее обычного sft, т.к. почти не ломают базовые способности модели. для sft/rl нужно огромное кол-во данных и можностей, а я не думаю что у кого-то в доступе пара сотен лишнихН200 завалялось.

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


      1. fuwiak
        06.06.2026 15:59

        17-летний ML-инженер из Питера.


        У меня нет больше вопросов, не буду издеваться над ребенком)))) Что там тебе чат гпт подсказал?)))


  1. rPman
    06.06.2026 15:59

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

    я не вижу уменьшения ошибки, какие то хаотические метания или ухудшения

    p.s. для llm-ок более удобным и эффективным структурированным форматом входных данных, является toon, этакая модификация csv

    для выходных данных есть structured outputs (или grammar для llama.cpp)

    p.p.s. 0.5b модели в лучшем случае хватит для классификатора и простеньких embending

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


  1. maksimym3612
    06.06.2026 15:59

    Максим, отличный туториал! Спасибо за труд.Вы очень точно описали ключевую проблему: большие LLM не запустить на маленьком железе, а маленькие LLM не решают узкую задачу из коробки. И выход, который вы предлагаете — Fine-tuning.

    Но, как вы сами заметили, это путь компромиссов: модель забывает прошлые знания, обучение стоит дорого, а результат всё равно не гарантирован.А что, если я скажу, что есть другой путь? Не «дрессировать» одну большую модель, а дать ей врождённую архитектуру, которая решит проблему безопасности и забывания на корню.Если вам интересен принципиально иной подход к созданию ИИ, посмотрите мою работу «Становление субъекта: архитектура, этика и дорожная карта субъектного ИИ».GitHub: https://github.com/maksim-timoshenko/AI-consciousness-continuum. Там все расписано.


  1. Ra2007
    06.06.2026 15:59

    Тема с 152-ФЗ актуальна: именно из-за неё часть кодовых задач у нас не уходит во внешний API. Но перед дообучением пробовали ещё один шаг: хорошо структурированный контекст через CLAUDE.md + примеры из нашей базы. Для задач где у модели достаточно способностей, но не хватает контекста, это дешевле и быстрее дообучения. Вопрос: на каком пороге сложности выбирали дообучение, а не RAG или prompt engineering?


    1. Mersavets
      06.06.2026 15:59

      С 152 ФЗ легко справляется простое обезличивание


      1. Ra2007
        06.06.2026 15:59

        Обезличивание закрывает часть случаев. У нас 30-40% задач это архитектурные решения и бизнес-логика которую мы не хотим отдавать в любой внешний сервис, не только из-за ФЗ. Там обезличивание не поможет, нужно своё железо. Поэтому граница между RAG на локальных данных и дообучением для нас реальная


  1. Guestishe
    06.06.2026 15:59

    Я тот самый новичок, будет ли продолжение про дообучение с учителем?


  1. IVA48
    06.06.2026 15:59

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