Parlons de votre projet
Intelligence artificielle

Un modèle plus petit peut-il garder les informations d’un grand ?

Vous faites extraire des événements de livraison par un modèle enseignant, puis transmettez ses probabilités à un étudiant plus petit. Le tutoriel compare cette distillation à un SFT sur les mêmes exemples et mesure qualité, erreurs et coût.

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

Un système fictif transforme des messages de livraison en événements JSON courts. Un enseignant de 1,5B est adapté localement puis transmet ses distributions de prochain token à un étudiant de 0,5B, évalué contre un étudiant entraîné uniquement sur les mêmes labels.

Pour un même message, afficher enseignant, étudiant SFT et étudiant distillé, leurs erreurs de champs et leurs coûts d'inférence. Montrer les probabilités souples de quelques tokens au même préfixe, avec température.

Télécharger le projet complet, révision r001

Le parcours du tutoriel, du cas concret à la vérification.

Une tâche bornée et des familles réservées

ParcelEvents-v1 contient 400 familles fictives de colis, cinq messages par famille, soit 2000 exemples. Une permutation de graine 50 répartit les familles en 280 train, 60 validation et 60 test : 1400/300/300 messages. Les cinq situations couvrent réception par le transporteur, transfert entre hubs, livraison confirmée au destinataire, arrivée avec identifiant et heure absents, et correction d’un brouillon non confirmé. Une arrivée au hub n’est pas une livraison au destinataire.

Les réponses possèdent exactement cinq champs : parcel, event, location, time et delivered. Les champs parcel/location/time peuvent valoir null ; delivered est un booléen. Quatre événements sont admis : received, transferred, arrived_at_hub et delivered. Les données partagent huit hubs et des patrons de phrase entre partitions. Les identités et familles restent disjointes ; cette régularité ne représente pas la diversité de documents logistiques réels. Dans les 300 cas test, 60 indiquent une livraison et 240 ne l’indiquent pas ; chacune des trois informations nullable manque dans 60 cas.

Aligner les coordonnées avant de comparer les distributions

Le professeur Qwen2.5-1.5B-Instruct et l’étudiant Qwen2.5-0.5B-Instruct produisent chacun 151936 logits. Une dimension égale ne prouve pas que la coordonnée 42 signifie le même token. Le contrôle compare le dictionnaire entier token→ID, les tokens ajoutés et spéciaux, leurs IDs, le template de conversation, la configuration BPE sérialisée et la configuration de génération.

Les tokenizers acquis doivent décrire exactement les mêmes 151665 IDs effectifs et les mêmes 271 coordonnées de sortie inutilisées. Ces coordonnées restent incluses dans la KL complète. On ne les supprime pas pour faire coïncider artificiellement deux distributions. Un désaccord de vocabulaire, template ou token spécial arrête le parcours sans remappage silencieux.

Une seule construction du prompt et de la réponse fournit input_ids et labels. Le contrôle encode réellement les 2000 exemples avec les deux tokenizers et exige la même séquence et le même hash. Prompt au plus 320 tokens, réponse avec EOS au plus 96, contexte 416. Un dépassement des cibles est refusé ; aucune troncature de gold n’est cachée. EOS vaut 151645 et PAD 151643. Le padding est à droite et son masque porte sur les positions.

Apprendre le professeur localement

Le professeur a 28 blocs, largeur 1536, 12 têtes de requêtes et deux têtes KV. Sa base BF16 reste gelée. Nous codons LoRA : W(x)+(alpha/r)B(Ax), rang 8, alpha 16, dropout nul, sur les sept projections q/k/v/o/gate/up/down de chacun des 28 blocs. Les 196 projections ajoutent 9 232 384 paramètres FP32. L’addition est calculée en FP32 puis retourne au dtype BF16 de la base. Des tests comparent valeurs et gradients à PEFT ; aucun trainer PEFT n’est appelé.

Le professeur apprend les 1400 réponses train pendant deux époques. Avec microbatch 1 et accumulation de huit exemples, cela donne 350 mises à jour AdamW à 1e−4. La validation suit la CE chaque époque ; le protocole retient l’état final fixé à l’avance, sans sélectionner un checkpoint d’après les résultats test. Le professeur devient ensuite eval et entièrement gelé pour la distillation. Les empreintes des poids et l’absence de gradients contrôlent cette immutabilité.

Teacher forcing et perte codée

Au token de réponse t, les deux modèles reçoivent le même prompt et les mêmes tokens gold précédents. C’est du teacher forcing hors politique : le professeur évalue ce préfixe supervisé, pas une réponse libre de l’étudiant. Aucun cache géant des logits du corpus et aucune approximation top-k ne sont utilisés.

Avec z les logits du professeur et u ceux de l’étudiant, p_T=softmax(z/T), q_T=softmax(u/T). La divergence orientée est KL(p_T || q_T)=somme_v p_T(v)[log p_T(v)−log q_T(v)]. Inverser les arguments donne une autre perte. Nous utilisons log_softmax stable et toutes les 151936 coordonnées. Le professeur est calculé sous no_grad par microbatch ; la divergence transmet un gradient uniquement à l’étudiant.

La perte vaut (1−alpha) CE(q_1, cible) + alpha T² KL(p_T || q_T). La CE utilise les logits de l’étudiant à température 1. Le facteur T² compense l’échelle du signal de distillation ; il ne garantit ni une meilleure prédiction ni une identité parfaite des gradients pour toute température. Les tests comparent la formule et ses gradients à torch.nn.functional.kl_div sur distributions FP64 asymétriques, vérifient le sens, la nullité pour deux distributions identiques, les limites alpha=0/1 et l’exclusion du graphe professeur.

