Trao đổi về dự án
Trí Tuệ Nhân Tạo

GRPO có thể học gì khi mọi câu trả lời đều sai?

Hãy để một mô hình nhỏ trả lời các bài toán tồn kho, rồi kiểm tra từng phản hồi JSON bằng phép tính chính xác. Bốn lần thử cho mỗi bài cho thấy khi nào GRPO học được, khi nào không có tín hiệu và các lần thử thêm tốn bao nhiêu.

Parcours pédagogique : comprendre le cas, construire, mesurer puis vérifier.

Một bài tập tồn kho hư cấu mô tả các lô hàng nhập và xuất. Mô hình phải trả về một JSON nhỏ chứa số lượng tồn kho cuối cùng dưới dạng số nguyên. Một bộ kiểm tra độc lập tính đáp án từ dữ liệu có cấu trúc mà không thực thi văn bản của mô hình.

Quan sát bốn câu trả lời cho cùng một bài toán, phần thưởng, lợi thế tương đối và một lần cập nhật của chúng. So sánh SFT khởi tạo, SFT bổ sung và GRPO trên các bài toán mới, cùng chi phí tạo sinh.

Tải xuống toàn bộ dự án, bản sửa đổi r001

Lộ trình của bài hướng dẫn, từ tình huống cụ thể đến kiểm chứng.

Câu trả lời phải khớp với phép tính

StockArithmetic-v1 có 1.400 bài toán: 1.000 bài train, 200 bài validation và 200 bài test. Mỗi bài cho một tồn kho ban đầu, hai lần giao hàng rồi hai lần rút hàng; mọi mức tồn kho trung gian đều không âm. Các tuple được khử trùng lặp sau khi sắp xếp riêng giao hàng và rút hàng, để một hoán vị của cùng các chuyển động không đi qua các tập phân chia. Các mẫu ngôn ngữ và phép toán số học được dùng chung: corpus nhỏ này không đại diện cho tồn kho thực tế.

Các toán hạng train và validation nằm trong khoảng từ 0 đến 50. Tập test có 100 bài trong khoảng đó và 100 bài có ít nhất một toán hạng từ 51 đến 100, được đánh dấu OOD100. Kết quả của hai quần thể này được tách riêng. 128 mục tiêu train đầu tiên dùng cho khởi tạo; 872 mục tiêu còn lại dùng cho SFT bổ sung hoặc các lần rút prompt RL. Các mục tiêu được tính toán, không do con người gán nhãn hay do LLM tạo ra.

Mô hình chỉ thấy đề bài và một hướng dẫn định dạng. Bộ kiểm tra nhận riêng cấu trúc của bài toán. Nó yêu cầu đúng một đối tượng JSON hoàn chỉnh, giới hạn 256 ký tự, chứa chính xác final_stock với một số nguyên từ 0 đến 300. Boolean, số thực, trường bổ sung, trường trùng lặp, Markdown, nhiều đối tượng và số không hữu hạn đều bị từ chối. Một ID được tạo mà tokenizer không có cũng làm câu trả lời không hợp lệ; ID đó không thể âm thầm biến mất khi giải mã. Không văn bản sinh ra nào được thực thi. Một câu trả lời dừng ở giới hạn token nhận điểm không, dù phần đầu chứa JSON đúng.

Phần thưởng bằng một cho một câu trả lời hoàn chỉnh và đúng, bằng không trong mọi trường hợp khác. Không có thưởng phong cách, phần thưởng một phần hay giải mã JSON có ràng buộc. Bộ giải xác định đã biết cách tính mọi mục tiêu: việc học tốn kém có thể không cần thiết cho bài toán này.

Ba chính sách khác nhau

Qwen2.5-0.5B-Instruct được lấy tại bản sửa đổi 7ae557604adf67be50417f59c2c2f167def9a775. Toàn bộ 494032768 tham số duy nhất của nó được tối ưu ở FP32, với eager attention và TF32 bị tắt. Kiến trúc cùng các trọng số embedding/đầu ra được buộc chung đều được kiểm tra. Logit dự đoán token tiếp theo; chỉ các token phản hồi được giám sát, gồm cả EOS 151645. Prompt và padding được che theo vị trí. Prompt có tối đa 256 token, phản hồi 64 token, tổng 320; không ví dụ giám sát quá dài nào bị cắt âm thầm.

SFT khởi tạo học 128 ví dụ trong ba epoch, tức 48 lần cập nhật với tám ví dụ mỗi lần. SFT bổ sung bắt đầu từ trạng thái đó và học 872 ví dụ trong hai epoch, tức 218 lần cập nhật. Cả hai dùng AdamW ở 2e−5. CE được chuẩn hóa trên mọi token hoạt động của lần tích lũy, không phải trung bình của các trung bình phản hồi.

GRPO bắt đầu từ cùng trạng thái khởi tạo. Tham chiếu cố định là một bản sao được nạp lại đúng từ trạng thái đó, bị đóng băng và không có gradient. Chính sách cũ là trạng thái đã tạo ra các phản hồi của một vòng lặp; log-probability của nó được ghi lại khi tạo sinh và vẫn tách rời. Chính sách hiện tại nhận gradient. Không có critic giá trị nào được học. Ba vai trò này không thể thay thế cho nhau.

Từ nhóm đến lợi thế

Một vòng lặp chọn bốn prompt train. Với mỗi prompt, nó lấy mẫu G=4 phản hồi tuần tự ở nhiệt độ 1, không top-k, không cắt top-p và không loại token nào khỏi từ vựng. Xác suất hành vi đến từ các logit thực tại thời điểm lấy mẫu. Token, điểm số, trạng thái chính sách, lý do dừng và thời gian được giữ lại. Chế độ eval tắt dropout khi tạo sinh VÀ khi cập nhật; eval không cấm autograd.

Trong mỗi nhóm, tính A=(phần thưởng−trung bình)/(độ lệch chuẩn+1e−8), dùng độ lệch chuẩn quần thể, correction=0. Phần thưởng và lợi thế được tách rời. Với [1,0,1,0], các lợi thế xấp xỉ [1,−1,1,−1]; epsilon giải thích vì sao chúng không hoàn toàn là các số nguyên đó. Nếu cả bốn phần thưởng giống nhau, mọi lợi thế đều chính xác bằng không. Ta đo tần suất của những nhóm này mà không bịa ra một người thắng.

Ở mỗi token phản hồi, rho=exp(logp_hiện_tại−logp_cũ). Trước lần cập nhật đầu tiên, việc chấm điểm toàn bộ chuỗi phải khôi phục các điểm đã ghi ở các tiền tố trong sai số 2e−4; hình dạng kernel có thể tạo khác biệt số học nhỏ. Điểm cũ không bao giờ bị thay bằng các logit mới đã tách rời sau khi học.

Hạng cần tối đa hóa là min(rho A, clip(rho,0,8,1,2) A). Dấu của A là thiết yếu: các kiểm thử kiểm tra cả hai phía và gradient của các nhánh bị cắt. Loss là số đối của hạng này, lấy trung bình trước trên token của mỗi phản hồi, rồi trên các phản hồi và cuối cùng trên các prompt. Một phản hồi dài không tự động nhận nhiều trọng số tổng hơn; tuy vậy, chuẩn hóa này vẫn có thiên lệch về độ dài và độ khó mà một thí nghiệm nhỏ không loại bỏ được.

KL chính xác trên các tiền tố đã đi qua

Biến thể này có tên “GRPO sư phạm, KL chính xác trên tiền tố”. Với mỗi tiền tố thực sự đã được đi qua, ta tính tổng_v p_hiện_tại(v) × [log p_hiện_tại(v)−log p_tham_chiếu(v)] trên toàn bộ từ vựng. Từ vựng được duyệt theo các khối 4096, với các chuẩn hóa logsumexp đầy đủ. Chuẩn hóa từng khối riêng lẻ sẽ làm thay đổi phân kỳ.

