Source code for blueqat.utils

# Copyright 2019-2026 The Blueqat Developers
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Integrated Quantum Operators, Utilities, VQE, and QAOA module with PyTorch.
Refactored and merged into a unified utils.py module with robust Autograd tracking.
"""

import cmath
import math
import re
from collections import Counter, defaultdict, namedtuple
from dataclasses import dataclass, field
from functools import reduce
from itertools import combinations, product
from math import pi
from numbers import Number, Integral
import typing
from typing import Any, Callable, Dict, Iterator, List, Optional, Sequence, Tuple, Type, Union

import torch
import warnings

# 同層パッケージへの最小限の外部インポート
from .circuit import Circuit


# ==============================================================================
# SECTION 1: Pauli Operators & Algebra
# ==============================================================================

_PauliTuple = namedtuple("_PauliTuple", "n")
half_pi = pi / 2

_matrix: Dict[str, torch.Tensor] = {
    'I': torch.tensor([[1, 0], [0, 1]], dtype=torch.complex128),
    'X': torch.tensor([[0, 1], [1, 0]], dtype=torch.complex128),
    'Y': torch.tensor([[0, -1j], [1j, 0]], dtype=torch.complex128),
    'Z': torch.tensor([[1, 0], [0, -1]], dtype=torch.complex128)
}

_mul_map: Dict[Tuple[str, str], Tuple[complex, str]] = {
    ('X', 'X'): (1.0, 'I'), ('X', 'Y'): (1j, 'Z'), ('X', 'Z'): (-1j, 'Y'),
    ('Y', 'X'): (-1j, 'Z'), ('Y', 'Y'): (1.0, 'I'), ('Y', 'Z'): (1j, 'X'),
    ('Z', 'X'): (1j, 'Y'), ('Z', 'Y'): (-1j, 'X'), ('Z', 'Z'): (1.0, 'I'),
}

def _kron_1d(a: torch.Tensor, b: torch.Tensor) -> torch.Tensor:
    return torch.kron(a, b)

def _kron_1d_rec(krons: list, lo: int, hi: int) -> torch.Tensor:
    if hi - lo == 1: return krons[lo]
    if hi - lo == 2: return _kron_1d(krons[lo], krons[lo + 1])
    mid = (lo + hi) // 2
    return _kron_1d(_kron_1d_rec(krons, lo, mid), _kron_1d_rec(krons, mid, hi))

def _term_to_dataarray(term: 'Term', n_qubits: int, device: torch.device) -> torch.Tensor:
    y_mat = torch.tensor([1j, -1j], dtype=torch.complex128, device=device)
    z_mat = torch.tensor([1, -1], dtype=torch.complex128, device=device)
    
    paulis = ['I'] * n_qubits
    for op in term.ops:
        paulis[op.n] = op.op
        
    data_list = []
    for g in paulis:
        if g == 'Y': data_list.append(y_mat.clone())
        elif g == 'Z': data_list.append(z_mat.clone())
        elif g in ('X', 'I'):
            data_list.append(torch.tensor([1, 1], dtype=torch.complex128, device=device))
            
    data_list.reverse()
    base_data = _kron_1d_rec(data_list, 0, len(data_list))
    return base_data * torch.as_tensor(term.coeff, dtype=torch.complex128, device=device)


class _PauliImpl:
    @property
    def op(self) -> str: return self.__class__.__name__[1]
    @property
    def is_identity(self) -> bool: return self.op == "I"
    @property
    def n_qubits(self) -> int: return 0 if self.is_identity else self.n + 1

    def __hash__(self) -> int: return hash((self.op, getattr(self, 'n', -1)))

    def __eq__(self, other: Any) -> bool:
        if isinstance(other, _PauliImpl):
            if self.is_identity: return other.is_identity
            return self.n == getattr(other, 'n', None) and self.op == other.op
        if isinstance(other, Term): return self.to_term() == other
        if isinstance(other, Expr): return self.to_expr() == other
        return NotImplemented

    def __ne__(self, other: Any) -> bool:
        result = self.__eq__(other)
        return result if result is NotImplemented else not result

    def __mul__(self, other: Any) -> Any:
        if isinstance(other, (Number, torch.Tensor)): return Term.from_pauli(self, other)
        if not isinstance(other, _PauliImpl): return NotImplemented
        if self.is_identity: return other.to_term()
        if other.is_identity: return self.to_term()
        if self.n == other.n and self.op == other.op: return I.to_term()
        return Term.from_paulipair(self, other)

    def __rmul__(self, other: Any) -> Any:
        return Term.from_pauli(self, other) if isinstance(other, (Number, torch.Tensor)) else NotImplemented

    def __truediv__(self, other: Any) -> Any:
        return Term.from_pauli(self, 1.0 / other) if isinstance(other, (Number, torch.Tensor)) else NotImplemented

    def __add__(self, other: Any) -> 'Expr': return self.to_expr() + other
    def __radd__(self, other: Any) -> 'Expr': return other + self.to_expr()
    def __sub__(self, other: Any) -> 'Expr': return self.to_expr() - other
    def __rsub__(self, other: Any) -> 'Expr': return other - self.to_expr()
    def __neg__(self) -> 'Term': return Term.from_pauli(self, -1.0)
    def __repr__(self) -> str: return "I" if self.is_identity else f"{self.op}[{self.n}]"

    def to_term(self) -> 'Term': return Term.from_pauli(self)
    def to_expr(self) -> 'Expr': return self.to_term().to_expr()
    
    @property
    def matrix(self) -> torch.Tensor: return _matrix[self.op].clone()
    def to_matrix(self, n_qubits: int = -1, *, sparse: bool = False, device: Optional[torch.device] = None) -> torch.Tensor:
        return self.to_term().to_matrix(n_qubits, sparse=sparse, device=device)


class _X(_PauliImpl, _PauliTuple): pass
class _Y(_PauliImpl, _PauliTuple): pass
class _Z(_PauliImpl, _PauliTuple): pass

class _PauliCtor:
    def __init__(self, ty: Type) -> None: self.ty = ty
    def __call__(self, n: int) -> _PauliImpl: return self.ty(n)
    def __getitem__(self, n: int) -> _PauliImpl: return self.ty(n)
    @property
    def matrix(self) -> torch.Tensor: return _matrix[self.ty.__name__[-1]].clone()

X = _PauliCtor(_X)
Y = _PauliCtor(_Y)
Z = _PauliCtor(_Z)

class _I(_PauliImpl, namedtuple("_I", "")):
    def __call__(self) -> '_I': return self
    @property
    def matrix(self) -> torch.Tensor: return _matrix['I'].clone()

I = _I()
_TermTuple = namedtuple("_TermTuple", "ops coeff")


[docs] class Term(_TermTuple):
[docs] @staticmethod def from_paulipair(pauli1: Any, pauli2: Any) -> 'Term': return Term(Term.join_ops((pauli1, ), (pauli2, )), 1.0)
[docs] @staticmethod def from_pauli(pauli: Any, coeff: Any = 1.0) -> 'Term': return Term((), coeff) if pauli.is_identity else Term((pauli, ), coeff)
[docs] @staticmethod def from_ops_iter(ops: Any, coeff: Any) -> 'Term': return Term(tuple(ops), coeff)
[docs] @staticmethod def from_chars(chars: Any) -> 'Term': paulis = [pauli_from_char(c, n) for n, c in enumerate(chars) if c != "I"] return 1.0 * I if not paulis else reduce(lambda a, b: a * b, paulis)
[docs] @staticmethod def join_ops(ops1: tuple, ops2: tuple) -> tuple: i, j = len(ops1) - 1, 0 while i >= 0 and j < len(ops2): if ops1[i] == ops2[j]: i, j = i - 1, j + 1 else: break return ops1[:i + 1] + ops2[j:]
@property def is_identity(self) -> bool: return not self.ops def __mul__(self, other: Any) -> Any: if isinstance(other, (Number, torch.Tensor)): return Term(self.ops, self.coeff * other) if isinstance(other, Term): return Term(Term.join_ops(self.ops, other.ops), self.coeff * other.coeff) if isinstance(other, _PauliImpl): return self if other.is_identity else Term(Term.join_ops(self.ops, (other, )), self.coeff) return NotImplemented def __rmul__(self, other: Any) -> Any: if isinstance(other, (Number, torch.Tensor)): return Term(self.ops, self.coeff * other) if isinstance(other, _PauliImpl): return self if other.is_identity else Term(Term.join_ops((other, ), self.ops), self.coeff) return NotImplemented def __truediv__(self, other: Any) -> Any: return Term(self.ops, self.coeff / other) if isinstance(other, (Number, torch.Tensor)) else NotImplemented def __pow__(self, n: Any) -> Any: if isinstance(n, Integral): if n < 0: raise ValueError("n shall not be negative.") return Term.from_pauli(I) if n == 0 else Term(self.ops * n, self.coeff**n) return NotImplemented def __add__(self, other: Any) -> 'Expr': return Expr.from_term(self) + other def __radd__(self, other: Any) -> 'Expr': return other + Expr.from_term(self) def __sub__(self, other: Any) -> 'Expr': return Expr.from_term(self) - other def __rsub__(self, other: Any) -> 'Expr': return other - self.to_expr() def __neg__(self) -> 'Term': return Term(self.ops, -self.coeff) def __repr__(self) -> str: coeff_str = str(self.coeff.item()) if isinstance(self.coeff, torch.Tensor) else str(self.coeff) if not self.ops: return f"{coeff_str}*I" return f"{coeff_str}*" + "*".join(f"{op.op}[{op.n}]" for op in self.ops) def __eq__(self, other: Any) -> bool: if isinstance(other, _PauliImpl): other = other.to_term() if isinstance(other, Term): return _TermTuple.__eq__(self.simplify(), other.simplify()) if isinstance(other, Expr): return NotImplemented # let Expr.__eq__(other, self) handle it return False def __ne__(self, other: Any) -> bool: result = self.__eq__(other) return result if result is NotImplemented else not result
[docs] def to_term(self) -> 'Term': return self
[docs] def to_expr(self) -> 'Expr': return Expr.from_term(self)
[docs] def simplify(self) -> 'Term': def mul(op1: str, op2: str) -> Tuple[complex, str]: return (1.0, op2) if op1 == "I" else ((1.0, op1) if op2 == "I" else _mul_map[op1, op2]) before = defaultdict(list) for op in self.ops: if op.op != "I": before[op.n].append(op.op) new_coeff = self.coeff new_ops = [] for n in sorted(before.keys()): ops = before[n] k = 1.0 op = ops[0] for _op in ops[1:]: _k, op = mul(op, _op) k *= _k new_coeff = new_coeff * k if isinstance(new_coeff, torch.Tensor): # .imag raises for non-complex dtypes, so only touch it when the # tensor is actually complex (e.g. a real theta*Z[0] coefficient # that never got promoted to complex must be left alone). if torch.is_complex(new_coeff) and new_coeff.imag == 0: new_coeff = new_coeff.real elif isinstance(new_coeff, complex) and new_coeff.imag == 0: new_coeff = new_coeff.real if op != "I": new_ops.append(pauli_from_char(op, n)) return Term(tuple(new_ops), new_coeff)
[docs] def n_iter(self) -> Iterator[int]: return (op.n for op in self.ops)
[docs] def max_n(self) -> int: try: return max(self.n_iter()) except ValueError: return -1
@property def n_qubits(self) -> int: return self.max_n() + 1
[docs] def is_commutable_with(self, other: Any) -> bool: return is_commutable(self, other)
[docs] def get_time_evolution(self) -> Any: """Returns a function `f(circuit, t)` appending exp(-i t P) to `circuit`, where P = coeff * (this term's Pauli product). Requires a real coefficient (a complex one would make the "evolution" non-unitary).""" term = self.simplify() coeff, ops = term.coeff, term.ops if isinstance(coeff, complex): if coeff.imag != 0: raise ValueError("Cannot make time evolution of complex coefficient.") coeff = coeff.real elif isinstance(coeff, torch.Tensor) and torch.is_complex(coeff): if coeff.imag != 0: raise ValueError("Cannot make time evolution of complex coefficient.") coeff = coeff.real def append_to_circuit(circuit: Any, t: float) -> None: if not ops: return # Basis change into Z: H X H = Z, and RX(+pi/2) Y RX(-pi/2) = Z # (RX(-pi/2) would give -Z, silently flipping the sign for Y terms). for op in ops: if op.op == "X": circuit.h[op.n] elif op.op == "Y": circuit.rx(half_pi)[op.n] for i in range(1, len(ops)): circuit.cx[ops[i - 1].n, ops[i].n] # exp(-i theta P) with P a Pauli product == RZ(2 theta) on the parity # qubit (rz(phi) = diag(e^{-i phi/2}, e^{i phi/2})). circuit.rz(2 * coeff * t)[ops[-1].n] for i in range(len(ops) - 1, 0, -1): circuit.cx[ops[i - 1].n, ops[i].n] for op in ops: if op.op == "X": circuit.h[op.n] elif op.op == "Y": circuit.rx(-half_pi)[op.n] return append_to_circuit
[docs] def to_matrix(self, n_qubits: int = -1, *, sparse: bool = False, device: Optional[torch.device] = None) -> torch.Tensor: if device is None: device = torch.device('cpu') if n_qubits == -1: n_qubits = self.n_qubits if n_qubits == 0: m = torch.as_tensor([[self.coeff]], dtype=torch.complex128, device=device) return m.to_sparse() if sparse else m dim = 2**n_qubits term = self.simplify() xor_bits = sum(1 << op.n for op in term.ops if op.op in ('X', 'Y')) cols = torch.arange(dim, dtype=torch.int64, device=device) rows = cols ^ xor_bits vals = _term_to_dataarray(term, n_qubits, device) if sparse: return torch.sparse_coo_tensor(torch.stack([rows, cols]), vals, (dim, dim), dtype=torch.complex128, device=device) m = torch.zeros((dim, dim), dtype=torch.complex128, device=device) m[rows, cols] = vals return m
_ExprTuple = namedtuple("_ExprTuple", "terms")
[docs] class Expr(_ExprTuple):
[docs] @staticmethod def from_number(num: Any) -> 'Expr': return Expr.zero() if num == 0 else Expr.from_term(Term((), num))
[docs] @staticmethod def from_term(term: Term) -> 'Expr': return Expr((term, ))
[docs] @staticmethod def from_terms_iter(terms: Any) -> 'Expr': return Expr(tuple(term for term in terms))
[docs] def terms_to_dict(self) -> dict: # Sum coefficients on collision rather than overwrite: a plain dict # comprehension would silently drop earlier terms if self.terms ever # contains duplicate `ops` keys (e.g. an Expr built via from_terms_iter # with unmerged input). d: dict = {} for op, coeff in self.terms: d[op] = d[op] + coeff if op in d else coeff return d
[docs] @staticmethod def from_terms_dict(terms_dict: dict) -> 'Expr': return Expr(tuple(Term(k, v) for k, v in terms_dict.items()))
[docs] @staticmethod def zero() -> 'Expr': return Expr(())
@property def is_identity(self) -> bool: return True if not self.terms else (len(self.terms) == 1 and not self.terms[0].ops and self.terms[0].coeff == 1.0) def __eq__(self, other: Any) -> bool: if isinstance(other, (_PauliImpl, Term)): other = other.to_expr() return self.simplify().terms == other.simplify().terms if isinstance(other, Expr) else False def __ne__(self, other: Any) -> bool: return not self.__eq__(other) def __add__(self, other: Any) -> 'Expr': if isinstance(other, (Number, torch.Tensor)): other = Expr.from_number(other) elif isinstance(other, Term): other = Expr.from_term(other) if isinstance(other, Expr): terms = self.terms_to_dict() for op, coeff in other.terms: terms[op] = terms[op] + coeff if op in terms else coeff return Expr.from_terms_dict(terms) return NotImplemented def __sub__(self, other: Any) -> 'Expr': if isinstance(other, (Number, torch.Tensor)): other = Expr.from_number(other) elif isinstance(other, Term): other = Expr.from_term(other) if isinstance(other, Expr): terms = self.terms_to_dict() for op, coeff in other.terms: terms[op] = terms[op] - coeff if op in terms else -coeff return Expr.from_terms_dict(terms) return NotImplemented def __radd__(self, other: Any) -> 'Expr': return Expr.from_number(other) + self if isinstance(other, (Number, torch.Tensor)) else NotImplemented def __rsub__(self, other: Any) -> 'Expr': return Expr.from_number(other) - self if isinstance(other, (Number, torch.Tensor)) else NotImplemented def __neg__(self) -> 'Expr': return Expr(tuple(Term(op, -coeff) for op, coeff in self.terms)) def __mul__(self, other: Any) -> Any: if isinstance(other, (Number, torch.Tensor)): return Expr.from_terms_iter(Term(op, coeff * other) for op, coeff in self.terms) if isinstance(other, _PauliImpl): other = other.to_term() if isinstance(other, Term): return Expr(tuple(term * other for term in self.terms)) if isinstance(other, Expr): terms = defaultdict(float) for t1, t2 in product(self.terms, other.terms): term = t1 * t2 terms[term.ops] = terms[term.ops] + term.coeff if term.ops in terms else term.coeff return Expr.from_terms_dict(terms) return NotImplemented def __rmul__(self, other: Any) -> Any: if isinstance(other, (Number, torch.Tensor)): return Expr.from_terms_iter(Term(op, coeff * other) for op, coeff in self.terms) if isinstance(other, _PauliImpl): other = other.to_term() return Expr(tuple(other * term for term in self.terms)) if isinstance(other, Term) else NotImplemented def __truediv__(self, other: Any) -> Any: return Expr(tuple(term / other for term in self.terms)) if isinstance(other, (Number, torch.Tensor)) else NotImplemented def __iter__(self) -> Iterator[Term]: return iter(self.terms) def __getnewargs__(self) -> Tuple[Tuple[Term, ...]]: return (self.terms, ) def __repr__(self) -> str: return "0*I" if not self.terms else " + ".join(repr(term) for term in self.terms)
[docs] def to_expr(self) -> 'Expr': return self
[docs] def max_n(self) -> int: try: return max(term.max_n() for term in self.terms if term.ops) except ValueError: return -1
[docs] def is_commutable_with(self, other: Any) -> bool: return is_commutable(self, other)
[docs] def is_all_terms_commutable(self) -> bool: return all(is_commutable(a, b) for a, b in combinations(self.terms, 2))
@property def n_qubits(self) -> int: return self.max_n() + 1
[docs] def coeffs(self) -> Iterator[Any]: for term in self.terms: yield term.coeff
[docs] def simplify(self) -> 'Expr': d = defaultdict(float) for term in self.terms: term = term.simplify() d[term.ops] = d[term.ops] + term.coeff if term.ops in d else term.coeff return Expr.from_terms_iter(Term.from_ops_iter(k, d[k]) for k in sorted(d, key=repr) if d[k])
[docs] def to_matrix(self, n_qubits: int = -1, *, sparse: bool = False, device: Optional[torch.device] = None) -> torch.Tensor: if device is None: device = torch.device('cpu') if n_qubits == -1: n_qubits = self.n_qubits dim = 2**n_qubits expr = self.simplify() if sparse: total_matrix = torch.sparse_coo_tensor(torch.empty((2, 0), dtype=torch.int64, device=device), torch.empty(0, dtype=torch.complex128, device=device), (dim, dim)) for term in expr.terms: total_matrix = total_matrix + term.to_matrix(n_qubits, sparse=True, device=device) return total_matrix.coalesce() else: total_matrix = torch.zeros((dim, dim), dtype=torch.complex128, device=device) for term in expr.terms: total_matrix = total_matrix + term.to_matrix(n_qubits, sparse=False, device=device) return total_matrix
[docs] def pauli_from_char(ch: str, n: int = 0) -> '_PauliImpl': ch = ch.upper() if ch == "I": return I if ch == "X": return X(n) if ch == "Y": return Y(n) if ch == "Z": return Z(n) raise ValueError("ch shall be X, Y, Z or I")
[docs] def term_from_chars(chars: str) -> 'Term': """Make Pauli's Term from chars written as 'X', 'Y', 'Z' or 'I'.""" return Term.from_chars(reversed(chars))
[docs] def commutator(expr1: Any, expr2: Any) -> 'Expr': """Returns [expr1, expr2] = expr1 * expr2 - expr2 * expr1.""" expr1 = expr1.to_expr().simplify() expr2 = expr2.to_expr().simplify() return (expr1 * expr2 - expr2 * expr1).simplify()
[docs] def is_commutable(expr1: Any, expr2: Any, eps: float = 1e-8) -> bool: """Test whether expr1 and expr2 are commutable.""" return sum((x * x.conjugate()).real for x in commutator(expr1, expr2).coeffs()) < eps
_PAULI_TERM_RE = re.compile(r'([XYZI])\s*(?:\[\s*(\d+)\s*\]|(\d+))?') _PAULI_COEFF_RE = re.compile(r'^[+-]?\s*(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?') _PAULI_SPLIT_RE = re.compile(r'(?<![eE])(?=[+-])')
[docs] def parse_hamiltonian(text: str) -> Expr: """Parse a Pauli-expression string like ``"1.5*Z[0]*Z[1] - 0.5*X0 + 2"`` into an :class:`Expr`, without using `eval` (safe for untrusted input, e.g. tool calls arriving over MCP). Grammar: terms joined by ``+``/``-``; each term is an optional numeric coefficient and a product of Pauli factors ``X/Y/Z/I`` with the qubit index written as ``[n]`` or directly ``n``. ``*`` between factors is optional. A term with no Pauli factor is a constant (times identity). """ if not text or not text.strip(): raise ValueError('empty hamiltonian expression.') total: Any = None for raw_term in _PAULI_SPLIT_RE.split(text.replace(' ', '')): term_src = raw_term.strip() if not term_src: continue sign = -1.0 if term_src.startswith('-') else 1.0 body = term_src.lstrip('+-') coeff = 1.0 m = _PAULI_COEFF_RE.match(body) if m: coeff = float(m.group(0)) body = body[m.end():] body = body.lstrip('*') term: Any = sign * coeff * I consumed = 0 for pm in _PAULI_TERM_RE.finditer(body): op, idx_a, idx_b = pm.groups() idx = idx_a if idx_a is not None else idx_b if op != 'I' and idx is None: raise ValueError( f'Pauli factor {op!r} needs a qubit index in {raw_term!r}.') if op != 'I': term = term * pauli_from_char(op, int(idx)) consumed += len(pm.group(0)) if len(body.replace('*', '')) != consumed: raise ValueError(f'Could not parse hamiltonian term: {raw_term!r}') total = term if total is None else total + term if total is None: raise ValueError('empty hamiltonian expression.') return total.to_expr().simplify()
[docs] def qubo_bit(n: int) -> Expr: return 0.5 - 0.5 * Z[n]
[docs] def from_qubo(qubo: Sequence[Sequence[float]]) -> Expr: h = 0.0 for i in range(len(qubo)): h += qubo_bit(i) * qubo[i][i] for j in range(i + 1, len(qubo)): h += qubo_bit(i) * qubo_bit(j) * (qubo[i][j] + qubo[j][i]) return h
# ============================================================================== # SECTION 2: General Utility Formats # ==============================================================================
[docs] def to_inttuple(bitstr: Union[str, Counter, Dict[str, int]]) -> Union[Tuple[int, ...], Counter, Dict[Tuple[int, ...], int]]: if isinstance(bitstr, str): return tuple(int(b) for b in bitstr) if isinstance(bitstr, Counter): return Counter({tuple(int(b) for b in k): v for k, v in bitstr.items()}) if isinstance(bitstr, dict): return {tuple(int(b) for b in k): v for k, v in bitstr.items()} raise ValueError("bitstr type shall be `str`, `Counter` or `dict`")
[docs] def ignore_global_phase(statevec: torch.Tensor) -> torch.Tensor: """Multiply e^-iθ to `statevec` where θ is a phase of first non-zero element.""" for q in statevec: if torch.abs(q) > 1e-7: ang = torch.abs(q) / q statevec = statevec * ang break return statevec
[docs] def gen_graycode(n: int) -> Iterator[int]: return (v ^ (v >> 1) for v in range(2**n))
[docs] def gen_gray_controls(n: int) -> Iterator[Tuple[int, int, int]]: """Generate an iterator which returns bit indices for constructing Gray code based controlled gate. """ def gen_changedbit(n_bits: int) -> Iterator[int]: pow2 = [2 ** i for i in range(n_bits)] gen = gen_graycode(n_bits) try: prev = next(gen) except StopIteration: raise ValueError("Empty Gray code generation.") from None for g in gen: yield pow2.index(g ^ prev) prev = g def gen_cxtarget() -> Iterator[int]: k = 0 while True: for _ in range(2**k): yield k k += 1 def gen_parity() -> Iterator[int]: while True: yield 0 yield 1 for c0, c1, p in zip(gen_changedbit(n), gen_cxtarget(), gen_parity()): if c0 == c1: yield c0 - 1, c1, p else: yield c0, c1, p
[docs] def random_unitary(dim: int, seed: Optional[int] = None, device: Optional[torch.device] = None) -> torch.Tensor: """A ``dim x dim`` unitary drawn from the Haar measure. The usual recipe -- QR-decompose a complex Gaussian matrix and take Q -- is **not** Haar distributed on its own, because QR does not fix the phases of Q's columns. Multiplying by the phases of R's diagonal is what fixes it, and omitting that step biases the distribution in a way that quietly shifts quantities like the heavy-output probability of a random circuit. `seed` uses a private generator and leaves the global RNG alone. Returns a ``torch.Tensor``, as everything else in this SDK does -- call ``.numpy()`` on it before mixing with NumPy, or ``numpy`` operations will fail on the tensor rather than converting it. """ if dim < 1: raise ValueError(f"dim must be at least 1, got {dim}.") if device is None: device = torch.device('cpu') generator = None if seed is not None: generator = torch.Generator(device=device) generator.manual_seed(int(seed)) real = torch.randn(dim, dim, dtype=torch.float64, device=device, generator=generator) imag = torch.randn(dim, dim, dtype=torch.float64, device=device, generator=generator) q, r = torch.linalg.qr(torch.complex(real, imag)) diagonal = torch.diagonal(r) return q * (diagonal / torch.abs(diagonal)).unsqueeze(0)
[docs] def check_unitarity(mat: torch.Tensor) -> bool: """Check whether mat is a unitary matrix.""" if mat.dim() != 2 or mat.shape[0] != mat.shape[1]: return False eye = torch.eye(mat.shape[0], dtype=mat.dtype, device=mat.device) return torch.allclose(mat @ mat.mH, eye, atol=1e-6)
[docs] def calc_u_params(mat: torch.Tensor) -> Tuple[float, float, float, float]: """Calculate U-gate parameters from a 2x2 unitary matrix. ``U(theta, phi, lam, gamma)`` is ``e^{i gamma} [[cos(t), -e^{i lam} sin(t)], [e^{i phi} sin(t), e^{i(phi+lam)} cos(t)]]`` with ``t = theta / 2``. The general route reads `gamma` off ``mat[0, 0]`` and ``phi + lam`` off ``mat[1, 1]``. An antidiagonal unitary -- X and Y among them -- has both of those equal to zero, and ``cmath.phase(0)`` returns ``0.0`` without complaint, so two free phases silently vanish and the reconstructed gate is a different unitary, not merely a different global phase. That case is handled separately below. """ assert mat.shape == (2, 2) assert check_unitarity(mat) m00, m01, m10, m11 = (complex(mat[0, 0]), complex(mat[0, 1]), complex(mat[1, 0]), complex(mat[1, 1])) if abs(m00) < 1e-12: # cos(theta/2) == 0, so theta = pi and the matrix is [[0, -e^{i(g+l)}], # [e^{i(g+p)}, 0]]. Only the two sums are determined; gamma is free, and # taking it as 0 reconstructs the input exactly. theta = math.pi gamma = 0.0 phi = cmath.phase(m10) % (2.0 * math.pi) lam = cmath.phase(-m01) % (2.0 * math.pi) return theta, phi, lam, gamma gamma = cmath.phase(m00) phase = cmath.exp(-1j * gamma) m00, m10, m11 = m00 * phase, m10 * phase, m11 * phase theta = math.atan2(abs(m10), m00.real) * 2.0 phi_plus_lambda = cmath.phase(m11) phi = cmath.phase(m10) % (2.0 * math.pi) lam = (phi_plus_lambda - phi) % (2.0 * math.pi) return theta, phi, lam, gamma
[docs] def sqrt_2x2_matrix(mat: torch.Tensor) -> torch.Tensor: """Returns square root of a 2x2 matrix. Reference: https://en.wikipedia.org/wiki/Square_root_of_a_2_by_2_matrix """ assert mat.shape == (2, 2) eye = torch.eye(2, dtype=mat.dtype, device=mat.device) s = torch.sqrt(torch.linalg.det(mat)) t = torch.sqrt(mat[0, 0] + mat[1, 1] + 2 * s) if abs(complex(t)) < 1e-8: # Avoid division by zero s = -s t = torch.sqrt(mat[0, 0] + mat[1, 1] + 2 * s) return (mat + s * eye) / t
# ============================================================================== # SECTION 3: VQE / QAOA Ansatz Execution Framework # ==============================================================================
[docs] class AnsatzBase: """Base class for Variational Quantum Eigensolver Ansatz using PyTorch.""" def __init__(self, hamiltonian: Any, n_params: int) -> None: self.hamiltonian = hamiltonian self.n_params = n_params self.n_qubits: int = self.hamiltonian.max_n() + 1 self.sparse: Optional[torch.Tensor] = None
[docs] def make_sparse(self, sparse: bool = True, device: Optional[torch.device] = None) -> None: # self.n_qubits may be wider than the hamiltonian's own qubit span (e.g. an # init_circuit with extra/ancilla qubits), so it must be passed explicitly -- # otherwise to_matrix() infers a narrower width and later matrix-vector ops # against the full-width statevector fail with a dimension mismatch. self.sparse = self.hamiltonian.to_matrix(self.n_qubits, sparse=sparse, device=device)
[docs] def get_circuit(self, params: torch.Tensor) -> Circuit: raise NotImplementedError
[docs] def get_energy(self, circuit: Circuit, sampler: Callable[[Circuit, typing.Iterable[int]], Dict[Tuple[int, ...], float]]) -> torch.Tensor: """Calculate energy expectation value from circuit and sampler with Autograd support. Whether the result carries a gradient back to `circuit`'s parameters depends on `sampler`: an exact sampler (e.g. `non_sampling_sampler`) keeps the autograd graph intact, while a genuinely stochastic one (e.g. one built from `get_measurement_sampler`) does not -- real shot noise isn't differentiable, so that is expected, not a bug. To optimize through such a sampler anyway, `Vqe` estimates the gradient with `parameter_shift_gradient` instead, which it selects on its own by default. """ val: Any = 0.0 for raw_meas in self.hamiltonian: # Merge any operators sharing a qubit (e.g. X[0]*Z[0] -> -1j*Y[0]) into a # single effective Pauli per qubit first. Without this, a term touching # the same qubit more than once would get an extra basis rotation applied # to it and would have that qubit's bit counted more than once in the # parity check below, corrupting the sign of the contribution. meas = raw_meas.simplify() coeff_val = meas.coeff if isinstance(meas.coeff, torch.Tensor) else complex(meas.coeff) # 1. 定数項(Iのみ)の処理 if not meas.ops: val = val + coeff_val continue # 2. この項に関係する全ての量子ビットを特定 active_qubits = sorted(op.n for op in meas.ops) n_qubits = max(max(active_qubits) + 1, circuit.n_qubits) # 3. 各項ごとに完全に独立した測定用回路を作成 c = Circuit(n_qubits) c.ops = list(circuit.ops) # Rotate each measured operator into the Z basis: H X H = Z and # RX(+pi/2) Y RX(-pi/2) = Z. Using RX(-pi/2) here would measure -Y, # flipping the sign of every term containing an odd number of Y's. for op in meas.ops: if op.op == "X": c.h[op.n] elif op.op == "Y": c.rx(torch.tensor(torch.pi / 2, dtype=torch.float64))[op.n] # 4. サンプラーが返す (測定qubitごとのbit tuple -> 確率) を実際に消費し、 # 各qubitの1ビットのパリティで符号を決めて集計する # (simplify() 済みなので active_qubits は重複なく meas.ops と1対1) for bits, prob in sampler(c, active_qubits).items(): parity = sum(bits) % 2 val = val + (-prob * coeff_val if parity else prob * coeff_val) if isinstance(val, torch.Tensor): return (val.real if torch.is_complex(val) else val).squeeze() return torch.tensor(val.real if isinstance(val, complex) else val, dtype=torch.float64)
[docs] def get_energy_sparse(self, circuit: Circuit) -> torch.Tensor: return sparse_expectation(self.sparse, circuit.run())
[docs] def get_objective(self, sampler: Optional[Callable[[Circuit, typing.Iterable[int]], Dict[Tuple[int, ...], float]]] = None, device: Optional[torch.device] = None) -> Callable[[torch.Tensor], torch.Tensor]: if self.sparse is None: self.make_sparse(sparse=True, device=device) if sampler is not None: return lambda p: self.get_energy(self.get_circuit(p), sampler) return lambda p: self.get_energy_sparse(self.get_circuit(p))
[docs] class QaoaAnsatz(AnsatzBase): def __init__(self, hamiltonian: Any, step: int = 1, init_circuit: Optional[Circuit] = None, mixer: Optional[Any] = None) -> None: # Convert to Expr before super().__init__, which immediately calls # .max_n() on it -- a bare Pauli operator like Z[0] (as opposed to a # Term/Expr) doesn't have that method and would raise AttributeError. hamiltonian = hamiltonian.to_expr().simplify() super().__init__(hamiltonian, step * 2) self.hamiltonian = hamiltonian if not self.check_hamiltonian(): raise ValueError("Hamiltonian terms are not commutable") self.step = step self.n_qubits = self.hamiltonian.max_n() + 1 # A custom mixer may touch qubits the Hamiltonian never mentions. Taking # the width from init_circuit alone built the Hamiltonian's matrix narrow # while get_circuit() produced a wider state, and the expectation failed # on a dimension mismatch deep inside the optimizer. if mixer is not None: mixer_width = mixer.to_expr().max_n() + 1 if hasattr(mixer, 'to_expr') else 0 self.n_qubits = max(self.n_qubits, mixer_width) if init_circuit: self.init_circuit = init_circuit if init_circuit.n_qubits > self.n_qubits: self.n_qubits = init_circuit.n_qubits else: if mixer: raise ValueError('init_circuit is required when mixer is not default.') self.init_circuit = Circuit(self.n_qubits).h[:] self.mixer = mixer self.time_evolutions = [term.get_time_evolution() for term in self.hamiltonian] self.mixer_time_evolutions = [term.get_time_evolution() for term in self.mixer] if mixer else []
[docs] def check_hamiltonian(self) -> bool: """Check hamiltonian is commutable. This condition is required for QaoaAnsatz, since get_circuit Trotterizes e^{-iHt} into a per-term product of time evolutions -- exact only when every term commutes with every other term.""" return self.hamiltonian.is_all_terms_commutable()
[docs] def get_circuit(self, params: torch.Tensor) -> Circuit: c = self.init_circuit.copy() betas, gammas = params[:self.step], params[self.step:] for beta, gamma in zip(betas, gammas): for evo in self.time_evolutions: evo(c, gamma * 2.0 * torch.pi) if self.mixer is None: c.rx(beta * torch.pi)[:] else: for evo in self.mixer_time_evolutions: evo(c, beta * torch.pi) return c
[docs] @dataclass class VqeResult: vqe: Optional['Vqe'] = None params: Optional[torch.Tensor] = None circuit: Optional[Circuit] = None #: Objective value at every optimizer iteration, in order, so that a run can #: be checked for convergence without re-running it with another optimizer. #: `len(loss_history)` is the number of iterations actually taken (which is #: below `max_iter` when the gradient-norm tolerance stopped the loop early). loss_history: List[float] = field(default_factory=list) _probs: Optional[Dict[Tuple[int, ...], float]] = None
[docs] def most_common(self, n: int = 1) -> Tuple[Tuple[Tuple[int, ...], float], ...]: return tuple(sorted(self.get_probs().items(), key=lambda item: -item[1]))[:n]
[docs] def get_probs(self, sampler: Optional[Callable[[Circuit, typing.Iterable[int]], Dict[Tuple[int, ...], float]]] = None, rerun: Optional[bool] = None, store: bool = True) -> Dict[Tuple[int, ...], float]: if rerun is None: rerun = sampler is not None if self._probs is not None and not rerun: return self._probs if sampler is None and self.vqe is not None: sampler = self.vqe.sampler if self.circuit is None: raise ValueError("No circuit available.") raw_probs = expect(self.circuit.run(), range(self.circuit.n_qubits)) if sampler is None else sampler(self.circuit, range(self.circuit.n_qubits)) # get_probs()/most_common() are reporting APIs (sorting, printing, equality # checks against plain dicts), not part of an autograd graph, so normalize # to plain floats regardless of whether expect() or a custom sampler handed # back tensors. probs = {k: (v.item() if isinstance(v, torch.Tensor) else v) for k, v in raw_probs.items()} if store: self._probs = probs return probs
[docs] class Vqe: def __init__(self, ansatz: AnsatzBase, optimizer_cls: Type[torch.optim.Optimizer] = torch.optim.Adam, optimizer_kwargs: Optional[Dict[str, Any]] = None, sampler: Optional[Callable[[Circuit, typing.Iterable[int]], Dict[Tuple[int, ...], float]]] = None, seed: Optional[int] = None, gradient: str = 'auto') -> None: """`seed` (also accepted per-call as `Vqe.run(seed=...)`) makes the whole run deterministic: it fixes the random `initial_params` and re-seeds the sampler if it is a seedable one (as built by `get_measurement_sampler`). Without it, `run()` starts from `torch.rand` parameters, so repeated runs of the same problem legitimately land in different local optima. `gradient` picks how the optimizer gets its gradient: ``'auto'`` The default. Backpropagate when the objective carries a gradient, and fall back to the parameter-shift rule when it does not -- which is exactly the shot-based case, where sampling has thrown the autograd graph away. ``'backprop'`` Always backpropagate. Fails on a sampler that estimates from shots. ``'parameter_shift'`` Always use the shift rule. Exact, and the only option that works with shot noise, but it costs two extra circuit evaluations per parametric gate application. """ if gradient not in ('auto', 'backprop', 'parameter_shift'): raise ValueError("gradient must be 'auto', 'backprop' or 'parameter_shift', " f"got {gradient!r}.") self.ansatz = ansatz self.optimizer_cls = optimizer_cls self.optimizer_kwargs = optimizer_kwargs or {"lr": 0.05} self.sampler = sampler self.sampler_call_count = 0 self.seed = seed self.gradient = gradient
[docs] def run(self, max_iter: int = 500, tol: float = 1e-6, verbose: bool = False, device: Optional[torch.device] = None, initial_params: Optional[torch.Tensor] = None, seed: Optional[int] = None, gradient: Optional[str] = None) -> VqeResult: if device is None: device = torch.device('cpu') if seed is None: seed = self.seed if gradient is None: gradient = self.gradient if gradient not in ('auto', 'backprop', 'parameter_shift'): raise ValueError("gradient must be 'auto', 'backprop' or 'parameter_shift', " f"got {gradient!r}.") self.sampler_call_count = 0 if seed is not None and hasattr(self.sampler, "set_seed"): # One user-facing seed drives both sources of randomness. The sampler gets # a derived (not identical) seed so that its draws are not correlated with # the initial-parameter draws. self.sampler.set_seed(int(seed) + 0x9E3779B9) counting_sampler = None if self.sampler is not None: def counting_sampler(circuit: Circuit, meas: typing.Iterable[int]) -> Dict[Tuple[int, ...], float]: self.sampler_call_count += 1 return self.sampler(circuit, meas) objective_fn = self.ansatz.get_objective(counting_sampler, device=device) if initial_params is None: generator = None if seed is not None: generator = torch.Generator(device=device) generator.manual_seed(int(seed)) params = torch.rand(self.ansatz.n_params, dtype=torch.float64, device=device, generator=generator).requires_grad_(True) else: params = torch.as_tensor(initial_params, dtype=torch.float64, device=device).clone().detach().requires_grad_(True) if params.shape != (self.ansatz.n_params,): raise ValueError(f"initial_params must have shape ({self.ansatz.n_params},), got {tuple(params.shape)}") optimizer = self.optimizer_cls([params], **self.optimizer_kwargs) if counting_sampler is not None: energy_of_circuit: Callable[[Circuit], Any] = \ lambda circuit: self.ansatz.get_energy(circuit, counting_sampler) else: energy_of_circuit = self.ansatz.get_energy_sparse use_shift = gradient == 'parameter_shift' if gradient == 'auto': # A sampler that estimates from shots returns plain numbers, so the # objective comes back with no graph to backpropagate through. That is # the signal -- not a guess about which sampler was supplied. probe = objective_fn(params) use_shift = not (isinstance(probe, torch.Tensor) and probe.requires_grad) loss_history: List[float] = [] for idx in range(max_iter): optimizer.zero_grad() if use_shift: loss, grad = parameter_shift_gradient(self.ansatz, params, energy_of_circuit) params.grad = grad.to(dtype=params.dtype, device=params.device) else: loss = objective_fn(params) loss.backward() loss_history.append(float(loss.item())) if verbose: print(f"iter {idx}: loss={loss_history[-1]}") optimizer.step() if params.grad is not None and torch.norm(params.grad) < tol: break final_params = params.detach() self._result = VqeResult(self, final_params, self.ansatz.get_circuit(final_params), loss_history=loss_history) return self._result
[docs] def expect(qubits: torch.Tensor, meas: typing.Iterable[int]) -> Dict[Tuple[int, ...], torch.Tensor]: """Marginal probabilities of `meas` qubits, as gradient-carrying tensors (not plain floats) so that `AnsatzBase.get_energy` can backprop through them when `qubits` came from a differentiable circuit run.""" meas_tuple = tuple(meas) mask = reduce(lambda acc, v: acc | (1 << v), meas_tuple, 0) cnt: Dict[int, torch.Tensor] = {} probs = torch.abs(qubits) ** 2 for i, p_val in enumerate(probs): # .item() here is only a control-flow check (skip exactly-zero-probability # outcomes, matching the previous behavior); p_val itself -- what actually # gets accumulated -- stays a tensor so the gradient is preserved. if p_val.item() == 0.0: continue key = i & mask cnt[key] = cnt[key] + p_val if key in cnt else p_val return {tuple(1 if k & (1 << i) else 0 for i in meas_tuple): val for k, val in cnt.items()}
[docs] def non_sampling_sampler(circuit: Circuit, meas: typing.Iterable[int]) -> Dict[Tuple[int, ...], float]: return expect(circuit.run(), meas)
[docs] def get_measurement_sampler(n_sample: int, device: Optional[torch.device] = None, seed: Optional[int] = None) -> Callable[[Circuit, typing.Iterable[int]], Dict[Tuple[int, ...], float]]: """A sampler that estimates probabilities from `n_sample` simulated measurements. With `seed` set, the sampler draws from its own generator instead of the global RNG, so a VQE run using it is reproducible. The returned callable also carries a `set_seed(seed)` method, which is what `Vqe.run(seed=...)` calls to put the whole run -- initial parameters and sampling alike -- under a single seed.""" state: Dict[str, Optional[torch.Generator]] = {"generator": None} def set_seed(new_seed: Optional[int]) -> None: if new_seed is None: state["generator"] = None return generator = torch.Generator(device=device or torch.device('cpu')) generator.manual_seed(int(new_seed)) state["generator"] = generator set_seed(seed) def sampling_by_measurement(circuit: Circuit, meas: typing.Iterable[int]) -> Dict[Tuple[int, ...], float]: meas_tuple = tuple(meas) statevector = circuit.run() probs = torch.abs(statevector) ** 2 # torch.multinomial is capped at 2^24 categories; inverse-CDF sampling # (cumsum + searchsorted) has no such limit. Same approach as TorchBackend. cdf = torch.cumsum(probs, dim=0) cdf[-1] = 1.0 u = torch.rand(n_sample, device=probs.device, dtype=probs.dtype, generator=state["generator"]) samples = torch.searchsorted(cdf, u) unique_elements, counts = torch.unique(samples, return_counts=True) result_counts = Counter() for idx, count in zip(unique_elements, counts): bit_key = tuple((idx.item() >> m) & 1 for m in meas_tuple) result_counts[bit_key] += count.item() return {k: v / n_sample for k, v in result_counts.items()} sampling_by_measurement.set_seed = set_seed # type: ignore[attr-defined] return sampling_by_measurement
[docs] def pauli_expectation(hamiltonian: Any, state: torch.Tensor, n_qubits: int = -1) -> torch.Tensor: """``<psi|H|psi>`` for a Pauli-expression `H`, without ever building `H` as a matrix. A Pauli product is a signed permutation of basis states, so each term costs one pass over the state. Forming the ``2**n x 2**n`` matrix first -- what :meth:`Expr.to_matrix` does -- instead costs ``4**n``, which puts even 16 qubits out of reach. Differentiable in both the state and tensor-valued coefficients. `state` is either a statevector (1-D), giving ``<psi|H|psi>``, or a density matrix (2-D), giving ``Tr(rho H)``. `n_qubits` defaults to the width implied by the state. """ if hasattr(hamiltonian, 'to_expr'): hamiltonian = hamiltonian.to_expr() expr = hamiltonian.simplify() if state.dim() == 2: return _density_pauli_expectation(expr, state, n_qubits) statevector = state.reshape(-1) dim = statevector.shape[0] if dim & (dim - 1): raise ValueError(f"statevector length must be a power of two, got {dim}.") implied = dim.bit_length() - 1 if n_qubits == -1: n_qubits = implied elif (1 << n_qubits) != dim: raise ValueError(f"statevector of length {dim} does not hold {n_qubits} qubits.") max_n = expr.max_n() if max_n >= n_qubits: raise ValueError(f"Hamiltonian acts on qubit {max_n}, beyond the {n_qubits}-qubit state.") device = statevector.device bra = statevector.conj() indices: Optional[torch.Tensor] = None total: Any = None for term in expr.terms: term = term.simplify() if not term.ops: # Identity term: coeff * <psi|psi> (matching what the matrix form would # give for an unnormalized state). contribution = torch.as_tensor(term.coeff, dtype=statevector.dtype, device=device) * torch.sum(bra * statevector) else: # `vals[c]` is the term's only nonzero entry in column c (coefficient # included), sitting in row `c ^ flip`; so the sum below is exactly # sum_c conj(psi[c ^ flip]) * vals[c] * psi[c]. vals = _term_to_dataarray(term, n_qubits, device).to(statevector.dtype) flip = 0 for op in term.ops: if op.op in ('X', 'Y'): flip |= 1 << op.n if flip: if indices is None: indices = torch.arange(dim, device=device) out = bra[indices ^ flip] else: out = bra contribution = torch.sum(out * vals * statevector) total = contribution if total is None else total + contribution if total is None: return torch.zeros((), dtype=torch.float64, device=device) return total.real
def _density_pauli_expectation(expr: 'Expr', rho: torch.Tensor, n_qubits: int = -1) -> torch.Tensor: """``Tr(rho H)``, the density-matrix counterpart of :func:`pauli_expectation`.""" dim = rho.shape[0] if rho.shape[0] != rho.shape[1] or dim & (dim - 1): raise ValueError(f"density matrix must be square with a power-of-two size, " f"got {tuple(rho.shape)}.") implied = dim.bit_length() - 1 if n_qubits == -1: n_qubits = implied elif (1 << n_qubits) != dim: raise ValueError(f"density matrix of size {dim} does not hold {n_qubits} qubits.") max_n = expr.max_n() if max_n >= n_qubits: raise ValueError(f"Hamiltonian acts on qubit {max_n}, beyond the {n_qubits}-qubit state.") device = rho.device indices = torch.arange(dim, device=device) total: Any = None for term in expr.terms: term = term.simplify() if not term.ops: contribution = torch.as_tensor(term.coeff, dtype=rho.dtype, device=device) * torch.diagonal(rho).sum() else: # The term's only nonzero entry in column c sits in row `c ^ flip`, so # Tr(rho P) = sum_r rho[r, r ^ flip] * vals[r]. vals = _term_to_dataarray(term, n_qubits, device).to(rho.dtype) flip = 0 for op in term.ops: if op.op in ('X', 'Y'): flip |= 1 << op.n contribution = torch.sum(rho[indices, indices ^ flip] * vals) total = contribution if total is None else total + contribution if total is None: return torch.zeros((), dtype=torch.float64, device=device) return total.real #: Gates whose generator has exactly two eigenvalues one apart, which is what makes #: the two-term shift rule exact. Controlled rotations (crx/cry/crz) have four #: eigenvalues and need a four-term rule, so they are refused rather than #: silently given a wrong gradient. SHIFT_RULE_GATES = frozenset({ 'rx', 'ry', 'rz', 'p', 'phase', 'r', 'rxx', 'ryy', 'rzz', 'cp', 'cphase', 'cr', 'exch', 'exchange', })
[docs] def parameter_shift_gradient( ansatz: 'AnsatzBase', params: torch.Tensor, energy_of_circuit: Callable[[Circuit], Any]) -> Tuple[torch.Tensor, torch.Tensor]: """The energy and its gradient at `params`, by the parameter-shift rule. Backpropagation cannot see through a sampler: estimating an expectation value from shots throws away the autograd graph, so shot-based VQE has no gradient to descend. The shift rule gets one from the same estimator, by evaluating it at shifted parameters instead of differentiating it. Each gate's own derivative is ``(E(theta + pi/2) - E(theta - pi/2)) / 2``, exact rather than a finite difference. Those are then chained onto `params` through autograd, so a parameter feeding several gates -- as QAOA's angles do -- correctly sums their contributions. `energy_of_circuit` takes a circuit and returns its energy; the cost is two of those evaluations per parametric gate application. """ from .circuit_funcs.flatten import flatten tracked = params.detach().clone().requires_grad_(True) # Flattening expands named blocks and sliced targets, so that a gate applied # to three qubits becomes three applications: the shift rule needs a term per # application, not per written operation. flat = flatten(ansatz.get_circuit(tracked)) ops = list(flat.ops) n_qubits = flat.n_qubits trainable: List[Tuple[int, torch.Tensor]] = [] base_ops: List[Any] = [] for i, op in enumerate(ops): depends = any(isinstance(v, torch.Tensor) and v.requires_grad for v in op.params) if not depends: base_ops.append(op) continue if len(op.params) != 1: raise ValueError( f"{op.lowername} takes {len(op.params)} parameters; the shift rule " f"here handles single-parameter gates only.") if op.lowername not in SHIFT_RULE_GATES: raise ValueError( f"{op.lowername} does not satisfy the two-term parameter-shift rule " f"(its generator has more than two eigenvalues). Rebuild the ansatz " f"from {sorted(SHIFT_RULE_GATES)}, or optimize by backpropagation " f"with an exact sampler.") trainable.append((i, op.params[0])) base_ops.append(type(op).create(op.targets, (float(op.params[0].detach()), ), None)) def energy_at(op_list: List[Any]) -> float: return float(energy_of_circuit(Circuit(n_qubits, list(op_list)))) value = torch.tensor(energy_at(base_ops), dtype=torch.float64) if not trainable: raise ValueError( "No gate parameter depends on `params`. The shift rule needs the ansatz " "to pass parameters into gates as tensors; converting them to Python " "floats inside get_circuit() breaks the connection.") derivatives = [] for index, _ in trainable: theta = float(base_ops[index].params[0]) shifted = [] for offset in (pi / 2, -pi / 2): variant = list(base_ops) variant[index] = type(ops[index]).create(ops[index].targets, (theta + offset, ), None) shifted.append(energy_at(variant)) derivatives.append(0.5 * (shifted[0] - shifted[1])) angles = [angle for _, angle in trainable] grad_outputs = [torch.as_tensor(d, dtype=angle.dtype, device=angle.device) for d, angle in zip(derivatives, angles)] grads = torch.autograd.grad(angles, tracked, grad_outputs=grad_outputs, allow_unused=True) gradient = grads[0] if gradient is None: gradient = torch.zeros_like(tracked) return value, gradient.detach().to(params.dtype)
[docs] def sparse_expectation(mat: torch.Tensor, vec: torch.Tensor) -> torch.Tensor: mv = torch.sparse.mm(mat, vec.unsqueeze(1)).squeeze(1) if mat.is_sparse else torch.mv(mat, vec) return torch.vdot(vec, mv).real