Skip to content

Ansatz

A block vocabulary, the templates written in it, and re-uploading as a composition.

qmlkit.ansatz.blocks

blocks

A composable vocabulary for building ansätze.

Most published ansätze are a handful of primitives stacked in a pattern. Writing them as expressions in that vocabulary — rather than as bespoke functions — means a new one costs a line and inherits everything: correct parameter-shift rules, resource counting, a torch layer, and a place in the registry.

hardware_efficient = repeat(n_layers, RotationLayer(("ry", "rz")) + EntanglerLayer("cx"))

Blocks allocate their own parameters through a build context, so nothing is ever hand-counted. :func:share re-uses one set of parameters across repetitions — weight tying — which the gradient code already handles per occurrence.

BuildContext

BuildContext(n_qubits: int, n_inputs: int = 0)

Hands out parameter slots and tracks which qubits are still active.

active shrinks as pooling layers halve the register (the QCNN pattern), so later blocks act on what is left rather than on the full width.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, n_qubits: int, n_inputs: int = 0) -> None:
    self.n_qubits = n_qubits
    self.n_inputs = n_inputs
    self.active: list[int] = list(range(n_qubits))
    # inputs occupy the first n_inputs indices; weights follow. That split is what
    # lets one flat vector carry both while df/dx and df/dtheta stay separable.
    self._next = n_inputs
    self._replay: list[int] | None = None
    self._replay_pos = 0
    self._log: list[int] = []
    # input slots are carved out of [0, n_inputs) one feature map at a time, so
    # two maps never land on each other's angles. Keyed by identity and holding a
    # strong reference, because a freed id() could be handed to a later map.
    self._input_owners: list[object] = []
    self._input_ranges: list[tuple[int, int]] = []
    self._next_input = 0
n_params property
n_params: int

Total parameters, inputs included.

n_weights property
n_weights: int

Trainable parameters only.

input_ref
input_ref(
    i: int, scale: float = 1.0, offset: float = 0.0
) -> ParamRef

Reference encoding angle i. Re-uploads reuse the same reference.

Source code in src/qmlkit/ansatz/blocks.py
def input_ref(self, i: int, scale: float = 1.0, offset: float = 0.0) -> ParamRef:
    """Reference encoding angle ``i``. Re-uploads reuse the same reference."""
    if not 0 <= i < self.n_inputs:
        raise IndexError(f"input {i} out of range for n_inputs={self.n_inputs}")
    return ParamRef(i, scale, offset)
input_slots
input_slots(owner: object, n_angles: int) -> list[ParamRef]

The input slots belonging to owner, allocating them on first sight.

A slot holds an angle, and every feature map derives its angles differently — ZZFeatureMap emits 2*x_i where AngleFeatureMap emits x_i. So angle i of one map is not angle i of another, and two maps sharing a slot would silently feed one map's transformed angles into the other. Each map therefore owns a disjoint range, while the same map re-used gets the range it already has — which is what makes re-uploading feed the same data in again.

Source code in src/qmlkit/ansatz/blocks.py
def input_slots(self, owner: object, n_angles: int) -> list[ParamRef]:
    """The input slots belonging to ``owner``, allocating them on first sight.

    A slot holds an *angle*, and every feature map derives its angles differently
    — ``ZZFeatureMap`` emits ``2*x_i`` where ``AngleFeatureMap`` emits ``x_i``. So
    angle ``i`` of one map is not angle ``i`` of another, and two maps sharing a
    slot would silently feed one map's transformed angles into the other. Each map
    therefore owns a disjoint range, while *the same map* re-used gets the range it
    already has — which is what makes re-uploading feed the same data in again.
    """
    for existing, (start, stop) in zip(self._input_owners, self._input_ranges, strict=True):
        if existing is owner:
            return [ParamRef(i) for i in range(start, stop)]
    start = self._next_input
    stop = start + n_angles
    if stop > self.n_inputs:
        already = (
            f"{self._owner_summary()}, and {n_angles} more for this one"
            if self._input_ranges
            else f"{n_angles} for this one"
        )
        raise ValueError(
            f"the circuit reserves {self.n_inputs} input slot(s), and the encodings "
            f"in it need at least {stop}: {already}. Leave n_inputs unset and it is "
            f"inferred; raising it to one feature map's angle count is what makes "
            f"two maps overlap."
        )
    self._input_owners.append(owner)
    self._input_ranges.append((start, stop))
    self._next_input = stop
    return [ParamRef(i) for i in range(start, stop)]
new_param
new_param(
    scale: float = 1.0, offset: float = 0.0
) -> ParamRef

Allocate a parameter — or replay a shared one, when tying weights.

Source code in src/qmlkit/ansatz/blocks.py
def new_param(self, scale: float = 1.0, offset: float = 0.0) -> ParamRef:
    """Allocate a parameter — or replay a shared one, when tying weights."""
    if self._replay is not None:
        index = self._replay[self._replay_pos % len(self._replay)]
        self._replay_pos += 1
    else:
        index = self._next
        self._next += 1
        self._log.append(index)
    return ParamRef(index, scale, offset)
replaying
replaying(indices: Sequence[int]) -> Iterator[None]

Re-issue these parameter indices instead of allocating new ones.

