JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

對比學習:拉近彼此、推開他人

認識對比學習:教網路把同一張照片的兩個裁切視為一夥、其餘皆為冒牌貨,優質特徵便自然浮現。

核心直覺:同一事物的不同視角

在第 1 篇我們談定了夢想:不靠人工標註,直接從原始影像中學到好的特徵。這一篇要交出第一個真正有效的方法。先拿一張照片——比方說一張柯基犬的圖。接著對它做兩種隨機編輯:裁下一塊放大、把顏色稍微挪一挪、也許再左右翻轉。我們把每一份編輯後的副本叫作一個視角(view)。這兩個視角在像素層面看起來不一樣,但它們呈現的是同一隻狗。整個構想就是:好的網路應該把這兩個視角對應到特徵空間裡彼此靠近的點,因為它們共享內容——而一張完全不同影像(貓、汽車、樹)的視角,則應該落在很遠的地方。

同一張影像的兩個增強視角在特徵空間中被拉近;其他影像的視角則被推開。

一張示意圖:一張來源影像被切成兩個增強視角,對應到兩個相鄰向量;另一張不同影像對應到一個遙遠的向量;箭頭把相符的一對拉近、把其他的推開。

這裡有個你的大腦早就在跑的日常類比。不論你正面看到朋友、從側面看到、半張臉藏在陰影裡、隔著吵雜的房間看到,或是他戴了頂新帽子,你都認得出來。每一次落在視網膜上的影像都天差地遠,但你把它們全部收攏成一個穩定的概念:那個人。同時你也把他和陌生人牢牢分開。自監督視覺要的正是這種反射——一種能在表面變化中說「同一個身分」、在真正不同的事物間說「不同身分」的表示。

對比學習就是把這個反射轉成可訓練目標的技術。它的原則只有兩個短動詞:跨視角要一致、跨實例要相異。我們訓練網路,使同一張影像的兩個視角一致(它們的特徵向量靠近),不同影像的視角相異(它們的向量遠離)。關鍵在於:網路必須尊重的這份「同一性」,完全由我們允許哪些編輯來定義——這個性質叫作資料增強不變性,下一節會說明它正是整套方法背後的祕密引擎。

資料增強打造不變性

資料增強(augmentation)就是對影像施加的一種隨機、保留內容的編輯。標準的對比學習工具箱很小,值得背下來:隨機裁切縮放(random resized crop,剪出一塊隨機區域再縮放)色彩抖動(color jitter,隨機微調亮度、對比、飽和度、色相)高斯模糊(Gaussian blur,柔化細節)隨機灰階(random grayscale,以某機率把顏色整個丟掉),以及水平翻轉(horizontal flip,左右鏡像)。要做出一個正樣本對,我們從這組工具中抽出兩條獨立的隨機流程,把同一張來源影像各跑一次。這兩個視角在裁切、顏色、銳利度上不同——但「這張照片拍的是什麼」永遠不變。

一張來源影像散開成數種增強——裁切、色彩抖動、模糊、灰階、翻轉。每一種都剝掉一個模型必須學會忽略的干擾因素。

一個網格:同一張照片經由隨機裁切、色彩抖動、高斯模糊、灰階與水平翻轉,變成數個外觀不同但內容相同的版本。

現在來看那個承重的洞見,幾乎每個人第一次都會驚訝:這組增強本身就是監督訊號。 想想你實際上在要求網路做什麼。你要求兩個視角對應到同一個向量。而兩個視角的差異,就只在於增強改變了的那些東西。於是為了滿足你,網路必須產出在那些東西改變時不會改變的特徵——它必須對它們變得不變(invariant)。你選擇抖動顏色,就等於下令「確切的顏色不該重要」。你選擇裁切,就等於下令「絕對位置與尺度不該重要」。因此資料增強不變性不是副作用;它正是對比學習裡「相同內容」的精準定義。你增強了什麼,就是模型學會去忽略什麼。

一個具體的演練會讓這點難以忘記。假設你偷懶,只用裁切。現在網路有一條廉價捷徑可鑽:同一張照片的兩個裁切,往往有相近的平均顏色(海灘照整體偏藍、森林偏綠)。網路只要算出每個裁切的平均 RGB 大概就能滿足「讓兩個視角一致」——它從不需要理解形狀、紋理或物體。它學到的特徵幾乎沒用。現在加上色彩抖動:海灘的兩個視角顏色被故意調得不一致,於是平均顏色不再是「同一張影像」的可靠線索。捷徑被毀掉了。為了仍能配對成功,網路被迫轉向某種與顏色無關、且有意義得多的東西——形狀、佈局、真正的內容。增強不只是增加了多樣性;它封死了一條逃生通道。

