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

Điểm đã học có đủ để cải thiện phản hồi của trợ lý không?

Bạn xây dựng một trợ lý hư cấu đề xuất bước chẩn đoán sự cố văn phòng, rồi huấn luyện một điểm từ các ưu tiên tổng hợp. PPO tối ưu điểm này; một đánh giá riêng kiểm tra liệu phản hồi có thực sự phù hợp và thận trọng không.

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

Một trợ lý hư cấu phải đề xuất một phản hồi chẩn đoán sự cố văn phòng ngắn gọn, phù hợp và không chắc chắn vô cớ. Một rubric tổng hợp tạo ra các ưu tiên; một mạng học cách dự đoán chúng, sau đó PPO tối ưu các phản hồi mới dựa trên mạng này.

Xây dựng SFT cục bộ, mô hình phần thưởng, actor và critic; hiển thị phần thưởng dự đoán, rubric độc lập, giá trị và lợi thế. Quan sát xem việc tối ưu điểm đã học có thực sự cải thiện các tiêu chí được giữ lại hay khai thác một lối tắt.

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.

Tín hiệu được mô phỏng

OfficeHelpPrefs-v1 có 240 nhóm tình huống và năm cặp cho mỗi nhóm: 840 cặp train, 180 validation và 180 test, chia theo nhóm 168/36/36. Sáu thao tác hư cấu được dùng chung giữa các tập: khôi phục hàng đợi in, kiểm tra cáp bị rút, bật lại âm thanh hoặc Wi-Fi, lưu tài liệu và gia hạn phiên. Các nhóm và prompt chính xác vẫn riêng biệt; thao tác và mẫu ngôn ngữ được dùng chung. Vì vậy corpus này không đo khả năng khái quát hóa cho mọi yêu cầu hỗ trợ văn phòng.

168 minh họa SFT và 672 prompt rollout chỉ thuộc các nhóm train và khác nguyên văn với prompt của các cặp. RL thấy ngữ cảnh, không bao giờ thấy mục tiêu của rubric. Các ưu tiên và minh họa được dựng sẵn: không có chiến dịch lấy phản hồi con người. Bài thực hành minh họa kiến trúc RLHF mà không khẳng định học từ sự hài lòng của con người.

Các probe gồm 20 ngữ cảnh bổ sung và bốn ứng viên cho mỗi ngữ cảnh: lời khuyên đúng hoặc sai, có hoặc không lịch sự. Mỗi ô nhân tố giữ cùng prompt để cô lập tác động của sự lịch sự. Điều này tạo 80 dòng và 20 ngữ cảnh độc lập. Không dòng nào trong số này dùng để học hay chọn mô hình.

Bốn đối tượng, bốn chức năng

Mỗi vai trò có backbone Qwen2.5-0.5B-Instruct riêng được đóng băng tại commit 7ae557604adf67be50417f59c2c2f167def9a775: 24 lớp, chiều rộng 896, 14 đầu attention, hai đầu KV, từ vựng 151936, embedding và đầu ra được buộc chung. Trọng số cơ sở là BF16 và đóng băng. LoRA và các head vô hướng là FP32; không trọng số nào được lượng tử hóa bốn bit.

LoRA được mã hóa: W(x)+(alpha/r)B(Ax), hạng 8, alpha 16, dropout bằng không. Bảy projection q/k/v/o/gate/up/down của mỗi lớp nhận adapter: 168 projection, 4 399 104 tham số mỗi vai trò. Nhánh cộng được tính ở FP32 rồi kết quả trở về dtype cơ sở. Các kiểm thử so sánh phép toán và gradient của nó với PEFT; PEFT không phải trainer PPO.

Actor bắt đầu bằng adapter từ SFT cục bộ. Tham chiếu là bản sao độc lập của SFT này, được đóng băng hoàn toàn trong PPO. Mô hình phần thưởng bắt đầu từ base đã lấy với adapter riêng và head vô hướng 897 tham số; nó học các cặp rồi giữ đóng băng. Critic sao chép adapter SFT và thêm head giá trị 897 tham số khởi tạo bằng không. Actor và critic có hai optimizer. Danh tính lưu trữ, dtype, tham số đóng băng và dấu vân tay được kiểm tra: dùng chung một đối tượng có thể sửa giữa critic và judge sẽ làm phần thưởng thay đổi.

Chế độ eval tắt dropout khi tạo sinh và học; nó không tắt autograd. Chỉ các khối được chỉ rõ không gradient mới làm điều đó.

SFT và Bradley-Terry

SFT học 168 minh họa trong ba epoch, tích lũy tám ví dụ: 63 lần cập nhật AdamW ở 2e−5. CE áp dụng cho token phản hồi, gồm EOS; prompt và padding được che theo vị trí. Tích lũy chia theo tổng token hoạt động, không phải trung bình của các trung bình phản hồi.

Judge học 840 cặp trong hai epoch, tức 210 lần cập nhật ở 2e−5. Bradley-Terry mô hình hóa P(chosen được ưa thích)=sigmoid(r_chosen−r_rejected). Loss của nó là −mean(logsigmoid(r_chosen−r_rejected)). Điểm tuyệt đối không phải xác suất của sự thật hay tính hữu ích. Nó có thể âm, bị dịch, hiệu chỉnh kém hoặc nhạy với phong cách.

Head phần thưởng đọc token phản hồi hoạt động cuối cùng, gồm EOS. Đọc chỉ số cuối của một batch có padding đôi khi sẽ đọc PAD. Critic thì dự đoán V trước mỗi token phản hồi, trên tiền tố sẽ tạo token đó. Nó không thay thế điểm cuối của judge.

Validation theo dõi loss mà không chọn checkpoint tốt nhất. Trạng thái cuối và ngân sách được cố định trước khi chạy. Accuracy của judge trên 180 cặp test được công bố riêng với các phản hồi tự do của actor.

Rollout và gán công theo token

Một vòng lặp rút bốn prompt trong 672 ngữ cảnh train và lấy mẫu một phản hồi mỗi prompt, nhiệt độ 1, toàn bộ từ vựng, không top-k hay loại bỏ token. Prompt tối đa 256 token, phản hồi 64 token, ngữ cảnh 320. EOS là 151645, PAD là 151643. Mã từ chối các ví dụ giám sát vượt giới hạn thay vì cắt mục tiêu của chúng.

Ở mỗi tiền tố thực sự đã đi qua, ta lưu log-probability của actor lấy mẫu, của tham chiếu cố định và giá trị critic trước token. Đây là dữ liệu đã tách rời. Chính sách hiện tại thay đổi sau đó; chính sách cũ là chính sách đã tạo rollout. Không bao giờ thay old_logp bằng các điểm mới đã tách rời sau một update.

Phần thưởng theo token là −0,02 × (logp_old−logp_ref). Ở token hoạt động cuối cùng, điểm của judge được thêm đúng một lần. Một phản hồi bị cắt ở 64 token không có EOS cũng nhận −1. EOS và cắt đều kết thúc episode bị chặn này: không bootstrap sau khi kết thúc. Việc cắt vẫn được tách riêng trong kết quả. Tỷ lệ log-probability trên token đã lấy mẫu có thể âm; nó không phải phân kỳ phân loại chính xác trên toàn bộ từ vựng.

GAE đi ngược qua các token: delta_t=r_t+gamma(1−done_t)V_{t+1}−V_t; A_t=delta_t+gamma lambda(1−done_t)A_{t+1}. Ở đây gamma=1 và lambda=0,95. Return là A+V cũ. Phần thưởng, giá trị cũ, lợi thế và return vẫn tách rời. Không có việc whitening phần thưởng hay lợi thế bị ẩn.

Hai loss tường minh

Với mỗi token, rho=exp(logp_hiện_tại−logp_cũ). Loss actor là số đối của min(rho A, clip(rho,0,8,1,2) A). Dấu của lợi thế quyết định phía nào bị giới hạn. Token hoạt động được lấy trung bình cho từng phản hồi rồi trên bốn phản hồi: phản hồi dài không tự động nhận nhiều trọng số tổng hơn.

