Pythonista 數據科學家 Elior Cohen 近日在 Medium 上發文解讀了最常見的三大降維技術:PCA、t-SNE 和自編碼器。為了幫助理解,他還為其中每種相關算法編寫了代碼(也發布在了 GitHub 上)。機器之心對本文進行了編譯介紹。
代碼地址:https://github.com/eliorc/Medium/blob/master/PCA-tSNE-AE.ipynb
在這篇文章中,我將盡我所能揭秘三種降維技術:PCA、t-SNE 和自編碼器。我做這件事的主要原因是基本上這些方法都被當作黑箱對待,因此有時候會被誤用。理解它們將能讓讀者有辦法決定在何時如何使用哪一種方法。
為了實現這一目標,我將深入到每種方法的內部,並且將使用 TensorFlow 從零開始為每種方法編寫代碼(t-SNE 除外)。為什麼選擇 TensorFlow?因為其最常被用於深度學習領域,讓我們能有點挑戰。
動機
當處理真實問題和真實數據時,我們往往遇到維度高達數百萬的高維數據。
儘管在其原來的高維結構中,數據能夠得到最好的表達,但有時候我們可能需要給數據降維。
降維的需求往往與可視化有關(減少兩三個維度,好讓我們可以繪圖),但這只是其中一個原因。
有時候,我們認為性能比精度更重要,那麼我們就可以將 1000 維的數據降至 10 維,從而讓我們可以更快地對這些數據進行操作(比如計算距離)。
有時候對降維的需求是真實存在的,而且有很多應用。
在我們開始之前,先看一個問題:如果你要為以下案例選擇一種降維技術,你會怎麼選?
1. 你的系統可以使用餘弦相似度測量距離,但你需要將其可視化,以便不懂技術的董事會成員也能理解,這些人可能甚至從來沒聽說過餘弦相似度;你會怎麼做?
2. 你有必要將數據的維度壓縮到儘可能最低,你的限制是要保留大約 80% 的數據,你會怎麼做?
3. 你有一個資料庫,其中的數據是耗費了大量時間收集的,而且還時不時有新的(相似類型的)數據加入。你需要降低你已有數據的維度,並且還要給到來的新數據降維,你會選擇什麼方法?
這篇文章的目的是希望能幫助你更好地了解降維,以便你能輕鬆應對類似這樣的問題。
讓我們從 PCA 開始吧。
PCA
PCA,即主成分分析(Principal Component Analysis),可能是最古老的技巧了。
PCA 已經得到了充分的研究,而且有很多方法可以實現這種解決方案,這裡我們會談到其中兩種:Eigen 分解和奇異值分解(SVD),然後我們會在 TensorFlow 中實現其中的 SVD 方法。
從現在起,假設我們的數據矩陣為 X,其 shape 為 (n, p),其中 n 是指樣本的數量,而 p 是指維度。
所以給定了 X 之後,這兩種方法都要靠自己的方式找到一種操作並分解 X 的方法,以便接下來我們可以將分解後的結果相乘,從而以更少的維度表徵最大化的信息。我知道,這聽起來很唬人,但我們不會深入到數學證明中去,僅保留有助於我們理解這種方法的優缺點的部分。
所以 Eigen 分解和 SVD 都是分解矩陣的方式,讓我們看看它們可以在 PCA 中提供怎樣的幫助,以及它們有怎樣的聯繫。
先看看下面的流程圖,我會在後面解釋。
圖 1:PCA 工作流程
所以,你為什麼要關心這個?實際上,這兩個流程中有一些非常基本的東西,能夠給我們理解 PCA 提供很大幫助。
你可以看到,這兩種方法都是純線性代數,這基本上就意味著:使用 PCA 就是在另一個角度看待真實數據——這是 PCA 獨有的特性,因為其它方法都是始於低維數據的隨機表徵,然後使其表現得就像是高維數據。
另外值得一提的是因為所有的運算都是線性的,所以 SVD 的速度非常快。
另外,給定同樣的數據,PCA 總是會給出同樣的答案(而其它兩種方法卻不是這樣)。
注意我們在 SVD 中是怎樣選擇 r(r 是我們想要降低至的維度)的,以便將 Σ 中的大部分值保留到更低的維度上。
Σ 則有一些特別之處。
Σ 是一個對角矩陣(diagonal matrix),其中有 p(維度數)個對角值(被稱為奇異值(singular value)),它們的大小表明了它們對保存信息的重要程度。
所以我們可以選擇降維到能保留給定比例的數據的維度數,後面我將通過代碼說明。(比如讓我們降維,但最多失去 15% 的數據。)
你將看到,用 TensorFlow 寫這個代碼是很簡單的,我們要編碼的是一個帶有 fit 方法和我們將提供維度的 reduce 方法的類。
代碼(PCA)
設 self.X 包含數據且 self.dtype=tf.float32,那麼 fit 方法看起來就是這樣:
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})所以 fit 的目標是創建我們後面要用到的 Σ 和 U。
我們將從 tf.svd 行開始,這一行給了我們奇異值(就是圖 1 中標記為 Σ 的對角值)和矩陣 U 和 V。
然後 tf.diag 是 TensorFlow 的將一個 1D 向量轉換成一個對角矩陣的方法,在我們的例子中會得到 Σ。
在 fit 調用結束後,我們將得到奇異值、Σ 和 U。
現在讓我們實現 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})如你所見,reduce 既有 keep_info,也有 n_dimensions(我沒有實現輸入檢查看是否只提供了其中一個)。
如果我們提供 n_dimensions,它就會降維到那個數;但如果我們提供 keep_info(應該是 0 到 1 之間的一個浮點數),就說明了我們將從原來的數據中保存多少信息(假如是 0.9,就保存 90% 的數據)。
在第一個 if 中,我們規範化並檢查了需要多少了奇異值,基本上能從 keep_info 得知 n_dimensions。
在這個圖中,我們只需要從 Σ (sigma) 矩陣切下我們所需量的數據,然後執行矩陣乘法。
現在讓我們在鳶尾花數據集上試一試,這是一個 (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:鳶尾花數據集 PCA 二維繪圖
還不錯吧?
t-SNE
相對於 PCA,t-SNE 是一種相對較新的方法,起源於 2008 年的論文《Visualizing Data using t-SNE》:http://www.jmlr.org/papers/volume9/vandermaaten08a/vandermaaten08a.pdf
它也比 PCA 更難理解,所以讓我們一起堅持一下。
我們對 t-SNE 的符號定義為:X 是原來的數據;P 是一個矩陣,顯示了高維(原來的)空間中 X 中的點之間的親和度(affinities,約等於距離);Q 也是一個矩陣,顯示了低維空間中數據點之間的親和度。如果你有 n 個數據樣本,那麼 Q 和 P 都是 n×n 的矩陣(從任意點到任意點的距離包含自身)。
現在 t-SNE 有自己的「獨特的」測量事物之間距離的方式(我們下面就會介紹)、一種測量高維空間中數據點之間的距離的方式、一種測量低維空間中數據點之間的距離的方式以及一種測量 P 和 Q 之間的距離的方式。
根據原始論文,一個數據點 x_j 與另一個點 x_i 之間的相似度是 p_j|i,其定義為:「x_i 選取 x_j 為其近鄰點(neighbor),而近鄰點的選取與以 x_i 為中心的高斯分布概率密度成正比。」
「這是什麼意思!」不要擔心,我前面說了,t-SNE 有自己測量距離的獨特方式,所以讓我們看看用於測量距離(親和度)的公式,然後從中取出我們理解 t-SNE 的行為所需的見解。
從高層面來講,這就是算法的工作方式(注意和 PCA 不一樣,這是一個迭代式的算法)。
圖 3:t-SNE 工作流程
讓我們一步步地研究一下這個流程。
這個算法有兩個輸入,一個是數據本身,另一個被稱為困惑度(Perp)。
簡單來說,困惑度(perplexity)是指在優化過程中數據的局部(封閉點)和全局結構的焦點的平衡程度——本文建議將其保持在 5 到 50 之間。
更高的困惑度意味著一個數據點會把更多的數據點看作是其緊密的近鄰點,更低的困惑度就更少。
困惑度會實際影響可視化的結果,而且你需要小心應對,因為它可能會在可視化低維數據時出現誤導現象——我強烈推薦閱讀這篇文章了解如何使用 t-SNE 困惑度:http://distill.pub/2016/misread-tsne,其中介紹了不同困惑度的影響。
這種困惑度從何而來?它被用於求解式子 (1) 中的 σ_i,而且因為它們有單調的連接,所以可以通過二元搜索(binary search)找到。
所以使用我們提供給算法的困惑度,我們基本上會找到不同的 σ_i。
讓我們看看公式為我們提供了哪些關於 t-SNE 的信息。
在我們探索公式 (1) 和 (2) 之前,需要知道 p_ii 和 q_ii 被設置為 0(即使我們將它們應用到兩個相似的點上,公式的輸出也不會是 0,這只是給定的值)。
所以看看公式 (1) 和 (2),我希望你注意到,當兩個點很接近時(在高維表徵中),分子的值大約為 1,而如果它們相距非常遠,那麼我們會接近無窮小——這將有助於我們後面理解成本函數。
現在我們可以了解關於 t-SNE 的一些事情了。
一是因為親和度公式的構建方式,在 t-SNE 圖中解讀距離可能會出問題。
這意味著聚類之間的距離和聚類大小可能被誤導,並且也會受到所選擇的困惑度的影響(在上面我推薦的文章中,你可以看到這些現象的可視化)。
第二件要注意的事情是,怎麼在等式 (1) 中我們基本上計算的是點之間的歐幾裡得距離?這方面 t-SNE 很強大,我們可以用任何我們喜歡的距離測量來取代它,比如餘弦距離、Manhattan 距離,也可以使用任何你想用的測量方法(只要其保持空間度量(space metric),而且保持低維親和度一樣)——以歐幾裡得的方式會得到複雜的距離繪圖。
比如說,如果你是一位 CTO,你有一些數據需要根據餘弦相似度測量距離,而你的 CEO 想要你通過圖表的形式呈現這些數據。我不確定你是否有時間向董事會解釋什麼是餘弦相似度以及解讀聚類的方式,你可以直接繪製餘弦相似度聚類,因為歐幾裡得距離聚類使用 t-SNE——要我說,這確實很酷。
在代碼中,你可以在 scikit-learn 中通過向 TSNE 方法提供一個距離矩陣來實現。
現在,我們知道當 x_i 和 x_j 更近時,p_ij/q_ij 的值更大;相反則該值更小。
讓我們看看這會對我們的成本函數(被稱為 KL 散度(Kullback–Leibler divergence))帶來怎樣的影響。讓我們繪圖,然後看看沒有求和部分的公式 (3)。
圖 4:沒有求和部分的 t-SNE 成本函數
很難看明白這是啥?但我在上面給軸加了名字。
如你所見,這個成本函數是不對稱的。
對於高維空間中臨近的點,其得出了非常高的成本(p 軸),但這些點是低維空間中很遠的點表示的;而在高維空間中遠離的點則成本更低,它們則是用低維空間中臨近的點表示的。
這說明在 t-SNE 圖中,距離解釋能力的問題甚至還更多。
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:在鳶尾花數據集上的 t-SNE,不同的困惑度
正如我們從數學中了解到的那樣,你可以看到給定一個好的困惑度,數據會聚類,但要注意超參數的敏感性(如果不給梯度下降提供學習率,我無法找到聚類)。
在我們繼續之前,我想說如果使用正確,t-SNE 會是一種非常強大的方法,而不會受到前面提及的負面影響,只是你要清楚如何使用它。
接下來是自編碼器。
自編碼器(Auto Encoders)
PCA 和 t-SNE 是方法,而自編碼器則是一系列的方法。
自編碼器是一種神經網絡,其目標是通過使用比輸入節點更少的隱藏節點(在編碼器一端)預測輸入(訓練該網絡使其輸出儘可能與輸入相似),為此該網絡需要儘可能多地將信息編碼到隱藏節點中。
圖 6 給出了一個用於 4 維鳶尾花數據集的基本自編碼器,其中輸入層到隱藏層之間的連接線被稱為編碼器(encoder),而隱藏層到輸出層之間的線被稱為解碼器(decoder)。
圖 6:用於鳶尾花數據集的基本自編碼器
所以為什麼自編碼器是一系列方法呢?因為我們僅有的約束條件是其輸入層和輸出層具有同樣的維度,在兩者之間,我們可以創建任何我們想要的可以最好地編碼我們的高維數據的結構。
自編碼器始於一些隨機的低維表徵(z)並會通過梯度下降改變其輸入層和隱藏層以及隱藏層和輸出層之間連接的權重,從而找到它們的解。
現在,我們對自編碼器已經有了一定了解,因為我們可以控制該網絡的內部,我們可以讓編碼器能挑選出特徵之間的非常複雜的關係。
自編碼器還有一個加分項。因為在訓練結束時,我們有與隱藏層的連接權重,所以我們可以在特定的輸入上訓練,如果後面我們遇到了另一個數據點,那麼我們無需重新訓練就可以使用這些權重進行降維——但這種操作要小心,只有當新數據點與我們訓練所用的數據點類似時這才有效。
在這種情況下,探索自編碼器的數學可能很簡單,但卻沒什麼用處,因為對於每一種架構和我們選擇的成本函數,其數學形式可能是不同的。
但如果我們想一想自編碼器權重的優化方式,我們會理解我們定義的成本函數具有非常重要的作用。
因為自編碼器會使用成本函數來確定其預測結果的質量,那麼我們就可以使用這個功能來強化我們希望實現的東西。
不管我們想要的是歐幾裡得距離還是其它測量,我們都可以通過成本函數、使用不同的距離方法、使用不對稱函數和其它方法而將其反映到編碼的數據上。
而且因為自編碼器本質上是神經網絡,所以它還具有更大的力量,我們甚至可以在訓練時給類和樣本加權,從而為數據中的特定現象提供更大的重要性。
這能給我們壓縮數據的方式提供很大的靈活性。
自編碼器非常強大,而且在一些案例中能實現比其它方法更好的結果(谷歌搜一下「PCA vs Auto Encoders」,你就知道),所以它們肯定是一種有效的方法。
讓我們用 TensorFlow 實現一個基於鳶尾花數據集的基本自編碼器,並且繪圖。
代碼(自編碼器)
同樣,我們將其分成了 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})這裡沒什麼特別要說的,代碼本身就已經給出了解釋。我們可以看到在偏置中的編碼器權重,然後我們可以在下面的 reduce 方法中給該數據降維。
def reduce(self):
return np.add(np.matmul(self.features, self.encoder['weights']), self.encoder['bias'])沒錯,就是這麼簡單 :)
讓我們看看它的效果(批大小:50,1000 epoch)。
圖 7:這個簡單自編碼器在鳶尾花數據集上的輸出
我們可以繼續調整批大小、epoch 數和不同的優化器,甚至無需改變架構我們就能得到不同的結果。
注意這裡我給超參數隨便選擇了一些值,在真實場景中,我們需要通過交叉驗證或測試數據來檢驗我們的結果,並藉此找到最優的設置。
結語
這樣的文章往往會通過某種形式的對比表格收尾,看看它們長處短處之類的。但那正好不是我想做的事情。
我的目標是深入到這些方法內部,讓讀者可以自己明白它們各自的優勢和劣勢。
我希望你喜歡這篇文章,並且能從中獲益。現在回到文章開始處的問題,你知道它們的答案了嗎?
原文連結:https://reinforce.io/blog/introduction-to-tensorforce/
本文為機器之心編譯,轉載請聯繫本公眾號獲得授權。
✄---
加入機器之心(全職記者/實習生):hr@jiqizhixin.com
投稿或尋求報導:editor@jiqizhixin.com
廣告&商務合作:bd@jiqizhixin.com
點擊閱讀原文,查看機器之心官網↓↓↓