pasqal-io / pyqtorch

PyTorch-based state vector simulator
https://pasqal-io.github.io/pyqtorch/
Apache License 2.0
43 stars 15 forks source link

[Proto] Noise modifications for Qadence implementation #200

Open EthanObadia opened 5 days ago

EthanObadia commented 5 days ago

This issue presents a prototype idea to modify noise for the Qadence implementation, where the noise is implemented such as:

bitflip_noise = Noise(protocol=Noise.BITFLIP, options={"error_probability": 0.5})
phaseflip_noise = Noise(protocol=Noise.PHASEFLIP, options={"error_probability": 0.2})
noise = {
    "bitflip": bitflip_noise,
    "phaseflip": phaseflip_noise
}
x = X(target = 0, noise = noise)

The goal is to modify the way single qubit gates have been implemented to minimize the number of manipulations in Qadence while maintaining consistent syntax. To achieve this, following the way noise is invoked in Qadence, we will no longer treat noise models as separate gates. Instead, they will be parameters that modify the Primitive gates, which will now be the only gates that can be directly called as seen in the example above.

Prototype:

1. Add a Noise Parameter to Primitive Gates in pyq: • Introduce a noise parameter to Primitive gates in pyq. • By default, this noise parameter is set to None.

For instance:

class X(Primitive):
    def __init__(self, target: int, noise: Noisy_protocols | dict[str, Noisy_protocols] | None = None):
        super().__init__(OPERATIONS_DICT["X"], target, noise)

2. Modification of the Primitive forward function : • If the noise parameter remains None, the gate behaves as a standard Primitive forward pass. • If the noise parameter is an instance of Noise, the Primitive forward function will call the Noise forward pass.

In primitive.py:

    def forward(
        self, state: Tensor, values: dict[str, Tensor] | Tensor = dict()
    ) -> Tensor:
        if self.noise:
            if isinstance(self.noise, dict):
                for noise_instance in self.noise.values():
                    protocol =  noise_instance.protocol_to_gate()
                    noise_gate = protocol(
                        primitive=self.unitary(values),
                        target=self.target,
                        probability= noise_instance.error_probability,
                    )
                # CALCULUS
            else:
                protocol =  self.noise.protocol_to_gate()
                noise_gate = protocol(
                    primitive = self.unitary(values),
                    target = self.target,
                    probability = self.noise.error_probability,
                )
                return noise_gate(state)
        else:
            if isinstance(state, DensityMatrix):
                # FIXME: fix error type int | tuple[int, ...] expected "int"
                # Only supports single-qubit gates
                return operator_product(
                    self.unitary(values),
                    operator_product(state, self.dagger(values), self.target),  # type: ignore [arg-type]
                    self.target,  # type: ignore [arg-type]
                )
            else:
                return apply_operator(
                    state,
                    self.unitary(values),
                    self.qubit_support,
                    len(state.size()) - 1,
                )

3. Modification of the Noise forward function : • Create the noisy primitive tensor as $X_{noisy} = X(\text{Kraus}_1 +\text{Kraus}_2)$, • Using this tensor to compute :

$$ \rho^{\prime} = X{noisy} \rho X^{\dagger}{noisy} = X(\text{Kraus}_1 +\text{Kraus}_2) \rho (\text{Kraus}^{\dagger}_1 +\text{Kraus}^{\dagger}_2)X^{\dagger}.$$

In noise.py (will add it later):

    def forward(
        self, state: Tensor, values: dict[str, Tensor] | Tensor = dict()
    ) -> Tensor:
EthanObadia commented 4 days ago

Discussion Point:

The idea is not to have heavily modified the Noise class in pyq, but simply to add a parameter, such as a primitive parameter, which would indicate to which primitive gate we are adding noise, in order to have this syntax:

pyq.noise.BitFlip(primitive= x.__class__.__name__, target=x.target, proba=error_prob)

Now, the question is, how can we make pyq's Noise understand the error_prob and the protocol to use, knowing that the Noise in qadence is not written in the same way at all?

