Skip to content

quast_decisiontree.algorithms.hybrid.qrisp_vqe

quast_decisiontree.algorithms.hybrid.qrisp_vqe

QrispVQE — Measurement-based VQE using Qrisp circuits.

logger module-attribute

logger = logging.getLogger('dt_logger')

VQEResult dataclass

Bases: VariationalResult

Result container for QrispVQE execution.

Inherits from VariationalResult

counts, optimal_params, cost_history, num_evals

Additional attributes

energy: The minimized energy eigenvalue. best_bitstring: The bitstring with the lowest energy (from final measurement).

Source code in src/quast_decisiontree/algorithms/hybrid/qrisp_vqe.py
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@dataclass
class VQEResult(VariationalResult):
    """Result container for QrispVQE execution.

    Inherits from VariationalResult:
        counts, optimal_params, cost_history, num_evals

    Additional attributes:
        energy: The minimized energy eigenvalue.
        best_bitstring: The bitstring with the lowest energy (from final measurement).
    """

    energy: float = float("inf")
    best_bitstring: str = ""

energy class-attribute instance-attribute

energy = float('inf')

best_bitstring class-attribute instance-attribute

best_bitstring = ''

__init__

__init__(
    counts,
    optimal_params=None,
    cost_history=list(),
    num_evals=0,
    energy=float("inf"),
    best_bitstring="",
)

QrispVQE

Bases: HybridAlgorithm

Measurement-based VQE implementation using Qrisp circuits.

Uses a sampling approach: prepare parameterized state → measure → compute energy from counts by iterating over Hamiltonian terms. This avoids Qrisp's VQEProblem.expectation_value() which has memory issues with large Hamiltonians due to JAX graph accumulation.

Energy evaluation uses QubitOperator.terms_dict to iterate over Hamiltonian terms and QubitTerm.factor_dict for per-qubit Pauli type lookup. For diagonal (Z-only) Hamiltonians, this yields exact per-bitstring energies. Non-Z terms contribute zero in the computational basis and are handled gracefully.

Compatible with the decision tree node flow

QrispAnsatzNode → sets ansatz_function + num_params SelectOptimizerNode → sets optimizer SelectBackendNode → sets backend QrispVQESetupNode → instantiates this class HybridAlgorithmExecuteNode → calls execute()

Example usage (standalone)::

