負樣本的難題
上一篇我們建立了一個學習器:把同一張影像的兩個視圖拉近,把不同影像的視圖推遠。「拉近」的夥伴就是正負樣本對中的正樣本對,「推遠」的夥伴則是負樣本。整個對比學習背後藏著一個安靜卻頑強的事實:每張影像能比對的負樣本越多,學到的特徵往往越銳利、越有用。只有少數幾個負樣本時,一個嵌入只需要和少數鄰居不同就好;但有上千個負樣本時,它就必須在表示空間中刻出一個真正獨特的位置。
想看清負樣本到底住在哪裡,請回憶第二篇的 InfoNCE 損失。對一個查詢嵌入而言,它構成一個 softmax:分子是唯一正確(正樣本)配對的分數,分母則加總正樣本加上每一個負樣本的分數。示意地說,損失是 (正樣本分數) / (正樣本分數 + 所有負樣本分數之和) 的負對數。負樣本完全坐落在那個分母裡。多加一些負樣本,會讓分母變成一道更難、競爭更激烈的門檻,正樣本必須越過它——這正是為什麼更多負樣本能讓損失回傳給網路的訊號更銳利。
問題來了。在 SimCLR 中,任一張影像的負樣本,就只是同一個訓練批次裡的其他影像——沒有另外存放它們的地方。所以「更多負樣本」字面上就等於「更大的批次」。SimCLR 最好的成績來自 4096 張影像甚至更大的批次,每張影像產生兩個視圖,全部同時編碼並保存在記憶體中,好讓它們的梯度能一起計算。這需要一整批高記憶體的加速器(TPU 或頂級 GPU)才能塞下一個訓練步。對多數實驗室與個人而言,這筆硬體帳單就是他們撞上的那道牆。
把字典做成一條佇列
MoCo 的第一步是換個視角。別再想成「一批影像」,而把對比學習想成一次字典查詢。你有一個查詢(某張影像的一個視圖),想在一本裝滿鍵(key)的字典裡,找到它對應的那個鍵(同一張影像的另一個視圖)。每個鍵不過是某個編碼器產生的特徵向量。正樣本鍵是屬於該查詢的那一個;字典裡所有其他的鍵都是負樣本。學習,就是調整編碼器,讓每個查詢的向量落在離自己那個鍵最近、離其餘鍵最遠的地方。
關鍵洞見在這裡。如果字典是一個獨立的結構,而不是當前批次,那它的大小就不必等於批次大小了。動量對比(MoCo) 把字典實作成一條固定長度的佇列——一個滾動的緩衝區,存放最近編碼出來的鍵向量。每個訓練步,你編碼當前的小批次,把它的鍵推入(enqueue)佇列,並丟掉(dequeue)最舊的鍵以維持長度不變。經過許多步之後,佇列裡就累積了上千個來自近期的鍵,儘管每一個單獨的步驟其實只編碼了一個小批次。這些佇列中的鍵,就充當當前查詢的負樣本。
用一個比喻就能體會這個滾動緩衝區。想像一位警衛,一次只能瞄一眼幾張臉,但他心裡保有一份「最近幾分鐘看過的臉」的滾動相簿。要判斷一張新面孔是否陌生,他就拿它去比對整份近期相簿——而不是一次比對整個人群,那是誰也記不住的。這份相簿就是佇列:一段可管理的工作記憶,存著「最近見過的其他人」,讓每張新臉都有大量對象可供對比,卻完全不需要同時看見所有人。
對比學習示意圖:一個查詢嵌入被拉向一個正樣本,並被推離許多負樣本。
一致性難題與動量編碼器
佇列這個點子藏著一個瑕疵,而找出它正是 MoCo 高明之處。佇列裡的鍵並非同時編碼出來的——最舊的可能是幾千步前產生的,最新的才剛剛產生。但編碼器正在被訓練,它的權重每一步都在變。如果編碼器變得太快,那麼 1000 步前的鍵,就是由一個和此刻明顯不同的網路所造的。於是查詢是在和一群說著略微不同「語言」的鍵相比。這種不一致讓比對變得嘈雜,梯度變得不可靠。
這不只是個小麻煩——它是一種穩定性風險,和糾纏自監督學習的崩塌憂慮屬於同一家族。如果鍵彼此不一致、抖個不停,損失就可能去追一個移動的標靶,特徵不但不進步反而退化,滑向 表示崩塌 所描述的那種平庸解。一本好字典不只要大,還要內部一致:它的鍵應該彼此可比,彷彿都出自同一個穩定的參照。
MoCo 的解法是整個方法的核心:用一個「獨立」的編碼器——動量編碼器——來編碼鍵,而它的權重只非常緩慢地移動。查詢編碼器透過普通的反向傳播快速學習;鍵編碼器則是它的一個緩慢、被平滑過的回聲。因為它每一步幾乎不變,它放進佇列的每一個鍵,都是由一個幾乎相同的網路所造,所以它們彼此始終可比。緩慢,即是一致。讓鍵編碼器變慢的精確規則,就是一條更新方程式。
動量更新:鍵編碼器的權重是查詢編碼器權重的指數移動平均。
逐一讀懂每個符號。θ_q(theta-q)是「查詢」編碼器的參數——把查詢視圖轉成向量的那個網路;這些參數照常更新,靠對損失做梯度下降。θ_k(theta-k)是「鍵」(動量)編碼器的參數;關鍵在於,梯度「絕不」直接訓練它們——它們只由這一行設定。箭頭代表「每一步用右邊覆寫左邊」。m(動量係數)是一個落在 [0,1) 的數字,典型值 0.999。這個式子是一個指數移動平均:新的 θ_k 大部分是它的舊自己(權重 m),再加上朝當前 θ_q 輕輕一推(權重 1−m)。
用兩個極端建立直覺。若 m = 0,這行變成 θ_k ← θ_q:鍵編碼器每一步都被覆寫成和查詢編碼器一模一樣。這很快——卻把不一致問題重新製造出來,因為鍵編碼器現在跟查詢編碼器一樣劇烈搖晃。反之若 m = 0.999,每一步只把 θ_k 朝 θ_q 移動千分之一,所以鍵編碼器漂移得極慢。幾百步前存下的鍵,是由一個和今天只差零點幾個百分點的網路所造——仍然彼此可比。想像一位深思的老師,逐步而非一夜之間修正自己的觀點,於是學生上個月做的筆記,今天聽老師講仍然一致。正是這種溫和、被平滑過的漂移,讓一條長佇列始終值得信賴。
把 MoCo 組起來
現在所有零件都齊了;讓我們從頭到尾走一遍一個訓練步。取一張影像,做出兩個增強視圖,就像 SimCLR 一樣。一個視圖成為查詢,通過可訓練的查詢編碼器,得到嵌入 q。另一個視圖是正樣本鍵,而一小批近期的鍵加上整條佇列就是負樣本——所有的鍵都由緩慢的動量編碼器編碼。接著我們套用一個InfoNCE 損失,含一個正樣本與許多佇列中的負樣本。梯度只回流進查詢編碼器;動量編碼器只由第三節的 EMA 規則更新,絕不由梯度更新。
一條 CNN 編碼管線,透過堆疊的卷積階段把影像視圖轉成特徵向量。
MoCo 對單一查詢的 InfoNCE 損失:形式與 SimCLR 完全相同,但負樣本來自佇列。
逐一拆解符號。q 是查詢編碼器給出的查詢嵌入。k₊(k-plus)是它唯一的正樣本鍵——同一張影像的另一個視圖,由動量編碼器產生。k₋(k-minus)是從佇列拉出來的許多負樣本鍵,同樣全部來自動量編碼器。點積 q·k 衡量相似度(向量正規化後,這就是第二篇講的餘弦相似度)。τ(tau)就是第二篇的那個溫度:一個小的正數,在 softmax 前縮放分數,控制損失把正樣本與負樣本分得多銳利。整個式子是 (正樣本指數化分數) 除以 (正樣本加上所有負樣本) 的負對數。把這個損失壓低,就意味著讓 q·k₊ 變大、讓每個 q·k₋ 變小。
這是你該牢牢記住的重點:這和 SimCLR 是「同一個」目標函數。把它和第二篇對照,唯一的結構性改變就是負樣本的來源——它們現在來自佇列,而非當前批次。這一個替換就是 MoCo 的全部要旨:它把負樣本的數量(佇列長度)和批次大小脫鉤,直接回答了第一節的問題。
# f_q: query encoder (trainable) f_k: momentum / key encoder
# queue: a buffer of K negative keys (shape C x K)
# m: momentum coefficient (e.g. 0.999) t: temperature
f_k.params = f_q.params # start the key encoder as a copy
for x in loader: # x is a small minibatch of N images
x_q = aug(x) # one random view -> query
x_k = aug(x) # another random view -> key
q = f_q.forward(x_q) # queries N x C (carries gradients)
k = f_k.forward(x_k) # keys N x C
k = k.detach() # stop gradient: f_k is NOT trained by SGD
# positive logits N x 1 : each query with its own key
l_pos = bmm(q.view(N, 1, C), k.view(N, C, 1))
# negative logits N x K : each query against every queued key
l_neg = mm(q.view(N, C), queue.view(C, K))
logits = cat([l_pos, l_neg], dim=1) # N x (1 + K)
labels = zeros(N) # the positive is always index 0
loss = CrossEntropy(logits / t, labels)
loss.backward()
sgd_update(f_q.params) # gradients update ONLY the query encoder
# slow, smoothed update of the key encoder (the momentum rule)
f_k.params = m * f_k.params + (1 - m) * f_q.params
enqueue(queue, k) # add the newest keys
dequeue(queue) # drop the oldest keys (keep length K)還有一段實務插曲:MoCo v2。在 SimCLR 出現後,它的作者證明 SimCLR 的兩項配方能乾淨地移植進 MoCo 框架——在編碼器上加一個小型 MLP 投影頭(取代單一線性層),以及使用更強的資料增強。把這些併入 MoCo(這次升級就是「v2」)幾乎補平了準確率差距,同時仍保有 MoCo 小批次、基於佇列的預算。教訓是:佇列/動量這套機制,和 SimCLR 的增強/投影技巧,是可以彼此疊加的正交改進。
SimCLR vs MoCo:取捨之道
兩種方法都攤在桌上後,你現在可以把它們當成設計選擇來推理,而不是死背。它們是同一個問題——負樣本從哪來?——的兩個答案,而每個答案各有帳單。SimCLR 說:負樣本「就是」當前批次。這在概念上很乾淨(沒有額外編碼器、沒有額外緩衝區、每個元件都由同一個梯度訓練),但它逼出巨大的批次,以及隨之而來的高記憶體。MoCo 說:負樣本住在佇列裡,由一個緩慢的動量編碼器編碼。這讓很小的批次就夠用,但代價是多出第二個編碼器這套機制,以及它必須維持的一致性。
把比較攤在明確的軸上會有幫助。記憶體:SimCLR 很餓(批次必須一次容納所有負樣本);MoCo 很省(佇列存的是便宜的特徵向量,不是需要梯度的影像或激活值)。負樣本數量:SimCLR 大約被批次大小所限;MoCo 被佇列長度所限,而後者可以大得多。每步運算量:編碼器成本相當,但 SimCLR 的巨大批次需要許多加速器並行。實作複雜度:SimCLR 較簡單;MoCo 多了動量編碼器、detach,以及入列/出列的記帳。
- 記憶體預算:加速器稀缺時選 MoCo——單張 GPU 就能訓練它;只有當你已掌握大型並行運算叢集時才選 SimCLR。
- 想要的負樣本數:若你需要數萬個負樣本,佇列能便宜地提供;SimCLR 要追平就得用大到不切實際的批次。
- 簡潔 vs 彈性:想要最精簡、好除錯的管線就選 SimCLR;當預算限制壓過多出來的零件時就選 MoCo。
- 底線:兩者共享 v2 技巧後,準確率大致相當——選擇是由硬體決定,而非由品質上限決定。
對比方法默默依賴的前提
退一步問:到底是什麼在撐住這些系統?SimCLR 和 MoCo 都依賴負樣本來提供一股排斥力。光靠正樣本項——「把一張影像的兩個視圖拉近」——有一個平庸卻完美的解:把每張影像都映射到同一個點。這樣每個正樣本對都一模一樣,拉近損失就是零。唯一阻止這場災難的,就是針對負樣本的推遠,它禁止所有嵌入擠在同一個地方。負樣本,就是 表示崩塌 的明白解藥。
把話講白,因為它鋪設了接下來的一切:在對比學習裡,負樣本不是一個性能上的微調——它們是表示之所以不崩塌的「結構性原因」。把它們拿掉,照原樣寫的損失就會愉快地崩塌。這是兩個旗艦方法底下那根承重的假設。
但負樣本也很彆扭,而這份彆扭正是下一篇要攻擊的對象。它們昂貴:你得要嘛用 SimCLR 的巨大批次,要嘛用 MoCo 的佇列加動量編碼器,才湊得到足夠的數量。它們在概念上也可疑:正負樣本對是按影像身分指派的,所以兩張不同的照片——比如兩隻不同的狗——會被標成負樣本而被推遠,儘管牠們同屬一類、照理該靠近。我們在排斥真正相似的東西,只因為我們沒有標籤可以分辨得更好。