Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functions to find correct sector for qubit tapering #2041

Merged
merged 16 commits into from
Jan 6, 2022
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion doc/releases/changelog-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@

* Functions for tapering qubits based on molecular symmetries is added.
[(#1966)](https://github.com/PennyLaneAI/pennylane/pull/1966)
* [(#1974)](https://github.com/PennyLaneAI/pennylane/pull/1974)
[(#1974)](https://github.com/PennyLaneAI/pennylane/pull/1974)
[(#2041)](https://github.com/PennyLaneAI/pennylane/pull/2041)

With this functionality, a molecular Hamiltonian can be transformed to a new Hamiltonian that acts
on a reduced number of qubits.
Expand Down
1 change: 1 addition & 0 deletions pennylane/hf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,5 @@
generate_symmetries,
get_generators,
transform_hamiltonian,
find_optimal_sector,
)
108 changes: 107 additions & 1 deletion pennylane/hf/tapering.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@
This module contains the functions needed for tapering qubits using symmetries.
"""
# pylint: disable=unnecessary-lambda
import functools

import itertools as it
import functools
import scipy as sp
import numpy
import autograd.numpy as anp
import pennylane as qml
from pennylane import numpy as np
Expand Down Expand Up @@ -464,3 +467,106 @@ def transform_hamiltonian(h, generators, paulix_ops, paulix_sector):
c = qml.math.stack(c)

return _simplify(qml.Hamiltonian(c, o))


def _sector_energy(sector):
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
"""Computes energy of the given tapered Hamiltonian.

Args:
sector (pennylane.Hamiltonian): tapered Hamiltonian whose energy has to be calculated
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm still very confused by what we mean by "sector". I thought it was a list of eigenvalues? But here it is a Hamiltonian?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am sorry for the confusion. I somehow used "sector" here to represent the tapered Hamiltonian. I guess while implementing the function I more or less remembered quite a literal definition of the word sector (as in part of the whole). Didn't realize the conflict.


Returns:
energy (float): energy of the tapered hamiltonian
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
"""

if len(sector.wires) < 2:
energy = min(sp.linalg.eigvals(qml.utils.sparse_hamiltonian(sector).toarray()))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't this be very expensive for more complicated Hamiltonians? Thinking long term, if there is ever an experiment attempting to simulate a large system, it may be prohibitive to directly diagonalize the Hamiltonian. Isn't that a problem here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The energy calculation was being done only to support the brute force method. Now that I have removed it, this is not needed either.

else:
energy = sp.sparse.linalg.eigs(qml.utils.sparse_hamiltonian(sector), k=2)[0].min()

return energy


def find_optimal_sector(qubit_op, generators, paulix_ops, brute_force=True, num_electrons=0):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this function should only return the correct sector and not the energy. This means that we should not perform the brute force method here since the user can always call the transform_hamiltonian function with any desired sector. Also, if the user can already obtain the correct sector, then no need to do a brute force at all. Therefore, the args and return might be:

Args: generators, reference HF state
Return: list of eigenvalues

Instead of the reference HF state, we might have mol, core=None, active=None or something similar.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with Soran 💯 Why use an inefficient brute force method when we know a better algorithm?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. My original intention to include brute force was for the sake of completeness. But now that transform_hamiltonian can already allow users to do it already, I also believe that we can skip doing it here.

obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
r"""Get the optimal sector which contains the ground state.

To obtain the optimal sector one can brute force through all the permutation or by utilize the following
relation between the Pauli-Z qubit operator and the occupation number under Jordan-Wigner transform.
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved

.. math::

\sigma_z^{i} = a_{i}^{\dagger}a_{i} - 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be \sigma_z^{i} = 1 - 2 a_{i}^{\dagger}a_{i} or maybe I am missing something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number operator is [[0, 0], [0, 1]] so indeed the formula Soran is quoting should be the correct one. Also, I think it's better to express the number operator in terms of the Pauli operators. I suggest changing to

n_i=a_{i}^{\dagger}a_{i}=I-2\sigma_z^i

But please check that we are using labels on the Pauli operators consistently. I recall in other PRs we were using \sigma_i^z, though I may be wrong

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. Even I'd gotten the same result when I was calculating it by myself. But I wonder why Setia's paper that we are following used this definition (Eq 17).


This relation allows us to figure out whether the orbital is occupied or unoccupied in a given symmetry sector,
to build the correct eigensector.
Copy link
Contributor

@soranjh soranjh Dec 20, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The theory can be explained a bit better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree. How exactly does knowing if an orbital is occupied or not help us build the correct eigensector?


Args:
qubit_op (pennylane.Hamiltonian): Hamiltonian for which symmetries are being generated for performing tapering
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
generators (list[pennylane.Hamiltonian]): list of generators of symmetries, taus, for the Hamiltonian
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
paulix_ops (list[pennylane.operation.Observable]): list of single-qubit Pauli X operators
brute_force (bool): determines whether to use brute-force strategy to pick the correct sector or not
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
num_electrons (int): If `brute_force = True`, user must provide the number of active electrons in
the system for generating the Hartree-Fock bitstring

Returns:
tuple (list[int], float):

* list[int]: eigenvalues corresponding to the optimal sector which contains the ground state
* float: energy of the computed optimal sector
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved

.. code-block:: python
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved

>>> symbols = ['H', 'H']
>>> coordinates = np.array([0., 0., -0.66140414, 0., 0., 0.66140414]))
>>> mol = qml.hf.Molecule(symbols, coordinates)
>>> H, qubits = qml.hf.generate_hamiltonian(mol)(), 4
>>> generators, paulix_ops = generate_symmetries(H, qubits)
>>> find_optimal_sector(H, generators, paulix_ops, False, 2)
([1, -1, -1], -1.1372701746609024)

"""

if not brute_force:

if num_electrons < 1:
raise ValueError(
f"Brute force search is disabled;"
f"the number of electrons must be provided and should be greater than zero;"
f"got 'electrons'={num_electrons}"
)

num_orbitals = len(qubit_op.wires)

if num_electrons > num_orbitals:
raise ValueError(
f"Number of active orbitals cannot be smaller than number of active electrons;"
f" got 'orbitals'={num_orbitals} < 'electrons'={num_electrons}."
)

hf_str = np.where(np.arange(num_orbitals) < num_electrons, 1, 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clever implementation!

perm = []

for op in generators:
symmstr = np.zeros(len(qubit_op.wires), dtype=int)
for wire in op.wires:
symmstr[wire] = 1
coeff = -1 if numpy.logical_xor.reduce(numpy.logical_and(symmstr, hf_str)) else 1
perm.append(coeff)

sector = transform_hamiltonian(qubit_op, generators, paulix_ops, perm)
energy = _sector_energy(sector)
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved

return perm, energy

sectors = []
energies = []
perms = list(it.product([1, -1], repeat=len(generators)))

for perm in perms:
sector = transform_hamiltonian(qubit_op, generators, paulix_ops, perm)
energy = _sector_energy(sector)
sectors.append(sector)
energies.append(float(energy.real))

index = energies.index(min(energies))
return list(perms[index]), energies[index]
149 changes: 96 additions & 53 deletions tests/hf/test_tapering.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"""
Unit tests for functions needed for qubit tapering.
"""
import pennylane as qml
import pytest
import pennylane as qml
from pennylane import numpy as np
from pennylane.hf.tapering import (
_binary_matrix,
Expand All @@ -28,6 +28,8 @@
generate_symmetries,
get_generators,
transform_hamiltonian,
_sector_energy,
find_optimal_sector,
)


Expand Down Expand Up @@ -286,59 +288,11 @@ def test_generate_paulis(generators, num_qubits, result):


@pytest.mark.parametrize(
("hamiltonian", "num_qubits", "res_generators", "res_pauli_ops"),
("symbols", "geometry", "num_qubits", "res_generators", "res_pauli_ops"),
[
(
qml.Hamiltonian(
np.array(
[
-0.09886397,
0.17119775,
0.17119775,
-0.22278593,
-0.22278593,
0.16862219,
0.0453222,
-0.0453222,
-0.0453222,
0.0453222,
0.12054482,
0.16586702,
0.16586702,
0.12054482,
0.17434844,
]
),
[
qml.Identity(wires=[0]),
qml.PauliZ(wires=[0]),
qml.PauliZ(wires=[1]),
qml.PauliZ(wires=[2]),
qml.PauliZ(wires=[3]),
qml.PauliZ(wires=[0]) @ qml.PauliZ(wires=[1]),
qml.PauliY(wires=[0])
@ qml.PauliX(wires=[1])
@ qml.PauliX(wires=[2])
@ qml.PauliY(wires=[3]),
qml.PauliY(wires=[0])
@ qml.PauliY(wires=[1])
@ qml.PauliX(wires=[2])
@ qml.PauliX(wires=[3]),
qml.PauliX(wires=[0])
@ qml.PauliX(wires=[1])
@ qml.PauliY(wires=[2])
@ qml.PauliY(wires=[3]),
qml.PauliX(wires=[0])
@ qml.PauliY(wires=[1])
@ qml.PauliY(wires=[2])
@ qml.PauliX(wires=[3]),
qml.PauliZ(wires=[0]) @ qml.PauliZ(wires=[2]),
qml.PauliZ(wires=[0]) @ qml.PauliZ(wires=[3]),
qml.PauliZ(wires=[1]) @ qml.PauliZ(wires=[2]),
qml.PauliZ(wires=[1]) @ qml.PauliZ(wires=[3]),
qml.PauliZ(wires=[2]) @ qml.PauliZ(wires=[3]),
],
),
["H", "H"],
np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.40104295]], requires_grad=False),
4,
[
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(1)]),
Expand All @@ -349,10 +303,13 @@ def test_generate_paulis(generators, num_qubits, result):
),
],
)
def test_generate_symmetries(hamiltonian, num_qubits, res_generators, res_pauli_ops):
def test_generate_symmetries(symbols, geometry, num_qubits, res_generators, res_pauli_ops):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this change being made?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To avoid hardcoding the input Hamiltonian which could be huge for any molecule other than H2.

r"""Test that generate_symmetries returns the correct result."""

mol = qml.hf.Molecule(symbols, geometry)
hamiltonian = qml.hf.generate_hamiltonian(mol)()
generators, pauli_ops = generate_symmetries(hamiltonian, num_qubits)

for g1, g2 in zip(generators, res_generators):
assert g1.compare(g2)

Expand Down Expand Up @@ -499,3 +456,89 @@ def test_transform_hamiltonian(symbols, geometry, generator, paulix_ops, paulix_
for i, term in enumerate(sorted_terms):
assert np.allclose(term[0], ham_ref.terms[0][i])
assert term[1].compare(ham_ref.terms[1][i])


@pytest.mark.parametrize(
soranjh marked this conversation as resolved.
Show resolved Hide resolved
("symbols", "geometry", "result"),
[
(
["H", "H"],
np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.40104295]], requires_grad=False),
-1.137270174,
),
],
)
def test_sector_energy(symbols, geometry, result):
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
r"""Test that _sector_energy returns the correct result."""
mol = qml.hf.Molecule(symbols, geometry)
hamiltonian = qml.hf.generate_hamiltonian(mol)()
energy = _sector_energy(hamiltonian)
assert np.isclose(energy, result)


@pytest.mark.parametrize(
("symbols", "geometry", "generators", "paulix_ops", "result"),
[
(
["H", "H"],
np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.40104295]], requires_grad=False),
[
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(1)]),
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(2)]),
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(3)]),
],
[qml.PauliX(wires=[1]), qml.PauliX(wires=[2]), qml.PauliX(wires=[3])],
([1, -1, -1], -1.137270174),
),
],
)
@pytest.mark.parametrize(("brute_force", "num_electrons"), [(True, 0), (False, 2)])
obliviateandsurrender marked this conversation as resolved.
Show resolved Hide resolved
def test_find_optimal_sector(
symbols, geometry, generators, paulix_ops, brute_force, num_electrons, result
):
r"""Test that find_optimal_sector returns the correct result."""
mol = qml.hf.Molecule(symbols, geometry)
hamiltonian = qml.hf.generate_hamiltonian(mol)()

perm, energy = find_optimal_sector(
hamiltonian, generators, paulix_ops, brute_force, num_electrons
)

for sec in zip(perm, result[0]):
assert sec[0] == sec[1]
assert np.isclose(energy, result[1])


@pytest.mark.parametrize(
("symbols", "geometry", "generators", "paulix_ops"),
[
(
["H", "H"],
np.array([[0.0, 0.0, 0.0], [0.0, 0.0, 1.40104295]], requires_grad=False),
[
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(1)]),
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(2)]),
qml.Hamiltonian([1.0], [qml.PauliZ(0) @ qml.PauliZ(3)]),
],
[qml.PauliX(wires=[1]), qml.PauliX(wires=[2]), qml.PauliX(wires=[3])],
),
],
)
@pytest.mark.parametrize(
("brute_force", "num_electrons", "msg_match"),
[
(False, 0, f"Brute force search is disabled"),
(False, 5, f"Number of active orbitals cannot be smaller than number of active electrons"),
],
)
def test_exceptions_find_optimal_sector(
symbols, geometry, generators, paulix_ops, brute_force, num_electrons, msg_match
):
r"""Test that find_optimal_sector returns the correct result."""
mol = qml.hf.Molecule(symbols, geometry)
hamiltonian = qml.hf.generate_hamiltonian(mol)()

with pytest.raises(ValueError, match=msg_match):
perm, energy = find_optimal_sector(
hamiltonian, generators, paulix_ops, brute_force, num_electrons
)