想学SVD(奇异值分解)算法?看这篇就够了

文摘   2024-08-11 15:29   广东  

线性代数是机器学习领域的基础,其中一个最重要的概念是奇异值分解(SVD),本文尽可能简洁的介绍SVD(奇异值分解)算法的基础理解,以及它在现实世界中的应用。

SVD是最广泛使用的无监督学习算法之一,它在许多推荐系统和降维系统中居于核心位置,这些系统是全球公司如谷歌、Netflix、Facebook、YouTube等的核心技术。

简单来说,SVD是将一个任意矩阵分解为三个矩阵。所以如果我们有一个矩阵A,那么它的SVD可以表示为:

A是矩阵,U是的正交矩阵,的正交矩阵。

U也被称为左奇异向量,S为奇异值,V为右奇异向量。

带维度的奇异值分解:

用矩阵表示奇异值分解:

image-20240808230323995

我们通常将具有较大特征值的向量排列在前,而较小特征值的向量则排在后面。

特征值与向量的对应关系:

与特征值分解相比,奇异值分解可以应用于非方阵。在SVD中,U和 V 对于任何矩阵都是可逆的,并且它们是正交归一的,这是我们所喜爱的特性。虽然这里不进行证明,但我们可以告诉你,奇异值比特征值在数值上更稳定。

为了更好地理解,我们通过一个例子演示SVD

假设我们有非方阵A:

我们计算矩阵与转置矩阵的乘积,有:

求解的特征值和特征向量:



求解的特征值和特征向量:



奇异值是正特征值的平方根,即5和3。因此非方阵A的SVD分解为:

SVD分解证明

为了得到矩阵A的SVD分解:

我们需要求解正交矩阵U和V

其中I是单位矩阵。

上面3个方程有3个未知数,因此是可以求解这3个未知数的。

矩阵A的转置是:

由于正交矩阵V和U满足:

我们计算


最后一个方程等价于求矩阵的特征向量,我们只需将所有特征向量放入一个矩阵中,矩阵S则是包含特征值的对角矩阵。

,有:

其中的特征值。

计算矩阵的表达式:

image-20240810203329639

其中矩阵V包含了的所有特征向量,矩阵S包含了的所有特征值的平方根。我们可以对重复相同的工程,并得到一个类似的方程。

image-20240810203805956

现在我们知道了通过分别得到了矩阵U和V,其中U和V包含了各自的特征向量,S的特征值是矩阵特征值的平方根。因此3个未知量都求解了,即可得到矩阵A的SVD分解:

SVD的另一种表述

由于矩阵V是正交的,等于单位矩阵I。我们可以将SVD方程重新写为:

矩阵V:

其中

矩阵U:

其中

矩阵S:



我们取第1个特征向量:

得:

可以通用化上式:

因此A的SVD分解可以看做是的一系列外积:

SVD降维

若我们取前k个奇异值,根据上式得到新的矩阵


矩阵是降维后的数值。若大家还有疑问,欢迎留言。

SVD应用

1.图像降维

我们可以将图像存储在一个矩阵中。每个图像由一组像素组成,这些像素是构成该图像的基本单元。每个像素表示图像中某个特定位置的颜色或光强度。在 PNG 格式的灰度图像中,每个像素的值在 0 和 1 之间,其中 0 对应黑色,1 对应白色。因此一个具有 像素的灰度图像可以存储在一个 矩阵或 NumPy 数组中。在这里,我们使用 imread() 函数加载一张爱因斯坦的灰度图像,其分辨率为 480 × 423 像素,并将其存储为一个二维数组。然后,我们使用 SVD 来分解该矩阵,并使用前 30 个奇异值来重建图像。

# Reading the image
mat = plt.imread("Picture.png")

# SVD 
U, s, VT = LA.svd(mat)

Sigma = np.zeros((mat.shape[0], mat.shape[1]))
Sigma[:min(mat.shape[0], mat.shape[1]), :min(mat.shape[0], mat.shape[1])] = np.diag(s)

# Reconstruction of the matrix using the first 30 singular values
k = 30  # 取前30个特征值,重建图像
mat_approx = U[:, :k] @ Sigma[:k, :k] @ VT[:k, :]

fig, (ax1, ax2) = plt.subplots(12, figsize=(10,8))
plt.subplots_adjust(wspace=0.3, hspace=0.2)

ax1.imshow(mat, cmap='gray')
ax1.set_title("Original image")

ax2.imshow(mat_approx, cmap='gray')
ax2.set_title("Reconstructed image using the \n first {} singular values".format(k))
plt.show()

image-20240810223105337

左图是原始图像,右图是取前30个奇异值重建的图像。

原始矩阵是 480×423 的,因此我们需要存储 480×423=203040 个值。经过 SVD 后,每个具有 480 个元素,每个 具有 423 个元素。为了使用前 30 个奇异值重建图像,我们只需要保留前 30 个 ,这意味着只需要存储 30×(1+480+423)=27120 个值。这大约是原始图像所需值数量的 13%。因此,通过使用 SVD,我们可以得到原始图像的良好近似,并节省大量内存。

