Source code for netket.utils.group._group

# Copyright 2021 The NetKet Authors - All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#    http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Ignore false-positives for redefined `product` functions:
# pylint: disable=function-redefined

import itertools

import numpy as np

from netket.utils import HashableArray, struct
from netket.utils.float import comparable, prune_zeros
from netket.utils.types import Array
from netket.utils.dispatch import dispatch

from ._semigroup import Element, FiniteSemiGroup, Identity


[docs] @struct.dataclass class FiniteGroup(FiniteSemiGroup): """ Collection of Elements expected to satisfy group axioms. Unlike FiniteSemiGroup, product tables, conjugacy classes, etc. can be calculated. Group elements can be implemented in any way, as long as a subclass of Group is able to implement their action. Subclasses must implement a :code:`_canonical()` method that returns an array of integers for each acceptable Element such that two Elements are considered equal iff the corresponding matrices are equal. """ def __hash__(self): return super().__hash__() def _canonical(self, x: Element) -> Array: r""" Canonical form of :code:`Element`s, used for equality testing (i.e., two :code:`Element`s `x,y` are deemed equal iff :code:`_canonical(x) == _canonical(y)`. Must be overridden in subclasses Arguments: x: an `Element` Returns: the canonical form as a numpy.ndarray of integers """ raise NotImplementedError def _canonical_array(self) -> Array: r""" Lists the canonical forms returned by `_canonical` as rows of a 2D array. """ return np.array([self._canonical(x).flatten() for x in self.elems]) def _canonical_lookup(self) -> dict: r""" Creates a lookup table from canonical forms to index in `self.elems` """ return { HashableArray(self._canonical(element)): index for index, element in enumerate(self.elems) }
[docs] def remove_duplicates(self, *, return_inverse=False) -> "FiniteGroup": r""" Returns a new :class:`FiniteGroup` with duplicate elements (that is, elements with identical canonical forms) removed. Arguments: return_inverse: If True, also return indices to reconstruct the original group from the result. Returns: The group with duplicate elements removed. If :code:`return_inverse==True` it also returns the list of indices needed to reconstruct the original group from the result. """ result = np.unique( self._canonical_array(), axis=0, return_index=True, return_inverse=return_inverse, ) group = FiniteGroup([self.elems[i] for i in sorted(result[1])]) if return_inverse: return group, result[2] else: return group
@struct.property_cached def inverse(self) -> Array: r""" Indices of the inverse of each element. Assuming the definitions .. code:: g = self[idx_g] h = self[self.inverse[idx_g]] :code:`gh = product(g, h)` is equivalent to :code:`Identity()` """ canonical_identity = self._canonical(Identity()) inverse = np.zeros(len(self.elems), dtype=int) for i, e1 in enumerate(self.elems): for j, e2 in enumerate(self.elems): prod = e1 @ e2 if np.all(self._canonical(prod) == canonical_identity): inverse[i] = j return inverse @struct.property_cached def product_table(self) -> Array: r""" A table of indices corresponding to :math:`g^{-1} h` over the group. Assuming the definitions .. code:: g = self[idx_g] h = self[idx_h] idx_u = self.product_table[idx_g, idx_h] :code:`self[idx_u]` corresponds to :math:`u = g^{-1} h` . """ n_symm = len(self) product_table = np.zeros([n_symm, n_symm], dtype=int) lookup = self._canonical_lookup() for i, e1 in enumerate(self.elems[self.inverse]): for j, e2 in enumerate(self.elems): prod = e1 @ e2 product_table[i, j] = lookup[HashableArray(self._canonical(prod))] return product_table @struct.property_cached def conjugacy_table(self) -> Array: r""" The conjugacy table of this Permutation Group. Assuming the definitions .. code:: g = self[idx_g] h = self[idx_h] :code:`self[self.conjugacy_table[idx_g,idx_h]]` corresponds to :math:`h^{-1}gh`. """ col_index = np.arange(len(self))[np.newaxis, :] # exploits that h^{-1}gh = (g^{-1} h)^{-1} h return self.product_table[self.product_table, col_index] @struct.property_cached def conjugacy_classes(self) -> tuple[Array, Array, Array]: r""" The conjugacy classes of the group. Returns: The three arrays - classes: a boolean array, each row indicating the elements that belong to one conjugacy class - representatives: the lowest-indexed member of each conjugacy class - inverse: the conjugacy class index of every group element """ row_index = np.arange(len(self))[:, np.newaxis] # if is_conjugate[i,j] is True, self[i] and self[j] are in the same class is_conjugate = np.full((len(self), len(self)), False) is_conjugate[row_index, self.conjugacy_table] = True classes, representatives, inverse = np.unique( is_conjugate, axis=0, return_index=True, return_inverse=True ) # Usually self[0] == Identity(), which is its own class # This class is listed last by the lexicographic order used by np.unique # so we reverse it to get a more conventional layout classes = classes[::-1] representatives = representatives[::-1] inverse = (representatives.size - 1) - inverse return classes, representatives, inverse @struct.property_cached def character_table_by_class(self) -> Array: r""" Calculates the character table using Burnside's algorithm. Each row of the output lists the characters of one irrep in the order the conjugacy classes are listed in `self.conjugacy_classes`. Assumes that :code:`Identity() == self[0]`, if not, the sign of some characters may be flipped. The irreps are sorted by dimension. """ classes, _, _ = self.conjugacy_classes class_sizes = classes.sum(axis=1) # Construct a random linear combination of the class matrices c_S # (c_S)_{RT} = #{r,s: r \in R, s \in S: rs = t} # for conjugacy classes R,S,T, and a fixed t \in T. # # From our oblique times table it is easier to calculate # (d_S)_{RT} = #{r,t: r \in R, t \in T: rs = t} # for a fixed s \in S. This is just `product_table == s`, aggregated # over conjugacy classes. c_S and d_S are related by # c_{RST} = |S| d_{RST} / |T|; # since we only want a random linear combination, we forget about the # constant |S| and only divide each column through with the appropriate |T| class_matrix = ( classes @ random(len(self), seed=0)[self.product_table] @ classes.T ) class_matrix /= class_sizes # The vectors |R|\chi(r) are (column) eigenvectors of all class matrices # the random linear combination ensures (with probability 1) that # none of them are degenerate _, table = np.linalg.eig(class_matrix) table = table.T / class_sizes # Normalise the eigenvectors by orthogonality: \sum_g |\chi(g)|^2 = |G| norm = np.sum(np.abs(table) ** 2 * class_sizes, axis=1, keepdims=True) ** 0.5 table /= norm table /= _cplx_sign(table[:, 0])[:, np.newaxis] # ensure correct sign table *= len(self) ** 0.5 # Sort lexicographically, ascending by first column, descending by others sorting_table = np.column_stack((table.real, table.imag)) sorting_table[:, 1:] *= -1 sorting_table = comparable(sorting_table) _, indices = np.unique(sorting_table, axis=0, return_index=True) table = table[indices] # Get rid of annoying nearly-zero entries table = prune_zeros(table) return table
[docs] def character_table(self) -> Array: r""" Calculates the character table using Burnside's algorithm. Each row of the output lists the characters of all group elements for one irrep, i.e. :code:`self.character_table()[i,g]` gives :math:`\chi_i(g)`. Assumes that :code:`Identity() == self[0]`, if not, the sign of some characters may be flipped. The irreps are sorted by dimension. """ _, _, inverse = self.conjugacy_classes CT = self.character_table_by_class return CT[:, inverse]
[docs] def character_table_readable(self) -> tuple[list[str], Array]: r""" Returns a conventional rendering of the character table. Returns: A tuple containing a list of strings and an array - :code:`classes`: a text description of a representative of each conjugacy class as a list - :code:`characters`: a matrix, each row of which lists the characters of one irrep """ # TODO put more effort into nice rendering? classes, idx_repr, _ = self.conjugacy_classes class_sizes = classes.sum(axis=1) representatives = [ f"{class_sizes[cls]}x{self[rep]}" for cls, rep in enumerate(idx_repr) ] return representatives, self.character_table_by_class
@struct.property_cached def _irrep_matrices(self) -> list[Array]: # We use Dixon's algorithm (Math. Comp. 24 (1970), 707) to decompose # the regular representation of the group into its irreps. # We start with a Hermitian matrix E that commutes with every matrix in # this rep: the space spanned by its degenerate eigenvectors then all # transform according to some rep of the group. # The matrix is randomised to ensure there are no accidental degeneracies, # i.e. all these spaces are irreps. # For real irreps, real matrices are returned: if needed, the same # routine is run with a real symmetric and a complex Hermitian matrix. true_product_table = self.product_table[self.inverse] inverted_product_table = true_product_table[:, self.inverse] def invariant_subspaces(e, seed): # Construct a Hermitian matrix that commutes with all matrices # in the regular rep. # These matrices obey E_{g,h} = e_{gh^{-1}} for some vector e e = e[inverted_product_table] e = e + e.T.conj() # Since E commutes with all the ρ, its eigenspaces reduce the rep # For a random input vector, there are no accidental degeneracies # except for complex irreps and real symmetric E. e, v = np.linalg.eigh(e) # indices that split v into eigenspaces _, starting_idx = np.unique(comparable(e), return_index=True) # Calculate sᴴPv for one eigenvector v per eigenspace, a fixed # random vector s and all regular projectors P # These are calculated as linear combinations of sᴴρv for the # regular rep matrices ρ, which is given by the latter two terms vs = v[:, starting_idx] s = random(len(self), seed=seed)[inverted_product_table] # row #i of this `s` is sᴴρ(self[i]), where sᴴ is the random vector proj = self.character_table().conj() @ s @ vs starting_idx = list(starting_idx) + [len(self)] return v, starting_idx, proj # Calculate the eigenspaces for a real and/or complex random matrix # To check which are needed, calculate Frobenius-Schur indicators # This is +1, 0, -1 for real, complex, and quaternionic irreps squares = np.diag(true_product_table) frob = np.array( np.rint( np.sum(self.character_table()[:, squares], axis=1).real / len(self) ), dtype=int, ) eigen = {} if np.any(frob == 1): # real irreps: start from a real symmetric invariant matrix e = random(len(self), seed=0) eigen["real"] = invariant_subspaces(e, seed=1) if np.any(frob != 1): # complex or quaternionic irreps: complex hermitian invariant matrix e = random(len(self), seed=2, cplx=True) eigen["cplx"] = invariant_subspaces(e, seed=3) irreps = [] for i, chi in enumerate(self.character_table()): v, idx, proj = eigen["real"] if frob[i] == 1 else eigen["cplx"] # Check which eigenspaces belong to this irrep proj = np.logical_not(np.isclose(proj[i], 0.0)) # Pick the first eigenspace in this irrep first = np.arange(len(idx) - 1, dtype=int)[proj][0] v = v[:, idx[first] : idx[first + 1]] # v is now the basis of a single irrep: project the regular rep onto it irreps.append(np.einsum("gi,ghj ->hij", v.conj(), v[true_product_table, :])) return irreps
[docs] def irrep_matrices(self) -> list[Array]: """ Returns matrices that realise all irreps of the group. Returns: A list of 3D arrays such that `self.irrep_matrices()[i][g]` contains the representation of `self[g]` consistent with the characters in `self.character_table()[i]`. """ return self._irrep_matrices
def _cplx_sign(x): return x / np.abs(x) def random(n, seed, cplx=False): if cplx: v = np.random.default_rng(seed).normal(size=(2, n)) v = v[0] + 1j * v[1] return v else: return np.random.default_rng(seed).normal(size=n) @dispatch def product(A: FiniteGroup, B: FiniteGroup): return FiniteGroup( elems=[a @ b for a, b in itertools.product(A.elems, B.elems)], )