Source code for quantum_mind.core.linalg

r"""Linear-algebra building blocks of quantum-like models.

States are unit vectors (real or complex) or density matrices. A "yes" answer to a question is an
orthogonal projector :math:`P`, and answering applies the Lüders rule

.. math:: p(\text{yes}) = \lVert P\psi \rVert^2, \qquad \psi \mapsto P\psi / \lVert P\psi \rVert .

Nothing here assumes a particular domain: the model families, the robotics layer and the circuit
builders are all written on top of these functions.
"""
from __future__ import annotations

import numpy as np
from scipy.linalg import expm

__all__ = ['normalize', 'projector', 'complement', 'luders', 'sequence_probabilities', 'angles_to_unit',
           'givens_frame', 'density', 'dephase', 'lindblad_superoperator', 'evolve_density', 'unitary',
           'is_projector']


[docs] def normalize(v): """Scale a vector to unit length. Parameters ---------- v : array_like Real or complex vector. Returns ------- numpy.ndarray ``v / ||v||``, real if ``v`` is real and complex otherwise. Raises ------ ValueError If ``v`` is the zero vector. """ v = np.asarray(v, dtype=complex if np.iscomplexobj(v) else float) n = np.linalg.norm(v) if n == 0: raise ValueError('cannot normalise the zero vector') return v / n
[docs] def projector(basis): """Orthogonal projector onto the span of a set of vectors. The vectors are orthonormalised first (QR decomposition), so any spanning set may be passed. Parameters ---------- basis : array_like A single vector, or a 2D array whose rows span the subspace. Returns ------- numpy.ndarray Hermitian, idempotent matrix :math:`QQ^\\dagger` of shape ``(d, d)``. Examples -------- >>> from quantum_mind.core import projector >>> projector([1, 1]).round(2) array([[0.5, 0.5], [0.5, 0.5]]) """ B = np.atleast_2d(np.asarray(basis)) Q, _ = np.linalg.qr(B.T) rank = np.linalg.matrix_rank(B) Q = Q[:, :rank] # keep only the columns that span the subspace return Q @ Q.conj().T
[docs] def complement(P): """Projector onto the orthogonal complement, :math:`I - P`. Parameters ---------- P : numpy.ndarray Orthogonal projector of shape ``(d, d)``. Returns ------- numpy.ndarray The "no" projector belonging to the "yes" projector ``P``. """ return np.eye(P.shape[0]) - P
[docs] def is_projector(P, tol=1e-9): """Test whether a matrix is an orthogonal projector. Parameters ---------- P : numpy.ndarray Square matrix. tol : float, optional Absolute tolerance for :math:`P^2 = P` and :math:`P = P^\\dagger`. Returns ------- bool True if ``P`` is Hermitian and idempotent within ``tol``. """ return np.allclose(P @ P, P, atol=tol) and np.allclose(P, P.conj().T, atol=tol)
[docs] def luders(psi, P): """Apply the Lüders rule for one projective measurement outcome. Parameters ---------- psi : numpy.ndarray Normalised state vector. P : numpy.ndarray Projector of the outcome. Returns ------- probability : float :math:`\\lVert P\\psi \\rVert^2`. state : numpy.ndarray Post-measurement state :math:`P\\psi / \\lVert P\\psi \\rVert`; the unnormalised vector is returned when the probability is numerically zero. """ w = P @ psi p = float(np.real(np.vdot(w, w))) return p, (w / np.sqrt(p) if p > 1e-15 else w)
[docs] def sequence_probabilities(psi, projectors, order): """Probabilities of every yes/no answer sequence to a sequence of questions. Each question is answered in turn with the Lüders rule, so the result depends on the order whenever the projectors do not commute (the source of question-order effects). Parameters ---------- psi : numpy.ndarray Initial normalised state. projectors : dict Maps a question name to the projector of its "yes" answer. order : sequence of str Question names in the order they are asked. Returns ------- dict ``{(a1, a2, ...): probability}`` with ``a = 1`` for yes and ``0`` for no. The values sum to 1. Examples -------- >>> import numpy as np >>> from quantum_mind.core import projector, sequence_probabilities >>> P = {'A': projector([1, 0]), 'B': projector([1, 1])} >>> p = sequence_probabilities(np.array([1.0, 0.0]), P, ['A', 'B']) >>> round(p[(1, 1)], 3) 0.5 """ # Each branch keeps (probability so far, post-measurement state). out = {(): (1.0, np.asarray(psi))} for q in order: P = projectors[q] Q = complement(P) new = {} for seq, (p, v) in out.items(): for ans, M in ((1, P), (0, Q)): pr, w = luders(v, M) new[seq + (ans,)] = (p * pr, w) out = new return {k: p for k, (p, _) in out.items()}
[docs] def angles_to_unit(angles): """Unit vector from hyperspherical angles. Parameters ---------- angles : array_like ``d - 1`` angles :math:`\\theta_1, \\dots, \\theta_{d-1}`. Returns ------- numpy.ndarray Real unit vector in :math:`\\mathbb{R}^d` with :math:`v_i = \\cos\\theta_i \\prod_{j<i} \\sin\\theta_j` (the last entry has no cosine). """ angles = np.asarray(angles, float) d = len(angles) + 1 v = np.ones(d) for i, a in enumerate(angles): v[i] *= np.cos(a) v[i + 1:] *= np.sin(a) return v
[docs] def givens_frame(angles, d): """Orthonormal frame built from Givens rotations. Parameters ---------- angles : array_like ``d (d - 1) / 2`` rotation angles, one per coordinate plane ``(i, j)`` with ``i < j``. d : int Dimension. Returns ------- numpy.ndarray Orthogonal ``(d, d)`` matrix whose columns form an orthonormal basis. Every rotation of :math:`\\mathbb{R}^d` with determinant 1 can be written this way. """ R = np.eye(d) k = 0 for i in range(d - 1): for j in range(i + 1, d): # rotation by angles[k] in the (i, j) plane G = np.eye(d) c, s = np.cos(angles[k]), np.sin(angles[k]) G[i, i] = G[j, j] = c G[i, j] = -s G[j, i] = s R = R @ G k += 1 return R
[docs] def density(psi): """Density matrix of a pure state. Parameters ---------- psi : array_like Normalised state vector. Returns ------- numpy.ndarray :math:`\\rho = |\\psi\\rangle\\langle\\psi|`. """ psi = np.asarray(psi) return np.outer(psi, psi.conj())
[docs] def dephase(rho, strength, basis_projectors=None): """Partial dephasing channel. .. math:: \\rho \\mapsto (1 - s)\\,\\rho + s \\sum_k P_k \\rho P_k Parameters ---------- rho : numpy.ndarray Density matrix. strength : float Dephasing strength ``s`` in [0, 1]; ``s = 1`` removes all coherences between the subspaces. basis_projectors : sequence of numpy.ndarray, optional Projectors :math:`P_k` that sum to the identity. Defaults to the computational basis. Returns ------- numpy.ndarray The dephased density matrix. """ if basis_projectors is None: D = np.diag(np.diag(rho)) else: D = sum(P @ rho @ P for P in basis_projectors) return (1 - strength) * rho + strength * D
[docs] def unitary(H, t): """Time-evolution operator :math:`e^{-iHt}`. Parameters ---------- H : array_like Hermitian Hamiltonian. t : float Time. Returns ------- numpy.ndarray Unitary matrix. """ return expm(-1j * np.asarray(H) * t)
[docs] def lindblad_superoperator(H, jumps, rates): """Lindblad generator as a matrix acting on the row-stacked density matrix. .. math:: \\dot\\rho = -i[H, \\rho] + \\sum_k \\gamma_k \\left(L_k \\rho L_k^\\dagger - \\tfrac12 \\{L_k^\\dagger L_k, \\rho\\}\\right) Parameters ---------- H : array_like Hamiltonian, shape ``(n, n)``. jumps : sequence of array_like Jump operators :math:`L_k`. rates : sequence of float Rates :math:`\\gamma_k \\ge 0`, one per jump operator. Returns ------- numpy.ndarray Superoperator of shape ``(n**2, n**2)``; use it with :func:`evolve_density`. """ H = np.asarray(H, complex) n = H.shape[0] I = np.eye(n) # vec(A rho B) = (A kron B^T) vec(rho) for the row-stacked vec L = -1j * (np.kron(H, I) - np.kron(I, H.T)) for Lk, g in zip(jumps, rates): Lk = np.asarray(Lk, complex) LdL = Lk.conj().T @ Lk L += g * (np.kron(Lk, Lk.conj()) - 0.5 * np.kron(LdL, I) - 0.5 * np.kron(I, LdL.T)) return L
[docs] def evolve_density(rho, superop, t): """Evolve a density matrix under a Lindblad generator. Parameters ---------- rho : numpy.ndarray Initial density matrix, shape ``(n, n)``. superop : numpy.ndarray Generator from :func:`lindblad_superoperator`. t : float Evolution time. Returns ------- numpy.ndarray :math:`\\rho(t)`, computed with a matrix exponential. """ n = rho.shape[0] v = expm(superop * t) @ rho.reshape(-1) return v.reshape(n, n)