Usage#
Central component of our package is the Fourier model which you can import with
In the simplest scenario, one would instantiate such a model with \(4\) qubits and a single layer using the "Hardware Efficient" ansatz by:
You can take a look at your model, by simply calling

Looks good to you? Head over to the Training page for getting started with an easy example, where we also show how to implement trainable frequencies
If you want to learn more about, why we get the above results, checkout the Data-Reuploading section.
Note that calling the model without any (None) values for the params and inputs argument, will implicitly call the model with the recently (or initial) parameters and 0s as input.
I.e. simply running the following
observables=None is default, measuring all qubits).
In the following we will describe some concepts of the Model class.
For a more detailled reference on the methods and arguments that are available, please see the references page.
The essentials#
There is much more to this package than just providing a Fourier model.
You can calculate the Expressibility or Entangling Capability besides the Coefficients which are unique to this kind of QML interpretation.
You can also provide a custom circuit, by instantiating from the Circuit class in qml_essentials.ansaetze.Circuit.
See page Ansaetze for more details and a list of available Ansatzes that we provide with this package.
Data-Reuploading#
The idea of repeating the input encoding is one of the core features of our framework and builds upon the work by Schuld et al. (2020). Essentially, it allows us to represent a quantum circuit as a truncated Fourier series, which is a powerful feature that enables the model to mimic arbitrary non-linear functions. The number of frequencies that the model can represent is constrained by the number of data encoding steps within the circuit.
Typically, there is a reuploading step after each layer and on each qubit (data_reupload=True).
However, our package also allows you to specify an array with the number of rows representing the qubits and number of columns representing the layers.
Then, a True means that encoding is applied at the corresponding position within the circuit.
In the following example, we disable two instances of the data-reuploading step, thus leaving the model with model.degree = (5) frequencies (2 negative + zero frequency + 2 positive).
model = Model(
n_qubits=2,
n_layers=2,
circuit_type="Hardware_Efficient",
data_reupload=[[True, False], [False, True]],
)
Checkout the Coefficients page for more details on how you can visualize such a model using tools from signal analysis.
If you want to encode multi-dimensional data (check out the Encoding section on how to do that), you can specify another dimension in the data_reupload argument (which just extents naturally).
model = Model(
n_qubits=2,
n_layers=2,
circuit_type="Hardware_Efficient",
data_reupload=[[[0, 1], [1, 1]], [[1, 1], [0, 1]]],
)
sum([0,1,1,0]) = 2), and the second input will have four frequencies (sum([1,1,1,1]) = 4).
Of course, this is just a rule of thumb and can vary depending on the exact encoding strategy.
Parameter Initialization#
The initialization strategy can be set when instantiating the model with the initialization argument.
The default strategy is "random" which will result in random initialization of the parameters using the domain specified in the initialization_domain argument.
Other options are:
"zeros": All parameters are initialized to \(0\)"zero-controlled": All parameters are initialized to randomly except for the angles of the controlled rotations which are initialized to \(0\)"pi-controlled": All parameters are initialized to randomly except for the angles of the controlled rotations which are initialized to \(\\pi\)"pi": All parameters are initialized to \(\\pi\)
The initialize_params method provides the option to re-initialise the parameters after model instantiation using either the previous configuration or a different strategy.
Given a PRNG key, it returns the key from key, subkey = random.split(key) as documented here and uses the subkey for the actual parameter initialization.
It's also possible to omit the key argument entirely, as the model has an internal random_key state which is updated every time randomness is utilized.
This allows to repeatingly call model.initialize_params() to generate a continous sequence of random initializations.
The same key state can be advanced manually with model.next_key(), which is required to obtain fresh randomness inside a JAX transformation (see Noise).
Encoding#
The encoding can be set when instantiating the model with the encoding argument.
The default encoding is "RX" which will result in a single RX rotation per qubit. Other options are:
- A string such as
"RX"that will result in a single RX rotation per qubit - A list of strings such as
["RX", "RY"]that will result in a sequential RX and RY rotation per qubit - Any callable such as
Gates.RX - A list of callables such as
[Gates.RX, Gates.RY] - An instance of the
Encodingclass
See page Ansaetze for more details regarding the Gates class.
If a list of encodings is provided, the input is assumed to be multi-dimensional.
Otherwise multiple inputs are treated as batches of inputs.
Encoding gates are always part of the circuit, also when the input is zero and the gates reduce to the identity.
In case of a multi-dimensional input, you can obtain the highest frequency in each encoding dimension from the model.degree property.
Note that, model.degree includes the negative and zero frequency (i.e. the full spectrum).
Individual frequencies can be obtained via model.frequencies.
By default, all encodings are Hamming encodings, meaning, all encodings are applied equally in each data-reuploading step.
Note it is also possible to provide a custom encoding as the encoding argument essentially accepts any callable or list of callables see here for more details.
To make things a little bit easier, we implement following encoding strategies as introduced in Generalization despite overfitting in quantum machine learning models with their respective spectral properties:
| Encoding strategy | Spectrum \(\Omega\) | \(\vert\Omega\vert\) |
|---|---|---|
| Hamming | \(\{-n_{q},-(n_{q}-1),\ldots,n_{q}\}\) | \(2 n_{q}+1\) |
| Binary | \(\{-2^{n_{q}}+1,\ldots,2^{n_{q}}-1\}\) | \(2^{n_{q}+1}- 1\) |
| Ternary | \(\left\{-\left\lfloor\frac{3^{n_{q}}}{2}\right\rfloor,\ldots,\left\lfloor\frac{3^{n_{q}}}{2}\right\rfloor\right\}\) | \(3^{n_{q}}\) |
| Golomb | \(\{m_{i}-m_{j} : m_{i}, m_{j} \in G\}\) for a Golomb ruler \(G\) with \(2^{n_{q}}\) marks | \(2^{n_{q}}(2^{n_{q}}-1)+1\) |
Unlike the per-qubit strategies above, the Golomb strategy encodes the input on a multi-qubit diagonal Hamiltonian whose eigenvalues form a Golomb ruler, so that all pairwise differences (the resulting frequencies) are distinct. This yields the maximum number of distinct frequencies with minimal degeneracy for a given number of qubits.
You can use these templates by instantiating an Encoding class with the encoding strategy you like and passing it to the model upon initialization:
from qml_essentials.ansaetze import Encoding
model = Model(
n_qubits=2,
n_layers=1,
circuit_type="Circuit_19",
encoding=Encoding("ternary", ["RX", "RY"]),
)
model.frequencies
Returns [9,9], which corresponds to the ternary spectrum \(3^{2}\) for two indpendent inputs.
State Preparation#
While the encoding is applied in each data-reuploading step, the state preparation is only applied at the beginning of the circuit, but after the StatePreparation noise (see below for details).
The default is no state preparation. Similar to the encoding, you can provide the state_preparation argument as
- A string such as
"H"that will result in a single Hadamard per qubit - A list of strings such as
["H", "H"]that will result in two consecutive Hadamards per qubit - Any callable such as
Gates.H - A list of callables such as
[Gates.H, Gates.H]
See page Ansaetze for more details regarding the Gates class.
Output Shape#
The output shape is determined by the observables argument, provided in the instantiation of the model.
When set to None all qubits are measured which will result in the shape being of size \(n\) by default (depending on the execution type, see below).
Setting observables to an integer will measure the qubit with the index specified.
Furthermore, "parity measurements" are supported, where observables becomes a list of qubit groups, e.g. [[0, 1], [2, 3]] to measure the parity between qubits 0 and 1 and qubits 2 and 3.
Alternatively, observables accepts a list of Operation objects, in which case the expval execution type returns one expectation value per observable.
The output_qubit argument is a deprecated alias for observables.
If force_mean flag is set when calling the model, the output is averaged to a single value (while keeping the batch/ input dimension).
This is usually helpful, if you want to perform a n-local measurement over all qubits where only the average over \(n\) expecation values is of interest.
Execution Type#
Our model be simulated in different ways by setting the execution_type property, when calling the model, to:
expval: Returns the expectation value between \(0\) and \(1\)density: Calculates the density matrixprobs: Simulates the model with the number of shots, set bymodel.shots
For all three different execution types, the output shape is determined by the observables argument, provided in the instantiation of the model.
In case of density the partial density matrix is returned.
Noise#
Noise can be added to the model by providing a noise_params argument, when calling the model, which is a dictionary with following keys
BitFlipPhaseFlipAmplitudeDampingPhaseDampingDepolarizingMultiQubitDepolarizingStatePreparationMeasurement
with values between \(0\) and \(1\).
Additionally, a GateError can be applied, which controls the variance of a Gaussian distribution with zero mean applied on the input vector.
Each gate draws its error independently.
While BitFlip, PhaseFlip, Depolarizing and GateErrors are applied on each gate, AmplitudeDamping, PhaseDamping, StatePreparation and Measurement are applied on the whole circuit.
Furthermore, ThermalRelaxation can be applied.
Instead of the probability, the entry for this type of error consists of another dict with the keys:
t1: The relative T1 relaxation time (a typical value might be \(180\mathrm{us}\))t2: The relative T2 relaxation time (a typical value might be \(100\mathrm{us}\))t_factor: The relative gate time factor (a typical value might be \(0.018\mathrm{us}\))
The units can be ignored as we are only interested in relative times, above values might belong to some superconducting system.
Note that t2 is required to be max. \(2\times\)t1.
Based on t_factor and the circuit depth the execution time is estimated, and therefore the influence of thermal relaxation over time.
Pulse Level Simulation#
Our framework extends beyond unitary-level simulation by integrating pulse-level simulation. This allows you to move from the abstract unitary layer, where gates are treated as instantaneous idealized operations, down to the physical pulse layer, where gates are represented by time-dependent microwave control fields.
In the pulse representation, each gate is decomposed into Gaussian-shaped pulses parameterized by:
- \(A\): amplitude of the pulse
- \(\sigma\): width (standard deviation) of the Gaussian envelope
- \(t\): pulse duration
By default, the framework provides optimized pulse parameters based on typical superconducting qubit frequencies (\(\omega_q = 10\pi\), \(\omega_c = 10\pi\)).
The Gaussian envelope is the default; other shapes can be selected via the pulse_shape argument on model instantiation (see Pulses for the available envelopes).
Switching between unitary-level and pulse-level execution is seamless and controlled by which pulse parameters you pass:
# Default unitary-level simulation
model(params, inputs)
# Ansatz and state-preparation gates at pulse level
model(params, inputs, pulse_params=model.pulse_params)
# Only the input-encoding gates at pulse level
model(params, inputs, enc_pulse_params=model.enc_pulse_params)
# Everything at pulse level
model(params, inputs, pulse_params=model.pulse_params, enc_pulse_params=model.enc_pulse_params)
The two parameter groups let you choose which group of gates is lowered to the pulse layer.
pulse_params lowers the ansatz and state-preparation gates, enc_pulse_params lowers the input-encoding gates, and omitting both keeps every gate ideal.
See Pulses for the parameters belonging to each group.
Pulse-level gates can also be instantiated directly:
from jaqsi import Gates
# RX gate represented by its microwave pulse
Gates.RX(w, wires=0, pulse=True)
# With custom pulse parameters [A, sigma, t]
pulse_params = [0.5, 0.2, 1.0]
Gates.RX(w, wires=0, pulse_params=pulse_params, pulse=True)
For more details:
- See Ansaetze for a deeper explanation of our pulse-level gates and ansaetze, as well as details on Quantum Optimal Control (QOC), which enables optimizing pulses directly for target unitaries.
- See Training for how to train pulse parameters jointly with rotation angles.
Batching and Multithreading (using JAX)#
In our framework, JAX automatically handles the number and distribution of the workers depending on the batch sizes and available CPUs.
Batching works for inputs, parameters, pulse parameters and encoding pulse parameters.
If all four are provided, with sizes B_I, B_P, B_R and B_E, respectively, the effective batch dimension will multiply, i.e. resulting in B_I * B_P * B_R * B_E combinations.
Internally, these combinations will be flattened during processing and then reshaped to the original shape afterwards, such that the output shape is [B_I, B_P, B_R, B_E, O].
Here, O is the general output shape depending on the execution type, and may span more than one axis (e.g. for density and probs).
The batch part of that shape is also available as a property of the model: model.batch_shape.
Note, that the output shape is squeezed by default, i.e. every axis of dimension 1 is suppressed.
This includes the output axis O, so a single observable or a batch of one changes the rank of the result.
Pass keepdims=True to get the full [B_I, B_P, B_R, B_E, O] shape instead:
In addition to letting the model handle repeating the batch axes, it is also possible to disable this functionality by setting repeat_batch_axis upon model initialization.
This parameter is an array of four boolean values determining if the corresponding axis in the batch_shape (#Inputs, #Params, #PulseParams, #EncPulseParams) should be repeated.
Of course, when providing the batch manually, the dimensions have to match.
model = Model(
n_qubits=2,
n_layers=1,
circuit_type="Circuit_19",
repeat_batch_axis=[False, True, True],
,
)
key = jax.random.key(1000)
key = model.initialize_params(key, repeat=10)
model(inputs=random.uniform(key, (10, 1)))
100, the output will have a batch size of 10 instead (shape (10,2)).
Calls with a batch dimension reuse a compiled execution plan whenever the shapes of the arguments match.
Changes to the circuit structure, such as data_reupload or observables, are accounted for, but replacing the encoding after instantiation is not supported.
Functional Execution#
Calling the model directly stores the arguments it receives on the model, which is convenient but means that such a call cannot be wrapped in an outer jax.jit or jax.vmap: the stashed tracer would escape the transform and invalidate the next call.
For that purpose, the model provides apply, which is a pure counterpart of the regular call.
It writes no model state, and the output always keeps the full [B_I, B_P, B_R, B_E, O] shape, so its rank does not depend on the batch sizes.
import jax
def cost(params):
y_hat = model.apply(params=params, inputs=inputs, force_mean=True)
return jnp.mean((y_hat.reshape(-1) - targets) ** 2)
# the whole training step can be jitted
loss, grads = jax.jit(jax.value_and_grad(cost))(model.params)
Arguments left as None fall back to the current model state, which an outer jax.jit bakes in at trace time.
Anything that varies between calls, such as the parameters during training or the key for shots, has to be passed explicitly.
In contrast to the regular call, apply takes no data_reupload argument, as that reconfigures the circuit; set model.data_reupload beforehand instead.
Randomness is controlled through the key argument, which defaults to the model's random key without advancing it.
The regular call accepts the equivalent random_key argument; in both cases a fresh key per step comes from model.next_key(), see Noise.
Quantikz Export#
In addition to the printing the model to console and into a figure using matplotlib (thanks to Pennylane); our framework extends this functionality by allowing you to create nice Quantikz figures that you can embedd in a Latex document .
This can be achieved by
fig = model.draw(figure="tikz", gate_values=False)
fig.export("tikz_circuit.tex", full_document=True)

If you want to see the actual gate values instead of variables, simply set gate_values=True which is also the default option.
The returned fig variable is a TikzFigure object that stores the Latex string and allows exporting to a specified file.
To create a document that can be compiled, simply pass full_document=True when calling export.
Using a arbitrary circuit#
In some cases you may not want to utilize the structure enforced by the Model class.
Therefore, this section provides an example on how to use a custom circuit.
Gates are applied through jaqsi's Gates entry point, exactly as inside a Model.
It records the gate, attaches any noise you request, and runs it as an ideal unitary or, with pulse=True, at pulse level.
from jaqsi import Gates as g
from qml_essentials.model import Model
import jaqsi as js
import jax.numpy as jnp
def my_circuit(params, inputs, *args, **kwargs) -> None:
params = params.squeeze() # because the input shape can be a bit tricky otherwise
g.H(wires=[0])
g.H(wires=[1])
g.RX(1 * inputs, wires=[0])
g.PauliRot(params[0], wires=[0, 1])
g.CRX(3 * inputs, wires=[0, 1])
params = jnp.array([[jnp.pi / 2, 1]])
model = Model(
n_qubits=2,
n_layers=1,
observables=0, # this will correspond to PauliZ on qubit 0
)
# Define the spectrum (usually this is inferred from the encoding)
model.degree = (7,) # we count a full spectrum (-3,-2,...,2,3)
model.frequencies = (-3, -1, 0, 1, 3) # here we define the actual frequencies
# We need to define which shape the parameters have
model._params_shape = (2, 1) # (n_layers, n_params_per_layer)
# Overwrite the script with our own variational circuit
model.script = js.Script(f=my_circuit)