Skip to content

Commit 4e6fb1c

Browse files
authored
Reduce optimality gap for symmetric (#10)
It turns out that initializing with the solution to the unconstrained problem produces better results (in the sense of a closer match to quadratic optimality) than the previous diagonal initialization. Keep both as options, but default to the higher-quality one. The reason to keep the old strategy is that it is faster (about 2x on a 5x5 matrix), largely by avoiding the need to take logarithms.
1 parent 88c5b0a commit 4e6fb1c

3 files changed

Lines changed: 115 additions & 37 deletions

File tree

docs/src/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ A concrete example: a 3×3 matrix whose rows and columns correspond to physical
3232
variables with different units (position in meters, velocity in m/s, force
3333
in N):
3434

35-
```jldoctest coverones; filter = r"(\d+\.\d{6})\d+" => s"\1"
35+
```jldoctest coverones; filter = r"(\d+\.\d{2})\d+" => s"\1"
3636
julia> using ScaleInvariantAnalysis
3737
3838
julia> A = [1e6 1e3 1.0; 1e3 1.0 1e-3; 1.0 1e-3 1e-6];

src/covers.jl

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,24 +29,29 @@ cover_qobjective(a, b, A) = sum(log(a[i] * b[j] / abs(A[i, j]))^2 for i in axes(
2929
cover_qobjective(a, A) = cover_qobjective(a, a, A)
3030

3131
"""
32-
a = symcover(A; iter=3)
32+
a = symcover(A; prioritize::Symbol=:quality, iter=3)
3333
3434
Given a square matrix `A` assumed to be symmetric, return a vector `a`
3535
representing the symmetric cover of `A`, so that `a[i] * a[j] >= abs(A[i, j])`
3636
for all `i`, `j`.
3737
38-
`a` may not be minimal, but it is tightened iteratively, with `iter` specifying
39-
the number of iterations (more iterations make tighter covers).
40-
`symcover` is fast and generally recommended for production use.
38+
`prioritize=:quality` yields a cover that is typically closer to being
39+
quadratically optimal, though there are exceptions.
40+
`prioritize=:speed` is often about twice as fast (with default `iter=3`). In
41+
either case, after initialization `a` is tightened iteratively, with `iter`
42+
specifying the number of iterations (more iterations make tighter covers).
43+
44+
Regardless of which `prioritize` option is chosen, `symcover` is fast and
45+
generally recommended for production use.
4146
4247
See also: [`symcover_lmin`](@ref), [`symcover_qmin`](@ref), [`cover`](@ref).
4348
4449
# Examples
4550
46-
```jldoctest
51+
```jldoctest; filter = r"(\\d+\\.\\d{6})\\d+" => s"\\1"
4752
julia> A = [4 -1; -1 0];
4853
49-
julia> a = symcover(A)
54+
julia> a = symcover(A; prioritize=:speed)
5055
2-element Vector{Float64}:
5156
2.0
5257
0.5
@@ -55,18 +60,43 @@ julia> a * a'
5560
2×2 Matrix{Float64}:
5661
4.0 1.0
5762
1.0 0.25
63+
64+
julia> A = [0 12 9; 12 7 12; 9 12 0];
65+
66+
julia> a = symcover(A; prioritize=:quality)
67+
3-element Vector{Float64}:
68+
3.4021999694928753
69+
3.54528705924512
70+
3.3847752803845172
71+
72+
julia> a * a'
73+
3×3 Matrix{Float64}:
74+
11.575 12.0618 11.5157
75+
12.0618 12.5691 12.0
76+
11.5157 12.0 11.4567
77+
78+
julia> a = symcover(A; prioritize=:speed)
79+
3-element Vector{Float64}:
80+
4.535573676110727
81+
2.6457513110645907
82+
4.535573676110727
83+
84+
julia> a * a'
85+
3×3 Matrix{Float64}:
86+
20.5714 12.0 20.5714
87+
12.0 7.0 12.0
88+
20.5714 12.0 20.5714
5889
```
5990
"""
60-
function symcover(A::AbstractMatrix; exclude_diagonal::Bool=false, kwargs...)
91+
function symcover(A::AbstractMatrix; exclude_diagonal::Bool=false, prioritize::Symbol=:quality, kwargs...)
92+
prioritize in (:quality, :speed) || throw(ArgumentError("prioritize must be :quality or :speed"))
6193
ax = axes(A, 1)
6294
axes(A, 2) == ax || throw(ArgumentError("symcover requires a square matrix"))
6395
a = similar(A, float(eltype(A)), ax)
64-
if exclude_diagonal
65-
fill!(a, zero(eltype(a)))
96+
if prioritize == :quality
97+
_symcover_init_quadratic!(a, A; exclude_diagonal)
6698
else
67-
for j in ax
68-
a[j] = sqrt(abs(A[j, j]))
69-
end
99+
_symcover_init_fast!(a, A; exclude_diagonal)
70100
end
71101
# Iterate over the diagonals of A, and update a[i] and a[j] to satisfy |A[i, j]| ≤ a[i] * a[j] whenever this constraint is violated
72102
# Iterating over the diagonals gives a more "balanced" result and typically results in lower loss than iterating in a triangular pattern.
@@ -94,6 +124,49 @@ function symcover(A::AbstractMatrix; exclude_diagonal::Bool=false, kwargs...)
94124
return tighten_cover!(a, A; exclude_diagonal, kwargs...)
95125
end
96126

127+
function _symcover_init_quadratic!(a::AbstractVector{T}, A::AbstractMatrix; exclude_diagonal::Bool=false) where T
128+
ax = eachindex(a)
129+
loga = fill!(similar(a), zero(T))
130+
nza = fill(0, ax)
131+
for j in ax
132+
for i in first(ax):j - exclude_diagonal
133+
Aij = abs(A[i, j])
134+
iszero(Aij) && continue
135+
lAij = log(Aij)
136+
loga[i] += lAij
137+
nza[i] += 1
138+
if j != i
139+
loga[j] += lAij
140+
nza[j] += 1
141+
end
142+
end
143+
end
144+
nztotal = sum(nza)
145+
halfmu = iszero(nztotal) ? zero(T) : sum(loga) / (2 * nztotal)
146+
for i in ax
147+
a[i] = ai = iszero(nza[i]) ? zero(T) : exp(loga[i] / nza[i] - halfmu)
148+
if !exclude_diagonal
149+
# The rest of the algorithm will ensure the initialization is a valid cover, but we have to do the diagonal here.
150+
Aii = abs(A[i, i])
151+
if ai^2 < Aii
152+
a[i] = sqrt(Aii)
153+
end
154+
end
155+
end
156+
return a
157+
end
158+
159+
function _symcover_init_fast!(a::AbstractVector{T}, A::AbstractMatrix; exclude_diagonal::Bool=false) where T
160+
if exclude_diagonal
161+
fill!(a, zero(T))
162+
else
163+
for j in eachindex(a)
164+
a[j] = sqrt(abs(A[j, j]))
165+
end
166+
end
167+
return a
168+
end
169+
97170
"""
98171
d, a = symdiagcover(A; kwargs...)
99172

test/runtests.jl

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@ using Test
1010
# Cover property: a[i]*a[j] >= abs(A[i,j]) for all i, j
1111
for A in ([2.0 1.0; 1.0 3.0], [1.0 -0.2; -0.2 0.0], [1.0 0.0; 0.0 0.0],
1212
[100.0 1.0; 1.0 0.01])
13-
a = symcover(A)
14-
@test all(a[i] * a[j] >= abs(A[i, j]) - 1e-12 for i in axes(A, 1), j in axes(A, 2))
13+
for prioritize in (:speed, :quality)
14+
a = symcover(A; prioritize)
15+
@test all(a[i] * a[j] >= abs(A[i, j]) - 1e-12 for i in axes(A, 1), j in axes(A, 2))
16+
end
1517
end
1618
# Zero diagonal gives zero scale
1719
a = symcover([1.0 0; 0 0])
@@ -54,15 +56,17 @@ using Test
5456
@testset "symdiagcover" begin
5557
for A in ([2.0 1.0; 1.0 3.0], [1.0 -0.2; -0.2 0.0], [4.0 1e-8; 1e-8 1.0],
5658
[100.0 1.0; 1.0 0.01], [4.0 2.0 1.0; 2.0 3.0 2.0; 1.0 2.0 5.0])
57-
d, a = symdiagcover(A)
58-
# Off-diagonal cover property
59-
@test all(a[i] * a[j] >= abs(A[i, j]) - 1e-12 for i in axes(A, 1), j in axes(A, 2) if i != j)
60-
# Diagonal cover property
61-
@test all(a[i]^2 + d[i] >= abs(A[i, i]) - 1e-12 for i in axes(A, 1))
62-
# d is non-negative
63-
@test all(d[i] >= 0 for i in axes(A, 1))
64-
# d is as tight as possible: d[i] == max(0, |A[i,i]| - a[i]^2)
65-
@test all(d[i] max(0.0, abs(A[i, i]) - a[i]^2) for i in axes(A, 1))
59+
for prioritize in (:speed, :quality)
60+
d, a = symdiagcover(A; prioritize)
61+
# Off-diagonal cover property
62+
@test all(a[i] * a[j] >= abs(A[i, j]) - 1e-12 for i in axes(A, 1), j in axes(A, 2) if i != j)
63+
# Diagonal cover property
64+
@test all(a[i]^2 + d[i] >= abs(A[i, i]) - 1e-12 for i in axes(A, 1))
65+
# d is non-negative
66+
@test all(d[i] >= 0 for i in axes(A, 1))
67+
# d is as tight as possible: d[i] == max(0, |A[i,i]| - a[i]^2)
68+
@test all(d[i] max(0.0, abs(A[i, i]) - a[i]^2) for i in axes(A, 1))
69+
end
6670
end
6771
# Non-square input is rejected
6872
@test_throws ArgumentError symdiagcover([1.0 2.0; 3.0 4.0; 5.0 6.0])
@@ -161,35 +165,36 @@ using Test
161165
include("testmatrices.jl") # defines symmetric_matrices and general_matrices
162166
end
163167

164-
# symcover cover_lobjective should be close to optimal (symcover_lmin)
168+
# symcover cover_qobjective should be close to optimal (symcover_qmin)
165169
sym_ratios = Float64[]
166170
for (_, A) in symmetric_matrices
167171
Af = Float64.(A)
172+
# Initialization should give a valid cover
168173
a0 = symcover(Af; iter=0)
169174
@test all(a0[i] * a0[j] >= abs(Af[i, j]) - 1e-12 for i in axes(Af, 1), j in axes(Af, 2))
170175
a0 = symcover(Af / 100; iter=0)
171176
@test all(a0[i] * a0[j] >= abs(Af[i, j])/100 - 1e-12 for i in axes(Af, 1), j in axes(Af, 2))
172-
a = symcover(Af; iter=10)
173-
lopt = cover_lobjective(symcover_lmin(Af), Af)
174-
lfast = cover_lobjective(a, Af)
175-
iszero(lopt) || push!(sym_ratios, lfast / lopt)
177+
# Covers are nearly quadratically optimal
178+
qopt = cover_qobjective(symcover_qmin(Af), Af)
179+
qfast = cover_qobjective(symcover(Af; iter=10), Af)
180+
iszero(qopt) || push!(sym_ratios, qfast / qopt)
176181
end
177-
@test median(sym_ratios) < 1.1
182+
@test median(sym_ratios) < 1.02
178183

179-
# cover cover_lobjective should be close to optimal (cover_lmin)
184+
# cover cover_qobjective should be close to optimal (cover_qmin)
180185
gen_ratios = Float64[]
181186
for (_, A) in general_matrices
182187
Af = Float64.(A)
188+
# Initialization should give a valid cover
183189
a0, b0 = cover(Af; iter=0)
184190
@test all(a0[i] * b0[j] >= abs(Af[i, j]) - 1e-12 for i in axes(Af, 1), j in axes(Af, 2))
185191
a0, b0 = cover(Af / 100; iter=0)
186192
@test all(a0[i] * b0[j] >= abs(Af[i, j])/100 - 1e-12 for i in axes(Af, 1), j in axes(Af, 2))
187-
al, bl = cover_lmin(Af)
188-
a, b = cover(Af; iter=10)
189-
lopt = cover_lobjective(al, bl, Af)
190-
lfast = cover_lobjective(a, b, Af)
191-
iszero(lopt) || push!(gen_ratios, lfast / lopt)
193+
# Covers are nearly quadratically optimal
194+
qopt = cover_qobjective(cover_qmin(Af)..., Af)
195+
qfast = cover_qobjective(cover(Af; iter=10)..., Af)
196+
iszero(qopt) || push!(gen_ratios, qfast / qopt)
192197
end
193-
@test median(gen_ratios) < 1.1
198+
@test median(gen_ratios) < 1.02
194199
end
195200
end

0 commit comments

Comments
 (0)