Python实战:5分钟搞懂GMM聚类与EM算法的核心原理(附完整代码)
从零构建高斯混合模型:用Python手撕EM算法,告别调包
很多朋友第一次接触高斯混合模型(GMM)和期望最大化(EM)算法,大概都是从sklearn.mixture.GaussianMixture开始的。点几下鼠标,调个fit()方法,模型就训练好了,聚类结果也出来了,看起来一切都很美好。但用久了心里总会有点不踏实——这黑盒子里面到底发生了什么?为什么我的数据有时候能分得很好,有时候却一团糟?参数初始化的玄学到底是怎么回事?
如果你也有过类似的困惑,那么今天这篇文章就是为你准备的。我们不打算再重复那些教科书式的公式推导,而是换个角度,从代码的视角重新理解GMM和EM。我会带你用numpy从头实现一个完整的GMM,在这个过程中,你会发现那些看似复杂的数学公式,其实对应着非常直观的编程逻辑。更重要的是,你会真正理解为什么EM算法能“神奇地”找到那些隐藏的分布参数,以及在实际应用中需要注意哪些坑。
这篇文章适合有一定Python和概率基础的朋友,但即使你是数据科学的新手,只要跟着代码一步步走,也能完全掌握。我们不追求数学上的绝对严谨,而是追求直觉上的通透和代码上的可复现。
1. 为什么需要混合模型?单高斯分布的局限性
在开始动手之前,我们先得搞清楚一个根本问题:为什么好好的单高斯分布不够用,非要搞出个“混合”模型?
想象一下这样的场景:你正在分析一个电商平台的用户消费数据。如果你把所有用户的月消费金额画成直方图,可能会看到这样的分布——不是标准的钟形曲线,而是多个峰值。也许一个峰值对应的是“低频低消”用户群,另一个峰值对应的是“高频高消”用户群。如果你强行用一个单高斯分布去拟合,就像试图用一把钥匙开所有的锁,结果只能是哪个锁都开不好。
这就是单高斯模型的根本局限——它假设你的数据都来自同一个“母体”,服从同一个分布。但现实世界的数据往往是异质的,由多个不同的子群体混合而成。每个子群体有自己的行为模式,对应着不同的分布参数。
高斯混合模型的核心思想就是用多个高斯分布的加权和来描述数据:
$$ p(x) = \sum_{k=1}^{K} \pi_k \cdot \mathcal{N}(x | \mu_k, \Sigma_k) $$
这里:
- $K$ 是混合成分的数量(也就是你想找到的几个子群体)
- $\pi_k$ 是第 $k$ 个高斯分布的混合系数,满足 $\sum_k \pi_k = 1$,可以理解为每个子群体的“权重”
- $\mu_k$ 和 $\Sigma_k$ 分别是第 $k$ 个高斯分布的均值和协方差矩阵
那么问题来了:给你一堆数据点 $x_1, x_2, ..., x_N$,你怎么知道每个点属于哪个子群体?更进一步,你怎么估计出每个子群体的 $\pi_k$、$\mu_k$ 和 $\Sigma_k$?
这就是EM算法要解决的问题。但在此之前,我们需要先准备好数据。
2. 数据生成:理解GMM的数据结构
理解一个模型最好的方式,就是先学会如何生成符合这个模型的数据。这能帮你建立对模型行为的直观感受。
下面这个函数会生成符合指定GMM参数的二维数据。我特意把代码写得详细一些,方便你理解每个参数的作用:
import numpy as np
import matplotlib.pyplot as plt
from scipy.stats import multivariate_normal
def generate_gmm_data(n_samples, weights, means, covariances):
"""
生成符合高斯混合模型分布的样本数据
参数:
n_samples: 生成的样本总数
weights: 每个高斯成分的权重,形状为 (n_components,)
means: 每个高斯成分的均值,形状为 (n_components, n_features)
covariances: 每个高斯成分的协方差矩阵,形状为 (n_components, n_features, n_features)
返回:
X: 生成的样本数据,形状为 (n_samples, n_features)
labels: 每个样本的真实成分标签
"""
n_components = len(weights)
n_features = means.shape[1]
# 确保权重和为1
weights = np.array(weights) / np.sum(weights)
# 第一步:根据权重随机分配每个样本来自哪个高斯成分
# 这模拟了真实世界中数据来自不同子群体的过程
component_indices = np.random.choice(
n_components,
size=n_samples,
p=weights
)
# 第二步:为每个成分生成对应数量的样本
X = np.zeros((n_samples, n_features))
labels = np.zeros(n_samples, dtype=int)
for k in range(n_components):
# 找出属于当前成分的样本索引
mask = (component_indices == k)
n_k = np.sum(mask)
if n_k > 0:
# 从第k个高斯分布生成样本
X[mask] = np.random.multivariate_normal(
mean=means[k],
cov=covariances[k],
size=n_k
)
labels[mask] = k
return X, labels
让我们实际生成一些数据看看效果:
# 设置GMM参数:两个高斯成分
true_weights = [0.3, 0.7] # 30%的数据来自第一个分布,70%来自第二个
true_means = np.array([
[1.0, 2.0], # 第一个分布的均值
[5.0, 6.0] # 第二个分布的均值
])
true_covs = np.array([
[[0.8, 0.2], # 第一个分布的协方差矩阵
[0.2, 0.5]],
[[1.0, 0.1], # 第二个分布的协方差矩阵
[0.1, 0.8]]
])
# 生成1000个样本
np.random.seed(42) # 固定随机种子,确保结果可复现
X, true_labels = generate_gmm_data(
n_samples=1000,
weights=true_weights,
means=true_means,
covariances=true_covs
)
# 可视化生成的数据
plt.figure(figsize=(10, 8))
colors = ['red', 'blue']
for k in range(2):
mask = (true_labels == k)
plt.scatter(X[mask, 0], X[mask, 1],
c=colors[k], alpha=0.6,
label=f'Component {k}', s=30)
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.title('Generated GMM Data (True Labels)')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()
运行这段代码,你会看到两个明显不同的数据簇。但关键点在于:在实际问题中,我们只能看到这些散点,看不到颜色(标签)。我们的任务就是从这些没有标签的数据中,反推出每个簇的分布参数。
注意:在实际应用中,你永远不知道数据的真实分布参数。这里我们生成数据时知道真实参数,只是为了验证我们的算法是否有效。
3. EM算法的直觉:一个"先猜后证"的迭代游戏
现在进入正题:如何从无标签数据中估计GMM的参数?EM算法的核心思想可以用一个生活中的比喻来理解:
假设你是一位考古学家,发现了一批古代硬币,它们来自两个不同的铸币厂,但混在一起了。你不知道哪些硬币来自哪个厂,也不知道每个厂的铸造工艺(硬币的平均重量和重量波动)。
你会怎么做?一个合理的策略是:
- 先猜:随机猜测两个铸币厂的工艺参数(比如,A厂平均重10g,B厂平均重12g)
- 分配:根据猜测的参数,计算每个硬币更可能来自哪个厂
- 更新:根据这个"软分配"(每个硬币属于每个厂的概率),重新估计两个厂的工艺参数
- 重复:用新的参数回到第2步,直到参数不再明显变化
这就是EM算法的E步(Expectation)和M步(Maximization):
- E步:基于当前参数,计算每个数据点属于每个成分的"责任"(responsibility)
- M步:基于这些"责任",更新每个成分的参数
为什么这个迭代过程能收敛到合理的解?因为每一步都在提升数据的似然值——也就是让观测到的数据出现的概率变得更大。这就像爬山,每一步都往更高的地方走,最终会到达一个山顶(可能是局部最高点)。
3.1 E步:计算"责任"矩阵
在E步中,我们需要计算每个数据点 $x_i$ 对第 $k$ 个高斯成分的"责任" $\gamma_{ik}$:
$$ \gamma_{ik} = \frac{\pi_k \cdot \mathcal{N}(x_i | \mu_k, \Sigma_k)}{\sum_{j=1}^K \pi_j \cdot \mathcal{N}(x_i | \mu_j, \Sigma_j)} $$
这个公式的直观解释是:$\gamma_{ik}$ 衡量了在当前的参数估计下,数据点 $x_i$ 有多大可能来自第 $k$ 个成分。它是一个"软"分配,取值在0到1之间,所有成分的 $\gamma_{ik}$ 之和为1。
用Python实现E步:
def e_step(X, weights, means, covariances):
"""
E步:计算每个样本对每个高斯成分的责任
参数:
X: 数据矩阵,形状为 (n_samples, n_features)
weights: 当前混合系数,形状为 (n_components,)
means: 当前均值,形状为 (n_components, n_features)
covariances: 当前协方差矩阵,形状为 (n_components, n_features, n_features)
返回:
responsibilities: 责任矩阵,形状为 (n_samples, n_components)
log_likelihood: 当前参数下的对数似然值
"""
n_samples, n_features = X.shape
n_components = len(weights)
responsibilities = np.zeros((n_samples, n_components))
log_likelihood = 0
for k in range(n_components):
# 计算每个样本在第k个高斯分布下的概率密度
# 使用scipy的多元正态分布函数,避免自己实现复杂的行列式计算
from scipy.stats import multivariate_normal
prob = multivariate_normal.pdf(
X,
mean=means[k],
cov=covariances[k]
)
responsibilities[:, k] = weights[k] * prob
# 归一化:每个样本的责任之和为1
row_sums = responsibilities.sum(axis=1, keepdims=True)
responsibilities /= row_sums
# 计算对数似然
log_likelihood = np.sum(np.log(row_sums))
return responsibilities, log_likelihood
这里有个重要的实现细节:数值稳定性。当数据维度较高或协方差矩阵接近奇异时,概率密度的计算可能出现下溢(非常接近0)。在实际的工业级实现中,我们通常会使用对数空间的计算来避免这个问题。
3.2 M步:更新参数
在M步中,我们基于E步计算出的责任矩阵,更新GMM的所有参数。更新公式非常直观:
- 更新混合系数:$\pi_k = \frac{\sum_{i=1}^N \gamma_{ik}}{N}$
- 更新均值:$\mu_k = \frac{\sum_{i=1}^N \gamma_{ik} x_i}{\sum_{i=1}^N \gamma_{ik}}$
- 更新协方差:$\Sigma_k = \frac{\sum_{i=1}^N \gamma_{ik} (x_i - \mu_k)(x_i - \mu_k)^T}{\sum_{i=1}^N \gamma_{ik}}$
这些公式的直观意义是什么?
- 混合系数 $\pi_k$ 就是所有样本对第 $k$ 个成分的平均"责任"
- 均值 $\mu_k$ 是所有样本的加权平均,权重就是它们对第 $k$ 个成分的"责任"
- 协方差 $\Sigma_k$ 是加权后的样本协方差
用Python实现M步:
def m_step(X, responsibilities):
"""
M步:基于责任矩阵更新GMM参数
参数:
X: 数据矩阵,形状为 (n_samples, n_features)
responsibilities: 责任矩阵,形状为 (n_samples, n_components)
返回:
weights: 更新后的混合系数
means: 更新后的均值
covariances: 更新后的协方差矩阵
"""
n_samples, n_features = X.shape
n_components = responsibilities.shape[1]
# 有效样本数:每个成分的"责任"总和
nk = responsibilities.sum(axis=0)
# 更新混合系数
weights = nk / n_samples
# 更新均值
means = np.zeros((n_components, n_features))
for k in range(n_components):
# 加权平均
means[k] = np.sum(responsibilities[:, k:k+1] * X, axis=0) / nk[k]
# 更新协方差矩阵
covariances = np.zeros((n_components, n_features, n_features))
for k in range(n_components):
# 计算加权协方差
diff = X - means[k]
# 使用矩阵乘法高效计算加权协方差
weighted_diff = responsibilities[:, k:k+1] * diff
covariances[k] = weighted_diff.T @ diff / nk[k]
# 添加正则化项,防止协方差矩阵奇异
covariances[k] += 1e-6 * np.eye(n_features)
return weights, means, covariances
注意:在更新协方差矩阵时,我添加了一个很小的正则化项(
1e-6 * np.eye(n_features))。这是实际应用中非常重要的技巧,可以防止协方差矩阵变得奇异(不可逆),从而避免数值计算问题。
4. 完整实现与调参实战
现在我们把E步和M步组合起来,实现完整的EM算法。但在此之前,我们需要解决一个关键问题:参数初始化。
4.1 智能初始化:K-means热身
EM算法对初始值很敏感。糟糕的初始化可能导致算法收敛到很差的局部最优解。一个常用的策略是先用K-means聚类得到初始的簇中心,然后用这些中心作为GMM的初始均值。
def initialize_parameters(X, n_components, method='kmeans'):
"""
初始化GMM参数
参数:
X: 数据矩阵
n_components: 高斯成分数量
method: 初始化方法,'kmeans'或'random'
返回:
weights, means, covariances
"""
n_samples, n_features = X.shape
if method == 'kmeans':
# 使用K-means进行初始化
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=n_components, n_init=10, random_state=42)
labels = kmeans.fit_predict(X)
weights = np.zeros(n_components)
means = np.zeros((n_components, n_features))
covariances = np.zeros((n_components, n_features, n_features))
for k in range(n_components):
mask = (labels == k)
weights[k] = np.sum(mask) / n_samples
means[k] = np.mean(X[mask], axis=0)
if np.sum(mask) > 1:
covariances[k] = np.cov(X[mask].T)
else:
covariances[k] = np.cov(X.T) # 回退到全局协方差
# 添加正则化
covariances[k] += 1e-6 * np.eye(n_features)
else: # random initialization
weights = np.ones(n_components) / n_components
means = X[np.random.choice(n_samples, n_components, replace=False)]
# 使用全局协方差作为初始值
global_cov = np.cov(X.T) + 1e-6 * np.eye(n_features)
covariances = np.array([global_cov.copy() for _ in range(n_components)])
return weights, means, covariances
4.2 完整的EM算法实现
现在我们可以实现完整的GMM训练过程了:
class GaussianMixtureModel:
"""高斯混合模型的手动实现"""
def __init__(self, n_components=3, max_iter=100, tol=1e-3,
init_method='kmeans', random_state=None):
"""
初始化GMM
参数:
n_components: 高斯成分数量
max_iter: 最大迭代次数
tol: 收敛阈值(对数似然变化小于此值则停止)
init_method: 初始化方法,'kmeans'或'random'
random_state: 随机种子
"""
self.n_components = n_components
self.max_iter = max_iter
self.tol = tol
self.init_method = init_method
self.random_state = random_state
if random_state is not None:
np.random.seed(random_state)
# 模型参数
self.weights_ = None # 混合系数
self.means_ = None # 均值
self.covariances_ = None # 协方差矩阵
self.responsibilities_ = None # 责任矩阵
self.log_likelihood_history_ = [] # 记录似然变化
def fit(self, X):
"""
使用EM算法训练GMM
参数:
X: 训练数据,形状为 (n_samples, n_features)
返回:
self: 训练好的模型
"""
n_samples, n_features = X.shape
# 1. 初始化参数
self.weights_, self.means_, self.covariances_ = \
initialize_parameters(X, self.n_components, self.init_method)
# 2. EM迭代
prev_log_likelihood = -np.inf
for iteration in range(self.max_iter):
# E步
responsibilities, log_likelihood = e_step(
X, self.weights_, self.means_, self.covariances_
)
# 记录似然变化
self.log_likelihood_history_.append(log_likelihood)
# 检查收敛
if iteration > 0 and abs(log_likelihood - prev_log_likelihood) < self.tol:
print(f"Converged at iteration {iteration}")
break
# M步
self.weights_, self.means_, self.covariances_ = m_step(X, responsibilities)
# 保存责任矩阵(可用于后续的聚类)
self.responsibilities_ = responsibilities
prev_log_likelihood = log_likelihood
# 每10次迭代打印进度
if iteration % 10 == 0:
print(f"Iteration {iteration}: log-likelihood = {log_likelihood:.4f}")
else:
print(f"Reached maximum iterations ({self.max_iter})")
return self
def predict_proba(self, X):
"""
预测每个样本属于每个成分的概率
参数:
X: 数据矩阵
返回:
概率矩阵,形状为 (n_samples, n_components)
"""
responsibilities, _ = e_step(X, self.weights_, self.means_, self.covariances_)
return responsibilities
def predict(self, X):
"""
预测每个样本最可能属于的成分
参数:
X: 数据矩阵
返回:
预测的簇标签,形状为 (n_samples,)
"""
responsibilities = self.predict_proba(X)
return np.argmax(responsibilities, axis=1)
def score_samples(self, X):
"""
计算每个样本的对数似然
参数:
X: 数据矩阵
返回:
每个样本的对数似然,形状为 (n_samples,)
"""
n_samples = X.shape[0]
log_probs = np.zeros(n_samples)
for k in range(self.n_components):
from scipy.stats import multivariate_normal
prob = multivariate_normal.pdf(
X,
mean=self.means_[k],
cov=self.covariances_[k]
)
log_probs += self.weights_[k] * prob
return np.log(log_probs)
def bic(self, X):
"""
计算贝叶斯信息准则(BIC),用于模型选择
参数:
X: 数据矩阵
返回:
BIC值(越小越好)
"""
n_samples, n_features = X.shape
# 参数数量
# 混合系数: n_components - 1 (因为和为1)
# 均值: n_components * n_features
# 协方差: n_components * n_features * (n_features + 1) / 2
n_params = (self.n_components - 1) + \
self.n_components * n_features + \
self.n_components * n_features * (n_features + 1) // 2
# 对数似然
log_likelihood = np.sum(self.score_samples(X))
# BIC = -2 * log_likelihood + n_params * log(n_samples)
bic = -2 * log_likelihood + n_params * np.log(n_samples)
return bic
4.3 实战:在生成数据上测试
让我们用之前生成的数据测试我们的实现:
# 创建并训练GMM模型
gmm = GaussianMixtureModel(
n_components=2,
max_iter=100,
tol=1e-4,
init_method='kmeans',
random_state=42
)
gmm.fit(X)
# 查看训练结果
print("Estimated weights:", gmm.weights_)
print("Estimated means:\n", gmm.means_)
print("\nTrue weights:", true_weights)
print("True means:\n", true_means)
# 预测聚类结果
predicted_labels = gmm.predict(X)
# 计算准确率(需要对齐标签,因为GMM的标签顺序是任意的)
from sklearn.metrics import adjusted_rand_score
ari = adjusted_rand_score(true_labels, predicted_labels)
print(f"\nAdjusted Rand Index: {ari:.4f}")
# 可视化聚类结果
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
for k in range(2):
mask = (true_labels == k)
plt.scatter(X[mask, 0], X[mask, 1],
c=colors[k], alpha=0.6,
label=f'True Component {k}', s=30)
plt.title('True Clusters')
plt.legend()
plt.subplot(1, 2, 2)
for k in range(2):
mask = (predicted_labels == k)
plt.scatter(X[mask, 0], X[mask, 1],
c=colors[k], alpha=0.6,
label=f'Predicted Component {k}', s=30)
plt.title('GMM Clustering Result')
plt.legend()
plt.tight_layout()
plt.show()
# 绘制对数似然的变化曲线
plt.figure(figsize=(8, 4))
plt.plot(gmm.log_likelihood_history_)
plt.xlabel('Iteration')
plt.ylabel('Log-Likelihood')
plt.title('EM Algorithm Convergence')
plt.grid(True, alpha=0.3)
plt.show()
运行这段代码,你会看到:
- 估计的参数与真实参数非常接近
- 聚类结果与真实标签高度一致(ARI接近1)
- 对数似然随着迭代单调增加,最终收敛
5. 高级话题与实战技巧
5.1 如何选择成分数量K?
这是使用GMM时最常遇到的问题。成分数量太少,模型欠拟合;太多,模型过拟合。有几种常用的方法:
1. 信息准则法 最常用的是贝叶斯信息准则(BIC)和赤池信息准则(AIC)。我们的实现中已经包含了BIC的计算:
def select_best_k(X, max_k=10):
"""使用BIC选择最佳的成分数量"""
bic_scores = []
models = []
for k in range(1, max_k + 1):
gmm = GaussianMixtureModel(n_components=k, random_state=42)
gmm.fit(X)
bic = gmm.bic(X)
bic_scores.append(bic)
models.append(gmm)
print(f"K={k}: BIC={bic:.2f}")
best_k = np.argmin(bic_scores) + 1
print(f"\nBest K = {best_k}")
# 可视化BIC曲线
plt.figure(figsize=(8, 4))
plt.plot(range(1, max_k + 1), bic_scores, 'o-')
plt.xlabel('Number of Components (K)')
plt.ylabel('BIC Score')
plt.title('BIC for Different K Values')
plt.axvline(x=best_k, color='r', linestyle='--', alpha=0.5)
plt.grid(True, alpha=0.3)
plt.show()
return best_k, models[best_k - 1]
# 在实际数据上测试
best_k, best_model = select_best_k(X, max_k=5)
2. 肘部法则(Elbow Method) 绘制对数似然随K变化的曲线,选择曲线"拐弯"的点:
def elbow_method(X, max_k=10):
"""肘部法则选择K"""
log_likelihoods = []
for k in range(1, max_k + 1):
gmm = GaussianMixtureModel(n_components=k, random_state=42)
gmm.fit(X)
# 使用最终的对数似然
final_ll = gmm.log_likelihood_history_[-1]
log_likelihoods.append(final_ll)
print(f"K={k}: Log-Likelihood={final_ll:.2f}")
# 计算相邻K的改善程度
improvements = np.diff(log_likelihoods)
plt.figure(figsize=(10, 4))
plt.subplot(1, 2, 1)
plt.plot(range(1, max_k + 1), log_likelihoods, 'o-')
plt.xlabel('Number of Components (K)')
plt.ylabel('Log-Likelihood')
plt.title('Log-Likelihood vs K')
plt.grid(True, alpha=0.3)
plt.subplot(1, 2, 2)
plt.plot(range(2, max_k + 1), improvements, 'o-')
plt.xlabel('Number of Components (K)')
plt.ylabel('Improvement in Log-Likelihood')
plt.title('Improvement vs K')
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
5.2 处理高维数据:协方差矩阵的约束
当数据维度很高时,协方差矩阵的估计会变得不稳定,需要大量数据。这时可以对协方差矩阵施加约束:
| 协方差类型 | 参数数量 | 适用场景 | Python实现 |
|---|---|---|---|
| 完全协方差 | $K \times D \times (D+1)/2$ | 数据充足,各成分形状任意 | 本文实现 |
| 对角协方差 | $K \times D$ | 特征相对独立 | covariances_[k] = np.diag(np.diag(cov)) |
| 球面协方差 | $K$ | 各向同性,所有特征方差相同 | covariances_[k] = sigma_k * np.eye(D) |
| 共享协方差 | $D \times (D+1)/2$ | 所有成分形状相同 | 所有成分用同一个协方差矩阵 |
实现对角协方差版本:
def m_step_diag(X, responsibilities):
"""M步(对角协方差版本)"""
n_samples, n_features = X.shape
n_components = responsibilities.shape[1]
nk = responsibilities.sum(axis=0)
weights = nk / n_samples
means = np.zeros((n_components, n_features))
covariances = np.zeros((n_components, n_features, n_features))
for k in range(n_components):
# 更新均值
means[k] = np.sum(responsibilities[:, k:k+1] * X, axis=0) / nk[k]
# 更新对角协方差
diff = X - means[k]
# 只保留对角线元素
var = np.sum(responsibilities[:, k:k+1] * diff**2, axis=0) / nk[k]
covariances[k] = np.diag(var)
# 添加正则化
covariances[k] += 1e-6 * np.eye(n_features)
return weights, means, covariances
5.3 EM算法的收敛性与初始化策略
EM算法保证收敛到局部最优,但不一定是全局最优。这意味着初始化非常重要。除了K-means初始化,还有其他策略:
1. 多次随机初始化
def fit_with_multiple_inits(X, n_components=3, n_init=10):
"""多次随机初始化,选择最好的结果"""
best_gmm = None
best_log_likelihood = -np.inf
for init in range(n_init):
gmm = GaussianMixtureModel(
n_components=n_components,
init_method='random',
random_state=42 + init # 不同的随机种子
)
gmm.fit(X)
final_ll = gmm.log_likelihood_history_[-1]
if final_ll > best_log_likelihood:
best_log_likelihood = final_ll
best_gmm = gmm
return best_gmm
2. 基于数据分布的初始化
def initialize_smart(X, n_components):
"""基于数据分布的智能初始化"""
n_samples, n_features = X.shape
# 使用数据的主成分方向进行初始化
from sklearn.decomposition import PCA
pca = PCA(n_components=n_features)
X_pca = pca.fit_transform(X)
# 在PCA空间中进行K-means
from sklearn.cluster import KMeans
kmeans = KMeans(n_clusters=n_components, n_init=10)
labels = kmeans.fit_predict(X_pca)
# 转换回原始空间
weights = np.zeros(n_components)
means = np.zeros((n_components, n_features))
covariances = np.zeros((n_components, n_features, n_features))
for k in range(n_components):
mask = (labels == k)
weights[k] = np.sum(mask) / n_samples
means[k] = np.mean(X[mask], axis=0)
if np.sum(mask) > 1:
covariances[k] = np.cov(X[mask].T)
else:
covariances[k] = np.cov(X.T)
covariances[k] += 1e-6 * np.eye(n_features)
return weights, means, covariances
5.4 实际应用中的注意事项
在实际项目中使用GMM时,有几个坑需要特别注意:
1. 协方差矩阵的正定性 协方差矩阵必须是正定的。在计算过程中,由于数值误差,可能变得非正定。解决方法:
- 添加正则化项:
cov += epsilon * np.eye(n_features) - 使用Cholesky分解检查正定性
2. 数值下溢问题 高维数据或小方差时,概率密度可能非常小,导致计算下溢。解决方法:
- 在对数空间进行计算
- 使用
scipy.special.logsumexp进行稳定的对数求和
3. 异常值处理 GMM对异常值敏感。异常值可能"吸引"一个高斯成分,破坏整体拟合。解决方法:
- 数据预处理时去除异常值
- 使用鲁棒版本的GMM(如t混合模型)
4. 成分数量的不确定性 有时数据没有清晰的簇结构,选择K很困难。这时:
- 结合领域知识
- 使用非参数方法(如Dirichlet过程混合模型)
- 可视化不同K的结果,看哪个最有意义
6. 超越聚类:GMM的更多应用
GMM不仅用于聚类,还有很多其他有趣的应用:
1. 密度估计 GMM是强大的概率密度估计器,可以拟合任意复杂的分布:
def density_estimation_demo():
"""展示GMM在密度估计上的能力"""
# 生成复杂形状的数据
np.random.seed(42)
n_samples = 1000
# 生成"双月形"数据
from sklearn.datasets import make_moons
X, _ = make_moons(n_samples=n_samples, noise=0.1)
# 用GMM拟合
gmm = GaussianMixtureModel(n_components=10, max_iter=200)
gmm.fit(X)
# 生成网格进行密度可视化
x_min, x_max = X[:, 0].min() - 0.5, X[:, 0].max() + 0.5
y_min, y_max = X[:, 1].min() - 0.5, X[:, 1].max() + 0.5
xx, yy = np.meshgrid(np.linspace(x_min, x_max, 100),
np.linspace(y_min, y_max, 100))
grid_points = np.c_[xx.ravel(), yy.ravel()]
# 计算每个网格点的概率密度
Z = np.exp(gmm.score_samples(grid_points))
Z = Z.reshape(xx.shape)
# 可视化
plt.figure(figsize=(10, 8))
plt.scatter(X[:, 0], X[:, 1], alpha=0.5, s=10)
plt.contour(xx, yy, Z, levels=20, cmap='Reds', alpha=0.8)
plt.colorbar(label='Log Probability Density')
plt.title('GMM Density Estimation')
plt.xlabel('Feature 1')
plt.ylabel('Feature 2')
plt.show()
2. 异常检测 由于GMM给出了每个点的概率密度,低密度区域可以视为异常:
def anomaly_detection(X, contamination=0.1):
"""使用GMM进行异常检测"""
gmm = GaussianMixtureModel(n_components=5)
gmm.fit(X)
# 计算每个样本的对数似然
log_probs = gmm.score_samples(X)
# 找到阈值(最低的contamination%)
threshold = np.percentile(log_probs, contamination * 100)
# 标记异常点
anomalies = log_probs < threshold
return anomalies, log_probs, threshold
3. 数据生成 训练好的GMM可以生成新的、与训练数据分布相似的样本:
def generate_from_gmm(gmm, n_samples):
"""从训练好的GMM生成新样本"""
n_components = len(gmm.weights_)
n_features = gmm.means_.shape[1]
# 根据混合系数选择成分
component_indices = np.random.choice(
n_components,
size=n_samples,
p=gmm.weights_
)
# 从每个成分生成样本
samples = np.zeros((n_samples, n_features))
for k in range(n_components):
mask = (component_indices == k)
n_k = np.sum(mask)
if n_k > 0:
samples[mask] = np.random.multivariate_normal(
mean=gmm.means_[k],
cov=gmm.covariances_[k],
size=n_k
)
return samples
7. 性能优化与生产级实现
我们上面的实现为了清晰牺牲了性能。在实际生产环境中,需要做很多优化:
1. 向量化计算 避免for循环,使用矩阵运算:
def e_step_vectorized(X, weights, means, covariances):
"""向量化的E步实现"""
n_samples, n_features = X.shape
n_components = len(weights)
# 一次性计算所有成分对所有样本的概率密度
# 使用scipy的多元正态分布,支持向量化计算
from scipy.stats import multivariate_normal
# 预计算每个高斯分布的逆协方差矩阵和行列式
precisions = np.zeros_like(covariances)
dets = np.zeros(n_components)
for k in range(n_components):
# 添加正则化确保可逆
cov = covariances[k] + 1e-6 * np.eye(n_features)
precisions[k] = np.linalg.inv(cov)
dets[k] = np.linalg.det(cov)
# 计算所有样本对所有成分的对数概率
log_probs = np.zeros((n_components, n_samples))
for k in range(n_components):
diff = X - means[k]
# 手动计算对数概率,避免scipy的开销
# log N(x|μ,Σ) = -0.5 * [D*log(2π) + log|Σ| + (x-μ)^T Σ^{-1} (x-μ)]
mahalanobis = np.sum(diff @ precisions[k] * diff, axis=1)
log_probs[k] = -0.5 * (n_features * np.log(2 * np.pi) +
np.log(dets[k]) + mahalanobis)
# 计算责任(使用log-sum-exp避免数值下溢)
weighted_log_probs = log_probs + np.log(weights[:, np.newaxis])
log_sum_exp = np.logaddexp.reduce(weighted_log_probs, axis=0)
log_responsibilities = weighted_log_probs - log_sum_exp
responsibilities = np.exp(log_responsibilities).T
# 对数似然
log_likelihood = np.sum(log_sum_exp)
return responsibilities, log_likelihood
2. 并行计算 对于大数据集,可以并行计算每个成分的概率:
from concurrent.futures import ThreadPoolExecutor
import multiprocessing as mp
def e_step_parallel(X, weights, means, covariances, n_jobs=-1):
"""并行化的E步"""
n_components = len(weights)
if n_jobs == -1:
n_jobs = mp.cpu_count()
# 将计算任务分配到多个进程
with ThreadPoolExecutor(max_workers=n_jobs) as executor:
futures = []
for k in range(n_components):
future = executor.submit(
multivariate_normal.pdf,
X,
mean=means[k],
cov=covariances[k]
)
futures.append(future)
# 收集结果
probs = [future.result() for future in futures]
probs = np.array(probs)
weighted_probs = weights[:, np.newaxis] * probs
responsibilities = weighted_probs / weighted_probs.sum(axis=0)
responsibilities = responsibilities.T
log_likelihood = np.sum(np.log(weighted_probs.sum(axis=0)))
return responsibilities, log_likelihood
3. 增量学习 对于流式数据或大数据,可以使用在线EM算法:
class OnlineGMM:
"""在线GMM实现(简化版)"""
def __init__(self, n_components=3, learning_rate=0.01):
self.n_components = n_components
self.learning_rate = learning_rate
self.weights_ = None
self.means_ = None
self.covariances_ = None
self.n_samples_seen_ = 0
def partial_fit(self, X_batch):
"""增量更新模型参数"""
if self.weights_ is None:
# 第一次看到数据,初始化
self._initialize(X_batch)
n_batch = X_batch.shape[0]
# E步:计算责任
responsibilities, _ = e_step_vectorized(
X_batch, self.weights_, self.means_, self.covariances_
)
# 在线M步:增量更新参数
for k in range(self.n_components):
# 计算批次的统计量
nk_batch = responsibilities[:, k].sum()
if nk_batch > 0:
mean_batch = np.sum(responsibilities[:, k:k+1] * X_batch, axis=0) / nk_batch
# 更新均值(指数加权平均)
self.means_[k] = (1 - self.learning_rate) * self.means_[k] + \
self.learning_rate * mean_batch
# 更新协方差(简化版)
diff = X_batch - self.means_[k]
cov_update = np.zeros_like(self.covariances_[k])
for i in range(n_batch):
cov_update += responsibilities[i, k] * np.outer(diff[i], diff[i])
cov_update /= max(nk_batch, 1)
self.covariances_[k] = (1 - self.learning_rate) * self.covariances_[k] + \
self.learning_rate * cov_update
# 更新混合系数
total_nk = responsibilities.sum(axis=0)
self.weights_ = (1 - self.learning_rate) * self.weights_ + \
self.learning_rate * (total_nk / n_batch)
self.n_samples_seen_ += n_batch
return self
def _initialize(self, X_batch):
"""使用第一批数据初始化"""
n_features = X_batch.shape[1]
# 简单初始化:随机选择数据点作为均值
idx = np.random.choice(len(X_batch), self.n_components, replace=False)
self.means_ = X_batch[idx]
# 使用全局协方差
global_cov = np.cov(X_batch.T) + 1e-6 * np.eye(n_features)
self.covariances_ = np.array([global_cov.copy()
for _ in range(self.n_components)])
# 均匀的混合系数
self.weights_ = np.ones(self.n_components) / self.n_components
8. 常见问题与调试技巧
在实际使用中,你可能会遇到各种问题。这里分享一些调试经验:
问题1:算法不收敛或收敛很慢 可能原因和解决方法:
- 学习率问题:在线学习时学习率太大或太小。可以尝试自适应学习率
- 初始化太差:尝试多次随机初始化,选择最好的
- 数据尺度不一致:不同特征量纲差异大,需要标准化
def check_convergence(gmm):
"""检查EM算法的收敛情况"""
log_likelihoods = gmm.log_likelihood_history_
if len(log_likelihoods) < 2:
return False, "Not enough iterations"
# 计算最后几次迭代的变化
last_changes = np.diff(log_likelihoods[-5:]) if len(log_likelihoods) >= 5 else np.diff(log_likelihoods)
if np.all(np.abs(last_changes) < 1e-6):
return True, "Converged (small changes)"
elif np.any(last_changes < 0):
return False, "Likelihood decreased (check implementation)"
else:
return False, f"Still improving, last change: {last_changes[-1]:.6f}"
问题2:协方差矩阵奇异 症状:计算概率密度时出现NaN或inf 解决方法:
def ensure_positive_definite(covariance, epsilon=1e-6):
"""确保协方差矩阵正定"""
n_features = covariance.shape[0]
# 方法1:添加小的对角线元素
covariance = covariance + epsilon * np.eye(n_features)
# 方法2:使用最近的正定矩阵(更稳定)
# 计算特征值
eigvals, eigvecs = np.linalg.eigh(covariance)
# 确保所有特征值都大于epsilon
eigvals = np.maximum(eigvals, epsilon)
# 重建协方差矩阵
covariance = eigvecs @ np.diag(eigvals) @ eigvecs.T
return covariance
问题3:成分权重趋于0 某些成分的权重变得非常小,几乎不贡献 解决方法:
- 设置权重的最小值
- 合并或删除权重太小的成分
- 使用Dirichlet先验(贝叶斯GMM)
def prune_components(gmm, min_weight=0.01):
"""修剪权重太小的成分"""
mask = gmm.weights_ >= min_weight
if np.sum(mask) < 2: # 至少保留2个成分
# 保留权重最大的2个
idx = np.argsort(gmm.weights_)[-2:]
mask = np.zeros_like(gmm.weights_, dtype=bool)
mask[idx] = True
gmm.weights_ = gmm.weights_[mask]
gmm.weights_ /= gmm.weights_.sum() # 重新归一化
gmm.means_ = gmm.means_[mask]
gmm.covariances_ = gmm.covariances_[mask]
gmm.n_components = np.sum(mask)
return gmm
问题4:选择K的困惑 当BIC或AIC没有明显的最小值时:
- 可视化不同K的聚类结果,看哪个最有意义
- 使用稳定性分析:多次运行看结果是否稳定
- 考虑业务需求:有时业务上就有明确的簇数量
def stability_analysis(X, n_components_range, n_runs=10):
"""稳定性分析:多次运行看聚类结果的一致性"""
from sklearn.metrics import adjusted_rand_score
stability_scores = {}
for k in n_components_range:
all_labels = []
for run in range(n_runs):
gmm = GaussianMixtureModel(n_components=k, random_state=run)
gmm.fit(X)
labels = gmm.predict(X)
all_labels.append(labels)
# 计算所有运行之间的平均一致性
pairwise_agreements = []
for i in range(n_runs):
for j in range(i + 1, n_runs):
ari = adjusted_rand_score(all_labels[i], all_labels[j])
pairwise_agreements.append(ari)
stability_scores[k] = np.mean(pairwise_agreements)
print(f"K={k}: Average ARI between runs = {stability_scores[k]:.4f}")
return stability_scores
写到这里,我想起第一次实现GMM时遇到的坑。最大的教训是:永远不要假设你的协方差矩阵是可逆的。在实际数据中,特别是高维数据中,协方差矩阵经常是奇异的。添加那个小小的正则化项 epsilon * np.eye(n_features) 看起来微不足道,但它能避免90%的数值问题。
另一个经验是:可视化是你的好朋友。无论公式推导得多完美,代码写得多优雅,最终都要用眼睛看看结果。画出数据的散点图,画出每个高斯成分的等高线,画出对数似然的变化曲线——这些可视化能帮你快速发现问题,理解模型的行为。
最后,GMM虽然强大,但它不是银弹。对于有明显非线性边界的数据,或者簇的形状特别奇怪的数据,GMM可能不是最佳选择。这时候可能需要考虑谱聚类、DBSCAN或者其他非线性方法。但无论如何,理解GMM和EM算法的工作原理,会让你对概率模型和聚类问题有更深刻的认识——这种认识会迁移到你遇到的几乎所有机器学习问题中。
更多推荐



所有评论(0)