Contrastive Predictive Coding : Guide Complet — Prévision Contrastive dans l’Espace Latent

Contrastive Predictive Coding : Guide Complet — Prévision Contrastive dans l'Espace Latent

Contrastive Predictive Coding

Résumé

Le Contrastive Predictive Coding (CPC) est une méthode révolutionnaire d’apprentissage de représentations self-supervisé. Introduit par Aaron van den Oord et ses collaborateurs chez DeepMind en 2018, le CPC repose sur une idée simple mais élégante : au lieu de prédire les données brutes du futur, le modèle apprend à prédire des représentations latentes dans un espace de plus haute dimension. Cette approche permet de capturer l’essence structurelle des données sans jamais avoir besoin d’étiquettes manuelles. Le moteur d’apprentissage est la fonction de perte InfoNCE (Information Noise-Contrastive Estimation), qui maximise une borne inférieure de l’information mutuelle entre le contexte présent et les observations futures. Le résultat est un système capable d’extraire des caractéristiques riches et transférables à des tâches variées : classification audio, reconnaissance d’images, compréhension du langage naturel, et bien d’autres applications.

Principe Mathématique du Contrastive Predictive Coding

Le CPC repose sur trois composants fondamentaux qui s’articulent dans un pipeline élégant de transformation progressive des données.

1. Encodeur — Production des Représentations Locales

Chaque observation brute $x_t$ (un échantillon audio, un patch d’image, un mot dans une phrase) est transformée en une représentation latente compacte $z_t$ par un encodeur non linéaire :

$$z_t = f_{\text{enc}}(x_t)$$

L’encodeur $f_{\text{enc}}$ est généralement un réseau de neurones convolutif (CNN) qui réduit la dimensionalité des données tout en préservant l’information sémantique pertinente. Pour un signal audio par exemple, le spectrogramme brut (de dimension élevée) est compressé en un vecteur $z_t$ de taille fixe, disons 256 ou 512 dimensions. Ce vecteur capture les caractéristiques locales essentielles — la fréquence dominante, le timbre, l’énergie — dans une forme beaucoup plus compacte.

2. Réseau d’Agrégation Contextuelle — Construction du Contexte

Les représentations locales $z_1, z_2, …, z_t$ sont ensuite agrégées séquentiellement par un réseau autorégressif $f_{\text{autoreg}}$ (généralement un RNN de type GRU ou LSTM, ou bien un Transformeur) pour produire un vecteur de contexte $c_t$ :

$$c_t = f_{\text{autoreg}}(z_{1:t})$$

Ce vecteur $c_t$ résume toute l’histoire des représentations jusqu’au pas de temps $t$. Il contient l’information accumulée du passé récent et lointain, comprimée dans un unique espace vectoriel. C’est le rôle du réseau d’agrégation contextuelle : agir comme une mémoire dynamique qui intègre progressivement l’information au fur et à mesure qu’elle arrive. Contrairement à un simple empilement de caractéristiques, le réseau autorégressif apprend quelles informations du passé sont pertinentes pour la prédiction future, et lesquelles peuvent être oubliées.

3. Prédiction dans l’Espace Latent

Le cœur du CPC réside dans la prédiction. Pour chaque pas futur $k \geq 1$, le modèle apprend une transformation linéaire qui prédit la représentation future $z_{t+k}$ à partir du contexte courant $c_t$ :

$$\phi_k(c_t) = W_k \cdot c_t$$

Ici, $W_k$ est une matrice de poids spécifique au pas de prédiction $k$. L’idée est que chaque horizon temporel nécessite une projection différente : prédire 1 pas en avant n’est pas la même tâche que prédire 10 pas en avant. La similarité score entre la prédiction et la représentation future réelle est calculée par produit scalaire :

$$f_k(x_{t+k}, c_t) = \exp(z_{t+k}^T \cdot W_k \cdot c_t)$$

4. Perte InfoNCE — Maximisation de l’Information Mutuelle

La perte centrale du CPC est la fonction InfoNCE (Information Noise-Contrastive Estimation). Pour un contexte $c_t$ donné, le modèle doit distinguer le véritable échantillon futur $x_{t+k}$ d’un ensemble de distracteurs (échantillons négatifs) ${x_j}$ tirés d’autres séquences :

$$L_{\text{CPC}} = -\sum_k \log \frac{\exp(z_{t+k}^T \cdot W_k \cdot c_t)}{\sum_{x_j} \exp(z_j^T \cdot W_k \cdot c_t)}$$

Cette formule est remarquablement efficace. Le numérateur mesure la similarité entre la prédiction correcte ($z_{t+k}$ prédit depuis $c_t$) et le dénominateur normalise par rapport à tous les candidats possibles. En minimisant cette perte, le modèle apprend à assigner un score élevé à la paire correcte (contexte + vrai futur) et un score bas aux paires incorrectes. La perte InfoNCE maximise en réalité une borne inférieure de l’information mutuelle entre $c_t$ et $x_{t+k}$, ce qui justifie théoriquement l’approche : plus la perte est faible, plus le contexte $c_t$ contient d’information sur le futur.

Intuition — Comprendre le CPC à travers une Analogie

Le CPC, c’est comme un journaliste sportif qui essaie de deviner la suite d’un événement en cours. Imaginez qu’un match de football vienne de commencer. Le journaliste ne va pas prédire le détail exact de chaque action — « Ronaldo va marquer à la 37e minute », « Modric va faire une passe dans l’axe à la 52e ». Ce serait beaucoup trop difficile, presque impossible.

À la place, il prédit l’idée générale, le contexte global : « Il y aura des buts », « les deux équipes vont attaquer », « le score sera serré », « il y aura des corners et des cartons jaunes ». Cette prédiction à haut niveau capture l’essence du match sans se perdre dans les détails imprévisibles.

C’est exactement ce que fait le CPC ! Au lieu de prédire le pixel exact qui va suivre dans une image ou l’échantillon audio brut (tâche extrêmement difficile, car le bruit et la variabilité sont énormes), le modèle prédit la représentation latente future — c’est-à-dire les caractéristiques sémantiques abstraites qui décrivent ce qui va arriver. Il ne prédit pas « le 440 Hz à l’amplitude 0,73 », il prédit « c’est un accord majeur, probablement la tonique, dans un contexte musical jazz ».

Cette abstraction est la clé du succès. En travaillant dans l’espace latent au lieu de l’espace des données brutes, le CPC se concentre sur ce qui est structurellement important et ignore le bruit superficiel. Les représentations ainsi apprises sont beaucoup plus robustes et transférables que des représentations entraînées par reconstruction pure.

Une autre façon de voir les choses : c’est comme si vous écoutiez une conversation téléphonique avec une qualité médiocre. Vous ne percevez pas chaque syllabe parfaitement, mais votre cerveau reconstitue le sens global de la conversation. Le CPC fonctionne de manière similaire — il extrait le signal sémantique du bruit, le sens de la forme brute.

Implémentation Python Complète avec PyTorch

Voici une implémentation complète du CPC appliqué à des données audio sous forme de spectrogrammes. Le code est structuré en modules clairs et commenté en détail.

import torch
import torch.nn as nn
import torch.nn.functional as F
from torch.utils.data import DataLoader, TensorDataset
import numpy as np


class CPCEncoder(nn.Module):
    """Encodeur CNN: transforme les spectrogrammes bruts
    en représentations latentes compactes z_t."""

    def __init__(self, latent_dim=256):
        super().__init__()
        self.latent_dim = latent_dim
        # Chaque couche CNN réduit la dimension temporelle
        # par un facteur 2 et extrait des caractéristiques
        self.conv_layers = nn.Sequential(
            nn.Conv1d(1, 64, kernel_size=10, stride=5, padding=2),
            nn.BatchNorm1d(64),
            nn.ReLU(inplace=True),

            nn.Conv1d(64, 128, kernel_size=8, stride=4, padding=2),
            nn.BatchNorm1d(128),
            nn.ReLU(inplace=True),

            nn.Conv1d(128, 256, kernel_size=4, stride=2, padding=1),
            nn.BatchNorm1d(256),
            nn.ReLU(inplace=True),

            nn.Conv1d(256, self.latent_dim, kernel_size=4, stride=2, padding=1),
            nn.BatchNorm1d(self.latent_dim),
            nn.ReLU(inplace=True),
        )

    def forward(self, x):
        # x: (batch, 1, temps) — spectrogramme mono-canal
        # z: (batch, latent_dim, temps_reduit)
        z = self.conv_layers(x)
        return z  # Chaque pas de temps est une représentation locale z_t


class CPCAutoregressor(nn.Module):
    """Réseau autorégressif (GRU) qui agrège
    l'historique des représentations en un contexte c_t."""

    def __init__(self, latent_dim=256, num_layers=3):
        super().__init__()
        self.gru = nn.GRU(
            input_size=latent_dim,
            hidden_size=latent_dim,
            num_layers=num_layers,
            batch_first=True
        )

    def forward(self, z):
        # z: (batch, temps, latent_dim)
        # On transpose car l'encodeur produit (batch, dim, temps)
        z_seq = z.transpose(1, 2)
        # c: (batch, temps, latent_dim) — contexte à chaque pas
        c, _ = self.gru(z_seq)
        return c


class CPCModel(nn.Module):
    """Modèle CPC complet : encodeur + autorégresseur + projections."""

    def __init__(self, latent_dim=256, n_future_steps=12,
                 autoregressor_layers=3):
        super().__init__()
        self.latent_dim = latent_dim
        self.n_future_steps = n_future_steps
        self.encoder = CPCEncoder(latent_dim=latent_dim)
        self.autoregressor = CPCAutoregressor(
            latent_dim=latent_dim,
            num_layers=autoregressor_layers
        )
        # Matrices W_k pour chaque pas de prédiction futur
        self.W_k = nn.ModuleList([
            nn.Linear(latent_dim, latent_dim)
            for _ in range(n_future_steps)
        ])

    def forward(self, x):
        """Renvoie le contexte c_t et les représentations z."""
        # Encodage
        z = self.encoder(x)              # (batch, dim, temps')
        z_seq = z.transpose(1, 2)        # (batch, temps', dim)
        # Contexte autorégressif
        c = self.autoregressor(z)        # (batch, temps', dim)
        return c, z_seq

    def compute_logits(self, c_t, z_future, k, negatives=None):
        """Calcule les scores pour InfoNCE."""
        # Prédiction: W_k * c_t
        prediction = self.W_k[k](c_t)    # (batch, 1, dim)
        # Score positif: similarité avec le vrai futur
        pos_score = torch.bmm(z_future.unsqueeze(1),
                              prediction.transpose(1, 2)).squeeze(-1)
        if negatives is not None:
            neg_scores = torch.bmm(negatives,
                                   prediction.transpose(1, 2))
            logits = torch.cat([pos_score, neg_scores.squeeze(-1)], dim=1)
        else:
            logits = pos_score
        return logits

    def info_nce_loss(self, c, z, n_negatives=8):
        """Calcule la perte InfoNCE sur tous les pas futurs."""
        batch_size, seq_len, dim = c.shape
        total_loss = 0.0
        n_valid_k = 0

        for k in range(1, min(self.n_future_steps, seq_len)):
            # Contexte au temps t
            c_t = c[:, :-k, :]               # (batch, T-k, dim)
            # Représentation vraie au temps t+k
            z_future = z[:, k:, :]           # (batch, T-k, dim)

            # Échantillons négatifs: on mélange les z dans le batch
            shuffled_z = z.flatten(0, 1)
            neg_indices = torch.randperm(shuffled_z.size(0))
            neg_z = shuffled_z[neg_indices]
            neg_z = neg_z[:batch_size * (seq_len - k) * n_negatives]
            neg_z = neg_z.view(batch_size, seq_len - k, n_negatives, dim)

            # Calcul des scores
            prediction = self.W_k[k - 1](c_t)  # (batch, T-k, dim)
            # Score positif
            pos_score = (z_future * prediction).sum(dim=-1, keepdim=True)
            # Scores négatifs
            neg_score = torch.einsum('btd,bknd->btkn', z_future, neg_z)
            neg_score = neg_score.sum(dim=-1)  # (batch, T-k)

            logits = torch.cat([pos_score.squeeze(-1), neg_score], dim=1)

            # Labels : la classe 0 est toujours le positif
            labels = torch.zeros(batch_size * (seq_len - k),
                                 dtype=torch.long, device=logits.device)
            logits_flat = logits.view(-1, logits.size(-1))
            loss = F.cross_entropy(logits_flat, labels)
            total_loss += loss
            n_valid_k += 1

        return total_loss / max(n_valid_k, 1)


# ============================================================
# Entrainement
# ============================================================

def train_cpc(model, dataloader, optimizer, n_epochs=50, device='cuda'):
    """Boucle d'entraînement du modèle CPC."""
    model = model.to(device)
    model.train()

    for epoch in range(n_epochs):
        epoch_loss = 0.0
        n_batches = 0

        for batch_x, _ in dataloader:  # _ = labels non utilisés en CPC
            batch_x = batch_x.to(device)

            # Forward pass
            c, z = model(batch_x)
            loss = model.info_nce_loss(c, z, n_negatives=8)

            # Backward pass
            optimizer.zero_grad()
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
            optimizer.step()

            epoch_loss += loss.item()
            n_batches += 1

        avg_loss = epoch_loss / max(n_batches, 1)
        print(f"Epoch {epoch+1}/{n_epochs} | Perte InfoNCE: {avg_loss:.4f}")

    return model


# ============================================================
# Évaluation en Aval — Classification
# ============================================================

class DownstreamClassifier(nn.Module):
    """Classificateur linéaire entraîné sur les
    caractéristiques CPC gelées (évaluation en aval)."""

    def __init__(self, latent_dim, n_classes):
        super().__init__()
        self.classifier = nn.Sequential(
            nn.Linear(latent_dim, 128),
            nn.ReLU(inplace=True),
            nn.Dropout(0.3),
            nn.Linear(128, 64),
            nn.ReLU(inplace=True),
            nn.Linear(64, n_classes),
        )

    def forward(self, features):
        # features: (batch, latent_dim) — Moyenne sur la dimension temporelle
        return self.classifier(features)


def evaluate_downstream(cpc_model, train_loader, test_loader,
                        n_classes=10, device='cuda'):
    """Gèle le CPC, extrait les features, entraîné un classificateur."""
    cpc_model.eval()
    cpc_model.to(device)

    # Extraction des caractéristiques
    def extract_features(loader):
        all_features, all_labels = [], []
        with torch.no_grad():
            for x, y in loader:
                x = x.to(device)
                c, z = cpc_model(x)
                # Moyenne globale sur le temps
                features = c.mean(dim=1)  # (batch, latent_dim)
                all_features.append(features.cpu())
                all_labels.append(y)
        return torch.cat(all_features), torch.cat(all_labels)

    train_feats, train_labels = extract_features(train_loader)
    test_feats, test_labels = extract_features(test_loader)

    # Entraînement du classificateur
    classifier = DownstreamClassifier(cpc_model.latent_dim, n_classes)
    optimizer = torch.optim.Adam(classifier.parameters(), lr=1e-3)
    criterion = nn.CrossEntropyLoss()

    train_dataset = TensorDataset(train_feats, train_labels)
    train_dl = DataLoader(train_dataset, batch_size=128, shuffle=True)

    for epoch in range(30):
        classifier.train()
        for feats, labels in train_dl:
            optimizer.zero_grad()
            logits = classifier(feats)
            loss = criterion(logits, labels)
            loss.backward()
            optimizer.step()

    # Évaluation
    classifier.eval()
    with torch.no_grad():
        test_logits = classifier(test_feats.to(device))
        preds = test_logits.argmax(dim=1)
        accuracy = (preds.cpu() == test_labels).float().mean().item()

    print(f"Précision en aval: {accuracy:.4f}")
    return accuracy