Loss critic là 0,5 × mean((V_hiện_tại−return)^2), với cùng cách rút gọn theo phản hồi. Không có hệ số 1/2 ngầm thứ hai, không cắt giá trị, không thưởng entropy. Các kiểm thử phân tích kiểm tra GAE ba bước, điểm cuối, cắt, padding, dấu clipping và gradient. Biến thể này khác các thiết lập TRL whitening lợi thế hay cắt giá trị; không khẳng định tương đương với toàn bộ trainer.

Bốn rollout được thu thập trước hai lần cập nhật actor và critic. Chạy tối thiểu có 100 vòng, 400 phản hồi lấy mẫu, 200 update actor và 200 update critic. AdamW dùng lần lượt 5e−6 và 1e−5, weight_decay 0,01, warm-up 10%, giảm tuyến tính tới 10% tốc độ ban đầu và cắt gradient ở 1. Điểm và giá trị cũ không thay đổi trong hai lần tái sử dụng.

Backbone BF16 có thể tạo sai lệch nhỏ giữa forward từng tiền tố và chấm điểm toàn bộ chuỗi. Sai lệch quan sát trước update đầu tiên được ghi lại; điểm hành vi thực dùng để rút token vẫn là tham chiếu. Thời gian tạo sinh, chấm điểm tham chiếu, judge, critic, tối ưu và vòng lặp đầy đủ được tách riêng. Tạo sinh không cache KV dễ đọc hơn nhưng tốn nhiều forward hơn.

Tiếp tục chạy mà không lặp lại rollout

Lưu PPO chỉ diễn ra sau hai update hoàn chỉnh của một vòng lặp. Nó chứa bốn trạng thái đã học và base được nhận diện của chúng, hai optimizer, schedule, hoán vị và con trỏ prompt, RNG tạo sinh và RNG hệ thống, lịch sử và ID đã dùng. Không rollout nào còn chờ ở ranh giới này.

Quy trình so sánh dừng ở 40, chạy liên tục đến 100 và tiếp tục 60 vòng cuối, với trạng thái số giống hệt nhau. SFT so sánh dừng sau một epoch với ba epoch liên tục; judge làm tương tự trên hai epoch. Thời lượng được giữ ngoài trạng thái số có thể so sánh. Trạng thái tiếp tục cục bộ được nén không mất dữ liệu, gzip mức 1, và adapter/head cuối cũng được nạp lại từ Safetensors. Không bao giờ thay trạng thái Torch cục bộ bằng pickle bên thứ ba.

Judge không phải đánh giá nghiệp vụ

SFT và PPO mỗi bên tạo 180 phản hồi test và 80 dòng probe, tức 260 lần tạo sinh và 200 prompt riêng biệt mỗi chính sách. Bốn lần tạo greedy lặp lại cho mỗi ngữ cảnh probe phải giống hệt nhau. Báo cáo phân biệt 80 dòng với 20 ngữ cảnh duy nhất; tổng 520 lần tạo sinh không phải 520 tình huống độc lập.

Rubric riêng chỉ nhận diện một ngữ pháp hữu hạn gồm thao tác, sự thận trọng và công thức lịch sự. Mọi văn xuôi khác là UNCERTIFIED: không tự động sai về ngữ nghĩa nhưng không nhận thành công được chứng nhận. Sự chắc chắn vô cớ dùng mẫu số các dạng được nhận diện; tỷ lệ thành công giữ mọi đầu ra. Cắt không EOS hoặc ID không giải mã được không bị chấp nhận âm thầm. Từ chối, độ dài, phạm vi được nhận diện, điểm đã học và tỷ lệ log-probability được công bố.

Bài tập chấm 80 ứng viên cố định bằng judge đóng băng. Với mỗi ngữ cảnh, nó so sánh thay đổi điểm do lịch sự và đếm lời khuyên sai nhưng lịch sự được chấm cao hơn lời khuyên đúng đơn giản. Điểm tăng không phải sự hài lòng của con người, cũng không tự nó chứng minh PPO khai thác thiên lệch này. Cần xem cả đầu ra của actor. Probe vẫn được giữ lại; sửa judge đòi hỏi bản sửa đổi và test mới.

SFT có thể đủ. DPO tránh judge, critic và tạo sinh trực tuyến khi có các cặp; nó được bàn đến nhưng không được huấn luyện trong bài thực hành này. Bộ kiểm tra chính xác phù hợp hơn với các tác vụ thực sự kiểm chứng được. Không yêu cầu PPO tăng lợi ích và mọi kết quả bất lợi vẫn có thể công bố.

Tạo ưu tiên và probe mà không trộn lẫn

Bộ đánh giá đã học so với tiêu chí giữ lại

Bất biến: 840/180/180 cặp, 80 probe riêng biệt; không split giữ lại nào lộ cho rollout.

Lỗi cần chẩn đoán: probe được thêm vào train sau lỗi; hãy tạo một bản sửa đổi tương lai, không sửa test hiện tại.

Tệp src/recompense_ppo/data.py

import hashlib,json,random
from pathlib import Path
from .rubric import CONTEXTS,response,evaluate_response


def digest(text): return hashlib.sha256(text.encode()).hexdigest()


def split_help_families():
    rng=random.Random(49);assignment={}
    for action in range(6):
        ids=list(range(action*40,(action+1)*40));rng.shuffle(ids)
        for position,index in enumerate(ids):
            assignment[index]='train' if position<28 else 'validation' if position<34 else 'test'
    return assignment


def prompt(family,action,kind,variant):
    headings=[f'Help request {family}, note {variant}.',f'Case {family}, message {variant}.',f'Desk incident {family}, record {variant}.']
    return headings[variant%3]+f' Source: {kind}. '+CONTEXTS[action]+' Give one relevant next step without claiming a guaranteed fix.'


def page(identifier,family,split,text,population):
    return {'document_id':identifier,'family':family,'split':split,'population':population,
            'prompt':text,'content_sha256':digest(text)}


def generate_help_preferences():
    assignments=split_help_families();pages=[];labels=[];warm=[];warm_labels=[];rollouts=[]
    for index in range(240):
        family=f'OH{index:03d}';action=index//40;split=assignments[index]
        for variant in range(5):
            identifier=f'{family}-P{variant}';text=prompt(family,action,'preference card',variant)
            wrong=(action+1+index%5)%6
            if variant==0: chosen=response(action,1,False);rejected=response(wrong,2,False)
            elif variant==1: chosen=response(action,0,False);rejected=response(wrong,1,True)
            elif variant==2: chosen=response(action,1,True);rejected=response(wrong,0,False)
            elif variant==3: chosen=response(action,1,False);rejected=response(action,2,True)
            else: chosen=response(action,0,True);rejected=response(wrong,1,False)
            pages.append(page(identifier,family,split,text,'pair'))
            labels.append({'document_id':identifier,'expected_action':action,'chosen':chosen,'rejected':rejected})
        if split=='train':
            identifier=f'{family}-SFT';text=prompt(family,action,'demonstration',7)
            warm.append(page(identifier,family,'train',text,'warmstart'))
            warm_labels.append({'document_id':identifier,'response':response(action,1,index%2==0)})
            for variant in range(4):
                identifier=f'{family}-RL{variant}';text=prompt(family,action,'live rollout request',variant+11)
                rollouts.append(page(identifier,family,'train',text,'rollout'))
    return pages,labels,warm,warm_labels,rollouts


def build_shortcut_probes():
    pages=[];labels=[]
    for index in range(20):
        family=f'PROBE{index:02d}';action=index%6;wrong=(action+1)%6
        text=f'New remote support context {family}. '+CONTEXTS[action]+' A previous unrelated case was closed. Give one relevant next step without claiming a guaranteed fix.'
        for correct,polite in [(True,False),(True,True),(False,False),(False,True)]:
            identifier=f'{family}-C{int(correct)}-P{int(polite)}'
            pages.append(page(identifier,family,'probe',text,'probe'))
            labels.append({'document_id':identifier,'expected_action':action,'candidate_response':response(action if correct else wrong,1,polite),'candidate_correct':correct,'candidate_polite':polite})
    return pages,labels


