Make the DTW band pluggable: Sakoe-Chiba and Itakura constraints (#197) - #571
Merged
Merged
Conversation
…#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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This was referenced Sep 12, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:The callable is resolved outside the numba kernel, since
nopythoncode cannot call back into Python; only the finished array crosses the boundary. That is whydistance_matrixis now a thin wrapper over the jitted_banded_distance_matrix.full_band,resolve_bandandvalidate_bandare 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:
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:
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:
backtrack_optimal_pathwould have crashed with a bareAssertionError. It selected a predecessor with<=chains ending inelse: 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 aValueErrornaming the cause. On unconstrained input the choice, ties included, is unchanged: diagonal wins a tie, then horizontal (pinned by a test).one_iteration_dtwdiscarded the reason a batch failed. It re-raised every per-batchValueErrorasFailed on batch {id}withfrom 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 atceil((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.windowis anint(rows) or afloat(fraction of the reference length);boolis rejected, sinceTrueas 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 equivalentnp.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.refwithref[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 pytestfull 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_improveall clean.band=None.New coverage: 36 tests over six classes.
TestFullBand(the default cannot drift),TestSakoeChibaBand(diagonal centring, corners, the radius floor, int vs float windows,boolrejected, 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) andTestBatchDtwBandSetting(end to end, including that an impossible band explains itself).Checklist
pyproject.toml(MINOR: 1.88.0 to 1.89.0, new public API and a new setting;CITATION.cffin step in the same commit)ruff check .passesCHANGELOG.mdupdatedStill open in #197
Three items remain, and this PR does not touch them: the percentage-scale x-axis with configurable resolution, the batch-key
strcoercion bug, and tightening the alignment test now that DTW termination is settled.🤖 Generated with Claude Code
https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og