Block encoding of molecular Hamiltonian¶
Quantum computers only run unitary operations. However, the qubit Hamiltonians that were mapped from molecular Hamiltonians are not unitary. Block encoding a matrix representation of the molecular Hamiltonian allows their processing by quantum algorithms like time evolution and other matrix functions. The objective of block encoding is to embed a (scaled) operator \(H/\lambda\) into the upper-left block of a matrix U using ancilla qubits:
For a normalization factor \(\lambda \geq ||H||\) the block encoding satisfies
In this tutorial, we shall start with the molecular Hamiltonian for H2, map that to a qubit Hamiltonian using Jordan-Wigner mapping, and then block encode the qubit Hamiltonian into a circuit with unitary \(U\).
from qibochem.driver import Molecule
import numpy as np
# perform the PySCF calculation to get the integrals and thus, second-quantized Hamiltonian
h2 = Molecule([('H', (0.0, 0.0, 0.0)), ('H', (0.0, 0.0, 0.7))])
h2.run_pyscf()
# retrieve the symbolic qubit hamiltonian, which is already a linear combination of unitaries (LCU)
sym_hamiltonian = h2.hamiltonian('sym')
[Qibo 0.3.4|INFO|2026-08-28 17:17:49]: Using numpy backend on /CPU:0
# obtain the coefficients, Pauli operators, and the target qubits from the Hamiltonian
coeffs = sym_hamiltonian.simple_terms[0]
raw_opp = sym_hamiltonian.simple_terms[1]
raw_opq = sym_hamiltonian.simple_terms[2]
# reverse the order of the Pauli operations for each term to effect their order of application on the input state
opp = [p[::-1] for p in raw_opp]
opq = [q[::-1] for q in raw_opq]
# append the constant term:
coeffs.append(sym_hamiltonian.constant)
opp.append('I')
opq.append((0,))
PREP circuit¶
For our Hamiltonian, which is already a linear combination of unitaries:
the PREP circuit takes in the \(|0\rangle\) state and encodes:
We need to:
calculate
lcu_norm, the normalization factor \(\lambda\)calculate
alphas, the coefficients of the basis states \(|k\rangle\), that is, \(\sqrt{\frac{|\alpha_k|}{\lambda}}\)determine the number of qubits to represent the basis states \(|k\rangle\)
Encode them using some binary encoder; in our case we shall use the Mottonen-complex encoder available in
qibo.models.encodings.
lcu_norm = np.sum(np.abs(coeffs))
alphas = np.sqrt(np.abs(coeffs)/lcu_norm)
# ensure that the state |k> will be normalized
print('Norm of |k> = ', np.linalg.norm(alphas))
print('Number of terms in |k> = ', len(alphas))
Norm of |k> = 1.0 Number of terms in |k> = 15
# determine qubits needed
nq1 = int(np.ceil(np.log2(len(alphas))))
# pad the alpha vector such that it has the exact number of terms for nq1 qubits
pad_size = (2 ** nq1) - len(alphas)
temp1 = np.array(alphas, dtype=complex)
temp2 = np.zeros(pad_size, dtype=complex)
pad_alphas = np.concatenate((temp1, temp2))
# encode this state vector into the circuit.
from qibo.models.encodings import binary_encoder
circuit_PREP = binary_encoder(nqubits=nq1, parametrization='mottonen-complex', data=pad_alphas)
circuit_PREP.draw()
0: ─RY─o────o─────────o─────────o───────────────────o───────────────────o ...
1: ─RY─X─RY─X────o────|────o────|─────────o─────────|─────────o─────────| ...
2: ───────────RY─X─RY─X─RY─X─RY─X────o────|────o────|────o────|────o────| ...
3: ───────────────────────────────RY─X─RY─X─RY─X─RY─X─RY─X─RY─X─RY─X─RY─X ...
0: ... ─RZ─o────o─────────o─────────o───────────────────o───────────────────o ...
1: ... ─RZ─X─RZ─X────o────|────o────|─────────o─────────|─────────o─────────| ...
2: ... ───────────RZ─X─RZ─X─RZ─X─RZ─X────o────|────o────|────o────|────o────| ...
3: ... ───────────────────────────────RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X ...
0: ... ─
1: ... ─
2: ... ─
3: ... ─
# save the elements as a list for checking
state_PREP = circuit_PREP()
state_PREP_elements = state_PREP.to_dict()['state']
SELECT circuit¶
The SELECT circuit is used within an LCU frameworkf to apply specific target unitary operators \(U_k\) to a system register conditioned on an ancilla state \(|k\rangle\).
The k-th unitary \(U_k\) is applied to the system register only when the ancilla register holds the state \(|k\rangle\)
This implementation uses multi-qubit controlled Pauli gates, but also adds a multi-control Z gate if the coefficient is negative to account for phase.
import qibo
from qibo import Circuit, gates, models
circuit_SELECT = Circuit(nq1 + sym_hamiltonian.nqubits)
# Helper function to return the correct Qibo Pauli gate
def get_pauli_gate(pauli_char, target_qubit):
if pauli_char == 'X':
return gates.X(target_qubit)
elif pauli_char == 'Y':
return gates.Y(target_qubit)
elif pauli_char == 'Z':
return gates.Z(target_qubit)
elif pauli_char == 'I':
return gates.I(target_qubit)
else:
raise ValueError(f"Unsupported gate: {pauli_char}")
bitlength = nq1
select_controls = []
for _i in range(2**nq1):
control_string = f"{_i:0{bitlength}b}"
select_controls.append(control_string)
# Define control qubits and target qubit mapping
control_qubits = (0, 1, 2, 3)
target_map = {0: 4, 1: 5, 2: 6, 3: 7}
# Iterate through the operations
for i in range(len(opp)):
control_state = select_controls[i]
pauli_string = opp[i]
targets_unmapped = opq[i]
coeff = coeffs[i] # Get the original coefficient to check its sign
# Step 1: Apply X gates on control qubits that need to be conditioned on '0'
for j, bit in enumerate(control_state):
if bit == '0':
circuit_SELECT.add(gates.X(control_qubits[j]))
# Step 2: Apply the multi-controlled Pauli gates to the mapped target qubits
for j, pauli_char in enumerate(pauli_string):
unmapped_target = targets_unmapped[j]
target_qubit = target_map[unmapped_target]
# Create base gate and add controls
base_gate = get_pauli_gate(pauli_char, target_qubit)
controlled_gate = base_gate.controlled_by(*control_qubits)
circuit_SELECT.add(controlled_gate)
# Step 3: Apply multi-controlled -1 phase for negative coefficients
if coeff.real < 0:
# Multi-controlled Z applies a -1 phase to the state |11...1>
circuit_SELECT.add(gates.Z(control_qubits[-1]).controlled_by(*control_qubits[:-1]))
# Step 4: Uncompute the X gates to restore the control qubits
for j, bit in enumerate(control_state):
if bit == '0':
circuit_SELECT.add(gates.X(control_qubits[j]))
# Display a summary of the generated circuit
print(circuit_SELECT.draw())
#print(circuit_SELECT.summary())
0: ─X─o─X─X─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─o─o─────o ...
1: ─X─o─X─X─o─X─X─o─o─X─X─o─o─X───o─o─────o─o─────o─o─────o─o─X─o─o─X─X─o ...
2: ─X─o─X─X─o─X───o─o─────o─o─X───o─o─X─X─o─o─X───o─o─────o─o─X─o─o─X─X─o ...
3: ─X─o─X───o─X───o─Z─X───o─Z─X───o─o─X───o─o─X───o─o─X───o─o─X─o─o─X───o ...
4: ───|─────Z─────|───────|───────|─Z─────|─|─────|─|─────|─Z───|─|─────| ...
5: ───Z───────────|───────|───────|───────|─Z─────|─|─────|─────|─Z─────Z ...
6: ───────────────Z───────|───────Z───────|───────|─Z─────|─────Z──────── ...
7: ───────────────────────Z───────────────Z───────Z───────Z────────────── ...
0: ... ─o─────o─o─o─o─────o─o─o─o───o─o─o─o─o─────o─o─o─o─o───o─o───
1: ... ─o─X─X─o─o─o─o─X─X─o─o─o─o─X─o─o─o─o─o─────o─o─o─o─o───o─o───
2: ... ─o─X───o─o─o─o─────o─o─o─o─X─o─o─o─o─o─X─X─o─o─o─o─o─X─o─o───
3: ... ─o─X───o─o─o─o─X───o─o─o─o─X─o─o─o─o─Z─X───o─o─o─o─Z─X─o─Z─X─
4: ... ─Z─────|─|─|─X─────|─|─|─Y───|─|─|─X───────|─|─|─Y─────I─────
5: ... ───────|─|─Y───────|─|─X─────|─|─X─────────|─|─Y─────────────
6: ... ───────|─Y─────────|─X───────|─Y───────────|─X───────────────
7: ... ───────X───────────Y─────────Y─────────────X─────────────────
None
PREP\(\dagger\)¶
The inverse PREP is implemented easily using the .invert() method
for the PREP circuit earlier.
# prep_dagger
circuit_PREP_dag = circuit_PREP.invert()
Final circuit¶
Assemble the block encoding circuit for above Hamiltonian by piecing everything together.
circuit_BLOCKENCODE = Circuit(nq1 + sym_hamiltonian.nqubits)
circuit_BLOCKENCODE.add(circuit_PREP.on_qubits(*range(0,4)))
circuit_BLOCKENCODE.add(circuit_SELECT.on_qubits(*range(0,8)))
circuit_BLOCKENCODE.add(circuit_PREP_dag.on_qubits(*range(0,4)))
#circuit_BLOCKENCODE.add(gates.M(*range(4,8)))
print(circuit_BLOCKENCODE.draw())
0: ─RY─o────o─────────o─────────o───────────────────o───────────────────o ...
1: ─RY─X─RY─X────o────|────o────|─────────o─────────|─────────o─────────| ...
2: ───────────RY─X─RY─X─RY─X─RY─X────o────|────o────|────o────|────o────| ...
3: ───────────────────────────────RY─X─RY─X─RY─X─RY─X─RY─X─RY─X─RY─X─RY─X ...
4: ────────────────────────────────────────────────────────────────────── ...
5: ────────────────────────────────────────────────────────────────────── ...
6: ────────────────────────────────────────────────────────────────────── ...
7: ────────────────────────────────────────────────────────────────────── ...
0: ... ─RZ─o────o─────────o─────────o───────────────────o───────────────────o ...
1: ... ─RZ─X─RZ─X────o────|────o────|─────────o─────────|─────────o─────────| ...
2: ... ───────────RZ─X─RZ─X─RZ─X─RZ─X────o────|────o────|────o────|────o────| ...
3: ... ───────────────────────────────RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X ...
4: ... ────────────────────────────────────────────────────────────────────── ...
5: ... ────────────────────────────────────────────────────────────────────── ...
6: ... ────────────────────────────────────────────────────────────────────── ...
7: ... ────────────────────────────────────────────────────────────────────── ...
0: ... ─X─o─X─X─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─X─o─o─X─o─o─────o ...
1: ... ─X─o─X─X─o─X─X─o─o─X─X─o─o─X───o─o─────o─o─────o─o─────o─o─X─o─o─X─X─o ...
2: ... ─X─o─X─X─o─X───o─o─────o─o─X───o─o─X─X─o─o─X───o─o─────o─o─X─o─o─X─X─o ...
3: ... ─X─o─X───o─X───o─Z─X───o─Z─X───o─o─X───o─o─X───o─o─X───o─o─X─o─o─X───o ...
4: ... ───|─────Z─────|───────|───────|─Z─────|─|─────|─|─────|─Z───|─|─────| ...
5: ... ───Z───────────|───────|───────|───────|─Z─────|─|─────|─────|─Z─────Z ...
6: ... ───────────────Z───────|───────Z───────|───────|─Z─────|─────Z──────── ...
7: ... ───────────────────────Z───────────────Z───────Z───────Z────────────── ...
0: ... ─o─────o─o─o─o─────o─o─o─o───o─o─o─o─o─────o─o─o─o─o───o─o───o──────── ...
1: ... ─o─X─X─o─o─o─o─X─X─o─o─o─o─X─o─o─o─o─o─────o─o─o─o─o───o─o───|──────── ...
2: ... ─o─X───o─o─o─o─────o─o─o─o─X─o─o─o─o─o─X─X─o─o─o─o─o─X─o─o───|────o─── ...
3: ... ─o─X───o─o─o─o─X───o─o─o─o─X─o─o─o─o─Z─X───o─o─o─o─Z─X─o─Z─X─X─RZ─X─RZ ...
4: ... ─Z─────|─|─|─X─────|─|─|─Y───|─|─|─X───────|─|─|─Y─────I────────────── ...
5: ... ───────|─|─Y───────|─|─X─────|─|─X─────────|─|─Y────────────────────── ...
6: ... ───────|─Y─────────|─X───────|─Y───────────|─X──────────────────────── ...
7: ... ───────X───────────Y─────────Y─────────────X────────────────────────── ...
0: ... ───────────o───────────────────o─────────o─────────o────o─RZ─o──────── ...
1: ... ─o─────────|─────────o─────────|────o────|────o────X─RZ─X─RZ─|──────── ...
2: ... ─|────o────|────o────|────o────X─RZ─X─RZ─X─RZ─X─RZ───────────|────o─── ...
3: ... ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ─X─RZ───────────────────────────────X─RY─X─RY ...
4: ... ────────────────────────────────────────────────────────────────────── ...
5: ... ────────────────────────────────────────────────────────────────────── ...
6: ... ────────────────────────────────────────────────────────────────────── ...
7: ... ────────────────────────────────────────────────────────────────────── ...
0: ... ───────────o───────────────────o─────────o─────────o────o─RY─
1: ... ─o─────────|─────────o─────────|────o────|────o────X─RY─X─RY─
2: ... ─|────o────|────o────|────o────X─RY─X─RY─X─RY─X─RY───────────
3: ... ─X─RY─X─RY─X─RY─X─RY─X─RY─X─RY───────────────────────────────
4: ... ─────────────────────────────────────────────────────────────
5: ... ─────────────────────────────────────────────────────────────
6: ... ─────────────────────────────────────────────────────────────
7: ... ─────────────────────────────────────────────────────────────
None
Now compare the \(H/\lambda\) (H_lam_mat) and the 16 by 16
upper-left block of the circuit unitary (U16_mat) to see if the
Hamiltonian is successfully block encoded.
# Compare
H_lam_mat = sym_hamiltonian.matrix / lcu_norm
U16_mat = circuit_BLOCKENCODE.unitary()[0:16, 0:16]
print(np.allclose(H_lam_mat, U16_mat))
True