Source code in src/qmlkit/ansatz/blocks.py
@contextmanager
def replaying(self, indices: Sequence[int]) -> Iterator[None]:
    """Re-issue these parameter indices instead of allocating new ones."""
    prev, prev_pos = self._replay, self._replay_pos
    self._replay, self._replay_pos = list(indices), 0
    try:
        yield
    finally:
        self._replay, self._replay_pos = prev, prev_pos

Block

One piece of an ansatz. Compose with +.

Sequential

Sequential(blocks: Sequence[Block])

Bases: Block

Blocks applied in order.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, blocks: Sequence[Block]) -> None:
    self.blocks = tuple(blocks)

RotationLayer

RotationLayer(
    gates: str | Sequence[str] = ("ry",),
    wires: Sequence[int] | None = None,
)

Bases: Block

One trainable rotation per gate per active wire.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, gates: str | Sequence[str] = ("ry",), wires: Sequence[int] | None = None):
    self.gates = (gates,) if isinstance(gates, str) else tuple(gates)
    self.wires = tuple(wires) if wires is not None else None
    for g in self.gates:
        if not get_gate(g).is_parametric:
            raise ValueError(f"{g!r} takes no parameters; use EntanglerLayer for fixed gates")

EntanglerLayer

EntanglerLayer(gate: str = 'cx', pattern: str = 'chain')

Bases: Block

A layer of fixed two-qubit gates following a named pattern.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, gate: str = "cx", pattern: str = "chain"):
    if get_gate(gate).is_parametric:
        raise ValueError(f"{gate!r} is parameterised; use ParametricEntangler")
    self.gate = gate
    self.pattern = pattern

ParametricEntangler

ParametricEntangler(
    gate: str = "crz", pattern: str = "ring"
)

Bases: Block

A layer of trainable two-qubit gates — exercises the four-term shift rule.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, gate: str = "crz", pattern: str = "ring"):
    if not get_gate(gate).is_parametric:
        raise ValueError(f"{gate!r} takes no parameters; use EntanglerLayer")
    self.gate = gate
    self.pattern = pattern

PoolLayer

PoolLayer(
    keep: str = "odd",
    mode: str = "discard",
    tied: bool = True,
)

Bases: Block

Halve the active register — the pooling half of a QCNN.

keep="odd" retains every second wire starting from the second, matching the convention where the surviving qubit is the target of the preceding entangler.

Two pooling modes, because the literature uses both:

mode="discard" Simply stop using the wire. Cheap, adds no parameters, and the information it held survives only through whatever the convolution already moved.

mode="controlled" Before dropping a wire, apply a trainable crz from it onto its surviving partner, so pooling learns what to carry forward. This is the simulator's stand-in for the measure-and-conditionally-rotate pooling of Cong, Choi & Lukin, which needs mid-circuit measurement and feed-forward.

tied shares one pooling angle across the whole layer, matching the way a tied convolution shares one filter.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, keep: str = "odd", mode: str = "discard", tied: bool = True):
    if keep not in ("even", "odd"):
        raise unknown("keep", keep, ("even", "odd"))
    if mode not in ("discard", "controlled"):
        raise unknown("mode", mode, ("discard", "controlled"))
    self.keep = keep
    self.mode = mode
    self.tied = tied

Custom

Custom(
    fn: Callable[[QCircuit, BuildContext], None],
    name: str = "Custom",
)

Bases: Block

Wrap an arbitrary fn(qc, ctx) as a block.

The escape hatch: anything the vocabulary cannot express, written directly against the builder, still composes with everything else.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, fn: Callable[[QCircuit, BuildContext], None], name: str = "Custom"):
    self.fn = fn
    self.name = name

EncodingLayer

EncodingLayer(feature_map: object)

Bases: Block

Insert a feature map as a block, so re-uploading is just composition.

Data re-uploading is not one structure. It is any interleaving of an encoding with a trainable block, and which encoding, which block, and in what order are all design choices:

repeat(3, EncodingLayer(fmap) + RotationLayer(("rz", "ry", "rz")) + EntanglerLayer())
repeat(2, RotationLayer("ry") + EncodingLayer(fmap))          # W before S
EncodingLayer(fmap) + repeat(4, RotationLayer("ry"))          # encode once, vary often
EncodingLayer(zz) + RotationLayer("ry") + EncodingLayer(angle)  # two different maps

Which slots a layer reads. Every repeat of the same feature map references the same input angles: re-uploading means feeding the same data in again, not consuming new features. Two different maps get disjoint ranges, allocated in the order they first appear, because a slot holds an angle and each map derives its angles its own way — ZZFeatureMap(2).angles([0.3, 0.7]) is [0.6, 1.4, 13.876], since a Z term follows the Rz(2 phi) convention, while AngleFeatureMap(2).angles([0.3, 0.7]) is [0.3, 0.7]. Angle 0 of one is not angle 0 of the other, so sharing the slot would encode the wrong number without raising.

So the composition above reserves 3 + 2 = 5 input slots, and :meth:~qmlkit.ansatz.library.Ansatz.angles concatenates the maps' angles in the same order. n_inputs is inferred from the block; pass it only to assert a total you already know.

Slots cannot instead be keyed by feature, so that both maps read the raw x: a slot is referenced by a :class:~qmlkit.core.ir.ParamRef, which is affine in one parameter (scale * theta[i] + offset), and a Pauli map's higher-order angle is 2 * prod_j (pi - x_j) — nonlinear, in several features at once. The map from features to angles has to stay classical, which is what :meth:~qmlkit.encoding.feature_maps.FeatureMap.angle_jacobian is for.

To learn the frequencies rather than inherit them from the encoding, put a classical layer in front — nn.Sequential(nn.Linear(d, d), QuantumLayer(...)). That is a scaling of the inputs, which torch already differentiates; it does not need to live inside the circuit.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, feature_map: object) -> None:
    self.feature_map = feature_map

Repeat

Repeat(times: int, block: Block)

Bases: Block

Apply a block times times, with fresh parameters each time.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, times: int, block: Block):
    if times < 1:
        raise ValueError("times must be at least 1")
    self.times = times
    self.block = block

Share

Share(times: int, block: Block)

Bases: Block

Apply a block times times, re-using the same parameters.

Weight tying, as a QCNN's shared convolution filter does. The gradient code sums over each occurrence separately, which is the whole reason the IR tracks slots rather than just parameters.

Source code in src/qmlkit/ansatz/blocks.py
def __init__(self, times: int, block: Block):
    if times < 1:
        raise ValueError("times must be at least 1")
    self.times = times
    self.block = block

encoding_layers

encoding_layers(block: Block) -> list[EncodingLayer]

Every :class:EncodingLayer in a block tree, in the order it is first emitted.

Used to infer how many input slots a model reserves. Repeat and Share re-emit one child, and a repeated encoding re-uses its slots, so each node is reported once — the count that matters here is distinct maps, not uploads.

Source code in src/qmlkit/ansatz/blocks.py
def encoding_layers(block: Block) -> list[EncodingLayer]:
    """Every :class:`EncodingLayer` in a block tree, in the order it is first emitted.

    Used to infer how many input slots a model reserves. ``Repeat`` and ``Share``
    re-emit one child, and a repeated encoding re-uses its slots, so each node is
    reported once — the count that matters here is *distinct maps*, not uploads.
    """
    found: list[EncodingLayer] = []

    def walk(b: Block) -> None:
        if isinstance(b, EncodingLayer):
            found.append(b)
        for attr in ("blocks", "block"):
            child = getattr(b, attr, None)
            if isinstance(child, Block):
                walk(child)
            elif isinstance(child, tuple):
                for item in child:
                    if isinstance(item, Block):
                        walk(item)

    walk(block)
    return found

repeat

repeat(times: int, block: Block) -> Repeat

repeat(3, RotationLayer("ry")) — three layers, fresh weights each.

Source code in src/qmlkit/ansatz/blocks.py
def repeat(times: int, block: Block) -> Repeat:
    """``repeat(3, RotationLayer("ry"))`` — three layers, fresh weights each."""
    return Repeat(times, block)

share

share(times: int, block: Block) -> Share

share(3, conv_block) — three applications of one tied weight set.

Source code in src/qmlkit/ansatz/blocks.py
def share(times: int, block: Block) -> Share:
    """``share(3, conv_block)`` — three applications of one tied weight set."""
    return Share(times, block)

qmlkit.ansatz.library

library

The Ansatz type, the built-in zoo, and the registry.

An ansatz is a width plus a block. That is the whole type — which is what makes proposing a new one a one-liner rather than a subclass:

brick = Ansatz(6, repeat(3, RotationLayer("ry") + EntanglerLayer("cz", "alternating")))

Parameter counts are inferred from a dry build, never hand-counted, so a miscount is not a failure mode. Everything downstream — gradients, resources, the torch layer — reads the resulting IR, so a new ansatz cannot be missing a capability it never had to opt into.

Ansatz

Ansatz(
    n_qubits: int,
    block: Block,
    name: str = "ansatz",
    n_inputs: int | None = None,
)

A trainable circuit: a qubit count and a composable block.

n_inputs is inferred from the encoding layers in the block — each feature map owns a disjoint range of input slots, and the same map re-used shares its own. Pass it only to assert a total you already know; a value that disagrees raises.

Source code in src/qmlkit/ansatz/library.py
def __init__(
    self, n_qubits: int, block: Block, name: str = "ansatz", n_inputs: int | None = None
) -> None:
    if n_qubits < 1:
        raise ValueError("n_qubits must be at least 1")
    self.n_qubits = n_qubits
    self.block = block
    self.name = name
    self._spec: CircuitSpec | None = None
    self.feature_maps: tuple[Any, ...] = _distinct_encodings(block)
    needed = sum(int(m.n_angles) for m in self.feature_maps)
    self.n_inputs = needed if n_inputs is None else n_inputs
    if self.feature_maps and n_inputs is not None and n_inputs != needed:
        raise ValueError(
            f"{name!r} was given n_inputs={n_inputs}, but its encodings reserve "
            f"{needed} slot(s): {_encoding_summary(self.feature_maps)}. Each feature "
            "map owns its own input slots, because each derives its angles its own "
            "way, so the total is their sum and not any single map's angle count. "
            "Leave n_inputs unset and it is inferred."
        )
    widths = {int(m.n_features) for m in self.feature_maps}
    if len(widths) > 1:
        raise ValueError(
            f"{name!r} composes feature maps that read different numbers of "
            f"features ({sorted(widths)}). Every map in one model is handed the "
            "same x, so a model whose maps disagree about its width could never "
            "be bound."
        )
