Qu

Compressed Sensing

Recovering a signal from far fewer measurements than Nyquist demands.

The idea

You measure y = A·x, where A is m × n and m is much smaller than n. That system is massively underdetermined — infinitely many x explain any y — so on its own it says nothing at all.

Compressed sensing adds one assumption and gets a unique answer back: x is sparse. At most k of its entries are non-zero, and k is small compared with m.

The assumption is not a trick, and it is not rare. Almost everything a laboratory measures is sparse in *some* basis: a spectrum is a handful of tones among thousands of bins, an impedance sweep is a few relaxation processes, a vibration record is a few modes, an image is sparse in wavelets. Whenever the interesting part of a measurement is small compared with the grid it is measured on, you can measure below Nyquist and lose nothing.

The example below builds exactly that scene. A is a 60 × 200 sensing matrix — 60 random measurements of 200 unknowns — and the true x is 3-sparse: three named entries, the rest exactly zero. cs_recover(A, y, method = "omp", sparsity = 3) is told the target sparsity up front and returns a record whose .x field is the reconstruction; the printed line reports the reconstruction error ‖r.x - x‖₂, which comes out on the order of 1e-12 — recovered, to floating-point precision, from well under a third of the samples a direct measurement of x would need.

n = 200
m = 60
A = randn(m, n, seed = 7) / sqrt(m)

x = zeros(n)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75

y = A * x

r = cs_recover(A, y, method = "omp", sparsity = 3)
print("recovered from {m} of {n}: error {norm(r.x - x):.2e}")
output
recovered from 60 of 200: error 3.19e-12

Is your sensing matrix any good?

Two conditions, in decreasing order of usefulness.

Coherence — coherence(A) — takes one argument, A (a Mat, m × n, the sensing matrix), and returns a single number: the largest absolute inner product between two different unit-normalised columns of A. Small is good. Exact recovery is *guaranteed* whenever k < (1 + 1/mu)/2, where mu is that coherence. cs_guarantee(A) takes the same single argument A (Mat, m × n) and returns that bound directly, as a number giving the largest sparsity k coherence alone can *prove* recoverable.

The example below takes the same 60 × 200 Gaussian matrix used above and prints both numbers on one line, so the coherence and the sparsity it proves recoverable can be read off together.

A = randn(60, 200, seed = 7) / sqrt(60)
print("coherence {coherence(A):.3f}, provable sparsity {cs_guarantee(A)}")
output
coherence 0.558, provable sparsity 1

That guarantee is real and badly pessimistic. The matrix above proves k=1 and recovers k=5 without difficulty. Treat a pass as proof and a failure as no information. It is the only certificate that is computable at all, which is why it is here.

The restricted isometry property is the condition the strong theorems are stated in: A satisfies RIP of order k with constant d when (1-d)‖x‖² ≤ ‖Ax‖² ≤ (1+d)‖x‖² for every k-sparse x. Verifying it for a given matrix is NP-hard, so nothing here computes it. You get it from *construction* instead: a random Gaussian or Bernoulli matrix satisfies RIP of order k with high probability once m ≳ k·log(n/k). That is where the working rule of thumb comes from:

> m ≈ 4k measurements for k non-zeros, and check by trying.

Randomness here is not laziness. No deterministic construction is known that does better for general use, and randn(m, n, seed=) with a fixed seed is reproducible, which is what actually matters.

The methods

Finding the sparsest x is combinatorial — an l0 problem, NP-hard — so every method below is a tractable stand-in. They divide into two families that fail differently, which is the reason to have both.

All of them are reached through one call:

cs_recover(A, y, method = , sparsity = , lambda = , iters = , tol = , debias = , step = , rho = )

One dispatcher rather than eight builtins, because the arguments are the same for all of them and the method is a keyword — so comparing methods means changing one word, not one call. Its parameters:

