ES3J1 Lecture Lab 4
ES3J1 Lecture Lab 4
ES3J1 Lecture Lab 4
February 6, 2023
We will consider the solution of ODE initial value problems (IVPs) where we seek a function
𝑥 ∶ [𝑡0 , 𝑇 ] → ℝ𝑛 satisfying
𝑥(𝑡)
̇ = 𝑓(𝑡, 𝑥(𝑡)) for all 𝑡 > 𝑡0 , (1)
𝑥(𝑡0 ) = 𝑥0 (2)
where the vector field 𝑓 ∶ [𝑡0 , 𝑇 ]×ℝ𝑛 → ℝ𝑛 and the initial condition 𝑥0 ∈ ℝ𝑛 are given. Equivalently,
the solution 𝑥 (if it exists) satisfies the integral equation
𝑡
𝑥(𝑡) = 𝑥0 + ∫ 𝑓(𝑠, 𝑥(𝑠)) d𝑠 for all 𝑡 > 𝑡0 , (3)
𝑡0
For this reason, there is a close relationship between the numerical solution of ODEs and numer-
ical integration (quadrature), although IVPs are usually solved sequentially in time, i.e. working
forwards from 𝑡0 to later times 𝑡 > 𝑡0 .
Usually an analytically exact solution to the IVP is not available and numerical schemes output
numerical approximations 𝑋(𝑡𝑖 ) to 𝑥(𝑡𝑖 ) on some prescribed grid or mesh of times 𝑡0 < 𝑡1 <
𝑡2 < ⋯ < 𝑇 . (This lecture/lab will use lowercase letters for the exact solution and uppercase letters
for numerical approximations.) Sometimes the grid points 𝑡𝑖 are given by the user in advance;
sometimes they are chosen on the fly by the solution algorithm (so-called “adaptive methods”,
which are basically essential for serious problems).
In general, there is no need to the grid points 𝑡𝑖 to be specified in advance nor for them to be
equally spaced. Black-box solvers will generally produce adaptive and unevenly-spaced time grids.
1
The solvers written in this lab will all required pre-specified grids, and can be used with unevenly
spaced grids. However, for the sake of simplicity, we will illustrate all the ideas with a fixed time
step, so you can think of 𝑡𝑘 as simply 𝑡0 + 𝑘𝛿 for some fixed 𝛿 > 0.
𝑥(𝑡𝑖 ) − 𝑥(𝑡𝑖−1 )
𝑥(𝑡
̇ 𝑖−1 ) ≈ . (4)
𝑡𝑖 − 𝑡𝑖−1
To save some space, write Δ𝑡𝑖 = 𝑡𝑖 − 𝑡𝑖−1 for the length of the time step — which is often taken to
be a constant in simple applications. We rearrange the above approximation to obtain
Let’s try this solver on the simplest ODE of all, the one representing exponential decay:
𝑥(𝑡)
̇ = −𝑥(𝑡) (9)
𝑥(0) = 𝑥0 (10)
2
[7]: def decay_f(t, x):
return -x
x0 = np.array([2.0])
t_min = 0.0
t_max = 1.0
dt = 0.1
T = np.arange(t_min, t_max + dt, dt)
T, X_fe = solve_ivp_fe(decay_f, x0, T)
fig, ax = plt.subplots()
ax.plot(T, X_fe, color="blue")
ax.set_xlabel("t");
ax.set_ylabel("x");
fig, ax = plt.subplots(2)
ax[0].plot(T, X_fe, color="blue")
ax[0].plot(T, X_ex, color="blue", linestyle=":")
3
ax[1].plot(T, err, color="red")
ax[1].set_xlabel("t");
ax[0].set_ylabel("x");
ax[1].set_ylabel("error");
Linear Systems. When the vector field 𝑓 is linear in the state 𝑥, e.g. 𝑓(𝑡, 𝑥) = 𝐴(𝑡)𝑥(𝑡) + 𝑏(𝑡) for
some square matrix 𝐴(𝑡) and vector 𝑏(𝑡), it is often helpful to write the forward Euler update in
linear form:
This representation is particularly useful for applications that rely on a linear representation of the
dynamics, such as the linear Kalman filter for state estimation.
Lab Task. In the example above, we used a fixed time step dt = 0.1. Write a function to calculate
the global error of the forward Euler scheme (i.e. the maximum absolute difference between the
forward Euler solution and the exact solution) for this problem as a function of the time step size
dt, and then plot this global error on appropriate axes. You should observe that the global error
scales roughly like the time step, which is why forward Euler is said to have “order one” (as opposed
to, e.g., “order two” if the global error were proportional to the square of the time step).
4
[9]: def global_error_fe(T):
pass
Warning! It is all too easy — especially with “stiff” or “chaotic” systems — to choose time step
sizes that are too large for the numerical solution to be any good at all, as the code snippet below
illustrates in rather extreme fashion. It turns out that forward Euler can be a unstable numerical
method. As a matter of good practice, regardless of your choice of ODE solver, you should always
check that your results are not unduly sensitive to numerical parameters such as time step sizes.
[10]: x0 = np.array([2.0])
t_min = 0.0
t_max = 10.0
dt = 2.0
T = np.arange(t_min, t_max + dt, dt)
T, X_fe = solve_ivp_fe(decay_f, x0, T)
X_ex = [ x0 * np.exp(- t) for t in T ]
err = [ np.abs(X_fe[i][0] - X_ex[i]) for i in range(len(T)) ]
fig, ax = plt.subplots(2)
ax[0].plot(T, X_fe, color="blue")
ax[0].plot(T, X_ex, color="blue", linestyle=":")
ax[1].plot(T, err, color="red")
ax[1].set_xlabel("t");
ax[0].set_ylabel("x");
ax[1].set_ylabel("error");
5
1.1.3 Backward (implicit) Euler
The next most basic initial value problem solver is the backward Euler or implicit Euler method,
which has much better stability properties and is based on the approximation
𝑥(𝑡𝑖 ) − 𝑥(𝑡𝑖−1 )
𝑥(𝑡
̇ 𝑖) ≈ . (15)
𝑡𝑖 − 𝑡𝑖−1
We rearrange the above approximation to obtain
i.e., the next state 𝑋(𝑡𝑖 ) is determined implicitly as the solution of the equation
In general, solving this equation will require the use of an optimisation routine (which we have
not covered yet) to minimise the difference between the LHS and RHS of the previous equation.
However, in the special case that 𝑓 is linear, with
we have
6
and so
i.e.
−1
𝑋(𝑡𝑖 ) = (𝐼 − Δ𝑡𝑖 𝐴(𝑡𝑖 )) (𝑋(𝑡𝑖−1 ) − Δ𝑡𝑖 𝑏(𝑡𝑖 )). (21)
return T, X
def decay_A(t):
return - 1.0 * np.eye(1)
def decay_b(t):
return np.zeros((1,))
x0 = np.array([2.0])
t_min = 0.0
t_max = 10.0
dt = 0.5
T = np.arange(t_min, t_max + dt, dt)
T, X_be = solve_ivp_be(decay_A, decay_b, x0, T)
X_ex = [ x0 * np.exp(- t) for t in T ]
err = [ np.abs(X_be[i][0] - X_ex[i]) for i in range(len(T)) ]
fig, ax = plt.subplots(2)
ax[0].plot(T, X_be, color="blue")
ax[0].plot(T, X_ex, color="blue", linestyle=":")
ax[1].plot(T, err, color="red")
7
ax[1].set_xlabel("t");
ax[0].set_ylabel("x");
ax[1].set_ylabel("error");
Lab Task. Investigate the global error of the backward Euler scheme as a function of fixed time
step dt, again using the decay ODE as your test case. What order does backward Euler appear to
have?
for some constant 𝐶(𝑓, 𝑥0 , 𝑇 ) ≥ 0 that depends only on the vector field 𝑓, the initial condition 𝑥0 ,
and the terminal time 𝑇 . Naturally, there is also interest in having solvers that converge to the
true solution at a faster rate, e.g.
𝑝
max |𝑋(𝑡𝑖 ) − 𝑥(𝑡𝑖 )| ≤ 𝐶(𝑓, 𝑥0 , 𝑇 ) ( max Δ𝑡𝑖+1 ) (23)
0≤𝑡𝑖 ≤𝑇 0≤𝑡𝑖 ≤𝑇
8
for some 𝑝 > 1. We call 𝑝 the order of the method.
Lab Task. Look up the definition of the fourth-order Runge–Kutta scheme and write an
implementation of it, following the model of the forward and backward Euler schemes given above.
Perform the same analysis of the global error as you did before, on the same test problem, to
convince yourself that it does indeed have order four.
𝑢(𝑡)
̈ = −𝑢(𝑡) (24)
but we can reformulate this as a first-order ODE by setting 𝑣 = 𝑢̇ and 𝑥 = (𝑢, 𝑣) so that
𝑢(𝑡)
̇ 𝑣(𝑡) 0 1
𝑥(𝑡)
̇ =( )=( )=( ) 𝑥(𝑡). (25)
𝑣(𝑡)
̇ −𝑢(𝑡) −1 0
This is not a particularly challenging system to solve, but it already illustrates some of the great
weaknesses of forward Euler and why one would consider more advanced and structured ODE
solvers in practice.
x0 = np.array([2.0, 0.0])
t_min = 0.0
t_max = 100.0
dt = 0.01
T = np.arange(t_min, t_max + dt, dt)
T, X_fe = solve_ivp_fe(oscillator_f, x0, T)
X_ex = [ np.array([[np.cos(t), np.sin(t)], [-np.sin(t), np.cos(t)]]) @ x0 for t␣
↪in T ]
def total_energy(x):
'''
Calculates the total energy of the system as the sum of the
potential energy and the kinetic (the spring constant and the
mass both being 1 in our chosen units).
'''
return 0.5 * x[0] * x[0] + 0.5 * x[1] * x[1]
9
fig, ax = plt.subplots(nrows=3, ncols=1, figsize=(12, 9))
ax[0].plot(T, X_fe)
#ax[0].plot(T, X_ex)
ax[0].set_ylabel("x[0] (blue), x[1] (orange)");
ax[1].plot(T, err, color="red")
ax[1].set_ylabel("error")
ax[2].set_xlabel("t");
ax[2].plot(T, E_fe, color="black")
ax[2].set_ylabel("total energy");
Lab Task (Optional). How should oscillator_f() and total_energy() be modified to reflect
a non-unit mass and non-unit spring constant for the oscillator?
Lab Task. Solve this oscillator system using the backward Euler solver provided above. You
will need to design appropriate functions oscillator_A() and oscillator_b() and you should
observe that the backward Euler solutions decay towards (0, 0) over time, exhibiting pronounced
energy loss.
Conservation of energy, momentum, etc. are very important physical properties, so the systematic
error in the total energy of the system (energy gain under forward Euler and energy loss under
backward Euler) is rather worrying. Perhaps the Euler methods are simply too crude, or we made
a mistake, so let’s try a generic solver as offered by the scipy library.
10
[18]: x0 = np.array([2.0, 0.0])
#sol = sp.integrate.solve_ivp(oscillator_f, [t_min, t_max], x0)
sol = sp.integrate.solve_ivp(oscillator_f, [t_min, t_max], x0, max_step=0.1)
T , X_sp = sol.t , np.array(sol.y).T
E_sp = [ total_energy(x) for x in X_sp ]
X_ex = [ np.array([[np.cos(t), np.sin(t)], [-np.sin(t), np.cos(t)]]) @ x0 for t␣
↪in T ]
The energy gain and energy loss exhibited by these solvers for a system that ought to have con-
stant total energy is very undesirable. Simply put, we cannot use such solvers to make physically
11
consistent predictions with any accuracy or confidence. Fortunately, there are solvers known as
structured integrators or geometric integrators that are carefully designed to preserve to-
tal energy, or at least to preserve a close approximation to it. The simplest example of such an
integrator is the semi-implicit Euler method for ODEs of the form
𝑥(𝑡)
̇ = 𝑓(𝑡, 𝑦(𝑡)), (26)
𝑦(𝑡)
̇ = 𝑔(𝑡, 𝑥(𝑡)), (27)
(𝑥(𝑡0 ), 𝑦(𝑡0 )) = (𝑥0 , 𝑦0 ). (28)
More involved examples of such integrators include the leapfrog method or Störmer–Verlet
method.
t_min = 0.0
t_max = 100.0
dt = 0.01
T = np.arange(t_min, t_max + dt, dt)
T, X_sie, Y_sie = solve_ivp_sie(oscillator_2_f, oscillator_2_g, 2.0, 0.0, T)
E_sie = [ total_energy([X_sie[i], Y_sie[i]]) for i in range(len(T)) ]
12
X_ex = [ np.array([[np.cos(t), np.sin(t)], [-np.sin(t), np.cos(t)]]) @ x0 for t␣
↪in T ]
Lab Task. Adjust the time step used in the semi-implicit Euler scheme and explore both the error
in the state space (x) and the error in energy (E). You should see that while the total energy does
oscillate, the energy error remains bounded and has an error of order dt.
13
1.1.6 Integration (quadrature)
Scipy offers simple one-line interfaces for the numerical integration (quadrature) of functions of one
or more variables. As a simple example, we can try to numerically replicate the exact integral
1 1
𝑥3
2 1
∫ 𝑥 d𝑥 = [ ] = . (29)
0 3 𝑥=0 3
Ultimately, numerical quadrature routines boil down to the selection of suitable nodes 𝑥1 , … , 𝑥𝑚
and weights 𝑤1 , … , 𝑤𝑚 and the approximation of the integral as
1 𝑚
∫ 𝑥2 d𝑥 ≈ ∑ 𝑤𝑖 𝑓(𝑥𝑖 ). (30)
0 𝑖=1
By comparing the estimates constructed using different nodal sets (e.g. for 𝑚 and 2𝑚) it is even
possible to give an estimate for the approximation error. The sp.integrate.quadrature() routine
does this by default, returning both an (approximate) value for the integral and an (approximate)
error estimate:
R = 3
print(f"Using sp.integrate.quadrature over [-{R}, {R}]")
val, err_est = sp.integrate.quadrature(lambda x: x * Gaussian_density(x), -R, R)
print(f"Quadrature value = {val}")
print(f" Error estimate = {err_est}")
print(f" Actual error = {np.abs(val - 0.0)}")
14
print(f" Error estimate = {err_est}")
print(f" Actual error = {np.abs(val - 0.0)}")
∫ 𝑥1 𝜌(𝑥) d𝑥 = 0, (32)
ℝ𝑛
exp(−‖𝑥‖2 /2)
𝜌(𝑥) = . (33)
(2𝜋)𝑛/2
def Gaussian_density(x):
return np.exp(- 0.5 * np.linalg.norm(x) ** 2.0) / (np.sqrt((2.0 * np.pi) **␣
↪x.shape[0]))
dims = [1, 2, 3, 4, 5]
n_dim = 1
t0 = dt.now()
val, err_est = sp.integrate.nquad(lambda x0: x0 * Gaussian_density(np.
↪array([x0])), n_dim * [[-1000, 1000]])
t1 = dt.now()
report(n_dim, val, err_est, t0, t1)
runtimes = [ (t1 - t0).total_seconds() ]
15
n_dim = 2
t0 = dt.now()
## I WISH! val, err_est = sp.integrate.nquad(lambda x: x[0] *␣
↪Gaussian_density(x), n_dim * [[-1000, 1000]]) !!!
t1 = dt.now()
report(n_dim, val, err_est, t0, t1)
runtimes.append((t1 - t0).total_seconds())
n_dim = 3
t0 = dt.now()
val, err_est = sp.integrate.nquad(lambda x0, x1, x2: x0 * Gaussian_density(np.
↪array([x0, x1, x2])), n_dim * [[-1000, 1000]])
t1 = dt.now()
report(n_dim, val, err_est, t0, t1)
runtimes.append((t1 - t0).total_seconds())
n_dim = 4
t0 = dt.now()
val, err_est = sp.integrate.nquad(lambda x0, x1, x2, x3: x0 *␣
↪Gaussian_density(np.array([x0, x1, x2, x3])), n_dim * [[-1000, 1000]])
t1 = dt.now()
report(n_dim, val, err_est, t0, t1)
runtimes.append((t1 - t0).total_seconds())
n_dim = 5
t0 = dt.now()
val, err_est = sp.integrate.nquad(lambda x0, x1, x2, x3, x4: x0 *␣
↪Gaussian_density(np.array([x0, x1, x2, x3, x4])), n_dim * [[-1000, 1000]])
t1 = dt.now()
report(n_dim, val, err_est, t0, t1)
runtimes.append((t1 - t0).total_seconds())
16
ax.set_ylabel("runtime / s");
ax.set_xticks(dims)
ax.set_title(r"Quadrature runtimes (dashed = $10^{n - 4}$)");
17
states that, if 𝑋1 , 𝑋2 , … , 𝑋𝑚 , … is a sequence of independent samples with the same PDF 𝜌, then
1 𝑚
𝔼[𝑓(𝑋)] = lim ∑ 𝑓(𝑋𝑖 ). (35)
𝑚→∞ 𝑚
𝑖=1
1 𝑚 𝐶(𝑓)
∣𝔼[𝑓(𝑋)] − ∑ 𝑓(𝑋𝑖 )∣ ≤ √ . (36)
𝑚 𝑖=1 𝑚
We illustrate the idea with the same example as we used for nquad:
exp(−‖𝑥‖2 /2)
𝜌(𝑥) = . (38)
(2𝜋)𝑛/2
X = []
min_samples = int(np.floor(min_samples))
max_samples = int(np.floor(max_samples))
check_samples = int(np.floor(check_samples))
## First we draw from the standard Gaussian min_samples times
for i in range(min_samples):
x = np.random.normal(size=(n,))
X.append(x)
## Here is a little function to calculate the sample average of the␣
↪integrand
## absolute and relative changes in the sample mean are small enough,␣
↪checking
18
new_m = sample_m()
a_ch = np.abs(new_m - old_m) ## Absolute change in the estimate of␣
↪the mean
r_ch = a_ch / min([np.abs(old_m), np.abs(new_m)]) ## And the␣
↪relative change
if a_ch < a_tol and r_ch < r_tol:
carry_on = False ## Set a flag to terminate the run if the␣
↪absolute and
Since Monte Carlo is a random algorithm, its accuracy and runtime are random, but one thing that
quickly becomes evident is that the performance does not depend strongly upon the dimension of
the integration domain.
t1 = dt.now()
print(f"Dimension {n_dim} MC value of {val} with {N} samples took {(t1 -␣
↪t0).total_seconds()}s")
errors.append(np.abs(val))
runtimes.append((t1 - t0).total_seconds())
fig, ax = plt.subplots(2)
ax[0].plot(dims, errors, color="red")
19
ax[1].plot(dims, runtimes, color="blue")
ax[0].set_ylabel("absolute error");
ax[1].set_ylabel("runtime / s");
ax[1].set_xlabel("dimension, n");
Lab Task. Plot the average performance (accuracy and cost) of Monte Carlo integration over a
number of runs on apprpriate axes.
Extensions. To implement Monte Carlo integration, one needs to be able to draw samples from
the density 𝜌, which is not always easy. To get around this difficulty, one can use the more advanced
Markov chain Monte Carlo technique, which we will not discuss in this module — but feel free
to ask me about it if you are interested.
20