Skip to content

Commit 20b8869

Browse files
authored
train/sft: add type annotations to all Python scripts (#85)
Add comprehensive Python type annotations across all 18 scripts in train/sft/, improving IDE support and debuggability of raw data dicts. Changes: - Define TypedDict classes for recurring data shapes: ShareGPTMessage, ShareGPTExample, ImageInfo, DatasetInfoEntry, RetrievalRow, RetrievalHit, SplitStats, EvalResult, EMCharMetrics, JudgeMetrics - Annotate all function/method signatures (params + return types) - Type key local variables (accumulators, containers) where not inferrable from context Bug fixes discovered during analysis: - download_tiles.py: collect_paths() had return annotation set[str] but actually returns dict[str, str | None] — fixed - prepare_sft_data_variable.py L100: removed dead dict comprehension expression whose result was discarded No behavioral changes beyond the dead-code removal. All files pass ruff check, ruff format, and py_compile.
1 parent 3941e72 commit 20b8869

18 files changed

Lines changed: 224 additions & 56 deletions

train/sft/download_tiles.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@ def shard_suffix(p: str) -> str:
3434
return p
3535

3636

37-
def collect_paths(retrieval_dir: Path, splits: list[str]) -> set[str]:
37+
def collect_paths(retrieval_dir: Path, splits: list[str]) -> dict[str, str | None]:
3838
"""Collect every unique absolute path across hit lists + gold suffixes.
3939
4040
Gold paths use the dataset-relative form ('images/shard_.../chunk.png');
4141
hits are absolute '/opt/dlami/nvme/kiwix_tiles/shard_.../chunk.png'.
4242
We normalize everything to shard-suffix for dedup across sources.
43-
Returns set of (shard_suffix_key, preferred_abs_path_for_fetch).
43+
Returns dict mapping shard_suffix_key to preferred_abs_path_for_fetch.
4444
"""
45-
by_suffix = {}
45+
by_suffix: dict[str, str | None] = {}
4646
for split in splits:
4747
p = retrieval_dir / f"{split}.jsonl"
4848
if not p.exists():
@@ -111,7 +111,7 @@ def fetch_tile(
111111
return False, f"{last_err}"
112112

113113

114-
def main():
114+
def main() -> None:
115115
p = argparse.ArgumentParser()
116116
p.add_argument(
117117
"--retrieval-dir",
@@ -151,7 +151,7 @@ def main():
151151
print(f" Unique tile suffixes: {len(by_suffix):,}")
152152

153153
# Split into linkable-from-local vs must-fetch
154-
need_fetch = [] # (suffix, abs_path_for_api)
154+
need_fetch: list[tuple[str, str]] = []
155155
linked = 0
156156
already = 0
157157
for suffix, abs_fetch in by_suffix.items():
@@ -183,7 +183,7 @@ def main():
183183
fail_paths = []
184184
with open(failed_log, "w") as f_fail:
185185

186-
def _work(item):
186+
def _work(item: tuple[str, str]) -> tuple[str, str, bool, str]:
187187
suffix, abs_path = item
188188
dst = mirror / suffix
189189
success, err = fetch_tile(args.api_url, abs_path, dst)

train/sft/eval_baseline.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,37 @@
2020
import sys
2121
from concurrent.futures import ThreadPoolExecutor, as_completed
2222
from pathlib import Path
23+
from typing import TypedDict
2324

2425
import torch
2526
from tqdm import tqdm
2627
from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
2728
from qwen_vl_utils import process_vision_info
2829

2930

31+
class EvalResult(TypedDict, total=False):
32+
query: str
33+
golden: str
34+
predicted: str
35+
chunk_path: str
36+
image_missing: bool
37+
n_images: int
38+
judge_grade: str
39+
judge_correct: bool
40+
41+
42+
class EMCharMetrics(TypedDict):
43+
exact_match: float
44+
char_accuracy: float
45+
scored: int
46+
47+
48+
class JudgeMetrics(TypedDict):
49+
llm_judge_accuracy: float
50+
llm_judge_correct: int
51+
llm_judge_total: int
52+
53+
3054
# Reused from train_contrastors.py — SimpleQA-style grader, returns A/B/C.
3155
_GRADER_TEMPLATE = """Your job is to look at a question, a gold target, and a predicted answer, and then assign a grade of either ["CORRECT", "INCORRECT", "NOT_ATTEMPTED"].
3256
Only semantic meaning matters; capitalization, punctuation, grammar, and order don't matter.
@@ -49,7 +73,7 @@
4973
Just return the letters "A", "B", or "C", with no text around it."""
5074

5175

52-
def _resolve_image_path(ex: dict, images_root: str) -> str:
76+
def _resolve_image_path(ex: dict[str, str], images_root: str) -> str:
5377
"""chunk_path is relative to the dataset root (e.g. images/shard_000/...).
5478
images_root is the directory that contains the `images/` subtree (compressed or original)."""
5579
rel = ex["chunk_path"]
@@ -59,15 +83,15 @@ def _resolve_image_path(ex: dict, images_root: str) -> str:
5983

6084

6185
def run_inference(
62-
model,
63-
processor,
64-
examples,
86+
model: Qwen3VLForConditionalGeneration,
87+
processor: AutoProcessor,
88+
examples: list[dict[str, str]],
6589
images_root: str,
6690
device: str,
6791
desc: str,
6892
max_new_tokens: int = 128,
6993
enable_thinking: bool = False,
70-
):
94+
) -> list[EvalResult]:
7195
"""Run VQA inference on a list of examples; returns list of (golden, predicted) pairs."""
7296
results = []
7397
for ex in tqdm(examples, desc=desc):
@@ -126,7 +150,7 @@ def run_inference(
126150
return results
127151

128152

129-
def compute_em_char(results):
153+
def compute_em_char(results: list[EvalResult]) -> EMCharMetrics:
130154
correct_em = 0
131155
char_correct = 0
132156
char_total = 0
@@ -150,7 +174,9 @@ def compute_em_char(results):
150174
}
151175

152176

153-
def grade_with_gpt(results, model: str, concurrency: int = 16):
177+
def grade_with_gpt(
178+
results: list[EvalResult], model: str, concurrency: int = 16
179+
) -> JudgeMetrics:
154180
"""Grade predictions with GPT-4.1. Returns list of (bool correct, raw grade)."""
155181
from openai import OpenAI
156182

@@ -202,7 +228,7 @@ def _grade(idx, r):
202228
}
203229

204230

205-
def main():
231+
def main() -> None:
206232
p = argparse.ArgumentParser()
207233
p.add_argument("--model", default="Qwen/Qwen3-VL-4B-Instruct")
208234
p.add_argument(

train/sft/eval_multiimage.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,39 @@
1616
import sys
1717
from concurrent.futures import ThreadPoolExecutor, as_completed
1818
from pathlib import Path
19+
from typing import TypedDict
1920

2021
import torch
2122
from tqdm import tqdm
2223
from transformers import Qwen3VLForConditionalGeneration, AutoProcessor
2324
from qwen_vl_utils import process_vision_info
2425

2526

27+
class EvalResult(TypedDict, total=False):
28+
query: str
29+
golden: str
30+
predicted: str
31+
image_missing: bool
32+
missing_paths: list[str]
33+
n_images: int
34+
gold_pos: int | None
35+
gold_in_top6_pos: int | None
36+
judge_grade: str
37+
judge_correct: bool
38+
39+
40+
class EMCharMetrics(TypedDict):
41+
exact_match: float
42+
char_accuracy: float
43+
scored: int
44+
45+
46+
class JudgeMetrics(TypedDict):
47+
llm_judge_accuracy: float
48+
llm_judge_correct: int
49+
llm_judge_total: int
50+
51+
2652
_GRADER_TEMPLATE = """Your job is to look at a question, a gold target, and a predicted answer, and then assign a grade of either ["CORRECT", "INCORRECT", "NOT_ATTEMPTED"].
2753
Only semantic meaning matters; capitalization, punctuation, grammar, and order don't matter.
2854
Hedging and guessing are permissible, provided that the gold target is fully included and the response contains no incorrect information or contradictions.
@@ -49,8 +75,14 @@ def strip_image_tokens(s: str) -> str:
4975

5076

5177
def run_inference(
52-
model, processor, examples, device, desc, max_new_tokens=128, enable_thinking=False
53-
):
78+
model: Qwen3VLForConditionalGeneration,
79+
processor: AutoProcessor,
80+
examples: list[dict[str, object]],
81+
device: str,
82+
desc: str,
83+
max_new_tokens: int = 128,
84+
enable_thinking: bool = False,
85+
) -> list[EvalResult]:
5486
results = []
5587
for ex in tqdm(examples, desc=desc):
5688
images = ex.get("images", [])
@@ -112,7 +144,7 @@ def run_inference(
112144
return results
113145

114146

115-
def compute_em_char(results):
147+
def compute_em_char(results: list[EvalResult]) -> EMCharMetrics:
116148
correct_em = 0
117149
char_correct = 0
118150
char_total = 0
@@ -136,7 +168,9 @@ def compute_em_char(results):
136168
}
137169

138170

139-
def grade_with_gpt(results, model: str, concurrency: int = 16):
171+
def grade_with_gpt(
172+
results: list[EvalResult], model: str, concurrency: int = 16
173+
) -> JudgeMetrics:
140174
from openai import OpenAI
141175

142176
client = OpenAI()
@@ -186,7 +220,7 @@ def _grade(idx, r):
186220
}
187221

188222

189-
def main():
223+
def main() -> None:
190224
p = argparse.ArgumentParser()
191225
p.add_argument("--model", default="Qwen/Qwen3-VL-4B-Instruct")
192226
p.add_argument("--adapter", default=None)

train/sft/fetch_top6_retrieval.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@
2828
import urllib.error
2929
import urllib.request
3030
from pathlib import Path
31+
from typing import TypedDict
32+
33+
34+
class SplitStats(TypedDict):
35+
split: str
36+
total: int
37+
gold_in_top1: int
38+
gold_in_top3: int
39+
gold_in_top6: int
40+
gold_miss: int
3141

3242

3343
def shard_suffix(p: str) -> str:
@@ -40,7 +50,7 @@ def shard_suffix(p: str) -> str:
4050

4151
def search_batch(
4252
api_url: str, queries: list[str], n_docs: int, timeout: int = 300, retries: int = 5
43-
) -> list[dict]:
53+
) -> list[dict[str, object]]:
4454
payload = {"queries": [{"text": q} for q in queries], "n_docs": n_docs}
4555
body = json.dumps(payload).encode()
4656
last_err = None
@@ -72,7 +82,7 @@ def process_split(
7282
api_url: str,
7383
batch_size: int,
7484
n_docs: int,
75-
) -> dict:
85+
) -> SplitStats:
7686
# Resume: count existing lines
7787
existing = 0
7888
if out_path.exists():
@@ -185,7 +195,7 @@ def process_split(
185195
}
186196

187197

188-
def _collect_stats(path: Path) -> dict:
198+
def _collect_stats(path: Path) -> SplitStats:
189199
gold_in_topk = {1: 0, 3: 0, 6: 0}
190200
gold_miss = 0
191201
n = 0
@@ -211,7 +221,7 @@ def _collect_stats(path: Path) -> dict:
211221
}
212222

213223

214-
def main():
224+
def main() -> None:
215225
p = argparse.ArgumentParser()
216226
p.add_argument(
217227
"--dataset-dir",

train/sft/generate_think_traces.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
Write a brief reasoning trace (2-3 sentences) showing how someone would find this answer by examining the screenshot. Mention what specific text/detail they would look for. Be natural and concise. No preamble. Output ONLY the reasoning, nothing else."""
3030

3131

32-
def process_one(client, model, ex):
32+
def process_one(
33+
client: OpenAI, model: str, ex: dict[str, str]
34+
) -> dict[str, str | None]:
3335
try:
3436
resp = client.chat.completions.create(
3537
model=model,
@@ -48,7 +50,7 @@ def process_one(client, model, ex):
4850
return {**ex, "reasoning": None, "_error": str(e)[:200]}
4951

5052

51-
def main():
53+
def main() -> None:
5254
p = argparse.ArgumentParser()
5355
p.add_argument("--input", required=True)
5456
p.add_argument("--output", required=True)

train/sft/generate_think_traces_v2.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ def encode_image(path: str, max_bytes: int = 4_000_000) -> str | None:
5858
return None
5959

6060

61-
def process_one(client, model, ex, image_root):
61+
def process_one(
62+
client: OpenAI, model: str, ex: dict[str, str], image_root: str
63+
) -> dict[str, str | None]:
6264
img_path = os.path.join(image_root, ex["chunk_path"])
6365
img_url = encode_image(img_path) if os.path.exists(img_path) else None
6466
if img_url is None:
@@ -92,7 +94,7 @@ def process_one(client, model, ex, image_root):
9294
return {**ex, "reasoning": None, "_error": str(e)[:200]}
9395

9496

95-
def main():
97+
def main() -> None:
9698
p = argparse.ArgumentParser()
9799
p.add_argument("--input", required=True)
98100
p.add_argument("--output", required=True)

train/sft/generate_think_traces_v3_highdetail.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ def encode_image(path: str, max_bytes: int = 4_000_000) -> str | None:
5858
return None
5959

6060

61-
def process_one(client, model, ex, image_root):
61+
def process_one(
62+
client: OpenAI, model: str, ex: dict[str, str], image_root: str
63+
) -> dict[str, str | None]:
6264
img_path = os.path.join(image_root, ex["chunk_path"])
6365
img_url = encode_image(img_path) if os.path.exists(img_path) else None
6466
if img_url is None:
@@ -92,7 +94,7 @@ def process_one(client, model, ex, image_root):
9294
return {**ex, "reasoning": None, "_error": str(e)[:200]}
9395

9496

95-
def main():
97+
def main() -> None:
9698
p = argparse.ArgumentParser()
9799
p.add_argument("--input", required=True)
98100
p.add_argument("--output", required=True)

train/sft/prepare_mixed_data.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
BASE = "/scratch/users/zwcolin/cxr_embeds/sft_data"
2121

2222

23-
def main():
23+
def main() -> None:
2424
p = argparse.ArgumentParser()
2525
p.add_argument("--output-dir", default=f"{BASE}/compressed_mixed")
2626
p.add_argument("--seed", type=int, default=42)

0 commit comments

Comments
 (0)