正樣本與負樣本

該把詞彙釘死了,因為這個領域接下來會不斷用到它。一個正樣本對(positive pair)同一張來源影像的兩個增強視角;它們應該相吸——在特徵空間裡被拉近。一個負樣本對(negative pair)是來自不同來源影像的兩個視角;它們應該相斥——被推開。這兩種關係,也就是正樣本與負樣本,就是學習訊號的全部輸入。請注意這裡完全不需要類別標註:「正樣本」只代表「同一張來源照片」,而這個事實是我們在選定要把哪些裁切配成一對的那一刻,自己生成的。

正樣本(同一影像)相吸;負樣本(不同影像)相斥。在數百萬張影像上跑這件事,嵌入空間就會自我組織成一群群相關內容的聚落。

一張特徵空間草圖:一個錨點、用相吸彈簧連到它的正樣本夥伴,以及數個用相斥箭頭向外推開的負樣本點。

一個物理比喻能讓這套動力學變得鮮活。把每個視角想成一顆漂浮在特徵空間裡的小珠子。在每個正樣本對之間,我們掛一根把它們拉攏的彈簧;在錨點與每個負樣本之間,我們放一塊相斥的磁鐵。一開始珠子隨機散落。隨著訓練反覆施加這些力,珠子滑來滑去,直到彈簧與磁鐵達成休戰:同一影像的視角擠成緊密的結,不同影像則落定在各自分開的區域。沒有人告訴網路柯基犬是什麼——但「柯基味」的視角會自然飄到一起,純粹因為它們一再互為彼此的正樣本,於是空間自我組織出有意義的鄰里。

為什麼要費事弄相斥的負樣本——直接把正樣本拉近、收工不就好了?因為光有拉力會有一個災難性地簡單的作弊法。如果唯一的規則是「讓相符的視角靠近」,網路可以瞬間取勝——把每一張影像都對應到完全相同的那一個向量。如此一來所有正樣本距離都是零——滿分——但特徵裡完全不帶任何資訊。這種失敗叫作塌縮(collapse),而負樣本正是解藥:相斥力堅持不同影像必須分開,因而禁止了「全部擠到一點」的解,空間就無法洩了氣。塌縮是整條學習路徑的頭號反派;對比學習用負樣本擊敗它,而第 4 篇會展示那些完全不用任何負樣本卻也能存活的巧妙方法。

有一個讓記帳變輕鬆的實務驚喜:實際上我們根本不用到處去找負樣本。我們一次處理一大批(minibatch)影像,而對任何一個給定的錨點視角而言,同一批裡其他所有影像就直接被當成它的負樣本。它們來自不同照片,按定義就是合格的負樣本——不花成本、不必額外載入資料。這也正是為什麼批次大小(batch size)在後面會這麼重要:批次越大,每一步給你的負樣本越多,而(下一節的損失會講清楚)負樣本越多,訊號通常越強、越有鑑別力。

逐項拆解 InfoNCE 損失

我們有了直覺(彈簧與磁鐵),但網路只能從損失中學習——一個要把它往下壓的單一數字。所以我們得把「拉近正樣本、推開負樣本」化成一條可微分的公式。最乾淨的做法是把整件事重新框成一個分類問題。站在某一個視角上,也就是錨點(anchor)。你面前有一排隊伍:它唯一真正的正樣本夥伴,加上一群負樣本。任務是:指出那個正樣本。如果網路能可靠地從隊伍中挑出正確的夥伴,它就必然學到了一種特徵,使同內容的視角比其他一切都更近。這正是我們要的行為,而「從眾多之中挑出正確的那一個」是我們早就知道怎麼評分的問題——softmax 分類。

首先我們需要一種衡量兩個特徵向量「有多近」的方式。我們用餘弦相似度(cosine similarity):把兩個向量各自縮放成單位長度後的內積。它讀的是兩者之間的夾角——不管向量有多長,只在乎方向。

\mathrm{sim}(u,v)=\frac{u^{\top}v}{\lVert u\rVert\,\lVert v\rVert}

餘弦相似度:1 代表方向相同,0 代表垂直,−1 代表方向相反。

