Optimizing water's molecular geometry

Hi @ImranNasrullah. The issue is very likely due to using the finite difference method which makes gradient computations problematic in cases where the sign of some of the Hamiltonian terms changes with small perturbations. There are a couple of possible ways to fix it. Here I mention one:

You can optimise the geometry with automatic differentiation, the details are explained in this demo. It could be expensive for water though. Please see this example and modify it for larger circuits and active spaces.

from autograd import grad
import pennylane as qml
from pennylane import numpy as np

symbols = ["O", "H", "H"]
geometry = np.array([[0.028,  0.054,  0.10],
                     [0.986,  1.610, -0.10],
                     [1.855,  0.002,  0.20]], requires_grad=True)

mol = qml.qchem.Molecule(symbols, geometry)

dev = qml.device("default.qubit")

def energy(mol):
    @qml.qnode(dev, interface="autograd")
    def circuit(*args):
        
        # note that active_electrons=2, active_orbitals=2 in this example
        qml.BasisState(np.array([1, 1, 0, 0]), wires=range(4))
        
        # note that this simple circuit has only one gate
        qml.DoubleExcitation(*args[0][0], wires=[0, 1, 2, 3])
        
        # note that active_electrons=2, active_orbitals=2 in this example
        H = qml.qchem.molecular_hamiltonian(mol.symbols, mol.coordinates,
            active_electrons=2, active_orbitals=2, args=args[1:])[0]
        
        return qml.expval(H)
    return circuit

# number of zeros should match the number of circuit gates
circuit_param = [np.array([0.0], requires_grad=True)]

for n in range(36):

    args = [circuit_param, geometry]
    mol = qml.qchem.Molecule(symbols, geometry)

    g_param = grad(energy(mol), argnum = 0)(*args)
    circuit_param = circuit_param - 0.25 * g_param[0]

    forces = -grad(energy(mol), argnum = 1)(*args)
    geometry = geometry + 0.5 * forces

    print(f'n: {n}, E: {energy(mol)(*args):.8f}, Force-max: {abs(forces).max():.8f}')

Please let me know how it goes and if it is too expensive, we work on using a different HF solver.

Hello Soran,
I tried out the code you sent me, this time with:
1). The full 8 electrons and 6 molecular orbitals.
2). All the selected double and single excitation gates that came from the ‘ADAPT-VQE’ algorithm.

The great news is that the algorithm jumped immediately to the proper values, and was quite fast at doing so (only like a minute for these 3 steps):

However soon after, the algorithm apparently can’t calculate any more energy eigenvalues:

If I go to the bottom of the error message, here’s what is displayed:

I’ve attached the full code to my github repo if you could kindly take a look. The file is called: water_8e_6mo-automatic-dhf.ipynb

Thanks @ImranNasrullah for the update. Could you please start from this more realistic initial geometry and let me know the results?

symbols = ["O", "H", "H"]
geometry = np.array([[0.10000000,  0.10000000,  0.28377432],
                     [0.10000000,  1.45278171, -1.00662237],
                     [0.10000000, -1.45278171, -1.00662237]], requires_grad=True)

Actually quick update, I modified a section of the code, and I used the old(er) coordinates you gave me:

symbols = ["O", "H", "H"]
geometry = np.array([[0.028,  0.054,  0.10],
                     [0.986,  1.610, -0.10],
                     [1.855,  0.002,  0.20]], requires_grad=True)

Here are the results I had after the quick modification:



Looks like it’s good to go! I’ll try it with the more “realistic” coordinates you sent:

symbols = ["O", "H", "H"]
geometry = np.array([[0.10000000,  0.10000000,  0.28377432],
                     [0.10000000,  1.45278171, -1.00662237],
                     [0.10000000, -1.45278171, -1.00662237]], requires_grad=True)

Edit: I did, and here are the results:



So for two different initializations, the algorithm converged very neatly

Thanks @ImranNasrullah, looks great!

Hello @sjahangiri , given this piece of code:

for n in range(100):
    args = [circuit_param, z_matrix]
    mol = qml.qchem.Molecule(symbols, geometry)

    g_param = grad(energy(mol), argnum = 0)(*args)
    circuit_param = circuit_param - 0.25 * g_param[0]

    forces = -grad(energy(mol), argnum = 1)(*args)
    z_matrix = z_matrix + (0.5 * forces) # forces should be a 2D vector of just the gradient updates of 'r' and 'theta'
    geometry = build_geometry(z_matrix[0][0], z_matrix[0][1])

