Quazim0t0 commited on
Commit
6c7d989
·
verified ·
1 Parent(s): 5bcba4b

Escarda-VE: JEPA+HRM vision encoder + Byrne-VE comparison

Browse files
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)}")