|
| 1 | +""" |
| 2 | +The :mod:chemotools.smooth._modified_sinc_smoother module implements the Modified Sinc Filter (SGF) transformation. |
| 3 | +""" |
| 4 | + |
| 5 | +# Authors: Nusret Emirhan Salli <nusret.emirhan.salli@gmail.com> |
| 6 | +# License: MIT |
| 7 | + |
| 8 | +from __future__ import annotations |
| 9 | +from typing import Literal |
| 10 | +import numpy as np |
| 11 | + |
| 12 | +from ._base import _BaseFIRFilter |
| 13 | + |
| 14 | + |
| 15 | +class ModifiedSincFilter(_BaseFIRFilter): |
| 16 | + """ |
| 17 | + Modified Sinc smoothing (MS) for denoising while preserving peak positions based on the paper "Why and How Savitzky–Golay Filters Should Be Replaced." |
| 18 | +
|
| 19 | + The Modified Sinc smoother is a linear-phase FIR filter: the signal is |
| 20 | + convolved with a fixed, symmetric kernel. The kernel is built from: |
| 21 | + 1) a sinc core with argument ((n + 4) / 2) * pi * x (Eq. 3, p. 187), |
| 22 | + 2) a special Gaussian-like window w(x) whose value and slope vanish at |
| 23 | + the window ends (Eq. 4, p. 187), and |
| 24 | + 3) small optional correction terms that flatten the passband so low |
| 25 | + frequencies are almost unattenuated (Eqs. 7–8 and Table 1, pp. 187–188). |
| 26 | +
|
| 27 | + Parameters |
| 28 | + ---------- |
| 29 | + window_size : int, default=21 |
| 30 | + Odd number of taps (2*m + 1). Larger values give stronger smoothing. |
| 31 | +
|
| 32 | + n : int, default=6 |
| 33 | + Even integer >= 2. Controls how many inner zeros the sinc has within |
| 34 | + the window. The paper discusses n = 2, 4, 6, 8, 10 (Fig. 2). |
| 35 | +
|
| 36 | + alpha : float, default=4.0 |
| 37 | + Positive window width parameter. Larger alpha reduces side lobes more |
| 38 | + aggressively (steeper roll-off). |
| 39 | +
|
| 40 | + use_corrections : bool, default=True |
| 41 | + If True and n in {6, 8, 10} and the window is large enough, add the |
| 42 | + small passband-flattening terms from Eqs. 7–8 (coefficients from Table 1). |
| 43 | +
|
| 44 | + mode : {"mirror", "constant", "nearest", "wrap", "interp"}, default="interp" |
| 45 | + Boundary strategy, passed to the base FIR class. "interp" performs |
| 46 | + linear extrapolation (recommended in the paper). |
| 47 | +
|
| 48 | + axis : int, default=1 |
| 49 | + Axis along which to smooth for 2D inputs (rows x features). Use 1 to |
| 50 | + smooth within each row. |
| 51 | +
|
| 52 | + Methods |
| 53 | + ------- |
| 54 | + fit(X, y=None) |
| 55 | + Inherited from the base class. Validates input and builds the kernel. |
| 56 | +
|
| 57 | + transform(X, y=None) |
| 58 | + Inherited from the base class. Pads, convolves, and returns the smoothed data. |
| 59 | +
|
| 60 | + References |
| 61 | + ---------- |
| 62 | + [1] Schmid, M.; Rath, D.; Diebold, U. "Why and How Savitzky–Golay Filters Should Be Replaced." |
| 63 | + ACS measurement science Au 2022, 2 (2), 185-196. |
| 64 | +
|
| 65 | + Examples |
| 66 | + -------- |
| 67 | + >>> from chemotools.smooth import ModifiedSincFilter |
| 68 | + >>> import numpy as np |
| 69 | + >>> X = np.array([[0.0, 1.0, 2.0, 1.0, 0.0]], dtype=float) |
| 70 | + >>> ms = ModifiedSincFilter(window_size= 9, n=6, alpha=4.0, mode="interp") |
| 71 | + >>> X_smooth = ms.fit_transform(X) |
| 72 | + """ |
| 73 | + |
| 74 | + def __init__( |
| 75 | + self, |
| 76 | + window_size: int = 21, |
| 77 | + n: int = 6, |
| 78 | + alpha: float = 4.0, |
| 79 | + use_corrections: bool = True, |
| 80 | + mode: Literal["mirror", "constant", "nearest", "wrap", "interp"] = "interp", |
| 81 | + axis: int = 1, |
| 82 | + ) -> None: |
| 83 | + super().__init__(window_size=window_size, mode=mode, axis=axis) |
| 84 | + self.n = n |
| 85 | + self.alpha = alpha |
| 86 | + self.use_corrections = use_corrections |
| 87 | + |
| 88 | + def _compute_kernel(self) -> np.ndarray: |
| 89 | + """ |
| 90 | + Build the Modified Sinc kernel h[i] for i = -m ... m. |
| 91 | +
|
| 92 | + Implementation map to the paper: |
| 93 | + 1) Map index to x = i / (m + 1) (Eq. 5). |
| 94 | + 2) Base sinc: np.sinc(0.5 * (n + 4) * x) (Eq. 3). |
| 95 | + 3) Solve a 3x3 system for the window coefficients so that |
| 96 | + w(0)=1, w(1)=0, w'(1)=0; then we form w(x) (Eq. 4). |
| 97 | + 4) h = sinc * w. |
| 98 | + 5) Optional corrections from based on Eqs. 7–8 (Table 1). |
| 99 | + 6) Symmetrize and normalize so sum(h)=1 (Eq. 6). |
| 100 | + """ |
| 101 | + |
| 102 | + # ---- parameter checks ---- |
| 103 | + if self.n % 2 != 0 or self.n < 2: |
| 104 | + raise ValueError("n must be an even integer >= 2.") |
| 105 | + if self.alpha <= 0: |
| 106 | + raise ValueError("alpha must be positive.") |
| 107 | + |
| 108 | + # ---- 1) Eq. 5: index -> normalized coordinate in [-1, 1] ---- |
| 109 | + m = (self.window_size - 1) // 2 |
| 110 | + i = np.arange(-m, m + 1, dtype=np.float64) |
| 111 | + x = i / (m + 1) if m >= 0 else np.array([0.0]) |
| 112 | + |
| 113 | + # ---- 2) Eq. 3: modified sinc core (note that numpy's sinc uses sin(pi*u)/(pi*u)) ---- |
| 114 | + core = np.sinc(0.5 * (self.n + 4) * x) |
| 115 | + |
| 116 | + # ---- 3) Eq. 4: window with w(0)=1, w(1)=0, w'(1)=0 ---- |
| 117 | + # Precompute the exponentials appearing in those constraints. |
| 118 | + E1 = np.exp( |
| 119 | + -self.alpha * 1.0 |
| 120 | + ) # exp(-alpha * 1^2) at x = 1 (central Gaussian) |
| 121 | + Ep = np.exp(-self.alpha * 1.0) # exp(-alpha * (1-2)^2) = exp(-alpha) for (x-2) |
| 122 | + Em = np.exp( |
| 123 | + -self.alpha * 9.0 |
| 124 | + ) # exp(-alpha * (1+2)^2) = exp(-9*alpha) for (x+2) |
| 125 | + e4 = np.exp(-self.alpha * 4.0) # exp(-alpha * (±2)^2) at x = 0 |
| 126 | + |
| 127 | + # Linear system rows correspond to: w(0)=1, w(1)=0, w'(1)=0 |
| 128 | + M = np.array( |
| 129 | + [ |
| 130 | + [1.0, 2.0 * e4, 1.0], |
| 131 | + [E1, (Ep + Em), 1.0], |
| 132 | + [-2 * self.alpha * E1, 2 * self.alpha * (Ep - 3 * Em), 0.0], |
| 133 | + ], |
| 134 | + dtype=np.float64, |
| 135 | + ) |
| 136 | + # solve the system using linear algebra. |
| 137 | + rhs = np.array([1.0, 0.0, 0.0], dtype=np.float64) |
| 138 | + |
| 139 | + Acoef, Bcoef, Ccoef = np.linalg.solve(M, rhs) |
| 140 | + |
| 141 | + # Window values calculated |
| 142 | + window = ( |
| 143 | + Acoef * np.exp(-self.alpha * x**2) |
| 144 | + + Bcoef |
| 145 | + * ( |
| 146 | + np.exp(-self.alpha * (x - 2.0) ** 2) |
| 147 | + + np.exp(-self.alpha * (x + 2.0) ** 2) |
| 148 | + ) |
| 149 | + + Ccoef |
| 150 | + ) |
| 151 | + |
| 152 | + # ---- 4) base kernel = windowed sinc ---- |
| 153 | + h = core * window |
| 154 | + |
| 155 | + # ---- 5) Eqs. 7–8 + Table 1: optional passband-flattening corrections ---- |
| 156 | + if ( |
| 157 | + self.use_corrections |
| 158 | + and self._has_kappa_table(self.n) |
| 159 | + and (m >= self.n // 2 + 2) |
| 160 | + ): |
| 161 | + # nu = 1 for n/2 odd (n=6,10); nu = 2 for n=8 (Eq. 7) |
| 162 | + nu = 1 if ((self.n // 2) % 2 == 1) else 2 |
| 163 | + kappas = self._kappa_coeffs( |
| 164 | + self.n, m |
| 165 | + ) # kappa = a + b / (c - m)^3 (Eq. 8; Table 1) |
| 166 | + corr = 0.0 |
| 167 | + # add the correction term according to Eq. 7 |
| 168 | + for j, kappa in enumerate(kappas): |
| 169 | + corr += kappa * window * x * np.sin((2 * j + nu) * np.pi * x) |
| 170 | + h = h + corr |
| 171 | + |
| 172 | + # ---- 6) Eq. 6: Enforce symmetry and Direct Current = 1 ---- |
| 173 | + h = 0.5 * (h + h[::-1]) # make it symmetric |
| 174 | + s = h.sum() |
| 175 | + if not np.isfinite(s) or abs(s) < 1e-15: |
| 176 | + raise FloatingPointError( |
| 177 | + "Kernel normalization failed; try different parameters." |
| 178 | + ) |
| 179 | + h = h / s # Normalize so sum = 1 |
| 180 | + return h |
| 181 | + |
| 182 | + # ====== Table 1 (p. 188): kappa fit coefficients for Eq. 8 ====== |
| 183 | + @staticmethod |
| 184 | + def _has_kappa_table(n: int) -> bool: |
| 185 | + # The paper provides corrections for n = 6, 8, 10 |
| 186 | + return n in (6, 8, 10) |
| 187 | + |
| 188 | + @staticmethod |
| 189 | + def _kappa_coeffs(n: int, m: int) -> np.ndarray: |
| 190 | + """ |
| 191 | + Return [kappa_0] for n=6; [kappa_0, kappa_1] for n=8 or 10. |
| 192 | + Formula: kappa_j^(n)(m) = a_j^(n) + b_j^(n) / (c_j^(n) - m)^3 |
| 193 | + (Eq. 8, p. 188; coefficients from Table 1). |
| 194 | + """ |
| 195 | + if n == 6: |
| 196 | + ABC = [(0.00172, 0.02437, 1.64375)] |
| 197 | + elif n == 8: |
| 198 | + ABC = [(0.00440, 0.08821, 2.35938), (0.00615, 0.02472, 3.63594)] |
| 199 | + elif n == 10: |
| 200 | + ABC = [(0.00118, 0.04219, 2.74688), (0.00367, 0.12780, 2.77031)] |
| 201 | + else: |
| 202 | + return np.zeros(0, dtype=np.float64) |
| 203 | + return np.asarray([a + b / ((c - m) ** 3) for a, b, c in ABC], dtype=np.float64) |
0 commit comments