Всем привет, меня зовут Максимов Максим. Я 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:
-
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 по схеме.
Метрики
Далее определим метрики, при помощи которых будем оценивать то, насколько хорошо мы решаем задачу.
Во время обучения модели я хочу оценивать 2 вещи:
-
Качество извлечения JSON из текста. А именно будем проверять, что JSON соответствует входной схеме, а также то, что извлечённый текст валиден (что модель не вытащила лишнее). Для оценки валидности будем использовать расстояние Левенштейна.
где
-
N — число примеров в тесте;
-
ŷ, y — ответ модели и эталон;
-
S — входная JSON‑схема;
-
lev — расстояние Левенштейна;
-
1[·] — индикаторная функция (на выходе 0 или 1).
2. Также будем оценивать забывание прошлых знаний модели во время дообучения под новую задачу. Для этого будем использовать бенчмарк MMLU, в котором необходимо по входному вопросу выбрать правильный вариант ответа из нескольких предложенных.
где
-
M — число вопросов из MMLU;
-
â_j, a_j — предсказанный и верный вариант ответа;
-
1[·] — индикаторная функция (на выходе 0 или 1).
Данные
В качестве обучающей выборки я буду использовать open‑source датасет, который нашёл на Hugging Face. А именно — scrapegraphai/scrapegraphai-100k. Это датасет из 100 тыс. реальных примеров извлечения структурированных данных, где каждый пример состоит из текста веб‑страницы, JSON‑схемы и ответа модели.
Так как для обучения я буду использовать небольшие ресурсы Colab, возьму небольшую подвыборку из данного датасета в количестве 1000 примеров.
В качестве тестовых данных я использую другой открытый датасет — paraloq/json_data_extraction. Это синтетический бенчмарк, сгенерированный Google Gemini Pro и покрывающий 8 доменов — от медицины и e‑commerce до производства. Взял его, потому что он не связан с обучающим датасетом и собран по‑другому, — это хороший способ проверить обобщающую способность LLM.
Для оценки забывания я использую MMLU (Massive Multitask Language Understanding) — стандартный бенчмарк общих знаний из 14 тыс. вопросов с выбором одного из 4 вариантов, охватывающий 57 доменов — от математики и физики до права, медицины и истории. Он хорошо подходит, чтобы замерить, не теряет ли модель прежние знания во время дообучения.
Из‑за ограничений Colab я беру не весь бенчмарк, а по 5 вопросов из каждого из 57 доменов (итого 285 вопросов) — этого достаточно, чтобы отследить динамику забывания.
Модель
В качестве модели я выбрал Qwen2.5-0.5B. Это самая младшая модель серии Qwen2.5 (0.5 млрд параметров, контекст 32K). Её размер позволяет дообучать модель на бесплатных ресурсах Colab вместе с LoRA.
Обучение
Для обучения я буду использовать подход LoRA с заморозкой части весов. LoRA (Low‑Rank Adaptation) — это метод, при котором основные веса модели замораживаются, а обучаются только небольшие дополнительные матрицы низкого ранга, встроенные в слои. За счёт этого мы обучаем лишь малую долю параметров вместо всей модели, и обучение помещается в память Colab.
Тестирование
Тестировать буду в три этапа. Сначала измерю метрики на необученной модели — это будет точка отсчёта. Затем во время обучения буду отслеживать метрики, чтобы видеть динамику. В конце оценю обученную модель на тестовой выборке и сравню её с необученной. Так мы поймём, какой результат дало дообучение и насколько модель забыла прежние знания.
Ход эксперимента
Далее переходим к практике. Ниже я буду описывать каждый проделанный шаг и 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}nn"
f"### JSON Scheman{json.dumps(schema, ensure_ascii=False)}nn"
f"### Textn{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.nn"
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()
Обучение заняло около часа.
Результаты получились следующие:
Что мы видим — с каждой итерацией ошибка на валидационной и обучающей выборках падала. Метрики оценки валидности 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 технологиях.
Автор: maksimov_m


