Coverage for cafl4ds/models/vit.py: 98%

113 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-17 14:39 +0000

1"""A small, self-contained Vision Transformer encoder (the shared backbone). 

2 

3Phase 0 needs *one* tiny, CPU-friendly encoder that both SSL methods can share: 

4 

5* **MAE** needs patch-level masking and access to the visible-token latent, plus an 

6 ``ids_restore`` permutation so a decoder can put the tokens back (see 

7 :class:`cafl4ds.models.heads.MAEDecoder`). 

8* **SimSiam** (and later BYOL/SimCLR) only needs a pooled embedding of the full image. 

9 

10So :class:`TinyViTEncoder` exposes :meth:`forward_encoder` (optionally masked, the MAE 

11entry point) and :meth:`embed` (a pooled ``[B, d]`` representation, the joint-embedding / 

12measurement entry point). It is deliberately hand-rolled and tiny — no external model zoo — 

13so the from-scratch smoke test runs on CPU in seconds; at HPU scale a larger / pretrained 

14encoder satisfying the same two methods can be dropped in via config. 

15 

16Shapes follow the standard ViT/MAE convention: images are ``[B, C, H, W]``; a patch 

17sequence is ``[B, N, d]`` with ``N = (H / p) * (W / p)`` patches; the token sequence handed 

18to the transformer blocks is ``[B, 1 + N, d]`` (a prepended ``cls`` token). 

19""" 

20 

21from __future__ import annotations 

22 

23from typing import cast 

24 

25import torch 

26from torch import nn 

27 

28 

29def patchify(imgs: torch.Tensor, patch_size: int) -> torch.Tensor: 

30 """Split a batch of images into a sequence of flattened patches. 

31 

32 Args: 

33 imgs: Images ``[B, C, H, W]`` with ``H`` and ``W`` divisible by ``patch_size``. 

34 patch_size: Side length ``p`` of each square patch. 

35 

36 Returns: 

37 Patches ``[B, N, p * p * C]`` with ``N = (H / p) * (W / p)``, in row-major 

38 (top-left to bottom-right) patch order. 

39 """ 

40 b, c, h, w = imgs.shape 

41 p = patch_size 

42 nh, nw = h // p, w // p 

43 x = imgs.reshape(b, c, nh, p, nw, p) 

44 x = torch.einsum("bchpwq->bhwpqc", x) 

45 return x.reshape(b, nh * nw, p * p * c) 

46 

47 

48def unpatchify(patches: torch.Tensor, patch_size: int, channels: int) -> torch.Tensor: 

49 """Inverse of :func:`patchify`: reassemble a patch sequence into images. 

50 

51 Args: 

52 patches: Patches ``[B, N, p * p * C]`` in row-major patch order. 

53 patch_size: Side length ``p`` of each square patch. 

54 channels: Number of image channels ``C``. 

55 

56 Returns: 

57 Images ``[B, C, H, W]`` with ``H = W = p * sqrt(N)`` (square grid assumed). 

58 """ 

59 b, n, _ = patches.shape 

60 p, c = patch_size, channels 

61 g = int(round(n**0.5)) 

62 x = patches.reshape(b, g, g, p, p, c) 

63 x = torch.einsum("bhwpqc->bchpwq", x) 

64 return x.reshape(b, c, g * p, g * p) 

65 

66 

67class Mlp(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env) 

68 """A two-layer MLP with GELU activation (transformer feed-forward block).""" 

69 

70 def __init__(self, dim: int, hidden_dim: int) -> None: 

71 """Build the MLP. 

72 

73 Args: 

74 dim: Input and output dimensionality. 

75 hidden_dim: Width of the hidden layer. 

76 """ 

77 super().__init__() 

78 self.fc1 = nn.Linear(dim, hidden_dim) 

79 self.act = nn.GELU() 

80 self.fc2 = nn.Linear(hidden_dim, dim) 

81 

82 def forward(self, x: torch.Tensor) -> torch.Tensor: 

83 """Apply the MLP to ``x`` (``[..., dim] -> [..., dim]``).""" 

84 return self.fc2(self.act(self.fc1(x))) 

85 

86 

87class Attention(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env) 

88 """Standard multi-head self-attention over a token sequence.""" 

89 

90 def __init__(self, dim: int, num_heads: int) -> None: 

91 """Build the attention layer. 

92 

93 Args: 

94 dim: Token dimensionality; must be divisible by ``num_heads``. 

95 num_heads: Number of attention heads. 

96 

97 Raises: 

98 ValueError: If ``dim`` is not divisible by ``num_heads``. 

99 """ 

100 super().__init__() 

101 if dim % num_heads != 0: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 raise ValueError(f"dim ({dim}) must be divisible by num_heads ({num_heads}).") 

103 self.num_heads = num_heads 

104 self.head_dim = dim // num_heads 

105 self.qkv = nn.Linear(dim, dim * 3) 

106 self.proj = nn.Linear(dim, dim) 

107 

108 def forward(self, x: torch.Tensor) -> torch.Tensor: 

109 """Self-attend over the token dimension (``[B, T, d] -> [B, T, d]``).""" 

110 b, t, d = x.shape 

111 qkv = self.qkv(x).reshape(b, t, 3, self.num_heads, self.head_dim).permute(2, 0, 3, 1, 4) 

112 q, k, v = qkv[0], qkv[1], qkv[2] 

113 out = torch.nn.functional.scaled_dot_product_attention(q, k, v) 

114 out = out.transpose(1, 2).reshape(b, t, d) 

115 return self.proj(out) 

116 

117 

118class Block(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env) 

119 """A pre-norm transformer block: attention + MLP with residual connections.""" 

120 

121 def __init__(self, dim: int, num_heads: int, mlp_ratio: float) -> None: 

122 """Build the transformer block. 

123 

124 Args: 

125 dim: Token dimensionality. 

126 num_heads: Number of attention heads. 

127 mlp_ratio: Hidden-layer width of the MLP as a multiple of ``dim``. 

128 """ 

129 super().__init__() 

130 self.norm1 = nn.LayerNorm(dim) 

131 self.attn = Attention(dim, num_heads) 

132 self.norm2 = nn.LayerNorm(dim) 

133 self.mlp = Mlp(dim, int(dim * mlp_ratio)) 

134 

135 def forward(self, x: torch.Tensor) -> torch.Tensor: 

136 """Apply attention then MLP, each with a residual connection.""" 

137 x = x + self.attn(self.norm1(x)) 

138 x = x + self.mlp(self.norm2(x)) 

139 return x 

140 

141 

142class TinyViTEncoder(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env) 

143 """A tiny ViT encoder shared by the SSL methods. 

144 

145 Exposes two entry points: 

146 

147 * :meth:`forward_encoder` — run the transformer over the (optionally masked) patch 

148 sequence; the MAE path uses ``mask_ratio > 0`` and consumes the returned mask / 

149 restore indices. 

150 * :meth:`embed` — a pooled ``[B, embed_dim]`` representation of the full image (mean of 

151 the patch tokens after the final norm); the joint-embedding and measurement path. 

152 

153 The embedding read by the health instruments and probes is deliberately the *backbone* 

154 representation (:meth:`embed`), never a projector/predictor output — those are training 

155 heads, not the representation under study. 

156 """ 

157 

158 def __init__( 

159 self, 

160 img_size: int = 32, 

161 patch_size: int = 8, 

162 in_chans: int = 3, 

163 embed_dim: int = 96, 

164 depth: int = 4, 

165 num_heads: int = 3, 

166 mlp_ratio: float = 2.0, 

167 ) -> None: 

168 """Build the encoder. 

169 

170 Args: 

171 img_size: Input image side length (square); must be divisible by ``patch_size``. 

172 patch_size: Side length of each square patch. 

173 in_chans: Number of input channels. 

174 embed_dim: Token / embedding dimensionality. 

175 depth: Number of transformer blocks. 

176 num_heads: Number of attention heads per block. 

177 mlp_ratio: MLP hidden width as a multiple of ``embed_dim``. 

178 

179 Raises: 

180 ValueError: If ``img_size`` is not divisible by ``patch_size``. 

181 """ 

182 super().__init__() 

183 if img_size % patch_size != 0: 

184 raise ValueError(f"img_size ({img_size}) must be divisible by patch_size ({patch_size}).") 

185 self.patch_size = patch_size 

186 self.in_chans = in_chans 

187 self.embed_dim = embed_dim 

188 self.num_patches = (img_size // patch_size) ** 2 

189 

190 self.patch_embed = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size) 

191 self.cls_token = nn.Parameter(torch.zeros(1, 1, embed_dim)) 

192 self.pos_embed = nn.Parameter(torch.zeros(1, 1 + self.num_patches, embed_dim)) 

193 self.blocks = nn.ModuleList([Block(embed_dim, num_heads, mlp_ratio) for _ in range(depth)]) 

194 self.norm = nn.LayerNorm(embed_dim) 

195 self._init_weights() 

196 

197 def _init_weights(self) -> None: 

198 """Initialize positional/token parameters and the linear/conv layers.""" 

199 nn.init.trunc_normal_(self.pos_embed, std=0.02) 

200 nn.init.trunc_normal_(self.cls_token, std=0.02) 

201 for m in self.modules(): 

202 if isinstance(m, nn.Linear): 

203 nn.init.trunc_normal_(m.weight, std=0.02) 

204 if m.bias is not None: 204 ↛ 201line 204 didn't jump to line 201 because the condition on line 204 was always true

205 nn.init.zeros_(m.bias) 

206 elif isinstance(m, nn.LayerNorm): 

207 nn.init.ones_(m.weight) 

