File size: 8,516 Bytes
d0e7c02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
"""Evaluate served slot adapters on the same cases, side by side, with paired statistics.

  python -m slotread.evaluate <set> <n> name=run_dir [name=run_dir ...]      (n=0: the whole set)

  agent_test    1,600 AGUVIS steps no run trained on (rows 6,000+ of every source)
  click         ScreenSpot clicks in the agent layout (1,272)
  box           ScreenSpot element boxes (1,272)
  box_refcoco   RefCOCO val referents as boxes (general objects; 3,811)
  box_coco17    COCO val2017 objects by category name (photos RefCOCO never used)
  box_fashion   Fashionpedia garments (a new domain)
  seg_val       RefCOCO val masks (also seg_testA, seg_testB); SEG_EXPR=one: one expression per object

  SHARD=0/2 python -m slotread.evaluate seg_val 0 ...      every 2nd case from 0 (split across servers)
  python -m slotread.evaluate merge <out.json> <shard.json> ...

Every case is read by every adapter (agent, click and box: per-slot requests; seg: one request with
prompt log-probabilities), scored under three readouts (default, peak, expected), and the adapters
are compared case by case: a paired bootstrap interval on the difference, and McNemar's exact test for
right/wrong metrics. A difference counts only when its interval excludes zero.
"""

import itertools, json, math, os, sys, time
from concurrent.futures import ThreadPoolExecutor

import numpy as np

from .client import SlotModel, agent_user
from .data import aguvis, evalsets, refcoco
from .images import data_url, image_of
from .numbers import slots_for

OUT = os.environ.get("SLOTREAD_RESULTS", "results")
CASE_WORKERS = int(os.environ.get("CASE_WORKERS", 6))
HOWS = ("default", "peak", "expected")
PRIMARY = {"agent": "step", "click": "hit", "box": "iou", "seg": "iou"}
BOOT = 10000


def mcnemar(a, b):
    """Two-sided exact McNemar p-value from paired booleans; also the discordant counts."""
    n01, n10 = int(np.sum(a & ~b)), int(np.sum(~a & b))
    n = n01 + n10
    if n == 0:
        return 1.0, n01, n10
    p = 2 * sum(math.comb(n, i) for i in range(min(n01, n10) + 1)) / 2 ** n
    return min(1.0, p), n01, n10


def boot(diff, rng):
    """A paired bootstrap 95% interval on the mean difference."""
    idx = rng.integers(0, len(diff), (BOOT, len(diff)))
    m = diff[idx].mean(1)
    return float(np.percentile(m, 2.5)), float(np.percentile(m, 97.5))


def cases(which, n):
    if which == "agent_test":
        return [dict(kind="agent", ex=ex) for ex in aguvis.test_steps(n)]
    if which in ("click", "box"):
        return [dict(kind=which, ex=ex) for ex in evalsets.screenspot(n)]
    if which == "box_coco17":
        return [dict(kind="box", ex=ex) for ex in evalsets.coco17(n)]
    if which == "box_fashion":
        return [dict(kind="box", ex=ex) for ex in evalsets.fashion(n or 1000)]
    if which == "box_refcoco":
        return [dict(kind="box", ex=ex) for ex in refcoco.photo_boxes(n, "validation", seed=0)]
    split = {"seg_val": "validation", "seg_testA": "test", "seg_testB": "testB"}[which]
    one = os.environ.get("SEG_EXPR", "all") == "one"
    return [dict(kind="seg", ex=ex) for ex in refcoco.examples(refcoco.rows(split), n, seed=0, one_sentence=one)]


# accuracy runs send screenshots as the post measured them: full-size PNG (EVAL_IMG_FMT / EVAL_IMG_MAX_PX to
# change). Serving and latency use the faster model-sized JPEG (slotread.images); on ScreenSpot clicks one
# run measured 0.825 with it against 0.860 with full-size PNG, while agent steps were unchanged.
EVAL_FMT = os.environ.get("EVAL_IMG_FMT", "png")
EVAL_MAX_PX = int(os.environ.get("EVAL_IMG_MAX_PX", 0))


def evaluate_case(model, case):
    kind, ex = case["kind"], case["ex"]
    url = data_url(image_of(ex), fmt=EVAL_FMT, max_px=EVAL_MAX_PX)
    a, out = model.arm, {}
    if kind in ("agent", "click"):
        task = model.tasks["agent"]
        user = ex["user"] if kind == "agent" else agent_user(ex["instruction"])
        lp = model.read("agent", url, user, ["action"] + slots_for("x", a) + slots_for("y", a))
        for how in HOWS:
            p = task.decode(lp, a, how)
            if kind == "agent":
                out[how] = task.score(p, ex)
            else:
                b = ex["box"]
                out[how] = dict(hit=float(b[0] <= p["x"] <= b[2] and b[1] <= p["y"] <= b[3]),
                                action_click=float(p["action"] == "click"))
    elif kind == "box":
        task = model.tasks["box"]
        lp = model.read("box", url, task.user(ex), task.layout(a))
        for how in HOWS:
            out[how] = task.score(task.decode(lp, a, how), ex)
    else:
        task = model.tasks["seg"]
        lp = model.read_one("seg", url, task.user(ex))
        for how in HOWS:
            out[how] = task.score(task.decode(lp, a, how), ex)
    return out


def summarize(which, rows, names):
    kind = rows[0]["kind"]
    key = PRIMARY[kind]
    cols = [k for k in sorted(rows[0]["arms"][names[0]]["default"]) if k not in ("inter", "union")]
    cols += ["ciou"] if kind == "seg" else []
    print(f"\n{which}: {len(rows)} cases\n\n| adapter | readout | " + " | ".join(cols) + " |\n|---|---|" + "---|" * len(cols))
    summary = {}
    for name in names:
        for how in HOWS:
            vals = [r["arms"][name][how] for r in rows]
            m = {}
            for k in cols:
                if k == "ciou":
                    m[k] = sum(v["inter"] for v in vals) / max(1, sum(v["union"] for v in vals))
                    continue
                xs = [v[k] for v in vals if v.get(k) is not None]
                m[k] = sum(xs) / len(xs) if xs else None
            summary[f"{name}/{how}"] = m
            print(f"| {name} | {how} | " + " | ".join("" if m[k] is None else f"{m[k]:.3f}" for k in cols) + " |",
                  flush=True)
    rng = np.random.default_rng(0)
    print(f"\nPaired, default readouts, on {key}:\n")
    for x, y in itertools.combinations(names, 2):
        vx = np.array([r["arms"][x]["default"][key] or 0.0 for r in rows], dtype=float)
        vy = np.array([r["arms"][y]["default"][key] or 0.0 for r in rows], dtype=float)
        lo, hi = boot(vx - vy, rng)
        extra = ""
        if kind in ("agent", "click"):
            p, n01, n10 = mcnemar(vx > 0.5, vy > 0.5)
            extra = f" | McNemar p {p:.3f} ({n01} / {n10})"
        verdict = "tie" if lo <= 0 <= hi else (f"{x} ahead" if lo > 0 else f"{y} ahead")
        print(f"- {x} {vx.mean():.3f} vs {y} {vy.mean():.3f}: diff {vx.mean() - vy.mean():+.3f} "
              f"[{lo:+.3f}, {hi:+.3f}]{extra} -> {verdict}", flush=True)
    return summary


def merge(out, parts):
    """Rejoin shards of one set (every row carries every adapter, so pairing survives the split)."""
    ds = [json.load(open(p)) for p in parts]
    rows = [r for d in ds for r in d["rows"]]
    summary = summarize(ds[0]["which"], rows, list(ds[0]["arms"]))
    json.dump(dict(which=ds[0]["which"], n=len(rows), arms=ds[0]["arms"], summary=summary, rows=rows, shards=parts),
              open(out, "w"))
    print(f"\nmerged {len(parts)} shards, {len(rows)} cases -> {out}", flush=True)


def main(args=None):
    args = args or sys.argv[1:]
    if args[0] == "merge":
        return merge(args[1], args[2:])
    which, n = args[0], int(args[1])
    models = [SlotModel(*a.split("=", 1)) for a in args[2:]]
    cs = cases(which, n)
    shard = os.environ.get("SHARD")
    if shard:  # "i/k": every k-th case starting at i
        i, k = map(int, shard.split("/"))
        cs = cs[i::k]
    print(f"{which}: {len(cs)} cases{f' (shard {shard})' if shard else ''}, adapters {', '.join(m.name for m in models)}",
          flush=True)

    def one(ic):
        i, c = ic
        return dict(id=i, kind=c["kind"], group=list(c["ex"].get("group", [])),
                    arms={m.name: evaluate_case(m, c) for m in models})

    rows, t = [], time.time()
    with ThreadPoolExecutor(CASE_WORKERS) as p:
        for k, row in enumerate(p.map(one, enumerate(cs))):
            rows.append(row)
            if (k + 1) % 200 == 0:
                print(f"  {k + 1}/{len(cs)} ({time.time() - t:.0f}s)", flush=True)
    summary = summarize(which, rows, [m.name for m in models])
    os.makedirs(OUT, exist_ok=True)
    path = f"{OUT}/eval_{which}_{n}{'_s' + shard.replace('/', 'of') if shard else ''}.json"
    json.dump(dict(which=which, n=len(rows), arms={m.name: m.path for m in models}, summary=summary, rows=rows),
              open(path, "w"))
    print(f"\nwrote {path}", flush=True)


if __name__ == "__main__":
    main()