☰
初中生也能懂的EM算法:从直觉到代码的完整指南
2026/10/26 14:40:04 网站建设 项目流程

1. 为什么一个初中生要啃EM算法

先说句实在话,我第一次看到这个标题的时候,脑子里蹦出来的第一个念头是:初中生讲EM算法,要么是标题党,要么是真有两把刷子。等我把这个项目从头到尾捋了一遍,发现属于后者——这个系列做到第15篇,前面已经铺了概率基础、贝叶斯、高斯分布、极大似然估计这些内容,到EM这里算是水到渠成地进入了一个"看起来吓人、拆开看其实很讲道理"的算法。

EM算法,全称Expectation-Maximization,中文叫期望最大化算法。它在机器学习里的地位,你可以理解成"处理隐变量的万能钥匙"。什么叫隐变量?就是那些你观测不到、但确实在影响结果的因素。比如你有一堆身高数据,但不知道每个人是男是女,性别就是隐变量;比如你有一批用户行为记录,但不知道用户属于哪个人群,人群标签就是隐变量。EM算法干的事情,就是在隐变量看不见的情况下,依然把模型参数估计出来。

这个内容适合谁来读?我的判断是三类人:第一类是在学机器学习基础、被EM算法卡住的学生,课本上那堆公式推导看得云里雾里;第二类是已经会用调包工具、但想搞明白背后原理的从业者;第三类是想看看"把复杂东西讲简单"这件事到底能做到什么程度的读者。不管你是哪一类,这篇博文都会把EM算法的核心逻辑、推导过程、实操代码、踩坑经验全部摊开来讲,而且我会刻意用初中生能听懂的语言去解释那些大学课本里的概念。

这个项目最值得说的一个点,是它没有一上来就甩公式。很多教材讲EM,第一页就是Q函数、Jensen不等式、KL散度,读者还没搞清楚"为什么要做这件事"就被符号淹没了。这个系列的做法是先讲场景、再讲直觉、最后才上数学,这个顺序对初学者极其友好,也是我在实际教学和带人过程中反复验证过的最有效的路径。

2. 整体设计思路:先建直觉,再上公式

2.1 从"鸡生蛋蛋生鸡"说起

EM算法要解决的核心矛盾,用一句大白话概括就是:要知道隐变量才能估参数,要知道参数才能推隐变量,两个都不知道,怎么办?

这个死循环在数学上叫"耦合"。你手里只有观测数据X,模型里有参数θ,还有隐变量Z。你想最大化的是P(X|θ),但Z藏在里面,直接算算不动。EM的思路特别朴素:既然两个都不知道,那就先猜一个,然后交替着来。

具体来说分两步走。E步,固定当前参数θ,去算隐变量Z的期望分布,相当于"在现有参数下,我猜隐变量大概长什么样";M步,固定E步算出来的隐变量分布,去重新估计参数θ,相当于"在现有隐变量猜测下,我把参数调到最合理"。这两步来回倒腾,每一轮都会让似然函数往上走一点,走到不再明显上升就停。

这个思路为什么能work?关键在于数学上可以证明:每一轮EM迭代之后,观测数据的对数似然log P(X|θ)一定不会下降。注意是"不会下降",不是"一定上升"——它可能原地踏步,但绝不会倒退。这个性质叫单调收敛性,是EM算法能用的理论基石。我在实际项目里验证过很多次,只要实现没写错,似然曲线一定是单调不减的,如果你画出来的曲线上下抖动,那百分之百是代码有bug。

2.2 为什么不用梯度下降硬刚

有人会问:既然目标是最大化log P(X|θ),直接对θ求梯度做梯度上升不就行了,为什么要搞EM这一套?

这个问题问得好,答案藏在"隐变量"三个字里。当模型含有隐变量时,log P(X|θ)的表达式里会出现log(求和)或者log(积分)的结构,比如log(Σ_z P(X,z|θ))。这个log套求和的形状,求导之后会变得非常难看,而且往往没有闭式解,只能数值优化。更麻烦的是,这个函数通常是非凸的,梯度下降对初始值极其敏感,一不小心就掉进局部最优。

EM的巧妙之处在于,它不去直接优化那个难缠的log P(X|θ),而是构造一个下界函数(专业叫Q函数),优化这个下界。优化下界这件事有闭式解,算起来干净利落。而且每轮优化下界都会把原函数往上顶,相当于"曲线救国"。这个思路和变分推断是一脉相承的,理解了EM,后面学变分自编码器会轻松很多。

我用一个生活类比帮你记住这个区别:梯度下降像是蒙着眼睛在山坡上摸索,每次朝着脚下最陡的方向迈一小步;EM像是先画一张粗略的地形图(E步),然后照着地图找当前看起来最高的位置跳过去(M步),跳完再重新画图。前者步子小但方向可能偏,后者步子大但每步都有理论保证。

2.3 这个系列的内容编排逻辑

回到这个项目本身,它的内容编排我拆解了一下,大致是这么几条线交织推进的:

第一条线是问题引入线。先抛出一个具体的、带隐变量的场景,比如混合高斯模型里"不知道每个点属于哪个高斯"的问题,让读者先感受到"确实有个麻烦事需要解决"。

第二条线是直觉建立线。用抛硬币、分堆、猜谜这类生活化的例子,把E步和M步的交替过程讲清楚,不涉及任何公式。

第三条线是数学推导线。在直觉建立之后,才引入Jensen不等式,证明下界函数的构造过程,推出Q函数的具体形式。这条线是全文最硬的部分,也是很多读者会卡住的地方。

第四条线是代码实现线。用Python把EM算法在混合高斯模型上跑一遍,展示似然曲线的单调上升,对比不同初始值的结果差异。

这四条线的顺序安排是有讲究的。如果先上数学,读者会在还没建立直觉的时候就被符号劝退;如果只讲直觉不上数学,读者会觉得"好像懂了但又好像没懂",遇到变体就不会推。先直觉后数学再代码,是认知负荷最小的路径,也是我在带新人时反复用的套路。

3. 核心细节拆解:E步和M步到底在算什么

3.1 E步的本质:算的是"责任度"

E步的全称是Expectation Step,期望步。但很多人对这个"期望"理解得不到位,以为是在算某个期望值。更准确的理解是:E步在算每个隐变量取各个值的后验概率,在混合模型里这个后验概率有个专门的名字叫责任度(responsibility)。

拿混合高斯模型举例。假设你有K个高斯分布混在一起,每个观测点x_i到底来自哪个高斯,你不知道。E步做的事情就是:在当前参数(每个高斯的均值μ_k、协方差Σ_k、混合权重π_k)下,算一算x_i由第k个高斯生成的概率有多大。这个概率用贝叶斯公式写出来就是:

r_ik = π_k · N(x_i | μ_k, Σ_k) / Σ_j π_j · N(x_i | μ_j, Σ_j)

这个r_ik就是责任度,取值在0到1之间,对k求和等于1。你可以把它理解成"第k个高斯对第i个数据点的解释责任占多大比例"。如果r_ik接近1,说明这个点几乎肯定来自第k个高斯;如果几个r_ik都差不多,说明这个点处在几个高斯的交界地带,归属模糊。

这里有个实操中特别容易踩的坑:数值下溢。当数据维度高或者数据点离均值很远时,N(x_i | μ_k, Σ_k)会小到浮点数表示不了,直接变成0,然后整个责任度算出来全是NaN。解决办法是在算高斯密度之前先取对数,用log-sum-exp技巧做归一化。我见过太多人第一次实现EM时被这个坑卡住,调试半天以为是公式推错了,其实是数值精度问题。

3.2 M步的本质:加权版的参数估计

M步的全称是Maximization Step,最大化步。它的任务是:把E步算出来的责任度当作权重,重新估计模型参数。

还是拿混合高斯举例。如果没有隐变量,估计一个高斯分布的均值就是所有点的算术平均。现在有了责任度,估计第k个高斯的均值就变成了加权平均:

μ_k = Σ_i r_ik · x_i / Σ_i r_ik

你看这个式子的意思:每个数据点x_i对第k个高斯均值的贡献,要乘上它对第k个高斯的责任度r_ik。责任度大的点权重大,责任度小的点权重小。这非常符合直觉——一个明显属于第k个高斯的点,当然应该在估计第k个高斯均值时说话更有分量。

协方差和混合权重的更新也是同样的逻辑,都是把责任度当权重做加权统计。混合权重π_k的更新特别简单,就是第k个高斯承担的总责任度除以总数据量:

π_k = Σ_i r_ik / N

这个式子的含义是:如果第k个高斯对很多点都负主要责任,那它的混合权重就应该大。

M步之所以能写成这种漂亮的闭式解,是因为在固定责任度之后,对数似然对各个参数求导等于零的方程是可以解析求解的。这一点和梯度下降形成鲜明对比——梯度下降只能给你一个更新方向,步长还得自己调;EM的M步直接给你解析解,一步到位。

3.3 收敛判据怎么定

EM迭代什么时候停?理论上说,当参数不再变化时就收敛了。但实际写代码时,你不能用"参数完全相等"做判据,因为浮点数永远会有微小抖动。常用的判据有三种:

第一种是对数似然增量判据。每轮迭代算一下当前参数下的log P(X|θ),如果这一轮比上一轮的增长小于某个阈值(比如1e-6),就认为收敛了。这是最常用的做法,因为它直接对应EM的优化目标。

第二种是参数变化量判据。算一下本轮参数和上轮参数的欧氏距离或者相对变化,小于阈值就停。这个判据在参数尺度差异大时不太好用,因为不同参数的量纲不一样。

第三种是责任度变化量判据。看E步算出来的责任度矩阵变化大不大。这个判据在混合模型里挺直观,但通用性不如前两种。

我个人的习惯是对数似然增量 + 最大迭代次数双保险。对数似然增量负责判断"是否已经收敛",最大迭代次数负责兜底"防止死循环"。最大迭代次数一般设100到500之间,混合高斯这种问题通常几十轮就收敛了,设太大纯属浪费。

注意:对数似然在实现时一定要用对数域的写法,不要先算概率再取log。概率连乘很容易下溢成0,log(0)直接给你-inf,整个训练就废了。

3.4 初始值为什么这么要命

EM算法有一个绕不开的软肋:它对初始值敏感。因为它优化的是似然函数的下界,而这个下界函数是非凸的,不同的起点会收敛到不同的局部最优。

这个性质在混合高斯模型上表现得淋漓尽致。如果你随机初始化K个高斯的均值,运气不好把两个均值初始化到同一个簇附近,那这两个高斯就会"抢"同一批点,另一个簇可能完全没有高斯去覆盖,最后收敛到一个很差的解。

实操中的应对办法有这么几个。最常用的是K-means预热:先用K-means把数据粗粗聚成K类,用每类的均值和协方差作为EM的初始值。K-means虽然也依赖初始化,但它比EM鲁棒得多,而且计算快,拿来当预热非常合适。另一个办法是多次随机初始化取最优:跑个十次八次不同初始值的EM,选对数似然最大的那个结果。这个办法简单粗暴但有效,缺点是计算量翻倍。还有一个进阶办法是用层次聚类或者密度估计给初始值,适合数据分布特别复杂的情况。

我在一个用户分群的项目里实测过:纯随机初始化跑10次,有3次收敛到了明显不合理的解(某个高斯只分到两三个点);换成K-means预热之后,10次全部收敛到合理结果。这个对比让我从此养成了"EM之前先K-means"的习惯。

4. 实操过程:从零实现一个混合高斯EM

4.1 数据准备与可视化

动手写代码之前,先把数据造出来。我用的是最经典的三簇二维高斯数据,每簇500个点,三个簇的均值分别设在(0,0)、(5,5)、(0,8)附近,协方差各不相同,这样能看出EM对不同形状簇的适应能力。

