Skip to content

Commit 2166c43

Browse files
Add monotonic cubic spline (PCHIP) interpolation (#335)
Adds Scholar.Interpolation.MonotonicCubicSpline, a piecewise cubic Hermite interpolant whose knot derivatives are chosen with the Fritsch-Carlson method so the curve stays monotonic where the data is monotonic and does not overshoot around local extrema (equivalent to scipy.interpolate.PchipInterpolator). The power-basis coefficient layout and predict/3 are shared with CubicSpline, so only the derivative computation differs. Reference values in the tests were taken from SciPy. Closes #322
1 parent 1742ef3 commit 2166c43

3 files changed

Lines changed: 431 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
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

mix.exs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ defmodule Scholar.MixProject do
7777
Scholar.Interpolation.BezierSpline,
7878
Scholar.Interpolation.CubicSpline,
7979
Scholar.Interpolation.Linear,
80+
Scholar.Interpolation.MonotonicCubicSpline,
8081
Scholar.Linear.BayesianRidgeRegression,
8182
Scholar.Linear.IsotonicRegression,
8283
Scholar.Linear.LinearRegression,

0 commit comments

Comments
 (0)