Coverage for cafl4ds/models/heads.py: 96%
45 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-17 14:39 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-17 14:39 +0000
1"""Task heads attached to the shared :class:`~cafl4ds.models.vit.TinyViTEncoder`.
3These are *training* heads, discarded at measurement time (the health instruments read the
4encoder's pooled representation, never these outputs):
6* :class:`MLPHead` — a configurable BN-MLP; used as SimSiam's projector and predictor
7 (BYOL later reuses the same shape).
8* :class:`MAEDecoder` — a lightweight transformer decoder that reconstructs pixels from the
9 encoder's visible-token latent (He et al. 2022).
10"""
12from __future__ import annotations
14from typing import cast
16import torch
17from torch import nn
19from cafl4ds.models.vit import Block
22class MLPHead(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env)
23 """A batch-norm MLP head (SimSiam/BYOL projector or predictor).
25 With ``num_layers=3`` and ``last_bn=True`` this is the SimSiam projector; with
26 ``num_layers=2`` and ``last_bn=False`` it is the SimSiam predictor (Chen & He 2021).
27 """
29 def __init__(
30 self,
31 in_dim: int,
32 hidden_dim: int,
33 out_dim: int,
34 num_layers: int = 3,
35 last_bn: bool = True,
36 ) -> None:
37 """Build the MLP head.
39 Args:
40 in_dim: Input dimensionality.
41 hidden_dim: Hidden-layer width (used for all but the output layer).
42 out_dim: Output dimensionality.
43 num_layers: Total number of linear layers (``>= 1``).
44 last_bn: Whether to apply a (bias-free) BatchNorm after the output layer, with
45 no final activation — the SimSiam projector does, the predictor does not.
47 Raises:
48 ValueError: If ``num_layers < 1``.
49 """
50 super().__init__()
51 if num_layers < 1: 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true
52 raise ValueError(f"num_layers must be >= 1; got {num_layers}.")
53 layers: list[nn.Module] = []
54 dim = in_dim
55 for _ in range(num_layers - 1):
56 layers += [nn.Linear(dim, hidden_dim, bias=False), nn.BatchNorm1d(hidden_dim), nn.ReLU(inplace=True)]
57 dim = hidden_dim
58 layers.append(nn.Linear(dim, out_dim, bias=not last_bn))
59 if last_bn:
60 layers.append(nn.BatchNorm1d(out_dim, affine=False))
61 self.net = nn.Sequential(*layers)
63 def forward(self, x: torch.Tensor) -> torch.Tensor:
64 """Apply the MLP head (``[B, in_dim] -> [B, out_dim]``)."""
65 return cast(torch.Tensor, self.net(x))
68class MAEDecoder(nn.Module): # type: ignore[misc] # nn.Module is Any without torch stubs (mypy hook env)
69 """A lightweight MAE decoder: reconstruct pixels from the visible-token latent.
71 Following He et al. (2022): project the encoder latent into the decoder width, scatter
72 the visible tokens back into the full sequence (filling the holes with a shared learned
73 mask token), add decoder positional embeddings, run a few transformer blocks, and
74 linearly predict the per-patch pixels.
75 """
77 def __init__(
78 self,
79 num_patches: int,
80 encoder_dim: int,
81 patch_size: int,
82 in_chans: int = 3,
83 decoder_dim: int = 64,
84 depth: int = 2,
85 num_heads: int = 3,
86 mlp_ratio: float = 2.0,
87 ) -> None:
88 """Build the decoder.
90 Args:
91 num_patches: Number of patches ``N`` in the full (unmasked) sequence.
92 encoder_dim: Dimensionality of the encoder latent tokens.
93 patch_size: Side length ``p`` of each square patch (sets the pixel output width).
94 in_chans: Number of image channels ``C``.
95 decoder_dim: Decoder token dimensionality (smaller than the encoder's).
96 depth: Number of decoder transformer blocks.
97 num_heads: Number of attention heads per decoder block.
98 mlp_ratio: Decoder MLP hidden width as a multiple of ``decoder_dim``.
99 """
100 super().__init__()
101 self.decoder_embed = nn.Linear(encoder_dim, decoder_dim)
102 self.mask_token = nn.Parameter(torch.zeros(1, 1, decoder_dim))
103 self.decoder_pos_embed = nn.Parameter(torch.zeros(1, 1 + num_patches, decoder_dim))
104 self.blocks = nn.ModuleList([Block(decoder_dim, num_heads, mlp_ratio) for _ in range(depth)])
105 self.norm = nn.LayerNorm(decoder_dim)
106 self.pred = nn.Linear(decoder_dim, patch_size * patch_size * in_chans)
107 nn.init.trunc_normal_(self.mask_token, std=0.02)
108 nn.init.trunc_normal_(self.decoder_pos_embed, std=0.02)
110 def forward(self, latent: torch.Tensor, ids_restore: torch.Tensor) -> torch.Tensor:
111 """Reconstruct per-patch pixels from the encoder's visible-token latent.
113 Args:
114 latent: Encoder output ``[B, 1 + N_keep, encoder_dim]`` (``cls`` token first).
115 ids_restore: The ``[B, N]`` permutation from
116 :meth:`~cafl4ds.models.vit.TinyViTEncoder.random_masking` that undoes the
117 visible-patch shuffle.
119 Returns:
120 Predicted patches ``[B, N, p * p * C]`` in row-major patch order (``cls`` token
121 dropped).
122 """
123 x = self.decoder_embed(latent)
124 b, n = ids_restore.shape
125 n_keep = x.shape[1] - 1 # exclude cls
126 mask_tokens = self.mask_token.expand(b, n - n_keep, -1)
127 x_ = torch.cat([x[:, 1:, :], mask_tokens], dim=1) # drop cls, append mask tokens
128 x_ = torch.gather(x_, dim=1, index=ids_restore.unsqueeze(-1).expand(-1, -1, x.shape[2])) # unshuffle
129 x = torch.cat([x[:, :1, :], x_], dim=1) # restore cls
130 x = x + self.decoder_pos_embed
131 for blk in self.blocks:
132 x = blk(x)
133 x = self.norm(x)
134 return cast(torch.Tensor, self.pred(x[:, 1:, :])) # drop cls; [B, N, p*p*C]