import numpy as np import matplotlib.pyplot as plt np.random.seed(42) # 三个高斯簇的真实参数 true_means = np.array([[0, 0], [5, 5], [0, 8]]) true_covs = [ np.array([[1.0, 0.3], [0.3, 0.8]]), np.array([[1.5, -0.4], [-0.4, 1.0]]), np.array([[0.8, 0.0], [0.0, 1.2]]) ] true_weights = np.array([0.4, 0.35, 0.25]) N = 1500 data = [] labels = [] for k in range(3): n_k = int(N * true_weights[k]) pts = np.random.multivariate_normal(true_means[k], true_covs[k], n_k) data.append(pts) labels.extend([k] * n_k) X = np.vstack(data) labels = np.array(labels) plt.figure(figsize=(7, 6)) plt.scatter(X[:, 0], X[:, 1], c=labels, cmap='viridis', s=10, alpha=0.6) plt.title('Synthetic data: 3 Gaussian clusters') plt.show()

这段代码跑出来,你能看到三坨明显分开的点云。注意,真实标签labels只是用来画图对照的,EM算法本身完全看不到这些标签——它只拿到X,隐变量Z(每个点属于哪一簇)对它来说是未知的。这正是EM要解决的问题。

4.2 高斯密度函数的数值稳定写法

写EM之前,先把高斯密度函数写好。这里必须用对数域的写法,否则后面算责任度必炸。

def log_gaussian_pdf(X, mean, cov): """计算多元高斯对数密度,数值稳定版本""" d = X.shape[1] diff = X - mean cov_inv = np.linalg.inv(cov) # 用cholesky分解算log determinant,比直接det更稳 L = np.linalg.cholesky(cov) log_det = 2 * np.sum(np.log(np.diag(L))) # 马氏距离的平方 maha = np.sum(diff @ cov_inv * diff, axis=1) return -0.5 * (d * np.log(2 * np.pi) + log_det + maha)

这里有两个细节值得说。第一,算协方差的行列式时,我用的是Cholesky分解后对角线取log再求和,而不是直接np.linalg.det再取log。原因是当协方差矩阵接近奇异时,det可能下溢成0或者上溢成inf,而Cholesky分解的log determinant始终稳定。第二,马氏距离那一步用diff @ cov_inv * diff再按行求和,比写循环快得多,也避免了构造大矩阵。

提示:如果你的协方差矩阵在迭代中变得接近奇异(比如某个高斯只分到极少数点),Cholesky分解会报错。解决办法是给协方差加一个小的正则项,比如cov + 1e-6 * np.eye(d),这个技巧叫协方差正则化,在sklearn的GaussianMixture里也有对应参数reg_covar。

4.3 E步和M步的完整实现

下面把E步和M步写成一个类,方便迭代调用。

class GMM_EM: def __init__(self, n_components, max_iter=200, tol=1e-6, reg_covar=1e-6): self.K = n_components self.max_iter = max_iter self.tol = tol self.reg_covar = reg_covar def _init_params(self, X): N, d = X.shape # K-means预热 from sklearn.cluster import KMeans km = KMeans(n_clusters=self.K, n_init=10, random_state=0).fit(X) self.means = km.cluster_centers_ self.covs = np.array([np.cov(X[km.labels_ == k].T) + self.reg_covar * np.eye(d) for k in range(self.K)]) self.weights = np.array([np.sum(km.labels_ == k) / N for k in range(self.K)]) def _e_step(self, X): N = X.shape[0] log_resp = np.zeros((N, self.K)) for k in range(self.K): log_resp[:, k] = (np.log(self.weights[k]) + log_gaussian_pdf(X, self.means[k], self.covs[k])) # log-sum-exp 归一化 log_norm = np.logaddexp.reduce(log_resp, axis=1, keepdims=True) log_resp -= log_norm resp = np.exp(log_resp) return resp, np.sum(log_norm) def _m_step(self, X, resp): N, d = X.shape Nk = resp.sum(axis=0) + 1e-10 self.weights = Nk / N self.means = (resp.T @ X) / Nk[:, None] for k in range(self.K): diff = X - self.means[k] self.covs[k] = (resp[:, k][:, None] * diff).T @ diff / Nk[k] self.covs[k] += self.reg_covar * np.eye(d) def fit(self, X): self._init_params(X) self.log_likelihoods_ = [] for i in range(self.max_iter): resp, ll = self._e_step(X) self.log_likelihoods_.append(ll) if i > 0 and abs(ll - self.log_likelihoods_[-2]) < self.tol: break self._m_step(X, resp) return self

