MatrixCovers

This package computes covers of matrices. Given a matrix A, a cover (more specifically, a hard cover) is a matrix C that can be defined as C = a * b', where a and b are non-negative vectors. C must satisfy

\[C_{ij} \;\geq\; |A_{ij}| \quad \text{for all } i, j.\]

For a symmetric matrix the cover is symmetric (b = a), so a single vector suffices: a[i] * a[j] >= abs(A[i, j]).

An optimal cover is one for which C is "as tight as possible" in bounding A (equivalently, no larger than strictly necessary), by criteria that will be described below.

This package also supports soft covers: these satisfy C[i, j] ⪆ abs(A[i, j]), meaning that C matches or exceeds A at most indexes but not necessarily all; this intuitive notion will be made concrete below.

Why covers?

Covers provide a natural scale-covariant "summary" of a matrix. If you rescale rows by a positive diagonal factor D_r and columns by D_c, the optimal cover transforms as a → D_r * a, b → D_c * b, exactly mirroring how the matrix entries change. Scalar summaries like norm(A) or maximum(abs, A) do not have this property and therefore implicitly encode an arbitrary choice of units.

Most users will employ matrices that store pure numbers, and this package works well with such matrices. But to emphasize the scale-covariance, we'll start with an example of a 3×3 matrix whose rows and columns correspond to physical variables with different units — position in meters, velocity in m/s, force in Newtons. Loading Unitful lets the matrix carry those units itself:

julia> using MatrixCovers, Unitful

julia> L, V, F = u"m", u"m/s", u"N";  # length, velocity, force

julia> A = [1e6/L^2   1e3/(L*V)  1.0/(L*F)
            1e3/(L*V) 1.0/V^2    1e-3/(V*F)
            1.0/(L*F) 1e-3/(V*F) 1e-6/F^2];

julia> a = symcover(A);

julia> round.(typeof.(a), a; digits=6)
3-element Vector{Quantity{Float64}}:
 1000.0 m^-1
    1.0 s m^-1
    0.001 N^-1

A[i,j] has units 1/(u[i]*u[j]) (modeling a Hessian matrix for functions of parameter vectors with units u[i]), so a[i] comes back with gradient-like units of 1/u[i]: the cover provides each variable's natural scale inferred from A, here 1 mm, 1 m/s, and 1 kN. Had we expressed the original matrix in those units directly, we would have gotten the equivalent cover stated in those units.

Normalizing by the cover cancels the units along with the magnitudes, leaving a matrix that is all-ones, dimensionless, and scale-invariant:

julia> round.(A ./ (a .* a'); digits=6)
3×3 Matrix{Float64}:
 1.0  1.0  1.0
 1.0  1.0  1.0
 1.0  1.0  1.0

It is worth noting that this yields 1 only for entries where the cover bound is tight; had A been, say, diagonal, then A ./ (a .* a') would also be diagonal.

A cover exists only when the units of A factor as unit(A[i,j]) == unit(a[i])*unit(b[j]), and a matrix that fails this is rejected with a DimensionMismatch. This requirement is not exhorbitant: without it, the terms in a row of A*x carry different units, so A*x is undefined for every x. If a matrix can be used in matrix-vector multiplication, it has a cover.

Penalty functions

A cover is valid as long as every constraint is satisfied, but tighter covers better capture the scaling of A. Cover quality is measured through the ratios r[i, j] = |A[i,j]| / (a[i] * b[j]): a hard cover has every 0 ≤ r[i, j] ≤ 1, and r[i, j] == 1 means the constraint is exactly tight.

A penalty function ϕ combines those ratios into a scalar objective

\[\sum_{i,j} \phi\!\left(\frac{|A_{ij}|}{a_i\, b_j}\right),\]

which the solvers minimize. Two penalty families are provided:

  • AbsLog{p}ϕ(r) = |log r|^p (and ϕ(0) = 0). Convex in log space, which makes them a favorable (and therefore default) penalty for hard covers, where r ≤ 1 and |log r| is the log-excess of a constraint. AbsLog{1} sums the log-excesses (L1), AbsLog{2} sums their squares (L2). Their principal disadvantage is the divergence and discontinuity at r = 0.
  • AbsLinear{p}ϕ(r) = |1 - r|^p. Non-convex, but unlike AbsLog these are finite and continuous at r = 0 (ϕ(0) = 1), so zero entries of A contribute a bounded penalty. This is the penalty used by default for the soft covers, where r > 1 (an uncovered entry) is allowed but penalized.

cover_objective evaluates either penalty for a given cover:

julia> using MatrixCovers

julia> A = [4.0 2.0; 2.0 16.0];

julia> a = symcover(A)
2-element Vector{Float64}:
 2.0
 4.0

julia> cover_objective(AbsLog{1}(), a, A)   # sum of log-excesses (L1)
2.772588722239781

julia> cover_objective(AbsLog{2}(), a, A)   # sum of squared log-excesses (L2)
3.843624111345611

Both objectives are zero if and only if every constraint is exactly tight.

You can override the default penalty by supplying it as an argument to the solvers.

Choosing a cover algorithm

FunctionSymmetricConstraintDefault (or alternative) objectiveRequires
symcoveryeshard (r ≤ 1)heuristic
covernohard (r ≤ 1)heuristic
symcover_minyeshard (r ≤ 1)AbsLog{2} (or AbsLog{1}, AbsLinear)native for AbsLog{2}; else JuMP
cover_minnohard (r ≤ 1)AbsLog{2} (or AbsLog{1}, AbsLinear)native for AbsLog{2}; else JuMP
soft_symcoveryessoft (penalized)AbsLinear{2} (or AbsLog, AbsLinear{1})native for AbsLog; else —
soft_covernosoft (penalized)AbsLinear{2} (or AbsLog, AbsLinear{1})native for AbsLog; else —
soft_symcover_minyessoft (penalized)AbsLog{2}, AbsLinearnative for AbsLog{2}; else JuMP
soft_cover_minnosoft (penalized)AbsLog{2}, AbsLinearnative for AbsLog{2}; else JuMP

The two soft tiers are separated by a different axis than the two hard ones. For hard covers, symcover trades optimality for speed while guaranteeing feasibility, and symcover_min is optimal. Both soft tiers minimize the same unconstrained objective, and differ instead in what they promise about reaching its minimum:

  • soft_symcover and soft_cover are always native and best-effort. They require no extension for any penalty, and they own their multistart — but what they return is a coordinate-descent fixed point, which for the non-convex and nonsmooth penalties need not be a minimizer.
  • soft_symcover_min and soft_cover_min return a true minimizer of the basin they start in, and may require an extension. AbsLog{2} is native; the AbsLinear penalties need JuMP and Ipopt; AbsLog{1} is not implemented.

Both reduce to the same trade: cheap and always available, against best quality and possibly an extra dependency.

Under AbsLog{2} the objective is convex with a single minimizer, so the tiers coincide — soft_symcover is soft_symcover_min there, and likewise for the asymmetric pair. That is the degenerate case of the contract rather than an exception to it: with one minimizer there is nothing for a best-effort descent and a minimizer to disagree about. Under AbsLog{1} they part company most sharply — the soft AbsLog{1} covers are coordinate descents that reach a deterministic fixed point rather than a minimizer, and soft_symcover_min/soft_cover_min do not accept AbsLog{1} at all.

symcover, cover, and any native implementation can be recommended for production use, possibly with relaxed convergence bounds. The heuristic solvers are particularly fast: they run in $O(mn)$ time for an $m\times n$ matrix and often land within a few percent of the objective-minimal cover (see the quality tests involving test/testmatrices.jl). Native solvers (both hard and soft) are intermediate, still roughly $O(mn)$ but requiring many iterations (and for non-convex cases, multiple start points by default) for convergence; still, they are much faster than their JuMP-counterparts, which are provided mainly as a reference.

Covariance of the heuristics

symcover and cover, the two heuristic solvers, are not universally covariant. Both are covariant when every row and column of A has the same pattern of nonzeros, notably for any dense A lacking zero entries. But on an irregular sparse support they are only approximately covariant. A symmetric three-node path is enough to show it:

julia> using MatrixCovers, LinearAlgebra

julia> A = [1.0 1 0; 1 1 1; 0 1 1];   # rows 1 and 3 supported on 2 columns, row 2 on all 3

julia> d = [1.0, 6.0, 0.5]; D = Diagonal(d);

julia> a1 = symcover(A); a2 = symcover(D * A * D);

julia> P1 = (d .* a1) * (d .* a1)'; P2 = a2 * a2';   # does scaling commute with cover-computation?

julia> round.(extrema(P2 ./ P1); digits=3)           # not for the heuristic solver
(1.0, 1.077)

The departure is small (bounded by the heuristic's own suboptimality) and both covers are valid, so it matters mostly when the covariance itself is what you are relying on. When it is, use symcover_min or cover_min, whose minimizer is scale-covariant by construction.

Objective-minimal covers

symcover_min and cover_min return a cover that minimizes the chosen penalty subject to the hard constraint. For the default AbsLog{2} penalty they are solved natively (no external solver) by penalty-continuation with a damped semismooth Newton iteration:

julia> using MatrixCovers

julia> A = [1 2 3; 6 5 4];

julia> a, b = cover(A);          # fast heuristic

julia> aq, bq = cover_min(AbsLog{2}(), A);   # AbsLog{2}-minimal, native

julia> a * b'
2×3 Matrix{Float64}:
 2.16541  2.03444  3.0
 6.0      5.63709  8.31251

julia> aq * bq'
2×3 Matrix{Float64}:
 2.21042  2.0      3.0
 6.0      5.42884  8.14325

julia> round(cover_objective(AbsLog{2}(), a, b, A); digits=6)
1.146646

julia> round(cover_objective(AbsLog{2}(), aq, bq, A); digits=6)
1.141281

The native solver is near-exact (relative objective excess typically a few $\times 10^{-7}$, growing slowly with problem size) and orders of magnitude faster than a general-purpose convex solver. The other penalties — AbsLog{1} (a linear program) and the non-convex AbsLinear variants — are solved through JuMP and are loaded on demand as a package extension:

julia> using MatrixCovers, JuMP, HiGHS   # HiGHS for the AbsLog penalties

julia> S = [4 1 0; 1 1 5; 0 5 2];      # symmetric

julia> round.(symcover_min(AbsLog{1}(), S); digits=6)   # L1-minimal symmetric hard cover
3-element Vector{Float64}:
 2.0
 1.0
 5.0

julia> A = [1 2 3; 6 5 4];

julia> a, b = cover_min(AbsLog{1}(), A);   # L1-minimal general hard cover

julia> round.(a * b'; digits=6)            # tight on four of the six entries
2×3 Matrix{Float64}:
 2.4  2.0  3.0
 6.0  5.0  7.5

The solver returns values good to roughly solver tolerance, so these examples round before displaying.

The soft *_min solvers divide along the same line, but not at the same place: soft_symcover_min and soft_cover_min solve AbsLog{2} natively and reach for JuMP with Ipopt only for the AbsLinear penalties. They do not accept AbsLog{1}; the soft AbsLog{1} covers are available through soft_symcover and soft_cover, which are native.

Uniqueness

The AbsLog{2}() penalty generally has a unique minimum, with one exception: row/column scaling a → γ*a, b → b/γ does not affect C and is thus invisible to the objective function. For non-symmetric (i.e., not symcover) problems, the scaling of each is pinned by the balance convention ∑ n_i log a[i] = ∑ m_j log b[j], where n_i, m_j are the nonzero counts of row i and column j, respectively. The gauge freedom, and hence this convention, acts independently on each connected component of the bipartite support graph of A (rows and columns as vertices, stored nonzeros as edges), so the sums are taken within each component separately. This convention is not scale-invariant but has no impact on the cover itself.

Other penalties may be more degenerate. The AbsLog{1}() penalty is identical over a whole face of the feasible polytope, and its members are genuinely different covers — the products a[i]*b[j] differ — that merely happen to score the same objective. To make the result deterministic, we select the one that additionally minimizes the AbsLog{2} objective.

AbsLinear penalties typically have isolated minima, so are not as degenerate as AbsLog{1}(), but these minima occur in separate basins. There is no guarantee of global optimality.

Starting points: initialize and refine

For objectives with multiple minima, the solver starts from a specified point and descends. At a lower level, this package's interface is organized in three layers:

  • Initializers name the starting points. initialize_symcover and initialize_cover take a strategy:geomean, :leaveout, :diagfeasible, or :hardcover — and return that point. Each is a property of A alone; no objective is involved, so an initializer takes no penalty. A second keyword, feasible, says how the point is brought up to covering A: :inflate (the default) scales it bodily by one common factor, :boost raises only the rows touching a violated entry, and :none leaves it as it is. The hard-cover solvers need a cover, so they take one of the first two; the soft covers want :none, since forcing the geometric mean to cover A would spoil the very property that makes it the soft AbsLog{2} optimum.

    The two feasible routes land on the boundary at different points, hence in different basins — which is why the choice is a named part of the start rather than an internal detail. The heuristic cover is itself a composition of these: the geometric mean, boosted, then tightened.

  • Refiners improve a starting point in place, and are the !-suffixed forms of the solvers: symcover_min!, cover_min!, soft_symcover!, soft_cover!, soft_symcover_min!, and soft_cover_min! validate the start, then optimize from it. Which basin they reach is the caller's choice, by construction, and supplying the start is the caller's job. The hard refiners require a start that covers A; the soft ones do not, since their objective constrains nothing — build theirs with feasible=:none.

  • Solvers bundle the two. symcover_min, cover_min, soft_symcover, soft_cover, soft_symcover_min, and soft_cover_min refine every start on a menu (the strategies keyword, or the multistart's own list) and return the best cover by cover_objective, so their result depends on A and not on an initialization the caller never chose.

That is the rule for the whole grid: the plain form owns the menu, so its result is a property of A; the ! form refines the one start you give it, so its result is a property of A and that start. symcover! and cover! are the exception that proves it — they are initializers, not refiners, and construct their cover from scratch rather than reading the vector passed in.

For finer control, you can run these manually:

julia> using MatrixCovers, JuMP, Ipopt   # Ipopt for the AbsLinear penalties

julia> S = [4 1 0; 1 1 5; 0 5 2];

julia> round.(symcover_min(AbsLinear{2}(), S); digits=6)   # multistart over the whole menu
3-element Vector{Float64}:
 2.0
 1.0
 5.0

julia> round.(symcover_min(AbsLinear{2}(), S; strategies=(:geomean,)); digits=6)   # or commit to one start
3-element Vector{Float64}:
 2.0
 1.0
 5.0

julia> a0 = initialize_symcover(S; strategy=:geomean);     # or drive it yourself

julia> symcover_min!(AbsLinear{2}(), a0, S);

julia> round.(a0; digits=6)
3-element Vector{Float64}:
 2.0
 1.0
 5.0

The same menu supplies the starting points of the soft_symcover and soft_cover multistarts, adding (by default) a few randomized perturbations of a base point up to a user-controllable number of starts.

For the convex AbsLog penalties the start cannot change the result, and the refiners accept one only so that the two families share an interface.

Consuming one factor alone: gauges and Gram covers

For asymmetric covers, only the products a[i]*b[j] are determined by the problem; the split into the pair is fixed by the balance convention described under Uniqueness. That convention makes the split deterministic, but it is still a convention, and it is not covariant under one-sided rescaling: if a*b' covers A, then a*(D*b)' covers A*D — but the balanced representative of the rescaled problem is (γ*a, D*b/γ) for a per-component constant γ ≠ 1 that depends on D.

This has important implications for applications where you might estimate covers by composition. Let's take the example of the Levenberg-Marquardt algorithm, where you form products J'*J of the Jacobian matrix J. Suppose a*b' is a cover of J: then (a'*a) * b * b' is a cover of J'*J (note a'*a is a scalar). The tightness of this cover for J'*J depends on the convention used to balance a and b.

To do better, this package provides the Gram cover s = gramcover(a, b, J[, W]), a symmetric cover of J'*W*J built from the asymmetric cover of J. Built this way, s covaries with right-scaling of J.

julia> using MatrixCovers, LinearAlgebra

julia> J = [1.0 2; 3 4; 5 6];

julia> D = Diagonal([100.0, 1.0]);        # reparametrize the second frame

julia> a1, b1 = cover(J); a2, b2 = cover(J * D);   # `cover` is covariant because J has no zeros; `cover_min` is safer

julia> r = b2 ./ (D.diag .* b1); all(x -> x ≈ first(r), r)
true

julia> first(r) ≈ 1                       # bare-factor consumers see this constant
false

julia> s1 = gramcover(a1, b1, J); s2 = gramcover(a2, b2, J * D);

julia> s2 ≈ D.diag .* s1                  # the Gram cover co-varies exactly
true

Worked example: roundoff in A \ b

Because a cover names each variable's natural scale, it also says how to measure a solution in units that do not depend on how the problem was parameterized.

Solving x = A \ b is contravariant: rescaling A → D*A*D and b → D*b sends x → x ./ d, while the cover is covariant, a → d .* a. The products x .* a are therefore unchanged, and ∑ᵢ |xᵢ * aᵢ| is a measure of the solution's size that is the same in every frame.

That quantity can be estimated from the magnitudes of A and b alone, without forming x at all:

julia> using MatrixCovers, LinearAlgebra

julia> A = [1e6 1e3; 1e3 4.0];

julia> b = [1.5e3, 6.0];

julia> a = symcover(A);

julia> round.(a; digits=6)
2-element Vector{Float64}:
 1000.0
    2.0

julia> mag = sum(abs(bi / ai) for (bi, ai) in zip(b, a))
4.5

The cover reports natural scales of 1000 and 2, and mag estimates the size of the solution measured against them — here within a factor of 1.5 of the truth:

julia> x = A \ b;

julia> sum(abs.(x .* a))
3.0

Both numbers are scale-invariant, so the estimate is unchanged by any diagonal rescaling of the problem:

julia> d = [0.05, 3.0];

julia> Ad, bd = d .* A .* d', d .* b;

julia> ad = symcover(Ad);

julia> sum(abs(bi / ai) for (bi, ai) in zip(bd, ad))
4.5

This makes eps(mag) a scale-invariant estimate of the roundoff floor of the sum. For a well-conditioned A, the error of the Float64 solve meets that floor:

julia> xbig = big.(A) \ big.(b);

julia> abs(sum(abs.(x .* a)) - sum(abs.(Float64.(xbig) .* a))) <= 2 * eps(mag)
true

The estimate is built from magnitudes only, so it knows nothing about the conditioning of A or about cancellation during the solve. When A is ill-conditioned the true error sits far above the floor:

julia> Aill = [1.0 -0.9999; -0.9999 1.0];

julia> bill = [0.75, 7.0];

julia> aill = symcover(Aill);

julia> magill = sum(abs(bi / ai) for (bi, ai) in zip(bill, aill));

julia> xill = Aill \ bill;

julia> xbigill = big.(Aill) \ big.(bill);

julia> err = abs(sum(abs.(xill .* aill)) - sum(abs.(Float64.(xbigill) .* aill)));

julia> err > 1e6 * eps(magill)
true

Folding in the condition number of the normalized matrix A ./ (a .* a') — itself scale-invariant, since normalizing cancels the frame — restores a usable bound:

julia> κ = cond(Aill ./ (aill .* aill'));

julia> err <= 1e3 * eps(κ * magill)
true

Index of available tools

Reference documentation

MatrixCovers.AbsLinearType
AbsLinear{p}

Penalty type for φ(r) = |1 - r|^p. Unlike AbsLog, this penalty is continuous at r = 0 (φ(0) = 1), so zero entries in A naturally contribute a constant penalty.

The resulting optimization problems are non-convex and may have multiple local minima.

source
MatrixCovers.AbsLogType
AbsLog{p}

Penalty type for

φ(r) = |log(r)|^p  if r > 0
       0           if r = 0

The discontinuity at r=0 prevents zero entries in A from sending the objective value to infinity.

This leads to convex optimization problems in log space. AbsLog{1} typically has a flat minimum-basin in which members of an entire family of solutions are equally good. AbsLog{2}, except in degenerate cases like [0 1; 1 0], has a unique minimum.

See also: AbsLinear.

source
MatrixCovers.AbstractCoverPenaltyType
AbstractCoverPenalty <: Function

Supertype of the penalty functions ϕ that score a cover, and the type of the first argument of most of this package's API. The built-in subtypes are AbsLog and AbsLinear.

A penalty is a function of the single ratio r = |A[i,j]| / (a[i]*b[j]), and cover_objective sums it over the entries of A. Because ϕ sees only that ratio, and every diagonal rescaling of A leaves it fixed, any objective built from a penalty is automatically scale-invariant.

Extending

A subtype must be callable on a nonnegative real:

(::MyPenalty)(r::Real)

r ranges over [0, Inf]. Both endpoints occur and neither may error: r = 0 whenever A[i,j] is zero, and cover_objective passes typemax for an entry left uncovered by a zero scale. Penalties are conventionally singleton structs.

That call is the whole contract, and it buys exactly one thing: cover_objective works for any subtype. The solvers do not. Every solver in this package dispatches on a concrete built-in penalty — AbsLog{2} is solved natively, the AbsLinear penalties through JuMP — so a custom subtype passed to symcover_min, soft_symcover, or any other solver raises a MethodError. Scoring covers with your own penalty is supported; minimizing it is not.

source
MatrixCovers.SupportComponentsType
SupportComponents

Connected components of a matrix's bipartite support graph, as returned by support_components: one vertex per row and one per column, one edge per stored nonzero.

Component ids run 1:ncomponents(sc). A row or column of empty support belongs to no component and reports 0. Query an id with rowcomponent or colcomponent, which take the matrix's own indices, so offset axes need no special case at the call site.

The gauge orbit of an asymmetric cover has one dimension per component: the rescaling a -> γ*a, b -> b/γ acts independently on each, because no product a[i]*b[j] spans two components. Any convention pinning the split between a and b must therefore be imposed per component; a single global constraint leaves ncomponents(sc) - 1 directions unpinned.

Constructing this once and passing it to gramcover! lets a caller that already knows the component structure — or that obtains it by some route other than traversing a matrix — skip the traversal entirely.

source
MatrixCovers.colcomponentMethod
colcomponent(sc::SupportComponents, j) -> Int

Component id of column j, or 0 if that column has empty support. j is the matrix's own column index.

source
MatrixCovers.cover!Method
a, b = cover!(ϕ, a, b, A; maxiter=3)
a, b = cover!(a, b, A; maxiter=3)

Mutating counterpart of cover: writes the hard cover into a and b and returns them, rather than allocating new vectors. eachindex(a) must match axes(A, 1) and eachindex(b) must match axes(A, 2). ϕ has the same meaning as in cover, and is likewise ignored by the current heuristic covers.

See also: cover.

source
MatrixCovers.coverMethod
a, b = cover(ϕ, A; maxiter=3)
a, b = cover(A; maxiter=3)

Given a matrix A, return vectors a and b such that a[i] * b[j] >= abs(A[i, j]) for all i, j. The initialization is the AbsLog{2} unconstrained minimum (geometric mean of nonzero entries per row/column). It is then boosted to feasibility by a greedy max-deficit rule (the most-violated entries are covered first), and maxiter tightening iterations are applied.

Only the products a[i] * b[j] are determined by the problem: a -> c*a, b -> b/c leaves every one of them unchanged. The split is fixed by the balance convention ∑ nzaᵢ log a[i] = ∑ nzbⱼ log b[j] (nzaᵢ, nzbⱼ = nonzero counts of row i, column j), imposed within each connected component of the support (the gauge acts independently on each), as it is throughout the package; see cover_min.

ϕ names the penalty the caller would like the cover to do well on. Currently, the heuristic covers ignore ϕ, although this behavior may change in future versions. For a cover that provably minimizes a given ϕ, use cover_min.

See also: cover!, cover_min, symcover.

Examples

julia> A = [1 2 3; 6 5 4];

julia> a, b = cover(A)
([1.2544610775677627, 3.475905976749231], [1.7261686708831454, 1.621762761307448, 2.3914651906272066])

julia> a * b'
2×3 Matrix{Float64}:
 2.16541  2.03444  3.0
 6.0      5.63709  8.31251
source
MatrixCovers.cover_minFunction
a, b = cover_min(ϕ, A)
a, b = cover_min(A)

Return the ϕ-minimal asymmetric hard cover of A: the vectors a, b minimizing ∑_{i,j} ϕ(|A[i,j]|/(a[i]*b[j])) subject to a[i]*b[j] >= |A[i,j]| for every nonzero entry of A. Only the products a[i]*b[j] are determined by the problem: the gauge a -> γ*a, b -> b/γ leaves every one of them unchanged, and acts independently on each connected component of the bipartite support graph of A (rows and columns as vertices, stored nonzeros as edges), since no product spans two components. The split is pinned by imposing, within each component, the balance convention ∑ nzaᵢ log a[i] = ∑ nzbⱼ log b[j] (nzaᵢ, nzbⱼ = nonzero counts of row i, column j, summed over that component's rows/columns) — so the result is a deterministic function of the support and the products, and block-diagonal assembly commutes with the split. The no-ϕ form defaults to AbsLog{2}(), matching cover.

Supported ϕ values:

  • AbsLog{2}(): solved natively (no external solver). Accepts keyword arguments κs (the penalty-continuation schedule, default (1e2, 1e4, 1e6, 1e8)), maxiter (Newton steps per stage, default 40), and linsolve (the inner linear solve: :auto/:dense use a dense factorization of the reweighted normal equations; :lsqr uses matrix-free LSQR (per-iteration cost O(nnz), intended for large sparse supports)). linsolve defaults to :auto for dense A; the SparseMatrixCSC sparse method (from the SparseArrays extension) defaults to :lsqr instead, since a dense factorization of the reweighted normal equations is the wrong solve when nnz ≪ n².
  • AbsLog{1}(): requires JuMP and HiGHS.
  • AbsLinear{1}(), AbsLinear{2}(): requires JuMP and Ipopt. These objectives are non-convex, so the solver returns the minimum of the basin it starts in. Rather than commit to one start, these methods refine each of strategies — the initialize_cover menu, by default (:hardcover, :geomean) — and return the best cover found, at a cost of one solve per start. The result is the best local minimum on that menu: the multistart is a hedge against a poor basin, not a certificate of global optimality.

The AbsLog penalties are convex in the log-scales, so for them the minimum value is unique and no such hedge is needed. AbsLog{2} has a unique minimizer too. AbsLog{1} does not: its optimum is a whole face of the feasible polytope, whose members are genuinely different covers that happen to score alike. The one returned is the member of that face minimizing the AbsLog{2} objective.

Note

Even the native solver is more expensive than the cover heuristic.

See also: symcover_min, cover, cover_min!.

source
MatrixCovers.cover_min!Function
a, b = cover_min!(ϕ, a, b, A; kwargs...)
a, b = cover_min!(a, b, A; kwargs...)

Refine the starting cover (a, b) into the ϕ-minimal asymmetric hard cover of A, in place. This is the asymmetric counterpart of symcover_min!, and carries the same contract on the start: strict positivity on every supported row and column, coverage of A to within roundoff, and inert scales on the unsupported rows and columns. The no-ϕ form defaults to AbsLog{2}(), matching cover_min, whose keyword arguments and supported ϕ values these methods share.

The product a[i]*b[j] is unchanged by a -> c*a, b -> b/c, so the start is read only up to that gauge: (a, b) and (2a, b/2) give the same result, and the result itself is pinned to the balance convention of cover_min.

See also: initialize_cover, cover_min, symcover_min!.

source
MatrixCovers.cover_objectiveMethod
cover_objective(ϕ, a, b, A)
cover_objective(ϕ, a, A)

Compute the cover objective ∑_{i,j} ϕ(|A[i,j]| / (a[i] * b[j])) for the given penalty function ϕ. The two-argument form is for symmetric matrices where the cover is a*a'.

The sum runs over the full grid in both forms, so in the symmetric form each off-diagonal pair contributes twice and each diagonal entry once. This weighting is what the sym solvers minimize, so the score reported here is the quantity they optimized; code that reads a symmetric matrix through foreach_support_sym, which reports each pair once, must apply the factor of 2 itself to match.

Zero entries of A are handled according to ϕ:

  • AbsLog{p}: zero entries contribute 0 (φ(0) = 0 by convention).
  • AbsLinear{p}: zero entries contribute 1 (φ(0) = |1-0|^p = 1).

eachindex(a) must match axes(A, 1) and eachindex(b) must match axes(A, 2).

A is read through foreach_support, so the cost is proportional to the support rather than to length(A) for a storage type that specializes it.

See also:

source
MatrixCovers.foreach_supportMethod
foreach_support(f, A)

Call f(i, j, v) once for every entry of A whose magnitude v = abs(A[i, j]) is nonzero, and return nothing. Entries that are zero are skipped, so f never sees v == 0. The order is whatever suits A's storage and is not part of the contract; i and j are A's own indices, so offset axes are honored.

This is the hook through which cover algorithms read a matrix. Specializing it is what lets a storage type be covered in time proportional to its support rather than to length(A) — the package's own SparseMatrixCSC methods, which walk nzrange instead of the full grid, are the model.

Extending

To support a new matrix type, define

MatrixCovers.foreach_support(f, A::MyMatrix)

which must call f(i, j, abs(A[i, j])) exactly once for each (i, j) with abs(A[i, j]) != 0, must not call f for any other entry (a stored zero is still a zero), and must return nothing. Emitting an entry twice double-counts it in the objective; omitting one silently drops a constraint, yielding a "cover" that does not cover. Whatever f returns is ignored, so a traversal runs to completion and must not be stopped early on the strength of it.

See also: foreach_support_sym.

source
MatrixCovers.foreach_support_symMethod
foreach_support_sym(f, A)

Symmetric counterpart of foreach_support: call f(i, j, v) once per unordered index pair rather than once per entry, and return nothing. Pairs are reported in the canonical orientation i <= j, the diagonal included, with v = abs(A[i, j]); zero pairs are skipped. A must be square, or a DimensionMismatch is thrown.

abs.(A) must also be symmetric, not merely square. That is what makes reporting one member of each pair sufficient: a symmetric cover constrains a[i]*a[j] by a single magnitude, so visiting (j, i) as well would only duplicate it. Note the predicate is on the magnitudes, so a complex Hermitian satisfies it — |A[i,j]| == |conj(A[j,i])|.

This traversal does not check the precondition; the public sym entry points do, before they call it (MatrixCovers.require_abs_symmetric).

Objective weighting

Because each pair is reported once, a caller accumulating a cover objective must supply the multiplicity itself: w = (i == j) ? 1 : 2. That reproduces the ∑_{i,j} convention of cover_objective, which runs over the full grid and so counts each off-diagonal pair twice and each diagonal entry once. The constraint set needs no such correction — a[i]*a[j] >= |A[i,j]| and its transpose are the same constraint, so imposing it on the i <= j triangle alone is equivalent to imposing it everywhere. Every solver in this package minimizes the full-grid objective, so a cover's reported score and the quantity that was minimized agree.

Extending

To support a new matrix type, define

MatrixCovers.foreach_support_sym(f, A::MyMatrix)

which must call f(i, j, v) exactly once for each pair i <= j with v = abs(A[i, j]) != 0, must not call f for zero pairs, and must return nothing. Whatever f returns is ignored, so a traversal runs to completion and must not be stopped early on the strength of it. Reporting the same pair in both orientations double-counts it: the off-diagonal weight of 2 is the caller's to apply, per Objective weighting above, so a pair emitted twice is weighted 4. A type whose storage is triangular (Symmetric{<:Any,<:SparseMatrixCSC} in this package's own extension) must map stored (i, j) with i > j back to (j, i) rather than emit it as found.

See also: foreach_support.

source
MatrixCovers.gramcover!Method
s = gramcover!(s, a, b, A)
s = gramcover!(s, a, b, A, w::AbstractVector)
s = gramcover!(s, a, b, A, W::AbstractMatrix)
s = gramcover!(s, a, b, sc::SupportComponents)
s = gramcover!(s, a, b, sc::SupportComponents, w::AbstractVector)
s = gramcover!(s, a, b, sc::SupportComponents, W::AbstractMatrix)

Mutating counterpart of gramcover: writes the symmetric cover of the (weighted) Gram matrix into s and returns it, rather than allocating a new vector. eachindex(s) must match axes(A, 2)sc.colax for the SupportComponents forms — in addition to the axis requirements gramcover places on a, b, and w/W.

See also: gramcover.

source
MatrixCovers.gramcoverMethod
s = gramcover(a, b, A)
s = gramcover(a, b, A, w::AbstractVector)
s = gramcover(a, b, A, W::AbstractMatrix)
s = gramcover(a, b, sc::SupportComponents)
s = gramcover(a, b, sc::SupportComponents, w::AbstractVector)
s = gramcover(a, b, sc::SupportComponents, W::AbstractMatrix)

Given an asymmetric cover a[i]*b[j] >= abs(A[i,j]) — from cover, cover_min, or any other solver producing such a pair — return a symmetric cover s of a (weighted) Gram matrix of A, without forming that Gram matrix: s[j]*s[k] >= abs(G[j,k]) for every j, k, where G = A'*A for the two-argument form, G = A'*Diagonal(w)*A for the vector-weighted form, and G = A'*W*A for the general form. Only abs.(W) enters the bound, so W need not be positive definite, positive semidefinite, or even symmetric; passing W::Diagonal is equivalent to passing W.diag as w.

(a, b) covering A is a precondition, not verified here — use iscover(a, b, A) to check it beforehand. gramcover composes with any asymmetric cover this package produces: cover, cover_min, soft_cover, and their mutating and _min forms.

Only the connected components of A's support enter the two- and vector-weighted forms, so a caller holding an support_components(A) result may pass it in place of A; the matrix forms are exactly that call followed by the sc form. sc.rowax and sc.colax then play the roles of axes(A, 1) and axes(A, 2) in the axis requirements on a, b, and w/W.

Extended help

For G[j,k] = Σ_{i,i'} A[i,j]*W[i,i']*A[i',k], the triangle inequality against a[i]*b[j] >= abs(A[i,j]) gives abs(G[j,k]) <= (Σ_{i,i'} a[i]*abs(W[i,i'])*a[i']) * b[j]*b[k]. Partitioning the rows and columns of A into the connected components of its bipartite support graph, columns in different components share no supported row, so for W diagonal the sum needed is exactly the one over the rows of j's own component:

s[j] = sqrt(Σ_{i ∈ rows(comp(j))} abs(w[i])*a[i]^2) * b[j]

(the unweighted form is this with w[i] = 1). A nonzero off-diagonal W[i,i'] can couple rows from two different components; components joined by a chain of such couplings form a group. Within a group, writing M[p,q] = Σ_{i ∈ rows(p), i' ∈ rows(q)} a[i]*abs(W[i,i'])*a[i'] for the block sum over components p and q, abs(G[j,k]) <= M[p,q]*b[j]*b[k] for j ∈ p and k ∈ q. Take M symmetric, as it is whenever abs.(W) is; the general case needs one substitution, made in the remark below. Then

s[j] = sqrt(Σ_q M[p,q] * sqrt(M[p,p]/M[q,q])) * b[j],  j ∈ p

meets every one of those bounds: the q term of the sum for s[j] and the p term of the one for s[k] already multiply to M[p,q]*M[q,p] = M[p,q]^2, and no term is negative. For a component that no W entry couples to another, the group is a single component and this reduces to the diagonal-W formula above. G[j,k] is exactly zero across distinct groups, and columns with no support get s[j] = 0.

Rescaling a -> γ*a, b -> b/γ within any support component — independently per component — leaves s unchanged. Unlike b alone, s is therefore safe to use as an absolute scale, e.g. a Levenberg-Marquardt damping term λ*Diagonal(s.^2): a caller should maintain the dimensionless λ against s, not against a quantity that depends on which gauge the cover solver happened to return. What pins the relative scale of coupled components is the ratio M[p,p]/M[q,q], extended to a component whose own block vanishes (abs.(W) zero throughout it, though W couples it to a sibling) by propagating along the coupling. A group in which every component's own block vanishes is the one exception, and it is a genuine degeneracy rather than a shortcoming of the formula: the gauge acts on the surviving off-diagonal data as M[p,q] -> γ[p]*γ[q]*M[p,q], which fixes each product s[j]*s[k] across two components but nothing about how it divides between them. Such a group falls back to the uniform total sqrt(Σ_{p,q} M[p,q]), which covers, but there s depends on the factorization and not on the products alone.

With more than one component and no coupling between them, s[j] <= norm(a)*b[j], strictly tighter whenever another component carries weight — the naive global bound obtained by ignoring the block structure entirely. Coupled components admit no such uniform comparison: fixing the gauge redistributes tightness among them, resulting in an s that depends only on the products at the cost of individual entries that a gauge-dependent global sum can beat.

When abs.(W) is not symmetric, neither is G, and since s[j]*s[k] is a single number bounding both abs(G[j,k]) and abs(G[k,j]), M[p,q] is replaced throughout by max(M[p,q], M[q,p]); nothing else changes, the gauge included, since that replacement is itself symmetric.

When a positive-semidefinite W is available only as an operator — W[i,i] readable, W[i,i'] for i != i' not — abs(W[i,i']) <= sqrt(W[i,i]*W[i',i']) yields the looser diagonal-only bound s[j] = (Σ_{i ∈ rows(comp(j))} sqrt(W[i,i])*a[i]) * b[j], computable by hand from diag(W). The methods here always compute the tighter entrywise form above, which requires W's entries.

The accumulation feeding each sqrt is inflated to guarantee coverage despite naive-summation roundoff, without ever forming G to check it.

See also: gramcover!, symcover, cover, iscover.

Examples

julia> J = [4 1; 1 3];

julia> a, b = cover(J);

julia> s = gramcover(a, b, J);

julia> all(s * s' .>= abs.(J' * J))
true

julia> w = [1.0, -2.0];

julia> sw = gramcover(a, b, J, w);   # covers J'*Diagonal(w)*J

julia> all(sw * sw' .>= abs.(J' * (w .* J)))
true
source
MatrixCovers.initialize_cover!Method
a, b = initialize_cover!(a, b, A; strategy=:hardcover, feasible=:inflate, kwargs...)

Mutating counterpart of initialize_cover: writes the starting cover into a and b and returns them, rather than allocating new vectors. eachindex(a) must match axes(A, 1) and eachindex(b) must match axes(A, 2).

See also: initialize_cover.

source
MatrixCovers.initialize_coverMethod
a, b = initialize_cover(A; strategy=:hardcover, feasible=:inflate, kwargs...)

Build a starting point for the cover of A, as consumed by cover_min and by the soft_cover multistart. This is the asymmetric analog of initialize_symcover, and takes the same feasible keyword, under which the result covers A as a[i]*b[j] >= abs(A[i,j]).

Two of the strategies carry over: :hardcover (the tightened hard cover of cover, forwarding maxiter) and :geomean (the AbsLog{2} unconstrained minimum). :leaveout and :diagfeasible have no asymmetric formulation and raise an ArgumentError, as does any unrecognized strategy or feasible.

Under every feasible setting the result is strictly positive on every supported row and column and exactly zero on the unsupported ones, and the split between a and b is fixed by the balance convention ∑ nzaᵢ log a[i] = ∑ nzbⱼ log b[j], imposed within each connected component of the support, that every asymmetric cover in the package uses (see cover_min).

See also: initialize_cover!, initialize_symcover, cover, cover_min.

source
MatrixCovers.initialize_symcoverMethod
a = initialize_symcover(A; strategy=:hardcover, feasible=:inflate, kwargs...)

Build a starting point for the symmetric cover of A, as consumed by symcover_min and by the soft_symcover multistart.

No penalty is taken: every strategy below is a property of A alone, so the starting point does not depend on the objective it will be refined against.

strategy names the point:

  • :geomean — the geometric mean of the nonzero entries of each row, and not a cover. It minimizes the soft AbsLog{2} objective exactly when every entry of A is nonzero; on a sparse support it approximates that minimum, which soft_symcover_min(AbsLog{2}(), A) returns exactly.
  • :leaveout — the geometric mean recomputed with the most-underweighted support entry dropped, which lands in the basin that treats that entry as effectively zero. Raises an ArgumentError when no entry can be dropped (empty support, or dropping it would empty a row). Not a cover.
  • :diagfeasible — a cover grown from the diagonal by nearest-neighbor propagation. Feasible by construction.
  • :hardcover — the tightened hard cover of symcover, which is :geomean boosted to feasibility and then tightened. Forwards maxiter to the tightening pass. Feasible by construction, so feasible has no effect on it.

feasible names how the point is brought up to covering A — that is, to a[i]*a[j] >= abs(A[i,j]), up to the roundoff of the log-domain arithmetic:

  • :inflate (the default) multiplies every scale by the smallest common factor that achieves coverage, moving the point bodily and leaving its shape intact.
  • :boost raises only the rows that touch a violated entry, so it changes the shape of the point. This is the route symcover itself takes.
  • :none returns the strategy's own point, with no coverage guarantee. This is what the soft covers want: forcing the geometric mean to cover A would destroy the very property that makes it the soft AbsLog{2} optimum.

The two feasible routes land on the boundary at different points, and so in different basins of the non-convex AbsLinear objectives — which is exactly why a menu of starts is worth having, and why the choice is exposed rather than fixed.

Under every setting the result is strictly positive on every row that carries support and exactly zero on every row that carries none.

An unrecognized strategy or feasible raises an ArgumentError.

See also: initialize_symcover!, initialize_cover, symcover, symcover_min.

source
MatrixCovers.iscoverMethod
iscover(a, b, A; rtol=0, atol=0)
iscover(a, A; rtol=0, atol=0)

Test whether a and b cover A, that is, whether a[i]*b[j] >= abs(A[i,j]) for every entry. The two-argument form tests the symmetric cover a*a', and requires A to be square.

rtol and atol allow for small violations, testing

a[i]*b[j] >= abs(A[i,j])*(1 - rtol) - atol

The default for both tolerances is zero (no slack, test that the cover condition holds); note that atol != 0 breaks scale-invariance.

a and b must be nonnegative; a negative scale raises an ArgumentError. Zero is allowed, and is what every solver here returns for a row or column with no support.

eachindex(a) must match axes(A, 1) and eachindex(b) must match axes(A, 2).

See also: cover_objective, cover, symcover.

Examples

julia> A = [1.0 2.0; 3.0 4.0];

julia> a, b = cover(A);

julia> iscover(a, b, A; rtol=8eps())
true

julia> iscover([1.0, 1.0], [1.0, 1.0], A)   # a*b' = ones, which does not reach A[2,2]
false
source
MatrixCovers.ncomponentsMethod
ncomponents(sc::SupportComponents) -> Int

Number of connected components, so component ids run 1:ncomponents(sc).

source
MatrixCovers.rowcomponentMethod
rowcomponent(sc::SupportComponents, i) -> Int

Component id of row i, or 0 if that row has empty support. i is the matrix's own row index.

source
MatrixCovers.scalar_typeMethod
MatrixCovers.scalar_type(T)

The plain floating-point type underlying the element type T, with any units removed. cover_objective sums the ratios |A[i,j]| / (a[i]*b[j]), which are dimensionless because a cover requires unit(A[i,j]) == unit(a[i])*unit(b[j]), so the score is an ordinary number whatever the operands carry.

This cannot be expressed as float(real(T)): a matrix whose entries carry different units has an abstract eltype, for which real and oneunit are undefined. A unit-carrying element type therefore needs its own method.

source
MatrixCovers.soft_cover!Function
a, b = soft_cover!(ϕ, a, b, A; maxiter=...)
a, b = soft_cover!(a, b, A; maxiter=...)

Refine the starting point (a, b) into a soft cover of A, in place, and return it. This is the asymmetric counterpart of soft_symcover! and the refiner half of soft_cover, carrying the same contract on the start: finite and strictly positive on every supported row and column, inert (and zero on output) elsewhere, and under no obligation to cover A. Build one with initialize_cover and feasible=:none. The no-ϕ form defaults to AbsLinear{2}(), matching soft_cover.

The product a[i]*b[j] is unchanged by a -> c*a, b -> b/c, so the start is read only up to that gauge: (a, b) and (2a, b/2) give the same result. The result itself is pinned to the balance convention of cover_min, as every asymmetric cover in this package is.

See also: soft_cover, soft_cover_min!, initialize_cover, soft_symcover!.

source
MatrixCovers.soft_coverMethod
a, b = soft_cover(ϕ, A; maxiter=200, starts=4, σ=2.0, rng=MersenneTwister(0))
a, b = soft_cover(A; maxiter=200, starts=4, σ=2.0, rng=MersenneTwister(0))

Given a matrix A, return vectors a and b approximately minimizing the soft-cover objective ∑_{i,j} ϕ(|A[i,j]| / (a[i]*b[j])). This is the asymmetric analog of soft_symcover.

Unlike cover, there is no hard coverage constraint: a[i]*b[j] may be less than |A[i,j]|, with violations penalized by ϕ.

Supported penalty functions:

  • AbsLog{2}(): convex, and returns its exact unconstrained minimum from a single linear solve. Identical to soft_cover_min(AbsLog{2}(), A) — with one minimizer there is nothing for a heuristic and a minimizer to disagree about.

  • AbsLog{1}(): initializes from the AbsLog{2}() minimum, then refines by alternating weighted-median row and column updates, reaching a deterministic and scale-covariant fixed point. As in soft_symcover, that point is not in general a minimizer: each half-sweep minimizes exactly, but the objective's nonsmoothness couples a[i] with b[j], so the descent can settle where no such sweep improves and the objective still sits materially above its minimum. soft_cover_min does not yet offer an exact AbsLog{1} alternative.

  • AbsLinear{2}() (default): in the inverse-scale variables u = 1 ./ a, v = 1 ./ b, the objective ∑_{i,j∈S} (1 - |A[i,j]| u[i] v[j])² (sum over the nonzero support S) is biconvex, so alternating least squares with the closed-form half-sweeps

    u[i] = ∑_j |A[i,j]| v[j] / ∑_j (|A[i,j]| v[j])²   (dually for v[j])

    is monotone, stopping when the relative objective decrease falls to rounding level for the element type, or after maxiter sweeps.

  • AbsLinear{1}(): initializes from the AbsLinear{2}() result, then refines by alternating weighted-median updates — each row/column block is minimized exactly, so the descent is monotone. Its flat basins are broken by a deterministic lower-median tie-break, giving a scale-covariant representative.

Rows or columns of A that are entirely zero receive scale 0. As with cover, only the products a[i] * b[j] are determined by the problem; the split is fixed by the balance convention ∑ nzaᵢ log a[i] = ∑ nzbⱼ log b[j], imposed within each connected component of the support (the gauge acts independently on each).

The objective is non-convex, so starts starting points are tried and the lowest-objective result kept: the geometric mean boosted until it covers A, the tightened hard cover cover, and — for the remaining starts — multiplicative log-normal perturbations a .* exp.(σ .* ξ), with spread σ, of that boosted point, ξ drawn from rng. Every start co-varies with an independent row/column rescaling of A and the objective is scale-invariant, so the selection is scale-covariant. The default rng is a fresh MersenneTwister(0) per call, making repeated calls (and the two frames of a covariance check) agree; pass your own rng for reproducibility you control, since default RNG streams are not stable across Julia versions. sigma is accepted as an ASCII alias for σ.

See also: cover, soft_symcover, cover_objective.

Examples

julia> A = [1 2 3; 6 5 4];

julia> a, b = soft_cover(A);

julia> a * b'
2×3 Matrix{Float64}:
 1.93288  1.97239  2.50673
 4.97144  5.07307  6.44741
source
MatrixCovers.soft_cover_minFunction
a, b = soft_cover_min(ϕ, A)
a, b = soft_cover_min(A)

Return the ϕ-minimal asymmetric soft cover of A: minimizes ∑_{i,j} ϕ(|A[i,j]|/(a[i]*b[j])) with no coverage constraints. This is the asymmetric analog of soft_symcover_min. The no-ϕ form defaults to AbsLinear{2}(), matching soft_cover.

The row/column scales are pinned to the balance convention ∑ nzaᵢ log a[i] = ∑ nzbⱼ log b[j] (nzaᵢ, nzbⱼ = nonzero counts of row i, column j), imposed within each connected component of the support, as in cover_min: the objective depends on a and b only through the products a[i]*b[j], so without a convention the split between them would be arbitrary.

Supported ϕ values and required extensions:

  • AbsLog{2}(): solved natively (no external solver) — the same analytic geometric-mean minimum cover computes as its initial point. Convex, so the minimizer is unique.
  • AbsLinear{1}(), AbsLinear{2}(): requires JuMP and Ipopt. These objectives are non-convex, so the solver returns the minimum of the basin it starts in. Rather than commit to one start, these methods refine each of strategies — the initialize_cover menu, by default (:hardcover, :geomean), taken raw (feasible=:none, since the soft objective constrains nothing) — and return the best cover found, at a cost of one solve per start. The result is the best local minimum on that menu: the multistart is a hedge against a poor basin, not a certificate of global optimality.
  • AbsLog{1}(): not yet implemented. The objective is an LP in log space, but its optimum is a face, and the lexicographic AbsLog{2} selection that symcover_min uses to pin one member of the corresponding hard face does not carry over: the hard face is bounded by the coverage constraints, while this one is a level set of an unconstrained piecewise- linear objective, across which the quadratic pulls far enough to cost most of the exactly tight residuals that make AbsLog{1} worth choosing.

Every start on the menu co-varies with a rescaling of A and the objective is scale-invariant, so the selection — and hence the result — is scale-covariant.

See also: soft_cover_min!, soft_symcover_min, soft_cover.

source
MatrixCovers.soft_cover_min!Function
a, b = soft_cover_min!(ϕ, a, b, A)
a, b = soft_cover_min!(a, b, A)

Refine the starting point (a, b) into the ϕ-minimal asymmetric soft cover of A, in place. This is the asymmetric counterpart of soft_symcover_min!. The no-ϕ form defaults to AbsLinear{2}(), matching soft_cover_min, whose supported ϕ values these methods share.

a and b must be strictly positive on every supported row and column; scales on unsupported rows and columns are inert, and are zero on output. As with soft_symcover_min! — and unlike cover_min! — the start need not cover A. Build one with feasible=:none.

The product a[i]*b[j] is unchanged by a -> c*a, b -> b/c, so the start is read only up to that gauge, and the result is pinned to the balance convention of soft_cover_min.

See also: initialize_cover, soft_cover_min, soft_symcover_min!.

source
MatrixCovers.soft_symcover!Function
a = soft_symcover!(ϕ, a, A; maxiter=...)
a = soft_symcover!(a, A; maxiter=...)

Refine the starting point a into a symmetric soft cover of A, in place, and return it. The no-ϕ form defaults to AbsLinear{2}(), matching soft_symcover, whose supported ϕ values these methods share.

This is the refiner half of soft_symcover: the non-mutating form owns a multistart menu, so its result is a property of A, while this one descends from the single start you hand it, so its result is a property of A and that start. Building the start is the caller's job — see initialize_symcover, and pass feasible=:none, since a soft cover is under no obligation to cover A.

a must be finite and strictly positive on every row of A that carries support; scales on rows carrying no support are inert, and are zero on output. Unlike symcover_min!, a need not cover A — the soft objective imposes no coverage constraint.

maxiter bounds the descent sweeps; its default matches the corresponding soft_symcover method. Under AbsLog{2} the objective is convex with a unique minimizer, so the start is honored but not visible in the result.

See also: soft_symcover, soft_symcover_min!, initialize_symcover, soft_cover!.

source
MatrixCovers.soft_symcoverMethod
a = soft_symcover(ϕ, A; maxiter=32, starts=5, σ=2.0, rng=MersenneTwister(0))
a = soft_symcover(A; maxiter=32, starts=5, σ=2.0, rng=MersenneTwister(0))

Given a square matrix A assumed to be symmetric, return a vector a approximately minimizing the soft-cover objective ∑_{i,j} ϕ(|A[i,j]| / (a[i]*a[j])).

Unlike symcover, there is no hard coverage constraint: a[i]*a[j] may be less than |A[i,j]|, with violations penalized by ϕ.

Supported penalty functions:

  • AbsLog{2}(): convex, and returns its exact unconstrained minimum from a single linear solve. Identical to soft_symcover_min(AbsLog{2}(), A) — with one minimizer there is nothing for a heuristic and a minimizer to disagree about.
  • AbsLog{1}(): initializes from the AbsLog{2} minimum, then refines by coordinate descent with a log-space weighted-median step, reaching a deterministic and scale-covariant fixed point. That point is not in general a minimizer: each step minimizes exactly over one coordinate, but the objective's nonsmoothness couples a[i] with a[j], so the descent can settle where no single-coordinate move improves and the objective still sits materially above its minimum. soft_symcover_min does not yet offer an exact AbsLog{1} alternative.
  • AbsLinear{2}() (default): non-convex; refined by coordinate descent from starts scale-covariant starting points, keeping the lowest-objective result (see below).
  • AbsLinear{1}(): initializes from the AbsLinear{2}() result, coordinate descent uses a weighted-median step.

For the AbsLinear penalties the objective is non-convex, so starts starting points are tried and the best kept, taken in this order: the geometric-mean minimum, the tightened hard cover, the geometric-mean minimum inflated uniformly until it covers A, a leave-one-out geometric mean that drops the support entry with the most negative log-residual (this start keeps the result continuous as an entry |A[i,j]| approaches zero), and — only when A has a zero entry — a greedy feasible cover. Any slots left over are multiplicative log-normal perturbations a .* exp.(σ .* ξ) of the geometric-mean point with spread σ, ξ drawn from rng; at the default starts=5 there is at most one such perturbation. Every start co-varies with a diagonal rescaling of A and the objective is scale-invariant, so the selection is scale-covariant. The default rng is a fresh MersenneTwister(0) per call, making repeated calls (and the two frames of a covariance check) agree; pass your own rng for reproducibility you control, since default RNG streams are not stable across Julia versions. sigma is accepted as an ASCII alias for σ.

See also: symcover, cover_objective, soft_symcover_min.

Examples

The multistart converges to the covariant minimizer to within its objective tolerance; round to compare against exact values.

julia> A = [4 -1; -1 0];

julia> round.(soft_symcover(A); digits=4)
2-element Vector{Float64}:
 2.0
 0.5

julia> round.(soft_symcover([0 1; 1 0]); digits=4)
2-element Vector{Float64}:
 1.0
 1.0
source
MatrixCovers.soft_symcover_minFunction
a = soft_symcover_min(ϕ, A)
a = soft_symcover_min(A)

Return the ϕ-minimal symmetric soft cover of A: minimizes ∑_{i,j} ϕ(|A[i,j]|/(a[i]*a[j])) with no coverage constraints. The no-ϕ form defaults to AbsLinear{2}(), matching soft_symcover.

Supported ϕ values and required extensions:

  • AbsLog{2}(): solved natively (no external solver). In log space the objective is a linear least-squares, so one solve settles it, and being convex it has a unique minimizer that no start can influence. linsolve selects the inner solve, exactly as in symcover_min.
  • AbsLinear{1}(), AbsLinear{2}(): requires JuMP and Ipopt. These objectives are non-convex, so the solver returns the minimum of the basin it starts in. Rather than commit to one start, these methods refine each of strategies — the initialize_symcover menu, by default (:hardcover, :geomean, :leaveout), without forcing feasibility — and return the best cover found, at a cost of one solve per start.
  • AbsLog{1}(): not yet implemented. The objective is an LP in log space, but its optimum is a face, and the lexicographic AbsLog{2} selection that symcover_min uses to pin one member of the corresponding hard face does not carry over: the hard face is bounded by the coverage constraints, while this one is a level set of an unconstrained piecewise- linear objective, across which the quadratic pulls far enough to cost most of the exactly tight residuals that make AbsLog{1} worth choosing.

See also: soft_symcover_min!, soft_symcover, symcover_min.

source
MatrixCovers.soft_symcover_min!Function
a = soft_symcover_min!(ϕ, a, A)
a = soft_symcover_min!(a, A)

Refine the starting point a into the ϕ-minimal symmetric soft cover of A, in place. This is the soft counterpart of symcover_min!, and the second half of the initialize/refine pair whose first half is initialize_symcover. The no-ϕ form defaults to AbsLinear{2}(), matching soft_symcover_min, whose supported ϕ values these methods share.

a must be strictly positive on every row of A that carries support; scales on rows carrying no support are inert, and are zero on output. Unlike symcover_min!, a need not cover A — the soft objective imposes no coverage constraint, and the natural starts do not satisfy one. Pass feasible=:none when building a start with initialize_symcover.

The AbsLinear penalties are non-convex, so the start selects the local minimum the solver descends into; that is why soft_symcover_min tries several rather than committing to one. Under AbsLog{2} the objective is convex with a unique minimizer, so the start is honored but not visible in the result.

See also: initialize_symcover, soft_symcover_min, symcover_min!.

source
MatrixCovers.symcover!Method
a = symcover!(ϕ, a, A; maxiter=3)
a = symcover!(a, A; maxiter=3)

Mutating counterpart of symcover: writes the symmetric hard cover into a and returns it, rather than allocating a new vector. eachindex(a) must match axes(A, 1) (and A must be square). ϕ has the same meaning as in symcover, and is likewise ignored by the current heuristic covers.

See also: symcover.

source
MatrixCovers.symcoverMethod
a = symcover(ϕ, A; maxiter=3)
a = symcover(A; maxiter=3)

Given a square matrix A assumed to be symmetric, return a vector a representing a symmetric hard cover of A: a[i] * a[j] >= abs(A[i, j]) for all i, j.

The initialization is the AbsLog{2} unconstrained minimum (geometric mean of nonzero entries per row). It is then boosted to feasibility by a greedy max-deficit rule (the most-violated entries are covered first), and maxiter iterations of the tightening algorithm (Algorithm 1 of the manuscript) are applied.

ϕ names the penalty the caller would like the cover to do well on. Currently, the heuristic covers ignore ϕ, although this behavior may change in future versions. For a cover that provably minimizes a given ϕ, use symcover_min.

See also: symcover!, symcover_min, soft_symcover, cover.

Examples

julia> A = [4 1; 1 4];

julia> a = symcover(A)
2-element Vector{Float64}:
 2.0
 2.0

julia> a * a'   # covers |A|: a[i]*a[j] >= abs(A[i, j])
2×2 Matrix{Float64}:
 4.0  4.0
 4.0  4.0
source
MatrixCovers.symcover_minFunction
a = symcover_min(ϕ, A; kwargs...)
a = symcover_min(A; kwargs...)

Return the ϕ-minimal symmetric hard cover of A: the vector a minimizing ∑_{i,j} ϕ(|A[i,j]|/(a[i]*a[j])) subject to a[i]*a[j] >= |A[i,j]| for every nonzero entry of A. The no-ϕ form defaults to AbsLog{2}(), matching symcover.

Supported ϕ values:

  • AbsLog{2}(): solved natively (no external solver). Accepts keyword arguments κs (the penalty-continuation schedule, default (1e2, 1e4, 1e6, 1e8)), maxiter (Newton steps per stage, default 40), and linsolve (the inner linear solve: :auto/:dense use a dense factorization of the reweighted normal equations; :lsqr uses matrix-free LSQR (per-iteration cost O(nnz), intended for large sparse supports)). linsolve defaults to :auto for dense A; the SparseMatrixCSC/Symmetric/Hermitian sparse methods (from the SparseArrays extension) default to :lsqr instead, since a dense factorization of the reweighted normal equations is the wrong solve when nnz ≪ n².
  • AbsLog{1}(): requires JuMP and HiGHS.
  • AbsLinear{1}(), AbsLinear{2}(): requires JuMP and Ipopt. These objectives are non-convex, so the solver returns the minimum of the basin it starts in. Rather than commit to one start, these methods refine each of strategies — the initialize_symcover menu, by default (:hardcover, :geomean, :leaveout) — and return the best cover found, at a cost of one solve per start. A strategy that A admits no start for is skipped. The result is the best local minimum on that menu: the multistart is a hedge against a poor basin, not a certificate of global optimality.

The AbsLog penalties are convex in the log-scales, so for them the minimum value is unique and no such hedge is needed. AbsLog{2} has a unique minimizer too. AbsLog{1} does not: its optimum is a whole face of the feasible polytope, whose members are genuinely different covers that happen to score alike. The one returned is the member of that face minimizing the AbsLog{2} objective.

Note

Even the native solver is more expensive than the symcover heuristic.

See also: cover_min, symcover, symcover_min!.

source
MatrixCovers.symcover_min!Function
a = symcover_min!(ϕ, a, A; kwargs...)
a = symcover_min!(a, A; kwargs...)

Refine the starting cover a into the ϕ-minimal symmetric hard cover of A, in place. This is the second half of the initialize/refine pair: a must already be a starting point, as produced by initialize_symcover (or by symcover). The no-ϕ form defaults to AbsLog{2}(), matching symcover_min, whose keyword arguments and supported ϕ values these methods share.

a must be strictly positive on every row of A that carries support, and must cover Aa[i]*a[j] >= abs(A[i,j]) — to within the roundoff of the log-domain arithmetic; otherwise an ArgumentError is raised. Scales on rows carrying no support are inert: whatever they hold on input, they are zero on output.

How much the start matters depends on ϕ. Under the AbsLog penalties the result is start-independent: they are convex in the log-scales, AbsLog{2} has a unique minimizer, and AbsLog{1} — whose optimum is a whole face of equally-scoring covers — is pinned to the member of that face minimizing the AbsLog{2} objective. The AbsLinear penalties are non-convex, and the identified local minima depend on the start(s).

See also: initialize_symcover, symcover_min, cover_min!.

source