Getting Started with PricingBandits

PricingBandits implements the multi-armed bandit approaches to pricing experiments from Weaver, Kumar, and Jain, Nonparametric Pricing Bandits Leveraging Informational Externalities to Learn the Demand Curve (Marketing Science). The setting is that a firm is trying to maximize profits while experimenting from a fixed set of candidate prices. Each consumer is presented with a price and makes a decision whether to purchase based on their WTP. The algorithm observes only the price offered and the consumer’s purchase decision. The package’s entry point is a single function, PricingBandit().

library(PricingBandits)

The experiment environment

Everything about the demand environment is captured by one vector: the consumers’ valuations (willingness to pay), one draw per arriving consumer. The package makes no assumption about where they come from — an analytic distribution, an empirical CDF, transaction data, anything. A consumer buys if and only if their valuation exceeds the posted price.

In this vignette, we use a right-skewed Beta(2, 9) population — a difficult case, because the revenue-maximizing price sits near the bottom of the price grid — with 1,000 consumers. Every algorithm below is run against this exact same sequence of consumers, to minimize differences from luck of the draw.

set.seed(29)
valuations <- rbeta(1000, 2, 9)
prices     <- seq(10)/10          # ten candidate prices: 0.1, 0.2, ..., 1.0

The PricingBandit() arguments, one by one

The available policies:

policy Idea
"UCB" Upper Confidence Bound; every price learned independently
"TS" Thompson Sampling with independent Beta posteriors per price
"GP-UCB", "GP-TS" Prices tied together through a Gaussian-process demand curve, so each observation informs all prices
"GP-UCB-M", "GP-TS-M" Additionally impose that demand is weakly decreasing in price — the sampled curves are monotone everywhere by construction

Running every algorithm on the same consumers

Each call returns a data frame with one row per consumer (PricesTested, PurchaseDecisions), plus a diagnostics attribute with fallback counters. We reset the seed before each run so the policies’ randomness is reproducible too, while the consumer sequence stays fixed.

run <- function(policy, hetero = FALSE) {
  set.seed(1)
  PricingBandit(valuations, prices,
                policy     = policy,
                batch_size = 10,
                hetero     = hetero)
}

# Baselines: each arm learned independently
out_ucb  <- run("UCB")
out_ts   <- run("TS")

# Gaussian-process variants: the demand curve correlates the arms
out_gpucb <- run("GP-UCB")
out_gpts  <- run("GP-TS")

# Monotonic variants (basis-function construction; num_knots = 11 default)
out_gpucb_m <- run("GP-UCB-M")
out_gpts_m  <- run("GP-TS-M")

# Heterogeneous-noise versions of the monotonic algorithms
out_gpucb_m_h <- run("GP-UCB-M", hetero = TRUE)
out_gpts_m_h  <- run("GP-TS-M",  hetero = TRUE)

To judge performance we score each posted price by its expected revenue p * (1 - F(p)) under the true WTP distribution, and track cumulative revenue as a percentage of what the true optimal price would have earned:

expected_reward <- prices * (1 - pbeta(prices, 2, 9))
grid            <- seq(1e-6, 1, 1e-6)
true_optimal    <- max(grid * (1 - pbeta(grid, 2, 9)))

score <- function(out) {
  er <- expected_reward[match(out$PricesTested, prices)]
  cumsum(er) / (seq_along(er) * true_optimal) * 100
}

Results

The results below were precomputed with exactly the code above (they ship with the package so this vignette builds quickly).

res <- readRDS("vignette_results.rds")$results

if (requireNamespace("ggplot2", quietly = TRUE)) {
  library(ggplot2)
  res$family <- ifelse(grepl("TS", res$policy), "Thompson Sampling family",
                       "UCB family")
  res$variant <- ifelse(res$hetero, "heterogeneous noise", "standard")
  ggplot(res, aes(consumer, cum_pct_optimal, colour = policy,
                  linetype = variant)) +
    geom_line(linewidth = 0.6) +
    facet_wrap(~ family) +
    labs(x = "Consumers", y = "Cumulative revenue (% of true optimal)",
         colour = NULL, linetype = NULL,
         title = "All algorithms on the same 1,000 Beta(2,9) consumers") +
    coord_cartesian(ylim = c(0, 100)) +
    theme_minimal() +
    theme(legend.position = "bottom")
} else {
  final <- res[res$consumer == 1000, c("label", "cum_pct_optimal")]
  final[order(-final$cum_pct_optimal), ]
}

The ordering reflects the paper’s central result: exploiting the informational externalities — first correlation across prices (GP), then monotonicity of demand (the “-M” variants) — dramatically reduces the cost of learning, especially in this hard case where the optimal price sits at the low end of the grid.

Final standings after 1,000 consumers:

Algorithm % of optimal (cumulative, 1000 consumers)
GP-TS-M (hetero) 86.6
GP-TS-M 85.3
GP-UCB-M (hetero) 84.8
GP-UCB-M 80.7
GP-TS 69.8
TS 59.0
GP-UCB 45.8
UCB 12.4

Diagnostics

Each run counts how often its numerical fallback paths fired (hyperparameter optimization failing back to priors, truncated-sampler timeouts, last-resort samplers). In normal operation all counters are zero; a run with many last-resort events is telling you the sampler struggled with your price grid.

attr(out_gpts_m, "diagnostics")