ParameterType / shapeMeaning
AMat, m × nThe sensing matrix: m measurements of n unknowns, with m usually far smaller than n.
yVec, length mThe measurements, y = A·x.
methodstring, default "omp"Which solver to run: greedy ("omp", "cosamp", "sp"/"subspace_pursuit", "iht", "niht") or convex ("fista", "ista", "bp"/"basis_pursuit").
sparsitynumber (an integer count k), no defaultThe target number of non-zeros, 1 ≤ k ≤ n. Required by every greedy method — omitting it is an error; the convex methods infer the support instead and silently ignore sparsity= if it is passed anyway.
lambdanumber, default 1e-3The l1 penalty weight, used only by "ista"/"fista".
itersnumber (an integer count), default 200The iteration cap. "ista", "fista", and "bp" silently raise this to at least 1000, and "niht" to at least 500, regardless of a smaller value passed here.
tolnumber, default 1e-9The residual norm at which the solver declares convergence and stops early. "bp" never accepts a value tighter than 1e-8.
debiasbool, default falseWhen true, re-fits the recovered support by plain least squares after the chosen method finishes, removing the l1 shrinkage bias (see below).
stepnumber, default 0.5The gradient step size, used only by "iht". Must stay under 1/‖A‖₂² or the iteration diverges; "niht" computes its own step every iteration and ignores this parameter.
rhonumber, default 1.0The ADMM penalty parameter, used only by "bp".

cs_recover always returns a record; see "What comes back" below for its fields.

Greedy

Build the support one decision at a time. Fast, need k up front, and brittle: a column chosen wrongly early is a wrong answer.

method=What it doesWhen
"omp"Orthogonal matching pursuit, using sparsity=k and tol=. Add the column most correlated with the residual, re-solve least squares on the whole support, repeat until k columns are chosen or the residual falls below tol.The baseline. Hard to beat when A is well conditioned and k is genuinely small.
"cosamp"Compressive sampling matching pursuit, using sparsity=k, iters=, tol=. Takes the 2k best candidates at once, solves least squares on the union with the current support, prunes back to the best k, and repeats up to iters times or until tol is met.When OMP stalls. The prune lets it *undo* a bad early choice, which OMP structurally cannot.
"sp" / "subspace_pursuit"Subspace pursuit, using the same sparsity=k, iters=, tol= as CoSaMP: the same structure with a k-sized (rather than 2k-sized) working set each round.Cheaper than CoSaMP, comparable robustness.
"iht"Iterative hard thresholding, using sparsity=k, step= (default 0.5), iters=, tol=. Gradient step, keep the k largest entries, repeat. No least-squares solve at all.A problem too large to factorise. Needs step= below 1/‖A‖₂² or it diverges.
"niht"Normalised IHT, same sparsity=k, iters=, tol= as "iht", but the step is recomputed from the gradient every iteration, so step= is ignored, and at least 500 iterations always run.Almost always in preference to iht. It does not diverge.

Convex

Replace the count of non-zeros with the l1 norm, its tightest convex relaxation, and solve that exactly. Slower, and they do not need to be told k.

method=What it doesWhen
"fista"Proximal gradient with Nesterov momentum, using lambda= (default 1e-3), iters= (floored at 1000), tol=, on ½‖Ax-y‖² + λ‖x‖₁ — the LASSO / basis-pursuit-denoising problem.The default convex choice. Noisy measurements, or k only estimated.
"ista"The same objective, and the same lambda=/iters=/tol=, without the momentum term.Only to see the difference: same cost per iteration, O(1/t) convergence against FISTA's O(1/t²).
"bp" / "basis_pursuit"Basis pursuit by ADMM, using rho= (default 1.0), iters= (floored at 1000), tol= (floored at 1e-8): min ‖x‖₁ subject to Ax = y.Clean measurements, when you want the constraint satisfied rather than penalised.

The two families are not just different code paths — they behave visibly differently on the exact same problem, which the tables above describe but do not show: same sensing matrix, same signal, same measurements, and one call finishes in three iterations at machine precision while the other takes hundreds and settles for a residual bias. Neither number is a defect; it is what "combinatorial search over a known support size" versus "convex relaxation that infers the support" costs and buys.

Overloads: cs_recover

Case: Greedy (omp)

