Skip to content

Commit 7895bc6

Browse files
Clifford VQE for Z/X/Y
1 parent 1c11cbd commit 7895bc6

1 file changed

Lines changed: 120 additions & 98 deletions

File tree

Lines changed: 120 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Quantum chemistry example
22
# Developed with help from (OpenAI custom GPT) Elara
3+
# Adapted to atomic checks of Z/X/Y basis by (Anthropic) Claude
34
# (Requires OpenFermion)
45

56
from openfermion import MolecularData, FermionOperator, jordan_wigner, get_fermion_operator
@@ -230,68 +231,93 @@ def geometry_to_atom_str(geometry):
230231
for symbol, (x, y, z) in geometry
231232
)
232233

233-
def compute_energy(theta_bits, phi_bits, zx_hamiltonian):
234+
235+
# phi encodes Pauli basis per qubit: 0=Z, 1=X, 2=Y
236+
# theta encodes |0> vs |1> in that basis (False=|0>, True=|1>)
237+
#
238+
# For a single-qubit Clifford state |b>_P:
239+
# <b|_P sigma_P |b>_P = (-1)^b (eigenvalue of the matching Pauli)
240+
# <b|_P sigma_Q |b>_P = 0 (orthogonal Pauli, no contribution)
241+
#
242+
# So a term contributes only when ALL its qubits are measured in the
243+
# matching basis, and its sign is the product of (-1)^theta[qubit].
244+
245+
def compute_energy(theta, phi, zxy_hamiltonian):
234246
energy = 0.0
235-
for qubits, bases, coeff in zx_hamiltonian:
236-
is_sat = True
237-
for i in range(len(qubits)):
238-
qubit = qubits[i]
239-
if phi_bits[qubit] != bases[i]:
240-
is_sat = False
241-
break
247+
for qubits, bases, coeff in zxy_hamiltonian:
248+
# bases[i] is 0/1/2 for Z/X/Y; phi[qubit] must match for term to contribute
249+
is_sat = all(phi[qubits[i]] == bases[i] for i in range(len(qubits)))
242250
if not is_sat:
243251
continue
244-
245252
for qubit in qubits:
246-
if theta_bits[qubit]:
253+
if theta[qubit]:
247254
coeff *= -1
248255
energy += coeff
249-
250256
return energy
251257

252258

253-
def bootstrap_worker(theta, phi, zx_hamiltonian, d_theta, d_phi, energy):
259+
# A candidate update is a pair (new_theta_slice, new_phi_slice) for a subset of qubits,
260+
# tested atomically. We enumerate all 6 single-qubit Clifford states per qubit in the
261+
# subset, and all combinations across qubits for k>1.
262+
263+
def bootstrap_worker(theta, phi, zxy_hamiltonian, qubit_indices, new_states):
264+
"""new_states: list of (theta_val, phi_val) per qubit in qubit_indices."""
254265
local_theta = theta.copy()
255-
for i in d_theta:
256-
local_theta[i] = not local_theta[i]
257266
local_phi = phi.copy()
258-
for i in d_phi:
259-
local_phi[i] = not local_phi[i]
260-
energy = compute_energy(local_theta, local_phi, zx_hamiltonian)
261-
262-
return energy
267+
for idx, (t, p) in zip(qubit_indices, new_states):
268+
local_theta[idx] = t
269+
local_phi[idx] = p
270+
return compute_energy(local_theta, local_phi, zxy_hamiltonian)
271+
272+
273+
def bootstrap(theta, phi, zxy_hamiltonian, k, qubit_combos):
274+
"""
275+
For each combination of k qubits, test all 6^k assignments of
276+
(theta, phi) per qubit atomically. Returns list of (energy, qubit_indices, new_states).
277+
"""
278+
# 6 single-qubit Clifford states: (theta_val, phi_val)
279+
clifford_states = [
280+
(False, 0), (True, 0), # |0>_Z, |1>_Z
281+
(False, 1), (True, 1), # |0>_X, |1>_X
282+
(False, 2), (True, 2), # |0>_Y, |1>_Y
283+
]
284+
285+
args = []
286+
meta = []
287+
for combo in qubit_combos:
288+
for new_states in itertools.product(clifford_states, repeat=k):
289+
# Skip if this assignment matches the current state (no change)
290+
if all(
291+
theta[combo[i]] == new_states[i][0] and phi[combo[i]] == new_states[i][1]
292+
for i in range(k)
293+
):
294+
continue
295+
args.append((theta, phi, zxy_hamiltonian, list(combo), list(new_states)))
296+
meta.append((list(combo), list(new_states)))
263297

