The reason \(k=20\) gets that close is the shape of the spectrum. On the left, the singular values on a log scale; on the right, the fraction of the energy \(\sum_i \sigma_i^2\) kept by a rank-\(k\) truncation. The dashed line marks \(k=20\) in both.
idx = np.arange(1, len(s) +1)cumulative = np.cumsum(s **2) / total_energyn, d = gray.shapefig, axes = plt.subplots(1, 2, figsize=(9, 3))axes[0].plot(idx, s, color=TEAL, linewidth=1.6)axes[0].axvline(20, color="black", linestyle="--", linewidth=1.2)axes[0].set_yscale("log")axes[0].set_xlabel("Index $i$")axes[0].set_ylabel("Singular value $\\sigma_i$ (log scale)")axes[1].plot(idx, cumulative, color=CARDINAL, linewidth=1.6)axes[1].axvline(20, color="black", linestyle="--", linewidth=1.2)axes[1].set_ylim(0, 1.02)axes[1].set_xlabel("Rank $k$")axes[1].set_ylabel("Fraction of energy kept")plt.show()print(f"rank 20 keeps {cumulative[19]:.0%} of the energy")print(f"rank 20 costs {20* (n + d +1) / (n * d):.0%} of the storage")
rank 20 keeps 97% of the energy
rank 20 costs 7% of the storage
Apply the same method to video
Flatten every frame of a short video into one column of a matrix (pixels \(\times\) frames). Its singular values measure how many directions contain most of the video’s energy.
rng = np.random.default_rng(15)T, H, W =60, 40, 40xs, ys = np.meshgrid(np.arange(W), np.arange(H))frames = []for t inrange(T): cx =5+ (W -10) * t / (T -1) # a blob drifting left to right cy = H /2 frame = np.exp(-((xs - cx) **2+ (ys - cy) **2) / (2*6**2)) frame += rng.normal(0, 0.02, size=frame.shape) frames.append(frame.ravel())M = np.array(frames).T # (H*W) x T: one column per frameprint("video-as-matrix shape:", M.shape)u_v, s_v, vt_v = np.linalg.svd(M, full_matrices=False)total_v = np.sum(s_v **2)for k in [1, 2, 3, 5]:print(f"rank {k}: {np.sum(s_v[:k] **2) / total_v:.1%} of the video's energy")
video-as-matrix shape: (1600, 60)
rank 1: 56.7% of the video's energy
rank 2: 86.2% of the video's energy
rank 3: 96.5% of the video's energy
rank 5: 99.4% of the video's energy
For this moving-blob video, rank \(3\) retains nearly all of the visible motion.
Add more noise
When the signal is concentrated in a few singular directions, low-rank reconstruction should discard more noise than signal. Try noise_std = 0.3 below (much noisier than the 0.02 used above) and compare the rank-\(3\) reconstruction with the noisy original.
The rank-\(3\) reconstruction of the noisy video is close to the clean original. The blob’s motion is concentrated in the top three singular directions, while the noise is spread across all \(60\), so truncation removes more noise than signal.