pimf 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7.0.1
13
+ - uses: astral-sh/setup-uv@v9.0.0
14
+ with:
15
+ enable-cache: true
16
+ - run: uv run --frozen python -m pytest -q
17
+ - run: uv build
18
+ - uses: actions/upload-artifact@v7.0.1
19
+ with:
20
+ name: dist
21
+ path: dist/
22
+
23
+ publish:
24
+ needs: build
25
+ runs-on: ubuntu-latest
26
+ # Must match the environment name configured on the PyPI trusted publisher.
27
+ environment: pypi
28
+ permissions:
29
+ id-token: write
30
+ steps:
31
+ - uses: actions/download-artifact@v8.0.1
32
+ with:
33
+ name: dist
34
+ path: dist/
35
+ - uses: pypa/gh-action-pypi-publish@release/v1
pimf-0.1.0/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .DS_Store
pimf-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kuziuk
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pimf-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,181 @@
1
+ Metadata-Version: 2.4
2
+ Name: pimf
3
+ Version: 0.1.0
4
+ Summary: Intrinsic multiscale filtering (IMF) for 1-D signals: linear and robust decompositions.
5
+ Project-URL: Homepage, https://github.com/mkuziuk/pimf
6
+ Project-URL: Source, https://github.com/mkuziuk/pimf
7
+ Project-URL: Issues, https://github.com/mkuziuk/pimf/issues
8
+ Author: Mikhail Kuziuk
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: decomposition,m-estimator,robust-statistics,signal-processing,smoothing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: numpy>=1.22
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pimf
25
+
26
+ Intrinsic multiscale filtering (IMF) for one-dimensional signals: a **linear**
27
+ decomposition (local weighted mean) and a **robust** variant that replaces the
28
+ mean with a smooth robust location fit solved by gradient descent.
29
+
30
+ Both are the same algorithm. Starting from `r_1 = y`, each stage smooths the
31
+ current residual with a local M-estimator and passes on what is left:
32
+
33
+ ```
34
+ S_k = argmin_x sum_u w_{t,u} * rho(r_k(u) - x) (per position t)
35
+ r_{k+1} = r_k - S_k
36
+ ```
37
+
38
+ The signal decomposes exactly: `y = S_1 + ... + S_K + r_{K+1}`. The only
39
+ difference between the variants is the contrast `rho`:
40
+
41
+ - **Quadratic** `rho(r) = r^2/2` — closed form, the kernel-weighted local mean
42
+ (the linear IMF).
43
+ - **SmoothAbs** `rho_h(r) = r*erf(r/(sqrt(2)h)) + sqrt(2/pi)*h*exp(-r^2/(2h^2))`
44
+ — a smoothed absolute value with bounded score `psi_h(r) = erf(r/(sqrt(2)h))`,
45
+ solved by clipped gradient descent (the robust IMF). Large contaminated
46
+ observations have bounded influence.
47
+
48
+ The library packages the algorithms validated in the IMF research project
49
+ (`Projects/imf`) and reproduces its numerics — the robust decomposition is
50
+ bit-identical to the reference notebook on the research example.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pip install pimf
56
+ ```
57
+
58
+ Requires Python >= 3.10. NumPy is the only dependency.
59
+
60
+ For development, clone the repo and install in editable mode:
61
+
62
+ ```bash
63
+ pip install -e .
64
+ ```
65
+
66
+ ## Quickstart
67
+
68
+ ```python
69
+ import numpy as np
70
+ import pimf
71
+
72
+ t = np.linspace(0.0, 1.0, 1000)
73
+ y = (
74
+ np.sin(2 * np.pi * t)
75
+ + 0.25 * np.sin(12 * np.pi * t)
76
+ + np.random.default_rng(0).normal(0, 0.1, 1000)
77
+ )
78
+
79
+ linear = pimf.linear_imf(y) # quadratic contrast, closed form
80
+ robust = pimf.robust_imf(y, h=0.2) # smooth-abs contrast, h ~ 2 * noise sigma
81
+
82
+ robust.imfs # (K, n) array of components, coarsest first
83
+ robust.residual # (n,) final residual
84
+ robust.stages # per-stage diagnostics (window size, GD iterations, ...)
85
+ robust.reconstruction # imfs.sum(axis=0) + residual == y to ~1e-15
86
+ ```
87
+
88
+ The general entry point is `pimf.imf(y, window_sizes=..., contrast=..., kernel=...,
89
+ boundary=...)`; `linear_imf` and `robust_imf` are one-line wrappers around it.
90
+
91
+ ## Concepts
92
+
93
+ **Window schedule.** `make_window_schedule(n)` generates the per-stage window
94
+ sizes: odd, strictly decreasing, starting at about `n/2` and shrinking
95
+ geometrically by `sqrt(2)` down to a floor of 31. Pass `window_sizes=` to
96
+ override. Windows must be odd.
97
+
98
+ **Kernel.** The window weights come from a kernel profile `k(u)` on `[-1, 1]`.
99
+ The default `SquaredTriangle` uses `k(u) = 0.75 * (1 - |u|)^2` — the square of
100
+ the triangular kernel. (The research project and IMF.pdf call this kernel
101
+ "Epanechnikov"; that is a misnomer — the classical Epanechnikov kernel is
102
+ `(3/4)(1 - u^2)` — so this library names it for what it is.) Endpoint weights
103
+ are exactly zero, and weights are normalized to sum to one.
104
+
105
+ **Contrast.** A contrast supplies the loss `rho` (via `__call__`), its
106
+ derivative `psi` (the score), and `curvature()` — an upper bound on `rho''`
107
+ that sets the stable gradient step `0.95 / curvature()`. A contrast with a
108
+ closed-form minimizer can provide `solve(windows, weights)`, which the driver
109
+ uses instead of gradient descent (that is what makes `Quadratic` the fast
110
+ linear path). For `SmoothAbs(h)`, `h ~ 2 * sigma` of the Gaussian noise is the
111
+ research-validated choice: smaller `h` behaves like a running median, larger
112
+ `h` like the local mean.
113
+
114
+ **Boundary.** `boundary="wrap"` (circular) is the default and the setting the
115
+ linear-operator theory of the research project assumes; it is passed straight
116
+ to `np.pad`, so `"reflect"` and `"edge"` also work.
117
+
118
+ ## Extending
119
+
120
+ Custom contrast — one small class:
121
+
122
+ ```python
123
+ import numpy as np
124
+ import pimf
125
+
126
+
127
+ class Huber(pimf.Contrast):
128
+ def __init__(self, delta):
129
+ self.delta = delta
130
+
131
+ def __call__(self, r):
132
+ a = np.abs(r)
133
+ return np.where(a <= self.delta, 0.5 * r**2, self.delta * (a - 0.5 * self.delta))
134
+
135
+ def psi(self, r):
136
+ return np.clip(r, -self.delta, self.delta)
137
+
138
+ def curvature(self):
139
+ return 1.0
140
+
141
+
142
+ result = pimf.imf(y, contrast=Huber(0.3))
143
+ ```
144
+
145
+ Custom kernel — one line:
146
+
147
+ ```python
148
+ class Triangle(pimf.Kernel):
149
+ def profile(self, u):
150
+ return 1.0 - np.abs(u)
151
+
152
+
153
+ result = pimf.imf(y, kernel=Triangle())
154
+ ```
155
+
156
+ ## Numerical guarantees
157
+
158
+ - Exact reconstruction: `imfs.sum(axis=0) + residual` matches the input to
159
+ ~1e-15 (float rounding only).
160
+ - Deterministic: no threading, no hidden state; the same input always gives
161
+ the same output.
162
+ - Research parity (verified by cross-check against the reference notebook on
163
+ the seed-777 example): kernel weights, the erf approximation
164
+ (Abramowitz–Stegun 7.1.26 — deliberately kept instead of SciPy), signal
165
+ generation, and the full robust decomposition are bit-identical; the linear
166
+ path matches the loop-based notebook to ~2e-15 (float summation order — it
167
+ is bit-identical to the vectorized `windows @ weights` notebooks).
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ uv sync # or: pip install -e . && pip install pytest ruff
173
+ python -m pytest
174
+ ruff check . && ruff format --check .
175
+ ```
176
+
177
+ See [AGENTS.md](AGENTS.md) for the project's code style and hard rules.
178
+
179
+ ## License
180
+
181
+ MIT — see [LICENSE](LICENSE).
pimf-0.1.0/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # pimf
2
+
3
+ Intrinsic multiscale filtering (IMF) for one-dimensional signals: a **linear**
4
+ decomposition (local weighted mean) and a **robust** variant that replaces the
5
+ mean with a smooth robust location fit solved by gradient descent.
6
+
7
+ Both are the same algorithm. Starting from `r_1 = y`, each stage smooths the
8
+ current residual with a local M-estimator and passes on what is left:
9
+
10
+ ```
11
+ S_k = argmin_x sum_u w_{t,u} * rho(r_k(u) - x) (per position t)
12
+ r_{k+1} = r_k - S_k
13
+ ```
14
+
15
+ The signal decomposes exactly: `y = S_1 + ... + S_K + r_{K+1}`. The only
16
+ difference between the variants is the contrast `rho`:
17
+
18
+ - **Quadratic** `rho(r) = r^2/2` — closed form, the kernel-weighted local mean
19
+ (the linear IMF).
20
+ - **SmoothAbs** `rho_h(r) = r*erf(r/(sqrt(2)h)) + sqrt(2/pi)*h*exp(-r^2/(2h^2))`
21
+ — a smoothed absolute value with bounded score `psi_h(r) = erf(r/(sqrt(2)h))`,
22
+ solved by clipped gradient descent (the robust IMF). Large contaminated
23
+ observations have bounded influence.
24
+
25
+ The library packages the algorithms validated in the IMF research project
26
+ (`Projects/imf`) and reproduces its numerics — the robust decomposition is
27
+ bit-identical to the reference notebook on the research example.
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ pip install pimf
33
+ ```
34
+
35
+ Requires Python >= 3.10. NumPy is the only dependency.
36
+
37
+ For development, clone the repo and install in editable mode:
38
+
39
+ ```bash
40
+ pip install -e .
41
+ ```
42
+
43
+ ## Quickstart
44
+
45
+ ```python
46
+ import numpy as np
47
+ import pimf
48
+
49
+ t = np.linspace(0.0, 1.0, 1000)
50
+ y = (
51
+ np.sin(2 * np.pi * t)
52
+ + 0.25 * np.sin(12 * np.pi * t)
53
+ + np.random.default_rng(0).normal(0, 0.1, 1000)
54
+ )
55
+
56
+ linear = pimf.linear_imf(y) # quadratic contrast, closed form
57
+ robust = pimf.robust_imf(y, h=0.2) # smooth-abs contrast, h ~ 2 * noise sigma
58
+
59
+ robust.imfs # (K, n) array of components, coarsest first
60
+ robust.residual # (n,) final residual
61
+ robust.stages # per-stage diagnostics (window size, GD iterations, ...)
62
+ robust.reconstruction # imfs.sum(axis=0) + residual == y to ~1e-15
63
+ ```
64
+
65
+ The general entry point is `pimf.imf(y, window_sizes=..., contrast=..., kernel=...,
66
+ boundary=...)`; `linear_imf` and `robust_imf` are one-line wrappers around it.
67
+
68
+ ## Concepts
69
+
70
+ **Window schedule.** `make_window_schedule(n)` generates the per-stage window
71
+ sizes: odd, strictly decreasing, starting at about `n/2` and shrinking
72
+ geometrically by `sqrt(2)` down to a floor of 31. Pass `window_sizes=` to
73
+ override. Windows must be odd.
74
+
75
+ **Kernel.** The window weights come from a kernel profile `k(u)` on `[-1, 1]`.
76
+ The default `SquaredTriangle` uses `k(u) = 0.75 * (1 - |u|)^2` — the square of
77
+ the triangular kernel. (The research project and IMF.pdf call this kernel
78
+ "Epanechnikov"; that is a misnomer — the classical Epanechnikov kernel is
79
+ `(3/4)(1 - u^2)` — so this library names it for what it is.) Endpoint weights
80
+ are exactly zero, and weights are normalized to sum to one.
81
+
82
+ **Contrast.** A contrast supplies the loss `rho` (via `__call__`), its
83
+ derivative `psi` (the score), and `curvature()` — an upper bound on `rho''`
84
+ that sets the stable gradient step `0.95 / curvature()`. A contrast with a
85
+ closed-form minimizer can provide `solve(windows, weights)`, which the driver
86
+ uses instead of gradient descent (that is what makes `Quadratic` the fast
87
+ linear path). For `SmoothAbs(h)`, `h ~ 2 * sigma` of the Gaussian noise is the
88
+ research-validated choice: smaller `h` behaves like a running median, larger
89
+ `h` like the local mean.
90
+
91
+ **Boundary.** `boundary="wrap"` (circular) is the default and the setting the
92
+ linear-operator theory of the research project assumes; it is passed straight
93
+ to `np.pad`, so `"reflect"` and `"edge"` also work.
94
+
95
+ ## Extending
96
+
97
+ Custom contrast — one small class:
98
+
99
+ ```python
100
+ import numpy as np
101
+ import pimf
102
+
103
+
104
+ class Huber(pimf.Contrast):
105
+ def __init__(self, delta):
106
+ self.delta = delta
107
+
108
+ def __call__(self, r):
109
+ a = np.abs(r)
110
+ return np.where(a <= self.delta, 0.5 * r**2, self.delta * (a - 0.5 * self.delta))
111
+
112
+ def psi(self, r):
113
+ return np.clip(r, -self.delta, self.delta)
114
+
115
+ def curvature(self):
116
+ return 1.0
117
+
118
+
119
+ result = pimf.imf(y, contrast=Huber(0.3))
120
+ ```
121
+
122
+ Custom kernel — one line:
123
+
124
+ ```python
125
+ class Triangle(pimf.Kernel):
126
+ def profile(self, u):
127
+ return 1.0 - np.abs(u)
128
+
129
+
130
+ result = pimf.imf(y, kernel=Triangle())
131
+ ```
132
+
133
+ ## Numerical guarantees
134
+
135
+ - Exact reconstruction: `imfs.sum(axis=0) + residual` matches the input to
136
+ ~1e-15 (float rounding only).
137
+ - Deterministic: no threading, no hidden state; the same input always gives
138
+ the same output.
139
+ - Research parity (verified by cross-check against the reference notebook on
140
+ the seed-777 example): kernel weights, the erf approximation
141
+ (Abramowitz–Stegun 7.1.26 — deliberately kept instead of SciPy), signal
142
+ generation, and the full robust decomposition are bit-identical; the linear
143
+ path matches the loop-based notebook to ~2e-15 (float summation order — it
144
+ is bit-identical to the vectorized `windows @ weights` notebooks).
145
+
146
+ ## Development
147
+
148
+ ```bash
149
+ uv sync # or: pip install -e . && pip install pytest ruff
150
+ python -m pytest
151
+ ruff check . && ruff format --check .
152
+ ```
153
+
154
+ See [AGENTS.md](AGENTS.md) for the project's code style and hard rules.
155
+
156
+ ## License
157
+
158
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pimf"
7
+ version = "0.1.0"
8
+ description = "Intrinsic multiscale filtering (IMF) for 1-D signals: linear and robust decompositions."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Mikhail Kuziuk" }]
13
+ keywords = ["signal-processing", "smoothing", "robust-statistics", "m-estimator", "decomposition"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Scientific/Engineering :: Mathematics",
23
+ ]
24
+ dependencies = ["numpy>=1.22"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/mkuziuk/pimf"
28
+ Source = "https://github.com/mkuziuk/pimf"
29
+ Issues = "https://github.com/mkuziuk/pimf/issues"
30
+
31
+ [dependency-groups]
32
+ dev = ["pytest>=8", "ruff"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ exclude = ["AGENTS.md", "CLAUDE.md", "uv.lock", ".gitignore"]
36
+
37
+ [tool.ruff]
38
+ target-version = "py310"
39
+ line-length = 100
40
+
41
+ [tool.ruff.lint]
42
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "NPY"]
43
+
44
+ [tool.pytest.ini_options]
45
+ testpaths = ["tests"]
@@ -0,0 +1,23 @@
1
+ """Intrinsic multiscale filtering (IMF) for 1-D signals."""
2
+
3
+ from .contrasts import Contrast, Quadratic, SmoothAbs
4
+ from .decompose import IMFResult, StageInfo, imf, linear_imf, robust_imf
5
+ from .kernels import Kernel, SquaredTriangle
6
+ from .schedule import make_window_schedule
7
+
8
+ __version__ = "0.1.0"
9
+
10
+ __all__ = [
11
+ "Contrast",
12
+ "IMFResult",
13
+ "Kernel",
14
+ "Quadratic",
15
+ "SmoothAbs",
16
+ "SquaredTriangle",
17
+ "StageInfo",
18
+ "__version__",
19
+ "imf",
20
+ "linear_imf",
21
+ "make_window_schedule",
22
+ "robust_imf",
23
+ ]
@@ -0,0 +1,28 @@
1
+ """Error function approximation shared by the contrast functions.
2
+
3
+ Abramowitz & Stegun formula 7.1.26 (max abs error ~1.5e-7), kept instead of
4
+ scipy.special.erf so results match the IMF research notebooks bit-for-bit.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+ SQRT_2 = np.sqrt(2.0)
10
+ SQRT_2_OVER_PI = np.sqrt(2.0 / np.pi)
11
+
12
+
13
+ def erf_approx(x):
14
+ """Vectorized Abramowitz-Stegun approximation to erf(x)."""
15
+ x = np.asarray(x, dtype=float)
16
+ sign = np.sign(x)
17
+ ax = np.abs(x)
18
+
19
+ p = 0.3275911
20
+ a1 = 0.254829592
21
+ a2 = -0.284496736
22
+ a3 = 1.421413741
23
+ a4 = -1.453152027
24
+ a5 = 1.061405429
25
+
26
+ z = 1.0 / (1.0 + p * ax)
27
+ poly = ((((a5 * z + a4) * z + a3) * z + a2) * z + a1) * z
28
+ return sign * (1.0 - poly * np.exp(-(ax**2)))
@@ -0,0 +1,71 @@
1
+ """Contrast functions for the local location fits.
2
+
3
+ A contrast supplies the loss rho (via __call__), its derivative psi (the
4
+ score), and an upper bound on rho'' (curvature) that sets a stable gradient
5
+ step. A contrast with a closed-form minimizer may also provide
6
+ solve(windows, weights); the decomposition then skips gradient descent.
7
+ """
8
+
9
+ import numpy as np
10
+
11
+ from ._erf import SQRT_2, SQRT_2_OVER_PI, erf_approx
12
+
13
+
14
+ class Contrast:
15
+ """Base contrast. Subclass and implement __call__, psi, and curvature."""
16
+
17
+ def __call__(self, r):
18
+ """Loss rho(r), vectorized."""
19
+ raise NotImplementedError
20
+
21
+ def psi(self, r):
22
+ """Score rho'(r), vectorized; drives the gradient-descent update."""
23
+ raise NotImplementedError
24
+
25
+ def curvature(self):
26
+ """Upper bound on rho''; the gradient step size is 0.95 / curvature()."""
27
+ raise NotImplementedError
28
+
29
+
30
+ class Quadratic(Contrast):
31
+ """rho(r) = r^2 / 2: the local weighted mean, i.e. the linear IMF."""
32
+
33
+ def __call__(self, r):
34
+ return 0.5 * np.asarray(r, dtype=float) ** 2
35
+
36
+ def psi(self, r):
37
+ return np.asarray(r, dtype=float)
38
+
39
+ def curvature(self):
40
+ return 1.0
41
+
42
+ def solve(self, windows, weights):
43
+ """Closed-form minimizer: the weighted mean of each window row."""
44
+ return windows @ weights
45
+
46
+
47
+ class SmoothAbs(Contrast):
48
+ """Smoothed absolute value: |r| convolved with a N(0, h^2) density.
49
+
50
+ rho_h(r) = r * erf(r / (sqrt(2) h)) + sqrt(2 / pi) * h * exp(-r^2 / (2 h^2))
51
+ psi_h(r) = erf(r / (sqrt(2) h)), bounded in [-1, 1].
52
+
53
+ h > 0 controls the transition from quadratic near zero to |r| in the
54
+ tails: smaller h is more median-like, larger h closer to the local mean.
55
+ h ~ 2 * noise sigma is the research-validated default choice.
56
+ """
57
+
58
+ def __init__(self, h):
59
+ if h <= 0:
60
+ raise ValueError("h must be positive")
61
+ self.h = float(h)
62
+
63
+ def __call__(self, r):
64
+ r = np.asarray(r, dtype=float)
65
+ return r * self.psi(r) + SQRT_2_OVER_PI * self.h * np.exp(-0.5 * (r / self.h) ** 2)
66
+
67
+ def psi(self, r):
68
+ return erf_approx(np.asarray(r, dtype=float) / (SQRT_2 * self.h))
69
+
70
+ def curvature(self):
71
+ return SQRT_2_OVER_PI / self.h
@@ -0,0 +1,138 @@
1
+ """Intrinsic multiscale filtering: the decomposition driver."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import numpy as np
6
+ from numpy.lib.stride_tricks import sliding_window_view
7
+
8
+ from .contrasts import Quadratic, SmoothAbs
9
+ from .kernels import SquaredTriangle
10
+ from .schedule import make_window_schedule
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class StageInfo:
15
+ """Per-stage diagnostics; the closed-form path reports iterations=1 and
16
+ final_max_delta=nan."""
17
+
18
+ stage: int
19
+ window_size: int
20
+ iterations: int
21
+ final_max_delta: float
22
+
23
+
24
+ @dataclass
25
+ class IMFResult:
26
+ """Decomposition output; y == imfs.sum(axis=0) + residual up to float rounding."""
27
+
28
+ imfs: np.ndarray
29
+ residual: np.ndarray
30
+ stages: list[StageInfo]
31
+ window_sizes: list[int]
32
+
33
+ @property
34
+ def reconstruction(self):
35
+ return self.imfs.sum(axis=0) + self.residual
36
+
37
+
38
+ def _gd_fit_windows(windows, weights, contrast, max_iter, tol):
39
+ """Minimize sum_u w_u * rho(window_u - x) per row by clipped gradient descent.
40
+
41
+ Port of robust_gd_fit_windows from the research notebooks, generalized to
42
+ any contrast via psi and curvature. Returns (x, iterations, max_delta).
43
+ """
44
+ weights = weights / weights.sum()
45
+ row_weights = weights.reshape(1, -1)
46
+
47
+ x = np.median(windows, axis=1)
48
+ lower = windows.min(axis=1)
49
+ upper = windows.max(axis=1)
50
+
51
+ # The weighted score is Lipschitz with constant curvature() because the
52
+ # weights are normalized, so this step size keeps the iteration stable.
53
+ step = 0.95 / contrast.curvature()
54
+
55
+ iterations = 0
56
+ max_delta = float("nan")
57
+ for _ in range(max_iter):
58
+ local_score = np.sum(row_weights * contrast.psi(windows - x[:, None]), axis=1)
59
+ x_next = np.clip(x + step * local_score, lower, upper)
60
+ max_delta = float(np.max(np.abs(x_next - x)))
61
+ x = x_next
62
+ iterations += 1
63
+ if max_delta <= tol * (1.0 + float(np.max(np.abs(x)))):
64
+ break
65
+
66
+ return x, iterations, max_delta
67
+
68
+
69
+ def _smooth_stage(residual, window_size, kernel, contrast, boundary, max_iter, tol):
70
+ """One smoothing pass: fit the local location at every position."""
71
+ weights = kernel.weights(window_size)
72
+ radius = window_size // 2
73
+ padded = np.pad(residual, pad_width=radius, mode=boundary)
74
+ windows = sliding_window_view(padded, window_size)
75
+
76
+ solve = getattr(contrast, "solve", None)
77
+ if callable(solve):
78
+ return solve(windows, weights), 1, float("nan")
79
+ return _gd_fit_windows(windows, weights, contrast, max_iter, tol)
80
+
81
+
82
+ def imf(y, window_sizes=None, contrast=None, kernel=None, boundary="wrap", max_iter=60, tol=1e-6):
83
+ """Decompose a 1-D signal into multiscale components plus a residual.
84
+
85
+ At each stage the current residual is smoothed by a local M-estimator
86
+ defined by kernel and contrast; the smooth becomes that stage's component
87
+ and the recursion continues on what is left:
88
+ r_1 = y, S_k = smooth(r_k), r_{k+1} = r_k - S_k.
89
+
90
+ Defaults: window_sizes = make_window_schedule(len(y)), contrast =
91
+ Quadratic() (the linear IMF), kernel = SquaredTriangle(). boundary is
92
+ passed to np.pad ("wrap", "reflect", "edge", ...). max_iter and tol apply
93
+ only when the contrast has no closed-form solve.
94
+ """
95
+ y = np.asarray(y, dtype=float)
96
+ if y.ndim != 1:
97
+ raise ValueError("y must be one-dimensional")
98
+ if len(y) == 0:
99
+ raise ValueError("y must not be empty")
100
+
101
+ if window_sizes is None:
102
+ window_sizes = make_window_schedule(len(y))
103
+ window_sizes = [int(size) for size in window_sizes]
104
+ if contrast is None:
105
+ contrast = Quadratic()
106
+ if kernel is None:
107
+ kernel = SquaredTriangle()
108
+
109
+ residual = y.copy()
110
+ imfs = []
111
+ stages = []
112
+ for stage, window_size in enumerate(window_sizes, start=1):
113
+ component, iterations, final_max_delta = _smooth_stage(
114
+ residual, window_size, kernel, contrast, boundary, max_iter, tol
115
+ )
116
+ imfs.append(component)
117
+ residual = residual - component
118
+ stages.append(StageInfo(stage, window_size, iterations, final_max_delta))
119
+
120
+ return IMFResult(np.array(imfs), residual, stages, window_sizes)
121
+
122
+
123
+ def linear_imf(y, window_sizes=None, kernel=None, boundary="wrap"):
124
+ """imf() with the Quadratic contrast: the linear (weighted local mean) IMF."""
125
+ return imf(y, window_sizes=window_sizes, contrast=Quadratic(), kernel=kernel, boundary=boundary)
126
+
127
+
128
+ def robust_imf(y, h, window_sizes=None, kernel=None, boundary="wrap", max_iter=60, tol=1e-6):
129
+ """imf() with the SmoothAbs(h) contrast; h ~ 2 * noise sigma works well."""
130
+ return imf(
131
+ y,
132
+ window_sizes=window_sizes,
133
+ contrast=SmoothAbs(h),
134
+ kernel=kernel,
135
+ boundary=boundary,
136
+ max_iter=max_iter,
137
+ tol=tol,
138
+ )
@@ -0,0 +1,54 @@
1
+ """Kernel window weights for the local fits.
2
+
3
+ A kernel is defined by its profile k(u) on [-1, 1]; the base class turns the
4
+ profile into a normalized, symmetric weight vector for an odd window size.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ class Kernel:
11
+ """Base kernel. Subclass and implement profile(u).
12
+
13
+ profile(u) must be vectorized and nonnegative on [-1, 1]; any constant
14
+ factor cancels under normalization.
15
+ """
16
+
17
+ def profile(self, u):
18
+ """Unnormalized kernel profile k(u) on [-1, 1]."""
19
+ raise NotImplementedError
20
+
21
+ def weights(self, window_size):
22
+ """Normalized weight vector for an odd window size."""
23
+ if window_size % 2 == 0:
24
+ raise ValueError("window_size must be odd")
25
+
26
+ radius = window_size // 2
27
+ if radius == 0:
28
+ return np.array([1.0])
29
+
30
+ offsets = np.arange(-radius, radius + 1)
31
+ u = offsets / radius
32
+ weights = np.asarray(self.profile(u), dtype=float)
33
+ if np.any(weights < 0):
34
+ raise ValueError("kernel profile must be nonnegative")
35
+ total = weights.sum()
36
+ if total <= 0:
37
+ raise ValueError("kernel weights must have positive sum")
38
+ return weights / total
39
+
40
+ __call__ = weights
41
+
42
+
43
+ class SquaredTriangle(Kernel):
44
+ """Squared triangular profile k(u) = 0.75 * (1 - |u|)^2.
45
+
46
+ The default kernel of the IMF research project, where it is called
47
+ "Epanechnikov" — a misnomer: the classical Epanechnikov kernel is
48
+ (3/4)(1 - u^2). The 0.75 factor cancels under normalization and is kept
49
+ for parity with the research code. The endpoint weights (|u| = 1) are
50
+ exactly zero.
51
+ """
52
+
53
+ def profile(self, u):
54
+ return 0.75 * np.maximum(0.0, 1.0 - np.abs(u)) ** 2
@@ -0,0 +1,65 @@
1
+ """Geometric window-size schedule for the IMF decomposition.
2
+
3
+ Faithful port of make_window_schedule from the IMF research notebooks:
4
+ odd, strictly decreasing sizes from about n/2 down to min_window_size,
5
+ shrinking by factor each stage.
6
+ """
7
+
8
+ import numpy as np
9
+
10
+ from ._erf import SQRT_2
11
+
12
+
13
+ def odd_ceiling(value):
14
+ """Smallest odd integer >= ceil(value), at least 1."""
15
+ size = int(np.ceil(value))
16
+ if size % 2 == 0:
17
+ size += 1
18
+ return max(1, size)
19
+
20
+
21
+ def nearest_odd(value):
22
+ """Odd integer nearest to value (ties round down), at least 1."""
23
+ rounded = int(np.round(value))
24
+ if rounded % 2 == 1:
25
+ return max(1, rounded)
26
+
27
+ lower = max(1, rounded - 1)
28
+ upper = rounded + 1
29
+ if abs(value - lower) <= abs(upper - value):
30
+ return lower
31
+ return upper
32
+
33
+
34
+ def make_window_schedule(n, factor=SQRT_2, min_window_size=31):
35
+ """Window sizes for a length-n signal: first = odd_ceiling(n / 2), then
36
+ geometric shrink by factor, all odd, strictly decreasing, floored at
37
+ min_window_size."""
38
+ if n <= 0:
39
+ raise ValueError("n must be positive")
40
+ if factor <= 1:
41
+ raise ValueError("factor must be larger than 1")
42
+
43
+ first = odd_ceiling(n / 2)
44
+ if first > n:
45
+ first = n if n % 2 == 1 else n - 1
46
+
47
+ min_size = nearest_odd(min_window_size)
48
+ if min_size > first:
49
+ return [first]
50
+
51
+ sizes = [first]
52
+ current = first
53
+
54
+ while current > min_size:
55
+ candidate = nearest_odd(current / factor)
56
+ candidate = min(candidate, current - 2)
57
+ if candidate % 2 == 0:
58
+ candidate -= 1
59
+ if candidate < min_size:
60
+ candidate = min_size
61
+
62
+ sizes.append(candidate)
63
+ current = candidate
64
+
65
+ return sizes
@@ -0,0 +1,25 @@
1
+ """Test fixtures ported from the IMF research notebooks.
2
+
3
+ The rng draw order in generate_observation (normal, random, exponential)
4
+ matches the notebooks so seeded observations reproduce bit-for-bit.
5
+ """
6
+
7
+ import numpy as np
8
+
9
+
10
+ def gen_signal(t):
11
+ """Deterministic 4-component test signal from the research notebooks."""
12
+ slow = 0.6 * np.sin(2 * np.pi * t)
13
+ medium = 0.25 * np.sin(12 * np.pi * t)
14
+ bump = 0.8 * np.exp(-((t - 0.55) ** 2) / (2 * 0.015**2))
15
+ trend = 0.5 * (t - 0.5)
16
+ return slow + medium + bump + trend
17
+
18
+
19
+ def generate_observation(x, sigma, contamination_prob, contamination_scale, rng):
20
+ """Gaussian noise plus one-sided exponential contamination."""
21
+ gaussian_noise = rng.normal(loc=0.0, scale=sigma, size=len(x))
22
+ contamination_mask = rng.random(len(x)) < contamination_prob
23
+ exponential_noise = rng.exponential(scale=contamination_scale, size=len(x))
24
+ contamination = contamination_mask * exponential_noise
25
+ return x + gaussian_noise + contamination
@@ -0,0 +1,65 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from pimf import Quadratic, SmoothAbs
5
+ from pimf._erf import SQRT_2_OVER_PI
6
+
7
+
8
+ def test_smooth_abs_requires_positive_h():
9
+ with pytest.raises(ValueError):
10
+ SmoothAbs(0.0)
11
+ with pytest.raises(ValueError):
12
+ SmoothAbs(-1.0)
13
+
14
+
15
+ def test_smooth_abs_score_is_bounded():
16
+ contrast = SmoothAbs(0.4)
17
+ r = np.linspace(-1e6, 1e6, 10001)
18
+ assert np.all(np.abs(contrast.psi(r)) <= 1.0)
19
+
20
+
21
+ def test_smooth_abs_symmetry():
22
+ contrast = SmoothAbs(0.4)
23
+ r = np.linspace(0.0, 10.0, 1001)
24
+ assert np.array_equal(contrast.psi(-r), -contrast.psi(r))
25
+ assert np.allclose(contrast(-r), contrast(r), atol=1e-15)
26
+
27
+
28
+ def test_smooth_abs_at_zero():
29
+ h = 0.7
30
+ assert SmoothAbs(h)(0.0) == SQRT_2_OVER_PI * h
31
+
32
+
33
+ def test_smooth_abs_approaches_abs_in_tails():
34
+ h = 0.4
35
+ r = 50.0 * h
36
+ assert abs(SmoothAbs(h)(r) - r) / r < 1e-6
37
+
38
+
39
+ def test_smooth_abs_curvature():
40
+ h = 0.4
41
+ assert SmoothAbs(h).curvature() == SQRT_2_OVER_PI / h
42
+
43
+
44
+ def test_quadratic():
45
+ contrast = Quadratic()
46
+ r = np.linspace(-3.0, 3.0, 101)
47
+ assert np.array_equal(contrast.psi(r), r)
48
+ assert contrast.curvature() == 1.0
49
+ assert np.array_equal(contrast(r), 0.5 * r**2)
50
+
51
+
52
+ def test_quadratic_solve_is_weighted_mean():
53
+ rng = np.random.default_rng(0)
54
+ windows = rng.normal(size=(7, 5))
55
+ weights = np.array([0.0, 0.25, 0.5, 0.25, 0.0])
56
+ assert np.array_equal(Quadratic().solve(windows, weights), windows @ weights)
57
+
58
+
59
+ @pytest.mark.parametrize("contrast", [Quadratic(), SmoothAbs(0.5)])
60
+ def test_psi_is_derivative_of_rho(contrast):
61
+ # Template consistency check for any contrast implementation.
62
+ r = np.linspace(-3.0, 3.0, 61)
63
+ eps = 1e-6
64
+ finite_difference = (contrast(r + eps) - contrast(r - eps)) / (2 * eps)
65
+ assert np.max(np.abs(finite_difference - contrast.psi(r))) < 1e-4
@@ -0,0 +1,150 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from pimf import Quadratic, imf, linear_imf, robust_imf
5
+ from pimf.decompose import _gd_fit_windows
6
+ from pimf.kernels import SquaredTriangle
7
+
8
+
9
+ class QuadraticViaGD(Quadratic):
10
+ """Quadratic contrast with the closed form disabled, to exercise the GD path."""
11
+
12
+ solve = None
13
+
14
+
15
+ @pytest.fixture
16
+ def noisy_signal():
17
+ rng = np.random.default_rng(42)
18
+ t = np.linspace(0.0, 1.0, 512)
19
+ return np.sin(2 * np.pi * t) + 0.3 * np.sin(14 * np.pi * t) + rng.normal(0.0, 0.2, 512)
20
+
21
+
22
+ def test_reconstruction_linear(noisy_signal):
23
+ result = linear_imf(noisy_signal)
24
+ assert np.max(np.abs(result.reconstruction - noisy_signal)) < 1e-10
25
+
26
+
27
+ def test_reconstruction_robust(noisy_signal):
28
+ result = robust_imf(noisy_signal, h=0.4)
29
+ assert np.max(np.abs(result.reconstruction - noisy_signal)) < 1e-10
30
+
31
+
32
+ def test_shapes_and_stage_info(noisy_signal):
33
+ result = robust_imf(noisy_signal, h=0.4, window_sizes=[151, 75, 31])
34
+ assert result.imfs.shape == (3, len(noisy_signal))
35
+ assert result.residual.shape == (len(noisy_signal),)
36
+ assert result.window_sizes == [151, 75, 31]
37
+ assert np.all(np.isfinite(result.imfs))
38
+ assert np.all(np.isfinite(result.residual))
39
+ for k, info in enumerate(result.stages, start=1):
40
+ assert info.stage == k
41
+ assert 1 <= info.iterations <= 60
42
+ assert np.isfinite(info.final_max_delta)
43
+
44
+
45
+ def test_linear_stage_info(noisy_signal):
46
+ result = linear_imf(noisy_signal, window_sizes=[63, 31])
47
+ for info in result.stages:
48
+ assert info.iterations == 1
49
+ assert np.isnan(info.final_max_delta)
50
+
51
+
52
+ def test_linearity_of_linear_imf():
53
+ rng = np.random.default_rng(7)
54
+ x = rng.normal(size=256)
55
+ y = rng.normal(size=256)
56
+ left = linear_imf(y).imfs - linear_imf(x).imfs
57
+ right = linear_imf(y - x).imfs
58
+ assert np.max(np.abs(left - right)) < 1e-12
59
+
60
+
61
+ def test_gd_quadratic_matches_closed_form(noisy_signal):
62
+ # tol=1e-12: the GD stopping rule leaves ~tol-scale error, so the default
63
+ # 1e-6 would only match the closed form to ~1e-7.
64
+ closed = linear_imf(noisy_signal, window_sizes=[63, 31])
65
+ via_gd = imf(noisy_signal, window_sizes=[63, 31], contrast=QuadraticViaGD(), tol=1e-12)
66
+ assert np.max(np.abs(closed.imfs - via_gd.imfs)) < 1e-9
67
+ assert np.max(np.abs(closed.residual - via_gd.residual)) < 1e-9
68
+
69
+
70
+ def test_robust_fit_symmetric_window_returns_center():
71
+ from pimf import SmoothAbs
72
+
73
+ windows = np.array([[0.0, 1.0, 2.0, 3.0, 4.0]])
74
+ weights = SquaredTriangle().weights(5)
75
+ x, _, _ = _gd_fit_windows(windows, weights, SmoothAbs(0.5), max_iter=60, tol=1e-6)
76
+ assert x[0] == 2.0
77
+
78
+
79
+ def test_stage_one_noise_sd():
80
+ # Exact per-point SD under wrap is sigma * sqrt(sum w^2); audit fixture 0.0240001.
81
+ sigma = 0.4
82
+ weights = SquaredTriangle().weights(501)
83
+ exact_sd = sigma * np.sqrt(np.sum(weights**2))
84
+ assert abs(exact_sd - 0.0240001) < 1e-7
85
+
86
+ rng = np.random.default_rng(123)
87
+ smoothed = []
88
+ for _ in range(50):
89
+ noise = rng.normal(0.0, sigma, 1000)
90
+ smoothed.append(linear_imf(noise, window_sizes=[501]).imfs[0])
91
+ empirical_sd = np.std(np.concatenate(smoothed))
92
+ assert abs(empirical_sd - exact_sd) / exact_sd < 0.1
93
+
94
+
95
+ def test_invalid_inputs_raise():
96
+ y = np.zeros(64)
97
+ with pytest.raises(ValueError):
98
+ imf(y, window_sizes=[10])
99
+ with pytest.raises(ValueError):
100
+ imf(np.zeros((4, 4)))
101
+ with pytest.raises(ValueError):
102
+ imf(np.array([]))
103
+
104
+
105
+ @pytest.mark.parametrize("boundary", ["wrap", "reflect", "edge"])
106
+ def test_boundary_reaches_the_padding(boundary):
107
+ # Reconstruction telescopes for any smoother output, so it cannot detect
108
+ # boundary plumbing bugs. Instead pin the stage-1 edge value against the
109
+ # weighted window computed from independently padded data.
110
+ y = np.linspace(0.0, 1.0, 64) # a ramp: maximally asymmetric at the edges
111
+ component = linear_imf(y, window_sizes=[31], boundary=boundary).imfs[0]
112
+ weights = SquaredTriangle().weights(31)
113
+ padded = np.pad(y, 15, mode=boundary)
114
+ assert component[0] == pytest.approx(weights @ padded[:31], abs=1e-15)
115
+ assert component[-1] == pytest.approx(weights @ padded[-31:], abs=1e-15)
116
+
117
+
118
+ def test_boundaries_differ_at_the_edges():
119
+ y = np.linspace(0.0, 1.0, 64)
120
+ edge_values = {
121
+ boundary: linear_imf(y, window_sizes=[31], boundary=boundary).imfs[0][:5]
122
+ for boundary in ("wrap", "reflect", "edge")
123
+ }
124
+ assert not np.allclose(edge_values["wrap"], edge_values["reflect"], atol=1e-6)
125
+ assert not np.allclose(edge_values["wrap"], edge_values["edge"], atol=1e-6)
126
+ assert not np.allclose(edge_values["reflect"], edge_values["edge"], atol=1e-6)
127
+
128
+
129
+ def test_max_iter_is_respected(noisy_signal):
130
+ result = robust_imf(noisy_signal, h=0.4, window_sizes=[63, 31], max_iter=1)
131
+ for info in result.stages:
132
+ assert info.iterations == 1
133
+
134
+
135
+ def test_custom_kernel_flows_through_imf(noisy_signal):
136
+ from numpy.lib.stride_tricks import sliding_window_view
137
+
138
+ from pimf import Kernel
139
+
140
+ class Uniform(Kernel):
141
+ def profile(self, u):
142
+ return np.ones_like(u)
143
+
144
+ result = linear_imf(noisy_signal, window_sizes=[7], kernel=Uniform())
145
+ weights = Uniform().weights(7)
146
+ expected = sliding_window_view(np.pad(noisy_signal, 3, mode="wrap"), 7) @ weights
147
+ assert np.array_equal(result.imfs[0], expected)
148
+
149
+ default = linear_imf(noisy_signal, window_sizes=[7]).imfs[0]
150
+ assert not np.allclose(result.imfs[0], default, atol=1e-6)
@@ -0,0 +1,25 @@
1
+ import math
2
+
3
+ import numpy as np
4
+
5
+ from pimf._erf import erf_approx
6
+
7
+
8
+ def test_matches_math_erf():
9
+ x = np.linspace(-6.0, 6.0, 4001)
10
+ reference = np.array([math.erf(v) for v in x])
11
+ assert np.max(np.abs(erf_approx(x) - reference)) < 1.5e-7
12
+
13
+
14
+ def test_zero_is_exact():
15
+ assert erf_approx(0.0) == 0.0
16
+
17
+
18
+ def test_odd_symmetry_is_exact():
19
+ x = np.linspace(0.0, 8.0, 1001)
20
+ assert np.array_equal(erf_approx(-x), -erf_approx(x))
21
+
22
+
23
+ def test_saturates_in_tails():
24
+ assert erf_approx(1e6) == 1.0
25
+ assert erf_approx(-1e6) == -1.0
@@ -0,0 +1,69 @@
1
+ import numpy as np
2
+ import pytest
3
+
4
+ from pimf import Kernel, SquaredTriangle
5
+
6
+
7
+ def research_epanechnikov_weights(window_size):
8
+ """Inlined weight computation from the research notebooks (bit-parity target)."""
9
+ radius = window_size // 2
10
+ offsets = np.arange(-radius, radius + 1)
11
+ u = offsets / radius
12
+ weights = 0.75 * np.maximum(0, 1 - np.abs(u)) ** 2
13
+ return weights / weights.sum()
14
+
15
+
16
+ @pytest.mark.parametrize("window_size", [3, 31, 501])
17
+ def test_weight_invariants(window_size):
18
+ weights = SquaredTriangle().weights(window_size)
19
+ assert len(weights) == window_size
20
+ assert np.all(weights >= 0)
21
+ assert np.isclose(weights.sum(), 1.0, atol=1e-15)
22
+ assert weights[0] == 0.0
23
+ assert weights[-1] == 0.0
24
+ assert np.array_equal(weights, weights[::-1])
25
+
26
+
27
+ @pytest.mark.parametrize("window_size", [3, 13, 31, 151, 501])
28
+ def test_bit_parity_with_research_code(window_size):
29
+ assert np.array_equal(
30
+ SquaredTriangle().weights(window_size), research_epanechnikov_weights(window_size)
31
+ )
32
+
33
+
34
+ def test_sum_of_squares_fixture():
35
+ # Validated in the research audit: drives the exact stage-1 noise SD.
36
+ assert abs(np.sum(SquaredTriangle().weights(501) ** 2) - 0.0036000384) < 1e-10
37
+
38
+
39
+ def test_even_window_raises():
40
+ with pytest.raises(ValueError):
41
+ SquaredTriangle().weights(10)
42
+
43
+
44
+ def test_window_of_one():
45
+ assert np.array_equal(SquaredTriangle().weights(1), np.array([1.0]))
46
+
47
+
48
+ def test_kernel_is_callable():
49
+ kernel = SquaredTriangle()
50
+ assert np.array_equal(kernel(31), kernel.weights(31))
51
+
52
+
53
+ def test_custom_kernel_one_liner():
54
+ class Triangle(Kernel):
55
+ def profile(self, u):
56
+ return 1.0 - np.abs(u)
57
+
58
+ weights = Triangle().weights(5)
59
+ assert np.all(weights >= 0)
60
+ assert np.isclose(weights.sum(), 1.0)
61
+
62
+
63
+ def test_negative_profile_raises():
64
+ class Bad(Kernel):
65
+ def profile(self, u):
66
+ return u # negative on [-1, 0)
67
+
68
+ with pytest.raises(ValueError):
69
+ Bad().weights(5)
@@ -0,0 +1,99 @@
1
+ """End-to-end regression against the research example.
2
+
3
+ Setup mirrors experiments/robust-gradient-descent/robust_gradient_descent_imf.ipynb:
4
+ n=1000, sigma=0.1, contamination p=0.2 scale=2.0 (one-sided), seed 777,
5
+ windows [151, 107, 75, 53, 37, 27, 19, 13], robust h = 2 * sigma = 0.2
6
+ (the winner of the notebook's H grid).
7
+
8
+ The metric expectations follow the notebook's recorded results: the robust
9
+ decomposition wins on component-wise error (the contamination it rejects ends
10
+ up in its residual, so a total-MSE-with-residual comparison favors linear —
11
+ by design, not by defect).
12
+ """
13
+
14
+ import numpy as np
15
+ import pytest
16
+ from conftest import gen_signal, generate_observation
17
+
18
+ from pimf import linear_imf, robust_imf
19
+
20
+ WINDOW_SIZES = [151, 107, 75, 53, 37, 27, 19, 13]
21
+ SIGMA = 0.1
22
+ H = 0.2
23
+
24
+
25
+ @pytest.fixture(scope="module")
26
+ def signals():
27
+ t = np.linspace(0.0, 1.0, 1000)
28
+ x_clean = gen_signal(t)
29
+ rng = np.random.default_rng(777)
30
+ y_noisy = generate_observation(
31
+ x_clean, sigma=SIGMA, contamination_prob=0.2, contamination_scale=2.0, rng=rng
32
+ )
33
+ return x_clean, y_noisy
34
+
35
+
36
+ @pytest.fixture(scope="module")
37
+ def decompositions(signals):
38
+ x_clean, y_noisy = signals
39
+ return {
40
+ "linear_noisy": linear_imf(y_noisy, window_sizes=WINDOW_SIZES),
41
+ "linear_clean": linear_imf(x_clean, window_sizes=WINDOW_SIZES),
42
+ "robust_noisy": robust_imf(y_noisy, H, window_sizes=WINDOW_SIZES),
43
+ "robust_clean": robust_imf(x_clean, H, window_sizes=WINDOW_SIZES),
44
+ }
45
+
46
+
47
+ def test_reconstructions(signals, decompositions):
48
+ x_clean, y_noisy = signals
49
+ assert np.max(np.abs(decompositions["linear_noisy"].reconstruction - y_noisy)) < 1e-10
50
+ assert np.max(np.abs(decompositions["robust_noisy"].reconstruction - y_noisy)) < 1e-10
51
+ assert np.max(np.abs(decompositions["robust_clean"].reconstruction - x_clean)) < 1e-10
52
+
53
+
54
+ def test_robust_beats_linear_on_components(decompositions):
55
+ # Noisy-vs-clean error per component, excluding the residual: the robust
56
+ # fit rejects contamination into its residual, the linear fit spreads it
57
+ # over the components. Cross-checked: robust ~0.0014 vs linear ~0.023.
58
+ def component_mse(noisy, clean):
59
+ return float(np.mean((noisy.imfs - clean.imfs) ** 2))
60
+
61
+ robust = component_mse(decompositions["robust_noisy"], decompositions["robust_clean"])
62
+ linear = component_mse(decompositions["linear_noisy"], decompositions["linear_clean"])
63
+ assert robust < linear
64
+
65
+
66
+ def test_robust_beats_linear_on_mean_mae(decompositions):
67
+ # Mean MAE over components plus residual — the aggregate where the
68
+ # notebook's benchmark records the robust win (0.058 vs 0.128 here).
69
+ def mean_mae(noisy, clean):
70
+ rows = [np.mean(np.abs(a - b)) for a, b in zip(noisy.imfs, clean.imfs, strict=True)]
71
+ rows.append(np.mean(np.abs(noisy.residual - clean.residual)))
72
+ return float(np.mean(rows))
73
+
74
+ robust = mean_mae(decompositions["robust_noisy"], decompositions["robust_clean"])
75
+ linear = mean_mae(decompositions["linear_noisy"], decompositions["linear_clean"])
76
+ assert robust < linear
77
+
78
+
79
+ def test_golden_spot_values(decompositions):
80
+ # Frozen from the implementation after it was verified bit-identical to
81
+ # the reference notebook on this exact case (cross-check script).
82
+ result = decompositions["robust_noisy"]
83
+ golden_imfs = {
84
+ (0, 0): 0.07303580151881464,
85
+ (0, 500): 0.11423344217234899,
86
+ (3, 250): -0.011978870909632695,
87
+ (5, 777): 0.034920007932709995,
88
+ (7, 999): 0.08526826398194841,
89
+ }
90
+ for (stage, index), value in golden_imfs.items():
91
+ assert abs(result.imfs[stage, index] - value) < 1e-12
92
+ assert abs(result.residual[123] - (-0.031524706119654425)) < 1e-12
93
+
94
+
95
+ def test_stage_iterations_match_reference(decompositions):
96
+ # Cross-checked against the reference implementation (bit-identical run):
97
+ # late stages legitimately hit the max_iter cap on this contaminated case.
98
+ iterations = [info.iterations for info in decompositions["robust_noisy"].stages]
99
+ assert iterations == [39, 18, 18, 19, 24, 30, 60, 60]
@@ -0,0 +1,35 @@
1
+ import pytest
2
+
3
+ from pimf import make_window_schedule
4
+
5
+
6
+ @pytest.mark.parametrize(
7
+ ("n", "expected"),
8
+ [
9
+ (200, [101, 71, 51, 37, 31]),
10
+ (500, [251, 177, 125, 89, 63, 45, 31]),
11
+ (1000, [501, 355, 251, 177, 125, 89, 63, 45, 31]),
12
+ (2000, [1001, 707, 499, 353, 249, 177, 125, 89, 63, 45, 31]),
13
+ ],
14
+ )
15
+ def test_research_fixtures(n, expected):
16
+ assert make_window_schedule(n) == expected
17
+
18
+
19
+ @pytest.mark.parametrize("n", [50, 200, 777, 1000, 4096])
20
+ def test_invariants(n):
21
+ sizes = make_window_schedule(n)
22
+ assert all(size % 2 == 1 for size in sizes)
23
+ assert all(a > b for a, b in zip(sizes, sizes[1:], strict=False))
24
+ assert sizes[0] <= n
25
+
26
+
27
+ def test_tiny_signal_returns_single_window():
28
+ assert make_window_schedule(10) == [5]
29
+
30
+
31
+ def test_invalid_arguments_raise():
32
+ with pytest.raises(ValueError):
33
+ make_window_schedule(0)
34
+ with pytest.raises(ValueError):
35
+ make_window_schedule(1000, factor=1.0)