Flip-TTA at the deployment operating points: measured, and not worth 2× GPU (#78) - #81
Conversation
…compare (#78) The evaluated config (stage_two/evaluate.py, flip-TTA) and the deployed config (single-pass) differ by an unmeasured delta at the operating points. This rides the #54 extraction exactly as issue #78 prescribes: - extract --tta: two passes per pano, the mirrored heatmap un-flipped and maxed with the original — numerically identical to stage_two/evaluate.py''s composition (clip commutes with max; asserted in tests). TTA caches must live in their own --cache directory; _assert_cache_arm refuses to mix arms in one dir, because every CPU consumer maps split -> <dir>/<split>.json with no idea which arm produced it, and extract''s resume-skip would silently "resume" over the other arm. - low_floor_sweep tta: both arms swept on the identical grid/GT per split plus pooled US, AP per arm, and the four-lever decomposition (drop alone, TTA alone, both, TTA-after-the-drop) — the marginal row is the 2x-GPU decision. manual_gold needs no GPU pass: its committed detections ARE a TTA export at the 0.05 floor (detections_meta.json), so the records are the arm and the single-pass cache supplies pano set + GT entry-for-entry. - parity is now arm-aware: gated only when cache arm == records arm, so the manual_gold special case generalizes instead of being hardcoded. First result (manual_gold, in-distribution control, CPU-only): TTA@0.55 P 0.947 / R 0.873 vs single P 0.955 / R 0.849 — +0.023 R for -0.007 P, AP 0.917 vs 0.904. But after the #54 threshold drop the marginal TTA gain shrinks to +0.019 R / -0.015 P: the two levers do overlap. City-split numbers pending the GPU extraction. 241 tests pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Full result over all seven splits, both arms on identical grid/GT: - Pooled US (609 panos): TTA at the deployed 0.55 buys +2.4 recall points for -1.1 precision -- but after the #54 threshold drop to 0.30 the marginal value collapses to +0.9 R / -2.4 P with F1 DOWN on four of five US splits (annapolis marginal dR is +0.003). TTA is a mild threshold-lowering in disguise: its recall curve is the single-pass curve shifted left, so the free lever absorbs almost all of what the 2x-GPU lever would buy. - AP moves -0.012 (annapolis) to +0.014 (morgantown), +0.004 pooled -- TTA does not reliably improve even the full curve out of domain. - manual_gold (in-domain control) is the exception: marginal +1.9 R / -1.5 P, AP 0.904 -> 0.917. The paper''s TTA eval was a reasonable in-domain choice; it does not transfer to deployment imagery. - The evaluated-vs-deployed configuration mismatch (sidewalk-auto-labeler#3) is quantified and closed: TTA-evaluated numbers read +2.4 recall points above what single-pass deployment delivers at 0.55. Decision recorded as a negative result: no production TTA knob is filed in sidewalk-auto-labeler, deliberately -- the #78 plan filed it only if TTA won at the chosen operating point, and it loses there. The 0.30 single-pass recommendation is unchanged. New docs/operating_point.md section with the lever ledger and caveat direction (the #55 bias overstates TTA''s marginal precision cost if anything; the exact marginal recall gain is too small to matter), committed figure (plot_tta.py, validated two-slot palette, pooled-US panels), and the reproduce block gains the extract --tta / tta / plot_tta commands. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same reasoning as the committed op_cache/ singles: image-free, ~300 KB total, and they are what makes every number in the new docs/operating_point.md TTA section re-derivable on CPU -- low_floor_sweep.py tta and plot_tta.py run from a clean checkout with no GPU and no panos. tta_compare.csv is the table view behind the committed figure, matching the op/*.csv convention. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
) The #78 section reported what happened without recording why, which is the part that generalizes. Two properties of the setup determine the whole result: - The composition is elementwise max, not an average, so the TTA heatmap is pointwise >= the single-pass heatmap everywhere. At a fixed threshold TTA can only promote detections, never suppress one -- that is the mechanical reason it *is* a threshold change rather than merely correlating with one. An averaging TTA would be a variance reducer instead; untested, and not what the paper used. - The model was already trained with random hflip (p=0.5, x -> 1-x). Test-time flipping can only exploit asymmetry the training augmentation failed to remove, so a small gain is the expected outcome, not an anomaly. Inverted: the marginal gain measures residual view disagreement at the operating point, and <=1.3 recall points per US split says the training augmentation worked. That second point also bounds the scope of the negative result. The flip is the ONLY augmentation in the recipe (Resize/ToTensor/Normalize otherwise), so #78 says the one trained-for invariance is saturated and says nothing about the ones never trained for. Filed as #82: yaw-roll and photometric jitter, the two augmentations that are both legal on an equirectangular projection and untried, measured against the cross-city recall spread. Added to "What would change this" as well, since better transfer would move the 0.30 threshold itself. Docs only; no numbers changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jonfroehlich
left a comment
There was a problem hiding this comment.
Deep Code Review: PR #81
PR Title: Flip-TTA at the deployment operating points: measured, and not worth 2× GPU (#78)
Branch: feat/flip-tta-78
Status: All 241 unit tests pass (pytest -q verified)
1. Executive Summary
PR #81 evaluates horizontal-flip Test-Time Augmentation (TTA) against single-pass inference across all 7 benchmark splits (5 US city splits, 1 Budapest split, and 1 in-distribution manual_gold split).
The PR addresses a longstanding configuration mismatch identified in sidewalk-auto-labeler#3 (where evaluate.py used flip-TTA while production deployment ran single-pass). It demonstrates empirically and mathematically that TTA is not worth the 2× GPU inference cost at deployment.
Headline Findings
-
TTA acts as an implicit threshold-lowering mechanism: Because composition uses pointwise elementwise maximum
$\max(H_{\text{orig}}, \text{fliplr}(H_{\text{flip}}))$ , heatmaps satisfy$H_{\text{TTA}} \ge H_{\text{single}}$ . TTA exclusively promotes predictions and cannot suppress false positives. -
Diminishing returns after threshold drop: While TTA appears attractive at the legacy
$0.55$ threshold (+2.4 recall points on pooled US splits), its marginal benefit collapses to +0.9 recall points at a cost of -2.4 precision points once the operating threshold is lowered to$0.30$ (as established in PR #54). F1 scores actually drop on 4 of the 5 US splits. -
Training augmentation saturation: Because training already incorporates random horizontal flipping (
$p=0.5$ ), the model's learned flip invariance is already near saturation ($\le 1.3$ recall points gained per split). - Decision: Explicitly records a negative result in documentation and declines to add a TTA knob to production auto-labeling pipelines.
2. Technical Code Analysis & Architecture
A. Heatmap Composition & Numerical Equivalence
-
operating_point_curve.py:compose_tta(h_orig, h_flip)implements$\max(H_{\text{orig}}, \text{fliplr}(H_{\text{flip}}))$ .-
Commutativity with Clipping: The PR correctly observes and proves (via unit test
test_compose_tta_commutes_with_clip) that composing raw logit heatmaps and clipping at detection time is numerically identical tostage_two/evaluate.py's clip-then-max order. -
Mirroring Order:
ImageOps.mirror(img)mirrors the input image before standard pre-processing, matchingstage_two/evaluate.py.
-
Commutativity with Clipping: The PR correctly observes and proves (via unit test
B. Lever Decomposition & Analysis Framework
-
low_floor_sweep.py:tta_levers(...)cleanly decomposes the recall gains into a 4-row ledger:-
threshold drop alone($0.55 \to 0.30$ single-pass) -
TTA alone($0.55$ single-pass$\to 0.55$ TTA) -
both($0.55$ single-pass$\to 0.30$ TTA) -
TTA after the drop($0.30$ single-pass$\to 0.30$ TTA) — the key 2× GPU decision metric.
-
- Added unit test
test_tta_levers_ledger_adds_upverifies arithmetic consistency (both == drop + marginal).
C. Cache Safety & Arm Isolation
operating_point_curve.py:_assert_cache_arm(cache_dir, tta)checks metadata of existing JSON files incache_dirand refuses to mix TTA (meta["tta"] = True) and single-pass caches in the same directory.- Prevents subtle evaluation bugs where downstream CPU scripts could score a single-pass cache as TTA or vice versa.
D. Zero-GPU Fallback for manual_gold
low_floor_sweep.py:tta_panos_from_records(...)reuses committedmanual_goldTTA export records directly without requiring a GPU pass, maintaining fast CPU-only test suite execution.
E. Visualization & Artifact Generation
plot_tta.py: Generatesdocs/figures/tta_operating_point.pngshowing the dual PR-curve and Threshold-vs-Metrics panels.- Uses the project-standard 2-slot color palette (
#2a78d6single-pass,#eb6834flip-TTA).
3. Code Quality & Standards Compliance
| Criterion | Assessment | Details |
|---|---|---|
| Scientific Protocol (CLAUDE.md) | EXCELLENT | Negative result is fully documented in docs/operating_point.md with theoretical justification and reproduction commands. |
| Test Coverage | EXCELLENT | 125 new lines of tests in test_low_floor_sweep.py and test_operating_point_curve.py covering composition, clipping commutativity, cache guardrails, and ledger math. All 241 repo tests pass. |
| Reproducibility | EXCELLENT | analysis_out/op_cache_tta/*.json and analysis_out/op/tta_compare.csv are committed; numbers and figures re-derive on CPU without GPU or image dependencies. |
| Documentation | EXCELLENT | Comprehensive updates to docs/operating_point.md explaining why max-composition TTA behaves like a threshold drop and bounding future augmentation experiments (#82). |
4. Strengths & Key Observations
-
Theoretical Clarity: The explanation of why TTA fails to add value at
$0.30$ threshold (max-composition elementwise inequality$H_{\text{TTA}} \ge H_{\text{single}}$ + saturation of trained horizontal flip augmentation) elevates this PR from a simple empirical benchmark to a generalizable architectural insight. -
Defensive Guardrails:
_assert_cache_armactively prevents accidental cache contamination across arms, saving future maintainers from silent evaluation errors. - No Production Complexity: By documenting the negative result, the author explicitly avoids adding dead configuration flags or unnecessary runtime complexity to the production auto-labeler.
5. Conclusion & Recommendation
Recommendation: MERGE AS IS
This PR is an exemplary piece of scientific research engineering. The code is well-tested, robust, thoroughly documented, and adheres strictly to repository guidelines.
🤖 Generated with Google Antigravity (Gemini 3.6 Flash)
Closes #78.
What this measures
The evaluated configuration (
stage_two/evaluate.py, horizontal-flip TTA) and the deployed detector (single-pass) have differed by an unmeasured delta since sidewalk-auto-labeler#3. This rides #54's low-floor extraction exactly as the issue prescribed: a secondextract --ttaarm (mirrored heatmap un-flipped and maxed with the original — numerically identical toevaluate.py's composition, with tests asserting the clip-order equivalence), swept post-hoc on identical GT alongside the single-pass arm.manual_goldneeded no GPU pass: its committed detections are a TTA export at the 0.05 floor, so the records supplied that arm.What flip-TTA actually is
For anyone reading this cold: it is the obvious thing. Run the panorama through the model; mirror the panorama and run it again; un-mirror that second heatmap back into pano orientation; combine the two. Nothing is retrained — it is purely an inference-time trick that costs a second forward pass.
The combine rule is where the entire result comes from.
stage_two/evaluate.py:191composes with elementwisenp.maximum, not an average.Two reasons this was worth running even though the outcome is negative:
--ttadefaults toTrueinevaluate.py, commented "as in the paper" — so every committed gold-set number is a TTA number, while the deployed labeler is single-pass. Sizing that gap was mandatory regardless of whether TTA turned out to be worth deploying.The answer
Pooled US (609 panos): TTA at the deployed 0.55 buys +2.4 recall points for −1.1 precision — but after the #54 threshold drop to 0.30, the marginal value collapses to +0.9 R / −2.4 P, with F1 down on four of the five US splits. TTA is a mild threshold-lowering in disguise (its recall curve is the single-pass curve shifted left — see the committed figure), so the free lever absorbs almost everything the 2×-GPU lever would buy. AP spans −0.012 (annapolis) to +0.014 (morgantown), +0.004 pooled.
manual_gold(in-domain control) is the one exception — marginal +1.9 R / −1.5 P, AP 0.904 → 0.917 — so the paper's TTA evaluation was a reasonable in-domain choice; it just doesn't transfer to deployment imagery. And the configuration mismatch is now quantified and closed: TTA-evaluated numbers read +2.4 recall points above what single-pass deployment delivers at 0.55.Negative result, recorded deliberately: no production TTA knob gets filed in sidewalk-auto-labeler. The #78 plan filed it only if TTA won at the chosen operating point; it loses there. The 0.30 single-pass recommendation from #54 is unchanged.
Why the mechanism produces exactly this result
Both halves of the finding fall out of two properties of the setup, neither of them specific to this benchmark. This is now written into
docs/operating_point.mdso the reasoning survives with the numbers.Max-combine can only add detections. Because the two passes combine pixelwise by max, the TTA heatmap is pointwise ≥ the single-pass heatmap everywhere. At a fixed threshold, TTA can promote a detection but can never suppress one. That is the mechanical reason it behaves as a threshold change rather than merely correlating with one: the two levers are the same kind of operation — both admit weaker evidence, trading precision for recall. Hence "threshold-lowering in disguise" is a description of the operator, not an analogy.
(An averaging TTA would be a different animal: a variance reducer, able to remove false positives as well as add true ones. That is not what the paper used and not what these numbers measure. It remains untested.)
The model was already trained to be flip-invariant.
stage_two/train.py:202-207applies random horizontal flip at p = 0.5, correctly relabellingx → 1 − x; the crop models do the same (stage_one/crop_model/*/train.py:71). Test-time flipping can therefore only exploit whatever asymmetry the training-time flip failed to remove. A small gain is the expected outcome, not an anomaly — training aug and test aug are the same transform pointed at the same invariance, and the better the former worked, the less the latter can find.Read the other way, this makes the marginal TTA gain a measurement: it says how much the two views still disagree in a way that matters at the operating point. At ≤1.3 recall points per US split, the answer is "barely" — the training augmentation did its job.
What this does not say — follow-up filed as #82
That second point also bounds the scope of the negative result. The horizontal flip is the only augmentation in the training recipe: the input transform is
Resize(2048×4096) → ToTensor → Normalize(train.py:224-229), with no photometric, scale, or other geometric variation, on either the pano model or the crop models.So #78 establishes that the one invariance that was trained for is saturated. It says nothing about the ones that were never trained for — and the benchmark's live failure mode is cross-city transfer (GT-measured recall at 0.55 spans richmond 0.895 down to clovis 0.650). #82 takes that up: yaw-roll and photometric jitter, which are the two augmentations that are simultaneously legal on an equirectangular projection and untried. Worth noting for anyone reaching for the standard list — in-plane rotation and perspective warp are not legal here (the top and bottom image rows are the poles; an image-plane rotation mixes pole with horizon and destroys the projection), whereas horizontal roll is exactly a camera yaw, wraps seamlessly, and is exactly label-preserving.
What's in the PR
operating_point_curve.py extract --tta+ arm guard (TTA and single-pass caches cannot share a directory — extract's resume-skip would otherwise silently score one arm as the other), arm-aware parity gate,low_floor_sweep.py ttawith the four-lever decomposition,plot_tta.py+ committed figure, thedocs/operating_point.mdsection (including the caveat direction: the GT-completeness correction: spot-check the low-confidence incremental FPs from the operating-point curve #55 GT bias overstates TTA's marginal precision cost if anything, and the exact marginal recall gain is too small to price 2× GPU against), and the reproduce/README updates.#82entry in "What would change this" — better transfer would move the 0.30 threshold itself, since part of what 0.30 is compensating for is out-of-domain under-confidence.🤖 Generated with Claude Code (claude-fable-5)
🤖 "What flip-TTA actually is" / "Why the mechanism produces exactly this result" sections added with Claude Code (claude-opus-5[1m])