VKВлад КуликовОткрытые лекции
RL для LLM7 / 13

Модуль 7. Модели награды и данные о предпочтениях

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

Скачать файлы курса (.zip) 382,1 КБ

Архив содержит файлы на обоих языках и вспомогательные материалы с сохранением структуры каталогов.

Практика Модуля 7: модели награды, калибровка и переоптимизация прокси

Основной ноутбук модуля

Скачать .ipynb module-07-reward-models.ru.ipynb · 20,1 КБ

Показать текст ноутбука

Практика Модуля 7: модели награды, калибровка и переоптимизация прокси

В ноутбуке есть два раздельных маршрута.

  1. Синтетический офлайн-маршрут работает на CPU с NumPy и PyTorch: обучает небольшую попарную модель, сохраняет разбиение по запросам, оценивает ранжирование и калибровку и воспроизводит учебный пример переоптимизации прокси.

  2. Маршрут с реальной моделью — необязательный план для среды с подходящим ускорителем с TRL, LoRA, Qwen/Qwen3-0.6B и trl-lib/ultrafeedback_binarized.

Канонический ноутбук хранится без результатов выполнения. Устанавливайте RUN_REAL_MODEL=True только после проверки ресурсов и происхождения данных. Реальная ветвь не запускалась при выпуске курса.

Контракт ресурсов и происхождения данных

  • Проверочный режим: 512–1000 пар предпочтений, max_length=512, одна короткая эпоха или ограниченное число шагов.
  • Полный учебный режим: 10–50 тысяч пар, max_length=1024, отдельные наборы для оценивания и калибровки. Реальная потребность в памяти зависит от длин и версий библиотек; параметры следует измерять, а не угадывать.
  • Пары UltraFeedback Binarized основаны на синтетической AI-обратной связи: кандидаты оценивались моделью, а не прямой человеческой разметкой.
  • Вместе с адаптерами сохраняйте токенизатор, шаблон диалога, версии модели и набора данных, статистику обрезки и скалярную голову score.
  • Успешный проверочный запуск подтверждает путь данных и кода, но не устойчивость RM под последующей оптимизацией политики.
Python
from __future__ import annotations

from dataclasses import dataclass
import math
import random

import numpy as np
import torch

SEED = 7
RUN_REAL_MODEL = False
SMOKE_MODE = True

random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)

print({"torch": torch.__version__, "device": "cuda" if torch.cuda.is_available() else "cpu"})

1. Синтетические данные предпочтений с разбиением по запросам

Маленькая модель учит один скаляр по интерпретируемым признакам ответа. Для каждого запроса строится несколько пар; все пары одного запроса остаются в одном разбиении. Такая постановка намеренно меньше языковой RM: здесь виден статистический контракт без шума большого стека.

Python
@dataclass(frozen=True)
class PairBatch:
    chosen: torch.Tensor
    rejected: torch.Tensor
    prompt_ids: list[str]
    first: torch.Tensor
    second: torch.Tensor
    first_preferred: torch.Tensor


def make_synthetic_pairs(n_prompts: int = 800, pairs_per_prompt: int = 2, seed: int = SEED):
    rng = np.random.default_rng(seed)
    true_w = np.array([1.5, 0.8, -0.9, 0.4], dtype=np.float32)
    rows = []
    for prompt_index in range(n_prompts):
        prompt_id = f"p{prompt_index:05d}"
        prompt_shift = rng.normal(0.0, 0.5)
        for _ in range(pairs_per_prompt):
            a = rng.normal(size=4).astype(np.float32)
            b = rng.normal(size=4).astype(np.float32)
            utility_a = float(a @ true_w + prompt_shift + rng.normal(0.0, 0.35))
            utility_b = float(b @ true_w + prompt_shift + rng.normal(0.0, 0.35))
            chosen, rejected = (a, b) if utility_a >= utility_b else (b, a)
            rows.append((prompt_id, chosen, rejected, a, b, float(utility_a >= utility_b)))
    return rows


def split_by_prompt(rows, eval_fraction: float = 0.2, seed: int = SEED):
    prompt_ids = sorted({row[0] for row in rows})
    rng = np.random.default_rng(seed)
    rng.shuffle(prompt_ids)
    cut = int(round(len(prompt_ids) * (1.0 - eval_fraction)))
    train_ids = set(prompt_ids[:cut])
    eval_ids = set(prompt_ids[cut:])
    assert train_ids.isdisjoint(eval_ids)

    def pack(selected_ids):
        chosen = np.stack([row[1] for row in rows if row[0] in selected_ids])
        rejected = np.stack([row[2] for row in rows if row[0] in selected_ids])
        ids = [row[0] for row in rows if row[0] in selected_ids]
        first = np.stack([row[3] for row in rows if row[0] in selected_ids])
        second = np.stack([row[4] for row in rows if row[0] in selected_ids])
        labels = [row[5] for row in rows if row[0] in selected_ids]
        return PairBatch(torch.tensor(chosen), torch.tensor(rejected), ids,
                         torch.tensor(first), torch.tensor(second), torch.tensor(labels))

    return pack(train_ids), pack(eval_ids)