On décale logits et labels pour prédire le token suivant, puis on sélectionne seulement les tokens de réponse, EOS inclus. Prompt et PAD sont exclus. Chaque accumulation divise par le nombre total de tokens actifs des huit exemples. Moyenner huit pertes déjà moyennées donnerait le même poids à une réponse courte et à une longue. À alpha=0, le chemin est exactement la CE et ne charge pas le professeur.

Deux étudiants et une reprise exacte

L’étudiant a 24 blocs, largeur 896, 14 têtes de requêtes, deux têtes KV et 494 032 768 paramètres uniques. Tous sont entraînés en FP32 ; embeddings et projection de sortie partagent le même Parameter. Le témoin SFT alpha=0 et la distillation alpha=0,5/T=2 repartent des mêmes poids publics, de la même graine et du même ordre train. Chacun effectue deux époques, 350 mises à jour à 2e−5.

AdamW utilise weight_decay=0,01, clipping du gradient à 1, warm-up de 35 updates puis décroissance linéaire jusqu’à 10 % du taux initial. Le mode eval conserve les gradients et désactive les sources de dropout ; il n’est pas synonyme de no_grad. Le témoin SFT n’appelle aucun professeur. Les coûts de forward du professeur en KD, calcul/backward du modèle optimisé, optimisation et validation sont conservés séparément.

Chaque branche compare une interruption après une époque, deux époques continues et la continuation de l’époque restante. Les poids, états Adam, scheduler, permutation, RNG et historiques numériques doivent être exactement égaux. Les durées restent hors comparaison d’état. Les états locaux sont compressés sans perte, niveau gzip 1 ; les poids finaux sont également sauvegardés et rechargés en Safetensors. Le total avec exercice conserve 15 états de reprise et cinq poids finaux. Ces pickles Torch sont produits localement ; ne pas les remplacer par des fichiers tiers.

Générer librement et mesurer le périmètre réel

Professeur adapté, étudiant initial, SFT et KD génèrent chacun 300 réponses test, sans préfixe gold. Les deux variantes de l’exercice font de même : 1800 réponses test, plus les warm-ups identifiés séparément. La génération est gloutonne sur tout le vocabulaire, sans cache KV, sans schéma forcé, avec plafond de 96 nouveaux tokens. Les IDs, textes bruts, EOS ou coupure, log-probabilités, durées et deux sondes de logits complets sont conservés.

Le service étudiant n’a physiquement aucun montage du professeur ou de ses adaptateurs. Le rechargement de l’étudiant utilise sa configuration, son tokenizer et ses seuls poids appris. L’inférence ne monte aucun label. L’évaluation rassemble ensuite les sorties et les cibles, sans modifier les poids.

Le parseur distingue JSON parsable, schéma strict et réponse complète : une sortie coupée échoue même si elle contient un objet bien formé. Clés dupliquées, NaN, champs supplémentaires, Markdown et IDs inconnus ne sont pas réparés. Les erreurs restent au dénominateur des 300 cas pour exact match et chaque champ. Les taux de fausse livraison ou de champ rempli à tort portent sur les réponses complètes valides et indiquent aussi les populations éligibles totales et la couverture. Une sortie invalide n’est pas certifiée sûre.

Chaque modèle reçoit le même message train de warm-up, hors test. On rapporte latence par requête et par token, longueurs avec EOS, médiane et p95 empirique (rang 285 sur 300). Les deux premières requêtes incluent le coût des sondes de logits, signalé dans le rapport. Le chargement et ses vérifications d’intégrité restent distincts. Paramètres uniques, dtypes, octets logiques et taille réelle des fichiers servis sont mesurés. Le professeur BF16 avec adaptateurs FP32 et l’étudiant entièrement FP32 diffèrent aussi de précision : un rapport de paramètres n’est pas un rapport garanti de mémoire ou de vitesse.

Exercice : déplacer alpha et T

Le SFT principal est le véritable comparateur alpha=0. Deux étudiants supplémentaires repartent de la même base : alpha=1/T=2 et alpha=1/T=4, chacun 350 updates et sa propre continuation exacte. Aucun checkpoint n’est simplement renommé. Les données, le professeur et les résultats principaux sont protégés par empreintes ; le professeur n’est pas réentraîné pour l’exercice.

Alpha=1 imite exclusivement le professeur sur les préfixes gold, y compris ses erreurs. Augmenter T redistribue la masse et les gradients ; cela ne révèle pas la vérité du champ delivered. Comparer les mêmes 300 événements, erreurs et coûts. Aucune amélioration ni température optimale n’est imposée au programme.

Limites

Une graine, un corpus synthétique régulier et un petit ensemble de types ne constituent pas une évaluation métier. La baisse de KL sur des préfixes gold ne prouve pas une meilleure génération libre. Le professeur peut transférer ses erreurs. Les mesures locales sans charge concurrente contrôlée ne sont pas un engagement de performance de service. Le TP n’envoie aucun colis, message ni action extérieure.

Construire les événements et vérifier les actifs

Alignement des données et tokenizers

Erreur fréquente : Comparer seulement vocab_size ignore une permutation d'IDs : comparer les dictionnaires et les exemples encodés.

Invariant : Familles disjointes et rapport d'alignement complet ; arrêt clair en cas de mismatch.

Fichier src/distillation_generative/data.py

"""Original synthetic parcel events, grouped by full parcel/scenario family."""
import hashlib,json,random
from pathlib import Path
HUBS=['East','West','North','South','Central','Coastal','Valley','Airport']
FIELDS=['parcel','event','location','time','delivered']
EVENTS=['received','transferred','arrived_at_hub','delivered']


