5: Multistep methods.#
The methods that we have considered so far are called single-step methods because the point \((x_{i+1}, y_{i+1})\) is computed using only information from the previous step, namely \((x_i, y_i)\). In particular, the methods have all depended on a formula of the form
These methods are also called starting methods because the initial-value condition \(y(x_0)=y_0\) gives enough information to use these methods from the start. On the other hand, multistep methods use more than one of the previously computed \(y_j\). An \(m\)-step method would use the previous \(m\) values: \((x_i,y_i), (x_{i-1},y_{i-1}),\dots, (x_{i-m+1},y_{i-m+1})\). A linear \(m\)-step method takes the form
Clearly such a method cannot be used until \(m\) previous values are known. For this reason, these methods must be paired with a starting method and are themselves called continuing methods. However, there is an additional wrinkle if \(\beta_0\ne 0\). In particular, if \(\beta_0\ne 0\), then \(y_{i+1}\) appears on both sides of the defining equation. Therefore, we say that the method is explicit if \(\beta_0=0\) and implicit otherwise. If the method is implicit and the function \(f(x, y)\) is linear in \(y\), then it is easy to solve for \(y_{i+1}\) once the specific IVP is given, but in practice this is often not the case. We will consider implicit methods in the next lecture. For now, we concentrate on the case of explicit methods. Some of the most popular explicit linear multistep methods are the Adams–Bashforth methods, which set \(\beta_0=0\) and \(\alpha_j=0\) for \(j\ge 2\).
Adams–Bashforth 2-step method.#
For a \(2\)-step Adams—Bashforth method, equation (14) simplifies to
The idea then is to force this formula to be exact for the first 3 terms of the Taylor series of the true solution \(y\). This will ensure that the local truncation error for the method is \(O(h^3)\) as \(h\to 0\). Note that with only \(3\) parameters to determine (viz, \(\alpha_1, \beta_1, \beta_2)\), this is the best that can be done. By linearity, it is enough to make it exact for the cases \(y(x)=1\), \(y(x)=x\), and \(y(x)=x^2\). If \(y(x)=1\), then \(f(x,y(x)) = y'(x) = 0\), and so the equation (15) becomes
Similarly, if \(y(x)=x\), then \(y'(x)=1\), and (15) becomes
Finally, if \(y(x)=x^2\), then \(y'(x)=2x\), and (15) becomes
Now, equations (16), (17), and (18) must hold for all values of \(x_i\) and \(h\). Choosing \(x_{i}=1\) and \(h=1\) so that \(x_{i-1}=0\) and \(x_{i+1}=2\), we arrive at the system of equations
Solving the system yields \(\alpha_1=1\), \(\beta_1=3/2\), and \(\beta_2=-1/2\). The Adams–Bashforth two-step method (AB2) is therefore defined by the recurrence
where \(y_i' = f(x_i, y_i)\) for each \(i\ge 1\).
As we noted above, the local truncation error for the method is \(O(h^3)\) as \(h\to 0\). This makes AB2 an order \(2\) method. Thus, it should be paired with a starter method whose order of accuracy is at least \(2\) such as Heun’s modified Euler method.
Adams–Bashforth 4-step method.#
In a similar manner, one may derive the Adams–Bashforth four-step method (AB4), which is defined by the recurrence
and has local truncation error that is \(O(h^5)\) as \(h\to 0\). Thus, it should be paired with an order \(4\) starting method such as the classical RK4 method.
Advantages and disadvantages.#
The disadvantage of a multistep method is that it is typically slightly more difficult to implement than a single-step method. It even requires a single-step method to get started. However, there tends to be a major gain in order of accuracy per work. We usually measure the work per step of a numerical method based on the number of times that we need to evaluate the “right-hand side” function \(f(x,y)\). The typical single-step Runge-Kutta method of order 2 (e.g., Heun’s modified Euler method) requires 2 functional evaluations per step. We compare this to the Adams–Bashforth 2-step method which has the same order of accuracy and only requires one additional functional evaluation per step since it saves the evaluations from previous steps until it is finished with them. Furthermore, since the error \(|y(x_i)-y_i|\) tends to increase with \(i\), it is reasonable to hope that a multistep may have better accuracy (not just order of accuracy) by mere fact that it explicitly incorporates more accurate previous data when approximating \(y_{i+1}\).
Python implementation.#
Our Python implementation of the Adams–Bashforth 2-step method uses Heun’s modified Euler method as starter.
The code is included in the math263
module.
import numpy as np
def ab2(f, a, b, y0, n):
"""
numerically solves the IVP
y' = f(x,y), y(a)=y0
over the interval [a, b] via n steps of second order Adams–Bashforth method
"""
h = (b - a) / n
x = np.linspace(a, b, num=n + 1)
y = np.empty((x.size, np.size(y0)))
y[0] = y0
# take first step with Heun's MEM
k1 = f(x[0], y[0])
k2 = f(x[1], y[0] + h * k1)
y[1] = y[0] + h * (k1 + k2) / 2
# begin multistepping
f2 = f(x[0], y[0])
for i in range(1, n):
f1 = f(x[i], y[i])
y[i + 1] = y[i] + h * (3 * f1 - f2) / 2
f2 = f1
# step f-vals down to get ready for next step
return x, y
Example.#
We now conduct an empirical comparison of AB2 with Heun’s modified Euler method (MEM) using the IVP
as a test case. First we use both methods to solve the problem over the interval \([0,2]\) in \(n=10\) steps. We then present the global truncation errors \(e_i=|y(x_i) - y_i|\) for each method at each step in a table.
from timeit import timeit
import numpy as np
import sympy
from tabulate import tabulate
import math263
# define IVP parameters
f = lambda x, y: y - x**2 + 1
a, b = 0, 2
y0 = 1 / 2
# solve the IVP symbolically with the sympy library
x = sympy.Symbol("x")
y = sympy.Function("y")
ode = sympy.Eq(y(x).diff(x), f(x, y(x)))
soln = sympy.dsolve(ode, y(x), ics={y(a): y0})
sym_y = sympy.lambdify(x, soln.rhs, modules=["numpy"])
# numerically solve the IVP with n=10 steps of forward Euler and n=10 steps of RK4
n = 10
x, y_mem = math263.mem(f, a, b, y0, n)
x, y_ab2 = math263.ab2(f, a, b, y0, n)
# tabulate the results
print(
f"Comparison of global errors for MEM and AB2\
across interval for step-size h = {(b - a)/n}."
)
table = np.c_[x, abs(sym_y(x) - y_mem[:, 0]), abs(sym_y(x) - y_ab2[:, 0])]
hdrs = ["i", "x_i", "MEM global error", "AB2 global error"]
print(tabulate(table, hdrs, tablefmt="mixed_grid", floatfmt="0.5f", showindex=True))
Comparison of global errors for MEM and AB2 across interval for step-size h = 0.2.
┍━━━━━┯━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━┑
│ i │ x_i │ MEM global error │ AB2 global error │
┝━━━━━┿━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━┥
│ 0 │ 0.00000 │ 0.00000 │ 0.00000 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 1 │ 0.20000 │ 0.00330 │ 0.00330 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 2 │ 0.40000 │ 0.00717 │ 0.00229 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 3 │ 0.60000 │ 0.01170 │ 0.00020 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 4 │ 0.80000 │ 0.01699 │ 0.00295 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 5 │ 1.00000 │ 0.02317 │ 0.00750 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 6 │ 1.20000 │ 0.03036 │ 0.01391 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 7 │ 1.40000 │ 0.03871 │ 0.02277 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 8 │ 1.60000 │ 0.04839 │ 0.03486 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 9 │ 1.80000 │ 0.05956 │ 0.05115 │
├─────┼─────────┼────────────────────┼────────────────────┤
│ 10 │ 2.00000 │ 0.07242 │ 0.07292 │
┕━━━━━┷━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━┙
We now compare the global error for AB2 at the right endpoint of the interval with that of MEM as we shrink the step-size.
# compute abs errors at right endpoint for various step-sizes
base = 10
max_exp = 7
num_steps = [base**j for j in range(1, max_exp)]
h = [(b - a) / n for n in num_steps]
mem_errors = [
abs(math263.mem(f, a, b, y0, n)[1][:, 0][-1] - sym_y(b)) for n in num_steps
]
ab2_errors = [
abs(math263.ab2(f, a, b, y0, n)[1][:, 0][-1] - sym_y(b)) for n in num_steps
]
# compare size of error to size at previous step-size
mem_cutdown = [mem_errors[i + 1] / mem_errors[i] for i in range(len(num_steps) - 1)]
mem_cutdown.insert(0, None)
ab2_cutdown = [ab2_errors[i + 1] / ab2_errors[i] for i in range(len(num_steps) - 1)]
ab2_cutdown.insert(0, None)
# tabulate the results
print(f"Comparison of global errors |y_n - y({b})| for various step-sizes.")
table = np.c_[h, mem_errors, mem_cutdown, ab2_errors, ab2_cutdown]
hdrs = ["step-size", "MEM global error", "cutdown", "AB2 global error", "cutdown"]
print(
tabulate(table, hdrs, tablefmt="mixed_grid", floatfmt=["0.7f", "e", "f", "e", "f"])
)
Comparison of global errors |y_n - y(2)| for various step-sizes.
┍━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━┑
│ step-size │ MEM global error │ cutdown │ AB2 global error │ cutdown │
┝━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━┥
│ 0.2000000 │ 7.241732e-02 │ │ 7.291525e-02 │ │
├─────────────┼────────────────────┼───────────┼────────────────────┼───────────┤
│ 0.0200000 │ 7.797255e-04 │ 0.010767 │ 1.179610e-03 │ 0.016178 │
├─────────────┼────────────────────┼───────────┼────────────────────┼───────────┤
│ 0.0020000 │ 7.846676e-06 │ 0.010063 │ 1.226335e-05 │ 0.010396 │
├─────────────┼────────────────────┼───────────┼────────────────────┼───────────┤
│ 0.0002000 │ 7.851535e-08 │ 0.010006 │ 1.230993e-07 │ 0.010038 │
├─────────────┼────────────────────┼───────────┼────────────────────┼───────────┤
│ 0.0000200 │ 7.851320e-10 │ 0.010000 │ 1.231354e-09 │ 0.010003 │
├─────────────┼────────────────────┼───────────┼────────────────────┼───────────┤
│ 0.0000020 │ 7.706724e-12 │ 0.009816 │ 1.189537e-11 │ 0.009660 │
┕━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━┙
We also compare the empirical average running time for our implementations as the number of steps increases.
num_trials = 10
mem_times = [
timeit(lambda: math263.mem(f, a, b, y0, base**j), number=num_trials) / num_trials
for j in range(1, max_exp)
]
ab2_times = [
timeit(lambda: math263.ab2(f, a, b, y0, base**j), number=num_trials) / num_trials
for j in range(1, max_exp)
]
# tabulate the results
print(f"Comparison of average compute time for various step-sizes.")
table = np.c_[num_steps, mem_times, ab2_times]
hdrs = ["num steps", "MEM time (secs)", "AB2 time (secs)"]
print(tabulate(table, hdrs, tablefmt="mixed_grid", floatfmt=["0.0f", "0.5f", "0.5f"]))
Comparison of average compute time for various step-sizes.
┍━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━┯━━━━━━━━━━━━━━━━━━━┑
│ num steps │ MEM time (secs) │ AB2 time (secs) │
┝━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━┥
│ 10 │ 0.00005 │ 0.00004 │
├─────────────┼───────────────────┼───────────────────┤
│ 100 │ 0.00044 │ 0.00033 │
├─────────────┼───────────────────┼───────────────────┤
│ 1000 │ 0.00443 │ 0.00319 │
├─────────────┼───────────────────┼───────────────────┤
│ 10000 │ 0.04361 │ 0.03188 │
├─────────────┼───────────────────┼───────────────────┤
│ 100000 │ 0.43594 │ 0.31782 │
├─────────────┼───────────────────┼───────────────────┤
│ 1000000 │ 4.34698 │ 3.19615 │
┕━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━┷━━━━━━━━━━━━━━━━━━━┙