Gradient Descent For The Schrรถdinger Equation With Python
Today Iโll discuss a numerical method that is widely applicable to problems in quantum mechanics and quantum chemistry, although it is rarely found in textbooks. Suppose we want to solve the stationary Schrรถdinger equation in its most general form
where ๐ป is the Hamiltonian operator, ๐_๐ is an eigenvector (wave functions) of the Hamiltonian and ๐ธ_๐ the corresponding (energy) eigenvalue. We want to calculate all the different eigenvectors with their energies. In particular, we are interested in the ground state, i.e. the eigenvector with the lowest energy.
The method we discuss here is a variant of gradient descent. But itโs gradient descent in disguise. Consider what happens if we apply ๐ป to some arbitrary wave function ๐_init. We can expand ๐ in terms of the (yet unknown) eigenvectors:
where ๐_0 shall denote the eigenvector with the lowest energy and ๐_๐ the one with the highest energy. Then we have
because
the term with the largest eigenvalue gets bigger and bigger in comparison with the other terms. So in the limit, only that term survives and the resulting vector lies completely in the direction of ๐_๐.
Now, we donโt want the vector with the largest eigenvalue, but the one with the lowest. How can we achieve that? Easy! We just map ๐ป linearly to some other operator, which has the same eigenvectors, but its eigenvalues are reversed. The simplest choice is
where ๐ is some constant to be chosen later. When we apply ๐พ to some eigenvalue of ๐ป, we get
In other words, the eigenvectors of ๐ป are also eigenvectors or ๐พ, but with different eigenvalues. For the H-eigenvector ๐_0 with lowest H-eigenvalue ๐ธ_0 we get
so the corresponding K-eigenvalue is less than 1.
For the H-eigenvector ๐_๐ with highest H-eigenvalue ๐ธ_๐, we get
and the corresponding value is 1.
So we see that the spectrum has been reversed. Now we use the idea above to iterate to the K-eigenvector with highest K-eigenvalue, and this will be the H-eigenvector with lowest H-eigenvalue, the ground state.
The iteration scheme in detail is like that, where the upper index in braces counts iterations:
2. Iterate
where
and N is the normalizing operation
We normalize in each step so that we donโt run out of the range of valid floating point numbers.
Finally, we must choose the step size ๐. In general, it must be
but since we donโt know the energies beforehand, that doesnโt help very much. There are sophisticated methods available to choose an optimal step size, but some experimentation shall suffice for us here.
But why did I call it gradient descent in disguise? Because ๐ป๐ is directly related to the functional derivative of the energy with respect to the wave function:
where ๐ฟ denotes a functional derivative. So this recipe is really a gradient descent.
Now letโs write a general routine for our gradient descent:
import numpy as np
def gradient_descent(H, n_steps, eps, psi_init, integral):
def normalize(psi):
return psi / np.sqrt(integral(psi**2))
psi = normalize(psi_init)
for j in range(n_steps):
E = integral(psi * H(psi))
psi = normalize(psi - eps * (H(psi) - E*psi))
E = integral(psi * H(psi))
return psi, E
We kept this function completely general, so it can be applied to all kind of problems. Letโs apply it to the 1D harmonic oscillator, first. We need to implement the Hamiltonian and the integral operator:
from findiff import FinDiff
x = np.linspace(-5, 5, 100)
dx = x[1] - x[0]
laplace = FinDiff(0, dx, 2)
V = 0.5 * x**2
def H(psi):
return -0.5 * laplace(psi) + V * psi
def integral(f):
return np.trapz(f) * dx
Now we can compute the ground state of the harmonic oscillator:
psi = np.exp(-np.abs(x))
psi, E = gradient_descent(H, 100, 0.01, psi, integral)
E
Out: 0.4997480777288626
The exact value should be 0.5.
import matplotlib.pyplot as plt
plt.plot(x, psi)
Or the hydrogen atom:
x = y = z = np.linspace(-10, 10, 80)
dx = dy = dz = x[1] - x[0]
laplace = FinDiff(0, dx, 2) + FinDiff(1, dy, 2) + FinDiff(2, dz, 2)
X, Y, Z = np.meshgrid(x, y, z, indexing='ij')
R = np.sqrt(X**2 + Y**2 + Z**2)
V = -2 / R
def H(psi):
return -laplace(psi) + V * psi
def integral(f):
return np.trapz(np.trapz(np.trapz(f))) * dx * dy * dz
psi = np.exp(-R**2)
psi, E = gradient_descent(H, 600, 0.01, psi, integral)
E
Out: -0.9848171001223128
The exact value should be -1, so the result is quite crude. This is because we didnโt take care of the singularity at the origin. But thatโs another story.
There is much more that we could discuss, like taking into account constraints to calculate excited states. But I will leave that for a separate post. So stay tuned. Thanks for reading!