208 nn.init.zeros_(m.bias) 

209 

210 def _embed_patches(self, imgs: torch.Tensor) -> torch.Tensor: 

211 """Convolutional patch embedding, adding the patch positional encodings. 

212 

213 Args: 

214 imgs: Images ``[B, C, H, W]``. 

215 

216 Returns: 

217 Patch tokens ``[B, N, embed_dim]`` with positional embeddings added (the ``cls`` 

218 token and its position are handled by the callers). 

219 """ 

220 x = self.patch_embed(imgs).flatten(2).transpose(1, 2) # [B, N, D] 

221 return cast(torch.Tensor, x + self.pos_embed[:, 1:, :]) 

222 

223 @staticmethod 

224 def random_masking(x: torch.Tensor, mask_ratio: float) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: 

225 """Per-sample random masking of a patch sequence (MAE, He et al. 2022). 

226 

227 Args: 

228 x: Patch tokens ``[B, N, d]``. 

229 mask_ratio: Fraction of patches to drop, in ``[0, 1)``. 

230 

231 Returns: 

232 A tuple ``(x_kept, mask, ids_restore)`` where ``x_kept`` is ``[B, N_keep, d]`` 

233 (the visible tokens), ``mask`` is ``[B, N]`` (1 = masked/removed, 0 = kept), and 

234 ``ids_restore`` is ``[B, N]`` — the permutation that undoes the shuffle. 

235 """ 

236 b, n, d = x.shape 

237 len_keep = max(1, int(round(n * (1.0 - mask_ratio)))) 

238 noise = torch.rand(b, n, device=x.device) 

239 ids_shuffle = torch.argsort(noise, dim=1) 

240 ids_restore = torch.argsort(ids_shuffle, dim=1) 

241 ids_keep = ids_shuffle[:, :len_keep] 

242 x_kept = torch.gather(x, dim=1, index=ids_keep.unsqueeze(-1).expand(-1, -1, d)) 

243 mask = torch.ones(b, n, device=x.device) 

244 mask[:, :len_keep] = 0 

245 mask = torch.gather(mask, dim=1, index=ids_restore) 

246 return x_kept, mask, ids_restore 

247 

248 def forward_encoder( 

249 self, imgs: torch.Tensor, mask_ratio: float = 0.0 

250 ) -> tuple[torch.Tensor, torch.Tensor | None, torch.Tensor | None]: 

251 """Run the transformer over the (optionally masked) patch sequence. 

252 

253 Args: 

254 imgs: Images ``[B, C, H, W]``. 

255 mask_ratio: Fraction of patches to mask before encoding (MAE). ``0`` disables 

256 masking and processes the full sequence (joint-embedding / measurement). 

257 

258 Returns: 

259 A tuple ``(tokens, mask, ids_restore)``. ``tokens`` is ``[B, 1 + N_keep, d]`` 

260 (final-normed, ``cls`` token first). ``mask`` and ``ids_restore`` are ``[B, N]`` 

261 when ``mask_ratio > 0`` and ``None`` otherwise. 

262 """ 

263 x = self._embed_patches(imgs) 

264 mask: torch.Tensor | None = None 

265 ids_restore: torch.Tensor | None = None 

266 if mask_ratio > 0.0: 

267 x, mask, ids_restore = self.random_masking(x, mask_ratio) 

268 cls = (self.cls_token + self.pos_embed[:, :1, :]).expand(x.shape[0], -1, -1) 

269 x = torch.cat([cls, x], dim=1) 

270 for blk in self.blocks: 

271 x = blk(x) 

272 return self.norm(x), mask, ids_restore 

273 

274 def forward(self, imgs: torch.Tensor) -> torch.Tensor: 

275 """Return the full-sequence tokens ``[B, 1 + N, d]`` (unmasked).""" 

276 tokens, _, _ = self.forward_encoder(imgs, mask_ratio=0.0) 

277 return tokens 

278 

279 def embed(self, imgs: torch.Tensor) -> torch.Tensor: 

280 """Return a pooled ``[B, embed_dim]`` representation (mean of the patch tokens). 

281 

282 This is the representation consumed by the health instruments and probes — the 

283 backbone output, never a training-head projection. 

284 

285 Args: 

286 imgs: Images ``[B, C, H, W]``. 

287 

288 Returns: 

289 The mean-pooled patch-token embedding ``[B, embed_dim]``. 

290 """ 

291 # The measurement/probe callers hold their fixed eval tensors on CPU, so coerce to 

292 # the backbone's device here (a no-op when both are CPU). This keeps ``embed`` — the 

293 # instrument entry point — usable when the model has been moved to an accelerator 

294 # (e.g. ``hpu``) without pushing device bookkeeping into the monitor/measurements. 

295 imgs = imgs.to(self.pos_embed.device) 

296 tokens = self.forward(imgs) 

297 return tokens[:, 1:, :].mean(dim=1)