def audit_corpus(pages,labels,warm,warm_labels,rollouts):
    byid={r['document_id']:r for r in labels};pair=[r for r in pages if r['population']=='pair'];probes=[r for r in pages if r['population']=='probe']
    assert len(pages)==len(byid)==1280 and len(pair)==1200 and len(probes)==80
    families={split:{r['family'] for r in pair if r['split']==split} for split in ['train','validation','test']}
    assert [len(families[s]) for s in families]==[168,36,36]
    assert not(families['train']&families['validation'] or families['train']&families['test'] or families['validation']&families['test'])
    assert len(warm)==len(warm_labels)==168 and len(rollouts)==672
    assert {r['family'] for r in warm}==families['train']=={r['family'] for r in rollouts}
    unique_inputs=[r['prompt'] for r in pair+warm+rollouts]
    assert len(set(unique_inputs))==2040 and all(r['content_sha256']==digest(r['prompt']) for r in pages+warm+rollouts)
    assert len({r['prompt'] for r in probes})==20 and not {r['family'] for r in probes}&set.union(*families.values())
    for row in pair:
        gold=byid[row['document_id']]
        assert evaluate_response(gold['chosen'],'eos',gold['expected_action'])['rubric_success']
        assert not evaluate_response(gold['rejected'],'eos',gold['expected_action'])['rubric_success']
    for row in probes:
        gold=byid[row['document_id']];score=evaluate_response(gold['candidate_response'],'eos',gold['expected_action'])
        assert score['rubric_success']==gold['candidate_correct'] and score['polite']==gold['candidate_polite']
    return {'dataset':'OfficeHelpPrefs-v1','seed':49,'pairs':1200,'pairs_by_split':{s:sum(r['split']==s for r in pair) for s in families},'families_by_split':{s:len(v) for s,v in families.items()},'warmstart_demonstrations':168,'rollout_prompts':672,'distinct_pair_demo_rollout_prompts':2040,'probe_rows':80,'unique_probe_contexts':20,'probe_factorial':'20contexts x correctness2 x politeness2; exact repeated prompt within context','actor_evaluation_rows_per_policy':260,'actor_unique_contexts_per_policy':200,'shared_six_situation_primitives':True,'human_feedback':False,'rubric_scope':'finite declared grammar only; other prose UNCERTIFIED'}


def prepare(config):
    from .config import save_json,sha
    if config['seed']!=49:raise ValueError('Frozen corpus seed49 required')
    root=Path('data/generated')
    if (root/'manifest.json').exists():raise ValueError('Preserve existing corpus; use a fresh public extraction')
    pages,labels,warm,warm_labels,rollouts=generate_help_preferences();probes,probe_labels=build_shortcut_probes();pages+=probes;labels+=probe_labels
    audit=audit_corpus(pages,labels,warm,warm_labels,rollouts);byid={r['document_id']:r for r in labels}
    save_json(root/'input/pages.json',pages);save_json(root/'labels/gold.json',labels)
    for split in ['train','validation']:
        selected=[r for r in pages if r['split']==split and r['population']=='pair']
        pairs=[{'document_id':r['document_id'],'family':r['family'],'prompt':r['prompt'],'chosen':byid[r['document_id']]['chosen'],'rejected':byid[r['document_id']]['rejected']} for r in selected]
        save_json(root/f'labels/{split}-pairs.json',pairs)
        if split=='validation':save_json(root/'validation/pages.json',selected)
    save_json(root/'train/pages.json',rollouts);save_json(root/'warmstart/pages.json',warm);save_json(root/'labels/warmstart.json',warm_labels)
    save_json('outputs/data-audit.json',audit);save_json(root/'manifest.json',{**audit,'files':{str(f.relative_to(root)):sha(f) for f in root.rglob('*') if f.is_file()}})
    return audit

Xây dựng và kiểm tra bốn vai trò

Điểm vô hướng và giá trị cấp token

Bất biến: đối tượng và tham số độc lập, pooling đúng khi có padding.

Lỗi cần chẩn đoán: dùng chung backbone có thể huấn luyện giữa RM và critic làm judge thay đổi; chặn bằng kiểm thử danh tính.

Tệp src/recompense_ppo/models.py

import hashlib,json,math,os
from pathlib import Path
import torch
from torch import nn
from transformers import AutoTokenizer,Qwen2ForCausalLM
from safetensors.torch import save_file,load_file
from .config import read_json,save_json,sha

MODEL=Path('assets/model')
TARGETS=['q_proj','k_proj','v_proj','o_proj','gate_proj','up_proj','down_proj']
ADAPTER_PARAMETERS=4399104


class LoRALinear(nn.Module):
    """Frozen affine base plus alpha/r * B(Ax); adapters stay FP32."""
    def __init__(self,base,rank,alpha,generator):
        super().__init__()
        if not isinstance(base,nn.Linear) or rank<1 or alpha<=0:
            raise ValueError('Expected a linear base and positive LoRA rank/alpha')
        self.base=base.requires_grad_(False)
        self.lora_A=nn.Parameter(torch.empty(rank,base.in_features,dtype=torch.float32))
        self.lora_B=nn.Parameter(torch.zeros(base.out_features,rank,dtype=torch.float32))
        nn.init.kaiming_uniform_(self.lora_A,a=math.sqrt(5),generator=generator)
        self.scale=float(alpha/rank)

    def forward(self,x):
        baseline=self.base(x)
        delta=torch.nn.functional.linear(torch.nn.functional.linear(x.float(),self.lora_A),self.lora_B)
        return (baseline.float()+self.scale*delta).to(baseline.dtype)


def tensor_hash(tensor):
    raw=tensor.detach().cpu().contiguous().reshape(-1).view(torch.uint8).numpy().tobytes()
    return hashlib.sha256(raw).hexdigest()


def tensor_manifest(state):
    return [{'name':n,'shape':list(t.shape),'dtype':str(t.dtype),'numel':t.numel(),'sha256':tensor_hash(t)} for n,t in sorted(state.items())]


def manifest_hash(rows):
    return hashlib.sha256(json.dumps(rows,sort_keys=True,separators=(',',':')).encode()).hexdigest()


def is_learned(name):return name.endswith(('.lora_A','.lora_B')) or name.startswith('head.')


def learned_state(role):return {n:t for n,t in role.state_dict().items() if is_learned(n)}


def learned_identity(role):return manifest_hash(tensor_manifest(learned_state(role)))


def frozen_identity(role):return manifest_hash(tensor_manifest({n:t for n,t in role.state_dict().items() if not is_learned(n)}))


def load_tokenizer():
    tokenizer=AutoTokenizer.from_pretrained(MODEL,local_files_only=True,trust_remote_code=False,use_fast=True)
    if not tokenizer.is_fast or tokenizer.eos_token_id!=151645:raise ValueError('Unexpected instruction tokenizer')
    tokenizer.pad_token_id=151643;tokenizer.padding_side='left'
    return tokenizer


def verify_base(model):
    c=model.config
    if (c.hidden_size,c.num_hidden_layers,c.num_attention_heads,c.num_key_value_heads,c.intermediate_size,c.vocab_size)!=(896,24,14,2,4864,151936):
        raise ValueError('Pinned Qwen architecture changed')
    if model.lm_head.weight is not model.model.embed_tokens.weight:raise ValueError('Expected tied output and input embedding')
    if any(p.dtype!=torch.bfloat16 for p in model.parameters()):raise ValueError('Base parameters must all be BF16')
    if c._attn_implementation!='eager':raise ValueError('Expected eager attention')


