Tutorial - Pricing a European call option using quantum computingο
Welcome!ππ This tutorial notebook is designed such that you will get to work hands-on with pricing a European call option using quantum computing algorithms. It is structured in the following 4 sections:
Reminder - what is a European call option?
Asset distributions and payoff function
Classical Monte Carlo on a QC
Quantum Amplitude Estimation (QAE)
If you have any questions or need assistance, dont hesitate to find either one of us in the room and weβll try and help you! π€
Authors: BjΓΆrn LΓΆfdahl, Victorio Γbeda Sosa, Wilhelm Γ gren
0. Reminder - what is a European call option?ο
A call option is a financial contract in which the holder (buyer) has the right (but not the obligation) to buy a specified quantity of a security at a specified price (strike price) within a fixed period of time (until its expiration/maturity). For a European call option the holder can only exercise the option at expiry/maturity.
For the writer (seller) of a call option, it represents an obligation to sell the underlying security at the strike price if the option is exercised. The call option writer is paid a premium for taking on the risk associated with the obligation (this is however omitted here as it only offsets the payoff by the premium price).
Suppose a European call option with strike price
For an investor to profit from a European call option, the spot price at maturity
Simple exampleο
Assume that Bob is the writer for a European call option with IonQ (NYSE: IONQ) as the underlying with spot price
Is this premium too high? Is it too low? Is it perhaps a fair price? π€
Alice is interested in buying this call option but is unsure whether Bob has a fair premium on the contract. However, Alice strongly believes that IonQ will be trading for
If IonQ would be worth less than the strike price
Why would Bob want to write this specific call option? Based on the strike price
In our simple case above Alice accepted the premium (the price of the option) based solely on her strong belief that IonQ would be worth much more in the future. But could she in some way have made a better decision on whether or not the premium of the call option was fair?
Yes, she herself could have estimated a fair price of the call option based on e.g. the current spot price
So what should be the fair price of the European call option? Letβs estimate it using quantum computing! π
1. Asset distributions and payoff functionο
The first step in estimating a fair price for the European call option is to encode/load the distribution of the underlying asset on to a quantum circuit. Assume that the underlying asset can be described by the Geometric Brownian Motion
and that we follow the Black & Scholes model such that the spot price at time
where
Because we will be working with quantum circuits in this tutorial, we will have to create an equidistant discretization of the distribution on to
π Exerciseο
Now we want you to:
Classically create the distribution that we just defined, and sample from it on an equidistant interval.
Change the variables around and see how the distribution of the spot price at maturity
changes.What happens if the asset has a large initial spot price
with a relatively high volatility ?Hint: perhaps we have too few qubits to represent such a βwideβ distribution?
Some help to get you started:
To calculate
and refer to the above equations.To create an equidistant range with
grid points you can use thenp.linspace
method.Normalizing the distribution in this context means that the probabilities should sum to 1.
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
n_uncertainty_qubits = 3 # change this also if you want to :)
n_qubit_states = 2 ** n_uncertainty_qubits
S = 2 # initial spot price
volatility = 0.4 # implied volatility
r = 0.05 # risk-free market rate
T = 40 / 365 # time to maturity
mu = ... # this should be the annualized drift rate of S
sigma = ... # this should be the scaled volatility
# Some properties of the log-normal distribution
# https://en.wikipedia.org/wiki/Log-normal_distribution
mean = np.exp(mu + 0.5 * sigma ** 2)
variance = (np.exp(sigma ** 2) - 1) * np.exp(2 * mu + sigma ** 2)
std_dev = np.sqrt(variance)
low = np.maximum(mean - 3 * std_dev, 0)
high = mean + 3 * std_dev
# Scipy log-normal distribution
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.lognorm.html
lognorm_scale = S * np.exp(r * T - 0.5 * sigma ** 2)
xx = ... # create an equidistant interval from `low` to `high` with `n_qubit_states` grid points
yy = stats.lognorm.pdf(xx, sigma, scale=lognorm_scale) / sigma
yy = ... # normalize the pdf
plt.figure()
plt.bar(xx, yy, width=0.2, alpha=0.5)
plt.plot(xx, yy, linewidth=2, color="blue")
plt.xticks(np.arange(min(xx) - 0.05, max(xx) + 0.05, (max(xx) - min(xx)) / 10), size=12, rotation=70)
plt.yticks(size=12)
plt.title(r"$ln \frac{S_T}{S_t} \sim N((r - \frac{1}{2}\sigma^2)T, \sigma\sqrt{T})$", size=20)
plt.xlabel(r"Spot price at maturity $S_T$ (β¬)", size=15)
plt.ylabel(r"Probability ($\%$)", size=15)
plt.grid()
plt.show()
Encode the distribution onto a quantum circuitο
Next we need to construct a circuit to load the log-normal distribution into a quantum state. The distribution as defined before is truncated to the given interval
where
We can use a pre-defined circuit from the qiskit-finance
library which calculates the same probabilities as we did above and then initalizes a circuit with gates that encode the probabilities for the states. It uses the Initialize class from the main qiskit
library to create a circuit with gates that represent the provided probabilities.
π Exerciseο
No we want you to:
Change the number of qubits (defined in the code cell above) and see how the depth of the circuit changes.
What happens to the depth? Is this a sustainable behaviour?
Look at the types of gates and try and think about what would happen if the hardware we wanted to run on only supported a certain set of (native) basis gates. Do you think the circuit would be deeper? Shallower? What are you thoughts?
You will get to explore this practically later in the tutorial, but it is good to start thinking about already.
Verify that the quantum circuit has implemented the expected distribution (the one you made in the code cell above).
from qiskit_finance.circuit.library import LogNormalDistribution
uncertainty_model = LogNormalDistribution(
n_uncertainty_qubits, mu=mu, sigma=sigma**2, bounds=(low, high)
)
decomposed_uncertainy_model = uncertainty_model.decompose(reps=10)
print(f"Depth of the P(X) circuit: {decomposed_uncertainy_model.depth()} ({n_uncertainty_qubits} qubits)")
display(uncertainty_model.draw("mpl"))
display(decomposed_uncertainy_model.draw("mpl"))
# Verify that the circuit encodes the same probability distribution.
# You should see the exact same distribution as above when we used `scipy.stats.lognorm.pdf`.
xx = uncertainty_model.values
yy = uncertainty_model.probabilities
plt.figure()
plt.bar(xx, yy, width=0.2, alpha=0.5)
plt.plot(xx, yy, linewidth=2, color="blue")
plt.xticks(np.arange(min(xx) - 0.05, max(xx) + 0.05, (max(xx) - min(xx)) / 10), size=12, rotation=70)
plt.yticks(size=12)
plt.title(r"$ln \frac{S_T}{S_t} \sim N((r - \frac{1}{2}\sigma^2)T, \sigma\sqrt{T})$", size=20)
plt.xlabel(r"Spot price at maturity $S_T$ (β¬)", size=15)
plt.ylabel(r"Probability ($\%$)", size=15)
plt.grid()
plt.show()
Encoding the payoff functionο
Next step is to encode the payoff function onto a quantum circuit. Remember that the payoff function is defined as
and since we have a equidistant discretized grid for the random variable
where it is required that the function
where we get that the probability of measuring
However, we have a problem, because our payoff function does not currently map to the
π Exerciseο
No we want you to:
Set a desired strike price
Implement the payoff function
classically
Some help:
Set the strike price based on your belief on how the underlying assets value will evolve until the time to maturity.
The
np.maximum
method can be used to get the largest of two values.
K = ... # define a strike price
xx = uncertainty_model.values
yy = ... # implement the payoff function
plt.figure()
plt.plot(xx, yy, linewidth=2, color="red", marker=".", ms=8)
plt.title(r"Payoff function $\phi(S_T) = \max(S_T - K, 0)$", size=20)
plt.xlabel(r"Spot price at maturity $S_T$ (β¬)", size=15)
plt.ylabel(r"Payoff amount (β¬)", size=15)
plt.xticks(np.arange(min(xx) - 0.05, max(xx) + 0.05, (max(xx) - min(xx)) / 10), size=12, rotation=70)
plt.yticks(size=12)
plt.grid()
plt.show()
Now we want to implement this function on a quantum circuit. The LinearAmplitudeFunction in the qiskit circuit library uses PiecewiseLinearPauliRotations to implement the piecewise linear function which is our payoff function.
π Exerciseο
Set the slopes of the piecewise payoff function.
Set the minimum and maximum value that the payoff function can have.
Some help:
Look at the above plot of the payoff function and try and figure out which two lines together make up the payoff function.
Hint: At the strike price
we start making money, before that, we profit nothing (payoff=0).
What is the minimum amount of money that we can make? Think logically π
The maximum amount of money we can make depends on the highest value in our equidistant range.
from qiskit.circuit.library import LinearAmplitudeFunction
# approximation scaling for the payoff function, determines accuracy in the Taylor approximation.
# https://www.nature.com/articles/s41534-019-0130-6
c_approx = 0.25
# piecewise linear with breakpoints on x_min and when S_T >= K.
breakpoints = (low, K)
slopes = ... # set the slopes of the piecewise linear payoff function
offsets = (0, 0)
f_min = ... # set the minimum value that the payoff function can have
f_max = ... # set the maximum value that the payoff function can have
european_call_objective = LinearAmplitudeFunction(
num_state_qubits=n_uncertainty_qubits,
slope=slopes,
offset=offsets,
domain=(low, high),
image=(f_min, f_max),
breakpoints=breakpoints,
rescaling_factor=c_approx,
)
decomposed_european_call_objective = european_call_objective.decompose(reps=10)
print(f"Depth of the f(x) circuit: {decomposed_european_call_objective.depth()} ({n_uncertainty_qubits} qubits)")
display(decomposed_european_call_objective.draw("mpl"))
Wait, now we are working with more qubits than we originally specified for .. βο
For each qubit in our
π Exerciseο
For a circuit with
qubits for the circuit, how many total qubits would we need to construct the full circuit with both and the payoff function circuit?Hint: change the number of qubits for
and see how the number of qubits changes for the payoff function circuit.
Now we need to add the distribution circuit to before the payoff functionο
from qiskit import AncillaRegister, ClassicalRegister, QuantumCircuit, QuantumRegister
n_payoff_qubits = european_call_objective.num_qubits
n_cl_bits = 1
qreg_s = QuantumRegister(n_uncertainty_qubits, "s")
qreg_target = QuantumRegister(1, "q")
qreg_a = AncillaRegister(n_payoff_qubits - n_uncertainty_qubits - 1, "a")
creg = ClassicalRegister(n_cl_bits, "creg")
european_call = QuantumCircuit(qreg_s, qreg_target, qreg_a)
european_call.append(uncertainty_model, range(n_uncertainty_qubits))
european_call.append(european_call_objective, range(n_payoff_qubits))
measured_european_call = european_call.copy()
measured_european_call.add_register(creg)
measured_european_call.measure(qreg_target, creg)
decomposed_european_call = european_call.decompose(reps=10)
decomposed_measured_european_call = measured_european_call.decompose(reps=10)
print(f"Depth of the P(X) + f operations circuit: {decomposed_measured_european_call.depth()} ({decomposed_measured_european_call.num_qubits} qubits)")
display(measured_european_call.draw("mpl"))
display(decomposed_measured_european_call.draw("mpl"))
Do you see any potential issues with the circuit depth?ο
What would happen to the depth if we had to apply the payoff function circuit
2. Classical Monte Carlo on a QCο
If we sample multiple times from the circuit that we have defined above then we can get an estimate of the expected payoff, however, this is just classical monte carlo but performed using quantum circuits.
There is no potential speed-up involved by doing this, but doing this step might be helpful for you to see and understand the gains with doing actual Quantum Monte Carlo (also referred to as Quantum Amplitude Estimation), which you will get to do soon in this tutorial.
Estimate the expected payoff for the European call optionο
Now you will perform classical monte carlo using the quantum circuit that you created above. We are specifically interested in the probability of measuring the
and with probability
π Exerciseο
Determine how many samples you want to take.
Try both a small - and a large amount, and see how the estimation and its confidence bound changes.
Rescale the estimated value to its original interval
.Hint: the circuit used for implementing the objective function has a method called post_processing that can be used to map the scaled results to its original domain.
Define a confidence bound on the estimated value.
Hint: assume a confidence bound on
and use the same method as above to rescale to the correct interval.
from qiskit.primitives import StatevectorSampler as Sampler
sampler = Sampler()
n_shots = ... # how many samples do you want to take for your MC?
result = sampler.run([measured_european_call, ], shots=n_shots).result()
quasi_dist_dict = result[0].data.creg.get_int_counts()
quasi_dist = np.array([quasi_dist_dict[0], quasi_dist_dict[1]])
dist = quasi_dist / quasi_dist.sum()
plt.figure()
plt.bar(["|0>", "|1>"], dist, width=0.2, alpha=0.8)
plt.yticks(size=12)
plt.xticks(size=12)
plt.title(r"Probability of measuring either $|0>$ or $|1>$", size=20)
plt.ylabel(r"Quasi-probability ($\%$)", size=15)
plt.grid()
plt.show()
p_hat = dist[1]
p_std = np.sqrt(p_hat * (1 - p_hat) / n_shots)
print(f"Raw estimated mu={p_hat:.5f}, std={p_std:.5f}")
mc_expectation = ... # estimated value
mc_lower_conf = ... # lower confidence bound
mc_upper_conf = ... # upper confidence bound
print(f"Estimation of the expected payoff:\t\t{mc_expectation:.5f}")
exact_value = np.dot(uncertainty_model.probabilities, np.maximum(uncertainty_model.values - K, 0))
print(f"Exact expected value from discretization:\t{exact_value:.5f}")
print(f"Confidence bound on estimation:\t\t({mc_lower_conf:.5f}, {mc_upper_conf:.5f})")
3. Quantum Amplitude Estimation (QAE)ο
Remember that the standard Monte Carlo method for pricing consists in approximating the expected payoff
where
and the posed question is whether or not the estimation error rate can be improved on?
Quantum Amplitude Estimation (QAE) is a quantum algorithm that provides an alternative to the classical Monte Carlo in order to compute approximate expectations of random variables.
Suppose we are able to construct a circuit that implements a state preparation operator
where
Then, QAE can be used to obtain an estimate of
together with an inverse Quantum Fourier Transform (QFT), where
It can be showed that, with high probability
where
Quantum Amplitude Estimation can be applied to our circuit that we performed classical Monte Carlo on, and doing QAE should theoretically improve our estimation on the fair price of the European call option.
So lets apply QAE and see if we get a better result than classical Monte Carlo! ππ
π Exerciseο
Pick
, i.e., the number of repetitions of .How is the depth of the circuit impacted by the choice of
?
# Define QAE circuit
from qiskit import QuantumCircuit, QuantumRegister, AncillaRegister, ClassicalRegister
from qiskit.circuit.library import QFT, GroverOperator
class AECircuit(QuantumCircuit):
def __init__(self, state_preparation_circuit, num_ancilla_qubits, objective_qubit):
self.state_preparation_circuit = state_preparation_circuit # The cirtuit implementing operator A
self.num_ancilla_qubits = num_ancilla_qubits # Number of ancilla qubits (m in IQAE paper)
self.num_state_qubits = state_preparation_circuit.num_qubits # Number of qubits in circuit A (n+1 in the IQAE paper)
self.objective_qubit = objective_qubit # Index of the objective qubit within the circuit A (0<=objective_qubit<=num_state_qubits)
# Initialize circuit
ancilla_register = AncillaRegister(self.num_ancilla_qubits, name="ancilla")
state_register = QuantumRegister(self.num_state_qubits, name="state")
classical_register = ClassicalRegister(self.num_ancilla_qubits, name="creg")
super().__init__(
ancilla_register,
state_register,
classical_register
)
# Hadamard gates on the ancilla qubits
for j in range(self.num_ancilla_qubits):
self.h(ancilla_register[j])
# Circuit A on the state and objective qubits
A_gate = self.state_preparation_circuit.to_gate(label="$A$")
self.append(A_gate, state_register[:])
self.barrier()
# Powers of Q
Q = self.groverOp(self.state_preparation_circuit, self.objective_qubit)
for j in range(num_ancilla_qubits):
Qj = Q.power(2**j).to_gate(label=f"$Q^{{{2**j}}}$").control(1) # Controlled version of Q^{2j}
# Apply the controlled Q^{2j} gate on state and objective qubits, controlled by the j-th ancilla qubit
self.append(Qj, [ancilla_register[self.num_ancilla_qubits-j-1]] + state_register[:])
self.barrier()
# Inverse QFT
QFT_gate = QFT(num_qubits=self.num_ancilla_qubits, inverse=True, do_swaps=False).to_gate(label="$QFT^\dagger$")
self.compose(QFT_gate, ancilla_register, inplace=True)
self.barrier()
# Measure the ancilla qubits
self.measure(ancilla_register, classical_register)
def groverOp(self, state_preparation_circuit, objective_qubit):
# construct the grover operator
oracle = QuantumCircuit(max(state_preparation_circuit.num_qubits - state_preparation_circuit.num_ancillas,1))
oracle.h(objective_qubit)
oracle.x(objective_qubit)
oracle.h(objective_qubit)
Q = GroverOperator(oracle, state_preparation_circuit)
return Q
m = ... # The number of Q repetitions (number of ancilla qubits)
M = 2 ** m
n_ancilla_qubits = m
qae = AECircuit(
state_preparation_circuit=decomposed_european_call,
num_ancilla_qubits=n_ancilla_qubits,
objective_qubit=n_uncertainty_qubits,
)
decomposed_qae = qae.decompose(reps=10)
print(f"Depth of the full QAE circuit: {decomposed_qae.depth()} ({decomposed_qae.num_qubits} qubits) ({m} ancilla qubits)")
display(qae.draw("mpl"))
Recall from the lecture that quantum amplitude estimation uses a combination of phase estimation and amplitude amplification in order to get an estimate of
Now you should run the QAE circuit that was constructed above. Note that in a world with perfect quantum computers, with no errors, we would only have to run this circuit once.
However, we do not (yet?) live in such a world, and thus we have to sample
π Exerciseο
Define how many times you want to sample/measure from the QAE circuit.
sampler = Sampler()
n_shots = ... # how many samples do you want to take for the estimation?
result = sampler.run([qae], shots=n_shots).result()
π Exerciseο
Calculate the angles based on the measured values
:Hint: the formula for the angle is
Calculate the estimates based on the angles:
Hint: the formula for the estimate is
Optional: round it to a suitable number of decimals (e.g. 15)
Determine which estimate has the highest probability and then post-process it.
Hint: you can use the
np.argmax
method to get the index with the largest value.
#
# QAE Post-processing
#
counts = result[0].data.creg.get_int_counts()
qq = np.array(list(counts.keys()))
theta_estimates = ... # Calculate the angles
estimates = ... # Calculate the estimates
probabilities = np.array(list(counts.values()), dtype="float64")
probabilities = probabilities / probabilities.sum()
# Combine any estimate or probability duplicates (because sin is periodic)
unique_estimates = list(set(estimates))
unique_probabilities = [np.sum(probabilities[estimates==e]) for e in unique_estimates]
qae_estimates = np.array(unique_estimates)
qae_probabilities = np.array(unique_probabilities)
qae_p_mode = ... # Extract the estimate with the highest probability
qae_expectation = european_call_objective.post_processing(qae_p_mode)
print(" Exact MC QAE")
print("="*70)
print(f"Expectation: {exact_value:.5f} {mc_expectation:.5f} {qae_expectation:.5f}")
print(f"Diff (abs): N/A {np.abs(exact_value - mc_expectation):.5f} {np.abs(exact_value - qae_expectation):.5f}")
print(f"Diff (%): N/A {np.abs(exact_value - mc_expectation)/mc_expectation:.5f} {np.abs(exact_value - qae_expectation)/qae_expectation:.5f}")
What if we wanted to run this QMC circuit on a specific backend?ο
If you for example wanted to run this circuit on the Helmi QC then you would have to transpile the circuit to be compatible with the native gate set of the target backend.
Qiskit has a method for (attempting) translating (transpiling) your circuit with your gates to the target gate set, this method is called transpile and composes 6 different stages (each of which can be read about in detail on the provided link).
The 5th stage in the transpilation process is called optimization and can be controlled by the user by specifying what optimization level you want to have. The optimization stage is necessary in order to manage increased circuit depths due to the transpilation process potentially adding a lot of gates in order to both: map the source gates to the target gate set, but also to match the target hardware qubit topology. We have discussed circuit depth quite a bit in this tutorial, and one way to (potentially) cope with extremely deep circuits is through transpilation optimization.
The user can specify what optimization level they want to run with:
no optimization
light optimization
heavy optimization
even heavier optimization
and the default level is 2. The higher the optimization level, the more optimized circuits, at the expense of longer transpilation times. Important to note is that the transpilation process is stochastic and will thus generate circuits with e.g. varying depth and gate layout even for the same source circuit.
π Exerciseο
Decide on a set of native gates that you want to have as target for the transpilation process.
If you have the time, you could try and find the specifications of a real QPU and its native gate set and use that. Here is a link for the IBM QPUs.
Set your desired optimization level and see how the depth of the circuit changes as you change the optimization level.
How many gates can you remove from the original (extremely deep) QAE circuit by optimizing it?
Can you find a gate set that you canβt transpile the circuit to? Why do you think that is?
from qiskit import transpile
from typing import List
backend_native_gates: List[str] = ... # the native gates of the target backend
optimization_level: int = ... # your desired optimization level [0, 3]
transpiled_circuit = transpile(
circuits=qae,
basis_gates=backend_native_gates,
optimization_level=optimization_level,
)
print(transpiled_circuit.depth())
# You can most likely not display this circuit since the transpilation process
# decomposes any grouped gates, meaning, it will try and draw ~100_000 gates...
# display(transpiled_circuit.draw("mpl"))
Thatβs all we had prepared for you today, well done! ππ«π
Hopefully you had the time to get a better understanding of how quantum amplitude estimation can be used in finance for pricing options, and also that you had the time to reflect on the suitability of these algorithms in the NISQ era.
Code solutionsο
If you are stuck on a problem or you want to compare your code to our code, look here. π
#
# Log-normal distribution (classically)
#
import numpy as np
import matplotlib.pyplot as plt
from scipy import stats
n_uncertainty_qubits = 3
n_qubit_states = 2 ** n_uncertainty_qubits
S = 2 # initial spot price
volatility = 0.4 # implied volatility
r = 0.05 # risk-free market rate
T = 40 / 365 # time to maturity
mu = (r - 0.5 * volatility ** 2) * T + np.log(S)
sigma = volatility * np.sqrt(T)
# Some properties of the log-normal distribution
# https://en.wikipedia.org/wiki/Log-normal_distribution
mean = np.exp(mu + 0.5 * sigma ** 2)
variance = (np.exp(sigma ** 2) - 1) * np.exp(2 * mu + sigma ** 2)
std_dev = np.sqrt(variance)
low = np.maximum(mean - 3 * std_dev, 0)
high = mean + 3 * std_dev
# Scipy log-normal distribution
# https://docs.scipy.org/doc/scipy/reference/generated/scipy.stats.lognorm.html
lognorm_scale = S * np.exp(r * T - 0.5 * sigma ** 2)
xx = np.linspace(low, high, num=n_qubit_states)
yy = stats.lognorm.pdf(xx, sigma, scale=lognorm_scale) / sigma
yy = yy / yy.sum()
plt.figure()
plt.bar(xx, yy, width=0.2, alpha=0.5)
plt.plot(xx, yy, linewidth=2, color="blue")
plt.xticks(np.arange(min(xx) - 0.05, max(xx) + 0.05, (max(xx) - min(xx)) / 10), size=12, rotation=70)
plt.yticks(size=12)
plt.title(r"$ln \frac{S_T}{S_t} \sim N((r - \frac{1}{2}\sigma^2)T, \sigma\sqrt{T})$", size=20)
plt.xlabel(r"Spot price at maturity $S_T$ (β¬)", size=15)
plt.ylabel(r"Probability ($\%$)", size=15)
plt.grid()
plt.show()
#
# Encoding the payoff function (classically)
#
K = 1.713 # this can be whatever..
xx = uncertainty_model.values
yy = np.maximum(xx - K, 0)
plt.figure()
plt.plot(xx, yy, linewidth=2, color="red", marker=".", ms=8)
plt.title(r"Payoff function $\phi(S_T) = \max(S_T - K, 0)$", size=20)
plt.xlabel(r"Spot price at maturity $S_T$ (β¬)", size=15)
plt.ylabel(r"Payoff amount (β¬)", size=15)
plt.xticks(np.arange(min(xx) - 0.05, max(xx) + 0.05, (max(xx) - min(xx)) / 10), size=12, rotation=70)
plt.yticks(size=12)
plt.grid()
plt.show()
#
# Encoding the payoff function (QC)
#
from qiskit.circuit.library import LinearAmplitudeFunction
# approximation scaling for the payoff function, determines accuracy in the Taylor approximation.
# https://www.nature.com/articles/s41534-019-0130-6
c_approx = 0.25
breakpoints = (low, K)
slopes = (0, 1)
offsets = (0, 0)
f_min = 0
f_max = high - K
european_call_objective = LinearAmplitudeFunction(
num_state_qubits=n_uncertainty_qubits,
slope=slopes,
offset=offsets,
domain=(low, high),
image=(f_min, f_max),
breakpoints=breakpoints,
rescaling_factor=c_approx,
)
decomposed_european_call_objective = european_call_objective.decompose(reps=10)
print(f"Depth of the f(x) circuit: {decomposed_european_call_objective.depth()} ({n_uncertainty_qubits} qubits)")
display(decomposed_european_call_objective.draw("mpl"))
#
# Performing classical MC on a QC
#
from qiskit.primitives import StatevectorSampler as Sampler
sampler = Sampler()
n_shots = 100_000
result = sampler.run([measured_european_call, ], shots=n_shots).result()
quasi_dist_dict = result[0].data.creg.get_int_counts()
quasi_dist = np.array([quasi_dist_dict[0], quasi_dist_dict[1]])
dist = quasi_dist / quasi_dist.sum()
plt.figure()
plt.bar(["|0>", "|1>"], dist, width=0.2, alpha=0.8)
plt.yticks(size=12)
plt.xticks(size=12)
plt.title(r"Probability of measuring either $|0>$ or $|1>$", size=20)
plt.ylabel(r"Quasi-probability ($\%$)", size=15)
plt.grid()
plt.show()
p_hat = dist[1]
p_std = np.sqrt(p_hat * (1 - p_hat) / n_shots)
print(f"Raw estimated mu={p_hat:.5f}, std={p_std:.5f}")
expectation = european_call_objective.post_processing(p_hat)
lower_conf = european_call_objective.post_processing(p_hat - 2 * p_std)
upper_conf = european_call_objective.post_processing(p_hat + 2 * p_std)
print(f"Estimation of the expected payoff:\t\t{expectation:.5f}")
exact_value = np.dot(uncertainty_model.probabilities, np.maximum(uncertainty_model.values - K, 0))
print(f"Exact expected value from discretization:\t{exact_value:.5f}")
print(f"Confidence bound on estimation:\t\t({lower_conf:.5f}, {upper_conf:.5f})")
#
# QAE Post-processing
#
counts = result[0].data.creg.get_int_counts()
qq = np.array(list(counts.keys()))
theta_estimates = qq * np.pi / M
estimates = np.round(np.sin(theta_estimates) ** 2, 15)
probabilities = np.array(list(counts.values()), dtype="float64")
probabilities = probabilities / probabilities.sum()
# Combine any estimate or probability duplicates (because sin is periodic)
unique_estimates = list(set(estimates))
unique_probabilities = [np.sum(probabilities[estimates==e]) for e in unique_estimates]
qae_estimates = np.array(unique_estimates)
qae_probabilities = np.array(unique_probabilities)
qae_p_mode = qae_estimates[np.argmax(qae_probabilities)]
qae_expectation = european_call_objective.post_processing(qae_p_mode)
print(" Exact MC QAE")
print("="*70)
print(f"Expectation: {exact_value:.5f} {mc_expectation:.5f} {qae_expectation:.5f}")
print(f"Diff (abs): N/A {np.abs(exact_value - mc_expectation):.5f} {np.abs(exact_value - qae_expectation):.5f}")
print(f"Diff (%): N/A {np.abs(exact_value - mc_expectation)/mc_expectation:.5f} {np.abs(exact_value - qae_expectation)/qae_expectation:.5f}")
#
# Transpiling and optimizing quantum circuits using qiskit
#
from qiskit import transpile
from typing import List
backend_native_gates: List[str] = ["u", "x", "ry", "cx"]
optimization_level: int = 3
transpiled_circuit = transpile(
circuits=qae,
basis_gates=backend_native_gates,
optimization_level=optimization_level,
)
print(transpiled_circuit.depth())
# You can most likely not display this circuit since the transpilation process
# decomposes any grouped gates, meaning, it will try and draw ~100_000 gates...
# display(transpiled_circuit.draw("mpl"))