From e8ef850089b2fc8addd081fafe968f43853d56da Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 31 Jan 2026 12:06:48 +0100 Subject: [PATCH 01/36] feat: introduced simple COI proxy --- engine/wrapper.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/engine/wrapper.py b/engine/wrapper.py index 0301082..7221a8a 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -3,6 +3,7 @@ from gymnasium import spaces import numpy as np from .engine import Limbo, MarketEngine, PricingEngine from .lib.render import DashboardRenderer +from .lib.coi import compute_coi_proxy class PHANTOM(gym.Env): @@ -15,11 +16,13 @@ class PHANTOM(gym.Env): N: int = 100, price_bounds: tuple = (10.0, 150.0), lambda_coi: float = 0.1, + coi_window: int = 10, render_mode: str = None): super().__init__() self.n_products = n_products self.price_bounds = price_bounds self.lambda_coi = lambda_coi + self.coi_window = coi_window # K steps for rolling COI calculation self.render_mode = render_mode self.alpha = alpha self.N = N @@ -44,15 +47,22 @@ class PHANTOM(gym.Env): self._price_history = [] self._revenue_history = [] self._renderer = None + self._initial_episode_prices = None # prices at episode start for COI calc def _get_obs(self) -> dict: demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32) return {"demand": demand_arr, "prices": self._prices.astype(np.float32)} + def _compute_coi_proxy(self): + return compute_coi_proxy( + self._price_history, self._demand_history, self._initial_episode_prices, + self._prices, self.price_bounds, self.alpha, self.coi_window + ) + def _compute_reward(self, prices: np.ndarray, demand: dict) -> float: revenue = np.sum(prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)])) - # TODO: implement supra-competitive price punishment - return float(revenue) + coi_penalty = self.lambda_coi * self._compute_coi_proxy() + return float(revenue - coi_penalty) def _record_history(self): demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)]) @@ -63,6 +73,7 @@ class PHANTOM(gym.Env): def reset(self, seed=None, options=None): super().reset(seed=seed) self._prices = np.random.uniform(*self.price_bounds, size=self.n_products) + self._initial_episode_prices = self._prices.copy() # snapshot for COI calculation self._demand = self.market.act(self._prices) self._step_count = 0 self._demand_history, self._price_history, self._revenue_history = [], [], [] @@ -75,10 +86,17 @@ class PHANTOM(gym.Env): self._step_count += 1 self._record_history() + coi_proxy = self._compute_coi_proxy() reward = self._compute_reward(self._prices, self._demand) terminated = self._step_count >= 100 - return self._get_obs(), reward, terminated, False, {"step": self._step_count} + info = { + "step": self._step_count, + "coi_proxy": coi_proxy, + "coi_penalty": self.lambda_coi * coi_proxy, + "raw_revenue": np.sum(self._prices * np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])), + } + return self._get_obs(), reward, terminated, False, info def _compute_elasticity(self) -> np.ndarray: """point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]""" From 33cb0d7e954831567b9acd6468ea858c51ecbfc6 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 31 Jan 2026 12:56:48 +0100 Subject: [PATCH 02/36] feature: refactored demand splitting and implementation --- engine/engine.py | 41 +++++++++++++++++++++++++---------------- engine/lib/__init__.py | 2 +- engine/lib/behavior.py | 6 +++--- engine/lib/demand.py | 39 +++++++++++++++++++-------------------- engine/wrapper.py | 10 +++++++++- 5 files changed, 57 insertions(+), 41 deletions(-) diff --git a/engine/engine.py b/engine/engine.py index cacac7a..a4d568d 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -1,30 +1,39 @@ from sys import platform import numpy as np -from .lib.demand import generate_demand, estimate_demand +from .lib.demand import generate_demand_for_actor, estimate_demand from .lib.behavior import sample_behavior from logging import INFO, getLogger logger = getLogger(__name__) logger.setLevel(INFO) - class MarketEngine(): + """implements separate demand distributions for humans and agents per Section 3.1.1""" + def __init__(self, - alpha = 0.5, - N = 100, - demand_distribution = (50, 10), - demand_sampling_function = np.random.normal): - self.Nagents = int(N*alpha) - self.Nhumans = int(N*(1-alpha)) - self.demand = (demand_sampling_function, demand_distribution) + alpha: float, + N: int, + human_params: tuple, + agent_params: tuple, + demand_distribution = np.random.normal, + noise_std: float = 1.0): + # no defaults for D_H, D_A - force explicit experiment design + self.alpha = alpha + self.Nagents = int(N * alpha) + self.Nhumans = int(N * (1 - alpha)) + self.human_params = human_params + self.agent_params = agent_params + self.noise_std = noise_std + self.demand_dist = demand_distribution def act(self, prices): - demand = generate_demand(prices, *self.demand) - sample_n = lambda n, human: [sample_behavior(demand, human=human) for _ in range(n)] - human_t, agent_t = sample_n(self.Nhumans, True), sample_n(self.Nagents, False) - trajectories = human_t + agent_t - demand_estimate = estimate_demand(trajectories) - return demand_estimate + # generate separate demands d() per actor type + demand_h = generate_demand_for_actor(prices, self.human_params, self.noise_std, distribution_method = self.demand_dist) + demand_a = generate_demand_for_actor(prices, self.agent_params, self.noise_std, distribution_method = self.demand_dist) + # sample behavior trajectories from each demand distribution + human_t = [sample_behavior(demand_h, human=True) for _ in range(self.Nhumans)] + agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)] + return estimate_demand(human_t + agent_t) def measure(self): pass @@ -60,7 +69,7 @@ class Limbo(): if __name__ == "__main__": platform = PricingEngine() - market = MarketEngine() + market = MarketEngine(alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)) limbo = Limbo(platform, market) for _ in range(10): limbo.step() diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 8e17835..4e8fd99 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -1,3 +1,3 @@ -from .demand import generate_demand, estimate_demand +from .demand import estimate_demand, generate_demand_for_actor from .behavior import sample_behavior from .render import DashboardRenderer, style_axis diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index 1822dde..f7dcc65 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -1,7 +1,7 @@ from sim.rl.behavior_loader.models import BehaviorModel, AgentBehaviorModel, aggregate_event_transitions import pandas as pd import numpy as np -from .demand import generate_demand +from .demand import generate_demand_for_actor base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments" human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/" @@ -41,7 +41,7 @@ def sample_behavior(condition, human=True, max_len=40): return trajectory if __name__ == "__main__": - t=sample_behavior(generate_demand(np.array([10,20,30])), human=True) + t=sample_behavior(generate_demand_for_actor(np.array([10,20,30])), human=True) print(t) - t=sample_behavior(generate_demand(np.array([10,20,30])), human=False) + t=sample_behavior(generate_demand_for_actor(np.array([10,20,30])), human=False) print(t) diff --git a/engine/lib/demand.py b/engine/lib/demand.py index 7215f7c..d9f7edb 100644 --- a/engine/lib/demand.py +++ b/engine/lib/demand.py @@ -3,15 +3,15 @@ import numpy as np from logging import getLogger logger = getLogger(__name__) -def generate_demand(prices, distribution_method = np.random.normal, distribution_params = (50.0, 10.0)): - # assumption 1: each product has an intrinsic valuation drawn from a normal distribution centered at 50 - product_valuations = distribution_method(*distribution_params, size=len(prices)) - # assumption 2: demand decreases as price increases, following a simple linear model - demand = np.maximum(0, product_valuations - prices) # demand cannot be negative +def generate_demand_for_actor(prices: np.ndarray, params: tuple, noise_std: float = 1.0, distribution_method=np.random.normal) -> np.ndarray: + """d(p;0) = max(0, valuation - price) + epsi for single actor type + params: (mean, std) for valuation distribution D_H or D_A""" + val = distribution_method(*params, size=len(prices)) + noise = distribution_method(0, noise_std, len(prices)) + demand = np.maximum(0, val - prices + noise) total = np.sum(demand) - demand = demand / total * 100 if total > 0 else demand # normalize to percentage, avoid div by zero - logger.info(f"Generated demand for prices {prices}: {demand} with valuations from distribution {distribution_params}") - return demand + return demand / total * 100 if total > 0 else demand + def estimate_demand(trajectories): demand_estimate = {} @@ -29,17 +29,16 @@ def estimate_demand(trajectories): if __name__ == "__main__": np.random.seed(42) prices = np.array([20.0, 35.0, 50.0, 65.0]) - demand = generate_demand(prices) - print("Generated Demand:", demand) + # demo actor-specific demands + human_params, agent_params = (50, 10), (45, 15) + demand_h = generate_demand_for_actor(prices, human_params) + demand_a = generate_demand_for_actor(prices, agent_params) + print("Human Demand:", demand_h) + print("Agent Demand:", demand_a) from .behavior import sample_behavior - N, alphat =200, 0.1 - trajectories = [] - for _ in range(int(N*(1 - alphat))): - trajectories.append(sample_behavior(demand, human=True)) - for _ in range(int(N*alphat)): - trajectories.append(sample_behavior(demand, human=False)) - demand_estimate = estimate_demand(trajectories) + N, alpha = 200, 0.3 + n_h, n_a = int(N * (1 - alpha)), int(N * alpha) + human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)] + agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)] + demand_estimate = estimate_demand(human_t + agent_t) print("Estimated Demand from Behavior:", demand_estimate) - delta = {k: demand_estimate.get(k, 0) - demand[i] for i, k in enumerate(range(len(prices)))} - delta = np.mean([np.abs(v) for v in delta.values()]) - print("Demand Delta:", delta) diff --git a/engine/wrapper.py b/engine/wrapper.py index 7221a8a..9bf3048 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -14,6 +14,9 @@ class PHANTOM(gym.Env): n_products: int = 10, alpha: float = 0.3, N: int = 100, + human_params: tuple = (50.0, 10.0), + agent_params: tuple = (45.0, 15.0), + noise_std: float = 1.0, price_bounds: tuple = (10.0, 150.0), lambda_coi: float = 0.1, coi_window: int = 10, @@ -26,8 +29,13 @@ class PHANTOM(gym.Env): self.render_mode = render_mode self.alpha = alpha self.N = N + self.human_params = human_params + self.agent_params = agent_params - self.market = MarketEngine(alpha=alpha, N=N) + self.market = MarketEngine( + alpha=alpha, N=N, + human_params=human_params, agent_params=agent_params, noise_std=noise_std + ) self._platform_stub = PricingEngine() self._limbo = Limbo(self._platform_stub, self.market) From 4abef97bf72632ea4512653265df2d825d83fc66 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 31 Jan 2026 16:21:10 +0100 Subject: [PATCH 03/36] chore: adding simulation logging with wandb --- engine/lib/__init__.py | 3 +++ engine/lib/behavior.py | 33 ++++++++++++++++++++++------- engine/train.py | 28 +++++++++++-------------- engine/wrapper.py | 47 +++++++++++++++++++++++++++++++++++------- requirements.txt | 1 + 5 files changed, 81 insertions(+), 31 deletions(-) diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 4e8fd99..d120204 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -1,3 +1,6 @@ from .demand import estimate_demand, generate_demand_for_actor from .behavior import sample_behavior from .render import DashboardRenderer, style_axis +from .wrappers import EconomicMetricsWrapper +from .callbacks import MetricsCallback, EvalMetricsCallback +from .providers import ProviderBenchmark, ProviderResult, BenchmarkConfig diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index f7dcc65..0f8c486 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -1,27 +1,39 @@ -from sim.rl.behavior_loader.models import BehaviorModel, AgentBehaviorModel, aggregate_event_transitions +from sim.rl.behavior_loader.models import ( + BehaviorModel, + AgentBehaviorModel, + aggregate_event_transitions, +) import pandas as pd import numpy as np from .demand import generate_demand_for_actor base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments" -human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/" +human_dir, agent_dir = ( + f"{base_dir}/collected_data/", + f"{base_dir}/agents/collected_data/", +) _cache = {} # lazy cache for models and base pivots + def _get_base_pivot(human: bool): - key = 'human' if human else 'agent' + key = "human" if human else "agent" if key not in _cache: model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir) mdp = model.build_MDP() _cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0) return _cache[key] + def adjust_behavior_to_condition(condition, transition_matrix): # expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition cond_norm = condition / np.sum(condition) n_products = len(condition) base_vals = transition_matrix.values - base_cols, base_rows = transition_matrix.columns.tolist(), transition_matrix.index.tolist() + base_cols, base_rows = ( + transition_matrix.columns.tolist(), + transition_matrix.index.tolist(), + ) # expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm)) @@ -29,19 +41,24 @@ def adjust_behavior_to_condition(condition, transition_matrix): new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)] return pd.DataFrame(expanded, index=new_rows, columns=new_cols) + def sample_behavior(condition, human=True, max_len=40): base_pivot = _get_base_pivot(human) adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot) trajectory = [np.random.choice(adjusted_transitions.index)] - while len(trajectory) < max_len or 'checkout' in trajectory[-1]: + while len(trajectory) < max_len and "checkout" not in trajectory[-1]: probs = adjusted_transitions.loc[trajectory[-1]].values - sample = np.random.choice(adjusted_transitions.columns, p=probs/np.sum(probs) if np.sum(probs) > 0 else None) + sample = np.random.choice( + adjusted_transitions.columns, + p=probs / np.sum(probs) if np.sum(probs) > 0 else None, + ) trajectory.append(sample) return trajectory + if __name__ == "__main__": - t=sample_behavior(generate_demand_for_actor(np.array([10,20,30])), human=True) + t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True) print(t) - t=sample_behavior(generate_demand_for_actor(np.array([10,20,30])), human=False) + t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False) print(t) diff --git a/engine/train.py b/engine/train.py index 496ecfd..ebb14f4 100644 --- a/engine/train.py +++ b/engine/train.py @@ -1,21 +1,16 @@ +import wandb from stable_baselines3 import SAC -from stable_baselines3.common.callbacks import EvalCallback, BaseCallback +from stable_baselines3.common.callbacks import EvalCallback from .wrapper import PHANTOM +from .lib import EconomicMetricsWrapper, MetricsCallback +wandb.init( + project="phantom-pricing", + config={"alpha": 0.3, "n_products": 10, "total_timesteps": 50000} +) -class RenderCallback(BaseCallback): - """Renders environment on every step for live visualization.""" - def __init__(self, env: PHANTOM): - super().__init__() - self.env = env - - def _on_step(self) -> bool: - self.env.render() - return True - - -env = PHANTOM(n_products=10, alpha=0.3, render_mode="human") -eval_env = PHANTOM(n_products=10, alpha=0.3, render_mode=None) +env = EconomicMetricsWrapper(PHANTOM(n_products=10, alpha=0.3, render_mode=None)) +eval_env = EconomicMetricsWrapper(PHANTOM(n_products=10, alpha=0.3, render_mode=None)) model = SAC( "MultiInputPolicy", @@ -28,11 +23,12 @@ model = SAC( gamma=0.99, ) -render_cb = RenderCallback(env) +metrics_cb = MetricsCallback(log_histograms=True, log_freq=100) eval_cb = EvalCallback(eval_env, eval_freq=1000, n_eval_episodes=5, verbose=1) -model.learn(total_timesteps=50000, callback=[render_cb, eval_cb]) +model.learn(total_timesteps=50000, callback=[metrics_cb, eval_cb]) model.save("phantom_sac") +wandb.finish() # test trained policy env = PHANTOM(n_products=10, alpha=0.3, render_mode="human") diff --git a/engine/wrapper.py b/engine/wrapper.py index 9bf3048..1dee8f5 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -4,6 +4,7 @@ import numpy as np from .engine import Limbo, MarketEngine, PricingEngine from .lib.render import DashboardRenderer from .lib.coi import compute_coi_proxy +from .lib.wrappers import EconomicMetricsWrapper class PHANTOM(gym.Env): @@ -134,11 +135,43 @@ class PHANTOM(gym.Env): if __name__ == "__main__": - env = PHANTOM(n_products=15, alpha=0.3, N=100, render_mode="human") - obs, _ = env.reset() - for step in range(100): - action = env.action_space.sample() - obs, reward, term, trunc, info = env.step(action) - env.render() - if term: break + import wandb + from .lib import MetricsCallback + + class RandomPolicy: + """Minimal SB3-compatible random policy for baseline testing.""" + def __init__(self, env): + self.env = env + self.num_timesteps = 0 + + def learn(self, total_timesteps, callback=None): + callback.model = self + callback.num_timesteps = 0 + callback.locals = {} + callback.on_training_start({}, {}) + + obs, _ = self.env.reset() + for step in range(total_timesteps): + action = self.env.action_space.sample() + obs, reward, term, trunc, info = self.env.step(action) + self.num_timesteps = step + 1 + callback.num_timesteps = self.num_timesteps + callback.locals = {"infos": [info]} + callback.on_step() + if term or trunc: + callback.on_rollout_end() + obs, _ = self.env.reset() + return self + + def predict(self, obs, **kwargs): + return self.env.action_space.sample(), None + + wandb.init(project="phantom-pricing", config={"policy": "random", "alpha": 0.3}) + env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None)) + + model = RandomPolicy(env) + model.learn(total_timesteps=1000, callback=MetricsCallback()) + + print(f"Episode revenue: {env.episode_revenue:.1f}") + wandb.finish() env.close() diff --git a/requirements.txt b/requirements.txt index e7eaf09..247121e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ uv scikit-learn supabase pymc +wandb From c4fd1352c9e054035ca6af4f7c03ecc7dc9cbd6a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 2 Feb 2026 11:18:37 +0100 Subject: [PATCH 04/36] naoice COI implementation --- engine/engine.py | 58 +++++++++----- engine/lib/__init__.py | 3 +- engine/lib/behavior.py | 53 +++++++++++-- engine/wrapper.py | 173 +++++++++++++++++++++++++++++++---------- paper/src/main.tex | 2 +- 5 files changed, 221 insertions(+), 68 deletions(-) diff --git a/engine/engine.py b/engine/engine.py index a4d568d..000f03f 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -3,20 +3,23 @@ import numpy as np from .lib.demand import generate_demand_for_actor, estimate_demand from .lib.behavior import sample_behavior from logging import INFO, getLogger + logger = getLogger(__name__) logger.setLevel(INFO) -class MarketEngine(): +class MarketEngine: """implements separate demand distributions for humans and agents per Section 3.1.1""" - def __init__(self, - alpha: float, - N: int, - human_params: tuple, - agent_params: tuple, - demand_distribution = np.random.normal, - noise_std: float = 1.0): + def __init__( + self, + alpha: float, + N: int, + human_params: tuple, + agent_params: tuple, + demand_distribution=np.random.normal, + noise_std: float = 1.0, + ): # no defaults for D_H, D_A - force explicit experiment design self.alpha = alpha self.Nagents = int(N * alpha) @@ -28,31 +31,41 @@ class MarketEngine(): def act(self, prices): # generate separate demands d() per actor type - demand_h = generate_demand_for_actor(prices, self.human_params, self.noise_std, distribution_method = self.demand_dist) - demand_a = generate_demand_for_actor(prices, self.agent_params, self.noise_std, distribution_method = self.demand_dist) + demand_h = generate_demand_for_actor( + prices, + self.human_params, + self.noise_std, + distribution_method=self.demand_dist, + ) + demand_a = generate_demand_for_actor( + prices, + self.agent_params, + self.noise_std, + distribution_method=self.demand_dist, + ) # sample behavior trajectories from each demand distribution human_t = [sample_behavior(demand_h, human=True) for _ in range(self.Nhumans)] agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)] - return estimate_demand(human_t + agent_t) + # store trajectories for agent probability calculation + self.last_trajectories = human_t + agent_t + return estimate_demand(self.last_trajectories) def measure(self): pass -class PricingEngine(): - def __init__(self, - ) -> None: + +class PricingEngine: + def __init__( + self, + ) -> None: pass def act(self, demand): return np.random.uniform(low=25, high=100, size=10) - -class Limbo(): - def __init__(self, - platform, - market - ) -> None: +class Limbo: + def __init__(self, platform, market) -> None: self.platform_turn = True self.platform = platform self.market = market @@ -67,9 +80,12 @@ class Limbo(): print(self.output) self.platform_turn = not self.platform_turn + if __name__ == "__main__": platform = PricingEngine() - market = MarketEngine(alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)) + market = MarketEngine( + alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15) + ) limbo = Limbo(platform, market) for _ in range(10): limbo.step() diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index d120204..0546a18 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -1,6 +1,7 @@ from .demand import estimate_demand, generate_demand_for_actor -from .behavior import sample_behavior +from .behavior import sample_behavior, get_transition_models, trajectory_to_events from .render import DashboardRenderer, style_axis from .wrappers import EconomicMetricsWrapper from .callbacks import MetricsCallback, EvalMetricsCallback from .providers import ProviderBenchmark, ProviderResult, BenchmarkConfig +from .coi import compute_coi_leakage, compute_erosion_metrics, compute_agent_probability diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index 0f8c486..34faad2 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -1,3 +1,8 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[2])) + from sim.rl.behavior_loader.models import ( BehaviorModel, AgentBehaviorModel, @@ -7,11 +12,9 @@ import pandas as pd import numpy as np from .demand import generate_demand_for_actor -base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments" -human_dir, agent_dir = ( - f"{base_dir}/collected_data/", - f"{base_dir}/agents/collected_data/", -) +base_dir = Path(__file__).parents[2] / "experiments" +human_dir = str(base_dir / "collected_data") +agent_dir = str(base_dir / "agents" / "collected_data") _cache = {} # lazy cache for models and base pivots @@ -25,6 +28,46 @@ def _get_base_pivot(human: bool): return _cache[key] +def get_transition_models(): + """load human and agent transition models for agent probability calculation + + returns: + tuple: (human_transitions, agent_transitions) as dicts of event->event->prob + """ + human_model = BehaviorModel(human_dir) + agent_model = AgentBehaviorModel(agent_dir) + + human_mdp = human_model.build_MDP() + agent_mdp = agent_model.build_MDP() + + human_trans = aggregate_event_transitions(human_mdp) + agent_trans = aggregate_event_transitions(agent_mdp) + + return human_trans, agent_trans + + +def trajectory_to_events(trajectory: list) -> list: + """extract event names from trajectory for KL divergence calculation + + trajectories are in format 'eventName_product0', extract just eventName + + args: + trajectory: list like ['view_product0', 'add_to_cart_product1', 'checkout_product1'] + + returns: + list: event names like ['view', 'add_to_cart', 'checkout'] + """ + events = [] + for state in trajectory: + # state format from sample_behavior: 'eventName_productX' + if "_product" in state: + event = state.rsplit("_product", 1)[0] + else: + event = state + events.append(event) + return events + + def adjust_behavior_to_condition(condition, transition_matrix): # expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition cond_norm = condition / np.sum(condition) diff --git a/engine/wrapper.py b/engine/wrapper.py index 1dee8f5..e435aeb 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -3,30 +3,42 @@ from gymnasium import spaces import numpy as np from .engine import Limbo, MarketEngine, PricingEngine from .lib.render import DashboardRenderer -from .lib.coi import compute_coi_proxy +from .lib.coi import ( + compute_coi_leakage, + compute_erosion_metrics, + compute_agent_probability, +) +from .lib.behavior import get_transition_models, trajectory_to_events from .lib.wrappers import EconomicMetricsWrapper class PHANTOM(gym.Env): - """Gymnasium wrapper for the Limbo pricing-market simulation. Platform sets prices, market responds with demand.""" + """Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework + + reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL + COI_leak uses behavioral divergence to estimate agent probability f(τ') + """ + metadata = {"render_modes": ["human", "ansi"]} - def __init__(self, - n_products: int = 10, - alpha: float = 0.3, - N: int = 100, - human_params: tuple = (50.0, 10.0), - agent_params: tuple = (45.0, 15.0), - noise_std: float = 1.0, - price_bounds: tuple = (10.0, 150.0), - lambda_coi: float = 0.1, - coi_window: int = 10, - render_mode: str = None): + def __init__( + self, + n_products: int = 10, + alpha: float = 0.3, + N: int = 100, + human_params: tuple = (50.0, 10.0), + agent_params: tuple = (45.0, 15.0), + noise_std: float = 1.0, + price_bounds: tuple = (10.0, 150.0), + lambda_coi: float = 0.1, + coi_window: int = 10, + render_mode: str = None, + ): super().__init__() self.n_products = n_products self.price_bounds = price_bounds self.lambda_coi = lambda_coi - self.coi_window = coi_window # K steps for rolling COI calculation + self.coi_window = coi_window self.render_mode = render_mode self.alpha = alpha self.N = N @@ -34,20 +46,34 @@ class PHANTOM(gym.Env): self.agent_params = agent_params self.market = MarketEngine( - alpha=alpha, N=N, - human_params=human_params, agent_params=agent_params, noise_std=noise_std + alpha=alpha, + N=N, + human_params=human_params, + agent_params=agent_params, + noise_std=noise_std, ) self._platform_stub = PricingEngine() self._limbo = Limbo(self._platform_stub, self.market) self.action_space = spaces.Box( - low=price_bounds[0], high=price_bounds[1], - shape=(n_products,), dtype=np.float32 + low=price_bounds[0], + high=price_bounds[1], + shape=(n_products,), + dtype=np.float32, + ) + self.observation_space = spaces.Dict( + { + "demand": spaces.Box( + low=0.0, high=100.0, shape=(n_products,), dtype=np.float32 + ), + "prices": spaces.Box( + low=price_bounds[0], + high=price_bounds[1], + shape=(n_products,), + dtype=np.float32, + ), + } ) - self.observation_space = spaces.Dict({ - "demand": spaces.Box(low=0.0, high=100.0, shape=(n_products,), dtype=np.float32), - "prices": spaces.Box(low=price_bounds[0], high=price_bounds[1], shape=(n_products,), dtype=np.float32), - }) self._prices = None self._demand = None @@ -56,25 +82,61 @@ class PHANTOM(gym.Env): self._price_history = [] self._revenue_history = [] self._renderer = None - self._initial_episode_prices = None # prices at episode start for COI calc + self._initial_episode_prices = None + self._trajectories = [] # session trajectories for agent prob calculation + + # load behavioral models for agent probability estimation + try: + self._human_trans, self._agent_trans = get_transition_models() + except Exception: + # fallback if behavioral data unavailable + self._human_trans, self._agent_trans = None, None def _get_obs(self) -> dict: - demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32) + demand_arr = np.array( + [self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32 + ) return {"demand": demand_arr, "prices": self._prices.astype(np.float32)} - def _compute_coi_proxy(self): - return compute_coi_proxy( - self._price_history, self._demand_history, self._initial_episode_prices, - self._prices, self.price_bounds, self.alpha, self.coi_window + def _compute_agent_prob(self) -> float: + """estimate agent probability from accumulated trajectories using KL divergence""" + if ( + not self._trajectories + or self._human_trans is None + or self._agent_trans is None + ): + return self.alpha # fallback to contamination level + + # aggregate all trajectories from this episode + all_events = [] + for traj in self._trajectories: + all_events.extend(trajectory_to_events(traj)) + + if len(all_events) < 2: + return self.alpha + + return compute_agent_probability( + all_events, self._human_trans, self._agent_trans ) def _compute_reward(self, prices: np.ndarray, demand: dict) -> float: - revenue = np.sum(prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)])) - coi_penalty = self.lambda_coi * self._compute_coi_proxy() + revenue = np.sum( + prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)]) + ) + + # compute agent probability from behavioral trajectories + agent_prob = self._compute_agent_prob() + + # COI leakage: minimal implementation per thesis + coi_leakage = compute_coi_leakage(prices, agent_prob) + coi_penalty = self.lambda_coi * coi_leakage + return float(revenue - coi_penalty) def _record_history(self): - demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)]) + demand_arr = np.array( + [self._demand.get(i, 0.0) for i in range(self.n_products)] + ) self._demand_history.append(demand_arr) self._price_history.append(self._prices.copy()) self._revenue_history.append(np.sum(self._prices * demand_arr)) @@ -82,10 +144,11 @@ class PHANTOM(gym.Env): def reset(self, seed=None, options=None): super().reset(seed=seed) self._prices = np.random.uniform(*self.price_bounds, size=self.n_products) - self._initial_episode_prices = self._prices.copy() # snapshot for COI calculation + self._initial_episode_prices = self._prices.copy() self._demand = self.market.act(self._prices) self._step_count = 0 self._demand_history, self._price_history, self._revenue_history = [], [], [] + self._trajectories = [] self._record_history() return self._get_obs(), {} @@ -95,15 +158,36 @@ class PHANTOM(gym.Env): self._step_count += 1 self._record_history() - coi_proxy = self._compute_coi_proxy() + # capture trajectories generated by market for agent prob estimation + if hasattr(self.market, "last_trajectories"): + self._trajectories.extend(self.market.last_trajectories) + + agent_prob = self._compute_agent_prob() + coi_leakage = compute_coi_leakage(self._prices, agent_prob) reward = self._compute_reward(self._prices, self._demand) terminated = self._step_count >= 100 + # legacy erosion metrics for comparison + erosion = compute_erosion_metrics( + self._price_history, + self._demand_history, + self._initial_episode_prices, + self._prices, + self.price_bounds, + self.alpha, + self.coi_window, + ) + info = { "step": self._step_count, - "coi_proxy": coi_proxy, - "coi_penalty": self.lambda_coi * coi_proxy, - "raw_revenue": np.sum(self._prices * np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])), + "agent_prob": agent_prob, + "coi_leakage": coi_leakage, + "coi_penalty": self.lambda_coi * coi_leakage, + "erosion_metrics": erosion, + "raw_revenue": np.sum( + self._prices + * np.array([self._demand.get(i, 0.0) for i in range(self.n_products)]) + ), } return self._get_obs(), reward, terminated, False, info @@ -114,10 +198,16 @@ class PHANTOM(gym.Env): p, q = np.array(self._price_history), np.array(self._demand_history) dp, dq = np.diff(p, axis=0), np.diff(q, axis=0) valid = np.abs(dp) > 0.5 - with np.errstate(divide='ignore', invalid='ignore'): - elasticity = np.where(valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0) + with np.errstate(divide="ignore", invalid="ignore"): + elasticity = np.where( + valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0 + ) elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0) - return np.mean(elasticity, axis=0) if len(elasticity) > 0 else np.zeros(self.n_products) + return ( + np.mean(elasticity, axis=0) + if len(elasticity) > 0 + else np.zeros(self.n_products) + ) def render(self): if self.render_mode == "human": @@ -125,7 +215,9 @@ class PHANTOM(gym.Env): self._renderer = DashboardRenderer() self._renderer.render(self) elif self.render_mode == "ansi": - return f"step={self._step_count}, prices={self._prices}, demand={self._demand}" + return ( + f"step={self._step_count}, prices={self._prices}, demand={self._demand}" + ) return None def close(self): @@ -140,6 +232,7 @@ if __name__ == "__main__": class RandomPolicy: """Minimal SB3-compatible random policy for baseline testing.""" + def __init__(self, env): self.env = env self.num_timesteps = 0 diff --git a/paper/src/main.tex b/paper/src/main.tex index 88260b9..3680ac8 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -27,7 +27,7 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \noindent\textbf{Keywords:} Dynamic Pricing, LLM Agents, Adversarial Machine Learning, E-commerce, Behavioral Detection, Reinforcement Learning \vspace{1em} -\noindent\textbf{Acknowledgments:} Eugene Bykovets, PhD - ETH for helping with problem formulation. This research was supported by the TPU Research Cloud program. +\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program. \clearpage \input{chapters/01-intro} From 08c0afb55aa602af5b0ef09430baa01908465232 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 2 Feb 2026 12:03:30 +0100 Subject: [PATCH 05/36] chore: add chart of supra competive pricing --- paper/src/chapters/03-methodology.tex | 2 +- paper/src/chapters/04-results.tex | 6 + paper/src/chapters/figures/process_supra.py | 131 + paper/src/chapters/figures/supra.csv | 41 + paper/src/chapters/figures/supra.tex | 26 + paper/src/chapters/figures/supra_data.csv | 4041 +++++++++++++++++++ paper/src/preamble.tex | 2 + 7 files changed, 4248 insertions(+), 1 deletion(-) create mode 100644 paper/src/chapters/figures/process_supra.py create mode 100644 paper/src/chapters/figures/supra.csv create mode 100644 paper/src/chapters/figures/supra.tex create mode 100644 paper/src/chapters/figures/supra_data.csv diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 0c68240..540ae68 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -317,7 +317,7 @@ We also need to think about a policy like taxation to the agents Strategy-Proof \subsubsection{Pricing Mechanism Summary} -We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_pricing_loop} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories. +We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_loop_clean} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories. \begin{algorithm}[t] \caption{PHANTOM defensive pricing loop (bachelor-thesis level)} diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index ca57292..e2a1735 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -1,4 +1,10 @@ \section{Results} +\begin{figure}[ht] + \centering + \input{chapters/figures/supra.tex} + \caption{Evolution of price distributions over experiment steps. The heatmap illustrates the density of price offerings. This is an early baseline simulation which demonstrates supra-competitive price-setting in deep learning agents such as SAC as can be clearly seen by the high density at the highest available price.} + \label{fig:supra_heatmap} +\end{figure} \subsection{Behavioral Analysis} diff --git a/paper/src/chapters/figures/process_supra.py b/paper/src/chapters/figures/process_supra.py new file mode 100644 index 0000000..a7b01e6 --- /dev/null +++ b/paper/src/chapters/figures/process_supra.py @@ -0,0 +1,131 @@ +import pandas as pd +import json +import numpy as np +import sys +import os + + +def process_supra(input_file, output_file): + print(f"Processing {input_file} -> {output_file}") + + # Read the CSV + try: + # The CSV has a weird format: "Step","giddy-deluge-6 - distributions/prices" + # The header is on line 1. + # Let's verify the file content format first effectively. + # The previous read showed standard CSV with quoted fields. + df = pd.read_csv(input_file, quotechar='"', skipinitialspace=True) + except Exception as e: + print(f"Error reading CSV: {e}") + return + + # Prepare for re-binning + # We need a common set of bins to plot a heatmap (surface) + # First, let's collect all data to determine range + all_min = float("inf") + all_max = float("-inf") + + parsed_data = [] + + # The column names might be dynamic, so let's rely on indices + # Column 0: Step + # Column 1: JSON blob + + for index, row in df.iterrows(): + try: + step = int(row.iloc[0]) + json_str = row.iloc[1] + + # Cleaning potential double quotes issue if pandas didn't catch it perfect + # but pandas read_csv usually handles standard CSV escaping well. + + data = json.loads(json_str) + + bins = np.array(data["bins"]) + values = np.array(data["values"]) + + # Update global range + if bins.min() < all_min: + all_min = bins.min() + if bins.max() > all_max: + all_max = bins.max() + + parsed_data.append({"step": step, "bins": bins, "values": values}) + except Exception as e: + print(f"Skipping row {index} due to error: {e}") + continue + + if not parsed_data: + print("No data parsed.") + return + + print(f"Found {len(parsed_data)} steps. Range: {all_min} to {all_max}") + + # Define common grid + # Y-axis (Price) + # Using 100 bins for resolution + y_bins_edges = np.linspace(all_min, all_max, 101) + y_bin_centers = (y_bins_edges[:-1] + y_bins_edges[1:]) / 2 + + # Open output file + with open(output_file, "w") as f: + # PGFPlots 3D format often prefers no header or a specific header. + # We will use named columns. + f.write("step,price,density\n") + + # Sort by step to ensure correct mesh ordering + parsed_data.sort(key=lambda x: x["step"]) + + for item in parsed_data: + step = item["step"] + original_bins = item["bins"] + original_values = item["values"] + + # Re-binning logic + current_new_hist = np.zeros(len(y_bin_centers)) + + for i, (new_start, new_end) in enumerate( + zip(y_bins_edges[:-1], y_bins_edges[1:]) + ): + val = 0.0 + # This inner loop is slightly inefficient O(N*M) but N~3000, M~100 -> 300k ops, totally fine. + for j in range(len(original_values)): + b_start = original_bins[j] + # Handle cases where values array might be 1 shorter than bins (histogram edges vs centers) + # The provided JSON has "bins" array larger than "values" by 1 usually for edges. + if j + 1 >= len(original_bins): + break + + b_end = original_bins[j + 1] + b_width = b_end - b_start + + if b_width <= 0: + continue + + # Calculate overlap + overlap_start = max(new_start, b_start) + overlap_end = min(new_end, b_end) + overlap = max(0, overlap_end - overlap_start) + + if overlap > 0: + # Add proportional count + val += original_values[j] * (overlap / b_width) + + current_new_hist[i] = val + + # Write row to file for this step + for price, density in zip(y_bin_centers, current_new_hist): + # PGFPlots expects x y z + f.write(f"{step},{price},{density}\n") + + # Add a blank line for PGFPlots matrix format (essential for 'mesh' or 'surf') + f.write("\n") + + +if __name__ == "__main__": + # Resolve relative paths relative to where script is run, or use absolute + base_dir = os.path.dirname(os.path.abspath(__file__)) + input_path = os.path.join(base_dir, "supra.csv") + output_path = os.path.join(base_dir, "supra_data.csv") + + process_supra(input_path, output_path) diff --git a/paper/src/chapters/figures/supra.csv b/paper/src/chapters/figures/supra.csv new file mode 100644 index 0000000..9a2caf5 --- /dev/null +++ b/paper/src/chapters/figures/supra.csv @@ -0,0 +1,41 @@ +"Step","giddy-deluge-6 - distributions/prices" +"100","{""values"":[2,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1],""_type"":""histogram"",""bins"":[15.76888656616211,17.813893377780914,19.85890018939972,21.903907001018524,23.94891381263733,25.993920624256134,28.03892743587494,30.083934247493744,32.12894105911255,34.173947870731354,36.21895468235016,38.263961493968964,40.30896830558777,42.35397511720657,44.39898192882538,46.44398874044418,48.48899555206299,50.53400236368179,52.5790091753006,54.6240159869194,56.66902279853821,58.71402961015701,60.75903642177582,62.80404323339462,64.84905004501343,66.89405685663223,68.93906366825104,70.98407047986984,73.02907729148865,75.07408410310745,77.11909091472626,79.16409772634506,81.20910453796387,83.25411134958267,85.29911816120148,87.34412497282028,89.38913178443909,91.43413859605789,93.4791454076767,95.5241522192955,97.5691590309143,99.61416584253311,101.65917265415192,103.70417946577072,105.74918627738953,107.79419308900833,109.83919990062714,111.88420671224594,113.92921352386475,115.97422033548355,118.01922714710236,120.06423395872116,122.10924077033997,124.15424758195877,126.19925439357758,128.24426120519638,130.28926801681519,132.334274828434,134.3792816400528,136.4242884516716,138.4692952632904,140.5143020749092,142.55930888652802,144.60431569814682,146.64932250976562]}" +"200","{""_type"":""histogram"",""values"":[1,1,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,3],""bins"":[10.439504623413086,12.620137363672256,14.800770103931427,16.981402844190598,19.162035584449768,21.34266832470894,23.52330106496811,25.70393380522728,27.88456654548645,30.06519928574562,32.24583202600479,34.42646476626396,36.60709750652313,38.7877302467823,40.96836298704147,43.148995727300644,45.329628467559814,47.510261207818985,49.690893948078156,51.871526688337326,54.0521594285965,56.23279216885567,58.41342490911484,60.59405764937401,62.77469038963318,64.95532312989235,67.13595587015152,69.31658861041069,71.49722135066986,73.67785409092903,75.8584868311882,78.03911957144737,80.21975231170654,82.40038505196571,84.58101779222488,86.76165053248405,88.94228327274323,91.1229160130024,93.30354875326157,95.48418149352074,97.66481423377991,99.84544697403908,102.02607971429825,104.20671245455742,106.38734519481659,108.56797793507576,110.74861067533493,112.9292434155941,115.10987615585327,117.29050889611244,119.47114163637161,121.65177437663078,123.83240711688995,126.01303985714912,128.1936725974083,130.37430533766747,132.55493807792664,134.7355708181858,136.91620355844498,139.09683629870415,141.27746903896332,143.4581017792225,145.63873451948166,147.81936725974083,150]}" +"300","{""bins"":[92.91828918457031,93.81018829345703,94.70209503173828,95.593994140625,96.48589324951172,97.37779998779297,98.26969909667969,99.1615982055664,100.05350494384766,100.94540405273438,101.83731079101562,102.72920989990234,103.62110900878906,104.51301574707031,105.40491485595703,106.29681396484375,107.188720703125,108.08061981201172,108.97251892089844,109.86442565917969,110.7563247680664,111.64822387695312,112.54013061523438,113.4320297241211,114.32392883300781,115.21583557128906,116.10773468017578,116.9996337890625,117.89154052734375,118.78343963623047,119.67533874511719,120.56724548339844,121.45914459228516,122.35104370117188,123.24295043945312,124.13484954833984,125.02674865722656,125.91865539550781,126.81055450439453,127.70245361328125,128.5943603515625,129.48626708984375,130.37815856933594,131.2700653076172,132.16195678710938,133.05386352539062,133.94577026367188,134.83767700195312,135.7295684814453,136.62147521972656,137.51336669921875,138.4052734375,139.29718017578125,140.1890869140625,141.0809783935547,141.97288513183594,142.86477661132812,143.75668334960938,144.64859008789062,145.54049682617188,146.43238830566406,147.3242950439453,148.21620178222656,149.10809326171875,150],""_type"":""histogram"",""values"":[1,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,1,1,0,3]}" +"400","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,3,0,0,0,3],""bins"":[141.8555450439453,141.98280334472656,142.1100616455078,142.23731994628906,142.3645782470703,142.49183654785156,142.6190948486328,142.746337890625,142.87359619140625,143.0008544921875,143.12811279296875,143.25537109375,143.38262939453125,143.5098876953125,143.63714599609375,143.764404296875,143.89166259765625,144.0189208984375,144.14617919921875,144.2734375,144.4006805419922,144.52793884277344,144.6551971435547,144.78245544433594,144.9097137451172,145.03697204589844,145.1642303466797,145.29148864746094,145.4187469482422,145.54600524902344,145.6732635498047,145.80052185058594,145.92776489257812,146.05502319335938,146.18228149414062,146.30953979492188,146.43679809570312,146.56405639648438,146.69131469726562,146.81857299804688,146.94583129882812,147.07308959960938,147.20034790039062,147.32760620117188,147.45486450195312,147.58212280273438,147.70936584472656,147.8366241455078,147.96388244628906,148.0911407470703,148.21839904785156,148.3456573486328,148.47291564941406,148.6001739501953,148.72743225097656,148.8546905517578,148.98194885253906,149.1092071533203,149.2364501953125,149.36370849609375,149.490966796875,149.61822509765625,149.7454833984375,149.87274169921875,150],""_type"":""histogram""}" +"500","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,2,1,0,3],""bins"":[142.30267333984375,142.42294311523438,142.543212890625,142.66348266601562,142.78375244140625,142.90402221679688,143.0242919921875,143.14456176757812,143.26483154296875,143.38511657714844,143.50538635253906,143.6256561279297,143.7459259033203,143.86619567871094,143.98646545410156,144.1067352294922,144.2270050048828,144.34727478027344,144.46754455566406,144.5878143310547,144.7080841064453,144.82835388183594,144.94862365722656,145.0688934326172,145.18917846679688,145.3094482421875,145.42971801757812,145.54998779296875,145.67025756835938,145.79052734375,145.91079711914062,146.03106689453125,146.15133666992188,146.2716064453125,146.39187622070312,146.51214599609375,146.63241577148438,146.752685546875,146.87295532226562,146.99322509765625,147.11349487304688,147.23377990722656,147.3540496826172,147.4743194580078,147.59458923339844,147.71485900878906,147.8351287841797,147.9553985595703,148.07566833496094,148.19593811035156,148.3162078857422,148.4364776611328,148.55674743652344,148.67701721191406,148.7972869873047,148.9175567626953,149.037841796875,149.15811157226562,149.27838134765625,149.39865112304688,149.5189208984375,149.63919067382812,149.75946044921875,149.87973022460938,150]}" +"600","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,1,1,0,0,0,0,1,0,1,0,0,0,0,3],""bins"":[143.02142333984375,143.13046264648438,143.239501953125,143.34854125976562,143.45758056640625,143.56661987304688,143.6756591796875,143.78469848632812,143.89373779296875,144.00279235839844,144.11183166503906,144.2208709716797,144.3299102783203,144.43894958496094,144.54798889160156,144.6570281982422,144.7660675048828,144.87510681152344,144.98414611816406,145.0931854248047,145.2022247314453,145.31126403808594,145.42030334472656,145.5293426513672,145.63839721679688,145.7474365234375,145.85647583007812,145.96551513671875,146.07455444335938,146.18359375,146.29263305664062,146.40167236328125,146.51071166992188,146.6197509765625,146.72879028320312,146.83782958984375,146.94686889648438,147.055908203125,147.16494750976562,147.27398681640625,147.38302612304688,147.49208068847656,147.6011199951172,147.7101593017578,147.81919860839844,147.92823791503906,148.0372772216797,148.1463165283203,148.25535583496094,148.36439514160156,148.4734344482422,148.5824737548828,148.69151306152344,148.80055236816406,148.9095916748047,149.0186309814453,149.127685546875,149.23672485351562,149.34576416015625,149.45480346679688,149.5638427734375,149.67288208007812,149.78192138671875,149.89096069335938,150],""_type"":""histogram""}" +"700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,1,1,1,3],""bins"":[147.6887969970703,147.72491455078125,147.76101684570312,147.79713439941406,147.833251953125,147.86935424804688,147.9054718017578,147.94158935546875,147.97769165039062,148.01380920410156,148.0499267578125,148.08602905273438,148.1221466064453,148.15826416015625,148.19436645507812,148.23048400878906,148.2666015625,148.30270385742188,148.3388214111328,148.37493896484375,148.41104125976562,148.44715881347656,148.4832763671875,148.51937866210938,148.5554962158203,148.59161376953125,148.62771606445312,148.66383361816406,148.699951171875,148.73605346679688,148.7721710205078,148.80828857421875,148.84439086914062,148.88050842285156,148.9166259765625,148.95274353027344,148.9888458251953,149.02496337890625,149.0610809326172,149.09718322753906,149.13330078125,149.16941833496094,149.2055206298828,149.24163818359375,149.2777557373047,149.31385803222656,149.3499755859375,149.38609313964844,149.4221954345703,149.45831298828125,149.4944305419922,149.53053283691406,149.566650390625,149.60276794433594,149.6388702392578,149.67498779296875,149.7111053466797,149.74720764160156,149.7833251953125,149.81944274902344,149.8555450439453,149.89166259765625,149.9277801513672,149.96388244628906,150]}" +"800","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,3],""bins"":[149.21865844726562,149.23086547851562,149.24307250976562,149.25527954101562,149.26748657226562,149.27969360351562,149.2919158935547,149.3041229248047,149.3163299560547,149.3285369873047,149.3407440185547,149.3529510498047,149.3651580810547,149.3773651123047,149.3895721435547,149.4017791748047,149.41400146484375,149.42620849609375,149.43841552734375,149.45062255859375,149.46282958984375,149.47503662109375,149.48724365234375,149.49945068359375,149.51165771484375,149.52386474609375,149.53607177734375,149.5482940673828,149.5605010986328,149.5727081298828,149.5849151611328,149.5971221923828,149.6093292236328,149.6215362548828,149.6337432861328,149.6459503173828,149.6581573486328,149.6703643798828,149.68258666992188,149.69479370117188,149.70700073242188,149.71920776367188,149.73141479492188,149.74362182617188,149.75582885742188,149.76803588867188,149.78024291992188,149.79244995117188,149.80465698242188,149.81687927246094,149.82908630371094,149.84129333496094,149.85350036621094,149.86570739746094,149.87791442871094,149.89012145996094,149.90232849121094,149.91453552246094,149.92674255371094,149.93896484375,149.951171875,149.96337890625,149.9755859375,149.98779296875,150]}" +"900","{""_type"":""histogram"",""values"":[1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,3],""bins"":[149.2405548095703,149.25242614746094,149.2642822265625,149.27615356445312,149.28802490234375,149.2998809814453,149.31175231933594,149.32362365722656,149.33547973632812,149.34735107421875,149.35922241210938,149.37107849121094,149.38294982910156,149.3948211669922,149.40667724609375,149.41854858398438,149.430419921875,149.44227600097656,149.4541473388672,149.4660186767578,149.47787475585938,149.48974609375,149.50161743164062,149.5134735107422,149.5253448486328,149.53721618652344,149.549072265625,149.56094360351562,149.57281494140625,149.5846710205078,149.59654235839844,149.60841369628906,149.62026977539062,149.63214111328125,149.64401245117188,149.6558837890625,149.66773986816406,149.6796112060547,149.6914825439453,149.70333862304688,149.7152099609375,149.72708129882812,149.7389373779297,149.7508087158203,149.76268005371094,149.7745361328125,149.78640747070312,149.79827880859375,149.8101348876953,149.82200622558594,149.83387756347656,149.84573364257812,149.85760498046875,149.86947631835938,149.88133239746094,149.89320373535156,149.9050750732422,149.91693115234375,149.92880249023438,149.940673828125,149.95252990722656,149.9644012451172,149.9762725830078,149.98812866210938,150]}" +"1000","{""bins"":[149.04977416992188,149.0646209716797,149.0794677734375,149.0943145751953,149.10916137695312,149.12400817871094,149.13885498046875,149.15370178222656,149.16854858398438,149.1833953857422,149.1982421875,149.2130889892578,149.22793579101562,149.24278259277344,149.25762939453125,149.27247619628906,149.28732299804688,149.30218505859375,149.31703186035156,149.33187866210938,149.3467254638672,149.361572265625,149.3764190673828,149.39126586914062,149.40611267089844,149.42095947265625,149.43580627441406,149.45065307617188,149.4654998779297,149.4803466796875,149.4951934814453,149.51004028320312,149.52488708496094,149.53973388671875,149.55458068847656,149.56942749023438,149.5842742919922,149.59912109375,149.6139678955078,149.62881469726562,149.64366149902344,149.65850830078125,149.67335510253906,149.68820190429688,149.7030487060547,149.7178955078125,149.7327423095703,149.74758911132812,149.762451171875,149.7772979736328,149.79214477539062,149.80699157714844,149.82183837890625,149.83668518066406,149.85153198242188,149.8663787841797,149.8812255859375,149.8960723876953,149.91091918945312,149.92576599121094,149.94061279296875,149.95545959472656,149.97030639648438,149.9851531982422,150],""_type"":""histogram"",""values"":[1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,3]}" +"1100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,1,0,0,0,0,0,0,1,0,0,0,0,3],""bins"":[149.34432983398438,149.3545684814453,149.3648223876953,149.37506103515625,149.38531494140625,149.3955535888672,149.40579223632812,149.41604614257812,149.42628479003906,149.43653869628906,149.44677734375,149.45701599121094,149.46726989746094,149.47750854492188,149.48776245117188,149.4980010986328,149.50823974609375,149.51849365234375,149.5287322998047,149.5389862060547,149.54922485351562,149.55947875976562,149.56971740722656,149.5799560546875,149.5902099609375,149.60044860839844,149.61070251464844,149.62094116210938,149.6311798095703,149.6414337158203,149.65167236328125,149.66192626953125,149.6721649169922,149.68240356445312,149.69265747070312,149.70289611816406,149.71315002441406,149.723388671875,149.73362731933594,149.74388122558594,149.75411987304688,149.76437377929688,149.7746124267578,149.78485107421875,149.79510498046875,149.8053436279297,149.8155975341797,149.82583618164062,149.83609008789062,149.84632873535156,149.8565673828125,149.8668212890625,149.87705993652344,149.88731384277344,149.89755249023438,149.9077911376953,149.9180450439453,149.92828369140625,149.93853759765625,149.9487762451172,149.95901489257812,149.96926879882812,149.97950744628906,149.98976135253906,150]}" +"1200","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,2,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,3],""bins"":[148.93704223632812,148.95364379882812,148.9702606201172,148.9868621826172,149.00347900390625,149.02008056640625,149.0366973876953,149.0532989501953,149.06991577148438,149.08651733398438,149.10313415527344,149.11973571777344,149.1363525390625,149.1529541015625,149.16957092285156,149.18617248535156,149.20278930664062,149.21939086914062,149.23599243164062,149.2526092529297,149.2692108154297,149.28582763671875,149.30242919921875,149.3190460205078,149.3356475830078,149.35226440429688,149.36886596679688,149.38548278808594,149.40208435058594,149.418701171875,149.435302734375,149.45191955566406,149.46852111816406,149.48512268066406,149.50173950195312,149.51834106445312,149.5349578857422,149.5515594482422,149.56817626953125,149.58477783203125,149.6013946533203,149.6179962158203,149.63461303710938,149.65121459960938,149.66783142089844,149.68443298339844,149.7010498046875,149.7176513671875,149.7342529296875,149.75086975097656,149.76747131347656,149.78408813476562,149.80068969726562,149.8173065185547,149.8339080810547,149.85052490234375,149.86712646484375,149.8837432861328,149.9003448486328,149.91696166992188,149.93356323242188,149.95018005371094,149.96678161621094,149.9833984375,150],""_type"":""histogram""}" +"1300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,3],""bins"":[149.0462646484375,149.06117248535156,149.07606506347656,149.09097290039062,149.10586547851562,149.1207733154297,149.13568115234375,149.15057373046875,149.1654815673828,149.18038940429688,149.19528198242188,149.21018981933594,149.22509765625,149.239990234375,149.25489807128906,149.26979064941406,149.28469848632812,149.2996063232422,149.3144989013672,149.32940673828125,149.34429931640625,149.3592071533203,149.37411499023438,149.38900756835938,149.40391540527344,149.4188232421875,149.4337158203125,149.44862365722656,149.46353149414062,149.47842407226562,149.4933319091797,149.5082244873047,149.52313232421875,149.5380401611328,149.5529327392578,149.56784057617188,149.58273315429688,149.59764099121094,149.612548828125,149.62744140625,149.64234924316406,149.65725708007812,149.67214965820312,149.6870574951172,149.70196533203125,149.71685791015625,149.7317657470703,149.7466583251953,149.76156616210938,149.77647399902344,149.79136657714844,149.8062744140625,149.8211669921875,149.83607482910156,149.85098266601562,149.86587524414062,149.8807830810547,149.89569091796875,149.91058349609375,149.9254913330078,149.94039916992188,149.95529174804688,149.97019958496094,149.98509216308594,150]}" +"1400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,1,0,0,0,0,4],""bins"":[148.76895141601562,148.78819274902344,148.8074188232422,148.82666015625,148.84588623046875,148.86512756347656,148.88436889648438,148.90359497070312,148.92283630371094,148.9420623779297,148.9613037109375,148.9805450439453,148.99977111816406,149.01901245117188,149.03823852539062,149.05747985839844,149.07672119140625,149.095947265625,149.1151885986328,149.13441467285156,149.15365600585938,149.17288208007812,149.19212341308594,149.21136474609375,149.2305908203125,149.2498321533203,149.26905822753906,149.28829956054688,149.3075408935547,149.32676696777344,149.34600830078125,149.365234375,149.3844757080078,149.40371704101562,149.42294311523438,149.4421844482422,149.46141052246094,149.48065185546875,149.49989318847656,149.5191192626953,149.53836059570312,149.55758666992188,149.5768280029297,149.5960693359375,149.61529541015625,149.63453674316406,149.6537628173828,149.67300415039062,149.69223022460938,149.7114715576172,149.730712890625,149.74993896484375,149.76918029785156,149.7884063720703,149.80764770507812,149.82688903808594,149.8461151123047,149.8653564453125,149.88458251953125,149.90382385253906,149.92306518554688,149.94229125976562,149.96153259277344,149.9807586669922,150]}" +"1500","{""bins"":[149.55426025390625,149.56121826171875,149.5681915283203,149.5751495361328,149.58212280273438,149.58908081054688,149.59605407714844,149.60301208496094,149.6099853515625,149.616943359375,149.6239013671875,149.63087463378906,149.63783264160156,149.64480590820312,149.65176391601562,149.6587371826172,149.6656951904297,149.6726531982422,149.67962646484375,149.68658447265625,149.6935577392578,149.7005157470703,149.70748901367188,149.71444702148438,149.72140502929688,149.72837829589844,149.73533630371094,149.7423095703125,149.749267578125,149.75624084472656,149.76319885253906,149.77017211914062,149.77713012695312,149.78408813476562,149.7910614013672,149.7980194091797,149.80499267578125,149.81195068359375,149.8189239501953,149.8258819580078,149.83285522460938,149.83981323242188,149.84677124023438,149.85374450683594,149.86070251464844,149.86767578125,149.8746337890625,149.88160705566406,149.88856506347656,149.89552307128906,149.90249633789062,149.90945434570312,149.9164276123047,149.9233856201172,149.93035888671875,149.93731689453125,149.94427490234375,149.9512481689453,149.9582061767578,149.96517944335938,149.97213745117188,149.97911071777344,149.98606872558594,149.9930419921875,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}" +"1600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,1,1,0,0,0,0,3],""bins"":[149.17001342773438,149.1829833984375,149.19595336914062,149.20892333984375,149.22189331054688,149.23486328125,149.24781799316406,149.2607879638672,149.2737579345703,149.28672790527344,149.29969787597656,149.3126678466797,149.3256378173828,149.33860778808594,149.35157775878906,149.3645477294922,149.37750244140625,149.39047241210938,149.4034423828125,149.41641235351562,149.42938232421875,149.44235229492188,149.455322265625,149.46829223632812,149.48126220703125,149.49423217773438,149.5072021484375,149.52015686035156,149.5331268310547,149.5460968017578,149.55906677246094,149.57203674316406,149.5850067138672,149.5979766845703,149.61094665527344,149.62391662597656,149.6368865966797,149.6498565673828,149.66281127929688,149.67578125,149.68875122070312,149.70172119140625,149.71469116210938,149.7276611328125,149.74063110351562,149.75360107421875,149.76657104492188,149.779541015625,149.79251098632812,149.8054656982422,149.8184356689453,149.83140563964844,149.84437561035156,149.8573455810547,149.8703155517578,149.88328552246094,149.89625549316406,149.9092254638672,149.9221954345703,149.93515014648438,149.9481201171875,149.96109008789062,149.97406005859375,149.98703002929688,150]}" +"1700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,3],""bins"":[149.36141967773438,149.37139892578125,149.38137817382812,149.391357421875,149.40133666992188,149.41131591796875,149.42127990722656,149.43125915527344,149.4412384033203,149.4512176513672,149.46119689941406,149.47117614746094,149.4811553955078,149.4911346435547,149.50111389160156,149.51109313964844,149.52105712890625,149.53103637695312,149.541015625,149.55099487304688,149.56097412109375,149.57095336914062,149.5809326171875,149.59091186523438,149.60089111328125,149.61087036132812,149.620849609375,149.6308135986328,149.6407928466797,149.65077209472656,149.66075134277344,149.6707305908203,149.6807098388672,149.69068908691406,149.70066833496094,149.7106475830078,149.7206268310547,149.73060607910156,149.74057006835938,149.75054931640625,149.76052856445312,149.7705078125,149.78048706054688,149.79046630859375,149.80044555664062,149.8104248046875,149.82040405273438,149.83038330078125,149.84036254882812,149.85032653808594,149.8603057861328,149.8702850341797,149.88026428222656,149.89024353027344,149.9002227783203,149.9102020263672,149.92018127441406,149.93016052246094,149.9401397705078,149.95010375976562,149.9600830078125,149.97006225585938,149.98004150390625,149.99002075195312,150]}" +"1800","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,3],""bins"":[149.42300415039062,149.43202209472656,149.4410400390625,149.45005798339844,149.4590606689453,149.46807861328125,149.4770965576172,149.48611450195312,149.49513244628906,149.504150390625,149.51315307617188,149.5221710205078,149.53118896484375,149.5402069091797,149.54922485351562,149.55824279785156,149.5672607421875,149.57626342773438,149.5852813720703,149.59429931640625,149.6033172607422,149.61233520507812,149.62135314941406,149.63035583496094,149.63937377929688,149.6483917236328,149.65740966796875,149.6664276123047,149.67544555664062,149.6844482421875,149.69346618652344,149.70248413085938,149.7115020751953,149.72052001953125,149.7295379638672,149.73855590820312,149.74755859375,149.75657653808594,149.76559448242188,149.7746124267578,149.78363037109375,149.7926483154297,149.80165100097656,149.8106689453125,149.81968688964844,149.82870483398438,149.8377227783203,149.84674072265625,149.85574340820312,149.86476135253906,149.873779296875,149.88279724121094,149.89181518554688,149.9008331298828,149.90985107421875,149.91885375976562,149.92787170410156,149.9368896484375,149.94590759277344,149.95492553710938,149.9639434814453,149.9729461669922,149.98196411132812,149.99098205566406,150],""_type"":""histogram""}" +"1900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,1,0,0,1,0,1,0,0,3],""bins"":[148.7623748779297,148.78170776367188,148.80105590820312,148.8203887939453,148.8397216796875,148.85906982421875,148.87840270996094,148.89773559570312,148.91708374023438,148.93641662597656,148.95574951171875,148.97509765625,148.9944305419922,149.01376342773438,149.03311157226562,149.0524444580078,149.07177734375,149.09112548828125,149.11045837402344,149.12979125976562,149.14913940429688,149.16847229003906,149.18780517578125,149.2071533203125,149.2264862060547,149.24581909179688,149.26516723632812,149.2845001220703,149.3038330078125,149.32318115234375,149.34251403808594,149.36184692382812,149.38119506835938,149.40052795410156,149.41986083984375,149.43919372558594,149.4585418701172,149.47787475585938,149.49720764160156,149.5165557861328,149.535888671875,149.5552215576172,149.57456970214844,149.59390258789062,149.6132354736328,149.63258361816406,149.65191650390625,149.67124938964844,149.6905975341797,149.70993041992188,149.72926330566406,149.7486114501953,149.7679443359375,149.7872772216797,149.80662536621094,149.82595825195312,149.8452911376953,149.86463928222656,149.88397216796875,149.90330505371094,149.9226531982422,149.94198608398438,149.96131896972656,149.9806671142578,150]}" +"2000","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,2,1,0,0,0,0,1,0,0,0,0,0,1,3],""bins"":[149.5408477783203,149.5480194091797,149.55519104003906,149.5623779296875,149.56954956054688,149.57672119140625,149.58389282226562,149.591064453125,149.59823608398438,149.6054229736328,149.6125946044922,149.61976623535156,149.62693786621094,149.6341094970703,149.6412811279297,149.64846801757812,149.6556396484375,149.66281127929688,149.66998291015625,149.67715454101562,149.684326171875,149.69151306152344,149.6986846923828,149.7058563232422,149.71302795410156,149.72019958496094,149.7273712158203,149.73455810546875,149.74172973632812,149.7489013671875,149.75607299804688,149.76324462890625,149.77041625976562,149.77760314941406,149.78477478027344,149.7919464111328,149.7991180419922,149.80628967285156,149.8134765625,149.82064819335938,149.82781982421875,149.83499145507812,149.8421630859375,149.84933471679688,149.8565216064453,149.8636932373047,149.87086486816406,149.87803649902344,149.8852081298828,149.8923797607422,149.89956665039062,149.90673828125,149.91390991210938,149.92108154296875,149.92825317382812,149.9354248046875,149.94261169433594,149.9497833251953,149.9569549560547,149.96412658691406,149.97129821777344,149.9784698486328,149.98565673828125,149.99282836914062,150]}" +"2100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,3],""bins"":[149.5018310546875,149.50961303710938,149.51739501953125,149.52517700195312,149.532958984375,149.54075622558594,149.5485382080078,149.5563201904297,149.56410217285156,149.57188415527344,149.5796661376953,149.5874481201172,149.59524536132812,149.60302734375,149.61080932617188,149.61859130859375,149.62637329101562,149.6341552734375,149.64193725585938,149.64971923828125,149.65750122070312,149.66529846191406,149.67308044433594,149.6808624267578,149.6886444091797,149.69642639160156,149.70420837402344,149.7119903564453,149.71978759765625,149.72756958007812,149.7353515625,149.74313354492188,149.75091552734375,149.75869750976562,149.7664794921875,149.77426147460938,149.78204345703125,149.7898406982422,149.79762268066406,149.80540466308594,149.8131866455078,149.8209686279297,149.82875061035156,149.83653259277344,149.84432983398438,149.85211181640625,149.85989379882812,149.86767578125,149.87545776367188,149.88323974609375,149.89102172851562,149.8988037109375,149.90658569335938,149.9143829345703,149.9221649169922,149.92994689941406,149.93772888183594,149.9455108642578,149.9532928466797,149.96107482910156,149.9688720703125,149.97665405273438,149.98443603515625,149.99221801757812,150]}" +"2200","{""bins"":[149.558349609375,149.56524658203125,149.5721435546875,149.5790557861328,149.58595275878906,149.5928497314453,149.59976196289062,149.60665893554688,149.61355590820312,149.62045288085938,149.62734985351562,149.63426208496094,149.6411590576172,149.64805603027344,149.65496826171875,149.661865234375,149.66876220703125,149.6756591796875,149.68255615234375,149.68946838378906,149.6963653564453,149.70326232910156,149.71017456054688,149.71707153320312,149.72396850585938,149.73086547851562,149.73776245117188,149.7446746826172,149.75157165527344,149.7584686279297,149.765380859375,149.77227783203125,149.7791748046875,149.78607177734375,149.79296875,149.7998809814453,149.80677795410156,149.8136749267578,149.82058715820312,149.82748413085938,149.83438110351562,149.84127807617188,149.84817504882812,149.85508728027344,149.8619842529297,149.86888122558594,149.87579345703125,149.8826904296875,149.88958740234375,149.896484375,149.90338134765625,149.91029357910156,149.9171905517578,149.92408752441406,149.93099975585938,149.93789672851562,149.94479370117188,149.95169067382812,149.95858764648438,149.9654998779297,149.97239685058594,149.9792938232422,149.9862060546875,149.99310302734375,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,0,2,1,1,0,0,0,3]}" +"2300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,2,1,0,0,3],""bins"":[149.1126251220703,149.12649536132812,149.14035034179688,149.1542205810547,149.1680908203125,149.18194580078125,149.19581604003906,149.20968627929688,149.22354125976562,149.23741149902344,149.25128173828125,149.26513671875,149.2790069580078,149.29287719726562,149.30673217773438,149.3206024169922,149.33447265625,149.34832763671875,149.36219787597656,149.37606811523438,149.38992309570312,149.40379333496094,149.41766357421875,149.4315185546875,149.4453887939453,149.45925903320312,149.47311401367188,149.4869842529297,149.5008544921875,149.51470947265625,149.52857971191406,149.54244995117188,149.55630493164062,149.57017517089844,149.58404541015625,149.59791564941406,149.6117706298828,149.62564086914062,149.63951110839844,149.6533660888672,149.667236328125,149.6811065673828,149.69496154785156,149.70883178710938,149.7227020263672,149.73655700683594,149.75042724609375,149.76429748535156,149.7781524658203,149.79202270507812,149.80589294433594,149.8197479248047,149.8336181640625,149.8474884033203,149.86134338378906,149.87521362304688,149.8890838623047,149.90293884277344,149.91680908203125,149.93067932128906,149.9445343017578,149.95840454101562,149.97227478027344,149.9861297607422,150]}" +"2400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,2,0,0,0,0,0,0,0,0,0,3],""bins"":[149.71124267578125,149.71575927734375,149.7202606201172,149.7247772216797,149.7292938232422,149.73379516601562,149.73831176757812,149.74282836914062,149.74734497070312,149.75184631347656,149.75636291503906,149.76087951660156,149.765380859375,149.7698974609375,149.7744140625,149.77891540527344,149.78343200683594,149.78794860839844,149.79244995117188,149.79696655273438,149.80148315429688,149.8059844970703,149.8105010986328,149.8150177001953,149.81951904296875,149.82403564453125,149.82855224609375,149.83306884765625,149.8375701904297,149.8420867919922,149.8466033935547,149.85110473632812,149.85562133789062,149.86013793945312,149.86463928222656,149.86915588378906,149.87367248535156,149.878173828125,149.8826904296875,149.88720703125,149.8917236328125,149.89622497558594,149.90074157714844,149.90525817871094,149.90975952148438,149.91427612304688,149.91879272460938,149.9232940673828,149.9278106689453,149.9323272705078,149.93682861328125,149.94134521484375,149.94586181640625,149.9503631591797,149.9548797607422,149.9593963623047,149.96389770507812,149.96841430664062,149.97293090820312,149.97744750976562,149.98194885253906,149.98646545410156,149.99098205566406,149.9954833984375,150]}" +"2500","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,1,0,0,0,0,0,3],""bins"":[149.58355712890625,149.59005737304688,149.59657287597656,149.6030731201172,149.60958862304688,149.6160888671875,149.6226043701172,149.6291046142578,149.6356201171875,149.64212036132812,149.64862060546875,149.65513610839844,149.66163635253906,149.66815185546875,149.67465209960938,149.68116760253906,149.6876678466797,149.6941680908203,149.70068359375,149.70718383789062,149.7136993408203,149.72019958496094,149.72671508789062,149.73321533203125,149.73971557617188,149.74623107910156,149.7527313232422,149.75924682617188,149.7657470703125,149.7722625732422,149.7787628173828,149.7852783203125,149.79177856445312,149.79827880859375,149.80479431152344,149.81129455566406,149.81781005859375,149.82431030273438,149.83082580566406,149.8373260498047,149.84384155273438,149.850341796875,149.85684204101562,149.8633575439453,149.86985778808594,149.87637329101562,149.88287353515625,149.88938903808594,149.89588928222656,149.9023895263672,149.90890502929688,149.9154052734375,149.9219207763672,149.9284210205078,149.9349365234375,149.94143676757812,149.94793701171875,149.95445251464844,149.96095275878906,149.96746826171875,149.97396850585938,149.98048400878906,149.9869842529297,149.99349975585938,150],""_type"":""histogram""}" +"2600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,1,0,0,0,0,0,0,0,0,0,0,3],""bins"":[149.67474365234375,149.67982482910156,149.68490600585938,149.6899871826172,149.695068359375,149.7001495361328,149.70523071289062,149.71031188964844,149.71539306640625,149.72048950195312,149.72557067871094,149.73065185546875,149.73573303222656,149.74081420898438,149.7458953857422,149.7509765625,149.7560577392578,149.76113891601562,149.76622009277344,149.77130126953125,149.77638244628906,149.78146362304688,149.7865447998047,149.7916259765625,149.79672241210938,149.8018035888672,149.806884765625,149.8119659423828,149.81704711914062,149.82212829589844,149.82720947265625,149.83229064941406,149.83737182617188,149.8424530029297,149.8475341796875,149.8526153564453,149.85769653320312,149.86277770996094,149.86785888671875,149.87294006347656,149.87802124023438,149.88311767578125,149.88819885253906,149.89328002929688,149.8983612060547,149.9034423828125,149.9085235595703,149.91360473632812,149.91868591308594,149.92376708984375,149.92884826660156,149.93392944335938,149.9390106201172,149.944091796875,149.9491729736328,149.95425415039062,149.9593505859375,149.9644317626953,149.96951293945312,149.97459411621094,149.97967529296875,149.98475646972656,149.98983764648438,149.9949188232422,150]}" +"2700","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,1,0,0,0,0,0,3],""_type"":""histogram"",""bins"":[149.65234375,149.65777587890625,149.6632080078125,149.66864013671875,149.674072265625,149.67950439453125,149.6849365234375,149.69036865234375,149.69580078125,149.70123291015625,149.7066650390625,149.71209716796875,149.717529296875,149.72296142578125,149.7283935546875,149.73382568359375,149.7392578125,149.74468994140625,149.7501220703125,149.75555419921875,149.760986328125,149.76641845703125,149.7718505859375,149.77728271484375,149.78271484375,149.78814697265625,149.7935791015625,149.79901123046875,149.804443359375,149.80987548828125,149.8153076171875,149.82073974609375,149.826171875,149.83160400390625,149.8370361328125,149.84246826171875,149.847900390625,149.85333251953125,149.8587646484375,149.86419677734375,149.86962890625,149.87506103515625,149.8804931640625,149.88592529296875,149.891357421875,149.89678955078125,149.9022216796875,149.90765380859375,149.9130859375,149.91851806640625,149.9239501953125,149.92938232421875,149.934814453125,149.94024658203125,149.9456787109375,149.95111083984375,149.95654296875,149.96197509765625,149.9674072265625,149.97283935546875,149.978271484375,149.98370361328125,149.9891357421875,149.99456787109375,150]}" +"2800","{""bins"":[149.7410430908203,149.74508666992188,149.74913024902344,149.75318908691406,149.75723266601562,149.7612762451172,149.76531982421875,149.7693634033203,149.77340698242188,149.7774658203125,149.78150939941406,149.78555297851562,149.7895965576172,149.79364013671875,149.7976837158203,149.80174255371094,149.8057861328125,149.80982971191406,149.81387329101562,149.8179168701172,149.82196044921875,149.82601928710938,149.83006286621094,149.8341064453125,149.83815002441406,149.84219360351562,149.8462371826172,149.8502960205078,149.85433959960938,149.85838317871094,149.8624267578125,149.86647033691406,149.87051391601562,149.87457275390625,149.8786163330078,149.88265991210938,149.88670349121094,149.8907470703125,149.89480590820312,149.8988494873047,149.90289306640625,149.9069366455078,149.91098022460938,149.91502380371094,149.91908264160156,149.92312622070312,149.9271697998047,149.93121337890625,149.9352569580078,149.93930053710938,149.943359375,149.94740295410156,149.95144653320312,149.9554901123047,149.95953369140625,149.9635772705078,149.96763610839844,149.9716796875,149.97572326660156,149.97976684570312,149.9838104248047,149.98785400390625,149.99191284179688,149.99595642089844,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,0,3]}" +"2900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,0,1,0,0,3],""bins"":[149.65863037109375,149.66397094726562,149.66929626464844,149.6746368408203,149.67996215820312,149.685302734375,149.6906280517578,149.6959686279297,149.7012939453125,149.70663452148438,149.71197509765625,149.71730041503906,149.72264099121094,149.72796630859375,149.73330688476562,149.73863220214844,149.7439727783203,149.7493133544922,149.754638671875,149.75997924804688,149.7653045654297,149.77064514160156,149.77597045898438,149.78131103515625,149.78665161132812,149.79197692871094,149.7973175048828,149.80264282226562,149.8079833984375,149.8133087158203,149.8186492919922,149.823974609375,149.82931518554688,149.83465576171875,149.83998107910156,149.84532165527344,149.85064697265625,149.85598754882812,149.86131286621094,149.8666534423828,149.87197875976562,149.8773193359375,149.88265991210938,149.8879852294922,149.89332580566406,149.89865112304688,149.90399169921875,149.90931701660156,149.91465759277344,149.9199981689453,149.92532348632812,149.9306640625,149.9359893798828,149.9413299560547,149.9466552734375,149.95199584960938,149.95733642578125,149.96266174316406,149.96800231933594,149.97332763671875,149.97866821289062,149.98399353027344,149.9893341064453,149.99465942382812,150]}" +"3000","{""_type"":""histogram"",""values"":[2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3],""bins"":[149.756103515625,149.75991821289062,149.76373291015625,149.7675323486328,149.77134704589844,149.77516174316406,149.77896118164062,149.78277587890625,149.78659057617188,149.7904052734375,149.79421997070312,149.7980194091797,149.8018341064453,149.80564880371094,149.8094482421875,149.81326293945312,149.81707763671875,149.82089233398438,149.82470703125,149.82850646972656,149.8323211669922,149.8361358642578,149.83993530273438,149.84375,149.84756469726562,149.85137939453125,149.85519409179688,149.85899353027344,149.86280822753906,149.8666229248047,149.87042236328125,149.87423706054688,149.8780517578125,149.88186645507812,149.88568115234375,149.8894805908203,149.89329528808594,149.89710998535156,149.90090942382812,149.90472412109375,149.90853881835938,149.912353515625,149.91616821289062,149.9199676513672,149.9237823486328,149.92759704589844,149.931396484375,149.93521118164062,149.93902587890625,149.94284057617188,149.9466552734375,149.95045471191406,149.9542694091797,149.9580841064453,149.96188354492188,149.9656982421875,149.96951293945312,149.97332763671875,149.97714233398438,149.98094177246094,149.98475646972656,149.9885711669922,149.99237060546875,149.99618530273438,150]}" +"3100","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,4],""bins"":[149.4569091796875,149.46539306640625,149.473876953125,149.48236083984375,149.4908447265625,149.4993438720703,149.50782775878906,149.5163116455078,149.52479553222656,149.5332794189453,149.54176330566406,149.5502471923828,149.55874633789062,149.56723022460938,149.57571411132812,149.58419799804688,149.59268188476562,149.60116577148438,149.60964965820312,149.61813354492188,149.62661743164062,149.63511657714844,149.6436004638672,149.65208435058594,149.6605682373047,149.66905212402344,149.6775360107422,149.68601989746094,149.69451904296875,149.7030029296875,149.71148681640625,149.719970703125,149.72845458984375,149.7369384765625,149.74542236328125,149.75390625,149.76239013671875,149.77088928222656,149.7793731689453,149.78785705566406,149.7963409423828,149.80482482910156,149.8133087158203,149.82179260253906,149.83029174804688,149.83877563476562,149.84725952148438,149.85574340820312,149.86422729492188,149.87271118164062,149.88119506835938,149.88967895507812,149.89816284179688,149.9066619873047,149.91514587402344,149.9236297607422,149.93211364746094,149.9405975341797,149.94908142089844,149.9575653076172,149.966064453125,149.97454833984375,149.9830322265625,149.99151611328125,150]}" +"3200","{""bins"":[149.8720245361328,149.8740234375,149.8760223388672,149.87802124023438,149.88002014160156,149.88201904296875,149.88401794433594,149.88601684570312,149.8880157470703,149.8900146484375,149.8920135498047,149.89402770996094,149.89602661132812,149.8980255126953,149.9000244140625,149.9020233154297,149.90402221679688,149.90602111816406,149.90802001953125,149.91001892089844,149.91201782226562,149.9140167236328,149.916015625,149.9180145263672,149.92001342773438,149.92201232910156,149.92401123046875,149.92601013183594,149.92800903320312,149.9300079345703,149.9320068359375,149.9340057373047,149.93600463867188,149.93801879882812,149.9400177001953,149.9420166015625,149.9440155029297,149.94601440429688,149.94801330566406,149.95001220703125,149.95201110839844,149.95401000976562,149.9560089111328,149.9580078125,149.9600067138672,149.96200561523438,149.96400451660156,149.96600341796875,149.96800231933594,149.97000122070312,149.9720001220703,149.9739990234375,149.9759979248047,149.97799682617188,149.98001098632812,149.9820098876953,149.9840087890625,149.9860076904297,149.98800659179688,149.99000549316406,149.99200439453125,149.99400329589844,149.99600219726562,149.9980010986328,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,3]}" +"3300","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,1,0,0,0,0,3],""bins"":[149.5899658203125,149.59637451171875,149.602783203125,149.60919189453125,149.6156005859375,149.6219940185547,149.62840270996094,149.6348114013672,149.64122009277344,149.6476287841797,149.65403747558594,149.6604461669922,149.66683959960938,149.67324829101562,149.67965698242188,149.68606567382812,149.69247436523438,149.69888305664062,149.70529174804688,149.71170043945312,149.71810913085938,149.72450256347656,149.7309112548828,149.73731994628906,149.7437286376953,149.75013732910156,149.7565460205078,149.76295471191406,149.76934814453125,149.7757568359375,149.78216552734375,149.78857421875,149.79498291015625,149.8013916015625,149.80780029296875,149.814208984375,149.82061767578125,149.82701110839844,149.8334197998047,149.83982849121094,149.8462371826172,149.85264587402344,149.8590545654297,149.86546325683594,149.87185668945312,149.87826538085938,149.88467407226562,149.89108276367188,149.89749145507812,149.90390014648438,149.91030883789062,149.91671752929688,149.92312622070312,149.9295196533203,149.93592834472656,149.9423370361328,149.94874572753906,149.9551544189453,149.96156311035156,149.9679718017578,149.974365234375,149.98077392578125,149.9871826171875,149.99359130859375,150]}" +"3400","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0,0,3],""bins"":[149.78488159179688,149.78823852539062,149.79161071777344,149.7949676513672,149.79832458496094,149.8016815185547,149.8050537109375,149.80841064453125,149.811767578125,149.8151397705078,149.81849670410156,149.8218536376953,149.82521057128906,149.82858276367188,149.83193969726562,149.83529663085938,149.83865356445312,149.84202575683594,149.8453826904297,149.84873962402344,149.85211181640625,149.85546875,149.85882568359375,149.8621826171875,149.8655548095703,149.86891174316406,149.8722686767578,149.87564086914062,149.87899780273438,149.88235473632812,149.88571166992188,149.8890838623047,149.89244079589844,149.8957977294922,149.899169921875,149.90252685546875,149.9058837890625,149.90924072265625,149.91261291503906,149.9159698486328,149.91932678222656,149.92269897460938,149.92605590820312,149.92941284179688,149.93276977539062,149.93614196777344,149.9394989013672,149.94285583496094,149.94622802734375,149.9495849609375,149.95294189453125,149.956298828125,149.9596710205078,149.96302795410156,149.9663848876953,149.96974182128906,149.97311401367188,149.97647094726562,149.97982788085938,149.9832000732422,149.98655700683594,149.9899139404297,149.99327087402344,149.99664306640625,150]}" +"3500","{""bins"":[149.75067138671875,149.7545623779297,149.7584686279297,149.76235961914062,149.76625061035156,149.77015686035156,149.7740478515625,149.77793884277344,149.78182983398438,149.78573608398438,149.7896270751953,149.79351806640625,149.79742431640625,149.8013153076172,149.80520629882812,149.80911254882812,149.81300354003906,149.81689453125,149.82080078125,149.82469177246094,149.82858276367188,149.83248901367188,149.8363800048828,149.84027099609375,149.84417724609375,149.8480682373047,149.85195922851562,149.85585021972656,149.85975646972656,149.8636474609375,149.86753845214844,149.87144470214844,149.87533569335938,149.8792266845703,149.8831329345703,149.88702392578125,149.8909149169922,149.8948211669922,149.89871215820312,149.90260314941406,149.906494140625,149.910400390625,149.91429138183594,149.91818237304688,149.92208862304688,149.9259796142578,149.92987060546875,149.93377685546875,149.9376678466797,149.94155883789062,149.94546508789062,149.94935607910156,149.9532470703125,149.9571533203125,149.96104431152344,149.96493530273438,149.96884155273438,149.9727325439453,149.97662353515625,149.9805145263672,149.9844207763672,149.98831176757812,149.99220275878906,149.99610900878906,150],""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,1,0,1,0,0,0,0,1,0,0,0,3]}" +"3600","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,3],""bins"":[149.89866638183594,149.90025329589844,149.90184020996094,149.90341186523438,149.90499877929688,149.90658569335938,149.90817260742188,149.9097442626953,149.9113311767578,149.9129180908203,149.9145050048828,149.91607666015625,149.91766357421875,149.91925048828125,149.92083740234375,149.9224090576172,149.9239959716797,149.9255828857422,149.9271697998047,149.9287567138672,149.93032836914062,149.93191528320312,149.93350219726562,149.93508911132812,149.93666076660156,149.93824768066406,149.93983459472656,149.94142150878906,149.9429931640625,149.944580078125,149.9461669921875,149.94775390625,149.9493408203125,149.95091247558594,149.95249938964844,149.95408630371094,149.95567321777344,149.95724487304688,149.95883178710938,149.96041870117188,149.96200561523438,149.9635772705078,149.9651641845703,149.9667510986328,149.9683380126953,149.96990966796875,149.97149658203125,149.97308349609375,149.97467041015625,149.97625732421875,149.9778289794922,149.9794158935547,149.9810028076172,149.9825897216797,149.98416137695312,149.98574829101562,149.98733520507812,149.98892211914062,149.99049377441406,149.99208068847656,149.99366760253906,149.99525451660156,149.996826171875,149.9984130859375,150]}" +"3700","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,1,0,0,0,0,1,0,0,0,3],""bins"":[149.76803588867188,149.77166748046875,149.77528381347656,149.77891540527344,149.78253173828125,149.78616333007812,149.78977966308594,149.7934112548828,149.79702758789062,149.8006591796875,149.8042755126953,149.8079071044922,149.8115234375,149.81515502929688,149.8187713623047,149.82240295410156,149.82601928710938,149.82965087890625,149.83328247070312,149.83689880371094,149.8405303955078,149.84414672851562,149.8477783203125,149.8513946533203,149.8550262451172,149.858642578125,149.86227416992188,149.8658905029297,149.86952209472656,149.87313842773438,149.87677001953125,149.88038635253906,149.88401794433594,149.8876495361328,149.89126586914062,149.8948974609375,149.8985137939453,149.9021453857422,149.90576171875,149.90939331054688,149.9130096435547,149.91664123535156,149.92025756835938,149.92388916015625,149.92750549316406,149.93113708496094,149.93475341796875,149.93838500976562,149.9420166015625,149.9456329345703,149.9492645263672,149.952880859375,149.95651245117188,149.9601287841797,149.96376037597656,149.96737670898438,149.97100830078125,149.97462463378906,149.97825622558594,149.98187255859375,149.98550415039062,149.98912048339844,149.9927520751953,149.99636840820312,150]}" +"3800","{""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,2,0,1,0,0,1,0,0,0,3],""bins"":[149.3803253173828,149.3900146484375,149.39968872070312,149.4093780517578,149.41905212402344,149.42874145507812,149.43841552734375,149.44810485839844,149.45777893066406,149.46746826171875,149.47714233398438,149.48683166503906,149.49652099609375,149.50619506835938,149.51588439941406,149.5255584716797,149.53524780273438,149.544921875,149.5546112060547,149.5642852783203,149.573974609375,149.58364868164062,149.5933380126953,149.60302734375,149.61270141601562,149.6223907470703,149.63206481933594,149.64175415039062,149.65142822265625,149.66111755371094,149.67079162597656,149.68048095703125,149.69015502929688,149.69984436035156,149.70953369140625,149.71920776367188,149.72889709472656,149.7385711669922,149.74826049804688,149.7579345703125,149.7676239013672,149.7772979736328,149.7869873046875,149.7966766357422,149.8063507080078,149.8160400390625,149.82571411132812,149.8354034423828,149.84507751464844,149.85476684570312,149.86444091796875,149.87413024902344,149.88380432128906,149.89349365234375,149.90318298339844,149.91285705566406,149.92254638671875,149.93222045898438,149.94190979003906,149.9515838623047,149.96127319335938,149.970947265625,149.9806365966797,149.9903106689453,150],""_type"":""histogram""}" +"3900","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,1,0,0,3],""bins"":[149.67698669433594,149.68203735351562,149.6870880126953,149.69212341308594,149.69717407226562,149.7022247314453,149.707275390625,149.71231079101562,149.7173614501953,149.722412109375,149.7274627685547,149.7324981689453,149.737548828125,149.7425994873047,149.74765014648438,149.752685546875,149.7577362060547,149.76278686523438,149.76783752441406,149.77288818359375,149.77792358398438,149.78297424316406,149.78802490234375,149.79307556152344,149.79811096191406,149.80316162109375,149.80821228027344,149.81326293945312,149.81829833984375,149.82334899902344,149.82839965820312,149.8334503173828,149.8385009765625,149.84353637695312,149.8485870361328,149.8536376953125,149.8586883544922,149.8637237548828,149.8687744140625,149.8738250732422,149.87887573242188,149.8839111328125,149.8889617919922,149.89401245117188,149.89906311035156,149.9040985107422,149.90914916992188,149.91419982910156,149.91925048828125,149.92430114746094,149.92933654785156,149.93438720703125,149.93943786621094,149.94448852539062,149.94952392578125,149.95457458496094,149.95962524414062,149.9646759033203,149.96971130371094,149.97476196289062,149.9798126220703,149.98486328125,149.98989868164062,149.9949493408203,150]}" +"4000","{""_type"":""histogram"",""values"":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0,0,1,0,1,0,0,0,3],""bins"":[149.80003356933594,149.80316162109375,149.80628967285156,149.8094024658203,149.81253051757812,149.81565856933594,149.81878662109375,149.8218994140625,149.8250274658203,149.82815551757812,149.83128356933594,149.8343963623047,149.8375244140625,149.8406524658203,149.84378051757812,149.84689331054688,149.8500213623047,149.8531494140625,149.8562774658203,149.85940551757812,149.86251831054688,149.8656463623047,149.8687744140625,149.8719024658203,149.87501525878906,149.87814331054688,149.8812713623047,149.8843994140625,149.88751220703125,149.89064025878906,149.89376831054688,149.8968963623047,149.9000244140625,149.90313720703125,149.90626525878906,149.90939331054688,149.9125213623047,149.91563415527344,149.91876220703125,149.92189025878906,149.92501831054688,149.92813110351562,149.93125915527344,149.93438720703125,149.93751525878906,149.9406280517578,149.94375610351562,149.94688415527344,149.95001220703125,149.95314025878906,149.9562530517578,149.95938110351562,149.96250915527344,149.96563720703125,149.96875,149.9718780517578,149.97500610351562,149.97813415527344,149.9812469482422,149.984375,149.9875030517578,149.99063110351562,149.99374389648438,149.9968719482422,150]}" diff --git a/paper/src/chapters/figures/supra.tex b/paper/src/chapters/figures/supra.tex new file mode 100644 index 0000000..439f22e --- /dev/null +++ b/paper/src/chapters/figures/supra.tex @@ -0,0 +1,26 @@ +\begin{tikzpicture} + \begin{axis}[ + view={0}{90}, % Top-down view for heatmap + xlabel={Step}, + ylabel={Price}, + colorbar, + colorbar style={ + title={Density}, + ylabel={}, + }, + colormap/viridis, + % Adjust these axis limits if necessary based on data + enlargelimits=false, + axis on top, + width=0.9\columnwidth, + height=0.6\columnwidth, + ] + + \addplot3[ + surf, + shader=flat, + mesh/check=false % Disable check to rely on empty lines + ] table [col sep=comma, x=step, y=price, z=density] {chapters/figures/supra_data.csv}; + + \end{axis} +\end{tikzpicture} diff --git a/paper/src/chapters/figures/supra_data.csv b/paper/src/chapters/figures/supra_data.csv new file mode 100644 index 0000000..f005217 --- /dev/null +++ b/paper/src/chapters/figures/supra_data.csv @@ -0,0 +1,4041 @@ +step,price,density +100,11.13730710029602,0.0 +100,12.532912054061889,0.0 +100,13.92851700782776,0.0 +100,15.32412196159363,0.24746897748878635 +100,16.719726915359498,1.3648902740437554 +100,18.115331869125367,0.8762655112556068 +100,19.51093682289124,0.5113752372118514 +100,20.906541776657107,0.0 +100,22.302146730422976,0.5359601738537835 +100,23.697751684188844,0.4640398261462166 +100,25.093356637954713,0.0 +100,26.48896159172058,0.0 +100,27.88456654548645,0.0 +100,29.28017149925232,0.0 +100,30.67577645301819,0.0 +100,32.07138140678406,0.0 +100,33.46698636054993,0.0 +100,34.8625913143158,0.0 +100,36.25819626808167,0.0 +100,37.65380122184754,0.0 +100,39.049406175613406,0.0 +100,40.445011129379274,0.0 +100,41.84061608314514,0.0 +100,43.23622103691101,0.0 +100,44.63182599067688,0.0 +100,46.02743094444275,0.0 +100,47.423035898208624,0.0 +100,48.81864085197449,0.0 +100,50.21424580574036,0.0 +100,51.60985075950623,0.0 +100,53.0054557132721,0.0 +100,54.40106066703797,0.23219832535697751 +100,55.796665620803836,0.6824451370218777 +100,57.192270574569704,0.08535653762114478 +100,58.58787552833557,0.0 +100,59.98348048210144,0.0 +100,61.37908543586731,0.0 +100,62.77469038963318,0.0 +100,64.17029534339906,0.0 +100,65.56590029716492,0.0 +100,66.9615052509308,0.0 +100,68.35711020469665,0.0 +100,69.75271515846254,0.0 +100,71.14832011222839,0.0 +100,72.54392506599427,0.10398510664139111 +100,73.93953001976013,0.6824451370218777 +100,75.33513497352601,0.2135697563367312 +100,76.73073992729186,0.0 +100,78.12634488105775,0.0 +100,79.5219498348236,0.5162107917507797 +100,80.91755478858948,0.4837892082492204 +100,82.31315974235534,0.0 +100,83.70876469612122,0.0 +100,85.10436964988709,0.0 +100,86.49997460365296,0.0 +100,87.89557955741884,0.0 +100,89.2911845111847,0.0 +100,90.68678946495058,0.0 +100,92.08239441871643,0.0 +100,93.47799937248232,0.0 +100,94.87360432624817,0.02310729899144128 +100,96.26920928001405,0.6824451370218777 +100,97.66481423377991,0.29444756398668104 +100,99.06041918754579,0.0 +100,100.45602414131164,0.0 +100,101.85162909507753,0.0 +100,103.24723404884338,0.0 +100,104.64283900260926,0.0 +100,106.03844395637512,0.0 +100,107.434048910141,0.0 +100,108.82965386390686,0.0 +100,110.22525881767274,0.0 +100,111.6208637714386,0.0 +100,113.01646872520448,0.0 +100,114.41207367897034,0.0 +100,115.80767863273621,0.0 +100,117.2032835865021,0.0 +100,118.59888854026795,0.0 +100,119.99449349403383,0.0 +100,121.39009844779969,0.0 +100,122.78570340156557,0.0 +100,124.18130835533142,0.0 +100,125.5769133090973,0.0 +100,126.97251826286316,0.0 +100,128.36812321662904,0.0 +100,129.76372817039493,0.08423572453839077 +100,131.15933312416075,0.6824451370218707 +100,132.55493807792664,0.23331913843973848 +100,133.95054303169252,0.0 +100,135.3461479854584,0.0 +100,136.74175293922423,0.0 +100,138.1373578929901,0.0 +100,139.532962846756,0.0 +100,140.92856780052188,0.0 +100,142.32417275428773,0.0 +100,143.71977770805358,0.0 +100,145.11538266181947,0.5911322317790524 +100,146.51098761558535,0.40886776822094756 +100,147.9065925693512,0.0 +100,149.30219752311706,0.0 + +200,11.13730710029602,0.6399999999999998 +200,12.532912054061889,0.6400000000000006 +200,13.92851700782776,0.6399999999999998 +200,15.32412196159363,0.07999999999999997 +200,16.719726915359498,0.0 +200,18.115331869125367,0.0 +200,19.51093682289124,0.0 +200,20.906541776657107,0.12000000000000118 +200,22.302146730422976,0.6399999999999998 +200,23.697751684188844,0.23999999999999908 +200,25.093356637954713,0.04000000000000039 +200,26.48896159172058,0.6399999999999998 +200,27.88456654548645,0.3199999999999999 +200,29.28017149925232,0.0 +200,30.67577645301819,0.0 +200,32.07138140678406,0.0 +200,33.46698636054993,0.0 +200,34.8625913143158,0.0 +200,36.25819626808167,0.0 +200,37.65380122184754,0.0 +200,39.049406175613406,0.0 +200,40.445011129379274,0.0 +200,41.84061608314514,0.0 +200,43.23622103691101,0.0 +200,44.63182599067688,0.0 +200,46.02743094444275,0.0 +200,47.423035898208624,0.0 +200,48.81864085197449,0.0 +200,50.21424580574036,0.0 +200,51.60985075950623,0.0 +200,53.0054557132721,0.0 +200,54.40106066703797,0.0 +200,55.796665620803836,0.0 +200,57.192270574569704,0.0 +200,58.58787552833557,0.0 +200,59.98348048210144,0.0 +200,61.37908543586731,0.0 +200,62.77469038963318,0.0 +200,64.17029534339906,0.0 +200,65.56590029716492,0.0 +200,66.9615052509308,0.0 +200,68.35711020469665,0.0 +200,69.75271515846254,0.0 +200,71.14832011222839,0.0 +200,72.54392506599427,0.0 +200,73.93953001976013,0.0 +200,75.33513497352601,0.0 +200,76.73073992729186,0.0 +200,78.12634488105775,0.0 +200,79.5219498348236,0.0 +200,80.91755478858948,0.0 +200,82.31315974235534,0.27999999999999947 +200,83.70876469612122,0.6399999999999998 +200,85.10436964988709,0.08000000000000078 +200,86.49997460365296,0.0 +200,87.89557955741884,0.0 +200,89.2911845111847,0.4800000000000047 +200,90.68678946495058,0.5199999999999954 +200,92.08239441871643,0.0 +200,93.47799937248232,0.0 +200,94.87360432624817,0.0 +200,96.26920928001405,0.0 +200,97.66481423377991,0.0 +200,99.06041918754579,0.0 +200,100.45602414131164,0.0 +200,101.85162909507753,0.0 +200,103.24723404884338,0.0 +200,104.64283900260926,0.0 +200,106.03844395637512,0.0 +200,107.434048910141,0.0 +200,108.82965386390686,0.0 +200,110.22525881767274,0.0 +200,111.6208637714386,0.0 +200,113.01646872520448,0.0 +200,114.41207367897034,0.0 +200,115.80767863273621,0.0 +200,117.2032835865021,0.0 +200,118.59888854026795,0.0 +200,119.99449349403383,0.0 +200,121.39009844779969,0.0 +200,122.78570340156557,0.0 +200,124.18130835533142,0.0 +200,125.5769133090973,0.12000000000000444 +200,126.97251826286316,0.6399999999999998 +200,128.36812321662904,0.23999999999999583 +200,129.76372817039493,0.0 +200,131.15933312416075,0.0 +200,132.55493807792664,0.0 +200,133.95054303169252,0.0 +200,135.3461479854584,0.0 +200,136.74175293922423,0.0 +200,138.1373578929901,0.0 +200,139.532962846756,0.0 +200,140.92856780052188,0.0 +200,142.32417275428773,0.0 +200,143.71977770805358,0.0 +200,145.11538266181947,0.0 +200,146.51098761558535,0.0 +200,147.9065925693512,1.0800000000000203 +200,149.30219752311706,1.9199999999999797 + +300,11.13730710029602,0.0 +300,12.532912054061889,0.0 +300,13.92851700782776,0.0 +300,15.32412196159363,0.0 +300,16.719726915359498,0.0 +300,18.115331869125367,0.0 +300,19.51093682289124,0.0 +300,20.906541776657107,0.0 +300,22.302146730422976,0.0 +300,23.697751684188844,0.0 +300,25.093356637954713,0.0 +300,26.48896159172058,0.0 +300,27.88456654548645,0.0 +300,29.28017149925232,0.0 +300,30.67577645301819,0.0 +300,32.07138140678406,0.0 +300,33.46698636054993,0.0 +300,34.8625913143158,0.0 +300,36.25819626808167,0.0 +300,37.65380122184754,0.0 +300,39.049406175613406,0.0 +300,40.445011129379274,0.0 +300,41.84061608314514,0.0 +300,43.23622103691101,0.0 +300,44.63182599067688,0.0 +300,46.02743094444275,0.0 +300,47.423035898208624,0.0 +300,48.81864085197449,0.0 +300,50.21424580574036,0.0 +300,51.60985075950623,0.0 +300,53.0054557132721,0.0 +300,54.40106066703797,0.0 +300,55.796665620803836,0.0 +300,57.192270574569704,0.0 +300,58.58787552833557,0.0 +300,59.98348048210144,0.0 +300,61.37908543586731,0.0 +300,62.77469038963318,0.0 +300,64.17029534339906,0.0 +300,65.56590029716492,0.0 +300,66.9615052509308,0.0 +300,68.35711020469665,0.0 +300,69.75271515846254,0.0 +300,71.14832011222839,0.0 +300,72.54392506599427,0.0 +300,73.93953001976013,0.0 +300,75.33513497352601,0.0 +300,76.73073992729186,0.0 +300,78.12634488105775,0.0 +300,79.5219498348236,0.0 +300,80.91755478858948,0.0 +300,82.31315974235534,0.0 +300,83.70876469612122,0.0 +300,85.10436964988709,0.0 +300,86.49997460365296,0.0 +300,87.89557955741884,0.0 +300,89.2911845111847,0.0 +300,90.68678946495058,0.0 +300,92.08239441871643,0.0 +300,93.47799937248232,1.0 +300,94.87360432624817,0.0 +300,96.26920928001405,0.5394269229453309 +300,97.66481423377991,0.46057307705466904 +300,99.06041918754579,0.0 +300,100.45602414131164,0.0 +300,101.85162909507753,0.7984319906247117 +300,103.24723404884338,0.20156800937528832 +300,104.64283900260926,0.0 +300,106.03844395637512,0.0 +300,107.434048910141,0.0 +300,108.82965386390686,0.0 +300,110.22525881767274,0.0 +300,111.6208637714386,0.0 +300,113.01646872520448,0.0 +300,114.41207367897034,0.0 +300,115.80767863273621,0.0 +300,117.2032835865021,0.0 +300,118.59888854026795,0.0 +300,119.99449349403383,0.0 +300,121.39009844779969,0.0 +300,122.78570340156557,0.0 +300,124.18130835533142,0.0 +300,125.5769133090973,0.0 +300,126.97251826286316,0.0 +300,128.36812321662904,0.0 +300,129.76372817039493,0.0 +300,131.15933312416075,0.0 +300,132.55493807792664,0.0 +300,133.95054303169252,0.0 +300,135.3461479854584,0.0 +300,136.74175293922423,0.0 +300,138.1373578929901,2.0 +300,139.532962846756,0.0 +300,140.92856780052188,0.0 +300,142.32417275428773,0.0 +300,143.71977770805358,0.0 +300,145.11538266181947,0.0 +300,146.51098761558535,0.8704966040511886 +300,147.9065925693512,1.1295033959488114 +300,149.30219752311706,3.0 + +400,11.13730710029602,0.0 +400,12.532912054061889,0.0 +400,13.92851700782776,0.0 +400,15.32412196159363,0.0 +400,16.719726915359498,0.0 +400,18.115331869125367,0.0 +400,19.51093682289124,0.0 +400,20.906541776657107,0.0 +400,22.302146730422976,0.0 +400,23.697751684188844,0.0 +400,25.093356637954713,0.0 +400,26.48896159172058,0.0 +400,27.88456654548645,0.0 +400,29.28017149925232,0.0 +400,30.67577645301819,0.0 +400,32.07138140678406,0.0 +400,33.46698636054993,0.0 +400,34.8625913143158,0.0 +400,36.25819626808167,0.0 +400,37.65380122184754,0.0 +400,39.049406175613406,0.0 +400,40.445011129379274,0.0 +400,41.84061608314514,0.0 +400,43.23622103691101,0.0 +400,44.63182599067688,0.0 +400,46.02743094444275,0.0 +400,47.423035898208624,0.0 +400,48.81864085197449,0.0 +400,50.21424580574036,0.0 +400,51.60985075950623,0.0 +400,53.0054557132721,0.0 +400,54.40106066703797,0.0 +400,55.796665620803836,0.0 +400,57.192270574569704,0.0 +400,58.58787552833557,0.0 +400,59.98348048210144,0.0 +400,61.37908543586731,0.0 +400,62.77469038963318,0.0 +400,64.17029534339906,0.0 +400,65.56590029716492,0.0 +400,66.9615052509308,0.0 +400,68.35711020469665,0.0 +400,69.75271515846254,0.0 +400,71.14832011222839,0.0 +400,72.54392506599427,0.0 +400,73.93953001976013,0.0 +400,75.33513497352601,0.0 +400,76.73073992729186,0.0 +400,78.12634488105775,0.0 +400,79.5219498348236,0.0 +400,80.91755478858948,0.0 +400,82.31315974235534,0.0 +400,83.70876469612122,0.0 +400,85.10436964988709,0.0 +400,86.49997460365296,0.0 +400,87.89557955741884,0.0 +400,89.2911845111847,0.0 +400,90.68678946495058,0.0 +400,92.08239441871643,0.0 +400,93.47799937248232,0.0 +400,94.87360432624817,0.0 +400,96.26920928001405,0.0 +400,97.66481423377991,0.0 +400,99.06041918754579,0.0 +400,100.45602414131164,0.0 +400,101.85162909507753,0.0 +400,103.24723404884338,0.0 +400,104.64283900260926,0.0 +400,106.03844395637512,0.0 +400,107.434048910141,0.0 +400,108.82965386390686,0.0 +400,110.22525881767274,0.0 +400,111.6208637714386,0.0 +400,113.01646872520448,0.0 +400,114.41207367897034,0.0 +400,115.80767863273621,0.0 +400,117.2032835865021,0.0 +400,118.59888854026795,0.0 +400,119.99449349403383,0.0 +400,121.39009844779969,0.0 +400,122.78570340156557,0.0 +400,124.18130835533142,0.0 +400,125.5769133090973,0.0 +400,126.97251826286316,0.0 +400,128.36812321662904,0.0 +400,129.76372817039493,0.0 +400,131.15933312416075,0.0 +400,132.55493807792664,0.0 +400,133.95054303169252,0.0 +400,135.3461479854584,0.0 +400,136.74175293922423,0.0 +400,138.1373578929901,0.0 +400,139.532962846756,0.0 +400,140.92856780052188,0.0 +400,142.32417275428773,1.0 +400,143.71977770805358,0.0 +400,145.11538266181947,0.0 +400,146.51098761558535,1.0 +400,147.9065925693512,1.0 +400,149.30219752311706,7.0 + +500,11.13730710029602,0.0 +500,12.532912054061889,0.0 +500,13.92851700782776,0.0 +500,15.32412196159363,0.0 +500,16.719726915359498,0.0 +500,18.115331869125367,0.0 +500,19.51093682289124,0.0 +500,20.906541776657107,0.0 +500,22.302146730422976,0.0 +500,23.697751684188844,0.0 +500,25.093356637954713,0.0 +500,26.48896159172058,0.0 +500,27.88456654548645,0.0 +500,29.28017149925232,0.0 +500,30.67577645301819,0.0 +500,32.07138140678406,0.0 +500,33.46698636054993,0.0 +500,34.8625913143158,0.0 +500,36.25819626808167,0.0 +500,37.65380122184754,0.0 +500,39.049406175613406,0.0 +500,40.445011129379274,0.0 +500,41.84061608314514,0.0 +500,43.23622103691101,0.0 +500,44.63182599067688,0.0 +500,46.02743094444275,0.0 +500,47.423035898208624,0.0 +500,48.81864085197449,0.0 +500,50.21424580574036,0.0 +500,51.60985075950623,0.0 +500,53.0054557132721,0.0 +500,54.40106066703797,0.0 +500,55.796665620803836,0.0 +500,57.192270574569704,0.0 +500,58.58787552833557,0.0 +500,59.98348048210144,0.0 +500,61.37908543586731,0.0 +500,62.77469038963318,0.0 +500,64.17029534339906,0.0 +500,65.56590029716492,0.0 +500,66.9615052509308,0.0 +500,68.35711020469665,0.0 +500,69.75271515846254,0.0 +500,71.14832011222839,0.0 +500,72.54392506599427,0.0 +500,73.93953001976013,0.0 +500,75.33513497352601,0.0 +500,76.73073992729186,0.0 +500,78.12634488105775,0.0 +500,79.5219498348236,0.0 +500,80.91755478858948,0.0 +500,82.31315974235534,0.0 +500,83.70876469612122,0.0 +500,85.10436964988709,0.0 +500,86.49997460365296,0.0 +500,87.89557955741884,0.0 +500,89.2911845111847,0.0 +500,90.68678946495058,0.0 +500,92.08239441871643,0.0 +500,93.47799937248232,0.0 +500,94.87360432624817,0.0 +500,96.26920928001405,0.0 +500,97.66481423377991,0.0 +500,99.06041918754579,0.0 +500,100.45602414131164,0.0 +500,101.85162909507753,0.0 +500,103.24723404884338,0.0 +500,104.64283900260926,0.0 +500,106.03844395637512,0.0 +500,107.434048910141,0.0 +500,108.82965386390686,0.0 +500,110.22525881767274,0.0 +500,111.6208637714386,0.0 +500,113.01646872520448,0.0 +500,114.41207367897034,0.0 +500,115.80767863273621,0.0 +500,117.2032835865021,0.0 +500,118.59888854026795,0.0 +500,119.99449349403383,0.0 +500,121.39009844779969,0.0 +500,122.78570340156557,0.0 +500,124.18130835533142,0.0 +500,125.5769133090973,0.0 +500,126.97251826286316,0.0 +500,128.36812321662904,0.0 +500,129.76372817039493,0.0 +500,131.15933312416075,0.0 +500,132.55493807792664,0.0 +500,133.95054303169252,0.0 +500,135.3461479854584,0.0 +500,136.74175293922423,0.0 +500,138.1373578929901,0.0 +500,139.532962846756,0.0 +500,140.92856780052188,0.0 +500,142.32417275428773,1.0 +500,143.71977770805358,0.0 +500,145.11538266181947,1.0 +500,146.51098761558535,1.0 +500,147.9065925693512,1.0 +500,149.30219752311706,6.0 + +600,11.13730710029602,0.0 +600,12.532912054061889,0.0 +600,13.92851700782776,0.0 +600,15.32412196159363,0.0 +600,16.719726915359498,0.0 +600,18.115331869125367,0.0 +600,19.51093682289124,0.0 +600,20.906541776657107,0.0 +600,22.302146730422976,0.0 +600,23.697751684188844,0.0 +600,25.093356637954713,0.0 +600,26.48896159172058,0.0 +600,27.88456654548645,0.0 +600,29.28017149925232,0.0 +600,30.67577645301819,0.0 +600,32.07138140678406,0.0 +600,33.46698636054993,0.0 +600,34.8625913143158,0.0 +600,36.25819626808167,0.0 +600,37.65380122184754,0.0 +600,39.049406175613406,0.0 +600,40.445011129379274,0.0 +600,41.84061608314514,0.0 +600,43.23622103691101,0.0 +600,44.63182599067688,0.0 +600,46.02743094444275,0.0 +600,47.423035898208624,0.0 +600,48.81864085197449,0.0 +600,50.21424580574036,0.0 +600,51.60985075950623,0.0 +600,53.0054557132721,0.0 +600,54.40106066703797,0.0 +600,55.796665620803836,0.0 +600,57.192270574569704,0.0 +600,58.58787552833557,0.0 +600,59.98348048210144,0.0 +600,61.37908543586731,0.0 +600,62.77469038963318,0.0 +600,64.17029534339906,0.0 +600,65.56590029716492,0.0 +600,66.9615052509308,0.0 +600,68.35711020469665,0.0 +600,69.75271515846254,0.0 +600,71.14832011222839,0.0 +600,72.54392506599427,0.0 +600,73.93953001976013,0.0 +600,75.33513497352601,0.0 +600,76.73073992729186,0.0 +600,78.12634488105775,0.0 +600,79.5219498348236,0.0 +600,80.91755478858948,0.0 +600,82.31315974235534,0.0 +600,83.70876469612122,0.0 +600,85.10436964988709,0.0 +600,86.49997460365296,0.0 +600,87.89557955741884,0.0 +600,89.2911845111847,0.0 +600,90.68678946495058,0.0 +600,92.08239441871643,0.0 +600,93.47799937248232,0.0 +600,94.87360432624817,0.0 +600,96.26920928001405,0.0 +600,97.66481423377991,0.0 +600,99.06041918754579,0.0 +600,100.45602414131164,0.0 +600,101.85162909507753,0.0 +600,103.24723404884338,0.0 +600,104.64283900260926,0.0 +600,106.03844395637512,0.0 +600,107.434048910141,0.0 +600,108.82965386390686,0.0 +600,110.22525881767274,0.0 +600,111.6208637714386,0.0 +600,113.01646872520448,0.0 +600,114.41207367897034,0.0 +600,115.80767863273621,0.0 +600,117.2032835865021,0.0 +600,118.59888854026795,0.0 +600,119.99449349403383,0.0 +600,121.39009844779969,0.0 +600,122.78570340156557,0.0 +600,124.18130835533142,0.0 +600,125.5769133090973,0.0 +600,126.97251826286316,0.0 +600,128.36812321662904,0.0 +600,129.76372817039493,0.0 +600,131.15933312416075,0.0 +600,132.55493807792664,0.0 +600,133.95054303169252,0.0 +600,135.3461479854584,0.0 +600,136.74175293922423,0.0 +600,138.1373578929901,0.0 +600,139.532962846756,0.0 +600,140.92856780052188,0.0 +600,142.32417275428773,0.005061397985043043 +600,143.71977770805358,0.994938602014957 +600,145.11538266181947,0.0 +600,146.51098761558535,0.0 +600,147.9065925693512,3.201040267282531 +600,149.30219752311706,5.798959732717469 + +700,11.13730710029602,0.0 +700,12.532912054061889,0.0 +700,13.92851700782776,0.0 +700,15.32412196159363,0.0 +700,16.719726915359498,0.0 +700,18.115331869125367,0.0 +700,19.51093682289124,0.0 +700,20.906541776657107,0.0 +700,22.302146730422976,0.0 +700,23.697751684188844,0.0 +700,25.093356637954713,0.0 +700,26.48896159172058,0.0 +700,27.88456654548645,0.0 +700,29.28017149925232,0.0 +700,30.67577645301819,0.0 +700,32.07138140678406,0.0 +700,33.46698636054993,0.0 +700,34.8625913143158,0.0 +700,36.25819626808167,0.0 +700,37.65380122184754,0.0 +700,39.049406175613406,0.0 +700,40.445011129379274,0.0 +700,41.84061608314514,0.0 +700,43.23622103691101,0.0 +700,44.63182599067688,0.0 +700,46.02743094444275,0.0 +700,47.423035898208624,0.0 +700,48.81864085197449,0.0 +700,50.21424580574036,0.0 +700,51.60985075950623,0.0 +700,53.0054557132721,0.0 +700,54.40106066703797,0.0 +700,55.796665620803836,0.0 +700,57.192270574569704,0.0 +700,58.58787552833557,0.0 +700,59.98348048210144,0.0 +700,61.37908543586731,0.0 +700,62.77469038963318,0.0 +700,64.17029534339906,0.0 +700,65.56590029716492,0.0 +700,66.9615052509308,0.0 +700,68.35711020469665,0.0 +700,69.75271515846254,0.0 +700,71.14832011222839,0.0 +700,72.54392506599427,0.0 +700,73.93953001976013,0.0 +700,75.33513497352601,0.0 +700,76.73073992729186,0.0 +700,78.12634488105775,0.0 +700,79.5219498348236,0.0 +700,80.91755478858948,0.0 +700,82.31315974235534,0.0 +700,83.70876469612122,0.0 +700,85.10436964988709,0.0 +700,86.49997460365296,0.0 +700,87.89557955741884,0.0 +700,89.2911845111847,0.0 +700,90.68678946495058,0.0 +700,92.08239441871643,0.0 +700,93.47799937248232,0.0 +700,94.87360432624817,0.0 +700,96.26920928001405,0.0 +700,97.66481423377991,0.0 +700,99.06041918754579,0.0 +700,100.45602414131164,0.0 +700,101.85162909507753,0.0 +700,103.24723404884338,0.0 +700,104.64283900260926,0.0 +700,106.03844395637512,0.0 +700,107.434048910141,0.0 +700,108.82965386390686,0.0 +700,110.22525881767274,0.0 +700,111.6208637714386,0.0 +700,113.01646872520448,0.0 +700,114.41207367897034,0.0 +700,115.80767863273621,0.0 +700,117.2032835865021,0.0 +700,118.59888854026795,0.0 +700,119.99449349403383,0.0 +700,121.39009844779969,0.0 +700,122.78570340156557,0.0 +700,124.18130835533142,0.0 +700,125.5769133090973,0.0 +700,126.97251826286316,0.0 +700,128.36812321662904,0.0 +700,129.76372817039493,0.0 +700,131.15933312416075,0.0 +700,132.55493807792664,0.0 +700,133.95054303169252,0.0 +700,135.3461479854584,0.0 +700,136.74175293922423,0.0 +700,138.1373578929901,0.0 +700,139.532962846756,0.0 +700,140.92856780052188,0.0 +700,142.32417275428773,0.0 +700,143.71977770805358,0.0 +700,145.11538266181947,0.0 +700,146.51098761558535,0.0 +700,147.9065925693512,1.0 +700,149.30219752311706,9.0 + +800,11.13730710029602,0.0 +800,12.532912054061889,0.0 +800,13.92851700782776,0.0 +800,15.32412196159363,0.0 +800,16.719726915359498,0.0 +800,18.115331869125367,0.0 +800,19.51093682289124,0.0 +800,20.906541776657107,0.0 +800,22.302146730422976,0.0 +800,23.697751684188844,0.0 +800,25.093356637954713,0.0 +800,26.48896159172058,0.0 +800,27.88456654548645,0.0 +800,29.28017149925232,0.0 +800,30.67577645301819,0.0 +800,32.07138140678406,0.0 +800,33.46698636054993,0.0 +800,34.8625913143158,0.0 +800,36.25819626808167,0.0 +800,37.65380122184754,0.0 +800,39.049406175613406,0.0 +800,40.445011129379274,0.0 +800,41.84061608314514,0.0 +800,43.23622103691101,0.0 +800,44.63182599067688,0.0 +800,46.02743094444275,0.0 +800,47.423035898208624,0.0 +800,48.81864085197449,0.0 +800,50.21424580574036,0.0 +800,51.60985075950623,0.0 +800,53.0054557132721,0.0 +800,54.40106066703797,0.0 +800,55.796665620803836,0.0 +800,57.192270574569704,0.0 +800,58.58787552833557,0.0 +800,59.98348048210144,0.0 +800,61.37908543586731,0.0 +800,62.77469038963318,0.0 +800,64.17029534339906,0.0 +800,65.56590029716492,0.0 +800,66.9615052509308,0.0 +800,68.35711020469665,0.0 +800,69.75271515846254,0.0 +800,71.14832011222839,0.0 +800,72.54392506599427,0.0 +800,73.93953001976013,0.0 +800,75.33513497352601,0.0 +800,76.73073992729186,0.0 +800,78.12634488105775,0.0 +800,79.5219498348236,0.0 +800,80.91755478858948,0.0 +800,82.31315974235534,0.0 +800,83.70876469612122,0.0 +800,85.10436964988709,0.0 +800,86.49997460365296,0.0 +800,87.89557955741884,0.0 +800,89.2911845111847,0.0 +800,90.68678946495058,0.0 +800,92.08239441871643,0.0 +800,93.47799937248232,0.0 +800,94.87360432624817,0.0 +800,96.26920928001405,0.0 +800,97.66481423377991,0.0 +800,99.06041918754579,0.0 +800,100.45602414131164,0.0 +800,101.85162909507753,0.0 +800,103.24723404884338,0.0 +800,104.64283900260926,0.0 +800,106.03844395637512,0.0 +800,107.434048910141,0.0 +800,108.82965386390686,0.0 +800,110.22525881767274,0.0 +800,111.6208637714386,0.0 +800,113.01646872520448,0.0 +800,114.41207367897034,0.0 +800,115.80767863273621,0.0 +800,117.2032835865021,0.0 +800,118.59888854026795,0.0 +800,119.99449349403383,0.0 +800,121.39009844779969,0.0 +800,122.78570340156557,0.0 +800,124.18130835533142,0.0 +800,125.5769133090973,0.0 +800,126.97251826286316,0.0 +800,128.36812321662904,0.0 +800,129.76372817039493,0.0 +800,131.15933312416075,0.0 +800,132.55493807792664,0.0 +800,133.95054303169252,0.0 +800,135.3461479854584,0.0 +800,136.74175293922423,0.0 +800,138.1373578929901,0.0 +800,139.532962846756,0.0 +800,140.92856780052188,0.0 +800,142.32417275428773,0.0 +800,143.71977770805358,0.0 +800,145.11538266181947,0.0 +800,146.51098761558535,0.0 +800,147.9065925693512,0.0 +800,149.30219752311706,10.0 + +900,11.13730710029602,0.0 +900,12.532912054061889,0.0 +900,13.92851700782776,0.0 +900,15.32412196159363,0.0 +900,16.719726915359498,0.0 +900,18.115331869125367,0.0 +900,19.51093682289124,0.0 +900,20.906541776657107,0.0 +900,22.302146730422976,0.0 +900,23.697751684188844,0.0 +900,25.093356637954713,0.0 +900,26.48896159172058,0.0 +900,27.88456654548645,0.0 +900,29.28017149925232,0.0 +900,30.67577645301819,0.0 +900,32.07138140678406,0.0 +900,33.46698636054993,0.0 +900,34.8625913143158,0.0 +900,36.25819626808167,0.0 +900,37.65380122184754,0.0 +900,39.049406175613406,0.0 +900,40.445011129379274,0.0 +900,41.84061608314514,0.0 +900,43.23622103691101,0.0 +900,44.63182599067688,0.0 +900,46.02743094444275,0.0 +900,47.423035898208624,0.0 +900,48.81864085197449,0.0 +900,50.21424580574036,0.0 +900,51.60985075950623,0.0 +900,53.0054557132721,0.0 +900,54.40106066703797,0.0 +900,55.796665620803836,0.0 +900,57.192270574569704,0.0 +900,58.58787552833557,0.0 +900,59.98348048210144,0.0 +900,61.37908543586731,0.0 +900,62.77469038963318,0.0 +900,64.17029534339906,0.0 +900,65.56590029716492,0.0 +900,66.9615052509308,0.0 +900,68.35711020469665,0.0 +900,69.75271515846254,0.0 +900,71.14832011222839,0.0 +900,72.54392506599427,0.0 +900,73.93953001976013,0.0 +900,75.33513497352601,0.0 +900,76.73073992729186,0.0 +900,78.12634488105775,0.0 +900,79.5219498348236,0.0 +900,80.91755478858948,0.0 +900,82.31315974235534,0.0 +900,83.70876469612122,0.0 +900,85.10436964988709,0.0 +900,86.49997460365296,0.0 +900,87.89557955741884,0.0 +900,89.2911845111847,0.0 +900,90.68678946495058,0.0 +900,92.08239441871643,0.0 +900,93.47799937248232,0.0 +900,94.87360432624817,0.0 +900,96.26920928001405,0.0 +900,97.66481423377991,0.0 +900,99.06041918754579,0.0 +900,100.45602414131164,0.0 +900,101.85162909507753,0.0 +900,103.24723404884338,0.0 +900,104.64283900260926,0.0 +900,106.03844395637512,0.0 +900,107.434048910141,0.0 +900,108.82965386390686,0.0 +900,110.22525881767274,0.0 +900,111.6208637714386,0.0 +900,113.01646872520448,0.0 +900,114.41207367897034,0.0 +900,115.80767863273621,0.0 +900,117.2032835865021,0.0 +900,118.59888854026795,0.0 +900,119.99449349403383,0.0 +900,121.39009844779969,0.0 +900,122.78570340156557,0.0 +900,124.18130835533142,0.0 +900,125.5769133090973,0.0 +900,126.97251826286316,0.0 +900,128.36812321662904,0.0 +900,129.76372817039493,0.0 +900,131.15933312416075,0.0 +900,132.55493807792664,0.0 +900,133.95054303169252,0.0 +900,135.3461479854584,0.0 +900,136.74175293922423,0.0 +900,138.1373578929901,0.0 +900,139.532962846756,0.0 +900,140.92856780052188,0.0 +900,142.32417275428773,0.0 +900,143.71977770805358,0.0 +900,145.11538266181947,0.0 +900,146.51098761558535,0.0 +900,147.9065925693512,0.0 +900,149.30219752311706,10.0 + +1000,11.13730710029602,0.0 +1000,12.532912054061889,0.0 +1000,13.92851700782776,0.0 +1000,15.32412196159363,0.0 +1000,16.719726915359498,0.0 +1000,18.115331869125367,0.0 +1000,19.51093682289124,0.0 +1000,20.906541776657107,0.0 +1000,22.302146730422976,0.0 +1000,23.697751684188844,0.0 +1000,25.093356637954713,0.0 +1000,26.48896159172058,0.0 +1000,27.88456654548645,0.0 +1000,29.28017149925232,0.0 +1000,30.67577645301819,0.0 +1000,32.07138140678406,0.0 +1000,33.46698636054993,0.0 +1000,34.8625913143158,0.0 +1000,36.25819626808167,0.0 +1000,37.65380122184754,0.0 +1000,39.049406175613406,0.0 +1000,40.445011129379274,0.0 +1000,41.84061608314514,0.0 +1000,43.23622103691101,0.0 +1000,44.63182599067688,0.0 +1000,46.02743094444275,0.0 +1000,47.423035898208624,0.0 +1000,48.81864085197449,0.0 +1000,50.21424580574036,0.0 +1000,51.60985075950623,0.0 +1000,53.0054557132721,0.0 +1000,54.40106066703797,0.0 +1000,55.796665620803836,0.0 +1000,57.192270574569704,0.0 +1000,58.58787552833557,0.0 +1000,59.98348048210144,0.0 +1000,61.37908543586731,0.0 +1000,62.77469038963318,0.0 +1000,64.17029534339906,0.0 +1000,65.56590029716492,0.0 +1000,66.9615052509308,0.0 +1000,68.35711020469665,0.0 +1000,69.75271515846254,0.0 +1000,71.14832011222839,0.0 +1000,72.54392506599427,0.0 +1000,73.93953001976013,0.0 +1000,75.33513497352601,0.0 +1000,76.73073992729186,0.0 +1000,78.12634488105775,0.0 +1000,79.5219498348236,0.0 +1000,80.91755478858948,0.0 +1000,82.31315974235534,0.0 +1000,83.70876469612122,0.0 +1000,85.10436964988709,0.0 +1000,86.49997460365296,0.0 +1000,87.89557955741884,0.0 +1000,89.2911845111847,0.0 +1000,90.68678946495058,0.0 +1000,92.08239441871643,0.0 +1000,93.47799937248232,0.0 +1000,94.87360432624817,0.0 +1000,96.26920928001405,0.0 +1000,97.66481423377991,0.0 +1000,99.06041918754579,0.0 +1000,100.45602414131164,0.0 +1000,101.85162909507753,0.0 +1000,103.24723404884338,0.0 +1000,104.64283900260926,0.0 +1000,106.03844395637512,0.0 +1000,107.434048910141,0.0 +1000,108.82965386390686,0.0 +1000,110.22525881767274,0.0 +1000,111.6208637714386,0.0 +1000,113.01646872520448,0.0 +1000,114.41207367897034,0.0 +1000,115.80767863273621,0.0 +1000,117.2032835865021,0.0 +1000,118.59888854026795,0.0 +1000,119.99449349403383,0.0 +1000,121.39009844779969,0.0 +1000,122.78570340156557,0.0 +1000,124.18130835533142,0.0 +1000,125.5769133090973,0.0 +1000,126.97251826286316,0.0 +1000,128.36812321662904,0.0 +1000,129.76372817039493,0.0 +1000,131.15933312416075,0.0 +1000,132.55493807792664,0.0 +1000,133.95054303169252,0.0 +1000,135.3461479854584,0.0 +1000,136.74175293922423,0.0 +1000,138.1373578929901,0.0 +1000,139.532962846756,0.0 +1000,140.92856780052188,0.0 +1000,142.32417275428773,0.0 +1000,143.71977770805358,0.0 +1000,145.11538266181947,0.0 +1000,146.51098761558535,0.0 +1000,147.9065925693512,0.0 +1000,149.30219752311706,10.0 + +1100,11.13730710029602,0.0 +1100,12.532912054061889,0.0 +1100,13.92851700782776,0.0 +1100,15.32412196159363,0.0 +1100,16.719726915359498,0.0 +1100,18.115331869125367,0.0 +1100,19.51093682289124,0.0 +1100,20.906541776657107,0.0 +1100,22.302146730422976,0.0 +1100,23.697751684188844,0.0 +1100,25.093356637954713,0.0 +1100,26.48896159172058,0.0 +1100,27.88456654548645,0.0 +1100,29.28017149925232,0.0 +1100,30.67577645301819,0.0 +1100,32.07138140678406,0.0 +1100,33.46698636054993,0.0 +1100,34.8625913143158,0.0 +1100,36.25819626808167,0.0 +1100,37.65380122184754,0.0 +1100,39.049406175613406,0.0 +1100,40.445011129379274,0.0 +1100,41.84061608314514,0.0 +1100,43.23622103691101,0.0 +1100,44.63182599067688,0.0 +1100,46.02743094444275,0.0 +1100,47.423035898208624,0.0 +1100,48.81864085197449,0.0 +1100,50.21424580574036,0.0 +1100,51.60985075950623,0.0 +1100,53.0054557132721,0.0 +1100,54.40106066703797,0.0 +1100,55.796665620803836,0.0 +1100,57.192270574569704,0.0 +1100,58.58787552833557,0.0 +1100,59.98348048210144,0.0 +1100,61.37908543586731,0.0 +1100,62.77469038963318,0.0 +1100,64.17029534339906,0.0 +1100,65.56590029716492,0.0 +1100,66.9615052509308,0.0 +1100,68.35711020469665,0.0 +1100,69.75271515846254,0.0 +1100,71.14832011222839,0.0 +1100,72.54392506599427,0.0 +1100,73.93953001976013,0.0 +1100,75.33513497352601,0.0 +1100,76.73073992729186,0.0 +1100,78.12634488105775,0.0 +1100,79.5219498348236,0.0 +1100,80.91755478858948,0.0 +1100,82.31315974235534,0.0 +1100,83.70876469612122,0.0 +1100,85.10436964988709,0.0 +1100,86.49997460365296,0.0 +1100,87.89557955741884,0.0 +1100,89.2911845111847,0.0 +1100,90.68678946495058,0.0 +1100,92.08239441871643,0.0 +1100,93.47799937248232,0.0 +1100,94.87360432624817,0.0 +1100,96.26920928001405,0.0 +1100,97.66481423377991,0.0 +1100,99.06041918754579,0.0 +1100,100.45602414131164,0.0 +1100,101.85162909507753,0.0 +1100,103.24723404884338,0.0 +1100,104.64283900260926,0.0 +1100,106.03844395637512,0.0 +1100,107.434048910141,0.0 +1100,108.82965386390686,0.0 +1100,110.22525881767274,0.0 +1100,111.6208637714386,0.0 +1100,113.01646872520448,0.0 +1100,114.41207367897034,0.0 +1100,115.80767863273621,0.0 +1100,117.2032835865021,0.0 +1100,118.59888854026795,0.0 +1100,119.99449349403383,0.0 +1100,121.39009844779969,0.0 +1100,122.78570340156557,0.0 +1100,124.18130835533142,0.0 +1100,125.5769133090973,0.0 +1100,126.97251826286316,0.0 +1100,128.36812321662904,0.0 +1100,129.76372817039493,0.0 +1100,131.15933312416075,0.0 +1100,132.55493807792664,0.0 +1100,133.95054303169252,0.0 +1100,135.3461479854584,0.0 +1100,136.74175293922423,0.0 +1100,138.1373578929901,0.0 +1100,139.532962846756,0.0 +1100,140.92856780052188,0.0 +1100,142.32417275428773,0.0 +1100,143.71977770805358,0.0 +1100,145.11538266181947,0.0 +1100,146.51098761558535,0.0 +1100,147.9065925693512,0.0 +1100,149.30219752311706,10.0 + +1200,11.13730710029602,0.0 +1200,12.532912054061889,0.0 +1200,13.92851700782776,0.0 +1200,15.32412196159363,0.0 +1200,16.719726915359498,0.0 +1200,18.115331869125367,0.0 +1200,19.51093682289124,0.0 +1200,20.906541776657107,0.0 +1200,22.302146730422976,0.0 +1200,23.697751684188844,0.0 +1200,25.093356637954713,0.0 +1200,26.48896159172058,0.0 +1200,27.88456654548645,0.0 +1200,29.28017149925232,0.0 +1200,30.67577645301819,0.0 +1200,32.07138140678406,0.0 +1200,33.46698636054993,0.0 +1200,34.8625913143158,0.0 +1200,36.25819626808167,0.0 +1200,37.65380122184754,0.0 +1200,39.049406175613406,0.0 +1200,40.445011129379274,0.0 +1200,41.84061608314514,0.0 +1200,43.23622103691101,0.0 +1200,44.63182599067688,0.0 +1200,46.02743094444275,0.0 +1200,47.423035898208624,0.0 +1200,48.81864085197449,0.0 +1200,50.21424580574036,0.0 +1200,51.60985075950623,0.0 +1200,53.0054557132721,0.0 +1200,54.40106066703797,0.0 +1200,55.796665620803836,0.0 +1200,57.192270574569704,0.0 +1200,58.58787552833557,0.0 +1200,59.98348048210144,0.0 +1200,61.37908543586731,0.0 +1200,62.77469038963318,0.0 +1200,64.17029534339906,0.0 +1200,65.56590029716492,0.0 +1200,66.9615052509308,0.0 +1200,68.35711020469665,0.0 +1200,69.75271515846254,0.0 +1200,71.14832011222839,0.0 +1200,72.54392506599427,0.0 +1200,73.93953001976013,0.0 +1200,75.33513497352601,0.0 +1200,76.73073992729186,0.0 +1200,78.12634488105775,0.0 +1200,79.5219498348236,0.0 +1200,80.91755478858948,0.0 +1200,82.31315974235534,0.0 +1200,83.70876469612122,0.0 +1200,85.10436964988709,0.0 +1200,86.49997460365296,0.0 +1200,87.89557955741884,0.0 +1200,89.2911845111847,0.0 +1200,90.68678946495058,0.0 +1200,92.08239441871643,0.0 +1200,93.47799937248232,0.0 +1200,94.87360432624817,0.0 +1200,96.26920928001405,0.0 +1200,97.66481423377991,0.0 +1200,99.06041918754579,0.0 +1200,100.45602414131164,0.0 +1200,101.85162909507753,0.0 +1200,103.24723404884338,0.0 +1200,104.64283900260926,0.0 +1200,106.03844395637512,0.0 +1200,107.434048910141,0.0 +1200,108.82965386390686,0.0 +1200,110.22525881767274,0.0 +1200,111.6208637714386,0.0 +1200,113.01646872520448,0.0 +1200,114.41207367897034,0.0 +1200,115.80767863273621,0.0 +1200,117.2032835865021,0.0 +1200,118.59888854026795,0.0 +1200,119.99449349403383,0.0 +1200,121.39009844779969,0.0 +1200,122.78570340156557,0.0 +1200,124.18130835533142,0.0 +1200,125.5769133090973,0.0 +1200,126.97251826286316,0.0 +1200,128.36812321662904,0.0 +1200,129.76372817039493,0.0 +1200,131.15933312416075,0.0 +1200,132.55493807792664,0.0 +1200,133.95054303169252,0.0 +1200,135.3461479854584,0.0 +1200,136.74175293922423,0.0 +1200,138.1373578929901,0.0 +1200,139.532962846756,0.0 +1200,140.92856780052188,0.0 +1200,142.32417275428773,0.0 +1200,143.71977770805358,0.0 +1200,145.11538266181947,0.0 +1200,146.51098761558535,0.0 +1200,147.9065925693512,0.0 +1200,149.30219752311706,10.0 + +1300,11.13730710029602,0.0 +1300,12.532912054061889,0.0 +1300,13.92851700782776,0.0 +1300,15.32412196159363,0.0 +1300,16.719726915359498,0.0 +1300,18.115331869125367,0.0 +1300,19.51093682289124,0.0 +1300,20.906541776657107,0.0 +1300,22.302146730422976,0.0 +1300,23.697751684188844,0.0 +1300,25.093356637954713,0.0 +1300,26.48896159172058,0.0 +1300,27.88456654548645,0.0 +1300,29.28017149925232,0.0 +1300,30.67577645301819,0.0 +1300,32.07138140678406,0.0 +1300,33.46698636054993,0.0 +1300,34.8625913143158,0.0 +1300,36.25819626808167,0.0 +1300,37.65380122184754,0.0 +1300,39.049406175613406,0.0 +1300,40.445011129379274,0.0 +1300,41.84061608314514,0.0 +1300,43.23622103691101,0.0 +1300,44.63182599067688,0.0 +1300,46.02743094444275,0.0 +1300,47.423035898208624,0.0 +1300,48.81864085197449,0.0 +1300,50.21424580574036,0.0 +1300,51.60985075950623,0.0 +1300,53.0054557132721,0.0 +1300,54.40106066703797,0.0 +1300,55.796665620803836,0.0 +1300,57.192270574569704,0.0 +1300,58.58787552833557,0.0 +1300,59.98348048210144,0.0 +1300,61.37908543586731,0.0 +1300,62.77469038963318,0.0 +1300,64.17029534339906,0.0 +1300,65.56590029716492,0.0 +1300,66.9615052509308,0.0 +1300,68.35711020469665,0.0 +1300,69.75271515846254,0.0 +1300,71.14832011222839,0.0 +1300,72.54392506599427,0.0 +1300,73.93953001976013,0.0 +1300,75.33513497352601,0.0 +1300,76.73073992729186,0.0 +1300,78.12634488105775,0.0 +1300,79.5219498348236,0.0 +1300,80.91755478858948,0.0 +1300,82.31315974235534,0.0 +1300,83.70876469612122,0.0 +1300,85.10436964988709,0.0 +1300,86.49997460365296,0.0 +1300,87.89557955741884,0.0 +1300,89.2911845111847,0.0 +1300,90.68678946495058,0.0 +1300,92.08239441871643,0.0 +1300,93.47799937248232,0.0 +1300,94.87360432624817,0.0 +1300,96.26920928001405,0.0 +1300,97.66481423377991,0.0 +1300,99.06041918754579,0.0 +1300,100.45602414131164,0.0 +1300,101.85162909507753,0.0 +1300,103.24723404884338,0.0 +1300,104.64283900260926,0.0 +1300,106.03844395637512,0.0 +1300,107.434048910141,0.0 +1300,108.82965386390686,0.0 +1300,110.22525881767274,0.0 +1300,111.6208637714386,0.0 +1300,113.01646872520448,0.0 +1300,114.41207367897034,0.0 +1300,115.80767863273621,0.0 +1300,117.2032835865021,0.0 +1300,118.59888854026795,0.0 +1300,119.99449349403383,0.0 +1300,121.39009844779969,0.0 +1300,122.78570340156557,0.0 +1300,124.18130835533142,0.0 +1300,125.5769133090973,0.0 +1300,126.97251826286316,0.0 +1300,128.36812321662904,0.0 +1300,129.76372817039493,0.0 +1300,131.15933312416075,0.0 +1300,132.55493807792664,0.0 +1300,133.95054303169252,0.0 +1300,135.3461479854584,0.0 +1300,136.74175293922423,0.0 +1300,138.1373578929901,0.0 +1300,139.532962846756,0.0 +1300,140.92856780052188,0.0 +1300,142.32417275428773,0.0 +1300,143.71977770805358,0.0 +1300,145.11538266181947,0.0 +1300,146.51098761558535,0.0 +1300,147.9065925693512,0.0 +1300,149.30219752311706,10.0 + +1400,11.13730710029602,0.0 +1400,12.532912054061889,0.0 +1400,13.92851700782776,0.0 +1400,15.32412196159363,0.0 +1400,16.719726915359498,0.0 +1400,18.115331869125367,0.0 +1400,19.51093682289124,0.0 +1400,20.906541776657107,0.0 +1400,22.302146730422976,0.0 +1400,23.697751684188844,0.0 +1400,25.093356637954713,0.0 +1400,26.48896159172058,0.0 +1400,27.88456654548645,0.0 +1400,29.28017149925232,0.0 +1400,30.67577645301819,0.0 +1400,32.07138140678406,0.0 +1400,33.46698636054993,0.0 +1400,34.8625913143158,0.0 +1400,36.25819626808167,0.0 +1400,37.65380122184754,0.0 +1400,39.049406175613406,0.0 +1400,40.445011129379274,0.0 +1400,41.84061608314514,0.0 +1400,43.23622103691101,0.0 +1400,44.63182599067688,0.0 +1400,46.02743094444275,0.0 +1400,47.423035898208624,0.0 +1400,48.81864085197449,0.0 +1400,50.21424580574036,0.0 +1400,51.60985075950623,0.0 +1400,53.0054557132721,0.0 +1400,54.40106066703797,0.0 +1400,55.796665620803836,0.0 +1400,57.192270574569704,0.0 +1400,58.58787552833557,0.0 +1400,59.98348048210144,0.0 +1400,61.37908543586731,0.0 +1400,62.77469038963318,0.0 +1400,64.17029534339906,0.0 +1400,65.56590029716492,0.0 +1400,66.9615052509308,0.0 +1400,68.35711020469665,0.0 +1400,69.75271515846254,0.0 +1400,71.14832011222839,0.0 +1400,72.54392506599427,0.0 +1400,73.93953001976013,0.0 +1400,75.33513497352601,0.0 +1400,76.73073992729186,0.0 +1400,78.12634488105775,0.0 +1400,79.5219498348236,0.0 +1400,80.91755478858948,0.0 +1400,82.31315974235534,0.0 +1400,83.70876469612122,0.0 +1400,85.10436964988709,0.0 +1400,86.49997460365296,0.0 +1400,87.89557955741884,0.0 +1400,89.2911845111847,0.0 +1400,90.68678946495058,0.0 +1400,92.08239441871643,0.0 +1400,93.47799937248232,0.0 +1400,94.87360432624817,0.0 +1400,96.26920928001405,0.0 +1400,97.66481423377991,0.0 +1400,99.06041918754579,0.0 +1400,100.45602414131164,0.0 +1400,101.85162909507753,0.0 +1400,103.24723404884338,0.0 +1400,104.64283900260926,0.0 +1400,106.03844395637512,0.0 +1400,107.434048910141,0.0 +1400,108.82965386390686,0.0 +1400,110.22525881767274,0.0 +1400,111.6208637714386,0.0 +1400,113.01646872520448,0.0 +1400,114.41207367897034,0.0 +1400,115.80767863273621,0.0 +1400,117.2032835865021,0.0 +1400,118.59888854026795,0.0 +1400,119.99449349403383,0.0 +1400,121.39009844779969,0.0 +1400,122.78570340156557,0.0 +1400,124.18130835533142,0.0 +1400,125.5769133090973,0.0 +1400,126.97251826286316,0.0 +1400,128.36812321662904,0.0 +1400,129.76372817039493,0.0 +1400,131.15933312416075,0.0 +1400,132.55493807792664,0.0 +1400,133.95054303169252,0.0 +1400,135.3461479854584,0.0 +1400,136.74175293922423,0.0 +1400,138.1373578929901,0.0 +1400,139.532962846756,0.0 +1400,140.92856780052188,0.0 +1400,142.32417275428773,0.0 +1400,143.71977770805358,0.0 +1400,145.11538266181947,0.0 +1400,146.51098761558535,0.0 +1400,147.9065925693512,0.0 +1400,149.30219752311706,10.0 + +1500,11.13730710029602,0.0 +1500,12.532912054061889,0.0 +1500,13.92851700782776,0.0 +1500,15.32412196159363,0.0 +1500,16.719726915359498,0.0 +1500,18.115331869125367,0.0 +1500,19.51093682289124,0.0 +1500,20.906541776657107,0.0 +1500,22.302146730422976,0.0 +1500,23.697751684188844,0.0 +1500,25.093356637954713,0.0 +1500,26.48896159172058,0.0 +1500,27.88456654548645,0.0 +1500,29.28017149925232,0.0 +1500,30.67577645301819,0.0 +1500,32.07138140678406,0.0 +1500,33.46698636054993,0.0 +1500,34.8625913143158,0.0 +1500,36.25819626808167,0.0 +1500,37.65380122184754,0.0 +1500,39.049406175613406,0.0 +1500,40.445011129379274,0.0 +1500,41.84061608314514,0.0 +1500,43.23622103691101,0.0 +1500,44.63182599067688,0.0 +1500,46.02743094444275,0.0 +1500,47.423035898208624,0.0 +1500,48.81864085197449,0.0 +1500,50.21424580574036,0.0 +1500,51.60985075950623,0.0 +1500,53.0054557132721,0.0 +1500,54.40106066703797,0.0 +1500,55.796665620803836,0.0 +1500,57.192270574569704,0.0 +1500,58.58787552833557,0.0 +1500,59.98348048210144,0.0 +1500,61.37908543586731,0.0 +1500,62.77469038963318,0.0 +1500,64.17029534339906,0.0 +1500,65.56590029716492,0.0 +1500,66.9615052509308,0.0 +1500,68.35711020469665,0.0 +1500,69.75271515846254,0.0 +1500,71.14832011222839,0.0 +1500,72.54392506599427,0.0 +1500,73.93953001976013,0.0 +1500,75.33513497352601,0.0 +1500,76.73073992729186,0.0 +1500,78.12634488105775,0.0 +1500,79.5219498348236,0.0 +1500,80.91755478858948,0.0 +1500,82.31315974235534,0.0 +1500,83.70876469612122,0.0 +1500,85.10436964988709,0.0 +1500,86.49997460365296,0.0 +1500,87.89557955741884,0.0 +1500,89.2911845111847,0.0 +1500,90.68678946495058,0.0 +1500,92.08239441871643,0.0 +1500,93.47799937248232,0.0 +1500,94.87360432624817,0.0 +1500,96.26920928001405,0.0 +1500,97.66481423377991,0.0 +1500,99.06041918754579,0.0 +1500,100.45602414131164,0.0 +1500,101.85162909507753,0.0 +1500,103.24723404884338,0.0 +1500,104.64283900260926,0.0 +1500,106.03844395637512,0.0 +1500,107.434048910141,0.0 +1500,108.82965386390686,0.0 +1500,110.22525881767274,0.0 +1500,111.6208637714386,0.0 +1500,113.01646872520448,0.0 +1500,114.41207367897034,0.0 +1500,115.80767863273621,0.0 +1500,117.2032835865021,0.0 +1500,118.59888854026795,0.0 +1500,119.99449349403383,0.0 +1500,121.39009844779969,0.0 +1500,122.78570340156557,0.0 +1500,124.18130835533142,0.0 +1500,125.5769133090973,0.0 +1500,126.97251826286316,0.0 +1500,128.36812321662904,0.0 +1500,129.76372817039493,0.0 +1500,131.15933312416075,0.0 +1500,132.55493807792664,0.0 +1500,133.95054303169252,0.0 +1500,135.3461479854584,0.0 +1500,136.74175293922423,0.0 +1500,138.1373578929901,0.0 +1500,139.532962846756,0.0 +1500,140.92856780052188,0.0 +1500,142.32417275428773,0.0 +1500,143.71977770805358,0.0 +1500,145.11538266181947,0.0 +1500,146.51098761558535,0.0 +1500,147.9065925693512,0.0 +1500,149.30219752311706,10.0 + +1600,11.13730710029602,0.0 +1600,12.532912054061889,0.0 +1600,13.92851700782776,0.0 +1600,15.32412196159363,0.0 +1600,16.719726915359498,0.0 +1600,18.115331869125367,0.0 +1600,19.51093682289124,0.0 +1600,20.906541776657107,0.0 +1600,22.302146730422976,0.0 +1600,23.697751684188844,0.0 +1600,25.093356637954713,0.0 +1600,26.48896159172058,0.0 +1600,27.88456654548645,0.0 +1600,29.28017149925232,0.0 +1600,30.67577645301819,0.0 +1600,32.07138140678406,0.0 +1600,33.46698636054993,0.0 +1600,34.8625913143158,0.0 +1600,36.25819626808167,0.0 +1600,37.65380122184754,0.0 +1600,39.049406175613406,0.0 +1600,40.445011129379274,0.0 +1600,41.84061608314514,0.0 +1600,43.23622103691101,0.0 +1600,44.63182599067688,0.0 +1600,46.02743094444275,0.0 +1600,47.423035898208624,0.0 +1600,48.81864085197449,0.0 +1600,50.21424580574036,0.0 +1600,51.60985075950623,0.0 +1600,53.0054557132721,0.0 +1600,54.40106066703797,0.0 +1600,55.796665620803836,0.0 +1600,57.192270574569704,0.0 +1600,58.58787552833557,0.0 +1600,59.98348048210144,0.0 +1600,61.37908543586731,0.0 +1600,62.77469038963318,0.0 +1600,64.17029534339906,0.0 +1600,65.56590029716492,0.0 +1600,66.9615052509308,0.0 +1600,68.35711020469665,0.0 +1600,69.75271515846254,0.0 +1600,71.14832011222839,0.0 +1600,72.54392506599427,0.0 +1600,73.93953001976013,0.0 +1600,75.33513497352601,0.0 +1600,76.73073992729186,0.0 +1600,78.12634488105775,0.0 +1600,79.5219498348236,0.0 +1600,80.91755478858948,0.0 +1600,82.31315974235534,0.0 +1600,83.70876469612122,0.0 +1600,85.10436964988709,0.0 +1600,86.49997460365296,0.0 +1600,87.89557955741884,0.0 +1600,89.2911845111847,0.0 +1600,90.68678946495058,0.0 +1600,92.08239441871643,0.0 +1600,93.47799937248232,0.0 +1600,94.87360432624817,0.0 +1600,96.26920928001405,0.0 +1600,97.66481423377991,0.0 +1600,99.06041918754579,0.0 +1600,100.45602414131164,0.0 +1600,101.85162909507753,0.0 +1600,103.24723404884338,0.0 +1600,104.64283900260926,0.0 +1600,106.03844395637512,0.0 +1600,107.434048910141,0.0 +1600,108.82965386390686,0.0 +1600,110.22525881767274,0.0 +1600,111.6208637714386,0.0 +1600,113.01646872520448,0.0 +1600,114.41207367897034,0.0 +1600,115.80767863273621,0.0 +1600,117.2032835865021,0.0 +1600,118.59888854026795,0.0 +1600,119.99449349403383,0.0 +1600,121.39009844779969,0.0 +1600,122.78570340156557,0.0 +1600,124.18130835533142,0.0 +1600,125.5769133090973,0.0 +1600,126.97251826286316,0.0 +1600,128.36812321662904,0.0 +1600,129.76372817039493,0.0 +1600,131.15933312416075,0.0 +1600,132.55493807792664,0.0 +1600,133.95054303169252,0.0 +1600,135.3461479854584,0.0 +1600,136.74175293922423,0.0 +1600,138.1373578929901,0.0 +1600,139.532962846756,0.0 +1600,140.92856780052188,0.0 +1600,142.32417275428773,0.0 +1600,143.71977770805358,0.0 +1600,145.11538266181947,0.0 +1600,146.51098761558535,0.0 +1600,147.9065925693512,0.0 +1600,149.30219752311706,10.0 + +1700,11.13730710029602,0.0 +1700,12.532912054061889,0.0 +1700,13.92851700782776,0.0 +1700,15.32412196159363,0.0 +1700,16.719726915359498,0.0 +1700,18.115331869125367,0.0 +1700,19.51093682289124,0.0 +1700,20.906541776657107,0.0 +1700,22.302146730422976,0.0 +1700,23.697751684188844,0.0 +1700,25.093356637954713,0.0 +1700,26.48896159172058,0.0 +1700,27.88456654548645,0.0 +1700,29.28017149925232,0.0 +1700,30.67577645301819,0.0 +1700,32.07138140678406,0.0 +1700,33.46698636054993,0.0 +1700,34.8625913143158,0.0 +1700,36.25819626808167,0.0 +1700,37.65380122184754,0.0 +1700,39.049406175613406,0.0 +1700,40.445011129379274,0.0 +1700,41.84061608314514,0.0 +1700,43.23622103691101,0.0 +1700,44.63182599067688,0.0 +1700,46.02743094444275,0.0 +1700,47.423035898208624,0.0 +1700,48.81864085197449,0.0 +1700,50.21424580574036,0.0 +1700,51.60985075950623,0.0 +1700,53.0054557132721,0.0 +1700,54.40106066703797,0.0 +1700,55.796665620803836,0.0 +1700,57.192270574569704,0.0 +1700,58.58787552833557,0.0 +1700,59.98348048210144,0.0 +1700,61.37908543586731,0.0 +1700,62.77469038963318,0.0 +1700,64.17029534339906,0.0 +1700,65.56590029716492,0.0 +1700,66.9615052509308,0.0 +1700,68.35711020469665,0.0 +1700,69.75271515846254,0.0 +1700,71.14832011222839,0.0 +1700,72.54392506599427,0.0 +1700,73.93953001976013,0.0 +1700,75.33513497352601,0.0 +1700,76.73073992729186,0.0 +1700,78.12634488105775,0.0 +1700,79.5219498348236,0.0 +1700,80.91755478858948,0.0 +1700,82.31315974235534,0.0 +1700,83.70876469612122,0.0 +1700,85.10436964988709,0.0 +1700,86.49997460365296,0.0 +1700,87.89557955741884,0.0 +1700,89.2911845111847,0.0 +1700,90.68678946495058,0.0 +1700,92.08239441871643,0.0 +1700,93.47799937248232,0.0 +1700,94.87360432624817,0.0 +1700,96.26920928001405,0.0 +1700,97.66481423377991,0.0 +1700,99.06041918754579,0.0 +1700,100.45602414131164,0.0 +1700,101.85162909507753,0.0 +1700,103.24723404884338,0.0 +1700,104.64283900260926,0.0 +1700,106.03844395637512,0.0 +1700,107.434048910141,0.0 +1700,108.82965386390686,0.0 +1700,110.22525881767274,0.0 +1700,111.6208637714386,0.0 +1700,113.01646872520448,0.0 +1700,114.41207367897034,0.0 +1700,115.80767863273621,0.0 +1700,117.2032835865021,0.0 +1700,118.59888854026795,0.0 +1700,119.99449349403383,0.0 +1700,121.39009844779969,0.0 +1700,122.78570340156557,0.0 +1700,124.18130835533142,0.0 +1700,125.5769133090973,0.0 +1700,126.97251826286316,0.0 +1700,128.36812321662904,0.0 +1700,129.76372817039493,0.0 +1700,131.15933312416075,0.0 +1700,132.55493807792664,0.0 +1700,133.95054303169252,0.0 +1700,135.3461479854584,0.0 +1700,136.74175293922423,0.0 +1700,138.1373578929901,0.0 +1700,139.532962846756,0.0 +1700,140.92856780052188,0.0 +1700,142.32417275428773,0.0 +1700,143.71977770805358,0.0 +1700,145.11538266181947,0.0 +1700,146.51098761558535,0.0 +1700,147.9065925693512,0.0 +1700,149.30219752311706,10.0 + +1800,11.13730710029602,0.0 +1800,12.532912054061889,0.0 +1800,13.92851700782776,0.0 +1800,15.32412196159363,0.0 +1800,16.719726915359498,0.0 +1800,18.115331869125367,0.0 +1800,19.51093682289124,0.0 +1800,20.906541776657107,0.0 +1800,22.302146730422976,0.0 +1800,23.697751684188844,0.0 +1800,25.093356637954713,0.0 +1800,26.48896159172058,0.0 +1800,27.88456654548645,0.0 +1800,29.28017149925232,0.0 +1800,30.67577645301819,0.0 +1800,32.07138140678406,0.0 +1800,33.46698636054993,0.0 +1800,34.8625913143158,0.0 +1800,36.25819626808167,0.0 +1800,37.65380122184754,0.0 +1800,39.049406175613406,0.0 +1800,40.445011129379274,0.0 +1800,41.84061608314514,0.0 +1800,43.23622103691101,0.0 +1800,44.63182599067688,0.0 +1800,46.02743094444275,0.0 +1800,47.423035898208624,0.0 +1800,48.81864085197449,0.0 +1800,50.21424580574036,0.0 +1800,51.60985075950623,0.0 +1800,53.0054557132721,0.0 +1800,54.40106066703797,0.0 +1800,55.796665620803836,0.0 +1800,57.192270574569704,0.0 +1800,58.58787552833557,0.0 +1800,59.98348048210144,0.0 +1800,61.37908543586731,0.0 +1800,62.77469038963318,0.0 +1800,64.17029534339906,0.0 +1800,65.56590029716492,0.0 +1800,66.9615052509308,0.0 +1800,68.35711020469665,0.0 +1800,69.75271515846254,0.0 +1800,71.14832011222839,0.0 +1800,72.54392506599427,0.0 +1800,73.93953001976013,0.0 +1800,75.33513497352601,0.0 +1800,76.73073992729186,0.0 +1800,78.12634488105775,0.0 +1800,79.5219498348236,0.0 +1800,80.91755478858948,0.0 +1800,82.31315974235534,0.0 +1800,83.70876469612122,0.0 +1800,85.10436964988709,0.0 +1800,86.49997460365296,0.0 +1800,87.89557955741884,0.0 +1800,89.2911845111847,0.0 +1800,90.68678946495058,0.0 +1800,92.08239441871643,0.0 +1800,93.47799937248232,0.0 +1800,94.87360432624817,0.0 +1800,96.26920928001405,0.0 +1800,97.66481423377991,0.0 +1800,99.06041918754579,0.0 +1800,100.45602414131164,0.0 +1800,101.85162909507753,0.0 +1800,103.24723404884338,0.0 +1800,104.64283900260926,0.0 +1800,106.03844395637512,0.0 +1800,107.434048910141,0.0 +1800,108.82965386390686,0.0 +1800,110.22525881767274,0.0 +1800,111.6208637714386,0.0 +1800,113.01646872520448,0.0 +1800,114.41207367897034,0.0 +1800,115.80767863273621,0.0 +1800,117.2032835865021,0.0 +1800,118.59888854026795,0.0 +1800,119.99449349403383,0.0 +1800,121.39009844779969,0.0 +1800,122.78570340156557,0.0 +1800,124.18130835533142,0.0 +1800,125.5769133090973,0.0 +1800,126.97251826286316,0.0 +1800,128.36812321662904,0.0 +1800,129.76372817039493,0.0 +1800,131.15933312416075,0.0 +1800,132.55493807792664,0.0 +1800,133.95054303169252,0.0 +1800,135.3461479854584,0.0 +1800,136.74175293922423,0.0 +1800,138.1373578929901,0.0 +1800,139.532962846756,0.0 +1800,140.92856780052188,0.0 +1800,142.32417275428773,0.0 +1800,143.71977770805358,0.0 +1800,145.11538266181947,0.0 +1800,146.51098761558535,0.0 +1800,147.9065925693512,0.0 +1800,149.30219752311706,10.0 + +1900,11.13730710029602,0.0 +1900,12.532912054061889,0.0 +1900,13.92851700782776,0.0 +1900,15.32412196159363,0.0 +1900,16.719726915359498,0.0 +1900,18.115331869125367,0.0 +1900,19.51093682289124,0.0 +1900,20.906541776657107,0.0 +1900,22.302146730422976,0.0 +1900,23.697751684188844,0.0 +1900,25.093356637954713,0.0 +1900,26.48896159172058,0.0 +1900,27.88456654548645,0.0 +1900,29.28017149925232,0.0 +1900,30.67577645301819,0.0 +1900,32.07138140678406,0.0 +1900,33.46698636054993,0.0 +1900,34.8625913143158,0.0 +1900,36.25819626808167,0.0 +1900,37.65380122184754,0.0 +1900,39.049406175613406,0.0 +1900,40.445011129379274,0.0 +1900,41.84061608314514,0.0 +1900,43.23622103691101,0.0 +1900,44.63182599067688,0.0 +1900,46.02743094444275,0.0 +1900,47.423035898208624,0.0 +1900,48.81864085197449,0.0 +1900,50.21424580574036,0.0 +1900,51.60985075950623,0.0 +1900,53.0054557132721,0.0 +1900,54.40106066703797,0.0 +1900,55.796665620803836,0.0 +1900,57.192270574569704,0.0 +1900,58.58787552833557,0.0 +1900,59.98348048210144,0.0 +1900,61.37908543586731,0.0 +1900,62.77469038963318,0.0 +1900,64.17029534339906,0.0 +1900,65.56590029716492,0.0 +1900,66.9615052509308,0.0 +1900,68.35711020469665,0.0 +1900,69.75271515846254,0.0 +1900,71.14832011222839,0.0 +1900,72.54392506599427,0.0 +1900,73.93953001976013,0.0 +1900,75.33513497352601,0.0 +1900,76.73073992729186,0.0 +1900,78.12634488105775,0.0 +1900,79.5219498348236,0.0 +1900,80.91755478858948,0.0 +1900,82.31315974235534,0.0 +1900,83.70876469612122,0.0 +1900,85.10436964988709,0.0 +1900,86.49997460365296,0.0 +1900,87.89557955741884,0.0 +1900,89.2911845111847,0.0 +1900,90.68678946495058,0.0 +1900,92.08239441871643,0.0 +1900,93.47799937248232,0.0 +1900,94.87360432624817,0.0 +1900,96.26920928001405,0.0 +1900,97.66481423377991,0.0 +1900,99.06041918754579,0.0 +1900,100.45602414131164,0.0 +1900,101.85162909507753,0.0 +1900,103.24723404884338,0.0 +1900,104.64283900260926,0.0 +1900,106.03844395637512,0.0 +1900,107.434048910141,0.0 +1900,108.82965386390686,0.0 +1900,110.22525881767274,0.0 +1900,111.6208637714386,0.0 +1900,113.01646872520448,0.0 +1900,114.41207367897034,0.0 +1900,115.80767863273621,0.0 +1900,117.2032835865021,0.0 +1900,118.59888854026795,0.0 +1900,119.99449349403383,0.0 +1900,121.39009844779969,0.0 +1900,122.78570340156557,0.0 +1900,124.18130835533142,0.0 +1900,125.5769133090973,0.0 +1900,126.97251826286316,0.0 +1900,128.36812321662904,0.0 +1900,129.76372817039493,0.0 +1900,131.15933312416075,0.0 +1900,132.55493807792664,0.0 +1900,133.95054303169252,0.0 +1900,135.3461479854584,0.0 +1900,136.74175293922423,0.0 +1900,138.1373578929901,0.0 +1900,139.532962846756,0.0 +1900,140.92856780052188,0.0 +1900,142.32417275428773,0.0 +1900,143.71977770805358,0.0 +1900,145.11538266181947,0.0 +1900,146.51098761558535,0.0 +1900,147.9065925693512,0.0 +1900,149.30219752311706,10.0 + +2000,11.13730710029602,0.0 +2000,12.532912054061889,0.0 +2000,13.92851700782776,0.0 +2000,15.32412196159363,0.0 +2000,16.719726915359498,0.0 +2000,18.115331869125367,0.0 +2000,19.51093682289124,0.0 +2000,20.906541776657107,0.0 +2000,22.302146730422976,0.0 +2000,23.697751684188844,0.0 +2000,25.093356637954713,0.0 +2000,26.48896159172058,0.0 +2000,27.88456654548645,0.0 +2000,29.28017149925232,0.0 +2000,30.67577645301819,0.0 +2000,32.07138140678406,0.0 +2000,33.46698636054993,0.0 +2000,34.8625913143158,0.0 +2000,36.25819626808167,0.0 +2000,37.65380122184754,0.0 +2000,39.049406175613406,0.0 +2000,40.445011129379274,0.0 +2000,41.84061608314514,0.0 +2000,43.23622103691101,0.0 +2000,44.63182599067688,0.0 +2000,46.02743094444275,0.0 +2000,47.423035898208624,0.0 +2000,48.81864085197449,0.0 +2000,50.21424580574036,0.0 +2000,51.60985075950623,0.0 +2000,53.0054557132721,0.0 +2000,54.40106066703797,0.0 +2000,55.796665620803836,0.0 +2000,57.192270574569704,0.0 +2000,58.58787552833557,0.0 +2000,59.98348048210144,0.0 +2000,61.37908543586731,0.0 +2000,62.77469038963318,0.0 +2000,64.17029534339906,0.0 +2000,65.56590029716492,0.0 +2000,66.9615052509308,0.0 +2000,68.35711020469665,0.0 +2000,69.75271515846254,0.0 +2000,71.14832011222839,0.0 +2000,72.54392506599427,0.0 +2000,73.93953001976013,0.0 +2000,75.33513497352601,0.0 +2000,76.73073992729186,0.0 +2000,78.12634488105775,0.0 +2000,79.5219498348236,0.0 +2000,80.91755478858948,0.0 +2000,82.31315974235534,0.0 +2000,83.70876469612122,0.0 +2000,85.10436964988709,0.0 +2000,86.49997460365296,0.0 +2000,87.89557955741884,0.0 +2000,89.2911845111847,0.0 +2000,90.68678946495058,0.0 +2000,92.08239441871643,0.0 +2000,93.47799937248232,0.0 +2000,94.87360432624817,0.0 +2000,96.26920928001405,0.0 +2000,97.66481423377991,0.0 +2000,99.06041918754579,0.0 +2000,100.45602414131164,0.0 +2000,101.85162909507753,0.0 +2000,103.24723404884338,0.0 +2000,104.64283900260926,0.0 +2000,106.03844395637512,0.0 +2000,107.434048910141,0.0 +2000,108.82965386390686,0.0 +2000,110.22525881767274,0.0 +2000,111.6208637714386,0.0 +2000,113.01646872520448,0.0 +2000,114.41207367897034,0.0 +2000,115.80767863273621,0.0 +2000,117.2032835865021,0.0 +2000,118.59888854026795,0.0 +2000,119.99449349403383,0.0 +2000,121.39009844779969,0.0 +2000,122.78570340156557,0.0 +2000,124.18130835533142,0.0 +2000,125.5769133090973,0.0 +2000,126.97251826286316,0.0 +2000,128.36812321662904,0.0 +2000,129.76372817039493,0.0 +2000,131.15933312416075,0.0 +2000,132.55493807792664,0.0 +2000,133.95054303169252,0.0 +2000,135.3461479854584,0.0 +2000,136.74175293922423,0.0 +2000,138.1373578929901,0.0 +2000,139.532962846756,0.0 +2000,140.92856780052188,0.0 +2000,142.32417275428773,0.0 +2000,143.71977770805358,0.0 +2000,145.11538266181947,0.0 +2000,146.51098761558535,0.0 +2000,147.9065925693512,0.0 +2000,149.30219752311706,10.0 + +2100,11.13730710029602,0.0 +2100,12.532912054061889,0.0 +2100,13.92851700782776,0.0 +2100,15.32412196159363,0.0 +2100,16.719726915359498,0.0 +2100,18.115331869125367,0.0 +2100,19.51093682289124,0.0 +2100,20.906541776657107,0.0 +2100,22.302146730422976,0.0 +2100,23.697751684188844,0.0 +2100,25.093356637954713,0.0 +2100,26.48896159172058,0.0 +2100,27.88456654548645,0.0 +2100,29.28017149925232,0.0 +2100,30.67577645301819,0.0 +2100,32.07138140678406,0.0 +2100,33.46698636054993,0.0 +2100,34.8625913143158,0.0 +2100,36.25819626808167,0.0 +2100,37.65380122184754,0.0 +2100,39.049406175613406,0.0 +2100,40.445011129379274,0.0 +2100,41.84061608314514,0.0 +2100,43.23622103691101,0.0 +2100,44.63182599067688,0.0 +2100,46.02743094444275,0.0 +2100,47.423035898208624,0.0 +2100,48.81864085197449,0.0 +2100,50.21424580574036,0.0 +2100,51.60985075950623,0.0 +2100,53.0054557132721,0.0 +2100,54.40106066703797,0.0 +2100,55.796665620803836,0.0 +2100,57.192270574569704,0.0 +2100,58.58787552833557,0.0 +2100,59.98348048210144,0.0 +2100,61.37908543586731,0.0 +2100,62.77469038963318,0.0 +2100,64.17029534339906,0.0 +2100,65.56590029716492,0.0 +2100,66.9615052509308,0.0 +2100,68.35711020469665,0.0 +2100,69.75271515846254,0.0 +2100,71.14832011222839,0.0 +2100,72.54392506599427,0.0 +2100,73.93953001976013,0.0 +2100,75.33513497352601,0.0 +2100,76.73073992729186,0.0 +2100,78.12634488105775,0.0 +2100,79.5219498348236,0.0 +2100,80.91755478858948,0.0 +2100,82.31315974235534,0.0 +2100,83.70876469612122,0.0 +2100,85.10436964988709,0.0 +2100,86.49997460365296,0.0 +2100,87.89557955741884,0.0 +2100,89.2911845111847,0.0 +2100,90.68678946495058,0.0 +2100,92.08239441871643,0.0 +2100,93.47799937248232,0.0 +2100,94.87360432624817,0.0 +2100,96.26920928001405,0.0 +2100,97.66481423377991,0.0 +2100,99.06041918754579,0.0 +2100,100.45602414131164,0.0 +2100,101.85162909507753,0.0 +2100,103.24723404884338,0.0 +2100,104.64283900260926,0.0 +2100,106.03844395637512,0.0 +2100,107.434048910141,0.0 +2100,108.82965386390686,0.0 +2100,110.22525881767274,0.0 +2100,111.6208637714386,0.0 +2100,113.01646872520448,0.0 +2100,114.41207367897034,0.0 +2100,115.80767863273621,0.0 +2100,117.2032835865021,0.0 +2100,118.59888854026795,0.0 +2100,119.99449349403383,0.0 +2100,121.39009844779969,0.0 +2100,122.78570340156557,0.0 +2100,124.18130835533142,0.0 +2100,125.5769133090973,0.0 +2100,126.97251826286316,0.0 +2100,128.36812321662904,0.0 +2100,129.76372817039493,0.0 +2100,131.15933312416075,0.0 +2100,132.55493807792664,0.0 +2100,133.95054303169252,0.0 +2100,135.3461479854584,0.0 +2100,136.74175293922423,0.0 +2100,138.1373578929901,0.0 +2100,139.532962846756,0.0 +2100,140.92856780052188,0.0 +2100,142.32417275428773,0.0 +2100,143.71977770805358,0.0 +2100,145.11538266181947,0.0 +2100,146.51098761558535,0.0 +2100,147.9065925693512,0.0 +2100,149.30219752311706,10.0 + +2200,11.13730710029602,0.0 +2200,12.532912054061889,0.0 +2200,13.92851700782776,0.0 +2200,15.32412196159363,0.0 +2200,16.719726915359498,0.0 +2200,18.115331869125367,0.0 +2200,19.51093682289124,0.0 +2200,20.906541776657107,0.0 +2200,22.302146730422976,0.0 +2200,23.697751684188844,0.0 +2200,25.093356637954713,0.0 +2200,26.48896159172058,0.0 +2200,27.88456654548645,0.0 +2200,29.28017149925232,0.0 +2200,30.67577645301819,0.0 +2200,32.07138140678406,0.0 +2200,33.46698636054993,0.0 +2200,34.8625913143158,0.0 +2200,36.25819626808167,0.0 +2200,37.65380122184754,0.0 +2200,39.049406175613406,0.0 +2200,40.445011129379274,0.0 +2200,41.84061608314514,0.0 +2200,43.23622103691101,0.0 +2200,44.63182599067688,0.0 +2200,46.02743094444275,0.0 +2200,47.423035898208624,0.0 +2200,48.81864085197449,0.0 +2200,50.21424580574036,0.0 +2200,51.60985075950623,0.0 +2200,53.0054557132721,0.0 +2200,54.40106066703797,0.0 +2200,55.796665620803836,0.0 +2200,57.192270574569704,0.0 +2200,58.58787552833557,0.0 +2200,59.98348048210144,0.0 +2200,61.37908543586731,0.0 +2200,62.77469038963318,0.0 +2200,64.17029534339906,0.0 +2200,65.56590029716492,0.0 +2200,66.9615052509308,0.0 +2200,68.35711020469665,0.0 +2200,69.75271515846254,0.0 +2200,71.14832011222839,0.0 +2200,72.54392506599427,0.0 +2200,73.93953001976013,0.0 +2200,75.33513497352601,0.0 +2200,76.73073992729186,0.0 +2200,78.12634488105775,0.0 +2200,79.5219498348236,0.0 +2200,80.91755478858948,0.0 +2200,82.31315974235534,0.0 +2200,83.70876469612122,0.0 +2200,85.10436964988709,0.0 +2200,86.49997460365296,0.0 +2200,87.89557955741884,0.0 +2200,89.2911845111847,0.0 +2200,90.68678946495058,0.0 +2200,92.08239441871643,0.0 +2200,93.47799937248232,0.0 +2200,94.87360432624817,0.0 +2200,96.26920928001405,0.0 +2200,97.66481423377991,0.0 +2200,99.06041918754579,0.0 +2200,100.45602414131164,0.0 +2200,101.85162909507753,0.0 +2200,103.24723404884338,0.0 +2200,104.64283900260926,0.0 +2200,106.03844395637512,0.0 +2200,107.434048910141,0.0 +2200,108.82965386390686,0.0 +2200,110.22525881767274,0.0 +2200,111.6208637714386,0.0 +2200,113.01646872520448,0.0 +2200,114.41207367897034,0.0 +2200,115.80767863273621,0.0 +2200,117.2032835865021,0.0 +2200,118.59888854026795,0.0 +2200,119.99449349403383,0.0 +2200,121.39009844779969,0.0 +2200,122.78570340156557,0.0 +2200,124.18130835533142,0.0 +2200,125.5769133090973,0.0 +2200,126.97251826286316,0.0 +2200,128.36812321662904,0.0 +2200,129.76372817039493,0.0 +2200,131.15933312416075,0.0 +2200,132.55493807792664,0.0 +2200,133.95054303169252,0.0 +2200,135.3461479854584,0.0 +2200,136.74175293922423,0.0 +2200,138.1373578929901,0.0 +2200,139.532962846756,0.0 +2200,140.92856780052188,0.0 +2200,142.32417275428773,0.0 +2200,143.71977770805358,0.0 +2200,145.11538266181947,0.0 +2200,146.51098761558535,0.0 +2200,147.9065925693512,0.0 +2200,149.30219752311706,10.0 + +2300,11.13730710029602,0.0 +2300,12.532912054061889,0.0 +2300,13.92851700782776,0.0 +2300,15.32412196159363,0.0 +2300,16.719726915359498,0.0 +2300,18.115331869125367,0.0 +2300,19.51093682289124,0.0 +2300,20.906541776657107,0.0 +2300,22.302146730422976,0.0 +2300,23.697751684188844,0.0 +2300,25.093356637954713,0.0 +2300,26.48896159172058,0.0 +2300,27.88456654548645,0.0 +2300,29.28017149925232,0.0 +2300,30.67577645301819,0.0 +2300,32.07138140678406,0.0 +2300,33.46698636054993,0.0 +2300,34.8625913143158,0.0 +2300,36.25819626808167,0.0 +2300,37.65380122184754,0.0 +2300,39.049406175613406,0.0 +2300,40.445011129379274,0.0 +2300,41.84061608314514,0.0 +2300,43.23622103691101,0.0 +2300,44.63182599067688,0.0 +2300,46.02743094444275,0.0 +2300,47.423035898208624,0.0 +2300,48.81864085197449,0.0 +2300,50.21424580574036,0.0 +2300,51.60985075950623,0.0 +2300,53.0054557132721,0.0 +2300,54.40106066703797,0.0 +2300,55.796665620803836,0.0 +2300,57.192270574569704,0.0 +2300,58.58787552833557,0.0 +2300,59.98348048210144,0.0 +2300,61.37908543586731,0.0 +2300,62.77469038963318,0.0 +2300,64.17029534339906,0.0 +2300,65.56590029716492,0.0 +2300,66.9615052509308,0.0 +2300,68.35711020469665,0.0 +2300,69.75271515846254,0.0 +2300,71.14832011222839,0.0 +2300,72.54392506599427,0.0 +2300,73.93953001976013,0.0 +2300,75.33513497352601,0.0 +2300,76.73073992729186,0.0 +2300,78.12634488105775,0.0 +2300,79.5219498348236,0.0 +2300,80.91755478858948,0.0 +2300,82.31315974235534,0.0 +2300,83.70876469612122,0.0 +2300,85.10436964988709,0.0 +2300,86.49997460365296,0.0 +2300,87.89557955741884,0.0 +2300,89.2911845111847,0.0 +2300,90.68678946495058,0.0 +2300,92.08239441871643,0.0 +2300,93.47799937248232,0.0 +2300,94.87360432624817,0.0 +2300,96.26920928001405,0.0 +2300,97.66481423377991,0.0 +2300,99.06041918754579,0.0 +2300,100.45602414131164,0.0 +2300,101.85162909507753,0.0 +2300,103.24723404884338,0.0 +2300,104.64283900260926,0.0 +2300,106.03844395637512,0.0 +2300,107.434048910141,0.0 +2300,108.82965386390686,0.0 +2300,110.22525881767274,0.0 +2300,111.6208637714386,0.0 +2300,113.01646872520448,0.0 +2300,114.41207367897034,0.0 +2300,115.80767863273621,0.0 +2300,117.2032835865021,0.0 +2300,118.59888854026795,0.0 +2300,119.99449349403383,0.0 +2300,121.39009844779969,0.0 +2300,122.78570340156557,0.0 +2300,124.18130835533142,0.0 +2300,125.5769133090973,0.0 +2300,126.97251826286316,0.0 +2300,128.36812321662904,0.0 +2300,129.76372817039493,0.0 +2300,131.15933312416075,0.0 +2300,132.55493807792664,0.0 +2300,133.95054303169252,0.0 +2300,135.3461479854584,0.0 +2300,136.74175293922423,0.0 +2300,138.1373578929901,0.0 +2300,139.532962846756,0.0 +2300,140.92856780052188,0.0 +2300,142.32417275428773,0.0 +2300,143.71977770805358,0.0 +2300,145.11538266181947,0.0 +2300,146.51098761558535,0.0 +2300,147.9065925693512,0.0 +2300,149.30219752311706,10.0 + +2400,11.13730710029602,0.0 +2400,12.532912054061889,0.0 +2400,13.92851700782776,0.0 +2400,15.32412196159363,0.0 +2400,16.719726915359498,0.0 +2400,18.115331869125367,0.0 +2400,19.51093682289124,0.0 +2400,20.906541776657107,0.0 +2400,22.302146730422976,0.0 +2400,23.697751684188844,0.0 +2400,25.093356637954713,0.0 +2400,26.48896159172058,0.0 +2400,27.88456654548645,0.0 +2400,29.28017149925232,0.0 +2400,30.67577645301819,0.0 +2400,32.07138140678406,0.0 +2400,33.46698636054993,0.0 +2400,34.8625913143158,0.0 +2400,36.25819626808167,0.0 +2400,37.65380122184754,0.0 +2400,39.049406175613406,0.0 +2400,40.445011129379274,0.0 +2400,41.84061608314514,0.0 +2400,43.23622103691101,0.0 +2400,44.63182599067688,0.0 +2400,46.02743094444275,0.0 +2400,47.423035898208624,0.0 +2400,48.81864085197449,0.0 +2400,50.21424580574036,0.0 +2400,51.60985075950623,0.0 +2400,53.0054557132721,0.0 +2400,54.40106066703797,0.0 +2400,55.796665620803836,0.0 +2400,57.192270574569704,0.0 +2400,58.58787552833557,0.0 +2400,59.98348048210144,0.0 +2400,61.37908543586731,0.0 +2400,62.77469038963318,0.0 +2400,64.17029534339906,0.0 +2400,65.56590029716492,0.0 +2400,66.9615052509308,0.0 +2400,68.35711020469665,0.0 +2400,69.75271515846254,0.0 +2400,71.14832011222839,0.0 +2400,72.54392506599427,0.0 +2400,73.93953001976013,0.0 +2400,75.33513497352601,0.0 +2400,76.73073992729186,0.0 +2400,78.12634488105775,0.0 +2400,79.5219498348236,0.0 +2400,80.91755478858948,0.0 +2400,82.31315974235534,0.0 +2400,83.70876469612122,0.0 +2400,85.10436964988709,0.0 +2400,86.49997460365296,0.0 +2400,87.89557955741884,0.0 +2400,89.2911845111847,0.0 +2400,90.68678946495058,0.0 +2400,92.08239441871643,0.0 +2400,93.47799937248232,0.0 +2400,94.87360432624817,0.0 +2400,96.26920928001405,0.0 +2400,97.66481423377991,0.0 +2400,99.06041918754579,0.0 +2400,100.45602414131164,0.0 +2400,101.85162909507753,0.0 +2400,103.24723404884338,0.0 +2400,104.64283900260926,0.0 +2400,106.03844395637512,0.0 +2400,107.434048910141,0.0 +2400,108.82965386390686,0.0 +2400,110.22525881767274,0.0 +2400,111.6208637714386,0.0 +2400,113.01646872520448,0.0 +2400,114.41207367897034,0.0 +2400,115.80767863273621,0.0 +2400,117.2032835865021,0.0 +2400,118.59888854026795,0.0 +2400,119.99449349403383,0.0 +2400,121.39009844779969,0.0 +2400,122.78570340156557,0.0 +2400,124.18130835533142,0.0 +2400,125.5769133090973,0.0 +2400,126.97251826286316,0.0 +2400,128.36812321662904,0.0 +2400,129.76372817039493,0.0 +2400,131.15933312416075,0.0 +2400,132.55493807792664,0.0 +2400,133.95054303169252,0.0 +2400,135.3461479854584,0.0 +2400,136.74175293922423,0.0 +2400,138.1373578929901,0.0 +2400,139.532962846756,0.0 +2400,140.92856780052188,0.0 +2400,142.32417275428773,0.0 +2400,143.71977770805358,0.0 +2400,145.11538266181947,0.0 +2400,146.51098761558535,0.0 +2400,147.9065925693512,0.0 +2400,149.30219752311706,10.0 + +2500,11.13730710029602,0.0 +2500,12.532912054061889,0.0 +2500,13.92851700782776,0.0 +2500,15.32412196159363,0.0 +2500,16.719726915359498,0.0 +2500,18.115331869125367,0.0 +2500,19.51093682289124,0.0 +2500,20.906541776657107,0.0 +2500,22.302146730422976,0.0 +2500,23.697751684188844,0.0 +2500,25.093356637954713,0.0 +2500,26.48896159172058,0.0 +2500,27.88456654548645,0.0 +2500,29.28017149925232,0.0 +2500,30.67577645301819,0.0 +2500,32.07138140678406,0.0 +2500,33.46698636054993,0.0 +2500,34.8625913143158,0.0 +2500,36.25819626808167,0.0 +2500,37.65380122184754,0.0 +2500,39.049406175613406,0.0 +2500,40.445011129379274,0.0 +2500,41.84061608314514,0.0 +2500,43.23622103691101,0.0 +2500,44.63182599067688,0.0 +2500,46.02743094444275,0.0 +2500,47.423035898208624,0.0 +2500,48.81864085197449,0.0 +2500,50.21424580574036,0.0 +2500,51.60985075950623,0.0 +2500,53.0054557132721,0.0 +2500,54.40106066703797,0.0 +2500,55.796665620803836,0.0 +2500,57.192270574569704,0.0 +2500,58.58787552833557,0.0 +2500,59.98348048210144,0.0 +2500,61.37908543586731,0.0 +2500,62.77469038963318,0.0 +2500,64.17029534339906,0.0 +2500,65.56590029716492,0.0 +2500,66.9615052509308,0.0 +2500,68.35711020469665,0.0 +2500,69.75271515846254,0.0 +2500,71.14832011222839,0.0 +2500,72.54392506599427,0.0 +2500,73.93953001976013,0.0 +2500,75.33513497352601,0.0 +2500,76.73073992729186,0.0 +2500,78.12634488105775,0.0 +2500,79.5219498348236,0.0 +2500,80.91755478858948,0.0 +2500,82.31315974235534,0.0 +2500,83.70876469612122,0.0 +2500,85.10436964988709,0.0 +2500,86.49997460365296,0.0 +2500,87.89557955741884,0.0 +2500,89.2911845111847,0.0 +2500,90.68678946495058,0.0 +2500,92.08239441871643,0.0 +2500,93.47799937248232,0.0 +2500,94.87360432624817,0.0 +2500,96.26920928001405,0.0 +2500,97.66481423377991,0.0 +2500,99.06041918754579,0.0 +2500,100.45602414131164,0.0 +2500,101.85162909507753,0.0 +2500,103.24723404884338,0.0 +2500,104.64283900260926,0.0 +2500,106.03844395637512,0.0 +2500,107.434048910141,0.0 +2500,108.82965386390686,0.0 +2500,110.22525881767274,0.0 +2500,111.6208637714386,0.0 +2500,113.01646872520448,0.0 +2500,114.41207367897034,0.0 +2500,115.80767863273621,0.0 +2500,117.2032835865021,0.0 +2500,118.59888854026795,0.0 +2500,119.99449349403383,0.0 +2500,121.39009844779969,0.0 +2500,122.78570340156557,0.0 +2500,124.18130835533142,0.0 +2500,125.5769133090973,0.0 +2500,126.97251826286316,0.0 +2500,128.36812321662904,0.0 +2500,129.76372817039493,0.0 +2500,131.15933312416075,0.0 +2500,132.55493807792664,0.0 +2500,133.95054303169252,0.0 +2500,135.3461479854584,0.0 +2500,136.74175293922423,0.0 +2500,138.1373578929901,0.0 +2500,139.532962846756,0.0 +2500,140.92856780052188,0.0 +2500,142.32417275428773,0.0 +2500,143.71977770805358,0.0 +2500,145.11538266181947,0.0 +2500,146.51098761558535,0.0 +2500,147.9065925693512,0.0 +2500,149.30219752311706,10.0 + +2600,11.13730710029602,0.0 +2600,12.532912054061889,0.0 +2600,13.92851700782776,0.0 +2600,15.32412196159363,0.0 +2600,16.719726915359498,0.0 +2600,18.115331869125367,0.0 +2600,19.51093682289124,0.0 +2600,20.906541776657107,0.0 +2600,22.302146730422976,0.0 +2600,23.697751684188844,0.0 +2600,25.093356637954713,0.0 +2600,26.48896159172058,0.0 +2600,27.88456654548645,0.0 +2600,29.28017149925232,0.0 +2600,30.67577645301819,0.0 +2600,32.07138140678406,0.0 +2600,33.46698636054993,0.0 +2600,34.8625913143158,0.0 +2600,36.25819626808167,0.0 +2600,37.65380122184754,0.0 +2600,39.049406175613406,0.0 +2600,40.445011129379274,0.0 +2600,41.84061608314514,0.0 +2600,43.23622103691101,0.0 +2600,44.63182599067688,0.0 +2600,46.02743094444275,0.0 +2600,47.423035898208624,0.0 +2600,48.81864085197449,0.0 +2600,50.21424580574036,0.0 +2600,51.60985075950623,0.0 +2600,53.0054557132721,0.0 +2600,54.40106066703797,0.0 +2600,55.796665620803836,0.0 +2600,57.192270574569704,0.0 +2600,58.58787552833557,0.0 +2600,59.98348048210144,0.0 +2600,61.37908543586731,0.0 +2600,62.77469038963318,0.0 +2600,64.17029534339906,0.0 +2600,65.56590029716492,0.0 +2600,66.9615052509308,0.0 +2600,68.35711020469665,0.0 +2600,69.75271515846254,0.0 +2600,71.14832011222839,0.0 +2600,72.54392506599427,0.0 +2600,73.93953001976013,0.0 +2600,75.33513497352601,0.0 +2600,76.73073992729186,0.0 +2600,78.12634488105775,0.0 +2600,79.5219498348236,0.0 +2600,80.91755478858948,0.0 +2600,82.31315974235534,0.0 +2600,83.70876469612122,0.0 +2600,85.10436964988709,0.0 +2600,86.49997460365296,0.0 +2600,87.89557955741884,0.0 +2600,89.2911845111847,0.0 +2600,90.68678946495058,0.0 +2600,92.08239441871643,0.0 +2600,93.47799937248232,0.0 +2600,94.87360432624817,0.0 +2600,96.26920928001405,0.0 +2600,97.66481423377991,0.0 +2600,99.06041918754579,0.0 +2600,100.45602414131164,0.0 +2600,101.85162909507753,0.0 +2600,103.24723404884338,0.0 +2600,104.64283900260926,0.0 +2600,106.03844395637512,0.0 +2600,107.434048910141,0.0 +2600,108.82965386390686,0.0 +2600,110.22525881767274,0.0 +2600,111.6208637714386,0.0 +2600,113.01646872520448,0.0 +2600,114.41207367897034,0.0 +2600,115.80767863273621,0.0 +2600,117.2032835865021,0.0 +2600,118.59888854026795,0.0 +2600,119.99449349403383,0.0 +2600,121.39009844779969,0.0 +2600,122.78570340156557,0.0 +2600,124.18130835533142,0.0 +2600,125.5769133090973,0.0 +2600,126.97251826286316,0.0 +2600,128.36812321662904,0.0 +2600,129.76372817039493,0.0 +2600,131.15933312416075,0.0 +2600,132.55493807792664,0.0 +2600,133.95054303169252,0.0 +2600,135.3461479854584,0.0 +2600,136.74175293922423,0.0 +2600,138.1373578929901,0.0 +2600,139.532962846756,0.0 +2600,140.92856780052188,0.0 +2600,142.32417275428773,0.0 +2600,143.71977770805358,0.0 +2600,145.11538266181947,0.0 +2600,146.51098761558535,0.0 +2600,147.9065925693512,0.0 +2600,149.30219752311706,10.0 + +2700,11.13730710029602,0.0 +2700,12.532912054061889,0.0 +2700,13.92851700782776,0.0 +2700,15.32412196159363,0.0 +2700,16.719726915359498,0.0 +2700,18.115331869125367,0.0 +2700,19.51093682289124,0.0 +2700,20.906541776657107,0.0 +2700,22.302146730422976,0.0 +2700,23.697751684188844,0.0 +2700,25.093356637954713,0.0 +2700,26.48896159172058,0.0 +2700,27.88456654548645,0.0 +2700,29.28017149925232,0.0 +2700,30.67577645301819,0.0 +2700,32.07138140678406,0.0 +2700,33.46698636054993,0.0 +2700,34.8625913143158,0.0 +2700,36.25819626808167,0.0 +2700,37.65380122184754,0.0 +2700,39.049406175613406,0.0 +2700,40.445011129379274,0.0 +2700,41.84061608314514,0.0 +2700,43.23622103691101,0.0 +2700,44.63182599067688,0.0 +2700,46.02743094444275,0.0 +2700,47.423035898208624,0.0 +2700,48.81864085197449,0.0 +2700,50.21424580574036,0.0 +2700,51.60985075950623,0.0 +2700,53.0054557132721,0.0 +2700,54.40106066703797,0.0 +2700,55.796665620803836,0.0 +2700,57.192270574569704,0.0 +2700,58.58787552833557,0.0 +2700,59.98348048210144,0.0 +2700,61.37908543586731,0.0 +2700,62.77469038963318,0.0 +2700,64.17029534339906,0.0 +2700,65.56590029716492,0.0 +2700,66.9615052509308,0.0 +2700,68.35711020469665,0.0 +2700,69.75271515846254,0.0 +2700,71.14832011222839,0.0 +2700,72.54392506599427,0.0 +2700,73.93953001976013,0.0 +2700,75.33513497352601,0.0 +2700,76.73073992729186,0.0 +2700,78.12634488105775,0.0 +2700,79.5219498348236,0.0 +2700,80.91755478858948,0.0 +2700,82.31315974235534,0.0 +2700,83.70876469612122,0.0 +2700,85.10436964988709,0.0 +2700,86.49997460365296,0.0 +2700,87.89557955741884,0.0 +2700,89.2911845111847,0.0 +2700,90.68678946495058,0.0 +2700,92.08239441871643,0.0 +2700,93.47799937248232,0.0 +2700,94.87360432624817,0.0 +2700,96.26920928001405,0.0 +2700,97.66481423377991,0.0 +2700,99.06041918754579,0.0 +2700,100.45602414131164,0.0 +2700,101.85162909507753,0.0 +2700,103.24723404884338,0.0 +2700,104.64283900260926,0.0 +2700,106.03844395637512,0.0 +2700,107.434048910141,0.0 +2700,108.82965386390686,0.0 +2700,110.22525881767274,0.0 +2700,111.6208637714386,0.0 +2700,113.01646872520448,0.0 +2700,114.41207367897034,0.0 +2700,115.80767863273621,0.0 +2700,117.2032835865021,0.0 +2700,118.59888854026795,0.0 +2700,119.99449349403383,0.0 +2700,121.39009844779969,0.0 +2700,122.78570340156557,0.0 +2700,124.18130835533142,0.0 +2700,125.5769133090973,0.0 +2700,126.97251826286316,0.0 +2700,128.36812321662904,0.0 +2700,129.76372817039493,0.0 +2700,131.15933312416075,0.0 +2700,132.55493807792664,0.0 +2700,133.95054303169252,0.0 +2700,135.3461479854584,0.0 +2700,136.74175293922423,0.0 +2700,138.1373578929901,0.0 +2700,139.532962846756,0.0 +2700,140.92856780052188,0.0 +2700,142.32417275428773,0.0 +2700,143.71977770805358,0.0 +2700,145.11538266181947,0.0 +2700,146.51098761558535,0.0 +2700,147.9065925693512,0.0 +2700,149.30219752311706,10.0 + +2800,11.13730710029602,0.0 +2800,12.532912054061889,0.0 +2800,13.92851700782776,0.0 +2800,15.32412196159363,0.0 +2800,16.719726915359498,0.0 +2800,18.115331869125367,0.0 +2800,19.51093682289124,0.0 +2800,20.906541776657107,0.0 +2800,22.302146730422976,0.0 +2800,23.697751684188844,0.0 +2800,25.093356637954713,0.0 +2800,26.48896159172058,0.0 +2800,27.88456654548645,0.0 +2800,29.28017149925232,0.0 +2800,30.67577645301819,0.0 +2800,32.07138140678406,0.0 +2800,33.46698636054993,0.0 +2800,34.8625913143158,0.0 +2800,36.25819626808167,0.0 +2800,37.65380122184754,0.0 +2800,39.049406175613406,0.0 +2800,40.445011129379274,0.0 +2800,41.84061608314514,0.0 +2800,43.23622103691101,0.0 +2800,44.63182599067688,0.0 +2800,46.02743094444275,0.0 +2800,47.423035898208624,0.0 +2800,48.81864085197449,0.0 +2800,50.21424580574036,0.0 +2800,51.60985075950623,0.0 +2800,53.0054557132721,0.0 +2800,54.40106066703797,0.0 +2800,55.796665620803836,0.0 +2800,57.192270574569704,0.0 +2800,58.58787552833557,0.0 +2800,59.98348048210144,0.0 +2800,61.37908543586731,0.0 +2800,62.77469038963318,0.0 +2800,64.17029534339906,0.0 +2800,65.56590029716492,0.0 +2800,66.9615052509308,0.0 +2800,68.35711020469665,0.0 +2800,69.75271515846254,0.0 +2800,71.14832011222839,0.0 +2800,72.54392506599427,0.0 +2800,73.93953001976013,0.0 +2800,75.33513497352601,0.0 +2800,76.73073992729186,0.0 +2800,78.12634488105775,0.0 +2800,79.5219498348236,0.0 +2800,80.91755478858948,0.0 +2800,82.31315974235534,0.0 +2800,83.70876469612122,0.0 +2800,85.10436964988709,0.0 +2800,86.49997460365296,0.0 +2800,87.89557955741884,0.0 +2800,89.2911845111847,0.0 +2800,90.68678946495058,0.0 +2800,92.08239441871643,0.0 +2800,93.47799937248232,0.0 +2800,94.87360432624817,0.0 +2800,96.26920928001405,0.0 +2800,97.66481423377991,0.0 +2800,99.06041918754579,0.0 +2800,100.45602414131164,0.0 +2800,101.85162909507753,0.0 +2800,103.24723404884338,0.0 +2800,104.64283900260926,0.0 +2800,106.03844395637512,0.0 +2800,107.434048910141,0.0 +2800,108.82965386390686,0.0 +2800,110.22525881767274,0.0 +2800,111.6208637714386,0.0 +2800,113.01646872520448,0.0 +2800,114.41207367897034,0.0 +2800,115.80767863273621,0.0 +2800,117.2032835865021,0.0 +2800,118.59888854026795,0.0 +2800,119.99449349403383,0.0 +2800,121.39009844779969,0.0 +2800,122.78570340156557,0.0 +2800,124.18130835533142,0.0 +2800,125.5769133090973,0.0 +2800,126.97251826286316,0.0 +2800,128.36812321662904,0.0 +2800,129.76372817039493,0.0 +2800,131.15933312416075,0.0 +2800,132.55493807792664,0.0 +2800,133.95054303169252,0.0 +2800,135.3461479854584,0.0 +2800,136.74175293922423,0.0 +2800,138.1373578929901,0.0 +2800,139.532962846756,0.0 +2800,140.92856780052188,0.0 +2800,142.32417275428773,0.0 +2800,143.71977770805358,0.0 +2800,145.11538266181947,0.0 +2800,146.51098761558535,0.0 +2800,147.9065925693512,0.0 +2800,149.30219752311706,10.0 + +2900,11.13730710029602,0.0 +2900,12.532912054061889,0.0 +2900,13.92851700782776,0.0 +2900,15.32412196159363,0.0 +2900,16.719726915359498,0.0 +2900,18.115331869125367,0.0 +2900,19.51093682289124,0.0 +2900,20.906541776657107,0.0 +2900,22.302146730422976,0.0 +2900,23.697751684188844,0.0 +2900,25.093356637954713,0.0 +2900,26.48896159172058,0.0 +2900,27.88456654548645,0.0 +2900,29.28017149925232,0.0 +2900,30.67577645301819,0.0 +2900,32.07138140678406,0.0 +2900,33.46698636054993,0.0 +2900,34.8625913143158,0.0 +2900,36.25819626808167,0.0 +2900,37.65380122184754,0.0 +2900,39.049406175613406,0.0 +2900,40.445011129379274,0.0 +2900,41.84061608314514,0.0 +2900,43.23622103691101,0.0 +2900,44.63182599067688,0.0 +2900,46.02743094444275,0.0 +2900,47.423035898208624,0.0 +2900,48.81864085197449,0.0 +2900,50.21424580574036,0.0 +2900,51.60985075950623,0.0 +2900,53.0054557132721,0.0 +2900,54.40106066703797,0.0 +2900,55.796665620803836,0.0 +2900,57.192270574569704,0.0 +2900,58.58787552833557,0.0 +2900,59.98348048210144,0.0 +2900,61.37908543586731,0.0 +2900,62.77469038963318,0.0 +2900,64.17029534339906,0.0 +2900,65.56590029716492,0.0 +2900,66.9615052509308,0.0 +2900,68.35711020469665,0.0 +2900,69.75271515846254,0.0 +2900,71.14832011222839,0.0 +2900,72.54392506599427,0.0 +2900,73.93953001976013,0.0 +2900,75.33513497352601,0.0 +2900,76.73073992729186,0.0 +2900,78.12634488105775,0.0 +2900,79.5219498348236,0.0 +2900,80.91755478858948,0.0 +2900,82.31315974235534,0.0 +2900,83.70876469612122,0.0 +2900,85.10436964988709,0.0 +2900,86.49997460365296,0.0 +2900,87.89557955741884,0.0 +2900,89.2911845111847,0.0 +2900,90.68678946495058,0.0 +2900,92.08239441871643,0.0 +2900,93.47799937248232,0.0 +2900,94.87360432624817,0.0 +2900,96.26920928001405,0.0 +2900,97.66481423377991,0.0 +2900,99.06041918754579,0.0 +2900,100.45602414131164,0.0 +2900,101.85162909507753,0.0 +2900,103.24723404884338,0.0 +2900,104.64283900260926,0.0 +2900,106.03844395637512,0.0 +2900,107.434048910141,0.0 +2900,108.82965386390686,0.0 +2900,110.22525881767274,0.0 +2900,111.6208637714386,0.0 +2900,113.01646872520448,0.0 +2900,114.41207367897034,0.0 +2900,115.80767863273621,0.0 +2900,117.2032835865021,0.0 +2900,118.59888854026795,0.0 +2900,119.99449349403383,0.0 +2900,121.39009844779969,0.0 +2900,122.78570340156557,0.0 +2900,124.18130835533142,0.0 +2900,125.5769133090973,0.0 +2900,126.97251826286316,0.0 +2900,128.36812321662904,0.0 +2900,129.76372817039493,0.0 +2900,131.15933312416075,0.0 +2900,132.55493807792664,0.0 +2900,133.95054303169252,0.0 +2900,135.3461479854584,0.0 +2900,136.74175293922423,0.0 +2900,138.1373578929901,0.0 +2900,139.532962846756,0.0 +2900,140.92856780052188,0.0 +2900,142.32417275428773,0.0 +2900,143.71977770805358,0.0 +2900,145.11538266181947,0.0 +2900,146.51098761558535,0.0 +2900,147.9065925693512,0.0 +2900,149.30219752311706,10.0 + +3000,11.13730710029602,0.0 +3000,12.532912054061889,0.0 +3000,13.92851700782776,0.0 +3000,15.32412196159363,0.0 +3000,16.719726915359498,0.0 +3000,18.115331869125367,0.0 +3000,19.51093682289124,0.0 +3000,20.906541776657107,0.0 +3000,22.302146730422976,0.0 +3000,23.697751684188844,0.0 +3000,25.093356637954713,0.0 +3000,26.48896159172058,0.0 +3000,27.88456654548645,0.0 +3000,29.28017149925232,0.0 +3000,30.67577645301819,0.0 +3000,32.07138140678406,0.0 +3000,33.46698636054993,0.0 +3000,34.8625913143158,0.0 +3000,36.25819626808167,0.0 +3000,37.65380122184754,0.0 +3000,39.049406175613406,0.0 +3000,40.445011129379274,0.0 +3000,41.84061608314514,0.0 +3000,43.23622103691101,0.0 +3000,44.63182599067688,0.0 +3000,46.02743094444275,0.0 +3000,47.423035898208624,0.0 +3000,48.81864085197449,0.0 +3000,50.21424580574036,0.0 +3000,51.60985075950623,0.0 +3000,53.0054557132721,0.0 +3000,54.40106066703797,0.0 +3000,55.796665620803836,0.0 +3000,57.192270574569704,0.0 +3000,58.58787552833557,0.0 +3000,59.98348048210144,0.0 +3000,61.37908543586731,0.0 +3000,62.77469038963318,0.0 +3000,64.17029534339906,0.0 +3000,65.56590029716492,0.0 +3000,66.9615052509308,0.0 +3000,68.35711020469665,0.0 +3000,69.75271515846254,0.0 +3000,71.14832011222839,0.0 +3000,72.54392506599427,0.0 +3000,73.93953001976013,0.0 +3000,75.33513497352601,0.0 +3000,76.73073992729186,0.0 +3000,78.12634488105775,0.0 +3000,79.5219498348236,0.0 +3000,80.91755478858948,0.0 +3000,82.31315974235534,0.0 +3000,83.70876469612122,0.0 +3000,85.10436964988709,0.0 +3000,86.49997460365296,0.0 +3000,87.89557955741884,0.0 +3000,89.2911845111847,0.0 +3000,90.68678946495058,0.0 +3000,92.08239441871643,0.0 +3000,93.47799937248232,0.0 +3000,94.87360432624817,0.0 +3000,96.26920928001405,0.0 +3000,97.66481423377991,0.0 +3000,99.06041918754579,0.0 +3000,100.45602414131164,0.0 +3000,101.85162909507753,0.0 +3000,103.24723404884338,0.0 +3000,104.64283900260926,0.0 +3000,106.03844395637512,0.0 +3000,107.434048910141,0.0 +3000,108.82965386390686,0.0 +3000,110.22525881767274,0.0 +3000,111.6208637714386,0.0 +3000,113.01646872520448,0.0 +3000,114.41207367897034,0.0 +3000,115.80767863273621,0.0 +3000,117.2032835865021,0.0 +3000,118.59888854026795,0.0 +3000,119.99449349403383,0.0 +3000,121.39009844779969,0.0 +3000,122.78570340156557,0.0 +3000,124.18130835533142,0.0 +3000,125.5769133090973,0.0 +3000,126.97251826286316,0.0 +3000,128.36812321662904,0.0 +3000,129.76372817039493,0.0 +3000,131.15933312416075,0.0 +3000,132.55493807792664,0.0 +3000,133.95054303169252,0.0 +3000,135.3461479854584,0.0 +3000,136.74175293922423,0.0 +3000,138.1373578929901,0.0 +3000,139.532962846756,0.0 +3000,140.92856780052188,0.0 +3000,142.32417275428773,0.0 +3000,143.71977770805358,0.0 +3000,145.11538266181947,0.0 +3000,146.51098761558535,0.0 +3000,147.9065925693512,0.0 +3000,149.30219752311706,10.0 + +3100,11.13730710029602,0.0 +3100,12.532912054061889,0.0 +3100,13.92851700782776,0.0 +3100,15.32412196159363,0.0 +3100,16.719726915359498,0.0 +3100,18.115331869125367,0.0 +3100,19.51093682289124,0.0 +3100,20.906541776657107,0.0 +3100,22.302146730422976,0.0 +3100,23.697751684188844,0.0 +3100,25.093356637954713,0.0 +3100,26.48896159172058,0.0 +3100,27.88456654548645,0.0 +3100,29.28017149925232,0.0 +3100,30.67577645301819,0.0 +3100,32.07138140678406,0.0 +3100,33.46698636054993,0.0 +3100,34.8625913143158,0.0 +3100,36.25819626808167,0.0 +3100,37.65380122184754,0.0 +3100,39.049406175613406,0.0 +3100,40.445011129379274,0.0 +3100,41.84061608314514,0.0 +3100,43.23622103691101,0.0 +3100,44.63182599067688,0.0 +3100,46.02743094444275,0.0 +3100,47.423035898208624,0.0 +3100,48.81864085197449,0.0 +3100,50.21424580574036,0.0 +3100,51.60985075950623,0.0 +3100,53.0054557132721,0.0 +3100,54.40106066703797,0.0 +3100,55.796665620803836,0.0 +3100,57.192270574569704,0.0 +3100,58.58787552833557,0.0 +3100,59.98348048210144,0.0 +3100,61.37908543586731,0.0 +3100,62.77469038963318,0.0 +3100,64.17029534339906,0.0 +3100,65.56590029716492,0.0 +3100,66.9615052509308,0.0 +3100,68.35711020469665,0.0 +3100,69.75271515846254,0.0 +3100,71.14832011222839,0.0 +3100,72.54392506599427,0.0 +3100,73.93953001976013,0.0 +3100,75.33513497352601,0.0 +3100,76.73073992729186,0.0 +3100,78.12634488105775,0.0 +3100,79.5219498348236,0.0 +3100,80.91755478858948,0.0 +3100,82.31315974235534,0.0 +3100,83.70876469612122,0.0 +3100,85.10436964988709,0.0 +3100,86.49997460365296,0.0 +3100,87.89557955741884,0.0 +3100,89.2911845111847,0.0 +3100,90.68678946495058,0.0 +3100,92.08239441871643,0.0 +3100,93.47799937248232,0.0 +3100,94.87360432624817,0.0 +3100,96.26920928001405,0.0 +3100,97.66481423377991,0.0 +3100,99.06041918754579,0.0 +3100,100.45602414131164,0.0 +3100,101.85162909507753,0.0 +3100,103.24723404884338,0.0 +3100,104.64283900260926,0.0 +3100,106.03844395637512,0.0 +3100,107.434048910141,0.0 +3100,108.82965386390686,0.0 +3100,110.22525881767274,0.0 +3100,111.6208637714386,0.0 +3100,113.01646872520448,0.0 +3100,114.41207367897034,0.0 +3100,115.80767863273621,0.0 +3100,117.2032835865021,0.0 +3100,118.59888854026795,0.0 +3100,119.99449349403383,0.0 +3100,121.39009844779969,0.0 +3100,122.78570340156557,0.0 +3100,124.18130835533142,0.0 +3100,125.5769133090973,0.0 +3100,126.97251826286316,0.0 +3100,128.36812321662904,0.0 +3100,129.76372817039493,0.0 +3100,131.15933312416075,0.0 +3100,132.55493807792664,0.0 +3100,133.95054303169252,0.0 +3100,135.3461479854584,0.0 +3100,136.74175293922423,0.0 +3100,138.1373578929901,0.0 +3100,139.532962846756,0.0 +3100,140.92856780052188,0.0 +3100,142.32417275428773,0.0 +3100,143.71977770805358,0.0 +3100,145.11538266181947,0.0 +3100,146.51098761558535,0.0 +3100,147.9065925693512,0.0 +3100,149.30219752311706,10.0 + +3200,11.13730710029602,0.0 +3200,12.532912054061889,0.0 +3200,13.92851700782776,0.0 +3200,15.32412196159363,0.0 +3200,16.719726915359498,0.0 +3200,18.115331869125367,0.0 +3200,19.51093682289124,0.0 +3200,20.906541776657107,0.0 +3200,22.302146730422976,0.0 +3200,23.697751684188844,0.0 +3200,25.093356637954713,0.0 +3200,26.48896159172058,0.0 +3200,27.88456654548645,0.0 +3200,29.28017149925232,0.0 +3200,30.67577645301819,0.0 +3200,32.07138140678406,0.0 +3200,33.46698636054993,0.0 +3200,34.8625913143158,0.0 +3200,36.25819626808167,0.0 +3200,37.65380122184754,0.0 +3200,39.049406175613406,0.0 +3200,40.445011129379274,0.0 +3200,41.84061608314514,0.0 +3200,43.23622103691101,0.0 +3200,44.63182599067688,0.0 +3200,46.02743094444275,0.0 +3200,47.423035898208624,0.0 +3200,48.81864085197449,0.0 +3200,50.21424580574036,0.0 +3200,51.60985075950623,0.0 +3200,53.0054557132721,0.0 +3200,54.40106066703797,0.0 +3200,55.796665620803836,0.0 +3200,57.192270574569704,0.0 +3200,58.58787552833557,0.0 +3200,59.98348048210144,0.0 +3200,61.37908543586731,0.0 +3200,62.77469038963318,0.0 +3200,64.17029534339906,0.0 +3200,65.56590029716492,0.0 +3200,66.9615052509308,0.0 +3200,68.35711020469665,0.0 +3200,69.75271515846254,0.0 +3200,71.14832011222839,0.0 +3200,72.54392506599427,0.0 +3200,73.93953001976013,0.0 +3200,75.33513497352601,0.0 +3200,76.73073992729186,0.0 +3200,78.12634488105775,0.0 +3200,79.5219498348236,0.0 +3200,80.91755478858948,0.0 +3200,82.31315974235534,0.0 +3200,83.70876469612122,0.0 +3200,85.10436964988709,0.0 +3200,86.49997460365296,0.0 +3200,87.89557955741884,0.0 +3200,89.2911845111847,0.0 +3200,90.68678946495058,0.0 +3200,92.08239441871643,0.0 +3200,93.47799937248232,0.0 +3200,94.87360432624817,0.0 +3200,96.26920928001405,0.0 +3200,97.66481423377991,0.0 +3200,99.06041918754579,0.0 +3200,100.45602414131164,0.0 +3200,101.85162909507753,0.0 +3200,103.24723404884338,0.0 +3200,104.64283900260926,0.0 +3200,106.03844395637512,0.0 +3200,107.434048910141,0.0 +3200,108.82965386390686,0.0 +3200,110.22525881767274,0.0 +3200,111.6208637714386,0.0 +3200,113.01646872520448,0.0 +3200,114.41207367897034,0.0 +3200,115.80767863273621,0.0 +3200,117.2032835865021,0.0 +3200,118.59888854026795,0.0 +3200,119.99449349403383,0.0 +3200,121.39009844779969,0.0 +3200,122.78570340156557,0.0 +3200,124.18130835533142,0.0 +3200,125.5769133090973,0.0 +3200,126.97251826286316,0.0 +3200,128.36812321662904,0.0 +3200,129.76372817039493,0.0 +3200,131.15933312416075,0.0 +3200,132.55493807792664,0.0 +3200,133.95054303169252,0.0 +3200,135.3461479854584,0.0 +3200,136.74175293922423,0.0 +3200,138.1373578929901,0.0 +3200,139.532962846756,0.0 +3200,140.92856780052188,0.0 +3200,142.32417275428773,0.0 +3200,143.71977770805358,0.0 +3200,145.11538266181947,0.0 +3200,146.51098761558535,0.0 +3200,147.9065925693512,0.0 +3200,149.30219752311706,10.0 + +3300,11.13730710029602,0.0 +3300,12.532912054061889,0.0 +3300,13.92851700782776,0.0 +3300,15.32412196159363,0.0 +3300,16.719726915359498,0.0 +3300,18.115331869125367,0.0 +3300,19.51093682289124,0.0 +3300,20.906541776657107,0.0 +3300,22.302146730422976,0.0 +3300,23.697751684188844,0.0 +3300,25.093356637954713,0.0 +3300,26.48896159172058,0.0 +3300,27.88456654548645,0.0 +3300,29.28017149925232,0.0 +3300,30.67577645301819,0.0 +3300,32.07138140678406,0.0 +3300,33.46698636054993,0.0 +3300,34.8625913143158,0.0 +3300,36.25819626808167,0.0 +3300,37.65380122184754,0.0 +3300,39.049406175613406,0.0 +3300,40.445011129379274,0.0 +3300,41.84061608314514,0.0 +3300,43.23622103691101,0.0 +3300,44.63182599067688,0.0 +3300,46.02743094444275,0.0 +3300,47.423035898208624,0.0 +3300,48.81864085197449,0.0 +3300,50.21424580574036,0.0 +3300,51.60985075950623,0.0 +3300,53.0054557132721,0.0 +3300,54.40106066703797,0.0 +3300,55.796665620803836,0.0 +3300,57.192270574569704,0.0 +3300,58.58787552833557,0.0 +3300,59.98348048210144,0.0 +3300,61.37908543586731,0.0 +3300,62.77469038963318,0.0 +3300,64.17029534339906,0.0 +3300,65.56590029716492,0.0 +3300,66.9615052509308,0.0 +3300,68.35711020469665,0.0 +3300,69.75271515846254,0.0 +3300,71.14832011222839,0.0 +3300,72.54392506599427,0.0 +3300,73.93953001976013,0.0 +3300,75.33513497352601,0.0 +3300,76.73073992729186,0.0 +3300,78.12634488105775,0.0 +3300,79.5219498348236,0.0 +3300,80.91755478858948,0.0 +3300,82.31315974235534,0.0 +3300,83.70876469612122,0.0 +3300,85.10436964988709,0.0 +3300,86.49997460365296,0.0 +3300,87.89557955741884,0.0 +3300,89.2911845111847,0.0 +3300,90.68678946495058,0.0 +3300,92.08239441871643,0.0 +3300,93.47799937248232,0.0 +3300,94.87360432624817,0.0 +3300,96.26920928001405,0.0 +3300,97.66481423377991,0.0 +3300,99.06041918754579,0.0 +3300,100.45602414131164,0.0 +3300,101.85162909507753,0.0 +3300,103.24723404884338,0.0 +3300,104.64283900260926,0.0 +3300,106.03844395637512,0.0 +3300,107.434048910141,0.0 +3300,108.82965386390686,0.0 +3300,110.22525881767274,0.0 +3300,111.6208637714386,0.0 +3300,113.01646872520448,0.0 +3300,114.41207367897034,0.0 +3300,115.80767863273621,0.0 +3300,117.2032835865021,0.0 +3300,118.59888854026795,0.0 +3300,119.99449349403383,0.0 +3300,121.39009844779969,0.0 +3300,122.78570340156557,0.0 +3300,124.18130835533142,0.0 +3300,125.5769133090973,0.0 +3300,126.97251826286316,0.0 +3300,128.36812321662904,0.0 +3300,129.76372817039493,0.0 +3300,131.15933312416075,0.0 +3300,132.55493807792664,0.0 +3300,133.95054303169252,0.0 +3300,135.3461479854584,0.0 +3300,136.74175293922423,0.0 +3300,138.1373578929901,0.0 +3300,139.532962846756,0.0 +3300,140.92856780052188,0.0 +3300,142.32417275428773,0.0 +3300,143.71977770805358,0.0 +3300,145.11538266181947,0.0 +3300,146.51098761558535,0.0 +3300,147.9065925693512,0.0 +3300,149.30219752311706,10.0 + +3400,11.13730710029602,0.0 +3400,12.532912054061889,0.0 +3400,13.92851700782776,0.0 +3400,15.32412196159363,0.0 +3400,16.719726915359498,0.0 +3400,18.115331869125367,0.0 +3400,19.51093682289124,0.0 +3400,20.906541776657107,0.0 +3400,22.302146730422976,0.0 +3400,23.697751684188844,0.0 +3400,25.093356637954713,0.0 +3400,26.48896159172058,0.0 +3400,27.88456654548645,0.0 +3400,29.28017149925232,0.0 +3400,30.67577645301819,0.0 +3400,32.07138140678406,0.0 +3400,33.46698636054993,0.0 +3400,34.8625913143158,0.0 +3400,36.25819626808167,0.0 +3400,37.65380122184754,0.0 +3400,39.049406175613406,0.0 +3400,40.445011129379274,0.0 +3400,41.84061608314514,0.0 +3400,43.23622103691101,0.0 +3400,44.63182599067688,0.0 +3400,46.02743094444275,0.0 +3400,47.423035898208624,0.0 +3400,48.81864085197449,0.0 +3400,50.21424580574036,0.0 +3400,51.60985075950623,0.0 +3400,53.0054557132721,0.0 +3400,54.40106066703797,0.0 +3400,55.796665620803836,0.0 +3400,57.192270574569704,0.0 +3400,58.58787552833557,0.0 +3400,59.98348048210144,0.0 +3400,61.37908543586731,0.0 +3400,62.77469038963318,0.0 +3400,64.17029534339906,0.0 +3400,65.56590029716492,0.0 +3400,66.9615052509308,0.0 +3400,68.35711020469665,0.0 +3400,69.75271515846254,0.0 +3400,71.14832011222839,0.0 +3400,72.54392506599427,0.0 +3400,73.93953001976013,0.0 +3400,75.33513497352601,0.0 +3400,76.73073992729186,0.0 +3400,78.12634488105775,0.0 +3400,79.5219498348236,0.0 +3400,80.91755478858948,0.0 +3400,82.31315974235534,0.0 +3400,83.70876469612122,0.0 +3400,85.10436964988709,0.0 +3400,86.49997460365296,0.0 +3400,87.89557955741884,0.0 +3400,89.2911845111847,0.0 +3400,90.68678946495058,0.0 +3400,92.08239441871643,0.0 +3400,93.47799937248232,0.0 +3400,94.87360432624817,0.0 +3400,96.26920928001405,0.0 +3400,97.66481423377991,0.0 +3400,99.06041918754579,0.0 +3400,100.45602414131164,0.0 +3400,101.85162909507753,0.0 +3400,103.24723404884338,0.0 +3400,104.64283900260926,0.0 +3400,106.03844395637512,0.0 +3400,107.434048910141,0.0 +3400,108.82965386390686,0.0 +3400,110.22525881767274,0.0 +3400,111.6208637714386,0.0 +3400,113.01646872520448,0.0 +3400,114.41207367897034,0.0 +3400,115.80767863273621,0.0 +3400,117.2032835865021,0.0 +3400,118.59888854026795,0.0 +3400,119.99449349403383,0.0 +3400,121.39009844779969,0.0 +3400,122.78570340156557,0.0 +3400,124.18130835533142,0.0 +3400,125.5769133090973,0.0 +3400,126.97251826286316,0.0 +3400,128.36812321662904,0.0 +3400,129.76372817039493,0.0 +3400,131.15933312416075,0.0 +3400,132.55493807792664,0.0 +3400,133.95054303169252,0.0 +3400,135.3461479854584,0.0 +3400,136.74175293922423,0.0 +3400,138.1373578929901,0.0 +3400,139.532962846756,0.0 +3400,140.92856780052188,0.0 +3400,142.32417275428773,0.0 +3400,143.71977770805358,0.0 +3400,145.11538266181947,0.0 +3400,146.51098761558535,0.0 +3400,147.9065925693512,0.0 +3400,149.30219752311706,10.0 + +3500,11.13730710029602,0.0 +3500,12.532912054061889,0.0 +3500,13.92851700782776,0.0 +3500,15.32412196159363,0.0 +3500,16.719726915359498,0.0 +3500,18.115331869125367,0.0 +3500,19.51093682289124,0.0 +3500,20.906541776657107,0.0 +3500,22.302146730422976,0.0 +3500,23.697751684188844,0.0 +3500,25.093356637954713,0.0 +3500,26.48896159172058,0.0 +3500,27.88456654548645,0.0 +3500,29.28017149925232,0.0 +3500,30.67577645301819,0.0 +3500,32.07138140678406,0.0 +3500,33.46698636054993,0.0 +3500,34.8625913143158,0.0 +3500,36.25819626808167,0.0 +3500,37.65380122184754,0.0 +3500,39.049406175613406,0.0 +3500,40.445011129379274,0.0 +3500,41.84061608314514,0.0 +3500,43.23622103691101,0.0 +3500,44.63182599067688,0.0 +3500,46.02743094444275,0.0 +3500,47.423035898208624,0.0 +3500,48.81864085197449,0.0 +3500,50.21424580574036,0.0 +3500,51.60985075950623,0.0 +3500,53.0054557132721,0.0 +3500,54.40106066703797,0.0 +3500,55.796665620803836,0.0 +3500,57.192270574569704,0.0 +3500,58.58787552833557,0.0 +3500,59.98348048210144,0.0 +3500,61.37908543586731,0.0 +3500,62.77469038963318,0.0 +3500,64.17029534339906,0.0 +3500,65.56590029716492,0.0 +3500,66.9615052509308,0.0 +3500,68.35711020469665,0.0 +3500,69.75271515846254,0.0 +3500,71.14832011222839,0.0 +3500,72.54392506599427,0.0 +3500,73.93953001976013,0.0 +3500,75.33513497352601,0.0 +3500,76.73073992729186,0.0 +3500,78.12634488105775,0.0 +3500,79.5219498348236,0.0 +3500,80.91755478858948,0.0 +3500,82.31315974235534,0.0 +3500,83.70876469612122,0.0 +3500,85.10436964988709,0.0 +3500,86.49997460365296,0.0 +3500,87.89557955741884,0.0 +3500,89.2911845111847,0.0 +3500,90.68678946495058,0.0 +3500,92.08239441871643,0.0 +3500,93.47799937248232,0.0 +3500,94.87360432624817,0.0 +3500,96.26920928001405,0.0 +3500,97.66481423377991,0.0 +3500,99.06041918754579,0.0 +3500,100.45602414131164,0.0 +3500,101.85162909507753,0.0 +3500,103.24723404884338,0.0 +3500,104.64283900260926,0.0 +3500,106.03844395637512,0.0 +3500,107.434048910141,0.0 +3500,108.82965386390686,0.0 +3500,110.22525881767274,0.0 +3500,111.6208637714386,0.0 +3500,113.01646872520448,0.0 +3500,114.41207367897034,0.0 +3500,115.80767863273621,0.0 +3500,117.2032835865021,0.0 +3500,118.59888854026795,0.0 +3500,119.99449349403383,0.0 +3500,121.39009844779969,0.0 +3500,122.78570340156557,0.0 +3500,124.18130835533142,0.0 +3500,125.5769133090973,0.0 +3500,126.97251826286316,0.0 +3500,128.36812321662904,0.0 +3500,129.76372817039493,0.0 +3500,131.15933312416075,0.0 +3500,132.55493807792664,0.0 +3500,133.95054303169252,0.0 +3500,135.3461479854584,0.0 +3500,136.74175293922423,0.0 +3500,138.1373578929901,0.0 +3500,139.532962846756,0.0 +3500,140.92856780052188,0.0 +3500,142.32417275428773,0.0 +3500,143.71977770805358,0.0 +3500,145.11538266181947,0.0 +3500,146.51098761558535,0.0 +3500,147.9065925693512,0.0 +3500,149.30219752311706,10.0 + +3600,11.13730710029602,0.0 +3600,12.532912054061889,0.0 +3600,13.92851700782776,0.0 +3600,15.32412196159363,0.0 +3600,16.719726915359498,0.0 +3600,18.115331869125367,0.0 +3600,19.51093682289124,0.0 +3600,20.906541776657107,0.0 +3600,22.302146730422976,0.0 +3600,23.697751684188844,0.0 +3600,25.093356637954713,0.0 +3600,26.48896159172058,0.0 +3600,27.88456654548645,0.0 +3600,29.28017149925232,0.0 +3600,30.67577645301819,0.0 +3600,32.07138140678406,0.0 +3600,33.46698636054993,0.0 +3600,34.8625913143158,0.0 +3600,36.25819626808167,0.0 +3600,37.65380122184754,0.0 +3600,39.049406175613406,0.0 +3600,40.445011129379274,0.0 +3600,41.84061608314514,0.0 +3600,43.23622103691101,0.0 +3600,44.63182599067688,0.0 +3600,46.02743094444275,0.0 +3600,47.423035898208624,0.0 +3600,48.81864085197449,0.0 +3600,50.21424580574036,0.0 +3600,51.60985075950623,0.0 +3600,53.0054557132721,0.0 +3600,54.40106066703797,0.0 +3600,55.796665620803836,0.0 +3600,57.192270574569704,0.0 +3600,58.58787552833557,0.0 +3600,59.98348048210144,0.0 +3600,61.37908543586731,0.0 +3600,62.77469038963318,0.0 +3600,64.17029534339906,0.0 +3600,65.56590029716492,0.0 +3600,66.9615052509308,0.0 +3600,68.35711020469665,0.0 +3600,69.75271515846254,0.0 +3600,71.14832011222839,0.0 +3600,72.54392506599427,0.0 +3600,73.93953001976013,0.0 +3600,75.33513497352601,0.0 +3600,76.73073992729186,0.0 +3600,78.12634488105775,0.0 +3600,79.5219498348236,0.0 +3600,80.91755478858948,0.0 +3600,82.31315974235534,0.0 +3600,83.70876469612122,0.0 +3600,85.10436964988709,0.0 +3600,86.49997460365296,0.0 +3600,87.89557955741884,0.0 +3600,89.2911845111847,0.0 +3600,90.68678946495058,0.0 +3600,92.08239441871643,0.0 +3600,93.47799937248232,0.0 +3600,94.87360432624817,0.0 +3600,96.26920928001405,0.0 +3600,97.66481423377991,0.0 +3600,99.06041918754579,0.0 +3600,100.45602414131164,0.0 +3600,101.85162909507753,0.0 +3600,103.24723404884338,0.0 +3600,104.64283900260926,0.0 +3600,106.03844395637512,0.0 +3600,107.434048910141,0.0 +3600,108.82965386390686,0.0 +3600,110.22525881767274,0.0 +3600,111.6208637714386,0.0 +3600,113.01646872520448,0.0 +3600,114.41207367897034,0.0 +3600,115.80767863273621,0.0 +3600,117.2032835865021,0.0 +3600,118.59888854026795,0.0 +3600,119.99449349403383,0.0 +3600,121.39009844779969,0.0 +3600,122.78570340156557,0.0 +3600,124.18130835533142,0.0 +3600,125.5769133090973,0.0 +3600,126.97251826286316,0.0 +3600,128.36812321662904,0.0 +3600,129.76372817039493,0.0 +3600,131.15933312416075,0.0 +3600,132.55493807792664,0.0 +3600,133.95054303169252,0.0 +3600,135.3461479854584,0.0 +3600,136.74175293922423,0.0 +3600,138.1373578929901,0.0 +3600,139.532962846756,0.0 +3600,140.92856780052188,0.0 +3600,142.32417275428773,0.0 +3600,143.71977770805358,0.0 +3600,145.11538266181947,0.0 +3600,146.51098761558535,0.0 +3600,147.9065925693512,0.0 +3600,149.30219752311706,10.0 + +3700,11.13730710029602,0.0 +3700,12.532912054061889,0.0 +3700,13.92851700782776,0.0 +3700,15.32412196159363,0.0 +3700,16.719726915359498,0.0 +3700,18.115331869125367,0.0 +3700,19.51093682289124,0.0 +3700,20.906541776657107,0.0 +3700,22.302146730422976,0.0 +3700,23.697751684188844,0.0 +3700,25.093356637954713,0.0 +3700,26.48896159172058,0.0 +3700,27.88456654548645,0.0 +3700,29.28017149925232,0.0 +3700,30.67577645301819,0.0 +3700,32.07138140678406,0.0 +3700,33.46698636054993,0.0 +3700,34.8625913143158,0.0 +3700,36.25819626808167,0.0 +3700,37.65380122184754,0.0 +3700,39.049406175613406,0.0 +3700,40.445011129379274,0.0 +3700,41.84061608314514,0.0 +3700,43.23622103691101,0.0 +3700,44.63182599067688,0.0 +3700,46.02743094444275,0.0 +3700,47.423035898208624,0.0 +3700,48.81864085197449,0.0 +3700,50.21424580574036,0.0 +3700,51.60985075950623,0.0 +3700,53.0054557132721,0.0 +3700,54.40106066703797,0.0 +3700,55.796665620803836,0.0 +3700,57.192270574569704,0.0 +3700,58.58787552833557,0.0 +3700,59.98348048210144,0.0 +3700,61.37908543586731,0.0 +3700,62.77469038963318,0.0 +3700,64.17029534339906,0.0 +3700,65.56590029716492,0.0 +3700,66.9615052509308,0.0 +3700,68.35711020469665,0.0 +3700,69.75271515846254,0.0 +3700,71.14832011222839,0.0 +3700,72.54392506599427,0.0 +3700,73.93953001976013,0.0 +3700,75.33513497352601,0.0 +3700,76.73073992729186,0.0 +3700,78.12634488105775,0.0 +3700,79.5219498348236,0.0 +3700,80.91755478858948,0.0 +3700,82.31315974235534,0.0 +3700,83.70876469612122,0.0 +3700,85.10436964988709,0.0 +3700,86.49997460365296,0.0 +3700,87.89557955741884,0.0 +3700,89.2911845111847,0.0 +3700,90.68678946495058,0.0 +3700,92.08239441871643,0.0 +3700,93.47799937248232,0.0 +3700,94.87360432624817,0.0 +3700,96.26920928001405,0.0 +3700,97.66481423377991,0.0 +3700,99.06041918754579,0.0 +3700,100.45602414131164,0.0 +3700,101.85162909507753,0.0 +3700,103.24723404884338,0.0 +3700,104.64283900260926,0.0 +3700,106.03844395637512,0.0 +3700,107.434048910141,0.0 +3700,108.82965386390686,0.0 +3700,110.22525881767274,0.0 +3700,111.6208637714386,0.0 +3700,113.01646872520448,0.0 +3700,114.41207367897034,0.0 +3700,115.80767863273621,0.0 +3700,117.2032835865021,0.0 +3700,118.59888854026795,0.0 +3700,119.99449349403383,0.0 +3700,121.39009844779969,0.0 +3700,122.78570340156557,0.0 +3700,124.18130835533142,0.0 +3700,125.5769133090973,0.0 +3700,126.97251826286316,0.0 +3700,128.36812321662904,0.0 +3700,129.76372817039493,0.0 +3700,131.15933312416075,0.0 +3700,132.55493807792664,0.0 +3700,133.95054303169252,0.0 +3700,135.3461479854584,0.0 +3700,136.74175293922423,0.0 +3700,138.1373578929901,0.0 +3700,139.532962846756,0.0 +3700,140.92856780052188,0.0 +3700,142.32417275428773,0.0 +3700,143.71977770805358,0.0 +3700,145.11538266181947,0.0 +3700,146.51098761558535,0.0 +3700,147.9065925693512,0.0 +3700,149.30219752311706,10.0 + +3800,11.13730710029602,0.0 +3800,12.532912054061889,0.0 +3800,13.92851700782776,0.0 +3800,15.32412196159363,0.0 +3800,16.719726915359498,0.0 +3800,18.115331869125367,0.0 +3800,19.51093682289124,0.0 +3800,20.906541776657107,0.0 +3800,22.302146730422976,0.0 +3800,23.697751684188844,0.0 +3800,25.093356637954713,0.0 +3800,26.48896159172058,0.0 +3800,27.88456654548645,0.0 +3800,29.28017149925232,0.0 +3800,30.67577645301819,0.0 +3800,32.07138140678406,0.0 +3800,33.46698636054993,0.0 +3800,34.8625913143158,0.0 +3800,36.25819626808167,0.0 +3800,37.65380122184754,0.0 +3800,39.049406175613406,0.0 +3800,40.445011129379274,0.0 +3800,41.84061608314514,0.0 +3800,43.23622103691101,0.0 +3800,44.63182599067688,0.0 +3800,46.02743094444275,0.0 +3800,47.423035898208624,0.0 +3800,48.81864085197449,0.0 +3800,50.21424580574036,0.0 +3800,51.60985075950623,0.0 +3800,53.0054557132721,0.0 +3800,54.40106066703797,0.0 +3800,55.796665620803836,0.0 +3800,57.192270574569704,0.0 +3800,58.58787552833557,0.0 +3800,59.98348048210144,0.0 +3800,61.37908543586731,0.0 +3800,62.77469038963318,0.0 +3800,64.17029534339906,0.0 +3800,65.56590029716492,0.0 +3800,66.9615052509308,0.0 +3800,68.35711020469665,0.0 +3800,69.75271515846254,0.0 +3800,71.14832011222839,0.0 +3800,72.54392506599427,0.0 +3800,73.93953001976013,0.0 +3800,75.33513497352601,0.0 +3800,76.73073992729186,0.0 +3800,78.12634488105775,0.0 +3800,79.5219498348236,0.0 +3800,80.91755478858948,0.0 +3800,82.31315974235534,0.0 +3800,83.70876469612122,0.0 +3800,85.10436964988709,0.0 +3800,86.49997460365296,0.0 +3800,87.89557955741884,0.0 +3800,89.2911845111847,0.0 +3800,90.68678946495058,0.0 +3800,92.08239441871643,0.0 +3800,93.47799937248232,0.0 +3800,94.87360432624817,0.0 +3800,96.26920928001405,0.0 +3800,97.66481423377991,0.0 +3800,99.06041918754579,0.0 +3800,100.45602414131164,0.0 +3800,101.85162909507753,0.0 +3800,103.24723404884338,0.0 +3800,104.64283900260926,0.0 +3800,106.03844395637512,0.0 +3800,107.434048910141,0.0 +3800,108.82965386390686,0.0 +3800,110.22525881767274,0.0 +3800,111.6208637714386,0.0 +3800,113.01646872520448,0.0 +3800,114.41207367897034,0.0 +3800,115.80767863273621,0.0 +3800,117.2032835865021,0.0 +3800,118.59888854026795,0.0 +3800,119.99449349403383,0.0 +3800,121.39009844779969,0.0 +3800,122.78570340156557,0.0 +3800,124.18130835533142,0.0 +3800,125.5769133090973,0.0 +3800,126.97251826286316,0.0 +3800,128.36812321662904,0.0 +3800,129.76372817039493,0.0 +3800,131.15933312416075,0.0 +3800,132.55493807792664,0.0 +3800,133.95054303169252,0.0 +3800,135.3461479854584,0.0 +3800,136.74175293922423,0.0 +3800,138.1373578929901,0.0 +3800,139.532962846756,0.0 +3800,140.92856780052188,0.0 +3800,142.32417275428773,0.0 +3800,143.71977770805358,0.0 +3800,145.11538266181947,0.0 +3800,146.51098761558535,0.0 +3800,147.9065925693512,0.0 +3800,149.30219752311706,10.0 + +3900,11.13730710029602,0.0 +3900,12.532912054061889,0.0 +3900,13.92851700782776,0.0 +3900,15.32412196159363,0.0 +3900,16.719726915359498,0.0 +3900,18.115331869125367,0.0 +3900,19.51093682289124,0.0 +3900,20.906541776657107,0.0 +3900,22.302146730422976,0.0 +3900,23.697751684188844,0.0 +3900,25.093356637954713,0.0 +3900,26.48896159172058,0.0 +3900,27.88456654548645,0.0 +3900,29.28017149925232,0.0 +3900,30.67577645301819,0.0 +3900,32.07138140678406,0.0 +3900,33.46698636054993,0.0 +3900,34.8625913143158,0.0 +3900,36.25819626808167,0.0 +3900,37.65380122184754,0.0 +3900,39.049406175613406,0.0 +3900,40.445011129379274,0.0 +3900,41.84061608314514,0.0 +3900,43.23622103691101,0.0 +3900,44.63182599067688,0.0 +3900,46.02743094444275,0.0 +3900,47.423035898208624,0.0 +3900,48.81864085197449,0.0 +3900,50.21424580574036,0.0 +3900,51.60985075950623,0.0 +3900,53.0054557132721,0.0 +3900,54.40106066703797,0.0 +3900,55.796665620803836,0.0 +3900,57.192270574569704,0.0 +3900,58.58787552833557,0.0 +3900,59.98348048210144,0.0 +3900,61.37908543586731,0.0 +3900,62.77469038963318,0.0 +3900,64.17029534339906,0.0 +3900,65.56590029716492,0.0 +3900,66.9615052509308,0.0 +3900,68.35711020469665,0.0 +3900,69.75271515846254,0.0 +3900,71.14832011222839,0.0 +3900,72.54392506599427,0.0 +3900,73.93953001976013,0.0 +3900,75.33513497352601,0.0 +3900,76.73073992729186,0.0 +3900,78.12634488105775,0.0 +3900,79.5219498348236,0.0 +3900,80.91755478858948,0.0 +3900,82.31315974235534,0.0 +3900,83.70876469612122,0.0 +3900,85.10436964988709,0.0 +3900,86.49997460365296,0.0 +3900,87.89557955741884,0.0 +3900,89.2911845111847,0.0 +3900,90.68678946495058,0.0 +3900,92.08239441871643,0.0 +3900,93.47799937248232,0.0 +3900,94.87360432624817,0.0 +3900,96.26920928001405,0.0 +3900,97.66481423377991,0.0 +3900,99.06041918754579,0.0 +3900,100.45602414131164,0.0 +3900,101.85162909507753,0.0 +3900,103.24723404884338,0.0 +3900,104.64283900260926,0.0 +3900,106.03844395637512,0.0 +3900,107.434048910141,0.0 +3900,108.82965386390686,0.0 +3900,110.22525881767274,0.0 +3900,111.6208637714386,0.0 +3900,113.01646872520448,0.0 +3900,114.41207367897034,0.0 +3900,115.80767863273621,0.0 +3900,117.2032835865021,0.0 +3900,118.59888854026795,0.0 +3900,119.99449349403383,0.0 +3900,121.39009844779969,0.0 +3900,122.78570340156557,0.0 +3900,124.18130835533142,0.0 +3900,125.5769133090973,0.0 +3900,126.97251826286316,0.0 +3900,128.36812321662904,0.0 +3900,129.76372817039493,0.0 +3900,131.15933312416075,0.0 +3900,132.55493807792664,0.0 +3900,133.95054303169252,0.0 +3900,135.3461479854584,0.0 +3900,136.74175293922423,0.0 +3900,138.1373578929901,0.0 +3900,139.532962846756,0.0 +3900,140.92856780052188,0.0 +3900,142.32417275428773,0.0 +3900,143.71977770805358,0.0 +3900,145.11538266181947,0.0 +3900,146.51098761558535,0.0 +3900,147.9065925693512,0.0 +3900,149.30219752311706,10.0 + +4000,11.13730710029602,0.0 +4000,12.532912054061889,0.0 +4000,13.92851700782776,0.0 +4000,15.32412196159363,0.0 +4000,16.719726915359498,0.0 +4000,18.115331869125367,0.0 +4000,19.51093682289124,0.0 +4000,20.906541776657107,0.0 +4000,22.302146730422976,0.0 +4000,23.697751684188844,0.0 +4000,25.093356637954713,0.0 +4000,26.48896159172058,0.0 +4000,27.88456654548645,0.0 +4000,29.28017149925232,0.0 +4000,30.67577645301819,0.0 +4000,32.07138140678406,0.0 +4000,33.46698636054993,0.0 +4000,34.8625913143158,0.0 +4000,36.25819626808167,0.0 +4000,37.65380122184754,0.0 +4000,39.049406175613406,0.0 +4000,40.445011129379274,0.0 +4000,41.84061608314514,0.0 +4000,43.23622103691101,0.0 +4000,44.63182599067688,0.0 +4000,46.02743094444275,0.0 +4000,47.423035898208624,0.0 +4000,48.81864085197449,0.0 +4000,50.21424580574036,0.0 +4000,51.60985075950623,0.0 +4000,53.0054557132721,0.0 +4000,54.40106066703797,0.0 +4000,55.796665620803836,0.0 +4000,57.192270574569704,0.0 +4000,58.58787552833557,0.0 +4000,59.98348048210144,0.0 +4000,61.37908543586731,0.0 +4000,62.77469038963318,0.0 +4000,64.17029534339906,0.0 +4000,65.56590029716492,0.0 +4000,66.9615052509308,0.0 +4000,68.35711020469665,0.0 +4000,69.75271515846254,0.0 +4000,71.14832011222839,0.0 +4000,72.54392506599427,0.0 +4000,73.93953001976013,0.0 +4000,75.33513497352601,0.0 +4000,76.73073992729186,0.0 +4000,78.12634488105775,0.0 +4000,79.5219498348236,0.0 +4000,80.91755478858948,0.0 +4000,82.31315974235534,0.0 +4000,83.70876469612122,0.0 +4000,85.10436964988709,0.0 +4000,86.49997460365296,0.0 +4000,87.89557955741884,0.0 +4000,89.2911845111847,0.0 +4000,90.68678946495058,0.0 +4000,92.08239441871643,0.0 +4000,93.47799937248232,0.0 +4000,94.87360432624817,0.0 +4000,96.26920928001405,0.0 +4000,97.66481423377991,0.0 +4000,99.06041918754579,0.0 +4000,100.45602414131164,0.0 +4000,101.85162909507753,0.0 +4000,103.24723404884338,0.0 +4000,104.64283900260926,0.0 +4000,106.03844395637512,0.0 +4000,107.434048910141,0.0 +4000,108.82965386390686,0.0 +4000,110.22525881767274,0.0 +4000,111.6208637714386,0.0 +4000,113.01646872520448,0.0 +4000,114.41207367897034,0.0 +4000,115.80767863273621,0.0 +4000,117.2032835865021,0.0 +4000,118.59888854026795,0.0 +4000,119.99449349403383,0.0 +4000,121.39009844779969,0.0 +4000,122.78570340156557,0.0 +4000,124.18130835533142,0.0 +4000,125.5769133090973,0.0 +4000,126.97251826286316,0.0 +4000,128.36812321662904,0.0 +4000,129.76372817039493,0.0 +4000,131.15933312416075,0.0 +4000,132.55493807792664,0.0 +4000,133.95054303169252,0.0 +4000,135.3461479854584,0.0 +4000,136.74175293922423,0.0 +4000,138.1373578929901,0.0 +4000,139.532962846756,0.0 +4000,140.92856780052188,0.0 +4000,142.32417275428773,0.0 +4000,143.71977770805358,0.0 +4000,145.11538266181947,0.0 +4000,146.51098761558535,0.0 +4000,147.9065925693512,0.0 +4000,149.30219752311706,10.0 + diff --git a/paper/src/preamble.tex b/paper/src/preamble.tex index ad633de..d8f9876 100644 --- a/paper/src/preamble.tex +++ b/paper/src/preamble.tex @@ -29,6 +29,8 @@ \usepackage{subcaption} \usepackage{siunitx} \usepackage{tikz} +\usepackage{pgfplots} +\pgfplotsset{compat=1.18} \usepackage{listings} \usepackage{xcolor} \usepackage[ruled,vlined]{algorithm2e} From e0b074161b227e9d12991113d5bae6ceedc9f936 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 2 Feb 2026 12:08:24 +0100 Subject: [PATCH 06/36] fix: typo --- paper/src/chapters/03-methodology.tex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 540ae68..ff859e9 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -300,7 +300,7 @@ where $R(p, d)$ is the revenue function and $\lambda$ weighs the penalty for inf Another proposed formulation of the optimal policy would be to adjust the ambiguity set dyanmically over the live computed divergence where $\epsilon(\Delta_H)$ to adjust the ball around or estimator according to each behavioral signal emited through a given trajctory. We state this as a possibility but do not peruse it due to literature suggesting that wesserstine methods do not require absolute continuity and are better with ``black swans'' \parencite{kuhn_wasserstein_2024}. \subsubsection{Actor Implementation} -In our simulation, the "Follower" is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. +In our simulation, the ``follower'' is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. As part of our reward engineering we think about the UX factor ($UX \in [0,1]$) whic his our proxy for user experience degradation, this is computed as a mixture of contribution from the separability model metric of $\frac{1}{\text{Specificity}}$. @@ -320,7 +320,7 @@ We also need to think about a policy like taxation to the agents Strategy-Proof We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_loop_clean} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories. \begin{algorithm}[t] -\caption{PHANTOM defensive pricing loop (bachelor-thesis level)} +\caption{PHANTOM defensive pricing loop} \label{alg:phantom_loop_clean} \DontPrintSemicolon \SetKwInOut{Input}{Input}\SetKwInOut{Output}{Output} From a9e2e7cbf304ee20d8bc6162dc1f2dd1c614b88f Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 2 Feb 2026 16:52:50 +0100 Subject: [PATCH 07/36] improving on the methodlology --- paper/src/chapters/03-methodology.tex | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index ff859e9..fd46e16 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -137,8 +137,11 @@ The architecture of this platform begins with the deployed web-apps posting inte \subsubsection{DevOps Principles} + + \subsubsection{Online Dynamic Pricing} +In order to collect data from actors under correct conditions we replicate a naive and simple dynamic pricing algorithm which runs in the background during the experiments. The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$. The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$): \begin{equation} @@ -177,6 +180,14 @@ Our approach can be well summarized by a three-stage division, first we intend t Our web platform (developed in similar patterns as the RecSim by \textcite{ie_recsim_2019}) allows us to setup a controled environment in which we assign tasks to human and agentic actors which are then carried out. Each actor gets a browser assigned experiment identification which is persistent across possibly multiple session identifiers. We then group by experiments and extract all the session interactions (trajectories) which follow the schema formalized below. +To speak to the quality and realism, in user interview, participants reported that the platform's architecture mirrored standard commercial booking interfaces, reducing the cognitive load required to learn the system. One participant noted the flow was 'intuitive' and indistinguishable from a 'normal' transaction, suggesting that observed behaviors were driven by the pricing variables rather than interface novelty. +The dynamic pricing mechanisms successfully elicited immediate behavioral adjustments. Participants demonstrated high sensitivity to price volatility, for instance, observing sudden price boosts triggered panic booking behaviors, while significant discrepancies between listing and final prices prompted heightened scrutiny and comparison behaviors. This is comforting as control for the data settings we gather is closely reflective of real life environments. + + +\subsubsection{Design of Training Factorial Study} + +Since in our simulation we have different configurable factors such as the distributions from which we sample individual product valuations, how we parameterize the demand estimation and many more, we need to design a multi factor study. Current estimate is 4x4x3x2x2. This would normally be computationally prohibitive for reinforcement learning, we however have access to 300+ trillium TPU chips in a large cluster. + \subsubsection{Interaction Schema} We extend the basic event tuple $e_{s,k}$ to capture the full observational signal available to the platform. An interaction event is defined as the extended tuple: @@ -221,7 +232,6 @@ In addition to behavioral events, the platform logs price observations to a sepa To develop a robust pricing learner, we require a simulation environment capable of generating realistic, contaminated interaction data. We achieve this by learning from our Phantom platform data using a two-stage approach. - \subsubsection{GOFAI-Based Separability} We employ Good Old-Fashioned AI (GOFAI) heuristics to generate initial weak labels for separability. We define a set of rule-based predicates $\phi_j: \tau \to \{0, 1\}$ to partition the dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We construct distinct MDPs per each behavioral profile of humans and agents and from those we establish $D_{KL}$. From initial findings we compute a KL divergence of $\approx 2.0236$ across transition probabilities between states which can be seen in \ref{fig:human_mdp_viz} and \ref{fig:agent_mdp_viz}. From c4d82b2ecc62ce42ab57f00e86f7b361b7d72601 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 2 Feb 2026 16:55:06 +0100 Subject: [PATCH 08/36] rescaling the graph --- paper/src/chapters/figures/supra.tex | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/paper/src/chapters/figures/supra.tex b/paper/src/chapters/figures/supra.tex index 439f22e..290a2a1 100644 --- a/paper/src/chapters/figures/supra.tex +++ b/paper/src/chapters/figures/supra.tex @@ -3,6 +3,7 @@ view={0}{90}, % Top-down view for heatmap xlabel={Step}, ylabel={Price}, + ymin=90, colorbar, colorbar style={ title={Density}, @@ -13,14 +14,14 @@ enlargelimits=false, axis on top, width=0.9\columnwidth, - height=0.6\columnwidth, + height=0.5\columnwidth, ] - + \addplot3[ surf, - shader=flat, + shader=flat, mesh/check=false % Disable check to rely on empty lines ] table [col sep=comma, x=step, y=price, z=density] {chapters/figures/supra_data.csv}; - + \end{axis} \end{tikzpicture} From ebd23788591de3e7df88bdefccbba291b4265c1f Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 5 Feb 2026 12:28:26 +0100 Subject: [PATCH 09/36] yapping --- paper/src/chapters/03-methodology.tex | 48 +++++++++++---------------- 1 file changed, 20 insertions(+), 28 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index fd46e16..ddbcf3e 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -1,5 +1,8 @@ \section{Methodology} +% Extra notes and clarifications: we observed some humans and get their transition probabilities between event types +% We modify behavioral profiles of transition matrices with price elasticity matrices generated by sample valuations of a distributing. + This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven separability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets. \subsection{Problem Formalization} @@ -36,15 +39,17 @@ where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of \subsection{Cost of Information (COI) Framework} -The \textit{Cost of Information} (COI) represents the markup a pricing policy $\pi$ attempts to extract from the market by leveraging demand signals. We define COI as the expected premium over the minimum viable price $\underline{p}$ (or marginal cost). This also speaks to the financial urgency as a consequence of information asymmetry between the platform and the actors. +The platform's pricing power comes from information asymmetry: users who express strong interest signals pay more than the base price. We quantify this markup as the \textit{Cost of Information} (COI), which represents the average premium extracted above marginal cost. COI measures the revenue at risk when information asymmetry collapses. \begin{definition}[Cost of Information] Let $\pi(\tau)$ be a pricing policy mapping interaction histories to prices. The COI is defined as: -\begin{align} -\text{COI} &= \mathbb{E}[P] - \underline{p} \\ - &= \int_{\underline{p}}^{\bar{p}} (1 - F_\pi(p)) \, dp -\end{align} -where $F_\pi(p)$ is the cumulative distribution function of prices generated by $\pi$ under standard operating conditions. +\begin{equation} +\text{COI} = \mathbb{E}[P] - \underline{p} +\end{equation} +where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underline{p}$ is the minimum viable price (marginal cost). +% Alternative survival function representation (used in proof): +% COI = \int_{\underline{p}}^{\bar{p}} (1 - F_\pi(p)) \, dp +% where F_\pi(p) is the CDF of prices generated by \pi \end{definition} \begin{figure}[ht] @@ -86,37 +91,24 @@ Let $N$ be the number of independent, utility-maximizing agents querying the pla \end{theorem} \begin{proof} -Let $p_1, \ldots, p_N$ be independent and identically distributed (i.i.d.) price samples drawn from the policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. The realizable price for an optimal searching agent is the first order statistic $p_{(1)} = \min(p_1, \ldots, p_N)$. +Consider $N$ independent agents querying the platform, each receiving a price sample $p_i$ drawn from the pricing policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. A strategic agent conducting reconnaissance will select the minimum observed price: $p_{(1)} = \min(p_1, \ldots, p_N)$. -The survival function (or reliability function) of the minimum price is given by: +The probability that the minimum price exceeds some threshold $t$ is: \begin{equation} -S_{p_{(1)}}(t) = P(p_{(1)} > t) = [1 - F(t)]^N +P(p_{(1)} > t) = P(\text{all } p_i > t) = [1 - F(t)]^N \end{equation} -To determine the expected value $\mathbb{E}[p_{(1)}]$, we recall the property that for any continuous random variable $X$ with support $[A, B]$, the expectation can be expressed as the lower bound plus the integral of the survival function: +For any price $t > \underline{p}$, the CDF satisfies $F(t) > 0$, so $1 - F(t) < 1$. As $N$ grows, this probability decays exponentially: $[1 - F(t)]^N \to 0$. + +The expected minimum price can be written as: \begin{equation} -\mathbb{E}[X] = A + \int_{A}^{B} P(X > t) \, dt +\mathbb{E}[p_{(1)}] = \underline{p} + \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt \end{equation} -Applying this to our pricing statistic where the lower bound is $\underline{p}$: -\begin{align} -\mathbb{E}[p_{(1)}] &= \underline{p} + \int_{\underline{p}}^{\bar{p}} P(p_{(1)} > t) \, dt \\ -&= \underline{p} + \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt -\end{align} - -Since $F(t)$ is a valid CDF, for any $t > \underline{p}$, we have strict inequality $F(t) > 0$, implying $0 \le 1 - F(t) < 1$. By the properties of limits, as $N \to \infty$, the term $[1 - F(t)]^N$ converges to 0 pointwise for all $t > \underline{p}$. - -Applying the Lebesgue Dominated Convergence Theorem (noting that the integrand is bounded by 1 on the finite interval $[\underline{p}, \bar{p}]$): +Since the integrand vanishes as $N \to \infty$ for all $t > \underline{p}$, the integral converges to zero. Therefore: \begin{equation} -\lim_{N \to \infty} \int_{\underline{p}}^{\bar{p}} [1 - F(t)]^N \, dt = \int_{\underline{p}}^{\bar{p}} 0 \, dt = 0 +\lim_{N \to \infty} \text{COI} = \lim_{N \to \infty} (\mathbb{E}[p_{(1)}] - \underline{p}) = 0 \end{equation} - -Substituting this back into the expression for COI: -\begin{align} -\lim_{N \to \infty} \text{COI} &= \lim_{N \to \infty} (\mathbb{E}[p_{(1)}] - \underline{p}) \\ -&= \lim_{N \to \infty} \left( (\underline{p} + 0) - \underline{p} \right) \\ -&= 0 -\end{align} \end{proof} From e44feb7da0f976565de2c07de16a527f263dafc4 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 5 Feb 2026 12:47:13 +0100 Subject: [PATCH 10/36] updaing coi definition --- engine/lib/__init__.py | 2 +- engine/train.py | 2 +- engine/wrapper.py | 52 ++++++++++++++++++++---------------------- 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 0546a18..3d22207 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -4,4 +4,4 @@ from .render import DashboardRenderer, style_axis from .wrappers import EconomicMetricsWrapper from .callbacks import MetricsCallback, EvalMetricsCallback from .providers import ProviderBenchmark, ProviderResult, BenchmarkConfig -from .coi import compute_coi_leakage, compute_erosion_metrics, compute_agent_probability +from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability diff --git a/engine/train.py b/engine/train.py index ebb14f4..f733895 100644 --- a/engine/train.py +++ b/engine/train.py @@ -31,7 +31,7 @@ model.save("phantom_sac") wandb.finish() # test trained policy -env = PHANTOM(n_products=10, alpha=0.3, render_mode="human") +env = PHANTOM(n_products=10, alpha=0.3, render_mode=None) obs, _ = env.reset() for _ in range(100): action, _ = model.predict(obs, deterministic=True) diff --git a/engine/wrapper.py b/engine/wrapper.py index e435aeb..52ee5b6 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -4,8 +4,8 @@ import numpy as np from .engine import Limbo, MarketEngine, PricingEngine from .lib.render import DashboardRenderer from .lib.coi import ( - compute_coi_leakage, - compute_erosion_metrics, + compute_uplift_coi, + extract_purchases, compute_agent_probability, ) from .lib.behavior import get_transition_models, trajectory_to_events @@ -84,6 +84,7 @@ class PHANTOM(gym.Env): self._renderer = None self._initial_episode_prices = None self._trajectories = [] # session trajectories for agent prob calculation + self.baseline_prices = np.full(self.n_products, self.price_bounds[0]) # load behavioral models for agent probability estimation try: @@ -119,19 +120,30 @@ class PHANTOM(gym.Env): all_events, self._human_trans, self._agent_trans ) - def _compute_reward(self, prices: np.ndarray, demand: dict) -> float: - revenue = np.sum( - prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)]) - ) + def _compute_reward(self, prices: np.ndarray, demand: dict) -> tuple[float, dict]: + revenue = sum(prices[i] * demand.get(i, 0.0) for i in range(self.n_products)) - # compute agent probability from behavioral trajectories - agent_prob = self._compute_agent_prob() + trajs_mix = self.market.last_trajectories + purchases_mix = extract_purchases(trajs_mix) + coi_mix = compute_uplift_coi(prices, purchases_mix, self.baseline_prices) - # COI leakage: minimal implementation per thesis - coi_leakage = compute_coi_leakage(prices, agent_prob) + old_state = (self.market.alpha, self.market.Nagents, self.market.Nhumans) + self.market.alpha, self.market.Nagents, self.market.Nhumans = 0.0, 0, self.N + self.market.act(prices) + purchases_base = extract_purchases(self.market.last_trajectories) + coi_base = compute_uplift_coi(prices, purchases_base, self.baseline_prices) + self.market.alpha, self.market.Nagents, self.market.Nhumans = old_state + + coi_leakage = max(0.0, coi_base - coi_mix) coi_penalty = self.lambda_coi * coi_leakage - return float(revenue - coi_penalty) + return float(revenue - coi_penalty), { + "revenue": float(revenue), + "coi_mix": float(coi_mix), + "coi_base": float(coi_base), + "coi_leakage": float(coi_leakage), + "coi_penalty": float(coi_penalty), + } def _record_history(self): demand_arr = np.array( @@ -163,27 +175,13 @@ class PHANTOM(gym.Env): self._trajectories.extend(self.market.last_trajectories) agent_prob = self._compute_agent_prob() - coi_leakage = compute_coi_leakage(self._prices, agent_prob) - reward = self._compute_reward(self._prices, self._demand) + reward, metrics = self._compute_reward(self._prices, self._demand) terminated = self._step_count >= 100 - # legacy erosion metrics for comparison - erosion = compute_erosion_metrics( - self._price_history, - self._demand_history, - self._initial_episode_prices, - self._prices, - self.price_bounds, - self.alpha, - self.coi_window, - ) - info = { "step": self._step_count, "agent_prob": agent_prob, - "coi_leakage": coi_leakage, - "coi_penalty": self.lambda_coi * coi_leakage, - "erosion_metrics": erosion, + **metrics, "raw_revenue": np.sum( self._prices * np.array([self._demand.get(i, 0.0) for i in range(self.n_products)]) From e22286371f8ee5b1a0fdcdc957e6545d3b339162 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Fri, 6 Feb 2026 11:54:23 +0100 Subject: [PATCH 11/36] feat: proportiona lrevenu --- engine/wrapper.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/engine/wrapper.py b/engine/wrapper.py index 52ee5b6..fe1e6bb 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -135,7 +135,8 @@ class PHANTOM(gym.Env): self.market.alpha, self.market.Nagents, self.market.Nhumans = old_state coi_leakage = max(0.0, coi_base - coi_mix) - coi_penalty = self.lambda_coi * coi_leakage + coi_penalty = max(self.lambda_coi * coi_leakage, 1000) / 1000 + coi_penalty *= revenue return float(revenue - coi_penalty), { "revenue": float(revenue), From f6f97294249e4a74dcdb02d99dd7e6c2c727c928 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 10 Feb 2026 18:12:49 +0100 Subject: [PATCH 12/36] improving expression of ideas from dump --- paper/src/chapters/03-methodology.tex | 79 +++++++++++++++------------ 1 file changed, 45 insertions(+), 34 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index ddbcf3e..62c103e 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -147,21 +147,27 @@ p_{0,i} & \text{otherwise} where $p_0 \in \mathbb{R}^N$ is the base price vector (which is seeded into our database distinctly for each mode of the commerce platform), $\theta_{\text{high}}, \theta_{\text{low}} \in \mathbb{R}$ are demand thresholds defining surge and discount regions, and $\lambda_{\text{surge}}, \lambda_{\text{disc}} \in \mathbb{R}^+$ are multiplicative factors with typical values $\lambda_{\text{surge}} = 1.2$ and $\lambda_{\text{disc}} = 0.9$. This piecewise function enables rapid price adjustment in response to observed demand without requiring complex elasticity estimation or historical calibration, allowing us to expose actors within our experiments to a system with a dynamic component of pricing. -We will for our offilne experimental intents generalize a master function for encompasing distinct demand estimation and pricing strategies. +For our offline experimental setting, we generalize a master value function that can encompass different demand estimation and pricing strategies. \begin{align} V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]} \end{align} -We follow differnet substitutouns which will server as hyperparameters later on. +We evaluate different substitutions of this objective, which later serve as hyperparameters in the simulator. \subsection{Experimental Design} -The experimentation begins with the design of goals, with careful consideration to assure a uniform spanning across different variables within each product-architecture of either the hotel or airline platforms. Our crafted collection of goals (jobs to be done) is then tracked in a postgress database with one table to track goals and another table to track different experiment runs, and their associated goals in a experiment-goal one-to-one relationship. +We start from a practical constraint: we do not have access to proprietary production data. Because of that, we design our own fictional platform that still represents how commercial platforms work in the real world. The design comes from a survey of hotel and airline websites, where we extracted common interface components and used them as a high-level template for dynamic pricing environments. -The purpose of this effort to gather data on interactions, is the first half of our research. With this collected data on behavioral characteristics, enhanced by our feature augmentation, we can create distribution separation into two bins $y \in \{A,H\}$ with a certain probability $p$ dependent on the session-specific features. To address the second loop of our system, we use this gained capability of discrimination to enhance the learner design involved in our surrogate dynamic pricing task which simulates an independent dynamic pricing scenario under which we can train a more controlled policy with the ability to account for true demand signals under conditions of contamination from non-human actors. +The interface is organized as a product catalog where each product belongs to a time-bounded price vector (for example, a daily pricing period). During each period we collect interaction data by instrumenting UI components and predefined action templates that are still customizable. This gives us control without losing realism. -Our approach can be well summarized by a three-stage division, first we intend to observe and \textit{vectorize} the behavioral interaction data from our experiments, we then develop the separability which helps us deepen the semantic understanding of the behavioral patterns. Finally we use our newly gained learner to leverage a defensive mechanism within the simulation stage of a controlled dynamic pricing loop. +Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants. A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor. + +To evaluate quality and realism of the setup, we store both structured event logs and full interaction transcripts. This lets us combine quantitative analysis with transcript-level qualitative findings. The result is an isolated system where we can control the interaction process while preserving realistic behavior. + +Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, run table, and assignment mapping). This data-acquisition phase is the first half of the methodology and is intentionally a disconnected component that feeds the later contributions. The second half uses collected behavioral traces to separate classes $y \in \{A,H\}$ with session-conditioned probability estimates, then injects those estimates into the pricing learner. + +Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn separability to characterize human versus agent patterns, and (3) use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator. \begin{figure}[ht] \resizebox{\columnwidth}{!}{% @@ -170,15 +176,16 @@ Our approach can be well summarized by a three-stage division, first we intend t \caption{Overview of the Dynamic Pricing Tasks.} \end{figure} -Our web platform (developed in similar patterns as the RecSim by \textcite{ie_recsim_2019}) allows us to setup a controled environment in which we assign tasks to human and agentic actors which are then carried out. Each actor gets a browser assigned experiment identification which is persistent across possibly multiple session identifiers. We then group by experiments and extract all the session interactions (trajectories) which follow the schema formalized below. +Our web platform (developed in similar spirit to RecSim \parencite{ie_recsim_2019}) gives us a controlled environment where tasks are assigned to human and agentic actors and then executed. Each actor receives a browser-level experiment identifier that may persist across multiple session IDs. We then group by experiment and extract session trajectories using the schema below. -To speak to the quality and realism, in user interview, participants reported that the platform's architecture mirrored standard commercial booking interfaces, reducing the cognitive load required to learn the system. One participant noted the flow was 'intuitive' and indistinguishable from a 'normal' transaction, suggesting that observed behaviors were driven by the pricing variables rather than interface novelty. -The dynamic pricing mechanisms successfully elicited immediate behavioral adjustments. Participants demonstrated high sensitivity to price volatility, for instance, observing sudden price boosts triggered panic booking behaviors, while significant discrepancies between listing and final prices prompted heightened scrutiny and comparison behaviors. This is comforting as control for the data settings we gather is closely reflective of real life environments. +To speak to realism, user interviews reported that the platform architecture mirrored standard booking interfaces and reduced the cognitive load required to learn the system. One participant described the flow as ``intuitive'' and close to a ``normal'' transaction, suggesting observed behavior was primarily driven by pricing treatment rather than interface novelty. + +The dynamic pricing mechanism elicited immediate behavioral adjustments. Participants were sensitive to price volatility: sudden boosts triggered urgency and faster booking attempts, while large listing-to-final discrepancies triggered deeper comparison behavior. This is comforting because the controlled setup still produces commercially relevant interaction data. \subsubsection{Design of Training Factorial Study} -Since in our simulation we have different configurable factors such as the distributions from which we sample individual product valuations, how we parameterize the demand estimation and many more, we need to design a multi factor study. Current estimate is 4x4x3x2x2. This would normally be computationally prohibitive for reinforcement learning, we however have access to 300+ trillium TPU chips in a large cluster. +The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable. \subsubsection{Interaction Schema} @@ -221,11 +228,13 @@ In addition to behavioral events, the platform logs price observations to a sepa \subsection{Generative Contamination and Separability} -To develop a robust pricing learner, we require a simulation environment capable of generating realistic, contaminated interaction data. We achieve this by learning from our Phantom platform data using a two-stage approach. +To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from Phantom data using a two-stage approach. \subsubsection{GOFAI-Based Separability} -We employ Good Old-Fashioned AI (GOFAI) heuristics to generate initial weak labels for separability. We define a set of rule-based predicates $\phi_j: \tau \to \{0, 1\}$ to partition the dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We construct distinct MDPs per each behavioral profile of humans and agents and from those we establish $D_{KL}$. From initial findings we compute a KL divergence of $\approx 2.0236$ across transition probabilities between states which can be seen in \ref{fig:human_mdp_viz} and \ref{fig:agent_mdp_viz}. +We use Good Old-Fashioned AI (GOFAI) heuristics to generate weak labels for separability. A set of rule-based predicates $\phi_j: \tau \to \{0,1\}$ partitions dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We then estimate separate transition models for both groups and ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability? + +To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. \begin{definition}[Kullback-Leibler Divergence for Transition Distributions] Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is: @@ -235,20 +244,22 @@ Let $P_e$ and $Q_e$ be categorical distributions over destination states followi where $\mathcal{S}_e$ denotes the set of destination events that follow $e$ in the human trajectories. \end{definition} -To obtain this statistic we aggregate state transitions by their triggering event $e$ and treat the normalized outgoing probabilities as the categorical distributions $P_e$ (human) and $Q_e$ (agent). The computation intersects the event labels observed in both datasets, then iterates over each label and accumulates the log-ratio score. In practice this is implemented exactly as in models: for each destination $k$ we multiply the human probability by the log of the probability ratio and add the result to the running sum. Large contributions (including the case where $Q_e(k)$ is near zero) point to intents, such as rapid checkout or repeated navigation, that the agent policy fails to reproduce and therefore drive the contamination analysis. +To obtain this statistic, we aggregate transitions by triggering event $e$ and treat normalized outgoing probabilities as categorical distributions $P_e$ (human) and $Q_e$ (agent). We intersect shared event labels, then accumulate log-ratio contributions over shared destinations. Large contributions, including near-zero $Q_e(k)$ cases, identify transitions where one actor class is difficult to mimic. -With this divergence we train a contrastive learning method to estimate a weak probability of a given trajectory being an agent $f(\cdot) \to [0,1]$ which we can use as a leverage for a weighted sum. This is a first attempt at a more informed separability. +With these divergence features we train a contrastive model to estimate a weak agent probability $f(\tau)\in[0,1]$, which we later use as a weighting and control signal. \subsubsection{Transition Probability Estimation} \label{sec:tpe} - For both subsets, we model the session dynamics as a Markov Decision Process (MDP) and estimate the transition kernel $\mathcal{T}$. for each respective actor type we define $\hat{\mathcal{T}}_A$ and $\hat{\mathcal{T}}_H$ which are the general transition kernels subject to clustering into $\hat{\mathcal{T}}_y^i$ where $\forall i \in \text{behavioral clusters of } \hat{\mathcal{T}}_y$. This is done to avoid a lumping of all actor behavior and allows for more intral-class penalization. The probability of transitioning to state $s'$ given state $s$ is estimated via maximum likelihood: +For both subsets, we model session dynamics as an MDP and estimate transition kernel $\mathcal{T}$. For each actor type we estimate global kernels $\hat{\mathcal{T}}_A$ and $\hat{\mathcal{T}}_H$, then cluster into behavioral sub-kernels $\hat{\mathcal{T}}_y^i$ to avoid collapsing all behavior into one average profile. Transition probabilities are estimated by maximum likelihood: \begin{equation} \hat{P}(s' \mid s) = \frac{N(s, s')}{\sum_{k \in \mathcal{S}} N(s, k)} \end{equation} -where $N(s, s')$ is the count of observed transitions. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. In addition, given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from the learned transition matrix $\hat{P}_A$ until the effective mixing ratio reaches $\alpha$. From these transition probabilities we can observe an important feature which contributes to a differentiating assumption, which is that the mouse-behavior of an agent is almost non existent and therefore not utilized as a distinguishing factor both in the prior separability nor in any feature engineering. +where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. + +To scale this to catalog-level pricing, we lift the base event transition structure from $T\times T$ (event states only) to $(T\cdot N + C)\times(T\cdot N + C)$, where $N$ is catalog size and $C$ captures generic events (homepage, login, checkout terminal states). This construction lets demand and behavior be product-specific while preserving shared navigation transitions. \begin{figure}[ht] \centering @@ -265,15 +276,15 @@ where $N(s, s')$ is the count of observed transitions. This allows us to constru \end{figure} -\subsection{Stronger Classification} -We re-map the current event schema semantically to the event schema of another dataset. Our contaminated dataset is then used in another classifier where we can now also apply better feature engineering on other features while assigning correct lables to the entire dataset so the new dataset can be contaminated with $\mathcal{G}$ under some different contamination ratio $\alpha$. - -This new classified can then be used in the reinforcement learning reward structure. +\subsection{Second-Stage Classification} +After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure. \subsection{Distributionally Robust Reinforcement Learning (DR-RL)} -We formulate the pricing problem as a Stackelberg Game where the Platform (Leader) sets prices $p_t$ and the Aggregate Demand (Follower) responds. However, the exact mixing parameter $\alpha$ and the demand distribution shift are non-stationary and unknown in online settings. Relying on a simple error term $\epsilon$ is insufficient. Instead, we adopt a Distributionally Robust Optimization (DRO) objective. To formulate the entire dependency chain from the trajctory $\tau^\prime$ which is a newly observed trajectory observed by the platform and generated by an unknown actor type (sampled over a behavioral profile defined in section \ref{sec:tpe}). As part of the dynamic pricing we need a mapping of demand parameterized by a trajectory and a price $\hat{Q}(p, \tau^\prime)$. For an observed trajectory we compute a new $\hat{\mathcal{T}}^\prime$ and using a baseline controlled observations of both $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$ we can compute during inference time the following: +We formulate pricing as a Stackelberg game: the platform (leader) sets prices $p_t$, and the population (follower) responds through trajectories and demand. A useful intuition is that the platform behaves like a distorted mirror at a 45-degree angle: what it mirrors is population demand into an estimated demand proxy, and that proxy drives revenue. + +Because contamination level $\alpha$ and demand shift are non-stationary online, a simple error term is not enough. We therefore use a Distributionally Robust Optimization objective. Let $\tau'$ be a newly observed trajectory generated by an unknown actor profile (sampled from the behavioral models in Section~\ref{sec:tpe}). We need a demand mapping conditioned on price and trajectory, $\hat{Q}(p,\tau')$. For each $\tau'$, we compute $\hat{\mathcal{T}}'$ and compare it with controlled baselines $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$: \begin{align} \label{eq:delta_H} @@ -282,7 +293,9 @@ We formulate the pricing problem as a Stackelberg Game where the Platform (Leade \Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A) \end{align} -This creates two centroid-like heuristics which can on a per-session granularity basis guide our mixing paramtere $\alpha$. +This yields two centroid-like heuristics that guide contamination estimation at session granularity. + +In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack): leader moves (prices) are pushed first, follower responses (trajectory-derived demand proxies) are appended next, and updates are computed over this sequence. \subsubsection{Ambiguity Set Construction} We define an ambiguity set $\mathcal{U}_p(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: @@ -299,13 +312,19 @@ The robust policy $\pi^*$ is obtained by solving the maximin problem: \end{equation} where $R(p, d)$ is the revenue function and $\lambda$ weighs the penalty for information leakage (COI). We previously defined $\text{COI}$, however to properly connect this concept into the reward structure we need to define a parametrized version which informs us of the leakage of said structure with $\text{COI}(p)$. -Another proposed formulation of the optimal policy would be to adjust the ambiguity set dyanmically over the live computed divergence where $\epsilon(\Delta_H)$ to adjust the ball around or estimator according to each behavioral signal emited through a given trajctory. We state this as a possibility but do not peruse it due to literature suggesting that wesserstine methods do not require absolute continuity and are better with ``black swans'' \parencite{kuhn_wasserstein_2024}. +In practice, we parameterize this with a session-level leakage term: +\begin{equation} +\text{COI}_{\text{leak}}(p,\tau') = f(\tau')\cdot \text{InfoValue}(p,\tau') +\end{equation} +where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$. + +Another possible extension is to adapt the ambiguity radius online, e.g., $\epsilon(\Delta_H)$, so the Wasserstein ball changes with live divergence. We keep this as future work and retain a fixed-radius setup because Wasserstein ambiguity already handles heavy-tail and ``black swan'' behavior without absolute continuity assumptions \parencite{kuhn_wasserstein_2024}. \subsubsection{Actor Implementation} In our simulation, the ``follower'' is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. -As part of our reward engineering we think about the UX factor ($UX \in [0,1]$) whic his our proxy for user experience degradation, this is computed as a mixture of contribution from the separability model metric of $\frac{1}{\text{Specificity}}$. +As part of reward engineering, we include a UX factor ($UX\in[0,1]$) as a proxy for user-experience degradation. This is computed from separability-model calibration and specificity-sensitive penalties. \begin{figure}[ht] \centering @@ -315,7 +334,7 @@ As part of our reward engineering we think about the UX factor ($UX \in [0,1]$) \caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-like scale.} \end{figure} -We also need to think about a policy like taxation to the agents Strategy-Proof Mechanism Design, specifically the Vickrey-Clarke-Groves (VCG) payment rule. We link and prove that this would create an incentive for the dominant strategy to become truth-telling. +We also consider taxation-like overlays for agent traffic under strategy-proof mechanism design (e.g., Vickrey-Clarke-Groves style rules). This remains an extension path and is not part of the main implementation in this thesis. \subsubsection{Pricing Mechanism Summary} @@ -354,14 +373,6 @@ Initialize contamination estimate \(\hat\alpha \leftarrow 0.2\)\; \end{algorithm} -The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform publishes prices (leader move), observes the resulting session trajectories (follower response), and updates its contamination estimate based on behavioral divergence from the learned human and agent transition kernels $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$. The history buffer $\mathcal{L}$ (termed ``Limbo'' in our implementation) enforces the alternating Stackelberg structure by maintaining the temporal sequence of price publications and demand observations. +The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform publishes prices (leader move), observes resulting session trajectories (follower response), and updates contamination estimates based on divergence from learned human and agent kernels $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations. -%The defensive price update in Line 24 implements a contamination-aware margin shrinkage: as the estimated agent contamination $\hat{\alpha}_t$ increases, the margin $(p^{\mathrm{ref}} - c)$ is proportionally reduced by factor $\kappa \in [0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring prices remain within the feasible set $\mathcal{P}$. In subsequent experiments, this heuristic update is replaced by the DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}, which optimizes against the Wasserstein ambiguity set $\mathcal{U}_\epsilon$ rather than relying on a fixed margin adjustment rule. - -\section{Heuristics as part of neuro-inspired steering systems} - -Steve Burns, superior culliculus (face heuristics) we create this sort of part of the 'brain' + amortized inference. - -We could say that a DQN for example is the learnin subsystem and then within our reward mechanism or some other computational method we introduce a steering subsystem which acts as the proposed ``pricing heuristic'' against the given non human transaction data. - -\section{Market construction} +%The defensive price update in Line 24 implements contamination-aware margin shrinkage: as estimated contamination $\hat{\alpha}_t$ rises, the margin $(p^{\mathrm{ref}} - c)$ is reduced by factor $\kappa\in[0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring feasibility. In subsequent experiments this heuristic rule is replaced by DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}. From d1aa13360fe92225eedc75a0f298ca0c86b12be7 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Fri, 13 Feb 2026 21:03:02 +0100 Subject: [PATCH 13/36] cleaning refactors --- paper/src/chapters/02-literature-review.tex | 1 + paper/src/chapters/03-methodology.tex | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/paper/src/chapters/02-literature-review.tex b/paper/src/chapters/02-literature-review.tex index b75624a..5e788ed 100644 --- a/paper/src/chapters/02-literature-review.tex +++ b/paper/src/chapters/02-literature-review.tex @@ -50,6 +50,7 @@ Our effort to combat contamination stems from research by \textcite{hardt_strate To bridge the gap between detection and robust pricing, we look at work in Distributionally Robust Optimization (DRO). As defined by \textcite{kuhn_wasserstein_2024}, DRO provides a framework for decision-making under ambiguity, where the true data distribution is unknown but lies within a ``Wasserstein ball'' of a target distribution. In our context, the ``ambiguity set'' represents the uncertainty introduced by agentic reconnaissance. By optimizing for the worst-case distribution within this set, pricing mechanisms can become resilient to the distributional shifts such as the ones caused by non-human actors, effectively robustifying the revenue function against the contamination described in our problem statement. In order to create an environment in which prices can be tested against a demand estimate generated by some behavioral model, we take inspiration from the architecture proposed by \textcite{ie_recsim_2019} in the RecSim platform built for recommendation systems. By modeling the distinct user behavior as POMDPs we can generate faithful interactions which allow us to generalize, past the constraint which is also present in recommendation systems, of rarely having enough experience with individual actor's interactions for good recommendations without generalization. The key inspiration comes from the user choice modeling which we translate to a user transition model for each distinct actor type (agent or human). We further consider the possibility of modeling our quantitative research platform using dynamic Bayesian networks for the sake of tractability within the system. The contribution or RecSim enables researchers to better understand learning algorithms in fixed environments, a gap we identify as needing to be bridged within the space of dynamic pricing. +% TODO: mention https://github.com/meta-pytorch/OpenEnv/tree/main/envs/browsergym_env We also acknowledge the difficulty in similarly affected fields such as authorship, where \textcite{ganie_uncertainty_2025} demonstrate the theoretical limits of the distributional divergence between text authored by a human or large language model. Their approach of computing the divergence between two distributions demonstrates purely theoretically that no classifier can outperform random guessing on their particular task. This is yet another factor to take into consideration when exploring the potential mitigation strategies. diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 62c103e..df97695 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -259,7 +259,7 @@ For both subsets, we model session dynamics as an MDP and estimate transition ke \end{equation} where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. -To scale this to catalog-level pricing, we lift the base event transition structure from $T\times T$ (event states only) to $(T\cdot N + C)\times(T\cdot N + C)$, where $N$ is catalog size and $C$ captures generic events (homepage, login, checkout terminal states). This construction lets demand and behavior be product-specific while preserving shared navigation transitions. +To scale this to catalog-level pricing, we expand the base event transition matrix from $T\times T$ into product-specific transitions using the current demand condition. In practice, we normalize the demand vector across products and use it to weight how much transition mass each product pair receives. Concretely, each cell of the base matrix becomes an $N\times N$ block (for $N$ products), so the transition matrix grows from $T\times T$ to $(T\cdot N)\times(T\cdot N)$. Finally, we add $C$ generic states (homepage, login, checkout terminal states), which gives the full kernel size $(T\cdot N + C)\times(T\cdot N + C)$. \begin{figure}[ht] \centering From fba2a9739e372fee92de5ed43cb2f1256a0925e8 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 13:13:00 +0100 Subject: [PATCH 14/36] updating paper details --- paper/src/chapters/01-intro.tex | 9 +++++---- paper/src/main.tex | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/paper/src/chapters/01-intro.tex b/paper/src/chapters/01-intro.tex index 2df2f27..f5f2fe8 100644 --- a/paper/src/chapters/01-intro.tex +++ b/paper/src/chapters/01-intro.tex @@ -27,11 +27,12 @@ We formally define interaction data as coming from some actor which can either b \subsection{Research Questions} -This work addresses three core research questions: +This dissertation is organized around one main research question and three supporting sub-questions: \begin{enumerate} - \item[\textbf{RQ1}] \textit{Separability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting? - \item[\textbf{RQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems? - \item[\textbf{RQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination? + \item[\textbf{Main RQ}] How can dynamic pricing systems preserve margin integrity when transaction orchestration is increasingly mediated by non-human agents? + \item[\textbf{SQ1}] \textit{Separability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting? + \item[\textbf{SQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems? + \item[\textbf{SQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination? \end{enumerate} diff --git a/paper/src/main.tex b/paper/src/main.tex index 3680ac8..bcce09e 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -27,7 +27,7 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \noindent\textbf{Keywords:} Dynamic Pricing, LLM Agents, Adversarial Machine Learning, E-commerce, Behavioral Detection, Reinforcement Learning \vspace{1em} -\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program. +\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program, which provided access to Google Cloud TPU accelerators (including TPU v2/v3/v4). \clearpage \input{chapters/01-intro} From 895eea567452fa4674a3216f6dfd9868493e0c06 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 14:28:18 +0100 Subject: [PATCH 15/36] imporving methodology and adding onto it --- paper/src/chapters/03-methodology.tex | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index df97695..aca63f2 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -86,13 +86,21 @@ where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underlin We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes. +\begin{assumption} + A fundamental assumption for our claim lays in the alignment of the AI agent through it's prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective. +\end{assumption} + \begin{theorem}[COI Erosion in the Limit] Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0. \end{theorem} + + + + \begin{proof} Consider $N$ independent agents querying the platform, each receiving a price sample $p_i$ drawn from the pricing policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. A strategic agent conducting reconnaissance will select the minimum observed price: $p_{(1)} = \min(p_1, \ldots, p_N)$. - +% support here means that its the range of possible outputs. The probability that the minimum price exceeds some threshold $t$ is: \begin{equation} P(p_{(1)} > t) = P(\text{all } p_i > t) = [1 - F(t)]^N @@ -112,7 +120,7 @@ Since the integrand vanishes as $N \to \infty$ for all $t > \underline{p}$, the \end{proof} -This result proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a robust counter-mechanism. +This result naively proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a robust counter-mechanism. % The DRO objective creates a lower bound on COI extraction, effectively guaranteeing a minimum margin even in the presence of adversarial agents. we need to prove this and demonstrate that in a theorem. @@ -126,10 +134,12 @@ In order for our research to have grounding in interactions we built a robust e- The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise. +We transition the Kappa like architecture of the data collection to a Lambda system for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. + \subsubsection{DevOps Principles} - +Reproducible results are key to quality research platforms, this is taken into mind when deploying and working with our research platform. From a deployment standpoint the platform can be deployed across a large variety of providers and can be run locally. When developing a new interaction modality apart from the ones that come out of the box, a simple template pattern can be followed. The middleware of the framework is designed to properly render the chosen modality from environmental variables, thus deployment of different or parallel version of the software can be easily parametrized. \subsubsection{Online Dynamic Pricing} @@ -185,7 +195,7 @@ The dynamic pricing mechanism elicited immediate behavioral adjustments. Partici \subsubsection{Design of Training Factorial Study} -The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable. +The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid estimate: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable and logged with services provided by weights and biases. \subsubsection{Interaction Schema} @@ -279,6 +289,8 @@ To scale this to catalog-level pricing, we expand the base event transition matr \subsection{Second-Stage Classification} After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure. +Now might be a good time to stand up and go for a quick walk before returning to the rest of this paper. + \subsection{Distributionally Robust Reinforcement Learning (DR-RL)} @@ -318,11 +330,14 @@ In practice, we parameterize this with a session-level leakage term: \end{equation} where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$. + Another possible extension is to adapt the ambiguity radius online, e.g., $\epsilon(\Delta_H)$, so the Wasserstein ball changes with live divergence. We keep this as future work and retain a fixed-radius setup because Wasserstein ambiguity already handles heavy-tail and ``black swan'' behavior without absolute continuity assumptions \parencite{kuhn_wasserstein_2024}. \subsubsection{Actor Implementation} In our simulation, the ``follower'' is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. +Practical implementation of interactions of agent with web environment is a strongly evolving field with near weekly releases of SOTA architectures. An agent we develop uses Playwright to + As part of reward engineering, we include a UX factor ($UX\in[0,1]$) as a proxy for user-experience degradation. This is computed from separability-model calibration and specificity-sensitive penalties. From bc6c481d034081b6a60c7b944305f86c757e4eac Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 14:53:30 +0100 Subject: [PATCH 16/36] minor refactors to codebase to implement DRO --- .gitignore | 1 + engine/engine.py | 12 +++- engine/lib/__init__.py | 2 +- engine/lib/demand.py | 76 ++++++++++++++++---- engine/train.py | 26 +++++-- engine/wrapper.py | 153 +++++++++++++++++++++++++++-------------- 6 files changed, 195 insertions(+), 75 deletions(-) diff --git a/.gitignore b/.gitignore index 19bb041..f18e3d4 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ sim/rl/behavior_loader/*.pdf tests/e2e/node_modules/** lab/case/thesis/runs*/ sim/case/thesis_simplified/runs*/ +PHANTOM_web/* diff --git a/engine/engine.py b/engine/engine.py index 000f03f..8ca6339 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -19,15 +19,18 @@ class MarketEngine: agent_params: tuple, demand_distribution=np.random.normal, noise_std: float = 1.0, + action_weights: dict | None = None, ): # no defaults for D_H, D_A - force explicit experiment design self.alpha = alpha + self.N = int(N) self.Nagents = int(N * alpha) self.Nhumans = int(N * (1 - alpha)) self.human_params = human_params self.agent_params = agent_params self.noise_std = noise_std self.demand_dist = demand_distribution + self.action_weights = action_weights def act(self, prices): # generate separate demands d() per actor type @@ -48,7 +51,7 @@ class MarketEngine: agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)] # store trajectories for agent probability calculation self.last_trajectories = human_t + agent_t - return estimate_demand(self.last_trajectories) + return estimate_demand(self.last_trajectories, self.action_weights) def measure(self): pass @@ -72,13 +75,16 @@ class Limbo: self.output = None def step(self): - # we could code golf this a little bit if self.platform_turn: self.output = self.platform.act(self.output) else: self.output = self.market.act(self.output) - print(self.output) self.platform_turn = not self.platform_turn + return self.output + + def reset(self): + self.platform_turn = True + self.output = None if __name__ == "__main__": diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 3d22207..04a06c2 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -1,4 +1,4 @@ -from .demand import estimate_demand, generate_demand_for_actor +from .demand import estimate_demand, estimate_weighted_demand, generate_demand_for_actor from .behavior import sample_behavior, get_transition_models, trajectory_to_events from .render import DashboardRenderer, style_axis from .wrappers import EconomicMetricsWrapper diff --git a/engine/lib/demand.py b/engine/lib/demand.py index d9f7edb..cb37c3d 100644 --- a/engine/lib/demand.py +++ b/engine/lib/demand.py @@ -1,9 +1,23 @@ -import logging import numpy as np -from logging import getLogger -logger = getLogger(__name__) -def generate_demand_for_actor(prices: np.ndarray, params: tuple, noise_std: float = 1.0, distribution_method=np.random.normal) -> np.ndarray: +CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5} +ACTION_CATEGORIES = { + "cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"}, + "dwell": {"hover_title", "hover_paragraph", "hover_link"}, + "nav": {"page_view", "view_item", "view", "learn_more"}, + "filter": {"search", "filter_date", "filter_price", "sort"}, +} +DEFAULT_ACTION_WEIGHTS = { + a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions +} + + +def generate_demand_for_actor( + prices: np.ndarray, + params: tuple, + noise_std: float = 1.0, + distribution_method=np.random.normal, +) -> np.ndarray: """d(p;0) = max(0, valuation - price) + epsi for single actor type params: (mean, std) for valuation distribution D_H or D_A""" val = distribution_method(*params, size=len(prices)) @@ -13,17 +27,50 @@ def generate_demand_for_actor(prices: np.ndarray, params: tuple, noise_std: floa return demand / total * 100 if total > 0 else demand -def estimate_demand(trajectories): - demand_estimate = {} +def estimate_demand(trajectories, action_weights=None): + return estimate_weighted_demand(trajectories, action_weights) + + +def _parse_event_state(state: str): + if "_product" not in state: + return state, None + action, raw_pid = state.rsplit("_product", 1) + return action, int(raw_pid) if raw_pid.isdigit() else None + + +def _weight_for_action(action: str, action_weights: dict) -> float: + if action in action_weights: + return action_weights[action] + if action.startswith("hover"): + return CATEGORY_WEIGHTS["dwell"] + if action.startswith("filter") or action in {"search", "sort"}: + return CATEGORY_WEIGHTS["filter"] + if action.startswith("add") or action in {"checkout", "purchase", "remove"}: + return CATEGORY_WEIGHTS["cart"] + return CATEGORY_WEIGHTS["nav"] + + +def estimate_weighted_demand(trajectories, action_weights=None): + action_weights = ( + DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights + ) + scores = {} for traj in trajectories: - for event in traj: - if 'view_product' in event: - product_id = int(event.split('_')[-1].replace('product', '')) - demand_estimate[product_id] = demand_estimate.get(product_id, 0) + 1 - total_views = sum(demand_estimate.values()) - for product_id in demand_estimate: - demand_estimate[product_id] = (demand_estimate[product_id] / total_views) * 100 # normalize to percentage - return demand_estimate + for state in traj: + action, product_id = _parse_event_state(state) + if product_id is None: + continue + w = _weight_for_action(action, action_weights) + if w <= 0: + continue + scores[product_id] = scores.get(product_id, 0.0) + w + total = sum(scores.values()) + return ( + {pid: (score / total) * 100 for pid, score in scores.items()} + if total > 0 + else {} + ) + # Example usage if __name__ == "__main__": @@ -36,6 +83,7 @@ if __name__ == "__main__": print("Human Demand:", demand_h) print("Agent Demand:", demand_a) from .behavior import sample_behavior + N, alpha = 200, 0.3 n_h, n_a = int(N * (1 - alpha)), int(N * alpha) human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)] diff --git a/engine/train.py b/engine/train.py index f733895..00aa274 100644 --- a/engine/train.py +++ b/engine/train.py @@ -6,11 +6,26 @@ from .lib import EconomicMetricsWrapper, MetricsCallback wandb.init( project="phantom-pricing", - config={"alpha": 0.3, "n_products": 10, "total_timesteps": 50000} + config={ + "alpha": 0.3, + "n_products": 10, + "total_timesteps": 50000, + "robust_radius": 0.15, + "robust_points": 5, + "lambda_coi": 0.2, + }, ) -env = EconomicMetricsWrapper(PHANTOM(n_products=10, alpha=0.3, render_mode=None)) -eval_env = EconomicMetricsWrapper(PHANTOM(n_products=10, alpha=0.3, render_mode=None)) +env_kwargs = { + "n_products": 10, + "alpha": 0.3, + "lambda_coi": 0.2, + "robust_radius": 0.15, + "robust_points": 5, + "render_mode": None, +} +env = EconomicMetricsWrapper(PHANTOM(**env_kwargs)) +eval_env = EconomicMetricsWrapper(PHANTOM(**env_kwargs)) model = SAC( "MultiInputPolicy", @@ -31,11 +46,12 @@ model.save("phantom_sac") wandb.finish() # test trained policy -env = PHANTOM(n_products=10, alpha=0.3, render_mode=None) +env = PHANTOM(**env_kwargs) obs, _ = env.reset() for _ in range(100): action, _ = model.predict(obs, deterministic=True) obs, reward, term, trunc, _ = env.step(action) env.render() - if term or trunc: break + if term or trunc: + break env.close() diff --git a/engine/wrapper.py b/engine/wrapper.py index fe1e6bb..e1ea79b 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -12,11 +12,23 @@ from .lib.behavior import get_transition_models, trajectory_to_events from .lib.wrappers import EconomicMetricsWrapper +class _ActionPricingEngine(PricingEngine): + def __init__(self, n_products: int, price_bounds: tuple): + self._prices = np.full(n_products, price_bounds[0], dtype=float) + + def set_prices(self, prices: np.ndarray): + self._prices = np.asarray(prices, dtype=float) + + def act(self, _): + return self._prices + + class PHANTOM(gym.Env): """Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL COI_leak uses behavioral divergence to estimate agent probability f(τ') + robust inner step: min over alpha in Wasserstein interval around nominal alpha """ metadata = {"render_modes": ["human", "ansi"]} @@ -32,6 +44,9 @@ class PHANTOM(gym.Env): price_bounds: tuple = (10.0, 150.0), lambda_coi: float = 0.1, coi_window: int = 10, + robust_radius: float = 0.0, + robust_points: int = 5, + info_value: float = 1.0, render_mode: str = None, ): super().__init__() @@ -40,10 +55,14 @@ class PHANTOM(gym.Env): self.lambda_coi = lambda_coi self.coi_window = coi_window self.render_mode = render_mode - self.alpha = alpha + self.alpha = float(alpha) + self.nominal_alpha = float(alpha) self.N = N self.human_params = human_params self.agent_params = agent_params + self.robust_radius = max(0.0, float(robust_radius)) + self.robust_points = max(1, int(robust_points)) + self.info_value = float(info_value) self.market = MarketEngine( alpha=alpha, @@ -52,8 +71,9 @@ class PHANTOM(gym.Env): agent_params=agent_params, noise_std=noise_std, ) - self._platform_stub = PricingEngine() + self._platform_stub = _ActionPricingEngine(n_products, price_bounds) self._limbo = Limbo(self._platform_stub, self.market) + self._set_market_mix(self.nominal_alpha) self.action_space = spaces.Box( low=price_bounds[0], @@ -99,53 +119,72 @@ class PHANTOM(gym.Env): ) return {"demand": demand_arr, "prices": self._prices.astype(np.float32)} - def _compute_agent_prob(self) -> float: - """estimate agent probability from accumulated trajectories using KL divergence""" - if ( - not self._trajectories - or self._human_trans is None - or self._agent_trans is None - ): - return self.alpha # fallback to contamination level + def _set_market_mix(self, alpha: float): + alpha = float(np.clip(alpha, 0.0, 1.0)) + n_agents = int(self.N * alpha) + self.alpha = alpha + self.market.alpha = alpha + self.market.Nagents = n_agents + self.market.Nhumans = self.N - n_agents - # aggregate all trajectories from this episode - all_events = [] - for traj in self._trajectories: - all_events.extend(trajectory_to_events(traj)) - - if len(all_events) < 2: - return self.alpha - - return compute_agent_probability( - all_events, self._human_trans, self._agent_trans + def _compute_agent_prob(self, trajectories=None) -> float: + trajectories = ( + self.market.last_trajectories if trajectories is None else trajectories ) + if not trajectories or self._human_trans is None or self._agent_trans is None: + return float(self.market.alpha) + probs = [] + for traj in trajectories: + events = trajectory_to_events(traj) + if len(events) < 2: + continue + probs.append( + compute_agent_probability(events, self._human_trans, self._agent_trans) + ) + return float(np.mean(probs)) if probs else float(self.market.alpha) - def _compute_reward(self, prices: np.ndarray, demand: dict) -> tuple[float, dict]: - revenue = sum(prices[i] * demand.get(i, 0.0) for i in range(self.n_products)) - - trajs_mix = self.market.last_trajectories - purchases_mix = extract_purchases(trajs_mix) - coi_mix = compute_uplift_coi(prices, purchases_mix, self.baseline_prices) - - old_state = (self.market.alpha, self.market.Nagents, self.market.Nhumans) - self.market.alpha, self.market.Nagents, self.market.Nhumans = 0.0, 0, self.N - self.market.act(prices) - purchases_base = extract_purchases(self.market.last_trajectories) - coi_base = compute_uplift_coi(prices, purchases_base, self.baseline_prices) - self.market.alpha, self.market.Nagents, self.market.Nhumans = old_state - - coi_leakage = max(0.0, coi_base - coi_mix) - coi_penalty = max(self.lambda_coi * coi_leakage, 1000) / 1000 - coi_penalty *= revenue - + def _compute_reward( + self, prices: np.ndarray, demand: dict, agent_prob: float, trajectories: list + ) -> tuple[float, dict]: + demand_arr = np.array( + [demand.get(i, 0.0) for i in range(self.n_products)], dtype=float + ) + revenue = float(np.dot(prices, demand_arr)) + purchases = extract_purchases(trajectories) + coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices) + coi_leakage = float(agent_prob * self.info_value) + coi_penalty = float(self.lambda_coi * coi_leakage) return float(revenue - coi_penalty), { - "revenue": float(revenue), + "revenue": revenue, "coi_mix": float(coi_mix), - "coi_base": float(coi_base), - "coi_leakage": float(coi_leakage), - "coi_penalty": float(coi_penalty), + "coi_base": 0.0, + "coi_leakage": coi_leakage, + "coi_penalty": coi_penalty, } + def _alpha_candidates(self) -> np.ndarray: + if self.robust_radius <= 0.0 or self.robust_points == 1: + return np.array([self.nominal_alpha], dtype=float) + lo = max(0.0, self.nominal_alpha - self.robust_radius) + hi = min(1.0, self.nominal_alpha + self.robust_radius) + return np.linspace(lo, hi, self.robust_points) + + def _select_adversarial_alpha(self, prices: np.ndarray) -> float: + candidates = self._alpha_candidates() + if len(candidates) == 1: + return float(candidates[0]) + best_alpha, worst_reward = float(candidates[0]), np.inf + for alpha in candidates: + self._set_market_mix(float(alpha)) + demand = self.market.act(prices) + trajectories = self.market.last_trajectories + agent_prob = self._compute_agent_prob(trajectories) + reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories) + if reward < worst_reward: + worst_reward = reward + best_alpha = float(alpha) + return best_alpha + def _record_history(self): demand_arr = np.array( [self._demand.get(i, 0.0) for i in range(self.n_products)] @@ -156,32 +195,42 @@ class PHANTOM(gym.Env): def reset(self, seed=None, options=None): super().reset(seed=seed) + self._set_market_mix(self.nominal_alpha) + self._limbo.reset() self._prices = np.random.uniform(*self.price_bounds, size=self.n_products) + self._platform_stub.set_prices(self._prices) + self._limbo.step() + self._demand = self._limbo.step() self._initial_episode_prices = self._prices.copy() - self._demand = self.market.act(self._prices) self._step_count = 0 self._demand_history, self._price_history, self._revenue_history = [], [], [] - self._trajectories = [] + self._trajectories = list(getattr(self.market, "last_trajectories", [])) self._record_history() return self._get_obs(), {} def step(self, action: np.ndarray): self._prices = np.clip(action, *self.price_bounds) - self._demand = self.market.act(self._prices) + alpha_adv = self._select_adversarial_alpha(self._prices) + self._set_market_mix(alpha_adv) + self._platform_stub.set_prices(self._prices) + self._limbo.step() + self._demand = self._limbo.step() + trajectories = getattr(self.market, "last_trajectories", []) self._step_count += 1 + self._trajectories.extend(trajectories) + + agent_prob = self._compute_agent_prob(trajectories) + reward, metrics = self._compute_reward( + self._prices, self._demand, agent_prob, trajectories + ) self._record_history() - - # capture trajectories generated by market for agent prob estimation - if hasattr(self.market, "last_trajectories"): - self._trajectories.extend(self.market.last_trajectories) - - agent_prob = self._compute_agent_prob() - reward, metrics = self._compute_reward(self._prices, self._demand) terminated = self._step_count >= 100 info = { "step": self._step_count, "agent_prob": agent_prob, + "alpha_adv": float(alpha_adv), + "wasserstein_radius": float(self.robust_radius), **metrics, "raw_revenue": np.sum( self._prices From e8229ac3138c42daa36aa0d4d4b598c3a07d43d9 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 15:20:38 +0100 Subject: [PATCH 17/36] updating methodology with better refelction --- paper/src/chapters/03-methodology.tex | 43 ++++++++++++++++++++------- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index aca63f2..d2bc554 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -27,6 +27,12 @@ The platform does not directly observe the true underlying demand function $d(p) \end{equation} where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay. +In the current engine implementation, we use the normalized variant of this proxy for each step: +\begin{equation} +\tilde q_{t,i} = 100 \cdot \frac{\hat q_{t,i}}{\sum_{j=1}^{N}\hat q_{t,j} + \varepsilon} +\end{equation} +with fixed category-level weights (cart, dwell, nav, filter) following the same rank order from Table~\ref{tab:action_space}. This keeps the signal dense and directly usable in the simulator. + \subsubsection{Actor Types and Demand Curves} We formalize the heterogeneity of actors by introducing a type space $\Theta$. An actor of class $Y_s$ is further parameterized by a type $\theta \sim \mathcal{D}_{Y}$. This type determines the actor's demand response function $d(p; \theta)$, sampled from a distribution of possible demand curves. The total observed demand is a stochastic process governed by the naively defined mixture: \begin{equation} @@ -231,6 +237,8 @@ $\mathcal{A}_{\text{filter}}$ & \texttt{search}, \texttt{filter\_date}, \texttt{ This partition enables the weight function $\omega$ from Eq.~\ref{eq:qhat} to assign category-specific signal strengths, with $\omega(\mathcal{A}_{\text{cart}}) > \omega(\mathcal{A}_{\text{dwell}}) > \omega(\mathcal{A}_{\text{nav}}) > \omega(\mathcal{A}_{\text{filter}})$ reflecting decreasing commitment. +In the simulator baseline this order is encoded with a compact fixed scale: cart $=4.0$, dwell $=2.0$, nav $=1.0$, filter $=0.5$. Unknown actions are mapped by prefix heuristics to the nearest category. + The metadata record $\mu$ varies by action type. For product views, $\mu$ contains the observed price $p_{\text{obs}}$ and product attributes. For dwell events, $\mu$ includes the element text and accumulated hover duration. This heterogeneous structure is captured via a schema-on-read approach in our Kafka ingestion pipeline, where events are validated against type-specific schemas before storage. In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response. @@ -289,8 +297,6 @@ To scale this to catalog-level pricing, we expand the base event transition matr \subsection{Second-Stage Classification} After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure. -Now might be a good time to stand up and go for a quick walk before returning to the rest of this paper. - \subsection{Distributionally Robust Reinforcement Learning (DR-RL)} @@ -307,22 +313,28 @@ Because contamination level $\alpha$ and demand shift are non-stationary online, This yields two centroid-like heuristics that guide contamination estimation at session granularity. -In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack): leader moves (prices) are pushed first, follower responses (trajectory-derived demand proxies) are appended next, and updates are computed over this sequence. +In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move). \subsubsection{Ambiguity Set Construction} -We define an ambiguity set $\mathcal{U}_p(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: +We define an ambiguity set $\mathcal{U}_\epsilon(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: \begin{equation} \mathcal{U}_\epsilon(\hat{P}_N) = \left\{ Q \in \mathcal{P}(\Xi) : W_p(Q, \hat{P}_N) \le \epsilon \right\} \end{equation} This set captures all distributions that are statistically close to our observed training data but allows for adversarial shifts. +For the current engine baseline, we use a compact inner-robust approximation by applying ambiguity over contamination in a local interval around nominal contamination $\alpha_0$: +\begin{equation} +\mathcal{A}_{\epsilon_\alpha}(\alpha_0)=\left\{\alpha\in[0,1]:\lvert\alpha-\alpha_0\rvert\le\epsilon_\alpha\right\} +\end{equation} +and we evaluate a small fixed grid in $\mathcal{A}_{\epsilon_\alpha}(\alpha_0)$ per step, selecting the worst-case candidate for the learner. + \subsubsection{The Min-Max Objective} The robust policy $\pi^*$ is obtained by solving the maximin problem: \begin{equation} \label{eq:robust_policy} -\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}(p) \right] +\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}_{\text{leak}}(p,\tau') \right] \end{equation} -where $R(p, d)$ is the revenue function and $\lambda$ weighs the penalty for information leakage (COI). We previously defined $\text{COI}$, however to properly connect this concept into the reward structure we need to define a parametrized version which informs us of the leakage of said structure with $\text{COI}(p)$. +where $R(p, d)$ is the revenue function and $\lambda$ weighs the information-leakage penalty. In practice, we parameterize this with a session-level leakage term: \begin{equation} @@ -330,16 +342,22 @@ In practice, we parameterize this with a session-level leakage term: \end{equation} where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$. +For the baseline engine reported here, we intentionally use the constant query-tax surrogate to keep the mechanism minimal: +\begin{equation} +r_t = R(p_t,\tilde q_t) - \lambda\,f(\tau_t')\,c_{\text{info}} +\end{equation} +with fixed $c_{\text{info}}>0$. + Another possible extension is to adapt the ambiguity radius online, e.g., $\epsilon(\Delta_H)$, so the Wasserstein ball changes with live divergence. We keep this as future work and retain a fixed-radius setup because Wasserstein ambiguity already handles heavy-tail and ``black swan'' behavior without absolute continuity assumptions \parencite{kuhn_wasserstein_2024}. \subsubsection{Actor Implementation} In our simulation, the ``follower'' is implemented as a set of Actors. Each Actor is initialized with a type $\theta$ which samples a specific demand curve $d(p; \theta)$ from the latent distribution. This formalization ensures that our DR-RL agent does not overfit to a single deterministic demand function but learns a policy robust to the distributional uncertainty defined by $\mathcal{U}_\epsilon$. -Practical implementation of interactions of agent with web environment is a strongly evolving field with near weekly releases of SOTA architectures. An agent we develop uses Playwright to +Practical implementation of browser agents is a strongly evolving field with near-weekly releases of SOTA architectures. In this thesis implementation we abstract that layer into trajectory generators learned from observed human/agent transition kernels. -As part of reward engineering, we include a UX factor ($UX\in[0,1]$) as a proxy for user-experience degradation. This is computed from separability-model calibration and specificity-sensitive penalties. +As part of reward engineering, we keep a UX factor ($UX\in[0,1]$) as an auxiliary evaluation axis. In the current baseline it is not injected into the core reward; it is tracked separately to compare policy trade-offs. \begin{figure}[ht] \centering @@ -362,7 +380,8 @@ We now present the complete pricing mechanism that integrates the behavioral sep \SetKwInOut{Input}{Input}\SetKwInOut{Output}{Output} \Input{catalog size \(N\); costs \(c\); reference prices \(p^{ref}\); behavior models \(\bar T_H,\bar T_A\); -action weights \(\omega\); penalty \(\lambda\); horizon \(T\); sessions per step \(M\)} +action weights \(\omega\); penalty \(\lambda\); nominal contamination \(\alpha_0\); ambiguity radius \(\epsilon_\alpha\); +candidate count \(K\); horizon \(T\); sessions per step \(M\)} \Output{price/demand trajectory \(\{(p_t,\hat Q_t,\hat\alpha_t)\}_{t=0}^{T-1}\)} Initialize contamination estimate \(\hat\alpha \leftarrow 0.2\)\; @@ -383,7 +402,11 @@ Initialize contamination estimate \(\hat\alpha \leftarrow 0.2\)\; \tcp{Estimate contamination from behavioral separability} compute \(\hat\alpha \leftarrow \frac{1}{M}\sum_{\tau\in\mathcal S_t} \Big[\sigma\big(\beta(\Delta_H(\tau)-\Delta_A(\tau))\big)\Big]\)\; - compute \(J_t \leftarrow \text{Revenue}(p_t,\hat Q_t) - \lambda\cdot \text{COILeak}(\hat\alpha)\)\; + \tcp{Inner robust step over local ambiguity interval} + define \(\mathcal{A}_{\epsilon_\alpha}(\alpha_0)\) and sample \(K\) candidates\; + pick \(\alpha_t^* \leftarrow \arg\min_{\alpha\in\mathcal{A}_{\epsilon_\alpha}(\alpha_0)} \Big[\text{Revenue}(p_t,\hat Q_t^{\alpha}) - \lambda\cdot \text{COI}_{\text{leak}}(p_t,\tau_t^{\alpha})\Big]\)\; + + compute \(J_t \leftarrow \text{Revenue}(p_t,\hat Q_t^{\alpha_t^*}) - \lambda\cdot \text{COI}_{\text{leak}}(p_t,\tau_t^{\alpha_t^*})\)\; } \end{algorithm} From d7657db2871090b7c03502ad4740d531ab9dc7f6 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 21:49:40 +0100 Subject: [PATCH 18/36] reintroducing our note :) --- paper/src/chapters/03-methodology.tex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index d2bc554..fef7957 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -297,6 +297,8 @@ To scale this to catalog-level pricing, we expand the base event transition matr \subsection{Second-Stage Classification} After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure. +Now might be a good time to stand up and go for a quick walk before returning to the rest of this paper. + \subsection{Distributionally Robust Reinforcement Learning (DR-RL)} From ef1d1f65575650e1b3b7d3a417db3a0885ad69e7 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sat, 14 Feb 2026 21:54:42 +0100 Subject: [PATCH 19/36] fixing assumption definition --- paper/src/chapters/03-methodology.tex | 2 -- 1 file changed, 2 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index fef7957..9258a80 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -92,9 +92,7 @@ where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underlin We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes. -\begin{assumption} A fundamental assumption for our claim lays in the alignment of the AI agent through it's prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective. -\end{assumption} \begin{theorem}[COI Erosion in the Limit] Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0. From 2b47c3499aec11d4a513e107708dd57237d384ed Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 15:45:46 +0100 Subject: [PATCH 20/36] chore: fixing discretization of actions --- engine/lib/__init__.py | 1 + engine/lib/behavior.py | 17 +- engine/train.py | 437 +++++++++++++++++++++++--- engine/wrapper.py | 34 +- paper/src/chapters/03-methodology.tex | 2 + 5 files changed, 436 insertions(+), 55 deletions(-) diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 04a06c2..2a56747 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -5,3 +5,4 @@ from .wrappers import EconomicMetricsWrapper from .callbacks import MetricsCallback, EvalMetricsCallback from .providers import ProviderBenchmark, ProviderResult, BenchmarkConfig from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability +from .discrete import EventQTable diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index 34faad2..e8fe2be 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -70,7 +70,14 @@ def trajectory_to_events(trajectory: list) -> list: def adjust_behavior_to_condition(condition, transition_matrix): # expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition - cond_norm = condition / np.sum(condition) + condition = np.asarray(condition, dtype=float) + condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0) + condition = np.clip(condition, 0.0, None) + s = float(np.sum(condition)) + if not np.isfinite(s) or s <= 0: + cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float) + else: + cond_norm = condition / s n_products = len(condition) base_vals = transition_matrix.values base_cols, base_rows = ( @@ -91,10 +98,12 @@ def sample_behavior(condition, human=True, max_len=40): trajectory = [np.random.choice(adjusted_transitions.index)] while len(trajectory) < max_len and "checkout" not in trajectory[-1]: - probs = adjusted_transitions.loc[trajectory[-1]].values + probs = np.asarray(adjusted_transitions.loc[trajectory[-1]].values, dtype=float) + probs = np.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0) + probs = np.clip(probs, 0.0, None) + s = float(np.sum(probs)) sample = np.random.choice( - adjusted_transitions.columns, - p=probs / np.sum(probs) if np.sum(probs) > 0 else None, + adjusted_transitions.columns, p=(probs / s) if s > 0 else None ) trajectory.append(sample) return trajectory diff --git a/engine/train.py b/engine/train.py index 00aa274..e059593 100644 --- a/engine/train.py +++ b/engine/train.py @@ -1,57 +1,408 @@ -import wandb -from stable_baselines3 import SAC -from stable_baselines3.common.callbacks import EvalCallback +import argparse +import json +from pathlib import Path +import numpy as np +from gymnasium.wrappers import FlattenObservation + +try: + import wandb + + HAS_WANDB = True +except ImportError: + HAS_WANDB = False + +try: + from stable_baselines3 import PPO, A2C, DQN + from stable_baselines3.common.callbacks import EvalCallback + from stable_baselines3.common.monitor import Monitor + + HAS_SB3 = True +except ImportError: + HAS_SB3 = False + from .wrapper import PHANTOM from .lib import EconomicMetricsWrapper, MetricsCallback +from .lib.discrete import EventQTable -wandb.init( - project="phantom-pricing", - config={ - "alpha": 0.3, - "n_products": 10, - "total_timesteps": 50000, - "robust_radius": 0.15, - "robust_points": 5, - "lambda_coi": 0.2, - }, -) -env_kwargs = { +DEFAULT_CFG = { + "project": "phantom-pricing", + "algo": "ppo", + "seed": 42, + "total_timesteps": 50_000, + "eval_episodes": 5, + "eval_freq": 1_000, + "log_freq": 100, + "revenue_weight": 0.01, "n_products": 10, + "N": 100, "alpha": 0.3, "lambda_coi": 0.2, "robust_radius": 0.15, "robust_points": 5, - "render_mode": None, + "info_value": 1.0, + "price_low": 10.0, + "price_high": 150.0, + "action_levels": 9, + "action_scale_low": 0.8, + "action_scale_high": 1.2, + "learning_rate": 3e-4, + "gamma": 0.99, + "buffer_size": 50_000, + "batch_size": 256, + "tau": 0.005, + "train_freq": 1, + "learning_starts": 1_000, + "target_update_interval": 1_000, + "exploration_fraction": 0.2, + "exploration_final_eps": 0.05, + "n_steps": 2_048, + "n_epochs": 10, + "gae_lambda": 0.95, + "clip_range": 0.2, + "ent_coef": 0.0, + "q_lr": 0.1, + "eps_start": 1.0, + "eps_end": 0.05, + "eps_decay": 0.9995, + "model_dir": "engine/models", + "arch": "small", + "activation": "relu", + "q_bins": 6, } -env = EconomicMetricsWrapper(PHANTOM(**env_kwargs)) -eval_env = EconomicMetricsWrapper(PHANTOM(**env_kwargs)) -model = SAC( - "MultiInputPolicy", - env, - verbose=1, - learning_rate=3e-4, - buffer_size=50000, - batch_size=256, - tau=0.005, - gamma=0.99, -) -metrics_cb = MetricsCallback(log_histograms=True, log_freq=100) -eval_cb = EvalCallback(eval_env, eval_freq=1000, n_eval_episodes=5, verbose=1) +def _cfg(raw: dict | None = None) -> dict: + cfg = dict(DEFAULT_CFG) + if raw: + cfg.update({k: v for k, v in raw.items() if v is not None}) + cfg["algo"] = str(cfg["algo"]).lower() + return cfg -model.learn(total_timesteps=50000, callback=[metrics_cb, eval_cb]) -model.save("phantom_sac") -wandb.finish() -# test trained policy -env = PHANTOM(**env_kwargs) -obs, _ = env.reset() -for _ in range(100): - action, _ = model.predict(obs, deterministic=True) - obs, reward, term, trunc, _ = env.step(action) - env.render() - if term or trunc: - break -env.close() +def _wandb_cfg_dict() -> dict: + return ( + {k: wandb.config[k] for k in wandb.config.keys()} + if HAS_WANDB and wandb.run + else {} + ) + + +def make_env(cfg: dict): + env = PHANTOM( + n_products=int(cfg["n_products"]), + alpha=float(cfg["alpha"]), + N=int(cfg["N"]), + price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])), + lambda_coi=float(cfg["lambda_coi"]), + robust_radius=float(cfg["robust_radius"]), + robust_points=int(cfg["robust_points"]), + info_value=float(cfg["info_value"]), + action_levels=int(cfg["action_levels"]), + action_scale_low=float(cfg["action_scale_low"]), + action_scale_high=float(cfg["action_scale_high"]), + render_mode=None, + ) + env = EconomicMetricsWrapper(env) + env = FlattenObservation(env) + return env + + +def _net_arch(name) -> list[int]: + presets = { + "tiny": [32, 32], + "small": [64, 64], + "medium": [128, 128], + "large": [256, 256], + } + if isinstance(name, (list, tuple)): + return [int(v) for v in name] + s = str(name).lower().strip() + if s in presets: + return presets[s] + if "x" in s: + try: + vals = [int(v) for v in s.split("x") if v] + return vals if vals else presets["small"] + except ValueError: + return presets["small"] + return presets["small"] + + +def _activation(name): + try: + import torch.nn as nn + except ImportError: + return None + return { + "relu": nn.ReLU, + "tanh": nn.Tanh, + "elu": nn.ELU, + "leaky_relu": nn.LeakyReLU, + }.get(str(name).lower().strip(), nn.ReLU) + + +def _policy_kwargs(cfg: dict) -> dict: + kw = {"net_arch": _net_arch(cfg.get("arch", "small"))} + act = _activation(cfg.get("activation", "relu")) + if act is not None: + kw["activation_fn"] = act + return kw + + +def _action(agent, obs, deterministic: bool = True): + out = agent.predict(obs, deterministic=deterministic) + a = out[0] if isinstance(out, tuple) else out + if isinstance(a, np.ndarray) and a.size == 1: + return int(a.reshape(-1)[0]) + return a + + +def evaluate(agent, env, episodes: int) -> dict: + rewards, revenues = [], [] + for _ in range(int(episodes)): + obs, _ = env.reset() + done, ep_r, ep_rev = False, 0.0, 0.0 + while not done: + obs, reward, term, trunc, info = env.step(_action(agent, obs, True)) + done = term or trunc + ep_r += float(reward) + ep_rev += float( + info.get("economics", {}).get("revenue", info.get("revenue", 0.0)) + ) + rewards.append(ep_r) + revenues.append(ep_rev) + return { + "eval/reward": float(np.mean(rewards)), + "eval/revenue": float(np.mean(revenues)), + "eval/reward_std": float(np.std(rewards)), + "eval/revenue_std": float(np.std(revenues)), + } + + +def build_model(cfg: dict, env): + algo = cfg["algo"] + policy_kwargs = _policy_kwargs(cfg) + if algo == "sac": + raise ValueError("sac is not supported with the discrete core env") + if algo == "ppo": + return PPO( + "MlpPolicy", + env, + verbose=1, + policy_kwargs=policy_kwargs, + seed=int(cfg["seed"]), + learning_rate=float(cfg["learning_rate"]), + n_steps=int(cfg["n_steps"]), + batch_size=int(cfg["batch_size"]), + n_epochs=int(cfg["n_epochs"]), + gamma=float(cfg["gamma"]), + gae_lambda=float(cfg["gae_lambda"]), + clip_range=float(cfg["clip_range"]), + ent_coef=float(cfg["ent_coef"]), + ) + if algo == "a2c": + return A2C( + "MlpPolicy", + env, + verbose=1, + policy_kwargs=policy_kwargs, + seed=int(cfg["seed"]), + learning_rate=float(cfg["learning_rate"]), + n_steps=max(5, int(cfg["n_steps"]) // 32), + gamma=float(cfg["gamma"]), + gae_lambda=float(cfg["gae_lambda"]), + ent_coef=float(cfg["ent_coef"]), + ) + if algo == "dqn": + return DQN( + "MlpPolicy", + env, + verbose=1, + policy_kwargs=policy_kwargs, + seed=int(cfg["seed"]), + learning_rate=float(cfg["learning_rate"]), + buffer_size=int(cfg["buffer_size"]), + batch_size=int(cfg["batch_size"]), + gamma=float(cfg["gamma"]), + train_freq=int(cfg["train_freq"]), + learning_starts=int(cfg["learning_starts"]), + target_update_interval=int(cfg["target_update_interval"]), + exploration_fraction=float(cfg["exploration_fraction"]), + exploration_final_eps=float(cfg["exploration_final_eps"]), + ) + raise ValueError(f"unsupported algo '{algo}'") + + +def train_qtable(cfg: dict) -> tuple[EventQTable, dict]: + np.random.seed(int(cfg["seed"])) + env = make_env(cfg) + eval_env = make_env(cfg) + agent = EventQTable( + env.action_space.n, + int(cfg["n_products"]), + (float(cfg["price_low"]), float(cfg["price_high"])), + lr=float(cfg["q_lr"]), + gamma=float(cfg["gamma"]), + n_bins=int(cfg["q_bins"]), + ) + eps = float(cfg["eps_start"]) + obs, _ = env.reset(seed=int(cfg["seed"])) + for t in range(int(cfg["total_timesteps"])): + a, s = agent.act(obs, eps) + nxt, reward, term, trunc, info = env.step(a) + done = term or trunc + agent.update(s, a, float(reward), agent.encode(nxt), done) + eps = max(float(cfg["eps_end"]), eps * float(cfg["eps_decay"])) + if HAS_WANDB and wandb.run and (t + 1) % int(cfg["log_freq"]) == 0: + econ = info.get("economics", {}) + wandb.log( + { + "train/reward": float(reward), + "train/revenue": float(econ.get("revenue", 0.0)), + "train/epsilon": float(eps), + }, + step=t + 1, + ) + obs = env.reset()[0] if done else nxt + metrics = evaluate(agent, eval_env, int(cfg["eval_episodes"])) + metrics["train/global_step"] = int(cfg["total_timesteps"]) + env.close() + eval_env.close() + return agent, metrics + + +def train_sb3(cfg: dict) -> tuple[object, dict]: + if not HAS_SB3: + raise ImportError("stable-baselines3 is required for SB3 models") + env = make_env(cfg) + eval_env = make_env(cfg) + env = Monitor(env) + eval_env = Monitor(eval_env) + model = build_model(cfg, env) + cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))] + cbs.append( + EvalCallback( + eval_env, + eval_freq=int(cfg["eval_freq"]), + n_eval_episodes=int(cfg["eval_episodes"]), + deterministic=True, + verbose=0, + ) + ) + model.learn(total_timesteps=int(cfg["total_timesteps"]), callback=cbs) + model_path = Path(cfg["model_dir"]) + model_path.mkdir(parents=True, exist_ok=True) + model.save(str(model_path / f"phantom_{cfg['algo']}")) + metrics = evaluate(model, eval_env, int(cfg["eval_episodes"])) + metrics["train/global_step"] = int(model.num_timesteps) + env.close() + eval_env.close() + return model, metrics + + +def train_once(cfg: dict) -> dict: + algo = cfg["algo"] + if algo == "qtable": + _, metrics = train_qtable(cfg) + else: + _, metrics = train_sb3(cfg) + metrics["sweep/score"] = float( + metrics["eval/reward"] + float(cfg["revenue_weight"]) * metrics["eval/revenue"] + ) + return metrics + + +def run_wandb( + project: str, overrides: dict, mode: str = "online", sweep_mode: bool = False +) -> dict: + if not HAS_WANDB: + raise ImportError("wandb is required for sweep runs") + init_kwargs = {"mode": mode} + if sweep_mode: + run = wandb.init(**init_kwargs) + cfg = _cfg(_wandb_cfg_dict()) + for k, v in overrides.items(): + if k not in wandb.config: + cfg[k] = v + else: + run = wandb.init(project=project, config=overrides, **init_kwargs) + cfg = _cfg(_wandb_cfg_dict()) + metrics = train_once(cfg) + step = int(metrics.get("train/global_step", cfg["total_timesteps"])) + wandb.log(metrics, step=step) + for k, v in metrics.items(): + run.summary[k] = v + wandb.finish() + return metrics + + +def run_local(overrides: dict) -> dict: + cfg = _cfg(overrides) + metrics = train_once(cfg) + print(json.dumps(metrics, indent=2)) + return metrics + + +def main(): + p = argparse.ArgumentParser(description="PHANTOM training and W&B sweeps") + p.add_argument("--project", default=DEFAULT_CFG["project"]) + p.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable"]) + p.add_argument("--total-timesteps", type=int) + p.add_argument("--alpha", type=float) + p.add_argument("--n-products", type=int) + p.add_argument("--lambda-coi", type=float) + p.add_argument("--robust-radius", type=float) + p.add_argument("--robust-points", type=int) + p.add_argument("--learning-rate", type=float) + p.add_argument("--gamma", type=float) + p.add_argument("--revenue-weight", type=float) + p.add_argument("--arch", type=str) + p.add_argument("--activation", type=str) + p.add_argument("--sweep-agent", action="store_true") + p.add_argument("--sweep-id", type=str) + p.add_argument("--count", type=int, default=0) + p.add_argument("--offline", action="store_true") + p.add_argument("--no-wandb", action="store_true") + args = p.parse_args() + + overrides = { + "algo": args.algo, + "total_timesteps": args.total_timesteps, + "alpha": args.alpha, + "n_products": args.n_products, + "lambda_coi": args.lambda_coi, + "robust_radius": args.robust_radius, + "robust_points": args.robust_points, + "learning_rate": args.learning_rate, + "gamma": args.gamma, + "revenue_weight": args.revenue_weight, + "arch": args.arch, + "activation": args.activation, + } + overrides = {k: v for k, v in overrides.items() if v is not None} + + if args.sweep_agent: + if args.no_wandb: + raise ValueError("sweep agent requires wandb") + if not args.sweep_id: + raise ValueError("--sweep-id is required with --sweep-agent") + mode = "offline" if args.offline else "online" + wandb.agent( + args.sweep_id, + function=lambda: run_wandb( + args.project, overrides, mode=mode, sweep_mode=True + ), + count=args.count if args.count > 0 else None, + ) + return + + if args.no_wandb or not HAS_WANDB: + run_local(overrides) + return + + run_wandb(args.project, overrides, mode="offline" if args.offline else "online") + + +if __name__ == "__main__": + main() diff --git a/engine/wrapper.py b/engine/wrapper.py index e1ea79b..22e958b 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -29,6 +29,7 @@ class PHANTOM(gym.Env): reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL COI_leak uses behavioral divergence to estimate agent probability f(τ') robust inner step: min over alpha in Wasserstein interval around nominal alpha + actions are discrete global price-scale moves """ metadata = {"render_modes": ["human", "ansi"]} @@ -47,6 +48,9 @@ class PHANTOM(gym.Env): robust_radius: float = 0.0, robust_points: int = 5, info_value: float = 1.0, + action_levels: int = 9, + action_scale_low: float = 0.9, + action_scale_high: float = 1.1, render_mode: str = None, ): super().__init__() @@ -63,6 +67,10 @@ class PHANTOM(gym.Env): self.robust_radius = max(0.0, float(robust_radius)) self.robust_points = max(1, int(robust_points)) self.info_value = float(info_value) + self.action_levels = max(2, int(action_levels)) + self._action_scales = np.linspace( + float(action_scale_low), float(action_scale_high), self.action_levels + ) self.market = MarketEngine( alpha=alpha, @@ -75,12 +83,7 @@ class PHANTOM(gym.Env): self._limbo = Limbo(self._platform_stub, self.market) self._set_market_mix(self.nominal_alpha) - self.action_space = spaces.Box( - low=price_bounds[0], - high=price_bounds[1], - shape=(n_products,), - dtype=np.float32, - ) + self.action_space = spaces.Discrete(self.action_levels) self.observation_space = spaces.Dict( { "demand": spaces.Box( @@ -127,6 +130,21 @@ class PHANTOM(gym.Env): self.market.Nagents = n_agents self.market.Nhumans = self.N - n_agents + def _decode_action(self, action) -> np.ndarray: + base = ( + self._prices + if self._prices is not None + else np.full(self.n_products, self.price_bounds[0], dtype=float) + ) + if np.isscalar(action): + idx = int(np.clip(int(action), 0, self.action_levels - 1)) + return np.clip(base * self._action_scales[idx], *self.price_bounds) + a = np.asarray(action) + if a.size == 1: + idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1)) + return np.clip(base * self._action_scales[idx], *self.price_bounds) + return np.clip(a.astype(float), *self.price_bounds) + def _compute_agent_prob(self, trajectories=None) -> float: trajectories = ( self.market.last_trajectories if trajectories is None else trajectories @@ -208,8 +226,8 @@ class PHANTOM(gym.Env): self._record_history() return self._get_obs(), {} - def step(self, action: np.ndarray): - self._prices = np.clip(action, *self.price_bounds) + def step(self, action): + self._prices = self._decode_action(action) alpha_adv = self._select_adversarial_alpha(self._prices) self._set_market_mix(alpha_adv) self._platform_stub.set_prices(self._prices) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 9258a80..79e5ca7 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -315,6 +315,8 @@ This yields two centroid-like heuristics that guide contamination estimation at In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move). +% Mention discretized action space and the clipping and over shotting in continuous action spaces + \subsubsection{Ambiguity Set Construction} We define an ambiguity set $\mathcal{U}_\epsilon(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: \begin{equation} From 024f6d4132291131548dcf904e37bafff0950de4 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 17:10:13 +0100 Subject: [PATCH 21/36] banner addition --- paper/src/chapters/03-methodology.tex | 1 + paper/src/graphics/banner.png | Bin 0 -> 42284 bytes paper/src/graphics/banner.py | 14 ++++++++++++++ paper/src/graphics/banner.txt | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+) create mode 100644 paper/src/graphics/banner.png create mode 100644 paper/src/graphics/banner.py create mode 100644 paper/src/graphics/banner.txt diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 79e5ca7..2109814 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -316,6 +316,7 @@ This yields two centroid-like heuristics that guide contamination estimation at In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move). % Mention discretized action space and the clipping and over shotting in continuous action spaces +% Also talk about catastrophic economics, we add termination on bankrupcy or zero demand so market collaps \subsubsection{Ambiguity Set Construction} We define an ambiguity set $\mathcal{U}_\epsilon(\hat{P}_N)$ centered around our empirical reference distribution $\hat{P}_N$ (derived from the generator $\mathcal{G}$). We utilize the Wasserstein distance metric to define the set of plausible demand distributions the agent might face: diff --git a/paper/src/graphics/banner.png b/paper/src/graphics/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..232186ec43b6e98cc63ff36f6468565b76f819ef GIT binary patch literal 42284 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 EALH`SbpQYW literal 0 HcmV?d00001 diff --git a/paper/src/graphics/banner.py b/paper/src/graphics/banner.py new file mode 100644 index 0000000..4b77f43 --- /dev/null +++ b/paper/src/graphics/banner.py @@ -0,0 +1,14 @@ +from PIL import Image, ImageDraw, ImageFont + +text = open("banner.txt", "r", encoding="utf-8").read() +font = ImageFont.truetype("DejaVuSansMono.ttf", 18) + +dummy = Image.new("RGB", (1, 1)) +d = ImageDraw.Draw(dummy) +bbox = d.multiline_textbbox((0,0), text, font=font) +w, h = bbox[2]-bbox[0], bbox[3]-bbox[1] + +img = Image.new("RGB", (w+20, h+20), "white") +d = ImageDraw.Draw(img) +d.multiline_text((10,10), text, font=font, fill="black") +img.save("banner.png") diff --git a/paper/src/graphics/banner.txt b/paper/src/graphics/banner.txt new file mode 100644 index 0000000..f4d3cb9 --- /dev/null +++ b/paper/src/graphics/banner.txt @@ -0,0 +1,23 @@ +Actors Trajectories +■════■ interact ┌────────────┐ ┌──┐ +║Agent──────┬──────▻Web Platform├──┐ │τ1│ ┌▻Q (demand estimate)─┐ +║Human──────┘ └──────△─────┘ └──▻..│──┘ │ +╚════■ │ │τK│ │ + △ │ └──┘ │ + │motivate │ │ + └────────┐ │Setting ┌──┐ Pricing Engine │ + ▲ ┌──┐│ │Prices │p1│ ┌──────────────┐ │ + │ ┌─┘ ││ └───────────┤..│◅────│▒▒▒▒▒▒▒▒▒▒▒▒▒▒│◅──┘ + │ │ └──┐ │pN│ └─────┬──┬─────┘ + │ ┌─┘ │ └──┘ │ │ + └─┴─────────┴─▶ │ │ + Private Valuations │ │ + │ │ + ╔═══════════════════════════════════════════════════╧══╧════════╗ + ║ Training Loop / SAC PPO DQN A2C ║ + ║ ■═════════════════════════════■ ║ + ║ Q̂_t,i = Σ_s Σ_k ω(a_s,k) · 1[i_s,k = i] │ ║ + ║ f(τ') from KL( T' || T_H ) and KL( T' || T_A ) │ ║ + ║ α* = argmin_{α ∈ Aε(α0)} [ Revenue(p, Q^α) - λ·COI_leak ] │ ║ + ║ r_t = Revenue - λ·f(τ') | a* ▽ ║ + ╚═══════════════════════════════════════════════════════════════╝ From 8e4dd59f90a5d248158300c93059e9dce215a048 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 17:10:16 +0100 Subject: [PATCH 22/36] banner rendering --- paper/src/main.tex | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/paper/src/main.tex b/paper/src/main.tex index bcce09e..abb87a8 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -17,6 +17,12 @@ \large\today \end{titlepage} +\begin{center} + \includegraphics[width=\textwidth]{graphics/banner.png} +\end{center} + +\vspace{1em} + \begin{abstract} With accelerated growth of Lager Language Model agents in e-commerce a novel adversarial dynamic to digital markets emerges. This paper address the vulnerability of dynamic pricing systems to AI intermediaries that decouple the information gather stages from the transaction execution. By conducing reconnaissance isolates sessions, agents circumvent the ``Cost of Information'' (COI) defined as the accumulated price premium typically thought demand expression estimators. We formally define this phenomenon and derive the Cost of Information Theorem, proving that as the saturation of independent, utility-maximizing agents increases, the platform’s ability to sustain a COI converges to zero, rendering standard dynamic pricing mechanisms incentive-incompatible. From ded7290935d47a80915c822286fdb75316062dd0 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 17:12:12 +0100 Subject: [PATCH 23/36] hidef banner rendering --- paper/src/graphics/banner.png | Bin 42284 -> 85531 bytes paper/src/graphics/banner.py | 26 ++++++++++++++++++-------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/paper/src/graphics/banner.png b/paper/src/graphics/banner.png index 232186ec43b6e98cc63ff36f6468565b76f819ef..992202e9ce0c4227ebbf6405f4b4e70c6c3ee25a 100644 GIT binary patch literal 85531 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 KquT~f5&sVcpPEzv literal 42284 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 EALH`SbpQYW diff --git a/paper/src/graphics/banner.py b/paper/src/graphics/banner.py index 4b77f43..4228362 100644 --- a/paper/src/graphics/banner.py +++ b/paper/src/graphics/banner.py @@ -1,14 +1,24 @@ from PIL import Image, ImageDraw, ImageFont text = open("banner.txt", "r", encoding="utf-8").read() -font = ImageFont.truetype("DejaVuSansMono.ttf", 18) -dummy = Image.new("RGB", (1, 1)) +scale = 4 # 2–6 is typical +pad = 10 +font_px = 18 + +font = ImageFont.truetype("DejaVuSansMono.ttf", font_px * scale) + +# Measure at high res +dummy = Image.new("RGB", (1, 1), "white") d = ImageDraw.Draw(dummy) -bbox = d.multiline_textbbox((0,0), text, font=font) -w, h = bbox[2]-bbox[0], bbox[3]-bbox[1] +bbox = d.multiline_textbbox((0, 0), text, font=font) +w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] -img = Image.new("RGB", (w+20, h+20), "white") -d = ImageDraw.Draw(img) -d.multiline_text((10,10), text, font=font, fill="black") -img.save("banner.png") +# Render at high res +hi = Image.new("RGB", (w + 2*pad*scale, h + 2*pad*scale), "white") +d = ImageDraw.Draw(hi) +d.multiline_text((pad*scale, pad*scale), text, font=font, fill="black") + +# Downscale with a good filter +out = hi.resize((hi.width // scale, hi.height // scale), resample=Image.Resampling.LANCZOS) +out.save("banner.png", dpi=(300, 300)) From 9b133cddfd67a30a4649ad6bcb93acf225d9e845 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 17:15:25 +0100 Subject: [PATCH 24/36] introduce penalized sessions to episodes --- engine/lib/__init__.py | 8 +++++- engine/wrapper.py | 58 ++++++++++++++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 2a56747..874db63 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -3,6 +3,12 @@ from .behavior import sample_behavior, get_transition_models, trajectory_to_even from .render import DashboardRenderer, style_axis from .wrappers import EconomicMetricsWrapper from .callbacks import MetricsCallback, EvalMetricsCallback -from .providers import ProviderBenchmark, ProviderResult, BenchmarkConfig +from .providers import ( + ProviderBenchmark, + ProviderResult, + BenchmarkConfig, + RandomBaseline, + SurgeBaseline, +) from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability from .discrete import EventQTable diff --git a/engine/wrapper.py b/engine/wrapper.py index 22e958b..3e37d9a 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -51,6 +51,9 @@ class PHANTOM(gym.Env): action_levels: int = 9, action_scale_low: float = 0.9, action_scale_high: float = 1.1, + max_steps: int = 100, + margin_floor: float = 0.05, + margin_floor_patience: int = 5, render_mode: str = None, ): super().__init__() @@ -58,6 +61,11 @@ class PHANTOM(gym.Env): self.price_bounds = price_bounds self.lambda_coi = lambda_coi self.coi_window = coi_window + self.max_steps = max(1, int(max_steps)) + self.margin_floor = float( + margin_floor + ) # terminate if avg margin stays below this for patience steps + self.margin_floor_patience = max(1, int(margin_floor_patience)) self.render_mode = render_mode self.alpha = float(alpha) self.nominal_alpha = float(alpha) @@ -108,6 +116,7 @@ class PHANTOM(gym.Env): self._initial_episode_prices = None self._trajectories = [] # session trajectories for agent prob calculation self.baseline_prices = np.full(self.n_products, self.price_bounds[0]) + self._low_margin_streak = 0 # consecutive steps below margin_floor # load behavioral models for agent probability estimation try: @@ -170,14 +179,18 @@ class PHANTOM(gym.Env): revenue = float(np.dot(prices, demand_arr)) purchases = extract_purchases(trajectories) coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices) + # multiplicative penalty so COI term scales with revenue magnitude coi_leakage = float(agent_prob * self.info_value) - coi_penalty = float(self.lambda_coi * coi_leakage) - return float(revenue - coi_penalty), { + discount = float(np.clip(1.0 - self.lambda_coi * coi_leakage, 0.0, 1.0)) + coi_penalty = revenue * (1.0 - discount) # absolute penalty in revenue units + reward = revenue * discount + return reward, { "revenue": revenue, "coi_mix": float(coi_mix), "coi_base": 0.0, "coi_leakage": coi_leakage, "coi_penalty": coi_penalty, + "coi_discount": discount, } def _alpha_candidates(self) -> np.ndarray: @@ -187,21 +200,28 @@ class PHANTOM(gym.Env): hi = min(1.0, self.nominal_alpha + self.robust_radius) return np.linspace(lo, hi, self.robust_points) - def _select_adversarial_alpha(self, prices: np.ndarray) -> float: + def _select_adversarial_alpha( + self, prices: np.ndarray + ) -> tuple[float, dict, list, float]: + """inner robust step: pick worst-case alpha and return its outcome directly to avoid double-sampling""" candidates = self._alpha_candidates() - if len(candidates) == 1: - return float(candidates[0]) best_alpha, worst_reward = float(candidates[0]), np.inf + best_demand, best_trajectories, best_agent_prob = None, [], 0.0 for alpha in candidates: self._set_market_mix(float(alpha)) demand = self.market.act(prices) - trajectories = self.market.last_trajectories + trajectories = list(self.market.last_trajectories) agent_prob = self._compute_agent_prob(trajectories) reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories) if reward < worst_reward: worst_reward = reward - best_alpha = float(alpha) - return best_alpha + best_alpha, best_demand, best_trajectories, best_agent_prob = ( + float(alpha), + demand, + trajectories, + agent_prob, + ) + return best_alpha, best_demand, best_trajectories, best_agent_prob def _record_history(self): demand_arr = np.array( @@ -221,6 +241,7 @@ class PHANTOM(gym.Env): self._demand = self._limbo.step() self._initial_episode_prices = self._prices.copy() self._step_count = 0 + self._low_margin_streak = 0 self._demand_history, self._price_history, self._revenue_history = [], [], [] self._trajectories = list(getattr(self.market, "last_trajectories", [])) self._record_history() @@ -228,21 +249,30 @@ class PHANTOM(gym.Env): def step(self, action): self._prices = self._decode_action(action) - alpha_adv = self._select_adversarial_alpha(self._prices) + # inner robust step returns worst-case outcome directly, no re-sampling + alpha_adv, self._demand, trajectories, agent_prob = ( + self._select_adversarial_alpha(self._prices) + ) self._set_market_mix(alpha_adv) self._platform_stub.set_prices(self._prices) - self._limbo.step() - self._demand = self._limbo.step() - trajectories = getattr(self.market, "last_trajectories", []) self._step_count += 1 self._trajectories.extend(trajectories) - agent_prob = self._compute_agent_prob(trajectories) reward, metrics = self._compute_reward( self._prices, self._demand, agent_prob, trajectories ) self._record_history() - terminated = self._step_count >= 100 + + # soft early termination when margin collapses for too long + avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max( + float(np.mean(self._prices)), 1e-6 + ) + if avg_margin < self.margin_floor: + self._low_margin_streak += 1 + else: + self._low_margin_streak = 0 + margin_collapsed = self._low_margin_streak >= self.margin_floor_patience + terminated = self._step_count >= self.max_steps or margin_collapsed info = { "step": self._step_count, From 1e04a928aae051729f28c441c0f0dfe62f9c2a4b Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Feb 2026 17:31:31 +0100 Subject: [PATCH 25/36] migrated new banner --- paper/src/main.tex | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/paper/src/main.tex b/paper/src/main.tex index abb87a8..49828f6 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -7,7 +7,7 @@ \begin{titlepage} \centering - \includegraphics[width=0.3\textwidth]{graphics/SST.png}\\[1cm] + \includegraphics[width=\textwidth]{graphics/banner.png}\\[0.8cm] \LARGE\textbf{PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms}\\[0.5cm] \Large\textbf{Daniel Rösel}\\ \large\textit{Bachelor of Computer Science \& Artificial Intelligence}\\[0.5cm] @@ -17,12 +17,6 @@ \large\today \end{titlepage} -\begin{center} - \includegraphics[width=\textwidth]{graphics/banner.png} -\end{center} - -\vspace{1em} - \begin{abstract} With accelerated growth of Lager Language Model agents in e-commerce a novel adversarial dynamic to digital markets emerges. This paper address the vulnerability of dynamic pricing systems to AI intermediaries that decouple the information gather stages from the transaction execution. By conducing reconnaissance isolates sessions, agents circumvent the ``Cost of Information'' (COI) defined as the accumulated price premium typically thought demand expression estimators. We formally define this phenomenon and derive the Cost of Information Theorem, proving that as the saturation of independent, utility-maximizing agents increases, the platform’s ability to sustain a COI converges to zero, rendering standard dynamic pricing mechanisms incentive-incompatible. From 64ee7e6d9b67196895d758f945ad0d4cf980af2c Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Feb 2026 11:30:18 +0100 Subject: [PATCH 26/36] forcing light mode --- web/src/app/globals.css | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 457b974..af112a1 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -1,5 +1,7 @@ @import "tailwindcss"; +@custom-variant dark (&:where(.dark, .dark *)); + @layer base { :root { --background: #ffffff; @@ -16,6 +18,7 @@ --spacing-lg: 32px; --border-radius: 8px; --shadow-card: 0 2px 8px rgba(0, 0, 0, 0.1); + color-scheme: light; } } @@ -27,13 +30,6 @@ } @layer base { -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - * { box-sizing: border-box; margin: 0; @@ -43,6 +39,7 @@ body { background: var(--background); color: var(--foreground); + color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.6; -webkit-font-smoothing: antialiased; From 76c31a2abd4dbb05cdfd11a3424f7e8e2f6b1ef4 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Feb 2026 09:40:20 +0100 Subject: [PATCH 27/36] citing marc and --- paper/src/bib/references.bib | 23 +++++++++++++++++++++++ paper/src/chapters/03-methodology.tex | 5 ++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/paper/src/bib/references.bib b/paper/src/bib/references.bib index 4689742..99f57ca 100644 --- a/paper/src/bib/references.bib +++ b/paper/src/bib/references.bib @@ -562,3 +562,26 @@ Volume: 21}, note = {No. 3:25-cv-09514-MMC}, file = {PDF:/home/velocitatem/Zotero/storage/4JWZSTXJ/Posner - UNITED STATES DISTRICT COURT NORTHERN DISTRICT OF CALIFORNIA SAN FRANCISCO DIVISION.pdf:application/pdf}, } + +@article{wright_2026_2025, + title = {2026 {Artificial} {Intelligence} {Outlook}: {The} {Great} {Competition} {Wars} {Have} {Begun}}, + language = {en}, + journal = {Pitchbook}, + author = {Wright, Brian and Javaheri, Ali and Bellomo, Eric and Hernandez, Derek and Yang, Rudy and MacDonagh, John and DeGagne, Aaron and Frederick, Alex and Geurkink, Jonathan and Zabelin, Dimitri and Ulan, James}, + month = dec, + year = {2025}, + file = {PDF:/home/velocitatem/Zotero/storage/AIY5K3TX/Wright et al. - 2025 - Institutional Research Group.pdf:application/pdf}, +} + +@misc{rachitsky_marc_2026, + title = {Marc {Andreessen}: {The} real {AI} boom hasn’t even started yet}, + shorttitle = {Marc {Andreessen}}, + url = {https://www.lennysnewsletter.com/p/marc-andreessen-the-real-ai-boom}, + abstract = {On raising kids, why job loss fears are overblown, the future of PM/eng/design careers, and the macro force you should pay attention to}, + language = {en}, + urldate = {2026-02-01}, + author = {Rachitsky, Lenny}, + month = feb, + year = {2026}, + file = {Snapshot:/home/velocitatem/Zotero/storage/DGW8PHMV/marc-andreessen-the-real-ai-boom.html:text/html}, +} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 2109814..e6c5bd8 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -46,6 +46,7 @@ where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of \subsection{Cost of Information (COI) Framework} The platform's pricing power comes from information asymmetry: users who express strong interest signals pay more than the base price. We quantify this markup as the \textit{Cost of Information} (COI), which represents the average premium extracted above marginal cost. COI measures the revenue at risk when information asymmetry collapses. +A top-level view in the current AI discourse is that sufficiently large productivity gains can induce vertical deflation through cost compression and supply expansion \parencite{rachitsky_marc_2026}. Our contribution is narrower and mechanism-level: even under long-run deflation, platform revenue still depends on short-run information costs to the user. We formalize that rent as the Cost of Information (COI) and study how agentic reconnaissance accelerates its erosion. \begin{definition}[Cost of Information] Let $\pi(\tau)$ be a pricing policy mapping interaction histories to prices. The COI is defined as: @@ -135,11 +136,9 @@ This result naively proves that standard pricing policies $\pi$ fail to extract In order for our research to have grounding in interactions we built a robust e-commerce web-platform. We initially conducted a survey of the leading platforms of airlines and hotel booking sites to identify the specific interface patterns that effectively manage complex travel data. Our analysis revealed a clear industry standard: while both sectors rely on tabbed service selection and left-sidebar filtering to streamline navigation, they diverge in result presentation: airlines utilize visual date-price bars and multi-step wizards to optimize for logistical transparency, whereas hotel platforms leverage image-led cards and scarcity triggers to drive emotional engagement and urgency. Our web framework defines a highly agnostic boilerplate which can be seeded with any data-modality with an easy-to-tailor pattern, which we leverage to define a \texttt{hotel} and \texttt{airline} mode. Both modes are then individually deployed via an environment level argument which adjusts the proxy routing with a custom middleware inside next.js to render only the desired mode. The purpose of this was to create a baseline adaptable to any use-case or desired commercial application. - The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise. -We transition the Kappa like architecture of the data collection to a Lambda system for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. - +\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda system for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment. \subsubsection{DevOps Principles} From 244af9ac095d76c615d5f2682fa714586e9fe241 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Feb 2026 14:46:34 +0100 Subject: [PATCH 28/36] citing compute --- paper/src/bib/references.bib | 31 +++++++++++++++++++++ paper/src/chapters/03-methodology.tex | 39 ++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/paper/src/bib/references.bib b/paper/src/bib/references.bib index 99f57ca..5dc3352 100644 --- a/paper/src/bib/references.bib +++ b/paper/src/bib/references.bib @@ -585,3 +585,34 @@ Volume: 21}, year = {2026}, file = {Snapshot:/home/velocitatem/Zotero/storage/DGW8PHMV/marc-andreessen-the-real-ai-boom.html:text/html}, } + +@misc{noauthor_tpu_2025, + title = {{TPU} v6e}, + url = {https://cloud.google.com/tpu/docs/v6e}, + language = {es-419-x-mtfrom-en}, + urldate = {2026-02-17}, + journal = {Google Cloud Documentation}, + month = dec, + year = {2025}, + file = {Snapshot:/home/velocitatem/Zotero/storage/RNMB32KD/v6e.html:text/html}, +} + +@misc{noauthor_tpu_2025-1, + title = {{TPU} v5e {\textbar} {Google} {Cloud} {Documentation}}, + url = {https://cloud.google.com/tpu/docs/v5e}, + language = {es-419-x-mtfrom-en}, + urldate = {2026-02-17}, + month = dec, + year = {2025}, + file = {Snapshot:/home/velocitatem/Zotero/storage/BLLG9NZC/v5e.html:text/html}, +} + +@misc{noauthor_tpu_2026, + title = {{TPU} v4 {\textbar} {Google} {Cloud} {Documentation}}, + url = {https://cloud.google.com/tpu/docs/v4}, + language = {es-419-x-mtfrom-en}, + urldate = {2026-02-17}, + month = feb, + year = {2026}, + file = {Snapshot:/home/velocitatem/Zotero/storage/N724QGF6/v4.html:text/html}, +} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index e6c5bd8..19c5997 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -198,7 +198,44 @@ The dynamic pricing mechanism elicited immediate behavioral adjustments. Partici \subsubsection{Design of Training Factorial Study} -The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid estimate: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable and logged with services provided by weights and biases. +The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid estimate: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable. + +Our training budget is provisioned through TPU Research Cloud and spans 384 chips across TPU v4, v5e, and v6e generations, with a spot-heavy allocation plus an on-demand reserve. At peak BF16 throughput this corresponds to approximately 160 PFLOPS of aggregate compute, which makes repeated seeds, ablations, and sensitivity sweeps feasible within practical wall-clock limits. We allocate v6e capacity to the highest-intensity policy training jobs, use v5e for wider hyperparameter exploration where throughput-per-dollar is favorable, and reserve on-demand v4 capacity for runs that should not be interrupted. + +\begin{table}[ht] +\centering +\caption{Compact comparison of TPU generations used in the training stack.} +\label{tab:tpu_specs} +\begin{tabular}{@{}llll@{}} +\toprule +\textbf{Feature} & \textbf{TPU v4} & \textbf{TPU v5e} & \textbf{TPU v6e (Trillium)} \\ +\midrule +Peak BF16 per chip (TFLOPS) & 275 & 197 & 918 \\ +HBM capacity per chip (GB) & 32 & 16 & 32 \\ +HBM bandwidth per chip (GB/s) & 1200 & 819 & 1600 \\ +TensorCores per chip & 2 & 1 & 1 \\ +Interconnect topology & 3D mesh/torus & 2D torus & 2D torus \\ +Max pod size (chips) & 4096 & 256 & 256 \\ +\bottomrule +\end{tabular} +\end{table} + +\begin{table}[ht] +\centering +\caption{TPU allocation used for the factorial study.} +\label{tab:tpu_allocation} +\begin{tabular}{@{}llll@{}} +\toprule +\textbf{TPU Type} & \textbf{Total Chips} & \textbf{Zone(s)} & \textbf{Provisioning} \\ +\midrule +v6e & 128 (64 + 64) & europe-west4-a, us-east1-d & Spot \\ +v5e & 128 (64 + 64) & us-central1-a, europe-west4-b & Spot \\ +v4 & 64 (32 + 32) & us-central2-b & 32 Spot + 32 On-demand \\ +\bottomrule +\end{tabular} +\end{table} + +For interactive monitoring from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}. \subsubsection{Interaction Schema} From 66c4a0cd1dccac5df4117344912b9a0833467577 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Feb 2026 14:46:43 +0100 Subject: [PATCH 29/36] chore: fix chips used --- paper/src/main.tex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paper/src/main.tex b/paper/src/main.tex index 49828f6..c350741 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -27,7 +27,7 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \noindent\textbf{Keywords:} Dynamic Pricing, LLM Agents, Adversarial Machine Learning, E-commerce, Behavioral Detection, Reinforcement Learning \vspace{1em} -\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program, which provided access to Google Cloud TPU accelerators (including TPU v2/v3/v4). +\noindent\textbf{Acknowledgments:} This research was supported by the TPU Research Cloud program, which provided access to Google Cloud TPU accelerators (including TPU v4, v5e, and v6e). \clearpage \input{chapters/01-intro} From 802f31b4a1ab87e7b95fbd8316460ebd0767194e Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Feb 2026 14:48:18 +0100 Subject: [PATCH 30/36] adding naive jax and libraries and make adjustments --- Makefile | 111 +++++- engine/jax/__init__.py | 13 + engine/jax/checkpoint.py | 49 +++ engine/jax/env.py | 287 +++++++++++++++ engine/jax/primitives.py | 493 ++++++++++++++++++++++++++ engine/jax/requirements.txt | 5 + engine/jax/train.py | 471 ++++++++++++++++++++++++ engine/lib/callbacks.py | 119 +++++++ engine/lib/coi.py | 76 ++++ engine/lib/discrete.py | 70 ++++ engine/lib/providers.py | 182 ++++++++++ engine/lib/wrappers.py | 77 ++++ engine/sweeps/model_mix.yaml | 84 +++++ engine/sweeps/models_only.yaml | 85 +++++ engine/sweeps/sac_tune.yaml | 54 +++ engine/sweeps/small_arch_compare.yaml | 86 +++++ engine/train.py | 75 +++- 17 files changed, 2331 insertions(+), 6 deletions(-) create mode 100644 engine/jax/__init__.py create mode 100644 engine/jax/checkpoint.py create mode 100644 engine/jax/env.py create mode 100644 engine/jax/primitives.py create mode 100644 engine/jax/requirements.txt create mode 100644 engine/jax/train.py create mode 100644 engine/lib/callbacks.py create mode 100644 engine/lib/coi.py create mode 100644 engine/lib/discrete.py create mode 100644 engine/lib/providers.py create mode 100644 engine/lib/wrappers.py create mode 100644 engine/sweeps/model_mix.yaml create mode 100644 engine/sweeps/models_only.yaml create mode 100644 engine/sweeps/sac_tune.yaml create mode 100644 engine/sweeps/small_arch_compare.yaml diff --git a/Makefile b/Makefile index d7fd956..27ce523 100644 --- a/Makefile +++ b/Makefile @@ -8,12 +8,30 @@ VENV := .venv PYTHON := $(VENV)/bin/python PIP := $(VENV)/bin/pip PYTEST := $(VENV)/bin/pytest +TPU_NAME ?= phantom-tpu +TPU_ZONE ?= us-central2-b +TPU_TYPE ?= v4-32 +TPU_RUNTIME ?= tpu-vm-v4-base +TPU_PROJECT ?= phantom-trc +TPU_NETWORK ?= default +TPU_SUBNETWORK ?= default-us-central2 +TPU_USE_SPOT ?= 0 +TPU_EXTRA_CREATE_FLAGS ?= +TPU_WORKDIR ?= ~/PHANTOM +TPU_SYNC_PATHS ?= engine lib requirements.txt Makefile .env +TPU_TRAIN_ARGS ?= --algo ppo --jax --total-timesteps 20000 +TPU_JAX_WHEEL_URL ?= https://storage.googleapis.com/jax-releases/libtpu_releases.html +TPU_VENV ?= .venv-tpu +TPU_TRAIN_ENV ?= PHANTOM_USE_JAX=1 WANDB_MODE=offline +TPU_SPOT_FLAG := $(if $(filter 1 true TRUE yes YES,$(TPU_USE_SPOT)),--spot,) +TPU_CREATE_CMD = gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm create "$(TPU_NAME)" --zone="$(TPU_ZONE)" --accelerator-type="$(TPU_TYPE)" --version="$(TPU_RUNTIME)" --network="$(TPU_NETWORK)" --subnetwork="$(TPU_SUBNETWORK)" $(TPU_SPOT_FLAG) $(TPU_EXTRA_CREATE_FLAGS) .DEFAULT_GOAL := help .PHONY: help help: - @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines" + @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines | tpu.*" + @echo "TPU presets: tpu.create.v4.ondemand | tpu.create.v4.spot" $(BUILDDIR): mkdir -p paper/$(BUILDDIR) @@ -70,6 +88,97 @@ $(VENV): install: $(VENV) $(PIP) install -r requirements.txt +.PHONY: tpu.setup +tpu.setup: + @command -v gcloud >/dev/null 2>&1 || (echo "gcloud CLI not found. Install from https://cloud.google.com/sdk/docs/install" && exit 1) + @gcloud auth login --update-adc + @gcloud auth application-default login + @gcloud config set project "$(TPU_PROJECT)" + +.PHONY: tpu.check.zone +tpu.check.zone: + @case "$(TPU_ZONE)" in \ + europe-west4-a|us-central2-b|us-central1-a|us-east1-d|europe-west4-b) ;; \ + *) echo "Unsupported TPU_ZONE='$(TPU_ZONE)'. Allowed zones: europe-west4-a us-central2-b us-central1-a us-east1-d europe-west4-b"; exit 1 ;; \ + esac + +.PHONY: tpu.create.v4.ondemand +tpu.create.v4.ondemand: + $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=0 TPU_SUBNETWORK=default-us-central2 + +.PHONY: tpu.create.v4.spot +tpu.create.v4.spot: + $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=1 TPU_SUBNETWORK=default-us-central2 + +.PHONY: tpu.create +tpu.create: tpu.check.zone + @if gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" >/dev/null 2>&1; then \ + STATE=$$(gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" --format='value(state)'); \ + echo "TPU VM $(TPU_NAME) already exists in $(TPU_ZONE) with state=$$STATE, skipping create"; \ + else \ + $(TPU_CREATE_CMD); \ + fi + +.PHONY: tpu.ensure +tpu.ensure: tpu.check.zone + @set -e; \ + STATE=$$(gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" --format='value(state)' 2>/dev/null || true); \ + if [ -z "$$STATE" ]; then \ + echo "TPU VM $(TPU_NAME) not found in $(TPU_ZONE), creating"; \ + $(TPU_CREATE_CMD); \ + elif [ "$$STATE" = "READY" ]; then \ + echo "TPU VM $(TPU_NAME) is READY"; \ + elif [ "$$STATE" = "PREEMPTED" ] || [ "$$STATE" = "TERMINATED" ] || [ "$$STATE" = "FAILED" ]; then \ + echo "TPU VM $(TPU_NAME) is in terminal state $$STATE, recreating"; \ + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm delete "$(TPU_NAME)" --zone="$(TPU_ZONE)" --quiet || true; \ + $(TPU_CREATE_CMD); \ + else \ + echo "TPU VM $(TPU_NAME) is in state $$STATE; wait or recreate manually"; \ + exit 1; \ + fi + +.PHONY: tpu.status +tpu.status: + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" + +.PHONY: tpu.ssh +tpu.ssh: + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" + +.PHONY: tpu.prepare +tpu.prepare: tpu.ensure + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command "mkdir -p $(TPU_WORKDIR)" + +.PHONY: tpu.deploy +tpu.deploy: tpu.prepare + @for p in $(TPU_SYNC_PATHS); do \ + if [ ! -e "$$p" ]; then continue; fi; \ + if [ -d "$$p" ]; then \ + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm scp --recurse "$$p" "$(TPU_NAME):$(TPU_WORKDIR)/$$p" --zone="$(TPU_ZONE)"; \ + else \ + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm scp "$$p" "$(TPU_NAME):$(TPU_WORKDIR)/$$p" --zone="$(TPU_ZONE)"; \ + fi; \ + done + +.PHONY: tpu.install +tpu.install: tpu.ensure + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'cd $(TPU_WORKDIR) && PYBIN=$$(command -v python3.11 || command -v python3.10 || command -v python3) && $$PYBIN -m venv $(TPU_VENV) && $(TPU_VENV)/bin/pip install --upgrade pip setuptools wheel && $(TPU_VENV)/bin/pip install -r requirements.txt && $(TPU_VENV)/bin/pip install -r engine/jax/requirements.txt && $(TPU_VENV)/bin/pip install "jax[tpu]" -f $(TPU_JAX_WHEEL_URL)' + +.PHONY: tpu.check.remote +tpu.check.remote: tpu.ensure + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'set -e; mkdir -p $(TPU_WORKDIR); cd $(TPU_WORKDIR); test -f engine/train.py || (echo "Missing code on TPU VM. Run: make tpu.deploy" && exit 2); test -x $(TPU_VENV)/bin/python || (echo "Missing TPU venv. Run: make tpu.install" && exit 3)' + +.PHONY: tpu.train +tpu.train: tpu.check.remote + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'cd $(TPU_WORKDIR) && if [ -f .env ]; then set -a && . ./.env && set +a; fi && $(TPU_TRAIN_ENV) $(TPU_VENV)/bin/python -m engine.train $(TPU_TRAIN_ARGS)' + +.PHONY: tpu.bootstrap +tpu.bootstrap: tpu.ensure tpu.deploy tpu.install + +.PHONY: tpu.delete +tpu.delete: + gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm delete "$(TPU_NAME)" --zone="$(TPU_ZONE)" --quiet + .PHONY: stats.lines stats.lines: @find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \ diff --git a/engine/jax/__init__.py b/engine/jax/__init__.py new file mode 100644 index 0000000..8b6f740 --- /dev/null +++ b/engine/jax/__init__.py @@ -0,0 +1,13 @@ +"""JAX-compatible training and environment modules for PHANTOM.""" + +from __future__ import annotations + +try: + import jax # noqa: F401 + import jax.numpy as jnp # noqa: F401 + + JAX_AVAILABLE = True +except ImportError: + JAX_AVAILABLE = False + +__all__ = ["JAX_AVAILABLE"] diff --git a/engine/jax/checkpoint.py b/engine/jax/checkpoint.py new file mode 100644 index 0000000..c75c6bc --- /dev/null +++ b/engine/jax/checkpoint.py @@ -0,0 +1,49 @@ +"""Orbax checkpoint helpers for JAX training runs.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +try: + import orbax.checkpoint as ocp + + HAS_ORBAX = True +except ImportError: + HAS_ORBAX = False + + +def _require_orbax() -> None: + if not HAS_ORBAX: + raise ImportError( + "orbax-checkpoint is required for checkpoint support. " + "Install engine/jax/requirements.txt first." + ) + + +def create_manager(directory: str | Path, max_to_keep: int = 5): + _require_orbax() + root = Path(directory) + root.mkdir(parents=True, exist_ok=True) + options = ocp.CheckpointManagerOptions( + max_to_keep=max(1, int(max_to_keep)), create=True + ) + return ocp.CheckpointManager(root.as_posix(), ocp.PyTreeCheckpointer(), options) + + +def save(manager, *, step: int, payload: Any) -> bool: + _require_orbax() + return bool(manager.save(int(step), payload)) + + +def latest_step(manager) -> int | None: + _require_orbax() + return manager.latest_step() + + +def restore(manager, *, target: Any, step: int | None = None) -> Any: + _require_orbax() + step_to_restore = manager.latest_step() if step is None else int(step) + if step_to_restore is None: + return target + return manager.restore(step_to_restore, items=target) diff --git a/engine/jax/env.py b/engine/jax/env.py new file mode 100644 index 0000000..06542b1 --- /dev/null +++ b/engine/jax/env.py @@ -0,0 +1,287 @@ +"""JAX-native PHANTOM environment with robust contamination step.""" + +from __future__ import annotations + +from typing import NamedTuple + +try: + import jax + import jax.numpy as jnp +except ImportError as exc: # pragma: no cover + raise ImportError("engine.jax.env requires JAX") from exc + +from .primitives import ( + _sample_sessions_jax, + agent_probability_from_kl, + batch_kl, + compute_session_transitions, + load_transition_data, + purchase_flags, + reward_with_coi_penalty, + revenue_from_demand, + weighted_demand, +) + + +class EnvParams(NamedTuple): + n_products: int + n_sessions: int + max_episode_steps: int + max_session_steps: int + price_low: float + price_high: float + lambda_coi: float + info_value: float + robust_radius: float + margin_floor: float + margin_floor_patience: int + action_scales: jax.Array + alpha_nominal: float + alpha_candidates: jax.Array + human_T: jax.Array + agent_T: jax.Array + terminal_mask: jax.Array + purchase_mask: jax.Array + event_weights: jax.Array + start_idx: int + term_idx: int + + +class EnvState(NamedTuple): + prices: jax.Array + demand: jax.Array + step_count: jax.Array + low_margin_streak: jax.Array + last_agent_prob: jax.Array + last_alpha_adv: jax.Array + + +class CandidateEval(NamedTuple): + reward: jax.Array + revenue: jax.Array + demand: jax.Array + agent_prob: jax.Array + leakage: jax.Array + discount: jax.Array + n_purchases: jax.Array + n_agents: jax.Array + + +def make_env_params( + *, + n_products: int, + alpha: float, + n_sessions: int, + lambda_coi: float, + robust_radius: float, + robust_points: int, + info_value: float, + action_levels: int, + action_scale_low: float, + action_scale_high: float, + price_low: float, + price_high: float, + max_episode_steps: int, + max_session_steps: int = 40, + margin_floor: float = 0.05, + margin_floor_patience: int = 5, + prefer_behavior_data: bool = True, +) -> EnvParams: + transition = load_transition_data(prefer_data=prefer_behavior_data).to_jax() + if robust_radius <= 0.0 or robust_points <= 1: + alpha_candidates = jnp.asarray([float(alpha)], dtype=jnp.float32) + else: + lo = max(0.0, float(alpha) - float(robust_radius)) + hi = min(1.0, float(alpha) + float(robust_radius)) + alpha_candidates = jnp.linspace(lo, hi, int(robust_points), dtype=jnp.float32) + + action_scales = jnp.linspace( + float(action_scale_low), + float(action_scale_high), + int(action_levels), + dtype=jnp.float32, + ) + return EnvParams( + n_products=int(n_products), + n_sessions=int(n_sessions), + max_episode_steps=int(max_episode_steps), + max_session_steps=int(max_session_steps), + price_low=float(price_low), + price_high=float(price_high), + lambda_coi=float(lambda_coi), + info_value=float(info_value), + robust_radius=float(robust_radius), + margin_floor=float(margin_floor), + margin_floor_patience=int(margin_floor_patience), + action_scales=action_scales, + alpha_nominal=float(alpha), + alpha_candidates=alpha_candidates, + human_T=jnp.asarray(transition.human_T), + agent_T=jnp.asarray(transition.agent_T), + terminal_mask=jnp.asarray(transition.terminal_mask), + purchase_mask=jnp.asarray(transition.purchase_mask), + event_weights=jnp.asarray(transition.event_weights), + start_idx=int(transition.start_idx), + term_idx=int(transition.term_idx), + ) + + +def _flatten_obs(demand: jax.Array, prices: jax.Array) -> jax.Array: + return jnp.concatenate([demand.astype(jnp.float32), prices.astype(jnp.float32)]) + + +def _decode_action( + prices: jax.Array, action: jax.Array, params: EnvParams +) -> jax.Array: + idx = jnp.clip(action.astype(jnp.int32), 0, params.action_scales.shape[0] - 1) + scale = params.action_scales[idx] + next_prices = prices * scale + return jnp.clip(next_prices, params.price_low, params.price_high) + + +def _evaluate_candidate( + key: jax.Array, + alpha_candidate: jax.Array, + prices: jax.Array, + params: EnvParams, +) -> CandidateEval: + states, products, actors, lengths = _sample_sessions_jax( + key, + params.human_T, + params.agent_T, + params.terminal_mask, + params.start_idx, + params.term_idx, + alpha_candidate, + params.n_products, + params.n_sessions, + params.max_session_steps, + int(params.human_T.shape[0]), + ) + session_trans = compute_session_transitions( + states, lengths, int(params.human_T.shape[0]) + ) + delta_h, delta_a = batch_kl(session_trans, params.human_T, params.agent_T) + agent_probs = agent_probability_from_kl(delta_h, delta_a) + agent_prob = jnp.mean(agent_probs) + + demand = weighted_demand(states, products, params.n_products, params.event_weights) + revenue = revenue_from_demand(prices, demand) + reward, leakage, discount = reward_with_coi_penalty( + revenue, + agent_prob, + params.lambda_coi, + params.info_value, + ) + purchases = purchase_flags(states, params.purchase_mask) + return CandidateEval( + reward=reward, + revenue=revenue, + demand=demand, + agent_prob=agent_prob, + leakage=leakage, + discount=discount, + n_purchases=jnp.sum(purchases.astype(jnp.float32)), + n_agents=jnp.sum(actors.astype(jnp.float32)), + ) + + +def reset_env(key: jax.Array, params: EnvParams) -> tuple[jax.Array, EnvState]: + prices = jax.random.uniform( + key, + shape=(params.n_products,), + minval=params.price_low, + maxval=params.price_high, + ) + demand = jnp.zeros((params.n_products,), dtype=jnp.float32) + state = EnvState( + prices=prices, + demand=demand, + step_count=jnp.asarray(0, dtype=jnp.int32), + low_margin_streak=jnp.asarray(0, dtype=jnp.int32), + last_agent_prob=jnp.asarray(params.alpha_nominal, dtype=jnp.float32), + last_alpha_adv=jnp.asarray(params.alpha_nominal, dtype=jnp.float32), + ) + return _flatten_obs(demand, prices), state + + +def step_env( + key: jax.Array, + state: EnvState, + action: jax.Array, + params: EnvParams, +) -> tuple[jax.Array, EnvState, jax.Array, jax.Array, dict[str, jax.Array]]: + prices = _decode_action(state.prices, action, params) + n_candidates = params.alpha_candidates.shape[0] + cand_keys = jax.random.split(key, n_candidates) + evals = jax.vmap( + lambda k, a: _evaluate_candidate(k, a, prices, params), + in_axes=(0, 0), + )(cand_keys, params.alpha_candidates) + idx = jnp.argmin(evals.reward) + + demand = evals.demand[idx] + reward = evals.reward[idx] + revenue = evals.revenue[idx] + agent_prob = evals.agent_prob[idx] + leakage = evals.leakage[idx] + discount = evals.discount[idx] + n_purchases = evals.n_purchases[idx] + n_agents = evals.n_agents[idx] + alpha_adv = params.alpha_candidates[idx] + + step_count = state.step_count + 1 + avg_price = jnp.maximum(jnp.mean(prices), 1e-6) + avg_margin = (avg_price - params.price_low) / avg_price + next_streak = jnp.where( + avg_margin < params.margin_floor, state.low_margin_streak + 1, 0 + ) + + margin_collapsed = next_streak >= params.margin_floor_patience + done = (step_count >= params.max_episode_steps) | margin_collapsed + + next_state = EnvState( + prices=prices, + demand=demand, + step_count=step_count, + low_margin_streak=next_streak, + last_agent_prob=agent_prob, + last_alpha_adv=alpha_adv, + ) + obs = _flatten_obs(demand, prices) + info = { + "revenue": revenue, + "agent_prob": agent_prob, + "alpha_adv": alpha_adv, + "coi_leakage": leakage, + "coi_discount": discount, + "n_purchases": n_purchases, + "n_agents": n_agents, + "avg_margin": avg_margin, + } + return obs, next_state, reward, done, info + + +class PHANTOMJAXEnv: + def __init__(self, params: EnvParams): + self.params = params + + def reset(self, key: jax.Array, params: EnvParams | None = None): + return reset_env(key, self.params if params is None else params) + + def step( + self, + key: jax.Array, + state: EnvState, + action: jax.Array, + params: EnvParams | None = None, + ): + return step_env(key, state, action, self.params if params is None else params) + + def action_space_n(self, params: EnvParams | None = None) -> int: + p = self.params if params is None else params + return int(p.action_scales.shape[0]) + + def observation_dim(self, params: EnvParams | None = None) -> int: + p = self.params if params is None else params + return int(p.n_products * 2) diff --git a/engine/jax/primitives.py b/engine/jax/primitives.py new file mode 100644 index 0000000..8de4c2b --- /dev/null +++ b/engine/jax/primitives.py @@ -0,0 +1,493 @@ +"""JAX-compatible primitives for PHANTOM session simulation and separability.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import partial +from typing import Mapping, Sequence + +import numpy as np + +try: + import jax + import jax.numpy as jnp + + JAX_AVAILABLE = True +except ImportError: + jax = None # type: ignore[assignment] + jnp = np # type: ignore[assignment] + JAX_AVAILABLE = False + + +STATE_START_KEYS = ("session_start", "start") +TERMINAL_EVENT_TOKENS = ( + "session_end", + "end", + "purchase_complete", + "checkout_start", + "checkout", +) +PURCHASE_EVENT_TOKENS = ( + "purchase_complete", + "purchase", + "checkout_start", + "checkout", +) + +CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5} +ACTION_CATEGORIES = { + "cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"}, + "dwell": { + "hover_title", + "hover_paragraph", + "hover_link", + "hover_over_title", + "hover_over_paragraph", + "hover_over_link", + "hover_over_button", + }, + "nav": { + "page_view", + "view_item", + "view", + "learn_more", + "learn_more_about_item", + "view_item_page", + "session_start", + }, + "filter": { + "search", + "filter_date", + "filter_price", + "sort", + "filter_for_date", + "filter_for_price", + "filter_for_amenities", + "sort_change", + }, +} +DEFAULT_ACTION_WEIGHTS = { + action: CATEGORY_WEIGHTS[group] + for group, actions in ACTION_CATEGORIES.items() + for action in actions +} + + +@dataclass(frozen=True) +class TransitionData: + """Dense transition kernels and per-state metadata.""" + + human_T: np.ndarray + agent_T: np.ndarray + terminal_mask: np.ndarray + purchase_mask: np.ndarray + event_weights: np.ndarray + event_names: tuple[str, ...] + start_idx: int + term_idx: int + + def to_jax(self) -> "TransitionData": + if not JAX_AVAILABLE: + return self + return TransitionData( + human_T=jnp.asarray(self.human_T), + agent_T=jnp.asarray(self.agent_T), + terminal_mask=jnp.asarray(self.terminal_mask), + purchase_mask=jnp.asarray(self.purchase_mask), + event_weights=jnp.asarray(self.event_weights), + event_names=self.event_names, + start_idx=int(self.start_idx), + term_idx=int(self.term_idx), + ) + + +@dataclass(frozen=True) +class SessionBatch: + states: np.ndarray + products: np.ndarray + actors: np.ndarray + lengths: np.ndarray + + +def _event_weight(name: str) -> float: + if name in DEFAULT_ACTION_WEIGHTS: + return float(DEFAULT_ACTION_WEIGHTS[name]) + if name.startswith("hover"): + return float(CATEGORY_WEIGHTS["dwell"]) + if name.startswith("filter") or name in {"search", "sort", "sort_change"}: + return float(CATEGORY_WEIGHTS["filter"]) + if name.startswith("add") or name in { + "checkout", + "checkout_start", + "purchase", + "remove_item", + "purchase_complete", + }: + return float(CATEGORY_WEIGHTS["cart"]) + if any(token in name for token in TERMINAL_EVENT_TOKENS): + return 0.0 + return float(CATEGORY_WEIGHTS["nav"]) + + +def _is_terminal(name: str) -> bool: + return any(token in name for token in TERMINAL_EVENT_TOKENS) + + +def _is_purchase(name: str) -> bool: + return any(token in name for token in PURCHASE_EVENT_TOKENS) + + +def _collect_events(*transitions: Mapping[str, Mapping[str, float]]) -> tuple[str, ...]: + names: set[str] = set() + for trans in transitions: + for src, dsts in trans.items(): + names.add(src) + names.update(dsts.keys()) + names.discard("__terminal__") + return tuple(sorted(names)) + + +def _normalize_rows(matrix: np.ndarray, term_idx: int) -> np.ndarray: + row_sums = matrix.sum(axis=1, keepdims=True) + dead_rows = np.isclose(row_sums.squeeze(-1), 0.0) + if np.any(dead_rows): + matrix[dead_rows] = 0.0 + matrix[dead_rows, term_idx] = 1.0 + row_sums = matrix.sum(axis=1, keepdims=True) + return matrix / np.maximum(row_sums, 1e-8) + + +def _dense_from_dict( + transitions: Mapping[str, Mapping[str, float]], + event_to_idx: Mapping[str, int], + term_idx: int, +) -> np.ndarray: + n_states = len(event_to_idx) + matrix = np.zeros((n_states, n_states), dtype=np.float32) + for src, dsts in transitions.items(): + i = event_to_idx.get(src) + if i is None: + continue + for dst, prob in dsts.items(): + j = event_to_idx.get(dst) + if j is None: + continue + matrix[i, j] += float(prob) + return _normalize_rows(matrix, term_idx) + + +def compile_transition_data( + human_transitions: Mapping[str, Mapping[str, float]], + agent_transitions: Mapping[str, Mapping[str, float]], +) -> TransitionData: + event_names = _collect_events(human_transitions, agent_transitions) + if not event_names: + return fallback_transition_data() + + event_names = tuple([*event_names, "__terminal__"]) + term_idx = len(event_names) - 1 + event_to_idx = {name: i for i, name in enumerate(event_names)} + + human_T = _dense_from_dict(human_transitions, event_to_idx, term_idx) + agent_T = _dense_from_dict(agent_transitions, event_to_idx, term_idx) + + terminal_mask = np.array([_is_terminal(name) for name in event_names], dtype=bool) + purchase_mask = np.array([_is_purchase(name) for name in event_names], dtype=bool) + event_weights = np.array( + [_event_weight(name) for name in event_names], dtype=np.float32 + ) + + terminal_mask[term_idx] = True + + for idx, is_term in enumerate(terminal_mask): + if not is_term: + continue + human_T[idx] = 0.0 + agent_T[idx] = 0.0 + human_T[idx, idx] = 1.0 + agent_T[idx, idx] = 1.0 + + start_idx = 0 + for key in STATE_START_KEYS: + if key in event_to_idx: + start_idx = int(event_to_idx[key]) + break + + return TransitionData( + human_T=human_T, + agent_T=agent_T, + terminal_mask=terminal_mask, + purchase_mask=purchase_mask, + event_weights=event_weights, + event_names=event_names, + start_idx=start_idx, + term_idx=term_idx, + ) + + +def fallback_transition_data() -> TransitionData: + human = { + "session_start": { + "page_view": 0.80, + "view_item_page": 0.15, + "session_end": 0.05, + }, + "page_view": {"view_item_page": 0.55, "search": 0.25, "session_end": 0.20}, + "view_item_page": { + "learn_more_about_item": 0.40, + "add_item_to_cart": 0.28, + "session_end": 0.32, + }, + "learn_more_about_item": { + "add_item_to_cart": 0.50, + "view_item_page": 0.30, + "session_end": 0.20, + }, + "add_item_to_cart": { + "checkout_start": 0.58, + "view_item_page": 0.24, + "session_end": 0.18, + }, + "checkout_start": {"purchase_complete": 0.70, "session_end": 0.30}, + "purchase_complete": {"session_end": 1.0}, + } + agent = { + "session_start": { + "page_view": 0.90, + "view_item_page": 0.08, + "session_end": 0.02, + }, + "page_view": {"view_item_page": 0.40, "search": 0.35, "session_end": 0.25}, + "view_item_page": { + "learn_more_about_item": 0.55, + "add_item_to_cart": 0.15, + "session_end": 0.30, + }, + "learn_more_about_item": { + "view_item_page": 0.45, + "add_item_to_cart": 0.20, + "session_end": 0.35, + }, + "add_item_to_cart": { + "checkout_start": 0.42, + "view_item_page": 0.28, + "session_end": 0.30, + }, + "checkout_start": {"purchase_complete": 0.52, "session_end": 0.48}, + "purchase_complete": {"session_end": 1.0}, + } + return compile_transition_data(human, agent) + + +def load_transition_data(prefer_data: bool = True) -> TransitionData: + if not prefer_data: + return fallback_transition_data() + try: + from ..lib.behavior import get_transition_models + + human_trans, agent_trans = get_transition_models() + return compile_transition_data(human_trans, agent_trans) + except Exception: + return fallback_transition_data() + + +if JAX_AVAILABLE: + + @partial(jax.jit, static_argnums=(8, 9, 10)) + def _sample_sessions_jax( + key: jax.Array, + human_T: jax.Array, + agent_T: jax.Array, + terminal_mask: jax.Array, + start_idx: int, + term_idx: int, + alpha: float, + n_products: int, + n_sessions: int, + max_steps: int, + n_states: int, + ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]: + k_actor, k_product, k_step = jax.random.split(key, 3) + actor_draw = jax.random.uniform(k_actor, (n_sessions,)) + actors = (actor_draw < alpha).astype(jnp.int32) + products = jax.random.randint( + k_product, (n_sessions,), 0, n_products, dtype=jnp.int32 + ) + + active_init = jnp.ones((n_sessions,), dtype=jnp.bool_) + state_init = jnp.full((n_sessions,), int(start_idx), dtype=jnp.int32) + + def _scan_step(carry, _): + states, active, rng = carry + rng, k = jax.random.split(rng) + probs_h = human_T[states] + probs_a = agent_T[states] + probs = jnp.where(actors[:, None] == 0, probs_h, probs_a) + next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1) + next_state = jnp.where(active, next_state, int(term_idx)) + emitted = jnp.where(active, next_state, -1) + is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)] + next_active = active & (~is_terminal) + carry_states = jnp.where(next_active, next_state, int(term_idx)) + return (carry_states, next_active, rng), emitted + + _, state_t = jax.lax.scan( + _scan_step, (state_init, active_init, k_step), None, length=max_steps + ) + states = state_t.T + lengths = jnp.sum(states >= 0, axis=1, dtype=jnp.int32) + return states, products, actors, lengths + + +def sample_sessions( + key, + transition_data: TransitionData, + alpha: float, + n_products: int, + n_sessions: int, + max_steps: int, +) -> SessionBatch: + if JAX_AVAILABLE: + td = transition_data.to_jax() + states, products, actors, lengths = _sample_sessions_jax( + key, + td.human_T, + td.agent_T, + td.terminal_mask, + int(td.start_idx), + int(td.term_idx), + float(alpha), + int(n_products), + int(n_sessions), + int(max_steps), + int(td.human_T.shape[0]), + ) + return SessionBatch( + states=states, products=products, actors=actors, lengths=lengths + ) + + rng = np.random.default_rng(int(np.asarray(key).reshape(-1)[0])) + n_states = transition_data.human_T.shape[0] + products = rng.integers(0, n_products, size=n_sessions, dtype=np.int32) + actors = (rng.random(size=n_sessions) < alpha).astype(np.int32) + states = np.full((n_sessions, max_steps), -1, dtype=np.int32) + lengths = np.zeros((n_sessions,), dtype=np.int32) + for i in range(n_sessions): + current = int(transition_data.start_idx) + mat = transition_data.agent_T if actors[i] == 1 else transition_data.human_T + for t in range(max_steps): + nxt = int(rng.choice(n_states, p=mat[current])) + states[i, t] = nxt + if transition_data.terminal_mask[nxt]: + lengths[i] = t + 1 + break + current = nxt + if lengths[i] == 0: + lengths[i] = max_steps + return SessionBatch( + states=states, products=products, actors=actors, lengths=lengths + ) + + +if JAX_AVAILABLE: + + @partial(jax.jit, static_argnums=(2,)) + def compute_session_transitions(states, lengths, n_states: int): + src = states[:, :-1] + dst = states[:, 1:] + time_idx = jnp.arange(src.shape[1])[None, :] + valid = (src >= 0) & (dst >= 0) & (time_idx < (lengths[:, None] - 1)) + src_clip = jnp.clip(src, 0, n_states - 1) + dst_clip = jnp.clip(dst, 0, n_states - 1) + src_oh = jax.nn.one_hot(src_clip, n_states) + dst_oh = jax.nn.one_hot(dst_clip, n_states) + counts = jnp.einsum( + "nti,ntj,nt->nij", src_oh, dst_oh, valid.astype(jnp.float32) + ) + row_sums = jnp.sum(counts, axis=-1, keepdims=True) + return counts / (row_sums + 1e-10) + + +else: + + def compute_session_transitions(states, lengths, n_states: int): + trans = np.zeros((states.shape[0], n_states, n_states), dtype=np.float32) + for i in range(states.shape[0]): + for t in range(max(int(lengths[i]) - 1, 0)): + s = int(states[i, t]) + d = int(states[i, t + 1]) + if s >= 0 and d >= 0: + trans[i, s, d] += 1.0 + row_sums = trans.sum(axis=-1, keepdims=True) + return trans / (row_sums + 1e-10) + + +def batch_kl(P, Q_human, Q_agent, eps: float = 1e-10): + p = P + eps + p = p / jnp.sum(p, axis=-1, keepdims=True) + qh = Q_human[None, ...] + eps + qa = Q_agent[None, ...] + eps + delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2)) + delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2)) + return delta_h, delta_a + + +if JAX_AVAILABLE: + batch_kl = jax.jit(batch_kl) + + +def agent_probability_from_kl(delta_h, delta_a, temperature: float = 1.0): + t = jnp.maximum(float(temperature), 1e-6) + exp_h = jnp.exp(-delta_h / t) + exp_a = jnp.exp(-delta_a / t) + return exp_a / (exp_h + exp_a + 1e-10) + + +def estimate_alpha_from_kl(delta_h, delta_a, beta: float = 2.0): + logits = beta * (delta_h - delta_a) + return 1.0 / (1.0 + jnp.exp(-logits)) + + +def weighted_demand(states, products, n_products: int, event_weights): + valid = states >= 0 + state_clip = jnp.clip(states, 0, event_weights.shape[0] - 1) + weights = event_weights[state_clip] * valid + per_session = jnp.sum(weights, axis=1) + demand = jnp.zeros((n_products,), dtype=jnp.float32) + demand = demand.at[products].add(per_session) + total = jnp.sum(demand) + return jnp.where(total > 0.0, (demand / total) * 100.0, demand) + + +if JAX_AVAILABLE: + weighted_demand = jax.jit(weighted_demand, static_argnums=(2,)) + + +def purchase_flags(states, purchase_mask): + state_clip = jnp.clip(states, 0, purchase_mask.shape[0] - 1) + hits = purchase_mask[state_clip] & (states >= 0) + return jnp.any(hits, axis=1) + + +if JAX_AVAILABLE: + purchase_flags = jax.jit(purchase_flags) + + +def revenue_from_demand(prices, demand): + return jnp.dot(prices, demand) + + +if JAX_AVAILABLE: + revenue_from_demand = jax.jit(revenue_from_demand) + + +def reward_with_coi_penalty( + revenue, agent_prob: float, lambda_coi: float, info_value: float +): + leakage = agent_prob * info_value + discount = jnp.clip(1.0 - lambda_coi * leakage, 0.0, 1.0) + return revenue * discount, leakage, discount + + +if JAX_AVAILABLE: + reward_with_coi_penalty = jax.jit(reward_with_coi_penalty) diff --git a/engine/jax/requirements.txt b/engine/jax/requirements.txt new file mode 100644 index 0000000..42ba457 --- /dev/null +++ b/engine/jax/requirements.txt @@ -0,0 +1,5 @@ +flax>=0.8.0 +optax>=0.2.0 +distrax>=0.1.5 +orbax-checkpoint>=0.5.0 +chex>=0.1.8 diff --git a/engine/jax/train.py b/engine/jax/train.py new file mode 100644 index 0000000..f2f4168 --- /dev/null +++ b/engine/jax/train.py @@ -0,0 +1,471 @@ +"""Pure JAX PPO trainer for the PHANTOM environment.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, NamedTuple + +import numpy as np + +try: + import jax + import jax.numpy as jnp + import distrax + import flax.linen as nn + import optax + from flax import serialization + from flax.linen.initializers import constant, orthogonal + from flax.training.train_state import TrainState + + HAS_JAX_STACK = True +except ImportError: + jax = None # type: ignore[assignment] + jnp = None # type: ignore[assignment] + distrax = None # type: ignore[assignment] + optax = None # type: ignore[assignment] + serialization = None # type: ignore[assignment] + + class _ModuleStub: + pass + + class _NNStub: + Module = _ModuleStub + + @staticmethod + def compact(fn): + return fn + + nn = _NNStub() # type: ignore[assignment] + + def constant(*_args, **_kwargs): # type: ignore[override] + return None + + def orthogonal(*_args, **_kwargs): # type: ignore[override] + return None + + class TrainState: # type: ignore[override] + pass + + HAS_JAX_STACK = False + +from .env import PHANTOMJAXEnv, make_env_params + + +class ActorCritic(nn.Module): + action_dim: int + activation: str = "tanh" + + @nn.compact + def __call__(self, x): + activation_fn = nn.relu if self.activation == "relu" else nn.tanh + + actor = nn.Dense( + 64, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(x) + actor = activation_fn(actor) + actor = nn.Dense( + 64, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(actor) + actor = activation_fn(actor) + logits = nn.Dense( + self.action_dim, + kernel_init=orthogonal(0.01), + bias_init=constant(0.0), + )(actor) + + critic = nn.Dense( + 64, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(x) + critic = activation_fn(critic) + critic = nn.Dense( + 64, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(critic) + critic = activation_fn(critic) + value = nn.Dense(1, kernel_init=orthogonal(1.0), bias_init=constant(0.0))( + critic + ) + return distrax.Categorical(logits=logits), jnp.squeeze(value, axis=-1) + + +class Transition(NamedTuple): + done: jax.Array + action: jax.Array + value: jax.Array + reward: jax.Array + log_prob: jax.Array + obs: jax.Array + info: dict[str, jax.Array] + + +def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: + out = { + "algo": str(cfg.get("algo", "ppo")).lower(), + "seed": int(cfg.get("seed", 42)), + "learning_rate": float(cfg.get("learning_rate", 3e-4)), + "gamma": float(cfg.get("gamma", 0.99)), + "gae_lambda": float(cfg.get("gae_lambda", 0.95)), + "clip_range": float(cfg.get("clip_range", 0.2)), + "ent_coef": float(cfg.get("ent_coef", 0.01)), + "vf_coef": float(cfg.get("vf_coef", 0.5)), + "max_grad_norm": float(cfg.get("max_grad_norm", 0.5)), + "activation": str(cfg.get("activation", "relu")), + "total_timesteps": int(cfg.get("total_timesteps", 50_000)), + "eval_episodes": int(cfg.get("eval_episodes", 5)), + "model_dir": str(cfg.get("model_dir", "engine/models")), + "n_products": int(cfg.get("n_products", 10)), + "N": int(cfg.get("N", 100)), + "alpha": float(cfg.get("alpha", 0.3)), + "lambda_coi": float(cfg.get("lambda_coi", 0.2)), + "robust_radius": float(cfg.get("robust_radius", 0.15)), + "robust_points": int(cfg.get("robust_points", 5)), + "info_value": float(cfg.get("info_value", 1.0)), + "price_low": float(cfg.get("price_low", 10.0)), + "price_high": float(cfg.get("price_high", 150.0)), + "action_levels": int(cfg.get("action_levels", 9)), + "action_scale_low": float(cfg.get("action_scale_low", 0.8)), + "action_scale_high": float(cfg.get("action_scale_high", 1.2)), + "max_episode_steps": int(cfg.get("max_steps", 100)), + "max_session_steps": int(cfg.get("max_session_steps", 40)), + "margin_floor": float(cfg.get("margin_floor", 0.05)), + "margin_floor_patience": int(cfg.get("margin_floor_patience", 5)), + "prefer_behavior_data": bool(cfg.get("prefer_behavior_data", True)), + "num_envs": int(cfg.get("jax_num_envs", 16)), + "num_steps": int(cfg.get("jax_num_steps", 128)), + "num_minibatches": int(cfg.get("jax_num_minibatches", 4)), + "update_epochs": int(cfg.get("jax_update_epochs", 4)), + "anneal_lr": bool(cfg.get("jax_anneal_lr", True)), + } + rollout = out["num_envs"] * out["num_steps"] + out["num_updates"] = max(1, out["total_timesteps"] // max(rollout, 1)) + out["minibatch_size"] = max(1, rollout // max(out["num_minibatches"], 1)) + return out + + +def _select_env_state(done: jax.Array, keep: jax.Array, reset: jax.Array) -> jax.Array: + mask = done + while mask.ndim < keep.ndim: + mask = mask[..., None] + return jnp.where(mask, reset, keep) + + +def make_train(config: dict[str, Any]): + cfg = _jax_cfg(config) + env_params = make_env_params( + n_products=cfg["n_products"], + alpha=cfg["alpha"], + n_sessions=cfg["N"], + lambda_coi=cfg["lambda_coi"], + robust_radius=cfg["robust_radius"], + robust_points=cfg["robust_points"], + info_value=cfg["info_value"], + action_levels=cfg["action_levels"], + action_scale_low=cfg["action_scale_low"], + action_scale_high=cfg["action_scale_high"], + price_low=cfg["price_low"], + price_high=cfg["price_high"], + max_episode_steps=cfg["max_episode_steps"], + max_session_steps=cfg["max_session_steps"], + margin_floor=cfg["margin_floor"], + margin_floor_patience=cfg["margin_floor_patience"], + prefer_behavior_data=cfg["prefer_behavior_data"], + ) + env = PHANTOMJAXEnv(env_params) + network = ActorCritic(env.action_space_n(), activation=cfg["activation"]) + + def linear_schedule(count: jax.Array) -> jax.Array: + updates_done = count // (cfg["num_minibatches"] * cfg["update_epochs"]) + frac = 1.0 - updates_done / max(cfg["num_updates"], 1) + return cfg["learning_rate"] * frac + + def train(rng: jax.Array): + rng, init_key = jax.random.split(rng) + init_obs = jnp.zeros((env.observation_dim(),), dtype=jnp.float32) + params = network.init(init_key, init_obs) + + if cfg["anneal_lr"]: + tx = optax.chain( + optax.clip_by_global_norm(cfg["max_grad_norm"]), + optax.adam(learning_rate=linear_schedule, eps=1e-5), + ) + else: + tx = optax.chain( + optax.clip_by_global_norm(cfg["max_grad_norm"]), + optax.adam(cfg["learning_rate"], eps=1e-5), + ) + train_state = TrainState.create(apply_fn=network.apply, params=params, tx=tx) + + rng, reset_key = jax.random.split(rng) + reset_keys = jax.random.split(reset_key, cfg["num_envs"]) + obs, env_state = jax.vmap(env.reset)(reset_keys) + + def _update_step(runner_state, _): + def _env_step(runner_state, _): + train_state, env_state, last_obs, rng = runner_state + rng, action_key = jax.random.split(rng) + policy, value = network.apply(train_state.params, last_obs) + action = policy.sample(seed=action_key) + log_prob = policy.log_prob(action) + + rng, step_key = jax.random.split(rng) + step_keys = jax.random.split(step_key, cfg["num_envs"]) + nxt_obs, nxt_state, reward, done, info = jax.vmap( + env.step, + in_axes=(0, 0, 0), + )(step_keys, env_state, action) + + rng, reset_key = jax.random.split(rng) + reset_keys = jax.random.split(reset_key, cfg["num_envs"]) + rst_obs, rst_state = jax.vmap(env.reset)(reset_keys) + obs_next = jnp.where(done[:, None], rst_obs, nxt_obs) + env_next = jax.tree_util.tree_map( + lambda keep, reset: _select_env_state(done, keep, reset), + nxt_state, + rst_state, + ) + transition = Transition( + done=done, + action=action, + value=value, + reward=reward, + log_prob=log_prob, + obs=last_obs, + info=info, + ) + return (train_state, env_next, obs_next, rng), transition + + runner_state, traj_batch = jax.lax.scan( + _env_step, + runner_state, + None, + length=cfg["num_steps"], + ) + + train_state, env_state, last_obs, rng = runner_state + _, last_value = network.apply(train_state.params, last_obs) + + def _compute_gae(traj_batch, last_value): + def _gae_step(carry, transition): + gae, next_value = carry + delta = ( + transition.reward + + cfg["gamma"] * next_value * (1.0 - transition.done) + - transition.value + ) + gae = ( + delta + + cfg["gamma"] + * cfg["gae_lambda"] + * (1.0 - transition.done) + * gae + ) + return (gae, transition.value), gae + + _, advantages = jax.lax.scan( + _gae_step, + (jnp.zeros_like(last_value), last_value), + traj_batch, + reverse=True, + unroll=16, + ) + targets = advantages + traj_batch.value + return advantages, targets + + advantages, targets = _compute_gae(traj_batch, last_value) + + def _update_epoch(update_state, _): + def _update_minibatch(train_state, batch_info): + traj_b, adv_b, tgt_b = batch_info + + def _loss_fn(params, traj_b, adv_b, tgt_b): + policy, value = network.apply(params, traj_b.obs) + log_prob = policy.log_prob(traj_b.action) + + value_clipped = traj_b.value + (value - traj_b.value).clip( + -cfg["clip_range"], cfg["clip_range"] + ) + value_loss = ( + 0.5 + * jnp.maximum( + jnp.square(value - tgt_b), + jnp.square(value_clipped - tgt_b), + ).mean() + ) + + adv_norm = (adv_b - adv_b.mean()) / (adv_b.std() + 1e-8) + ratio = jnp.exp(log_prob - traj_b.log_prob) + loss_actor = -jnp.minimum( + ratio * adv_norm, + jnp.clip( + ratio, + 1.0 - cfg["clip_range"], + 1.0 + cfg["clip_range"], + ) + * adv_norm, + ).mean() + entropy = policy.entropy().mean() + total_loss = ( + loss_actor + + cfg["vf_coef"] * value_loss + - cfg["ent_coef"] * entropy + ) + return total_loss, (value_loss, loss_actor, entropy) + + grad_fn = jax.value_and_grad(_loss_fn, has_aux=True) + (_, _), grads = grad_fn(train_state.params, traj_b, adv_b, tgt_b) + train_state = train_state.apply_gradients(grads=grads) + return train_state, jnp.asarray(0.0, dtype=jnp.float32) + + train_state, traj_batch, advantages, targets, rng = update_state + rng, perm_key = jax.random.split(rng) + batch_size = cfg["num_envs"] * cfg["num_steps"] + permutation = jax.random.permutation(perm_key, batch_size) + batch = (traj_batch, advantages, targets) + batch = jax.tree_util.tree_map( + lambda x: x.reshape((batch_size,) + x.shape[2:]), + batch, + ) + shuffled = jax.tree_util.tree_map( + lambda x: jnp.take(x, permutation, axis=0), + batch, + ) + minibatches = jax.tree_util.tree_map( + lambda x: x.reshape( + (cfg["num_minibatches"], cfg["minibatch_size"]) + x.shape[1:] + ), + shuffled, + ) + train_state, _ = jax.lax.scan( + _update_minibatch, train_state, minibatches + ) + return (train_state, traj_batch, advantages, targets, rng), None + + update_state = (train_state, traj_batch, advantages, targets, rng) + update_state, _ = jax.lax.scan( + _update_epoch, + update_state, + None, + length=cfg["update_epochs"], + ) + train_state = update_state[0] + rng = update_state[-1] + + metric = { + "reward": jnp.mean(traj_batch.reward), + "revenue": jnp.mean(traj_batch.info["revenue"]), + "agent_prob": jnp.mean(traj_batch.info["agent_prob"]), + "alpha_adv": jnp.mean(traj_batch.info["alpha_adv"]), + "coi_leakage": jnp.mean(traj_batch.info["coi_leakage"]), + } + runner_state = (train_state, env_state, last_obs, rng) + return runner_state, metric + + runner_state = (train_state, env_state, obs, rng) + runner_state, metric = jax.lax.scan( + _update_step, + runner_state, + None, + length=cfg["num_updates"], + ) + return { + "runner_state": runner_state, + "metrics": metric, + } + + return train, network, env, cfg + + +def evaluate_policy( + *, + network: ActorCritic, + params: Any, + env: PHANTOMJAXEnv, + episodes: int, + seed: int, +) -> dict[str, float]: + rewards: list[float] = [] + revenues: list[float] = [] + key = jax.random.PRNGKey(seed) + + for _ in range(int(episodes)): + key, reset_key = jax.random.split(key) + obs, state = env.reset(reset_key) + ep_reward = 0.0 + ep_revenue = 0.0 + done = False + steps = 0 + + while not done and steps < int(env.params.max_episode_steps): + policy, _ = network.apply(params, obs) + action = jnp.argmax(policy.logits) + key, step_key = jax.random.split(key) + obs, state, reward, done_flag, info = env.step(step_key, state, action) + ep_reward += float(np.asarray(reward)) + ep_revenue += float(np.asarray(info["revenue"])) + done = bool(np.asarray(done_flag)) + steps += 1 + + rewards.append(ep_reward) + revenues.append(ep_revenue) + + return { + "eval/reward": float(np.mean(rewards)), + "eval/revenue": float(np.mean(revenues)), + "eval/reward_std": float(np.std(rewards)), + "eval/revenue_std": float(np.std(revenues)), + } + + +def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: + if not HAS_JAX_STACK: + raise ImportError( + "JAX PPO path requires jax, flax, optax, and distrax. " + "Install engine/jax/requirements.txt on this machine first." + ) + + run_cfg = _jax_cfg(cfg) + if run_cfg["algo"] != "ppo": + raise ValueError( + f"JAX backend currently supports algo='ppo' only, got '{run_cfg['algo']}'" + ) + + train_fn, network, env, run_cfg = make_train(run_cfg) + train_jit = jax.jit(train_fn) + rng = jax.random.PRNGKey(run_cfg["seed"]) + out = train_jit(rng) + + train_state = out["runner_state"][0] + metric = out["metrics"] + metrics = { + "train/reward": float(np.mean(np.asarray(metric["reward"]))), + "train/revenue": float(np.mean(np.asarray(metric["revenue"]))), + "train/agent_prob": float(np.mean(np.asarray(metric["agent_prob"]))), + "train/alpha_adv": float(np.mean(np.asarray(metric["alpha_adv"]))), + "train/coi_leakage": float(np.mean(np.asarray(metric["coi_leakage"]))), + "train/global_step": int( + run_cfg["num_updates"] * run_cfg["num_steps"] * run_cfg["num_envs"] + ), + } + + eval_metrics = evaluate_policy( + network=network, + params=train_state.params, + env=env, + episodes=run_cfg["eval_episodes"], + seed=run_cfg["seed"] + 7, + ) + metrics.update(eval_metrics) + + model_dir = Path(run_cfg["model_dir"]) + model_dir.mkdir(parents=True, exist_ok=True) + model_path = model_dir / "phantom_ppo_jax.msgpack" + model_path.write_bytes(serialization.to_bytes(train_state.params)) + metrics["model/path"] = str(model_path) + return {"params": train_state.params}, metrics diff --git a/engine/lib/callbacks.py b/engine/lib/callbacks.py new file mode 100644 index 0000000..9e16d4b --- /dev/null +++ b/engine/lib/callbacks.py @@ -0,0 +1,119 @@ +"""Training callbacks for W&B/TensorBoard logging - reads from info dict.""" + +from stable_baselines3.common.callbacks import BaseCallback, EvalCallback +import numpy as np + +try: + import wandb + + HAS_WANDB = True +except ImportError: + HAS_WANDB = False + + +class MetricsCallback(BaseCallback): + """Training metrics logger - reads info['economics'], logs to W&B.""" + + def __init__( + self, log_histograms: bool = True, log_freq: int = 100, verbose: int = 0 + ): + super().__init__(verbose) + self.log_histograms = log_histograms + self.log_freq = log_freq + self._episode_revenues: list[float] = [] + + def _on_step(self) -> bool: + if not HAS_WANDB or wandb.run is None: + return True + + for info in self.locals.get("infos", []): + if "economics" not in info: + continue + + econ = info["economics"] + t = self.num_timesteps + + payload = { + "economics/revenue": econ["revenue"], + "economics/margin": econ["margin"], + "coi/level": econ["coi_level"], + "economics/regret": econ["regret"], + } + if "coi_mix" in econ: + payload["coi/mix"] = econ["coi_mix"] + if "coi_base" in econ: + payload["coi/base"] = econ["coi_base"] + if "coi_leakage" in econ: + payload["coi/leakage"] = econ["coi_leakage"] + if "coi_penalty" in econ: + payload["coi/penalty"] = econ["coi_penalty"] + wandb.log(payload, step=t) + + self._episode_revenues.append(econ["revenue"]) + + # histograms at log_freq intervals + if self.log_histograms and self.num_timesteps % self.log_freq == 0: + for info in self.locals.get("infos", []): + if "prices" in info: + wandb.log( + {"distributions/prices": wandb.Histogram(info["prices"])}, + step=self.num_timesteps, + ) + if "demand" in info: + wandb.log( + {"distributions/demand": wandb.Histogram(info["demand"])}, + step=self.num_timesteps, + ) + + return True + + def _on_rollout_end(self) -> None: + if not HAS_WANDB or wandb.run is None or not self._episode_revenues: + return + wandb.log( + { + "episode/mean_revenue": np.mean(self._episode_revenues), + "episode/total_revenue": np.sum(self._episode_revenues), + }, + step=self.num_timesteps, + ) + self._episode_revenues = [] + + +class EvalMetricsCallback(EvalCallback): + """Deterministic evaluation - true performance without exploration noise.""" + + def __init__( + self, eval_env, eval_freq: int = 1000, n_eval_episodes: int = 5, **kwargs + ): + super().__init__( + eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs + ) + self._eval_revenues: list[float] = [] + + def _on_step(self) -> bool: + result = super()._on_step() + + if not HAS_WANDB or wandb.run is None: + return result + + # log eval metrics after evaluation runs + if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"): + wandb.log( + { + "eval/mean_reward": self.last_mean_reward, + "eval/mean_revenue": np.mean(self._eval_revenues) + if self._eval_revenues + else 0, + }, + step=self.num_timesteps, + ) + self._eval_revenues = [] + + return result + + def _log_success_callback(self, locals_: dict, globals_: dict) -> None: + # called after each eval episode + info = locals_.get("info", {}) + if "economics" in info: + self._eval_revenues.append(info["economics"]["revenue"]) diff --git a/engine/lib/coi.py b/engine/lib/coi.py new file mode 100644 index 0000000..33267b5 --- /dev/null +++ b/engine/lib/coi.py @@ -0,0 +1,76 @@ +import numpy as np +from typing import Dict + + +def compute_agent_probability( + trajectory: list, human_transitions: Dict, agent_transitions: Dict +) -> float: + """estimate agent probability via KL divergence between trajectory transitions and reference models + + compares empirical trajectory transition distribution to human/agent prototypes + + args: + trajectory: list of state/event strings from session + human_transitions: reference transition dict from human MDP (event->event->prob) + agent_transitions: reference transition dict from agent MDP (event->event->prob) + + returns: + agent probability in [0, 1] via softmax over KL divergences + """ + if len(trajectory) < 2: + return 0.0 # insufficient data, assume human + + # build empirical transition distribution from trajectory + trans_counts = {} + for s, s_next in zip(trajectory[:-1], trajectory[1:]): + if s not in trans_counts: + trans_counts[s] = {} + trans_counts[s][s_next] = trans_counts[s].get(s_next, 0) + 1 + + # normalize to probabilities + empirical = {} + for s, nxt in trans_counts.items(): + total = sum(nxt.values()) + empirical[s] = {s_n: cnt / total for s_n, cnt in nxt.items()} + + # compute KL divergence to each prototype + def kl_div(p_dist: Dict, q_dist: Dict) -> float: + eps = 1e-10 + # aggregate over all source states in empirical dist + kl = 0.0 + for s in p_dist: + if s not in q_dist: + continue # skip states not in reference + p_trans, q_trans = p_dist[s], q_dist[s] + for k in p_trans: + p_val = p_trans[k] + eps + q_val = q_trans.get(k, 0.0) + eps + kl += p_val * np.log(p_val / q_val) + return kl + + kl_human = kl_div(empirical, human_transitions) + kl_agent = kl_div(empirical, agent_transitions) + + # convert to probability via softmax (lower KL = higher prob) + # agent_prob = exp(-kl_agent) / (exp(-kl_human) + exp(-kl_agent)) + exp_h = np.exp(-kl_human) + exp_a = np.exp(-kl_agent) + return float(exp_a / (exp_h + exp_a + 1e-10)) + + +def extract_purchases(trajectories: list) -> Dict[int, int]: + purchases: Dict[int, int] = {} + for traj in trajectories: + if traj and "checkout" in traj[-1] and "_product" in traj[-1]: + prod_id = int(traj[-1].rsplit("_product", 1)[1]) + purchases[prod_id] = purchases.get(prod_id, 0) + 1 + return purchases + + +def compute_uplift_coi( + prices: np.ndarray, purchases: Dict[int, int], baseline_prices: np.ndarray +) -> float: + # TODO: consider view-weighted fractional purchase for denser signal + return float( + sum(max(0.0, prices[k] - baseline_prices[k]) * n for k, n in purchases.items()) + ) diff --git a/engine/lib/discrete.py b/engine/lib/discrete.py new file mode 100644 index 0000000..9cee3ad --- /dev/null +++ b/engine/lib/discrete.py @@ -0,0 +1,70 @@ +from collections import defaultdict +import gymnasium as gym +from gymnasium import spaces +import numpy as np + + +class DiscretePriceActionWrapper(gym.ActionWrapper): + def __init__( + self, + env: gym.Env, + n_levels: int = 9, + min_scale: float = 0.8, + max_scale: float = 1.2, + ): + super().__init__(env) + self.scales = np.linspace(min_scale, max_scale, n_levels, dtype=np.float32) + self.action_space = spaces.Discrete(n_levels) + + def action(self, action: int): + scale = float(self.scales[int(action)]) + cur = np.asarray(self.env.unwrapped._prices, dtype=np.float32) + lo, hi = self.env.unwrapped.price_bounds + return np.clip(cur * scale, lo, hi).astype(np.float32) + + +class EventQTable: + def __init__( + self, + n_actions: int, + n_products: int, + price_bounds: tuple, + lr: float = 0.1, + gamma: float = 0.99, + n_bins: int = 6, + ): + self.n_actions = int(n_actions) + self.n_products = int(n_products) + self.lr = float(lr) + self.gamma = float(gamma) + self.q = defaultdict(lambda: np.zeros(self.n_actions, dtype=np.float32)) + lo, hi = price_bounds + self.demand_bins = np.linspace(0.0, 100.0, n_bins + 1)[1:-1] + self.price_bins = np.linspace(lo, hi, n_bins + 1)[1:-1] + + def encode(self, obs: np.ndarray) -> tuple: + obs = np.asarray(obs, dtype=np.float32) + d = obs[: self.n_products] + p = obs[self.n_products : 2 * self.n_products] + d_mean = float(np.mean(d)) if d.size else 0.0 + d_std = float(np.std(d)) if d.size else 0.0 + p_mean = float(np.mean(p)) if p.size else 0.0 + return ( + int(np.digitize(d_mean, self.demand_bins)), + int(np.digitize(d_std, self.demand_bins)), + int(np.digitize(p_mean, self.price_bins)), + ) + + def act(self, obs: np.ndarray, eps: float = 0.0) -> tuple[int, tuple]: + s = self.encode(obs) + if np.random.random() < eps: + return int(np.random.randint(self.n_actions)), s + return int(np.argmax(self.q[s])), s + + def update(self, s: tuple, a: int, r: float, s2: tuple, done: bool): + target = r + (0.0 if done else self.gamma * float(np.max(self.q[s2]))) + self.q[s][a] += self.lr * (target - self.q[s][a]) + + def predict(self, obs: np.ndarray, deterministic: bool = True): + a, _ = self.act(obs, 0.0 if deterministic else 0.05) + return a, None diff --git a/engine/lib/providers.py b/engine/lib/providers.py new file mode 100644 index 0000000..19d2788 --- /dev/null +++ b/engine/lib/providers.py @@ -0,0 +1,182 @@ +"""Provider benchmarking - compare pricing strategies across contamination levels.""" + +from dataclasses import dataclass, field +from typing import Callable, Any +import numpy as np +import pandas as pd + +try: + import wandb + + HAS_WANDB = True +except ImportError: + HAS_WANDB = False + + +class RandomBaseline: + """uniform random action selection as a lower-bound baseline""" + + def __init__(self, n_actions: int): + self.n = n_actions + + def __call__(self, obs): + return int(np.random.randint(self.n)) + + def predict(self, obs, **kw): + return self(obs), None + + +class SurgeBaseline: + """heuristic surge pricing: boost price when demand is above threshold, discount when below. + matches the naive pricing rule from thesis Section 3.3.2""" + + def __init__( + self, n_actions: int, high_threshold: float = 60.0, low_threshold: float = 30.0 + ): + self.n = n_actions + self.mid = n_actions // 2 # identity action (scale ~1.0) + self.high_t = high_threshold + self.low_t = low_threshold + + def __call__(self, obs): + obs = np.asarray(obs, dtype=np.float32) + n_prod = len(obs) // 2 + demand_mean = float(np.mean(obs[:n_prod])) if n_prod > 0 else 0.0 + if demand_mean >= self.high_t: + return min(self.mid + 2, self.n - 1) # surge: two levels above identity + if demand_mean <= self.low_t: + return max(self.mid - 2, 0) # discount: two levels below identity + return self.mid # hold + + def predict(self, obs, **kw): + return self(obs), None + + +@dataclass +class ProviderResult: + """Single benchmark result for one provider at one alpha level.""" + + name: str + alpha: float + total_revenue: float + mean_revenue: float + coi_level: float + coi_preserved_pct: float # vs alpha=0 baseline + margin_integrity: float + regret: float + episodes: int + + +@dataclass +class BenchmarkConfig: + """Configuration for provider benchmark runs.""" + + n_episodes: int = 100 + alpha_range: list[float] = field(default_factory=lambda: [0.0, 0.1, 0.3, 0.5]) + baseline_name: str = "fixed" + + +class ProviderBenchmark: + """Compare pricing providers to prove margin preservation across contamination levels. + + Usage: + def env_factory(alpha): + return EconomicMetricsWrapper(PHANTOM(alpha=alpha)) + + providers = { + "fixed": lambda obs: np.ones(10) * 50, + "learned": model.predict, + } + + benchmark = ProviderBenchmark(env_factory, providers) + results = benchmark.run() + print(benchmark.summary_table()) + """ + + def __init__( + self, + env_factory: Callable[[float], Any], + providers: dict[str, Callable], + config: BenchmarkConfig | None = None, + ): + self.env_factory = env_factory # fn(alpha) -> wrapped env + self.providers = providers # {name: fn(obs) -> action} + self.config = config or BenchmarkConfig() + self.results: list[ProviderResult] = [] + + def run(self) -> list[ProviderResult]: + """Run benchmark across all providers and alpha levels.""" + baseline_coi: dict[str, float] = {} # {provider: coi at alpha=0} + + for alpha in self.config.alpha_range: + env = self.env_factory(alpha) + + for name, policy_fn in self.providers.items(): + revenues, coi_levels, margins = [], [], [] + + for _ in range(self.config.n_episodes): + obs, _ = env.reset() + episode_revenue = 0.0 + done = False + + while not done: + action = policy_fn(obs) + # handle sb3 model.predict returning tuple + if isinstance(action, tuple): + action = action[0] + obs, reward, term, trunc, info = env.step(action) + done = term or trunc + + econ = info.get("economics", {}) + episode_revenue += econ.get("revenue", 0) + coi_levels.append(econ.get("coi_level", 0)) + margins.append(econ.get("margin", 0)) + + revenues.append(episode_revenue) + + mean_coi = np.mean(coi_levels) if coi_levels else 0.0 + if alpha == 0.0: + baseline_coi[name] = mean_coi + + base = baseline_coi.get(name, mean_coi) + coi_preserved = mean_coi / base if base > 0 else 1.0 + + result = ProviderResult( + name=name, + alpha=alpha, + total_revenue=float(np.sum(revenues)), + mean_revenue=float(np.mean(revenues)), + coi_level=mean_coi, + coi_preserved_pct=coi_preserved * 100, + margin_integrity=float(np.mean(margins)) if margins else 0.0, + regret=0.0, # compute vs optimal if known + episodes=self.config.n_episodes, + ) + self.results.append(result) + + # log to wandb if available + if HAS_WANDB and wandb.run is not None: + wandb.log( + { + f"benchmark/{name}/revenue": result.mean_revenue, + f"benchmark/{name}/coi_preserved": result.coi_preserved_pct, + f"benchmark/{name}/margin": result.margin_integrity, + "benchmark/alpha": alpha, + } + ) + + return self.results + + def to_dataframe(self) -> pd.DataFrame: + """Convert results to pandas DataFrame.""" + return pd.DataFrame([r.__dict__ for r in self.results]) + + def summary_table(self) -> pd.DataFrame: + """Pivot table: providers x alpha with revenue/COI metrics.""" + df = self.to_dataframe() + return df.pivot_table( + index="name", + columns="alpha", + values=["mean_revenue", "coi_preserved_pct", "margin_integrity"], + aggfunc="mean", + ) diff --git a/engine/lib/wrappers.py b/engine/lib/wrappers.py new file mode 100644 index 0000000..3d74b79 --- /dev/null +++ b/engine/lib/wrappers.py @@ -0,0 +1,77 @@ +"""Economic metrics wrapper - calculates thesis-aligned KPIs and injects into info dict.""" + +import gymnasium as gym +import numpy as np + + +class EconomicMetricsWrapper(gym.Wrapper): + """Calculates thesis-aligned economic metrics per step, injects into info. + + Metrics follow thesis definitions: + - COI level: E[P] - p_min (Definition 1) + - Margin: (avg_price - p_min) / avg_price + - Regret: 1 - (revenue / baseline_revenue) + """ + + def __init__( + self, env: gym.Env, p_min: float = 10.0, baseline_revenue: float | None = None + ): + super().__init__(env) + self.p_min = p_min + self.baseline_revenue = baseline_revenue + self._price_history: list[np.ndarray] = [] + self._revenue_history: list[float] = [] + + def reset(self, **kwargs): + obs, info = self.env.reset(**kwargs) + self._price_history = [] + self._revenue_history = [] + return obs, info + + def step(self, action): + obs, reward, terminated, truncated, info = self.env.step(action) + + # extract from unwrapped env + prices = self.env.unwrapped._prices + demand_dict = self.env.unwrapped._demand + demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))]) + alpha = self.env.unwrapped.alpha + + # core calculations + revenue = float(np.sum(prices * demand)) + avg_price = float(np.mean(prices)) + margin = (avg_price - self.p_min) / max(avg_price, 1e-6) + coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1 + + self._price_history.append(prices.copy()) + self._revenue_history.append(revenue) + + # regret vs baseline (golden path) + regret = 0.0 + if self.baseline_revenue and self.baseline_revenue > 0: + regret = 1.0 - (revenue / self.baseline_revenue) + + # inject structured metrics into info + info["economics"] = { + "revenue": revenue, + "margin": margin, + "coi_level": coi_level, + "regret": regret, + } + for key in ("coi_mix", "coi_base", "coi_leakage", "coi_penalty"): + if key in info: + info["economics"][key] = info[key] + info["prices"] = prices.copy() + info["demand"] = demand.copy() + + return obs, reward, terminated, truncated, info + + @property + def episode_revenue(self) -> float: + return sum(self._revenue_history) + + @property + def episode_mean_price(self) -> float: + if not self._price_history: + return 0.0 + return float(np.mean([np.mean(p) for p in self._price_history])) diff --git a/engine/sweeps/model_mix.yaml b/engine/sweeps/model_mix.yaml new file mode 100644 index 0000000..28a7f38 --- /dev/null +++ b/engine/sweeps/model_mix.yaml @@ -0,0 +1,84 @@ +method: random +metric: + name: sweep/score + goal: maximize +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + values: [ppo, a2c, dqn, qtable] + total_timesteps: + values: [30000, 50000, 80000] + seed: + values: [13, 42, 77] + n_products: + values: [8, 10, 12] + alpha: + distribution: uniform + min: 0.1 + max: 0.6 + lambda_coi: + distribution: uniform + min: 0.05 + max: 0.6 + robust_radius: + distribution: uniform + min: 0.0 + max: 0.3 + robust_points: + values: [3, 5, 7] + info_value: + distribution: uniform + min: 0.5 + max: 2.0 + revenue_weight: + values: [0.005, 0.01, 0.02] + learning_rate: + distribution: log_uniform_values + min: 1.0e-5 + max: 1.0e-3 + gamma: + values: [0.97, 0.99, 0.995] + buffer_size: + values: [20000, 50000, 100000] + batch_size: + values: [128, 256, 512] + tau: + values: [0.002, 0.005, 0.01] + train_freq: + values: [1, 4, 8] + learning_starts: + values: [500, 1000, 3000] + n_steps: + values: [512, 1024, 2048] + n_epochs: + values: [5, 10, 20] + gae_lambda: + values: [0.9, 0.95, 0.98] + clip_range: + values: [0.1, 0.2, 0.3] + ent_coef: + values: [0.0, 0.005, 0.01] + target_update_interval: + values: [500, 1000, 2000] + exploration_fraction: + values: [0.1, 0.2, 0.3] + exploration_final_eps: + values: [0.01, 0.03, 0.05] + action_levels: + values: [7, 9, 11] + action_scale_low: + values: [0.75, 0.8, 0.85] + action_scale_high: + values: [1.15, 1.2, 1.25] + q_lr: + values: [0.03, 0.05, 0.1, 0.2] + eps_start: + value: 1.0 + eps_end: + values: [0.02, 0.05, 0.1] + eps_decay: + values: [0.999, 0.9995, 0.9999] diff --git a/engine/sweeps/models_only.yaml b/engine/sweeps/models_only.yaml new file mode 100644 index 0000000..e0bd708 --- /dev/null +++ b/engine/sweeps/models_only.yaml @@ -0,0 +1,85 @@ +method: grid +metric: + name: sweep/score + goal: maximize +run_cap: 4 +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + values: [ppo, a2c, dqn, qtable] + seed: + value: 42 + total_timesteps: + value: 12000 + eval_episodes: + value: 3 + eval_freq: + value: 500 + log_freq: + value: 100 + revenue_weight: + value: 0.01 + n_products: + value: 8 + N: + value: 80 + alpha: + value: 0.3 + lambda_coi: + value: 0.2 + robust_radius: + value: 0.0 + robust_points: + value: 1 + info_value: + value: 1.0 + learning_rate: + value: 0.0003 + gamma: + value: 0.99 + buffer_size: + value: 20000 + batch_size: + value: 128 + tau: + value: 0.005 + train_freq: + value: 1 + learning_starts: + value: 500 + n_steps: + value: 512 + n_epochs: + value: 10 + gae_lambda: + value: 0.95 + clip_range: + value: 0.2 + ent_coef: + value: 0.0 + target_update_interval: + value: 500 + exploration_fraction: + value: 0.2 + exploration_final_eps: + value: 0.05 + action_levels: + value: 7 + action_scale_low: + value: 0.9 + action_scale_high: + value: 1.1 + q_lr: + value: 0.1 + q_bins: + value: 6 + eps_start: + value: 1.0 + eps_end: + value: 0.05 + eps_decay: + value: 0.9995 diff --git a/engine/sweeps/sac_tune.yaml b/engine/sweeps/sac_tune.yaml new file mode 100644 index 0000000..97558cf --- /dev/null +++ b/engine/sweeps/sac_tune.yaml @@ -0,0 +1,54 @@ +method: bayes +metric: + name: sweep/score + goal: maximize +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + value: sac + total_timesteps: + values: [50000, 80000, 120000] + seed: + values: [13, 42, 77] + alpha: + distribution: uniform + min: 0.15 + max: 0.55 + n_products: + values: [8, 10, 12] + lambda_coi: + distribution: uniform + min: 0.05 + max: 0.5 + robust_radius: + distribution: uniform + min: 0.05 + max: 0.3 + robust_points: + values: [3, 5, 7] + info_value: + distribution: uniform + min: 0.5 + max: 2.0 + revenue_weight: + values: [0.005, 0.01, 0.02] + learning_rate: + distribution: log_uniform_values + min: 3.0e-5 + max: 1.0e-3 + gamma: + values: [0.98, 0.99, 0.995] + buffer_size: + values: [50000, 100000, 200000] + batch_size: + values: [128, 256, 512] + tau: + values: [0.002, 0.005, 0.01] + train_freq: + values: [1, 4, 8] + learning_starts: + values: [1000, 3000, 5000] diff --git a/engine/sweeps/small_arch_compare.yaml b/engine/sweeps/small_arch_compare.yaml new file mode 100644 index 0000000..2eae9a0 --- /dev/null +++ b/engine/sweeps/small_arch_compare.yaml @@ -0,0 +1,86 @@ +method: random +metric: + name: sweep/score + goal: maximize +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + values: [ppo, a2c, dqn, qtable] + arch: + values: [tiny, small, medium] + activation: + values: [relu, tanh] + total_timesteps: + values: [8000, 12000, 20000] + seed: + values: [13, 42, 77] + n_products: + values: [6, 8, 10] + alpha: + distribution: uniform + min: 0.1 + max: 0.5 + lambda_coi: + distribution: uniform + min: 0.05 + max: 0.4 + robust_radius: + values: [0.0, 0.1, 0.2] + robust_points: + values: [3, 5] + info_value: + values: [0.75, 1.0, 1.5] + revenue_weight: + values: [0.005, 0.01, 0.02] + learning_rate: + distribution: log_uniform_values + min: 1.0e-5 + max: 5.0e-4 + gamma: + values: [0.98, 0.99] + buffer_size: + values: [10000, 30000, 50000] + batch_size: + values: [64, 128, 256] + tau: + values: [0.002, 0.005, 0.01] + train_freq: + values: [1, 4] + learning_starts: + values: [500, 1000, 2000] + n_steps: + values: [256, 512, 1024] + n_epochs: + values: [5, 10] + gae_lambda: + values: [0.9, 0.95] + clip_range: + values: [0.1, 0.2] + ent_coef: + values: [0.0, 0.005] + target_update_interval: + values: [500, 1000] + exploration_fraction: + values: [0.1, 0.2] + exploration_final_eps: + values: [0.02, 0.05] + action_levels: + values: [5, 7, 9] + action_scale_low: + values: [0.85, 0.9] + action_scale_high: + values: [1.1, 1.15] + q_lr: + values: [0.05, 0.1, 0.2] + q_bins: + values: [4, 6, 8] + eps_start: + value: 1.0 + eps_end: + values: [0.02, 0.05] + eps_decay: + values: [0.999, 0.9995] diff --git a/engine/train.py b/engine/train.py index e059593..8e4eb07 100644 --- a/engine/train.py +++ b/engine/train.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import argparse import json +import os from pathlib import Path import numpy as np -from gymnasium.wrappers import FlattenObservation try: import wandb @@ -20,9 +22,7 @@ try: except ImportError: HAS_SB3 = False -from .wrapper import PHANTOM -from .lib import EconomicMetricsWrapper, MetricsCallback -from .lib.discrete import EventQTable +from .jax import JAX_AVAILABLE DEFAULT_CFG = { @@ -69,14 +69,34 @@ DEFAULT_CFG = { "arch": "small", "activation": "relu", "q_bins": 6, + "max_steps": 100, + "margin_floor": 0.05, + "margin_floor_patience": 5, + "use_jax": False, + "jax_num_envs": 16, + "jax_num_steps": 128, + "jax_num_minibatches": 4, + "jax_update_epochs": 4, + "jax_anneal_lr": True, } +def _truthy(value: str | bool | None) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + def _cfg(raw: dict | None = None) -> dict: cfg = dict(DEFAULT_CFG) if raw: cfg.update({k: v for k, v in raw.items() if v is not None}) cfg["algo"] = str(cfg["algo"]).lower() + cfg["use_jax"] = _truthy(cfg.get("use_jax")) or _truthy( + os.environ.get("PHANTOM_USE_JAX") + ) return cfg @@ -89,6 +109,11 @@ def _wandb_cfg_dict() -> dict: def make_env(cfg: dict): + from gymnasium.wrappers import FlattenObservation + + from .wrapper import PHANTOM + from .lib.wrappers import EconomicMetricsWrapper + env = PHANTOM( n_products=int(cfg["n_products"]), alpha=float(cfg["alpha"]), @@ -101,6 +126,9 @@ def make_env(cfg: dict): action_levels=int(cfg["action_levels"]), action_scale_low=float(cfg["action_scale_low"]), action_scale_high=float(cfg["action_scale_high"]), + max_steps=int(cfg.get("max_steps", 100)), + margin_floor=float(cfg.get("margin_floor", 0.05)), + margin_floor_patience=int(cfg.get("margin_floor_patience", 5)), render_mode=None, ) env = EconomicMetricsWrapper(env) @@ -235,6 +263,8 @@ def build_model(cfg: dict, env): def train_qtable(cfg: dict) -> tuple[EventQTable, dict]: + from .lib.discrete import EventQTable + np.random.seed(int(cfg["seed"])) env = make_env(cfg) eval_env = make_env(cfg) @@ -275,6 +305,8 @@ def train_qtable(cfg: dict) -> tuple[EventQTable, dict]: def train_sb3(cfg: dict) -> tuple[object, dict]: if not HAS_SB3: raise ImportError("stable-baselines3 is required for SB3 models") + from .lib.callbacks import MetricsCallback + env = make_env(cfg) eval_env = make_env(cfg) env = Monitor(env) @@ -303,7 +335,20 @@ def train_sb3(cfg: dict) -> tuple[object, dict]: def train_once(cfg: dict) -> dict: algo = cfg["algo"] - if algo == "qtable": + if cfg.get("use_jax"): + if not JAX_AVAILABLE: + raise ImportError( + "JAX backend requested but JAX is not installed. " + "Install engine/jax/requirements.txt and jax[tpu] for TPU runs." + ) + if algo == "qtable": + raise ValueError("qtable is not supported in JAX backend") + try: + from .jax.train import train_jax + except Exception as exc: # pragma: no cover + raise ImportError(f"Failed to import JAX trainer: {exc}") from exc + _, metrics = train_jax(cfg) + elif algo == "qtable": _, metrics = train_qtable(cfg) else: _, metrics = train_sb3(cfg) @@ -357,8 +402,17 @@ def main(): p.add_argument("--learning-rate", type=float) p.add_argument("--gamma", type=float) p.add_argument("--revenue-weight", type=float) + p.add_argument("--max-steps", type=int) + p.add_argument("--margin-floor", type=float) + p.add_argument("--margin-floor-patience", type=int) p.add_argument("--arch", type=str) p.add_argument("--activation", type=str) + p.add_argument("--jax", action="store_true") + p.add_argument("--jax-num-envs", type=int) + p.add_argument("--jax-num-steps", type=int) + p.add_argument("--jax-num-minibatches", type=int) + p.add_argument("--jax-update-epochs", type=int) + p.add_argument("--jax-anneal-lr", type=str) p.add_argument("--sweep-agent", action="store_true") p.add_argument("--sweep-id", type=str) p.add_argument("--count", type=int, default=0) @@ -377,8 +431,19 @@ def main(): "learning_rate": args.learning_rate, "gamma": args.gamma, "revenue_weight": args.revenue_weight, + "max_steps": args.max_steps, + "margin_floor": args.margin_floor, + "margin_floor_patience": args.margin_floor_patience, "arch": args.arch, "activation": args.activation, + "use_jax": args.jax, + "jax_num_envs": args.jax_num_envs, + "jax_num_steps": args.jax_num_steps, + "jax_num_minibatches": args.jax_num_minibatches, + "jax_update_epochs": args.jax_update_epochs, + "jax_anneal_lr": _truthy(args.jax_anneal_lr) + if args.jax_anneal_lr is not None + else None, } overrides = {k: v for k, v in overrides.items() if v is not None} From 9acc998cc90744c936f582e85db343ec0e3bcff4 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Feb 2026 16:54:55 +0100 Subject: [PATCH 31/36] fixing models for gcp --- Makefile | 116 +++++++++- engine/jax/train.py | 460 ++++++++++++++++++++++++---------------- engine/lib/__init__.py | 2 +- engine/lib/callbacks.py | 63 ++++++ engine/train.py | 49 ++++- 5 files changed, 497 insertions(+), 193 deletions(-) diff --git a/Makefile b/Makefile index 27ce523..43c55ee 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,8 @@ TPU_ZONE ?= us-central2-b TPU_TYPE ?= v4-32 TPU_RUNTIME ?= tpu-vm-v4-base TPU_PROJECT ?= phantom-trc -TPU_NETWORK ?= default -TPU_SUBNETWORK ?= default-us-central2 +TPU_NETWORK ?= tpu-network +TPU_SUBNETWORK ?= tpu-network TPU_USE_SPOT ?= 0 TPU_EXTRA_CREATE_FLAGS ?= TPU_WORKDIR ?= ~/PHANTOM @@ -22,7 +22,29 @@ TPU_SYNC_PATHS ?= engine lib requirements.txt Makefile .env TPU_TRAIN_ARGS ?= --algo ppo --jax --total-timesteps 20000 TPU_JAX_WHEEL_URL ?= https://storage.googleapis.com/jax-releases/libtpu_releases.html TPU_VENV ?= .venv-tpu -TPU_TRAIN_ENV ?= PHANTOM_USE_JAX=1 WANDB_MODE=offline +TPU_TRAIN_ENV ?= PHANTOM_USE_JAX=1 WANDB_MODE=online +SWEEP_ID ?= +SWEEP_COUNT ?= 5 +QUEUE_SCRIPT ?= scripts/queue_sweep.sh +TPU_QUEUE_TYPE ?= +TPU_QUEUE_ZONES ?= europe-west4-a us-central2-b us-central1-a us-east1-d europe-west4-b +TPU_QUEUE_REUSE_EXISTING ?= 1 +TPU_QUEUE_KEEP_ALIVE ?= 1 +TPU_QUEUE_STRICT_QUOTA ?= 0 +TPU_QUEUE_DOWNSHIFT_ON_QUOTA ?= 1 +TPU_QUEUE_FILTER_ZONE ?= +TPU_QUEUE_FILTER_TYPE ?= +TPU_QUEUE_EXECUTION_MODE ?= venv +TPU_QUEUE_SYNC_METHOD ?= tar +TPU_QUEUE_SKIP_SYNC ?= 0 +TPU_QUEUE_DOCKER_IMAGE ?= +TPU_QUEUE_DOCKER_PULL ?= 1 +TPU_QUEUE_DOCKER_AUTO_INSTALL ?= 1 +TPU_QUEUE_SSH_BATCH_MODE ?= 1 +TPU_QUEUE_SSH_CONNECT_TIMEOUT ?= 12 +TPU_QUEUE_SSH_KEY_FILE ?= $(HOME)/.ssh/google_compute_engine +TPU_QUEUE_REQUIRE_SSH_AGENT ?= 1 +TPU_QUEUE_AUTO_SSH_ADD ?= 1 TPU_SPOT_FLAG := $(if $(filter 1 true TRUE yes YES,$(TPU_USE_SPOT)),--spot,) TPU_CREATE_CMD = gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm create "$(TPU_NAME)" --zone="$(TPU_ZONE)" --accelerator-type="$(TPU_TYPE)" --version="$(TPU_RUNTIME)" --network="$(TPU_NETWORK)" --subnetwork="$(TPU_SUBNETWORK)" $(TPU_SPOT_FLAG) $(TPU_EXTRA_CREATE_FLAGS) @@ -30,8 +52,13 @@ TPU_CREATE_CMD = gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm create "$ .PHONY: help help: - @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines | tpu.*" + @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines | tpu.* | tpu.queue.*" @echo "TPU presets: tpu.create.v4.ondemand | tpu.create.v4.spot" + @echo "Queued sweep: SWEEP_ID=entity/project/id make tpu.queue.sweep" + @echo "Queued sweep filters: TPU_QUEUE_FILTER_TYPE=v6e TPU_QUEUE_FILTER_ZONE=europe-west4-a" + @echo "Docker queue: make tpu.queue.sweep.docker TPU_QUEUE_DOCKER_IMAGE=gcr.io//:tag" + @echo "Docker queue without sync: add TPU_QUEUE_SKIP_SYNC=1" + @echo "If SSH key is encrypted: run ssh-add ~/.ssh/google_compute_engine first" $(BUILDDIR): mkdir -p paper/$(BUILDDIR) @@ -104,11 +131,11 @@ tpu.check.zone: .PHONY: tpu.create.v4.ondemand tpu.create.v4.ondemand: - $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=0 TPU_SUBNETWORK=default-us-central2 + $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=0 TPU_SUBNETWORK=tpu-network .PHONY: tpu.create.v4.spot tpu.create.v4.spot: - $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=1 TPU_SUBNETWORK=default-us-central2 + $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=1 TPU_SUBNETWORK=tpu-network .PHONY: tpu.create tpu.create: tpu.check.zone @@ -179,6 +206,83 @@ tpu.bootstrap: tpu.ensure tpu.deploy tpu.install tpu.delete: gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm delete "$(TPU_NAME)" --zone="$(TPU_ZONE)" --quiet +.PHONY: tpu.queue.sweep +tpu.queue.sweep: + @set -e; \ + test -n "$(SWEEP_ID)" || (echo "SWEEP_ID is required, e.g. SWEEP_ID=entity/project/id" && exit 1); \ + test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY is required in your shell" && exit 1); \ + if [ "$(TPU_QUEUE_AUTO_SSH_ADD)" = "1" ] && [ "$(TPU_QUEUE_SSH_BATCH_MODE)" != "0" ] && command -v ssh-add >/dev/null 2>&1 && [ -f "$(TPU_QUEUE_SSH_KEY_FILE)" ]; then \ + if ! ssh-add -l >/dev/null 2>&1; then \ + if [ -z "$$SSH_AUTH_SOCK" ] && command -v ssh-agent >/dev/null 2>&1; then eval "$$(ssh-agent -s)" >/dev/null; fi; \ + ssh-add "$(TPU_QUEUE_SSH_KEY_FILE)"; \ + fi; \ + fi; \ + AGENT_COUNT="$(SWEEP_COUNT)" PROJECT_ID="$(TPU_PROJECT)" TPU_NETWORK="$(TPU_NETWORK)" TPU_SUBNETWORK="$(TPU_SUBNETWORK)" TPU_REUSE_EXISTING="$(TPU_QUEUE_REUSE_EXISTING)" TPU_KEEP_ALIVE="$(TPU_QUEUE_KEEP_ALIVE)" TPU_STRICT_QUOTA="$(TPU_QUEUE_STRICT_QUOTA)" TPU_DOWNSHIFT_ON_QUOTA="$(TPU_QUEUE_DOWNSHIFT_ON_QUOTA)" TPU_EXECUTION_MODE="$(TPU_QUEUE_EXECUTION_MODE)" TPU_SYNC_METHOD="$(TPU_QUEUE_SYNC_METHOD)" TPU_SKIP_SYNC="$(TPU_QUEUE_SKIP_SYNC)" TPU_DOCKER_IMAGE="$(TPU_QUEUE_DOCKER_IMAGE)" TPU_DOCKER_PULL="$(TPU_QUEUE_DOCKER_PULL)" TPU_DOCKER_AUTO_INSTALL="$(TPU_QUEUE_DOCKER_AUTO_INSTALL)" TPU_SSH_BATCH_MODE="$(TPU_QUEUE_SSH_BATCH_MODE)" TPU_SSH_CONNECT_TIMEOUT="$(TPU_QUEUE_SSH_CONNECT_TIMEOUT)" TPU_SSH_KEY_FILE="$(TPU_QUEUE_SSH_KEY_FILE)" TPU_REQUIRE_SSH_AGENT="$(TPU_QUEUE_REQUIRE_SSH_AGENT)" TPU_QUEUE_FILTER_ZONE="$(TPU_QUEUE_FILTER_ZONE)" TPU_QUEUE_FILTER_TYPE="$(TPU_QUEUE_FILTER_TYPE)" WANDB_API_KEY="$$WANDB_API_KEY" "$(QUEUE_SCRIPT)" "$(SWEEP_ID)" + +.PHONY: tpu.queue.worker +tpu.queue.worker: + @set -e; \ + test -n "$(SWEEP_ID)" || (echo "SWEEP_ID is required, e.g. SWEEP_ID=entity/project/id" && exit 1); \ + test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY is required in your shell" && exit 1); \ + if [ "$(TPU_QUEUE_AUTO_SSH_ADD)" = "1" ] && [ "$(TPU_QUEUE_SSH_BATCH_MODE)" != "0" ] && command -v ssh-add >/dev/null 2>&1 && [ -f "$(TPU_QUEUE_SSH_KEY_FILE)" ]; then \ + if ! ssh-add -l >/dev/null 2>&1; then \ + if [ -z "$$SSH_AUTH_SOCK" ] && command -v ssh-agent >/dev/null 2>&1; then eval "$$(ssh-agent -s)" >/dev/null; fi; \ + ssh-add "$(TPU_QUEUE_SSH_KEY_FILE)"; \ + fi; \ + fi; \ + AGENT_COUNT="$(SWEEP_COUNT)" PROJECT_ID="$(TPU_PROJECT)" TPU_NETWORK="$(TPU_NETWORK)" TPU_SUBNETWORK="$(TPU_SUBNETWORK)" TPU_REUSE_EXISTING="$(TPU_QUEUE_REUSE_EXISTING)" TPU_KEEP_ALIVE="$(TPU_QUEUE_KEEP_ALIVE)" TPU_STRICT_QUOTA="$(TPU_QUEUE_STRICT_QUOTA)" TPU_DOWNSHIFT_ON_QUOTA="$(TPU_QUEUE_DOWNSHIFT_ON_QUOTA)" TPU_EXECUTION_MODE="$(TPU_QUEUE_EXECUTION_MODE)" TPU_SYNC_METHOD="$(TPU_QUEUE_SYNC_METHOD)" TPU_SKIP_SYNC="$(TPU_QUEUE_SKIP_SYNC)" TPU_DOCKER_IMAGE="$(TPU_QUEUE_DOCKER_IMAGE)" TPU_DOCKER_PULL="$(TPU_QUEUE_DOCKER_PULL)" TPU_DOCKER_AUTO_INSTALL="$(TPU_QUEUE_DOCKER_AUTO_INSTALL)" TPU_SSH_BATCH_MODE="$(TPU_QUEUE_SSH_BATCH_MODE)" TPU_SSH_CONNECT_TIMEOUT="$(TPU_QUEUE_SSH_CONNECT_TIMEOUT)" TPU_SSH_KEY_FILE="$(TPU_QUEUE_SSH_KEY_FILE)" TPU_REQUIRE_SSH_AGENT="$(TPU_QUEUE_REQUIRE_SSH_AGENT)" TPU_QUEUE_FILTER_ZONE="$(TPU_ZONE)" TPU_QUEUE_FILTER_TYPE="$(TPU_QUEUE_TYPE)" WANDB_API_KEY="$$WANDB_API_KEY" "$(QUEUE_SCRIPT)" "$(SWEEP_ID)" + +.PHONY: tpu.queue.sweep.docker +tpu.queue.sweep.docker: + @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) + @$(MAKE) tpu.queue.sweep TPU_QUEUE_EXECUTION_MODE=docker + +.PHONY: tpu.queue.worker.docker +tpu.queue.worker.docker: + @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) + @$(MAKE) tpu.queue.worker TPU_QUEUE_EXECUTION_MODE=docker + +.PHONY: tpu.queue.docker.build +tpu.queue.docker.build: + @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) + docker build -f docker/TPUSweep.Dockerfile -t "$(TPU_QUEUE_DOCKER_IMAGE)" . + +.PHONY: tpu.queue.docker.push +tpu.queue.docker.push: + @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) + docker push "$(TPU_QUEUE_DOCKER_IMAGE)" + +.PHONY: tpu.queue.status +tpu.queue.status: + @set -e; \ + if gcloud compute tpus queued-resources list --help >/dev/null 2>&1; then \ + QCMD='gcloud --project=$(TPU_PROJECT) compute tpus queued-resources'; \ + else \ + QCMD='gcloud --project=$(TPU_PROJECT) alpha compute tpus queued-resources'; \ + fi; \ + for ZONE in $(TPU_QUEUE_ZONES); do \ + echo "--- $$ZONE ---"; \ + if ! $$QCMD list --zone="$$ZONE"; then \ + echo "Skipping $$ZONE (unavailable or no permission)"; \ + fi; \ + done + +.PHONY: tpu.queue.clean +tpu.queue.clean: + @set -e; \ + if gcloud compute tpus queued-resources list --help >/dev/null 2>&1; then \ + QCMD='gcloud --project=$(TPU_PROJECT) compute tpus queued-resources'; \ + else \ + QCMD='gcloud --project=$(TPU_PROJECT) alpha compute tpus queued-resources'; \ + fi; \ + for ZONE in $(TPU_QUEUE_ZONES); do \ + $$QCMD list --zone="$$ZONE" --format='value(name)' 2>/dev/null | while read -r NAME; do \ + case "$$NAME" in \ + qr-*) echo "Deleting $$NAME ($$ZONE)"; $$QCMD delete "$$NAME" --zone="$$ZONE" --quiet ;; \ + esac; \ + done; \ + done + .PHONY: stats.lines stats.lines: @find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \ diff --git a/engine/jax/train.py b/engine/jax/train.py index f2f4168..41678c1 100644 --- a/engine/jax/train.py +++ b/engine/jax/train.py @@ -7,6 +7,19 @@ from typing import Any, NamedTuple import numpy as np +try: + import wandb + + HAS_WANDB = True +except ImportError: + HAS_WANDB = False + +from ..wandb_checkpoint import ( + checkpoint_artifact_name, + download_latest_checkpoint, + log_checkpoint_bytes, +) + try: import jax import jax.numpy as jnp @@ -142,6 +155,7 @@ def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: "num_minibatches": int(cfg.get("jax_num_minibatches", 4)), "update_epochs": int(cfg.get("jax_update_epochs", 4)), "anneal_lr": bool(cfg.get("jax_anneal_lr", True)), + "checkpoint_interval": int(cfg.get("checkpoint_interval", 10_000)), } rollout = out["num_envs"] * out["num_steps"] out["num_updates"] = max(1, out["total_timesteps"] // max(rollout, 1)) @@ -185,201 +199,198 @@ def make_train(config: dict[str, Any]): frac = 1.0 - updates_done / max(cfg["num_updates"], 1) return cfg["learning_rate"] * frac - def train(rng: jax.Array): + if cfg["anneal_lr"]: + tx = optax.chain( + optax.clip_by_global_norm(cfg["max_grad_norm"]), + optax.adam(learning_rate=linear_schedule, eps=1e-5), + ) + else: + tx = optax.chain( + optax.clip_by_global_norm(cfg["max_grad_norm"]), + optax.adam(cfg["learning_rate"], eps=1e-5), + ) + + def init_runner_state(rng: jax.Array): rng, init_key = jax.random.split(rng) init_obs = jnp.zeros((env.observation_dim(),), dtype=jnp.float32) params = network.init(init_key, init_obs) - - if cfg["anneal_lr"]: - tx = optax.chain( - optax.clip_by_global_norm(cfg["max_grad_norm"]), - optax.adam(learning_rate=linear_schedule, eps=1e-5), - ) - else: - tx = optax.chain( - optax.clip_by_global_norm(cfg["max_grad_norm"]), - optax.adam(cfg["learning_rate"], eps=1e-5), - ) train_state = TrainState.create(apply_fn=network.apply, params=params, tx=tx) rng, reset_key = jax.random.split(rng) reset_keys = jax.random.split(reset_key, cfg["num_envs"]) obs, env_state = jax.vmap(env.reset)(reset_keys) + return train_state, env_state, obs, rng - def _update_step(runner_state, _): - def _env_step(runner_state, _): - train_state, env_state, last_obs, rng = runner_state - rng, action_key = jax.random.split(rng) - policy, value = network.apply(train_state.params, last_obs) - action = policy.sample(seed=action_key) - log_prob = policy.log_prob(action) - - rng, step_key = jax.random.split(rng) - step_keys = jax.random.split(step_key, cfg["num_envs"]) - nxt_obs, nxt_state, reward, done, info = jax.vmap( - env.step, - in_axes=(0, 0, 0), - )(step_keys, env_state, action) - - rng, reset_key = jax.random.split(rng) - reset_keys = jax.random.split(reset_key, cfg["num_envs"]) - rst_obs, rst_state = jax.vmap(env.reset)(reset_keys) - obs_next = jnp.where(done[:, None], rst_obs, nxt_obs) - env_next = jax.tree_util.tree_map( - lambda keep, reset: _select_env_state(done, keep, reset), - nxt_state, - rst_state, - ) - transition = Transition( - done=done, - action=action, - value=value, - reward=reward, - log_prob=log_prob, - obs=last_obs, - info=info, - ) - return (train_state, env_next, obs_next, rng), transition - - runner_state, traj_batch = jax.lax.scan( - _env_step, - runner_state, - None, - length=cfg["num_steps"], - ) - + def _update_step(runner_state, _): + def _env_step(runner_state, _): train_state, env_state, last_obs, rng = runner_state - _, last_value = network.apply(train_state.params, last_obs) + rng, action_key = jax.random.split(rng) + policy, value = network.apply(train_state.params, last_obs) + action = policy.sample(seed=action_key) + log_prob = policy.log_prob(action) - def _compute_gae(traj_batch, last_value): - def _gae_step(carry, transition): - gae, next_value = carry - delta = ( - transition.reward - + cfg["gamma"] * next_value * (1.0 - transition.done) - - transition.value - ) - gae = ( - delta - + cfg["gamma"] - * cfg["gae_lambda"] - * (1.0 - transition.done) - * gae - ) - return (gae, transition.value), gae + rng, step_key = jax.random.split(rng) + step_keys = jax.random.split(step_key, cfg["num_envs"]) + nxt_obs, nxt_state, reward, done, info = jax.vmap( + env.step, + in_axes=(0, 0, 0), + )(step_keys, env_state, action) - _, advantages = jax.lax.scan( - _gae_step, - (jnp.zeros_like(last_value), last_value), - traj_batch, - reverse=True, - unroll=16, - ) - targets = advantages + traj_batch.value - return advantages, targets - - advantages, targets = _compute_gae(traj_batch, last_value) - - def _update_epoch(update_state, _): - def _update_minibatch(train_state, batch_info): - traj_b, adv_b, tgt_b = batch_info - - def _loss_fn(params, traj_b, adv_b, tgt_b): - policy, value = network.apply(params, traj_b.obs) - log_prob = policy.log_prob(traj_b.action) - - value_clipped = traj_b.value + (value - traj_b.value).clip( - -cfg["clip_range"], cfg["clip_range"] - ) - value_loss = ( - 0.5 - * jnp.maximum( - jnp.square(value - tgt_b), - jnp.square(value_clipped - tgt_b), - ).mean() - ) - - adv_norm = (adv_b - adv_b.mean()) / (adv_b.std() + 1e-8) - ratio = jnp.exp(log_prob - traj_b.log_prob) - loss_actor = -jnp.minimum( - ratio * adv_norm, - jnp.clip( - ratio, - 1.0 - cfg["clip_range"], - 1.0 + cfg["clip_range"], - ) - * adv_norm, - ).mean() - entropy = policy.entropy().mean() - total_loss = ( - loss_actor - + cfg["vf_coef"] * value_loss - - cfg["ent_coef"] * entropy - ) - return total_loss, (value_loss, loss_actor, entropy) - - grad_fn = jax.value_and_grad(_loss_fn, has_aux=True) - (_, _), grads = grad_fn(train_state.params, traj_b, adv_b, tgt_b) - train_state = train_state.apply_gradients(grads=grads) - return train_state, jnp.asarray(0.0, dtype=jnp.float32) - - train_state, traj_batch, advantages, targets, rng = update_state - rng, perm_key = jax.random.split(rng) - batch_size = cfg["num_envs"] * cfg["num_steps"] - permutation = jax.random.permutation(perm_key, batch_size) - batch = (traj_batch, advantages, targets) - batch = jax.tree_util.tree_map( - lambda x: x.reshape((batch_size,) + x.shape[2:]), - batch, - ) - shuffled = jax.tree_util.tree_map( - lambda x: jnp.take(x, permutation, axis=0), - batch, - ) - minibatches = jax.tree_util.tree_map( - lambda x: x.reshape( - (cfg["num_minibatches"], cfg["minibatch_size"]) + x.shape[1:] - ), - shuffled, - ) - train_state, _ = jax.lax.scan( - _update_minibatch, train_state, minibatches - ) - return (train_state, traj_batch, advantages, targets, rng), None - - update_state = (train_state, traj_batch, advantages, targets, rng) - update_state, _ = jax.lax.scan( - _update_epoch, - update_state, - None, - length=cfg["update_epochs"], + rng, reset_key = jax.random.split(rng) + reset_keys = jax.random.split(reset_key, cfg["num_envs"]) + rst_obs, rst_state = jax.vmap(env.reset)(reset_keys) + obs_next = jnp.where(done[:, None], rst_obs, nxt_obs) + env_next = jax.tree_util.tree_map( + lambda keep, reset: _select_env_state(done, keep, reset), + nxt_state, + rst_state, ) - train_state = update_state[0] - rng = update_state[-1] + transition = Transition( + done=done, + action=action, + value=value, + reward=reward, + log_prob=log_prob, + obs=last_obs, + info=info, + ) + return (train_state, env_next, obs_next, rng), transition - metric = { - "reward": jnp.mean(traj_batch.reward), - "revenue": jnp.mean(traj_batch.info["revenue"]), - "agent_prob": jnp.mean(traj_batch.info["agent_prob"]), - "alpha_adv": jnp.mean(traj_batch.info["alpha_adv"]), - "coi_leakage": jnp.mean(traj_batch.info["coi_leakage"]), - } - runner_state = (train_state, env_state, last_obs, rng) - return runner_state, metric + runner_state, traj_batch = jax.lax.scan( + _env_step, + runner_state, + None, + length=cfg["num_steps"], + ) - runner_state = (train_state, env_state, obs, rng) + train_state, env_state, last_obs, rng = runner_state + _, last_value = network.apply(train_state.params, last_obs) + + def _compute_gae(traj_batch, last_value): + def _gae_step(carry, transition): + gae, next_value = carry + delta = ( + transition.reward + + cfg["gamma"] * next_value * (1.0 - transition.done) + - transition.value + ) + gae = ( + delta + + cfg["gamma"] * cfg["gae_lambda"] * (1.0 - transition.done) * gae + ) + return (gae, transition.value), gae + + _, advantages = jax.lax.scan( + _gae_step, + (jnp.zeros_like(last_value), last_value), + traj_batch, + reverse=True, + unroll=16, + ) + targets = advantages + traj_batch.value + return advantages, targets + + advantages, targets = _compute_gae(traj_batch, last_value) + + def _update_epoch(update_state, _): + def _update_minibatch(train_state, batch_info): + traj_b, adv_b, tgt_b = batch_info + + def _loss_fn(params, traj_b, adv_b, tgt_b): + policy, value = network.apply(params, traj_b.obs) + log_prob = policy.log_prob(traj_b.action) + + value_clipped = traj_b.value + (value - traj_b.value).clip( + -cfg["clip_range"], cfg["clip_range"] + ) + value_loss = ( + 0.5 + * jnp.maximum( + jnp.square(value - tgt_b), + jnp.square(value_clipped - tgt_b), + ).mean() + ) + + adv_norm = (adv_b - adv_b.mean()) / (adv_b.std() + 1e-8) + ratio = jnp.exp(log_prob - traj_b.log_prob) + loss_actor = -jnp.minimum( + ratio * adv_norm, + jnp.clip( + ratio, + 1.0 - cfg["clip_range"], + 1.0 + cfg["clip_range"], + ) + * adv_norm, + ).mean() + entropy = policy.entropy().mean() + total_loss = ( + loss_actor + + cfg["vf_coef"] * value_loss + - cfg["ent_coef"] * entropy + ) + return total_loss, (value_loss, loss_actor, entropy) + + grad_fn = jax.value_and_grad(_loss_fn, has_aux=True) + (_, _), grads = grad_fn(train_state.params, traj_b, adv_b, tgt_b) + train_state = train_state.apply_gradients(grads=grads) + return train_state, jnp.asarray(0.0, dtype=jnp.float32) + + train_state, traj_batch, advantages, targets, rng = update_state + rng, perm_key = jax.random.split(rng) + batch_size = cfg["num_envs"] * cfg["num_steps"] + permutation = jax.random.permutation(perm_key, batch_size) + batch = (traj_batch, advantages, targets) + batch = jax.tree_util.tree_map( + lambda x: x.reshape((batch_size,) + x.shape[2:]), + batch, + ) + shuffled = jax.tree_util.tree_map( + lambda x: jnp.take(x, permutation, axis=0), + batch, + ) + minibatches = jax.tree_util.tree_map( + lambda x: x.reshape( + (cfg["num_minibatches"], cfg["minibatch_size"]) + x.shape[1:] + ), + shuffled, + ) + train_state, _ = jax.lax.scan(_update_minibatch, train_state, minibatches) + return (train_state, traj_batch, advantages, targets, rng), None + + update_state = (train_state, traj_batch, advantages, targets, rng) + update_state, _ = jax.lax.scan( + _update_epoch, + update_state, + None, + length=cfg["update_epochs"], + ) + train_state = update_state[0] + rng = update_state[-1] + + metric = { + "reward": jnp.mean(traj_batch.reward), + "revenue": jnp.mean(traj_batch.info["revenue"]), + "agent_prob": jnp.mean(traj_batch.info["agent_prob"]), + "alpha_adv": jnp.mean(traj_batch.info["alpha_adv"]), + "coi_leakage": jnp.mean(traj_batch.info["coi_leakage"]), + } + next_runner_state = (train_state, env_state, last_obs, rng) + return next_runner_state, metric + + def run_updates(runner_state, *, num_updates: int): + updates = max(1, int(num_updates)) runner_state, metric = jax.lax.scan( _update_step, runner_state, None, - length=cfg["num_updates"], + length=updates, ) return { "runner_state": runner_state, "metrics": metric, } - return train, network, env, cfg + return init_runner_state, run_updates, network, env, cfg def evaluate_policy( @@ -436,22 +447,103 @@ def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: f"JAX backend currently supports algo='ppo' only, got '{run_cfg['algo']}'" ) - train_fn, network, env, run_cfg = make_train(run_cfg) - train_jit = jax.jit(train_fn) - rng = jax.random.PRNGKey(run_cfg["seed"]) - out = train_jit(rng) + init_runner_state, run_updates, network, env, run_cfg = make_train(run_cfg) + run_updates_jit = jax.jit(run_updates, static_argnames=("num_updates",)) + rollout_steps = int(run_cfg["num_steps"] * run_cfg["num_envs"]) + total_updates = int(run_cfg["num_updates"]) + checkpoint_interval = max(1, int(run_cfg.get("checkpoint_interval", 10_000))) + segment_updates = max(1, checkpoint_interval // max(rollout_steps, 1)) - train_state = out["runner_state"][0] - metric = out["metrics"] + rng = jax.random.PRNGKey(run_cfg["seed"]) + runner_state = init_runner_state(rng) + updates_done = 0 + + artifact_name = None + if HAS_WANDB and wandb.run is not None: + sweep_id = getattr(wandb.run, "sweep_id", None) + artifact_name = checkpoint_artifact_name( + run_cfg, + backend="jax", + sweep_id=sweep_id, + ) + restored = download_latest_checkpoint( + artifact_name, + file_name="jax_runner_state.msgpack", + ) + if restored is not None: + checkpoint_path, metadata = restored + template = { + "runner_state": runner_state, + "updates_done": 0, + } + payload = serialization.from_bytes(template, checkpoint_path.read_bytes()) + runner_state = payload["runner_state"] + updates_done = int(payload.get("updates_done", 0)) + if updates_done <= 0: + updates_done = int(metadata.get("updates_done", 0)) + updates_done = max(0, min(updates_done, total_updates)) + + metric_keys = ["reward", "revenue", "agent_prob", "alpha_adv", "coi_leakage"] + metric_sums = {k: 0.0 for k in metric_keys} + metric_count = 0 + + while updates_done < total_updates: + updates_this_segment = min(segment_updates, total_updates - updates_done) + out = run_updates_jit(runner_state, num_updates=updates_this_segment) + runner_state = out["runner_state"] + metric = out["metrics"] + + segment_values = { + k: np.asarray(metric[k], dtype=np.float64) for k in metric_keys + } + segment_count = int(segment_values["reward"].shape[0]) if segment_values else 0 + metric_count += segment_count + for key in metric_keys: + metric_sums[key] += float(segment_values[key].sum()) + + updates_done += int(updates_this_segment) + global_step = int(updates_done * rollout_steps) + + if HAS_WANDB and wandb.run is not None: + wandb.log( + { + "train/reward": float(segment_values["reward"].mean()), + "train/revenue": float(segment_values["revenue"].mean()), + "train/agent_prob": float(segment_values["agent_prob"].mean()), + "train/alpha_adv": float(segment_values["alpha_adv"].mean()), + "train/coi_leakage": float(segment_values["coi_leakage"].mean()), + "train/global_step": global_step, + }, + step=global_step, + ) + if artifact_name is not None: + checkpoint_payload = serialization.to_bytes( + { + "runner_state": runner_state, + "updates_done": updates_done, + } + ) + log_checkpoint_bytes( + artifact_name, + file_name="jax_runner_state.msgpack", + payload=checkpoint_payload, + metadata={ + "step": global_step, + "updates_done": updates_done, + "rollout_steps": rollout_steps, + "algo": "ppo", + }, + ) + + train_state = runner_state[0] + denom = float(metric_count) if metric_count > 0 else 1.0 metrics = { - "train/reward": float(np.mean(np.asarray(metric["reward"]))), - "train/revenue": float(np.mean(np.asarray(metric["revenue"]))), - "train/agent_prob": float(np.mean(np.asarray(metric["agent_prob"]))), - "train/alpha_adv": float(np.mean(np.asarray(metric["alpha_adv"]))), - "train/coi_leakage": float(np.mean(np.asarray(metric["coi_leakage"]))), - "train/global_step": int( - run_cfg["num_updates"] * run_cfg["num_steps"] * run_cfg["num_envs"] - ), + "train/reward": float(metric_sums["reward"] / denom), + "train/revenue": float(metric_sums["revenue"] / denom), + "train/agent_prob": float(metric_sums["agent_prob"] / denom), + "train/alpha_adv": float(metric_sums["alpha_adv"] / denom), + "train/coi_leakage": float(metric_sums["coi_leakage"] / denom), + "train/global_step": int(updates_done * rollout_steps), } eval_metrics = evaluate_policy( diff --git a/engine/lib/__init__.py b/engine/lib/__init__.py index 874db63..c2fafc9 100644 --- a/engine/lib/__init__.py +++ b/engine/lib/__init__.py @@ -2,7 +2,7 @@ from .demand import estimate_demand, estimate_weighted_demand, generate_demand_f from .behavior import sample_behavior, get_transition_models, trajectory_to_events from .render import DashboardRenderer, style_axis from .wrappers import EconomicMetricsWrapper -from .callbacks import MetricsCallback, EvalMetricsCallback +from .callbacks import MetricsCallback, EvalMetricsCallback, CheckpointArtifactCallback from .providers import ( ProviderBenchmark, ProviderResult, diff --git a/engine/lib/callbacks.py b/engine/lib/callbacks.py index 9e16d4b..05e77a0 100644 --- a/engine/lib/callbacks.py +++ b/engine/lib/callbacks.py @@ -1,8 +1,12 @@ """Training callbacks for W&B/TensorBoard logging - reads from info dict.""" +from pathlib import Path + from stable_baselines3.common.callbacks import BaseCallback, EvalCallback import numpy as np +from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file + try: import wandb @@ -80,6 +84,65 @@ class MetricsCallback(BaseCallback): self._episode_revenues = [] +class CheckpointArtifactCallback(BaseCallback): + """Periodic SB3 checkpoint uploader backed by W&B artifacts.""" + + def __init__(self, cfg: dict, interval: int = 10_000, verbose: int = 0): + super().__init__(verbose) + self.cfg = dict(cfg) + self.interval = max(1, int(interval)) + self.model_dir = Path(str(self.cfg.get("model_dir", "engine/models"))) + self.model_dir.mkdir(parents=True, exist_ok=True) + self._next_checkpoint = self.interval + self._last_saved_step = -1 + + def _artifact_name(self) -> str: + sweep_id = ( + getattr(wandb.run, "sweep_id", None) + if HAS_WANDB and wandb.run is not None + else None + ) + return checkpoint_artifact_name(self.cfg, backend="sb3", sweep_id=sweep_id) + + def _checkpoint_file(self) -> Path: + algo = str(self.cfg.get("algo", "model")) + base = self.model_dir / f"phantom_{algo}_checkpoint" + self.model.save(str(base)) + return base.with_suffix(".zip") + + def _save_checkpoint(self) -> None: + if not HAS_WANDB or wandb.run is None: + return + step = int(self.num_timesteps) + if step <= self._last_saved_step: + return + checkpoint_path = self._checkpoint_file() + metadata = { + "step": step, + "algo": str(self.cfg.get("algo", "unknown")), + "sweep_id": getattr(wandb.run, "sweep_id", None), + } + saved = log_checkpoint_file( + self._artifact_name(), + file_path=checkpoint_path, + artifact_file_name=checkpoint_path.name, + metadata=metadata, + ) + if saved: + self._last_saved_step = step + + def _on_step(self) -> bool: + if self.num_timesteps < self._next_checkpoint: + return True + self._save_checkpoint() + while self._next_checkpoint <= self.num_timesteps: + self._next_checkpoint += self.interval + return True + + def _on_training_end(self) -> None: + self._save_checkpoint() + + class EvalMetricsCallback(EvalCallback): """Deterministic evaluation - true performance without exploration noise.""" diff --git a/engine/train.py b/engine/train.py index 8e4eb07..35ca582 100644 --- a/engine/train.py +++ b/engine/train.py @@ -6,6 +6,8 @@ import os from pathlib import Path import numpy as np +from .wandb_checkpoint import checkpoint_artifact_name, download_latest_checkpoint + try: import wandb @@ -78,6 +80,7 @@ DEFAULT_CFG = { "jax_num_minibatches": 4, "jax_update_epochs": 4, "jax_anneal_lr": True, + "checkpoint_interval": 10_000, } @@ -262,6 +265,16 @@ def build_model(cfg: dict, env): raise ValueError(f"unsupported algo '{algo}'") +def _sb3_model_cls(algo: str): + if algo == "ppo": + return PPO + if algo == "a2c": + return A2C + if algo == "dqn": + return DQN + raise ValueError(f"unsupported algo '{algo}'") + + def train_qtable(cfg: dict) -> tuple[EventQTable, dict]: from .lib.discrete import EventQTable @@ -305,14 +318,36 @@ def train_qtable(cfg: dict) -> tuple[EventQTable, dict]: def train_sb3(cfg: dict) -> tuple[object, dict]: if not HAS_SB3: raise ImportError("stable-baselines3 is required for SB3 models") - from .lib.callbacks import MetricsCallback + from .lib.callbacks import CheckpointArtifactCallback, MetricsCallback env = make_env(cfg) eval_env = make_env(cfg) env = Monitor(env) eval_env = Monitor(eval_env) model = build_model(cfg, env) + resume_step = 0 + if HAS_WANDB and wandb.run is not None: + sweep_id = getattr(wandb.run, "sweep_id", None) + artifact_name = checkpoint_artifact_name(cfg, backend="sb3", sweep_id=sweep_id) + checkpoint_file = f"phantom_{cfg['algo']}_checkpoint.zip" + restored = download_latest_checkpoint(artifact_name, file_name=checkpoint_file) + if restored is not None: + checkpoint_path, metadata = restored + model = _sb3_model_cls(cfg["algo"]).load( + checkpoint_path.as_posix(), env=env + ) + resume_step = int(metadata.get("step", getattr(model, "num_timesteps", 0))) + model.num_timesteps = max( + int(getattr(model, "num_timesteps", 0)), resume_step + ) + cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))] + cbs.append( + CheckpointArtifactCallback( + cfg, + interval=int(cfg.get("checkpoint_interval", 10_000)), + ) + ) cbs.append( EvalCallback( eval_env, @@ -322,7 +357,15 @@ def train_sb3(cfg: dict) -> tuple[object, dict]: verbose=0, ) ) - model.learn(total_timesteps=int(cfg["total_timesteps"]), callback=cbs) + target_steps = int(cfg["total_timesteps"]) + remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0))) + if remaining_steps > 0: + model.learn( + total_timesteps=remaining_steps, + callback=cbs, + reset_num_timesteps=False, + ) + model_path = Path(cfg["model_dir"]) model_path.mkdir(parents=True, exist_ok=True) model.save(str(model_path / f"phantom_{cfg['algo']}")) @@ -413,6 +456,7 @@ def main(): p.add_argument("--jax-num-minibatches", type=int) p.add_argument("--jax-update-epochs", type=int) p.add_argument("--jax-anneal-lr", type=str) + p.add_argument("--checkpoint-interval", type=int) p.add_argument("--sweep-agent", action="store_true") p.add_argument("--sweep-id", type=str) p.add_argument("--count", type=int, default=0) @@ -441,6 +485,7 @@ def main(): "jax_num_steps": args.jax_num_steps, "jax_num_minibatches": args.jax_num_minibatches, "jax_update_epochs": args.jax_update_epochs, + "checkpoint_interval": args.checkpoint_interval, "jax_anneal_lr": _truthy(args.jax_anneal_lr) if args.jax_anneal_lr is not None else None, From 843564eeb09f25a9705f24ae7d518ec60025cfed Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 19 Feb 2026 13:03:03 +0100 Subject: [PATCH 32/36] TPU startup scripts --- TPUS/README.md | 6 ++++++ TPUS/v4_32_spot_uscentral2b.sh | 22 ++++++++++++++++++++++ TPUS/v4_uscentral2b.sh | 13 +++++++++++++ TPUS/v5e_64_spot_europewest4b.sh | 22 ++++++++++++++++++++++ TPUS/v5e_64_spot_uscentral1a.sh | 22 ++++++++++++++++++++++ TPUS/v6e_64_spot_europewest4a.sh | 22 ++++++++++++++++++++++ TPUS/v6e_64_spot_useast1d.sh | 22 ++++++++++++++++++++++ 7 files changed, 129 insertions(+) create mode 100644 TPUS/README.md create mode 100644 TPUS/v4_32_spot_uscentral2b.sh create mode 100644 TPUS/v4_uscentral2b.sh create mode 100644 TPUS/v5e_64_spot_europewest4b.sh create mode 100644 TPUS/v5e_64_spot_uscentral1a.sh create mode 100644 TPUS/v6e_64_spot_europewest4a.sh create mode 100644 TPUS/v6e_64_spot_useast1d.sh diff --git a/TPUS/README.md b/TPUS/README.md new file mode 100644 index 0000000..bb88fce --- /dev/null +++ b/TPUS/README.md @@ -0,0 +1,6 @@ +64 spot Cloud TPU v6e chips in zone europe-west4-a +32 spot Cloud TPU v4 chips in zone us-central2-b +64 spot Cloud TPU v5e chips in zone us-central1-a +64 spot Cloud TPU v6e chips in zone us-east1-d +32 on-demand Cloud TPU v4 chips in zone us-central2-b +64 spot Cloud TPU v5e chips in zone europe-west4-b diff --git a/TPUS/v4_32_spot_uscentral2b.sh b/TPUS/v4_32_spot_uscentral2b.sh new file mode 100644 index 0000000..661bcdc --- /dev/null +++ b/TPUS/v4_32_spot_uscentral2b.sh @@ -0,0 +1,22 @@ +# 32 spot Cloud TPU v4 chips in zone us-central2-b +export PROJECT_ID=phantom-trc +export QR_NAME=TPUv4s32spotUC2B +export TPU_NAME=tpu-v4-32-uc2b-spot +export ZONE=us-central2-b +export ACCELERATOR_TYPE=v4-32 +export RUNTIME_VERSION=v2-alpha-tpuv4 + +gcloud compute tpus tpu-vm create ${TPU_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --version=${RUNTIME_VERSION} \ + --spot \ +|| \ +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} \ + --spot diff --git a/TPUS/v4_uscentral2b.sh b/TPUS/v4_uscentral2b.sh new file mode 100644 index 0000000..a372078 --- /dev/null +++ b/TPUS/v4_uscentral2b.sh @@ -0,0 +1,13 @@ +# 32 on-demand Cloud TPU v4 chips in zone us-central2-b +export PROJECT_ID=phantom-trc +export QR_NAME=TPUlong +export ZONE=us-central2-b +export ACCELERATOR_TYPE=v4-32 +export RUNTIME_VERSION=v2-alpha-tpuv4 +#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION} +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} diff --git a/TPUS/v5e_64_spot_europewest4b.sh b/TPUS/v5e_64_spot_europewest4b.sh new file mode 100644 index 0000000..7a35d7e --- /dev/null +++ b/TPUS/v5e_64_spot_europewest4b.sh @@ -0,0 +1,22 @@ +# 64 spot Cloud TPU v5e chips in zone europe-west4-b +export PROJECT_ID=phantom-trc +export QR_NAME=TPUv5e64spotEW4B +export TPU_NAME=tpu-v5e-64-ew4b +export ZONE=europe-west4-b +export ACCELERATOR_TYPE=v5e-64 +export RUNTIME_VERSION=v2-alpha-tpuv5-lite + +gcloud compute tpus tpu-vm create ${TPU_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --version=${RUNTIME_VERSION} \ + --spot \ +|| \ +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} \ + --spot diff --git a/TPUS/v5e_64_spot_uscentral1a.sh b/TPUS/v5e_64_spot_uscentral1a.sh new file mode 100644 index 0000000..96375fd --- /dev/null +++ b/TPUS/v5e_64_spot_uscentral1a.sh @@ -0,0 +1,22 @@ +# 64 spot Cloud TPU v5e chips in zone us-central1-a +export PROJECT_ID=phantom-trc +export QR_NAME=TPUv5e64spotUC1A +export TPU_NAME=tpu-v5e-64-uc1a +export ZONE=us-central1-a +export ACCELERATOR_TYPE=v5e-64 +export RUNTIME_VERSION=v2-alpha-tpuv5-lite + +gcloud compute tpus tpu-vm create ${TPU_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --version=${RUNTIME_VERSION} \ + --spot \ +|| \ +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} \ + --spot diff --git a/TPUS/v6e_64_spot_europewest4a.sh b/TPUS/v6e_64_spot_europewest4a.sh new file mode 100644 index 0000000..1ea17ac --- /dev/null +++ b/TPUS/v6e_64_spot_europewest4a.sh @@ -0,0 +1,22 @@ +# 64 spot Cloud TPU v6e chips in zone europe-west4-a +export PROJECT_ID=phantom-trc +export QR_NAME=TPUv6e64spotEW4A +export TPU_NAME=tpu-v6e-64-ew4a +export ZONE=europe-west4-a +export ACCELERATOR_TYPE=v6e-64 +export RUNTIME_VERSION=v2-alpha-tpuv6e + +gcloud compute tpus tpu-vm create ${TPU_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --version=${RUNTIME_VERSION} \ + --spot \ +|| \ +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} \ + --spot diff --git a/TPUS/v6e_64_spot_useast1d.sh b/TPUS/v6e_64_spot_useast1d.sh new file mode 100644 index 0000000..cada53f --- /dev/null +++ b/TPUS/v6e_64_spot_useast1d.sh @@ -0,0 +1,22 @@ +# 64 spot Cloud TPU v6e chips in zone us-east1-d +export PROJECT_ID=phantom-trc +export QR_NAME=TPUv6e64spotUE1D +export TPU_NAME=tpu-v6e-64-ue1d +export ZONE=us-east1-d +export ACCELERATOR_TYPE=v6e-64 +export RUNTIME_VERSION=v2-alpha-tpuv6e + +gcloud compute tpus tpu-vm create ${TPU_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --version=${RUNTIME_VERSION} \ + --spot \ +|| \ +gcloud compute tpus queued-resources create ${QR_NAME} \ + --project=${PROJECT_ID} \ + --zone=${ZONE} \ + --node-id=${TPU_NAME} \ + --accelerator-type=${ACCELERATOR_TYPE} \ + --runtime-version=${RUNTIME_VERSION} \ + --spot From 5912062dc0d4970a58e5cdc157ed9e4db10e7482 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 19 Feb 2026 13:03:25 +0100 Subject: [PATCH 33/36] new trainer image --- docker/Trainer.dockerfile | 43 ++++++++++++++++++++++++++++++ docker/trainer-agent-entrypoint.sh | 23 ++++++++++++++++ docker/trainer.requirements.txt | 13 +++++++++ 3 files changed, 79 insertions(+) create mode 100644 docker/Trainer.dockerfile create mode 100644 docker/trainer-agent-entrypoint.sh create mode 100644 docker/trainer.requirements.txt diff --git a/docker/Trainer.dockerfile b/docker/Trainer.dockerfile new file mode 100644 index 0000000..c6776ea --- /dev/null +++ b/docker/Trainer.dockerfile @@ -0,0 +1,43 @@ +# syntax=docker/dockerfile:1.7 + +FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime AS gpu + +WORKDIR /app + +COPY docker/trainer.requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# Optional for JAX-on-GPU workflows. +ARG INSTALL_JAX_GPU=false +RUN if [ "${INSTALL_JAX_GPU}" = "true" ]; then \ + pip install --no-cache-dir "jax[cuda12]==0.4.30" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html; \ + fi + +COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint +COPY engine /app/engine + +ENV PYTHONPATH=/app \ + XLA_PYTHON_CLIENT_PREALLOCATE=false + +ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"] + + +FROM python:3.11-slim AS tpu + +WORKDIR /app + +COPY docker/trainer.requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +RUN pip install --no-cache-dir "jax[tpu]==0.4.30" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html + +COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint +COPY engine /app/engine + +ENV PYTHONPATH=/app \ + PHANTOM_USE_JAX=1 \ + PHANTOM_DEFAULT_AGENT_ARGS="--jax" \ + JAX_PLATFORMS=tpu,cpu \ + XLA_PYTHON_CLIENT_PREALLOCATE=false + +ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"] diff --git a/docker/trainer-agent-entrypoint.sh b/docker/trainer-agent-entrypoint.sh new file mode 100644 index 0000000..2d53767 --- /dev/null +++ b/docker/trainer-agent-entrypoint.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env sh +set -eu + +if [ -z "${SWEEP_ID:-}" ]; then + echo "SWEEP_ID is required" + exit 1 +fi + +set -- python -m engine.train --sweep-agent --sweep-id "${SWEEP_ID}" + +if [ -n "${PHANTOM_DEFAULT_AGENT_ARGS:-}" ]; then + set -- "$@" ${PHANTOM_DEFAULT_AGENT_ARGS} +fi + +if [ -n "${TRAIN_ARGS:-}" ]; then + set -- "$@" ${TRAIN_ARGS} +fi + +if [ "${AGENT_COUNT:-0}" != "0" ]; then + set -- "$@" --count "${AGENT_COUNT}" +fi + +exec "$@" diff --git a/docker/trainer.requirements.txt b/docker/trainer.requirements.txt new file mode 100644 index 0000000..c47ed11 --- /dev/null +++ b/docker/trainer.requirements.txt @@ -0,0 +1,13 @@ +numpy>=1.24.0 +pandas>=2.0.0 +scipy>=1.11.0 +gymnasium>=0.29.0 +stable-baselines3>=2.2.0 +tensorboard>=2.15.0 +wandb>=0.17.0 +tensorflow-probability==0.24.0 +flax==0.10.7 +optax==0.2.7 +distrax==0.1.5 +orbax-checkpoint==0.11.32 +chex==0.1.90 From 1a9901f11877e552016d0ccd2e428a63669e0c8a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 19 Feb 2026 18:23:08 +0100 Subject: [PATCH 34/36] refactored training approaches --- Makefile | 291 +++------ docker/Trainer.dockerfile | 1 - engine/jax/primitives.py | 8 +- engine/jax/requirements.txt | 10 +- engine/jax/train.py | 881 ++++++++++++++++++++++++-- engine/lib/behavior.py | 28 +- engine/train.py | 29 +- paper/src/chapters/03-methodology.tex | 7 +- 8 files changed, 947 insertions(+), 308 deletions(-) diff --git a/Makefile b/Makefile index 43c55ee..6a24577 100644 --- a/Makefile +++ b/Makefile @@ -8,57 +8,44 @@ VENV := .venv PYTHON := $(VENV)/bin/python PIP := $(VENV)/bin/pip PYTEST := $(VENV)/bin/pytest -TPU_NAME ?= phantom-tpu -TPU_ZONE ?= us-central2-b -TPU_TYPE ?= v4-32 -TPU_RUNTIME ?= tpu-vm-v4-base -TPU_PROJECT ?= phantom-trc -TPU_NETWORK ?= tpu-network -TPU_SUBNETWORK ?= tpu-network -TPU_USE_SPOT ?= 0 -TPU_EXTRA_CREATE_FLAGS ?= -TPU_WORKDIR ?= ~/PHANTOM -TPU_SYNC_PATHS ?= engine lib requirements.txt Makefile .env -TPU_TRAIN_ARGS ?= --algo ppo --jax --total-timesteps 20000 -TPU_JAX_WHEEL_URL ?= https://storage.googleapis.com/jax-releases/libtpu_releases.html -TPU_VENV ?= .venv-tpu -TPU_TRAIN_ENV ?= PHANTOM_USE_JAX=1 WANDB_MODE=online + +SWEEP_ENV_FILE ?= .env.sweep + +WANDB_ENTITY ?= +WANDB_PROJECT ?= phantom-pricing SWEEP_ID ?= -SWEEP_COUNT ?= 5 -QUEUE_SCRIPT ?= scripts/queue_sweep.sh -TPU_QUEUE_TYPE ?= -TPU_QUEUE_ZONES ?= europe-west4-a us-central2-b us-central1-a us-east1-d europe-west4-b -TPU_QUEUE_REUSE_EXISTING ?= 1 -TPU_QUEUE_KEEP_ALIVE ?= 1 -TPU_QUEUE_STRICT_QUOTA ?= 0 -TPU_QUEUE_DOWNSHIFT_ON_QUOTA ?= 1 -TPU_QUEUE_FILTER_ZONE ?= -TPU_QUEUE_FILTER_TYPE ?= -TPU_QUEUE_EXECUTION_MODE ?= venv -TPU_QUEUE_SYNC_METHOD ?= tar -TPU_QUEUE_SKIP_SYNC ?= 0 -TPU_QUEUE_DOCKER_IMAGE ?= -TPU_QUEUE_DOCKER_PULL ?= 1 -TPU_QUEUE_DOCKER_AUTO_INSTALL ?= 1 -TPU_QUEUE_SSH_BATCH_MODE ?= 1 -TPU_QUEUE_SSH_CONNECT_TIMEOUT ?= 12 -TPU_QUEUE_SSH_KEY_FILE ?= $(HOME)/.ssh/google_compute_engine -TPU_QUEUE_REQUIRE_SSH_AGENT ?= 1 -TPU_QUEUE_AUTO_SSH_ADD ?= 1 -TPU_SPOT_FLAG := $(if $(filter 1 true TRUE yes YES,$(TPU_USE_SPOT)),--spot,) -TPU_CREATE_CMD = gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm create "$(TPU_NAME)" --zone="$(TPU_ZONE)" --accelerator-type="$(TPU_TYPE)" --version="$(TPU_RUNTIME)" --network="$(TPU_NETWORK)" --subnetwork="$(TPU_SUBNETWORK)" $(TPU_SPOT_FLAG) $(TPU_EXTRA_CREATE_FLAGS) +LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000 +AGENT_COUNT ?= 0 + +REPO_URL ?= +BRANCH ?= main +WORKDIR ?= $(HOME)/PHANTOM-agent +AGENT_LOOP ?= 1 +RETRY_SECONDS ?= 20 + +TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer +TPU_NAME ?= +TPU_ZONE ?= us-central2-b + +SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a .DEFAULT_GOAL := help .PHONY: help help: - @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines | tpu.* | tpu.queue.*" - @echo "TPU presets: tpu.create.v4.ondemand | tpu.create.v4.spot" - @echo "Queued sweep: SWEEP_ID=entity/project/id make tpu.queue.sweep" - @echo "Queued sweep filters: TPU_QUEUE_FILTER_TYPE=v6e TPU_QUEUE_FILTER_ZONE=europe-west4-a" - @echo "Docker queue: make tpu.queue.sweep.docker TPU_QUEUE_DOCKER_IMAGE=gcr.io//:tag" - @echo "Docker queue without sync: add TPU_QUEUE_SKIP_SYNC=1" - @echo "If SSH key is encrypted: run ssh-add ~/.ssh/google_compute_engine first" + @echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | stats.lines" + @echo "docker.train.publish" + @echo "" + @echo "Local wandb run:" + @echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'" + @echo "" + @echo "Local sweep agent from this repo:" + @echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5" + @echo "" + @echo "Bootstrap private repo worker from anywhere:" + @echo " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id" + @echo "" + @echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)" $(BUILDDIR): mkdir -p paper/$(BUILDDIR) @@ -115,173 +102,39 @@ $(VENV): install: $(VENV) $(PIP) install -r requirements.txt -.PHONY: tpu.setup -tpu.setup: - @command -v gcloud >/dev/null 2>&1 || (echo "gcloud CLI not found. Install from https://cloud.google.com/sdk/docs/install" && exit 1) - @gcloud auth login --update-adc - @gcloud auth application-default login - @gcloud config set project "$(TPU_PROJECT)" +.PHONY: train +train: install + @$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1) + @$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \ + $(PYTHON) -m engine.train $(LOCAL_TRAIN_ARGS) -.PHONY: tpu.check.zone -tpu.check.zone: - @case "$(TPU_ZONE)" in \ - europe-west4-a|us-central2-b|us-central1-a|us-east1-d|europe-west4-b) ;; \ - *) echo "Unsupported TPU_ZONE='$(TPU_ZONE)'. Allowed zones: europe-west4-a us-central2-b us-central1-a us-east1-d europe-west4-b"; exit 1 ;; \ - esac +.PHONY: train.agent +train.agent: install + @$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1) + @test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1) + @$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \ + $(PYTHON) -m engine.train --sweep-agent --sweep-id "$(SWEEP_ID)" \ + $(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),) -.PHONY: tpu.create.v4.ondemand -tpu.create.v4.ondemand: - $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=0 TPU_SUBNETWORK=tpu-network - -.PHONY: tpu.create.v4.spot -tpu.create.v4.spot: - $(MAKE) tpu.create TPU_ZONE=us-central2-b TPU_TYPE=v4-32 TPU_USE_SPOT=1 TPU_SUBNETWORK=tpu-network - -.PHONY: tpu.create -tpu.create: tpu.check.zone - @if gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" >/dev/null 2>&1; then \ - STATE=$$(gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" --format='value(state)'); \ - echo "TPU VM $(TPU_NAME) already exists in $(TPU_ZONE) with state=$$STATE, skipping create"; \ - else \ - $(TPU_CREATE_CMD); \ - fi - -.PHONY: tpu.ensure -tpu.ensure: tpu.check.zone - @set -e; \ - STATE=$$(gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" --format='value(state)' 2>/dev/null || true); \ - if [ -z "$$STATE" ]; then \ - echo "TPU VM $(TPU_NAME) not found in $(TPU_ZONE), creating"; \ - $(TPU_CREATE_CMD); \ - elif [ "$$STATE" = "READY" ]; then \ - echo "TPU VM $(TPU_NAME) is READY"; \ - elif [ "$$STATE" = "PREEMPTED" ] || [ "$$STATE" = "TERMINATED" ] || [ "$$STATE" = "FAILED" ]; then \ - echo "TPU VM $(TPU_NAME) is in terminal state $$STATE, recreating"; \ - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm delete "$(TPU_NAME)" --zone="$(TPU_ZONE)" --quiet || true; \ - $(TPU_CREATE_CMD); \ - else \ - echo "TPU VM $(TPU_NAME) is in state $$STATE; wait or recreate manually"; \ - exit 1; \ - fi - -.PHONY: tpu.status -tpu.status: - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm describe "$(TPU_NAME)" --zone="$(TPU_ZONE)" - -.PHONY: tpu.ssh -tpu.ssh: - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" - -.PHONY: tpu.prepare -tpu.prepare: tpu.ensure - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command "mkdir -p $(TPU_WORKDIR)" - -.PHONY: tpu.deploy -tpu.deploy: tpu.prepare - @for p in $(TPU_SYNC_PATHS); do \ - if [ ! -e "$$p" ]; then continue; fi; \ - if [ -d "$$p" ]; then \ - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm scp --recurse "$$p" "$(TPU_NAME):$(TPU_WORKDIR)/$$p" --zone="$(TPU_ZONE)"; \ - else \ - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm scp "$$p" "$(TPU_NAME):$(TPU_WORKDIR)/$$p" --zone="$(TPU_ZONE)"; \ - fi; \ - done - -.PHONY: tpu.install -tpu.install: tpu.ensure - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'cd $(TPU_WORKDIR) && PYBIN=$$(command -v python3.11 || command -v python3.10 || command -v python3) && $$PYBIN -m venv $(TPU_VENV) && $(TPU_VENV)/bin/pip install --upgrade pip setuptools wheel && $(TPU_VENV)/bin/pip install -r requirements.txt && $(TPU_VENV)/bin/pip install -r engine/jax/requirements.txt && $(TPU_VENV)/bin/pip install "jax[tpu]" -f $(TPU_JAX_WHEEL_URL)' - -.PHONY: tpu.check.remote -tpu.check.remote: tpu.ensure - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'set -e; mkdir -p $(TPU_WORKDIR); cd $(TPU_WORKDIR); test -f engine/train.py || (echo "Missing code on TPU VM. Run: make tpu.deploy" && exit 2); test -x $(TPU_VENV)/bin/python || (echo "Missing TPU venv. Run: make tpu.install" && exit 3)' - -.PHONY: tpu.train -tpu.train: tpu.check.remote - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm ssh "$(TPU_NAME)" --zone="$(TPU_ZONE)" --command 'cd $(TPU_WORKDIR) && if [ -f .env ]; then set -a && . ./.env && set +a; fi && $(TPU_TRAIN_ENV) $(TPU_VENV)/bin/python -m engine.train $(TPU_TRAIN_ARGS)' - -.PHONY: tpu.bootstrap -tpu.bootstrap: tpu.ensure tpu.deploy tpu.install - -.PHONY: tpu.delete -tpu.delete: - gcloud --project="$(TPU_PROJECT)" compute tpus tpu-vm delete "$(TPU_NAME)" --zone="$(TPU_ZONE)" --quiet - -.PHONY: tpu.queue.sweep -tpu.queue.sweep: - @set -e; \ - test -n "$(SWEEP_ID)" || (echo "SWEEP_ID is required, e.g. SWEEP_ID=entity/project/id" && exit 1); \ - test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY is required in your shell" && exit 1); \ - if [ "$(TPU_QUEUE_AUTO_SSH_ADD)" = "1" ] && [ "$(TPU_QUEUE_SSH_BATCH_MODE)" != "0" ] && command -v ssh-add >/dev/null 2>&1 && [ -f "$(TPU_QUEUE_SSH_KEY_FILE)" ]; then \ - if ! ssh-add -l >/dev/null 2>&1; then \ - if [ -z "$$SSH_AUTH_SOCK" ] && command -v ssh-agent >/dev/null 2>&1; then eval "$$(ssh-agent -s)" >/dev/null; fi; \ - ssh-add "$(TPU_QUEUE_SSH_KEY_FILE)"; \ - fi; \ - fi; \ - AGENT_COUNT="$(SWEEP_COUNT)" PROJECT_ID="$(TPU_PROJECT)" TPU_NETWORK="$(TPU_NETWORK)" TPU_SUBNETWORK="$(TPU_SUBNETWORK)" TPU_REUSE_EXISTING="$(TPU_QUEUE_REUSE_EXISTING)" TPU_KEEP_ALIVE="$(TPU_QUEUE_KEEP_ALIVE)" TPU_STRICT_QUOTA="$(TPU_QUEUE_STRICT_QUOTA)" TPU_DOWNSHIFT_ON_QUOTA="$(TPU_QUEUE_DOWNSHIFT_ON_QUOTA)" TPU_EXECUTION_MODE="$(TPU_QUEUE_EXECUTION_MODE)" TPU_SYNC_METHOD="$(TPU_QUEUE_SYNC_METHOD)" TPU_SKIP_SYNC="$(TPU_QUEUE_SKIP_SYNC)" TPU_DOCKER_IMAGE="$(TPU_QUEUE_DOCKER_IMAGE)" TPU_DOCKER_PULL="$(TPU_QUEUE_DOCKER_PULL)" TPU_DOCKER_AUTO_INSTALL="$(TPU_QUEUE_DOCKER_AUTO_INSTALL)" TPU_SSH_BATCH_MODE="$(TPU_QUEUE_SSH_BATCH_MODE)" TPU_SSH_CONNECT_TIMEOUT="$(TPU_QUEUE_SSH_CONNECT_TIMEOUT)" TPU_SSH_KEY_FILE="$(TPU_QUEUE_SSH_KEY_FILE)" TPU_REQUIRE_SSH_AGENT="$(TPU_QUEUE_REQUIRE_SSH_AGENT)" TPU_QUEUE_FILTER_ZONE="$(TPU_QUEUE_FILTER_ZONE)" TPU_QUEUE_FILTER_TYPE="$(TPU_QUEUE_FILTER_TYPE)" WANDB_API_KEY="$$WANDB_API_KEY" "$(QUEUE_SCRIPT)" "$(SWEEP_ID)" - -.PHONY: tpu.queue.worker -tpu.queue.worker: - @set -e; \ - test -n "$(SWEEP_ID)" || (echo "SWEEP_ID is required, e.g. SWEEP_ID=entity/project/id" && exit 1); \ - test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY is required in your shell" && exit 1); \ - if [ "$(TPU_QUEUE_AUTO_SSH_ADD)" = "1" ] && [ "$(TPU_QUEUE_SSH_BATCH_MODE)" != "0" ] && command -v ssh-add >/dev/null 2>&1 && [ -f "$(TPU_QUEUE_SSH_KEY_FILE)" ]; then \ - if ! ssh-add -l >/dev/null 2>&1; then \ - if [ -z "$$SSH_AUTH_SOCK" ] && command -v ssh-agent >/dev/null 2>&1; then eval "$$(ssh-agent -s)" >/dev/null; fi; \ - ssh-add "$(TPU_QUEUE_SSH_KEY_FILE)"; \ - fi; \ - fi; \ - AGENT_COUNT="$(SWEEP_COUNT)" PROJECT_ID="$(TPU_PROJECT)" TPU_NETWORK="$(TPU_NETWORK)" TPU_SUBNETWORK="$(TPU_SUBNETWORK)" TPU_REUSE_EXISTING="$(TPU_QUEUE_REUSE_EXISTING)" TPU_KEEP_ALIVE="$(TPU_QUEUE_KEEP_ALIVE)" TPU_STRICT_QUOTA="$(TPU_QUEUE_STRICT_QUOTA)" TPU_DOWNSHIFT_ON_QUOTA="$(TPU_QUEUE_DOWNSHIFT_ON_QUOTA)" TPU_EXECUTION_MODE="$(TPU_QUEUE_EXECUTION_MODE)" TPU_SYNC_METHOD="$(TPU_QUEUE_SYNC_METHOD)" TPU_SKIP_SYNC="$(TPU_QUEUE_SKIP_SYNC)" TPU_DOCKER_IMAGE="$(TPU_QUEUE_DOCKER_IMAGE)" TPU_DOCKER_PULL="$(TPU_QUEUE_DOCKER_PULL)" TPU_DOCKER_AUTO_INSTALL="$(TPU_QUEUE_DOCKER_AUTO_INSTALL)" TPU_SSH_BATCH_MODE="$(TPU_QUEUE_SSH_BATCH_MODE)" TPU_SSH_CONNECT_TIMEOUT="$(TPU_QUEUE_SSH_CONNECT_TIMEOUT)" TPU_SSH_KEY_FILE="$(TPU_QUEUE_SSH_KEY_FILE)" TPU_REQUIRE_SSH_AGENT="$(TPU_QUEUE_REQUIRE_SSH_AGENT)" TPU_QUEUE_FILTER_ZONE="$(TPU_ZONE)" TPU_QUEUE_FILTER_TYPE="$(TPU_QUEUE_TYPE)" WANDB_API_KEY="$$WANDB_API_KEY" "$(QUEUE_SCRIPT)" "$(SWEEP_ID)" - -.PHONY: tpu.queue.sweep.docker -tpu.queue.sweep.docker: - @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) - @$(MAKE) tpu.queue.sweep TPU_QUEUE_EXECUTION_MODE=docker - -.PHONY: tpu.queue.worker.docker -tpu.queue.worker.docker: - @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) - @$(MAKE) tpu.queue.worker TPU_QUEUE_EXECUTION_MODE=docker - -.PHONY: tpu.queue.docker.build -tpu.queue.docker.build: - @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) - docker build -f docker/TPUSweep.Dockerfile -t "$(TPU_QUEUE_DOCKER_IMAGE)" . - -.PHONY: tpu.queue.docker.push -tpu.queue.docker.push: - @test -n "$(TPU_QUEUE_DOCKER_IMAGE)" || (echo "TPU_QUEUE_DOCKER_IMAGE is required" && exit 1) - docker push "$(TPU_QUEUE_DOCKER_IMAGE)" - -.PHONY: tpu.queue.status -tpu.queue.status: - @set -e; \ - if gcloud compute tpus queued-resources list --help >/dev/null 2>&1; then \ - QCMD='gcloud --project=$(TPU_PROJECT) compute tpus queued-resources'; \ - else \ - QCMD='gcloud --project=$(TPU_PROJECT) alpha compute tpus queued-resources'; \ - fi; \ - for ZONE in $(TPU_QUEUE_ZONES); do \ - echo "--- $$ZONE ---"; \ - if ! $$QCMD list --zone="$$ZONE"; then \ - echo "Skipping $$ZONE (unavailable or no permission)"; \ - fi; \ - done - -.PHONY: tpu.queue.clean -tpu.queue.clean: - @set -e; \ - if gcloud compute tpus queued-resources list --help >/dev/null 2>&1; then \ - QCMD='gcloud --project=$(TPU_PROJECT) compute tpus queued-resources'; \ - else \ - QCMD='gcloud --project=$(TPU_PROJECT) alpha compute tpus queued-resources'; \ - fi; \ - for ZONE in $(TPU_QUEUE_ZONES); do \ - $$QCMD list --zone="$$ZONE" --format='value(name)' 2>/dev/null | while read -r NAME; do \ - case "$$NAME" in \ - qr-*) echo "Deleting $$NAME ($$ZONE)"; $$QCMD delete "$$NAME" --zone="$$ZONE" --quiet ;; \ - esac; \ - done; \ - done +.PHONY: train.bootstrap +train.bootstrap: + @$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1) + @$(SWEEP_ENV_LOAD); test -n "$$GITHUB_TOKEN" || (echo "GITHUB_TOKEN required — set it in $(SWEEP_ENV_FILE)" && exit 1) + @test -n "$(REPO_URL)" || (echo "REPO_URL required, e.g. REPO_URL=https://github.com/org/repo.git" && exit 1) + @test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1) + @$(SWEEP_ENV_LOAD); \ + WANDB_API_KEY="$$WANDB_API_KEY" \ + WANDB_ENTITY="$(WANDB_ENTITY)" \ + WANDB_PROJECT="$(WANDB_PROJECT)" \ + GITHUB_TOKEN="$$GITHUB_TOKEN" \ + REPO_URL="$(REPO_URL)" \ + BRANCH="$(BRANCH)" \ + WORKDIR="$(WORKDIR)" \ + SWEEP_ID="$(SWEEP_ID)" \ + AGENT_COUNT="$(AGENT_COUNT)" \ + AGENT_LOOP="$(AGENT_LOOP)" \ + RETRY_SECONDS="$(RETRY_SECONDS)" \ + bash scripts/wandb_agent_bootstrap.sh .PHONY: stats.lines stats.lines: @@ -299,6 +152,24 @@ wordcount: $(SRCDIR)/chapters/05-discussion.tex \ $(SRCDIR)/chapters/06-conclusion.tex +.PHONY: docker.train.publish +docker.train.publish: + docker build -f docker/Trainer.dockerfile --target gpu -t $(TRAIN_IMAGE_REF):gpu-latest . + docker push $(TRAIN_IMAGE_REF):gpu-latest + docker build -f docker/Trainer.dockerfile --target tpu -t $(TRAIN_IMAGE_REF):tpu-latest . + docker push $(TRAIN_IMAGE_REF):tpu-latest + +.PHONY: train.tpu.pod +train.tpu.pod: + @test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1) + @test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1) + @$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1) + gcloud compute tpus tpu-vm scp scripts/tpu_pod_run.sh $(TPU_NAME):/tmp/tpu_pod_run.sh \ + --zone=$(TPU_ZONE) --project=phantom-trc --worker=all + @$(SWEEP_ENV_LOAD); \ + gcloud compute tpus tpu-vm ssh $(TPU_NAME) \ + --zone=$(TPU_ZONE) --project=phantom-trc --worker=all \ + --command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh" .PHONY: pdf clean watch run.webapp test count-lines all pdf: pdf.build diff --git a/docker/Trainer.dockerfile b/docker/Trainer.dockerfile index c6776ea..df50fed 100644 --- a/docker/Trainer.dockerfile +++ b/docker/Trainer.dockerfile @@ -37,7 +37,6 @@ COPY engine /app/engine ENV PYTHONPATH=/app \ PHANTOM_USE_JAX=1 \ PHANTOM_DEFAULT_AGENT_ARGS="--jax" \ - JAX_PLATFORMS=tpu,cpu \ XLA_PYTHON_CLIENT_PREALLOCATE=false ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"] diff --git a/engine/jax/primitives.py b/engine/jax/primitives.py index 8de4c2b..37bf326 100644 --- a/engine/jax/primitives.py +++ b/engine/jax/primitives.py @@ -308,6 +308,8 @@ if JAX_AVAILABLE: n_states: int, ) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]: k_actor, k_product, k_step = jax.random.split(key, 3) + start_idx_i32 = jnp.asarray(start_idx, dtype=jnp.int32) + term_idx_i32 = jnp.asarray(term_idx, dtype=jnp.int32) actor_draw = jax.random.uniform(k_actor, (n_sessions,)) actors = (actor_draw < alpha).astype(jnp.int32) products = jax.random.randint( @@ -315,7 +317,7 @@ if JAX_AVAILABLE: ) active_init = jnp.ones((n_sessions,), dtype=jnp.bool_) - state_init = jnp.full((n_sessions,), int(start_idx), dtype=jnp.int32) + state_init = jnp.full((n_sessions,), start_idx_i32, dtype=jnp.int32) def _scan_step(carry, _): states, active, rng = carry @@ -324,11 +326,11 @@ if JAX_AVAILABLE: probs_a = agent_T[states] probs = jnp.where(actors[:, None] == 0, probs_h, probs_a) next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1) - next_state = jnp.where(active, next_state, int(term_idx)) + next_state = jnp.where(active, next_state, term_idx_i32) emitted = jnp.where(active, next_state, -1) is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)] next_active = active & (~is_terminal) - carry_states = jnp.where(next_active, next_state, int(term_idx)) + carry_states = jnp.where(next_active, next_state, term_idx_i32) return (carry_states, next_active, rng), emitted _, state_t = jax.lax.scan( diff --git a/engine/jax/requirements.txt b/engine/jax/requirements.txt index 42ba457..7bde61c 100644 --- a/engine/jax/requirements.txt +++ b/engine/jax/requirements.txt @@ -1,5 +1,5 @@ -flax>=0.8.0 -optax>=0.2.0 -distrax>=0.1.5 -orbax-checkpoint>=0.5.0 -chex>=0.1.8 +flax==0.10.7 +optax==0.2.7 +distrax==0.1.5 +orbax-checkpoint==0.11.32 +chex==0.1.90 diff --git a/engine/jax/train.py b/engine/jax/train.py index 41678c1..408f9b3 100644 --- a/engine/jax/train.py +++ b/engine/jax/train.py @@ -1,12 +1,34 @@ -"""Pure JAX PPO trainer for the PHANTOM environment.""" +"""Pure JAX trainers for PHANTOM environment.""" from __future__ import annotations from pathlib import Path from typing import Any, NamedTuple +import signal +import threading + import numpy as np +_stop_requested = threading.Event() +_jax_dist_initialized = False + + +def _init_jax_distributed() -> None: + """Initialize JAX distributed if running on a multi-host TPU pod. + Safe to call multiple times; no-op after first successful init or when JAX unavailable.""" + global _jax_dist_initialized + if _jax_dist_initialized: + return + _jax_dist_initialized = True + try: + import jax as _jax + + _jax.distributed.initialize() + except Exception: + pass + + try: import wandb @@ -108,6 +130,33 @@ class ActorCritic(nn.Module): return distrax.Categorical(logits=logits), jnp.squeeze(value, axis=-1) +class QNetwork(nn.Module): + action_dim: int + activation: str = "relu" + + @nn.compact + def __call__(self, x): + activation_fn = nn.relu if self.activation == "relu" else nn.tanh + x = nn.Dense( + 128, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(x) + x = activation_fn(x) + x = nn.Dense( + 128, + kernel_init=orthogonal(np.sqrt(2.0)), + bias_init=constant(0.0), + )(x) + x = activation_fn(x) + q_values = nn.Dense( + self.action_dim, + kernel_init=orthogonal(1.0), + bias_init=constant(0.0), + )(x) + return q_values + + class Transition(NamedTuple): done: jax.Array action: jax.Array @@ -118,6 +167,24 @@ class Transition(NamedTuple): info: dict[str, jax.Array] +class ReplayBatch(NamedTuple): + obs: jax.Array + actions: jax.Array + rewards: jax.Array + next_obs: jax.Array + dones: jax.Array + + +class ReplayBuffer(NamedTuple): + obs: jax.Array + actions: jax.Array + rewards: jax.Array + next_obs: jax.Array + dones: jax.Array + ptr: jax.Array + size: jax.Array + + def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: out = { "algo": str(cfg.get("algo", "ppo")).lower(), @@ -133,6 +200,7 @@ def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: "total_timesteps": int(cfg.get("total_timesteps", 50_000)), "eval_episodes": int(cfg.get("eval_episodes", 5)), "model_dir": str(cfg.get("model_dir", "engine/models")), + "log_freq": int(cfg.get("log_freq", 100)), "n_products": int(cfg.get("n_products", 10)), "N": int(cfg.get("N", 100)), "alpha": float(cfg.get("alpha", 0.3)), @@ -156,6 +224,18 @@ def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: "update_epochs": int(cfg.get("jax_update_epochs", 4)), "anneal_lr": bool(cfg.get("jax_anneal_lr", True)), "checkpoint_interval": int(cfg.get("checkpoint_interval", 10_000)), + "buffer_size": int(cfg.get("buffer_size", 50_000)), + "batch_size": int(cfg.get("batch_size", 256)), + "train_freq": int(cfg.get("train_freq", 1)), + "learning_starts": int(cfg.get("learning_starts", 1_000)), + "target_update_interval": int(cfg.get("target_update_interval", 1_000)), + "exploration_fraction": float(cfg.get("exploration_fraction", 0.2)), + "exploration_final_eps": float(cfg.get("exploration_final_eps", 0.05)), + "eps_start": float(cfg.get("eps_start", 1.0)), + "eps_end": float(cfg.get("eps_end", 0.05)), + "eps_decay": float(cfg.get("eps_decay", 0.9995)), + "q_lr": float(cfg.get("q_lr", 0.1)), + "q_bins": int(cfg.get("q_bins", 6)), } rollout = out["num_envs"] * out["num_steps"] out["num_updates"] = max(1, out["total_timesteps"] // max(rollout, 1)) @@ -163,15 +243,15 @@ def _jax_cfg(cfg: dict[str, Any]) -> dict[str, Any]: return out -def _select_env_state(done: jax.Array, keep: jax.Array, reset: jax.Array) -> jax.Array: - mask = done - while mask.ndim < keep.ndim: - mask = mask[..., None] - return jnp.where(mask, reset, keep) +def _scalar(value: Any) -> float: + return float(np.asarray(value)) -def make_train(config: dict[str, Any]): - cfg = _jax_cfg(config) +def _scalar_int(value: Any) -> int: + return int(np.asarray(value)) + + +def _make_env(cfg: dict[str, Any]) -> PHANTOMJAXEnv: env_params = make_env_params( n_products=cfg["n_products"], alpha=cfg["alpha"], @@ -191,7 +271,109 @@ def make_train(config: dict[str, Any]): margin_floor_patience=cfg["margin_floor_patience"], prefer_behavior_data=cfg["prefer_behavior_data"], ) - env = PHANTOMJAXEnv(env_params) + return PHANTOMJAXEnv(env_params) + + +def _select_env_state(done: jax.Array, keep: jax.Array, reset: jax.Array) -> jax.Array: + mask = done + while mask.ndim < keep.ndim: + mask = mask[..., None] + return jnp.where(mask, reset, keep) + + +def _epsilon_by_fraction(step: int, cfg: dict[str, Any]) -> float: + start = float(cfg["eps_start"]) + end = float(cfg["exploration_final_eps"]) + frac = float(cfg["exploration_fraction"]) + total = max(1, int(cfg["total_timesteps"])) + decay_steps = max(1, int(total * frac)) + if step >= decay_steps: + return end + slope = (end - start) / decay_steps + return float(start + slope * step) + + +def _digitize_scalar(value: jax.Array, bins: jax.Array) -> jax.Array: + return jnp.sum(value > bins).astype(jnp.int32) + + +def _encode_qtable_state( + obs: jax.Array, + *, + n_products: int, + demand_bins: jax.Array, + price_bins: jax.Array, +) -> tuple[jax.Array, jax.Array, jax.Array]: + demand = obs[:n_products] + prices = obs[n_products : 2 * n_products] + d_mean = jnp.mean(demand) + d_std = jnp.std(demand) + p_mean = jnp.mean(prices) + return ( + _digitize_scalar(d_mean, demand_bins), + _digitize_scalar(d_std, demand_bins), + _digitize_scalar(p_mean, price_bins), + ) + + +def _init_replay_buffer(capacity: int, obs_dim: int) -> ReplayBuffer: + cap = max(1, int(capacity)) + return ReplayBuffer( + obs=jnp.zeros((cap, obs_dim), dtype=jnp.float32), + actions=jnp.zeros((cap,), dtype=jnp.int32), + rewards=jnp.zeros((cap,), dtype=jnp.float32), + next_obs=jnp.zeros((cap, obs_dim), dtype=jnp.float32), + dones=jnp.zeros((cap,), dtype=jnp.float32), + ptr=jnp.asarray(0, dtype=jnp.int32), + size=jnp.asarray(0, dtype=jnp.int32), + ) + + +def _replay_size(buffer: ReplayBuffer) -> int: + return _scalar_int(buffer.size) + + +def _replay_add( + buffer: ReplayBuffer, + obs: jax.Array, + action: jax.Array, + reward: jax.Array, + next_obs: jax.Array, + done: jax.Array, +) -> ReplayBuffer: + capacity = int(buffer.obs.shape[0]) + idx = buffer.ptr % capacity + return ReplayBuffer( + obs=buffer.obs.at[idx].set(obs.astype(jnp.float32)), + actions=buffer.actions.at[idx].set(action.astype(jnp.int32)), + rewards=buffer.rewards.at[idx].set(reward.astype(jnp.float32)), + next_obs=buffer.next_obs.at[idx].set(next_obs.astype(jnp.float32)), + dones=buffer.dones.at[idx].set(done.astype(jnp.float32)), + ptr=buffer.ptr + 1, + size=jnp.minimum(buffer.size + 1, jnp.asarray(capacity, dtype=jnp.int32)), + ) + + +def _replay_sample( + buffer: ReplayBuffer, key: jax.Array, batch_size: int +) -> ReplayBatch: + size = jnp.maximum(buffer.size, 1) + idx = jax.random.randint(key, shape=(batch_size,), minval=0, maxval=size) + return ReplayBatch( + obs=buffer.obs[idx], + actions=buffer.actions[idx], + rewards=buffer.rewards[idx], + next_obs=buffer.next_obs[idx], + dones=buffer.dones[idx], + ) + + +def _make_actor_critic_train( + config: dict[str, Any], *, algo: str, use_pmap: bool = False +): + cfg = dict(config) + cfg["algo"] = algo + env = _make_env(cfg) network = ActorCritic(env.action_space_n(), activation=cfg["activation"]) def linear_schedule(count: jax.Array) -> jax.Array: @@ -299,39 +481,45 @@ def make_train(config: dict[str, Any]): def _loss_fn(params, traj_b, adv_b, tgt_b): policy, value = network.apply(params, traj_b.obs) log_prob = policy.log_prob(traj_b.action) - - value_clipped = traj_b.value + (value - traj_b.value).clip( - -cfg["clip_range"], cfg["clip_range"] - ) - value_loss = ( - 0.5 - * jnp.maximum( - jnp.square(value - tgt_b), - jnp.square(value_clipped - tgt_b), - ).mean() - ) - adv_norm = (adv_b - adv_b.mean()) / (adv_b.std() + 1e-8) - ratio = jnp.exp(log_prob - traj_b.log_prob) - loss_actor = -jnp.minimum( - ratio * adv_norm, - jnp.clip( - ratio, - 1.0 - cfg["clip_range"], - 1.0 + cfg["clip_range"], + + if algo == "ppo": + value_clipped = traj_b.value + (value - traj_b.value).clip( + -cfg["clip_range"], cfg["clip_range"] ) - * adv_norm, - ).mean() + value_loss = ( + 0.5 + * jnp.maximum( + jnp.square(value - tgt_b), + jnp.square(value_clipped - tgt_b), + ).mean() + ) + ratio = jnp.exp(log_prob - traj_b.log_prob) + policy_loss = -jnp.minimum( + ratio * adv_norm, + jnp.clip( + ratio, + 1.0 - cfg["clip_range"], + 1.0 + cfg["clip_range"], + ) + * adv_norm, + ).mean() + else: + value_loss = 0.5 * jnp.mean(jnp.square(value - tgt_b)) + policy_loss = -(log_prob * adv_norm).mean() + entropy = policy.entropy().mean() total_loss = ( - loss_actor + policy_loss + cfg["vf_coef"] * value_loss - cfg["ent_coef"] * entropy ) - return total_loss, (value_loss, loss_actor, entropy) + return total_loss, (value_loss, policy_loss, entropy) grad_fn = jax.value_and_grad(_loss_fn, has_aux=True) (_, _), grads = grad_fn(train_state.params, traj_b, adv_b, tgt_b) + if use_pmap: + grads = jax.lax.pmean(grads, axis_name="devices") train_state = train_state.apply_gradients(grads=grads) return train_state, jnp.asarray(0.0, dtype=jnp.float32) @@ -339,6 +527,7 @@ def make_train(config: dict[str, Any]): rng, perm_key = jax.random.split(rng) batch_size = cfg["num_envs"] * cfg["num_steps"] permutation = jax.random.permutation(perm_key, batch_size) + batch = (traj_batch, advantages, targets) batch = jax.tree_util.tree_map( lambda x: x.reshape((batch_size,) + x.shape[2:]), @@ -377,7 +566,7 @@ def make_train(config: dict[str, Any]): next_runner_state = (train_state, env_state, last_obs, rng) return next_runner_state, metric - def run_updates(runner_state, *, num_updates: int): + def run_updates(runner_state, num_updates: int): updates = max(1, int(num_updates)) runner_state, metric = jax.lax.scan( _update_step, @@ -393,6 +582,14 @@ def make_train(config: dict[str, Any]): return init_runner_state, run_updates, network, env, cfg +def make_train(config: dict[str, Any]): + cfg = _jax_cfg(config) + algo = cfg["algo"] + if algo not in {"ppo", "a2c"}: + raise ValueError(f"make_train supports actor-critic algos only, got '{algo}'") + return _make_actor_critic_train(cfg, algo=algo) + + def evaluate_policy( *, network: ActorCritic, @@ -418,8 +615,8 @@ def evaluate_policy( action = jnp.argmax(policy.logits) key, step_key = jax.random.split(key) obs, state, reward, done_flag, info = env.step(step_key, state, action) - ep_reward += float(np.asarray(reward)) - ep_revenue += float(np.asarray(info["revenue"])) + ep_reward += _scalar(reward) + ep_revenue += _scalar(info["revenue"]) done = bool(np.asarray(done_flag)) steps += 1 @@ -434,32 +631,130 @@ def evaluate_policy( } -def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: - if not HAS_JAX_STACK: - raise ImportError( - "JAX PPO path requires jax, flax, optax, and distrax. " - "Install engine/jax/requirements.txt on this machine first." - ) +def _evaluate_q_network( + *, + network: QNetwork, + params: Any, + env: PHANTOMJAXEnv, + episodes: int, + seed: int, +) -> dict[str, float]: + rewards: list[float] = [] + revenues: list[float] = [] + key = jax.random.PRNGKey(seed) - run_cfg = _jax_cfg(cfg) - if run_cfg["algo"] != "ppo": - raise ValueError( - f"JAX backend currently supports algo='ppo' only, got '{run_cfg['algo']}'" - ) + for _ in range(int(episodes)): + key, reset_key = jax.random.split(key) + obs, state = env.reset(reset_key) + ep_reward = 0.0 + ep_revenue = 0.0 + done = False + steps = 0 + + while not done and steps < int(env.params.max_episode_steps): + q_values = network.apply(params, obs) + action = jnp.argmax(q_values) + key, step_key = jax.random.split(key) + obs, state, reward, done_flag, info = env.step(step_key, state, action) + ep_reward += _scalar(reward) + ep_revenue += _scalar(info["revenue"]) + done = bool(np.asarray(done_flag)) + steps += 1 + + rewards.append(ep_reward) + revenues.append(ep_revenue) + + return { + "eval/reward": float(np.mean(rewards)), + "eval/revenue": float(np.mean(revenues)), + "eval/reward_std": float(np.std(rewards)), + "eval/revenue_std": float(np.std(revenues)), + } + + +def _evaluate_q_table( + *, + q_table: jax.Array, + env: PHANTOMJAXEnv, + episodes: int, + seed: int, + n_products: int, + demand_bins: jax.Array, + price_bins: jax.Array, +) -> dict[str, float]: + rewards: list[float] = [] + revenues: list[float] = [] + key = jax.random.PRNGKey(seed) + + for _ in range(int(episodes)): + key, reset_key = jax.random.split(key) + obs, state = env.reset(reset_key) + ep_reward = 0.0 + ep_revenue = 0.0 + done = False + steps = 0 + + while not done and steps < int(env.params.max_episode_steps): + s0, s1, s2 = _encode_qtable_state( + obs, + n_products=n_products, + demand_bins=demand_bins, + price_bins=price_bins, + ) + action = jnp.argmax(q_table[s0, s1, s2]) + key, step_key = jax.random.split(key) + obs, state, reward, done_flag, info = env.step(step_key, state, action) + ep_reward += _scalar(reward) + ep_revenue += _scalar(info["revenue"]) + done = bool(np.asarray(done_flag)) + steps += 1 + + rewards.append(ep_reward) + revenues.append(ep_revenue) + + return { + "eval/reward": float(np.mean(rewards)), + "eval/revenue": float(np.mean(revenues)), + "eval/reward_std": float(np.std(rewards)), + "eval/revenue_std": float(np.std(revenues)), + } + + +def _train_actor_critic( + cfg: dict[str, Any], + *, + algo: str, +) -> tuple[dict[str, Any], dict[str, float]]: + num_devices = jax.local_device_count() + use_pmap = num_devices > 1 + + init_runner_state, run_updates_raw, network, env, run_cfg = ( + _make_actor_critic_train(cfg, algo=algo, use_pmap=use_pmap) + ) + + if use_pmap: + run_fn = jax.pmap( + run_updates_raw, + axis_name="devices", + static_broadcasted_argnums=(1,), + devices=jax.local_devices(), + ) + else: + run_fn = jax.jit(run_updates_raw, static_argnames=("num_updates",)) - init_runner_state, run_updates, network, env, run_cfg = make_train(run_cfg) - run_updates_jit = jax.jit(run_updates, static_argnames=("num_updates",)) rollout_steps = int(run_cfg["num_steps"] * run_cfg["num_envs"]) total_updates = int(run_cfg["num_updates"]) checkpoint_interval = max(1, int(run_cfg.get("checkpoint_interval", 10_000))) segment_updates = max(1, checkpoint_interval // max(rollout_steps, 1)) rng = jax.random.PRNGKey(run_cfg["seed"]) - runner_state = init_runner_state(rng) + # single-device state used as template for serialization and eval + single_runner_state = init_runner_state(rng) updates_done = 0 + is_primary = jax.process_index() == 0 artifact_name = None - if HAS_WANDB and wandb.run is not None: + if is_primary and HAS_WANDB and wandb.run is not None: sweep_id = getattr(wandb.run, "sweep_id", None) artifact_name = checkpoint_artifact_name( run_cfg, @@ -468,34 +763,48 @@ def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: ) restored = download_latest_checkpoint( artifact_name, - file_name="jax_runner_state.msgpack", + file_name=f"jax_{algo}_runner_state.msgpack", ) if restored is not None: checkpoint_path, metadata = restored - template = { - "runner_state": runner_state, - "updates_done": 0, - } + template = {"runner_state": single_runner_state, "updates_done": 0} payload = serialization.from_bytes(template, checkpoint_path.read_bytes()) - runner_state = payload["runner_state"] + single_runner_state = payload["runner_state"] updates_done = int(payload.get("updates_done", 0)) if updates_done <= 0: updates_done = int(metadata.get("updates_done", 0)) updates_done = max(0, min(updates_done, total_updates)) + if use_pmap: + runner_state = jax.device_put_replicated( + single_runner_state, jax.local_devices() + ) + else: + runner_state = single_runner_state + metric_keys = ["reward", "revenue", "agent_prob", "alpha_adv", "coi_leakage"] metric_sums = {k: 0.0 for k in metric_keys} metric_count = 0 while updates_done < total_updates: updates_this_segment = min(segment_updates, total_updates - updates_done) - out = run_updates_jit(runner_state, num_updates=updates_this_segment) + if use_pmap: + out = run_fn(runner_state, updates_this_segment) + else: + out = run_fn(runner_state, updates_this_segment) runner_state = out["runner_state"] metric = out["metrics"] - segment_values = { - k: np.asarray(metric[k], dtype=np.float64) for k in metric_keys - } + if use_pmap: + # take device-0 slice; shape is (n_devices, segment_updates) + segment_values = { + key: np.asarray(metric[key][0], dtype=np.float64) for key in metric_keys + } + else: + segment_values = { + key: np.asarray(metric[key], dtype=np.float64) for key in metric_keys + } + segment_count = int(segment_values["reward"].shape[0]) if segment_values else 0 metric_count += segment_count for key in metric_keys: @@ -504,7 +813,7 @@ def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: updates_done += int(updates_this_segment) global_step = int(updates_done * rollout_steps) - if HAS_WANDB and wandb.run is not None: + if is_primary and HAS_WANDB and wandb.run is not None: wandb.log( { "train/reward": float(segment_values["reward"].mean()), @@ -517,25 +826,36 @@ def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: step=global_step, ) if artifact_name is not None: + # extract device-0 state for checkpoint portability + state_to_save = ( + jax.tree_util.tree_map(lambda x: x[0], runner_state) + if use_pmap + else runner_state + ) checkpoint_payload = serialization.to_bytes( - { - "runner_state": runner_state, - "updates_done": updates_done, - } + {"runner_state": state_to_save, "updates_done": updates_done} ) log_checkpoint_bytes( artifact_name, - file_name="jax_runner_state.msgpack", + file_name=f"jax_{algo}_runner_state.msgpack", payload=checkpoint_payload, metadata={ "step": global_step, "updates_done": updates_done, "rollout_steps": rollout_steps, - "algo": "ppo", + "algo": algo, }, ) + if _stop_requested.is_set(): + break - train_state = runner_state[0] + # extract device-0 params for eval and save + final_runner = ( + jax.tree_util.tree_map(lambda x: x[0], runner_state) + if use_pmap + else runner_state + ) + train_state = final_runner[0] denom = float(metric_count) if metric_count > 0 else 1.0 metrics = { "train/reward": float(metric_sums["reward"] / denom), @@ -555,9 +875,430 @@ def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: ) metrics.update(eval_metrics) + if is_primary: + model_dir = Path(run_cfg["model_dir"]) + model_dir.mkdir(parents=True, exist_ok=True) + model_path = model_dir / f"phantom_{algo}_jax.msgpack" + model_path.write_bytes(serialization.to_bytes(train_state.params)) + metrics["model/path"] = str(model_path) + + return {"params": train_state.params}, metrics + + +def _train_dqn(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: + run_cfg = dict(cfg) + env = _make_env(run_cfg) + action_dim = env.action_space_n() + obs_dim = env.observation_dim() + q_net = QNetwork(action_dim=action_dim, activation=run_cfg["activation"]) + + init_obs = jnp.zeros((obs_dim,), dtype=jnp.float32) + rng = jax.random.PRNGKey(run_cfg["seed"]) + rng, init_key = jax.random.split(rng) + params = q_net.init(init_key, init_obs) + tx = optax.adam(run_cfg["learning_rate"]) + train_state = TrainState.create(apply_fn=q_net.apply, params=params, tx=tx) + target_params = train_state.params + + buffer = _init_replay_buffer(run_cfg["buffer_size"], obs_dim) + + rng, reset_key = jax.random.split(rng) + obs, env_state = env.reset(reset_key) + + start_step = 0 + epsilon_value = float(run_cfg["eps_start"]) + artifact_name = None + + if HAS_WANDB and wandb.run is not None: + sweep_id = getattr(wandb.run, "sweep_id", None) + artifact_name = checkpoint_artifact_name( + run_cfg, + backend="jax", + sweep_id=sweep_id, + ) + restored = download_latest_checkpoint( + artifact_name, + file_name="jax_dqn_state.msgpack", + ) + if restored is not None: + checkpoint_path, metadata = restored + template = { + "params": train_state.params, + "target_params": target_params, + "opt_state": train_state.opt_state, + "global_step": 0, + "epsilon": epsilon_value, + } + payload = serialization.from_bytes(template, checkpoint_path.read_bytes()) + train_state = train_state.replace( + params=payload["params"], + opt_state=payload["opt_state"], + ) + target_params = payload["target_params"] + start_step = int(payload.get("global_step", metadata.get("step", 0))) + start_step = max(0, min(start_step, int(run_cfg["total_timesteps"]))) + epsilon_value = float(payload.get("epsilon", epsilon_value)) + + @jax.jit + def dqn_update( + state: TrainState, + target: Any, + batch: ReplayBatch, + ) -> tuple[TrainState, jax.Array]: + def loss_fn(model_params): + q_values = q_net.apply(model_params, batch.obs) + chosen = jnp.take_along_axis( + q_values, + batch.actions[:, None], + axis=1, + ).squeeze(-1) + next_q = q_net.apply(target, batch.next_obs) + next_max = jnp.max(next_q, axis=1) + td_target = ( + batch.rewards + run_cfg["gamma"] * (1.0 - batch.dones) * next_max + ) + td_error = chosen - jax.lax.stop_gradient(td_target) + return jnp.mean(jnp.square(td_error)) + + loss, grads = jax.value_and_grad(loss_fn)(state.params) + next_state = state.apply_gradients(grads=grads) + return next_state, loss + + metric_sums = { + "reward": 0.0, + "revenue": 0.0, + "agent_prob": 0.0, + "alpha_adv": 0.0, + "coi_leakage": 0.0, + "loss": 0.0, + } + metric_count = 0 + loss_count = 0 + + total_steps = int(run_cfg["total_timesteps"]) + checkpoint_interval = max(1, int(run_cfg["checkpoint_interval"])) + batch_size = max(1, int(run_cfg["batch_size"])) + + for global_step in range(start_step + 1, total_steps + 1): + epsilon_value = _epsilon_by_fraction(global_step - 1, run_cfg) + + rng, eps_key, action_key, step_key, reset_key, sample_key = jax.random.split( + rng, 6 + ) + do_explore = bool(np.asarray(jax.random.uniform(eps_key) < epsilon_value)) + if do_explore: + action = jax.random.randint( + action_key, shape=(), minval=0, maxval=action_dim + ) + else: + q_values = q_net.apply(train_state.params, obs) + action = jnp.argmax(q_values) + + next_obs, next_state, reward, done, info = env.step(step_key, env_state, action) + buffer = _replay_add( + buffer, + obs, + action, + reward, + next_obs, + done.astype(jnp.float32), + ) + + metric_count += 1 + metric_sums["reward"] += _scalar(reward) + metric_sums["revenue"] += _scalar(info["revenue"]) + metric_sums["agent_prob"] += _scalar(info["agent_prob"]) + metric_sums["alpha_adv"] += _scalar(info["alpha_adv"]) + metric_sums["coi_leakage"] += _scalar(info["coi_leakage"]) + + if bool(np.asarray(done)): + obs, env_state = env.reset(reset_key) + else: + obs, env_state = next_obs, next_state + + ready = ( + global_step >= int(run_cfg["learning_starts"]) + and global_step % int(run_cfg["train_freq"]) == 0 + and _replay_size(buffer) >= batch_size + ) + if ready: + batch = _replay_sample(buffer, sample_key, batch_size) + train_state, loss = dqn_update(train_state, target_params, batch) + metric_sums["loss"] += _scalar(loss) + loss_count += 1 + + if global_step % int(run_cfg["target_update_interval"]) == 0: + target_params = train_state.params + + if ( + HAS_WANDB + and wandb.run is not None + and global_step % int(run_cfg["log_freq"]) == 0 + ): + wandb.log( + { + "train/reward": metric_sums["reward"] / max(metric_count, 1), + "train/revenue": metric_sums["revenue"] / max(metric_count, 1), + "train/agent_prob": metric_sums["agent_prob"] + / max(metric_count, 1), + "train/alpha_adv": metric_sums["alpha_adv"] / max(metric_count, 1), + "train/coi_leakage": metric_sums["coi_leakage"] + / max(metric_count, 1), + "train/dqn_loss": metric_sums["loss"] / max(loss_count, 1), + "train/epsilon": epsilon_value, + "train/global_step": global_step, + }, + step=global_step, + ) + + if artifact_name is not None and global_step % checkpoint_interval == 0: + payload = serialization.to_bytes( + { + "params": train_state.params, + "target_params": target_params, + "opt_state": train_state.opt_state, + "global_step": global_step, + "epsilon": epsilon_value, + } + ) + log_checkpoint_bytes( + artifact_name, + file_name="jax_dqn_state.msgpack", + payload=payload, + metadata={ + "step": global_step, + "algo": "dqn", + }, + ) + if _stop_requested.is_set(): + break + + denom = float(metric_count) if metric_count > 0 else 1.0 + metrics = { + "train/reward": float(metric_sums["reward"] / denom), + "train/revenue": float(metric_sums["revenue"] / denom), + "train/agent_prob": float(metric_sums["agent_prob"] / denom), + "train/alpha_adv": float(metric_sums["alpha_adv"] / denom), + "train/coi_leakage": float(metric_sums["coi_leakage"] / denom), + "train/dqn_loss": float(metric_sums["loss"] / max(loss_count, 1)), + "train/global_step": total_steps, + } + + eval_metrics = _evaluate_q_network( + network=q_net, + params=train_state.params, + env=env, + episodes=run_cfg["eval_episodes"], + seed=run_cfg["seed"] + 7, + ) + metrics.update(eval_metrics) + model_dir = Path(run_cfg["model_dir"]) model_dir.mkdir(parents=True, exist_ok=True) - model_path = model_dir / "phantom_ppo_jax.msgpack" + model_path = model_dir / "phantom_dqn_jax.msgpack" model_path.write_bytes(serialization.to_bytes(train_state.params)) metrics["model/path"] = str(model_path) - return {"params": train_state.params}, metrics + return { + "params": train_state.params, + "target_params": target_params, + }, metrics + + +def _train_qtable(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: + run_cfg = dict(cfg) + env = _make_env(run_cfg) + action_dim = env.action_space_n() + n_bins = max(2, int(run_cfg["q_bins"])) + n_products = int(run_cfg["n_products"]) + + q_table = jnp.zeros((n_bins, n_bins, n_bins, action_dim), dtype=jnp.float32) + demand_bins = jnp.linspace(0.0, 100.0, n_bins + 1, dtype=jnp.float32)[1:-1] + price_bins = jnp.linspace( + float(run_cfg["price_low"]), + float(run_cfg["price_high"]), + n_bins + 1, + dtype=jnp.float32, + )[1:-1] + + rng = jax.random.PRNGKey(run_cfg["seed"]) + rng, reset_key = jax.random.split(rng) + obs, env_state = env.reset(reset_key) + + epsilon_value = float(run_cfg["eps_start"]) + start_step = 0 + artifact_name = None + + if HAS_WANDB and wandb.run is not None: + sweep_id = getattr(wandb.run, "sweep_id", None) + artifact_name = checkpoint_artifact_name( + run_cfg, + backend="jax", + sweep_id=sweep_id, + ) + restored = download_latest_checkpoint( + artifact_name, + file_name="jax_qtable_state.msgpack", + ) + if restored is not None: + checkpoint_path, metadata = restored + template = { + "q_table": q_table, + "global_step": 0, + "epsilon": epsilon_value, + } + payload = serialization.from_bytes(template, checkpoint_path.read_bytes()) + q_table = payload["q_table"] + start_step = int(payload.get("global_step", metadata.get("step", 0))) + start_step = max(0, min(start_step, int(run_cfg["total_timesteps"]))) + epsilon_value = float(payload.get("epsilon", epsilon_value)) + + metric_sums = { + "reward": 0.0, + "revenue": 0.0, + "agent_prob": 0.0, + "alpha_adv": 0.0, + "coi_leakage": 0.0, + } + metric_count = 0 + + total_steps = int(run_cfg["total_timesteps"]) + checkpoint_interval = max(1, int(run_cfg["checkpoint_interval"])) + + for global_step in range(start_step + 1, total_steps + 1): + s0, s1, s2 = _encode_qtable_state( + obs, + n_products=n_products, + demand_bins=demand_bins, + price_bins=price_bins, + ) + state_q = q_table[s0, s1, s2] + + rng, eps_key, action_key, step_key, reset_key = jax.random.split(rng, 5) + do_explore = bool(np.asarray(jax.random.uniform(eps_key) < epsilon_value)) + if do_explore: + action = jax.random.randint( + action_key, shape=(), minval=0, maxval=action_dim + ) + else: + action = jnp.argmax(state_q) + + next_obs, next_state, reward, done, info = env.step(step_key, env_state, action) + ns0, ns1, ns2 = _encode_qtable_state( + next_obs, + n_products=n_products, + demand_bins=demand_bins, + price_bins=price_bins, + ) + + best_next = jnp.max(q_table[ns0, ns1, ns2]) + done_f = done.astype(jnp.float32) + td_target = reward + run_cfg["gamma"] * (1.0 - done_f) * best_next + old_value = q_table[s0, s1, s2, action] + new_value = old_value + run_cfg["q_lr"] * (td_target - old_value) + q_table = q_table.at[s0, s1, s2, action].set(new_value) + + epsilon_value = max( + float(run_cfg["eps_end"]), + epsilon_value * float(run_cfg["eps_decay"]), + ) + + metric_count += 1 + metric_sums["reward"] += _scalar(reward) + metric_sums["revenue"] += _scalar(info["revenue"]) + metric_sums["agent_prob"] += _scalar(info["agent_prob"]) + metric_sums["alpha_adv"] += _scalar(info["alpha_adv"]) + metric_sums["coi_leakage"] += _scalar(info["coi_leakage"]) + + if bool(np.asarray(done)): + obs, env_state = env.reset(reset_key) + else: + obs, env_state = next_obs, next_state + + if ( + HAS_WANDB + and wandb.run is not None + and global_step % int(run_cfg["log_freq"]) == 0 + ): + wandb.log( + { + "train/reward": metric_sums["reward"] / max(metric_count, 1), + "train/revenue": metric_sums["revenue"] / max(metric_count, 1), + "train/agent_prob": metric_sums["agent_prob"] + / max(metric_count, 1), + "train/alpha_adv": metric_sums["alpha_adv"] / max(metric_count, 1), + "train/coi_leakage": metric_sums["coi_leakage"] + / max(metric_count, 1), + "train/epsilon": epsilon_value, + "train/global_step": global_step, + }, + step=global_step, + ) + + if artifact_name is not None and global_step % checkpoint_interval == 0: + payload = serialization.to_bytes( + { + "q_table": q_table, + "global_step": global_step, + "epsilon": epsilon_value, + } + ) + log_checkpoint_bytes( + artifact_name, + file_name="jax_qtable_state.msgpack", + payload=payload, + metadata={ + "step": global_step, + "algo": "qtable", + }, + ) + + denom = float(metric_count) if metric_count > 0 else 1.0 + metrics = { + "train/reward": float(metric_sums["reward"] / denom), + "train/revenue": float(metric_sums["revenue"] / denom), + "train/agent_prob": float(metric_sums["agent_prob"] / denom), + "train/alpha_adv": float(metric_sums["alpha_adv"] / denom), + "train/coi_leakage": float(metric_sums["coi_leakage"] / denom), + "train/global_step": total_steps, + } + + eval_metrics = _evaluate_q_table( + q_table=q_table, + env=env, + episodes=run_cfg["eval_episodes"], + seed=run_cfg["seed"] + 7, + n_products=n_products, + demand_bins=demand_bins, + price_bins=price_bins, + ) + metrics.update(eval_metrics) + + model_dir = Path(run_cfg["model_dir"]) + model_dir.mkdir(parents=True, exist_ok=True) + model_path = model_dir / "phantom_qtable_jax.msgpack" + model_path.write_bytes(serialization.to_bytes(q_table)) + metrics["model/path"] = str(model_path) + return {"q_table": q_table}, metrics + + +def train_jax(cfg: dict[str, Any]) -> tuple[dict[str, Any], dict[str, float]]: + if not HAS_JAX_STACK: + raise ImportError( + "JAX path requires jax, flax, optax, and distrax. " + "Install engine/jax/requirements.txt on this machine first." + ) + + _init_jax_distributed() + _stop_requested.clear() + run_cfg = _jax_cfg(cfg) + algo = run_cfg["algo"] + if threading.current_thread() is threading.main_thread(): + signal.signal(signal.SIGTERM, lambda *_: _stop_requested.set()) + + if algo in {"ppo", "a2c"}: + return _train_actor_critic(run_cfg, algo=algo) + if algo == "dqn": + return _train_dqn(run_cfg) + if algo == "qtable": + return _train_qtable(run_cfg) + raise ValueError(f"Unsupported JAX algo '{algo}'") diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index e8fe2be..6a3a411 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -3,11 +3,16 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).parents[2])) -from sim.rl.behavior_loader.models import ( - BehaviorModel, - AgentBehaviorModel, - aggregate_event_transitions, -) +try: + from sim.rl.behavior_loader.models import ( + BehaviorModel, + AgentBehaviorModel, + aggregate_event_transitions, + ) +except ImportError: + BehaviorModel = None + AgentBehaviorModel = None + aggregate_event_transitions = None import pandas as pd import numpy as np from .demand import generate_demand_for_actor @@ -20,6 +25,12 @@ _cache = {} # lazy cache for models and base pivots def _get_base_pivot(human: bool): + if ( + BehaviorModel is None + or AgentBehaviorModel is None + or aggregate_event_transitions is None + ): + raise ImportError("behavior loader dependencies are unavailable") key = "human" if human else "agent" if key not in _cache: model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir) @@ -34,6 +45,13 @@ def get_transition_models(): returns: tuple: (human_transitions, agent_transitions) as dicts of event->event->prob """ + if ( + BehaviorModel is None + or AgentBehaviorModel is None + or aggregate_event_transitions is None + ): + raise ImportError("behavior loader dependencies are unavailable") + human_model = BehaviorModel(human_dir) agent_model = AgentBehaviorModel(agent_dir) diff --git a/engine/train.py b/engine/train.py index 35ca582..f6b256d 100644 --- a/engine/train.py +++ b/engine/train.py @@ -384,8 +384,6 @@ def train_once(cfg: dict) -> dict: "JAX backend requested but JAX is not installed. " "Install engine/jax/requirements.txt and jax[tpu] for TPU runs." ) - if algo == "qtable": - raise ValueError("qtable is not supported in JAX backend") try: from .jax.train import train_jax except Exception as exc: # pragma: no cover @@ -409,20 +407,25 @@ def run_wandb( init_kwargs = {"mode": mode} if sweep_mode: run = wandb.init(**init_kwargs) - cfg = _cfg(_wandb_cfg_dict()) - for k, v in overrides.items(): - if k not in wandb.config: - cfg[k] = v else: run = wandb.init(project=project, config=overrides, **init_kwargs) + + try: cfg = _cfg(_wandb_cfg_dict()) - metrics = train_once(cfg) - step = int(metrics.get("train/global_step", cfg["total_timesteps"])) - wandb.log(metrics, step=step) - for k, v in metrics.items(): - run.summary[k] = v - wandb.finish() - return metrics + if sweep_mode: + for k, v in overrides.items(): + if k not in wandb.config: + cfg[k] = v + + metrics = train_once(cfg) + step = int(metrics.get("train/global_step", cfg["total_timesteps"])) + wandb.log(metrics, step=step) + for k, v in metrics.items(): + run.summary[k] = v + return metrics + finally: + if wandb.run is not None: + wandb.finish() def run_local(overrides: dict) -> dict: diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 19c5997..f20f59c 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -140,6 +140,7 @@ The architecture of this platform begins with the deployed web-apps posting inte \paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda system for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment. + \subsubsection{DevOps Principles} Reproducible results are key to quality research platforms, this is taken into mind when deploying and working with our research platform. From a deployment standpoint the platform can be deployed across a large variety of providers and can be run locally. When developing a new interaction modality apart from the ones that come out of the box, a simple template pattern can be followed. The middleware of the framework is designed to properly render the chosen modality from environmental variables, thus deployment of different or parallel version of the software can be easily parametrized. @@ -235,7 +236,11 @@ v4 & 64 (32 + 32) & us-central2-b & 32 Spot + 32 On-demand \\ \end{tabular} \end{table} -For interactive monitoring from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}. +For connections from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs with the benefit of having the most grouped chips within a single region. This regional grouping is important for the deployment of our Kubernetes cluster which cannot span multiple regions. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}. + +Design of training processes: we build docker image with the fact in mind of different caching over layers in order to most speed up docker re-building and such we place the most volatile steps towards the end of the image building. What is means in practice is that any dependency installations are isolated so edits to source code do no trigger rebuilds. Only if we update our entry point of training a sweep, Docker will also rebuild the source-code copy stage. + +Due to the preemptive nature of the current demand of TPU chips we sttle for running our on demeaned as the primary source of compute. The on demand TPU pod of 32 chips spread across 4 virtual hosts creates a relatively unique parallelization setup. Despite our desire to use a traditional approach of clustering and perhaps deploying SLURM jobs of our sweep agent, the lack of predictability in provisioning each instance of a compute resource makes this an high friction layer we do not want to add. \subsubsection{Interaction Schema} From a4b7b5b4b2dd8567b03f5ac64e7d47df6329d893 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 19 Feb 2026 18:28:40 +0100 Subject: [PATCH 35/36] improements of the methodology for now almost ready tosubmit --- paper/src/chapters/03-methodology.tex | 33 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index f20f59c..0924c98 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -93,7 +93,7 @@ where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underlin We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes. - A fundamental assumption for our claim lays in the alignment of the AI agent through it's prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective. + A fundamental assumption for our claim lies in the alignment of the AI agent through its prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective. \begin{theorem}[COI Erosion in the Limit] Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0. @@ -161,13 +161,13 @@ p_{0,i} & \text{otherwise} where $p_0 \in \mathbb{R}^N$ is the base price vector (which is seeded into our database distinctly for each mode of the commerce platform), $\theta_{\text{high}}, \theta_{\text{low}} \in \mathbb{R}$ are demand thresholds defining surge and discount regions, and $\lambda_{\text{surge}}, \lambda_{\text{disc}} \in \mathbb{R}^+$ are multiplicative factors with typical values $\lambda_{\text{surge}} = 1.2$ and $\lambda_{\text{disc}} = 0.9$. This piecewise function enables rapid price adjustment in response to observed demand without requiring complex elasticity estimation or historical calibration, allowing us to expose actors within our experiments to a system with a dynamic component of pricing. -For our offline experimental setting, we generalize a master value function that can encompass different demand estimation and pricing strategies. - -\begin{align} -V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]} -\end{align} - -We evaluate different substitutions of this objective, which later serve as hyperparameters in the simulator. +% For our offline experimental setting, we generalize a master value function that can encompass different demand estimation and pricing strategies. +% +% \begin{align} +% V(\cdot) = \max_{p_t} \min_{Q \in \mathcal{U}(\hat{d})}{\mathbb{E}_{d\sim Q} [p_t \times d(p_t, x_t ; \theta) + \psi V_{t+1}(\cdot)]} +% \end{align} +% +% We evaluate different substitutions of this objective, which later serve as hyperparameters in the simulator. \subsection{Experimental Design} @@ -175,7 +175,11 @@ We start from a practical constraint: we do not have access to proprietary produ The interface is organized as a product catalog where each product belongs to a time-bounded price vector (for example, a daily pricing period). During each period we collect interaction data by instrumenting UI components and predefined action templates that are still customizable. This gives us control without losing realism. -Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants. A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor. +Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants. +% TODO: describe the task pool in detail here -- list the specific tasks used in the experiments +A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor. + +The human data collection involved 18 participants, all of whom provided explicit informed consent prior to their session. Participants had an average age of 21 years and were recruited from a university population. Alongside the 18 human sessions we ran 18 agent sessions of equivalent task scope, giving a balanced dataset of 36 labeled trajectories. Each participant was assigned a single platform mode and a single task drawn from the pool, and completed the session independently without guidance on navigation or pricing strategy. To evaluate quality and realism of the setup, we store both structured event logs and full interaction transcripts. This lets us combine quantitative analysis with transcript-level qualitative findings. The result is an isolated system where we can control the interaction process while preserving realistic behavior. @@ -199,7 +203,9 @@ The dynamic pricing mechanism elicited immediate behavioral adjustments. Partici \subsubsection{Design of Training Factorial Study} -The simulator has multiple configurable factors, including valuation distributions, demand parametrization, contamination ratio, and policy settings. We therefore design a multi-factor study (current grid estimate: $4\times4\times3\times2\times2$). While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable. +The simulator has multiple configurable factors. We design a multi-factor study across five axes derived from the sweep configurations: (1) RL algorithm (\texttt{ppo}, \texttt{a2c}, \texttt{dqn}, \texttt{qtable}; 4 levels), (2) contamination ratio $\alpha$ sampled from $[0.1, 0.6]$ at four representative levels, (3) robustness radius $\epsilon_\alpha \in \{0.0, 0.15, 0.3\}$ (3 levels), (4) COI penalty weight $\lambda_\text{coi}$ at two reference levels, and (5) pricing action granularity (two discretization settings for \texttt{action\_levels}); giving a grid of $4\times4\times3\times2\times2 = 192$ configurations. Statistical power for the behavioral comparisons is determined by a two-sample test over per-session KL divergence scores; a formal power analysis with minimum detectable effect size at $n=18+18$ is reported in the results. +% Power analysis plan: apply a two-sample Mann-Whitney U (or permutation test) on per-session (delta_H - delta_A) divergence scores comparing the human and agent groups. Compute minimum detectable effect size at alpha=0.05, power=0.8, given n=18 per group. Bootstrap confidence intervals on mean KL are a cleaner complement given the non-normality of divergence distributions. +While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable. Our training budget is provisioned through TPU Research Cloud and spans 384 chips across TPU v4, v5e, and v6e generations, with a spot-heavy allocation plus an on-demand reserve. At peak BF16 throughput this corresponds to approximately 160 PFLOPS of aggregate compute, which makes repeated seeds, ablations, and sensitivity sweeps feasible within practical wall-clock limits. We allocate v6e capacity to the highest-intensity policy training jobs, use v5e for wider hyperparameter exploration where throughput-per-dollar is favorable, and reserve on-demand v4 capacity for runs that should not be interrupted. @@ -288,10 +294,11 @@ In addition to behavioral events, the platform logs price observations to a sepa To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from Phantom data using a two-stage approach. -\subsubsection{GOFAI-Based Separability} -We use Good Old-Fashioned AI (GOFAI) heuristics to generate weak labels for separability. A set of rule-based predicates $\phi_j: \tau \to \{0,1\}$ partitions dataset $\mathcal{D}$ into high-confidence sets $\mathcal{D}_H$ and $\mathcal{D}_A$. We then estimate separate transition models for both groups and ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability? +\subsubsection{Ground-Truth Separability} +Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $y_s \in \{H, A\}$ are available as ground truth rather than as the output of a heuristic classifier. We therefore estimate separate transition kernels directly from each labeled partition $\mathcal{D}_H$ and $\mathcal{D}_A$, treating the resulting $\hat{\mathcal{T}}_H$ and $\hat{\mathcal{T}}_A$ as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability? To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. +% To contextualize this figure a useful intra-class baseline is to randomly split D_H into two equal halves, estimate a kernel from each half, compute the same average KL statistic, and repeat for B bootstrap samples (e.g. B=100). The resulting null distribution (mean +/- std) gives the divergence expected purely from estimation noise at this sample size. A between-class KL substantially above this null confirms the separation is real and not a finite-sample artefact. In practice: for each of B splits, partition D_H 50/50 without replacement, run build_kernel() on each half, average the per-state KL values, and collect the B scores into a reference distribution to compare against the 1.8 figure. \begin{definition}[Kullback-Leibler Divergence for Transition Distributions] Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is: @@ -317,6 +324,7 @@ For both subsets, we model session dynamics as an MDP and estimate transition ke where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. To scale this to catalog-level pricing, we expand the base event transition matrix from $T\times T$ into product-specific transitions using the current demand condition. In practice, we normalize the demand vector across products and use it to weight how much transition mass each product pair receives. Concretely, each cell of the base matrix becomes an $N\times N$ block (for $N$ products), so the transition matrix grows from $T\times T$ to $(T\cdot N)\times(T\cdot N)$. Finally, we add $C$ generic states (homepage, login, checkout terminal states), which gives the full kernel size $(T\cdot N + C)\times(T\cdot N + C)$. +% The validity of this demand-weighted block expansion is still subject to formal proof: it needs to be shown that the resulting matrix retains row-stochasticity (rows summing to 1) and that the weighting by the demand vector preserves the Markov property for the expanded state space. In the engine source this is the target of ongoing validation before the expansion is relied on for behavioral generation at scale. \begin{figure}[ht] \centering @@ -371,6 +379,7 @@ For the current engine baseline, we use a compact inner-robust approximation by \mathcal{A}_{\epsilon_\alpha}(\alpha_0)=\left\{\alpha\in[0,1]:\lvert\alpha-\alpha_0\rvert\le\epsilon_\alpha\right\} \end{equation} and we evaluate a small fixed grid in $\mathcal{A}_{\epsilon_\alpha}(\alpha_0)$ per step, selecting the worst-case candidate for the learner. +% A proper Wasserstein ball implementation over the full demand distribution (rather than a scalar alpha interval) would use the POT library (Python Optimal Transport): compute W_2 between the empirical reference P_hat and each candidate Q using ot.emd2() or ot.sliced_wasserstein_distance() for scalability, then accept only candidates within epsilon. In practice the inner minimization becomes: candidates = [G(alpha) for alpha in linspace]; dists = [ot.emd2(p_hat, q, M) for q in candidates]; worst = candidates[argmin(reward[dists <= epsilon])]. The current grid-on-alpha approximation is a computationally cheap substitute; moving to a true Wasserstein ball would tighten the worst-case guarantee but requires specifying the ground metric M over the demand space. \subsubsection{The Min-Max Objective} The robust policy $\pi^*$ is obtained by solving the maximin problem: From 4667a1678ff3e1a31de3381c3162937791817f2a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 25 Feb 2026 09:16:00 +0100 Subject: [PATCH 36/36] chore minor paper edits --- paper/concat_code.sh | 6 ++- paper/src/chapters/01-intro.tex | 6 +-- paper/src/chapters/03-methodology.tex | 59 +++++++++++---------------- paper/src/chapters/loop_figure.tex | 4 +- 4 files changed, 33 insertions(+), 42 deletions(-) diff --git a/paper/concat_code.sh b/paper/concat_code.sh index 7de4bb3..a6fa96a 100755 --- a/paper/concat_code.sh +++ b/paper/concat_code.sh @@ -42,6 +42,10 @@ EOF # Process each directory echo "Concatenating code from source directories..." +# Engine +find "$PROJECT_ROOT/engine" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do + add_file "$file" +done # Backend find "$PROJECT_ROOT/backend" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do add_file "$file" @@ -53,7 +57,7 @@ find "$PROJECT_ROOT/experiments" -type d \( -name ".venv" -o -name "__pycache__" done # Docker -find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do +find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "*.Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do add_file "$file" done diff --git a/paper/src/chapters/01-intro.tex b/paper/src/chapters/01-intro.tex index f5f2fe8..bd70de4 100644 --- a/paper/src/chapters/01-intro.tex +++ b/paper/src/chapters/01-intro.tex @@ -10,7 +10,7 @@ In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove separability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners. -This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. +This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 31st 2026.} \subsection{Motivation and Market Context} @@ -39,8 +39,8 @@ This dissertation is organized around one main research question and three suppo \begin{algorithm}[t] \DontPrintSemicolon -\SetKwInOut{Input}{Input} -\SetKwInOut{Output}{Output} +\SetKwInput{Input}{Input} +\SetKwInput{Output}{Output} \Input{Goal $G$, Platform URL $u$, LLM $\mathcal{M}$} \Output{Task completion result $r$} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 0924c98..44bc09f 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -104,7 +104,7 @@ Let $N$ be the number of independent, utility-maximizing agents querying the pla \begin{proof} -Consider $N$ independent agents querying the platform, each receiving a price sample $p_i$ drawn from the pricing policy's distribution $F(p)$ with support $[\underline{p}, \bar{p}]$. A strategic agent conducting reconnaissance will select the minimum observed price: $p_{(1)} = \min(p_1, \ldots, p_N)$. +Consider $N$ independent agents querying the platform, each receiving a price sample $p_i$ drawn from the pricing policy's distribution $F(p)$ bounded by $[\underline{p}, \bar{p}]$. A strategic agent conducting reconnaissance will select the minimum observed price: $p_{(1)} = \min(p_1, \ldots, p_N)$. % support here means that its the range of possible outputs. The probability that the minimum price exceeds some threshold $t$ is: \begin{equation} @@ -138,7 +138,7 @@ In order for our research to have grounding in interactions we built a robust e- The architecture of this platform begins with the deployed web-apps posting interaction data to our backend which processes them and stores each ingested interaction into a kafka cluster. This serves as our data reservoir tracking and associating each interaction with its session and importantly with which experiment it belongs to. Not only do we track the behavioral interactions, but our pricing provider micro-service, once called by the frontend reports the observed/queried price-product into kafka. This kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The final stage of the pricing pipeline, submits computed dynamic pricing results into a redis database for quick updates which is then read by the pricing provider and displayed on the webapp. This is a very generic end-to-end mechanism which is applicable to a variety of different e-commerce tasks. We intentionally put emphasis on the development of this infrastructure to establish a reproducible framework for interaction and to minimize any noise. -\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda system for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment. +\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda architecture for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment. \subsubsection{DevOps Principles} @@ -297,7 +297,7 @@ To train a robust pricing learner, we need a simulator that can generate realist \subsubsection{Ground-Truth Separability} Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $y_s \in \{H, A\}$ are available as ground truth rather than as the output of a heuristic classifier. We therefore estimate separate transition kernels directly from each labeled partition $\mathcal{D}_H$ and $\mathcal{D}_A$, treating the resulting $\hat{\mathcal{T}}_H$ and $\hat{\mathcal{T}}_A$ as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability? -To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. +To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. To contextualize this divergence metric we compare with an intra-class comparison baseline of randomly selected transitions. % To contextualize this figure a useful intra-class baseline is to randomly split D_H into two equal halves, estimate a kernel from each half, compute the same average KL statistic, and repeat for B bootstrap samples (e.g. B=100). The resulting null distribution (mean +/- std) gives the divergence expected purely from estimation noise at this sample size. A between-class KL substantially above this null confirms the separation is real and not a finite-sample artefact. In practice: for each of B splits, partition D_H 50/50 without replacement, run build_kernel() on each half, average the per-state KL values, and collect the B scores into a reference distribution to compare against the 1.8 figure. \begin{definition}[Kullback-Leibler Divergence for Transition Distributions] @@ -321,7 +321,7 @@ For both subsets, we model session dynamics as an MDP and estimate transition ke \begin{equation} \hat{P}(s' \mid s) = \frac{N(s, s')}{\sum_{k \in \mathcal{S}} N(s, k)} \end{equation} -where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. +where $N(s, s')$ is the observed transition count. This allows us to construct a \textit{Contamination Generator} $\mathcal{G}(\alpha)$. Given a clean trajectory dataset, $\mathcal{G}$ injects synthetic agent trajectories sampled from $\hat{\mathcal{T}}_A$ until the effective mixing ratio reaches $\alpha$. The properties of an MDP such as ... should be preserved by the operation described below. To scale this to catalog-level pricing, we expand the base event transition matrix from $T\times T$ into product-specific transitions using the current demand condition. In practice, we normalize the demand vector across products and use it to weight how much transition mass each product pair receives. Concretely, each cell of the base matrix becomes an $N\times N$ block (for $N$ products), so the transition matrix grows from $T\times T$ to $(T\cdot N)\times(T\cdot N)$. Finally, we add $C$ generic states (homepage, login, checkout terminal states), which gives the full kernel size $(T\cdot N + C)\times(T\cdot N + C)$. % The validity of this demand-weighted block expansion is still subject to formal proof: it needs to be shown that the resulting matrix retains row-stochasticity (rows summing to 1) and that the weighting by the demand vector preserves the Markov property for the expanded state space. In the engine source this is the target of ongoing validation before the expansion is relied on for behavioral generation at scale. @@ -329,7 +329,7 @@ To scale this to catalog-level pricing, we expand the base event transition matr \begin{figure}[ht] \centering \includegraphics[width=0.8\textwidth]{chapters/mdp_human.pdf} - \caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for human actions.} + \caption{Markov Decision Process visualization illustrating the behavioral transition dynamics for \textbf{human} actions.} \label{fig:human_mdp_viz} \end{figure} @@ -344,9 +344,6 @@ To scale this to catalog-level pricing, we expand the base event transition matr \subsection{Second-Stage Classification} After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure. -Now might be a good time to stand up and go for a quick walk before returning to the rest of this paper. - - \subsection{Distributionally Robust Reinforcement Learning (DR-RL)} We formulate pricing as a Stackelberg game: the platform (leader) sets prices $p_t$, and the population (follower) responds through trajectories and demand. A useful intuition is that the platform behaves like a distorted mirror at a 45-degree angle: what it mirrors is population demand into an estimated demand proxy, and that proxy drives revenue. @@ -360,7 +357,7 @@ Because contamination level $\alpha$ and demand shift are non-stationary online, \Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A) \end{align} -This yields two centroid-like heuristics that guide contamination estimation at session granularity. +This yields two centroid-like heuristics that act as a session-level agent score in the engine. On a per-customer or use-case basis a similar study should be done in order to obtain ground truth behavior models for humans and agents and their specific interaction with a given products website. In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move). @@ -430,40 +427,30 @@ We now present the complete pricing mechanism that integrates the behavioral sep \caption{PHANTOM defensive pricing loop} \label{alg:phantom_loop_clean} \DontPrintSemicolon -\SetKwInOut{Input}{Input}\SetKwInOut{Output}{Output} - -\Input{catalog size \(N\); costs \(c\); reference prices \(p^{ref}\); behavior models \(\bar T_H,\bar T_A\); -action weights \(\omega\); penalty \(\lambda\); nominal contamination \(\alpha_0\); ambiguity radius \(\epsilon_\alpha\); -candidate count \(K\); horizon \(T\); sessions per step \(M\)} -\Output{price/demand trajectory \(\{(p_t,\hat Q_t,\hat\alpha_t)\}_{t=0}^{T-1}\)} - -Initialize contamination estimate \(\hat\alpha \leftarrow 0.2\)\; +\SetKwInput{Input}{Input} +\SetKwInput{Output}{Output} +\Input{catalog size \(N\); action scale grid \(\mathcal{S}_{act}\); nominal contamination \(\alpha_0\); ambiguity radius \(\epsilon_\alpha\); candidate count \(K\); horizon \(T\); sessions per step \(M\); behavior kernels \(\bar T_H,\bar T_A\); event weights \(\omega\); COI penalty \(\lambda\)} +\Output{trajectory \(\{(p_t,\hat Q_t,\alpha_t^*)\}_{t=0}^{T-1}\)} \For{\(t \leftarrow 0\) \KwTo \(T-1\)}{ + observe \(o_t=[\hat Q_{t-1}, p_{t-1}]\)\; + choose discrete action \(a_t \in \{1,\dots,|\mathcal{S}_{act}|\}\) from policy \(\pi\)\; + set \(p_t \leftarrow \mathrm{clip}(p_{t-1} \cdot \mathcal{S}_{act}[a_t])\)\; - set \(p_t \leftarrow \pi(\cdot) \) %c + (1 - \kappa \hat\alpha)\,(p^{ref}-c)\)\; - and clip \(p_t\) to a feasible range (e.g., near cost up to a max margin)\; - - - \(\hat Q_t \leftarrow 0\), \(\mathcal S_t \leftarrow \emptyset\); \tcp{Observe sessions and compute demand proxy (Eq.~2)} - \For{\(m \leftarrow 1\) \KwTo \(M\)}{ - sample a session trajectory \(\tau_m\) using \(\bar T_H\) or \(\bar T_A\)\; - \(\hat Q_t \leftarrow \hat Q_t + \sum_{k}\omega(a_{m,k})\)\; - \(\mathcal S_t \leftarrow \mathcal S_t \cup \{\tau_m\}\)\; + define local ambiguity interval \(\mathcal{A}_{\epsilon_\alpha}(\alpha_0)=\{\alpha:\lvert\alpha-\alpha_0\rvert\le\epsilon_\alpha\}\)\; + \For{\(k \leftarrow 1\) \KwTo \(K\)}{ + set \(\alpha_k \in \mathcal{A}_{\epsilon_\alpha}(\alpha_0)\) from a uniform grid\; + sample \(M\) sessions from mixture \((1-\alpha_k)\bar T_H + \alpha_k \bar T_A\)\; + compute demand proxy \(\hat Q_t^{(k)} = \sum_{m=1}^{M}\sum_j \omega(a_{m,j})\,\mathbf{1}[i_{m,j}=i]\)\; + compute \((\Delta_H^{(k)},\Delta_A^{(k)})\) and session score \(f_t^{(k)}\) from KL divergence\; + compute candidate reward \(r_t^{(k)} = R(p_t,\hat Q_t^{(k)}) - \lambda\,f_t^{(k)}\,c_{info}\)\; } - - \tcp{Estimate contamination from behavioral separability} - compute \(\hat\alpha \leftarrow \frac{1}{M}\sum_{\tau\in\mathcal S_t} \Big[\sigma\big(\beta(\Delta_H(\tau)-\Delta_A(\tau))\big)\Big]\)\; - - \tcp{Inner robust step over local ambiguity interval} - define \(\mathcal{A}_{\epsilon_\alpha}(\alpha_0)\) and sample \(K\) candidates\; - pick \(\alpha_t^* \leftarrow \arg\min_{\alpha\in\mathcal{A}_{\epsilon_\alpha}(\alpha_0)} \Big[\text{Revenue}(p_t,\hat Q_t^{\alpha}) - \lambda\cdot \text{COI}_{\text{leak}}(p_t,\tau_t^{\alpha})\Big]\)\; - - compute \(J_t \leftarrow \text{Revenue}(p_t,\hat Q_t^{\alpha_t^*}) - \lambda\cdot \text{COI}_{\text{leak}}(p_t,\tau_t^{\alpha_t^*})\)\; + choose \(k^* \leftarrow \arg\min_k r_t^{(k)}\), set \(\alpha_t^* \leftarrow \alpha_{k^*}\)\; + set \(\hat Q_t \leftarrow \hat Q_t^{(k^*)}\), \(r_t \leftarrow r_t^{(k^*)}\)\; } \end{algorithm} -The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform publishes prices (leader move), observes resulting session trajectories (follower response), and updates contamination estimates based on divergence from learned human and agent kernels $\bar{\mathcal{T}}_H$ and $\bar{\mathcal{T}}_A$. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations. +The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform applies one discrete multiplicative price action, the environment samples a batch of sessions, and demand is recomputed from weighted events. Robustness is implemented as an inner minimization over a small local grid of contamination candidates around nominal $\alpha_0$, matching the current engine implementation. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations. %The defensive price update in Line 24 implements contamination-aware margin shrinkage: as estimated contamination $\hat{\alpha}_t$ rises, the margin $(p^{\mathrm{ref}} - c)$ is reduced by factor $\kappa\in[0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring feasibility. In subsequent experiments this heuristic rule is replaced by DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}. diff --git a/paper/src/chapters/loop_figure.tex b/paper/src/chapters/loop_figure.tex index e90e018..b050c5a 100644 --- a/paper/src/chapters/loop_figure.tex +++ b/paper/src/chapters/loop_figure.tex @@ -49,11 +49,11 @@ \node[greenbox, minimum width=3.5cm] (commerce) at (-3.5, 2) {Commerce Experiment}; \node[greenbox, minimum width=1.5cm] (raw) at (-6.5, 0) {Raw\\Logs}; \node[greenbox, minimum width=1.5cm] (features) at (-4, -2.5) {Features}; - \node[greenbox, minimum width=2.5cm] (classification) at (-1, -0.5) {Classification\\Training A/H}; + \node[greenbox, minimum width=2.5cm] (classification) at (-0.8, 0) {Classification\\Training A/H}; % Right Loop (Blue) Nodes \node[bluebox, minimum width=2.5cm] (trainedpricing) at (3.2, 2) {Trained Pricing}; - \node[bluebox, minimum width=2.5cm] (policy) at (6.5, 0) {Trained Pricing\\Policy}; + \node[bluebox, minimum width=1.5cm] (policy) at (6.5, 0) {Trained\\Pricing\\Policy}; \node[bluebox, minimum width=2.5cm] (rlgym) at (3.2, -2.2) {RL Gym\\Training}; % --- Background Dashed Loops ---