n_params property
n_params: int

Every parameter, inputs included. Inferred — never hand-counted.

n_weights property
n_weights: int

Trainable parameters only, excluding reserved input slots.

n_features property
n_features: int

How wide the data is. Every encoding reads the same x, so they agree.

With no encoding layer there is nothing to transform and the input slots are the features themselves.

build
build(theta: ArrayLike | None = None) -> CircuitSpec

The circuit, bound to theta if given.

theta is the full parameter vector: any reserved input slots first, then the weights. When n_inputs is 0 that is just the weights, but a re-uploading model reserves input slots — use :meth:bind there, which takes data and weights separately.

Source code in src/qmlkit/ansatz/library.py
def build(self, theta: ArrayLike | None = None) -> CircuitSpec:
    """The circuit, bound to ``theta`` if given.

    ``theta`` is the **full** parameter vector: any reserved input slots first,
    then the weights. When ``n_inputs`` is 0 that is just the weights, but a
    re-uploading model reserves input slots — use :meth:`bind` there, which takes
    data and weights separately.
    """
    spec = self._template()
    if theta is None:
        return spec
    arr = np.asarray(theta, dtype=float).ravel()
    if self.n_inputs and arr.size == self.n_weights:
        raise ValueError(
            f"{type(self).__name__} reserves {self.n_inputs} input slots, so build() "
            f"expects {self.n_params} values (inputs first, then {self.n_weights} "
            "weights). Use .bind(x, weights) to pass data and weights separately."
        )
    return spec.bind(arr)
bind
bind(x: ArrayLike, weights: ArrayLike) -> CircuitSpec

Bind data and weights separately, in that order.

Source code in src/qmlkit/ansatz/library.py
def bind(self, x: ArrayLike, weights: ArrayLike) -> CircuitSpec:
    """Bind data and weights separately, in that order."""
    return self.build(np.concatenate([np.ravel(self.angles(x)), np.ravel(weights)]))
angles
angles(x: ArrayLike) -> NDArray[Any]

The input slots for x, in slot order.

One feature map's angles, or several maps' angles concatenated in the order their slots were allocated. With no encoding layer there is nothing to transform, so the values are the slot values themselves.

Source code in src/qmlkit/ansatz/library.py
def angles(self, x: ArrayLike) -> npt.NDArray[Any]:
    """The input slots for ``x``, in slot order.

    One feature map's angles, or several maps' angles concatenated in the order
    their slots were allocated. With no encoding layer there is nothing to
    transform, so the values are the slot values themselves.
    """
    if not self.feature_maps:
        return np.asarray(x, dtype=float).ravel()
    parts = [np.ravel(m.angles(x)) for m in self.feature_maps]
    return np.concatenate(parts) if len(parts) > 1 else parts[0]
angle_jacobian
angle_jacobian(
    x: ArrayLike, eps: float = 1e-06
) -> NDArray[Any]

d(angle)/d(feature), shape (n_inputs, n_features) — one block per map.

Source code in src/qmlkit/ansatz/library.py
def angle_jacobian(self, x: ArrayLike, eps: float = 1e-6) -> npt.NDArray[Any]:
    """``d(angle)/d(feature)``, shape ``(n_inputs, n_features)`` — one block per map."""
    if not self.feature_maps:
        return np.eye(np.asarray(x, dtype=float).ravel().size)
    rows = [np.atleast_2d(m.angle_jacobian(x)) for m in self.feature_maps]
    return np.vstack(rows) if len(rows) > 1 else rows[0]
init
init(
    method: str = "small",
    seed: int | None = None,
    scale: float = 0.1,
) -> NDArray[Any]

Initial parameters.

small (default) keeps angles near zero, which keeps the circuit near identity and the gradient away from the barren-plateau regime. uniform samples the full range — the standard way to land on a plateau, useful when that is what you are studying. zeros is exactly identity.

Source code in src/qmlkit/ansatz/library.py
def init(
    self, method: str = "small", seed: int | None = None, scale: float = 0.1
) -> npt.NDArray[Any]:
    """Initial parameters.

    ``small`` (default) keeps angles near zero, which keeps the circuit near
    identity and the gradient away from the barren-plateau regime. ``uniform``
    samples the full range — the standard way to *land* on a plateau, useful
    when that is what you are studying. ``zeros`` is exactly identity.
    """
    rng = np.random.default_rng(seed)
    n = self.n_weights
    if method == "small":
        return rng.normal(0.0, scale, n)
    if method == "uniform":
        return rng.uniform(-np.pi, np.pi, n)
    if method == "zeros":
        return np.zeros(n)
    raise unknown("init method", method, ("small", "uniform", "zeros"))
resources
resources() -> dict[str, object]

Gate counts, depth, and the real gradient cost.

Source code in src/qmlkit/ansatz/library.py
def resources(self) -> dict[str, object]:
    """Gate counts, depth, and the *real* gradient cost."""
    from qmlkit.gradients.parameter_shift import grad_circuit_cost

    spec = self._template()
    out = dict(spec.resources())
    out["grad_circuits"] = grad_circuit_cost(spec)
    return out

register_ansatz

