Skip to content

Commit bd5f6ba

Browse files
committed
Add similarity test for square matrices
1 parent a71618f commit bd5f6ba

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

matrix/similar_matrices.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""Determine whether two square matrices are similar.
2+
3+
Two square matrices :math:`A` and :math:`B` of the same size are similar if
4+
there exists an invertible matrix :math:`P` such that :math:`P^{-1} A P = B`.
5+
This implementation relies on SymPy to compute the Jordan canonical form of
6+
both matrices. Two matrices are similar precisely when their Jordan forms are
7+
equal up to permutation of the Jordan blocks.
8+
9+
Examples
10+
--------
11+
>>> are_similar_matrices([[3, 1], [0, 3]], [[3, 0], [0, 3]])
12+
False
13+
>>> from sympy import Matrix
14+
>>> matrix_a = [[3, 1], [0, 3]]
15+
>>> transform = Matrix([[1, 1], [0, 1]])
16+
>>> matrix_b = (transform.inv() * Matrix(matrix_a) * transform).tolist()
17+
>>> are_similar_matrices(matrix_a, matrix_b)
18+
True
19+
>>> are_similar_matrices(
20+
... [[1, 2, 0], [0, 1, 0], [0, 0, 3]],
21+
... [[1, 0, 0], [0, 1, 0], [0, 0, 3]],
22+
... )
23+
False
24+
>>> are_similar_matrices([[1, 2], [0, 1]], [[1, 2, 0], [0, 1, 0]])
25+
Traceback (most recent call last):
26+
...
27+
ValueError: both matrices must be square with the same dimensions
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from collections.abc import Sequence
33+
from typing import Any
34+
35+
from sympy import Matrix, nsimplify
36+
from sympy.matrices.common import MatrixError
37+
38+
__all__ = ["are_similar_matrices"]
39+
40+
41+
MatrixLike = Sequence[Sequence[Any]] | Matrix
42+
43+
44+
def _as_square_matrix(matrix: MatrixLike, *, simplify_entries: bool) -> Matrix:
45+
"""Return a SymPy matrix after validating that ``matrix`` is square.
46+
47+
Parameters
48+
----------
49+
matrix:
50+
Nested sequences (or a SymPy matrix) describing the matrix entries.
51+
simplify_entries:
52+
When ``True`` each entry is passed through :func:`sympy.nsimplify`
53+
which helps treat values such as ``0.5`` and ``1/2`` as the same.
54+
55+
Raises
56+
------
57+
TypeError
58+
If ``matrix`` cannot be converted into a SymPy matrix.
59+
ValueError
60+
If ``matrix`` is not square.
61+
"""
62+
63+
try:
64+
sympy_matrix = Matrix(matrix)
65+
except (TypeError, ValueError) as exc: # pragma: no cover - defensive
66+
msg = "matrix input must be a rectangular sequence of numbers"
67+
raise TypeError(msg) from exc
68+
69+
if sympy_matrix.rows != sympy_matrix.cols:
70+
raise ValueError("both matrices must be square with the same dimensions")
71+
72+
if simplify_entries:
73+
sympy_matrix = sympy_matrix.applyfunc(nsimplify)
74+
75+
return sympy_matrix
76+
77+
78+
def _jordan_signature(matrix: Matrix) -> tuple[tuple[Any, tuple[int, ...]], ...]:
79+
"""Return a hashable representation of the Jordan form of ``matrix``."""
80+
81+
_, blocks = matrix.jordan_cells()
82+
summary: dict[Any, list[int]] = {}
83+
for block in blocks:
84+
block_matrix = Matrix(block)
85+
eigenvalue = block_matrix[0, 0]
86+
summary.setdefault(eigenvalue, []).append(block_matrix.rows)
87+
88+
return tuple(
89+
(
90+
eigenvalue,
91+
tuple(sorted(block_sizes, reverse=True)),
92+
)
93+
for eigenvalue, block_sizes in sorted(summary.items(), key=lambda item: repr(item[0]))
94+
)
95+
96+
97+
def are_similar_matrices(
98+
matrix_a: MatrixLike,
99+
matrix_b: MatrixLike,
100+
*,
101+
simplify_entries: bool = True,
102+
) -> bool:
103+
"""Return ``True`` if ``matrix_a`` and ``matrix_b`` are similar matrices.
104+
105+
Parameters
106+
----------
107+
matrix_a, matrix_b:
108+
Square matrices represented as nested sequences (or SymPy matrices).
109+
simplify_entries:
110+
If ``True`` (default) the function attempts to simplify each entry so
111+
that values that are algebraically equal are treated as such. Set this
112+
to ``False`` to skip simplification when working with symbolic inputs
113+
that should remain untouched.
114+
115+
Raises
116+
------
117+
ValueError
118+
If the matrices are not square or their dimensions do not match.
119+
TypeError
120+
If either matrix cannot be interpreted as a numeric matrix.
121+
"""
122+
123+
sympy_a = _as_square_matrix(matrix_a, simplify_entries=simplify_entries)
124+
sympy_b = _as_square_matrix(matrix_b, simplify_entries=simplify_entries)
125+
126+
if sympy_a.shape != sympy_b.shape:
127+
raise ValueError("both matrices must be square with the same dimensions")
128+
129+
try:
130+
signature_a = _jordan_signature(sympy_a)
131+
signature_b = _jordan_signature(sympy_b)
132+
except MatrixError as exc: # pragma: no cover - rare SymPy failure
133+
msg = "unable to determine the Jordan canonical form"
134+
raise ValueError(msg) from exc
135+
136+
return signature_a == signature_b
137+
138+
139+
if __name__ == "__main__": # pragma: no cover - convenience execution
140+
from doctest import testmod
141+
142+
testmod()

0 commit comments

Comments
 (0)