Note

This page was generated from docs/tutorials/12_quantum_random_access_optimizer.ipynb.

Quantum Random Access Optimization#

The Quantum Random Access Optimization (QRAO) module is designed to enable users to leverage a new quantum method for combinatorial optimization problems [1]. This approach incorporates Quantum Random Access Codes (QRACs) as a tool to encode multiple classical binary variables into a single qubit, thereby saving quantum resources and enabling exploration of larger problem instances on a quantum computer. The encodings produce a local quantum Hamiltonian whose ground state can be approximated with standard algorithms such as VQE, and then rounded to yield approximation solutions of the original problem.

QRAO through a series of 3 classes: 1. The encoding class (QuantumRandomAccessEncoding): This class encodes the original problem into a relaxed problem that requires fewer resources to solve. 2. The rounding schemes (SemideterministicRounding and MagicRounding): This scheme is used to round the solution obtained from the relaxed problem back to a solution of the original problem. 3. The optimizer class (QuantumRandomAccessOptimizer): This class performs the high-level optimization algorithm, utilizing the capabilities of the encoding class and the rounding scheme.

References

[1] Bryce Fuller et al., Approximate Solutions of Combinatorial Problems via Quantum Relaxations, arXiv:2111.03167

[1]:
from qiskit_optimization.algorithms.qrao import (
    QuantumRandomAccessEncoding,
    SemideterministicRounding,
    QuantumRandomAccessOptimizer,
)

Set up a combinatorial optimization problem#

In this tutorial, we will consider a random max-cut problem instance and use QRAO to try to find a maximum cut; in other words, a partition of the graph’s vertices (nodes) into two sets that maximizes the number of edges between the sets.

To begin, we utilize the Maxcut class from Qiskit Optimization’s application module. It allows us to generate a QuadraticProgram representation of the given graph.

Note that once our problem has been represented as a QuadraticProgram, it will need to be converted to the correct type, a quadratic unconstrained binary optimization (QUBO) problem, so that it is compatible with QRAO. A QuadraticProgram generated by Maxcut is already a QUBO, but if you define your own problem be sure you convert it to a QUBO before proceeding. Here is a tutorial on converting QuadraticPrograms.

[2]:
import networkx as nx

from qiskit_optimization.applications import Maxcut

seed = 1
num_nodes = 6
graph = nx.random_regular_graph(d=3, n=num_nodes, seed=seed)
nx.draw(graph, with_labels=True, pos=nx.spring_layout(graph, seed=seed))

maxcut = Maxcut(graph)
problem = maxcut.to_quadratic_program()
print(problem.prettyprint())
Problem name: Max-cut

Maximize
  -2*x_0*x_1 - 2*x_0*x_3 - 2*x_0*x_4 - 2*x_1*x_2 - 2*x_1*x_5 - 2*x_2*x_3
  - 2*x_2*x_4 - 2*x_3*x_5 - 2*x_4*x_5 + 3*x_0 + 3*x_1 + 3*x_2 + 3*x_3 + 3*x_4
  + 3*x_5

Subject to
  No constraints

  Binary variables (6)
    x_0 x_1 x_2 x_3 x_4 x_5

../_images/tutorials_12_quantum_random_access_optimizer_4_1.png

Encode the problem into a quantum Hamiltonian#

Once we have appropriately configured our problem, we proceed to encode it using the QuantumRandomAccessEncoding class from the qrao module. This encoding step allows us to generate a quantum Hamiltonian operator that represents our problem. In particular, we employ a Quantum Random Access Code (QRAC) to encode multiple classical binary variables (corresponding to the nodes of our max-cut graph) into each qubit.

It’s important to note that the resulting “relaxed” Hamiltonian, produced by this encoding, will not be diagonal. This differs from the standard workflow in qiskit-optimization, which typically generates a diagonal (Ising) Hamiltonian suitable for optimization using a MinimumEigenOptimizer. You can find a tutorial on the MinimumEigenOptimizer here.

In our encoding process, we employ a \((3,1,p)-\)QRAC, where each qubit can accommodate a maximum of 3 classical binary variables. The parameter \(p\) represents the bit recovery probability achieved through measurement. Depending on the nature of the problem, some qubits may have fewer than 3 classical variables assigned to them. To evaluate the compression achieved, we can examine the compression_ratio attribute of the encoding, which provides the ratio between the number of original binary variables and the number of qubits used (at best, a factor of 3).

