Practical error bounds for the forces

DFTK includes an implementation of the strategy from [CDKL2022] to compute practical error bounds for forces and other quantities of interest.

This is an example showing how to compute error estimates for the forces on a ${\rm TiO}_2$ molecule, from which we can either compute asymptotically valid error bounds or increase the precision on the computation of the forces.

using DFTK
using Printf
using LinearAlgebra
using ForwardDiff

Setup

We setup manually the ${\rm TiO}_2$ configuration from Materials Project.

Ti = ElementPsp(:Ti, load_psp("hgh/lda/ti-q4.hgh"))
O  = ElementPsp(:O,  load_psp("hgh/lda/o-q6.hgh"))
atoms     = [Ti, Ti, O, O, O, O]
positions = [[0.5,     0.5,     0.5],  # Ti
             [0.0,     0.0,     0.0],  # Ti
             [0.19542, 0.80458, 0.5],  # O
             [0.80458, 0.19542, 0.5],  # O
             [0.30458, 0.30458, 0.0],  # O
             [0.69542, 0.69542, 0.0]]  # O
lattice   = [[8.79341  0.0      0.0];
             [0.0      8.79341  0.0];
             [0.0      0.0      5.61098]];

We apply a small displacement to one of the $\rm Ti$ atoms to get nonzero forces.

positions[1] .+= [-0.022, 0.028, 0.035]
3-element Vector{Float64}:
 0.478
 0.528
 0.535

We build a model with one $k$-point only, not too high Ecut_ref and small tolerance to limit computational time. These parameters can be increased for more precise results.

model = model_DFT(lattice, atoms, positions; functionals=LDA())
kgrid = [1, 1, 1]
Ecut_ref = 35
basis_ref = PlaneWaveBasis(model; Ecut=Ecut_ref, kgrid)
tol = 1e-5;

We also build a basis with smaller Ecut, to compute a variational approximation of the reference solution.

Choice of convergence parameters

Be careful to choose Ecut not too close to Ecut_ref. Note also that the current choice Ecut_ref = 35 is such that the reference solution is not converged and Ecut = 15 is such that the asymptotic regime (crucial to validate the approach) is barely established.

Ecut = 15
basis = PlaneWaveBasis(model; Ecut, kgrid);

Computations

Compute the solution on the smaller basis:

scfres = self_consistent_field(basis; tol, callback=identity);

Compute first order corrections refinement.δψ and refinement.δρ. Note that refinement.ψ and refinement.ρ are the quantities computed with Ecut and then extended to the reference grid. This step is roughly as expensive as the self_consistent_field call above.

refinement = refine_scfres(scfres, basis_ref; tol, callback=identity);

Error estimates

  • Computation of the force from the variational solution without any post-processing:
f = compute_forces(scfres)
6-element Vector{StaticArraysCore.SVector{3, Float64}}:
 [1.1354592125650875, -1.0152800921595546, 0.40013733460598955]
 [-0.5924500556248098, 0.8447309648704835, 0.4691171994572463]
 [-3.662708723400401, 1.4344898846418797, -0.4095539232932976]
 [0.9251806861973142, 0.9710628028762223, -0.14999476709108925]
 [2.6530156539920675, 0.39939609910732976, -0.12658459617277784]
 [-0.45853955729501017, -2.6344577044977093, -0.1826863321984593]
  • Computation of the forces by a linearization argument when replacing the error $P-P_*$ by the modified residual $R_{\rm Schur}(P)$. The latter quantity is computable in practice.
force_refinement = refine_forces(refinement)
forces_refined = f + force_refinement.dF
6-element Vector{StaticArraysCore.SVector{3, Float64}}:
 [1.2910406613778544, -1.1015927432167192, 0.6908030035664467]
 [-0.40995445530388996, 0.7109648509773729, 0.5337602589801487]
 [-4.2031554250618175, 1.62584924061604, -0.6027076111733753]
 [0.8244359207045978, 1.2387920397250687, -0.3457586635967996]
 [3.015567635004705, 0.490597578617507, -0.09430379456637827]
 [-0.5197679830828569, -2.962435189756391, -0.17946664850433525]

A practical estimate of the error on the forces is then the following:

dF_estimate = forces_refined - f
6-element Vector{StaticArraysCore.SVector{3, Float64}}:
 [0.15558144881276692, -0.08631265105716457, 0.2906656689604572]
 [0.18249560032091983, -0.13376611389311055, 0.06464305952290239]
 [-0.5404467016614163, 0.19135935597416043, -0.19315368788007775]
 [-0.10074476549271638, 0.2677292368488464, -0.19576389650571033]
 [0.36255198101263764, 0.09120147951017726, 0.03228080160639957]
 [-0.06122842578784671, -0.32797748525868187, 0.0032196836941240647]

Comparisons against non-practical estimates.

For practical computations one can stop at forces_refined and dF_estimate. We continue here with a comparison of different ways to obtain the refined forces, noting that the computational cost is much higher.

Computations

We compute the reference solution $P_*$ from which we will compute the references forces.

scfres_ref = self_consistent_field(basis_ref; tol, callback=identity)
ψ_ref = DFTK.select_occupied_orbitals(basis_ref, scfres_ref.ψ, scfres_ref.occupation).ψ;
  • Compute the error $P-P_*$ on the associated orbitals $ϕ-ψ$ after aligning them: this is done by solving $\min |ϕ - ψU|$ for $U$ unitary matrix of size $N×N$ ($N$ being the number of electrons) whose solution is $U = S(S^*S)^{-1/2}$ where $S$ is the overlap matrix $ψ^*ϕ$.
function compute_error(ϕ, ψ)
    map(zip(ϕ, ψ)) do (ϕk, ψk)
        S = ψk'ϕk
        U = S*(S'S)^(-1/2)
        ϕk - ψk*U
    end
end
error = compute_error(refinement.ψ, ψ_ref);

Error estimates

We start with different estimations of the forces:

  • Force from the reference solution
f_ref = compute_forces(scfres_ref)
forces   = Dict("F(P_*)" => f_ref)
relerror = Dict("F(P_*)" => 0.0)
compute_relerror(f) = norm(f - f_ref) / norm(f_ref);
  • Force from the variational solution and relative error without any post-processing:
forces["F(P)"]   = f

relerror["F(P)"] = compute_relerror(f);

We then try to improve $F(P)$ using the first order linearization:

\[F(P) = F(P_*) + {\rm d}F(P)·(P-P_*).\]

To this end, we use the ForwardDiff.jl package to compute ${\rm d}F(P)$ using automatic differentiation.

function df(basis, occupation, ψ, δψ, ρ)
    δρ = DFTK.compute_δρ(basis, ψ, δψ, occupation)
    ForwardDiff.derivative(ε -> compute_forces(basis, ψ.+ε.*δψ, occupation; ρ=ρ+ε.*δρ), 0)
end;
  • Computation of the forces by a linearization argument if we have access to the actual error $P-P_*$. Usually this is of course not the case, but this is the "best" improvement we can hope for with a linearisation, so we are aiming for this precision.
df_err = df(basis_ref, refinement.occupation, refinement.ψ,
            DFTK.proj_tangent(error, refinement.ψ), refinement.ρ)
forces["F(P) - df(P)⋅(P-P_*)"]   = f - df_err
relerror["F(P) - df(P)⋅(P-P_*)"] = compute_relerror(f - df_err);
  • Computation of the forces by a linearization argument when replacing the error $P-P_*$ by the modified residual $R_{\rm Schur}(P)$. The latter quantity is computable in practice.
forces["F(P) - df(P)⋅Rschur(P)"]   = forces_refined
relerror["F(P) - df(P)⋅Rschur(P)"] = compute_relerror(forces_refined);

Summary of all forces on the first atom (Ti)

for (key, value) in pairs(forces)
    @printf("%30s = [%7.5f, %7.5f, %7.5f]   (rel. error: %7.5f)\n",
            key, (value[1])..., relerror[key])
end
                        F(P_*) = [1.47900, -1.25369, 0.81011]   (rel. error: 0.00000)
                          F(P) = [1.13546, -1.01528, 0.40014]   (rel. error: 0.20484)
        F(P) - df(P)⋅Rschur(P) = [1.29104, -1.10159, 0.69080]   (rel. error: 0.07837)
          F(P) - df(P)⋅(P-P_*) = [1.50906, -1.28630, 0.86146]   (rel. error: 0.08072)

Notice how close the computable expression $F(P) - {\rm d}F(P)⋅R_{\rm Schur}(P)$ is to the best linearization ansatz $F(P) - {\rm d}F(P)⋅(P-P_*)$.