Skip to content

Fine-tuning heads and the linear-head export

ModuleL6.5 · build · Python · Pass 5 · 4 to 5 h, plus your graded tests (rung R5)
You buildpython/tinyllm/obj/heads.py: hidden_states, pool, SequenceClassifier, TokenClassifier, RewardHead, pairwise_reward_loss, lora_classifier, train_classifier, predict, save_classifier, load_classifier, and the policy head: fit_linear_head, head_probs, head_metrics, export_linear_head, load_linear_head; and your own oracle tests in python/tests/l6-5-heads/
Contractcourse/contracts/py/tinyllm/obj/heads.pyi
Testscourse/tests/L6.5/test_heads.py (what they check: section 4); one learning test against the reference’s calibrated bar; your tests are graded by mutation, threshold 0.80 with every required pitfall fault killed
NeedsL6.1 GPT · L6.2 BertEncoder · L6.6 inject_lora · M07.7 IRLS, ROC-AUC, ECE · M10.3 AdamW · L0.1 · L0.2 · L0.3 · L0.4 · L0.6 · M06.3 (or --ref-deps)
Used byL6.7 the zoo’s classifier rows · later: ethics.05 the usage-policy head, evaluated in Go by gw.08; L12.3 reward model (optional)
MilestoneMS-L6 (finetune classify --lora r=8: accuracy at the calibrated bar, trainable_frac < 0.05)
Optional depthDevlin et al., BERT (2019), section 4; Ouyang et al., “Training language models to follow instructions with human feedback” (2022), section 3.5 (the reward model); Bradley and Terry, “Rank Analysis of Incomplete Block Designs” (1952); scikit-learn’s LogisticRegression docs
  • A head is small: pool one vector per sequence (cls, mean over real tokens, or the last real token) and apply one Linear (test_hand_example_pooling_and_loss, test_last_pool_reads_the_last_real_token).
  • Padding must be invisible: the same row with or without padding gets the same logits (test_bert_classifier_does_not_see_padding).
  • A reward head is trained on pairs: the loss is −log⁡σ(rchosen−rrejected)-\log\sigma(r_{\text{chosen}} - r_{\text{rejected}}) (test_hand_example_reward_loss, test_reward_head_ranks_by_the_margin).
  • LoRA fine-tuning trains the adapters and the new head (test_lora_classifier_trains_adapters_and_the_head).
  • The policy head is M07.7’s logistic regression fitted on unit embeddings and exported as JSON that Go scores identically (test_fit_linear_head_is_irls_on_unit_vectors, test_python_scores_equal_the_shared_fixture, test_export_validates_against_the_schema).
Terminal window
ol start L6.5 # stubs heads.py; prints your test path and rung (R5)
ol tests L6.5 # the course tests
# write your oracle tests in python/tests/l6-5-heads/, then:
ol check L6.5 # course tests, the learning test, and the mutation grade of your tests
ol mutate L6.5 # the full grade, cached by your test files' hash
ol diff L6.5 # after passing: your code against the reference

Your backbones (L6.1 GPT, L6.2 BERT, L6.3 ELECTRA’s discriminator) turn tokens into vectors, but nothing in the system asks them a question yet. Three call sites need a head. The model zoo (L6.7) compares backbones by how well they classify the same sentences. The gateway’s usage policy (gw.08, Pass 10) must refuse some prompts, and Python never serves HTTP (D9), so the classifier has to be something Go can evaluate: a linear head over the engine’s own /v1/embeddings (D33). And post-training (L12.3, optional) needs a reward model. All three are a pooled vector and one Linear; what goes wrong is the plumbing around it: padding, pooling, which parameters train, and numbers that must survive a trip through JSON into another language.

SymbolMeaningType / shape
hhthe backbone’s hidden statesfloat32[B, T, d]
mmthe real-token mask (attn_mask), right paddingbool[B, T]
vbv_bthe pooled vector of row bbfloat32[d]
Wc,bcW_c, b_cthe head’s Linear (classifier)[C, d], [C]
rra reward, one per sequencefloat32[B]
eean embedding from /v1/embeddingsfloat64[d]
u=e/∥e∥u = e / \lVert e \rVertthe unit embedding the policy head reads (u=0u = 0 when e=0e = 0)float64[d]
w,cw, cthe logistic regression’s weights and intercept (M07.7)[d], scalar
σ(z)=1/(1+e−z)\sigma(z) = 1 / (1 + e^{-z})the logistic function

Every head starts from hh: an encoder is called as backbone(ids, token_type_ids, attn_mask) (L6.2), a decoder through backbone.hidden(ids) (L6.1; causal attention never reads the right padding, so it needs no mask). One vector per row:

poolvbv_bwhen
clshb,0h_{b,0}BERT and ELECTRA: position 0 is [CLS], which attends to everything
mean∑tmbthbt/∑tmbt\sum_t m_{bt} h_{bt} / \sum_t m_{bt}any encoder; robust for short fine-tunes
lasthb,t∗h_{b, t^*} with t∗t^* the last real positiondecoders: only the last token has read the whole row

The mean is over real tokens in both the sum and the count; last reads the last real token, not position T−1T - 1, which for a short row is padding. A SequenceClassifier is then classifier(dropout(pool(h))) and its loss L0.3’s cross-entropy against one label per row.

A TokenClassifier applies the Linear at every position: logits [B, T, C], and a loss over the real positions whose label is not -100 (padding never counts, whatever label the batch gives it). A RewardHead maps the last real token to a scalar, r=w⊤hb,t∗r = w^\top h_{b,t^*} (score, no bias: a constant shift would cancel in every comparison). It learns from preference pairs with the Bradley-Terry model, P(chosen≻rejected)=σ(rc−rr)P(\text{chosen} \succ \text{rejected}) = \sigma(r_c - r_r), whose negative log-likelihood is

L=−log⁡σ(rc−rr)=softplus(−(rc−rr)),L = -\log \sigma(r_c - r_r) = \mathrm{softplus}(-(r_c - r_r)),

computed as L0.3’s bce_with_logits of the margin against 1. Only the margin matters.

lora_classifier calls L6.6’s inject_lora on the classifier, which freezes everything and adapts the backbone’s attention queries and values (the Linears named ...q_proj and ...v_proj). Then it makes the head trainable again: the head is new, there is no pretrained value to protect, and a frozen random head cannot learn anything. This is PEFT’s modules_to_save. train_classifier runs AdamW (M10.3) over the parameters that require grad, with batches drawn with replacement by rng.below(n). Before saving, merge the adapters (L6.6), so the directory is a plain classifier that load_classifier rebuilds from tl_backbone (a BertConfig or GPTConfig as a dict) and tl_head.

The gateway sees a prompt, asks the engine for its embedding ee, and must decide “allow” or “refuse” with nothing but arithmetic. The head is

p=softmax(Wu+b),u=e/∥e∥,p = \mathrm{softmax}(W u + b), \qquad u = e / \lVert e \rVert,

stored as formats/linear-head.schema.json. Normalizing makes the score independent of the embedding’s length, which varies with the prompt; the head must be fitted on unit vectors too, or it learns to read length. fit_linear_head fits M07.7’s logistic_regression_fit (IRLS: Newton steps, each a weighted least-squares solve) on the unit rows, giving ww and an intercept cc, and stores two softmax rows with class 0 as the reference:

W=(0w⊤),b=(0,c),p1=ew⊤u+c1+ew⊤u+c=σ(w⊤u+c),W = \begin{pmatrix} 0 \\ w^\top \end{pmatrix}, \quad b = (0, c), \quad p_1 = \frac{e^{w^\top u + c}}{1 + e^{w^\top u + c}} = \sigma(w^\top u + c),

exactly the fitted probability. The decision is p1≥p_1 \ge threshold. head_metrics records accuracy, precision, and recall at that threshold, plus M07.7’s threshold-free ROC-AUC and expected calibration error. The export writes every number as the shortest decimal that reads back as the same float64, so Go’s strconv.ParseFloat gets the same weights, and course/fixtures/L6.5/linear_head.json holds probe embeddings and the probabilities both languages must produce within 1e-6.

Pooling. One row of hidden states (1,2)(1, 2), (3,4)(3, 4), (5,6)(5, 6) with the third position padding (m=(1,1,0)m = (1, 1, 0)): cls =(1,2)= (1, 2); mean =((1+3)/2,(2+4)/2)=(2,3)= ((1 + 3)/2, (2 + 4)/2) = (2, 3); last =(3,4)= (3, 4), the last real token, not (5,6)(5, 6). With an identity classifier and no bias, the mean-pooled logits are (2,3)(2, 3); for label 1 the cross-entropy is −log⁡e3e2+e3=log⁡(1+e−1)=0.313262-\log \frac{e^3}{e^2 + e^3} = \log(1 + e^{-1}) = 0.313262.

