Skip to content

Commit fc3df0d

Browse files
authored
Add VISRegLoss (VISReg paper) (#1968)
* Add VISRegLoss (VISReg paper) Implements the variance-invariance-sketching regularization loss from https://arxiv.org/abs/2606.02572 per Algorithm 1, with per-component losses, per-device random slices for distributed training, and the Eq. 8 prediction term over both global and local views. Part 1 of #1960. * Fix Any-return in visreg_shape_loss under pinned torch The tensor subtraction resolves to Any with the pinned torch stubs used in CI, tripping mypy's no-any-return. Annotate the return explicitly, matching visreg_scale_loss. * Add CUDA forward test for VISRegLoss Other loss tests include a skip-guarded CUDA forward test; add the matching one for VISRegLoss to verify the tensors created inside the shape loss (slices, quantiles) land on the input's device. Addresses review feedback on #1968. * Merge branch 'master' into add-visreg-loss
1 parent fc6bf30 commit fc3df0d

4 files changed

Lines changed: 775 additions & 0 deletions

File tree

docs/source/lightly.loss.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,7 @@ lightly.loss
8888

8989
.. autoclass:: lightly.loss.vicregl_loss.VICRegLLoss
9090
:members:
91+
92+
.. autoclass:: lightly.loss.visreg_loss.VISRegLoss
93+
:members:
94+
:special-members: __init__

lightly/loss/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@
2525
from lightly.loss.tico_loss import TiCoLoss
2626
from lightly.loss.vicreg_loss import VICRegLoss
2727
from lightly.loss.vicregl_loss import VICRegLLoss
28+
from lightly.loss.visreg_loss import VISRegLoss, VISRegLossComponents
2829
from lightly.loss.wmse_loss import Whitening2d, WMSELoss

lightly/loss/visreg_loss.py

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
1+
"""VISReg loss functions."""
2+
3+
from __future__ import annotations
4+
5+
from typing import NamedTuple
6+
7+
import torch
8+
from torch import Tensor, nn
9+
from torch import distributed as torch_dist
10+
from torch.distributions import Normal
11+
12+
from lightly.loss import lejepa_loss
13+
from lightly.utils import dist as lightly_dist
14+
15+
16+
def visreg_center_loss(z: Tensor) -> Tensor:
17+
"""VISReg center loss penalizing the batch mean of the embeddings.
18+
19+
Pushes the per-dimension batch mean of the embeddings toward zero so
20+
the embedding distribution stays centered at the origin (Eq. 6 in the
21+
paper).
22+
23+
Reference:
24+
VISReg, Wu et al., 2026, https://arxiv.org/abs/2606.02572
25+
26+
Args:
27+
z: Projected embeddings of shape ``(..., N, D)`` where ``N`` is the
28+
batch size and ``D`` is the projection dimensionality.
29+
30+
Returns:
31+
Scalar center loss.
32+
"""
33+
return z.mean(dim=-2).square().mean()
34+
35+
36+
def visreg_scale_loss(z: Tensor) -> Tensor:
37+
"""VISReg scale loss regulating the per-dimension standard deviation.
38+
39+
Penalizes the squared deviation of every embedding dimension's standard
40+
deviation from one (Eq. 1 in the paper). The gradient of this term
41+
approaches a constant as the embeddings collapse, providing a
42+
corrective signal exactly when characteristic-function sketching loses
43+
it (Figure 2 in the paper).
44+
45+
Reference:
46+
VISReg, Wu et al., 2026, https://arxiv.org/abs/2606.02572
47+
48+
Args:
49+
z: Projected embeddings of shape ``(..., N, D)`` where ``N`` is the
50+
batch size and ``D`` is the projection dimensionality.
51+
52+
Returns:
53+
Scalar scale loss.
54+
"""
55+
std = z.std(dim=-2, unbiased=False)
56+
loss: Tensor = (1.0 - std).square().mean()
57+
return loss
58+
59+
60+
def visreg_shape_loss(z: Tensor, num_slices: int, eps: float = 1e-4) -> Tensor:
61+
"""VISReg shape loss sketching the embeddings toward an isotropic Gaussian.
62+
63+
Implements the sliced-Wasserstein sketching term (Eq. 5 in the paper).
64+
The embeddings are centered and normalized by their standard deviation
65+
with a stop-gradient on the standard deviation, so shape optimization
66+
does not interfere with scale regulation (Eq. 2 in the paper). The
67+
normalized embeddings are projected onto ``num_slices`` random unit
68+
directions and the sorted projections are compared against the
69+
quantiles of a standard Gaussian, which is the closed-form 1D
70+
2-Wasserstein distance.
71+
72+
Every call draws fresh random directions from the default generator. In
73+
distributed training each device therefore uses its own slices, which
74+
multiplies the effective number of slices by the world size (Section
75+
3.2 in the paper) provided the random number generators of the devices
76+
are seeded differently.
77+
78+
Reference:
79+
VISReg, Wu et al., 2026, https://arxiv.org/abs/2606.02572
80+
81+
Args:
82+
z: Projected embeddings of shape ``(..., N, D)`` where ``N`` is the
83+
batch size and ``D`` is the projection dimensionality.
84+
num_slices: Number of random 1D projection directions (slices).
85+
eps: Numerical stability term added to the standard deviation before
86+
normalization.
87+
88+
Returns:
89+
Scalar shape loss.
90+
91+
Raises:
92+
ValueError: If num_slices or eps is not positive.
93+
"""
94+
if num_slices < 1:
95+
raise ValueError("num_slices must be a positive integer.")
96+
if eps <= 0:
97+
raise ValueError("eps must be greater than zero.")
98+
99+
num_samples = z.size(-2)
100+
num_features = z.size(-1)
101+
102+
mean = z.mean(dim=-2, keepdim=True)
103+
std = z.std(dim=-2, unbiased=False, keepdim=True)
104+
# The stop-gradient on std decouples shape from scale optimization.
105+
z_norm = (z - mean) / (std.detach() + eps)
106+
107+
slices = torch.randn(num_features, num_slices, device=z.device, dtype=z.dtype)
108+
slices = slices / slices.norm(p=2, dim=0)
109+
projections = z_norm @ slices
110+
111+
# Fixed standard Gaussian quantiles at positions i / (N + 1) for
112+
# i = 1, ..., N. The positions never reach 0 or 1, so the quantiles are
113+
# always finite. Computed in float32 for icdf precision, then cast.
114+
positions = torch.arange(
115+
start=1, end=num_samples + 1, device=z.device, dtype=torch.float32
116+
) / (num_samples + 1)
117+
quantiles: Tensor = Normal(loc=0.0, scale=1.0).icdf(positions)
118+
quantiles = quantiles.to(dtype=z.dtype).unsqueeze(-1)
119+
120+
projections_sorted = projections.sort(dim=-2).values
121+
loss: Tensor = (projections_sorted - quantiles).square().mean()
122+
return loss
123+
124+
125+
class VISRegLossComponents(NamedTuple):
126+
"""Individual components of the VISReg loss.
127+
128+
Attributes:
129+
total: The combined VISReg loss.
130+
pred: The multi-view invariance (prediction) loss.
131+
scale: The unweighted scale loss.
132+
shape: The unweighted shape loss.
133+
center: The unweighted center loss.
134+
"""
135+
136+
total: Tensor
137+
pred: Tensor
138+
scale: Tensor
139+
shape: Tensor
140+
center: Tensor
141+
142+
143+
class VISRegLoss(nn.Module):
144+
"""Implementation of the VISReg loss [0].
145+
146+
VISReg regularizes the projected embeddings with three decoupled terms:
147+
a center loss on the batch mean, a scale loss on the per-dimension
148+
standard deviation, and a sliced-Wasserstein shape loss that sketches
149+
the embedding distribution toward an isotropic Gaussian. The
150+
regularization is combined with the LeJEPA multi-view invariance loss:
151+
152+
``total = (1 - lambda_param) * pred + lambda_param * reg`` where
153+
``reg = lambda_scale * scale + lambda_shape * shape + lambda_center *
154+
center``.
155+
156+
The implementation follows Algorithm 1 of the paper. The default
157+
``lambda_param=0.9`` and equal component weights are the best settings
158+
reported for large datasets such as ImageNet-1K (Tables 3 and 12 in
159+
[0]); the paper recommends ``lambda_param=0.6`` for small datasets and
160+
increasing ``lambda_shape`` for long-tailed or low-rank data.
161+
162+
In distributed training every device draws its own random slices and
163+
computes the loss on its local batch, so no communication is required
164+
and the effective number of slices grows with the world size (Section
165+
3.2 in [0]). This is the paper's setting and the default. Note that
166+
this requires the random number generators of the devices to be seeded
167+
differently; identically seeded devices draw identical slices. Set
168+
``gather_distributed=True`` to additionally gather the embeddings from
169+
all devices before computing the regularization statistics, which can
170+
help when the per-device batch is very small.
171+
172+
- [0]: VISReg, Wu et al., 2026, https://arxiv.org/abs/2606.02572
173+
- [1]: https://haiyuwu.github.io/visreg
174+
175+
Attributes:
176+
lambda_param: Weight for the regularization term in [0, 1]. The
177+
invariance term is weighted by (1 - lambda_param).
178+
num_slices: Number of random 1D projection directions (slices) drawn
179+
per forward pass on each device.
180+
lambda_scale: Weight for the scale loss inside the regularization term.
181+
lambda_shape: Weight for the shape loss inside the regularization term.
182+
lambda_center: Weight for the center loss inside the regularization
183+
term.
184+
gather_distributed: If True, the embeddings from all devices are
185+
gathered before computing the regularization statistics.
186+
eps: Numerical stability term for the shape loss normalization.
187+
188+
Examples:
189+
>>> # initialize loss function
190+
>>> loss_fn = VISRegLoss()
191+
>>>
192+
>>> # generate local and global views
193+
>>> local_views = [transform(images) for _ in range(n_local)]
194+
>>> global_views = [transform(images) for _ in range(n_global)]
195+
>>>
196+
>>> # project each view and stack to shape (V, N, D)
197+
>>> local_proj = torch.stack([model(v) for v in local_views])
198+
>>> global_proj = torch.stack([model(v) for v in global_views])
199+
>>>
200+
>>> # calculate loss
201+
>>> loss = loss_fn(local_proj=local_proj, global_proj=global_proj)
202+
"""
203+
204+
def __init__(
205+
self,
206+
lambda_param: float = 0.9,
207+
num_slices: int = 4096,
208+
lambda_scale: float = 1.0,
209+
lambda_shape: float = 1.0,
210+
lambda_center: float = 1.0,
211+
gather_distributed: bool = False,
212+
eps: float = 1e-4,
213+
):
214+
"""Initializes the VISRegLoss module with the specified parameters.
215+
216+
Args:
217+
lambda_param: Weight for the regularization term in ``[0, 1]``.
218+
The invariance term is weighted by ``1 - lambda_param``.
219+
num_slices: Number of random 1D projection directions (slices)
220+
drawn per forward pass on each device.
221+
lambda_scale: Weight for the scale loss inside the regularization
222+
term.
223+
lambda_shape: Weight for the shape loss inside the regularization
224+
term.
225+
lambda_center: Weight for the center loss inside the
226+
regularization term.
227+
gather_distributed: If True, the embeddings from all devices are
228+
gathered before computing the regularization statistics.
229+
eps: Numerical stability term for the shape loss normalization.
230+
231+
Raises:
232+
ValueError: If any parameter is outside its valid range or if
233+
gather_distributed is True but torch.distributed is not
234+
available.
235+
"""
236+
super().__init__()
237+
if not 0.0 <= lambda_param <= 1.0:
238+
raise ValueError("lambda_param must be in the unit interval [0, 1].")
239+
if num_slices < 1:
240+
raise ValueError("num_slices must be a positive integer.")
241+
if lambda_scale < 0 or lambda_shape < 0 or lambda_center < 0:
242+
raise ValueError(
243+
"lambda_scale, lambda_shape, and lambda_center must be non-negative."
244+
)
245+
if eps <= 0:
246+
raise ValueError("eps must be greater than zero.")
247+
if gather_distributed and not torch_dist.is_available():
248+
raise ValueError(
249+
"gather_distributed is True but torch.distributed is not available. "
250+
"Please set gather_distributed=False or install a torch version with "
251+
"distributed support."
252+
)
253+
254+
self.lambda_param = lambda_param
255+
self.num_slices = num_slices
256+
self.lambda_scale = lambda_scale
257+
self.lambda_shape = lambda_shape
258+
self.lambda_center = lambda_center
259+
self.gather_distributed = gather_distributed
260+
self.eps = eps
261+
262+
def forward(self, *, local_proj: Tensor, global_proj: Tensor) -> Tensor:
263+
"""Computes the VISReg loss for a batch of multi-view projections.
264+
265+
Args:
266+
local_proj: Local-view projected embeddings of shape
267+
``(Vl, N, D)`` where ``Vl`` is the number of local views,
268+
``N`` is the batch size, and ``D`` is the projection
269+
dimensionality.
270+
global_proj: Global-view projected embeddings of shape
271+
``(Vg, N, D)`` where ``Vg`` is the number of global views.
272+
273+
Returns:
274+
The combined VISReg loss.
275+
"""
276+
return self.forward_components(
277+
local_proj=local_proj, global_proj=global_proj
278+
).total
279+
280+
def forward_components(
281+
self, *, local_proj: Tensor, global_proj: Tensor
282+
) -> VISRegLossComponents:
283+
"""Computes the VISReg loss and returns all components separately.
284+
285+
The prediction term pulls every view, global and local, toward the
286+
centroid of the global views (Eq. 8 in the paper). The
287+
regularization terms are computed over all views with each view's
288+
batch treated as an independent sample set.
289+
290+
Args:
291+
local_proj: Local-view projected embeddings of shape
292+
``(Vl, N, D)`` where ``Vl`` is the number of local views,
293+
``N`` is the batch size, and ``D`` is the projection
294+
dimensionality.
295+
global_proj: Global-view projected embeddings of shape
296+
``(Vg, N, D)`` where ``Vg`` is the number of global views.
297+
298+
Returns:
299+
A VISRegLossComponents tuple with the total loss and the
300+
individual pred, scale, shape, and center losses.
301+
302+
Raises:
303+
ValueError: If the projections have invalid shapes or a batch
304+
size smaller than two.
305+
"""
306+
if local_proj.ndim != 3:
307+
raise ValueError(
308+
f"local_proj must have shape (V_local, N, D), got {local_proj.shape}."
309+
)
310+
if global_proj.ndim != 3:
311+
raise ValueError(
312+
f"global_proj must have shape (V_global, N, D), got "
313+
f"{global_proj.shape}."
314+
)
315+
if local_proj.shape[1:] != global_proj.shape[1:]:
316+
raise ValueError(
317+
"local_proj and global_proj must have matching batch and feature "
318+
f"dimensions, got {local_proj.shape} and {global_proj.shape}."
319+
)
320+
if local_proj.size(-2) < 2:
321+
raise ValueError(
322+
f"Batch size must be greater than one, got {local_proj.size(-2)}."
323+
)
324+
325+
proj = torch.cat([global_proj, local_proj], dim=0)
326+
# Eq. 8 pulls every view, global and local, toward the centroid of
327+
# the global views.
328+
pred_loss = lejepa_loss.lejepa_invariance_loss(
329+
local_proj=proj, global_proj=global_proj
330+
)
331+
332+
# The prediction loss above is computed per device (never gathered),
333+
# matching VICRegLoss. When gathering is enabled, only the
334+
# regularization statistics below use the batch from all devices.
335+
if self.gather_distributed and lightly_dist.world_size() > 1:
336+
proj = torch.cat(lightly_dist.gather(proj), dim=-2)
337+
338+
scale_loss = visreg_scale_loss(proj)
339+
shape_loss = visreg_shape_loss(proj, num_slices=self.num_slices, eps=self.eps)
340+
center_loss = visreg_center_loss(proj)
341+
reg_loss = (
342+
self.lambda_scale * scale_loss
343+
+ self.lambda_shape * shape_loss
344+
+ self.lambda_center * center_loss
345+
)
346+
total_loss = (
347+
1.0 - self.lambda_param
348+
) * pred_loss + self.lambda_param * reg_loss
349+
return VISRegLossComponents(
350+
total=total_loss,
351+
pred=pred_loss,
352+
scale=scale_loss,
353+
shape=shape_loss,
354+
center=center_loss,
355+
)

0 commit comments

Comments
 (0)