Calculate sensitivity when the control is a fem function

Dear all,

I have been stuck with an optimal control problem for a few days. I want to find the sensitivity of a cost function with respect to a forcing term of a PDE. I have solved the problem writing my own Matlab code and the Taylor test passes. However, when I implement it in Dolfinx, the Taylor test does not pass. I present here a simplified version of the problem with the code.

Consider the Poisson equation over a squared domain \Omega with homogeneous Dirichlet boundaries \Gamma:

-\Delta u = f in \Omega
u = 0 at \Gamma

I define the following cost function:
J(u) = \frac{1}{2}\int_{\Omega}||u-u_{obs}||^{2}d\Omega
where u_{obs} is the desired profile of u.
u_{obs}(x,y) = x(1-x)y(1-y)

This problem can be found in https://fenicsproject.org/pub/course/lectures/2015-08-logg-beijing/lecture_12_sensitivities.pdf solved with dolfin-adjoint.

Using the adjoint method, I compute the gradient of the cost function J with respect to the design parameter \boldsymbol{m} (i.e., f in this case) as follows:

\frac{dJ}{d\textbf{m}} = \frac{\partial{J}}{\partial\textbf{m}} + \left(\frac{\partial{\textbf{R}}}{\partial\textbf{m}}\right)^{T} \boldsymbol{\lambda}
where \boldsymbol{\lambda} is the adjoint variable, solution of the adjoint problem:
\left(\frac{\partial{\textbf{R}}}{\partial\textbf{u}}\right)^{T}\boldsymbol{\lambda} = -\frac{\partial{J}}{\partial\textbf{u}}

My code for solving this problem is below:

import dolfinx
from ufl import Measure, inner, grad, div, TestFunction, TrialFunction, adjoint, derivative
from dolfinx.mesh import CellType, create_rectangle
from dolfinx import fem, nls, io
from mpi4py import MPI
from petsc4py import PETSc
import numpy as np
import time

import dolfinx.nls.petsc
import dolfinx.fem.petsc

# mesh and function space
mesh = create_rectangle(MPI.COMM_WORLD, [[0, 0], [1, 1]], [64, 64], CellType.triangle)
V = fem.FunctionSpace(mesh, ("CG", 1))
u = fem.Function(V)
v = TestFunction(V)

# sensitivity will be calculated wrt this
f = fem.Function(V)  # The forcing f is the design variable
f.x.array[:] = 0.0

dx = Measure("dx")

# Residual of the variational form of Poisson problem
R = inner(grad(u), grad(v))*dx - inner(f,v)*dx

# boundary conditions
def left(x):
    return np.isclose(x[0], 0)
def right(x):
    return np.isclose(x[0], 1)
def top(x):
    return np.isclose(x[1], 1)
def bottom(x):
    return np.isclose(x[1], 0)

fdim = mesh.topology.dim - 1

uD = fem.Function(V)

left_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, left)
right_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, right)
top_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, top)
bottom_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, bottom)

with uD.vector.localForm() as loc:
    loc.set(0.0)

bc1 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, left_facets))
bc2 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, right_facets))
bc3 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, top_facets))
bc4 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, bottom_facets))

bcs = [bc1, bc2, bc3, bc4]

# Solver for the direct problem
problem_dir = fem.petsc.NonlinearProblem(R, u, bcs)
solver = nls.petsc.NewtonSolver(MPI.COMM_WORLD, problem_dir)
solver.rtol = 1e-16

# Compute solution
solver.solve(u)

# Define desired u profile
def profile(x):
    return x[0]*(1-x[0])*x[1]*(1-x[1])
u_obs = fem.Function(V)
u_obs.interpolate(profile)

# Define cost function
J = (1/2)*inner(u-u_obs, u-u_obs)*dx
J_form = fem.form(J)
J_val = fem.assemble_scalar(J_form)

# Bilinear and linear form of the adjoint problem
lhs = adjoint(derivative(R, u))
rhs = -derivative(J, u)

# Create adjoint problem solver
problem = dolfinx.fem.petsc.LinearProblem(lhs, rhs, bcs=bcs,petsc_options={"ksp_type": "preonly", "pc_type": "lu"})

# solve adjoint vector
lmbda = problem.solve()

# Compute sensitivity: dJ/dm
# Partial derivative of J w.r.t. m
dJdm_form = fem.form(derivative(J, f))
dJdm = fem.petsc.assemble_vector(dJdm_form)
dJdm.assemble()

# partial derivative of R w.r.t. m
dRdm_form = fem.form(adjoint(derivative(R, f)))
dRdm = fem.petsc.assemble_matrix(dRdm_form)
dRdm.assemble()

# Calculate the gradient of J with respect to m (the forcing f)
dJdm += dRdm*lmbda.vector
dJdm.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)

Now I define the following functions to perform the Taylor test:

# Function to compute the cost
def J_cost(m_n):
    f.x.array[:] = m_n
    solver.solve(u)
    J_val = fem.assemble_scalar(J_form)

    return J_val

# Function to compute the gradient of the cost w.r.t. f
def grad_J(m_n):
    f.x.array[:] = m_n

    # Direct solution
    solver.solve(u)

    # Adjoint solution
    lmbda = problem.solve()

    # Reassemble dJdm
    dJdm = fem.petsc.assemble_vector(dJdm_form)
    dJdm.assemble()

    # Reassemble dRdm 
    dRdm = fem.petsc.assemble_matrix(dRdm_form)
    dRdm.assemble()

    # Compute gradient 
    dJdm += dRdm*lmbda.vector
    dJdm.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.REVERSE)

    return dJdm

# Taylor test
def taylor_test(cost, grad, m_0, p=1e-6, n=5):
        g0 = grad(m_0)
        l0 = cost(m_0)
        reminder = []
        perturbance = []
        for i in range(0, n):
            l1 = cost(m_0+p)
            reminder.append(l1 - l0 - sum(np.transpose(g0)*p))
            perturbance.append(p)
            p /= 2
        conv_rate = convergence_rates(reminder, perturbance)
        return reminder, perturbance, conv_rate

# Compute convergence rate (they should be 2.0 for small p)
def convergence_rates(r, p):
    cr = [] # convergence rates
    for i in range(1, len(p)):
        cr.append(np.log(r[i] / r[i - 1])
                 / np.log(p[i] / p[i - 1]))
    return cr

m_0 = np.zeros(len(u.x.array))
r = taylor_test(J_cost, grad_J, m_0)
print("Convergence rate:", r[2])


The output should be 2.0, but gives

Convergence rate: [1.8214896414517914, 1.458183253415033, 0.6623141576735944, 0.33714267999803627]

Does anybody know what I am doing wrong?
I done a similar problem in this post Calculate adjoint derivatives w.r.t to multiple Constants where the control is a constant, but when the control is a function, I can’t get the expected solution in dolfinx.

If something is not clear, please let me know.

Thank you all in advance.

I think there is few things happening here.

When I run your code as is, I get this result:
Convergence rate: [2.0924435576205673, 2.4596218406630284, 4.589509511644071, nan]

The parameter p in the Taylor test should be bigger.
Your code actually works for:
taylor_test(J_cost, grad_J, m_0, p=1e-4)
at which point the result is:
Convergence rate: [2.000010723617184, 2.0000270773462976, 2.0001589607488435, 2.0006124744482032]

You can also set the tolerance of the adjoint problem solver to:
ksp_rtol = 1e-16 and ksp_atol = 1e-16
You do not want any errors coming from there.

The code does not pass the Taylor test when running in parallel though.
There is bunch of stuff that you need to pay attention to. The PETSc is kind of tricky to deal with regarding ghostUpdates, values that are contain in the arrays before you call assemble, and what part of the vectors and matrices exist on each processor. It is not easy to get right.

General advice I would give:

  • Zero the vectors and matrices before you assemble them.
  • In order to test properly, run at least two Taylor tests back to back, so you can be sure that no values from previous test stays in the arrays and mess with the grad calculation between calls
  • If it is not working, try to check if you have done the ghostUpdates correctly.
  • The Taylor test must reflect the gradient and cost globally, not just the local chunks of them.

Having said that here is code that works for me in serial and parallel:

import dolfinx
from ufl import Measure, inner, grad, div, TestFunction, TrialFunction, adjoint, derivative
from dolfinx.mesh import CellType, create_rectangle
from dolfinx import fem, nls, io
from mpi4py import MPI
from petsc4py import PETSc
import numpy as np
import time

