Советы и рекомендации по экстраполяции#

Обработка экстраполяции — вычисление интерполяторов в точках запроса вне области интерполированных данных — не полностью согласована между различными процедурами в scipy.interpolate. Разные интерполяторы используют разные наборы ключевых аргументов для управления поведением вне области данных: некоторые используют extrapolate=True/False/None, некоторые позволяют fill_value ключевое слово. Обратитесь к документации API для подробностей по каждой конкретной интерполяционной процедуре.

В зависимости от конкретной задачи доступные ключевые слова могут быть достаточными или нет. Особое внимание следует уделить экстраполяции нелинейных интерполянтов. Очень часто экстраполированные результаты становятся менее осмысленными с увеличением расстояния от области данных. Это, конечно, ожидаемо: интерполянт знает только данные внутри области данных.

Когда результаты экстраполяции по умолчанию неадекватны, пользователям нужно реализовать желаемый режим экстраполяции самостоятельно.

В этом руководстве мы рассмотрим несколько рабочих примеров, где продемонстрируем как использование доступных ключевых слов, так и ручную реализацию желаемых режимов экстраполяции. Эти примеры могут быть или не быть применимы к вашей конкретной задаче; они не обязательно являются лучшими практиками; и они намеренно сведены к основным элементам, необходимым для демонстрации главных идей, в надежде, что они послужат вдохновением для вашего подхода к решению вашей конкретной задачи.

interp1d : репликация numpy.interp левое и правое значения заполнения#

TL;DR: Используйте fill_value=(left, right)

numpy.interp использует постоянную экстраполяцию и по умолчанию расширяет первое и последнее значения y массив в интервале интерполяции: выход np.interp(xnew, x, y) является y[0] для xnew < x[0] и y[-1] для xnew > x[-1].

По умолчанию, interp1d отказывается экстраполировать и вызывает ValueError при вычислении в точке данных вне диапазона интерполяции. Это можно отключить с помощью bounds_error=False аргумент: тогда interp1d устанавливает значения вне диапазона с помощью fill_value, что является nan по умолчанию.

Чтобы имитировать поведение numpy.interp с interp1d, вы можете использовать тот факт, что он поддерживает 2-кортеж в качестве fill_value. Затем элементы кортежа используются для заполнения xnew < min(x) и x > max(x), соответственно. Для многомерных y, эти элементы должны иметь ту же форму, что и y или быть транслируемыми к нему.

Для иллюстрации:

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import interp1d

x = np.linspace(0, 1.5*np.pi, 11)
y = np.column_stack((np.cos(x), np.sin(x)))   # y.shape is (11, 2)

func = interp1d(x, y,
                axis=0,  # interpolate along columns
                bounds_error=False,
                kind='linear',
                fill_value=(y[0], y[-1]))
xnew = np.linspace(-np.pi, 2.5*np.pi, 51)
ynew = func(xnew)

fix, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 4))
ax1.plot(xnew, ynew[:, 0])
ax1.plot(x, y[:, 0], 'o')

ax2.plot(xnew, ynew[:, 1])
ax2.plot(x, y[:, 1], 'o')
plt.tight_layout()
../../_images/extrapolation_examples-1.png

CubicSpline расширяет граничные условия#

CubicSpline требует двух дополнительных граничных условий, которые контролируются параметром bc_type параметр. Этот параметр может либо перечислять явные значения производных на границах, либо использовать полезные псевдонимы. Например, bc_type="clamped" устанавливает первые производные в ноль, bc_type="natural" устанавливает вторые производные в ноль (два других распознаваемых строковых значения — "periodic" и "not-a-knot")

Хотя экстраполяция контролируется граничным условием, соотношение не очень интуитивно понятно. Например, можно ожидать, что для bc_type="natural", экстраполяция линейна. Это ожидание слишком строгое: каждое граничное условие задает производные в единственной точке, на границе только. Экстраполяция выполняется из первого и последнего полиномиальных сегментов, которые — для естественного сплайна — являются кубическими с нулевой второй производной в данной точке.

Еще один способ понять, почему это ожидание слишком сильное — рассмотреть набор данных всего с тремя точками, где сплайн состоит из двух полиномиальных частей. Для линейной экстраполяции это ожидание подразумевает, что обе эти части линейны. Но тогда две линейные части не могут совпадать в средней точке с непрерывной 2-й производной! (Конечно, если только все три точки данных действительно лежат на одной прямой).