[3]:
from qiskit_optimization.algorithms.qrao import QuantumRandomAccessEncoding


# Create an encoding object with a maximum of 3 variables per qubit, aka a (3,1,p)-QRAC
encoding = QuantumRandomAccessEncoding(max_vars_per_qubit=3)

# Encode the QUBO problem into an encoded Hamiltonian
encoding.encode(problem)

# This is our encoded Hamiltonian
print(f"Our encoded Hamiltonian is:\n( {encoding.qubit_op} ).\n")
print(
    "We achieve a compression ratio of "
    f"({encoding.num_vars} binary variables : {encoding.num_qubits} qubits) "
    f"≈ {encoding.compression_ratio}.\n"
)
Our encoded Hamiltonian is:
( SparsePauliOp(['XX', 'XY', 'XZ', 'YX', 'ZX', 'YY', 'YZ', 'ZY', 'ZZ'],
              coeffs=[1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j,
 1.5+0.j]) ).

We achieve a compression ratio of (6 binary variables : 2 qubits) ≈ 3.0.

Solve the problem using the QuantumRandomAccessOptimizer#

Having successfully encoded our input problem as a relaxed Hamiltonian, we proceed to solve it using the QuantumRandomAccessOptimizer. This optimizer allows us to find an approximate solution to the relaxed problem by leveraging quantum computing techniques.

To set up the optimizer, we need to specify two crucial components:

  1. Minimum Eigensolver: We specify a minimum eigensolver to heuristically search for the ground state of the relaxed problem Hamiltonian. As an example, we can use the Variational Quantum Eigensolver (VQE). For simulation purposes, we’ll employ an simulator, but you can choose a quantum device as the backend if desired.

  2. Rounding Scheme: To map the ground state results back to a solution for the original problem, we specify a rounding scheme. By default, the SemideterministicRounding is used, but alternative scheme, MagicRounding, is also available.

[4]:
from qiskit_algorithms import VQE
from qiskit_algorithms.optimizers import COBYLA
from qiskit.circuit.library import RealAmplitudes
from qiskit.primitives import Estimator

from qiskit_optimization.algorithms.qrao import (
    QuantumRandomAccessOptimizer,
    SemideterministicRounding,
)


# Prepare the VQE algorithm
ansatz = RealAmplitudes(2)
vqe = VQE(
    ansatz=ansatz,
    optimizer=COBYLA(),
    estimator=Estimator(),
)

# Use semi-deterministic rounding, known as "Pauli rounding"
# in https://arxiv.org/pdf/2111.03167v2.pdf
# (This is the default if no rounding scheme is specified.)
semidterministic_rounding = SemideterministicRounding()

# Construct the optimizer
qrao = QuantumRandomAccessOptimizer(min_eigen_solver=vqe, rounding_scheme=semidterministic_rounding)

Finally, we move forward with solving the problem by invoking the solve() method. It’s important to note that when calling solve(), we pass the problem itself as an argument. Although we previously used encode() in QuantumRandomAccessEncoding to provide a clear understanding of the flow, solve(problem) automatically encodes the problem internally using QuantumRandomAccessEncoding. This provides a streamlined and simplified workflow that eliminates the need for explicit encoding steps.

The result is provides us as a QuantumRandomAccessOptimizationResult. The x contains the binary values representing the best solution found, while the fval contains the corresponding objective value.

The relaxed_fval provides the expectation value of the relaxed Hamiltonian, adjusted to be in the units of the original optimization problem. For maximization problems, the best possible relaxed function value will always be greater than or equal to the best possible objective function value of the original problem. In practice, this often holds true for the best found value and best found objective function value as well.

[5]:
# Solve the optimization problem
results = qrao.solve(problem)

print(
    f"The objective function value: {results.fval}\n"
    f"x: {results.x}\n"
    f"relaxed function value: {-1 * results.relaxed_fval}\n"
)
The objective function value: 5.0
x: [1 0 1 0 1 0]
relaxed function value: 8.999999991284032

