Files
2026-07-13 12:09:03 +08:00

236 lines
7.5 KiB
Python

"""Two-layer MLP projection from vision-token space to text embedding space.
The vision encoder (lessons 58 and 59) stays frozen. A frozen mock text
embedding table provides target vectors for synthetic captions. Only the
projector trains. The objective is per-pair cosine alignment.
Run with: python3 main.py
"""
from __future__ import annotations
import importlib.util
import sys
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
THIS_DIR = Path(__file__).resolve().parent
LESSON_59 = THIS_DIR.parent.parent / "59-vit-transformer" / "code"
def _load_module(name: str, path: Path):
if name in sys.modules:
return sys.modules[name]
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise ImportError(f"could not load {path}")
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(name, None)
raise
return mod
_encoder_mod = _load_module("vit_encoder_lesson59", LESSON_59 / "main.py")
ViTConfig = _encoder_mod.ViTConfig
VisionEncoder = _encoder_mod.VisionEncoder
synthesize_image = _encoder_mod.synthesize_image
@dataclass(frozen=True)
class AlignConfig:
vision_hidden: int = 768
projection_hidden: int = 1024
text_hidden: int = 512
vocab_size: int = 4096
max_caption_len: int = 16
pairs: int = 32
steps: int = 200
lr: float = 3e-4
seed: int = 0
class MLPProjector(nn.Module):
"""Two-layer MLP, the canonical adapter shape used by LLaVA-style VLMs."""
def __init__(self, in_dim: int, hidden_dim: int, out_dim: int) -> None:
super().__init__()
self.fc1 = nn.Linear(in_dim, hidden_dim)
self.fc2 = nn.Linear(hidden_dim, out_dim)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.fc2(F.gelu(self.fc1(x)))
class MockTextEmbedding(nn.Module):
"""Frozen text table used as alignment targets.
Captions are sequences of token ids; the caption embedding is the mean of
the embedded ids. Deterministic given seed.
"""
def __init__(self, vocab_size: int, dim: int, seed: int) -> None:
super().__init__()
gen = torch.Generator().manual_seed(seed)
weight = torch.randn(vocab_size, dim, generator=gen) * (1.0 / dim ** 0.5)
self.table = nn.Embedding(vocab_size, dim, _weight=weight)
for p in self.table.parameters():
p.requires_grad_(False)
def forward(self, ids: torch.Tensor) -> torch.Tensor:
if ids.dim() != 2:
raise ValueError(f"expected (B, L) ids, got {tuple(ids.shape)}")
embed = self.table(ids)
mask = (ids != 0).float().unsqueeze(-1)
denom = mask.sum(dim=1).clamp(min=1.0)
pooled = (embed * mask).sum(dim=1) / denom
return pooled
def make_pair(seed: int, vocab_size: int, max_len: int) -> tuple[torch.Tensor, torch.Tensor]:
"""One synthetic (image, caption_ids) pair.
Image is the deterministic 224x224x3 fixture from lesson 58 with a per-pair
seed. Caption is a length-`max_len` sequence of token ids, again
deterministic in seed. Token id 0 is reserved as padding.
"""
img = synthesize_image(seed=seed)
rng = np.random.default_rng(seed + 10_000)
length = int(rng.integers(4, max_len + 1))
ids = np.zeros((max_len,), dtype=np.int64)
ids[:length] = rng.integers(1, vocab_size, size=length)
return img, torch.from_numpy(ids).unsqueeze(0)
def cosine_alignment_loss(image_emb: torch.Tensor, text_emb: torch.Tensor) -> torch.Tensor:
if image_emb.shape != text_emb.shape:
raise ValueError(
f"shape mismatch image {tuple(image_emb.shape)} vs text {tuple(text_emb.shape)}"
)
img_n = F.normalize(image_emb, dim=-1)
txt_n = F.normalize(text_emb, dim=-1)
cos = (img_n * txt_n).sum(dim=-1)
return (1.0 - cos).mean()
def freeze(module: nn.Module) -> None:
for p in module.parameters():
p.requires_grad_(False)
@dataclass
class TrainStats:
initial_loss: float
final_loss: float
final_cos: float
losses: list[float]
def train(cfg: AlignConfig) -> tuple[MLPProjector, TrainStats]:
if cfg.pairs <= 0:
raise ValueError(f"pairs must be > 0, got {cfg.pairs}")
if cfg.steps <= 0:
raise ValueError(f"steps must be > 0, got {cfg.steps}")
if cfg.max_caption_len < 4:
raise ValueError(
f"max_caption_len must be >= 4 for make_pair(), got {cfg.max_caption_len}"
)
torch.manual_seed(cfg.seed)
encoder_cfg = ViTConfig(image_size=224, patch_size=16, hidden=cfg.vision_hidden,
depth=4, heads=8, mlp_ratio=2.0)
encoder = VisionEncoder(encoder_cfg).eval()
freeze(encoder)
text = MockTextEmbedding(cfg.vocab_size, cfg.text_hidden, seed=cfg.seed + 1)
freeze(text)
projector = MLPProjector(cfg.vision_hidden, cfg.projection_hidden, cfg.text_hidden)
pairs = [make_pair(seed=cfg.seed + 1000 + i,
vocab_size=cfg.vocab_size,
max_len=cfg.max_caption_len) for i in range(cfg.pairs)]
opt = torch.optim.Adam(projector.parameters(), lr=cfg.lr)
losses: list[float] = []
initial_loss = 0.0
final_loss = 0.0
final_cos = 0.0
for step in range(cfg.steps):
img, ids = pairs[step % cfg.pairs]
with torch.no_grad():
_, cls = encoder(img)
text_emb = text(ids)
image_emb = projector(cls)
loss = cosine_alignment_loss(image_emb, text_emb)
opt.zero_grad(set_to_none=True)
loss.backward()
opt.step()
losses.append(loss.item())
if step == 0:
initial_loss = loss.item()
if step % 25 == 0 or step == cfg.steps - 1:
with torch.no_grad():
cos = F.cosine_similarity(image_emb, text_emb).mean().item()
print(f" step {step:4d} loss {loss.item():.4f} cos {cos:+.4f}")
if step == cfg.steps - 1:
final_loss = loss.item()
with torch.no_grad():
final_cos = F.cosine_similarity(image_emb, text_emb).mean().item()
return projector, TrainStats(
initial_loss=initial_loss,
final_loss=final_loss,
final_cos=final_cos,
losses=losses,
)
def main() -> None:
print("=" * 60)
print("PROJECTION LAYER FOR MODALITY ALIGNMENT")
print("=" * 60)
cfg = AlignConfig()
print(f" vision hidden : {cfg.vision_hidden}")
print(f" projection hidden : {cfg.projection_hidden}")
print(f" text hidden : {cfg.text_hidden}")
print(f" vocab size : {cfg.vocab_size}")
print(f" pairs : {cfg.pairs}")
print(f" steps : {cfg.steps}")
print(f" learning rate : {cfg.lr}")
print("\ntraining (vision encoder frozen, text table frozen, projector trains):")
projector, stats = train(cfg)
n_proj = sum(p.numel() for p in projector.parameters())
print(f"\nprojector params : {n_proj:,}")
print(f"initial loss : {stats.initial_loss:.4f}")
print(f"final loss : {stats.final_loss:.4f}")
print(f"final cosine sim : {stats.final_cos:+.4f}")
drop = stats.initial_loss - stats.final_loss
print(f"loss drop : {drop:.4f}")
if drop > 0.0:
print(" ok: projector learned an alignment direction")
else:
print(" FAIL: loss did not decrease")
print("\ndone.")
if __name__ == "__main__":
main()