Skip to content

Encoding

Getting classical data into a circuit, and the scaling decisions that come first.

qmlkit.encoding.angle

angle

Angle and basis encoding — getting classical numbers into a circuit.

angle_encode

angle_encode(
    x: Sequence[float],
    rotation: str = "ry",
    trainable: bool = False,
) -> CircuitSpec

One feature per qubit, written into a rotation angle.

trainable=False bakes the values in as literals. trainable=True makes them circuit parameters instead — which is what lets the same shift rule deliver df/dx, the gradient a classical pre-net needs in a hybrid stack.

Source code in src/qmlkit/encoding/angle.py
def angle_encode(
    x: Sequence[float],
    rotation: str = "ry",
    trainable: bool = False,
) -> CircuitSpec:
    """One feature per qubit, written into a rotation angle.

    ``trainable=False`` bakes the values in as literals. ``trainable=True`` makes
    them circuit *parameters* instead — which is what lets the same shift rule
    deliver ``df/dx``, the gradient a classical pre-net needs in a hybrid stack.
    """
    values = np.atleast_1d(np.asarray(x, dtype=float)).ravel()
    if values.size == 0:
        raise ValueError("angle_encode needs at least one feature")
    qc = QCircuit(values.size)
    for i, xi in enumerate(values):
        qc.apply(rotation, i, ParamRef(i) if trainable else float(xi))
    return qc.to_spec()

basis_encode

basis_encode(bits: Sequence[int]) -> CircuitSpec

Computational-basis encoding: flip a qubit wherever the bit is 1.

Source code in src/qmlkit/encoding/angle.py
def basis_encode(bits: Sequence[int]) -> CircuitSpec:
    """Computational-basis encoding: flip a qubit wherever the bit is 1."""
    values = [int(b) for b in bits]
    if not values:
        raise ValueError("basis_encode needs at least one bit")
    if any(b not in (0, 1) for b in values):
        raise ValueError(f"basis_encode takes 0/1 values, got {values}")
    qc = QCircuit(len(values))
    for i, b in enumerate(values):
        if b == 1:
            qc.x(i)
    return qc.to_spec()

basis_index

basis_index(bits: Sequence[int]) -> int

[1, 0, 1] -> 5. Qubit 0 is the most significant bit.

Source code in src/qmlkit/encoding/angle.py
def basis_index(bits: Sequence[int]) -> int:
    """``[1, 0, 1] -> 5``. Qubit 0 is the most significant bit."""
    out = 0
    for b in bits:
        out = (out << 1) | int(b)
    return out

n_qubits_for

n_qubits_for(n_values: int) -> int

Qubits needed to hold n_values amplitudes: ceil(log2 N).

Source code in src/qmlkit/encoding/angle.py
def n_qubits_for(n_values: int) -> int:
    """Qubits needed to hold ``n_values`` amplitudes: ``ceil(log2 N)``."""
    if n_values <= 0:
        raise ValueError("n_values must be positive")
    return max(1, int(np.ceil(np.log2(n_values))))

qmlkit.encoding.amplitude

amplitude

Amplitude encoding — :math:n qubits hold :math:2^n numbers.

Built from uniformly-controlled rotations, not from a backend state-preparation primitive. That matters: the resulting circuit is made of ordinary registered gates, so it runs identically on every backend, can be drawn and transpiled, and its resource cost is visible rather than hidden inside an SDK call.

The construction is the standard one. Magnitudes come from a binary tree of partial norms, each level applying a uniformly-controlled Ry; phases, when the data is complex, come from a second cascade of uniformly-controlled Rz.

A uniformly-controlled rotation decomposes recursively:

.. code-block:: text

UCR(theta, [c0, ...], t) = UCR(alpha, [...], t) . CX(c0, t) . UCR(beta, [...], t) . CX(c0, t)
alpha_j = (theta_j + theta_{j + h}) / 2      beta_j = (theta_j - theta_{j + h}) / 2

which costs 2**m rotations and 2**m CX gates for m controls — the exponential price of loading exponentially many numbers.

Global phase. The phase cascade reproduces every relative phase exactly and drops one overall factor, which is unobservable. If you embed an amplitude-encoded block inside a larger controlled circuit, that factor stops being global; use check=True to assert the prepared state matches your target up to phase.

pad_to_power_of_two

pad_to_power_of_two(
    vec: Sequence[float] | NDArray[Any],
) -> NDArray[Any]

Zero-pad a vector up to the next power of two.

Source code in src/qmlkit/encoding/amplitude.py
def pad_to_power_of_two(vec: Sequence[float] | npt.NDArray[Any]) -> npt.NDArray[Any]:
    """Zero-pad a vector up to the next power of two."""
    arr = np.atleast_1d(np.asarray(vec)).ravel()
    if arr.size == 0:
        raise ValueError("cannot encode an empty vector")
    # at least one qubit, so the amplitude vector always matches the circuit width
    n = max(1, int(np.ceil(np.log2(arr.size))))
    size = 2**n
    if arr.size == size:
        return arr.astype(complex)
    out = np.zeros(size, dtype=complex)
    out[: arr.size] = arr
    return out

uniformly_controlled_rotation

uniformly_controlled_rotation(
    qc: QCircuit,
    rotation: str,
    angles: NDArray[Any],
    controls: Sequence[int],
    target: int,
) -> None

