File size: 9,429 Bytes
0b72d45 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 | #!/usr/bin/env python3
"""
peer_gen.py -- build NIAH prompts for a PEER model, in that model's own tokenizer.
Why this exists rather than reusing longeval_gen.py: that script emits GPT-2 token
ids, and the peer models do not share GPT-2's vocabulary (Qwen3 151,936,
Spark-X2.5 131,072). Feeding GPT-2 ids to them is meaningless, and decoding to
text then letting each model re-tokenize would make "16,384 tokens" mean a
different amount of text per model, which is exactly the comparison we are trying
to make fair.
THE INVARIANT: a prompt is EXACTLY --prompt-len tokens OF THE TARGET TOKENIZER,
and the haystack TEXT is byte-identical across models. So every model sees the
same story, cut to the same number of its own tokens.
Prompts are emitted as token IDS, not text. A decode->re-encode round trip is not
length-preserving for BPE, so building in token space and handing ids straight to
the model is the only way to guarantee the exact length.
Tasks and needle vocabulary are copied from longeval_gen.py deliberately, so a
peer number is directly comparable to ours. Same WORDS list, same 7-digit values,
same answer-prefix prompting, same depth semantics (0.0 = window start, farthest
from the question; 1.0 = adjacent to it).
usage:
peer_gen.py --tokenizer Qwen/Qwen3-4B-Base --haystack hay.txt \
--task niah_single --prompt-len 16384 --n 100 \
--depths 0.0,0.25,0.5,0.75,1.0 --out-dir out/
"""
import argparse
import json
import os
import random
import sys
# --- copied verbatim from longeval_gen.py so peer numbers are comparable -------
WORDS = [
"aardvark", "buffalo", "cactus", "dolphin", "emerald", "falcon", "glacier",
"harbor", "iceberg", "jaguar", "kestrel", "lantern", "meadow", "nutmeg",
"orchid", "penguin", "quartz", "raven", "sapphire", "tundra", "umbrella",
"violet", "walnut", "xylophone", "yarrow", "zephyr", "anchor", "bramble",
"cinder", "driftwood", "ember", "fjord", "granite", "hollow", "ivory",
]
VT_ANCHOR_MAX = 0.8
def rnd_number(rng):
return str(rng.randint(1000000, 9999999))
def effective_depth(task, depth):
return min(depth, VT_ANCHOR_MAX) if task == "vt" else depth
def t_niah_single(rng, depth):
k, v = rng.choice(WORDS), rnd_number(rng)
return ([(f" The special magic number for {k} is {v}. ", depth)],
f"\nQuestion: What is the special magic number for {k}?\n",
f"Answer: The special magic number for {k} is", [v])
def t_niah_multikey(rng, depth, n_keys=4):
keys = rng.sample(WORDS, n_keys)
vals = [rnd_number(rng) for _ in keys]
tgt = rng.randrange(n_keys)
sents = [(f" The special magic number for {k} is {v}. ",
depth if i == tgt else rng.random())
for i, (k, v) in enumerate(zip(keys, vals))]
k, v = keys[tgt], vals[tgt]
return (sents, f"\nQuestion: What is the special magic number for {k}?\n",
f"Answer: The special magic number for {k} is", [v])
def t_niah_multivalue(rng, depth, n_vals=3):
k = rng.choice(WORDS)
vals = [rnd_number(rng) for _ in range(n_vals)]
sents = [(f" One of the special magic numbers for {k} is {v}. ",
depth if i == 0 else rng.random()) for i, v in enumerate(vals)]
return (sents, f"\nQuestion: What are all the special magic numbers for {k}?\n",
f"Answer: The special magic numbers for {k} are", vals)
def t_vt(rng, depth, hops=4):
names = rng.sample([w.upper()[:3] + str(rng.randint(10, 99)) for w in WORDS], hops)
val = rnd_number(rng)
anchor = effective_depth("vt", depth)
sents = [(f" VAR {names[0]} = {val}. ", anchor)]
for i in range(1, hops):
sents.append((f" VAR {names[i]} = {names[i-1]}. ",
anchor + (1.0 - anchor) * i / hops))
return (sents, f"\nQuestion: What is the value of VAR {names[-1]}?\n",
f"Answer: VAR {names[-1]} =", [val])
def t_val_backward(rng, depth):
k, v = rng.choice(WORDS), rnd_number(rng)
return ([(f" The special magic number for {k} is {v}. ", depth)],
f"\nQuestion: Which item has the special magic number {v}?\n",
f"Answer: The item with the special magic number {v} is", [k])
TASKS = {"niah_single": t_niah_single, "niah_multikey": t_niah_multikey,
"niah_multivalue": t_niah_multivalue, "vt": t_vt,
"val_forward": t_niah_single, "val_backward": t_val_backward}
def build_one(task, rng, enc, prompt_len, hay_ids, depth, hay_off):
"""One prompt of EXACTLY prompt_len tokens in `enc`'s vocabulary."""
sents, q, a, expected = TASKS[task](rng, depth)
q_ids, a_ids = enc(q), enc(a)
tail = len(q_ids) + len(a_ids)
body_budget = prompt_len - tail
if body_budget <= 0:
raise SystemExit(f"--prompt-len {prompt_len} too small for task {task}")
planted = sorted(((max(0.0, min(1.0, d)), enc(s)) for s, d in sents),
key=lambda x: x[0])
fill = body_budget - sum(len(i) for _, i in planted)
if fill <= 0:
raise SystemExit(f"needles do not fit in --prompt-len {prompt_len}")
H = lambda lo, n: hay_ids[hay_off + lo: hay_off + lo + max(0, n)]
body, used, pos = [], 0, 0
for frac, ids in planted:
take = max(0, int(frac * fill) - used)
body.extend(H(pos, take)); pos += take; used += take
body.extend(ids)
body.extend(H(pos, fill - used))
out = body + q_ids + a_ids
if len(out) < prompt_len: # haystack ran short
out = body + H(pos, prompt_len - len(out)) + q_ids + a_ids
out = out[:prompt_len - tail] + q_ids + a_ids
assert len(out) == prompt_len, (len(out), prompt_len)
return out, expected
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--tokenizer", required=True,
help="HF repo id, or the literal 'gpt2' for tiktoken parity")
ap.add_argument("--haystack", required=True, help="UTF-8 text file of filler")
ap.add_argument("--task", required=True, choices=sorted(TASKS) + ["all"])
ap.add_argument("--prompt-len", type=int, required=True)
ap.add_argument("--n", type=int, default=100)
ap.add_argument("--depths", default="0.0,0.25,0.5,0.75,1.0")
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--out-dir", required=True)
args = ap.parse_args()
if args.tokenizer == "gpt2":
import tiktoken
_e = tiktoken.get_encoding("gpt2")
# disallowed_special=() matches the corpus's own encode_ordinary: any
# "<|endoftext|>"-looking text is ordinary text, never a special id.
# Without this, tiktoken raises on the first such string in the haystack.
enc = lambda s: _e.encode(s, disallowed_special=())
name = "tiktoken-gpt2"
else:
from transformers import AutoTokenizer
_t = AutoTokenizer.from_pretrained(args.tokenizer, trust_remote_code=True)
enc = lambda s: _t.encode(s, add_special_tokens=False)
name = args.tokenizer
text = open(args.haystack, encoding="utf-8", errors="ignore").read()
hay_ids = enc(text)
print(f" tokenizer {name}: haystack {len(text):,} chars -> {len(hay_ids):,} tokens")
os.makedirs(args.out_dir, exist_ok=True)
depths = [float(d) for d in args.depths.split(",")]
tasks = sorted(TASKS) if args.task == "all" else [args.task]
need = args.prompt_len * (args.n + 2)
if len(hay_ids) < need:
# tile rather than fail: a short haystack repeats, which is weaker
# distraction and INFLATES accuracy, so it is reported loudly.
reps = need // len(hay_ids) + 2
print(f" WARN: haystack has {len(hay_ids):,} tokens, need {need:,} -- "
f"tiling {reps}x. Repetitive filler inflates accuracy; record this.")
hay_ids = hay_ids * reps
for task in tasks:
rng = random.Random(args.seed) # per task, so tasks are independent
meta, rows = [], []
for i in range(args.n):
want = depths[i % len(depths)]
eff = effective_depth(task, want)
ids, expected = build_one(task, rng, enc, args.prompt_len, hay_ids,
want, hay_off=i * args.prompt_len)
rows.append({"i": i, "ids": ids})
meta.append({"i": i, "depth": eff, "depth_requested": want,
"expected": expected})
# A special id inside the body would act as a document reset and silently
# shorten the effective context -- the exact thing NIAH measures.
if args.tokenizer != "gpt2":
bad = sorted(set(_t.all_special_ids or []))
if bad:
hit = {t for r in rows for t in r["ids"] if t in set(bad)}
if hit:
print(f" WARN: {len(hit)} special id(s) {sorted(hit)} appear "
f"inside {task} prompts -- these truncate attention")
with open(os.path.join(args.out_dir, f"{task}_prompts.jsonl"), "w") as f:
for r in rows:
f.write(json.dumps(r) + "\n")
with open(os.path.join(args.out_dir, f"{task}_meta.json"), "w") as f:
json.dump({"task": task, "prompt_len": args.prompt_len, "n": args.n,
"seed": args.seed, "tokenizer": name, "items": meta}, f)
print(f" {task:<16} {args.n} x {args.prompt_len} tok -> {args.out_dir}")
if __name__ == "__main__":
main()
|