Еще со времён школы меня будоражили возможности, которые дают компьютеры. Написать программу — это как создать что-то материальное своими руками. Неделю назад я за один вечер прочитал книгу Себастьяна Рашки «Строим LLM с нуля» (доступна на английском бесплатно), в которой без сложной теории матанализа описывается архитектура современных LLM и как их тюнить.
Если вы интересовались, как работают LLM, то уже имеете представление, что модели умеют предсказывать следующее слово и что за этим стоит математика. Но на этом объяснение, как правило, заканчивается. Детали того, как они предсказывают следующее слово, часто рассматриваются как черный ящик.. В этой статье предлагаю рассмотреть эту тему подробнее и познакомиться с тонкой настройкой (fine-tuning) LLM для решения условно-практической задачи классификации с помощью примеров кода, приведенных в упомянутой книге.
Статья устроена так, что все шаги в статье вы можете повторить и в конце получить набор скриптов для выстраивания пайплайна обучения LLM. Я же описал свои шаги, потому что лучший способ что-то понять — это применить теорию на практике и попытаться объяснить результат кому-то.
Чтобы приступить к лабораторной работе, достаем двойные листочки, расчехляем питон и тиктокен.
Немного об архитектуре GPT-like моделей
Для решения поставленной задачи классификации, нужно понять как именно работают модели. Для этого предлагаю рассмотреть их архитектуру.
Модели состоят из следующих компонентов:
LLM (Large Language Model) — это большая языковая модель, основанная на трансформерах, которая способна обрабатывать и генерировать текст.
Machine learning — метод, который позволяет системам учиться на данных и делать прогнозы или принимать решения без явного программирования на каждую задачу. Признаки обычно создаются и выбираются вручную специалистами.
Deep learning (глубокое обучение) — это подмножество машинного обучения. Фокусируется на использовании нейронных сетей с тремя или более уровнями (также называемых глубокими нейронными сетями) для моделирования сложных шаблонов и абстракций в данных. Традиционное машинное обучение требует ручного извлечения признаков. Это означает, что специалисты-люди должны определить и выбрать наиболее релевантные функции для модели.
Gen AI — класс искусственного интеллекта, который способен создавать новые данные на основе изученных шаблонов. GPT является примером генеративного ИИ, который позволяет генерировать связный текст с помощью трансформеров. Ключевой компонент трансформера в LLM — механизм самоконтроля (Self-attention), который позволяет модели последовательно взвешивать важность различных слов (или токенов) относительно друг друга.

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

Точная настройка может быть выполнена для следования инструкциям или для задач классификации.
При тонкой настройке инструкций, размеченный набор данных должен состоять из пар инструкций и ответов, например, запрос перевода текста, сопровождаемый правильно переведённым текстом.
При тонкой настройке классификации размеченный набор данных должен состоять из текстов и связанных с ними меток классов, например, электронные письма, отнесенные к спам- и не-спам-меткам.
Таким образом, в большинстве случаев нет необходимости в создании базовой модели. Мы можем сосредоточиться на тонкой настройке для решения необходимых задач.
Выбор базовой модели
Прежде чем приступить к тонкой настройке, выберем подходящую базовую модель.
Себастьян Рашка предлагает использовать GPT-2, поскольку она уже обучена на большом объеме данных, представлена в разных размерах, исходный код и весовые параметры модели были открыты компанией OpenAI и доступны для исследователей и разработчиков. Это позволяет свободно использовать модель для до-обучения и тонкой настройки на своих данных.
Стоит отметить, что недавно вышла GPT-OSS, но она требует больше ресурсов для до-обучения. Выбор модели для своих задач — это отдельная большая тема.
GPT2 доступна в нескольких размерах:
124M (smallest model),
355M,
774M,
1558M (largest model).
Для загрузки модели подготовлен скрипт, который можно вызвать передав на вход размер модели. Для примера скачаем самую маленькую модель (124М), чтобы проверить на что она способна и рассмотреть её архитектуру:
python scripts/download_model.py --model_size 124M
Данный скрипт скачивает модель, состоящую из нескольких файлов из открытого источника и выводит параметры модели:
# перечисление файлов модели
filenames = [
"checkpoint", "encoder.json", "hparams.json",
"model.ckpt.data-00000-of-00001", "model.ckpt.index",
"model.ckpt.meta", "vocab.bpe"
]
# Создание директории
os.makedirs(model_dir, exist_ok=True)
# Загрузка каждого файла
for filename in filenames:
file_url = os.path.join(base_url, model_size, filename)
backup_url = os.path.join(backup_base_url, model_size, filename)
file_path = os.path.join(model_dir, filename)
download_file(file_url, file_path, backup_url)
# Вывод параметров модели
settings = json.load(open(os.path.join(model_dir, "hparams.json"), "r", encoding="utf-8"))
Вывод содержит следующее:
Model settings: {'n_vocab': 50257, 'n_ctx': 1024, 'n_embd': 768, 'n_head': 12, 'n_layer': 12}
Пояснение:
n_vocab: 50 257 — размер словаря (количество уникальных токенов).
n_ctx: 1024 — максимальное контекстное окно (длина последовательности), которое может обработать модель.
n_embd: 768 — размерность встраиваний токенов и скрытых состояний.
n_head: 12 — количество элементов управления вниманием в каждом слое преобразователя.
n_layer: 12 — количество слоев декодера преобразователя в модели.
Далее, чтобы проверить, что она умеет, выполним скрипт tests\base_model_test.py, который содержит код загрузки модели в память, преобразование в токены входных параметров с помощью tiktoken и генерацию текста:
# инициализация токенизатора с настройкой для GPT2 модели
tokenizer = tiktoken.get_encoding("gpt2")
# загрузка параметров
model_size = CHOOSE_MODEL.split(" ")[-1].lstrip("(").rstrip(")")
settings, params = download_and_load_gpt2(model_size=model_size)
# инициализация модели
model = GPTModel(BASE_CONFIG)
load_weights_into_gpt(model, params)
model.eval()
# проверка возможности генерации текста
text_1 = "Every effort moves you"
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_1, tokenizer),
max_new_tokens=15,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
# проверка возможности модели следовать инструкциям
text_2 = (
"Is the following text 'spam'? Answer with 'yes' or 'no':"
" 'You are a winner you have been specially"
" selected to receive $1000 cash or a $2000 award.'"
)
token_ids = generate_text_simple(
model=model,
idx=text_to_token_ids(text_2, tokenizer),
max_new_tokens=23,
context_size=BASE_CONFIG["context_length"]
)
print(token_ids_to_text(token_ids, tokenizer))
На вход модели подаём 2 примера текста: с инструкцией и без инструкции, и модель возвращает ответ. Результат показывает, что базовая модель умеет только дополнять текст, используя механизм self-attention и не умеет следовать инструкциям, соответственно вывод будет таким:
Every effort moves you forward.
The first step is to understand the importance of your work
Is the following text 'spam'? Answer with 'yes' or 'no': 'You are a winner you have been specially selected to receive $1000 cash or a $2000 award.'
The following text 'spam'? Answer with 'yes' or 'no': 'You are a winner
Также, предлагаю посмотреть, что именно было загружено для работы модели GPT-2. Данные файлы служат различным целям в архитектуре модели:
checkpoint — содержит метаданные о сохраненной контрольной точкой модели, указывает, какие файлы являются частью контрольной точки и их взаимосвязи, используется TensorFlow для определения последней завершенной контрольной точки.
encoder.json — содержит конфигурацию кодера для токенизатора, который определяет, как текст преобразуется в числовые токены.
-
hparams.json — содержит настройки конфигурации модели:
n_layer: количество слоев трансформатора,n_head: количество направляющих элементов,n_embd: размер встраиваемого элемента,vocab_size: размер словаря,max_position_embeddings: максимальная длина последовательности.
model.ckpt.data-00000-of-00001 — содержит фактические веса и параметры модели.
model.ckpt.index — индексный файл, который сопоставляет имена параметров с их местоположениями в файле данных, позволяет TensorFlow эффективно загружать определенные параметры без чтения всего файла данных. Необходим для загрузки весов модели и управления ими.
model.ckpt.meta — содержит структуру вычислительного графа модели (метаграф).
vocab.bpe — словарный файл, использующий парное кодирование байтов.
Примечание. Хотя в этой статье много длинных тире она написана человеком. А если интересно, почему модели так любят длинные тире, читайте статью по ссылке ниже. В ней описана вполне реалистичная гипотеза.
Обучающий конвейер для тонкой настройки модели
Чтобы приступить к тонкой настройке, нужно учесть следующие тезисы:
LLM требуют преобразования текстовых данных в числовые векторы, известные как встраивания (embeddings). Встраивания преобразуют дискретные данные (например, слова или изображения) в непрерывные векторные пространства что делает их совместимыми с операциями нейронной сети.
Текст, с которым работает модель, разбивается на токены, которые могут быть словами или символами. Затем токены преобразуются в целочисленные представления, называемые идентификаторами токенов.
Встраивание слоев в PyTorch работает как операция поиска, извлекая векторы, соответствующие идентификаторам токенов. Результирующие векторы встраивания обеспечивают генерацию выходных токенов.
Далее, рассмотрим обучающий конвейер, состоящий из нескольких этапов:
-
Подготовка набора данных:
Можно скачать открытый датасет или подготовить его самостоятельно.
Предобработка данных.
Создание загрузчика данных.
-
Настройка модели:
Инициализация модели.
Загрузка пред-тренировочных весов.
Изменение модели для целей тонкой настройки.
Оценка результата после тренировки.
-
Тонкая настройка:
Собственно, сам запуск скриптов тонкой настройки.
Оценка настроенной модели.
Использование модели на новых данных.

