Skip to content

Make the DTW band pluggable: Sakoe-Chiba and Itakura constraints (#197) - #571

Merged
kgdunn merged 6 commits into
mainfrom
claude/audit-todo-fixme-items-gy9vj6
Sep 12, 2026
Merged

kgdunn merged 6 commits into
mainfrom
claude/audit-todo-fixme-items-gy9vj6

Conversation

@kgdunn

@kgdunn kgdunn commented Sep 12, 2026

Copy link
Copy Markdown
Owner

Summary

Closes the two band items of #197: "allow a user-specified band" and "add Sakoe-Chiba constraints". Implemented as option 3 of the three we discussed: one pluggable mechanism, with Sakoe-Chiba and Itakura as its first two constraints, so a future geometry is a function rather than a branch.

No existing behaviour changes. The default is band=None, which builds the same unconstrained band the code hard-coded before. The cost matrix is verified bit-identical to the pre-#197 kernel, and no slower.

What a constraint is

None (unconstrained), a literal (n_test, 2) array of half-open reference-row bounds, or a callable of the two batch lengths. The lengths are not known until a pair is aligned, so the callable is the useful form:

from process_improve.batch.alignment_helpers import sakoe_chiba, itakura

batch_dtw(..., settings={"band": sakoe_chiba(window=0.1)})   # 10% of batch duration
batch_dtw(..., settings={"band": itakura(max_slope=2.0)})

The callable is resolved outside the numba kernel, since nopython code cannot call back into Python; only the finished array crosses the boundary. That is why distance_matrix is now a thin wrapper over the jitted _banded_distance_matrix.

full_band, resolve_band and validate_band are public too, for a third geometry.

What a band actually buys, and costs

The band has to constrain the cost evaluation, not just the accumulation. My first version constrained only the accumulation, which left the whole function quadratic in the batch lengths however narrow the band: a 10% band was then 1.3x faster at 1000 samples and slower at 2000. Measured on random series, with the cost restricted too:

samples no band 10% band speedup
100 1.95 ms 0.38 ms 5.7x
300 24.5 ms 3.3 ms 7.6x
700 6.55 s 25.0 ms 323x

It also changes results. On the bundled dryer fixture (71 batches, 89 to 201 samples), a 50% window reproduces the unconstrained weights to nine figures; narrowing degrades the alignment monotonically:

window worst normalized distance
none 0.0714
50% 0.0714 (identical weights)
20% 0.0804
10% 0.178
5% 0.235

So the guidance in the docstring is to widen the window until the alignment stops changing.

Three defects the band exposed

None could fire while the band was always full, so they are latent rather than regressions, but any band work walks straight into them:

  1. backtrack_optimal_path would have crashed with a bare AssertionError. It selected a predecessor with <= chains ending in else: raise AssertionError. NaN fails every comparison, and every out-of-band cell is NaN, so the first step to a constrained cell fell through to that bare raise. It now picks the cheapest finite predecessor and raises a ValueError naming the cause. On unconstrained input the choice, ties included, is unchanged: diagonal wins a tie, then horizontal (pinned by a test).
  2. The first row and column were filled ignoring the band, which would have let a path run along an edge outside the corridor and re-enter it later. They are now filled only as far as the band admits.
  3. one_iteration_dtw discarded the reason a batch failed. It re-raised every per-batch ValueError as Failed on batch {id} with from None. That was already unhelpful, and with a band the dropped part is the actionable one: a corridor too narrow for one batch says which window or slope to use. The cause is now interpolated and chained.

Geometry details worth reviewing

Sakoe-Chiba centres on the line joining the two corners rather than on m == n, so it is correct for unequal durations, and floors the radius at ceil((n_ref - 1) / (n_test - 1)). Below that the corridor has gaps no monotone path can cross, so the alignment would be infeasible rather than merely constrained. window is an int (rows) or a float (fraction of the reference length); bool is rejected, since True as a one-row radius is almost certainly a mistake.

Itakura rounds its edges outwards. Rounding inwards empties any column whose corridor is thinner than one row, which happens as soon as the two lengths differ much. Batches of very different duration admit no Itakura parallelogram at any slope (its corner columns are one cell wide, so a path needing many reference rows per test sample has nowhere to start); that case is refused by name and points at Sakoe-Chiba.

The refusal quotes a minimum slope found by bisection on the real construction, rounded up to four significant figures and confirmed accepted. My first attempt derived it in closed form and formatted it with :.4g, which rounded below the threshold: the error told the reader to pass a value the code then rejected. A sweep over 36 length pairs caught it, and a parametrised test now pins it.

Two things I found and deliberately did not change

  • np.diag(A @ W @ A.T) forms an h-by-h product to keep its h diagonal entries, so the cost matrix is cubic in batch length. That is why 700 samples takes seconds at all. The row-wise equivalent np.sum((A @ W) * A, axis=1) measured 150x to 1500x faster, but it accumulates in a different order and differs in the last bits (~1e-15), so it would move pinned values across the repo. Worth its own PR; say the word.
  • A slice of the full extent is not free under numba. Replacing ref with ref[0:nr] cost the unconstrained path 4x at 700 samples (1.63 s to 6.65 s on the cost loop) because the slice cannot be proven contiguous. The two cases are separate loops for that reason, not for clarity.

Test plan

  • uv run pytest full suite: 3297 passed, 41 skipped (3261 before, plus 36 new; the skips are proxy-blocked openmv.net downloads, pre-existing).
  • ruff check ., ruff format --check ., mypy src/process_improve all clean.
  • Default cost matrix bit-identical to the pre-Batch alignment improvements #197 kernel at 100, 300 and 700 samples, and no slower.
  • Dryer alignment weights unchanged to twelve figures with band=None.
  • Sweep over 36 length pairs (2 to 97 samples, both orders) times 5 windows and 4 slopes: every band validates, every path runs corner to corner, and no banded distance is ever cheaper than the unconstrained one.

New coverage: 36 tests over six classes. TestFullBand (the default cannot drift), TestSakoeChibaBand (diagonal centring, corners, the radius floor, int vs float windows, bool rejected, wide window recovers the unconstrained distance, narrow one costs more), TestItakuraBand (corner columns one cell wide, widest in the middle, no empty column at unequal lengths, the lopsided refusal, and that the advised slope is itself accepted), TestBandValidation (one test per rejection message), TestBacktrackingUnreachableCells (both NaN paths raise a named error; ties still take the diagonal) and TestBatchDtwBandSetting (end to end, including that an impossible band explains itself).

Checklist

  • Version bumped in pyproject.toml (MINOR: 1.88.0 to 1.89.0, new public API and a new setting; CITATION.cff in step in the same commit)
  • Tests added
  • ruff check . passes
  • CHANGELOG.md updated

Still open in #197

Three items remain, and this PR does not touch them: the percentage-scale x-axis with configurable resolution, the batch-key str coercion bug, and tightening the alignment test now that DTW termination is settled.

🤖 Generated with Claude Code

https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

…#197)

`distance_matrix` always built a fully unconstrained band and two TODOs asked
for it to be configurable. The band was already the mechanism the dynamic
programme ran on, so the work is in deciding who computes it.

A constraint is now `None` (unconstrained, the default and bit-identical to
before), a literal (n_test, 2) array of row bounds, or a callable of the two
batch lengths. `sakoe_chiba(window)` and `itakura(max_slope)` return callables
of that shape, so a new geometry is a function rather than a branch. The
callable is resolved outside the numba kernel, since nopython code cannot call
back into Python; only the finished array crosses the boundary.

Three things the band exposes that were latent before:

- `backtrack_optimal_path` compared three neighbours with `<=` chains and fell
  through to a bare `AssertionError`. NaN fails every comparison, and every
  out-of-band cell is NaN, so any constrained matrix would have crashed there
  with no message. It now picks the cheapest finite predecessor, keeping the
  previous tie order on unconstrained input, and raises a ValueError that names
  the cause.
- The first row and column were filled regardless of the band, which would have
  let a path run along an edge outside the corridor and re-enter it. They are
  now filled only as far as the band admits.
- `validate_band` rejects a corridor no monotone path can follow (empty column,
  non-monotone bounds, missing corner, disconnected step) and names the first
  condition that fails.

Sakoe-Chiba centres on the diagonal between the two lengths, so it is correct
for batches of different duration, and floors the radius at the diagonal's own
step, below which the corridor would have uncrossable gaps. Itakura rounds its
edges outwards, since rounding inwards empties a column whose corridor is
thinner than one row. Batches of very different length admit no Itakura
parallelogram at any slope; that case is refused by name, and the refusal
quotes a minimum slope found by bisection on the real construction and
confirmed to be accepted, rather than a closed form that can round below the
threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
The band constrained which cells the accumulation visited, but every cell of
the Mahalanobis cost matrix was still computed first, so the function stayed
quadratic in the two batch lengths however narrow the band. Measured on random
series, a 10% band bought 1.8x at 100 samples, 1.4x at 300, 1.3x at 1000, and
0.6x at 2000: by then the unbanded cost matrix dominated and the extra
allocation made it slower than no band at all.

The cost is now evaluated per test sample over only the reference rows the band
admits, so the work scales with the corridor's width. The expression is
untouched, and for the default full band the slice is the whole array, so the
arithmetic is bit-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
… a batch failed (#197)

`settings["band"]` reaches `dtw_core` through `one_iteration_dtw` unchanged, so
one constraint applies to every pair in the alignment. The kind of the
constraint is checked once, up front, rather than once per batch.

`one_iteration_dtw` wrapped every per-batch ValueError as `raise ValueError(f"Failed
on batch {batch_id}") from None`, which threw away the message. That was already
unhelpful and becomes actively misleading with a band: a corridor too narrow for
one batch explains exactly which window or slope to use, and that explanation was
the part being dropped. The cause is now both interpolated and chained.

The docstring records what a constraint costs as well as what it buys: a corridor
that excludes the true warp changes the aligned trajectories, so the iterated
average converges to a different fixed point.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
… path (#197)

36 tests over six classes. The ones worth calling out, because they pin
behaviour that was wrong at some point while writing this:

- The full band must give a bit-identical cost matrix to passing nothing, so
  the default cannot drift.
- Sakoe-Chiba centres on the diagonal between unequal lengths (checked against
  the centres it should produce), admits both corners at every length pair and
  window, and floors its radius at the diagonal step.
- The slope an infeasible Itakura refusal advises must itself be accepted. The
  first version quoted a closed-form threshold formatted to four significant
  figures, which rounded below it, so the error told the reader to pass a value
  the code then rejected.
- Backtracking a matrix with an unreachable cell raises a named ValueError.
  Both cases previously ended at a bare `AssertionError`.
- Equal costs still take the diagonal, so the finite-only predecessor choice
  keeps the old tie order.
- Narrowing the corridor on the dryer fixture degrades the alignment
  monotonically (worst normalized distance 0.0714 unconstrained to 0.235 at a
  5% window), and a 50% window reproduces the unconstrained weights to 1e-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
Restricting the cost matrix to the band made the default slower, which the band
work was not entitled to do. A slice of the full extent taken with runtime
bounds is not equivalent to the array itself under numba: it cannot be proven
contiguous, so the matmul compiles to a slower path. Measured at 700 samples,
the previous commit's cost loop took 6.65 s against the original 1.63 s, a 4x
regression on the path every current caller uses.

The band's bounds are monotone, so two entries decide whether it spans
everything, and the two cases are now separate loops. The unconstrained one is
the original expression. Verified at 100, 300 and 700 samples: the cost matrix
is bit-identical to the pre-#197 kernel, and the whole kernel is no slower
(8.07 s to 6.55 s at 700 samples). A 10% band runs 5.7x, 7.6x and 323x faster
than no band at those three sizes.

Separately worth knowing, and deliberately not changed here: `np.diag(A @ W @
A.T)` forms an h-by-h product to keep its h diagonal entries, so the cost matrix
is cubic in batch length, which is why 700 samples takes seconds at all. The
row-wise equivalent, `np.sum((A @ W) * A, axis=1)`, measured 150x to 1500x
faster, but it accumulates in a different order and differs in the last bits
(about 1e-15), so it would move pinned values. That belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
MINOR: `batch_dtw` gains a `band` setting, and `alignment_helpers` gains
`full_band`, `sakoe_chiba_band`, `itakura_band`, `sakoe_chiba`, `itakura`,
`resolve_band` and `validate_band` as public API. Nothing is removed and the
default behaviour is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kgdunn
kgdunn merged commit 0ba19c8 into main Sep 12, 2026
14 checks passed
@kgdunn
kgdunn deleted the claude/audit-todo-fixme-items-gy9vj6 branch September 12, 2026 15:38
kgdunn added a commit that referenced this pull request Sep 13, 2026
…ost per row (#573)

* Align only the columns that carry a trajectory (#199)

`columns_to_align=None` resolved to every column of the first batch, which swept
in whatever else the frame carried. Two kinds of column do not belong:

- **Not numeric.** A label is not a trajectory. What to do with it (carry it,
  encode it, drop it) is the caller's decision, so it is skipped rather than
  guessed at. Named explicitly in `columns_to_align` it now raises instead,
  naming the column and its dtype, since the caller asked for it by name.

- **Flat through every batch.** This is the case that prompted the change, and a
  dtype test alone does not catch it: the identifier `melted_to_dict` leaves in
  place is usually an integer. On the dryer data `batch_id` survived as a
  numeric column and was scaled as though it were a tag.

  Including it is not harmless. It takes almost no weight itself, 0.000051,
  because its forced range of 1.0 leaves the raw identifiers to deviate wildly
  from the average. But it joins the distance the alignment minimises and the
  normalisation that follows, and the other weights move a long way as a result:
  measured over ten dryer batches, `JacketTemperatureSP` went from 0.132 to
  0.472 and `AgitatorPower` from 0.051 to 0.081.

Every batch is checked for spread, not only the first, so a tag that happens to
be flat in the first batch but moves in a later one is kept. The check is a min
against a max rather than a distinct count: one pass, no hashing, and the
question is only whether the column moves at all.

A constant column named explicitly is left alone: constant over this particular
set of batches does not mean constant in general.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

* Compute the DTW cost matrix per row, not through a square product

`np.diag(A @ W @ A.T)` builds an h-by-h product to keep its h diagonal entries.
That is quadratic in the number of reference rows per test sample, so cubic over
the whole matrix, and it allocates a temporary to match. Summing `(A @ W) * A`
along the tags computes only the diagonal, in `h * J^2` work rather than
`h^2 * J`, with no large temporary.

Measured on random series, unconstrained:

| samples | before | after |
|---|---|---|
| 100 | 2.08 ms | 1.49 ms |
| 300 | 24.98 ms | 11.69 ms |
| 700 | 272.65 ms | 82.14 ms |
| 1200 | 885.63 ms | 240.06 ms |

It also removes the contiguity problem the matmul had, where a slice taken with
runtime bounds compiled to a slower kernel than the whole array. That was the
only reason the banded and unconstrained cases were separate loops in #571, so
one loop now serves both.

I expected this to move pinned values and it does not. Individual cost values
differ in the last bits, about 5e-16 relative, because the summation order
changes. Over nine combinations of length and tag count the warping path came
back identical, and the dryer alignment reproduces its final weights exactly.
The path is chosen by comparisons between accumulated costs that are far apart
relative to one ulp, so the change is invisible downstream.

Tests pin the equivalence rather than trusting it: the kernel is checked against
the literal Mahalanobis expression, and three length and tag combinations are
checked to produce the same warping path as a reference dynamic programme built
from that expression. A fourth covers a tall reference against few tags, which
under the old form would have allocated a 4000-by-4000 product per test sample.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

* Bump to 1.93.0 for the column-resolution change

MINOR: `columns_to_align=None` resolves to fewer columns than before, which
changes the alignment for anyone who relied on the default sweeping in every
column, and an explicitly named non-numeric column now raises. Both are
deliberate behaviour changes rather than fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

* Update the zero-range warning test for the new column resolution

`test_a_constant_tag_is_reported_once_for_the_whole_call` asserted that
`batch_id` heads the warning on a default `determine_scaling` call. That was
true when the default swept in every column, and is the behaviour this branch
deliberately removes: `batch_id` is flat in every batch, so resolution drops it
and it never reaches the zero-range substitution.

Split into two tests rather than deleting the assertion. One keeps the original
point, that a tag flat in some batches is reported once for the whole call, on
`DifferentialPressure`, which is the real case: flat in 16 of 71 batches and
varying in the rest, so it is aligned and reported. The other pins the new
behaviour directly, that `batch_id` is absent from the message, so a regression
in column resolution fails here with a message that says what happened.

Caught by CI on test (3.13, ubuntu-latest); it was the only failure in 3389
tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

* Test the column-resolution rules (#199)

Codecov flagged five lines of the new resolution as unreached. I had checked
each of these paths while writing it, but with throwaway scripts rather than
tests, which leaves nothing behind to catch a regression. Eight tests now cover:

- a non-numeric column skipped on the default path, and raising when named
  explicitly, with the message naming the column and its dtype;
- a numeric column flat through every batch skipped, which is the identifier
  case a dtype test alone misses;
- a column flat in only the first batch kept, which is why every batch is
  checked rather than just the first;
- a constant column named explicitly left alone;
- batches with nothing alignable raising, listing the columns that were there;
- an all-NaN column counting as flat, since no usable value means no spread;
- a column absent from some batches not raising while the rest are checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants