Files
Python/matrix/similar_matrices.py
65b1482203 Add similarity test for square matrices (#13022)
* Add similarity test for square matrices

* Fix naming convention: MatrixLike to matrix_like

* Use TypeAlias for proper mypy type checking

* Fix CI errors: use type keyword and fix line length

* Fix docstring formatting in similar_matrices.py

Corrected formatting and punctuation in docstring.

* updating DIRECTORY.md

---------

Co-authored-by: Christian Clauss <cclauss@me.com>
Co-authored-by: cclauss <cclauss@users.noreply.github.com>
2026-09-24 15:02:05 +02:00

147 lines
4.6 KiB
Python

"""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.
* https://en.wikipedia.org/wiki/Jordan_matrix
* https://en.wikipedia.org/wiki/Jordan_normal_form
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()