import matplotlib
if not hasattr(matplotlib.RcParams, "_get"):
matplotlib.RcParams._get = dict.get
15.3 Lossless compression#
In the previous section, our key question was how to remove perceptually redundant information. Here, the key question shifts: how can we leverage statistical redundancy to compress with absolutely no distortion?
Lossless codecs carry a robust guarantee of zero distortion:
Our goal is to minimize the information rate, subject to this guarantee. We would also like the coder and decoder to be fast and simple to compute. For digital audio, lossless codecs like FLAC typically achieve compression factors of about \(1.4\times\) to \(2\times\), far less than lossy codecs, but with a perfect round trip.
The key idea of lossless compression is that the more predictable a signal is, the more we can compress it. We saw this idea in action with run-length encoding, which compressed strings with predictably long runs of \(0\)s. Let’s make this idea more precise.
Entropy#
Imagine creating strings of bits by tossing a weighted coin, which comes up heads with probability \(p\). We write down \(1\) for heads and \(0\) for tails.
If \(p = 0.01\), we get tails \(99\%\) of the time, so our strings have very long runs of \(0\)s. We already know of strategies to compress these strings, such as run-length encoding.
If \(p = 0.5\) (a fair coin), the strings look random, and it is not obvious how we could do better than using \(1\) bit per toss.
What is the theoretical minimum number of bits per toss that we could use, as a function of \(p\)? Claude Shannon answered this question in 1948 [Sha48]. (You might remember him from the Nyquist-Shannon sampling theorem!) To answer it, he introduced a measure of the information in a probability distribution, which he called entropy (after a mathematically similar quantity in physics). His work founded the field of information theory:
Definition 52 (Entropy)
The entropy of a random variable \(X\) with alphabet \(\mathcal{X}\) is
With a base-\(2\) logarithm, entropy is measured in bits.
Shannon’s source coding theorem states that no lossless codec can use fewer than \(H(X)\) bits per outcome, on average. This implies two goals: (1) estimate \(H(X)\) if possible, and (2) design a codec that can come arbitrarily close to this bound. Entropy measures how unpredictable, or surprising, a random variable is. The more predictable the outcomes, the lower the entropy, and the more we can compress them.
Let’s compute the entropy of our coin tosses, a random variable \(X\) with alphabet \(\{\text{heads}, \text{tails}\}\). For a fair coin, \(p = 0.5\):
As we suspected, we can’t do better than \(1\) bit per toss. For a coin with \(p = 0.01\):
This implies that we could compress these strings with a compression factor of up to \(\frac{1}{0.081} \approx 12.4\times\)!
Fig. 126 The entropy of a coin toss, as a function of the probability of heads.#
Huffman coding#
How can we design a practical compression algorithm that approaches the theoretical bound imposed by entropy? One classic algorithm is Huffman coding, which David Huffman invented in 1952 [Huf52]. The idea is to assign each outcome a codeword (a string of bits), giving short codewords to likely outcomes and long codewords to unlikely ones.
With only two outcomes, heads and tails, the shortest possible codewords have \(1\) bit each, so we can’t do better than \(1\) bit per toss. Instead, we encode blocks of \(N\) tosses at a time. With \(N = 2\), there are four outcomes, HH, HT, TH, and TT. Let’s use a coin with \(p = 0.1\), whose entropy is about \(0.469\) bits per toss. Since the tosses are independent, the probability of each block is the product of the probabilities of its tosses, for example, \(P(\text{TT}) = 0.9 \cdot 0.9 = 0.81\).
Algorithm 1 (Huffman coding)
Given an alphabet and the probability of each outcome:
Create a node for each outcome, labeled with its probability.
While more than one node remains, merge the two least probable nodes into a new parent node, whose probability is the sum of theirs. Label the edges to its two children \(0\) and \(1\).
The codeword for each outcome is the sequence of edge labels on the path from the final (root) node to that outcome.
For our four outcomes, we first merge HH (\(0.01\)) with HT (\(0.09\)), then their parent (\(0.10\)) with TH (\(0.09\)), then their parent (\(0.19\)) with TT (\(0.81\)):
Fig. 127 The Huffman tree for blocks of \(N = 2\) tosses of a coin with \(P(\text{heads}) = 0.1\). Each codeword is the sequence of edge labels from the root to a leaf.#
This gives the codewords TT → \(\texttt{0}\), TH → \(\texttt{11}\), HT → \(\texttt{100}\), and HH → \(\texttt{101}\). Notice that no codeword is the beginning (prefix) of another. This makes Huffman codes easy to decode, even though the codewords have different lengths. For example, \(\texttt{0110100}\) can only be read as \(\texttt{0}\,|\,\texttt{11}\,|\,\texttt{0}\,|\,\texttt{100}\), or TT TH TT HT.
The common case, a string of all tails, takes just \(1\) bit per pair, or \(\frac{1}{2}\) bit per toss. A rare string can take up to \(3\) bits per pair. In expectation, the code takes
a compression factor of \(\frac{1}{0.645} \approx 1.55\times\), approaching the theoretical limit of \(\frac{1}{0.469} \approx 2.13\times\). As the block length \(N\) grows, the expected length of a Huffman code approaches the entropy. The widget below builds the Huffman tree for any block length \(N\) and probability \(p\):
Huffman coding is how MP3 losslessly compresses its quantized spectrogram. The mostly-zero coefficients are highly predictable, so Huffman codes compress them efficiently. Ogg Vorbis uses closely related codebooks.
FLAC#
The techniques we’ve studied so far work well for coin tosses, which are independent of one another. But for audio, we know that each sample is closely related to its neighbors. How can we exploit this?
The Free Lossless Audio Codec (FLAC), developed by Josh Coalson in 2001 and now maintained by the Xiph.Org Foundation, takes a two-step approach. It (1) uses a statistical model to predict each sample from the samples before it, removing the redundancy between neighboring samples, and (2) losslessly compresses whatever the model could not predict. FLAC splits audio into blocks of a few thousand samples, and for each block, the coder:
Fits a linear predictive coding (LPC) model of order \(K\), from Chapter 14, which predicts each sample as a linear combination of the \(K\) samples before it, with coefficients \(h_1, \ldots, h_K\).
Quantizes the coefficients to integers.
Computes the residual, the difference between the signal and the prediction.
Losslessly compresses the residual, using Rice coding.
The decoder reverses the process. Each step deserves a closer look.
Quantizing the coefficients. Our samples are integers (for example, \(16\)-bit PCM), but the optimal LPC coefficients are real numbers. To guarantee a perfect round trip, the coder and decoder must compute exactly the same predictions, so FLAC sticks to integer arithmetic. It scales each coefficient by \(2^s\) (for some shift \(s\), such as \(12\)) and rounds it to an integer, \(q_k = \text{round}(2^s h_k)\). Then the prediction, and the residual, are integers too:
The coder stores the \(K\) quantized coefficients, the first \(K\) samples of the block (verbatim), and the residual. Just as in Chapter 14, the decoder inverts this with a recursive filter, \(x[n] = e[n] + \hat{x}[n]\). Since \(\hat{x}[n]\) depends only on samples that the decoder has already reconstructed exactly, the round trip is perfect.
Why does this help? A good predictor leaves a residual that is much smaller than the signal, and concentrated near zero:
Fig. 128 A block of a drum loop as 16-bit integers, its prediction by a quantized LPC model with \(K = 8\), and the residual (left). The residual’s values are concentrated near zero (right).#
Rice coding. Small residual values are much more common than large ones, often exponentially so. Rice coding is a simple and fast lossless code that is nearly optimal for such data [Ric79]. Unlike Huffman coding, which needs a table of probabilities for a fixed alphabet, Rice coding operates directly on integers, with a single parameter \(k\).
First, since residuals can be negative, Rice coding maps them to non-negative integers with a zigzag encoding, \(0, -1, 1, -2, 2, \ldots \mapsto 0, 1, 2, 3, 4, \ldots\):
Then, it splits \(u\) into a quotient and a remainder, \(q = \lfloor u / 2^k \rfloor\) and \(r = u \bmod 2^k\). It writes the quotient in unary, as \(q\) ones followed by a zero (\(0 \to \texttt{0}\), \(1 \to \texttt{10}\), \(2 \to \texttt{110}\), …), and the remainder in ordinary binary, with exactly \(k\) bits. Small values have small quotients, and therefore short codes. For example, with \(k = 4\):
\(v = 5\): \(u = 10\), so \(q = 0\) and \(r = 10\). The code is \(\texttt{0}\,|\,\texttt{1010}\), \(5\) bits.
\(v = -60\): \(u = 119\), so \(q = 7\) and \(r = 7\). The code is \(\texttt{11111110}\,|\,\texttt{0111}\), \(12\) bits.
The code below implements Rice coding for \(k = 4\):
def zigzag(v: int) -> int:
"""Interleave signed integers as 0, -1, 1, -2, 2, ... -> 0, 1, 2, 3, 4, ..."""
return 2 * v if v >= 0 else -2 * v - 1
def rice_encode(v: int, k: int) -> str:
"""The Rice code of a signed integer v with parameter k, as a string of bits."""
u = zigzag(v)
q, r = u >> k, u & ((1 << k) - 1) # quotient and remainder of u / 2^k
unary = "1" * q + "0" # q ones, then a terminating zero
binary = format(r, f"0{k}b") if k else "" # the remainder, in exactly k bits
return unary + binary
for v in [0, -1, 1, 5, -60]:
code = rice_encode(v, k=4)
print(f"{v:4d} -> {code} ({len(code)} bits)")
0 -> 00000 (5 bits)
-1 -> 00001 (5 bits)
1 -> 00010 (5 bits)
5 -> 01010 (5 bits)
-60 -> 111111100111 (12 bits)
The FLAC coder chooses \(k\) for each block to minimize the total length of its codes. Larger residuals call for a larger \(k\). Putting all of these steps together, the code below implements a toy version of FLAC. It compresses our drum loop to fewer than \(10\) bits per sample (a compression factor of about \(1.8\times\)), and verifies that every block decodes perfectly:
def encode_block(x: np.ndarray, K: int = 8, shift: int = 12) -> tuple[np.ndarray, np.ndarray]:
"""Fit LPC coefficients to a block of integer samples, quantize them to integers
(in units of 2^-shift), and return them along with the integer residual."""
N = len(x)
X = np.stack([x[K - k:N - k] for k in range(1, K + 1)], axis=1) # row n: x[n-1], ..., x[n-K]
h, *_ = np.linalg.lstsq(X.astype(float), x[K:].astype(float), rcond=None)
q = np.round(h * 2 ** shift).astype(np.int64) # quantized coefficients
prediction = (X @ q) >> shift # integer arithmetic only
return q, x[K:] - prediction
def decode_block(warmup: np.ndarray, q: np.ndarray, residual: np.ndarray,
shift: int = 12) -> np.ndarray:
"""Invert encode_block: x[n] = e[n] + prediction, one sample at a time (recursive)."""
x = [int(v) for v in warmup]
q = [int(c) for c in q]
for e in residual:
prediction = sum(c * x[-k] for k, c in enumerate(q, start=1)) >> shift
x.append(int(e) + prediction)
return np.array(x)
drums = pq.Audio.from_file("./assets/audio-drums.wav")
x = np.round(drums.samples[:, 0] * 32768).astype(np.int64) # back to 16-bit integers
B, K = 4096, 8
bits = 0
for start in range(0, len(x) - K, B):
block = x[start:start + B]
q, residual = encode_block(block, K)
k = min(range(16), key=lambda k: rice_bits(zigzag(residual), k)) # best k per block
bits += 16 * K + 16 * K + 4 + rice_bits(zigzag(residual), k) # warm-up, coefficients, k, residual
assert np.array_equal(decode_block(block[:K], q, residual), block) # lossless!
print(f"Uncompressed: 16 bits per sample")
print(f"Toy FLAC: {bits / len(x):.2f} bits per sample (compression factor {16 * len(x) / bits:.2f}×)")
Uncompressed: 16 bits per sample
Toy FLAC: 9.07 bits per sample (compression factor 1.76×)