unparallelled commited on
Commit
0be0229
·
verified ·
1 Parent(s): 1802505

Upload source/files/needle_gen.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. source/files/needle_gen.py +287 -0
source/files/needle_gen.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ needle_gen.py -- synthesize retrieval-probe token streams for the existing
4
+ CP_EVAL_PPL / CP_EVAL_TOKENS_BIN path. No C++ changes are needed: the eval
5
+ already windows a raw int32 token file and already reports PER-POSITION
6
+ mean_nll, so planting an answer span at a known offset lets us read retrieval
7
+ NLL straight out of the CSV.
8
+
9
+ Stdlib only (array + random) -- deliberately no numpy, because the local pyenv
10
+ python3 lacks it while /usr/bin/python3 has it, and this must run identically on
11
+ the local box and the server regardless of which `python3` resolves first.
12
+
13
+ FIXED-SLOT LAYOUT (the construction that makes the differencing valid)
14
+ ---------------------------------------------------------------------
15
+ Every variant of a trial has byte-identical structure except for WHERE the
16
+ needle sits (or whether it is present). The probe prefix and the answer span are
17
+ always at the SAME absolute offsets, so we always difference NLLs measured at
18
+ the same point on a position-varying curve:
19
+
20
+ trial (T tokens):
21
+ [ filler .......... N0..N(k-1) ......... filler ][ probe prefix ][ ANSWER ][pad]
22
+ ^needle, k tokens k/2 k/2 >=2
23
+ slot_start = round(p_frac * max_slot_start)
24
+
25
+ p_frac = 0.0 -> needle as far from the probe as possible
26
+ p_frac = 1.0 -> needle immediately adjacent to the probe (the RETRIEVAL FLOOR)
27
+
28
+ VARIANTS (7 per model/T/cache)
29
+ p0, p0.25, p0.5, p0.75, p1 needle planted at that slot; p1 is the floor
30
+ absent no needle planted; probe prefix and answer tokens
31
+ still present (the no-information ceiling)
32
+ null needle planted at p_frac=0 but the probe prefix is
33
+ an UNRELATED k/2-gram, so retrieval is impossible
34
+ -> supplies the significance threshold (RAW NLL)
35
+
36
+ CRITICAL OFF-BY-ONE
37
+ The eval scores input[t] = toks[base+t] against target[t] = toks[base+t+1] and
38
+ accumulates the NLL at the position of the INPUT. So the NLL of the answer token
39
+ living at trial offset `a` appears in the CSV at row `a-1`. The manifest records
40
+ BOTH; needle_score.py must read `csv_positions`, never `answer_offsets`.
41
+
42
+ Usage
43
+ python3 needle_gen.py --T 32768 --variant p0.5 --block 0 --out-dir needles
44
+ python3 needle_gen.py --T 32768 --all --blocks 5 --out-dir needles
45
+ """
46
+ import argparse, array, json, os, random, sys
47
+
48
+ VOCAB = 50304 # must match cfg.vocab_size; the C++ side bound-checks too
49
+ PAD_TAIL = 4 # >=2: windows stride by exactly T, so the target at T-1
50
+ # belongs to the NEXT trial. Keep the answer clear of it.
51
+ P_FRACS = [0.0, 0.25, 0.5, 0.75, 1.0]
52
+ MIX = 1_000_003 # odd multiplier for deterministic seed derivation
53
+
54
+
55
+ # Active p_frac grid. Defaults to P_FRACS; --p-fracs overrides it for the S>R
56
+ # ablation, whose predicted dead gap [R,S) falls BETWEEN the default points and
57
+ # would otherwise be unobservable. Overriding changes only which slots are probed,
58
+ # never the layout, so answer-span offsets stay invariant.
59
+ _ACTIVE_P_FRACS = list(P_FRACS)
60
+
61
+
62
+ def set_p_fracs(vals):
63
+ global _ACTIVE_P_FRACS
64
+ _ACTIVE_P_FRACS = list(vals)
65
+
66
+
67
+ def variant_names():
68
+ return [f"p{p:g}" for p in _ACTIVE_P_FRACS] + ["absent", "null"]
69
+
70
+
71
+ def parse_variant(name):
72
+ """-> (p_frac or None, is_null). absent -> (None, False); null -> (0.0, True)."""
73
+ if name == "absent":
74
+ return None, False
75
+ if name == "null":
76
+ return 0.0, True
77
+ if name.startswith("p"):
78
+ p = float(name[1:])
79
+ if not 0.0 <= p <= 1.0:
80
+ raise SystemExit(f"p_frac must be in [0,1] (got {p})")
81
+ return p, False
82
+ raise SystemExit(f"unknown variant '{name}' (expected one of {variant_names()})")
83
+
84
+
85
+ def check_platform():
86
+ if sys.byteorder != "little":
87
+ raise SystemExit("CP_EVAL_TOKENS_BIN expects int32 LITTLE-endian; this host is big-endian")
88
+ if array.array("i").itemsize != 4 or array.array("H").itemsize != 2:
89
+ raise SystemExit("unexpected array itemsizes ('i' must be 4B, 'H' must be 2B)")
90
+
91
+
92
+ def load_filler(path, need, rng):
93
+ """Real tokens, so the LM is not off-distribution (fabricated filler would
94
+ inflate NLL everywhere and mask the effect). Source is uint16; the eval file
95
+ must be int32 LE, so widen explicitly and bound-check."""
96
+ ntok = os.path.getsize(path) // 2
97
+ if ntok < need + 1:
98
+ raise SystemExit(f"{path} has {ntok} tokens, need >= {need + 1}")
99
+ # One contiguous run per trial, so filler keeps local textual statistics
100
+ # instead of being a shuffled bag of tokens.
101
+ start = rng.randrange(0, ntok - need)
102
+ buf = array.array("H")
103
+ with open(path, "rb") as f:
104
+ f.seek(start * 2)
105
+ buf.fromfile(f, need)
106
+ out = array.array("i", buf) # C-level uint16 -> int32
107
+ lo, hi = min(out), max(out)
108
+ if lo < 0 or hi >= VOCAB:
109
+ raise SystemExit(f"{path} has ids outside [0,{VOCAB}): min={lo} max={hi}")
110
+ return out
111
+
112
+
113
+ def build_layout(T, k, probe_pos=None):
114
+ """Offsets are a pure function of (T, k, probe_pos) -- identical for every variant.
115
+
116
+ probe_pos is None (default): probe+answer sit at the END of the trial, so the
117
+ needle-to-probe DISTANCE is T - slot. Distance and the needle's ABSOLUTE
118
+ position are then perfectly anti-correlated -- one variable, not two -- which
119
+ makes "far from the question" and "early in the document" indistinguishable.
120
+
121
+ probe_pos = P: probe+answer are planted at absolute offset P and everything
122
+ after them is ordinary filler. Pinning the needle's slot while varying P (or
123
+ vice versa) breaks that confound. Legal because the eval reports NLL at EVERY
124
+ position, so the scored span does not have to be at the end -- and the
125
+ manifest's csv_positions carries the offsets, so needle_score.py follows
126
+ automatically with no change.
127
+ """
128
+ if k % 2:
129
+ raise SystemExit(f"--needle-len must be even (got {k})")
130
+ if probe_pos is None:
131
+ answer_end = T - PAD_TAIL
132
+ answer_start = answer_end - k // 2
133
+ probe_start = answer_start - k // 2 # probe prefix: [probe_start, answer_start)
134
+ else:
135
+ probe_start = probe_pos
136
+ answer_start = probe_start + k // 2
137
+ answer_end = probe_start + k
138
+ if answer_end > T - PAD_TAIL:
139
+ raise SystemExit(f"--probe-pos {probe_pos} puts the answer span end at "
140
+ f"{answer_end}, past T-PAD_TAIL={T - PAD_TAIL}; the last "
141
+ "position's target belongs to the next trial")
142
+ max_slot_start = probe_start - k # needle must fit before the probe
143
+ if max_slot_start < 0:
144
+ raise SystemExit(f"probe at {probe_start} leaves no room for a needle of "
145
+ f"{k} tokens before it (T={T})")
146
+ return dict(
147
+ T=T, needle_len=k, pad_tail=PAD_TAIL, probe_pos=probe_pos,
148
+ probe_start=probe_start, answer_start=answer_start, answer_end=answer_end,
149
+ max_slot_start=max_slot_start,
150
+ answer_offsets=[answer_start, answer_end],
151
+ # NLL is reported at the INPUT position, so the answer token at offset a is
152
+ # scored on CSV row a-1. See "CRITICAL OFF-BY-ONE" above.
153
+ csv_positions=[answer_start - 1, answer_end - 1],
154
+ )
155
+
156
+
157
+ def make_trial(lay, p_frac, is_null, filler, rng, reverse=False):
158
+ """One trial of exactly T int32 tokens. `filler` is consumed (modified)."""
159
+ k = lay["needle_len"]
160
+ half = k // 2
161
+ trial = filler # already a fresh copy per trial
162
+ needle = array.array("i", [rng.randrange(VOCAB) for _ in range(k)])
163
+
164
+ # The answer span is the needle's second half, at fixed offsets -- unless
165
+ # `reverse`, which swaps the halves: probe = needle's SECOND half, answer = its
166
+ # FIRST, so the model must retrieve what PRECEDES the probe. Forward retrieval
167
+ # (answer following the keyword) is an induction-head copy and "an easily
168
+ # learned pattern" (arXiv:2404.16811 s3.1), so it is exactly what training on
169
+ # forward data would Goodhart; backward retrieval cannot be satisfied that way,
170
+ # which makes it the transfer test. Layout OFFSETS are untouched, so
171
+ # csv_positions stays valid and needle_score.py needs no change.
172
+ trial[lay["answer_start"]:lay["answer_end"]] = needle[:half] if reverse else needle[half:]
173
+
174
+ # Probe prefix = the needle's other half, EXCEPT for `null`, where an unrelated
175
+ # k/2-gram makes retrieval impossible while the answer TARGETS stay identical.
176
+ if is_null:
177
+ prefix = array.array("i", [rng.randrange(VOCAB) for _ in range(half)])
178
+ else:
179
+ prefix = needle[half:] if reverse else needle[:half]
180
+ trial[lay["probe_start"]:lay["answer_start"]] = prefix
181
+
182
+ # Plant the needle in exactly one slot (or none, for `absent`).
183
+ slot_start = None
184
+ if p_frac is not None:
185
+ slot_start = int(round(p_frac * lay["max_slot_start"]))
186
+ trial[slot_start:slot_start + k] = needle
187
+ return trial, slot_start
188
+
189
+
190
+ def gen(args, variant, block):
191
+ p_frac, is_null = parse_variant(variant)
192
+ lay = build_layout(args.T, args.needle_len, args.probe_pos)
193
+ # Seed depends on (seed, T, block, trial) and NOT on the variant, so every
194
+ # variant of a block shares identical filler and identical needle tokens --
195
+ # the whole point of the fixed-slot construction.
196
+ base_seed = (args.seed * MIX + args.T) * MIX + block
197
+
198
+ stream = array.array("i")
199
+ slots = []
200
+ for i in range(args.trials):
201
+ rng = random.Random(base_seed * MIX + i)
202
+ filler = load_filler(args.filler, args.T, rng)
203
+ trial, slot = make_trial(lay, p_frac, is_null, filler, rng, args.reverse)
204
+ if len(trial) != args.T:
205
+ raise SystemExit(f"trial length {len(trial)} != T={args.T} -- bug")
206
+ stream.extend(trial)
207
+ slots.append(slot)
208
+
209
+ # +1 token: window w reads toks[base+t] and toks[base+t+1] for t in [0,T), so
210
+ # the final window needs index N*T to exist.
211
+ stream.append(stream[-1])
212
+ if len(stream) != args.trials * args.T + 1:
213
+ raise SystemExit("stream length wrong -- bug")
214
+
215
+ os.makedirs(args.out_dir, exist_ok=True)
216
+ tag = f"T{args.T}_{variant}_b{block}"
217
+ bin_path = os.path.join(args.out_dir, f"needle_{tag}.bin")
218
+ with open(bin_path, "wb") as f:
219
+ stream.tofile(f)
220
+
221
+ man = dict(bin=bin_path, variant=variant, block=block, p_frac=p_frac, is_null=is_null,
222
+ trials=args.trials, seed=args.seed, vocab=VOCAB, reverse=args.reverse,
223
+ slot_starts=slots, **lay)
224
+ man_path = os.path.join(args.out_dir, f"needle_{tag}.json")
225
+ with open(man_path, "w") as f:
226
+ json.dump(man, f, indent=2)
227
+ return bin_path, man_path, man
228
+
229
+
230
+ def main():
231
+ ap = argparse.ArgumentParser()
232
+ ap.add_argument("--T", type=int, required=True, help="tokens per trial (= CP_T)")
233
+ ap.add_argument("--trials", type=int, default=None,
234
+ help="trials per block (= CP_EVAL_WINDOWS). Default is the plan's "
235
+ "pinned N: 8 at T<=131072, 4 above.")
236
+ ap.add_argument("--blocks", type=int, default=5,
237
+ help="blocks = SEPARATE files, each needing its own eval invocation; "
238
+ "the only source of spread (CP_EVAL_PPL emits no variance)")
239
+ ap.add_argument("--block", type=int, default=None, help="generate just this block index")
240
+ ap.add_argument("--variant", default=None, help=f"one of {variant_names()}")
241
+ ap.add_argument("--all", action="store_true", help="every variant x every block")
242
+ ap.add_argument("--needle-len", type=int, default=8, help="k; probe=k/2, answer=k/2")
243
+ ap.add_argument("--filler", default="Data_Loader/Data/edufineweb_val_00000.bin")
244
+ ap.add_argument("--seed", type=int, default=0)
245
+ ap.add_argument("--probe-pos", type=int, default=None,
246
+ help="absolute offset for the probe+answer span. Default: the end "
247
+ "of the trial. Setting it lets DISTANCE vary independently of "
248
+ "the needle's absolute position -- the confound the default "
249
+ "layout cannot separate. Isolate runs by --out-dir: filenames "
250
+ "are unchanged, so needle_score.py needs no flag.")
251
+ ap.add_argument("--out-dir", default="needles")
252
+ ap.add_argument("--reverse", action="store_true",
253
+ help="BACKWARD retrieval (transfer test): probe = needle's 2nd half, "
254
+ "answer = its 1st. Use a SEPARATE --out-dir -- filenames do not "
255
+ "encode it, and absent/null must be regenerated in reverse too.")
256
+ ap.add_argument("--p-fracs", default=None,
257
+ help="comma-separated p_frac list overriding the default "
258
+ f"{P_FRACS}. Needed by the STRING S>R ablation, whose predicted "
259
+ "dead gap lies between the default points.")
260
+ args = ap.parse_args()
261
+
262
+ if args.p_fracs:
263
+ vals = [float(x) for x in args.p_fracs.split(",") if x.strip()]
264
+ if not vals or any(not 0.0 <= v <= 1.0 for v in vals):
265
+ raise SystemExit(f"--p-fracs must be values in [0,1] (got {args.p_fracs})")
266
+ set_p_fracs(vals)
267
+
268
+ check_platform()
269
+ if args.trials is None: # pinned N from the plan (A1)
270
+ args.trials = 8 if args.T <= 131072 else 4
271
+
272
+ if args.all:
273
+ jobs = [(v, b) for v in variant_names() for b in range(args.blocks)]
274
+ else:
275
+ if args.variant is None:
276
+ raise SystemExit("give --variant NAME (and optionally --block), or --all")
277
+ jobs = [(args.variant, args.block if args.block is not None else 0)]
278
+
279
+ for v, b in jobs:
280
+ bp, _, man = gen(args, v, b)
281
+ slot = man["slot_starts"][0]
282
+ print(f"{v:8s} block={b} {bp} answer_offsets={man['answer_offsets']} "
283
+ f"csv_positions={man['csv_positions']} slot={slot}")
284
+
285
+
286
+ if __name__ == "__main__":
287
+ main()