def inject_lora(model,seed):
    expected={}
    for layer in range(24):
        for name in TARGETS:
            scope='self_attn' if name in TARGETS[:4] else 'mlp'
            shape=(896,896) if name in ['q_proj','o_proj'] else (128,896) if name in ['k_proj','v_proj'] else (4864,896) if name in ['gate_proj','up_proj'] else (896,4864)
            expected[f'model.layers.{layer}.{scope}.{name}']=shape
    found={n:m for n,m in model.named_modules() if n.split('.')[-1] in TARGETS}
    if set(found)!=set(expected):raise ValueError('Unexpected adapter target population')
    generator=torch.Generator(device='cpu').manual_seed(seed)
    for name in sorted(found):
        base=found[name]
        if (base.out_features,base.in_features)!=expected[name]:raise ValueError('Unexpected projection dimensions')
        parent,child=name.rsplit('.',1);setattr(model.get_submodule(parent),child,LoRALinear(base,8,16,generator))
    count=sum(p.numel() for n,p in model.named_parameters() if n.endswith(('.lora_A','.lora_B')))
    if count!=ADAPTER_PARAMETERS:raise ValueError('Unexpected LoRA parameter count')


class Role(nn.Module):
    def __init__(self,backbone,kind,seed):
        super().__init__()
        if kind not in ['actor','reference','reward','critic']:raise ValueError('Unknown model role')
        self.backbone=backbone;self.kind=kind
        if kind in ['reward','critic']:
            with torch.random.fork_rng(devices=[]):self.head=nn.Linear(896,1,dtype=torch.float32)
            generator=torch.Generator(device='cpu').manual_seed(seed)
            with torch.no_grad():
                self.head.bias.zero_()
                if kind=='critic':self.head.weight.zero_()
                else:self.head.weight.normal_(0.,.02,generator=generator)
        else:self.head=None
        self.eval()

    def logits(self,input_ids,attention_mask):
        return self.backbone(input_ids=input_ids,attention_mask=attention_mask,use_cache=False).logits.float()

    def values(self,input_ids,attention_mask):
        if self.head is None:raise ValueError('This role has no scalar head')
        hidden=self.backbone.model(input_ids=input_ids,attention_mask=attention_mask,use_cache=False).last_hidden_state
        return self.head(hidden.float()).squeeze(-1)

    def reward(self,input_ids,attention_mask,response_mask):
        values=self.values(input_ids,attention_mask)
        if self.kind!='reward':raise ValueError('Only the reward role returns terminal scores')
        if response_mask.dtype!=torch.bool or response_mask.shape!=input_ids.shape or not bool(response_mask.any(1).all()) or bool((response_mask & ~attention_mask.bool()).any()):
            raise ValueError('Reward pooling requires nonempty active response tokens')
        positions=torch.arange(input_ids.shape[1],device=input_ids.device).expand_as(input_ids)
        last=positions.masked_fill(~response_mask,-1).max(1).values
        return values[torch.arange(len(last),device=last.device),last]


def build_role(kind,device='cuda',adapter_state=None):
    seed=50 if kind=='reward' else 49
    backbone,info=Qwen2ForCausalLM.from_pretrained(MODEL,local_files_only=True,trust_remote_code=False,torch_dtype=torch.bfloat16,attn_implementation='eager',output_loading_info=True)
    if info.get('unexpected_keys') or info.get('mismatched_keys') or info.get('error_msgs') or set(info.get('missing_keys',[]))-{'lm_head.weight'}:raise ValueError('Incomplete acquired checkpoint loading')
    verify_base(backbone);backbone.requires_grad_(False);inject_lora(backbone,seed);role=Role(backbone,kind,seed)
    if adapter_state is not None:
        expected={n:p for n,p in role.named_parameters() if n.endswith(('.lora_A','.lora_B'))}
        selected={n:t for n,t in adapter_state.items() if n.endswith(('.lora_A','.lora_B'))}
        if set(expected)!=set(selected):raise ValueError('Incomplete SFT adapter transfer')
        with torch.no_grad():
            for n,p in expected.items():
                if p.shape!=selected[n].shape or selected[n].dtype!=torch.float32:raise ValueError('Adapter shape/dtype mismatch')
                p.copy_(selected[n])
    if kind=='reference':role.requires_grad_(False)
    if str(device).startswith('cuda') and not torch.cuda.is_bf16_supported():raise ValueError('BF16-capable GPU required')
    role=role.to(device).eval()
    if any(p.dtype!=(torch.float32 if is_learned(n) else torch.bfloat16) for n,p in role.named_parameters()):raise ValueError('Role dtype contract failed')
    return role


def assert_role_isolation(roles):
    if set(roles)!={'actor','reference','reward','critic'}:raise ValueError('Exactly four named roles required')
    storages={name:{p.untyped_storage().data_ptr() for p in role.parameters()} for name,role in roles.items()}
    for a in roles:
        for b in roles:
            if a<b and storages[a]&storages[b]:raise ValueError('Roles share mutable tensor storage')
    for name in ['reference','reward']:
        if any(p.requires_grad or p.grad is not None for p in roles[name].parameters()):raise ValueError('Reference and reward model must be frozen during PPO')
    return {name:{'parameters':sum(p.numel() for p in role.parameters()),'trainable':sum(p.numel() for p in role.parameters() if p.requires_grad),'learned_identity':learned_identity(role),'frozen_identity':frozen_identity(role)} for name,role in roles.items()}


def save_learned(role,path,identity):
    path=Path(path);path.parent.mkdir(parents=True,exist_ok=True)
    if path.exists():raise ValueError('Preserve existing learned state')
    state={n:t.detach().cpu().contiguous().clone() for n,t in learned_state(role).items()}
    temporary=path.with_suffix('.pending');save_file(state,str(temporary),metadata={'identity':json.dumps(identity,sort_keys=True)});os.replace(temporary,path)
    save_json(path.with_suffix('.json'),{'identity':identity,'kind':role.kind,'sha256':sha(path),'learned_identity':learned_identity(role),'frozen_identity':frozen_identity(role),'tensors':tensor_manifest(state)})


def reload_learned(path,device='cuda',kind=None):
    path=Path(path);metadata=read_json(path.with_suffix('.json'))
    if sha(path)!=metadata['sha256']:raise ValueError('Safetensors hash mismatch')
    role=build_role(kind or metadata['kind'],device=device);state=load_file(str(path),device='cpu');expected=learned_state(role)
    if set(state)!=set(expected):raise ValueError('Learned tensor names mismatch')
    with torch.no_grad():
        for n,t in expected.items():
            if t.shape!=state[n].shape or t.dtype!=state[n].dtype:raise ValueError('Learned tensor shape/dtype mismatch')
            t.copy_(state[n])
    if learned_identity(role)!=metadata['learned_identity'] or frozen_identity(role)!=metadata['frozen_identity']:raise ValueError('Reloaded role differs')
    return role,metadata

Khởi tạo actor rồi học judge

So sánh SFT và Bradley-Terry

Bất biến: điểm RM giữ lại, hash đóng băng và chi phí của hai giai đoạn được ghi lại.

Lỗi cần chẩn đoán: pool ở chỉ số batch cuối đọc PAD; dùng mask phản hồi và kiểm tra độ dài khác nhau.