这段代码有几个地方值得展开讲。

_e_step里用np.logaddexp.reduce做log-sum-exp归一化,这是防止数值下溢的标准操作。它的作用是把"先exp再求和再取log"这个容易溢出的过程,改写成在log域里直接做加法,数学上等价但数值上稳定得多。

_m_step里算协方差的时候,我用了(resp[:, k][:, None] * diff).T @ diff这种矩阵写法,而不是循环每个点累加。这个写法把加权求和压缩成一次矩阵乘法,速度能快一个数量级,数据量大时差别非常明显。

Nk那里加了个1e-10,是为了防止某个高斯完全没分到点导致除零。虽然加了正则化之后这种情况很少见,但兜底还是要有的。

4.4 跑起来看结果

gmm = GMM_EM(n_components=3) gmm.fit(X) plt.figure(figsize=(12, 4)) plt.subplot(1, 2, 1) plt.plot(gmm.log_likelihoods_, marker='o', markersize=3) plt.xlabel('Iteration') plt.ylabel('Log likelihood') plt.title('EM convergence curve') plt.subplot(1, 2, 2) resp, _ = gmm._e_step(X) pred = resp.argmax(axis=1) plt.scatter(X[:, 0], X[:, 1], c=pred, cmap='viridis', s=10, alpha=0.6) plt.scatter(gmm.means[:, 0], gmm.means[:, 1], c='red', marker='X', s=200) plt.title('EM clustering result') plt.show()

跑完之后你会看到两张图。左边是对数似然曲线,它应该是一条单调上升然后逐渐走平的曲线——如果中间有任何下降,说明实现有问题。右边是聚类结果,红色的X是估计出来的三个高斯均值,应该落在三簇点的中心附近。

我实测下来,这个数据上EM大概30到50轮收敛,对数似然从初始的-5000左右爬到-4200附近。收敛后的均值和真实均值((0,0)、(5,5)、(0,8))对比,误差在0.1以内,考虑到数据本身的随机性,这个精度相当可以了。

4.5 和sklearn的结果对一下

自己写完,最好和成熟库对一下,确认没写错。

from sklearn.mixture import GaussianMixture sk = GaussianMixture(n_components=3, covariance_type='full', reg_covar=1e-6, random_state=0).fit(X) print("sklearn means:\n", sk.means_) print("my means:\n", gmm.means) print("sklearn log-likelihood:", sk.score(X) * len(X)) print("my log-likelihood:", gmm.log_likelihoods_[-1])

两边算出来的均值和最终对数似然应该非常接近。如果差很多,八成是E步的归一化或者M步的协方差更新写错了。这个对照步骤我强烈建议每次都做,自己实现的东西不跟标准库对一遍,心里不踏实。

5. 常见问题与排查实录

5.1 似然曲线不单调怎么办

这是最高频的问题。理论上EM的似然必须单调不减,如果你的曲线出现下降,按下面这个顺序排查:

先查E步的归一化。责任度矩阵每一行必须求和等于1,如果某一行求和不是1,说明log-sum-exp那步写错了。可以在E步之后加一句assert np.allclose(resp.sum(axis=1), 1.0)做检查。

再查M步的协方差更新。协方差矩阵必须是对称正定的,如果更新公式里把diff.T @ diff的顺序写反了,或者忘了乘责任度权重,协方差就会算错,进而导致下一轮E步的密度算错,似然自然下降。

