[译] 高斯混合模型 --- python教程

本文翻译自https://jakevdp.github.io/PythonDataScienceHandbook/05.12-gaussian-mixtures.html

上一节中探讨的k-means聚类模型简单易懂,但其简单性导致其应用中存在实际挑战。具体而言,k-means的非概率特性及简单地计算点与类蔟中心的欧式距离来判定归属,会导致其在许多真实的场景中性能较差。本节,我们将探讨高斯混合模型(GMMs),其可以看成k-means的延伸,更可以看成一个强有力的估计工具,而不仅仅是聚类。

我们将以一个标准的import开始

%matplotlib inline
import matplotlib.pyplot as plt
import seaborn as sns; sns.set()
import numpy as np

GMM的动机:k-means的弱点

我们看下k-means的缺陷,思考下如何提高聚类模型。正如上一节所示,给定简单,易于分类的数据,k-means能找到合适的聚类结果。
举例而言,假设我们有些简单的数据点,k-means算法能以某种方式很快地将它们聚类,跟我们肉眼分辨的结果很接近:

# Generate some data
from sklearn.datasets.samples_generator import make_blobs
X, y_true = make_blobs(n_samples=400, centers=4,
                       cluster_std=0.60, random_state=0)
X = X[:, ::-1] # flip axes for better plotting
# Plot the data with K Means Labels
from sklearn.cluster import KMeans
kmeans = KMeans(4, random_state=0)
labels = kmeans.fit(X).predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis');

从直观的角度来看,我可能期望聚类分配时,某些点比其他的更确定:举例而言,中间两个聚类之间似乎存在非常轻微的重叠,这样我们可能对这些数据点的分配没有完全的信心。不幸的是,k-means模型没有聚类分配的概率或不确定性的内在度量(尽管可能使用bootstrap 的方式来估计这种不确定性)。为此,我们必须考虑泛化这种模型。
k-means模型的一种理解思路是,它在每个类蔟的中心放置了一个圈(或者,更高维度超球面),其半径由聚类中最远的点确定。该半径充当训练集中聚类分配的一个硬截断:任何圈外的数据点不被视为该类的成员。我们可以使用以下函数可视化这个聚类模型:

from sklearn.cluster import KMeans
from scipy.spatial.distance import cdist

def plot_kmeans(kmeans, X, n_clusters=4, rseed=0, ax=None):
    labels = kmeans.fit_predict(X)

    # plot the input data
    ax = ax or plt.gca()
    ax.axis('equal')
    ax.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis', zorder=2)

    # plot the representation of the KMeans model
    centers = kmeans.cluster_centers_
    radii = [cdist(X[labels == i], [center]).max()
             for i, center in enumerate(centers)]
    for c, r in zip(centers, radii):
        ax.add_patch(plt.Circle(c, r, fc='#CCCCCC', lw=3, alpha=0.5, zorder=1))
kmeans = KMeans(n_clusters=4, random_state=0)
plot_kmeans(kmeans, X)

观察k-means的一个重要发现,这些聚类模式必须是圆形的。k-means没有内置的方法来计算椭圆形或椭圆形的簇。因此,举例而言,假设我们将相同的数据点作变换,这种聚类分配方式最终变得混乱:

rng = np.random.RandomState(13)
X_stretched = np.dot(X, rng.randn(2, 2))

kmeans = KMeans(n_clusters=4, random_state=0)
plot_kmeans(kmeans, X_stretched)


肉眼观察,我们分别出这些变换过的集群是非圆的,因此圆形模式的聚类很难拟合。然而,k-means不够灵活解释这一点,尝试强行将数据点分配到四个圆形类别。这将导致聚类分配的混乱,其生成的圆形重叠:具体见图的右下角。有人会想到使用PCA(In Depth: Principal Component Analysis)预处理数据来解决这种特殊情况,但实际上并不能保证这种处理方式能使数据分布成圆簇状。
k-means的两大缺陷 -- 聚类形状的不够灵活和缺少聚类分配的概率值,
意味着许多数据集(尤其是低维数据集),可能不会有预期的效果。
你也许想到通过泛化k-means模型来克服这些缺点:譬如,你可以在数据点聚类分配时,利用每个点到各个类簇的中心的距离来衡量不确定性,而不是仅仅关注最接近的。你也可以想象允许聚类的边界是椭圆而不是圆,以便更好地拟合非圆形的类簇。事实证明,高斯混合模型有着这两种基本的优点。

E-M的推广:高斯混合模型

高斯混合模型(GMM)试图找到一个多维高斯概率分布的混合,以模拟任何输入数据集。在最简单的情况下,GMM可用于以与k-means相同的方式聚类。

from sklearn.mixture import GMM
gmm = GMM(n_components=4).fit(X)
labels = gmm.predict(X)
plt.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis');

但因为GMM包含概率模型,因此可以找到聚类分配的概率方式 - 在Scikit-Learn中,通过调用predict_proba方法实现。它将返回一个大小为[n_samples, n_clusters]的矩阵,用于衡量每个点属于给定类别的概率:

probs = gmm.predict_proba(X)
print(probs[:5].round(3))
[[ 0.     0.     0.475  0.525]
 [ 0.     1.     0.     0.   ]
 [ 0.     1.     0.     0.   ]
 [ 0.     0.     0.     1.   ]
 [ 0.     1.     0.     0.   ]]

我们可以可视化这种不确定性,比如每个点的大小与预测的确定性成比例;如下图,我们可以看到正是群集之间边界处的点反映了群集分配的不确定性:

size = 50 * probs.max(1) ** 2  # square emphasizes differences
plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=size);

本质上说,高斯混合模型与k-means非常相似:它使用期望-最大化的方式,定性地执行以下操作:

  1. 选择位置和形状的初始猜想
  2. 重复直到收敛
    1. E步骤:对于每个点,计算其属于每个类别的概率权重
    2. M步骤:对于每个类别,使用E步算出的权重,根据所有数据点,更新其位置,规范化和形状
      结果是,每个类别不是被硬边界的球体界定,而是平滑的高斯模型。正如在k-means的期望-最大方法一样,这种算法有时可能会错过全局最优解,因此在实践中使用多个随机初始化。
      让我们创建一个函数,通过基于GMM输出,绘制椭圆来帮助我们可视化GMM聚类的位置和形状:
from matplotlib.patches import Ellipse

def draw_ellipse(position, covariance, ax=None, **kwargs):
    """Draw an ellipse with a given position and covariance"""
    ax = ax or plt.gca()
    
    # Convert covariance to principal axes
    if covariance.shape == (2, 2):
        U, s, Vt = np.linalg.svd(covariance)
        angle = np.degrees(np.arctan2(U[1, 0], U[0, 0]))
        width, height = 2 * np.sqrt(s)
    else:
        angle = 0
        width, height = 2 * np.sqrt(covariance)
    
    # Draw the Ellipse
    for nsig in range(1, 4):
        ax.add_patch(Ellipse(position, nsig * width, nsig * height,
                             angle, **kwargs))
        
def plot_gmm(gmm, X, label=True, ax=None):
    ax = ax or plt.gca()
    labels = gmm.fit(X).predict(X)
    if label:
        ax.scatter(X[:, 0], X[:, 1], c=labels, s=40, cmap='viridis', zorder=2)
    else:
        ax.scatter(X[:, 0], X[:, 1], s=40, zorder=2)
    ax.axis('equal')
    
    w_factor = 0.2 / gmm.weights_.max()
    for pos, covar, w in zip(gmm.means_, gmm.covars_, gmm.weights_):
        draw_ellipse(pos, covar, alpha=w * w_factor)

有了这个,我们可以看看四成分的GMM为我们的初始数据提供了什么:

gmm = GMM(n_components=4, random_state=42)
plot_gmm(gmm, X)

同样,我们可以使用GMM方法来拟合我们的拉伸数据集;允许full的协方差,该模型甚至可以适应非常椭圆形,伸展的聚类模式:

gmm = GMM(n_components=4, covariance_type='full', random_state=42)
plot_gmm(gmm, X_stretched)

这清楚地表明GMM解决了以前遇到的k-means的两个主要实际问题。

选择协方差类型

如果看了之前拟合的细节,你将看到covariance_type选项在每个中都设置不同。该超参数控制每个类簇的形状的自由度;对于任意给定的问题,必须仔细设置。默认值为covariance_type =“diag”,这意味着可以独立设置沿每个维度的类蔟大小,并将得到的椭圆约束为与轴对齐。一个稍微简单和快速的模型是covariance_type =“spherical”,它约束了类簇的形状,使得所有维度都相等。尽管它并不完全等效,其产生的聚类将具有与k均值相似的特征。更复杂且计算量更大的模型(特别是随着维数的增长)是使用covariance_type =“full”,这允许将每个簇建模为具有任意方向的椭圆。
对于一个类蔟,下图我们可以看到这三个选项的可视化表示:


GMM 作为密度估计

尽管GMM通常被归类为聚类算法,但从根本上说它是一种密度估算算法。也就是说,GMM适合某些数据的结果在技术上不是聚类模型,而是描述数据分布的生成概率模型。
例如,考虑一下Scikit-Learn的make_moons函数生成的一些数据:

from sklearn.datasets import make_moons
Xmoon, ymoon = make_moons(200, noise=.05, random_state=0)
plt.scatter(Xmoon[:, 0], Xmoon[:, 1]);

如果我们尝试用视为聚类模型的双成分的GMM模拟数据,则结果不是特别有用:

gmm2 = GMM(n_components=2, covariance_type='full', random_state=0)
plot_gmm(gmm2, Xmoon)

但是如果我们使用更多成分的GMM模型,并忽视聚类的类别,我们会发现更接近输入数据的拟合:

gmm16 = GMM(n_components=16, covariance_type='full', random_state=0)
plot_gmm(gmm16, Xmoon, label=False)

这里,16个高斯分布的混合不是为了找到分离的数据簇,而是为了对输入数据的整体分布进行建模。这是分布的一个生成模型,这意味着GMM为我们提供了生成与我们的输入类似分布的新随机数据的方法。例如,以下是从这个16分量GMM拟合到我们原始数据的400个新点:

Xnew = gmm16.sample(400, random_state=42)
plt.scatter(Xnew[:, 0], Xnew[:, 1]);

GMM非常方便,可以灵活地建模任意多维数据分布。

多少components?

GMM是一种生成模型这一事实为我们提供了一种确定给定数据集的最佳组件数的自然方法。生成模型本质上是数据集的概率分布,因此我们可以简单地评估模型下数据的可能性,使用交叉验证来避免过度拟合。校正过度拟合的另一种方法是使用一些分析标准来调整模型可能性,例如Akaike information criterion (AIC)Bayesian information criterion (BIC)。Scikit-Learn的GMM估计器实际上包含计算这两者的内置方法,因此在这种方法上操作非常容易。
让我们看看在moon数据集中,使用AIC和BIC函数确定GMM组件数量:

n_components = np.arange(1, 21)
models = [GMM(n, covariance_type='full', random_state=0).fit(Xmoon)
          for n in n_components]

plt.plot(n_components, [m.bic(Xmoon) for m in models], label='BIC')
plt.plot(n_components, [m.aic(Xmoon) for m in models], label='AIC')
plt.legend(loc='best')
plt.xlabel('n_components');

最佳的聚类数目是使得AIC或BIC最小化的值,具体取决于我们希望使用的近似值。 AIC告诉我们,我们上面选择的16个组件可能太多了:大约8-12个组件可能是更好的选择。与此类问题一样,BIC建议使用更简单的模型。
注意重点:这个组件数量的选择衡量GMM作为密度估算器的效果,而不是它作为聚类算法的效果。我鼓励您将GMM主要视为密度估算器,并且只有在简单数据集中保证时才将其用于聚类。

例子:GMM生成新数据

我们刚刚看到了一个使用GMM作为数据生成模型的简单示例,以便根据输入数据定义的分布创建新样本。在这里,我们将运行这个想法,并从我们以前使用过的标准数字语料库中生成新的手写数字。
首先,让我们使用Scikit-Learn的数据工具加载数字数据:

from sklearn.datasets import load_digits
digits = load_digits()
digits.data.shape
(1797, 64)

接下来让我们绘制前100个,以准确回忆我们正在看的内容:

def plot_digits(data):
    fig, ax = plt.subplots(10, 10, figsize=(8, 8),
                           subplot_kw=dict(xticks=[], yticks=[]))
    fig.subplots_adjust(hspace=0.05, wspace=0.05)
    for i, axi in enumerate(ax.flat):
        im = axi.imshow(data[i].reshape(8, 8), cmap='binary')
        im.set_clim(0, 16)
plot_digits(digits.data)

我们有64个维度的近1,800位数字,我们可以在这些位置上构建GMM以产生更多。 GMM可能难以在如此高维空间中收敛,因此我们将从数据上的可逆维数减少算法开始。在这里,我们将使用一个简单的PCA,要求它保留99%的预测数据方差:

from sklearn.decomposition import PCA
pca = PCA(0.99, whiten=True)
data = pca.fit_transform(digits.data)
data.shape
(1797, 41)

结果是41个维度,减少了近1/3,几乎没有信息丢失。根据这些预测数据,让我们使用AIC来计算我们应该使用的GMM组件的数量:

n_components = np.arange(50, 210, 10)
models = [GMM(n, covariance_type='full', random_state=0)
          for n in n_components]
aics = [model.fit(data).aic(data) for model in models]
plt.plot(n_components, aics);

似乎大约110个components最小化了AIC;我们将使用这个模型。我们迅速将其与数据拟合并确保它已收敛合:

gmm = GMM(110, covariance_type='full', random_state=0)
gmm.fit(data)
print(gmm.converged_)
True

现在我们可以使用GMM作为生成模型在这个41维投影空间内绘制100个新点的样本:

data_new = gmm.sample(100, random_state=0)
data_new.shape
(100, 41)

最后,我们可以使用PCA对象的逆变换来构造新的数字:

digits_new = pca.inverse_transform(data_new)
plot_digits(digits_new)

大部分结果看起来像数据集中合理的数字!
考虑一下我们在这里做了什么:给定一个手写数字的样本,我们已经模拟了数据的分布,这样我们就可以从数据中生成全新的数字样本:这些是“手写数字”,不是单独的出现在原始数据集中,而是捕获混合模型建模的输入数据的一般特征。这种数字生成模型可以证明作为贝叶斯生成分类器的一个组成部分非常有用,我们将在下一节中看到。

推荐阅读更多精彩内容