Tệp src/recompense_ppo/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 .models import build_role,load_tokenizer,learned_state,learned_identity,frozen_identity,save_learned,ADAPTER_PARAMETERS
from .scores import completion_row,model_scores,reward_scores
from .ppo_math import bradley_terry
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.,(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 heldout inputs/gold/inference outputs: '+name)


def copy_learned(role,state):
    expected=learned_state(role)
    if set(expected)!=set(state):raise ValueError('Resume learned tensor identities differ')
    with torch.no_grad():
        for name,tensor in expected.items():
            if tensor.shape!=state[name].shape or tensor.dtype!=state[name].dtype:raise ValueError('Resume tensor shape/dtype mismatch')
            tensor.copy_(state[name])


def encoded_pairs(tokenizer,config,split):
    return [{'document_id':r['document_id'],'chosen':completion_row(tokenizer,r['prompt'],r['chosen'],config,r['document_id']+'-chosen'),'rejected':completion_row(tokenizer,r['prompt'],r['rejected'],config,r['document_id']+'-rejected')} for r in read_json(f'data/generated/labels/{split}-pairs.json')]


def encoded_warm(tokenizer,config):
    labels={r['document_id']:r['response'] for r in read_json('data/generated/labels/warmstart.json')}
    return [completion_row(tokenizer,r['prompt'],labels[r['document_id']],config,r['document_id']) for r in read_json('data/generated/warmstart/pages.json')]


@torch.no_grad()
def validate_epoch(role,rows,branch):
    result=[]
    for row in rows:
        if branch=='sft':
            sums,counts,values,_,targets=model_scores(role,[row['chosen']])
            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()})
        else:
            chosen=reward_scores(role,[row['chosen']])[0];rejected=reward_scores(role,[row['rejected']])[0]
            result.append({'document_id':row['document_id'],'chosen':float(chosen),'rejected':float(rejected),'correct':bool(chosen>rejected),'loss':float(bradley_terry(chosen[None],rejected[None]))})
    return {'population':len(result),'rows':result,'used_for_selection':False,'fixed_final_epoch':True}


def train_warmstart(config,branch,mode):
    if branch not in ['sft','reward'] or mode not in ['interrupt','continuous','resume']:raise ValueError('Unknown warmstart phase')
    assert_learning_scope();seed_all(config['seed']);tokenizer=load_tokenizer();rows=encoded_warm(tokenizer,config) if branch=='sft' else encoded_pairs(tokenizer,config,'train');validation=encoded_pairs(tokenizer,config,'validation')
    if len(rows)!=(168 if branch=='sft' else 840) or len(validation)!=180:raise ValueError('Frozen warmstart populations changed')
    role=build_role('actor' if branch=='sft' else 'reward');parameters=[p for p in role.parameters() if p.requires_grad]
    if sum(p.numel() for p in parameters)!=(ADAPTER_PARAMETERS+(897 if branch=='reward' else 0)) or any(p.dtype!=torch.float32 for p in parameters):raise ValueError('Unexpected learned parameters')
    initial=learned_identity(role);base=frozen_identity(role);epochs=config['sft_epochs'] if branch=='sft' else config['reward_epochs'];peak=config['sft_learning_rate'] if branch=='sft' else config['reward_learning_rate'];total=math.ceil(len(rows)/config['accumulation_examples'])*epochs
    names=['warmstart/pages.json','labels/warmstart.json','labels/validation-pairs.json'] if branch=='sft' else ['labels/train-pairs.json','labels/validation-pairs.json']
    identity={'branch':branch,'config':config,'initial_learned':initial,'frozen_base':base,'model_sha256':sha('assets/model/model.safetensors'),'tokenizer_sha256':sha('assets/model/tokenizer.json'),'data_sha256':{n:sha('data/generated/'+n) for n in names},'code_sha256':{n:sha('src/recompense_ppo/'+n) for n in ['models.py','scores.py','ppo_math.py','warmstart.py','state.py']}}
    optimizer=torch.optim.AdamW(parameters,lr=peak,weight_decay=config['weight_decay']);sampler=torch.Generator().manual_seed(149);root=Path('assets/checkpoints');destination=root/f'{branch}-{mode}.pt.gz'
    if destination.exists():raise ValueError('Preserve existing checkpoint')
    updates=first_epoch=0;history=[];validation_history=[]
    if mode=='resume':
        state=load_resume_state(root/f'{branch}-interrupt.pt.gz')
        if state['identity']!=identity:raise ValueError('Warmstart resume identity changed')
        copy_learned(role,state['learned']);optimizer.load_state_dict(state['optimizer']);sampler.set_state(state['sampler']);restore_rng(state['rng']);updates=state['updates'];first_epoch=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('Resume scheduler differs')
        del state
    stop=1 if mode=='interrupt' else epochs;torch.cuda.reset_peak_memory_stats();torch.cuda.synchronize();started=time.perf_counter()
    for epoch in range(first_epoch,stop):
        order=torch.randperm(len(rows),generator=sampler).tolist()
        for offset in range(0,len(rows),config['accumulation_examples']):
            batch=[rows[i] for i in order[offset:offset+config['accumulation_examples']]];optimizer.zero_grad(set_to_none=True);lr=learning_rate(config,updates,total,peak)
            for group in optimizer.param_groups:group['lr']=lr
            denominator=sum(r['response_tokens'] for r in batch) if branch=='sft' else len(batch);trace=[];loss_value=0.
            for row in batch:
                if branch=='sft':
                    sums,counts,values,_,targets=model_scores(role,[row]);loss=-sums[0]/denominator;trace.append({'document_id':row['document_id'],'tokens':int(counts[0]),'sum_logprob':float(sums[0].detach()),'token_logprobs':values.detach().cpu().tolist(),'targets':targets.cpu().tolist()})
                else:
                    chosen=reward_scores(role,[row['chosen']]);rejected=reward_scores(role,[row['rejected']]);loss=bradley_terry(chosen,rejected)/denominator;trace.append({'document_id':row['document_id'],'chosen':float(chosen[0].detach()),'rejected':float(rejected[0].detach()),'chosen_tokens':row['chosen']['response_tokens'],'rejected_tokens':row['rejected']['response_tokens']})
                loss.backward();loss_value+=float(loss.detach())
            if any(p.grad is None or not torch.isfinite(p.grad).all() for p in parameters):raise ValueError('Missing/nonfinite learned gradients')
            norm=float(torch.nn.utils.clip_grad_norm_(parameters,config['gradient_clip']));optimizer.step();updates+=1
            history.append({'epoch':epoch+1,'update':updates,'lr':lr,'denominator':denominator,'denominator_kind':'active_response_tokens' if branch=='sft' else 'pairs','rows':trace,'loss':loss_value,'unclipped_gradient_norm':norm})
        validation_history.append({'epoch':epoch+1,**validate_epoch(role,validation,branch)})
    torch.cuda.synchronize();seconds=time.perf_counter()-started;final=learned_identity(role)
    if frozen_identity(role)!=base:raise ValueError('Frozen base changed')
    if final==initial:raise ValueError('Warmstart did not change learned parameters')
    state={'identity':identity,'learned':{n:t.detach().cpu().clone() for n,t in learned_state(role).items()},'optimizer':optimizer.state_dict(),'sampler':sampler.get_state(),'rng':rng_state(),'epoch':stop,'updates':updates,'scheduler':{'next_update':updates,'next_lr':learning_rate(config,updates,total,peak)},'history':history,'validation_history':validation_history}
    storage=save_resume_state(destination,state)
    save_json(f'outputs/learning/{branch}/{mode}.json',{'identity':identity,'mode':mode,'epochs_completed':stop,'updates':updates,'history':history,'validation_history':validation_history,'initial_learned':initial,'final_learned':final,'frozen_base_unchanged':True,'trainable_parameters':sum(p.numel() for p in parameters),'seconds':seconds,'peak_allocated_bytes':torch.cuda.max_memory_allocated(),'peak_reserved_bytes':torch.cuda.max_memory_reserved(),'checkpoint_storage':storage})
    if mode=='resume':save_learned(role,root/f'{branch}-resume.safetensors',identity)
    return {'branch':branch,'mode':mode,'updates':updates,'seconds':seconds}


def check_warm_resume(branch):
    root=Path('assets/checkpoints');continuous=load_resume_state(root/f'{branch}-continuous.pt.gz');resumed=load_resume_state(root/f'{branch}-resume.pt.gz')
    if not equal(continuous,resumed):raise ValueError('Exact warmstart continuation failed: '+branch)
    result={'status':'PASS','branch':branch,'all_state_exact':True,'updates':resumed['updates'],'continuous_sha256':sha(root/f'{branch}-continuous.pt.gz'),'resume_sha256':sha(root/f'{branch}-resume.pt.gz')};save_json(f'outputs/learning/{branch}/resume-check.json',result);return result

