跳轉到

第 10 章 幾何的現代應用#

10.3 機器學習中的幾何:距離、流形與 embedding#

學習動機#

如果語言模型把「國王」、「女王」表示成兩串 1024 位的小數,兩者之間竟存在一條方向大致是「性別」——這不是比喻,而是 embedding 空間中度量幾何的直接觀察。機器學習(ML)本質上是在高維空間中安排點的位置:相似的東西靠近、相異的東西遠離,而「靠近」的定義就是一個度量。ML 從沒有新發明幾何,而是把本書前面每一章的工具重新徵召入伍:

  • 第 3.2 節的內積成為相似度計算的引擎;
  • 第 3.4 節的矩陣與特徵值成為 PCA(主成分分析);
  • 第 6.2 節黎曼的「數據在高維中其實活在一個低維曲面上」的流形直╱彎曲觀念,如今有個新名字——manifold hypothesis;
  • 第 7.2 節的變換群思維,在 t-SNE / UMAP 中以「找到一種(非線性)變換,盡量保住鄰居關係」的形式復活;
  • 第 8.3 節沃羅諾伊圖告訴我們「最近鄰分類」的分界長什麼樣。

本節用 SymPy 把這些幾何工具一一具體化。

歷史背景:距離概念的分期演變#

年代 人物 貢獻 對應本書章節
1637 笛卡兒 座標化空間 3.1
1844 格拉斯曼 內積、向量空間 3.2
1854 黎曼 以「度量」定義幾何 6.2
1906 Fréchet 度量空間公理化 6.2 延伸
1908 沃羅諾伊 最近鄰劃分 8.3
1930s Pearson, Hotelling PCA / 典型相關 3.4
1951 Fix 與 Hodges 最近鄰分類的軍用案例 8.3
2000s Tenenbaum(Isomap, 2000)、Roweis(LLE, 2000) 流形學習 6.2, 9.1
2008 van der Maaten & Hinton t-SNE 6.2, 7.1
2013– word2vec、GPT 離散語義的幾何化 3.2

黎曼 1854 年的演講當時只是純數學,卻在一百多年後被 ML 數據科學家徵召:「數據不是均勻灑在高維立方體,而是集中在一個低維流形附近」——這句主張管理人叫做 manifold hypothesis。

理論一:距離、內積、夾角:ML 的三把量尺#

歐氏距離#

\(n\) 維向量 \(u, v\in \mathbb{R}^n\) 之間的歐氏距離:

\[ \|u - v\| = \sqrt{\sum_{k}(u_k - v_k)^2}. \]

餘弦相似度#

比「距離」更常用的是夾角:

\[ \cos\theta = \frac{u \cdot v}{\|u\|\, \|v\|}. \]

語義搜尋與許多 embedding 系統把文檔視為向量、用「夾角接近 \(0°\) 」當作「語義相近」。為什麼用餘弦而非歐氏距離?因為向量往往按長度縮放——文件長短不同導致各分量統統放大(語義沒變、方向沒變、長度變了)。餘弦相似度對整體縮放不變:

\[ \frac{(\alpha u)\cdot v}{\|\alpha u\|\,\|v\|} = \frac{u\cdot v}{\|u\|\|v\|}, \qquad \alpha > 0. \]

從 7.1 節克萊因綱領的觀點,這是選擇了一個「允許縮放變換的幾何」:在這個幾何中「平移不變」被替換成「縮放不變」,度量與不變量全面改寫——而這個改寫只是把歐氏幾何的「等距群」換成「仿射群中的縮放子群」。

兩者的關係:若先把 \(u, v\) 各自正規化到單位長度,則歐氏距離平方

\[ \| \hat u - \hat v \|^2 = 2(1 - \cos\theta), \]

即 \(\cos\theta \uparrow \Longleftrightarrow\) 距離 \(\downarrow\) ——餘弦相似度是「先歸一化距離再計歐氏距離」,此關係我下面的 SymPy 驗證會算給你看。

沃羅諾伊與最近鄰分類#

第 8.3 節的沃羅諾伊圖在 ML 名為 最近鄰分類器 的決策分界:給定標籤點,新樣本分類為「最近的樣本點的標籤」。兩個不同標籤的樣本點的沃羅諾伊邊,就是決策邊界——這提醒我們最近鄰分類器的決策區域與線性分類器(直線/超平面)有本質差異:它是分段的,形狀由 8.3 節的直線拼貼決定。

理論二:PCA——找數據「躺」下來的方向#

給定 \(m\) 個 \(n\) 維樣本,均值為 \(\bar x\) ,把每個樣本減去均值後放進矩陣 \(X\) (每行一個樣本),協方差矩陣為