register_ansatz(
    name: str, factory: AnsatzFactory | None = None
) -> (
    Callable[[AnsatzFactory], AnsatzFactory] | AnsatzFactory
)

Register an ansatz factory. Usable as a decorator or a direct call.

def brick_wall(n_qubits, n_layers=3): return Ansatz(n_qubits, repeat(n_layers, RotationLayer("ry") + EntanglerLayer("cz", "alternating")))

Source code in src/qmlkit/ansatz/library.py
def register_ansatz(
    name: str, factory: AnsatzFactory | None = None
) -> Callable[[AnsatzFactory], AnsatzFactory] | AnsatzFactory:
    """Register an ansatz factory. Usable as a decorator or a direct call.

    def brick_wall(n_qubits, n_layers=3):
    return Ansatz(n_qubits, repeat(n_layers, RotationLayer("ry")
                                             + EntanglerLayer("cz", "alternating")))
    """

    def _register(f: AnsatzFactory) -> AnsatzFactory:
        if name in _REGISTRY:
            raise ValueError(f"ansatz {name!r} is already registered")
        _REGISTRY[name] = f
        return f

    return _register if factory is None else _register(factory)

get_ansatz

get_ansatz(name: str, **kwargs: object) -> Ansatz

Build a registered ansatz by name.

Source code in src/qmlkit/ansatz/library.py
def get_ansatz(name: str, **kwargs: object) -> Ansatz:
    """Build a registered ansatz by name."""
    try:
        factory = _REGISTRY[name]
    except KeyError:
        raise unknown(
            "ansatz",
            name,
            list_ansatze(),
            hint="Add your own with register_ansatz(name, factory).",
            error=KeyError,
        ) from None
    return factory(**kwargs)

hardware_efficient

hardware_efficient(
    n_qubits: int,
    n_layers: int = 2,
    rotations: Sequence[str] = ("ry", "rz"),
    entangler: str = "cx",
    pattern: str = "chain",
) -> Ansatz

Rotations then entanglers, repeated. General-purpose, barren-plateau prone.

Source code in src/qmlkit/ansatz/library.py
def hardware_efficient(
    n_qubits: int,
    n_layers: int = 2,
    rotations: Sequence[str] = ("ry", "rz"),
    entangler: str = "cx",
    pattern: str = "chain",
) -> Ansatz:
    """Rotations then entanglers, repeated. General-purpose, barren-plateau prone."""
    return Ansatz(
        n_qubits,
        repeat(n_layers, RotationLayer(rotations) + EntanglerLayer(entangler, pattern)),
        "hardware_efficient",
    )

strongly_entangling

strongly_entangling(
    n_qubits: int, n_layers: int = 2
) -> Ansatz

Three rotations per wire, plus a ring of CX per layer.

Source code in src/qmlkit/ansatz/library.py
def strongly_entangling(n_qubits: int, n_layers: int = 2) -> Ansatz:
    """Three rotations per wire, plus a ring of CX per layer."""
    return Ansatz(
        n_qubits,
        repeat(n_layers, RotationLayer(("rz", "ry", "rz")) + EntanglerLayer("cx", "ring")),
        "strongly_entangling",
    )

simplified_two_design

simplified_two_design(
    n_qubits: int, n_layers: int = 2
) -> Ansatz

The standard reference ansatz in barren-plateau studies.

Source code in src/qmlkit/ansatz/library.py
def simplified_two_design(n_qubits: int, n_layers: int = 2) -> Ansatz:
    """The standard reference ansatz in barren-plateau studies."""
    return Ansatz(
        n_qubits,
        RotationLayer("ry")
        + repeat(n_layers, EntanglerLayer("cz", "alternating") + RotationLayer("ry")),
        "simplified_two_design",
    )

tree_tensor_network

tree_tensor_network(
    n_qubits: int,
    filter: str | tuple[ConvFilter, int] = "ry_cx",
    tied: bool = False,
) -> Ansatz

Log-depth merge tree — shallow, and resistant to barren plateaus.

Each merge is a two-qubit filter from the same registry a QCNN convolves with, so the tensor at every node is as general as you choose to pay for.

Source code in src/qmlkit/ansatz/library.py
def tree_tensor_network(
    n_qubits: int,
    filter: str | tuple[ConvFilter, int] = "ry_cx",  # noqa: A002 - the domain word
    tied: bool = False,
) -> Ansatz:
    """Log-depth merge tree — shallow, and resistant to barren plateaus.

    Each merge is a two-qubit ``filter`` from the same registry a QCNN convolves
    with, so the tensor at every node is as general as you choose to pay for.
    """
    fn, n_params = _resolve_filter(filter)

    def build(qc: QCircuit, ctx: BuildContext) -> None:
        nodes = list(ctx.active)
        shared = tuple(ctx.new_param() for _ in range(n_params)) if tied else None
        while len(nodes) > 1:
            nxt = []
            for i in range(0, len(nodes) - 1, 2):
                a, b = nodes[i], nodes[i + 1]
                params = shared or tuple(ctx.new_param() for _ in range(n_params))
                fn(qc, a, b, params)
                nxt.append(b)
            if len(nodes) % 2:
                nxt.append(nodes[-1])
            nodes = nxt
        ctx.active = nodes

    return Ansatz(n_qubits, Custom(build, "ttn"), "tree_tensor_network")

mps_ansatz

