Skip to content

Chebyshev Splines

The Gibbs Phenomenon

Chebyshev interpolation converges exponentially for smooth (analytic) functions -- this is the spectral advantage that makes the method so powerful. But when the target function has a discontinuity or a kink, this advantage disappears.

Jump discontinuities. For a function \(f\) with a jump discontinuity at \(c \in (a, b)\), the Chebyshev interpolant converges only as \(O(1/n)\) pointwise away from \(c\) (Trefethen 2013, Ch. 9). Near \(c\), oscillations persist regardless of how many nodes you add -- this is the classical Gibbs phenomenon.

Kinks. For a function that is continuous but whose derivative is discontinuous at \(c\) -- for example \(|x|\) at \(x = 0\) or a call payoff \(\max(S - K, 0)\) at \(S = K\) -- the situation is better but still algebraic: convergence is \(O(1/n^2)\) instead of exponential. This means that increasing the node count from 15 to 30 only halves the error, rather than reducing it by orders of magnitude as it would for a smooth function.

Interpolating \(|x|\) on \([-1, 1]\)

With global Chebyshev interpolation, the error near \(x = 0\) plateaus at approximately \(0.01\) regardless of whether you use 10, 20, or 40 nodes. This is exactly the algebraic \(O(1/n^2)\) convergence rate -- the spectral advantage of Chebyshev is lost.

In quantitative finance this problem is ubiquitous: option payoffs have kinks at strike prices, barrier levels, and exercise boundaries. Applying global Chebyshev interpolation to such functions wastes nodes fighting the Gibbs oscillations instead of refining the smooth parts of the function.

Why Piecewise Chebyshev Restores Spectral Convergence

The key to understanding why piecewise interpolation helps lies in the Bernstein ellipse theorem (Trefethen 2013, Ch. 8).

The Bernstein ellipse

For a function \(f\) analytic in the interior of the Bernstein ellipse \(\mathcal{E}_\rho\) -- the ellipse in the complex plane with foci at \(\pm 1\) and semi-axis sum \(\rho > 1\) -- the Chebyshev interpolation error on \([-1, 1]\) with \(n\) nodes satisfies:

\[ \| f - p_n \|_\infty \leq \frac{2 M}{\rho^n (\rho - 1)} \]

where \(M = \max_{z \in \mathcal{E}_\rho} |f(z)|\). The rate \(\rho^{-n}\) is exponential in \(n\) -- this is spectral convergence.

How kinks destroy analyticity

A function with a kink at \(c\) is not analytic at \(c\). The Bernstein ellipse cannot extend past the singularity: it collapses to the real interval near \(c\), forcing \(\rho \to 1\) and reducing convergence to algebraic.

How splitting restores it

By placing a knot at \(c\) and interpolating each sub-interval separately, each piece sees a smooth function:

  • \(|x|\) restricted to \([-1, 0]\) is just \(-x\), which is entire (analytic everywhere in \(\mathbb{C}\)).
  • \(|x|\) restricted to \([0, 1]\) is just \(x\), also entire.

Each piece has a large Bernstein ellipse parameter \(\rho_k \gg 1\), and the error on piece \(k\) with \(n\) Chebyshev nodes is:

\[ E_k \leq \frac{2 M_k}{\rho_k^n (\rho_k - 1)} \]

where \(M_k = \max_{z \in \mathcal{E}_{\rho_k}} |f(z)|\) on that piece's Bernstein ellipse. Because pieces cover disjoint sub-domains, the overall interpolation error is:

\[ \| f - \mathcal{S}_n f \|_\infty = \max_k \, E_k \]

This is exponential in \(n\) -- spectral convergence is restored.

Book reference

The Chebyshev Spline technique is described in Section 3.8 of Ruiz & Zeron (2021), Machine Learning for Risk Calculations, Wiley Finance. The book demonstrates that pricing a European call near the strike kink requires 95 nodes with global Chebyshev but only 25 nodes (split into two pieces at \(K\)) with a Chebyshev spline -- same accuracy, 4x fewer evaluations.

Quick Start

import math
from pychebyshev import ChebyshevSpline

# European call payoff max(S - K, 0) * exp(-rT) with kink at K = 100
def payoff(x, _):
    return max(x[0] - 100.0, 0.0) * math.exp(-0.05 * x[1])

# Place a knot at S = 100 (the strike), no knots in the T dimension
spline = ChebyshevSpline(
    payoff,
    num_dimensions=2,
    domain=[[80, 120], [0.25, 1.0]],
    n_nodes=[15, 15],
    knots=[[100.0], []],   # knot at S=K, none in T
)
spline.build()

# Evaluate in-the-money
price_itm = spline.eval([110.0, 0.5], [0, 0])

# Evaluate out-of-the-money
price_otm = spline.eval([90.0, 0.5], [0, 0])

# Delta (dV/dS) on the in-the-money side
delta = spline.eval([110.0, 0.5], [1, 0])

print(spline)

Output:

ChebyshevSpline (2D, built)
  Nodes:       [15, 15] per piece
  Knots:       [[100.0], []]
  Pieces:      2 (2 x 1)
  Build:       0.012s (450 function evals)
  Domain:      [80, 120] x [0.25, 1.0]
  Error est:   1.23e-10

With global ChebyshevApproximation on the same domain, you would need approximately 95 nodes to achieve similar accuracy. The spline uses 2 pieces of 15 nodes each (450 total evaluations vs. 9,025).

Choosing Knots

Place knots at the locations where the function is non-smooth:

Singularity type Example Knot location
Payoff kink European call \(\max(S - K, 0)\) \(S = K\)
Barrier level Knock-out option \(S = B\)
Exercise boundary American option (if known) \(S = S^*(T)\)
Absolute value \(\|x\|\) \(x = 0\)

Guidelines:

  • Only add knots where the function is non-smooth. For smooth functions, knots provide no benefit -- you pay extra build cost for no accuracy gain.
  • More knots = more pieces = more build evaluations. Each dimension with \(k_d\) interior knots creates \(k_d + 1\) sub-intervals. The total number of pieces is the Cartesian product \(\prod_d (k_d + 1)\).
  • Knots must be known a priori. ChebyshevSpline does not detect singularities automatically; you must specify where they are.

Multiple Knots and Multi-Dimensional Problems

Multiple knots in one dimension

# Two knots in dimension 0: at x = -1 and x = 1
# This creates 3 pieces in dimension 0
spline = ChebyshevSpline(
    my_func, 1,
    domain=[[-3, 3]],
    n_nodes=[15],
    knots=[[-1.0, 1.0]],
)

Multi-dimensional knots

Each dimension has its own independent list of knots. The total number of pieces is the Cartesian product of per-dimension intervals:

# 2D: 2 knots in dim 0, 1 knot in dim 1
# Pieces: (2+1) x (1+1) = 3 x 2 = 6
spline = ChebyshevSpline(
    my_func, 2,
    domain=[[0, 10], [0, 5]],
    n_nodes=[15, 15],
    knots=[[3.0, 7.0], [2.5]],
)

No knots in a dimension

Use an empty list [] for dimensions where the function is smooth:

# Knot at S = 100 in price dimension, none in time or vol
spline = ChebyshevSpline(
    bs_func, 3,
    domain=[[80, 120], [0.25, 1.0], [0.15, 0.35]],
    n_nodes=[15, 15, 15],
    knots=[[100.0], [], []],
)

Degenerate case: no knots at all

If every dimension has an empty knot list, the spline has exactly one piece and behaves identically to a plain ChebyshevApproximation.

Derivatives

Within each piece, derivatives are computed analytically via spectral differentiation matrices -- the same mechanism used by ChebyshevApproximation. No finite differences are needed.

# First derivative w.r.t. dimension 0
dfdx0 = spline.eval([110.0, 0.5], [1, 0])

# Second derivative w.r.t. dimension 0
d2fdx0 = spline.eval([110.0, 0.5], [2, 0])

# Multiple derivatives at once (shared barycentric weights)
results = spline.eval_multi(
    [110.0, 0.5],
    [
        [0, 0],  # function value
        [1, 0],  # dV/dS
        [2, 0],  # d2V/dS2
        [0, 1],  # dV/dT
    ],
)

Derivatives at knot boundaries

Derivatives are not defined at knot boundaries. At a kink, the left and right polynomial pieces have different derivative values. Requesting a derivative at a knot raises ValueError:

# This raises ValueError:
# "Derivative w.r.t. dimension 0 is not defined at knot x[0]=100.0"
spline.eval([100.0, 0.5], [1, 0])

# Function values are fine at knots:
spline.eval([100.0, 0.5], [0, 0])  # OK

Evaluate near the knot instead

If you need a derivative near a knot, evaluate slightly to one side: spline.eval([100.001, 0.5], [1, 0]) gives the right-side derivative.

Error Estimation

error_estimate() returns the maximum error estimate across all pieces:

spline.build()
print(f"Error estimate: {spline.error_estimate():.2e}")

Since pieces cover disjoint sub-domains, the interpolation error at any point is bounded by the error of the piece containing that point. The worst-case error is therefore the maximum over all pieces:

\[ \hat{E} = \max_k \hat{E}_k \]

This differs from ChebyshevSlider, where all slides contribute to every point and the error estimate is the sum over slides.

When to Use ChebyshevSpline

Scenario Recommended class Why
Smooth function, \(\leq\) 5D ChebyshevApproximation Full tensor is feasible; spectral convergence without knots
Function with kinks at known locations ChebyshevSpline Restores spectral convergence by splitting at singularities
High-dimensional (\(5+\)D), general ChebyshevTT TT-Cross builds from \(O(d \cdot n \cdot r^2)\) evaluations
High-dimensional, additively separable ChebyshevSlider Additive decomposition; cheapest build

Use ChebyshevSpline when:

  • The function has known non-smooth points (kinks, discontinuities).
  • The dimension count is low enough for full tensor grids (typically \(\leq 5\)D).
  • You need analytical derivatives (not finite differences).
  • You want spectral accuracy on a function that would otherwise converge slowly.

Batch Evaluation

For evaluating many points at once, eval_batch() vectorises the piece-routing step and groups points by piece:

import numpy as np

points = np.column_stack([
    np.random.uniform(80, 120, 1000),
    np.random.uniform(0.25, 1.0, 1000),
])
values = spline.eval_batch(points, [0, 0])

Points that fall in the same piece are batched together for efficient evaluation.

Serialization

Save and load splines using the same pattern as other PyChebyshev classes:

# Save
spline.save("payoff_spline.pkl")

# Load (no rebuild needed)
from pychebyshev import ChebyshevSpline
loaded = ChebyshevSpline.load("payoff_spline.pkl")
val = loaded.eval([110.0, 0.5], [0, 0])

The original function is not saved -- only the numerical data needed for evaluation. Assign a new function before calling build() again if a rebuild is desired.

References

  • Ruiz, G. & Zeron, M. (2021). Machine Learning for Risk Calculations. Wiley Finance. Section 3.8.
  • Trefethen, L. N. (2013). Approximation Theory and Approximation Practice. SIAM. Chapters 8--9.

Limitations

  • Knots must be known a priori. ChebyshevSpline does not automatically detect singularities. You must know where the function is non-smooth and place knots accordingly.

  • Build cost scales with the number of pieces. Total evaluations are \(\text{num\_pieces} \times \prod_d n_d\). Many knots in many dimensions creates many pieces: 3 knots in each of 4 dimensions means \(4^4 = 256\) pieces.

  • Low-dimensional only. Like ChebyshevApproximation, each piece requires a full tensor grid. For high-dimensional functions with kinks, a future extension could compose ChebyshevSpline with ChebyshevTT or ChebyshevSlider.

API Reference

Piecewise Chebyshev interpolation with user-specified knots.

Partitions the domain into sub-intervals at interior knots and builds an independent :class:ChebyshevApproximation on each piece. Query points are routed to the appropriate piece for evaluation.

This is the correct approach when the target function has known singularities (kinks, discontinuities) at specific locations: place knots at those locations so that each piece is smooth, restoring spectral convergence.

Parameters:

Name Type Description Default
function callable

Function to approximate. Signature: f(point, data) -> float where point is a list of floats and data is arbitrary additional data (can be None).

required
num_dimensions int

Number of input dimensions.

required
domain list of (float, float)

Bounds [lo, hi] for each dimension.

required
n_nodes list of int

Number of Chebyshev nodes per dimension per piece.

required
knots list of list of float

Interior knots for each dimension. Each sub-list must be sorted and every knot must lie strictly inside the corresponding domain interval. Use an empty list [] for dimensions with no knots.

required
max_derivative_order int

Maximum derivative order to pre-compute (default 2).

2

Examples:

>>> import math
>>> def f(x, _):
...     return abs(x[0])
>>> sp = ChebyshevSpline(f, 1, [[-1, 1]], [15], [[0.0]])
>>> sp.build(verbose=False)
>>> round(sp.eval([0.5], [0]), 10)
0.5
>>> round(sp.eval([-0.3], [0]), 10)
0.3

num_pieces property

Total number of pieces (Cartesian product of per-dimension intervals).

total_build_evals property

Total number of function evaluations used during build.

build_time property

Wall-clock time (seconds) for the most recent build() call.

build(verbose=True)

Build all pieces by evaluating the function on each sub-domain.

Each piece is an independent :class:ChebyshevApproximation built on the Cartesian product of per-dimension sub-intervals.

Parameters:

Name Type Description Default
verbose bool

If True, print build progress. Default is True.

True

eval(point, derivative_order)

Evaluate the spline approximation at a point.

Routes the query to the piece whose sub-domain contains point and delegates to its :meth:~ChebyshevApproximation.vectorized_eval.

Parameters:

Name Type Description Default
point list of float

Evaluation point in the full domain.

required
derivative_order list of int

Derivative order for each dimension (0 = function value).

required

Returns:

Type Description
float

Approximated function value or derivative.

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If the point is at a knot and a non-zero derivative is requested.

eval_multi(point, derivative_orders)

Evaluate multiple derivative orders at one point, sharing weights.

Routes to a single piece and delegates to its :meth:~ChebyshevApproximation.vectorized_eval_multi so that barycentric weight computation is shared across all requested derivative orders.

Parameters:

Name Type Description Default
point list of float

Evaluation point in the full domain.

required
derivative_orders list of list of int

Each inner list specifies derivative order per dimension.

required

Returns:

Type Description
list of float

One result per derivative order.

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If the point is at a knot and a non-zero derivative is requested.

eval_batch(points, derivative_order)

Evaluate at multiple points, grouping by piece for efficiency.

Vectorises the piece-routing step using np.searchsorted and evaluates each piece's batch via :meth:~ChebyshevApproximation.vectorized_eval_batch.

Parameters:

Name Type Description Default
points ndarray of shape (N, num_dimensions)

Evaluation points.

required
derivative_order list of int

Derivative order for each dimension.

required

Returns:

Type Description
ndarray of shape (N,)

Approximated values or derivatives at each point.

Raises:

Type Description
RuntimeError

If build() has not been called.

error_estimate()

Estimate the supremum-norm interpolation error.

Returns the maximum error estimate across all pieces. Since pieces cover disjoint sub-domains, the interpolation error at any point is bounded by the error of the piece containing that point. The worst-case error is therefore the maximum over all pieces (not the sum, unlike :class:ChebyshevSlider where all slides contribute to every point).

Returns:

Type Description
float

Estimated maximum interpolation error (sup-norm).

Raises:

Type Description
RuntimeError

If build() has not been called.

__getstate__()

Return picklable state, excluding the original function.

__setstate__(state)

Restore state from a pickled dict.

save(path)

Save the built spline to a file.

The original function is not saved -- only the numerical data needed for evaluation. The saved file can be loaded with :meth:load without access to the original function.

Parameters:

Name Type Description Default
path str or path - like

Destination file path.

required

Raises:

Type Description
RuntimeError

If the spline has not been built yet.

load(path) classmethod

Load a previously saved spline from a file.

The loaded object can evaluate immediately; no rebuild is needed. The function attribute will be None. Assign a new function before calling build() again if a rebuild is desired.

Parameters:

Name Type Description Default
path str or path - like

Path to the saved file.

required

Returns:

Type Description
ChebyshevSpline

The restored spline.

Warns:

Type Description
UserWarning

If the file was saved with a different PyChebyshev version.

.. warning::

This method uses :mod:pickle internally. Pickle can execute arbitrary code during deserialization. Only load files you trust.

nodes(num_dimensions, domain, n_nodes, knots) staticmethod

Generate Chebyshev nodes for every piece without evaluating any function.

Use this to obtain the per-piece grid points, evaluate your function externally, then pass the results to :meth:from_values.

Parameters:

Name Type Description Default
num_dimensions int

Number of dimensions.

required
domain list of (float, float)

Lower and upper bounds for each dimension.

required
n_nodes list of int

Number of Chebyshev nodes per dimension per piece.

required
knots list of list of float

Knot positions for each dimension (may be empty).

required

Returns:

Type Description
dict

'pieces' : list of dicts, one per piece in C-order (np.ndindex(*piece_shape)). Each dict contains:

  • 'piece_index' : tuple — multi-index of this piece
  • 'sub_domain' : list of (float, float) — bounds for this piece
  • 'nodes_per_dim' : list of 1-D arrays
  • 'full_grid' : 2-D array, shape (prod(n_nodes), num_dimensions)
  • 'shape' : tuple of int

'num_pieces' : int — total number of pieces.

'piece_shape' : tuple of int — per-dimension piece counts.

Examples:

>>> info = ChebyshevSpline.nodes(1, [[-1, 1]], [10], [[0.0]])
>>> info['num_pieces']
2
>>> info['pieces'][0]['sub_domain']
[(-1, 0.0)]

from_values(piece_values, num_dimensions, domain, n_nodes, knots, max_derivative_order=2) classmethod

Create a spline from pre-computed function values on each piece.

The resulting object is fully functional: evaluation, derivatives, integration, rootfinding, algebra, extrusion/slicing, and serialization all work exactly as if build() had been called.

Parameters:

Name Type Description Default
piece_values list of numpy.ndarray

Function values for each piece. Length must equal the total number of pieces (prod of per-dimension piece counts). Order follows np.ndindex(*piece_shape) (C-order), matching the 'pieces' list returned by :meth:nodes. Each array must have shape tuple(n_nodes).

required
num_dimensions int

Number of dimensions.

required
domain list of (float, float)

Lower and upper bounds for each dimension.

required
n_nodes list of int

Number of Chebyshev nodes per dimension per piece.

required
knots list of list of float

Knot positions for each dimension (may be empty).

required
max_derivative_order int

Maximum derivative order (default 2).

2

Returns:

Type Description
ChebyshevSpline

A fully built spline with function=None.

Raises:

Type Description
ValueError

If the number of pieces does not match, or any piece has the wrong shape or contains NaN/Inf.

extrude(params)

Add new dimensions where the function is constant.

Each piece is extruded independently via :meth:ChebyshevApproximation.extrude. The extruded spline evaluates identically to the original regardless of the new coordinate(s), because Chebyshev basis functions form a partition of unity. The new dimension gets knots=[] and a single interval (lo, hi).

Parameters:

Name Type Description Default
params tuple or list of tuples

Single (dim_index, (lo, hi), n_nodes) or a list of such tuples. dim_index is the position in the output space (0-indexed). n_nodes must be >= 2 and lo < hi.

required

Returns:

Type Description
ChebyshevSpline

A new, higher-dimensional spline (already built). The result has function=None and build_time=0.0.

Raises:

Type Description
RuntimeError

If the spline has not been built yet.

TypeError

If dim_index is not an integer.

ValueError

If dim_index is out of range, duplicated, lo >= hi, or n_nodes < 2.

slice(params)

Fix one or more dimensions at given values, reducing dimensionality.

For each sliced dimension, only the pieces whose interval contains the slice value survive. Each surviving piece is then sliced via :meth:ChebyshevApproximation.slice, which contracts the tensor along that axis using the barycentric interpolation formula. When the slice value coincides with a Chebyshev node (within 1e-14), the contraction reduces to an exact np.take (fast path).

Parameters:

Name Type Description Default
params tuple or list of tuples

Single (dim_index, value) or a list of such tuples. value must lie within the domain for that dimension.

required

Returns:

Type Description
ChebyshevSpline

A new, lower-dimensional spline (already built). The result has function=None and build_time=0.0.

Raises:

Type Description
RuntimeError

If the spline has not been built yet.

TypeError

If dim_index is not an integer.

ValueError

If a slice value is outside the domain, if slicing all dimensions, or if dim_index is out of range or duplicated.

integrate(dims=None, bounds=None)

Integrate the spline over one or more dimensions.

For full integration, sums the integrals of each piece (pieces cover disjoint sub-domains). For partial integration, pieces along the integrated dimension are summed and the result is a lower-dimensional spline.

Parameters:

Name Type Description Default
dims int, list of int, or None

Dimensions to integrate out. If None, integrates over all dimensions and returns a scalar.

None
bounds tuple, list of tuple/None, or None

Sub-interval bounds for integration. None (default) integrates over the full domain of each dimension. A single tuple (lo, hi) applies to a single dims entry. A list of tuples/None provides per-dimension bounds with positional correspondence to dims.

None

Returns:

Type Description
float or ChebyshevSpline

Scalar for full integration; lower-dimensional spline for partial integration.

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If any dimension index is out of range or duplicated, or if bounds are outside the domain.

roots(dim=None, fixed=None)

Find all roots of the spline along a specified dimension.

Slices the spline to 1-D, then finds roots in each piece and merges the results.

Parameters:

Name Type Description Default
dim int or None

Dimension along which to find roots.

None
fixed dict or None

For multi-D, dict {dim_index: value} for all other dims.

None

Returns:

Type Description
ndarray

Sorted array of root locations in the physical domain.

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If dim / fixed validation fails.

minimize(dim=None, fixed=None)

Find the minimum value of the spline along a dimension.

Parameters:

Name Type Description Default
dim int or None

Dimension along which to minimize.

None
fixed dict or None

For multi-D, dict {dim_index: value} for all other dims.

None

Returns:

Type Description
(value, location) : (float, float)

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If dim / fixed validation fails.

maximize(dim=None, fixed=None)

Find the maximum value of the spline along a dimension.

Parameters:

Name Type Description Default
dim int or None

Dimension along which to maximize.

None
fixed dict or None

For multi-D, dict {dim_index: value} for all other dims.

None

Returns:

Type Description
(value, location) : (float, float)

Raises:

Type Description
RuntimeError

If build() has not been called.

ValueError

If dim / fixed validation fails.