Skip to content

Commit a1c6568

Browse files
committed
added code files
1 parent 3933883 commit a1c6568

File tree

2 files changed

+445
-0
lines changed

2 files changed

+445
-0
lines changed
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
2+
Variational Quantum Eigensolver (VQE) Implementation from Scratch
3+
4+
5+
6+
Introduction
7+
8+
9+
The Variational Quantum Eigensolver (VQE) is a hybrid quantum-classical algorithm used to find the ground state (minimum eigenvalue) of a given Hamiltonian . It works by preparing a parameterized quantum state (ansatz) on a quantum simulator and measuring the expectation value of the Hamiltonian, then using a classical optimizer to adjust the parameters to minimize this energy . The Hamiltonian is expressed as a sum of Pauli operators (a linear combination of Pauli matrices acting on qubits) . By the variational principle, the measured energy is always an upper-bound to the true ground energy, and the algorithm aims to converge towards the minimum possible value.
10+
11+
12+
Approach Outline
13+
14+
15+
Our implementation will use only NumPy and SciPy (no Qiskit or other quantum libraries). Key components include:
16+
17+
Hamiltonian Construction: We generate a random Hermitian Hamiltonian for n qubits, represented as a list of Pauli-term operators with random coefficients . Each term is a tensor product of Pauli matrices (I, X, Y, Z) on the qubits.
18+
Layered Ansatz Circuit: We define a hardware-efficient ansatz composed of repeated layers. Each layer applies a parameterized single-qubit rotation (here we use $R_y$ rotations) on every qubit, followed by entangling two-qubit gates (we use CNOTs between neighboring qubits) . This provides a flexible trial wavefunction with a number of parameters that grows linearly with qubit count and number of layers.
19+
State Simulation: We simulate the quantum state as a full $2^n$-dimensional complex vector. Applying single-qubit gates is done by multiplying the state vector by the 2×2 unitary on the appropriate subspace, and two-qubit gates by a 4×4 unitary on the appropriate pair of qubits. The state is initially $|00\cdots0\rangle$ (all zeros).
20+
Measurement by Sampling: Instead of calculating exact expectation values, we simulate quantum measurements. For each term in the Hamiltonian, we rotate the state into the basis where that Pauli term is diagonal (using appropriate gates like H for X-basis or $S^\dagger$ then H for Y-basis) . We then perform repeated shots: sample bitstring outcomes from the state’s probability distribution and evaluate the term’s eigenvalue (+1 or –1) for each sample, averaging over many shots to estimate the expectation . This mimics the stochastic nature of real quantum measurements.
21+
Classical Optimization: We use SciPy’s optimization (BFGS or similar gradient-based method) to minimize the energy with respect to the ansatz parameters . The optimizer will call our energy evaluation function (which performs the quantum simulation and measurement sampling) iteratively, updating parameters to find a lower energy. Gradient information is obtained via finite differences internally (since our energy estimates are noisy due to sampling).
22+
23+
24+
Below is the full Python script implementing VQE with detailed inline comments explaining each step:
25+
import numpy as np
26+
from scipy.optimize import minimize
27+
28+
# Define Pauli matrices (2x2) as NumPy arrays for convenience
29+
I2 = np.array([[1, 0],
30+
[0, 1]], dtype=complex)
31+
X2 = np.array([[0, 1],
32+
[1, 0]], dtype=complex)
33+
Y2 = np.array([[0, -1j],
34+
[1j, 0]], dtype=complex)
35+
Z2 = np.array([[1, 0],
36+
[0, -1]], dtype=complex)
37+
38+
# Define common single-qubit gates: Hadamard (H) and phase (S) gates
39+
H2 = (1/np.sqrt(2)) * np.array([[1, 1],
40+
[1, -1]], dtype=complex) # Hadamard gate
41+
S2 = np.array([[1, 0],
42+
[0, 1j]], dtype=complex) # Phase gate (90° rotation around Z)
43+
Sdg2 = np.array([[1, 0],
44+
[0, -1j]], dtype=complex) # S† (conjugate transpose of S)
45+
46+
def generate_random_hamiltonian(n_qubits, n_terms=3):
47+
"""
48+
Generate a random Hermitian Hamiltonian for n_qubits, expressed as a list of Pauli terms.
49+
Each term is a tuple (coeff, pauli_string), where pauli_string is e.g. 'XZIY' (length = n_qubits).
50+
"""
51+
paulis = ['I', 'X', 'Y', 'Z']
52+
terms = []
53+
for _ in range(n_terms):
54+
# Randomly choose a Pauli (or Identity) for each qubit
55+
P_str = ''.join(np.random.choice(paulis) for _ in range(n_qubits))
56+
# Ensure the term is not the all-identity (which just adds a constant energy)
57+
if set(P_str) == {'I'}:
58+
# If we accidentally got all 'I's, flip one qubit to a random Pauli
59+
idx = np.random.randint(0, n_qubits)
60+
P_str_list = list(P_str)
61+
P_str_list[idx] = np.random.choice(['X', 'Y', 'Z'])
62+
P_str = ''.join(P_str_list)
63+
# Random coefficient (real number)
64+
coeff = np.random.uniform(-1.0, 1.0)
65+
terms.append((coeff, P_str))
66+
return terms
67+
68+
def apply_one_qubit_gate(state_vec, gate_matrix, qubit_index, n_qubits):
69+
"""
70+
Apply a single-qubit gate (2x2 unitary) to the state vector on the given qubit index.
71+
state_vec: 1D NumPy array of length 2^n (complex amplitudes).
72+
gate_matrix: 2x2 unitary matrix to apply.
73+
qubit_index: which qubit (0-indexed) the gate acts on (0 = most significant qubit).
74+
Returns a new state vector of length 2^n after applying the gate.
75+
"""
76+
# Reshape state vector into an n-dimensional tensor of shape (2,2,...,2)
77+
state_tensor = state_vec.reshape([2] * n_qubits)
78+
# Move the target qubit axis to the front (axis 0) for easy manipulation
79+
# After this, state_tensor has shape (2, 2^(n-1))
80+
state_tensor = np.moveaxis(state_tensor, qubit_index, 0)
81+
state_tensor = state_tensor.reshape(2, -1) # flatten remaining qubits into second dimension
82+
# Apply the 2x2 gate matrix to this 2 x (2^(n-1)) matrix of amplitudes
83+
new_state_tensor = gate_matrix @ state_tensor # matrix multiplication
84+
# Reshape back to the tensor of shape (2,2,...,2)
85+
new_state_tensor = new_state_tensor.reshape([2] * n_qubits)
86+
# Move the axes back to original order (put the qubit axis back to its original position)
87+
new_state_tensor = np.moveaxis(new_state_tensor, 0, qubit_index)
88+
# Flatten back to 1D state vector
89+
return new_state_tensor.reshape(-1)
90+
91+
def apply_two_qubit_gate(state_vec, gate_matrix, q1, q2, n_qubits):
92+
"""
93+
Apply a two-qubit gate (4x4 unitary) to the state vector on qubits q1 and q2.
94+
q1, q2: indices of the two qubits (0-indexed). The 4x4 gate_matrix is assumed to correspond
95+
to (qubit q1, qubit q2) in that order.
96+
"""
97+
if q1 == q2:
98+
raise ValueError("q1 and q2 must be two different qubits")
99+
# Determine order of qubits for matrix application
100+
# We'll bring q1 and q2 to the front as axes 0 and 1 of the tensor
101+
axes = list(range(n_qubits))
102+
# Put q1 and q2 as the first two axes (in their original relative order)
103+
new_axes = [q1, q2] + [ax for ax in axes if ax not in (q1, q2)]
104+
# Reshape state to a 2^n tensor and permute axes so that q1->axis0, q2->axis1
105+
state_tensor = state_vec.reshape([2] * n_qubits).transpose(new_axes)
106+
# state_tensor now has shape (2,2, 2^(n-2))
107+
original_shape = state_tensor.shape # will be (2, 2, 2^(n-2))
108+
state_tensor = state_tensor.reshape(4, -1) # combine the two qubit axes into one of length 4
109+
# Apply the 4x4 gate matrix
110+
new_state_tensor = gate_matrix @ state_tensor
111+
# Reshape back to separate the two qubit axes
112+
new_state_tensor = new_state_tensor.reshape(original_shape)
113+
# Inverse permutation to restore original axis order
114+
inv_axes = np.argsort(new_axes) # indices to invert the axis permutation
115+
new_state_tensor = new_state_tensor.transpose(inv_axes)
116+
# Flatten back to 1D
117+
return new_state_tensor.reshape(-1)
118+
119+
def ansatz_state(params, n_qubits, layers):
120+
"""
121+
Build the ansatz (trial state) for given parameters.
122+
params: 1D array of rotation angles, length = layers * n_qubits (each layer has one angle per qubit).
123+
n_qubits: number of qubits.
124+
layers: number of layers in the ansatz.
125+
Returns the final state vector (1D complex array of length 2^n).
126+
"""
127+
# Start in the initial state |00...0>
128+
state = np.zeros(2**n_qubits, dtype=complex)
129+
state[0] = 1.0
130+
# Iterate through layers of the ansatz
131+
param_index = 0
132+
for layer in range(layers):
133+
# 1. Apply parameterized single-qubit rotations (Ry) on each qubit
134+
for q in range(n_qubits):
135+
theta = params[param_index]
136+
param_index += 1
137+
# Rotation around Y-axis by angle theta:
138+
# Ry(theta) = [[cos(theta/2), -sin(theta/2)], [sin(theta/2), cos(theta/2)]]
139+
Ry = np.array([[np.cos(theta/2), -np.sin(theta/2)],
140+
[np.sin(theta/2), np.cos(theta/2)]], dtype=complex)
141+
state = apply_one_qubit_gate(state, Ry, q, n_qubits)
142+
# 2. Apply entangling gates (use CNOTs between neighboring qubits in a chain)
143+
# For example, for qubits [0,1,2,...,n-1], apply CNOT(0->1), CNOT(1->2), ..., CNOT(n-2 -> n-1)
144+
if n_qubits > 1:
145+
# Define CNOT gate matrix (4x4) with control = first qubit, target = second qubit
146+
CNOT = np.array([[1, 0, 0, 0],
147+
[0, 1, 0, 0],
148+
[0, 0, 0, 1],
149+
[0, 0, 1, 0]], dtype=complex)
150+
for q in range(n_qubits - 1):
151+
# Apply CNOT with control = q, target = q+1
152+
state = apply_two_qubit_gate(state, CNOT, q, q+1, n_qubits)
153+
return state
154+
155+
def estimate_energy(params, hamiltonian, n_qubits, layers, shots=1000):
156+
"""
157+
Estimate the expectation value of the Hamiltonian for given ansatz parameters.
158+
- params: array of ansatz parameters.
159+
- hamiltonian: list of (coeff, pauli_string) terms defining H.
160+
- n_qubits: number of qubits.
161+
- layers: number of ansatz layers.
162+
- shots: number of measurement samples to draw for estimating expectation.
163+
Returns: estimated energy (expectation value <psi(theta)|H|psi(theta)>).
164+
"""
165+
# First, get the trial state vector for these parameters
166+
psi = ansatz_state(params, n_qubits, layers)
167+
energy_est = 0.0
168+
# Loop over each term in the Hamiltonian
169+
for coeff, Pstr in hamiltonian:
170+
# Copy the state vector because we will modify it for measurement basis rotation
171+
psi_term = psi.copy()
172+
# For each qubit, apply a basis transformation so that measuring in Z-basis corresponds to measuring this Pauli
173+
# e.g., if Pstr has 'X' on qubit i, apply H on qubit i (since H|0/1> = |+/->, aligning Z-basis to X-basis)
174+
# if 'Y' on qubit i, apply S† then H (aligns Z-basis to Y-basis) [oai_citation:8‡raw.githubusercontent.com](https://raw.githubusercontent.com/stfnmangini/VQE_from_scratch/refs/heads/master/vqe_from_scratch.ipynb#:~:text=,in).
175+
for qubit, P in enumerate(Pstr):
176+
if P == 'X':
177+
psi_term = apply_one_qubit_gate(psi_term, H2, qubit, n_qubits)
178+
elif P == 'Y':
179+
psi_term = apply_one_qubit_gate(psi_term, Sdg2, qubit, n_qubits)
180+
psi_term = apply_one_qubit_gate(psi_term, H2, qubit, n_qubits)
181+
elif P == 'Z':
182+
# Z: no basis change needed (Z eigenstates are |0>, |1>)
183+
pass
184+
elif P == 'I':
185+
# I (identity): this term doesn't depend on qubit state, no change needed
186+
pass
187+
# Now psi_term is expressed in the measurement basis for this term.
188+
# Simulate measuring all qubits in Z basis 'shots' times:
189+
probabilities = np.abs(psi_term)**2 # probability of each computational basis state
190+
probabilities = probabilities.real # (should be real, within numerical error)
191+
probabilities /= probabilities.sum() # normalize (safety, should already sum to 1)
192+
# Sample bitstring outcomes according to these probabilities
193+
outcomes = np.random.choice(len(probabilities), size=shots, p=probabilities)
194+
# Compute the Pauli term's value (+1 or -1) for each outcome
195+
# We do this by computing the product of eigenvalues for each qubit in the term.
196+
# For a computational basis outcome, each qubit's Z measurement gives 0 or 1 as result (|0> -> eigenvalue +1, |1> -> -1 for Z).
197+
# For X or Y terms, we already rotated the basis, so the Z measurement gives the X or Y eigenvalue.
198+
values = np.ones(shots) # will hold the product of eigenvalues for each shot
199+
for qubit, P in enumerate(Pstr):
200+
if P == 'I':
201+
continue # identity contributes a factor of 1
202+
# Determine the bit (0 or 1) of this outcome for the current qubit
203+
# We interpret the binary representation of outcome index with qubit0 as MSB.
204+
bit = (outcomes >> (n_qubits - 1 - qubit)) & 1 # extract the bit of 'outcomes' at position 'qubit'
205+
# Map bit to eigenvalue: 0 -> +1, 1 -> -1
206+
value = 1 - 2 * bit # if bit=0, value=1; if bit=1, value=-1
207+
values *= value # multiply into the running product
208+
# The expectation value of this term is the average of these values
209+
term_expectation = values.mean()
210+
# Accumulate contribution to energy: coeff * <P>
211+
energy_est += coeff * term_expectation
212+
return energy_est
213+
214+
# Example usage (if run as a script):
215+
if __name__ == "__main__":
216+
np.random.seed(42) # for reproducibility
217+
# Problem setup
218+
n_qubits = 2 # number of qubits (flexible)
219+
layers = 2 # number of ansatz layers
220+
H = generate_random_hamiltonian(n_qubits, n_terms=3) # random Hamiltonian with 3 terms
221+
print("Hamiltonian (coeff, Pauli_term):", H)
222+
# Initial guess for parameters (random angles between 0 and 2π for each parameter)
223+
num_params = layers * n_qubits
224+
init_params = np.random.uniform(0, 2*np.pi, num_params)
225+
# Objective function for optimizer: VQE energy as a function of parameters
226+
def objective(params):
227+
return estimate_energy(params, H, n_qubits, layers, shots=1000)
228+
# Run classical optimization (gradient-based) to minimize the energy
229+
result = minimize(objective, init_params, method="BFGS", options={'maxiter': 100, 'disp': True})
230+
optimal_params = result.x
231+
min_energy = result.fun
232+
print(f"\nOptimized parameters: {optimal_params}")
233+
print(f"Estimated minimum energy: {min_energy:.6f}")
234+
How it works: We start by generating a random Hamiltonian on n_qubits as a list of Pauli strings. The ansatz is implemented in ansatz_state(), which applies the rotations and CNOT gates layer by layer. The core of the energy evaluation is estimate_energy(): for each Hamiltonian term, it rotates the state into the measurement basis and uses shots samples to estimate the expectation value of that term . The classical optimizer (SciPy’s minimize) then iteratively calls this function to adjust the parameters and lower the energy . The script prints the random Hamiltonian and the optimized results if run directly.
235+
236+
Note: Because we use finite sampling, the energy estimates have statistical noise. Increasing the number of shots will improve accuracy at the cost of runtime. Likewise, more layers or a better initial guess may be needed to reach the true ground state energy. Nonetheless, this implementation demonstrates a complete VQE workflow using only NumPy/SciPy and simulates the measurement process realistically (via sampling rather than exact calculation).

0 commit comments

Comments
 (0)