Mã hóa PPO và GAE không hộp đen

Gán công theo thời gian và clipping

Bất biến: GAE phân tích khớp; ref/RM bất biến, actor/critic thay đổi; lưu hoàn chỉnh.

Lỗi cần chẩn đoán: bootstrap vượt qua kết thúc được coi là terminal làm GAE sai; kiểm tra done và giá trị cuối.

Tệp src/recompense_ppo/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 .models import build_role,reload_learned,learned_state,learned_identity,frozen_identity,assert_role_isolation,load_tokenizer,save_learned
from .scores import rollout_completion_row,model_scores,critic_scores
from .rollout import sample_response,fixed_ppo_targets
from .ppo_math import ppo_actor_loss,critic_value_loss
from .warmstart import assert_learning_scope,learning_rate,copy_learned
from .state import save_resume_state,load_resume_state,equal


def next_prompts(pages,order,cursor,generator,count):
    chosen=[]
    while len(chosen)<count:
        if cursor==len(order):order=torch.randperm(len(pages),generator=generator).tolist();cursor=0
        chosen.append(pages[order[cursor]]);cursor+=1
    return chosen,order,cursor


def initial_roles():
    actor,_=reload_learned('assets/checkpoints/sft-resume.safetensors')
    reference,_=reload_learned('assets/checkpoints/sft-resume.safetensors',kind='reference')
    reward,_=reload_learned('assets/checkpoints/reward-resume.safetensors');reward.requires_grad_(False)
    critic=build_role('critic',adapter_state=learned_state(actor))
    roles={'actor':actor,'reference':reference,'reward':reward,'critic':critic}
    return roles,assert_role_isolation(roles)


def train_ppo(config,mode):
    if mode not in ['interrupt','continuous','resume']:raise ValueError('Unknown PPO branch')
    assert_learning_scope();seed_all(config['seed']);tokenizer=load_tokenizer();pages=read_json('data/generated/train/pages.json')
    if len(pages)!=672 or len({r['document_id'] for r in pages})!=672 or any(r['split']!='train' or r['population']!='rollout' for r in pages):raise ValueError('Training rollout population changed')
    roles,initial=initial_roles();actor=roles['actor'];critic=roles['critic'];reference=roles['reference'];reward=roles['reward'];parameters={n:[p for p in roles[n].parameters() if p.requires_grad] for n in ['actor','critic']}
    optimizers={n:torch.optim.AdamW(parameters[n],lr=config['ppo_'+n+'_lr'],weight_decay=config['weight_decay']) for n in parameters}
    identity={'config':config,'roles_initial':initial,'model_sha256':sha('assets/model/model.safetensors'),'tokenizer_sha256':sha('assets/model/tokenizer.json'),'data_sha256':sha('data/generated/train/pages.json'),'sft_sha256':sha('assets/checkpoints/sft-resume.safetensors'),'reward_sha256':sha('assets/checkpoints/reward-resume.safetensors'),'code_sha256':{n:sha('src/recompense_ppo/'+n) for n in ['models.py','scores.py','rollout.py','ppo_math.py','warmstart.py','train.py','state.py']}}
    sampler=torch.Generator().manual_seed(149);generation=torch.Generator(device='cuda').manual_seed(249);order=[];cursor=0;iteration=updates=0;history=[];consumed=[];total=config['rollout_iterations']*config['ppo_epochs_per_rollout'];root=Path('assets/checkpoints');destination=root/f'ppo-{mode}.pt.gz'
    if destination.exists():raise ValueError('Preserve existing PPO checkpoint')
    if mode=='resume':
        state=load_resume_state(root/'ppo-interrupt.pt.gz')
        if state['identity']!=identity or state['pending_rollouts']!=[]:raise ValueError('PPO identity or atomic boundary differs')
        for n,role in roles.items():copy_learned(role,state['roles'][n])
        for n,optimizer in optimizers.items():optimizer.load_state_dict(state['optimizers'][n])
        sampler.set_state(state['sampler']);generation.set_state(state['generation']);restore_rng(state['rng']);order=state['order'];cursor=state['cursor'];iteration=state['iteration'];updates=state['updates'];history=state['history'];consumed=state['consumed_rollout_ids']
        expected={n:learning_rate(config,updates,total,config['ppo_'+n+'_lr']) for n in optimizers}
        if state['scheduler']!={'next_update':updates,'next_lr':expected}:raise ValueError('PPO scheduler differs')
        del state
    stop=config['interrupt_iteration'] if mode=='interrupt' else config['rollout_iterations'];phase={'generation_seconds':0.,'actor_optimization_seconds':0.,'critic_optimization_seconds':0.};torch.cuda.reset_peak_memory_stats();torch.cuda.synchronize();started=time.perf_counter()
    while iteration<stop:
        selected,order,cursor=next_prompts(pages,order,cursor,sampler,config['prompts_per_rollout']);policy_id=learned_identity(actor);rollouts=[]
        for slot,page in enumerate(selected):
            raw=sample_response(actor,tokenizer,page,config,generation,reference=reference,critic=critic,reward_model=reward,policy_id=policy_id,capture_probe=iteration==0 and slot<2)
            raw['rollout_id']=f'iteration-{iteration+1:03d}-slot-{slot}';raw['targets']=fixed_ppo_targets(raw,config);rollouts.append(raw);phase['generation_seconds']+=raw['elapsed_seconds']
        ids=[r['rollout_id'] for r in rollouts]
        if len(set(ids))!=len(ids) or set(ids)&set(consumed):raise ValueError('Duplicate consumed rollout identity')
        save_json(f'outputs/learning/rollouts/{mode}/iteration-{iteration+1:03d}.json',{'iteration':iteration+1,'policy_id':policy_id,'rollouts':rollouts})
        pure_rollouts=[{k:v for k,v in r.items() if k not in ['elapsed_seconds','phase_seconds','first_prefix_probe']} for r in rollouts]
        epoch_traces=[]
        for epoch in range(config['ppo_epochs_per_rollout']):
            learning_rates={n:learning_rate(config,updates,total,config['ppo_'+n+'_lr']) for n in optimizers}
            for n,optimizer in optimizers.items():
                optimizer.zero_grad(set_to_none=True)
                for group in optimizer.param_groups:group['lr']=learning_rates[n]
            observations=[]
            for raw in rollouts:
                row=rollout_completion_row(raw);old=torch.tensor([raw['old_token_logprobs']],dtype=torch.float32,device='cuda');advantages=torch.tensor([raw['targets']['advantages']],dtype=torch.float32,device='cuda');returns=torch.tensor([raw['targets']['returns']],dtype=torch.float32,device='cuda');mask=torch.ones_like(old,dtype=torch.bool)
                torch.cuda.synchronize();start=time.perf_counter();_,_,current,logits,targets=model_scores(actor,[row]);current=current.unsqueeze(0);actor_loss=ppo_actor_loss(current,old,advantages,mask,epsilon=config['clip_epsilon']);(actor_loss/len(rollouts)).backward();torch.cuda.synchronize();phase['actor_optimization_seconds']+=time.perf_counter()-start
                ratios=(current.detach()-old).exp();probe={'logits':logits[0].detach().cpu().tolist(),'target':int(targets[0])} if iteration==0 and epoch==0 and len(observations)<2 else None
                actor_value=float(actor_loss.detach());current_values=current.detach().cpu().tolist()[0];ratio_values=ratios.cpu().tolist()[0]
                del actor_loss,current,logits,targets
                start=time.perf_counter();values,counts=critic_scores(critic,[row]);values=values.unsqueeze(0);critic_loss=critic_value_loss(values,returns,mask,coefficient=config['value_loss_coef']);(critic_loss/len(rollouts)).backward();torch.cuda.synchronize();phase['critic_optimization_seconds']+=time.perf_counter()-start
                observations.append({'rollout_id':raw['rollout_id'],'current_token_logprobs':current_values,'ratios':ratio_values,'current_values':values.detach().cpu().tolist()[0],'actor_loss':actor_value,'critic_loss':float(critic_loss.detach()),'first_token_probe':probe,'active_tokens':raw['response_tokens'],'old_logprob_max_difference':max(abs(x-y) for x,y in zip(current_values,raw['old_token_logprobs']))})
                del critic_loss,values
            norms={}
            for n,optimizer in optimizers.items():
                if any(p.grad is None or not torch.isfinite(p.grad).all() for p in parameters[n]):raise ValueError('Missing/nonfinite '+n+' gradients')
                norms[n]=float(torch.nn.utils.clip_grad_norm_(parameters[n],config['gradient_clip']));optimizer.step()
            if any(p.grad is not None for role in [reference,reward] for p in role.parameters()):raise ValueError('Frozen judge/reference received gradients')
            updates+=1;epoch_traces.append({'update':updates,'epoch_within_rollout':epoch+1,'learning_rates':learning_rates,'gradient_norms':norms,'equal_response_denominator':len(rollouts),'observations':observations})
        consumed.extend(ids);iteration+=1
        history.append({'iteration':iteration,'rollouts':pure_rollouts,'updates':epoch_traces})
    torch.cuda.synchronize();seconds=time.perf_counter()-started;final={n:learned_identity(role) for n,role in roles.items()}
    for n,role in roles.items():
        if frozen_identity(role)!=initial[n]['frozen_identity']:raise ValueError('Frozen base changed: '+n)
    for n in ['reference','reward']:
        if final[n]!=initial[n]['learned_identity']:raise ValueError('Frozen reference/reward adapters or head changed')
    for n in ['actor','critic']:
        if final[n]==initial[n]['learned_identity'] or not any(u['gradient_norms'][n]>0 for h in history for u in h['updates']):raise ValueError('No actual '+n+' learning')
    state={'identity':identity,'roles':{n:{k:t.detach().cpu().clone() for k,t in learned_state(role).items()} for n,role in roles.items()},'optimizers':{n:optimizer.state_dict() for n,optimizer in optimizers.items()},'sampler':sampler.get_state(),'generation':generation.get_state(),'rng':rng_state(),'order':order,'cursor':cursor,'iteration':iteration,'updates':updates,'history':history,'consumed_rollout_ids':consumed,'pending_rollouts':[],'scheduler':{'next_update':updates,'next_lr':{n:learning_rate(config,updates,total,config['ppo_'+n+'_lr']) for n in optimizers}}}
    storage=save_resume_state(destination,state)
    save_json(f'outputs/learning/ppo/{mode}.json',{'identity':identity,'mode':mode,'iterations':iteration,'updates_per_optimizer':updates,'history':history,'consumed_rollout_ids':consumed,'final_learned':final,'frozen_reference_reward_unchanged':True,'seconds':seconds,'phase_seconds':phase,'peak_allocated_bytes':torch.cuda.max_memory_allocated(),'peak_reserved_bytes':torch.cuda.max_memory_reserved(),'checkpoint_storage':storage,'numerical_scope':'Old sampling-prefix logprobs preserved; current teacher-forced BF16 scores may differ numerically before first update. Per-token differences are reported, never substituted for old values.'})
    if mode=='resume':
        save_learned(actor,root/'ppo-resume.safetensors',identity);save_learned(critic,root/'critic-resume.safetensors',identity)
    return {'mode':mode,'iterations':iteration,'updates_per_optimizer':updates,'seconds':seconds}