vqe = QrispVQE(depth=1, max_iter=100, optimizer="COBYLA")
result = vqe.execute(
    backend=my_backend,
    hamiltonian=my_hamiltonian,
    ansatz_function=my_ansatz,
    num_params=8,
    num_qubits=4,
)
print(result.energy, result.best_bitstring)
Source code in src/quast_decisiontree/algorithms/hybrid/qrisp_vqe.py
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
class QrispVQE(HybridAlgorithm):
    """Measurement-based VQE implementation using Qrisp circuits.

    Uses a sampling approach: prepare parameterized state → measure → compute
    energy from counts by iterating over Hamiltonian terms. This avoids Qrisp's
    VQEProblem.expectation_value() which has memory issues with large
    Hamiltonians due to JAX graph accumulation.

    Energy evaluation uses QubitOperator.terms_dict to iterate over Hamiltonian
    terms and QubitTerm.factor_dict for per-qubit Pauli type lookup. For
    diagonal (Z-only) Hamiltonians, this yields exact per-bitstring energies.
    Non-Z terms contribute zero in the computational basis and are handled
    gracefully.

    Compatible with the decision tree node flow:
        QrispAnsatzNode → sets ansatz_function + num_params
        SelectOptimizerNode → sets optimizer
        SelectBackendNode → sets backend
        QrispVQESetupNode → instantiates this class
        HybridAlgorithmExecuteNode → calls execute()

    Example usage (standalone)::

        vqe = QrispVQE(depth=1, max_iter=100, optimizer="COBYLA")
        result = vqe.execute(
            backend=my_backend,
            hamiltonian=my_hamiltonian,
            ansatz_function=my_ansatz,
            num_params=8,
            num_qubits=4,
        )
        print(result.energy, result.best_bitstring)
    """

    HYPERPARAMS: ClassVar[list] = [
        HyperParam(
            name="depth",
            hparam_type=int,
            description="Number of ansatz repetitions (layers)",
            default=1,
            test=lambda x: x > 0,
        ),
        HyperParam(
            name="max_iter",
            hparam_type=int,
            description="Maximum number of classical optimizer iterations",
            default=50,
            test=lambda x: x > 0,
        ),
        HyperParam(
            name="optimizer",
            hparam_type=None,
            description=(
                "Classical optimizer: a string name for scipy.optimize.minimize "
                "(e.g. 'COBYLA', 'Nelder-Mead') or an object with a "
                ".minimize(fun, x0) method (e.g. from OptimizerBuilder)"
            ),
            default="COBYLA",
        ),
        HyperParam(
            name="shots",
            hparam_type=int,
            description="Number of measurement shots per evaluation",
            default=1024,
            test=lambda x: x > 0,
        ),
        HyperParam(
            name="init_params",
            hparam_type=None,
            description="Initial variational parameters (array-like, optional)",
            default=None,
        ),
    ]

    INPUT_KEYS: ClassVar[Sequence[str]] = (
        "hamiltonian",
        "ansatz_function",
        "num_params",
        "num_qubits",
    )

    def run_algorithm(self, backend: Backend, input: dict[str, Any]) -> VQEResult:
        """Execute measurement-based VQE.

        Args:
            backend: The backend to run quantum circuits on.
            input: Dict with keys from INPUT_KEYS.

        Returns:
            VQEResult with energy, counts, best solution, and metadata.
        """
        hamiltonian = input["hamiltonian"]
        ansatz_function = input["ansatz_function"]
        num_params = input["num_params"]
        num_qubits = input["num_qubits"]
        total_params = num_params * self.depth

        logger.debug(
            "QrispVQE.run_algorithm called with num_qubits=%d, num_params=%d, "
            "depth=%d, total_params=%d",
            num_qubits,
            num_params,
            self.depth,
            total_params,
        )
        logger.debug(
            "QrispVQE hyperparameters: max_iter=%d, optimizer=%r, shots=%d",
            self.max_iter,
            self.optimizer,
            self.shots,
        )

        terms_dict = hamiltonian.terms_dict
        logger.debug("Hamiltonian has %d terms.", len(terms_dict))

        self._cost_history: list[float] = []

        cost_fn = self._make_cost_closure(
            backend, terms_dict, ansatz_function, num_params, num_qubits
        )

        init_params = self._initialize_params(total_params)
        logger.debug("Initial parameters shape: %s", init_params.shape)

        logger.debug("Starting optimization loop.")
        opt_result = self._run_optimizer(cost_fn, init_params)
        optimal_params = self._extract_params(opt_result)
        logger.debug(
            "Optimization complete after %d evaluations. Final energy: %s",
            len(self._cost_history),
            self._cost_history[-1] if self._cost_history else "N/A",
        )

        # Final measurement with optimal parameters
        logger.debug("Performing final measurement with optimal parameters.")
        final_counts = self._measure(
            backend, optimal_params, ansatz_function, num_params, num_qubits
        )
        logger.debug(
            "Final measurement: %d unique bitstrings from %d shots.",
            len(final_counts),
            sum(final_counts.values()),
        )

        # Find best bitstring
        best_bitstring, best_energy = self._evaluate_best(final_counts, terms_dict)
        logger.debug("Best bitstring: %r with energy: %.6f", best_bitstring, best_energy)

        final_energy = self._cost_history[-1] if self._cost_history else best_energy

        return VQEResult(
            counts=final_counts,
            energy=final_energy,
            best_bitstring=best_bitstring,
            optimal_params=optimal_params,
            cost_history=list(self._cost_history),
            num_evals=len(self._cost_history),
        )

    def _make_cost_closure(self, backend, terms_dict, ansatz_function, num_params, num_qubits):
        """Create the cost function closure for the optimizer."""

        def cost_fn(params):
            counts = self._measure(backend, params, ansatz_function, num_params, num_qubits)
            energy = self._compute_energy(counts, terms_dict)
            self._cost_history.append(energy)

            if len(self._cost_history) % 10 == 0:
                logger.debug(
                    "VQE iteration %d: energy = %.6f",
                    len(self._cost_history),
                    energy,
                )

            return energy

        return cost_fn

    def _measure(self, backend, params, ansatz_function, num_params, num_qubits) -> dict[str, int]:
        """Prepare parameterized state and measure.

        Args:
            backend: The backend to run quantum circuits on.
            params: Full parameter vector (length = num_params * depth).
            ansatz_function: Callable(qv, layer_params) applying one ansatz layer.
            num_params: Number of parameters per ansatz layer.
            num_qubits: Number of qubits.

        Returns:
            Measurement counts as {bitstring: count}.
        """
        from qrisp import QuantumVariable

        qv = QuantumVariable(num_qubits)

        # Apply ansatz layers
        for d in range(self.depth):
            layer_params = params[d * num_params : (d + 1) * num_params]
            ansatz_function(qv, layer_params)

        # Measure
        mes_kwargs = {}
        if backend is not None and backend.has_submit:
            backend_name = backend.get("name", "")
            if backend_name != "qrisp_simulator":
                mes_kwargs["backend"] = backend

        return qv.get_measurement(shots=self.shots, **mes_kwargs)

    @staticmethod
    def _bitstring_energy(bitstring: str, terms_dict: dict) -> float:
        """Compute energy of a single bitstring for a Hamiltonian.

        For each term: coeff * Π_{i in qubits} eigenvalue(pauli_i, bit_i)
        Z eigenvalues: |0⟩ → +1, |1⟩ → -1.
        Non-Z (X, Y) terms contribute zero in the computational basis.

        Args:
            bitstring: Measured bitstring (qubit 0 is leftmost).
            terms_dict: QubitOperator.terms_dict mapping QubitTerm → coefficient.

        Returns:
            Energy of the bitstring.
        """
        energy = 0.0
        for term, coeff in terms_dict.items():
            term_val = float(coeff)
            for qubit_idx, pauli in term.factor_dict.items():
                if pauli == "Z":
                    bit = int(bitstring[qubit_idx])
                    term_val *= 1 - 2 * bit
                else:
                    # X and Y have zero expectation value in computational basis
                    term_val = 0.0
                    break
            energy += term_val
        return energy

    @staticmethod
    def _compute_energy(counts: dict[str, float], terms_dict: dict) -> float:
        """Compute expectation value from measurement counts.

        Evaluates E = Σ_b (count_b / total) * E(b) where E(b) is the
        energy of bitstring b computed via term iteration.

        Args:
            counts: {bitstring: count} mapping from measurement.
            terms_dict: QubitOperator.terms_dict for term iteration.

        Returns:
            Estimated expectation value of the Hamiltonian.
        """
        total_shots = sum(counts.values())
        if total_shots == 0:
            return float("inf")

        energy = 0.0
        for bitstring, count in counts.items():
            energy += (count / total_shots) * QrispVQE._bitstring_energy(bitstring, terms_dict)
        return energy

    @staticmethod
    def _evaluate_best(counts: dict[str, float], terms_dict: dict) -> tuple:
        """Find the measured bitstring with the lowest energy.

        Args:
            counts: {bitstring: count} mapping from measurement.
            terms_dict: QubitOperator.terms_dict for term iteration.

        Returns:
            Tuple of (best_bitstring, best_energy).
        """
        if not counts:
            return "", float("inf")

        best_bitstring = ""
        best_energy = float("inf")

        for bitstring in counts:
            bitstring_energy = QrispVQE._bitstring_energy(bitstring, terms_dict)
            if bitstring_energy < best_energy:
                best_energy = bitstring_energy
                best_bitstring = bitstring

        return best_bitstring, best_energy

    def _initialize_params(self, total_params: int) -> np.ndarray:
        """Initialize variational parameters.

        Uses init_params if provided and length matches, otherwise
        random uniform in [0, 2π).
        """
        if self.init_params is not None:
            params = np.asarray(self.init_params, dtype=float)
            if len(params) != total_params:
                logger.warning(
                    "init_params length (%d) != total_params (%d). Using random initialization.",
                    len(params),
                    total_params,
                )
                return np.random.uniform(0, 2 * np.pi, size=total_params)
            return params
        return np.random.uniform(0, 2 * np.pi, size=total_params)

    def _run_optimizer(self, cost_fn, init_params: np.ndarray):
        """Run the classical optimization loop.

        Delegates to scipy.optimize.minimize or a custom optimizer
        adapted via adapt_optimizer().
        """
        adapted = adapt_optimizer(self.optimizer)

        if adapted is None or isinstance(adapted, str):
            method = adapted if isinstance(adapted, str) else "COBYLA"
            logger.debug("Using scipy.optimize.minimize with method='%s'.", method)
            return scipy_minimize(
                cost_fn,
                init_params,
                method=method,
                options={"maxiter": self.max_iter},
            )
        elif callable(adapted):
            logger.debug("Using adapted custom optimizer.")
            return adapted(cost_fn, init_params)
        else:
            logger.debug("Using COBYLA as fallback optimizer.")
            return scipy_minimize(
                cost_fn,
                init_params,
                method="COBYLA",
                options={"maxiter": self.max_iter},
            )

    @staticmethod
    def _extract_params(opt_result) -> np.ndarray:
        """Extract parameter array from optimizer result."""
        if hasattr(opt_result, "x"):
            return np.asarray(opt_result.x)
        return np.asarray(opt_result)