where I’m choosing the optimize water in terms of two parameters ‘r’ and ‘theta’, how would I adapt this code for that? in this piece of code, z_matrix is similar to the geometry vector from the original code, except z_matrix will only store the OH bond length, and the angle betwen both OH bonds, rather then storing all 9 euclidean coordinates of water.

The issue I’m facing is, how would I modify the code so that forces = -grad(energy(mol), argnum = 1)(*args) will return a 2-D vector that is the updates for OH bond length, and OH1-OH2 bond angle, or ‘r’ and ‘theta’ respectively?

Update: I modified the energy(mol) function so that the forces vector is now:

I’m attaching a minimally working example here in case you could kindly take a look at it:

# Minimally working and self-contained example:
# imports and functions:
import pennylane as qml
from pennylane import qchem
from pennylane import numpy as np
from autograd import grad
import time
import math
bohr_angs = 0.529177210903  

def unit_vector(vector):
    """ Returns the unit vector of the vector.  """
    return vector / np.linalg.norm(vector)
def angle_between(v1, v2):
    """ Returns the angle in radians between vectors 'v1' and 'v2'::
    """
    v1_u = unit_vector(v1)
    v2_u = unit_vector(v2)
    return (np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) * 180/math.pi) #convert to degrees

def build_geometry(r, angle): # prepares water's xyz coordinates given a bond length and angle
    if (not isinstance(r, np.ndarray)): 
        # If argnum=1 in 'energy(mol)' function, the type of 'r' and 'angle' are for some reason of ArrayBox.
        # So to get their values, I use the '._value' attribute but not sure if this is right
        # Otherwise, if argnum=0 in 'energy(mol)', then these values are tensor numpy arrays, so no issue
        r = r._value
        angle = angle._value   
    bohr_angs = 0.529177210903   
    r = r/bohr_angs # converts from units of angstroms back to units of Bohrs
    
    x = np.array([[0.10000000,  0.10000000,  0.28377432],
                     [0.10000000,  1.45278171, -1.00662237],
                     [0.10000000, -1.45278171, -1.00662237]], requires_grad=True)
    
    # O1's nuclear coordinates, is positioned at x,y,z: (0,0,0)
    x[0][0] = 0.0; x[0][1] = 0.0; x[0][2] = 0.0

    # H1's nuclear coordinates, is positioned at x,y,z: (r,0,0)
    x[1][0] = r; x[1][1] = 0.0; x[1][2] = 0.0
    
    # H2's nuclear coordinates, is position at x,y,z: (r*cos(angle), r*sin(angle), 0)
    x[2][0] = r * np.cos(angle * np.pi/180); x[2][1] = r * np.sin(angle * np.pi/180); x[2][2] = 0.0
        
    return x 

# Initializations
symbols = ["O", "H", "H"]
geometry = np.array([[0.10000000,  0.10000000,  0.28377432],
                     [0.10000000,  1.45278171, -1.00662237],
                     [0.10000000, -1.45278171, -1.00662237]], requires_grad=True)
# Initialize starting r and starting theta
OH1 = geometry[0] - geometry[1]
OH2 = geometry[0] - geometry[2]
r = np.linalg.norm(geometry[0] - geometry[1]) * bohr_angs
theta = angle_between(OH1, OH2)
z_matrix = [np.array([r], requires_grad=True)] + [np.array([theta], requires_grad=True)]

# re-initialize geometry:
geometry = build_geometry(z_matrix[0], z_matrix[1])

mol = qml.qchem.Molecule(symbols, geometry)
dev = qml.device("default.qubit")

def energy(mol):
    @qml.qnode(dev, interface="autograd")
    def circuit(*args):
        # note that active_electrons=2, active_orbitals=2 in this example
        qml.BasisState([1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0], wires=range(12))
        
        # Just apply one gate for simplicity:
        qml.DoubleExcitation(*args[0][0], wires=[0, 1, 2, 3])
        
        H = qml.qchem.molecular_hamiltonian(mol.symbols, build_geometry(*args[1][0], *args[1][1]),
        active_electrons=8, active_orbitals=6, args=[build_geometry(*args[1][0], *args[1][1])])[0]

        return qml.expval(H)
    return circuit

circuit_param = [np.array([0.0], requires_grad=True)] * 1

for n in range(1): # Just run 1 iteration

    args = [circuit_param, z_matrix] # original line: args = [circuit_param, geometry]
    mol = qml.qchem.Molecule(symbols, geometry)

    g_param = grad(energy(mol), argnum = 0)(*args)
    circuit_param = circuit_param - 0.25 * g_param[0]

    forces = -np.array(grad(energy(mol), argnum = 1)(*args)) # without np.array(), this line returns "TypeError: bad operand type for unary -: 'list'""
    print("Forces vector:", forces)
    z_matrix = z_matrix + (0.5 * forces) # update just 2 parameters
    geometry = build_geometry(z_matrix[0][0], z_matrix[1][0]) # re-update geometry from these 2 updated-parameters

    print(f'n: {n}, E: {energy(mol)(*args):.8f}, Force-max: {abs(forces).max():.8f}')

Hi @ImranNasrullah, could you please run the energy function, with no gradient calculation, and see if it runs correctly? Let me know if you still have issues with it.

Hello @sjahangiri thanks for the response! I used my modified ‘energy’ function, along with a modified ‘args’ vector that only one circuit parameter and the z-matrix function. I believe it was able to run correctly.

However, I see that the issue is that it seems the gradient calculation for the energy is independent of the inputs I’m trying to use, ‘r’ and ‘theta’ as seen in the error message. This is what I need help with, and I can’t figure out how to make the ‘grad’ function sense that the energ() function is a function of both ‘r’ and ‘theta’.

Please let me know if you have any thoughts on this, thanks

(Just to clarify, I was able to have VQE working with all 9 euclidean-coordinates using automatic dhf). I’m now trying to do something extra where I can optimize water in terms of two coordinates r and theta instead of 9 spatial-coordinates)

Hi @ImranNasrullah, the double excitation wires are not correct in the code. The DoubleExcitation operator must excite electrons to empty orbitals.

I recommend going step-by-step and only change a small piece of the working code at a time to make it easy to debug. There is very likely a syntactic error somewhere.
Please let me know when you narrow down the issue and we can discuss more. Thanks!

1 Like

Hello @sjahangiri

As shown in the picture, I moved the double-excitation gates to excite electrons to appropriate, empty orbitals. Would you happen to know how I can get rid of the “Output seems independent of input” error? In my opinion, this is what is causing the force vector to be 0, as derivatives of constants are 0

In addition, do you know how to make the finite-difference method more robust? Even though automatic dhf works now for optimizing water’s full 9-coordinates, finite-diff is more preferable as their is more control in how we’d like to calculate the gradients, such as in optimizing with respect to just two coordinates for water, r and theta?
If not, then I’ll have to stick with automatic-dhf, but I’m just wondering if it’s even possible to using automatic-dhf in terms of ‘r’ and ‘theta’? I was able to do it with finite-diff though

Thanks so much!

Hi @ImranNasrullah. The error simply happens because for some reason, changing the parameters does not affect the final output. So the gradients are always zero. It should be definitely possible to optimise r and theta with the autodif approach, just need to be a bit careful with the way you define new functions etc. That is why I suggest starting from a very simple code, that for example, optimises one parameter and then try to evolve it step-by-step to the final version you want.

By the way, it is very interesting that you use qchem for this advanced qchem workflows! Please follow the step-by-step path and if still have problem, let me know.

Thanks so much @sjahangiri ! The only thing is, I’m not sure where to approach the error from here. I know there’s some modification to the energy(mol) function, and I thought all I had to do was that everywhere there was the coordinates from the old energy(mol) function, swap it out with a function of r, and theta.

Since that didn’t work, I’m not sure how to approach this, any advice would be appreciated on what to change

Hello @sjahangiri , wondering if you were able to get back with me? A bit urgent, as I’m completing this for a research project and not sure how to proceed. Specifically not sure how to have the energy(mol) function have a dependence on just two parameters, r and theta, rather then all 9 euclidean-distance parameters

Hi @ImranNasrullah , this is a simple working code for H2 that optimises the bond length. You can use it as a base for more complicated molecules and optimisations. Please note that you need some experience with autograd and please refer to this demo for more details on optimising molecular properties with automatic differentiation.

from autograd import grad
import pennylane as qml
from pennylane import numpy as np

symbols = ["H", "H"]
geometry = np.array([[0.028,  0.000,  0.0000],
                     [1.500,  0.000, -0.1000]], requires_grad=True)

mol = qml.qchem.Molecule(symbols, geometry)

dev = qml.device("default.qubit")

def energy(mol):
    @qml.qnode(dev, interface="autograd")
    def circuit(*args):
        
        # re-define geometry according to r
        geometry = mol.coordinates
        zeros = np.zeros((geometry.shape))
        zeros[1][0] = 1
        geometry = geometry + zeros * args[1][0]

        
        # note that active_electrons=2, active_orbitals=2 in this example
        qml.BasisState(np.array([1, 1, 0, 0]), wires=range(4))
        
        # # note that this simple circuit has only one gate
        qml.DoubleExcitation(*args[0][0], wires=[0, 1, 2, 3])
        
        # note that active_electrons=2, active_orbitals=2 in this example
        H = qml.qchem.molecular_hamiltonian(mol.symbols, mol.coordinates,
            active_electrons=2, active_orbitals=2, args=[geometry])[0]
        
        return qml.expval(H)
    return circuit


# number of zeros should match the number of circuit gates
circuit_param = [np.array([0.0], requires_grad=True)]

# define the initial bond length
r = np.array([1.500], requires_grad=True)

for n in range(30):

    args = [circuit_param, r]
    geometry[1][0] = args[1][0]
    
    mol = qml.qchem.Molecule(symbols, geometry)

    g_param = grad(energy(mol), argnum = 0)(*args)
    circuit_param = circuit_param - 0.25 * g_param[0]

    forces = -grad(energy(mol), argnum = 1)(*args)
    r = r + 0.5 * forces
    geometry[1][0] = geometry[1][0] + 0.5 * forces

    print(f'n: {n}, E: {energy(mol)(*args):.8f}, Force-max: {abs(forces).max():.8f}, Bond-length: {r[0]:.3f}')

Hello @sjahangiri,
Thanks for the reply! Question on the code. What is the purpose of:

      # re-define geometry according to r
      geometry = mol.coordinates
      zeros = np.zeros((geometry.shape))
      zeros[1][0] = 1
      geometry = geometry + zeros * args[1][0]

It seems like you’re adding the value of the parameter r to one of the geometry coordinates, and you use this for the args parameter of qml.qchem.molecular_hamiltonian. I know that the args parameter controls the value of differentiable parameters for the Hamiltonian, but I’m not sure what it does to set the ‘geometry’ variable to args.

Thanks

Hi @ImranNasrullah, the name geometry is just a name here. You can alternatively use args or any other name. The point is that the args parameter passed to molecular_hamiltonian must have the shape of the geometry object. That means you cannot just pass the bond length to the molecular_hamiltonian function as args. So the bond length parameter r is used to recreate the args parameter with a similar shape to geometry and then this args is passed to molecular_hamiltonian. Please let me know if it is not yet clear.

Sorry, let me clarify my question:

What is the reason for offsetting geometry 's [1][0] coordinate by args[1][0], and setting this to args parameter of the qml.qchem.molecular_hamiltonian function? I notice that if you don’t do the offset, both the “args” parameter and mol.coordinates are the same, and there is 0 gradient.

Seems like the only way for the code to work is both args and mol.coordinates are different, and I’m wondering why that is the case

In addition it seems like the actual bond length and the ‘r’ coordinates come out to different values at the end:

Actually, I changed some stuff up, and I was able to get both to be the same:

Now, moving onto adding an angle in there!

Hello @sjahangiri , I was able to get a working code for water, optimizing both r and theta (angle between both OH) bonds. Upon initializing the water to:

geometry = np.array([[0.028,  0.054,  0.0],
                     [0.986,  1.610, 0.0],
                     [1.855, 0.002, 0.0]], requires_grad=True)

Which i think is the geometry you gave me thats around 60 degrees, the algorithm diverges. I’m wondering why this is the case, as I thought autograd was super accurate?