298+
if not args:
299+
return []
264300

265-
def bootstrap(theta, phi, zx_hamiltonian, k, indices_array, energy):
266-
n = len(indices_array) // k
267301
with multiprocessing.Pool(processes=os.cpu_count()) as pool:
268-
args = []
269-
for i in range(n):
270-
j = i * k
271-
args.append((theta, phi, zx_hamiltonian, indices_array[j : j + k], [], energy))
272-
for i in range(n):
273-
j = i * k
274-
args.append((theta, phi, zx_hamiltonian, [], indices_array[j : j + k], energy))
275-
for i in range(n):
276-
j = i * k
277-
args.append((theta, phi, zx_hamiltonian, indices_array[j : j + k], indices_array[j : j + k], energy))
278302
energies = pool.starmap(bootstrap_worker, args)
279303

280-
return energies
304+
return list(zip(energies, meta))
281305

282306

283-
def multiprocessing_bootstrap(zx_hamiltonian, n_qubits, reheat_tries=0):
284-
best_theta = np.random.randint(2, size=n_qubits)
285-
best_phi = np.random.randint(2, size=n_qubits)
286-
min_energy = compute_energy(best_theta, best_phi, zx_hamiltonian)
307+
def multiprocessing_bootstrap(zxy_hamiltonian, n_qubits, reheat_tries=0):
308+
# Initialise randomly across all 6 Clifford states
309+
best_theta = np.array([bool(random.randint(0, 1)) for _ in range(n_qubits)])
310+
best_phi = np.array([random.randint(0, 2) for _ in range(n_qubits)])
311+
min_energy = compute_energy(best_theta, best_phi, zxy_hamiltonian)
287312

288313
combos_list = []
289314
reheat_theta = best_theta.copy()
290-
reheat_phi = best_phi.copy()
315+
reheat_phi = best_phi.copy()
291316
reheat_min_energy = min_energy
317+
292318
for reheat_round in range(reheat_tries + 1):
293319
improved = True
294-
quality = 1
320+
quality = 1
295321
while improved:
296322
improved = False
297323
k = 1
@@ -300,135 +326,131 @@ def multiprocessing_bootstrap(zx_hamiltonian, n_qubits, reheat_tries=0):
300326
break
301327

302328
if len(combos_list) < k:
303-
combos = np.array(list(
304-
item for sublist in itertools.combinations(range(n_qubits), k) for item in sublist
305-
))
329+
combos = list(itertools.combinations(range(n_qubits), k))
306330
combos_list.append(combos)
307331
else:
308332
combos = combos_list[k - 1]
309333

310-
energies = bootstrap(reheat_theta, reheat_phi, zx_hamiltonian, k, combos, reheat_min_energy)
334+
results = bootstrap(reheat_theta, reheat_phi, zxy_hamiltonian, k, combos)
311335

312-
energy = min(energies)
336+
if not results:
337+
k += 1
338+
continue
339+
340+
best_result = min(results, key=lambda x: x[0])
341+
energy, (qubit_indices, new_states) = best_result
313342