def generate_parcel_messages():
    order=list(range(400));random.Random(50).shuffle(order);split={family:('train' if i<280 else 'validation' if i<340 else 'test') for i,family in enumerate(order)};pages=[];labels=[]
    for family in range(400):
        parcel=f'P{family:04d}';location=HUBS[family%8];old_location=HUBS[(family+3)%8];minutes=(family*37+65)%(24*60);clock=f'{minutes//60:02d}:{minutes%60:02d}'
        for variant in range(5):
            identifier=f'PAR{family:03d}-M{variant}';target={'parcel':parcel,'event':None,'location':location,'time':clock,'delivered':False}
            if variant==0:
                message=f'Parcel {parcel} was received by the carrier at {location} at {clock}. It has not been delivered to the recipient.';target['event']='received'
            elif variant==1:
                message=f'Parcel {parcel} left {old_location} earlier. Its latest confirmed event is a transfer to {location} at {clock}. The recipient has not received it.';target['event']='transferred'
            elif variant==2:
                message=f'The recipient confirmed delivery of parcel {parcel} at {location} at {clock}. This is a completed delivery, not a hub arrival.';target.update(event='delivered',delivered=True)
            elif variant==3:
                message=f'A parcel reached hub {location}. Its parcel identifier and event time were not reported. The recipient has not received it. Internal case note {family:03d} is not a parcel identifier.';target.update(parcel=None,event='arrived_at_hub',time=None)
            else:
                message=f'An unverified draft said parcel {parcel} was delivered at {old_location}. Correction: parcel {parcel} only arrived at a hub at {clock}; the hub name was not reported. Delivery has not occurred.';target.update(event='arrived_at_hub',location=None)
            pages.append({'document_id':identifier,'family':f'PAR{family:03d}','split':split[family],'variant':variant,'statement':message,'content_sha256':hashlib.sha256(message.encode()).hexdigest()});labels.append({'document_id':identifier,'target':target,'response':json.dumps(target,ensure_ascii=False,separators=(',',':'))})
    return pages,labels


def audit_corpus(pages,labels):
    if len(pages)!=2000 or len(labels)!=2000 or len({r['document_id'] for r in pages})!=2000 or len({r['statement'] for r in pages})!=2000:raise ValueError('Expected2000unique original messages')
    families={};gold={r['document_id']:r for r in labels}
    for p in pages:
        families.setdefault(p['family'],[]).append(p)
        if p['content_sha256']!=hashlib.sha256(p['statement'].encode()).hexdigest() or set(gold[p['document_id']]['target'])!=set(FIELDS) or json.loads(gold[p['document_id']]['response'])!=gold[p['document_id']]['target']:raise ValueError('Corpus integrity changed')
    if len(families)!=400 or any(len(rows)!=5 or len({r['split'] for r in rows})!=1 for rows in families.values()):raise ValueError('Whole parcel family must stay in one split')
    counts={s:sum(p['split']==s for p in pages) for s in ['train','validation','test']}
    if counts!={'train':1400,'validation':300,'test':300}:raise ValueError('Frozen split sizes changed')
    return {'dataset':'ParcelEvents-v1','messages':2000,'families':400,'messages_by_split':counts,'families_by_split':{s:sum(rows[0]['split']==s for rows in families.values()) for s in counts},'shared_event_templates':True,'shared_hubs':HUBS,'real_parcels':False,'missing_field_counts':{field:sum(g['target'][field] is None for g in labels) for field in FIELDS},'delivery_labels':sum(g['target']['delivered'] for g in labels)}


def prepare(config):
    from .config import save_json,sha
    root=Path('data/generated')
    if config['seed']!=50:raise ValueError('Frozen synthetic seed50 required')
    if (root/'manifest.json').exists():raise ValueError('Preserve existing data; use a new extraction')
    pages,labels=generate_parcel_messages();audit=audit_corpus(pages,labels);gold={g['document_id']:g for g in labels}
    save_json(root/'input/pages.json',pages);save_json(root/'labels/gold.json',labels)
    for split in ['train','validation']:
        selected=[p for p in pages if p['split']==split];save_json(root/f'{split}/pages.json',selected);save_json(root/f'labels/{split}.json',[gold[p['document_id']] for p in selected])
    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

Former des préfixes vraiment identiques

Teacher forcing et masque réponse

Erreur fréquente : Un template ajouté deux fois côté enseignant décale les positions : hacher les tokens du batch commun.

Invariant : IDs, masques et décalages identiques pour teacher/student.

Fichier src/distillation_generative/collator.py

import hashlib,json
import torch
SYSTEM='Extract the latest confirmed parcel event from this fictional message. Return only one JSON object with keys parcel, event, location, time, delivered. Use null for missing parcel, location or time. Event is received, transferred, arrived_at_hub or delivered. Delivered is a boolean. Ignore unverified drafts and do not invent a delivery.'


def prompt_ids(tokenizer,statement):
    messages=[{'role':'system','content':SYSTEM},{'role':'user','content':statement}]
    return messages,tokenizer.apply_chat_template(messages,tokenize=True,add_generation_prompt=True)


def completion_row(tokenizer,statement,response,config,identifier):
    messages,prompt=prompt_ids(tokenizer,statement);target=tokenizer.encode(response,add_special_tokens=False)+[151645]
    if not prompt or len(prompt)>config['max_prompt_tokens'] or not 1<len(target)<=config['max_response_tokens_including_eos'] or len(prompt)+len(target)>config['context']:raise ValueError(identifier+': context budget exceeded; do not truncate gold')
    ids=prompt+target;labels=[-100]*len(prompt)+target
    return {'document_id':identifier,'messages':messages,'input_ids':ids,'labels':labels,'prompt_tokens':len(prompt),'response_tokens':len(target),'template_version':'parcel-events-v1','shared_tokens_sha256':hashlib.sha256(json.dumps({'input_ids':ids,'labels':labels},separators=(',',':')).encode()).hexdigest()}


