Qu

Sparse and Compressed Sensing

The idea

The sampling theorem says: to recover a signal with no ambiguity, sample at twice its highest frequency, no less. That bound is airtight under one assumption it is easy to forget is being made — that nothing beyond the signal's bandwidth is known in advance.

Compressed sensing adds a second assumption and the bound changes completely. Suppose a signal of length \(n\) is sparse in some basis: almost all its coefficients are exactly zero, and only \(k \ll n\) are not. A spectrum that is a handful of tones among thousands of frequency bins is sparse in the Fourier basis; a natural image is sparse in a wavelet basis, which is the actual mechanism a JPEG-family codec exploits.

In symbols, take \(m\) linear measurements of an \(n\)-vector \(x\):

\[ y = Ax, \qquad A \in \mathbb{R}^{m \times n},\ m \ll n,\ \|x\|_0 = k \]

An ordinary \(m \times n\) system with \(m < n\) has infinitely many solutions and reveals nothing. Add the sparsity constraint, and under a condition on \(A\) called the restricted isometry property — that for every \(k\)-sparse vector \(v\),

\[ (1 - \delta_k)\,\|v\|_2^2 \le \|Av\|_2^2 \le (1 + \delta_k)\,\|v\|_2^2 \]

holds with \(\delta_k\) small — there is exactly one sparse \(x\) consistent with \(y\), recoverable in principle by

\[ \hat{x} = \arg\min_{x} \|x\|_1 \quad \text{subject to} \quad y = Ax, \]

or, more cheaply, by a greedy method that builds up the support one column at a time. Recovery needs not "twice the bandwidth" but roughly \(m \sim k \log(n/k)\) measurements — a number that can be far smaller than \(n\) itself. Emmanuel Candès, Justin Romberg and Terence Tao, and independently David Donoho, published the founding results within months of each other in 2006; Nyquist's bound is from 1928.

What makes a vector a candidate for this at all is how fast its sorted coefficients decay — a genuinely sparse or compressible signal collapses to near-zero after a handful of terms, where unstructured noise does not:

seed(3)
n = 200
sparse = zeros(n)
for i in 0 to 5
    sparse[i * 30 + 5] = 5.0 / (i + 1)
end for
dense = randn(n, seed = 6)
sorted_sparse = sort(abs(sparse), true)
sorted_dense = sort(abs(dense), true)

figure()
plot(0 to n - 1, sorted_sparse, color = "#0f172a", width = 2, label = "sparse signal")
plot(0 to n - 1, sorted_dense, color = "#5B7CFA", width = 2, label = "dense (noise) signal")
xlabel("coefficient rank")
ylabel("magnitude (sorted, descending)")
title("Compressibility: how fast sorted coefficients decay")
legend()
output
· plot: figure 1 (200 points)
· plot: figure 2 (200 points)
0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 4.0 4.5 5.0 0 20 40 60 80 100 120 140 160 180 200 Compressibility: how fast sorted coefficients decay coefficient rank magnitude (sorted, descending) sparse signal dense (noise) signal

Six non-zero values, and the sparse curve is flat at zero past rank 6; the dense curve decays slowly because every one of its 200 values is doing some work. Compressed sensing is only ever exploiting curves shaped like the first one.

In Qu

cs_recover performs the reconstruction. Build a 300-bin spectrum with five active tones, take 90 random linear measurements of it — 90, not 300 — and ask for it back:

seed(4)
n = 300
k = 5
m = 90
spectrum = zeros(n)
bins = (18, 63, 122, 201, 267)
amps = [3.0, -2.2, 1.6, 2.8, -1.1]
idx = 0
for b in bins
    spectrum[b] = amps[idx]
    idx = idx + 1
end for

A = randn(m, n, seed = 4) / sqrt(m)
y = A * spectrum
r = cs_recover(A, y, method = "omp", sparsity = k)
print("{m} compressive measurements of a {n}-bin spectrum, {k} active tones")
print("support found:  {r.support}")
print("true support:   {bins}")
print("recovery error: {round(rmse(spectrum, r.x), 8)}")

