Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[unitaryHACK] Controlling the Insertion of Multi-Qubit Gates in the Generation of Random Circuits #12059 #12483

Merged
merged 14 commits into from
Jun 7, 2024
Merged
123 changes: 100 additions & 23 deletions qiskit/circuit/random/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@


def random_circuit(
num_qubits, depth, max_operands=4, measure=False, conditional=False, reset=False, seed=None
num_qubits,
depth,
max_operands=4,
measure=False,
conditional=False,
reset=False,
seed=None,
num_operand_distribution: dict = None,
):
"""Generate random circuit of arbitrary size and form.

Expand All @@ -44,18 +51,49 @@ def random_circuit(
conditional (bool): if True, insert middle measurements and conditionals
reset (bool): if True, insert middle resets
seed (int): sets random seed (optional)
num_operand_distribution (dict): a distribution of gates that specifies the ratio
of 1-qubit, 2-qubit, 3-qubit, ..., n-qubit gates in the random circuit. Expect a
deviation from the specified ratios that depends on the size of the requested
random circuit. (optional)

Returns:
QuantumCircuit: constructed circuit

Raises:
CircuitError: when invalid options given
"""
if seed is None:
seed = np.random.randint(0, np.iinfo(np.int32).max)
rng = np.random.default_rng(seed)

if num_operand_distribution:
if min(num_operand_distribution.keys()) < 1 or max(num_operand_distribution.keys()) > 4:
raise CircuitError("'num_operand_distribution' must have keys between 1 and 4")
for key, prob in num_operand_distribution.items():
if key > num_qubits and prob != 0.0:
raise CircuitError(
f"'num_operand_distribution' cannot have {key}-qubit gates"
f" for circuit with {num_qubits} qubits"
)
num_operand_distribution = dict(sorted(num_operand_distribution.items()))

if not num_operand_distribution and max_operands:
if max_operands < 1 or max_operands > 4:
raise CircuitError("max_operands must be between 1 and 4")
max_operands = max_operands if num_qubits > max_operands else num_qubits
sbrandhsn marked this conversation as resolved.
Show resolved Hide resolved
rand_dist = rng.dirichlet(
np.ones(max_operands)
) # This will create a random distribution that sums to 1
num_operand_distribution = {i + 1: rand_dist[i] for i in range(max_operands)}
num_operand_distribution = dict(sorted(num_operand_distribution.items()))

# Here we will use np.isclose() because very rarely there might be floating
# point precision errors
if not np.isclose(sum(num_operand_distribution.values()), 1):
raise CircuitError("The sum of all the values in 'num_operand_distribution' is not 1.")

if num_qubits == 0:
return QuantumCircuit()
if max_operands < 1 or max_operands > 4:
raise CircuitError("max_operands must be between 1 and 4")
max_operands = max_operands if num_qubits > max_operands else num_qubits

