Repository navigation
Categorical breakpoint detection: sustained switch vs flicker across the annual class series (the temporal leg of change QA) #9
Description
Context
dft_rast_consensus() (#8) computes per-pixel mode across classified rasters. This works well for filtering single-year misclassification noise, but has a fundamental limitation: it can't distinguish noise from real change.
The problem
A pixel that transitions Trees → Rangeland in 2022 looks like this across a 2021-2023 window:
| Year | Class |
|---|---|
| 2021 | Trees |
| 2022 | Rangeland |
| 2023 | Rangeland |
Mode = Trees (2/3 if tied, or Rangeland 2/3 depending on direction). The consensus either misses the real change or requires the post-change class to dominate the window before it registers.
A 3-year window needs 2+ years of the new class. A 5-year window needs 3+. Real change is slow to detect; noise filtering improves.
What has landed since this was filed (revised 2026-09-05; built as dft_rast_break_class() the same day, PR pending)
Two of the three axes on which "is this change real" can be asked now exist in drift, and the body below is rewritten around the one that does not:
| axis | question | shipped |
|---|---|---|
| spectral, continuous | did the vegetation signal actually drop, and when? | dft_rast_break() / dft_rast_trend() (#30) — BFAST on a Sentinel-2 index cube |
| spatial, geometric | is this patch the shape of a registration artifact? | dft_transition_artifact() (#44) — width, boundary-hugging, reciprocal pairs |
| temporal, categorical | across the annual class series, is this a sustained switch or flicker? | not built — this issue |
The BFAST / LandTrendr discussion originally here is resolved: #30 built that pipeline, and it is the spectral complement, not something to duplicate. What #30 cannot do is run on classified inputs alone, and what #44 cannot do is see time at all — a boundary-hugging sliver that is Rangeland in every year but one is noise, one that is Rangeland in every year since 2020 is real, and the geometry is identical.
Proposed solution: categorical breakpoint detection
IO LULC annual v02 gives seven labelled years (2017–2023). For each pixel, scan the class sequence for a sustained transition: class A for N consecutive years, then class B for M consecutive years, and nothing else. Report, per pixel:
break_year— the first year of B, orNAif the series never settles (flicker) or never changes (stable)from/to— A and B as the samefrom * 1000 + tocodesdft_rast_transition()uses, sodft_transition_vectors()anddft_transition_artifact()work on the result unchangedn_before/n_after— N and M; confidence ismin(N, M)n_flips— how many class changes the series contains (1 for a clean switch, more for flicker); theA/B/A/B/Apixel is 4 flips and no break
A 2017 -> 2023 two-epoch comparison then splits into: pixels with a clean break (real, dated), pixels that differ between the two epochs but flicker in between (label noise — exactly the borderline pixels the trajectory vignette reads as "an outline with no red"), and pixels that differ only because 2017 or 2023 is itself the odd year out.
This is the missing categorical-only route to real change, and it is the correction path too: a per-pixel "corrected" series is the sustained classes with the flicker years overwritten — a mode filter that preserves a real break instead of averaging it away, which is exactly what dft_rast_consensus() (#8) cannot do.
Weighted mode — deprioritised
The original recommendation was a weighted mode on dft_rast_consensus(). It is cheap, but it answers a weaker question (which class dominates recently) and still cannot say when a pixel changed or distinguish a clean switch from flicker. Keep it only if the breakpoint scan turns out too expensive at scale; the scan is one pass over an n-year stack, so that is unlikely.
Acceptance
- Synthetic 7-year fixtures: clean switch at each possible year, flicker, stable, and a series where the last year alone differs — each classified as designed.
- Output codes interoperate with
dft_transition_vectors()anddft_transition_artifact()without conversion. - Measured on the BULK floodplain: what share of the 4,625 ha of 2017 -> 2023 change is a clean break, and what share flickers. (Corrected 2026-09-05: the
bulk_co_ff04STAC item carriesclassified_2017/2020/2023only, not seven years; the seven IO LULC years are fetched for itsfloodplainpolygon withdft_stac_fetch(tile_size = 20000)—data-raw/benchmark_break_class_bulk.R.) - Documented in the land-cover vignette beside
patch_area_minand the geometric tags, as the third leg.
Relates to #8 (temporal mode filter this generalises), #30 (spectral route), #44 (spatial route), #31 (labelling spectral breaks with categories).
Activity
Add vector patches and zone attribution to vignette
[-]Weighted consensus and categorical breakpoint detection for change confidence[/-][+]Categorical breakpoint detection: sustained switch vs flicker across the annual class series (the temporal leg of change QA)[/+]Archive planning files for issue #9
Merge pull request #61 from NewGraphEnvironment/9-categorical-breakpo…