Skip to content

Rubbersheet polyfit: a 1/32 px offset change can discontinuously alter the final inlier set at critical_value=0.1 #351

Description

Summary

Under the settings the NISAR rubbersheet workflow passes to it (critical_value=0.1 and max_iterations=len(data); the function's own default budget is 50), isce3.math.offsets_polyfit.polyfit_offsets can turn a change of one correlation-grid step (1/32 px) in a single input offset into a wholesale change of the final inlier set and a fitted-surface jump three orders of magnitude above the continuous response. The module is byte-identical on develop and v0.25.16 (last touched by PR #173), so this is not a regression report. The question we would most like answered: is crit_value = 0.1 intended as a threshold on the componentwise standardized w-test statistic (classically of order unity to several), or was "0.1 px" the intent — and is the full rejection budget intentional? We sketch a few directions below and welcome comments; if useful, we can implement the ones you prefer and report comparative results.

Minimal reproduction (self-contained)

"""One-quantum (-1/32 px) change of ONE input offset discontinuously
changes the final inlier set of the isce3 rubbersheet polyfit
(pinned synthetic case; production fit settings)."""
import numpy as np
from isce3.math import offsets_polyfit as op

GRID = (41040, 52906)             # full radar grid of the real pair
SENSOR = dict(prf=1520.0, abw=1263.68013808518,
              rsr=48000000.0, rbw=40000000.0)  # workflow-call values
Q = 1.0 / 32.0                    # Ampcor correlation-grid quantum
CL = (0.4, 1.2, -0.6, 0.3, -0.2, 0.15)    # degree-2 truth, azimuth
CP = (-0.2, 0.5, 0.8, -0.25, 0.1, -0.3)   # degree-2 truth, range

# 30x30 sample grid; the driver sits at the node nearest the real
# driver's radar position and carries its real corr_peak 0.9485.
rng = np.random.default_rng(29)   # pinned existence-proof seed
lines = np.linspace(0, GRID[0] - 1, 30).round()
pixels = np.linspace(0, GRID[1] - 1, 30).round()
driver = int(np.argmin(abs(lines - 23405))) * 30 \
    + int(np.argmin(abs(pixels - 42589)))
ll, pp = (a.ravel() for a in np.meshgrid(lines, pixels, indexing="ij"))
n = ll.size
A = op.build_design_matrix(ll, pp, 2, 0, GRID[0], 0, GRID[1])
tl, tp = A @ np.array(CL), A @ np.array(CP)

coh = np.zeros(n, bool)           # small coherent elite in a junk
coh[rng.choice(n, size=int(round(0.08 * n)), replace=False)] = True
coh[driver] = True                # majority, as in production
dl = np.where(coh, tl + rng.normal(0.0, 0.02, n),
              tl + rng.uniform(-20.0, 20.0, n))
dp = np.where(coh, tp + rng.normal(0.0, 0.02, n),
              tp + rng.uniform(-20.0, 20.0, n))
w = np.where(coh, rng.uniform(0.3, 0.8, n),
             rng.uniform(0.02, 0.15, n))
dl[driver], dp[driver], w[driver] = tl[driver], tp[driver], 0.9485
dl = (np.round(dl / Q) * Q).astype(np.float32).astype(np.float64)
dp = (np.round(dp / Q) * Q).astype(np.float32).astype(np.float64)
w = w.astype(np.float32).astype(np.float64)
data = np.column_stack([np.arange(n, dtype=float), ll, pp, dl, dp, w])


def fit(d):                       # the production call shape
    return op.polyfit_offsets(
        d.copy(), degree=2, crit_value=0.1, max_iterations=len(d),
        minL=0, maxL=GRID[0], minP=0, maxP=GRID[1], **SENSOR)


base = fit(data)
flipped = data.copy()
flipped[driver, 3] -= Q           # THE perturbation: one azimuth
flip = fit(flipped)               # offset of one sample, one quantum

inl_b = set(base["inliers"][:, 0].astype(int))
inl_f = set(flip["inliers"][:, 0].astype(int))
gl, gp = np.meshgrid(np.linspace(0, GRID[0], 101),
                     np.linspace(0, GRID[1], 101), indexing="ij")
jl, _ = op.predict_offsets(gl, gp, flip["coefL"] - base["coefL"],
                           flip["coefP"] - base["coefP"], 2,
                           0, GRID[0], 0, GRID[1])
it = (flip["removed_indices"].index(driver)
      if driver in flip["removed_indices"] else None)
print(f"baseline: {len(base['removed_indices'])}/{n} removed, "
      f"driver {'IN the final inliers' if driver in inl_b else 'OUT'}")
print(f"flip    : driver removed at index {it} of "
      f"{len(flip['removed_indices'])} removals (0-based), "
      f"final inliers {len(inl_b)} vs {len(inl_f)}, "
      f"common {len(inl_b & inl_f)}")
print(f"jump    : induced azimuth-surface RMS "
      f"{np.sqrt(np.mean(jl.ravel() ** 2)):.3e} px")

Output (isce3 0.26.0-dev at 2919e1c97, numpy 1.26.4, thread env pinned to 1):

baseline: 868/900 removed, driver IN the final inliers
flip    : driver removed at index 832 of 870 removals (0-based), final inliers 32 vs 30, common 25
jump    : induced azimuth-surface RMS 2.751e-02 px

The only difference between the two fits is flipped[driver, 3] -= Q: one azimuth offset of one high-weight sample, moved by exactly one Ampcor correlation-grid step. Everything else — all 900 samples, all weights, all fit settings — is bit-identical. The script needs an isce3 Python environment (NumPy, and SciPy — the module under test imports it transitively); nothing else. An extended version of this case with 8 pass/fail checks lives in our benchmark repository: repro_polyfit_quantum_membership.py (8/8 PASS in the two software environments we tested on the same host — numpy 1.26.4 and 2.2.6 — with an identical discrete removal chain; coefficient tails differ by up to ~2 ULP; other CPU architectures and BLAS implementations are untested). The pinned seed is an existence proof found by a documented 40-seed calibration hunt; no prevalence claim is made.

The synthetic case and the recorded real-data replay show the same discrete signature
Check Synthetic-900 Recorded 40k replay
Baseline retention 3.6% (868/900 removed), driver survives 4.2%, driver survives
Flip -1/32 px at the driver removed at index 832 of 870 removals (0-based) = 95.6% of the chain removed at index 36,565 of 38,465 (0-based) = 95.1%
Final membership 32 vs 30 inliers, 25 common 1,677 vs 1,535, 985 common
Induced azimuth jump RMS 2.75e-2 px (max 0.106 px) RMS 3.60e-2 px
Tolerance bound vs half-quantum 0.0132 px < 0.0156 px same (production kwargs)

Mechanism: two-stage amplification

  1. Quantizer (subpixel argmax). The correlation-surface argmax maps input differences to "exactly zero or at least one grid step": whenever two implementations rank neighboring grid points differently — and cross-backend bit identity (e.g. CPU vs GPU Ampcor) is not generally guaranteed — the returned offset moves by at least q = 1/32 px. The quantizer is not the defect; the question is the robustness of the downstream removal chain to such small, valid input perturbations.
  2. Membership amplifier (sequential worst-outlier removal). Each iteration refits, then removes the sample with the largest combined squared standardized residual (argmax(wL² + wP²)); the stop test is componentwise (max(|wL|, |wP|) <= crit_value). The fit response to an input offset is piecewise-smooth: linear while the removal chain is unchanged, with discontinuous jumps where a removal decision flips. Chains fork from perturbations as small as ~1e-4 px and almost always re-converge benignly; across a membership boundary, the jump is neither proportional nor monotone in the perturbation (3.6e-2 px from one quantum on the recorded data, vs the ~1e-5 px continuous-response scale).

A simple condition exposes the susceptibility:

crit_value * sigmaL / w  =  0.1 * 0.1247 / 0.9485  =  0.0132 px
   <  q/2  =  1/64  =  0.0156 px
   (half-bin bound on the nearest-grid quantization error)

The exact stop tolerance at a sample is crit_value * sigma * sqrt(1/w² - h_ii), bounded above by crit_value * sigma / w, so a sufficiently high-weight sample can fail the stop test on quantization error alone. This is a property of the high-weight tail — at the survivor-median weight 0.56 the bound is 0.0223 px and the inequality reverses — and it neither forces a deep purge nor guarantees a membership jump. On the recorded data the susceptibility was realized: 95.8% of the 40,000 samples were removed and the fit was decided by a ~4% high-weight elite (a crit_value sweep shows the flip response dropping 160-fold already at 0.2).

Questions and possible directions

The main question is the one in the Summary: the intended statistical interpretation of crit_value = 0.1, and the intent of the full rejection budget (max_iterations = len(data)). Beyond the synthetic case, we hit the mechanism in production form as a CPU-vs-GPU product-level difference (see "How we found it" below). The sketches below are for discussion, not a pre-made PR — we would rather agree on a direction first, and we are happy to implement the candidates you prefer and evaluate them comparatively on the synthetic reproducer and the recorded case.

Direction sketches (semantics calibration, observability, quantization-aware deadband, stable trimming, smooth losses)
  1. Confirm and calibrate the crit_value semantics. The stop test compares componentwise standardized w-test statistics |e| / (s * sigma) against crit_value. Classical w-test critical values are typically of order unity to several, depending on the significance design (e.g. Baarda's B-method); for high-weight, low-leverage samples a threshold of 0.1 corresponds to demanding residuals at roughly one tenth of the prior noise level. If a re-calibration is considered appropriate, we can implement candidate calibrations and report their effect on both the reproducer and the recorded case.
  2. Observability and guardrails — report n_removed / the inlier fraction next to the coefficients the workflow already prints, warn on extreme retention, optionally a max-rejection / min-inlier policy. Detection, not a cure; a min-inlier cap trades off stopping before the w-test criterion is met.
  3. Quantization-aware residual model / deadband. The offsets entering the fit are quantized at a configuration-known q (the Ampcor correlation-surface oversampling), so q is available to the fit. Numbers from the recorded case: adding q²/12 to the prior variance alone moves sigmaL from 0.124705 to 0.125031 px (+0.26%) and the driver tolerance bound from 0.013148 to 0.013182 px — still below q/2 = 0.015625 px, so it is nearly ineffective by itself. An explicit tolerance floor at q/2 (deadband on the stop test) is the variant that bites; the crit-0.2 sweep point (160-fold response drop) is adjacent empirical support.
  4. Batch / stable trimming instead of one-at-a-time hard deletion — reduces the sensitivity of the chain to a single flipped decision.
  5. Comparative evaluation of smooth robust losses (convex Huber-class IRLS): a unique convex solution avoids hard membership deletion and is expected to remove this jump class, but is a broad behavior change; Tukey-style non-convex losses can retain basin sensitivity, so no blanket guarantee is claimed — an evaluation candidate, not a promise.

Where the relevant pieces live

Code excerpts (pinned at 2919e1c97)
# python/packages/isce3/math/offsets_polyfit.py, L192-199
def polyfit_offsets(
    data,degree=2,
    crit_value=0.1,
    max_iterations=50,
    minL=None, maxL=None,
    minP=None, maxP=None,
    prf=None, abw=None,
    rsr=None, rbw=None):
# python/packages/isce3/math/offsets_polyfit.py, L325-343 (trimmed)
        # w-test
        s = np.sqrt(diag_Qe)
        wL, wP = (eL[:, 0] / (s * sigmaL)), (eP[:, 0] / (s * sigmaP))
        max_any = max(np.abs(wL).max(), np.abs(wP).max())
        if (max_any <= crit_value) or (iteration >= max_iterations):
            return { ... }

        # Remove worst outlier
        worst = int(np.argmax(wL * wL + wP * wP))
        removed_indices.append(int(data[worst, 0]))
        data = np.delete(data, worst, axis=0)
        A = np.delete(A, worst, axis=0)
# python/packages/nisar/workflows/rubbersheet.py, L169-177
results = offsets_polyfit.polyfit_offsets(
    data,
    degree=rubbersheet_params['polyfitting']['degree'],
    crit_value=rubbersheet_params['polyfitting']['critical_value'],
    max_iterations=len(data),
    minL=minL, maxL=maxL,
    minP=minP, maxP=maxP,
    prf=prf, rbw=rbw,
    abw=abw, rsr=rsr)

How we found it (recorded production comparison)

This is not hypothetical: we first hit the mechanism as a ~3.6e-2 px RMS difference between the CPU and the GPU InSAR workflow products on a NISAR L-SAR pair, and a controlled replay of the production fits attributed that difference to exactly one such quantum flip. In the extracted sample sets, ~99.97% of the 40,000 sample rows were bit-identical between the CPU and GPU runs, and every difference was at least one quantum. The full report, the recorded artifacts and the replay harness are public: report · artifacts · replay/probe harness.

The synthetic reproducer above is independently runnable by anyone. The real-data replay results are public recorded evidence: the source rasters and the extracted sample matrices are not published; we can archive the extracted matrices in a DOI-backed repository (e.g. Zenodo) after confirming redistribution and citation metadata.

Recorded 40k replay: transplanting one sample row reproduces the logged coefficient delta

Dataset context: ascending track 139, frame 019 (L1 RSLC), processed once with the CPU and once with the GPU InSAR workflow. The two runs' RIFG pixelOffsets layers differ by a smooth ~3.6e-2 px RMS degree-2 bowl; a linear regression on the offsets difference explains ~99.5% of the unwrapped phase-difference variance between the runs.

Sample-set counting: two independent CPU-Ampcor runs were each compared against the GPU-Ampcor baseline on the production sample grid (run A: 10 of 40,000 rows differ; run B: 9 rows; the union is 12 changed rows, 7 common to both).

All replay fits are CPU-only and thread-count controlled; A/A reruns are bit-identical.

Result Value
Self-consistency gate the replayed fit reproduces the on-disk culled surfaces to 6.1e-15 px and the logged coefficients to print precision (4.1e-9)
Full-input swap (controlled CPU-Ampcor offset set into the GPU baseline) reproduces the observed CPU-minus-GPU coefficient target: cosine 1-6e-15; the replayed coefficients match the CPU run's logged values at max abs diff 5.0e-8
Channel attribution offsets-only = full target; weights-only (38.5k float32 epsilons in corr_peak) forks at iteration 468 and re-converges benignly (2.4e-9 px)
Minimal destructive set ONE sample row (corr_peak 0.9485, azimuth offset differing by exactly -1/32 px, no range difference) — necessary (complement transplants change the coefficients by exactly zero) and sufficient (the single transplant = full target) within the observed difference sets; the other 11 rows of the 12-row union, including a 5.6/13.9 px (azimuth/range) outlier row, change nothing
Perturbation basin at the driver only the exact -1/32 px value lands in the target basin; -1e-5 px does not fork the chain; -1e-4 px and ±2-quantum deltas fork and re-converge benignly
Endgame membership final inliers 1,677 (GPU baseline) vs 1,535 (CPU-offsets), 985 common; the driver is removed at index 36,565 of the 38,465-removal sequence (0-based; 95.1% of the chain); the amplification curve stays at 1e-5..1e-3 px mid-run and explodes over the last ~2,000 iterations

The endgame-membership figures are recorded in the membership summary JSON in the artifact directory; the remaining table entries map to the replay/probe JSONs there.

Scope and limitations

  • Single dataset, single pair; replay determinism claims are same-environment claims. "Exact" means exact to the available production log precision.
  • The real-data evidence is a controlled substitution on recorded rasters. The actual production CPU run's raw offsets were not reconstructed; "the one flip existed in the actual CPU run" is consistent with the coefficient-level evidence (the replayed coefficients land on the CPU run's logged values at 5.0e-8) but is not established.
Further limitations
  • Pairwise interaction of the 12 changed rows was not enumerated (the complement probes close the necessity question for the observed sets).
  • The synthetic two-population weight structure is a simplification of the production corr_peak distribution, not a quantitative match.
  • Why the CPU and GPU correlation surfaces rank neighboring grid points differently in that one window (the origin of the argmax flip) is out of scope here.
  • The mechanism is an existence result under production kwargs; on this very dataset 11 of the 12 changed rows and all 38.5k weight epsilons were benign.
Appendix: docstring mismatch in the same module

The polyfit_offsets docstring describes minL/maxL (minP/maxP) as bounds on acceptable offsets ("Points with dL outside [minL, maxL] are removed before fitting") and removed_indices as including points removed "by hard bounds". The implementation uses these parameters only as coordinate-normalization bounds for the design matrix (defaulting to the data's line/pixel coordinate range); no pre-fit removal by offset bounds exists. Happy to fold a docstring fix into whatever comes out of the discussion, or file it separately.


Disclosure: The diagnosis and harness were written with assistance from AI agents. I ran the diagnostic scripts myself and reproduced the reported numbers before posting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions