対照学習(Contrastive Learning)の理論と実装

はじめに — なぜ対照学習が重要なのか

深層学習の成功は、大量のラベル付きデータに支えられてきました。しかし、ラベル付きデータの収集には膨大なコストがかかります。医療画像の診断ラベル、衛星画像のセグメンテーションマスク、自然言語のセンチメントラベルなど、専門家による注釈が必要な分野では特にこの問題が深刻です。

一方、ラベルなしのデータ は大量に存在します。インターネット上の画像、テキスト、音声データは日々爆発的に増加しています。この膨大なラベルなしデータから 有用な表現 を学習できれば、少量のラベル付きデータで高い性能を達成できるはずです。

この発想を実現するアプローチのひとつが 対照学習(Contrastive Learning) です。

対照学習の基本的なアイデアは非常にシンプルです。「似ているもの同士を近づけ、異なるもの同士を遠ざける」——これだけです。たとえば、同じ画像から異なるデータ拡張(回転、色変換、切り抜きなど)を施した2つのビューは「似ている」と判断し、異なる画像から生成されたビューは「異なる」と判断します。この単純な原理に基づいて、ラベルなしでデータの意味的な構造を捉える表現を学習できるのです。

対照学習の概念:似たものを近づけ異なるものを遠ざける

イメージは上の図の通りです。学習前(左)は表現空間でデータが混ざっていますが、学習後(右)は同じ意味のもの同士(正例)が引き寄せられ、異なるもの(負例)が押し離されて、クラスごとのまとまりが生まれます。ラベルを与えていないのに意味的な構造が立ち上がる、これが対照学習の狙いです。

対照学習は2020年前後にコンピュータビジョンの分野で大きなブレイクスルーを起こしました。SimCLR(Chen et al., 2020)、MoCo(He et al., 2020)、BYOL(Grill et al., 2020)といった手法が次々と提案され、教師あり学習に匹敵する性能を達成しました。

本記事では、以下の内容を扱います。

  1. 対照学習の基本原理と問題設定
  2. InfoNCE損失の数学的な意味——なぜこの損失関数が有効なのか
  3. SimCLRのアーキテクチャとPyTorch実装
  4. データ拡張戦略の影響
  5. 温度パラメータの役割

表現学習や自己教師あり学習に関心のある方はもちろん、転移学習や半教師あり学習の基盤技術としても重要なトピックです。

画像なし
転移学習の理論と実践
事前学習モデルの活用戦略。対照学習は事前学習の強力な手法です。

前提知識

この記事を読むにあたって、以下の知識があるとスムーズです。

  • ニューラルネットワークの基礎: MLP、CNN、損失関数
  • 情報理論の基礎: エントロピー、相互情報量(概念レベルでOK)
  • PyTorchの基礎: nn.ModuleDataLoader、学習ループ
画像なし
交差エントロピーを完全理解
情報理論から損失関数の導出まで。
画像なし
CNNの基礎
畳み込み・プーリングの数理。

対照学習の問題設定

自己教師あり学習とは

まず、対照学習が属する 自己教師あり学習(Self-Supervised Learning) の枠組みを整理しましょう。

教師あり学習では、入力 $\bm{x}$ とラベル $y$ のペア $(\bm{x}, y)$ からモデルを学習します。自己教師あり学習では、ラベル $y$ を使わず、データそのものから教師信号を生成 します。

自己教師あり学習は大きく2つのアプローチに分けられます。

生成的アプローチ: データの一部を隠して復元する。例: マスク言語モデル(BERT)、オートエンコーダ

対照的アプローチ: データ間の類似度を学習する。例: SimCLR、MoCo

対照学習は後者のアプローチに属します。データのピクセル単位の復元ではなく、「何が似ていて何が異なるか」という 関係性 を学習するのが特徴です。

正例ペアと負例ペア

対照学習では、以下のようにデータを正例と負例に分けます。

正例ペア(Positive Pair): 意味的に「同じ」とみなすべきデータの組。典型的には、同じ画像に異なるデータ拡張を施した2つのビュー。

負例ペア(Negative Pair): 意味的に「異なる」とみなすべきデータの組。典型的には、異なる画像から生成されたビューの組み合わせ。

数式で表現しましょう。画像 $\bm{x}$ に対して、2つのデータ拡張 $t_1, t_2 \sim \mathcal{T}$ をランダムに適用して、2つのビュー $\tilde{\bm{x}}_1 = t_1(\bm{x})$ と $\tilde{\bm{x}}_2 = t_2(\bm{x})$ を生成します。$(\tilde{\bm{x}}_1, \tilde{\bm{x}}_2)$ が正例ペアです。

バッチ内の他の画像から生成されたビュー $\tilde{\bm{x}}_j$ ($j$ は異なる画像のインデックス)との組み合わせが負例ペアとなります。

正例ペアと負例ペアの作り方

図のように、1枚の画像 $x$ に2種類の拡張 $t_1,t_2$ をかけた2つのビューが正例ペア(近づける対象)です。一方、バッチ内の別画像から作られたビューはすべて負例(遠ざける対象)として扱います。正例は「同じ画像の別の見え方」、負例は「他人の画像」と考えると分かりやすいでしょう。

表現空間での学習

対照学習の目標は、エンコーダ $f(\cdot)$ を学習して、以下の性質を持つ表現空間を構築することです。

$$ \text{sim}(f(\tilde{\bm{x}}_1), f(\tilde{\bm{x}}_2)) \gg \text{sim}(f(\tilde{\bm{x}}_1), f(\tilde{\bm{x}}_j)) \quad (j \neq 同じ画像) $$

ここで $\text{sim}(\cdot, \cdot)$ は類似度関数(典型的にはコサイン類似度)です。正例ペアの類似度が、負例ペアの類似度よりも十分に大きくなるように学習します。

では、この「近づけて遠ざける」という目標をどのような損失関数で実現するのでしょうか。次のセクションでInfoNCE損失を詳しく見ていきましょう。

InfoNCE損失の理論

InfoNCE損失の定義

対照学習で最も広く使われる損失関数が InfoNCE損失(Noise-Contrastive Estimation に基づく損失)です。

バッチサイズ $N$ のミニバッチにおいて、各画像から2つのビューを生成し、合計 $2N$ 個のビューが得られます。画像 $i$ から生成された2つのビューの表現を $\bm{z}_i$ と $\bm{z}_{i+N}$(または $\bm{z}_{2i-1}$ と $\bm{z}_{2i}$)とします。

$\bm{z}_i$ をアンカー、$\bm{z}_{i+N}$ を正例とすると、残りの $2(N-1)$ 個のビューがすべて負例となります。InfoNCE損失は次のように定義されます。

$$ \mathcal{L}_i = -\log \frac{\exp(\text{sim}(\bm{z}_i, \bm{z}_{i+N}) / \tau)}{\sum_{k=1}^{2N} \mathbb{1}_{[k \neq i]} \exp(\text{sim}(\bm{z}_i, \bm{z}_k) / \tau)} $$

ここで: – $\text{sim}(\bm{u}, \bm{v}) = \bm{u}^\top \bm{v} / (\|\bm{u}\| \|\bm{v}\|)$ はコサイン類似度 – $\tau > 0$ は 温度パラメータ(temperature) – $\mathbb{1}_{[k \neq i]}$ は $k \neq i$ のときに1、そうでないときに0を返す指示関数

全体の損失は、すべてのアンカーに対する平均です。

$$ \mathcal{L} = \frac{1}{2N} \sum_{i=1}^{2N} \mathcal{L}_i $$

ソフトマックス交差エントロピーとの関係

InfoNCE損失の形をよく見ると、ソフトマックス交差エントロピー損失と同じ構造をしていることがわかります。

ソフトマックス交差エントロピーは、$C$ クラスの分類問題で正解クラス $y$ に対して

$$ \mathcal{L}_{CE} = -\log \frac{\exp(l_y)}{\sum_{c=1}^{C} \exp(l_c)} $$

と定義されます。ここで $l_c$ はクラス $c$ のロジットです。

InfoNCE損失では、「アンカー $\bm{z}_i$ と各ビュー $\bm{z}_k$ の類似度(温度でスケーリング)」がロジットに対応し、「正例のインデックス」が正解クラスに対応します。つまり、InfoNCE損失は $(2N-1)$ クラスの分類問題 とみなせるのです。「アンカーに対する正しいペアはどれか?」という識別タスクを解いていると解釈できます。

InfoNCE損失は正例を当てる分類

図のように、アンカーと各ビューの類似度を $\tau$ で割ってsoftmaxにかけ、「正例の確率」を最大化します。これは正例のインデックスを正解ラベルとする交差エントロピーそのものです。負例が多いほど(バッチが大きいほど)難しい識別問題になり、より良い表現が要求されます。

情報理論的な解釈

InfoNCE損失のもうひとつの重要な解釈は、相互情報量(Mutual Information)の下限 を最大化しているというものです。

2つのランダム変数 $X$ と $Y$ の相互情報量 $I(X; Y)$ は、$X$ を知ることで $Y$ についてどれだけの情報が得られるかを測ります。

$$ I(X; Y) = \mathbb{E}\left[\log \frac{p(X, Y)}{p(X) p(Y)}\right] $$

Oord et al. (2018) は、InfoNCE損失が以下の関係を満たすことを示しました。

$$ I(X; Y) \geq \log N – \mathcal{L}_{InfoNCE} $$

つまり、InfoNCE損失を最小化することは、相互情報量の下限を最大化することに対応します。直感的には、正例ペアの表現が互いに多くの情報を共有するように学習しているということです。

ただし、この下限は $\log N$ で上限づけられているため、バッチサイズ $N$ が大きいほど、より多くの相互情報量を捉えられる可能性があります。これが、対照学習で大きなバッチサイズが重要とされる理論的な根拠の一つです。

温度パラメータ $\tau$ の役割

温度パラメータ $\tau$ は、対照学習において非常に重要な役割を果たします。

$\tau$ が小さい場合: ソフトマックスの出力分布が「尖る」ため、最も類似度の高い負例に対して強いペナルティがかかります。つまり、ハード負例(正例に似ている負例)をより重視して学習します。しかし、小さすぎると勾配が不安定になる問題があります。

$\tau$ が大きい場合: ソフトマックスの出力分布が「平ら」になるため、すべての負例を均等に扱います。学習が安定しますが、区別が困難な負例から十分に学習できない可能性があります。

数式で理解しましょう。$\tau \to 0$ の極限では

$$ \frac{\exp(s_i / \tau)}{\sum_j \exp(s_j / \tau)} \to \begin{cases} 1 & \text{if } i = \arg\max_j s_j \\ 0 & \text{otherwise} \end{cases} $$

となり、argmaxの操作に近づきます。$\tau \to \infty$ の極限では一様分布に近づきます。

SimCLRでは $\tau = 0.5$ が典型的な値として使われますが、タスクやデータに応じた調整が必要です。

温度によるsoftmaxの尖りの変化

同じ類似度でも、$\tau$ を小さくするとsoftmaxが尖って正例(緑)に確率が集中し、最も紛らわしい負例を強く押し下げます(ハード負例重視)。逆に $\tau$ を大きくすると分布が平坦になり、すべての負例をほぼ均等に扱います。$\tau$ は「どれだけ厳しく区別するか」を決めるつまみだと捉えてください。

温度パラメータの影響は後半の実験で詳しく検証します。まずは、対照学習の代表的な手法であるSimCLRのアーキテクチャを見ていきましょう。

SimCLRのアーキテクチャ

SimCLRの全体像

SimCLR(Simple Framework for Contrastive Learning of Visual Representations, Chen et al., 2020)は、対照学習のフレームワークとして最もシンプルかつ効果的な手法のひとつです。

SimCLRは4つのコンポーネントで構成されます。

1. データ拡張モジュール: 入力画像 $\bm{x}$ に対してランダムなデータ拡張を適用し、2つのビュー $\tilde{\bm{x}}_1, \tilde{\bm{x}}_2$ を生成します。

2. エンコーダ $f(\cdot)$: ビューから表現ベクトル $\bm{h} = f(\tilde{\bm{x}})$ を抽出します。ResNetなどのCNNが使われます。

3. 射影ヘッド $g(\cdot)$: 表現 $\bm{h}$ を射影空間にマッピングします。$\bm{z} = g(\bm{h}) = W_2 \sigma(W_1 \bm{h})$。典型的には2層のMLPです。

4. InfoNCE損失: 射影空間での正例の類似度を最大化し、負例の類似度を最小化します。

なぜ射影ヘッドが必要なのか

SimCLRの重要な発見のひとつは、射影ヘッド $g(\cdot)$ が学習に大きく影響するということです。

表現 $\bm{h}$ を直接使ってInfoNCE損失を計算するよりも、射影ヘッド $g$ を通した $\bm{z} = g(\bm{h})$ でInfoNCE損失を計算した方が、下流タスクの性能が大幅に向上します。しかし、下流タスクでは $\bm{z}$ ではなく $\bm{h}$ を使うのがポイントです。

なぜでしょうか。直感的な説明は次の通りです。InfoNCE損失を最小化する過程で、対照学習に不要な情報(たとえば、データ拡張の種類に関する情報)が表現から除去されます。射影ヘッド $g$ がこの「情報の削除」を担当することで、$\bm{h}$ にはデータ拡張に依存しない 普遍的な特徴 が保持されます。

つまり、射影ヘッドは対照学習の「犠牲層」のような役割を果たし、表現 $\bm{h}$ を対照学習の副作用から守っているのです。

エンコーダと射影ヘッドの構成

流れは図の通りです。エンコーダ $f$ が表現 $h$ を作り、射影ヘッド $g$ が射影 $z$ を作って、InfoNCE損失は $z$ の側で計算します。ところが下流タスクで使うのは $h$ です。拡張に依存する情報を $g$ が引き受けることで、$h$ にはより普遍的な特徴が残る——この役割分担がSimCLRの効きどころです。

データ拡張の重要性

SimCLRにおいて、データ拡張の選択は表現の質に決定的な影響を与えます。Chen et al. (2020) の実験では、以下の拡張の組み合わせが最も効果的でした。

  • ランダムクロッピング + リサイズ: 画像の異なる領域を切り出す
  • 色変換(Color Jitter): 明るさ・コントラスト・彩度・色相のランダムな変化
  • グレースケール変換: 確率的にグレースケールに変換
  • ガウシアンブラー: 確率的にぼかしを適用

特に重要なのは、ランダムクロッピング色変換 の組み合わせです。クロッピングだけでは「色の特徴だけで正例を見分けるショートカット」が生じ、色変換だけでは「空間的な位置だけで見分けるショートカット」が生じます。両方を組み合わせることで、モデルはより意味的な特徴を学習せざるを得なくなります。

では、実際にSimCLRをPyTorchで実装してみましょう。

SimCLRのPyTorch実装

データ拡張の実装

まず、対照学習に必要なデータ拡張パイプラインを実装します。ここではCIFAR-10の代わりに合成データを使用して、小規模な実験を行います。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
    if any(_c==_f.name for _f in _fm.fontManager.ttflist):
        plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False

class SimpleAugmentation:
    """シンプルなデータ拡張(数値データ用)"""
    def __init__(self, noise_scale=0.1, mask_prob=0.1):
        self.noise_scale = noise_scale
        self.mask_prob = mask_prob

    def __call__(self, x):
        # ガウスノイズの追加
        noise = torch.randn_like(x) * self.noise_scale
        x_aug = x + noise
        # ランダムマスキング
        mask = (torch.rand_like(x) > self.mask_prob).float()
        x_aug = x_aug * mask
        return x_aug

class ContrastiveDataset:
    """対照学習用データセット"""
    def __init__(self, data, augmentation):
        self.data = data
        self.augmentation = augmentation

    def __len__(self):
        return len(self.data)

    def get_pair(self, idx):
        """同じデータから2つの拡張ビューを生成"""
        x = self.data[idx]
        view1 = self.augmentation(x)
        view2 = self.augmentation(x)
        return view1, view2

    def get_batch(self, batch_size):
        """バッチを取得"""
        indices = np.random.choice(len(self.data), batch_size, replace=False)
        views1, views2 = [], []
        for idx in indices:
            v1, v2 = self.get_pair(idx)
            views1.append(v1)
            views2.append(v2)
        return torch.stack(views1), torch.stack(views2)

# 合成データの生成: 5クラスのクラスタ
np.random.seed(42)
num_classes = 5
samples_per_class = 200
dim = 20

data_list = []
labels_list = []
for c in range(num_classes):
    center = np.random.randn(dim) * 1.5
    samples = center + np.random.randn(samples_per_class, dim) * 2.0
    data_list.append(samples)
    labels_list.append(np.full(samples_per_class, c))

data = torch.FloatTensor(np.concatenate(data_list))
labels = torch.LongTensor(np.concatenate(labels_list))

# シャッフル
perm = torch.randperm(len(data))
data = data[perm]
labels = labels[perm]

print(f"データサイズ: {data.shape}")
print(f"クラス数: {num_classes}")
print(f"クラスごとのサンプル数: {samples_per_class}")

5クラスの合成データを生成しました。各クラスは20次元空間上の異なる中心を持つガウスクラスタです。対照学習では、このラベル情報を使わずに有用な表現を学習し、その後のラベル付き評価で表現の質を確認します。

SimCLRモデルの実装

import torch
import torch.nn as nn
import torch.nn.functional as F

class SimCLR(nn.Module):
    """SimCLRモデル"""
    def __init__(self, input_dim, hidden_dim=64, proj_dim=32):
        super().__init__()
        # エンコーダ f: 入力 -> 表現 h
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.BatchNorm1d(hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim)
        )
        # 射影ヘッド g: 表現 h -> 射影 z
        self.projector = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim),
            nn.ReLU(),
            nn.Linear(hidden_dim, proj_dim)
        )

    def forward(self, x):
        h = self.encoder(x)    # 表現(下流タスクで使用)
        z = self.projector(h)   # 射影(対照学習の損失計算で使用)
        return h, z

    def get_representation(self, x):
        """下流タスク用: エンコーダのみの出力"""
        return self.encoder(x)

def info_nce_loss(z1, z2, temperature=0.5):
    """
    InfoNCE損失の計算
    z1, z2: 正例ペアの射影表現 (batch_size, proj_dim)
    temperature: 温度パラメータ
    """
    batch_size = z1.size(0)

    # L2正規化(コサイン類似度の計算のため)
    z1 = F.normalize(z1, dim=1)
    z2 = F.normalize(z2, dim=1)

    # 全ペアの類似度行列を計算
    # z: (2*batch_size, proj_dim) に連結
    z = torch.cat([z1, z2], dim=0)  # (2N, d)
    sim_matrix = torch.matmul(z, z.t()) / temperature  # (2N, 2N)

    # 自分自身との類似度をマスク(-infにして softmax で0にする)
    mask = torch.eye(2 * batch_size, device=z.device).bool()
    sim_matrix.masked_fill_(mask, float('-inf'))

    # 正例のインデックスを設定
    # z1[i] の正例は z2[i](インデックス i+N)
    # z2[i] の正例は z1[i](インデックス i)
    pos_indices = torch.cat([
        torch.arange(batch_size, 2 * batch_size),  # z1の正例はz2
        torch.arange(0, batch_size)                 # z2の正例はz1
    ]).to(z.device)

    # InfoNCE損失 = クロスエントロピー損失
    loss = F.cross_entropy(sim_matrix, pos_indices)
    return loss

# テスト
torch.manual_seed(42)
model = SimCLR(input_dim=20, hidden_dim=64, proj_dim=32)
z1 = torch.randn(8, 32)
z2 = torch.randn(8, 32)
loss = info_nce_loss(z1, z2, temperature=0.5)
print(f"InfoNCE損失(ランダム初期化): {loss.item():.4f}")
print(f"理論上のランダム時の損失 (log(2N-1)): {np.log(2*8 - 1):.4f}")

InfoNCE損失の実装では、F.cross_entropyをそのまま活用しています。類似度行列の各行が「分類のロジット」、正例のインデックスが「正解ラベル」に対応するため、$(2N-1)$クラスの分類問題として扱えます。ランダム初期化時の損失値が $\log(2N – 1)$ に近いことから、初期状態ではアンカーが正例を他の負例と区別できていないことが確認できます。

対照学習の訓練

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
    if any(_c==_f.name for _f in _fm.fontManager.ttflist):
        plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False

# 前のセクションで定義したクラスを再利用
class SimCLR(nn.Module):
    def __init__(self, input_dim, hidden_dim=64, proj_dim=32):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim)
        )
        self.projector = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, proj_dim)
        )

    def forward(self, x):
        h = self.encoder(x)
        z = self.projector(h)
        return h, z

    def get_representation(self, x):
        return self.encoder(x)

def info_nce_loss(z1, z2, temperature=0.5):
    batch_size = z1.size(0)
    z1 = F.normalize(z1, dim=1)
    z2 = F.normalize(z2, dim=1)
    z = torch.cat([z1, z2], dim=0)
    sim = torch.matmul(z, z.t()) / temperature
    mask = torch.eye(2 * batch_size, device=z.device).bool()
    sim.masked_fill_(mask, float('-inf'))
    pos_idx = torch.cat([torch.arange(batch_size, 2*batch_size),
                         torch.arange(0, batch_size)]).to(z.device)
    return F.cross_entropy(sim, pos_idx)

class SimpleAugmentation:
    def __init__(self, noise_scale=0.1, mask_prob=0.1):
        self.noise_scale = noise_scale
        self.mask_prob = mask_prob

    def __call__(self, x):
        noise = torch.randn_like(x) * self.noise_scale
        x_aug = x + noise
        mask = (torch.rand_like(x) > self.mask_prob).float()
        return x_aug * mask

# データ生成
np.random.seed(42)
num_classes = 5
dim = 20
data_list, labels_list = [], []
for c in range(num_classes):
    center = np.random.randn(dim) * 1.5
    samples = center + np.random.randn(200, dim) * 2.0
    data_list.append(samples)
    labels_list.append(np.full(200, c))
data = torch.FloatTensor(np.concatenate(data_list))
labels = torch.LongTensor(np.concatenate(labels_list))
perm = torch.randperm(len(data))
data, labels = data[perm], labels[perm]

# 訓練/テスト分割
split = int(len(data) * 0.8)
train_data, test_data = data[:split], data[split:]
train_labels, test_labels = labels[:split], labels[split:]

# SimCLRの訓練
torch.manual_seed(42)
model = SimCLR(input_dim=20, hidden_dim=64, proj_dim=32)
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5)
augmentation = SimpleAugmentation(noise_scale=0.3, mask_prob=0.15)

losses = []
epochs = 200
batch_size = 128

for epoch in range(epochs):
    model.train()
    # ランダムにバッチを取得
    idx = torch.randperm(len(train_data))[:batch_size]
    x_batch = train_data[idx]

    # 2つの拡張ビューを生成
    view1 = augmentation(x_batch)
    view2 = augmentation(x_batch)

    # 順伝播
    _, z1 = model(view1)
    _, z2 = model(view2)

    # InfoNCE損失
    loss = info_nce_loss(z1, z2, temperature=0.5)

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

    losses.append(loss.item())
    if (epoch + 1) % 50 == 0:
        print(f"Epoch {epoch+1}/{epochs}, Loss: {loss.item():.4f}")

# 訓練損失のプロット
fig, ax = plt.subplots(1, 1, figsize=(8, 5))
ax.plot(losses, color='#2ecc71', alpha=0.8)
ax.set_xlabel('エポック', fontsize=12)
ax.set_ylabel('InfoNCE損失', fontsize=12)
ax.set_title('SimCLRの学習損失', fontsize=14)
ax.axhline(y=np.log(2 * batch_size - 1), color='red', linestyle='--',
           alpha=0.5, label=f'ランダム基準 (log(2N-1)={np.log(2*batch_size-1):.2f})')
ax.legend(fontsize=10)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('simclr_training_loss.png', dpi=150, bbox_inches='tight')
plt.show()

SimCLRの学習損失の推移

訓練損失のグラフを見ると、損失が $\log(2N-1)$(ランダムベースライン)から大きく減少していることがわかります。これは、モデルが正例ペアを負例から効果的に区別できるようになったことを意味します。損失が0に近づくほど、正例ペアの類似度が負例ペアの類似度よりも圧倒的に高くなっています。

表現の質の評価

学習された表現の質を、線形プローブ(Linear Probe)と可視化で評価します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
    if any(_c==_f.name for _f in _fm.fontManager.ttflist):
        plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False
from sklearn.decomposition import PCA

# 表現の抽出
model.eval()
with torch.no_grad():
    train_repr = model.get_representation(train_data)
    test_repr = model.get_representation(test_data)

# 1. 線形プローブ: 凍結された表現の上に線形分類器を訓練
class LinearProbe(nn.Module):
    def __init__(self, input_dim, num_classes):
        super().__init__()
        self.linear = nn.Linear(input_dim, num_classes)

    def forward(self, x):
        return self.linear(x)

# 対照学習の表現で線形プローブ
probe_cl = LinearProbe(64, num_classes)
opt_cl = torch.optim.Adam(probe_cl.parameters(), lr=0.01)

# 比較: ランダム初期化のエンコーダの表現で線形プローブ
random_model = SimCLR(input_dim=20, hidden_dim=64, proj_dim=32)
with torch.no_grad():
    train_repr_random = random_model.get_representation(train_data)
    test_repr_random = random_model.get_representation(test_data)

probe_random = LinearProbe(64, num_classes)
opt_random = torch.optim.Adam(probe_random.parameters(), lr=0.01)

# 比較: 教師あり学習(直接学習)
supervised_model = nn.Sequential(
    nn.Linear(20, 64), nn.BatchNorm1d(64), nn.ReLU(),
    nn.Linear(64, 64), nn.BatchNorm1d(64), nn.ReLU(),
    nn.Linear(64, num_classes)
)
opt_sup = torch.optim.Adam(supervised_model.parameters(), lr=0.01)

results = {
    'SimCLR+線形': [],
    'ランダム+線形': [],
    '教師あり': []
}

for epoch in range(100):
    # SimCLR + Linear Probe
    probe_cl.train()
    logits = probe_cl(train_repr.detach())
    loss = F.cross_entropy(logits, train_labels)
    opt_cl.zero_grad(); loss.backward(); opt_cl.step()

    # Random + Linear Probe
    probe_random.train()
    logits_r = probe_random(train_repr_random.detach())
    loss_r = F.cross_entropy(logits_r, train_labels)
    opt_random.zero_grad(); loss_r.backward(); opt_random.step()

    # Supervised
    supervised_model.train()
    logits_s = supervised_model(train_data)
    loss_s = F.cross_entropy(logits_s, train_labels)
    opt_sup.zero_grad(); loss_s.backward(); opt_sup.step()

    if (epoch + 1) % 5 == 0:
        probe_cl.eval(); probe_random.eval(); supervised_model.eval()
        with torch.no_grad():
            acc_cl = (probe_cl(test_repr).argmax(1) == test_labels).float().mean().item()
            acc_r = (probe_random(test_repr_random).argmax(1) == test_labels).float().mean().item()
            acc_s = (supervised_model(test_data).argmax(1) == test_labels).float().mean().item()

        results['SimCLR+線形'].append((epoch+1, acc_cl))
        results['ランダム+線形'].append((epoch+1, acc_r))
        results['教師あり'].append((epoch+1, acc_s))

# 2. 表現のPCA可視化
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# SimCLRの表現
pca = PCA(n_components=2)
repr_2d = pca.fit_transform(test_repr.detach().numpy())
scatter = axes[0].scatter(repr_2d[:, 0], repr_2d[:, 1], c=test_labels.numpy(),
                          cmap='tab10', s=20, alpha=0.7)
axes[0].set_title('SimCLRの表現(PCA)', fontsize=14)
axes[0].set_xlabel('第1主成分', fontsize=12)
axes[0].set_ylabel('第2主成分', fontsize=12)

# ランダムエンコーダの表現
repr_random_2d = pca.fit_transform(test_repr_random.detach().numpy())
axes[1].scatter(repr_random_2d[:, 0], repr_random_2d[:, 1], c=test_labels.numpy(),
                cmap='tab10', s=20, alpha=0.7)
axes[1].set_title('ランダムエンコーダ(PCA)', fontsize=14)
axes[1].set_xlabel('第1主成分', fontsize=12)
axes[1].set_ylabel('第2主成分', fontsize=12)

# テスト精度の比較
colors = {'SimCLR+線形': '#2ecc71', 'ランダム+線形': '#e74c3c', '教師あり': '#3498db'}
for name, res in results.items():
    epochs_list, accs = zip(*res)
    axes[2].plot(epochs_list, accs, 'o-', label=name, color=colors[name], markersize=3)
axes[2].set_xlabel('線形プローブのエポック', fontsize=12)
axes[2].set_ylabel('テスト精度', fontsize=12)
axes[2].set_title('下流分類の精度', fontsize=14)
axes[2].legend(fontsize=10)
axes[2].set_ylim(0, 1.05)
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('simclr_evaluation.png', dpi=150, bbox_inches='tight')
plt.show()

# 最終精度
for name, res in results.items():
    print(f"{name:>20s}: {res[-1][1]*100:.1f}%")

SimCLR表現のPCAと下流分類精度

PCAによる2次元可視化を見ると、SimCLRで学習された表現(左)ではクラスごとのクラスタが明確に分離されている一方、ランダムエンコーダの表現(中央)ではクラスタの分離が不明瞭です。ラベルを一切使わずに、対照学習だけでこのような構造化された表現が得られているのは注目に値します。右のグラフでは、SimCLRの表現上の線形プローブが、ランダムエンコーダよりも大幅に高い精度を達成し、教師あり学習に近い性能を示していることが確認できます。

温度パラメータの影響

温度 $\tau$ がInfoNCE損失と学習される表現にどう影響するかを実験的に検証します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
    if any(_c==_f.name for _f in _fm.fontManager.ttflist):
        plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False

# 再度モデルとデータの定義(前セクションと同一)
class SimCLRTemp(nn.Module):
    def __init__(self, input_dim, hidden_dim=64, proj_dim=32):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim)
        )
        self.projector = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, proj_dim)
        )

    def forward(self, x):
        h = self.encoder(x)
        z = self.projector(h)
        return h, z

    def get_representation(self, x):
        return self.encoder(x)

def info_nce_loss(z1, z2, temperature=0.5):
    bs = z1.size(0)
    z1 = F.normalize(z1, dim=1)
    z2 = F.normalize(z2, dim=1)
    z = torch.cat([z1, z2], dim=0)
    sim = torch.matmul(z, z.t()) / temperature
    mask = torch.eye(2*bs, device=z.device).bool()
    sim.masked_fill_(mask, float('-inf'))
    pos_idx = torch.cat([torch.arange(bs, 2*bs), torch.arange(0, bs)]).to(z.device)
    return F.cross_entropy(sim, pos_idx)

class SimpleAug:
    def __init__(self, noise_scale=0.3, mask_prob=0.15):
        self.noise_scale = noise_scale
        self.mask_prob = mask_prob
    def __call__(self, x):
        x = x + torch.randn_like(x) * self.noise_scale
        return x * (torch.rand_like(x) > self.mask_prob).float()

# データ生成
np.random.seed(42)
data_list, labels_list = [], []
for c in range(5):
    center = np.random.randn(20) * 1.5
    data_list.append(center + np.random.randn(200, 20) * 2.0)
    labels_list.append(np.full(200, c))
data = torch.FloatTensor(np.concatenate(data_list))
labels = torch.LongTensor(np.concatenate(labels_list))
perm = torch.randperm(len(data))
data, labels = data[perm], labels[perm]
split = int(len(data) * 0.8)
train_data, test_data = data[:split], data[split:]
train_labels, test_labels = labels[:split], labels[split:]

# 異なる温度での訓練
temperatures = [0.05, 0.1, 0.5, 1.0, 2.0]
aug = SimpleAug()
temp_results = {}

for tau in temperatures:
    torch.manual_seed(42)
    model = SimCLRTemp(20, 64, 32)
    opt = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5)
    losses = []

    for epoch in range(200):
        model.train()
        idx = torch.randperm(len(train_data))[:128]
        v1, v2 = aug(train_data[idx]), aug(train_data[idx])
        _, z1 = model(v1)
        _, z2 = model(v2)
        loss = info_nce_loss(z1, z2, temperature=tau)
        opt.zero_grad(); loss.backward(); opt.step()
        losses.append(loss.item())

    # 線形プローブで評価
    model.eval()
    with torch.no_grad():
        train_repr = model.get_representation(train_data)
        test_repr = model.get_representation(test_data)

    probe = nn.Linear(64, 5)
    opt_p = torch.optim.Adam(probe.parameters(), lr=0.01)
    for ep in range(100):
        probe.train()
        logits = probe(train_repr.detach())
        l = F.cross_entropy(logits, train_labels)
        opt_p.zero_grad(); l.backward(); opt_p.step()

    probe.eval()
    with torch.no_grad():
        acc = (probe(test_repr).argmax(1) == test_labels).float().mean().item()

    temp_results[tau] = {'losses': losses, 'accuracy': acc}
    print(f"tau={tau:.2f}: テスト精度={acc*100:.1f}%")

# 可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# 損失の推移
colors_temp = plt.cm.viridis(np.linspace(0, 1, len(temperatures)))
for (tau, res), color in zip(temp_results.items(), colors_temp):
    axes[0].plot(res['losses'], label=f'tau={tau}', color=color, alpha=0.8)
axes[0].set_xlabel('エポック', fontsize=12)
axes[0].set_ylabel('InfoNCE損失', fontsize=12)
axes[0].set_title('温度別の学習損失', fontsize=14)
axes[0].legend(fontsize=10)
axes[0].grid(True, alpha=0.3)

# 精度の比較
taus = list(temp_results.keys())
accs = [temp_results[t]['accuracy'] for t in taus]
bars = axes[1].bar([str(t) for t in taus], accs, color='#2ecc71', edgecolor='black', linewidth=0.8)
axes[1].set_xlabel('温度 tau', fontsize=12)
axes[1].set_ylabel('テスト精度(線形プローブ)', fontsize=12)
axes[1].set_title('温度が下流精度に与える影響', fontsize=14)
axes[1].set_ylim(0, 1.05)
for bar, acc in zip(bars, accs):
    axes[1].text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.01,
                f'{acc*100:.1f}%', ha='center', fontsize=11, fontweight='bold')
axes[1].grid(True, alpha=0.3, axis='y')

plt.tight_layout()
plt.savefig('temperature_effect.png', dpi=150, bbox_inches='tight')
plt.show()

温度別の学習損失と下流精度

左パネルから、損失の値は $\tau$ が小さいほど大きくなることがわかります。これは類似度を $1/\tau$ でスケールするため、$\tau$ が小さいほどsoftmaxが尖り、損失の取りうる範囲が広がるからです。この傾向は安定して再現します。一方、右パネルの下流精度の差はこの小さな合成データでは数ポイント程度と小さく、乱数シード次第で順位も変わります。実務上は、極端に小さい/大きい $\tau$ を避けて中間的な値(おおむね0.1〜0.5)を選ぶのが無難です。

対照学習の発展手法

MoCo(Momentum Contrast)

SimCLRの課題のひとつは、大きなバッチサイズが必要なことです。InfoNCE損失では、バッチ内の他のサンプルを負例として使うため、バッチサイズが大きいほど多くの負例を得られ、表現の質が向上します。しかし、大きなバッチサイズはGPUメモリの制約を受けます。

MoCo(He et al., 2020)は、モメンタムエンコーダキュー を用いてこの問題を解決しました。

キューには過去のバッチで計算された表現が蓄積され、負例として使われます。これにより、実際のバッチサイズに依存せず、大量の負例を利用できます。

モメンタムエンコーダは、メインエンコーダの指数移動平均(EMA)で更新されます。

$$ \theta_{k} \leftarrow m \theta_{k} + (1 – m) \theta_{q} $$

ここで $\theta_q$ はメインエンコーダのパラメータ、$\theta_k$ はモメンタムエンコーダのパラメータ、$m$(例: 0.999)はモメンタム係数です。

BYOL(Bootstrap Your Own Latent)

BYOL(Grill et al., 2020)は、驚くべきことに 負例を使わない 対照学習手法です。

BYOLは2つのネットワーク(オンラインとターゲット)を持ち、オンラインネットワークの出力がターゲットネットワークの出力を予測するように学習します。ターゲットネットワークはモメンタム更新されます。

負例がなければ 崩壊(collapse)——すべての入力に同じ表現を割り当てる——が起きるはずですが、BYOLでは非対称な予測ヘッドとモメンタム更新の組み合わせによってこれを防いでいます。

SimSiam

SimSiam(Chen & He, 2021)は、BYOLをさらに簡略化し、モメンタム更新すら不要にした手法です。stop-gradient 操作のみで崩壊を防ぎます。

Barlow Twins

Barlow Twins(Zbontar et al., 2021)は、2つのビューの表現の 相互相関行列 が単位行列に近づくように学習します。これにより、各特徴次元が独立かつ冗長のない表現が学習されます。

$$ \mathcal{L} = \sum_i (1 – C_{ii})^2 + \lambda \sum_i \sum_{j \neq i} C_{ij}^2 $$

ここで $C$ は2つのビューの特徴量のバッチ方向の相互相関行列です。

手法の比較

手法 負例 モメンタム バッチサイズ依存性 特徴
SimCLR 必要 不要 高い シンプル、大バッチ必要
MoCo 必要 必要 低い キューで負例を蓄積
BYOL 不要 必要 低い 負例なし、予測タスク
SimSiam 不要 不要 中程度 stop-gradient
Barlow Twins 不要 不要 中程度 相互相関行列

対照学習の主要手法の比較

図に主要手法を整理しました。SimCLR/MoCoは負例を使う「対照型」、BYOL/SimSiam/Barlow Twinsは負例なしで崩壊を防ぐ工夫(モメンタム・stop-gradient・相互相関)を持つ手法です。負例の確保コストと崩壊回避の仕組みがトレードオフになっている点を押さえておきましょう。

類似度行列の可視化

対照学習で何が起きているかをより直感的に理解するために、学習前後の類似度行列を可視化します。

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
    if any(_c==_f.name for _f in _fm.fontManager.ttflist):
        plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False

# 学習済みモデルと未学習モデルの類似度行列を比較
# ここでは小さなバッチで可視化

class SimCLRViz(nn.Module):
    def __init__(self, input_dim, hidden_dim=64, proj_dim=32):
        super().__init__()
        self.encoder = nn.Sequential(
            nn.Linear(input_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim), nn.BatchNorm1d(hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, hidden_dim)
        )
        self.projector = nn.Sequential(
            nn.Linear(hidden_dim, hidden_dim), nn.ReLU(),
            nn.Linear(hidden_dim, proj_dim)
        )
    def forward(self, x):
        return self.projector(self.encoder(x))

def info_nce_loss_fn(z1, z2, temperature=0.5):
    bs = z1.size(0)
    z1 = F.normalize(z1, dim=1)
    z2 = F.normalize(z2, dim=1)
    z = torch.cat([z1, z2], dim=0)
    sim = torch.matmul(z, z.t()) / temperature
    mask = torch.eye(2*bs, device=z.device).bool()
    sim.masked_fill_(mask, float('-inf'))
    pos_idx = torch.cat([torch.arange(bs, 2*bs), torch.arange(0, bs)]).to(z.device)
    return F.cross_entropy(sim, pos_idx)

class SimpleAugViz:
    def __init__(self, ns=0.3, mp=0.15):
        self.ns, self.mp = ns, mp
    def __call__(self, x):
        return (x + torch.randn_like(x)*self.ns) * (torch.rand_like(x)>self.mp).float()

# データ生成
np.random.seed(42)
data_list, labels_list = [], []
for c in range(5):
    center = np.random.randn(20) * 1.5
    data_list.append(center + np.random.randn(200, 20) * 2.0)
    labels_list.append(np.full(200, c))
