Escarda-VE: JEPA+HRM vision encoder + Byrne-VE comparison
Browse files- README.md +38 -0
- escarda_ve.pt +3 -0
- requirements.txt +4 -0
- vision/__init__.py +3 -0
- vision/dataset.py +89 -0
- vision/eval_probe.py +81 -0
- vision/infer.py +39 -0
- vision/model.py +392 -0
README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
license: apache-2.0
|
| 3 |
+
tags:
|
| 4 |
+
- vision-encoder
|
| 5 |
+
- jepa
|
| 6 |
+
- hrm
|
| 7 |
+
- dinov2-distillation
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Escarda-VE
|
| 11 |
+
|
| 12 |
+
**Escarda-VE** is the vision encoder of the **Escarda** model family (~39.6M params,
|
| 13 |
+
448px / patch16 / 784 tokens). It is the **Byrne-VE** architecture **plus a JEPA head
|
| 14 |
+
added alongside the HRM refinement block** — Escarda's defining family trait.
|
| 15 |
+
|
| 16 |
+
- ViT-style: RMSNorm, 2D-axial RoPE, QK-Norm, SwiGLU, HRM refinement.
|
| 17 |
+
- **JEPA head** (auxiliary): from a patch token it predicts the raster-order neighbour
|
| 18 |
+
patch's own representation (stop-gradient target, 1−cosine loss). **Zero inference cost.**
|
| 19 |
+
- Trained by distillation from a frozen **DINOv2-base** teacher (50k steps, CLS + patch),
|
| 20 |
+
then 20k steps of DINO-style **self-distillation** (EMA teacher, cosine prototypes, no collapse).
|
| 21 |
+
|
| 22 |
+
## Escarda-VE vs Byrne-VE (DINOv2 teacher-alignment, n=1024 held-out)
|
| 23 |
+
|
| 24 |
+
| | Byrne-VE | Escarda-VE |
|
| 25 |
+
|---|---|---|
|
| 26 |
+
| Params | 39.34M | 39.60M (+JEPA head) |
|
| 27 |
+
| CLS cosine | **0.776** | 0.771 |
|
| 28 |
+
| PATCH cosine | **0.600** | 0.584 |
|
| 29 |
+
| JEPA self-consistency | — | 0.040 |
|
| 30 |
+
|
| 31 |
+
**Read:** Escarda trades ~1–3% teacher-alignment (the JEPA objective diverts a little
|
| 32 |
+
capacity away from pure supervised mimicry) in exchange for a self-supervised spatial
|
| 33 |
+
neighbour-prediction signal that costs nothing at inference. Same size class as Byrne-VE.
|
| 34 |
+
|
| 35 |
+
## Related
|
| 36 |
+
- [Byrne-VE](https://huggingface.co/Quazim0t0/Byrne-VE) — HRM-only sibling
|
| 37 |
+
- [Escarda-Docling-126M](https://huggingface.co/Quazim0t0/Escarda-Docling-126M) — document VLM built on this encoder
|
| 38 |
+
- [Escarda-86M-Base](https://huggingface.co/Quazim0t0/Escarda-86M-Base)
|
escarda_ve.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:056e8ba04e6f4727a340eccf1950a86a0424759f07a94af8dd256654590db113
|
| 3 |
+
size 158447229
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch
|
| 2 |
+
numpy
|
| 3 |
+
pillow
|
| 4 |
+
datasets
|
vision/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .model import VisionEncoder, VisionConfig
|
| 2 |
+
|
| 3 |
+
__all__ = ["VisionEncoder", "VisionConfig"]
|
vision/dataset.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Image inputs for distillation. PIL-only (no torchvision dependency).
|
| 2 |
+
|
| 3 |
+
Two sources, same preprocessing (resize shorter side to image_size+32, center-crop,
|
| 4 |
+
ImageNet-normalize — what DINOv2 expects):
|
| 5 |
+
- ImageFolder: a local folder of images, searched recursively.
|
| 6 |
+
- HFStreamImages: an HF streaming dataset (e.g. timm/imagenet-1k-wds) — shards are
|
| 7 |
+
pulled on the fly, nothing is stored on disk.
|
| 8 |
+
"""
|
| 9 |
+
import os
|
| 10 |
+
import glob
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import torch
|
| 15 |
+
from PIL import Image
|
| 16 |
+
from torch.utils.data import Dataset, IterableDataset
|
| 17 |
+
|
| 18 |
+
_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 19 |
+
_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 20 |
+
_EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tif", ".tiff")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def preprocess(img, size=224):
|
| 24 |
+
"""PIL.Image -> normalized [3,H,W] float tensor."""
|
| 25 |
+
img = img.convert("RGB")
|
| 26 |
+
w, h = img.size
|
| 27 |
+
s = size + 32
|
| 28 |
+
nw, nh = (s, round(h * s / w)) if w < h else (round(w * s / h), s)
|
| 29 |
+
img = img.resize((nw, nh), Image.BICUBIC)
|
| 30 |
+
left, top = (nw - size) // 2, (nh - size) // 2
|
| 31 |
+
img = img.crop((left, top, left + size, top + size))
|
| 32 |
+
arr = (np.asarray(img, dtype=np.float32) / 255.0 - _MEAN) / _STD # [H,W,3]
|
| 33 |
+
return torch.from_numpy(arr).permute(2, 0, 1) # [3,H,W]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def load_image(path, size=224):
|
| 37 |
+
return preprocess(Image.open(path), size)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class ImageFolder(Dataset):
|
| 41 |
+
def __init__(self, root, image_size=224):
|
| 42 |
+
self.size = image_size
|
| 43 |
+
self.files = sorted(p for p in glob.glob(os.path.join(root, "**", "*"), recursive=True)
|
| 44 |
+
if p.lower().endswith(_EXTS))
|
| 45 |
+
if not self.files:
|
| 46 |
+
raise FileNotFoundError(f"no images found under {root}")
|
| 47 |
+
|
| 48 |
+
def __len__(self):
|
| 49 |
+
return len(self.files)
|
| 50 |
+
|
| 51 |
+
def __getitem__(self, i):
|
| 52 |
+
try:
|
| 53 |
+
return load_image(self.files[i], self.size)
|
| 54 |
+
except Exception: # skip a corrupt file
|
| 55 |
+
return self.__getitem__(random.randrange(len(self.files)))
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _find_image(example, key=None):
|
| 59 |
+
"""Pull the PIL image out of an HF example dict (auto-detect if key not given)."""
|
| 60 |
+
if key is not None:
|
| 61 |
+
return example[key]
|
| 62 |
+
for v in example.values():
|
| 63 |
+
if isinstance(v, Image.Image):
|
| 64 |
+
return v
|
| 65 |
+
raise KeyError("no PIL image column found; pass --image-key")
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class HFStreamImages(IterableDataset):
|
| 69 |
+
"""Streams images from a Hugging Face dataset (no full download).
|
| 70 |
+
|
| 71 |
+
e.g. name='timm/imagenet-1k-wds', split='train'. Reshuffles shard/sample order
|
| 72 |
+
each epoch with a bounded buffer so DataLoader can keep shuffle=False.
|
| 73 |
+
"""
|
| 74 |
+
def __init__(self, name, split="train", image_size=224, config=None,
|
| 75 |
+
image_key=None, shuffle_buffer=2000, seed=0):
|
| 76 |
+
from datasets import load_dataset
|
| 77 |
+
self.size = image_size
|
| 78 |
+
self.image_key = image_key
|
| 79 |
+
ds = load_dataset(name, config, split=split, streaming=True)
|
| 80 |
+
if shuffle_buffer:
|
| 81 |
+
ds = ds.shuffle(seed=seed, buffer_size=shuffle_buffer)
|
| 82 |
+
self.ds = ds
|
| 83 |
+
|
| 84 |
+
def __iter__(self):
|
| 85 |
+
for ex in self.ds:
|
| 86 |
+
try:
|
| 87 |
+
yield preprocess(_find_image(ex, self.image_key), self.size)
|
| 88 |
+
except Exception: # skip a corrupt/odd sample
|
| 89 |
+
continue
|
vision/eval_probe.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Downstream probe: weighted k-NN classification accuracy of a frozen encoder.
|
| 2 |
+
|
| 3 |
+
A label-free distillation/self-distillation objective tells you how close two
|
| 4 |
+
feature spaces are, but not whether the features are *useful*. This measures that
|
| 5 |
+
directly: freeze the encoder, embed a labelled set, and classify by weighted k-NN
|
| 6 |
+
in feature space (no training, no learned head). Use it to compare checkpoints —
|
| 7 |
+
e.g. the teacher-distilled `vision_last.pt` vs the self-distilled
|
| 8 |
+
`vision_self_last.pt` — under identical preprocessing.
|
| 9 |
+
|
| 10 |
+
python -m vision.eval_probe --ckpt checkpoints/vision_last.pt
|
| 11 |
+
python -m vision.eval_probe --ckpt checkpoints/vision_self_last.pt
|
| 12 |
+
|
| 13 |
+
CIFAR-10 (10 classes, ungated, ~170 MB) is the default probe set: small and fast,
|
| 14 |
+
and since both checkpoints get the exact same pipeline the *relative* number is a
|
| 15 |
+
fair read even though CIFAR is out-of-domain for an ImageNet encoder.
|
| 16 |
+
"""
|
| 17 |
+
import argparse
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import torch
|
| 21 |
+
import torch.nn.functional as F
|
| 22 |
+
|
| 23 |
+
from .infer import load_encoder
|
| 24 |
+
from .dataset import preprocess
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@torch.no_grad()
|
| 28 |
+
def embed(model, cfg, images, device, bs=128):
|
| 29 |
+
feats = []
|
| 30 |
+
for i in range(0, len(images), bs):
|
| 31 |
+
batch = torch.stack([preprocess(im, cfg.image_size) for im in images[i:i + bs]]).to(device)
|
| 32 |
+
feats.append(F.normalize(model(batch)["pooled"], dim=-1).cpu())
|
| 33 |
+
return torch.cat(feats)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def knn_accuracy(bank, bank_y, query, query_y, k=20, T=0.07, n_classes=10):
|
| 37 |
+
sim = query @ bank.T # cosine (features L2-normed)
|
| 38 |
+
topk_sim, topk_idx = sim.topk(min(k, bank.shape[0]), dim=1)
|
| 39 |
+
topk_y = bank_y[topk_idx] # (Q, k)
|
| 40 |
+
w = (topk_sim / T).exp() # distance weighting
|
| 41 |
+
votes = torch.zeros(query.shape[0], n_classes)
|
| 42 |
+
votes.scatter_add_(1, topk_y, w)
|
| 43 |
+
pred = votes.argmax(1)
|
| 44 |
+
return (pred == query_y).float().mean().item()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main():
|
| 48 |
+
ap = argparse.ArgumentParser()
|
| 49 |
+
ap.add_argument("--ckpt", required=True)
|
| 50 |
+
ap.add_argument("--dataset", default="uoft-cs/cifar10")
|
| 51 |
+
ap.add_argument("--n-bank", type=int, default=5000)
|
| 52 |
+
ap.add_argument("--n-query", type=int, default=1000)
|
| 53 |
+
ap.add_argument("--k", type=int, default=20)
|
| 54 |
+
ap.add_argument("--seed", type=int, default=0)
|
| 55 |
+
args = ap.parse_args()
|
| 56 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 57 |
+
|
| 58 |
+
from datasets import load_dataset
|
| 59 |
+
ds = load_dataset(args.dataset)
|
| 60 |
+
train, test = ds["train"], ds["test"]
|
| 61 |
+
img_key = "img" if "img" in train.column_names else "image"
|
| 62 |
+
lab_key = "label" if "label" in train.column_names else "fine_label"
|
| 63 |
+
n_classes = train.features[lab_key].num_classes
|
| 64 |
+
|
| 65 |
+
train = train.shuffle(seed=args.seed).select(range(args.n_bank))
|
| 66 |
+
test = test.shuffle(seed=args.seed).select(range(args.n_query))
|
| 67 |
+
model, cfg = load_encoder(args.ckpt, device)
|
| 68 |
+
|
| 69 |
+
bank = embed(model, cfg, train[img_key], device)
|
| 70 |
+
bank_y = torch.tensor(train[lab_key])
|
| 71 |
+
query = embed(model, cfg, test[img_key], device)
|
| 72 |
+
query_y = torch.tensor(test[lab_key])
|
| 73 |
+
acc = knn_accuracy(bank, bank_y, query, query_y, k=args.k, n_classes=n_classes)
|
| 74 |
+
|
| 75 |
+
chance = 1.0 / n_classes
|
| 76 |
+
print(f"{args.ckpt}: k-NN(k={args.k}) top-1 = {acc*100:.2f}% "
|
| 77 |
+
f"({args.n_bank} bank / {args.n_query} query, {n_classes}-way, chance {chance*100:.0f}%)")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
main()
|
vision/infer.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Encode an image with the distilled VisionEncoder.
|
| 2 |
+
|
| 3 |
+
python -m vision.infer --ckpt checkpoints/vision_last.pt --image photo.jpg
|
| 4 |
+
"""
|
| 5 |
+
import argparse
|
| 6 |
+
|
| 7 |
+
import torch
|
| 8 |
+
|
| 9 |
+
from .model import VisionEncoder, VisionConfig
|
| 10 |
+
from .dataset import load_image
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def load_encoder(ckpt="checkpoints/vision_last.pt", device="cpu"):
|
| 14 |
+
ck = torch.load(ckpt, map_location=device)
|
| 15 |
+
cfg = VisionConfig(**ck["cfg"]) if "cfg" in ck else VisionConfig()
|
| 16 |
+
m = VisionEncoder(cfg).to(device)
|
| 17 |
+
m.load_state_dict(ck["student"])
|
| 18 |
+
m.eval()
|
| 19 |
+
return m, cfg
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
ap = argparse.ArgumentParser()
|
| 24 |
+
ap.add_argument("--ckpt", default="checkpoints/vision_last.pt")
|
| 25 |
+
ap.add_argument("--image", required=True)
|
| 26 |
+
ap.add_argument("--out", default="feature.pt")
|
| 27 |
+
args = ap.parse_args()
|
| 28 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 29 |
+
m, cfg = load_encoder(args.ckpt, device)
|
| 30 |
+
x = load_image(args.image, cfg.image_size).unsqueeze(0).to(device)
|
| 31 |
+
with torch.no_grad():
|
| 32 |
+
out = m(x)
|
| 33 |
+
print(f"pooled feature {tuple(out['pooled'].shape)} | {out['tokens'].shape[1]} patch tokens")
|
| 34 |
+
torch.save(out["pooled"][0].cpu(), args.out)
|
| 35 |
+
print(f"saved {args.out}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
main()
|
vision/model.py
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny Vision Encoder (~40M params) — from scratch in PyTorch.
|
| 2 |
+
|
| 3 |
+
A ViT-style image encoder that shares the SpikeWhale/Inflect family DNA used by
|
| 4 |
+
the rest of this repo (see reference/model_v2.py):
|
| 5 |
+
|
| 6 |
+
- RMSNorm everywhere (pre-norm blocks + QK-Norm)
|
| 7 |
+
- 2D axial RoPE on the patch grid (replaces learned/sinusoidal position embeds)
|
| 8 |
+
- QK-Norm attention (Gemma2/OLMo2-style logit stability)
|
| 9 |
+
- SwiGLU FFN (silu(gate) * up -> down)
|
| 10 |
+
|
| 11 |
+
It is non-causal (full bidirectional attention) and emits both a pooled CLS
|
| 12 |
+
embedding and the full sequence of patch tokens, so it can feed a classifier
|
| 13 |
+
head, a contrastive projection, or a multimodal decoder.
|
| 14 |
+
|
| 15 |
+
Default config (`VisionConfig()`) targets ~40M parameters; run this file
|
| 16 |
+
directly to print the exact count.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import math
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
import torch.nn as nn
|
| 26 |
+
import torch.nn.functional as F
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class VisionConfig:
|
| 31 |
+
image_size: int = 224
|
| 32 |
+
patch_size: int = 16
|
| 33 |
+
in_channels: int = 3
|
| 34 |
+
hidden_size: int = 512
|
| 35 |
+
num_layers: int = 12
|
| 36 |
+
num_heads: int = 8
|
| 37 |
+
ffn_mult: float = 8 / 3 # SwiGLU effective expansion
|
| 38 |
+
ffn_multiple_of: int = 64 # round ffn dim up to this
|
| 39 |
+
rms_norm_eps: float = 1e-6
|
| 40 |
+
rope_theta: float = 10000.0
|
| 41 |
+
use_qk_norm: bool = True
|
| 42 |
+
use_cls_token: bool = True
|
| 43 |
+
drop_path: float = 0.0 # stochastic depth rate (linearly scaled)
|
| 44 |
+
use_hrm_refine: bool = True # family HRM refinement before output norm
|
| 45 |
+
hrm_refine_dim: int = 256
|
| 46 |
+
hrm_refine_steps: int = 2
|
| 47 |
+
# Escarda family trait: JEPA auxiliary head ALONGSIDE HRM (both active). Predicts the
|
| 48 |
+
# trunk's own neighbouring patch representation (detached target) in embedding space.
|
| 49 |
+
use_jepa: bool = False # Byrne = False; Escarda = True
|
| 50 |
+
jepa_pred_dim: int = 256
|
| 51 |
+
jepa_horizon: int = 1
|
| 52 |
+
|
| 53 |
+
@property
|
| 54 |
+
def grid_size(self) -> int:
|
| 55 |
+
assert self.image_size % self.patch_size == 0, "image_size must divide by patch_size"
|
| 56 |
+
return self.image_size // self.patch_size
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def num_patches(self) -> int:
|
| 60 |
+
return self.grid_size ** 2
|
| 61 |
+
|
| 62 |
+
@property
|
| 63 |
+
def head_dim(self) -> int:
|
| 64 |
+
assert self.hidden_size % self.num_heads == 0, "hidden_size must divide by num_heads"
|
| 65 |
+
return self.hidden_size // self.num_heads
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class RMSNorm(nn.Module):
|
| 69 |
+
def __init__(self, dim: int, eps: float = 1e-6):
|
| 70 |
+
super().__init__()
|
| 71 |
+
self.eps = eps
|
| 72 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 73 |
+
|
| 74 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 75 |
+
dtype = x.dtype
|
| 76 |
+
x = x.float()
|
| 77 |
+
x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
|
| 78 |
+
return (x * self.weight).to(dtype)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_axial_rope(grid_size: int, head_dim: int, theta: float, device, dtype):
|
| 82 |
+
"""2D axial RoPE cos/sin for a (grid_size x grid_size) patch grid.
|
| 83 |
+
|
| 84 |
+
Half of head_dim encodes the row coordinate, the other half the column.
|
| 85 |
+
Returns cos, sin each of shape (num_patches, head_dim).
|
| 86 |
+
"""
|
| 87 |
+
assert head_dim % 4 == 0, "head_dim must be divisible by 4 for 2D axial RoPE"
|
| 88 |
+
quarter = head_dim // 4
|
| 89 |
+
inv_freq = 1.0 / (theta ** (torch.arange(0, quarter, device=device).float() / quarter))
|
| 90 |
+
|
| 91 |
+
coords = torch.arange(grid_size, device=device).float()
|
| 92 |
+
rows = coords.view(-1, 1).expand(grid_size, grid_size).reshape(-1) # (P,)
|
| 93 |
+
cols = coords.view(1, -1).expand(grid_size, grid_size).reshape(-1) # (P,)
|
| 94 |
+
|
| 95 |
+
row_ang = torch.outer(rows, inv_freq) # (P, quarter)
|
| 96 |
+
col_ang = torch.outer(cols, inv_freq) # (P, quarter)
|
| 97 |
+
ang = torch.cat([row_ang, col_ang], dim=-1) # (P, head_dim//2)
|
| 98 |
+
emb = torch.cat([ang, ang], dim=-1) # (P, head_dim)
|
| 99 |
+
return emb.cos().to(dtype), emb.sin().to(dtype)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def rotate_half(x: torch.Tensor) -> torch.Tensor:
|
| 103 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 104 |
+
return torch.cat([-x2, x1], dim=-1)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def apply_rope(x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor:
|
| 108 |
+
# x: (B, H, P, D), cos/sin: (P, D)
|
| 109 |
+
cos = cos.unsqueeze(0).unsqueeze(0)
|
| 110 |
+
sin = sin.unsqueeze(0).unsqueeze(0)
|
| 111 |
+
return x * cos + rotate_half(x) * sin
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
class Attention(nn.Module):
|
| 115 |
+
def __init__(self, cfg: VisionConfig):
|
| 116 |
+
super().__init__()
|
| 117 |
+
self.num_heads = cfg.num_heads
|
| 118 |
+
self.head_dim = cfg.head_dim
|
| 119 |
+
self.use_qk_norm = cfg.use_qk_norm
|
| 120 |
+
self.qkv = nn.Linear(cfg.hidden_size, 3 * cfg.hidden_size, bias=False)
|
| 121 |
+
self.proj = nn.Linear(cfg.hidden_size, cfg.hidden_size, bias=False)
|
| 122 |
+
if cfg.use_qk_norm:
|
| 123 |
+
self.q_norm = RMSNorm(self.head_dim, cfg.rms_norm_eps)
|
| 124 |
+
self.k_norm = RMSNorm(self.head_dim, cfg.rms_norm_eps)
|
| 125 |
+
|
| 126 |
+
def forward(self, x: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor,
|
| 127 |
+
rope_offset: int) -> torch.Tensor:
|
| 128 |
+
B, N, C = x.shape
|
| 129 |
+
qkv = self.qkv(x).view(B, N, 3, self.num_heads, self.head_dim)
|
| 130 |
+
q, k, v = qkv.unbind(dim=2)
|
| 131 |
+
q = q.transpose(1, 2) # (B, H, N, D)
|
| 132 |
+
k = k.transpose(1, 2)
|
| 133 |
+
v = v.transpose(1, 2)
|
| 134 |
+
|
| 135 |
+
if self.use_qk_norm:
|
| 136 |
+
q = self.q_norm(q)
|
| 137 |
+
k = self.k_norm(k)
|
| 138 |
+
|
| 139 |
+
# RoPE only on patch tokens; the leading CLS token (offset) is left untouched.
|
| 140 |
+
if rope_offset:
|
| 141 |
+
q_cls, q_pat = q[:, :, :rope_offset], q[:, :, rope_offset:]
|
| 142 |
+
k_cls, k_pat = k[:, :, :rope_offset], k[:, :, rope_offset:]
|
| 143 |
+
q_pat = apply_rope(q_pat, cos, sin)
|
| 144 |
+
k_pat = apply_rope(k_pat, cos, sin)
|
| 145 |
+
q = torch.cat([q_cls, q_pat], dim=2)
|
| 146 |
+
k = torch.cat([k_cls, k_pat], dim=2)
|
| 147 |
+
else:
|
| 148 |
+
q = apply_rope(q, cos, sin)
|
| 149 |
+
k = apply_rope(k, cos, sin)
|
| 150 |
+
|
| 151 |
+
out = F.scaled_dot_product_attention(q, k, v) # full bidirectional attention
|
| 152 |
+
out = out.transpose(1, 2).reshape(B, N, C)
|
| 153 |
+
return self.proj(out)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
class SwiGLU(nn.Module):
|
| 157 |
+
def __init__(self, cfg: VisionConfig):
|
| 158 |
+
super().__init__()
|
| 159 |
+
hidden = int(cfg.ffn_mult * cfg.hidden_size)
|
| 160 |
+
hidden = cfg.ffn_multiple_of * math.ceil(hidden / cfg.ffn_multiple_of)
|
| 161 |
+
self.gate = nn.Linear(cfg.hidden_size, hidden, bias=False)
|
| 162 |
+
self.up = nn.Linear(cfg.hidden_size, hidden, bias=False)
|
| 163 |
+
self.down = nn.Linear(hidden, cfg.hidden_size, bias=False)
|
| 164 |
+
|
| 165 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 166 |
+
return self.down(F.silu(self.gate(x)) * self.up(x))
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
class DropPath(nn.Module):
|
| 170 |
+
"""Per-sample stochastic depth on the residual branch."""
|
| 171 |
+
|
| 172 |
+
def __init__(self, p: float):
|
| 173 |
+
super().__init__()
|
| 174 |
+
self.p = p
|
| 175 |
+
|
| 176 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 177 |
+
if self.p == 0.0 or not self.training:
|
| 178 |
+
return x
|
| 179 |
+
keep = 1 - self.p
|
| 180 |
+
shape = (x.shape[0],) + (1,) * (x.ndim - 1)
|
| 181 |
+
mask = torch.empty(shape, dtype=x.dtype, device=x.device).bernoulli_(keep)
|
| 182 |
+
return x * mask / keep
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
class Block(nn.Module):
|
| 186 |
+
def __init__(self, cfg: VisionConfig, drop_path: float):
|
| 187 |
+
super().__init__()
|
| 188 |
+
self.attn_norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
|
| 189 |
+
self.attn = Attention(cfg)
|
| 190 |
+
self.ffn_norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
|
| 191 |
+
self.ffn = SwiGLU(cfg)
|
| 192 |
+
self.drop_path = DropPath(drop_path)
|
| 193 |
+
|
| 194 |
+
def forward(self, x, cos, sin, rope_offset):
|
| 195 |
+
x = x + self.drop_path(self.attn(self.attn_norm(x), cos, sin, rope_offset))
|
| 196 |
+
x = x + self.drop_path(self.ffn(self.ffn_norm(x)))
|
| 197 |
+
return x
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class HRMRefinementBlock(nn.Module):
|
| 201 |
+
"""Iterative zero-init-gated refinement (ported from reference/model_v2.py).
|
| 202 |
+
|
| 203 |
+
Polishes the final token sequence; each step re-injects the pre-refinement
|
| 204 |
+
anchor. Gates init at zero (tanh(0)=0) so it is a safe no-op at start.
|
| 205 |
+
"""
|
| 206 |
+
|
| 207 |
+
def __init__(self, hidden_size: int, refine_dim: int, steps: int, eps: float = 1e-6):
|
| 208 |
+
super().__init__()
|
| 209 |
+
self.steps = steps
|
| 210 |
+
self.norm = RMSNorm(hidden_size, eps)
|
| 211 |
+
self.down = nn.Linear(hidden_size * 2, refine_dim, bias=False)
|
| 212 |
+
self.up = nn.Linear(refine_dim, hidden_size, bias=False)
|
| 213 |
+
self.gate = nn.Parameter(torch.zeros(steps))
|
| 214 |
+
nn.init.normal_(self.down.weight, std=0.02)
|
| 215 |
+
# up was zero-init too -> with gate=0 AND up=0 neither gets gradient (dead block).
|
| 216 |
+
# Zero ONLY the scalar gate; a small non-zero up lets the gate receive gradient and
|
| 217 |
+
# wake up, while the output is still ~0 at init (tanh(0)=0) so it's a safe no-op.
|
| 218 |
+
nn.init.normal_(self.up.weight, std=0.02)
|
| 219 |
+
|
| 220 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 221 |
+
anchor = x
|
| 222 |
+
h = x
|
| 223 |
+
for t in range(self.steps):
|
| 224 |
+
inp = torch.cat([self.norm(h), anchor], dim=-1)
|
| 225 |
+
update = self.up(F.silu(self.down(inp)))
|
| 226 |
+
h = h + torch.tanh(self.gate[t]) * update
|
| 227 |
+
return h
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
class JEPAPredictorBlock(nn.Module):
|
| 231 |
+
"""Escarda JEPA head (ported from the Escarda LM's JEPAPredictorBlock, shaped like
|
| 232 |
+
HRMRefinementBlock). Runs ALONGSIDE the HRM block (not replacing it). From patch
|
| 233 |
+
token t it predicts the trunk's own token t+k in *representation* space; the target
|
| 234 |
+
is stop-gradient (detached) — the JEPA asymmetry that prevents collapse. Auxiliary
|
| 235 |
+
only: used in the training loss, never in the forward output, so inference cost is 0.
|
| 236 |
+
|
| 237 |
+
gate starts at zero (identity at init, zero-risk); up is normal-init (not zero) so the
|
| 238 |
+
gate receives gradient — same double-zero-saddle fix as the HRM block.
|
| 239 |
+
"""
|
| 240 |
+
|
| 241 |
+
def __init__(self, hidden_size: int, pred_dim: int, horizon: int, eps: float = 1e-6):
|
| 242 |
+
super().__init__()
|
| 243 |
+
self.horizon = horizon
|
| 244 |
+
self.norm = RMSNorm(hidden_size, eps)
|
| 245 |
+
self.down = nn.Linear(hidden_size, pred_dim, bias=False)
|
| 246 |
+
self.up = nn.Linear(pred_dim, hidden_size, bias=False)
|
| 247 |
+
self.gate = nn.Parameter(torch.zeros(horizon))
|
| 248 |
+
nn.init.normal_(self.down.weight, std=0.02)
|
| 249 |
+
nn.init.normal_(self.up.weight, std=0.02)
|
| 250 |
+
|
| 251 |
+
def forward(self, h: torch.Tensor, k: int) -> torch.Tensor:
|
| 252 |
+
update = self.up(F.silu(self.down(self.norm(h))))
|
| 253 |
+
return h + torch.tanh(self.gate[k - 1]) * update
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class PatchEmbed(nn.Module):
|
| 257 |
+
def __init__(self, cfg: VisionConfig):
|
| 258 |
+
super().__init__()
|
| 259 |
+
self.proj = nn.Conv2d(cfg.in_channels, cfg.hidden_size,
|
| 260 |
+
kernel_size=cfg.patch_size, stride=cfg.patch_size)
|
| 261 |
+
|
| 262 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 263 |
+
x = self.proj(x) # (B, C, gh, gw)
|
| 264 |
+
return x.flatten(2).transpose(1, 2) # (B, P, C)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class VisionEncoder(nn.Module):
|
| 268 |
+
def __init__(self, cfg: VisionConfig | None = None):
|
| 269 |
+
super().__init__()
|
| 270 |
+
self.cfg = cfg or VisionConfig()
|
| 271 |
+
cfg = self.cfg
|
| 272 |
+
|
| 273 |
+
self.patch_embed = PatchEmbed(cfg)
|
| 274 |
+
self.rope_offset = 1 if cfg.use_cls_token else 0
|
| 275 |
+
if cfg.use_cls_token:
|
| 276 |
+
self.cls_token = nn.Parameter(torch.zeros(1, 1, cfg.hidden_size))
|
| 277 |
+
|
| 278 |
+
dpr = [cfg.drop_path * i / max(cfg.num_layers - 1, 1) for i in range(cfg.num_layers)]
|
| 279 |
+
self.blocks = nn.ModuleList([Block(cfg, dpr[i]) for i in range(cfg.num_layers)])
|
| 280 |
+
self.hrm_refine = (
|
| 281 |
+
HRMRefinementBlock(cfg.hidden_size, cfg.hrm_refine_dim,
|
| 282 |
+
cfg.hrm_refine_steps, cfg.rms_norm_eps)
|
| 283 |
+
if cfg.use_hrm_refine else None
|
| 284 |
+
)
|
| 285 |
+
self.jepa = ( # Escarda: JEPA alongside HRM
|
| 286 |
+
JEPAPredictorBlock(cfg.hidden_size, cfg.jepa_pred_dim,
|
| 287 |
+
cfg.jepa_horizon, cfg.rms_norm_eps)
|
| 288 |
+
if getattr(cfg, "use_jepa", False) else None
|
| 289 |
+
)
|
| 290 |
+
self.norm = RMSNorm(cfg.hidden_size, cfg.rms_norm_eps)
|
| 291 |
+
|
| 292 |
+
cos, sin = build_axial_rope(cfg.grid_size, cfg.head_dim, cfg.rope_theta,
|
| 293 |
+
device="cpu", dtype=torch.float32)
|
| 294 |
+
self.register_buffer("rope_cos", cos, persistent=False)
|
| 295 |
+
self.register_buffer("rope_sin", sin, persistent=False)
|
| 296 |
+
|
| 297 |
+
self.apply(self._init_weights)
|
| 298 |
+
if cfg.use_cls_token:
|
| 299 |
+
nn.init.trunc_normal_(self.cls_token, std=0.02)
|
| 300 |
+
# Re-apply HRM init that the global _init_weights pass would clobber. Gate-only
|
| 301 |
+
# zero-init (up is non-zero) so the block is a safe no-op at start AND can train.
|
| 302 |
+
if self.hrm_refine is not None:
|
| 303 |
+
nn.init.normal_(self.hrm_refine.down.weight, std=0.02)
|
| 304 |
+
nn.init.normal_(self.hrm_refine.up.weight, std=0.02)
|
| 305 |
+
|
| 306 |
+
@staticmethod
|
| 307 |
+
def _init_weights(m: nn.Module):
|
| 308 |
+
if isinstance(m, nn.Linear):
|
| 309 |
+
nn.init.trunc_normal_(m.weight, std=0.02)
|
| 310 |
+
if m.bias is not None:
|
| 311 |
+
nn.init.zeros_(m.bias)
|
| 312 |
+
elif isinstance(m, nn.Conv2d):
|
| 313 |
+
nn.init.trunc_normal_(m.weight, std=0.02)
|
| 314 |
+
if m.bias is not None:
|
| 315 |
+
nn.init.zeros_(m.bias)
|
| 316 |
+
|
| 317 |
+
def forward(self, images: torch.Tensor) -> dict:
|
| 318 |
+
"""images: (B, C, H, W) in [-1, 1] or normalized. Returns pooled + tokens."""
|
| 319 |
+
cfg = self.cfg
|
| 320 |
+
x = self.patch_embed(images) # (B, P, C)
|
| 321 |
+
if cfg.use_cls_token:
|
| 322 |
+
cls = self.cls_token.expand(x.shape[0], -1, -1)
|
| 323 |
+
x = torch.cat([cls, x], dim=1)
|
| 324 |
+
|
| 325 |
+
cos = self.rope_cos.to(x.dtype)
|
| 326 |
+
sin = self.rope_sin.to(x.dtype)
|
| 327 |
+
for blk in self.blocks:
|
| 328 |
+
x = blk(x, cos, sin, self.rope_offset)
|
| 329 |
+
if self.hrm_refine is not None:
|
| 330 |
+
x = self.hrm_refine(x)
|
| 331 |
+
x = self.norm(x)
|
| 332 |
+
|
| 333 |
+
if cfg.use_cls_token:
|
| 334 |
+
pooled = x[:, 0]
|
| 335 |
+
tokens = x[:, 1:]
|
| 336 |
+
else:
|
| 337 |
+
pooled = x.mean(dim=1)
|
| 338 |
+
tokens = x
|
| 339 |
+
return {"pooled": pooled, "tokens": tokens, "last_hidden_state": x}
|
| 340 |
+
|
| 341 |
+
def jepa_aux_loss(self, tokens: torch.Tensor) -> torch.Tensor:
|
| 342 |
+
"""Escarda JEPA auxiliary loss on the patch tokens: from token t predict token
|
| 343 |
+
t+k (raster-order neighbour) in representation space, target detached, 1-cosine
|
| 344 |
+
averaged over offsets. Returns 0 if JEPA is disabled."""
|
| 345 |
+
if self.jepa is None:
|
| 346 |
+
return tokens.new_zeros(())
|
| 347 |
+
total = tokens.new_zeros(())
|
| 348 |
+
n = 0
|
| 349 |
+
for k in range(1, self.jepa.horizon + 1):
|
| 350 |
+
pred = self.jepa(tokens[:, :-k, :], k)
|
| 351 |
+
target = tokens[:, k:, :].detach()
|
| 352 |
+
cos = F.cosine_similarity(pred.float(), target.float(), dim=-1)
|
| 353 |
+
total = total + (1.0 - cos).mean()
|
| 354 |
+
n += 1
|
| 355 |
+
return total / max(n, 1)
|
| 356 |
+
|
| 357 |
+
def num_parameters(self) -> int:
|
| 358 |
+
return sum(p.numel() for p in self.parameters())
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
def _format_millions(n: int) -> str:
|
| 362 |
+
return f"{n / 1e6:.2f}M"
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
if __name__ == "__main__":
|
| 366 |
+
cfg = VisionConfig()
|
| 367 |
+
model = VisionEncoder(cfg)
|
| 368 |
+
total = model.num_parameters()
|
| 369 |
+
|
| 370 |
+
# Per-group breakdown
|
| 371 |
+
groups = {"patch_embed": 0, "blocks": 0, "hrm_refine": 0, "norm/cls": 0}
|
| 372 |
+
for name, p in model.named_parameters():
|
| 373 |
+
if name.startswith("patch_embed"):
|
| 374 |
+
groups["patch_embed"] += p.numel()
|
| 375 |
+
elif name.startswith("blocks"):
|
| 376 |
+
groups["blocks"] += p.numel()
|
| 377 |
+
elif name.startswith("hrm_refine"):
|
| 378 |
+
groups["hrm_refine"] += p.numel()
|
| 379 |
+
else:
|
| 380 |
+
groups["norm/cls"] += p.numel()
|
| 381 |
+
|
| 382 |
+
print(f"VisionEncoder config: dim={cfg.hidden_size} depth={cfg.num_layers} "
|
| 383 |
+
f"heads={cfg.num_heads} patch={cfg.patch_size} img={cfg.image_size} "
|
| 384 |
+
f"({cfg.num_patches} patches)")
|
| 385 |
+
for g, v in groups.items():
|
| 386 |
+
print(f" {g:14s}: {_format_millions(v)}")
|
| 387 |
+
print(f" {'TOTAL':14s}: {_format_millions(total)} ({total:,} params)")
|
| 388 |
+
|
| 389 |
+
x = torch.randn(2, cfg.in_channels, cfg.image_size, cfg.image_size)
|
| 390 |
+
out = model(x)
|
| 391 |
+
print(f"\nforward OK: pooled={tuple(out['pooled'].shape)} "
|
| 392 |
+
f"tokens={tuple(out['tokens'].shape)}")
|