Noise¶
hqnn_forge.noise ¶
hqnn_forge.noise¶
Noise channels, readout error and finite-shot evaluation for HQNN-Forge quantum layers.
The layers and hybrid classifiers can train under depolarizing, amplitude-damping,
phase-damping, bit-flip or phase-flip noise, and with
readout_error=(p01, p10) (see
Training-time noise). Post hoc, apply_depolarizing_noise re-runs a
trained model's quantum layer with any of the five channels for the duration of
a with block, always as an exact density matrix on default.mixed whatever
the layer's noise_method, swapping only the QNode and restoring it when the
block exits. Gradients still flow, so optimizer steps inside the block can update
model weights. apply_readout_error applies readout error, and
noise_sweep repeats either over a range of strengths (channel="readout"
for readout error). apply_shots and shot_sweep evaluate with
finite shots (see Shot noise).
Channel
qml.DepolarizingChannel(p) maps ρ → (1−p)ρ + p/3 (XρX + YρY + ZρZ), so on
its own it scales every Pauli expectation by 1 − 4p/3; p = 3/4 is fully
depolarizing, and p is restricted to [0, 3/4].
position chooses where channels go, as in qml.noise.insert:
"all"(default) -- after every gate, on every wire the gate acts on: a simple gate-noise model whose effect grows with circuit size."end"-- once on every wire before measurement: readout-style noise that damps each ⟨Z_i⟩ by exactly1 − 4p/3.
Other channels
channel (noise_channel on the layers) selects the channel p is the
strength of. What each does to a ⟨Z⟩ readout with position="end":
======================== ============================= ===================
channel effect on ⟨Z⟩ at "end" p range
======================== ============================= ===================
"depolarizing" (1 − 4p/3) ⟨Z⟩ [0, 3/4]
"amplitude_damping" (1 − p) ⟨Z⟩ + p (T1) [0, 1]
"phase_damping" unchanged (T2 dephasing) [0, 1]
"bit_flip" (1 − 2p) ⟨Z⟩ [0, 1]
"phase_flip" unchanged [0, 1]
readout error (1 − p01 − p10) ⟨Z⟩ each in [0, 1]
(p01, p10) + (p10 − p01)
======================== ============================= ===================
Dephasing and phase flips commute with a Z measurement, so they act only
through gates that follow them (position="all"). A readout error flips
each measured bit, 0 → 1 with probability p01 and 1 → 0 with p10; on
hardware p10 is usually the larger, as the qubit relaxes during
measurement. It is classical, so it acts on the expectation values directly
(readout_error_map), with no mixed-state simulation:
apply_readout_error applies it post hoc, noise_sweep(...,
channel="readout") sweeps it, and the layers' and classifiers'
readout_error=(p01, p10) trains with it (#358). The symmetric case
p01 = p10 = p is exactly "bit_flip" at "end". On a sampled
layer each shot is flipped instead, from the device's generator (#486; see
apply_readout_error). Because the map is
applied to the layer's output rather than in its QNode, it exists only where
the layer runs its circuit through TrainingNoiseMixin._run_circuit:
a direct layer.qlayer(...) call is unaffected, as are the kernels, which
rebuild the circuit from qlayer.qnode, and
apply_readout_error refuses a layer without the mixin. Training noise by trajectories covers every channel; see
Damping channels as trajectories below for how the two damping channels are
sampled and what that rules out.
p = 0 replaces nothing: the original QNode stays in place, so the output
is bit-identical to the noiseless model rather than merely close to it. It
still counts as an active wrapper, so a layer's training-time noise (below),
its readout_error included, is
suppressed inside it just as for p > 0, but it does not count as a
replaced QNode: a p > 0 block may be opened inside it.
Training-time noise
The encoding layers and hybrid classifiers also take noise_level,
noise_position and noise_channel at construction. With noise_level > 0 the layer runs
the same noisy QNode (built by training_noise_qnode) whenever it is
in train mode, so gradients are computed through the noisy circuit, and
the noiseless QNode in eval mode, like dropout. readout_error is the
same kind of option, train mode only, with or without a noise_level. Unlike
a channel that only shrinks ⟨Z⟩, it also shifts every output by
p10 − p01, which the head learns: evaluate such a model inside
apply_readout_error with the same pair, or it is evaluated on outputs
without the shift it was trained on (#488). Evaluation under noise is
then done with apply_depolarizing_noise / noise_sweep, which
take precedence over the training-time channel if both are active at once.
noise_level=0 (the default) leaves the layer exactly as before.
Two methods, chosen with noise_method:
"density" (default)
The exact channel: mixed-state simulation on default.mixed,
differentiated with backprop. It costs O(4^n) memory, and training
keeps a batch × 4^n complex density matrix for every gate and every
inserted channel for the backward pass. At 8 qubits, batch 64 and
position="all", one step measured +2.7 GB peak and 50 s, so this is
practical up to about 6 qubits.
"trajectories"
Pauli-trajectory (Monte Carlo) sampling on the layer's own device and
differentiation method. At every channel site each sample independently
gets one Pauli, drawn with the channel's weights: for depolarizing I
with probability 1 − p and X, Y or Z with p/3 each;
for bit flip I with 1 − p and X with p; for phase flip
I with 1 − p and Z with p (the paulis weights in
CHANNELS). Each mixture is its channel, so the output averaged
over draws equals the "density" output, and so does the gradient:
each step's loss gradient is an unbiased estimate of the one
"density" computes, at pure-state cost. The same step measured
+34 MB and 0.8 s on lightning.qubit with adjoint (+18 MB and 0.3 s
noiseless). The price is gradient variance, as with dropout, which a
fresh draw every forward pass resembles. noise_trajectories = k
averages k draws per sample, at k times the cost, to reduce it.
Measured on one small proxy dataset (#311, breast cancer, 4 and 6
qubits, 5 seeds; ``docs/results/trajectory-noise-study.md``; the
benchmark datasets are #414): at
``p = 0.01``, and with noise only before measurement, trajectory
training matched the density channel; at ``p = 0.05`` after every gate
it was slower to leave the initial loss plateau, so that 3 of 20 runs
scored a test MCC below 0.5 within 30 epochs and a patience of 10,
density none. Re-run for 60 epochs without early stopping, two of the
three trained (one of them at ``k = 4``) and one, at ``k = 1``, still
had not (#480). A follow-up on the same data and budget over 10 seeds
(#347, ``docs/results/trajectory-collapse-study.md``) counted such runs
across five training variants: 10 of 100 at ``k = 1``, 6 of 100 at
``k = 4`` and none of 100 at ``noise_trajectories = 8``, which also
matched density. A lower learning rate, gradient clipping and a noise
warm-up did not reliably help within that budget; the counts do not
separate a late start from a run that never trains (#480). On
``default.qubit`` with backprop the draws run as one batch, so ``k = 8``
cost 1.1 to 1.4 times ``k = 1`` at 4 and 6 qubits; on the adjoint path
each sample runs separately, so it is expected to cost about 8 times
(not measured), which is why the default stays 1. So keep ``"density"``
where it fits (up to about 6 qubits), and beyond that use
``"trajectories"`` with ``noise_trajectories ≥ 8``, or with fewer draws
a longer training budget than density needs, and check the runs.
**Damping channels as trajectories** (#357). Phase damping with
strength ``γ`` keeps the diagonal of ρ and scales its off-diagonal by
``sqrt(1 − γ)``, which is exactly what a phase flip with probability
``p = (1 − sqrt(1 − γ))/2`` does, so it is sampled as that Pauli channel,
with no restriction. Amplitude damping is not a mixture of unitaries:
its jump ``sqrt(γ)|0⟩⟨1|`` depends on the state, and a true quantum-jump
unravelling would need a state-dependent choice inside the circuit, which
a PennyLane tape cannot express. Instead each site draws a Kraus branch
with a *fixed* probability -- the jump with ``q = γ/2`` -- and applies
``K_i / sqrt(q_i)`` as a non-unitary matrix. The state is left
unnormalised, and the expectation value of the weighted state, averaged
over draws, is the channel's output exactly: enumerating every branch of
a two-qubit circuit reproduced the density result and its gradient to
1e-14. The price is twofold. First, a draw's output is no longer
bounded by ±1 and spreads more than a Pauli draw's: the RMS error of one
draw on a 4-qubit circuit with noise after every gate was 0.16, 0.32 and
0.88 at ``γ`` = 0.01, 0.05 and 0.2, against 0.13, 0.22 and 0.26 for
depolarizing trajectories at the same strength, so use more draws
(``noise_trajectories``) at larger ``γ``. Second, it needs
``backprop``, ``parameter-shift`` or ``finite-diff``: ``adjoint`` inverts
every operation by its adjoint, which is wrong for a non-unitary one (its
gradient was off by 0.17 on the test circuit), and shot sampling draws
from normalised probabilities. Third, the device must apply the given
matrix as it is: one that decomposes ``QubitUnitary`` into rotations
would run a unitary in its place and drop the weight. That was checked
on ``default.qubit`` and ``lightning.qubit`` only (#477). Any other
method or device, and shots, are refused when the layer is built.
The Pauli at a site is applied as ``RZ(π·z)`` then ``RX(π·x)`` with bits
``(x, z)``: ``(0, 0)`` is ``I``, ``(1, 0)`` is ``X``, ``(0, 1)`` is ``Z``
and ``(1, 1)`` is ``Y`` up to a global phase. Every sample therefore
runs the same gate sequence with different angles, so parameter
broadcasting and the per-sample batch split of the adjoint path apply
unchanged. The sites are placed by ``qml.noise.insert`` itself, so they
are exactly the sites the ``"density"`` path puts channels on. The
draws use torch's global RNG, like dropout, so ``torch.manual_seed``
makes them reproducible -- on an exact layer. With ``shots`` the
readouts are also sampled by the device's own generator, which torch does
not seed, so such a layer repeats only when built with ``seed``.
Shot noise
The other error a device adds is sampling: every expectation value is
estimated from a finite number of measurements. A layer or classifier built
with shots=N is sampled that way throughout (training with
parameter-shift). apply_shots evaluates a model trained on exact
values with N shots for the duration of a with block, and
shot_sweep repeats its predictions over a range of shot counts, like
noise_sweep over noise levels.
Channel
module-attribute
¶
Channel = Literal['depolarizing', 'amplitude_damping', 'phase_damping', 'bit_flip', 'phase_flip']
CHANNELS
module-attribute
¶
CHANNELS: dict[str, _ChannelSpec] = {'depolarizing': _ChannelSpec(qml.DepolarizingChannel, MAX_P, lambda p: (1 - p, p / 3, p / 3, p / 3)), 'amplitude_damping': _ChannelSpec(qml.AmplitudeDamping, 1.0, None, _amplitude_damping_branches), 'phase_damping': _ChannelSpec(qml.PhaseDamping, 1.0, _phase_damping_as_phase_flip), 'bit_flip': _ChannelSpec(qml.BitFlip, 1.0, lambda p: (1 - p, p, 0.0, 0.0)), 'phase_flip': _ChannelSpec(qml.PhaseFlip, 1.0, lambda p: (1 - p, 0.0, 0.0, p))}
KRAUS_DIFF_METHODS
module-attribute
¶
KRAUS_DIFF_METHODS = ('backprop', 'parameter-shift', 'finite-diff')
Differentiation methods whose gradient through the weighted Kraus branches
equals the density channel's (enumerated in the tests). A list of the
methods allowed, not of those refused: adjoint reverses the circuit
with each operation's adjoint as its inverse and is wrong, and a name such
as "best" resolves to it on lightning without saying so.
KRAUS_DEVICES
module-attribute
¶
KRAUS_DEVICES = ('default.qubit', 'lightning.qubit')
Devices checked to apply a non-unitary QubitUnitary matrix as given.
One that decomposes the operation runs a unitary in its place (#477).
MAX_TRAINING_NOISE_QUBITS
module-attribute
¶
MAX_TRAINING_NOISE_QUBITS = 6
Above this many qubits, a layer built with noise_method="density" warns.
TrainingNoiseMixin ¶
Training-time noise for an encoding layer: construction, dispatch and repr.
The one implementation behind every layer's noise_level,
noise_position, noise_method, noise_trajectories and
readout_error. A layer
calls _init_training_noise once its QNode exists, runs its circuit
through _run_circuit (train mode with noise: the noisy QNode, else
qlayer itself; eval passes under torch.no_grad() dispatch to an
undifferentiated QNode when diff_method="adjoint") and appends
_noise_repr to extra_repr.
Everything else -- validation at construction, the memory warning, the
QNode swap and its restore, the precedence of
apply_depolarizing_noise -- happens here.
A readout error is classical and is applied to the circuit's output, not
inside it: the layer's own readout_error in train mode, or
apply_readout_error's in either mode. It therefore exists only on
the _run_circuit path; qlayer(prepare_inputs(x)) called
directly never has it.
shots
property
¶
shots: int | None
The shot count the layer samples with now; None for exact values.
Read from the QNode the layer runs, so it follows
apply_shots (and is None inside a p > 0
apply_depolarizing_noise block, which simulates the exact
channel) instead of repeating the construction argument.
NoiseSweepPoint ¶
ShotSweepPoint ¶
Bases: NamedTuple
One shot count of a sweep.
probabilities
instance-attribute
¶
probabilities: Tensor
(n_repeats, *predict_proba(X).shape): (n_repeats, n_samples)
for a binary classifier, (n_repeats, n_samples, n_classes) for
MulticlassHybridClassifier.
scores
instance-attribute
¶
scores: list[float] | None
One score per row of probabilities, or None without score_fn.
qnode_shots ¶
qnode_shots(qnode: object) -> int | None
The shot count qnode samples with, or None for exact values.
validate_noise ¶
validate_noise(p: float, position: str, *, p_name: str = 'p', position_name: str = 'position', channel: str = 'depolarizing', channel_name: str = 'channel') -> None
Raise ValueError unless channel is known, 0 <= p is at most the
channel's maximum, and position is known.
The *_name arguments are the names the messages use, so a caller that
exposes these under other names (noise_level) is quoted.
training_noise_qnode ¶
training_noise_qnode(qnode: QNode, n_qubits: int, p: float, position: Position = 'all', *, channel: Channel) -> qml.QNode
The noisy counterpart of qnode an encoding layer runs in train mode.
Same construction as apply_depolarizing_noise uses: the layer's
circuit function on default.mixed with channel of strength p
inserted at position, differentiated with backprop. The layer's own
device_name and diff_method apply to its noiseless path only;
mixed-state simulation costs O(4^n) memory per sample, and training
through it keeps one such state per operation for backprop (see the module
docstring).
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
check_kraus_trajectories ¶
check_kraus_trajectories(channel: str, qnode: QNode) -> None
Raise ValueError if channel's trajectories cannot run on qnode.
Only channels that are not drawn as Pauli errors are affected: amplitude
damping, sampled by weighted Kraus branches. Their states are
unnormalised, so the gradient is right only for the methods in
KRAUS_DIFF_METHODS (adjoint was off by 0.17 on a two-qubit
test circuit, #357; diff_method=None computes none and is allowed),
the matrices reach the simulator unchanged only on the devices in
KRAUS_DEVICES, and shot sampling, drawing from normalised
probabilities, cannot represent them. A channel with neither sampler is
refused as well.
trajectory_noise_qnode ¶
trajectory_noise_qnode(qnode: QNode, p: float, position: Position = 'all', *, channel: Channel) -> qml.QNode
qnode with channel sampled as trajectories.
Runs on qnode's own device and differentiation method; each call draws
a fresh error pattern per sample (see the module docstring). The average
over draws equals training_noise_qnode's output. The Pauli
channels -- depolarizing, bit flip, phase flip, and phase damping, which
is a phase flip -- are drawn as Pauli errors; amplitude damping as
weighted Kraus branches, which rules out adjoint, shots and devices
other than default.qubit and lightning.qubit (see
check_kraus_trajectories).
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
run_with_training_noise ¶
run_with_training_noise(qlayer: TorchLayer, noisy_qnode: QNode, x: Tensor, n_trajectories: int = 1) -> torch.Tensor
Evaluate qlayer on x with noisy_qnode in place of its QNode.
n_trajectories > 1 (the "trajectories" method only) runs each
sample that many times and returns the mean, each run with its own draw.
If apply_depolarizing_noise is active on the layer, its channel
(none at p = 0) is kept and the training-time one is not applied: the
post-hoc wrapper is the evaluation instrument and wins. The original
QNode is restored afterwards, including when the forward pass raises.
validate_noise_method ¶
validate_noise_method(method: str, n_trajectories: object) -> None
Raise ValueError unless method is known and n_trajectories fits it.
n_trajectories must be a positive int (not bool), and 1 for
"density", which is exact and has nothing to average.
validate_readout_error ¶
validate_readout_error(readout_error: object, name: str = 'readout_error') -> tuple[float, float] | None
readout_error as a pair of plain floats (p01, p10), or None.
Accepts any ordered pair of real numbers in [0, 1] -- a tuple, a list, an array or a tensor of two, NumPy scalars included -- and returns built-in floats, which is the form to store: it survives a checkpoint and does not alias the caller's object.
Raises:
| Type | Description |
|---|---|
ValueError
|
Unless |
readout_error_map ¶
readout_error_map(expectations: Tensor, p01: float, p10: float) -> torch.Tensor
⟨Z⟩ of each measured qubit after an asymmetric readout error.
Each measured bit reads 1 instead of 0 with probability p01 and 0
instead of 1 with probability p10: a classical confusion matrix on the
qubit's outcome distribution. With P(0) = (1 + ⟨Z⟩)/2 that gives
⟨Z⟩ → (1 − p01 − p10) ⟨Z⟩ + (p10 − p01),
exactly, for every single-qubit ⟨Z⟩ readout, which is all the layers
measure. p01 = p10 = p is "bit_flip" at position="end".
This is the effect on exact expectation values. A sampled layer
(shots, or inside apply_shots) flips its shots instead
(#486), so that the spread is that of a device with this readout error;
the map is then the mean of that output and gives its gradient (see
apply_readout_error).
apply_readout_error ¶
apply_readout_error(model: Module, p01: float, p10: float) -> Iterator[nn.Module]
Evaluate model with an asymmetric readout error for the with block.
Applies readout_error_map to the quantum layer's output, in train
and eval mode alike, without touching the weights, and on top of an open
apply_depolarizing_noise or apply_shots block. A layer's
own training-time readout_error is replaced, not added to, while the
block is open.
Sampled layers (#486). With shots (the layer's own, or an
apply_shots block's) the error flips every shot rather than
rescaling the estimate: each output has the distribution of the mean of
shots flipped shots, mean ⟨Z'⟩ and variance
(1 − ⟨Z'⟩²)/shots, which is what a device with this readout error
gives. An estimate ẑ from N shots is k = N (1 + ẑ)/2
zeros (outcome 0 is Z = +1); the zeros read out are
Binomial(k, 1 − p01) + Binomial(N − k, p10), exactly in
distribution, and the output is (2k' − N)/N. The same applies
to the layer's own readout_error in train mode.
- Generator. The flips are drawn from the device's own NumPy
generator, the one the shots come from, so a layer built with
seedrepeats them whatever torch's seed, and SPSA's common random numbers cover them; a device without one gets a generator seeded from torch's (one draw from it per forward pass). - Trajectories. With
noise_method="trajectories"in train mode an output is the mean ofnoise_trajectoriesdraws ofshotsshots each; the flips are drawn on the pooled count of all of them. The shots are flipped independently, and a sum of independent binomials with the same probability is the binomial of the summed count, so this is the same in distribution as flipping each draw's shots. - Gradient. A flipped count is piecewise constant in the weights, so
its own derivative is zero almost everywhere. The output therefore
carries the gradient of
readout_error_mapof the sampled estimate,(1 − p01 − p10)times the sampled gradient, the same estimator as before #486 (a seeded layer's values differ, as the flips now draw from its generator before the backward pass's shots): the conditional mean of the flipped output given the shots is exactly that map, so withparameter-shift's unbiased sampled gradient this is an unbiased estimate of∂⟨Z'⟩/∂θ. SPSA uses the flipped values themselves. (0, 0)draws nothing and returns the sampled estimate unchanged.
On exact values (shots=None) the map is the whole effect, unchanged.
The map is applied where the layer runs its circuit
(TrainingNoiseMixin._run_circuit), not inside the QNode, so the
layer must be a TrainingNoiseMixin whose forward goes through
it, as every built-in encoding layer's does. Code that runs the circuit
itself inside the block -- a direct layer.qlayer(...) call, or
hqnn_forge.kernels, which rebuild it from qlayer.qnode -- gets
its output without the error.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
TypeError
|
If |
RuntimeError
|
If a block is already open on the same layer. |
apply_depolarizing_noise ¶
apply_depolarizing_noise(model: Module, p: float, *, position: Position = 'all', channel: Channel = 'depolarizing') -> Iterator[nn.Module]
Run model's quantum layer with noise inside the block.
Named for its default channel; channel selects any of CHANNELS.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Module
|
A hybrid classifier or an encoding layer. |
required |
p
|
float
|
Strength of each channel: the depolarizing probability in [0, 0.75], or the damping / flip probability in [0, 1]. |
required |
position
|
Position
|
|
'all'
|
channel
|
Channel
|
|
'depolarizing'
|
Yields:
| Type | Description |
|---|---|
Module
|
|
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
RuntimeError
|
If the layer is already running a replaced QNode (nested use). |
Notes
The original QNode is restored on exit, including when the block raises. Gradients flow through the noisy circuit, so the block can also be used to fine-tune under noise.
noise_sweep ¶
noise_sweep(model: Module, X: Tensor, ps: Iterable[float] | Iterable[float | tuple[float, float]], *, position: Position = 'all', channel: Channel | Literal['readout'] = 'depolarizing', y: Tensor | None = None, score_fn: Callable[[Tensor, Tensor], float] | None = None) -> list[NoiseSweepPoint]
Predict X at each noise level in ps, without retraining.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
Module
|
A classifier with |
required |
X
|
Tensor
|
Inputs to evaluate. |
required |
ps
|
Iterable[float] | Iterable[float | tuple[float, float]]
|
Channel strengths, each in the channel's range; consumed once. With
|
required |
position
|
Position
|
Passed to |
'all'
|
channel
|
Position
|
Passed to |
'all'
|
y
|
Tensor | None
|
If both are given, |
None
|
score_fn
|
Tensor | None
|
If both are given, |
None
|
Returns:
| Type | Description |
|---|---|
list[NoiseSweepPoint]
|
In the order of |
Raises:
| Type | Description |
|---|---|
ValueError
|
If only one of |
TypeError
|
If |
apply_shots ¶
apply_shots(model: Module, shots: int | None) -> Iterator[nn.Module]
Run model's quantum layer with shots samples per circuit inside the block.
Every expectation value is then estimated from shots measurements, as
on hardware: a readout with exact value ⟨Z⟩ comes back with standard
deviation sqrt((1 − ⟨Z⟩²) / shots). This evaluates a model trained on
exact values under sampling, without rebuilding it or touching its
weights; shots=None gives exact values again, for a reference point.
The layer's circuit runs on its own device through a parameter-shift
QNode, the one differentiation method that samples unbiased gradients, so
gradients inside the block are sampled too. A layer with
noise_method="trajectories" training noise samples its train-mode
trajectories with the block's shots as well; one with "density"
training noise, which simulates the exact channel, raises in train mode,
and so does one with amplitude-damping trajectories, whose unnormalised
states cannot be sampled (eval mode, where training noise is off, is
unaffected in both cases). The original
QNodes are restored on exit, including when the block raises.
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
RuntimeError
|
If the layer is already inside |
shot_sweep ¶
shot_sweep(model: Module, X: Tensor, shots: Iterable[int | None], *, n_repeats: int = 5, y: Tensor | None = None, score_fn: Callable[[Tensor, Tensor], float] | None = None) -> list[ShotSweepPoint]
Predict X n_repeats times at each finite shot count, without retraining.
The repeats show the spread shot noise alone puts on the predictions and
on the score; None in shots gives the exact reference, evaluated
and scored once with its probabilities and score repeated n_repeats
times. That assumes predict_proba is deterministic at shots=None,
as it is for the hqnn_forge models, which predict in eval mode; a custom
one that leaves dropout on would have its spread hidden. Arguments as for
noise_sweep.
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
TypeError
|
If |