data = torch.FloatTensor(np.concatenate(data_list))
labels = torch.LongTensor(np.concatenate(labels_list))
perm = torch.randperm(len(data))
data, labels = data[perm], labels[perm]

# 各クラスから2サンプルずつ選択(計10サンプル)
selected_indices = []
for c in range(5):
    class_indices = (labels == c).nonzero(as_tuple=True)[0][:2]
    selected_indices.extend(class_indices.tolist())
selected_data = data[selected_indices]
selected_labels = labels[selected_indices]

# 学習
torch.manual_seed(42)
model_trained = SimCLRViz(20, 64, 32)
aug = SimpleAugViz()
opt = torch.optim.Adam(model_trained.parameters(), lr=0.001)

for epoch in range(300):
    model_trained.train()
    idx = torch.randperm(len(data))[:128]
    v1, v2 = aug(data[idx]), aug(data[idx])
    loss = info_nce_loss_fn(model_trained(v1), model_trained(v2))
    opt.zero_grad(); loss.backward(); opt.step()

# 未学習モデル
torch.manual_seed(0)
model_random = SimCLRViz(20, 64, 32)

# 類似度行列の計算と可視化
fig, axes = plt.subplots(1, 2, figsize=(14, 6))

for ax, model, title in [(axes[0], model_random, '学習前(ランダム)'),
                          (axes[1], model_trained, '対照学習の後')]:
    model.eval()
    with torch.no_grad():
        z = model(selected_data)
        z = F.normalize(z, dim=1)
        sim = torch.matmul(z, z.t()).numpy()

    im = ax.imshow(sim, cmap='RdBu_r', vmin=-1, vmax=1)
    ax.set_title(title, fontsize=14)

    # ラベル表示
    tick_labels = [f'C{selected_labels[i].item()}' for i in range(len(selected_labels))]
    ax.set_xticks(range(len(tick_labels)))
    ax.set_xticklabels(tick_labels, fontsize=10)
    ax.set_yticks(range(len(tick_labels)))
    ax.set_yticklabels(tick_labels, fontsize=10)

    # 数値を表示
    for i in range(len(sim)):
        for j in range(len(sim)):
            ax.text(j, i, f'{sim[i,j]:.2f}', ha='center', va='center',
                    fontsize=8, color='white' if abs(sim[i,j]) > 0.5 else 'black')

    plt.colorbar(im, ax=ax, fraction=0.046)

plt.suptitle('コサイン類似度行列:各クラス2サンプル', fontsize=15, y=1.02)
plt.tight_layout()
plt.savefig('similarity_matrix_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

学習前後のコサイン類似度行列

類似度行列の可視化は非常に示唆的です。学習前(左)では、同じクラスのサンプル間の類似度と異なるクラスのサンプル間の類似度に明確な差がありません。学習後(右)では、同じクラスのサンプル(例: C0同士、C1同士)のコサイン類似度が1に近い値を示し、異なるクラスのサンプル間の類似度は0に近いか負の値を示しています。これは対照学習がラベルなしで意味的な構造を表現空間に埋め込むことに成功したことを明確に示しています。

対照学習の応用先

コンピュータビジョン以外への応用

対照学習は画像認識だけでなく、さまざまなドメインに応用されています。

自然言語処理: SimCSE(Gao et al., 2021)は、同じ文をドロップアウトの異なるマスクで2回エンコードして正例ペアを作ることで、高品質な文の埋め込みを学習します。

グラフ学習: GraphCL(You et al., 2020)は、グラフにデータ拡張(ノード削除、エッジ摂動、サブグラフ抽出など)を施して対照学習を行います。

音声処理: wav2vec 2.0(Baevski et al., 2020)は、マスクされた音声フレームを対照学習で予測することで、音声の表現を学習します。

マルチモーダル: CLIP(Radford et al., 2021)は、画像とテキストのペアを対照学習で学習し、画像とテキストの共通の表現空間を構築します。

半教師あり学習との組み合わせ

対照学習は半教師あり学習のパイプラインの一部としても効果的です。

  1. 大量のラベルなしデータで対照学習による事前学習を行う
  2. 少量のラベル付きデータで線形分類器やMLPをファインチューニングする

このアプローチにより、ラベル付きデータが限られた状況でも高い性能を達成できます。

まとめ

本記事では、対照学習(Contrastive Learning)の理論をInfoNCE損失の数学的な意味から解説し、SimCLRをPyTorchで実装しました。

重要なポイント

対照学習の原理: 「似ているものを近づけ、異なるものを遠ざける」というシンプルな原理に基づいて、ラベルなしでデータの意味的な構造を捉える表現を学習します。正例ペアはデータ拡張で生成し、バッチ内の他のサンプルを負例として使います。

InfoNCE損失: $(2N-1)$クラスのソフトマックス交差エントロピーとして解釈でき、相互情報量の下限を最大化する操作に対応します。温度パラメータ $\tau$ は負例の重み付けを制御し、小さすぎると不安定、大きすぎると学習が不十分になります。

SimCLRの設計: エンコーダ + 射影ヘッドの2段構成が重要です。射影ヘッドがデータ拡張に依存する情報を吸収し、エンコーダの出力(表現 $\bm{h}$)に普遍的な特徴が保持されます。

データ拡張の重要性: ショートカットを防ぐために、複数の拡張手法を組み合わせることが不可欠です。拡張が弱すぎると自明な解に収束し、強すぎると正例の類似性が失われます。

今後の学習に向けて

対照学習は自己教師あり学習の一分野であり、その後BYOL、SimSiam、Barlow Twinsなど負例を使わない手法も登場しています。また、CLIPのようなマルチモーダル対照学習は、現在のAI技術の基盤のひとつとなっています。

画像なし
転移学習の理論と実践
対照学習で得た事前学習モデルを下流タスクに活用する方法。
画像なし
Attention機構の基礎
Transformerベースの対照学習モデルを理解するための前提知識。