Interpret the solution#

In the context of max-cut, the result’s “optimal value” tells us which subset each node belongs to given the partition found by the optimizer.

[6]:
maxcut_partition = maxcut.interpret(results)
print(
    f"The obtained solution places a partition between nodes {maxcut_partition[0]} "
    f"and nodes {maxcut_partition[1]}."
)
maxcut.draw(results, pos=nx.spring_layout(graph, seed=seed))
The obtained solution places a partition between nodes [1, 3, 5] and nodes [0, 2, 4].
../_images/tutorials_12_quantum_random_access_optimizer_13_1.png

Inspect the results of subroutines#

The MinimumEigensolverResult that results from performing VQE on the relaxed Hamiltonian is available:

[7]:
results.relaxed_result
[7]:
<qiskit_algorithms.minimum_eigensolvers.vqe.VQEResult at 0x7f284fb6c2b0>

The result of the rounding scheme is also worth considering. In this example, we used the SemideterministricRounding. It’s important to note that with semi-deterministic rounding, a single sample is generated as the result, making it the optimal solution candidate.

However, if we use the MagicRounding instead, multiple samples would be generated, each with a probability associated with it. These probabilities sum up to one, providing a distribution of potential optimal solutions.

[8]:
results.samples
[8]:
[SolutionSample(x=array([1, 0, 1, 0, 1, 0]), fval=5.0, probability=1.0, status=<OptimizationResultStatus.SUCCESS: 0>)]

Exact Problem Solution with the NumpyMinimumEigensolver#

To assess the performance of QRAO in approximating the optimal solution, we can utilize the NumpyMinimumEigensolver, an exact classical optimizer. We can obtain the exact optimal solution to the problem as follows:

[9]:
from qiskit_algorithms import NumPyMinimumEigensolver

from qiskit_optimization.algorithms import MinimumEigenOptimizer

exact_mes = NumPyMinimumEigensolver()
exact = MinimumEigenOptimizer(exact_mes)
exact_result = exact.solve(problem)
print(exact_result.prettyprint())
objective function value: 9.0
variable values: x_0=0.0, x_1=1.0, x_2=0.0, x_3=1.0, x_4=1.0, x_5=0.0
status: SUCCESS

The approximation ratio (QRAO’s objective function value divided by the optimal objective function value) tells us how closely QRAO approximated the optimal solution to the problem.

[10]:
print("QRAO Approximate Optimal Function Value:", results.fval)
print("Exact Optimal Function Value:", exact_result.fval)
print(f"Approximation Ratio: {results.fval /  exact_result.fval :.2f}")
QRAO Approximate Optimal Function Value: 5.0
Exact Optimal Function Value: 9.0
Approximation Ratio: 0.56

Solve the problem using the QuantumRandomAccessOptimizer with MagicRounding#

Magic rounding is a quantum technique employed to map the ground state results of our encoded Hamiltonian back to a solution of the original problem. Unlike semi-deterministic rounding, magic rounding requires a quantum backend, which can be either hardware or a simulator. The backend is passed to the MagicRounding class through a Sampler, which also determines the total number of shots (samples) that magic rounding will utilize. Note that to specify the backend, you need to choose a Sampler from providers such as Aer or IBM Runtime. Consequently, we need to specify Estimator and Sampler for the optimizer and the rounding scheme, respectively.

In practice, users may choose to set a significantly higher number of magic rounding shots compared to the shots used by the minimum eigensolver for the relaxed problem. This difference arises because the minimum eigensolver estimates expectation values, while the magic rounding scheme returns the sample corresponding to the maximum function value found. The number of magic rounding shots directly impacts the diversity of the computational basis we can generate. When estimating an expectation value, increasing the number of shots enhances the convergence to the true value. However, when aiming to identify the largest possible function value, we often sample from the tail of a distribution of outcomes. As a result, until we observe the highest value outcome in our distribution, each additional shot increases the expected return value.

In this tutorial, we use the Estimator for solving the relaxed Hamiltonian and the Sampler for performing magic rounding. Here, 10 times as many shots are used in the Sampler. As the number of qubits increases, you may need more shots or weighted basis sampling, as explained above.”