Подготовка данных
Для тренировки модели нам необходимы размеченные данные.
Допустим, мы хотим определять токсичные комментарии на Хабре, тогда нам нужно собрать достаточное количество таких примеров, и каждый из них пометить. Разметка может выглядеть самым простым образом: текстовый файл, где первое слово — это метка, далее любой машиночитаемый разделитель и далее текст комментария.
Я же решил взять в качестве примера определение субличности по методологии IFS (wiki), которую человек опишет во входном промте. Я не смог найти общедоступного большого набора размеченных данных, предназначенного специально для классификации субличностей по IFS (что естественно). Большинство существующих ресурсов данной тематики посвящены отчетам, стенограммам терапевтических сеансов или упражнениям по составлению карт, а не стандартизированным наборам данных с маркировкой для машинного обучения. Поэтому нужно создать свой собственный набор данных.
Чтобы создать датасет, нужно определить какие классы мы планируем рассматривать. Исходя из теории IFS, основными классами субличностей являются: Менеджер (Manager), Пожарный (Firefighter), Изгнанник (Exile).
Данные нужно подготовить в машиночитаемом виде. Я сделаю файл в формате TSV (tab-separated values), с колонками: Label (класс) и Text (описание на естественном языке).
Для генерации размеченных данных я использовал промт, который можно использовать для любой другой большой LLM. Данный промт нужно повторить для Manager, Firefighter, Exile. Нам нужно получить около 1000 примеров и сохранить в текстовый файл.
Give as much as you can row by row examples of annotations outlining Мanager type characteristics according to Internal family system (IFS) psychological methodology. Use Paraphrasing, Synonyms, Expansion.
Но обращение к другим моделям для генерации данных для обучения плохой подход, не делайте так. Это все равно что отправиться на машине времени в прошлое и встретить самого себя, что приведёт к коллапсу пространственно-временного континиума. Хотя это из другой вселенной, поэтому для лабораторной работы должно подойти.
Несколько примеров из датасета:
Exile a collapsed part that feels physically deflated.
Firefighter A protective part believes enough knowledge can prevent emotional pain.
Manager I create rules for every scenario, hoping governance equals safety.
После сбора данных в один файл нужно подготовить данные для тренировки и оценки.
Изучим получившийся файл и посмотрим на количество сгенерированных сэмплов для каждого класса:
import pandas as pd # type: ignore
# загрузка данных из файла в датафрейм
df = pd.DataFrame([
{'Label': parts[0], 'Text': parts[1]}
for line in open('data/raw/dataset/ifs_parts_dateset_en.tsv', 'r', encoding='utf-8')
if line.strip() and (parts := line.split('\t', 1)) and len(parts) == 2
])
# вывод количества
print(df)
print(df["Label"].value_counts())
Вывод будет таким:
[529 rows x 2 columns]
Label
Exile 205
Firefighter 167
Manager 157
Можно заметить, что данные не сбалансированы по количеству. Несбалансированный датасет приводит к смещению модели (bias), плохой обобщающей способности и низкой точности. Чтобы это исправить есть следующие стратегии:
Ограничение выборки (сокращение выборки классов с избыточным количеством до наименьшего).
Увеличение выборки (дополнительная подготовка данных).
Гибридный подход (дополнительная генерация данных и сокращение уже достаточно больших классов).
SMOTE — метод балансировки наборов данных путем создания синтетических примеров.
Взвешивание классов — это метод, при котором функция потерь модели корректируется таким образом, чтобы ошибки в классах с меньшим количеством сэмплов вносили больший вклад в общие потери.
Для простоты сделаю выборку с ограничением по классу с наименьшим количеством примеров:
def balance_by_undersampling(df, target_samples=144):
"""
Balance dataset by undersampling the majority class (Exile)
"""
balanced_data = []
for category in df['Label'].unique():
category_data = df[df['Label'] == category]
if len(category_data) > target_samples:
# Undersample majority class
undersampled = category_data.sample(n=target_samples, random_state=42)
balanced_data.append(undersampled)
else:
# Keep minority classes as-is
balanced_data.append(category_data)
return pd.concat(balanced_data, ignore_index=True)
balanced_df = balance_by_undersampling(df, target_samples=275)
print(f"Balanced dataset shape: {balanced_df.shape}")
print(f"Labels distribution:\n{balanced_df['Label'].value_counts()}")
Затем необходимо запустить функцию random_split, которая разделит набор данных на три части: 70% для обучения, 10% для проверки и 20% для тестирования. Эти соотношения часто используются в машинном обучении. Эти данные необходимо сохранить для дальнейшего использования.
def random_split(df, train_frac, validation_frac):
df = df.sample(frac=1, random_state=123).reset_index(drop=True) #A
train_end = int(len(df) * train_frac) #B
validation_end = train_end + int(len(df) * validation_frac)
#C
train_df = df[:train_end]
validation_df = df[train_end:validation_end]
test_df = df[validation_end:]
return train_df, validation_df, test_df
train_df, validation_df, test_df = random_split(balanced_df, 0.7, 0.1)
Все, что необходимо для подготовки данных, сохраним в scripts\prepare_data.py.
Тонкая настройка и адаптация к задачам
В этом разделе мы модифицируем предварительно обученную модель, чтобы подготовить её к точной настройке для задач классификации. Чтобы сделать это, мы заменим исходный выходной слой, который сопоставляет скрытое представление со словарем в 50257 слов, на выходной слой меньшего размера, соответствующий количеству классов, по которым мы проведём обучение.