def collate(rows,device='cuda',pad_to=None):
    if not rows:raise ValueError('Empty batch')
    width=max(len(r['input_ids']) for r in rows) if pad_to is None else pad_to;ids=torch.full((len(rows),width),151643,dtype=torch.long,device=device);mask=torch.zeros_like(ids,dtype=torch.bool);labels=torch.full_like(ids,-100)
    for i,row in enumerate(rows):
        n=len(row['input_ids'])
        if n>width:raise ValueError('Padding would truncate an example')
        ids[i,:n]=torch.tensor(row['input_ids'],device=device);mask[i,:n]=True;labels[i,:n]=torch.tensor(row['labels'],device=device)
    return ids,mask,labels

Adapter l'enseignant et mesurer sa qualité

Enseignant utile, non oracle

Erreur fréquente : Utiliser des labels test pour choisir le meilleur teacher invalide le transfert : sélection validation seulement.

Invariant : Adaptateurs réels entraînés, teacher gelé et résultats initiaux conservés.

Fichier src/distillation_generative/teacher.py

"""Local teacher training shares the explicit accumulation loop with students."""
from .train import train,verify_resume

def train_teacher_adapter(config,mode):
    return train(config,"teacher",mode)

Coder la KL et entraîner les deux étudiants

Cibles souples et gradient

Erreur fréquente : torch.kl_div pris dans le mauvais ordre inverse la divergence : test sur distributions asymétriques.

Invariant : KL analytique correcte, teacher immuable, deux étudiants réellement mis à jour et reprise possible.

Fichier src/distillation_generative/train.py

"""Explicit microbatch/accumulation training; no external trainer."""
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 load_base,load_tokenizer,reload_teacher,adapter_state,copy_teacher_adapter,adapter_identity,frozen_teacher_identity,model_identity,parameter_manifest,save_teacher,save_student
from .collator import completion_row,collate
from .loss import masked_distillation_terms
from .state import save_resume_state,load_resume_state,equal

VARIANTS={'teacher':(0.,2.),'sft':(0.,2.),'kd':(.5,2.),'alpha1-t2':(1.,2.),'alpha1-t4':(1.,4.)}


def learning_rate(step,total,peak):
    warm=max(1,int(total*.1))
    if step<warm:return peak*(step+1)/warm
    return peak*(1-min(1.,(step-warm)/max(1,total-warm-1))*.9)


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 expose heldout inputs/gold/inference: '+name)
    alignment=read_json('outputs/learning/alignment.json')
    if alignment['status']!='PASS' or alignment['encoded_examples_verified']!=2000 or not alignment['all_token_ids_identical']:raise ValueError('Actual complete tokenizer alignment required before training')


def encoded_rows(tokenizer,config,split):
    pages=read_json(f'data/generated/{split}/pages.json');gold={g['document_id']:g for g in read_json(f'data/generated/labels/{split}.json')}
    return [completion_row(tokenizer,p['statement'],gold[p['document_id']]['response'],config,p['document_id']) for p in pages]


def terms_for_row(model,teacher,row,alpha,temperature):
    ids,mask,labels=collate([row]);teacher_logits=None;teacher_seconds=0.
    if alpha>0:
        if teacher is None or teacher.training or any(p.requires_grad or p.grad is not None for p in teacher.parameters()):raise ValueError('Distillation teacher must be frozen and in eval mode')
        torch.cuda.synchronize();start=time.perf_counter()
        with torch.no_grad():teacher_logits=teacher(input_ids=ids,attention_mask=mask,use_cache=False).logits.float()
        torch.cuda.synchronize();teacher_seconds=time.perf_counter()-start
    student_logits=model(input_ids=ids,attention_mask=mask,use_cache=False).logits
    return masked_distillation_terms(student_logits,labels,teacher_logits,alpha,temperature),teacher_seconds


@torch.no_grad()
def validation_scores(model,teacher,rows,alpha,temperature):
    records=[]
    for row in rows:
        terms,_=terms_for_row(model,teacher,row,alpha,temperature);records.append({'document_id':row['document_id'],'tokens':terms['tokens'],'ce_sum':float(terms['ce_sum']),'kl_sum':float(terms['kl_sum']),'mixed_sum':float(terms['mixed_sum']),'token_logprobs':terms['student_token_logprobs'].cpu().tolist(),'token_kl':terms['teacher_token_kl'].cpu().tolist(),'target_ids':terms['targets'].cpu().tolist()})
    den=sum(r['tokens'] for r in records)
    return {'examples':len(rows),'tokens':den,'ce':sum(r['ce_sum'] for r in records)/den,'kl':sum(r['kl_sum'] for r in records)/den,'mixed':sum(r['mixed_sum'] for r in records)/den,'used_for_selection':False,'rows':records}


