Parametric circuits#

Quantum circuits with variable parameters play an important role in some quantum algorithms, especially variational algorithms. QURI Parts treats such circuits in a special way so that such algorithms can be efficiently performed.

Prerequisite#

QURI Parts modules used in this tutorial: quri-parts-circuit, quri-parts-core and quri-parts-qulacs. You can install them as follows:

[ ]:
!pip install "quri-parts[qulacs]"

Parametric quantum circuit#

“Parametric quantum circuit” and “parametric quantum gate” are often used to mean a circuit and a gate with some parameters (usually real numbers) for both of the following two cases: the parameters are not bound, i.e., no specific values are assigned to them, or the parameters are bound to specific values. In QURI Parts, the term “parametric” basically means the former case. We use a term “unbound” when we want to make it clear.

Parameter#

An unbound parameter in a parametric circuit is represented by a quri_parts.circuit.Parameter class. A Parameter object works as a placeholder for the parameter and does not hold any specific value. Identity of a Parameter object is determined by identity of it as a Python object. Even if two Parameter objects have the same name, they are treated as different parameters:

[1]:
from quri_parts.circuit import Parameter, CONST

phi = Parameter("phi")
psi1 = Parameter("psi")
psi2 = Parameter("psi2")

# CONST is a pre-defined parameter that represents a constant.
print(phi, psi1, psi2, CONST)
print("phi == psi1:", phi == psi1)
print("psi1 == psi2:", psi1 == psi2)
print("phi == CONST:", phi == CONST)
Parameter(name=phi) Parameter(name=psi) Parameter(name=psi2) Parameter(name=)
phi == psi1: False
psi1 == psi2: False
phi == CONST: False

Although a Parameter can be created as above, you don’t usually need to do it yourself since parameters are managed by parametric circuit objects.

Unbound parametric quantum circuit: common usage#

There are several types of unbound parametric quantum circuits, which will be described in the following sections. Here we describe common usage of them.

[2]:
# This part shows one way to create an unbound parametric circuit.
# Details will be described in the following sections.
from quri_parts.circuit import UnboundParametricQuantumCircuit
parametric_circuit = UnboundParametricQuantumCircuit(2)
parametric_circuit.add_H_gate(0)
parametric_circuit.add_CNOT_gate(0, 1)
param1 = parametric_circuit.add_ParametricRX_gate(0)
param2 = parametric_circuit.add_ParametricRZ_gate(1)

An unbound parametric circuit object has some properties in common with a usual (non-parametric) circuit object, and also some additional properties:

[3]:
print("Qubit count:", parametric_circuit.qubit_count)
print("Circuit depth:", parametric_circuit.depth)

# A parametric circuit object does not have .gates property.

print("Parameter count:", parametric_circuit.parameter_count)

# Non-parametric gates can be added in the same way as QuantumCircuit:
parametric_circuit.add_X_gate(1)
print("Circuit depth:", parametric_circuit.depth)
Qubit count: 2
Circuit depth: 3
Parameter count: 2
Circuit depth: 4

You can copy or get a frozen version of an unbound parametric circuit:

[4]:
frozen_parametric_circuit = parametric_circuit.freeze()
copied_parametric_circuit = parametric_circuit.get_mutable_copy()

You can bind specific values to parameters in a parametric circuit by .bind_parameters method. This method does not modify the original parametric circuit object but returns a newly created circuit object:

[5]:
bound_circuit = parametric_circuit.bind_parameters([0.2, 0.3])
# bound_circuit is an immutable version of a usual circuit object, which has .gates property.
for gate in bound_circuit.gates:
    print(gate)
QuantumGate(name='H', target_indices=(0,), control_indices=(), params=(), pauli_ids=())
QuantumGate(name='CNOT', target_indices=(1,), control_indices=(0,), params=(), pauli_ids=())
QuantumGate(name='RX', target_indices=(0,), control_indices=(), params=(0.2,), pauli_ids=())
QuantumGate(name='RZ', target_indices=(1,), control_indices=(), params=(0.3,), pauli_ids=())
QuantumGate(name='X', target_indices=(1,), control_indices=(), params=(), pauli_ids=())

UnboundParametricQuantumCircuit and LinearMappedUnboundParametricQuantumCircuit#

QURI Parts currently provides two types of parametric circuits: UnboundParametricQuantumCircuit and LinearMappedUnboundParametricQuantumCircuit. Here we introduce the former one.

UnboundParametricQuantumCircuit represents an unbound parametric circuit where each parametric gate in it has its own parameter independent from other parameters. You can add each parametric gate as the following:

[6]:
from quri_parts.circuit import UnboundParametricQuantumCircuit
parametric_circuit = UnboundParametricQuantumCircuit(2)
parametric_circuit.add_H_gate(0)
parametric_circuit.add_CNOT_gate(0, 1)
param1 = parametric_circuit.add_ParametricRX_gate(0)
param2 = parametric_circuit.add_ParametricRZ_gate(1)
print("param1 == param2:", param1 == param2)
param1 == param2: False

A parametric gate is added by .add_Parametric{}_gate methods (here {} should be replaced by a specific gate name) and a newly created parameter is returned. This parametric circuit corresponds to a gate sequence \([\mathrm{H}_0, \mathrm{CNOT}_{0, 1}, \mathrm{RX}(\theta)_0, \mathrm{RZ}(\phi)_1]\), where \(\theta\) and \(\phi\) are independent parameters. You can bind two values to those two parameters independently.

[7]:
bound_circuit = parametric_circuit.bind_parameters([0.2, 0.3])
for gate in bound_circuit.gates:
    print(gate)
QuantumGate(name='H', target_indices=(0,), control_indices=(), params=(), pauli_ids=())
QuantumGate(name='CNOT', target_indices=(1,), control_indices=(0,), params=(), pauli_ids=())
QuantumGate(name='RX', target_indices=(0,), control_indices=(), params=(0.2,), pauli_ids=())
QuantumGate(name='RZ', target_indices=(1,), control_indices=(), params=(0.3,), pauli_ids=())

It is often the case where you want multiple parametric gates in a parametric circuit to depend on the same parameters via some functions. LinearMappedUnboundParametricQuantumCircuit supports this use case (only for linear dependency). For example, suppose we want to define a parametric circuit with two independent parameters \(\theta\) and \(\phi\), whose gate sequence is \([\mathrm{H}_0, \mathrm{CNOT}_{0, 1}, \mathrm{RX}(\theta/2+\phi/3+\pi/2)_0, \mathrm{RZ}(\theta/3-\phi/2-\pi/2)_1]\). Such a parametric circuit can be constructed as:

[8]:
from math import pi
from quri_parts.circuit import LinearMappedUnboundParametricQuantumCircuit, CONST

linear_param_circuit = LinearMappedUnboundParametricQuantumCircuit(2)
linear_param_circuit.add_H_gate(0)
linear_param_circuit.add_CNOT_gate(0, 1)

theta, phi = linear_param_circuit.add_parameters("theta", "phi")
linear_param_circuit.add_ParametricRX_gate(0, {theta: 1/2, phi: 1/3, CONST: pi/2})
linear_param_circuit.add_ParametricRZ_gate(1, {theta: 1/3, phi: -1/2, CONST: -pi/2})

To add parametric gates to a LinearMappedUnboundParametricQuantumCircuit, you need to define independent parameters for the circuit beforehand by .add_parameters method. Then you can specify a parameter of a parametric gate as a dictionary in which a key is a circuit parameter and a value is its coefficient. quri_parts.circuit.CONST is used to represent a constant term (i.e. CONST is substituted with 1 when binding parameter values). You can bind two values to the two circuit parameters:

[9]:
bound_linear_circuit = linear_param_circuit.bind_parameters([0.2, 0.3])
for gate in bound_linear_circuit.gates:
    print(gate)
QuantumGate(name='H', target_indices=(0,), control_indices=(), params=(), pauli_ids=())
QuantumGate(name='CNOT', target_indices=(1,), control_indices=(0,), params=(), pauli_ids=())
QuantumGate(name='RX', target_indices=(0,), control_indices=(), params=(1.7707963267948965,), pauli_ids=())
QuantumGate(name='RZ', target_indices=(1,), control_indices=(), params=(-1.6541296601282298,), pauli_ids=())

Parametric state and operator expectation estimation#

A GeneralCircuitQuantumState can be created from a circuit obtained by binding values to a parametric circuit and an Estimator can be used to estimate an expectation value of an operator for the state:

[10]:
from quri_parts.core.state import GeneralCircuitQuantumState

circuit = parametric_circuit.bind_parameters([0.2, 0.3])
circuit_state = GeneralCircuitQuantumState(2, circuit)

from quri_parts.core.operator import Operator, pauli_label
op = Operator({
    pauli_label("X0 Y1"): 0.5 + 0.5j,
    pauli_label("Z0 X1"): 0.2,
})

from quri_parts.qulacs.estimator import create_qulacs_vector_estimator
estimator = create_qulacs_vector_estimator()

estimate = estimator(op, circuit_state)
print(f"Estimated expectation value: {estimate.value}")
Estimated expectation value: (0.15950226366943507+0.14776010333066977j)

However, it is more straightforward to use ParametricCircuitQuantumState. It has a few benefits:

  • It makes it clear that the state is parametric and possible to treat parameter-related problems in terms of quantum state (e.g. gradient of an expectation value for the state with respect to its parameters).

  • It may improve performance for some circuit simulators (e.g. Qulacs).

In order to estimate an expectation value of an operator for a parametric state, you can use ParametricQuantumEstimator interface:

[11]:
from quri_parts.core.state import ParametricCircuitQuantumState

parametric_state = ParametricCircuitQuantumState(2, parametric_circuit)

# Create a parametric estimator using Qulacs, which implements ParametricQuantumEstimator interface.
from quri_parts.qulacs.estimator import create_qulacs_vector_parametric_estimator
parametric_estimator = create_qulacs_vector_parametric_estimator()

estimate = parametric_estimator(op, parametric_state, [0.2, 0.3])
print(f"Estimated expectation value: {estimate.value}")
Estimated expectation value: (0.15950226366943507+0.14776010333066977j)

There is also a ConcurrentParametricQuantumEstimator interface in the same way as a (non-parametric) Estimator. Especially the parametric Estimator for Qulacs provides performance improvement compared to using an Estimator with states obtained by binding parameter values each time.

[12]:
from quri_parts.qulacs.estimator import create_qulacs_vector_concurrent_parametric_estimator
# Here we don't specify executor and concurrency arguments, but it still brings performance improvement
concurrent_parametric_estimator = create_qulacs_vector_concurrent_parametric_estimator()

estimates = concurrent_parametric_estimator(op, parametric_state, [[0.2, 0.3], [0.4, 0.5]])
for i, est in enumerate(estimates):
    print(f"Parameters {i}: value={est.value}")
Parameters 0: value=(0.15950226366943507+0.14776010333066977j)
Parameters 1: value=(0.2770521890028376+0.23971276930210147j)