Repository navigation
Expand file tree
/
Copy pathmodal_app.py
More file actions
2542 lines (2223 loc) · 137 KB
/
Copy pathmodal_app.py
File metadata and controls
2542 lines (2223 loc) · 137 KB
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
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Modal jobs for the VLM-backed decision model (``laya.vlm``): SmolVLM by default, ModernVBERT on request.
modal run modal_app.py::test # pytest on a GPU + latency, both backbones
modal run modal_app.py::finetune --minutes 18 # short fine-tune + held-out acc / ECE
modal run modal_app.py::evaluate --run-name <run> # re-evaluate a saved checkpoint
modal run modal_app.py::evidence --run cauldron-score-2ep-bidir-full/best
# row-level val predictions -> results/raw/ + SHA256SUMS
# (checked by benchmarks/verify_published.py)
modal run --detach modal_app.py::robustness_eval [--run <run>] [--n 300] # perturbation robustness,
# see laya/robustness/__init__.py -> results/robustness/
modal run --detach modal_app.py::finetune_long # ~3-epoch A100 run with per-epoch eval + best checkpoint
modal run --detach modal_app.py::finetune_long --backbone ModernVBERT/modernvbert --run-name mvb-3ep
# the same run on the bidirectional backbone
modal run modal_app.py::prepare_cauldron # The Cauldron's closed-form subsets -> /data/vqa/cauldron_<subset>
modal run modal_app.py::prepare_score # rubric-scored sets (score questions) -> /data/vqa/score_<name>
modal run modal_app.py::prepare_eval # held-out eval sets (KonIQ, EvalMuse, CIFAR-10H, FER+, VizWiz,
# POPE) -> /data/vqa/eval_<name>; evaluate scores them by default
modal run --detach modal_app.py::finetune_long --run-name cauldron-score-2ep --epochs 2 --max-passes 4 \
--datasets cauldron,score --val-datasets vqa,cauldron,score
# Cauldron + the score sets (group names expand, see DATASET_GROUPS);
# add --backbone ModernVBERT/modernvbert for the bidirectional one,
# or --option-attention block to un-causal SmolVLM's option block
modal run --detach modal_app.py::split_bench # SmolVLM2, image splitting off vs 1024 vs 2048 on a 6-set subset:
# accuracy per set, tokens, L4 latency -> /ckpt/smolvlm2/split-bench/
modal run modal_app.py::bench_prefix_cache # predict latency, prefix cache off vs on, L4 bf16
modal run modal_app.py::try_model --image photo.jpg [--questions q.json] [--text "..."] # ask a checkpoint about an image
modal run modal_app.py::publish [--repo user/name] [--run all3-3ep/best] # push checkpoint + hf_model_card.md to the HF Hub
modal run modal_app.py::publish --repo thaitea/laya-vision-modernvbert-250m --run modernvbert/cauldron-2ep/best \
--metrics modernvbert/cauldron-2ep/metrics.json --card hf_model_card_modernvbert.md # the ModernVBERT one
modal run modal_app.py::publish_space # push space/ to the thaitea/laya-vision-demo Space
modal run modal_app.py::prepare_doom_basic # auto-labelled ViZDoom "basic" frames -> /data/vqa/doom_basic
modal run modal_app.py::doom_eval --models all3-3ep/best # play "basic": expert / random / always-attack / models
modal run modal_app.py::maze_eval --models <run>/best # Maze at 4x4 / 6x6 / 8x8 cells: BFS expert, random, models
modal run modal_app.py::snake_eval --models <run>/best # Snake on a 10x10 board: greedy expert, random, models
modal run modal_app.py::control_eval --models <run>/best # CartPole / Acrobot / MountainCar / LunarLander: expert, random, models
modal run modal_app.py::full_eval --model <run>/best # EVERYTHING on one checkpoint, in parallel: evaluate over
# vqa,cauldron,score,eval + games suite + latency + robustness
# controls (shuffled / no image, option order); one JSON in
# eval-results/ and <run>/evals/ on the checkpoint volume
modal run modal_app.py::games_eval --model <run>/best --out games.json
# the games suite on one checkpoint: Atari Freeway / Breakout /
# Galaxian, ViZDoom basic, Maze, Snake, classic control, with
# baselines, in parallel
Volumes (created out of band; never ``modal deploy`` this app):
laya-hf-cache -> /cache/hf (HF_HOME, shared model weights)
laya-datasets -> /data (read-only; /data/vqa/<name>/{<split>.jsonl, images/, _READY})
laya-checkpoints -> /ckpt (this app writes only under /ckpt/smolvlm/ and /ckpt/modernvbert/;
robustness results under /ckpt/smolvlm/robustness/)
Data: the fine-tune jobs default to The Cauldron subsets (``CAULDRON_DATASETS``, written by ``prepare_cauldron``);
two layouts exist on the volume: the capped ``cauldron_<subset>`` sets (10,000 usable rows per subset, 5% val)
and the uncapped ``cauldronfull_<subset>`` sets (``CAULDRON_FULL_DATASETS``, every usable row, 1% val, written by
``prepare_cauldron --prefix cauldronfull_ --max-rows 0 --val-pct 1``). The original three VQA sets (``VQA_DATASETS``, official val splits, the README table) stay available with
``--datasets aokvqa,scienceqa,vqav2_yesno`` and as ``--val-datasets`` for a Cauldron-trained model. The ``score``
head has its own sets (``SCORE_DATASETS``, written by ``prepare_score`` from ``laya.rubric``): rubric-graded
responses, aesthetics votes, generated-image ratings and damage levels; add them to ``--datasets`` to train it.
The held-out evaluation sets (``EVAL_DATASETS``, written by ``prepare_eval`` from ``laya.evalsets``) score
calibration against human vote histograms, abstention, hallucination and rubric scoring; ``evaluate`` includes
them, and ``--val-split test`` reads the official test split where one exists (KonIQ, FER+).
``--datasets`` and ``--val-datasets`` take dataset names and the group names ``vqa``, ``cauldron``, ``cauldronfull``,
``score`` and ``eval`` (``DATASET_GROUPS``).
Run names: ``finetune`` and ``finetune_long`` take ``--backbone`` and write under that backbone's root
(``CKPT_ROOTS``). Everywhere a job takes a saved run (``evaluate``, ``try_model``, ``doom_eval``, ``games_eval``, ``--init-from``)
the name is relative to /ckpt/smolvlm as before, or to /ckpt, so a ModernVBERT run is ``modernvbert/<run>/best``.
"""
import json
import os
import subprocess
import sys
import time
import modal
app = modal.App("laya-smolvlm")
hf_vol = modal.Volume.from_name("laya-hf-cache")
data_vol = modal.Volume.from_name("laya-datasets")
ckpt_vol = modal.Volume.from_name("laya-checkpoints")
base_image = (
modal.Image.debian_slim(python_version="3.12")
.pip_install(
"torch==2.14.0",
"torchvision==0.29.0",
"transformers==5.17.0",
"safetensors",
"huggingface_hub",
"numpy",
"pillow",
"datasets",
"pytest",
"num2words",
)
.env({"HF_HOME": "/cache/hf", "TOKENIZERS_PARALLELISM": "false"})
)
def _with_local_code(img):
return img.add_local_dir("tests", "/root/tests").add_local_python_source("laya")
image = _with_local_code(base_image)
BACKBONE = "HuggingFaceTB/SmolVLM-256M-Instruct"
SMOLVLM2 = "HuggingFaceTB/SmolVLM2-256M-Video-Instruct"
MODERNVBERT = "ModernVBERT/modernvbert"
VQA_DATASETS = ("aokvqa", "scienceqa", "vqav2_yesno") # the original post-training sets, official val splits
CAULDRON_SUBSETS = ("ai2d", "aokvqa", "iconqa", "intergps", "scienceqa", "tqa", "visual7w", "raven",
"figureqa", "hateful_memes", "nlvr2", "vsr", "vqarad",
"clevr", "dvqa", "mapqa", "ocrvqa", "vqav2", "chartqa") # see laya/cauldron.py
CAULDRON_DATASETS = tuple("cauldron_" + s for s in CAULDRON_SUBSETS)
CAULDRON_FULL_DATASETS = tuple("cauldronfull_" + s for s in CAULDRON_SUBSETS) # uncapped prep, see the docstring
SCORE_SOURCES = ("vlfeedback", "ava", "richhf", "crisismmd") # see laya/rubric.py
SCORE_DATASETS = tuple("score_" + s for s in SCORE_SOURCES) # rubric-scored sets for the ``score`` head, written by prepare_score
EVAL_SOURCES = ("koniq", "evalmuse", "cifar10h", "ferplus", "vizwiz", "pope_random", "pope_popular",
"pope_adversarial") # see laya/evalsets.py
EVAL_DATASETS = tuple("eval_" + s for s in EVAL_SOURCES) # held-out sets written by prepare_eval
DATASETS = CAULDRON_DATASETS
CKPT_ROOTS = {BACKBONE: "/ckpt/smolvlm", SMOLVLM2: "/ckpt/smolvlm2", MODERNVBERT: "/ckpt/modernvbert"}
CKPT_ROOT = CKPT_ROOTS[BACKBONE]
def _parse_mix(mix: str) -> dict:
"""``"name=w,name=w"`` -> ``{name: float(w)}``; ``""`` -> ``{}`` (equal sampling)."""
out = {}
for kv in mix.split(","):
if kv.strip():
k, v = kv.split("=")
out[k.strip()] = float(v)
return out
def _ckpt_root(backbone: str) -> str:
"""Where a backbone's runs go: the two known ones by name, anything else by its Hub name."""
return CKPT_ROOTS.get(backbone, "/ckpt/" + backbone.split("/")[-1].lower())
def _ckpt_file(rel: str) -> str:
"""A file under a run, e.g. ``all3-3ep/metrics.json`` or ``modernvbert/cauldron-2ep/metrics.json``, resolved
like ``_ckpt_path``: under /ckpt/smolvlm first, then /ckpt."""
for root in (CKPT_ROOT, "/ckpt"):
path = os.path.join(root, rel)
if os.path.exists(path):
return path
return os.path.join(CKPT_ROOT, rel)
def _ckpt_path(run_name: str) -> str:
"""A saved run: ``<run>`` under /ckpt/smolvlm (the original layout) or ``<family>/<run>`` under /ckpt."""
for root in (CKPT_ROOT, "/ckpt"):
path = os.path.join(root, run_name)
if os.path.exists(os.path.join(path, "vlm_agent_config.json")):
return path
return os.path.join(CKPT_ROOT, run_name)
@app.function(image=image, gpu="L4", timeout=45 * 60, volumes={"/cache/hf": hf_vol})
def test(backbones: str = BACKBONE + "," + SMOLVLM2 + "," + MODERNVBERT):
"""Run tests/test_vlm.py, test_smolvlm2.py and test_modernvbert.py on the GPU, then time predict() in fp32 and
bf16."""
import torch
print("GPU:", torch.cuda.get_device_name(0), "| torch", torch.__version__)
rc = subprocess.run(
[sys.executable, "-m", "pytest", "/root/tests/test_vlm.py", "/root/tests/test_smolvlm2.py",
"/root/tests/test_modernvbert.py", "-v", "-s",
"-p", "no:cacheprovider", "-W", "ignore"],
cwd="/root",
).returncode
hf_vol.commit()
from PIL import Image
from laya.vlm import VLMAgent
img = Image.new("RGB", (96, 96), (255, 255, 255))
img.paste(Image.new("RGB", (48, 48), (220, 20, 20)), (24, 24))
one = {"is_red": {"type": "noul", "instructions": "Is the square red?"}}
three = dict(one, color={"type": "choice", "instructions": "What color?", "criteria": ["red", "blue", "green"]},
size={"type": "score", "instructions": "How big?", "criteria": ["small", "medium", "large"]})
for backbone in [b for b in backbones.split(",") if b]:
for dtype in ("fp32", "bf16"):
agent = VLMAgent(backbone=backbone, device="cuda", dtype=dtype)
for label, state, qs in (("image, 1 q", {"image": img}, one), ("image, 3 q", {"image": img}, three),
("text, 1 q", "Customer: I was billed twice.", one), ("text, 3 q", "Customer: I was billed twice.", three)):
for _ in range(3):
agent.predict(state, qs)
ts = []
for _ in range(20):
torch.cuda.synchronize()
t0 = time.perf_counter()
agent.predict(state, qs)
torch.cuda.synchronize()
ts.append((time.perf_counter() - t0) * 1000)
ts.sort()
print("latency %-28s %-5s %-11s median %6.1f ms p90 %6.1f ms" % (backbone, dtype, label, ts[10], ts[18]))
del agent
if rc != 0:
raise SystemExit("pytest failed with exit code %d" % rc)
def _load_split(name: str, split: str, limit):
from laya.vlm_train import load_jsonl_examples
return load_jsonl_examples("/data/vqa", name, split, limit=limit)
DATASET_GROUPS = {"vqa": VQA_DATASETS, "cauldron": CAULDRON_DATASETS, "cauldronfull": CAULDRON_FULL_DATASETS,
"score": SCORE_DATASETS, "eval": EVAL_DATASETS}
def _expand_datasets(names: str) -> list:
"""``"cauldron,score,aokvqa"`` -> the dataset names, with the group names in ``DATASET_GROUPS`` expanded."""
out = []
for name in [d.strip() for d in names.split(",") if d.strip()]:
for n in DATASET_GROUPS.get(name, (name,)):
if n not in out:
out.append(n)
return out
def _ready(names: str):
out = []
for name in _expand_datasets(names):
if os.path.exists("/data/vqa/%s/_READY" % name):
out.append(name)
else:
print("dataset %s not ready (no _READY); skipping" % name)
return out
def _load_data(datasets: str, train_split: str, val_split: str, n_calib: int, max_train: int, max_val: int, caps: dict,
val_datasets: str = ""):
"""(train, calib, val) examples. The LAST ``n_calib`` train records per dataset (file order) are held out of
training for temperature fitting, as in the SigLIP-projector runs (runs before commit 2651641 held out a seeded
random 300 instead). ``val_datasets`` scores different sets than were trained on (default: the same)."""
data_vol.reload()
train_ex, calib_ex, val_ex = [], [], []
for name in _ready(datasets):
tr = _load_split(name, train_split, max_train + n_calib if max_train else 0)
calib_ex += tr[-n_calib:]
train_ex += tr[:-n_calib]
print("dataset %s: %d train, %d calib" % (name, len(tr) - n_calib, min(n_calib, len(tr))))
for name in _ready(val_datasets or datasets):
va = _load_split(name, val_split, caps.get(name, max_val) or 0)
val_ex += va
print("dataset %s: %d val" % (name, len(va)))
if not train_ex:
raise SystemExit("no training data (datasets not ready)")
if not val_ex:
raise SystemExit("no validation data (datasets not ready)")
return train_ex, calib_ex, val_ex
@app.function(
image=image,
gpu="A10G",
cpu=16,
memory=32768,
timeout=40 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol},
)
def finetune(
datasets: str = ",".join(DATASETS),
minutes: float = 18.0,
freeze: str = "full",
n_last: int = 8,
batch_size: int = 16,
lr_head: float = 1e-4,
lr_backbone: float = 2e-5,
train_split: str = "train",
val_split: str = "val",
max_train: int = 0,
max_val: int = 0,
n_calib: int = 300,
eval_every: int = 400,
val_caps: str = "",
num_workers: int = 14,
synthetic: bool = False,
run_name: str = "",
backbone: str = BACKBONE,
val_datasets: str = "",
preprocess: str = "processor",
option_attention: str = "causal",
w_ce_schedule: str = "const",
mix: str = "",
mix_alpha: float = 0.0,
):
"""Short fine-tune on the prepared VQA sets; logs loss and held-out accuracy / ECE, saves to
``<backbone root>/<run>`` (/ckpt/smolvlm or /ckpt/modernvbert).
``max_train`` / ``max_val`` = 0 means the whole split; otherwise the first N records in file order.
``val_caps`` overrides the val cap per dataset, e.g. ``"vqav2_yesno=1000"``. ``preprocess`` is the image
path recorded in the checkpoint: ``"processor"`` (the Hugging Face processor, what the released model
used) for photos of mixed sizes; the device-side ``"gpu"`` path stacks raw frames and needs them all the
same size, so it is for game frames (``modal_atari_train.py``). ``w_ce_schedule``, ``mix`` and ``mix_alpha``:
see ``finetune_long``.
"""
import torch
from laya.vlm import VLMAgent
from laya.vlm_train import collect_logits, fit_temperatures_from, format_metrics, metrics_from, synthetic_examples, train
t_start = time.time()
print("GPU:", torch.cuda.get_device_name(0), "| torch", torch.__version__)
run_name = run_name or time.strftime("run-%Y%m%d-%H%M%S")
out_dir = os.path.join(_ckpt_root(backbone), run_name)
caps = {k: int(v) for k, v in (kv.split("=") for kv in val_caps.split(",") if kv)}
if synthetic:
train_ex = [dict(ex, dataset="synthetic") for ex in synthetic_examples(64, seed=0)]
calib_ex = [dict(ex, dataset="synthetic") for ex in synthetic_examples(16, seed=2)]
val_ex = [dict(ex, dataset="synthetic") for ex in synthetic_examples(32, seed=1)]
else:
train_ex, calib_ex, val_ex = _load_data(datasets, train_split, val_split, n_calib, max_train, max_val, caps, val_datasets)
agent = VLMAgent(backbone=backbone, device="cuda", preprocess=preprocess, option_attention=option_attention)
print("backbone %s, readout %s, preprocess %s" % (backbone, agent.model.readout, agent.prep.backend))
hf_vol.commit()
model, proc = agent.model, agent.processor
ev_kw = dict(batch_size=32, num_workers=num_workers)
small_val = []
for name in sorted({ex["dataset"] for ex in val_ex}):
small_val += [ex for ex in val_ex if ex["dataset"] == name][:200]
mix_weights = _parse_mix(mix)
log = {"run": run_name, "args": dict(backbone=backbone, datasets=datasets, val_datasets=val_datasets or datasets,
preprocess=preprocess, option_attention=agent.model.option_attention,
w_ce_schedule=w_ce_schedule, mix=mix_weights, mix_alpha=mix_alpha,
minutes=minutes, freeze=freeze, n_last=n_last,
batch_size=batch_size, lr_head=lr_head, lr_backbone=lr_backbone, max_train=max_train,
max_val=max_val, val_caps=caps),
"evals": []}
base = metrics_from(collect_logits(model, proc, small_val, **ev_kw))
print("[eval step 0, untrained head] " + format_metrics(base), flush=True)
log["evals"].append({"step": 0, **base})
def eval_fn(step):
m = metrics_from(collect_logits(model, proc, small_val, **ev_kw))
print("[eval step %d] %s" % (step, format_metrics(m)), flush=True)
log["evals"].append({"step": step, **m})
losses = train(
model, proc, train_ex, steps=10**9, batch_size=batch_size, freeze=freeze, n_last=n_last,
lr_head=lr_head, lr_backbone=lr_backbone, device="cuda", log_every=50, max_minutes=minutes,
num_workers=num_workers, warmup=100, eval_fn=eval_fn, eval_every=eval_every, w_ce_schedule=w_ce_schedule,
mix_weights=mix_weights, mix_alpha=mix_alpha,
)
log["steps"], log["examples_seen"] = len(losses), len(losses) * batch_size
log["loss_first50"], log["loss_last50"] = sum(losses[:50]) / min(50, len(losses)), sum(losses[-50:]) / min(50, len(losses))
print("trained %d steps (%d examples); loss first50 %.4f -> last50 %.4f"
% (log["steps"], log["examples_seen"], log["loss_first50"], log["loss_last50"]), flush=True)
t_eval = time.time()
temps = fit_temperatures_from(collect_logits(model, proc, calib_ex, **ev_kw))
val_records = collect_logits(model, proc, val_ex, **ev_kw)
print("calib + val eval: %d examples in %.1f min" % (len(calib_ex) + len(val_ex), (time.time() - t_eval) / 60))
log["temperature"] = temps
log["val_raw"] = metrics_from(val_records)
log["val_calibrated"] = metrics_from(val_records, temps)
print("temperatures (choice, score, noul):", [round(t, 3) for t in temps])
print("[final val, T=1] " + format_metrics(log["val_raw"]))
print("[final val, calibrated] " + format_metrics(log["val_calibrated"]))
agent.temperature = temps
agent.save(out_dir, include_backbone=freeze != "head")
with open(os.path.join(out_dir, "metrics.json"), "w") as f:
json.dump(log, f, indent=2)
ckpt_vol.commit()
print("saved %s (%.1f min total)" % (out_dir, (time.time() - t_start) / 60))
return {k: log[k] for k in ("run", "steps", "loss_first50", "loss_last50", "temperature", "val_raw", "val_calibrated")}
@app.function(
image=image,
gpu="A100",
cpu=24,
memory=65536,
timeout=300 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol},
retries=modal.Retries(max_retries=3, initial_delay=10.0),
)
def finetune_long(
datasets: str = ",".join(DATASETS),
epochs: float = 3.0,
max_minutes: float = 90.0,
batch_size: int = 32,
lr_head: float = 1e-4,
lr_backbone: float = 2e-5,
lr_ref_batch: int = 16,
warmup_frac: float = 0.03,
evals_per_epoch: float = 2.0,
train_eval_n: int = 1000,
max_passes: float = 0.0,
n_calib: int = 300,
num_workers: int = 22,
run_name: str = "all3-3ep",
init_from: str = "",
backbone: str = BACKBONE,
val_datasets: str = "",
preprocess: str = "processor",
option_attention: str = "causal",
w_ce_schedule: str = "const",
mix: str = "",
mix_alpha: float = 0.0,
split_edge: int = 0,
max_len: int = 0,
max_train: int = 0,
max_val: int = 0,
restart: bool = False,
state_every_min: float = 10.0,
crash_at_step: int = 0,
):
"""Multi-epoch fine-tune (vision tower frozen) with per-epoch train/val tracking and best-checkpoint keeping.
* ``epochs`` counts samples over the combined train set (datasets are sampled by the mix below, equally by
default, so small sets repeat more; ``max_passes`` > 0 caps the passes over any one dataset).
* LRs are given for ``lr_ref_batch`` and scaled by sqrt(batch_size / lr_ref_batch) (the usual rule for Adam).
* Linear warmup over ``warmup_frac`` of the steps, then cosine decay to 10%; ``max_minutes`` caps wall-clock.
* Each eval scores the full val splits and the first ``train_eval_n`` train records per dataset (seen in
training) to expose overfitting. ``last/`` is saved every eval; ``best/`` when mean per-dataset val acc
improves. The final model is the best one: temperatures are fitted on the calibration holdout, then the full
val splits are scored raw and calibrated, plus an option-order-bias check on aokvqa.
* ``init_from`` (a run under /ckpt/smolvlm, e.g. ``all3-3ep/best``, or ``modernvbert/<run>/best``) continues
from a trained checkpoint instead of a fresh head. Question types absent from the calibration holdout keep
that checkpoint's temperature rather than being reset to 1.0.
* ``backbone`` picks the family: SmolVLM (causal, the released model) or ``ModernVBERT/modernvbert``
(bidirectional, ``[MASK]`` readout). The run is saved under that backbone's root, so the two can share
a ``run_name``. Everything else, data, objective, schedule and evaluation, is identical, which is what
makes the two comparable.
* ``datasets`` defaults to The Cauldron subsets; ``val_datasets`` can score other sets, e.g. the official
A-OKVQA / ScienceQA / VQAv2 val splits of the README table, on a Cauldron-trained model. With many
subsets of very different sizes, set ``max_passes`` (the sampler draws subsets equally by default).
* ``mix`` and ``mix_alpha`` set the sampling mix: dataset ``k`` is drawn with probability proportional to
``w_k * n_k ** mix_alpha``, where ``w_k`` comes from ``mix`` (``"name=w,name=w"``, e.g.
``"cauldron_ai2d=2,cauldron_figureqa=0.5"``; unlisted datasets get 1) and ``n_k`` is the dataset's train
size. The defaults (``""``, 0) are the equal sampling of the earlier runs; ``mix_alpha=1`` samples in
proportion to size, ``0.5`` by square root. The effective probabilities are printed at the start.
* ``preprocess``: see ``finetune``.
* ``option_attention="block"`` (SmolVLM only; ``"bidirectional"`` is a deprecated alias) lets the option block attend to itself in both
directions through a 4D mask (``laya.vlm.option_block_mask``), so every option's readout sees every other
option, as ModernVBERT's ``[MASK]`` readout does; the state and question stay causal. The pretrained
backbone never saw this pattern, so it is only meaningful with the backbone unfrozen, as here. The setting
is saved in the checkpoint and applied at inference. Ignored (rejected) for the ``"mask"`` readout.
* ``w_ce_schedule="anneal"`` holds the soft cross-entropy weight for the first 30% of training and decays it
to 0 by 80%, so the run ends on the proper scoring rule alone, which keeps the raw model calibrated
(``laya.vlm_train.train``); ``"const"`` is the released recipe.
* ``split_edge`` > 0 turns on the processor's image splitting (``laya.preprocess``): each image is resized to
that longest edge and cut into 512 tiles plus a global view, up to 17 views at 2048 against 1 without.
Needs ``preprocess="processor"``. ``max_len`` (0: ``laya.vlm.default_max_len``, 1024 without splitting)
is the sequence cap; both are saved in the checkpoint. Neither applies with ``init_from``, which keeps the
checkpoint's.
* ``max_train`` / ``max_val`` > 0 keep the first records per dataset (file order) for a quicker run on a
subset; ``max_train`` does not count the ``n_calib`` holdout.
* Durability: every ``state_every_min`` minutes (clamped to ``vlm_train.MIN_STATE_MINUTES``, and backed off
further when writes are slow) and after every eval the run writes ``<out>/state.pt`` atomically (weights,
optimizer, step, RNG, per-dataset sample counts, elapsed training time, best-so-far and the log), and a
preempted or retried container resumes from it; ``max_minutes`` counts training time across attempts. The
state is deleted once the run finishes, so a later call with the same ``run_name`` starts fresh.
``restart`` ignores an unfinished run's state; ``crash_at_step`` raises once at that step to test the
resume path (Modal retries the container).
"""
import math
import torch
from laya.common import QTYPES
from laya.vlm import VLMAgent, _permutations
from laya.vlm_train import (collect_logits, cyclic_orders, fit_temperatures_from, format_metrics, metrics_from,
mix_probabilities, train)
t_start = time.time()
print("GPU:", torch.cuda.get_device_name(0), "| torch", torch.__version__)
out_dir = os.path.join(_ckpt_root(backbone), run_name)
train_ex, calib_ex, val_ex = _load_data(datasets, "train", "val", n_calib, max_train, max_val, {}, val_datasets)
names = sorted({ex["dataset"] for ex in train_ex})
val_names = sorted({ex["dataset"] for ex in val_ex})
train_eval = []
for name in names:
train_eval += [ex for ex in train_ex if ex["dataset"] == name][:train_eval_n]
scale = math.sqrt(batch_size / lr_ref_batch)
lr_h, lr_b = lr_head * scale, lr_backbone * scale
steps = int(math.ceil(epochs * len(train_ex) / batch_size))
eval_every = max(1, int(round(steps / (epochs * evals_per_epoch))))
warmup = max(1, int(warmup_frac * steps))
sizes = {n: sum(ex["dataset"] == n for ex in train_ex) for n in names}
mix_weights = _parse_mix(mix)
probs = mix_probabilities({n: range(sizes[n]) for n in names}, mix_weights, mix_alpha)
print("plan: %d steps x batch %d (%.1f epochs of %d), warmup %d, eval every %d, lr head %.2e backbone %.2e"
% (steps, batch_size, epochs, len(train_ex), warmup, eval_every, lr_h, lr_b))
print("expected passes per dataset with the sampling mix (before max_passes=%s): %s"
% (max_passes or None, {n: round(epochs * len(train_ex) * probs[n] / sizes[n], 2) for n in names}))
if init_from:
agent = VLMAgent(_ckpt_path(init_from), device="cuda")
print("initialised from %s (temperatures %s)" % (init_from, [round(t, 3) for t in agent.temperature]))
else:
agent = VLMAgent(backbone=backbone, device="cuda", preprocess=preprocess, option_attention=option_attention,
image_split_edge=split_edge, **({"max_len": max_len} if max_len else {}))
print("backbone %s, readout %s, preprocess %s, split_edge %d, max_len %d" % (
agent.cfg["backbone"], agent.model.readout, agent.prep.backend, agent.prep.split_edge, agent.cfg["max_len"]))
init_temps = list(agent.temperature)
hf_vol.commit()
model, proc = agent.model, agent.processor
import shutil
tr_workers, tr_prefetch = _loader_fit(agent.prep, batch_size, num_workers, 4)
ev_workers, _ = _loader_fit(agent.prep, 64, num_workers, 2, min_prefetch=2) # collect_logits: prefetch 2
print("loader: /dev/shm %.1f GB; train %d workers x prefetch %d, eval %d workers" % (
shutil.disk_usage("/dev/shm").total / 1e9, tr_workers, tr_prefetch, ev_workers), flush=True)
ev_kw = dict(batch_size=64, num_workers=ev_workers)
log = {"run": run_name, "args": dict(backbone=agent.cfg["backbone"], readout=agent.model.readout, datasets=datasets,
val_datasets=val_datasets or datasets, preprocess=agent.prep.backend,
split_edge=agent.prep.split_edge, max_len=agent.cfg["max_len"],
max_train=max_train, max_val=max_val,
option_attention=agent.model.option_attention,
w_ce_schedule=w_ce_schedule, mix=mix_weights, mix_alpha=mix_alpha,
epochs=epochs, max_minutes=max_minutes, batch_size=batch_size, lr_head=lr_h,
lr_backbone=lr_b, warmup=warmup, steps=steps, eval_every=eval_every,
max_passes=max_passes, n_calib=n_calib, train_eval_n=train_eval_n),
"evals": []}
best = {"score": -1.0, "step": None, "state": None}
# ------------------------------------------------------------------ durability: resume from state.pt
state_path = os.path.join(out_dir, "state.pt")
restart_marker = os.path.join(out_dir, "restarted")
call_id = modal.current_function_call_id() or str(t_start)
marked = open(restart_marker).read().strip() if os.path.exists(restart_marker) else ""
if restart and marked != call_id:
# only this call's first attempt restarts: its retries must still resume from their own state
for f_ in (state_path, os.path.join(out_dir, "crashed")):
if os.path.exists(f_):
os.remove(f_)
print("--restart: removed %s" % os.path.basename(f_))
os.makedirs(out_dir, exist_ok=True)
with open(restart_marker, "w") as f_:
f_.write(call_id)
ckpt_vol.commit()
resume = None
if os.path.exists(state_path):
blob = torch.load(state_path, map_location="cpu", weights_only=False)
model.load_state_dict(blob["model"])
model.to("cuda")
resume, log = blob["train"], blob["log"]
best.update(blob["best"])
print("resuming %s from state.pt at step %d (%d evals so far, best mean val acc %.4f)"
% (run_name, resume["step"], len(log["evals"]), best["score"]), flush=True)
def save_state(step, tstate):
ts = time.time()
os.makedirs(out_dir, exist_ok=True)
blob = {"train": tstate, "model": {k: v.detach().cpu() for k, v in model.state_dict().items()},
"best": {k: best[k] for k in ("score", "step")}, "log": log}
tmp = state_path + ".tmp"
torch.save(blob, tmp)
os.replace(tmp, state_path) # atomic: a torn write never replaces a good state
ckpt_vol.commit()
print(" wrote state.pt at step %d (%.1f s)" % (step, time.time() - ts), flush=True)
def write_log():
os.makedirs(out_dir, exist_ok=True)
with open(os.path.join(out_dir, "metrics.json"), "w") as f:
json.dump(log, f, indent=2)
def eval_fn(step):
te = time.time()
val_m = metrics_from(collect_logits(model, proc, val_ex, **ev_kw))
tr_m = metrics_from(collect_logits(model, proc, train_eval, **ev_kw))
score = sum(val_m[n]["acc"] for n in val_names) / len(val_names)
row = {"step": step, "epoch": round(step * batch_size / len(train_ex), 2), "mean_val_acc": score,
"val": val_m, "train": tr_m}
log["evals"].append(row)
print("[eval step %d, epoch %.2f] mean val acc %.4f | val: %s | train (seen): %s" % (
step, row["epoch"], score,
" | ".join("%s %.3f ece %.3f nll %.3f" % (n, val_m[n]["acc"], val_m[n]["ece"], val_m[n]["nll"]) for n in val_names),
" | ".join("%s %.3f" % (n, tr_m[n]["acc"]) for n in names)), flush=True)
agent.save(os.path.join(out_dir, "last"))
if score > best["score"]:
best.update(score=score, step=step, state={k: v.detach().cpu().clone() for k, v in model.state_dict().items()})
agent.save(os.path.join(out_dir, "best"))
print(" new best (step %d); saved %s/best" % (step, out_dir), flush=True)
log["best_step"], log["best_mean_val_acc"] = best["step"], best["score"]
write_log()
ckpt_vol.commit()
print(" eval + save took %.1f min" % ((time.time() - te) / 60), flush=True)
def maybe_eval(step):
# Called every few steps as a cheap probe (so ``crash_at_step`` can fire between evals); evaluates on
# multiples of ``eval_every`` and returns True only then, so the training loop writes state.pt after a
# real eval and not on every probe.
if crash_at_step and step >= crash_at_step and not os.path.exists(os.path.join(out_dir, "crashed")):
os.makedirs(out_dir, exist_ok=True)
open(os.path.join(out_dir, "crashed"), "w").close()
ckpt_vol.commit()
raise RuntimeError("crash_at_step %d: simulated preemption" % crash_at_step)
if step % eval_every == 0:
eval_fn(step)
return True
return False
stats = {}
losses = train(
model, proc, train_ex, steps=steps, batch_size=batch_size, freeze="full", lr_head=lr_h, lr_backbone=lr_b,
device="cuda", log_every=100, max_minutes=max_minutes, num_workers=tr_workers,
prefetch_factor=tr_prefetch, warmup=warmup,
eval_fn=maybe_eval, eval_every=math.gcd(25, eval_every), max_passes=max_passes or None, stats=stats,
w_ce_schedule=w_ce_schedule, mix_weights=mix_weights, mix_alpha=mix_alpha,
resume=resume, save_state_fn=save_state, save_state_every_min=state_every_min,
)
if not log["evals"] or log["evals"][-1]["step"] != stats["steps"]:
eval_fn(stats["steps"])
chunk = max(1, len(losses) // 10)
log["train_stats"] = dict(stats, passes={n: round(stats["samples_per_dataset"].get(n, 0) / sizes[n], 2) for n in names})
log["loss_curve"] = [{"steps": "%d-%d" % (i, min(i + chunk, len(losses)) - 1), "mean_loss": sum(losses[i:i + chunk]) / len(losses[i:i + chunk])}
for i in range(0, len(losses), chunk)]
print("train stats:", json.dumps(log["train_stats"]))
print("loss curve (10 chunks):", ", ".join("%.3f" % c["mean_loss"] for c in log["loss_curve"]))
# final model = best checkpoint by mean val acc; a resumed run kept it on disk rather than in memory
if best["state"] is not None:
model.load_state_dict(best["state"])
else:
from safetensors.torch import load_file
model.load_state_dict(load_file(os.path.join(out_dir, "best", "model.safetensors")))
model.to("cuda")
model.eval()
print("final model: best checkpoint from step %d (mean val acc %.4f)" % (best["step"], best["score"]))
temps = fit_temperatures_from(collect_logits(model, proc, calib_ex, **ev_kw))
n_calib_type = [sum(QTYPES[ex["q"]["t"]] == t for ex in calib_ex) for t in range(3)]
temps = [t if n >= 10 else init_temps[i] for i, (t, n) in enumerate(zip(temps, n_calib_type))]
val_records = collect_logits(model, proc, val_ex, **ev_kw)
log["temperature"] = temps
log["final"] = {"step": best["step"], "val_raw": metrics_from(val_records), "val_calibrated": metrics_from(val_records, temps)}
print("temperatures (choice, score, noul):", [round(t, 3) for t in temps])
print("[final val, T=1] " + format_metrics(log["final"]["val_raw"]))
print("[final val, calibrated] " + format_metrics(log["final"]["val_calibrated"]))
# option-order bias on aokvqa (4-way choice)
aok = [ex for ex in val_ex if ex["dataset"] == "aokvqa"]
if aok:
p4 = collect_logits(model, proc, aok, orders=lambda k: _permutations(k, 4), **ev_kw)
cyc = collect_logits(model, proc, aok, orders=cyclic_orders(4), **ev_kw)
p1 = [r for r in val_records if r["dataset"] == "aokvqa"]
bias = {"n_permutations=1": {"raw": metrics_from(p1)["aokvqa"], "calibrated": metrics_from(p1, temps)["aokvqa"]},
"n_permutations=4": {"raw": metrics_from(p4)["aokvqa"], "calibrated": metrics_from(p4, temps)["aokvqa"]}}
cyc_acc = []
for s_ in range(4):
recs = [dict(r, logits=r["logits_per_order"][s_]) for r in cyc]
cyc_acc.append(metrics_from(recs)["aokvqa"]["acc"])
bias["cyclic_shift_acc"] = cyc_acc
bias["cyclic_acc_spread"] = max(cyc_acc) - min(cyc_acc)
log["order_bias_aokvqa"] = bias
for k_ in ("n_permutations=1", "n_permutations=4"):
print("[aokvqa %s] raw acc %.4f ece %.4f nll %.4f | calibrated ece %.4f nll %.4f" % (
k_, bias[k_]["raw"]["acc"], bias[k_]["raw"]["ece"], bias[k_]["raw"]["nll"],
bias[k_]["calibrated"]["ece"], bias[k_]["calibrated"]["nll"]))
print("[aokvqa cyclic shifts 0..3] acc %s | spread %.4f" % (", ".join("%.4f" % a for a in cyc_acc), bias["cyclic_acc_spread"]))
agent.temperature = temps
agent.save(os.path.join(out_dir, "best"))
write_log()
for f_ in (state_path, os.path.join(out_dir, "crashed")):
if os.path.exists(f_):
os.remove(f_) # finished: a later call with this run_name starts fresh
ckpt_vol.commit()
print("saved %s/best with temperatures (%.1f min total)" % (out_dir, (time.time() - t_start) / 60))
return {k: log[k] for k in ("run", "args", "best_step", "best_mean_val_acc", "temperature", "final", "train_stats")}
@app.function(
image=image,
gpu=["A10G", "L4", "A100"], # any of these: an eval should not queue on one GPU type's capacity
cpu=16,
memory=32768,
timeout=60 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol.read_only()},
)
def evaluate(run_name: str, datasets: str = ",".join(VQA_DATASETS + CAULDRON_DATASETS + SCORE_DATASETS + EVAL_DATASETS),
val_split: str = "val",
max_val: int = 0):
"""Evaluate a saved checkpoint (``<run>`` under /ckpt/smolvlm, or ``modernvbert/<run>``) on the val splits,
raw and with its temperatures. Defaults to every prepared set (the official VQA splits and the Cauldron
holdouts); sets that are not prepared are skipped. The result records the GPU it ran on: the function takes any of
three types, and bf16 scores shift slightly between them (up to about a point on a set of ~100 questions)."""
import torch
from laya.vlm import VLMAgent
from laya.vlm_train import collect_logits, format_metrics, metrics_from
gpu = torch.cuda.get_device_name(0)
print("GPU:", gpu)
data_vol.reload()
val_ex = []
for name in _ready(datasets):
va = _load_split(name, val_split, max_val or 0)
print("dataset %s: %d val" % (name, len(va)))
val_ex += va
agent = VLMAgent(_ckpt_path(run_name), device="cuda")
print("backbone %s, readout %s" % (agent.cfg["backbone"], agent.model.readout))
records = collect_logits(agent.model, agent.processor, val_ex, batch_size=32, num_workers=14)
# ece_floor / ece_floor_p95 per set: the ECE a calibrated model scores at the same confidences and size
raw, cal = metrics_from(records, ece_floor_sims=200), metrics_from(records, agent.temperature, ece_floor_sims=200)
print("temperatures (choice, score, noul):", [round(t, 3) for t in agent.temperature])
print("[val, T=1] " + format_metrics(raw))
print("[val, calibrated] " + format_metrics(cal))
metas = {}
for name in {ex["dataset"] for ex in val_ex}:
try:
with open(os.path.join("/data/vqa", name, "meta.json")) as f:
metas[name] = json.load(f)
except (OSError, ValueError):
metas[name] = None
return {"val_raw": raw, "val_calibrated": cal, "temperature": list(agent.temperature), "dataset_meta": metas,
"gpu": gpu}
def _file_sha256(path: str) -> str:
import hashlib
h = hashlib.sha256()
with open(path, "rb") as f:
for block in iter(lambda: f.read(1 << 20), b""):
h.update(block)
return h.hexdigest()
@app.function(image=image, gpu="L4", cpu=8, memory=32768, timeout=40 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol.read_only()})
def row_evidence(run_name: str, datasets: str = "vqa", val_split: str = "val", max_val: int = 0, batch_size: int = 32,
code_commit: str = "") -> dict:
"""Score a saved checkpoint on val splits and return one row per example: the evidence behind a headline number.
Scored exactly as ``evaluate`` and the fine-tune jobs' final eval (``collect_logits``: identity option order,
bf16 autocast, ``batch_size`` 32), so the rows reproduce those metrics up to GPU-type numerics. Each row has
the dataset, the record id and its index among the split's usable records, the label, the option order used,
the raw label-order logits, the probabilities after the checkpoint's per-type temperature (what ``metrics_from``
calibrates with), and the sha256 of the exact input ids (``laya.vlm.input_ids_sha256``). Returns
``{"rows_jsonl": bytes, "meta_json": str}``; the meta records the checkpoint (weights and config sha256, backbone
revision), the datasets (val file sha256, and ``manifest.json`` / ``meta.json`` where the prep wrote one),
library versions, the GPU and the metrics computed here.
"""
import datetime
import torch
import transformers
from laya.vlm import PROMPT_FORMAT_VERSION, VLMAgent
from laya.vlm_train import collect_logits, metrics_from
gpu = torch.cuda.get_device_name(0)
print("GPU:", gpu)
data_vol.reload()
val_ex, ds_meta = [], {}
for name in _ready(datasets):
va = _load_split(name, val_split, max_val or 0)
base = os.path.join("/data/vqa", name)
info = {"n": len(va), "max_val": max_val, "file": "%s.jsonl" % val_split,
"file_sha256": _file_sha256(os.path.join(base, val_split + ".jsonl"))}
for extra in ("manifest.json", "meta.json"):
if os.path.exists(os.path.join(base, extra)):
with open(os.path.join(base, extra)) as f:
info[extra] = json.load(f)
ds_meta[name] = info
print("dataset %s: %d val" % (name, len(va)))
val_ex += [dict(ex, index=i) for i, ex in enumerate(va)]
path = _ckpt_path(run_name)
agent = VLMAgent(path, device="cuda")
t0 = time.time()
records = collect_logits(agent.model, agent.processor, val_ex, batch_size=batch_size, num_workers=7)
minutes = (time.time() - t0) / 60
temps = [float(t) for t in agent.temperature]
lines = []
for ex, r in zip(val_ex, records):
z = r["logits"]
p = torch.softmax(z / temps[r["qtype"]], -1)
lines.append(json.dumps({
"dataset": r["dataset"], "id": ex.get("id"), "index": ex["index"], "qtype": ex["q"]["t"],
"label": r["label"], "option_order": list(range(len(z))),
"logits": [round(float(v), 6) for v in z], "probs_calibrated": [round(float(v), 6) for v in p],
"input_ids_sha256": r["input_ids_sha256"][0],
}, separators=(",", ":")))
weights = os.path.join(path, "model.safetensors")
meta = {
"run": run_name, "checkpoint_path": path,
"created_utc": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"code_commit": code_commit or None, "prompt_format_version": PROMPT_FORMAT_VERSION,
"weights_sha256": _file_sha256(weights) if os.path.exists(weights) else None,
"config_sha256": _file_sha256(os.path.join(path, "vlm_agent_config.json")),
"backbone": agent.cfg.get("backbone"), "backbone_revision": agent.cfg.get("backbone_revision"),
"readout": agent.model.readout, "option_attention": agent.model.option_attention,
"temperature": temps, "calibration": "probs_calibrated = softmax(logits / temperature[qtype]), qtype order "
"(choice, score, noul)",
"scoring": {"orders": "identity", "n_permutations": 1, "autocast": "bf16", "batch_size": batch_size,
"weights_dtype": str(agent.model.encoder.dtype).replace("torch.", "")},
"gpu": gpu, "torch": torch.__version__, "transformers": transformers.__version__,
"datasets": ds_meta, "val_split": val_split, "n_rows": len(lines), "minutes": round(minutes, 2),
"metrics": {"val_raw": metrics_from(records), "val_calibrated": metrics_from(records, temps)},
}
print(json.dumps(meta["metrics"]["val_calibrated"]))
# plain bytes and JSON only: the local side has no torch to unpickle e.g. ``torch.__version__`` (a TorchVersion)
return {"rows_jsonl": ("\n".join(lines) + "\n").encode(), "meta_json": json.dumps(meta)}
def _write_sha256sums(raw_dir: str) -> None:
"""Rewrite ``<raw_dir>/SHA256SUMS`` over every file under it but itself and README.md (``sha256sum -c`` format)."""
names = []
for root, _, files in os.walk(raw_dir):
for fn in files:
rel = os.path.relpath(os.path.join(root, fn), raw_dir)
if rel not in ("SHA256SUMS", "README.md"):
names.append(rel)
with open(os.path.join(raw_dir, "SHA256SUMS"), "w") as f:
for rel in sorted(names):
f.write("%s %s\n" % (_file_sha256(os.path.join(raw_dir, rel)), rel))
@app.local_entrypoint()
def evidence(run: str = "cauldron-score-2ep-bidir-full/best", datasets: str = "vqa", val_split: str = "val",
max_val: int = 0, out_dir: str = "results/raw", name: str = ""):
"""modal run modal_app.py::evidence [--run <run>] [--datasets vqa] -- write row-level val predictions.
Writes ``<out_dir>/<name>.predictions.jsonl.gz`` (gzip with a zero mtime, so the bytes depend only on the rows)
and ``<name>.meta.json``, then regenerates ``<out_dir>/SHA256SUMS``. Results are create-only: an existing file
is never overwritten (pick a new ``--name``). ``name`` defaults to the run and datasets, e.g.
``smolvlm-cauldron-score-2ep-bidir-full-best.vqa-val``.
"""
import gzip
commit = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True).stdout.strip()
dirty = subprocess.run(["git", "status", "--porcelain", "--untracked-files=no"], capture_output=True,
text=True).stdout
family_run = run if run.startswith(("smolvlm", "modernvbert")) else "smolvlm/" + run
name = name or "%s.%s-%s" % (family_run.replace("/", "-"), datasets.replace(",", "+"), val_split)
rows_path = os.path.join(out_dir, name + ".predictions.jsonl.gz")
meta_path = os.path.join(out_dir, name + ".meta.json")
for path in (rows_path, meta_path):
if os.path.exists(path):
raise SystemExit("%s exists; results are create-only, pass a new --name" % path)
res = row_evidence.remote(run, datasets=datasets, val_split=val_split, max_val=max_val,
code_commit=commit + ("-dirty" if dirty.strip() else ""))
os.makedirs(out_dir, exist_ok=True)
with open(rows_path, "wb") as raw, gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as f:
f.write(res["rows_jsonl"])
meta = dict(json.loads(res["meta_json"]), rows_file=os.path.basename(rows_path))
with open(meta_path, "w") as f:
json.dump(meta, f, indent=2)
f.write("\n")
_write_sha256sums(out_dir)
print("wrote %s (%d rows) and %s; %s/SHA256SUMS regenerated" % (rows_path, meta["n_rows"], meta_path, out_dir))
for ds, m in meta["metrics"]["val_calibrated"].items():
print(" %-14s n=%5d acc=%.4f ece=%.4f" % (ds, m["n"], m["acc"], m["ece"]))
ROBUSTNESS_DATASETS = ("aokvqa", "scienceqa", "vqav2_yesno", "cauldron_ai2d", "cauldron_visual7w", "cauldron_vsr",
"cauldron_mapqa")
ROBUSTNESS_ROOT = CKPT_ROOT + "/robustness" # <tag>/{predictions.jsonl.gz, summary.json}; never overwritten
@app.function(image=image, gpu="L4", cpu=16, memory=32768, timeout=40 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol})
def robustness(run_name: str = "cauldron-score-2ep-bidir-full/best", datasets: str = ",".join(ROBUSTNESS_DATASETS),
n_per_dataset: int = 300, families: str = "", seed: int = 0, n_boot: int = 1000, val_split: str = "val",
tag: str = "", evals_name: str = "", write: bool = True):
"""Meaning-preserving perturbations of ``n_per_dataset`` seeded val rows per set (``laya.robustness``): option
order, rewording, image corruptions, and the shuffled-image / no-image controls, scored with the checkpoint's
temperatures. ``families`` (comma-separated) may also name the opt-in ``R.EXTRA_FAMILIES`` (option set /
abstention, question form / negation, text and typographic injection), summarised under their own keys. Writes the per-row predictions and the summary to ``ROBUSTNESS_ROOT/<tag>/`` (a new directory;
the job refuses an existing one) so a detached run's results survive the local client, and returns the
summary. ``full_eval``'s ``robustness`` part passes ``evals_name`` to write them to ``<run dir>/evals/<evals_name>/``
instead, beside its other result files, or ``write=False`` (``--no-save``) to write nothing."""
import gzip
import torch
from laya import robustness as R
from laya.vlm import VLMAgent
ckpt_vol.reload()
if evals_name:
out_dir = os.path.join(os.path.dirname(_ckpt_path(run_name).rstrip("/")), "evals", evals_name)
else:
out_dir = os.path.join(ROBUSTNESS_ROOT, tag or "%s-n%d-s%d" % (run_name.replace("/", "_"), n_per_dataset, seed))
if write and os.path.exists(out_dir):
raise SystemExit("%s exists; pass a new --tag" % out_dir)
t0 = time.time()
print("GPU:", torch.cuda.get_device_name(0))
data_vol.reload()
rows = []
for name in _ready(datasets):
src = R.source_rows(_load_split(name, val_split, 0), n=n_per_dataset, seed=seed, dataset=name)
print("dataset %s: %d source rows" % (name, len(src)))
rows += src
fams = [f for f in families.split(",") if f] or list(R.FAMILIES)
variants = R.build_variants(rows, fams, seed)
counts = {}
for v in variants:
counts[v["family"]] = counts.get(v["family"], 0) + 1
print("%d rows to score: %s" % (len(variants), counts))
agent = VLMAgent(_ckpt_path(run_name), device="cuda")
print("temperatures (choice, score, noul):", [round(t, 3) for t in agent.temperature])
preds = R.score_rows(agent.model, agent.processor, variants, agent.temperature, batch_size=32, num_workers=14)
print("scored in %.1f min" % ((time.time() - t0) / 60))
summary = R.summarize(preds, n_boot, seed)
print(R.format_table(summary))
meta = {"run": run_name, "datasets": sorted({r["dataset"] for r in rows}), "n_per_dataset": n_per_dataset,
"families": fams, "seed": seed, "n_boot": n_boot, "val_split": val_split,
"temperature": list(agent.temperature), "gpu": torch.cuda.get_device_name(0), "row_counts": counts,
"minutes": (time.time() - t0) / 60}
if not write:
return {"meta": meta, "summary": summary, "compact": R.compact(summary), "out_dir": None}
os.makedirs(out_dir)
with gzip.open(os.path.join(out_dir, "predictions.jsonl.gz"), "wt") as f:
for p in preds:
f.write(json.dumps(p, sort_keys=True) + "\n")
with open(os.path.join(out_dir, "summary.json"), "w") as f:
json.dump({"meta": meta, **summary}, f, indent=1)
ckpt_vol.commit()
print("wrote %s (%d rows)" % (out_dir, len(preds)))
return {"meta": meta, "summary": summary, "compact": R.compact(summary), "out_dir": out_dir}
@app.local_entrypoint()
def robustness_eval(run: str = "cauldron-score-2ep-bidir-full/best", datasets: str = ",".join(ROBUSTNESS_DATASETS),
n: int = 300, families: str = "", seed: int = 0, tag: str = "", out: str = "results/robustness"):
"""Run ``robustness`` (``modal run --detach`` keeps it going if this client drops), then copy
``predictions.jsonl.gz`` and ``summary.json`` from the volume into ``out`` (refusing to overwrite). After a
dropped client: ``modal volume get laya-checkpoints smolvlm/robustness/<tag>/ <out>``."""
for fname in ("predictions.jsonl.gz", "summary.json"):
if os.path.exists(os.path.join(out, fname)):
raise SystemExit("%s already holds %s; pass a new --out" % (out, fname))
res = robustness.remote(run, datasets, n, families, seed, tag=tag)
rel = os.path.relpath(res["out_dir"], "/ckpt")
os.makedirs(out, exist_ok=True)
for fname in ("predictions.jsonl.gz", "summary.json"):
with open(os.path.join(out, fname), "wb") as f:
for chunk in ckpt_vol.read_file(rel + "/" + fname):
f.write(chunk)
print("copied %s -> %s" % (res["out_dir"], out))
@app.function(image=image, gpu="L4", cpu=8, memory=32768, timeout=40 * 60,
volumes={"/cache/hf": hf_vol, "/data": data_vol.read_only(), "/ckpt": ckpt_vol})
def invariance(run_name: str = "cauldron-score-2ep-bidir-full/best", datasets: str = ",".join(ROBUSTNESS_DATASETS),
n_per_dataset: int = 50, seed: int = 0, batch_size: int = 32, bf16: bool = True, val_split: str = "val",
tag: str = "", code_commit: str = ""):
"""Repeat / batch / prefix-cache invariance (``laya.robustness.invariance``) of ``n_per_dataset`` seeded val
rows per set on the GPU. Precision per condition: the batch-path conditions (``collect_logits``) run under bf16
autocast on CUDA (not switchable without changing ``collect_logits``); the ``predict`` conditions run in the
checkpoint's dtype without autocast; ``predict_vs_batch`` is the gap between the two; ``bf16`` also casts the
backbone weights to bf16 (still under autocast). Writes ``invariance.json`` to
``ROBUSTNESS_ROOT/invariance/<tag>/`` (a new directory; the job refuses an existing one) and returns it."""
import hashlib
import torch
from laya import robustness as R
from laya.robustness.invariance import format_table, run_invariance
from laya.vlm import VLMAgent
out_dir = os.path.join(ROBUSTNESS_ROOT, "invariance",
tag or "%s-n%d-s%d" % (run_name.replace("/", "_"), n_per_dataset, seed))
ckpt_vol.reload()
if os.path.exists(out_dir):
raise SystemExit("%s exists; pass a new --tag" % out_dir)
t0 = time.time()
gpu = torch.cuda.get_device_name(0)
print("GPU:", gpu)
data_vol.reload()
rows = []
for name in _ready(datasets):
src = R.source_rows(_load_split(name, val_split, 0), n=n_per_dataset, seed=seed, dataset=name)
print("dataset %s: %d source rows" % (name, len(src)))
rows += src
path = _ckpt_path(run_name)
files = {}
for fname in sorted(os.listdir(path)):
fp = os.path.join(path, fname)
if os.path.isfile(fp):
h = hashlib.sha256()
with open(fp, "rb") as f:
for chunk in iter(lambda: f.read(1 << 22), b""):
h.update(chunk)
files[fname] = h.hexdigest()
agent = VLMAgent(path, device="cuda")
print("temperatures (choice, score, noul):", [round(t, 3) for t in agent.temperature])
res = run_invariance(agent, rows, batch_size=batch_size, bf16=bf16)
print(format_table(res))
model_dtype = str(agent.model.encoder.dtype).replace("torch.", "")
precision = {c: ("bf16 autocast, %s weights (collect_logits)" % model_dtype) for c in
("repeat", "batched", "batched_reversed", "hostile")}
precision.update({c: "%s, no autocast (predict)" % model_dtype for c in
("predict_repeat", "predict_multi", "predict_multi_hostile", "predict_prefix_cache",
"predict_multi_prefix_cache")})
precision["predict_vs_batch"] = "%s no autocast (predict) vs bf16 autocast (collect_logits)" % model_dtype
precision["bf16"] = "bf16 backbone weights + bf16 autocast vs %s weights + bf16 autocast" % model_dtype
res["meta"].update(