def train(config,branch,mode):
    if branch not in VARIANTS or mode not in ['interrupt','continuous','resume']:raise ValueError('Unknown training branch or continuation mode')
    assert_learning_scope();seed_all(50);alpha,temperature=VARIANTS[branch];role='teacher' if branch=='teacher' else 'student';tokenizer=load_tokenizer(role);rows=encoded_rows(tokenizer,config,'train');validation=encoded_rows(tokenizer,config,'validation')
    if len(rows)!=1400 or len(validation)!=300:raise ValueError('Frozen training populations changed')
    model=load_base(role);teacher=None;teacher_meta=None
    if alpha>0:teacher,teacher_meta=reload_teacher('assets/teacher-checkpoints/teacher-resume.safetensors',frozen=True)
    parameters=[p for p in model.parameters() if p.requires_grad];expected=9232384 if role=='teacher' else 494032768
    if sum(p.numel() for p in parameters)!=expected or len({id(p) for p in parameters})!=len(parameters):raise ValueError('Unexpected trainable population or duplicate tied parameter')
    initial_parameters=parameter_manifest(model);initial=model_identity(model);frozen_base=frozen_teacher_identity(model) if role=='teacher' else None;teacher_before=model_identity(teacher) if teacher is not None else None;peak=1e-4 if role=='teacher' else 2e-5;total=350
    identity={'branch':branch,'config':config,'alpha':alpha,'temperature':temperature,'initial_model_digest':initial,'teacher_checkpoint':None if teacher_meta is None else teacher_meta['sha256'],'teacher_digest':teacher_before,'alignment_sha256':sha('outputs/learning/alignment.json'),'data_sha256':{n:sha('data/generated/'+n) for n in ['train/pages.json','labels/train.json','validation/pages.json','labels/validation.json']},'model_sha256':sha('assets/'+role+'/model.safetensors'),'tokenizer_sha256':sha('assets/'+role+'/tokenizer.json'),'code_sha256':{n:sha('src/distillation_generative/'+n) for n in ['models.py','collator.py','loss.py','train.py','state.py','alignment.py']}}
    optimizer=torch.optim.AdamW(parameters,lr=peak,weight_decay=.01);sampler=torch.Generator().manual_seed(150);root=Path('assets')/('teacher-checkpoints' if role=='teacher' else 'student-checkpoints');destination=root/f'{branch}-{mode}.pt.gz';updates=first_epoch=0;history=[];validation_history=[]
    if destination.exists():raise ValueError('Preserve existing training checkpoint')
    if mode=='resume':
        state=load_resume_state(root/f'{branch}-interrupt.pt.gz')
        if state['identity']!=identity:raise ValueError('Resume teacher/student/data/objective identity changed')
        if role=='teacher':copy_teacher_adapter(model,state['model'])
        else: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'];first_epoch=state['epoch'];history=state['history'];validation_history=state['validation_history']
        if state['scheduler']!={'next_update':updates,'next_lr':learning_rate(updates,total,peak)}:raise ValueError('Resume scheduler changed')
        del state
    stop=1 if mode=='interrupt' else 2;phase={'teacher_forward_seconds':0.,'optimized_model_forward_loss_backward_seconds':0.,'optimizer_seconds':0.,'validation_seconds':0.};torch.cuda.reset_peak_memory_stats();torch.cuda.synchronize();start_run=time.perf_counter()
    for epoch in range(first_epoch,stop):
        order=torch.randperm(1400,generator=sampler).tolist()
        for offset in range(0,1400,8):
            batch=[rows[i] for i in order[offset:offset+8]];den=sum(r['response_tokens'] for r in batch);optimizer.zero_grad(set_to_none=True);lr=learning_rate(updates,total,peak)
            for group in optimizer.param_groups:group['lr']=lr
            trace=[];loss_total=0.
            for row in batch:
                torch.cuda.synchronize();started=time.perf_counter();terms,teacher_seconds=terms_for_row(model,teacher,row,alpha,temperature);loss=terms['mixed_sum']/den;loss.backward();torch.cuda.synchronize();elapsed=time.perf_counter()-started;phase['teacher_forward_seconds']+=teacher_seconds;phase['optimized_model_forward_loss_backward_seconds']+=elapsed-teacher_seconds;probe=None
                if updates==0 and len(trace)<2:
                    probe={'target':int(terms['targets'][0]),'student_logits':terms['student_selected_logits'][0].detach().cpu().tolist(),'teacher_logits':None if terms['teacher_selected_logits'] is None else terms['teacher_selected_logits'][0].detach().cpu().tolist()}
                trace.append({'document_id':row['document_id'],'shared_tokens_sha256':row['shared_tokens_sha256'],'tokens':terms['tokens'],'target_ids':terms['targets'].detach().cpu().tolist(),'token_logprobs':terms['student_token_logprobs'].detach().cpu().tolist(),'token_kl':terms['teacher_token_kl'].detach().cpu().tolist(),'ce_sum':float(terms['ce_sum'].detach()),'kl_sum':float(terms['kl_sum'].detach()),'mixed_sum':float(terms['mixed_sum'].detach()),'first_token_probe':probe});loss_total+=float(loss.detach());del terms,loss
            if any(p.grad is None or not torch.isfinite(p.grad).all() for p in parameters):raise ValueError('Missing/nonfinite trainable gradients')
            if teacher is not None and any(p.grad is not None or p.requires_grad for p in teacher.parameters()):raise ValueError('Teacher received a student gradient')
            started=time.perf_counter();norm=float(torch.nn.utils.clip_grad_norm_(parameters,1.,error_if_nonfinite=True));optimizer.step();torch.cuda.synchronize();phase['optimizer_seconds']+=time.perf_counter()-started;updates+=1;history.append({'epoch':epoch+1,'update':updates,'lr':lr,'response_token_denominator':den,'rows':trace,'loss':loss_total,'unclipped_gradient_norm':norm})
        started=time.perf_counter();validation_history.append({'epoch':epoch+1,**validation_scores(model,teacher,validation,alpha,temperature)});torch.cuda.synchronize();phase['validation_seconds']+=time.perf_counter()-started
    torch.cuda.synchronize();seconds=time.perf_counter()-start_run;final_parameters=parameter_manifest(model);final=model_identity(model)
    if final==initial or not any(h['unclipped_gradient_norm']>0 for h in history):raise ValueError('No actual training update')
    if role=='teacher' and frozen_teacher_identity(model)!=frozen_base:raise ValueError('Teacher base changed during LoRA')
    if teacher is not None and model_identity(teacher)!=teacher_before:raise ValueError('Distillation teacher changed')
    saved=adapter_state(model) if role=='teacher' else model.state_dict();state={'identity':identity,'model':{n:t.detach().cpu().clone() for n,t in saved.items()},'optimizer':optimizer.state_dict(),'sampler':sampler.get_state(),'rng':rng_state(),'epoch':stop,'updates':updates,'history':history,'validation_history':validation_history,'scheduler':{'next_update':updates,'next_lr':learning_rate(updates,total,peak)}};storage=save_resume_state(destination,state)
    result={'branch':branch,'mode':mode,'identity':identity,'epochs':stop,'updates':updates,'initial_parameters':initial_parameters,'final_parameters':final_parameters,'initial_model_digest':initial,'final_model_digest':final,'trainable_parameters':expected,'history':history,'validation_history':validation_history,'teacher_unchanged':True,'teacher_gradients':0,'teacher_forward_calls':(stop-first_epoch)*1400 if alpha>0 else 0,'validation_teacher_forward_calls':(stop-first_epoch)*300 if alpha>0 else 0,'frozen_base_unchanged':True if role=='teacher' else None,'seconds':seconds,'phase_seconds':phase,'peak_allocated_bytes':torch.cuda.max_memory_allocated(),'peak_reserved_bytes':torch.cuda.max_memory_reserved(),'checkpoint_storage':storage,'checkpoint_sha256':sha(destination),'training_test_visible':False};save_json(f'outputs/learning/{branch}/{mode}.json',result)
    if mode=='resume':
        saver=save_teacher if role=='teacher' else save_student;saver(model,root/f'{branch}-resume.safetensors',identity)
    return {'branch':branch,'mode':mode,'epochs':stop,'updates':updates,'seconds':seconds}


