Source code for quantum_mind.quantum.statevector

"""A small batched state-vector simulator for parameterised circuits, in pure NumPy.

Qubit q is bit q of the basis-state index (Qiskit ordering). Gate angles are *expressions*: a
number, :class:`W` (trainable weight k), :class:`X` (feature j of each sample, scaled and shifted),
:class:`XX` (a product feature) or a constant.

Circuits are simulated for a whole batch of samples at once, which makes training parameterised circuits
fast without a quantum SDK. Gradients are exact and cheap (:meth:`Circuit.value_and_grad`, adjoint
method). :meth:`Circuit.to_qiskit` produces the same circuit for Aer, IBM Quantum or Amazon Braket
(via :func:`quantum_mind.circuits.run`).

Examples
--------
>>> import numpy as np
>>> from quantum_mind.quantum import Circuit, W
>>> c = Circuit(2).h(0).cx(0, 1)                   # Bell state
>>> c.probabilities().round(3)
array([[0.5, 0. , 0. , 0.5]])
>>> c = Circuit(1).ry(W(0), 0)                     # one trainable rotation
>>> c.probabilities([np.pi]).round(3)
array([[0., 1.]])
"""
from __future__ import annotations

from dataclasses import dataclass
import numpy as np

__all__ = ['W', 'X', 'XX', 'Circuit', 'apply_matrix', 'basis_probs', 'expectation_z', 'MAX_QUBITS']

#: Largest number of qubits: beyond this a state vector does not fit in typical memory (2^22 x 16 bytes per sample).
MAX_QUBITS = 22
_CHUNK_AMPLITUDES = 2 ** 21   # samples are simulated in chunks of about this many amplitudes (32 MB)


[docs] @dataclass(frozen=True) class W: """Trainable weight as a gate angle. Parameters ---------- k : int Index of the weight in the weight vector. scale : float, optional Constant factor: the angle is ``scale * w[k]``. """ k: int scale: float = 1.0
[docs] @dataclass(frozen=True) class X: """Input feature as a gate angle. Parameters ---------- j : int Feature index. scale : float, optional Factor. shift : float, optional Offset: the angle is ``scale * x[j] + shift``. """ j: int scale: float = 1.0 shift: float = 0.0
[docs] @dataclass(frozen=True) class XX: """Product feature used by ZZ feature maps. Parameters ---------- i, j : int Feature indices. a : float, optional Offset; the angle is ``scale * (a - x[i]) * (a - x[j])`` (Havlíček et al., 2019). scale : float, optional Factor. """ i: int j: int a: float = np.pi scale: float = 1.0
def _value(p, w, x, B): """Angle per sample, shape (B,). w: (K,) shared weights or (B, K) one weight vector per sample (used to evaluate many weight settings, e.g. parameter shifts, in one batch).""" if isinstance(p, W): return p.scale * w[:, p.k] if w.ndim == 2 else np.full(B, p.scale * w[p.k]) if isinstance(p, X): return p.scale * x[:, p.j] + p.shift if isinstance(p, XX): return p.scale * (p.a - x[:, p.i]) * (p.a - x[:, p.j]) return np.full(B, float(p)) def _axes(n, q): """Axis of qubit q in a (B, 2, ..., 2) array reshaped from the big-endian index.""" return n - 1 - q def _apply_1q(state, M, q, n): """Fast path for one qubit: view the batch as (B, high, 2, low) with low = 2^q.""" B = state.shape[0] psi = state.reshape(B, 2 ** (n - 1 - q), 2, 2 ** q) a0, a1 = psi[:, :, 0, :], psi[:, :, 1, :] # amplitudes with qubit q = 0 and = 1 if M.ndim == 3: # one matrix per sample m = M[:, :, :, None, None] out0 = m[:, 0, 0] * a0 + m[:, 0, 1] * a1 out1 = m[:, 1, 0] * a0 + m[:, 1, 1] * a1 else: out0 = M[0, 0] * a0 + M[0, 1] * a1 out1 = M[1, 0] * a0 + M[1, 1] * a1 return np.stack([out0, out1], 2).reshape(B, 2 ** n) def _diag_phases(D, qubits, n): """Expand the diagonal of a k-qubit diagonal gate to all 2^n basis states (shape (..., 2^n)).""" idx = np.arange(2 ** n) sub = np.zeros(2 ** n, int) for i, q in enumerate(qubits): sub |= ((idx >> q) & 1) << i return D[..., sub]
[docs] def apply_matrix(state, M, qubits, n): """Apply a k-qubit matrix to a batch of states. Parameters ---------- state : numpy.ndarray Batch of states, shape ``(B, 2**n)``. M : numpy.ndarray ``(2**k, 2**k)`` matrix shared by the batch, or ``(B, 2**k, 2**k)`` one per sample. The first qubit in ``qubits`` is the least significant bit of M's index. qubits : sequence of int Qubits acted on. n : int Total number of qubits. Returns ------- numpy.ndarray New batch of states, shape ``(B, 2**n)``. Diagonal two-qubit gates use a fast phase path. """ B = state.shape[0] k = len(qubits) if k == 1: return _apply_1q(state, M, qubits[0], n) if k == 2 and np.count_nonzero(M - np.einsum('...ii->...i', M)[..., None] * np.eye(4)) == 0: return state * _diag_phases(np.einsum('...ii->...i', M), qubits, n) # general case: move the target axes to the front, multiply, move them back psi = state.reshape((B,) + (2,) * n) ax = [1 + _axes(n, q) for q in reversed(qubits)] # big-endian order of M's index psi = np.moveaxis(psi, ax, list(range(1, k + 1))).reshape(B, 2 ** k, -1) psi = (M @ psi) if M.ndim == 3 else np.einsum('ij,bjr->bir', M, psi) psi = psi.reshape((B,) + (2,) * n) psi = np.moveaxis(psi, list(range(1, k + 1)), ax) return psi.reshape(B, 2 ** n)
def _rot(name, th): """Matrices of a parametric gate for a batch of angles, shape (B, 2, 2) or (B, 4, 4).""" c, s = np.cos(th / 2), np.sin(th / 2) B = len(th) M = np.zeros((B, 2, 2), complex) if name == 'ry': M[:, 0, 0] = c; M[:, 0, 1] = -s; M[:, 1, 0] = s; M[:, 1, 1] = c elif name == 'rx': M[:, 0, 0] = c; M[:, 0, 1] = -1j * s; M[:, 1, 0] = -1j * s; M[:, 1, 1] = c elif name == 'rz': M[:, 0, 0] = np.exp(-0.5j * th); M[:, 1, 1] = np.exp(0.5j * th) elif name == 'p': M[:, 0, 0] = 1; M[:, 1, 1] = np.exp(1j * th) elif name == 'cp': # controlled phase diag(1, 1, 1, e^{i t}) M = np.zeros((B, 4, 4), complex) M[:, 0, 0] = M[:, 1, 1] = M[:, 2, 2] = 1 M[:, 3, 3] = np.exp(1j * th) elif name == 'rzz': # 4 x 4 diagonal M = np.zeros((B, 4, 4), complex) for b, z in enumerate([1, -1, -1, 1]): M[:, b, b] = np.exp(-0.5j * th * z) return M _FIXED = { 'h': np.array([[1, 1], [1, -1]], complex) / np.sqrt(2), 'x': np.array([[0, 1], [1, 0]], complex), 'y': np.array([[0, -1j], [1j, 0]], complex), 'z': np.diag([1, -1]).astype(complex), 's': np.diag([1, 1j]), 'sdg': np.diag([1, -1j]), 't': np.diag([1, np.exp(1j * np.pi / 4)]), 'cx': np.array([[1, 0, 0, 0], [0, 0, 0, 1], [0, 0, 1, 0], [0, 1, 0, 0]], complex), # control = first qubit 'cz': np.diag([1, 1, 1, -1]).astype(complex), 'swap': np.array([[1, 0, 0, 0], [0, 0, 1, 0], [0, 1, 0, 0], [0, 0, 0, 1]], complex), } #: Gates whose angle can be a number, :class:`W`, :class:`X` or :class:`XX`. PARAMETRIC = ('rx', 'ry', 'rz', 'p', 'rzz', 'cp') # Generators G of the parametric gates, used by the adjoint gradient. _GENERATOR = {'rx': _FIXED['x'], 'ry': _FIXED['y'], 'rz': _FIXED['z'], 'rzz': np.diag([1, -1, -1, 1]).astype(complex), 'p': np.diag([0, 1]).astype(complex), 'cp': np.diag([0, 0, 0, 1]).astype(complex)} def _dagger(M): """Conjugate transpose of the last two axes.""" return M.conj().swapaxes(-1, -2)
[docs] class Circuit: """Parameterised quantum circuit with a batched simulator. Build the circuit with the gate methods (each returns ``self``, so calls chain) and simulate it with :meth:`state` or :meth:`probabilities`. Parameters ---------- n : int Number of qubits, at most :data:`MAX_QUBITS`. Attributes ---------- ops : list of tuple Gates as ``(name, qubits, parameter)``. Raises ------ ValueError If ``n`` exceeds :data:`MAX_QUBITS`. """ def __init__(self, n): if n > MAX_QUBITS: raise ValueError('%d qubits exceed the simulator limit of %d (memory grows as 2^n)' % (n, MAX_QUBITS)) self.n = n self.ops = [] def _add(self, name, qubits, param=None): """Append a gate and return self.""" self.ops.append((name, tuple(qubits), param)) return self # ----------------------------------------------------------------------------------- fixed gates
[docs] def h(self, q): """Hadamard on qubit ``q``.""" return self._add('h', [q])
[docs] def x(self, q): """Pauli X on qubit ``q``.""" return self._add('x', [q])
[docs] def y(self, q): """Pauli Y on qubit ``q``.""" return self._add('y', [q])
[docs] def z(self, q): """Pauli Z on qubit ``q``.""" return self._add('z', [q])
[docs] def s(self, q): """S gate (phase pi/2) on qubit ``q``.""" return self._add('s', [q])
[docs] def sdg(self, q): """S-dagger gate on qubit ``q``.""" return self._add('sdg', [q])
[docs] def cx(self, c, t): """CNOT with control ``c`` and target ``t``.""" return self._add('cx', [c, t])
[docs] def cz(self, a, b): """Controlled Z on qubits ``a`` and ``b``.""" return self._add('cz', [a, b])
[docs] def swap(self, a, b): """Swap qubits ``a`` and ``b``.""" return self._add('swap', [a, b])
# ----------------------------------------------------------------------------------- parametric gates
[docs] def rx(self, p, q): """RX rotation on qubit ``q``; ``p`` is a number, :class:`W`, :class:`X` or :class:`XX`.""" return self._add('rx', [q], p)
[docs] def ry(self, p, q): """RY rotation on qubit ``q``; ``p`` is a number or an angle expression.""" return self._add('ry', [q], p)
[docs] def rz(self, p, q): """RZ rotation on qubit ``q``; ``p`` is a number or an angle expression.""" return self._add('rz', [q], p)
[docs] def p(self, p, q): """Phase gate diag(1, e^{ip}) on qubit ``q``.""" return self._add('p', [q], p)
[docs] def cp(self, p, c, t): """Controlled phase diag(1, 1, 1, e^{ip}) on qubits ``c`` and ``t`` (symmetric).""" return self._add('cp', [c, t], p)
[docs] def rzz(self, p, a, b): """RZZ(p) = exp(-i p Z_a Z_b / 2) on qubits ``a`` and ``b``.""" return self._add('rzz', [a, b], p)
[docs] def unitary(self, M, qubits, label='U'): """Fixed unitary on some qubits. Parameters ---------- M : array_like ``(2**k, 2**k)`` unitary. qubits : sequence of int Qubits acted on (the first is the least significant bit of M's index). label : str, optional Label in the Qiskit export. Returns ------- Circuit ``self``. """ self.ops.append(('unitary', tuple(qubits), (np.asarray(M, complex), label))) return self
[docs] def diagonal(self, phases, label='D'): """Diagonal unitary diag(exp(i phases)) on all qubits (for example a cost or oracle layer). Parameters ---------- phases : array_like ``2**n`` phases. label : str, optional Label. Returns ------- Circuit ``self``. """ self.ops.append(('diag', tuple(range(self.n)), (np.asarray(phases, float), label))) return self
@property def n_weights(self): """int: number of trainable weights (largest :class:`W` index + 1).""" ks = [p.k for _, _, p in self.ops if isinstance(p, W)] return 1 + max(ks) if ks else 0
[docs] def compose(self, other): """Append the gates of another circuit. Parameters ---------- other : Circuit Circuit on the same number of qubits. Returns ------- Circuit ``self``. """ self.ops += other.ops return self
# ----------------------------------------------------------------------------------- simulation
[docs] def state(self, w=None, X_=None, init=None): """Simulate the circuit. Parameters ---------- w : array_like, optional Weights, shape ``(K,)`` shared by the batch or ``(B, K)`` one per sample. Default zeros. X_ : array_like, optional Input samples, shape ``(B, n_features)``. init : array_like, optional Initial state (default :math:`|0\\dots0\\rangle`). Returns ------- numpy.ndarray Final states, shape ``(B, 2**n)``. Raises ------ ValueError If fewer weights than :attr:`n_weights` are given. """ w = np.zeros(self.n_weights) if w is None else np.asarray(w, float) Xa = None if X_ is None else np.atleast_2d(np.asarray(X_, float)) B = Xa.shape[0] if Xa is not None else (w.shape[0] if w.ndim == 2 else 1) if w.ndim == 1 and len(w) < self.n_weights: raise ValueError('expected %d weights, got %d' % (self.n_weights, len(w))) if init is None: psi = np.zeros((B, 2 ** self.n), complex) psi[:, 0] = 1 else: psi = np.tile(np.asarray(init, complex), (B, 1)) for op in self.ops: psi = self._apply(op, psi, w, Xa, B) return psi
def _matrix(self, op, w, Xa, B): """Matrix (or batch of matrices) of one gate.""" name, qs, p = op if name in PARAMETRIC: return _rot(name, _value(p, w, Xa, B)) if name == 'unitary': return p[0] return _FIXED[name] def _apply(self, op, psi, w, Xa, B, inverse=False): """Apply one gate (or its inverse) to a batch of states.""" name, qs, p = op if name == 'diag': return psi * np.exp((-1j if inverse else 1j) * p[0])[None, :] M = self._matrix(op, w, Xa, B) return apply_matrix(psi, _dagger(M) if inverse else M, list(qs), self.n)
[docs] def value_and_grad(self, w, X_, loss, chunk=None): """Loss and its exact gradient with respect to the weights (adjoint method). The circuit is run forward once, then backwards once with the inverse gates, so the cost is about three simulations whatever the number of weights. Samples are processed in chunks (memory ``O(chunk * 2**n)``). Weights shared by several gates (for example a repeated rotation angle) are handled. Parameters ---------- w : array_like Weights, shape ``(K,)``. X_ : array_like or None Input samples, shape ``(B, n_features)``. loss : callable ``loss(psi, rows) -> (L, phi)``: ``L`` is the loss summed over the samples ``rows`` of the chunk and ``phi`` = dL/d(conj psi), with the shape of ``psi``. chunk : int, optional Samples per chunk (default: about 2**21 amplitudes). Returns ------- loss : float Total loss. grad : numpy.ndarray Gradient, shape ``(K,)``. """ w = np.asarray(w, float) Xa = None if X_ is None else np.atleast_2d(np.asarray(X_, float)) B = 1 if Xa is None else Xa.shape[0] chunk = chunk or max(1, _CHUNK_AMPLITUDES // 2 ** self.n) total, grad = 0.0, np.zeros(self.n_weights) for start in range(0, B, chunk): rows = np.arange(start, min(B, start + chunk)) Xc = None if Xa is None else Xa[rows] b = len(rows) psi = self.state(w, Xc) L, phi = loss(psi, rows) total += float(L) # walk the circuit backwards, undoing each gate on psi and on the adjoint state phi for op in reversed(self.ops): name, qs, p = op if name in PARAMETRIC and isinstance(p, W): Gpsi = apply_matrix(psi, _GENERATOR[name], list(qs), self.n) x = np.einsum('bi,bi->', phi.conj(), Gpsi) # rotation exp(-i t G / 2): dL/dt = Im<phi|G|psi>; phase gate P(t): dL/dt = -2 Im<phi|1><1|psi> grad[p.k] += p.scale * (-2 * x.imag if name in ('p', 'cp') else x.imag) psi = self._apply(op, psi, w, Xc, b, inverse=True) phi = self._apply(op, phi, w, Xc, b, inverse=True) return total, grad
[docs] def probabilities(self, w=None, X_=None): """Born-rule probabilities of the final states. Parameters ---------- w : array_like, optional Weights. X_ : array_like, optional Input samples. Returns ------- numpy.ndarray Shape ``(B, 2**n)``. """ return np.abs(self.state(w, X_)) ** 2
# ----------------------------------------------------------------------------------- export
[docs] def to_qiskit(self, w=None, x=None, measure=True): """Export to Qiskit with every angle bound. Parameters ---------- w : array_like, optional Weights. x : array_like, optional One input sample. measure : bool, optional Add a final measurement of every qubit. Returns ------- qiskit.QuantumCircuit Ready for :func:`quantum_mind.circuits.run` on Aer, IBM Quantum or Amazon Braket. """ from .._optional import require require('qiskit', 'qiskit') from qiskit import QuantumCircuit from qiskit.circuit.library import UnitaryGate, DiagonalGate w = np.zeros(self.n_weights) if w is None else np.asarray(w, float) xa = None if x is None else np.atleast_2d(np.asarray(x, float)) qc = QuantumCircuit(self.n) for name, qs, p in self.ops: if name in PARAMETRIC: getattr(qc, name)(float(_value(p, w, xa, 1)[0]), *qs) elif name == 'unitary': qc.append(UnitaryGate(p[0], label=p[1]), list(qs)) elif name == 'diag': qc.append(DiagonalGate(list(np.exp(1j * p[0]))), list(qs)) else: getattr(qc, name)(*qs) if measure: qc.measure_all() return qc
def __repr__(self): return 'Circuit(%d qubits, %d gates, %d weights)' % (self.n, len(self.ops), self.n_weights)
[docs] def basis_probs(psi): """Born-rule probabilities. Parameters ---------- psi : numpy.ndarray State or batch of states. Returns ------- numpy.ndarray :math:`|\\psi|^2`. """ return np.abs(psi) ** 2
[docs] def expectation_z(psi, q, n): """Expectation of Pauli Z on one qubit. Parameters ---------- psi : numpy.ndarray Batch of states, shape ``(B, 2**n)``. q : int Qubit. n : int Number of qubits. Returns ------- numpy.ndarray :math:`\\langle Z_q \\rangle` for each state, shape ``(B,)``. """ idx = np.arange(2 ** n) sign = 1 - 2 * ((idx >> q) & 1) # +1 if qubit q is 0, -1 if it is 1 return (np.abs(psi) ** 2) @ sign