import dolfinx.nls.petsc
import dolfinx.fem.petsc

# mesh and function space
mesh = create_rectangle(MPI.COMM_WORLD, [[0, 0], [1, 1]], [64, 64], CellType.triangle)
V = fem.FunctionSpace(mesh, ("CG", 1))
u = fem.Function(V)
v = TestFunction(V)

# sensitivity will be calculated wrt this
f = fem.Function(V)  # The forcing f is the design variable
f.x.array[:] = 0.0

dx = Measure("dx")

# Residual of the variational form of Poisson problem
R = inner(grad(u), grad(v))*dx - inner(f,v)*dx

# boundary conditions
def left(x):
    return np.isclose(x[0], 0)
def right(x):
    return np.isclose(x[0], 1)
def top(x):
    return np.isclose(x[1], 1)
def bottom(x):
    return np.isclose(x[1], 0)

fdim = mesh.topology.dim - 1

uD = fem.Function(V)

left_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, left)
right_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, right)
top_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, top)
bottom_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, bottom)

with uD.vector.localForm() as loc:
    loc.set(0.0)

bc1 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, left_facets))
bc2 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, right_facets))
bc3 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, top_facets))
bc4 = fem.dirichletbc(uD, dolfinx.fem.locate_dofs_topological(V, fdim, bottom_facets))

bcs = [bc1, bc2, bc3, bc4]

# Solver for the direct problem
problem_dir = fem.petsc.NonlinearProblem(R, u, bcs)
solver = nls.petsc.NewtonSolver(MPI.COMM_WORLD, problem_dir)
solver.rtol = 1e-16
solver.atol = 1e-16

# Compute solution
solver.solve(u)

# Define desired u profile
def profile(x):
    return x[0]*(1-x[0])*x[1]*(1-x[1])
u_obs = fem.Function(V)
u_obs.interpolate(profile)

# Define cost function
J = (1/2)*inner(u-u_obs, u-u_obs)*dx
J_form = fem.form(J)
J_val = fem.assemble_scalar(J_form)

# Bilinear and linear form of the adjoint problem
lhs = adjoint(derivative(R, u))
rhs = -derivative(J, u)

# Create adjoint problem solver
problem = dolfinx.fem.petsc.LinearProblem(lhs, rhs, bcs=bcs, 
                                          petsc_options={"ksp_type": "preonly", 
                                                         "pc_type": "lu"
                                                         })

# solve adjoint vector
lmbda = problem.solve()

# Compute sensitivity: dJ/dm
# Partial derivative of J w.r.t. m
dJdm_form = fem.form(derivative(J, f))
dJdm = fem.petsc.create_vector(dJdm_form)
dJdm.assemble()

# partial derivative of R w.r.t. m
dRdm_form = fem.form(adjoint(derivative(R, f)))
dRdm = fem.petsc.create_matrix(dRdm_form)

# Calculate the gradient of J with respect to m (the forcing f)
#dJdm += dRdm*lmbda.vector
#dJdm.ghostUpdate(addv=PETSc.InsertMode.INSERT, mode=PETSc.ScatterMode.FORWARD)

# Function to compute the cost
def J_cost(m_n):
    f.x.array[:] = m_n
    solver.solve(u)
    J_val = fem.assemble_scalar(J_form)

    return J_val

# Function to compute the gradient of the cost w.r.t. f
def grad_J(m_n,dJdm=dJdm):
    f.x.array[:] = m_n

    # Direct solution
    solver.solve(u)

    # Adjoint solution
    lmbda = problem.solve()

    # Reassemble dRdm
    dRdm.zeroEntries()
    fem.petsc.assemble_matrix_mat(dRdm, dRdm_form)
    dRdm.assemble()

    # Compute gradient
    # zero gradient first
    with dJdm.localForm() as dJdm_loc:
        dJdm_loc.set(0)

    fem.petsc.assemble_vector(dJdm, dJdm_form)
    dJdm.assemble()

    dJdm += dRdm*lmbda.vector
    dJdm.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE)

    return dJdm

