Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Introduction

As an introduction, let’s watch the video below:

Loading...

In root finding we consider an arbitrary equation that depends on the variable xx and we look for the solution:

f(x)=0.f(x)=0.

The solutions of the equation are also called roots. A root of the equation f(x)=0f(x)=0 is at the same time a zero of the function y=f(x)y=f(x).

The function y=f(x)y=f(x) can have zeros of order:

  • first-order zero: the function crosses the horizontal axis at a nonzero angle,

  • even-order zeros: the function touches the horizontal axis but does not cross it,

  • odd-order zeros: the function crosses the horizontal axis; for a zero of order 3 or higher there is an inflection point (the tangent is parallel to the horizontal axis).

It is important to point out here that we are looking for the solution of an arbitrary equation f(x)=0f(x)=0. While for linear, quadratic, or cubic equations we can determine analytical solutions, for most nonlinear equations an analytical solution cannot be found. For this reason numerical approaches are all the more important.

Restrictions on the function f(x)f(x)

We require that the function y=f(x)y=f(x) be continuous on the closed interval [x0,x1][x_0, x_1]. When computing zeros, we will restrict ourselves only to first-order zeros.

Example

An arbitrary function y=f(x)y=f(x) can be defined with a Python function; as an example, let’s define a polynomial here:

Since this is the polynomial x3−10x2+5x^3-10x^2+5 with coefficients [1, -10, 0, 5], it is better to define it using np.poly1d:

numpy.poly1d(c_or_r, r=False, variable=None)

where the parameters are:

  • c_or_r the coefficients of the polynomial in order of decreasing power, or, if r=True, the roots of the polynomial,

  • r is False by default, which means that the polynomial coefficients are given,

  • variable the variable that is printed when using the print() function.

Note: np.poly1d belongs to the older interface; the more modern one is the class numpy.polynomial.Polynomial (documentation), which expects the coefficients in increasing order of powers: Polynomial([5, 0, -10, 1]). The object is likewise called as a function, and the zeros are obtained with the method roots().

Let’s import numpy and define the polynomial:

   3      2
1 x - 10 x + 5

Let’s plot the function f(x)f(x):

<Figure size 640x480 with 1 Axes>

We notice that the zeros of the function f(x)f(x) are near -0.7 and +0.7. The poly1d object has an attribute roots, or also r (see the documentation), which returns these zeros:

array([ 9.94949106, 0.73460351, -0.68409457])

In what follows, our goal will be to numerically determine a zero for an arbitrary function f.

Incremental method

Incremental solving is based on the idea that if the function f(x)f(x) has different signs at x0x_0 and x1x_1, then there is at least one zero in between. We therefore divide the closed interval [x0,x1][x_0, x_1] into segments of width Δx\Delta x; on the segment where we observe a sign change, there is at least one zero of the function.

The method is shown in the figure. Incremental method

For a zero we require:

∣xi+1−xi∣<εin∣f(xi+1)∣+∣f(xi)∣<D,\left|x_{i+1}-x_i\right|<\varepsilon\quad\textrm{in}\quad \left|f(x_{i+1})\right|+\left|f(x_{i})\right|<D,

where ε\varepsilon is the required accuracy of the solution and DD is a chosen small value that prevents us from identifying a pole as a zero (which is otherwise not possible because of the continuity condition).

The incremental method has some drawbacks:

  • it is very slow,

  • it can miss two zeros that are very close to each other,

  • it does not detect multiple even-order zeros (a local extremum that only touches the horizontal axis).

The incremental method belongs to the so-called bracketed methods, since it looks for zeros of the function only on the interval [x0,x1][x_0, x_1]. Later we will also learn about open methods, which can converge to a zero outside the given interval.

Because of all the drawbacks listed above, the incremental method is often used only to compute an initial estimate of a zero.

Numerical implementation

Let’s now look at the incremental search for zeros of a function:

Let’s now look at the usage on the polynomial defined above:

array([0.734, 0.735])

The zero is isolated with an accuracy of 0.001; let’s also check the sum of the absolute function values:

np.float64(0.013071529000000304)

We find that it is relatively small; with the following methods we will, however, try to substantially improve the result.