拆開來看:uv 是兩個特徵向量;u^{\top}v 是它們的內積(把對應分量相乘再加總);\lVert u\rVertu 的長度(歐氏範數),同除以兩者的長度就把大小消掉,只剩夾角。輸出落在 -11 之間:值為 1 代表兩向量指向同一方向(最相似),0 代表互相垂直(無關),-1 代表方向相反。小例子:若 u=(1,0)v=(1,1),則 u^{\top}v=1\lVert u\rVert=1\lVert v\rVert=\sqrt2\approx1.41,所以 \mathrm{sim}=1/1.41\approx0.71——夾角 45°,相當相似。在 SimCLR 中嵌入在進損失前已經做了 L2 正規化,所以「相似度」就字面上是這個餘弦值。

現在來到本篇的核心——InfoNCE 損失(在 SimCLR 中也叫 NT-Xent 損失,意為「正規化、溫度縮放的交叉熵」)。對一組索引為 (i,j) 的正樣本視角對,錨點 i 的損失為:

\mathcal{L}_i=-\log\frac{\exp\!\big(\mathrm{sim}(z_i,z_j)/\tau\big)}{\displaystyle\sum_{k=1}^{2N}\mathbb{1}_{[k\neq i]}\,\exp\!\big(\mathrm{sim}(z_i,z_k)/\tau\big)}

InfoNCE/NT-Xent:一個 (2N−1) 類的 softmax,其唯一正確類別是正樣本視角 j。

讓我們解剖每一個符號。N 是這一批裡來源影像的數量;因為每張做兩個視角,所以總共有 2N 個視角、2N 個嵌入。z_i 是視角 i 的(L2 正規化後)嵌入——也就是我們的錨點——而 z_jj 的嵌入,它的正樣本夥伴,亦即同一來源影像的另一個視角。\mathrm{sim}(z_i,z_k) 是我們剛定義的餘弦相似度。\tau(tau)是溫度,一個小的正數(常落在 0.10.5 附近),我們稍後會仔細研究;在取指數前把每個相似度都除以它,會讓最終分布變得更銳利或更柔和。指示函數 \mathbb{1}_{[k\neq i]} 是一個開關:對除了 k=i 以外的每個 k 都等於 1,在 k=i 時為 0——它就只是把錨點與自己的比較從總和中拿掉。所以分母跑遍除了 i 自己以外的全部 2N 個視角:那就是唯一的正樣本 z_j 加上全部 2N-2 個負樣本,也就是合計 2N-1 項。

把這個分式讀成一個 softmax 機率。分子是正確答案的「分數」(錨點配它的正樣本);分母是整排隊伍的分數總和(正樣本加上每個負樣本)。因此這個比值就是模型估計自己從 2N-1 個候選中挑中正樣本的機率。對這個機率取 -\log,正好就是交叉熵:當模型有信心且答對(機率接近 1)時它接近 0,而當模型把機率質量壓在某個負樣本上時它會暴增。所以把 \mathcal{L}_i 最小化,會同時且不可分割地做兩件事——把分子推高(把正樣本拉近)、把分母裡的負樣本項壓低(把負樣本推開)。彈簧與磁鐵,如今寫成了一個梯度能咀嚼的數字。

# Tiny worked example: N = 2 images -> 2N = 4 views
# Image A gives views z1, z2 (a POSITIVE pair).
# Image B gives views z3, z4 (another positive pair).
# Take anchor i = 1. Its positive is j = 2; negatives are 3 and 4.
#
# Suppose the cosine similarities of the anchor to the others are:
#   sim(z1, z2) = 0.90   <- positive (same image A)
#   sim(z1, z3) = 0.20   <- negative (image B)
#   sim(z1, z4) = 0.10   <- negative (image B)
# (sim(z1, z1) is EXCLUDED by the indicator 1_{k != i}.)

import math
tau = 0.1                       # temperature

sims = {"pos": 0.90, "neg1": 0.20, "neg2": 0.10}
exp  = {k: math.exp(v / tau) for k, v in sims.items()}
# exp(9.0)=8103.08 ,  exp(2.0)=7.389 ,  exp(1.0)=2.718

denominator = sum(exp.values())             # 8103.08 + 7.389 + 2.718 = 8113.19
prob_positive = exp["pos"] / denominator    # 8103.08 / 8113.19 = 0.99875
loss_i = -math.log(prob_positive)           # 0.00125  -> tiny: anchor already close

print(round(prob_positive, 5), round(loss_i, 5))   # 0.99875 0.00125
分母裡裝著一個正樣本項與其餘負樣本項;損失是正樣本 softmax 占比的負對數。這裡錨點已經對得很好,所以損失接近零。

