Position priors and debiasing¶
The problem¶
A small model asked to answer with a letter has a preference for certain letters before it reads any evidence. If the preference is strong, the model is answering by position. On a balanced dataset such a model scores about chance, and chance in an accuracy column looks like a weak model, not a broken one.
The measured size of the effect, on authored144 with Qwen3-0.6B
(bench/results/authored144-Qwen3-0.6B-ONNX-q4f16-readout-cyclic-shots3.json):
87 of 144 decisions changed their argmax when the options were rotated. The
single-ordering runs of the same model report 0 unstable rows, only because they never
looked.
The three numbers that expose it¶
evaluate always reports these together:
| Number | Question it answers |
|---|---|
balanced_accuracy |
Is it right, with every class weighted equally? |
order_consistency |
Of the decisions scored under more than one ordering, what share got the same answer under every ordering? None when only one ordering was scored. |
letter_distribution |
Which letters did it actually pick? A:100% B:0% C:0% is a complete diagnosis. |
order_consistency is computed from the per-ordering argmaxes, before any
averaging. Debiasing therefore cannot hide a positional model from it.
The four modes¶
debias accepts "none", "cyclic", "permutation" or "calibrated".
| Mode | Orderings scored per decision | Forward passes for an n-option decision | What is done with them |
|---|---|---|---|
none |
The order given | 1 | Argmax. order_consistency is None. |
cyclic |
The n rotations | n | Average the probability per option id, then argmax. |
permutation |
All n! arrangements | n! | Same averaging. Refused above 6 options (ConfigError). |
calibrated |
The n rotations | n, plus one content-free pass per distinct (question, ordering) | Divide each vector by the content-free prior and renormalise, then average per option id. |
Why averaging cancels a position prior¶
Suppose the model's score for an option is its real evidence for that option plus a bonus that depends only on the slot it sits in. Under a cyclic set, every option sits in every slot exactly once. Each option therefore collects the same total bonus. When you average per option id, the bonus is a constant added to every option and it drops out of the argmax. The same holds for the full permutation set.
The accumulator is keyed by option id, never by position. Position is the thing being forgotten.
What calibrated does¶
It measures the prior directly. For each distinct (criterion key, question, option
order) it scores the same prompt with the state replaced by the content-free string
"N/A" (NULL_STATE), caches that distribution, divides every real probability
vector by it and renormalises. Unlike averaging, this adjusts each individual
ordering, so when it works the single-ordering answer itself becomes right.
Averaging repairs the readout, not the model¶
This is the point most worth being honest about.
After cyclic averaging, the reported prediction no longer depends on the option
order. The model is unchanged. It still flips its answer when the options move, and
order_consistency still says so. A model that reads the evidence and a model that
answers by position and is corrected afterwards are different things:
- The corrected model needs n forward passes per decision in production, forever.
- Its per-ordering probabilities are not trustworthy as confidences.
- The correction assumes the bias is a function of slot alone. A bias that interacts with content does not cancel.
The measured numbers make the same point. Averaging moved balanced accuracy from 0.419 to 0.483 while 87 of 144 rows were order-unstable. The improvement and the instability are both real.
Cyclic and full permutation averaging cannot be ranked on this evidence
On authored144, full permutation averaging (6 orderings for 3 options) scored
0.442 with 106 of 144 rows unstable, and cyclic averaging (3 orderings) scored
0.483 with 87. That difference is not statistically distinguishable. The two
runs disagree on 11 rows (cyclic right on 8, full permutation right on 3), and an
exact McNemar test on those discordant pairs gives a two-sided p = 0.23
(bench/compare.mjs). The mechanism predicts no difference either: both sets put
every option in every position equally often. Do not conclude that fewer orderings
are better. What is established is cost: cyclic needs n passes and full
permutation needs n!, and that is the reason this site recommends cyclic as the
default.
The calibrated figure in the same results (0.459, 80 of 144 unstable) is
contextual calibration applied to each of the 3 cyclic rotations and then
averaged, which is also what debias="calibrated" does in this library. It has
not been tested against the other modes for significance. These are single runs on
one fixture with one model; measure on your own data.
The real fix¶
Fine-tune on order-randomised data. TrainConfig.randomise_option_order is True by
default and re-permutes every decision's options at the start of every epoch, so
"the answer is A" is never a hypothesis consistent with the training data. The eval
set is never re-permuted during training, because a moving eval set is not a
measurement.
After tuning, the success criterion is order_consistency close to 1.0 with
debias="cyclic", and debias="none" accuracy close to the debiased accuracy. When
those hold, you can drop the extra passes in production.
Doing it by hand¶
The augmentation functions are public, for audits on a sample:
from typedecide import Criterion, Decision, Option
from typedecide.data import all_permutations, all_rotations, randomise_option_order
criterion = Criterion("queue", "Which queue?", (
Option("billing", "Billing"), Option("shipping", "Shipping"), Option("access", "Access"),
))
decision = Decision(id="T-1", state="Charged twice.", criterion=criterion, answer_id="billing")
for variant in all_rotations(decision):
print(variant.id, variant.criterion.option_ids, variant.answer_id)
# T-1#r0 ('billing', 'shipping', 'access') billing
# T-1#r1 ('shipping', 'access', 'billing') billing
# T-1#r2 ('access', 'billing', 'shipping') billing
print(len(all_permutations(decision))) # 6
shuffled = randomise_option_order([decision], seed=0) # same ids, same answers
all_permutations refuses more than 8 options (8! is 40,320 variants of one row).
randomise_option_order seeds each row from (seed, decision.id), so a row gets the
same ordering whichever dataset it is part of and whatever order it was loaded in.