Long-context evaluation - NIAH and RULER#
Track 06 - Evaluation · Notebook 06 · Runtime: ≈1 min on CPU
Prerequisites:
01_inference/01(KV cache),06_eval/01(perplexity).Papers: gkamradt’s Needle in a Haystack repo; Hsieh et al. 2024, RULER: What’s the Real Context Size of Your Long-Context Language Models? (2404.06654).
What#
Long-context accuracy doesn’t scale smoothly with context window. Models that claim 128 K often degrade sharply past 16 K. Two standard benchmarks probe this:
Needle-in-a-Haystack (NIAH). Insert a short distinctive fact (“The magic number is 7891”) at depth
din a long haystack; ask the model to recall the fact. Sweep depth and context length; plot a heatmap.RULER. Extends NIAH with multi-hop aggregation, variable tracking, word frequency, and question-answering tasks. The goal is to stress every axis of long-context competence, not just needle recall.
We can’t run a real long-context model here, so we model the effect via a simple decay function: recall probability drops with distance from the end (models often forget the middle). We verify the harness reproduces known failure patterns.
from llm_systems_cookbook.nb import bootstrap
import numpy as np
s = bootstrap("06_eval_06_long_context_niah_ruler")
NIAH setup#
Construct haystacks at context_len ∈ {1k, 4k, 16k, 64k}. Insert a
needle at depth d ∈ {0.0, 0.25, 0.5, 0.75, 1.0} (fraction of
context from the start). Define a recall probability model that
captures two empirically observed patterns:
Lost-in-the-middle: models recall needles near the start and end of context better than the middle.
Context-length decay: recall drops as context grows, faster for middle depths than edges.
def recall_prob(context_len: int, depth: float, max_supported_len: int = 4096) -> float:
'''Simulated needle-retrieval probability.
Models:
- Full recall when context_len <= max_supported_len AND depth in {0, 1} (anchor positions).
- Gradual decay with context length for middle depths.
- Stronger at the end of the context than in the middle.
'''
# Distance from nearest anchor (start or end).
middle_distance = min(depth, 1.0 - depth) # 0 at edge, 0.5 at middle
length_penalty = np.clip(np.log2(context_len / max_supported_len + 1), 0.0, None)
# Recall falls off exponentially with middle_distance * length_penalty.
return float(np.clip(np.exp(-middle_distance * length_penalty * 4.0), 0.05, 1.0))
CONTEXT_LENS = [1024, 4096, 16384, 65536]
DEPTHS = [0.0, 0.25, 0.50, 0.75, 1.0]
heatmap = np.array([[recall_prob(L, d) for d in DEPTHS] for L in CONTEXT_LENS])
for L, row in zip(CONTEXT_LENS, heatmap, strict=True):
row_str = " ".join(f"{v:.2f}" for v in row)
print(f"L={L:>6} depths={DEPTHS} recall={row_str}")
NIAH checks#
Recall is always ≥ some floor (model doesn’t completely fail).
Edge depths (0.0 and 1.0) outperform middle depths (0.5) at every non-trivial context length.
Average recall at 64K is strictly worse than at 1K.
avg_recall_per_L = heatmap.mean(axis=1)
for L, avg in zip(CONTEXT_LENS, avg_recall_per_L, strict=True):
print(f"L={L:>6} avg recall = {avg:.3f}")
s.check(
"recall_always_above_floor",
lambda: float(heatmap.min()) > 0.0,
msg=f"min recall = {heatmap.min():.3f}",
)
s.check(
"recall_at_16k_edges_above_middle",
lambda: heatmap[2, 0] > heatmap[2, 2] and heatmap[2, 4] > heatmap[2, 2],
msg=f"L=16k row = {heatmap[2]}",
)
s.check(
"avg_recall_monotone_decreasing_with_context",
lambda: all(avg_recall_per_L[i] >= avg_recall_per_L[i + 1] for i in range(len(avg_recall_per_L) - 1)),
msg=f"avg per L = {avg_recall_per_L}",
)
RULER-style composite score#
RULER reports eight task categories averaged at each context length. We simulate three: single-NIAH (as above), multi-key NIAH (recall K of N needles), and variable tracking (follow a variable through reassignments). Harder tasks degrade faster with context.
def multi_key_niah_recall(context_len: int, n_keys: int) -> float:
'''Average recall over n_keys independently inserted needles.'''
return float(np.mean([recall_prob(context_len, (i + 1) / (n_keys + 1)) for i in range(n_keys)]))
def variable_tracking_accuracy(context_len: int, chain_length: int) -> float:
'''Probability of correctly tracking a variable through ``chain_length``
reassignments: each reassignment step must be retrieved correctly, and
the chain succeeds only if all of them do.
'''
per_step = recall_prob(context_len, 0.5)
return float(per_step ** chain_length)
ruler = {}
for L in CONTEXT_LENS:
niah_1 = recall_prob(L, 0.5)
niah_3 = multi_key_niah_recall(L, n_keys=3)
var_track = variable_tracking_accuracy(L, chain_length=4)
ruler[L] = {"niah_1": niah_1, "niah_3": niah_3, "var_track": var_track,
"avg": (niah_1 + niah_3 + var_track) / 3}
print(f"L={L:>6} niah_1={niah_1:.3f} niah_3={niah_3:.3f} var_track={var_track:.3f} avg={ruler[L]['avg']:.3f}")
s.check(
"ruler_composite_drops_with_context",
lambda: ruler[CONTEXT_LENS[-1]]["avg"] < ruler[CONTEXT_LENS[0]]["avg"],
msg=f"L={CONTEXT_LENS[0]} avg = {ruler[CONTEXT_LENS[0]]['avg']:.3f} "
f"L={CONTEXT_LENS[-1]} avg = {ruler[CONTEXT_LENS[-1]]['avg']:.3f}",
)
s.check(
"harder_task_degrades_faster_than_niah",
lambda: ruler[1024]["var_track"] < ruler[1024]["niah_1"],
msg=f"at 1k: niah_1={ruler[1024]['niah_1']:.3f} var_track={ruler[1024]['var_track']:.3f}",
)
The canonical NIAH heatmap#
The Needle-in-a-Haystack chart everyone has seen: needle depth on the x-axis, context length on the y-axis, cell colour = recall probability. The characteristic “lost-in-the-middle” bowtie appears as a darker central column at longer contexts, with the two edge columns staying bright.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(6.8, 4.0))
im = ax.imshow(heatmap, aspect="auto", cmap="RdYlGn", vmin=0.0, vmax=1.0, origin="lower")
ax.set_xticks(range(len(DEPTHS)))
ax.set_xticklabels([f"{d:.2f}" for d in DEPTHS])
ax.set_yticks(range(len(CONTEXT_LENS)))
ax.set_yticklabels([f"{L//1024}k" if L >= 1024 else str(L) for L in CONTEXT_LENS])
ax.set_xlabel("needle depth (fraction of context)")
ax.set_ylabel("context length (tokens)")
ax.set_title("NIAH recall heatmap (green = retrieved, red = missed)")
for i in range(heatmap.shape[0]):
for j in range(heatmap.shape[1]):
ax.text(j, i, f"{heatmap[i, j]:.2f}", ha="center", va="center",
color="black" if heatmap[i, j] > 0.5 else "white", fontsize=9)
fig.colorbar(im, ax=ax, label="recall probability")
fig.tight_layout()
plt.show()
Exercises#
Hook this up to a real long-context model (Llama-3.2-1B claims 128K but performance above 8K is a fair test). Replace
recall_probwith actual inference.Vary the needle pattern: exact-string match vs paraphrase-recall vs numerical arithmetic on the needle. Harder patterns degrade at shorter contexts.
Plot the NIAH heatmap with matplotlib’s
imshow; the diagonal stripe pattern is the standard “lost in the middle” visual.
References#
gkamradt’s NIAH script - the tooling everyone uses for the raw needle task.
RULER paper §3 for the eight-task breakdown and the “effective context window” definition.
s.summary()
s.save()