Skip to content

Commit 727ed48

Browse files
Copilotfgfuchs
andcommitted
Add MaxCutFree problem and XOrbit mixer; update MixerComparison notebook with correct free and orbit ansätze
Co-authored-by: fgfuchs <2428162+fgfuchs@users.noreply.github.com>
1 parent f60a1da commit 727ed48

File tree

6 files changed

+335
-35
lines changed

6 files changed

+335
-35
lines changed

examples/MaxCut/MixerComparison.ipynb

Lines changed: 78 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@
44
"cell_type": "markdown",
55
"metadata": {},
66
"source": [
7-
"## QAOA Mixer Comparison: Vanilla, Free, and Orbit\n",
7+
"## QAOA Ansatz Comparison: Vanilla, Free, and Orbit\n",
88
"\n",
9-
"This notebook compares three QAOA variants on the **house graph** (5 nodes, 6 edges):\n",
9+
"This notebook compares three QAOA ansätze on the **house graph** (5 nodes, 6 edges).\n",
1010
"\n",
11-
"| Variant | Problem | Mixer | \u03b3 / layer | \u03b2 / layer |\n",
12-
"|---------|---------|-------|-----------|----------|\n",
11+
"| Ansatz | Problem | Mixer | γ / layer | β / layer |\n",
12+
"|--------|---------|-------|-----------|----------|\n",
1313
"| **Vanilla** | `MaxCut` | `X` | 1 | 1 |\n",
14-
"| **Free** | `MaxCut` | `XMultiAngle` | 1 | N (one per qubit) |\n",
15-
"| **Orbit** | `MaxCutOrbit` | `X` | K (one per edge orbit) | 1 |\n",
14+
"| **Free** | `MaxCutFree` | `XMultiAngle` | E (one per edge) | N (one per node) |\n",
15+
"| **Orbit** | `MaxCutOrbit` | `XOrbit` | K_E (one per edge orbit) | K_N (one per node orbit) |\n",
1616
"\n",
17-
"- **Vanilla QAOA** uses the standard X mixer with a single shared \u03b3 and \u03b2 per layer.\n",
18-
"- **Free QAOA** (multi-angle) gives each qubit its own independent \u03b2, increasing expressibility.\n",
19-
"- **Orbit QAOA** exploits the graph's automorphism group: edges in the same symmetry orbit share a \u03b3 parameter, enabling the optimizer to differentiate structurally distinct edge types without the full multi-angle overhead."
17+
"### Definitions\n",
18+
"\n",
19+
"- **Vanilla QAOA** uses a single shared γ for all edges and a single β for all nodes. It is the original QAOA proposal.\n",
20+
"\n",
21+
"- **Free QAOA** (multi-angle) maximises the number of independent parameters: each edge gets its own γ and each node gets its own β. There is no parameter sharing whatsoever.\n",
22+
"\n",
23+
"- **Orbit QAOA** (following [arXiv:2410.05187](https://arxiv.org/abs/2410.05187)) exploits the automorphism group Aut(G) of the graph to construct an *equivariant* ansatz. Edges related by a graph automorphism share one γ, and nodes related by a graph automorphism share one β. This is the unique parameterisation that is both equivariant and maximally expressive within the equivariant subspace."
2024
]
2125
},
2226
{
@@ -49,7 +53,7 @@
4953
"source": [
5054
"### House Graph\n",
5155
"\n",
52-
"The house graph has 5 nodes and 6 edges arranged as a square with a triangular roof."
56+
"The *house graph* has 5 nodes and 6 edges: a square base with a triangular roof."
5357
]
5458
},
5559
{
@@ -74,9 +78,9 @@
7478
"cell_type": "markdown",
7579
"metadata": {},
7680
"source": [
77-
"### Compute the Optimal Cut\n",
81+
"### Optimal Max Cut\n",
7882
"\n",
79-
"Brute-force the minimum cost (maximum cut) so we can calculate approximation ratios."
83+
"Brute-force the minimum QAOA cost (= maximum cut value) for reference."
8084
]
8185
},
8286
{
@@ -86,22 +90,23 @@
8690
"outputs": [],
8791
"source": [
8892
"problem_ref = problems.MaxCut(G)\n",
89-
"min_cost, max_cost = problem_ref.computeMinMaxCosts()\n",
90-
"# cost() returns a positive value; the QAOA minimizes its negative\n",
91-
"mincost = -min_cost # most negative expectation value = best cut\n",
93+
"min_cost, _ = problem_ref.computeMinMaxCosts()\n",
94+
"# cost() is positive; QAOA minimises its negative\n",
95+
"mincost = -min_cost # most negative expectation = best cut\n",
9296
"maxcost = 0\n",
9397
"\n",
9498
"print(f\"Maximum cut value : {-mincost}\")\n",
95-
"print(f\"mincost used for approximation ratio: {mincost}\")"
99+
"print(f\"mincost (for approximation ratio): {mincost}\")"
96100
]
97101
},
98102
{
99103
"cell_type": "markdown",
100104
"metadata": {},
101105
"source": [
102-
"### Edge Orbits of the House Graph\n",
106+
"### Graph Symmetry Analysis\n",
103107
"\n",
104-
"The orbit QAOA assigns one \u03b3 parameter per edge orbit of the graph's automorphism group."
108+
"The orbit QAOA uses the automorphism group Aut(G) to assign shared parameters.\n",
109+
"Below we display the **node orbits** (for the β mixer parameters) and **edge orbits** (for the γ problem parameters)."
105110
]
106111
},
107112
{
@@ -111,16 +116,25 @@
111116
"outputs": [],
112117
"source": [
113118
"orbit_problem = problems.MaxCutOrbit(G)\n",
114-
"print(f\"Number of edge orbits: {orbit_problem.get_num_parameters()}\")\n",
115-
"for i, orbit in enumerate(orbit_problem.edge_orbits):\n",
116-
" print(f\" Orbit {i}: {orbit}\")"
119+
"orbit_mixer = mixers.XOrbit(G)\n",
120+
"\n",
121+
"print(f\"Node orbits ({len(orbit_mixer.node_orbits)} total):\")\n",
122+
"for i, orb in enumerate(orbit_mixer.node_orbits):\n",
123+
" print(f\" β_{i}: nodes {orb}\")\n",
124+
"\n",
125+
"print()\n",
126+
"print(f\"Edge orbits ({len(orbit_problem.edge_orbits)} total):\")\n",
127+
"for i, orb in enumerate(orbit_problem.edge_orbits):\n",
128+
" print(f\" γ_{i}: edges {orb}\")"
117129
]
118130
},
119131
{
120132
"cell_type": "markdown",
121133
"metadata": {},
122134
"source": [
123-
"### Create QAOA Instances"
135+
"### Create QAOA Instances\n",
136+
"\n",
137+
"Three instances with different parameter budgets per layer:"
124138
]
125139
},
126140
{
@@ -129,7 +143,34 @@
129143
"metadata": {},
130144
"outputs": [],
131145
"source": [
132-
"# Vanilla QAOA: X mixer, single \u03b3 and \u03b2 per layer\nqaoa_vanilla = QAOA(\n problem=problems.MaxCut(G),\n mixer=mixers.X(),\n initialstate=initialstates.Plus(),\n)\n\n# Free QAOA: XMultiAngle mixer, one \u03b2 per qubit\nqaoa_free = QAOA(\n problem=problems.MaxCut(G),\n mixer=mixers.XMultiAngle(),\n initialstate=initialstates.Plus(),\n)\n\n# Orbit QAOA: X mixer, one \u03b3 per edge orbit of the graph\nqaoa_orbit = QAOA(\n problem=problems.MaxCutOrbit(G),\n mixer=mixers.X(),\n initialstate=initialstates.Plus(),\n)\n\n# Build circuits at depth 1 to inspect parameter counts\nfor q in (qaoa_vanilla, qaoa_free, qaoa_orbit):\n q.createParameterizedCircuit(depth=1)\n\nprint(f\"Vanilla \u2014 \u03b3/layer: {qaoa_vanilla.n_gamma}, \u03b2/layer: {qaoa_vanilla.n_beta}\")\nprint(f\"Free \u2014 \u03b3/layer: {qaoa_free.n_gamma}, \u03b2/layer: {qaoa_free.n_beta}\")\nprint(f\"Orbit \u2014 \u03b3/layer: {qaoa_orbit.n_gamma}, \u03b2/layer: {qaoa_orbit.n_beta}\")"
146+
"# Vanilla: one shared γ and one shared β\n",
147+
"qaoa_vanilla = QAOA(\n",
148+
" problem=problems.MaxCut(G),\n",
149+
" mixer=mixers.X(),\n",
150+
" initialstate=initialstates.Plus(),\n",
151+
")\n",
152+
"\n",
153+
"# Free: one γ per edge, one β per node\n",
154+
"qaoa_free = QAOA(\n",
155+
" problem=problems.MaxCutFree(G),\n",
156+
" mixer=mixers.XMultiAngle(),\n",
157+
" initialstate=initialstates.Plus(),\n",
158+
")\n",
159+
"\n",
160+
"# Orbit: one γ per edge orbit, one β per node orbit (arXiv:2410.05187)\n",
161+
"qaoa_orbit = QAOA(\n",
162+
" problem=problems.MaxCutOrbit(G),\n",
163+
" mixer=mixers.XOrbit(G),\n",
164+
" initialstate=initialstates.Plus(),\n",
165+
")\n",
166+
"\n",
167+
"# Build circuits at depth 1 to inspect parameter counts\n",
168+
"for q in (qaoa_vanilla, qaoa_free, qaoa_orbit):\n",
169+
" q.createParameterizedCircuit(depth=1)\n",
170+
"\n",
171+
"print(f\"Vanilla — γ/layer: {qaoa_vanilla.n_gamma}, β/layer: {qaoa_vanilla.n_beta}\")\n",
172+
"print(f\"Free — γ/layer: {qaoa_free.n_gamma} (1 per edge), β/layer: {qaoa_free.n_beta} (1 per node)\")\n",
173+
"print(f\"Orbit — γ/layer: {qaoa_orbit.n_gamma} (1 per edge orbit), β/layer: {qaoa_orbit.n_beta} (1 per node orbit)\")"
133174
]
134175
},
135176
{
@@ -138,7 +179,8 @@
138179
"source": [
139180
"### Energy Landscape at Depth 1 (Vanilla)\n",
140181
"\n",
141-
"Sample the energy landscape over (\u03b3, \u03b2) for the vanilla instance."
182+
"Sample the cost landscape over the (γ, β) grid for the vanilla instance. \n",
183+
"This grid search also provides the starting point for deeper optimisation."
142184
]
143185
},
144186
{
@@ -152,15 +194,18 @@
152194
"\n",
153195
"fig = plt.figure(figsize=(6, 5))\n",
154196
"plot_E(qaoa_vanilla, fig=fig)\n",
155-
"plt.title(\"Vanilla QAOA \u2014 energy landscape (depth 1)\")\n",
197+
"plt.title(\"Vanilla QAOA energy landscape (depth 1)\")\n",
156198
"plt.show()"
157199
]
158200
},
159201
{
160202
"cell_type": "markdown",
161203
"metadata": {},
162204
"source": [
163-
"### Run Optimization"
205+
"### Run Optimisation\n",
206+
"\n",
207+
"Optimise all three ansätze up to `maxdepth = 5`. \n",
208+
"Each depth is initialised using the interpolation heuristic from the previous depth."
164209
]
165210
},
166211
{
@@ -194,26 +239,26 @@
194239
"plot_ApproximationRatio(\n",
195240
" qaoa_vanilla, maxdepth,\n",
196241
" mincost=mincost, maxcost=maxcost,\n",
197-
" label=\"Vanilla (X mixer)\",\n",
242+
" label=f\"Vanilla (1γ + 1β)\",\n",
198243
" style=\"o--b\",\n",
199244
" fig=fig,\n",
200245
")\n",
201246
"plot_ApproximationRatio(\n",
202247
" qaoa_free, maxdepth,\n",
203248
" mincost=mincost, maxcost=maxcost,\n",
204-
" label=\"Free (XMultiAngle mixer)\",\n",
249+
" label=f\"Free ({qaoa_free.n_gamma}γ + {qaoa_free.n_beta}β, one per edge/node)\",\n",
205250
" style=\"s--r\",\n",
206251
" fig=fig,\n",
207252
")\n",
208253
"plot_ApproximationRatio(\n",
209254
" qaoa_orbit, maxdepth,\n",
210255
" mincost=mincost, maxcost=maxcost,\n",
211-
" label=\"Orbit (MaxCutOrbit + X mixer)\",\n",
256+
" label=f\"Orbit ({qaoa_orbit.n_gamma}γ + {qaoa_orbit.n_beta}β, one per orbit)\",\n",
212257
" style=\"^--g\",\n",
213258
" fig=fig,\n",
214259
")\n",
215260
"\n",
216-
"plt.title(\"Approximation ratio vs. depth \u2014 House graph\")\n",
261+
"plt.title(\"Approximation ratio vs. depth House graph\")\n",
217262
"plt.tight_layout()\n",
218263
"plt.show()"
219264
]
@@ -224,9 +269,7 @@
224269
"source": [
225270
"### Optimal Angles at Maximum Depth (Vanilla)\n",
226271
"\n",
227-
"`plot_angles` expects the standard 1 \u03b3 + 1 \u03b2 per-layer format. \n",
228-
"We display it for the vanilla variant; the free and orbit instances use a\n",
229-
"different layout (multiple \u03b3 or \u03b2 per layer)."
272+
"`plot_angles` expects the 1 γ + 1 β per-layer format, so we display it only for the vanilla ansatz."
230273
]
231274
},
232275
{
@@ -237,7 +280,7 @@
237280
"source": [
238281
"fig = plt.figure()\n",
239282
"plot_angles(qaoa_vanilla, maxdepth, label=\"Vanilla\", style=\"ob\", fig=fig)\n",
240-
"plt.title(f\"Optimal angles \u2014 Vanilla QAOA, depth {maxdepth}\")\n",
283+
"plt.title(f\"Optimal angles Vanilla QAOA, depth {maxdepth}\")\n",
241284
"plt.tight_layout()\n",
242285
"plt.show()"
243286
]
@@ -256,4 +299,4 @@
256299
},
257300
"nbformat": 4,
258301
"nbformat_minor": 4
259-
}
302+
}