走一遍數字。我們把每個相似度除以 \tau=0.1(於是 0.99.0,以此類推),取指數,再加總:分母 8113.19 被正樣本的 8103.08 主宰,因為它得分最高。正樣本的占比是 0.99875,而 -\log(0.99875)\approx0.00125——一個接近零的損失,告訴我們這個錨點已經做好它的工作。現在把 \tau0.1 改成 1 再跑一次,感受溫度的效果:指數變成 0.9,0.2,0.1\exp 值為 2.46, 1.22, 1.11,分母 4.79,正樣本占比只剩 0.514,損失約 0.665。同樣的嵌入,損失天差地遠。\tau 會把相似度之間的差距放得極大,於是 softmax 變得尖銳,損失執著於最難的負樣本——那少數幾個近到幾乎和正樣本一樣近的——並狠狠懲罰它們。\tau 則把一切壓平,對所有負樣本溫和且大致一視同仁。溫度就是決定模型對它最強勁的對手要盯得多兇的那個旋鈕。

兩個收尾的備註,讓損失對稱且完整。第一,\mathcal{L}_i 是從錨點 i 的觀點寫的,但夥伴 j 同樣有資格擁有自己的項 \mathcal{L}_j(錨點 j、正樣本 i);整批的損失是對全部 2N 個視角取 \mathcal{L} 的平均,所以每個視角都輪流當一次錨點。第二,請記得這一切——InfoNCE 損失、它唯一的正樣本與一群負樣本、整個正樣本對負樣本的結構——正是讓對比學習得以訓練的具體機械裝置。彈簧與磁鐵的畫面,和這條公式,是同一個東西從兩個角度看到的樣子。

SimCLR:把所有零件組起來

所有零件現在都到齊了,那就把那套經典的對比系統卡起來:SimCLR(視覺表示對比學習的簡單框架)。它只有四個會動的部件。(1) 一個編碼器(encoder) f——一個標準骨幹網路,例如 ResNet——把影像轉成一個表示向量 h;這正是我們真正想學到的東西。(2) 一個小小的投影頭(projection head) g——一個迷你 MLP(一層帶非線性的隱藏層)——把 h 映射到一個較低維的向量 z,而InfoNCE 損失就是在 z 上計算的。(3) 第 2 節的增強流程,每張影像跑兩次。(4) 一個大批次,免費供應負樣本。整套機器就這些。

每張影像的流向:增強兩次 → 編碼器 f → 表示 h → 投影頭 g → 嵌入 z,InfoNCE 在這裡把它與批次中其他每個 z 比較。

一張流水線示意圖:一張輸入影像被增強成兩個視角,各自通過共享的 CNN 編碼器得到表示,再經投影頭得到供對比損失使用的嵌入。

  1. 抽取一批 N 張影像。
  2. 用兩條獨立的隨機流程把每張影像各增強一次,產生 2N 個視角(來自同一張影像的每一對視角即為一個正樣本對)。
  3. 把全部 2N 個視角都送進同一個編碼器 f 得到表示 h(所有視角共享同一組權重)。
  4. 把每個 h 送進同一個投影頭 g 得到嵌入 z,再把它們 L2 正規化成單位長度。
  5. 對每個視角(作為錨點),針對它唯一的正樣本與批次內的 2N−2 個負樣本計算 InfoNCE 損失;對全部 2N 個錨點取平均。
  6. 反向傳播並同時更新 f 與 g。重複許多個訓練週期(epoch)。
# SimCLR training step (PyTorch-style pseudocode)
for images in loader:                 # images: a batch of N pictures
    v1 = augment(images)              # N views, random pipeline #1
    v2 = augment(images)              # N views, random pipeline #2
    views = concat(v1, v2)            # 2N views; view k and view k+N are positives

    h = f(views)                      # encoder -> representations,  shape (2N, d_h)
    z = g(h)                          # projection head -> embeddings, shape (2N, d_z)
    z = normalize(z, dim=1)           # L2-normalize so dot product == cosine sim

    sim = (z @ z.T) / tau             # (2N x 2N) all-pairs cosine sims, temperature-scaled
    mask_self = eye(2N)               # marks the k == i diagonal
    sim.masked_fill_(mask_self.bool(), -inf)   # remove self-comparison (the indicator)

    targets = positive_index(2N)      # for row i, the column of its positive partner
    loss = cross_entropy(sim, targets)# (2N-1)-way softmax classification; correct class = positive

    loss.backward(); optimizer.step(); optimizer.zero_grad()

