def _perp_eigendecompose( matrix: np.ndarray, tolerance: Tolerance) -> Tuple[np.array, List[np.ndarray]]: """An eigendecomposition that ensures eigenvectors are perpendicular. numpy.linalg.eig doesn't guarantee that eigenvectors from the same eigenspace will be perpendicular. This method uses Gram-Schmidt to recover a perpendicular set. It further checks that all eigenvectors are perpendicular and raises an ArithmeticError otherwise. Args: matrix: The matrix to decompose. tolerance: Thresholds for determining whether eigenvalues are from the same eigenspace and whether eigenvectors are perpendicular. Returns: The eigenvalues and column eigenvectors. The i'th eigenvalue is associated with the i'th column eigenvector. Raises: ArithmeticError: Failed to find perpendicular eigenvectors. """ vals, cols = np.linalg.eig(np.mat(matrix)) vecs = [cols[:, i] for i in range(len(cols))] # Group by similar eigenvalue. n = len(vecs) groups = _group_similar( list(range(n)), lambda k1, k2: tolerance.all_close(vals[k1], vals[k2])) # Remove overlap between eigenvectors with the same eigenvalue. for g in groups: q, _ = np.linalg.qr(np.concatenate([vecs[i] for i in g], axis=1)) for i in range(len(g)): vecs[g[i]] = q[:, i] # Ensure no eigenvectors overlap. for i in range(len(vecs)): for j in range(i + 1, len(vecs)): if not tolerance.all_near_zero(np.dot(np.conj(vecs[i].T), vecs[j])): raise ArithmeticError('Eigenvectors overlap.') return vals, vecs
def test_all_zero(): tol = Tolerance(atol=5, rtol=9999999) assert tol.all_near_zero(0) assert tol.all_near_zero(4.5) assert not tol.all_near_zero(5.5) assert tol.all_near_zero([-4.5, 0, 1, 4.5, 3]) assert not tol.all_near_zero([-4.5, 0, 1, 4.5, 30])
def test_is_diagonal_tolerance(): tol = Tolerance(atol=0.5) # Pays attention to specified tolerance. assert predicates.is_diagonal(np.array([[1, 0], [-0.5, 1]]), tol) assert not predicates.is_diagonal(np.array([[1, 0], [-0.6, 1]]), tol) # Error isn't accumulated across entries. assert predicates.is_diagonal(np.array([[1, 0.5], [-0.5, 1]]), tol) assert not predicates.is_diagonal(np.array([[1, 0.5], [-0.6, 1]]), tol)
def _perp_eigendecompose(matrix: np.ndarray, tolerance: Tolerance ) -> Tuple[np.array, List[np.ndarray]]: """An eigendecomposition that ensures eigenvectors are perpendicular. numpy.linalg.eig doesn't guarantee that eigenvectors from the same eigenspace will be perpendicular. This method uses Gram-Schmidt to recover a perpendicular set. It further checks that all eigenvectors are perpendicular and raises an ArithmeticError otherwise. Args: matrix: The matrix to decompose. tolerance: Thresholds for determining whether eigenvalues are from the same eigenspace and whether eigenvectors are perpendicular. Returns: The eigenvalues and column eigenvectors. The i'th eigenvalue is associated with the i'th column eigenvector. Raises: ArithmeticError: Failed to find perpendicular eigenvectors. """ vals, cols = np.linalg.eig(np.mat(matrix)) vecs = [cols[:, i] for i in range(len(cols))] # Group by similar eigenvalue. n = len(vecs) groups = _group_similar( list(range(n)), lambda k1, k2: tolerance.all_close(vals[k1], vals[k2])) # Remove overlap between eigenvectors with the same eigenvalue. for g in groups: q, _ = np.linalg.qr(np.concatenate([vecs[i] for i in g], axis=1)) for i in range(len(g)): vecs[g[i]] = q[:, i] # Ensure no eigenvectors overlap. for i in range(len(vecs)): for j in range(i + 1, len(vecs)): if not tolerance.all_near_zero(np.dot(np.conj(vecs[i].T), vecs[j])): raise ArithmeticError('Eigenvectors overlap.') return vals, vecs
def test_is_unitary_tolerance(): tol = Tolerance(atol=0.5) # Pays attention to specified tolerance. assert predicates.is_unitary(np.array([[1, 0], [-0.5, 1]]), tol) assert not predicates.is_unitary(np.array([[1, 0], [-0.6, 1]]), tol) # Error isn't accumulated across entries. assert predicates.is_unitary( np.array([[1.2, 0, 0], [0, 1.2, 0], [0, 0, 1.2]]), tol) assert not predicates.is_unitary( np.array([[1.2, 0, 0], [0, 1.3, 0], [0, 0, 1.2]]), tol)
def kron_factor_4x4_to_2x2s( matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT ) -> Tuple[complex, np.ndarray, np.ndarray]: """Splits a 4x4 matrix U = kron(A, B) into A, B, and a global factor. Requires the matrix to be the kronecker product of two 2x2 unitaries. Requires the matrix to have a non-zero determinant. Args: matrix: The 4x4 unitary matrix to factor. tolerance: Acceptable numeric error thresholds. Returns: A scalar factor and a pair of 2x2 unit-determinant matrices. The kronecker product of all three is equal to the given matrix. Raises: ValueError: The given matrix can't be tensor-factored into 2x2 pieces. """ # Use the entry with the largest magnitude as a reference point. a, b = max( ((i, j) for i in range(4) for j in range(4)), key=lambda t: abs(matrix[t])) # Extract sub-factors touching the reference cell. f1 = np.zeros((2, 2), dtype=np.complex128) f2 = np.zeros((2, 2), dtype=np.complex128) for i in range(2): for j in range(2): f1[(a >> 1) ^ i, (b >> 1) ^ j] = matrix[a ^ (i << 1), b ^ (j << 1)] f2[(a & 1) ^ i, (b & 1) ^ j] = matrix[a ^ i, b ^ j] # Rescale factors to have unit determinants. f1 /= (np.sqrt(np.linalg.det(f1)) or 1) f2 /= (np.sqrt(np.linalg.det(f2)) or 1) # Determine global phase. g = matrix[a, b] / (f1[a >> 1, b >> 1] * f2[a & 1, b & 1]) if np.real(g) < 0: f1 *= -1 g = -g restored = g * combinators.kron(f1, f2) if np.any(np.isnan(restored)) or not tolerance.all_close(restored, matrix): raise ValueError("Can't factor into kronecker product.") return g, f1, f2
def is_hermitian(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is approximately Hermitian. A matrix is Hermitian if it's square and equal to its adjoint. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is Hermitian within the given tolerance. """ return (matrix.shape[0] == matrix.shape[1] and tolerance.all_close(matrix, np.conj(matrix.T)))
def is_unitary(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is approximately unitary. A matrix is unitary if it's square and its adjoint is its inverse. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is unitary within the given tolerance. """ return (matrix.shape[0] == matrix.shape[1] and tolerance.all_close( matrix.dot(np.conj(matrix.T)), np.eye(matrix.shape[0])))
def test_is_special_orthogonal_tolerance(): tol = Tolerance(atol=0.5) # Pays attention to specified tolerance. assert predicates.is_special_orthogonal( np.array([[1, 0], [-0.5, 1]]), tol) assert not predicates.is_special_orthogonal( np.array([[1, 0], [-0.6, 1]]), tol) # Error isn't accumulated across entries, except for determinant factors. assert predicates.is_special_orthogonal( np.array([[1.2, 0, 0], [0, 1.2, 0], [0, 0, 1 / 1.2]]), tol) assert not predicates.is_special_orthogonal( np.array([[1.2, 0, 0], [0, 1.2, 0], [0, 0, 1.2]]), tol) assert not predicates.is_special_orthogonal( np.array([[1.2, 0, 0], [0, 1.3, 0], [0, 0, 1 / 1.2]]), tol)
def test_is_hermitian_tolerance(): tol = Tolerance(atol=0.5) # Pays attention to specified tolerance. assert predicates.is_hermitian(np.array([[1, 0], [-0.5, 1]]), tol) assert predicates.is_hermitian(np.array([[1, 0.25], [-0.25, 1]]), tol) assert not predicates.is_hermitian(np.array([[1, 0], [-0.6, 1]]), tol) assert not predicates.is_hermitian(np.array([[1, 0.25], [-0.35, 1]]), tol) # Error isn't accumulated across entries. assert predicates.is_hermitian( np.array([[1, 0.5, 0.5], [0, 1, 0], [0, 0, 1]]), tol) assert not predicates.is_hermitian( np.array([[1, 0.5, 0.6], [0, 1, 0], [0, 0, 1]]), tol) assert not predicates.is_hermitian( np.array([[1, 0, 0.6], [0, 1, 0], [0, 0, 1]]), tol)
def is_orthogonal(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is approximately orthogonal. A matrix is orthogonal if it's square and real and its transpose is its inverse. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is orthogonal within the given tolerance. """ return (matrix.shape[0] == matrix.shape[1] and np.all(np.imag(matrix) == 0) and tolerance.all_close( matrix.dot(matrix.T), np.eye(matrix.shape[0])))
def is_diagonal(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is a approximately diagonal. A matrix is diagonal if i!=j implies m[i,j]==0. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is diagonal within the given tolerance. """ matrix = np.copy(matrix) for i in range(min(matrix.shape)): matrix[i, i] = 0 return tolerance.all_near_zero(matrix)
def is_special_orthogonal(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is approximately special orthogonal. A matrix is special orthogonal if it is square and real and its transpose is its inverse and its determinant is one. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is special orthogonal within the given tolerance. """ return (is_orthogonal(matrix, tolerance) and (matrix.shape[0] == 0 or tolerance.all_close(np.linalg.det(matrix), 1)))
def so4_to_magic_su2s( mat: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT ) -> Tuple[np.ndarray, np.ndarray]: """Finds 2x2 special-unitaries A, B where mat = Mag.H @ kron(A, B) @ Mag. Mag is the magic basis matrix: 1 0 0 i 0 i 1 0 0 i -1 0 (times sqrt(0.5) to normalize) 1 0 0 -i Args: mat: A real 4x4 orthogonal matrix. tolerance: Per-matrix-entry tolerance on equality. Returns: A pair (A, B) of matrices in SU(2) such that Mag.H @ kron(A, B) @ Mag is approximately equal to the given matrix. Raises: ValueError: Bad matrix. ArithmeticError: Failed to perform the decomposition to desired tolerance. """ if mat.shape != (4, 4) or not predicates.is_special_orthogonal(mat, tolerance): raise ValueError('mat must be 4x4 special orthogonal.') magic = np.array([[1, 0, 0, 1j], [0, 1j, 1, 0], [0, 1j, -1, 0], [1, 0, 0, -1j]]) * np.sqrt(0.5) ab = combinators.dot(magic, mat, np.conj(magic.T)) _, a, b = kron_factor_4x4_to_2x2s(ab, tolerance) # Check decomposition against desired tolerance. reconstructed = combinators.dot(np.conj(magic.T), combinators.kron(a, b), magic) if not tolerance.all_close(reconstructed, mat): raise ArithmeticError('Failed to decompose to desired tolerance.') return a, b
def is_special_unitary(matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if a matrix is approximately unitary with unit determinant. A matrix is special-unitary if it is square and its adjoint is its inverse and its determinant is one. Args: matrix: The matrix to check. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the matrix is unitary with unit determinant within the given tolerance. """ return (is_unitary(matrix, tolerance) and (matrix.shape[0] == 0 or tolerance.all_close(np.linalg.det(matrix), 1)))
def commutes(m1: np.ndarray, m2: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT) -> bool: """Determines if two matrices approximately commute. Two matrices A and B commute if they are square and have the same size and AB = BA. Args: m1: One of the matrices. m2: The other matrix. tolerance: The per-matrix-entry tolerance on equality. Returns: Whether the two matrices have compatible sizes and a commutator equal to zero within tolerance. """ return (m1.shape[0] == m1.shape[1] and m1.shape == m2.shape and tolerance.all_close(m1.dot(m2), m2.dot(m1)))
def bidiagonalize_real_matrix_pair_with_symmetric_products( mat1: np.ndarray, mat2: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT ) -> Tuple[np.ndarray, np.ndarray]: """Finds orthogonal matrices that diagonalize both mat1 and mat2. Requires mat1 and mat2 to be real. Requires mat1.T @ mat2 to be symmetric. Requires mat1 @ mat2.T to be symmetric. Args: mat1: One of the real matrices. mat2: The other real matrix. tolerance: Numeric error thresholds. Returns: A tuple (L, R) of two orthogonal matrices, such that both L @ mat1 @ R and L @ mat2 @ R are diagonal matrices. Raises: ValueError: Matrices don't meet preconditions (e.g. not real). ArithmeticError: Failed to meet specified tolerance. """ if np.any(np.imag(mat1) != 0): raise ValueError('mat1 must be real.') if np.any(np.imag(mat2) != 0): raise ValueError('mat2 must be real.') if not predicates.is_hermitian(mat1.dot(mat2.T), tolerance): raise ValueError('mat1 @ mat2.T must be symmetric.') if not predicates.is_hermitian(mat1.T.dot(mat2), tolerance): raise ValueError('mat1.T @ mat2 must be symmetric.') # Use SVD to bi-diagonalize the first matrix. base_left, base_diag, base_right = _svd_handling_empty(np.real(mat1)) base_diag = np.diag(base_diag) # Determine where we switch between diagonalization-fixup strategies. dim = base_diag.shape[0] rank = dim while rank > 0 and tolerance.all_near_zero(base_diag[rank - 1, rank - 1]): rank -= 1 base_diag = base_diag[:rank, :rank] # Try diagonalizing the second matrix with the same factors as the first. semi_corrected = base_left.T.dot(np.real(mat2)).dot(base_right.T) # Fix up the part of the second matrix's diagonalization that's matched # against non-zero diagonal entries in the first matrix's diagonalization # by performing simultaneous diagonalization. overlap = semi_corrected[:rank, :rank] overlap_adjust = diagonalize_real_symmetric_and_sorted_diagonal_matrices( overlap, base_diag, tolerance) # Fix up the part of the second matrix's diagonalization that's matched # against zeros in the first matrix's diagonalization by performing an SVD. extra = semi_corrected[rank:, rank:] extra_left_adjust, _, extra_right_adjust = _svd_handling_empty(extra) # Merge the fixup factors into the initial diagonalization. left_adjust = combinators.block_diag(overlap_adjust, extra_left_adjust) right_adjust = combinators.block_diag(overlap_adjust.T, extra_right_adjust) left = left_adjust.T.dot(base_left.T) right = base_right.T.dot(right_adjust.T) # Check acceptability vs tolerances. if any(not predicates.is_diagonal(left.dot(mat).dot(right), tolerance) for mat in [mat1, mat2]): raise ArithmeticError('Failed to diagonalize to specified tolerance.') return left, right
def test_all_close(): no_tol = Tolerance() assert no_tol.all_close(1, 1) assert not no_tol.all_close(1, 0.5) assert not no_tol.all_close(1, 1.5) assert not no_tol.all_close(1, 100.5) assert no_tol.all_close(100, 100) assert not no_tol.all_close(100, 99.5) assert not no_tol.all_close(100, 100.5) assert no_tol.all_close([1, 2], [1, 2]) assert not no_tol.all_close([1, 2], [1, 3]) atol5 = Tolerance(atol=5) assert atol5.all_close(1, 1) assert atol5.all_close(1, 0.5) assert atol5.all_close(1, 1.5) assert atol5.all_close(1, 5.5) assert atol5.all_close(1, -3.5) assert not atol5.all_close(1, 6.5) assert not atol5.all_close(1, -4.5) assert not atol5.all_close(1, 100.5) assert atol5.all_close(100, 100) assert atol5.all_close(100, 100.5) assert atol5.all_close(100, 104.5) assert not atol5.all_close(100, 105.5) rtol2 = Tolerance(rtol=2) assert rtol2.all_close(100, 100) assert rtol2.all_close(299.5, 100) assert rtol2.all_close(1, 100) assert rtol2.all_close(-99, 100) assert not rtol2.all_close(100, 1) # Doesn't commute. assert not rtol2.all_close(300.5, 100) assert not rtol2.all_close(-101, 100) tol25 = Tolerance(rtol=2, atol=5) assert tol25.all_close(100, 100) assert tol25.all_close(106, 100) assert tol25.all_close(201, 100) assert tol25.all_close(304.5, 100) assert not tol25.all_close(305.5, 100)
def test_repr(): assert (repr(Tolerance(rtol=2, atol=3, equal_nan=True)) == 'Tolerance(rtol=2, atol=3, equal_nan=True)') assert (str(Tolerance(rtol=5, atol=6, equal_nan=False)) == 'Tolerance(rtol=5, atol=6, equal_nan=False)')
def test_all_zero_mod(): tol = Tolerance(atol=5, rtol=9999999) assert tol.all_near_zero_mod(0, 100) assert tol.all_near_zero_mod(4.5, 100) assert not tol.all_near_zero_mod(5.5, 100) assert tol.all_near_zero_mod(100, 100) assert tol.all_near_zero_mod(95.5, 100) assert not tol.all_near_zero_mod(94.5, 100) assert tol.all_near_zero_mod(-4.5, 100) assert not tol.all_near_zero_mod(-5.5, 100) assert tol.all_near_zero_mod(104.5, 100) assert not tol.all_near_zero_mod(105.5, 100) assert tol.all_near_zero_mod([-4.5, 0, 1, 4.5, 3, 95.5, 104.5], 100) assert not tol.all_near_zero_mod([-4.5, 0, 1, 4.5, 30], 100)
def test_near_zero_mod(): tol = Tolerance(atol=5, rtol=9999999) assert tol.near_zero_mod(0, 100) assert tol.near_zero_mod(4.5, 100) assert not tol.near_zero_mod(5.5, 100) assert tol.near_zero_mod(100, 100) assert tol.near_zero_mod(95.5, 100) assert not tol.near_zero_mod(94.5, 100) assert tol.near_zero_mod(-4.5, 100) assert not tol.near_zero_mod(-5.5, 100) assert tol.near_zero_mod(104.5, 100) assert not tol.near_zero_mod(105.5, 100)
def test_near_zero(): tol = Tolerance(atol=5, rtol=9999999) assert tol.near_zero(0) assert tol.near_zero(4.5) assert not tol.near_zero(5.5)
def diagonalize_real_symmetric_and_sorted_diagonal_matrices( symmetric_matrix: np.ndarray, diagonal_matrix: np.ndarray, tolerance: Tolerance = Tolerance.DEFAULT ) -> np.ndarray: """Returns an orthogonal matrix that diagonalizes both given matrices. The given matrices must commute. Guarantees that the sorted diagonal matrix is not permuted by the diagonalization (except for nearly-equal values). Args: symmetric_matrix: A real symmetric matrix. diagonal_matrix: A real diagonal matrix with entries along the diagonal sorted into descending order. tolerance: Numeric error thresholds. Returns: An orthogonal matrix P such that P.T @ symmetric_matrix @ P is diagonal and P.T @ diagonal_matrix @ P = diagonal_matrix (up to tolerance). Raises: ValueError: Matrices don't meet preconditions (e.g. not symmetric). ArithmeticError: Failed to meet specified tolerance. """ # Verify preconditions. if (np.any(np.imag(symmetric_matrix) != 0) or not predicates.is_hermitian(symmetric_matrix)): raise ValueError('symmetric_matrix must be real symmetric.') if (not predicates.is_diagonal(diagonal_matrix) or np.any(np.imag(diagonal_matrix) != 0) or np.any(diagonal_matrix[:-1, :-1] < diagonal_matrix[1:, 1:])): raise ValueError( 'diagonal_matrix must be real diagonal descending.') if not predicates.commutes(diagonal_matrix, symmetric_matrix): raise ValueError('Given matrices must commute.') def similar_singular(i, j): return tolerance.all_close(diagonal_matrix[i, i], diagonal_matrix[j, j]) # Because the symmetric matrix commutes with the diagonal singulars matrix, # the symmetric matrix should be block-diagonal with a block boundary # wherever the singular values happen change. So we can use the singular # values to extract blocks that can be independently diagonalized. ranges = _contiguous_groups(diagonal_matrix.shape[0], similar_singular) # Build the overall diagonalization by diagonalizing each block. p = np.zeros(symmetric_matrix.shape, dtype=np.float64) for start, end in ranges: block = symmetric_matrix[start:end, start:end] p[start:end, start:end] = diagonalize_real_symmetric_matrix(block) # Check acceptability vs tolerances. if (not predicates.is_diagonal(p.T.dot(symmetric_matrix).dot(p), tolerance) or not tolerance.all_close(diagonal_matrix, p.T.dot(diagonal_matrix).dot(p))): raise ArithmeticError('Failed to diagonalize to specified tolerance.') return p