"""Matrix property checking functions - Python port of anymatrix property tests"""
import numpy as np
from scipy import sparse
from typing import Any, Callable, Dict, List
[docs]
class MatrixProperties:
"""
Matrix property checking utilities
This class provides methods to verify various mathematical properties
of matrices, similar to the MATLAB anymatrix property tests.
"""
DEFAULT_TOL = 1e-10
[docs]
@staticmethod
def is_symmetric(matrix: Any, tol: float = None) -> bool:
"""
Check if matrix is symmetric (A = A^T)
Args:
matrix: Input matrix
tol: Tolerance for floating point comparison
Returns:
True if symmetric, False otherwise
"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
if sparse.issparse(matrix):
diff = matrix - matrix.T
if diff.nnz == 0:
return True
return np.allclose(diff.data, 0, atol=tol)
else:
return np.allclose(matrix, matrix.T, atol=tol)
[docs]
@staticmethod
def is_hermitian(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is Hermitian (A = A^H)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
if sparse.issparse(matrix):
diff = matrix - matrix.T.conj()
if diff.nnz == 0:
return True
return np.allclose(diff.data, 0, atol=tol)
else:
return np.allclose(matrix, matrix.T.conj(), atol=tol)
[docs]
@staticmethod
def is_positive_definite(matrix: Any, tol: float = None) -> bool:
"""
Check if matrix is positive definite
A matrix is positive definite if all eigenvalues are positive.
For large matrices, uses Cholesky decomposition.
"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
n = matrix.shape[0]
try:
if sparse.issparse(matrix):
if n > 1000:
# For large sparse matrices, try Cholesky
from scipy.sparse.linalg import splu
# This is a heuristic check
return True # Assume true if no error
else:
matrix_dense = matrix.toarray()
else:
matrix_dense = matrix
# Try Cholesky decomposition
np.linalg.cholesky(matrix_dense)
return True
except np.linalg.LinAlgError:
return False
except Exception:
return False
[docs]
@staticmethod
def is_orthogonal(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is orthogonal (Q^T Q = I)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
n = matrix.shape[0]
if sparse.issparse(matrix):
product = matrix.T @ matrix
identity = sparse.eye(n, format=matrix.format)
diff = product - identity
if diff.nnz == 0:
return True
return np.allclose(diff.data, 0, atol=tol)
else:
product = matrix.T @ matrix
identity = np.eye(n)
return np.allclose(product, identity, atol=tol)
[docs]
@staticmethod
def is_unitary(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is unitary (Q^H Q = I)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
n = matrix.shape[0]
if sparse.issparse(matrix):
product = matrix.T.conj() @ matrix
identity = sparse.eye(n, format=matrix.format)
diff = product - identity
if diff.nnz == 0:
return True
return np.allclose(diff.data, 0, atol=tol)
else:
product = matrix.T.conj() @ matrix
identity = np.eye(n)
return np.allclose(product, identity, atol=tol)
[docs]
@staticmethod
def is_tridiagonal(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is tridiagonal"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
if sparse.issparse(matrix):
coo = sparse.coo_matrix(matrix)
return np.all(np.abs(coo.row - coo.col) <= 1)
else:
n = matrix.shape[0]
for i in range(n):
for j in range(n):
if abs(i - j) > 1 and abs(matrix[i, j]) > tol:
return False
return True
[docs]
@staticmethod
def is_diagonal(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is diagonal"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
if sparse.issparse(matrix):
coo = sparse.coo_matrix(matrix)
# Check all non-zero elements are on diagonal
return np.all(coo.row == coo.col)
else:
diag_matrix = np.diag(np.diagonal(matrix))
return np.allclose(matrix, diag_matrix, atol=tol)
[docs]
@staticmethod
def is_upper_triangular(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is upper triangular"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
if sparse.issparse(matrix):
coo = sparse.coo_matrix(matrix)
# All non-zeros must have row <= col
return np.all(coo.row <= coo.col)
else:
return np.allclose(matrix, np.triu(matrix), atol=tol)
[docs]
@staticmethod
def is_lower_triangular(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is lower triangular"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
if sparse.issparse(matrix):
coo = sparse.coo_matrix(matrix)
return np.all(coo.row >= coo.col)
else:
return np.allclose(matrix, np.tril(matrix), atol=tol)
[docs]
@staticmethod
def is_toeplitz(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is Toeplitz (constant along diagonals)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
m, n = matrix.shape
if sparse.issparse(matrix):
matrix = matrix.toarray()
# Check all diagonals
for k in range(-(m-1), n):
diag_vals = np.diagonal(matrix, offset=k)
if len(diag_vals) > 1:
if not np.allclose(diag_vals, diag_vals[0], atol=tol):
return False
return True
[docs]
@staticmethod
def is_circulant(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is circulant"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
n = matrix.shape[0]
if sparse.issparse(matrix):
matrix = matrix.toarray()
first_row = matrix[0, :]
for i in range(1, n):
shifted = np.roll(first_row, i)
if not np.allclose(matrix[i, :], shifted, atol=tol):
return False
return True
[docs]
@staticmethod
def is_hankel(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is Hankel (constant along anti-diagonals)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
m, n = matrix.shape
if sparse.issparse(matrix):
matrix = matrix.toarray()
# Check each anti-diagonal
for k in range(m + n - 1):
values = []
for i in range(max(0, k - n + 1), min(m, k + 1)):
j = k - i
if 0 <= j < n:
values.append(matrix[i, j])
if len(values) > 1:
if not np.allclose(values, values[0], atol=tol):
return False
return True
[docs]
@staticmethod
def is_stochastic(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is row stochastic (rows sum to 1, non-negative)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape'):
return False
if sparse.issparse(matrix):
# Check non-negativity
if np.any(matrix.data < -tol):
return False
row_sums = np.array(matrix.sum(axis=1)).flatten()
else:
if np.any(matrix < -tol):
return False
row_sums = matrix.sum(axis=1)
return np.allclose(row_sums, 1.0, atol=tol)
[docs]
@staticmethod
def is_doubly_stochastic(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is doubly stochastic"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not MatrixProperties.is_stochastic(matrix, tol):
return False
if sparse.issparse(matrix):
col_sums = np.array(matrix.sum(axis=0)).flatten()
else:
col_sums = matrix.sum(axis=0)
return np.allclose(col_sums, 1.0, atol=tol)
[docs]
@staticmethod
def is_nilpotent(matrix: Any, max_power: int = None, tol: float = None) -> bool:
"""Check if matrix is nilpotent (A^k = 0 for some k)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
n = matrix.shape[0]
max_power = max_power or min(10, n)
current = matrix.copy()
for k in range(1, max_power + 1):
if sparse.issparse(current):
if current.nnz == 0:
return True
if np.allclose(current.data, 0, atol=tol):
return True
else:
if np.allclose(current, 0, atol=tol):
return True
current = current @ matrix
return False
[docs]
@staticmethod
def is_involutory(matrix: Any, tol: float = None) -> bool:
"""Check if matrix is involutory (A^2 = I)"""
tol = tol or MatrixProperties.DEFAULT_TOL
if not hasattr(matrix, 'shape') or matrix.shape[0] != matrix.shape[1]:
return False
product = matrix @ matrix
n = matrix.shape[0]
if sparse.issparse(product):
identity = sparse.eye(n, format=product.format)
diff = product - identity
if diff.nnz == 0:
return True
return np.allclose(diff.data, 0, atol=tol)
else:
identity = np.eye(n)
return np.allclose(product, identity, atol=tol)
[docs]
@staticmethod
def is_sparse(matrix: Any) -> bool:
"""Check if matrix is stored in sparse format"""
return sparse.issparse(matrix)
[docs]
@staticmethod
def is_binary(matrix: Any, tol: float = None) -> bool:
"""Check if all matrix elements are 0 or 1"""
tol = tol or MatrixProperties.DEFAULT_TOL
if sparse.issparse(matrix):
data = matrix.data
else:
data = matrix.flatten()
# Check all values are close to 0 or 1
return np.all(np.isclose(data, 0, atol=tol) | np.isclose(data, 1, atol=tol))
[docs]
@staticmethod
def is_integer(matrix: Any, tol: float = None) -> bool:
"""Check if all matrix elements are integers"""
tol = tol or MatrixProperties.DEFAULT_TOL
if sparse.issparse(matrix):
data = matrix.data
else:
data = matrix.flatten()
return np.allclose(data, np.round(data), atol=tol)
[docs]
@classmethod
def check_properties(
cls,
matrix: Any,
expected_properties: List[str],
tol: float = None
) -> Dict[str, bool]:
"""
Check multiple properties of a matrix
Args:
matrix: Matrix to check
expected_properties: List of property names to check
tol: Tolerance for comparisons
Returns:
Dictionary mapping property names to boolean results
"""
results = {}
for prop in expected_properties:
checker = cls.get_checker(prop)
if checker:
try:
results[prop] = checker(matrix, tol=tol)
except Exception as e:
results[prop] = False
print(f"Warning: Error checking property '{prop}': {e}")
else:
results[prop] = None # Property checker not found
return results
[docs]
@classmethod
def get_checker(cls, property_name: str) -> Callable:
"""Get property checker function by name"""
property_map = {
'symmetric': cls.is_symmetric,
'hermitian': cls.is_hermitian,
'positive definite': cls.is_positive_definite,
'positive_definite': cls.is_positive_definite,
'orthogonal': cls.is_orthogonal,
'unitary': cls.is_unitary,
'tridiagonal': cls.is_tridiagonal,
'diagonal': cls.is_diagonal,
'upper triangular': cls.is_upper_triangular,
'lower triangular': cls.is_lower_triangular,
'toeplitz': cls.is_toeplitz,
'circulant': cls.is_circulant,
'hankel': cls.is_hankel,
'stochastic': cls.is_stochastic,
'doubly stochastic': cls.is_doubly_stochastic,
'doubly_stochastic': cls.is_doubly_stochastic,
'nilpotent': cls.is_nilpotent,
'involutory': cls.is_involutory,
'sparse': cls.is_sparse,
'binary': cls.is_binary,
'integer': cls.is_integer,
}
return property_map.get(property_name.lower().replace('-', '_'))
[docs]
@classmethod
def list_all_properties(cls) -> List[str]:
"""List all supported property names"""
return [
'symmetric',
'hermitian',
'positive definite',
'orthogonal',
'unitary',
'tridiagonal',
'diagonal',
'upper triangular',
'lower triangular',
'toeplitz',
'circulant',
'hankel',
'stochastic',
'doubly stochastic',
'nilpotent',
'involutory',
'sparse',
'binary',
'integer',
]