Skip to content

Commit 69ed90e

Browse files
timholyclaude
andauthored
Switch to inequality bounds (#5)
This PR switches from an unconstrained formulation to a constrained one, requiring that `a[i] * b[j] >= abs(A[i, j])` for all `i, j`. This was motivated by the observation that near-zero values could greatly distort the former solution; adding the constraint means the solution is dominated by "largest" entries (in a scale-invariant sense). This also: - changes terminology from "scale" to "cover" - provides fast, scale-invariant, but non-optimal implementations as the workhorse - adds optimal solutions that require JuMP - Add tests for the new API (symcover, cover, lobjective, qobjective, dotabs, divmag, *_qmin variants, quality vs optimal on testmatrices) - Drop tests for removed functions (symscale, matrixscale, condscale) - Add Statistics to test dependencies - Rewrite README to summarize key highlights and link to docs - Rewrite docs/src/index.md with motivation, examples, algorithm table, and divmag description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 861538f commit 69ed90e

11 files changed

Lines changed: 5111 additions & 308 deletions

File tree

Project.toml

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,34 @@ authors = ["Tim Holy <tim.holy@gmail.com> and contributors"]
55

66
[deps]
77
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"
8+
LoopVectorization = "bdcacae8-1622-11e9-2a5c-532679323890"
89
SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf"
910

11+
[weakdeps]
12+
HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b"
13+
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
14+
15+
[extensions]
16+
SIAJuMP = ["HiGHS", "JuMP"]
17+
1018
[compat]
19+
Graphs = "1"
20+
HiGHS = "1"
21+
JuMP = "1"
1122
LinearAlgebra = "1.10.0"
23+
NautyGraphs = "0.7"
24+
ParametricOptInterface = "0.15"
1225
SparseArrays = "1.10.0"
1326
julia = "1.10.10"
1427

1528
[extras]
29+
Graphs = "86223c79-3864-5bf0-83f7-82e725a168b6"
30+
HiGHS = "87dc4568-4c63-4d18-b0c0-bb2238e4078b"
31+
JuMP = "4076af6c-e467-56ae-b986-b466b2749572"
32+
NautyGraphs = "7509a0a4-015a-4167-b44b-0799a1a2605e"
33+
ParametricOptInterface = "0ce4ce61-57bf-432b-a095-efac525d185e"
34+
Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2"
1635
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
1736

1837
[targets]
19-
test = ["Test"]
38+
test = ["Graphs", "HiGHS", "JuMP", "NautyGraphs", "ParametricOptInterface", "Statistics", "Test"]

README.md

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,16 @@
55
[![Build Status](https://github.com/HolyLab/ScaleInvariantAnalysis.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/HolyLab/ScaleInvariantAnalysis.jl/actions/workflows/CI.yml?query=branch%3Amain)
66
[![Coverage](https://codecov.io/gh/HolyLab/ScaleInvariantAnalysis.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/HolyLab/ScaleInvariantAnalysis.jl)
77

8-
This package provides tools for numerical analysis under conditions where the
9-
underlying mathematics is scale-invariant. We work on computers with finite
10-
precision, so operations like matrix-multiplication and matrix-division are
11-
expected to have some error. However, naive estimates of the error, based on
12-
quantities like `norm(x)`, may not be scale-invariant.
8+
This package computes **covers** of matrices: non-negative vectors `a` (and `b`)
9+
such that `a[i] * b[j] >= abs(A[i, j])` for all `i`, `j`. Covers are the
10+
natural scale-covariant representation of a matrix — under row/column diagonal
11+
scaling they transform exactly as the matrix entries do — making them a useful
12+
building block for scale-invariant numerical analysis.
1313

14-
For example, if `H` is a diagonal nonnegative (Hessian) matrix (i.e., a rank-2
15-
covariant tensor), with a change-of-scale in the variables all such `H` are
16-
equivalent to the identity matrix. Therefore we might think that its [condition
17-
number](https://en.wikipedia.org/wiki/Condition_number) should in fact be 1.
18-
This package provides tool to calculate condition number, as well as several
19-
other quantities, under conditions of scale-invariance.
14+
Fast O(n²) heuristics (`symcover`, `cover`) are provided for everyday use.
15+
Globally optimal covers minimizing a log-domain linear or quadratic objective
16+
(`symcover_lmin`, `cover_lmin`, `symcover_qmin`, `cover_qmin`) are available
17+
as an extension when JuMP and HiGHS are loaded.
2018

21-
See the
22-
[documentation](https://HolyLab.github.io/ScaleInvariantAnalysis.jl/dev/) for
23-
more information.
19+
See the [documentation](https://HolyLab.github.io/ScaleInvariantAnalysis.jl/dev/)
20+
for motivation, examples, and a full API reference.

docs/src/index.md

Lines changed: 92 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -4,58 +4,114 @@ CurrentModule = ScaleInvariantAnalysis
44

55
# ScaleInvariantAnalysis
66

7-
This small package provides a number of tools for numerical analysis under
8-
conditions where the underlying problems are scale-invariant. At present it is
9-
oriented toward the types of problems that appear in mathematical optimization.
10-
Under scaling transformations ``x \rightarrow s \odot x`` (``\odot`` is the
11-
[Hadamard product](https://en.wikipedia.org/wiki/Hadamard_product_(matrices))),
12-
a Hessian matrix ``H`` transforms as ``H \rightarrow H \oslash (s \otimes s)``.
13-
Therefore, operations like `norm(H)` are non-sensical. Nevertheless, we work on
14-
computers with finite precision, so operations like ``Hx`` and ``H^{-1} g`` are
15-
expected to have some error. This package provides tools for calculating and
16-
estimating errors in a scale-covariant manner.
7+
This package computes **covers** of matrices. Given a matrix `A`, a cover is a
8+
pair of non-negative vectors `a`, `b` satisfying
179

18-
## Example
10+
```math
11+
a_i \cdot b_j \;\geq\; |A_{ij}| \quad \text{for all } i, j.
12+
```
13+
14+
For a symmetric matrix the cover is symmetric (`b = a`), so a single vector
15+
suffices: `a[i] * a[j] >= abs(A[i, j])`.
16+
17+
## Why covers?
1918

20-
Suppose you have a diagonal Hessian matrix and you estimate its condition number
21-
using tools that are not scale-invariant:
19+
Covers are the natural **scale-covariant** representation of a matrix. If you
20+
rescale rows by a positive diagonal factor `D_r` and columns by `D_c`, the
21+
optimal cover transforms as `a → D_r * a`, `b → D_c * b` — exactly mirroring
22+
how the matrix entries change. Scalar summaries like `norm(A)` or
23+
`maximum(abs, A)` do not have this property and therefore implicitly encode an
24+
arbitrary choice of units.
2225

23-
```jldoctest example
24-
julia> using LinearAlgebra
26+
A concrete example: a 3×3 matrix whose rows and columns correspond to physical
27+
variables at very different scales (position in metres, velocity in m/s, force
28+
in N):
29+
30+
```jldoctest coverones
31+
julia> using ScaleInvariantAnalysis
2532
26-
julia> H = [1 0; 0 1e-8];
33+
julia> A = [1e6 1e3 1.0; 1e3 1.0 1e-3; 1.0 1e-3 1e-6];
2734
28-
julia> cond(H)
29-
1.0e8
35+
julia> a = symcover(A)
36+
3-element Vector{Float64}:
37+
1000.0
38+
1.0
39+
0.001
3040
```
3141

32-
You might declare this matrix to be "poorly scaled." However, the operations
33-
`H * x` and `H \ g` both have coordinatewise relative errors of size `eps()`: there
34-
are no delicate cancelations and thus operations involving `H` reach the full
35-
machine precision. This does not seem entirely consistent with common
36-
expectations of working with matrices with large condition numbers.
42+
The cover `a` captures the natural per-variable scale. The normalised matrix
43+
`A ./ (a .* a')` is all-ones and is scale-invariant.
3744

38-
Under a coordinate transformation `x → [x[1], x[2]/10^4]`, `H` becomes the
39-
identity matrix which has a condition number of 1, and this better reflects our
40-
actual experience with operations involving `H`. This package provides a
41-
scale-invariant analog of the condition number:
45+
## Measuring cover quality
4246

43-
```jldoctest example; filter = r"1\.0\d*" => "1.0"
47+
A cover is valid as long as every constraint is satisfied, but tighter covers
48+
better capture the scaling of `A`. The *log-excess* of an entry is
49+
`log(a[i] * b[j] / abs(A[i, j])) >= 0`; it is zero when the constraint is
50+
exactly tight. Two summary statistics aggregate these excesses:
51+
52+
- [`lobjective`](@ref) — sum of log-excesses (L1 in log space).
53+
- [`qobjective`](@ref) — sum of squared log-excesses (L2 in log space).
54+
55+
Both equal zero if and only if every constraint is tight.
56+
57+
```jldoctest quality; filter = r"(\d+\.\d{6})\d+" => s"\1"
4458
julia> using ScaleInvariantAnalysis
4559
46-
julia> condscale(H)
47-
1.0
60+
julia> A = [4.0 2.0; 2.0 9.0];
61+
62+
julia> a = symcover(A)
63+
2-element Vector{Float64}:
64+
2.0
65+
3.0
66+
67+
julia> lobjective(a, A)
68+
2.1972245773362196
69+
70+
julia> qobjective(a, A)
71+
2.413897921625164
4872
```
4973

50-
(You may have some roundoff error in the last few digits.) This version of the
51-
condition number matches our actual experience using `H`. In contrast,
74+
## Choosing a cover algorithm
75+
76+
| Function | Symmetric | Minimizes | Requires |
77+
|---|---|---|---|
78+
| [`symcover`](@ref) | yes | (fast heuristic) ||
79+
| [`cover`](@ref) | no | (fast heuristic) ||
80+
| [`symcover_lmin`](@ref) | yes | `lobjective` | JuMP + HiGHS |
81+
| [`cover_lmin`](@ref) | no | `lobjective` | JuMP + HiGHS |
82+
| [`symcover_qmin`](@ref) | yes | `qobjective` | JuMP + HiGHS |
83+
| [`cover_qmin`](@ref) | no | `qobjective` | JuMP + HiGHS |
84+
85+
**`symcover` and `cover` are recommended for production use.** They run in
86+
O(n²) time and often land within a few percent of the `lobjective`-optimal
87+
cover (see the quality tests in `test/testmatrices.jl`).
88+
89+
The `*_lmin` and `*_qmin` variants solve a convex program (via
90+
[JuMP](https://jump.dev/) and [HiGHS](https://highs.dev/)) and return a
91+
global optimum of the respective objective. They are loaded on demand as a
92+
package extension — simply load both libraries before calling them:
93+
94+
```julia
95+
using JuMP, HiGHS
96+
using ScaleInvariantAnalysis
97+
98+
a = symcover_lmin(A) # globally l-minimal symmetric cover
99+
a, b = cover_qmin(A) # globally q-minimal general cover
100+
```
101+
102+
## Scale-invariant magnitude estimation
103+
104+
[`divmag`](@ref) combines `symcover` with a right-hand side vector to produce a
105+
scale-covariant estimate of the magnitude of `A \ b` without solving the system:
52106

53-
```jldoctest example; filter = r"(19999\.0\d*|19998\.9\d+)" => "19999.0"
54-
julia> condscale([1 0.9999; 0.9999 1])
55-
19999.0
107+
```julia
108+
a, mag = divmag(A, b)
56109
```
57110

58-
remains poorly-conditioned under all scale-transformations of the matrix.
111+
`a` is `symcover(A)` and `mag` estimates `dotabs(A \ b, a)`. Both transform
112+
covariantly when `A` and `b` are rescaled together, so `mag` serves as a
113+
reliable unit for assessing roundoff in the solution. Pass `cond=true` to fold
114+
in the scale-invariant condition number for ill-conditioned systems.
59115

60116
## Index of available tools
61117

ext/SIAJuMP.jl

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
module SIAJuMP
2+
3+
using JuMP: JuMP, @variable, @objective, @constraint
4+
using HiGHS: HiGHS
5+
using ScaleInvariantAnalysis
6+
7+
# Reference implementation for a symmetric matrix cover
8+
function ScaleInvariantAnalysis.symcover_qmin(A)
9+
ax = axes(A, 1)
10+
axes(A, 2) == ax || throw(ArgumentError("symcover_qmin requires a square matrix"))
11+
logA = log.(abs.(A))
12+
n = size(logA, 1)
13+
model = JuMP.Model(HiGHS.Optimizer)
14+
JuMP.set_silent(model)
15+
@variable(model, α[1:n])
16+
@objective(model, Min, sum(abs2, α[i] + α[j] - logA[i, j] for i in 1:n, j in 1:n if A[i, j] != 0))
17+
for i in 1:n
18+
for j in i:n
19+
if A[i, j] != 0
20+
@constraint(model, α[i] + α[j] - logA[i, j] >= 0)
21+
end
22+
end
23+
end
24+
JuMP.optimize!(model)
25+
return exp.(JuMP.value.(α))
26+
end
27+
28+
function ScaleInvariantAnalysis.symcover_lmin(A)
29+
ax = axes(A, 1)
30+
axes(A, 2) == ax || throw(ArgumentError("symcover_lmin_ref requires a square matrix"))
31+
logA = log.(abs.(A))
32+
n = size(logA, 1)
33+
model = JuMP.Model(HiGHS.Optimizer)
34+
JuMP.set_silent(model)
35+
@variable(model, α[1:n])
36+
@objective(model, Min, sum(α[i] + α[j] - logA[i, j] for i in 1:n, j in 1:n if A[i, j] != 0))
37+
for i in 1:n
38+
for j in i:n
39+
if A[i, j] != 0
40+
@constraint(model, α[i] + α[j] - logA[i, j] >= 0)
41+
end
42+
end
43+
end
44+
JuMP.optimize!(model)
45+
return exp.(JuMP.value.(α))
46+
end
47+
48+
# Reference implementation for a general matrix cover
49+
function ScaleInvariantAnalysis.cover_qmin(A)
50+
logA = log.(abs.(A))
51+
m, n = size(logA)
52+
model = JuMP.Model(HiGHS.Optimizer)
53+
JuMP.set_silent(model)
54+
@variable(model, α[1:m])
55+
@variable(model, β[1:n])
56+
@objective(model, Min, sum(abs2, α[i] + β[j] - logA[i, j] for i in 1:m, j in 1:n if A[i, j] != 0))
57+
for i in 1:m
58+
for j in 1:n
59+
if A[i, j] != 0
60+
@constraint(model, α[i] + β[j] - logA[i, j] >= 0)
61+
end
62+
end
63+
end
64+
nza, nzb = sum(!iszero, A; dims=2)[:], sum(!iszero, A; dims=1)[:]
65+
@constraint(model, sum(nza[i] * α[i] for i in 1:m) == sum(nzb[j] * β[j] for j in 1:n))
66+
JuMP.optimize!(model)
67+
return exp.(JuMP.value.(α)), exp.(JuMP.value.(β))
68+
end
69+
70+
function ScaleInvariantAnalysis.cover_lmin(A)
71+
logA = log.(abs.(A))
72+
m, n = size(logA)
73+
model = JuMP.Model(HiGHS.Optimizer)
74+
JuMP.set_silent(model)
75+
@variable(model, α[1:m])
76+
@variable(model, β[1:n])
77+
@objective(model, Min, sum(α[i] + β[j] - logA[i, j] for i in 1:m, j in 1:n if A[i, j] != 0))
78+
for i in 1:m
79+
for j in 1:n
80+
if A[i, j] != 0
81+
@constraint(model, α[i] + β[j] - logA[i, j] >= 0)
82+
end
83+
end
84+
end
85+
nza, nzb = sum(!iszero, A; dims=2)[:], sum(!iszero, A; dims=1)[:]
86+
@constraint(model, sum(nza[i] * α[i] for i in 1:m) == sum(nzb[j] * β[j] for j in 1:n))
87+
JuMP.optimize!(model)
88+
return exp.(JuMP.value.(α)), exp.(JuMP.value.(β))
89+
end
90+
91+
end

0 commit comments

Comments
 (0)