feat: relative width smoothing procedures - #718
Conversation
|
The idea is that the scaled kernel has constant width on a grid that is the logarithm of the original grid, and when the kernel is constant width it can be computed efficiently using Here's a document describing the implementation in more detail: |
|
@SimonHeybrock Do you think this functionality should go here in Scippneutron or in Scipp? It's mainly useful for analysis work where you have an idealized model and want to apply some smoothing from resolution effects to obtain something that is comparable to the measurement data. |
|
Before looking into details, can you compare this to what we already have in https://scipp.github.io/generated/modules/scipy/scipp.scipy.ndimage.gaussian_filter.html (and https://scipp.github.io/generated/modules/scipy/scipp.scipy.ndimage.generic_filter.html)? What is added on top of that? Would it make sense to have a common API? |
|
The difference is that this is a more convenient interface for typical smoothing needs, more of a interface for users than for developers.
Since the functions implemented in this module use The functions in this module are explicitly 1D, and not ND like the functions you mentioned. That is a limitation, but it allows a simpler interface and it covers common use cases. |
SimonHeybrock
left a comment
There was a problem hiding this comment.
Nice work — the numerical core is careful and the test suite is the strongest part of this PR. Analytic reference solutions for the smoothed quadratic, second-order convergence checks against grid spacing, and tail-truncation scaling with a grid-limited plateau is real verification rather than smoke tests. The convolution alignment, the O(n) boundary normalization in _valid_weight_sums, and the direct-vs-FFT switch for non-finite input all check out.
Findings below, roughly in order of importance. One is a genuine crash (OverflowError on the relative path for coordinates that collide in log space), two are smaller error-handling issues, and the rest is structure and documentation.
The largest item is that the relative and translation-invariant paths are one algorithm under a coordinate map — I prototyped the unification to confirm that, details on _translation_invariant_kernel_weights.
Separately, and not a code review point so not raised inline: I still want to settle whether this belongs in scippneutron at all, given it is 672 lines with no neutron-specific content. Let's discuss that on the thread.
| return _trim_kernel_weights(m, w) | ||
|
|
||
|
|
||
| def _translation_invariant_kernel_weights( |
There was a problem hiding this comment.
This and _relative_kernel_weights are the same function under a coordinate map, and the reduction is exact rather than approximate: substitute u(z) = scale*z, z(u) = u/scale, z_domain_min = -inf into the relative version and norm_mass collapses to 1, has_finite_log_support collapses to the plain isfinite check, and log1p/expm1 collapse to linear. One level up, _smooth_relative_kernel and _smooth_kernel_values differ only by log-vs-identity plus the x > 0 check.
I prototyped the unified version to check this isn't hand-waving: the four functions become two, parameterized on a small geometry object supplying coordinate, points, displacement_min, offset and displacement. The core drops from 436 to ~290 lines, all 73 tests in this PR pass unchanged, and across 180 combinations of grid (uniform, geometric, jittered, 4-point, 1e-6..1e6), kernel (gaussian, boxcar, triangular, asymmetric uniform, asymmetric triangular, expon) and scale, the outputs agree with this branch to 1e-10.
Two things fall out for free. The truncation condition becomes isfinite(u_left) and isfinite(u_right), which is provably equivalent to has_finite_log_support but easier to check by eye. And computing k in float for the shared max_grid_points guard fixes the overflow reported on line 358 by construction.
Happy to push the prototype if useful.
There was a problem hiding this comment.
I don't think this is worth DRYing up by unifying the two cases. It's better to keep them separate because I think that makes it easier to understand, it's already complex enough.
There was a problem hiding this comment.
But less duplication is easier to review.
There was a problem hiding this comment.
I don't agree that's obviously the case.
I've already said what I think about it. Do you think I should do the refactor suggested by the model?
There was a problem hiding this comment.
I tried a refactor to unify _relative_kernel_weights and _translation_invariant_kernel_weights, but it did not result in any real code reduction, while being more complex.
It's possible there's a better shape that actually reduces code size and complexity. You are welcome to push such a version to this branch if you prefer it.
| tail: float = 1e-12, | ||
| max_grid_points: int = 1_000_000, | ||
| ) -> _ScippArray: | ||
| """Smooth sampled data with a translation-invariant kernel. |
There was a problem hiding this comment.
Two properties are worth stating explicitly here, because both public functions are normalized weighted averages rather than convolutions:
- A constant is preserved exactly, but a sum is not. Boundary renormalization pulls mass back into the domain, so a Gaussian peak's total changes by ~2% at
scale=1.0on a 200-point grid. That is the right behavior for the use case in the PR description (smearing an idealized model to compare against data), but it makes the function unsuitable for smearing raw counts — and the tests useunit='counts'throughout, which invites exactly that reading. - The result may contain NaN where no kernel mass is reachable: at the boundary for one-sided kernels, and for the whole array when the kernel has no reachable mass at all (the
nonzero.size == 0path at line 88). This is deliberate and tested, but neitherReturnssection mentions it.
There was a problem hiding this comment.
I think the first point has to be clarified a bit. Exactly what was the test case and what was the result of that test?
It's not correct to say that the normalization behavior "makes the function unsuitable for smearing raw counts". That is a far to general statement. It is just context dependent.
The smoothing operation needs to have a strategy for handling boundaries. Here the strategy used is to re-normalize by the mass of the kernel that falls inside the boundary. This can be understood as an assumption that the (kernel-)weighted mean of the signal outside the boundary is the same as the weighted mean of the signal inside the boundary.
In practice that is a conservative assumption, we don't expect anything drastic happening to the signal exactly at the boundary.
But of course that assumption will be wrong sometimes, any assumption is.
How to deal with that as a user
As a user we might know something about the behavior of the signal outside the domain, for example, we might know it is zero, or we know it decays following a certain patterns, or something else.
In almost all such cases the smoothed signal near the boundary will not be what it would be if we had taken our extra knowledge into account properly.
If the user wants perfect boundary behavior the best option for them is to extend the signal that they pass to the smoother with the "tails" that they assume it has outside of the bounds of the signal. Then they can crop out the portion of the smoothed signal that overlaps with the real signal, and that entire portion will have been unaffected by "boundary effects".
Another (simpler) option is to smooth the signal and then cut out a center section of the smoothed signal that is unaffected by boundary effects.
| # SPDX-License-Identifier: BSD-3-Clause | ||
| # Copyright (c) 2026 Scipp contributors (https://github.com/scipp) | ||
|
|
||
| from __future__ import annotations |
There was a problem hiding this comment.
No module docstring. The geometric-grid trick — that a relative-width kernel is translation invariant in log(x), which is what makes scipy.signal.convolve applicable — is the central idea here, and it currently lives only in a PDF attached to a PR comment. That should be in the module docstring so it survives.
… remove unnecessary validation
The relative and translation-invariant procedures are the same algorithm under a coordinate map. Substituting u(z) = scale*z, z(u) = u/scale and z_min = -inf into the relative version reduces it exactly to the translation-invariant one: the reachable-mass normalization collapses to one, log1p/expm1 collapse to linear, and the branch selecting between exact support and a tail cutoff collapses to a plain isfinite check on the bounds in u. Both weight computations and both resample-smooth-interpolate drivers therefore become one of each, parameterized on a geometry supplying the working coordinate and the map between a displacement and its offset in that coordinate. This removes a class of bug rather than a number of lines. The grid size overflowing to infinity for coordinates that collide in the working coordinate was fixed on the relative path only; the uniform path still raised OverflowError for inputs whose spacing ratio overflows, such as [0.0, 5e-324, 1.0]. With a single path the two cannot diverge again. Move the stencil-clamping test onto the public API so it keeps testing the behavior rather than the helper that happens to implement it.
|
Pushed The reduction is exact rather than approximate, which is what makes the shape work: substituting What convinced me it was worth pushing rather than dropping: the paths had already diverged again. smooth(sc.array(dims=['x'], values=np.array([0.0, 5e-324, 1.0]), unit='m'), y, scale=sc.scalar(0.1, unit='m'))
# OverflowError: cannot convert float infinity to integerSame for Verification, since this is a numerics change and the diff is not small:
Two test changes, both flagged in the review as testing implementation rather than behavior. Untouched, since they are yours to decide rather than mechanical: the float32 → float64 widening, and documenting that these are normalized weighted averages, so a constant is preserved exactly but a sum is not (~2% on a peak at |
|
@jokasimr What do you think about the pushed change? I like the |
|
|
||
| # The kernel is flat to within 1e-5 over the whole input, so every point | ||
| # averages the entire array. | ||
| np.testing.assert_allclose(actual, np.full(size, y.mean()), rtol=1e-4) |
There was a problem hiding this comment.
I think testing the internal helper was simpler and clearer here. It needed less explanatory comments. I'm also not sure if this tests the same things.
Doesn't seem worth it to me just to get the private helper out of the tests.
I think it looks good 👍 I'm just a little bit worried about the changed test, it would be good to either re-add that, or test it locally manually once to make sure there's no behavioral change. |
SimonHeybrock
left a comment
There was a problem hiding this comment.
This review was created with the help of AI. If anything below strikes you as unpleasantly formulated (tone, verbosity, ...), as too nit-picky, or as otherwise improvable, please tell me -- I am actively trying to improve this.
The algorithm looks correct to me. I checked the two convolution helpers independently against a brute-force double loop over 300 random asymmetric stencils, and both the alignment (start = offsets[-1]) and the O(n) boundary normalisation come out exact.
Nice work on the tests! Comparing against an analytically smoothed quadratic via truncated-normal moments, rather than golden values, means they would catch a wrong answer and not merely a changed one; test_asymmetric_kernel_convolution_matches_direct_weighted_sum brute-forces the convolution, which is exactly the test I would have asked for; and the nextafter / 5e-324 / 1e300 cases are more thorough than most numerical code gets.
Comments below are mostly small. The one I would like settled before merge is the input API (see the comment on _scipp_input), since it is public and awkward to walk back later.
Aside, not for this PR: docs/user-guide/algorithms-background/ would be a natural home for the figure in the PR description at some point -- it currently only lives here, and it illustrates the behaviour well.
| def _scipp_input( | ||
| x: object, y: object | ||
| ) -> tuple[sc.Variable, sc.Variable, sc.DataArray | None]: | ||
| """Validate untyped user input and reduce it to a coordinate/data pair.""" |
There was a problem hiding this comment.
Both entry points accept either a DataArray or an (x, y) pair of Variables. I would suggest dropping the pair form and taking a DataArray only, matching scipp.scipy: ndimage.gaussian_filter(x, /, *, sigma=...) and signal.sosfiltfilt(obj, dim, *, sos=...) both take a single data argument rather than a coord/data pair.
The pair form saves the caller a sc.DataArray(y, coords={'x': x}). In exchange, this function re-validates dimensionality, matching dims and binned-ness that a DataArray enforces structurally; y becomes a positional-optional with a "must be omitted" TypeError; and the first parameter is named x even when it holds the data.
| z_min = geometry.displacement_min(scale) | ||
| p_min = float(dist.cdf(z_min)) | ||
| reachable_mass = 1.0 - p_min | ||
| if not np.isfinite(reachable_mass) or reachable_mass <= 0.0: | ||
| raise ValueError("kernel has no mass in the valid domain for this scale") | ||
|
|
||
| support_min, support_max = _kernel_support(dist) |
There was a problem hiding this comment.
_kernel_support has a helpful message for unfrozen distributions:
kernel must be a fully specified distribution. For distributions with shape parameters, pass a frozen distribution such as triang(c=0.5), not triang.
It is unreachable. dist.cdf(z_min) above runs first, so kernel=triang gives the user TypeError: _parse_args() missing 1 required positional argument: 'c' instead. Distributions without shape parameters (norm, uniform) pass the CDF call and never reach the branch either, so it never fires at all.
Taking the support first fixes it:
| z_min = geometry.displacement_min(scale) | |
| p_min = float(dist.cdf(z_min)) | |
| reachable_mass = 1.0 - p_min | |
| if not np.isfinite(reachable_mass) or reachable_mass <= 0.0: | |
| raise ValueError("kernel has no mass in the valid domain for this scale") | |
| support_min, support_max = _kernel_support(dist) | |
| z_min = geometry.displacement_min(scale) | |
| support_min, support_max = _kernel_support(dist) | |
| p_min = float(dist.cdf(z_min)) | |
| reachable_mass = 1.0 - p_min | |
| if not np.isfinite(reachable_mass) or reachable_mass <= 0.0: | |
| raise ValueError("kernel has no mass in the valid domain for this scale") |
| raise sc.DimensionError("x and y must be one-dimensional") | ||
| if x.dims != y.dims: | ||
| raise sc.DimensionError("x and y must have the same dimension") | ||
| if x.is_binned or y.is_binned: |
There was a problem hiding this comment.
A binned DataArray hits x.coords.is_edges(x.dim) first and gets CoordError: the dimension coordinate must not contain bin edges, which points at the wrong thing.
This check is only reachable through the Variable-pair path, so if that path goes away it becomes dead code unless it moves ahead of the is_edges check.
| return offsets[first:last], weights / weights.sum() | ||
|
|
||
|
|
||
| class _Geometry(Protocol): |
There was a problem hiding this comment.
This part was implemented by myself, looking at it again now I see that scale is threaded through displacement_min, offset and displacement, and is the same value for the whole call. Binding it at construction (_Geometric(scale)) would drop the parameter from three signatures, take _kernel_weights from six parameters to five, and make a mismatched scale between the offset/displacement inverse pair impossible to express.
| float(value) for value in geometry.offset(scale, dist.ppf(probabilities)) | ||
| ) | ||
| if not np.isfinite(u_right): | ||
| raise ValueError("right kernel bound is not finite; increase tail") |
There was a problem hiding this comment.
Worth a comment here: there is no matching finiteness check on u_left, which instead clamps to -max_offset via np.clip below. That is right on both sides -- the left bound is bounded by the valid domain, the right one is not, and the geometric case with scale=1 relies on the clamp -- but the asymmetry reads as an oversight without a line saying why.
Minor, same block: when only one side of the support is infinite, both bounds get recomputed from ppf, so tail/2 is discarded off a side that was already finite. Within budget, just slightly wasteful.
| ) | ||
|
|
||
|
|
||
| def test_gaussian_smoothing_matches_exact_quadratic_on_geometric_grid(): |
There was a problem hiding this comment.
| def test_gaussian_smoothing_matches_exact_quadratic_on_geometric_grid(): | |
| def test_relative_width_gaussian_matches_exact_quadratic_on_geometric_grid(): |
| assert np.sqrt(np.mean(error**2)) < 1e-7 | ||
|
|
||
|
|
||
| def test_gaussian_smoothing_error_is_second_order_in_log_grid_spacing(): |
There was a problem hiding this comment.
| def test_gaussian_smoothing_error_is_second_order_in_log_grid_spacing(): | |
| def test_relative_width_gaussian_error_is_second_order_in_log_grid_spacing(): |
| np.testing.assert_allclose(observed_orders, 2.0, atol=0.06) | ||
|
|
||
|
|
||
| def test_gaussian_smoothing_error_scales_with_inverse_kernel_width(): |
There was a problem hiding this comment.
| def test_gaussian_smoothing_error_scales_with_inverse_kernel_width(): | |
| def test_relative_width_gaussian_error_scales_with_inverse_kernel_width(): |
| ) | ||
|
|
||
|
|
||
| def test_gaussian_smoothing_error_scales_with_tail_until_grid_error_dominates(): |
There was a problem hiding this comment.
| def test_gaussian_smoothing_error_scales_with_tail_until_grid_error_dominates(): | |
| def test_relative_width_gaussian_error_scales_with_tail_until_grid_error_dominates(): |
| def test_compact_symmetric_kernel_matches_exact_interior_quadratic_moments( | ||
| kernel, relative_variance, max_error | ||
| ): |
There was a problem hiding this comment.
| def test_compact_symmetric_kernel_matches_exact_interior_quadratic_moments( | |
| kernel, relative_variance, max_error | |
| ): | |
| def test_relative_width_compact_kernel_matches_interior_quadratic_moments( | |
| kernel, relative_variance, max_error | |
| ): |
Adds functionality to scippneutron for smoothing 1D curves with kernels that have a width relative to the dimension coordinate.
As an example, in the figure below the step
signalis smoothed by a gaussian kernel with width proportional tox:The computed
smoothedsignal and the expectedexactsignal overlap.