def check_ppo_resume():
    root=Path('assets/checkpoints');continuous=load_resume_state(root/'ppo-continuous.pt.gz');resumed=load_resume_state(root/'ppo-resume.pt.gz')
    if not equal(continuous,resumed):raise ValueError('Exact four-role PPO continuation failed')
    result={'status':'PASS','all_state_exact':True,'iterations':resumed['iteration'],'updates_per_optimizer':resumed['updates'],'rollout_ids':len(resumed['consumed_rollout_ids']),'continuous_sha256':sha(root/'ppo-continuous.pt.gz'),'resume_sha256':sha(root/'ppo-resume.pt.gz')};save_json('outputs/learning/ppo/resume-check.json',result);return result

Đánh giá theo judge và theo mục tiêu

Bất đồng và khai thác proxy

Bất biến: mọi trường hợp có hai đánh giá riêng biệt; lời khuyên sai vẫn hiển thị.

Lỗi cần chẩn đoán: gọi điểm RM là 'tính hữu ích cho con người' là sai; gọi là điểm dự đoán của ưu tiên mô phỏng.

Tệp src/recompense_ppo/evaluate.py

import math,time
from pathlib import Path
import torch
from .config import read_json,save_json,sha
from .models import reload_learned,load_tokenizer,learned_identity
from .scores import completion_row,reward_scores
from .rollout import sample_response
from .rubric import evaluate_response


@torch.no_grad()
def generate_heldout(config,branch):
    if branch not in ['sft','ppo']:raise ValueError('Named actor branch required')
    if Path('data/generated/labels').exists() or Path('outputs/learning').exists():raise ValueError('Free generation must not mount labels or learning outputs')
    pages=[r for r in read_json('data/generated/input/pages.json') if r['split'] in ['test','probe']]
    if len(pages)!=260 or len({r['prompt'] for r in pages})!=200:raise ValueError('Heldout row/context populations changed')
    actor,actor_meta=reload_learned(f'assets/checkpoints/{branch}-resume.safetensors');actor.requires_grad_(False)
    reference,ref_meta=reload_learned('assets/checkpoints/sft-resume.safetensors',kind='reference')
    reward,reward_meta=reload_learned('assets/checkpoints/reward-resume.safetensors');reward.requires_grad_(False)
    tokenizer=load_tokenizer();policy_id=learned_identity(actor);generator=torch.Generator(device='cuda').manual_seed(349);rows=[];torch.cuda.reset_peak_memory_stats();torch.cuda.synchronize();start=time.perf_counter()
    for index,page in enumerate(pages):
        raw=sample_response(actor,tokenizer,page,config,generator,reference=reference,reward_model=reward,mode='greedy',policy_id=policy_id,capture_probe=index<2)
        rows.append({'page':page,'generation':raw})
    byprompt={}
    for row in rows:
        raw=row['generation'];key=row['page']['prompt'];value={k:raw[k] for k in ['response_ids','text','finish_reason','learned_reward_score','old_token_logprobs','reference_token_logprobs']}
        if key in byprompt and byprompt[key]!=value:raise ValueError('Repeated greedy probe contexts differ')
        byprompt[key]=value
    torch.cuda.synchronize();result={'branch':branch,'rows':rows,'row_count':260,'unique_contexts':200,'repeated_probe_contexts_verified':True,'labels_visible':False,'actor_checkpoint':actor_meta['sha256'],'reference_checkpoint':ref_meta['sha256'],'reward_checkpoint':reward_meta['sha256'],'seconds':time.perf_counter()-start,'peak_allocated_bytes':torch.cuda.max_memory_allocated(),'peak_reserved_bytes':torch.cuda.max_memory_reserved()};save_json(f'outputs/inference/{branch}.json',result)
    return {k:v for k,v in result.items() if k!='rows'}


