Conditional Logit and Loglinear Models on Toy Matching Data

matching
loglinear models
conditional logit
A small worked example comparing conditional logit and loglinear formulations for the same toy contingency table.
Author

Benjamin F. Jarvis

Published

July 3, 2026

Question

How close are the conditional logit and loglinear views of a small matching table? This note starts from a toy table of pairings between three male categories and three female categories, then fits parallel models that describe the same pattern from two different directions.

The goal is not to make a substantive claim from the toy data. The goal is to make the bookkeeping visible: counts in a contingency table can be represented as matched choices, and the model formulas then become easier to compare.

Toy Data

library(survival)
library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(tidyr)

set.seed(123)

dt <- expand_grid(m = c("a", "b", "c"), f = c("a", "b", "c")) |>
  bind_cols(n = c(40, 8, 2, 8, 14, 8, 2, 8, 10)) |>
  mutate(
    homogamy = if_else(m == f, m, "not"),
    homogamy = relevel(factor(homogamy), ref = "not")
  ) |>
  group_by(f) |>
  mutate(n_f = sum(n)) |>
  group_by(m) |>
  mutate(n_m = sum(n)) |>
  ungroup()

The marginal distribution of male categories is:

xtabs(n ~ m, data = dt)
m
 a  b  c 
50 30 20 

The full table is:

xtabs(n ~ m + f, data = dt)
   f
m    a  b  c
  a 40  8  2
  b  8 14  8
  c  2  8 10

From Counts to Choice Sets

The loglinear models work directly with the cell counts. The conditional logit models need the same information represented as individual choices. The next steps expand the table into male and female records, then recover the observed pairings implied by the cell counts.

singles_m <- dt |>
  group_by(m) |>
  summarize(n = sum(n), .groups = "drop") |>
  reframe(m = rep(m, times = n)) |>
  mutate(m_id = row_number()) |>
  group_by(m) |>
  mutate(m_order = row_number()) |>
  ungroup()

singles_f <- dt |>
  group_by(f) |>
  summarize(n = sum(n), .groups = "drop") |>
  reframe(f = rep(f, times = n)) |>
  mutate(f_id = row_number()) |>
  group_by(f) |>
  mutate(f_order = row_number()) |>
  ungroup()

cell_assignments <- dt |>
  group_by(m) |>
  mutate(
    m_start = lag(cumsum(n), default = 0) + 1,
    m_end = cumsum(n)
  ) |>
  ungroup() |>
  group_by(f) |>
  mutate(
    f_start = lag(cumsum(n), default = 0) + 1,
    f_end = cumsum(n)
  ) |>
  ungroup()

unions <- cell_assignments |>
  rowwise() |>
  mutate(
    m_order = list(m_start:m_end),
    f_order = list(f_start:f_end)
  ) |>
  unnest(c(m_order, f_order)) |>
  ungroup() |>
  select(m, f, m_order, f_order) |>
  left_join(singles_m, by = c("m", "m_order")) |>
  left_join(singles_f, by = c("f", "f_order")) |>
  select(m_id, f_id, m, f)

cset <- expand_grid(
  singles_m |> select(-m_order),
  singles_f |> select(-f_order)
) |>
  left_join(mutate(unions, choice = 1L), by = c("m_id", "f_id", "m", "f")) |>
  mutate(choice = !is.na(choice)) |>
  mutate(
    homogamy = if_else(m == f, m, "not"),
    homogamy = relevel(factor(homogamy), ref = "not")
  )

The expanded choice-set data have one row for every possible pairing. The observed unions are the rows where choice is TRUE.

cset |>
  count(choice)
# A tibble: 2 × 2
  choice     n
  <lgl>  <int>
1 FALSE   9900
2 TRUE     100

Model Fits

These four loglinear specifications and their conditional-logit counterparts are deliberately paired. The first loglinear model is saturated. There is no direct conditional-logit counterpart in this setup because conditioning on the male identifier absorbs the male-side total and leaves no comparable saturated choice model.

loglin_models <- list(
  "1a" = glm(n ~ m * f, family = poisson, data = dt),
  "1b" = glm(n ~ m * f + offset(log(n_f)), family = poisson, data = dt),
  "1c" = glm(n ~ m + f + homogamy, family = poisson, data = dt),
  "2a" = glm(n ~ m + offset(log(n_f)) + homogamy, family = poisson, data = dt)
)

clogit_models <- list(
  "1a" = NULL,
  "1b" = clogit(choice ~ m * f + strata(m_id), data = cset),
  "1c" = clogit(choice ~ f + homogamy + strata(m_id), data = cset),
  "2a" = clogit(choice ~ homogamy + strata(m_id), data = cset)
)

Predicted Counts

For the loglinear models, predicted counts are obtained directly from the fitted Poisson mean. For the conditional logit models, the linear predictors are converted into probabilities within each male choice set and then summed back to the original table cells.

loglin_predictions <- lapply(loglin_models, function(x) {
  bind_cols(dt |> select(m, f), n_hat = exp(predict(x)))
})

clogit_predictions <- lapply(clogit_models, function(x) {
  if (is.null(x)) {
    return(NULL)
  }

  bind_cols(cset, eta = predict(x)) |>
    group_by(m_id) |>
    mutate(p = exp(eta) / sum(exp(eta))) |>
    ungroup() |>
    group_by(m, f) |>
    summarize(n_hat = sum(p), .groups = "drop")
})

The paired predictions can then be compared cell by cell.

compare_predictions <- function(model_id) {
  loglin <- loglin_predictions[[model_id]] |>
    rename(loglinear = n_hat)

  clogit <- clogit_predictions[[model_id]]

  if (is.null(clogit)) {
    return(loglin)
  }

  loglin |>
    left_join(rename(clogit, conditional_logit = n_hat), by = c("m", "f")) |>
    mutate(difference = loglinear - conditional_logit)
}

compare_predictions("1c")
# A tibble: 9 × 5
  m     f     loglinear conditional_logit difference
  <chr> <chr>     <dbl>             <dbl>      <dbl>
1 a     a         40.0              40.0    7.36e-11
2 a     b          8.00              8.00   3.29e-12
3 a     c          2.00              2.00  -7.39e-11
4 b     a          8.00              8.00   1.15e-11
5 b     b         14.0              14.0    1.01e-12
6 b     c          8.00              8.00  -1.26e-11
7 c     a          2.00              2.00  -6.04e-11
8 c     b          8.00              8.00   3.28e-11
9 c     c         10.00             10.00   2.82e-11

Interpretation

The comparison is useful because the two model families emphasize different parts of the same setup. The loglinear model treats the table cells as the observations. The conditional logit model treats each observed male case as a choice among possible female cases. With the right conditioning and offsets, the fitted implications can line up closely, but the route to those fitted counts is different.

For this toy table, the most interesting comparisons are not the saturated model, but the reduced specifications that keep the broad marginal structure and then ask how much is added by category-specific homogamy terms.