# Taylor test
def taylor_test(cost, grad, m_0, p=1e-6, n=5):
        g0 = grad(m_0)
        l0 = cost(m_0)
        l0 = MPI.COMM_WORLD.allreduce(l0, op=MPI.SUM)
        reminder = []
        perturbance = []
        for i in range(0, n):
            l1 = cost(m_0+p)
            l1 = MPI.COMM_WORLD.allreduce(l1, op=MPI.SUM)
            g = np.sum(g0.array)
            g = MPI.COMM_WORLD.allreduce(g, op=MPI.SUM)
            reminder.append(l1 - l0 - g*p)
            perturbance.append(p)
            p /= 2
        conv_rate = convergence_rates(reminder, perturbance)
        return reminder, perturbance, conv_rate

# Compute convergence rate (they should be 2.0 for small p)
def convergence_rates(r, p):
    cr = [] # convergence rates
    for i in range(1, len(p)):
        cr.append(np.log(r[i] / r[i - 1])
                 / np.log(p[i] / p[i - 1]))
    return cr

m_0 = np.zeros(len(u.x.array))
r = taylor_test(J_cost, grad_J, m_0, p=1e-4)
r = taylor_test(J_cost, grad_J, m_0, p=1e-4)
print("Convergence rate:", r[2])

which for mpirun -n 1 gives:
Convergence rate: [2.0000107236531175, 2.000027077418167, 2.000158960892612, 2.0006124747359526]

and for mpirun -n 10 gives:

Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]
Convergence rate: [1.9999988379660412, 1.999996020138476, 1.9999705520429036, 1.9999611215295605]

Hope this helps.

3 Likes

Here is a version that work for non-homogeneous and in parallel:

# Adjoint computation for the Poisson problem
# Author: Jørgen S. Dokken

import dolfinx
from ufl import Measure, inner, grad, TestFunction, adjoint, derivative, action
from dolfinx.mesh import CellType, create_rectangle
from dolfinx import fem, nls, la
from mpi4py import MPI
import numpy as np
import dolfinx.nls.petsc
import dolfinx.fem.petsc


class Problem():
    def __init__(self, V, bcs, u_obs):
        """Initialize all variational forms for the given problem, for a given set of boundary conditions and observed u"""
        # Residual of the variational form of Poisson problem
        self.f = fem.Function(V)  # The forcing f is the design variable
        self.u = fem.Function(V)
        R = inner(grad(self.u), grad(v))*dx - inner(self.f, v)*dx

        # Forward problem
        self.forward_problem = fem.petsc.NonlinearProblem(
            R, self.u, bcs)
        self.forward_solver = nls.petsc.NewtonSolver(
            V.mesh.comm, self.forward_problem)
        self.forward_solver.rtol = 1e-16

        # Define cost function
        J = (1/2)*inner(self.u-u_obs, self.u-u_obs)*dx
        self.J_form = fem.form(J)

        # Define derivative of cost function
        # Bilinear and linear form of the adjoint problem
        dRdu = adjoint(derivative(R, self.u))
        adjoint_lhs = fem.form(dRdu)
        adjoint_rhs = derivative(J, self.u)

        # Create adjoint problem solver
        self.lmbda = fem.Function(V)
        lambda_0 = fem.Function(V)
        lambda_0.x.array[:] = 0
        bcs_adjoint = [fem.dirichletbc(
            lambda_0, bc._cpp_object.dof_indices()[0]) for bc in bcs]  # Homogenize bcs
        self.adjoint_problem = dolfinx.fem.petsc.LinearProblem(adjoint_lhs, adjoint_rhs, u=self.lmbda,
                                                               bcs=bcs_adjoint, petsc_options={
                                                                   "ksp_type": "preonly", "pc_type": "lu"})

        # Compute sensitivity: dJ/dm
        # Partial derivative of J w.r.t. m
        dJdm = derivative(J, self.f)
        # partial derivative of R w.r.t. m
        dRdm = action(adjoint(derivative(R, self.f)), self.lmbda)

        self.dJdm = fem.form(dJdm-dRdm)

    def eval_forward(self, f_data: np.ndarray):
        """Compute functional for a given control"""
        # Compute solution
        self.f.x.array[:] = f_data
        self.f.x.scatter_forward()
        self.forward_solver.solve(self.u)
        self.u.x.scatter_forward()
        return self.u.function_space.mesh.comm.allreduce(fem.assemble_scalar(self.J_form), op=MPI.SUM)

    def eval_derivative(self):
        """
        Compute derivative of functional
        """
        self.adjoint_problem.solve()
        self.lmbda.x.scatter_forward()
        dJdm_local = fem.assemble_vector(self.dJdm)
        dJdm_local.scatter_reverse(la.InsertMode.add)
        dJdm_local.scatter_forward()
        return dJdm_local.array[: dJdm_local.index_map.size_local * dJdm_local.block_size]


