pimf 0.1.0__py3-none-any.whl
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/__init__.py +23 -0
- pimf/_erf.py +28 -0
- pimf/contrasts.py +71 -0
- pimf/decompose.py +138 -0
- pimf/kernels.py +54 -0
- pimf/schedule.py +65 -0
- pimf-0.1.0.dist-info/METADATA +181 -0
- pimf-0.1.0.dist-info/RECORD +10 -0
- pimf-0.1.0.dist-info/WHEEL +4 -0
- pimf-0.1.0.dist-info/licenses/LICENSE +21 -0
pimf/__init__.py
ADDED
|
@@ -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
|
+
]
|
pimf/_erf.py
ADDED
|
@@ -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)))
|
pimf/contrasts.py
ADDED
|
@@ -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
|
pimf/decompose.py
ADDED
|
@@ -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
|
+
)
|
pimf/kernels.py
ADDED
|
@@ -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
|
pimf/schedule.py
ADDED
|
@@ -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,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).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pimf/__init__.py,sha256=PoaP3c__2ImyPZrMbtiiTOewLsmsUmlRNY8GT78Np38,529
|
|
2
|
+
pimf/_erf.py,sha256=Af-8ML2eUHUpgHFtXIwB1r6Uui5ySyU9XM3EgRerRl4,732
|
|
3
|
+
pimf/contrasts.py,sha256=AItiF3W8m_MaIqI5Iv4LrYuGV3sLJjZJR2Xex3XdCQ4,2239
|
|
4
|
+
pimf/decompose.py,sha256=D1N3ugJo9EseKSou5cIs7PAgV2xBZLSj4WK5OeWFi5Y,4781
|
|
5
|
+
pimf/kernels.py,sha256=CTz5DMOh2Fif6hjakWFZnDJuVoifDSQxfuan59C-j_c,1736
|
|
6
|
+
pimf/schedule.py,sha256=AbKnXFwr09pmS9celGBY-vZu94Zv0m2tVydzrdFq4Sk,1734
|
|
7
|
+
pimf-0.1.0.dist-info/METADATA,sha256=ZqQY5GeeCLstSOb479-M5uTpTCrFgitdlxjHAWhbZDU,6220
|
|
8
|
+
pimf-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
9
|
+
pimf-0.1.0.dist-info/licenses/LICENSE,sha256=rS68B5Bud03naAkRCKHRRuCiwtzQt0ydaeSxpvDKlIY,1071
|
|
10
|
+
pimf-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|