pasqal-io / qadence

Digital-analog quantum programming interface
https://pasqal-io.github.io/qadence/latest/
Apache License 2.0
64 stars 17 forks source link

[Proto] Integrate the Pyqtorch's noisy simulation #423

Open EthanObadia opened 1 month ago

EthanObadia commented 1 month ago

This issue is to present a prototype for integrating pyq's noise gates into qadence. The primary goal is to ensure that noise handling is effectively incorporated without disrupting the existing functionalities. Below are the key points of this prototype:

1. Creation of NoisyPrimitivesBlocks: • New blocks derived from PrimitivesBlock have been created, called NoisyPrimitivesBlock. • These blocks introduce a new input parameter, noise_probability, which allows specifying the probability of noise when creating a block. In blocks/primitive.py :

class NoisyPrimitiveBlock(PrimitiveBlock):
    """
    NoisyPrimitiveBlock represents elementary unitary operations with noise.
    This class adds a noise probability parameter to the primitive block,
    representing the likelihood of an error occurring during the operation.
    """

    name = "NoisyPrimitiveBlock"

    def __init__(
        self, qubit_support: tuple[int, ...], noise_probability: float | tuple[float, ...]
    ):
        super().__init__(qubit_support)
        self._noise_probability = noise_probability

    @property
    def noise_probability(self) -> float | tuple[float, ...]:
        return self._noise_probability

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, NoisyPrimitiveBlock):
            return False
        return super().__eq__(other) and self.noise_probability == other.noise_probability

    def _to_dict(self) -> dict:
        block_dict = super()._to_dict()
        block_dict.update({"noise_probability": self.noise_probability})
        return block_dict

    @classmethod
    def _from_dict(cls, d: dict) -> NoisyPrimitiveBlock:
        return cls(d["qubit_support"], d["noise_probability"])

    def __hash__(self) -> int:
        return hash((super().__hash__(), self.noise_probability))

    def dagger(self) -> PrimitiveBlock:
        raise ValueError("Property `dagger` not available for noise gate.")

2. Create Noisy Gates in Qadence: • Prior to this, we added the noise gates name to theOpName class. • Develop blocks that will represent our noisy gates within qadence. • Create subclasses of NoisyPrimitiveBlock to implement these noise gates. For instance with the BitFlip gate. In operations/noise.py:

class BitFlip(NoisyPrimitiveBlock):
    """The Bitflip noise gate."""

    name = OpName.BITFLIP

    def __init__(self, target: int, noise_probability: float | tuple[float, ...]):
        super().__init__((target,), noise_probability)

    @property
    def generator(self) -> None:
        raise ValueError("Property `generator` not available for non-unitary operator.")

    @property
    def eigenvalues_generator(self) -> None:
        raise ValueError("Property `eigenvalues_generator` not available for non-unitary operator.")

3.Modify convert_block function for pyq: • Prior to this, we created thesingle_qubit_noise_gateset type list . • During the conversion of blocks to gates for pyq, add a condition for noise block in convert_block. • Ensure that the new noise_probability parameter is taken into account during this conversion. In operations/__init__.py:

single_qubit_gateset = [X, Y, Z, H, I, RX, RY, RZ, U, S, SDagger, T, PHASE]
single_qubit_noise_gateset = [BitFlip]

In backends/pyqtorch/convert_ops.py:

def convert_block():
...
     elif isinstance(block, tuple(single_qubit_noise_gateset)):
         pyq_cls = getattr(pyq, block.name)
         op = pyq_cls(qubit_support[0], block.noise_probability)  # type: ignore[attr-defined]
         return [op]
EthanObadia commented 1 week ago

What do you think @Roland-djee @gvelikova @jpmoutinho @dominikandreasseitz @rajaiitp ?

EthanObadia commented 1 week ago

I have changed my approach because the goal is not to create additional blocks but simply to add a noise parameter to the existing blocks. This parameter will allow me to add a noisy block after the existing blocks. The noise parameter is optional and initialized by default to None. It can be either an instance of Noise or a dictionary of Noise instances if we want to apply multiple types of noise to the same block. It can be initialize as follow:

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
}

Below are the key points of this prototype:

1. Modification of the PrimitiveBlock Class: • Add an optional noise parameter to the PrimitiveBlock class. • Add a noise method to handle the noise parameter. • Modify the representation of the blocks (_block_title) to handle the new parameter.

In blocks/primitive.py:

class PrimitiveBlock(AbstractBlock):
    """
    Primitive blocks represent elementary unitary operations.

    #TODO: Add a description of the noise attribut

    Examples are single/multi-qubit gates or Hamiltonian evolution.
    See [`qadence.operations`](/qadence/operations.md) for a full list of
    primitive blocks.
    """
    name = "PrimitiveBlock"

    def __init__(
        self, qubit_support: tuple[int, ...], noise: Noise | dict[str, Noise] | None = None
    ):
        self._qubit_support = qubit_support
        self._noise = noise

    @property
    def qubit_support(self) -> Tuple[int, ...]:
        return self._qubit_support

    @property
    def noise(self) -> Noise | dict[str, Noise] | None:
        return self._noise

    def digital_decomposition(self) -> AbstractBlock:
        """Decomposition into purely digital gates.

        This method returns a decomposition of the Block in a
        combination of purely digital single-qubit and two-qubit
        'gates', by manual/custom knowledge of how this can be done efficiently.
        :return:
        """
        if self.noise is None:
            raise ValueError(
                "Decomposition into purely digital gates is only avalaible for unitary gate"
            )
        return self

    ...

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, AbstractBlock):
            raise TypeError(f"Cant compare {type(self)} to {type(other)}")
        if isinstance(other, type(self)):
            return self.qubit_support == other.qubit_support and self.noise == self.noise
        return False

    def _to_dict(self) -> dict:
        return {
            "type": type(self).__name__,
            "qubit_support": self.qubit_support,
            "tag": self.tag,
            "noise": self.noise._to_dict()
            if isinstance(self.noise, Noise)
            else {k: v._to_dict() for k, v in self.noise.items()}
            if self.noise
            else None,
        }

    @classmethod
    def _from_dict(cls, d: dict) -> PrimitiveBlock:
        noise = d.get("noise")
        if isinstance(noise, dict):
            noise = {k: Noise._from_dict(v) for k, v in noise.items()}
        elif noise is not None:
            noise = Noise._from_dict(noise)
        return cls(tuple(d["qubit_support"]), noise)

    ...

2.Modify All Subclasses of PrimitiveBlock: • Update the primitive gate class to support the noise attribute. • Update the ParametricBlock class to support the noise attribute. • Adjust all their methods accordingly to handle this new noise parameter.

3.Add Protocols to the Noise Class: • Implement necessary protocols in the Noise class. In noise/protocols.py:

@dataclass
class Noise:
    BITFLIP = "BitFlip"
    PHASEFLIP = "PhaseFlip"
    PAULI_CHANNEL = "PauliChannel"
    AMPLITUDE_DAMPING = "AmplitudeDamping"
    PHASE_DAMPING = "PhaseDamping"
    GENERALIZED_AMPLITUDE_DAMPING = "GeneralizedAmplitudeDamping"
    DEPHASING = "dephasing"
    DEPOLARIZING = "depolarizing"  # check if no cap is ok for pyq
    READOUT = "readout"
    ...

4.Modify the convert_block function: • Ensure the convert_block function in pyq can properly handle these blocks with the noise parameter. In backends/pyqtorch/convert_ops.py:

def convert_block():
...
    elif block.noise:
        protocols = []
        error_probabilities = []
        if isinstance(block.noise, dict):
            for noise_instance in block.noise.values():
                protocols.append(noise_instance.protocol)
                error_probabilities.append(noise_instance.options.get("error_probability"))
        elif isinstance(block.noise, Noise):
            protocols.append(block.noise.protocol)
            error_probabilities.append(block.noise.options.get("error_probability"))
        return [pyq.Noisy_Sequence(primitive = block.name, noise = protocols, error_probability = error_probabilities)]
    ...

5. Add a constructor in pyq: • Implement a constructor in pyq to automatically create two blocks: one primitive or parametric and one noise block. • This should eliminate the need to create them manually. In Pyqtorch directly, in circuit.py:

class Noisy_Sequence:
     def __init__(primitive: Primitive, noise: Noise | list[Noise, ...], error_probability: float | list[float,...]):

(These are the last two steps; more details will be added later.)

EthanObadia commented 5 days ago

After further consideration, step 5, which involves adding a constructor in pyq, may not be strictly necessary. I would like to present an alternative solution where I use the existing sequence constructors in pyq from qadence. This approach leverages the already established constructors: In backends/pyqtorch/convert_ops.py:


def noisy_block(block: AbstractBlock) -> list:
    operators = []

    def process_block(single_block):
        pyq_cls = getattr(pyq, single_block.name)
        operators.append(pyq_cls(single_block.qubit_support[0]))
        if isinstance(single_block.noise, dict):
            for noise_instance in single_block.noise.values():
                pyq_noise = getattr(pyq, noise_instance.protocol)
                op = pyq_noise(single_block.qubit_support[0], noise_instance.options.get("error_probability"))
                operators.append(op)
        elif isinstance(single_block.noise, Noise):
            pyq_noise = getattr(pyq, single_block.noise.protocol)
            op = pyq_noise(single_block.qubit_support[0], single_block.noise.options.get("error_probability"))
            operators.append(op)

    if isinstance(block, CompositeBlock):
        for sub_block in block.blocks:
            process_block(sub_block)
    else:
        process_block(block)
    return operators

def convert_block(
    block: AbstractBlock, n_qubits: int = None, config: Configuration = None
) -> Sequence[Module | Tensor | str | sympy.Expr]:
     ...
     elif isinstance(block, CompositeBlock):
        if all([b.noise is None for b in block]):
            ops = list(flatten(*(convert_block(b, n_qubits, config) for b in block.blocks)))
        else:
            ops = noisy_block(block)
        if isinstance(block, AddBlock):
            return [pyq.Add(ops)]  # add
        elif (
            is_single_qubit_chain(block)
            and config.use_single_qubit_composition
            and all([b.noise is None for b in block])
        ):
            return [
                pyq.Merge(ops)
            ]  # for chains of single qubit ops on the same qubit without noise
        else:
            return [pyq.Sequence(ops)]  # for kron and chain with multiple qubits/1-qubit with noise
      ...
     elif isinstance(block, tuple(single_qubit_gateset)):
        if block.noise:
            return [pyq.Sequence(noisy_block(block))]
        else:
            pyq_cls = getattr(pyq,block.name)
            if isinstance(block, ParametricBlock):
                if isinstance(block, U):
                    op = pyq_cls(qubit_support[0], *config.get_param_name(block))
                else:
                    op = pyq_cls(qubit_support[0], config.get_param_name(block)[0])
            else:
                op = pyq_cls(qubit_support[0])
            return [op]