Qiskit / qiskit-aer

Aer is a high performance simulator for quantum circuits that includes noise models
https://qiskit.github.io/qiskit-aer/
Apache License 2.0
483 stars 360 forks source link

Transpiler pass to add gate dependent noise #1390

Closed chriseclectic closed 2 years ago

chriseclectic commented 2 years ago

What is the expected behavior?

The transpilation pass should look something like:

class NoisePass(TransformationPass):

    """Transpiler pass to insert noise into a circuit.

    The noise in this pass is defined by a noise function or callable with signature 

    .. code:: python

            def fn(
                inst: Instruction,
                qubits: Optional[List[int]] = None,
                clbits: Optional[List[int]] = None,
            ) -> Union[QuantumError, Instruction, QuantumCircuit, None]:

    For every instance of one of the reference instructions in a circuit the
    supplied function is called on that instruction and the returned noise
    is added to the circuit. This noise can depend on properties of the
    instruction it is called on (for example parameters or duration) to
    allow inserting parameterized noise models.

    Several methods for adding the constructed errors to circuits are supported
    and can be set by using the ``method`` kwarg. The supported methods are

    * ``"append"``: add the return of the callable after the instruction.
    * ``"prepend"``: add the return of the callable before the instruction.
    * ``"replace"``: replace the instruction with the return of the callable.

    """

    def __init__(
        self,
        fn: Callable,
        instructions: Optional[Union[Instruction, Sequence[Instruction] = None,
        method: Optional[str] = 'append'
    ):
        """Initialize noise pass.

        Args:
            fn: noise function `fn(inst, qubits, clbits)`.
            instructions: Optional, single or list of instructions to apply the
                                  noise function to. If None the noise function will be
                                  applied to all instructions in the circuit.
            method: method for inserting noise. Allow methods are
                           'append', 'prepend', 'replace'.
        """
        ...

    # other methods for transpiler pass implementation...

This should work to iterate over all instructions in a circuit and for each instance of the specified instructions (or for every instruction is instructions is None) depending on the initialized method it should append, prepend, or replace the circuit instruction with the return of the callable.

If the callable returns None, then that should not be added to the circuit. The edge case of method="replace" and the callable returns None, the instruction should be removed from the circuit.

In addition to this general pass we should also add a specific thermal relaxation pass.

class RelaxationNoisePass(NoisePass):
    """Add duration dependent thermal relaxation noise after instructions."""

    def __init__(
        self,
        instructions: Optional[Union[Instruction Sequence[Instructions]] = None,
        t1s: Optional[List[float]] = None,
        t2s: Optional[List[float]] = None,
        excited_state_populations: Optional[List[float]] = None,
        backend: Optional[Backend] = None,
        unit: str = 's',
    ):
        """Initialize noise pass.

        Args:
            instruction: Optional, the instruction to add relaxation to. If None
                 relaxation will be added to all instructions.
            t1s: Optional, list of T1 times for each qubit.
            t2s: Optional, list of T2 times for each qubit.
            excited_state_populations: Optional, list of excited state populations
                for each qubit at thermal equilibrium. If not supplied or obtained
                from the backend this will be set to 0 for each qubit.
            backend: Optional, backend object to extract T1, T2 values from.
            unit: Time unit for T1 and T2 values if not obtained from a
                  backend (s, us, ms, ns, etc).
        """
        # If `t1s` and `t2s` are supplied use them for getting T1, T2 values
        # for each qubit. Otherwise use backend.properties to construct
        # these values.
        ...

        self._t1s = np.asarray(t1s)
        self._t2s = np.asarray(t2s)
        if excited_state_populations is not None:
            self._p1s = np.asarray(excited_state_populations)
        else:
            self._p1s = np.zeros(len(t1s))
        super().__init__(self._relaxation_error, instructions=instructions, method="append")

        # Note: We might need extra kwarg such as dt for converting gate durations
        # in circuit into same time units as T1 T2 times...

        # Note: In principle you could compute excited_state_populations from backend
        # based on each qubit frequency and the effective temperature of the
        # qubit. I don't think this temperature info is in the backend though...

    # pylint: disable = unused-argument
    def _thermal_relaxation_error(
        gate: Instruction, =
        qubits: Sequence[int],
        clbits: Optional[Sequence[int]] = None.
        ):
        """Return thermal relaxation error on each gate qubit"""
        duration = gate.duration
        if duration == 0:
            return None

        t1s = self._t1s[qubits]
        t2s = self._t2s[qubits]
        p1s = self._p1s[qubits]

        # Note: Make sure to convert duration and t1, t2 to same time units!

        if gate.num_qubits == 1:
            t1, t2, p1 = t1s[0], t2s[0], p1s[0]
            if t1 == np.inf and t2 == np.inf:
                return None
            return thermal_relaxation_error(t1, t2, duration, p1)

        # General multi-qubit case
        noise = QuantumCircuit(gate.num_qubits)
        for qubit, (t1, t2, p1) in enumerate(zip(t1s, t2s, p1s)):
            if t1 == np.inf and t2 == np.inf:
                # No relaxation on this qubit
                continue
            error = thermal_relaxation_error(t1, t2, duration, p1)
            noise.append(error, [qubit])

        return noise

Basic Example

After adding these passes one could add duration dependent relaxation noise to delay gates as follows:

from qiskit.circuit import Delay

backend = AerSimulator.from_backend(ibmq_backend)
delay_pass = RelaxationNoisePass(Delay, backend=backend)

# Build circuits
circuits = ...

# Schedule to insert delays
sched_circuits = transpile(circuits, backend, scheduling_method='asap')

# Add delay noise
sched_circuits = [delay_pass(circ) for circ in sched_circuits]

# Run on simulator with rest of device noise model for other gates
result = backend.run(sched_circuits).result()
itoko commented 2 years ago

I'll work on this.

I like the idea of method option of NoisePass. Don't we need qubits argument in NoisePass? Is this only for all qubits errors? But what happens if the fn returns errors on two or more qubits? Apply the error to all qubit pairs etc?

I personally prefer a bit general FunctionalOpMapPass accepting fn which returns only Union[Instruction, QuantumCircuit, None]. By excluding QuantumError, it would be easier for the pass to live in Terra in the future. If we need NoisePass for instance check like isinstance(pass_, NoisePass), I like to have NoisePass as an "interface" mixin class, which has only abstract methods.

Anyways, I'll try to create a draft PR based on the idea above as fast as possible. Let's discuss further based on the draft implementation.

chriseclectic commented 2 years ago

I don't really see the need for an abstract base class or mixin yet. This NoisePass is intended to be a concrete noise pass that I could use to implement any local gate or readout error model (ie errors that apply to a subset of qubits + clbits for the instructions, so LocalNoisePass would actually be a better name for it). Different passes would be needed for non-local noise which isn't the purpose of this pass.

Specific qubit/clbit errors are handled by the callable itself, this is why the callable has the signature fn(inst, qubits, clbits) and I think is the most general thing you need for any local Markovian error model. For qubit specific errors it should return the correct error for the specified qubits (or None).

eg: This LocalNoisePass could implement the current NoiseModel (minus non-local errors) as a single pass doing something like this (pseudo code, since you can't do dict lookup of noise model quite this easily with inst/qubit types):

def noise_model_fn(inst, qubits, clbits):
    # Quantum errors
    local_errors = noise_model._local_quantum_errors
    default_errors = noise_model._default_quantum_errors
    if inst in local_errors and qubits in local_errors[inst]:
         return local_errors[inst][qubits]
    elif inst in default_errors:
         return default_errors[inst]

    # Readout errors
    if isinstance(inst, Measure):
        if qubits in noise_model._local_readout_errors:
             return local_errors[inst][qubits]
         else:
             return noise_model._default_readout_errors

    # Default case
    return None

noise_model_pass = LocalNoisePass(noise_model_fn, [Measure, *gates])

It is most convenient for user if the function can return QuantumError or any other operator. Since these can be appended to a circuit it shouldn't matter -- the NoisePass code should handle calling to_instruction on its return if its not already an instruction/circuit.

chriseclectic commented 2 years ago

@itoko After writing above example it seems that clbits aren't actually needed for the callable signature, so I think it should be simplified to just fn(inst, qubits) for this pass.

Readout Errors are a bit of an odd case, but still fit, since in the current noise model they are looked up by qubits the measure acts on, but the returned readout error is a classical instruction applied to clbits of that measure instruction. This could be handled by the pass itself instead of the callable -- it could looks at the return from func after converting to instruction, and can use num qubits, num clbits to figure out how to append for instructions that could be applied if its an instruction with only qubits, only clbits, or both.

So if we do this I think the signature can actually be:

class LocalNoisePass(TransformationPass):
    def __init__(
        self,
        fn: Callable,
        instructions: Optional[Union[Instruction, Sequence[Instruction]] = None,
        method: str = 'append'
    ):
        """Initialize noise pass.

        Args:
            fn: noise function `fn(inst, qubits) -> InstructionLike`.
            instructions: Optional, single or list of instructions to apply the
                                  noise function to. If None the noise function will be
                                  applied to all instructions in the circuit.
            method: method for inserting noise. Allow methods are
                           'append', 'prepend', 'replace'.
        """

where signature of function is expected to be:

def fn(inst: Instruction, qubits: List[int]) -> Optional[InstructionLike]

where InstructionLike means anything that is an Instruction or can be converted to an instruction (ie has to_instruction method).

chriseclectic commented 2 years ago

Some more examples of how you could use this pass for some single-error type cases are

# Add a all-qubit quantum error to specific gate
LocalNoisePass((lambda inst, qubits: error), gate)

# Add quantum error to specific gate on specific qubits
LocalNoisePass((lambda inst, qubits: error if qubits == target_qubits else None), gate)

Though for many gate errors these single error passes are going to be much less efficient since you want to minimize number of passes, so should generally be avoided.

itoko commented 2 years ago

@chriseclectic Based on your suggestions, I've created draft PR #1391.

Minor things I tweaked are:

Build circuits

circuits = ...

Schedule to prepare to insert delay noises

sched_circuits = transpile(circuits, noisy_sim, scheduling_method='asap')

Run on noisy simulator (insert delay noises to circuits and run them with noise model for other gates behind the scene)

result = noisy_sim.(sched_circuits).result()

As a bonus, you can visualize noisy circuits (not yet correctly implemented) (Note: I don't want to promote the API below very much)

noise_model = NoiseModel.from_backend(ibmq_backend, delay_noise=True)

Still need to schedule before inserting delay noises

sched_circuit = transpile(circuit, ibmq_backend, scheduling_method='asap')

No smart but you can obtain noisy circuit without running backend

noisy_circuit = noise_model.pass_manager(only_custom=False).run(sched_circuit)



I'm not sure but the approach that `LocalNoisePass` always takes a function rather than a noise object might cause some performance issues because it might not work well with `parallel_map` used in the pass manager. I'll check if it really happens or not after completing the PR.
rviktor85 commented 1 year ago

Hello, I am not sure if this is a right place where I should ask my question. I am trying to implement custom noise model and add it to some predefined basis gates. In order to achieve this, I am using LocalNoissPass function. It works perfectly when a circuit has only quantum gates, but it is not functioning when there is some measurements or classical bits in the circuit. Is there any way around it? @itoko @chriseclectic