How many components in a mixture?

R
mixture models
model selection
ecology tutorial
Choosing the number of components in a mixture model in R: why the likelihood ratio test fails, and what the bootstrap, BIC and ICL each recover instead.
Author

Tidy Ecology

Published

2026-07-29

The perch came out of the gill nets over four mornings in late August, and by the end of the week the notebook held a few hundred standard lengths. Somebody drew the histogram on the whiteboard in the field station kitchen, and the shape had a shoulder: a tall pile of small fish, then a lump further right that was too heavy to be a tail. The obvious reading was two age classes, the young of the year and everything older, and the obvious next step was to fit a two-component mixture and report the two mean lengths.

The awkward part was the sentence that had to go under the figure. Not “here are the two means”, which the fitter will happily produce, but “there are two age classes in this lake”. That second sentence is a claim about the number of components, and it is the one part of a mixture analysis where the usual machinery quietly stops working. Fit two components and you get two means. Fit three and you get three. The fitter never refuses.

The test everybody reaches for is the likelihood ratio: fit k components, fit k plus one, take twice the difference in log-likelihood, look it up in a chi-squared table. That test is not valid here, and the reason is structural rather than a matter of sample size. Under the null that k components suffice, the extra component has to have either zero weight, which sits on the boundary of the parameter space, or a mean equal to one of the existing means, which leaves its own mean unidentified. Both of those break the conditions the chi-squared limit needs.

This post measures the damage rather than asserting it, and then measures three things that do work: a parametric bootstrap version of the same test, the information criteria (AIC, BIC and ICL), and the separation between components that has to be there before any of it has a chance. Every simulation below runs a few hundred replicates and prints the count, and the whole post fits in base R plus ggplot2, with the EM algorithm written out in about twenty lines.

The fitting machinery itself is built and explained in fitting a mixture of normals in R; this post rebuilds a compact version so it stands alone, but that one is the place to go if the EM steps are new. The question of whether the second component is real at all, as opposed to skewness in one population, is taken further in when a mixture is really skewness.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

fmt <- function(x, digits = 4) formatC(x, format = "f", digits = digits)

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"),
          legend.position = "bottom")
}

The fitter, written out once

Everything below leans on one function, so it belongs on the page. em_mix fits a k-component univariate normal mixture by expectation maximisation. The E step computes the posterior probability that each observation came from each component; the M step sets the weights, means and standard deviations to their weighted averages. Two options matter later: equal_var decides whether the components share one standard deviation, and starts decides how many initialisations are tried before the best fit is kept.

Two guards are in there for reasons that come up in the last section. The standard deviations are floored at five per cent of the sample standard deviation, because with unequal variances the likelihood is unbounded: a component can sit on a single point, shrink its variance towards zero and send the likelihood to infinity. And the iteration count is capped, because near the null the algorithm crawls, and an uncapped run would spend most of the post’s budget in cases where the answer barely moves.

em_mix <- function(x, k, equal_var = TRUE, max_iter = 60, tol = 1e-6, starts = 1) {
  n <- length(x)
  sx <- sqrt(sum((x - mean(x))^2) / n)
  fl <- 0.05 * sx
  best <- NULL
  best_ll <- -Inf
  dens <- matrix(0, n, k)
  for (s in seq_len(starts)) {
    mu <- if (s == 1) as.numeric(quantile(x, (seq_len(k) - 0.5) / k)) else x[sample.int(n, k)]
    sg <- rep(sx, k)
    w <- rep(1 / k, k)
    ll <- -Inf
    ll_old <- -Inf
    for (it in seq_len(max_iter)) {
      for (j in seq_len(k)) dens[, j] <- w[j] * dnorm(x, mu[j], sg[j])
      tot <- .rowSums(dens, n, k) + 1e-300
      ll <- sum(log(tot))
      resp <- dens / tot
      nk <- .colSums(resp, n, k) + 1e-12
      w <- nk / n
      mu <- .colSums(resp * x, n, k) / nk
      ss <- numeric(k)
      for (j in seq_len(k)) ss[j] <- sum(resp[, j] * (x - mu[j])^2)
      sg <- if (equal_var) rep(sqrt(sum(ss) / n), k) else sqrt(ss / nk)
      sg <- pmax(sg, fl)
      if (abs(ll - ll_old) < tol * (abs(ll_old) + 1)) break
      ll_old <- ll
    }
    if (ll > best_ll) {
      best_ll <- ll
      best <- list(w = w, mu = mu, sg = sg, ll = ll, resp = resp, iter = it)
    }
  }
  best$k <- k
  best$npar <- if (equal_var) 2 * k else 3 * k - 1
  best
}

fit_one <- function(x) {
  n <- length(x)
  m <- mean(x)
  s <- sqrt(sum((x - m)^2) / n)
  list(w = 1, mu = m, sg = s, ll = sum(dnorm(x, m, s, log = TRUE)),
       resp = matrix(1, n, 1), k = 1, npar = 2)
}

crit_all <- function(f, n) {
  p <- pmax(f$resp, 1e-12)
  ent <- -sum(ifelse(f$resp > 1e-12, f$resp * log(p), 0))
  c(aic = -2 * f$ll + 2 * f$npar,
    bic = -2 * f$ll + f$npar * log(n),
    icl = -2 * f$ll + f$npar * log(n) + 2 * ent,
    ent = ent)
}

r_mix <- function(n, w, mu, sg) {
  z <- sample.int(length(w), n, TRUE, prob = w)
  rnorm(n, mu[z], sg[z])
}
set.seed(20260729)
chk_n <- 600
chk_x <- r_mix(chk_n, c(0.35, 0.65), c(0, 4), c(1, 1))
chk_f <- em_mix(chk_x, 2, max_iter = 200, starts = 3)
print(c(n = chk_n))
  n 
