Introduction¶
Every elementary function can be differentiated analytically. The definition of the derivative is:
Directly applying the equation above leads to subtracting very similar function values (, ) that are burdened by round-off error, and then dividing them by a small value ; as a result, the derivative has substantially fewer significant digits than the function values. We avoid numerical differentiation whenever we can; in some cases, however (e.g., solving differential equations), it is an indispensable tool!
In numerical differentiation we have two fundamentally different approaches:
first perform interpolation/approximation, then compute the derivative based on the known interpolation/approximation functions (we already discussed this topic in interpolation and approximation), and
computing the derivative directly from the values in a table.
In this chapter we will learn how to numerically compute the derivative of a function ; here the values of the function are given in tabular form (pairs , ), as shown in the figure:
We will first focus on equidistant values spaced with a step ; the function values will be .
Based on the definition of the derivative above, the first derivative (at location ) could be written as:
where . By rearranging the equation:
we can also say that for the first derivative of the function at location we weight the function value at by and the function value at by .
In what follows we will look at the theoretical background of how to determine the appropriate weights for various orders of derivatives, what options we have here, and how this affects the order of accuracy.
Approximating the first derivative using the finite-difference method¶
As an introduction, let’s watch the video below:
from IPython.display import YouTubeVideo
YouTubeVideo('YYuGL-VP2BE', width=800, height=300)The derivative can be approximated based on a Taylor series expansion. We call this method the finite-difference method or the difference method.
Let’s expand the forward Taylor series (forward, because of the term):
The term denotes a second-order error. If we express the first derivative from the equation:
We find that we can estimate the first derivative at the point (that is: ) based on two consecutive function values:
and in doing so we make a method error, which is of first order .
We used (see the figure above).
The following expression therefore holds:
Now let’s see how we arrive at the same result with a machine-assisted derivation; first we import sympy:
import sympy as sym
sym.init_printing()Let’s define the symbols:
f = sym.Function('f')
x, h = sym.symbols('x, h')Then we continue with the forward Taylor series expansion:
f(x+h).series(h, n=2)The term contains terms of second and higher order. The equation above uses a temporary variable for differentiation ; let’s carry out the differentiation and substitute :
f(x+h).series(h, n=2).doit()We write the equation:
equation = sym.Eq(f(x+h), f(x+h).series(h, n=2).doit())
equationWe solve it for the first derivative :
f1_forward_exact = sym.solve(equation, f(x).diff(x))[0]
f1_forward_exactIf we do not account for the second- and higher-order derivatives, we have therefore made an error:
f1_forward_O = f1_forward_exact.expand().getO()
f1_forward_OThe error is therefore of first order, and if we neglect this term we make a method error and obtain an estimate of the derivative:
f1_forward_estimate = f1_forward_exact.expand().removeO()
f1_forward_estimateWe find that this is the same expression we derived above, so it is:
The weights are therefore:
| Derivative Values | ||
|---|---|---|
| -1 | 1 |
Central difference scheme¶
Derivative ¶
First let’s look at the backward Taylor series expansion:
f(x-h).series(h, n=3).doit()We find that in the difference between the forward and backward series the even-order terms cancel out; let’s define:
def difference(n=3):
return f(x+h).series(h, n=n).doit()-f(x-h).series(h, n=n).doit()
difference(n=3)We carry out the following steps:
subtract the backward Taylor series from the forward series, the even-order derivatives cancel out,
solve the equation for the first derivative,
determine the method error,
determine the estimate of the derivative.
Let’s carry out the steps above:
f1_cent_exact = sym.solve(
sym.Eq(f(x+h) - f(x-h), difference(n=3)), # step 1
f(x).diff(x))[0] # step 2
f1_cent_O = f1_cent_exact.expand().getO() # step 3
f1_cent_estimate = f1_cent_exact.expand().removeO() # step 4The estimate of the 1st derivative is therefore:
f1_cent_estimateThe method error is therefore of second order:
f1_cent_OExample: ¶
Let’s look at an example of the exponential function and, for the point , compute the first derivative at the steps and .
First we prepare a table of numerical values and the exact result:
import numpy as np
x0 = np.array([0., 1., 2.]) # step h=1
y0 = np.exp(-x0)
h0 = x0[1]-x0[0]
x1 = np.array([0.9, 1.0, 1.1]) # step h=0.1
y1 = np.exp(-x1)
h1 = x1[1]-x1[0]
f1_exact0 = - np.exp(-1) # exact result
f1_exact1 = - np.exp(-1) # exact resultThen we use the forward scheme:
f1_forward_estimate # as a reminderf1_forward0 = (y0[1:]-y0[:-1])/h0 # step h_0
f1_forward1 = (y1[1:]-y1[:-1])/h1 # step h_1Let’s compute the error at :
f1_exact0 - f1_forward0[1]f1_exact1 - f1_forward1[1] # step h1We can confirm that the error at step is indeed approximately 1/10 of that at step .
Now let’s also look at the error for the central difference scheme, which is of second order:
f1_cent_estimatef1_cent0 = (y0[2:]-y0[:-2])/(2*h0) # step h_0
f1_cent1 = (y1[2:]-y1[:-2])/(2*h1) # step h_1Let’s analyze the error:
f1_exact0 - f1_cent0[0] # step h0f1_exact1 - f1_cent1[0] # step h1We can confirm that the error at step is indeed approximately 1/100 of that at step .
Derivative ¶
If we add the forward and backward Taylor series, the odd-order derivatives cancel out:
def total(n=3):
return f(x+h).series(h, n=n).doit() + f(x-h).series(h, n=n).doit()
total(n=4)Let’s determine the second derivative:
f2_cent_exact = sym.solve(
sym.Eq(f(x+h) + f(x-h), total(n=4)), # step 1
f(x).diff(x,2))[0] # step 2
f2_cent_O = f2_cent_exact.expand().getO() # step 3
f2_cent_estimate = f2_cent_exact.expand().removeO() # step 4The estimate of the second derivative is:
f2_cent_estimateThe method error is again of second order:
f2_cent_ODerivative ¶
If we want to determine the third derivative, we have to expand the Taylor series to order 5:
eq_h = sym.Eq(f(x+h)-f(x-h), difference(n=5))
eq_hUsing the 1st derivative we derived above would not help us, since the order of the error is , which means that in the equation above, when dividing by , we would get .
Let’s use a trick: we repeat the expansion, but based on additional points that are and away from :
eq_2h = eq_h.subs(h, 2*h)
eq_2hNow we have two equations and two unknowns; we will solve the system step by step:
solve equation
eq_hfor the first derivative,solve equation
eq_2hfor the first derivative,equate the results of the first two steps and solve for the third derivative,
determine the method error,
determine the estimate of the derivative.
Let’s carry out the stated steps:
f3_cent_exact = sym.solve(
sym.Eq(sym.solve(eq_h, f(x).diff(x))[0], # step 1
sym.solve(eq_2h, f(x).diff(x))[0]), # step 2
f(x).diff(x,3))[0] # step 3
f3_cent_O = f3_cent_exact.expand().getO() # step 4
f3_cent_estimate = f3_cent_exact.expand().removeO() # step 5The estimate of the 3rd derivative is:
f3_cent_estimateLet’s confirm that the method error is of second order:
f3_cent_ODerivative ¶
Let’s repeat a procedure similar to the one for the 3rd derivative, but for the 4th derivative we add the forward and backward Taylor series (up to order 6):
eq_h = sym.Eq(f(x+h)+f(x-h), total(n=6))
eq_hLet’s prepare an additional equation based on the points that are and away from :
eq_2h = eq_h.subs(h, 2*h)
eq_2hFrom the two equations we determine the 4th derivative:
f4_cent_exact = sym.solve(
sym.Eq(sym.solve(eq_h, f(x).diff(x,2))[0], # step 1
sym.solve(eq_2h, f(x).diff(x,2))[0]), # step 2
f(x).diff(x,4))[0] # step 3
f4_cent_O = f4_cent_exact.expand().getO() # step 4
f4_cent_estimate = f4_cent_exact.expand().removeO() # step 5The estimate of the 4th derivative is:
f4_cent_estimateLet’s confirm that the method error is of second order:
f4_cent_OSummary of the central difference scheme¶
Above we derived the first four derivatives with a 2nd-order method error. The key point of the derivations above is that they give us the weights by which we must multiply the function values in order to compute the approximation of a given derivative. For this reason, we will collect these weights here. If you are impatient, you can jump to the table below. Continue reading if you want to learn how to organize the previously derived expressions by machine.
First let’s collect all the derivative estimates into a list:
derivatives = [f1_cent_estimate, f2_cent_estimate, f3_cent_estimate, f4_cent_estimate]
derivativesWe have 5 function values available (at locations ), which we put into a list:
function_values = [f(x-2*h), f(x-h), f(x), f(x+h), f(x+2*h)]We compute the weight of the first derivative for the function value :
f1_cent_estimate.expand().coeff(function_values[1])Now let’s generalize and compute the weights for all function values and for all derivative estimates:
central_diff_scheme = [[derivative.expand().coeff(fv) for fv in function_values] \
for derivative in derivatives]
central_diff_schemeThe summaries above can also be written in tabular form:
| Derivative Values | |||||
|---|---|---|---|---|---|
| 0 | -0.5 | 0 | 0.5 | 0 | |
| 0 | 1 | -2 | 1 | 0 | |
| -0.5 | 1 | 0 | -1 | 0.5 | |
| 1 | -4 | 6 | -4 | 1 |
The central difference scheme shown has a 2nd-order error .
Note: if you are interested in a generalization of the difference method, please see the findiff package.
Improved approximation - Richardson extrapolation¶
If the exact derivative is computed as:
where is the numerically computed derivative at the point and is the error estimate.
For a method of accuracy order : at step the following holds:
where is an unknown constant.
If we halve the step and assume that does not change, the following holds:
From both equations we eliminate the constant and determine the improved approximation:
Example¶
Let’s look at the example (the analytical derivative is: ):
x = np.linspace(0, 2*np.pi, 9)
y = np.sin(x)At step we have the function values defined at:
xarray([0. , 0.78539816, 1.57079633, 2.35619449, 3.14159265,
3.92699082, 4.71238898, 5.49778714, 6.28318531])The numerical derivative at :
x[4]and at step is
h = x[1] - x[0]
f_estimate_h = (-0.5*y[3] + 0.5*y[5])/h
f_estimate_hThe computation at step :
h2 = x[2] - x[0]
f_estimate_2h = (-0.5*y[2] + 0.5*y[6])/h2
f_estimate_2hLet’s compute the improved estimate for :
f_estimate_improved = (2**2 * f_estimate_h - f_estimate_2h)/(2**2-1)
f_estimate_improvedWe see that the improved estimate is closest to the theoretical value .
Non-central difference scheme¶
The central difference scheme we learned above is very useful and relatively accurate. However, since we cannot always use it (for example, at the beginning or end of the table), we have to rely on non-central difference schemes for computing derivatives.
We have:
the forward difference scheme, which approximates the derivative at a point with the function values at the following points, and
the backward difference scheme, which approximates the derivative at a point with the values at the preceding points.
The derivations are similar to what we showed for the central difference scheme, so we will not treat them here and will only show the final result.
Forward difference scheme¶
Forward difference scheme with error order :
| Derivative Values | |||||
|---|---|---|---|---|---|
| -1 | 1 | 0 | 0 | 0 | |
| 1 | -2 | 1 | 0 | 0 | |
| -1 | 3 | -3 | 1 | 0 | |
| 1 | -4 | 6 | -4 | 1 |
Forward difference scheme with error order :
| Derivative Values | ||||||
|---|---|---|---|---|---|---|
| -3 | 4 | -1 | 0 | 0 | 0 | |
| 2 | -5 | 4 | -1 | 0 | 0 | |
| -5 | 18 | -24 | 14 | -3 | 0 | |
| 3 | -14 | 26 | -24 | 11 | -2 |
Backward difference scheme¶
Backward difference scheme with error order :
| Derivative Values | |||||
|---|---|---|---|---|---|
| 0 | 0 | 0 | -1 | 1 | |
| 0 | 0 | 1 | -2 | 1 | |
| 0 | -1 | 3 | -3 | 1 | |
| 1 | -4 | 6 | -4 | 1 |
Backward difference scheme with error order :
| Derivative Values | ||||||
|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 1 | -4 | 3 | |
| 0 | 0 | -1 | 4 | -5 | 2 | |
| 0 | 3 | -14 | 24 | -18 | 5 | |
| -2 | 11 | -24 | 26 | -14 | 3 |
Using numpy.gradient¶
To compute numerical derivatives (2nd-order central difference scheme) we can also use numpy.gradient() (documentation):
gradient(f, *varargs, **kwargs)where f represents a table of values (in the form of a numerical array) of the function whose derivative we are looking for. f can be one or more dimensions. The positional parameters varargs define the spacing between the values of the argument of the function f; the default value is 1. This value can be a scalar, but it can also be a list of values of the independent variable (or even a combination of both). At the edges, the gradient method uses the forward or backward scheme; the parameter edge_order defines the order of the scheme used at the edges (we can choose between 1 or 2, the default value is 1).
The result of the gradient function is a numerical array (or an array of numerical arrays) with the computed derivatives.
For details see the documentation.
For differentiating a function (and not a table of values), see: scipy.differentiate.
Example¶
We will look at an example of how to use the weights, the gradient function, and the special handling at the edges. First we prepare a table of data:
x, h = np.linspace(0, 1, 20, retstep=True)
y = np.sin(2*np.pi*x)Weights of the difference schemes:
central = np.array([-0.5, 0, 0.5]) # weights of the central scheme; see also the findiff package (below)
forward = np.array([-3/2, 2, -1/2])
backward = np.array([1/2, -2, 3/2])Now we compute the derivative of the interior points (the first way is with list comprehensions, the second is vectorized):
derivative_interior = np.array([y[i-1:i+2] @ central/h for i in range(1, len(x)-1)]) # list comprehension
derivative_interior = np.convolve(y, central[::-1], mode='valid') / h # vectorizedAt the edges we use the forward or backward difference scheme:
derivative_first = y[:len(forward)] @ forward / h # forward
derivative_last = y[-len(backward):] @ backward / h # backwardLet’s assemble the result:
derivative_full = np.hstack([derivative_first, derivative_interior, derivative_last])We display the result together with the result of the np.gradient function:
import matplotlib.pyplot as plt
%matplotlib inline
plt.plot(x, derivative_full, 'ko', lw=3, label='own implementation')
plt.plot(x, np.gradient(y, h), 'g.', label='np.gradient, edge_order=1')
plt.plot(x, np.gradient(y, h, edge_order=2), 'r.', label='np.gradient, edge_order=2')
plt.legend();
Using numpy.diff¶
Let’s also present here the frequently used function numpy.diff (documentation), which computes the n-th discrete difference along a given axis. The first difference is given as . This is the basic operation for numerical differentiation (it is a first-order accurate derivative), but we have to divide the result by the step .
When using np.diff, the length of the array is reduced by 1.
x_d = np.linspace(0, 2*np.pi, 20)
y_d = np.sin(x_d)
h_d = x_d[1] - x_d[0]
# First derivative with np.diff (forward difference)
dy = np.diff(y_d) / h_d
x_diff = x_d[:-1] # the x axis shrinks
plt.figure()
plt.plot(x_d, np.cos(x_d), label='Exact derivative')
plt.plot(x_diff, dy, 'o', label='np.diff approximation')
plt.legend()
plt.title('Using np.diff for differentiation')
plt.show()
Round-off error in numerical differentiation¶
Above we focused on the method error. In numerical differentiation, however, we must also pay very close attention to the round-off (or representation) error! Let’s look at the first derivative (using the central difference scheme) written with the method error () and the round-off error :
In the worst case the round-off errors add up and the total error is:
When is large, the method error dominates; when is small, the round-off error dominates. The error has a minimum when:
It follows that:
Example¶
Below we will look at an example where we vary the precision in three steps:
float16- 16-bit format: sign 1 bit, exponent 5 bits, mantissa 10 bitsfloat32- 32-bit format: sign 1 bit, exponent 8 bits, mantissa 23 bitsfloat64- 64-bit format: sign 1 bit, exponent 11 bits, mantissa 52 bits (this is the default precision).
For more about types in numpy see the documentation
Now let’s determine the basic round-off error for each type:
eps16 = np.finfo(np.float16).eps
eps32 = np.finfo(np.float32).eps
eps64 = np.finfo(np.float64).eps
print(f'The basic round-off error for the types \
`float16`, `float32`, and `float64` is:\n{[eps16, eps32, eps64]}')The basic round-off error for the types `float16`, `float32`, and `float64` is:
[np.float16(0.000977), np.float32(1.1920929e-07), np.float64(2.220446049250313e-16)]
As an example let’s look at addition: we add half of the basic round-off error eps16 to the number 1. and convert to the type float16; we find that the new value is still equal to the value 1.:
(1.+eps16/2).astype('float16')np.float16(1.0)First let’s define the function , which will give a result with the precision defined by the parameter dtype:
def fun(x, dtype=float): # function
return np.exp(-dtype(x)).astype(dtype)Let’s also define a function for the analytically determinable derivative (we will need this later to determine the relative error):
def f1_fun(x): # "exact" derivative of the function
return -np.exp(-x)Similarly to addition above, we can also see for the function value that a change in the value smaller than leads to the same result:
fun(1., dtype=np.float16)np.float16(0.368)fun(1+eps16/2, dtype=np.float16)np.float16(0.368)Now let’s use the central difference scheme for the first derivative. Here the numbers should be written with precision dtype, and with the help of the exact derivative the relative error is also computed:
def f1_CDS(fun, x, h, dtype=np.float64):
f1_estimate = (fun(x+h, dtype=dtype)-fun(x-h,dtype=dtype))/(2*dtype(h))
f1_exact = f1_fun(x)
relative_error = (f1_exact - f1_estimate) / f1_exact
return f1_estimate, relative_errorLet’s look at an example of the derivative at (the Python function returns the value and the relative error):
f1_CDS(fun, x=1., h=.01, dtype=np.float16)(np.float16(-0.3662), np.float64(0.004535463210798897))f1_CDS(fun, x=1., h=.01, dtype=np.float64)Now let’s define the step:
h=0.25**np.arange(30)
h[:10]array([1.00000000e+00, 2.50000000e-01, 6.25000000e-02, 1.56250000e-02,
3.90625000e-03, 9.76562500e-04, 2.44140625e-04, 6.10351562e-05,
1.52587891e-05, 3.81469727e-06])We compute the derivative estimate for various precisions (because of division by 0 we get a warning):
f1_16 = f1_CDS(fun, x=1., h=h, dtype=np.float16)
f1_32 = f1_CDS(fun, x=1., h=h, dtype=np.float32)
f1_64 = f1_CDS(fun, x=1., h=h, dtype=np.float64)/tmp/ipykernel_355507/3908051182.py:2: RuntimeWarning: invalid value encountered in divide
f1_estimate = (fun(x+h, dtype=dtype)-fun(x-h,dtype=dtype))/(2*dtype(h))
We plot the various types as a function of the step size . First we import the required libraries:
import matplotlib.pyplot as plt
%matplotlib inlineLet’s define the figure:
def fig_estimate():
plt.semilogx(h, f1_16[0], 'b', lw=3, alpha=0.5, label='float16')
plt.semilogx(h, f1_32[0], 'r', lw=3, alpha=0.5, label='float32')
plt.semilogx(h, f1_64[0], 'g', lw=3, alpha=0.5, label='float64=float')
plt.title('Derivative estimate for various precision types')
plt.xlabel('$h$')
plt.ylabel('Derivative estimate: $-\\exp(-1)=−0.3678794$')
plt.ylim(-0.37, -0.365)
plt.legend();Let’s display it:
fig_estimate()
At a relatively large step the method error dominates, while at a small step the round-off error dominates; the optimal step can be estimated based on:
In this particular case:
It follows that:
We compute a suitable step for 16-, 32-, and 64-bit format:
np.power(3*eps16*np.exp(1),1/3)np.power(3*eps32*np.exp(1),1/3)np.power(3*eps64*np.exp(1),1/3)The 64-bit format performs best, but even with it a step smaller than about 1e-5 is not recommended!
Complex-step derivative approximation¶
The complex-step derivative approximation is useful when we have any analytic function defined but do not have an analytical expression for the first derivative; for details see (source). The idea stems from the Taylor series expansion of the function , but the step is taken in the direction of the imaginary axis ():
If we now assume that the function maps real values to the real axis and that and are real values, then we can derive:
From the first equation (above) we then derive the expression for the first derivative:
The result is in a way very surprising. Above, with a one-step forward scheme, we managed to obtain accuracy , while here it is !
An important advantage of the complex-step method is that it has no problem with subtracting nearly equal numbers (cancellation error), which plagues classical difference methods at very small steps . Therefore we can choose an extremely small (e.g., 10-200) and obtain a practically exact result (down to machine precision).
If we now look at the use of the method on the example from above, , let’s first recall the exact result at the value :
f1_exact0When called with a step we get two (three) correct digits (similarly to the central difference scheme), and by reducing the step to one tenth we would roughly double the number of correct digits.
h1_complex = 0.1
y1_complex = np.exp(-(1.+1.j*h1_complex))
f1_complex = np.imag(y1_complex)/h1_complex
f1_complexSince we have no problem with subtracting nearly equal numbers, we can reduce the step extremely and obtain an exact result (down to machine precision):
h1_complex = 1e-10
y1_complex = np.exp(-(1.+1.j*h1_complex))
f1_complex = np.imag(y1_complex)/h1_complex
f1_complexnp.sin(2*x0*1e-5j)array([0.+0.e+00j, 0.+2.e-05j, 0.+4.e-05j])np.sin(x0**2+2*x0*1e-5j)array([ 0. +0.00000000e+00j, 0.84147098+1.08060461e-05j,
-0.7568025 -2.61457448e-05j])Let’s also look at an example of differentiating the function sin(x**2). From the code and the figure below we can see a substantially faster convergence of the complex-step method. Why does the method work so well? If we substitute into , we get:
Since is very small, is negligible in the real part, while the imaginary part carries linearly through the sine. In this way we obtain an approximation of the theoretical derivative . The method sees the derivative in the imaginary part without subtraction, so no round-off error occurs.
import matplotlib.pyplot as plt
import numpy as np
# Function: sin(x^2)
def f(x):
return np.sin(x**2)
# Analytical derivative: 2x * cos(x^2)
def df_analytical(x):
return 2*x * np.cos(x**2)
x0 = 1.5
exact = df_analytical(x0)
h_vals = np.logspace(-10, 0, 100)
err_fd = []
err_cs = []
for h in h_vals:
# Finite difference (Forward)
fd_approx = (f(x0 + h) - f(x0)) / h
err_fd.append(abs(fd_approx - exact))
# Complex step
# f(x + ih) = sin((x+ih)^2) ≈ sin(x^2 + 2ixh)
# ≈ sin(x^2) + i * 2xh * cos(x^2)
# Imag/h gives us exactly 2x*cos(x^2)
cs_approx = f(x0 + 1j*h).imag / h
err_cs.append(abs(cs_approx - exact))
plt.figure(figsize=(10, 6))
plt.loglog(h_vals, err_fd, 'r-o', label='Finite difference', markersize=4)
plt.loglog(h_vals, err_cs, 'b--', label='Complex step', linewidth=2)
plt.xlabel('Step $h$')
plt.ylabel('Absolute error')
plt.title(r'Error in differentiating $f(x)=\sin(x^2)$')
plt.grid(True, which="both", alpha=0.5)
plt.legend()
plt.show()