Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 37 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ A flexible, modular Python library for the [Quantum Approximate Optimization Alg
- [Installation](#installation)
- [Requirements](#requirements)
- [Quick Example](#quick-example)
- [Objective direction and sign convention](#objective-direction-and-sign-convention)
- [Background](#background)
- [Custom Ansatz](#custom-ansatz)
- [Running Optimization](#running-optimization-at-depth-p)
Expand Down Expand Up @@ -81,7 +82,8 @@ qaoa.sample_cost_landscape()
qaoa.optimize(depth=3)

# Extract results
print("Optimal expectation value:", qaoa.get_Exp(depth=3))
print("Optimal energy:", qaoa.get_energy(depth=3))
print("Optimal objective:", qaoa.get_objective(depth=3))
print("Optimal parameters (gamma):", qaoa.get_gamma(depth=3))
print("Optimal parameters (beta):", qaoa.get_beta(depth=3))
```
Expand All @@ -90,14 +92,41 @@ See [examples/](examples/) for more complete worked examples.

---

## Objective direction and sign convention

Each problem declares an explicit objective direction via `problem.objective_sense` (`"minimize"` or `"maximize"`).

- `problem.objective_value(x)` is the natural mathematical objective.
- `problem.energy(x)` is the canonical quantity minimized by QAOA.

Conversion is centralized:

- `MINIMIZE`: `energy(x) = objective_value(x)`
- `MAXIMIZE`: `energy(x) = -objective_value(x)`

Phase separators follow:

$$U_P(\gamma)|x\rangle = e^{-i\gamma\,\mathrm{energy}(x)}|x\rangle.$$

Examples:

- **MaxCut**: natural objective is positive cut value; energy is its negative.
- **QUBO**: natural objective is the un-negated polynomial
$x^\top Q x + c^\top x + b$.

Legacy `cost(x)` is kept as a deprecated compatibility score with old behavior:
`cost(x) = -energy(x)`.

---

## Background
Given a **cost function**
$$c: \lbrace 0, 1\rbrace^n \rightarrow \mathbb{R}$$
Given an **energy function**
$$E: \lbrace 0, 1\rbrace^n \rightarrow \mathbb{R}$$
one defines a **problem Hamiltonian** $H_P$ through the action on computational basis states via

$$ H_P |x\rangle = c(x) |x\rangle,$$
$$ H_P |x\rangle = E(x) |x\rangle,$$

which means that ground states minimize the cost function $c$.
which means that ground states minimize the canonical energy.
Given a parametrized ansatz $| \gamma, \beta \rangle$, a classical optimizer is used to minimize the energy

$$ \langle \gamma, \beta | H_P | \gamma, \beta \rangle.$$
Expand All @@ -119,7 +148,7 @@ $U_M(\beta_l)=e^{-i\beta_l X^{\otimes n}}$, $U_P(\gamma_l)=e^{-i\gamma_l H_P}$,

## Custom Ansatz

To create a custom QAOA ansatz, specify a [problem](qaoa/problems/base_problem.py), a [mixer](qaoa/mixers/base_mixer.py), and an [initial state](qaoa/initialstates/base_initialstate.py). These base classes each have an abstract method `def create_circuit:` that must be implemented. The problem base class additionally requires `def cost:`.
To create a custom QAOA ansatz, specify a [problem](qaoa/problems/base_problem.py), a [mixer](qaoa/mixers/base_mixer.py), and an [initial state](qaoa/initialstates/base_initialstate.py). These base classes each have an abstract method `def create_circuit:` that must be implemented. A custom problem should define `objective_sense` and `objective_value()`. The phase separator must encode `energy(x)` as above.

This library already contains several standard implementations.

Expand Down Expand Up @@ -177,12 +206,12 @@ qaoa.sample_cost_landscape()

Sampling high-dimensional target functions quickly becomes intractable for depth $p>1$. The library therefore **iteratively increases the depth**. At each depth a **local optimization** algorithm (e.g. COBYLA) finds a local minimum, using the following **initial guess**:

- At depth $p=1$: parameters $(\gamma, \beta)$ are taken from the minimum of the sampled cost landscape.
- At depth $p=1$: parameters $(\gamma, \beta)$ are taken from the minimum of the sampled energy landscape.
- At depth $p>1$: two strategies are available, controlled by the `interpolate` parameter:

* **Interpolation** (`interpolate=True`, default): uses the [INTERP heuristic](https://arxiv.org/pdf/1812.01041.pdf) to produce a smooth initial guess by interpolating the optimal angles from depth $p-1$. Works well for vanilla QAOA.

* **Layer-by-layer grid scan** (`interpolate=False`): the best angles from depth $p-1$ are *locked* and a 2-D grid search is performed over the new layer's parameters. Because the grid includes $(γ=0, β=0)$ — which adds an identity layer reproducing the depth-$(p-1)$ result — the initial cost at depth $p$ is guaranteed to be ≤ cost at depth $p-1$, ensuring a monotonically increasing approximation ratio. Recommended for multi-angle and orbit ansätze.
* **Layer-by-layer grid scan** (`interpolate=False`): the best angles from depth $p-1$ are *locked* and a 2-D grid search is performed over the new layer's parameters. Because the grid includes $(γ=0, β=0)$ — which adds an identity layer reproducing the depth-$(p-1)$ result — the initial energy at depth $p$ is guaranteed to be ≤ energy at depth $p-1$, ensuring a monotonically increasing approximation ratio. Recommended for multi-angle and orbit ansätze.

```python
# Interpolation (default)
Expand Down
2 changes: 1 addition & 1 deletion qaoa/problems/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from .base_problem import Problem
from .base_problem import Problem, ObjectiveSense
from .qubo_problem import QUBO
from .graph_problem import GraphProblem
from .exactcover_problem import ExactCover
Expand Down
120 changes: 94 additions & 26 deletions qaoa/problems/base_problem.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
from abc import ABC, abstractmethod
from enum import Enum
import itertools
import warnings

from qaoa.utils import validation


class ObjectiveSense(str, Enum):
MINIMIZE = "minimize"
MAXIMIZE = "maximize"


class BaseProblem(ABC):
"""
Base class for defining optimization problems.
Expand Down Expand Up @@ -65,21 +73,59 @@ def create_circuit(self):
```
"""

@abstractmethod
def cost(self, string):
"""
Abstract method to calculate the cost of a solution.

Subclasses must implement this method to define how the cost of a
solution is calculated for the specific optimization problem.

Args:
string (str): A solution string or configuration to evaluate.
def __init__(self, objective_sense: ObjectiveSense = ObjectiveSense.MINIMIZE) -> None:
super().__init__()
if not isinstance(objective_sense, ObjectiveSense):
try:
objective_sense = ObjectiveSense(objective_sense)
except Exception as exc:
raise ValueError(
"objective_sense must be one of "
f"{[s.value for s in ObjectiveSense]}"
) from exc
self.objective_sense = objective_sense

def _has_objective_value_override(self) -> bool:
return self.__class__.objective_value is not Problem.objective_value

def _has_legacy_cost_override(self) -> bool:
return self.__class__.cost is not Problem.cost

def objective_value(self, string):
if self._has_legacy_cost_override():
warnings.warn(
"Implement objective_value() and objective_sense for custom problems. "
"Legacy cost() fallback is deprecated.",
DeprecationWarning,
stacklevel=2,
)
return self.cost(string)
raise NotImplementedError("Subclasses must implement objective_value().")

def energy(self, string):
if not self._has_objective_value_override() and self._has_legacy_cost_override():
return -self.cost(string)
value = self.objective_value(string)
if self.objective_sense is ObjectiveSense.MINIMIZE:
return value
return -value

def objective_from_energy(self, energy):
if self.objective_sense is ObjectiveSense.MINIMIZE:
return energy
return -energy

def score(self, string):
return -self.energy(string)

Returns:
float: The cost of the given solution.
"""
pass
def cost(self, string):
warnings.warn(
"cost() is deprecated and kept for backward compatibility. "
"Use objective_value() or energy(). cost(x) == -energy(x).",
DeprecationWarning,
stacklevel=2,
)
return self.score(string)

@abstractmethod
def create_circuit(self):
Expand Down Expand Up @@ -120,25 +166,47 @@ def get_num_parameters(self):

def computeMinMaxCosts(self):
"""
Brute force method to compute min and max cost of feasible solution
Deprecated wrapper for objective_bounds(). Kept for backward compatibility.
"""
import itertools

max_cost = float("-inf")
min_cost = float("inf")
for s in ["".join(i) for i in itertools.product("01", repeat=self.N_qubits)]:
warnings.warn(
"computeMinMaxCosts() is deprecated. Use objective_bounds().",
DeprecationWarning,
stacklevel=2,
)
return self.objective_bounds()

def objective_bounds(self):
min_objective = float("inf")
max_objective = float("-inf")
for s in map("".join, itertools.product("01", repeat=self.N_qubits)):
if self.isFeasible(s):
value = self.objective_value(s)
min_objective = min(min_objective, value)
max_objective = max(max_objective, value)
return min_objective, max_objective

def optimal_objective(self):
min_objective, max_objective = self.objective_bounds()
if self.objective_sense is ObjectiveSense.MINIMIZE:
return min_objective
return max_objective

def energy_bounds(self):
min_energy = float("inf")
max_energy = float("-inf")
for s in map("".join, itertools.product("01", repeat=self.N_qubits)):
if self.isFeasible(s):
cost = -self.cost(s)
max_cost = max(max_cost, cost)
min_cost = min(min_cost, cost)
return min_cost, max_cost
value = self.energy(s)
min_energy = min(min_energy, value)
max_energy = max(max_energy, value)
return min_energy, max_energy

def validate_circuit(self, t=1, flip=True, atol=1e-8, rtol=1e-8):
"""
Exact check that the problem's circuit represents the problem's cost function.
This tests checks that the unitary operator represented by the quantum circuit is
equal to the expected matrix with diagonal elements
exp(-j*t*cost(e)),
equal to the expected matrix with diagonal elements
exp(-j*t*energy(e)),
where e is the corresponding binary state, up to a global phase.
Comment on lines 206 to 210

Suitable for <= 10 qubits as this check uses the full unitary matrix of size 2^n x 2^n).
Expand Down
14 changes: 7 additions & 7 deletions qaoa/problems/exactcover_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,9 @@ def __init__(

self.N_qubits = numColumns

def cost(self, string):
def objective_value(self, string):
"""
Calculates the cost so that states where an element is not covered, or covered more than once, will be penalized, whereas
Calculates the natural objective so that states where an element is not covered, or covered more than once, will be penalized, whereas
sets that contain elements that are covered exactly once are favored.

Args:
Expand All @@ -97,7 +97,7 @@ def cost(self, string):
x = np.array(list(map(int, string)))
c_e = self.__exactCover(x)

return -(self.weights @ x + self.penalty_factor * c_e)
return self.weights @ x + self.penalty_factor * c_e


def isFeasible(self, string):
Expand Down Expand Up @@ -161,14 +161,14 @@ def bitstrings_all_generator(n, k):
bitstrings_generator = bitstrings_all_generator
if self.hamming_weight is not None:
bitstrings_generator = bitstrings_hamming_weight_generator
opt_val = -np.inf
opt_val = np.inf
opt_sol = None
num_feasible = 0

for bs in bitstrings_generator(self.N_qubits, self.hamming_weight):
cost = self.cost(bs)
if cost > opt_val:
opt_val = cost
energy = self.energy(bs)
if energy < opt_val:
opt_val = energy
opt_sol = bs
num_feasible += self.isFeasible(bs)

Expand Down
9 changes: 5 additions & 4 deletions qaoa/problems/graph_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from qiskit.circuit import Parameter
from abc import abstractmethod

from .base_problem import Problem
from .base_problem import ObjectiveSense, Problem
from qaoa.utils import *


Expand Down Expand Up @@ -43,6 +43,7 @@ def __init__(
entangling gates eliminated (proportional to the degree of the fixed node).
"""
super().__init__()
self.objective_sense = ObjectiveSense.MAXIMIZE

# fixes the highest-degree node (node n-1 after relabeling) to "color1"
self.fix_one_node = fix_one_node
Expand Down Expand Up @@ -149,9 +150,9 @@ def slice_string(self, string: str) -> list:
labels.append(self.colors["color1"][0])
return labels

def cost(self, string: str) -> float | int:
def objective_value(self, string: str) -> float | int:
"""
Compute the cost for a given solution.
Compute the natural objective value for a given solution.

Args:
string (str): Binary string.
Expand All @@ -160,7 +161,7 @@ def cost(self, string: str) -> float | int:
ValueError: If the length of the string does not match the number of qubits.

Returns:
float | int: The cost of the given solution.
float | int: The weighted cut value of the given solution.
"""
if len(string) != self.N_qubits:
raise ValueError(
Expand Down
8 changes: 4 additions & 4 deletions qaoa/problems/maxkcut_one_hot_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from qiskit.circuit import Parameter
import networkx as nx

from .base_problem import Problem
from .base_problem import ObjectiveSense, Problem


class MaxKCutOneHot(Problem):
Expand Down Expand Up @@ -32,7 +32,7 @@ def __init__(self, G: nx.Graph, k_cuts: int) -> None:
Raises:
ValueError: If k_cuts is less than 2 or greater than 8.
"""
super().__init__()
super().__init__(objective_sense=ObjectiveSense.MAXIMIZE)
if (k_cuts < 2) or (k_cuts > 8):
raise ValueError(
"k_cuts must be 2 or more, and is not implemented for k_cuts > 8"
Expand Down Expand Up @@ -68,9 +68,9 @@ def binstringToLabels(self, string: str) -> str:
labels += str(idx)
return labels

def cost(self, string: str) -> float | int:
def objective_value(self, string: str) -> float | int:
"""
Computes the Max k-Cut cost for a given binary string representing a coloring.
Computes the Max k-Cut objective for a given binary string representing a coloring.

Args:
string (str): The binary string representing the one-hot encoding of node colors.
Expand Down
17 changes: 8 additions & 9 deletions qaoa/problems/portfolio_problem.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,22 +53,21 @@ def __init__(self, risk, budget, cov_matrix, exp_return, penalty=0) -> None:

super().__init__(Q=Q, c=c, b=b)

def cost(self, string):
def objective_value(self, string):
"""
Computes the portfolio cost of a given bitstring. This overrides the QUBO base class
cost to use the problem-specific formula directly.
Computes the portfolio natural objective of a given bitstring.

Args:
string (str): Bitstring representing the selected assets (portfolio).

Returns:
cost (float): The negative of the portfolio objective value (including penalty).
float: The portfolio objective value (including penalty).
"""
x = np.array(list(map(int, string)))
cost = self.risk * (x.T @ self.cov_matrix @ x) - self.exp_return.T @ x
cost += self.penalty * (x.sum() - self.budget) ** 2

return -cost
return cost

def isFeasible(self, string):
"""
Expand Down Expand Up @@ -97,12 +96,12 @@ def bitstrings_hamming_weight_generator(n, k):
s[i] = '1'
yield ''.join(s)

opt_val = -np.inf
opt_val = np.inf
opt_sol = None
for bs in bitstrings_hamming_weight_generator(self.N_qubits, self.budget):
cost = self.cost(bs)
if cost > opt_val:
opt_val = cost
energy = self.energy(bs)
if energy < opt_val:
opt_val = energy
opt_sol = bs

return opt_sol
Expand Down
Loading