Прежде чем мы приступим к модификации, показанной на рисунке выше, мы можем проверить архитектуру модели с помощью функции print(model), которая выводит следующее:
GPTModel(
(tok_emb): Embedding(50257, 768)
(pos_emb): Embedding(1024, 768)
(drop_emb): Dropout(p=0.0, inplace=False)
(trf_blocks): Sequential(
(0): TransformerBlock(
(att): MultiHeadAttention(
(W_query): Linear(in_features=768, out_features=768, bias=True)
(W_key): Linear(in_features=768, out_features=768, bias=True)
(W_value): Linear(in_features=768, out_features=768, bias=True)
(out_proj): Linear(in_features=768, out_features=768, bias=True)
(dropout): Dropout(p=0.0, inplace=False)
)
(ff): FeedForward(
(layers): Sequential(
(0): Linear(in_features=768, out_features=3072, bias=True)
(1): GELU()
(2): Linear(in_features=3072, out_features=768, bias=True)
)
)
(norm1): LayerNorm()
(norm2): LayerNorm()
(drop_resid): Dropout(p=0.0, inplace=False)
)
(1): TransformerBlock(
...
)
(final_norm): LayerNorm()
(out_head): Linear(in_features=768, out_features=50257, bias=False)
)
Из вывода мы можем видеть, что класс GPTModel состоит из слоев встраивания, за которыми следуют 12 идентичных трансформаторных блоков (для краткости показан только первый блок), за которыми следуют:
final_norm— это слой нормализации, который стандартизирует активации последнего слоя трансформера, способствуя стабильности и улучшению качества вывода модели.out_head— это выходной слой или «голова» модели, который преобразует последние скрытые представления (эмбеддинги) в вероятности слов из словаря (токенов).
Далее наша цель — замена и точная настройка выходного слоя. Поэтому сначала нужно заморозить модель, то есть сделать все слои необучаемыми.
Также нужно установить выходному слою (model.out_head) количество категорий, которые нам необходимы для классификации.
Изначально слой имеет значение в 50 257, что соответствует размеру словаря для генерации текста, нам нужно оставить только 3 - в соответствии с количеством классов в датасете.
# инициализация модели
model = GPTModel(config)
model.eval()
device = "cuda"
# Цель состоит в замене и точной настройке выходного слоя
# Для достижения этой цели мы сначала замораживаем модель, то есть делаем все слои необучаемыми
for param in model.parameters():
param.requires_grad = False
# Затем мы заменяем выходной слой (`model.out_head`),
# который изначально отображает входные данные слоя в 50 257 измерениях (размер словаря).
num_classes = 3
model.out_head = torch.nn.Linear(in_features=int(config["emb_dim"]),
out_features=num_classes)
model.to(device)
# Чтобы сделать конечную норму уровня и последний трансформаторный блок доступными
# для обучения, мы устанавливаем для их соответствующих requires_grad значение True
num_unfrozen_layers = 1
num_layers = len(model.trf_blocks)
start_layer = max(0, num_layers - num_unfrozen_layers)
for i in range(start_layer, num_layers):
for param in model.trf_blocks[i].parameters():
param.requires_grad = True
for param in model.final_norm.parameters():
param.requires_grad = True
После изучения результатов тренировки можно будет изменить количество обучаемых выходных слоев, чтобы посмотреть, как это повлияет на точность классификации.
После этого необходимо использовать функцию тренировки для классификации train_classifier_simple используя подготовленный датасет, которая вернёт потери и точность выполненной тренировки.
start_time = time.time()
torch.manual_seed(123)
torch.set_num_threads(4)
optimizer = torch.optim.AdamW(model.parameters(),
lr=training_settings["learning_rate"],
weight_decay=training_settings["weight_decay"])
train_losses, val_losses, train_accs, val_accs, examples_seen = train_classifier_simple(
model, train_loader, val_loader, optimizer, device,
num_epochs=num_epochs,
eval_freq=training_settings["eval_freq"],
eval_iter=training_settings["eval_iter"],
)
end_time = time.time()
execution_time_minutes = (end_time - start_time) / 60
print(f"Training completed in {execution_time_minutes:.2f} minutes.")
Данная функция не модифицирует изначальные веса в файлах модели, они загружаются в новый экземпляр модели PyTorch, соответственно результат обучения необходимо будет сохранить:
model_filename = f"data/models/ifs_navigator_{args.model}.pth"
torch.save(model.state_dict(), model_filename)
Запустим обучение получившимся скриптом scripts\train.py, который выведет следующее:
37 training batches
6 validation batches
11 test batches
Ep 1 (Step 000000): Train loss 1.358, Val loss 1.232
Training accuracy: 37.50% | Validation accuracy: 45.00%
Ep 2 (Step 000050): Train loss 1.316, Val loss 1.199
Training accuracy: 20.00% | Validation accuracy: 45.00%
Ep 3 (Step 000100): Train loss 1.321, Val loss 1.174
Training accuracy: 30.00% | Validation accuracy: 45.00%
Training accuracy: 27.50% | Validation accuracy: 45.00%
Ep 5 (Step 000150): Train loss 1.283, Val loss 1.153
Training accuracy: 42.50% | Validation accuracy: 45.00%
Training completed in 0.07 minutes.
Результат Training accuracy: 42.50% показывает точность тренировки, и это довольно низкий результат. В первую очередь это было вызвано изначально маленьким датасетом из 150 примеров. Я его дополнил, а также, чтобы повысить качество тренировки у нас есть несколько параметров, которые мы можем изменить.
Исходная конфигурация модели содержит параметры, влияющие на качество обучения:
Context_length— длина контекста, который будет взят при обучении. Данная длина должна соответствовать датасету.Drop_rate— показатель отсева. Изменяя параметр можно регулировать чтобы предотвратить переобучение.Emb_dim— размер вектора, используемого для представления каждого токена.N_layers— количество слоёв трансформеров, участвующих в генерации.N_heads— количество голов внимания для фокусировки на разных частях входного сигнала.
Изменим влияющие параметры:
BASE_CONFIG = {
"vocab_size": 50257,
"context_length": 60,
"drop_rate": 0.1,
"qkv_bias": False,
"emb_dim": 64, # увеличили размер вектора с 12
"n_layers": 3, # увеличим количество обучаемых слоёв до 3-х
"n_heads": 4 # увеличим количество голов внимания до 4-х
}
Уменьшим Weight_decay, который отвечает за метод регуляризации, который помогает предотвратить переобучение путем добавления штрафа, пропорционального величине весов.
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
Увеличим количество циклов тренировки с 5 до 15: num_epochs = 15.
Разморозим большее количество слоёв для тренировки: num_unfrozen_layers = 1.
После запуска тренировки с применением мы можем наблюдать повышение точности тренировки:
Ep 1 (Step 000000): Train loss 1.464, Val loss 1.523
Ep 1 (Step 000050): Train loss 1.107, Val loss 1.083
Training accuracy: 27.50% | Validation accuracy: 40.00%
Ep 2 (Step 000100): Train loss 1.109, Val loss 1.077
Training accuracy: 42.50% | Validation accuracy: 45.00%
Ep 3 (Step 000150): Train loss 1.080, Val loss 1.086
Ep 3 (Step 000200): Train loss 1.043, Val loss 1.049
Training accuracy: 25.00% | Validation accuracy: 32.50%
...
Ep 13 (Step 000900): Train loss 0.436, Val loss 0.471
Training accuracy: 82.50% | Validation accuracy: 60.00%
Ep 14 (Step 000950): Train loss 0.388, Val loss 0.475
Ep 14 (Step 001000): Train loss 0.331, Val loss 0.454
Training accuracy: 67.50% | Validation accuracy: 67.50%
Ep 15 (Step 001050): Train loss 0.324, Val loss 0.450
Training accuracy: 82.50% | Validation accuracy: 67.50%
Training completed in 0.28 minutes.
Также попробуем запустить тренировку большой модели 1558M.
Файл scripts\train_xl.py содержит следующую конфигурацию:
BASE_CONFIG = {
"vocab_size": 50257, # размер словаря
"context_length": 1024, # длина контекста
"emb_dim": 1600, # размер встраиваний
"n_layers": 48, # количество слоев для модели 1558M
"n_heads": 25, # количество голов внимания
"drop_rate": 0.0, # Коэффициент отбрасывания
"qkv_bias": True # Сдвиг запроса по ключу и значению
}
# Optimizer settings
learning_rate = 2e-5 # Более низкая скорость обучения для более крупной модели
weight_decay = 0.01 # уменьшение потери веса
Для большей модели попробуем сделать более частую переоценку: eval_freq = 25.
Вывод показывает, что мы достигли 100% точности тренировки. Как это интерпретировать рассмотрим ниже.
train_dataset.max_length: 44
Input batch dimensions: torch.Size([8, 44])
Label batch dimensions torch.Size([8])
72 training batches
11 validation batches
21 test batches
Ep 1 (Step 000000): Train loss 1.302, Val loss 1.285
Ep 1 (Step 000025): Train loss 0.702, Val loss 0.744
Ep 1 (Step 000050): Train loss 0.521, Val loss 0.596
Training accuracy: 80.00% | Validation accuracy: 62.50%
Ep 2 (Step 000075): Train loss 0.505, Val loss 0.548
Ep 2 (Step 000100): Train loss 0.389, Val loss 0.572
Ep 2 (Step 000125): Train loss 0.334, Val loss 0.480
Training accuracy: 65.00% | Validation accuracy: 72.50%
...
Ep 14 (Step 000950): Train loss 0.000, Val loss 0.428
Ep 14 (Step 000975): Train loss 0.000, Val loss 0.442
Ep 14 (Step 001000): Train loss 0.000, Val loss 0.432
Training accuracy: 100.00% | Validation accuracy: 87.50%
Ep 15 (Step 001025): Train loss 0.000, Val loss 0.430
Ep 15 (Step 001050): Train loss 0.000, Val loss 0.437
Ep 15 (Step 001075): Train loss 0.000, Val loss 0.442
Training accuracy: 100.00% | Validation accuracy: 90.00%
Training completed in 8.96 minutes.
Теория оценки эффективности обучения моделей
После тренировки модели мы получили текстовый вывод и графики (которые были сгенерированы кодом):
epochs_seen = list(range(1, num_epochs + 1))
plot_values(epochs_seen, examples_seen, train_losses, val_losses, label="loss")
plot_values(epochs_seen, examples_seen, train_accs, val_accs, label="accuracy")
Анализ графиков точности обучения и валидации нужен для понимания динамики обучения модели. Интерпретация позволяет оценить качество модели, диагностировать чрезмерную или недостаточную тренировку и внести коррективы в настройки тренировки.
Для LLM, таких как GPT-2, предлагается использовать 2 графика:
Точность в зависимости от пройденных эпох/поданных примеров, который показывает частоту совпадения прогнозов с фактическими данными с течением времени для обучающих и проверочных наборов.
Потери в зависимости от пройденных эпох/поданных примеров: служит для иллюстрации ошибок модели; меньшие потери указывают на лучшие прогнозы.
Паттерны графиков и то, на что они указывают
№ |
Шаблон |
Потери на обучении |
Потери на валидации |
Разрыв |
Точность |
Частые причины |
|---|---|---|---|---|---|---|
1 |
Идеальное обучение |
↓ |
↓ |
Малый |
↑ |
Хорошая архитектура/параметры |
2 |
Недообучение |
Высокие, стабильные |
Высокие, стабильные |
Малый |
Низкая |
Низкая ёмкость модели или плохие признаки |
3 |
Переобучение |
↓ |
↑ или стабильные |
Большой |
Валидация ↓ или ровная |
Слишком много эпох / мало данных / слабая регуляризация |
4 |
Раннее прекращение (оптимально) |
↓ |
↓, затем ↑ |
Растёт |
Пик на минимуме |
Обучать до минимума валидационных потерь |
5 |
Нестабильность |
Рваные/неустойчивые |
Рваные/неустойчивые |
Большой |
Нестабильная |
Слишком высокий learning rate, плохие батчи |
Пояснение к таблице:
№1. Идеальное обучение.
-
Потери при обучении: неуклонно снижаются.

График сгенерирован описанным выше скриптом train.py -
Потери при проверке: уменьшаются и следуют за потерями при обучении, возможно, немного больше.

График сгенерирован описанным выше скриптом train.py Интерпретация: модель хорошо обучается, а не переобучается или недообучается.
№2. Недостаточная адаптация.
Потери (в обоих случаях): высокие и не снижаются существенно.
Точность (в обоих случаях): низкая и не улучшается.
Закономерность: графики показывают незначительные изменения по эпохам или примерам.
Интерпретация: модель слишком проста, недостаточно функциональна или имеет проблемы с обучением (например, слишком низкая скорость обучения).
№3. Переобучение.
Потери при обучении: продолжают уменьшаться, достигая очень низких значений.
Потери при проверке: сначала уменьшаются, но затем увеличиваются или выходят на плато после определенного момента.
Точность (при обучении): очень высокая.
Точность (валидация): достигает максимума, затем замедляется или падает.
Закономерность: разрыв между графиками обучения и валидации увеличивается после определенных периодов.
Интерпретация: модель запоминает данные обучения, но не обобщает, может быть вызвано тем, что модель слишком большая для выбранного дата-сета.
№4. Хорошая точка остановки на ранней стадии.
Потери при проверке: достигает минимума перед потерей обучения; увеличение потери валидации после этого указывает на переобучение.
Интерпретация: наилучшая контрольная точка модели находится на минимальном уровне проверки или непосредственно перед ним.
№5. Нестабильность или расхождение.
Потери при проверке: показывает резкие скачки или быстрое увеличение после снижения.
Точность: неустойчивая, с большими скачками и падениями.
Закономерность: неровные линии вместо плавных кривых.
Интерпретация: слишком высокая скорость обучения, некачественные данные или нестабильная пакетная нормализация.
Потеря валидации/точности: перестает улучшаться, в то время как показатели обучения продолжают улучшаться.
Интерпретация: недостаточная вместимость модели, насыщенность данными или необходимость в дополнительной систематизации.
Рекомендации по обучению модели
Таким образом, при обучении языковой модели вроде GPT-2 важно понимать, что потери (loss) должны постепенно уменьшаться. Это показывает, что модель учится лучше предсказывать текст. Если значения потерь не падают — значит, что-то идёт не так в процессе обучения.
Нужно следить не только за эпохами, но и за количеством обработанных примеров. Это помогает точнее понимать, сколько реально данных модель уже «увидела», особенно если вы используете пакеты разного размера или продолжаете обучение после паузы.
Если графики немного «дрожат» от прогона к прогону — не переживайте, это нормально. Главное смотреть на общую тенденцию, а не на каждую точку. Если кривая всё-таки движется вниз, вы на правильном пути.
Иногда потери резко прыгают в начале эпохи. Скорее всего набор данных просто нужно перемешать перед каждым циклом. Это помогает сделать обучение стабильнее.
Чтобы проще видеть общие закономерности, можно сгладить графики, например, применить скользящее среднее. Так вы уберёте случайные всплески и лучше поймёте, как модель на самом деле обучается.
Если кажется, что модель «зазубривает» данные и не обобщает, попробуйте добавить регуляризацию: используйте dropout, соберите больше обучающих данных или уменьшите размер модели. Размер модели должен соответствовать датасету.
И последнее — сохраняйте контрольную копию модели (checkpoint) в тот момент, когда качество на проверочных данных лучшее, а не просто после последней эпохи. Так вы сможете вернуться к наиболее удачному состоянию модели без повторного обучения.
Оценка эффективности обучения и как мы можем на неё повлиять
Настало время оценить результаты тренировки. Я запускал тренировку для маленкой модели (124М) скриптом scripts\train.py и большой (1558M) scripts\train_xl.py Я покажу примеры диаграмм после обучения большой модели 1558M, они показались более наглядными.
Диаграмма точности тренировки:

График точности тренировки (Training accuracy, синяя линия) показывает, что модель правильно классифицирует каждый отдельный пример обучения после 5-ой эпохи, но это означает, что модель запомнила данные обучения. Это красный флаг: 100%-ная точность тренировки обычно слишком высока, чтобы быть правдой. Произошло переобучение на маленьком датасете. (На улице похолодало, поэтому и датасет такой маленький.)
При ~1000 примеров достижение 100% точности предполагает, что модель запомнила варианты, а не обобщает их. Прямо как троешник, который на экзамене сидел на заднем ряду и слушал ответы, а потом сдал на отлично. Ставь класс, если ты бы таким троешником.
График точности валидации (Validation accuracy, оранжевая линия) остановился на 0,9 (90%) после 6-ой эпохи, это значит, что модель правильно классифицирует 90% примеров проверки. Относительно большой разрыв свидетельствует о плохом обобщении.
Хорошим результатом считаются следующие значения:
Точность обучения: 95-98%.
Точность проверки: 85-90%.
Разрыв: 5-8%.
Диаграмма потерь:

На диаграмме мы видим потери тренировки и валидации.
Высокие начальные потери (1,25 в эпоху 0), поскольку модель делает случайные прогнозы.
Уменьшение потерь (до 0,4 в эпоху 4) означает, что модель эффективно обучилась на шаблонах. Это хороший признак, быстрая начальная конвергенция показывает, что производительность модели соответствует требованиям.
Далее должно наблюдаться устойчивое снижение потерь как при обучении, так и при валидации, но мы видим резкий скачок потерь при проверке (до 0,9 в эпоху 5) означает, что произошло переобучение, это предупреждающий знак. Причины могут быть следующие:
Слишком высокая скорость обучения.
Слишком долгое обучение на одних и тех же данных.
Модель начинает запоминать шумы в обучающих данных.
Восстановление (уменьшение потерь до 0,3 на эпохе 6), означает, что модель снова лучше классифицирует данные.
Диаграмма снова показывает, что маленький датасет не даёт эффективно учиться и происходит переобучения при использовании большой модели.
Чтобы это исправить, есть несколько подходов:
Использовать меньшую модель.
Задать автоматическую остановку обучения, если потери начинают расти.
Снизить скорость обучения, когда начнется переобучение.
Увеличить dropout или weight_decay.
Хорошим результатом считается плавный спуск обоих графиков.
Домашнее задание: доработать датасет и подобрать параметры, чтобы достичь хороших результатов.
Если же использовать маленькую модель 124M, более подходящую для датасета с учетом длины примеров, переданных в контекст, то мы увидим графики, соответствующие идеальному обучению и при этом достигнем поставленной задачи с использование минимума ресурсов.
Практическое применение
После обучения можно выполнить проверку, подав на вход модели несколько примеров и оценить результат прочитав (глазами) вывод модели, для этого выполним скрипт scripts\evaluate.py:
# Load tokenizer
tokenizer = tiktoken.get_encoding("gpt2")
# Load model
model = load_model(args.model, config, device)
print(f"Loaded model from {args.model}")
# Evaluate on test set
test_accuracy = calc_accuracy_loader(test_loader, model, device)
print(f"Test accuracy: {test_accuracy*100:.2f}%")
# Test on example prompts
test_prompts = [
"I feel so alone and worthless, like no one cares about me",
"I need to drink to forget this pain and make it stop",
"Let's organize our thoughts and create a structured plan to handle this",
"I feel scared and small, like I'm not good enough",
"I just want to numb this pain with alcohol or drugs",
"I must keep everything under control and prevent any mistakes",
"My heart aches with loneliness and despair",
"Let's go shopping to feel better",
"I need to be perfect to be worthy"
]
print("\nTesting model on example prompts:")
for prompt in test_prompts:
prediction = classify_prompt(prompt, model, tokenizer, test_dataset.max_length, device, class_names)
print(f"Prompt: '{prompt}'")
print(f"Predicted IFS Part: {prediction}")
print("-" * 50)
На выходе мы видим, что модель получает на вход реплику (промт), которую мы задали в скрипте, и пытается классифицировать часть по IFS, используя классы, которые мы использовали в датасете.
Учитывая, что размер датасета довольно маленький, его нужно провалидировать, а также еще более внимательно подобрать параметры тренировки, результат не совсем соответствует методологии, но технически цель была достигнута — модель умеет классифицировать описанную часть, что мы видим в выводе:
Testing model on example prompts:
Prompt: 'I feel so alone and worthless, like no one cares about me'
Predicted IFS Part: Firefighter
--------------------------------------------------
Prompt: 'I need to drink to forget this pain and make it stop'
Predicted IFS Part: Firefighter
--------------------------------------------------
Prompt: 'Let's organize our thoughts and create a structured plan to handle this'
Predicted IFS Part: Manager
--------------------------------------------------
Prompt: 'I feel scared and small, like I'm not good enough'
Predicted IFS Part: Firefighter
--------------------------------------------------
Prompt: 'I just want to numb this pain with alcohol or drugs'
Predicted IFS Part: Firefighter
--------------------------------------------------
Prompt: 'I must keep everything under control and prevent any mistakes'
Predicted IFS Part: Firefighter
--------------------------------------------------
Prompt: 'My heart aches with loneliness and despair'
Predicted IFS Part: Manager
--------------------------------------------------
Prompt: 'Let's go shopping to feel better'
Predicted IFS Part: Exile
--------------------------------------------------
Prompt: 'I need to be perfect to be worthy'
Predicted IFS Part: Firefighter
--------------------------------------------------
Подведение итогов
В данном материале мы рассмотрели процесс подготовки датасета, тонкую настройку модели для задач классификации и оценку результата тренировки. Задача тренировки модели не простая, но получив представление о том, как это сделать и подготовив набор инструментов, можно продолжить эксперименты. Расширяя датасет и подбирая параметры можно добиться нужного результата.
Двойные листочки можно использовать, чтобы сделать самолётик, а питон и тиктокен можно зачехлить обратно.
Исходный код для данной лабы можно найти в моём репозитории.
Всем спасибо!
P.S. Есть ли жизнь с LLM на AMD?
Тесты проводились на AMD Radeon RX 9060 XT 16GB. 24-ого сентября этого года AMD выпустили драйвера PyTorch Preview Edition, которые позволяют использовать CUDA для обучения модели. Большая модель обучается примерно за 90 минут на CPU, и 9 минут на GPU, что существенно.
Заходите в Телеграм-канал Alfa Digital — рассказываем о работе в IT и Digital, про спорт, карьеру, про работу направленией и команд, делимся интересными вакансиями, новостями и полезными советами, иногда шутим.
Рекомендуем:
mmMike
Ни на что она не способна. Вообще. Динозавр уже.
Мои эксперименты с обучением (в unslosh) показали, что максимум 4b модель LLM на 16Gb GPU можно до обучить и то на игрушечных данных.
А 4b модель - это игрушка, которая не стоить даже упоминания, не то что статьи. От LLM такого размера толку 0.
А уж < 1b...
Кстати, в unslosh в jupyter notebook гораздо удобнее. Особенно "на попробовать". И готовых примеров полно. хорошо комментированных примеров, которые можно прям по шагам запускать в notebook
К сожалению в 16Gb GPU для обучения даже модели Stable Diffusion XL не лезут. Только SD1.5 нормально. Но это для картинок. Там хоть, если узко специализировано обучить, то результата можно добиться.
Lora, к сожалению, на картинках дает хуже результат, чем обучение модели.
А LLM до обучать "дома" практического смысла нет.