|
| 1 | +defmodule Scholar.Interpolation.MonotonicCubicSpline do |
| 2 | + @moduledoc """ |
| 3 | + Monotonic cubic spline interpolation (also known as PCHIP, from |
| 4 | + *Piecewise Cubic Hermite Interpolating Polynomial*). |
| 5 | +
|
| 6 | + Like `Scholar.Interpolation.CubicSpline`, this fits a piecewise cubic |
| 7 | + polynomial to the given points. The derivatives at each point, however, |
| 8 | + are chosen with the Fritsch-Carlson method so that the resulting curve is |
| 9 | + monotonic wherever the data is monotonic and has no overshoot around local |
| 10 | + extrema. In exchange, only the first derivative of the curve is guaranteed |
| 11 | + to be continuous (the second one is not). |
| 12 | +
|
| 13 | + This is a good choice when the shape of the data matters more than its |
| 14 | + smoothness, for example to avoid interpolated values that fall outside the |
| 15 | + range of the surrounding samples. |
| 16 | +
|
| 17 | + Monotonic cubic spline interpolation is $O(N)$ where $N$ is the number of points. |
| 18 | +
|
| 19 | + References: |
| 20 | +
|
| 21 | + * [1] - [Monotone cubic interpolation](https://en.wikipedia.org/wiki/Monotone_cubic_interpolation) |
| 22 | + * [2] - [Fritsch, F. N.; Carlson, R. E. (1980). "Monotone Piecewise Cubic Interpolation"](https://doi.org/10.1137/0717021) |
| 23 | + * [3] - [SciPy implementation (PchipInterpolator)](https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.PchipInterpolator.html) |
| 24 | + """ |
| 25 | + import Nx.Defn |
| 26 | + import Scholar.Shared |
| 27 | + |
| 28 | + @derive {Nx.Container, containers: [:coefficients, :x]} |
| 29 | + defstruct [:coefficients, :x] |
| 30 | + |
| 31 | + @doc """ |
| 32 | + Fits a monotonic cubic spline interpolation of the given `(x, y)` points. |
| 33 | +
|
| 34 | + Inputs are expected to be rank-1 tensors with the same shape |
| 35 | + and at least 2 entries. |
| 36 | +
|
| 37 | + ## Examples |
| 38 | +
|
| 39 | + iex> x = Nx.iota({3}) |
| 40 | + iex> y = Nx.tensor([0.0, 1.0, 0.0]) |
| 41 | + iex> Scholar.Interpolation.MonotonicCubicSpline.fit(x, y) |
| 42 | + %Scholar.Interpolation.MonotonicCubicSpline{ |
| 43 | + coefficients: Nx.tensor( |
| 44 | + [ |
| 45 | + [0.0, -1.0, 2.0, 0.0], |
| 46 | + [0.0, -1.0, 0.0, 1.0] |
| 47 | + ] |
| 48 | + ), |
| 49 | + x: Nx.tensor( |
| 50 | + [0, 1, 2] |
| 51 | + ) |
| 52 | + } |
| 53 | + """ |
| 54 | + deftransform fit(x, y) do |
| 55 | + fit_n(x, y) |
| 56 | + end |
| 57 | + |
| 58 | + defnp fit_n(x, y) do |
| 59 | + # https://en.wikipedia.org/wiki/Monotone_cubic_interpolation |
| 60 | + # Reference implementation in SciPy (PchipInterpolator) |
| 61 | + |
| 62 | + n = |
| 63 | + case Nx.shape(x) do |
| 64 | + {n} when n > 1 -> |
| 65 | + n |
| 66 | + |
| 67 | + shape -> |
| 68 | + raise ArgumentError, |
| 69 | + "expected x to be a tensor with shape {n}, where n > 1, got: #{inspect(shape)}" |
| 70 | + end |
| 71 | + |
| 72 | + case {Nx.shape(y), Nx.shape(x)} do |
| 73 | + {shape, shape} -> |
| 74 | + :ok |
| 75 | + |
| 76 | + {y_shape, x_shape} -> |
| 77 | + raise ArgumentError, |
| 78 | + "expected y to have shape #{inspect(x_shape)}, got: #{inspect(y_shape)}" |
| 79 | + end |
| 80 | + |
| 81 | + sort_idx = Nx.argsort(x) |
| 82 | + x = Nx.take(x, sort_idx) |
| 83 | + y = Nx.take(y, sort_idx) |
| 84 | + |
| 85 | + dx = Nx.diff(x) |
| 86 | + slope = Nx.diff(y) / dx |
| 87 | + |
| 88 | + s = find_derivatives(dx, slope, n) |
| 89 | + |
| 90 | + # convert to power-basis coefficients, as in `Scholar.Interpolation.CubicSpline` |
| 91 | + t = (s[0..-2//1] + s[1..-1//1] - 2 * slope) / dx |
| 92 | + |
| 93 | + c_3 = t / dx |
| 94 | + c_2 = (slope - s[0..-2//1]) / dx - t |
| 95 | + c_1 = s[0..-2//1] |
| 96 | + c_0 = y[0..-2//1] |
| 97 | + |
| 98 | + c = Nx.stack([c_3, c_2, c_1, c_0], axis: 1) |
| 99 | + |
| 100 | + %__MODULE__{coefficients: c, x: x} |
| 101 | + end |
| 102 | + |
| 103 | + defnp find_derivatives(dx, slope, n) do |
| 104 | + case n do |
| 105 | + 2 -> |
| 106 | + # a single interval is linear: both derivatives equal the secant slope |
| 107 | + Nx.broadcast(slope[0], {2}) |
| 108 | + |
| 109 | + _ -> |
| 110 | + sign = Nx.sign(slope) |
| 111 | + slope_left = slope[0..-2//1] |
| 112 | + slope_right = slope[1..-1//1] |
| 113 | + |
| 114 | + # zero the derivative where the data is not locally monotonic |
| 115 | + # (secant slopes change sign or one is zero) to avoid overshoot |
| 116 | + non_monotonic? = |
| 117 | + sign[1..-1//1] != sign[0..-2//1] or slope_right == 0 or slope_left == 0 |
| 118 | + |
| 119 | + dx_left = dx[0..-2//1] |
| 120 | + dx_right = dx[1..-1//1] |
| 121 | + |
| 122 | + w1 = 2 * dx_right + dx_left |
| 123 | + w2 = dx_right + 2 * dx_left |
| 124 | + |
| 125 | + # avoid dividing by a zero slope: some backends raise on `x / 0` |
| 126 | + # instead of returning infinity, so masking afterwards is not enough |
| 127 | + safe_slope_left = Nx.select(slope_left == 0, 1.0, slope_left) |
| 128 | + safe_slope_right = Nx.select(slope_right == 0, 1.0, slope_right) |
| 129 | + |
| 130 | + weighted_harmonic_mean = (w1 / safe_slope_left + w2 / safe_slope_right) / (w1 + w2) |
| 131 | + safe_weighted_harmonic_mean = Nx.select(non_monotonic?, 1.0, weighted_harmonic_mean) |
| 132 | + |
| 133 | + interior = Nx.select(non_monotonic?, 0.0, 1.0 / safe_weighted_harmonic_mean) |
| 134 | + |
| 135 | + first = edge_derivative(dx[0], dx[1], slope[0], slope[1]) |
| 136 | + last = edge_derivative(dx[-1], dx[-2], slope[-1], slope[-2]) |
| 137 | + |
| 138 | + Nx.concatenate([Nx.new_axis(first, 0), interior, Nx.new_axis(last, 0)]) |
| 139 | + end |
| 140 | + end |
| 141 | + |
| 142 | + # one-sided three-point estimate at the boundary, capped to avoid overshoot |
| 143 | + defnp edge_derivative(h0, h1, m0, m1) do |
| 144 | + d = ((2 * h0 + h1) * m0 - h0 * m1) / (h0 + h1) |
| 145 | + |
| 146 | + opposite_sign? = Nx.sign(d) != Nx.sign(m0) |
| 147 | + overshoot? = Nx.sign(m0) != Nx.sign(m1) and Nx.abs(d) > 3 * Nx.abs(m0) |
| 148 | + |
| 149 | + d = Nx.select(opposite_sign?, 0.0, d) |
| 150 | + Nx.select(not opposite_sign? and overshoot?, 3 * m0, d) |
| 151 | + end |
| 152 | + |
| 153 | + predict_opts = [ |
| 154 | + extrapolate: [ |
| 155 | + required: false, |
| 156 | + default: true, |
| 157 | + type: :boolean, |
| 158 | + doc: "if false, out-of-bounds x return NaN." |
| 159 | + ] |
| 160 | + ] |
| 161 | + |
| 162 | + @predict_opts_schema NimbleOptions.new!(predict_opts) |
| 163 | + |
| 164 | + @doc """ |
| 165 | + Returns the value fit by `fit/2` corresponding to the `target_x` input. |
| 166 | +
|
| 167 | + ### Options |
| 168 | +
|
| 169 | + #{NimbleOptions.docs(@predict_opts_schema)} |
| 170 | +
|
| 171 | + ## Examples |
| 172 | +
|
| 173 | + iex> x = Nx.iota({3}) |
| 174 | + iex> y = Nx.tensor([0.0, 1.0, 0.0]) |
| 175 | + iex> model = Scholar.Interpolation.MonotonicCubicSpline.fit(x, y) |
| 176 | + iex> Scholar.Interpolation.MonotonicCubicSpline.predict(model, Nx.tensor([0.5, 1.5])) |
| 177 | + Nx.tensor( |
| 178 | + [0.75, 0.75] |
| 179 | + ) |
| 180 | + """ |
| 181 | + deftransform predict(%__MODULE__{} = model, target_x, opts \\ []) do |
| 182 | + predict_n(model, target_x, NimbleOptions.validate!(opts, @predict_opts_schema)) |
| 183 | + end |
| 184 | + |
| 185 | + defnp predict_n(%__MODULE__{x: x, coefficients: coefficients}, target_x, opts) do |
| 186 | + original_shape = Nx.shape(target_x) |
| 187 | + target_x = Nx.flatten(target_x) |
| 188 | + |
| 189 | + idx_selector = Nx.new_axis(target_x, 1) > Nx.new_axis(x, 0) |
| 190 | + |
| 191 | + idx_poly = |
| 192 | + idx_selector |
| 193 | + |> Nx.argmax(axis: 1, tie_break: :high) |
| 194 | + |> Nx.min(Nx.size(x) - 2) |
| 195 | + |
| 196 | + # deal with the case where no valid index is found |
| 197 | + # means that we're in the first interval |
| 198 | + # _poly suffix because we're selecting a specific polynomial |
| 199 | + # for each target_x value |
| 200 | + idx_poly = |
| 201 | + Nx.all(idx_selector == 0, axes: [1]) |
| 202 | + |> Nx.select(0, idx_poly) |
| 203 | + |
| 204 | + coef_poly = Nx.take(coefficients, idx_poly) |
| 205 | + |
| 206 | + # each polynomial is calculated as if the origin was moved to the |
| 207 | + # x value that represents the start of the interval |
| 208 | + x_poly = target_x - Nx.take(x, idx_poly) |
| 209 | + |
| 210 | + result = |
| 211 | + x_poly |
| 212 | + |> Nx.new_axis(1) |
| 213 | + |> Nx.pow(Nx.tensor([3, 2, 1, 0])) |
| 214 | + |> Nx.dot([1], [0], coef_poly, [1], [0]) |
| 215 | + |
| 216 | + result = |
| 217 | + if opts[:extrapolate] do |
| 218 | + result |
| 219 | + else |
| 220 | + nan_selector = target_x < x[0] or target_x > x[-1] |
| 221 | + |
| 222 | + nan = Nx.tensor(:nan, type: to_float_type(target_x)) |
| 223 | + Nx.select(nan_selector, nan, result) |
| 224 | + end |
| 225 | + |
| 226 | + Nx.reshape(result, original_shape) |
| 227 | + end |
| 228 | +end |
0 commit comments