def verify_resume(branch):
    if branch not in VARIANTS:raise ValueError('Unknown variant')
    root=Path('assets')/('teacher-checkpoints' if branch=='teacher' else 'student-checkpoints');a=root/f'{branch}-continuous.pt.gz';b=root/f'{branch}-resume.pt.gz';continuous=load_resume_state(a);resumed=load_resume_state(b)
    if not equal(continuous,resumed):raise ValueError('Exact model/teacher/optimizer/sampler/RNG continuation failed')
    result={'status':'PASS','branch':branch,'all_state_exact':True,'updates':resumed['updates'],'continuous_sha256':sha(a),'resume_sha256':sha(b)};save_json(f'outputs/learning/{branch}/resume-check.json',result);return result

Servir l'étudiant sans dépendre de l'enseignant

Qualité libre et coût d'inférence

Erreur fréquente : Une génération plus courte semble plus rapide : publier longueurs et coût par requête et token.

Invariant : Aucune requête teacher pendant l'inférence étudiant ; 300 IDs par branche.

Fichier src/distillation_generative/evaluate.py

import json,time,statistics
from pathlib import Path
import torch
from .config import read_json,save_json,sha
from .models import load_base,load_tokenizer,reload_teacher,reload_student,model_identity,parameter_manifest
from .collator import prompt_ids
from .event_schema import FIELDS,parse_event,score_event


@torch.no_grad()
def generate_one(model,tokenizer,page,config,probe=False):
    started=time.perf_counter();messages,prompt=prompt_ids(tokenizer,page['statement']);tokenization_seconds=time.perf_counter()-started
    if not prompt or len(prompt)>config['max_prompt_tokens'] or len(prompt)+config['max_new_tokens']>config['context']:raise ValueError('Prompt exceeds frozen generation budget')
    ids=torch.tensor([prompt],device='cuda',dtype=torch.long);generated=[];selected=[];first=None;torch.cuda.synchronize();start=time.perf_counter();first_token_seconds=None
    for step in range(config['max_new_tokens']):
        logits=model(input_ids=ids,attention_mask=torch.ones_like(ids),use_cache=False).logits[0,-1].float();token=int(logits.argmax());logprob=float(torch.log_softmax(logits,-1)[token])
        if not torch.isfinite(logits).all() or not torch.isfinite(torch.tensor(logprob)):raise ValueError('Nonfinite generated distribution')
        if step==0:
            torch.cuda.synchronize();first_token_seconds=time.perf_counter()-start
            if probe:first={'prefix_ids':prompt,'target':token,'logits':logits.cpu().tolist(),'selected_logprob':logprob}
        generated.append(token);selected.append(logprob);ids=torch.cat([ids,torch.tensor([[token]],device='cuda')],dim=1)
        if token==151645:break
    torch.cuda.synchronize();generation_seconds=time.perf_counter()-start;finish='eos' if generated[-1]==151645 else 'length';decode_start=time.perf_counter();text=tokenizer.decode(generated[:-1] if finish=='eos' else generated,skip_special_tokens=False,clean_up_tokenization_spaces=False);decode_seconds=time.perf_counter()-decode_start
    return {'document_id':page['document_id'],'messages':messages,'prompt_ids':prompt,'generated_ids':generated,'generated_tokens':len(generated),'token_logprobs':selected,'text':text,'tokenization_valid':all(tokenizer.convert_ids_to_tokens(t) is not None for t in generated),'finish_reason':finish,'mode':'greedy','max_new_tokens':96,'first_token_probe':first,'tokenization_seconds':tokenization_seconds,'generation_seconds':generation_seconds,'first_token_seconds':first_token_seconds,'decode_seconds':decode_seconds,'seconds_per_generated_token':generation_seconds/len(generated),'kv_cache':False}


