51. Numerical solution of ordinary differential equations: High order Runge-Kutta methods#

André Massing

Date: March 17, 2021

If you want to have a nicer theme for your jupyter notebook, download the cascade stylesheet file tma4125.css and execute the next cell:

import matplotlib.pyplot as plt
import numpy as np
from IPython.core.display import HTML
from numpy import pi
from numpy.linalg import norm, solve


def css_styling():
    try:
        with open("tma4125.css") as f:
            styles = f.read()
            return HTML(styles)
    except FileNotFoundError:
        pass  # Do nothing


# Comment out next line and execute this cell to restore the default notebook style
css_styling()

As always, we start by import some important Python modules.

%matplotlib inline


# Use a funny plotting style
plt.xkcd()
newparams = {
    "figure.figsize": (6.0, 6.0),
    "axes.grid": True,
    "lines.markersize": 8,
    "lines.linewidth": 2,
    "font.size": 14,
}
plt.rcParams.update(newparams)

52. Runge-Kutta Methods#

In the previous lectures we introduced Euler’s method and Heun’s method as particular instances of the One Step Methods, and we presented the general error theory for one step method.

In this Lecture, we introduce a large family of the one step methods which go under the name Runge-Kutta methods (RKM). We will see that Euler’s method and Heun’s method are instance of RKMs.

52.1. Derivation of Runge-Kutta Methods#

For a given time interval \(I_i = [t_i, t_{i+1}]\) we want to compute \(y_{i+1}\) assuming that \(y_i\) is given. Start from the exact expression

\[ y(t_{i+1}) - y(t_i) = \int_{t_i}^{t_{i+1}} f(t, y(t)){\,\mathrm{d}t}, \]

The idea is now to approximate the integral by some quadrature rule \(\mathrm{Q}[\cdot](\{\xi_j\}_{j=1}^s,\{b_j\}_{j=1}^s)\) defined on \(I_i\). Then we get

\[ \begin{equation} y(t_{i+1}) - y(t_i) = \int_{t_i}^{t_{i+1}} f(t, y(t)){\,\mathrm{d}t} \label{_auto1} \tag{1} \end{equation} \]
\[ \begin{equation} \approx \tau \sum_{j=0}^s b_j f(\xi_j, y(\xi_j)) \label{_auto2} \tag{2} \end{equation} \]

Now we can define \(\{c_j\}_{j=1}^s\) such that \(\xi_j = t_{i} + c_j \tau\) for \(j=1,\ldots,s\)

52.2. Exercise 1: A first condition on \(b_j\)#

Question: What value do you expect for \(\sum_{j=1}^s b_{j}\)?

Choice A: \(\sum_{j=1}^s b_{j} = \tau\)

Choice B: \(\sum_{j=1}^s b_{j} = 0\)

Choice C: \(\sum_{j=1}^s b_{j} = 1\)

In contrast to pure numerical integration, we don’t know the values of \(y(\xi_j)\). Again, we could use the same idea to approximate

\[ y(\xi_j) - y(t_i) = \int_{t_i}^{t_i+c_j \tau} f(t, y(t)){\,\mathrm{d}t} \]

But then again we get a closure problem if we choose new quadrature points. The idea is now to not introduce even more new quadrature points but to use same \(y(\xi_j)\) to avoid the closure problem. Note that this leads to an approximation of the integrals \(\int_{t_i}^{t_i+c_j \tau}\) with possible nodes outside of \([t_i, t_i + c_j \tau ]\).

This leads us to

\[ \begin{equation} y(\xi_j) - y(t_i) = \int_{t_i}^{t_i+c_j \tau} f(t, y(t)){\,\mathrm{d}t} \label{_auto3} \tag{3} \end{equation} \]
\[ \begin{equation} \approx c_j \tau \sum_{l=1}^{s} \tilde{a}_{jl} f(\xi_l, y(\xi_l)) \label{_auto4} \tag{4} \end{equation} \]
\[ \begin{equation} = \tau \sum_{l=1}^{s} {a}_{jl} f(\xi_l, y(\xi_l)) \label{_auto5} \tag{5} \end{equation} \]

where we set \( c_j \tilde{a}_{jl} = a_{jl}\).

52.3. Exercise 2: A first condition on \(a_{jl}\)#

Question: What value do you expect for \(\sum_{l=1}^s a_{jl}\)?

Choice A: \(\sum_{l=1}^s a_{jl} = \tfrac{1}{c_j}\)

Choice B: \(\sum_{l=1}^s a_{jl} = c_j \)

Choice C: \(\sum_{l=1}^s a_{jl} = 1 \)

Choice D: \(\sum_{l=1}^s a_{jl} = \tau \)

52.4. Definition 1: Runge-Kutta methods#

Given \(b_j\), \(c_j\), and \(a_{jl}\) for \(j,l = 1,\ldots s\), the Runge-Kutta method is defined by the recipe

\[ \begin{equation} Y_{j} = y_i + \tau \sum_{l=1}^{s} {a}_{jl} f(t_i + c_l \tau, Y_l) \quad \text{for } j = 1,\ldots s, \label{eq:rk-stages} \tag{6} \end{equation} \]
\[ \begin{equation} \label{eq:rk-final} \tag{7} y_{i+1} = y_i + \tau \sum_{j=1}^s b_j f(t_i + c_j \tau, Y_j) \end{equation} \]

Runge-Kutta schemes are often specified in the form of a Butcher table:

\[\begin{split} \begin{equation} \label{ode:eq:butchertable} \tag{8} \begin{array}{c|ccc} c_1 & a_{11} & \cdots & a_{1s} \\ \vdots & \vdots & & \vdots \\ c_s & a_{s1} & \cdots & a_{ss} \\ \hline & b_1 & \cdots & b_s \end{array} \end{equation} \end{split}\]

If \(a_{ij} = 0\) for \(j \geqslant i\) the Runge-Kutta method is called explicit. (Why?)

Note that in the final step, all the function evaluation we need to perform have already been performed when computing the stages \(Y_j\).

Therefore one often rewrite the scheme by introducing stage derivatives

\[ \begin{equation} k_l = f(t_i + c_l \tau, Y_l) \label{_auto6} \tag{9} \end{equation} \]
\[ \begin{equation} = f(t_i + c_l \tau, y_i + \tau \sum_{j=1}^{s} {a}_{lj} k_j) \quad j = 1,\ldots s, \label{_auto7} \tag{10} \end{equation} \]

so the resulting scheme will be (swapping index \(l\) and \(j\))

\[ \begin{equation} k_{j} = f(t_i + c_j \tau, y_i + \tau \sum_{l=1}^{s} {a}_{jl} k_l) \quad j = 1,\ldots s, \label{eq:rk-stage-derivatives} \tag{11} \end{equation} \]
\[ \begin{equation} \label{eq:rk-final-stage-derivatives} \tag{12} y_{i+1} = y_{i} + \tau \sum_{j=1}^s b_j k_j \end{equation} \]

52.5. Exercise 3: Butcher table for the explicit Euler#

Write down the Butcher table for the explicit Euler.

Solution. Define \(k_1 = f(t_i, y_i) = f(t_i + 0\cdot \tau, y_i + \tau \cdot 0 \cdot k_1)\). Then the explicit Euler step \(y_{i+1} = y_i + \tau k_1 = y_i + \tau \cdot 1 \cdot k_1\), and thus the Butcher table is given by

\[\begin{split} \begin{array}{c|c} 0 & 0 \\ \hline & 1 \end{array}. \end{split}\]

52.6. Exercise 4: The improved explicit Euler method#

We formally derive the explicit midpoint rule or improved explicit Euler method. Applying the midpoint rule to our integral representatio yields

\[ \begin{equation} y(t_{i+1}) - y(t_i) = \int_{t_i}^{t_{i+1}} f(t, y(t)){\,\mathrm{d}t} \label{_auto8} \tag{13} \end{equation} \]
\[ \begin{equation} \approx \tau f(t_i + \tfrac{1}{2}\tau, y(t_i + \tfrac{1}{2}\tau)) \label{_auto9} \tag{14} \end{equation} \]

Since we cannot determine the value \(y(t_i + \tfrac{1}{2}\tau)\) from this system, we approximate it using a half Euler step

\[ y(t_i + \tfrac{1}{2}\tau) \approx y(t_i) + \tfrac{1}{2}\tau f(t_i, y(t_i)) \]

leading to the scheme

\[ \begin{equation} y_{i+1/2} := y_i + \tfrac{1}{2}\tau f(t_i, y_i) \label{ode:eq:improved_euler_step_1} \tag{15} \end{equation} \]
\[ \begin{equation} y_{i+1} := y_i + \tau f(t_i + \tfrac{1}{2}\tau, y_{i+1/2}) \label{ode:eq:improved_euler_step_2} \tag{16} \end{equation} \]

a) Is this a one-step function? Can you define the increment function \(\Phi\)?

Solution. Yes it is, and it’s increment function is given by

\[ \Phi(t_i, y_i, y_{i+1}, \tau) = f(t_i + \tfrac{1}{2}\tau, y_i + \tfrac{1}{2}\tau f(t_i, y_i)) \]

b) Can you rewrite this as a Runge-Kutta method? If so, determine the Butcher table of it.

Solution. Define \(k_1\) and \(k_2\) as follows,

\[ \begin{equation} y_{i+1/2} := y_i + \tfrac{1}{2}\tau \underbrace{f(t_i, y_i)}_{=: k_1} \label{ode:eq:improved_euler_k1} \tag{17} \end{equation} \]
\[ \begin{equation} y_{i+1} := y_i + \tau f(t_i + \tfrac{1}{2}\tau, y_{i+1/2}) = y_i + \tau \underbrace{f(t_i + \tfrac{1}{2}\tau, y_{i} + \tau \tfrac{1}{2} k_1)}_{:= k_2}. \label{_auto10} \tag{18} \end{equation} \]

Then \begin{equation} y_{i+1} = y_i + \tau k_2 \label{ode:eq:improved_euler_k2} \tag{19} \end{equation}

Thus the Butcher table is given by

\[\begin{split} \begin{array}{c|c c} 0 & 0 & 0 \\ \tfrac{1}{2} & \tfrac{1}{2} & 0 \\ \hline & 0 & 1 \end{array} \end{split}\]

52.7. Implementation of explicit Runge-Kutta methods#

Below you will find the implementation a general solver class Explicit_Runge_Kutta which at its initialization takes in a Butcher table and has __call__ function

        def __call__(self, y0, f, t0, T, n):

and can be used like this

        # Define Butcher table
        a = np.array([[0, 0, 0],
                      [1.0/3.0, 0, 0],
                      [0, 2.0/3.0, 0]])
        
        b = np.array([1.0/4.0, 0, 3.0/4.0])
        
        c = np.array([0, 1.0/3.0, 2.0/3.0])
        # Create solver
        rk3 = Explicit_Runge_Kutta(a, b, c)
        
        # Solve problem (applies __call__ function)
        ts, ys = rk3(y0, t0, T, f, Nmax)

The complete implementation is given here:

class Explicit_Runge_Kutta:
    def __init__(self, a, b, c):
        self.a = a
        self.b = b
        self.c = c

    def __call__(self, y0, t0, T, f, Nmax):
        # Extract Butcher table
        a, b, c = self.a, self.b, self.c

        # Stages
        s = len(b)
        ks = [np.zeros_like(y0, dtype=np.double) for s in range(s)]

        # Start time-stepping
        ys = [y0]
        ts = [t0]
        dt = (T - t0) / Nmax

        while ts[-1] < T:
            t, y = ts[-1], ys[-1]

            # Compute stages derivatives k_j
            for j in range(s):
                t_j = t + c[j] * dt
                dY_j = np.zeros_like(y, dtype=np.double)
                for l in range(j):
                    dY_j += dt * a[j, l] * ks[l]

                ks[j] = f(t_j, y + dY_j)

            # Compute next time-step
            dy = np.zeros_like(y, dtype=np.double)
            for j in range(s):
                dy += dt * b[j] * ks[j]

            ys.append(y + dy)
            ts.append(t + dt)

        return (np.array(ts), np.array(ys))

52.8. Example 1: Implementation and testing of the improved Euler method#

We implement the improved explicit Euler from above and plot the analytical and the numerical solution. Finally, we determine the convergence order.

def compute_eoc(y0, t0, T, f, Nmax_list, solver, y_ex):
    errs = []
    for Nmax in Nmax_list:
        ts, ys = solver(y0, t0, T, f, Nmax)
        ys_ex = y_ex(ts)
        errs.append(np.abs(ys - ys_ex).max())
        print(f"For Nmax = {Nmax:3}, max ||y(t_i) - y_i||= {errs[-1]:.3e}")

    errs = np.array(errs)
    Nmax_list = np.array(Nmax_list)
    dts = (T - t0) / Nmax_list

    eocs = np.log(errs[1:] / errs[:-1]) / np.log(dts[1:] / dts[:-1])
    return errs, eocs
# Define Butcher table for improved Euler
a = np.array([[0, 0], [0.5, 0]])
b = np.array([0, 1])
c = np.array([0, 0.5])