mps_ansatz(
    n_qubits: int,
    filter: str | tuple[ConvFilter, int] = "ry_cx",
    tied: bool = False,
) -> Ansatz

A staircase of two-qubit blocks — a bond-dimension-2 matrix product state.

The block is the same two-qubit filter a QCNN convolves with, so it comes from the same registry: "su4" gives a genuine bond-dimension-2 MPS with arbitrary tensors, "ry_cx" the cheap real-valued one. tied=True reuses one tensor down the whole chain, which is the translation-invariant MPS.

Source code in src/qmlkit/ansatz/library.py
def mps_ansatz(
    n_qubits: int,
    filter: str | tuple[ConvFilter, int] = "ry_cx",  # noqa: A002 - the domain word
    tied: bool = False,
) -> Ansatz:
    """A staircase of two-qubit blocks — a bond-dimension-2 matrix product state.

    The block is the same two-qubit *filter* a QCNN convolves with, so it comes from
    the same registry: ``"su4"`` gives a genuine bond-dimension-2 MPS with arbitrary
    tensors, ``"ry_cx"`` the cheap real-valued one. ``tied=True`` reuses one tensor
    down the whole chain, which is the translation-invariant MPS.
    """
    fn, n_params = _resolve_filter(filter)

    def build(qc: QCircuit, ctx: BuildContext) -> None:
        wires = ctx.active
        shared = tuple(ctx.new_param() for _ in range(n_params)) if tied else None
        for i in range(len(wires) - 1):
            params = shared or tuple(ctx.new_param() for _ in range(n_params))
            fn(qc, wires[i], wires[i + 1], params)

    return Ansatz(n_qubits, Custom(build, "mps"), "mps")

register_conv_filter

register_conv_filter(
    name: str, fn: ConvFilter, n_params: int
) -> None

Make a two-qubit filter reachable by name from :func:conv_block.

Source code in src/qmlkit/ansatz/library.py
def register_conv_filter(name: str, fn: ConvFilter, n_params: int) -> None:
    """Make a two-qubit filter reachable by name from :func:`conv_block`."""
    if name in _FILTERS:
        raise ValueError(f"conv filter {name!r} is already registered")
    _FILTERS[name] = (fn, n_params)

conv_block

conv_block(
    pattern: str = "chain",
    tied: bool = True,
    filter: str | tuple[ConvFilter, int] = "ry_cx",
) -> Block

A QCNN convolution layer: slide one two-qubit filter across pattern.

tied=True allocates one filter and reuses it at every pair — the genuine convolutional structure, and the reason the gradient code sums over occurrences. tied=False gives each pair its own weights.

filter is a registered name (:func:list_conv_filters) or a (fn, n_params) pair, where fn(qc, a, b, params) writes the filter. That is the whole extension point: a filter from a paper we have never heard of is a function you pass in, not a class you subclass.

Source code in src/qmlkit/ansatz/library.py
def conv_block(
    pattern: str = "chain",
    tied: bool = True,
    filter: str | tuple[ConvFilter, int] = "ry_cx",  # noqa: A002 - the domain word
) -> Block:
    """A QCNN convolution layer: slide one two-qubit ``filter`` across ``pattern``.

    ``tied=True`` allocates **one** filter and reuses it at every pair — the genuine
    convolutional structure, and the reason the gradient code sums over occurrences.
    ``tied=False`` gives each pair its own weights.

    ``filter`` is a registered name (:func:`list_conv_filters`) or a
    ``(fn, n_params)`` pair, where ``fn(qc, a, b, params)`` writes the filter. That
    is the whole extension point: a filter from a paper we have never heard of is a
    function you pass in, not a class you subclass.
    """
    fn, n_params = _resolve_filter(filter)
    label = filter if isinstance(filter, str) else getattr(filter[0], "__name__", "custom")

    def build(qc: QCircuit, ctx: BuildContext) -> None:
        wires = ctx.active
        pairs = entangler_pairs(len(wires), pattern)
        if not pairs:
            return
        shared = tuple(ctx.new_param() for _ in range(n_params)) if tied else None
        for a, b in pairs:
            params = (
                shared if shared is not None else tuple(ctx.new_param() for _ in range(n_params))
            )
            fn(qc, wires[a], wires[b], params)

    return Custom(build, f"conv_{label}{'_tied' if tied else ''}")

qcnn_ansatz

qcnn_ansatz(
    n_qubits: int,
    tie_weights: bool = True,
    filter: str | tuple[ConvFilter, int] = "ry_cx",
    pattern: str = "chain",
    pool: str = "discard",
    keep: str = "odd",
) -> Ansatz

Convolution + pooling, halving the register until one qubit is left.

There is no single "the QCNN": papers differ in the two-qubit filter and in how pooling discards a wire. Rather than shipping one class per paper, this is the shared skeleton with both choices exposed — so reproducing a particular variant is a keyword, and inventing one is a function.

tie_weights=True shares one filter across all applications in a layer — the genuine convolutional structure, and the case whose gradient needs a sum over occurrences.

