Align only the columns that carry a trajectory, and compute the DTW cost per row - #573
Merged
Merged
Conversation
`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
`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
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
`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
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
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
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
The two follow-ups left open after #572: skip non-numeric columns when aligning, and fix the DTW cost matrix. #197, #198 and #199 are now closed.
Aligning only the columns that carry a trajectory
columns_to_align=Noneresolved to every column of the first batch, sweeping in whatever else the frame carried. Two kinds do not belong.Not numeric. A label is not a trajectory. Skipped on the default path; named explicitly it now raises, giving the column and its dtype, since you asked for it by name and whether to carry, encode or discard it is yours to decide.
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_dictleaves in place is usually an integer, so dryer'sbatch_idsailed throughselect_dtypes(include="number")and was scaled as though it were a tag.I checked whether that actually mattered before changing it. It is not the "identifier steals all the weight" story I half expected:
batch_idtakes almost no weight (0.000051), because its forced range of 1.0 leaves the raw identifiers deviating wildly from the average. But it joins the distance the alignment minimises and the normalisation that follows, and the real weights move a long way:batch_idEvery batch is checked for spread, not only the first, so a tag that happens to be flat in batch 1 but moves later 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, since constant over one set of batches does not mean constant in general.
Side effect worth noting: the zero-range warning added in #572 now reports only the genuine case (
DifferentialPressure, 16 of 71 batches) instead of leading withbatch_idon every default call.The DTW cost matrix
np.diag(A @ W @ A.T)builds an h-by-h product to keep its h diagonal entries: quadratic in the reference length per test sample, cubic over the whole matrix, plus a temporary to match. Summing(A @ W) * Aalong the tags computes only the diagonal, inh * J²work rather thanh² * J.It also removes the numba 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 #571 split the banded and unconstrained cases into separate loops, so one loop now serves both.
I was wrong about this needing a pinned-value sweep
I told you this would shift pinned values by ~1e-15 and therefore needed its own careful PR. Measured, it does not. Cost values differ by about 5e-16 relative (one ulp), because the summation order changes, but:
The path is chosen by comparisons between accumulated costs that sit far apart relative to one ulp, so the change does not reach anything downstream. Tests pin this rather than trusting it: the kernel is checked against the literal Mahalanobis expression, and three length/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 (4000 × 2), which under the old form allocated a 4000-by-4000 product per test sample.
A correction to numbers I published in #571
The "6.55 s at 700 samples" in that PR was inflated by background test suites competing for the machine; uncontended it is 272 ms. The ratios there were measured back-to-back so they stand, but the absolute times were too high.
Test plan
uv run pytestfull suite: 3352 passed, 41 skipped, coverage 94.37%.test-under-dash-O, lint, typecheck, CodeQL, build and codecov.ruff check .,ruff format --check .,mypy src/process_improveall clean.The first CI run caught one genuine failure, and it was the right one: a test from #572 asserted
batch_idheads the zero-range warning, which is exactly the behaviour this PR removes. Split into two tests rather than deleted, so the original point is still pinned onDifferentialPressureand the new behaviour is pinned directly. Codecov then flagged five unreached lines in the new resolution; covered with eight tests, one per rule, rather than suppressed.Checklist
CITATION.cffin step in the same commit). MINOR rather than PATCH becausecolumns_to_align=Nonenow resolves to fewer columns, which changes the alignment for anyone relying on the old sweep, and an explicitly named non-numeric column now raises.ruff check .passesCHANGELOG.mdupdated🤖 Generated with Claude Code
https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og