rows = make_synthetic_pairs()
train_batch, eval_batch = split_by_prompt(rows)
print({"train_pairs": len(train_batch.prompt_ids), "eval_pairs": len(eval_batch.prompt_ids)})

2. Попарное обучение

Для оценок r+r^+ и r−r^- устойчивая функция потерь Брэдли—Терри равна

softplus⁡[−(r+−r−)].\operatorname{softplus}[-(r^+-r^-)].

В модели нет языкового представления: она изолирует попарную цель и соглашение о центрировании шкалы.

Python
class TinyRewardModel(torch.nn.Module):
    def __init__(self, n_features: int = 4):
        super().__init__()
        self.score = torch.nn.Linear(n_features, 1)

    def forward(self, features: torch.Tensor) -> torch.Tensor:
        return self.score(features).squeeze(-1)


def pair_metrics(model: TinyRewardModel, batch: PairBatch) -> dict[str, float]:
    model.eval()
    with torch.no_grad():
        delta = model(batch.chosen) - model(batch.rejected)
        probability = torch.sigmoid(delta)
        target = torch.ones_like(probability)
        loss = torch.nn.functional.binary_cross_entropy(probability, target)
        brier = torch.mean((probability - target) ** 2)
        return {
            "accuracy": float((delta > 0).float().mean()),
            "log_loss": float(loss),
            "brier": float(brier),
            "mean_margin": float(delta.mean()),
        }


model = TinyRewardModel()
optimizer = torch.optim.AdamW(model.parameters(), lr=0.03, weight_decay=1e-3)
steps = 120 if SMOKE_MODE else 600
batch_size = 256
rng = np.random.default_rng(SEED)

for step in range(steps):
    index = torch.tensor(rng.integers(0, len(train_batch.prompt_ids), size=batch_size))
    chosen_score = model(train_batch.chosen[index])
    rejected_score = model(train_batch.rejected[index])
    loss = torch.nn.functional.softplus(-(chosen_score - rejected_score)).mean()
    # Optional gauge convention: discourage a drifting common score origin.
    center = 0.5 * (chosen_score.mean() + rejected_score.mean())
    total_loss = loss + 1e-2 * center.square()
    optimizer.zero_grad(set_to_none=True)
    total_loss.backward()
    optimizer.step()

print("train", pair_metrics(model, train_batch))
print("eval ", pair_metrics(model, eval_batch))

3. Калибровочные корзины

Калибровка оценивается по вероятностям предпочтения, а не по сырым баллам. Сохраняйте исходный порядок A/B, заданный до наблюдения предпочтения, и соответствующую бинарную метку. Сортировка каждой пары в chosen/rejected обусловливает порядок исходом и делает все метки единицами; такие корзины не измеряют калибровку P(A предпочтительнее B). BT log loss и Brier score при согласованной перестановке вероятности и метки не меняются. ECE с равными корзинами — лишь одна сводка, зависящая от разбиения.

Python
def reliability_bins(probability: np.ndarray, label: np.ndarray, n_bins: int = 10):
    probability = np.asarray(probability, dtype=np.float64).reshape(-1)
    label = np.asarray(label, dtype=np.float64).reshape(-1)
    if probability.shape != label.shape or probability.size == 0:
        raise ValueError("aligned non-empty arrays required")
    indices = np.minimum((probability * n_bins).astype(int), n_bins - 1)
    bins = []
    for bin_id in range(n_bins):
        mask = indices == bin_id
        if np.any(mask):
            bins.append({
                "bin": bin_id,
                "count": int(mask.sum()),
                "confidence": float(probability[mask].mean()),
                "frequency": float(label[mask].mean()),
            })
    return bins


with torch.no_grad():
    eval_delta = model(eval_batch.first) - model(eval_batch.second)
    eval_probability = torch.sigmoid(eval_delta).numpy()
labels = eval_batch.first_preferred.numpy()
bins = reliability_bins(eval_probability, labels, n_bins=5)
ece = sum(item["count"] / len(labels) * abs(item["confidence"] - item["frequency"]) for item in bins)
print({"ece_5_bins": ece, "bins": bins})

4. Переоптимизация прокси

Следующая ячейка воспроизводит учебный механизм модуля. Это не эксперимент с языковой моделью и не воспроизведение опубликованного закона масштабирования.