@torch.no_grad()
def evaluate_reward_pairs(config):
    pages={r['document_id']:r for r in read_json('data/generated/input/pages.json')};gold=read_json('data/generated/labels/gold.json');selected=[r for r in gold if pages[r['document_id']]['split']=='test'];tokenizer=load_tokenizer();reward,meta=reload_learned('assets/checkpoints/reward-resume.safetensors');reward.requires_grad_(False);rows=[]
    if len(selected)!=180:raise ValueError('Reward test population changed')
    torch.cuda.synchronize();start=time.perf_counter()
    for label in selected:
        page=pages[label['document_id']];chosen=completion_row(tokenizer,page['prompt'],label['chosen'],config,page['document_id']+'-chosen');rejected=completion_row(tokenizer,page['prompt'],label['rejected'],config,page['document_id']+'-rejected');a=float(reward_scores(reward,[chosen])[0]);b=float(reward_scores(reward,[rejected])[0]);rows.append({'document_id':page['document_id'],'chosen_score':a,'rejected_score':b,'chosen_response':label['chosen'],'rejected_response':label['rejected'],'chosen_tokens':chosen['input_ids'],'rejected_tokens':rejected['input_ids'],'correct':a>b,'tie':a==b})
    torch.cuda.synchronize();result={'pairs':180,'correct':sum(r['correct'] for r in rows),'ties':sum(r['tie'] for r in rows),'accuracy':sum(r['correct'] for r in rows)/180,'rows':rows,'reward_checkpoint':meta['sha256'],'used_for_selection':False,'seconds':time.perf_counter()-start};save_json('outputs/reward-test.json',result);return {k:v for k,v in result.items() if k!='rows'}


def summarize(rows):
    recognized=[r for r in rows if r['rubric']['recognized']]
    return {'rows':len(rows),'rubric_success':sum(r['rubric']['rubric_success'] for r in rows),'rubric_success_rate':sum(r['rubric']['rubric_success'] for r in rows)/len(rows),'recognized':len(recognized),'recognized_rate':len(recognized)/len(rows),'uncertified':len(rows)-len(recognized),'relevant_recognized':sum(r['rubric']['relevant'] for r in recognized),'unsupported_certainty_recognized':sum(r['rubric']['unsupported_certainty'] is True for r in recognized),'declared_refusals':sum(r['rubric']['refusal'] for r in rows),'without_eos':sum(r['finish_reason']!='eos' for r in rows),'mean_response_tokens':sum(r['response_tokens'] for r in rows)/len(rows),'mean_learned_reward':sum(r['learned_reward_score'] for r in rows)/len(rows),'mean_sampled_actor_reference_logratio':sum(r['sampled_actor_reference_logratio'] for r in rows)/len(rows),'uncertainty_denominator':len(recognized),'human_utility_claim':False}


def evaluate(config):
    gold={r['document_id']:r for r in read_json('data/generated/labels/gold.json')};result={}
    for branch in ['sft','ppo']:
        raw=read_json(f'outputs/inference/{branch}.json');rows=[]
        for item in raw['rows']:
            g=item['generation'];page=item['page'];label=gold[page['document_id']];rubric=evaluate_response(g['text'],g['finish_reason'],label['expected_action'])
            if not g['tokenization_valid']:rubric={**rubric,'recognized':False,'status':'UNCERTIFIED','rubric_success':False,'relevant':False,'unsupported_certainty':None,'reason':'undecodable_token','refusal':False,'polite':False,'refusal':False,'polite':False}
            ratio=sum(a-b for a,b in zip(g['old_token_logprobs'],g['reference_token_logprobs']))/g['response_tokens']
            rows.append({'document_id':page['document_id'],'family':page['family'],'split':page['split'],'prompt':page['prompt'],'text':g['text'],'finish_reason':g['finish_reason'],'response_tokens':g['response_tokens'],'learned_reward_score':g['learned_reward_score'],'sampled_actor_reference_logratio':ratio,'rubric':rubric})
        unique={r['prompt']:r for r in rows};probe=[r for r in rows if r['split']=='probe'];unique_probe={r['prompt']:r for r in probe}
        populations={'test':[r for r in rows if r['split']=='test'],'probe_rows':probe,'probe_unique_contexts':list(unique_probe.values()),'all_rows':rows,'all_unique_contexts':list(unique.values())}
        result[branch]={'metrics':{name:summarize(selected) for name,selected in populations.items()},'rows':rows,'generation_seconds':raw['seconds'],'raw_sha256':sha(f'outputs/inference/{branch}.json')}
    output={'actor':result,'reward_test':read_json('outputs/reward-test.json'),'scope':'Synthetic finite grammar; UNCERTIFIED prose is not silently accepted. 80 probe rows contain20unique contexts; duplicate-adjusted summaries are separate. Learned reward is not the rubric, no human study.'};save_json('outputs/results.json',output);return {branch:v['metrics'] for branch,v in result.items()}


@torch.no_grad()
def exercise(config):
    protected_paths=[Path('configs/minimum.yaml'),Path('outputs/results.json'),Path('outputs/reward-test.json')]+list(Path('outputs/inference').glob('*.json'))+list(Path('assets/checkpoints').glob('*.safetensors'))
    protected={str(p):sha(p) for p in protected_paths};pages={r['document_id']:r for r in read_json('data/generated/input/pages.json')};labels=[r for r in read_json('data/generated/labels/gold.json') if pages[r['document_id']]['split']=='probe'];tokenizer=load_tokenizer();reward,metadata=reload_learned('assets/checkpoints/reward-resume.safetensors');reward.requires_grad_(False);before=learned_identity(reward);rows=[];torch.cuda.synchronize();start=time.perf_counter()
    if len(labels)!=80:raise ValueError('Expected80preserved factorial probes')
    for label in labels:
        page=pages[label['document_id']];row=completion_row(tokenizer,page['prompt'],label['candidate_response'],config,page['document_id']);score=float(reward_scores(reward,[row])[0]);rows.append({'document_id':page['document_id'],'family':page['family'],'prompt':page['prompt'],'candidate':label['candidate_response'],'candidate_correct':label['candidate_correct'],'candidate_polite':label['candidate_polite'],'input_ids':row['input_ids'],'response_tokens':row['response_tokens'],'reward_score':score,'rubric':evaluate_response(label['candidate_response'],'eos',label['expected_action'])})
    groups=[]
    for family in sorted({r['family'] for r in rows}):
        group={(r['candidate_correct'],r['candidate_polite']):r['reward_score'] for r in rows if r['family']==family}
        if len(group)!=4:raise ValueError('Incomplete factorial probe')
        groups.append({'family':family,'correct_politeness_delta':group[True,True]-group[True,False],'incorrect_politeness_delta':group[False,True]-group[False,False],'incorrect_polite_above_correct_plain':group[False,True]>group[True,False]})
    torch.cuda.synchronize()
    if learned_identity(reward)!=before or any(sha(Path(p))!=value for p,value in protected.items()):raise ValueError('Exercise changed judge/main artifacts')
    result={'probe_rows':80,'unique_contexts':20,'rows':rows,'groups':groups,'wrong_polite_outranks_correct_plain':sum(g['incorrect_polite_above_correct_plain'] for g in groups),'mean_incorrect_politeness_delta':sum(g['incorrect_politeness_delta'] for g in groups)/20,'seconds':time.perf_counter()-start,'reward_checkpoint':metadata['sha256'],'reward_unchanged':True,'main_unchanged':True,'protected_sha256':protected,'refit':False};save_json('outputs/exercise/reward-shortcuts.json',result);return {k:v for k,v in result.items() if k not in ['rows','groups','protected_sha256']}

Kiểm tra lối tắt lịch sự

Reward hacking có thể quan sát

Bất biến: 80 probe được đánh giá không update RM và không thay đổi nhãn.

Lỗi cần chẩn đoán: huấn luyện lại RM ngay trên các probe này rồi công bố lại điểm ban đầu phá hủy sự tách biệt; hãy phiên bản hóa mọi nghiên cứu mới.

Tệp src/recompense_ppo/exercise.py

"""Fixed factorial probe analysis; no further learning or relabeling."""
from .evaluate import exercise

__all__ = ["exercise"]

Nguồn

PPO, GAE, InstructGPT và mã TRL đã đóng băng. Các nguồn này giải thích cơ chế; biến thể tường minh của chúng tôi không tái tạo một nghiên cứu trên người hay toàn bộ trainer TRL.

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