600 
print(round(c(w1_true = 0.35, w1_hat = min(chk_f$w),
              mu1_true = 0, mu1_hat = min(chk_f$mu),
              mu2_true = 4, mu2_hat = max(chk_f$mu),
              sigma_true = 1, sigma_hat = chk_f$sg[1]), 4))
   w1_true     w1_hat   mu1_true    mu1_hat   mu2_true    mu2_hat sigma_true 
    0.3500     0.3314     0.0000     0.0867     4.0000     3.9927     1.0000 
 sigma_hat 
    1.0177 
chk_c <- crit_all(chk_f, chk_n)
print(round(chk_c, 3))
     aic      bic      icl      ent 
2417.167 2434.755 2511.528   38.387 
print(round(c(icl_minus_bic = chk_c[["icl"]] - chk_c[["bic"]]), 3))
icl_minus_bic 
       76.773 

With 600 observations from a known mixture, the fitter returns a smaller weight of 0.3314 against a true 0.3500, means of 0.0867 and 3.9927 against true values of 0.0000 and 4.0000, and a common standard deviation of 1.0177 against a true 1.0000. Nothing surprising: given the number of components, estimating the components is easy.

crit_all returns the three selection criteria on the deviance scale, so smaller is better in all three. AIC penalises each parameter by two, BIC by log(n), and ICL takes BIC and adds twice the entropy of the classification. That entropy term is the sum over observations and components of the posterior probability times its own logarithm, negated; it is zero when every observation belongs unambiguously to one component and large when the posteriors sit near one half. For a one-component fit it is exactly zero, so ICL and BIC agree there by construction. For the fit above the entropy is 38.387, which puts ICL 76.773 above BIC on a BIC of 2434.755. Small on that scale, but not nothing, and the last two sections are about the cases where it decides the answer.

The test that does not work, measured

Take the simplest possible null: the data really do come from one normal component. Simulate a few hundred such data sets, fit one and two components to each, and record twice the difference in log-likelihood. If the chi-squared approximation held, that statistic would follow a chi-squared distribution, and the only question would be the degrees of freedom.

That question already has no clean answer. Counting parameters, the equal-variance one-component model has two (a mean and a variance) and the two-component model has four (two means, one variance, one weight), so the difference is two. An analyst who reasons that the weight is stuck on the boundary and therefore should not count arrives at one. An analyst working in the unequal-variance family counts three. All three numbers appear in real analyses, so the honest thing is to check all three against the same simulated null.

set.seed(20260729)
rep_a <- 300
n_a <- 200
lr_a <- numeric(rep_a)
for (r in seq_len(rep_a)) {
  x <- rnorm(n_a)
  lr_a[r] <- 2 * (em_mix(x, 2, max_iter = 80, starts = 2)$ll - fit_one(x)$ll)
}
qs <- c(0.5, 0.9, 0.95, 0.99)
print(c(replicates = rep_a, n = n_a, em_max_iter = 80, em_starts = 2))
 replicates           n em_max_iter   em_starts 
        300         200          80           2 
print(round(rbind(empirical = quantile(lr_a, qs),
                  chisq_df1 = qchisq(qs, 1),
                  chisq_df2 = qchisq(qs, 2),
                  chisq_df3 = qchisq(qs, 3)), 3))
            50%   90%   95%    99%
empirical 0.776 4.171 5.706  8.818
chisq_df1 0.455 2.706 3.841  6.635
chisq_df2 1.386 4.605 5.991  9.210
chisq_df3 2.366 6.251 7.815 11.345
print(c(collapse_threshold = 0.01))
collapse_threshold 
              0.01 
print(round(c(mean_stat = mean(lr_a),
              prop_below_0.01 = mean(lr_a < 0.01),
              fpr_df1 = mean(lr_a > qchisq(0.95, 1)),
              fpr_df2 = mean(lr_a > qchisq(0.95, 2)),
              fpr_df3 = mean(lr_a > qchisq(0.95, 3))), 4))
      mean_stat prop_below_0.01         fpr_df1         fpr_df2         fpr_df3 
         1.5224          0.1633          0.1167          0.0467          0.0267 

The result is more interesting than a flat failure. At the 95th percentile the empirical statistic reaches 5.706, and the chi-squared quantiles for one, two and three degrees of freedom are 3.841, 5.991 and 7.815. So the false positive rate at a nominal five per cent comes out at 0.1167 with one degree of freedom, 0.0467 with two, and 0.0267 with three. The middle choice, which is also the naive parameter count, is close to nominal. The boundary argument that sounds most careful is the one that inflates the error rate to more than twice its nominal value.

Look at the median and the picture changes completely. The empirical median is 0.776 against 1.386 for chi-squared on two degrees of freedom, and 0.1633 of the replicates produced a statistic below 0.01, meaning the two-component fit collapsed onto the one-component one and bought nothing at all. A chi-squared distribution has no such spike at zero. The empirical distribution is a point mass at the origin glued to a right tail, and only the tail happens to line up with a chi-squared curve.

That coincidence is the trap. Nothing in the simulation says it will survive a change of sample size, of family, or of the null value of k. So repeat the whole exercise one level up: generate from a genuine two-component mixture with means 0 and 3 and equal weights, then test two components against three.

set.seed(20260730)
rep_b <- 150
n_b <- 250
lr_b <- numeric(rep_b)
for (r in seq_len(rep_b)) {
  x <- r_mix(n_b, c(0.5, 0.5), c(0, 3), c(1, 1))
  lr_b[r] <- 2 * (em_mix(x, 3, max_iter = 80, starts = 2)$ll -
                    em_mix(x, 2, max_iter = 80, starts = 2)$ll)
}
print(c(replicates = rep_b, n = n_b))
replicates          n 
       150        250 
print(round(rbind(empirical = quantile(lr_b, qs), chisq_df2 = qchisq(qs, 2)), 3))
            50%   90%   95%   99%