Source code in src/qmlkit/ansatz/library.py
def qcnn_ansatz(
    n_qubits: int,
    tie_weights: bool = True,
    filter: str | tuple[ConvFilter, int] = "ry_cx",  # noqa: A002 - the domain word
    pattern: str = "chain",
    pool: str = "discard",
    keep: str = "odd",
) -> Ansatz:
    """Convolution + pooling, halving the register until one qubit is left.

    There is no single "the QCNN": papers differ in the two-qubit filter and in how
    pooling discards a wire. Rather than shipping one class per paper, this is the
    shared skeleton with both choices exposed — so reproducing a particular variant
    is a keyword, and inventing one is a function.

    ``tie_weights=True`` shares one filter across all applications in a layer — the
    genuine convolutional structure, and the case whose gradient needs a sum over
    occurrences.
    """
    import math

    n_layers = max(1, int(math.ceil(math.log2(n_qubits))))
    layer = conv_block(pattern=pattern, tied=tie_weights, filter=filter) + PoolLayer(
        keep, mode=pool, tied=tie_weights
    )
    return Ansatz(n_qubits, repeat(n_layers, layer), "qcnn")

qaoa_ansatz

qaoa_ansatz(
    n_qubits: int,
    edges: Sequence[tuple[int, int]] | None = None,
    p: int = 1,
    mixer: str = "x",
) -> Ansatz

Cost and mixer layers — only 2p parameters, whatever the width.

Both angles in a round are shared across all their gates, which is what keeps the parameter count at 2p rather than growing with the graph.

Source code in src/qmlkit/ansatz/library.py
def qaoa_ansatz(
    n_qubits: int,
    edges: Sequence[tuple[int, int]] | None = None,
    p: int = 1,
    mixer: str = "x",
) -> Ansatz:
    """Cost and mixer layers — only ``2p`` parameters, whatever the width.

    Both angles in a round are shared across all their gates, which is what keeps
    the parameter count at ``2p`` rather than growing with the graph.
    """
    graph = list(edges) if edges is not None else list(entangler_pairs(n_qubits, "chain"))
    if mixer not in ("x", "y", "xy"):
        raise unknown("mixer", mixer, ("x", "y", "xy"))

    def build(qc: QCircuit, ctx: BuildContext) -> None:
        for q in ctx.active:
            qc.h(q)
        for _ in range(p):
            gamma = ctx.new_param()  # one cost angle for the whole round
            for a, b in graph:
                qc.cx(a, b)
                qc.apply("rz", b, gamma)
                qc.cx(a, b)
            beta = ctx.new_param()  # one mixer angle for the whole round
            if mixer in ("x", "xy"):
                for q in ctx.active:
                    qc.apply("rx", q, beta)
            if mixer in ("y", "xy"):
                for q in ctx.active:
                    qc.apply("ry", q, beta)

    return Ansatz(n_qubits, Custom(build, "qaoa"), "qaoa")

basic_entangler

basic_entangler(
    n_qubits: int, n_layers: int = 2, rotation: str = "rx"
) -> Ansatz

One rotation per wire plus a ring of CNOTs — the minimal useful template.

Source code in src/qmlkit/ansatz/library.py
def basic_entangler(n_qubits: int, n_layers: int = 2, rotation: str = "rx") -> Ansatz:
    """One rotation per wire plus a ring of CNOTs — the minimal useful template."""
    return Ansatz(
        n_qubits,
        repeat(n_layers, RotationLayer((rotation,)) + EntanglerLayer("cx", "ring")),
        "basic_entangler",
    )

two_local

two_local(
    n_qubits: int,
    n_layers: int = 2,
    rotations: Sequence[str] = ("ry",),
    entangler: str = "cx",
    pattern: str = "full",
) -> Ansatz

A configurable rotation/entangler alternation, ending on a rotation layer.

Source code in src/qmlkit/ansatz/library.py
def two_local(
    n_qubits: int,
    n_layers: int = 2,
    rotations: Sequence[str] = ("ry",),
    entangler: str = "cx",
    pattern: str = "full",
) -> Ansatz:
    """A configurable rotation/entangler alternation, ending on a rotation layer."""
    return Ansatz(
        n_qubits,
        repeat(n_layers, RotationLayer(rotations) + EntanglerLayer(entangler, pattern))
        + RotationLayer(rotations),
        "two_local",
    )

random_layers

random_layers(
    n_qubits: int,
    n_layers: int = 2,
    ratio_imprimitive: float = 0.3,
    seed: int | None = None,
) -> Ansatz

Randomly placed rotations and CNOTs — the baseline a new ansatz must beat.

Source code in src/qmlkit/ansatz/library.py
def random_layers(
    n_qubits: int, n_layers: int = 2, ratio_imprimitive: float = 0.3, seed: int | None = None
) -> Ansatz:
    """Randomly placed rotations and CNOTs — the baseline a new ansatz must beat."""
    rng = np.random.default_rng(seed)
    plan: list[tuple[str, tuple[int, ...]]] = []
    for _ in range(n_layers):
        for q in range(n_qubits):
            plan.append((str(rng.choice(["rx", "ry", "rz"])), (q,)))
            if n_qubits > 1 and rng.random() < ratio_imprimitive:
                other = int(rng.choice([w for w in range(n_qubits) if w != q]))
                plan.append(("cx", (q, other)))

    def build(qc: QCircuit, ctx: BuildContext) -> None:
        for gate, wires in plan:
            if gate == "cx":
                qc.cx(*wires)
            else:
                qc.apply(gate, wires[0], ctx.new_param())

    return Ansatz(n_qubits, Custom(build, "random"), "random_layers")