# After training: THROW AWAY g, keep the encoder f for downstream tasks.
整個訓練迴圈就是一個 softmax 分類,正確答案是每個視角的正樣本夥伴;這裡的交叉熵就是 InfoNCE 損失。

SimCLR 留下的恆久教訓不是某個新架構,而是一份精準的經驗配方——一些小選擇疊在一起,終於讓對比學習得以與監督式預訓練分庭抗禮。四種成分撐起了大部分功勞:(1) 一個強力的增強組合——關鍵是色彩抖動加上隨機裁切一起用,因為如我們所見,任一個單獨用都會留下可鑽的捷徑;(2) 那個非線性投影頭 g,它讓特徵明顯優於把損失直接套在 h 上(第 6 節會解釋那個出人意料的原因);(3) 大批次(數千張影像),因為批次內的負樣本是唯一的負樣本,批次越大代表隊伍越豐富、越難;以及 (4) 長時間訓練(許多個 epoch),因為每個視角需要隨時間遇到許多不同的負樣本,才能在空間中刻出一塊乾淨的位置。

訓練結束時,這裡有個把本篇接回第 1 篇的動作:我們把投影頭 g 整個丟掉,只留下編碼器 f。向量 h=f(\text{影像}) 就是我們一開始想學到的通用表示。要衡量它有多好,我們用第 1 篇的線性評估協定——凍結 f,在 h 上用我們手邊有的任何標註訓練一個單層線性分類器,再讀出準確率。高的線性探針分數代表 f 學到的特徵乾淨到連單層分類器都能分開各類別——正是我們想要的回報,而且整個預訓練過程沒有用到任何一個標註。

溫度與投影頭為何關鍵

SimCLR 中有兩個設計選擇總是絆倒新手,因為它們的效果比看起來更大、更奇怪。第一個是溫度 \tau。我們已經從機制上看到小的 \tau 會讓 softmax 變銳利;其後果是梯度幾乎全部集中到難負樣本(hard negatives)身上——就是那少數幾張、嵌入近到幾乎和真正的正樣本一樣靠近錨點的影像。這種聚焦很強大:難負樣本正是教出最細微區別的那些(「這隻哈士奇不是那隻阿拉斯加雪橇犬」)。但若把 \tau 壓得太小,損失會執迷於少數幾個近乎重複的樣本,梯度變得尖銳不穩,有用的宏觀結構反被忽略。若把 \tau 調得太大,每個負樣本都被一視同仁,訊號變糊,特徵也糊在一起。沒有放諸四海皆準的最佳值;\tau 是個確實敏感的超參數(常在 0.050.5 之間調),而且它實質改變了InfoNCE 損失究竟在獎勵什麼。

第二個選擇是整篇論文裡最反直覺的結果:在評估時,你應該*丟掉投影頭 g,使用它之前*的那個表示 h*,而不是損失實際計算所在的嵌入 z。這感覺整個反過來——損失優化的那一層,難道不該是最好的嗎?答案很微妙,值得內化。損失要求不變性:它逼 z 忘掉兩個視角之間所有不同的東西——顏色、方向、確切的裁切。這對解對比任務*而言是對的,但被丟掉的那些資訊(顏色、姿態、位置)有不少對下游任務其實很有用。投影頭 g 扮演一個犧牲性的緩衝:它吸收了損失堅持的那些嚴苛不變性,好讓投影頭之前的表示 h 能保持更豐富、更通用。一句話:損失所在的空間,和廣泛有用的特徵所在的空間,並不是同一個地方——所以我們在一個地方優化,卻從另一個地方收割。

最後,一份簡短的實務陷阱清單,每一條都是我們所搭建內容的直接推論。批次太小:因為負樣本只來自批次內部,小批次給的是稀薄、容易的隊伍與微弱的訊號——這正是第 3 篇(MoCo)會用一個負樣本記憶佇列來解決的頭痛問題。增強太弱:把色彩抖動拿掉,網路就會重新發現第 2 節的平均顏色捷徑;弱的增強代表弱的不變性,代表懶惰的特徵。未正規化的嵌入:若你忘了在進損失前把 z 做 L2 正規化,「相似度」就不再是乾淨的餘弦,溫度也不再是你以為的意思,訓練常常變得不穩。把這三件事做對,一個基本的對比設定在線性評估協定下,就已經能學到驚人地逼近監督式預訓練的特徵。