@torch.no_grad()
def generate_events(config,branch):
    allowed=['initial','teacher','sft','kd','alpha1-t2','alpha1-t4']
    if branch not in allowed:raise ValueError('Unknown serving branch')
    if Path('data/generated/labels').exists() or Path('outputs/learning').exists():raise ValueError('Serving must not mount labels or learning outputs')
    role='teacher' if branch=='teacher' else 'student'
    if role=='student' and (Path('assets/teacher').exists() or Path('assets/teacher-checkpoints').exists()):raise ValueError('Student serving must be physically independent of the teacher')
    pages=read_json('data/generated/input/pages.json');test=[p for p in pages if p['split']=='test'];warmup_page=next(p for p in pages if p['split']=='train')
    if len(test)!=300 or len({p['document_id'] for p in test})!=300:raise ValueError('Exactly300reserved messages required')
    started=time.perf_counter();tokenizer=load_tokenizer(role);tokenizer_load_seconds=time.perf_counter()-started;started=time.perf_counter()
    if branch=='teacher':model,metadata=reload_teacher('assets/teacher-checkpoints/teacher-resume.safetensors');served=['assets/teacher/model.safetensors','assets/teacher-checkpoints/teacher-resume.safetensors']
    elif branch=='initial':model=load_base('student');metadata={'sha256':sha('assets/student/model.safetensors')};served=['assets/student/model.safetensors']
    else:model,metadata=reload_student(f'assets/student-checkpoints/{branch}-resume.safetensors');served=[f'assets/student-checkpoints/{branch}-resume.safetensors']
    model.eval().requires_grad_(False);torch.cuda.synchronize();load_seconds=time.perf_counter()-started;identity=model_identity(model);manifest=parameter_manifest(model);parameters=sum(p.numel() for p in model.parameters());logical_bytes=sum(p.numel()*p.element_size() for p in model.parameters());files={name:{'sha256':sha(name),'size_bytes':Path(name).stat().st_size} for name in served}
    # Same fixed train statement warms each model; it is outside all300test denominators.
    warmup=generate_one(model,tokenizer,warmup_page,config);torch.cuda.reset_peak_memory_stats();torch.cuda.synchronize();started=time.perf_counter();rows=[]
    for index,page in enumerate(test):rows.append({'page':page,'generation':generate_one(model,tokenizer,page,config,probe=index<2)})
    torch.cuda.synchronize();seconds=time.perf_counter()-started
    if model_identity(model)!=identity:raise ValueError('Inference changed served weights')
    loops=[r['generation']['generation_seconds'] for r in rows];lengths=[r['generation']['generated_tokens'] for r in rows];result={'branch':branch,'rows':rows,'cases':300,'labels_visible':False,'teacher_visible':role=='teacher','teacher_calls_during_student_serving':0,'checkpoint_sha256':metadata['sha256'],'model_digest':identity,'parameters':parameters,'logical_parameter_bytes':logical_bytes,'parameter_dtypes':sorted({r['dtype'] for r in manifest}),'served_weight_files':files,'served_weight_file_bytes':sum(v['size_bytes'] for v in files.values()),'tokenizer_load_seconds':tokenizer_load_seconds,'model_load_seconds_including_integrity_checks':load_seconds,'warmup':warmup,'test_seconds':seconds,'latency':{'mean_request_generation_seconds':sum(loops)/300,'median_request_generation_seconds':statistics.median(loops),'p95_request_generation_seconds':sorted(loops)[284],'total_generated_tokens_including_eos':sum(lengths),'mean_generated_tokens_including_eos':sum(lengths)/300,'weighted_seconds_per_generated_token':sum(loops)/sum(lengths),'tokenization_seconds':sum(r['generation']['tokenization_seconds'] for r in rows),'decode_seconds':sum(r['generation']['decode_seconds'] for r in rows),'first_two_requests_include_full_logit_probe_copy':True},'peak_allocated_bytes':torch.cuda.max_memory_allocated(),'peak_reserved_bytes':torch.cuda.max_memory_reserved(),'same_no_kv_cache_greedy_algorithm':True};save_json(f'outputs/inference/{branch}.json',result);return {k:v for k,v in result.items() if k not in ['rows','warmup','served_weight_files']}


def summarize(rows):
    n=len(rows);valid=[r for r in rows if r['parsed']['complete_valid']];not_delivered=[r for r in rows if r['score']['false_delivery_eligible']];valid_not_delivered=[r for r in not_delivered if r['parsed']['complete_valid']];missing=sum(r['score']['missing_fields'] for r in rows);valid_missing=sum(r['score']['missing_fields'] for r in valid);filled=sum(r['score']['filled_when_absent'] for r in rows);false=sum(r['score']['false_delivery'] for r in rows)
    return {'cases':n,'json_parsable':sum(r['parsed']['json_parsable'] for r in rows),'schema_valid_before_eos_check':sum(r['parsed']['schema_valid'] for r in rows),'complete_valid':len(valid),'complete_valid_rate':len(valid)/n,'exact_match':sum(r['score']['exact_match'] for r in rows),'exact_match_rate':sum(r['score']['exact_match'] for r in rows)/n,'per_field_correct':{f:sum(r['score']['fields_correct'][f] for r in rows) for f in FIELDS},'per_field_denominator':n,'false_delivery':false,'not_delivered_reference_cases':len(not_delivered),'valid_not_delivered_reference_cases':len(valid_not_delivered),'false_delivery_rate_all_eligible':false/len(not_delivered) if not_delivered else None,'false_delivery_rate_valid_eligible':false/len(valid_not_delivered) if valid_not_delivered else None,'filled_when_absent':filled,'missing_reference_fields':missing,'missing_reference_fields_in_valid_outputs':valid_missing,'filled_when_absent_rate_valid_fields':filled/valid_missing if valid_missing else None,'without_eos':sum(r['finish_reason']!='eos' for r in rows),'undecodable_token':sum(not r['tokenization_valid'] for r in rows),'invalid_outputs_not_certified':n-len(valid)}