qaoa/mixers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .xy_mixer import XY
33
from .x_mixer import X
44
from .x_multiangle_mixer import XMultiAngle
5+
from .x_orbit_mixer import XOrbit
56
from .grover_mixer import Grover
67
from .xy_tensor import XYTensor
78
from .maxkcut_grover_mixer import MaxKCutGrover

qaoa/mixers/x_orbit_mixer.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
from collections import defaultdict
2+
3+
import networkx as nx
4+
from networkx.algorithms.isomorphism import GraphMatcher
5+
from qiskit import QuantumCircuit, QuantumRegister
6+
from qiskit.circuit import Parameter
7+
8+
from .base_mixer import Mixer
9+
from qaoa.util import GraphHandler
10+
11+
12+
class XOrbit(Mixer):
13+
"""
14+
X mixer with one independent rotation angle per node orbit.
15+
16+
Uses the automorphism group of the graph to identify structurally
17+
equivalent nodes (qubits). Nodes in the same orbit under the graph's
18+
automorphism group share a single :math:`\\beta` parameter, implementing
19+
the orbit-equivariant mixer described in *arXiv:2410.05187*.
20+
21+
Combined with :class:`~qaoa.problems.MaxCutOrbit` (one :math:`\\gamma`
22+
per edge orbit), this gives the orbit QAOA ansatz that is equivariant
23+
under the full symmetry group of the graph.
24+
25+
Attributes:
26+
node_orbits (list[list]): List of node groups; each group contains
27+
all nodes that belong to the same automorphism orbit.
28+
node_to_orbit (dict): Mapping from a canonical node label to its
29+
orbit index.
30+
31+
Args:
32+
G (nx.Graph): The graph whose node orbits define the parameter
33+
sharing. A :class:`~qaoa.util.GraphHandler` is used internally
34+
to obtain the same canonical node ordering as the problem circuit.
35+
"""
36+
37+
def __init__(self, G: nx.Graph) -> None:
38+
"""
39+
Initialises the XOrbit mixer.
40+
41+
Args:
42+
G (nx.Graph): The input graph used to compute node orbits.
43+
"""
44+
super().__init__()
45+
graph_handler = GraphHandler(G)
46+
self._canonical_G = graph_handler.G
47+
self._compute_node_orbits()
48+
49+
# ------------------------------------------------------------------
50+
# Orbit computation
51+
# ------------------------------------------------------------------
52+
53+
def _compute_node_orbits(self) -> None:
54+
"""
55+
Compute node orbits of ``self._canonical_G`` under its automorphism
56+
group.
57+
58+
Sets:
59+
self.node_orbits: list of node-lists, one list per orbit.
60+
self.node_to_orbit: dict mapping each canonical node label to
61+
its orbit index.
62+
"""
63+
G = self._canonical_G
64+
# Use sorted node list so indices are stable
65+
nodes: list = sorted(G.nodes())
66+
n_nodes = len(nodes)
67+
68+
# --- Union-Find -----------------------------------------------
69+
parent = list(range(n_nodes))
70+
71+
def find(x: int) -> int:
72+
while parent[x] != x:
73+
parent[x] = parent[parent[x]]
74+
x = parent[x]
75+
return x
76+
77+
def union(x: int, y: int) -> None:
78+
rx, ry = find(x), find(y)
79+
if rx != ry:
80+
parent[rx] = ry
81+
82+
node_to_idx: dict[int, int] = {v: i for i, v in enumerate(nodes)}
83+
84+
# --- Enumerate automorphisms and union equivalent nodes --------
85+
# Note: for graphs with large automorphism groups the enumeration can
86+
# be expensive. For typical QAOA problem graphs (tens of nodes) this
87+
# is fast; for highly symmetric graphs (e.g. complete graphs) the
88+
# number of automorphisms can be n! and you may want to limit
89+
# iterations via early termination once all nodes are merged.
90+
gm = GraphMatcher(G, G)
91+
for auto in gm.isomorphisms_iter():
92+
for idx, v in enumerate(nodes):
93+
mapped_v = auto[v]
94+
mapped_idx = node_to_idx[mapped_v]
95+
union(idx, mapped_idx)
96+
# Early exit: if all nodes are already in one orbit, stop
97+
if len({find(i) for i in range(n_nodes)}) == 1:
98+
break
99+
100+
# --- Group nodes by orbit root ---------------------------------
101+
orbit_groups: dict[int, list] = defaultdict(list)
102+
for idx in range(n_nodes):
103+
orbit_groups[find(idx)].append(nodes[idx])
104+
105+
self.node_orbits: list[list] = list(orbit_groups.values())
106+
107+
# Build node → orbit index map
108+
self.node_to_orbit: dict[int, int] = {}
109+
for orbit_idx, orbit_nodes in enumerate(self.node_orbits):
110+
for v in orbit_nodes:
111+
self.node_to_orbit[v] = orbit_idx
112+
113+
# ------------------------------------------------------------------
114+
# Overrides
115+
# ------------------------------------------------------------------
116+
117+
def get_num_parameters(self) -> int:
118+
"""
119+
Returns the number of :math:`\\beta` parameters per layer.
120+
121+
One parameter is used per node orbit of the graph.
122+
123+
Returns:
124+
int: Number of node orbits (≥ 1).
125+
"""
126+
return len(self.node_orbits)
127+
128+
def create_circuit(self) -> None:
129+
"""
130+
Constructs the orbit-equivariant X mixer circuit.
131+
132+
Each qubit (node) :math:`i` receives an RX rotation whose parameter
133+
is shared with all nodes in the same automorphism orbit. Parameters
134+
are named ``x_beta_orbit_0``, ``x_beta_orbit_1``, … (zero-padded so
135+
alphabetical ordering matches orbit index order).
136+
"""
137+
n_orbits = self.get_num_parameters()
138+
n_digits = len(str(n_orbits - 1)) if n_orbits > 1 else 1
139+
orbit_params = [
140+
Parameter(f"x_beta_orbit_{i:0{n_digits}d}") for i in range(n_orbits)
141+
]
142+
143+
# Stable node ordering (sorted integers) matches qubit indices
144+
nodes: list = sorted(self._canonical_G.nodes())
145+
146+
q = QuantumRegister(self.N_qubits)
147+
self.circuit = QuantumCircuit(q)
148+
149+
for qubit_idx, v in enumerate(nodes):
150+
orbit_idx = self.node_to_orbit[v]
151+
self.circuit.rx(-2 * orbit_params[orbit_idx], q[qubit_idx])

0 commit comments

Comments
 (0)