def taylor_test(cost, grad, m_0, p=1e-2, n=5):
    """
    Compute a Taylor test for a cost function and gradient function from `m_0` in direction `p` 

    """
    l0 = cost(m_0)
    local_gradient = grad()
    global_gradient = np.hstack(MPI.COMM_WORLD.allgather(local_gradient))

    if isinstance(p, float):
        p = np.full_like(m_0, p)
    p_global = np.hstack(MPI.COMM_WORLD.allgather(p[:len(local_gradient)]))
    dJdm = np.dot(global_gradient, p_global)
    remainder = []
    perturbance = []
    for i in range(0, n):
        step = 0.5**i
        l1 = cost(m_0+step*p)
        remainder.append(l1-l0 - step*dJdm)
        perturbance.append(step)
    conv_rate = convergence_rates(remainder, perturbance)
    return remainder, perturbance, conv_rate


def convergence_rates(r, p):
    cr = []  # convergence rates
    for i in range(1, len(p)):
        cr.append(np.log(r[i] / r[i - 1])
                  / np.log(p[i] / p[i - 1]))
    return cr


# mesh and function space
mesh = create_rectangle(MPI.COMM_WORLD, [[0, 0], [1, 1]], [
                        64, 64], CellType.triangle)
V = fem.functionspace(mesh, ("CG", 1))
v = TestFunction(V)
dx = Measure("dx", domain=mesh)


def left(x):
    return np.isclose(x[0], 0)


def right(x):
    return np.isclose(x[0], 1)


def top(x):
    return np.isclose(x[1], 1)


def bottom(x):
    return np.isclose(x[1], 0)


fdim = mesh.topology.dim - 1

uD = fem.Function(V)

left_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, left)
right_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, right)
top_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, top)
bottom_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, bottom)

with uD.vector.localForm() as loc:
    loc.set(1.0)

bc1 = fem.dirichletbc(
    uD, dolfinx.fem.locate_dofs_topological(V, fdim, left_facets))
bc2 = fem.dirichletbc(
    uD, dolfinx.fem.locate_dofs_topological(V, fdim, right_facets))
bc3 = fem.dirichletbc(
    uD, dolfinx.fem.locate_dofs_topological(V, fdim, top_facets))
bc4 = fem.dirichletbc(
    uD, dolfinx.fem.locate_dofs_topological(V, fdim, bottom_facets))

bcs = [bc1, bc2, bc3, bc4]


# Define desired u profile


def profile(x):
    return x[0]*(1-x[0])*x[1]*(1-x[1])


u_obs = fem.Function(V)
u_obs.interpolate(profile)


problem = Problem(V, bcs, u_obs)


f_data = 10*np.random.random(len(problem.f.x.array)).astype(np.float64)
f_0 = np.zeros_like(f_data)
J_0 = problem.eval_forward(f_0)

dJdf = problem.eval_derivative()


error, perturbance, rate = taylor_test(problem.eval_forward,
                                       grad=problem.eval_derivative, m_0=f_0, p=f_data)

if mesh.comm.rank == 0:
    print(f"(1) Error: {error}")
    print(f"(1) Perturbance: {perturbance}")
    print(f"(1) Convergence rate: {rate}")


error, perturbance, rate = taylor_test(problem.eval_forward,
                                       grad=problem.eval_derivative, m_0=f_0)
if mesh.comm.rank == 0:
    print(f"(2) Error: {error}")
    print(f"(2) Perturbance: {perturbance}")
    print(f"(2) Convergence rate: {rate}")

serial:

root@dokken-XPS-15-9560:~/shared/debug# python3 adjoint.py 
(1) Error: [0.021619627022312904, 0.005404906755620248, 0.0013512266889327135, 0.00033780667226231825, 8.445166809268809e-05]
(1) Perturbance: [1.0, 0.5, 0.25, 0.125, 0.0625]
(1) Convergence rate: [1.9999999999887834, 1.9999999999704765, 1.99999999987555, 1.9999999995369027]
(2) Error: [8.501957100197442e-08, 2.1254875918607242e-08, 5.313705366477095e-09, 1.328413713299931e-09, 3.320927149065762e-10]
(2) Perturbance: [1.0, 0.5, 0.25, 0.125, 0.0625]
(2) Convergence rate: [2.0000011424799404, 2.0000036960337733, 2.0000137146484653, 2.000046541057984]

parallel:

mpirun -n 4 python3 adjoint.py 
(1) Error: [0.022252050386877914, 0.00556301259674051, 0.0013907531492012223, 0.00034768828731673515, 8.692207184526728e-05]
(1) Perturbance: [1.0, 0.5, 0.25, 0.125, 0.0625]
(1) Convergence rate: [1.9999999999945455, 1.999999999983304, 1.9999999999318272, 1.9999999997330533]
(2) Error: [8.501958598212478e-08, 2.1254894566423823e-08, 5.313721296212382e-09, 1.3284283672612977e-09, 3.321041497124507e-10]
(2) Perturbance: [1.0, 0.5, 0.25, 0.125, 0.0625]
(2) Convergence rate: [2.000000130939888, 2.000000636782914, 2.000002125106489, 2.0000127807449206]
3 Likes

Thank you @LiborKudela and @dokken for your quick and detailed response. I have a couple of questions:

  1. How would you explain that, running the same code, you get different convergence rates? (i.e., [2.0924435576205673, 2.4596218406630284, 4.589509511644071, nan] instead of what I get [1.8214896414517914, 1.458183253415033, 0.6623141576735944, 0.33714267999803627]). It looks that the test passes in your case for p=1e-6 (apart from the last nan, but I guess this is some kind of numerical error/machine precission).

  2. Why the perturbance parameter p has to be bigger? Shouldn’t the second-order Taylor reminder (reminder variable in the code) tend to zero as we decrease p? As I said before, I understand that very small values lead to machine precission problems, but is it p=1e-6 that small?

Thank you again!

I might be just different versions of dolfinx (I have 0.7.2, from ubuntu PPA). I am not qualified to answer this more precisely.

There is also question of sensitivity of the solution w.r.t. the perturbation. p=1e-6 does not seem too small, but the solution might change e.g. only 1e-20, which would already be lower that tolerances of the forward solver. The machine precision issues actually come out when you do operations with set of floats that are of wildly different orders (something like 1e2 and 1e-16).

There is a thing called V-shape error, when you do this sort of “finite differences”.

If you start with p=1e-6, than the taylor_test function lowers the perturbation n times and the perturbation on the last values in the convergence_rate_values becomes noisy (see the v-shape error getting higher beyond certain point). You need to start with higher value, so it does not hit this limit inside the taylor_test.

2 Likes

If you want to see this behavior in action check out this simple script:

# example
def f(x):
    return x**2

def df(x, p=1e-6):
    return (f(x+p) - f(x))/p

def true_df(x):
    return 2*x

def df_error_absolute(x, p=1e-6):
    return abs(true_df(x) - df(x, p))

def df_error_relative(x, p=1e-6):
    return df_error_absolute(x, p)/true_df(x)

print("Abs. error:")
print(df_error_absolute(1e10, 1e0))  # high x, high p, orders difference acceptable
print(df_error_absolute(1e0, 1e-10)) # small x, small p, orders difference acceptable
print(df_error_absolute(1e0, 1e-16)) # the order diference is too high here
print(df_error_absolute(1e10, 1e-6)) # the order diference is too high here

print("Rel. error:")
print(df_error_relative(1e10, 1e0))  # high x, high p, orders difference acceptable
print(df_error_relative(1e0, 1e-10)) # small x, small p, orders difference acceptable
print(df_error_relative(1e0, 1e-16)) # the order diference is too high here
print(df_error_relative(1e10, 1e-6)) # the order diference is too high here

which outputs this:

Abs. error:
2048.0
1.6548074199818075e-07
2.0
12768000000.0

Rel. error:
1.024e-07
8.274037099909037e-08
1.0
0.6384
2 Likes

I see, it makes sense.
Thank you again for the detailed answer!