Apply R(angles[k]) to target for each control basis state k.

controls[0] is the most significant bit of k. Emits only Ry/Rz and CX, so it works on any backend.

Source code in src/qmlkit/encoding/amplitude.py
def uniformly_controlled_rotation(
    qc: QCircuit, rotation: str, angles: npt.NDArray[Any], controls: Sequence[int], target: int
) -> None:
    """Apply ``R(angles[k])`` to ``target`` for each control basis state ``k``.

    ``controls[0]`` is the most significant bit of ``k``. Emits only ``Ry``/``Rz``
    and ``CX``, so it works on any backend.
    """
    angles = np.asarray(angles, dtype=float).ravel()
    m = len(controls)
    if angles.size != 2**m:
        raise ValueError(f"expected {2**m} angles for {m} controls, got {angles.size}")

    if m == 0:
        if abs(angles[0]) > 1e-15:
            qc.apply(rotation, target, float(angles[0]))
        return

    half = angles.size // 2
    alpha = (angles[:half] + angles[half:]) / 2.0
    beta = (angles[:half] - angles[half:]) / 2.0
    rest = list(controls[1:])

    uniformly_controlled_rotation(qc, rotation, alpha, rest, target)
    qc.cx(controls[0], target)
    uniformly_controlled_rotation(qc, rotation, beta, rest, target)
    qc.cx(controls[0], target)

state_preparation_angles

state_preparation_angles(
    amplitudes: NDArray[Any],
) -> tuple[list[NDArray[Any]], list[NDArray[Any]]]

Ry angles per level (magnitudes) and Rz angles per level (phases).

Source code in src/qmlkit/encoding/amplitude.py
def state_preparation_angles(
    amplitudes: npt.NDArray[Any],
) -> tuple[list[npt.NDArray[Any]], list[npt.NDArray[Any]]]:
    """Ry angles per level (magnitudes) and Rz angles per level (phases)."""
    amps = np.asarray(amplitudes, dtype=complex).ravel()
    n = int(np.log2(amps.size))

    # --- magnitudes: a binary tree of partial norms -------------------------
    norms: list[npt.NDArray[Any]] = [np.abs(amps)]
    for _ in range(n):
        prev = norms[0]
        norms.insert(0, np.sqrt(prev[0::2] ** 2 + prev[1::2] ** 2))

    ry_angles: list[npt.NDArray[Any]] = []
    for level in range(n):
        parent = norms[level]
        child = norms[level + 1]
        # Ry(theta)|0> = cos(theta/2)|0> + sin(theta/2)|1>, so the branch ratio
        # fixes theta = 2 * atan2(lower, upper). A zero-norm parent contributes
        # nothing observable, so its angle is free; zero keeps the circuit small.
        with np.errstate(invalid="ignore", divide="ignore"):
            theta = 2.0 * np.arctan2(child[1::2], child[0::2])
        theta = np.where(parent > 1e-15, theta, 0.0)
        ry_angles.append(theta)

    # --- phases: pair up, emit the difference, recurse on the mean ----------
    rz_angles: list[npt.NDArray[Any]] = []
    phases = np.angle(amps)
    if np.allclose(phases, 0.0, atol=1e-15):
        return ry_angles, rz_angles

    current = phases
    for _ in range(n):
        # Rz(a) = diag(e^{-ia/2}, e^{+ia/2}), so a = phi_upper_pair_difference
        rz_angles.insert(0, current[1::2] - current[0::2])
        current = (current[0::2] + current[1::2]) / 2.0  # leftover -> higher level
    return ry_angles, rz_angles

amplitude_encode

amplitude_encode(
    vec: Sequence[float] | NDArray[Any],
    normalize: bool = True,
    pad: bool = True,
    check: bool = False,
) -> CircuitSpec

Encode a vector into the amplitudes of ceil(log2 len(vec)) qubits.

Only the direction of the vector survives — amplitudes must be normalised, so the magnitude is lost. normalize=False refuses a vector that is not already a unit vector rather than silently rescaling it.

check=True re-simulates the circuit and asserts it prepares the intended state (up to global phase). Cheap insurance while you are getting a pipeline working; leave it off in a training loop.

Source code in src/qmlkit/encoding/amplitude.py
def amplitude_encode(
    vec: Sequence[float] | npt.NDArray[Any],
    normalize: bool = True,
    pad: bool = True,
    check: bool = False,
) -> CircuitSpec:
    """Encode a vector into the amplitudes of ``ceil(log2 len(vec))`` qubits.

    Only the *direction* of the vector survives — amplitudes must be normalised, so
    the magnitude is lost. ``normalize=False`` refuses a vector that is not already
    a unit vector rather than silently rescaling it.

    ``check=True`` re-simulates the circuit and asserts it prepares the intended
    state (up to global phase). Cheap insurance while you are getting a pipeline
    working; leave it off in a training loop.
    """
    arr = np.atleast_1d(np.asarray(vec)).ravel()
    if not pad and arr.size & (arr.size - 1):
        raise ValueError(f"length {arr.size} is not a power of two; pass pad=True to zero-fill")
    amps = pad_to_power_of_two(arr)

    norm = np.linalg.norm(amps)
    if norm < 1e-15:
        raise ValueError("cannot encode the zero vector: it has no direction")
    if normalize:
        amps = amps / norm
    elif not np.isclose(norm, 1.0, atol=1e-9):
        raise ValueError(f"vector has norm {norm:.6g}, not 1; pass normalize=True to rescale it")

    n_qubits = int(np.log2(amps.size))
    ry_angles, rz_angles = state_preparation_angles(amps)

    qc = QCircuit(n_qubits)
    for level, angles in enumerate(ry_angles):
        uniformly_controlled_rotation(qc, "ry", angles, list(range(level)), level)
    for level, angles in enumerate(rz_angles):
        uniformly_controlled_rotation(qc, "rz", angles, list(range(level)), level)
    spec = qc.to_spec()

    if check:
        from qmlkit.core.execute import statevector

        got = statevector(spec)
        overlap = abs(np.vdot(amps, got))
        if not np.isclose(overlap, 1.0, atol=1e-8):
            raise AssertionError(
                f"amplitude encoding produced a state with overlap {overlap:.12f}, expected 1"
            )
    return spec