Tổng phân loại này chính xác đến sai số làm tròn cho mỗi tiền tố. Các tiền tố vẫn là những tiền tố từ rollout: nó không liệt kê mọi chuỗi có thể. Hệ số bằng 0,02. Cách rút gọn của nó theo cùng trung bình token/phản hồi/prompt như surrogate. Giá trị và gradient trên các tensor nhỏ được so sánh với kl_div của PyTorch ở FP64 và FP32; một vài phân phối đầy đủ thực đóng vai trò các phép thăm dò độc lập.

Bài báo dùng một ước lượng khác cho phạt KL. TRL cung cấp nhiều phép rút gọn, trong đó có DAPO; chúng tôi không khẳng định mặc định của nó hay sự đồng nhất với trainer của nó. Bài thực hành triển khai rõ ràng biến thể riêng và không cài TRL. Các nhóm có lợi thế bằng không không có gradient phần thưởng, nhưng điều chuẩn KL và quy tắc tối ưu vẫn có thể tác động.

Ngân sách, chi phí và tiếp tục chạy

GRPO tối thiểu có 100 vòng lặp, bốn prompt mỗi vòng, bốn phản hồi mỗi prompt và hai lần cập nhật trên mỗi tập rollout: 1.600 phản hồi thực và 200 lần cập nhật. AdamW dùng lr=1e−6, weight_decay=0,01, cắt gradient ở 1, warm-up 10% rồi giảm tuyến tính xuống 10% tốc độ tối đa. Khởi tạo, SFT và RL không tiêu thụ cùng ví dụ, token, lượt forward hay GPU-giây; không giả định chi phí bằng nhau.

Tạo sinh không có cache KV ưu tiên việc chấm điểm dễ hiểu. Thời gian tạo sinh, kiểm tra điểm cũ, tối ưu với tham chiếu, toàn bộ vòng lặp và các lệnh được phân biệt. Dấu vân tay trạng thái cũng có chi phí. Chi phí của so sánh chạy liên tục/tiếp tục phải hiện rõ ngoài chi phí huấn luyện liên tục tham chiếu.

GRPO tiếp tục sau một vòng lặp đã được tối ưu hoàn toàn, tức sau hai lần cập nhật. Toàn bộ mô hình, AdamW, scheduler, hoán vị/con trỏ prompt, RNG tạo sinh, RNG hệ thống và danh sách rollout đã dùng được lưu. Dừng ở 40, chạy liên tục đến 100 và tiếp tục 60 vòng còn lại phải kết thúc ở các trạng thái giống hệt nhau. Một rollout không thể được dùng hai lần. Thời lượng thực nằm ngoài trạng thái số có thể so sánh, nhưng bằng chứng của chúng được giữ lại. SFT cũng so sánh dừng sau một epoch, chạy liên tục và tiếp tục.

Mười lăm trạng thái đầy đủ của năm nhánh được giữ cục bộ trong gzip mức 1 không mất dữ liệu; năm mô hình cuối cũng được nạp lại từ Safetensors. Kích thước được đo, không hứa hẹn tỷ lệ nén. Chỉ được phép nạp đối tượng Torch ở đây vì chúng được tạo cục bộ; không thay chúng bằng một tệp bên thứ ba.

Đánh giá chính sách, không chọn phản hồi tốt nhất của nó

Các chính sách khởi tạo, SFT và GRPO mỗi chính sách tạo một phản hồi greedy trên cùng 200 bài test: đó là pass@1 greedy. Chúng cũng tạo bốn lần lấy mẫu ngẫu nhiên độc lập cho mỗi bài với seed cố định. Tỷ lệ thành công theo mỗi lần lấy mẫu dùng 800 phản hồi; nó không phải thành công của đáp án tốt nhất trong bốn. Các quần thể ID50, OOD100 và tổng của chúng vẫn tách biệt.

Tính hợp lệ JSON, độ chính xác, không có EOS, độ dài và thời gian được tính trên mọi đầu ra. Validation SFT đo NLL mà không chọn checkpoint; các trạng thái cuối được cố định trước thử nghiệm. Phản hồi test không đi vào các cập nhật. Loss giảm không chứng minh độ chính xác tăng, và kết quả bất lợi cho GRPO vẫn có thể được công bố.

Bài tập: hai kích thước nhóm

Các nhóm hằng số trước hết được nghiên cứu bằng phần thưởng được dựng sẵn, tách biệt rõ khỏi phản hồi của mô hình. Sau đó G=2 và G=8 thực sự được huấn luyện 20 vòng từ cùng trạng thái khởi tạo, với cùng 80 lần trình bày prompt và 40 lần cập nhật. Chúng lần lượt tạo 160 và 640 rollout: cùng số lần cập nhật không có nghĩa cùng chi phí. Cả hai chính sách được đánh giá trên cùng 200 bài toán và bốn lần lấy mẫu.

G=4 chính có 100 vòng lặp. So sánh trực tiếp với các nhánh 20 vòng sẽ không phải một ablation chỉ cô lập kích thước nhóm; giới hạn này được nêu rõ. Bài tập bảo vệ SHA-256 của trọng số, kết quả và cấu hình chính. Bộ kiểm tra không thay đổi, kể cả khi mọi nhóm đều sai.

Viết bài toán và bộ kiểm tra độc lập

Phần thưởng không phụ thuộc vào mô hình

Lỗi cần chẩn đoán: một đầu ra chứa hai JSON, trong đó một cái đúng; yêu cầu một phản hồi hoàn chỉnh duy nhất.

Bất biến: 1.000/200/200 bài toán riêng biệt; bộ giải chính xác trên fixture và trường hợp biên.

Tệp src/grpo_recompense_verifiable/verifier.py

import json, random


def compute_final_stock(problem):
    if not isinstance(problem, dict) or set(problem) != {
        "initial",
        "deliveries",
        "withdrawals",
    }:
        raise ValueError("Expected initial, deliveries and withdrawals only")
    if type(problem["initial"]) is not int or not 0 <= problem["initial"] <= 100:
        raise ValueError("Initial stock must be an integer in 0..100")
    stock = problem["initial"]
    for key, sign in [("deliveries", 1), ("withdrawals", -1)]:
        values = problem[key]
        if (
            not isinstance(values, list)
            or len(values) != 2
            or any(type(v) is not int or not 0 <= v <= 100 for v in values)
        ):
            raise ValueError("Exactly two bounded integer movements required")
        for value in values:
            stock += sign * value
            if stock < 0:
                raise ValueError("An intermediate stock cannot be negative")
    return stock


def normalized_problem(problem):
    compute_final_stock(problem)
    return (
        problem["initial"],
        tuple(sorted(problem["deliveries"])),
        tuple(sorted(problem["withdrawals"])),
    )


def generate_stock_problem(rng, maximum=50):
    if maximum not in [50, 100]:
        raise ValueError("Named ID50 or OOD100 range required")
    initial = rng.randint(0, maximum)
    deliveries = [rng.randint(0, maximum) for _ in range(2)]
    available = initial + sum(deliveries)
    first = rng.randint(0, min(maximum, available))
    second = rng.randint(0, min(maximum, available - first))
    return {
        "initial": initial,
        "deliveries": deliveries,
        "withdrawals": [first, second],
    }


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("Duplicate JSON field")
        result[key] = value
    return result


def reject_constant(value):
    raise ValueError("Non-finite JSON number forbidden")