HYPERPARAMS class-attribute

HYPERPARAMS = [
    HyperParam(
        name="depth",
        hparam_type=int,
        description="Number of ansatz repetitions (layers)",
        default=1,
        test=lambda x: x > 0,
    ),
    HyperParam(
        name="max_iter",
        hparam_type=int,
        description="Maximum number of classical optimizer iterations",
        default=50,
        test=lambda x: x > 0,
    ),
    HyperParam(
        name="optimizer",
        hparam_type=None,
        description="Classical optimizer: a string name for scipy.optimize.minimize (e.g. 'COBYLA', 'Nelder-Mead') or an object with a .minimize(fun, x0) method (e.g. from OptimizerBuilder)",
        default="COBYLA",
    ),
    HyperParam(
        name="shots",
        hparam_type=int,
        description="Number of measurement shots per evaluation",
        default=1024,
        test=lambda x: x > 0,
    ),
    HyperParam(
        name="init_params",
        hparam_type=None,
        description="Initial variational parameters (array-like, optional)",
        default=None,
    ),
]

INPUT_KEYS class-attribute

INPUT_KEYS = (
    "hamiltonian",
    "ansatz_function",
    "num_params",
    "num_qubits",
)

run_algorithm

run_algorithm(backend, input)

Execute measurement-based VQE.