empirical 0.493 4.136 5.308 8.958
chisq_df2 1.386 4.605 5.991 9.210
print(round(c(prop_below_0.01 = mean(lr_b < 0.01),
              fpr_df1 = mean(lr_b > qchisq(0.95, 1)),
              fpr_df2 = mean(lr_b > qchisq(0.95, 2)),
              fpr_df3 = mean(lr_b > qchisq(0.95, 3))), 4))
prop_below_0.01         fpr_df1         fpr_df2         fpr_df3 
         0.3067          0.1133          0.0333          0.0267 
qgrid <- seq(0, 12, length.out = 241)
ecdf_a <- ecdf(lr_a)
ecdf_b <- ecdf(lr_b)
null_df <- rbind(
  data.frame(q = qgrid, p = ecdf_a(qgrid), series = "simulated",
             panel = "one component tested against two"),
  data.frame(q = qgrid, p = pchisq(qgrid, 1), series = "chi-squared, df = 1",
             panel = "one component tested against two"),
  data.frame(q = qgrid, p = pchisq(qgrid, 2), series = "chi-squared, df = 2",
             panel = "one component tested against two"),
  data.frame(q = qgrid, p = pchisq(qgrid, 3), series = "chi-squared, df = 3",
             panel = "one component tested against two"),
  data.frame(q = qgrid, p = ecdf_b(qgrid), series = "simulated",
             panel = "two components tested against three"),
  data.frame(q = qgrid, p = pchisq(qgrid, 1), series = "chi-squared, df = 1",
             panel = "two components tested against three"),
  data.frame(q = qgrid, p = pchisq(qgrid, 2), series = "chi-squared, df = 2",
             panel = "two components tested against three"),
  data.frame(q = qgrid, p = pchisq(qgrid, 3), series = "chi-squared, df = 3",
             panel = "two components tested against three"))
null_df$series <- factor(null_df$series,
                         levels = c("simulated", "chi-squared, df = 1",
                                    "chi-squared, df = 2", "chi-squared, df = 3"))

ggplot(null_df, aes(q, p, colour = series, linewidth = series)) +
  geom_line() +
  facet_wrap(~ panel) +
  scale_colour_manual(values = c(te_pal$ink, te_pal$clay, te_pal$gold, te_pal$green),
                      name = NULL) +
  scale_linewidth_manual(values = c(1.4, 0.7, 0.7, 0.7), guide = "none") +
  scale_x_continuous(breaks = c(0, 2.5, 5, 7.5, 10)) +
  labs(title = "The null distribution is not chi-squared",
       x = "likelihood ratio statistic", y = "probability at or below") +
  theme_te() +
  theme(panel.spacing.x = grid::unit(14, "pt"),
        plot.margin = margin(6, 12, 6, 6))
Two panels on cream paper, each with four rising curves. In both panels the thick dark simulated curve jumps straight up at the left edge, to about one sixth of the way up in the left panel and to roughly three tenths in the right, and then climbs steeply rather than gently: in the left panel it is half way up before the statistic reaches one and nine tenths of the way up by about four, flattening only towards the top right corner. The clay one degree of freedom curve starts at the origin, overtakes the simulated curve almost at once and stays above it across the middle of both panels. The gold two degree of freedom and mid-green three degree of freedom curves stay below the simulated curve through most of the range before all four converge at the top right corner.
Figure 1: Cumulative distribution of the likelihood ratio statistic under two nulls: one true component tested against two (left) and two true components tested against three (right). The thick dark line is the simulated distribution; the three thinner lines are chi-squared references on one, two and three degrees of freedom. Both simulated curves start well above zero on the vertical axis, which is the point mass produced when the larger model collapses onto the smaller one.

At the second level the point mass at zero has grown to 0.3067 of replicates, up from 0.1633, and the false positive rate on two degrees of freedom has moved to 0.0333 from 0.0467. The one degree of freedom rate stays high at 0.1133. So the reference that looked calibrated in the first setting has drifted in the second, in a direction nobody could have predicted from the first, and the choice that looked most conservative in theory is wrong by a factor of about two in both.

The figure shows why summarising this with a single false positive rate hides the problem. Both simulated curves leave the vertical axis at a positive height, because a sizeable share of data sets give a two-component fit that is numerically identical to the one-component fit. Then they climb slowly, cross the one degree of freedom reference early, and end up wedged between the two and three degree of freedom curves. There is no degrees of freedom setting that matches the whole curve. Picking one is picking which part of the distribution to get right, and the part a test needs is the part nobody checks.

The bootstrap version, and what it costs

The repair is old and simple. If you do not know the null distribution of the statistic, simulate it. Fit the k-component model to the real data, treat the fitted model as the truth, generate many data sets from it, refit both the k and the k plus one component models to each, and see where the observed statistic falls in that pile. The p-value is the rank of the observed statistic among the simulated ones, with the observed value counted in the numerator and denominator so the test stays valid at small numbers of replicates.

boot_lrt <- function(x, boot_b) {
  f1 <- fit_one(x)
  stat_obs <- 2 * (em_mix(x, 2, max_iter = 35)$ll - f1$ll)
  n <- length(x)
  stat_b <- numeric(boot_b)
  for (b in seq_len(boot_b)) {
    xb <- rnorm(n, f1$mu, f1$sg)
    stat_b[b] <- 2 * (em_mix(xb, 2, max_iter = 35)$ll - fit_one(xb)$ll)
  }
  c(stat = stat_obs, p = (1 + sum(stat_b >= stat_obs)) / (boot_b + 1))
}

