-
Notifications
You must be signed in to change notification settings - Fork 357
(prototype) Move the NT-Xent equation into lightly.functional #2035
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Draft
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """Pure functions over tensors: one equation each, no parameters, no state. | ||
|
|
||
| A block that owns parameters, buffers or optimiser state does not belong here. | ||
| """ | ||
|
|
||
| from lightly.functional.ntxent import ntxent | ||
|
|
||
| __all__ = ["ntxent"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| """The NT-Xent equation, with no state and no distributed communication.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import Optional | ||
|
|
||
| import torch | ||
| from torch import Tensor | ||
| from torch.nn.functional import cross_entropy | ||
|
|
||
| __all__ = ["ntxent"] | ||
|
|
||
|
|
||
| def ntxent( | ||
| z0: Tensor, | ||
| z1: Tensor, | ||
| *, | ||
| temperature: float, | ||
| negatives: Optional[Tensor] = None, | ||
| z0_all: Optional[Tensor] = None, | ||
| z1_all: Optional[Tensor] = None, | ||
| positives_at: int = 0, | ||
| ) -> Tensor: | ||
| """Normalized temperature-scaled cross entropy, as in SimCLR. | ||
|
|
||
| Every argument is a tensor or a number, so this function communicates with | ||
| no other rank and holds nothing between calls. Gathering the negatives is | ||
| :class:`~lightly.loss.ntx_ent_loss.NTXentLoss`'s job, and it passes the | ||
| result in through ``z0_all`` and ``z1_all``. | ||
|
|
||
| Reference: | ||
| SimCLR, 2020, https://arxiv.org/abs/2002.05709 | ||
|
|
||
| Args: | ||
| z0: | ||
| Projections of the first view, shape ``(B, D)``, L2-normalized. | ||
| z1: | ||
| Projections of the second view, shape ``(B, D)``, L2-normalized. | ||
| temperature: | ||
| Scales the logits by its inverse. | ||
| negatives: | ||
| Negatives from a memory bank, shape ``(D, K)``. When given, the other | ||
| samples in the batch are not used as negatives. | ||
| z0_all: | ||
| The first view's projections across all ranks, shape ``(B * W, D)``. | ||
| Defaults to ``z0``, which is the single-rank case. | ||
| z1_all: | ||
| The second view's projections across all ranks. Defaults to ``z1``. | ||
| positives_at: | ||
| The column at which this rank's rows start inside ``z0_all``, so | ||
| ``rank * B``. Zero on one rank. | ||
|
|
||
| Returns: | ||
| The loss, as a scalar. | ||
| """ | ||
| device = z0.device | ||
| batch_size = z0.size(0) | ||
|
|
||
| if negatives is not None: | ||
| # sim_pos[i] is the similarity of sample i to its positive pair, and | ||
| # sim_neg[i, j] its similarity to the j-th vector in the bank. | ||
| sim_pos = torch.einsum("nc,nc->n", z0, z1).unsqueeze(-1) | ||
| sim_neg = torch.einsum("nc,ck->nk", z0, negatives.to(device)) | ||
| logits = torch.cat([sim_pos, sim_neg], dim=1) / temperature | ||
| labels = torch.zeros(logits.size(0), device=device, dtype=torch.long) | ||
| return cross_entropy(logits, labels) | ||
|
|
||
| z0_all = z0 if z0_all is None else z0_all | ||
| z1_all = z1 if z1_all is None else z1_all | ||
|
|
||
| # The similarities of a view with itself, which carry no signal and are | ||
| # dropped below. On one rank this is torch.eye. | ||
| rows = torch.arange(batch_size, device=device, dtype=torch.long) | ||
| diagonal = torch.zeros(batch_size, z0_all.size(0), device=device, dtype=torch.bool) | ||
| diagonal[rows, rows + positives_at] = True | ||
|
|
||
| # n is the local batch size and m the batch across ranks, so every block is | ||
| # (n, m). | ||
| logits_00 = torch.einsum("nc,mc->nm", z0, z0_all) / temperature | ||
| logits_01 = torch.einsum("nc,mc->nm", z0, z1_all) / temperature | ||
| logits_10 = torch.einsum("nc,mc->nm", z1, z0_all) / temperature | ||
| logits_11 = torch.einsum("nc,mc->nm", z1, z1_all) / temperature | ||
|
|
||
| logits_00 = logits_00[~diagonal].view(batch_size, -1) | ||
| logits_11 = logits_11[~diagonal].view(batch_size, -1) | ||
|
|
||
| logits = torch.cat( | ||
| [ | ||
| torch.cat([logits_01, logits_00], dim=1), | ||
| torch.cat([logits_10, logits_11], dim=1), | ||
| ], | ||
| dim=0, | ||
| ) | ||
| labels = (rows + positives_at).repeat(2) | ||
| return cross_entropy(logits, labels) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| """How a loss behaves when the batch is split across ranks.""" | ||
|
|
||
| from enum import Enum, auto | ||
|
|
||
| __all__ = ["DistributedKind"] | ||
|
|
||
|
|
||
| class DistributedKind(Enum): | ||
| """What a loss needs from the ranks around it. | ||
|
|
||
| DDP averages the per-rank loss and the per-rank gradient. Whether that is | ||
| correct depends on the loss, and the difference is not visible in a forward | ||
| pass: issue #1920 shipped a correct SIGReg forward with a gradient off by | ||
| exactly ``1 / world_size``, and came back as #1977 against | ||
| ``BarlowTwinsLoss``. Declaring the kind is what selects the test. | ||
| """ | ||
|
|
||
| RANK_LOCAL = auto() | ||
| """The per-rank value is a valid estimate, so DDP averaging is correct. | ||
|
|
||
| An MSE reconstruction loss. | ||
| """ | ||
|
|
||
| GATHER_FOR_NEGATIVES = auto() | ||
| """Correct only if the features are gathered with gradient first. | ||
|
|
||
| NT-Xent and CLIP: every sample on every rank is a negative for every other. | ||
| """ | ||
|
|
||
| GLOBAL_STATISTIC = auto() | ||
| """A function of the global batch, so averaging per rank is wrong. | ||
|
|
||
| SIGReg and the distribution-matching regularisers. The gap between the two | ||
| is the variance across ranks, which closes only once the ranks agree. | ||
| """ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """Golden values for the NT-Xent equation. | ||
|
|
||
| The numbers below are the second statement of the equation, as data rather than | ||
| as code. Editing ``lightly/functional/ntxent.py`` moves them, and someone then | ||
| has to approve the new number or restore the old one. A pinned loss sees changes | ||
| above roughly 1e-5. | ||
| """ | ||
|
|
||
| from typing import Tuple | ||
|
|
||
| import pytest | ||
| import torch | ||
| from torch import Tensor | ||
| from torch.nn.functional import normalize | ||
|
|
||
| from lightly.functional import ntxent | ||
| from lightly.loss import NTXentLoss | ||
|
|
||
| GOLDEN_LOSS = 5.350811958313 # seed 11, batch 8, dim 16, temperature 0.1 | ||
| GOLDEN_LOSS_MEMORY_BANK = 9.919633865356 # the same batch, 8 negatives from a bank | ||
| TOLERANCE = 1e-5 | ||
|
|
||
|
|
||
| def batch(seed: int = 11) -> Tuple[Tensor, Tensor]: | ||
| torch.manual_seed(seed) | ||
| return ( | ||
| normalize(torch.randn(8, 16), dim=1), | ||
| normalize(torch.randn(8, 16), dim=1), | ||
| ) | ||
|
|
||
|
|
||
| def test_golden_loss() -> None: | ||
| z0, z1 = batch() | ||
| assert float(ntxent(z0, z1, temperature=0.1)) == pytest.approx( | ||
| GOLDEN_LOSS, abs=TOLERANCE | ||
| ) | ||
|
|
||
|
|
||
| def test_golden_loss_with_a_memory_bank() -> None: | ||
| z0, z1 = batch() | ||
| torch.manual_seed(11) | ||
| negatives = normalize(torch.randn(8, 16), dim=1).T | ||
| loss = ntxent(z0, z1, temperature=0.1, negatives=negatives) | ||
| assert float(loss) == pytest.approx(GOLDEN_LOSS_MEMORY_BANK, abs=TOLERANCE) | ||
|
|
||
|
|
||
| def test_the_module_computes_the_same_equation() -> None: | ||
| z0, z1 = batch() | ||
| module = NTXentLoss(temperature=0.1) | ||
| assert float(module(z0, z1)) == pytest.approx(GOLDEN_LOSS, abs=TOLERANCE) | ||
|
|
||
|
|
||
| def test_temperature_scales_the_logits() -> None: | ||
| z0, z1 = batch() | ||
| assert float(ntxent(z0, z1, temperature=0.1)) > float( | ||
| ntxent(z0, z1, temperature=0.5) | ||
| ) | ||
|
|
||
|
|
||
| def test_positives_at_zero_is_the_single_rank_case() -> None: | ||
| z0, z1 = batch() | ||
| with_all = ntxent(z0, z1, temperature=0.1, z0_all=z0, z1_all=z1, positives_at=0) | ||
| assert torch.equal(with_all, ntxent(z0, z1, temperature=0.1)) | ||
|
|
||
|
|
||
| def test_the_loss_declares_how_it_behaves_across_ranks() -> None: | ||
| from lightly.loss import DistributedKind | ||
|
|
||
| assert NTXentLoss.distributed_kind is DistributedKind.GATHER_FOR_NEGATIVES |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a caller supplies only one of
z0_allorz1_all—which the independently optional arguments and documented defaults permit—the gathered tensor can haveB * Wrows while the other defaults toB. Thediagonalmask is sized exclusively fromz0_all, so applying it tologits_11raises a shape-mismatchIndexErrorfor either one-sided case. Either reject calls that do not provide both gathered views or build the self-similarity masks independently.Useful? React with 👍 / 👎.