Let’s prepare the figure:

Let’s display the result:

<Figure size 640x480 with 1 Axes>

So in order to compute the solution on the interval [0,1][0, 1] with an accuracy of Δx=0.001\Delta x=0.001, we had to call the function f(x)f(x) 1000 times. This is a very inefficient method, so we will look for better approaches; first with a simple iterative incremental approach.

Iterative incremental method

In the first iteration, the iterative incremental method uses the incremental method to bracket the search interval for zeros at a relatively large step. The interval found in the first iteration is divided into smaller intervals in the second iteration, and the incremental search for the zero is repeated. The third iteration then restricts itself to the interval determined in the second, and so on. We stop iterating once we have reached the prescribed accuracy of the solution ϵ\epsilon.

The method is shown in the figure: Iterative incremental method

Numerical implementation

With 30 calls to the function f(x)f(x) we thus obtain accuracy similar to the previous 1000:

array([0.734, 0.735])
array([0.734, 0.735])

Of course, we can substantially improve the accuracy by increasing the number of iterations:

array([0.7346035 , 0.73460351])

Let’s also check the criterion of the sum of the absolute function values, which must be small:

np.float64(1.3073143190212022e-07)

Bisection method

On the interval [x0,x1][x_0, x_1], where we know that a zero of the function exists (the signs of f(x0)f(x_0) and f(x1)f(x_1) differ), we can use the bisection method.

The idea of the method is:

  • divide the interval [x0,x1][x_0, x_1] in half (hence the name: bi-section): x2=(x0+x1)/2x_2 = (x_0+x_1)/2,

  • if f(x0)f(x_0) and f(x2)f(x_2) have different signs, the new search interval for the zero is [x0,x2][x_0, x_2], otherwise: [x2,x1][x_2, x_1],

  • based on the previous step, we define a new closed interval [x0,x1][x_0, x_1] and continue the iterative procedure until we reach the desired accuracy ∣x1−x0∣<ε\left|x_1-x_0\right|<\varepsilon.

Figure of the method: Bisection method

The bisection method belongs to the bracketed methods, which return a zero of the function on the given interval [x0,x1][x_0, x_1].

Error estimate

If we start with the interval Δx=∣x1−x0∣\Delta x = \left|x_1-x_0\right|, then the accuracy of the bisection method after the first bisection step is:

ε1=Δx/2,\varepsilon_1 = \Delta x/2,

after the second step:

ε2=Δx/22\varepsilon_2 = \Delta x/2^2

and after nn steps:

εn=Δx/2n.\varepsilon_n = \Delta x/2^n.

Usually we require that the solution be given with an accuracy ε\varepsilon, and from the equation above we can derive the number of bisection steps required:

n=log⁡(Δxε)log⁡(2).n = \frac{\log\left(\frac{\Delta x}{\varepsilon}\right)}{\log(2)}.

Of course, the number of steps is an integer.

Numerical implementation

Now let’s try to find the zero with an accuracy of 1e-3:

Solution: 0.735, number of iterations: 10, D: 0.01277

In ten iterations we obtained the same accurate result as above with the iterative incremental method result30. Let’s also look at computing the zero with even greater accuracy:

Solution: 0.734603, number of iterations: 20, D: 0.00001

We can check the execution speed with the so-called magic function timeit (documentation), which runs the function several times and analyzes the execution time. If the magic function is preceded by the double symbol %%, it executes and times the entire cell, but if only a single %, then only one line.

348 μs ± 4.2 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Searching for a zero near a pole

Let’s now look at searching for a zero of the tan function near a pole (the zero does not actually exist):

<Figure size 640x480 with 1 Axes>

In the case of searching on the interval [−1,1][-1, 1] we find the correct zero:

Solution: -0.000, number of iterations: 11, D: 0.00098

In the case of searching near a pole, however, the program warns us about it (the function call is commented out here, otherwise the automatic generation is interrupted here):

The bisection method built into scipy does not have such a check (for the sake of speed) and will return a result, which will, however, be wrong. So we must be careful when using it.

Using scipy.optimize.root_scalar

The bisection method is a slow but reliable method for finding zeros and is implemented within scipy. First we import it:

