""" First-Price Auction Simulator for RTB Bidding Benchmark Simulates a stream of first-price auctions with: - Impression features (from Criteo_x4 for CTR) - Click labels (ground truth from Criteo) - Synthetic market prices (competing bids) conditioned on features - Full information feedback (observes all competing bids) Used to evaluate bidding algorithms under controlled conditions. """ import numpy as np from collections import defaultdict class FirstPriceAuctionSimulator: """ Simulates repeated first-price auctions. In a first-price auction: - Each bidder submits a sealed bid - Highest bidder wins and pays their bid - Losing bidders pay nothing We simulate the "advertiser" (our bidder) vs. a pool of competing bidders. The maximum competing bid d_t follows a distribution conditioned on features. """ def __init__( self, features, pctr_true, click_labels, value_per_click=50.0, market_price_config=None, seed=42 ): """ Args: features: (N, D) impression feature matrix pctr_true: (N,) true or predicted CTR values click_labels: (N,) binary click labels value_per_click: Value of each click in currency market_price_config: Dict controlling price generation seed: Random seed """ self.features = features self.pctr_true = pctr_true self.click_labels = click_labels self.value_per_click = value_per_click self.N = len(features) self.rng = np.random.RandomState(seed) # Generate synthetic market prices self.market_prices = self._generate_market_prices(market_price_config or {}) # Shuffle indices self.order = self.rng.permutation(self.N) self.position = 0 def _generate_market_prices(self, config): """ Generate synthetic market prices (max competing bids). Prices are conditioned on features: higher-CTR impressions tend to have higher competition (more bidders want them). Default: log-normal distribution with mean correlated to pCTR. """ base_mean = config.get('base_mean', 20.0) ctr_correlation = config.get('ctr_correlation', 10.0) noise_std = config.get('noise_std', 0.6) price_multiplier = config.get('price_multiplier', 1.0) N = self.N # Mean price increases with CTR (popular impressions cost more) mean_prices = base_mean + ctr_correlation * self.pctr_true mean_prices = np.clip(mean_prices, 1.0, 200.0) # Log-normal noise prices = self.rng.lognormal( mean=np.log(mean_prices), sigma=noise_std, size=N ) # Scale prices = prices * price_multiplier # Add feature-dependent variation if self.features.shape[1] > 1: # Use first two features to add correlated variation feature_effect = ( 0.02 * self.features[:, 0] + 0.01 * self.features[:, 1] ) prices = prices * np.exp(feature_effect * 0.1) return np.clip(prices, 0.5, 500.0) def reset(self): """Reset simulator to beginning of auction sequence.""" self.position = 0 self.order = self.rng.permutation(self.N) def next_auction(self): """ Return next auction: (features, pctr, true_click, market_price). Returns None when exhausted. """ if self.position >= self.N: return None idx = self.order[self.position] self.position += 1 return ( self.features[idx], self.pctr_true[idx], int(self.click_labels[idx]), self.market_prices[idx] ) def simulate_bid(self, bid): """ Simulate the outcome of placing a bid in the current auction. Must be called after next_auction(). Args: bid: Bid price Returns: (won, cost, click_occurred) """ _, _, click, market_price = ( self.features[self.order[self.position - 1]], self.pctr_true[self.order[self.position - 1]], int(self.click_labels[self.order[self.position - 1]]), self.market_prices[self.order[self.position - 1]] ) won = bid >= market_price cost = bid if won else 0.0 click_occurred = click if won else 0 return won, cost, click_occurred, market_price def run_algorithm(self, algo, ctr_predictor=None): """ Run a bidding algorithm through the full auction sequence. Args: algo: Bidding algorithm instance with .bid(pctr) and .update(won, cost, pctr, d_t) ctr_predictor: Optional CTRPredictor. If None, uses self.pctr_true. Returns: results dict with metrics """ self.reset() # Set budget on algorithm if it supports it if hasattr(algo, 'set_budget'): algo.set_budget(algo.B if hasattr(algo, 'B') else 5000) metrics = defaultdict(list) total_clicks = 0 while True: auction = self.next_auction() if auction is None: break features, _, true_click, market_price = auction # Get CTR prediction if ctr_predictor is not None: # Use learned CTR model pctr = ctr_predictor.predict_single({ f'I{i+1}': features[i] for i in range(13) } | { f'C{i+1}': features[13+i] for i in range(26) }) else: pctr = self.pctr_true[self.order[self.position - 1]] # Place bid bid = algo.bid(pctr, features) bid = np.clip(bid, 0, algo.remaining_budget if hasattr(algo, 'remaining_budget') else float('inf')) # Simulate outcome won, cost, click, d_t = self.simulate_bid(bid) if won: total_clicks += click # Update algorithm algo.update(won, cost, pctr, d_t) # Track metrics metrics['bids'].append(bid) metrics['won'].append(int(won)) metrics['cost'].append(cost) metrics['pctr'].append(pctr) metrics['market_price'].append(market_price) # Compute summary results = algo.get_stats() results.update({ 'total_clicks': total_clicks, 'total_impressions': len(metrics['bids']), 'total_wins': sum(metrics['won']), 'total_spent': sum(metrics['cost']), 'ctr': total_clicks / max(sum(metrics['won']), 1), 'budget_used_frac': sum(metrics['cost']) / algo.B if hasattr(algo, 'B') else 0, 'cpc': sum(metrics['cost']) / max(total_clicks, 1), 'avg_bid': np.mean(metrics['bids']), 'win_rate': sum(metrics['won']) / max(len(metrics['won']), 1), 'avg_market_price': np.mean(metrics['market_price']), }) return results def run_comparison(self, algorithms, ctr_predictor=None): """ Run multiple algorithms on the same auction sequence and compare. Args: algorithms: Dict of {name: algorithm_instance} ctr_predictor: Shared CTR predictor Returns: Dict of {name: results_dict} """ all_results = {} for name, algo in algorithms.items(): print(f"\nRunning {name}...") results = self.run_algorithm(algo, ctr_predictor) all_results[name] = results print(f" Clicks: {results['total_clicks']}") print(f" Spend: {results['total_spent']:.2f}") print(f" Budget used: {results.get('budget_used_frac', 0):.1%}") print(f" CPC: {results.get('cpc', 0):.2f}") return all_results