下图计算了前 6 个奇异值对应的矩阵。每个矩阵 的秩为 1,并且具有与原始矩阵相同的行数和列数。

fig, axes = plt.subplots(23, figsize=(10,8))
plt.subplots_adjust(wspace=0.3, hspace=0.2)

for i in range(06):
    mat_i = s[i] * U[:,i].reshape(-1,1) @ VT[i,:].reshape(1,-1)
    axes[i // 3, i % 3].imshow(mat_i)
    axes[i // 3, i % 3].set_title("$\sigma_{0}\mathbf{{u_{0}}}\mathbf{{v_{0}}}^T$".format(i+1), fontsize=16)
    
plt.show()

image-20240810223817059

请注意,与原始灰度图像不同,这些秩为 1 的矩阵的元素值可以大于 1 或小于 0,它们不应被解释为灰度图像。因此,我没有使用 cmap='gray' 也没有将它们显示为灰度图像。在绘制这些矩阵时,我们不关心像素的绝对值,而是关注它们之间的相对值。

为了理解每个矩阵中如何存储图像信息,我们可以研究一个更简单的图像。下面的代码读取了一个包含五个简单形状的二进制图像(一个矩形和 4 个圆形),并取前2个、前4个和前6个奇异值重建图像。

mat = plt.imread("shapes.png")

# SVD 
U, s, VT = LA.svd(mat)

Sigma = np.zeros((mat.shape[0], mat.shape[1]))
Sigma[:min(mat.shape[0], mat.shape[1]), :min(mat.shape[0], mat.shape[1])] = np.diag(s)

fig, axes = plt.subplots(22, figsize=(10,8))
plt.subplots_adjust(wspace=0.3, hspace=0.2)

axes[00].imshow(mat, cmap='gray')
axes[00].set_title("Original image")

for i in range(14):
    k = i * 2
    # Reconstruction of the matrix using the first k singular values
    mat_approx = U[:, :k] @ Sigma[:k, :k] @ VT[:k, :]

    axes[i // 2, i % 2].imshow(mat_approx, cmap='gray')
    axes[i // 2, i % 2].set_title("Reconstructed image using the \n first {} singular values".format(k))

plt.show()

image-20240810224420549

现在我们绘制前 6 个奇异值对应的矩阵:

fig, axes = plt.subplots(23, figsize=(10,6))
plt.subplots_adjust(wspace=0.3, hspace=0.05)

for i in range(06):
    mat_i = s[i] * U[:,i].reshape(-1,1) @ VT[i,:].reshape(1,-1)
    #mat_i[mat_i < 1e-8] = 0
    axes[i // 3, i % 3].imshow(mat_i)
    axes[i // 3, i % 3].set_title("$\sigma_{0}\mathbf{{u_{0}}}\mathbf{{v_{0}}}^T$".format(i+1), fontsize=16)
    
plt.show()

image-20240810224526788

从每个奇异值的图像可以看出,前两个奇异值构建的矩阵大致捕捉了四个圆形作为四个矩形,而最后四个奇异值构建的矩阵则添加了更多细节。

如下图,图像中有 6 根柱子,第一个奇异值对应的矩阵可以捕捉到原始图像中的柱子数量。

image-20240810224851967
2.特征脸

在这个示例中,我们将使用 Scikit-learn 库中的 Olivetti faces 数据集。该数据集包含 400 张图像,图像展示了 40 位不同受试者的面部特征。对于一些受试者,图像是在不同时间拍摄的,具有不同的光照、面部表情和面部细节。这些图像为灰度图像,每张图像具有 64×64 像素。每个像素的强度值在区间 [0, 1] 上。首先,我们加载数据集:

data = fetch_olivetti_faces()
imgs = data.images
print(imgs.shape)

我们调用它来读取数据,并将图像存储在 imgs 数组中。这是一个形状为 (400, 64, 64) 的数组,包含 400 张 64×64 的灰度图像。我们可以在这里展示其中的一些作为示例:

fig, axes = plt.subplots(15, figsize=(148))
plt.subplots_adjust(wspace=0.4)

for i in range(05):
    axes[i].imshow(imgs[i*40], cmap='gray')

plt.show()

image-20240810225330410

在前一个示例中,我们将原始图像存储在一个矩阵中,然后使用 SVD 对其进行分解。在这里,我们采用另一种方法。我们有400 张图像并给每张图像分配一个从 1 到 400 的标签。现在我们使用独热编码来表示这些标签,使用一个包含 400 个元素的列向量。对于每个标签 k,除了第 k个元素,所有元素都是零。因此,标签 k将由以下向量表示:

image-20240810225600111

现在我们将每张图像存储在一个列向量中。每张图像有 64 × 64 = 4096 个像素。因此,我们可以将每张图像展平并将像素值放入一个具有 4096 个元素的列向量中,如下图所示:

image-20240810230051322

因此,每张标签为k的图像将存储在向量中,我们需要 400 个 向量来保存所有图像。现在我们定义一个变换矩阵,它将标签向量 转换为其对应的图像向量。这些 向量将作为矩阵 的列:

image-20240810225925424

这个矩阵有 4096 行和 400 列。我们可以简单地使用 来找到每个标签对应的图像(可以是任何向量 ,而 将是对应的)。例如,对于这个数据集中的第三张图像,标签是 3, 的所有元素都是零,除了第三个元素是 1。现在记住分块矩阵的乘法,当我们用 乘以 时,除了第三列 之外, 的所有列都被乘以零,所以:

image-20240810230844410

现在我们对矩阵进行SVD分解。

我们计算并显示前8个奇异值对应的U向量:

U, s, VT = LA.svd(M)

fig, axes = plt.subplots(24, figsize=(10,6))
plt.subplots_adjust(wspace=0.3, hspace=0.1)

for i in range(08):
    axes[i // 4, i % 4].imshow(U[:, i].reshape((64,64)))
    axes[i // 4, i % 4].set_title("$\mathbf{{u_{0}}}$".format(i+1), fontsize=16)
    
plt.show()

image-20240810231222673

上图就是我们要介绍的特征脸,每个特征脸捕捉了图像向量的一些信息。例如特征脸主要反映了眼睛信息,特征脸捕捉了鼻子信息。

上述代码的数组s具有400个元素,因此我们有400个非零的奇异值,矩阵的矩为400。因此我们需要前 400 个 向量来完全重建矩阵,使用这些基向量轻松重建其中一张图像。

x= np.zeros((4001))
x[1600] = 1
Sigma = np.zeros((M.shape[0], M.shape[1]))
Sigma[:min(M.shape[0], M.shape[1]), :min(M.shape[0], M.shape[1])] = np.diag(s)

fig, axes = plt.subplots(24, figsize=(148))
fig.suptitle("Reconstructed image using the first k singular values", fontsize=16)
plt.subplots_adjust(wspace=0.3, hspace=0.1)

axes[00].imshow(imgs[160], cmap='gray')
axes[00].set_title("Original image")

k_list = [161015203580]
for i in range(18):
    # Reconstruction of the matrix using the first k singular values
    k = k_list[i-1
    mat_approx = U[:, :k] @ Sigma[:k, :k] @ VT[:k, :] @ x

    axes[i // 4, i % 4].imshow(mat_approx.reshape((64,64)), cmap='gray')
    axes[i // 4, i % 4].set_title("k = {}".format(k))

plt.show()

这里我们使用不同个数的奇异值去重建第160号图像(k表示使用前k个奇异值重建图像)。

image-20240810232014008

上图反映的信息与特征脸步骤的细节基本一致,如最高奇异值(k=1)重建的图像反映了眼睛信息,同样地,第一个特征脸反映的也是眼睛信息。

3.降低噪声

SVD可以用于降低图像中的噪声。

mat = plt.imread("text.png")

# Adding noise
noise = np.random.rand(mat.shape[0], mat.shape[1])
mat[noise > 0.95] = 0

# SVD 
U, s, VT = LA.svd(mat)

Sigma = np.zeros((mat.shape[0], mat.shape[1]))
Sigma[:min(mat.shape[0], mat.shape[1]), :min(mat.shape[0], mat.shape[1])] = np.diag(s)

fig, axes = plt.subplots(22, figsize=(10,8))
plt.subplots_adjust(wspace=0.2, hspace=0.1)

axes[00].imshow(mat, cmap='gray')
axes[00].set_title("Original image", y=1.08)

k_list = [2055200]
for i in range(14):
    k = k_list[i-1]
    mat_rank_k = U[:, :k] @ Sigma[:k, :k] @ VT[:k, :]
    axes[i // 2, i % 2].imshow(mat_rank_k, cmap='gray')
    axes[i // 2, i % 2].set_title("Reconstructed image using the \n first {} singular values".format(k), y=1.08)

plt.show()

image-20240811135427217

首先,我们加载图像并添加一些噪声。然后使用前 20、55 和 200 个奇异值重建图像。如上图所示,随着重建矩阵的秩增加,噪声的量也随之增加。因此如果我们使用较低的秩,比如 20,可以显著减少图像中的噪声。

结论

我真的觉得奇异值分解(SVD)被低估了。它是线性代数中一个非常重要的基础概念,而且它的应用非常酷!相信我,我们看到的只是 SVD 众多用途中的一小部分。有什么问题,欢迎讨论!

欢迎扫码关注:

机器学习实战
多名大厂算法工程师共同运营,主要专注机器学习算法、深度学习算法、计算机视觉等领域技术干货分享,一天进步一点点
 最新文章