def parse_generated_json(text, finish_reason):
    if finish_reason != "eos":
        return {"valid": False, "value": None, "reason": "completion_without_eos"}
    if not isinstance(text, str) or len(text) > 256:
        return {"valid": False, "value": None, "reason": "oversized_or_nontext"}
    try:
        obj = json.loads(
            text, object_pairs_hook=unique_object, parse_constant=reject_constant
        )
        if type(obj) is not dict or set(obj) != {"final_stock"}:
            raise ValueError("One exact field required")
        value = obj["final_stock"]
        if type(value) is not int or not 0 <= value <= 300:
            raise ValueError("A bounded integer excluding bool/float is required")
        return {"valid": True, "value": value, "reason": None}
    except (ValueError, TypeError, RecursionError):
        return {"valid": False, "value": None, "reason": "invalid_json_schema"}


def score_generated_json(text, finish_reason, problem, tokenization_valid=True):
    expected = compute_final_stock(problem)
    parsed = parse_generated_json(text, finish_reason)
    if type(tokenization_valid) is not bool:
        raise ValueError("Explicit tokenization validity required")
    if not tokenization_valid:
        parsed = {"valid": False, "value": None, "reason": "undecodable_token"}
    return {
        **parsed,
        "reward": int(parsed["valid"] and parsed["value"] == expected),
        "expected": expected,
    }

Tạo một nhóm và kiểm tra điểm số

Rollout và xác suất hành vi

Lỗi cần chẩn đoán: dropout đang hoạt động làm log-prob không nhất quán; đặt chính sách ở chế độ đánh giá nhưng vẫn cho phép autograd khi cập nhật.

Bất biến: mỗi nhóm có một prompt_id duy nhất và bốn phản hồi với cách chấm điểm tương thích.

Tệp src/grpo_recompense_verifiable/rollout.py

import time
import torch
from .scores import format_prompt, rollout_completion_row, model_scores
from .loss import group_advantages
from .verifier import score_generated_json


def capture_behavior_logprobs(logits, token):
    # Probability captured at the actual sampling prefix, not reconstructed from
    # the current policy after an optimizer step. Temperature is fixed to one.
    return float(torch.log_softmax(logits.float(), dim=-1)[token].detach())


@torch.no_grad()
def sample_response(
    model,
    tokenizer,
    page,
    config,
    generator,
    mode="sample",
    policy_id=None,
    draw=0,
    capture_probe=False,
):
    if model.training:
        raise ValueError("Sampling requires eval mode; dropout must be off")
    if config["temperature"] != 1.0 or config["top_p"] != 1.0 or config["top_k"] != 0:
        raise ValueError(
            "The public protocol requires full support and temperature one"
        )
    if mode not in ["sample", "greedy"]:
        raise ValueError("Named generation mode required")
    messages, prompt = format_prompt(
        tokenizer, page["statement"], config["template_version"]
    )
    if not prompt or len(prompt) > config["max_prompt_tokens"]:
        raise ValueError("Prompt exceeds the declared budget; no truncation")
    ids = torch.tensor([prompt], device="cuda")
    response = []
    behavior = []
    first_distribution = None
    torch.cuda.synchronize()
    start = time.perf_counter()
    for position in range(config["max_new_tokens"]):
        # No KV cache: sampling and teacher-forced scoring both use eager FP32
        # causal attention. Prefix/full-sequence kernels may differ numerically.
        logits = (
            model(
                input_ids=ids,
                attention_mask=torch.ones_like(ids),
                use_cache=False,
                return_dict=True,
            )
            .logits[0, -1]
            .float()
        )
        probabilities = torch.softmax(logits, dim=-1)
        if not torch.isfinite(probabilities).all():
            raise ValueError("Non-finite sampling distribution")
        token = (
            int(torch.argmax(logits))
            if mode == "greedy"
            else int(torch.multinomial(probabilities, 1, generator=generator))
        )
        behavior.append(capture_behavior_logprobs(logits, token))
        response.append(token)
        if position == 0 and capture_probe:
            first_distribution = {
                "prefix_ids": prompt,
                "logits": logits.cpu().tolist(),
                "target": token,
                "selected_logprob": behavior[-1],
            }
        ids = torch.cat([ids, torch.tensor([[token]], device="cuda")], dim=1)
        if token == tokenizer.eos_token_id:
            break
    torch.cuda.synchronize()
    seconds = time.perf_counter() - start
    finish = "eos" if response[-1] == tokenizer.eos_token_id else "length"
    # Keep non-EOS special tokens in decoded text: silently dropping generated
    # controls could turn an invalid response into apparently valid JSON.
    decoded_ids = response[:-1] if finish == "eos" else response
    text = tokenizer.decode(
        decoded_ids, skip_special_tokens=False, clean_up_tokenization_spaces=False
    )
    tokenization_valid = all(
        tokenizer.convert_ids_to_tokens(token) is not None for token in response
    )
    return {
        "document_id": page["document_id"],
        "messages": messages,
        "prompt_ids": prompt,
        "response_ids": response,
        "old_token_logprobs": behavior,
        "response_tokens": len(response),
        "finish_reason": finish,
        "text": text,
        "tokenization_valid": tokenization_valid,
        "generation_seconds": seconds,
        "mode": mode,
        "draw": draw,
        "policy_id": policy_id,
        "temperature": 1.0,
        "top_p": 1.0,
        "top_k": 0,
        "dropout_active": False,
        "first_prefix_probe": first_distribution,
    }


@torch.no_grad()
def sample_group(
    model,
    tokenizer,
    page,
    problem,
    config,
    generator,
    policy_id,
    group_id,
    group_size=None,
    capture_probe=False,
):
    size = config["group_size"] if group_size is None else group_size
    if type(size) is not int or size < 2:
        raise ValueError("At least two actual stochastic responses required")
    responses = []
    for member in range(size):
        row = sample_response(
            model,
            tokenizer,
            page,
            config,
            generator,
            policy_id=policy_id,
            draw=member,
            capture_probe=capture_probe and member < 2,
        )
        row["rollout_id"] = f"{group_id}/response-{member:02d}"
        row["verifier"] = score_generated_json(
            row["text"], row["finish_reason"], problem, row["tokenization_valid"]
        )
        responses.append(row)
    rewards = torch.tensor(
        [[r["verifier"]["reward"] for r in responses]], dtype=torch.float32
    )
    advantages, mean, std = group_advantages(rewards, config["advantage_epsilon"])
    return {
        "group_id": group_id,
        "document_id": page["document_id"],
        "behavior_policy_id": policy_id,
        "responses": responses,
        "rewards": rewards[0].tolist(),
        "advantages": advantages[0].tolist(),
        "reward_mean": float(mean[0, 0]),
        "population_std": float(std[0, 0]),
        "zero_variance": bool(std[0, 0] == 0),
        "std_correction": 0,
        "epsilon": config["advantage_epsilon"],
    }


@torch.no_grad()
def verify_behavior_before_update(model, group, tolerance=2e-4):
    if model.training:
        raise ValueError("Ratio verification requires eval mode")
    maximum = 0.0
    for response in group["responses"]:
        _, _, actual, _, _ = model_scores(model, [rollout_completion_row(response)])
        recorded = torch.tensor(
            response["old_token_logprobs"], device=actual.device, dtype=actual.dtype
        )
        error = float((actual - recorded).abs().max())
        maximum = max(maximum, error)
        if error > tolerance:
            raise ValueError(
                "Behavior/full-prefix scoring disagreement before update: " + str(error)
            )
    return {
        "maximum_absolute_logprob_difference": maximum,
        "tolerance": tolerance,
        "old_logprobs_source": "captured at actual generation, unchanged",
        "before_any_update": True,
    }

Khởi tạo và so sánh SFT trực tiếp

Tham chiếu học không có RL

Lỗi cần chẩn đoán: tải checkpoint của một bài thực hành cũ thay thế phần tối thiểu; hãy khởi tạo lại trong dự án này.

Bất biến: không ví dụ test nào trong khởi tạo; chi phí và hash ban đầu được ghi lại.

