Cortex-0 / src /model /clip.py
Frankenstein-Labs's picture
feat(clip): Phase 5 — alignement image-texte
827713a verified
Raw History Blame Contribute Delete
5.36 kB
"""Modèle CLIP maison (Phase 5) : alignement image-texte from scratch.
Deux encodeurs (texte + image) produisent chacun un vecteur, projeté dans un espace
commun puis **normalisé L2**. La similarité est un produit scalaire. Aucun modèle
CLIP pré-entraîné n'est utilisé : encodeur image = `VisionEncoder` maison (Phase 4),
encodeur texte = transformer maison (Phase 2).
Justification : docs/architecture.md §4.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import torch
import torch.nn as nn
import torch.nn.functional as F
from src.model.projectors import MLPProjector
from src.model.text_model import CortexTextLM, TextConfig
from src.model.vit import VisionEncoder, ViTConfig
def _default_text_config() -> TextConfig:
return TextConfig(vocab_size=32000, n_layer=2, n_head=4, n_embd=128,
ctx_len=32, tie_embeddings=True)
def _default_vision_config() -> ViTConfig:
return ViTConfig(image_size=32, patch_size=8, n_layer=2, n_head=4, n_embd=128)
@dataclass
class ClipConfig:
embed_dim: int = 64 # dimension de l'espace commun (image/texte)
text: TextConfig = field(default_factory=_default_text_config)
vision: ViTConfig = field(default_factory=_default_vision_config)
init_temperature: float = 0.07
learn_temperature: bool = True
class TextEncoder(nn.Module):
"""Transformer texte (Phase 2) -> pooling masqué -> vecteur ``(B, n_embd)``."""
def __init__(self, config: TextConfig):
super().__init__()
self.backbone = CortexTextLM(config)
def forward(self, input_ids: torch.Tensor, attention_mask: torch.Tensor | None = None):
hidden = self.backbone.encode(input_ids) # (B, T, n_embd), avant la tête
if attention_mask is None:
return hidden.mean(dim=1)
mask = attention_mask.unsqueeze(-1).float()
return (hidden * mask).sum(1) / mask.sum(1).clamp(min=1e-6)
class CortexClip(nn.Module):
"""CLIP maison : encodeurs texte + image, projecteurs, `logit_scale` apprise."""
def __init__(self, config: ClipConfig):
super().__init__()
self.config = config
self.text_encoder = TextEncoder(config.text)
self.vision_encoder = VisionEncoder(config.vision)
self.text_proj = MLPProjector(config.text.n_embd, config.embed_dim)
self.image_proj = MLPProjector(config.vision.n_embd, config.embed_dim)
init_logit_scale = float(torch.log(torch.tensor(1.0 / config.init_temperature)))
if config.learn_temperature:
self.logit_scale = nn.Parameter(torch.tensor(init_logit_scale))
else:
self.register_buffer("logit_scale", torch.tensor(init_logit_scale))
def encode_text(self, input_ids, attention_mask=None):
pooled = self.text_proj(self.text_encoder(input_ids, attention_mask))
return F.normalize(pooled, dim=-1)
def encode_image(self, images):
feat = self.vision_encoder.pool(self.vision_encoder(images))
return F.normalize(self.image_proj(feat), dim=-1)
def forward(self, images, input_ids, attention_mask=None):
img = self.encode_image(images)
txt = self.encode_text(input_ids, attention_mask)
scale = self.logit_scale.exp().clamp(max=100.0)
logits_per_image = scale * img @ txt.t()
return logits_per_image, logits_per_image.t(), img, txt
def contrastive_loss(logits_per_image: torch.Tensor, logits_per_text: torch.Tensor) -> torch.Tensor:
"""Perte contrastive symétrique (écrite à la main).
La bonne paire est sur la diagonale : image i ↔ texte i.
"""
batch = logits_per_image.size(0)
targets = torch.arange(batch, device=logits_per_image.device)
return 0.5 * (F.cross_entropy(logits_per_image, targets)
+ F.cross_entropy(logits_per_text, targets))
def clip_loss(model: CortexClip, images, input_ids, attention_mask=None) -> torch.Tensor:
logits_per_image, logits_per_text, _, _ = model(images, input_ids, attention_mask)
return contrastive_loss(logits_per_image, logits_per_text)
@torch.no_grad()
def retrieval_accuracy(model: CortexClip, images, input_ids, attention_mask=None) -> float:
"""Précision de retrieval image->texte (diagonale) sur un lot **sans doublon**."""
img = model.encode_image(images)
txt = model.encode_text(input_ids, attention_mask)
preds = (img @ txt.t()).argmax(dim=-1)
targets = torch.arange(images.size(0), device=images.device)
return (preds == targets).float().mean().item()
@torch.no_grad()
def class_retrieval_accuracy(
model: CortexClip,
images: torch.Tensor,
bank_ids: torch.Tensor,
bank_mask: torch.Tensor,
label_ids: torch.Tensor,
) -> float:
"""Retrieval image->texte **au niveau des classes**.
``bank_ids``/``bank_mask`` décrivent la banque de légendes **uniques** (ici les
9 classes) ; ``label_ids[i]`` est l'indice de la vraie légende de l'image ``i``
dans cette banque. C'est la bonne mesure quand les légendes se répètent (une
légende partagée par 10 images ne peut pas être « la » bonne par un argmax
sur 90 colonnes).
"""
img = model.encode_image(images)
txt = model.encode_text(bank_ids, bank_mask)
preds = (img @ txt.t()).argmax(dim=-1)
return (preds == label_ids).float().mean().item()