Solve the Sherrington-Kirkpatrick model with the ParityQC Parity Twine Optimizer
Usage estimate: 10 seconds on a Nighthawk r2 processor. (NOTE: This is an estimate only. Your runtime may vary.)
Learning outcomes
- Use the Parity Twine Optimizer to solve the Sherrington-Kirkpatrick model.
- Learn which options for the Parity Twine Optimizer are available and what results are output.
Background
This tutorial demonstrates how to solve the Sherrington-Kirkpatrick model using the ParityQC Parity Twine Optimizer.
It provides code to formulate the problem entirely locally in a form that can interface with the Parity Twine Optimizer.
The Sherrington-Kirkpatrick model
The Sherrington-Kirkpatrick (SK) model is a foundational model in statistical mechanics, specifically within the study of spin glasses. Unlike the standard Ising model, where interactions are usually limited to nearest neighbors, the SK model is an infinite-range model, meaning every spin interacts with every other spin in the system. This leads to a highly complex, "rugged", energy landscape characterized by many local minima, which is the hallmark of glassy behavior.
The core feature of the SK model lies in frustration. In the model, interaction strengths between spins are randomly distributed between positive and negative values. This leads to situations (for example, in triangular arrangements) where spins cannot be arranged to minimize all interactions simultaneously. In the SK model, because every spin interacts with every other spin, this frustration is compounded globally, leading to a web of conflicting constraints.
Mathematical formulation
The state of the system is defined by a set of Ising spins, . The energy of a specific configuration is given by the Hamiltonian:
where is the coupling strength between spin and spin .
In the SK model, the couplings are independent and identically distributed random variables. To ensure that the energy remains extensive (proportional to ) as , the variance of the couplings must scale with the number of particles:
The ground state for a given is the specific configuration of spins (), which minimizes the energy. Finding the ground state is an NP-hard optimization problem.
Requirements
Before starting this tutorial, ensure the following are installed:
- Quantum Functions Catalog IBM Client (
pip install qiskit-ibm-catalog) - Qiskit addon Optimization Mapper (
pip install qiskit_addon_opt_mapper) - NumPy (
pip install numpy)
You also need permission to access the ParityQC Twine Optimizer function. To request access, complete this form.
Setup
(This code assumes you've already saved your account to your local environment.)
First, import all required packages for this tutorial.
import numpy as np
from qiskit_ibm_catalog import FunctionsCatalogLoad the Parity Twine Optimizer from the quantum functions catalog:
catalog = FunctionsCatalog(channel="ibm_quantum_platform")
function = catalog.load("parityqc/parity-twine-optimizer")Step 1: Define the problem as an objective function
Instead of obtaining the SK problem from a library as we do for the market split problem, you formulate it directly.
The generate_sk_problem function formulates the SK problem directly in the required dictionary format. The only input required is n, the number of spins in the model.
def generate_sk_problem(
n: int,
coupling_mean: float = 0.0,
coupling_std: float = 1.0,
local_fields_mean: float = 0.0,
local_fields_std: float = 0.0,
edge_density: float = 1.0,
ensure_extensivity: bool = False,
seed: int | None = None,
) -> dict:
"""Generate the Sherrington-Kirkpatrick (SK) model with varying
edge density.
Samples couplings and local fields via ``generate_couplings_sk_model``
and assembles the corresponding Ising Hamiltonian
H = -∑_{i<j} J_ij z_i z_j - ∑_i h_i z_i,
where z_i ∈ {-1, +1}.
Args:
n: Number of spins (>= 2).
coupling_mean: Mean coupling before optional SK scaling.
coupling_std: Coupling std before optional SK scaling.
local_fields_mean: Mean longitudinal field.
local_fields_std: Std of the longitudinal fields.
edge_density: Fraction of non-zero couplings, in ``[2/n, 1]``.
ensure_extensivity: Whether to apply the SK 1/n scaling.
seed: random number generator seed.
Returns:
An encoding the SK Hamiltonian.
Raises:
ValueError: If ``n < 2``, ``coupling_std < 0``, ``local_fields_std < 0``,
or ``edge_density`` is outside ``[2/n, 1]``.
"""
couplings, local_fields = _generate_couplings_sk_model(
n=n,
coupling_mean=coupling_mean,
coupling_std=coupling_std,
local_fields_mean=local_fields_mean,
local_fields_std=local_fields_std,
edge_density=edge_density,
ensure_extensivity=ensure_extensivity,
seed=seed,
)
# Handle quadratic terms: coupling[i, j] * zj[i] * zj[j]
# Only iterate over the upper triangle (i < j)
sk_problem = {
str((i, j)): float(couplings[i, j])
for i in range(n)
for j in range(i + 1, n)
if couplings[i, j] != 0
}
# Handle linear terms: local_fields[i] * zj[i]
sk_problem.update(
{
str((i,)): float(local_fields[i])
for i in range(n)
if local_fields[i] != 0
}
)
return sk_problemUse the function _generate_couplings_sk_model to calculate the random coupling terms in the SK model. For more control over the couplings, you can use optional arguments, which are explained in the function's docstring.
def _generate_couplings_sk_model(
n: int,
coupling_mean: float = 0.0,
coupling_std: float = 1.0,
local_fields_mean: float = 0.0,
local_fields_std: float = 0.0,
edge_density: float = 1.0,
ensure_extensivity: bool = False,
seed: int | None = None,
) -> tuple[np.ndarray, np.ndarray]:
"""Generate random couplings and local fields for an Ising / SK model.
Couplings are Gaussian. With ``ensure_extensivity=True`` they follow the
Sherrington-Kirkpatrick scaling ``J_ij ~ N(coupling_mean/n, coupling_std^2/n)``
(extensive energy, O(n)); otherwise ``J_ij ~ N(coupling_mean, coupling_std^2)``
(energy O(n^2)). Fields are ``h_i ~ N(local_fields_mean, local_fields_std^2)``.
``edge_density`` sets the fraction of the ``n*(n-1)/2`` possible couplings that
are non-zero (1 = fully dense). The kept edges always include a random spanning
tree, so the interaction graph is guaranteed connected. This requires at least
``n-1`` edges, so ``edge_density`` must be at least ``2/n``.
Args:
n: Number of spins (>= 2).
coupling_mean: Mean coupling before optional SK scaling.
coupling_std: Coupling std before optional SK scaling.
local_fields_mean: Mean longitudinal field.
local_fields_std: Std of the longitudinal fields.
edge_density: Fraction of non-zero couplings, in ``[2/n, 1]``.
ensure_extensivity: Whether to apply the SK 1/n scaling.
seed: random number generator seed.
Returns:
Tuple ``(couplings, fields)``: a symmetric ``(n, n)`` matrix with zero
diagonal, and an ``(n,)`` field vector.
Raises:
ValueError: If ``n < 2``, ``coupling_std < 0``, ``local_fields_std < 0``,
or ``edge_density`` is outside ``[2/n, 1]``.
"""
if n < 2:
raise ValueError(f"n must be >= 2, got {n}")
if coupling_std < 0 or local_fields_std < 0:
raise ValueError(
"coupling_std and local_fields_std must be non-negative"
)
# A connected graph on n nodes needs at least n-1 of the n*(n-1)/2 possible
# edges, so edge_density has a hard lower bound of 2/n.
min_edge_density = 2.0 / n
if not min_edge_density <= edge_density <= 1.0:
raise ValueError(
f"edge_density must be in [{min_edge_density:.4g}, 1] for n={n} "
f"(at least n-1 edges are needed to keep the graph connected), "
f"got {edge_density}"
)
rng = np.random.default_rng(seed)
j_loc, j_scale = (
(coupling_mean / n, coupling_std / np.sqrt(n))
if ensure_extensivity
else (coupling_mean, coupling_std)
)
upper_idx = np.triu_indices(n, k=1)
n_edges = len(upper_idx[0])
# Select which edges are present.
if edge_density < 1.0:
n_keep = int(round(edge_density * n_edges))
# Map each (i, j) node pair to its position in the flat upper-triangle list.
pair_to_flat = {
(int(i), int(j)): idx
for idx, (i, j) in enumerate(
zip(upper_idx[0], upper_idx[1], strict=False)
)
}
# Random spanning tree: node perm[k] links to a random earlier node.
perm = rng.permutation(n)
keep = np.zeros(n_edges, dtype=bool)
for k in range(1, n):
child, parent = perm[k], perm[rng.integers(0, k)]
i, j = min(child, parent), max(child, parent)
keep[pair_to_flat[(int(i), int(j))]] = True
# Fill the remaining budget with random non-tree edges.
remaining = n_keep - (n - 1)
if remaining > 0:
keep[
rng.choice(
np.flatnonzero(~keep), size=remaining, replace=False
)
] = True
else:
keep = np.ones(n_edges, dtype=bool)
n_present = int(keep.sum())
if j_scale == 0.0:
vals = np.full(n_present, j_loc)
else:
vals = rng.normal(loc=j_loc, scale=j_scale, size=n_present)
couplings = np.zeros((n, n))
couplings[upper_idx[0][keep], upper_idx[1][keep]] = vals
couplings += couplings.T # symmetrize; diagonal stays zero
fields = (
np.full(n, local_fields_mean)
if local_fields_std == 0.0
else rng.normal(loc=local_fields_mean, scale=local_fields_std, size=n)
)
return couplings, fieldsStep 2: Calculate the function size
The SK model is defined by , the number of interacting spins. Choose and
generate the problem for n_spins:
n_spins = 50
sk_problem = generate_sk_problem(n_spins)Every job runs at one function size: S, M, or L. The size comes from two
properties of the model: how many variables it has, and how densely they interact (the
fraction of all variable pairs that appear together in a term). If the size is too
small, the job is rejected with error 4600. If it is larger than needed, the job runs
on a bigger compute profile than the work requires. A job that does not request a size
runs at M. See Function sizes in the guide for the criteria of each
size.
The following cell defines the size calculator from the guide. It reads the problem and does nothing else: no job is submitted and nothing is reserved.
# Calculate the function size a model needs, before submitting it.
# Call ``calculate_function_size(problem)``: it returns "S", "M" or "L" and submits
# nothing. The size comes from the number of variables and how densely they interact.
import ast
from collections.abc import Iterator
from itertools import combinations
from typing import Any, Literal
from qiskit_addon_opt_mapper.problems import OptimizationProblem
Size = Literal["S", "M", "L"]
VariableType = Literal["spin", "binary"]
_ORDER = {"S": 0, "M": 1, "L": 2}
# Limits are provided by IBM
_SMALL_NUMVARS, _SMALL_DENSITY = 50, 0.1
_MEDIUM_NUMVARS, _MEDIUM_DENSITY = 100, 0.25
def calculate_function_size(
problem: dict[str, float] | OptimizationProblem, **kwargs: Any
) -> Size:
"""Return the size ("S", "M" or "L") needed to run ``problem``."""
metrics = _measure(problem, **kwargs)
if _is_small(metrics):
return "S"
if _is_medium(metrics):
return "M"
return "L"
def to_optimization_problem(
problem: dict[str, float] | OptimizationProblem,
problem_type: str = "",
variable_type: VariableType = "binary",
) -> OptimizationProblem:
"""Convert the Parity Twine Optimizer ``problem`` input into an
``OptimizationProblem``.
Args:
problem: A dictionary {term: coefficient} where each term is a string
version of a tuple of variable ids, for example
{"()": 3, "(0,)": 1, "(0, 1)": 2}. An ``OptimizationProblem`` is
returned unchanged.
problem_type: The ``problem_type`` passed to the function, for
example "mis".
variable_type: Type of all variables, either "spin" or "binary".
Returns:
The parsed problem, as a minimization without constraints.
"""
if isinstance(problem, OptimizationProblem):
return problem
if variable_type not in ("spin", "binary"):
raise ValueError(
f"Invalid variable type {variable_type!r}, expected 'spin' or 'binary'."
)
constant: float = 0
linear_terms: dict[str, float] = {}
quadratic_terms: dict[tuple[str, str], float] = {}
variables: set[str] = set()
for raw_term, coefficient in problem.items():
try:
parsed = ast.literal_eval(raw_term)
except (ValueError, SyntaxError) as exc:
raise ValueError(
f"Invalid problem term {raw_term!r}: expected a tuple literal like '(0, 1)'."
) from exc
if not isinstance(parsed, tuple):
raise ValueError(
f"Invalid problem term {raw_term!r}: expected a tuple literal like '(0, 1)'."
)
term = tuple(map(str, parsed))
if len(term) == 0:
constant = coefficient
elif len(term) == 1:
linear_terms[term[0]] = coefficient
elif len(term) == 2:
quadratic_terms[term] = coefficient
else:
raise ValueError(
"Invalid problem term, only quadratic problems are "
f"supported, higher-order term: {term}"
)
variables.update(term)
model = OptimizationProblem()
model.name = problem_type
for variable in sorted(variables):
if variable_type == "spin":
model.spin_var(variable)
else:
model.binary_var(variable)
model.minimize(
constant=constant, linear=linear_terms, quadratic=quadratic_terms
)
return model
def _measure(
problem: dict[str, float] | OptimizationProblem, **kwargs: Any
) -> dict[str, float]:
"""Return the number of variables and the interaction density."""
model = to_optimization_problem(
problem,
problem_type=kwargs.get("problem_type", ""),
variable_type=kwargs.get("variable_type", "binary"),
)
num_vars = model.get_num_vars()
pairs = set()
for term in _interactions(model):
pairs.update(frozenset(pair) for pair in combinations(set(term), 2))
max_pairs = num_vars * (num_vars - 1) // 2
return {
"num_vars": num_vars,
"density": len(pairs) / max_pairs if max_pairs else 0.0,
}
def _interactions(problem: OptimizationProblem) -> Iterator[tuple[int, ...]]:
"""Yield the variables of every term that couples two or more of them.
A constraint contributes every variable in it, because on hardware it becomes
a penalty whose square couples them all.
"""
objective = problem.objective
yield from objective.quadratic.to_dict()
for expression in objective.higher_order.values():
yield from expression.to_dict()
for constraint in problem.linear_constraints:
yield _constraint_support(constraint)
for constraint in problem.quadratic_constraints:
yield _constraint_support(constraint)
for constraint in problem.higher_order_constraints:
yield _constraint_support(constraint)
def _constraint_support(constraint: Any) -> tuple[int, ...]:
"""Return every variable appearing in a constraint's left-hand side."""
variables = set(constraint.linear.to_dict())
if hasattr(constraint, "quadratic"):
for term in constraint.quadratic.to_dict():
variables.update(term)
if hasattr(constraint, "higher_order"):
for expression in constraint.higher_order.values():
for term in expression.to_dict():
variables.update(term)
return tuple(variables)
def _is_small(metrics: dict[str, float]) -> bool:
"""Determine if the metrics fit T-size 'S'."""
return (
metrics["num_vars"] <= _SMALL_NUMVARS
and metrics["density"] <= _SMALL_DENSITY
)
def _is_medium(metrics: dict[str, float]) -> bool:
"""Determine if the metrics fit T-size 'M'."""
return (
metrics["num_vars"] <= _MEDIUM_NUMVARS
and metrics["density"] <= _MEDIUM_DENSITY
)Calculate the size of the problem:
size = calculate_function_size(sk_problem)
print(size)Every spin in the SK model interacts with every other spin, so the interaction density
is always 1.0 and the model needs size L for any .
Step 3: Solve the problem using the Parity Twine Optimizer
With the SK problem and its size, you can find a solution by using the Twine Optimizer and a chosen IBM Quantum® backend.
To run the function, choose a suitable backend; for example, ibm_phoenix.
Optionally, use options for additional control over the submission:
options = {
"shots": 100000,
"postprocessing_level": 1,
"transpile_only": False,
"job_tags": ["sk"],
}where shots is an integer that specifies the number of circuit executions, postprocessing_level determines if post-processing is applied to the result,
transpile_only chooses if the problem is only transpiled to a circuit (and not solved), and job_tags is label to identify the job on IBM Quantum Platform.
Run the optimizer, passing the calculated size:
function_job = function.run(
problem=sk_problem,
variable_type="spin",
backend_name="ibm_phoenix",
options=options,
function_size=size,
)
print(f"Job ID: {function_job.job_id}")Check the job status:
# Monitor the job status
function_job.status()Retrieve results:
result = function_job.result()
resultThe result is of form:
{
'solution': {'0': 1, '1': 1, '10': 1, '11': 1, ... },
'objective_value': -240.5425312543882,
'solution_bitstring': '00001101110100100111001110101001101111111011001110',
'metadata': {
'circuit_metrics': {
'depth': 523,
'gate_count': 10118,
'two_qubit_gate_depth': 196,
'two_qubit_gate_count': 2499,
'num_qubits': 50,
'operations': {'sx': 3353, 'rz': 3320, 'cz': 2499, 'delay': 894, 'measure': 50, 'x': 2},
},
'solver_info': {
'variable_mapping': {'0': 0, '1': 1, '10': 2, '11': 3, ... },
'bitstring_distributions': {
'before_postprocessing': {'011101110010110111001110011000': 1, ... },
'after_postprocessing': {'011011110000110101001111011000': 1, ... }
},
'best_parameters': {
'beta': [-0.4602084830507902],
'gamma': [1.8500357096574955]
}
},
'resource_usage': {
'RUNNING: MAPPING': {'CPU_TIME': 290.272},
'RUNNING: OPTIMIZING_FOR_HARDWARE': {'CPU_TIME': 0.494},
'RUNNING: WAITING_FOR_QPU': {'CPU_TIME': 8.775},
'RUNNING: EXECUTING_QPU': {'QPU_TIME': 31.0},
'RUNNING: POST_PROCESSING': {'CPU_TIME': 162.96},
},
}
}
where the solution dictionary corresponds to the qubits defined in the problem and gives their optimized spin values for the SK model Hamiltonian.
This specific sequence of spins in the optimal solution represents the configuration that minimizes the total system energy based on the given random interaction strengths.
In the SK model, this can be regarded as the lowest energy state of a disordered magnetic system.
metadata gives information on the transpilation (two qubit gates counts/depth, gates used, active qubits) and various runtimes.
Next steps
- Request access to the function by completing this form.
- Visit the API reference for this quantum function.
- Read the guide.
- Try the tutorial for applying the Parity Twine Optimizer to the market split problem.
- Review the Connectivity-aware Synthesis of Quantum Algorithms, Drier et al. (2025) ArXiv preprint.