Told the true sparsity k = 3 up front, OMP picks one column per iteration and stops the moment it has three — .iterations comes out equal to k, and the least-squares re-solve on that exact support drives the error to floating-point zero.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "omp", sparsity = 3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
3 iterations, converged true, error 3.19e-12

Case: Convex (fista)

The same A, x, and y, handed to FISTA instead — no sparsity= at all, the support falls out of the l1 penalty — takes hundreds of proximal-gradient steps rather than three, and still settles for an error around 1e-3 instead of machine precision, the shrinkage bias the next section is about.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "fista", lambda = 1e-3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
563 iterations, converged true, error 1.75e-3

Case: Greedy (cosamp)

Same problem, 2k-at-a-time candidate selection with pruning — converges in even fewer iterations than plain OMP here because the prune corrects for any early misstep, though OMP already had no misstep to correct on this well-conditioned a matrix.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "cosamp", sparsity = 3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
2 iterations, converged true, error 4.87e-12

Case: Greedy (sp)

Subspace pursuit: the same structure as CoSaMP with a k-sized rather than 2k-sized working set each round — cheaper per iteration, same machine-precision result on this problem.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "sp", sparsity = 3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
1 iterations, converged true, error 3.19e-12

Case: Greedy (iht)

No least-squares solve at all — just a gradient step and a hard threshold to the k largest entries, repeated. Needs many more iterations than the solve-based greedy methods above to reach the same precision, the real cost of avoiding the solve.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "iht", sparsity = 3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
35 iterations, converged true, error 5.91e-10

Case: Greedy (niht)

The normalised variant of iht: the step size is recomputed from the gradient every iteration instead of held fixed, so it needs roughly a third as many iterations here and, unlike plain iht, cannot diverge from a poorly chosen fixed step.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "niht", sparsity = 3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
13 iterations, converged true, error 3.26e-10

Case: Convex (ista)

The same LASSO objective as fista, without the Nesterov momentum term. Both hit the iteration cap on this problem, but where fista still lands at a 1e-3-scale error, plain ista's slower O(1/t) convergence leaves it far short — the practical reason FISTA, not ISTA, is the default convex choice.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "ista", lambda = 1e-3)
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
1000 iterations, converged false, error 1.58e0

Case: Convex (bp)

Basis pursuit by ADMM: solves min ‖x‖₁ subject to Ax = y exactly rather than a penalized version of it — no lambda= to choose at all — landing between the greedy methods' machine precision and FISTA/ISTA's shrinkage bias, at a cost of roughly a hundred iterations on this problem.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
x[91] = 0.75
y = A * x

r = cs_recover(A, y, method = "bp")
print("{r.iterations} iterations, converged {r.converged}, error {norm(r.x - x):.2e}")
output
115 iterations, converged true, error 1.02e-8

The shrinkage, and debias=true

The l1 penalty does two jobs with one term: it finds the support, and it pulls every coefficient toward zero. The first is what you wanted; the second is a systematic bias, visible as a few parts in ten thousand.

Once the support is known that pull has no purpose. debias = true re-fits those columns by plain least squares and discards the shrunk values.

The example below recovers the same 2-sparse setup twice with method = "fista": once as raw, straight off the solver, and once as fixed, with debias = true added. Both find the same support, but raw.x still carries the l1 shrinkage on its two non-zero coefficients, while fixed.x has had that support re-fit by ordinary least squares. The two printed lines report the worst-case coefficient error, max(abs(...)), for each version; the debiased one should read several orders of magnitude smaller.

A = randn(60, 200, seed = 7) / sqrt(60)
x = zeros(200)
x[7] = 1.5
x[33] = -2.0
y = A * x

raw = cs_recover(A, y, method = "fista", lambda = 1e-3)
fixed = cs_recover(A, y, method = "fista", lambda = 1e-3, debias = true)
print("fista alone    {max(abs(raw.x - x)):.2e}")
print("fista debiased {max(abs(fixed.x - x)):.2e}")
output
fista alone    9.41e-4
fista debiased 2.11e-12

Standard practice, and cheap. The one reason not to: if the support is wrong, debiasing commits to the error instead of leaving it visibly shrunk.

What comes back