Parameters:

Name Type Description Default
backend Backend

The backend to run quantum circuits on.

required
input dict[str, Any]

Dict with keys from INPUT_KEYS.

required

Returns:

Type Description
VQEResult

VQEResult with energy, counts, best solution, and metadata.

Source code in src/quast_decisiontree/algorithms/hybrid/qrisp_vqe.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
def run_algorithm(self, backend: Backend, input: dict[str, Any]) -> VQEResult:
    """Execute measurement-based VQE.

    Args:
        backend: The backend to run quantum circuits on.
        input: Dict with keys from INPUT_KEYS.

    Returns:
        VQEResult with energy, counts, best solution, and metadata.
    """
    hamiltonian = input["hamiltonian"]
    ansatz_function = input["ansatz_function"]
    num_params = input["num_params"]
    num_qubits = input["num_qubits"]
    total_params = num_params * self.depth

    logger.debug(
        "QrispVQE.run_algorithm called with num_qubits=%d, num_params=%d, "
        "depth=%d, total_params=%d",
        num_qubits,
        num_params,
        self.depth,
        total_params,
    )
    logger.debug(
        "QrispVQE hyperparameters: max_iter=%d, optimizer=%r, shots=%d",
        self.max_iter,
        self.optimizer,
        self.shots,
    )

    terms_dict = hamiltonian.terms_dict
    logger.debug("Hamiltonian has %d terms.", len(terms_dict))

    self._cost_history: list[float] = []

    cost_fn = self._make_cost_closure(
        backend, terms_dict, ansatz_function, num_params, num_qubits
    )

    init_params = self._initialize_params(total_params)
    logger.debug("Initial parameters shape: %s", init_params.shape)

    logger.debug("Starting optimization loop.")
    opt_result = self._run_optimizer(cost_fn, init_params)
    optimal_params = self._extract_params(opt_result)
    logger.debug(
        "Optimization complete after %d evaluations. Final energy: %s",
        len(self._cost_history),
        self._cost_history[-1] if self._cost_history else "N/A",
    )

    # Final measurement with optimal parameters
    logger.debug("Performing final measurement with optimal parameters.")
    final_counts = self._measure(
        backend, optimal_params, ansatz_function, num_params, num_qubits
    )
    logger.debug(
        "Final measurement: %d unique bitstrings from %d shots.",
        len(final_counts),
        sum(final_counts.values()),
    )

    # Find best bitstring
    best_bitstring, best_energy = self._evaluate_best(final_counts, terms_dict)
    logger.debug("Best bitstring: %r with energy: %.6f", best_bitstring, best_energy)

    final_energy = self._cost_history[-1] if self._cost_history else best_energy

    return VQEResult(
        counts=final_counts,
        energy=final_energy,
        best_bitstring=best_bitstring,
        optimal_params=optimal_params,
        cost_history=list(self._cost_history),
        num_evals=len(self._cost_history),
    )