[11]:
from qiskit.primitives import Sampler

from qiskit_optimization.algorithms.qrao import MagicRounding


estimator = Estimator(options={"shots": 1000, "seed": seed})
sampler = Sampler(options={"shots": 10000, "seed": seed})

# Prepare the VQE algorithm
ansatz = RealAmplitudes(2)
vqe = VQE(
    ansatz=ansatz,
    optimizer=COBYLA(),
    estimator=estimator,
)


# Use magic rounding
magic_rounding = MagicRounding(sampler=sampler)

# Construct the optimizer
qrao = QuantumRandomAccessOptimizer(min_eigen_solver=vqe, rounding_scheme=magic_rounding)

results = qrao.solve(problem)
[12]:
print(
    f"The objective function value: {results.fval}\n"
    f"x: {results.x}\n"
    f"relaxed function value: {-1 * results.relaxed_fval}\n"
)
The objective function value: 9.0
x: [1 0 1 0 0 1]
relaxed function value: 8.99999408886349

Since magic rounding relies on nondeterministic measurements, the method collects a number of samples based on the shots count provided to the Sampler mentioned earlier. These samples are then consolidated, taking into account duplicates and calculating the empirical probability for each SolutionSample. Each sample in the consolidation process includes a corresponding function value (fval).

From the consolidated samples, we select the sample with the “optimal” function value. In the case of a max-cut problem, this means choosing the sample with the largest function value as our solution.

[13]:
print(f"The number of distinct samples is {len(results.samples)}.")
print("Top 10 samples with the largest fval:")
for sample in results.samples[:10]:
    print(sample)
The number of distinct samples is 56.
Top 10 samples with the largest fval:
SolutionSample(x=array([1, 0, 1, 0, 0, 1]), fval=9.0, probability=0.0095, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([0, 1, 0, 1, 1, 0]), fval=9.0, probability=0.0111, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([0, 0, 0, 1, 1, 0]), fval=6.0, probability=0.0212, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([1, 1, 1, 0, 0, 1]), fval=6.0, probability=0.022299999999999997, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([0, 1, 1, 1, 1, 0]), fval=6.0, probability=0.0198, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([1, 0, 0, 0, 0, 1]), fval=6.0, probability=0.0208, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([1, 0, 1, 0, 0, 0]), fval=6.0, probability=0.0201, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([0, 1, 0, 1, 1, 1]), fval=6.0, probability=0.0211, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([1, 0, 1, 0, 1, 1]), fval=6.0, probability=0.0215, status=<OptimizationResultStatus.SUCCESS: 0>)
SolutionSample(x=array([0, 1, 0, 1, 0, 0]), fval=6.0, probability=0.0196, status=<OptimizationResultStatus.SUCCESS: 0>)

Alternative: Solve the Problem in Two Explicit Steps#

In the previous part of this tutorial, we utilized the qrao.solve() method, which solved the encoded problem (the ground state of the relaxed Hamiltonian) and performed rounding to map the ground state results back to a solution of the original problem. However, it is also possible to explicitly break down the calculation into these two distinct steps. This can be beneficial, especially when comparing solutions obtained across multiple rounding schemes applied to a candidate ground state.

In this section, we will explore how to perform each of these steps explicitly.

Manually solve the relaxed problem.#

Let’s start by invoking the qrao.solve_relaxed() method to directly solve the relaxed problem encoded by QuantumRandomAccessEncoding. This method allows us to focus solely on solving the relaxed problem without performing rounding.

By invoking qrao.solve_relaxed(), we obtain two essential outputs:

  • MinimumEigensolverResult: This object contains the results of running the minimum eigen optimizer such as the VQE on the relaxed problem. It provides information about the eigenvalue, and other relevant details. You can refer to the Qiskit Algorithms documentation for a comprehensive explanation of the entries within this object.

  • RoundingContext: This object encapsulates essential information about the encoding and the solution of the relaxed problem in a form that is ready for consumption by the rounding schemes.

[14]:
# Encode the QUBO problem into a relaxed Hamiltonian
encoding = QuantumRandomAccessEncoding(max_vars_per_qubit=3)
encoding.encode(problem)