Для иллюстрации поведения мы рассматриваем синтетический набор данных и сравниваем несколько граничных условий:

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline

xs = [1, 2, 3, 4, 5, 6, 7, 8]
ys = [4.5, 3.6, 1.6, 0.0, -3.3, -3.1, -1.8, -1.7]

notaknot = CubicSpline(xs, ys, bc_type='not-a-knot')
natural = CubicSpline(xs, ys, bc_type='natural')
clamped = CubicSpline(xs, ys, bc_type='clamped')
xnew = np.linspace(min(xs) - 4, max(xs) + 4, 101)

splines = [notaknot, natural, clamped]
titles = ['not-a-knot', 'natural', 'clamped']

fig, axs = plt.subplots(3, 3, figsize=(12, 12))
for i in [0, 1, 2]:
    for j, spline, title in zip(range(3), splines, titles):
        axs[i, j].plot(xs, spline(xs, nu=i),'o')
        axs[i, j].plot(xnew, spline(xnew, nu=i),'-')
        axs[i, j].set_title(f'{title}, deriv={i}')

plt.tight_layout()
plt.show()
../../_images/extrapolation_examples-2.png

Чётко видно, что естественный сплайн действительно имеет нулевую вторую производную на границах, но экстраполяция нелинейна. bc_type="clamped" показывает аналогичное поведение: первые производные равны нулю только точно на границе. Во всех случаях экстраполяция выполняется путем расширения первого и последнего полиномиальных участков сплайна, какими бы они ни были.

Один из возможных способов принудительной экстраполяции — расширить область интерполяции, добавив первый и последний полиномиальные сегменты с желаемыми свойствами.

Здесь мы используем extend метод CubicSpline суперкласс, PPoly, чтобы добавить две дополнительные точки разрыва и убедиться, что дополнительные полиномиальные куски сохраняют значения производных. Затем экстраполяция продолжается с использованием этих двух дополнительных интервалов.

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CubicSpline

def add_boundary_knots(spline):
    """
    Add knots infinitesimally to the left and right.

    Additional intervals are added to have zero 2nd and 3rd derivatives,
    and to maintain the first derivative from whatever boundary condition
    was selected. The spline is modified in place.
    """
    # determine the slope at the left edge
    leftx = spline.x[0]
    lefty = spline(leftx)
    leftslope = spline(leftx, nu=1)

    # add a new breakpoint just to the left and use the
    # known slope to construct the PPoly coefficients.
    leftxnext = np.nextafter(leftx, leftx - 1)
    leftynext = lefty + leftslope*(leftxnext - leftx)
    leftcoeffs = np.array([0, 0, leftslope, leftynext])
    spline.extend(leftcoeffs[..., None], np.r_[leftxnext])

    # repeat with additional knots to the right
    rightx = spline.x[-1]
    righty = spline(rightx)
    rightslope = spline(rightx,nu=1)
    rightxnext = np.nextafter(rightx, rightx + 1)
    rightynext = righty + rightslope * (rightxnext - rightx)
    rightcoeffs = np.array([0, 0, rightslope, rightynext])
    spline.extend(rightcoeffs[..., None], np.r_[rightxnext])

xs = [1, 2, 3, 4, 5, 6, 7, 8]
ys = [4.5, 3.6, 1.6, 0.0, -3.3, -3.1, -1.8, -1.7]

notaknot = CubicSpline(xs,ys, bc_type='not-a-knot')
# not-a-knot does not require additional intervals

natural = CubicSpline(xs,ys, bc_type='natural')
# extend the natural natural spline with linear extrapolating knots
add_boundary_knots(natural)

clamped = CubicSpline(xs,ys, bc_type='clamped')
# extend the clamped spline with constant extrapolating knots
add_boundary_knots(clamped)

xnew = np.linspace(min(xs) - 5, max(xs) + 5, 201)

fig, axs = plt.subplots(3, 3,figsize=(12,12))

splines = [notaknot, natural, clamped]
titles = ['not-a-knot', 'natural', 'clamped']

for i in [0, 1, 2]:
    for j, spline, title in zip(range(3), splines, titles):
        axs[i, j].plot(xs, spline(xs, nu=i),'o')
        axs[i, j].plot(xnew, spline(xnew, nu=i),'-')
        axs[i, j].set_title(f'{title}, deriv={i}')

plt.tight_layout()
plt.show()
../../_images/extrapolation_examples-3.png

Вручную реализовать асимптотику#

Предыдущий трюк расширения области интерполяции основан на CubicSpline.extend метод. Более общая альтернатива — реализовать обертку, которая явно обрабатывает поведение за пределами границ. Рассмотрим рабочий пример.

Настройка#

Предположим, мы хотим решить при заданном значении \(a\) уравнение

\[a x = 1/\tan{x}\;.\]

(Одно из применений, где появляются такие уравнения — решение для энергетических уровней квантовой частицы). Для простоты рассмотрим только \(x\in (0, \pi/2)\).

Решение этого уравнения однажды просто:

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import brentq

def f(x, a):
    return a*x - 1/np.tan(x)

a = 3
x0 = brentq(f, 1e-16, np.pi/2, args=(a,))   # here we shift the left edge
                                            # by a machine epsilon to avoid
                                            # a division by zero at x=0
xx = np.linspace(0.2, np.pi/2, 101)
plt.plot(xx, a*xx, '--')
plt.plot(xx, 1/np.tan(xx), '--')
plt.plot(x0, a*x0, 'o', ms=12)
plt.text(0.1, 0.9, fr'$x_0 = {x0:.3f}$',
               transform=plt.gca().transAxes, fontsize=16)
plt.show()
../../_images/extrapolation_examples-4.png

Однако, если нам нужно решить её несколько раз (например, чтобы найти ряд корней из-за периодичности tan функция), повторные вызовы scipy.optimize.brentq становятся непомерно дорогими.

Чтобы обойти эту трудность, мы табулируем \(y = ax - 1/\tan{x}\) и интерполировать её на табличной сетке. Фактически, мы будем использовать обратный интерполяция: мы интерполируем значения \(x\) против \(у\). Таким образом, решение исходного уравнения сводится просто к вычислению интерполированной функции в нуле \(y\) аргумент.

Чтобы улучшить точность интерполяции, мы будем использовать знание производных табулированной функции. Мы будем использовать BPoly.from_derivatives для построения кубического интерполянта (эквивалентно, мы могли бы использовать CubicHermiteSpline)

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import BPoly

def f(x, a):
    return a*x - 1/np.tan(x)

xleft, xright = 0.2, np.pi/2
x = np.linspace(xleft, xright, 11)

fig, ax = plt.subplots(1, 2, figsize=(12, 4))

for j, a in enumerate([3, 93]):
    y = f(x, a)
    dydx = a + 1./np.sin(x)**2    # d(ax - 1/tan(x)) / dx
    dxdy = 1 / dydx               # dx/dy = 1 / (dy/dx)

    xdx = np.c_[x, dxdy]
    spl = BPoly.from_derivatives(y, xdx)   # inverse interpolation

    yy = np.linspace(f(xleft, a), f(xright, a), 51)
    ax[j].plot(yy, spl(yy), '--')
    ax[j].plot(y, x, 'o')
    ax[j].set_xlabel(r'$y$')
    ax[j].set_ylabel(r'$x$')
    ax[j].set_title(rf'$a = {a}$')

    ax[j].plot(0, spl(0), 'o', ms=12)
    ax[j].text(0.1, 0.85, fr'$x_0 = {spl(0):.3f}$',
               transform=ax[j].transAxes, fontsize=18)
    ax[j].grid(True)
plt.tight_layout()
plt.show()
../../_images/extrapolation_examples-5.png

Заметим, что для \(a=3\), spl(0) согласуется с brentq вызов выше, в то время как для \(a = 93\), разница существенна. Причина, по которой процедура начинает давать сбой для больших \(a\) заключается в том, что прямая линия \(y = ax\) стремится к вертикальной оси, а корень исходного уравнения стремится к \(x=0\). Поскольку мы табулировали исходную функцию на конечной сетке, spl(0) включает экстраполяцию для слишком больших значений \(a\). Полагаться на экстраполяцию склонно к потере точности, и этого лучше избегать.

Используйте известные асимптотики#

Рассматривая исходное уравнение, мы отмечаем, что для \(x\to 0\), \(\tan(x) = x + O(x^3)\), и исходное уравнение становится

\[ax = 1/x \;,\]

так что \(x_0 \approx 1/\sqrt{a}\) для \(a \gg 1\).

Мы будем использовать это для создания класса, который переключается с интерполяции на использование этого известного асимптотического поведения для данных вне диапазона. Базовая реализация может выглядеть так

class RootWithAsymptotics:
   def __init__(self, a):

       # construct the interpolant
       xleft, xright = 0.2, np.pi/2
       x = np.linspace(xleft, xright, 11)

       y = f(x, a)
       dydx = a + 1./np.sin(x)**2    # d(ax - 1/tan(x)) / dx
       dxdy = 1 / dydx               # dx/dy = 1 / (dy/dx)

       # inverse interpolation
       self.spl = BPoly.from_derivatives(y, np.c_[x, dxdy])
       self.a = a

   def root(self):
       out = self.spl(0)
       asympt = 1./np.sqrt(self.a)
       return np.where(spl.x.min() < asympt, out, asympt)

И затем

>>> r = RootWithAsymptotics(93)
>>> r.root()
array(0.10369517)

что отличается от экстраполированного результата и согласуется с brentq вызов.

Обратите внимание, что эта реализация намеренно упрощена. С точки зрения API, вы можете вместо этого реализовать __call__ метод так, чтобы полная зависимость от x на y доступен. С числовой точки зрения требуется дополнительная работа, чтобы убедиться, что переход между интерполяцией и асимптотикой происходит достаточно глубоко в асимптотическом режиме, чтобы результирующая функция была достаточно гладкой в точке перехода.

Также в этом примере мы искусственно ограничили задачу только одним интервалом периодичности tan функция, и только обрабатывалась \(a > 0\). Для отрицательных значений \(a\), нам потребуется реализовать другие асимптотики, для \(x\to \pi\).

Однако основная идея та же.

Экстраполяция в D > 1#

Основная идея реализации экстраполяции вручную в классе-обёртке или функции может быть легко обобщена на более высокие размерности. В качестве примера мы рассматриваем C1-гладкую интерполяционную задачу для 2D данных с использованием CloughTocher2DInterpolator. По умолчанию он заполняет значения вне границ nans, и мы хотим вместо этого использовать для каждой точки запроса значение её ближайшего соседа.

Поскольку CloughTocher2DInterpolator принимает либо 2D данные, либо триангуляцию Делоне точек данных; эффективный способ поиска ближайших соседей точек запроса — построить триангуляцию (используя scipy.spatial инструменты) и используйте его для поиска ближайших соседей на выпуклой оболочке данных.

Вместо этого мы будем использовать более простой, наивный метод и полагаться на цикл по всему набору данных с использованием трансляции NumPy.

import numpy as np
import matplotlib.pyplot as plt
from scipy.interpolate import CloughTocher2DInterpolator as CT

def my_CT(xy, z):
    """CT interpolator + nearest-neighbor extrapolation.

    Parameters
    ----------
    xy : ndarray, shape (npoints, ndim)
        Coordinates of data points
    z : ndarray, shape (npoints)
        Values at data points

    Returns
    -------
    func : callable
        A callable object which mirrors the CT behavior,
        with an additional neareast-neighbor extrapolation
        outside of the data range.
    """
    x = xy[:, 0]
    y = xy[:, 1]
    f = CT(xy, z)

    # this inner function will be returned to a user
    def new_f(xx, yy):
        # evaluate the CT interpolator. Out-of-bounds values are nan.
        zz = f(xx, yy)
        nans = np.isnan(zz)

        if nans.any():
            # for each nan point, find its nearest neighbor
            inds = np.argmin(
                (x[:, None] - xx[nans])**2 +
                (y[:, None] - yy[nans])**2
                , axis=0)
            # ... and use its value
            zz[nans] = z[inds]
        return zz

    return new_f

# Now illustrate the difference between the original ``CT`` interpolant
# and ``my_CT`` on a small example:

x = np.array([1, 1, 1, 2, 2, 2, 4, 4, 4])
y = np.array([1, 2, 3, 1, 2, 3, 1, 2, 3])
z = np.array([0, 7, 8, 3, 4, 7, 1, 3, 4])

xy = np.c_[x, y]
lut = CT(xy, z)
lut2 = my_CT(xy, z)

X = np.linspace(min(x) - 0.5, max(x) + 0.5, 71)
Y = np.linspace(min(y) - 0.5, max(y) + 0.5, 71)
X, Y = np.meshgrid(X, Y)

fig = plt.figure()
ax = fig.add_subplot(projection='3d')

ax.plot_wireframe(X, Y, lut(X, Y), label='CT')
ax.plot_wireframe(X, Y, lut2(X, Y), color='m',
                  cstride=10, rstride=10, alpha=0.7, label='CT + n.n.')

ax.scatter(x, y, z,  'o', color='k', s=48, label='data')
ax.legend()
plt.tight_layout()
../../_images/extrapolation_examples-6.png