Tệp src/grpo_recompense_verifiable/warmstart.py

import math, time
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all, rng_state, restore_rng
from .model import (
    load_base,
    load_tokenizer,
    reload_learned,
    save_learned,
    parameter_manifest,
    manifest_digest,
)
from .scores import completion_row, model_scores
from .state import save_resume_state, load_resume_state, equal


def learning_rate(config, step, total, peak):
    warm = max(1, int(total * config["warmup_ratio"]))
    if step < warm:
        return peak * (step + 1) / warm
    return peak * (
        1
        - min(1.0, (step - warm) / max(1, total - warm - 1))
        * (1 - config["minimum_lr_ratio"])
    )


def assert_learning_scope():
    for name in [
        "data/generated/input",
        "data/generated/labels/gold.json",
        "outputs/inference",
    ]:
        if Path(name).exists():
            raise ValueError(
                "Learning must not mount reserved inputs, gold or inference outputs: "
                + name
            )


def encoded_split(config, tokenizer, split):
    pages = read_json(f"data/generated/{split}/pages.json")
    labels = {
        r["document_id"]: r for r in read_json(f"data/generated/labels/{split}.json")
    }
    return [
        completion_row(
            tokenizer,
            p["statement"],
            labels[p["document_id"]]["response"],
            config,
            p["document_id"],
        )
        for p in pages
    ]


@torch.no_grad()
def validation_nll(model, rows):
    model.eval()
    result = []
    for row in rows:
        sums, counts, values, _, targets = model_scores(model, [row])
        result.append(
            {
                "document_id": row["document_id"],
                "sum_logprob": float(sums[0]),
                "tokens": int(counts[0]),
                "token_logprobs": values.cpu().tolist(),
                "targets": targets.cpu().tolist(),
            }
        )
    return {
        "examples": len(rows),
        "tokens": sum(r["tokens"] for r in result),
        "nll": -sum(r["sum_logprob"] for r in result)
        / sum(r["tokens"] for r in result),
        "rows": result,
        "used_for_selection": False,
    }


def freeze_initial_reference():
    model, metadata = reload_learned("assets/checkpoints/warmstart-resume.safetensors")
    for parameter in model.parameters():
        parameter.requires_grad_(False)
    model = model.cuda().eval()
    if any(p.requires_grad or p.grad is not None for p in model.parameters()):
        raise ValueError("Reference must be frozen without gradients")
    return model, metadata


def train_warmstart(config, branch="warmstart", mode="continuous"):
    if branch not in ["warmstart", "sft"] or mode not in [
        "interrupt",
        "continuous",
        "resume",
    ]:
        raise ValueError("Unknown SFT phase")
    assert_learning_scope()
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    split = "warmstart" if branch == "warmstart" else "train"
    rows = encoded_split(config, tokenizer, split)
    validation = encoded_split(config, tokenizer, "validation")
    if len(rows) != (128 if branch == "warmstart" else 872) or len(validation) != 200:
        raise ValueError("Frozen SFT population changed")
    model = (
        load_base()
        if branch == "warmstart"
        else reload_learned("assets/checkpoints/warmstart-resume.safetensors")[0]
    )
    model = model.cuda().eval()
    parameters = list(model.parameters())
    initial = parameter_manifest(model)
    digest = manifest_digest(initial)
    if sum(p.numel() for p in parameters) != 494032768 or any(
        not p.requires_grad or p.dtype != torch.float32 for p in parameters
    ):
        raise ValueError("Expected full FP32 policy")
    epochs = (
        config["warmstart_epochs"] if branch == "warmstart" else config["sft_epochs"]
    )
    peak = (
        config["warmstart_learning_rate"]
        if branch == "warmstart"
        else config["sft_learning_rate"]
    )
    total = math.ceil(len(rows) / config["accumulation_examples"]) * epochs
    identity = {
        "branch": branch,
        "config": config,
        "initial_model_digest": digest,
        "model_sha256": sha("assets/model/model.safetensors"),
        "tokenizer_sha256": sha("assets/model/tokenizer.json"),
        "data_sha256": {
            name: sha("data/generated/" + name)
            for name in [
                f"{split}/pages.json",
                f"labels/{split}.json",
                "validation/pages.json",
                "labels/validation.json",
            ]
        },
        "code_sha256": {
            name: sha("src/grpo_recompense_verifiable/" + name)
            for name in ["model.py", "scores.py", "warmstart.py", "state.py"]
        },
    }
    optimizer = torch.optim.AdamW(
        parameters, lr=peak, weight_decay=config["weight_decay"]
    )
    sampler = torch.Generator().manual_seed(config["seed"] + 100)
    root = Path("assets/checkpoints")
    destination = root / f"{branch}-{mode}.pt.gz"
    if destination.exists():
        raise ValueError("Preserve existing full checkpoint")
    updates = start = 0
    history = []
    validation_history = []
    if mode == "resume":
        state = load_resume_state(root / f"{branch}-interrupt.pt.gz")
        if state["identity"] != identity:
            raise ValueError("SFT resume identity changed")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        sampler.set_state(state["sampler"])
        restore_rng(state["rng"])
        updates = state["updates"]
        start = state["epoch"]
        history = state["history"]
        validation_history = state["validation_history"]
        if state["scheduler"] != {
            "next_update": updates,
            "next_lr": learning_rate(config, updates, total, peak),
        }:
            raise ValueError("SFT scheduler changed")
        del state
    stop = 1 if mode == "interrupt" else epochs
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
    begun = time.perf_counter()
    optimization_seconds = 0.0
    for epoch in range(start, stop):
        order = torch.randperm(len(rows), generator=sampler).tolist()
        torch.cuda.synchronize()
        start_epoch = time.perf_counter()
        for offset in range(0, len(rows), config["accumulation_examples"]):
            selected = order[offset : offset + config["accumulation_examples"]]
            denominator = sum(rows[i]["response_tokens"] for i in selected)
            lr = learning_rate(config, updates, total, peak)
            for group in optimizer.param_groups:
                group["lr"] = lr
            optimizer.zero_grad(set_to_none=True)
            details = []
            for index in selected:
                sums, counts, *_ = model_scores(model, [rows[index]])
                loss = -sums.sum() / denominator
                if not torch.isfinite(loss):
                    raise ValueError("Nonfinite SFT objective")
                loss.backward()
                details.append(
                    {
                        "document_id": rows[index]["document_id"],
                        "sum_logprob": float(sums.detach()[0]),
                        "response_tokens": int(counts[0]),
                    }
                )
            norm = torch.nn.utils.clip_grad_norm_(
                parameters, config["gradient_clip"], error_if_nonfinite=True
            )
            if any(p.grad is None or p.grad.dtype != torch.float32 for p in parameters):
                raise ValueError("All full-policy gradients required")
            optimizer.step()
            updates += 1
            history.append(
                {
                    "update": updates,
                    "epoch": epoch + 1,
                    "document_ids": [rows[i]["document_id"] for i in selected],
                    "response_token_denominator": denominator,
                    "nll": -sum(r["sum_logprob"] for r in details) / denominator,
                    "learning_rate": lr,
                    "gradient_norm_before_clip": float(norm),
                    "rows": details,
                }
            )
        torch.cuda.synchronize()
        optimization_seconds += time.perf_counter() - start_epoch
        observed = validation_nll(model, validation)
        validation_history.append({"epoch": epoch + 1, **observed})
        print(
            {
                "branch": branch,
                "epoch": epoch + 1,
                "updates": updates,
                "validation_nll": observed["nll"],
            },
            flush=True,
        )
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - begun
    peak_memory = torch.cuda.max_memory_allocated()
    final = parameter_manifest(model)
    final_digest = manifest_digest(final)
    if final_digest == digest:
        raise ValueError("No actual SFT weight update")
    state = {
        "identity": identity,
        "epoch": stop,
        "updates": updates,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "sampler": sampler.get_state(),
        "scheduler": {
            "next_update": updates,
            "next_lr": learning_rate(config, updates, total, peak),
        },
        "rng": rng_state(),
        "history": history,
        "validation_history": validation_history,
    }
    compression = save_resume_state(destination, state)
    if mode == "resume":
        save_learned(model, root / f"{branch}-resume.safetensors", identity)
    save_json(
        f"outputs/learning/train-{branch}-{mode}.json",
        {
            "branch": branch,
            "mode": mode,
            "identity": identity,
            "epochs": stop,
            "updates": updates,
            "history": history,
            "validation_history": validation_history,
            "tokenization": rows,
            "initial_parameters": initial,
            "final_parameters": final,
            "initial_model_digest": digest,
            "final_model_digest": final_digest,
            "trainable_parameters": 494032768,
            "checkpoint_sha256": sha(destination),
            "checkpoint_bytes": destination.stat().st_size,
            "checkpoint_compression": compression,
            "optimization_seconds_this_process": optimization_seconds,
            "training_and_validation_seconds_this_process": elapsed,
            "max_torch_allocated_bytes": peak_memory,
            "test_visible": False,
            "dropout_active": False,
        },
    )