# Solve the relaxed problem
relaxed_results, rounding_context = qrao.solve_relaxed(encoding)
[15]:
for k in dir(relaxed_results):
    if not k.startswith("_"):
        print(f"{k}: {getattr(relaxed_results, k)}")
aux_operators_evaluated: [(0.010899982546387852, {'variance': 0.9999999991963255, 'shots': 1000}), (0.026010211326202858, {'variance': 0.9999999991954647, 'shots': 1000}), (0.01044933784106082, {'variance': 1.0, 'shots': 1000}), (-0.04120945001189341, {'variance': 1.0, 'shots': 1000}), (0.028522457738391727, {'variance': 0.9999999884634327, 'shots': 1000}), (0.014223015722205993, {'variance': 0.9999999884625718, 'shots': 1000})]
combine: <bound method AlgorithmResult.combine of <qiskit_algorithms.minimum_eigensolvers.vqe.VQEResult object at 0x7f2875e40100>>
cost_function_evals: 97
eigenvalue: -4.499992396164788
optimal_circuit:      ┌──────────────────────────────────────────────────────────┐
q_0: ┤0                                                         ├
     │  RealAmplitudes(θ[0],θ[1],θ[2],θ[3],θ[4],θ[5],θ[6],θ[7]) │
q_1: ┤1                                                         ├
     └──────────────────────────────────────────────────────────┘
optimal_parameters: {ParameterVectorElement(θ[0]): -1.7901396887948946, ParameterVectorElement(θ[1]): 0.19325120967082843, ParameterVectorElement(θ[2]): 2.4954456446904993, ParameterVectorElement(θ[3]): -1.2926638242889081, ParameterVectorElement(θ[4]): -0.6706331173258124, ParameterVectorElement(θ[5]): 2.547756928280065, ParameterVectorElement(θ[6]): 2.082750164148933, ParameterVectorElement(θ[7]): 0.9912658938121004}
optimal_point: [-1.79013969  0.19325121  2.49544564 -1.29266382 -0.67063312  2.54775693
  2.08275016  0.99126589]
optimal_value: -4.499992396164788
optimizer_evals: None
optimizer_result: {   'fun': -4.499992396164788,
    'jac': None,
    'nfev': 97,
    'nit': None,
    'njev': None,
    'x': array([-1.79013969,  0.19325121,  2.49544564, -1.29266382, -0.67063312,
        2.54775693,  2.08275016,  0.99126589])}
optimizer_time: 0.27496337890625

Manually perform rounding on the relaxed problem results#

Next, we proceed with rounding the results obtained from solving the relaxed problem. To achieve this, we call the round() method on an instance of the desired rounding scheme and pass it the RoundingContext object. Below, we provide an example for both rounding schemes, utilizing the relaxed solution obtained in the previous step.

By manually performing the rounding step, we have more flexibility and control over the rounding scheme applied to the relaxed problem results. This allows for greater exploration and comparison of different rounding strategies.

[16]:
# Round the relaxed solution using semi-deterministic rounding
semidterministic_rounding = SemideterministicRounding()
sdr_results = semidterministic_rounding.round(rounding_context)
qrao_results_sdr = qrao.process_result(
    problem=problem, encoding=encoding, relaxed_result=relaxed_results, rounding_result=sdr_results
)

print(
    f"The objective function value: {qrao_results_sdr.fval}\n"
    f"x: {qrao_results_sdr.x}\n"
    f"relaxed function value: {-1 * qrao_results_sdr.relaxed_fval}\n"
    f"The number of distinct samples is {len(qrao_results_sdr.samples)}."
)
The objective function value: 3.0
x: [0 0 0 1 0 0]
relaxed function value: -8.999992396164789
The number of distinct samples is 1.
[17]:
magic_rounding = MagicRounding(sampler=sampler)
mr_results = magic_rounding.round(rounding_context)
qrao_results_mr = qrao.process_result(
    problem=problem, encoding=encoding, relaxed_result=relaxed_results, rounding_result=mr_results
)

print(
    f"The objective function value: {qrao_results_mr.fval}\n"
    f"x: {qrao_results_mr.x}\n"
    f"relaxed function value: {-1 * qrao_results_mr.relaxed_fval}\n"
    f"The number of distinct samples is {len(qrao_results_mr.samples)}."
)
The objective function value: 9.0
x: [1 0 1 0 0 1]
relaxed function value: -8.999992396164789
The number of distinct samples is 56.

