Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

matrices and groups: Smith Normal Form and an infinity test for fp groups #12705

Merged
merged 10 commits into from Jun 19, 2017
28 changes: 26 additions & 2 deletions sympy/combinatorics/fp_groups.py
Expand Up @@ -135,15 +135,39 @@ def order(self, strategy="relator_based"):
2

"""
from sympy.core import S
if self._order != None:
return self._order
if self._coset_table != None:
self._order = len(self._coset_table.table)
else:
self._coset_table = self.coset_enumeration([], strategy)
self._order = len(self._coset_table.table)
if self._is_infinite():
self._order = S.Infinity
else:
self._coset_table = self.coset_enumeration([], strategy)
self._order = len(self._coset_table.table)
return self._order

def _is_infinite(self):
'''
Test if the group is infinite. Return `True` if the test succeeds
and `None` otherwise

'''
# Abelianisation test: check is the abelianisation is infinite
abelian_rels = []
from sympy.polys.solvers import RawMatrix as Matrix
from sympy.polys.domains import ZZ
from sympy.matrices.normalforms import smith_normal_invariants
for rel in self.relators():
abelian_rels.append([rel.exponent_sum(g) for g in self.generators])
m = Matrix(abelian_rels)
setattr(m, "ring", ZZ)
if 0 in smith_normal_invariants(m):
return True
else:
return None

def index(self, H, strategy="relator_based"):
"""
Returns the index of subgroup ``H`` in group ``self``.
Expand Down
3 changes: 2 additions & 1 deletion sympy/combinatorics/free_groups.py
Expand Up @@ -585,7 +585,8 @@ def order(self):
return S.Infinity

def commutator(self, other):
"""Returns the commutator of `self` and `x`: ``~x*~self*x*self``
"""
Return the commutator of `self` and `x`: ``~x*~self*x*self``
"""
group = self.group
if not isinstance(other, group.dtype):
Expand Down
6 changes: 6 additions & 0 deletions sympy/combinatorics/tests/test_fp_groups.py
@@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from sympy import S
from sympy.combinatorics.fp_groups import (FpGroup, CosetTable, low_index_subgroups,
coset_enumeration_r, coset_enumeration_c, reidemeister_presentation)
from sympy.combinatorics.free_groups import free_group
Expand Down Expand Up @@ -848,3 +849,8 @@ def test_subgroup_presentations():
"a_0**5*b_1**-2*a_0*b_1**2*c_3**-1*b_1**-2*c_3**-1*b_1*a_0**5*b_1**-2*a_0*b_1**2*c_3**-1*b_1**-2*c_3**-1*b_1*a_0**5*b_1**-2*a_0*b_1**2*c_3**-1*b_1**-2*c_3**-1*b_1))"
)
assert str(reidemeister_presentation(f, H)) == k

def test_is_infinite():
F, x, y = free_group("x, y")
f = FpGroup(F, [x*y*x**-1*y**-1, y**2])
assert f.order() == S.Infinity
145 changes: 145 additions & 0 deletions sympy/matrices/normalforms.py
@@ -0,0 +1,145 @@
from __future__ import print_function, division
from sympy.matrices import diag

'''
Functions returning normal forms of matrices

'''
def smith_normal_form(m, domain = None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a docstring for this function along with information regarding type of inputs and returned values?
Few examples to go along with this would be ideal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should also be some reference though there are not many apart from Wikipedia. That will contain further references to the theoretical background (Lang's Algebra, 3rd ed.) and applications to modules over PIDs (invariant factors, elementary divisors).

'''
Return the Smith Normal Form of a matrix `m` over the ring `domain`.
This will only work if the ring is a principal ideal domain.

Examples
========

>>> from sympy.polys.solvers import RawMatrix as Matrix
>>> from sympy.polys.domains import ZZ
>>> from sympy.matrices.normalforms import smith_normal_form
>>> m = Matrix([[12, 6, 4], [3, 9, 6], [2, 16, 14]])
>>> setattr(m, "ring", ZZ)
>>> print(smith_normal_form(m))
Matrix([[1, 0, 0], [0, 10, 0], [0, 0, -30]])

'''
invs = smith_normal_invariants(m, domain=domain)
smf = diag(*invs)
n = len(invs)
if m.rows > n:
smf = smf.row_insert(m.rows, zeros(m.rows-n, m.cols))
elif m.cols > n:
smf = smf.col_insert(m.cols, zeros(m.rows, m.cols-n))
return smf

def smith_normal_invariants(m, domain = None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this could be invariant_factors. That name appears in the literature in connection with finitely generated modules over PIDs together with 'elementary divisors'. Both terms seem to be in common use. We could choose to use 'invariant factors' specifically in connection with matrices and think of 'elementary divisors' as module invariants.

'''
Return the tuple of abelian invariants for a matrix `m`
(as in the Smith-Normal form)

References
==========

[1] https://en.wikipedia.org/wiki/Smith_normal_form#Algorithm
[2] http://sierra.nmsu.edu/morandi/notes/SmithNormalForm.pdf

'''
if not domain:
if not (hasattr(m, "ring") and m.ring.is_PID):
raise ValueError(
"The matrix entries must be over a principal ideal domain")
else:
domain = m.ring

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should check here that the matrix is not empty. If it is (i.e., len(m) == 0), then the empty list is returned.

if len(m) == 0:
return ()

m = m[:, :]

def add_rows(m, i, j, a, b, c, d):
# replace m[i, :] by a*m[i, :] + b*m[j, :]
# and m[j, :] by c*m[i, :] + d*m[j, :]
for k in range(m.cols):
e = m[i, k]
m[i, k] = a*e + b*m[j, k]
m[j, k] = c*e + d*m[j, k]

def add_columns(m, i, j, a, b, c, d):
# replace m[:, i] by a*m[:, i] + b*m[:, j]
# and m[:, j] by c*m[:, i] + d*m[:, j]
for k in range(m.rows):
e = m[k, i]
m[k, i] = a*e + b*m[k, j]
m[k, j] = c*e + d*m[k, j]

def clear_column(m):
# make m[1:, 0] zero by row and column operations
if m[0,0] == 0:
return m
pivot = m[0, 0]
for j in range(1, m.rows):
if m[j, 0] == 0:
continue
d, r = domain.div(m[j,0], pivot)
if r == 0:
add_rows(m, 0, j, 1, 0, -d, 1)
else:
a, b, g = domain.gcdex(pivot, m[j,0])
d_0 = domain.div(m[j, 0], g)[0]
d_j = domain.div(pivot, g)[0]
add_rows(m, 0, j, a, b, d_0, -d_j)
pivot = g
return m

def clear_row(m):
# make m[0, 1:] zero by row and column operations
if m[0] == 0:
return m
pivot = m[0, 0]
for j in range(1, m.cols):
if m[0, j] == 0:
continue
d, r = domain.div(m[0, j], pivot)
if r == 0:
add_columns(m, 0, j, 1, 0, -d, 1)
else:
a, b, g = domain.gcdex(pivot, m[0, j])
d_0 = domain.div(m[0, j], g)[0]
d_j = domain.div(pivot, g)[0]
add_columns(m, 0, j, a, b, d_0, -d_j)
pivot = g
return m

# permute the rows and columns until m[0,0] is non-zero if possible
ind = [i for i in range(m.rows) if m[i,0] != 0]
if ind:
m = m.permute_rows([[0, ind[0]]])
else:
ind = [j for j in range(m.cols) if m[0,j] != 0]
if ind:
m = m.permute_cols([[0, ind[0]]])

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Else both the first column and the first row consist of zeros and we have to scan the whole matrix.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If both the first column and row are zero, the function will just add 0 to the invariants list which it should do in this case anyway.

Copy link
Member

@jksuom jksuom Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should find the invariants in (divisibility) order starting with the gcd of all matrix entries and leaving the zeros, if any, to the end. Otherwise expressed, we should use the inverted inclusion order of the ideals generated by the invariants, first the largest one, generated by all matrix entries, and the smallest ideals, {0} in particular, coming last.

This means that we should add 0 to the invariant list only when there are no nonzero entries left. Therefore all entries should be scanned at this point.

# make the first row and column except m[0,0] zero
while (any([m[0,i] != 0 for i in range(1,m.cols)]) or
any([m[i,0] != 0 for i in range(1,m.rows)])):
m = clear_column(m)
m = clear_row(m)

if 1 in m.shape:
invs = ()
else:
invs = smith_normal_invariants(m[1:,1:], domain=domain)

if m[0,0]:
result = [m[0,0]]
result.extend(invs)
# in case m[0] doesn't divide the invariants of the rest of the matrix
for i in range(len(result)-1):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not clear to me that this loop would give the correct invariants. Can you give some proof?

Copy link
Contributor Author

@valglad valglad Jun 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My reasoning was like this.
We have a diagonal matrix. Denote result[i] by r_i and the gcd of r_i and r_(i+1) by g.

Add (i+1)-th row to i-th row, postmultiply by the matrix that in the 2x2 case looks like Matrix([a, r_(i+1)], [b, r_i]) where a*r_i+b*r_(i+1) = g - this is invertable because its determinant is -a*r_i/g-b*r_(i+1)/g == 1 which is a unit in the ring (in the higher dimensional case the matrix is extended with 1s along the diagonal and this 2x2 block starting at (i,i)-th entry, so still invertable). The result is a diagonal matrix as before but (i,i)-th entry is g, (i+1,i+1)th is r_i/g*r_(i+1) and (i+1, i)th is a b*r_(i+1). The latter can be eliminated by subtracting an appropriate multiple of i-th row from the (i+1)-th since g divides r_(i+1). The resultant matrix is diagonal with the entries modified in exactly the way this loop does and all operations were invertable so that's fine.

As for divisibility, the idea is this. We've just found that the gcd of r_0 and r_1 is g_0 and replaced r_0 with g_0 and r_1 with s*r_1 where s == r_0/g_0. Now we are doing the same with s*r_1 and r_2 and find that their gcd is g_1. But r_1 divides r_2 so r_1 divides g_1. In particular, g_0 divides g_1. And then we go on like this until we get to the end of the matrix noting that at the i-th step the entries before i satisfy the divisibility requirement.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation, that is about what I was expecting. It seems that it is valid though I don't recall having seen that algorithm explained anywhere. Can you find a reference? There should be references for algorithms used in SymPy, especially for those that are not as familiar as Euclid's algorithm. Othewise there should be adequate comments along the code, which at this point might become rather longish.

I'd also suggest that that the zero invariants would come last. (That would be natural as I noted above.) Then it seems that it would not be necessary to scan inv separating zeros and nonzero results. It would suffice to run this loop as long as the invariants are nonzero before breaking (if inv[i + 1] == 0, then it is divisible by inv[i]).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it would be more natural and I had zeros at the end initially. I introduced the scanning of invs to separate them because usually the convention is to have the 0s come first, as far as I know.

I'll try to search for a reference. Most of what I did is described on wikipedia but the last bit I worked out on paper. I'd be surprised if this shortcut of the last step isn't mentioned anywhere though.

if result[i] and domain.div(result[i+1], result[i])[1] != 0:
g = domain.gcd(result[i+1], result[i])
result[i+1] = domain.div(result[i], g)[0]*result[i+1]
result[i] = g
else:
break
else:
result = invs + (m[0,0],)
return tuple(result)
20 changes: 20 additions & 0 deletions sympy/matrices/tests/test_normalforms.py
@@ -0,0 +1,20 @@
from __future__ import print_function, division

from sympy import Symbol, Poly
from sympy.polys.solvers import RawMatrix as Matrix
from sympy.matrices.normalforms import smith_normal_invariants, smith_normal_form
from sympy.polys.domains import ZZ, QQ

def test_smith_normal():
m = Matrix([[12, 6, 4,8],[3,9,6,12],[2,16,14,28],[20,10,10,20]])
setattr(m, 'ring', ZZ)
smf = Matrix([[1, 0, 0, 0], [0, 10, 0, 0], [0, 0, -30, 0], [0, 0, 0, 0]])
assert smith_normal_form(m) == smf

x = Symbol('x')
m = Matrix([[Poly(x-1), Poly(1, x),Poly(-1,x)],
[0, Poly(x), Poly(-1,x)],
[Poly(0,x),Poly(-1,x),Poly(x)]])
setattr(m, 'ring', QQ[x])
invs = (Poly(1, x), Poly(x - 1), Poly(x**2 - 1))
assert smith_normal_invariants(m) == invs
1 change: 1 addition & 0 deletions sympy/polys/domains/domain.py
Expand Up @@ -43,6 +43,7 @@ class Domain(object):

is_Simple = False
is_Composite = False
is_PID = False

has_CharacteristicZero = False

Expand Down
1 change: 1 addition & 0 deletions sympy/polys/domains/field.py
Expand Up @@ -11,6 +11,7 @@ class Field(Ring):
"""Represents a field domain. """

is_Field = True
is_PID = True

def get_ring(self):
"""Returns a ring associated with ``self``. """
Expand Down
1 change: 1 addition & 0 deletions sympy/polys/domains/integerring.py
Expand Up @@ -18,6 +18,7 @@ class IntegerRing(Ring, CharacteristicZero, SimpleDomain):

is_IntegerRing = is_ZZ = True
is_Numerical = True
is_PID = True

has_assoc_Ring = True
has_assoc_Field = True
Expand Down
5 changes: 5 additions & 0 deletions sympy/polys/domains/polynomialring.py
Expand Up @@ -33,6 +33,11 @@ def __init__(self, domain_or_ring, symbols=None, order=None):
self.symbols = ring.symbols
self.domain = ring.domain


if symbols:
if ring.domain.is_Field and ring.domain.is_Exact and len(symbols)==1:
self.is_PID = True

# TODO: remove this
self.dom = self.domain

Expand Down
1 change: 1 addition & 0 deletions sympy/polys/domains/realfield.py
Expand Up @@ -21,6 +21,7 @@ class RealField(Field, CharacteristicZero, SimpleDomain):

is_Exact = False
is_Numerical = True
is_PID = False

has_assoc_Ring = False
has_assoc_Field = True
Expand Down
4 changes: 4 additions & 0 deletions sympy/polys/solvers.py
Expand Up @@ -7,6 +7,10 @@
class RawMatrix(Matrix):
_sympify = staticmethod(lambda x: x)

def is_zero():
from sympy.matrices import MatrixShaping
return MatrixShaping.is_zero(self)

def eqs_to_matrix(eqs, ring):
"""Transform from equations to matrix form. """
xs = ring.gens
Expand Down