def verify_resume(branch):
    root = Path("assets/checkpoints")
    a = load_resume_state(root / f"{branch}-continuous.pt.gz")
    b = load_resume_state(root / f"{branch}-resume.pt.gz")
    checks = {k: equal(a[k], b[k]) for k in a}
    if a.keys() != b.keys() or not all(checks.values()):
        raise ValueError("Full resume differs: " + str(checks))
    save_json(
        f"outputs/learning/resume-{branch}.json",
        {
            "status": "PASS",
            "all_state_exact": True,
            "checks": checks,
            "boundary": "Full epoch for SFT; completed rollout iteration with both optimizer updates for GRPO. Timings live outside resumable numerical state.",
        },
    )

Lập trình lợi thế, clipping và KL

Cập nhật trực tuyến theo nhóm

Lỗi cần chẩn đoán: dùng log-prob hiện tại đã tách rời làm old ở mỗi microbatch sẽ xóa lịch sử; hãy ghi lại khi tạo sinh.

Bất biến: các kiểm thử dấu của clipping, nhóm hằng số và gradient đều đạt; chính sách thực sự được cập nhật.

Tệp src/grpo_recompense_verifiable/train.py

import time
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all, rng_state, restore_rng
from .model import (
    load_tokenizer,
    reload_learned,
    save_learned,
    parameter_manifest,
    manifest_digest,
)
from .scores import rollout_completion_row, model_scores
from .rollout import sample_group, verify_behavior_before_update
from .loss import group_advantages, clipped_group_surrogate, exact_prefix_kl
from .warmstart import assert_learning_scope, freeze_initial_reference, learning_rate
from .state import save_resume_state, load_resume_state


def next_prompt_indices(sampler, population, order, cursor, count):
    selected = []
    while len(selected) < count:
        if cursor == len(order):
            order = torch.randperm(population, generator=sampler).tolist()
            cursor = 0
        selected.append(order[cursor])
        cursor += 1
    return selected, order, cursor