Appendix#

How to verify correctness of your encoding#

We assume for sake of the QRAO method that the relaxation commutes with the objective function. This notebook demonstrates how one can verify this for any problem (a QuadraticProgram in the language of Qiskit Optimization). One might want to verify this for pedagogical purposes, or as a sanity check when investigating unexpected behavior with the QRAO. Any problem that does not commute should be considered a bug, and if such a problem is discovered, we encourage that you submit it as an issue on GitHub.

The EncodingCommutationVerifier class allows one to conveniently iterate over all decision variable states and compare each objective value with the corresponding encoded objective value, in order to identify any discrepancy.

[18]:
from qiskit_optimization.algorithms.qrao import EncodingCommutationVerifier

seed = 1
num_nodes = 6
graph = nx.random_regular_graph(d=3, n=num_nodes, seed=seed)
nx.draw(graph, with_labels=True, pos=nx.spring_layout(graph, seed=seed))

maxcut = Maxcut(graph)
problem = maxcut.to_quadratic_program()
print(problem.prettyprint())
Problem name: Max-cut

Maximize
  -2*x_0*x_1 - 2*x_0*x_3 - 2*x_0*x_4 - 2*x_1*x_2 - 2*x_1*x_5 - 2*x_2*x_3
  - 2*x_2*x_4 - 2*x_3*x_5 - 2*x_4*x_5 + 3*x_0 + 3*x_1 + 3*x_2 + 3*x_3 + 3*x_4
  + 3*x_5

Subject to
  No constraints

  Binary variables (6)
    x_0 x_1 x_2 x_3 x_4 x_5

../_images/tutorials_12_quantum_random_access_optimizer_35_1.png

As before, we encode() the problem using the QuantumRandomAccessEncoding class:

[19]:
encoding = QuantumRandomAccessEncoding(max_vars_per_qubit=3)
encoding.encode(problem)

print("Encoded Problem:\n=================")
print(encoding.qubit_op)  # The Hamiltonian without the offset
print("Offset = ", encoding.offset)
print("Variables encoded on each qubit: ", encoding.q2vars)
Encoded Problem:
=================
SparsePauliOp(['XX', 'XY', 'XZ', 'YX', 'ZX', 'YY', 'YZ', 'ZY', 'ZZ'],
              coeffs=[1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j, 1.5+0.j,
 1.5+0.j])
Offset =  -4.5
Variables encoded on each qubit:  [[0, 2, 5], [1, 3, 4]]

Finally, we iterate over every decision variable state using EncodingCommutationVerifier and verify that, in each case, the problem objective value matches the encoded objective value:

[20]:
import numpy as np

verifier = EncodingCommutationVerifier(encoding, estimator=Estimator())
if not len(verifier) == 2**encoding.num_vars:
    print("The number results of the encoded problem is not equal to 2 ** num_vars.")

for str_dvars, obj_val, encoded_obj_val in verifier:
    if not np.isclose(obj_val, encoded_obj_val):
        print(
            f"Violation identified: {str_dvars} evaluates to {obj_val} "
            f"but the encoded problem evaluates to {encoded_obj_val}."
        )

If you are able to construct a problem that causes a violation, it is quite possible that you have discovered a bug in the QuantumRandomAccessEncoding logic. We would greatly appreciate it if you could share the problem with us by submitting it as an issue on GitHub.

[21]:
import tutorial_magics

%qiskit_version_table
%qiskit_copyright

Version Information

SoftwareVersion
qiskit1.0.0
qiskit_algorithms0.3.0
qiskit_optimization0.6.0
System information
Python version3.8.18
OSLinux
Tue Feb 20 18:58:02 2024 UTC

This code is a part of a Qiskit project

© Copyright IBM 2017, 2024.

This code is licensed under the Apache License, Version 2.0. You may
obtain a copy of this license in the LICENSE.txt file in the root directory
of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.

Any modifications or derivative works of this code must retain this
copyright notice, and modified files need to carry a notice indicating
that they have been altered from the originals.

[ ]: