PCA、t-SNE 和自动编码器

本文将以较小的篇幅来揭开三大降维算法的神秘面纱: PCA, t-SNE 和自动编码器 Auto Encoders。

这样做的主要动机是: 这些方法大多数被当作黑箱处理,因此有时会被误用。深入了解它们将有助于我们如何选择以及如何使用它们。

本文将研究每个方法的内部结构并使用 TensorFlow 从头开始编写每个方法 (t-SNE 除外) 的代码来实现这一点。为什么是 TensorFlow? 因为它主要用于深度学习,让我们给它一些其他的挑战吧 :)

文中代码见文末代码链接[1]。

1动机

在处理实际问题和实际数据时,我们通常会处理维度高达数百万的高维数据。而在它原始的高维结构中,数据本身表现得最好,但有时我们可能需要降低它的维数。减少维数的需求通常与可视化相关(减少到 2-3 个维数,这样我们就可以将它绘制出来以更好地观察或者解释一些现象),但情况并非总是如此。

有时我们可能看重性能而不是精度,这样我们就可以将 1000 维的数据减少到 10 维,以便更快地操作它(例如,计算距离)等。有时需要降低维度, 这是现实的,并且有许多应用。

在我们开始之前,如果你必须为以下情况选择一种降维技术,那么,你会选择哪一种呢?

  • 1、你的系统使用余弦相似度来计算距离,但是你需要向一些可能根本不熟悉余弦相似度的非技术人员可视化它 — 你该怎么做呢?

  • 2、你需要将数据压缩到尽可能小的维数,并且所给出的约束条件是保持大约 80% 的数据,你会怎么做?

  • 3、你有一个数据集,包含了花费你很多时间收集的某种类型的数据,并且类似类型的数据在不断地增加。你需要精简现有以及将来新增的数据,你会选择哪种方法呢?

我希望这篇文章能帮助你更好地理解降维,这样你就会对类似的问题感到得心应手。

让我们从 PCA 开始吧!

2PCA

主成分分析(Principal Components Analysis,简称为 PCA)可能是书中最古老的降维方法。PCA 得到了很好的研究,有许多方法可以求解决它。我们将在这里讨论其中的两种,特征分解和奇异值分解(SVD),然后我们将在 TensorFlow 中实现 SVD 方法。

从现在开始, 将成为形状 的数据矩阵,其中 n 是样本数,p 是维数。因此,在给定 的情况下,这两种方法都尝试用它们自己的方式来处理和分解 ,之后我们可以将分解后的结果相乘,以用更小的维度表示最大化的信息。这听起来有点儿可怕,但是我会帮你省去大部分的数学计算,仍保留有助于理解方法利弊的部分。

特征分解和奇异值分解都是分解矩阵的好方法,让我们看看它们在 PCA 中如何发挥作用以及有什么联系的。请先看下面的算法流程图,具体我马上就会解释。

译者注: 观察变换以后的数据 的协方差矩阵 是不是一个对角矩阵?说明已经消除了各个维度之间的相关性,这点正是 PCA 的目标之一。

+

正如你所看到的,这两种方法都是纯粹的线性代数, 基本上告诉我们, 使用 PCA 是从一个完全不同的角度去审视真实数据。这对 PCA 来说是独特的,因为其他方法从低维数据的随机表示开始, 试着让它表现得像高维数据。其他一些值得注意的事情是,所有的操作都是线性的,并且使用 SVD 的话,数据量不要太大时速度还是挺快的。

不过这里要注意的是,给定相同数据,PCA 总是会给出相同的结果(而其他两个方法并不是这样的)

请注意,在 SVD 中,我们如何选择 r(即我们想要减少后的维数为 r)使 中大多数的值保持在较少的维度中?

这儿有一些关于 的知识。 是一个对角矩阵,这儿有 p 个(维数)对角线值(被称为奇异值),其大小表明它们对保存信息的重要性。所以我们可以选择降维,降至能大约保持的给定数据的某个百分比数量的维数,我将在代码中演示(例如,使我们能够减少维数,并限制丢失最多百分之 的数据)。

正如你会看到的, 在 TensorFlow 中编写代码是非常简单的 — 我们将要编写的是一个类,它有拟合方法fit和一个提供降维方法reduce

1代码 (PCA)

让我们看看拟合方法fit是如何的吧,给定 self.X 包含数据和 self.dtype = tf.float32。

def fit(self):
self.graph = tf.Graph()
with self.graph.as_default():
self.X = tf.placeholder(self.dtype, shape=self.data.shape)

# Perform SVD
singular_values, u, _ = tf.svd(self.X)

# Create sigma matrix
sigma = tf.diag(singular_values)

with tf.Session(graph=self.graph) as session:
self.u, self.singular_values, self.sigma = session.run([u, singular_values, sigma],
feed_dict={self.X: self.data})

所以拟合的目标是创建我们的 和 ,以供后用。我们将从给出奇异值的那行tf.svd开始,该行代码返回了奇异值(即图 1 中表示为 的对角矩阵),以及矩阵 和 。而tf.diag就是 TensorFlow 将一维向量转换成对角矩阵的方式,在我们的例子中,它的结果是 。在拟合方法 fit 的末尾,我们将得到奇异值 以及 。

现在让我们来实现reduce

def reduce(self, n_dimensions=None, keep_info=None):
if keep_info:
# Normalize singular values
normalized_singular_values = self.singular_values / sum(self.singular_values)

# Create the aggregated ladder of kept information per dimension
ladder = np.cumsum(normalized_singular_values)

# Get the first index which is above the given information threshold
index = next(idx for idx, value in enumerate(ladder) if value >= keep_info) + 1
n_dimensions = index

with self.graph.as_default():
# Cut out the relevant part from sigma
sigma = tf.slice(self.sigma, [0, 0], [self.data.shape[1], n_dimensions])

# PCA
pca = tf.matmul(self.u, sigma)

with tf.Session(graph=self.graph) as session:
return session.run(pca, feed_dict={self.X: self.data})

所以如你所看到的,通过降维得到keep_inf或者n_dimensions(我没有实现输入检查,其中只有一个是必须提供的)。如果我们提供n_dimensions,它表示将降维到那个维数,但是如果我们提供keep_info,它应该是 0 到 1 之间的浮点数,我们将保留原始数据中的那么多比例的信息(如 0.9,就是保留 90% 的数据)。在第一个If语句中,我们标准化并检查需要多少个奇异值,就是计算出keep_info中的n_dimensions

在图中,我们对 矩阵进行切片以得到尽可能多的数据,并执行矩阵乘法。

让我们在 Iris 数据集(鸢尾花卉数据集)上试一下,它是三种鸢尾花的(150,4)数据集。

from sklearn import datasets
import matplotlib.pyplot as plt
import seaborn as sns

tf_pca = TF_PCA(iris_dataset.data, iris_dataset.target)
tf_pca.fit()
pca = tf_pca.reduce(keep_info=0.9) # Results in 2 dimensions

color_mapping = {0: sns.xkcd_rgb['bright purple'], 1: sns.xkcd_rgb['lime'], 2: sns.xkcd_rgb['ochre']}
colors = list(map(lambda x: color_mapping[x], tf_pca.target))

plt.scatter(pca[:, 0], pca[:, 1], c=colors)

打开网易新闻 查看精彩图片
图2. Iris 数据集 PCA 二维可视化

啊哈,这效果看起来不赖吧!

3t-SNE

t-SNE 相对来说是一种新方法(和 PCA 比起来),它起源于2008年。它了解起来也比 PCA 更复杂些,所以请耐心点哦。

对于 t-SNE 的表示,我们将如下所示: X 是原始数据;P 是一个矩阵,其中包含高(原始)维空间中 X 中的点之间的亲和度;Q 是在低维空间中数据点之间的亲和矩阵。如果我们有 n 个样本数据,那么 Q 和 P 都为 矩阵(从任何一点到包括自己在内的任何点的距离)。

注意这里,t-SNE 有几种自己的特殊方式(稍后我们就会来介绍)来衡量事物之间的距离, 比如用一种特定的方式来测量高维空间中数据点间的距离, 用另一种方式来测量低维空间中数据点间的距离,以及用第三种方式来测量 P 和 Q 之间的距离。

这里参考提出 t-SNE 的原始论文,点 与另一点 之间的相似度由 给出,即如果在以 为中心的高斯分布下,则 将按概率密度的比例来选择 作为其邻居。

什什 ... 么?请不要担心,正如我所讲,t-SNE 有它自己的计算距离的方法,所以我们来看一看距离(亲和度)计算公式,以理解 t-SNE 的行为。

下图展示的就是该算法的工作方式。注意,与 PCA 不同,它是一个迭代算法。

让我们一步一步来看。

算法接收两个输入,一个是数据本身,另一个被称为困惑度(perplexity,简称为 Perp)。简单地说,perplexity 就是在优化过程中起到对数据的局部(邻近点)和全局结构之间的一种平衡作用。本文建议将其保持在 5 到 50 之间。较高的 perplexity 意味着一个数据点会考虑更多的点作为它的近邻点,较低意味着较少的邻近点。Perplexity 的确会影响最终的可视化效果,所以要小心,因为它会在可视化的低维数据中产生误导现象。

我墙裂建议阅读这篇关于如何正确使用 t-SNE[2]的妙文,它涵盖了不同的 perplexity 的影响。

这个 perplexity 在方程中好像没有存在感? 它其实是用来计算方程 (1) 中 的,由于它们之间有单调的联系,例如可以用二分查找法来计算。所以 基本上是用我们提供给算法的 perplexity 来用不同的方法求出来的。

让我们看看关于 t-SNE 的方程吧。

在审视方程 (1) 和 (2) 之前,需要知道的是, 设置为 0, 也设置为 0(尽管如果我们在两个相似的点上计算,不会得到 0,但这只是一个给定值)。

所以看方程 (1) 和 (2), 需要大家注意, 如果两个点很接近(在高维空间),分子将产生一个大约是 1 的值,而如果它们相距很远, 会得到一个无限小的值,这将帮助我们在稍后理解损失函数。

关于 t-SNE,这里有两点需要注意:

  • 1、在 t-SNE 图中解释距离可能会有些问题,这是由于建立亲和方程的方式。这意味着簇和簇之间的距离以及簇的大小可能会产生误导,因为它们会受到所选择的 perplexity 的影响(我将再次向大家推荐可以在上面的段落中找到的那篇好文,这样可以查看这些现象的可视化效果)。

  • 2、在方程(1)中我们计算了点与点之间的欧氏距离?算法在这里有一定灵活性,我们可以根据自己的喜好,用余弦距离、曼哈顿距离或者任何你想要的距离(只要保持空间度量)来切换该距离度量,并保持在低维空间使用一致的距离计算 — 这将以欧几里得距离的方式来绘制复杂的距离。

假如你是一名首席技术官,并且你有一些数据,可以通过余弦相似度来测量其距离,而你的首席执行官希望你绘制出数据,那么我不太确定你是否会有时间向董事会解释什么是余弦相似度,以及如何解释簇,但你可以简单地使用 t-SNE 将基于余弦相似的簇用基于欧几里得距离的簇来进行简单的展示。

在代码中,你可以通过给 t-SNE 方法提供一个距离矩阵来在 scikit-learn 中实现这一点。

好的,现在我们知道,当 和 邻近时, 值更大,而当 和 远离时, 值就很小。

让我们看看这如何影响我们的损失函数(它被称为 散度),通过绘制它并对照方程 (3) 中没有求和的那部分。

这真得很难捕获,但我确实将轴名称放到了那里。正如你所看到的,损失函数是不对称的。对于高维空间中邻近点(看 p 轴),如果在低维空间中表示后成了的远离点,这将会产生很高的代价,而用低维空间中的邻近点表示高维空间中的远离点的代价则相对更小。这多少说明了 t-SNE 在绘制距离解释能力时的问题。