qmlkit.encoding.feature_maps

feature_maps

Pauli feature maps.

Each term :math:S of a feature map contributes

.. math:: \exp!\left(-i\,\phi_S(x) \prod_{j \in S} P_j\right)

realised as W . (CX ladder, Rz(2 phi), CX ladder) . W^dagger, where W is the basis change that sends each Pauli to Z. The default data map is the standard one: :math:\phi_{\{i\}}(x) = x_i for singletons and :math:\phi_S(x) = \prod_{j \in S}(\pi - x_j) for higher-order terms.

A Pauli feature map needs two pieces that are usually left implicit: the basis change that diagonalises each Pauli string, and the data map that turns features into angles. Both are public here — :func:basis_change and :func:default_data_map — so either can be replaced without rewriting the map, and the map is tested against the analytic kernel it is supposed to induce rather than against itself.

FeatureMap

Turns a feature vector into a circuit.

Subclasses implement :meth:build. adjoint comes free from the IR, which is what the fidelity kernel's compute-uncompute test needs.

n_angles property
n_angles: int

How many distinct angles the map uses.

build
build(x: ArrayLike) -> CircuitSpec

The circuit for a concrete feature vector.

Source code in src/qmlkit/encoding/feature_maps.py
def build(self, x: ArrayLike) -> CircuitSpec:
    """The circuit for a concrete feature vector."""
    return self._emit(self.angles(x))
angles
angles(x: ArrayLike) -> NDArray[Any]

The rotation angles this map derives from x.

Source code in src/qmlkit/encoding/feature_maps.py
def angles(self, x: ArrayLike) -> npt.NDArray[Any]:  # pragma: no cover - abstract
    """The rotation angles this map derives from ``x``."""
    raise NotImplementedError
build_parametric
build_parametric(offset: int = 0) -> CircuitSpec

The circuit with each encoding angle as a free parameter.

This is what lets a gradient flow back to the data: the circuit is differentiated with respect to its angles, and the chain rule to x is finished classically by :meth:angle_jacobian.

Source code in src/qmlkit/encoding/feature_maps.py
def build_parametric(self, offset: int = 0) -> CircuitSpec:
    """The circuit with each encoding angle as a free parameter.

    This is what lets a gradient flow back to the *data*: the circuit is
    differentiated with respect to its angles, and the chain rule to ``x`` is
    finished classically by :meth:`angle_jacobian`.
    """
    return self._emit([ParamRef(offset + i) for i in range(self.n_angles)])
angle_jacobian
angle_jacobian(
    x: ArrayLike, eps: float = 1e-06
) -> NDArray[Any]

d(angle) / d(feature), shape (n_angles, n_features).

The default differences the classical data map — no circuits involved, so it costs nothing quantum. Override it when a closed form is available.

Source code in src/qmlkit/encoding/feature_maps.py
def angle_jacobian(self, x: ArrayLike, eps: float = 1e-6) -> npt.NDArray[Any]:
    """``d(angle) / d(feature)``, shape ``(n_angles, n_features)``.

    The default differences the *classical* data map — no circuits involved, so
    it costs nothing quantum. Override it when a closed form is available.
    """
    arr = self._validate(x)
    jac = np.zeros((self.n_angles, arr.size), dtype=float)
    for i in range(arr.size):
        plus = arr.copy()
        minus = arr.copy()
        plus[i] += eps
        minus[i] -= eps
        jac[:, i] = (self.angles(plus) - self.angles(minus)) / (2 * eps)
    return jac
adjoint
adjoint(x: Sequence[float]) -> CircuitSpec

U(x)^dagger — the second half of an inversion-test kernel.

Source code in src/qmlkit/encoding/feature_maps.py
def adjoint(self, x: Sequence[float]) -> CircuitSpec:
    """``U(x)^dagger`` — the second half of an inversion-test kernel."""
    return self.build(x).adjoint()
resources
resources() -> dict[str, object]

Gate counts and depth for a representative input.

Source code in src/qmlkit/encoding/feature_maps.py
def resources(self) -> dict[str, object]:
    """Gate counts and depth for a representative input."""
    return self.build(np.zeros(self.n_features)).resources()

PauliFeatureMap

PauliFeatureMap(
    n_features: int,
    paulis: Sequence[str] = ("Z", "ZZ"),
    reps: int = 2,
    entanglement: str = "linear",
    data_map: DataMap | None = None,
)

Bases: FeatureMap

