|
| 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