gates_1q = [
# (Gate class, number of qubits, number of parameters)
Expand Down Expand Up @@ -119,47 +157,87 @@ def random_circuit(
(standard_gates.RC3XGate, 4, 0),
]

gates = gates_1q.copy()
if max_operands >= 2:
gates.extend(gates_2q)
if max_operands >= 3:
gates.extend(gates_3q)
if max_operands >= 4:
gates.extend(gates_4q)
gates = np.array(
gates, dtype=[("class", object), ("num_qubits", np.int64), ("num_params", np.int64)]
gates_1q = np.array(
gates_1q, dtype=[("class", object), ("num_qubits", np.int64), ("num_params", np.int64)]
)
gates_1q = np.array(gates_1q, dtype=gates.dtype)
gates_2q = np.array(gates_2q, dtype=gates_1q.dtype)
gates_3q = np.array(gates_3q, dtype=gates_1q.dtype)
gates_4q = np.array(gates_4q, dtype=gates_1q.dtype)

all_gate_lists = [gates_1q, gates_2q, gates_3q, gates_4q]

# Here we will create a list 'gates_to_consider' that will have a
# subset of different n-qubit gates and will also create a list for
# ratio (or probability) for each gates
gates_to_consider = []
distribution = []
for n_qubits, ratio in num_operand_distribution.items():
gate_list = all_gate_lists[n_qubits - 1]
gates_to_consider.extend(gate_list)
distribution.extend([ratio / len(gate_list)] * len(gate_list))

gates = np.array(gates_to_consider, dtype=gates_1q.dtype)

qc = QuantumCircuit(num_qubits)

if measure or conditional:
cr = ClassicalRegister(num_qubits, "c")
qc.add_register(cr)

if seed is None:
seed = np.random.randint(0, np.iinfo(np.int32).max)
rng = np.random.default_rng(seed)

qubits = np.array(qc.qubits, dtype=object, copy=True)

# Counter to keep track of number of different gate types
counter = np.zeros(len(all_gate_lists) + 1, dtype=np.int64)
total_gates = 0

# Apply arbitrary random operations in layers across all qubits.
for layer_number in range(depth):
# We generate all the randomness for the layer in one go, to avoid many separate calls to
# the randomisation routines, which can be fairly slow.

# This reliably draws too much randomness, but it's less expensive than looping over more
# calls to the rng. After, trim it down by finding the point when we've used all the qubits.
gate_specs = rng.choice(gates, size=len(qubits))

# Due to the stochastic nature of generating a random circuit, the resulting ratios
# may not precisely match the specified values from `num_operand_distribution`. Expect
# greater deviations from the target ratios in quantum circuits with fewer qubits and
# shallower depths, and smaller deviations in larger and deeper quantum circuits.
# For more information on how the distribution changes with number of qubits and depth
# refer to the pull request #12483 on Qiskit GitHub.

gate_specs = rng.choice(gates, size=len(qubits), p=distribution)
cumulative_qubits = np.cumsum(gate_specs["num_qubits"], dtype=np.int64)

# Efficiently find the point in the list where the total gates would use as many as
# possible of, but not more than, the number of qubits in the layer. If there's slack, fill
# it with 1q gates.
max_index = np.searchsorted(cumulative_qubits, num_qubits, side="right")
gate_specs = gate_specs[:max_index]

slack = num_qubits - cumulative_qubits[max_index - 1]
if slack:
gate_specs = np.hstack((gate_specs, rng.choice(gates_1q, size=slack)))

# Updating the counter for 1-qubit, 2-qubit, 3-qubit and 4-qubit gates
gate_qubits = gate_specs["num_qubits"]
counter += np.bincount(gate_qubits, minlength=len(all_gate_lists) + 1)

total_gates += len(gate_specs)

# Slack handling loop, this loop will add gates to fill
# the slack while respecting the 'num_operand_distribution'
while slack > 0:
gate_added_flag = False

for key, dist in sorted(num_operand_distribution.items(), reverse=True):
if slack >= key and counter[key] / total_gates < dist:
gate_to_add = np.array(
all_gate_lists[key - 1][rng.integers(0, len(all_gate_lists[key - 1]))]
)
gate_specs = np.hstack((gate_specs, gate_to_add))
counter[key] += 1
total_gates += 1
slack -= key
gate_added_flag = True
if not gate_added_flag:
break

# For efficiency in the Python loop, this uses Numpy vectorisation to pre-calculate the
# indices into the lists of qubits and parameters for every gate, and then suitably
Expand Down Expand Up @@ -202,7 +280,6 @@ def random_circuit(
):
operation = gate(*parameters[p_start:p_end])
qc._append(CircuitInstruction(operation=operation, qubits=qubits[q_start:q_end]))

if measure:
qc.measure(qc.qubits, cr)

Expand Down
21 changes: 21 additions & 0 deletions releasenotes/notes/extended-random-circuits-049b67cce39003f4.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
features_circuits:
- |
The `random_circuit` function from `qiskit.circuit.random.utils` has a new feature where
users can specify a distribution `num_operand_distribution` (a dict) that specifies the
ratio of 1-qubit, 2-qubit, 3-qubit, and 4-qubit gates in the random circuit. For example,
if `num_operand_distribution = {1: 0.25, 2: 0.25, 3: 0.25, 4: 0.25}` is passed to the function
then the generated circuit will have approximately 25% of 1-qubit, 2-qubit, 3-qubit, and
4-qubit gates (The order in which the dictionary is passed does not matter i.e. you can specify
`num_operand_distribution = {3: 0.5, 1: 0.0, 4: 0.3, 2: 0.2}` and the function will still work
as expected). Also it should be noted that the if `num_operand_distribution` is not specified
then `max_operands` will default to 4 and a random circuit with a random gate distribution will
be generated. If both `num_operand_distribution` and `max_operands` are specified at the same
time then `num_operand_distribution` will be used to generate the random circuit.
Example usage::

from qiskit.circuit.random import random_circuit

circ = random_circuit(num_qubits=6, depth=5, num_operand_distribution = {1: 0.25, 2: 0.25, 3: 0.25, 4: 0.25})
circ.draw(output='mpl')

81 changes: 80 additions & 1 deletion test/python/circuit/test_random_circuit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@


"""Test random circuit generation utility."""
import numpy as np
from qiskit.circuit import QuantumCircuit, ClassicalRegister, Clbit
from qiskit.circuit import Measure
from qiskit.circuit.random import random_circuit
Expand Down Expand Up @@ -71,7 +72,7 @@ def test_large_conditional(self):
def test_random_mid_circuit_measure_conditional(self):
"""Test random circuit with mid-circuit measurements for conditionals."""
num_qubits = depth = 2
circ = random_circuit(num_qubits, depth, conditional=True, seed=4)
circ = random_circuit(num_qubits, depth, conditional=True, seed=16)
self.assertEqual(circ.width(), 2 * num_qubits)
op_names = [instruction.operation.name for instruction in circ]
# Before a condition, there needs to be measurement in all the qubits.
Expand All @@ -81,3 +82,81 @@ def test_random_mid_circuit_measure_conditional(self):
bool(getattr(instruction.operation, "condition", None)) for instruction in circ
]
self.assertEqual([False, False, False, True], conditions)

def test_random_circuit_num_operand_distribution(self):
"""Test that num_operand_distribution argument generates gates in correct proportion"""
num_qubits = 50
depth = 300
num_op_dist = {2: 0.25, 3: 0.25, 1: 0.25, 4: 0.25}
circ = random_circuit(
num_qubits, depth, num_operand_distribution=num_op_dist, seed=123456789
)
total_gates = circ.size()
self.assertEqual(circ.width(), num_qubits)
self.assertEqual(circ.depth(), depth)
gate_qubits = [instruction.operation.num_qubits for instruction in circ]
gate_type_counter = np.bincount(gate_qubits, minlength=5)
for gate_type, prob in sorted(num_op_dist.items()):
self.assertAlmostEqual(prob, gate_type_counter[gate_type] / total_gates, delta=0.1)

def test_random_circuit_2and3_qubit_gates_only(self):
"""
Test that the generated random circuit only has 2 and 3 qubit gates,
while disallowing 1-qubit and 4-qubit gates if
num_operand_distribution = {2: some_prob, 3: some_prob}
"""
num_qubits = 10
depth = 200
num_op_dist = {2: 0.5, 3: 0.5}
circ = random_circuit(num_qubits, depth, num_operand_distribution=num_op_dist, seed=200)
total_gates = circ.size()
gate_qubits = [instruction.operation.num_qubits for instruction in circ]
gate_type_counter = np.bincount(gate_qubits, minlength=5)
# Testing that the distribution of 2 and 3 qubit gate matches with given distribution
for gate_type, prob in sorted(num_op_dist.items()):
self.assertAlmostEqual(prob, gate_type_counter[gate_type] / total_gates, delta=0.1)
# Testing that there are no 1-qubit gate and 4-qubit in the generated random circuit
self.assertEqual(gate_type_counter[1], 0.0)
self.assertEqual(gate_type_counter[4], 0.0)

def test_random_circuit_3and4_qubit_gates_only(self):
"""
Test that the generated random circuit only has 3 and 4 qubit gates,
while disallowing 1-qubit and 2-qubit gates if
num_operand_distribution = {3: some_prob, 4: some_prob}
"""
num_qubits = 10
depth = 200
num_op_dist = {3: 0.5, 4: 0.5}
circ = random_circuit(
num_qubits, depth, num_operand_distribution=num_op_dist, seed=11111111
)
total_gates = circ.size()
gate_qubits = [instruction.operation.num_qubits for instruction in circ]
gate_type_counter = np.bincount(gate_qubits, minlength=5)
# Testing that the distribution of 3 and 4 qubit gate matches with given distribution
for gate_type, prob in sorted(num_op_dist.items()):
self.assertAlmostEqual(prob, gate_type_counter[gate_type] / total_gates, delta=0.1)
# Testing that there are no 1-qubit gate and 2-qubit in the generated random circuit
self.assertEqual(gate_type_counter[1], 0.0)
self.assertEqual(gate_type_counter[2], 0.0)

def test_random_circuit_with_zero_distribution(self):
"""
Test that the generated random circuit only has 3 and 4 qubit gates,
while disallowing 1-qubit and 2-qubit gates if
num_operand_distribution = {1: 0.0, 2: 0.0, 3: some_prob, 4: some_prob}
"""
num_qubits = 10
depth = 200
num_op_dist = {1: 0.0, 2: 0.0, 3: 0.5, 4: 0.5}
circ = random_circuit(num_qubits, depth, num_operand_distribution=num_op_dist, seed=12)
total_gates = circ.size()
gate_qubits = [instruction.operation.num_qubits for instruction in circ]
gate_type_counter = np.bincount(gate_qubits, minlength=5)
# Testing that the distribution of 3 and 4 qubit gate matches with given distribution
for gate_type, prob in sorted(num_op_dist.items()):
self.assertAlmostEqual(prob, gate_type_counter[gate_type] / total_gates, delta=0.1)
# Testing that there are no 1-qubit gate and 2-qubit in the generated random circuit
self.assertEqual(gate_type_counter[1], 0.0)
self.assertEqual(gate_type_counter[2], 0.0)
Loading