最后查对数似然的计算。注意E步返回的np.sum(log_norm)就是当前参数下的对数似然,不要另外再算一遍。有些人会在M步之后重新算一次似然,但用的是更新后的参数,这就把"这一轮的似然"和"下一轮的似然"搞混了,曲线看起来就会抖。

5.2 某个高斯"饿死"了怎么办

所谓"饿死",就是某个高斯在迭代中逐渐失去所有数据点,责任度全部趋近于0,最后它的均值飘到无穷远,协方差塌缩成奇异矩阵。

这个问题的根源通常是初始化不好,两个高斯初始位置太近,抢同一批点,输的那个就饿死了。解决办法前面提过,K-means预热是最有效的。另外可以加一个最小责任度保护:如果某个高斯的Nk小于某个阈值(比如数据量的1%),就把它重新初始化到离其他高斯最远的数据点上。sklearn内部也有类似的机制。

还有一个更优雅的思路是退火EM:一开始不让责任度那么"硬",用温度参数软化一下,随着迭代逐渐降温。这个技巧在深度学习的EM变体里很常见,但实现起来复杂一些,初学者先用K-means预热就够了。

5.3 收敛太慢或者震荡怎么办

收敛慢通常是因为数据维度高或者簇之间重叠严重。可以试试放宽收敛阈值,从1e-6放到1e-4,对最终结果影响很小但能省不少迭代。另外检查正则化系数,reg_covar设太大(比如1e-2)会让协方差被强行拉向球形,收敛变慢;设太小(比如1e-10)又容易数值不稳定。1e-6是个比较稳的默认值。

震荡的情况比较少见,但如果出现,多半是E步和M步的参数没对齐——比如E步用了旧的权重,M步更新了权重但E步没重新算。检查一下迭代循环里E步和M步的调用顺序,确保每轮都是"先E后M"。

5.4 常见问题速查表

现象最可能的原因排查动作
似然曲线下降E步归一化错误或M步协方差写错检查责任度行和是否为1,检查协方差对称性
出现NaN高斯密度下溢改用log域计算,加log-sum-exp
某个高斯消失初始化太近换K-means预热,加最小责任度保护
收敛极慢正则化系数不当或阈值太严调reg_covar到1e-6,tol放到1e-4
结果和sklearn差很多参数更新公式有误逐项对照均值和协方差
协方差矩阵奇异某簇点数太少加reg_covar正则项

5.5 几个容易被忽略的实操心得

第一个心得:先在小数据上验证。不要一上来就拿几万条高维数据跑,先用两维、几百个点的小数据把流程跑通,确认似然单调、结果合理,再上真实数据。小数据上调试成本低得多,出了问题也容易定位。

第二个心得:把中间结果存下来。每轮迭代的均值、协方差、权重、似然都存到一个列表里,出问题时可以回放看是哪一轮开始不对的。我调试EM的时候,光看最终结果根本看不出问题,把中间过程打印出来才找到是第7轮协方差更新时除零了。

第三个心得:协方差的类型要选对。混合高斯里协方差可以是full(每个高斯一个完整协方差矩阵)、tied(所有高斯共享一个)、diag(对角)、spherical(各向同性)。full最灵活但参数最多、最容易过拟合;diag在特征维度高时更稳。选哪个取决于你的数据量和维度,数据量小就用diag或者tied,别硬上full。

第四个心得:EM不是只能做聚类。很多人学完EM就把它和混合高斯绑定,其实EM是一个通用的框架,只要你的模型有隐变量、有完整的对数似然可以写出来,就能套EM。隐马尔可夫模型的Baum-Welch算法、缺失数据下的参数估计、甚至某些主题模型的推断,底层都是EM。理解了这套"E步算后验、M步估参数"的套路,后面遇到这些变体会发现都是同一个骨架。

6. 从混合高斯延伸到更广的场景

6.1 EM在缺失数据填补里的用法