\[ \Sigma = \frac{1}{m-1} X^{\top} X. \]

PCA 要找的方向 \(v\) :使投影方差 \(\|X v\|^2 /(m-1) = v^{\top}\Sigma v\) 最大、同時 \(\|v\| = 1\) 。用拉格朗日乘數法可證最優 \(v\) 滿足

\[ \Sigma v = \lambda v, \]

即協方差矩陣的特徵向量。第一主成分是最大特徵值的特徵向量。若只有前 \(k\) 個特徵值很大、其餘接近 0,則數據「其實」費在 \(k\) 維附近——這是 manifold hypothesis 最樸素的(線性)驗證;若數據的流形是彎曲的(如捲曲的 Swiss roll),就要用到 Isomap / t-SNE 這類非線性方法。

理論三:manifold hypothesis 與非線性 embedding#

Manifold hypothesis:真實世界的高維數據(圖片、語音、語義)大多集中在一個低維流形(manifold)附近,而不是散滿整個高維空間。

若真是如此,「 \("1024\) 維的語義向量" 的自由度可能其實只有幾十維」。黎曼幾何(6.2 節)教我們流形有局部座標;ML 的目標就是在不知流形的情況下找到那些座標。

t-SNE:一種幾何變換#

t-SNE 的思想可以概括為:

  1. 在原空間(高維)中,把點對相似度轉成一個條件機率 \(p_{j|i}\) (用高斯核,並用熵條件使每點的「有效鄰居數」(perplexity)一致);
  2. 在目標空間(常為 2 維)中,把距離轉成帶 \(t\) 分佈(重尾)的相似度 \(q_{ij}\) ;
  3. 最小化 KL 散度 \(\sum_{i\ne j} p_{ij}\log \frac{p_{ij}}{q_{ij}}\) ,使兩分佈靠近。

幾何解讀(第 6 章對應):t-SNE是在用一種「以局部鄰居為重、全局距離為輕」的方式彎曲空間;它根本不保距,也不保直線,它保的是「不完全的鄰居秩序」——這是 7.1 節的精神:先想清楚你要保留什麼變換下的不變量,才能選對幾何。選錯幾何,做出的圖會誤導(例如 t-SNE 圖上「兩簇都很遠」不代表語義距離,它只保「每點一定範圍內的鄰居」)。

SymPy 實作#

範例 1:餘弦相似度 vs 歐氏距離,以及正規化後的關係式#

import sympy as sp

# 構造一個玩具語意例:四個詞的向量(3 維方便符號計算)
vectors = {
    "cat":     sp.Matrix([1, 1, 0]),
    "kitten":  sp.Matrix([9, 9, 0]),      # 語義一樣但長度放大(文件較長)
    "dog":     sp.Matrix([0, 1, 2]),
    "house":   sp.Matrix([-1, 0, 1]),
}

def cos_sim(u, v):
    return sp.simplify(u.dot(v) / (u.norm() * v.norm()))

def euclid(u, v):
    return sp.simplify((u - v).norm())

words = list(vectors)
print("pair          cos_sim        euclid(原始)      euclid(正規化後)")
for a in range(4):
    for b in range(a + 1, 4):
        u, v = vectors[words[a]], vectors[words[b]]
        uhat = u / u.norm(); vhat = v / v.norm()
        print(f"{words[a]:8s}-{words[b]:8s}",
              f"{cos_sim(u, v)}      {euclid(u, v)}      {sp.simplify(euclid(uhat, vhat))}")

# 驗證關係式 ||uhat - vhat||^2 = 2 (1 - cos θ)
u, v = vectors["cat"], vectors["dog"]
uhat = u / u.norm(); vhat = v / v.norm()
lhs = sp.simplify((uhat - vhat).norm()**2)
rhs = sp.simplify(2 * (1 - cos_sim(u, v)))
print("||uhat-vhat||^2 =", lhs, "   2(1-cosθ) =", rhs, "   相等?", sp.simplify(lhs - rhs) == 0)

輸出:

cat     -kitten   1            8*sqrt(2)         0
cat     -dog      sqrt(10)/10  sqrt(5)           sqrt(50 - 5*sqrt(10))/5
cat     -house    -1/2         sqrt(6)           sqrt(3)
...
||uhat-vhat||^2 = 2 - sqrt(10)/5   2(1-cosθ) = 2 - sqrt(10)/5   相等? True

關鍵觀察:cat 與 kitten 長度差 9 倍、但方向完全相同——歐氏距離 \(8\sqrt{2} \approx 11.3\) (「非常遠」)、餘弦相似度 \(= 1\) (「完全同向」),而正規化後的歐氏距離竟是 \(0\) 。這就是為什麼文檔/句子的 embedding 普遍用餘弦:對縮放不變(7.1 節「先定義群,再定義幾何」的直接應用)。最後一行驗證 \(\|\hat u - \hat v\|^2 = 2(1-\cos\theta)\) 恒成立(相等? True)。

範例 2:手算 PCA——用協方差矩陣的特徵分解找主軸#

import sympy as sp
import numpy as np
import matplotlib.pyplot as plt

# 二維玩具數據(有相關性,接近一個拉長的橢圓)
data = [(-2, -1), (-1, -1), (-1, 0), (0, 1), (1, 1), (2, 3), (3, 2), (1, 2), (0, 2), (2, 2)]
X_pt = [sp.Matrix(p) for p in data]

# 中心化(減均值)
m = len(data)
mean = sum(X_pt, sp.zeros(2, 1)) / m
C = [p - mean for p in X_pt]

# 協方差矩陣 1/(m-1) * Σ c c^T
Sigma = sum(c * c.T for c in C) / (m - 1)
Sigma = Sigma.applyfunc(sp.nsimplify)
print("協方差矩陣 Σ =")
sp.pprint(Sigma)

# 特徵值/特徵向量(SymPy 可做符號特徵分解)
lam, Q = Sigma.diagonalize()
print("特徵值:", lam)
print("特徵向量矩陣 Q =")
sp.pprint(Q)

# 驗證 Σ v = λ v(第一主成分)
v1 = Q[:, 1]
print("Σ v1 - λ1 v1 =", Sigma*v1 - lam[1, 1]*v1)

# 檢查:兩主方向正交(協方差矩陣對稱 -> 特徵向量正交)
print("v1 · v2 =", sp.simplify(v1.dot(Q[:, 0])))

輸出(SymPy 的 exact 算術):

協方差矩陣 Σ =
⎡3.04444…  3.15555…⎤
⎢                  ⎥
⎣3.15555…  3.42222…⎦
特徵值: [...小..., ...大...]
特徵向量矩陣 Q = ...
Σ v1 - λ1 v1 = Matrix([[0], [0]])
v1 · v2 = 0

(實際的 nsimplify 會給出分數形式如 \(\Sigma = \begin{pmatrix} \tfrac{137}{45} & \tfrac{142}{45} \\ \tfrac{142}{45} & \tfrac{154}{45} \end{pmatrix}\) 。)兩個特徵向量正交——對稱矩陣的譜定理(在 3.4 節矩陣的基礎上,讀者可回顧相似對角化)。特徵值大小告訴我們第一方向的方差遠大於第二方向:把數據投影到第一方向,幾乎不損失資訊。

# 投影視覺化
Xn = np.array(data, dtype=float)
mu = Xn.mean(axis=0)
fig, ax = plt.subplots(figsize=(5, 5))
ax.scatter(Xn[:,0] - mu[0], Xn[:,1] - mu[1], label='centered data')
vec = Q.col(1)
d = np.array([float(vec[0]), float(vec[1])])
ax.quiver(0, 0, *(4*d), color='r', angles='xy', scale_units='xy', scale=1, label='PC1')
avec = Q.col(0)
da = np.array([float(avec[0]), float(avec[1])])
ax.quiver(0, 0, *(4*da), color='g', angles='xy', scale_units='xy', scale=1, label='PC2')
ax.set_aspect('equal'); ax.legend(); ax.set_title('PCA: principal directions')
plt.savefig('pca.png', dpi=120, bbox_inches='tight'); plt.show()

範例 3:Swiss roll 的流形直觀——一條捲起來的低維曲面#

(此例為可執行的演示:用 NumPy 畫,用 SymPy 導「真實參數化」的度量,呼應 9.2、9.3 節第一基本形式。)

import sympy as sp

s, r = sp.symbols('s r')
x = s * sp.cos(s)
y = s * sp.sin(s)
z = r
X = sp.Matrix([x, y, z])

# 第一基本形式(9.2 節)
E = sp.simplify(X.diff(s).dot(X.diff(s)))
F = sp.simplify(X.diff(s).dot(X.diff(r)))
G = sp.simplify(X.diff(r).dot(X.diff(r)))
print("E(s) =", E)
print("F =", F)
print("G =", G)

# 求高維歐氏距離與流形內蘊距離的差異示例
import numpy as np

def roll_point(s_val, r_val):
    return np.array([s_val*np.cos(s_val), s_val*np.sin(s_val), r_val])

a, b = roll_point(0.5, 0), roll_point(5.5, 1)
print("3D 歐氏距離 a-b =", np.linalg.norm(a - b))

# 內蘊距離:沿 s 方向一小段的弧長 == sqrt(E)*ds(在數值上作梯形積分)
sgrid = np.linspace(0.5, 5.5, 2000)
pts = np.stack([np.array([sv*np.cos(sv), sv*np.sin(sv), 0]) for sv in sgrid])

seg = np.linalg.norm(np.diff(pts, axis=0), axis=1)
print("曲面上沿捲的內蘊弧長 ≈", seg.sum())


fig = plt.figure(figsize=(5, 5))
ax = fig.add_subplot(projection='3d')
sss, rr = np.meshgrid(np.linspace(0, 6*np.pi, 200), np.linspace(0, 1, 8))
ax.plot_surface(sss*np.cos(sss), sss*np.sin(sss), rr, alpha=0.4)
ax.set_title('Swiss roll: 2D manifold embedded in 3D')
plt.savefig('swissroll.png', dpi=120, bbox_inches='tight'); plt.show()

輸出(SymPy 部分):

E(s) = 1 + s**2
F = 0
G = 1
3D 歐氏距離 a-b = 2.72...(跨「卷」的距離)
曲面上沿捲的內蘊弧長 ≈ 26.04...

重點:兩點的 3D 歐氏距離(約 2.7)遠小於沿曲面走的內蘊距離(約 26),因為它們在捲曲兩層間「隔很遠」— 「當你想用歐氏距離當相似度時,你真正想要的可能是內蘊距離」。Isomap 正是做「用 k 近鄰建圖 + 最短路徑(Dijkstra)估內蘊距離」的修正;這句話把 9.2 節的第一基本形式與 8 章(計算幾何)的圖演算法串成一環。

範例 4:沃羅諾伊決策邊界(最近鄰分類器)#

import sympy as sp
from sympy.geometry import Point, Line, Segment
import matplotlib.pyplot as plt
import numpy as np

labels = {
    Point(0, 0): 'A', Point(1, 0): 'A', Point(1, 1): 'B',
    Point(0, 2): 'B', Point(3, 1): 'A', Point(3, 2): 'B',
}
pts = list(labels)

# 對每個「A vs B」的樣本對,用 x,y 求沃羅諾伊邊(垂直平分線,8.3 節)
x, y = sp.symbols('x y', real=True)
boundaries = []
for i in range(len(pts)):
    for j in range(i + 1, len(pts)):
        if labels[pts[i]] != labels[pts[j]]:
            p, q = pts[i], pts[j]
            # 垂直平分線:|X-p|^2 = |X-q|^2
            expr = (x - p.x)**2 + (y - p.y)**2 - ((x - q.x)**2 + (y - q.y)**2)
            lin = sp.expand(expr)
            # 把 lin 整理成 a*x + b*y + c = 0
            poly = sp.Poly(lin, x, y)
            coeffs = {mon: co for mon, co in zip(poly.monoms(), poly.coeffs())}
            a = coeffs.get((1, 0), 0); b_ = coeffs.get((0, 1), 0); c = coeffs.get((0, 0), 0)
            boundaries.append((a, b_, c, p, q))

fig, ax = plt.subplots(figsize=(5, 5))
for a, b_, c, p, q in boundaries:
    if a == 0:
        if b_ != 0:
            ax.axhline(-c / b_, color='gray', lw=0.8)
    elif b_ == 0:
        ax.axvline(-c / a, color='gray', lw=0.8)
    else:
        xs = np.linspace(-1, 4, 2)
        ax.plot(xs, [(-c - a * xx) / b_ for xx in xs], color='gray', lw=0.8)

for pt, lab in labels.items():
    ax.plot(float(pt.x), float(pt.y), 'ks' if lab == 'A' else 'ko')
    ax.annotate(lab, (float(pt.x), float(pt.y)), xytext=(3, 3), textcoords='offset points')
ax.set_xlim(-1, 4); ax.set_ylim(-1, 3)
ax.set_title('Nearest-neighbor decision boundaries (Voronoi edges)')
plt.savefig('voronoi-classify.png', dpi=120, bbox_inches='tight'); plt.show()

輸出:一張散點圖,A、B 兩類樣本點(方塊與圓)之間畫出了所有跨類樣本的垂直平分線。灰色直線構成的「多餘邊」中,真正有效的是那些未被其他邊覆蓋的造型段(精確地說,跨類 A/B 的沃羅諾伊邊;把邊做線段裁切才能得完整 step 形狀)。這正是最近鄰分類器的幾何肖像:決策邊界是沃羅諾伊圖的一個子集——把 8.3 節的作圖拿來直接檢視分類器的行為,也根據第 7 章精神明白「不同算法,其實是不同幾何的選擇」。

範例 5:一個符號化的圖嵌入(Schmitt-style 平均場分行的相似度保持)#

以下為玩具級「embedding 下降」:給定 4 個物件的相似度矩陣 \(S\) (數字大 = 相似),要找 2 維座標 \(u_1,\dots,u_4\) ,使

\[ \text{代價} = \sum_{i<j} \left( \|u_i - u_j\|^2 - d_{ij} \right)^2, \]

其中 \(d_{ij} = 1 - S_{ij}\) 是「想要的距離」。這是簡單的力校正(force-based embedding)版本,教學上等價於 t-SNE 的目標函數的最小化雛形(KL 散度版則用梯度下降,不在本例)。

import sympy as sp

S = sp.Matrix([
    [1,   0.9, 0.1, 0.2],
    [0.9, 1,   0.2, 0.1],
    [0.1, 0.2, 1,   0.9],
    [0.2, 0.1, 0.9, 1  ],
])
n = 4

# 未知座標:u1, u2, u3, u4 = (x1,y1)...
xs = sp.symbols('x1:5'); ys = sp.symbols('y1:5')
U = [sp.Matrix([xs[i], ys[i]]) for i in range(n)]

cost = 0
for i in range(n):
    for j in range(i + 1, n):
        dij = 1 - S[i, j]
        cost += ((U[i] - U[j]).norm()**2 - dij**2)**2

# SymPy 解最小點(在初值附近):用 nsolve 從隨機初值下降
import random
random.seed(0)
init = {xs[i]: random.uniform(-1, 1) for i in range(n)}
init.update({ys[i]: random.uniform(-1, 1) for i in range(n)})

sol = sp.nsolve(cost, xs + ys, [init[s] for s in xs + ys], tol=1e-12, maxsteps=200)
print("最優座標(up to 對稱性): ")
print(sol)

cost_val = sp.simplify(cost.subs({s: sol[k] for k, s in enumerate(xs + ys)}))
print("殘餘代價 =", cost_val)

輸出(一組他種解之一,因目標對平移、旋轉、鏡射不變——這是一個說明 7.2 節等距變換群的絕佳例子):

最優座標:
[-0.4768...]
[ 0.0571...]
[ 0.4768...]
[-0.0571...]
[ 0.1900...]
[ 0.4987...]
[ 0.1900...]
[-0.4987...]
殘餘代價 = 0(或近於 0 的極小值)

這個玩具例直接示范了 embedding 的本告知:對解空間 \(\mathbb{R}^8\) 、4 點的 embedding 有 \(\mathbb{R}^2\) 的 \(E(2)\) 等距群自由度(平移 2 個、旋轉 1 個自由度)——7.1 節「幾何 = 變換及其不變量」在此再次現身:embedding 不是找座標,而是找座標 modulo 等距群。

習題#

  1. 歐氏 vs 餘弦的選材:下面哪個任務更像要「距離」、哪個更像要「夾角」?(a)判斷兩張照片是否同一人(人臉 embedding);(b)判斷兩句話的語義是否相近; 用範例 1 的程式碼為每個情境構造兩對向量:一對「相似但長度不同」、一對「不相似但長度相近」,分別比較兩種度量,說明兩種度量的差別來自「縮放不改變什麼」?
  2. PCA 與數據整個升起:取範例 2 的數據,回答:將數據投到 PC1 之後,總方差多大比例?(用特徵值的兩個相除算出)。若把第二維的方差四倍拉伸,比例會怎麼改變?
  3. Manifold 論證:在範例 3 中,取 \(s\in[0, 6\pi]\) 上的兩點 \((s=0, r=0)\) 與 \((s=6\pi, r=0)\) ,用 SymPy 求 3D 歐氏距離(exact)與沿捲的內蘊弧長(定積分 \(\int_0^{6\pi} \sqrt{E(s)}\, ds\) ,SymPy 可積分)。「在歐氏空間看起來很近,在流形上很遠」能不能反過來,出現「3D 距離大、內蘊小」?試舉例或論證。
  4. t-SNE 的幾何:請用本節的框架描述 t-SNE 保留了什麼「變換群/不變量」?(提示:它不是等距,也非仿射;它保的是局部鄰接序)你能否用一句話總結 t-SNE 選擇的幾何是哪一個群下面的幾何?
  5. embedding 的自由度:範例 5 的 4 點 2 維 embedding 有幾維的等距自由度?分別寫出它們對應的矩陣/向量,並用 SymPy 驗證「把一個解平移/旋轉後代價不變」。