For me, it would require a conversion function either in qadence or in pyq. But I think pyq should be completely independent of qadence. Additionally, the whole purpose of this approach was to ensure that qadence does not perform any additional operations. Therefore, I am not sure how to translate the following syntax so that the noise parameter added to the primitive can understand it:

bitflip_noise = Noise(protocol=Noise.BITFLIP, options={"error_probability": 0.5})
EthanObadia commented 4 days ago

Discussion Point:

The idea is not to have heavily modified the Noise class in pyq, but simply to add a parameter, such as a primitive parameter, which would indicate to which primitive gate we are adding noise, in order to have this syntax:

pyq.noise.BitFlip(primitive= x.__class__.__name__, target=x.target, proba=error_prob)

Now, the question is, how can we make pyq's Noise understand the error_prob and the protocol to use, knowing that the Noise in qadence is not written in the same way at all?

For me, it would require a conversion function either in qadence or in pyq. But I think pyq should be completely independent of qadence. Additionally, the whole purpose of this approach was to ensure that qadence does not perform any additional operations. Therefore, I am not sure how to translate the following syntax so that the noise parameter added to the primitive can understand it:

bitflip_noise = Noise(protocol=Noise.BITFLIP, options={"error_probability": 0.5})

4.Creation of a Noise Type Equivalent to Qadence: • To avoid type conflicts, create a Noise type equivalent to Qadence, which will allow us to extract all information without type conflicts for the noise attribute in Primitive. • The conversion between the Qadence type and the pyq type will be done in the converts_op.py file in Qadence.

In noise.py:

class Noisy_protocols:
    BITFLIP = "BitFlip"
    PHASEFLIP = "PhaseFlip"
    PAULI_CHANNEL = "PauliChannel"
    AMPLITUDE_DAMPING = "AmplitudeDamping"
    PHASE_DAMPING = "PhaseDamping"
    GENERALIZED_AMPLITUDE_DAMPING = "GeneralizedAmplitudeDamping"
    DEPHASING = "Dephasing"

    def __init__(self, protocol: str, options: dict = dict()) -> None:
        self.protocol: str = protocol
        self.options: dict = options

    def __repr__(self) -> str:
        return f"protocol: {self.protocol}, options: {self.options}"

    @property
    def error_probability(self):
        return self.options.get("error_probability")

    def protocol_to_gate(self):
        try:
            gate_class = getattr(sys.modules[__name__], self.protocol)
            return gate_class
        except AttributeError:
            raise ValueError(
                f"The protocol {self.protocol} has not been implemented in pyqtorch yet."
            )
EthanObadia commented 3 days ago

Comments on the Evolution of the Density Matrix with a Noisy Gate:

The initially proposed equation for the evolution of the density matrix is incorrect:

$$\rho^{\prime} = X{noisy} \rho X^{\dagger}{noisy} = X(\text{Kraus}_1 + \text{Kraus}_2) \rho (\text{Kraus}^{\dagger}_1 + \text{Kraus}^{\dagger}_2)X^{\dagger}. $$

This formulation is incorrect because the Kraus operators cannot be simply factorized in this manner.

For a noisy gate, the correct evolution of the density matrix $\rho$ is given by:

$$ S(\rho) = \sum_i K_i \rho K^{\dagger}_i. $$

If a unitary operation $X$ is applied before the noisy gate, the correct evolution is:

$$ \rho' = \sum_i K_i (X \rho X^{\dagger })K^{\dagger}_i . $$

This change in calculation means that a loop is required to sum over the Kraus operators, which makes the calculation more involved. However, this does not affect the overall reasoning.

Roland-djee commented 2 days ago

@EthanObadia you deserve a special mention for the 200th issue !

rajaiitp commented 7 hours ago

I'm glad you have documented this. This clarifies the implementation detail that I was interested in. Yep, you cannot simply add krauss operators, the noise acts independently to output its effect, which gets summed up later. Wish I looked up this thread sooner