simit 0.1.0__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.
- simit/__init__.py +39 -0
- simit/api.py +258 -0
- simit/backends/__init__.py +112 -0
- simit/backends/bagel.py +359 -0
- simit/backends/base.py +114 -0
- simit/backends/hf.py +336 -0
- simit/backends/imagegen.py +136 -0
- simit/backends/lance.py +338 -0
- simit/backends/mot/engine.py +844 -0
- simit/backends/mot/kernels.py +130 -0
- simit/backends/mot/modeling.py +514 -0
- simit/backends/mot/wan_vae.py +872 -0
- simit/backends/vllm.py +204 -0
- simit/config.py +112 -0
- simit/metrics.py +113 -0
- simit/pipeline/core.py +586 -0
- simit/pipeline/prompts.py +167 -0
- simit/skills/__init__.py +6 -0
- simit/skills/assets/MERMAID_LICENSE +21 -0
- simit/skills/assets/mermaid.min.js +3587 -0
- simit/skills/base.py +172 -0
- simit/skills/builtin.py +614 -0
- simit/skills/helpers.py +197 -0
- simit/skills/pool.py +262 -0
- simit/skills/prompts.py +2374 -0
- simit/skills/renderers.py +1292 -0
- simit/skills/routing.py +89 -0
- simit/skills/sandbox.py +188 -0
- simit/skills/web.py +253 -0
- simit/tune.py +323 -0
- simit/types.py +96 -0
- simit/utils.py +67 -0
- simit-0.1.0.dist-info/METADATA +388 -0
- simit-0.1.0.dist-info/RECORD +38 -0
- simit-0.1.0.dist-info/WHEEL +5 -0
- simit-0.1.0.dist-info/licenses/LICENSE +202 -0
- simit-0.1.0.dist-info/licenses/src/simit/skills/assets/MERMAID_LICENSE +21 -0
- simit-0.1.0.dist-info/top_level.txt +1 -0
simit/tune.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Hyperparameter search for adaptive budget allocation + difficulty filtering.
|
|
2
|
+
|
|
3
|
+
Following Appendix G, synthesis runs once: for every validation query we
|
|
4
|
+
cache its zero-shot answer and confidence p0 plus K_max verified candidates
|
|
5
|
+
with their confidences. A trial then only re-selects demonstrations from the
|
|
6
|
+
cache (the first K*(p0) candidates inside the confidence band) and answers.
|
|
7
|
+
Answers are memoized per (query, selected subset) -- at most 2^K_max per
|
|
8
|
+
query -- so later trials are nearly free.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import pickle
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Callable, Optional, Sequence, Union
|
|
22
|
+
|
|
23
|
+
from PIL import Image
|
|
24
|
+
|
|
25
|
+
from .config import SIMITConfig
|
|
26
|
+
from .metrics import get_metric
|
|
27
|
+
from .types import Demo, ZeroShot
|
|
28
|
+
from .utils import image_hash, text_hash, to_pil
|
|
29
|
+
|
|
30
|
+
log = logging.getLogger("simit")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class TuneResult:
|
|
35
|
+
config: SIMITConfig
|
|
36
|
+
best_score: float
|
|
37
|
+
zero_shot_score: float
|
|
38
|
+
best_params: dict
|
|
39
|
+
n_trials: int
|
|
40
|
+
relative_gain: float = 0.0
|
|
41
|
+
mean_candidates: float = 0.0 # candidates synthesized per query by the best config
|
|
42
|
+
mean_demos: float = 0.0 # demonstrations shown per query by the best config
|
|
43
|
+
runtime_ratio: Optional[float] = None # estimated batched runtime / zero-shot (with max_runtime_ratio)
|
|
44
|
+
history: list = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
def __repr__(self):
|
|
47
|
+
rt = f", runtime≈{self.runtime_ratio:.1f}x" if self.runtime_ratio is not None else ""
|
|
48
|
+
return (f"TuneResult(best={self.best_score:.4f}, zero_shot={self.zero_shot_score:.4f}, "
|
|
49
|
+
f"gain={100 * self.relative_gain:+.2f}%, candidates/query={self.mean_candidates:.2f}, "
|
|
50
|
+
f"demos/query={self.mean_demos:.2f}{rt}, params={self.best_params})")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _items(val_set) -> tuple[list[tuple[Image.Image, str, list[str]]], list]:
|
|
54
|
+
"""Validation examples, plus each one's metric override (None: the global one)."""
|
|
55
|
+
out, metrics = [], []
|
|
56
|
+
for ex in val_set:
|
|
57
|
+
metric = None
|
|
58
|
+
if isinstance(ex, dict):
|
|
59
|
+
image = ex.get("image")
|
|
60
|
+
question = ex.get("question") or ex.get("prompt") or ex.get("text")
|
|
61
|
+
answers = ex.get("answers", ex.get("answer"))
|
|
62
|
+
metric = ex.get("metric")
|
|
63
|
+
else:
|
|
64
|
+
image, question, answers = ex
|
|
65
|
+
if answers is None:
|
|
66
|
+
raise ValueError("every validation example needs an answer")
|
|
67
|
+
answers = [answers] if isinstance(answers, str) else [str(a) for a in answers]
|
|
68
|
+
out.append((to_pil(image), str(question), answers))
|
|
69
|
+
metrics.append(get_metric(metric) if metric is not None else None)
|
|
70
|
+
return out, metrics
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class _Entry:
|
|
75
|
+
zero_shot: ZeroShot
|
|
76
|
+
candidates: list # Demo objects, in production order
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cache_key(image, question, sim_id) -> str:
|
|
80
|
+
return text_hash(image_hash(image), question, sim_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _dump(entry: _Entry) -> bytes:
|
|
84
|
+
cands = []
|
|
85
|
+
for d in entry.candidates:
|
|
86
|
+
buf = io.BytesIO()
|
|
87
|
+
d.image.save(buf, format="PNG")
|
|
88
|
+
cands.append({**d.to_dict(), "image": buf.getvalue()})
|
|
89
|
+
return pickle.dumps({"zero_shot": entry.zero_shot, "candidates": cands})
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _load(data: bytes) -> _Entry:
|
|
93
|
+
obj = pickle.loads(data)
|
|
94
|
+
cands = []
|
|
95
|
+
for c in obj["candidates"]:
|
|
96
|
+
img = Image.open(io.BytesIO(c.pop("image")))
|
|
97
|
+
img.load()
|
|
98
|
+
cands.append(Demo(image=img, **c))
|
|
99
|
+
return _Entry(obj["zero_shot"], cands)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def select(cfg: SIMITConfig, entry: _Entry) -> tuple[int, ...]:
|
|
103
|
+
"""Indices of the demonstrations SIMIT would use (ABA + DF online rule)."""
|
|
104
|
+
k = cfg.budget(entry.zero_shot.confidence)
|
|
105
|
+
if k <= 0:
|
|
106
|
+
return ()
|
|
107
|
+
kept = [i for i, d in enumerate(entry.candidates[:cfg.k_max]) if cfg.in_band(d.confidence)]
|
|
108
|
+
return tuple(kept[:k])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def synthesis_cost(cfg: SIMITConfig, entry: _Entry) -> int:
|
|
112
|
+
"""Candidates the online rule synthesizes for this query: it stops once
|
|
113
|
+
K*(p0) candidates passed DF, or after K_max candidates."""
|
|
114
|
+
k = cfg.budget(entry.zero_shot.confidence)
|
|
115
|
+
if k <= 0:
|
|
116
|
+
return 0
|
|
117
|
+
cands = entry.candidates[:cfg.k_max]
|
|
118
|
+
passed = 0
|
|
119
|
+
for i, d in enumerate(cands):
|
|
120
|
+
passed += cfg.in_band(d.confidence)
|
|
121
|
+
if passed >= k:
|
|
122
|
+
return i + 1
|
|
123
|
+
return cfg.k_max
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def reachable_subsets(entry: _Entry, k_max_choices: Sequence[int]) -> set:
|
|
127
|
+
"""Every demo subset ``select`` can return for this query under some
|
|
128
|
+
configuration: the DF band keeps the candidates whose confidence lies in an
|
|
129
|
+
interval, and ABA takes the first k of them."""
|
|
130
|
+
out = set()
|
|
131
|
+
for km in k_max_choices:
|
|
132
|
+
cands = entry.candidates[:km]
|
|
133
|
+
confs = sorted({d.confidence for d in cands if d.confidence is not None})
|
|
134
|
+
for a, lo in enumerate(confs):
|
|
135
|
+
for hi in confs[a:]:
|
|
136
|
+
kept = [i for i, d in enumerate(cands) if d.confidence is not None and lo <= d.confidence <= hi]
|
|
137
|
+
out.update(tuple(kept[:k]) for k in range(1, km + 1))
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_tune(sim, val_set, metric: Union[str, Callable] = "exact_match", n_trials: int = 100,
|
|
142
|
+
k_max_choices: Optional[Sequence[int]] = None, cache_dir=None, seed: int = 0,
|
|
143
|
+
concurrency: int = 16, max_new_tokens: Optional[int] = None, apply: bool = True,
|
|
144
|
+
max_candidates: Optional[float] = None, max_runtime_ratio: Optional[float] = None) -> TuneResult:
|
|
145
|
+
import optuna
|
|
146
|
+
|
|
147
|
+
items, item_metrics = _items(val_set)
|
|
148
|
+
default_fn = get_metric(metric)
|
|
149
|
+
|
|
150
|
+
def score(i: int, prediction: str) -> float: # per-example metric for mixed validation sets
|
|
151
|
+
return (item_metrics[i] or default_fn)(prediction, items[i][2])
|
|
152
|
+
base_cfg: SIMITConfig = sim.config
|
|
153
|
+
choices = sorted(set(k_max_choices or [base_cfg.k_max]))
|
|
154
|
+
k_cache = max(choices)
|
|
155
|
+
fill_cfg = base_cfg.replace(k_max=k_cache)
|
|
156
|
+
mnt = max_new_tokens or base_cfg.answer_max_new_tokens
|
|
157
|
+
pipe = sim._pipe
|
|
158
|
+
cache_dir = Path(cache_dir) if cache_dir else None
|
|
159
|
+
if cache_dir:
|
|
160
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
sim_id = text_hash(sim.model_id, str(k_cache), str(mnt), str(fill_cfg.verify), str(fill_cfg.diversity_prompt),
|
|
162
|
+
str(fill_cfg.use_skills))
|
|
163
|
+
|
|
164
|
+
# ---- 0) batched zero-shot time per query: the unit of max_runtime_ratio
|
|
165
|
+
zs_list, t_zs = None, None
|
|
166
|
+
if max_runtime_ratio is not None:
|
|
167
|
+
async def zs_all():
|
|
168
|
+
return await asyncio.gather(*[pipe.zero_shot(img, q, mnt) for img, q, _ in items])
|
|
169
|
+
sim._run(pipe.zero_shot(items[0][0], items[0][1], mnt)) # warm-up (graph capture, kernel compiles)
|
|
170
|
+
t0 = time.perf_counter()
|
|
171
|
+
zs_list = sim._run(zs_all())
|
|
172
|
+
t_zs = (time.perf_counter() - t0) / len(items)
|
|
173
|
+
|
|
174
|
+
# ---- 1) synthesis cache: zero-shot + K_max candidates per query
|
|
175
|
+
entries: list[Optional[_Entry]] = [None] * len(items)
|
|
176
|
+
todo = []
|
|
177
|
+
for i, (img, q, _) in enumerate(items):
|
|
178
|
+
f = cache_dir / f"{_cache_key(img, q, sim_id)}.pkl" if cache_dir else None
|
|
179
|
+
if f is not None and f.exists():
|
|
180
|
+
entries[i] = _load(f.read_bytes())
|
|
181
|
+
else:
|
|
182
|
+
todo.append(i)
|
|
183
|
+
|
|
184
|
+
async def build():
|
|
185
|
+
sem = asyncio.Semaphore(concurrency)
|
|
186
|
+
old = pipe.config
|
|
187
|
+
pipe.config = fill_cfg
|
|
188
|
+
done = 0
|
|
189
|
+
|
|
190
|
+
async def one(i):
|
|
191
|
+
nonlocal done
|
|
192
|
+
img, q, _ = items[i]
|
|
193
|
+
async with sem:
|
|
194
|
+
res = await pipe.imagine(img, q, fill=True, max_new_tokens=mnt,
|
|
195
|
+
zero_shot=zs_list[i] if zs_list else None)
|
|
196
|
+
entries[i] = _Entry(res.zero_shot, res.candidates)
|
|
197
|
+
if cache_dir:
|
|
198
|
+
(cache_dir / f"{_cache_key(img, q, sim_id)}.pkl").write_bytes(_dump(entries[i]))
|
|
199
|
+
done += 1
|
|
200
|
+
log.info("tune cache %d/%d (query %d: %d candidates)", done, len(todo), i, len(res.candidates))
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
await asyncio.gather(*[one(i) for i in todo])
|
|
204
|
+
finally:
|
|
205
|
+
pipe.config = old
|
|
206
|
+
|
|
207
|
+
# Batched synthesis seconds per candidate, kept next to the cache for later runs.
|
|
208
|
+
cost_file = cache_dir / "cost.json" if cache_dir else None
|
|
209
|
+
spent = json.loads(cost_file.read_text()) if cost_file and cost_file.exists() else {"seconds": 0.0, "candidates": 0}
|
|
210
|
+
if todo:
|
|
211
|
+
t0 = time.perf_counter()
|
|
212
|
+
sim._run(build())
|
|
213
|
+
spent["seconds"] += time.perf_counter() - t0
|
|
214
|
+
spent["candidates"] += sum(len(entries[i].candidates) for i in todo)
|
|
215
|
+
if cost_file:
|
|
216
|
+
cost_file.write_text(json.dumps(spent))
|
|
217
|
+
sec_per_candidate = spent["seconds"] / spent["candidates"] if spent["candidates"] else None
|
|
218
|
+
if max_runtime_ratio is not None and sec_per_candidate is None:
|
|
219
|
+
log.warning("max_runtime_ratio ignored: no synthesis timing (cache built without it)")
|
|
220
|
+
|
|
221
|
+
zs_scores = [score(i, e.zero_shot.answer) for i, e in enumerate(entries)]
|
|
222
|
+
zero_shot_score = sum(zs_scores) / len(items)
|
|
223
|
+
|
|
224
|
+
# ---- 2) trials over cached candidates
|
|
225
|
+
memo: dict[tuple[int, tuple], float] = {}
|
|
226
|
+
answered = {"seconds": 0.0, "n": 0}
|
|
227
|
+
|
|
228
|
+
def answer(pairs):
|
|
229
|
+
async def answer_all():
|
|
230
|
+
sem = asyncio.Semaphore(concurrency * 4)
|
|
231
|
+
|
|
232
|
+
async def one(i, sel):
|
|
233
|
+
img, q, _ = items[i]
|
|
234
|
+
demos = [entries[i].candidates[j] for j in sel]
|
|
235
|
+
async with sem:
|
|
236
|
+
return await pipe.answer(img, q, demos, mnt)
|
|
237
|
+
return await asyncio.gather(*[one(i, sel) for i, sel in pairs])
|
|
238
|
+
|
|
239
|
+
t0 = time.perf_counter()
|
|
240
|
+
answers = sim._run(answer_all())
|
|
241
|
+
answered["seconds"] += time.perf_counter() - t0
|
|
242
|
+
answered["n"] += len(pairs)
|
|
243
|
+
for (i, sel), ans in zip(pairs, answers):
|
|
244
|
+
memo[(i, sel)] = score(i, ans)
|
|
245
|
+
|
|
246
|
+
# Only a few subsets per query are reachable: answer them all in one
|
|
247
|
+
# batch, so trials become lookups (unless that set is unusually large).
|
|
248
|
+
reach = [(i, sel) for i, e in enumerate(entries) for sel in sorted(reachable_subsets(e, choices))]
|
|
249
|
+
if len(reach) <= 32 * len(entries):
|
|
250
|
+
answer(reach)
|
|
251
|
+
|
|
252
|
+
def evaluate(cfg: SIMITConfig) -> float:
|
|
253
|
+
keys = [(i, select(cfg, e)) for i, e in enumerate(entries)]
|
|
254
|
+
missing = list(dict.fromkeys(k for k in keys if k[1] and k not in memo))
|
|
255
|
+
if missing:
|
|
256
|
+
answer(missing)
|
|
257
|
+
total = 0.0
|
|
258
|
+
for i, sel in keys:
|
|
259
|
+
total += memo[(i, sel)] if sel else zs_scores[i]
|
|
260
|
+
return total / len(items)
|
|
261
|
+
|
|
262
|
+
def sample(trial) -> SIMITConfig:
|
|
263
|
+
eps = trial.suggest_float("epsilon", 0.0, 0.5)
|
|
264
|
+
a0 = trial.suggest_float("A0", 0.05, 0.6)
|
|
265
|
+
a1 = trial.suggest_float("A1", a0, 0.9)
|
|
266
|
+
b = trial.suggest_float("B", 0.0, 0.5)
|
|
267
|
+
tl = trial.suggest_float("t_low", 0.0, 0.5)
|
|
268
|
+
th = trial.suggest_float("t_high", tl, 1.0)
|
|
269
|
+
km = trial.suggest_categorical("k_max", choices) if len(choices) > 1 else choices[0]
|
|
270
|
+
return base_cfg.replace(epsilon=eps, A0=a0, A1=a1, B=b, t_low=tl, t_high=th, k_max=km,
|
|
271
|
+
use_aba=True, use_df=True)
|
|
272
|
+
|
|
273
|
+
optuna.logging.set_verbosity(optuna.logging.WARNING)
|
|
274
|
+
study = optuna.create_study(direction="maximize", sampler=optuna.samplers.TPESampler(seed=seed))
|
|
275
|
+
start = {k: getattr(base_cfg, k) for k in ("epsilon", "A0", "A1", "B", "t_low", "t_high")}
|
|
276
|
+
if len(choices) > 1:
|
|
277
|
+
start["k_max"] = base_cfg.k_max if base_cfg.k_max in choices else choices[-1]
|
|
278
|
+
study.enqueue_trial(start)
|
|
279
|
+
study.enqueue_trial({**start, "epsilon": 0.0, "t_low": 0.0, "t_high": 1.0}) # plain ICL with K_max demos
|
|
280
|
+
history = []
|
|
281
|
+
|
|
282
|
+
def cost(cfg) -> float:
|
|
283
|
+
return sum(synthesis_cost(cfg, e) for e in entries) / len(entries)
|
|
284
|
+
|
|
285
|
+
def runtime_ratio(cfg) -> Optional[float]:
|
|
286
|
+
"""Zero-shot + synthesis + ICL answers, relative to zero-shot (batched)."""
|
|
287
|
+
if t_zs is None or sec_per_candidate is None:
|
|
288
|
+
return None
|
|
289
|
+
with_demos = sum(bool(select(cfg, e)) for e in entries) / len(entries)
|
|
290
|
+
per_answer = answered["seconds"] / answered["n"] if answered["n"] else t_zs
|
|
291
|
+
return 1 + (sec_per_candidate * cost(cfg) + per_answer * with_demos) / t_zs
|
|
292
|
+
|
|
293
|
+
def objective(trial):
|
|
294
|
+
cfg = sample(trial)
|
|
295
|
+
c = cost(cfg)
|
|
296
|
+
r = runtime_ratio(cfg) if max_runtime_ratio is not None else None
|
|
297
|
+
trial.set_user_attr("mean_candidates", c)
|
|
298
|
+
over = 0.0 # infeasible: over the synthesis / runtime budget
|
|
299
|
+
if max_candidates is not None and c > max_candidates:
|
|
300
|
+
over += c - max_candidates
|
|
301
|
+
if r is not None and r > max_runtime_ratio:
|
|
302
|
+
over += (r - max_runtime_ratio) / max_runtime_ratio
|
|
303
|
+
if over > 0:
|
|
304
|
+
value = -1.0 - over
|
|
305
|
+
else:
|
|
306
|
+
value = evaluate(cfg)
|
|
307
|
+
history.append((trial.params, value, c))
|
|
308
|
+
return value
|
|
309
|
+
|
|
310
|
+
study.optimize(objective, n_trials=n_trials)
|
|
311
|
+
best_cfg = sample(optuna.trial.FixedTrial(study.best_params))
|
|
312
|
+
best_score, best_params = study.best_value, study.best_params
|
|
313
|
+
if best_score <= zero_shot_score: # nothing (within budget) beats zero-shot: never imagine
|
|
314
|
+
best_cfg = base_cfg.replace(epsilon=1.0, use_aba=True)
|
|
315
|
+
best_score, best_params = zero_shot_score, {"epsilon": 1.0}
|
|
316
|
+
rel = (best_score - zero_shot_score) / zero_shot_score if zero_shot_score > 0 else 0.0
|
|
317
|
+
if apply:
|
|
318
|
+
sim.config = best_cfg
|
|
319
|
+
demos = sum(len(select(best_cfg, e)) for e in entries) / len(entries)
|
|
320
|
+
return TuneResult(config=best_cfg, best_score=best_score, zero_shot_score=zero_shot_score,
|
|
321
|
+
best_params=best_params, n_trials=n_trials, relative_gain=rel,
|
|
322
|
+
mean_candidates=cost(best_cfg), mean_demos=demos, runtime_ratio=runtime_ratio(best_cfg),
|
|
323
|
+
history=history)
|
simit/types.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Data types returned by SIMIT."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from PIL import Image
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Demo:
|
|
15
|
+
"""One imagined (image, question, answer) demonstration."""
|
|
16
|
+
|
|
17
|
+
image: Image.Image
|
|
18
|
+
question: str
|
|
19
|
+
answer: str
|
|
20
|
+
description: str = "" # the textual image description it was realized from
|
|
21
|
+
skill: str = "" # skill that realized the image ("natural", "figure", ...)
|
|
22
|
+
confidence: Optional[float] = None # model's teacher-forced confidence in ``answer``
|
|
23
|
+
verify_score: Optional[int] = None # critic score of the image
|
|
24
|
+
spec: str = "" # skill specification the image was rendered from
|
|
25
|
+
|
|
26
|
+
def to_dict(self, image_path: Optional[str] = None) -> dict:
|
|
27
|
+
return {"image": image_path, "question": self.question, "answer": self.answer,
|
|
28
|
+
"description": self.description, "skill": self.skill, "confidence": self.confidence,
|
|
29
|
+
"verify_score": self.verify_score, "spec": self.spec}
|
|
30
|
+
|
|
31
|
+
def _repr_html_(self): # notebooks
|
|
32
|
+
import base64
|
|
33
|
+
import io
|
|
34
|
+
buf = io.BytesIO()
|
|
35
|
+
img = self.image.copy()
|
|
36
|
+
img.thumbnail((256, 256))
|
|
37
|
+
img.save(buf, format="PNG")
|
|
38
|
+
b64 = base64.b64encode(buf.getvalue()).decode()
|
|
39
|
+
return (f'<div><img src="data:image/png;base64,{b64}"/><br><b>Q:</b> {self.question}<br>'
|
|
40
|
+
f'<b>A:</b> {self.answer} <i>({self.skill})</i></div>')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ZeroShot:
|
|
45
|
+
answer: str
|
|
46
|
+
confidence: Optional[float] # geometric-mean token probability (p0)
|
|
47
|
+
token_logprobs: Sequence[float] = ()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Imagination:
|
|
52
|
+
"""Everything ``imagine`` computed for one query."""
|
|
53
|
+
|
|
54
|
+
demos: list[Demo]
|
|
55
|
+
zero_shot: ZeroShot
|
|
56
|
+
budget: int # K*(q) chosen by adaptive budget allocation
|
|
57
|
+
candidates: list[Demo] = field(default_factory=list) # verified candidates before filtering
|
|
58
|
+
stats: dict = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_demos(demos: Sequence[Demo], directory, prefix: str = "demo") -> Path:
|
|
62
|
+
"""Write demos as PNGs plus ``demos.jsonl`` (LLaVA-style ``conversations``
|
|
63
|
+
included), e.g. to fine-tune on them later (SIMIT-FT)."""
|
|
64
|
+
directory = Path(directory)
|
|
65
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
path = directory / "demos.jsonl"
|
|
67
|
+
start = 0 # appending: continue numbering so earlier records keep their images
|
|
68
|
+
while (directory / f"{prefix}_{start}.png").exists():
|
|
69
|
+
start += 1
|
|
70
|
+
lines = []
|
|
71
|
+
for i, d in enumerate(demos, start):
|
|
72
|
+
name = f"{prefix}_{i}.png"
|
|
73
|
+
d.image.save(directory / name)
|
|
74
|
+
rec = d.to_dict(image_path=name)
|
|
75
|
+
rec["conversations"] = [{"from": "human", "value": f"<image>\n{d.question}"},
|
|
76
|
+
{"from": "gpt", "value": d.answer}]
|
|
77
|
+
lines.append(json.dumps(rec, ensure_ascii=False))
|
|
78
|
+
with open(path, "a") as f:
|
|
79
|
+
f.write("\n".join(lines) + ("\n" if lines else ""))
|
|
80
|
+
return path
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_demos(directory) -> list[Demo]:
|
|
84
|
+
directory = Path(directory)
|
|
85
|
+
demos = []
|
|
86
|
+
for line in (directory / "demos.jsonl").read_text().splitlines():
|
|
87
|
+
if not line.strip():
|
|
88
|
+
continue
|
|
89
|
+
rec = json.loads(line)
|
|
90
|
+
img = Image.open(directory / rec["image"])
|
|
91
|
+
img.load()
|
|
92
|
+
demos.append(Demo(image=img, question=rec["question"], answer=rec["answer"],
|
|
93
|
+
description=rec.get("description", ""), skill=rec.get("skill", ""),
|
|
94
|
+
confidence=rec.get("confidence"), verify_score=rec.get("verify_score"),
|
|
95
|
+
spec=rec.get("spec", "")))
|
|
96
|
+
return demos
|
simit/utils.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Small shared helpers: image coercion, hashing, response cleanup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import io
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Union
|
|
10
|
+
|
|
11
|
+
from PIL import Image
|
|
12
|
+
|
|
13
|
+
ImageLike = Union[Image.Image, str, Path, bytes]
|
|
14
|
+
|
|
15
|
+
# Chat special tokens that some backends leave in decoded text.
|
|
16
|
+
_SPECIAL_TOKEN_RE = re.compile(r"<\|im_(?:start|end)\|>|<\|endoftext\|>")
|
|
17
|
+
# CJK ideographs: small models sometimes drift into Chinese mid-output.
|
|
18
|
+
CJK_RE = re.compile(r"[一-鿿]")
|
|
19
|
+
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def to_pil(image: ImageLike) -> Image.Image:
|
|
23
|
+
"""Load an image from a PIL image, path, or raw bytes, flattened to RGB
|
|
24
|
+
(transparent pixels become white, matching BAGEL's ``pil_img2rgb``)."""
|
|
25
|
+
if isinstance(image, (str, Path)):
|
|
26
|
+
image = Image.open(image)
|
|
27
|
+
elif isinstance(image, (bytes, bytearray)):
|
|
28
|
+
image = Image.open(io.BytesIO(image))
|
|
29
|
+
if not isinstance(image, Image.Image):
|
|
30
|
+
raise TypeError(f"expected an image, got {type(image).__name__}")
|
|
31
|
+
if image.mode == "RGBA" or image.info.get("transparency") is not None:
|
|
32
|
+
image = image.convert("RGBA")
|
|
33
|
+
white = Image.new("RGB", image.size, (255, 255, 255))
|
|
34
|
+
white.paste(image, mask=image.split()[3])
|
|
35
|
+
return white
|
|
36
|
+
return image.convert("RGB")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def image_hash(image: Image.Image) -> str:
|
|
40
|
+
h = hashlib.sha1()
|
|
41
|
+
h.update(f"{image.size}{image.mode}".encode())
|
|
42
|
+
h.update(image.tobytes())
|
|
43
|
+
return h.hexdigest()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def text_hash(*parts: str) -> str:
|
|
47
|
+
return hashlib.sha1("\x1f".join(parts).encode()).hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def clean_response(text: str) -> str:
|
|
51
|
+
return _SPECIAL_TOKEN_RE.sub("", text)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def strip_thinking(text: str) -> str:
|
|
55
|
+
"""Drop a leading ``<think>...</think>`` block (or an unterminated one)."""
|
|
56
|
+
text = _THINK_RE.sub("", text)
|
|
57
|
+
if "<think>" in text: # truncated mid-thought: nothing usable after it
|
|
58
|
+
text = text.split("<think>")[0]
|
|
59
|
+
if "</think>" in text: # template already opened the think block
|
|
60
|
+
text = text.split("</think>", 1)[1]
|
|
61
|
+
return text.strip()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def png_bytes(image: Image.Image) -> bytes:
|
|
65
|
+
buf = io.BytesIO()
|
|
66
|
+
image.save(buf, format="PNG")
|
|
67
|
+
return buf.getvalue()
|