# Define rk2
rk2 = Explicit_Runge_Kutta(a, b, c)

t0, T = 0, 1
y0 = 1
lam = 1

# rhs of IVP
f = lambda t, y: lam * y

# Exact solution to compare against
y_ex = lambda t: y0 * np.exp(lam * (t - t0))

# EOC test
Nmax_list = [4, 8, 16, 32, 64, 128]

errs, eocs = compute_eoc(y0, t0, T, f, Nmax_list, rk2, y_ex)
print(errs)
print(eocs)
For Nmax =   4, max ||y(t_i) - y_i||= 2.343e-02
For Nmax =   8, max ||y(t_i) - y_i||= 6.441e-03
For Nmax =  16, max ||y(t_i) - y_i||= 1.688e-03
For Nmax =  32, max ||y(t_i) - y_i||= 4.322e-04
For Nmax =  64, max ||y(t_i) - y_i||= 1.093e-04
For Nmax = 128, max ||y(t_i) - y_i||= 2.749e-05
[2.34261385e-02 6.44058991e-03 1.68830598e-03 4.32154479e-04
 1.09316895e-04 2.74901378e-05]
[1.86285442 1.93161644 1.96595738 1.98303072 1.99153035]

52.9. Exercise 5#

If you have time, determine the experimental order of convergence EOC for the following methods:

Heun’s method with 3 stages $\( \begin{array}{c|c c c} 0 & 0 & 0 & 0 \\ \tfrac{1}{3} & \tfrac{1}{3} & 0 & 0 \\ \tfrac{2}{3} & 0 & \tfrac{2}{3} & 0 \\ \hline & \tfrac{1}{4} & 0 & \tfrac{3}{4} \end{array} \)$

# Insert code here

The classical Runge-Kutta method with 4 stages $\( \begin{array}{c|c c c c} 0 & 0 & 0 & 0 & 0 \\ \tfrac{1}{2} & \tfrac{1}{2} & 0 & 0 & 0 \\ \tfrac{1}{2} & 0 & \tfrac{1}{2} & 0 & 0 \\ 1 & 0 & 0 & 1 & 0 \\ \hline & \tfrac{1}{6} & \tfrac{1}{3}& \tfrac{1}{3} & \tfrac{1}{6} \end{array} \)$

# Insert code here

52.10. Order conditions for Runge-Kutta Methods#

The convergence theorem for one-step methods gave us some necessary conditions to guarantee that a method is convergent order of \(p\):

  • “consistency order \(p\)” + “Increment function satisfies a Lipschitz condition” \(\Rightarrow\) “convergence order \(p\).

To put it in slightly different words:

  • “local truncation error behaves like \(\mathcal{O}(\tau^{p+1})\)” + “Increment function satisfies a Lipschitz condition” \(\Rightarrow\) “global truncation error behaves like \(\mathcal{O}(\tau^{p})\)

It turns out that for \(f\) is at least \(C^1\) with respect to all its arguments then the increment function \(\Phi\) associated with any Runge-Kutta methods satisfies a Lipschitz condition.

52.11. Theorem 1: Order conditions for Runge-Kutta methods#

A Runge - Kutta method has consistency order $p$ if and only if all the conditions up to and including $p$ in the table below are satisfied.
\[\begin{split} \begin{array}{c|c|c} p & \text{conditions} \\ \hline 1 & \sum b_i = 1 \\ \hline 2 & \sum b_i c_i = 1/2 \\ \hline 3 & \sum b_i c_i^2 = 1/3\\ & \sum b_i a_{ij} c_j = 1/6 \\ \hline 4 & \sum b_ic_i^3=1/4 \\ & \sum b_i c_i a_{ij}c_j=1/8 \\ & \sum b_i a_{ij}c_j^2=1/12 \\ & \sum b_i a_{ij} a_{jk} c_k = 1/24 \\ \hline \end{array} \end{split}\]

where sums are taken over all the indices from 1 to \(s\).

Proof. Without proof.

52.12. Theorem 2: Convergence theorem for Runge-Kutta methods#

Given the IVP ${\boldsymbol y}' = {\boldsymbol f}(t, {\boldsymbol y}), {\boldsymbol y}(0) = {\boldsymbol y}_0$. Assume $f \in C^1$ and that a given Runge-Kutta method satisfies the order conditions from Theorem [Theorem 1: Order conditions for Runge-Kutta methods](#thm:rk-order-conditions) up to order $p$. Then the Runge-Kutta method is convergent of order $p$.