qmlkit.ansatz.reupload

reupload

Data re-uploading, as a pattern rather than a fixed structure.

Re-uploading is any interleaving of an encoding with a trainable block. The encoding can be any feature map, the trainable block any ansatz block, and the order, depth and sharing are all free. Treating it as one hardcoded class is a category error — so it is not one here.

:func:reupload is a convenience over that freedom, not a replacement for it:

reupload(fmap, n_layers=3)                                   # S W S W S W
reupload(fmap, n_layers=3, order="WS")                       # W S W S W S
reupload(fmap, n_layers=3, block=RotationLayer("ry") + EntanglerLayer("cz", "ring"))
reupload(fmap, n_layers=3, share_weights=True)               # one tied block, reused

Anything it cannot express, compose directly — that is the same vocabulary:

Ansatz(n, EncodingLayer(zz) + RotationLayer("ry") + EncodingLayer(angle))

Frequencies. L uploads reach frequencies 0..L only when the trainable block does not commute with the encoding rotation; if it does, the uploads merge into a single rotation. :func:reupload checks this and warns.

ReuploadModel

ReuploadModel(
    n_qubits: int,
    block: Block,
    name: str,
    n_inputs: int,
    feature_map: object,
    n_uploads: int,
)

Bases: Ansatz

An :class:Ansatz that also knows its encoding and upload count.

Source code in src/qmlkit/ansatz/reupload.py
def __init__(
    self,
    n_qubits: int,
    block: Block,
    name: str,
    n_inputs: int,
    feature_map: object,
    n_uploads: int,
) -> None:
    super().__init__(n_qubits, block, name, n_inputs)
    self.feature_map = feature_map
    self.n_uploads = n_uploads
n_frequencies property
n_frequencies: int

Reachable frequencies 0..L, so L + 1 of them.

reupload

reupload(
    feature_map: object,
    n_layers: int = 3,
    block: Block | None = None,
    order: str = "SW",
    entangler: str | None = "cx",
    pattern: str = "chain",
    rotations: Sequence[str] = ("rz", "ry", "rz"),
    share_weights: bool = False,
    name: str = "reupload",
) -> Ansatz

Build a re-uploading ansatz from any feature map and any trainable block.

Parameters:

Name Type Description Default
feature_map object

Any :class:~qmlkit.encoding.feature_maps.FeatureMap.

required
block Block | None

The trainable block. Defaults to a rotation layer plus an entangler.

None
order str

"SW" encodes then varies; "WS" varies then encodes. The difference is real: "WS" lets the model transform the state before the first upload, "SW" does not.

'SW'
share_weights bool

Tie one trainable block across every layer — far fewer parameters, and the gradient sums over occurrences.

False
Source code in src/qmlkit/ansatz/reupload.py
def reupload(
    feature_map: object,
    n_layers: int = 3,
    block: Block | None = None,
    order: str = "SW",
    entangler: str | None = "cx",
    pattern: str = "chain",
    rotations: Sequence[str] = ("rz", "ry", "rz"),
    share_weights: bool = False,
    name: str = "reupload",
) -> Ansatz:
    """Build a re-uploading ansatz from any feature map and any trainable block.

    Parameters
    ----------
    feature_map
        Any :class:`~qmlkit.encoding.feature_maps.FeatureMap`.
    block
        The trainable block. Defaults to a rotation layer plus an entangler.
    order
        ``"SW"`` encodes then varies; ``"WS"`` varies then encodes. The difference
        is real: ``"WS"`` lets the model transform the state before the first
        upload, ``"SW"`` does not.
    share_weights
        Tie one trainable block across every layer — far fewer parameters, and the
        gradient sums over occurrences.
    """
    if n_layers < 1:
        raise ValueError("n_layers must be at least 1")
    if order not in ("SW", "WS"):
        raise unknown(
            "order",
            order,
            ("SW", "WS"),
            hint="'SW' encodes then varies; 'WS' varies before the first upload.",
        )

    n_qubits = int(feature_map.n_qubits)  # type: ignore[attr-defined]
    n_inputs = int(feature_map.n_angles)  # type: ignore[attr-defined]

    if block is None:
        block = RotationLayer(rotations)
        if entangler and n_qubits > 1:
            block = block + EntanglerLayer(entangler, pattern)

    collapsing = _commutes_with_encoding(block, feature_map)
    if collapsing is not None:
        found = ", ".join(repr(g) for g in sorted(collapsing))
        encoding_gate = getattr(feature_map, "rotation", "the encoding")
        warnings.warn(
            f"the trainable block only uses {found}, which commutes with the "
            f"{encoding_gate!r} encoding rotation: the uploads collapse into a single "
            f"rotation, so the model reaches one frequency instead of 0..{n_layers} and "
            "its weights do nothing beyond a phase. Use a block whose generators differ "
            "from the encoding's, or add an entangler, either of which breaks the "
            "collapse.",
            UserWarning,
            stacklevel=2,
        )

    encoding = EncodingLayer(feature_map)
    layer = (encoding + block) if order == "SW" else (block + encoding)
    body = share(n_layers, layer) if share_weights else repeat(n_layers, layer)
    return ReuploadModel(n_qubits, body, name, n_inputs, feature_map, n_layers)