cs_recover returns a record, and the diagnostics are not decoration:

FieldType / shapeMeaning
.xVec, length nThe recovered signal.
.supportVec of indices, length ≤ nIndices where .x is non-zero, ascending.
.residualnumber‖A·x - y‖₂ at the answer.
.iterationsnumber (an integer count)How many the solver actually ran.
.convergedboolWhether the tolerance was met, or the iteration cap hit.
.methodstringThe method= value that was used, echoed back exactly as passed (or "omp" if method= was omitted) — useful when a script loops over several methods and prints results together.

A method that ran out of iterations and one that converged return the same shape of answer, and are told apart only by .converged. A script that ignores it will eventually publish a result that never converged.

The other kind of sparsity

Everything above assumes few non-zero *samples*. Total variation assumes few *changes* instead — a staircase, a segmented profile, a step response — by penalising ‖∇x‖₁ rather than ‖x‖₁.

tv_denoise(y, lambda) takes the noisy signal y (a Vec, length n) and the penalty weight lambda (a number, default 1.0 if the second argument is omitted — larger means smoother), and returns a Vec of length n: the denoised signal.

The example below builds a clean step (clean), corrupts it with Gaussian noise, and denoises it with tv_denoise(noisy, 0.3). sum(abs(diff(...))) is a cheap proxy for how much "changiness" is left in a signal — the total variation of the noisy trace versus the denoised one — printed before and after so the two can be compared directly; the denoised number should be far smaller, showing the flat regions were smoothed while the one real jump at the step survived.

clean = zeros(200)
for i in 60 to 129
    clean[i] = 1.0
end for
noisy = clean + 0.15 * randn(200, seed = 3)

tv = tv_denoise(noisy, 0.3)
print("total variation {sum(abs(diff(noisy))):.1f} -> {sum(abs(diff(tv))):.1f}")
output
total variation 36.5 -> 2.5

This is what recovers an edge that a smoothing filter would round off. On the signal above, against a low-pass filter tuned to remove a comparable amount of noise: TV reaches half the RMS error, and its 10–90% rise is 5 samples against the filter's 7. Neither reaches the true 1, and TV is nonlinear with a penalty to choose where a filter has a cutoff — but it never assumed the signal was smooth, only that it changes rarely.

tv_denoise uses Condat's direct algorithm: one pass, O(n), exact rather than iterative.

Choosing

Clean, k known, A well conditioned"omp"
Noisy, or k only estimated"fista", with debias = true
OMP finding the wrong support"cosamp"
Too large to factorise"niht"
Piecewise-constant signaltv_denoise

catalog/qu_compressed_sensing.qu runs all six on the same data and prints the comparison.

The claim, drawn

The whole subject rests on one surprising fact: a signal with few non-zeros can be recovered from far fewer measurements than it has unknowns. Here are 200 unknowns, 8 of them non-zero, from 60 measurements.

n = 200
k = 8
m = 60
truth = zeros(n)
for j in (11, 27, 48, 76, 103, 140, 168, 191)
    truth[j] = 1
end for

A = randn(m, n, seed = 12) / sqrt(m)
y = A * truth
r = cs_recover(A, y, method = "omp", sparsity = k)

print("{m} measurements, {n} unknowns, {k} non-zeros")
print("support found:  {r.support}")
print("recovery error: {round(rmse(truth, r.x), 8)}")

