Close out #198, #197 and #199 - #572
Merged
Merged
Conversation
… hiding collapsed ranges (#198) The TODO asked whether `f_iqr` would work as the robust range instead of q98 - q02. Measured on the bundled data, it works but is not interchangeable, so it is offered as `settings["robust_range"]` rather than substituted. The IQR spans the middle half of a batch; q98 - q02 spans nearly all of it. On a Gaussian tag the second is about 3.05 times the first, but a batch trajectory is not Gaussian, so the ratio varies by tag: per tag it runs 1.02 to 4.23 on the dryer data and 1.21 to 2.95 on nylon. Switching therefore re-weights the tags against each other rather than rescaling them together, which would silently change every existing alignment. The default is unchanged. The IQR also collapses to zero more often, because a tag holding one value for more than half a batch has no interquartile spread: `DifferentialPressure` collapses in 21 of the 71 dryer batches under the IQR against 16 under q98 - q02. That collapse was silent. `rnge[rnge == 0] = 1.0` leaves the tag unscaled and said nothing, which `docs/development/error_handling.rst` names as a `warnings.warn` case ("a constant column dropped"). One aggregated UserWarning per call now names the tags and how many batches each affected, rather than one warning per batch. On the dryer data with the default columns it reports `batch_id` in 71 of 71 batches, which is worth knowing on its own: the identifier column is swept in as a tag whenever `columns_to_align` is left None. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
`f_rupture` has never had a working body: until #563 it returned None for a valid call, and since then it has raised NotImplementedError. It now detects change points with `ruptures`, added to the `batch` extra and imported at module level through the `_MissingExtra` idiom already used for plotly, so the module still imports without it. Detection uses PELT, which is exact and linear in the signal length and does not need to be told how many change points to look for. Three decisions worth recording: - **The parameter is `tags`, not `columns`.** Every other feature function in this module takes `tags`; `f_rupture` was the odd one out, and since it never returned a value there is no working caller to break. - **The default penalty is log(n), not the 100.0 from the old sketch.** The rbf cost is bounded, so on a 100-sample signal a penalty of 100 is unreachable and the function finds nothing: a five-sigma step went undetected. log(n) is the BIC-style choice and adapts to batch length. Measured on a single five-sigma step it recovers the change point exactly at 60, 200 and 1000 samples under both rbf and l2, and on pure noise of those lengths reports at most one spurious point. - **The default model is rbf because its cost is scale-free.** Multiplying a signal by 1000 leaves the detected points unchanged under rbf; under l2 at the same penalty one true change point became 37 spurious ones. The docstring says so, rather than leaving the reader to discover it. The return shape is one `<tag>_rupture` column per tag holding a tuple of positions, which is the only faithful shape for a result whose length varies per batch; the docstring shows `.map(len)` for a numeric feature. The trailing sentinel `ruptures` appends (the signal length, not a change point) is stripped, and a signal too short to split or carrying missing data returns an empty tuple rather than raising. The sketch's matplotlib call is not carried over: the function returns the breakpoints and leaves plotting to the caller. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
…ting MINOR: `f_rupture` becomes functional, `determine_scaling` gains a `robust_range` setting, and `ruptures` joins the optional `batch` extra. `determine_scaling` now warns where it used to substitute silently; nothing is removed and no default changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
…ion (#197) Three items from #197, all in the alignment path. **Batch identifiers of string type crashed, in two places.** `melted_to_dict` never coerced its keys, but two functions downstream assumed every column was numeric: - `determine_scaling` took its per-batch minimum over every column rather than over `columns_to_align`, so a string identifier raised `TypeError: Cannot convert [...] to numeric`. With integer identifiers it did not raise, but it still emitted rows for columns that were never scaled, leaving a real `Minimum` against a NaN `Range` for each of them. - `align_with_path` then averaged whole rows, so a compression in the warping path reached `TypeError: unsupported operand type(s) for /: 'str' and 'int'`, which is the crash the TODO predicted. With integer identifiers it wrote the mean of the identifier into the aligned frame, and pandas had already started warning that assigning a string into the float frame would become an error. Non-numeric columns are now carried through unaveraged: they are constant within a batch, so the first value is taken and the column keeps its own dtype. The integer path is verified bit-identical (weights, average and aligned values), and string identifiers now produce exactly the same numbers as integer ones. **The resampled axis only accepted a delta that divided the maximum.** The target axis came from `np.arange(0, maximum, delta)` while the source axis was rebuilt as `maximum - delta`, which agree only when the delta divides evenly. A delta of 0.3 or 7 put the endpoints in different places, and two asserts turned that into a bare AssertionError. Worse, `python -O` strips asserts, so under optimisation it extrapolated silently instead. The source axis now takes its endpoints from the target axis, so any delta works and there is nothing left to assert. An axis that cannot be built at all (a non-positive value, or a delta no smaller than the maximum) is rejected up front with a message. **The commented-out termination assertion is restored.** It had been parked while DTW termination was in flux, and had drifted twice over: it indexed `weight_history[4, :]`, a row that does not exist in the 3-row history that tolerance produces, and positional indexing no longer works now that the history is a DataFrame. The values are captured from a run. New tests pin what termination now does: tolerances of 1.0, 0.06 and 0.01 give 1, 3 and 11 iterations, every run starts from unit weights, and the looser run is a prefix of the tighter one, so the tolerance decides only when to stop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
MINOR: a delta that does not divide the interpolation maximum now works rather than failing an assert, and non-numeric columns survive alignment instead of crashing it. Both change what the function does on inputs it previously rejected or corrupted, so this is not a pure patch. 1.90.0 was never released, so its entry is merged in rather than left as a separate heading, and the duplicate `### Fixed` blocks that the merge produced are combined into one in Keep a Changelog order. 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! |
…imator (#199) **`settings["batch_weighting"]`.** Every batch contributed equally to the variable weights, so one badly aligned batch inflated the summed deviation of whichever variables it misfit and depressed their weights for every other batch. `"huber"` weights each batch by Huber's function on the robust z-score of its `normalized_distance`, against the median and MAD of the batch set: weight 1 inside the 1.345 cutoff, falling off as 1/|z| beyond it, rescaled to average 1.0. Huber rather than a redescending function (Tukey's bisquare) because it never reaches zero. That is the feedback risk the previous TODO recorded: a downweighted batch pulls the average trajectory away from itself, so it looks worse next iteration, and a weight that could reach zero would make that a one-way door. The weights are recomputed from scratch each iteration and floored, so a batch that recovers is counted again. The default stays `"equal"`, which returns exactly 1.0 per batch, so multiplying by it is a no-op and existing results cannot drift. Measured on the dryer fixture, Huber leaves 53 of 71 batches at full weight and downweights batches 23, 48 and 34 hardest. Those are precisely the three the `distances` output added in #570 reports as worst, so the diagnostic and the weighting agree without being wired together. The worst normalized distance rises slightly, from 0.0714 to 0.0759, which is the feedback working as described and staying bounded: the average moves away from the batch being downweighted. The point is not to fit the worst batch better, it is to stop that batch from setting the variable weights for the other seventy. **`BatchScaler`.** A fit / transform wrapper over `determine_scaling`, `apply_scaling` and `reverse_scaling`, following the estimator contract the rest of the package uses, so batch scaling composes with Pipeline and survives `clone`. The three functions are unchanged and still public. It also takes the melted-DataFrame input the functions reject: pass `batch_col` and it splits the frame itself, rather than telling the caller to do it. Also closes the "thesis page 181" item: it referred to averaging the batch samples that compress onto one reference index in `align_with_path`. That is implemented and pinned by tests/batch/test_dtw_align_with_path.py, and the marker was already replaced with a plain comment in #563; the issue's list was stale. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
MINOR: a new public estimator and a new setting. 1.91.0 was never released, so its entry is folded in rather than left as a separate heading. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
Codecov flagged two lines of the batch-weighting helper as unreached. Both are the guards that decide what happens when the distances carry no information, so they are worth a test rather than a pragma: - every distance non-finite, where there is no basis to rank batches and none is penalised; - one distance non-finite among usable ones, where that batch is floored rather than dropped, so it still contributes; - no batches at all, where the `zip(..., strict=True)` in the caller needs the lengths to agree even at zero. The stand-in result object is factored into a helper, since three tests now need one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og
This was referenced Sep 12, 2026
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 #198, the three remaining items of #197, and #199. All three land here because this session works on one branch; happy to split if you prefer.
No default behaviour changes. The scaling default is byte-for-byte what it was, the integer-identifier alignment path is verified bit-identical, and the new batch weighting defaults to exactly 1.0 per batch. The only new signals on existing call paths are a warning that was previously a silent substitution, and a
ValueErrorwhere there was a bareassert.#199
Downweighting badly aligned batches
Every batch contributed equally to the variable weights, so one badly aligned batch inflated the summed deviation of whichever variables it misfit and depressed their weights for every other batch.
settings["batch_weighting"]is now"equal"(default) or"huber".Huber weights each batch by its
normalized_distanceturned into a robust z-score against the median and MAD of the batch set: weight 1 inside the 1.345 cutoff, falling off as1/|z|beyond it, rescaled to average 1.0.Huber rather than a redescending function (Tukey's bisquare) because it never reaches zero. That is the feedback risk the old TODO recorded: a downweighted batch pulls the average trajectory away from itself, so it looks worse next iteration. A weight that could reach zero makes that a one-way door. Weights are recomputed from scratch each iteration and floored, so a batch that recovers is counted again.
Measured on the dryer fixture:
Those three batches are exactly the ones the
distancesoutput added in #570 reports as worst, so the diagnostic and the weighting agree without being wired together.One result that looks wrong and is not: the worst normalized distance rises under Huber, 0.0714 to 0.0759. That is the feedback working as described and staying bounded, because the average moves away from the batch being downweighted. The aim is not to fit the worst batch better; it is to stop that batch setting the variable weights for the other seventy.
BatchScalerA fit / transform wrapper over
determine_scaling,apply_scalingandreverse_scaling, following the estimator contract the rest of the package uses, so batch scaling composes withPipelineand survivesclone. The three functions are unchanged and still public.The "thesis page 181" item was stale
It referred to
align_with_path: averaging the batch samples that compress onto one reference index. The original marker read "where more than 1 point in the target trajectory is aligned with the reference: compute the average". That is implemented (np.nanmeanover those samples), pinned bytests/batch/test_dtw_align_with_path.py, and the marker was already replaced with a plain comment in #563. Nothing to do; the issue's list had not caught up.#197
Batch identifiers may be of any type
A string identifier raised in two places, not the one the TODO predicted:
determine_scalingtook its per-batch minimum over every column rather than overcolumns_to_align:TypeError: Cannot convert [...] to numeric. With integer identifiers it did not raise, but it still returned rows for columns never scaled, each with a realMinimumagainst a NaNRange.align_with_paththen averaged whole rows, so a compression in the warping path hitTypeError: unsupported operand type(s) for /: 'str' and 'int'. That is the predicted crash. With integer identifiers it wrote the mean of the identifier into the aligned frame, and pandas had already begun warning the string case would become a hard error.Non-numeric columns are now carried through unaveraged, keeping their dtype. Integer path verified bit-identical; string identifiers give the same numbers per batch to 1e-12.
The resampled percentage axis accepts any resolution
AssertionErrorAssertionErrorWorse than the bare assert:
python -Ostrips asserts, and this repo runs atest-under-dash-Ojob, so under optimisation it extrapolated silently. The source axis now takes its endpoints from the target axis, so any delta works and there is nothing left to assert.The parked termination assertion is restored
It had drifted twice: it indexed
weight_history[4, :], a row that does not exist in the 3-row history, and positional indexing no longer works now the history is a DataFrame. New tests pin termination: tolerances 1.0 / 0.06 / 0.01 give 1, 3 and 11 iterations, every run starts from unit weights, and the looser run is a prefix of the tighter one.#198
f_rupturenow worksNever had a working body: it returned
None, then raisedNotImplementedErrorafter #563. Now PELT fromruptures.The default penalty is
log(n), not the100.0in the old sketch. Therbfcost is bounded, so on a 100-sample signal a penalty of 100 is unreachable and the function finds nothing: a five-sigma step went undetected.log(n)recovers the step exactly at 60, 200 and 1000 samples under bothrbfandl2, with at most one spurious point on pure noise.The default model is
rbfbecause its cost is scale-free. Multiplying a signal by 1000 leaves the points unchanged underrbf; underl2at the same penalty one true change point became 37 spurious ones.The first keyword is now
tags, notcolumns, matching every other feature function. No working caller can be affected, but it is a signature change.ruptures>=1.1.9joins the optionalbatchextra via the_MissingExtraidiom.q98 - q02 versus IQR
The IQR works but is not interchangeable: the ratio varies by tag (1.02 to 4.23 on dryer, 1.21 to 2.95 on nylon), so switching re-weights tags against each other rather than rescaling them together. Offered as
settings["robust_range"], not substituted. It also collapses to zero more often (DifferentialPressure: 21 of 71 batches against 16).The collapsed range was silent
rnge[rnge == 0] = 1.0said nothing, whichdocs/development/error_handling.rstnames as awarnings.warncase. One aggregatedUserWarningper call now names each tag and its batch count.Worth its own decision: it reports
batch_idin 71 of 71 batches. Withcolumns_to_alignleftNone, the identifier column is swept in and scaled as though it were a tag. Pre-existing, unchanged here, but the warning is how you would notice.Test plan
uv run pytestfull suite: 3343 passed, 41 skipped, coverage 94.37% (3261 before, plus 82 new tests).test-under-dash-O, lint, typecheck, CodeQL, build and codecov.ruff check .,ruff format --check .,mypy src/process_improveall clean locally."equal"batch weighting returns exactly 1.0.Codecov first flagged two unreached lines in
_batch_weights, both guards for distances that carry no information. Covered with three tests (every distance non-finite, one non-finite among usable ones, no batches at all) rather than suppressed; codecov is green again.A note on one number, in case it shows up in a log: an intermediate full-suite run reported 40% coverage. That was my own doing, from running targeted
pytestcommands concurrently with the full suite, which clobbers the shared.coveragedata. The clean serial runs read 94.37%.Checklist
CITATION.cffin step in the same commit). 1.90.0 and 1.91.0 were never released, so their entries are folded into one section in Keep a Changelog order.ruff check .passesCHANGELOG.mdupdatedAfter this merges
#198, #197 and #199 should all be closable. Their remaining list items are either delivered here or verified already done (
f_robust_mad, the bare TODO markers cleared in #563,f_cross/f_elbow/ theagecolumn, and the thesis page 181 item above).🤖 Generated with Claude Code
https://claude.ai/code/session_0123j56Hk6jRz9zy91Doc1og