|
| 1 | +import cudaq |
| 2 | +from cudaq import spin |
| 3 | + |
| 4 | +from collections import Counter |
| 5 | +import numpy as np |
| 6 | + |
| 7 | +from scipy.sparse import csr_matrix |
| 8 | +from scipy.sparse.linalg import eigsh |
| 9 | + |
| 10 | + |
| 11 | +def transform_state_with_pauli_operator(computational_state, |
| 12 | + pauli_operator_string): |
| 13 | + """ |
| 14 | + Transform a computational basis state by applying a Pauli operator string. |
| 15 | + |
| 16 | + This function computes how a Pauli string acts on a computational basis state. |
| 17 | + For example, applying "`XY`" to |01⟩ gives i|10⟩ (bit flip + phase). |
| 18 | + |
| 19 | + Implementation follows MSB convention: `pauli_operator_string[i]` acts on computational_state[i] |
| 20 | + where i=0 corresponds to the most significant bit (leftmost position). |
| 21 | + |
| 22 | + Args: |
| 23 | + computational_state: Binary array representing |0⟩ and |1⟩ states in MSB order |
| 24 | + `pauli_operator_string`: String of Pauli operators (I, X, Y, Z) in MSB order |
| 25 | + |
| 26 | + Returns: |
| 27 | + tuple: (transformed_state, quantum_phase) where quantum_phase is ±1 or ±1j |
| 28 | + """ |
| 29 | + transformed_state = computational_state.copy() |
| 30 | + quantum_phase = 1.0 + 0j # Start with no phase; Y operators will add ±i phases |
| 31 | + |
| 32 | + # Apply each Pauli operator to its corresponding qubit |
| 33 | + for qubit_position, pauli_op in enumerate(pauli_operator_string): |
| 34 | + if pauli_op == 'I': |
| 35 | + # Identity: no change to state or phase |
| 36 | + continue |
| 37 | + elif pauli_op == 'X': |
| 38 | + # Pauli-X: bit flip |0⟩ ↔ |1⟩ |
| 39 | + transformed_state[ |
| 40 | + qubit_position] = 1 - transformed_state[qubit_position] |
| 41 | + elif pauli_op == 'Y': |
| 42 | + # Pauli-Y = i·X·Z: bit flip with phase ±i depending on initial state |
| 43 | + if transformed_state[qubit_position] == 0: |
| 44 | + quantum_phase *= 1j # Y|0⟩ = i|1⟩ |
| 45 | + else: |
| 46 | + quantum_phase *= -1j # Y|1⟩ = -i|0⟩ |
| 47 | + transformed_state[ |
| 48 | + qubit_position] = 1 - transformed_state[qubit_position] |
| 49 | + elif pauli_op == 'Z': |
| 50 | + # Pauli-Z: phase flip for |1⟩ state |
| 51 | + if transformed_state[qubit_position] == 1: |
| 52 | + quantum_phase *= -1 # Z|1⟩ = -|1⟩, Z|0⟩ = |0⟩ |
| 53 | + |
| 54 | + return transformed_state, quantum_phase |
| 55 | + |
| 56 | + |
| 57 | +def construct_hamiltonian_in_subspace(pauli_operator_list, |
| 58 | + hamiltonian_coefficients, |
| 59 | + subspace_basis_states): |
| 60 | + """ |
| 61 | + Project Hamiltonian operator onto the computational subspace spanned by basis states. |
| 62 | + |
| 63 | + This is the heart of SKQD: instead of computing expensive quantum matrix elements, |
| 64 | + we compute how each Pauli term in the Hamiltonian acts on our sampled basis states. |
| 65 | + Only matrix elements between states in our subspace contribute to the final result. |
| 66 | + |
| 67 | + Native CUDA-Q implementation using MSB (Most Significant Bit first) convention: |
| 68 | + - Both Pauli operator strings and basis states follow MSB bit ordering |
| 69 | + - Pauli operator index 0 acts on qubit 0 (leftmost bit position) |
| 70 | + |
| 71 | + Args: |
| 72 | + `pauli_operator_list: List of Pauli operator strings (e.g., ['IIXY', 'ZIII'])` |
| 73 | + hamiltonian_coefficients: List of real coefficients for each Pauli operator |
| 74 | + subspace_basis_states: Array of computational basis states defining the subspace |
| 75 | + |
| 76 | + Returns: |
| 77 | + `scipy.sparse matrix representing projected Hamiltonian within the subspace` |
| 78 | + """ |
| 79 | + subspace_dimension = subspace_basis_states.shape[0] |
| 80 | + |
| 81 | + # Create fast lookup: basis state → subspace index |
| 82 | + # This allows O(1) checking if a transformed state is in our subspace |
| 83 | + state_to_index_map = {} |
| 84 | + for idx, basis_state in enumerate(subspace_basis_states): |
| 85 | + state_key = tuple(basis_state) |
| 86 | + state_to_index_map[state_key] = idx |
| 87 | + |
| 88 | + # Build sparse matrix in COO format (rows, cols, values) |
| 89 | + matrix_rows, matrix_cols, matrix_elements = [], [], [] |
| 90 | + |
| 91 | + # For each basis state |i⟩ in our subspace... |
| 92 | + for initial_state_idx, initial_state in enumerate(subspace_basis_states): |
| 93 | + # For each Pauli term P_k in the Hamiltonian... |
| 94 | + for pauli_operator, coefficient in zip(pauli_operator_list, |
| 95 | + hamiltonian_coefficients): |
| 96 | + # Compute P_k|i⟩ = phase × |j⟩ |
| 97 | + final_state, phase_factor = transform_state_with_pauli_operator( |
| 98 | + initial_state, pauli_operator) |
| 99 | + final_state_key = tuple(final_state) |
| 100 | + |
| 101 | + # Only keep matrix element if |j⟩ is also in our subspace |
| 102 | + if final_state_key in state_to_index_map: |
| 103 | + final_state_idx = state_to_index_map[final_state_key] |
| 104 | + matrix_rows.append(final_state_idx) |
| 105 | + matrix_cols.append(initial_state_idx) |
| 106 | + |
| 107 | + # Matrix element: ⟨j|H|i⟩ = coefficient × phase_factor |
| 108 | + hamiltonian_element = coefficient * phase_factor |
| 109 | + |
| 110 | + # Clean up tiny imaginary parts (should be real for Hermitian H) |
| 111 | + if abs(hamiltonian_element.imag) < 1e-14: |
| 112 | + hamiltonian_element = hamiltonian_element.real |
| 113 | + matrix_elements.append(hamiltonian_element) |
| 114 | + |
| 115 | + # Convert to efficient sparse matrix format |
| 116 | + return csr_matrix((matrix_elements, (matrix_rows, matrix_cols)), |
| 117 | + shape=(subspace_dimension, subspace_dimension)) |
| 118 | + |
| 119 | + |
| 120 | +def diagonalize_subspace_hamiltonian(subspace_basis_states, |
| 121 | + pauli_operator_list, |
| 122 | + hamiltonian_coefficients, |
| 123 | + verbose=False, |
| 124 | + **solver_options): |
| 125 | + """ |
| 126 | + Perform eigenvalue decomposition of Hamiltonian within the computational subspace. |
| 127 | + |
| 128 | + This function combines Hamiltonian projection and `diagonalization` into one step. |
| 129 | + It's the final piece of SKQD: finding the lowest eigenvalues in our Krylov subspace. |
| 130 | + |
| 131 | + Args: |
| 132 | + subspace_basis_states: Array of computational basis states defining the subspace |
| 133 | + `pauli_operator_list: List of Pauli operator strings (e.g., ['IIXY', 'ZIII'])` |
| 134 | + hamiltonian_coefficients: List of real coefficients for each Pauli operator |
| 135 | + verbose: Enable diagnostic output |
| 136 | + `**solver_options: Additional arguments for scipy.sparse.linalg.eigsh` |
| 137 | + |
| 138 | + Returns: |
| 139 | + `numpy` array of eigenvalues from the subspace `diagonalization` |
| 140 | + """ |
| 141 | + if subspace_basis_states.shape[0] == 0: |
| 142 | + return np.array([]) |
| 143 | + |
| 144 | + # Step 1: Project the full Hamiltonian onto our sampled subspace |
| 145 | + projected_hamiltonian = construct_hamiltonian_in_subspace( |
| 146 | + pauli_operator_list, hamiltonian_coefficients, subspace_basis_states) |
| 147 | + |
| 148 | + if verbose: |
| 149 | + print(f"Subspace dimension: {projected_hamiltonian.shape[0]}") |
| 150 | + print( |
| 151 | + f"Hamiltonian sparsity: {projected_hamiltonian.nnz / (projected_hamiltonian.shape[0]**2):.4f}" |
| 152 | + ) |
| 153 | + |
| 154 | + # Step 2: Find eigenvalues of the projected Hamiltonian |
| 155 | + try: |
| 156 | + # Use sparse eigensolver for efficiency (typically we only need a few eigenvalues) |
| 157 | + eigenvalues = eigsh(projected_hamiltonian, |
| 158 | + return_eigenvectors=False, |
| 159 | + **solver_options) |
| 160 | + return eigenvalues |
| 161 | + except Exception as solver_error: |
| 162 | + if verbose: |
| 163 | + print(f"Sparse eigensolver failed: {solver_error}") |
| 164 | + |
| 165 | + # Fallback: use dense `diagonalization` for small matrices |
| 166 | + if projected_hamiltonian.shape[0] <= 100: |
| 167 | + dense_hamiltonian = projected_hamiltonian.toarray() |
| 168 | + eigenvalues = np.linalg.eigvals(dense_hamiltonian) |
| 169 | + |
| 170 | + # Extract only the requested eigenvalues to match sparse solver behavior |
| 171 | + num_eigenvalues = solver_options.get('k', min(6, len(eigenvalues))) |
| 172 | + eigenvalue_selection = solver_options.get('which', 'SA') |
| 173 | + if eigenvalue_selection == 'SA': # smallest algebraic eigenvalues |
| 174 | + selected_indices = np.argsort(eigenvalues)[:num_eigenvalues] |
| 175 | + else: |
| 176 | + selected_indices = np.argsort(eigenvalues)[-num_eigenvalues:] |
| 177 | + return eigenvalues[selected_indices] |
| 178 | + else: |
| 179 | + raise |
| 180 | + |
| 181 | + |
| 182 | +def accumulate_krylov_measurements(measurement_results_sequence, |
| 183 | + krylov_dimension): |
| 184 | + """ |
| 185 | + Progressively accumulate measurement outcomes from Krylov state evolution. |
| 186 | + |
| 187 | + This is a key insight of SKQD: instead of treating each Krylov state separately, |
| 188 | + we combine measurements from |ψ⟩, U|ψ⟩, U²|ψ⟩, ... to build increasingly |
| 189 | + rich computational `subspaces` that better approximate the true Krylov space. |
| 190 | + |
| 191 | + Args: |
| 192 | + measurement_results_sequence: List of measurement dictionaries from each U^k|ψ⟩ |
| 193 | + krylov_dimension: Number of Krylov states to consider |
| 194 | + |
| 195 | + Returns: |
| 196 | + List of accumulated measurement dictionaries, where entry k contains |
| 197 | + measurements from all states |ψ⟩, U|ψ⟩, ..., U^k|ψ⟩ |
| 198 | + """ |
| 199 | + accumulated_measurements = [] |
| 200 | + |
| 201 | + # For each Krylov dimension k = 1, 2, 3, ... |
| 202 | + for evolution_step in range(krylov_dimension): |
| 203 | + measurement_accumulator = Counter() |
| 204 | + |
| 205 | + # Combine measurements from all states up to U^k|ψ⟩ |
| 206 | + for measurement_data in measurement_results_sequence[:evolution_step + |
| 207 | + 1]: |
| 208 | + measurement_accumulator.update(measurement_data) |
| 209 | + |
| 210 | + # Convert back to dictionary and store |
| 211 | + combined_measurements = dict(measurement_accumulator) |
| 212 | + accumulated_measurements.append(combined_measurements) |
| 213 | + |
| 214 | + return accumulated_measurements |
| 215 | + |
| 216 | + |
| 217 | +def construct_xyz_spin_hamiltonian( |
| 218 | + system_size: int, |
| 219 | + interaction_strengths: tuple[float, float, float] = (1.0, 1.0, 1.0), |
| 220 | + external_field_strengths: tuple[float, float, float] = (0.0, 0.0, 0.0), |
| 221 | + topology_type: str = "ring") -> cudaq.SpinOperator: |
| 222 | + """ |
| 223 | + Construct `XYZ` spin model Hamiltonian using native CUDA-Q SpinOperator framework. |
| 224 | + |
| 225 | + Implements the quantum many-body Hamiltonian: |
| 226 | + H = sum_{(i,j) ∈ edges} [J_x σ_i^x σ_j^x + J_y σ_i^y σ_j^y + J_z σ_i^z σ_j^z] + |
| 227 | + sum_{i ∈ sites} [h_x σ_i^x + h_y σ_i^y + h_z σ_i^z] |
| 228 | + |
| 229 | + Args: |
| 230 | + system_size: Number of quantum spins in the system |
| 231 | + interaction_strengths: (J_x, J_y, J_z) nearest-neighbor coupling parameters |
| 232 | + external_field_strengths: (h_x, h_y, h_z) local magnetic field components |
| 233 | + topology_type: Lattice connectivity ("ring" implements periodic boundary conditions) |
| 234 | + |
| 235 | + Returns: |
| 236 | + CUDA-Q SpinOperator encoding the XYZ spin Hamiltonian |
| 237 | + """ |
| 238 | + J_x, J_y, J_z = interaction_strengths |
| 239 | + h_x, h_y, h_z = external_field_strengths |
| 240 | + |
| 241 | + # Initialize Hamiltonian with null operator |
| 242 | + spin_hamiltonian = 0.0 * spin.z(0) |
| 243 | + |
| 244 | + # Construct nearest-neighbor interaction terms (ring topology only) |
| 245 | + for site_i in range(system_size): |
| 246 | + site_j = (site_i + |
| 247 | + 1) % system_size # Nearest neighbor with periodic wrapping |
| 248 | + |
| 249 | + # Add Pauli tensor product interactions (skip zero coefficients) |
| 250 | + if J_x != 0.0: |
| 251 | + spin_hamiltonian += J_x * spin.x(site_i) * spin.x(site_j) |
| 252 | + if J_y != 0.0: |
| 253 | + spin_hamiltonian += J_y * spin.y(site_i) * spin.y(site_j) |
| 254 | + if J_z != 0.0: |
| 255 | + spin_hamiltonian += J_z * spin.z(site_i) * spin.z(site_j) |
| 256 | + |
| 257 | + # Add local magnetic field terms (skip zero fields) |
| 258 | + if h_x != 0.0 or h_y != 0.0 or h_z != 0.0: |
| 259 | + for site_i in range(system_size): |
| 260 | + if h_x != 0.0: |
| 261 | + spin_hamiltonian += h_x * spin.x(site_i) |
| 262 | + if h_y != 0.0: |
| 263 | + spin_hamiltonian += h_y * spin.y(site_i) |
| 264 | + if h_z != 0.0: |
| 265 | + spin_hamiltonian += h_z * spin.z(site_i) |
| 266 | + |
| 267 | + return spin_hamiltonian |
| 268 | + |
| 269 | + |
| 270 | +def create_heisenberg_hamiltonian(n_spins: int, Jx: float, Jy: float, Jz: float, |
| 271 | + h_x: list[float], h_y: list[float], |
| 272 | + h_z: list[float]): |
| 273 | + |
| 274 | + ham = 0 |
| 275 | + |
| 276 | + # Add two-qubit interaction terms for Heisenberg Hamiltonian |
| 277 | + for i in range(0, n_spins - 1): |
| 278 | + ham += Jx * spin.x(i) * spin.x(i + 1) |
| 279 | + ham += Jy * spin.y(i) * spin.y(i + 1) |
| 280 | + ham += Jz * spin.z(i) * spin.z(i + 1) |
| 281 | + |
| 282 | + return ham |
| 283 | + |
| 284 | + |
| 285 | +def extract_hamiltonian_data(spin_operator: cudaq.SpinOperator): |
| 286 | + """Extract coefficients, Pauli words, and strings from CUDA-Q SpinOperator. |
| 287 | + |
| 288 | + Optimized single-pass extraction of all required Hamiltonian data. |
| 289 | + |
| 290 | + Args: |
| 291 | + spin_operator: CUDA-Q SpinOperator to decompose |
| 292 | + |
| 293 | + Returns: |
| 294 | + `tuple: (coefficients_list, pauli_words_list, pauli_strings_list)` |
| 295 | + """ |
| 296 | + system_size = spin_operator.qubit_count |
| 297 | + coefficients_list = [] |
| 298 | + pauli_words_list = [] |
| 299 | + pauli_strings_list = [] |
| 300 | + |
| 301 | + for pauli_term in spin_operator: |
| 302 | + # Extract coefficient |
| 303 | + term_coefficient = pauli_term.evaluate_coefficient() |
| 304 | + assert abs( |
| 305 | + term_coefficient.imag |
| 306 | + ) < 1e-10, f"Non-real coefficient encountered: {term_coefficient}" |
| 307 | + coefficients_list.append(float(term_coefficient.real)) |
| 308 | + |
| 309 | + # Extract Pauli string |
| 310 | + pauli_string = pauli_term.get_pauli_word(system_size) |
| 311 | + pauli_strings_list.append(pauli_string) |
| 312 | + |
| 313 | + # Create Pauli word object |
| 314 | + pauli_words_list.append(cudaq.pauli_word(pauli_string)) |
| 315 | + |
| 316 | + return coefficients_list, pauli_words_list, pauli_strings_list |
| 317 | + |
| 318 | + |
| 319 | +def create_tfim_hamiltonian(n_spins: int, h_field: float): |
| 320 | + """Create the Hamiltonian operator""" |
| 321 | + ham = 0 |
| 322 | + |
| 323 | + # Add single-qubit terms |
| 324 | + for i in range(0, n_spins): |
| 325 | + ham += -1 * h_field * spin.x(i) |
| 326 | + |
| 327 | + # Add two-qubit interaction terms for Ising Hamiltonian |
| 328 | + for i in range(0, n_spins - 1): |
| 329 | + ham += -1 * spin.z(i) * spin.z(i + 1) |
| 330 | + |
| 331 | + return ham |
| 332 | + |
| 333 | + |
| 334 | +def extract_basis_states_from_measurements(measurement_counts): |
| 335 | + """ |
| 336 | + Extract computational basis states from CUDA-Q measurement results. |
| 337 | + |
| 338 | + This function converts the measurement outcome dictionary into a matrix where |
| 339 | + each row represents a unique computational basis state observed during sampling. |
| 340 | + |
| 341 | + Args: |
| 342 | + measurement_counts: Dictionary mapping bitstring outcomes to their frequencies |
| 343 | + |
| 344 | + Returns: |
| 345 | + `numpy` array of computational basis states (MSB ordering) |
| 346 | + `Shape: (num_unique_states, num_qubits)` |
| 347 | + """ |
| 348 | + if not measurement_counts: |
| 349 | + return np.array([]) |
| 350 | + |
| 351 | + # Extract all unique bitstrings that were observed during measurements |
| 352 | + observed_bitstrings = list(measurement_counts.keys()) |
| 353 | + num_qubits = len(observed_bitstrings[0]) |
| 354 | + |
| 355 | + # Convert bitstrings to binary matrix representation |
| 356 | + # Each row is a computational basis state |00...⟩, |01...⟩, etc. |
| 357 | + basis_state_matrix = np.array( |
| 358 | + [[int(bit) for bit in bitstring] for bitstring in observed_bitstrings], |
| 359 | + dtype=np.int8) |
| 360 | + |
| 361 | + return basis_state_matrix |
0 commit comments