Source code for tensorcircuit.analogcircuit

"""
Analog-Digital Hybrid Circuit class wrapper
only support jax backend
"""

from typing import Any, List, Optional, Callable, Dict, Tuple, Union, Sequence
from dataclasses import dataclass
from functools import partial

import numpy as np
import tensornetwork as tn

from .cons import backend, rdtypestr
from .abstractcircuit import defined_gates
from .circuit import Circuit
from .quantum import QuOperator
from .timeevol import ode_evol_global, ode_evol_local
from .utils import arg_alias

Tensor = Any


[docs] @dataclass class AnalogBlock: """ A data structure to hold information about an analog evolution block. """ hamiltonian_func: Callable[[Tensor], Tensor] time: float index: Optional[List[int]] = None solver_options: Optional[Dict[str, Any]] = None
[docs] class AnalogCircuit: """ A class for hybrid digital-analog quantum simulation with time-dependent Hamiltonians. """
[docs] def __init__( self, nqubits: int, inputs: Optional[Tensor] = None, mps_inputs: Optional[QuOperator] = None, split: Optional[Dict[str, Any]] = None, dim: Optional[int] = None, ): """ Initializes the hybrid circuit. :param nqubits: The number of qubits in the circuit. :type nqubits: int :param dim: The local Hilbert space dimension per site. Qudit is supported for 2 <= d <= 36. :type dim: If None, the dimension of the circuit will be `2`, which is a qubit system. :param inputs: If not None, the initial state of the circuit is taken as ``inputs`` instead of :math:`\vert 0 \rangle^n` qubits, defaults to None. :type inputs: Optional[Tensor], optional :param mps_inputs: QuVector for a MPS like initial wavefunction. :type mps_inputs: Optional[QuOperator] :param split: dict if two qubit gate is ready for split, including parameters for at least one of ``max_singular_values`` and ``max_truncation_err``. :type split: Optional[Dict[str, Any]] """ self.num_qubits, self._nqubits = nqubits, nqubits self.dim = 2**self.num_qubits if inputs is None: self.inputs = np.zeros([self.dim]) self.inputs[0] = 1.0 self.inputs = backend.convert_to_tensor(self.inputs) else: self.inputs = inputs # List of digital circuits, starting with one empty circuit. self.digital_circuits: List[Circuit] = [ Circuit( self.num_qubits, inputs=self.inputs, mps_inputs=mps_inputs, split=split, dim=dim, ) ] # List of analog blocks, each containing the Hamiltonian function, time, and solver options. self.analog_blocks: List[AnalogBlock] = [] self._effective_circuit: Optional[Circuit] = None self._solver_options: Dict[str, Any] = {}
[docs] def set_solver_options(self, **kws: Any) -> None: """ set solver options globally for this circuit object """ self._solver_options = kws
@property def effective_circuit(self) -> Circuit: """ Returns the effective circuit after all blocks have been added. """ if self._effective_circuit is None: self.state() return self._effective_circuit # type: ignore @property def current_digital_circuit(self) -> Circuit: """ Returns the last (currently active) digital circuit. """ return self.digital_circuits[-1]
[docs] def add_analog_block( self, hamiltonian: Callable[[float], Tensor], time: Union[float, List[Tensor]], index: Optional[List[int]] = None, **solver_options: Any, ) -> "AnalogCircuit": """ Adds a time-dependent analog evolution block to the circuit. This finalizes the current digital block and prepares a new one for subsequent gates. :param hamiltonian_func: A function H(t) that takes a time `t` (from 0 to `time`) and returns the Hamiltonian matrix at that instant. :type hamiltonian_func: Callable[[float], np.ndarray] :param time: The total evolution time 'T'. :type time: float :param index: The indices of the qubits to apply the analog evolution to. Defaults None for global application. :type index: Optional[List[int]] :param solver_options: Keyword arguments passed directly to `tc.timeevol.ode_evolve` :type solver_options: Dict[str, Any] """ # Create and store the analog block information time = backend.convert_to_tensor(time, dtype=rdtypestr) time = backend.reshape(time, [-1]) if backend.shape_tuple(time)[0] == 1: time = backend.stack([0.0, time[0]]) # type: ignore elif backend.shape_tuple(time)[0] > 2: raise ValueError( "Time must be a scalar or a two elements array for the starting and end points." ) combined_solver_options = self._solver_options.copy() combined_solver_options.update(solver_options) block = AnalogBlock( hamiltonian_func=hamiltonian, time=time, # type: ignore index=index, solver_options=combined_solver_options, ) self.analog_blocks.append(block) # After adding an analog block, we start a new digital block. self.digital_circuits.append(Circuit(self.num_qubits, inputs=self.inputs)) self._effective_circuit = None return self # Allow for chaining
[docs] def append(self, c: Any, indices: Optional[List[int]] = None) -> "AnalogCircuit": """ Append a circuit or another AnalogCircuit to the current hybrid circuit. If an AnalogCircuit is appended, its block structure is merged into the current one. :param c: The circuit to append. :type c: Union[Circuit, AnalogCircuit] :param indices: Optional qubit indices to map the appended circuit to. :type indices: Optional[Sequence[int]] :return: The updated AnalogCircuit. :rtype: AnalogCircuit """ if isinstance(c, AnalogCircuit): # 1. Concatenate the first digital circuit of c to our current digital circuit self.current_digital_circuit.append(c.digital_circuits[0], indices=indices) # 2. Append all subsequent analog blocks and their corresponding digital circuits for i in range(len(c.analog_blocks)): self.analog_blocks.append(c.analog_blocks[i]) self.digital_circuits.append(c.digital_circuits[i + 1]) elif isinstance(c, Circuit): self.current_digital_circuit.append(c, indices=indices) else: raise TypeError( f"AnalogCircuit.append expects a Circuit or AnalogCircuit, got {type(c).__name__}" ) self._effective_circuit = None return self
def _build_digital_circuit_from_qir( self, qir: List[Dict[str, Any]], nqubits: int ) -> Circuit: """Helper to build a digital Circuit from QIR with inputs set for replace_inputs compatibility.""" return Circuit.from_qir( # type: ignore[return-value] qir, circuit_params={"nqubits": nqubits, "inputs": self.inputs}, )
[docs] def inverse(self) -> "AnalogCircuit": """ Inverse the hybrid circuit, including digital gate sequences and analog evolutions. Analog blocks are inverted by negating the Hamiltonian (H -> -H), which gives the physical inverse e^{+iHT} of the evolution e^{-iHT}. :return: The inversed AnalogCircuit. :rtype: AnalogCircuit """ new_circ = AnalogCircuit(self._nqubits) # Reverse the blocks: # Original: D0 -> B0 -> D1 -> B1 -> ... -> DN # Inverse: DN^-1 -> B_{N-1}^-1 -> DN-1^-1 -> ... -> D0^-1 # 1. Start with the inverse of the last digital circuit last_inv = self.digital_circuits[-1].inverse() new_circ.digital_circuits = [ self._build_digital_circuit_from_qir(last_inv.to_qir(), self._nqubits) ] # 2. Iterate backwards through analog blocks and preceding digital circuits for i in range(len(self.analog_blocks) - 1, -1, -1): block = self.analog_blocks[i] # Negate the Hamiltonian for the inverse evolution: # e^{-iHT} is inverted by e^{+iHT} = e^{-i(-H)T} neg_ham = lambda t, _orig=block.hamiltonian_func: -_orig(t) inv_block = AnalogBlock( hamiltonian_func=neg_ham, time=block.time, index=block.index, solver_options=block.solver_options, ) new_circ.analog_blocks.append(inv_block) inv_c = self.digital_circuits[i].inverse() new_circ.digital_circuits.append( self._build_digital_circuit_from_qir(inv_c.to_qir(), self._nqubits) ) new_circ._effective_circuit = None return new_circ
def __getattr__(self, name: str) -> Any: """ Metaprogramming to forward gate calls to the current digital circuit. This enables syntax like `analog_circuit.h(0)`. """ gate_method = getattr(self.current_digital_circuit, name, None) if gate_method and callable(gate_method) and name.lower() in defined_gates: def wrapper(*args, **kwargs): # type: ignore gate_method(*args, **kwargs) self._effective_circuit = None return self return wrapper else: raise AttributeError( f"'{type(self).__name__}' object or its underlying '{type(self.current_digital_circuit).__name__}' " f"object has no attribute '{name}'." )
[docs] def state(self, form: str = "default") -> Tensor: """ Executes the full digital-analog sequence. :return: The final state vector after the full evolution :rtype: Tensor """ # Propagate the state through the alternating circuit blocks for i, analog_block in enumerate(self.analog_blocks): # 1. Apply Digital Block i digital_c = self.digital_circuits[i] if i > 0: digital_c.replace_inputs(psi) # type: ignore psi = digital_c.wavefunction() if analog_block.index is None: psi = ode_evol_global( # type: ignore hamiltonian=analog_block.hamiltonian_func, initial_state=psi, times=analog_block.time, **analog_block.solver_options, ) else: psi = ode_evol_local( # type: ignore hamiltonian=analog_block.hamiltonian_func, initial_state=psi, times=analog_block.time, index=analog_block.index, **analog_block.solver_options, ) psi = psi[-1] # TODO(@refraction-ray): support more time evol methods # 3. Apply the final digital circuit if self.analog_blocks: self.digital_circuits[-1].replace_inputs(psi) psi = self.digital_circuits[-1].wavefunction() else: psi = self.digital_circuits[-1].wavefunction() self._effective_circuit = Circuit(self.num_qubits, inputs=psi) if form == "default": shape = [-1] elif form == "ket": shape = [-1, 1] elif form == "bra": # no conj here shape = [1, -1] return backend.reshape(psi, shape=shape)
wavefunction = state
[docs] def expectation( self, *ops: Tuple[tn.Node, List[int]], reuse: bool = True, enable_lightcone: bool = False, nmc: int = 1000, **kws: Any, ) -> Tensor: """ Compute expectation(s) of local operators. :param ops: Pairs of `(operator_node, [sites])` specifying where each operator acts. :type ops: Tuple[tn.Node, List[int]] :param reuse: If True, then the wavefunction tensor is cached for further expectation evaluation, defaults to be true. :type reuse: bool, optional :param enable_lightcone: whether enable light cone simplification, defaults to False :type enable_lightcone: bool, optional :param nmc: repetition time for Monte Carlo sampling for noisfy calculation, defaults to 1000 :type nmc: int, optional :return: Tensor with one element :rtype: Tensor """ return self.effective_circuit.expectation( *ops, reuse=reuse, enable_lightcone=enable_lightcone, noise_conf=None, nmc=nmc, **kws, )
[docs] def measure_jit( self, *index: int, with_prob: bool = False, status: Optional[Tensor] = None ) -> Tuple[Tensor, Tensor]: """ Take measurement on the given site indices (computational basis). This method is jittable! :param index: Measure on which site (wire) index. :type index: int :param with_prob: If true, theoretical probability is also returned. :type with_prob: bool, optional :param status: external randomness, with shape [index], defaults to None :type status: Optional[Tensor] :return: The sample output and probability (optional) of the quantum line. :rtype: Tuple[Tensor, Tensor] """ return self.effective_circuit.measure_jit( *index, with_prob=with_prob, status=status )
measure = measure_jit
[docs] def amplitude(self, l: Union[str, Tensor]) -> Tensor: r""" Return the amplitude for a given bitstring `l`. For state simulators, this computes :math:`\langle l \vert \psi \rangle`. :param l: Bitstring in base-`d` using `0-9A-Z`. :type l: Union[str, Tensor] :return: Complex amplitude. :rtype: Tensor """ return self.effective_circuit.amplitude(l)
[docs] def probability(self) -> Tensor: """ Get the length-`2^n` probability vector over the computational basis. :return: Probability vector of shape `[dim^n]`. :rtype: Tensor """ return self.effective_circuit.probability()
[docs] def expectation_ps( self, x: Optional[Sequence[int]] = None, y: Optional[Sequence[int]] = None, z: Optional[Sequence[int]] = None, ps: Optional[Sequence[int]] = None, reuse: bool = True, noise_conf: Optional[Any] = None, nmc: int = 1000, status: Optional[Tensor] = None, **kws: Any, ) -> Tensor: """ Shortcut for Pauli string expectation. x, y, z list are for X, Y, Z positions :Example: >>> c = tc.Circuit(2) >>> c.X(0) >>> c.H(1) >>> c.expectation_ps(x=[1], z=[0]) array(-0.99999994+0.j, dtype=complex64) :param x: sites to apply X gate, defaults to None :type x: Optional[Sequence[int]], optional :param y: sites to apply Y gate, defaults to None :type y: Optional[Sequence[int]], optional :param z: sites to apply Z gate, defaults to None :type z: Optional[Sequence[int]], optional :param ps: or one can apply a ps structures instead of ``x``, ``y``, ``z``, e.g. [0, 1, 3, 0, 2, 2] for X_1Z_2Y_4Y_5 defaults to None, ``ps`` can overwrite ``x``, ``y`` and ``z`` :type ps: Optional[Sequence[int]], optional :param reuse: whether to cache and reuse the wavefunction, defaults to True :type reuse: bool, optional :param noise_conf: Noise Configuration, defaults to None :type noise_conf: Optional[NoiseConf], optional :param nmc: repetition time for Monte Carlo sampling for noisfy calculation, defaults to 1000 :type nmc: int, optional :param status: external randomness given by tensor uniformly from [0, 1], defaults to None, used for noisfy circuit sampling :type status: Optional[Tensor], optional :return: Expectation value :rtype: Tensor """ return self.effective_circuit.expectation_ps( x=x, y=y, z=z, ps=ps, reuse=reuse, noise_conf=noise_conf, nmc=nmc, status=status, **kws, )
[docs] @partial(arg_alias, alias_dict={"format": ["format_"]}) def sample( self, batch: Optional[int] = None, allow_state: bool = False, readout_error: Optional[Sequence[Any]] = None, format: Optional[str] = None, random_generator: Optional[Any] = None, status: Optional[Tensor] = None, jittable: bool = True, ) -> Any: r""" Batched sampling from the circuit or final state. :param batch: Number of samples. If `None`, returns a single draw. :type batch: Optional[int] :param allow_state: If `True`, sample from the final state (when memory allows). Prefer `True` for speed. :type allow_state: bool :param readout_error: Optional readout error model. :type readout_error: Optional[Sequence[Any]] :param format: Output format. See :py:meth:`tensorcircuit.quantum.measurement_results`. :type format: Optional[str] :param random_generator: random generator, defaults to None :type random_generator: Optional[Any], optional :param status: external randomness given by tensor uniformly from [0, 1], if set, can overwrite random_generator, shape [batch] for `allow_state=True` and shape [batch, nqudits] for `allow_state=False` using perfect sampling implementation :type status: Optional[Tensor] :param jittable: when converting to count, whether keep the full size. if false, may be conflict external jit, if true, may fail for large scale system with actual limited count results :type jittable: bool, defaults true :return: List (if batch) of tuple (binary configuration tensor and corresponding probability) if the format is None, and consistent with format when given :rtype: Any """ return self.effective_circuit.sample( batch=batch, allow_state=allow_state, readout_error=readout_error, format=format, random_generator=random_generator, status=status, jittable=jittable, )
def __repr__(self) -> str: s = f"AnalogCircuit(n={self.num_qubits}):\n" s += "=" * 40 + "\n" num_stages = len(self.analog_blocks) + 1 for i in range(num_stages): # Print digital part s += f"--- Digital Block {i} ---\n" # Print analog part (if it exists) if i < len(self.analog_blocks): block = self.analog_blocks[i] s += f"--- Analog Block {i} (T={block.time}) ---\n" s += f" H(t) function: '{block.hamiltonian_func.__name__}'\n" s += "=" * 40 return s