Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions matrix/similar_matrices.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Determine whether two square matrices are similar.

Two square matrices :math:`A` and :math:`B` of the same size are similar if
there exists an invertible matrix :math:`P` such that :math:`P^{-1} A P = B`.
This implementation relies on SymPy to compute the Jordan canonical form of
both matrices. Two matrices are similar precisely when their Jordan forms are
equal up to permutation of the Jordan blocks.

Examples
--------
>>> are_similar_matrices([[3, 1], [0, 3]], [[3, 0], [0, 3]])
False
>>> from sympy import Matrix
>>> matrix_a = [[3, 1], [0, 3]]
>>> transform = Matrix([[1, 1], [0, 1]])
>>> matrix_b = (transform.inv() * Matrix(matrix_a) * transform).tolist()
>>> are_similar_matrices(matrix_a, matrix_b)
True
>>> are_similar_matrices(
... [[1, 2, 0], [0, 1, 0], [0, 0, 3]],
... [[1, 0, 0], [0, 1, 0], [0, 0, 3]],
... )
False
>>> are_similar_matrices([[1, 2], [0, 1]], [[1, 2, 0], [0, 1, 0]])
Traceback (most recent call last):
...
ValueError: both matrices must be square with the same dimensions
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from sympy import Matrix, nsimplify
from sympy.matrices.common import MatrixError

__all__ = ["are_similar_matrices"]


type MatrixLike = Sequence[Sequence[Any]] | Matrix


def _as_square_matrix(matrix: MatrixLike, *, simplify_entries: bool) -> Matrix:
"""Return a SymPy matrix after validating that ``matrix`` is square.

Parameters
----------
matrix:
Nested sequences (or a SymPy matrix) describing the matrix entries.
simplify_entries:
When ``True`` each entry is passed through :func:`sympy.nsimplify`
which helps treat values such as ``0.5`` and ``1/2`` as the same.

Raises
------
TypeError
If ``matrix`` cannot be converted into a SymPy matrix.
ValueError
If ``matrix`` is not square.
"""

try:
sympy_matrix = Matrix(matrix)
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
msg = "matrix input must be a rectangular sequence of numbers"
raise TypeError(msg) from exc

if sympy_matrix.rows != sympy_matrix.cols:
raise ValueError("both matrices must be square with the same dimensions")

if simplify_entries:
sympy_matrix = sympy_matrix.applyfunc(nsimplify)

return sympy_matrix


def _jordan_signature(matrix: Matrix) -> tuple[tuple[Any, tuple[int, ...]], ...]:
"""Return a hashable representation of the Jordan form of ``matrix``."""

_, blocks = matrix.jordan_cells()
summary: dict[Any, list[int]] = {}
for block in blocks:
block_matrix = Matrix(block)
eigenvalue = block_matrix[0, 0]
summary.setdefault(eigenvalue, []).append(block_matrix.rows)

return tuple(
(
eigenvalue,
tuple(sorted(block_sizes, reverse=True)),
)
for eigenvalue, block_sizes in sorted(
summary.items(), key=lambda item: repr(item[0])
)
)


def are_similar_matrices(
matrix_a: MatrixLike,
matrix_b: MatrixLike,
*,
simplify_entries: bool = True,
) -> bool:
"""Return ``True`` if ``matrix_a`` and ``matrix_b`` are similar matrices.

Parameters
----------
matrix_a, matrix_b:
Square matrices represented as nested sequences (or SymPy matrices).
simplify_entries:
If ``True`` (default) the function attempts to simplify each entry so
that values that are algebraically equal are treated as such. Set this
to ``False`` to skip simplification when working with symbolic inputs
that should remain untouched.

Raises
------
ValueError
If the matrices are not square or their dimensions do not match.
TypeError
If either matrix cannot be interpreted as a numeric matrix.
"""

sympy_a = _as_square_matrix(matrix_a, simplify_entries=simplify_entries)
sympy_b = _as_square_matrix(matrix_b, simplify_entries=simplify_entries)

if sympy_a.shape != sympy_b.shape:
raise ValueError("both matrices must be square with the same dimensions")

try:
signature_a = _jordan_signature(sympy_a)
signature_b = _jordan_signature(sympy_b)
except MatrixError as exc: # pragma: no cover - rare SymPy failure
msg = "unable to determine the Jordan canonical form"
raise ValueError(msg) from exc

return signature_a == signature_b


if __name__ == "__main__": # pragma: no cover - convenience execution
from doctest import testmod

testmod()