02 · Learning by Comparison
Noise-Contrastive Estimation
Estimating unnormalised models by discriminating data from noise.
What problem does NCE solve?
"The idea is to perform nonlinear logistic regression to discriminate between the observed data and some artificially generated noise, using the model log-density function in the regression nonlinearity."
Gutmann and Hyvärinen (2010) consider the problem of estimating unnormalised statistical models. Before diving into their solution, we need to understand what this problem actually involves. We will unpack four ideas: (1) What an unnormalised model is; (2) What it means to observe data from an unknown density; (3) How we structure our search with a parameterised family; (4) And what it means for the true density to live inside that family.
(1) What is an unnormalised statistical model?
A probability density function must integrate to one. This is the basic rule that makes probabilities meaningful: if we sum up the likelihood of every possible outcome, the total must be exactly one.
In many practical settings, however, we can only write down a function that tells us the relative likelihood of different inputs. We call this function the unnormalised model and denote it
is the input vector. What represents depends on the domain. It could be a vector of pixel intensities in an image, a sequence of word embeddings, sensor readings from a robot, or any other high-dimensional observation we want to model. is the parameter vector that controls the shape of the model. For a Gaussian, would contain the mean and covariance. For a neural network, would be the collection of all weights and biases. Changing changes which inputs the model considers likely. is the unnormalised score that the model assigns to input . The superscript distinguishes it from the normalised density ; it signals that this is the "raw" form before dividing by . It is always non-negative, and higher values mean the model considers more plausible. Crucially, these scores do not need to sum (or integrate) to one across all possible .
To turn these raw scores into a valid probability density, we divide by the normalisation constant (also called the partition function):
Here
The difficulty is that computing
This is not a minor inconvenience; it is the central obstacle that NCE is designed to overcome. The problem arises across many model families, including Markov random fields, energy-based models, and deep generative networks.
(2) The unknown data density
Suppose we observe
The task of density estimation is to use these samples to learn a model that approximates
(3) Parameterised families
To make the estimation problem tractable, we choose a parameterised family of candidate densities
The estimation problem then reduces to a search: which value of
(4) The true parameters
We make one key assumption: the true data density belongs to our model family. Formally, there exists some parameter vector
This is the well-specified (or realisable) setting. It means the model family is rich enough to contain the true density as one of its members. The goal of estimation is to recover
With all four pieces in place, we can now state the problem precisely: given samples from an unknown density
The normalisation constraint
Any solution
Here
This normalisation requirement defines a constraint on the optimisation problem. In principle, the constraint can always be satisfied by dividing by
Why not maximum likelihood?
A natural first idea would be to treat
Maximum Likelihood Estimation (MLE)
MLE is the most widely used principle for fitting a statistical model to data. Given observed samples
Taking the logarithm (which does not change the maximiser) gives the more common form:
Why does MLE fail for unnormalised models?
Substituting
If we treat
This failure has motivated methods that estimate the model directly through
Gutmann and Hyvärinen (2010) propose a different approach. Their key insight is to recast density estimation as a binary classification problem: learn to discriminate between samples from the true data distribution
Noise-Contrastive Estimation (NCE)
Definition of the estimator
We saw that treating
Why work in log-space?
- Normalisation means dividing by
: . - Taking logs turns the division into a subtraction:
. - So the scalar
plays the role of , and the additive form is simply the log-space version of dividing by the partition function.
Here
Next, we introduce a noise distribution
The NCE estimator
where
The function
Intuitively,
- When the model assigns higher density than the noise,
is positive and is close to 1. - When the noise assigns higher density,
is negative and is close to 0.
The objective
Notice that
Connection to supervised learning
The objective
To make this connection explicit, consider the union of both datasets,
if the point came from the data if the point came from the noise
Since the data density
Because we draw
The denominator
Substituting the class-conditionals
The
The class labels
Substituting
which is, up to the factor
But why does this avoid normalisation?
When I was first studying the NCE paper, this was the part that confused me the most. We start with a model
I kept asking myself: where did the normalisation come from?
Here is the intuition that helped me to understand. The key observation is that NCE never needs to evaluate
Now consider what happens if the model is incorrectly normalised:
- If
is too large (the model overestimates densities everywhere):- The ratio
will be inflated. - The classifier will assign
to everything, including noise points. - This hurts the second term of the objective,
, which wants to be small on noise.
- The ratio
- If
is too small (the model underestimates densities):- The ratio
will be deflated. - The classifier will assign
everywhere, including data points. - This hurts the first term,
, which wants to be large on data.
- The ratio
The only way to maximise both terms simultaneously is for
In other words, correct normalisation emerges as a consequence of optimal classification, not as an explicit constraint.
This is why the learnable parameter
Properties of the estimator
But why does maximising
Gutmann and Hyvärinen (2010) prove three key results. We state them informally here; the full proofs can be found in the original paper.
1. The objective has the right optimum
In the infinite-data limit, the population objective
A crucial point: this result holds without imposing any normalisation constraint. Unlike MLE (where
2. Consistency
As the number of samples
3. Asymptotic normality
For large
NCE in six steps
Noise-contrastive estimation (Gutmann and Hyvärinen, 2010) provides a way to learn unnormalised probability distributions without ever computing the intractable partition function. Here is the full picture (Balestriero et al., 2023):
-
Setup. We observe i.i.d. samples
from an unknown distribution . We want to approximate with a parametric function without enforcing during training. -
Introduce noise. We draw noise samples
and form a mixture: flip a Bernoulli coin with , then observe if (data) or if (noise). -
Apply Bayes' rule. Writing
for the noise-to-data ratio, the posterior probability that a sample came from the data is -
Parametrise. We model
with learnable parameters . -
Train. Minimise the negative log-likelihood of the logistic regression (the standard binary classification loss):
-
Result. At the optimum,
provided that . If is powerful enough (e.g. a deep network), one can fix and the model will self-normalise.
In short, NCE turns an intractable density estimation problem into a tractable binary classification problem. The noise distribution provides a reference against which the model is compared, and the classifier learns to tell data from noise. As a byproduct, the model converges to the true data density, including the correct normalisation. This idea of "learning by comparison" is at the heart of many modern self-supervised methods, most notably InfoNCE.
NCE in PyTorch
To make everything concrete, here is a minimal PyTorch implementation of NCE. We estimate a 2D density where the true distribution is a mixture of Gaussians. The model is a small MLP that outputs the unnormalised log-density
# nce_example.py
import torch
import torch.nn as nn
import torch.optim as optim
# ── True distribution: mixture of 3 Gaussians in 2D ──────────────
def sample_data(n):
"""Sample from a mixture of three 2D Gaussians."""
means = torch.tensor([[-2.0, 0.0], [2.0, 0.0], [0.0, 3.0]])
k = torch.randint(0, 3, (n,))
samples = means[k] + 0.5 * torch.randn(n, 2)
return samples
# ── Noise distribution: standard 2D Gaussian ─────────────────────
noise_dist = torch.distributions.MultivariateNormal(
torch.zeros(2), torch.eye(2)
)
def sample_noise(n):
return noise_dist.sample((n,))
def log_p_noise(y):
return noise_dist.log_prob(y)
# ── Unnormalised model: MLP + learnable c ─────────────────────────
class NCEModel(nn.Module):
def __init__(self, hidden=128):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, hidden),
nn.ReLU(),
nn.Linear(hidden, hidden),
nn.ReLU(),
nn.Linear(hidden, 1), # outputs ln p_m^0(x; alpha)
)
self.c = nn.Parameter(torch.zeros(1)) # log-normalisation constant
def log_pm(self, x):
"""Compute ln p_m(x; theta) = ln p_m^0(x; alpha) + c."""
return self.net(x).squeeze(-1) + self.c
# ── NCE objective ─────────────────────────────────────────────────
def nce_loss(model, x_data, y_noise):
"""
Compute -J_T(theta).
We minimise the negative objective (equivalent to maximising J_T).
"""
# Log-ratio G(u; theta) = ln p_m(u; theta) - ln p_n(u)
G_data = model.log_pm(x_data) - log_p_noise(x_data)
G_noise = model.log_pm(y_noise) - log_p_noise(y_noise)
# h(u; theta) = sigmoid(G(u; theta))
# J_T = (1/2T) * sum[ ln h(x_t) + ln(1 - h(y_t)) ]
# Using log-sigmoid for numerical stability:
# ln h(u) = ln sigmoid(G) = log_sigmoid(G)
# ln(1-h(u)) = ln sigmoid(-G) = log_sigmoid(-G)
loss_data = -torch.nn.functional.logsigmoid(G_data).mean()
loss_noise = -torch.nn.functional.logsigmoid(-G_noise).mean()
return (loss_data + loss_noise) / 2
# ── Training loop ─────────────────────────────────────────────────
model = NCEModel()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
T = 1024 # samples per batch
for step in range(5000):
x = sample_data(T)
y = sample_noise(T)
loss = nce_loss(model, x, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if (step + 1) % 1000 == 0:
print(f"Step {step+1:5d} | loss = {loss.item():.4f} | c = {model.c.item():.4f}")
# ── After training ────────────────────────────────────────────────
# model.log_pm(x) now approximates ln p_d(x)
# model.c has converged to approximately -ln Z(alpha)
print(f"\nLearned c = {model.c.item():.4f}")
print("(If the model has self-normalised, c should be close to 0)")
A few things to notice in the code:
- The model outputs
(the raw unnormalised log-density), and we add the learnable scalarself.cto get . This is the reparameterisation from the paper. - The loss function computes
for both data and noise, then useslogsigmoidfor numerical stability (equivalent to and ). - We minimise the negative objective, which is the standard convention in PyTorch (minimise loss = maximise
). - The noise distribution is a simple standard Gaussian. We can evaluate its log-density exactly via
noise_dist.log_prob(), which is all NCE requires from the noise.