Qiskit / qiskit

Qiskit is an open-source SDK for working with quantum computers at the level of extended quantum circuits, operators, and primitives.
https://www.ibm.com/quantum/qiskit
Apache License 2.0
4.99k stars 2.32k forks source link

Add `apply_layout` to other operators #11824

Open chriseclectic opened 6 months ago

chriseclectic commented 6 months ago

What should we add?

apply_layout was added to SparsePauliOp at some point, but not to other operators (eg Pauli).

This method could in theory be added to BaseOperator to apply to any operator (caveat, it requires ScalarOp which is a subclass of BaseOperator, and you could easily blow up your computers memory if you try and apply a large qubit layout to a channel op or Operator).

This function generalizes the current function to work for any n-qubit BaseOperator subclass:

from typing import List
from qiskit.quantum_info.operators.base_operator import BaseOperator
from qiskit.quantum_info.operators import ScalarOp
from qiskit.transpiler import TranspileLayout

def apply_layout(
    operator: BaseOperator, layout: TranspileLayout | List[int] | None, num_qubits: int | None = None
) -> BaseOperator:
    """Apply a transpiler layout to this operator.

    Args:
        layout: Either a :class:`~.TranspileLayout`, a list of integers or None.
                If both layout and num_qubits are none, a copy of the operator is
                returned.
        num_qubits: The number of subsystems to expand the operator to. If not
            provided then if ``layout`` is a :class:`~.TranspileLayout` the
            number of the transpiler output circuit qubits will be used by
            default. If ``layout`` is a list of integers the permutation
            specified will be applied without any expansion. If layout is
            None, the operator will be expanded to the given number of qubits.

    Returns:
        A new operator with the provided layout applied
    """
    from qiskit.transpiler.layout import TranspileLayout

    if layout is None and num_qargs is None:
        return self.copy()

    if operator.num_qubits is None:
        raise QiskitError("Cannot apply layout to an operator with non-qubit dimensions")

    if layout is None:
        n_qubits = operator.num_qubits
        layout = list(range(n_qubits))
    elif isinstance(layout, TranspileLayout):
        n_qubits = len(layout._output_qubit_list)
        layout = layout.final_index_layout()
        if layout is not None and any(x >= n_qubits for x in layout):
            raise QiskitError("Provided layout contains indicies outside the number of qubits.")

    if num_qubits is not None:
        if num_qubits < n_qubits:
            raise QiskitError(
                f"The input num_qubits is too small, a {num_qubits} qubit layout cannot be "
                f"applied to a {n_qubits} qubit operator"
            )
        n_qubits = num_qubits
    new_op = ScalarOp(n_qubits * (2,))
    return new_op.compose(operator, qargs=layout)
jakelishman commented 5 months ago

For the current definition of the V2 primitives, the only Qiskit-defined class other than SparsePauliOp that can be given as an observable is Pauli, which we could add apply_layout to. That doesn't affect the free-form inputs that BaseEstimatorV2 is also required to accept - str and dict, but for those, we either have a loose function (which presumably would need to live in qiskit.primitives, since it's a primitives-specific interpretation of standard Python classes), or more naturally we'd put it on the primitives container classes that these get coerced to, except that those aren't public, and also I don't think are actually valid as user inputs to the primitives anyway.

Fwiw, I feel like apply_layout is a (quite possibly necessary) hack around a more preferential input format which would have specified the qubits the entire ObservableArray was supposed to act on separately to the observables themselves, like how Qiskit Instruction instances don't contain the specific qubits (and aren't full-width on the circuit), but their context object (the CircuitInstruction, in this analogy) contains them.

I'm totally fine to add Pauli.apply_layout to ease this in the short term, and it does feel weird that we have SparsePauliOp.apply_layout but not Pauli.apply_layout. I'm somewhat against adding BaseOperator.apply_layout, because we don't really have a use for that, and adding an extra way to make it super easy to request a universe-ending amount of memory is probably something we'd be better keeping away from users who don't necessarily understand what Operator is if it's not needed.

jakelishman commented 4 months ago

Since #12066 merged, we've got apply_layout on everything that's a possible input to the EstimatorV2 primitive, I think, which should bridge the gap before we know what a complete solution to #11825 looks like end-to-end and the timescale of that.

For this issue, I guess the question is: is it worth putting method on the base class to fill it in for all the remaining classes, which at this point are mostly (maybe only?) things that can't possibly represent the expanded operator for any layout that they're likely to be given? It potentially feels safer to me not to offer the API opportunity to make the mistake, and to argue that apply_layout only really makes sense for things that have some degree of sparsity to them (which even Pauli and SparsePauliOp don't entirely).