The RampNet-vs-YOLO gap at matched operating points is 0.039, not 0.252 — plus the label rescue and the equirect control (#51) - #154
Conversation
Every label directory under /gscratch/scrubbed/jfroehli/yolo/ is empty -- all five
dataset variants, train and val -- while every image directory is intact (557,413
train tiles, 161,002 val). That is why y11x_tiles (38612069) and its two retries
(38657533, 38657535) all died at dataset init with
ValueError: train: No labels found in .../yolo/tiles/labels/train.cache
The mechanism is worth naming because it will recur. `scrubbed` purges by access
time, and the .cache files are precisely what stopped anything from reading the
individual label .txt files after 2026-07-25. Their atime froze while the images
kept being read every epoch, so the purge took the labels and left the images.
**The cache that made training fast is what got the labels deleted.** Same shape as
the partially-purged conda package cache that broke the env build earlier in #51: a
populated cache actively hides the thing it caches from whatever decides what is
cold, so a cache is not a backup and its presence is not evidence its source exists.
Recovery needs no GPU and no re-derivation. Ultralytics' cache is not a digest -- it
stores parsed labels, `cls` (n,1) and `bboxes` (n,4) as normalized xywh, which is the
on-disk .txt format itself. 968,227 boxes over 557,413 train records are all there.
The write format is lifted from prepare_yolo_dataset.py::_write_pair so rebuilt files
are byte-identical to the originals for any value surviving the float32 round trip,
including the zero-byte file a background tile gets. That distinction is load-bearing:
Ultralytics counts a missing label as `nm` and an empty one as `nf`, and the error
above fires when `nf == 0`.
Two deliberate choices:
- **Read the durable cache copies under /gscratch/makelab**, not the ones on scrubbed.
The scrubbed copies are the siblings of what was already lost once; rebuilding from
them would make the recovery depend on the thing that failed. The rescued pair was
staged 2026-08-17 with sha256sums.txt, which the launcher checks before trusting it.
- **Rebuild rather than re-run prepare_yolo_dataset.py.** The cache reproduces the
labels the published arms actually trained on; re-deriving would produce labels from
today's code and geometry constants. Those should agree, but the arms in flight were
trained on these.
--verify re-reads every written file and compares the boxes back against the cache,
which is the only check that proves the round trip rather than assuming it.
The rebuild is a batch job because it creates ~718,000 small files on GPFS, which is
sustained and metadata-heavy; klone reaps heavy login processes and that reap also
kills the SSH control master.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#51) The first version pointed at an ad-hoc staging directory from the session that wrote it, which is exactly the 'configured by edits made during a session' shape the replication rule exists to prevent. Every other klone launcher in the repo defaults to $HOME/RampNet; match them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rebuild verifies its output against the cache it read, which proves the round trip but not the thing that actually broke -- the failure was inside Ultralytics' dataset init, so the only test that closes the loop is making Ultralytics build the dataset and report a non-zero nf. Two checks a bare 'did it crash' run would miss: - **nf against nm.** Ultralytics treats a MISSING label file as a background image and carries on, so a rebuild that skipped the label-less tiles would still 'work' while silently changing the dataset. 59,923 of 557,413 train tiles are background and must be present as zero-byte files, not absent. - **Total boxes.** Loading is not loading everything. If the rebuilt dataset scans to a number other than the cache's 968,227, the labels are wrong in a way that trains fine and scores wrong. No model, no GPU, no training step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…age (#51) Two things on /gscratch/scrubbed were one purge away from unreproducible, and they fail for the same reason, so this protects both in one job (39361251, rc=0, 2.5 min). **The pano label caches had no durable copy at all.** The 2026-08 purge took every label .txt under yolo/pano/labels/ -- 0 files remain against 150,063 + 42,875 surviving images -- exactly as it did for tiles. pano/labels/{train,val}.cache survived and can reconstitute them, but they were sitting on the same volume, on the same access-time clock, that had already deleted their own labels once. The tiles pair was staged to /gscratch/makelab on 2026-08-17; the pano pair never was. They are now at label_cache_rescue/pano/ (39,475,038 + 11,227,447 bytes) with sha256sums.txt in the format run_rebuild_yolo_labels.slurm already checks. **The checkpoint snapshot was 14 days stale, on exactly the arms under discussion.** Every tiles arm had trained past its durable copy: y11x_tiles ep44 vs ep32 saved y26_tiles ep12 vs ep9 y11x_pano ep38 vs ep30 saved y11l_tiles ep11 vs ep9 so the checkpoints an evaluation would actually score existed at their current epoch in one place, and it was the volume with the demonstrated purge behaviour. Refreshed: 12 copied, 16 unchanged, 0 failed, 50 verified hashes. The rescue is a script rather than the hand-assembly that staged the tiles pair, because a hand-assembled backup cannot be re-run by someone else -- the test this repo applies to everything else. It reuses snapshot_runs.sh's discipline: .tmp-then-rename after the hash matches, and the source hashed before AND after so a cache being rewritten by a live run cannot be captured torn. One deliberate refusal: rescue_label_caches.sh will NOT overwrite a differing durable copy without FORCE=1. The live tiles cache is now a LATER regeneration (Ultralytics rebuilt it during the 08-29 acceptance test) than the durable 08-17 copy, so a backup script that blindly mirrored would have replaced the original the published arms trained on with a look-alike, while reporting success. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ured (#51) #51's headline is that RampNet beats a supervised YOLO baseline by 0.12-0.37 F1 on nine out-of-distribution splits. The standing objection is that this is not architecture: YOLO is being fed 2048x4096 equirectangular panoramas, which is not the geometry a COCO-shaped detector expects, so the gap could be a handicap rather than a result. The tiles arms exist precisely to answer that -- same data, same schedule, fed through the same perspective-view rig the VLMs get -- and NO tiles checkpoint had ever been scored on the benchmark. The control for the headline was unmeasured. Three legs, each in its training geometry per the pre-registered protocol (#71): y11x_tiles ep44 perspective, imgsz 1024 y11x_pano ep38 whole-pano, imgsz 1280 y11x_pano_h200 ep60 whole-pano, imgsz 1280 control, already published The first two are the same architecture at near-matched budget with opposite geometry, which is the cleanest read on the confound available without training anything. They are not EQUAL budget -- 44 vs 38 are the epochs that exist, not a design -- so the pair is "roughly matched" and the h200 control says what a converged pano arm does. The control is the part worth defending. The published h200 numbers were produced 2026-08-14 against a repo predating the #132 seam fix, which changed how the matcher wraps the 360 seam and therefore changes scores. Scoring a fresh tiles number against a stale pano number would attribute a code change to geometry. Re-running it here under the same commit keeps all three mutually comparable and measures how far the published number moved. Two invocations per split rather than one: --tiling and --yolo-imgsz are global flags, so a tiles arm and a pano arm cannot share a call. The two pano arms do share one, which also makes that table the direct ep38-vs-ep60 read. env.txt records repo HEAD, torch/ultralytics versions, GPU and all three checkpoint sha256s, because a number whose checkpoint hash is not written down cannot be re-derived by anyone else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rchitecture (#51) #51's headline is that RampNet beats a supervised YOLO baseline on the same data by 0.252 F1 over the seven pooled US splits, and that the difference is the keypoint architecture. The standing objection is that the YOLO arms are handicapped: they are fed 2048x4096 equirectangular panoramas, where straight edges bow and a far ramp occupies very few pixels. The tiles arms exist to answer exactly that, and NO tiles checkpoint had ever been scored -- so the control for the headline was unmeasured. It is now measured. Pooled macro-mean F1 at the pre-registered conf 0.25: y11x_tiles ep44 0.667 -0.160 vs RampNet y11x_pano ep38 0.623 -0.204 y11x_pano_h200 ep60 0.575 -0.252 <- the published arm THE CONTROL REPRODUCED EXACTLY. y11x_pano_h200 was published on 2026-08-14 against a repo predating the #132 seam fix, which changed how the matcher wraps the 360 seam and therefore changes scores. Re-scored here under the same commit as the two new legs it returns P 0.969 / R 0.416 / F1 0.575 / AP 0.730 -- identical to three decimals. That validates the comparison AND means the published #51 table needs no seam amendment. Scoring a fresh tiles number against a stale pano number would have attributed a code change to geometry; the assertion is in the script so the day it stops holding, it fails. THE GAP DOES NOT DECOMPOSE INTO GEOMETRY ALONE. Quoting the raw +0.092 tiles-minus- published difference would overclaim, because ep44 vs ep60 mixes geometry with budget. Split on the pano lineage, where only budget moves: over-training pano ep60 -> ep38 +0.048 same geometry geometry pano ep38 -> tiles +0.044 ~matched budget So the handicap is real and worth about 0.044 F1 -- roughly half the recoverable difference, not the whole of it. As with the Vistas parity arm, the geometry half is RECALL (0.474 -> 0.520) at flat precision. #51's claim survives, restated: the honest residual is 0.160 F1, not 0.252, and the write-up should say so. The budget term is confounded -- y11x_pano and y11x_pano_h200 are divergent continuations of one lineage on different hardware, not one run at two epochs. Stated next to the number, in the doc, not in a separate limitations note. A result nobody was looking for: more training made the pano arm WORSE out of distribution. ep38 beats ep60 on 6 of 7 city splits while the ordering REVERSES in-distribution (manual_gold 0.840 vs 0.851). That lines up with the already-recorded finding that the 1-epoch RampNet release beats all three 60-epoch YOLO arms. Bearing on the open ~400 GPU-hour decision, which this was run to inform: y11x_tiles val mAP50-95 moved +0.011 across 36 epochs (0.46686 ep8 -> 0.47804 ep44), +0.0054 over the last twelve, so sixteen more buy about +0.007 on the metric selection uses -- and the pano lineage suggests the extra epochs may cost OOD F1, which is the regime every city split measures. The cheaper experiment that answers more is scoring the tiles checkpoints that already exist at several epochs, for a benchmark F1-vs-epoch curve instead of a val-mAP one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…51) The published comparison reads RampNet at 0.55 -- its shipped deployment threshold -- and every YOLO leg at 0.25, which is the Ultralytics predict() default that nobody selected. So the headline gap mixes "which model is better" with "whose default happened to suit F1 on this benchmark". Give each model one uniform threshold, picked the same way, on a dev split the headline is never reported over, and the residual falls from 0.160 to 0.039. The asymmetry is the finding: parity moves RampNet +0.018 because 0.55 was nearly right for it, and moves y11x_tiles +0.137 because 0.25 was nowhere near right. Stable at 0.032-0.039 across all three candidate dev splits. Two things that do not change, both stated in the doc: - AP still favours RampNet by 0.076 (0.849 vs 0.773), and both sides were already floored at 0.05, so that column was never asymmetric. RampNet has the better curve; the F1s at each model's own best point are what is close. - RampNet's lead is not uniform. y11x_tiles beats it on morgantown and on manual_gold -- the only independently-labelled split -- and ties on bend. CPU only, no GPU, no network: RampNet is re-scored from the committed op_cache and the YOLO legs from their committed sweeps. Control: RampNet at 0.55 re-scores to 0.824 against a bundle-derived published 0.827, a -0.0025 peak extraction difference that the script asserts stays under 0.005. Seed variance is now the binding limitation rather than a footnote -- at a 0.160 gap it was irrelevant, at 0.039 it is the whole question. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ed (#51) The geometry write-up closed by recommending the cheap follow-up -- "score the tiles checkpoints that already exist at several epochs (they are on durable storage)" -- to turn the fork-confounded budget term into a measured one. They do not exist. Every arm trained with save_period: -1, so Ultralytics kept only best.pt and last.pt; find over both the durable snapshot and /gscratch/scrubbed/jfroehli/yolo_runs returns zero epoch*.pt, and best.pt is 0-4 epochs from last.pt on every arm. y11x_pano_h200 was a resume, so it inherited save_period=-1 and the Tillicum launcher's SAVE_PERIOD=5 never applied. retarget_yolo_checkpoint.py already documents exactly this -- "per-epoch weights cannot be recovered for an arm that did not start with it" -- so the recommendation contradicted a committed docstring in this repo. The consequence is that the budget term cannot be de-confounded cheaply: a resume honours the checkpoint's saved save_period, and a fresh start resets the LR schedule and stops being a continuation. Full retrain or nothing, and since the geometry half is the better-controlled one and already measured, leaving it confounded is the right call. Also marks this page's headline residual as superseded by the matched operating-point read. The geometry decomposition itself is unaffected -- it compares YOLO legs to each other, all at the same 0.25. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deep reviewRe-ran everything from the branch head (
1. High — the scripts and tests read the live
|
| split | bundle | op_cache | in bundle, not in cache | at the seam | in cache, not in bundle | ΔF1 @0.55 |
|---|---|---|---|---|---|---|
| richmond, clovis, morgantown, annapolis, budapest_district5 | — | — | 0 | 0 | 0 | 0.0000 |
| bend | 265 | 257 | 35 | 0 | 27 | +0.0034 |
| paterson | 284 | 281 | 36 | 0 | 33 | −0.0048 |
| gainesville | 205 | 197 | 33 | 1 | 25 | −0.0163 |
| sao_paulo (the dev split) | 251 | 228 | 53 | 1 | 30 | −0.0189 |
| manual_gold | 3610 | 3487 | 495 | 26 | 372 | −0.0095 |
Five splits agree detection-for-detection (tp/fp/fn identical). The other five differ by 13–14 % of their detections, mostly at different locations (bend: 8 of 35 are the same point at a different confidence; the rest are elsewhere). That is two different heatmaps, not one heatmap read at two floors. And the repo has already measured why — docs/operating_point.md:80–90: manual_gold's bundle was exported with horizontal-flip TTA ("3610 vs 3487 at ≥ 0.55", the exact counts above), while op_cache is the no-TTA deployment path; and for the GSV splits (bend, paterson, gainesville, and sao_paulo) the production bundle came from a different resample of the panorama than the native-res cache, with detections displaced up to 0.44 R. The five that agree are the Mapillary splits, which #152 records as bit-exact between the two paths.
Consequences for this PR:
- The 0.005 tolerance passes on the pooled macro only by cancellation (+0.003, −0.005, −0.016 across the three GSV splits in the pool). A per-split control would have failed on gainesville.
- The dev split has the largest discrepancy of the ten (−0.019). RampNet's threshold was selected on the split where its
op_cachecurve is least like its shipped detections. (The selection is probably robust — the sensitivity table lands on 0.30–0.35 either way — but that is an argument the doc has to make, not one it can skip.) - On
manual_goldthe TTA-vs-no-TTA delta (0.0095) is the same size as the margin by which the tiles arm "wins" it (0.009). See finding 3.
Fix: replace the peak-extraction paragraph in the doc, the script docstring, and the test docstring with the documented causes and a pointer to operating_point.md's parity gate; add the per-split Δ table above to the artifact (control.per_split) and print it; keep the macro assertion but add a per-split one with its own tolerance, including the dev split and manual_gold, so cancellation cannot hide a split-level divergence again.
3. Medium — the per-split "wins" are read below the doc's own noise rule and inside the source discrepancy
operating_point_parity_51.md "Gaps" says differences under ~0.02 F1 should not be read. Then §2 reads 0.009 (manual_gold, 0.911 vs 0.902) as "loses the split that matters most" and "the first result in this benchmark where a non-RampNet model takes a split outright", 0.017 (morgantown) as a win, and the status line counts "3 of the 10 splits". The PR body promotes manual_gold to one of the two things reviewers must see before merging.
The direction is not fragile to RampNet's threshold — over the whole 0.05–0.95 grid RampNet's best on manual_gold is 0.904 @0.35 and on morgantown 0.851 @0.40, both still below the tiles arm — but the margin is inside the TTA delta on that split (finding 2): RampNet's committed manual_gold configuration (TTA, detections_meta.json) at 0.55 scores 0.9085 against the no-TTA 0.8990, so at 0.30 with TTA it would plausibly tie 0.911. A paired test would settle it and needs no GPU (both models' per-pano detections exist), but the tiles arm's detections are not committed (see the decision below), so it cannot be run from a clean clone.
Fix: qualify §2, the status line and the "3 of 10" count with the 0.02 rule and the TTA caveat ("within the noise rule; the manual_gold margin is the size of the TTA delta on that split; a paired test is blocked on publishing the tiles detections"). The PR body's reviewer note is Jon's to soften.
4. Medium — the scoreboard and the YOLO README now contradict this PR and do not point to it
docs/model_scoreboard.md:362: "The YOLO tiles arms are absent — still training." The tiles arm is scored on all ten splits in this PR.scripts/model_comparison/yolo_baseline/README.md:88: "the tiles arms remain unevaluated."- The scoreboard's headline table and its
manual_goldcolumn (RampNet 0.908 vs YOLO11x 0.851) are the published comparison; a reader of that page has no way to find that at matched operating points the residual is 0.039 and the best YOLO leg is within noise of RampNet on three splits. This PR touches neither file.
Fix: a short caveat block in model_scoreboard.md beside the headline table (or under "Choosing an operating point") linking operating_point_parity_51.md and yolo_geometry_51.md, stating that the YOLO rows are at the un-selected 0.25 default and what parity does to them; replace the two stale sentences. docs/model_comparison.md's YOLO section should get the same one-line pointer.
5. Medium — provenance is recorded, but four gaps are not stated beside the numbers
env.txt: "repo dirty : 10 paths" atd964d5d. The doc says "at repod964d5d" only.git status --porcelain | wc -lcounts untracked outputs and modified code alike, so a reader cannot tell which; the driver should log the list, not the count.- The control leg was not re-inferred. All ten
*_pano.txtreports say[y11x_pano_h200] all N panos already cached; model load skipped. The control re-scores detections cached on makelab2 from the 2026-08-14 publishing run — which is the right test for a matcher change and is what "re-scored" means, butrun_yolo_geometry_eval.sh:19says "re-run", and the doc should say the control isolates the matcher and does not test inference reproducibility. - Where the checkpoints live.
env.txtrecords sha256s of copies under makelab2yolo_ckpts/. Nothing in the PR says which durable path on klone (/gscratch/makelab/jonf/rampnet_yolo_baseline_51/<arm>/weights/best.ptperrun_durable_snapshot.slurm) carries those hashes, or that the checkpoints are unpublished — so "reproducible by someone with cluster access" is true but undiscoverable, and "not reproducible from a clean clone" is unstated. - The rescue run's outcome lives only in the PR body.
rebuild_yolo_labels_from_cache.pyandcheck_yolo_dataset_loads.pycarry the expected counts (557,413 / 968,227 / 59,923 background) in docstrings; the job ids, dates,--verify PASSand the acceptance-test output are not in the repo.
Fix: a "Provenance and cost" block in yolo_geometry_51.md covering the four points; run_yolo_geometry_eval.sh logs git status --porcelain in full; commit the rebuild/snapshot job logs (or a dated section in yolo_baseline/README.md with job ids and the PASS lines).
6. Medium-low — cost is not recorded
driver.log gives the eval as 17:10:09 → 18:17:48 on one A40: 67 min 39 s wall, ≈ 1.13 GPU-h, $0 (makelab2). The klone rescue and snapshot jobs (CPU, ckpt-all) have no job ids anywhere in the repo. The time-and-money rule is a standing one even though #147 has not merged.
Fix: one line in each doc; a compute_log.jsonl row once #147 lands (state "pending #147" until then).
7. Low
rebuild_yolo_labels_from_cache.pyoverclaims "byte-identical". Ultralyticsverify_image_label(read in 8.4.104 here; 8.4.120 at eval) drops duplicate rows and, when it does, reorders the survivors (lb = lb[i]fromnp.unique(..., return_index=True)). A file that had a duplicated box is rebuilt deduplicated and sorted — training-equivalent, not byte-identical.write_text(body)also takes the platform newline; addnewline="\n". The float32 arithmetic itself is right (≤ 6e-8 error against 5e-7 rounding), and the 1e-6 verify tolerance is correct.test_no_candidate_dev_split_is_ever_reported_overcannot fail.NON_POOLEDis defined as the complement ofUS_SPLITS(:106), soisdisjointis a tautology. The invariant is real — argparsechoicesandbuild()enforce it — but the doc cites this test as the guarantee. Assert it onbuild(dev)for every candidate instead (dev not in result["pool"]and the pool is exactly the pinned seven).PUBLISHED_RAMPNET_F1 = 0.827and 0.55 are duplicated across the two scripts; the parity script already imports fromyolo_geometry_51. Import them.- The AP table's RampNet 0.849 is not in either artifact. It is the scoreboard's macro AP (
model_scoreboard.md:32,scoreboard.json); say so in the caption or compute it fromop_cachein the script. - Slang in committed text: "load-bearing" (
yolo_geometry_51.md:40,yolo_geometry_51.py:19, both test docstrings), "split-brain" (operating_point_parity_51.md:156,.py:64). Plain wording. scripts/analysis/README.mdindexes the analysis scripts and does not list the two new ones.
Decisions for Jon, not fixes
- Register
y11x_tilesandy11x_panoas roster legs and publish their detections. Neither is inrampnet/roster.pyandbenchmark/model_detections/has no files for them, so the scoreboard cannot show the parity result and the paired test in finding 3 cannot be run from a clean clone.export_model_cache.pyfrom the makelab2 cache is the path; it is the same "unpublished parity detections" gap Vistas at resolution parity: the handicap was real, and it was recall (#126) #137 carries for Vistas. - Merge order with Add Laurens, IA — the first rural split (recall 0.390) and register it as the 8th US split #152/The Laurens deficit is the rig, not the town: add the GSV arm and score all 11 challengers on both (#151) #153. After finding 1 there is none. Before it, whichever merges second is red.
- Three seeds of
y11x_tiles— the doc's own recommendation and a GPU spend. - The GSV-split
op_cachevs production-resample discrepancy (finding 2) is a pre-existing GT-completeness correction: spot-check the low-confidence incremental FPs from the operating-point curve #55/Assess location precision for the unassessed candidate cities — the gate every route to 500k depends on #96 matter, not this PR's; whether to regenerate those caches is separate.
What holds up
- Both artifacts regenerate byte-identically on Windows; LF-only, floats rounded to 4 dp;
--checkon both is a real guard (the JSON comparison is of the full payload, sensitivity included). - The methodology is the fair one: a single uniform threshold per model, selected on a split the headline never reports over, with sensitivity over all three candidates; macro-mean matches the scoreboard's convention (
scoreboard.py:33, 356–398); both floors really are 0.05 (meta.score_flooron all ten caches,YoloDetector.score_threshold = 0.05atdetectors.py:1290), so the AP comparison was always like-for-like as the correction says. - RampNet's selected threshold on
sao_paulois 0.30 — Lower the deployment operating point (peak threshold 0.55 → ~0.25–0.35): a free, recall-first lever #54's recommendation, reached by an independent route. - The control leg reproduces its published row to every digit and the test proves the check can fail.
- The decomposition arithmetic, the "6 of 7" claim (paterson a tie at 3 dp), the in-distribution reversal, and the recall-not-precision mechanism all check against the JSON.
- The retraction is kept beside the recommendation it retracts (
yolo_geometry_51.md:152–167), and the "Superseded" block in §2 points forward — nothing was silently deleted. - The rescue tooling has no destructive step:
.tmp+ rename after a hash match, a second source hash to catch a torn copy, refusal to overwrite a differing durable copy withoutFORCE=1, stale.cachefiles moved aside not deleted. Zero-byte background files are the correct Ultralytics semantics (nf, notnm), and the acceptance test drivesYOLODatasetitself. env.txtpins repo HEAD,ultralyticsversion, GPU and three sha256s, and a test asserts that stays true.- No ToS or licensing text anywhere in the diff.
Fix list
- [F1] High — Pin the split population in both scripts as explicit tuples with a reason (seven pooled + the ten as run), derive
NON_POOLEDfrom them, and make the tests assert against the pinned tuples, not the liveUS_SPLITS/ALL_SPLITS— files: scripts/analysis/operating_point_parity_51.py, scripts/analysis/yolo_geometry_51.py, tests/test_operating_point_parity_51.py, tests/test_yolo_geometry_51.py - [F2] High — Replace the "peak extraction / min_distance" explanation with the documented causes (flip-TTA on manual_gold; production-vs-native resample on the GSV splits; Mapillary splits identical), cite docs/operating_point.md's parity gate, add a per-split bundle-vs-op_cache Δ table to the artifact and the doc, and add a per-split control assertion (dev split and manual_gold included) beside the macro one — files: scripts/analysis/operating_point_parity_51.py, docs/operating_point_parity_51.md, tests/test_operating_point_parity_51.py, docs/data/operating_point_parity_51.json
- [F3] Medium — Qualify the manual_gold / morgantown / "3 of 10" reads with the doc's own 0.02 rule and the TTA caveat; state that a paired test is blocked on publishing the tiles detections — files: docs/operating_point_parity_51.md
- [F4] Medium — Add a caveat block to model_scoreboard.md linking both new docs and stating the YOLO rows are at the un-selected 0.25 default; replace the stale "tiles arms are absent / remain unevaluated" sentences; one-line pointer in docs/model_comparison.md's YOLO section — files: docs/model_scoreboard.md, scripts/model_comparison/yolo_baseline/README.md, docs/model_comparison.md
- [F5] Medium — Add a "Provenance and cost" block: the 10 dirty paths caveat, the control re-scored from cache not re-inferred, the durable checkpoint path + unpublished status, and the rescue job ids/dates/PASS output; make the driver log
git status --porcelainin full — files: docs/yolo_geometry_51.md, scripts/model_comparison/yolo_baseline/run_yolo_geometry_eval.sh, scripts/model_comparison/yolo_baseline/README.md - [F6] Medium-low — Record the eval cost (67 min 39 s on one A40 ≈ 1.13 GPU-h, $0) and the klone rescue jobs' ids in the docs — files: docs/yolo_geometry_51.md, docs/operating_point_parity_51.md
- [F7] Low — Docstring caveat on dedup/reorder (not byte-identical for files with duplicate boxes);
write_text(body, newline="\n")— files: scripts/model_comparison/rebuild_yolo_labels_from_cache.py - [F8] Low — Make the dev-split test assert on
build(dev)output for every candidate instead of the tautologicalisdisjoint— files: tests/test_operating_point_parity_51.py - [F9] Low — Import
PUBLISHED_RAMPNET_F1(and the 0.55 deployed threshold) from yolo_geometry_51 instead of redefining — files: scripts/analysis/operating_point_parity_51.py - [F10] Low — State the source of the AP table's RampNet 0.849 (scoreboard macro AP) or compute it in the script — files: docs/operating_point_parity_51.md, scripts/analysis/operating_point_parity_51.py
- [F11] Low — Replace "load-bearing" and "split-brain" with plain wording — files: docs/yolo_geometry_51.md, docs/operating_point_parity_51.md, scripts/analysis/yolo_geometry_51.py, scripts/analysis/operating_point_parity_51.py, tests/test_operating_point_parity_51.py, tests/test_yolo_geometry_51.py
- [F12] Low — Add the two new scripts to the analysis index — files: scripts/analysis/README.md
🤖 Generated with Claude Code (claude-fable-5-1)
Three connected things for #51, in the order they had to happen: rescue the training data a purge had deleted, then answer the standing equirect objection by scoring the tiles arms that objection exists for, then fix the comparison itself — which turned out to have been reading the two models at operating points chosen by different procedures.
Net effect on the headline: the RampNet-vs-YOLO residual is 0.039 F1, not 0.252 and not 0.160.
1. The comparison was not at parity, and that is most of the gap
model_scoreboard.mdandyolo_geometry_51.mdcompared rows whose operating points were chosen differently: RampNet at 0.55 (its shipped deployment threshold), every YOLO leg at 0.25 — the Ultralyticspredict()default that nobody selected. So the published gap mixed which model is better with whose default happened to suit F1 on this benchmark.Give each model one uniform threshold, picked the same way, on a dev split the headline is never reported over (
sao_paulo), macro-meaned over the seven pooled US splits:y11x_tiles(ep44)y11x_pano(ep38)y11x_pano_h200(ep60)The asymmetry in the last column is the whole finding. 0.55 was nearly right for RampNet; 0.25 was 0.137 F1 away from YOLO's own optimum. The gap holds at 0.032–0.039 across all three candidate dev splits, so the choice of dev split is not carrying it. Selection never touches a reported split, and
test_no_candidate_dev_split_is_ever_reported_overasserts that rather than trusting review.Two things reviewers should see before merging
RampNet loses
manual_goldat parity — 0.902 against the tiles arm's 0.911 — and losesmorgantown(0.845 vs 0.862) and tiesbend.manual_goldis the only split whose ground truth was labelled independently of RampNet's own outputs, the least circular comparison in the benchmark, and the supervised baseline takes it. This is the first split any non-RampNet model has won outright at a defensible operating point.AP is unmoved and still favours RampNet, 0.849 vs 0.773. A correction travels with that: an earlier reading of this analysis claimed the AP columns were asymmetrically floored, and that was wrong —
op_cachemeta.score_floorandYoloDetector.score_thresholdare both 0.05, so that column was always like-for-like. The honest summary is that RampNet has the better curve, while at each model's own best point the F1s are much closer than published.2. The equirect objection, measured
#51's claim is that the architecture is what RampNet contributes. The standing objection is that the YOLO arms are handicapped — fed 2048×4096 equirectangular panoramas, where straight edges bow and a far ramp is a few pixels. The tiles arms exist to answer exactly that, and no tiles checkpoint had ever been scored, so the control for the headline was unmeasured.
At the pre-registered conf 0.25, pooled macro-mean F1:
y11x_tilesy11x_panoy11x_pano_h200The raw +0.092 tiles-minus-published difference would overclaim, because ep44-vs-ep60 mixes geometry with budget. Split on the pano lineage, where only budget moves:
So the handicap is real and worth about 0.044 F1 — half the recoverable difference, not all of it. As with the Vistas parity arm, the geometry half is recall (0.474 → 0.520) at flat precision.
The control reproduced exactly.
y11x_pano_h200was published 2026-08-14 against a repo predating the #132 seam fix, which changed how the matcher wraps the 360° seam. Re-scored here under the same commit as the two new legs it returns P 0.969 / R 0.416 / F1 0.575 / AP 0.730 — identical to three decimals. That validates the comparison and means the published #51 table needs no seam amendment. The assertion is in the script, so the day it stops holding it fails loudly.A result nobody was looking for: more training made the pano arm worse out of distribution. ep38 beats ep60 on 6 of 7 city splits while the ordering reverses in-distribution (
manual_gold0.840 vs 0.851). That lines up with the already-recorded finding that the 1-epoch RampNet release beats all three 60-epoch YOLO arms, and it is the main reason the open ~400 GPU-hour ep60 run still does not look worth it.The budget term is confounded —
y11x_panoandy11x_pano_h200are divergent continuations of one lineage on different hardware, not one run read at two epochs. Stated next to the number, in the doc.3. The data rescue that had to come first
Every label directory under
/gscratch/scrubbed/jfroehli/yolo/was empty — all five dataset variants, train and val — while every image directory was intact. That is whyy11x_tiles(38612069) and its two retries died at dataset init withNo labels found in .../tiles/labels/train.cache.The mechanism is worth naming because it will recur:
scrubbedpurges by access time, and the.cachefiles are precisely what stopped anything from reading the individual label.txtfiles. Their atime froze while the images kept being read every epoch, so the purge took the labels and left the images. The cache that made training fast is what got the labels deleted — same shape as the partially-purged conda package cache earlier in #51. A cache is not a backup, and its presence is not evidence its source still exists.Recovery needed no GPU and no re-derivation: Ultralytics' cache stores parsed labels (
clsand normalized-xywhbboxes), which is the on-disk.txtformat itself — 968,227 boxes over 557,413 train records. The writer is lifted fromprepare_yolo_dataset.py::_write_pairso rebuilt files are byte-identical for anything surviving the float32 round trip, including the zero-byte file a background tile gets — load-bearing, since Ultralytics counts a missing label asnmand an empty one asnf, and the error above fires whennf == 0.Then the same failure was closed off for the arms actually under discussion: the pano label caches had no durable copy at all, and the checkpoint snapshot was 14 days stale on every tiles arm (
y11x_tilesep44 vs ep32 saved,y11x_panoep38 vs ep30,y26_tilesep12 vs ep9,y11l_tilesep11 vs ep9) — so the checkpoints an evaluation would score existed at their current epoch in exactly one place, and it was the volume with the demonstrated purge behaviour. The rescue is a committed script rather than hand-assembly, because a hand-assembled backup cannot be re-run by anyone else.The epoch-curve follow-up is retracted
The geometry write-up closed by recommending the cheap follow-up — score the tiles checkpoints that already exist at several epochs — to turn the confounded budget term into a measured one. Those checkpoints never existed. Every arm trained
save_period: -1, so Ultralytics kept onlybest.ptandlast.pt;findover both the durable snapshot and/gscratch/scrubbed/jfroehli/yolo_runsreturns zeroepoch*.pt, andbestis 0–4 epochs fromlaston every arm.y11x_pano_h200was a resume, so it inheritedsave_period=-1and Tillicum'sSAVE_PERIOD=5never applied.retarget_yolo_checkpoint.pyalready documented exactly this, so the recommendation contradicted a committed docstring in this repo. The budget term cannot be de-confounded cheaply: full retrain or nothing.Gaps, stated
y11x_tilesis the experiment that would settle whether the architecture claim survives — and it should come before the ~400 GPU-h ep60 run.budapest_district5as dev its selected threshold is 0.05, the cache floor — the edge of what was ever measured. Closing this means re-dumping both models at a lower floor; doing one would reintroduce the asymmetry this PR removes.y11x_tilesis at ep44 of a 60-epoch schedule, but the pano lineage suggests more epochs would hurt it out of distribution, so this is not simply "the undertrained arm".xarchitecture is swept;y11l_*andy26_*have no committed sweep.model_comparison.mdare unaffected in either direction.Reproducing
CPU only, no GPU, no network — everything it reads is committed:
python scripts/analysis/operating_point_parity_51.py --sensitivity python scripts/analysis/operating_point_parity_51.py --check # fails if the artifact drifted pytest tests/test_operating_point_parity_51.py tests/test_yolo_geometry_51.py -qThe control worth keeping. RampNet at 0.55 re-scores to 0.824 from
op_cacheagainst the bundle-derived published 0.827. That −0.0025 is peak extraction, not a floor effect (the bundle floor is 0.5501, i.e. above 0.55):peak_local_maxat a 0.05 floor finds a different peak set than at 0.55, because newly admitted low peaks change which maxima survivemin_distance. The script asserts the two stay within 0.005 and fails loudly if that stops being true. Never assert equality between the two sources.Related: #151 found that the RampNet–YOLO ordering flips between imagery rigs over the same ground, which qualifies this result further.
🤖 Generated with Claude Code (claude-opus-5[1m])