qreflex 0.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qreflex/__init__.py +38 -0
- qreflex/api.py +311 -0
- qreflex/data.py +138 -0
- qreflex/eval.py +149 -0
- qreflex/generate.py +93 -0
- qreflex/losses.py +38 -0
- qreflex/model.py +632 -0
- qreflex/serve.py +206 -0
- qreflex/train.py +302 -0
- qreflex-0.0.1.dist-info/METADATA +261 -0
- qreflex-0.0.1.dist-info/RECORD +15 -0
- qreflex-0.0.1.dist-info/WHEEL +5 -0
- qreflex-0.0.1.dist-info/entry_points.txt +5 -0
- qreflex-0.0.1.dist-info/licenses/LICENSE +191 -0
- qreflex-0.0.1.dist-info/top_level.txt +1 -0
qreflex/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QreFLEX (reFLEX) — Responsive Flexible Learning and EXperience.
|
|
3
|
+
|
|
4
|
+
A small transformer whose central reasoning stream (Main) can selectively
|
|
5
|
+
consult two side channels, Intent (what is this input about) and
|
|
6
|
+
Experience (have I seen something like this before), without either
|
|
7
|
+
channel being able to override Main's own reasoning. See the project
|
|
8
|
+
README for the full architecture writeup and the "what changed from the
|
|
9
|
+
original prototype" history of real bugs found and fixed by actually
|
|
10
|
+
running this code against real data.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from qreflex.model import (
|
|
14
|
+
ReFLEX,
|
|
15
|
+
ReFLEXConfig,
|
|
16
|
+
IntentEncoder,
|
|
17
|
+
ExperienceEncoder,
|
|
18
|
+
ExperienceBank,
|
|
19
|
+
count_parameters,
|
|
20
|
+
small_reflex_config,
|
|
21
|
+
)
|
|
22
|
+
from qreflex.losses import retrieval_contrastive_loss
|
|
23
|
+
from qreflex.api import reFLEX
|
|
24
|
+
|
|
25
|
+
__version__ = "0.2.0"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"reFLEX",
|
|
29
|
+
"ReFLEX",
|
|
30
|
+
"ReFLEXConfig",
|
|
31
|
+
"IntentEncoder",
|
|
32
|
+
"ExperienceEncoder",
|
|
33
|
+
"ExperienceBank",
|
|
34
|
+
"count_parameters",
|
|
35
|
+
"small_reflex_config",
|
|
36
|
+
"retrieval_contrastive_loss",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
qreflex/api.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level "load it and generate" API for reFLEX models.
|
|
3
|
+
|
|
4
|
+
from qreflex import reFLEX
|
|
5
|
+
|
|
6
|
+
model = reFLEX(directory="./my-reflex-model", from_pretrained=True)
|
|
7
|
+
answer = model.generate("A: hello there\\nB:")
|
|
8
|
+
|
|
9
|
+
# streaming
|
|
10
|
+
for chunk in model.generate("A: hello there\\nB:", stream=True):
|
|
11
|
+
print(chunk, end="", flush=True)
|
|
12
|
+
|
|
13
|
+
This sits on top of the lower-level pieces that already exist in this
|
|
14
|
+
package (qreflex.model, qreflex.train.load_checkpoint,
|
|
15
|
+
qreflex.data.load_tokenizer) and adds two things neither the CLI
|
|
16
|
+
(qreflex-generate) nor the raw classes give you directly:
|
|
17
|
+
|
|
18
|
+
1. `directory` can be a local folder OR a Hugging Face Hub repo id
|
|
19
|
+
("YourUser/your-reflex-model") — resolved automatically.
|
|
20
|
+
2. `from_pretrained=False` gives you a randomly-initialized model
|
|
21
|
+
(still needs a tokenizer.json, since a model's vocab isn't
|
|
22
|
+
meaningful without one) for when you want to point a fresh model
|
|
23
|
+
at a directory before/instead of loading trained weights.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
from typing import Generator, Optional, Union
|
|
32
|
+
|
|
33
|
+
import torch
|
|
34
|
+
import torch.nn.functional as F
|
|
35
|
+
|
|
36
|
+
from qreflex.model import ReFLEX, ReFLEXConfig, ExperienceBank, small_reflex_config
|
|
37
|
+
from qreflex.data import load_tokenizer
|
|
38
|
+
|
|
39
|
+
_CHECKPOINT_NAMES = ("reFLEX-v1-15M.pt",)
|
|
40
|
+
_TOKENIZER_NAMES = ("tokenizer.json",)
|
|
41
|
+
_CONFIG_NAMES = ("config.json",)
|
|
42
|
+
|
|
43
|
+
# "user/repo-name" or "user/repo-name:revision" — deliberately conservative;
|
|
44
|
+
# anything that doesn't match this and isn't a local path is rejected with a
|
|
45
|
+
# clear error rather than silently trying (and failing) a Hub download.
|
|
46
|
+
_HF_REPO_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _looks_like_hf_repo_id(s: str) -> bool:
|
|
50
|
+
if os.path.exists(s) or s.startswith((".", "/", "~")):
|
|
51
|
+
return False
|
|
52
|
+
return bool(_HF_REPO_ID_RE.match(s))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _find_file(local_dir: str, preferred_names, contains: str, exts, kind: str) -> Optional[str]:
|
|
56
|
+
"""Look for one of `preferred_names` first; otherwise fall back to the
|
|
57
|
+
single file in `local_dir` whose name contains `contains` and whose
|
|
58
|
+
extension is in `exts`. Raises if that fallback is ambiguous."""
|
|
59
|
+
for name in preferred_names:
|
|
60
|
+
p = os.path.join(local_dir, name)
|
|
61
|
+
if os.path.isfile(p):
|
|
62
|
+
return p
|
|
63
|
+
candidates = sorted(
|
|
64
|
+
f for f in os.listdir(local_dir)
|
|
65
|
+
if f.lower().endswith(exts) and contains in f.lower()
|
|
66
|
+
)
|
|
67
|
+
if len(candidates) == 1:
|
|
68
|
+
return os.path.join(local_dir, candidates[0])
|
|
69
|
+
if len(candidates) > 1:
|
|
70
|
+
raise FileNotFoundError(
|
|
71
|
+
f"Found multiple possible {kind} files in '{local_dir}': {candidates}. "
|
|
72
|
+
f"Rename the one you want to one of {preferred_names}, or pass it explicitly "
|
|
73
|
+
f"via the matching *_file argument."
|
|
74
|
+
)
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class reFLEX:
|
|
79
|
+
"""Load a reFLEX checkpoint and generate text from it.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
directory: A local folder, or a Hugging Face Hub repo id
|
|
83
|
+
(e.g. "YourUser/your-reflex-model"). Should contain a
|
|
84
|
+
tokenizer.json, and (if from_pretrained=True) a checkpoint
|
|
85
|
+
.pt file in the format written by qreflex.train.save_checkpoint.
|
|
86
|
+
from_pretrained: True loads model weights from a checkpoint found
|
|
87
|
+
in `directory`. False builds a randomly-initialized model
|
|
88
|
+
instead (a config.json in `directory` is used if present,
|
|
89
|
+
otherwise a small default config sized to the tokenizer).
|
|
90
|
+
device: "cuda" / "cpu" / etc. Defaults to cuda if available.
|
|
91
|
+
use_experience: Default for whether generate() consults the
|
|
92
|
+
experience bank stored in the checkpoint (if any). Can be
|
|
93
|
+
overridden per-call.
|
|
94
|
+
checkpoint_file / tokenizer_file / config_file: explicit paths,
|
|
95
|
+
for when auto-detection in `directory` is ambiguous or the
|
|
96
|
+
files use non-standard names.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
directory: str,
|
|
102
|
+
from_pretrained: bool = True,
|
|
103
|
+
device: Optional[str] = None,
|
|
104
|
+
use_experience: bool = True,
|
|
105
|
+
checkpoint_file: Optional[str] = None,
|
|
106
|
+
tokenizer_file: Optional[str] = None,
|
|
107
|
+
config_file: Optional[str] = None,
|
|
108
|
+
):
|
|
109
|
+
self.directory = directory
|
|
110
|
+
self.from_pretrained = from_pretrained
|
|
111
|
+
self.use_experience = use_experience
|
|
112
|
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
|
113
|
+
|
|
114
|
+
local_dir = self._resolve_directory(directory)
|
|
115
|
+
|
|
116
|
+
tok_path = tokenizer_file or _find_file(
|
|
117
|
+
local_dir, _TOKENIZER_NAMES, "tokenizer", (".json",), "tokenizer"
|
|
118
|
+
)
|
|
119
|
+
if tok_path is None:
|
|
120
|
+
raise FileNotFoundError(
|
|
121
|
+
f"No tokenizer.json found in '{local_dir}'. A reFLEX model always needs the "
|
|
122
|
+
f"tokenizer it was (or will be) trained with — point `directory` at a folder "
|
|
123
|
+
f"that has one, or pass `tokenizer_file=` explicitly."
|
|
124
|
+
)
|
|
125
|
+
self.tokenizer = load_tokenizer(tok_path)
|
|
126
|
+
|
|
127
|
+
self.bank: Optional[ExperienceBank] = None
|
|
128
|
+
self.step = 0
|
|
129
|
+
self._experience_encoder = None
|
|
130
|
+
|
|
131
|
+
if from_pretrained:
|
|
132
|
+
ckpt_path = checkpoint_file or _find_file(
|
|
133
|
+
local_dir, _CHECKPOINT_NAMES, "checkpoint", (".pt", ".bin"), "checkpoint"
|
|
134
|
+
)
|
|
135
|
+
if ckpt_path is None:
|
|
136
|
+
raise FileNotFoundError(
|
|
137
|
+
f"from_pretrained=True but no checkpoint (.pt) file found in '{local_dir}'. "
|
|
138
|
+
f"Pass `checkpoint_file=` explicitly, or use from_pretrained=False to start "
|
|
139
|
+
f"from a randomly-initialized model instead."
|
|
140
|
+
)
|
|
141
|
+
# Imported lazily: qreflex.train pulls in the training loop's deps
|
|
142
|
+
# (DataLoader etc.) that a pure inference user shouldn't need to
|
|
143
|
+
# pay for just to import qreflex.
|
|
144
|
+
from qreflex.train import load_checkpoint
|
|
145
|
+
|
|
146
|
+
self.model, self._experience_encoder, self.bank, self.step = load_checkpoint(
|
|
147
|
+
ckpt_path, device=self.device
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
cfg_path = config_file or _find_file(
|
|
151
|
+
local_dir, _CONFIG_NAMES, "config", (".json",), "config"
|
|
152
|
+
)
|
|
153
|
+
if cfg_path is not None:
|
|
154
|
+
with open(cfg_path) as f:
|
|
155
|
+
cfg = ReFLEXConfig(**json.load(f))
|
|
156
|
+
else:
|
|
157
|
+
cfg = small_reflex_config(vocab_size=self.tokenizer.get_vocab_size())
|
|
158
|
+
self.model = ReFLEX(cfg).to(self.device)
|
|
159
|
+
|
|
160
|
+
self.model.eval()
|
|
161
|
+
|
|
162
|
+
# -- directory / hub resolution ------------------------------------------
|
|
163
|
+
|
|
164
|
+
def _resolve_directory(self, directory: str) -> str:
|
|
165
|
+
if os.path.isdir(directory):
|
|
166
|
+
return os.path.abspath(directory)
|
|
167
|
+
|
|
168
|
+
if _looks_like_hf_repo_id(directory):
|
|
169
|
+
try:
|
|
170
|
+
from huggingface_hub import snapshot_download
|
|
171
|
+
except ImportError as e:
|
|
172
|
+
raise ImportError(
|
|
173
|
+
"huggingface_hub is required to load a reFLEX model straight from the "
|
|
174
|
+
"Hugging Face Hub. Install it with `pip install huggingface_hub`."
|
|
175
|
+
) from e
|
|
176
|
+
return snapshot_download(repo_id=directory, allow_patterns=["*.json", "*.pt", "*.bin"])
|
|
177
|
+
|
|
178
|
+
raise FileNotFoundError(
|
|
179
|
+
f"'{directory}' is neither an existing local directory nor a Hugging Face Hub "
|
|
180
|
+
f"repo id (e.g. 'YourUser/your-reflex-model'). Point `directory` at a local "
|
|
181
|
+
f"folder or a public/private Hub repo."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# -- generation ------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
def _encode_prompt(self, prompt: str) -> torch.Tensor:
|
|
187
|
+
ids = [self.model.cfg.bos_token_id] + self.tokenizer.encode(prompt).ids
|
|
188
|
+
if len(ids) > self.model.cfg.max_seq_len:
|
|
189
|
+
ids = ids[-self.model.cfg.max_seq_len:] # keep the most recent context
|
|
190
|
+
return torch.tensor([ids], dtype=torch.long, device=self.device)
|
|
191
|
+
|
|
192
|
+
@staticmethod
|
|
193
|
+
def _sample_next(logits: torch.Tensor, temperature: float, top_k: Optional[int]) -> torch.Tensor:
|
|
194
|
+
if temperature <= 0:
|
|
195
|
+
return logits.argmax(dim=-1, keepdim=True)
|
|
196
|
+
logits = logits / temperature
|
|
197
|
+
if top_k is not None:
|
|
198
|
+
k = min(top_k, logits.size(-1))
|
|
199
|
+
values, _ = torch.topk(logits, k)
|
|
200
|
+
threshold = values[:, -1].unsqueeze(-1)
|
|
201
|
+
logits = logits.masked_fill(logits < threshold, float("-inf"))
|
|
202
|
+
probs = F.softmax(logits, dim=-1)
|
|
203
|
+
return torch.multinomial(probs, num_samples=1)
|
|
204
|
+
|
|
205
|
+
def generate(
|
|
206
|
+
self,
|
|
207
|
+
prompt: str,
|
|
208
|
+
max_new_tokens: int = 64,
|
|
209
|
+
temperature: float = 0.8,
|
|
210
|
+
top_k: int = 50,
|
|
211
|
+
use_experience: Optional[bool] = None,
|
|
212
|
+
stream: bool = False,
|
|
213
|
+
stop: Optional[Union[str, list]] = None,
|
|
214
|
+
) -> Union[str, Generator[str, None, None]]:
|
|
215
|
+
"""Generate a continuation for `prompt`.
|
|
216
|
+
|
|
217
|
+
stream=False (default): returns the full generated text as one string.
|
|
218
|
+
stream=True: returns a generator yielding text chunks as they're produced.
|
|
219
|
+
|
|
220
|
+
stop: one string, or a list of strings, that ends generation as soon
|
|
221
|
+
as any of them appears in the output. The stop string itself is cut
|
|
222
|
+
from the returned/streamed text (e.g. stop="\\nA:" on a model that
|
|
223
|
+
drifts into writing the next turn will give you just that turn's
|
|
224
|
+
reply, with "\\nA:" and everything after it removed).
|
|
225
|
+
"""
|
|
226
|
+
exp = self.use_experience if use_experience is None else use_experience
|
|
227
|
+
if stop is None:
|
|
228
|
+
stop_strs = None
|
|
229
|
+
elif isinstance(stop, str):
|
|
230
|
+
stop_strs = [stop]
|
|
231
|
+
else:
|
|
232
|
+
stop_strs = list(stop)
|
|
233
|
+
|
|
234
|
+
gen = self._generate(prompt, max_new_tokens, temperature, top_k, exp, stop_strs)
|
|
235
|
+
if stream:
|
|
236
|
+
return gen
|
|
237
|
+
return "".join(gen)
|
|
238
|
+
|
|
239
|
+
@torch.no_grad()
|
|
240
|
+
def _generate(
|
|
241
|
+
self, prompt, max_new_tokens, temperature, top_k, use_experience, stop_strs
|
|
242
|
+
) -> Generator[str, None, None]:
|
|
243
|
+
self.model.eval()
|
|
244
|
+
tokens = self._encode_prompt(prompt)
|
|
245
|
+
prompt_len = tokens.size(1)
|
|
246
|
+
eos_id = self.model.cfg.eos_token_id
|
|
247
|
+
emitted = "" # text already released to the caller so far
|
|
248
|
+
|
|
249
|
+
# A stop string can straddle a token boundary (e.g. "A" comes out one
|
|
250
|
+
# step, ":" the next). If we released "A" the instant it appeared,
|
|
251
|
+
# we couldn't take it back once "A:" completes the match one step
|
|
252
|
+
# later. So once text could still be the start of a stop string, we
|
|
253
|
+
# withhold up to (longest stop string - 1) trailing characters until
|
|
254
|
+
# either the match completes (cut it, done) or a later character
|
|
255
|
+
# rules it out (release it, it was never part of a stop string).
|
|
256
|
+
hold_back = (max(len(s) for s in stop_strs) - 1) if stop_strs else 0
|
|
257
|
+
|
|
258
|
+
for _ in range(max_new_tokens):
|
|
259
|
+
context = tokens[:, -self.model.cfg.max_seq_len:]
|
|
260
|
+
output = self.model(
|
|
261
|
+
context,
|
|
262
|
+
experience_bank=self.bank if use_experience else None,
|
|
263
|
+
use_experience=use_experience,
|
|
264
|
+
)
|
|
265
|
+
next_token = self._sample_next(output["logits"][:, -1], temperature, top_k)
|
|
266
|
+
tokens = torch.cat([tokens, next_token], dim=1)
|
|
267
|
+
|
|
268
|
+
if next_token.item() == eos_id:
|
|
269
|
+
break
|
|
270
|
+
|
|
271
|
+
# Re-decode the whole generated span each step rather than each
|
|
272
|
+
# token id in isolation — some merges only resolve to the right
|
|
273
|
+
# text once neighboring tokens are known.
|
|
274
|
+
full_text = self.tokenizer.decode(tokens[0, prompt_len:].tolist())
|
|
275
|
+
|
|
276
|
+
if stop_strs:
|
|
277
|
+
hits = [i for i in (full_text.find(s) for s in stop_strs) if i != -1]
|
|
278
|
+
if hits:
|
|
279
|
+
cut_at = min(hits)
|
|
280
|
+
delta = full_text[len(emitted):cut_at]
|
|
281
|
+
if delta:
|
|
282
|
+
yield delta
|
|
283
|
+
return # stop string reached — it and everything after is discarded
|
|
284
|
+
|
|
285
|
+
safe_upto = max(0, len(full_text) - hold_back)
|
|
286
|
+
delta = full_text[len(emitted):safe_upto]
|
|
287
|
+
if delta:
|
|
288
|
+
emitted = full_text[:safe_upto]
|
|
289
|
+
yield delta
|
|
290
|
+
else:
|
|
291
|
+
delta = full_text[len(emitted):]
|
|
292
|
+
if delta:
|
|
293
|
+
emitted = full_text
|
|
294
|
+
yield delta
|
|
295
|
+
|
|
296
|
+
# Generation ended (eos or max_new_tokens) without ever completing a
|
|
297
|
+
# stop-string match — release whatever was withheld as a possible
|
|
298
|
+
# prefix, since we now know for certain it wasn't one.
|
|
299
|
+
if stop_strs:
|
|
300
|
+
final_text = self.tokenizer.decode(tokens[0, prompt_len:].tolist())
|
|
301
|
+
delta = final_text[len(emitted):]
|
|
302
|
+
if delta:
|
|
303
|
+
yield delta
|
|
304
|
+
|
|
305
|
+
def __repr__(self) -> str:
|
|
306
|
+
n_params = sum(p.numel() for p in self.model.parameters())
|
|
307
|
+
return (
|
|
308
|
+
f"reFLEX(directory={self.directory!r}, from_pretrained={self.from_pretrained}, "
|
|
309
|
+
f"device={self.device!r}, params={n_params:,}, step={self.step}, "
|
|
310
|
+
f"bank_size={len(self.bank) if self.bank else 0})"
|
|
311
|
+
)
|
qreflex/data.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterator, List, Optional
|
|
7
|
+
|
|
8
|
+
import torch
|
|
9
|
+
from torch.utils.data import Dataset, DataLoader
|
|
10
|
+
from tokenizers import Tokenizer, models, pre_tokenizers, trainers, decoders
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
SPECIAL_TOKENS = ["<pad>", "<bos>", "<eos>", "<unk>"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def train_tokenizer(
|
|
17
|
+
text_files: List[str],
|
|
18
|
+
vocab_size: int,
|
|
19
|
+
save_path: str,
|
|
20
|
+
) -> Tokenizer:
|
|
21
|
+
tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
|
|
22
|
+
tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
|
|
23
|
+
tokenizer.decoder = decoders.ByteLevel()
|
|
24
|
+
|
|
25
|
+
trainer = trainers.BpeTrainer(
|
|
26
|
+
vocab_size=vocab_size,
|
|
27
|
+
special_tokens=SPECIAL_TOKENS,
|
|
28
|
+
show_progress=True,
|
|
29
|
+
)
|
|
30
|
+
tokenizer.train(text_files, trainer)
|
|
31
|
+
|
|
32
|
+
os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
|
|
33
|
+
tokenizer.save(save_path)
|
|
34
|
+
return tokenizer
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_tokenizer(path: str) -> Tokenizer:
|
|
38
|
+
return Tokenizer.from_file(path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PackedExample:
|
|
43
|
+
input_ids: List[int]
|
|
44
|
+
labels: List[int]
|
|
45
|
+
experience_tokens: List[int]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PackedTextDataset(Dataset):
|
|
49
|
+
"""
|
|
50
|
+
Tokenizes a list of raw text documents, packs them into fixed-length
|
|
51
|
+
sequences for LM training, and separately produces an `experience_tokens`
|
|
52
|
+
field per example: the SAME window of tokens (not shifted), used as the
|
|
53
|
+
input to the ExperienceEncoder for that example.
|
|
54
|
+
|
|
55
|
+
Using the example's own tokens as its experience-encoder input, paired
|
|
56
|
+
with the query built from a prefix of that same example inside the model,
|
|
57
|
+
is what makes the contrastive retrieval loss meaningful: the model learns
|
|
58
|
+
to build queries that point at keys describing the content actually
|
|
59
|
+
relevant to it, rather than being trained to point at a fixed lookup
|
|
60
|
+
that has no relation to the current input.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
texts: List[str],
|
|
66
|
+
tokenizer: Tokenizer,
|
|
67
|
+
seq_len: int,
|
|
68
|
+
pad_token_id: int = 0,
|
|
69
|
+
bos_token_id: int = 1,
|
|
70
|
+
eos_token_id: int = 2,
|
|
71
|
+
):
|
|
72
|
+
self.tokenizer = tokenizer
|
|
73
|
+
self.seq_len = seq_len
|
|
74
|
+
self.pad_token_id = pad_token_id
|
|
75
|
+
self.bos_token_id = bos_token_id
|
|
76
|
+
self.eos_token_id = eos_token_id
|
|
77
|
+
|
|
78
|
+
self.examples: List[PackedExample] = []
|
|
79
|
+
self._pack(texts)
|
|
80
|
+
|
|
81
|
+
def _pack(self, texts: List[str]):
|
|
82
|
+
buffer: List[int] = []
|
|
83
|
+
for text in texts:
|
|
84
|
+
ids = self.tokenizer.encode(text).ids
|
|
85
|
+
buffer.extend([self.bos_token_id] + ids + [self.eos_token_id])
|
|
86
|
+
|
|
87
|
+
while len(buffer) >= self.seq_len + 1:
|
|
88
|
+
chunk = buffer[: self.seq_len + 1]
|
|
89
|
+
buffer = buffer[self.seq_len:]
|
|
90
|
+
|
|
91
|
+
input_ids = chunk[:-1]
|
|
92
|
+
labels = chunk[1:]
|
|
93
|
+
self.examples.append(
|
|
94
|
+
PackedExample(
|
|
95
|
+
input_ids=input_ids,
|
|
96
|
+
labels=labels,
|
|
97
|
+
experience_tokens=input_ids,
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def __len__(self) -> int:
|
|
102
|
+
return len(self.examples)
|
|
103
|
+
|
|
104
|
+
def __getitem__(self, idx: int):
|
|
105
|
+
ex = self.examples[idx]
|
|
106
|
+
return {
|
|
107
|
+
"input_ids": torch.tensor(ex.input_ids, dtype=torch.long),
|
|
108
|
+
"labels": torch.tensor(ex.labels, dtype=torch.long),
|
|
109
|
+
"experience_tokens": torch.tensor(ex.experience_tokens, dtype=torch.long),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_jsonl_texts(path: str, text_field: str = "text") -> List[str]:
|
|
114
|
+
texts = []
|
|
115
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
116
|
+
for line in f:
|
|
117
|
+
line = line.strip()
|
|
118
|
+
if not line:
|
|
119
|
+
continue
|
|
120
|
+
obj = json.loads(line)
|
|
121
|
+
texts.append(obj[text_field])
|
|
122
|
+
return texts
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def build_dataloader(
|
|
126
|
+
dataset: Dataset,
|
|
127
|
+
batch_size: int,
|
|
128
|
+
shuffle: bool = True,
|
|
129
|
+
num_workers: int = 0,
|
|
130
|
+
) -> DataLoader:
|
|
131
|
+
return DataLoader(
|
|
132
|
+
dataset,
|
|
133
|
+
batch_size=batch_size,
|
|
134
|
+
shuffle=shuffle,
|
|
135
|
+
num_workers=num_workers,
|
|
136
|
+
drop_last=True,
|
|
137
|
+
pin_memory=torch.cuda.is_available(),
|
|
138
|
+
)
|
qreflex/eval.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import torch
|
|
8
|
+
|
|
9
|
+
from qreflex.train import load_checkpoint
|
|
10
|
+
from qreflex.data import load_tokenizer, load_jsonl_texts, PackedTextDataset, build_dataloader
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@torch.no_grad()
|
|
14
|
+
def evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=True, max_batches=None):
|
|
15
|
+
model.eval()
|
|
16
|
+
total_loss = 0.0
|
|
17
|
+
total_tokens = 0
|
|
18
|
+
n_batches = 0
|
|
19
|
+
|
|
20
|
+
for batch in dataloader:
|
|
21
|
+
if max_batches is not None and n_batches >= max_batches:
|
|
22
|
+
break
|
|
23
|
+
tokens = batch["input_ids"].to(device)
|
|
24
|
+
targets = batch["labels"].to(device)
|
|
25
|
+
|
|
26
|
+
out = model(
|
|
27
|
+
tokens,
|
|
28
|
+
targets=targets,
|
|
29
|
+
experience_bank=bank if use_experience else None,
|
|
30
|
+
use_experience=use_experience,
|
|
31
|
+
)
|
|
32
|
+
n_valid = (targets != -100).sum().item()
|
|
33
|
+
total_loss += out["lm_loss"].item() * n_valid
|
|
34
|
+
total_tokens += n_valid
|
|
35
|
+
n_batches += 1
|
|
36
|
+
|
|
37
|
+
avg_loss = total_loss / max(1, total_tokens)
|
|
38
|
+
ppl = math.exp(min(avg_loss, 20))
|
|
39
|
+
return avg_loss, ppl
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@torch.no_grad()
|
|
43
|
+
def evaluate_retrieval_alignment(model, experience_encoder, dataloader, device, max_batches=20):
|
|
44
|
+
"""
|
|
45
|
+
Measures whether the query built for an example is closer to its OWN
|
|
46
|
+
experience key than to other examples' keys in the batch (retrieval
|
|
47
|
+
rank / top-1 accuracy). This is a direct check on whether the
|
|
48
|
+
contrastive retrieval loss actually taught anything, independent of
|
|
49
|
+
downstream LM performance.
|
|
50
|
+
|
|
51
|
+
max_batches=None means no limit, matching evaluate_perplexity's
|
|
52
|
+
contract for the same parameter name.
|
|
53
|
+
"""
|
|
54
|
+
model.eval()
|
|
55
|
+
experience_encoder.eval()
|
|
56
|
+
|
|
57
|
+
correct = 0
|
|
58
|
+
total = 0
|
|
59
|
+
mean_rank = 0.0
|
|
60
|
+
|
|
61
|
+
for i, batch in enumerate(dataloader):
|
|
62
|
+
if max_batches is not None and i >= max_batches:
|
|
63
|
+
break
|
|
64
|
+
tokens = batch["input_ids"].to(device)
|
|
65
|
+
exp_tokens = batch["experience_tokens"].to(device)
|
|
66
|
+
|
|
67
|
+
query, _ = model.build_query(tokens)
|
|
68
|
+
keys, _ = experience_encoder(exp_tokens)
|
|
69
|
+
|
|
70
|
+
sims = query @ keys.T # [B, B]
|
|
71
|
+
ranks = sims.argsort(dim=-1, descending=True)
|
|
72
|
+
labels = torch.arange(tokens.size(0), device=device)
|
|
73
|
+
|
|
74
|
+
top1 = (ranks[:, 0] == labels).sum().item()
|
|
75
|
+
correct += top1
|
|
76
|
+
total += tokens.size(0)
|
|
77
|
+
|
|
78
|
+
for b in range(tokens.size(0)):
|
|
79
|
+
rank_of_true = (ranks[b] == b).nonzero(as_tuple=True)[0].item()
|
|
80
|
+
mean_rank += rank_of_true
|
|
81
|
+
|
|
82
|
+
top1_acc = correct / max(1, total)
|
|
83
|
+
mean_rank = mean_rank / max(1, total)
|
|
84
|
+
return top1_acc, mean_rank
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def main(args):
|
|
88
|
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
|
89
|
+
model, experience_encoder, bank, step = load_checkpoint(args.checkpoint, device=device)
|
|
90
|
+
print(f"loaded checkpoint at step {step}, bank size {len(bank) if bank else 0}")
|
|
91
|
+
|
|
92
|
+
tokenizer = load_tokenizer(args.tokenizer_path)
|
|
93
|
+
texts = load_jsonl_texts(args.eval_data, text_field=args.text_field)
|
|
94
|
+
dataset = PackedTextDataset(texts, tokenizer, seq_len=model.cfg.max_seq_len)
|
|
95
|
+
dataloader = build_dataloader(dataset, batch_size=args.batch_size, shuffle=False)
|
|
96
|
+
|
|
97
|
+
print(f"eval examples: {len(dataset)}")
|
|
98
|
+
|
|
99
|
+
loss_no_exp, ppl_no_exp = evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=False, max_batches=args.max_batches)
|
|
100
|
+
print(f"[no experience] loss={loss_no_exp:.4f} ppl={ppl_no_exp:.2f}")
|
|
101
|
+
|
|
102
|
+
if bank is not None and len(bank) > 0:
|
|
103
|
+
loss_exp, ppl_exp = evaluate_perplexity(model, experience_encoder, bank, dataloader, device, use_experience=True, max_batches=args.max_batches)
|
|
104
|
+
print(f"[with experience] loss={loss_exp:.4f} ppl={ppl_exp:.2f}")
|
|
105
|
+
print(f"delta (negative = experience helps): {loss_exp - loss_no_exp:+.4f}")
|
|
106
|
+
|
|
107
|
+
top1_acc, mean_rank = evaluate_retrieval_alignment(model, experience_encoder, dataloader, device, max_batches=args.max_batches)
|
|
108
|
+
print(f"[retrieval alignment] top-1 self-match accuracy={top1_acc:.4f} mean_rank={mean_rank:.2f}")
|
|
109
|
+
else:
|
|
110
|
+
print("no experience bank in checkpoint; skipping experience-conditioned eval")
|
|
111
|
+
|
|
112
|
+
results = {
|
|
113
|
+
"step": step,
|
|
114
|
+
"loss_no_experience": loss_no_exp,
|
|
115
|
+
"ppl_no_experience": ppl_no_exp,
|
|
116
|
+
}
|
|
117
|
+
if bank is not None and len(bank) > 0:
|
|
118
|
+
results["loss_with_experience"] = loss_exp
|
|
119
|
+
results["ppl_with_experience"] = ppl_exp
|
|
120
|
+
results["retrieval_top1_accuracy"] = top1_acc
|
|
121
|
+
results["retrieval_mean_rank"] = mean_rank
|
|
122
|
+
|
|
123
|
+
if args.output_json:
|
|
124
|
+
with open(args.output_json, "w") as f:
|
|
125
|
+
json.dump(results, f, indent=2)
|
|
126
|
+
print(f"wrote results to {args.output_json}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_arg_parser() -> argparse.ArgumentParser:
|
|
130
|
+
p = argparse.ArgumentParser(description="Evaluate a reFLEX checkpoint")
|
|
131
|
+
p.add_argument("--checkpoint", type=str, required=True)
|
|
132
|
+
p.add_argument("--tokenizer_path", type=str, required=True)
|
|
133
|
+
p.add_argument("--eval_data", type=str, required=True)
|
|
134
|
+
p.add_argument("--text_field", type=str, default="text")
|
|
135
|
+
p.add_argument("--batch_size", type=int, default=16)
|
|
136
|
+
p.add_argument("--max_batches", type=int, default=None)
|
|
137
|
+
p.add_argument("--output_json", type=str, default=None)
|
|
138
|
+
return p
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def main_cli():
|
|
142
|
+
"""Entry point for the qreflex-eval console script."""
|
|
143
|
+
parser = build_arg_parser()
|
|
144
|
+
args = parser.parse_args()
|
|
145
|
+
main(args)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main_cli()
|