Get started with Fire Opal on IonQ Quantum Cloud

Solve dense Max-Cut instances using Fire Opal's built-in QAOA solver on IonQ quantum hardware

Maximum Cut (Max-Cut) is a classic problem in combinatorial optimization and graph theory. The aim of Max-Cut is to separate the nodes of a graph into two groups by intersecting as many edges between nodes as possible with a single partition, or "cut". The Max-Cut problem can be applied to many real-world applications across various domains, such as communications network design, image segmentation in computer vision, and financial risk management. For non-planar graphs where edges cross, there is no known polynomial-time solution, making this problem an excellent candidate for quantum optimization algorithms.

IonQ's trapped-ion architecture provides native all-to-all qubit connectivity, meaning any qubit can interact directly with any other qubit. Fire Opal's built-in Quantum Approximate Optimization Algorithm (QAOA) solver can take advantage of this architecture to tackle denser, more complex Max-Cut instances with greater accuracy.

This guide illustrates how to solve a non-trivial, 10-node Max-Cut problem on IonQ hardware. By using Fire Opal's QAOA solver via the Fire Opal Python client (fireopal) on a highly-connected graph, the guide demonstrates how Fire Opal's QAOA solver manages the high gate density and noise inherent in dense optimization problems. The same capability is also available natively through IonQ's API and SDK.

Requirements

1. Imports and initialization

The following section sets up the necessary imports and helper functions. You can install the required packages by uncommenting and running the following cell if you are running this example in a notebook using the IPython kernel. Please note that after installing new packages, you will need to restart your notebook session or kernel for the newly installed packages to be recognized.

# %pip install fire-opal matplotlib qctrl-visualizer
import fireopal as fo
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
from pyscipopt import Model
import qctrlvisualizer as qv

1.1 Set up your accounts and choose a backend

To run this tutorial, you will need an IonQ Quantum Cloud API key. To create an API key, you can follow IonQ's guide.

token = ""  # IonQ API token

credentials = fo.credentials.make_credentials_for_ionq(token=token)
# Enter your desired IonQ backend here.
# Valid values are ionq_simulator, ionq_qpu.forte-1, and ionq_qpu.forte-enterprise-1.
# Check device availability at https://status.ionq.co/.
backend_name = "ionq_qpu.forte-enterprise-1"

Optionally, you may also authenticate with Q-CTRL using an API key from the Sign in and security tab of your Q-CTRL Accounts page. If you skip this step, you will be prompted to authenticate in your browser when calling fo.solve_qaoa later in this tutorial.

fo.authenticate_qctrl_account(api_key="")
Q-CTRL authentication successful!

1.2 Define helper functions

The following functions are used to set up the Max-Cut problem instances and analyze results.

def maxcut_obj(bitstring: str, graph: nx.Graph) -> float:
    """
    Given a bitstring, this function returns the number of edges
    shared between the two partitions of the graph.
    """
    obj = 0
    # Iterate through each edge in the graph
    for i, j, data in graph.edges(data=True):
        # Calculate the contribution of the edge to the Max-Cut objective
        # If the nodes are in different partitions, the term (int(bitstring[i]) - int(bitstring[j]))**2 will be 1
        # Otherwise, it will be 0
        obj -= data.get("weight", 1) * (int(bitstring[i]) - int(bitstring[j])) ** 2
    return obj


def get_cost_distribution(
    distribution: dict[str, int], graph: nx.Graph
) -> tuple[np.ndarray, np.ndarray]:
    """
    Given a bitstring count distribution and a graph, returns the
    costs and relative probabilities as numpy arrays.
    """
    # Total number of bitstrings (shots)
    shot_count = sum(distribution.values())
    costs = []
    probabilities = []
    # Calculate the cost and probability for each bitstring
    for bitstring, count in distribution.items():
        # Reverse the bitstring to match graph node indices
        cost = maxcut_obj(bitstring[::-1], graph)
        costs.append(cost)
        probabilities.append(count / shot_count)
    return np.array(costs), np.array(probabilities)


def generate_random_bitstrings(
    length: int, num_bitstrings: int, rng_seed: int = 0
) -> list[str]:
    """
    Generate a random sampling of bitstrings.
    """
    # Initialize the random number generator with a seed for reproducibility
    rng = np.random.default_rng(seed=rng_seed)
    # Generate random bitstrings
    random_array = rng.integers(0, 2, size=(num_bitstrings, length))
    bitstrings = ["".join(row.astype(str)) for row in random_array]
    return bitstrings


def calculate_percent_gap_to_optimal(
    costs: np.ndarray, optimal_cut_value: float
) -> None:
    """
    Determine the percent gap between the optimal cut value and the best cut value
    found from Fire Opal.
    """
    # Find the best cut value found (most negative cost)
    best_cut_value_found = max(-costs)
    # Calculate the percentage difference from the optimal value
    percent_optimality = np.round(
        100 - (abs(optimal_cut_value - best_cut_value_found) / optimal_cut_value * 100),
        2,
    )
    # Print the results
    print(
        f"The best cut value found by Fire Opal is {best_cut_value_found}. \n"
        f"This solution is {percent_optimality}% optimized relative to the classically determined solution, {optimal_cut_value}."
    )