def train_grpo(config, branch="grpo", mode="continuous"):
    if branch not in ["grpo", "grpo-g2", "grpo-g8"] or mode not in [
        "interrupt",
        "continuous",
        "resume",
    ]:
        raise ValueError("Unknown GRPO branch or process")
    group_size = (
        config["group_size"] if branch == "grpo" else int(branch.rsplit("g", 1)[1])
    )
    iterations = (
        config["iterations"] if branch == "grpo" else config["exercise_iterations"]
    )
    boundary = config["interrupt_iteration"] if branch == "grpo" else 8
    if config["std_correction"] != 0 or config["updates_per_rollout"] != 2:
        raise ValueError(
            "Frozen variant requires population std and two updates per rollout"
        )
    assert_learning_scope()
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    pages = read_json("data/generated/train/pages.json")
    gold = {r["document_id"]: r for r in read_json("data/generated/labels/train.json")}
    if (
        len(pages) != 872
        or any(p["split"] != "train" for p in pages)
        or any(r["warmstart"] for r in gold.values())
    ):
        raise ValueError("RL must use only the 872 remaining train problems")
    model, metadata = reload_learned("assets/checkpoints/warmstart-resume.safetensors")
    model = model.cuda().eval()
    reference, ref_metadata = freeze_initial_reference()
    parameters = list(model.parameters())
    initial = parameter_manifest(model)
    initial_digest = manifest_digest(initial)
    if (
        initial_digest != ref_metadata["model_digest"]
        or sum(p.numel() for p in parameters) != 494032768
        or any(not p.requires_grad or p.dtype != torch.float32 for p in parameters)
    ):
        raise ValueError(
            "Policy and fixed reference must start from the same full FP32 local SFT"
        )
    identity = {
        "branch": branch,
        "config": config,
        "group_size": group_size,
        "iterations": iterations,
        "initial_model_digest": initial_digest,
        "reference_sha256": sha("assets/checkpoints/warmstart-resume.safetensors"),
        "reference_model_digest": ref_metadata["model_digest"],
        "tokenizer_sha256": sha("assets/model/tokenizer.json"),
        "variant": "GRPO pédagogique, KL exacte sur préfixes",
        "data_sha256": {
            n: sha("data/generated/" + n)
            for n in ["train/pages.json", "labels/train.json"]
        },
        "code_sha256": {
            n: sha("src/grpo_recompense_verifiable/" + n)
            for n in [
                "model.py",
                "scores.py",
                "loss.py",
                "rollout.py",
                "verifier.py",
                "train.py",
                "state.py",
            ]
        },
    }
    optimizer = torch.optim.AdamW(
        parameters, lr=config["learning_rate"], weight_decay=config["weight_decay"]
    )
    sampler = torch.Generator().manual_seed(config["seed"] + 100)
    generation_rng = torch.Generator(device="cuda").manual_seed(config["seed"] + 200)
    root = Path("assets/checkpoints")
    destination = root / f"{branch}-{mode}.pt.gz"
    if destination.exists():
        raise ValueError("Preserve existing local GRPO state")
    start = updates = cursor = 0
    order = []
    history = []
    consumed = []
    total_updates = iterations * config["updates_per_rollout"]
    if mode == "resume":
        state = load_resume_state(root / f"{branch}-interrupt.pt.gz")
        if state["identity"] != identity:
            raise ValueError("GRPO resume identity differs")
        model.load_state_dict(state["model"], strict=True)
        optimizer.load_state_dict(state["optimizer"])
        sampler.set_state(state["sampler"])
        generation_rng.set_state(state["generation_rng"])
        restore_rng(state["rng"])
        start = state["iteration"]
        updates = state["updates"]
        cursor = state["cursor"]
        order = state["order"]
        history = state["history"]
        consumed = state["consumed_rollout_ids"]
        if (
            updates != start * config["updates_per_rollout"]
            or state["pending_rollouts"] != []
        ):
            raise ValueError("Resume only at a completed rollout boundary")
        if state["scheduler"] != {
            "next_update": updates,
            "next_lr": learning_rate(
                config, updates, total_updates, config["learning_rate"]
            ),
        }:
            raise ValueError("GRPO scheduler identity differs")
        del state
    stop = boundary if mode == "interrupt" else iterations
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
    begun = time.perf_counter()
    generation_seconds = scoring_seconds = optimization_seconds = 0.0
    for iteration in range(start, stop):
        selected, order, cursor = next_prompt_indices(
            sampler, len(pages), order, cursor, config["prompts_per_iteration"]
        )
        behavior_digest = manifest_digest(parameter_manifest(model))
        groups = []
        for prompt_position, index in enumerate(selected):
            group_id = (
                f"{branch}/iteration-{iteration:03d}/prompt-{prompt_position:02d}"
            )
            group = sample_group(
                model,
                tokenizer,
                pages[index],
                gold[pages[index]["document_id"]]["problem"],
                config,
                generation_rng,
                behavior_digest,
                group_id,
                group_size=group_size,
                capture_probe=iteration == 0 and prompt_position == 0,
            )
            generation_seconds += sum(
                r["generation_seconds"] for r in group["responses"]
            )
            torch.cuda.synchronize()
            score_start = time.perf_counter()
            group["before_update"] = verify_behavior_before_update(model, group)
            torch.cuda.synchronize()
            scoring_seconds += time.perf_counter() - score_start
            groups.append(group)
        rollout_path = Path(
            f"outputs/learning/rollouts/{branch}/{mode}/iteration-{iteration:03d}.json"
        )
        if rollout_path.exists():
            raise ValueError("Existing actual rollout evidence must be preserved")
        save_json(
            rollout_path,
            {
                "branch": branch,
                "mode": mode,
                "iteration": iteration,
                "behavior_model_digest": behavior_digest,
                "groups": groups,
                "reference_model_digest": identity["reference_model_digest"],
            },
        )
        current_ids = [r["rollout_id"] for g in groups for r in g["responses"]]
        if len(set(current_ids)) != len(current_ids) or set(current_ids) & set(
            consumed
        ):
            raise ValueError("Duplicate rollout would be optimized twice")
        update_rows = []
        torch.cuda.synchronize()
        update_start = time.perf_counter()
        for inner_update in range(config["updates_per_rollout"]):
            lr = learning_rate(config, updates, total_updates, config["learning_rate"])
            for parameter_group in optimizer.param_groups:
                parameter_group["lr"] = lr
            optimizer.zero_grad(set_to_none=True)
            details = []
            for group in groups:
                for member, response in enumerate(group["responses"]):
                    row = rollout_completion_row(response)
                    with torch.no_grad():
                        _, _, reference_values, reference_logits, _ = model_scores(
                            reference, [row]
                        )
                    _, _, current_values, current_logits, targets = model_scores(
                        model, [row]
                    )
                    old = torch.tensor(
                        response["old_token_logprobs"],
                        device="cuda",
                        dtype=current_values.dtype,
                    ).unsqueeze(0)
                    advantage = torch.tensor(
                        [group["advantages"][member]],
                        device="cuda",
                        dtype=current_values.dtype,
                    )
                    mask = torch.ones_like(old, dtype=torch.bool)
                    surrogate, audit = clipped_group_surrogate(
                        current_values.unsqueeze(0),
                        old,
                        advantage,
                        mask,
                        config["clip_epsilon"],
                    )
                    kl = exact_prefix_kl(
                        current_logits, reference_logits, config["kl_vocabulary_block"]
                    )
                    contribution = (
                        surrogate.mean() + config["kl_beta"] * kl.mean()
                    ) / (len(groups) * group_size)
                    if not torch.isfinite(contribution) or not torch.isfinite(kl).all():
                        raise ValueError("Nonfinite actual GRPO objective")
                    detail = {
                        "rollout_id": response["rollout_id"],
                        "tokens": len(response["response_ids"]),
                        "old_token_logprobs": response["old_token_logprobs"],
                        "current_token_logprobs": current_values.detach()
                        .cpu()
                        .tolist(),
                        "reference_token_logprobs": reference_values.cpu().tolist(),
                        "advantages": float(advantage[0]),
                        "token_ratios": audit["ratio"][0].cpu().tolist(),
                        "surrogate": float(surrogate.detach()[0]),
                        "exact_prefix_kl": kl.detach().cpu().tolist(),
                        "active_clip_fraction": float(audit["active_clip_fraction"][0]),
                        "outside_interval_fraction": float(
                            audit["outside_interval_fraction"][0]
                        ),
                        "scaled_contribution": float(contribution.detach()),
                    }
                    if iteration == 0 and inner_update == 0 and len(details) < 2:
                        detail["kl_probe"] = {
                            "current_logits": current_logits[0].detach().cpu().tolist(),
                            "reference_logits": reference_logits[0].cpu().tolist(),
                            "target": int(targets[0]),
                            "exact_kl": float(kl.detach()[0]),
                        }
                    contribution.backward()
                    details.append(detail)
                    del (
                        current_logits,
                        reference_logits,
                        current_values,
                        reference_values,
                        kl,
                        contribution,
                        surrogate,
                    )
            norm = torch.nn.utils.clip_grad_norm_(
                parameters, config["gradient_clip"], error_if_nonfinite=True
            )
            if any(p.grad is None or p.grad.dtype != torch.float32 for p in parameters):
                raise ValueError("Full policy gradients missing")
            if (
                any(
                    p.requires_grad or p.grad is not None
                    for p in reference.parameters()
                )
                or reference.training
                or model.training
            ):
                raise ValueError("Reference/dropout invariant broken")
            optimizer.step()
            updates += 1
            update_rows.append(
                {
                    "update": updates,
                    "inner_update": inner_update,
                    "learning_rate": lr,
                    "gradient_norm_before_clip": float(norm),
                    "prompt_denominator": len(groups),
                    "group_denominator": group_size,
                    "surrogate": sum(r["surrogate"] for r in details) / len(details),
                    "mean_exact_kl": sum(
                        sum(r["exact_prefix_kl"]) / r["tokens"] for r in details
                    )
                    / len(details),
                    "active_clip_fraction": sum(
                        r["active_clip_fraction"] for r in details
                    )
                    / len(details),
                    "total_loss": sum(r["scaled_contribution"] for r in details),
                    "responses": details,
                }
            )
        torch.cuda.synchronize()
        optimization_seconds += time.perf_counter() - update_start
        consumed.extend(current_ids)
        # Keep timing out of resumable state: wall-clock durations cannot be equal
        # across processes. Actual raw rollouts and timings are retained above.
        summary = {
            "iteration": iteration,
            "behavior_model_digest": behavior_digest,
            "document_ids": [pages[i]["document_id"] for i in selected],
            "group_size": group_size,
            "groups": [
                {
                    "group_id": g["group_id"],
                    "rewards": g["rewards"],
                    "advantages": g["advantages"],
                    "population_std": g["population_std"],
                    "zero_variance": g["zero_variance"],
                    "before_update": g["before_update"],
                    "response_ids": [r["response_ids"] for r in g["responses"]],
                }
                for g in groups
            ],
            "updates": update_rows,
        }
        history.append(summary)
        print(
            {
                "branch": branch,
                "iteration": iteration + 1,
                "updates": updates,
                "reward_mean": sum(g["reward_mean"] for g in groups) / len(groups),
                "zero_variance_fraction": sum(g["zero_variance"] for g in groups)
                / len(groups),
                "kl": update_rows[-1]["mean_exact_kl"],
            },
            flush=True,
        )
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - begun
    peak_memory = torch.cuda.max_memory_allocated()
    final = parameter_manifest(model)
    final_digest = manifest_digest(final)
    ref_final = manifest_digest(parameter_manifest(reference))
    if (
        ref_final != identity["reference_model_digest"]
        or sha("assets/checkpoints/warmstart-resume.safetensors")
        != identity["reference_sha256"]
    ):
        raise ValueError("Frozen reference changed")
    # Zero reward variation can leave a policy unchanged; this is a valid measured
    # limitation. Never require a fabricated improvement or weight change here.
    state = {
        "identity": identity,
        "iteration": stop,
        "updates": updates,
        "model": model.state_dict(),
        "optimizer": optimizer.state_dict(),
        "sampler": sampler.get_state(),
        "generation_rng": generation_rng.get_state(),
        "order": order,
        "cursor": cursor,
        "scheduler": {
            "next_update": updates,
            "next_lr": learning_rate(
                config, updates, total_updates, config["learning_rate"]
            ),
        },
        "rng": rng_state(),
        "history": history,
        "consumed_rollout_ids": consumed,
        "pending_rollouts": [],
    }
    compression = save_resume_state(destination, state)
    if mode == "resume":
        save_learned(model, root / f"{branch}-resume.safetensors", identity)
    save_json(
        f"outputs/learning/train-{branch}-{mode}.json",
        {
            "branch": branch,
            "mode": mode,
            "identity": identity,
            "iterations": stop,
            "updates": updates,
            "history": history,
            "consumed_rollout_ids": consumed,
            "initial_parameters": initial,
            "final_parameters": final,
            "initial_model_digest": initial_digest,
            "final_model_digest": final_digest,
            "reference_model_digest_after": ref_final,
            "weights_changed": final_digest != initial_digest,
            "trainable_parameters": 494032768,
            "reference_trainable_parameters": 0,
            "reference_gradients": 0,
            "checkpoint_sha256": sha(destination),
            "checkpoint_bytes": destination.stat().st_size,
            "checkpoint_compression": compression,
            "generation_seconds_this_process": generation_seconds,
            "behavior_scoring_seconds_this_process": scoring_seconds,
            "optimization_and_reference_scoring_seconds_this_process": optimization_seconds,
            "total_loop_seconds_this_process": elapsed,
            "max_torch_allocated_bytes": peak_memory,
            "test_visible": False,
            "dropout_active": False,
        },
    )