314343
if energy < reheat_min_energy:
315344
reheat_min_energy = energy
316-
index_match = energies.index(energy)
317-
318-
n_combos = len(combos)
319-
is_theta = (index_match < n_combos) or (index_match >= (n_combos << 1))
320-
is_phi = index_match >= n_combos
321-
index_match %= n_combos
322-
indices = combos[index_match * k : (index_match + 1) * k]
323-
if is_theta:
324-
for i in indices:
325-
reheat_theta[i] = not reheat_theta[i]
326-
if is_phi:
327-
for i in indices:
328-
reheat_phi[i] = not reheat_phi[i]
329-
345+
for idx, (t, p) in zip(qubit_indices, new_states):
346+
reheat_theta[idx] = t
347+
reheat_phi[idx] = p
330348
improved = True
331349
if quality < (k + 1):
332350
quality = k + 1
333351
if reheat_min_energy < min_energy:
334-
print(f" Qubits {indices} flip accepted. New energy: {reheat_min_energy}")
335-
print(f" θ: {reheat_theta}")
352+
basis_names = {0: 'Z', 1: 'X', 2: 'Y'}
353+
state_strs = [f"|{'1' if t else '0'}>_{basis_names[p]}"
354+
for t, p in new_states]
355+
print(f" Qubits {qubit_indices} -> {state_strs}. New energy: {reheat_min_energy}")
356+
print(f" θ: {reheat_theta.astype(int)}")
336357
print(f" φ: {reheat_phi}")
337358
break
338359

339-
k = k + 1
340-
print(" Qubit flips all rejected.")
360+
k += 1
361+
print(" Qubit updates all rejected.")
341362

342363
if min_energy < reheat_min_energy:
343-
reheat_theta = best_theta.copy()
344-
reheat_phi = best_phi.copy()
364+
reheat_theta = best_theta.copy()
365+
reheat_phi = best_phi.copy()
345366
reheat_min_energy = min_energy
346367
else:
347368
best_theta = reheat_theta.copy()
348-
best_phi = reheat_phi.copy()
369+
best_phi = reheat_phi.copy()
349370
min_energy = reheat_min_energy
350371

351372
if reheat_round < reheat_tries:
352373
print(" Reheating...")
353-
num_to_flip = int(np.round(np.log2(len(reheat_theta))))
354-
z_bits_to_flip = random.sample(list(range(n_qubits)), num_to_flip)
355-
for bit in z_bits_to_flip:
356-
reheat_theta[bit] = not reheat_theta[bit]
357-
x_bits_to_flip = random.sample(list(range(n_qubits)), num_to_flip)
358-
for bit in x_bits_to_flip:
359-
reheat_phi[bit] = not reheat_phi[bit]
360-
reheat_min_energy = compute_energy(reheat_theta, reheat_phi, zx_hamiltonian)
374+
num_to_flip = int(np.round(np.log2(n_qubits)))
375+
for bit in random.sample(range(n_qubits), num_to_flip):
376+
reheat_theta[bit] = bool(random.randint(0, 1))
377+
reheat_phi[bit] = random.randint(0, 2)
378+
reheat_min_energy = compute_energy(reheat_theta, reheat_phi, zxy_hamiltonian)
361379

362380
return best_theta, best_phi, min_energy
363381

382+
364383
is_charge_update = True
365384
while is_charge_update:
366385
is_charge_update = False
367386

368-
atom_str = geometry_to_atom_str(geometry)
387+
atom_str = geometry_to_atom_str(geometry)
369388
molecule_of = MolecularData(geometry, basis, multiplicity=multiplicity, charge=charge)
370-
molecule_of = run_pyscf(molecule_of, run_scf=True, run_mp2=False, run_cisd=False, run_ccsd=False, run_fci=False)
389+
molecule_of = run_pyscf(molecule_of, run_scf=True, run_mp2=False, run_cisd=False,
390+
run_ccsd=False, run_fci=False)
371391
fermion_ham = get_fermion_operator(molecule_of.get_molecular_hamiltonian())
372392
n_electrons = molecule_of.n_electrons
373-
n_qubits = molecule_of.n_qubits
393+
n_qubits = molecule_of.n_qubits
374394
print(f"Hartree-Fock energy: {molecule_of.hf_energy}")
375395
print(f"{n_electrons} electrons...")
376396
print(f"{n_qubits} qubits...")
377397

