3. t-SNE 数学推导:高维空间高斯分布、低维空间t分布、梯度计算
这一章我们来啃t-SNE的数学内核。别怕,虽然看着公式多,但核心逻辑其实很直观。
说白了,t-SNE要做的事情就是:在高维空间里,用高斯分布衡量点与点之间的相似度;然后在低维空间里,用t分布模仿这种相似度。最后通过梯度下降,让两个空间的分布尽量一致。
我个人习惯把这个问题拆成三步来看:
- 高维空间里,邻居关系怎么定义?
- 低维空间里,邻居关系怎么定义?
- 怎么让它们俩长得像?
3.1 高维空间:用高斯分布衡量相似度
假设我们有一堆高维数据点x₁, x₂, ... , xₙ。对于任意两个点xⱼ和xₖ,它们到底算不算“邻居”?t-SNE的做法不是简单算欧氏距离,而是用了一个条件概率的思路。
它把点xⱼ周围的距离分布,看成是一个以xⱼ为中心的高斯分布。然后问:如果我在这个分布里采样,采到xₖ的概率有多大?
这个条件概率p_{j|k}的公式长这样:
p_{j|k} = exp(-||xⱼ - xₖ||² / 2σₖ²) / Σ_{l≠k} exp(-||xₖ - xₗ||² / 2σₖ²)
其中σₖ是点xₖ周围高斯分布的方差。这个σₖ不是固定的——它通过一个叫“困惑度”(Perplexity)的参数来调节。困惑度大致相当于你期望每个点有多少个有效邻居。
我刚开始接触时也觉得这个σₖ很玄乎。后来想通了:它就是个“感知半径”。困惑度设小了,每个点只看几个最近的邻居;设大了,远处的点也会被拉进来。
有了条件概率之后,我们通常会把它们对称化,得到联合概率分布pⱼₖ:
pⱼₖ = (p_{j|k} + p_{k|j}) / 2n
这个对称化的目的,我个人理解是为了让距离关系更稳定,避免出现“A觉得B是邻居,但B不觉得A是邻居”这种尴尬情况。
核心要点:高维空间用高斯分布建模邻居关系,通过困惑度控制邻居数量。
3.2 低维空间:用t分布搞定“拥挤问题”
现在我们把数据映射到低维空间,得到了y₁, y₂, ... , yₙ。同样地,我们需要定义低维空间里点与点之间的相似度。
这里t-SNE做了一个关键选择——它没有继续用高斯分布,而是换成了t分布(自由度为1,也就是柯西分布)。
低维空间的联合概率qⱼₖ公式如下:
qⱼₖ = (1 + ||yⱼ - yₖ||²)⁻¹ / Σ_{l≠m} (1 + ||yₗ - yₘ||²)⁻¹
为什么要换成t分布?我遇到过不少初学者问这个问题。其实原因很直观:
- 高维空间里,数据点通常比较稀疏,远距离的点之间相似度自然很低。
- 但在低维空间里,如果还用高斯分布,远处的点会被“推”得很远,造成所谓的“拥挤问题”(Crowding Problem)。
- t分布有长尾特性——它允许远处的点在低维空间里稍微近一点,而不会过分惩罚它们。
需要注意的是:t分布的尾部比高斯分布厚重得多,这会导致低维空间中的距离关系被“拉伸”。这也是为什么t-SNE的可视化结果中,簇之间的距离往往不能直接解读为“真实差距”。
3.3 梯度计算:让两个分布越来越像
接下来就是关键步骤了——我们要让高维空间的p分布和低维空间的q分布尽量接近。衡量两个分布差异,最常用的就是KL散度(Kullback-Leibler divergence)。
目标函数C长这样:
C = Σⱼ Σₖ pⱼₖ * log(pⱼₖ / qⱼₖ)
我们的目标就是最小化这个C。怎么最小化?梯度下降。
对每个低维点yᵢ求梯度,经过一番推导(嗯,我当初推这个符号的时候也费了不少劲),最终得到的梯度公式是:
∂C/∂yᵢ = 4 * Σⱼ (pⱼᵢ - qⱼᵢ) * (yᵢ - yⱼ) * (1 + ||yᵢ - yⱼ||²)⁻¹
这个公式看着复杂,但拆开来看其实很清晰:
- pⱼᵢ - qⱼᵢ:高维和低维的相似度差异。如果高维认为它们该更近(p > q),就让它们靠近;反之则远离。
- yᵢ - yⱼ:移动的方向向量。
- (1 + ||yᵢ - yⱼ||²)⁻¹:t分布的权重,防止远距离点被过度拉扯。
我曾经在实现t-SNE时,一直觉得梯度计算这部分最容易出bug。特别是符号没搞对,结果可视化时发现所有点都挤成一团。嗯,后来检查发现是梯度里的负号写反了。
小技巧:实际实现时,通常会在梯度上加上一个“动量项”(Momentum),帮助算法跳出局部最优。一般动量值设为0.8左右,后期可以调高到0.9。
3.4 整体流程与核心逻辑
现在我们把这三步串起来,看看t-SNE的完整流程。下面这张图梳理了整个逻辑链条:
整体流程就是:高维→高斯分布→计算p→KL散度→梯度下降→更新低维坐标→用t分布重新计算q→再算KL散度→再梯度下降……如此循环,直到收敛。
3.5 实现中的一些坑(我踩过的)
最后分享几个实际编码时容易翻车的地方:
- 对称化公式写错:我曾经因为pⱼₖ对称化时忘了除以2n,结果所有点都挤到一块。调了一下午才发现。
- 困惑度太小:建议设置在5-50之间。我一般从30开始调,效果不好再往两边试。
- 学习率太激进:学习率设太大,梯度会震荡,结果不稳定。我习惯用200-500这个范围。
- 迭代次数不够:别指望几百步就能收敛。t-SNE通常需要1000步以上才能稳定。
记住一句话:t-SNE的数学不复杂,但参数调校和工程实现才是真正考验耐心的环节。
关于数学推导我们就聊到这里。理解了这些,后面看代码实现时会轻松很多。