--- title: "Getting Started with PricingBandits" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting Started with PricingBandits} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 7.5, fig.height = 5) ``` `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()`. ```{r setup} 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. ```{r valuations, eval = FALSE} 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 * **`valuations`** — numeric vector, one WTP draw per consumer. Its length sets the experiment length. Prices and valuations are assumed scaled to (0, 1] (rescale your data by its maximum price, and rescale back afterwards). * **`prices`** — the candidate price grid (the bandit's arms), values in (0, 1]. Need not be evenly spaced. * **`policy`** — which algorithm prices the consumers (see the table below). * **`batch_size`** — how many consumers are served between policy updates (default 10). Larger batches mean fewer, cheaper updates but slower learning. * **`num_knots`** — for the monotonic (`"-M"`) variants only: the number of knots of the basis expansion used to build monotone demand curves. **The default of 11 is deliberate and should be kept even for dense price grids** (e.g. 100 arms): with many more knots the truncated sampler operates in a near-degenerate, high-dimensional space and can break down. * **`hetero`** — if `TRUE`, the observation-noise level at each price is re-estimated every update from the Gaussian-process posterior instead of being held at its conservative Bernoulli bound. Available for all GP variants. * **`reset`** — if set to an integer `n`, the experiment history is wiped every `n` consumers. Useful when demand shifts over time (e.g. seasonality) and old observations mislead. * **`timeout`** — seconds allowed for each truncated-sampling attempt in the monotonic fallback chain (default 5). Increase on slow machines to give the exact sampler more time; decrease to fail over to the cheaper approximations sooner. 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. ```{r runs, eval = FALSE} 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: ```{r metric, eval = FALSE} 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). ```{r plot, echo = TRUE} 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: ```{r table, echo = FALSE} final <- res[res$consumer == 1000, c("label", "cum_pct_optimal")] final <- final[order(-final$cum_pct_optimal), ] knitr::kable(final, digits = 1, row.names = FALSE, col.names = c("Algorithm", "% of optimal (cumulative, 1000 consumers)")) ``` ## 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. ```{r diag, eval = FALSE} attr(out_gpts_m, "diagnostics") ```