Python
def toy_overoptimization(pool_sizes=(1, 2, 4, 8, 16, 32, 64, 128, 256, 512), trials=4000, seed=7):
    rng = np.random.default_rng(seed)
    proxy_means = []
    target_means = []
    for pool_size in pool_sizes:
        useful = rng.normal(size=(trials, pool_size))
        nuisance = rng.normal(size=(trials, pool_size))
        proxy = useful + nuisance
        target = useful - np.maximum(nuisance - 1.0, 0.0) ** 2
        selected = np.argmax(proxy, axis=1)
        rows = np.arange(trials)
        proxy_means.append(proxy[rows, selected].mean())
        target_means.append(target[rows, selected].mean())
    return np.asarray(proxy_means), np.asarray(target_means)


pool_sizes = np.array([1, 2, 4, 8, 16, 32, 64, 128, 256, 512])
proxy, target = toy_overoptimization(pool_sizes)
# Finite Monte Carlo estimates need not be monotone for every seed/budget.
assert np.isfinite(proxy).all() and np.isfinite(target).all()
print({"best_target_pool": int(pool_sizes[np.argmax(target)]), "final_proxy": proxy[-1], "final_target": target[-1]})

5. Необязательный маршрут с реальной моделью и ускорителем

Следующая команда — пример установки без закрепления версий. Подберите и проверьте совместимые версии в отдельном окружении с ускорителем, затем сохраните точные версии перед измерением. При необходимости перезапустите ядро.

text
pip install -U transformers datasets accelerate peft trl

Код следует актуальному на дату снимка интерфейсу TRL RewardTrainer. API меняются, поэтому рабочий запуск обязан закреплять версии. Голова score включается в modules_to_save, поскольку она инициализируется заново для классификации последовательностей.

Python
if RUN_REAL_MODEL:
    import torch
    from datasets import load_dataset
    from peft import LoraConfig, TaskType
    from trl import RewardConfig, RewardTrainer

    MODEL_ID = "Qwen/Qwen3-0.6B"
    DATASET_ID = "trl-lib/ultrafeedback_binarized"
    TRAIN_PAIRS = 512 if SMOKE_MODE else 10_000
    EVAL_PAIRS = 256 if SMOKE_MODE else 2_000
    MAX_LENGTH = 512 if SMOKE_MODE else 1024

    dataset = load_dataset(DATASET_ID)
    train_dataset = dataset["train"].select(range(min(TRAIN_PAIRS, len(dataset["train"]))))
    eval_split_name = "test" if "test" in dataset else next(name for name in dataset if name != "train")
    eval_dataset = dataset[eval_split_name].select(range(min(EVAL_PAIRS, len(dataset[eval_split_name]))))

    peft_config = LoraConfig(
        task_type=TaskType.SEQ_CLS,
        r=16,
        lora_alpha=32,
        lora_dropout=0.05,
        target_modules=["q_proj", "v_proj"],
        modules_to_save=["score"],
    )

    args = RewardConfig(
        output_dir="module-07-rm-smoke" if SMOKE_MODE else "module-07-rm-full",
        max_length=MAX_LENGTH,
        learning_rate=1e-4,
        per_device_train_batch_size=2,
        per_device_eval_batch_size=2,
        gradient_accumulation_steps=8,
        max_steps=40 if SMOKE_MODE else -1,
        num_train_epochs=1.0,
        eval_strategy="steps",
        eval_steps=20 if SMOKE_MODE else 200,
        save_strategy="no" if SMOKE_MODE else "steps",
        logging_steps=5,
        fp16=True,
        gradient_checkpointing=True,
        center_rewards_coefficient=1e-2,
        report_to="none",
        seed=SEED,
    )

    trainer = RewardTrainer(
        model=MODEL_ID,
        args=args,
        train_dataset=train_dataset,
        eval_dataset=eval_dataset,
        peft_config=peft_config,
    )
    trainer.train()
    metrics = trainer.evaluate()
    print(metrics)
else:
    print("Real-model branch skipped. Set RUN_REAL_MODEL=True in a reviewed Colab runtime.")

Контрольный список перед публикацией результатов реальной ветви

  • закрепить версии модели, набора данных, токенизатора, TRL, Transformers, PEFT, Accelerate и PyTorch;
  • сообщить число сохранённых, отфильтрованных и обрезанных пар;
  • разделять данные по запросам и добавить срезы по генераторам и доменам;
  • сообщать pair accuracy, log loss, Brier score и диаграмму надёжности;
  • сохранять адаптеры LoRA и скалярную голову score;
  • указывать тип ускорителя, время, пиковую память и случайные начальные значения;
  • не называть UltraFeedback Binarized прямой человеческой разметкой;
  • оценивать свежие ответы политики, не использованные при построении обучающих пар.