Gate-based quantum-inspired optimizer written in Python, using only NumPy for the quantum mechanics (no Qiskit or other quantum libraries).
This repo is built on the state-machine template of pso_basic so it drops into the same optimizer-suite ecosystem, sharing the same driver structure, objective-function format, and import/export interface.
This is a gate-based quantum-inspired algorithm (the QEA lineage). It is a distinct method from pso_quantum, which is a Quantum-behaved PSO (QPSO) built on a delta-potential-well sampling rule with no gates. This is meant as an exploratory implementation looking at some interesting literature. The no-Qiskit implementation simplifies this into something that can be integrated with AntennaCAT or benchmarked against other optimizers in the suite. Where QPSO borrows the quantum metaphor as a sampling formula, qea_python represents the population as actual qubit amplitudes and evolves them with literal unitary matrices (Hadamard and rotation gates), collapsing them to classical positions by measurement. It migh tnot be the most efficient implementation, but it's fun.
- Quantum-Inspired Optimization
- How the Gates Work
- Requirements
- Implementation
- Example Implementations
- References
- Related Publications and Repositories
- Licensing
Quantum-Inspired Evolutionary Algorithms (QEA) are a family of optimizers introduced in "Quantum-Inspired Evolutionary Algorithm for a Class of Combinatorial Optimization" [1] (K.-H. Han & J.-H. Kim, 2002). They borrow the representational ideas of quantum computing (superposition, qubit state, measurement) without requiring quantum hardware or a full quantum simulator.
Rather than storing a population of explicit candidate solutions, QEA stores a population of quantum registers. Each register is a set of qubits, and each qubit is held in superposition, encoding a probability distribution over classical bit values. A candidate solution is produced by measuring the register (collapsing each qubit to a 0 or 1), and the resulting bitstring is decoded into a real-valued position in the search space. Search proceeds by applying rotation gates that nudge each qubit's measurement probabilities toward the best solutions found so far.
This implementation keeps the population as independent single qubits rather than forming the full 2^n tensor-product state. That simplification is what makes the method tractable in plain NumPy, and it is the standard formulation for the QEA optimization lineage. Simulating a handful of independent single qubits is just a sequence of 2x2 matrix-vector products, so no circuit machinery, transpiler, or quantum backend is needed.
(This only applies as a compariton to the PSO_python extension in terms of the name changes. If you are not developing the algorithms, feel free to skip this section)
This parameter is the QEA analog of the swarm size in PSO, and is named NO_OF_REGISTERS (rather than the PSO term "particles") to reflect what it actually counts. In classical PSO, each particle is a point with a position and velocity moving through the search space. In QEA there are no moving points: NO_OF_REGISTERS is the number of independent quantum registers in the population, where each register is its own bundle of input_size * BITS_PER_VAR qubits holding its own superposition. This count is the first axis of the amplitude array Q, whose shape is (NO_OF_REGISTERS, n_qubits, 2).
Each iteration, the state machine advances one register at a time (an internal index walks 0 -> NO_OF_REGISTERS - 1 and wraps), measuring, evaluating, and rotating that register before moving to the next. A single register can collapse into an unlucky region on a given measurement, so having several registers is the algorithm's main source of diversity and its defense against premature convergence, the same role the population plays in the classical QEA literature. More registers means more independent samples of the search space per cycle (more exploration and robustness) at a proportional cost in objective-function calls. The default of 11 is a reasonable starting point.
Note that in the current implementation, all registers rotate toward the same shared global-best attractor once one is found, so the population diversifies mainly in the early iterations before the global best dominates. Rotating each register toward its own personal best some fraction of the time (the way the PSO variants blend personal and global pulls) is a natural extension if richer diversity is needed.
The optimizer represents the population as an amplitude array Q of shape (NO_OF_REGISTERS, n_qubits, 2), where n_qubits = input_size * BITS_PER_VAR. Each qubit is a 2-vector of amplitudes [alpha, beta] with alpha^2 + beta^2 = 1. Three operations drive the algorithm, all implemented as NumPy matrix math:
Hadamard gate (initialization). Every qubit starts in state |0> = [1, 0]. Applying the Hadamard matrix
puts each qubit into equal superposition [1/\sqrt{2}, 1/\sqrt{2}], i.e. a fair 50/50 measurement. This replaces uniform random initialization with a state-vector the rest of the algorithm can rotate.
Rotation gate (search). For each qubit, the algorithm compares its currently most-probable bit to the corresponding bit of the current best solution (the attractor), then applies a rotation
to push the qubit's amplitude toward the attractor bit. This is the gate-based analog of the PSO velocity update. The rotation angle is set by DELTA_THETA and is annealed (shrunk) as iterations progress, so the register can fine-tune near a minimum instead of overshooting on the discrete decode grid.
Measurement (collapse and decode). To evaluate a register, each qubit is collapsed to a classical bit: draw r ~ U(0,1), and the bit is 0 if r < alpha^2, else 1. The resulting bitstring is decoded per dimension into an integer, normalized to [0, 1], and scaled into [LB, UB]. That classical position is what gets passed to the objective function.
The per-iteration loop is therefore: rotate toward the best (search) → measure → decode → evaluate → update bests. The objective-function, constraint, threshold, and boundary handling are all shared with the rest of the optimizer suite and are unchanged.
This project requires numpy, pandas, and matplotlib for the full demos. To run the optimizer without visualization, only numpy and pandas are requirements.
Use 'pip install -r requirements.txt' to install the following dependencies:
contourpy==1.3.3
cycler==0.12.1
fonttools==4.63.0
kiwisolver==1.5.0
matplotlib==3.10.9
numpy==2.4.6
packaging==26.2
pandas==3.0.3
pillow==12.2.0
pyparsing==3.3.2
python-dateutil==2.9.0.post0
six==1.17.0
tzdata==2026.2Optionally, requirements can be installed manually with:
pip install matplotlib, numpy, pandasThis is an example for if you've had a difficult time with the requirements.txt file. Sometimes libraries are packaged together.
# Constant variables
NO_OF_REGISTERS = 11 # Number of registers in population
TOL = 10 ** -18 # Convergence Tolerance
MAXIT = 10000 # Maximum allowed iterations
BOUNDARY = 1 # int boundary 1 = random, 2 = reflecting
# 3 = absorbing, 4 = invisible
# Objective function dependent variables
func_F = func_configs.OBJECTIVE_FUNC # objective function
constr_F = func_configs.CONSTR_FUNC # constraint function
LB = func_configs.LB # Lower boundaries, [[0.21, 0, 0.1]]
UB = func_configs.UB # Upper boundaries, [[1, 1, 0.5]]
OUT_VARS = func_configs.OUT_VARS # Number of output variables (y-values)
TARGETS = func_configs.TARGETS # Target values for output
# threshold is same dims as TARGETS
# 0 = use target value as actual target. value should EQUAL target
# 1 = use as threshold. value should be LESS THAN OR EQUAL to target
# 2 = use as threshold. value should be GREATER THAN OR EQUAL to target
#DEFAULT THRESHOLD
THRESHOLD = np.zeros_like(TARGETS)
# optimizer constants
BITS_PER_VAR = 16 # Decode resolution per input dimension
# (grid has 2^BITS_PER_VAR - 1 steps per dim)
DELTA_THETA = 0.08 # Base qubit rotation angle in radians
# (annealed toward 0 over the run)
best_eval = 1
parent = None # for the optimizer test ONLY
evaluate_threshold = True # use target or threshold. True = THRESHOLD, False = EXACT TARGET
suppress_output = True # Suppress the console output of quantum register
allow_update = True # Allow objective call to update state
# Constant variables
opt_params = {'NO_OF_REGISTERS': [NO_OF_REGISTERS], # Number of registers in population
'BOUNDARY': [BOUNDARY], # int boundary 1 = random, 2 = reflecting
# 3 = absorbing, 4 = invisible
'BITS_PER_VAR': [BITS_PER_VAR], # Decode resolution per input dimension
'DELTA_THETA': [DELTA_THETA] } # Base qubit rotation angle (radians)
opt_df = pd.DataFrame(opt_params)
myOptimizer = swarm(LB, UB, TARGETS, TOL, MAXIT,
func_F, constr_F,
opt_df,
parent=parent,
evaluate_threshold=evaluate_threshold, obj_threshold=THRESHOLD,
decimal_limit = 4)
# arguments should take the form:
# swarm([[float, float, ...]], [[float, float, ...]], [[float, ...]], float, int,
# func, func,
# dataFrame,
# class obj,
# bool, [int, int, ...],
# int)
#
# opt_df contains class-specific tuning parameters
# NO_OF_REGISTERS: int
# boundary: int. 1 = random, 2 = reflecting, 3 = absorbing, 4 = invisible
# bits_per_var: int
# delta_theta: floatThe optimizer class is named qea and its method surface (step, call_objective, complete, get_convergence_data, export_swarm, import_swarm, and the getters) matches the rest of the suite, so existing drivers work with only the import line, the class name, and the opt_params tuning keys changed. The state-persistence methods keep their export_swarm/import_swarm names for interface consistency across the optimizer suite. Internally there is no swarm of moving points; the population is a set of quantum registers (see A note on NO_OF_REGISTERS above).
This optimizer uses a state machine structure to control the evolution of the quantum registers, the call to the objective function, and the evaluation of measured positions. The state machine implementation preserves the algorithm while making it possible to integrate other programs, classes, or functions as the objective function.
A controller with a while loop to check the completion status of the optimizer drives the process. Completion status is determined by at least 1) a set MAX number of iterations, and 2) the convergence to a given target using the L2 norm. Iterations are counted by calls to the objective function.
Within this while loop are three function calls to control the optimizer class:
- complete: the
complete functionchecks the status of the optimizer and if it has met the convergence or stop conditions. - step: the
step functiontakes a boolean variable (suppress_output) as an input to control detailed printout on current register (or agent) status. This function moves the optimizer one step forward. - call_objective: the
call_objective functiontakes a boolean variable (allow_update) to control if the objective function is able to be called. In most implementations, this value will always be true. However, there may be cases where the controller or a program running the state machine needs to assert control over this function without stopping the loop.
Additionally, get_convergence_data can be used to preview the current status of the optimizer, including the current best evaluation and the iterations.
The code below is an example of this process:
while not myOptimizer.complete():
# step through optimizer processing
# this will rotate and re-measure the registers
myOptimizer.step(suppress_output)
# call the objective function, control
# when it is allowed to update and return
# control to optimizer
myOptimizer.call_objective(allow_update)
# check the current progress of the optimizer
# iter: the number of objective function calls
# eval: current 'best' evaluation of the optimizer
iter, eval = myOptimizer.get_convergence_data()
if (eval < best_eval) and (eval != 0):
best_eval = eval
# optional. if the optimizer is not printing out detailed
# reports, preview by checking the iteration and best evaluation
if suppress_output:
if iter%100 ==0: #print out every 100th iteration update
print("Iteration")
print(iter)
print("Best Eval")
print(best_eval)This optimizer has two class-specific tuning parameters in opt_params, in addition to the shared NO_OF_REGISTERS and BOUNDARY:
| Parameter | Type | Description |
|---|---|---|
BITS_PER_VAR |
int | Number of qubits used to encode each input dimension. The decode grid has 2^BITS_PER_VAR - 1 steps per dimension, so this sets the finest achievable resolution. Higher values give more precision at the cost of more qubits (and more work per step). Total qubits per register are input_size * BITS_PER_VAR. |
DELTA_THETA |
float | Base qubit rotation angle in radians. Larger values search more aggressively but overshoot; smaller values refine slowly. The effective angle is annealed toward zero over the run. Values in the range 0.02 to 0.10 are a reasonable starting point. |
Two behaviors are worth knowing when tuning. First, final precision is bounded by BITS_PER_VAR: for a search range of width W, the smallest step is roughly W / (2^BITS_PER_VAR - 1), so if the reported best evaluation plateaus above the tolerance, increasing the bit count is usually the fix. Second, because measurement is a stochastic collapse, independent runs may converge to different optima (for example, different minima of Himmelblau's function). Seeding the generator (Generator(MT19937(seed)) in the class) makes runs reproducible for testing.
Some optimizer information can be exported or imported. This varies based on each optimizer.
Optimizer state can be exported at any step. When importing an optimizer state, the optimizer should be initialized first, and then the state information can be imported via a Python pickle file. Other methods can be used if custom code is written to handle preprocessing. For this optimizer, the exported state includes the full amplitude register Q along with BITS_PER_VAR, DELTA_THETA, and the shared best-position/fitness arrays, so a resumed run continues from the exact quantum state it left off in.
Returning data from optimizer and saving to a .pkl file:
data = demo_optimizer.export_swarm()
data_df = pd.DataFrame(data)
print(data_df)
data_df.to_pickle('output_data_df.pkl')Importing data from a .pkl file and importing it into the optimizer:
data_df = pd.read_pickle('output_data_df.pkl')
demo_optimizer.import_swarm(data_df)Users must create their own constraint function for their problems, if there are constraints beyond the problem bounds. This is then passed into the constructor. If the default constraint function is used, it always returns true (which means there are no constraints).
This optimizer has 4 different types of bounds: Random (registers that decode out of bounds are re-measured/respawned), Reflection, Absorb, and Invisible (out-of-bound registers are no longer evaluated). Because a gate-based register has no velocity, the Reflection and Absorb handlers restore the last valid location rather than inverting or zeroing a velocity component (which is what the classical PSO versions do).
Some updates have not incorporated appropriate handling for all boundary conditions. The most consistent boundary type at the moment is Random. If constraints are violated, but bounds are not, currently random bound rules are used to deal with this problem.
The no preference method of multi-objective optimization, but a Pareto Front is not calculated. Instead, the best choice (smallest norm of output vectors) is listed as the output.
The objective function is handled in two parts.
-
First, a defined function, such as one passed in from
func_F.py(see examples), is evaluated based on current measured positions. This allows for the optimizers to be utilized in the context of 1. benchmark functions from the objective function library, 2. user defined functions, 3. replacing explicitly defined functions with outside calls to programs such as simulations or other scripts that return a matrix of evaluated outputs. -
Secondly, the actual objective function is evaluated. In the AntennaCAT set of optimizers, the objective function evaluation is either a
TARGETorTHRESHOLDevaluation. For aTARGETevaluation, which is the default behavior, the optimizer minimizes the absolute value of the difference of the target outputs and the evaluated outputs. ATHRESHOLDevaluation includes boolean logic to determine if a 'greater than or equal to' or 'less than or equal to' or 'equal to' relation between the target outputs (or thresholds) and the evaluated outputs exist.
Future versions may include options for function minimization when target values are absent.
Custom objective functions can be used by creating a directory with the following files:
- configs_F.py
- constr_F.py
- func_F.py
configs_F.py contains lower bounds, upper bounds, the number of input variables, the number of output variables, the target values, and a global minimum if known. This file is used primarily for unit testing and evaluation of accuracy. If these values are not known, or are dynamic, then they can be included experimentally in the controller that runs the optimizer's state machine.
constr_F.py contains a function called constr_F that takes in an array, X, of measured positions to determine if the particle or agent is in a valid or invalid location.
func_F.py contains the objective function, func_F, which takes two inputs. The first input, X, is the array of particle or agent positions. The second input, NO_OF_OUTS, is the integer number of output variables, which is used to set the array size. In included objective functions, the default value is hardcoded to work with the specific objective function.
Below are examples of the format for these files.
configs_F.py:
OBJECTIVE_FUNC = func_F
CONSTR_FUNC = constr_F
OBJECTIVE_FUNC_NAME = "one_dim_x_test.func_F" #format: FUNCTION NAME.FUNCTION
CONSTR_FUNC_NAME = "one_dim_x_test.constr_F" #format: FUNCTION NAME.FUNCTION
# problem dependent variables
LB = [[0]] # Lower boundaries
UB = [[1]] # Upper boundaries
IN_VARS = 1 # Number of input variables (x-values)
OUT_VARS = 1 # Number of output variables (y-values)
TARGETS = [0] # Target values for output
GLOBAL_MIN = [] # Global minima sample, if they exist. constr_F.py, with no constraints:
def constr_F(x):
F = True
return Fconstr_F.py, with constraints:
def constr_F(X):
F = True
# objective function/problem constraints
if (X[2] > X[0]/2) or (X[2] < 0.1):
F = False
return Ffunc_F.py:
import numpy as np
import time
def func_F(X, NO_OF_OUTS=1):
F = np.zeros((NO_OF_OUTS))
noErrors = True
try:
x = X[0]
F = np.sin(5 * x**3) + np.cos(5 * x) * (1 - np.tanh(x ** 2))
except Exception as e:
print(e)
noErrors = False
return [F], noErrorsThere are three functions included in the repository:
- Himmelblau's function, which takes 2 inputs and has 1 output
- A multi-objective function with 3 inputs and 2 outputs (see lundquist_3_var)
- A single-objective function with 1 input and 1 output (see one_dim_x_test)
Each function has four files in a directory:
- configs_F.py - contains imports for the objective function and constraints, CONSTANT assignments for functions and labeling, boundary ranges, the number of input variables, the number of output values, and the target values for the output
- constr_F.py - contains a function with the problem constraints, both for the function and for error handling in the case of under/overflow.
- func_F.py - contains a function with the objective function.
- graph.py - contains a script to graph the function for visualization.
Other multi-objective functions can be applied to this project by following the same format (and several have been collected into a compatible library, and will be released in a separate repo)
Plotted Himmelblau’s Function with 3D Plot on the Left, and a 2D Contour on the Right
| Global Minima | Boundary | Constraints |
|---|---|---|
| f(3, 2) = 0 | ||
| f(-2.805118, 3.121212) = 0 | ||
| f(-3.779310, -3.283186) = 0 | ||
| f(3.584428, -1.848126) = 0 |
Plotted Multi-Objective Function Feasible Decision Space and Objective Space with Pareto Front
| Num. Input Variables | Boundary | Constraints |
|---|---|---|
| 3 |
|
|
Plotted Single Input, Single-objective Function Feasible Decision Space and Objective Space with Pareto Front
| Num. Input Variables | Boundary | Constraints |
|---|---|---|
| 1 |
Local minima at
Global minima at
An April 2025 feature is the user ability to toggle TARGET and THRESHOLD evaluation for the optimized values. The key variables for this are:
# Boolean. use target or threshold. True = THRESHOLD, False = EXACT TARGET
evaluate_threshold = True
# array
TARGETS = func_configs.TARGETS # Target values for output from function configs
# OR:
TARGETS = [0,0,0] #manually set BASED ON PROBLEM DIMENSIONS
# threshold is same dims as TARGETS
# 0 = use target value as actual target. value should EQUAL target
# 1 = use as threshold. value should be LESS THAN OR EQUAL to target
# 2 = use as threshold. value should be GREATER THAN OR EQUAL to target
#DEFAULT THRESHOLD
THRESHOLD = np.zeros_like(TARGETS)
# OR
THRESHOLD = [0,1,2] # can be any mix of TARGET and THRESHOLD To implement this, the original self.Flist objective function calculation has been replaced with the function objective_function_evaluation, which returns a numpy array.
The original calculation:
self.Flist = abs(self.targets - self.Fvals)Where self.Fvals is a re-arranged and error checked returned value from the passed in function from func_F.py (see examples for the internal objective function or creating a custom objective function).
When using a THRESHOLD, the Flist value corresponding to the target is set to epsilon (the smallest system value) if the evaluated func_F value meets the threshold condition for that target item. If the threshold is not met, the absolute value of the difference of the target output and the evaluated output is used. With a THRESHOLD configuration, each value in the numpy array is evaluated individually, so some values can be 'greater than or equal to' the target while others are 'equal' or 'less than or equal to' the target.
main_test.py provides a sample use case of the optimizer.
main_test_details.py provides an example using a parent class, and the self.suppress_output flag to control error messages that are passed back to the parent class to be printed with a timestamp. This implementation sets up the hooks for integration with AntennaCAT in order to provide the user feedback of warnings and errors.
main_test_graph.py provides an example using a parent class, and the self.suppress_output flag to control error messages that are passed back to the parent class to be printed with a timestamp. Additionally, a realtime graph shows the measured register locations at every step.
NOTE: if you close the graph as the code is running, the code will continue to run, but the graph will not re-open.
[1] K.-H. Han and J.-H. Kim, "Quantum-inspired evolutionary algorithm for a class of combinatorial optimization," IEEE Transactions on Evolutionary Computation, vol. 6, no. 6, pp. 580-593, Dec. 2002, doi: 10.1109/TEVC.2002.804320.
[2] J. Sun, B. Feng and W. Xu, "Particle swarm optimization with particles having quantum behavior," Proceedings of the 2004 Congress on Evolutionary Computation, Portland, OR, USA, 2004, pp. 325-331 Vol.1, doi: 10.1109/CEC.2004.1330875.
[3] J. Kennedy and R. Eberhart, "Particle swarm optimization," Proceedings of ICNN'95 - International Conference on Neural Networks, Perth, WA, Australia, 1995, pp. 1942-1948 vol.4, doi: 10.1109/ICNN.1995.488968.
This software works as a stand-alone implementation, and as one of the optimizers integrated into AntennaCAT. It is a companion to pso_quantum (the delta-well QPSO method) and pso_python / pso_basic (the classical PSO the state-machine template is drawn from).
This implementation isn't meant to be a serious replacement for Qiskit or other quantum themed optimizers. It's an interesting bit of literature, and the mechanics are neat.
The code in this repository has been released under GPL-2.0


