import numpy as np
import plotly.graph_objects as go
import sympy as sp
from numpy.polynomial import legendre
from scipy.interpolate import lagrange
try:
import executable_engineering as exe
except ImportError:
%pip install -q executable_engineering
import executable_engineering as exeIntroduction¶
Polynomial interpolation is a straightforward approach to interpolation.
Three methods to obtain polynomials are established here. For a given set of data, they all must result in the same polynomial. The difference is the means by which they are achieved, which translates to the ways that they are used.
The polynomial methods discussed here rely on a core property of linear algebra:
It is always possible to construct a unique polynomial of degree that passes exactly through distinct data points.
Error in Interpolation¶
It can be shown that the error in the interpolation is,
where is in the interval .
Since for datapoints there is a unique polynomial of degree , which can be expressed as a Lagrange polynomial, this analysis is universal to all polynomial interpolations!. The main takeaway is that:
The further a data point is from , the more it contributes to the error.
Lagrange Polynomial Interpolation¶
Lagrange polynomial interpolation constructs the polynomial as,
which is a weighted sum of the Lagrange basis polynomials, ,
N.B.: means the product of, like means the sum of.
Lagrange Basis Polynomials¶
By construction,
when
when .
Use the data: x = [0, .5, 2] y = [1, 3, 2]
Let’s plot each polynomial and verify the property that when and when .
# Data points
x = [0, .5, 2]
y = [1, 3, 2]
# Calculate the Lagrange basis polynomials using scipy.interpolate.lagrange
# P_i is generated by setting y_i = 1 and all other y_j = 0
n = len(x)
P = []
for i in range(n):
y_basis = np.zeros(n)
y_basis[i] = 1
P.append(lagrange(x, y_basis))
# Plot the Lagrange basis polynomials
x_plot = np.linspace(-1, 3, 100)
fig = go.Figure()
for i in range(n):
y_plot = P[i](x_plot)
fig.add_trace(go.Scatter(x=x_plot, y=y_plot, mode='lines', name=f'P_{i+1}(x)'))
fig.add_trace(go.Scatter(x=x, y=[1] * len(x), mode='markers', marker=dict(color='black'), showlegend=False))
fig.add_trace(go.Scatter(x=x, y=[0] * len(x), mode='markers', marker=dict(color='red'), showlegend=False))
fig.update_layout(title='Lagrange Basis Polynomials', xaxis_title='x', yaxis_title='P_i(x)')
fig.show()
Assembling the Polynomial¶
Since , and , it is trivial to see that for , the coefficients are simply:
# Construct the Lagrange polynomial
L = np.poly1d(0)
for i in range(n):
L = L + y[i] * P[i]
# Plot the Lagrange polynomial
y_plot = L(x_plot)
fig = go.Figure()
fig.add_trace(go.Scatter(x=x_plot, y=y_plot, mode='lines', name='L(x)'))
fig.add_trace(go.Scatter(x=x, y=y, mode='markers', name='Data points', marker=dict(color='red')))
fig.update_layout(title='Lagrange Polynomial Interpolation', xaxis_title='x', yaxis_title='L(x)')
fig.show()
Analysis¶
We can observe some notes:
For data points we necessarily produce a unique polynominal that crosses each one.
If we have two measurements at the same input, , which is undefined unless and in which case the data pair is redundant and can be removed.
Each evalulation of involves products, and is the sum of bases, therefore evaluation is
Adding new data means restarting the compuation.
x = [0, 1, 2, 3, 4]
y = [1, 3, 2, 5, 7]
n = len(x)
x_sym = sp.Symbol('x')
L = 0
for i in range(n):
term = y[i]
for j in range(n):
if i != j:
term *= (x_sym - x[j]) / (x[i] - x[j])
L += term
print(L)
print('which is an ugly way of writing out:')
print(L.simplify())
Barycentric Lagrange Interpolation¶
Let’s try to improve the performance of Lagrange Interpolation. Let:
and the barycentric weights, :
and write:
and factor the out of the sum:
which is for evaluation. Calculation of can be formulated recursively, such that each takes and the full takes with updates n.
NB: The weights depend only on , not - this means if we are measuring multiple functions on the same spacing, we can reuse the weights, leading to substantial computaitonal savings. The benefit being that the calucation of the , is precomputed.
We can write one more form which is commonly implemented. Let’s add one more piece of data:
then we divide the previous function and write:
where we have cancelled ! Besides elegance, this avoids an issue when evaluating where roundoff can cause subtractive cancellation. Since the term appears in the numerator and denominator this cancels out!
Newton’s Divided Difference Method¶
Newton’s polynomial interpolation has the form:
which has the advantage of evaluations due to recursion and nested multiplication. E.g. for 4 terms,
Newton’s method is also known as the divided differences
This algorithm was used to calculate function tables like logarithms and trignometry functions. It was then the basis for the difference engine, an early mechanical calculator.
Let’s generalize this. Define the two-argument function:
and the ternary recursively:
The n-ary function is:
Tableau¶
We can visualize this is in a tableau:
where each element is the difference of the two to the left. The diagonal is the coefficients that we need: .
Notes:
The order that the datapoints are added is arbitrary but will result in a different tableau (with the same diagonal).
We can build this matrix / tableau diagonal-by-diagonal which means adding new data points doesn’t require recalculation of the others.
Each new diagonal (datapoint) takes so assembly of the tableau takes .
Evaluation of f(x) takes .
These coefficients are independent of .
Direct Solution¶
Lastly, there is a direct solution method that only became really practical with the advent of modern computing since it focusses on linear systems:
Consider a general, th order polynomial,
since
we can write out in matrix form,
where the matrix of coefficients is called a Vandermonde matrix. This system can be solved for with a dense linear solver. The issue with this method is that the system is notoriously ill-conditioned and roundoff error accumulates rapidly for large .
import numpy as np
# Test condition number for various numbers of sample points
n_values = [5, 10, 15, 20]
print("Condition Numbers of Vandermonde Matrices (range -1 to 1):")
print("-" * 60)
for n in n_values:
# Generate equally spaced x values
x = np.linspace(-1, 1, n)
# Construct the Vandermonde matrix (increasing powers: 1, x, x^2...)
V = np.vander(x, increasing=True)
# Calculate the condition number using numpy.linalg.cond
cond_num = np.linalg.cond(V)
print(f"n = {n:2d} points | Matrix shape: {V.shape} | Condition Number: {cond_num:.2e}")Example: Interpolate our Toy Problem¶
Let us now examine our toy problem. Since all the polynomial interpolation functions generate the same unique polynomial, any will suffice.
YIKES! Watch out for Runge’s phenomenon: Even for a seemingly ideal case of equally spaced samples, higher order polynomials can show huge oscillations between samples! Let’s see this in action:
# Define the function
def f(x):
return np.exp(-(x/2)**2)
# Create x values for plotting
x_toy = np.linspace(-6, 6, 100)
y_toy = f(x_toy)
# Sample 11 random points in the range -5 to 6
x_d = np.arange(-5, 6, 1)
y_d = f(x_d)
# Interpolate using numpy Legendre
coefficients = legendre.legfit(x_d, y_d, len(x_d) - 1)
legendre_polynomial = legendre.Legendre(coefficients)
# Create x values for plotting the interpolated polynomial
x_interp = np.linspace(-5.5, 5.5, 200)
y_interp = legendre_polynomial(x_interp)
# Plot the original curve, sampled points, and interpolated polynomial
fig = go.Figure()
fig.add_trace(go.Scatter(x=x_toy, y=y_toy, name='exp(-(x/2)^2)'))
fig.add_trace(go.Scatter(x=x_d, y=y_d, mode='markers', name='Sampled points', marker=dict(color='red')))
fig.add_trace(go.Scatter(x=x_interp, y=y_interp, name='Legendre Interpolation'))
fig.update_layout(title='Function, Sampled Points, and Legendre Interpolation', xaxis_title='x', yaxis_title='y')
fig.show()