+

译者注:

  • 结合上面的流程图来看一下 t-SNE 的思路: 计算原始高维空间中邻近点的距离,将其转化为点的分布;计算数据的低维表示就转为一个最优化问题,即最小化代价函数以让低维空间中的分布不断逼近在原始高维空间中的分布。这里,分布之间的距离使用的是 KL 散度,因此是不对称的。但这里降维问题主要是希望高维空间在一起的点在低维空间中尽量不要分开,但高维空间中不在一个簇中的点由于维度降低也可能会拉近距离,这在损失函数中并没有惩罚。降维过程中,聚拢簇和拉开簇间距是一对矛盾,t-SNE 算法更注重前者。

  • 还有一点就是点在高维空间中是一盘散沙,本没有簇的概念,因此需要一个 perplexity 来促成它。perplexity 相当于是 expected density,是对簇的一个预估计。

  • 损失函数是一个关于所求数据低维表示的非线性函数,难以像 PCA 那样通过矩阵分解的直接法来求解,可以迭代计算。

+

好了,我们来使用 t-SNE 绘制 Iris 数据集的数据,并看看在不同的 perplexitiy 下会发生什么?

model = TSNE(learning_rate=100, n_components=2, random_state=0, perplexity=5)
tsne5 = model.fit_transform(iris_dataset.data)

model = TSNE(learning_rate=100, n_components=2, random_state=0, perplexity=30)
tsne30 = model.fit_transform(iris_dataset.data)

model = TSNE(learning_rate=100, n_components=2, random_state=0, perplexity=50)
tsne50 = model.fit_transform(iris_dataset.data)

plt.figure(1)
plt.subplot(311)
plt.scatter(tsne5[:, 0], tsne5[:, 1], c=colors)

plt.subplot(312)
plt.scatter(tsne30[:, 0], tsne30[:, 1], c=colors)

plt.subplot(313)
plt.scatter(tsne50[:, 0], tsne50[:, 1], c=colors)

plt.show()

打开网易新闻 查看精彩图片
图 5. Iris 数据集上的 t-SNE,不同的 perplexity

正如我们在数学中得到的理解那样,你可以看到,给定一个很好的 perplexity,数据确实会聚类,但是要注意超参数的敏感性(如果没有为梯度下降提供学习率,也就找不到簇啦)。

在继续往下之前,我想说,如果你正确地应用 t-SNE,这将是一个非常强大的方法。不过正如上面所看到的,该算法也有缺陷的一面,只是我们需要实践中多摸索来掌握如何使用它。

接下来看下一个,自动编码器。

4Auto Encoders

首先,相对于 PCA 和 t-SNE 来说,自动编码器是一组方法。自动编码器是神经网络,其中的网络旨在通过使用比输入节点更少的隐藏节点(在编码器的末端)来预测输入(输出被训练为与输入尽可能相似),即将尽可能多的信息编码到较少的隐藏节点中去,达到降维的目的。

我们的 4 维 Iris 数据集的基本自动编码器会如图 6 所示,其中连接输入层和隐藏层的线条被称为编码器,连接隐藏层和输出层的线条被称为解码器

那么为什么自动编码器是一族方法呢?因为我们唯一的限制是输入层和输出层的维数是相同的,在内部我们可以创建任何我们想要的架构来不同地编码我们的高维数据。

给定某类数据,我们的目的是得到能够较好地表示这类数据的一个自动编码器,即知道它的网络权值。从一些随机的低维表示(z)开始,通过梯度下降法改变连接输入层和隐藏层、隐藏层和输出层的权值来最小化损失函数的方式求得理想化的网络权值。

到目前为止,我们已经了解了一些关于自动编码器的重要信息,因为我们控制了网络的内部,我们可以设计编码器来挑选复杂特证之间的关系。自动编码器的另一个优点是,由于在训练结束时,我们得到了隐藏层的权值,因此如果以后遇到新的数据点,则无需训练就可以使用这些权值来降维。但要小心,只有在数据点与我们训练的数据相似度较高时,编码器的表现才不错。