The general Pauli feature map, for any set of Pauli strings.

Parameters:

Name Type Description Default
n_features int

One qubit per feature.

required
paulis Sequence[str]

Pauli strings to include, e.g. ("Z", "ZZ").

('Z', 'ZZ')
reps int

How many times to repeat the whole block. More reps means higher reachable frequencies, at proportional depth.

2
entanglement str

Pattern for two-body terms: linear/chain, ring, full, or alternating.

'linear'
data_map DataMap | None

Override the default :func:default_data_map.

None
Source code in src/qmlkit/encoding/feature_maps.py
def __init__(
    self,
    n_features: int,
    paulis: Sequence[str] = ("Z", "ZZ"),
    reps: int = 2,
    entanglement: str = "linear",
    data_map: DataMap | None = None,
) -> None:
    if n_features < 1:
        raise ValueError("n_features must be at least 1")
    if reps < 1:
        raise ValueError("reps must be at least 1")
    self.n_features = n_features
    self.n_qubits = n_features
    self.paulis = tuple(paulis)
    self.reps = reps
    self.entanglement = entanglement
    self.data_map = data_map or default_data_map
    self.terms = pauli_terms(self.paulis, n_features, entanglement)
n_angles property
n_angles: int

One angle per term. Reps reuse the same angles, so they add depth only.

angle_jacobian
angle_jacobian(
    x: ArrayLike, eps: float = 1e-06
) -> NDArray[Any]

Closed form for the standard data map; falls back to differencing otherwise.

Source code in src/qmlkit/encoding/feature_maps.py
def angle_jacobian(self, x: ArrayLike, eps: float = 1e-6) -> npt.NDArray[Any]:
    """Closed form for the standard data map; falls back to differencing otherwise."""
    if self.data_map is not default_data_map:
        return super().angle_jacobian(x, eps)
    arr = self._validate(x)
    jac = np.zeros((len(self.terms), arr.size), dtype=float)
    for row, (indices, _) in enumerate(self.terms):
        if len(indices) == 1:
            jac[row, indices[0]] = 2.0  # d(2 x_i)/dx_i
        else:
            for i in indices:  # d/dx_i of 2 * prod_j (pi - x_j)
                others = 1.0
                for j in indices:
                    if j != i:
                        others *= float(np.pi - arr[j])
                jac[row, i] = -2.0 * others
    return jac

ZFeatureMap

ZFeatureMap(n_features: int, reps: int = 2)

Bases: PauliFeatureMap

First-order, no entanglement — so its kernel factorises over features.

Source code in src/qmlkit/encoding/feature_maps.py
def __init__(self, n_features: int, reps: int = 2) -> None:
    super().__init__(n_features, paulis=("Z",), reps=reps)

ZZFeatureMap

ZZFeatureMap(
    n_features: int,
    reps: int = 2,
    entanglement: str = "linear",
)

Bases: PauliFeatureMap

First order plus entangling ZZ couplings — the kernel stops factorising.

Source code in src/qmlkit/encoding/feature_maps.py
def __init__(self, n_features: int, reps: int = 2, entanglement: str = "linear") -> None:
    super().__init__(n_features, paulis=("Z", "ZZ"), reps=reps, entanglement=entanglement)

AngleFeatureMap

AngleFeatureMap(
    n_features: int,
    rotation: str = "ry",
    entangle: bool = True,
    entanglement: str = "chain",
    reps: int = 1,
)

Bases: FeatureMap

One rotation per feature, optionally followed by an entangling layer.

