Adverse Selection

When insurers cannot tell who is sick

Adverse selection arises when one side of a market has private information about their type. In insurance, buyers know more about their health risk than insurers do. This asymmetry can destroy markets.

The logic of the death spiral

Akerlof (1970) described the “lemons problem” for used cars, but the logic applies directly to insurance:

  1. An insurer sets a premium equal to the average expected cost of the pool
  2. Healthy (low-risk) people find the premium too high relative to their expected costs — some drop out
  3. The remaining pool is sicker on average, so costs rise
  4. The insurer raises the premium to cover costs
  5. More healthy people drop out
  6. Repeat until only the sickest people remain, or the market collapses entirely

This “death spiral” is the textbook case of market failure from asymmetric information.

The Einav-Finkelstein-Cullen diagram

Einav, Finkelstein, and Cullen (2010) provided an elegant graphical framework. Plot willingness to pay (demand) and average cost against the quantity of people insured. Under adverse selection, the marginal enrollee is healthier than the average enrollee, so the average cost curve lies above the demand curve in part of the range. The efficient quantity is where the marginal cost curve intersects demand, but the market equilibrium is where the average cost curve intersects demand — too few people are insured.

The death spiral

#| standalone: true
#| viewerHeight: 680

library(shiny)

ui <- fluidPage(
  tags$head(tags$style(HTML("
    .stats-box {
      background: #f0f4f8; border-radius: 6px; padding: 14px;
      margin-top: 12px; font-size: 14px; line-height: 1.9;
    }
    .stats-box b { color: #2c3e50; }
    .good { color: #27ae60; font-weight: bold; }
    .bad  { color: #e74c3c; font-weight: bold; }
    .info-box {
      background: #ebf5fb; border-radius: 6px; padding: 12px;
      margin-top: 10px; font-size: 13px;
    }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      sliderInput("frac_sick", "Fraction sick in population:",
                  min = 0.1, max = 0.9, value = 0.3, step = 0.05),

      sliderInput("cost_sick", "Annual cost if sick ($):",
                  min = 5000, max = 50000, value = 20000, step = 1000),

      sliderInput("cost_healthy", "Annual cost if healthy ($):",
                  min = 500, max = 10000, value = 2000, step = 500),

      radioButtons("rating", "Pricing rule:",
                   choices = c("Community rating (pooled)" = "community",
                               "Risk rating (separate)" = "risk"),
                   selected = "community"),

      tags$hr(),
      uiOutput("info_box")
    ),

    mainPanel(
      width = 9,
      plotOutput("spiral_plot", height = "280px"),
      plotOutput("efc_plot", height = "300px")
    )
  )
)

server <- function(input, output, session) {

  vals <- reactive({
    fs <- input$frac_sick
    cs <- input$cost_sick
    ch <- input$cost_healthy

    # WTP: sick people will pay up to their expected cost + risk premium
    # Healthy people will pay less
    wtp_sick    <- cs * 1.1
    wtp_healthy <- ch * 1.5

    # Under community rating: one premium for all
    avg_cost    <- fs * cs + (1 - fs) * ch
    premium_com <- avg_cost * 1.05  # 5% loading

    # Who buys under community rating?
    sick_buy    <- wtp_sick >= premium_com
    healthy_buy <- wtp_healthy >= premium_com

    # Simulate death spiral rounds
    pool_sick <- fs
    pool_healthy <- 1 - fs
    premiums <- c()
    frac_insured <- c()
    rounds <- 8

    ps <- pool_sick
    ph <- pool_healthy

    for (r in 1:rounds) {
      total <- ps + ph
      if (total <= 0) {
        premiums <- c(premiums, NA)
        frac_insured <- c(frac_insured, 0)
        next
      }

      avg <- (ps * cs + ph * ch) / total
      prem <- avg * 1.05

      premiums <- c(premiums, prem)
      frac_insured <- c(frac_insured, total)

      # Healthy people drop out if premium > WTP
      # Fraction of healthy who stay: decreasing in premium
      healthy_stay_rate <- max(0, min(1, (wtp_healthy - prem) / wtp_healthy + 0.5))
      sick_stay_rate    <- max(0, min(1, (wtp_sick - prem) / wtp_sick + 0.5))

      ph <- ph * healthy_stay_rate
      ps <- ps * sick_stay_rate
    }

    # EFC diagram: rank people by WTP (highest first)
    # Demand curve: WTP as function of quantity insured
    n_people <- 100
    n_sick   <- round(fs * n_people)
    n_h      <- n_people - n_sick

    # Each person has a type and a WTP with some noise
    set.seed(42)
    types <- c(rep("sick", n_sick), rep("healthy", n_h))
    costs <- ifelse(types == "sick", cs, ch)
    wtp   <- ifelse(types == "sick",
                    cs * (1 + runif(n_people, 0, 0.3))[1:n_people],
                    ch * (1 + runif(n_people, 0.2, 0.8))[1:n_people])
    wtp <- wtp[1:n_people]
    costs <- costs[1:n_people]

    # Sort by WTP descending (highest WTP buys first)
    ord <- order(wtp, decreasing = TRUE)
    wtp_sorted <- wtp[ord]
    costs_sorted <- costs[ord]

    # Average cost curve: mean cost of first Q enrollees
    avg_cost_curve <- cumsum(costs_sorted) / (1:n_people)
    # Marginal cost curve
    mc_curve <- costs_sorted

    # Market equilibrium: where demand = average cost
    eq_q <- NA
    for (i in 1:n_people) {
      if (wtp_sorted[i] < avg_cost_curve[i]) {
        eq_q <- i - 1
        break
      }
    }
    if (is.na(eq_q)) eq_q <- n_people

    # Efficient quantity: where demand = marginal cost
    eff_q <- NA
    for (i in 1:n_people) {
      if (wtp_sorted[i] < mc_curve[i]) {
        eff_q <- i - 1
        break
      }
    }
    if (is.na(eff_q)) eff_q <- n_people

    dwl <- 0
    if (eff_q > eq_q && eq_q > 0) {
      # DWL = sum of (WTP - MC) for people between eq_q and eff_q
      idx <- (eq_q + 1):min(eff_q, n_people)
      dwl <- sum(wtp_sorted[idx] - mc_curve[idx])
    }

    eq_prem <- if (eq_q > 0) avg_cost_curve[eq_q] else NA

    list(premiums = premiums, frac_insured = frac_insured,
         wtp_sorted = wtp_sorted, avg_cost_curve = avg_cost_curve,
         mc_curve = mc_curve, eq_q = eq_q, eff_q = eff_q,
         dwl = dwl, n_people = n_people, eq_prem = eq_prem,
         rating = input$rating, fs = fs, cs = cs, ch = ch)
  })

  output$spiral_plot <- renderPlot({
    v <- vals()

    par(mar = c(4, 5, 3, 1))
    rounds <- 1:length(v$premiums)
    valid  <- !is.na(v$premiums)

    if (sum(valid) < 2) {
      plot.new()
      text(0.5, 0.5, "Market collapsed immediately", cex = 1.5, col = "#e74c3c")
      return()
    }

    plot(rounds[valid], v$premiums[valid] / 1000, type = "b", pch = 19, lwd = 2.5,
         col = "#e74c3c", xlab = "Round", ylab = "Premium ($1000s)",
         main = "Death Spiral: Premium Over Time",
         ylim = c(0, max(v$premiums[valid], na.rm = TRUE) / 1000 * 1.2),
         cex.lab = 1.1)

    par(new = TRUE)
    plot(rounds[valid], v$frac_insured[valid], type = "b", pch = 17, lwd = 2.5,
         col = "#3498db", axes = FALSE, xlab = "", ylab = "",
         ylim = c(0, 1.2))
    axis(4, col = "#3498db", col.axis = "#3498db")
    mtext("Fraction insured", side = 4, line = 2.5, col = "#3498db")

    legend("right", bty = "n", cex = 0.85,
           legend = c("Premium", "Fraction insured"),
           col = c("#e74c3c", "#3498db"), pch = c(19, 17), lwd = 2)
  })

  output$efc_plot <- renderPlot({
    v <- vals()

    par(mar = c(5, 5, 3, 1))
    q_seq <- 1:v$n_people

    plot(q_seq, v$wtp_sorted / 1000, type = "l", lwd = 3, col = "#2c3e50",
         xlab = "Quantity insured (people)", ylab = "$ (thousands)",
         main = "Demand-Cost Diagram (Einav-Finkelstein-Cullen)",
         ylim = c(0, max(v$wtp_sorted / 1000) * 1.1),
         cex.lab = 1.1)

    lines(q_seq, v$avg_cost_curve / 1000, lwd = 3, col = "#e74c3c")
    lines(q_seq, v$mc_curve / 1000, lwd = 2, col = "#e74c3c", lty = 2)

    # Shade DWL
    if (v$eff_q > v$eq_q && v$eq_q > 0) {
      idx <- v$eq_q:v$eff_q
      polygon(c(idx, rev(idx)),
              c(v$wtp_sorted[idx] / 1000, rev(v$mc_curve[idx] / 1000)),
              col = adjustcolor("#f39c12", 0.3), border = NA)
      text((v$eq_q + v$eff_q) / 2,
           mean(c(v$wtp_sorted[v$eq_q], v$mc_curve[v$eq_q])) / 1000,
           "DWL", col = "#e67e22", font = 2, cex = 1.1)
    }

    # Mark equilibrium and efficient quantities
    if (v$eq_q > 0 && v$eq_q <= v$n_people) {
      abline(v = v$eq_q, lty = 3, col = "#7f8c8d")
      text(v$eq_q, max(v$wtp_sorted / 1000) * 0.95,
           paste0("Mkt eq (Q=", v$eq_q, ")"), col = "#7f8c8d",
           cex = 0.85, adj = 0)
    }
    if (v$eff_q > 0 && v$eff_q <= v$n_people) {
      abline(v = v$eff_q, lty = 3, col = "#27ae60")
      text(v$eff_q, max(v$wtp_sorted / 1000) * 0.85,
           paste0("Efficient (Q=", v$eff_q, ")"), col = "#27ae60",
           cex = 0.85, adj = 0)
    }

    legend("topright", bty = "n", cex = 0.85,
           legend = c("Demand (WTP)", "Average cost", "Marginal cost", "DWL"),
           col = c("#2c3e50", "#e74c3c", "#e74c3c",
                   adjustcolor("#f39c12", 0.5)),
           lwd = c(3, 3, 2, 8), lty = c(1, 1, 2, 1))
  })

  output$info_box <- renderUI({
    v <- vals()

    eq_prem_txt <- if (!is.na(v$eq_prem)) {
      paste0("$", format(round(v$eq_prem), big.mark = ","))
    } else { "No equilibrium" }

    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>Market equilibrium:</b><br>",
        "Premium: ", eq_prem_txt, "<br>",
        "Insured: <span class='", ifelse(v$eq_q < v$eff_q, "bad", "good"),
        "'>", v$eq_q, " of ", v$n_people, "</span><br>",
        "Efficient: ", v$eff_q, " of ", v$n_people, "<br>",
        "<hr style='margin:8px 0'>",
        "<b>DWL from adverse selection:</b><br>",
        "<span class='bad'>$", format(round(v$dwl), big.mark = ","), "</span><br>",
        "<hr style='margin:8px 0'>",
        "<b>Underinsurance:</b> ", v$eff_q - v$eq_q, " people"
      ))
    )
  })
}

shinyApp(ui, server)

Things to try

  • Increase the fraction sick (0.7+): average cost rises, healthy people are priced out, the death spiral accelerates.
  • Set fraction sick very low (0.1): the pool is mostly healthy, the premium is low, and nearly everyone buys.
  • Widen the gap between sick and healthy costs: adverse selection becomes more severe because the types are more different.
  • Compare community rating (pooled premium) to risk rating: under risk rating, each type pays their own cost and there is no adverse selection — but sick people may be priced out entirely.

Did you know?

  • George Akerlof won the Nobel Prize in 2001 (with Spence and Stiglitz) for his 1970 paper on “lemons.” His original paper was rejected by three journals — reviewers said the result was trivial or incorrect.

  • The ACA individual mandate (2010) was a direct response to adverse selection. By requiring everyone to buy insurance (or pay a penalty), the mandate kept healthy people in the pool and prevented the death spiral. When the penalty was reduced to $0 in 2019, some predicted market collapse — but the exchanges survived, partly because subsidies kept coverage affordable for many.

  • Cutler and Zeckhauser (1998) documented adverse selection in employer health plans: when Harvard University introduced a choice between a generous and a stingy plan, the generous plan experienced a classic death spiral and was eventually eliminated. This became one of the most cited examples of adverse selection in practice.

  • The Einav-Finkelstein-Cullen (2010) framework can also detect advantageous selection — when healthier people are more likely to buy insurance (perhaps because they are also more risk-averse). In this case, the average cost curve slopes up rather than down, and the market provides too much insurance rather than too little.