在这种情况下,探索自动编码器的数学运算可能是简单的,但不是很有用,因为我们选择的每个架构和损失函数是不同的,因此具体的数学运算也会不同。但是如果我们花点时间,来思考一下自动编码器的优化方式,就会明白我们定义的损失函数发挥着非常重要的作用。

因为自动编码器将使用损失函数来确定它的预测有么好的表现,我们可以使用这种能力来强调我们想要的。无论是欧几里得距离还是其他的度量方法,都可以通过损失函数、不同的距离方法、非对称函数等来反映出来。更强大的能力在于,由于这本质上是一个神经网络,我们甚至可以在训练中对类和样本进行加权以赋予数据中某些现象更多的存在感。这为我们压缩数据提供了极好的灵活性。

自动编码器是非常强大的,并且在某些情况下(仅仅Google PCA vs Auto Encoders),和其他一些方法相比,显示出了更好的结果。因此,它们肯定是一个有效的方法。

下面我们来 用TensorFlow 训练 Iris 数据集的一个基本自动编码器吧。

2代码 (Auto Encoder)

再一次,我们将分成两个方法拟合fit和降维reduce

def fit(self, n_dimensions):
graph = tf.Graph()
with graph.as_default():

# Input variable
X = tf.placeholder(self.dtype, shape=(None, self.features.shape[1]))

# Network variables
encoder_weights = tf.Variable(tf.random_normal(shape=(self.features.shape[1], n_dimensions)))
encoder_bias = tf.Variable(tf.zeros(shape=[n_dimensions]))

decoder_weights = tf.Variable(tf.random_normal(shape=(n_dimensions, self.features.shape[1])))
decoder_bias = tf.Variable(tf.zeros(shape=[self.features.shape[1]]))

# Encoder part
encoding = tf.nn.sigmoid(tf.add(tf.matmul(X, encoder_weights), encoder_bias))

# Decoder part
predicted_x = tf.nn.sigmoid(tf.add(tf.matmul(encoding, decoder_weights), decoder_bias))

# Define the cost function and optimizer to minimize squared error
cost = tf.reduce_mean(tf.pow(tf.subtract(predicted_x, X), 2))
optimizer = tf.train.AdamOptimizer().minimize(cost)

with tf.Session(graph=graph) as session:
# Initialize global variables
session.run(tf.global_variables_initializer())

for batch_x in batch_generator(self.features):
self.encoder['weights'], self.encoder['bias'], _ = session.run([encoder_weights, encoder_bias, optimizer],
feed_dict={X: batch_x})

这儿没有什么特别的,代码本身是容易解释,我们在偏置中保存了编码器的权值,所以可以使用接下来要介绍的降维方法来减少数据。

def reduce(self):
return np.add(np.matmul(self.features, self.encoder['weights']), self.encoder['bias'])

对,就这么简单 :) 让我们看看它是如何工作的(批大小为 50,训练 1000 个 epoch)。

我们甚至可以在不改变网络架构的情况下继续改变批大小,epoch 以及不同的优化器,就会得到不同的结果。

请注意,我只是为超参数选择了一些任意的值。在实际训练中,我们将通过交叉验证或测试数据来评估我们的网络,并找到最佳设置。

5结语

像这样的文章,通常会以一些对比图表、优缺点等来完结。但这与我想要完成的恰恰相反。我想做的是揭晓这些方法的内部机理,这样读者就能够解决并理解每一个方法的优点和缺点。

返回到文章的开头,找到这三个问题,现在,是不是对它们更有感觉了呢?

参考资料

代码链接: https://github.com/eliorc/Medium/blob/master/PCA-tSNE-AE.ipynb

[2]

如何正确使用 t-SNE: https://distill.pub/2016/misread-tsne/

[3]

英文链接: https://towardsdatascience.com/reducing-dimensionality-from-dimensionality-reduction-techniques-f658aec24dfe