Policy head. e=(3,4)e = (3, 4), so ∥e∥=5\lVert e \rVert = 5 and u=(0.6,0.8)u = (0.6, 0.8). With W=(0012)W = \begin{pmatrix} 0 & 0 \\ 1 & 2 \end{pmatrix} and b=(0,−1)b = (0, -1): logits (0, 0.6+1.6−1)=(0,1.2)(0,\ 0.6 + 1.6 - 1) = (0, 1.2) and punsafe=σ(1.2)=0.768525p_{\text{unsafe}} = \sigma(1.2) = 0.768525. The embeddings (0.03,0.04)(0.03, 0.04) and (300,400)(300, 400) have the same uu, so the same score. The zero vector stays u=0u = 0 and scores softmax(b)=(0.731059,0.268941)\mathrm{softmax}(b) = (0.731059, 0.268941).

Reward pair. rc=2r_c = 2, rr=0r_r = 0: L=log⁡(1+e−2)=0.126928L = \log(1 + e^{-2}) = 0.126928; (5,3)(5, 3) gives the same loss.

These are test_hand_example_pooling_and_loss, test_hand_example_linear_head_probs, and test_hand_example_reward_loss.

def pool(h: Tensor, attn_mask, how: str) -> Tensor: ... # [B, d]
class SequenceClassifier(Module):
def __init__(self, backbone, d_model, n_classes, pool="cls", dropout=0.0, rng=None): ...
def forward(self, ids, token_type_ids=None, attn_mask=None, labels=None) -> tuple[Tensor, Tensor | None]: ...
class TokenClassifier(Module): ... # logits [B, T, C]
class RewardHead(Module): def forward(self, ids, attn_mask=None) -> Tensor: ... # [B]
def pairwise_reward_loss(r_chosen: Tensor, r_rejected: Tensor) -> Tensor: ...
def lora_classifier(clf, r, alpha, target=None, init="default", rng=None) -> list[str]: ...
def train_classifier(clf, ids, labels, steps, batch_size, lr, rng, attn_mask=None, weight_decay=0.0) -> list[float]: ...
def predict(clf, ids, attn_mask=None, batch_size=64) -> NDArray: ...
def save_classifier(clf, dir, arch, tokenizer="file", labels=()) -> None: ...
def load_classifier(dir) -> tuple[SequenceClassifier, dict]: ...
def fit_linear_head(embeddings, labels, classes, l2, iters=50, threshold=0.5) -> dict: ...
def head_probs(head, embeddings) -> NDArray: ... # float64 [n, C]
def head_metrics(head, embeddings, labels) -> dict[str, float]: ...
def export_linear_head(head, embedding_model, path, metrics=None) -> None: ...
def load_linear_head(path) -> dict: ...
TestKINDChecksWhy it matters downstream
test_hand_example_pooling_and_lossunitsection 3: the three pools and the loss 0.313262you and the test agree on pooling
test_hand_example_linear_head_probsunitsection 3: p=σ(1.2)p = \sigma(1.2) at three scales; the zero vector scores softmax(bb)the gateway’s formula
test_hand_example_reward_lossunitlog⁡(1+e−2)\log(1 + e^{-2}) for two pairs with margin 2the Bradley-Terry loss
test_last_pool_reads_the_last_real_tokenboundarya GPT backbone: last picks positions 6, 3, 1 of three padded rowsdecoder classifiers and reward models
test_mean_pool_ignores_paddingpropertyhuge values in padding change nothing; an all-padding row and an unknown pool raisepadded batches
test_bert_classifier_does_not_see_paddingpropertya padded row and the same row alone get the same logits, whatever the padding idsthe encoder gets the mask
test_token_classifier_skips_padding_and_ignored_labelsunitthe loss equals the mean over real, labelled positionstoken tasks
test_reward_head_ranks_by_the_marginpropertyr=w⊤ht∗r = w^\top h_{t^*}; one step on the pair loss widens the marginreward modelling (L12.3)
test_lora_classifier_trains_adapters_and_the_headunitexactly the q and v adapters plus the head train; a few steps runthe MS-L6 LoRA fine-tune
test_fit_linear_head_is_irls_on_unit_vectorsdifferentialp1p_1 equals M07.7’s fit on unit rows; scale invariance; 3 classes refusedthe head is the fitted regression
test_python_scores_equal_the_shared_fixturegoldenthe probabilities stored for gw.08, to 1e-12Python and Go agree within 1e-6
test_export_validates_against_the_schemaconformanceformats/linear-head.schema.json; weights read back bit for bit; bad heads refusedthe gateway loads the file
test_head_metrics_at_the_thresholdunitprecision, recall, accuracy at the threshold; AUC 3.5/6the numbers the policy card reports
test_save_load_classifier_roundtrippropertyBERT and GPT classifiers reload and predict alike; adapters must be merged firstthe zoo loads classifiers
test_classifier_learns_sentimentlearninga tiny BERT, 150 AdamW steps on the synthetic SST-2 stand-in: validation accuracy at the reference barthe whole path learns
test_validationboundarybad class counts, pools, shapes, and label counts raisecaller bugs fail loudly