Đánh giá chất lượng và tổng chi phí

Pass@1 và không có tín hiệu

Lỗi cần chẩn đoán: công bố lần lấy mẫu tốt nhất là pass@1; hãy nêu ngân sách lấy mẫu chính xác.

Bất biến: 200 ID mỗi nhánh, đầu ra không hợp lệ được giữ lại và chi phí tạo sinh không bị bỏ qua.

Tệp src/grpo_recompense_verifiable/evaluate.py

import time
from pathlib import Path
import torch
from .config import read_json, save_json, sha, seed_all
from .model import load_tokenizer, reload_learned, manifest_digest, parameter_manifest
from .rollout import sample_response
from .verifier import score_generated_json

MAIN = ["warmstart", "sft", "grpo"]
EXERCISE = ["grpo-g2", "grpo-g8"]


@torch.no_grad()
def generate_policy(config, branch):
    if branch not in MAIN + EXERCISE:
        raise ValueError("Unknown evaluation policy")
    if Path("data/generated/labels").exists() or Path("outputs/learning").exists():
        raise ValueError("Inference must not mount labels or learning outputs")
    seed_all(config["seed"])
    tokenizer = load_tokenizer()
    model, metadata = reload_learned(f"assets/checkpoints/{branch}-resume.safetensors")
    model = model.cuda().eval()
    pages = [
        p for p in read_json("data/generated/input/pages.json") if p["split"] == "test"
    ]
    if len(pages) != 200:
        raise ValueError("Frozen test population changed")
    root = Path("outputs/inference") / branch
    if root.exists():
        raise ValueError("Preserve existing actual predictions")
    torch.cuda.reset_peak_memory_stats()
    torch.cuda.synchronize()
    begun = time.perf_counter()
    files = {}
    for mode in ["greedy", "sample"]:
        results = []
        for index, page in enumerate(pages):
            for draw in range(1 if mode == "greedy" else config["evaluation_draws"]):
                # Fixed independent draws, common seeds across policies; no
                # selection of the best response and no label-dependent retry.
                seed = 480000 + index * config["evaluation_draws"] + draw
                generator = torch.Generator(device="cuda").manual_seed(seed)
                row = sample_response(
                    model,
                    tokenizer,
                    page,
                    config,
                    generator,
                    mode=mode,
                    policy_id=metadata["model_digest"],
                    draw=draw,
                    capture_probe=index < 2 and draw == 0,
                )
                row["sampling_seed"] = seed if mode == "sample" else None
                row["population"] = page["population"]
                results.append(row)
            if (index + 1) % 50 == 0:
                print(
                    {"policy": branch, "mode": mode, "problems": index + 1}, flush=True
                )
        path = root / f"{mode}.json"
        save_json(
            path,
            {
                "branch": branch,
                "mode": mode,
                "problems": 200,
                "draws_per_problem": (
                    1 if mode == "greedy" else config["evaluation_draws"]
                ),
                "model_digest": metadata["model_digest"],
                "rows": results,
                "labels_visible": False,
            },
        )
        files[mode] = {"sha256": sha(path), "responses": len(results)}
    torch.cuda.synchronize()
    elapsed = time.perf_counter() - begun
    if manifest_digest(parameter_manifest(model)) != metadata["model_digest"]:
        raise ValueError("Evaluation changed the policy")
    save_json(
        root / "audit.json",
        {
            "branch": branch,
            "model_digest": metadata["model_digest"],
            "seconds": elapsed,
            "max_torch_allocated_bytes": torch.cuda.max_memory_allocated(),
            "files": files,
            "labels_visible": False,
            "generation_support": "full vocabulary; temperature1; no top-k/top-p truncation; no JSON constraint",
        },
    )


def metrics(rows, labels):
    result = {}
    for population in ["ALL", "ID50", "OOD100"]:
        selected = [
            r for r in rows if population == "ALL" or r["population"] == population
        ]
        scored = []
        for row in selected:
            observed = score_generated_json(
                row["text"],
                row["finish_reason"],
                labels[row["document_id"]]["problem"],
                row["tokenization_valid"],
            )
            scored.append(
                {
                    "document_id": row["document_id"],
                    "draw": row["draw"],
                    "response_tokens": row["response_tokens"],
                    "finish_reason": row["finish_reason"],
                    **observed,
                }
            )
        n = len(scored)
        if n == 0:
            raise ValueError("A declared evaluation population is empty")
        result[population] = {
            "responses": n,
            "problems": len({r["document_id"] for r in scored}),
            "correct": sum(r["reward"] for r in scored),
            "success_rate": sum(r["reward"] for r in scored) / n,
            "valid_json": sum(r["valid"] for r in scored),
            "json_validity": sum(r["valid"] for r in scored) / n,
            "without_eos": sum(r["finish_reason"] != "eos" for r in scored),
            "mean_response_tokens": sum(r["response_tokens"] for r in scored) / n,
            "rows": scored,
        }
    return result


def evaluate(config, exercise=False):
    branches = EXERCISE if exercise else MAIN
    labels = {r["document_id"]: r for r in read_json("data/generated/labels/gold.json")}
    results = {}
    for branch in branches:
        root = Path("outputs/inference") / branch
        greedy = read_json(root / "greedy.json")
        sample = read_json(root / "sample.json")
        if (
            len(greedy["rows"]) != 200
            or len(sample["rows"]) != 200 * config["evaluation_draws"]
        ):
            raise ValueError("Incomplete actual evaluation")
        for source, draws in [(greedy, 1), (sample, config["evaluation_draws"])]:
            keys = [(r["document_id"], r["draw"]) for r in source["rows"]]
            if len(set(keys)) != len(keys) or any(
                sum(r["document_id"] == identifier for r in source["rows"]) != draws
                for identifier, _ in keys
            ):
                raise ValueError("Missing/duplicated test draw")
        results[branch] = {
            "greedy_pass_at_1": metrics(greedy["rows"], labels),
            "empirical_per_draw_success": metrics(sample["rows"], labels),
            "draws_per_problem": config["evaluation_draws"],
            "best_of_draws_reported_as_pass_at_1": False,
            "generation": read_json(root / "audit.json"),
        }
    save_json(
        "outputs/exercise/results.json" if exercise else "outputs/results.json",
        {
            "policies": results,
            "deterministic_solver": "Exact on all generated problems; a production stock calculation does not need a language model",
            "shared_templates": "Synthetic controlled arithmetic, not real operational evidence",
            "quality_gain_required": False,
        },
    )
    return results