# ============================================================
# Exemple d'utilisation
# ============================================================

if __name__ == '__main__':
    torch.manual_seed(42)
    device = 'cuda' if torch.cuda.is_available() else 'cpu'

    # Données synthétiques: spectrogrammes simulés
    # (batch=32, canal=1, temps=256)
    dummy_audio = torch.randn(32, 1, 256)
    dummy_labels = torch.randint(0, 10, (32,))

    dataset = TensorDataset(dummy_audio, dummy_labels)
    dataloader = DataLoader(dataset, batch_size=32, shuffle=True)

    # Modèle CPC
    model = CPCModel(latent_dim=256, n_future_steps=12,
                     autoregressor_layers=3)
    optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

    print("Début de l'entranement CPC...")
    model = train_cpc(model, dataloader, optimizer,
                      n_epochs=10, device=device)
    print("Entranement terminé avec succès!")

Hyperparamètres Clés

Le choix des hyperparamètres influence considérablement la qualité des représentations apprises par le Contrastive Predictive Coding. Voici les paramètres les plus critiques et leur rôle :

latent_dim (256–512) — La dimension de l’espace latent détermine la capacité d’information du modèle. Trop faible (64), et le modèle perd des nuances importantes. Trop élevé (1024+), et le modèle risque de mémoriser des détails insignifiants au détriment de l’abstraction sémantique. Pour la plupart des applications audio et visuelles, 256 à 512 constitue un bon compromis entre expressivité et généralisation.

n_future_steps (6–12) — Le nombre de pas futurs prédits détermine l’horizon temporel du modèle. Un petit nombre (3-5) capture des dépendances locales et immédiates. Un grand nombre (12-24) force le modèle à comprendre la structure à long terme. En pratique, 12 pas fonctionnent bien pour l’audio, tandis que 6 suffisent pour les images où la séquentialité est moins prononcée.

n_negatives (4–128) — Le nombre d’échantillons négatifs dans la perte InfoNCE influence directement la qualité de l’apprentissage. Peu de négatifs (2-4) rendent la tâche trop facile — le modèle n’apprend pas à discriminer finement. Beaucoup de négatifs (64-256) rendent la tâche plus discriminative mais augmentent le coût computationnel. La littérature recommande généralement entre 8 et 128 négatifs selon les ressources disponibles.

autoregressor_layers (2–4) — Le nombre de couches du GRU (ou LSTM) contrôle la profondeur de la mémoire contextuelle. Deux couches sont suffisantes pour des séquences courtes. Trois à quatre couches sont nécessaires pour capturer des dépendances complexes et à long terme, par exemple dans la parole continue ou les séquences vidéo.

Taux d’apprentissage — Un taux initial de 1×10⁻³ avec un décroissance (schedule) ou un warmup progressif est recommandé. Le clipping de gradient (à 1,0) est essentiel pour stabiliser l’entraînement, car la perte InfoNCE peut produire des gradients importants au début.

Avantages et Limites du CPC

Avantages

  1. Aucune étiquette nécessaire — Le CPC est entièrement self-supervisé. Il apprend des représentations riches à partir de données brutes non annotées, ce qui est crucial dans les domaines où l’annotation manuelle est coûteuse ou impossible.
  2. Représentations transférables — Les caractéristiques apprises par le CPC se transfèrent remarquablement bien à des tâches en aval : classification, détection, segmentation. C’est l’un des modèles foundation model avant l’heure.
  3. Efficacité computationnelle relative — En travaillant dans l’espace latent plutôt que dans l’espace des données brutes, le CPC réduit considérablement la complexité de la tâche de prédiction par rapport à un décodeur reconstructionniste classique (comme un autoencodeur variationnel).
  4. Base théorique solide — La connexion entre la perte InfoNCE et la maximisation de l’information mutuelle donne au CPC des garanties théoriques que peu d’autres méthodes possèdent. On sait mathématiquement ce que le modèle optimise.
  5. Universalité — L’architecture CPC s’adapte à presque tous les types de données séquentielles : audio, vidéo, texte, séries temporelles médicales ou financières, signaux capteurs.

