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/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()