root_scalar(f, args=(), method='bisect', bracket=[a,b], fprime=None, fprime2=None, x0=None, x1=None,xtol=None, rtol=None, maxiter=None, options=None)

To invoke the bisection method with the root_scalar function, we must pass three parameters to the function: the function f and the closed interval [a, b] (via the bracket parameter). The signs of f(a) and f(b) must be different. The other parameters, e.g. the absolute xtol and relative rtol error, and the maximum number of iterations maxiter, are optional - they have default values. For more, see the documentation.

Let’s look at the usage:

converged: True flag: converged function_calls: 41 iterations: 39 root: 0.7346035077880515 method: bisect

and the speed:

324 μs ± 2.83 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

We can also check the function tan(x), first on an interval where the function is continuous:

converged: True flag: converged function_calls: 3 iterations: 1 root: 0.0 method: bisect

Then also near a pole, where it is not continuous:

converged: True flag: converged function_calls: 42 iterations: 40 root: -1.5707963267941523 method: bisect

which is a wrong solution!

Secant method

The secant method requires two initial estimates x0x_0 and x1x_1 and a function f(x)f(x). Assuming linear interpolation between the points x0,f(x0)x_0, f(x_0) and x1,f(x1)x_1, f(x_1) (we draw a secant through the points, hence the name), x2x_2 is determined, where the linear interpolating function has a zero. x2x_2 represents the new estimate of the zero.

Based on the figure: Secant method

we can write (similar triangles are marked in yellow in the figure):

f(x1)x2−x1=f(x0)−f(x1)x1−x0.\frac{f(x_1)}{x_2 − x_1}= \frac{f(x_0) − f(x_1)}{x_1 − x_0}.

It follows that the new estimate of the zero is:

x2=x1−f(x1) x1−x0f(x1)−f(x0).x_2= x_1-f(x_1)\,\frac{x_1 − x_0}{f(x_1) - f(x_0)}.

In the next step of the secant method we perform the following substitutions: x0=x1x_0=x_1 and x1=x2x_1=x_2.

The secant method belongs to the open methods, since it can find a zero of the function that lies outside the domain [x0,x1][x_0, x_1].

Error estimate

Conservatively, we can estimate the error from the difference between two consecutive estimates:

ε=∣xn−1−xn∣\varepsilon = \left|x_{n-1} -x_{n}\right|

Convergence and order of convergence

Convergence means that the sequence of estimates converges to the solution of the equation α\alpha (α\alpha is the solution of the equation).

The order of convergence indicates the speed of convergence.

If we denote by ε\varepsilon the error of the estimate and reduce the error linearly with each iteration step (CC is a constant):

εn=C εn−11,\varepsilon_n = C\,\varepsilon_{n-1}^1,

we speak of order of convergence 1 (ε\varepsilon has the power 1)!

In the previously discussed bisection method, we halve the error at each step (εn/εn−1=C=1/2\varepsilon_n/\varepsilon_{n-1} = C = 1/2). The bisection method has order of convergence 1.

The order of convergence of the secant method is higher and can be estimated as:

εn=C εn−11.618.\varepsilon_n = C\,\varepsilon_{n-1}^{1.618}.

From the estimate above it follows that at each iteration step the number of correct digits increases by about 60%. Since the order of convergence is higher than 1 and lower than quadratic, such convergence is called superlinear convergence.

Numerical implementation

Let’s look at the usage:

1. step: x0=1, x1=0.555556.
2. step: x0=0.555556, x1=0.707845.
3. step: x0=0.707845, x1=0.737957.
4. step: x0=0.737957, x1=0.734549.
5. step: x0=0.734549, x1=0.734603.
6. step: x0=0.734603, x1=0.734604.
7. step: x0=0.734604, x1=0.734604.
Solution: 0.73460351, D: 0.00000

and the speed

119 μs ± 389 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

As we wrote above, the secant method is of the open type. The solution of the equation can be outside the given interval. Let’s look at an example:

1. step: x0=2, x1=1.41615.
2. step: x0=1.41615, x1=1.85165.
3. step: x0=1.85165, x1=1.69887.
4. step: x0=1.69887, x1=1.97485.
5. step: x0=1.97485, x1=2.09379.
6. step: x0=2.09379, x1=2.43519.
7. step: x0=2.43519, x1=2.76579.
8. step: x0=2.76579, x1=3.05013.
9. step: x0=3.05013, x1=3.13625.
10. step: x0=3.13625, x1=3.14158.
11. step: x0=3.14158, x1=3.14159.
Solution: 3.142, D: 0.00002

Using scipy.optimize.root_scalar

Within scipy the secant method is defined within the scipy.optimize.root_scalar function:

converged: True flag: converged function_calls: 7 iterations: 6 root: 0.7346035077893033 method: secant

In the case of the secant method, the second bound of the interval is computed according to the code:

if x0 >= 0:
    x1 = x0*(1 + 1e-4) + 1e-4
else:
    x1 = x0*(1 + 1e-4) - 1e-4

Let’s also look at the speed

153 μs ± 491 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Related to the secant method is Ridder’s method; we will not go into the details of the method here, but it is of the bracketed type, has a quadratic order of convergence, and we call it using the root_scalar function:

converged: True flag: converged function_calls: 14 iterations: 6 root: 0.7346035077883111 method: ridder

Brent’s method (brentq) is the default and recommended choice in Scipy for finding zeros on an interval. It combines the reliability of bisection with the speed of the secant method/inverse quadratic interpolation. Usage and speed:

converged: True flag: converged function_calls: 9 iterations: 8 root: 0.7346035077893034 method: brentq

A speed comparison shows that it is usually faster than bisection:

78.5 μs ± 184 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Scipy also supports the TOMS 748 algorithm, which is considered the asymptotically most efficient method for finding zeros on an interval:

converged: True flag: converged function_calls: 9 iterations: 4 root: 0.7346035077893033 method: toms748

A speed comparison (in most cases it should be the fastest):

530 μs ± 2.08 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

Newton’s method

The methods presented so far do not require additional derivatives of the function; Newton’s method, which we will look at next, requires one initial estimate x0x_0 and, in addition to the definition of the function f(x)f(x), also its derivative f′(x)f'(x). In the literature, Newton’s method is also found under the terms tangent method and Newton-Raphson method.

The working principle of the method is shown in the figure: Newton's method

We could derive the method graphically (from the figure), but here let’s look at the derivation using the Taylor series:

f(xi+1)=f(xi)+f′(xi) (xi+1−xi)+O((xi+1−xi)2),f(x_{i+1})=f(x_i)+f'(x_i)\,\left(x_{i+1}-x_i\right)+\mathcal{O}\left((x_{i+1}-x_i)^2\right),

if the value of the function at xi+1x_{i+1} is to be zero, then:

0=f(xi)+f′(xi) (xi+1−xi)+O((xi+1−xi)2).0=f(x_i)+f'(x_i)\,\left(x_{i+1}-x_i\right)+\mathcal{O}\left((x_{i+1}-x_i)^2\right).

We introduce a method error and neglect the higher-order terms in the Taylor series. We can derive:

xi+1=xi−f(xi)f′(xi).x_{i+1}=x_i-\frac{f(x_i)}{f'(x_i)}.

xi+1x_{i+1} is thus the new estimate of the sought zero.

The algorithm of Newton’s method is:

  1. we compute a new estimate xi+1x_{i+1},

  2. we stop the computation if the maximum number of iterations is reached (we have not found the solution of the equation),

  3. if ∣xi+1−xi∣<ε\left|x_{i+1}-x_i\right|<\varepsilon we stop the computation (we have computed an estimate of the zero), otherwise we increase the index ii and go to the first step.

Notes:

  • ε\varepsilon is the required absolute accuracy,

  • Newton’s method can diverge, so in the algorithm we prescribe a maximum number of iterations.

Above we mentioned that Newton’s method is one of the better methods for finding zeros of functions. It does, however, also have some drawbacks/limitations:

  • it belongs to the open methods,

  • quadratic convergence is guaranteed only in a sufficiently small neighborhood of the solution of the equation,

  • we must know the derivative of the function.

Order of convergence

The order of convergence of Newton’s method is quadratic:

εn=C εn−12,\varepsilon_n = C\,\varepsilon_{n-1}^{2},

where CC is:

C=−f′′(x)2 f′(x).C=-\frac{f''(x)}{2\,f'(x)}.

The convergence is therefore fast; in each new iteration the number of correct digits in the estimate doubles.

Numerical implementation

Let’s define the polynomial f and its first derivative df:

Let’s now compute the zero:

Solution: 0.73460351, number of iterations: 5, D: 0.00000000

Let’s check the execution speed:

4.04 μs ± 35.4 ns per loop (mean ± std. dev. of 7 runs, 100,000 loops each)

Using scipy.optimize.root_scalar

Within scipy Newton’s method is defined within the scipy.optimize.root_scalar function:

converged: True flag: converged function_calls: 10 iterations: 5 root: 0.7346035077893033 method: newton

And let’s measure the speed:

91.3 μs ± 284 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

If we also provide the second derivative fprime2 to root_scalar, Halley’s method is used, which is based on the expression:

xn+1=xn−2f(xn)f′(xn)2 f′2(xn)−f(xn)f′′(xn)x_{n+1}=x_{n}-\frac{2f(x_{n})f'(x_{n})}{2\,f'^{2}(x_{n})-f(x_{n})f''(x_{n})}
converged: True flag: converged function_calls: 10 iterations: 3 root: 0.7346035077893033 method: halley

And the speed:

62.3 μs ± 536 ns per loop (mean ± std. dev. of 7 runs, 10,000 loops each)

Solving systems of nonlinear equations

We solve a system of equations, which we write in vector form as follows:

f(x)=0.\mathbf{f}(\mathbf{x})=\mathbf{0}.

In scalar form we write the vector expression above as:

f0(x0,x1,…,xn−1)=0f1(x0,x1,…,xn−1)=0⋮fn−1(x0,x1,…,xn−1)=0.\begin{array}{rcl} f_0(x_0,x_1,\dots, x_{n-1})&=&0\\ f_1(x_0,x_1,\dots, x_{n-1})&=&0\\ &\vdots&\\ f_{n-1}(x_0,x_1,\dots, x_{n-1})&=&0.\\ \end{array}

Solving a system of nn nonlinear equations is significantly more demanding than solving a single nonlinear equation. Such a system of equations can have multiple solutions, and which one we compute depends on the initial conditions. Usually, a good choice of initial conditions is helped by the physical problem we are solving.

For computing the solution of a system of equations, Newton’s method turns out to be the simplest and often also the best (there are also other methods, which are, however, often variations of Newton’s method).

Similarly to the derivation of Newton’s method for solving a single equation, here too we start with the expansion of the function fif_i into a Taylor series:

fi(x+Δx)=fi(x)+∑j=0n−1∂fi∂xj Δxj+O(∥Δx∥2).f_i(\mathbf{x}+\Delta \mathbf{x})=f_i(\mathbf{x})+\sum_{j=0}^{n-1} \frac{\partial f_i}{\partial x_j}\,\Delta x_j+\mathcal{O}\left(\|\Delta\mathbf{x}\|^2\right).

We introduce a method error when we neglect the second- and higher-order terms and write the expression in matrix form:

f(x+Δx)=f(x)+J(x) Δx,\mathbf{f}(\mathbf{x}+\Delta \mathbf{x})=\mathbf{f}(\mathbf{x})+\mathbf{J}(\mathbf{x})\,\Delta \mathbf{x},

where J(x)\mathbf{J}(\mathbf{x}) is the Jacobian matrix at the values x\mathbf{x}. The elements of the Jacobian matrix are:

Jij=∂fi∂xj.J_{ij}=\frac{\partial f_i}{\partial x_j}.

If x+Δx\mathbf{x}+\Delta \mathbf{x} is to be a solution of the system of equations, then:

0=f(x)+J(x) Δx\mathbf{0}=\mathbf{f}(\mathbf{x})+\mathbf{J}(\mathbf{x})\,\Delta \mathbf{x}

and it therefore follows:

J(x) Δx=−f(x).\mathbf{J}(\mathbf{x})\,\Delta \mathbf{x}=-\mathbf{f}(\mathbf{x}).

We have derived a system of linear equations; the coefficient matrix is denoted by J(x)\mathbf{J}(\mathbf{x}), the vector of unknowns is Δx\Delta\mathbf{x}, and the vector of constants is −f(x).-\mathbf{f}(\mathbf{x}).

Note: analytical computation of the Jacobian matrix can be time-consuming, and therefore we often compute it approximately at x\mathbf{x} numerically:

∂fi∂xj≈fi(x+ej h)−fi(x)h,\frac{\partial f_i}{\partial x_j}\approx \frac{f_i(\mathbf{x}+\mathbf{e}_j\,h)-f_i(\mathbf{x})}{h},

where hh is a small displacement and ej\mathbf{e}_j is a unit displacement in the direction xjx_j. If the Jacobian matrix is computed numerically, we speak of the secant method and not Newton’s method.

For the numerical computation we can use the function scipy.optimize.approx_fprime (for details, see the documentation).

Numerical implementation

The algorithm is therefore:

  1. We choose an initial estimate x0\mathbf{x}_0, a maximum number of iterations, and set the index to zero: i=0i=0.

  2. We compute the Jacobian matrix J(xi)\mathbf{J}(\mathbf{x_i}) and solve the linear system: J(xi) Δxi=−f(xi)\mathbf{J}(\mathbf{x}_i)\,\Delta \mathbf{x}_i=-\mathbf{f}(\mathbf{x}_i).

  3. We compute a new estimate: xi+1=xi+Δxi\mathbf{x}_{i+1}=\mathbf{x}_{i}+\Delta\mathbf{x}_i.

  4. If the error is smaller than required, the procedure stops*. We also stop the procedure if the number of iterations is greater than allowed, otherwise we increase the index i=i+1i=i+1 and return to step 2.

* Note:

We can estimate the error by the norm of the difference of two consecutive estimates:

∑j=0n−1∣xi,j−xi−1,j∣<ε,\sum_{j=0}^{n-1}\left|x_{i,j}-x_{i-1,j}\right|<\varepsilon,

where ii is the iteration index and jj is the element index.

Using scipy.optimize.root

The function scipy.optimize.root has extensive documentation and enables a number of different approaches:

root(fun, x0, args=(), method='hybr', jac=None, tol=None, callback=None, options=None)

If we use the default parameters, we only need to define the vector function fun and the initial value x0.

Let’s import the function:

Let’s look at the usage on an example (this is an example on p. 76, Jože Petrišič, Reševanje enačb, 1996, FS, UNI-LJ):

f0(x)=x02+x0 x1−10=0,f_0(\mathbf{x})=x_0^2+x_0\,x_1-10=0,
f1(x)=x1+3 x0 x12−57=0,f_1(\mathbf{x})=x_1+3\,x_0\,x_1^2-57=0,

where the vector is x=[x0,x1]\mathbf{x}=[x_0, x_1].

First let’s define a Python function that returns the list of function results [f0(x),f1(x)][f_0(\mathbf{x}),f_1(\mathbf{x})]:

Let’s also define the Jacobian matrix:

Let’s use the initial values x0=1.5x_0=1.5 and x1=3.5x_1=3.5 and solve the problem:

message: The solution converged. success: True status: 1 fun: [-6.972e-12 3.148e-11] x: [ 2.000e+00 3.000e+00] method: hybr nfev: 12 njev: 1 fjac: [[-2.235e-01 -9.747e-01] [ 9.747e-01 -2.235e-01]] r: [-2.954e+01 -3.782e+01 -6.940e+00] qtf: [-2.836e-08 -1.324e-08]

The root function returns an extensive result. The most important are the attribute x, which represents the sought solution, and the attribute success, which tells whether the solution has converged:

array([2., 3.])
True
<Figure size 800x600 with 1 Axes>

Additional

Those of you who are enthusiastic about the Raspberry Pi and use their camera (e.g. this one without the infrared filter) might be interested in the picamera library.

Using sympy.solve to solve equations

For smaller systems we can also find the solution symbolically. Let’s look at the example above:

Loading...
Number of solutions: 4
First solution: (2, 3)