Limites

  1. Coût des négatifs — La perte InfoNCE nécessite un échantillonnage de distracteurs. Avec un grand nombre de négatifs, la consommation mémoire et le temps de calcul augmentent significativement. Les méthodes modernes comme SimCLR ont partiellement adressé ce problème en utilisant des augmentations de données comme source de négatifs.
  2. Effondrement du latent — Dans certaines configurations, le modèle peut apprendre un encodeur trivial qui produit toujours le même vecteur $z_t$, rendant la perte InfoNCE artificiellement faible. Des techniques de régularisation et d’augmentation de données sont nécessaires pour éviter ce phénomène.
  3. Sensibilité aux hyperparamètres — Le choix de latent_dim, n_future_steps et n_negatives influe fortement sur les performances. Un mauvais réglage peut produire des représentations de qualité médiocre, et la recherche systématique (grid search) est coûteuse.
  4. Pas de génération — Le CPC apprend des représentations discriminatives mais ne modélise pas explicitement la distribution des données. Contrairement aux VAEs ou aux GANs, il ne peut pas générer de nouvelles données à partir de bruit aléatoire.
  5. Dépendance à l’agrégation séquentielle — Le réseau autorégressif (GRU) limite le parallélisme pendant l’entraînement. Des variantes utilisant des Transformeurs ont été proposées pour résoudre ce problème, mais elles augmentent la complexité computationnelle.

4 Cas d’Usage Concrets du Contrastive Predictive Coding

1. Reconnaissance de la Parole (Speech Recognition)

Le CPC a révolutionné l’apprentissage de représentations audio. En s’entraînant sur des milliers d’heures de parole non étiquetée (LibriSpeech, CommonVoice), le modèle apprend des caractéristiques acoustiques riches qui capturent la phonétique, la prosodie et même certains aspects sémantiques. Ces représentations sont ensuite utilisées pour initialiser des modèles de reconnaissance de parole, réduisant le besoin de données annotées de manière drastique. Des études ont montré que le pré-entraînement CPC améliore le taux d’erreur de mots (WER) de 10 à 15 % sur des benchmarks comme LibriSpeech, surtout lorsque les données annotées sont limitées.

2. Classification d’Images par Patch Séquentiel

En traitant une image comme une séquence de patches (balayage de gauche à droite, comme un texte), le CPC peut apprendre des représentations visuelles puissantes. L’encodeur CNN produit des caractéristiques locales pour chaque patch, le GRU agrège le contexte spatial, et la prédiction contrastive force le modèle à comprendre la structure globale de l’image. Ces représentations se transfèrent efficacement à la classification d’images (ImageNet), à la détection d’objets, et à la segmentation sémantique. C’est l’ancêtre conceptuel de méthodes comme MAE (Masked Autoencoder) et DINO.

3. Analyse de Séries Temporelles Médicales

En médecine, les données temporelles abondent : électrocardiogrammes (ECG), électroencéphalogrammes (EEG), signaux de monitoring en réanimation. Le CPC peut s’entraîner sur ces signaux bruts pour apprendre des représentations qui capturent les motifs physiologiques normaux et anormaux. Un classificateur linéaire entraîné sur ces caractéristiques peut ensuite détecter des arythmies, des crises d’épilepsie, ou des signes avant-coureurs de sepsis — avec des performances supérieures aux méthodes traditionnelles, même avec peu de données annotées.

4. Compréhension du Langage Naturel

Bien que les Transformeurs aient dominé le NLP, le CPC offre une perspective intéressante pour l’apprentissage de représentations textuelles. En traitant une phrase ou un paragraphe comme une séquence, le modèle apprend à prédire les représentations des mots futurs à partir du contexte. Ces caractéristiques capturent la syntaxe et la sémantique locale, et peuvent être utilisées comme entrée pour des tâches en aval : analyse de sentiments, classification de textes, détection de langues. Le CPC est particulièrement utile pour les langues à faibles ressources où les modèles de type BERT ne sont pas disponibles.

Voir Aussi

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur la façon dont les données de vos commentaires sont traitées.