Expressibility and Entanglement Capability of the Parameterized Quantum Circuits
# !pip3 install qiskit qiskit-aer
import numpy as np
import scipy as sp
import qiskit as qk
import qiskit_aer as aer
Today we will learn about how to compare different ansatz structure by measuring their expressibility and entanglement capabilities. Things covered in this blog is inspired from [1].
Designing Ansatze¶
Ansatze are simply a parameterized quantum circuits (PQC), which play an essential role in the performance of many variational hybrid quantum-classical (HQC) algorithms. Major challenge while designing an asatz is to choose an effective template circuit that well represents the solution space while maintaining a low circuit depth and number of parameters. Here, we make a choice of two ansatze, one randomly and another inspired from the given hint.
Ansatz 1 (Random Choice)¶
def ansatz1(params, num_qubits):
"""
Generate an templated ansatz with given parameters
Args:
params (array[float]): Parameters to initialize the parameterized unitary.
num_qubits (int): Number of qubits in the circuit.
Returns:
ansatz (qiskit.QuantumCircuit): Generated ansatz circuit
"""
ansatz = qk.QuantumCircuit(num_qubits, num_qubits)
params = params.reshape(2,2)
for idx in range(num_qubits):
ansatz.rx(params[0][idx], idx)
ansatz.cx(0, 1)
for idx in range(num_qubits):
ansatz.rz(params[1][idx], idx)
return ansatz
ansatz1(np.random.uniform(-np.pi, np.pi, (2,2)), 2).draw()
┌─────────────┐ ┌────────────┐ q_0: ┤ Rx(-2.2727) ├──■───┤ Rz(2.8575) ├ └┬────────────┤┌─┴─┐┌┴────────────┤ q_1: ─┤ Rx(2.9026) ├┤ X ├┤ Rz(0.99914) ├ └────────────┘└───┘└─────────────┘ c: 2/═══════════════════════════════════
Ansatz 2 (Structured Choice)¶
def ansatz2(params, num_qubits):
"""
Generate an templated ansatz with given parameters
Args:
params (array[float]): Parameters to initialize the parameterized unitary.
num_qubits (int): Number of qubits in the circuit.
Returns:
ansatz (qiskit.QuantumCircuit): Generated ansatz circuit
"""
params = np.array(params).reshape(1)
ansatz = qk.QuantumCircuit(num_qubits, num_qubits)
ansatz.h(0)
ansatz.cx(0, 1)
ansatz.rx(params[0], 0)
return ansatz
ansatz2([np.pi], 2).draw()
┌───┐ ┌───────┐ q_0: ┤ H ├──■──┤ Rx(π) ├ └───┘┌─┴─┐└───────┘ q_1: ─────┤ X ├───────── └───┘ c: 2/═══════════════════
Ansatz 3 (Empty Circuit)¶
def ansatz3(params, num_qubits):
"""
Generate an templated ansatz with no parameters
Args:
params (array[float]): Parameters to initialize the parameterized unitary.
num_qubits (int): Number of qubits in the circuit.
Returns:
ansatz (QuantumCircuit): Generated ansatz circuit
"""
ansatz = qk.QuantumCircuit(num_qubits, num_qubits)
return ansatz
ansatz3((), 2).draw()
q_0: q_1: c: 2/
Checking Expressibility of Ansatze¶
We quantify expressibility of ansatze using the Hilbert-Schmidt norm of $A$ defined as:
$$A = \int_{\text{Haar}} |\psi\rangle\langle\psi| d\psi - \int_{\theta} |\psi_\theta\rangle\langle\psi_\theta| d\theta \tag{1}$$This quantity needs to be taken with a pinch of salt as it is an oversimplification of the which actually has to be calculated with the definition of an $\epsilon$-approximate $t$-state-design [1].
Here, the first term, i.e. a Haar integral, is the integral over a group of unitaries distributed randomly according to the Haar measure. Whereas, the second term, is taken over all states over the measure induced by uniformly sampling the parameters of the PQC.
def random_unitary(N):
"""
Return a Haar distributed random unitary from U(N)
"""
Z = np.random.randn(N, N) + 1.0j * np.random.randn(N, N)
[Q, R] = sp.linalg.qr(Z)
D = np.diag(np.diagonal(R) / np.abs(np.diagonal(R)))
return np.dot(Q, D)
def haar_integral(num_qubits, samples):
"""
Return calculation of Haar Integral for a specified number of samples.
"""
N = 2**num_qubits
randunit_density = np.zeros((N, N), dtype=complex)
zero_state = np.zeros(N, dtype=complex)
zero_state[0] = 1
for _ in range(samples):
A = np.matmul(zero_state, random_unitary(N)).reshape(-1,1)
randunit_density += np.kron(A, A.conj().T)
randunit_density/=samples
return randunit_density
def pqc_integral(num_qubits, ansatze, size, samples):
"""
Return calculation of Integral for a PQC over the uniformly sampled
the parameters θ for the specified number of samples.
"""
N = num_qubits
randunit_density = np.zeros((2**N, 2**N), dtype=complex)
backend = aer.StatevectorSimulator()
for _ in range(samples):
params = np.random.uniform(-np.pi, np.pi, size)
ansatz = ansatze(params, N)
result = backend.run(ansatz).result()
U = result.get_statevector(ansatz, decimals=5).data.reshape(-1,1)
randunit_density += np.kron(U, U.conj().T)
return randunit_density/samples
# Sanity Check
print(np.linalg.norm(haar_integral(2, 10000) - haar_integral(2, 10000)))
0.012572817703360996
Results for Ansatz¶
params_shape = [(2, 2), 1, 0]
for idx, ansatz in enumerate([ansatz1, ansatz2, ansatz3]):
print(f"Result for ansatz{idx+1} : {np.linalg.norm(haar_integral(2, 2048) - pqc_integral(2, ansatz, params_shape[idx], 2048))}")
Result for ansatz1 : 0.016188197632944736 Result for ansatz2 : 0.49229263961748737 Result for ansatz3 : 0.878161089154185
Clearly, expressibility are in the order: Ansatz 3 < Ansatz 2 < Ansatz 1
, i.e. the power to probe Hilbert space is much more for our randomly chosen ansatz, which is guessable.
Checking Entangling Capability of Ansatze¶
We quantify entanlging capability [1] of $n$-qubit ansatze by calculating the average Meyer-Wallach entanglement, $ Q $, of the states generated by it:
$$Q = \frac{2}{|S|}\sum_{\theta_i\in S}\left(1- \frac{1}{n}\sum_{k=1}^n \text{Tr}[\rho_k^2(\theta_i)]\right) \tag{2}$$Here, $\rho_k$ is the density operator for the qubit $k$ after tracing out the rest, and $\theta$ is the set of sampled parameters. The quantity within the first summation can also be called as the average subsystem linear entropy for the system, and to calculate it we make use of qiskit's partial_trace
.
def meyer_wallach(circuit, num_qubits, size, sample=1024):
"""
Returns the meyer-wallach entanglement measure for the given circuit.
"""
res = np.zeros(sample, dtype=complex)
N = num_qubits
backend = aer.StatevectorSimulator()
for i in range(sample):
params = np.random.uniform(-np.pi, np.pi, size)
ansatz = circuit(params, N)
result = backend.run(ansatz).result()
U = result.get_statevector(ansatz, decimals=5).data.reshape(-1,1)
entropy = 0
qb = list(range(N))
for j in range(N):
dens = qk.quantum_info.partial_trace(U, qb[:j]+qb[j+1:]).data
trace = np.trace(dens**2)
entropy += trace
entropy /= N
res[i] = 1 - entropy
return 2*np.sum(res).real/sample
# Sanity Check
print(meyer_wallach(ansatz3, 2, 0))
0.0
Results for Ansatz¶
params_shape = [(2, 2), 1, 0]
for idx, ansatz in enumerate([ansatz1, ansatz2, ansatz3]):
print(f"Result for ansatz{idx+1} : {meyer_wallach(ansatz, 2, params_shape[idx])}")
Result for ansatz1 : 0.6231297994520951 Result for ansatz2 : 0.9999998195955925 Result for ansatz3 : 0.0
Clearly, the entangling capability are in the order: Ansatz 3 < Ansatz 1 < Ansatz 2
. Therefore, we can guess limited expressibility of Ansatz 2 is compensated by its higher entangling capability.