set.seed(20260805)
n_sets <- 60
boot_b <- 99
n_c <- 60
res_boot <- t(sapply(seq_len(n_sets), function(s) boot_lrt(rnorm(n_c), boot_b)))
p_boot <- res_boot[, "p"]
p_naive1 <- pchisq(res_boot[, "stat"], 1, lower.tail = FALSE)
p_naive2 <- pchisq(res_boot[, "stat"], 2, lower.tail = FALSE)
print(c(null_data_sets = n_sets, bootstrap_replicates = boot_b, n = n_c,
        em_fits = n_sets * (boot_b + 1)))
      null_data_sets bootstrap_replicates                    n 
                  60                   99                   60 
             em_fits 
                6000 
print(round(c(size_boot_05 = mean(p_boot <= 0.05),
              size_boot_10 = mean(p_boot <= 0.10),
              size_boot_20 = mean(p_boot <= 0.20),
              size_naive_df1_05 = mean(p_naive1 <= 0.05),
              size_naive_df2_05 = mean(p_naive2 <= 0.05),
              mean_p_boot = mean(p_boot), uniform_mean_p = 0.5,
              mc_se_of_size = sqrt(0.05 * 0.95 / n_sets)), 4))
     size_boot_05      size_boot_10      size_boot_20 size_naive_df1_05 
           0.0667            0.0833            0.1333            0.1000 
size_naive_df2_05       mean_p_boot    uniform_mean_p     mc_se_of_size 
           0.0333            0.5553            0.5000            0.0281 

Across 60 null data sets of 60 observations each, the bootstrap test rejects at the five per cent level in 0.0667 of them, at the ten per cent level in 0.0833, and at the twenty per cent level in 0.1333. The Monte Carlo standard error on the first of those, with 60 data sets, is 0.0281, so all three sit within sampling noise of their nominal levels. The mean bootstrap p-value is 0.5553, against 0.5000 for a perfectly uniform p-value. On the same data sets the naive test on one degree of freedom rejects 0.1000 of the time and the two degree of freedom version 0.0333.

lev <- seq(0.01, 0.99, by = 0.01)
size_df <- rbind(
  data.frame(level = lev, rate = sapply(lev, function(a) mean(p_boot <= a)),
             series = "bootstrap"),
  data.frame(level = lev, rate = sapply(lev, function(a) mean(p_naive1 <= a)),
             series = "chi-squared, df = 1"),
  data.frame(level = lev, rate = sapply(lev, function(a) mean(p_naive2 <= a)),
             series = "chi-squared, df = 2"))

ggplot(size_df, aes(level, rate, colour = series)) +
  geom_abline(intercept = 0, slope = 1, linetype = "dashed",
              colour = te_pal$ink, linewidth = 0.5) +
  geom_step(linewidth = 1) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay, te_pal$gold),
                      name = NULL) +
  labs(title = "Calibration of three tests on the same null data",
       x = "nominal level", y = "measured rejection rate") +
  theme_te()
Three step curves on cream paper rising from the bottom left towards the top right, with a dashed straight diagonal as reference. The clay curve for the one degree of freedom chi-squared test lies above the diagonal over the left half of the range, then crosses it and falls away below. The dark green bootstrap curve runs close to the diagonal at the far left, sags below it through the middle, then crosses above it well before the right-hand edge and stays above for the rest of the range, flattening out at a rejection rate of one while the nominal level is still short of one. The gold two degree of freedom curve stays below the diagonal from one end to the other and finishes about three quarters of the way up.
Figure 2: Rejection rate against nominal level for three versions of the same test, computed on the same 60 simulated null data sets. The dashed diagonal is what a correctly calibrated test would trace. The one degree of freedom chi-squared test in clay sits above the diagonal over the left half; the bootstrap in dark green starts on the diagonal and sags below it in the middle; the two degree of freedom version in gold stays below throughout.

The figure shows where each curve pays. The bootstrap sits on the diagonal at the left edge, to within Monte Carlo error, and that is the part that matters, because a test is used at a small level and the calibration there is the calibration you spend; it then sags below the diagonal through the middle of the range, so it is mildly conservative at levels nobody reports. The one degree of freedom curve does the opposite, and in the more expensive direction: over the whole left half of the range it rejects more often than it promises.

The price is arithmetic. Each bootstrap test above cost 100 EM fits of the two-component model, one for the observed data and 99 for the simulated nulls, and measuring the size of the test took 6000 fits in total. The one-component fits are free because the normal maximum likelihood estimates are closed form. Against a single chi-squared lookup, that is a factor of 100.

How many replicates does the test actually need? The smallest attainable p-value is one over the number of replicates plus one, so the count sets a floor on what the test can report, and it also sets the exact size of the test at a given level.

b_grid <- c(9, 19, 49, 99, 399, 1999)
print(data.frame(B = b_grid,
                 smallest_p = round(1 / (b_grid + 1), 4),
                 exact_size_at_05 = round(floor(0.05 * (b_grid + 1)) / (b_grid + 1), 4),
                 mc_se_at_p_05 = round(sqrt(0.05 * 0.95 / b_grid), 4),
                 em_fits_per_test = b_grid + 1))
     B smallest_p exact_size_at_05 mc_se_at_p_05 em_fits_per_test
1    9     0.1000             0.00        0.0726               10
2   19     0.0500             0.05        0.0500               20
3   49     0.0200             0.04        0.0311               50
4   99     0.0100             0.05        0.0219              100
5  399     0.0025             0.05        0.0109              400
6 1999     0.0005             0.05        0.0049             2000

With 9 replicates the smallest reportable p-value is 0.1000 and the exact size of a five per cent test is 0.00: the test can never reject, whatever the data say. With 49 replicates the size is 0.04 rather than 0.0500, because the achievable p-values step in units of 0.0200 and none of them lands on the nominal level. 99 replicates is the smallest count that gives exactly 0.05 while still allowing a p-value low enough to be worth quoting, and its Monte Carlo standard error near p equal to 0.0500 is 0.0219. If the reported p-value has to separate 0.0200 from 0.0500, that error has to come down, and it only comes down as the square root: 399 replicates gives 0.0109 and 1999 gives 0.0049.