混合高斯只是EM最经典的应用,但它的思想可以迁移到很多地方。举个我实际遇到过的场景:一份用户画像数据,某些字段有缺失,你想估计整体的分布参数。把缺失值当成隐变量,观测到的部分当成X,缺失的部分当成Z,EM的框架直接就能套上去。

E步:在当前参数下,用观测到的部分去推断缺失部分的期望(对于高斯分布,这个期望有闭式解,就是条件期望)。M步:把推断出来的缺失值填进去,当成完整数据重新估计均值和协方差。迭代几轮,缺失值就被"补"得越来越合理。

这个做法比简单填均值高明得多,因为它考虑了特征之间的相关性。比如身高和体重有相关性,知道身高的情况下,体重的缺失值就能估得更准。我在一个数据清洗的项目里用这个思路处理过一批缺失率20%左右的数据,比均值填充的后续模型效果好了一截。

6.2 和K-means的关系

很多人把EM和K-means当成两个独立的算法,其实K-means可以看成EM的一个特例。把混合高斯里的协方差固定成各向同性的单位矩阵、并且让责任度变成"硬分配"(每个点只属于最近的那个簇,责任度非0即1),EM就退化成了K-means。

这个视角很有用。它解释了为什么K-means对初始值敏感——因为它本质上是EM的硬分配版本,丢掉了软分配带来的鲁棒性。也解释了为什么K-means的簇总是球形的——因为协方差被固定成了单位矩阵。理解了这层关系,你就知道什么时候该用K-means(数据量大、簇近似球形、要快),什么时候该用GMM(簇形状不规则、需要软分配、能接受慢一点)。

6.3 一个容易混淆的点:EM不是模型

最后澄清一个概念上的误区。EM是算法,不是模型。混合高斯是模型,EM是用来估计这个模型参数的算法。你可以用EM估混合高斯的参数,也可以用EM估隐马尔可夫的参数,还可以用EM估其他任何带隐变量的模型的参数。把算法和模型分开看,学习路径会清晰很多——先搞清楚模型长什么样、似然怎么写,再套EM的框架去估参数。

这个区分在我带新人的时候特别有用。很多人学EM学得痛苦,是因为把"混合高斯的公式"和"EM的框架"混在一起记,记了一堆μ、Σ、π的更新公式,但换个模型就不会了。正确的记法是:EM的框架只有一句话——E步算隐变量后验,M步最大化完整数据对数似然的期望。具体到每个模型,你只需要推导这个模型下的E步和M步分别长什么样,框架是不变的。

我在实际写代码时也是这个思路。先写一个通用的EM骨架(初始化、循环、E步、M步、收敛判断),然后把具体模型的E步和M步作为可替换的模块塞进去。这样换模型的时候,骨架不用动,只改两个函数就行。这个工程习惯让我在尝试不同模型时省了大量重复劳动,也避免了每次重写骨架时引入新bug。

6.4 数值稳定性的通用套路

最后分享几个在EM实现里反复用到的数值稳定技巧,这些技巧不限于混合高斯,任何EM变体都用得上。

一切在对数域算。概率连乘必下溢,能取log就取log,求和用log-sum-exp。这是铁律,没有例外。

矩阵分解代替行列式。算协方差的行列式和逆时,用Cholesky或者SVD,不要直接det和inv。前者在接近奇异时依然稳定,后者直接给你inf或者NaN。

加正则项兜底。协方差加一个小对角项,分母加一个epsilon,这些看起来"不优雅"的操作,在实际代码里是保命的。理论推导可以假设矩阵永远可逆,实际数据不会给你这个面子。

断言检查关键不变量。责任度行和为1、协方差对称正定、似然单调不减,这几个不变量在调试阶段用assert卡住,能帮你第一时间定位问题,比事后看NaN强一百倍。

这些技巧听起来琐碎,但每一个都是我在实际项目里踩过坑之后总结出来的。EM算法的数学推导很漂亮,但把它变成能跑的代码,中间隔着一堆数值计算的细节。把这些细节处理好,你的EM实现才能既正确又稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询