Repository: Qiskit/qiskit (Python)

Issue:
Controlling the Insertion of Multi-Qubit Gates in the Generation of Random Circuits
### What should we add?

The [generation of random circuits](https://github.com/Qiskit/qiskit/blob/main/qiskit/circuit/random/utils.py) should be extended by the ability to specify a distribution `num_operand_distribution` that specifies the ratio of  1-qubit, 2-qubit, ..., n-qubit gates in the random circuit. For instance, `random_circuit` with `num_operand_distribution={1: 0.5, 2:0.5}` should yield random circuits with roughly 50% single-qubit quantum gates and 50% two-qubit gates while disallowing n-qubit gates with n>2. 

The parameter `max_operands` should take precedence, i.e. if `max_operands` is set, draw a random distribution `num_operand_distribution`. Raise an error if `sum(num_operand_distribution.values()) != 1.0` or if `max_operands` is set at the same time as `num_operand_distribution` or if any key in `num_operand_distribution` is larger than the largest available n-qubit gate (see `get_standard_gate_name_mapping`). 

Please also add tests to validate your changes and add a release note according to https://github.com/Qiskit/qiskit/blob/main/CONTRIBUTING.md 

Note that this is a subtask of #11718. :-) 


Discussion: This issue is participating on [UnitaryHack 2024](https://unitaryhack.dev/projects/qiskit/), between May 29 and June 12, 2024.

Because the nature of the event, there is no need to request to be assigned: just go ahead and PR your fix. The first/best PR can get the bounty (or it could be shared if they complement each other). 



Fix (patch):
diff --git a/qiskit/circuit/random/utils.py b/qiskit/circuit/random/utils.py
index fc497a300cba..f27cbfbfca88 100644
--- a/qiskit/circuit/random/utils.py
+++ b/qiskit/circuit/random/utils.py
@@ -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.
 
@@ -44,6 +51,10 @@ 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
@@ -51,11 +62,38 @@ def random_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
+        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)
@@ -119,17 +157,26 @@ 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)
 
@@ -137,29 +184,60 @@ def random_circuit(
         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
@@ -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)
 
diff --git a/releasenotes/notes/extended-random-circuits-049b67cce39003f4.yaml b/releasenotes/notes/extended-random-circuits-049b67cce39003f4.yaml
new file mode 100644
index 000000000000..f4bb585053bc
--- /dev/null
+++ b/releasenotes/notes/extended-random-circuits-049b67cce39003f4.yaml
@@ -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')
+