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:
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:
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:
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.
ChebyshevSplinedoes 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:
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:
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.
ChebyshevSplinedoes 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 composeChebyshevSplinewithChebyshevTTorChebyshevSlider.
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: |
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 |
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 |
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 |
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 |
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 |
__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: |
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
|
|
Examples:
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 ( |
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 |
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 |
required |
Returns:
| Type | Description |
|---|---|
ChebyshevSpline
|
A new, higher-dimensional spline (already built).
The result has |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the spline has not been built yet. |
TypeError
|
If |
ValueError
|
If |
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 |
required |
Returns:
| Type | Description |
|---|---|
ChebyshevSpline
|
A new, lower-dimensional spline (already built).
The result has |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the spline has not been built yet. |
TypeError
|
If |
ValueError
|
If a slice value is outside the domain, if slicing all
dimensions, or if |
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
|
bounds
|
tuple, list of tuple/None, or None
|
Sub-interval bounds for integration. |
None
|
Returns:
| Type | Description |
|---|---|
float or ChebyshevSpline
|
Scalar for full integration; lower-dimensional spline for partial integration. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
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 |
None
|
Returns:
| Type | Description |
|---|---|
ndarray
|
Sorted array of root locations in the physical domain. |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
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 |
None
|
Returns:
| Type | Description |
|---|---|
(value, location) : (float, float)
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
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 |
None
|
Returns:
| Type | Description |
|---|---|
(value, location) : (float, float)
|
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If |
ValueError
|
If dim / fixed validation fails. |