The learning test’s data, course/fixtures/small-corpora/sst2-2k.tsv, is a synthetic stand-in for the SST-2 2k subset of the design (the real SST-2 has no clear license to commit): 2000 template sentences with negation (“is not dull”) and contrast (“dull but the ending is wonderful”), so bag-of-words is not enough. Its bar is the reference’s mean minus 3 standard deviations over 5 seeds (course/fixtures/ref-thresholds.tsv).

Your oracles: numpy pools and cross-entropy written out on a constant backbone (a Module that returns fixed hidden states), a padded and unpadded run of the same encoder, M07.7’s own logistic_regression_fit and logistic_predict_proba on the unit rows, and the reward loss from math.log1p. Cover the three pools, the encoder mask, the token loss over real positions, the reward sign, the head on unit vectors, and which parameters lora_classifier leaves trainable. Import only the contract. ol check L6.5 requires a mutation score of at least 0.80 with every required pitfall fault killed.

PitfallSymptomCaught by
1. last reads position T−1T - 1every short row is classified by a padding vectortest_last_pool_reads_the_last_real_token, test_hand_example_pooling_and_loss (mutant s01)
2. padding in the mean, or an encoder called without the maskpredictions change with batch compositiontest_mean_pool_ignores_padding (mutant s02), test_bert_classifier_does_not_see_padding (mutant s03)
3. padding counted in a token lossthe loss rewards predicting padding labelstest_token_classifier_skips_padding_and_ignored_labels (mutant s04)
4. the policy head fitted or scored on raw embeddingslong prompts score differently from short ones with the same content; Go and Python disagreetest_hand_example_linear_head_probs, test_fit_linear_head_is_irls_on_unit_vectors (mutant s05)
5. the reward margin with the wrong signthe reward model learns to prefer the rejected answerstest_hand_example_reward_loss (mutant s06)
6. the new head frozen under LoRAaccuracy stays at chance whatever the adapters dotest_lora_classifier_trains_adapters_and_the_head (mutant s07)
7. weights exported at float32 precisionGo reads other doubles; scores drift past 1e-6test_export_validates_against_the_schema (mutant s09)
8. precision and recall at 0.5 instead of the thresholdthe policy card reports a classifier the gateway never runstest_head_metrics_at_the_threshold (mutant s10)
DirectionModuleHow it uses this
BackL6.1GPT.hidden is the decoder backbone; GPTConfig rebuilds it from a directory
BackL6.2BertEncoder is the encoder backbone; BertConfig rebuilds it
BackL6.6inject_lora adapts the backbone; LoRALinear marks an unmerged model
BackM07.7logistic_regression_fit, roc_auc, ece
BackM10.3AdamW in train_classifier
BackL0.3cross_entropy and bce_with_logits
BackL0.4Linear, Dropout, Module
BackL0.1the Tensor and no_grad in predict
BackL0.2the ops of pooling
BackL0.6the classifier directory’s safetensors
BackM06.3PCG32: the default init stream and the batch draws
ForwardL6.7the zoo loads classifier directories (BERT and ELECTRA backbones) and reports accuracy
Forwardethics.05fits and exports the usage-policy head over the engine’s embeddings (Pass 10)
Forwardgw.08evaluates the exported head in Go: unit vector, dot products, softmax, threshold

If you skip this module, ol check L6.7 stops with needs L6.5: build it, or pass --ref-deps.

Your pieceProduction equivalentWhat it addsWhere to look
SequenceClassifierHF BertForSequenceClassification, AutoModelForSequenceClassificationBERT’s pooler (a tanh layer over [CLS]), regression and multi-label problem typestransformers/models/bert/modeling_bert.py
RewardHead, pairwise_reward_lossTRL RewardTrainermargins per pair, centering rewards, the reward model behind PPO and best-of-ntrl/trainer/reward_trainer.py
lora_classifierPEFT TaskType.SEQ_CLSmodules_to_save=["classifier", "score"] chosen per architecturepeft/utils/constants.py
the linear policy headmoderation endpoints, Llama Guarda full classifier model with a taxonomy; here a single dot product over embeddings the engine already computesOpenAI moderation docs; Inan et al., “Llama Guard” (2023)