figure()
plot(0 to n - 1, spectrum, color = "#0f172a", width = 2, label = "true spectrum")
scatter(0 to n - 1, r.x, color = "#e11d48", label = "recovered")
xlabel("frequency bin")
ylabel("amplitude")
title("Recovering a 300-bin spectrum from 90 measurements")
legend()
output
90 compressive measurements of a 300-bin spectrum, 5 active tones
support found:  [18, 63, 122, 201, 267]
true support:   (18, 63, 122, 201, 267)
recovery error: 0
· plot: figure 3 (300 points)
· scatter: figure 4 (300 points)
-2.0 -1.5 -1.0 -0.5 0 0.5 1.0 1.5 2.0 2.5 3.0 0 50 100 150 200 250 300 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=0 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=3 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=0 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=0 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=-2.2 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=0 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=0 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=1.6 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=0 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=0 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=0 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 x=200, y=0 x=201, y=2.8 x=202, y=0 x=203, y=0 x=204, y=0 x=205, y=0 x=206, y=0 x=207, y=0 x=208, y=0 x=209, y=0 x=210, y=0 x=211, y=0 x=212, y=0 x=213, y=0 x=214, y=0 x=215, y=0 x=216, y=0 x=217, y=0 x=218, y=0 x=219, y=0 x=220, y=0 x=221, y=0 x=222, y=0 x=223, y=0 x=224, y=0 x=225, y=0 x=226, y=0 x=227, y=0 x=228, y=0 x=229, y=0 x=230, y=0 x=231, y=0 x=232, y=0 x=233, y=0 x=234, y=0 x=235, y=0 x=236, y=0 x=237, y=0 x=238, y=0 x=239, y=0 x=240, y=0 x=241, y=0 x=242, y=0 x=243, y=0 x=244, y=0 x=245, y=0 x=246, y=0 x=247, y=0 x=248, y=0 x=249, y=0 x=250, y=0 x=251, y=0 x=252, y=0 x=253, y=0 x=254, y=0 x=255, y=0 x=256, y=0 x=257, y=0 x=258, y=0 x=259, y=0 x=260, y=0 x=261, y=0 x=262, y=0 x=263, y=0 x=264, y=0 x=265, y=0 x=266, y=0 x=267, y=-1.1 x=268, y=0 x=269, y=0 x=270, y=0 x=271, y=0 x=272, y=0 x=273, y=0 x=274, y=0 x=275, y=0 x=276, y=0 x=277, y=0 x=278, y=0 x=279, y=0 x=280, y=0 x=281, y=0 x=282, y=0 x=283, y=0 x=284, y=0 x=285, y=0 x=286, y=0 x=287, y=0 x=288, y=0 x=289, y=0 x=290, y=0 x=291, y=0 x=292, y=0 x=293, y=0 x=294, y=0 x=295, y=0 x=296, y=0 x=297, y=0 x=298, y=0 x=299, y=0 Recovering a 300-bin spectrum from 90 measurements frequency bin amplitude true spectrum recovered
90 compressive measurements of a 300-bin spectrum, 5 active tones
support found:  [18, 63, 122, 201, 267]
true support:   (18, 63, 122, 201, 267)
recovery error: 0

Zero, not approximately zero. cs_recover(..., method = "omp") — orthogonal matching pursuit — adds one column of A to its working support per iteration, the one most correlated with whatever of y is not yet explained, then re-solves an ordinary least-squares problem on exactly those columns once it has found all five. With the true support in hand, that solve is exact to floating point. The 90-row sensing matrix never knew where the five nonzero bins were. It did not need to.

Before trusting a result this clean, coherence(A) measures the worst-case similarity between any two columns of A (small is good), and cs_guarantee(A) turns that into a sparsity bound the theory can actually *prove* recoverable:

print("coherence {round(coherence(A), 3)}, provable sparsity {cs_guarantee(A)}")
output
coherence 0.439, provable sparsity 1
coherence 0.439, provable sparsity 1

Read literally, only \(k=1\) is guaranteed — yet the recovery above found \(k=5\) perfectly. The guarantee is genuinely that pessimistic: coherence is the only certificate cheap enough to compute (the restricted isometry property itself is NP-hard to verify for a specific matrix), and a random Gaussian matrix routinely outperforms what coherence alone can promise. Treat a coherence pass as reassurance, a failure as no information, and never treat success on this bound as proof recovery will fail.

Hold the signal fixed and shrink the number of measurements toward the sparsity count itself to see where the guarantee stops being pessimistic and starts being right:

ms = (90, 60, 30, 20, 15, 10, 8)
errs = zeros(length(ms))
i = 0
for mm in ms
    Am = randn(mm, n, seed = 4) / sqrt(mm)
    ym = Am * spectrum
    rm = cs_recover(Am, ym, method = "omp", sparsity = k)
    errs[i] = rmse(spectrum, rm.x)
    print("m={mm}: recovery error {round(errs[i], 6)}")
    i = i + 1
end for

figure()
plot(ms, errs, color = "#0f172a", width = 2)
scatter(ms, errs, color = "#e11d48")
xlabel("number of measurements m")
ylabel("recovery rmse")
title("Recovery collapses as m approaches k=5")
output
m=90: recovery error 0
m=60: recovery error 0
m=30: recovery error 0.30425
m=20: recovery error 0.219978
m=15: recovery error 0.357057
m=10: recovery error 0.387586
m=8: recovery error 0.437739
· plot: figure 5 (7 points)
· scatter: figure 6 (7 points)
0 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 10 20 30 40 50 60 70 80 90 x=90, y=0 x=60, y=0 x=30, y=0.304 x=20, y=0.22 x=15, y=0.357 x=10, y=0.388 x=8, y=0.438 Recovery collapses as m approaches k=5 number of measurements m recovery rmse
m=90: recovery error 0
m=60: recovery error 0
m=30: recovery error 0.30425
m=20: recovery error 0.219978
m=15: recovery error 0.357057
m=10: recovery error 0.387586
m=8: recovery error 0.437739

The textbook rule of thumb is \(m \approx 4k = 20\) measurements. The curve is not perfectly monotonic — m=20 lands slightly better than m=30, sampling jitter this close to the transition, not a contradiction — but the shape is unmistakable: exact recovery through m=60, then a real collapse, not a graceful fade, well before m reaches the sparsity count itself. The rule of thumb is a starting point for "try it and check," not a guarantee; the coherence bound is the only number here that comes with one attached, and it was the more pessimistic of the two by a wide margin.

Nothing about cs_recover reconstructed a *generic* 300-number vector from 90 measurements — that is impossible, and no algorithm changes the linear algebra of an underdetermined system. What made it possible was the sparsity assumption, checked after the fact by the recovered residual and never smuggled in as a free lunch. That trade — structure for sample count — is also the trade a classifier makes: rather than reconstructing every sample of a signal faithfully, it bets that a few well-chosen numbers extracted from it already carry everything a decision needs. Lesson 21 takes that bet and measures whether it pays off.