Diễn giải các nhóm không có người thắng

Tín hiệu bằng không mà không tạo ra phần thưởng giả

Lỗi cần chẩn đoán: thêm một khoản thưởng tùy tiện để ép đường cong đi xuống là bị cấm; hãy ghi nhận kết quả bằng không.

Bất biến: lợi thế bằng không cho các nhóm hằng số; không thay đổi bộ kiểm tra.

Tệp src/grpo_recompense_verifiable/exercise.py

from pathlib import Path
import torch
from .config import read_json, save_json, sha
from .loss import group_advantages, clipped_group_surrogate


def main_artifacts():
    names = ["configs/minimum.yaml", "outputs/results.json"]
    for branch in ["warmstart", "sft", "grpo"]:
        names += [
            f"assets/checkpoints/{branch}-resume.safetensors",
            f"outputs/learning/resume-{branch}.json",
            f"outputs/inference/{branch}/greedy.json",
            f"outputs/inference/{branch}/sample.json",
        ]
    return names


def preserve_main():
    path = Path("outputs/exercise/protected.json")
    if path.exists():
        raise ValueError("Preserve existing exercise snapshot")
    save_json(path, {name: sha(name) for name in main_artifacts()})


def run_group_size_ablation(config):
    protected = read_json("outputs/exercise/protected.json")
    if protected != {name: sha(name) for name in main_artifacts()}:
        raise ValueError("Exercise changed the main artifacts")
    analytic = []
    for rewards in [[0.0, 0.0, 0.0, 0.0], [1.0, 1.0, 1.0, 1.0], [1.0, 0.0, 1.0, 0.0]]:
        advantages, mean, std = group_advantages(
            torch.tensor([rewards], dtype=torch.float64), config["advantage_epsilon"]
        )
        current = torch.full((4, 2), -1.0, dtype=torch.float64, requires_grad=True)
        old = current.detach().clone()
        loss, _ = clipped_group_surrogate(
            current, old, advantages[0], torch.ones_like(current, dtype=torch.bool)
        )
        loss.mean().backward()
        analytic.append(
            {
                "rewards": rewards,
                "advantages": advantages[0].tolist(),
                "population_std": float(std[0, 0]),
                "reward_surrogate": float(loss.detach().mean()),
                "token_gradients": current.grad.tolist(),
                "source": "Constructed illustrative rewards, not actual model outputs",
            }
        )
    runs = {
        b: read_json(f"outputs/learning/train-{b}-continuous.json")
        for b in ["grpo-g2", "grpo-g8"]
    }
    if any(r["updates"] != 40 or r["iterations"] != 20 for r in runs.values()):
        raise ValueError("Frozen group-size ablation budget changed")
    if len({r["initial_model_digest"] for r in runs.values()}) != 1:
        raise ValueError("Ablation initialization differs")
    if [[h["document_ids"] for h in r["history"]] for r in runs.values()][0] != [
        [h["document_ids"] for h in r["history"]] for r in runs.values()
    ][1]:
        raise ValueError("Ablation prompt sequence differs")
    budgets = {
        b: {
            "updates": r["updates"],
            "prompts": sum(len(h["document_ids"]) for h in r["history"]),
            "rollouts": len(r["consumed_rollout_ids"]),
            "seconds": r["total_loop_seconds_this_process"],
        }
        for b, r in runs.items()
    }
    result = read_json("outputs/exercise/results.json")
    if set(result["policies"]) != {"grpo-g2", "grpo-g8"}:
        raise ValueError("Missing actual heldout ablation generation")
    save_json(
        "outputs/exercise/group-size.json",
        {
            "analytic_constant_groups": analytic,
            "actual_fresh_training": True,
            "budgets": budgets,
            "same_initialization_and_prompt_order": True,
            "equal_compute_claimed": False,
            "main_100_iteration_g4_is_not_a_controlled_group_size_only_comparison": True,
            "main_artifacts_unchanged": protected
            == {name: sha(name) for name in main_artifacts()},
            "protected_sha256": protected,
            "verifier_changed": False,
            "quality_gain_required": False,
        },
    )

Nguồn

DeepSeekMath v3 mô tả mục tiêu tương đối theo nhóm. TRL v0.23.1 ghi lại các phép rút gọn và biến thể khác nhau. Các nguồn này làm rõ các lựa chọn; không kết quả đã công bố hay trainer hoàn chỉnh nào được tái tạo. Qwen base đã đóng băng.

Quan sát trong quá trình phát triển

Các giá trị được tính lại từ đầu ra thô của lần chạy này. Chúng không phải ngưỡng hiệu năng; quy trình từ ZIP tính lại chúng trên cùng các quần thể.

{
  "status": "PASS",
  "metrics": {
    "warmstart": {
      "greedy_pass_at_1": {
        "responses": 200,
        "problems": 200,
        "correct": 3,
        "success_rate": 0.015,
        "valid_json": 200,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.85
      },
      "empirical_per_draw_success": {
        "responses": 800,
        "problems": 200,
        "correct": 15,
        "success_rate": 0.01875,
        "valid_json": 799,
        "json_validity": 0.99875,
        "without_eos": 0,
        "mean_response_tokens": 7.78125
      }
    },
    "sft": {
      "greedy_pass_at_1": {
        "responses": 200,
        "problems": 200,
        "correct": 16,
        "success_rate": 0.08,
        "valid_json": 200,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.865
      },
      "empirical_per_draw_success": {
        "responses": 800,
        "problems": 200,
        "correct": 30,
        "success_rate": 0.0375,
        "valid_json": 799,
        "json_validity": 0.99875,
        "without_eos": 0,
        "mean_response_tokens": 7.81125
      }
    },
    "grpo": {
      "greedy_pass_at_1": {
        "responses": 200,
        "problems": 200,
        "correct": 3,
        "success_rate": 0.015,
        "valid_json": 200,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.825
      },
      "empirical_per_draw_success": {
        "responses": 800,
        "problems": 200,
        "correct": 14,
        "success_rate": 0.0175,
        "valid_json": 799,
        "json_validity": 0.99875,
        "without_eos": 0,
        "mean_response_tokens": 7.74375
      }
    },
    "grpo-g2": {
      "greedy_pass_at_1": {
        "responses": 200,
        "problems": 200,
        "correct": 4,
        "success_rate": 0.02,
        "valid_json": 200,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.9
      },
      "empirical_per_draw_success": {
        "responses": 800,
        "problems": 200,
        "correct": 11,
        "success_rate": 0.01375,
        "valid_json": 800,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.805
      }
    },
    "grpo-g8": {
      "greedy_pass_at_1": {
        "responses": 200,
        "problems": 200,
        "correct": 3,
        "success_rate": 0.015,
        "valid_json": 200,
        "json_validity": 1.0,
        "without_eos": 0,
        "mean_response_tokens": 7.96
      },
      "empirical_per_draw_success": {
        "responses": 800,
        "problems": 200,
        "correct": 13,
        "success_rate": 0.01625,
        "valid_json": 799,
        "json_validity": 0.99875,
        "without_eos": 0,
        "mean_response_tokens": 7.79875
      }
    }
  },
  "training_branches": 5,
  "full_state_resume_checks": 5,
  "actual_test_generations": 5000,
  "scope": "Scalar reward/ratio/categorical-probe/metrics plus captured full-state comparisons; no scalar full Transformer or CUDA multinomial replay claimed",
  "quality_gain_required": false
}

Các kết quả quan sát được, phạm vi của chúng và thí nghiệm được đề xuất.

Chia sẻ bài viết