plot(0 to n - 1, truth, color = "#0f172a", width = 2, label = "truth")
scatter(0 to n - 1, r.x, color = "#e11d48", label = "recovered")
legend()
title("8 spikes from 60 measurements")
output
60 measurements, 200 unknowns, 8 non-zeros
support found:  [11, 27, 48, 76, 103, 140, 168, 191]
recovery error: 0
· plot: figure 1 (200 points)
· scatter: figure 2 (200 points)
0 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9 1.0 0 20 40 60 80 100 120 140 160 180 200 x=0, y=0 x=1, y=0 x=2, y=0 x=3, y=0 x=4, y=0 x=5, y=0 x=6, y=0 x=7, y=0 x=8, y=0 x=9, y=0 x=10, y=0 x=11, y=1 x=12, y=0 x=13, y=0 x=14, y=0 x=15, y=0 x=16, y=0 x=17, y=0 x=18, y=0 x=19, y=0 x=20, y=0 x=21, y=0 x=22, y=0 x=23, y=0 x=24, y=0 x=25, y=0 x=26, y=0 x=27, y=1 x=28, y=0 x=29, y=0 x=30, y=0 x=31, y=0 x=32, y=0 x=33, y=0 x=34, y=0 x=35, y=0 x=36, y=0 x=37, y=0 x=38, y=0 x=39, y=0 x=40, y=0 x=41, y=0 x=42, y=0 x=43, y=0 x=44, y=0 x=45, y=0 x=46, y=0 x=47, y=0 x=48, y=1 x=49, y=0 x=50, y=0 x=51, y=0 x=52, y=0 x=53, y=0 x=54, y=0 x=55, y=0 x=56, y=0 x=57, y=0 x=58, y=0 x=59, y=0 x=60, y=0 x=61, y=0 x=62, y=0 x=63, y=0 x=64, y=0 x=65, y=0 x=66, y=0 x=67, y=0 x=68, y=0 x=69, y=0 x=70, y=0 x=71, y=0 x=72, y=0 x=73, y=0 x=74, y=0 x=75, y=0 x=76, y=1 x=77, y=0 x=78, y=0 x=79, y=0 x=80, y=0 x=81, y=0 x=82, y=0 x=83, y=0 x=84, y=0 x=85, y=0 x=86, y=0 x=87, y=0 x=88, y=0 x=89, y=0 x=90, y=0 x=91, y=0 x=92, y=0 x=93, y=0 x=94, y=0 x=95, y=0 x=96, y=0 x=97, y=0 x=98, y=0 x=99, y=0 x=100, y=0 x=101, y=0 x=102, y=0 x=103, y=1 x=104, y=0 x=105, y=0 x=106, y=0 x=107, y=0 x=108, y=0 x=109, y=0 x=110, y=0 x=111, y=0 x=112, y=0 x=113, y=0 x=114, y=0 x=115, y=0 x=116, y=0 x=117, y=0 x=118, y=0 x=119, y=0 x=120, y=0 x=121, y=0 x=122, y=0 x=123, y=0 x=124, y=0 x=125, y=0 x=126, y=0 x=127, y=0 x=128, y=0 x=129, y=0 x=130, y=0 x=131, y=0 x=132, y=0 x=133, y=0 x=134, y=0 x=135, y=0 x=136, y=0 x=137, y=0 x=138, y=0 x=139, y=0 x=140, y=1 x=141, y=0 x=142, y=0 x=143, y=0 x=144, y=0 x=145, y=0 x=146, y=0 x=147, y=0 x=148, y=0 x=149, y=0 x=150, y=0 x=151, y=0 x=152, y=0 x=153, y=0 x=154, y=0 x=155, y=0 x=156, y=0 x=157, y=0 x=158, y=0 x=159, y=0 x=160, y=0 x=161, y=0 x=162, y=0 x=163, y=0 x=164, y=0 x=165, y=0 x=166, y=0 x=167, y=0 x=168, y=1 x=169, y=0 x=170, y=0 x=171, y=0 x=172, y=0 x=173, y=0 x=174, y=0 x=175, y=0 x=176, y=0 x=177, y=0 x=178, y=0 x=179, y=0 x=180, y=0 x=181, y=0 x=182, y=0 x=183, y=0 x=184, y=0 x=185, y=0 x=186, y=0 x=187, y=0 x=188, y=0 x=189, y=0 x=190, y=0 x=191, y=1 x=192, y=0 x=193, y=0 x=194, y=0 x=195, y=0 x=196, y=0 x=197, y=0 x=198, y=0 x=199, y=0 8 spikes from 60 measurements truth recovered

The error is zero — not small, zero to floating precision — because OMP identified the exact support and then solved an ordinary least-squares problem on those eight columns. That only happens while the number of measurements is comfortably above the sparsity; push m down towards k and it stops working, abruptly rather than gracefully.