Summary
lm_eval/evaluator.py evaluate() collects metrics like this:
metrics = task.process_results(doc, [req.filtered_resps[filter_key] for req in requests])
...
for metric, value in metrics.items():
acc["raw_metrics"][(metric, filter_key)].append(value)
If a task's process_results returns an empty dict, or omits a metric key, for a given document (a natural way for a task author to say "could not score this one": refusal, unparseable answer, empty extraction), that document contributes to no metric and the aggregate is a mean over the surviving documents only. Nothing warns, nothing counts the dropped documents per metric, and n-samples.effective in the results is computed in _compute_task_aggregations as the length of the LAST metric's list (the code carries a TODO admitting this), so with heterogeneous keys even the reported effective count is the wrong number for the other metrics.
The score inflation is structural: whatever a model does to make itself unscorable is removed from the average rather than counted against it.
Steps to reproduce
PoC (end-to-end through lm_eval.evaluator.evaluate, dummy LM, identical outputs in both runs): poc_unscorable_denominator.py
Six documents; the dummy model refuses on 2 and answers 4. The same model outputs are scored twice, differing only in the task's process_results convention. Observed (PROVED, run twice, byte-identical output):
model behavior: 2/6 docs refused (unscorable), 4/6 answered
[A: return empty dict for unscorable] acc=1.0 n-samples={'original': 6, 'effective': 4}
[B: return 0 for unscorable] acc=0.6666666666666666 n-samples={'original': 6, 'effective': 6}
RESULT: identical outputs, headline acc differs: True (1.0 vs 0.6666666666666666)
VERDICT: VULNERABLE
Impact
Headline accuracy can be inflated by exactly the documents a model fails to answer in a scorable way. Two tasks measuring identical model behavior can differ by the scoring convention alone, and consumers of the results file have no per-metric dropped-count to detect it. Group aggregation compounds this: Group.aggregate weights subtasks by the reported sample_len, which inherits the same last-metric count. Any task family that maps refusals or parse failures to an empty result (a pattern the process_results contract makes natural) is affected.
Suggested fix
Track denominators explicitly per (metric, filter): count evaluated documents per metric, count documents for which the metric was omitted, and report both (for example n-samples: {original, effective, unscorable_per_metric}) plus a warning when effective < original for any metric. Fix sample_len to be per metric rather than the last metric's length. Longer term, make the could-not-score case an explicit typed value in process_results rather than key omission.
References
- PoC script:
poc_unscorable_denominator.py (double-run REPRO: REPRO-E1-F4.sh, byte-identical)
- Pinned code: EleutherAI/lm-evaluation-harness @ 0f8479c, lm_eval/evaluator.py lines 636 to 669, lm_eval/evaluator_utils.py
_compute_task_aggregations lines 173 to 219 (sample_len TODO at line 204), lm_eval/api/group.py aggregate() lines 242 to 292
Summary
lm_eval/evaluator.pyevaluate() collects metrics like this:If a task's
process_resultsreturns an empty dict, or omits a metric key, for a given document (a natural way for a task author to say "could not score this one": refusal, unparseable answer, empty extraction), that document contributes to no metric and the aggregate is a mean over the surviving documents only. Nothing warns, nothing counts the dropped documents per metric, andn-samples.effectivein the results is computed in_compute_task_aggregationsas the length of the LAST metric's list (the code carries a TODO admitting this), so with heterogeneous keys even the reported effective count is the wrong number for the other metrics.The score inflation is structural: whatever a model does to make itself unscorable is removed from the average rather than counted against it.
Steps to reproduce
PoC (end-to-end through
lm_eval.evaluator.evaluate, dummy LM, identical outputs in both runs):poc_unscorable_denominator.pySix documents; the dummy model refuses on 2 and answers 4. The same model outputs are scored twice, differing only in the task's
process_resultsconvention. Observed (PROVED, run twice, byte-identical output):Impact
Headline accuracy can be inflated by exactly the documents a model fails to answer in a scorable way. Two tasks measuring identical model behavior can differ by the scoring convention alone, and consumers of the results file have no per-metric dropped-count to detect it. Group aggregation compounds this:
Group.aggregateweights subtasks by the reportedsample_len, which inherits the same last-metric count. Any task family that maps refusals or parse failures to an empty result (a pattern the process_results contract makes natural) is affected.Suggested fix
Track denominators explicitly per (metric, filter): count evaluated documents per metric, count documents for which the metric was omitted, and report both (for example
n-samples: {original, effective, unscorable_per_metric}) plus a warning when effective < original for any metric. Fixsample_lento be per metric rather than the last metric's length. Longer term, make the could-not-score case an explicit typed value in process_results rather than key omission.References
poc_unscorable_denominator.py(double-run REPRO:REPRO-E1-F4.sh, byte-identical)_compute_task_aggregationslines 173 to 219 (sample_len TODO at line 204), lm_eval/api/group.py aggregate() lines 242 to 292