378-
# Step 3: Iterate JW terms without materializing full op
379-
zx_hamiltonian = []
398+
# Build ZXY Hamiltonian: retain Z, X, and Y terms.
399+
# A Pauli string contributes only when all its qubits are in the matching basis.
400+
# bases[i] = 0 (Z), 1 (X), or 2 (Y).
401+
zxy_hamiltonian = []
380402
for term, coeff in fermion_ham.terms.items():
381-
jw_term = jordan_wigner(FermionOperator(term=term, coefficient=coeff)) # Transform single term
403+
jw_term = jordan_wigner(FermionOperator(term=term, coefficient=coeff))
382404

383405
for pauli_string, jw_coeff in jw_term.terms.items():
384-
if any(p in ('Y') for _, p in pauli_string):
385-
continue
386-
387406
q = []
388407
b = []
389408
for qubit, op in pauli_string:
390-
# Z/I terms: keep only Z
391409
if op == 'I':
392410
continue
411+
if op == 'Z':
412+
b.append(0)
413+
elif op == 'X':
414+
b.append(1)
415+
elif op == 'Y':
416+
b.append(2)
393417
q.append(qubit)
394-
b.append(op != 'Z')
395-
396-
zx_hamiltonian.append((q, b, jw_coeff.real))
418+
zxy_hamiltonian.append((q, b, jw_coeff.real))
397419

398-
399-
# Step 4: Bootstrap!
400-
theta, phi, min_energy = multiprocessing_bootstrap(zx_hamiltonian, n_qubits, 1)
420+
# Bootstrap!
421+
theta, phi, min_energy = multiprocessing_bootstrap(zxy_hamiltonian, n_qubits, 1)
401422

402423
print(f"\nFinal Bootstrap Ground State Energy: {min_energy} Ha")
403424
print("Final Bootstrap Parameters:")
404-
print(f" θ: {theta}")
405-
print(f" φ: {phi}")
425+
basis_names = {0: 'Z', 1: 'X', 2: 'Y'}
426+
print(f" θ: {theta.astype(int)}")
427+
print(f" φ: {[basis_names[p] for p in phi]}")
406428

429+
# Electron count: in Z basis a |1> is a full electron;
430+
# in X or Y basis a |1> contributes 1/2 (superposition of occupied/unoccupied).
407431
r_electrons = 0
408-
for i in range(len(theta)):
409-
b = theta[i]
432+
for i in range(n_qubits):
410433
if theta[i]:
411-
r_electrons += 1/2 if phi[i] else 1
434+
r_electrons += 1 if phi[i] == 0 else 0.5
412435
if int(r_electrons) != r_electrons:
413436
print("Whoops! We don't have an integer number of charges!")
414437
break
415438
r_electrons = int(r_electrons)
416439

417-
d_electrons = r_electrons - n_electrons
418-
r_charge = charge - d_electrons
440+
d_electrons = r_electrons - n_electrons
441+
r_charge = charge - d_electrons
419442
r_multiplicity = 1
420443
for i in range(0, len(theta), 2):
421444
if theta[i] != theta[i + 1]:
422445
r_multiplicity += 1
423446

424447
if n_electrons != r_electrons or multiplicity != r_multiplicity:
425448
print()
426-
print("Regresssed electron count or multiplicity doesn't match the assumptions!")
449+
print("Regressed electron count or multiplicity doesn't match the assumptions!")
427450
print("Running again with the natural parameters replacing your assumptions:")
428451
print(f"charge = {r_charge}")
429452
print(f"multiplicity = {r_multiplicity}")
430453
print()
431-
432-
charge = r_charge
454+
charge = r_charge
433455
multiplicity = r_multiplicity
434456
is_charge_update = True

0 commit comments

Comments
 (0)