So the practical recommendation is 99 replicates for a decision at the five per cent level, and 399 or more if the p-value itself is going into the paper. Multiply by the cost of the EM fits and by the number of values of k under consideration, and a full model selection over one to four components is several thousand fits. That is minutes rather than seconds, and it is why the information criteria stay popular.

AIC, BIC and the entropy penalty

The criteria cost one fit each and no simulation. All three are on the deviance scale here, so the smallest value wins. The comparison worth making is not which formula is prettiest but how often each one lands on the number of components that generated the data. Run six cells: one true component; two true components at separations of two, three and five standard deviations; three true components at separations of three and five. Fit one to four components in the equal-variance family and record what each criterion picks.

gen_cell <- function(kt, d, n) {
  if (kt == 1) rnorm(n) else r_mix(n, rep(1 / kt, kt), (seq_len(kt) - 1) * d, rep(1, kt))
}

cells <- list(c(1, 0), c(2, 2), c(2, 3), c(2, 5), c(3, 3), c(3, 5))
rep_c <- 60
n_d <- 250
set.seed(20260802)
grid_c <- NULL
for (cl in cells) {
  kt <- cl[1]
  d <- cl[2]
  sel <- matrix(0L, rep_c, 3)
  for (r in seq_len(rep_c)) {
    x <- gen_cell(kt, d, n_d)
    cm <- rbind(crit_all(fit_one(x), n_d),
                crit_all(em_mix(x, 2, max_iter = 50), n_d),
                crit_all(em_mix(x, 3, max_iter = 50, starts = 2), n_d),
                crit_all(em_mix(x, 4, max_iter = 50, starts = 2), n_d))
    sel[r, ] <- apply(cm[, 1:3], 2, which.min)
  }
  grid_c <- rbind(grid_c, data.frame(
    k_true = kt, delta = d,
    aic = mean(sel[, 1] == kt), bic = mean(sel[, 2] == kt), icl = mean(sel[, 3] == kt),
    aic_over = mean(sel[, 1] > kt), bic_over = mean(sel[, 2] > kt),
    icl_over = mean(sel[, 3] > kt),
    bic_under = mean(sel[, 2] < kt), icl_under = mean(sel[, 3] < kt)))
}
print(c(replicates_per_cell = rep_c, n = n_d, candidate_k_min = 1, candidate_k_max = 4))
replicates_per_cell                   n     candidate_k_min     candidate_k_max 
                 60                 250                   1                   4 
print(round(grid_c, 3))
  k_true delta   aic   bic icl aic_over bic_over icl_over bic_under icl_under
1      1     0 0.900 1.000   1    0.100    0.000        0     0.000         0
2      2     2 0.617 0.117   0    0.017    0.000        0     0.883         1
3      2     3 0.850 1.000   0    0.150    0.000        0     0.000         1
4      2     5 0.933 1.000   1    0.067    0.000        0     0.000         0
5      3     3 1.000 0.833   0    0.000    0.000        0     0.167         1
6      3     5 0.933 0.983   1    0.067    0.017        0     0.000         0
print(round(colMeans(grid_c[, 3:10]), 4))
      aic       bic       icl  aic_over  bic_over  icl_over bic_under icl_under 
   0.8722    0.8222    0.5000    0.0667    0.0028    0.0000    0.1750    0.5000 
lab_c <- ifelse(grid_c$k_true == 1, "k = 1",
                paste0("k = ", grid_c$k_true, ", sep ", grid_c$delta))
crit_df <- rbind(
  data.frame(cell = lab_c, rate = grid_c$aic, series = "AIC"),
  data.frame(cell = lab_c, rate = grid_c$bic, series = "BIC"),
  data.frame(cell = lab_c, rate = grid_c$icl, series = "ICL"))
crit_df$cell <- factor(crit_df$cell, levels = lab_c)
zero_df <- crit_df
zero_df$mark <- ifelse(zero_df$rate == 0, "0", "")
zero_df$rate <- 0.012

ggplot(crit_df, aes(cell, rate, fill = series)) +
  geom_col(position = position_dodge(width = 0.8), width = 0.72) +
  geom_text(data = zero_df, aes(cell, rate, group = series, label = mark),
            position = position_dodge(width = 0.8), vjust = 0, size = 3.2,
            colour = te_pal$clay, inherit.aes = FALSE) +
  scale_fill_manual(values = c(te_pal$green, te_pal$gold, te_pal$clay), name = NULL) +
  scale_y_continuous(limits = c(0, 1.02), expand = c(0, 0)) +
  labs(title = "How often each criterion recovers the truth",
       x = NULL, y = "proportion of replicates correct") +
  theme_te() +
  theme(plot.margin = margin(6, 14, 6, 6))
A grouped bar chart on cream paper with six groups along the horizontal axis and three bars per group. In the leftmost group, one true component, the gold and clay bars reach the top and the green bar is slightly lower. In the second group, two components separated by two standard deviations, the green bar is around six tenths, the gold bar is low and the clay bar has no height at all, its place held by a small printed zero sitting on the axis. The clay bar is a printed zero again in the third and fifth groups and at full height in the fourth and sixth. The gold bars reach the top in exactly three of the six groups, the first, third and fourth; in the sixth the gold bar stops visibly short of the top gridline, in the fifth it reaches about five sixths, and in the second it is low.
Figure 3: Proportion of 60 replicates per cell in which each criterion selected the true number of components, for six generating configurations at n = 250. AIC is mid-green, BIC gold, ICL clay. ICL reaches one in three cells, the single-component cell and the two whose components are five standard deviations apart, and drops to zero in the other three, where the separation is two or three standard deviations. A printed zero marks each cell where a bar has no height.

Averaged over the six cells, AIC recovers the truth 0.8722 of the time and BIC 0.8222. That is the wrong way round if the received advice is right, and the row that flips it is the one with two components separated by two standard deviations: AIC gets that cell right 0.617 of the time and BIC 0.117. The received advice is not wrong about the mechanism. AIC does over-select, at an average rate of 0.0667 against BIC’s 0.0028. What the advice leaves out is that BIC’s own error, under-selecting, runs at 0.1750 across the same cells, and in this grid the under-selection costs more than the over-selection does.

Which of those errors you would rather make is not a statistical question. If the second component is a hypothesis to be defended in a paper, BIC’s caution is the right default and AIC’s 0.100 over-selection rate on pure one-component data is exactly the false discovery you do not want. If the mixture is a density estimate feeding into something else, an extra component costs two parameters and improves the fit, and missing a real one is the worse outcome.

ICL is not competing in the same event. Its column in the table is all ones and zeros: it is right in every replicate of the one-component cell and of the two cells where the components are five standard deviations apart, and wrong in every replicate of the three cells where they overlap. Averaged over the grid that is a recovery rate of 0.5000, an under-selection rate of 0.5000 and an over-selection rate of 0.0000. That is not a defect. The entropy term makes ICL ask whether the components can be told apart, not whether the density needs them.

Where BIC and ICL disagree, and who is right

The cleanest disagreement is at a separation of three. Generate two components that far apart with equal weights and a comfortable sample size, fit both models, and record how each criterion votes along with how well the fitted mixture can actually label individuals. The Bayes limit on classification accuracy at separation d, with equal weights and unit variances, is one minus the normal tail below minus d over two, so it is available in closed form to check the fitted classifier against.

set.seed(20260806)
rep_e <- 60
n_e <- 400
dis <- NULL
for (d in c(3, 5)) {
  acc <- ent <- dbic <- dicl <- numeric(rep_e)
  for (r in seq_len(rep_e)) {
    z <- sample.int(2, n_e, TRUE)
    x <- rnorm(n_e, c(0, d)[z], 1)
    c1 <- crit_all(fit_one(x), n_e)
    f2 <- em_mix(x, 2, max_iter = 60, starts = 2)
    c2 <- crit_all(f2, n_e)
    hit <- mean(max.col(f2$resp) == z)
    acc[r] <- max(hit, 1 - hit)
    ent[r] <- c2["ent"]
    dbic[r] <- c1["bic"] - c2["bic"]
    dicl[r] <- c1["icl"] - c2["icl"]
  }
  dis <- rbind(dis, data.frame(
    separation = d, accuracy = mean(acc), bayes_accuracy = 1 - pnorm(-d / 2),
    misassigned = mean(1 - acc) * n_e, entropy = mean(ent),
    bic_gain = mean(dbic), icl_gain = mean(dicl),
    bic_picks_two = sum(dbic > 0), icl_picks_two = sum(dicl > 0)))
}
print(c(replicates = rep_e, n = n_e))
replicates          n 
        60        400 
print(round(dis, 3))
  separation accuracy bayes_accuracy misassigned entropy bic_gain icl_gain
1          3    0.933          0.933      26.867  66.192   40.251  -92.134
2          5    0.993          0.994       2.700   6.974  243.728  229.780
  bic_picks_two icl_picks_two
1            60             0
2            60            60
print(round(c(misassigned_sep3 = dis$misassigned[1],
              misassigned_sep5 = dis$misassigned[2]), 1))
misassigned_sep3 misassigned_sep5 
            26.9              2.7 

At a separation of 3, BIC prefers two components in 60 of the 60 replicates and ICL prefers two in 0 of them. The average BIC gain for the second component is 40.251 in its favour and the average ICL gain is -92.134, which is to say ICL is against by a wide margin. The classification entropy of the two-component fit averages 66.192 over 400 observations, and doubling that is more than enough to swamp the likelihood gain.

Both are right about different things, and the accuracy column says which is which. The fitted two-component model assigns 0.933 of individuals to the correct component, against a Bayes limit of 0.933 for the true parameters. The fit is essentially at the ceiling, and the ceiling still leaves about 26.9 of the 400 fish in the wrong group. If the question is whether the length distribution is a mixture, BIC’s answer is correct and ICL’s is a false negative. If the question is which fish belong to which cohort, ICL is telling you that the answer will be wrong for 26.9 of them however well the mixture is fitted, and that a table of per-individual assignments would be reporting noise as structure.

At a separation of 5 the disagreement vanishes. Accuracy climbs to 0.993 against a Bayes limit of 0.994, entropy falls to 6.974, and both criteria pick two components in all 60 replicates. The rule that comes out of this is short: use BIC when the model is the deliverable and ICL when the labels are.

How much separation, and how many animals

The textbook fact about two equal-weight normal components with a common variance is that the mixture density stops being bimodal once the means are closer than about two standard deviations. Below that the density has one hump and looks like a slightly wide normal. The optimistic reading of that fact is that a mixture can be fitted and selected below the bimodality threshold, because the likelihood is sensitive to shape and not only to the number of modes. Whether that optimism survives at real sample sizes is a measurement.

deltas <- c(2, 2.5, 3, 4)
n_grid <- c(50, 100, 200, 400, 800)
rep_f <- 60
set.seed(20260803)
grid_d <- NULL
for (d in deltas) {
  for (nn in n_grid) {
    hits <- 0L
    for (r in seq_len(rep_f)) {
      x <- r_mix(nn, c(0.5, 0.5), c(0, d), c(1, 1))
      b_two <- as.numeric(crit_all(em_mix(x, 2, max_iter = 50), nn)["bic"])
      b_one <- as.numeric(crit_all(fit_one(x), nn)["bic"])
      hits <- hits + (b_two < b_one)
    }
    grid_d <- rbind(grid_d, data.frame(separation = d, n = nn, power = hits / rep_f))
  }
}
print(c(replicates_per_cell = rep_f, separations = length(deltas),
        sample_sizes = length(n_grid), target_power = 0.8))
replicates_per_cell         separations        sample_sizes        target_power 
               60.0                 4.0                 5.0                 0.8 
print(round(grid_d, 3))
   separation   n power
1         2.0  50 0.083
2         2.0 100 0.150
3         2.0 200 0.167
4         2.0 400 0.333
5         2.0 800 0.633
6         2.5  50 0.167
7         2.5 100 0.333
8         2.5 200 0.717
9         2.5 400 0.950
10        2.5 800 1.000
11        3.0  50 0.533
12        3.0 100 0.800
13        3.0 200 1.000
14        3.0 400 1.000
15        3.0 800 1.000
16        4.0  50 0.983
17        4.0 100 1.000
18        4.0 200 1.000
19        4.0 400 1.000
20        4.0 800 1.000
n_needed <- sapply(deltas, function(d) {
  s <- grid_d[grid_d$separation == d, ]
  if (max(s$power) < 0.8) return(NA_real_)
  if (min(s$power) >= 0.8) return(min(s$n))
  i <- which(s$power >= 0.8)[1]
  lo <- s[i - 1, ]
  hi <- s[i, ]
  exp(log(lo$n) + (0.8 - lo$power) / (hi$power - lo$power) * (log(hi$n) - log(lo$n)))
})
print(data.frame(separation = deltas, n_for_power_0.8 = round(n_needed, 1)))
  separation n_for_power_0.8
1        2.0              NA
2        2.5           256.2
3        3.0           100.0
4        4.0            50.0
print(round(c(power_sep2_at_800 = grid_d$power[grid_d$separation == 2 & grid_d$n == 800],
              power_sep2.5_at_200 = grid_d$power[grid_d$separation == 2.5 & grid_d$n == 200],
              power_sep3_at_100 = grid_d$power[grid_d$separation == 3 & grid_d$n == 100],
              power_sep4_at_50 = grid_d$power[grid_d$separation == 4 & grid_d$n == 50]), 4))
  power_sep2_at_800 power_sep2.5_at_200   power_sep3_at_100    power_sep4_at_50 
             0.6333              0.7167              0.8000              0.9833 
plot_d <- grid_d
plot_d$series <- factor(plot_d$separation, levels = deltas,
                        labels = paste("separation", deltas))

ggplot(plot_d, aes(n, power, colour = series)) +
  geom_hline(yintercept = 0.8, linetype = "dashed",
             colour = te_pal$ink, linewidth = 0.5) +
  geom_line(linewidth = 1) +
  geom_point(aes(size = series), shape = 21, fill = te_pal$paper, stroke = 1.1) +
  scale_size_manual(values = c(4.0, 3.1, 2.3, 1.5), guide = "none") +
  scale_x_log10(breaks = n_grid) +
  scale_y_continuous(limits = c(0, 1.02)) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$green, te_pal$gold, te_pal$clay),
                      name = NULL) +
  labs(title = "Detecting a second component",
       x = "sample size", y = "probability BIC prefers two components") +
  theme_te()
Four rising curves on cream paper against a logarithmic horizontal axis running from fifty to eight hundred, each marked at the five sample sizes by a hollow ring, the rings drawn at different sizes so that where two series coincide the larger ring encircles the smaller. The clay curve for a separation of four starts at the top and stays flat. The gold curve for three rises steeply from about half at fifty to the ceiling by two hundred and then runs along the top underneath the clay curve, its rings still showing at four hundred and eight hundred. The mid-green curve for two and a half climbs from low values and crosses the dashed reference line between two hundred and four hundred. The dark forest curve for two rises slowly and ends around two thirds of the way up at eight hundred, still below the dashed line.
Figure 4: Probability that BIC prefers two components over one, against sample size on a logarithmic axis, for four separations between the component means measured in standard deviations. The dashed line marks a probability of 0.8. The curve for a separation of two never reaches it within the range simulated.

The optimism does not survive. At a separation of two standard deviations, which is exactly where bimodality appears, BIC prefers two components in 0.633 of replicates even with 800 observations, and the interpolated sample size for a probability of 0.8 is off the end of the grid. At a separation of 2.5 the interpolated requirement is about 256.2 observations. At 3.0 it is 100.0, and at 4.0 it is at or below 50.0, the smallest sample size in the grid.

Put those numbers next to the sample sizes ecologists actually have. A netting survey that yields 200 fish, a trapping season that yields 100 small mammals, a morphometric data set of 50 museum skins: at 200 observations the separation has to be around 2.5 before the second component is found reliably, which is more separation than bimodality needs, not less. The comfortable reading, that the likelihood sees structure the eye cannot, is backwards at these sample sizes. The eye sees a shoulder at a separation of two; the selection criterion needs to get to about 2.5 or 3.0 before it commits.

There is one honest qualification. This measures BIC specifically. AIC, from the previous section, picks up the two-component case at a separation of two far more often, at a rate of 0.617, and it pays for that with its over-selection rate on one-component data. The separation you can detect is not a property of the data; it is a property of the data plus the penalty you chose.

What unequal variances buy and cost

Every fit so far has forced the components to share a standard deviation. The unequal-variance family is more flexible and is the default in several packages, and the standard warning is that the extra freedom lets a spurious component attach itself to whatever is non-normal about the data. Test that by generating one-component data of three kinds, each standardised to unit variance: a clean normal, a skewed gamma with shape four, and a heavy-tailed t on five degrees of freedom. Then let BIC choose between one, two and three components within each family.

gen_e <- function(cond, n) {
  if (cond == "normal") rnorm(n)
  else if (cond == "skewed") (rgamma(n, 4, 1) - 4) / 2
  else rt(n, 5) / sqrt(5 / 3)
}

