diff --git a/challenges/VQA-Knapsack/pyproject.toml b/challenges/VQA-Knapsack/pyproject.toml index 589e31f..9f756b9 100644 --- a/challenges/VQA-Knapsack/pyproject.toml +++ b/challenges/VQA-Knapsack/pyproject.toml @@ -4,6 +4,8 @@ version = "0.1.0" requires-python = ">=3.12" dependencies = [ "matplotlib>=3.10.9", + "pylatexenc>=2.10", "qiskit>=2.4.1", + "qiskit-optimization>=0.7.0", "scipy>=1.17.1", ] diff --git a/pyproject.toml b/pyproject.toml index b353e3b..a0c8a00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,7 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.12" -dependencies = [ - "ipython>=9.13.0", -] +dependencies = ["ipython>=9.13.0"] [dependency-groups] dev = ["pytest>=9.0.3", "ruff>=0.15.13"] @@ -14,9 +12,14 @@ dev = ["pytest>=9.0.3", "ruff>=0.15.13"] [tool.pytest] pythonpath = ["src"] +[tool.ruff] +extend-exclude = ["*.ipynb"] + +[tool.ruff.lint] +select = ["D"] + +[tool.ruff.lint.pydocstyle] +convention = "google" + [tool.uv.workspace] -members = [ - "challenges/VQA-Knapsack", - "challenges/VQA_QChem", - "challenges/QNN_MNIST", -] +members = ["challenges/**"] diff --git a/solutions/knapsack/results/values1,2_weights4,5_cap6.zip b/solutions/knapsack/results/values1,2_weights4,5_cap6.zip new file mode 100644 index 0000000..319ee00 Binary files /dev/null and b/solutions/knapsack/results/values1,2_weights4,5_cap6.zip differ diff --git a/solutions/knapsack/results/values4,3,2_weights2,2,1_cap2.zip b/solutions/knapsack/results/values4,3,2_weights2,2,1_cap2.zip new file mode 100644 index 0000000..da66a14 Binary files /dev/null and b/solutions/knapsack/results/values4,3,2_weights2,2,1_cap2.zip differ diff --git a/solutions/knapsack/results/values4,3_weights2,1_cap2.zip b/solutions/knapsack/results/values4,3_weights2,1_cap2.zip new file mode 100644 index 0000000..77fd11c Binary files /dev/null and b/solutions/knapsack/results/values4,3_weights2,1_cap2.zip differ diff --git a/solutions/knapsack/src/knapsack.py b/solutions/knapsack/src/knapsack.py new file mode 100644 index 0000000..9ed4cef --- /dev/null +++ b/solutions/knapsack/src/knapsack.py @@ -0,0 +1,679 @@ +"""Knapsack utilities and QUBO mapping helpers. + +This module provides helper functions to evaluate knapsack objective and +penalty terms and a mapper that converts a knapsack instance into an Ising +Hamiltonian (QUBO) using Qiskit Optimization. It also exposes a small +script-style entrypoint for local demonstrations. +""" + +from dataclasses import asdict, dataclass, is_dataclass +from collections import defaultdict +import itertools + +import matplotlib.pyplot as plt +import numpy as np +from qiskit import QuantumCircuit +from qiskit.quantum_info import SparsePauliOp, Statevector +from qiskit_optimization import QuadraticProgram +from qiskit_optimization.converters import QuadraticProgramToQubo +from qiskit_optimization.applications import Knapsack +from qiskit.circuit.library import QAOAAnsatz +from qiskit.primitives import BaseEstimatorV2, StatevectorEstimator +from scipy.optimize import minimize, OptimizeResult + +import logging +from argparse import ArgumentParser + + +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s" +) +logger = logging.getLogger(__name__) + +SEED = None # Global seed for reproducibility, can be set from command-line arguments +OBJECTIVE_FUNC_VALUES = [] + + +@dataclass +class KnapsackInstance: + """Representation of a knapsack problem instance. + + Attributes: + values: List of integer values (profits) for each item. + weights: List of integer weights for each item. Must be same length as + ``values``. + capacity: Maximum allowed total weight (knapsack capacity). + """ + + values: list[int] + weights: list[int] + capacity: int + + +def cost_function(x: list[int], knapsack_instance: KnapsackInstance) -> float: + """Compute the (negative) total value for a binary selection vector. + + The function returns the negative sum of selected item values so that a + minimizer will prefer selections with larger total value. + + Args: + x: Binary list where 1 indicates the item is selected and 0 otherwise. + knapsack_instance: The knapsack instance containing item values and capacity. + + Returns: + The cost as a float (negative total value). + """ + cost = 0 + for i in range(len(x)): + cost -= x[i] * knapsack_instance.values[i] + + return cost + + +def penalty_function( + x: list[int], knapsack_instance: KnapsackInstance, penalty_value: float = None +) -> float: + """Compute a quadratic penalty for capacity violation. + + The penalty is applied when the total weight of the selected items + exceeds the knapsack capacity. It uses a quadratic term scaled by + ``penalty_value`` to heavily penalize infeasible selections. + + Args: + x: Binary list where 1 indicates the item is selected. + knapsack_instance: The knapsack instance containing item weights and capacity. + penalty_value: Scaling factor for the penalty (defaults to sum of + global `VALUES`). + + Returns: + The penalty term as a float. + """ + if penalty_value is None: + penalty_value = sum(knapsack_instance.values) + + penalty = penalty_value * np.square( + max( + 0, + sum(x[i] * knapsack_instance.weights[i] for i in range(len(x))) + - knapsack_instance.capacity, + ) + ) + + return penalty + + +def objective_function(x: list[int], knapsack_instance: KnapsackInstance) -> float: + """Compute the combined objective (cost + penalty) for a selection. + + Args: + x: Binary selection list. + knapsack_instance: The knapsack instance containing item values and capacity. + + Returns: + The scalar objective value (cost + penalty). + """ + return cost_function(x, knapsack_instance) + penalty_function(x, knapsack_instance) + + +def bruteforce(knapsack_instance: KnapsackInstance) -> tuple[list[int], float]: + """Brute-force search for the optimal selection vector. + + This helper function exhaustively evaluates all possible binary selection + vectors and returns the one with the lowest objective function value. + + Args: + knapsack_instance: The knapsack instance to solve. + + Returns: + The optimal binary selection vector as a list of integers (0 or 1) and its corresponding objective function value. + """ + bestValue = float("inf") + bestArray = [] + + for test in itertools.product([0, 1], repeat=len(knapsack_instance.values)): + test = list(test) + newValue = objective_function(test, knapsack_instance) + if newValue < bestValue: + bestValue = newValue + bestArray = test.copy() + + return bestArray, bestValue + + +def map_hamiltonian( + knapsack_instance: KnapsackInstance, penalty_factor: float +) -> tuple[SparsePauliOp, float]: + """Convert a `KnapsackInstance` into an Ising Hamiltonian. + + This helper constructs a Qiskit Optimization `Knapsack` application + model, converts it to a `QuadraticProgram`, maps it to a QUBO and then + returns the Ising representation (a `SparsePauliOp`) together with the + associated energy offset. + + Args: + knapsack_instance: The knapsack instance to convert. + penalty_factor: The scaling factor for the penalty term. + + Returns: + A tuple ``(hamiltonian, offset)`` where ``hamiltonian`` is a + `qiskit.quantum_info.SparsePauliOp` describing the Ising operator and + ``offset`` is the numeric energy offset (float). + """ + knapsack = Knapsack( + values=knapsack_instance.values, + weights=knapsack_instance.weights, + max_weight=knapsack_instance.capacity, + ) + + qprog: QuadraticProgram = knapsack.to_quadratic_program() + qubo = QuadraticProgramToQubo(penalty=penalty_factor).convert(qprog) + + return qubo.to_ising() + + +def pstate( + knapsack_instance: KnapsackInstance, hamiltonian: SparsePauliOp +) -> QuantumCircuit: + """Construct a quantum circuit that prepares a reference state from a + `KnapsackInstance`. + + Args: + knapsack_instance: The knapsack instance to create the state for. + hamiltonian: The Ising Hamiltonian associated with the problem. + + Returns: + A `QuantumCircuit` that prepares a reference state (e.g., all zeros). + """ + num_qubits = hamiltonian.num_qubits + circuit = QuantumCircuit(num_qubits) + + def most_valuable_item(): + """Identify the index of the most valuable item that fits within capacity.""" + best_index = None + best_value = -float("inf") + for i in range(len(knapsack_instance.values)): + if ( + knapsack_instance.weights[i] <= knapsack_instance.capacity + and knapsack_instance.values[i] > best_value + ): + best_value = knapsack_instance.values[i] + best_index = i + return best_index + + index = most_valuable_item() + if index is not None: + circuit.x(index) # Flip the qubit corresponding to the most valuable item + + return circuit + + +def decode_optimization_result( + optimization_result: OptimizeResult, + ansatz: QAOAAnsatz, + hamiltonian: SparsePauliOp, + estimator: BaseEstimatorV2, + results: dict, +): + """Decode the optimization result to extract the best solution and its value. + + This function takes the optimization result from scipy, constructs the corresponding quantum state using the ansatz and optimal parameters, and identifies the solution bitstring with the lowest energy. It then updates the results dictionary with the decoded solution and its value. + + Args: + optimization_result: The result object returned by scipy.optimize.minimize. + ansatz: The QAOA ansatz circuit used for the optimization. + hamiltonian: The cost Hamiltonian as a SparsePauliOp. + estimator: A Qiskit Estimator instance for evaluating expectation values. + results: The dictionary to update with the decoded solution and its value. + """ + optimal_params = optimization_result.x + optimal_circuit = ansatz.assign_parameters(optimal_params) + + optimal_state = Statevector.from_instruction(optimal_circuit) # type: ignore + probabilities = optimal_state.probabilities_dict() + results["quantum"]["probabilities"] = probabilities + + +def _x0_parameters(num_params) -> np.ndarray: + """Generate deterministic initial parameters for the optimizer. + + Args: + num_params: Number of parameters to generate. + + Returns: + np.ndarray: Seeded random initial parameter vector. + """ + params = np.random.RandomState(seed=SEED).random(num_params) + return params + + +def _json_default(value): + """Convert common non-JSON types in experiment results to plain Python objects.""" + if is_dataclass(value): + return asdict(value) + if isinstance(value, complex): + return {"real": value.real, "imag": value.imag} + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + + raise TypeError( + f"Object of type {value.__class__.__name__} is not JSON serializable" + ) + + +def _qiskit_bits_inversion(probabilities, sort_keys: bool = True): + """Invert Qiskit bitstring keys from q_{n-1}...q_0 to x_0...x_{n-1}. + + If a dict mapping bitstrings->values is provided, the mapping is + updated in-place so keys become their reversed bitstrings and the same + values are preserved. By default the resulting mapping is reordered so + keys are sorted lexicographically. If a list of keys is provided, a new + list of reversed (and optionally sorted) keys is returned. + """ + if isinstance(probabilities, dict): + new = {key[::-1]: value for key, value in probabilities.items()} + if sort_keys: + ordered = dict(sorted(new.items(), key=lambda kv: kv[0])) + else: + ordered = new + probabilities.clear() + probabilities.update(ordered) + return probabilities + + if isinstance(probabilities, list): + out = [key[::-1] for key in probabilities] + return sorted(out) if sort_keys else out + + raise TypeError("_qiskit_bits_inversion expects a dict or list of strings") + + +def _save_results(results: dict): + """Save the results dictionary to a JSON file. + + Args: + results: The dictionary containing optimization results and metadata. + """ + import json + import datetime as dt + from pathlib import Path + + timestamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S") + if not Path("results").exists(): + Path("results").mkdir() + result_dirname = f"knapsack_{timestamp}" + Path(f"results/{result_dirname}").mkdir() + filename = f"knapsack_results_{timestamp}.json" + with open(f"results/{result_dirname}/{filename}", "w") as f: + json.dump(results, f, indent=4, default=_json_default) + logger.info(f"Results saved to results/{result_dirname}/{filename}") + + def _plot_optimization_curve( + objective_log: list[dict[str, float]], result_dirname: str, timestamp: str + ) -> None: + """Plot the optimization history of the objective function values and saves the figure.""" + plt.figure(figsize=(10, 6)) + plt.plot( + [log["estimated_cost"] for log in objective_log], + ) + plt.title(f"Optimization iterations (offset={results['quantum']['offset']:.2f})") + plt.xlabel("Iteration") + plt.ylabel("Estimated Cost") + plt.grid() + plot_filename = f"results/{result_dirname}/optimization_history_{timestamp}.png" + plt.savefig(plot_filename, dpi=150) + logger.info(f"Optimization history plot saved to {plot_filename}") + plt.close() + + def _plot_top_bitstrings( + probabilities: dict[str, float], + result_dirname: str, + timestamp: str, + plotname: str = "top_bitstrings", + k: int = 30, + ) -> None: + """Plot the top-k most probable bitstrings and aggregate the rest as 'Other'.""" + top_k_bitstrings = { + bitstring + for bitstring, _ in sorted( + probabilities.items(), + key=lambda item: item[1], + reverse=True, + )[:k] + } + + labels: list[str] = [] + values: list[float] = [] + other_probability = 0.0 + + for bitstring, probability in probabilities.items(): + if bitstring in top_k_bitstrings: + labels.append(bitstring) + values.append(probability) + else: + other_probability += probability + + if other_probability > 0: + labels.append("Other") + values.append(other_probability) + + plt.figure(figsize=(12, 6)) + plt.bar(labels, values) + plt.title(f"Top {k} bitstring probabilities") + plt.xlabel("Bitstring") + plt.ylabel("Probability") + plt.xticks(rotation=75, ha="right") + plt.tight_layout() + + plot_filename = Path("results") / result_dirname / f"{plotname}_{timestamp}.png" + plt.savefig(plot_filename, dpi=150) + plt.close() + + def _plot_slack_distribution_for_top_info_bits( + probabilities: dict[str, float], + aggregated_probabilities: dict[str, float], + result_dirname: str, + timestamp: str, + top_info_count: int = 5, + ) -> None: + """ + For the top info-bit assignments, plot the distribution over slack bits. + + Assumes bitstrings are ordered as: + info bits followed by slack bits. + """ + if not aggregated_probabilities: + return + + n_info_bits = len(next(iter(aggregated_probabilities))) + + grouped: dict[str, dict[str, float]] = defaultdict(lambda: defaultdict(float)) + + for bitstring, probability in probabilities.items(): + info_bits = bitstring[:n_info_bits] + slack_bits = bitstring[n_info_bits:] + grouped[info_bits][slack_bits] += probability + + top_info_bits = [ + info_bits + for info_bits, _ in sorted( + aggregated_probabilities.items(), + key=lambda item: item[1], + reverse=True, + )[:top_info_count] + ] + + for info_bits in top_info_bits: + slack_distribution = grouped.get(info_bits) + + if not slack_distribution: + continue + + slack_items = sorted( + slack_distribution.items(), + key=lambda item: item[1], + reverse=True, + ) + + labels = [slack_bits for slack_bits, _ in slack_items] + values = [prob for _, prob in slack_items] + + plt.figure(figsize=(10, 5)) + plt.bar(labels, values) + + plt.title( + f"Slack distribution for info bits {info_bits} " + f"(total probability = {aggregated_probabilities[info_bits]:.4f})" + ) + plt.xlabel("Slack bits") + plt.ylabel("Probability") + plt.xticks(rotation=60, ha="right") + plt.tight_layout() + + plot_filename = ( + Path("results") + / result_dirname + / f"slack_distribution_info_{info_bits}_{timestamp}.png" + ) + + plt.savefig(plot_filename, dpi=150) + plt.close() + + _plot_optimization_curve( + objective_log=results["quantum"]["optimization_log"].values(), + result_dirname=result_dirname, + timestamp=timestamp, + ) + _plot_top_bitstrings( + probabilities=results["quantum"]["probabilities"], + result_dirname=result_dirname, + timestamp=timestamp, + ) + _plot_top_bitstrings( + probabilities=results["quantum"]["aggregated_probabilities"], + result_dirname=result_dirname, + timestamp=timestamp, + plotname="top_aggregated_bitstrings", + ) + + +def _aggregate_probabilities( + probabilities: dict[str, float], bits_labels: list[str] +) -> dict[str, float]: + """Aggregate probabilities of bitstrings that correspond to the same item selection. + + This function takes a dictionary of bitstring probabilities and a list of bit labels, and aggregates the probabilities of bitstrings that correspond to the same selection of items (ignoring slack variables). The resulting dictionary maps item selection bitstrings to their aggregated probabilities. + + Args: + probabilities: A dictionary mapping bitstrings (e.g., '0011') to their probabilities. + bits_labels: A list of bit labels corresponding to the qubits (e.g., ['x0', 'x1', 's0', 's1']). + + Returns: + A dictionary mapping item selection bitstrings (e.g., '00', '01', '10', '11') to their aggregated probabilities. + """ + item_bits_indices = [ + i for i, label in enumerate(bits_labels) if label.startswith("x") + ] + aggregated = {} + for bitstring, prob in probabilities.items(): + item_selection = "".join(bitstring[i] for i in item_bits_indices) + aggregated[item_selection] = aggregated.get(item_selection, 0) + prob + return aggregated + + +def process_result(optimization_result: OptimizeResult, results: dict): + """Process the optimization result and update the results dictionary. + + Args: + optimization_result: The optimization result from scipy.optimize.minimize. + results: The dictionary to update with quantum optimization results. + """ + results["quantum"]["final_cost"] = optimization_result.fun + results["quantum"]["optimal_parameters"] = optimization_result.x.tolist() + results["quantum"]["probabilities"] = _qiskit_bits_inversion( + results["quantum"]["probabilities"] + ) # type: ignore + results["quantum"]["aggregated_probabilities"] = _aggregate_probabilities( + results["quantum"]["probabilities"], results["quantum"]["bits_labels"] + ) + results["quantum"]["aggregated_solution"] = max( + results["quantum"]["aggregated_probabilities"], + key=results["quantum"]["aggregated_probabilities"].get, + ) + results["quantum"]["unique_solution"] = max( + results["quantum"]["probabilities"], key=results["quantum"]["probabilities"].get + ) + + _save_results(results) + + +def knapsack_solver(args): + """Script entrypoint that demonstrates mapping and prints the Hamiltonian. + + This function creates a sample :class:`KnapsackInstance`, converts it to an + Ising Hamiltonian using :func:`map_hamiltonian`, and prints the resulting + operator. It is intended for quick local demonstrations and testing. + """ + global SEED + SEED = args.seed + + knapsack_instance = KnapsackInstance( + values=[8, 10], + weights=[4, 7], + capacity=8, + ) + + results = { + "metadata": { + "seed": SEED, + "optimizer": args.optimizer, + "max_iterations": args.iterations, + "qaoa_layers": args.qaoa_layers, + "use_reference_state": args.use_reference_state, + "penalty_scaling": args.penalty_scaling + if args.penalty_scaling is not None + else sum(knapsack_instance.values), + "knapsack_instance": asdict(knapsack_instance), + }, + "classical": { + "solution": None, + "value": None, + }, + "quantum": { + "offset": None, + "final_cost": None, + "aggregated_solution": None, + "unique_solution": None, + "optimal_parameters": None, + "bits_labels": None, + "aggregated_probabilities": None, + "probabilities": None, + "hamiltonian": None, + "optimization_log": {}, + }, + } + + classical_solution, classical_value = bruteforce(knapsack_instance) + logger.info(f"Classical solution: {classical_solution}, Value: {classical_value}") + results["classical"]["solution"] = classical_solution + results["classical"]["value"] = classical_value + + hamiltonian, offset = map_hamiltonian( + knapsack_instance, + penalty_factor=args.penalty_scaling, + ) + results["quantum"]["hamiltonian"] = hamiltonian.to_list() + results["quantum"]["offset"] = offset + logger.info(f"Mapped Hamiltonian:\n{hamiltonian}\nOffset: {offset}") + p_state = None + + if args.use_reference_state: + p_state = pstate(knapsack_instance, hamiltonian) + ansatz = QAOAAnsatz(hamiltonian, reps=args.qaoa_layers, initial_state=p_state) + + logger.info(f"Number of qubits: {ansatz.num_qubits}") + logger.info(f"Initial parameters: {ansatz.parameters}") + + item_bits = [f"x{i}" for i in range(len(knapsack_instance.values))] + slack_bits = [ + f"s{i}" for i in range(ansatz.num_qubits - len(knapsack_instance.values)) + ] + all_bits = item_bits + slack_bits + results["quantum"]["bits_labels"] = all_bits + + estimator = StatevectorEstimator(seed=SEED) + + initial_params = _x0_parameters(ansatz.num_parameters) + + def cost_function_estimator( + params, + ansatz: QuantumCircuit, + hamiltonian: SparsePauliOp, + estimator: BaseEstimatorV2, + ) -> float: + """Estimate the cost function value for given parameters. + + This helper function takes a parameter vector, constructs the corresponding + quantum state using the provided ansatz, and estimates the expectation + value of the cost Hamiltonian using the given estimator. + + Args: + params: Parameter vector for the ansatz. + ansatz: The QAOA ansatz circuit. + hamiltonian: The cost Hamiltonian as a SparsePauliOp. + estimator: A Qiskit Estimator instance for evaluating expectation values. + + Returns: + The estimated cost function value as a float. + """ + pub = (ansatz, [hamiltonian], [params]) + + result = estimator.run(pubs=[pub]).result() + energy = result[0].data.evs + + num_evaluations = len(results["quantum"]["optimization_log"]) + 1 + results["quantum"]["optimization_log"][num_evaluations] = { + "parameters": params.tolist(), + "estimated_cost": energy, + } + logger.info( + f"Evaluation {num_evaluations}: Parameters: {params}, Estimated Cost: {energy}" + ) + + return energy + + optimization_result = minimize( + cost_function_estimator, + x0=initial_params, + args=(ansatz, hamiltonian, estimator), + method=args.optimizer, + options={"maxiter": args.iterations, "disp": True}, + ) + decode_optimization_result( + optimization_result, ansatz, hamiltonian, estimator, results + ) + + process_result(optimization_result, results) + + +if __name__ == "__main__": + parser = ArgumentParser( + description="Quantum resolution of the Knapsack problem using QAOA." + ) + parser.add_argument( + "--use-reference-state", + action="store_true", + help="Use a reference prepared state", + ) + parser.add_argument( + "--optimizer", + choices=["COBYLA", "SLSQP", "ADAM"], + default="COBYLA", + help="Optimizer to use for parameter optimization", + ) + parser.add_argument( + "--iterations", + type=int, + default=100, + help="Number of iterations for the optimizer", + ) + parser.add_argument( + "--qaoa-layers", + type=int, + default=1, + help="Number of QAOA layers (reps)", + ) + parser.add_argument( + "--seed", + type=int, + default=67, + help="Random seed for reproducibility", + ) + parser.add_argument( + "--penalty-scaling", + type=float, + help="Scaling factor for the penalty", + ) + + args = parser.parse_args() + knapsack_solver(args) diff --git a/solutions/knapsack/tests/test_knapsack.py b/solutions/knapsack/tests/test_knapsack.py new file mode 100644 index 0000000..b98ff50 --- /dev/null +++ b/solutions/knapsack/tests/test_knapsack.py @@ -0,0 +1,212 @@ +"""Tests for the knapsack helper module.""" + +from numbers import Real +from types import SimpleNamespace + +import pytest +from qiskit import QuantumCircuit +from qiskit.quantum_info import SparsePauliOp +from qiskit.primitives import BaseEstimatorV2 + +import src.knapsack as knapsack_module +from src.knapsack import ( + KnapsackInstance, + _x0_parameters, + bruteforce, + cost_function, + cost_function_estimator, + map_hamiltonian, + objective_function, + penalty_function, + pstate, +) + + +KNAPSACK_CASES = [ + { + "name": "two_item_balanced", + "instance": KnapsackInstance(values=[4, 3], weights=[2, 1], capacity=2), + "evaluations": [ + {"selection": [0, 0], "cost": 0.0, "penalty": 0.0, "objective": 0.0}, + {"selection": [1, 0], "cost": -4.0, "penalty": 0.0, "objective": -4.0}, + {"selection": [0, 1], "cost": -3.0, "penalty": 0.0, "objective": -3.0}, + {"selection": [1, 1], "cost": -7.0, "penalty": 7.0, "objective": 0.0}, + ], + "bruteforce": ([1, 0], -4.0), + }, + { + "name": "three_item_tight_capacity", + "instance": KnapsackInstance(values=[6, 2, 5], weights=[3, 2, 4], capacity=5), + "evaluations": [ + {"selection": [1, 0, 0], "cost": -6.0, "penalty": 0.0, "objective": -6.0}, + {"selection": [1, 1, 0], "cost": -8.0, "penalty": 0.0, "objective": -8.0}, + {"selection": [0, 1, 1], "cost": -7.0, "penalty": 13.0, "objective": 6.0}, + {"selection": [1, 0, 1], "cost": -11.0, "penalty": 52.0, "objective": 41.0}, + ], + "bruteforce": ([1, 1, 0], -8.0), + }, + { + "name": "single_capacity_item_choice", + "instance": KnapsackInstance(values=[1, 10, 2], weights=[2, 1, 1], capacity=1), + "evaluations": [ + {"selection": [0, 0, 0], "cost": 0.0, "penalty": 0.0, "objective": 0.0}, + {"selection": [0, 1, 0], "cost": -10.0, "penalty": 0.0, "objective": -10.0}, + {"selection": [0, 0, 1], "cost": -2.0, "penalty": 0.0, "objective": -2.0}, + {"selection": [1, 0, 0], "cost": -1.0, "penalty": 13.0, "objective": 12.0}, + ], + "bruteforce": ([0, 1, 0], -10.0), + }, +] + + +def _case_ids(cases): + return [case["name"] for case in cases] + + +def _selection_cases(field_name): + _ = field_name + return [ + (case, evaluation) + for case in KNAPSACK_CASES + for evaluation in case["evaluations"] + ] + + +class TestCostFunction: + """Tests for the negative-value cost helper.""" + + @pytest.mark.parametrize( + ("case", "evaluation"), + _selection_cases("cost"), + ids=[ + f"{case['name']}-{evaluation['selection']}" + for case, evaluation in _selection_cases("cost") + ], + ) + def test_returns_negative_selected_value(self, case, evaluation): + """The cost should be the negated sum of selected item values.""" + assert cost_function( + evaluation["selection"], case["instance"] + ) == pytest.approx(evaluation["cost"]) + + +class TestPenaltyFunction: + """Tests for the quadratic capacity penalty helper.""" + + @pytest.mark.parametrize( + ("case", "evaluation"), + _selection_cases("penalty"), + ids=[ + f"{case['name']}-{evaluation['selection']}" + for case, evaluation in _selection_cases("penalty") + ], + ) + def test_penalizes_capacity_overflow_quadratically(self, case, evaluation): + """The penalty should be zero when feasible and quadratic when infeasible.""" + assert penalty_function( + evaluation["selection"], case["instance"] + ) == pytest.approx(evaluation["penalty"]) + + +class TestObjectiveFunction: + """Tests for the combined objective helper.""" + + @pytest.mark.parametrize( + ("case", "evaluation"), + _selection_cases("objective"), + ids=[ + f"{case['name']}-{evaluation['selection']}" + for case, evaluation in _selection_cases("objective") + ], + ) + def test_combines_cost_and_penalty(self, case, evaluation): + """The objective should equal cost plus penalty for each selection.""" + selection = evaluation["selection"] + instance = case["instance"] + + expected = cost_function(selection, instance) + penalty_function( + selection, instance + ) + + assert objective_function(selection, instance) == pytest.approx(expected) + assert expected == pytest.approx(evaluation["objective"]) + + +class TestBruteforce: + """Tests for exhaustive knapsack search.""" + + @pytest.mark.parametrize("case", KNAPSACK_CASES, ids=_case_ids(KNAPSACK_CASES)) + def test_finds_lowest_objective_selection(self, case): + """The brute-force helper should return the best binary selection and its score.""" + best_selection, best_value = bruteforce(case["instance"]) + + expected_selection, expected_value = case["bruteforce"] + assert best_selection == expected_selection + assert best_value == pytest.approx(expected_value) + + +class TestQiskitHelpers: + """Tests for the Qiskit-facing helper functions.""" + + @pytest.mark.parametrize("case", KNAPSACK_CASES, ids=_case_ids(KNAPSACK_CASES)) + def test_map_hamiltonian_returns_ising_operator_and_offset(self, case): + """The mapper should produce an Ising operator with the expected size.""" + hamiltonian, offset = map_hamiltonian(case["instance"], penalty_factor=10.0) + + assert isinstance(hamiltonian, SparsePauliOp) + assert hamiltonian.num_qubits >= len(case["instance"].values) # type: ignore + assert isinstance(offset, Real) + + @pytest.mark.parametrize("case", KNAPSACK_CASES, ids=_case_ids(KNAPSACK_CASES)) + def test_reference_state_matches_number_of_items(self, case): + """The reference state should allocate one qubit per item and remain empty.""" + circuit = pstate(case["instance"]) + + assert isinstance(circuit, QuantumCircuit) + assert circuit.num_qubits == len(case["instance"].values) + assert circuit.size() == 0 + + def test_cost_function_estimator_records_energy_and_pub(self, monkeypatch): + """The estimator helper should forward the pub structure and track energies.""" + + class _FakeJob: + def __init__(self, energy): + self._energy = energy + + def result(self): + return [SimpleNamespace(data=SimpleNamespace(evs=self._energy))] + + class _FakeEstimator(BaseEstimatorV2): + def __init__(self, energy): + self.energy = energy + self.pubs = None + + def run(self, pubs): + self.pubs = pubs + return _FakeJob(self.energy) + + monkeypatch.setattr(knapsack_module, "OBJECTIVE_FUNC_VALUES", []) + + ansatz = QuantumCircuit(1) + hamiltonian = SparsePauliOp.from_list([("Z", 1.0)]) + params = [0.25] + estimator = _FakeEstimator(1.5) + + energy = cost_function_estimator(params, ansatz, hamiltonian, estimator) + + assert energy == pytest.approx(1.5) + assert knapsack_module.OBJECTIVE_FUNC_VALUES == [1.5] + assert estimator.pubs == [(ansatz, [hamiltonian], [params])] + + def test_initial_parameter_seed_is_deterministic(self, monkeypatch): + """The initial-parameter helper should be repeatable for a fixed seed.""" + monkeypatch.setattr(knapsack_module, "SEED", 123) + + params_one = _x0_parameters(5) + params_two = _x0_parameters(5) + + assert params_one.shape == (5,) + assert params_two.shape == (5,) + assert (params_one >= 0).all() + assert (params_one < 1).all() + assert params_one == pytest.approx(params_two) diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 88acd1a..0000000 --- a/src/main.py +++ /dev/null @@ -1,25 +0,0 @@ -"""This module is the entry point for your python application.""" - - -def sum(a: int, b: int) -> int: - """ - Returns the sum of two integers. - - Args: - a (int): The first integer. - b (int): The second integer. - - Returns: - int: The sum of the two integers. - - """ - return a + b - - -def main(): - """The main function of the application.""" - sum(2, 3) - - -if __name__ == "__main__": - main() diff --git a/tests/test_main.py b/tests/test_main.py deleted file mode 100644 index d65d123..0000000 --- a/tests/test_main.py +++ /dev/null @@ -1,22 +0,0 @@ -"""This module contains tests for the main module.""" - -import pytest - -from main import sum - - -class TestSum: - """Tests for the sum function.""" - - @pytest.mark.parametrize( - "a, b, expected", - [ - (1, 2, 3), - (0, 0, 0), - (-1, -1, -2), - (-1, 1, 0), - ], - ) - def test_sum(self, a, b, expected): - """Test the sum function.""" - assert sum(a, b) == expected diff --git a/uv.lock b/uv.lock index 91284c2..c3a6951 100644 --- a/uv.lock +++ b/uv.lock @@ -468,6 +468,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, ] +[[package]] +name = "docplex" +version = "2.32.264" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/02/7aec9042e1727b9f8fd745fa799bc8b4fd76e4db57f05a81eb676ead401b/docplex-2.32.264.tar.gz", hash = "sha256:4e2b29b3559e702bcfe12c6747428c752b0c39a508a81addf05edaab36b76bd8", size = 663972, upload-time = "2026-04-01T08:04:10.512Z" } + [[package]] name = "executing" version = "2.2.1" @@ -1649,6 +1658,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pylatexenc" +version = "2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5d/ab/34ec41718af73c00119d0351b7a2531d2ebddb51833a36448fc7b862be60/pylatexenc-2.10.tar.gz", hash = "sha256:3dd8fd84eb46dc30bee1e23eaab8d8fb5a7f507347b23e5f38ad9675c84f40d3", size = 162597, upload-time = "2021-04-06T07:56:07.854Z" } + [[package]] name = "pyparsing" version = "3.3.2" @@ -1709,6 +1724,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/11/6a/c9065d0f74178275963b44e57fd93530ed36089682f918553c3bceb9a8ba/qiskit-2.4.1-cp310-abi3-win_amd64.whl", hash = "sha256:91c8c8b0582a8d0dc46c0d3fd37896b2facfd99c54671ac557da9f643114e5e3", size = 9108769, upload-time = "2026-04-24T20:34:11.888Z" }, ] +[[package]] +name = "qiskit-optimization" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docplex" }, + { name = "networkx" }, + { name = "numpy" }, + { name = "qiskit" }, + { name = "scipy" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/e0/46e635e6d447f08ef9d9b2e89d129454ec2db3a2327237cea2bd3e8c4878/qiskit_optimization-0.7.0.tar.gz", hash = "sha256:059e9329c647534965e68d8c52de1b02828635c525c210aa0730e486f9c5a686", size = 219471, upload-time = "2025-08-20T14:04:16.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/a1/78908508b73f0e9f638abeace753c07439f92d3eb2fda36706f8e08e73d6/qiskit_optimization-0.7.0-py3-none-any.whl", hash = "sha256:2082390c2b89cdf2083f6f790486797a0e188016e0dd31c8a32b27f6913864dc", size = 237135, upload-time = "2025-08-20T14:04:15.478Z" }, +] + [[package]] name = "qnn-mnist" version = "0.1.0" @@ -2140,14 +2172,18 @@ version = "0.1.0" source = { virtual = "challenges/VQA-Knapsack" } dependencies = [ { name = "matplotlib" }, + { name = "pylatexenc" }, { name = "qiskit" }, + { name = "qiskit-optimization" }, { name = "scipy" }, ] [package.metadata] requires-dist = [ { name = "matplotlib", specifier = ">=3.10.9" }, + { name = "pylatexenc", specifier = ">=2.10" }, { name = "qiskit", specifier = ">=2.4.1" }, + { name = "qiskit-optimization", specifier = ">=0.7.0" }, { name = "scipy", specifier = ">=1.17.1" }, ]