# Algorithms for computing the reduced row echelon form of a matrix. # # We need to choose carefully which algorithms to use depending on the domain, # shape, and sparsity of the matrix as well as things like the bit count in the # case of ZZ or QQ. This is important because the algorithms have different # performance characteristics in the extremes of dense vs sparse. # # In all cases we use the sparse implementations but we need to choose between # Gauss-Jordan elimination with division and fraction-free Gauss-Jordan # elimination. For very sparse matrices over ZZ with low bit counts it is # asymptotically faster to use Gauss-Jordan elimination with division. For # dense matrices with high bit counts it is asymptotically faster to use # fraction-free Gauss-Jordan. # # The most important thing is to get the extreme cases right because it can # make a big difference. In between the extremes though we have to make a # choice and here we use empirically determined thresholds based on timings # with random sparse matrices. # # In the case of QQ we have to consider the denominators as well. If the # denominators are small then it is faster to clear them and use fraction-free # Gauss-Jordan over ZZ. If the denominators are large then it is faster to use # Gauss-Jordan elimination with division over QQ. # # Timings for the various algorithms can be found at # # https://github.com/sympy/sympy/issues/25410 # https://github.com/sympy/sympy/pull/25443 from sympy.polys.domains import ZZ from sympy.polys.matrices.sdm import SDM, sdm_irref, sdm_rref_den from sympy.polys.matrices.ddm import DDM from sympy.polys.matrices.dense import ddm_irref, ddm_irref_den def _dm_rref(M, *, method='auto'): """ Compute the reduced row echelon form of a ``DomainMatrix``. This function is the implementation of :meth:`DomainMatrix.rref`. Chooses the best algorithm depending on the domain, shape, and sparsity of the matrix as well as things like the bit count in the case of :ref:`ZZ` or :ref:`QQ`. The result is returned over the field associated with the domain of the Matrix. See Also ======== sympy.polys.matrices.domainmatrix.DomainMatrix.rref The ``DomainMatrix`` method that calls this function. sympy.polys.matrices.rref._dm_rref_den Alternative function for computing RREF with denominator. """ method, use_fmt = _dm_rref_choose_method(M, method, denominator=False) M, old_fmt = _dm_to_fmt(M, use_fmt) if method == 'GJ': # Use Gauss-Jordan with division over the associated field. Mf = _to_field(M) M_rref, pivots = _dm_rref_GJ(Mf) elif method == 'FF': # Use fraction-free GJ over the current domain. M_rref_f, den, pivots = _dm_rref_den_FF(M) M_rref = _to_field(M_rref_f) / den elif method == 'CD': # Clear denominators and use fraction-free GJ in the associated ring. _, Mr = M.clear_denoms_rowwise(convert=True) M_rref_f, den, pivots = _dm_rref_den_FF(Mr) M_rref = _to_field(M_rref_f) / den else: raise ValueError(f"Unknown method for rref: {method}") M_rref, _ = _dm_to_fmt(M_rref, old_fmt) # Invariants: # - M_rref is in the same format (sparse or dense) as the input matrix. # - M_rref is in the associated field domain and any denominator was # divided in (so is implicitly 1 now). return M_rref, pivots def _dm_rref_den(M, *, keep_domain=True, method='auto'): """ Compute the reduced row echelon form of a ``DomainMatrix`` with denominator. This function is the implementation of :meth:`DomainMatrix.rref_den`. Chooses the best algorithm depending on the domain, shape, and sparsity of the matrix as well as things like the bit count in the case of :ref:`ZZ` or :ref:`QQ`. The result is returned over the same domain as the input matrix unless ``keep_domain=False`` in which case the result might be over an associated ring or field domain. See Also ======== sympy.polys.matrices.domainmatrix.DomainMatrix.rref_den The ``DomainMatrix`` method that calls this function. sympy.polys.matrices.rref._dm_rref Alternative function for computing RREF without denominator. """ method, use_fmt = _dm_rref_choose_method(M, method, denominator=True) M, old_fmt = _dm_to_fmt(M, use_fmt) if method == 'FF': # Use fraction-free GJ over the current domain. M_rref, den, pivots = _dm_rref_den_FF(M) elif method == 'GJ': # Use Gauss-Jordan with division over the associated field. M_rref_f, pivots = _dm_rref_GJ(_to_field(M)) # Convert back to the ring? if keep_domain and M_rref_f.domain != M.domain: _, M_rref = M_rref_f.clear_denoms(convert=True) if pivots: den = M_rref[0, pivots[0]].element else: den = M_rref.domain.one else: # Possibly an associated field M_rref = M_rref_f den = M_rref.domain.one elif method == 'CD': # Clear denominators and use fraction-free GJ in the associated ring. _, Mr = M.clear_denoms_rowwise(convert=True) M_rref_r, den, pivots = _dm_rref_den_FF(Mr) if keep_domain and M_rref_r.domain != M.domain: # Convert back to the field M_rref = _to_field(M_rref_r) / den den = M.domain.one else: # Possibly an associated ring M_rref = M_rref_r if pivots: den = M_rref[0, pivots[0]].element else: den = M_rref.domain.one else: raise ValueError(f"Unknown method for rref: {method}") M_rref, _ = _dm_to_fmt(M_rref, old_fmt) # Invariants: # - M_rref is in the same format (sparse or dense) as the input matrix. # - If keep_domain=True then M_rref and den are in the same domain as the # input matrix # - If keep_domain=False then M_rref might be in an associated ring or # field domain but den is always in the same domain as M_rref. return M_rref, den, pivots def _dm_to_fmt(M, fmt): """Convert a matrix to the given format and return the old format.""" old_fmt = M.rep.fmt if old_fmt == fmt: pass elif fmt == 'dense': M = M.to_dense() elif fmt == 'sparse': M = M.to_sparse() else: raise ValueError(f'Unknown format: {fmt}') # pragma: no cover return M, old_fmt # These are the four basic implementations that we want to choose between: def _dm_rref_GJ(M): """Compute RREF using Gauss-Jordan elimination with division.""" if M.rep.fmt == 'sparse': return _dm_rref_GJ_sparse(M) else: return _dm_rref_GJ_dense(M) def _dm_rref_den_FF(M): """Compute RREF using fraction-free Gauss-Jordan elimination.""" if M.rep.fmt == 'sparse': return _dm_rref_den_FF_sparse(M) else: return _dm_rref_den_FF_dense(M) def _dm_rref_GJ_sparse(M): """Compute RREF using sparse Gauss-Jordan elimination with division.""" M_rref_d, pivots, _ = sdm_irref(M.rep) M_rref_sdm = SDM(M_rref_d, M.shape, M.domain) pivots = tuple(pivots) return M.from_rep(M_rref_sdm), pivots def _dm_rref_GJ_dense(M): """Compute RREF using dense Gauss-Jordan elimination with division.""" partial_pivot = M.domain.is_RR or M.domain.is_CC ddm = M.rep.to_ddm().copy() pivots = ddm_irref(ddm, _partial_pivot=partial_pivot) M_rref_ddm = DDM(ddm, M.shape, M.domain) pivots = tuple(pivots) return M.from_rep(M_rref_ddm.to_dfm_or_ddm()), pivots def _dm_rref_den_FF_sparse(M): """Compute RREF using sparse fraction-free Gauss-Jordan elimination.""" M_rref_d, den, pivots = sdm_rref_den(M.rep, M.domain) M_rref_sdm = SDM(M_rref_d, M.shape, M.domain) pivots = tuple(pivots) return M.from_rep(M_rref_sdm), den, pivots def _dm_rref_den_FF_dense(M): """Compute RREF using sparse fraction-free Gauss-Jordan elimination.""" ddm = M.rep.to_ddm().copy() den, pivots = ddm_irref_den(ddm, M.domain) M_rref_ddm = DDM(ddm, M.shape, M.domain) pivots = tuple(pivots) return M.from_rep(M_rref_ddm.to_dfm_or_ddm()), den, pivots def _dm_rref_choose_method(M, method, *, denominator=False): """Choose the fastest method for computing RREF for M.""" if method != 'auto': if method.endswith('_dense'): method = method[:-len('_dense')] use_fmt = 'dense' else: use_fmt = 'sparse' else: # The sparse implementations are always faster use_fmt = 'sparse' K = M.domain if K.is_ZZ: method = _dm_rref_choose_method_ZZ(M, denominator=denominator) elif K.is_QQ: method = _dm_rref_choose_method_QQ(M, denominator=denominator) elif K.is_RR or K.is_CC: # TODO: Add partial pivot support to the sparse implementations. method = 'GJ' use_fmt = 'dense' elif K.is_EX and M.rep.fmt == 'dense' and not denominator: # Do not switch to the sparse implementation for EX because the # domain does not have proper canonicalization and the sparse # implementation gives equivalent but non-identical results over EX # from performing arithmetic in a different order. Specifically # test_issue_23718 ends up getting a more complicated expression # when using the sparse implementation. Probably the best fix for # this is something else but for now we stick with the dense # implementation for EX if the matrix is already dense. method = 'GJ' use_fmt = 'dense' else: # This is definitely suboptimal. More work is needed to determine # the best method for computing RREF over different domains. if denominator: method = 'FF' else: method = 'GJ' return method, use_fmt def _dm_rref_choose_method_QQ(M, *, denominator=False): """Choose the fastest method for computing RREF over QQ.""" # The same sorts of considerations apply here as in the case of ZZ. Here # though a new more significant consideration is what sort of denominators # we have and what to do with them so we focus on that. # First compute the density. This is the average number of non-zero entries # per row but only counting rows that have at least one non-zero entry # since RREF can ignore fully zero rows. density, _, ncols = _dm_row_density(M) # For sparse matrices use Gauss-Jordan elimination over QQ regardless. if density < min(5, ncols/2): return 'GJ' # Compare the bit-length of the lcm of the denominators to the bit length # of the numerators. # # The threshold here is empirical: we prefer rref over QQ if clearing # denominators would result in a numerator matrix having 5x the bit size of # the current numerators. numers, denoms = _dm_QQ_numers_denoms(M) numer_bits = max([n.bit_length() for n in numers], default=1) denom_lcm = ZZ.one for d in denoms: denom_lcm = ZZ.lcm(denom_lcm, d) if denom_lcm.bit_length() > 5*numer_bits: return 'GJ' # If we get here then the matrix is dense and the lcm of the denominators # is not too large compared to the numerators. For particularly small # denominators it is fastest just to clear them and use fraction-free # Gauss-Jordan over ZZ. With very small denominators this is a little # faster than using rref_den over QQ but there is an intermediate regime # where rref_den over QQ is significantly faster. The small denominator # case is probably very common because small fractions like 1/2 or 1/3 are # often seen in user inputs. if denom_lcm.bit_length() < 50: return 'CD' else: return 'FF' def _dm_rref_choose_method_ZZ(M, *, denominator=False): """Choose the fastest method for computing RREF over ZZ.""" # In the extreme of very sparse matrices and low bit counts it is faster to # use Gauss-Jordan elimination over QQ rather than fraction-free # Gauss-Jordan over ZZ. In the opposite extreme of dense matrices and high # bit counts it is faster to use fraction-free Gauss-Jordan over ZZ. These # two extreme cases need to be handled differently because they lead to # different asymptotic complexities. In between these two extremes we need # a threshold for deciding which method to use. This threshold is # determined empirically by timing the two methods with random matrices. # The disadvantage of using empirical timings is that future optimisations # might change the relative speeds so this can easily become out of date. # The main thing is to get the asymptotic complexity right for the extreme # cases though so the precise value of the threshold is hopefully not too # important. # Empirically determined parameter. PARAM = 10000 # First compute the density. This is the average number of non-zero entries # per row but only counting rows that have at least one non-zero entry # since RREF can ignore fully zero rows. density, nrows_nz, ncols = _dm_row_density(M) # For small matrices use QQ if more than half the entries are zero. if nrows_nz < 10: if density < ncols/2: return 'GJ' else: return 'FF' # These are just shortcuts for the formula below. if density < 5: return 'GJ' elif density > 5 + PARAM/nrows_nz: return 'FF' # pragma: no cover # Maximum bitsize of any entry. elements = _dm_elements(M) bits = max([e.bit_length() for e in elements], default=1) # Wideness parameter. This is 1 for square or tall matrices but >1 for wide # matrices. wideness = max(1, 2/3*ncols/nrows_nz) max_density = (5 + PARAM/(nrows_nz*bits**2)) * wideness if density < max_density: return 'GJ' else: return 'FF' def _dm_row_density(M): """Density measure for sparse matrices. Defines the "density", ``d`` as the average number of non-zero entries per row except ignoring rows that are fully zero. RREF can ignore fully zero rows so they are excluded. By definition ``d >= 1`` except that we define ``d = 0`` for the zero matrix. Returns ``(density, nrows_nz, ncols)`` where ``nrows_nz`` counts the number of nonzero rows and ``ncols`` is the number of columns. """ # Uses the SDM dict-of-dicts representation. ncols = M.shape[1] rows_nz = M.rep.to_sdm().values() if not rows_nz: return 0, 0, ncols else: nrows_nz = len(rows_nz) density = sum(map(len, rows_nz)) / nrows_nz return density, nrows_nz, ncols def _dm_elements(M): """Return nonzero elements of a DomainMatrix.""" elements, _ = M.to_flat_nz() return elements def _dm_QQ_numers_denoms(Mq): """Returns the numerators and denominators of a DomainMatrix over QQ.""" elements = _dm_elements(Mq) numers = [e.numerator for e in elements] denoms = [e.denominator for e in elements] return numers, denoms def _to_field(M): """Convert a DomainMatrix to a field if possible.""" K = M.domain if K.has_assoc_Field: return M.to_field() else: return M