File size: 6,149 Bytes
f655146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# finetune/eval_ft.py
"""Stage 5 gate — side-by-side Bengali eval: fine-tuned MiniCPM-V (vLLM) vs the
native base model (the current shipping "Lever #1" path).

CLAUDE.md / finetune/README are explicit: ship the fine-tune ONLY if it clearly
beats the native path in a human read. The train metrics (loss/token_acc) say the
LoRA fit the distilled targets — they do NOT say Bengali quality improved. This
script produces the artifact a Bengali speaker needs to make that call before
FINETUNED_VISION_MODEL is ever set.

Fairness: BOTH paths get the EXACT same app-built Bengali prompt (build_story_prompt)
and the same image — exactly what each would receive in production. The only
difference under test is the model weights.

  native: core.modal_infra.generate_story_remote  → base openbmb/MiniCPM-V-4_5 (Ollama)
  FT:     finetune.serve_vllm.generate_story_ft_remote → merged LoRA (vLLM)

Held-out set: the 61 labelset images that the purity gate rejected, so they were
NEVER trained on, yet are on-distribution. (Override with --images for your own.)

Run:
    uv run modal deploy finetune/serve_vllm.py          # FT server must be live
    uv run python finetune/eval_ft.py --n 10            # 10 held-out images
    uv run python finetune/eval_ft.py --images path/to/dir --n 8 --style রূপকথা
Out:
    finetune/eval_results/ft_vs_native_YYYYMMDD_HHMM.md
"""

import argparse
import base64
import json
import os
import sys
import time
from datetime import datetime
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from core.model_config import ACTIVE_STACK, get_vision_options
from core.modal_infra import generate_story_remote
from core.prompts import STYLES, build_story_prompt
from finetune.serve_vllm import generate_story_ft_remote

TRAIN_JSON = Path("finetune/data/train.json")
LABELSET = Path("finetune/data/labelset")


def held_out_images() -> list[Path]:
    """Labelset images that are NOT in train.json — unseen but on-distribution."""
    trained = {x["image"].split("/")[-1] for x in json.loads(TRAIN_JSON.read_text())}
    imgs = [
        p for p in sorted(LABELSET.glob("*"))
        if p.suffix.lower() in (".jpg", ".jpeg", ".png") and p.name not in trained
    ]
    return imgs


def _encode(path: Path) -> str:
    return base64.b64encode(path.read_bytes()).decode()


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--images", default=None, help="dir of images (default: held-out labelset)")
    ap.add_argument("--n", type=int, default=10, help="number of images to evaluate")
    ap.add_argument("--style", default="রূপকথা", choices=list(STYLES["bn"].keys()))
    ap.add_argument("--instruction", default="একটা গল্প বলো")
    args = ap.parse_args()

    if args.images:
        imgs = [
            p for p in sorted(Path(args.images).glob("*"))
            if p.suffix.lower() in (".jpg", ".jpeg", ".png")
        ]
    else:
        imgs = held_out_images()
    imgs = imgs[: args.n]
    if not imgs:
        sys.exit("No images found to evaluate.")

    print(f"Evaluating {len(imgs)} images · style={args.style} · stack={ACTIVE_STACK}", flush=True)
    options = get_vision_options("bn")

    # Precompute the (identical) prompt + encoded bytes per image.
    items = []
    for img in imgs:
        prompt = build_story_prompt(
            instruction=args.instruction,
            language="bn",
            style=args.style,
            child_name="",
            stack_key=ACTIVE_STACK,
            num_images=1,
        )
        items.append((img, prompt, [_encode(img)]))

    # Two phases so each serverless model cold-starts ONCE, not per image (the
    # alternating native→FT loop kept scaling the other model back to zero).
    print("Phase 1/2: native (base) ...", flush=True)
    natives = []
    for i, (img, prompt, b64) in enumerate(items, 1):
        t0 = time.time()
        story = (generate_story_remote(b64, prompt, options) or "").strip()
        dt = round(time.time() - t0, 1)
        print(f"  native [{i}/{len(items)}] {img.name}  {dt}s", flush=True)
        natives.append((story, dt))

    print("Phase 2/2: fine-tuned (vLLM) ...", flush=True)
    fts = []
    for i, (img, prompt, b64) in enumerate(items, 1):
        t0 = time.time()
        story = (generate_story_ft_remote(b64, prompt) or "").strip()
        dt = round(time.time() - t0, 1)
        print(f"  ft [{i}/{len(items)}] {img.name}  {dt}s", flush=True)
        fts.append((story, dt))

    rows = [
        (items[i][0], natives[i][0], natives[i][1], fts[i][0], fts[i][1])
        for i in range(len(items))
    ]

    out_dir = Path("finetune/eval_results")
    out_dir.mkdir(exist_ok=True)
    fname = out_dir / f"ft_vs_native_{datetime.now():%Y%m%d_%H%M}.md"
    lines = [
        f"# FT vs Native — Bengali story quality ({args.style})",
        f"Generated: {datetime.now():%Y-%m-%d %H:%M} · stack {ACTIVE_STACK} · {len(rows)} held-out images\n",
        "**Native** = base openbmb/MiniCPM-V-4_5 (current shipping Lever #1). ",
        "**FT** = merged Bengali LoRA via vLLM. Same prompt + image for both.\n",
        "> For the Bengali reviewer: for each image, which story reads more like a real "
        "grandmother's bedtime tale (natural words, রূপকথা imagery, no English/garbled "
        "words, calm sleepy ending)? Mark a winner per row.\n",
        "---\n",
    ]
    for img, native, tn, ft, tf in rows:
        lines += [
            f"## {img.name}",
            f"![{img.name}]({os.path.relpath(img, out_dir)})\n",
            f"### Native (base) — {tn}s",
            native or "_(empty)_", "",
            f"### FT (LoRA) — {tf}s",
            ft or "_(empty)_", "",
            "**Winner (reviewer):** ☐ Native  ☐ FT  ☐ Tie  ·  notes: ____",
            "\n---\n",
        ]
    fname.write_text("\n".join(lines))
    print(f"\nReport written to {fname}")
    print("Open it, have a Bengali speaker mark winners, and only set "
          "FINETUNED_VISION_MODEL if FT clearly wins.")


if __name__ == "__main__":
    main()