def evaluate(config,exercise=False):
    gold={g['document_id']:g for g in read_json('data/generated/labels/gold.json')};branches=['alpha1-t2','alpha1-t4'] if exercise else ['initial','teacher','sft','kd'];result={}
    for branch in branches:
        raw=read_json(f'outputs/inference/{branch}.json');rows=[]
        for item in raw['rows']:
            page=item['page'];g=item['generation'];target=gold[page['document_id']]['target'];parsed=parse_event(g['text'],g['finish_reason'],g['tokenization_valid']);score=score_event(parsed,target);rows.append({'document_id':page['document_id'],'target':target,'text':g['text'],'finish_reason':g['finish_reason'],'tokenization_valid':g['tokenization_valid'],'parsed':parsed,'score':score})
        result[branch]={'metrics':summarize(rows),'rows':rows,'raw_sha256':sha(f'outputs/inference/{branch}.json'),'costs':{k:raw[k] for k in ['parameters','logical_parameter_bytes','served_weight_file_bytes','parameter_dtypes','tokenizer_load_seconds','model_load_seconds_including_integrity_checks','test_seconds','latency','peak_allocated_bytes','peak_reserved_bytes']},'warmup_document_id':raw['warmup']['document_id'],'warmup_generation_seconds':raw['warmup']['generation_seconds']}
    output={'policies':result,'scope':'Original fictitious messages, fixed300testcases; malformed/truncated/undecodable outputs remain in all-case denominators. False-delivery/absent-field rates on valid outputs are conditional and report coverage. FP32student versusBF16teacher+FP32LoRA is not a pure model-size latency experiment. No teacher calls during student serving.'};save_json('outputs/exercise/results.json' if exercise else 'outputs/results.json',output);return {b:v['metrics'] for b,v in result.items()}

Explorer la pondération du transfert

Rôle de alpha et T

Erreur fréquente : Réutiliser le checkpoint SFT en le renommant alpha=1 n'est pas une distillation : exiger la trace des mises à jour.

Invariant : alpha=0 concorde avec SFT et chaque variante a sa propre identité.

Fichier src/distillation_generative/exercise.py

from pathlib import Path
from .config import read_json,save_json,sha


def begin(config):
    path=Path('outputs/exercise/protected-main.json')
    if path.exists():raise ValueError('Preserve an existing exercise')
    files=[Path('configs/minimum.yaml'),Path('outputs/results.json'),Path('outputs/tokenizer-alignment.json')]+list(Path('outputs/inference').glob('*.json'))+list(Path('assets/teacher-checkpoints').glob('*.safetensors'))+list(Path('assets/student-checkpoints').glob('*.safetensors'))
    identities={str(p):sha(p) for p in files};save_json(path,identities);return {'protected_files':len(identities),'exercise':'Freshalpha1T2andalpha1T4; actual mainSFT isalpha0 comparator, no renamed weights'}


def finish(config):
    protected=read_json('outputs/exercise/protected-main.json')
    if any(sha(path)!=value for path,value in protected.items()):raise ValueError('Exercise changed main artifacts')
    main=read_json('outputs/results.json')['policies'];extra=read_json('outputs/exercise/results.json')['policies'];variants={n:{'metrics':v['metrics'],'costs':v['costs']} for n,v in {**main,**extra}.items() if n in ['sft','kd','alpha1-t2','alpha1-t4']};budgets={}
    for branch in variants:
        run=read_json(f'outputs/learning/{branch}/continuous.json');budgets[branch]={'updates':run['updates'],'alpha':run['identity']['alpha'],'temperature':run['identity']['temperature'],'seconds':run['seconds'],'teacher_forward_seconds':run['phase_seconds']['teacher_forward_seconds'],'final_model_digest':run['final_model_digest']}
        if run['updates']!=350 or run['identity']['initial_model_digest']!=read_json('outputs/learning/sft/continuous.json')['identity']['initial_model_digest']:raise ValueError('Exercise must start from same student and fixed350updates')
    if set(variants)!={'sft','kd','alpha1-t2','alpha1-t4'} or len({b['final_model_digest'] for b in budgets.values()})!=4:raise ValueError('Distinct actually learned branch states required')
    result={'status':'PASS','variants':variants,'budgets':budgets,'main_unchanged':True,'protected_sha256':protected,'alpha0_is_actual_main_sft':True,'fresh_exercise_training':True,'teacher_refit':False,'test_selection':False,'quality_gain_required':False};save_json('outputs/exercise/distillation-mixture.json',result);return {k:v for k,v in result.items() if k not in ['protected_sha256','variants']}

Sources et périmètre de référence

Hinton et al., Distilling the Knowledge in a Neural Network motive température et facteur T². PyTorch 2.8 kl_div fournit la référence de formule utilisée par les tests. TRL GKD 0.23.1 décrit notamment des préfixes générés par l’étudiant : notre exercice emploie les préfixes gold hors politique et ne revendique pas l’équivalence de ce trainer. Les modèles Qwen et les dépendances ont leurs révisions et hashes dans les locks publics.

Les résultats observés, leur portée et l'expérience proposée.

Partager cet article