Skip to content

Flip-TTA at the deployment operating points: measured, and not worth 2× GPU (#78) - #81

Merged
jonfroehlich merged 4 commits into
mainfrom
feat/flip-tta-78
Jul 29, 2026
Merged

Flip-TTA at the deployment operating points: measured, and not worth 2× GPU (#78)#81
jonfroehlich merged 4 commits into
mainfrom
feat/flip-tta-78

Conversation

@jonfroehlich

@jonfroehlich jonfroehlich commented Jul 29, 2026

Copy link
Copy Markdown
Member

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 second extract --tta arm (mirrored heatmap un-flipped and maxed with the original — numerically identical to evaluate.py's composition, with tests asserting the clip-order equivalence), swept post-hoc on identical GT alongside the single-pass arm. manual_gold needed 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:191 composes with elementwise np.maximum, not an average.

Two reasons this was worth running even though the outcome is negative:

  1. The accounting reason, which was the real driver. --tta defaults to True in evaluate.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.
  2. The ensembling hope. Two views, partly uncorrelated errors, and RampNet is recall-first (a false negative is a permanently missed ramp; a false positive is cheap to reject downstream). A cheap recall lever deserved a measurement.

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.

lever (from single@0.55, pooled US) ΔR ΔP ΔF1
threshold drop alone (0.55→0.30) +0.074 −0.068 +0.016
TTA alone (at 0.55) +0.024 −0.011 +0.011
both +0.083 −0.092 +0.009
TTA after the drop (at 0.30) +0.009 −0.024 −0.007

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.md so 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-207 applies random horizontal flip at p = 0.5, correctly relabelling x → 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

🤖 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])

jonfroehlich and others added 4 commits July 28, 2026 23:05
…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 jonfroehlich left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  1. 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.
  2. 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.
  3. 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).
  4. 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 to stage_two/evaluate.py's clip-then-max order.
    • Mirroring Order: ImageOps.mirror(img) mirrors the input image before standard pre-processing, matching stage_two/evaluate.py.

B. Lever Decomposition & Analysis Framework

  • low_floor_sweep.py: tta_levers(...) cleanly decomposes the recall gains into a 4-row ledger:
    1. threshold drop alone ($0.55 \to 0.30$ single-pass)
    2. TTA alone ($0.55$ single-pass $\to 0.55$ TTA)
    3. both ($0.55$ single-pass $\to 0.30$ TTA)
    4. 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_up verifies 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 in cache_dir and 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 committed manual_gold TTA export records directly without requiring a GPU pass, maintaining fast CPU-only test suite execution.

E. Visualization & Artifact Generation

  • plot_tta.py: Generates docs/figures/tta_operating_point.png showing the dual PR-curve and Threshold-vs-Metrics panels.
  • Uses the project-standard 2-slot color palette (#2a78d6 single-pass, #eb6834 flip-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

  1. 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.
  2. Defensive Guardrails: _assert_cache_arm actively prevents accidental cache contamination across arms, saving future maintainers from silent evaluation errors.
  3. 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)

@jonfroehlich
jonfroehlich merged commit e84df91 into main Jul 29, 2026
2 checks passed
@jonfroehlich
jonfroehlich deleted the feat/flip-tta-78 branch July 29, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Experiment: flip-TTA at deployment operating points — the evaluated config used it, deployed inference doesn't

1 participant