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.
- pimf-0.1.0/.github/workflows/release.yml +35 -0
- pimf-0.1.0/.gitignore +9 -0
- pimf-0.1.0/LICENSE +21 -0
- pimf-0.1.0/PKG-INFO +181 -0
- pimf-0.1.0/README.md +158 -0
- pimf-0.1.0/pyproject.toml +45 -0
- pimf-0.1.0/src/pimf/__init__.py +23 -0
- pimf-0.1.0/src/pimf/_erf.py +28 -0
- pimf-0.1.0/src/pimf/contrasts.py +71 -0
- pimf-0.1.0/src/pimf/decompose.py +138 -0
- pimf-0.1.0/src/pimf/kernels.py +54 -0
- pimf-0.1.0/src/pimf/schedule.py +65 -0
- pimf-0.1.0/tests/conftest.py +25 -0
- pimf-0.1.0/tests/test_contrasts.py +65 -0
- pimf-0.1.0/tests/test_decompose.py +150 -0
- pimf-0.1.0/tests/test_erf.py +25 -0
- pimf-0.1.0/tests/test_kernels.py +69 -0
- pimf-0.1.0/tests/test_regression.py +99 -0
- pimf-0.1.0/tests/test_schedule.py +35 -0
|
@@ -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
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)
|