def analyze_maxcut_results(
    distribution: dict[str, int], graph: nx.Graph, optimal_cut_value: float
) -> None:
    """
    Analyze results from Max-Cut execution.
    """
    # Number of nodes in the graph
    qubit_count = graph.number_of_nodes()
    # Total number of bitstrings (shots)
    shot_count = sum(distribution.values())

    # Get cost distribution from the results
    costs, probs = get_cost_distribution(distribution, graph)
    # Generate random bitstrings and get their cost distribution
    random_bitstrings = generate_random_bitstrings(qubit_count, shot_count)
    random_probs = np.ones(shot_count) / shot_count
    random_costs = np.asarray(
        [maxcut_obj(bitstring[::-1], graph) for bitstring in random_bitstrings]
    )

    # Plot histograms of cut values
    plt.style.use(qv.get_qctrl_style())
    _, ax = plt.subplots(1, 1)

    ax.hist(
        [-costs, -random_costs],
        np.arange(-max(random_costs), -min(costs) + 1, 1),
        weights=[probs, random_probs],
        label=["Fire Opal QAOA solver", "Random Sampling"],
        color=[qv.QCTRL_STYLE_COLORS[0], qv.QCTRL_STYLE_COLORS[1]],
    )
    ax.set_xlabel("Cut Value")
    ax.set_ylabel("Probability")
    ax.legend()

    # Calculate and print the percent gap to optimal
    calculate_percent_gap_to_optimal(costs=costs, optimal_cut_value=optimal_cut_value)


def solve_maxcut_classically(graph: nx.Graph) -> dict:
    """
    Solve the Max-Cut problem classically using linear programming.
    """
    model = Model("MaxCut")
    # Add binary variables for edges
    e = {(u, v): model.addVar(vtype="B", name=f"e({u, v})") for u, v in graph.edges()}
    # Add binary variables for nodes
    x = {u: model.addVar(vtype="B", name=f"x({u})") for u in graph.nodes()}

    # Define the objective function to maximize the cut value
    objective = sum(
        data.get("weight", 1) * e[(u, v)] for u, v, data in graph.edges(data=True)
    )
    model.setObjective(objective, "maximize")

    # Add constraints to ensure valid cuts
    for u, v in graph.edges():
        model.addCons(e[(u, v)] <= x[u] + x[v])
        model.addCons(e[(u, v)] <= 2 - (x[u] + x[v]))

    model.hideOutput(True)
    model.optimize()

    # Ensure the solution is optimal
    assert model.getStatus() == "optimal"

    # Return the solution indicating which partition each node belongs to
    return {u: model.getVal(x[u]) for u in graph.nodes()}


def cut_value(graph: nx.Graph, cut: dict[int, float]) -> float:
    """
    Calculate the cut value of a given partition.
    """
    value = 0
    for i, j, data in graph.edges(data=True):
        value += data.get("weight", 1) * np.round(cut[i] - cut[j]) ** 2
    return value

2. Example: Unconstrained Optimization

Run the (Max-Cut) problem. The following example demonstrates the Solver's capabilities on a 10-node, unweighted graph, but you can also solve weighted graph problems.

2.1 Define the problem

You can run a Max-Cut problem by defining a graph and specifying problem_type='maxcut'.

# Generate a random graph with 10 nodes
unweighted_graph = nx.connected_watts_strogatz_graph(n=10, k=6, p=0.5, seed=0)
# Optionally, visualize the graph
nx.draw(unweighted_graph, nx.kamada_kawai_layout(unweighted_graph), node_size=100)

png-1

2.2 Solve the graph problem using Fire Opal's QAOA Solver

unweighted_job = fo.solve_qaoa(
    problem=unweighted_graph,
    credentials=credentials,
    problem_type="maxcut",
    backend_name=backend_name,
)
This function performs multiple consecutive runs. Wait time may vary depending on hardware queues.
unweighted_result = unweighted_job.result()

2.3 Solve the graph problem using classical methods

Because this problem is still within the bounds of what can be solved by classical methods, it's possible to check the accuracy of Fire Opal's obtained solution.

solution = solve_maxcut_classically(unweighted_graph)
optimal_cut_value = cut_value(unweighted_graph, solution)
print(f"Classically derived cut value: {optimal_cut_value}")
Classically derived cut value: 21.0

2.4 Plot and analyze the solutions generated by Fire Opal

The performance baseline is set by sampling bitstrings uniformly at random (red histogram bars), akin to a random brute-force scan through the solution set. The number of random samples is the same as the samples from the optimal QAOA circuit.

analyze_maxcut_results(
    distribution=unweighted_result["final_bitstring_distribution"],
    graph=unweighted_graph,
    optimal_cut_value=optimal_cut_value,
)
The best cut value found by Fire Opal is 21. 
This solution is 100.0% optimized relative to the classically determined solution, 21.0.



png-2

In this scenario, the solutions produced by Fire Opal's QAOA solver significantly outperform the cut values obtained via random sampling, a benchmark akin to naive QAOA implementations at this scale. Fire Opal's best cut value achieves the true optimal solution with the highest probability.

Congratulations on successfully leveraging Fire Opal’s QAOA Solver to tackle a dense Max-Cut problem on IonQ hardware. To learn more about what sets this solver apart and how it delivers high-accuracy solutions at utility scales, explore the Fire Opal QAOA Solver technical deep dive.

The following package versions were used to produce this notebook.

from fireopal import print_package_versions

print_package_versions()
| Package               | Version |
| --------------------- | ------- |
| Python                | 3.11.3  |
| networkx              | 3.5     |
| numpy                 | 2.3.1   |
| sympy                 | 1.12    |
| fire-opal             | 10.1.0  |
| qctrl-workflow-client | 9.0.1   |

Was this useful?

cta background

New to Fire Opal?

Get access to everything you need to automate and optimize quantum hardware performance at scale.