The plainest map there is, and the one whose kernel has a closed form: cos^2((x - x')/2) per feature when entangle=False.

Source code in src/qmlkit/encoding/feature_maps.py
def __init__(
    self,
    n_features: int,
    rotation: str = "ry",
    entangle: bool = True,
    entanglement: str = "chain",
    reps: int = 1,
) -> None:
    if n_features < 1:
        raise ValueError("n_features must be at least 1")
    self.n_features = n_features
    self.n_qubits = n_features
    self.rotation = rotation
    self.entangle = entangle
    self.entanglement = entanglement
    self.reps = reps
angle_jacobian
angle_jacobian(
    x: ArrayLike, eps: float = 1e-06
) -> NDArray[Any]

The map is the identity, so the Jacobian is too.

Source code in src/qmlkit/encoding/feature_maps.py
def angle_jacobian(self, x: ArrayLike, eps: float = 1e-6) -> npt.NDArray[Any]:
    """The map is the identity, so the Jacobian is too."""
    self._validate(x)
    return np.eye(self.n_features)

default_data_map

default_data_map(
    x: NDArray[Any], indices: tuple[int, ...]
) -> float

The standard data map: x_i for one index, prod(pi - x_j) for more.

The product form is what makes higher-order terms nonlinear in the features — a linear map there would leave the kernel factorisable and the entanglers pointless.

Source code in src/qmlkit/encoding/feature_maps.py
def default_data_map(x: npt.NDArray[Any], indices: tuple[int, ...]) -> float:
    """The standard data map: ``x_i`` for one index, ``prod(pi - x_j)`` for more.

    The product form is what makes higher-order terms *nonlinear* in the features —
    a linear map there would leave the kernel factorisable and the entanglers
    pointless.
    """
    if len(indices) == 1:
        return float(x[indices[0]])
    out = 1.0
    for i in indices:
        out *= float(np.pi - x[i])
    return out

basis_change

basis_change(
    pauli: str,
) -> tuple[tuple[str, ...], tuple[str, ...]]

Gates that rotate pauli into the Z basis, and the gates that undo it.

Returns (forward, inverse) as gate-name tuples applied in circuit order. X = H Z H so W = H; Y = (SH) Z (SH)^dagger so W = H S^dagger, which in circuit order is sdg then h.

Source code in src/qmlkit/encoding/feature_maps.py
def basis_change(pauli: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
    """Gates that rotate ``pauli`` into the Z basis, and the gates that undo it.

    Returns ``(forward, inverse)`` as gate-name tuples applied in circuit order.
    ``X = H Z H`` so ``W = H``; ``Y = (SH) Z (SH)^dagger`` so ``W = H S^dagger``,
    which in circuit order is ``sdg`` then ``h``.
    """
    p = pauli.upper()
    if p in ("I", "Z"):
        return (), ()
    if p == "X":
        return ("h",), ("h",)
    if p == "Y":
        return ("sdg", "h"), ("h", "s")
    raise unknown("Pauli", pauli, ("I", "X", "Y", "Z"))

pauli_terms

pauli_terms(
    paulis: Sequence[str],
    n_features: int,
    entanglement: str = "linear",
) -> list[tuple[tuple[int, ...], str]]

Expand Pauli strings into concrete (qubit indices, pauli string) terms.

A one-character string like "Z" becomes one term per qubit. A two-character string like "ZZ" follows the entanglement pattern. Longer strings enumerate combinations of that size.

Source code in src/qmlkit/encoding/feature_maps.py
def pauli_terms(
    paulis: Sequence[str], n_features: int, entanglement: str = "linear"
) -> list[tuple[tuple[int, ...], str]]:
    """Expand Pauli strings into concrete ``(qubit indices, pauli string)`` terms.

    A one-character string like ``"Z"`` becomes one term per qubit. A two-character
    string like ``"ZZ"`` follows the entanglement pattern. Longer strings enumerate
    combinations of that size.
    """
    terms: list[tuple[tuple[int, ...], str]] = []
    for pauli in paulis:
        k = len(pauli)
        if k == 0:
            raise ValueError("empty Pauli string")
        if k == 1:
            idx_sets: list[tuple[int, ...]] = [(i,) for i in range(n_features)]
        elif k == 2:
            pattern = "chain" if entanglement == "linear" else entanglement
            idx_sets = [tuple(p) for p in entangler_pairs(n_features, pattern)]
        else:
            idx_sets = list(combinations(range(n_features), k))
        for idx in idx_sets:
            terms.append((idx, pauli.upper()))
    return terms

qmlkit.encoding.hamiltonian

hamiltonian

Hamiltonian (IQP-style) encoding and data re-uploading.

Hamiltonian encoding evolves the register under a data-dependent Hamiltonian :math:H(x) = \sum_i x_i Z_i + \sum_{(i,j)} x_i x_j Z_i Z_j for a time t, Trotterised into steps slices. Because every term commutes here, the Trotter split is exact at any number of steps — steps changes the circuit depth and nothing else. That is worth knowing before anyone tunes it hoping for accuracy.

Data re-uploading interleaves the encoding with trainable blocks. Each repeat widens the reachable Fourier spectrum: L uploads reach frequencies 0..L, which is the knob that decides which functions the model can represent at all, separately from the ansatz that picks the coefficients.

DataReuploadEncoder

DataReuploadEncoder(
    n_features: int,
    n_uploads: int = 3,
    rotations: Sequence[str] = ("rz", "ry", "rz"),
    encoding_rotation: str = "ry",
    entanglement: str | None = "chain",
    trainable_input: bool = False,
)

One convenient re-uploading shape: angle encoding, rotations, entangler.

.. note:: Re-uploading is a pattern, not a structure — any feature map, any trainable block, any interleaving. This class fixes one convenient choice. For anything else use :func:qmlkit.reupload, or compose :class:~qmlkit.ansatz.blocks.EncodingLayer directly with the block vocabulary. This remains for the plain angle-encoding case.

The circuit alternates S(x) — an angle encoding — with W(theta), a trainable rotation block, n_uploads times. Data enters as literals by default; pass trainable_input=True to make the features circuit parameters too, which is what yields df/dx for a classical pre-net.

The parameter vector is laid out as (n_uploads, n_qubits, len(rotations)), flattened, with the input parameters (if trainable) appended after it.

Source code in src/qmlkit/encoding/hamiltonian.py
def __init__(
    self,
    n_features: int,
    n_uploads: int = 3,
    rotations: Sequence[str] = ("rz", "ry", "rz"),
    encoding_rotation: str = "ry",
    entanglement: str | None = "chain",
    trainable_input: bool = False,
) -> None:
    if n_features < 1:
        raise ValueError("n_features must be at least 1")
    if n_uploads < 1:
        raise ValueError("n_uploads must be at least 1")
    self.n_features = n_features
    self.n_qubits = n_features
    self.n_uploads = n_uploads
    self.rotations = tuple(rotations)
    self.encoding_rotation = encoding_rotation
    self.entanglement = entanglement
    self.trainable_input = trainable_input

    # A trainable block that COMMUTES with the encoding is a silent trap:
    # Ry(x) Ry(t1) Ry(x) Ry(t2) = Ry(2x + t1 + t2), so the model collapses to a
    # single frequency and every weight becomes a phase shift. Measured: with
    # W = Ry only, L uploads give exactly one frequency (amplitude 1.0); with
    # W = Rz Ry Rz they give the full 0..L spectrum.
    if set(self.rotations) <= {self.encoding_rotation}:
        warnings.warn(
            f"rotations={self.rotations} commutes with encoding_rotation="
            f"{self.encoding_rotation!r}, so the uploads collapse into a single "
            f"rotation: the model reaches only frequency {n_uploads}, not 0..{n_uploads}, "
            "and its weights have no effect beyond a phase. Use a non-commuting "
            'block such as ("rz", "ry", "rz").',
            UserWarning,
            stacklevel=2,
        )
n_weights property
n_weights: int

Trainable angles in the variational blocks.

n_params property
n_params: int

Total circuit parameters — weights, plus inputs when they are trainable.

build
build(x: Sequence[float] | None = None) -> CircuitSpec

Build the circuit.

With trainable_input=False (default) x is required and baked in. With trainable_input=True x is ignored: the features become parameters, supplied later alongside the weights.

Source code in src/qmlkit/encoding/hamiltonian.py
def build(self, x: Sequence[float] | None = None) -> CircuitSpec:
    """Build the circuit.

    With ``trainable_input=False`` (default) ``x`` is required and baked in.
    With ``trainable_input=True`` ``x`` is ignored: the features become
    parameters, supplied later alongside the weights.
    """
    if not self.trainable_input:
        if x is None:
            raise ValueError("x is required unless trainable_input=True")
        values = np.atleast_1d(np.asarray(x, dtype=float)).ravel()
        if values.size != self.n_features:
            raise ValueError(f"expected {self.n_features} features, got {values.size}")

    qc = QCircuit(self.n_qubits)
    w = 0
    for _ in range(self.n_uploads):
        for i in range(self.n_qubits):  # S(x): inject the data
            angle = ParamRef(self.n_weights + i) if self.trainable_input else float(values[i])
            qc.apply(self.encoding_rotation, i, angle)
        for i in range(self.n_qubits):  # W(theta): the trainable block
            for gate in self.rotations:
                qc.apply(gate, i, ParamRef(w))
                w += 1
        if self.entanglement and self.n_qubits > 1:
            qc.entangle(self.entanglement)
    return qc.to_spec()

trotter_rz_angle

trotter_rz_angle(xi: float, t: float, steps: int) -> float

Single-qubit Rz angle per Trotter step: 2 * x_i * t / steps.

Source code in src/qmlkit/encoding/hamiltonian.py
def trotter_rz_angle(xi: float, t: float, steps: int) -> float:
    """Single-qubit ``Rz`` angle per Trotter step: ``2 * x_i * t / steps``."""
    if steps < 1:
        raise ValueError("steps must be at least 1")
    return 2.0 * float(xi) * float(t) / steps

trotter_zz_angle

trotter_zz_angle(
    xi: float, xj: float, t: float, steps: int
) -> float

Two-qubit coupling angle per Trotter step: 2 * x_i * x_j * t / steps.

Source code in src/qmlkit/encoding/hamiltonian.py
def trotter_zz_angle(xi: float, xj: float, t: float, steps: int) -> float:
    """Two-qubit coupling angle per Trotter step: ``2 * x_i * x_j * t / steps``."""
    if steps < 1:
        raise ValueError("steps must be at least 1")
    return 2.0 * float(xi) * float(xj) * float(t) / steps

hamiltonian_encode

hamiltonian_encode(
    x: Sequence[float],
    t: float = 1.0,
    steps: int = 3,
    entanglement: str = "chain",
    initial_hadamard: bool = True,
) -> CircuitSpec

Evolve under a data-dependent Ising Hamiltonian.

initial_hadamard=True starts in the uniform superposition, which is what makes the Z-diagonal evolution do anything observable — without it the register stays in a computational basis state and only picks up a global phase.

Source code in src/qmlkit/encoding/hamiltonian.py
def hamiltonian_encode(
    x: Sequence[float],
    t: float = 1.0,
    steps: int = 3,
    entanglement: str = "chain",
    initial_hadamard: bool = True,
) -> CircuitSpec:
    """Evolve under a data-dependent Ising Hamiltonian.

    ``initial_hadamard=True`` starts in the uniform superposition, which is what
    makes the Z-diagonal evolution do anything observable — without it the register
    stays in a computational basis state and only picks up a global phase.
    """
    values = np.atleast_1d(np.asarray(x, dtype=float)).ravel()
    n = values.size
    if n == 0:
        raise ValueError("hamiltonian_encode needs at least one feature")
    if steps < 1:
        raise ValueError("steps must be at least 1")

    qc = QCircuit(n)
    if initial_hadamard:
        for i in range(n):
            qc.h(i)

    pairs = entangler_pairs(n, entanglement)
    for _ in range(steps):
        for i, xi in enumerate(values):
            qc.rz(i, trotter_rz_angle(xi, t, steps))
        for a, b in pairs:
            qc.cx(a, b)
            qc.rz(b, trotter_zz_angle(values[a], values[b], t, steps))
            qc.cx(a, b)
    return qc.to_spec()

n_reachable_frequencies

n_reachable_frequencies(n_uploads: int) -> int

L uploads reach frequencies 0..L -- so L + 1 of them.

This holds only when the trainable block does not commute with the encoding rotation. If it does, the uploads merge into one rotation and the model reaches a single frequency instead. :class:DataReuploadEncoder warns when you build such a pairing.

Source code in src/qmlkit/encoding/hamiltonian.py
def n_reachable_frequencies(n_uploads: int) -> int:
    """``L`` uploads reach frequencies ``0..L`` -- so ``L + 1`` of them.

    This holds only when the trainable block does **not** commute with the encoding
    rotation. If it does, the uploads merge into one rotation and the model reaches
    a single frequency instead. :class:`DataReuploadEncoder` warns when you build
    such a pairing.
    """
    if n_uploads < 0:
        raise ValueError("n_uploads cannot be negative")
    return n_uploads + 1

qmlkit.encoding.pipeline

Standardise, reduce to n_qubits columns, scale into rotation angles — one scikit-learn-clonable object, used in every case study.

pipeline

Getting a real dataset onto a small number of qubits, once and reproducibly.

Almost every quantum model starts the same way: standardise the features, reduce them to as many columns as you have qubits, and scale those into rotation angles. Done by hand that is three objects to keep in sync and one easy mistake — fitting the reducer on the test set — so it is one object here.

pipeline = FeaturePipeline(n_qubits=4).fit(X_train)
Z_train, Z_test = pipeline.transform(X_train), pipeline.transform(X_test)

fit sees only the training data, and transform reuses exactly what it learned. :attr:FeaturePipeline.explained_variance_ reports what the reduction cost, because a model that never saw 20% of the variance is not underperforming — it was never shown the data.

Everything here is duck-typed to scikit-learn's estimator protocol (get_params / set_params / fit / transform), so it drops into Pipeline and GridSearchCV without qmlkit depending on scikit-learn.

SklearnCompatible

get_params / set_params, read off the constructor signature.

scikit-learn duck-types: clone, Pipeline and GridSearchCV need these two methods, not a base class. Implementing them directly is what lets a qmlkit estimator sit in a scikit-learn workflow while scikit-learn stays an optional dependency — which matters, because the NumPy backend is meant to work alone.

The one rule this imposes: an __init__ parameter must be stored on an attribute of the same name, unchanged.

FeaturePipeline

FeaturePipeline(
    n_qubits: int,
    method: str = "pca",
    standardize: bool = True,
    angle_range: tuple[float, float] = (0.0, 2 * pi),
)

Bases: SklearnCompatible

Standardise, reduce to n_qubits columns, and scale into rotation angles.

Parameters:

Name Type Description Default
n_qubits int

How many columns to come out with — one rotation angle per qubit.

required
method str

"pca" keeps the leading principal components. "truncate" keeps the first n_qubits columns, which is only honest when the features are already ordered by importance.

'pca'
standardize bool

Centre and scale to unit variance first. PCA without this is dominated by whichever feature happens to be measured in the largest units.

True
angle_range tuple[float, float]

Where the output lands. The default (0, 2pi) uses the full period of a rotation; a narrower range trades expressiveness for a gentler landscape.

(0.0, 2 * pi)
Source code in src/qmlkit/encoding/pipeline.py
def __init__(
    self,
    n_qubits: int,
    method: str = "pca",
    standardize: bool = True,
    angle_range: tuple[float, float] = (0.0, 2 * np.pi),
) -> None:
    if n_qubits < 1:
        raise ValueError("n_qubits must be at least 1")
    if method not in ("pca", "truncate"):
        raise unknown("method", method, ("pca", "truncate"))
    self.n_qubits = n_qubits
    self.method = method
    self.standardize = standardize
    self.angle_range = angle_range
    self.mean_: npt.NDArray[Any] | None = None
    self.scale_: npt.NDArray[Any] | None = None
    self.reducer_: PCAReducer | None = None
    self.scaler_: AngleScaler | None = None
    self.explained_variance_: float | None = None
fit
fit(X: NDArray[Any], y: Any = None) -> FeaturePipeline

Learn every step from the training data alone.

Source code in src/qmlkit/encoding/pipeline.py
def fit(self, X: npt.NDArray[Any], y: Any = None) -> FeaturePipeline:
    """Learn every step from the training data alone."""
    data = np.atleast_2d(np.asarray(X, dtype=float))
    if data.shape[1] < self.n_qubits:
        raise ValueError(
            f"cannot map {data.shape[1]} features onto {self.n_qubits} qubits; "
            "reduction can only remove columns, not invent them"
        )

    if self.standardize:
        self.mean_ = data.mean(axis=0)
        spread = data.std(axis=0)
        self.scale_ = np.where(spread > 1e-12, spread, 1.0)  # constant columns survive
        data = (data - self.mean_) / self.scale_
    else:
        self.mean_ = self.scale_ = None

    if self.method == "pca" and data.shape[1] != self.n_qubits:
        self.reducer_ = PCAReducer(self.n_qubits).fit(data)
        data = self.reducer_.transform(data)
        ratios = self.reducer_.explained_variance_ratio_
        self.explained_variance_ = float(np.sum(ratios)) if ratios is not None else 0.0
    else:
        self.reducer_ = None
        data = data[:, : self.n_qubits]
        self.explained_variance_ = None

    low, high = self.angle_range
    self.scaler_ = AngleScaler(lo=low, hi=high).fit(data)
    return self
transform
transform(X: NDArray[Any]) -> NDArray[Any]

Apply the fitted steps. Never re-fits — that is the whole point.

Source code in src/qmlkit/encoding/pipeline.py
def transform(self, X: npt.NDArray[Any]) -> npt.NDArray[Any]:
    """Apply the fitted steps. Never re-fits — that is the whole point."""
    if self.scaler_ is None:
        raise ValueError("FeaturePipeline must be fitted before transform()")
    data = np.atleast_2d(np.asarray(X, dtype=float))
    if self.standardize and self.mean_ is not None and self.scale_ is not None:
        data = (data - self.mean_) / self.scale_
    if self.reducer_ is not None:
        data = self.reducer_.transform(data)
    else:
        data = data[:, : self.n_qubits]
    return self.scaler_.transform(data)

qmlkit.encoding.scaling

scaling

Getting real data into the range and width an encoding needs.

Two problems every quantum model hits before any quantum step happens: features arrive on arbitrary scales when rotations want radians, and there are usually more features than qubits.

These are preprocessing, not classical baselines — no model is fitted here, and there is no sklearn dependency. The PCA reduction is a plain SVD.

AngleScaler dataclass

AngleScaler(
    lo: float = 0.0,
    hi: float = 2 * pi,
    data_min: NDArray[Any] | None = None,
    data_max: NDArray[Any] | None = None,
)

Fit-then-transform angle scaling, so train and test share one range.

PCAReducer dataclass

PCAReducer(
    n_components: int,
    mean_: NDArray[Any] | None = None,
    components_: NDArray[Any] | None = None,
    explained_variance_ratio_: NDArray[Any] | None = None,
)

Project features onto their leading principal components, via SVD.

Angle encoding needs one qubit per feature, so a 64-feature dataset needs 64 qubits — usually out of reach. Reducing first is the ordinary way through, and explained_variance_ratio_ says how much you gave up doing it.

to_angle_range

to_angle_range(
    x: NDArray[Any],
    lo: float = 0.0,
    hi: float = 2 * pi,
    data_min: NDArray[Any] | None = None,
    data_max: NDArray[Any] | None = None,
) -> NDArray[Any]

Rescale features into an angle window, per column.

Fit the range on training data and reuse it on test data by passing data_min/data_max explicitly — otherwise each call rescales to its own extremes, which silently makes train and test incomparable. A constant column maps to the middle of the window rather than dividing by zero.

Source code in src/qmlkit/encoding/scaling.py
def to_angle_range(
    x: npt.NDArray[Any],
    lo: float = 0.0,
    hi: float = 2 * np.pi,
    data_min: npt.NDArray[Any] | None = None,
    data_max: npt.NDArray[Any] | None = None,
) -> npt.NDArray[Any]:
    """Rescale features into an angle window, per column.

    Fit the range on training data and reuse it on test data by passing
    ``data_min``/``data_max`` explicitly — otherwise each call rescales to its own
    extremes, which silently makes train and test incomparable. A constant column
    maps to the middle of the window rather than dividing by zero.
    """
    arr = np.atleast_2d(np.asarray(x, dtype=float))
    lo_v = np.asarray(arr.min(axis=0) if data_min is None else data_min, dtype=float)
    hi_v = np.asarray(arr.max(axis=0) if data_max is None else data_max, dtype=float)
    span = hi_v - lo_v
    flat = span == 0
    span = np.where(flat, 1.0, span)
    unit = (arr - lo_v) / span
    unit = np.where(flat, 0.5, unit)
    return (lo + unit * (hi - lo)).reshape(np.asarray(x).shape)

reduce_to_qubits

reduce_to_qubits(
    x: NDArray[Any],
    n_qubits: int,
    method: str = "pca",
    to_angles: bool = True,
    lo: float = 0.0,
    hi: float = 2 * pi,
) -> NDArray[Any]

Reduce a feature matrix to n_qubits columns, ready for angle encoding.

method="pca" keeps the leading principal components; method="truncate" keeps the first n_qubits columns unchanged, which is only sensible when the features are already ordered by importance.

Source code in src/qmlkit/encoding/scaling.py
def reduce_to_qubits(
    x: npt.NDArray[Any],
    n_qubits: int,
    method: str = "pca",
    to_angles: bool = True,
    lo: float = 0.0,
    hi: float = 2 * np.pi,
) -> npt.NDArray[Any]:
    """Reduce a feature matrix to ``n_qubits`` columns, ready for angle encoding.

    ``method="pca"`` keeps the leading principal components; ``method="truncate"``
    keeps the first ``n_qubits`` columns unchanged, which is only sensible when the
    features are already ordered by importance.
    """
    arr = np.atleast_2d(np.asarray(x, dtype=float))
    if n_qubits < 1:
        raise ValueError("n_qubits must be at least 1")
    if method == "pca":
        reduced = arr if arr.shape[1] == n_qubits else PCAReducer(n_qubits).fit_transform(arr)
    elif method == "truncate":
        if arr.shape[1] < n_qubits:
            raise ValueError(f"only {arr.shape[1]} features available, need {n_qubits}")
        reduced = arr[:, :n_qubits]
    else:
        raise unknown("method", method, ("pca", "truncate"))
    return to_angle_range(reduced, lo, hi) if to_angles else reduced