conds <- c("normal", "skewed", "heavy")
rep_g <- 80
n_f <- 200
set.seed(20260804)
grid_e <- NULL
for (cond in conds) {
  for (ev in c(TRUE, FALSE)) {
    khat <- integer(rep_g)
    for (r in seq_len(rep_g)) {
      x <- gen_e(cond, n_f)
      khat[r] <- which.min(c(
        crit_all(fit_one(x), n_f)["bic"],
        crit_all(em_mix(x, 2, equal_var = ev, max_iter = 50), n_f)["bic"],
        crit_all(em_mix(x, 3, equal_var = ev, max_iter = 50, starts = 2), n_f)["bic"]))
    }
    grid_e <- rbind(grid_e, data.frame(
      shape = cond, family = ifelse(ev, "equal", "unequal"),
      over_selects = mean(khat > 1), picks_three = mean(khat == 3)))
  }
}
print(c(replicates_per_cell = rep_g, n = n_f, true_components = 1))
replicates_per_cell                   n     true_components 
                 80                 200                   1 
print(data.frame(shape = grid_e$shape, family = grid_e$family,
                 over_selects = round(grid_e$over_selects, 4),
                 picks_three = round(grid_e$picks_three, 4)))
   shape  family over_selects picks_three
1 normal   equal       0.0000      0.0000
2 normal unequal       0.0000      0.0000
3 skewed   equal       0.9500      0.1125
4 skewed unequal       0.9125      0.0750
5  heavy   equal       0.1750      0.1750
6  heavy unequal       0.4375      0.0125
diff_e <- grid_e$over_selects[c(2, 4, 6)] - grid_e$over_selects[c(1, 3, 5)]
print(round(setNames(diff_e, conds), 4))
 normal  skewed   heavy 
 0.0000 -0.0375  0.2625 

On clean normal data neither family over-selects: both sit at 0.0000. That is BIC doing its job, and it also confirms that the variance floor in the fitter is holding, because an unbounded unequal-variance likelihood would have produced spurious spike components here and it did not.

The other two conditions split the standard warning in half. On heavy-tailed data the warning is exactly right: the equal-variance family over-selects 0.1750 of the time and the unequal-variance family 0.4375, a difference of 0.2625. The extra component in the unequal-variance fit is a wide, low-weight component that swallows the tails, and it is cheap enough at one extra variance parameter that BIC accepts it in nearly half the replicates.

On skewed data the warning is wrong, and it is wrong in a way that matters more than the case where it is right. Both families over-select almost always: the equal-variance family at 0.9500 and the unequal-variance family at 0.9125, a difference of -0.0375, which is on the wrong side of zero. Constraining the variances gives no protection at all against skewness. With a common standard deviation the only way to bend a symmetric fit into a skewed shape is to shift a second mean sideways, which is precisely the spurious component, and BIC buys it 0.9500 of the time on data with one population in it.

That is the finding to carry out of this section, because it inverts the usual advice. Restricting to equal variances is often recommended as the cautious choice for deciding how many components there are. It is cautious against heavy tails and useless against skewness, and skewness is the more common shape in body sizes, seed masses, clutch sizes and almost every other positive measurement in ecology. The equal-variance family also picks three components more often than the unequal-variance one on heavy-tailed data, at 0.1750 against 0.0125, which is the same mechanism seen from the other side: with variances tied, it takes more components to cover the same departure from normality.

What to take away

The likelihood ratio test for the number of components does not have a chi-squared null distribution, and the way it fails is not the way the textbook warning suggests. Its distribution has a point mass at zero, at 0.1633 of replicates in the one against two comparison and 0.3067 in the two against three, and no degrees of freedom setting fits both the body and the tail. Choosing two degrees of freedom happened to give an error rate near nominal here, at 0.0467, but the same choice gave 0.0333 one level up, and the more careful-sounding choice of one degree of freedom gave 0.1167. If a test is what you want, the parametric bootstrap version measured at 0.0667 against a nominal five per cent, and it cost 100 EM fits per test.

If a criterion is what you want, the choice is between two kinds of error rather than between right and wrong. AIC over-selected at 0.0667 and BIC under-selected at 0.1750 on the same grid, and AIC came out ahead on overall recovery, 0.8722 against 0.8222, because the grid contained a poorly separated cell where caution is expensive. ICL is answering a different question and should be read that way: it prefers components you could assign individuals to, which is why it selected one component at a separation of 3 even though the density genuinely had two and the classifier was already at its Bayes limit of 0.933.

The honest limit is larger than any of these results. The number of components is a property of a model, not of a population: no lake contains a number of normal components, and there is no sample size at which the question becomes safe, because every one of the criteria above is comparing candidate models rather than testing nature. The measurements here show that the answer moves with the penalty, the variance family and the separation, and it moves by enough to change the conclusion. The question that survives is the applied one. If a two-component fit gives you cohort means you can defend and a growth curve that holds up against known-age fish, the fit has earned its second component. If the only evidence for it is that a criterion preferred it by a few units, the second component is a modelling choice being reported as a discovery.

References

McLachlan GJ 1987 Journal of the Royal Statistical Society Series C 36(3):318-324 (10.2307/2347790)

Biernacki C, Celeux G, Govaert G 2000 IEEE Transactions on Pattern Analysis and Machine Intelligence 22(7):719-725 (10.1109/34.865189)

Fraley C, Raftery AE 2002 Journal of the American Statistical Association 97(458):611-631 (10.1198/016214502760047131)

Feng ZD, McCulloch CE 1996 Journal of the Royal Statistical Society Series B 58(3):609-617 (10.1111/j.2517-6161.1996.tb02104.x)

Titterington DM, Smith AFM, Makov UE 1985 Statistical Analysis of Finite Mixture Distributions (ISBN 978-0-471-90763-3)

Dempster AP, Laird NM, Rubin DB 1977 Journal of the Royal Statistical Society Series B 39(1):1-22 (10.1111/j.2517-6161.1977.tb01600.x)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.