From 6d9613c0b6b47fd718e11501de6346825a2be0d5 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 10 Mar 2026 17:04:23 +0100 Subject: [PATCH 01/35] feat: talking about optimality --- paper/src/bib/references.bib | 15 +++++++++++ paper/src/chapters/03-methodology.tex | 38 +++++++++++++++++++++++++++ paper/src/main.tex | 3 +++ 3 files changed, 56 insertions(+) diff --git a/paper/src/bib/references.bib b/paper/src/bib/references.bib index 38e953f..b42c044 100644 --- a/paper/src/bib/references.bib +++ b/paper/src/bib/references.bib @@ -630,3 +630,18 @@ Volume: 21}, note = {Publisher: Institute of Mathematical Statistics}, pages = {50 -- 60}, } + +@article{horace_he_and_thinking_machines_lab_defeating_2025, + title = {Defeating {Nondeterminism} in {LLM} {Inference}}, + url = {https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/}, + doi = {10.64434/tml.20250910}, + abstract = {Reproducibility is a bedrock of scientific progress. However, it’s remarkably difficult to get reproducible results out of large language models. +For example, you might observe that asking ChatGPT the same question multiple times provides different results. This by itself is not surprising, since getting a result from a language model involves “sampling”, a process that converts the language model’s output into a probability distribution and probabilistically selects a token. +What might be more surprising is that even when we adjust the temperature down to 0This means that the LLM always chooses the highest probability token, which is called greedy sampling. (thus making the sampling theoretically deterministic), LLM APIs are still not deterministic in practice (see past discussions here, here, or here). Even when running inference on your own hardware with an OSS inference library like vLLM or SGLang, sampling still isn’t deterministic (see here or here).}, + language = {en}, + urldate = {2026-03-10}, + journal = {Thinking Machines Lab: Connectionism}, + author = {{Horace He and Thinking Machines Lab}}, + year = {2025}, + file = {Snapshot:/home/velocitatem/Zotero/storage/U5JG4CNM/defeating-nondeterminism-in-llm-inference.html:text/html}, +} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 6c63f95..bebff01 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -40,6 +40,7 @@ We formalize the heterogeneity of actors by introducing a type space $\Theta$. A Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t \end{equation} where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise. +Accounting for behavioral and market variation, we also treat $\epsilon_t$ as absorbing serving-path variability from LLM infrastructure (e.g., batch-size-dependent inference behavior under changing load), which appears stochastic at the request level even under greedy decoding \parencite{horace_he_and_thinking_machines_lab_defeating_2025}. @@ -494,3 +495,40 @@ We now present the complete pricing mechanism that integrates the behavioral sep 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}. + +\subsubsection{Computational Cost Analysis of the Simulation Step} + +The per-step cost of Algorithm~\ref{alg:phantom_loop_clean} is not uniform across its components. To inform hardware provisioning and to identify where algorithmic improvements are most impactful, we profile the hot path of the engine using Python's \texttt{cProfile} instrumentation over 20 environment steps under two configurations: a baseline with the robustness inner loop disabled ($K=1$, $\epsilon_\alpha=0$) and a standard robust setting ($K=5$, $\epsilon_\alpha=0.2$). Both runs use $M=10$ sessions per market call and $N=3$ products. + +The baseline achieves approximately 26 steps per second. Enabling the robustness inner loop with $K=5$ candidates drops throughput to 7.2 steps per second, a $3.6\times$ slowdown that is directly proportional to $K$, consistent with the $O(K)$ scaling of the adversarial alpha selection in the implementation. + +\begin{table}[ht] +\centering +\caption{Per-step profiling results (20 steps, $M=10$ sessions, $N=3$ products). Self-time measures time spent inside the function excluding callees; cumulative time includes the full call subtree.} +\label{tab:profile_results} +\begin{tabular}{@{}lrrrrl@{}} +\toprule +\textbf{Function} & \textbf{Calls} & \textbf{Self (ms)} & \textbf{Cum. (ms)} & \textbf{Cum. \%} & \textbf{Module} \\ +\midrule +\multicolumn{6}{l}{\textit{Baseline ($K=1$, 0.77\,s total, 26 steps/s)}} \\ +\texttt{sample\_behavior\_from\_transitions} & 420 & 131 & 658 & 86\% & \texttt{lib/behavior} \\ +\texttt{DataFrame.xs} & 4,820 & 30 & 201 & 26\% & pandas \\ +\texttt{numpy.nan\_to\_num} & 4,904 & 43 & 97 & 13\% & numpy \\ +\texttt{adjust\_behavior\_to\_condition} & 84 & 3 & 54 & 7\% & \texttt{lib/behavior} \\ +\midrule +\multicolumn{6}{l}{\textit{Robust ($K=5$, 2.79\,s total, 7.2 steps/s)}} \\ +\texttt{sample\_behavior\_from\_transitions} & 1,220 & 519 & 2,447 & 88\% & \texttt{lib/behavior} \\ +\texttt{DataFrame.xs} & 16,668 & 108 & 729 & 26\% & pandas \\ +\texttt{numpy.nan\_to\_num} & 16,912 & 164 & 363 & 13\% & numpy \\ +\texttt{adjust\_behavior\_to\_condition} & 244 & 11 & 108 & 4\% & \texttt{lib/behavior} \\ +\bottomrule +\end{tabular} +\end{table} + +Across both configurations, \texttt{sample\_behavior\_from\_transitions} accounts for 86--88\% of total wall time. The function implements the Markov chain sampler described in Section~\ref{sec:tpe}: at each transition it retrieves the current-state row from the expanded transition \texttt{DataFrame} via label-based indexing, which internally dispatches through the pandas \texttt{xs} and \texttt{fast\_xs} code paths. For $M$ sessions each running up to $L_{\max}=40$ transitions, a single \texttt{market.act()} call issues up to $M \cdot L_{\max}$ individual row lookups. With $K=5$ robustness candidates per outer step this accumulates to $5 \times 10 \times 40 = 2{,}000$ row accesses per outer step, producing the 16k \texttt{xs} invocations observed in Table~\ref{tab:profile_results}. + +The \texttt{numpy.nan\_to\_num} calls, accounting for 13\% of self-time, occur once per row lookup to sanitize sampled probability vectors before normalization; their call count therefore tracks the \texttt{xs} count exactly. + +\texttt{adjust\_behavior\_to\_condition} expands the base $E \times E$ event transition matrix to a $(E \cdot N) \times (E \cdot N)$ product-specific matrix via a Kronecker product. At $N=3$ this is inexpensive, but the cost scales as $O(E^2 N^2)$, so at the $N=10$ default it becomes a more significant contributor. The result is not cached across the $K$ robustness candidates inside a single outer step, meaning the Kronecker expansion is recomputed $2K$ times per step (once for the human kernel and once for the agent kernel at each candidate $\alpha_k$). + +The dominant bottleneck therefore has a clear structural cause: the expanded transition matrix is a string-keyed \texttt{DataFrame}, and pandas object-level indexing carries substantial per-call overhead relative to the arithmetic being performed. Converting the expanded matrix to a \texttt{numpy} array with an accompanying integer state-to-index map, computed once per \texttt{market.act()} call and cached for the duration of the robustness inner loop, eliminates the entire pandas dispatch chain. We leverage this bottleneck identified as an opportunity to squeeze the gap which is left by the computational needs of the pricing learner. We make use of JAX to parallelize on the TPU, and surprisingly we open up a large speedup even on CPU-only compute, improving throughput from 26 to 220 steps/s in the baseline configuration and from 7.2 to 136 steps/s under the full robust inner loop, an 8.5$\times$ and 19$\times$ speedup respectively. diff --git a/paper/src/main.tex b/paper/src/main.tex index 7a17506..65e3186 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -29,6 +29,9 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \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 v4, v5e, and v6e). +\vspace{0.5em} +\noindent\textbf{Project page:} \url{https://velocitatem.github.io/PHANTOM/} + \clearpage \input{chapters/01-intro} \input{chapters/02-literature-review} From 974498dab2c2c8a728e8d22c7731bd954a9f0602 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 10 Mar 2026 17:05:16 +0100 Subject: [PATCH 02/35] chor: implementing prallelization across jax --- engine/engine.py | 17 ++-- engine/jax/__init__.py | 3 + engine/jax/robust.py | 176 +++++++++++++++++++++++++++++++++++++++++ engine/lib/behavior.py | 114 ++++++++++++++++++-------- engine/wrapper.py | 34 +++++++- 5 files changed, 303 insertions(+), 41 deletions(-) create mode 100644 engine/jax/__init__.py create mode 100644 engine/jax/robust.py diff --git a/engine/engine.py b/engine/engine.py index b4a2cbc..81a4da7 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -1,4 +1,5 @@ from sys import platform +from concurrent.futures import ThreadPoolExecutor import numpy as np from .lib.demand import generate_demand_for_actor, estimate_demand from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions @@ -7,6 +8,9 @@ from logging import INFO, getLogger logger = getLogger(__name__) logger.setLevel(INFO) +# shared pool; reused across act() calls to avoid per-call thread-spawn overhead +_pool = ThreadPoolExecutor(max_workers=4) + class MarketEngine: """implements separate demand distributions for humans and agents per Section 3.1.1""" @@ -48,15 +52,18 @@ class MarketEngine: ) human_transitions = get_adjusted_transitions(demand_h, human=True) agent_transitions = get_adjusted_transitions(demand_a, human=False) - # sample behavior trajectories from each demand distribution - human_t = [ - sample_behavior_from_transitions(human_transitions) + # sample N trajectories in parallel; each chain is independent so threads + # do not share state and numpy's per-call RNG is thread-safe + h_futs = [ + _pool.submit(sample_behavior_from_transitions, human_transitions) for _ in range(self.Nhumans) ] - agent_t = [ - sample_behavior_from_transitions(agent_transitions) + a_futs = [ + _pool.submit(sample_behavior_from_transitions, agent_transitions) for _ in range(self.Nagents) ] + human_t = [f.result() for f in h_futs] + agent_t = [f.result() for f in a_futs] # store trajectories for agent probability calculation self.last_trajectories = human_t + agent_t return estimate_demand(self.last_trajectories, self.action_weights) diff --git a/engine/jax/__init__.py b/engine/jax/__init__.py new file mode 100644 index 0000000..84e3375 --- /dev/null +++ b/engine/jax/__init__.py @@ -0,0 +1,3 @@ +from .robust import select_adversarial_alpha_jax, _JAX_OK + +__all__ = ["select_adversarial_alpha_jax", "_JAX_OK"] diff --git a/engine/jax/robust.py b/engine/jax/robust.py new file mode 100644 index 0000000..e873872 --- /dev/null +++ b/engine/jax/robust.py @@ -0,0 +1,176 @@ +"""JAX-accelerated robust inner loop for PHANTOM. + +provides a drop-in replacement for the sequential alpha-candidate evaluation in +wrapper.py::_select_adversarial_alpha. the demand generation and reward +computation are vmapped over the K candidate alpha values so all candidates are +evaluated in a single vectorized pass instead of K sequential Python calls. + +public surface: + select_adversarial_alpha_jax(candidates, prices, human_params, agent_params, + noise_std, n_sessions, n_products, + baseline_prices, lambda_coi, info_value, + reward_profit_weight, rng_key) + -> (best_alpha: float, rewards: np.ndarray) + +falls back gracefully when JAX is unavailable. +""" + +from __future__ import annotations + +import numpy as np + +try: + import jax + import jax.numpy as jnp + from jax import vmap, jit + + _JAX_OK = True +except ImportError: + _JAX_OK = False + + +def _demand_for_actor_jax(prices, mean, std, noise_std, key): + """d(p;theta) = max(0, val - price + noise), normalized to sum 100.""" + k1, k2 = jax.random.split(key) + val = jax.random.normal(k1, shape=prices.shape) * std + mean + noise = jax.random.normal(k2, shape=prices.shape) * noise_std + demand = jnp.maximum(0.0, val - prices + noise) + total = demand.sum() + return jnp.where(total > 0, demand / total * 100.0, demand) + + +def _reward_for_candidate( + alpha, + prices, + human_mean, + human_std, + agent_mean, + agent_std, + noise_std, + baseline_prices, + lambda_coi, + info_value, + reward_profit_weight, + key, +): + """compute a scalar reward for a single alpha candidate (pure JAX, vmappable).""" + k_h, k_a = jax.random.split(key) + # mixed demand proxy: weighted sum of human and agent demand signals + demand_h = _demand_for_actor_jax(prices, human_mean, human_std, noise_std, k_h) + demand_a = _demand_for_actor_jax(prices, agent_mean, agent_std, noise_std, k_a) + demand = (1.0 - alpha) * demand_h + alpha * demand_a + + revenue = jnp.dot(prices, demand) + floor_cost = jnp.dot(baseline_prices, demand) + profit = revenue - floor_cost + + # agent_prob proxy: use alpha directly (no trajectory available in vectorized path) + coi_leakage = alpha * info_value + info_budget = jnp.maximum(floor_cost, 1.0) + coi_penalty = lambda_coi * coi_leakage * info_budget + + return reward_profit_weight * profit - coi_penalty + + +if _JAX_OK: + # compile once; retracing only happens on shape/dtype changes + # 12 args: alpha, prices, h_mean, h_std, a_mean, a_std, noise_std, + # baseline_prices, lambda_coi, info_value, reward_profit_weight, key + _reward_batched = jit( + vmap( + _reward_for_candidate, + in_axes=(0, None, None, None, None, None, None, None, None, None, None, 0), + ) + ) + + +def select_adversarial_alpha_jax( + candidates: np.ndarray, + prices: np.ndarray, + human_params: tuple, + agent_params: tuple, + noise_std: float, + baseline_prices: np.ndarray, + lambda_coi: float, + info_value: float, + reward_profit_weight: float, + rng_seed: int = 0, +) -> tuple[float, np.ndarray]: + """evaluate all alpha candidates in a single vmapped pass. + + returns (best_alpha, rewards_array) where best_alpha minimizes reward + (worst case for the platform, driving robust policy training). + + falls back to a pure-numpy sequential loop when JAX is unavailable so the + wrapper can call this function unconditionally. + """ + if not _JAX_OK: + return _fallback( + candidates, + prices, + human_params, + agent_params, + noise_std, + baseline_prices, + lambda_coi, + info_value, + reward_profit_weight, + ) + + k = len(candidates) + key = jax.random.PRNGKey(rng_seed) + keys = jax.random.split(key, k) + + rewards = np.asarray( + _reward_batched( + jnp.asarray(candidates, dtype=jnp.float32), + jnp.asarray(prices, dtype=jnp.float32), + float(human_params[0]), + float(human_params[1]), + float(agent_params[0]), + float(agent_params[1]), + float(noise_std), + jnp.asarray(baseline_prices, dtype=jnp.float32), + float(lambda_coi), + float(info_value), + float(reward_profit_weight), + keys, + ) + ) + best_idx = int(np.argmin(rewards)) + return float(candidates[best_idx]), rewards + + +def _fallback( + candidates, + prices, + human_params, + agent_params, + noise_std, + baseline_prices, + lambda_coi, + info_value, + reward_profit_weight, +): + """numpy fallback matching the reward formula above.""" + rewards = [] + for alpha in candidates: + rng = np.random.default_rng() + val_h = rng.normal(*human_params, size=len(prices)) + val_a = rng.normal(*agent_params, size=len(prices)) + noise_h = rng.normal(0, noise_std, len(prices)) + noise_a = rng.normal(0, noise_std, len(prices)) + d_h = np.maximum(0, val_h - prices + noise_h) + d_a = np.maximum(0, val_a - prices + noise_a) + s_h, s_a = d_h.sum(), d_a.sum() + d_h = d_h / s_h * 100 if s_h > 0 else d_h + d_a = d_a / s_a * 100 if s_a > 0 else d_a + demand = (1.0 - alpha) * d_h + alpha * d_a + revenue = float(np.dot(prices, demand)) + floor_cost = float(np.dot(baseline_prices, demand)) + profit = revenue - floor_cost + coi_penalty = lambda_coi * alpha * info_value * max(floor_cost, 1.0) + rewards.append(reward_profit_weight * profit - coi_penalty) + rewards = np.array(rewards) + best_idx = int(np.argmin(rewards)) + return float(candidates[best_idx]), rewards diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index 588ebc9..5c96c27 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -22,6 +22,9 @@ human_dir = str(base_dir / "collected_data") agent_dir = str(base_dir / "agents" / "collected_data") _cache = {} # lazy cache for models and base pivots +# cache keyed by (human: bool, condition_tuple) so we skip Kronecker re-expansion +# for repeated calls with the same demand condition inside the robustness inner loop +_transition_cache: dict = {} def _get_base_pivot(human: bool): @@ -68,22 +71,41 @@ 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 + return [s.rsplit("_product", 1)[0] if "_product" in s else s for s in trajectory] + + +class _TransitionTable: + """numpy-backed transition table; replaces per-step pandas .loc[] indexing. + + the profiling hotspot was DataFrame.xs called ~4-16k times per outer step. + converting once to a dense float32 array with an int-keyed state index map + reduces each row lookup to a single array slice with no pandas overhead. + rows are pre-normalized so sampling requires no per-step division. + """ + + __slots__ = ("matrix", "states", "state_index", "n_states") + + def __init__(self, df: pd.DataFrame): + self.states: list[str] = df.index.tolist() + self.state_index: dict[str, int] = {s: i for i, s in enumerate(self.states)} + # float64 throughout: float32 row-sums can drift enough to break np.random.choice + mat = np.nan_to_num( + df.values.astype(np.float64), nan=0.0, posinf=0.0, neginf=0.0 + ) + mat = np.clip(mat, 0.0, None) + row_sums = mat.sum(axis=1) + # dead rows (all zero) get uniform distribution so sampling never receives NaN + dead = row_sums <= 0 + mat[dead] = 1.0 + row_sums[dead] = float(mat.shape[1]) + mat = mat / row_sums[:, np.newaxis] + # final nan guard in case fp still drifts + np.nan_to_num(mat, nan=0.0, copy=False) + row_sums2 = mat.sum(axis=1, keepdims=True) + row_sums2[row_sums2 <= 0] = 1.0 + self.matrix: np.ndarray = mat / row_sums2 + self.n_states: int = len(self.states) def adjust_behavior_to_condition(condition, transition_matrix): @@ -92,46 +114,68 @@ def adjust_behavior_to_condition(condition, transition_matrix): 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 + cond_norm = ( + condition / s + if np.isfinite(s) and s > 0 + else np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float) + ) n_products = len(condition) base_vals = transition_matrix.values 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)) new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)] 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 get_adjusted_transitions(condition, human=True): +def get_adjusted_transitions(condition, human=True) -> _TransitionTable: + """return a _TransitionTable for the given demand condition. + + results are cached by (human, rounded-condition) so that repeated calls with + the same condition inside the robustness inner loop (K candidates, same prices) + skip the Kronecker expansion entirely. + """ + condition = np.asarray(condition, dtype=float) + # round to 4 significant digits for cache key stability + cache_key = (human, tuple(np.round(condition, 4).tolist())) + if cache_key in _transition_cache: + return _transition_cache[cache_key] base_pivot = _get_base_pivot(human) - return adjust_behavior_to_condition(condition, base_pivot) + df = adjust_behavior_to_condition(condition, base_pivot) + table = _TransitionTable(df) + _transition_cache[cache_key] = table + return table -def sample_behavior_from_transitions(adjusted_transitions, max_len=40): - trajectory = [np.random.choice(adjusted_transitions.index)] +def clear_transition_cache(): + """drop cached transition tables; call between episodes if condition space is large.""" + _transition_cache.clear() + + +def sample_behavior_from_transitions(table, max_len=40): + """sample a Markov trajectory. + + accepts _TransitionTable (fast path) or a legacy pandas DataFrame so existing + call sites that pass a DataFrame directly continue to work unchanged. + """ + if isinstance(table, pd.DataFrame): + table = _TransitionTable(table) + + idx = np.random.randint(table.n_states) + trajectory = [table.states[idx]] while len(trajectory) < max_len and "checkout" not in trajectory[-1]: - 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 / s) if s > 0 else None - ) - trajectory.append(sample) + row = table.matrix[table.state_index[trajectory[-1]]] + idx = int(np.random.choice(table.n_states, p=row)) + trajectory.append(table.states[idx]) return trajectory def sample_behavior(condition, human=True, max_len=40): - adjusted_transitions = get_adjusted_transitions(condition, human=human) - return sample_behavior_from_transitions(adjusted_transitions, max_len=max_len) + table = get_adjusted_transitions(condition, human=human) + return sample_behavior_from_transitions(table, max_len=max_len) if __name__ == "__main__": diff --git a/engine/wrapper.py b/engine/wrapper.py index d2ac2cd..2786780 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -10,6 +10,7 @@ from .lib.coi import ( ) from .lib.behavior import get_transition_models, trajectory_to_events from .lib.wrappers import EconomicMetricsWrapper +from .jax.robust import select_adversarial_alpha_jax, _JAX_OK class _ActionPricingEngine(PricingEngine): @@ -121,6 +122,7 @@ class PHANTOM(gym.Env): self._prices = None self._demand = None self._step_count = 0 + self._global_step = 0 # monotonic; used as JAX RNG seed across resets self._demand_history = [] self._price_history = [] self._revenue_history = [] @@ -261,8 +263,37 @@ class PHANTOM(gym.Env): return float(np.mean(rewards)) if rewards else 0.0 def _select_adversarial_alpha(self, prices: np.ndarray) -> float: - """inner robust step: evaluate candidates and pick worst-case alpha""" + """inner robust step: pick worst-case alpha from the ambiguity interval. + + when JAX is available and robust_rollouts==1 we use a vmapped pass over + all K candidates in a single call (no Python loop, no market.act overhead). + the JAX path approximates demand as the mixed closed-form d(p;theta) signal + rather than running full trajectory sampling, which is accurate for the + alpha-selection decision while being dramatically cheaper. + + when robust_rollouts>1 or JAX is unavailable we fall back to the sequential + market.act() loop so behavior is identical to the original implementation. + """ candidates = self._alpha_candidates() + if len(candidates) == 1: + return float(candidates[0]) + + if _JAX_OK and self.robust_rollouts == 1: + best_alpha, _ = select_adversarial_alpha_jax( + candidates=candidates, + prices=prices, + human_params=self.market.human_params, + agent_params=self.market.agent_params, + noise_std=self.market.noise_std, + baseline_prices=self.baseline_prices, + lambda_coi=self.lambda_coi, + info_value=self.info_value, + reward_profit_weight=self.reward_profit_weight, + rng_seed=self._global_step, + ) + return best_alpha + + # fallback: full trajectory-based sequential evaluation evaluations = [ (float(alpha), self._evaluate_candidate(float(alpha), prices)) for alpha in candidates @@ -299,6 +330,7 @@ class PHANTOM(gym.Env): def step(self, action): self._prices = self._decode_action(action) alpha_adv = self._select_adversarial_alpha(self._prices) + self._global_step += 1 # always increment; JAX path may have already done so self._set_market_mix(alpha_adv) self._platform_stub.set_prices(self._prices) self._step_count += 1 From 0f708aab152dd21647b82c1fb3a45c0a34404666 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 11 Mar 2026 11:48:51 +0100 Subject: [PATCH 03/35] feat: simple margin proving study --- Makefile | 13 +++ engine/studies/margin_erosion_alpha.py | 130 +++++++++++++++++++++++++ engine/studies/plot_margin_erosion.py | 126 ++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 engine/studies/margin_erosion_alpha.py create mode 100644 engine/studies/plot_margin_erosion.py diff --git a/Makefile b/Makefile index 94e7e2a..8a7d203 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || help: @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines" @echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish" + @echo "study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" @echo "" @echo "Build general public version:" @echo " make pdf.genpop" @@ -137,6 +138,18 @@ train.bootstrap: stats.lines: @$(NX) run research:stats +.PHONY: study.margin-erosion +study.margin-erosion: + python -m engine.studies.margin_erosion_alpha + +.PHONY: study.margin-erosion.quick +study.margin-erosion.quick: + python -m engine.studies.margin_erosion_alpha --quick + +.PHONY: study.margin-erosion.plot +study.margin-erosion.plot: + python -m engine.studies.plot_margin_erosion engine/studies/results/margin_erosion_alpha_*.json + .PHONY: wordcount wordcount: @$(NX) run paper:wordcount diff --git a/engine/studies/margin_erosion_alpha.py b/engine/studies/margin_erosion_alpha.py new file mode 100644 index 0000000..ef2dc79 --- /dev/null +++ b/engine/studies/margin_erosion_alpha.py @@ -0,0 +1,130 @@ +"""validate core thesis problem: margin erosion under agent contamination +trains standard RL (no robust components) across α levels to demonstrate systematic failure +""" + +from __future__ import annotations +import json, sys, time +from pathlib import Path +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from engine.spec import TrainSpec +from engine.orchestrators import run_train_once + + +def _run_baseline(alpha: float, algo: str, seed: int, steps: int) -> dict: + spec = TrainSpec.from_flat( + { + "algo": algo, + "seed": seed, + "alpha": alpha, + "total_timesteps": steps, + "lambda_coi": 0.0, + "robust_radius": 0.0, + "robust_points": 1, + "robust_rollouts": 1, + "no_robust": True, + "arch": "small", + "n_products": 10, + "N": 100, + "max_steps": 50, + "eval_freq": 5000, + "eval_episodes": 10, + "log_freq": 500, + } + ) + result = run_train_once( + spec, + project="phantom-margin-erosion", + offline=True, + no_wandb=True, + kind="study", + scenario=f"alpha{int(alpha * 100):02d}", + group=f"baseline_{algo}", + extra_tags=("margin_erosion", "baseline"), + ) + return { + "alpha": alpha, + "algo": algo, + "seed": seed, + "eval_reward": result.get("eval/reward_mean", np.nan), + "eval_revenue": result.get("eval/revenue_mean", np.nan), + "eval_coi_level": result.get("eval/coi_level_mean", np.nan), + "eval_margin": result.get("eval/margin_mean", np.nan), + "eval_agent_prob": result.get("eval/agent_prob_mean", np.nan), + } + + +def run_margin_erosion_study( + alphas: list[float] | None = None, + algos: list[str] | None = None, + seeds: int = 3, + steps: int = 30_000, +) -> dict: + alphas = alphas or [0.1, 0.3, 0.5, 0.7, 0.9] + algos = algos or ["ppo", "dqn", "qtable"] + output_dir = Path(__file__).parent / "results" + output_dir.mkdir(exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + + results = [] + for α in alphas: + for algo in algos: + for si in range(seeds): + seed = 42 + si + print(f"α={α:.1f} {algo} seed={seed}") + m = _run_baseline(α, algo, seed, steps) + results.append(m) + print( + f" margin={m['eval_margin']:.3f} rev={m['eval_revenue']:.0f} coi={m['eval_coi_level']:.1f}" + ) + + summary = {} + for α in alphas: + runs = [r for r in results if abs(r["alpha"] - α) < 0.01] + if not runs: + continue + s = {} + for metric in ["margin", "revenue", "coi_level", "agent_prob"]: + vals = [r[f"eval_{metric}"] for r in runs] + s[f"{metric}_mean"] = float(np.mean(vals)) + s[f"{metric}_std"] = float(np.std(vals)) + s["n_runs"] = len(runs) + summary[f"alpha_{α:.1f}"] = s + + output = { + "timestamp": ts, + "config": {"alphas": alphas, "algos": algos, "seeds": seeds, "steps": steps}, + "results": results, + "summary": summary, + } + + path = output_dir / f"margin_erosion_alpha_{ts}.json" + with open(path, "w") as f: + json.dump(output, f, indent=2) + + print(f"\n→ {path}") + for α in alphas: + k = f"alpha_{α:.1f}" + if k in summary: + s = summary[k] + print( + f" {k}: margin={s['margin_mean']:.3f}±{s['margin_std']:.3f} " + f"coi={s['coi_level_mean']:.1f}±{s['coi_level_std']:.1f}" + ) + return output + + +if __name__ == "__main__": + import argparse + + p = argparse.ArgumentParser(description="margin erosion vs α") + p.add_argument("--quick", action="store_true", help="fast test") + args = p.parse_args() + + run_margin_erosion_study( + alphas=[0.1, 0.7] if args.quick else [0.1, 0.3, 0.5, 0.7, 0.9], + algos=["qtable"] if args.quick else ["ppo", "dqn", "qtable"], + seeds=1 if args.quick else 3, + steps=5_000 if args.quick else 30_000, + ) diff --git a/engine/studies/plot_margin_erosion.py b/engine/studies/plot_margin_erosion.py new file mode 100644 index 0000000..021a8b5 --- /dev/null +++ b/engine/studies/plot_margin_erosion.py @@ -0,0 +1,126 @@ +"""plot margin erosion: margin/COI/revenue vs α with thesis-quality formatting""" + +import json, sys +from pathlib import Path +import numpy as np +import matplotlib.pyplot as plt +import matplotlib as mpl + +mpl.rcParams.update( + { + "font.size": 10, + "axes.labelsize": 11, + "axes.titlesize": 12, + "xtick.labelsize": 9, + "ytick.labelsize": 9, + "legend.fontsize": 9, + "figure.figsize": (7, 4), + "figure.dpi": 150, + "lines.linewidth": 1.5, + "lines.markersize": 6, + "errorbar.capsize": 3, + "grid.alpha": 0.3, + } +) + + +def plot_margin_erosion(data: dict, out: Path): + s = data["summary"] + αs = sorted([float(k.split("_")[1]) for k in s.keys()]) + + def get(metric): + return ( + [s[f"alpha_{α:.1f}"][f"{metric}_mean"] for α in αs], + [s[f"alpha_{α:.1f}"][f"{metric}_std"] for α in αs], + ) + + margins, margin_e = get("margin") + cois, coi_e = get("coi_level") + revs, rev_e = get("revenue") + + fig, axes = plt.subplots(1, 3, figsize=(12, 3.5)) + + axes[0].errorbar( + αs, + margins, + yerr=margin_e, + marker="o", + capsize=4, + label="Standard RL", + color="#d62728", + ) + axes[0].axhline(0.05, color="gray", linestyle="--", linewidth=1, label="Floor") + axes[0].set( + xlabel="Agent proportion (α)", + ylabel="Margin", + title="Margin erosion", + ylim=(0, max(margins) * 1.2), + ) + axes[0].grid(alpha=0.3) + axes[0].legend(loc="upper right") + + axes[1].errorbar(αs, cois, yerr=coi_e, marker="s", capsize=4, color="#ff7f0e") + axes[1].set( + xlabel="Agent proportion (α)", + ylabel="COI", + title="COI collapse (E[P] - p_min)", + ylim=(0, None), + ) + axes[1].grid(alpha=0.3) + + axes[2].errorbar(αs, revs, yerr=rev_e, marker="^", capsize=4, color="#2ca02c") + axes[2].set( + xlabel="Agent proportion (α)", + ylabel="Revenue", + title="Revenue degradation", + ylim=(0, None), + ) + axes[2].grid(alpha=0.3) + + plt.tight_layout() + pdf = out / "margin_erosion_alpha.pdf" + png = out / "margin_erosion_alpha.png" + plt.savefig(pdf, bbox_inches="tight", dpi=300) + plt.savefig(png, bbox_inches="tight", dpi=150) + print(f"→ {pdf}\n→ {png}") + + +def print_latex(data: dict): + s = data["summary"] + αs = sorted([float(k.split("_")[1]) for k in s.keys()]) + + print("\n% LaTeX table for appendix") + print("\\begin{table}[h]\n\\centering") + print("\\caption{Margin erosion: standard RL under agent contamination}") + print("\\label{tab:margin_erosion}") + print("\\begin{tabular}{cccc}\n\\toprule") + print("α & Margin & COI & Revenue \\\\\n\\midrule") + + for α in αs: + d = s[f"alpha_{α:.1f}"] + print( + f"{α:.1f} & ${d['margin_mean']:.3f} \\pm {d['margin_std']:.3f}$ & " + f"${d['coi_level_mean']:.1f} \\pm {d['coi_level_std']:.1f}$ & " + f"${d['revenue_mean']:.0f} \\pm {d['revenue_std']:.0f}$ \\\\" + ) + + print("\\bottomrule\n\\end{tabular}\n\\end{table}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("usage: python -m engine.studies.plot_margin_erosion ") + + path = Path(sys.argv[1]) + if not path.exists(): + sys.exit(f"error: {path} not found") + + with open(path) as f: + data = json.load(f) + + plot_margin_erosion(data, path.parent) + print_latex(data) + print( + f"\n{len(data['results'])} runs, {len(data['summary'])} α levels, " + f"algos={data['config']['algos']}, seeds={data['config']['seeds']}" + ) From fa2dde83070283d56bdfec22ddc4d60b0aec69e7 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 11 Mar 2026 12:46:22 +0100 Subject: [PATCH 04/35] responsive and representative demand for COI erosion --- Makefile | 4 - engine/backends/common.py | 4 + engine/lib/demand.py | 46 +++++++-- engine/lib/wrappers.py | 22 +++-- engine/spec.py | 6 ++ engine/studies/margin_erosion_alpha.py | 3 + engine/studies/plot_margin_erosion.py | 126 ------------------------- 7 files changed, 66 insertions(+), 145 deletions(-) delete mode 100644 engine/studies/plot_margin_erosion.py diff --git a/Makefile b/Makefile index 8a7d203..f0072a7 100644 --- a/Makefile +++ b/Makefile @@ -146,10 +146,6 @@ study.margin-erosion: study.margin-erosion.quick: python -m engine.studies.margin_erosion_alpha --quick -.PHONY: study.margin-erosion.plot -study.margin-erosion.plot: - python -m engine.studies.plot_margin_erosion engine/studies/results/margin_erosion_alpha_*.json - .PHONY: wordcount wordcount: @$(NX) run paper:wordcount diff --git a/engine/backends/common.py b/engine/backends/common.py index 9e50d48..ca508f7 100644 --- a/engine/backends/common.py +++ b/engine/backends/common.py @@ -15,6 +15,10 @@ def make_env(cfg: Mapping[str, Any]): n_products=int(cfg["n_products"]), alpha=float(cfg["alpha"]), N=int(cfg["N"]), + agent_params=( + float(cfg.get("agent_mu", 45.0)), + float(cfg.get("agent_std", 15.0)), + ), price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])), lambda_coi=float(cfg["lambda_coi"]), robust_radius=float(cfg["robust_radius"]), diff --git a/engine/lib/demand.py b/engine/lib/demand.py index cb37c3d..ba3ddfd 100644 --- a/engine/lib/demand.py +++ b/engine/lib/demand.py @@ -17,18 +17,32 @@ def generate_demand_for_actor( params: tuple, noise_std: float = 1.0, distribution_method=np.random.normal, + normalize: bool = False, ) -> 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) + if not normalize: + return demand total = np.sum(demand) return demand / total * 100 if total > 0 else demand -def estimate_demand(trajectories, action_weights=None): - return estimate_weighted_demand(trajectories, action_weights) +def estimate_demand( + trajectories, + action_weights=None, + *, + normalize: bool = False, + per_session: bool = True, +): + return estimate_weighted_demand( + trajectories, + action_weights, + normalize=normalize, + per_session=per_session, + ) def _parse_event_state(state: str): @@ -50,7 +64,13 @@ def _weight_for_action(action: str, action_weights: dict) -> float: return CATEGORY_WEIGHTS["nav"] -def estimate_weighted_demand(trajectories, action_weights=None): +def estimate_weighted_demand( + trajectories, + action_weights=None, + *, + normalize: bool = False, + per_session: bool = True, +): action_weights = ( DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights ) @@ -64,12 +84,20 @@ def estimate_weighted_demand(trajectories, action_weights=None): 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 {} - ) + if not scores: + return {} + + if per_session and len(trajectories) > 0: + inv_n = 1.0 / float(len(trajectories)) + scores = {pid: score * inv_n for pid, score in scores.items()} + + if not normalize: + return scores + + total = float(sum(scores.values())) + if total <= 0: + return {} + return {pid: (score / total) * 100.0 for pid, score in scores.items()} # Example usage diff --git a/engine/lib/wrappers.py b/engine/lib/wrappers.py index f68a27c..4cfd706 100644 --- a/engine/lib/wrappers.py +++ b/engine/lib/wrappers.py @@ -32,17 +32,23 @@ class EconomicMetricsWrapper(gym.Wrapper): obs, reward, terminated, truncated, info = self.env.step(action) # extract from unwrapped env - prices = self.env.unwrapped._prices + quoted_prices = np.asarray(self.env.unwrapped._prices, dtype=float) + effective_prices = np.asarray( + info.get("effective_prices", quoted_prices), dtype=float + ) + if effective_prices.shape != quoted_prices.shape: + effective_prices = quoted_prices demand_dict = self.env.unwrapped._demand - demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))]) + demand = np.array([demand_dict.get(i, 0.0) for i in range(len(quoted_prices))]) # core calculations - revenue = float(np.sum(prices * demand)) - avg_price = float(np.mean(prices)) + revenue = float(info.get("revenue", np.sum(effective_prices * demand))) + quoted_revenue = float(np.sum(quoted_prices * demand)) + avg_price = float(np.mean(effective_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._price_history.append(effective_prices.copy()) self._revenue_history.append(revenue) # regret vs baseline (golden path) @@ -53,6 +59,7 @@ class EconomicMetricsWrapper(gym.Wrapper): # inject structured metrics into info info["economics"] = { "revenue": revenue, + "quoted_revenue": quoted_revenue, "margin": margin, "coi_level": coi_level, "regret": regret, @@ -71,10 +78,13 @@ class EconomicMetricsWrapper(gym.Wrapper): "agent_prob", "alpha_adv", "alpha_nominal", + "erosion_share", + "effective_price_mean", ): if key in info: info["economics"][key] = info[key] - info["prices"] = prices.copy() + info["prices"] = quoted_prices.copy() + info["effective_prices"] = effective_prices.copy() info["demand"] = demand.copy() return obs, reward, terminated, truncated, info diff --git a/engine/spec.py b/engine/spec.py index 818d59f..5ddd0ce 100644 --- a/engine/spec.py +++ b/engine/spec.py @@ -72,6 +72,8 @@ class EnvSpec: max_steps: int = 100 margin_floor: float = 0.05 margin_floor_patience: int = 5 + agent_mu: float = 45.0 + agent_std: float = 15.0 @dataclass(frozen=True) @@ -167,6 +169,8 @@ class TrainSpec: "max_steps": self.env.max_steps, "margin_floor": self.env.margin_floor, "margin_floor_patience": self.env.margin_floor_patience, + "agent_mu": self.env.agent_mu, + "agent_std": self.env.agent_std, "alpha": self.study.alpha, "lambda_coi": self.study.lambda_coi, "robust_radius": self.study.robust_radius, @@ -246,6 +250,8 @@ class TrainSpec: max_steps=int(base["max_steps"]), margin_floor=float(base["margin_floor"]), margin_floor_patience=int(base["margin_floor_patience"]), + agent_mu=float(base.get("agent_mu", 45.0)), + agent_std=float(base.get("agent_std", 15.0)), ), study=StudySpec( alpha=float(base["alpha"]), diff --git a/engine/studies/margin_erosion_alpha.py b/engine/studies/margin_erosion_alpha.py index ef2dc79..3ff97a4 100644 --- a/engine/studies/margin_erosion_alpha.py +++ b/engine/studies/margin_erosion_alpha.py @@ -31,6 +31,9 @@ def _run_baseline(alpha: float, algo: str, seed: int, steps: int) -> dict: "eval_freq": 5000, "eval_episodes": 10, "log_freq": 500, + "robust_eval_enabled": False, + "agent_mu": 12.0, + "agent_std": 2.0, } ) result = run_train_once( diff --git a/engine/studies/plot_margin_erosion.py b/engine/studies/plot_margin_erosion.py deleted file mode 100644 index 021a8b5..0000000 --- a/engine/studies/plot_margin_erosion.py +++ /dev/null @@ -1,126 +0,0 @@ -"""plot margin erosion: margin/COI/revenue vs α with thesis-quality formatting""" - -import json, sys -from pathlib import Path -import numpy as np -import matplotlib.pyplot as plt -import matplotlib as mpl - -mpl.rcParams.update( - { - "font.size": 10, - "axes.labelsize": 11, - "axes.titlesize": 12, - "xtick.labelsize": 9, - "ytick.labelsize": 9, - "legend.fontsize": 9, - "figure.figsize": (7, 4), - "figure.dpi": 150, - "lines.linewidth": 1.5, - "lines.markersize": 6, - "errorbar.capsize": 3, - "grid.alpha": 0.3, - } -) - - -def plot_margin_erosion(data: dict, out: Path): - s = data["summary"] - αs = sorted([float(k.split("_")[1]) for k in s.keys()]) - - def get(metric): - return ( - [s[f"alpha_{α:.1f}"][f"{metric}_mean"] for α in αs], - [s[f"alpha_{α:.1f}"][f"{metric}_std"] for α in αs], - ) - - margins, margin_e = get("margin") - cois, coi_e = get("coi_level") - revs, rev_e = get("revenue") - - fig, axes = plt.subplots(1, 3, figsize=(12, 3.5)) - - axes[0].errorbar( - αs, - margins, - yerr=margin_e, - marker="o", - capsize=4, - label="Standard RL", - color="#d62728", - ) - axes[0].axhline(0.05, color="gray", linestyle="--", linewidth=1, label="Floor") - axes[0].set( - xlabel="Agent proportion (α)", - ylabel="Margin", - title="Margin erosion", - ylim=(0, max(margins) * 1.2), - ) - axes[0].grid(alpha=0.3) - axes[0].legend(loc="upper right") - - axes[1].errorbar(αs, cois, yerr=coi_e, marker="s", capsize=4, color="#ff7f0e") - axes[1].set( - xlabel="Agent proportion (α)", - ylabel="COI", - title="COI collapse (E[P] - p_min)", - ylim=(0, None), - ) - axes[1].grid(alpha=0.3) - - axes[2].errorbar(αs, revs, yerr=rev_e, marker="^", capsize=4, color="#2ca02c") - axes[2].set( - xlabel="Agent proportion (α)", - ylabel="Revenue", - title="Revenue degradation", - ylim=(0, None), - ) - axes[2].grid(alpha=0.3) - - plt.tight_layout() - pdf = out / "margin_erosion_alpha.pdf" - png = out / "margin_erosion_alpha.png" - plt.savefig(pdf, bbox_inches="tight", dpi=300) - plt.savefig(png, bbox_inches="tight", dpi=150) - print(f"→ {pdf}\n→ {png}") - - -def print_latex(data: dict): - s = data["summary"] - αs = sorted([float(k.split("_")[1]) for k in s.keys()]) - - print("\n% LaTeX table for appendix") - print("\\begin{table}[h]\n\\centering") - print("\\caption{Margin erosion: standard RL under agent contamination}") - print("\\label{tab:margin_erosion}") - print("\\begin{tabular}{cccc}\n\\toprule") - print("α & Margin & COI & Revenue \\\\\n\\midrule") - - for α in αs: - d = s[f"alpha_{α:.1f}"] - print( - f"{α:.1f} & ${d['margin_mean']:.3f} \\pm {d['margin_std']:.3f}$ & " - f"${d['coi_level_mean']:.1f} \\pm {d['coi_level_std']:.1f}$ & " - f"${d['revenue_mean']:.0f} \\pm {d['revenue_std']:.0f}$ \\\\" - ) - - print("\\bottomrule\n\\end{tabular}\n\\end{table}") - - -if __name__ == "__main__": - if len(sys.argv) < 2: - sys.exit("usage: python -m engine.studies.plot_margin_erosion ") - - path = Path(sys.argv[1]) - if not path.exists(): - sys.exit(f"error: {path} not found") - - with open(path) as f: - data = json.load(f) - - plot_margin_erosion(data, path.parent) - print_latex(data) - print( - f"\n{len(data['results'])} runs, {len(data['summary'])} α levels, " - f"algos={data['config']['algos']}, seeds={data['config']['seeds']}" - ) From d3a4febfde3b36924d391a584c4d9943ac41cabb Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 11 Mar 2026 20:49:28 +0100 Subject: [PATCH 05/35] tpu ready remodel --- Makefile | 9 ++++++- 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 --------------- docker-compose.yml | 15 +++++++++++ engine/engine.py | 14 +++------- engine/lib/behavior.py | 5 ++++ lib/config.py | 46 +++++++++++++++++++++----------- requirements.txt | 1 + 13 files changed, 63 insertions(+), 156 deletions(-) delete mode 100644 TPUS/README.md delete mode 100644 TPUS/v4_32_spot_uscentral2b.sh delete mode 100644 TPUS/v4_uscentral2b.sh delete mode 100644 TPUS/v5e_64_spot_europewest4b.sh delete mode 100644 TPUS/v5e_64_spot_uscentral1a.sh delete mode 100644 TPUS/v6e_64_spot_europewest4a.sh delete mode 100644 TPUS/v6e_64_spot_useast1d.sh diff --git a/Makefile b/Makefile index f0072a7..edb2a9a 100644 --- a/Makefile +++ b/Makefile @@ -37,7 +37,7 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || help: @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines" @echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish" - @echo "study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" + @echo "data.pull data.push | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" @echo "" @echo "Build general public version:" @echo " make pdf.genpop" @@ -134,6 +134,13 @@ train.agent: train.bootstrap: @WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" REPO_URL="$(REPO_URL)" BRANCH="$(BRANCH)" WORKDIR="$(WORKDIR)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" AGENT_LOOP="$(AGENT_LOOP)" RETRY_SECONDS="$(RETRY_SECONDS)" $(NX) run research:train-bootstrap +.PHONY: data.pull data.push +data.pull: + python scripts/hf_data.py pull + +data.push: + python scripts/hf_data.py push + .PHONY: stats.lines stats.lines: @$(NX) run research:stats diff --git a/TPUS/README.md b/TPUS/README.md deleted file mode 100644 index bb88fce..0000000 --- a/TPUS/README.md +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 661bcdc..0000000 --- a/TPUS/v4_32_spot_uscentral2b.sh +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index a372078..0000000 --- a/TPUS/v4_uscentral2b.sh +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index 7a35d7e..0000000 --- a/TPUS/v5e_64_spot_europewest4b.sh +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 96375fd..0000000 --- a/TPUS/v5e_64_spot_uscentral1a.sh +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index 1ea17ac..0000000 --- a/TPUS/v6e_64_spot_europewest4a.sh +++ /dev/null @@ -1,22 +0,0 @@ -# 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 deleted file mode 100644 index cada53f..0000000 --- a/TPUS/v6e_64_spot_useast1d.sh +++ /dev/null @@ -1,22 +0,0 @@ -# 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 diff --git a/docker-compose.yml b/docker-compose.yml index ba2e8a3..c00f4e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,19 @@ services: + tpu-watchdogs: + build: + context: . + dockerfile: docker/TPUWatchdog.dockerfile + container_name: "PHANTOM-tpu-watchdogs" + restart: unless-stopped + user: "${UID:-1000}:${GID:-1000}" + environment: + - HF_TOKEN=${HF_TOKEN} + - WANDB_API_KEY=${WANDB_API_KEY} + - GITHUB_TOKEN=${GITHUB_TOKEN} + - CLOUDSDK_CONFIG=/.config/gcloud + volumes: + - ~/.config/gcloud:/.config/gcloud:rw + tensorboard-rl: image: tensorflow/tensorflow:latest container_name: "PHANTOM-tensorboard-rl" diff --git a/engine/engine.py b/engine/engine.py index 81a4da7..d548177 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -1,5 +1,4 @@ from sys import platform -from concurrent.futures import ThreadPoolExecutor import numpy as np from .lib.demand import generate_demand_for_actor, estimate_demand from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions @@ -8,9 +7,6 @@ from logging import INFO, getLogger logger = getLogger(__name__) logger.setLevel(INFO) -# shared pool; reused across act() calls to avoid per-call thread-spawn overhead -_pool = ThreadPoolExecutor(max_workers=4) - class MarketEngine: """implements separate demand distributions for humans and agents per Section 3.1.1""" @@ -54,16 +50,14 @@ class MarketEngine: agent_transitions = get_adjusted_transitions(demand_a, human=False) # sample N trajectories in parallel; each chain is independent so threads # do not share state and numpy's per-call RNG is thread-safe - h_futs = [ - _pool.submit(sample_behavior_from_transitions, human_transitions) + human_t = [ + sample_behavior_from_transitions(human_transitions) for _ in range(self.Nhumans) ] - a_futs = [ - _pool.submit(sample_behavior_from_transitions, agent_transitions) + agent_t = [ + sample_behavior_from_transitions(agent_transitions) for _ in range(self.Nagents) ] - human_t = [f.result() for f in h_futs] - agent_t = [f.result() for f in a_futs] # store trajectories for agent probability calculation self.last_trajectories = human_t + agent_t return estimate_demand(self.last_trajectories, self.action_weights) diff --git a/engine/lib/behavior.py b/engine/lib/behavior.py index 5c96c27..52a9d7d 100644 --- a/engine/lib/behavior.py +++ b/engine/lib/behavior.py @@ -143,6 +143,11 @@ def get_adjusted_transitions(condition, human=True) -> _TransitionTable: cache_key = (human, tuple(np.round(condition, 4).tolist())) if cache_key in _transition_cache: return _transition_cache[cache_key] + + # prevent OOM by capping cache size + if len(_transition_cache) > 100: + _transition_cache.clear() + base_pivot = _get_base_pivot(human) df = adjust_behavior_to_condition(condition, base_pivot) table = _TransitionTable(df) diff --git a/lib/config.py b/lib/config.py index a27ffd9..d46f82c 100644 --- a/lib/config.py +++ b/lib/config.py @@ -2,6 +2,7 @@ All hardcoded paths should reference this module Paths can be overridden via environment variables """ + import os from pathlib import Path @@ -9,24 +10,34 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).parent.parent.resolve() # data directories -DATA_DIR = Path(os.getenv('PHANTOM_DATA_DIR', PROJECT_ROOT / 'data')) -EXPERIMENTS_DIR = Path(os.getenv('PHANTOM_EXPERIMENTS_DIR', PROJECT_ROOT / 'experiments')) +DATA_DIR = Path(os.getenv("PHANTOM_DATA_DIR", PROJECT_ROOT / "data")) +EXPERIMENTS_DIR = Path( + os.getenv("PHANTOM_EXPERIMENTS_DIR", PROJECT_ROOT / "experiments") +) # agent/human interaction data -AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', DATA_DIR / 'agents')) -HUMAN_DATA_DIR = Path(os.getenv('PHANTOM_HUMAN_DATA_DIR', DATA_DIR / 'humans')) +AGENT_DATA_DIR = Path(os.getenv("PHANTOM_AGENT_DATA_DIR", DATA_DIR / "agents")) +HUMAN_DATA_DIR = Path(os.getenv("PHANTOM_HUMAN_DATA_DIR", DATA_DIR / "humans")) # RL simulation runs -SIM_RUNS_DIR = Path(os.getenv('PHANTOM_SIM_RUNS_DIR', PROJECT_ROOT / 'sim' / 'rl' / 'runs')) +SIM_RUNS_DIR = Path( + os.getenv("PHANTOM_SIM_RUNS_DIR", PROJECT_ROOT / "sim" / "rl" / "runs") +) # model artifacts -MODEL_REGISTRY_DIR = Path(os.getenv('PHANTOM_MODEL_REGISTRY_DIR', DATA_DIR / 'models')) +MODEL_REGISTRY_DIR = Path(os.getenv("PHANTOM_MODEL_REGISTRY_DIR", DATA_DIR / "models")) # collected experiment data -COLLECTED_DATA_DIR = Path(os.getenv('PHANTOM_COLLECTED_DATA_DIR', EXPERIMENTS_DIR / 'agents' / 'collected_data')) +COLLECTED_DATA_DIR = Path( + os.getenv( + "PHANTOM_COLLECTED_DATA_DIR", EXPERIMENTS_DIR / "agents" / "collected_data" + ) +) # notebook outputs -NOTEBOOK_OUTPUT_DIR = Path(os.getenv('PHANTOM_NOTEBOOK_OUTPUT_DIR', EXPERIMENTS_DIR / 'notebooks' / 'outputs')) +NOTEBOOK_OUTPUT_DIR = Path( + os.getenv("PHANTOM_NOTEBOOK_OUTPUT_DIR", EXPERIMENTS_DIR / "notebooks" / "outputs") +) def ensure_dir(path: Path) -> Path: @@ -51,15 +62,18 @@ def get_sim_path(*parts: str) -> Path: # service configuration (from .env) -KAFKA_HOST = os.getenv('KAFKA_HOST', 'localhost') -KAFKA_PORT = os.getenv('KAFKA_PORT', '9092') +KAFKA_HOST = os.getenv("KAFKA_HOST", "localhost") +KAFKA_PORT = os.getenv("KAFKA_PORT", "9092") KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}" -REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') -REDIS_PORT = int(os.getenv('REDIS_PORT', '6379')) +REDIS_HOST = os.getenv("REDIS_HOST", "localhost") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) -SUPABASE_URL = os.getenv('NEXT_PUBLIC_SUPABASE_URL', '') -SUPABASE_ANON_KEY = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '') +SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "") +SUPABASE_ANON_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "") -BACKEND_PORT = int(os.getenv('BACKEND_PORT', '5000')) -PROVIDER_PORT = int(os.getenv('PROVIDER_PORT', '5001')) +BACKEND_PORT = int(os.getenv("BACKEND_PORT", "5000")) +PROVIDER_PORT = int(os.getenv("PROVIDER_PORT", "5001")) + +# huggingface dataset repo for collected behavioral data +HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "velocitatem/phantom-collected-data") diff --git a/requirements.txt b/requirements.txt index 247121e..c1a8686 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,3 +13,4 @@ scikit-learn supabase pymc wandb +huggingface_hub From 631b6d698cc45ef5bb752f95e8b0be357e885ffa Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 14:14:01 +0100 Subject: [PATCH 06/35] feat: tpu orchestrator --- tpu_orchestration/configs/test_vm.conf | 8 ++ tpu_orchestration/configs/v4_od_us.conf | 8 ++ tpu_orchestration/configs/v4_spot_us.conf | 8 ++ tpu_orchestration/configs/v5e_eu.conf | 8 ++ tpu_orchestration/configs/v5e_us.conf | 8 ++ tpu_orchestration/configs/v6e_eu.conf | 8 ++ tpu_orchestration/configs/v6e_us.conf | 8 ++ tpu_orchestration/tpu_startup.sh | 117 ++++++++++++++++++++ tpu_orchestration/watchdog.sh | 127 ++++++++++++++++++++++ 9 files changed, 300 insertions(+) create mode 100644 tpu_orchestration/configs/test_vm.conf create mode 100644 tpu_orchestration/configs/v4_od_us.conf create mode 100644 tpu_orchestration/configs/v4_spot_us.conf create mode 100644 tpu_orchestration/configs/v5e_eu.conf create mode 100644 tpu_orchestration/configs/v5e_us.conf create mode 100644 tpu_orchestration/configs/v6e_eu.conf create mode 100644 tpu_orchestration/configs/v6e_us.conf create mode 100644 tpu_orchestration/tpu_startup.sh create mode 100755 tpu_orchestration/watchdog.sh diff --git a/tpu_orchestration/configs/test_vm.conf b/tpu_orchestration/configs/test_vm.conf new file mode 100644 index 0000000..6c154ed --- /dev/null +++ b/tpu_orchestration/configs/test_vm.conf @@ -0,0 +1,8 @@ +ZONE="us-central2-b" +QR_NAME="v4-test-vm" +ACCEL_TYPE="v4-8" +RUNTIME_VERSION="v2-alpha-tpuv4" +IS_SPOT="true" +RUN_ID="phantom_v4_test_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v4_od_us.conf b/tpu_orchestration/configs/v4_od_us.conf new file mode 100644 index 0000000..8739861 --- /dev/null +++ b/tpu_orchestration/configs/v4_od_us.conf @@ -0,0 +1,8 @@ +ZONE="us-central2-b" +QR_NAME="v4-32-us-ondemand" +ACCEL_TYPE="v4-32" +RUNTIME_VERSION="v2-alpha-tpuv4" +IS_SPOT="false" +RUN_ID="phantom_v4_od_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/configs/v4_spot_us.conf b/tpu_orchestration/configs/v4_spot_us.conf new file mode 100644 index 0000000..d0f9755 --- /dev/null +++ b/tpu_orchestration/configs/v4_spot_us.conf @@ -0,0 +1,8 @@ +ZONE="us-central2-b" +QR_NAME="v4-32-us-spot" +ACCEL_TYPE="v4-32" +RUNTIME_VERSION="v2-alpha-tpuv4" +IS_SPOT="true" +RUN_ID="phantom_v4_spot_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/configs/v5e_eu.conf b/tpu_orchestration/configs/v5e_eu.conf new file mode 100644 index 0000000..c40ee21 --- /dev/null +++ b/tpu_orchestration/configs/v5e_eu.conf @@ -0,0 +1,8 @@ +ZONE="europe-west4-b" +QR_NAME="v5e-64-eu-spot" +ACCEL_TYPE="v5litepod-64" +RUNTIME_VERSION="v2-alpha-tpuv5-lite" +IS_SPOT="true" +RUN_ID="phantom_v5e_eu_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/configs/v5e_us.conf b/tpu_orchestration/configs/v5e_us.conf new file mode 100644 index 0000000..0d44cd5 --- /dev/null +++ b/tpu_orchestration/configs/v5e_us.conf @@ -0,0 +1,8 @@ +ZONE="us-central1-a" +QR_NAME="v5e-64-us-spot" +ACCEL_TYPE="v5litepod-64" +RUNTIME_VERSION="v2-alpha-tpuv5-lite" +IS_SPOT="true" +RUN_ID="phantom_v5e_us_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/configs/v6e_eu.conf b/tpu_orchestration/configs/v6e_eu.conf new file mode 100644 index 0000000..5d29b8c --- /dev/null +++ b/tpu_orchestration/configs/v6e_eu.conf @@ -0,0 +1,8 @@ +ZONE="europe-west4-a" +QR_NAME="v6e-64-eu-spot" +ACCEL_TYPE="v6e-64" +RUNTIME_VERSION="v2-alpha-tpuv6e" +IS_SPOT="true" +RUN_ID="phantom_v6e_eu_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/configs/v6e_us.conf b/tpu_orchestration/configs/v6e_us.conf new file mode 100644 index 0000000..f6cf423 --- /dev/null +++ b/tpu_orchestration/configs/v6e_us.conf @@ -0,0 +1,8 @@ +ZONE="us-east1-d" +QR_NAME="v6e-64-us-spot" +ACCEL_TYPE="v6e-64" +RUNTIME_VERSION="v2-alpha-tpuv6e" +IS_SPOT="true" +RUN_ID="phantom_v6e_us_1" +HF_REPO="velocitatem/capstone" +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file diff --git a/tpu_orchestration/tpu_startup.sh b/tpu_orchestration/tpu_startup.sh new file mode 100644 index 0000000..62b55a9 --- /dev/null +++ b/tpu_orchestration/tpu_startup.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Idempotent startup script for TPU VMs using HF Buckets + +exec > >(tee -a /var/log/tpu_startup.log) 2>&1 +echo "Starting TPU setup..." + +# 1. Fetch metadata from GCP +get_metadata() { + curl -s -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/attributes/$1" +} + +export HF_TOKEN=$(get_metadata "HF_TOKEN") +export WANDB_API_KEY=$(get_metadata "WANDB_API_KEY") +export RUN_ID=$(get_metadata "RUN_ID") +export HF_REPO=$(get_metadata "HF_REPO") +export ACCEL_TYPE=$(get_metadata "ACCEL_TYPE") +export GITHUB_REPO=$(get_metadata "GITHUB_REPO") +export BRANCH=$(get_metadata "BRANCH") +export TRAIN_CMD=$(get_metadata "TRAIN_CMD") + +export WORKER_ID=$(hostname) + +# 2. Install dependencies +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y git tmux jq curl build-essential wget + +# Install HF CLI +curl -LsSf https://hf.co/cli/install.sh | bash + +# Install Miniconda to ensure modern Python (3.10+) on older TPU OS bases +wget https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh -O /tmp/miniconda.sh +bash /tmp/miniconda.sh -b -p /opt/conda +rm /tmp/miniconda.sh +export PATH="/opt/conda/bin:$PATH" + +# Create and activate conda environment +conda create -n phantom python=3.11 -y +source /opt/conda/bin/activate phantom + +# Install Python ML dependencies +pip install --upgrade pip +pip install "jax[tpu]" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html +pip install wandb orbax-checkpoint huggingface_hub + +# 3. Setup directories +mkdir -p /app/data +mkdir -p /app/checkpoints +mkdir -p /app/logs +mkdir -p /app/xla_cache/$ACCEL_TYPE + +export JAX_COMPILATION_CACHE_DIR="/app/xla_cache/${ACCEL_TYPE}" + +# 4. Clone repository +if [ -d "/app/model" ]; then + rm -rf /app/model +fi +git clone --branch $BRANCH $GITHUB_REPO /app/model +cd /app/model + +# Install project-specific dependencies if available +if [ -f "requirements.txt" ]; then + pip install -r requirements.txt +fi + +# 5. Restore state from Hugging Face Buckets +echo "Restoring state from hf://buckets/$HF_REPO..." +# Download base data (shared across all) +hf buckets sync hf://buckets/$HF_REPO/data/base /app/data || echo "No base data found or failed to sync." + +# Download worker-specific checkpoints and logs +hf buckets sync hf://buckets/$HF_REPO/runs/$RUN_ID/checkpoints/$WORKER_ID /app/checkpoints || echo "No checkpoint found." +hf buckets sync hf://buckets/$HF_REPO/runs/$RUN_ID/logs/$WORKER_ID /app/logs || echo "No logs found." + +# Download architecture-specific XLA cache +hf buckets sync hf://buckets/$HF_REPO/runs/$RUN_ID/xla/$ACCEL_TYPE /app/xla_cache/$ACCEL_TYPE || echo "No XLA cache found." + +# 6. Start Background Sync Loop +cat << 'EOF' > /app/sync_loop.sh +#!/bin/bash +while true; do + sleep 120 + echo "[$(date)] Background sync to HF Bucket..." + hf buckets sync /app/checkpoints hf://buckets/$HF_REPO/runs/$RUN_ID/checkpoints/$WORKER_ID --quiet || true + hf buckets sync /app/logs hf://buckets/$HF_REPO/runs/$RUN_ID/logs/$WORKER_ID --quiet || true + hf buckets sync /app/xla_cache/$ACCEL_TYPE hf://buckets/$HF_REPO/runs/$RUN_ID/xla/$ACCEL_TYPE --quiet || true +done +EOF +chmod +x /app/sync_loop.sh +/app/sync_loop.sh & +SYNC_PID=$! + +# 7. Execute Training +echo "Starting training with command: $TRAIN_CMD" +# Ensure we are in the correct directory and environment +cd /app/model +export PYTHONPATH="/app/model:$PYTHONPATH" + +if [ -n "$TRAIN_CMD" ]; then + eval "$TRAIN_CMD" + EXIT_CODE=$? +else + echo "No TRAIN_CMD provided. Sleeping for testing purposes..." + # For testing: run a dummy process so the VM doesn't just idle immediately + sleep 3600 + EXIT_CODE=0 +fi + +# 8. Cleanup and Final Sync +echo "Training finished with exit code $EXIT_CODE. Stopping sync loop and performing final sync..." +kill $SYNC_PID + +hf buckets sync /app/checkpoints hf://buckets/$HF_REPO/runs/$RUN_ID/checkpoints/$WORKER_ID +hf buckets sync /app/logs hf://buckets/$HF_REPO/runs/$RUN_ID/logs/$WORKER_ID +hf buckets sync /app/xla_cache/$ACCEL_TYPE hf://buckets/$HF_REPO/runs/$RUN_ID/xla/$ACCEL_TYPE + +exit $EXIT_CODE \ No newline at end of file diff --git a/tpu_orchestration/watchdog.sh b/tpu_orchestration/watchdog.sh new file mode 100755 index 0000000..2b103bc --- /dev/null +++ b/tpu_orchestration/watchdog.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# Watchdog loop to ensure TPUs are re-queued when preempted + +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +CONFIG_FILE=$1 +if [ ! -f "$CONFIG_FILE" ]; then + echo "Config file $CONFIG_FILE not found." + exit 1 +fi + +# Load config +source "$CONFIG_FILE" + +# Make sure HF_TOKEN is available +if [ -z "$HF_TOKEN" ]; then + echo "Error: HF_TOKEN environment variable must be set before running watchdog." + echo "export HF_TOKEN=..." + exit 1 +fi + +# Make sure WANDB_API_KEY is available +if [ -z "$WANDB_API_KEY" ]; then + echo "Warning: WANDB_API_KEY environment variable is not set. Wandb logging may fail." +fi + +# Make sure GITHUB_REPO is set in config or env +if [ -z "$GITHUB_REPO" ]; then + GITHUB_REPO="https://github.com/velocitatem/PHANTOM.git" + if [ -n "$GITHUB_TOKEN" ]; then + GITHUB_REPO="https://velocitatem:${GITHUB_TOKEN}@github.com/velocitatem/PHANTOM.git" + fi +fi + +# Make sure BRANCH is set in config or env +if [ -z "$BRANCH" ]; then + BRANCH="main" +fi + +# Ensure PROJECT_ID is set +if [ -z "$PROJECT_ID" ]; then + PROJECT_ID=$(gcloud config get-value project 2>/dev/null) + if [ -z "$PROJECT_ID" ]; then + PROJECT_ID="phantom-trc" # Fallback to the known project ID + echo "Warning: PROJECT_ID not set and gcloud not configured. Defaulting to $PROJECT_ID" + fi +fi + +echo "Starting watchdog for $QR_NAME in $ZONE (Project: $PROJECT_ID)" +echo "Accelerator: $ACCEL_TYPE" +echo "Run ID: $RUN_ID" + +# Backoff tracking for IP quota errors +RETRY_DELAY=60 +MAX_RETRY_DELAY=300 + +while true; do + STATE=$(gcloud compute tpus queued-resources describe $QR_NAME --zone=$ZONE --project=$PROJECT_ID --format="value(state)" 2>/dev/null) + + if [ -z "$STATE" ] || [[ "$STATE" == *"SUSPENDED"* ]] || [[ "$STATE" == *"FAILED"* ]]; then + echo "[$(date)] Cluster '${STATE:-MISSING}' - cleaning IPs and re-queuing..." + + # Clean all orphaned RESERVED IPs in parallel to free quota + gcloud compute addresses list --project=$PROJECT_ID \ + --filter="status=RESERVED AND name~'^tpu-.*'" \ + --format="value(name,region)" 2>/dev/null | \ + while IFS=$'\t' read -r n r; do + [ -n "$n" ] && [ -n "$r" ] && gcloud compute addresses delete "$n" --region="$r" --project=$PROJECT_ID --quiet 2>/dev/null & + done + wait + + # Delete QR and any orphaned VM + gcloud compute tpus queued-resources delete $QR_NAME --zone=$ZONE --project=$PROJECT_ID --quiet --force 2>/dev/null + VM_STATE=$(gcloud compute tpus tpu-vm describe $QR_NAME --zone=$ZONE --project=$PROJECT_ID --format="value(state)" 2>/dev/null) + [ -n "$VM_STATE" ] && gcloud compute tpus tpu-vm delete $QR_NAME --zone=$ZONE --project=$PROJECT_ID --quiet 2>/dev/null + + sleep 5 + + # Create new QR + SPOT_FLAG="" + if [ "$IS_SPOT" = "true" ]; then + SPOT_FLAG="--spot" + fi + + # Prepare metadata + METADATA="HF_TOKEN=$HF_TOKEN,RUN_ID=$RUN_ID,HF_REPO=$HF_REPO,ACCEL_TYPE=$ACCEL_TYPE,GITHUB_REPO=$GITHUB_REPO,BRANCH=$BRANCH" + if [ -n "$WANDB_API_KEY" ]; then + METADATA="$METADATA,WANDB_API_KEY=$WANDB_API_KEY" + fi + if [ -n "$TRAIN_CMD" ]; then + METADATA="$METADATA,TRAIN_CMD=$TRAIN_CMD" + fi + + # Determine runtime version + RT_VERSION=${RUNTIME_VERSION:-"v2-alpha-tpuv4"} + + gcloud compute tpus queued-resources create $QR_NAME \ + --project=$PROJECT_ID \ + --node-id=$QR_NAME \ + --zone=$ZONE \ + --accelerator-type=$ACCEL_TYPE \ + --runtime-version=$RT_VERSION \ + $SPOT_FLAG \ + --metadata-from-file startup-script=$(dirname $0)/tpu_startup.sh \ + --metadata "$METADATA" 2>&1 | tee /tmp/tpu_create_${QR_NAME}.log + + if [ $? -eq 0 ]; then + echo "[$(date)] Successfully queued $QR_NAME." + RETRY_DELAY=60 + elif grep -q "IN_USE_ADDRESSES" /tmp/tpu_create_${QR_NAME}.log 2>/dev/null; then + echo "[$(date)] IP quota hit - backing off ${RETRY_DELAY}s" + sleep $RETRY_DELAY + RETRY_DELAY=$((RETRY_DELAY * 2)) + [ $RETRY_DELAY -gt $MAX_RETRY_DELAY ] && RETRY_DELAY=$MAX_RETRY_DELAY + continue + else + echo "[$(date)] Failed to queue $QR_NAME." + RETRY_DELAY=60 + fi + else + echo "[$(date)] Cluster state is $STATE. Checking again in 60s..." + fi + sleep 60 +done From 745792683e19a15a6e3efd688fec5bf55b5d4f71 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 11 Mar 2026 20:50:14 +0100 Subject: [PATCH 07/35] feat: data sync via HF --- scripts/hf_data.py | 94 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/hf_data.py diff --git a/scripts/hf_data.py b/scripts/hf_data.py new file mode 100644 index 0000000..120165c --- /dev/null +++ b/scripts/hf_data.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Sync collected behavioral data with HuggingFace Hub. + +Usage: + python scripts/hf_data.py pull # download from HF to local directories + python scripts/hf_data.py push # upload local directories to HF + +Expects HF_TOKEN env var (or logged in via `huggingface-cli login`). +Repo id comes from HF_DATASET_REPO env var, default: velocitatem/phantom-collected-data +""" + +import argparse +import os +import sys +from pathlib import Path + +from huggingface_hub import HfApi, snapshot_download + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +HUMAN_DIR = PROJECT_ROOT / "experiments" / "collected_data" +AGENT_DIR = PROJECT_ROOT / "experiments" / "agents" / "collected_data" + +DEFAULT_REPO = "velocitatem/phantom-collected-data" + +# mapping between local dirs and their prefix inside the HF repo +SLOT_MAP = {"human": HUMAN_DIR, "agent": AGENT_DIR} + + +def _repo_id() -> str: + return os.getenv("HF_DATASET_REPO", DEFAULT_REPO) + + +def _token() -> str | None: + return os.getenv("HF_TOKEN") or None + + +def push(): + api = HfApi(token=_token()) + repo = _repo_id() + api.create_repo(repo, repo_type="dataset", exist_ok=True, private=True) + + for prefix, local_dir in SLOT_MAP.items(): + if not local_dir.exists(): + print(f"skip {prefix}: {local_dir} does not exist") + continue + sessions = [d for d in local_dir.iterdir() if d.is_dir()] + if not sessions: + print(f"skip {prefix}: no session directories") + continue + print(f"uploading {len(sessions)} sessions from {prefix}/ ...") + api.upload_folder( + repo_id=repo, + repo_type="dataset", + folder_path=str(local_dir), + path_in_repo=prefix, + commit_message=f"update {prefix} data ({len(sessions)} sessions)", + ) + print("push complete") + + +def pull(): + repo = _repo_id() + token = _token() + cache = snapshot_download(repo, repo_type="dataset", token=token) + cache = Path(cache) + + for prefix, local_dir in SLOT_MAP.items(): + src = cache / prefix + if not src.exists(): + print(f"skip {prefix}: not present in remote") + continue + local_dir.mkdir(parents=True, exist_ok=True) + sessions = [d for d in src.iterdir() if d.is_dir()] + pulled = 0 + for sess in sessions: + dest = local_dir / sess.name + dest.mkdir(exist_ok=True) + for f in sess.iterdir(): + if f.is_file(): + (dest / f.name).write_bytes(f.read_bytes()) + pulled += 1 + print(f"{prefix}: pulled {len(sessions)} sessions ({pulled} files)") + print("pull complete") + + +def main(): + p = argparse.ArgumentParser(description="Sync collected data with HuggingFace Hub") + p.add_argument("action", choices=["pull", "push"], help="pull or push data") + args = p.parse_args() + {"pull": pull, "push": push}[args.action]() + + +if __name__ == "__main__": + main() From 9caad4de4e0f46b6e6a80546921b006e6513b17f Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 12 Mar 2026 00:22:09 +0100 Subject: [PATCH 08/35] setup for tpu orchestarion properly --- docker/TPUWatchdog.dockerfile | 70 ++++++++++++++ engine/__init__.py | 0 scripts/ray_distributed_train.py | 151 +++++++++++++++++++++++++++++++ submit_ray_job.sh | 81 +++++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 docker/TPUWatchdog.dockerfile create mode 100644 engine/__init__.py create mode 100644 scripts/ray_distributed_train.py create mode 100755 submit_ray_job.sh diff --git a/docker/TPUWatchdog.dockerfile b/docker/TPUWatchdog.dockerfile new file mode 100644 index 0000000..8299171 --- /dev/null +++ b/docker/TPUWatchdog.dockerfile @@ -0,0 +1,70 @@ +FROM google/cloud-sdk:slim + +# Install tmux to manage multiple watchdogs and jq for json parsing +RUN apt-get update && \ + apt-get install -y tmux jq && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy the orchestration scripts and configs +COPY tpu_orchestration/ /app/tpu_orchestration/ + +# Make sure scripts are executable +RUN chmod +x /app/tpu_orchestration/watchdog.sh +RUN chmod +x /app/tpu_orchestration/tpu_startup.sh + +# Create an entrypoint script that launches a watchdog for each config +COPY <<-'EOF' /app/entrypoint.sh +#!/bin/bash +set -e + +# Make sure required variables are set +if [ -z "$HF_TOKEN" ]; then + echo "Error: HF_TOKEN environment variable is required." + exit 1 +fi + +if [ -z "$WANDB_API_KEY" ]; then + echo "Warning: WANDB_API_KEY environment variable is not set. Wandb logging may fail on TPUs." +fi + +# Authenticate gcloud if credentials are provided +if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ]; then + CRED_TYPE=$(jq -r '.type' "$GOOGLE_APPLICATION_CREDENTIALS" 2>/dev/null || echo "unknown") + if [ "$CRED_TYPE" = "service_account" ]; then + echo "Authenticating gcloud using service account key..." + gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS" + + # Extract project ID from the key file + PROJECT_ID=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS") + if [ -n "$PROJECT_ID" ] && [ "$PROJECT_ID" != "null" ]; then + gcloud config set project "$PROJECT_ID" + echo "Set project to $PROJECT_ID" + fi + else + echo "Note: Using application default credentials or mounted gcloud config..." + fi +else + echo "Note: Assuming gcloud config is mounted from host." +fi + +# Run the watchdogs in the background using bash instead of tmux +# Tmux needs a TTY to attach properly which we might not have in docker +# Stagger startups by 15s to prevent simultaneous TPU creation quota hits +DELAY=0 +for conf in /app/tpu_orchestration/configs/*.conf; do + echo "Starting watchdog for $(basename "$conf" .conf) (delay: ${DELAY}s)" + (sleep $DELAY && /app/tpu_orchestration/watchdog.sh "$conf") & + DELAY=$((DELAY + 15)) +done + +echo "All watchdogs queued with staggered startup." + +# Keep the container running +wait +EOF + +RUN chmod +x /app/entrypoint.sh + +CMD ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/ray_distributed_train.py b/scripts/ray_distributed_train.py new file mode 100644 index 0000000..f918f33 --- /dev/null +++ b/scripts/ray_distributed_train.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import argparse +import os +import shlex +import subprocess +import sys +import time +from pathlib import Path + +import ray + + +def _has_flag(tokens: list[str], name: str) -> bool: + return any(tok == name or tok.startswith(f"{name}=") for tok in tokens) + + +def _alive_node_ips() -> list[str]: + seen: set[str] = set() + ips: list[str] = [] + for node in ray.nodes(): + if not bool(node.get("Alive", False)): + continue + ip = str(node.get("NodeManagerAddress", "")).strip() + if not ip or ip in seen: + continue + seen.add(ip) + ips.append(ip) + return sorted(ips) + + +@ray.remote(max_retries=0) +def _train_on_node( + *, + root: str, + train_args: str, + rank: int, + world_size: int, + coordinator_ip: str, + coordinator_port: int, + base_seed: int, + run_group: str, + sync_jax: bool, +) -> int: + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + requested_platform = str(env.get("PHANTOM_JAX_PLATFORM", "tpu")).strip().lower() + if world_size > 1 and requested_platform == "tpu": + requested_platform = "cpu" + print( + "PHANTOM_DISTRIBUTED_NOTE: forcing JAX_PLATFORMS=cpu for multi-node SB3 runs" + ) + env["JAX_PLATFORMS"] = requested_platform + # Keep each train process in single-host mode to avoid accidental global stalls. + env["CLOUD_TPU_TASK_ID"] = "0" + + cwd = str(Path(root)) + + try: + subprocess.run(["make", "data.pull"], cwd=cwd, env=env, check=True) + except (subprocess.SubprocessError, OSError): + pull_cmd = [sys.executable, "scripts/hf_data.py", "pull"] + subprocess.run(pull_cmd, cwd=cwd, env=env, check=True) + + if sync_jax and requested_platform == "tpu": + env_probe = dict(env) + env_probe["CLOUD_TPU_TASK_ID"] = str(rank) + probe = ( + "import jax; " + f"jax.distributed.initialize(coordinator_address='{coordinator_ip}:{coordinator_port}', " + f"num_processes={world_size}, process_id={rank}); " + "print('JAX_SYNC', jax.process_index(), jax.device_count(), jax.local_device_count())" + ) + subprocess.run( + [sys.executable, "-c", probe], cwd=cwd, env=env_probe, check=True + ) + + tokens = shlex.split(train_args) + if not _has_flag(tokens, "--seed"): + tokens.extend(["--seed", str(base_seed + rank)]) + if not _has_flag(tokens, "--group"): + tokens.extend(["--group", run_group]) + + cmd = [sys.executable, "-m", "engine.train", *tokens] + proc = subprocess.run(cmd, cwd=cwd, env=env) + return int(proc.returncode) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Launch one train run per Ray TPU node" + ) + parser.add_argument("--train-args", type=str, required=True) + parser.add_argument("--num-nodes", type=int, default=0) + parser.add_argument("--tpu-per-task", type=float, default=8.0) + parser.add_argument("--base-seed", type=int, default=42) + parser.add_argument("--sync-jax", action="store_true") + parser.add_argument("--coordinator-port", type=int, default=12355) + parser.add_argument("--run-group", type=str, default="") + args = parser.parse_args() + + ray.init(address="auto") + + node_ips = _alive_node_ips() + if not node_ips: + raise RuntimeError("No alive Ray nodes found") + + requested = int(args.num_nodes) + if requested > 0: + node_ips = node_ips[:requested] + + world_size = len(node_ips) + coordinator_ip = node_ips[0] + run_group = args.run_group or f"ray-dist-{int(time.time())}" + + print( + { + "nodes": node_ips, + "world_size": world_size, + "coordinator": f"{coordinator_ip}:{int(args.coordinator_port)}", + "train_args": args.train_args, + "run_group": run_group, + } + ) + + futures = [] + root = str(Path(__file__).resolve().parents[1]) + for rank, node_ip in enumerate(node_ips): + resources = {f"node:{node_ip}": 0.01, "TPU": float(args.tpu_per_task)} + futures.append( + _train_on_node.options(resources=resources).remote( + root=root, + train_args=args.train_args, + rank=rank, + world_size=world_size, + coordinator_ip=coordinator_ip, + coordinator_port=int(args.coordinator_port), + base_seed=int(args.base_seed), + run_group=run_group, + sync_jax=bool(args.sync_jax), + ) + ) + + results = ray.get(futures) + failed = [code for code in results if int(code) != 0] + if failed: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/submit_ray_job.sh b/submit_ray_job.sh new file mode 100755 index 0000000..11775d6 --- /dev/null +++ b/submit_ray_job.sh @@ -0,0 +1,81 @@ +#!/bin/bash +# Submits PHANTOM training to a Ray cluster with .env injection. +# Modes: +# RAY_MODE=single -> one run (default) +# RAY_MODE=distributed -> one run per TPU node (experimental) + +set -euo pipefail + +ROOT="/home/velocitatem/Documents/Projects/PHANTOM" +RAY_BIN="${RAY_BIN:-ray}" +if ! command -v "$RAY_BIN" >/dev/null 2>&1; then + if [ -x "$ROOT/.venv-ray/bin/ray" ]; then + RAY_BIN="$ROOT/.venv-ray/bin/ray" + else + echo "ray CLI not found. Activate .venv-ray or set RAY_BIN." >&2 + exit 1 + fi +fi + +# 1. Parse .env and generate the JSON payload for Ray +export RUNTIME_ENV_JSON=$(python -c ' +import json +import os +from dotenv import dotenv_values + +env = dotenv_values(".env") +# Filter out empty/None values +env_vars = {k: v for k, v in env.items() if v} +env_vars.setdefault("CLOUD_TPU_TASK_ID", os.getenv("CLOUD_TPU_TASK_ID", "0")) + +print(json.dumps({ + "pip": [ + "stable-baselines3>=2.2.0", + "gymnasium>=0.29.0", + "wandb", + "tensorboard", + "python-dotenv", + "pandas", + "pydantic", + "graphviz", + "huggingface_hub" + ], + "env_vars": env_vars +})) +') + +RAY_MODE="${RAY_MODE:-single}" +TRAIN_ARGS="${TRAIN_ARGS:---algo ppo --total-timesteps 1000000}" + +COMMON_ARGS=( + job submit + --address http://localhost:8265 + --working-dir "$ROOT" + --runtime-env-json "$RUNTIME_ENV_JSON" + -- +) + +if [ "$RAY_MODE" = "single" ]; then + read -r -a TRAIN_TOKENS <<< "$TRAIN_ARGS" + "$RAY_BIN" "${COMMON_ARGS[@]}" python -m engine.train "${TRAIN_TOKENS[@]}" + exit 0 +fi + +if [ "$RAY_MODE" = "distributed" ]; then + DIST_ARGS=( + python + scripts/ray_distributed_train.py + --train-args "$TRAIN_ARGS" + --num-nodes "${NUM_NODES:-4}" + --tpu-per-task "${TPU_PER_TASK:-8}" + --base-seed "${BASE_SEED:-42}" + ) + if [ "${SYNC_JAX:-0}" = "1" ]; then + DIST_ARGS+=(--sync-jax) + fi + "$RAY_BIN" "${COMMON_ARGS[@]}" "${DIST_ARGS[@]}" + exit 0 +fi + +echo "Unsupported RAY_MODE='$RAY_MODE' (expected 'single' or 'distributed')." >&2 +exit 1 From d748733231d1769e37a927d1f58909421c5dce2b Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 12 Mar 2026 00:22:24 +0100 Subject: [PATCH 09/35] chore: fixing previous error of software version --- tpu_orchestration/configs/test_vm.conf | 2 +- tpu_orchestration/configs/v4_od_us.conf | 2 +- tpu_orchestration/configs/v4_spot_us.conf | 2 +- tpu_orchestration/configs/v5e_eu.conf | 2 +- tpu_orchestration/configs/v5e_us.conf | 2 +- tpu_orchestration/configs/v6e_eu.conf | 2 +- tpu_orchestration/configs/v6e_us.conf | 2 +- tpu_orchestration/tpu_startup.sh | 3 +++ tpu_orchestration/watchdog.sh | 2 +- 9 files changed, 11 insertions(+), 8 deletions(-) diff --git a/tpu_orchestration/configs/test_vm.conf b/tpu_orchestration/configs/test_vm.conf index 6c154ed..12a78df 100644 --- a/tpu_orchestration/configs/test_vm.conf +++ b/tpu_orchestration/configs/test_vm.conf @@ -1,7 +1,7 @@ ZONE="us-central2-b" QR_NAME="v4-test-vm" ACCEL_TYPE="v4-8" -RUNTIME_VERSION="v2-alpha-tpuv4" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v4_test_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v4_od_us.conf b/tpu_orchestration/configs/v4_od_us.conf index 8739861..ba75d7f 100644 --- a/tpu_orchestration/configs/v4_od_us.conf +++ b/tpu_orchestration/configs/v4_od_us.conf @@ -1,7 +1,7 @@ ZONE="us-central2-b" QR_NAME="v4-32-us-ondemand" ACCEL_TYPE="v4-32" -RUNTIME_VERSION="v2-alpha-tpuv4" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="false" RUN_ID="phantom_v4_od_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v4_spot_us.conf b/tpu_orchestration/configs/v4_spot_us.conf index d0f9755..2e31a18 100644 --- a/tpu_orchestration/configs/v4_spot_us.conf +++ b/tpu_orchestration/configs/v4_spot_us.conf @@ -1,7 +1,7 @@ ZONE="us-central2-b" QR_NAME="v4-32-us-spot" ACCEL_TYPE="v4-32" -RUNTIME_VERSION="v2-alpha-tpuv4" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v4_spot_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v5e_eu.conf b/tpu_orchestration/configs/v5e_eu.conf index c40ee21..89ef604 100644 --- a/tpu_orchestration/configs/v5e_eu.conf +++ b/tpu_orchestration/configs/v5e_eu.conf @@ -1,7 +1,7 @@ ZONE="europe-west4-b" QR_NAME="v5e-64-eu-spot" ACCEL_TYPE="v5litepod-64" -RUNTIME_VERSION="v2-alpha-tpuv5-lite" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v5e_eu_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v5e_us.conf b/tpu_orchestration/configs/v5e_us.conf index 0d44cd5..a77c50e 100644 --- a/tpu_orchestration/configs/v5e_us.conf +++ b/tpu_orchestration/configs/v5e_us.conf @@ -1,7 +1,7 @@ ZONE="us-central1-a" QR_NAME="v5e-64-us-spot" ACCEL_TYPE="v5litepod-64" -RUNTIME_VERSION="v2-alpha-tpuv5-lite" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v5e_us_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v6e_eu.conf b/tpu_orchestration/configs/v6e_eu.conf index 5d29b8c..ae7bcc3 100644 --- a/tpu_orchestration/configs/v6e_eu.conf +++ b/tpu_orchestration/configs/v6e_eu.conf @@ -1,7 +1,7 @@ ZONE="europe-west4-a" QR_NAME="v6e-64-eu-spot" ACCEL_TYPE="v6e-64" -RUNTIME_VERSION="v2-alpha-tpuv6e" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v6e_eu_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/configs/v6e_us.conf b/tpu_orchestration/configs/v6e_us.conf index f6cf423..a5fe55d 100644 --- a/tpu_orchestration/configs/v6e_us.conf +++ b/tpu_orchestration/configs/v6e_us.conf @@ -1,7 +1,7 @@ ZONE="us-east1-d" QR_NAME="v6e-64-us-spot" ACCEL_TYPE="v6e-64" -RUNTIME_VERSION="v2-alpha-tpuv6e" +RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v6e_us_1" HF_REPO="velocitatem/capstone" diff --git a/tpu_orchestration/tpu_startup.sh b/tpu_orchestration/tpu_startup.sh index 62b55a9..ae5f556 100644 --- a/tpu_orchestration/tpu_startup.sh +++ b/tpu_orchestration/tpu_startup.sh @@ -62,6 +62,9 @@ cd /app/model if [ -f "requirements.txt" ]; then pip install -r requirements.txt fi +if [ -f "sim/requirements.txt" ]; then + pip install -r sim/requirements.txt +fi # 5. Restore state from Hugging Face Buckets echo "Restoring state from hf://buckets/$HF_REPO..." diff --git a/tpu_orchestration/watchdog.sh b/tpu_orchestration/watchdog.sh index 2b103bc..4c32562 100755 --- a/tpu_orchestration/watchdog.sh +++ b/tpu_orchestration/watchdog.sh @@ -95,7 +95,7 @@ while true; do fi # Determine runtime version - RT_VERSION=${RUNTIME_VERSION:-"v2-alpha-tpuv4"} + RT_VERSION=${RUNTIME_VERSION:-"tpu-ubuntu2204-base"} gcloud compute tpus queued-resources create $QR_NAME \ --project=$PROJECT_ID \ From 22e50aac4a83c69aa1827ca649da2ebe2e2033f7 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 12 Mar 2026 00:22:46 +0100 Subject: [PATCH 10/35] cleaning manim and improving rtraining setup --- Makefile | 9 +- engine/benchmark.py | 22 +- engine/jax/robust.py | 63 +- engine/train.py | 21 + package.json | 2 + paper/defense/manim/render.py | 84 -- paper/defense/manim/scenes.py | 1581 --------------------------------- 7 files changed, 94 insertions(+), 1688 deletions(-) delete mode 100644 paper/defense/manim/render.py delete mode 100644 paper/defense/manim/scenes.py diff --git a/Makefile b/Makefile index edb2a9a..fb347d2 100644 --- a/Makefile +++ b/Makefile @@ -35,7 +35,7 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || .PHONY: help help: - @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines" + @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | manim.render manim.render.all" @echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish" @echo "data.pull data.push | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" @echo "" @@ -201,3 +201,10 @@ count-lines: all: @$(NX) run paper:build + +.PHONY: manim.render manim.render.all +manim.render: + @$(NX) run manim:render + +manim.render.all: + @$(NX) run manim:render-all diff --git a/engine/benchmark.py b/engine/benchmark.py index 7e0afaf..47fb780 100644 --- a/engine/benchmark.py +++ b/engine/benchmark.py @@ -1,12 +1,32 @@ from __future__ import annotations +import os +import subprocess +import sys + import argparse import json import logging -import os from datetime import datetime, UTC from pathlib import Path +# clear stale TPU locks on startup +if os.path.exists("/dev/accel0"): + try: + subprocess.run( + ["rm", "-f", "/tmp/.libtpu_lockfile", "/tmp/libtpu_lockfile"], + stderr=subprocess.DEVNULL, + ) + except: + pass + +try: + import jax + + jax.config.update("jax_threefry_partitionable", True) +except ImportError: + pass + import matplotlib.pyplot as plt import numpy as np import pandas as pd diff --git a/engine/jax/robust.py b/engine/jax/robust.py index e873872..cacf663 100644 --- a/engine/jax/robust.py +++ b/engine/jax/robust.py @@ -28,6 +28,8 @@ try: except ImportError: _JAX_OK = False +_JAX_RUNTIME_OK = True + def _demand_for_actor_jax(prices, mean, std, noise_std, key): """d(p;theta) = max(0, val - price + noise), normalized to sum 100.""" @@ -104,7 +106,9 @@ def select_adversarial_alpha_jax( falls back to a pure-numpy sequential loop when JAX is unavailable so the wrapper can call this function unconditionally. """ - if not _JAX_OK: + global _JAX_RUNTIME_OK + + if not _JAX_OK or not _JAX_RUNTIME_OK: return _fallback( candidates, prices, @@ -117,28 +121,45 @@ def select_adversarial_alpha_jax( reward_profit_weight, ) - k = len(candidates) - key = jax.random.PRNGKey(rng_seed) - keys = jax.random.split(key, k) + try: + k = len(candidates) + key = jax.random.PRNGKey(rng_seed) + keys = jax.random.split(key, k) - rewards = np.asarray( - _reward_batched( - jnp.asarray(candidates, dtype=jnp.float32), - jnp.asarray(prices, dtype=jnp.float32), - float(human_params[0]), - float(human_params[1]), - float(agent_params[0]), - float(agent_params[1]), - float(noise_std), - jnp.asarray(baseline_prices, dtype=jnp.float32), - float(lambda_coi), - float(info_value), - float(reward_profit_weight), - keys, + rewards = np.asarray( + _reward_batched( + jnp.asarray(candidates, dtype=jnp.float32), + jnp.asarray(prices, dtype=jnp.float32), + float(human_params[0]), + float(human_params[1]), + float(agent_params[0]), + float(agent_params[1]), + float(noise_std), + jnp.asarray(baseline_prices, dtype=jnp.float32), + float(lambda_coi), + float(info_value), + float(reward_profit_weight), + keys, + ) + ) + best_idx = int(np.argmin(rewards)) + return float(candidates[best_idx]), rewards + except Exception as exc: + # TPU contention / backend init failures can happen in distributed schedulers. + # Degrade to numpy path for the remainder of the process. + _JAX_RUNTIME_OK = False + print(f"PHANTOM_JAX_FALLBACK: {exc}") + return _fallback( + candidates, + prices, + human_params, + agent_params, + noise_std, + baseline_prices, + lambda_coi, + info_value, + reward_profit_weight, ) - ) - best_idx = int(np.argmin(rewards)) - return float(candidates[best_idx]), rewards def _fallback( diff --git a/engine/train.py b/engine/train.py index 2828db3..aafd02c 100644 --- a/engine/train.py +++ b/engine/train.py @@ -179,8 +179,29 @@ def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]: def main(argv: list[str] | None = None) -> None: + import subprocess import sys + # Ensure data is downloaded + from pathlib import Path + + project_root = Path(__file__).parents[1] + data_dir = project_root / "experiments" / "collected_data" + needs_pull = (not data_dir.exists()) or (not any(data_dir.iterdir())) + if needs_pull: + try: + subprocess.run(["make", "data.pull"], cwd=str(project_root), check=True) + except (subprocess.SubprocessError, OSError) as exc: + sys.path.insert(0, str(project_root)) + try: + from scripts.hf_data import pull + + pull() + except (ImportError, OSError, RuntimeError, ValueError) as fallback_exc: + print( + f"Warning: data.pull failed ({exc}); fallback pull failed ({fallback_exc})" + ) + configure_logging() raw_args = list(sys.argv[1:] if argv is None else argv) run_kind = _probe_run_kind(raw_args) diff --git a/package.json b/package.json index 8590f3c..a47cfe8 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ ], "scripts": { "nx": "nx", + "manim:render": "nx run manim:render", + "manim:render-all": "nx run manim:render-all", "projects": "nx show projects", "graph": "nx graph", "web:dev": "nx run web:dev", diff --git a/paper/defense/manim/render.py b/paper/defense/manim/render.py deleted file mode 100644 index 5f15e1e..0000000 --- a/paper/defense/manim/render.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -import argparse -import subprocess -import sys -from pathlib import Path - -from scenes import SCENE_ORDER - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="Render thesis-defense Manim scenes") - parser.add_argument( - "--quality", - default="qm", - choices=["ql", "qm", "qh", "qk"], - help="Manim quality preset", - ) - parser.add_argument( - "--scene", - action="append", - dest="scenes", - help="Scene name; repeat flag to render many", - ) - parser.add_argument( - "--preview", action="store_true", help="Open video after each render" - ) - parser.add_argument( - "--list", action="store_true", help="List available scenes and exit" - ) - return parser.parse_args() - - -def validate_requested(requested: list[str]) -> list[str]: - missing = [name for name in requested if name not in SCENE_ORDER] - if missing: - choices = ", ".join(SCENE_ORDER) - raise ValueError(f"Unknown scenes: {', '.join(missing)}. Choices: {choices}") - return requested - - -def run_manim(scene_file: Path, scene_name: str, quality: str, preview: bool) -> None: - cmd = [sys.executable, "-m", "manim"] - if preview: - cmd.append("-p") - cmd.extend([f"-{quality}", str(scene_file), scene_name]) - subprocess.run(cmd, cwd=scene_file.parent, check=True) - - -def main() -> int: - args = parse_args() - if args.list: - for scene in SCENE_ORDER: - print(scene) - return 0 - - scenes = validate_requested(args.scenes) if args.scenes else list(SCENE_ORDER) - scene_file = Path(__file__).resolve().parent / "scenes.py" - - try: - for scene_name in scenes: - run_manim( - scene_file=scene_file, - scene_name=scene_name, - quality=args.quality, - preview=args.preview, - ) - except FileNotFoundError: - print( - "manim executable not found. Install Manim in your Python environment.", - file=sys.stderr, - ) - return 2 - except ValueError as exc: - print(str(exc), file=sys.stderr) - return 2 - except subprocess.CalledProcessError as exc: - return exc.returncode - - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/paper/defense/manim/scenes.py b/paper/defense/manim/scenes.py deleted file mode 100644 index 6a74998..0000000 --- a/paper/defense/manim/scenes.py +++ /dev/null @@ -1,1581 +0,0 @@ -from __future__ import annotations - -from typing import Iterable - -import numpy as np -from manim import ( - Axes, - Arrow, - BarChart, - BLUE_D, - Circle, - Create, - CurvedArrow, - DashedLine, - DecimalNumber, - Dot, - DOWN, - FadeIn, - FadeOut, - GREEN_C, - GREY_B, - LaggedStart, - LEFT, - Line, - MathTex, - Matrix, - NumberLine, - ORANGE, - Rectangle, - RED_C, - RIGHT, - RoundedRectangle, - Scene, - SurroundingRectangle, - Text, - Transform, - UP, - ValueTracker, - VGroup, - Write, - always_redraw, - config, -) - -P_MIN = 80.0 -P_MAX = 160.0 -LIGHT_BG = "#F8F8F4" -INK = "#1E1E1E" -AXIS_INK = "#2C2C2C" -HIGHLIGHT = "#8F5F00" - -config.background_color = LIGHT_BG -Text.set_default(color=INK) -MathTex.set_default(color=INK) -Line.set_default(color=AXIS_INK) -Arrow.set_default(color=AXIS_INK) -CurvedArrow.set_default(color=AXIS_INK) -DashedLine.set_default(color=AXIS_INK) - - -def normal_pdf(x: float, mu: float, sigma: float) -> float: - z = (x - mu) / sigma - return float(np.exp(-0.5 * z * z) / (sigma * np.sqrt(2.0 * np.pi))) - - -def scene_title(text: str) -> Text: - return Text(text, font_size=44, weight="BOLD", color=INK).to_edge(UP) - - -def card( - label: str, - color: str = BLUE_D, - width: float = 3.3, - height: float = 1.15, - font_size: float = 24, -) -> VGroup: - box = RoundedRectangle(corner_radius=0.15, width=width, height=height) - box.set_stroke(color=color, width=2.0) - box.set_fill(color=color, opacity=0.12) - text = Text(label, font_size=font_size).move_to(box.get_center()) - return VGroup(box, text) - - -def to_matrix( - values: Iterable[Iterable[float]], - title: str, - color: str, - header_buff: float = 0.28, -) -> VGroup: - mat = Matrix( - [[f"{v:.2f}" for v in row] for row in values], h_buff=1.15, v_buff=0.75 - ) - header = Text(title, font_size=25, weight="BOLD", color=color).next_to( - mat, UP, buff=header_buff - ) - frame = SurroundingRectangle(mat, color=color, buff=0.2) - return VGroup(header, frame, mat) - - -def rank_from_scale(scale: int) -> str: - clamped = max(1, min(scale, 10)) - return "A" if clamped == 1 else str(clamped) - - -def actor_face_card( - rank: str, - role: str, - accent: str, - width: float = 1.6, - height: float = 2.25, - show_role: bool = True, -) -> VGroup: - frame = RoundedRectangle(corner_radius=0.1, width=width, height=height) - frame.set_stroke(color=AXIS_INK, width=2.0) - frame.set_fill(color="#FFFFFF", opacity=1.0) - - top_rank = Text(rank, font_size=30, color=accent).move_to( - frame.get_corner(UP + LEFT) + RIGHT * 0.2 + DOWN * 0.22 - ) - bottom_rank = ( - Text(rank, font_size=30, color=accent) - .rotate(np.pi) - .move_to(frame.get_corner(DOWN + RIGHT) + LEFT * 0.2 + UP * 0.22) - ) - center_rank = Text(rank, font_size=56, weight="BOLD", color=accent).move_to( - frame.get_center() + UP * 0.03 - ) - - parts = [frame, top_rank, bottom_rank, center_rank] - if show_role: - role_label = Text(role, font_size=18, color=GREY_B).next_to( - frame, DOWN, buff=0.08 - ) - parts.append(role_label) - return VGroup(*parts) - - -def product_suit_card( - suit: str, - scale: int, - accent: str, - width: float = 1.86, - height: float = 1.04, - show_label: bool = False, -) -> tuple[VGroup, Text]: - frame = RoundedRectangle(corner_radius=0.08, width=width, height=height) - frame.set_stroke(color=AXIS_INK, width=2.0) - frame.set_fill(color="#FFFFFF", opacity=1.0) - - suit_left = Text(suit, font_size=28, color=accent).move_to( - frame.get_left() + RIGHT * 0.22 - ) - suit_right = Text(suit, font_size=28, color=accent).move_to( - frame.get_right() + LEFT * 0.22 - ) - scale_text = Text( - rank_from_scale(scale), - font_size=40, - weight="BOLD", - color=accent, - ).move_to(frame.get_center()) - - parts = [frame, suit_left, suit_right, scale_text] - if show_label: - scale_label = Text("scale", font_size=14, color=GREY_B).next_to( - frame, DOWN, buff=0.04 - ) - parts.append(scale_label) - return VGroup(*parts), scale_text - - -def private_valuation_card(value: int, show_label: bool = False) -> VGroup: - frame = RoundedRectangle(corner_radius=0.08, width=1.86, height=1.04) - frame.set_stroke(color=AXIS_INK, width=2.0) - frame.set_fill(color="#FFFFFF", opacity=1.0) - - rank = Text( - rank_from_scale(value), font_size=40, weight="BOLD", color=GREEN_C - ).move_to(frame.get_center()) - left_tag = Text("v", font_size=28, color=INK).move_to( - frame.get_left() + RIGHT * 0.22 - ) - right_tag = Text("*", font_size=28, color=INK).move_to( - frame.get_right() + LEFT * 0.22 - ) - - parts = [frame, left_tag, right_tag, rank] - if show_label: - title = Text("private value", font_size=14, color=GREY_B).next_to( - frame, DOWN, buff=0.04 - ) - parts.append(title) - return VGroup(*parts) - - -class DefenseOpening(Scene): - def construct(self) -> None: - title = scene_title("PHANTOM Thesis Defense") - subtitle = Text( - "A mechanism-level defense for dynamic pricing under agentic traffic", - font_size=27, - color=GREY_B, - ).next_to(title, DOWN, buff=0.35) - - roadmap = VGroup( - Text("1) Define pricing power from first principles", font_size=30), - Text("2) Show why agent saturation breaks it", font_size=30), - Text( - "3) Build a control loop from behavior to robust policy", font_size=30 - ), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.28) - roadmap.next_to(subtitle, DOWN, buff=0.75).align_to(subtitle, LEFT) - - self.play(Write(title), FadeIn(subtitle, shift=UP * 0.2)) - self.play( - LaggedStart( - *[FadeIn(item, shift=RIGHT * 0.25) for item in roadmap], lag_ratio=0.18 - ) - ) - - dist_axes = Axes( - x_range=[-6, 6, 2], - y_range=[0.0, 0.2, 0.05], - x_length=2.7, - y_length=1.5, - tips=False, - axis_config={"stroke_width": 1.8, "color": AXIS_INK}, - ) - dist_h = dist_axes.plot( - lambda x: normal_pdf(x, -1.9, 1.6), - x_range=[-6, 6], - color=BLUE_D, - stroke_width=4, - ) - dist_a = dist_axes.plot( - lambda x: normal_pdf(x, 1.8, 1.8), - x_range=[-6, 6], - color=RED_C, - stroke_width=4, - ) - dist_block = VGroup( - dist_axes, - dist_h, - dist_a, - Text("behavior gap g", font_size=16, color=GREY_B).next_to( - dist_axes, DOWN, buff=0.03 - ), - ) - - tail_axes = Axes( - x_range=[0, 1, 0.2], - y_range=[0, 1, 0.2], - x_length=2.7, - y_length=1.5, - tips=False, - axis_config={"stroke_width": 1.8, "color": AXIS_INK}, - ) - tail_n1 = tail_axes.plot( - lambda x: (1 - x) ** 1, - x_range=[0, 1], - color=GREEN_C, - stroke_width=4, - ) - tail_n8 = tail_axes.plot( - lambda x: (1 - x) ** 8, - x_range=[0, 1], - color=HIGHLIGHT, - stroke_width=4, - ) - tail_block = VGroup( - tail_axes, - tail_n1, - tail_n8, - Text("order-statistic tail", font_size=16, color=GREY_B).next_to( - tail_axes, DOWN, buff=0.03 - ), - ) - - control_eq = MathTex( - r"\hat\alpha(\tau')\Rightarrow\pi^*", - font_size=34, - color=HIGHLIGHT, - ) - control_box = SurroundingRectangle(control_eq, color=HIGHLIGHT, buff=0.12) - control_block = VGroup(control_box, control_eq) - - preview = VGroup(dist_block, tail_block, control_block).arrange( - RIGHT, buff=0.45 - ) - preview.next_to(roadmap, DOWN, buff=0.58) - preview_caption = Text("Math flow preview", font_size=21, color=GREY_B).next_to( - preview, UP, buff=0.08 - ) - - f_arrow_1 = Arrow(dist_block.get_right(), tail_block.get_left(), buff=0.08) - f_arrow_2 = Arrow(tail_block.get_right(), control_block.get_left(), buff=0.08) - - self.play(FadeIn(preview_caption, shift=UP * 0.1)) - self.play(FadeIn(dist_block), FadeIn(tail_block), FadeIn(control_block)) - self.play(FadeIn(f_arrow_1), FadeIn(f_arrow_2)) - self.wait(0.9) - - -class CardMarketAnalogyScene(Scene): - def construct(self) -> None: - title = scene_title("Card Analogy: Platform, Customer, Agent") - self.play(Write(title)) - - subtitle = Text( - "K = platform, Q = customer, J = search agent, suit cards = products", - font_size=20, - color=GREY_B, - ).next_to(title, DOWN, buff=0.16) - self.play(FadeIn(subtitle, shift=UP * 0.05)) - - king = actor_face_card( - rank="K", role="platform", accent=ORANGE, show_role=False - ) - king.move_to(LEFT * 5.35 + DOWN * 0.35) - - queen_home = RIGHT * 3.2 + DOWN * 0.28 - queen = actor_face_card( - rank="Q", role="customer", accent=BLUE_D, show_role=False - ) - queen.move_to(queen_home) - - valuation = private_valuation_card(value=5).next_to(queen, RIGHT, buff=0.35) - - specs = [ - ("C", INK, 4), - ("H", RED_C, 6), - ("S", INK, 5), - ("D", RED_C, 3), - ] - scales = [initial for _, _, initial in specs] - products = VGroup() - scale_tokens: list[Text] = [] - for suit, color, initial in specs: - product_card, token = product_suit_card( - suit=suit, scale=initial, accent=color - ) - products.add(product_card) - scale_tokens.append(token) - - products.arrange(DOWN, buff=0.15).move_to(LEFT * 1.75 + DOWN * 0.55) - - actor_link = Arrow( - king.get_right(), - products.get_left(), - buff=0.15, - color=HIGHLIGHT, - stroke_width=3.6, - ) - - self.play( - FadeIn(king, shift=RIGHT * 0.2), - FadeIn(products, shift=UP * 0.15), - FadeIn(queen, shift=LEFT * 0.2), - FadeIn(valuation, shift=LEFT * 0.2), - ) - self.play(FadeIn(actor_link)) - - stage = Text( - "Stage 1: queen browses directly and visited products rise in scale.", - font_size=21, - color=GREY_B, - ).to_edge(DOWN) - self.play(FadeIn(stage, shift=UP * 0.08)) - - direct_visits = [1, 2] - for idx in direct_visits: - target = products[idx] - demand_box = SurroundingRectangle(target, color=BLUE_D, buff=0.06) - king_box = SurroundingRectangle(king[0], color=HIGHLIGHT, buff=0.07) - - self.play( - queen.animate.move_to(target.get_right() + RIGHT * 0.9), - run_time=0.7, - ) - self.play(Create(demand_box), run_time=0.2) - - scales[idx] = min(10, scales[idx] + 2) - new_scale = Text( - rank_from_scale(scales[idx]), - font_size=40, - weight="BOLD", - color=specs[idx][1], - ).move_to(scale_tokens[idx]) - self.play( - Create(king_box), - Transform(scale_tokens[idx], new_scale), - run_time=0.5, - ) - self.play(FadeOut(king_box), FadeOut(demand_box), run_time=0.18) - - self.play(queen.animate.move_to(queen_home), run_time=0.7) - - stage_two = Text( - "Stage 2: queen hires jack to search every card before deciding.", - font_size=21, - color=GREY_B, - ).to_edge(DOWN) - self.play(Transform(stage, stage_two)) - - jack = actor_face_card( - rank="J", role="agent", accent=RED_C, show_role=False - ).scale(0.95) - jack.next_to(queen, LEFT, buff=0.35) - hire_arrow = Arrow( - queen.get_left(), - jack.get_right(), - buff=0.08, - color=HIGHLIGHT, - stroke_width=2.6, - ) - self.play(FadeIn(jack, shift=RIGHT * 0.16), FadeIn(hire_arrow)) - self.play(FadeOut(hire_arrow), run_time=0.2) - - for idx, target in enumerate(products): - demand_box = SurroundingRectangle(target, color=RED_C, buff=0.05) - king_box = SurroundingRectangle(king[0], color=HIGHLIGHT, buff=0.07) - - self.play( - jack.animate.move_to(target.get_right() + RIGHT * 0.62), - run_time=0.32, - ) - self.play(Create(demand_box), run_time=0.17) - - scales[idx] = min(10, scales[idx] + 1) - new_scale = Text( - rank_from_scale(scales[idx]), - font_size=40, - weight="BOLD", - color=specs[idx][1], - ).move_to(scale_tokens[idx]) - self.play( - Create(king_box), Transform(scale_tokens[idx], new_scale), run_time=0.38 - ) - self.play( - FadeOut(king_box), - FadeOut(demand_box), - run_time=0.15, - ) - - self.play(jack.animate.next_to(queen, LEFT, buff=0.35), run_time=0.55) - - report_arrow = Arrow( - jack.get_right(), - queen.get_left(), - buff=0.08, - color=GREEN_C, - stroke_width=2.6, - ) - self.play(FadeIn(report_arrow)) - - best_idx = int(np.argmin(scales)) - best_card = products[best_idx] - choice_box = SurroundingRectangle(best_card, color=GREEN_C, buff=0.07) - stage_three = Text( - "Decision rule: buy when private value v* exceeds shown scale.", - font_size=21, - color=GREY_B, - ).to_edge(DOWN) - - self.play( - Transform(stage, stage_three), - queen.animate.move_to(best_card.get_right() + RIGHT * 0.9), - Create(choice_box), - run_time=0.95, - ) - self.play( - FadeOut(jack), - FadeOut(report_arrow), - FadeOut(actor_link), - FadeOut(subtitle), - ) - self.wait(1.0) - - -class COIFirstPrinciplesScene(Scene): - def construct(self) -> None: - title = scene_title("Cost of Information from First Principles") - self.play(Write(title)) - - setup = VGroup( - MathTex(r"P\sim\pi(\tau)", font_size=44), - MathTex(r"\underline p=\text{reservation price}", font_size=38), - MathTex(r"M=P-\underline p", font_size=46, color=HIGHLIGHT), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.22) - setup.to_edge(LEFT).shift(UP * 0.55) - - self.play( - LaggedStart( - *[FadeIn(line, shift=RIGHT * 0.2) for line in setup], lag_ratio=0.2 - ) - ) - - floor_x = 86.0 - mean_x = 116.0 - axes = ( - Axes( - x_range=[80, 160, 10], - y_range=[0.0, 0.04, 0.01], - x_length=7.0, - y_length=3.3, - tips=False, - axis_config={"stroke_width": 2, "color": AXIS_INK}, - ) - .to_edge(RIGHT) - .shift(DOWN * 0.2) - ) - density = axes.plot( - lambda x: normal_pdf(x, mean_x, 12.0), - x_range=[80, 160], - color=BLUE_D, - stroke_width=6, - ) - floor_line = Line( - axes.c2p(floor_x, 0.0), - axes.c2p(floor_x, 0.036), - color=ORANGE, - stroke_width=4, - ) - mean_line = Line( - axes.c2p(mean_x, 0.0), - axes.c2p(mean_x, 0.036), - color=GREEN_C, - stroke_width=4, - ) - floor_tag = ( - MathTex(r"\underline p", color=ORANGE) - .scale(0.72) - .next_to(floor_line, UP, buff=0.06) - ) - mean_tag = ( - MathTex(r"\mathbb{E}[P]", color=GREEN_C) - .scale(0.72) - .next_to(mean_line, UP, buff=0.06) - ) - coi_span = Line( - axes.c2p(floor_x, 0.032), - axes.c2p(mean_x, 0.032), - color=HIGHLIGHT, - stroke_width=6, - ) - coi_tag = Text( - "average information rent", font_size=18, color=HIGHLIGHT - ).next_to(coi_span, UP, buff=0.05) - - chart = VGroup( - axes, - density, - floor_line, - mean_line, - floor_tag, - mean_tag, - coi_span, - coi_tag, - ) - - self.play(FadeIn(axes), FadeIn(density)) - self.play( - FadeIn(floor_line), FadeIn(mean_line), FadeIn(floor_tag), FadeIn(mean_tag) - ) - self.play(FadeIn(coi_span), FadeIn(coi_tag)) - self.play( - FadeOut(setup, shift=LEFT * 0.15), - chart.animate.scale(0.82).to_edge(RIGHT).shift(UP * 0.6), - ) - - coi_left = MathTex(r"\mathrm{COI}:=\mathbb{E}[", font_size=42) - coi_mid = MathTex(r"M", font_size=42) - coi_right = MathTex(r"]", font_size=42) - coi_eq = VGroup(coi_left, coi_mid, coi_right).arrange(RIGHT, buff=0.04) - coi_eq.to_edge(LEFT).shift(UP * 0.45) - - self.play(Write(coi_left), FadeIn(coi_mid, shift=UP * 0.05), Write(coi_right)) - - expanded_mid = MathTex(r"P-\underline p", font_size=42) - expanded_mid.move_to(coi_mid, aligned_edge=LEFT) - self.play( - Transform(coi_mid, expanded_mid), - coi_right.animate.next_to(coi_mid, RIGHT, buff=0.04), - ) - self.play(coi_eq.animate.set_color(HIGHLIGHT)) - - survival = MathTex( - r"\mathrm{COI}=\int_{\underline p}^{\bar p}(1-F_\pi(p))\,dp", - font_size=33, - color=GREY_B, - ).next_to(coi_eq, DOWN, aligned_edge=LEFT, buff=0.2) - self.play(Write(survival)) - - identity_1 = MathTex( - r"\mathbb E[X]=\int_0^{\infty}\mathbb P(X>u)\,du\quad (X\ge 0)", - font_size=31, - color=GREY_B, - ).next_to(survival, DOWN, aligned_edge=LEFT, buff=0.2) - identity_2 = MathTex( - r"X=P-\underline p,\;u=p-\underline p\Rightarrow\int_{\underline p}^{\bar p}(1-F_\pi(p))\,dp", - font_size=31, - color=GREY_B, - ).next_to(identity_1, DOWN, aligned_edge=LEFT, buff=0.14) - self.play(Write(identity_1)) - self.play(Write(identity_2)) - self.wait(1.0) - - -class COIOrderStatisticProofScene(Scene): - def construct(self) -> None: - title = scene_title("Why COI Erodes with Agent Saturation") - self.play(Write(title)) - - key = MathTex(r"p_{(1)}=\min(p_1,\ldots,p_N)", font_size=42, color=HIGHLIGHT) - key.next_to(title, DOWN, buff=0.35) - self.play(Write(key)) - - number_line = NumberLine( - x_range=[P_MIN, P_MAX, 10], - length=9.8, - color=AXIS_INK, - include_numbers=True, - decimal_number_config={"num_decimal_places": 0, "color": INK}, - ).shift(DOWN * 1.5) - floor_marker = Line( - number_line.n2p(P_MIN), - number_line.n2p(P_MIN) + UP * 0.85, - color=ORANGE, - stroke_width=5, - ) - floor_label = MathTex(r"\underline p", color=ORANGE).next_to( - floor_marker, UP, buff=0.05 - ) - self.play(FadeIn(number_line), FadeIn(floor_marker), FadeIn(floor_label)) - - rng = np.random.default_rng(17) - current_group: VGroup | None = None - current_info: VGroup | None = None - - for n in [1, 3, 8, 20]: - draws = np.sort(rng.beta(2.4, 2.1, size=n) * (P_MAX - P_MIN) + P_MIN) - dots = VGroup( - *[ - Dot(number_line.n2p(float(v)), radius=0.06, color=BLUE_D) - for v in draws - ] - ) - min_dot = Dot(number_line.n2p(float(draws[0])), radius=0.09, color=RED_C) - min_tag = ( - MathTex(r"p_{(1)}", color=RED_C) - .scale(0.65) - .next_to(min_dot, UP, buff=0.08) - ) - step_group = VGroup(dots, min_dot, min_tag) - - info = VGroup( - Text(f"N = {n}", font_size=28), - Text(f"min observed = {draws[0]:.2f}", font_size=24), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.12) - info.to_edge(LEFT).shift(UP * 0.55) - info_box = VGroup(SurroundingRectangle(info, color=GREY_B, buff=0.18), info) - - if current_group is None: - self.play(FadeIn(step_group), FadeIn(info_box)) - else: - self.play( - FadeOut(current_group), - FadeOut(current_info), - FadeIn(step_group), - FadeIn(info_box), - ) - current_group = step_group - current_info = info_box - self.wait(0.4) - - p1 = MathTex( - r"\mathbb{P}(p_{(1)}>t)=\mathbb{P}(p_1>t,\ldots,p_N>t)", font_size=36 - ) - p2 = MathTex(r"\mathbb{P}(p_{(1)}>t)=[1-F(t)]^N", font_size=42, color=HIGHLIGHT) - prob_group = VGroup(p1, p2).arrange(DOWN, aligned_edge=LEFT, buff=0.16) - prob_group.to_edge(RIGHT).shift(UP * 0.75) - - self.play(Write(p1)) - self.play(Write(p2)) - - cleanup_items: list = [key, number_line, floor_marker, floor_label] - if current_group is not None: - cleanup_items.append(current_group) - if current_info is not None: - cleanup_items.append(current_info) - self.play( - FadeOut(VGroup(*cleanup_items), shift=DOWN * 0.12), - prob_group.animate.shift(UP * 0.26), - ) - - tail_axes = ( - Axes( - x_range=[0, 1, 0.2], - y_range=[0, 1, 0.2], - x_length=4.1, - y_length=2.45, - tips=False, - axis_config={"stroke_width": 2, "color": AXIS_INK}, - ) - .to_edge(RIGHT) - .shift(DOWN * 1.0 + LEFT * 0.2) - ) - curve_1 = tail_axes.plot( - lambda x: (1 - x) ** 1, x_range=[0, 1], color=BLUE_D, stroke_width=4 - ) - curve_4 = tail_axes.plot( - lambda x: (1 - x) ** 4, x_range=[0, 1], color=GREEN_C, stroke_width=4 - ) - curve_16 = tail_axes.plot( - lambda x: (1 - x) ** 16, x_range=[0, 1], color=RED_C, stroke_width=4 - ) - c_labels = VGroup( - Text("N=1", font_size=18, color=BLUE_D), - Text("N=4", font_size=18, color=GREEN_C), - Text("N=16", font_size=18, color=RED_C), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.08) - c_labels.next_to(tail_axes, UP, buff=0.08).align_to(tail_axes, RIGHT) - tail_x = MathTex(r"F(t)", font_size=24).next_to(tail_axes, DOWN, buff=0.05) - tail_y = MathTex(r"[1-F(t)]^N", font_size=24).next_to( - tail_axes, LEFT, buff=0.05 - ) - - self.play(FadeIn(tail_axes), Create(curve_1), Create(curve_4), Create(curve_16)) - self.play(FadeIn(c_labels), FadeIn(tail_x), FadeIn(tail_y)) - - e1 = MathTex( - r"\mathbb{E}[p_{(1)}]=\underline p+\int_{\underline p}^{\bar p}[1-F(t)]^N\,dt", - font_size=32, - ) - e2 = MathTex( - r"X:=p_{(1)}-\underline p\ge 0,\quad \mathbb E[X]=\int_0^{\infty}\mathbb P(X>u)\,du", - font_size=27, - color=GREY_B, - ) - e3 = MathTex( - r"\mathbb P(X>u)=\mathbb P\!\left(p_{(1)}>\underline p+u\right)=[1-F(\underline p+u)]^N", - font_size=27, - color=GREY_B, - ) - e4 = MathTex( - r"0\le[1-F(t)]^N\le1,\quad [1-F(t)]^N\to0\ \text{for } t>\underline p", - font_size=27, - color=GREY_B, - ) - e5 = MathTex( - r"\Rightarrow\ \lim_{N\to\infty}(\mathbb{E}[p_{(1)}]-\underline p)=0", - font_size=38, - color=HIGHLIGHT, - ) - proof_block = VGroup(e1, e2, e3, e4, e5).arrange( - DOWN, aligned_edge=LEFT, buff=0.12 - ) - proof_block.to_edge(LEFT).shift(UP * 0.45) - self.play(Write(e1)) - self.play(Write(e2)) - self.play(Write(e3)) - self.play(Write(e4)) - self.play(Write(e5)) - - conclusion = Text( - "As independent query count grows, realizable markup collapses.", - font_size=24, - color=GREY_B, - ) - conclusion.to_edge(DOWN) - self.play(FadeIn(conclusion, shift=UP * 0.1)) - self.wait(1.1) - - -class BehaviorKernelConstructionScene(Scene): - def construct(self) -> None: - title = scene_title("From Session Paths to Transition Kernels") - self.play(Write(title)) - - traj_h = Text( - "human: start -> view -> detail -> cart -> purchase", - font_size=26, - color=GREEN_C, - ) - traj_a = Text( - "agent: start -> view -> detail -> view -> detail", - font_size=26, - color=RED_C, - ) - trajectories = VGroup(traj_h, traj_a).arrange( - DOWN, aligned_edge=LEFT, buff=0.16 - ) - trajectories.next_to(title, DOWN, buff=0.45).align_to(title, LEFT) - self.play( - LaggedStart( - *[FadeIn(t, shift=RIGHT * 0.2) for t in trajectories], lag_ratio=0.25 - ) - ) - - mle = MathTex( - r"\hat P(s'\mid s)=\frac{N(s,s')}{\sum_k N(s,k)}", - font_size=40, - color=HIGHLIGHT, - ) - mle.next_to(trajectories, DOWN, aligned_edge=LEFT, buff=0.28) - self.play(Write(mle)) - - counts = to_matrix( - ( - (0.00, 8.00, 0.00, 0.00), - (0.00, 2.00, 5.00, 1.00), - (0.00, 3.00, 2.00, 4.00), - (0.00, 1.00, 0.00, 6.00), - ), - "transition counts N(s,s')", - color=BLUE_D, - ) - probs = to_matrix( - ( - (0.00, 1.00, 0.00, 0.00), - (0.00, 0.25, 0.62, 0.13), - (0.00, 0.33, 0.22, 0.45), - (0.00, 0.14, 0.00, 0.86), - ), - "normalized kernel T", - color=GREEN_C, - header_buff=0.4, - ) - mats = ( - VGroup(counts, probs) - .arrange(RIGHT, buff=0.95) - .scale(0.92) - .to_edge(DOWN) - .shift(UP * 0.34) - ) - arrow = Arrow(counts.get_right(), probs.get_left(), buff=0.18, stroke_width=4) - arrow_tag = Text("row normalize", font_size=18, color=GREY_B).next_to( - arrow, UP, buff=0.08 - ) - kernel_arrow = Arrow( - mle.get_bottom(), - mats.get_top() + UP * 0.05, - buff=0.1, - color=GREY_B, - stroke_width=3.2, - ) - self.play( - FadeIn(mats, shift=UP * 0.12), - FadeIn(arrow), - FadeIn(arrow_tag), - FadeIn(kernel_arrow, shift=DOWN * 0.06), - ) - self.play( - FadeOut(mle, shift=UP * 0.08), - FadeOut(kernel_arrow, shift=DOWN * 0.08), - ) - - note = Text( - "Kernel shape is the compact behavioral signature used downstream.", - font_size=21, - color=GREY_B, - ) - note.next_to(mats, DOWN, buff=0.16) - self.play(FadeIn(note, shift=UP * 0.1)) - self.wait(1.0) - - -class SeparabilitySignalScene(Scene): - def construct(self) -> None: - title = Text( - "Separability into a Control Signal", - font_size=40, - weight="BOLD", - color=INK, - ).to_edge(UP, buff=0.18) - self.play(Write(title)) - - human = to_matrix( - ( - (0.05, 0.70, 0.20, 0.05), - (0.05, 0.20, 0.60, 0.15), - (0.10, 0.25, 0.30, 0.35), - (0.00, 0.00, 0.00, 1.00), - ), - "human centroid T_H", - color=GREEN_C, - ) - agent = to_matrix( - ( - (0.03, 0.82, 0.12, 0.03), - (0.06, 0.55, 0.21, 0.18), - (0.08, 0.48, 0.14, 0.30), - (0.00, 0.00, 0.00, 1.00), - ), - "agent centroid T_A", - color=RED_C, - ) - kernels = VGroup(human, agent).arrange(RIGHT, buff=0.95).shift(UP * 0.45) - self.play(FadeIn(kernels, shift=UP * 0.15)) - - self.play( - kernels.animate.scale(0.6) - .arrange(DOWN, aligned_edge=LEFT, buff=0.24) - .to_edge(LEFT) - .shift(UP * 0.18) - ) - - d_h = MathTex(r"\Delta_H=D_{KL}(\hat T'\parallel\bar T_H)", font_size=36) - d_a = MathTex(r"\Delta_A=D_{KL}(\hat T'\parallel\bar T_A)", font_size=36) - gap = MathTex(r"g=\Delta_H-\Delta_A", font_size=44, color=HIGHLIGHT) - alpha = MathTex(r"\hat\alpha(\tau')=\sigma(\beta g)", font_size=40) - eqs = VGroup(d_h, d_a, gap, alpha).arrange(DOWN, aligned_edge=LEFT, buff=0.2) - eqs.to_edge(RIGHT).shift(UP * 0.38) - self.play(LaggedStart(*[Write(eq) for eq in eqs], lag_ratio=0.18)) - - self.play( - eqs.animate.scale(0.66).next_to(kernels, DOWN, aligned_edge=LEFT, buff=0.16) - ) - - mu_h, sigma_h = -3.35, 2.67 - mu_a, sigma_a = 1.65, 2.83 - axis = ( - Axes( - x_range=[-10, 10, 2], - y_range=[0.0, 0.18, 0.03], - x_length=6.8, - y_length=3.7, - tips=False, - axis_config={"stroke_width": 2, "color": AXIS_INK}, - ) - .to_edge(RIGHT) - .shift(DOWN * 0.75 + LEFT * 0.15) - ) - x_tag = MathTex(r"g=\Delta_H-\Delta_A", font_size=30).next_to( - axis, DOWN, buff=0.15 - ) - - human_curve = axis.plot( - lambda x: normal_pdf(x, mu_h, sigma_h), - x_range=[-10, 10], - color=BLUE_D, - stroke_width=6, - ) - agent_curve = axis.plot( - lambda x: normal_pdf(x, mu_a, sigma_a), - x_range=[-10, 10], - color=RED_C, - stroke_width=6, - ) - h_label = Text("human", font_size=22, color=BLUE_D).move_to( - axis.c2p(-6.4, 0.108) - ) - a_label = Text("agent", font_size=22, color=RED_C).move_to(axis.c2p(5.8, 0.095)) - - boundary = DashedLine( - axis.c2p(0.0, 0.0), axis.c2p(0.0, 0.165), color=GREY_B, stroke_width=2 - ) - boundary_tag = Text("decision boundary", font_size=17, color=GREY_B).next_to( - boundary, UP, buff=0.08 - ) - boundary_tag.shift(RIGHT * 0.8) - - g_obs = 1.6 - g_line = Line( - axis.c2p(g_obs, 0.0), - axis.c2p(g_obs, 0.145), - color=HIGHLIGHT, - stroke_width=4, - ) - g_dot = Dot(axis.c2p(g_obs, 0.145), color=HIGHLIGHT, radius=0.06) - g_tag = ( - MathTex(r"g_{obs}", color=HIGHLIGHT) - .scale(0.72) - .next_to(g_dot, UP, buff=0.04) - ) - - self.play(FadeIn(axis), FadeIn(x_tag)) - self.play(Create(human_curve), Create(agent_curve)) - self.play( - FadeIn(h_label), FadeIn(a_label), FadeIn(boundary), FadeIn(boundary_tag) - ) - self.play(FadeIn(g_line), FadeIn(g_dot), FadeIn(g_tag)) - - hint = Text( - "Positive gap shifts score toward agent traffic.", - font_size=20, - color=GREY_B, - ) - hint.next_to(x_tag, DOWN, buff=0.1) - hint.match_x(axis) - self.play(FadeIn(hint, shift=UP * 0.1)) - self.wait(1.0) - - -class ContaminationGeneratorScene(Scene): - def construct(self) -> None: - title = scene_title("Contamination Generator G(alpha)") - self.play(Write(title)) - - human_pool = card("labeled human sessions", color=BLUE_D, width=4.1) - agent_pool = card("synthetic agent sessions", color=RED_C, width=4.1) - mixed_pool = card("mixed batch for training", color=HIGHLIGHT, width=4.4) - - top = ( - VGroup(human_pool, agent_pool) - .arrange(RIGHT, buff=1.1) - .next_to(title, DOWN, buff=0.55) - ) - mixed_pool.next_to(top, DOWN, buff=1.25) - - a1 = Arrow( - human_pool.get_bottom(), - mixed_pool.get_top() + LEFT * 1.0, - buff=0.1, - stroke_width=4, - ) - a2 = Arrow( - agent_pool.get_bottom(), - mixed_pool.get_top() + RIGHT * 1.0, - buff=0.1, - stroke_width=4, - ) - - self.play(FadeIn(top, shift=UP * 0.12), FadeIn(mixed_pool, shift=UP * 0.12)) - self.play(FadeIn(a1), FadeIn(a2)) - - flow = VGroup(top, mixed_pool, a1, a2) - self.play(flow.animate.scale(0.68).to_edge(LEFT).shift(UP * 0.58)) - - alpha_tracker = ValueTracker(0.18) - bar_outline = Rectangle( - width=7.0, height=0.46, stroke_color=AXIS_INK, stroke_width=2 - ).move_to(RIGHT * 0.55 + DOWN * 0.12) - base_h = Rectangle( - width=7.0, height=0.4, stroke_width=0, fill_color=BLUE_D, fill_opacity=0.35 - ).move_to(bar_outline) - - def make_agent_fill() -> Rectangle: - width = max(0.02, 7.0 * alpha_tracker.get_value()) - rect = Rectangle( - width=width, - height=0.4, - stroke_width=0, - fill_color=RED_C, - fill_opacity=0.68, - ) - rect.move_to(bar_outline.get_right() + LEFT * (width / 2.0)) - return rect - - agent_fill = always_redraw(make_agent_fill) - alpha_label = Text("alpha =", font_size=24).next_to( - bar_outline, DOWN, buff=0.16 - ) - alpha_value = always_redraw( - lambda: DecimalNumber( - alpha_tracker.get_value(), - num_decimal_places=2, - font_size=28, - color=HIGHLIGHT, - ).next_to(alpha_label, RIGHT, buff=0.1) - ) - left_tag = Text("human share (1-alpha)", font_size=18, color=BLUE_D).next_to( - bar_outline, LEFT, buff=0.15 - ) - right_tag = Text("agent share (alpha)", font_size=18, color=RED_C).next_to( - bar_outline, RIGHT, buff=0.15 - ) - - self.play(FadeIn(bar_outline), FadeIn(base_h), FadeIn(agent_fill)) - self.play( - FadeIn(alpha_label), - FadeIn(alpha_value), - FadeIn(left_tag), - FadeIn(right_tag), - ) - - mix_eq = MathTex( - r"\hat Q(p\mid\tau')=(1-\alpha)\,\hat Q_H(p\mid\tau')+\alpha\,\hat Q_A(p\mid\tau')", - font_size=31, - ).next_to(bar_outline, DOWN, buff=0.45) - interval = MathTex( - r"\alpha\in[\alpha_0-\epsilon_\alpha,\,\alpha_0+\epsilon_\alpha]", - font_size=31, - color=GREY_B, - ) - interval.next_to(mix_eq, DOWN, buff=0.2) - self.play(Write(mix_eq), Write(interval)) - - self.play(alpha_tracker.animate.set_value(0.32), run_time=1.2) - self.play(alpha_tracker.animate.set_value(0.55), run_time=1.2) - self.play(alpha_tracker.animate.set_value(0.24), run_time=1.1) - self.wait(0.9) - - -class RobustControlScene(Scene): - def construct(self) -> None: - title = scene_title("Distributionally Robust Control Layer") - self.play(Write(title)) - - objective = MathTex( - r"\pi^*=\arg\max_\pi\min_{Q\in\mathcal U_\epsilon}\mathbb E_{d\sim Q}[R(p,d)-\lambda\,COI_{leak}(p,\tau') ]", - font_size=31, - ).next_to(title, DOWN, buff=0.4) - reward = MathTex( - r"r_t=R(p_t,d_t)-\lambda f(\tau_t')c_{info},\quad d_t\sim Q(\cdot\mid p_t,\tau_t')", - font_size=31, - color=HIGHLIGHT, - ) - reward.next_to(objective, DOWN, buff=0.25) - demand_link = MathTex( - r"\hat Q(p_t,\tau_t')=\mathbb E_Q[d_t\mid p_t,\tau_t']", - font_size=29, - color=GREY_B, - ).next_to(reward, DOWN, buff=0.16) - self.play(Write(objective), Write(reward), Write(demand_link)) - - plane = ( - Axes( - x_range=[-3, 3, 1], - y_range=[-3, 3, 1], - x_length=5.6, - y_length=5.6, - tips=False, - axis_config={"stroke_width": 1.8, "color": AXIS_INK}, - ) - .to_edge(LEFT) - .shift(DOWN * 0.55) - ) - center = Dot(plane.c2p(0, 0), color=BLUE_D, radius=0.08) - center_tag = ( - MathTex(r"\hat P_N", color=BLUE_D) - .scale(0.75) - .next_to(center, UP, buff=0.07) - ) - ball = Circle(radius=1.75, color=HIGHLIGHT, stroke_width=3).move_to(center) - ball_tag = ( - MathTex(r"\mathcal U_\epsilon", color=HIGHLIGHT) - .scale(0.72) - .next_to(ball, UP, buff=0.08) - ) - - q1 = Dot(plane.c2p(1.0, 0.7), color=GREEN_C) - q2 = Dot(plane.c2p(-1.2, 0.9), color=RED_C) - q3 = Dot(plane.c2p(0.3, -1.3), color=GREEN_C) - q4 = Dot(plane.c2p(-0.9, -0.6), color=GREEN_C) - q2_tag = Text("worst-case Q*", font_size=18, color=RED_C).next_to( - q2, UP, buff=0.07 - ) - - self.play(FadeIn(plane), FadeIn(center), FadeIn(center_tag)) - self.play(Create(ball), FadeIn(ball_tag)) - self.play( - LaggedStart(*[FadeIn(dot) for dot in [q1, q2, q3, q4]], lag_ratio=0.14) - ) - self.play(FadeIn(q2_tag, shift=UP * 0.08)) - - inner_step = card( - "inner min picks Q*", color=RED_C, width=4.6, height=0.9, font_size=20 - ) - demand_step = card( - "sample demand from Q*", color=ORANGE, width=4.6, height=0.9, font_size=20 - ) - update_step = card( - "outer max updates policy", - color=GREEN_C, - width=4.6, - height=0.9, - font_size=20, - ) - pipeline = ( - VGroup(inner_step, demand_step, update_step) - .arrange(DOWN, buff=0.32) - .to_edge(RIGHT) - .shift(DOWN * 0.95) - ) - chooser = Arrow( - q2.get_right() + RIGHT * 0.15, - inner_step.get_left(), - buff=0.08, - color=RED_C, - stroke_width=4, - ) - stage_arrow_1 = Arrow( - inner_step.get_bottom(), - demand_step.get_top(), - buff=0.08, - stroke_width=3.6, - ) - stage_arrow_2 = Arrow( - demand_step.get_bottom(), - update_step.get_top(), - buff=0.08, - stroke_width=3.6, - ) - feedback = CurvedArrow( - update_step.get_left() + DOWN * 0.12, - center.get_right() + UP * 0.15, - angle=0.92, - color=GREEN_C, - stroke_width=3.6, - ) - self.play(FadeIn(pipeline, shift=LEFT * 0.15)) - self.play(FadeIn(chooser)) - self.play(FadeIn(stage_arrow_1), FadeIn(stage_arrow_2)) - self.play(FadeIn(feedback)) - - note = Text( - "Reward is evaluated on demand drawn from Q*, then used for the policy step.", - font_size=22, - color=GREY_B, - ) - note.to_edge(DOWN) - self.play(FadeIn(note, shift=UP * 0.1)) - self.wait(1.0) - - -class SystemLoopScene(Scene): - def construct(self) -> None: - title = scene_title("Online + Offline Defense Loop") - self.play(Write(title)) - - web = card("Web app", color=BLUE_D, width=2.9) - provider = card("Pricing provider", color=BLUE_D, width=3.5) - kafka = card("Kafka streams", color=HIGHLIGHT, width=3.1) - kernels = card("Kernel + KL estimator", color=GREEN_C, width=3.9) - generator = card("Generator G(alpha)", color=GREEN_C, width=3.5) - policy = card("DR-RL trainer", color=ORANGE, width=3.0) - - web.move_to(LEFT * 4.6 + UP * 1.35) - provider.move_to(RIGHT * 4.2 + UP * 1.35) - kafka.move_to(LEFT * 4.6 + DOWN * 1.1) - kernels.move_to(LEFT * 1.3 + DOWN * 1.1) - generator.move_to(RIGHT * 2.0 + DOWN * 1.1) - policy.move_to(RIGHT * 5.1 + DOWN * 1.1) - - online_tag = Text("online serving", font_size=22, weight="BOLD", color=GREY_B) - online_tag.next_to(web, UP, buff=0.38).align_to(web, LEFT) - offline_tag = Text( - "offline defense training", font_size=22, weight="BOLD", color=GREY_B - ) - offline_tag.next_to(kafka, UP, buff=0.38).align_to(kafka, LEFT) - - request_arrow = CurvedArrow( - web.get_right() + UP * 0.2, - provider.get_left() + UP * 0.2, - angle=-0.24, - stroke_width=4, - ) - response_arrow = CurvedArrow( - provider.get_left() + DOWN * 0.2, - web.get_right() + DOWN * 0.2, - angle=-0.24, - stroke_width=4, - ) - log_arrow = Arrow(web.get_bottom(), kafka.get_top(), buff=0.08, stroke_width=4) - k_to_kl = Arrow(kafka.get_right(), kernels.get_left(), buff=0.1, stroke_width=4) - kl_to_g = Arrow( - kernels.get_right(), generator.get_left(), buff=0.1, stroke_width=4 - ) - g_to_pi = Arrow( - generator.get_right(), policy.get_left(), buff=0.1, stroke_width=4 - ) - pi_to_provider = Arrow( - policy.get_top(), provider.get_bottom(), buff=0.08, stroke_width=4 - ) - - nodes = VGroup(web, provider, kafka, kernels, generator, policy) - self.play( - FadeIn(online_tag, shift=UP * 0.08), FadeIn(offline_tag, shift=UP * 0.08) - ) - self.play( - LaggedStart( - *[FadeIn(node, shift=UP * 0.08) for node in nodes], lag_ratio=0.12 - ) - ) - self.play( - LaggedStart( - *[ - FadeIn(a) - for a in [ - request_arrow, - response_arrow, - log_arrow, - k_to_kl, - kl_to_g, - g_to_pi, - pi_to_provider, - ] - ], - lag_ratio=0.08, - ) - ) - - labels = VGroup( - Text("request quote", font_size=17).next_to(request_arrow, UP, buff=0.06), - Text("serve price", font_size=17).next_to(response_arrow, DOWN, buff=0.06), - Text("events + quote logs", font_size=17).next_to( - log_arrow, RIGHT, buff=0.08 - ), - Text("fit kernels + alpha", font_size=17).next_to(kl_to_g, UP, buff=0.08), - Text("robust policy train", font_size=17).next_to(g_to_pi, UP, buff=0.08), - Text("publish model", font_size=17).next_to( - pi_to_provider, RIGHT, buff=0.08 - ), - ) - self.play(LaggedStart(*[FadeIn(l) for l in labels], lag_ratio=0.15)) - self.wait(1.0) - - -class ObjectiveAndResultsScene(Scene): - def construct(self) -> None: - title = scene_title("Early Experimental Signal") - self.play(Write(title)) - - objective_chart = BarChart( - values=[3.41, 3.91], - bar_names=["robust", "non-robust"], - y_range=[0, 5, 1], - y_length=2.9, - x_length=4.8, - bar_colors=[GREEN_C, RED_C], - ) - objective_label = Text("objective (x1e5)", font_size=21).next_to( - objective_chart, UP, buff=0.1 - ) - - revenue_chart = BarChart( - values=[3.80, 4.18], - bar_names=["robust", "non-robust"], - y_range=[0, 5, 1], - y_length=2.9, - x_length=4.8, - bar_colors=[GREEN_C, RED_C], - ) - revenue_label = Text("revenue (x1e5)", font_size=21).next_to( - revenue_chart, UP, buff=0.1 - ) - - charts = VGroup( - VGroup(objective_label, objective_chart), - VGroup(revenue_label, revenue_chart), - ).arrange(RIGHT, buff=0.85) - charts.next_to(title, DOWN, buff=0.7) - self.play(FadeIn(charts, shift=UP * 0.2)) - - pairwise = VGroup( - Text("pairwise win counts", font_size=24, weight="BOLD"), - Text("objective: robust beats baseline in 13 / 40", font_size=22), - Text("revenue: robust beats baseline in 16 / 40", font_size=22), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.13) - pairwise.next_to(charts, DOWN, buff=0.35) - self.play( - LaggedStart( - *[FadeIn(row, shift=RIGHT * 0.15) for row in pairwise], lag_ratio=0.18 - ) - ) - - caution = Text( - "Interpretation: defense effect is real but regime-dependent and needs calibration.", - font_size=22, - color=GREY_B, - ).to_edge(DOWN) - self.play(FadeIn(caution, shift=UP * 0.1)) - self.wait(1.1) - - -class TakeawayScene(Scene): - def construct(self) -> None: - title = scene_title("Takeaways") - self.play(Write(title)) - - bullets = VGroup( - Text("COI gives a clean monetary KPI for pricing power.", font_size=32), - Text( - "Behavioral KL separability becomes a live control signal.", - font_size=32, - ), - Text( - "DR-RL with ambiguity sets protects against contamination shift.", - font_size=32, - ), - ).arrange(DOWN, aligned_edge=LEFT, buff=0.32) - bullets.next_to(title, DOWN, buff=0.7).align_to(title, LEFT) - self.play( - LaggedStart( - *[FadeIn(item, shift=RIGHT * 0.2) for item in bullets], lag_ratio=0.2 - ) - ) - - final = Text( - "From mechanism failure to implementable defense loop.", - font_size=29, - color=HIGHLIGHT, - ) - final.to_edge(DOWN) - self.play(FadeIn(final, shift=UP * 0.1)) - self.wait(1.0) - - -class ThesisBannerPosterScene(Scene): - def construct(self) -> None: - title = Text("PHANTOM", font_size=72, weight="BOLD", color=INK).to_edge(UP) - subtitle = Text( - "Pricing Heuristics Against Non-human Transaction Orchestration", - font_size=24, - color=GREY_B, - ).next_to(title, DOWN, buff=0.05) - - coi_axes = Axes( - x_range=[0, 1, 0.2], - y_range=[0, 1, 0.2], - x_length=3.15, - y_length=1.75, - tips=False, - axis_config={"stroke_width": 1.8, "color": AXIS_INK}, - ) - coi_n1 = coi_axes.plot( - lambda x: (1 - x) ** 1, - x_range=[0, 1], - color=BLUE_D, - stroke_width=4, - ) - coi_n8 = coi_axes.plot( - lambda x: (1 - x) ** 8, - x_range=[0, 1], - color=ORANGE, - stroke_width=4, - ) - coi_hint = Text( - "Order-statistic tail compresses as query count grows", font_size=15 - ) - coi_hint.set_color(GREY_B).next_to(coi_axes, DOWN, buff=0.06) - coi_title = Text("1) COI erosion", font_size=23, weight="BOLD", color=ORANGE) - coi_body = VGroup(coi_axes, coi_n1, coi_n8, coi_hint) - coi_group = VGroup(coi_title, coi_body).arrange(DOWN, buff=0.08) - coi_frame = SurroundingRectangle(coi_group, color=ORANGE, buff=0.14) - coi_frame.set_fill(color=ORANGE, opacity=0.05) - coi_panel = VGroup(coi_frame, coi_group) - - gap_axes = Axes( - x_range=[-8, 8, 2], - y_range=[0.0, 0.2, 0.05], - x_length=3.15, - y_length=1.75, - tips=False, - axis_config={"stroke_width": 1.8, "color": AXIS_INK}, - ) - gap_h = gap_axes.plot( - lambda x: normal_pdf(x, -3.35, 2.67), - x_range=[-8, 8], - color=BLUE_D, - stroke_width=4, - ) - gap_a = gap_axes.plot( - lambda x: normal_pdf(x, 1.65, 2.83), - x_range=[-8, 8], - color=RED_C, - stroke_width=4, - ) - gap_boundary = DashedLine( - gap_axes.c2p(0, 0), - gap_axes.c2p(0, 0.17), - color=GREY_B, - stroke_width=2, - ) - gap_hint = Text( - "Gap score g = Delta_H - Delta_A drives alpha-hat", font_size=15 - ) - gap_hint.set_color(GREY_B).next_to(gap_axes, DOWN, buff=0.06) - gap_title = Text( - "2) Behavioral separability", font_size=23, weight="BOLD", color=GREEN_C - ) - gap_body = VGroup(gap_axes, gap_h, gap_a, gap_boundary, gap_hint) - gap_group = VGroup(gap_title, gap_body).arrange(DOWN, buff=0.08) - gap_frame = SurroundingRectangle(gap_group, color=GREEN_C, buff=0.14) - gap_frame.set_fill(color=GREEN_C, opacity=0.05) - gap_panel = VGroup(gap_frame, gap_group) - - ctrl_title = Text( - "3) Robust pricing control", font_size=23, weight="BOLD", color=HIGHLIGHT - ) - ctrl_signal = MathTex(r"\hat\alpha(\tau')=\sigma(\beta g)", font_size=31) - ctrl_policy = MathTex( - r"\pi^*=\arg\max_\pi\min_{Q\in\mathcal U_\epsilon}\mathbb E[r]", - font_size=29, - color=HIGHLIGHT, - ) - ctrl_steps = VGroup( - card( - "estimate contamination from behavior", - color=GREEN_C, - width=4.0, - height=0.72, - font_size=16, - ), - card( - "optimize price policy under uncertainty", - color=ORANGE, - width=4.0, - height=0.72, - font_size=16, - ), - ).arrange(DOWN, buff=0.18) - ctrl_arrow = Arrow( - ctrl_steps[0].get_bottom(), - ctrl_steps[1].get_top(), - buff=0.06, - color=AXIS_INK, - stroke_width=3, - ) - ctrl_body = VGroup(ctrl_signal, ctrl_policy, ctrl_steps, ctrl_arrow).arrange( - DOWN, buff=0.14 - ) - ctrl_group = VGroup(ctrl_title, ctrl_body).arrange(DOWN, buff=0.08) - ctrl_frame = SurroundingRectangle(ctrl_group, color=HIGHLIGHT, buff=0.14) - ctrl_frame.set_fill(color=HIGHLIGHT, opacity=0.05) - ctrl_panel = VGroup(ctrl_frame, ctrl_group) - - panels = VGroup(coi_panel, gap_panel, ctrl_panel).arrange(RIGHT, buff=0.3) - panels.scale(0.92).next_to(subtitle, DOWN, buff=0.28) - - web = card("web sessions", color=BLUE_D, width=2.2, height=0.7, font_size=17) - kafka = card( - "quote + event logs", color=HIGHLIGHT, width=2.6, height=0.7, font_size=17 - ) - kernel = card( - "transition kernels", color=GREEN_C, width=2.5, height=0.7, font_size=17 - ) - policy = card( - "robust policy", color=ORANGE, width=2.2, height=0.7, font_size=17 - ) - flow_nodes = VGroup(web, kafka, kernel, policy).arrange(RIGHT, buff=0.22) - flow_nodes.to_edge(DOWN, buff=0.52) - flow_arrows = VGroup( - Arrow(web.get_right(), kafka.get_left(), buff=0.05, stroke_width=2.8), - Arrow(kafka.get_right(), kernel.get_left(), buff=0.05, stroke_width=2.8), - Arrow(kernel.get_right(), policy.get_left(), buff=0.05, stroke_width=2.8), - ) - - status = VGroup( - Text("Mann-Whitney p = 0.0006", font_size=19, color=GREEN_C), - Text("Pairwise robust wins: 13/40 objective, 16/40 revenue", font_size=19), - ).arrange(DOWN, buff=0.06) - status[1].set_color(GREY_B) - status.next_to(flow_nodes, UP, buff=0.15) - - footer = Text( - "From mechanism failure to an implementable defense loop", - font_size=25, - color=HIGHLIGHT, - ).next_to(flow_nodes, DOWN, buff=0.13) - - self.add( - title, - subtitle, - panels, - flow_nodes, - flow_arrows, - status, - footer, - ) - self.wait(0.1) - - -SCENE_ORDER = [ - "DefenseOpening", - "CardMarketAnalogyScene", - "COIFirstPrinciplesScene", - "COIOrderStatisticProofScene", - "BehaviorKernelConstructionScene", - "SeparabilitySignalScene", - "ContaminationGeneratorScene", - "RobustControlScene", - "SystemLoopScene", - "ObjectiveAndResultsScene", - "TakeawayScene", -] - -POSTER_SCENES = ["ThesisBannerPosterScene"] - -AVAILABLE_SCENES = SCENE_ORDER + POSTER_SCENES From b1f583be39d0a4831cb99e3b5988e213f90aa325 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 12 Mar 2026 09:16:50 +0100 Subject: [PATCH 11/35] nightly benchmark run configureation --- engine/benchmark.py | 6 +-- scripts/ray_distributed_train.py | 78 ++++++++++++++++++++++++++++---- submit_ray_job.sh | 39 +++++++++++++++- 3 files changed, 109 insertions(+), 14 deletions(-) diff --git a/engine/benchmark.py b/engine/benchmark.py index 47fb780..fc0205f 100644 --- a/engine/benchmark.py +++ b/engine/benchmark.py @@ -7,7 +7,7 @@ import sys import argparse import json import logging -from datetime import datetime, UTC +from datetime import datetime, timezone from pathlib import Path # clear stale TPU locks on startup @@ -449,7 +449,7 @@ def _run_with_args(args, compare_robust_override: bool | None = None): out_dir = Path(args.output_dir) out_dir.mkdir(parents=True, exist_ok=True) - stamp = datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") csv_path = out_dir / f"benchmark_{stamp}.csv" trace_path = out_dir / f"benchmark_traces_{stamp}.json" df.to_csv(csv_path, index=False) @@ -580,7 +580,7 @@ def run_cli(raw_args: list[str] | None = None): tiers = _parse_list(args.tiers) alpha_values = _parse_float_list(args.alpha_values) - run_stamp = datetime.now(UTC).strftime("%m%d-%H%M%S") + run_stamp = datetime.now(timezone.utc).strftime("%m%d-%H%M%S") compare_enabled = _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST")) compare_tag = "robust-compare" if compare_enabled else "single-mode" modes = ( diff --git a/scripts/ray_distributed_train.py b/scripts/ray_distributed_train.py index f918f33..7e2fc23 100644 --- a/scripts/ray_distributed_train.py +++ b/scripts/ray_distributed_train.py @@ -15,6 +15,15 @@ def _has_flag(tokens: list[str], name: str) -> bool: return any(tok == name or tok.startswith(f"{name}=") for tok in tokens) +def _entry_tokens(run_kind: str, entry_args: str) -> list[str]: + tokens = shlex.split(entry_args) + if run_kind == "benchmark" and not ( + _has_flag(tokens, "--run-kind") or _has_flag(tokens, "--run-mode") + ): + return ["--run-kind", "benchmark", *tokens] + return tokens + + def _alive_node_ips() -> list[str]: seen: set[str] = set() ips: list[str] = [] @@ -33,13 +42,18 @@ def _alive_node_ips() -> list[str]: def _train_on_node( *, root: str, - train_args: str, + run_kind: str, + entry_args: str, rank: int, world_size: int, coordinator_ip: str, coordinator_port: int, base_seed: int, run_group: str, + compare_robust: bool, + output_root: str, + wandb_entity: str, + wandb_project: str, sync_jax: bool, ) -> int: env = dict(os.environ) @@ -53,6 +67,12 @@ def _train_on_node( env["JAX_PLATFORMS"] = requested_platform # Keep each train process in single-host mode to avoid accidental global stalls. env["CLOUD_TPU_TASK_ID"] = "0" + if run_kind == "benchmark": + env["PHANTOM_BENCHMARK_COMPARE_ROBUST"] = "1" if compare_robust else "0" + if wandb_entity: + env["WANDB_ENTITY"] = wandb_entity + if wandb_project: + env["WANDB_PROJECT"] = wandb_project cwd = str(Path(root)) @@ -75,30 +95,62 @@ def _train_on_node( [sys.executable, "-c", probe], cwd=cwd, env=env_probe, check=True ) - tokens = shlex.split(train_args) + tokens = _entry_tokens(run_kind, entry_args) + seed = int(base_seed + rank) if not _has_flag(tokens, "--seed"): - tokens.extend(["--seed", str(base_seed + rank)]) - if not _has_flag(tokens, "--group"): + tokens.extend(["--seed", str(seed)]) + + if run_kind == "train" and not _has_flag(tokens, "--group"): tokens.extend(["--group", run_group]) + if ( + run_kind == "benchmark" + and output_root + and not _has_flag(tokens, "--output-dir") + ): + out_dir = Path(output_root) / f"rank_{rank}" / f"seed_{seed}" + out_dir.parent.mkdir(parents=True, exist_ok=True) + tokens.extend(["--output-dir", str(out_dir)]) + cmd = [sys.executable, "-m", "engine.train", *tokens] + print( + { + "rank": int(rank), + "run_kind": run_kind, + "seed": int(seed), + "compare_robust": bool(compare_robust), + "wandb_entity": str(env.get("WANDB_ENTITY", "")), + "wandb_project": str(env.get("WANDB_PROJECT", "")), + "command": " ".join(cmd), + } + ) proc = subprocess.run(cmd, cwd=cwd, env=env) return int(proc.returncode) def main() -> None: parser = argparse.ArgumentParser( - description="Launch one train run per Ray TPU node" + description="Launch one train/benchmark run per Ray TPU node" ) - parser.add_argument("--train-args", type=str, required=True) + parser.add_argument("--run-kind", choices=["train", "benchmark"], default="train") + parser.add_argument("--entry-args", type=str, default="") + parser.add_argument("--train-args", type=str, default="") parser.add_argument("--num-nodes", type=int, default=0) parser.add_argument("--tpu-per-task", type=float, default=8.0) parser.add_argument("--base-seed", type=int, default=42) parser.add_argument("--sync-jax", action="store_true") parser.add_argument("--coordinator-port", type=int, default=12355) parser.add_argument("--run-group", type=str, default="") + parser.add_argument("--compare-robust", action="store_true") + parser.add_argument("--output-root", type=str, default="") + parser.add_argument("--wandb-entity", type=str, default="") + parser.add_argument("--wandb-project", type=str, default="") args = parser.parse_args() + entry_args = str(args.entry_args or args.train_args).strip() + if not entry_args: + raise ValueError("--entry-args (or legacy --train-args) is required") + ray.init(address="auto") node_ips = _alive_node_ips() @@ -118,8 +170,11 @@ def main() -> None: "nodes": node_ips, "world_size": world_size, "coordinator": f"{coordinator_ip}:{int(args.coordinator_port)}", - "train_args": args.train_args, + "run_kind": str(args.run_kind), + "entry_args": entry_args, "run_group": run_group, + "compare_robust": bool(args.compare_robust), + "output_root": str(args.output_root), } ) @@ -130,14 +185,19 @@ def main() -> None: futures.append( _train_on_node.options(resources=resources).remote( root=root, - train_args=args.train_args, + run_kind=str(args.run_kind), + entry_args=entry_args, rank=rank, world_size=world_size, coordinator_ip=coordinator_ip, coordinator_port=int(args.coordinator_port), base_seed=int(args.base_seed), run_group=run_group, - sync_jax=bool(args.sync_jax), + compare_robust=bool(args.compare_robust), + output_root=str(args.output_root), + wandb_entity=str(args.wandb_entity), + wandb_project=str(args.wandb_project), + sync_jax=bool(args.sync_jax and str(args.run_kind) == "train"), ) ) diff --git a/submit_ray_job.sh b/submit_ray_job.sh index 11775d6..c2f9709 100755 --- a/submit_ray_job.sh +++ b/submit_ray_job.sh @@ -3,6 +3,7 @@ # Modes: # RAY_MODE=single -> one run (default) # RAY_MODE=distributed -> one run per TPU node (experimental) +# RAY_MODE=benchmark -> one benchmark run per TPU node (overnight) set -euo pipefail @@ -27,6 +28,9 @@ env = dotenv_values(".env") # Filter out empty/None values env_vars = {k: v for k, v in env.items() if v} env_vars.setdefault("CLOUD_TPU_TASK_ID", os.getenv("CLOUD_TPU_TASK_ID", "0")) +for k in ("WANDB_ENTITY", "WANDB_PROJECT", "PHANTOM_BENCHMARK_COMPARE_ROBUST"): + if os.getenv(k): + env_vars[k] = os.getenv(k) print(json.dumps({ "pip": [ @@ -38,7 +42,8 @@ print(json.dumps({ "pandas", "pydantic", "graphviz", - "huggingface_hub" + "huggingface_hub", + "matplotlib" ], "env_vars": env_vars })) @@ -46,12 +51,22 @@ print(json.dumps({ RAY_MODE="${RAY_MODE:-single}" TRAIN_ARGS="${TRAIN_ARGS:---algo ppo --total-timesteps 1000000}" +BENCHMARK_ARGS="${BENCHMARK_ARGS:---project capstone_tpu --tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.1,0.25,0.4,0.6,0.8 --episodes 12 --total-timesteps 30000 --max-steps 100 --robust-radius 0.2 --robust-points 7 --robust-rollouts 1 --lambda-coi 0.2 --eta-ux 0.5 --reward-profit-weight 1.0 --device cpu}" + +SUBMIT_ARGS=() +if [ "${RAY_NO_WAIT:-0}" = "1" ]; then + SUBMIT_ARGS+=(--no-wait) +fi +if [ -n "${SUBMISSION_ID:-}" ]; then + SUBMIT_ARGS+=(--submission-id "$SUBMISSION_ID") +fi COMMON_ARGS=( job submit --address http://localhost:8265 --working-dir "$ROOT" --runtime-env-json "$RUNTIME_ENV_JSON" + "${SUBMIT_ARGS[@]}" -- ) @@ -77,5 +92,25 @@ if [ "$RAY_MODE" = "distributed" ]; then exit 0 fi -echo "Unsupported RAY_MODE='$RAY_MODE' (expected 'single' or 'distributed')." >&2 +if [ "$RAY_MODE" = "benchmark" ]; then + DIST_ARGS=( + python + scripts/ray_distributed_train.py + --run-kind benchmark + --entry-args "$BENCHMARK_ARGS" + --num-nodes "${NUM_NODES:-4}" + --tpu-per-task "${TPU_PER_TASK:-8}" + --base-seed "${BASE_SEED:-42}" + --output-root "${OUTPUT_ROOT:-engine/studies/results/overnight}" + --wandb-entity "${WANDB_ENTITY:-lusiana}" + --wandb-project "${WANDB_PROJECT:-capstone_tpu}" + ) + if [ "${COMPARE_ROBUST:-1}" = "1" ]; then + DIST_ARGS+=(--compare-robust) + fi + "$RAY_BIN" "${COMMON_ARGS[@]}" "${DIST_ARGS[@]}" + exit 0 +fi + +echo "Unsupported RAY_MODE='$RAY_MODE' (expected 'single', 'distributed', or 'benchmark')." >&2 exit 1 From 88155d22a75fd23f2ba415afe8de3066a4cfd377 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Thu, 12 Mar 2026 12:48:52 +0100 Subject: [PATCH 12/35] chore: refactor for sweeps and IP configs --- docker-compose.yml | 4 + docker/TPUWatchdog.dockerfile | 48 +++- scripts/ray_distributed_train.py | 418 +++++++++++++++++++++++++++++-- submit_ray_job.sh | 117 ++++++++- tpu_orchestration/watchdog.sh | 13 +- 5 files changed, 566 insertions(+), 34 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c00f4e1..24961c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,9 +10,13 @@ services: - HF_TOKEN=${HF_TOKEN} - WANDB_API_KEY=${WANDB_API_KEY} - GITHUB_TOKEN=${GITHUB_TOKEN} + - GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-sa.json + - GCP_ACCOUNT=${GCP_ACCOUNT:-} + - WATCHDOG_CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-v6e_*.conf} - CLOUDSDK_CONFIG=/.config/gcloud volumes: - ~/.config/gcloud:/.config/gcloud:rw + - ./secrets/gcp-sa.json:/secrets/gcp-sa.json:ro tensorboard-rl: image: tensorflow/tensorflow:latest diff --git a/docker/TPUWatchdog.dockerfile b/docker/TPUWatchdog.dockerfile index 8299171..66c0c3f 100644 --- a/docker/TPUWatchdog.dockerfile +++ b/docker/TPUWatchdog.dockerfile @@ -35,25 +35,55 @@ if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -f "$GOOGLE_APPLICATION_CREDENT if [ "$CRED_TYPE" = "service_account" ]; then echo "Authenticating gcloud using service account key..." gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS" - - # Extract project ID from the key file - PROJECT_ID=$(jq -r '.project_id' "$GOOGLE_APPLICATION_CREDENTIALS") - if [ -n "$PROJECT_ID" ] && [ "$PROJECT_ID" != "null" ]; then - gcloud config set project "$PROJECT_ID" - echo "Set project to $PROJECT_ID" + + if [ -z "$PROJECT_ID" ]; then + PROJECT_ID=$(jq -r '.project_id // empty' "$GOOGLE_APPLICATION_CREDENTIALS") fi + elif [ "$CRED_TYPE" = "authorized_user" ]; then + echo "Authenticating gcloud using authorized_user refresh token..." + + AUTH_ACCOUNT="$GCP_ACCOUNT" + if [ -z "$AUTH_ACCOUNT" ]; then + AUTH_ACCOUNT=$(jq -r '.account // empty' "$GOOGLE_APPLICATION_CREDENTIALS") + fi + if [ -z "$AUTH_ACCOUNT" ]; then + AUTH_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true) + fi + + REFRESH_TOKEN=$(jq -r '.refresh_token // empty' "$GOOGLE_APPLICATION_CREDENTIALS") + if [ -z "$AUTH_ACCOUNT" ] || [ -z "$REFRESH_TOKEN" ]; then + echo "Error: authorized_user credentials require GCP_ACCOUNT (or embedded account) and refresh_token." + exit 1 + fi + + gcloud auth activate-refresh-token "$AUTH_ACCOUNT" "$REFRESH_TOKEN" else - echo "Note: Using application default credentials or mounted gcloud config..." + echo "Warning: unsupported credential file type '$CRED_TYPE'. Falling back to mounted gcloud config." fi else echo "Note: Assuming gcloud config is mounted from host." fi +if [ -n "$PROJECT_ID" ]; then + gcloud config set project "$PROJECT_ID" + echo "Set project to $PROJECT_ID" +fi + # Run the watchdogs in the background using bash instead of tmux # Tmux needs a TTY to attach properly which we might not have in docker # Stagger startups by 15s to prevent simultaneous TPU creation quota hits +CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-"*.conf"} +shopt -s nullglob +CONFIGS=(/app/tpu_orchestration/configs/$CONFIG_PATTERN) + +if [ ${#CONFIGS[@]} -eq 0 ]; then + echo "Error: no watchdog configs matched pattern '$CONFIG_PATTERN'." + exit 1 +fi + +echo "Using watchdog config pattern: $CONFIG_PATTERN" DELAY=0 -for conf in /app/tpu_orchestration/configs/*.conf; do +for conf in "${CONFIGS[@]}"; do echo "Starting watchdog for $(basename "$conf" .conf) (delay: ${DELAY}s)" (sleep $DELAY && /app/tpu_orchestration/watchdog.sh "$conf") & DELAY=$((DELAY + 15)) @@ -67,4 +97,4 @@ EOF RUN chmod +x /app/entrypoint.sh -CMD ["/app/entrypoint.sh"] \ No newline at end of file +CMD ["/app/entrypoint.sh"] diff --git a/scripts/ray_distributed_train.py b/scripts/ray_distributed_train.py index 7e2fc23..3395a8f 100644 --- a/scripts/ray_distributed_train.py +++ b/scripts/ray_distributed_train.py @@ -1,14 +1,18 @@ from __future__ import annotations import argparse +import contextlib +import concurrent.futures import os import shlex import subprocess import sys +import threading import time from pathlib import Path import ray +from ray.util.scheduling_strategies import NodeAffinitySchedulingStrategy def _has_flag(tokens: list[str], name: str) -> bool: @@ -24,18 +28,301 @@ def _entry_tokens(run_kind: str, entry_args: str) -> list[str]: return tokens -def _alive_node_ips() -> list[str]: +def _get_flag_value(tokens: list[str], name: str, default: str = "") -> str: + for idx, tok in enumerate(tokens): + if tok == name and idx + 1 < len(tokens): + return str(tokens[idx + 1]) + if tok.startswith(f"{name}="): + return str(tok.split("=", 1)[1]) + return str(default) + + +def _set_flag_value(tokens: list[str], name: str, value: str) -> list[str]: + updated: list[str] = [] + replaced = False + idx = 0 + while idx < len(tokens): + tok = tokens[idx] + if tok == name: + replaced = True + updated.extend([name, str(value)]) + idx += 2 + continue + if tok.startswith(f"{name}="): + replaced = True + updated.append(f"{name}={value}") + idx += 1 + continue + updated.append(tok) + idx += 1 + if not replaced: + updated.extend([name, str(value)]) + return updated + + +def _remove_flag(tokens: list[str], name: str) -> list[str]: + updated: list[str] = [] + idx = 0 + while idx < len(tokens): + tok = tokens[idx] + if tok == name: + idx += 1 + continue + if tok.startswith(f"{name}="): + idx += 1 + continue + updated.append(tok) + idx += 1 + return updated + + +def _csv_values(raw: str) -> list[str]: + return [piece.strip() for piece in str(raw).split(",") if piece.strip()] + + +def _alpha_token(alpha: str) -> str: + return str(alpha).replace(".", "p").replace("-", "m") + + +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 _alive_nodes() -> list[tuple[str, str]]: seen: set[str] = set() - ips: list[str] = [] + nodes: list[tuple[str, str]] = [] for node in ray.nodes(): if not bool(node.get("Alive", False)): continue + node_id = str(node.get("NodeID", "")).strip() ip = str(node.get("NodeManagerAddress", "")).strip() - if not ip or ip in seen: + if not node_id or not ip or node_id in seen: continue - seen.add(ip) - ips.append(ip) - return sorted(ips) + seen.add(node_id) + nodes.append((node_id, ip)) + return sorted(nodes, key=lambda item: (item[1], item[0])) + + +def _benchmark_cells( + tokens: list[str], *, compare_robust: bool +) -> list[tuple[str, str, str, bool]]: + tiers = _csv_values( + _get_flag_value(tokens, "--tiers", "static,surge,linear,qtable,ppo") + ) + alphas = _csv_values(_get_flag_value(tokens, "--alpha-values", "0.0,0.3,0.6")) + base_no_robust = _has_flag(tokens, "--no-robust") + if compare_robust: + modes = [("robust", False), ("no_robust", True)] + else: + modes = [("no_robust", True)] if base_no_robust else [("robust", False)] + return [ + (tier, alpha, mode_label, no_robust) + for tier in tiers + for alpha in alphas + for mode_label, no_robust in modes + ] + + +def _thread_limited_env(env: dict[str, str], threads: int) -> dict[str, str]: + bounded = dict(env) + n = str(max(1, int(threads))) + for key in ( + "OMP_NUM_THREADS", + "MKL_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "NUMEXPR_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", + "BLIS_NUM_THREADS", + ): + bounded[key] = n + return bounded + + +@contextlib.contextmanager +def _semaphore_guard(semaphore: threading.Semaphore | None): + if semaphore is None: + yield + return + semaphore.acquire() + try: + yield + finally: + semaphore.release() + + +def _run_benchmark_cells_parallel( + *, + root: str, + env: dict[str, str], + base_tokens: list[str], + compare_robust: bool, + inner_workers: int, + inner_threads: int, + max_heavy_workers: int, + rank: int, +) -> int: + cells = _benchmark_cells(base_tokens, compare_robust=compare_robust) + if not cells: + return 0 + + cwd = str(Path(root)) + base_out = _get_flag_value(base_tokens, "--output-dir", "engine/studies/results") + max_workers = max(1, min(int(inner_workers), len(cells))) + heavy_tiers = {"ppo", "a2c", "dqn"} + heavy_limit = max(1, int(max_heavy_workers)) + heavy_sem = threading.Semaphore(heavy_limit) + print( + { + "rank": int(rank), + "benchmark_cells": len(cells), + "inner_workers": int(max_workers), + "inner_threads": int(max(1, int(inner_threads))), + "heavy_limit": int(heavy_limit), + } + ) + + def _run_cell( + index: int, + total: int, + tier: str, + alpha: str, + mode_label: str, + no_robust: bool, + ) -> tuple[str, str, str, int]: + tokens = list(base_tokens) + tokens = _set_flag_value(tokens, "--tiers", tier) + tokens = _set_flag_value(tokens, "--alpha-values", alpha) + if no_robust: + if not _has_flag(tokens, "--no-robust"): + tokens.append("--no-robust") + else: + tokens = _remove_flag(tokens, "--no-robust") + + cell_out = ( + Path(base_out) + / f"tier_{tier}" + / f"mode_{mode_label}" + / f"alpha_{_alpha_token(alpha)}" + ) + tokens = _set_flag_value(tokens, "--output-dir", str(cell_out)) + cmd = [sys.executable, "-m", "engine.train", *tokens] + cell_env = _thread_limited_env(env, int(inner_threads)) + cell_env["PHANTOM_BENCHMARK_COMPARE_ROBUST"] = "0" + print( + { + "rank": int(rank), + "cell": f"{index}/{total}", + "tier": tier, + "mode": mode_label, + "alpha": alpha, + "command": " ".join(cmd), + } + ) + heavy_guard = heavy_sem if str(tier).lower() in heavy_tiers else None + with _semaphore_guard(heavy_guard): + proc = subprocess.run(cmd, cwd=cwd, env=cell_env) + return tier, alpha, mode_label, int(proc.returncode) + + failures: list[tuple[str, str, str, int]] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool: + futures = [ + pool.submit(_run_cell, idx, len(cells), tier, alpha, mode_label, no_robust) + for idx, (tier, alpha, mode_label, no_robust) in enumerate(cells, start=1) + ] + for fut in concurrent.futures.as_completed(futures): + tier, alpha, mode_label, code = fut.result() + if code != 0: + failures.append((tier, alpha, mode_label, code)) + + if failures: + print({"rank": int(rank), "benchmark_failures": failures}) + return 1 + return 0 + + +def _run_sweep_agents_parallel( + *, + root: str, + env: dict[str, str], + base_tokens: list[str], + run_kind: str, + rank: int, + agents_per_node: int, + agent_count: int, + inner_threads: int, + tpu_agent_slots: int, +) -> int: + total = max(1, int(agents_per_node)) + cwd = str(Path(root)) + wants_tpu = str(env.get("JAX_PLATFORMS", "")).strip().lower() == "tpu" + tpu_slots = max(0, int(tpu_agent_slots)) + print( + { + "rank": int(rank), + "sweep_agents": int(total), + "agent_count": int(agent_count), + "inner_threads": int(max(1, int(inner_threads))), + "jax_platform": str(env.get("JAX_PLATFORMS", "")), + "tpu_agent_slots": int(tpu_slots), + } + ) + + def _run_agent(slot: int) -> int: + tokens = list(base_tokens) + if int(agent_count) > 0 and not _has_flag(tokens, "--count"): + tokens.extend(["--count", str(int(agent_count))]) + + if _has_flag(tokens, "--group"): + base_group = _get_flag_value(tokens, "--group", "ray-sweep") + tokens = _set_flag_value(tokens, "--group", f"{base_group}-a{slot}") + + if run_kind == "benchmark": + out_dir = _get_flag_value(tokens, "--output-dir", "engine/studies/results") + tokens = _set_flag_value( + tokens, "--output-dir", str(Path(out_dir) / f"agent_{slot}") + ) + if run_kind == "train": + model_dir = _get_flag_value(tokens, "--model-dir", "engine/models") + tokens = _set_flag_value( + tokens, "--model-dir", str(Path(model_dir) / f"agent_{slot}") + ) + + cmd = [sys.executable, "-m", "engine.train", *tokens] + agent_env = _thread_limited_env(env, int(inner_threads)) + if wants_tpu and tpu_slots > 0 and int(slot) > tpu_slots: + agent_env["JAX_PLATFORMS"] = "cpu" + agent_env["JAX_PLATFORM_NAME"] = "cpu" + agent_env["PHANTOM_SWEEP_AGENT_SLOT"] = str(int(slot)) + print( + { + "rank": int(rank), + "agent_slot": int(slot), + "jax_platform": str(agent_env.get("JAX_PLATFORMS", "")), + "command": " ".join(cmd), + } + ) + proc = subprocess.run(cmd, cwd=cwd, env=agent_env) + return int(proc.returncode) + + failures: list[tuple[int, int]] = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=total) as pool: + future_map = { + pool.submit(_run_agent, slot): slot for slot in range(1, total + 1) + } + for future in concurrent.futures.as_completed(future_map): + slot = int(future_map[future]) + code = int(future.result()) + if code != 0: + failures.append((slot, code)) + + if failures: + print({"rank": int(rank), "sweep_failures": failures}) + return 1 + return 0 @ray.remote(max_retries=0) @@ -44,6 +331,8 @@ def _train_on_node( root: str, run_kind: str, entry_args: str, + node_id: str, + node_ip: str, rank: int, world_size: int, coordinator_ip: str, @@ -54,17 +343,32 @@ def _train_on_node( output_root: str, wandb_entity: str, wandb_project: str, + agents_per_node: int, + agent_count: int, + inner_workers: int, + inner_threads: int, + max_heavy_workers: int, sync_jax: bool, ) -> int: env = dict(os.environ) env["PYTHONUNBUFFERED"] = "1" requested_platform = str(env.get("PHANTOM_JAX_PLATFORM", "tpu")).strip().lower() - if world_size > 1 and requested_platform == "tpu": + allow_multi_node_tpu = _truthy(env.get("PHANTOM_ALLOW_MULTI_NODE_TPU")) + if world_size > 1 and requested_platform == "tpu" and not allow_multi_node_tpu: requested_platform = "cpu" print( - "PHANTOM_DISTRIBUTED_NOTE: forcing JAX_PLATFORMS=cpu for multi-node SB3 runs" + "PHANTOM_DISTRIBUTED_NOTE: forcing JAX_PLATFORMS=cpu for multi-node SB3 runs " + "(set PHANTOM_ALLOW_MULTI_NODE_TPU=1 to keep TPU for JAX workloads)" + ) + elif world_size > 1 and requested_platform == "tpu" and allow_multi_node_tpu: + print( + "PHANTOM_DISTRIBUTED_NOTE: keeping JAX_PLATFORMS=tpu in multi-node mixed mode" ) env["JAX_PLATFORMS"] = requested_platform + if requested_platform == "cpu": + env["JAX_PLATFORM_NAME"] = "cpu" + else: + env.pop("JAX_PLATFORM_NAME", None) # Keep each train process in single-host mode to avoid accidental global stalls. env["CLOUD_TPU_TASK_ID"] = "0" if run_kind == "benchmark": @@ -96,13 +400,29 @@ def _train_on_node( ) tokens = _entry_tokens(run_kind, entry_args) + is_sweep_agent = _has_flag(tokens, "--sweep-agent") seed = int(base_seed + rank) - if not _has_flag(tokens, "--seed"): + if not is_sweep_agent and not _has_flag(tokens, "--seed"): tokens.extend(["--seed", str(seed)]) if run_kind == "train" and not _has_flag(tokens, "--group"): tokens.extend(["--group", run_group]) + if is_sweep_agent and int(agent_count) > 0 and not _has_flag(tokens, "--count"): + tokens.extend(["--count", str(int(agent_count))]) + + try: + tpu_agent_slots = int( + str( + env.get( + "PHANTOM_TPU_AGENT_SLOTS", + "1" if requested_platform == "tpu" else "0", + ) + ).strip() + ) + except ValueError: + tpu_agent_slots = 1 if requested_platform == "tpu" else 0 + if ( run_kind == "benchmark" and output_root @@ -112,9 +432,36 @@ def _train_on_node( out_dir.parent.mkdir(parents=True, exist_ok=True) tokens.extend(["--output-dir", str(out_dir)]) + if is_sweep_agent and int(agents_per_node) > 1: + return _run_sweep_agents_parallel( + root=root, + env=env, + base_tokens=tokens, + run_kind=run_kind, + rank=rank, + agents_per_node=int(agents_per_node), + agent_count=int(agent_count), + inner_threads=int(inner_threads), + tpu_agent_slots=int(max(0, tpu_agent_slots)), + ) + + if run_kind == "benchmark" and int(inner_workers) > 1 and not is_sweep_agent: + return _run_benchmark_cells_parallel( + root=root, + env=env, + base_tokens=tokens, + compare_robust=bool(compare_robust), + inner_workers=int(inner_workers), + inner_threads=int(inner_threads), + max_heavy_workers=int(max_heavy_workers), + rank=rank, + ) + cmd = [sys.executable, "-m", "engine.train", *tokens] print( { + "node_id": node_id, + "node_ip": node_ip, "rank": int(rank), "run_kind": run_kind, "seed": int(seed), @@ -124,7 +471,9 @@ def _train_on_node( "command": " ".join(cmd), } ) - proc = subprocess.run(cmd, cwd=cwd, env=env) + proc = subprocess.run( + cmd, cwd=cwd, env=_thread_limited_env(env, int(inner_threads)) + ) return int(proc.returncode) @@ -145,6 +494,12 @@ def main() -> None: parser.add_argument("--output-root", type=str, default="") parser.add_argument("--wandb-entity", type=str, default="") parser.add_argument("--wandb-project", type=str, default="") + parser.add_argument("--agents-per-node", type=int, default=1) + parser.add_argument("--agent-count", type=int, default=0) + parser.add_argument("--inner-workers", type=int, default=1) + parser.add_argument("--inner-threads", type=int, default=1) + parser.add_argument("--max-heavy-workers", type=int, default=2) + parser.add_argument("--worker-cpus", type=float, default=1.0) args = parser.parse_args() entry_args = str(args.entry_args or args.train_args).strip() @@ -153,21 +508,24 @@ def main() -> None: ray.init(address="auto") - node_ips = _alive_node_ips() - if not node_ips: + node_entries = _alive_nodes() + if not node_entries: raise RuntimeError("No alive Ray nodes found") requested = int(args.num_nodes) if requested > 0: - node_ips = node_ips[:requested] + node_entries = node_entries[:requested] - world_size = len(node_ips) - coordinator_ip = node_ips[0] + world_size = len(node_entries) + coordinator_ip = node_entries[0][1] run_group = args.run_group or f"ray-dist-{int(time.time())}" print( { - "nodes": node_ips, + "nodes": [ + {"node_id": node_id, "node_ip": node_ip} + for node_id, node_ip in node_entries + ], "world_size": world_size, "coordinator": f"{coordinator_ip}:{int(args.coordinator_port)}", "run_kind": str(args.run_kind), @@ -175,18 +533,35 @@ def main() -> None: "run_group": run_group, "compare_robust": bool(args.compare_robust), "output_root": str(args.output_root), + "agents_per_node": int(args.agents_per_node), + "agent_count": int(args.agent_count), + "inner_workers": int(args.inner_workers), + "inner_threads": int(args.inner_threads), + "max_heavy_workers": int(args.max_heavy_workers), } ) futures = [] root = str(Path(__file__).resolve().parents[1]) - for rank, node_ip in enumerate(node_ips): - resources = {f"node:{node_ip}": 0.01, "TPU": float(args.tpu_per_task)} + for rank, (node_id, node_ip) in enumerate(node_entries): + resources: dict[str, float] = {} + tpu_per_task = float(args.tpu_per_task) + if tpu_per_task > 0.0: + resources["TPU"] = tpu_per_task futures.append( - _train_on_node.options(resources=resources).remote( + _train_on_node.options( + resources=resources, + num_cpus=float(args.worker_cpus), + scheduling_strategy=NodeAffinitySchedulingStrategy( + node_id=node_id, + soft=False, + ), + ).remote( root=root, run_kind=str(args.run_kind), entry_args=entry_args, + node_id=node_id, + node_ip=node_ip, rank=rank, world_size=world_size, coordinator_ip=coordinator_ip, @@ -197,6 +572,11 @@ def main() -> None: output_root=str(args.output_root), wandb_entity=str(args.wandb_entity), wandb_project=str(args.wandb_project), + agents_per_node=int(args.agents_per_node), + agent_count=int(args.agent_count), + inner_workers=int(args.inner_workers), + inner_threads=int(args.inner_threads), + max_heavy_workers=int(args.max_heavy_workers), sync_jax=bool(args.sync_jax and str(args.run_kind) == "train"), ) ) diff --git a/submit_ray_job.sh b/submit_ray_job.sh index c2f9709..a6065ec 100755 --- a/submit_ray_job.sh +++ b/submit_ray_job.sh @@ -4,6 +4,7 @@ # RAY_MODE=single -> one run (default) # RAY_MODE=distributed -> one run per TPU node (experimental) # RAY_MODE=benchmark -> one benchmark run per TPU node (overnight) +# RAY_MODE=sweep -> distributed W&B sweep agents set -euo pipefail @@ -28,7 +29,14 @@ env = dotenv_values(".env") # Filter out empty/None values env_vars = {k: v for k, v in env.items() if v} env_vars.setdefault("CLOUD_TPU_TASK_ID", os.getenv("CLOUD_TPU_TASK_ID", "0")) -for k in ("WANDB_ENTITY", "WANDB_PROJECT", "PHANTOM_BENCHMARK_COMPARE_ROBUST"): +for k in ( + "WANDB_ENTITY", + "WANDB_PROJECT", + "PHANTOM_BENCHMARK_COMPARE_ROBUST", + "PHANTOM_JAX_PLATFORM", + "PHANTOM_ALLOW_MULTI_NODE_TPU", + "PHANTOM_TPU_AGENT_SLOTS", +): if os.getenv(k): env_vars[k] = os.getenv(k) @@ -52,6 +60,15 @@ print(json.dumps({ RAY_MODE="${RAY_MODE:-single}" TRAIN_ARGS="${TRAIN_ARGS:---algo ppo --total-timesteps 1000000}" BENCHMARK_ARGS="${BENCHMARK_ARGS:---project capstone_tpu --tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.1,0.25,0.4,0.6,0.8 --episodes 12 --total-timesteps 30000 --max-steps 100 --robust-radius 0.2 --robust-points 7 --robust-rollouts 1 --lambda-coi 0.2 --eta-ux 0.5 --reward-profit-weight 1.0 --device cpu}" +INNER_WORKERS="${INNER_WORKERS:-16}" +INNER_THREADS="${INNER_THREADS:-1}" +MAX_HEAVY_WORKERS="${MAX_HEAVY_WORKERS:-3}" +WORKER_CPUS="${WORKER_CPUS:-$((INNER_WORKERS * INNER_THREADS))}" +SWEEP_KIND="${SWEEP_KIND:-benchmark}" +SWEEP_METHOD="${SWEEP_METHOD:-random}" +SWEEP_RUN_CAP="${SWEEP_RUN_CAP:-0}" +AGENTS_PER_NODE="${AGENTS_PER_NODE:-16}" +AGENT_COUNT="${AGENT_COUNT:-0}" SUBMIT_ARGS=() if [ "${RAY_NO_WAIT:-0}" = "1" ]; then @@ -104,6 +121,10 @@ if [ "$RAY_MODE" = "benchmark" ]; then --output-root "${OUTPUT_ROOT:-engine/studies/results/overnight}" --wandb-entity "${WANDB_ENTITY:-lusiana}" --wandb-project "${WANDB_PROJECT:-capstone_tpu}" + --inner-workers "${INNER_WORKERS}" + --inner-threads "${INNER_THREADS}" + --max-heavy-workers "${MAX_HEAVY_WORKERS}" + --worker-cpus "${WORKER_CPUS}" ) if [ "${COMPARE_ROBUST:-1}" = "1" ]; then DIST_ARGS+=(--compare-robust) @@ -112,5 +133,97 @@ if [ "$RAY_MODE" = "benchmark" ]; then exit 0 fi -echo "Unsupported RAY_MODE='$RAY_MODE' (expected 'single', 'distributed', or 'benchmark')." >&2 +if [ "$RAY_MODE" = "sweep" ]; then + SWEEP_PROJECT="${WANDB_PROJECT:-capstone_tpu}" + SWEEP_ENTITY="${WANDB_ENTITY:-lusiana}" + SWEEP_ID_VALUE="${SWEEP_ID:-}" + SWEEP_NUM_NODES="${NUM_NODES:-5}" + PY_SWEEP_BIN="${PY_SWEEP_BIN:-}" + if [ -z "$PY_SWEEP_BIN" ]; then + for cand in "$ROOT/.venv/bin/python" "$ROOT/.venv-ray/bin/python" python3 python; do + if [ "$cand" = "python3" ] || [ "$cand" = "python" ]; then + command -v "$cand" >/dev/null 2>&1 || continue + elif [ ! -x "$cand" ]; then + continue + fi + if "$cand" - <<'PY' >/dev/null 2>&1 +import sys +from pathlib import Path +cwd = str(Path.cwd()) +sys.path = [p for p in sys.path if p not in {'', cwd}] +import wandb +print(wandb.__name__) +PY + then + PY_SWEEP_BIN="$cand" + break + fi + done + fi + if [ -z "$PY_SWEEP_BIN" ]; then + echo "No python interpreter with wandb is available for sweep creation." >&2 + exit 1 + fi + + if [ -z "$SWEEP_ID_VALUE" ]; then + if [ -z "${WANDB_API_KEY:-}" ]; then + export WANDB_API_KEY + WANDB_API_KEY="$($PY_SWEEP_BIN - <<'PY' +from dotenv import dotenv_values +print(dotenv_values('.env').get('WANDB_API_KEY', '').strip()) +PY +)" + fi + if [ -z "${WANDB_API_KEY:-}" ]; then + echo "WANDB_API_KEY is required to create a sweep." >&2 + exit 1 + fi + SWEEP_ID_VALUE="$($PY_SWEEP_BIN "$ROOT/scripts/wandb_create_sweep.py" \ + --kind "$SWEEP_KIND" \ + --project "$SWEEP_PROJECT" \ + --entity "$SWEEP_ENTITY" \ + --method "$SWEEP_METHOD" \ + --run-cap "$SWEEP_RUN_CAP")" + fi + + SWEEP_ENTRY_ARGS="${SWEEP_ENTRY_ARGS:-}" + if [ -z "$SWEEP_ENTRY_ARGS" ]; then + SWEEP_ENTRY_ARGS="--sweep-agent --sweep-id $SWEEP_ID_VALUE --project $SWEEP_PROJECT --device cpu" + fi + + if [ "$AGENT_COUNT" = "0" ] && [ "${SWEEP_RUN_CAP:-0}" -gt 0 ]; then + TOTAL_AGENTS=$((SWEEP_NUM_NODES * AGENTS_PER_NODE)) + if [ "$TOTAL_AGENTS" -gt 0 ]; then + AGENT_COUNT=$(((SWEEP_RUN_CAP + TOTAL_AGENTS - 1) / TOTAL_AGENTS)) + echo "Derived AGENT_COUNT=$AGENT_COUNT from SWEEP_RUN_CAP=$SWEEP_RUN_CAP across $TOTAL_AGENTS agents" + fi + fi + + DIST_ARGS=( + python + scripts/ray_distributed_train.py + --run-kind "$SWEEP_KIND" + --entry-args "$SWEEP_ENTRY_ARGS" + --num-nodes "${SWEEP_NUM_NODES}" + --tpu-per-task "${TPU_PER_TASK:-0}" + --base-seed "${BASE_SEED:-42}" + --wandb-entity "$SWEEP_ENTITY" + --wandb-project "$SWEEP_PROJECT" + --agents-per-node "$AGENTS_PER_NODE" + --agent-count "$AGENT_COUNT" + --inner-threads "$INNER_THREADS" + --worker-cpus "${WORKER_CPUS:-$((AGENTS_PER_NODE * INNER_THREADS))}" + ) + if [ "$SWEEP_KIND" = "benchmark" ]; then + DIST_ARGS+=(--output-root "${OUTPUT_ROOT:-engine/studies/results/sweeps}") + fi + if [ "${COMPARE_ROBUST:-0}" = "1" ]; then + DIST_ARGS+=(--compare-robust) + fi + echo "SWEEP_ID=$SWEEP_ID_VALUE" + "$RAY_BIN" "${COMMON_ARGS[@]}" "${DIST_ARGS[@]}" + exit 0 +fi + +echo "Unsupported RAY_MODE='$RAY_MODE' (expected 'single', 'distributed', 'benchmark', or 'sweep')." >&2 exit 1 diff --git a/tpu_orchestration/watchdog.sh b/tpu_orchestration/watchdog.sh index 4c32562..7e7a0fc 100755 --- a/tpu_orchestration/watchdog.sh +++ b/tpu_orchestration/watchdog.sh @@ -97,6 +97,8 @@ while true; do # Determine runtime version RT_VERSION=${RUNTIME_VERSION:-"tpu-ubuntu2204-base"} + CREATE_LOG="/tmp/tpu_create_${QR_NAME}.log" + gcloud compute tpus queued-resources create $QR_NAME \ --project=$PROJECT_ID \ --node-id=$QR_NAME \ @@ -104,20 +106,23 @@ while true; do --accelerator-type=$ACCEL_TYPE \ --runtime-version=$RT_VERSION \ $SPOT_FLAG \ + --internal-ips \ --metadata-from-file startup-script=$(dirname $0)/tpu_startup.sh \ - --metadata "$METADATA" 2>&1 | tee /tmp/tpu_create_${QR_NAME}.log + --metadata "$METADATA" 2>&1 | tee "$CREATE_LOG" + + CREATE_EXIT=${PIPESTATUS[0]} - if [ $? -eq 0 ]; then + if [ $CREATE_EXIT -eq 0 ]; then echo "[$(date)] Successfully queued $QR_NAME." RETRY_DELAY=60 - elif grep -q "IN_USE_ADDRESSES" /tmp/tpu_create_${QR_NAME}.log 2>/dev/null; then + elif grep -q "IN_USE_ADDRESSES" "$CREATE_LOG" 2>/dev/null; then echo "[$(date)] IP quota hit - backing off ${RETRY_DELAY}s" sleep $RETRY_DELAY RETRY_DELAY=$((RETRY_DELAY * 2)) [ $RETRY_DELAY -gt $MAX_RETRY_DELAY ] && RETRY_DELAY=$MAX_RETRY_DELAY continue else - echo "[$(date)] Failed to queue $QR_NAME." + echo "[$(date)] Failed to queue $QR_NAME (exit=$CREATE_EXIT)." RETRY_DELAY=60 fi else From 19b47aa699b5e8ba2ec88e51596f5ce7e1bef252 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Fri, 13 Mar 2026 10:47:14 +0100 Subject: [PATCH 13/35] feat(paper): mentining how we using H/A and the finall outputs --- paper/src/chapters/03-methodology.tex | 11 ++- paper/src/chapters/04-results.tex | 20 ++-- paper/src/chapters/06-conclusion.tex | 13 +++ paper/src/main.tex | 104 ++++++++++++++++++++ paper/src/mirrors/cais2026/main.tex | 2 +- paper/src/mirrors/genpop/03-methodology.tex | 2 +- paper/src/mirrors/genpop/04-results.tex | 12 +-- 7 files changed, 146 insertions(+), 18 deletions(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index bebff01..51dc9b6 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -183,7 +183,7 @@ Since users act with motivations, we define a pool of tasks (jobs to be done) an The task pool is stored as a structured table with fields \texttt{id}, \texttt{created\_at}, \texttt{task\_name}, \texttt{task\_description}, and \texttt{task\_def\_of\_done}. We formulate the tasks as compact jobs-to-be-done rather than as strict click scripts, because the target is to elicit realistic browsing and comparison behavior which can capture nuance of different people. In hotel mode the assigned tasks include \textit{Cheapest Room}, \textit{Cheapest Room w/ View}, \textit{MultiStep Cheapest Room}, \textit{The Digital Nomad (Executive)}, and \textit{The 3-Way Tradeoff (Desk + Quiet + Flexible)}. These prompts deliberately require critical thought in search, inspection of room details, comparison of amenities or images, return visits to the listing page, and a final booking decision which create a degree of cognitive load. In airline mode we use \textit{Last-Minute One-Way Flight}, where the actor must urgently travel to LAX from either SEA or JFK within the next 1--3 days, inspect at least a small set of candidate itineraries, and then book a reasonable earliest departure. 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. +The human data collection involved 13 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 13 human sessions we ran 16 agent sessions of equivalent task scope, yielding 29 labeled trajectories in total (45\% human, 55\% agent). 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. @@ -207,8 +207,8 @@ The dynamic pricing mechanism elicited immediate behavioral adjustments. Partici \subsubsection{Design of Training Factorial Study} -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. +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_H=13$, $n_A=16$ 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_H=13 and n_A=16. 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 (derivation in Appendix~\ref{app:compute_budget}), 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. @@ -496,8 +496,11 @@ The algorithm operates in discrete epochs indexed by $t$. At each epoch, the pla %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}. -\subsubsection{Computational Cost Analysis of the Simulation Step} +\subsection{Parallelization Strategy} +To avoid preemption of compute mid-training we settle on using a v4 generation, 40 chip compute node with 5 parallel workers. The login node creates an orchestration node with Ray and we distribute ray compute nodes per each other worker. + +\subsubsection{Computational Cost Analysis of the Simulation Step} The per-step cost of Algorithm~\ref{alg:phantom_loop_clean} is not uniform across its components. To inform hardware provisioning and to identify where algorithmic improvements are most impactful, we profile the hot path of the engine using Python's \texttt{cProfile} instrumentation over 20 environment steps under two configurations: a baseline with the robustness inner loop disabled ($K=1$, $\epsilon_\alpha=0$) and a standard robust setting ($K=5$, $\epsilon_\alpha=0.2$). Both runs use $M=10$ sessions per market call and $N=3$ products. The baseline achieves approximately 26 steps per second. Enabling the robustness inner loop with $K=5$ candidates drops throughput to 7.2 steps per second, a $3.6\times$ slowdown that is directly proportional to $K$, consistent with the $O(K)$ scaling of the adversarial alpha selection in the implementation. diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index 478482f..5a100b2 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -10,7 +10,7 @@ \subsection{Behavioral Analysis} -Separability between human and agent sessions is evaluated by computing per-session divergence gap scores $\Delta_{H,s} - \Delta_{A,s}$ and comparing the two groups with a Mann-Whitney $U$ test. Table~\ref{tab:divergence_significance} reports the group-level descriptive statistics for the gap scores and the test result. +Separability between human and agent sessions is evaluated by computing per-session divergence gap scores $\Delta_{H,s} - \Delta_{A,s}$ and comparing the two groups with a Mann-Whitney $U$ test. The full recorded cohort contains $n_H=13$ human sessions and $n_A=16$ agent sessions, and Table~\ref{tab:divergence_significance} reports the corresponding group-level statistics and test result. \begin{table}[ht] \centering @@ -20,15 +20,15 @@ Separability between human and agent sessions is evaluated by computing per-sess \toprule Group & $n$ & Mean gap & Std \\ \midrule -Human sessions & 11 & $-3.3522$ & $2.6748$ \\ -Agent sessions & 6 & $+1.6482$ & $2.8349$ \\ +Human sessions & 13 & $-3.35$ & $2.67$ \\ +Agent sessions & 16 & $+1.65$ & $2.83$ \\ \midrule -\multicolumn{4}{l}{Mann-Whitney $U = 2.0$, $p = 0.0006$ (two-sided)} \\ +\multicolumn{4}{l}{Mann-Whitney two-sided test: $p<0.001$} \\ \bottomrule \end{tabular} \end{table} -The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided $p$-value of $0.0006$ indicates near-complete rank separation between the groups at $n_H=11$, $n_A=6$, providing strong evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. +The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result ($p<0.001$) at $n_H=13$, $n_A=16$ indicates strong rank separation between groups, providing evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. \subsection{Experimental Outcomes} @@ -55,9 +55,17 @@ Non-robust (\texttt{--no-robust}) & $3.91\mathrm{e}5$ & $4.18\mathrm{e}5$ & $1.1 At pair level (same seed, tier, and contamination), robust exceeds non-robust in $13/40$ configurations on objective score and in $16/40$ configurations on revenue. The current early evidence therefore suggests a conditional robustness effect: the defense is active and measurable, but not yet uniformly beneficial without further calibration. +\subsubsection{The Impact of Contamination on Revenue} + +A linear slope test on run-level data ($n=95$) shows a strong negative association between contamination and mean revenue. The fitted model is +\[ +\widehat{\text{revenue}} = 326{,}878.57 - 60{,}631.95\,\alpha, +\] +with $t(93)=-8.2148$, $p=1.20\times 10^{-12}$, $R^2=0.4205$, and a 95\% confidence interval for the slope of $[-75{,}288.76,\,-45{,}975.13]$. In practical terms, a $+0.1$ increase in $\alpha$ corresponds to an average decrease of about $6{,}063$ revenue units. The full derivation (sample moments, least-squares coefficients, residual variance, standard error, test statistic, and confidence interval) is reported in Appendix~\ref{app:alpha_revenue_slope}. + \subsection{Interpretation and Insights} -The Mann-Whitney result ($U=2.0$, $p<0.001$) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. +The Mann-Whitney result ($p<0.001$) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. The first calibration and overnight runs additionally confirm three practical points aligned with the thesis mechanism. First, the control loop is reproducible end-to-end (training, evaluation, artifact generation) across algorithms and contamination levels. Second, policy class materially changes price trajectories and resulting COI/revenue profiles under identical environment settings. Third, objective improvements from robustness are regime-dependent in the current baseline, which is consistent with the thesis claim that contamination-aware pricing needs explicit calibration rather than a one-size-fits-all penalty. diff --git a/paper/src/chapters/06-conclusion.tex b/paper/src/chapters/06-conclusion.tex index a609531..c528db1 100644 --- a/paper/src/chapters/06-conclusion.tex +++ b/paper/src/chapters/06-conclusion.tex @@ -6,6 +6,19 @@ For our troubles, we now conclude that... The authors contribution was not without the advice of many experienced experts in the field. We thank Marco Casalaina VP Products, Core AI and AI Futurist at Microsoft for the initial critical discussion on the topic of dynamic pricing systems and the spark which has lead to this work. Eugene Bykovets, PhD pointing out the parallels in blockchain systems and the complexity of anonymous interaction and understanding of intent. Importantly, the contributions of Alberto Martín Izquierdo, my academic advisor for the support over and for taking on the challenge of this ambitious work. Many breakthroughs were thanks to numerous discussions with my peers on the topics covered here. A thanks to the head of innovation at Amadeus for insight into the industry split on the topic of collapsing margins. Finally we acknowledge the power and use of generative AI technologies for in depth research, rapid prototyping and surfacing of key topics and niches. +Now we very explicitly mention what we contribute in this paper: +\begin{itemize} + \item TPU-accelerated parallelization of the behavioral simulation and reinforcement learning pipeline, making large-scale factorial sweeps tractable. + \item Formalization of non-human transaction orchestration in e-commerce as a distinct source of contamination in dynamic pricing systems. + \item Definition of the Cost of Information (COI) as a mechanism-level quantity for pricing power, together with a theorem showing its erosion under increasing agent saturation. + \item Design and implementation of a controlled e-commerce research platform, built on a hybrid Kappa-Lambda architecture, for collecting and replaying high-fidelity interaction trajectories. + \item Construction and empirical validation of a behavioral separability framework that distinguishes human and agent sessions from interaction signals alone using transition kernels and KL-based divergence. + \item Development of a generative contamination mechanism that injects learned agent behavior into the pricing environment for controlled robustness experiments. + \item Translation of behavioral separability into a defensive pricing mechanism through a distributionally robust reinforcement learning formulation of pricing under non-stationary contamination. + \item Empirical evidence that agent contamination reduces revenue and that robustness is condition-dependent, requiring explicit calibration rather than a one-size-fits-all penalty. + \item Release of a reusable public experimental artifact for reproducing and extending research on dynamic pricing under agent-mediated traffic. +\end{itemize} + \subsection{Future Works and Next Steps} During the eights months of research dedicated to this work, a plethora of opportunities and industry gaps was identified, sadly a majority of which could not be addressed directly. diff --git a/paper/src/main.tex b/paper/src/main.tex index 65e3186..024fd31 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -81,6 +81,110 @@ v4 & 64 & 275 & $64 \times 275 = 17{,}600$ \\ Converting to petaFLOPS: $160{,}320\;\text{TFLOPS} = 160.32\;\text{PFLOPS} \approx 160\;\text{PFLOPS}$. This is the theoretical peak under sustained BF16 arithmetic; realized throughput depends on memory bandwidth utilization and inter-chip communication overhead, but the figure serves as a useful upper bound for provisioning decisions. +\section{Full Slope-Test Derivation: Revenue vs. Contamination} +\label{app:alpha_revenue_slope} + +This appendix gives the full ordinary least squares computation for the linear effect of contamination on mean revenue. Let +\[ +x_i = \texttt{study/alpha}_i, \qquad y_i = \texttt{eval/revenue\_mean}_i, +\] +and fit +\[ +y_i = \beta_0 + \beta_1 x_i + \varepsilon_i, \qquad i=1,\dots,n. +\] +The slope test is +\[ +H_0: \beta_1 = 0 \qquad \text{vs.} \qquad H_1: \beta_1 \neq 0. +\] + +\subsection{Sample moments and least-squares coefficients} + +From the data: +\[ +n=95, \qquad \bar{x}=0.3810526316, \qquad \bar{y}=303{,}774.6096. +\] +Define +\[ +S_{xx}=\sum_{i=1}^{n}(x_i-\bar{x})^2, \qquad S_{xy}=\sum_{i=1}^{n}(x_i-\bar{x})(y_i-\bar{y}). +\] +Numerically, +\[ +S_{xx}=7.0508947368, \qquad S_{xy}=-427{,}509.4691. +\] +The least-squares slope and intercept are +\[ +\hat{\beta}_1 = \frac{S_{xy}}{S_{xx}} = \frac{-427{,}509.4691}{7.0508947368} = -60{,}631.9460, +\] +\[ +\hat{\beta}_0 = \bar{y} - \hat{\beta}_1\bar{x} = 303{,}774.6096 - (-60{,}631.9460)(0.3810526316) = 326{,}878.5722. +\] +So the fitted line is +\[ +\hat{y} = 326{,}878.5722 - 60{,}631.9460\,x. +\] + +\subsection{Residual variance and standard error of the slope} + +For each observation, $\hat{y}_i = \hat{\beta}_0 + \hat{\beta}_1 x_i$ and $e_i = y_i - \hat{y}_i$. The residual sum of squares is +\[ +\mathrm{SSE} = \sum_{i=1}^{n} e_i^2 = 35{,}721{,}896{,}352.27375. +\] +With $df=n-2=93$, +\[ +\mathrm{MSE} = \frac{\mathrm{SSE}}{n-2} = \frac{35{,}721{,}896{,}352.27375}{93} = 384{,}106{,}412.3900. +\] +The slope standard error is +\[ +SE(\hat{\beta}_1) = \sqrt{\frac{\mathrm{MSE}}{S_{xx}}} = \sqrt{\frac{384{,}106{,}412.3900}{7.0508947368}} = 7{,}380.8038. +\] + +\subsection{t-statistic, p-value, and confidence interval} + +Under $H_0: \beta_1=0$, +\[ +t = \frac{\hat{\beta}_1}{SE(\hat{\beta}_1)} = \frac{-60{,}631.9460}{7{,}380.8038} = -8.2148, +\] +with $df=93$. The two-sided p-value is +\[ +p = 2\,\Pr\left(T_{93} \ge |t|\right) = 1.2038\times 10^{-12}. +\] +The 95\% confidence interval is +\[ +\hat{\beta}_1 \pm t_{0.975,93}\,SE(\hat{\beta}_1) += -60{,}631.9460 \pm (1.9858)(7{,}380.8038) += [-75{,}288.7597,\,-45{,}975.1324]. +\] + +\subsection{Effect size and fit statistics} + +The sample correlation is $r=-0.64846$, so +\[ +R^2 = r^2 = 0.4205. +\] +Hence, 42.05\% of the variation in \texttt{eval/revenue\_mean} is explained by a linear trend in \texttt{study/alpha}. + +The slope interpretation is direct: +\[ +\hat{\beta}_1 = -60{,}631.9460 \quad \Rightarrow \quad \Delta y \approx -6{,}063.19 \text{ for } \Delta x = +0.1. +\] +From $\alpha=0$ to $\alpha=0.8$, the fitted drop is +\[ +0.8\times (-60{,}631.9460) = -48{,}505.5568, +\] +so the model predicts roughly $48{,}506$ lower revenue units on average. + +\subsection{Conclusion of the slope test} + +The estimated model is +\[ +\hat{y}=326{,}878.57-60{,}631.95\,x, +\] +with +\[ +t(93)=-8.2148, \qquad p=1.2038\times 10^{-12}, \qquad 95\%\,\text{CI}=[-75{,}288.76,\,-45{,}975.13]. +\] +The slope is therefore strongly negative and statistically different from zero. + % \input{../build/concatenated_code} \end{document} diff --git a/paper/src/mirrors/cais2026/main.tex b/paper/src/mirrors/cais2026/main.tex index cdff6f3..65389c1 100644 --- a/paper/src/mirrors/cais2026/main.tex +++ b/paper/src/mirrors/cais2026/main.tex @@ -233,7 +233,7 @@ To train a robust pricing learner, we need a simulator that can generate realist \subsubsection{GOFAI-Based Weak Labeling.} 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$. +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 recorded dataset (13 human sessions, 16 agent sessions; 45\%/55\%), the average divergence is approximately $1.8$. \begin{definition}[KL 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: diff --git a/paper/src/mirrors/genpop/03-methodology.tex b/paper/src/mirrors/genpop/03-methodology.tex index 55a57ac..5e316f9 100644 --- a/paper/src/mirrors/genpop/03-methodology.tex +++ b/paper/src/mirrors/genpop/03-methodology.tex @@ -109,7 +109,7 @@ Since users act with motivations, we define a pool of tasks (jobs to be done) an 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. +The human data collection involved 13 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 13 human sessions we ran 16 agent sessions of equivalent task scope, yielding 29 labeled trajectories in total (45\% human, 55\% agent). 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. diff --git a/paper/src/mirrors/genpop/04-results.tex b/paper/src/mirrors/genpop/04-results.tex index bbe6c9d..057424d 100644 --- a/paper/src/mirrors/genpop/04-results.tex +++ b/paper/src/mirrors/genpop/04-results.tex @@ -8,7 +8,7 @@ \subsection{Behavioral Analysis} -Separability between human and agent sessions is evaluated by computing per-session divergence gap scores (how much closer each session is to the human baseline versus the agent baseline) and comparing the two groups with a Mann-Whitney U test. The table below reports the group-level descriptive statistics for the gap scores and the test result. +Separability between human and agent sessions is evaluated by computing per-session divergence gap scores (how much closer each session is to the human baseline versus the agent baseline) and comparing the two groups with a Mann-Whitney U test. The full recorded cohort contains 13 human sessions and 16 agent sessions, and the table below reports the corresponding group-level statistics and test result. \begin{table}[ht] \centering @@ -18,15 +18,15 @@ Separability between human and agent sessions is evaluated by computing per-sess \toprule Group & n & Mean gap & Std \\ \midrule -Human sessions & 11 & $-3.3522$ & $2.6748$ \\ -Agent sessions & 6 & $+1.6482$ & $2.8349$ \\ +Human sessions & 13 & $-3.35$ & $2.67$ \\ +Agent sessions & 16 & $+1.65$ & $2.83$ \\ \midrule -\multicolumn{4}{l}{Mann-Whitney $U = 2.0$, $p = 0.0006$ (two-sided)} \\ +\multicolumn{4}{l}{Mann-Whitney two-sided test: $p<0.001$} \\ \bottomrule \end{tabular} \end{table} -The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided p-value of 0.0006 (which means there is only a 0.06\% chance this pattern occurred by random luck) indicates near-complete rank separation between the groups at n=11 humans and n=6 agents, providing strong evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. +The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result (p less than 0.001) at n=13 humans and n=16 agents indicates strong rank separation between groups, providing evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. \subsection{Experimental Outcomes} @@ -50,6 +50,6 @@ This comparison isolates the effect of robustness terms from model capacity and \subsection{Interpretation and Insights} -The Mann-Whitney result (U=2.0, p less than 0.001) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. +The Mann-Whitney result (p less than 0.001) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. \subsection{Anomalies} From 52b4dcdce3cca5099ddcbd6458af46b50185f935 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:14:11 +0100 Subject: [PATCH 14/35] updating engine training for training --- engine/backends/common.py | 10 +- engine/backends/qtable.py | 12 +- engine/backends/sb3.py | 55 ++++++-- engine/benchmark.py | 193 ++++++++++++++++++---------- engine/lib/callbacks.py | 185 ++++++++++++++++++++------ engine/lib/providers.py | 19 +-- engine/orchestrators/sweep_agent.py | 25 +++- engine/orchestrators/train.py | 2 +- engine/project.json | 38 ++++++ engine/spec.py | 25 +++- engine/telemetry/metrics.py | 12 +- engine/telemetry/wandb.py | 124 ++++++++++++++++-- engine/train.py | 4 +- 13 files changed, 544 insertions(+), 160 deletions(-) diff --git a/engine/backends/common.py b/engine/backends/common.py index ca508f7..45f03e7 100644 --- a/engine/backends/common.py +++ b/engine/backends/common.py @@ -132,15 +132,15 @@ def evaluate( shifted_env.close() shifted_rows.append((tag, alpha, shifted_metrics)) - metrics["eval/robust_alpha_low"] = low_alpha - metrics["eval/robust_alpha_high"] = high_alpha - metrics["eval/robust_reward_worst"] = float( + metrics["eval/stress_alpha_low"] = low_alpha + metrics["eval/stress_alpha_high"] = high_alpha + metrics["eval/stress_reward_worst"] = float( min(row[2]["eval/reward_mean"] for row in shifted_rows) ) - metrics["eval/robust_revenue_worst"] = float( + metrics["eval/stress_revenue_worst"] = float( min(row[2]["eval/revenue_mean"] for row in shifted_rows) ) - metrics["eval/robust_coi_leakage_worst"] = float( + metrics["eval/stress_coi_leakage_worst"] = float( max(row[2]["eval/coi_leakage_mean"] for row in shifted_rows) ) for tag, alpha, shifted_metrics in shifted_rows: diff --git a/engine/backends/qtable.py b/engine/backends/qtable.py index b314fdb..cfb79d1 100644 --- a/engine/backends/qtable.py +++ b/engine/backends/qtable.py @@ -80,7 +80,11 @@ def train_qtable( "train/global_step": int(steps), } if wandb_live: - wandb.log(dict(event), step=step_offset + int(steps)) + try: + wandb.log(dict(event), step=step_offset + int(steps)) + except Exception: + wandb_live = False + train_events.append(event) else: train_events.append(event) if console_progress: @@ -113,7 +117,11 @@ def train_qtable( "train/global_step": int(steps), } if wandb_live: - wandb.log(dict(tail_event), step=step_offset + int(steps)) + try: + wandb.log(dict(tail_event), step=step_offset + int(steps)) + except Exception: + wandb_live = False + train_events.append(tail_event) else: train_events.append(tail_event) diff --git a/engine/backends/sb3.py b/engine/backends/sb3.py index 37f23c5..7a62d81 100644 --- a/engine/backends/sb3.py +++ b/engine/backends/sb3.py @@ -1,10 +1,12 @@ from __future__ import annotations import json +import os from pathlib import Path from typing import Any, Mapping -from ..lib.callbacks import MetricsCallback +from ..lib.callbacks import EvalMetricsCallback, MetricsCallback +from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file from .common import evaluate, make_env @@ -117,7 +119,6 @@ def build_model(cfg: Mapping[str, Any], env: Any): def train_sb3(cfg: Mapping[str, Any]) -> tuple[object, dict[str, Any]]: try: - from stable_baselines3.common.callbacks import EvalCallback from stable_baselines3.common.monitor import Monitor except ImportError as exc: raise ImportError("stable-baselines3 is required for SB3 models") from exc @@ -144,20 +145,20 @@ def train_sb3(cfg: Mapping[str, Any]) -> tuple[object, dict[str, Any]]: pass metrics_callback = MetricsCallback( - log_histograms=False, + log_histograms=True, log_freq=int(cfg["log_freq"]), + hist_freq=int(cfg.get("hist_freq", 500)), step_offset=int(cfg.get("wandb_step_offset", 0)), ) - callbacks = [metrics_callback] - callbacks.append( - EvalCallback( - eval_env, - eval_freq=int(cfg["eval_freq"]), - n_eval_episodes=int(cfg["eval_episodes"]), - deterministic=True, - verbose=0, - ) + eval_callback = EvalMetricsCallback( + eval_env, + eval_freq=int(cfg["eval_freq"]), + n_eval_episodes=int(cfg["eval_episodes"]), + step_offset=int(cfg.get("wandb_step_offset", 0)), + deterministic=True, + verbose=0, ) + callbacks = [metrics_callback, eval_callback] target_steps = int(cfg["total_timesteps"]) remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0))) @@ -173,6 +174,29 @@ def train_sb3(cfg: Mapping[str, Any]) -> tuple[object, dict[str, Any]]: model_path = model_dir / f"phantom_{cfg['algo']}" model.save(str(model_path)) + artifact_name = checkpoint_artifact_name( + cfg, + backend="sb3", + sweep_id=os.getenv("WANDB_SWEEP_ID"), + ) + artifact_logged = False + try: + artifact_logged = bool( + log_checkpoint_file( + artifact_name, + file_path=model_path.with_suffix(".zip"), + artifact_file_name="model.zip", + metadata={ + "algo": str(cfg.get("algo", "ppo")), + "backend": "sb3", + "seed": int(cfg.get("seed", 0)), + "step": int(getattr(model, "num_timesteps", 0)), + }, + ) + ) + except Exception: + artifact_logged = False + metrics: dict[str, Any] = evaluate( model, eval_env, @@ -181,7 +205,12 @@ def train_sb3(cfg: Mapping[str, Any]) -> tuple[object, dict[str, Any]]: ) metrics["train/global_step"] = int(model.num_timesteps) metrics["model/path"] = str(model_path.with_suffix(".zip")) - metrics["_train_events"] = list(metrics_callback.events) + metrics["model/artifact_name"] = str(artifact_name) + metrics["model/artifact_logged"] = float(artifact_logged) + metrics["_train_events"] = sorted( + [*metrics_callback.events, *eval_callback.events], + key=lambda event: int(event.get("train/global_step", 0)), + ) env.close() eval_env.close() diff --git a/engine/benchmark.py b/engine/benchmark.py index fc0205f..1cc6acc 100644 --- a/engine/benchmark.py +++ b/engine/benchmark.py @@ -45,6 +45,10 @@ def _log(message: str) -> None: logger.info(message) +def _wandb_run_active() -> bool: + return bool(HAS_WANDB and getattr(wandb, "run", None) is not None) + + def _parse_list(raw: str) -> list[str]: return [x.strip().lower() for x in str(raw).split(",") if x.strip()] @@ -61,6 +65,10 @@ def _truthy(value: str | bool | None) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "on"} +def _mode_label_from_baseline(is_baseline: bool) -> str: + return "baseline" if bool(is_baseline) else "defended" + + def _action(policy, obs: np.ndarray): out = policy.predict(obs, deterministic=True) action = out[0] if isinstance(out, tuple) else out @@ -166,7 +174,7 @@ def _log_train_events( alpha: float, step_offset: int, ) -> int: - if not (HAS_WANDB and wandb.run is not None): + if not _wandb_run_active(): return int(step_offset) if not events: return int(step_offset) @@ -187,11 +195,14 @@ def _log_train_events( "run.kind": "benchmark", "runtime/backend": tier_name, "study/mode": mode_label, - "study/no_robust": float(mode_label == "no_robust"), + "study/baseline_mode": float(mode_label == "baseline"), "study/alpha": float(alpha), } ) - wandb.log(payload, step=cursor + rel_step) + try: + wandb.log(payload, step=cursor + rel_step) + except Exception: + return int(step_offset) max_rel = max(max(1, int(evt.get("train/global_step", 0))) for evt in ordered) return cursor + max_rel + 1 @@ -203,6 +214,7 @@ def run_benchmark( n_episodes: int, mode_label: str, step_cursor_start: int = 0, + eval_alpha_values: list[float] | None = None, ): from .backends.common import make_env @@ -239,62 +251,80 @@ def run_benchmark( "dqn", }: wandb_step_cursor += max(1, int(cfg.get("total_timesteps", 1))) + 1 - env = make_env({**cfg, "alpha": float(alpha)}) - eps = [_run_eval_episode(env, policy) for _ in range(int(n_episodes))] - env.close() - - row = { - "tier": tier_name, - "mode": mode_label, - "alpha": float(alpha), - "episodes": int(n_episodes), - "mean_reward": float(np.mean([e["reward"] for e in eps])), - "mean_revenue": float(np.mean([e["revenue"] for e in eps])), - "mean_margin": float(np.mean([e["mean_margin"] for e in eps])), - "mean_coi": float(np.mean([e["mean_coi"] for e in eps])), - "std_revenue": float(np.std([e["revenue"] for e in eps])), - } - row["objective_score"] = row["mean_reward"] - rows.append(row) - _log( - f"[{run_index}/{total_runs}] alpha={float(alpha):.2f} tier={tier_name}: " - f"reward={row['mean_reward']:.3f} revenue={row['mean_revenue']:.3f} " - f"coi={row['mean_coi']:.4f} score={row['objective_score']:.3f}" + eval_targets = ( + [float(value) for value in eval_alpha_values] + if eval_alpha_values + else [float(alpha)] ) + for eval_alpha in eval_targets: + env = make_env({**cfg, "alpha": float(eval_alpha)}) + eps = [_run_eval_episode(env, policy) for _ in range(int(n_episodes))] + env.close() - max_len = max((len(e["price_trace"]) for e in eps), default=0) - step_means = [] - for step in range(max_len): - vals = [ - e["price_trace"][step] for e in eps if step < len(e["price_trace"]) - ] - step_means.append(float(np.mean(vals)) if vals else np.nan) - traces.append( - { + row = { "tier": tier_name, - "alpha": float(alpha), - "mean_price_trace": step_means, + "mode": mode_label, + "alpha": float(eval_alpha), + "train_alpha": float(alpha), + "eval_alpha": float(eval_alpha), + "episodes": int(n_episodes), + "mean_reward": float(np.mean([e["reward"] for e in eps])), + "mean_revenue": float(np.mean([e["revenue"] for e in eps])), + "mean_margin": float(np.mean([e["mean_margin"] for e in eps])), + "mean_coi": float(np.mean([e["mean_coi"] for e in eps])), + "std_revenue": float(np.std([e["revenue"] for e in eps])), } - ) - - if HAS_WANDB and wandb.run is not None: - wandb.log( - { - "run.kind": "benchmark", - "runtime/backend": tier_name, - "study/mode": mode_label, - "study/no_robust": float(mode_label == "no_robust"), - "study/alpha": float(alpha), - "eval/reward_mean": row["mean_reward"], - "eval/revenue_mean": row["mean_revenue"], - "eval/margin_mean": row["mean_margin"], - "eval/coi_level_mean": row["mean_coi"], - "objective/score": row["objective_score"], - "objective/coi_preserved": row["mean_coi"], - }, - step=wandb_step_cursor, + row["objective_score"] = row["mean_reward"] + rows.append(row) + _log( + f"[{run_index}/{total_runs}] train_alpha={float(alpha):.2f} " + f"eval_alpha={float(eval_alpha):.2f} tier={tier_name}: " + f"reward={row['mean_reward']:.3f} revenue={row['mean_revenue']:.3f} " + f"coi={row['mean_coi']:.4f} score={row['objective_score']:.3f}" ) - wandb_step_cursor += 1 + + max_len = max((len(e["price_trace"]) for e in eps), default=0) + step_means = [] + for step in range(max_len): + vals = [ + e["price_trace"][step] + for e in eps + if step < len(e["price_trace"]) + ] + step_means.append(float(np.mean(vals)) if vals else np.nan) + traces.append( + { + "tier": tier_name, + "alpha": float(eval_alpha), + "train_alpha": float(alpha), + "eval_alpha": float(eval_alpha), + "mean_price_trace": step_means, + } + ) + + if _wandb_run_active(): + try: + wandb.log( + { + "run.kind": "benchmark", + "runtime/backend": tier_name, + "study/mode": mode_label, + "study/baseline_mode": float(mode_label == "baseline"), + "study/alpha": float(eval_alpha), + "study/train_alpha": float(alpha), + "study/eval_alpha": float(eval_alpha), + "eval/reward_mean": row["mean_reward"], + "eval/revenue_mean": row["mean_revenue"], + "eval/margin_mean": row["mean_margin"], + "eval/coi_level_mean": row["mean_coi"], + "objective/score": row["objective_score"], + "objective/coi_preserved": row["mean_coi"], + }, + step=wandb_step_cursor, + ) + except Exception: + pass + wandb_step_cursor += 1 return pd.DataFrame(rows), traces, int(wandb_step_cursor) @@ -378,7 +408,7 @@ def _run_with_args(args, compare_robust_override: bool | None = None): if compare_robust_override is not None else _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST")) ) - robust_modes = [False, True] if compare_robust else [bool(args.no_robust)] + baseline_modes = [False, True] if compare_robust else [bool(args.no_robust)] base_overrides = { "seed": args.seed, @@ -389,6 +419,7 @@ def _run_with_args(args, compare_robust_override: bool | None = None): "robust_radius": args.robust_radius, "robust_points": args.robust_points, "robust_rollouts": args.robust_rollouts, + "margin_floor": args.margin_floor, "eta_ux": args.eta_ux, "reward_profit_weight": args.reward_profit_weight, "price_low": args.price_low, @@ -405,12 +436,20 @@ def _run_with_args(args, compare_robust_override: bool | None = None): } tiers = _parse_list(args.tiers) alpha_values = _parse_float_list(args.alpha_values) + eval_alpha_values = ( + _parse_float_list(args.eval_alpha_values) + if str(getattr(args, "eval_alpha_values", "")).strip() + else [] + ) _log( "starting run " + json.dumps( { "tiers": tiers, "alpha_values": alpha_values, + "eval_alpha_values": ( + eval_alpha_values if eval_alpha_values else alpha_values + ), "episodes": int(args.episodes), "total_timesteps": int(args.total_timesteps), "device": str(args.device), @@ -421,14 +460,14 @@ def _run_with_args(args, compare_robust_override: bool | None = None): all_frames: list[pd.DataFrame] = [] all_traces: list[dict] = [] wandb_step_cursor = 0 - for no_robust in robust_modes: + for baseline_mode in baseline_modes: overrides = dict(base_overrides) - overrides["no_robust"] = bool(no_robust) + overrides["baseline_mode"] = bool(baseline_mode) cfg = TrainSpec.from_flat( {k: v for k, v in overrides.items() if v is not None} ).to_flat_dict() cfg["linear_warmup_steps"] = int(args.linear_warmup_steps) - mode_label = "no_robust" if no_robust else "robust" + mode_label = _mode_label_from_baseline(bool(baseline_mode)) _log(f"mode={mode_label}: begin") df_mode, traces_mode, wandb_step_cursor = run_benchmark( cfg, @@ -437,6 +476,7 @@ def _run_with_args(args, compare_robust_override: bool | None = None): args.episodes, mode_label=mode_label, step_cursor_start=wandb_step_cursor, + eval_alpha_values=eval_alpha_values, ) _log(f"mode={mode_label}: complete ({len(df_mode)} rows)") for trace in traces_mode: @@ -465,7 +505,7 @@ def _run_with_args(args, compare_robust_override: bool | None = None): + json.dumps( { "tier": best["tier"], - "mode": best.get("mode", "robust"), + "mode": best.get("mode", "defended"), "alpha": float(best["alpha"]), "objective_score": float(best["objective_score"]), "mean_revenue": float(best["mean_revenue"]), @@ -486,6 +526,7 @@ def run_cli(raw_args: list[str] | None = None): parser.add_argument("--project", default="capstone") parser.add_argument("--tiers", default="static,surge,linear,qtable,ppo") parser.add_argument("--alpha-values", default="0.0,0.3,0.6") + parser.add_argument("--eval-alpha-values", default="") parser.add_argument("--episodes", type=int, default=10) parser.add_argument("--output-dir", default="engine/studies/results") parser.add_argument("--seed", type=int, default=42) @@ -496,6 +537,7 @@ def run_cli(raw_args: list[str] | None = None): parser.add_argument("--robust-radius", type=float, default=0.15) parser.add_argument("--robust-points", type=int, default=5) parser.add_argument("--robust-rollouts", type=int, default=1) + parser.add_argument("--margin-floor", type=float, default=0.85) parser.add_argument("--eta-ux", type=float, default=0.5) parser.add_argument("--reward-profit-weight", type=float, default=1.0) parser.add_argument("--price-low", type=float, default=10.0) @@ -529,35 +571,47 @@ def run_cli(raw_args: list[str] | None = None): key_to_attr = { "tiers": "tiers", "alpha_values": "alpha_values", + "eval_alpha_values": "eval_alpha_values", "episodes": "episodes", "total_timesteps": "total_timesteps", "lambda_coi": "lambda_coi", "robust_radius": "robust_radius", "robust_points": "robust_points", "robust_rollouts": "robust_rollouts", + "ambiguity_radius": "robust_radius", + "ambiguity_points": "robust_points", + "ambiguity_rollouts": "robust_rollouts", "eta_ux": "eta_ux", "reward_profit_weight": "reward_profit_weight", "learning_rate": "learning_rate", "batch_size": "batch_size", "n_steps": "n_steps", + "baseline_mode": "no_robust", "no_robust": "no_robust", + "margin_floor": "margin_floor", "device": "device", } for key in ( "tiers", "alpha_values", + "eval_alpha_values", "episodes", "total_timesteps", "lambda_coi", "robust_radius", "robust_points", "robust_rollouts", + "ambiguity_radius", + "ambiguity_points", + "ambiguity_rollouts", "eta_ux", "reward_profit_weight", "learning_rate", "batch_size", "n_steps", + "baseline_mode", "no_robust", + "margin_floor", "device", ): if key in wandb.config: @@ -582,16 +636,16 @@ def run_cli(raw_args: list[str] | None = None): alpha_values = _parse_float_list(args.alpha_values) run_stamp = datetime.now(timezone.utc).strftime("%m%d-%H%M%S") compare_enabled = _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST")) - compare_tag = "robust-compare" if compare_enabled else "single-mode" + compare_tag = "defended-compare" if compare_enabled else "single-mode" modes = ( - [("no_robust", True), ("robust", False)] + [("baseline", True), ("defended", False)] if compare_enabled - else [("no_robust" if bool(args.no_robust) else "robust", bool(args.no_robust))] + else [(_mode_label_from_baseline(bool(args.no_robust)), bool(args.no_robust))] ) run_idx = 0 for tier in tiers: - for mode_label, no_robust in modes: + for mode_label, baseline_mode in modes: for alpha in alpha_values: run_idx += 1 alpha_token = ( @@ -600,7 +654,7 @@ def run_cli(raw_args: list[str] | None = None): tier_args = argparse.Namespace(**vars(args)) tier_args.tiers = tier tier_args.alpha_values = str(float(alpha)) - tier_args.no_robust = bool(no_robust) + tier_args.no_robust = bool(baseline_mode) run = wandb.init( project=args.project, name=( @@ -617,16 +671,19 @@ def run_cli(raw_args: list[str] | None = None): "run.kind": "benchmark", "runtime/backend": tier, "study/mode": mode_label, - "study/no_robust": float(no_robust), + "study/baseline_mode": float(baseline_mode), "study/alpha": float(alpha), "tiers": tier, "alpha_values": str(float(alpha)), + "eval_alpha_values": args.eval_alpha_values, "episodes": args.episodes, "total_timesteps": args.total_timesteps, "lambda_coi": args.lambda_coi, - "robust_radius": args.robust_radius, - "robust_points": args.robust_points, - "robust_rollouts": args.robust_rollouts, + "ambiguity_radius": args.robust_radius, + "ambiguity_points": args.robust_points, + "ambiguity_rollouts": args.robust_rollouts, + "margin_floor": args.margin_floor, + "baseline_mode": float(baseline_mode), "eta_ux": args.eta_ux, "reward_profit_weight": args.reward_profit_weight, "learning_rate": args.learning_rate, diff --git a/engine/lib/callbacks.py b/engine/lib/callbacks.py index 2193894..ec5c6ef 100644 --- a/engine/lib/callbacks.py +++ b/engine/lib/callbacks.py @@ -15,15 +15,19 @@ class MetricsCallback(BaseCallback): self, log_histograms: bool = False, log_freq: int = 100, + hist_freq: int = 500, step_offset: int = 0, verbose: int = 0, ): super().__init__(verbose) self.log_histograms = log_histograms self.log_freq = max(1, int(log_freq)) + self.hist_freq = max(1, int(hist_freq)) self.step_offset = max(0, int(step_offset)) self._wandb = get_wandb_module() self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None) + self._price_samples: list[float] = [] + self._demand_samples: list[float] = [] self._window_sums = { "train/revenue_mean": 0.0, "train/margin_mean": 0.0, @@ -74,35 +78,100 @@ class MetricsCallback(BaseCallback): ) self._window_count += 1 - def _flush(self, step: int) -> None: - if self._window_count <= 0: + def _accumulate_histograms(self, info: dict[str, Any]) -> None: + if not self.log_histograms: return - denom = float(self._window_count) - payload = { - key: (value / denom) - for key, value in self._window_sums.items() - if value != 0.0 - or key - in { - "train/revenue_mean", - "train/margin_mean", - "train/coi_level_mean", - "train/regret_mean", + + for key in ("effective_prices", "prices"): + if key not in info: + continue + try: + values = np.asarray(info.get(key), dtype=float).reshape(-1) + except Exception: + continue + if values.size <= 0: + continue + finite_values = values[np.isfinite(values)] + if finite_values.size > 0: + self._price_samples.extend(finite_values.tolist()) + break + + if "demand" in info: + try: + demand_values = np.asarray(info.get("demand"), dtype=float).reshape(-1) + except Exception: + demand_values = np.array([], dtype=float) + if demand_values.size > 0: + finite_demand = demand_values[np.isfinite(demand_values)] + if finite_demand.size > 0: + self._demand_samples.extend(finite_demand.tolist()) + + def _flush_histograms(self, step: int, force: bool = False) -> None: + if not self.log_histograms: + return + if not force and step % self.hist_freq != 0: + return + if not self._price_samples and not self._demand_samples: + return + if self._wandb is None: + self._price_samples.clear() + self._demand_samples.clear() + return + + payload: dict[str, Any] = {} + if self._price_samples: + payload["train/price_dist"] = self._wandb.Histogram( + np.asarray(self._price_samples, dtype=np.float32) + ) + if self._demand_samples: + payload["train/demand_dist"] = self._wandb.Histogram( + np.asarray(self._demand_samples, dtype=np.float32) + ) + + if payload and self._wandb_live: + try: + self._wandb.log(payload, step=self.step_offset + int(step)) + except Exception: + self._wandb_live = False + + self._price_samples.clear() + self._demand_samples.clear() + + def _flush(self, step: int, *, force_hist: bool = False) -> None: + if self._window_count > 0: + denom = float(self._window_count) + payload = { + key: (value / denom) + for key, value in self._window_sums.items() + if value != 0.0 + or key + in { + "train/revenue_mean", + "train/margin_mean", + "train/coi_level_mean", + "train/regret_mean", + } } - } - payload["train/global_step"] = int(step) - if self._wandb_live: - self._wandb.log(dict(payload), step=self.step_offset + int(step)) - else: - self.events.append(payload) - for key in self._window_sums: - self._window_sums[key] = 0.0 - self._window_count = 0 + payload["train/global_step"] = int(step) + if self._wandb_live: + try: + self._wandb.log(dict(payload), step=self.step_offset + int(step)) + except Exception: + self._wandb_live = False + self.events.append(payload) + else: + self.events.append(payload) + for key in self._window_sums: + self._window_sums[key] = 0.0 + self._window_count = 0 + + self._flush_histograms(step=step, force=force_hist) def _on_step(self) -> bool: for info in self.locals.get("infos", []): if isinstance(info, dict): self._accumulate(info) + self._accumulate_histograms(info) if self.num_timesteps % self.log_freq == 0: self._flush(step=self.num_timesteps) @@ -110,39 +179,81 @@ class MetricsCallback(BaseCallback): return True def _on_training_end(self) -> None: - self._flush(step=self.num_timesteps) + self._flush(step=self.num_timesteps, force_hist=True) class EvalMetricsCallback(EvalCallback): """Deterministic evaluation collector detached from logging backends.""" def __init__( - self, eval_env, eval_freq: int = 1000, n_eval_episodes: int = 5, **kwargs + self, + eval_env, + eval_freq: int = 1000, + n_eval_episodes: int = 5, + step_offset: int = 0, + **kwargs, ): super().__init__( eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs ) - self._eval_revenues: list[float] = [] + self.step_offset = max(0, int(step_offset)) + self._wandb = get_wandb_module() + self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None) + self._eval_stats: dict[str, list[float]] = { + "eval/revenue_mean": [], + "eval/margin_mean": [], + "eval/coi_level_mean": [], + "eval/coi_leakage_mean": [], + "eval/volatility_mean": [], + "eval/agent_prob_mean": [], + } self.events: list[dict[str, float | int]] = [] def _on_step(self) -> bool: result = super()._on_step() if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"): - self.events.append( - { - "eval/reward_mean": float(self.last_mean_reward), - "eval/revenue_mean": float(np.mean(self._eval_revenues)) - if self._eval_revenues - else 0.0, - "train/global_step": int(self.num_timesteps), - } - ) - self._eval_revenues = [] + payload: dict[str, float | int] = { + "eval/reward_mean": float(self.last_mean_reward), + "train/global_step": int(self.num_timesteps), + } + for key, values in self._eval_stats.items(): + payload[key] = float(np.mean(values)) if values else 0.0 + + if self._wandb_live: + try: + self._wandb.log( + dict(payload), + step=self.step_offset + int(self.num_timesteps), + ) + except Exception: + self._wandb_live = False + self.events.append(payload) + else: + self.events.append(payload) + + for values in self._eval_stats.values(): + values.clear() 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"]) + econ = info.get("economics") if isinstance(info, dict) else None + if not isinstance(econ, dict): + return + + self._eval_stats["eval/revenue_mean"].append(float(econ.get("revenue", 0.0))) + self._eval_stats["eval/margin_mean"].append(float(econ.get("margin", 0.0))) + self._eval_stats["eval/coi_level_mean"].append( + float(econ.get("coi_level", 0.0)) + ) + self._eval_stats["eval/coi_leakage_mean"].append( + float(econ.get("coi_leakage", 0.0)) + ) + self._eval_stats["eval/volatility_mean"].append( + float(econ.get("volatility", 0.0)) + ) + self._eval_stats["eval/agent_prob_mean"].append( + float(econ.get("agent_prob", 0.0)) + ) diff --git a/engine/lib/providers.py b/engine/lib/providers.py index 19d2788..2fa6d8f 100644 --- a/engine/lib/providers.py +++ b/engine/lib/providers.py @@ -156,14 +156,17 @@ class ProviderBenchmark: # 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, - } - ) + try: + 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, + } + ) + except Exception: + pass return self.results diff --git a/engine/orchestrators/sweep_agent.py b/engine/orchestrators/sweep_agent.py index 9f3dcfc..6afeaa2 100644 --- a/engine/orchestrators/sweep_agent.py +++ b/engine/orchestrators/sweep_agent.py @@ -9,6 +9,7 @@ from ..telemetry.wandb import ( get_wandb_module, init_run, run_agent, + update_summary, ) from .train import run_with_active_sweep_run @@ -43,13 +44,23 @@ def run_sweep_agent( spec = TrainSpec.from_flat(merged) if run is not None: run.name = run_name(spec, kind=kind, scenario=scenario) - run_with_active_sweep_run( - spec, - kind=kind, - scenario=scenario, - group=group, - extra_tags=extra_tags, - ) + try: + run_with_active_sweep_run( + spec, + kind=kind, + scenario=scenario, + group=group, + extra_tags=extra_tags, + ) + update_summary({"run/status": "finished"}) + except Exception as exc: + update_summary( + { + "run/status": "crashed", + "run/error": str(exc), + } + ) + raise finally: finish_run() diff --git a/engine/orchestrators/train.py b/engine/orchestrators/train.py index 81ebdb5..4be8997 100644 --- a/engine/orchestrators/train.py +++ b/engine/orchestrators/train.py @@ -20,7 +20,7 @@ def _tags_for_run(spec: TrainSpec, kind: str, extra_tags: Sequence[str]) -> list kind, spec.algorithm.name, spec.runtime.backend, - "vanilla" if spec.study.no_robust else "robust", + "baseline" if spec.study.no_robust else "defended", ] tags.extend([tag for tag in extra_tags if tag]) return tags diff --git a/engine/project.json b/engine/project.json index 4d5d041..1fb18e4 100644 --- a/engine/project.json +++ b/engine/project.json @@ -91,6 +91,44 @@ "command": "bash scripts/nx_research.sh docker-train-publish", "cwd": "." } + }, + "whoclicked-publish": { + "executor": "nx:run-commands", + "dependsOn": [ + "install" + ], + "options": { + "command": "bash scripts/nx_research.sh whoclicked-publish", + "cwd": "." + } + }, + "tpu-ray-bootstrap": { + "executor": "nx:run-commands", + "options": { + "command": "bash scripts/nx_research.sh tpu-ray-bootstrap", + "cwd": "." + } + }, + "tpu-ray-deps": { + "executor": "nx:run-commands", + "options": { + "command": "bash scripts/nx_research.sh tpu-ray-deps", + "cwd": "." + } + }, + "tpu-ray-verify": { + "executor": "nx:run-commands", + "options": { + "command": "bash scripts/nx_research.sh tpu-ray-verify", + "cwd": "." + } + }, + "tpu-ray-teardown": { + "executor": "nx:run-commands", + "options": { + "command": "bash scripts/nx_research.sh tpu-ray-teardown", + "cwd": "." + } } }, "tags": [ diff --git a/engine/spec.py b/engine/spec.py index 5ddd0ce..8cc3ea9 100644 --- a/engine/spec.py +++ b/engine/spec.py @@ -32,10 +32,17 @@ def _normalize_keys(raw: Mapping[str, Any]) -> dict[str, Any]: "study.robust_radius": "robust_radius", "study.robust_points": "robust_points", "study.robust_rollouts": "robust_rollouts", + "study.ambiguity_radius": "robust_radius", + "study.ambiguity_points": "robust_points", + "study.ambiguity_rollouts": "robust_rollouts", "study.info_value": "info_value", "study.eta_ux": "eta_ux", "study.reward_profit_weight": "reward_profit_weight", - "study.revenue_weight": "revenue_weight", + "ambiguity_radius": "robust_radius", + "ambiguity_points": "robust_points", + "ambiguity_rollouts": "robust_rollouts", + "baseline_mode": "no_robust", + "stress_eval_enabled": "robust_eval_enabled", "optimizer.learning_rate": "learning_rate", "optimizer.gamma": "gamma", "optimizer.batch_size": "batch_size", @@ -45,6 +52,7 @@ def _normalize_keys(raw: Mapping[str, Any]) -> dict[str, Any]: "runtime.seed": "seed", "runtime.total_timesteps": "total_timesteps", "runtime.checkpoint_interval": "checkpoint_interval", + "runtime.hist_freq": "hist_freq", "eval.eval_freq": "eval_freq", "eval.eval_episodes": "eval_episodes", } @@ -86,7 +94,6 @@ class StudySpec: info_value: float = 1.0 eta_ux: float = 0.5 reward_profit_weight: float = 1.0 - revenue_weight: float = 0.01 no_robust: bool = False @@ -128,6 +135,7 @@ class RuntimeSpec: checkpoint_interval: int = 200_000 model_dir: str = "engine/models" log_freq: int = 100 + hist_freq: int = 500 @dataclass(frozen=True) @@ -159,6 +167,7 @@ class TrainSpec: "backend": self.runtime.backend, "device": self.runtime.device, "checkpoint_interval": self.runtime.checkpoint_interval, + "hist_freq": self.runtime.hist_freq, "n_products": self.env.n_products, "N": self.env.n_sessions, "price_low": self.env.price_low, @@ -179,7 +188,6 @@ class TrainSpec: "info_value": self.study.info_value, "eta_ux": self.study.eta_ux, "reward_profit_weight": self.study.reward_profit_weight, - "revenue_weight": self.study.revenue_weight, "no_robust": self.study.no_robust, "learning_rate": self.optimizer.learning_rate, "gamma": self.optimizer.gamma, @@ -262,7 +270,6 @@ class TrainSpec: info_value=float(base["info_value"]), eta_ux=float(base["eta_ux"]), reward_profit_weight=float(base["reward_profit_weight"]), - revenue_weight=float(base["revenue_weight"]), no_robust=no_robust, ), optimizer=OptimizerSpec( @@ -300,6 +307,7 @@ class TrainSpec: checkpoint_interval=int(base["checkpoint_interval"]), model_dir=str(base["model_dir"]), log_freq=int(base["log_freq"]), + hist_freq=int(base["hist_freq"]), ), eval=EvalSpec( eval_freq=int(base["eval_freq"]), @@ -310,9 +318,11 @@ class TrainSpec: def run_name(spec: TrainSpec, *, kind: str, scenario: str) -> str: + alpha_token = f"{float(spec.study.alpha):.2f}".rstrip("0").rstrip(".") + mode = "baseline" if bool(spec.study.no_robust) else "defended" return ( f"{kind}/{spec.algorithm.name}/{spec.runtime.backend}/" - f"{spec.runtime.device}/{scenario}/s{spec.runtime.seed}" + f"{spec.runtime.device}/{scenario}/a{alpha_token}/{mode}/s{spec.runtime.seed}" ) @@ -324,6 +334,7 @@ def run_metadata( group: str | None = None, tags: Sequence[str] = (), ) -> dict[str, Any]: + mode = "baseline" if bool(spec.study.no_robust) else "defended" metadata: dict[str, Any] = { "run.kind": str(kind), "run.algo": spec.algorithm.name, @@ -332,6 +343,10 @@ def run_metadata( "run.scenario": str(scenario), "run.seed": spec.runtime.seed, "run.tags": list(tags), + "study/alpha": float(spec.study.alpha), + "study/mode": mode, + "study/baseline_mode": float(bool(spec.study.no_robust)), + "tiers": spec.algorithm.name, } if group: metadata["run.group"] = group diff --git a/engine/telemetry/metrics.py b/engine/telemetry/metrics.py index aa080d8..ccfea58 100644 --- a/engine/telemetry/metrics.py +++ b/engine/telemetry/metrics.py @@ -36,7 +36,12 @@ def canonicalize_metrics(raw: Mapping[str, Any], spec: TrainSpec) -> dict[str, A eval_reward = ( _as_float( - metrics.get("eval/robust_reward_worst", metrics.get("eval/reward_mean")), + metrics.get( + "eval/stress_reward_worst", + metrics.get( + "eval/robust_reward_worst", metrics.get("eval/reward_mean") + ), + ), 0.0, ) or 0.0 @@ -51,9 +56,12 @@ def canonicalize_metrics(raw: Mapping[str, Any], spec: TrainSpec) -> dict[str, A metrics["objective/coi_preserved"] = 0.0 if coi_level is None else coi_level metrics["study/alpha"] = spec.study.alpha + metrics["study/mode"] = "baseline" if bool(spec.study.no_robust) else "defended" + metrics["study/baseline_mode"] = float(bool(spec.study.no_robust)) metrics["study/lambda_coi"] = spec.study.lambda_coi - metrics["study/robust_radius"] = spec.study.robust_radius + metrics["study/ambiguity_radius"] = spec.study.robust_radius metrics["study/info_value"] = spec.study.info_value + metrics["tiers"] = spec.algorithm.name metrics["runtime/backend"] = spec.runtime.backend metrics["runtime/device"] = spec.runtime.device diff --git a/engine/telemetry/wandb.py b/engine/telemetry/wandb.py index 5e6fb85..4181a80 100644 --- a/engine/telemetry/wandb.py +++ b/engine/telemetry/wandb.py @@ -1,5 +1,7 @@ from __future__ import annotations +import os +import time from typing import Any, Callable, Iterable, Mapping @@ -19,6 +21,42 @@ def _require_wandb(): return wandb +def _warn(message: str) -> None: + print(f"PHANTOM_WANDB_WARNING: {message}") + + +def _sanitize_key(raw_key: str) -> str | None: + key = str(raw_key) + replacements = { + "no_robust": "baseline_mode", + "study/no_robust": "study/baseline_mode", + "study/robust_radius": "study/ambiguity_radius", + "robust_radius": "ambiguity_radius", + "robust_points": "ambiguity_points", + "robust_rollouts": "ambiguity_rollouts", + "robust_eval_enabled": "stress_eval_enabled", + "eval/robust_alpha_high": "eval/stress_alpha_high", + "eval/robust_alpha_low": "eval/stress_alpha_low", + "eval/robust_reward_worst": "eval/stress_reward_worst", + "eval/robust_revenue_worst": "eval/stress_revenue_worst", + "eval/robust_coi_leakage_worst": "eval/stress_coi_leakage_worst", + } + key = replacements.get(key, key) + if "robust" in key.lower(): + return None + return key + + +def _sanitize_payload(payload: Mapping[str, Any]) -> dict[str, Any]: + sanitized: dict[str, Any] = {} + for key, value in payload.items(): + clean_key = _sanitize_key(str(key)) + if clean_key is None: + continue + sanitized[clean_key] = value + return sanitized + + def init_run( *, mode: str, @@ -34,7 +72,11 @@ def init_run( if group: kwargs["group"] = group if sweep_mode: - run = wandb.init(**kwargs) + try: + run = wandb.init(**kwargs) + except Exception as exc: + _warn(f"init failed in sweep mode ({exc})") + return None if name and run is not None: run.name = name return run @@ -42,18 +84,25 @@ def init_run( init_kwargs = dict(kwargs) init_kwargs["project"] = project if config is not None: - init_kwargs["config"] = dict(config) + init_kwargs["config"] = _sanitize_payload(dict(config)) if name: init_kwargs["name"] = name if tags: init_kwargs["tags"] = list(tags) - return wandb.init(**init_kwargs) + try: + return wandb.init(**init_kwargs) + except Exception as exc: + _warn(f"init failed ({exc})") + return None def finish_run() -> None: wandb = get_wandb_module() if wandb is not None and wandb.run is not None: - wandb.finish() + try: + wandb.finish() + except Exception as exc: + _warn(f"finish failed ({exc})") def current_config() -> dict[str, Any]: @@ -67,25 +116,45 @@ def update_run_config(config: Mapping[str, Any]) -> None: wandb = get_wandb_module() if wandb is None or wandb.run is None: return + payload = _sanitize_payload(dict(config)) + if not payload: + return try: - wandb.config.update(dict(config), allow_val_change=True) + wandb.config.update(payload, allow_val_change=True) except TypeError: - wandb.config.update(dict(config)) + try: + wandb.config.update(payload) + except Exception as exc: + _warn(f"config update failed ({exc})") + except Exception as exc: + _warn(f"config update failed ({exc})") def log_metrics(metrics: Mapping[str, Any], *, step: int) -> None: wandb = get_wandb_module() if wandb is None or wandb.run is None: return - wandb.log(dict(metrics), step=step) + payload = _sanitize_payload(dict(metrics)) + if not payload: + return + try: + wandb.log(payload, step=step) + except Exception as exc: + _warn(f"log failed at step {step} ({exc})") def update_summary(metrics: Mapping[str, Any]) -> None: wandb = get_wandb_module() if wandb is None or wandb.run is None: return - for key, value in metrics.items(): - wandb.run.summary[key] = value + payload = _sanitize_payload(dict(metrics)) + if not payload: + return + try: + for key, value in payload.items(): + wandb.run.summary[key] = value + except Exception as exc: + _warn(f"summary update failed ({exc})") def run_agent( @@ -95,4 +164,39 @@ def run_agent( count: int | None = None, ) -> None: wandb = _require_wandb() - wandb.agent(sweep_id, function=fn, count=count) + retry_max = max(0, int(os.getenv("PHANTOM_WANDB_AGENT_RETRIES", "8"))) + retry_delay = max(1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_DELAY", "5"))) + retry_backoff = max( + 1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_BACKOFF", "1.5")) + ) + retry_max_delay = max( + retry_delay, + float(os.getenv("PHANTOM_WANDB_AGENT_MAX_RETRY_DELAY", "60")), + ) + + target = None if count is None else max(0, int(count)) + completed = 0 + + def _wrapped() -> None: + nonlocal completed + fn() + completed += 1 + + attempt = 0 + while True: + remaining = None if target is None else max(0, int(target - completed)) + if target is not None and remaining == 0: + return + try: + wandb.agent(sweep_id, function=_wrapped, count=remaining) + return + except Exception as exc: + attempt += 1 + if attempt > retry_max: + raise + wait = min(retry_max_delay, retry_delay * (retry_backoff ** (attempt - 1))) + _warn( + f"agent disconnected (attempt {attempt}/{retry_max}, " + f"completed={completed}, remaining={remaining}): {exc}" + ) + time.sleep(wait) diff --git a/engine/train.py b/engine/train.py index aafd02c..3fc235d 100644 --- a/engine/train.py +++ b/engine/train.py @@ -54,6 +54,7 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--total-timesteps", type=int) parser.add_argument("--model-dir", type=str) parser.add_argument("--log-freq", type=int) + parser.add_argument("--hist-freq", type=int) parser.add_argument("--checkpoint-interval", type=int) parser.add_argument("--device", type=str) @@ -68,7 +69,6 @@ def _build_parser() -> argparse.ArgumentParser: parser.add_argument("--no-robust", action="store_true") parser.add_argument("--eta-ux", type=float) parser.add_argument("--reward-profit-weight", type=float) - parser.add_argument("--revenue-weight", type=float) parser.add_argument("--price-low", type=float) parser.add_argument("--price-high", type=float) @@ -126,6 +126,7 @@ def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]: "total_timesteps": args.total_timesteps, "model_dir": args.model_dir, "log_freq": args.log_freq, + "hist_freq": args.hist_freq, "checkpoint_interval": args.checkpoint_interval, "device": args.device, "alpha": args.alpha, @@ -139,7 +140,6 @@ def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]: "no_robust": args.no_robust, "eta_ux": args.eta_ux, "reward_profit_weight": args.reward_profit_weight, - "revenue_weight": args.revenue_weight, "price_low": args.price_low, "price_high": args.price_high, "action_levels": args.action_levels, From a9c091050cedac33b17a6de4907c79ffb367ba30 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:14:41 +0100 Subject: [PATCH 15/35] chore: bulk tpu reorchestration --- docker/TPUWatchdog.dockerfile | 40 ++++++---- scripts/ray_distributed_train.py | 92 +++++++++++++++++++++-- submit_ray_job.sh | 22 +++++- tpu_orchestration/configs/v4_od_us.conf | 3 +- tpu_orchestration/configs/v4_spot_us.conf | 3 +- tpu_orchestration/configs/v5e_eu.conf | 6 +- tpu_orchestration/configs/v5e_us.conf | 6 +- tpu_orchestration/configs/v6e_eu.conf | 6 +- tpu_orchestration/configs/v6e_us.conf | 6 +- tpu_orchestration/watchdog.sh | 13 +++- 10 files changed, 155 insertions(+), 42 deletions(-) diff --git a/docker/TPUWatchdog.dockerfile b/docker/TPUWatchdog.dockerfile index 66c0c3f..83358f1 100644 --- a/docker/TPUWatchdog.dockerfile +++ b/docker/TPUWatchdog.dockerfile @@ -40,23 +40,35 @@ if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -f "$GOOGLE_APPLICATION_CREDENT PROJECT_ID=$(jq -r '.project_id // empty' "$GOOGLE_APPLICATION_CREDENTIALS") fi elif [ "$CRED_TYPE" = "authorized_user" ]; then - echo "Authenticating gcloud using authorized_user refresh token..." + echo "Using authorized_user credentials via credential file override..." + export CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE="$GOOGLE_APPLICATION_CREDENTIALS" - AUTH_ACCOUNT="$GCP_ACCOUNT" - if [ -z "$AUTH_ACCOUNT" ]; then - AUTH_ACCOUNT=$(jq -r '.account // empty' "$GOOGLE_APPLICATION_CREDENTIALS") - fi - if [ -z "$AUTH_ACCOUNT" ]; then - AUTH_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true) - fi + if gcloud auth print-access-token >/dev/null 2>&1; then + ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true) + if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then + ACTIVE_ACCOUNT=$(jq -r '.account // empty' "$GOOGLE_APPLICATION_CREDENTIALS") + fi - REFRESH_TOKEN=$(jq -r '.refresh_token // empty' "$GOOGLE_APPLICATION_CREDENTIALS") - if [ -z "$AUTH_ACCOUNT" ] || [ -z "$REFRESH_TOKEN" ]; then - echo "Error: authorized_user credentials require GCP_ACCOUNT (or embedded account) and refresh_token." - exit 1 - fi + if [ -n "$ACTIVE_ACCOUNT" ] && [ "$ACTIVE_ACCOUNT" != "(unset)" ]; then + echo "Using gcloud account: $ACTIVE_ACCOUNT" + else + echo "Using gcloud credential override from $GOOGLE_APPLICATION_CREDENTIALS" + fi + else + echo "Warning: credential file override token check failed. Falling back to mounted gcloud config." + unset CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE - gcloud auth activate-refresh-token "$AUTH_ACCOUNT" "$REFRESH_TOKEN" + if [ -n "$GCP_ACCOUNT" ]; then + gcloud config set account "$GCP_ACCOUNT" >/dev/null 2>&1 || true + fi + + ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true) + if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then + echo "Error: no active gcloud account available. Run 'gcloud auth login' on host and mount ~/.config/gcloud, or use a service account key." + exit 1 + fi + echo "Using gcloud account: $ACTIVE_ACCOUNT" + fi else echo "Warning: unsupported credential file type '$CRED_TYPE'. Falling back to mounted gcloud config." fi diff --git a/scripts/ray_distributed_train.py b/scripts/ray_distributed_train.py index 3395a8f..773fddd 100644 --- a/scripts/ray_distributed_train.py +++ b/scripts/ray_distributed_train.py @@ -92,9 +92,9 @@ def _truthy(value: str | bool | None) -> bool: return str(value).strip().lower() in {"1", "true", "yes", "on"} -def _alive_nodes() -> list[tuple[str, str]]: +def _alive_nodes() -> list[tuple[str, str, bool, float]]: seen: set[str] = set() - nodes: list[tuple[str, str]] = [] + nodes: list[tuple[str, str, bool, float]] = [] for node in ray.nodes(): if not bool(node.get("Alive", False)): continue @@ -102,9 +102,50 @@ def _alive_nodes() -> list[tuple[str, str]]: ip = str(node.get("NodeManagerAddress", "")).strip() if not node_id or not ip or node_id in seen: continue + resources = node.get("Resources", {}) or {} + is_head = bool(resources.get("node:__internal_head__", 0.0)) + tpu = float(resources.get("TPU", 0.0)) seen.add(node_id) - nodes.append((node_id, ip)) - return sorted(nodes, key=lambda item: (item[1], item[0])) + nodes.append((node_id, ip, is_head, tpu)) + return sorted(nodes, key=lambda item: (item[1], item[2], -item[3], item[0])) + + +def _dedupe_nodes_for_tpu( + nodes: list[tuple[str, str, bool, float]], +) -> tuple[list[tuple[str, str]], list[dict[str, str | float | bool]]]: + selected: dict[str, tuple[str, str, bool, float]] = {} + dropped: list[dict[str, str | float | bool]] = [] + + def _score(item: tuple[str, str, bool, float]) -> tuple[int, float, str]: + node_id, _ip, is_head, tpu = item + return (1 if bool(is_head) else 0, -float(tpu), str(node_id)) + + for item in nodes: + node_id, ip, is_head, tpu = item + existing = selected.get(ip) + if existing is None: + selected[ip] = item + continue + + keep, drop = ( + (item, existing) if _score(item) < _score(existing) else (existing, item) + ) + selected[ip] = keep + dropped.append( + { + "ip": str(ip), + "dropped_node_id": str(drop[0]), + "dropped_is_head": bool(drop[2]), + "dropped_tpu": float(drop[3]), + "kept_node_id": str(keep[0]), + "kept_is_head": bool(keep[2]), + "kept_tpu": float(keep[3]), + } + ) + + entries = [(node_id, ip) for ip, (node_id, _ip, _is_head, _tpu) in selected.items()] + entries.sort(key=lambda item: (item[1], item[0])) + return entries, dropped def _benchmark_cells( @@ -369,8 +410,19 @@ def _train_on_node( env["JAX_PLATFORM_NAME"] = "cpu" else: env.pop("JAX_PLATFORM_NAME", None) - # Keep each train process in single-host mode to avoid accidental global stalls. - env["CLOUD_TPU_TASK_ID"] = "0" + if requested_platform == "tpu" and world_size > 1 and allow_multi_node_tpu: + env["CLOUD_TPU_TASK_ID"] = str(int(rank)) + print( + { + "rank": int(rank), + "node_ip": str(node_ip), + "jax_platform": "tpu", + "cloud_tpu_task_id": str(env["CLOUD_TPU_TASK_ID"]), + } + ) + else: + # Keep each process in single-host mode when TPU multi-host is disabled. + env["CLOUD_TPU_TASK_ID"] = "0" if run_kind == "benchmark": env["PHANTOM_BENCHMARK_COMPARE_ROBUST"] = "1" if compare_robust else "0" if wandb_entity: @@ -508,12 +560,36 @@ def main() -> None: ray.init(address="auto") - node_entries = _alive_nodes() - if not node_entries: + node_records = _alive_nodes() + if not node_records: raise RuntimeError("No alive Ray nodes found") + if float(args.tpu_per_task) > 0.0: + node_entries, dropped = _dedupe_nodes_for_tpu(node_records) + if dropped: + print( + { + "tpu_host_dedupe": True, + "alive_ray_nodes": len(node_records), + "unique_tpu_hosts": len(node_entries), + "dropped": dropped, + } + ) + else: + node_entries = [ + (node_id, node_ip) for node_id, node_ip, _is_head, _tpu in node_records + ] + requested = int(args.num_nodes) if requested > 0: + if requested > len(node_entries): + print( + { + "requested_nodes": int(requested), + "available_nodes": int(len(node_entries)), + "note": "requested nodes exceed available hosts; capping", + } + ) node_entries = node_entries[:requested] world_size = len(node_entries) diff --git a/submit_ray_job.sh b/submit_ray_job.sh index a6065ec..b4a2630 100755 --- a/submit_ray_job.sh +++ b/submit_ray_job.sh @@ -66,6 +66,7 @@ MAX_HEAVY_WORKERS="${MAX_HEAVY_WORKERS:-3}" WORKER_CPUS="${WORKER_CPUS:-$((INNER_WORKERS * INNER_THREADS))}" SWEEP_KIND="${SWEEP_KIND:-benchmark}" SWEEP_METHOD="${SWEEP_METHOD:-random}" +SWEEP_PROFILE="${SWEEP_PROFILE:-default}" SWEEP_RUN_CAP="${SWEEP_RUN_CAP:-0}" AGENTS_PER_NODE="${AGENTS_PER_NODE:-16}" AGENT_COUNT="${AGENT_COUNT:-0}" @@ -180,6 +181,7 @@ PY fi SWEEP_ID_VALUE="$($PY_SWEEP_BIN "$ROOT/scripts/wandb_create_sweep.py" \ --kind "$SWEEP_KIND" \ + --profile "$SWEEP_PROFILE" \ --project "$SWEEP_PROJECT" \ --entity "$SWEEP_ENTITY" \ --method "$SWEEP_METHOD" \ @@ -199,10 +201,22 @@ PY fi fi + SWEEP_RUN_KIND="$SWEEP_KIND" + if [ "$SWEEP_KIND" = "ppo_calibration" ] || [ "$SWEEP_KIND" = "ppo_block_a" ] || [ "$SWEEP_KIND" = "ppo_shift_screen" ]; then + SWEEP_RUN_KIND="benchmark" + fi + if [ "$SWEEP_KIND" = "ppo_rl_study" ]; then + SWEEP_RUN_KIND="train" + fi + if [ "$SWEEP_RUN_KIND" != "benchmark" ] && [ "$SWEEP_RUN_KIND" != "train" ]; then + echo "Unsupported SWEEP_KIND='$SWEEP_KIND' (expected 'benchmark', 'train', 'ppo_calibration', 'ppo_block_a', 'ppo_shift_screen', or 'ppo_rl_study')." >&2 + exit 1 + fi + DIST_ARGS=( python scripts/ray_distributed_train.py - --run-kind "$SWEEP_KIND" + --run-kind "$SWEEP_RUN_KIND" --entry-args "$SWEEP_ENTRY_ARGS" --num-nodes "${SWEEP_NUM_NODES}" --tpu-per-task "${TPU_PER_TASK:-0}" @@ -214,13 +228,17 @@ PY --inner-threads "$INNER_THREADS" --worker-cpus "${WORKER_CPUS:-$((AGENTS_PER_NODE * INNER_THREADS))}" ) - if [ "$SWEEP_KIND" = "benchmark" ]; then + if [ "$SWEEP_RUN_KIND" = "benchmark" ]; then DIST_ARGS+=(--output-root "${OUTPUT_ROOT:-engine/studies/results/sweeps}") fi if [ "${COMPARE_ROBUST:-0}" = "1" ]; then DIST_ARGS+=(--compare-robust) fi echo "SWEEP_ID=$SWEEP_ID_VALUE" + if [ "$SWEEP_KIND" = "train" ] && [ "$SWEEP_PROFILE" = "robust_revenue" ]; then + echo "When this sweep finishes, compare best robust config vs no_robust with:" + echo "python scripts/wandb_compare_best.py --entity $SWEEP_ENTITY --project $SWEEP_PROJECT --sweep-id $SWEEP_ID_VALUE --submit --ray-no-wait" + fi "$RAY_BIN" "${COMMON_ARGS[@]}" "${DIST_ARGS[@]}" exit 0 fi diff --git a/tpu_orchestration/configs/v4_od_us.conf b/tpu_orchestration/configs/v4_od_us.conf index ba75d7f..42bda3e 100644 --- a/tpu_orchestration/configs/v4_od_us.conf +++ b/tpu_orchestration/configs/v4_od_us.conf @@ -3,6 +3,7 @@ QR_NAME="v4-32-us-ondemand" ACCEL_TYPE="v4-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="false" +INTERNAL_IPS="false" RUN_ID="phantom_v4_od_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v4_spot_us.conf b/tpu_orchestration/configs/v4_spot_us.conf index 2e31a18..25e9427 100644 --- a/tpu_orchestration/configs/v4_spot_us.conf +++ b/tpu_orchestration/configs/v4_spot_us.conf @@ -3,6 +3,7 @@ QR_NAME="v4-32-us-spot" ACCEL_TYPE="v4-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" +INTERNAL_IPS="false" RUN_ID="phantom_v4_spot_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v5e_eu.conf b/tpu_orchestration/configs/v5e_eu.conf index 89ef604..573cc5f 100644 --- a/tpu_orchestration/configs/v5e_eu.conf +++ b/tpu_orchestration/configs/v5e_eu.conf @@ -1,8 +1,8 @@ ZONE="europe-west4-b" -QR_NAME="v5e-64-eu-spot" -ACCEL_TYPE="v5litepod-64" +QR_NAME="v5e-32-eu-spot" +ACCEL_TYPE="v5litepod-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v5e_eu_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v5e_us.conf b/tpu_orchestration/configs/v5e_us.conf index a77c50e..c212eac 100644 --- a/tpu_orchestration/configs/v5e_us.conf +++ b/tpu_orchestration/configs/v5e_us.conf @@ -1,8 +1,8 @@ ZONE="us-central1-a" -QR_NAME="v5e-64-us-spot" -ACCEL_TYPE="v5litepod-64" +QR_NAME="v5e-32-us-spot" +ACCEL_TYPE="v5litepod-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v5e_us_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v6e_eu.conf b/tpu_orchestration/configs/v6e_eu.conf index ae7bcc3..55d3e3e 100644 --- a/tpu_orchestration/configs/v6e_eu.conf +++ b/tpu_orchestration/configs/v6e_eu.conf @@ -1,8 +1,8 @@ ZONE="europe-west4-a" -QR_NAME="v6e-64-eu-spot" -ACCEL_TYPE="v6e-64" +QR_NAME="v6e-32-eu-spot" +ACCEL_TYPE="v6e-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v6e_eu_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/configs/v6e_us.conf b/tpu_orchestration/configs/v6e_us.conf index a5fe55d..8145d3d 100644 --- a/tpu_orchestration/configs/v6e_us.conf +++ b/tpu_orchestration/configs/v6e_us.conf @@ -1,8 +1,8 @@ ZONE="us-east1-d" -QR_NAME="v6e-64-us-spot" -ACCEL_TYPE="v6e-64" +QR_NAME="v6e-32-us-spot" +ACCEL_TYPE="v6e-32" RUNTIME_VERSION="tpu-ubuntu2204-base" IS_SPOT="true" RUN_ID="phantom_v6e_us_1" HF_REPO="velocitatem/capstone" -TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" \ No newline at end of file +TRAIN_CMD="python -m engine.train --sweep-agent --sweep-id lusiana/capstone/oasdorof" diff --git a/tpu_orchestration/watchdog.sh b/tpu_orchestration/watchdog.sh index 7e7a0fc..1a01447 100755 --- a/tpu_orchestration/watchdog.sh +++ b/tpu_orchestration/watchdog.sh @@ -58,7 +58,7 @@ RETRY_DELAY=60 MAX_RETRY_DELAY=300 while true; do - STATE=$(gcloud compute tpus queued-resources describe $QR_NAME --zone=$ZONE --project=$PROJECT_ID --format="value(state)" 2>/dev/null) + STATE=$(gcloud compute tpus queued-resources describe $QR_NAME --zone=$ZONE --project=$PROJECT_ID --format="value(state.state)" 2>/dev/null) if [ -z "$STATE" ] || [[ "$STATE" == *"SUSPENDED"* ]] || [[ "$STATE" == *"FAILED"* ]]; then echo "[$(date)] Cluster '${STATE:-MISSING}' - cleaning IPs and re-queuing..." @@ -84,6 +84,11 @@ while true; do if [ "$IS_SPOT" = "true" ]; then SPOT_FLAG="--spot" fi + + IP_FLAG="--internal-ips" + if [ "${INTERNAL_IPS:-true}" != "true" ]; then + IP_FLAG="" + fi # Prepare metadata METADATA="HF_TOKEN=$HF_TOKEN,RUN_ID=$RUN_ID,HF_REPO=$HF_REPO,ACCEL_TYPE=$ACCEL_TYPE,GITHUB_REPO=$GITHUB_REPO,BRANCH=$BRANCH" @@ -106,7 +111,7 @@ while true; do --accelerator-type=$ACCEL_TYPE \ --runtime-version=$RT_VERSION \ $SPOT_FLAG \ - --internal-ips \ + $IP_FLAG \ --metadata-from-file startup-script=$(dirname $0)/tpu_startup.sh \ --metadata "$METADATA" 2>&1 | tee "$CREATE_LOG" @@ -115,8 +120,8 @@ while true; do if [ $CREATE_EXIT -eq 0 ]; then echo "[$(date)] Successfully queued $QR_NAME." RETRY_DELAY=60 - elif grep -q "IN_USE_ADDRESSES" "$CREATE_LOG" 2>/dev/null; then - echo "[$(date)] IP quota hit - backing off ${RETRY_DELAY}s" + elif grep -Eq "IN_USE_ADDRESSES|RESOURCE_EXHAUSTED|Quota limit|QUOTA_EXCEEDED" "$CREATE_LOG" 2>/dev/null; then + echo "[$(date)] Quota pressure detected - backing off ${RETRY_DELAY}s" sleep $RETRY_DELAY RETRY_DELAY=$((RETRY_DELAY * 2)) [ $RETRY_DELAY -gt $MAX_RETRY_DELAY ] && RETRY_DELAY=$MAX_RETRY_DELAY From 0521a6393753dbe88a0b1c8d3cd2d593c9581180 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:15:01 +0100 Subject: [PATCH 16/35] chore: updating make reference and linking of builds --- .gitignore | 1 + Makefile | 35 ++++++++++++++++++++++++++++++++++- docker-compose.yml | 2 +- nx.json | 15 +++++++++++++++ scripts/nx_research.sh | 42 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 7644627..11ff6b1 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ .env.* !.env.*.example **/.venv +**/.venv-ray # python build/cache artifacts **/__pycache__ diff --git a/Makefile b/Makefile index fb347d2..d471d69 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ PYTEST := $(VENV)/bin/pytest NX := npx nx SWEEP_ENV_FILE ?= .env.sweep +TPU_CONF ?= tpu_orchestration/configs/v4_spot_us.conf WANDB_ENTITY ?= WANDB_PROJECT ?= capstone @@ -21,6 +22,14 @@ SIMPLE_BENCHMARK_ARGS ?= --tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3, BENCHMARK_AGENT_ARGS ?= AGENT_COUNT ?= 0 +WHOCLICKED_REPO ?= velocitatem/whoclickedit +WHOCLICKED_CSV ?= experiments/exports/whoclicked.csv +WHOCLICKED_CARD ?= experiments/exports/whoclicked_dataset_card.md +WHOCLICKED_CSV_PATH_IN_REPO ?= whoclicked.csv +WHOCLICKED_CARD_PATH_IN_REPO ?= README.md +WHOCLICKED_DATASET_MESSAGE ?= Update flattened whoclicked dataset +WHOCLICKED_CARD_MESSAGE ?= Update dataset card for WhoClicked + REPO_URL ?= BRANCH ?= main WORKDIR ?= $(HOME)/PHANTOM-agent @@ -37,7 +46,8 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || help: @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | manim.render manim.render.all" @echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish" - @echo "data.pull data.push | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" + @echo "data.pull data.push data.whoclicked.publish | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" + @echo "tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown" @echo "" @echo "Build general public version:" @echo " make pdf.genpop" @@ -57,6 +67,12 @@ help: @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 "Bootstrap Ray on TPU slice from config:" + @echo " make tpu.ray.bootstrap TPU_CONF=tpu_orchestration/configs/v4_spot_us.conf" + @echo "" + @echo "Publish WhoClicked dataset + card:" + @echo " make data.whoclicked.publish HF_TOKEN=... WHOCLICKED_REPO=velocitatem/whoclickedit" + @echo "" @echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)" $(BUILDDIR): @@ -134,6 +150,19 @@ train.agent: train.bootstrap: @WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" REPO_URL="$(REPO_URL)" BRANCH="$(BRANCH)" WORKDIR="$(WORKDIR)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" AGENT_LOOP="$(AGENT_LOOP)" RETRY_SECONDS="$(RETRY_SECONDS)" $(NX) run research:train-bootstrap +.PHONY: tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown +tpu.ray.bootstrap: + @TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-bootstrap + +tpu.ray.deps: + @TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-deps + +tpu.ray.verify: + @TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-verify + +tpu.ray.teardown: + @TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-teardown + .PHONY: data.pull data.push data.pull: python scripts/hf_data.py pull @@ -141,6 +170,10 @@ data.pull: data.push: python scripts/hf_data.py push +.PHONY: data.whoclicked.publish +data.whoclicked.publish: + @HF_TOKEN="$(HF_TOKEN)" WHOCLICKED_REPO="$(WHOCLICKED_REPO)" WHOCLICKED_CSV="$(WHOCLICKED_CSV)" WHOCLICKED_CARD="$(WHOCLICKED_CARD)" WHOCLICKED_CSV_PATH_IN_REPO="$(WHOCLICKED_CSV_PATH_IN_REPO)" WHOCLICKED_CARD_PATH_IN_REPO="$(WHOCLICKED_CARD_PATH_IN_REPO)" WHOCLICKED_DATASET_MESSAGE="$(WHOCLICKED_DATASET_MESSAGE)" WHOCLICKED_CARD_MESSAGE="$(WHOCLICKED_CARD_MESSAGE)" $(NX) run research:whoclicked-publish + .PHONY: stats.lines stats.lines: @$(NX) run research:stats diff --git a/docker-compose.yml b/docker-compose.yml index 24961c5..acbc37c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,7 +12,7 @@ services: - GITHUB_TOKEN=${GITHUB_TOKEN} - GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-sa.json - GCP_ACCOUNT=${GCP_ACCOUNT:-} - - WATCHDOG_CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-v6e_*.conf} + - WATCHDOG_CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-v[46]*.conf} - CLOUDSDK_CONFIG=/.config/gcloud volumes: - ~/.config/gcloud:/.config/gcloud:rw diff --git a/nx.json b/nx.json index d286a8f..a87654a 100644 --- a/nx.json +++ b/nx.json @@ -58,6 +58,21 @@ "benchmark": { "cache": false }, + "whoclicked-publish": { + "cache": false + }, + "tpu-ray-bootstrap": { + "cache": false + }, + "tpu-ray-deps": { + "cache": false + }, + "tpu-ray-verify": { + "cache": false + }, + "tpu-ray-teardown": { + "cache": false + }, "up": { "cache": false }, diff --git a/scripts/nx_research.sh b/scripts/nx_research.sh index 434a312..f74b8c2 100644 --- a/scripts/nx_research.sh +++ b/scripts/nx_research.sh @@ -4,6 +4,7 @@ set -euo pipefail cmd="${1:-}" env_file="${SWEEP_ENV_FILE:-.env.sweep}" +default_tpu_conf="tpu_orchestration/configs/v4_spot_us.conf" load_sweep_env() { set -a @@ -20,6 +21,21 @@ require_var() { fi } +run_tpu_ray_bootstrap() { + local mode_flag="${1:-}" + load_sweep_env + local conf_path="${TPU_CONF:-$default_tpu_conf}" + [ -f "$conf_path" ] || { + printf '%s\n' "TPU config not found: $conf_path" >&2 + exit 1 + } + if [ -n "$mode_flag" ]; then + bash tpu_orchestration/bootstrap_ray.sh --conf "$conf_path" "$mode_flag" + else + bash tpu_orchestration/bootstrap_ray.sh --conf "$conf_path" + fi +} + case "$cmd" in install) [ -x .venv/bin/python ] || python3 -m venv .venv @@ -120,6 +136,32 @@ PY docker build -f docker/Trainer.dockerfile --target gpu -t "$image_ref:gpu-latest" . docker push "$image_ref:gpu-latest" ;; + whoclicked-publish) + require_var HF_TOKEN "HF_TOKEN required - export HF_TOKEN=" + .venv/bin/python scripts/whoclicked_etl.py build-upload \ + --output "${WHOCLICKED_CSV:-experiments/exports/whoclicked.csv}" \ + --repo "${WHOCLICKED_REPO:-velocitatem/whoclickedit}" \ + --path-in-repo "${WHOCLICKED_CSV_PATH_IN_REPO:-whoclicked.csv}" \ + --message "${WHOCLICKED_DATASET_MESSAGE:-Update flattened whoclicked dataset}" + .venv/bin/python scripts/whoclicked_card.py build-upload \ + --csv "${WHOCLICKED_CSV:-experiments/exports/whoclicked.csv}" \ + --card "${WHOCLICKED_CARD:-experiments/exports/whoclicked_dataset_card.md}" \ + --repo "${WHOCLICKED_REPO:-velocitatem/whoclickedit}" \ + --path-in-repo "${WHOCLICKED_CARD_PATH_IN_REPO:-README.md}" \ + --message "${WHOCLICKED_CARD_MESSAGE:-Update dataset card for WhoClicked}" + ;; + tpu-ray-bootstrap) + run_tpu_ray_bootstrap + ;; + tpu-ray-deps) + run_tpu_ray_bootstrap --deps-only + ;; + tpu-ray-verify) + run_tpu_ray_bootstrap --verify-only + ;; + tpu-ray-teardown) + run_tpu_ray_bootstrap --teardown + ;; *) printf '%s\n' "Unknown research command: $cmd" >&2 exit 1 From 375445f260176a524d6dec5cf2946996d7c7850e Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:15:23 +0100 Subject: [PATCH 17/35] chore: refactoring, proper citation and updating on data and refs and apendices --- paper/src/bib/references.bib | 23 ++++ paper/src/chapters/03-methodology.tex | 22 ++-- paper/src/chapters/04-results.tex | 6 +- paper/src/graphics/banner.png | Bin 85531 -> 331899 bytes paper/src/main.tex | 149 ++++++++------------------ 5 files changed, 82 insertions(+), 118 deletions(-) diff --git a/paper/src/bib/references.bib b/paper/src/bib/references.bib index b42c044..d9c6d21 100644 --- a/paper/src/bib/references.bib +++ b/paper/src/bib/references.bib @@ -645,3 +645,26 @@ What might be more surprising is that even when we adjust the temperature down t year = {2025}, file = {Snapshot:/home/velocitatem/Zotero/storage/U5JG4CNM/defeating-nondeterminism-in-llm-inference.html:text/html}, } + +@misc{moritz_ray_2018, + title = {Ray: {A} {Distributed} {Framework} for {Emerging} {AI} {Applications}}, + shorttitle = {Ray}, + url = {http://arxiv.org/abs/1712.05889}, + doi = {10.48550/arXiv.1712.05889}, + abstract = {The next generation of AI applications will continuously interact with the environment and learn from these interactions. These applications impose new and demanding systems requirements, both in terms of performance and flexibility. In this paper, we consider these requirements and present Ray---a distributed system to address them. Ray implements a unified interface that can express both task-parallel and actor-based computations, supported by a single dynamic execution engine. To meet the performance requirements, Ray employs a distributed scheduler and a distributed and fault-tolerant store to manage the system's control state. In our experiments, we demonstrate scaling beyond 1.8 million tasks per second and better performance than existing specialized systems for several challenging reinforcement learning applications.}, + urldate = {2026-03-13}, + publisher = {arXiv}, + author = {Moritz, Philipp and Nishihara, Robert and Wang, Stephanie and Tumanov, Alexey and Liaw, Richard and Liang, Eric and Elibol, Melih and Yang, Zongheng and Paul, William and Jordan, Michael I. and Stoica, Ion}, + month = sep, + year = {2018}, + note = {arXiv:1712.05889 [cs]}, + keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Computer Science - Artificial Intelligence, Computer Science - Distributed, Parallel, and Cluster Computing}, + file = {Preprint PDF:/home/velocitatem/Zotero/storage/SUTDF5BP/Moritz et al. - 2018 - Ray A Distributed Framework for Emerging AI Applications.pdf:application/pdf;Snapshot:/home/velocitatem/Zotero/storage/5GV2DUAA/1712.html:text/html}, +} + +@misc{biewald_experiment_2020, + title = {Experiment {Tracking} with {Weights} and {Biases}}, + url = {https://www.wandb.com/}, + author = {Biewald, Lukas}, + year = {2020}, +} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 51dc9b6..95ac3c0 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -246,7 +246,8 @@ v4 & 64 (32 + 32) & us-central2-b & 32 Spot + 32 On-demand \\ \end{tabular} \end{table} -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}. +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. % TODO: cite this (from bib) +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. @@ -388,8 +389,10 @@ The complete pricing-demand-trajectory loop is illustrated in Figure~\ref{fig:or \begin{figure}[ht] \centering -\[ -\text{Oracle}(\vec{p}_{t-1},\vec{\hat{q}})\to +{\setlength{\arraycolsep}{4pt}% +\resizebox{0.98\linewidth}{!}{$ +\begin{aligned} +&\text{Oracle}(\vec{p}_{t-1},\vec{\hat{q}})\to \begin{pmatrix} p_0\\ p_1\\ @@ -398,14 +401,15 @@ p_N \end{pmatrix} \underrightarrow{d_i \sim \mathcal{N}_{\vec{p}}} \begin{pmatrix}d_0\\ d_1\\ \cdots \\ d_N\end{pmatrix} -\underrightarrow{\vec{d}\times \tau_\theta \to \tau^\prime} +\underrightarrow{\vec{d}\otimes \tau_\theta} \begin{bmatrix} 0.01 & 0.02 & \cdots & 0.3 \\ 0.41 & 0.24 & \cdots & 0.0 \\ \cdots & \cdots & \cdots & \cdots \\ 0.51 & 0.09 & \cdots & 0.1 \\ \end{bmatrix} -\underrightarrow{\tau_k \sim \tau^\prime} +\\ +&\underrightarrow{\tau_k \sim \tau^\prime} \{\tau_k\}_{k=0}^K \to \hat{Q}(\tau_k) \to \begin{pmatrix} \hat{q}_0 \\ @@ -414,8 +418,10 @@ p_N \hat{q}_N \\ \end{pmatrix} \to \text{Oracle}(\cdot) -\] -\caption{Oracle-based pricing loop: historical price and demand state map to a new price vector; each product samples demand curves from $\mathcal{N}_{\vec{p}}$; trajectories are generated by mixing demand with behavioral kernels $\tau_\theta$ into transition matrix $\tau'$; sampled trajectories $\{\tau_k\}$ aggregate through proxy $Q(\cdot)$ to yield updated demand $\vec{\hat{q}}$, closing the feedback loop.} +\end{aligned} +$}% +} +\caption{Oracle-based pricing loop: historical price and demand state map to a new price vector; each product samples demand curves from $\mathcal{N}_{\vec{p}}$; trajectories are generated via the Kronecker product $\vec{d}\otimes\tau_\theta$ into transition matrix $\tau'$; sampled trajectories $\{\tau_k\}$ aggregate through proxy $Q(\cdot)$ to yield updated demand $\vec{\hat{q}}$, closing the feedback loop.} \label{fig:oracle_flow} \end{figure} @@ -498,7 +504,7 @@ The algorithm operates in discrete epochs indexed by $t$. At each epoch, the pla \subsection{Parallelization Strategy} -To avoid preemption of compute mid-training we settle on using a v4 generation, 40 chip compute node with 5 parallel workers. The login node creates an orchestration node with Ray and we distribute ray compute nodes per each other worker. +To avoid preemption of compute mid-training we settle on using a v4 generation, 40 chip compute node with 5 parallel workers. The login node creates an orchestration node with Ray \parencite{moritz_ray_2018} and we distribute ray compute nodes per each other worker. \subsubsection{Computational Cost Analysis of the Simulation Step} The per-step cost of Algorithm~\ref{alg:phantom_loop_clean} is not uniform across its components. To inform hardware provisioning and to identify where algorithmic improvements are most impactful, we profile the hot path of the engine using Python's \texttt{cProfile} instrumentation over 20 environment steps under two configurations: a baseline with the robustness inner loop disabled ($K=1$, $\epsilon_\alpha=0$) and a standard robust setting ($K=5$, $\epsilon_\alpha=0.2$). Both runs use $M=10$ sessions per market call and $N=3$ products. diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index 5a100b2..8b1bfee 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -57,11 +57,7 @@ At pair level (same seed, tier, and contamination), robust exceeds non-robust in \subsubsection{The Impact of Contamination on Revenue} -A linear slope test on run-level data ($n=95$) shows a strong negative association between contamination and mean revenue. The fitted model is -\[ -\widehat{\text{revenue}} = 326{,}878.57 - 60{,}631.95\,\alpha, -\] -with $t(93)=-8.2148$, $p=1.20\times 10^{-12}$, $R^2=0.4205$, and a 95\% confidence interval for the slope of $[-75{,}288.76,\,-45{,}975.13]$. In practical terms, a $+0.1$ increase in $\alpha$ corresponds to an average decrease of about $6{,}063$ revenue units. The full derivation (sample moments, least-squares coefficients, residual variance, standard error, test statistic, and confidence interval) is reported in Appendix~\ref{app:alpha_revenue_slope}. +A linear slope test on run-level data ($n=95$) shows a strong negative association between contamination and mean revenue. The fitted model mapping $\alpha \to \text{revenue}$ result in $t(93)=-8.2148$, $p=1.20\times 10^{-12}$, $R^2=0.4205$, and a 95\% confidence interval for the slope of $[-75{,}288.76,\,-45{,}975.13]$. In practical terms, a $+0.1$ increase in $\alpha$ corresponds to an average decrease of about $6{,}063$ revenue units. A compact Appendix~\ref{app:alpha_revenue_slope} expansion can be found for these values using standard Python test methods. \subsection{Interpretation and Insights} diff --git a/paper/src/graphics/banner.png b/paper/src/graphics/banner.png index 992202e9ce0c4227ebbf6405f4b4e70c6c3ee25a..31351b0d0b2a08174c0994229f87dba60b03a1d8 100644 GIT binary patch literal 331899 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 ?Oq-0igz(l_H_j^ACb+x;(2zOK%$=?ep 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 z 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 z%0A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 U$DU0;Kloa3Lvw>Feb*=d1$~bej{pDw literal 85531 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 KquT~f5&sVcpPEzv diff --git a/paper/src/main.tex b/paper/src/main.tex index 024fd31..5ccb34d 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -46,15 +46,44 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \appendix \section{Terminology} \begin{description} -\item[Agent $A$] An actor of non-human nature, powered by an LLM. -\item[Human $H$] An individual human with some job to be done. -\item[Actor $\theta$] Defines a type of class which is either Agent or Human and has the capability to carry out actions on a web platform. -\item[Platform] Any web-based platform which serves an interface to a collection of items that can be purchased, each at some price $p_i$. -\item[Behavioral Model] A mathematical model predicting what action comes after a series of prior actions. -\item[LLM] Large Language Model served by some provider with the abstracted capability of tool calling. -\item[TPU] Tensor Processing Unit which is a unique kind of chip architecture developed by Google. -\item[Trajectory] Defined as a series of unspecified length, collecting data on states of some object over time. -% TODO: maybe define other things in a similar succient manner +\item[Agent $A$] A non-human actor, typically an LLM-driven system that executes web actions toward a goal. +\item[Human $H$] A human participant interacting with the platform to complete a task. +\item[Actor Type $\theta$] A latent class parameter describing whether a session is generated by a human or an agent profile. +\item[Platform] A web interface exposing purchasable items and their offered prices. +\item[Session $s$] A bounded interaction record tied to one actor and one session identifier. +\item[Event $e_{s,k}$] A single interaction tuple in a session, including action, item target, and timestamp. +\item[Trajectory $\tau_s$] The ordered sequence of events generated within a session. +\item[Demand Proxy $\hat{q}_{t,i}$] A weighted aggregate of observed actions used as an operational substitute for latent demand. +\item[Action Weight Function $\omega(a)$] A mapping from action type to signal strength in the demand proxy. +\item[True Demand $d(p;\theta)$] The latent purchase response as a function of price and actor type. +\item[Contamination $\alpha$] The proportion of agent-generated traffic in the session mixture. +\item[Non-stationary Noise $\epsilon_t$] Time-varying residual variation not explained by the actor mixture. +\item[Pricing Policy $\pi(\tau)$] A function mapping observed interaction history to an offered price. +\item[Cost of Information (COI)] The expected premium above the minimum viable price induced by the pricing policy. +\item[COI Leakage] A per-quote penalty term modeling information revealed to reconnaissance behavior. +\item[First-Order Statistic $p_{(1)}$] The minimum observed price among multiple independent queries. +\item[Transition Kernel $\mathcal{T}$] A Markov transition matrix over behavioral states or actions. +\item[Separability] The degree to which human and agent sessions can be distinguished from behavior alone. +\item[KL Divergence $D_{KL}$] A relative-entropy measure used to compare session transition structure against class prototypes. +\item[Divergence Scores $\Delta_H,\Delta_A$] Session-level distances to human and agent transition centroids. +\item[Weak Agent Probability $f(\tau)$] A session-level score estimating the likelihood that a trajectory is agent-generated. +\item[Contamination Generator $\mathcal{G}(\alpha)$] A simulator component that injects synthetic agent trajectories to reach a target mixture level. +\item[Stackelberg Game] A leader-follower formulation where the platform sets prices and demand responds. +\item[Ambiguity Set $\mathcal{U}_{\epsilon}$] A set of plausible demand distributions considered under distributional uncertainty. +\item[Wasserstein Ball] A distance-bounded neighborhood around an empirical distribution used in robust optimization. +\item[DR-RL] Distributionally Robust Reinforcement Learning for policies trained against worst-case distributional shifts. +\item[Nominal Contamination $\alpha_0$] The baseline contamination level around which robust candidates are evaluated. +\item[Robustness Radius $\epsilon_\alpha$] The local interval width used for inner minimization over contamination scenarios. +\item[Query-Tax Surrogate] A constant leakage proxy assigning fixed penalty to suspected reconnaissance queries. +\item[Revelation Surrogate] A leakage proxy based on $-\log\pi(p\mid\tau)$ to penalize highly informative quotes. +\item[Limbo Stack] The alternating game-history buffer that stores leader price moves and follower demand responses. +\item[UX Index] A bounded user-experience metric tracked to evaluate policy side effects on legitimate users. +\item[Look-to-Book Ratio] The ratio of search-like interactions to completed purchases, used as an operational contamination indicator. +\item[Hybrid Kappa-Lambda Architecture] A data design combining streaming ingestion with offline and batch learning loops. +\item[MDP / POMDP] Sequential decision models with full observability (MDP) or partial observability (POMDP). +\item[Behavioral Model] A model predicting what action is likely to follow from prior actions. +\item[LLM] Large Language Model served through an inference provider with tool-use capability. +\item[TPU] Tensor Processing Unit, a specialized accelerator architecture developed by Google. \end{description} \section{Aggregate Compute Budget Derivation} @@ -81,109 +110,19 @@ v4 & 64 & 275 & $64 \times 275 = 17{,}600$ \\ Converting to petaFLOPS: $160{,}320\;\text{TFLOPS} = 160.32\;\text{PFLOPS} \approx 160\;\text{PFLOPS}$. This is the theoretical peak under sustained BF16 arithmetic; realized throughput depends on memory bandwidth utilization and inter-chip communication overhead, but the figure serves as a useful upper bound for provisioning decisions. -\section{Full Slope-Test Derivation: Revenue vs. Contamination} +\section{Slope-Test Verification: Revenue vs. Contamination} \label{app:alpha_revenue_slope} -This appendix gives the full ordinary least squares computation for the linear effect of contamination on mean revenue. Let +This appendix provides a compact verification of the slope result reported in the main results section. Using the same run-level pairs $x_i=\texttt{study/alpha}_i$ and $y_i=\texttt{eval/revenue\_mean}_i$ ($n=95$), we re-checked the ordinary least squares slope test in Python with standard test routines (SciPy two-sided $t$ test for the slope). + \[ -x_i = \texttt{study/alpha}_i, \qquad y_i = \texttt{eval/revenue\_mean}_i, +\widehat{y}=326{,}878.57-60{,}631.95\,x, \] -and fit \[ -y_i = \beta_0 + \beta_1 x_i + \varepsilon_i, \qquad i=1,\dots,n. -\] -The slope test is -\[ -H_0: \beta_1 = 0 \qquad \text{vs.} \qquad H_1: \beta_1 \neq 0. +t(93)=-8.2148,\qquad p=1.2038\times 10^{-12},\qquad R^2=0.4205,\qquad 95\%\,\text{CI}_{\beta_1}=[-75{,}288.76,\,-45{,}975.13]. \] -\subsection{Sample moments and least-squares coefficients} - -From the data: -\[ -n=95, \qquad \bar{x}=0.3810526316, \qquad \bar{y}=303{,}774.6096. -\] -Define -\[ -S_{xx}=\sum_{i=1}^{n}(x_i-\bar{x})^2, \qquad S_{xy}=\sum_{i=1}^{n}(x_i-\bar{x})(y_i-\bar{y}). -\] -Numerically, -\[ -S_{xx}=7.0508947368, \qquad S_{xy}=-427{,}509.4691. -\] -The least-squares slope and intercept are -\[ -\hat{\beta}_1 = \frac{S_{xy}}{S_{xx}} = \frac{-427{,}509.4691}{7.0508947368} = -60{,}631.9460, -\] -\[ -\hat{\beta}_0 = \bar{y} - \hat{\beta}_1\bar{x} = 303{,}774.6096 - (-60{,}631.9460)(0.3810526316) = 326{,}878.5722. -\] -So the fitted line is -\[ -\hat{y} = 326{,}878.5722 - 60{,}631.9460\,x. -\] - -\subsection{Residual variance and standard error of the slope} - -For each observation, $\hat{y}_i = \hat{\beta}_0 + \hat{\beta}_1 x_i$ and $e_i = y_i - \hat{y}_i$. The residual sum of squares is -\[ -\mathrm{SSE} = \sum_{i=1}^{n} e_i^2 = 35{,}721{,}896{,}352.27375. -\] -With $df=n-2=93$, -\[ -\mathrm{MSE} = \frac{\mathrm{SSE}}{n-2} = \frac{35{,}721{,}896{,}352.27375}{93} = 384{,}106{,}412.3900. -\] -The slope standard error is -\[ -SE(\hat{\beta}_1) = \sqrt{\frac{\mathrm{MSE}}{S_{xx}}} = \sqrt{\frac{384{,}106{,}412.3900}{7.0508947368}} = 7{,}380.8038. -\] - -\subsection{t-statistic, p-value, and confidence interval} - -Under $H_0: \beta_1=0$, -\[ -t = \frac{\hat{\beta}_1}{SE(\hat{\beta}_1)} = \frac{-60{,}631.9460}{7{,}380.8038} = -8.2148, -\] -with $df=93$. The two-sided p-value is -\[ -p = 2\,\Pr\left(T_{93} \ge |t|\right) = 1.2038\times 10^{-12}. -\] -The 95\% confidence interval is -\[ -\hat{\beta}_1 \pm t_{0.975,93}\,SE(\hat{\beta}_1) -= -60{,}631.9460 \pm (1.9858)(7{,}380.8038) -= [-75{,}288.7597,\,-45{,}975.1324]. -\] - -\subsection{Effect size and fit statistics} - -The sample correlation is $r=-0.64846$, so -\[ -R^2 = r^2 = 0.4205. -\] -Hence, 42.05\% of the variation in \texttt{eval/revenue\_mean} is explained by a linear trend in \texttt{study/alpha}. - -The slope interpretation is direct: -\[ -\hat{\beta}_1 = -60{,}631.9460 \quad \Rightarrow \quad \Delta y \approx -6{,}063.19 \text{ for } \Delta x = +0.1. -\] -From $\alpha=0$ to $\alpha=0.8$, the fitted drop is -\[ -0.8\times (-60{,}631.9460) = -48{,}505.5568, -\] -so the model predicts roughly $48{,}506$ lower revenue units on average. - -\subsection{Conclusion of the slope test} - -The estimated model is -\[ -\hat{y}=326{,}878.57-60{,}631.95\,x, -\] -with -\[ -t(93)=-8.2148, \qquad p=1.2038\times 10^{-12}, \qquad 95\%\,\text{CI}=[-75{,}288.76,\,-45{,}975.13]. -\] -The slope is therefore strongly negative and statistically different from zero. +The Python verification reproduces the reported coefficients and inference values, confirming that the slope-test results are correct under standard methods. % \input{../build/concatenated_code} From 13937953593bd39883fe0721e9f51387596c523a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:33:25 +0100 Subject: [PATCH 18/35] chore: re referene new dataset --- Makefile | 6 +++--- paper/src/chapters/03-methodology.tex | 2 ++ paper/src/main.tex | 10 ++++++++++ scripts/nx_research.sh | 4 ++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index d471d69..9e2d5d2 100644 --- a/Makefile +++ b/Makefile @@ -27,8 +27,8 @@ WHOCLICKED_CSV ?= experiments/exports/whoclicked.csv WHOCLICKED_CARD ?= experiments/exports/whoclicked_dataset_card.md WHOCLICKED_CSV_PATH_IN_REPO ?= whoclicked.csv WHOCLICKED_CARD_PATH_IN_REPO ?= README.md -WHOCLICKED_DATASET_MESSAGE ?= Update flattened whoclicked dataset -WHOCLICKED_CARD_MESSAGE ?= Update dataset card for WhoClicked +WHOCLICKED_DATASET_MESSAGE ?= Update flattened whoclickedit dataset +WHOCLICKED_CARD_MESSAGE ?= Update dataset card for whoclickedit REPO_URL ?= BRANCH ?= main @@ -70,7 +70,7 @@ help: @echo "Bootstrap Ray on TPU slice from config:" @echo " make tpu.ray.bootstrap TPU_CONF=tpu_orchestration/configs/v4_spot_us.conf" @echo "" - @echo "Publish WhoClicked dataset + card:" + @echo "Publish whoclickedit dataset + card:" @echo " make data.whoclicked.publish HF_TOKEN=... WHOCLICKED_REPO=velocitatem/whoclickedit" @echo "" @echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)" diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 95ac3c0..9540820 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -141,6 +141,8 @@ 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 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. +\paragraph{Public Dataset Artifact} For reproducibility of the behavioral analysis and separability experiments, we also release the interaction dataset used in this thesis as \texttt{whoclickedit}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}. + \subsubsection{DevOps Principles} diff --git a/paper/src/main.tex b/paper/src/main.tex index 5ccb34d..d0c77b0 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -124,6 +124,16 @@ t(93)=-8.2148,\qquad p=1.2038\times 10^{-12},\qquad R^2=0.4205,\qquad 95\%\,\tex The Python verification reproduces the reported coefficients and inference values, confirming that the slope-test results are correct under standard methods. +\section{whoclickedit Dataset Card} +\label{app:whoclicked_card} + +For transparency and reproducibility, this appendix includes the full dataset card used for the public release of the \texttt{whoclickedit} dataset. + +\lstinputlisting[ + caption={whoclickedit dataset card (README snapshot)}, + label={lst:whoclicked_dataset_card} +]{chapters/auto/whoclicked_dataset_card.md} + % \input{../build/concatenated_code} \end{document} diff --git a/scripts/nx_research.sh b/scripts/nx_research.sh index f74b8c2..4cc39ee 100644 --- a/scripts/nx_research.sh +++ b/scripts/nx_research.sh @@ -142,13 +142,13 @@ PY --output "${WHOCLICKED_CSV:-experiments/exports/whoclicked.csv}" \ --repo "${WHOCLICKED_REPO:-velocitatem/whoclickedit}" \ --path-in-repo "${WHOCLICKED_CSV_PATH_IN_REPO:-whoclicked.csv}" \ - --message "${WHOCLICKED_DATASET_MESSAGE:-Update flattened whoclicked dataset}" + --message "${WHOCLICKED_DATASET_MESSAGE:-Update flattened whoclickedit dataset}" .venv/bin/python scripts/whoclicked_card.py build-upload \ --csv "${WHOCLICKED_CSV:-experiments/exports/whoclicked.csv}" \ --card "${WHOCLICKED_CARD:-experiments/exports/whoclicked_dataset_card.md}" \ --repo "${WHOCLICKED_REPO:-velocitatem/whoclickedit}" \ --path-in-repo "${WHOCLICKED_CARD_PATH_IN_REPO:-README.md}" \ - --message "${WHOCLICKED_CARD_MESSAGE:-Update dataset card for WhoClicked}" + --message "${WHOCLICKED_CARD_MESSAGE:-Update dataset card for whoclickedit}" ;; tpu-ray-bootstrap) run_tpu_ray_bootstrap From c8df2e9cbd7ab86ad3eb628b6f0d4f5e29de6be9 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 21:34:42 +0100 Subject: [PATCH 19/35] chore: fixing refaormating --- paper/src/chapters/03-methodology.tex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 9540820..dbc44b6 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -141,7 +141,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 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. -\paragraph{Public Dataset Artifact} For reproducibility of the behavioral analysis and separability experiments, we also release the interaction dataset used in this thesis as \texttt{whoclickedit}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}. +\paragraph{Public Dataset Artifact} For reproducibility of the behavioral analysis and separability experiments, we also release the interaction dataset used in this thesis as \textit{WhoClickedIt}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}. \subsubsection{DevOps Principles} From 9642edd1b1199799ced4cf10052f1a15aaaf9925 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Sun, 15 Mar 2026 22:09:02 +0100 Subject: [PATCH 20/35] chore: rename to distinguishability --- paper/src/chapters/01-intro.tex | 8 ++--- paper/src/chapters/02-literature-review.tex | 2 +- paper/src/chapters/03-methodology.tex | 18 +++++------ paper/src/chapters/04-results.tex | 6 ++-- paper/src/chapters/06-conclusion.tex | 4 +-- paper/src/chapters/slacberger.tex | 2 +- paper/src/main-genpop.tex | 2 +- paper/src/main.tex | 2 +- paper/src/mirrors/cais2026/main.tex | 30 +++++++++---------- paper/src/mirrors/genpop/01-intro.tex | 8 ++--- .../mirrors/genpop/02-literature-review.tex | 2 +- paper/src/mirrors/genpop/03-methodology.tex | 16 +++++----- paper/src/mirrors/genpop/04-results.tex | 6 ++-- 13 files changed, 53 insertions(+), 53 deletions(-) diff --git a/paper/src/chapters/01-intro.tex b/paper/src/chapters/01-intro.tex index 79e5f73..d66b0c2 100644 --- a/paper/src/chapters/01-intro.tex +++ b/paper/src/chapters/01-intro.tex @@ -8,9 +8,9 @@ \section{Introduction} -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. +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 distinguishability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned distinguishability 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. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 1st 2026.} +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 distinguishability 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 1st 2026.} \subsection{Motivation and Market Context} @@ -30,7 +30,7 @@ We formally define interaction data as coming from some actor which can either b This dissertation is organized around one main research question and three supporting sub-questions: \begin{enumerate} \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{SQ1}] \textit{Distinguishability}: 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} @@ -64,4 +64,4 @@ Extract final result $r$ from terminal state\; \end{algorithm} -The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary. +The previously described goal of distinguishability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary. diff --git a/paper/src/chapters/02-literature-review.tex b/paper/src/chapters/02-literature-review.tex index 5e788ed..272ea4c 100644 --- a/paper/src/chapters/02-literature-review.tex +++ b/paper/src/chapters/02-literature-review.tex @@ -1,6 +1,6 @@ \section{Literature Review} -To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups. +To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for distinguishing non-human reconnaissance from genuine human demand expression and integrating that distinguishability into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups. \subsection{Agent Taxonomy and Definitions} diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index dbc44b6..1653757 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -3,7 +3,7 @@ % 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. +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 distinguishability 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} @@ -141,7 +141,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 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. -\paragraph{Public Dataset Artifact} For reproducibility of the behavioral analysis and separability experiments, we also release the interaction dataset used in this thesis as \textit{WhoClickedIt}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}. +\paragraph{Public Dataset} For reproducibility of the behavioral analysis and distinguishability experiments, we also release the interaction dataset used in this thesis as \textit{WhoClickedIt}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}. \subsubsection{DevOps Principles} @@ -189,9 +189,9 @@ The human data collection involved 13 participants, all of whom provided explici 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 $\theta \in \{A,H\}$ with session-conditioned probability estimates, then injects those estimates into the pricing learner. +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 distinguish classes $\theta \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. +Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn distinguishability 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}{!}{% @@ -298,15 +298,15 @@ In addition to behavioral events, the platform logs price observations to a sepa -\subsection{Generative Contamination and Separability} +\subsection{Generative Contamination and Distinguishability} 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{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 $\theta_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? +\subsubsection{Ground-Truth Distinguishability} +Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $\theta_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 distinguishable enough to justify downstream pricing control that depends on that distinguishability? -To answer this, we compute per-session KL divergence scores against both class-level centroids. For each session $s$ in either partition, we fit a session-level event transition kernel $\hat{\mathcal{T}}_s$ from that session's trajectory alone, then compute its average KL divergence to the human centroid ($\Delta_{H,s}$) and to the agent centroid ($\Delta_{A,s}$). The per-session separability score is the gap $\Delta_{H,s} - \Delta_{A,s}$: a negative value indicates proximity to human behavior, a positive value indicates proximity to agent behavior. +To answer this, we compute per-session KL divergence scores against both class-level centroids. For each session $s$ in either partition, we fit a session-level event transition kernel $\hat{\mathcal{T}}_s$ from that session's trajectory alone, then compute its average KL divergence to the human centroid ($\Delta_{H,s}$) and to the agent centroid ($\Delta_{A,s}$). The per-session distinguishability score is the gap $\Delta_{H,s} - \Delta_{A,s}$: a negative value indicates proximity to human behavior, a positive value indicates proximity to agent behavior. The normality assumption cannot be made for KL divergence distributions, which are right-skewed and bounded below by zero, so we do not use a Student's $t$-test. Instead we apply a Mann-Whitney $U$ test \parencite{mann_test_1947} on the per-session gap scores between the two groups. The Mann-Whitney test is a rank-based nonparametric test that compares the stochastic ordering of two independent samples without distributional assumptions, making it appropriate for small samples drawn from skewed populations. We report $U$, the exact two-sided $p$-value, and group-level descriptive statistics for the gap scores. @@ -470,7 +470,7 @@ We also consider taxation-like overlays for agent traffic under strategy-proof m \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_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. +We now present the complete pricing mechanism that integrates the behavioral distinguishability, 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} diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index 8b1bfee..725dc7f 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -10,7 +10,7 @@ \subsection{Behavioral Analysis} -Separability between human and agent sessions is evaluated by computing per-session divergence gap scores $\Delta_{H,s} - \Delta_{A,s}$ and comparing the two groups with a Mann-Whitney $U$ test. The full recorded cohort contains $n_H=13$ human sessions and $n_A=16$ agent sessions, and Table~\ref{tab:divergence_significance} reports the corresponding group-level statistics and test result. +Distinguishability between human and agent sessions is evaluated by computing per-session divergence gap scores $\Delta_{H,s} - \Delta_{A,s}$ and comparing the two groups with a Mann-Whitney $U$ test. The full recorded cohort contains $n_H=13$ human sessions and $n_A=16$ agent sessions, and Table~\ref{tab:divergence_significance} reports the corresponding group-level statistics and test result. \begin{table}[ht] \centering @@ -28,7 +28,7 @@ Agent sessions & 16 & $+1.65$ & $2.83$ \\ \end{tabular} \end{table} -The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result ($p<0.001$) at $n_H=13$, $n_A=16$ indicates strong rank separation between groups, providing evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. +The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result ($p<0.001$) at $n_H=13$, $n_A=16$ indicates strong rank distinction between groups, providing evidence that the transition kernels are distinguishable enough to justify their use as a control signal in downstream pricing. \subsection{Experimental Outcomes} @@ -61,7 +61,7 @@ A linear slope test on run-level data ($n=95$) shows a strong negative associati \subsection{Interpretation and Insights} -The Mann-Whitney result ($p<0.001$) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. +The Mann-Whitney result ($p<0.001$) confirms that per-session divergence gaps distinguish the two actor classes with near-zero overlap in rank ordering. This is the condition required for distinguishability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. The first calibration and overnight runs additionally confirm three practical points aligned with the thesis mechanism. First, the control loop is reproducible end-to-end (training, evaluation, artifact generation) across algorithms and contamination levels. Second, policy class materially changes price trajectories and resulting COI/revenue profiles under identical environment settings. Third, objective improvements from robustness are regime-dependent in the current baseline, which is consistent with the thesis claim that contamination-aware pricing needs explicit calibration rather than a one-size-fits-all penalty. diff --git a/paper/src/chapters/06-conclusion.tex b/paper/src/chapters/06-conclusion.tex index c528db1..95fd320 100644 --- a/paper/src/chapters/06-conclusion.tex +++ b/paper/src/chapters/06-conclusion.tex @@ -12,9 +12,9 @@ Now we very explicitly mention what we contribute in this paper: \item Formalization of non-human transaction orchestration in e-commerce as a distinct source of contamination in dynamic pricing systems. \item Definition of the Cost of Information (COI) as a mechanism-level quantity for pricing power, together with a theorem showing its erosion under increasing agent saturation. \item Design and implementation of a controlled e-commerce research platform, built on a hybrid Kappa-Lambda architecture, for collecting and replaying high-fidelity interaction trajectories. - \item Construction and empirical validation of a behavioral separability framework that distinguishes human and agent sessions from interaction signals alone using transition kernels and KL-based divergence. + \item Construction and empirical validation of a behavioral distinguishability framework that distinguishes human and agent sessions from interaction signals alone using transition kernels and KL-based divergence. \item Development of a generative contamination mechanism that injects learned agent behavior into the pricing environment for controlled robustness experiments. - \item Translation of behavioral separability into a defensive pricing mechanism through a distributionally robust reinforcement learning formulation of pricing under non-stationary contamination. + \item Translation of behavioral distinguishability into a defensive pricing mechanism through a distributionally robust reinforcement learning formulation of pricing under non-stationary contamination. \item Empirical evidence that agent contamination reduces revenue and that robustness is condition-dependent, requiring explicit calibration rather than a one-size-fits-all penalty. \item Release of a reusable public experimental artifact for reproducing and extending research on dynamic pricing under agent-mediated traffic. \end{itemize} diff --git a/paper/src/chapters/slacberger.tex b/paper/src/chapters/slacberger.tex index 7728c91..1b0f153 100644 --- a/paper/src/chapters/slacberger.tex +++ b/paper/src/chapters/slacberger.tex @@ -62,7 +62,7 @@ We propose a robust optimization objective. The platform seeks a pricing policy Here: \begin{itemize} \item The first term, $p_t \cdot \hat{q}_t(p_t | \theta=H)$, represents the revenue generated strictly from the estimated human segment. - \item $\mathcal{L}_{detect}$ is a penalty term for failing to separate distributions (the cost of confusion). + \item $\mathcal{L}_{detect}$ is a penalty term for failing to distinguish distributions (the cost of confusion). \item $\lambda$ is a hyperparameter balancing revenue exploitation vs. robust detection. \end{itemize} diff --git a/paper/src/main-genpop.tex b/paper/src/main-genpop.tex index adf81a9..e54f1de 100644 --- a/paper/src/main-genpop.tex +++ b/paper/src/main-genpop.tex @@ -57,7 +57,7 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \item[Trajectory] Defined as a series of unspecified length, collecting data on states of some object over time. \item[Cost of Information (COI)] The average premium extracted above marginal cost due to information asymmetry. \item[Contamination Ratio] The proportion of agent sessions versus human sessions in the system. -\item[Separability] The ability to distinguish between human and agent behavioral patterns. +\item[Distinguishability] The ability to distinguish between human and agent behavioral patterns. \end{description} \section{Aggregate Compute Budget Derivation} diff --git a/paper/src/main.tex b/paper/src/main.tex index d0c77b0..f31edd9 100644 --- a/paper/src/main.tex +++ b/paper/src/main.tex @@ -63,7 +63,7 @@ These behavioral signals serve as inputs for a Distributionally Robust Reinforce \item[COI Leakage] A per-quote penalty term modeling information revealed to reconnaissance behavior. \item[First-Order Statistic $p_{(1)}$] The minimum observed price among multiple independent queries. \item[Transition Kernel $\mathcal{T}$] A Markov transition matrix over behavioral states or actions. -\item[Separability] The degree to which human and agent sessions can be distinguished from behavior alone. +\item[Distinguishability] The degree to which human and agent sessions can be distinguished from behavior alone. \item[KL Divergence $D_{KL}$] A relative-entropy measure used to compare session transition structure against class prototypes. \item[Divergence Scores $\Delta_H,\Delta_A$] Session-level distances to human and agent transition centroids. \item[Weak Agent Probability $f(\tau)$] A session-level score estimating the likelihood that a trajectory is agent-generated. diff --git a/paper/src/mirrors/cais2026/main.tex b/paper/src/mirrors/cais2026/main.tex index 65389c1..3fe8db7 100644 --- a/paper/src/mirrors/cais2026/main.tex +++ b/paper/src/mirrors/cais2026/main.tex @@ -41,7 +41,7 @@ \begin{abstract} Dynamic pricing pipelines in e-commerce consume behavioral demand signals to set prices, but the growing presence of LLM-powered agents introduces a novel contamination vector: these agents decouple information gathering from transaction execution across isolated sessions, eroding the platform's pricing power. -We present PHANTOM, a modular compound system that addresses this threat end-to-end. The system is composed of five orchestrated components: (1)~a configurable e-commerce research platform with dual-stream Kafka ingestion for behavioral and price-exposure events, (2)~a GOFAI-based weak labeling stage that partitions sessions into human and agent classes using rule-based predicates, (3)~a transition-kernel estimator that learns separable Markov models for each actor type and constructs a Contamination Generator for controlled simulation, (4)~a Distributionally Robust Reinforcement Learning policy that optimizes pricing under a Wasserstein ambiguity set conditioned on per-session divergence signals, and (5)~an Airflow-orchestrated pipeline that connects online data collection to offline policy training via Redis-backed model serving. +We present PHANTOM, a modular compound system that addresses this threat end-to-end. The system is composed of five orchestrated components: (1)~a configurable e-commerce research platform with dual-stream Kafka ingestion for behavioral and price-exposure events, (2)~a GOFAI-based weak labeling stage that partitions sessions into human and agent classes using rule-based predicates, (3)~a transition-kernel estimator that learns distinguishable Markov models for each actor type and constructs a Contamination Generator for controlled simulation, (4)~a Distributionally Robust Reinforcement Learning policy that optimizes pricing under a Wasserstein ambiguity set conditioned on per-session divergence signals, and (5)~an Airflow-orchestrated pipeline that connects online data collection to offline policy training via Redis-backed model serving. We formally derive the Cost of Information Theorem, proving that standard pricing mechanisms become incentive-incompatible as agent query volume grows. The system architecture, interaction schema, and factorial experiment harness are designed for reproducibility and are released as open artifacts. We evaluate system-level tradeoffs between revenue protection, information leakage, and user-experience degradation through a three-objective reward structure. \end{abstract} @@ -58,7 +58,7 @@ The current innovation boom in generative artificial intelligence and its applic The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. Dynamic pricing algorithms rely on directly translating demand features $q$ to new price assignments $\hat{p}$ across a catalogue of products of size $N$. When agent-driven reconnaissance traffic contaminates these demand signals, the pricing pipeline produces biased estimates that erode margins. This is not a single-model failure but a \textit{compound system} failure: the data ingestion, demand estimation, policy optimization, and model serving stages each propagate and amplify the contamination. -Existing work treats bot detection and dynamic pricing as separate concerns. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience. This gap is what our contribution aims to address. +Existing work treats bot detection and dynamic pricing as separate concerns. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for distinguishing non-human reconnaissance from genuine human demand expression and integrating that distinguishability into pricing heuristics without degrading legitimate user experience. This gap is what our contribution aims to address. \subsection{System-Level Contributions} @@ -78,7 +78,7 @@ We frame our contribution along the four CAIS pillars---architectural patterns, This work addresses three core research 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{RQ1}] \textit{Distinguishability}: 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? \end{enumerate} @@ -115,7 +115,7 @@ Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating th \subsection{Offline Loop: Policy Training} -The Kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The offline loop consumes collected trajectories, performs weak labeling and transition-kernel estimation (Section~\ref{sec:separability}), trains the DR-RL policy (Section~\ref{sec:drrl}) in a simulator, and pushes the resulting policy to Redis for the pricing provider to read. +The Kafka cluster is subscribed to by our pipeline which is configured on a schedule in Airflow, with the possibility of manual trigger. The offline loop consumes collected trajectories, performs weak labeling and transition-kernel estimation (Section~\ref{sec:distinguishability}), trains the DR-RL policy (Section~\ref{sec:drrl}) in a simulator, and pushes the resulting policy to Redis for the pricing provider to read. \subsection{Online Dynamic Pricing (Baseline)} @@ -165,7 +165,7 @@ The metadata record $\mu$ varies by action type. This heterogeneous structure is %% ==================================================================== \section{Methodology: Pipeline Components} -This section details the theoretical and practical framework behind each pipeline component. We formalize the problem environment, derive the \textit{Cost of Information} (COI) theorem that motivates the system design, describe the separability and contamination modules, and formulate the robust pricing policy. +This section details the theoretical and practical framework behind each pipeline component. We formalize the problem environment, derive the \textit{Cost of Information} (COI) theorem that motivates the system design, describe the distinguishability and contamination modules, and formulate the robust pricing policy. \subsection{Problem Formalization} @@ -225,15 +225,15 @@ Since the integrand vanishes as $N \to \infty$ for all $t > \underline{p}$, the This result is the theoretical motivation for the system design: it proves that standard pricing policies $\pi$ fail to extract surplus in the presence of large-scale agentic search, necessitating a contamination-aware component in the pipeline. -\subsection{Module: Separability and Contamination Generation} -\label{sec:separability} +\subsection{Module: Distinguishability and Contamination Generation} +\label{sec:distinguishability} To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from collected data using a two-stage approach. \subsubsection{GOFAI-Based Weak Labeling.} -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? +We use Good Old-Fashioned AI (GOFAI) heuristics to generate weak labels for distinguishability. 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 distinguishable enough to justify downstream pricing control that depends on that distinguishability? -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 recorded dataset (13 human sessions, 16 agent sessions; 45\%/55\%), the average divergence is approximately $1.8$. +To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global distinguishability and event-level diagnostics at the same time. In our recorded dataset (13 human sessions, 16 agent sessions; 45\%/55\%), the average divergence is approximately $1.8$. \begin{definition}[KL 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: @@ -243,7 +243,7 @@ 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} -With these divergence features we train a contrastive model to estimate a weak agent probability $f(\tau)\in[0,1]$, which serves as the interface between the separability module and the downstream pricing policy. +With these divergence features we train a contrastive model to estimate a weak agent probability $f(\tau)\in[0,1]$, which serves as the interface between the distinguishability module and the downstream pricing policy. \subsubsection{Transition-Kernel Estimation and Contamination Generator.} \label{sec:tpe} @@ -282,12 +282,12 @@ Given a newly observed partial trajectory $\tau'$, we compute its empirical tran \Delta_A(\tau') &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A) \end{align} -These divergence statistics serve as the operational connector between the separability module and the pricing policy. We define the per-session contamination estimate as: +These divergence statistics serve as the operational connector between the distinguishability module and the pricing policy. We define the per-session contamination estimate as: \begin{equation} \label{eq:alpha_hat} \hat{\alpha}(\tau') = \sigma\big(\beta(\Delta_H(\tau') - \Delta_A(\tau'))\big) \end{equation} -where $\sigma$ is the logistic function and $\beta > 0$ is a temperature parameter. This maps separability directly into a scalar control input for the pricing objective. +where $\sigma$ is the logistic function and $\beta > 0$ is a temperature parameter. This maps distinguishability directly into a scalar control input for the pricing objective. \subsubsection{Ambiguity Set Construction.} Because the contamination level $\alpha$ and demand shift are non-stationary, a point estimate of the demand distribution is insufficient. Let $\hat{P}_N$ denote the empirical reference distribution induced by the Contamination Generator $\mathcal{G}(\alpha)$. We define the Wasserstein ambiguity set: @@ -344,7 +344,7 @@ The simulator has multiple configurable factors, including valuation distributio Our training budget spans 384 TPU chips across v4, v5e, and v6e generations, distributed across Europe and U.S. regions with a spot-heavy mix and an on-demand reserve. At peak BF16 throughput this corresponds to roughly 160 PFLOPS of aggregate compute. We allocate v6e capacity to the heaviest policy training, use v5e for broad hyperparameter sweeps, and reserve on-demand v4 quota for runs that should not be preempted \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}. -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. +Our process follows three stages: (1)~observe and \textit{vectorize} behavioral interactions, (2)~learn distinguishability to characterize human versus agent patterns, and (3)~use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator. Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, run table, and assignment mapping). This data-acquisition phase is intentionally a disconnected component that feeds the later contributions. @@ -375,7 +375,7 @@ Initialize contamination estimate $\hat\alpha \leftarrow 0.2$\; $\mathcal S_t \leftarrow \mathcal S_t \cup \{\tau_m\}$\; } - \tcp{Estimate contamination from separability module} + \tcp{Estimate contamination from distinguishability module} 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) - \eta\cdot \text{UX}(\hat\alpha)$\; @@ -430,7 +430,7 @@ We formally defined the Cost of Information and proved that as the saturation of The system architecture, interaction schema, configurable e-commerce testbed, and factorial experiment harness are designed for reproducibility and released as open artifacts. 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. -Future work includes full factorial evaluation of the DR-RL policy across contamination levels, online adaptation of the ambiguity radius $\epsilon$ as a function of live divergence estimates, extension to multi-agent market maker settings, and integration of the HAP protocol~\cite{dhir_http_2025} as an additional signal source for the separability module. +Future work includes full factorial evaluation of the DR-RL policy across contamination levels, online adaptation of the ambiguity radius $\epsilon$ as a function of live divergence estimates, extension to multi-agent market maker settings, and integration of the HAP protocol~\cite{dhir_http_2025} as an additional signal source for the distinguishability module. %% ==================================================================== diff --git a/paper/src/mirrors/genpop/01-intro.tex b/paper/src/mirrors/genpop/01-intro.tex index 5222081..3528c91 100644 --- a/paper/src/mirrors/genpop/01-intro.tex +++ b/paper/src/mirrors/genpop/01-intro.tex @@ -2,9 +2,9 @@ \section{Introduction} -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. +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 distinguishability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned distinguishability 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. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 1st 2026.} +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 distinguishability 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 1st 2026.} \subsection{Motivation and Market Context} @@ -25,7 +25,7 @@ We formally define interaction data as coming from some actor which can either b This dissertation is organized around one main research question and three supporting sub-questions: \begin{enumerate} \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{SQ1}] \textit{Distinguishability}: 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} @@ -59,4 +59,4 @@ Extract final result from terminal state\; \end{algorithm} -The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary. +The previously described goal of distinguishability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary. diff --git a/paper/src/mirrors/genpop/02-literature-review.tex b/paper/src/mirrors/genpop/02-literature-review.tex index e9c9bf8..e28b37d 100644 --- a/paper/src/mirrors/genpop/02-literature-review.tex +++ b/paper/src/mirrors/genpop/02-literature-review.tex @@ -1,6 +1,6 @@ \section{Literature Review} -To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups. +To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for distinguishing non-human reconnaissance from genuine human demand expression and integrating that distinguishability into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups. \subsection{Agent Taxonomy and Definitions} diff --git a/paper/src/mirrors/genpop/03-methodology.tex b/paper/src/mirrors/genpop/03-methodology.tex index 5e316f9..222f553 100644 --- a/paper/src/mirrors/genpop/03-methodology.tex +++ b/paper/src/mirrors/genpop/03-methodology.tex @@ -1,6 +1,6 @@ \section{Methodology} -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. +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 distinguishability 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} @@ -113,9 +113,9 @@ The human data collection involved 13 participants, all of whom provided explici 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. 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 (agent vs human) with session-conditioned probability estimates, then injects those estimates into the pricing learner. +Operationally, goals and experiment runs are tracked in PostgreSQL. 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 distinguish classes (agent vs human) with session-conditioned probability estimates, then injects those estimates into the pricing learner. -Our process follows three stages: (1) observe and 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. +Our process follows three stages: (1) observe and vectorize behavioral interactions, (2) learn distinguishability 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}{!}{% @@ -209,15 +209,15 @@ In the simulator baseline this order is encoded with a compact fixed scale: cart In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response. -\subsection{Generative Contamination and Separability} +\subsection{Generative Contamination and Distinguishability} 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{Ground-Truth Separability} +\subsubsection{Ground-Truth Distinguishability} -Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels (human or agent) 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, treating the resulting human and agent kernels 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? +Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels (human or agent) 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, treating the resulting human and agent kernels as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels distinguishable enough to justify downstream pricing control that depends on that distinguishability? -To answer this, we compute per-session divergence scores against both class-level centroids. For each session in either partition, we fit a session-level event transition kernel from that session's trajectory alone, then compute its average divergence to the human centroid and to the agent centroid. The per-session separability score is the gap between these two divergences: a negative value indicates proximity to human behavior, a positive value indicates proximity to agent behavior. +To answer this, we compute per-session divergence scores against both class-level centroids. For each session in either partition, we fit a session-level event transition kernel from that session's trajectory alone, then compute its average divergence to the human centroid and to the agent centroid. The per-session distinguishability score is the gap between these two divergences: a negative value indicates proximity to human behavior, a positive value indicates proximity to agent behavior. We cannot assume normal distributions for divergence scores, which are right-skewed and bounded below by zero, so we do not use a Student's t-test. Instead we apply a Mann-Whitney U test \parencite{mann_test_1947} on the per-session gap scores between the two groups. The Mann-Whitney test is a rank-based nonparametric test that compares the ordering of two independent samples without distributional assumptions, making it appropriate for small samples drawn from skewed populations. @@ -305,7 +305,7 @@ We also consider taxation-like overlays for agent traffic under strategy-proof m \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. The defensive pricing loop algorithm formalizes the process 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 distinguishability, contamination estimation, and robust optimization components developed in the preceding sections. The defensive pricing loop algorithm formalizes the process 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} diff --git a/paper/src/mirrors/genpop/04-results.tex b/paper/src/mirrors/genpop/04-results.tex index 057424d..5950b35 100644 --- a/paper/src/mirrors/genpop/04-results.tex +++ b/paper/src/mirrors/genpop/04-results.tex @@ -8,7 +8,7 @@ \subsection{Behavioral Analysis} -Separability between human and agent sessions is evaluated by computing per-session divergence gap scores (how much closer each session is to the human baseline versus the agent baseline) and comparing the two groups with a Mann-Whitney U test. The full recorded cohort contains 13 human sessions and 16 agent sessions, and the table below reports the corresponding group-level statistics and test result. +Distinguishability between human and agent sessions is evaluated by computing per-session divergence gap scores (how much closer each session is to the human baseline versus the agent baseline) and comparing the two groups with a Mann-Whitney U test. The full recorded cohort contains 13 human sessions and 16 agent sessions, and the table below reports the corresponding group-level statistics and test result. \begin{table}[ht] \centering @@ -26,7 +26,7 @@ Agent sessions & 16 & $+1.65$ & $2.83$ \\ \end{tabular} \end{table} -The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result (p less than 0.001) at n=13 humans and n=16 agents indicates strong rank separation between groups, providing evidence that the transition kernels are separable enough to justify their use as a control signal in downstream pricing. +The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result (p less than 0.001) at n=13 humans and n=16 agents indicates strong rank distinction between groups, providing evidence that the transition kernels are distinguishable enough to justify their use as a control signal in downstream pricing. \subsection{Experimental Outcomes} @@ -50,6 +50,6 @@ This comparison isolates the effect of robustness terms from model capacity and \subsection{Interpretation and Insights} -The Mann-Whitney result (p less than 0.001) confirms that per-session divergence gaps separate the two actor classes with near-zero overlap in rank ordering. This is the condition required for separability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. +The Mann-Whitney result (p less than 0.001) confirms that per-session divergence gaps distinguish the two actor classes with near-zero overlap in rank ordering. This is the condition required for distinguishability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. \subsection{Anomalies} From 253364acae806befa53734ae92552adffa97939a Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:18:07 +0100 Subject: [PATCH 21/35] chore: plotting things and setting up her o better --- paper/src/chapters/03-methodology.tex | 41 ++- .../results/first_sweep_tier_revenue.tex | 1 + .../first_sweep_headline_summary.json | 10 + .../first_sweep_tier_alpha_deltas.csv | 31 ++ .../first_sweep_tier_alpha_mode_summary.csv | 61 ++++ .../first_sweep_tier_mode_summary.csv | 11 + .../generated/first_sweep_top_configs.csv | 26 ++ .../plots/first_sweep_tier_revenue.pdf | Bin 0 -> 17510 bytes .../generated/plots/ppo_alpha_curves.pdf | Bin 0 -> 25398 bytes .../generated/plots/ppo_delta_curves.pdf | Bin 0 -> 21999 bytes .../generated/plots/ppo_tradeoff_scatter.pdf | Bin 0 -> 26824 bytes .../results/generated/ppo_alpha_deltas.csv | 7 + .../generated/ppo_alpha_mode_summary.csv | 13 + .../generated/ppo_headline_summary.json | 7 + .../generated/ppo_overall_mode_summary.csv | 3 + .../generated/ppo_pairwise_win_rates.csv | 25 ++ .../chapters/figures/results/plot_results.py | 313 ++++++++++++++++++ .../figures/results/ppo_alpha_curves.tex | 1 + .../figures/results/ppo_delta_curves.tex | 1 + .../figures/results/ppo_tradeoff_scatter.tex | 1 + .../figures/results/process_all_results.py | 51 +++ .../figures/results/process_first_sweep.py | 272 +++++++++++++++ .../figures/results/process_ppo_benchmark.py | 277 ++++++++++++++++ .../src/chapters/hero_architecture_figure.tex | 166 ++++++++++ paper/src/preamble.tex | 2 +- 25 files changed, 1306 insertions(+), 14 deletions(-) create mode 100644 paper/src/chapters/figures/results/first_sweep_tier_revenue.tex create mode 100644 paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json create mode 100644 paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv create mode 100644 paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv create mode 100644 paper/src/chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf create mode 100644 paper/src/chapters/figures/results/generated/plots/ppo_alpha_curves.pdf create mode 100644 paper/src/chapters/figures/results/generated/plots/ppo_delta_curves.pdf create mode 100644 paper/src/chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf create mode 100644 paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv create mode 100644 paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/ppo_headline_summary.json create mode 100644 paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv create mode 100644 paper/src/chapters/figures/results/plot_results.py create mode 100644 paper/src/chapters/figures/results/ppo_alpha_curves.tex create mode 100644 paper/src/chapters/figures/results/ppo_delta_curves.tex create mode 100644 paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex create mode 100644 paper/src/chapters/figures/results/process_all_results.py create mode 100644 paper/src/chapters/figures/results/process_first_sweep.py create mode 100644 paper/src/chapters/figures/results/process_ppo_benchmark.py create mode 100644 paper/src/chapters/hero_architecture_figure.tex diff --git a/paper/src/chapters/03-methodology.tex b/paper/src/chapters/03-methodology.tex index 1653757..e07dcac 100644 --- a/paper/src/chapters/03-methodology.tex +++ b/paper/src/chapters/03-methodology.tex @@ -193,6 +193,17 @@ Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn distinguishability to characterize human versus agent patterns, and (3) use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator. +Figure~\ref{fig:phantom_unified_architecture} summarizes the full mechanism from online interaction capture to divergence-based contamination scoring and robust control of pricing decisions. + +\begin{figure}[ht] + \centering + \resizebox{\textwidth}{!}{% + \input{chapters/hero_architecture_figure.tex} + } + \caption{Unified PHANTOM defense architecture. (a) Online serving and logging with behavioral and price-query streams. (b) Distinguishability layer that estimates KL divergence to human/agent prototypes and derives session-level contamination scores. (c) Distributionally robust pricing control that optimizes under an ambiguity set while penalizing COI leakage and tracking UX cost.} + \label{fig:phantom_unified_architecture} +\end{figure} + \begin{figure}[ht] \resizebox{\columnwidth}{!}{% \input{chapters/loop_figure.tex} @@ -392,7 +403,7 @@ The complete pricing-demand-trajectory loop is illustrated in Figure~\ref{fig:or \begin{figure}[ht] \centering {\setlength{\arraycolsep}{4pt}% -\resizebox{0.98\linewidth}{!}{$ +\resizebox{0.85\linewidth}{!}{$ \begin{aligned} &\text{Oracle}(\vec{p}_{t-1},\vec{\hat{q}})\to \begin{pmatrix} @@ -517,23 +528,27 @@ The baseline achieves approximately 26 steps per second. Enabling the robustness \centering \caption{Per-step profiling results (20 steps, $M=10$ sessions, $N=3$ products). Self-time measures time spent inside the function excluding callees; cumulative time includes the full call subtree.} \label{tab:profile_results} -\begin{tabular}{@{}lrrrrl@{}} +\begingroup +\small +\setlength{\tabcolsep}{4pt} +\begin{tabular}{@{}lrrrr@{}} \toprule -\textbf{Function} & \textbf{Calls} & \textbf{Self (ms)} & \textbf{Cum. (ms)} & \textbf{Cum. \%} & \textbf{Module} \\ +\textbf{Function} & \textbf{Calls} & \textbf{Self (ms)} & \textbf{Cum. (ms)} & \textbf{Cum. \%} \\ \midrule -\multicolumn{6}{l}{\textit{Baseline ($K=1$, 0.77\,s total, 26 steps/s)}} \\ -\texttt{sample\_behavior\_from\_transitions} & 420 & 131 & 658 & 86\% & \texttt{lib/behavior} \\ -\texttt{DataFrame.xs} & 4,820 & 30 & 201 & 26\% & pandas \\ -\texttt{numpy.nan\_to\_num} & 4,904 & 43 & 97 & 13\% & numpy \\ -\texttt{adjust\_behavior\_to\_condition} & 84 & 3 & 54 & 7\% & \texttt{lib/behavior} \\ +\multicolumn{5}{l}{\textit{Baseline ($K=1$, 0.77\,s total, 26 steps/s)}} \\ +\texttt{sample\_behavior\_from\_transitions} & 420 & 131 & 658 & 86\% \\ +\texttt{DataFrame.xs} & 4,820 & 30 & 201 & 26\% \\ +\texttt{numpy.nan\_to\_num} & 4,904 & 43 & 97 & 13\% \\ +\texttt{adjust\_behavior\_to\_condition} & 84 & 3 & 54 & 7\% \\ \midrule -\multicolumn{6}{l}{\textit{Robust ($K=5$, 2.79\,s total, 7.2 steps/s)}} \\ -\texttt{sample\_behavior\_from\_transitions} & 1,220 & 519 & 2,447 & 88\% & \texttt{lib/behavior} \\ -\texttt{DataFrame.xs} & 16,668 & 108 & 729 & 26\% & pandas \\ -\texttt{numpy.nan\_to\_num} & 16,912 & 164 & 363 & 13\% & numpy \\ -\texttt{adjust\_behavior\_to\_condition} & 244 & 11 & 108 & 4\% & \texttt{lib/behavior} \\ +\multicolumn{5}{l}{\textit{Robust ($K=5$, 2.79\,s total, 7.2 steps/s)}} \\ +\texttt{sample\_behavior\_from\_transitions} & 1,220 & 519 & 2,447 & 88\% \\ +\texttt{DataFrame.xs} & 16,668 & 108 & 729 & 26\% \\ +\texttt{numpy.nan\_to\_num} & 16,912 & 164 & 363 & 13\% \\ +\texttt{adjust\_behavior\_to\_condition} & 244 & 11 & 108 & 4\% \\ \bottomrule \end{tabular} +\endgroup \end{table} Across both configurations, \texttt{sample\_behavior\_from\_transitions} accounts for 86--88\% of total wall time. The function implements the Markov chain sampler described in Section~\ref{sec:tpe}: at each transition it retrieves the current-state row from the expanded transition \texttt{DataFrame} via label-based indexing, which internally dispatches through the pandas \texttt{xs} and \texttt{fast\_xs} code paths. For $M$ sessions each running up to $L_{\max}=40$ transitions, a single \texttt{market.act()} call issues up to $M \cdot L_{\max}$ individual row lookups. With $K=5$ robustness candidates per outer step this accumulates to $5 \times 10 \times 40 = 2{,}000$ row accesses per outer step, producing the 16k \texttt{xs} invocations observed in Table~\ref{tab:profile_results}. diff --git a/paper/src/chapters/figures/results/first_sweep_tier_revenue.tex b/paper/src/chapters/figures/results/first_sweep_tier_revenue.tex new file mode 100644 index 0000000..f319a81 --- /dev/null +++ b/paper/src/chapters/figures/results/first_sweep_tier_revenue.tex @@ -0,0 +1 @@ +\includegraphics[width=0.99\linewidth]{chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf} diff --git a/paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json b/paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json new file mode 100644 index 0000000..caf3d15 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json @@ -0,0 +1,10 @@ +{ + "runs": 340, + "tiers": 5, + "alphas": 6, + "status": "ok", + "mean_tier_revenue_robust": 190714.62212212436, + "mean_tier_revenue_no_robust": 197371.17216609977, + "mean_tier_revenue_delta": -6656.5500439754105, + "mean_tier_revenue_delta_pct": -3.3726050116242514 +} \ No newline at end of file diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv b/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv new file mode 100644 index 0000000..fcddcd6 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv @@ -0,0 +1,31 @@ +tier,alpha,runs_robust,runs_no_robust,eval_revenue_mean_delta,eval_revenue_mean_delta_pct,eval_reward_mean_delta,eval_reward_mean_delta_pct,eval_coi_level_mean_delta,eval_coi_level_mean_delta_pct,eval_margin_mean_delta,eval_margin_mean_delta_pct,objective_score_delta,objective_score_delta_pct,train_alpha_adv_delta,train_alpha_adv_delta_pct +dqn,0.0,5.0,2.0,-31308.987414117495,-8.73651226889534,-1909.7427407095092,-0.5742991901121623,-2.8982436567700063,-2.1108702433020436,-0.001972064237093285,-0.2116777198290971,-1909.7427407095092,-0.5742991901121623,, +dqn,0.1,8.0,4.0,-7723.542755668925,-2.2789188721535494,-74239.37371836061,-21.063854618469847,1.7435833801418141,1.2859365583872486,0.0011891962142838164,0.1278074871971924,-74239.37371836061,-21.063854618469847,0.17619791666666657,176.19791666666694 +dqn,0.25,7.0,3.0,-12344.82818986749,-3.7035466052614323,93154.03627578515,36.06691230407512,0.03214544949867104,0.023426184113378143,1.763733457238459e-05,0.001893256490383175,93154.03627578515,36.06691230407512,0.14530952380952394,58.12380952380958 +dqn,0.4,5.0,10.0,-7816.300706216833,-2.4694340725162824,-42362.74668471434,-13.411888482380219,0.6251272343707797,0.4579446603861758,0.0002750615520492605,0.02953644634355915,-42362.74668471434,-13.411888482380219,0.09856666666666747,24.64166666666691 +dqn,0.6,5.0,4.0,-16150.011887742497,-5.347485987139731,-28508.74710866122,-10.151356300001888,-0.63306323164079,-0.46056970247177387,-0.00034537433455417155,-0.0370668515552649,-28508.74710866122,-10.151356300001888,0.1361999999999981,22.699999999999644 +dqn,0.8,7.0,6.0,-18191.8826663699,-6.440527544692988,-55296.94441124235,-20.19273590083627,-0.796733634735034,-0.579832425016392,-0.0006423984775592029,-0.0689476165584585,-55296.94441124235,-20.19273590083627,0.1532857142857158,19.160714285714512 +linear,0.0,9.0,8.0,-14967.67388588126,-4.273413942959129,-20107.23171681742,-6.60039931288617,-0.06127790826209889,-0.04564810574240612,-7.607744079518586e-05,-0.008177885913528719,-20107.23171681742,-6.60039931288617,, +linear,0.1,3.0,5.0,-24531.399901538738,-7.171831328305365,-96669.7835552101,-26.44920711447249,-0.3680976907859872,-0.2733723058172187,-0.0002515287835096469,-0.02702956778346356,-96669.7835552101,-26.44920711447249,, +linear,0.25,6.0,9.0,-14840.859479571285,-4.520682292638562,-26510.179456423968,-8.033117756667396,-0.13734776448131925,-0.10212641096230607,-9.41162442338328e-05,-0.010115001392981545,-26510.179456423968,-8.033117756667396,, +linear,0.4,4.0,11.0,-17196.7642560167,-5.486915251242723,-74520.10209817477,-25.042311510043184,0.12217076984330788,0.09098828726103136,0.00010713887099822461,0.011516865671259795,-74520.10209817477,-25.042311510043184,, +linear,0.6,5.0,3.0,-14284.06615788641,-4.854766876637072,38417.71856593515,14.088596762512362,0.24251461234271687,0.1806530855220358,0.0002606811969937395,0.028024824619509187,38417.71856593515,14.088596762512362,, +linear,0.8,4.0,11.0,-10840.488575784548,-3.933600919557566,15749.581078662042,6.447651726824251,0.028051260535562506,0.020876236575910773,5.361882659971062e-05,0.005763158099097226,15749.581078662042,6.447651726824251,, +qtable,0.0,9.0,8.0,-18644.457288398524,-8.15323701554329,32993.42568058451,20.675688115613053,10.369779227648095,10.682768960780463,0.018566897519637582,2.0803084179092814,32993.42568058451,20.675688115613053,0.11839814814814797, +qtable,0.1,6.0,5.0,-12549.400855549495,-4.616991193742389,-37207.79701261924,-15.336047254435487,0.0884057957559321,0.07703761042583206,-0.01127789819771663,-1.2272540823820444,-37207.79701261924,-15.336047254435487,0.07577777777777787,75.77777777777803 +qtable,0.25,6.0,5.0,-1534.3527429780224,-0.5456640130847226,18433.43663451099,7.304472653867784,-0.5776125938941306,-0.45734160960552755,-0.003316338490628068,-0.3584028328803385,18433.43663451099,7.304472653867784,0.1181458333333334,47.258333333333354 +qtable,0.4,8.0,6.0,-15146.258176090778,-5.274860187729517,-37364.22587794208,-13.005651205148677,0.4611471727478005,0.3629050099230144,0.0071046453227539,0.7751478467862876,-37364.22587794208,-13.005651205148677,0.11010416666666772,27.52604166666698 +qtable,0.6,6.0,6.0,-9577.578548656049,-3.9322693501816666,-19088.152339068736,-9.571307395166029,0.9081750157567683,0.7495917946306662,0.0015520804425310786,0.16838348372043557,-19088.152339068736,-9.571307395166029,0.16983333333333228,28.305555555555333 +qtable,0.8,5.0,2.0,-52751.680936846446,-19.699089872409548,-16508.209313987172,-7.589601869470744,-15.022454081083623,-11.215398490282094,-0.007791824761087751,-0.8384414846099099,-16508.209313987172,-7.589601869470744,0.11120000000000174,13.900000000000245 +static,0.0,5.0,6.0,-4782.871053113384,-5.233544525848519,14411.4689779756,25.538141347978577,1.307060701942973,1.8731997380823568,0.002537468952847566,0.2911381045328444,14411.4689779756,25.538141347978577,, +static,0.1,8.0,5.0,1629.4524528499896,1.880088900553112,-5347.078589385725,-8.14812684380662,0.3600324838305795,0.5019134064795009,-4.6492644957929485e-05,-0.005316014641356001,-5347.078589385725,-8.14812684380662,, +static,0.25,5.0,6.0,-9938.662276761897,-10.398087633377964,-23616.087243780566,-27.701108621456626,-3.0513860773271233,-4.099238223547561,-0.003519771479853273,-0.40113716461596144,-23616.087243780566,-27.701108621456626,, +static,0.4,3.0,4.0,1850.8400595222774,2.1912497828943436,15058.659457798465,23.67199439061036,3.669612467486587,5.430169778169349,0.006763447803564415,0.7804393835882188,15058.659457798465,23.67199439061036,, +static,0.6,6.0,5.0,1038.893948415236,1.2765037688226162,-6062.864079504681,-9.363144945348399,-1.712609061865976,-2.3996341009364213,-0.0042285583442709385,-0.48362088973179423,-6062.864079504681,-9.363144945348399,, +static,0.8,3.0,7.0,2696.6340631967323,3.6826150812750567,149.22406835677975,0.27280281303997084,0.8491716126507072,1.2427748744725668,0.0032786525965587954,0.3777595573932637,149.22406835677975,0.27280281303997084,, +surge,0.0,6.0,6.0,-606.73760243367,-5.066579306500225,-244.17585425326251,-5.525800641331023,0.014874931199557295,0.09186560988877175,0.0019308940532419272,0.4471794260021321,-244.17585425326251,-5.525800641331023,, +surge,0.1,2.0,5.0,169.78743573408792,1.446343107913299,-1012.7706974660168,-20.02053666691211,-0.14459518037699226,-0.864651254901582,-0.0018650458785858248,-0.4260349899970559,-1012.7706974660168,-20.02053666691211,, +surge,0.25,10.0,7.0,-128.20993816584632,-1.1276930411162496,-81.21373487263281,-1.7081453033360994,0.3008506477195141,1.839047728806548,0.0030750148302954305,0.7102446987902812,-81.21373487263281,-1.7081453033360994,, +surge,0.4,6.0,6.0,-473.03722764431404,-4.297928307550563,28.557452243338048,0.6755106104955642,-0.5027452173053764,-3.072002360121898,-0.005581380442163164,-1.288152985482699,28.557452243338048,0.6755106104955642,, +surge,0.6,2.0,5.0,307.79436325796996,3.0356727142643067,2060.57396030564,63.382050333909866,0.2339650444065704,1.438519400758399,0.001302270025389629,0.30077697380833807,2060.57396030564,63.382050333909866,, +surge,0.8,3.0,3.0,423.15386247993047,4.372210191290083,1117.0942083304312,34.86182570616373,0.8971464536957541,5.327339899805159,0.007068630716831503,1.6094191039618562,1117.0942083304312,34.86182570616373,, diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv b/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv new file mode 100644 index 0000000..dba8d81 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv @@ -0,0 +1,61 @@ +tier,alpha,mode,runs,eval_revenue_mean_mean,eval_revenue_mean_std,eval_reward_mean_mean,eval_reward_mean_std,eval_coi_level_mean_mean,eval_coi_level_mean_std,eval_margin_mean_mean,eval_margin_mean_std,objective_score_mean,objective_score_std,train_alpha_adv_mean,train_alpha_adv_std +dqn,0.0,no_robust,2,358369.40933039243,3531.782519351935,332534.46523867303,114183.5587841961,137.30089123035202,0.8184776440325546,0.9316352418598786,0.0006839003676302996,332534.46523867303,114183.5587841961,, +dqn,0.0,robust,5,327060.42191627494,24311.17412598574,330624.7224979635,62834.39223547943,134.40264757358202,6.160000643680792,0.9296631776227853,0.004262039730140749,330624.7224979635,62834.39223547943,0.17835000000000004,0.08829347371125472 +dqn,0.1,no_robust,4,338912.58043645386,19584.736810155388,352449.13650924934,34076.74819101191,135.58860029055563,3.4055508991301524,0.9304589585186211,0.0023438665484978773,352449.13650924934,34076.74819101191,0.0999999999999998,0.0 +dqn,0.1,robust,8,331189.03768078494,8060.912085646968,278209.7627908887,57861.69545853692,137.33218367069745,0.43113256118808096,0.931648154732905,0.000296560958972609,278209.7627908887,57861.69545853692,0.2761979166666664,0.09826648189130198 +dqn,0.25,no_robust,3,333324.4996115304,6101.717861804452,258281.15112936878,46772.05216097596,137.2201692904545,0.9866477887862672,0.9315871706751672,0.0006356053229300815,258281.15112936878,46772.05216097596,0.25,0.0 +dqn,0.25,robust,7,320979.6714216629,7345.8761269427705,351435.18740515393,40320.63699261721,137.25231473995316,0.3527287960309152,0.9316048080097395,0.0002575240668471541,351435.18740515393,40320.63699261721,0.39530952380952394,0.073021206240698 +dqn,0.4,no_robust,10,316521.94295076875,3631.1820920182718,315859.66987697606,59129.03566963754,136.50715652926755,0.5085743959240285,0.931261495881483,0.00031280530251053175,315859.66987697606,59129.03566963754,0.3999999999999993,0.0 +dqn,0.4,robust,5,308705.6422445519,10654.571556448245,273496.9231922617,68868.59270778317,137.13228376363833,0.9543108715306617,0.9315365574335323,0.0006302636717132419,273496.9231922617,68868.59270778317,0.49856666666666677,0.05745573175159429 +dqn,0.6,no_robust,4,302011.2988903938,2354.1141598720183,280836.828756133,58683.00124997926,137.4522093492651,0.4692723362517602,0.9317606434396914,0.0003317518021682495,280836.828756133,58683.00124997926,0.600000000000001,0.0 +dqn,0.6,robust,5,285861.2870026513,10386.571631344234,252328.08164747176,59388.56063758225,136.8191461176243,1.0629203361893034,0.9314152691051373,0.0005692783702932289,252328.08164747176,59388.56063758225,0.7361999999999991,0.07108625433623189 +dqn,0.8,no_robust,6,282459.51189759385,2625.018247527438,273845.72691287595,66378.16690732416,137.4075681801531,0.29728950101826707,0.9317196295169007,0.00022799290978965786,273845.72691287595,66378.16690732416,0.7999999999999985,0.0 +dqn,0.8,robust,7,264267.62923122395,6771.288971321149,218548.7825016336,50043.2009443344,136.61083454541807,1.2319662937254596,0.9310772310393415,0.0010118564779437284,218548.7825016336,50043.2009443344,0.9532857142857143,0.04709817507333055 +linear,0.0,no_robust,8,350250.9723061577,3156.286820918861,304636.59490360576,71682.88027353655,134.2397614654424,0.32611787466946035,0.9302824910938235,0.00024020749661685483,304636.59490360576,71682.88027353655,, +linear,0.0,robust,9,335283.29842027643,7707.594869976611,284529.36318678834,55524.58819004573,134.1784835571803,0.4477314164684001,0.9302064136530284,0.00034781034181738526,284529.36318678834,55524.58819004573,, +linear,0.1,no_robust,5,342052.1032713031,2576.546352056584,365492.17954557994,44890.93522299766,134.65068807375954,0.2181027640393531,0.930569018064469,0.00014058935916940913,365492.17954557994,44890.93522299766,, +linear,0.1,robust,3,317520.7033697644,4796.580459456527,268822.39599036984,39256.421140635124,134.28259038297355,0.24570499109363475,0.9303174892809594,0.00018817899183709092,268822.39599036984,39256.421140635124,, +linear,0.25,no_robust,9,328288.0441241802,2178.525494145428,330011.0898339667,38591.36053388808,134.48799697074742,0.2199303973026469,0.9304619997297959,0.00015341642413402035,330011.0898339667,38591.36053388808,, +linear,0.25,robust,6,313447.18464460893,11811.426711620714,303500.9103775427,63358.917144214036,134.3506492062661,0.2947034403278951,0.9303678834855621,0.00021446628431268986,303500.9103775427,63358.917144214036,, +linear,0.4,no_robust,11,313414.0672597746,1982.9537556159262,297576.7714904776,69396.90446617964,134.2708754290745,0.3062093691351849,0.9302780292522507,0.00023067974755288992,297576.7714904776,69396.90446617964,, +linear,0.4,robust,4,296217.3030037579,5109.898340355844,223056.66939230284,38293.73688466607,134.3930461989178,0.12347753686382154,0.9303851681232489,7.324605809708878e-05,223056.66939230284,38293.73688466607,, +linear,0.6,no_robust,3,294227.64307441004,2081.9176570448135,272686.62176604365,66672.50905805513,134.24327165069943,0.30764332256042104,0.9301795837547151,0.00020453921786790446,272686.62176604365,66672.50905805513,, +linear,0.6,robust,5,279943.5769165236,9866.031719660255,311104.3403319788,28363.930707781863,134.48578626304214,0.21280262186464388,0.9304402649517088,0.00020533894868120649,311104.3403319788,28363.930707781863,, +linear,0.8,no_robust,11,275586.89347174135,1618.038877505867,244268.4832547461,56201.44465269986,134.36933631960773,0.2845660213184439,0.9303723007028001,0.00017640716421186918,244268.4832547461,56201.44465269986,, +linear,0.8,robust,4,264746.4048959568,7976.6279174956235,260018.06433340814,57942.49882730146,134.3973875801433,0.31511916357643405,0.9304259195293998,0.00023606570471334208,260018.06433340814,57942.49882730146,, +qtable,0.0,no_robust,8,228675.52179404112,103199.70453252994,159575.94976328663,95848.81008103945,97.07014413321637,33.0637115678536,0.8925069648229078,0.04890522141482132,159575.94976328663,95848.81008103945,0.0,0.0 +qtable,0.0,robust,9,210031.0645056426,84361.3834579348,192569.37544387113,116824.7880426837,107.43992336086447,21.41128645838254,0.9110738623425454,0.019188350719133364,192569.37544387113,116824.7880426837,0.11839814814814797,0.061909456985161225 +qtable,0.1,no_robust,5,271809.0706466638,14898.209045050968,242616.60384397948,49181.45526408063,114.75666919996793,3.461383158930426,0.9189538140159812,0.002294693249439748,242616.60384397948,49181.45526408063,0.0999999999999998,0.0 +qtable,0.1,robust,6,259259.66979111428,102995.29934229614,205408.80683136024,94155.1845420674,114.84507499572386,36.206421837506966,0.9076759158182646,0.048591979839360346,205408.80683136024,94155.1845420674,0.17577777777777767,0.06720562696899951 +qtable,0.25,no_robust,5,281190.01916657295,70274.10208723843,252358.2126733039,129868.46825082717,126.29784427276161,15.368804047323954,0.9253103453385114,0.009044883517550522,252358.2126733039,129868.46825082717,0.25,0.0 +qtable,0.25,robust,6,279655.6664235949,93056.2549557545,270791.6493078149,116021.46257259768,125.72023167886748,26.760714047253796,0.9219940068478834,0.022785695882060884,270791.6493078149,116021.46257259768,0.3681458333333334,0.08845114686619042 +qtable,0.4,no_robust,6,287140.4669895195,32698.16434426399,287292.23388022534,83855.95000252876,127.07104066863859,9.200301166154173,0.9165535777734913,0.01306001923887748,287292.23388022534,83855.95000252876,0.3999999999999993,0.0 +qtable,0.4,robust,8,271994.2088134287,79259.3185780895,249928.00800228326,88265.30801790548,127.53218784138639,23.406428094683015,0.9236582230962452,0.020073747007871224,249928.00800228326,88265.30801790548,0.510104166666667,0.09294655989347765 +qtable,0.6,no_robust,6,243563.64469828535,67006.60707045678,199430.98211127534,79119.52886604435,121.15594411011905,17.91243944823949,0.9217533740470492,0.011558797825966702,199430.98211127534,79119.52886604435,0.600000000000001,0.0 +qtable,0.6,robust,6,233986.0661496293,43155.478617087436,180342.8297722066,48117.79957836251,122.06411912587582,12.160951090203252,0.9233054544895802,0.006840854872863436,180342.8297722066,48117.79957836251,0.7698333333333333,0.09107066853090896 +qtable,0.8,no_robust,2,267787.4017455507,1552.038101264713,217510.87340156303,45358.788584678456,133.9448981157492,0.47346860040111405,0.9293224278749692,0.0002998116010539045,217510.87340156303,45358.788584678456,0.7999999999999985,0.0 +qtable,0.8,robust,5,215035.72080870424,32869.73253165852,201002.66408757586,63247.67956376057,118.92244403466557,8.586916805142152,0.9215306031138815,0.004644709320891907,201002.66408757586,63247.67956376057,0.9112000000000002,0.07381653307732307 +static,0.0,no_robust,6,91388.75248869567,13415.65534300268,56431.15832748852,8525.098185703384,69.77689967440658,3.670744870085874,0.8715688236409825,0.005831496806767582,56431.15832748852,8525.098185703384,, +static,0.0,robust,5,86605.88143558228,7614.909395960895,70842.62730546412,8033.737230392738,71.08396037634955,3.6802889678420283,0.8741062925938301,0.005083911544334936,70842.62730546412,8033.737230392738,, +static,0.1,no_robust,5,86668.90445290186,8037.955688932984,65623.40881389238,19329.448262530004,71.73199185012882,4.199046495412734,0.874577067494122,0.006610505646022198,65623.40881389238,19329.448262530004,, +static,0.1,robust,8,88298.35690575185,9576.838833058617,60276.33022450666,13359.490452744656,72.0920243339594,6.7706096714767865,0.8745305748491641,0.010083585815241344,60276.33022450666,13359.490452744656,, +static,0.25,no_robust,6,95581.63603909909,8345.698435455577,85253.22060752509,13111.526873622026,74.43788116042678,2.1078820386097368,0.8774483618896327,0.0037254791853004897,85253.22060752509,13111.526873622026,, +static,0.25,robust,5,85642.97376233719,9472.880627242153,61637.13336374452,15937.429780623212,71.38649508309966,4.0264905454627264,0.8739285904097794,0.005323853359397925,61637.13336374452,15937.429780623212,, +static,0.4,no_robust,4,84465.04245981346,12101.831388745604,63613.81812329075,7778.361846092061,67.5782271530322,3.9088888968092,0.8666205147756862,0.007149121199217965,63613.81812329075,7778.361846092061,, +static,0.4,robust,3,86315.88251933573,8642.748496122398,78672.47758108922,17823.74997200773,71.24783962051879,2.790416943786253,0.8733839625792507,0.005990544453538607,78672.47758108922,17823.74997200773,, +static,0.6,no_robust,5,81385.88962988024,12343.523894997037,64752.43216774836,23486.779472906223,71.36959177224794,5.100226704959064,0.874353948320141,0.007787250295491337,64752.43216774836,23486.779472906223,, +static,0.6,robust,6,82424.78357829548,9831.886701625144,58689.56808824368,12672.506035553573,69.65698271038197,3.484982360048201,0.8701253899758701,0.005917711231889304,58689.56808824368,12672.506035553573,, +static,0.8,no_robust,7,73226.06364450825,4447.877985963851,54700.340767716196,14406.881298569717,68.32867561883204,3.68262917356943,0.8679204886788817,0.007467501164611224,54700.340767716196,14406.881298569717,, +static,0.8,robust,3,75922.69770770498,5046.089536162847,54849.564836072976,22780.98012221352,69.17784723148274,1.5268167784698885,0.8711991412754405,0.0033278715575433297,54849.564836072976,22780.98012221352,, +surge,0.0,no_robust,6,11975.290738176132,411.4052900076416,4418.832131346071,896.5828048394391,16.192056219479124,0.8040364003224534,0.4317940274006973,0.008271862690929055,4418.832131346071,896.5828048394391,, +surge,0.0,robust,6,11368.553135742462,623.8217438159004,4174.6562770928085,639.9963040241264,16.20693115067868,0.9853827520149101,0.4337249214539392,0.010371668289035135,4174.6562770928085,639.9963040241264,, +surge,0.1,no_robust,5,11739.084232858655,332.778792718381,5058.659087494994,1110.8409258976824,16.722948073839394,0.6578121995950104,0.4377682402562083,0.005683401047550787,5058.659087494994,1110.8409258976824,, +surge,0.1,robust,2,11908.871668592743,81.41250285550258,4045.8883900289775,784.7169500268457,16.5783528934624,0.4088194924856508,0.4359031943776225,0.004531137621699143,4045.8883900289775,784.7169500268457,, +surge,0.25,no_robust,7,11369.223138855004,236.1121240061105,4754.4980344481255,1038.0550037539617,16.359045119223275,0.3945156775653057,0.4329514652531622,0.0038762110261952457,4754.4980344481255,1038.0550037539617,, +surge,0.25,robust,10,11241.013200689158,684.503587066406,4673.284299575493,1187.78635131025,16.65989576694279,1.0515950311117155,0.4360264800834576,0.009701952962125513,4673.284299575493,1187.78635131025,, +surge,0.4,no_robust,6,11006.168409400554,364.6584583108646,4227.535704048808,1414.7964077877168,16.365391636138824,0.9138430058543858,0.4332855262584901,0.008024003783434592,4227.535704048808,1414.7964077877168,, +surge,0.4,robust,6,10533.13118175624,526.0758051960169,4256.093156292146,783.7965507386594,15.862646418833448,0.7732699435426456,0.42770414581632693,0.008967505611725135,4256.093156292146,783.7965507386594,, +surge,0.6,no_robust,5,10139.2472848498,97.448078425168,3251.037082975553,742.2100315641153,16.26429537781848,0.4432465691073604,0.4329686574409998,0.004121820888165019,3251.037082975553,742.2100315641153,, +surge,0.6,robust,2,10447.04164810777,524.0029334247373,5311.611043281193,1808.6200710093085,16.49826042222505,0.6088756908260344,0.43427092746638946,0.007817511630542989,5311.611043281193,1808.6200710093085,, +surge,0.8,no_robust,3,9678.259826640971,272.83530913170915,3204.3479815026553,556.8799617962688,16.840420745981802,0.4589959822922529,0.43920385308157944,0.004953937449529005,3204.3479815026553,556.8799617962688,, +surge,0.8,robust,3,10101.413689120902,526.8318040489241,4321.442189833087,1284.166148011517,17.737567199677557,0.6586775330563983,0.44627248379841095,0.004644261847052545,4321.442189833087,1284.166148011517,, diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv b/paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv new file mode 100644 index 0000000..e296749 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv @@ -0,0 +1,11 @@ +tier,mode,runs,eval_revenue_mean_mean,eval_revenue_mean_std,eval_reward_mean_mean,eval_reward_mean_std,eval_coi_level_mean_mean,eval_coi_level_mean_std,eval_margin_mean_mean,eval_margin_mean_std,objective_score_mean,objective_score_std,train_alpha_adv_mean,train_alpha_adv_std +dqn,no_robust,29,315185.66674813855,23538.781000060844,302576.8036266896,62951.88633145167,136.82560356086017,1.3692652218935986,0.9313739013618878,0.0009314135057224836,302576.8036266896,62951.88633145167,0.45740740740740693,0.2368477698794438 +dqn,robust,37,306875.13950902375,27585.74444520695,283724.7169827867,69843.05611741856,136.68837571992978,2.3797541654948753,0.9312171495138941,0.0016512408492580111,283724.7169827867,69843.05611741856,0.5058198198198196,0.28324483129860284 +linear,no_robust,47,315501.15296155965,27105.014861872147,298149.1730416604,67664.7308344108,134.36884359609928,0.29743647613433244,0.9303607531364,0.0002152647006739543,298149.1730416604,67664.7308344108,, +linear,robust,31,306269.9232239004,26399.875293394463,279872.824370329,54401.104602086416,134.32737693008372,0.31909212993628877,0.9303375215162144,0.00025000448833182963,279872.824370329,54401.104602086416,, +qtable,no_robust,32,259818.72178238883,67188.58622318009,222088.83510765125,94450.12569617687,116.84641954166946,22.42810298937963,0.9140582213134033,0.02778864370791322,222088.83510765125,94450.12569617687,0.29218749999999993,0.2559326319498438 +qtable,robust,40,244470.50673219413,78666.30912808319,216920.53697298188,93983.50987622296,118.94013969887506,23.1428303249914,0.9178608956089163,0.023827311253270544,216920.53697298188,93983.50987622296,0.4396239583333334,0.29521865862482416 +static,no_robust,33,85228.452028227,12041.415672002751,64828.579890468536,17681.280330831738,70.58818912317687,4.204964531595236,0.8721419294578765,0.007107262779462876,64828.579890468536,17681.280330831738,, +static,robust,30,84963.18577955024,8926.291379160475,63243.76603076817,14880.924342692271,70.94358095957392,4.363134562111469,0.8730306888410219,0.006660289247744752,63243.76603076817,14880.924342692271,, +surge,no_robust,32,11121.867310184698,809.9895800277001,4260.038064073964,1160.4282377968032,16.416108827015794,0.641203520341943,0.43413855082681374,0.006214799767130059,4260.038064073964,1160.4282377968032,, +surge,robust,29,10994.355365953365,750.5115890942825,4448.160863178768,1000.7519971246122,16.495943148858906,0.9823026347466668,0.4347587896392907,0.009698591291108968,4448.160863178768,1000.7519971246122,, diff --git a/paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv b/paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv new file mode 100644 index 0000000..e51fd74 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv @@ -0,0 +1,26 @@ +Name,tier,alpha,mode,objective/score,eval/revenue_mean,eval/reward_mean,eval/coi_level_mean,lambda_coi,robust_radius,learning_rate,batch_size,n_steps,total_timesteps +eager-sweep-244,dqn,0.0,no_robust,413274.4339549909,355872.06196128257,413274.4339549909,136.722140138007,0.2,0.1,0.0003,256,4096,15000 +efficient-sweep-319,linear,0.0,no_robust,410094.0151741567,353309.5198146561,410094.0151741567,134.55152038805429,0.4,0.1,0.001,128,4096,15000 +swept-sweep-422,linear,0.0,no_robust,403130.32747386186,347611.2815474988,403130.32747386186,133.8559785775022,0.4,0.3,0.0001,512,1024,15000 +decent-sweep-478,linear,0.1,no_robust,400452.36418713134,345284.5750647792,400452.36418713134,134.73082941975588,0.1,0.2,0.001,128,1024,50000 +eternal-sweep-339,linear,0.1,no_robust,399628.4231731644,344154.38525771734,399628.4231731644,134.89479277649667,0.4,0.1,0.0001,256,1024,50000 +ethereal-sweep-21,dqn,0.1,no_robust,398492.807245857,343580.6802427996,398492.807245857,136.67160732585188,0.1,0.2,0.001,512,2048,50000 +dark-sweep-418,linear,0.1,no_robust,394615.3720658343,339749.76272695075,394615.3720658343,134.39233246711,0.2,0.1,0.0003,256,1024,50000 +wandering-sweep-122,dqn,0.0,robust,394061.3617726404,339512.43434806296,394061.3617726404,137.6864755964331,0.1,0.3,0.0001,256,2048,30000 +laced-sweep-132,dqn,0.1,robust,389274.54998495104,335600.5979215904,389274.54998495104,137.36888574027677,0.4,0.2,0.001,256,2048,30000 +rich-sweep-53,qtable,0.0,robust,388601.2626147048,335630.6853337664,388601.2626147048,133.4414069888203,0.2,0.1,0.0001,512,1024,50000 +faithful-sweep-430,qtable,0.25,no_robust,387035.6970938766,333255.5771210341,387035.6970938766,137.4906091183188,0.1,0.2,0.0003,128,1024,15000 +dark-sweep-280,qtable,0.25,no_robust,386318.8845004527,332220.0316564078,386318.8845004527,137.26992450099925,0.4,0.1,0.0001,256,1024,50000 +chocolate-sweep-383,linear,0.25,no_robust,383989.49015403807,331071.7003244704,383989.49015403807,134.60590742050857,0.1,0.2,0.001,512,1024,30000 +dry-sweep-263,dqn,0.0,robust,383372.6880637367,330436.0312615148,383372.6880637367,137.40558130223476,0.1,0.3,0.001,128,1024,50000 +different-sweep-143,qtable,0.0,robust,383278.4198015018,330546.16800945485,383278.4198015018,135.9021538079678,0.1,0.3,0.001,256,2048,30000 +woven-sweep-139,dqn,0.25,robust,382788.1296637251,329427.735752473,382788.1296637251,136.8968339394894,0.1,0.1,0.001,512,1024,15000 +dark-sweep-215,dqn,0.25,robust,382358.2401374872,329330.0097603144,382358.2401374872,137.64528612332785,0.2,0.1,0.0001,512,4096,30000 +charmed-sweep-136,linear,0.25,no_robust,382249.5728044314,329646.2053260979,382249.5728044314,134.46825608007862,0.4,0.1,0.0001,256,2048,15000 +light-sweep-308,linear,0.0,robust,381939.1275250679,329628.9436641051,381939.1275250679,133.6209821974879,0.2,0.2,0.001,128,4096,30000 +treasured-sweep-325,linear,0.25,robust,381322.0104772589,328353.58675398555,381322.0104772589,134.8950293943581,0.1,0.1,0.0001,512,2048,15000 +fine-sweep-202,dqn,0.25,robust,378751.33572275366,326518.9068184018,378751.33572275366,137.2900973301052,0.1,0.2,0.0001,512,2048,30000 +treasured-sweep-380,linear,0.25,no_robust,377898.0979419424,325869.1953595453,377898.0979419424,134.54118723889738,0.4,0.3,0.001,128,1024,50000 +pretty-sweep-49,qtable,0.25,robust,377318.4766808995,325282.0152823859,377318.4766808995,137.19609012644068,0.4,0.1,0.0001,128,4096,50000 +desert-sweep-253,linear,0.25,robust,376808.6335063269,325146.3478714648,376808.6335063269,134.48396340732663,0.2,0.1,0.0003,256,1024,30000 +jolly-sweep-133,qtable,0.4,no_robust,376419.57394710975,323709.24588324485,376419.57394710975,137.8349363778071,0.1,0.3,0.0001,128,2048,50000 diff --git a/paper/src/chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf b/paper/src/chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a019aee4615d177aa840da1f218351311de822a5 GIT binary patch literal 17510 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 V-8}ZDF@z8}+l6p)%Baag{tr7N?@|B& literal 0 HcmV?d00001 diff --git a/paper/src/chapters/figures/results/generated/plots/ppo_alpha_curves.pdf b/paper/src/chapters/figures/results/generated/plots/ppo_alpha_curves.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d012154f84dbfb68caa05c7cc37a6838173f4046 GIT binary patch literal 25398 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 GVE+gHJF!gw literal 0 HcmV?d00001 diff --git a/paper/src/chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf b/paper/src/chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf new file mode 100644 index 0000000000000000000000000000000000000000..34da3accf8ed857cd42c0291a2e41dcfd8af8299 GIT binary patch literal 26824 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 literal 0 HcmV?d00001 diff --git a/paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv b/paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv new file mode 100644 index 0000000..42cf5c9 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv @@ -0,0 +1,7 @@ +alpha,runs_robust,runs_no_robust,eval_revenue_mean_robust,eval_revenue_mean_no_robust,eval_revenue_mean_delta,eval_revenue_mean_delta_pct,eval_reward_mean_robust,eval_reward_mean_no_robust,eval_reward_mean_delta,eval_reward_mean_delta_pct,eval_coi_level_mean_robust,eval_coi_level_mean_no_robust,eval_coi_level_mean_delta,eval_coi_level_mean_delta_pct,eval_coi_leakage_mean_robust,eval_coi_leakage_mean_no_robust,eval_coi_leakage_mean_delta,eval_coi_leakage_mean_delta_pct,eval_volatility_mean_robust,eval_volatility_mean_no_robust,eval_volatility_mean_delta,eval_volatility_mean_delta_pct,eval_margin_mean_robust,eval_margin_mean_no_robust,eval_margin_mean_delta,eval_margin_mean_delta_pct,train_alpha_adv_robust,train_alpha_adv_no_robust,train_alpha_adv_delta,train_alpha_adv_delta_pct,train_coi_penalty_robust,train_coi_penalty_no_robust,train_coi_penalty_delta,train_coi_penalty_delta_pct,train_ux_penalty_robust,train_ux_penalty_no_robust,train_ux_penalty_delta,train_ux_penalty_delta_pct,train_agent_prob_robust,train_agent_prob_no_robust,train_agent_prob_delta,train_agent_prob_delta_pct +0.0,4.0,4.0,3379.9042994670963,3565.2912010160844,-185.38690154898813,-5.199768857482219,313527.4707462,331300.229069,-17772.758322799986,-5.364547550342456,137.08358925982625,137.28764358955686,-0.2040543297306101,-0.14863269875959326,0.1146626165658294,0.11861133504329742,-0.003948718477468013,-3.3291240470622716,0.06687153537785637,0.06445662162531288,0.0024149137525434905,3.746572022625408,0.9315273502623671,0.9317078361627993,-0.00018048590043218127,-0.019371512552207898,0.18958333333333333,,,,5.553200113221484,,,,61.35134238638615,66.58479574844135,-5.233453362055201,-7.859832418540847,0.12778212146468534,0.11615891320235115,0.011623208262334192,10.00629907933654 +0.1,4.0,4.0,3307.028238366196,3458.002436284769,-150.97419791857283,-4.365936713473732,306772.49146475,321215.477968,-14442.986503249966,-4.4963544704059375,137.1182041122497,136.82757579763506,0.29062831461465066,0.21240478238427865,0.1128546052304944,0.11704917861668755,-0.004194573386193154,-3.5835991638433753,0.0685405649303561,0.06737596899527175,0.0011645959350843477,1.728503430007924,0.9315331673960889,0.9313276818191593,0.00020548557692967595,0.0220637248243606,0.2818749999999999,0.1,0.18187499999999987,181.87499999999986,5.079528726095333,,,,52.44772950699336,53.288869747139515,-0.841140240146153,-1.578453895039319,0.11644381911386253,0.11765277436070229,-0.0012089552468397546,-1.0275620387270383 +0.25,4.0,4.0,3134.3438215278165,3300.5539051855053,-166.21008365768876,-5.035823938416998,290691.4771835,306522.90003785,-15831.422854350007,-5.16484179563586,136.89990884669214,136.71752459667877,0.18238425001337077,0.1334022471160229,0.11113957413522965,0.1139905600539111,-0.0028509859186814507,-2.50107194607439,0.06427159998376095,0.06846858821082077,-0.004196988227059828,-6.12980103246314,0.9314501501825461,0.9313053225630614,0.0001448276194846443,0.015551035302371268,0.44833333333333336,0.25,0.19833333333333336,79.33333333333334,4.7183804755060255,,,,49.04307009982127,55.2030005738411,-6.159930474019831,-11.158687770568074,0.10998505830218755,0.11684259343269415,-0.0068575351305066035,-5.869037077182653 +0.4,4.0,4.0,2983.852437569374,3180.7872854626567,-196.9348478932825,-6.191386918369099,276545.26309355,295433.5405797,-18888.277486150037,-6.393409986248494,136.19210761854086,136.5783021470118,-0.38619452847095204,-0.2827641890402586,0.10875560547061063,0.11189234314151972,-0.0031367376709090927,-2.8033532794480807,0.07452230347799255,0.07104688223410768,0.003475421243884863,4.891729425132195,0.9307282962514367,0.9310542820602117,-0.0003259858087749645,-0.03501254599824534,0.5999999999999999,0.4000000000000001,0.1999999999999998,49.999999999999936,4.174996403604185,,,,47.99794119802058,50.794260008988424,-2.796318810967847,-5.505186630286606,0.10222958892923095,0.11161526349272373,-0.009385674563492777,-8.408952565976458 +0.6,4.0,4.0,2789.0434220430398,2982.2460998252786,-193.20267778223888,-6.4784283830083,258688.11700405,277051.95613675,-18363.8391327,-6.628301560749781,136.86774320500828,136.81931587629953,0.04842732870875466,0.035395096371142916,0.10501047827147733,0.10802266412956946,-0.0030121858580921257,-2.788475809557069,0.06914180963767007,0.06698591531512615,0.0021558943225439137,3.2184292957732996,0.9314130089130337,0.9313849217310588,2.8087181974889575e-05,0.003015636319588161,0.7733333333333334,0.5999999999999999,0.17333333333333356,28.888888888888935,4.178300996512875,,,,39.928062615509425,47.86860429278531,-7.940541677275881,-16.588203885594947,0.11297979438696983,0.1162670925925253,-0.0032872982055554695,-2.827367686122743 +0.8,4.0,4.0,2586.098242115281,2841.1305915063504,-255.03234939106915,-8.97643882169642,239765.24959855,264140.55002745,-24375.300428900024,-9.228155399224729,136.5038826686135,137.28163778418497,-0.7777551155714661,-0.5665397995864124,0.10253056902792507,0.1031498585902154,-0.0006192895622903344,-0.6003784888844036,0.07325665736408164,0.06592454978099352,0.007332107583088124,11.1219683827132,0.9311235469993302,0.9316596013994161,-0.0005360544000858614,-0.05753758124541101,1.0,0.8000000000000002,0.19999999999999984,24.99999999999998,3.5384100686094007,,,,37.14414699970415,37.43809775029793,-0.29395075059377973,-0.7851647606519765,0.09990322635678014,0.10432800196112454,-0.0044247756043444,-4.241215705437541 diff --git a/paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv b/paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv new file mode 100644 index 0000000..52cff7b --- /dev/null +++ b/paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv @@ -0,0 +1,13 @@ +alpha,mode,runs,eval_revenue_mean_mean,eval_revenue_mean_std,eval_reward_mean_mean,eval_reward_mean_std,eval_coi_level_mean_mean,eval_coi_level_mean_std,eval_coi_leakage_mean_mean,eval_coi_leakage_mean_std,eval_volatility_mean_mean,eval_volatility_mean_std,eval_margin_mean_mean,eval_margin_mean_std,train_alpha_adv_mean,train_alpha_adv_std,train_coi_penalty_mean,train_coi_penalty_std,train_ux_penalty_mean,train_ux_penalty_std,train_agent_prob_mean,train_agent_prob_std +0.0,no_robust,4,3565.2912010160844,52.219179508209216,331300.229069,5038.96659004527,137.28764358955686,0.6434240315013728,0.11861133504329742,0.004019332768284657,0.06445662162531288,0.004080405219050139,0.9317078361627993,0.00038018051704976865,,,,,66.58479574844135,32.282270089830455,0.11615891320235115,0.016558627227281013 +0.0,robust,4,3379.9042994670963,54.727408939657735,313527.4707462,5408.058196552377,137.08358925982625,1.047386315387148,0.1146626165658294,0.0025627354157035497,0.06687153537785637,0.008577061675868377,0.9315273502623671,0.0007274203134899985,0.18958333333333333,0.02083333333333336,5.553200113221484,0.45981481828856186,61.35134238638615,30.27964905193963,0.12778212146468534,0.027929667978205217 +0.1,no_robust,4,3458.002436284769,60.75923217871363,321215.477968,6016.373193216596,136.82757579763506,1.1899102161551907,0.11704917861668755,0.0021220259908233973,0.06737596899527175,0.006801136773079149,0.9313276818191593,0.0008352263172197586,0.1,0.0,,,53.288869747139515,18.480340945815023,0.11765277436070229,0.017544197575138736 +0.1,robust,4,3307.028238366196,35.58495715224888,306772.49146475,3488.2690530060245,137.1182041122497,0.8582218376452346,0.1128546052304944,0.0005963155492967403,0.0685405649303561,0.0050673362512629015,0.9315331673960889,0.0005217376436765336,0.2818749999999999,0.03624999999999999,5.079528726095333,0.6109585102054891,52.44772950699336,29.0263361696475,0.11644381911386253,0.021152545180088765 +0.25,no_robust,4,3300.5539051855053,50.460978662647115,306522.90003785,4860.668937531515,136.71752459667877,0.7410676951244369,0.1139905600539111,0.003319948537321803,0.06846858821082077,0.008614994548315848,0.9313053225630614,0.0004919872662680591,0.25,0.0,,,55.2030005738411,26.88247558235345,0.11684259343269415,0.013462146346772591 +0.25,robust,4,3134.3438215278165,64.06834403659167,290691.4771835,6331.196493752059,136.89990884669214,1.3796663751798552,0.11113957413522965,0.0015044942041406348,0.06427159998376095,0.0042331619171274894,0.9314501501825461,0.0008939739741734515,0.44833333333333336,0.0033333333333333518,4.7183804755060255,0.4538389380858333,49.04307009982127,28.20484665432831,0.10998505830218755,0.010731404693185651 +0.4,no_robust,4,3180.7872854626567,71.87564776824694,295433.5405797,7035.374110540269,136.5783021470118,1.7095219574599192,0.11189234314151972,0.0013821115134030936,0.07104688223410768,0.005766138692685495,0.9310542820602117,0.0013989725050689828,0.4000000000000001,0.0,,,50.794260008988424,24.836708377642946,0.11161526349272373,0.005787749200301594 +0.4,robust,4,2983.852437569374,45.51290575912758,276545.26309355,4555.1725323898245,136.19210761854086,1.5546063667946701,0.10875560547061063,0.001118798290958954,0.07452230347799255,0.0040446395928049874,0.9307282962514367,0.0013558080014763189,0.5999999999999999,0.0,4.174996403604185,0.12189448324552496,47.99794119802058,33.51782503281748,0.10222958892923095,0.0031686467591609474 +0.6,no_robust,4,2982.2460998252786,39.93674476199945,277051.95613675,3931.02017169463,136.81931587629953,1.1995405806950865,0.10802266412956946,0.000405835985606262,0.06698591531512615,0.002805894772223563,0.9313849217310588,0.0008100530228792662,0.5999999999999999,0.0,,,47.86860429278531,23.830502772642472,0.1162670925925253,0.028676813474186293 +0.6,robust,4,2789.0434220430398,35.297482315631626,258688.11700405,3420.6735023624556,136.86774320500828,0.7097303238857778,0.10501047827147733,0.0008273121554488608,0.06914180963767007,0.009066158371268139,0.9314130089130337,0.0005024421703994162,0.7733333333333334,0.053333333333333385,4.178300996512875,0.5865970573865015,39.928062615509425,30.25078643153115,0.11297979438696983,0.0274101056520461 +0.8,no_robust,4,2841.1305915063504,21.84043179776092,264140.55002745,2073.353315114627,137.28163778418497,0.6288968799501957,0.1031498585902154,0.0012877581835795701,0.06592454978099352,0.00340700896766341,0.9316596013994161,0.00038430108058413553,0.8000000000000002,0.0,,,37.43809775029793,32.01740090550489,0.10432800196112454,0.018337841526911584 +0.8,robust,4,2586.098242115281,48.05539265296157,239765.24959855,4681.6472175597555,136.5038826686135,1.0611320896043694,0.10253056902792507,0.002587472569909977,0.07325665736408164,0.0015359324114246234,0.9311235469993302,0.0006145440308596868,1.0,0.0,3.5384100686094007,0.391972726035734,37.14414699970415,25.614063825315505,0.09990322635678014,0.010269342031085898 diff --git a/paper/src/chapters/figures/results/generated/ppo_headline_summary.json b/paper/src/chapters/figures/results/generated/ppo_headline_summary.json new file mode 100644 index 0000000..5b106f2 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/ppo_headline_summary.json @@ -0,0 +1,7 @@ +{ + "status": "ok", + "revenue_delta": -191.29017636530716, + "revenue_delta_pct": -5.938226273545598, + "coi_leakage_delta": -0.002960415145605702, + "coi_leakage_delta_pct": -2.6404147469510946 +} \ No newline at end of file diff --git a/paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv b/paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv new file mode 100644 index 0000000..c45b856 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv @@ -0,0 +1,3 @@ +mode,runs,eval_revenue_mean_mean,eval_revenue_mean_std,eval_reward_mean_mean,eval_reward_mean_std,eval_coi_level_mean_mean,eval_coi_level_mean_std,eval_coi_leakage_mean_mean,eval_coi_leakage_mean_std,eval_volatility_mean_mean,eval_volatility_mean_std,eval_margin_mean_mean,eval_margin_mean_std,train_alpha_adv_mean,train_alpha_adv_std,train_coi_penalty_mean,train_coi_penalty_std,train_ux_penalty_mean,train_ux_penalty_std,train_agent_prob_mean,train_agent_prob_std +no_robust,24,3221.335253213441,262.46595166337727,299277.442303125,24382.561944761477,136.9186666318945,1.0038463876967063,0.11211932326253345,0.005805494533542669,0.06737642102693879,0.005402738047823369,0.9314066076226178,0.0007436370959663933,0.43,0.2546411303445653,,,51.86293802024894,25.340287421525442,0.11381077317368686,0.016664235359362907 +robust,24,3030.0450768481337,288.262657026656,280998.34484843333,26820.020161880373,136.77757261848845,1.06224696086916,0.10915890811692774,0.004616462637659704,0.06943407846195294,0.006435789449278624,0.9312959200008004,0.0007858424519830652,0.5488541666666666,0.2860373751485706,4.540469463924883,0.7906156355346259,47.985382134405825,27.407657819442747,0.11155393475895271,0.01943348418653492 diff --git a/paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv b/paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv new file mode 100644 index 0000000..856cc8b --- /dev/null +++ b/paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv @@ -0,0 +1,25 @@ +alpha,metric,direction,wins,ties,total_pairs,win_probability +0.0,eval/revenue_mean,higher,0,0,16,0.0 +0.0,eval/reward_mean,higher,0,0,16,0.0 +0.0,eval/coi_leakage_mean,lower,14,0,16,0.875 +0.0,eval/volatility_mean,lower,8,0,16,0.5 +0.1,eval/revenue_mean,higher,0,0,16,0.0 +0.1,eval/reward_mean,higher,0,0,16,0.0 +0.1,eval/coi_leakage_mean,lower,16,0,16,1.0 +0.1,eval/volatility_mean,lower,8,0,16,0.5 +0.25,eval/revenue_mean,higher,0,0,16,0.0 +0.25,eval/reward_mean,higher,0,0,16,0.0 +0.25,eval/coi_leakage_mean,lower,12,0,16,0.75 +0.25,eval/volatility_mean,lower,11,0,16,0.6875 +0.4,eval/revenue_mean,higher,0,0,16,0.0 +0.4,eval/reward_mean,higher,0,0,16,0.0 +0.4,eval/coi_leakage_mean,lower,16,0,16,1.0 +0.4,eval/volatility_mean,lower,6,0,16,0.375 +0.6,eval/revenue_mean,higher,0,0,16,0.0 +0.6,eval/reward_mean,higher,0,0,16,0.0 +0.6,eval/coi_leakage_mean,lower,16,0,16,1.0 +0.6,eval/volatility_mean,lower,7,0,16,0.4375 +0.8,eval/revenue_mean,higher,0,0,16,0.0 +0.8,eval/reward_mean,higher,0,0,16,0.0 +0.8,eval/coi_leakage_mean,lower,11,0,16,0.6875 +0.8,eval/volatility_mean,lower,0,0,16,0.0 diff --git a/paper/src/chapters/figures/results/plot_results.py b/paper/src/chapters/figures/results/plot_results.py new file mode 100644 index 0000000..0b80926 --- /dev/null +++ b/paper/src/chapters/figures/results/plot_results.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.ticker import FuncFormatter +import numpy as np +import pandas as pd + +from process_first_sweep import run as run_first_sweep +from process_ppo_benchmark import run as run_ppo_benchmark + + +def _output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" + + +def _plot_dir() -> Path: + return _output_dir() / "plots" + + +def _configure_style() -> None: + plt.rcParams.update( + { + "font.family": "serif", + "font.size": 10, + "axes.titlesize": 10, + "axes.labelsize": 9, + "legend.fontsize": 8, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "figure.dpi": 220, + "savefig.dpi": 320, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.alpha": 0.22, + } + ) + + +def _fmt_thousands(value: float, _: int) -> str: + return f"{int(value):,}" + + +def _load_csv(path: Path) -> pd.DataFrame: + if not path.exists(): + raise FileNotFoundError(f"Missing required input: {path}") + return pd.read_csv(path) + + +def _plot_ppo_alpha_curves(alpha_mode: pd.DataFrame, out_dir: Path) -> Path: + fig, axes = plt.subplots(2, 2, figsize=(9.3, 6.4), constrained_layout=True) + robust_color = "#C44E52" + baseline_color = "#4C72B0" + mode_colors = {"robust": robust_color, "no_robust": baseline_color} + mode_labels = {"robust": "Robust", "no_robust": "Non-robust"} + + panels = [ + ("eval_revenue_mean", "Mean Episode Revenue", "Revenue"), + ("eval_reward_mean", "Mean Episode Reward", "Reward"), + ("eval_coi_leakage_mean", "Mean COI Leakage", "COI Leakage"), + ("eval_volatility_mean", "Mean Price Volatility", "Volatility"), + ] + + for ax, (metric_prefix, title, ylabel) in zip(axes.flat, panels): + mean_col = f"{metric_prefix}_mean" + std_col = f"{metric_prefix}_std" + for mode in ("no_robust", "robust"): + sub = alpha_mode[alpha_mode["mode"] == mode].sort_values("alpha") + if sub.empty: + continue + x = sub["alpha"].to_numpy(dtype=float) + y = sub[mean_col].to_numpy(dtype=float) + ax.plot( + x, + y, + marker="o", + linewidth=1.8, + markersize=4, + color=mode_colors[mode], + label=mode_labels[mode], + ) + if std_col in sub.columns: + sigma = sub[std_col].fillna(0.0).to_numpy(dtype=float) + ax.fill_between( + x, + y - sigma, + y + sigma, + color=mode_colors[mode], + alpha=0.14, + linewidth=0, + ) + + ax.set_title(title) + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel(ylabel) + ax.set_xticks(sorted(alpha_mode["alpha"].unique())) + if metric_prefix in {"eval_revenue_mean", "eval_reward_mean"}: + ax.yaxis.set_major_formatter(FuncFormatter(_fmt_thousands)) + + handles, labels = axes.flat[0].get_legend_handles_labels() + fig.legend(handles, labels, ncol=2, loc="upper center", bbox_to_anchor=(0.5, 1.02)) + + out_path = out_dir / "ppo_alpha_curves.pdf" + fig.savefig(out_path, bbox_inches="tight") + plt.close(fig) + return out_path + + +def _plot_ppo_delta_curves(deltas: pd.DataFrame, out_dir: Path) -> Path: + fig, axes = plt.subplots(2, 1, figsize=(8.6, 6.0), constrained_layout=True) + deltas = deltas.sort_values("alpha") + x = deltas["alpha"].to_numpy(dtype=float) + + top_metrics = [ + ("eval_revenue_mean_delta_pct", "Revenue", "#4C72B0"), + ("eval_reward_mean_delta_pct", "Reward", "#8172B3"), + ] + for col, label, color in top_metrics: + axes[0].plot( + x, + deltas[col].to_numpy(dtype=float), + marker="o", + linewidth=1.8, + markersize=4, + color=color, + label=label, + ) + axes[0].axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + axes[0].set_title("Robust Minus Non-robust Delta by Contamination") + axes[0].set_ylabel("Delta (%)") + axes[0].set_xlabel(r"Contamination $\alpha$") + axes[0].set_xticks(x) + axes[0].legend(loc="lower left") + + bottom_metrics = [ + ("eval_coi_leakage_mean_delta_pct", "COI Leakage", "#55A868"), + ("eval_volatility_mean_delta_pct", "Volatility", "#DD8452"), + ] + for col, label, color in bottom_metrics: + axes[1].plot( + x, + deltas[col].to_numpy(dtype=float), + marker="o", + linewidth=1.8, + markersize=4, + color=color, + label=label, + ) + axes[1].axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + axes[1].set_ylabel("Delta (%)") + axes[1].set_xlabel(r"Contamination $\alpha$") + axes[1].set_xticks(x) + axes[1].legend(loc="lower left") + + out_path = out_dir / "ppo_delta_curves.pdf" + fig.savefig(out_path, bbox_inches="tight") + plt.close(fig) + return out_path + + +def _plot_ppo_tradeoff_scatter(deltas: pd.DataFrame, out_dir: Path) -> Path: + fig, ax = plt.subplots(figsize=(6.4, 5.2), constrained_layout=True) + data = deltas.sort_values("alpha") + x = data["eval_coi_leakage_mean_delta_pct"].to_numpy(dtype=float) + y = data["eval_revenue_mean_delta_pct"].to_numpy(dtype=float) + alphas = data["alpha"].to_numpy(dtype=float) + + scatter = ax.scatter( + x, + y, + c=alphas, + cmap="viridis", + s=72, + edgecolor="#222222", + linewidth=0.5, + ) + for x_i, y_i, alpha in zip(x, y, alphas): + ax.annotate( + rf"$\alpha={alpha:.2f}$", + (x_i, y_i), + textcoords="offset points", + xytext=(5, 4), + fontsize=8, + ) + + ax.axhline(0.0, color="#555555", linewidth=1.0, linestyle="--") + ax.axvline(0.0, color="#555555", linewidth=1.0, linestyle="--") + ax.set_xlabel("COI Leakage Delta (%)") + ax.set_ylabel("Revenue Delta (%)") + ax.set_title("PPO Robust Tradeoff Frontier") + cbar = fig.colorbar(scatter, ax=ax) + cbar.set_label(r"Contamination $\alpha$") + + out_path = out_dir / "ppo_tradeoff_scatter.pdf" + fig.savefig(out_path, bbox_inches="tight") + plt.close(fig) + return out_path + + +def _plot_first_sweep_tier_revenue(tier_mode: pd.DataFrame, out_dir: Path) -> Path: + pivot = ( + tier_mode.pivot(index="tier", columns="mode", values="eval_revenue_mean_mean") + .dropna(subset=["robust", "no_robust"], how="any") + .copy() + ) + if pivot.empty: + raise ValueError("First sweep tier summary missing robust/non-robust pairs") + + order = sorted(pivot.index.tolist()) + pivot = pivot.loc[order] + delta_pct = 100.0 * (pivot["robust"] - pivot["no_robust"]) / pivot["no_robust"] + + fig, axes = plt.subplots(1, 2, figsize=(10.2, 4.3), constrained_layout=True) + x = np.arange(len(order)) + width = 0.36 + + axes[0].bar( + x - width / 2, + pivot["no_robust"].to_numpy(dtype=float), + width=width, + label="Non-robust", + color="#4C72B0", + ) + axes[0].bar( + x + width / 2, + pivot["robust"].to_numpy(dtype=float), + width=width, + label="Robust", + color="#C44E52", + ) + axes[0].set_xticks(x) + axes[0].set_xticklabels(order, rotation=20) + axes[0].set_ylabel("Mean Revenue") + axes[0].set_yscale("log") + axes[0].yaxis.set_major_formatter(FuncFormatter(_fmt_thousands)) + axes[0].set_title("First Sweep Tier Revenue (log scale)") + axes[0].legend() + + axes[1].bar(x, delta_pct.to_numpy(dtype=float), color="#55A868", width=0.55) + axes[1].axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + axes[1].set_xticks(x) + axes[1].set_xticklabels(order, rotation=20) + axes[1].set_ylabel("Revenue Delta (%)") + axes[1].set_title("Robust Minus Non-robust by Tier") + + out_path = out_dir / "first_sweep_tier_revenue.pdf" + fig.savefig(out_path, bbox_inches="tight") + plt.close(fig) + return out_path + + +def build_plots(data_dir: Path, out_dir: Path) -> list[Path]: + alpha_mode = _load_csv(data_dir / "ppo_alpha_mode_summary.csv") + deltas = _load_csv(data_dir / "ppo_alpha_deltas.csv") + tier_mode = _load_csv(data_dir / "first_sweep_tier_mode_summary.csv") + + out_dir.mkdir(parents=True, exist_ok=True) + paths = [ + _plot_ppo_alpha_curves(alpha_mode, out_dir), + _plot_ppo_delta_curves(deltas, out_dir), + _plot_ppo_tradeoff_scatter(deltas, out_dir), + _plot_first_sweep_tier_revenue(tier_mode, out_dir), + ] + return paths + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Create paper-ready plots from result CSVs" + ) + parser.add_argument("--data-dir", type=Path, default=_output_dir()) + parser.add_argument("--plot-dir", type=Path, default=_plot_dir()) + parser.add_argument( + "--refresh-data", + action="store_true", + help="Regenerate processed CSVs before plotting", + ) + args = parser.parse_args() + + _configure_style() + + if bool(args.refresh_data): + run_ppo_benchmark( + input_path=Path(__file__).resolve().parents[5] + / "tpu_orchestration" + / "results" + / "ppo_benchmark.csv", + output_dir=args.data_dir, + include_non_finished=False, + ) + run_first_sweep( + input_path=Path(__file__).resolve().parents[5] + / "tpu_orchestration" + / "results" + / "first_sweep.csv", + output_dir=args.data_dir, + include_non_finished=False, + top_n=25, + ) + + outputs = build_plots(data_dir=args.data_dir, out_dir=args.plot_dir) + for path in outputs: + print(path) + + +if __name__ == "__main__": + main() diff --git a/paper/src/chapters/figures/results/ppo_alpha_curves.tex b/paper/src/chapters/figures/results/ppo_alpha_curves.tex new file mode 100644 index 0000000..2496584 --- /dev/null +++ b/paper/src/chapters/figures/results/ppo_alpha_curves.tex @@ -0,0 +1 @@ +\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/plots/ppo_alpha_curves.pdf} diff --git a/paper/src/chapters/figures/results/ppo_delta_curves.tex b/paper/src/chapters/figures/results/ppo_delta_curves.tex new file mode 100644 index 0000000..8c4eec0 --- /dev/null +++ b/paper/src/chapters/figures/results/ppo_delta_curves.tex @@ -0,0 +1 @@ +\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/plots/ppo_delta_curves.pdf} diff --git a/paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex b/paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex new file mode 100644 index 0000000..0117970 --- /dev/null +++ b/paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex @@ -0,0 +1 @@ +\includegraphics[width=0.88\linewidth]{chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf} diff --git a/paper/src/chapters/figures/results/process_all_results.py b/paper/src/chapters/figures/results/process_all_results.py new file mode 100644 index 0000000..2dc2a4d --- /dev/null +++ b/paper/src/chapters/figures/results/process_all_results.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from process_first_sweep import run as run_first_sweep +from process_ppo_benchmark import run as run_ppo_benchmark + + +def _default_output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Process all result CSV exports for paper figures" + ) + parser.add_argument("--output-dir", type=Path, default=_default_output_dir()) + parser.add_argument("--include-non-finished", action="store_true") + parser.add_argument("--top-n", type=int, default=25) + args = parser.parse_args() + + written: list[Path] = [] + written.extend( + run_ppo_benchmark( + input_path=Path(__file__).resolve().parents[5] + / "tpu_orchestration" + / "results" + / "ppo_benchmark.csv", + output_dir=args.output_dir, + include_non_finished=bool(args.include_non_finished), + ) + ) + written.extend( + run_first_sweep( + input_path=Path(__file__).resolve().parents[5] + / "tpu_orchestration" + / "results" + / "first_sweep.csv", + output_dir=args.output_dir, + include_non_finished=bool(args.include_non_finished), + top_n=int(args.top_n), + ) + ) + + for path in written: + print(path) + + +if __name__ == "__main__": + main() diff --git a/paper/src/chapters/figures/results/process_first_sweep.py b/paper/src/chapters/figures/results/process_first_sweep.py new file mode 100644 index 0000000..fd650b1 --- /dev/null +++ b/paper/src/chapters/figures/results/process_first_sweep.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Iterable + +import numpy as np +import pandas as pd + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[5] + + +def _default_input() -> Path: + return _project_root() / "tpu_orchestration" / "results" / "first_sweep.csv" + + +def _default_output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" + + +def _sanitize(key: str) -> str: + return key.replace("/", "_").replace("-", "_") + + +def _coerce_numeric(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame.columns: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + + +def _extract_alpha(frame: pd.DataFrame) -> pd.Series: + if "study/alpha" in frame.columns: + return pd.to_numeric(frame["study/alpha"], errors="coerce") + if "alpha" in frame.columns: + return pd.to_numeric(frame["alpha"], errors="coerce") + return pd.Series(np.nan, index=frame.index, dtype=float) + + +def _extract_mode(frame: pd.DataFrame) -> pd.Series: + if "study/mode" in frame.columns: + return frame["study/mode"].astype(str).str.strip().str.lower() + if "study/no_robust" in frame.columns: + no_robust = pd.to_numeric(frame["study/no_robust"], errors="coerce").fillna(0.0) + return pd.Series( + np.where(no_robust > 0.5, "no_robust", "robust"), + index=frame.index, + dtype="object", + ) + if "no_robust" in frame.columns: + no_robust = ( + frame["no_robust"].astype(str).str.lower().isin({"1", "true", "yes"}) + ) + return pd.Series( + np.where(no_robust, "no_robust", "robust"), + index=frame.index, + dtype="object", + ) + return pd.Series("", index=frame.index, dtype="object") + + +def _extract_tier(frame: pd.DataFrame) -> pd.Series: + for column in ("tiers", "runtime/backend", "algo", "run.backend", "run.algo"): + if column in frame.columns: + tier = frame[column].astype(str).str.strip().str.lower() + if tier.notna().any(): + return tier + return pd.Series("unknown", index=frame.index, dtype="object") + + +def _prepare_frame(frame: pd.DataFrame, include_non_finished: bool) -> pd.DataFrame: + data = frame.copy() + if not include_non_finished and "State" in data.columns: + data = data[data["State"].astype(str).str.lower() == "finished"].copy() + + data["alpha"] = _extract_alpha(data) + data["mode"] = _extract_mode(data) + data["tier"] = _extract_tier(data) + data = data[data["mode"].isin({"robust", "no_robust"})] + data = data[data["alpha"].notna()] + + _coerce_numeric( + data, + [ + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/margin_mean", + "eval/volatility_mean", + "objective/score", + "train/alpha_adv", + "lambda_coi", + "robust_radius", + "learning_rate", + "batch_size", + "n_steps", + "total_timesteps", + ], + ) + return data.sort_values(["tier", "alpha", "mode"]).reset_index(drop=True) + + +def _group_summary( + frame: pd.DataFrame, by: list[str], metrics: list[str] +) -> pd.DataFrame: + agg_spec: dict[str, tuple[str, str]] = {"runs": ("mode", "size")} + for metric in metrics: + safe = _sanitize(metric) + agg_spec[f"{safe}_mean"] = (metric, "mean") + agg_spec[f"{safe}_std"] = (metric, "std") + return frame.groupby(by, as_index=False).agg(**agg_spec).sort_values(by) + + +def _tier_alpha_deltas(summary: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + rows: list[dict[str, float | str]] = [] + for (tier, alpha), group in summary.groupby(["tier", "alpha"], sort=True): + robust = group[group["mode"] == "robust"] + no_robust = group[group["mode"] == "no_robust"] + if robust.empty or no_robust.empty: + continue + + row: dict[str, float | str] = { + "tier": str(tier), + "alpha": float(alpha), + "runs_robust": float(robust["runs"].iloc[0]), + "runs_no_robust": float(no_robust["runs"].iloc[0]), + } + for metric in metrics: + safe = _sanitize(metric) + robust_value = float(robust[f"{safe}_mean"].iloc[0]) + no_robust_value = float(no_robust[f"{safe}_mean"].iloc[0]) + delta = robust_value - no_robust_value + row[f"{safe}_delta"] = delta + row[f"{safe}_delta_pct"] = ( + np.nan if no_robust_value == 0 else 100.0 * delta / no_robust_value + ) + rows.append(row) + + return pd.DataFrame(rows) + + +def _top_runs(frame: pd.DataFrame, n: int) -> pd.DataFrame: + rank_metric = "objective/score" + if rank_metric not in frame.columns or frame[rank_metric].notna().sum() == 0: + rank_metric = "eval/reward_mean" + + keep = [ + "Name", + "tier", + "alpha", + "mode", + rank_metric, + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "lambda_coi", + "robust_radius", + "learning_rate", + "batch_size", + "n_steps", + "total_timesteps", + ] + present = [column for column in keep if column in frame.columns] + ranked = frame[present].copy().sort_values(rank_metric, ascending=False) + return ranked.head(max(1, int(n))).reset_index(drop=True) + + +def _headline_json( + frame: pd.DataFrame, tier_mode: pd.DataFrame +) -> dict[str, float | str]: + out: dict[str, float | str] = { + "runs": int(len(frame)), + "tiers": int(frame["tier"].nunique()), + "alphas": int(frame["alpha"].nunique()), + } + + robust_rows = tier_mode[tier_mode["mode"] == "robust"] + no_robust_rows = tier_mode[tier_mode["mode"] == "no_robust"] + if robust_rows.empty or no_robust_rows.empty: + out["status"] = "incomplete_modes" + return out + + robust_mean = robust_rows["eval_revenue_mean_mean"].mean() + no_robust_mean = no_robust_rows["eval_revenue_mean_mean"].mean() + out.update( + { + "status": "ok", + "mean_tier_revenue_robust": float(robust_mean), + "mean_tier_revenue_no_robust": float(no_robust_mean), + "mean_tier_revenue_delta": float(robust_mean - no_robust_mean), + "mean_tier_revenue_delta_pct": float( + 100.0 * (robust_mean - no_robust_mean) / no_robust_mean + ) + if no_robust_mean + else np.nan, + } + ) + return out + + +def run( + input_path: Path, output_dir: Path, include_non_finished: bool, top_n: int +) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + raw = pd.read_csv(input_path) + frame = _prepare_frame(raw, include_non_finished=include_non_finished) + + metrics = [ + metric + for metric in ( + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/margin_mean", + "eval/volatility_mean", + "objective/score", + "train/alpha_adv", + ) + if metric in frame.columns + ] + + tier_mode = _group_summary(frame, ["tier", "mode"], metrics) + tier_alpha_mode = _group_summary(frame, ["tier", "alpha", "mode"], metrics) + deltas = _tier_alpha_deltas(tier_alpha_mode, metrics) + top_configs = _top_runs(frame, n=top_n) + headline = _headline_json(frame, tier_mode) + + outputs = { + "first_sweep_tier_mode_summary.csv": tier_mode, + "first_sweep_tier_alpha_mode_summary.csv": tier_alpha_mode, + "first_sweep_tier_alpha_deltas.csv": deltas, + "first_sweep_top_configs.csv": top_configs, + } + written_paths: list[Path] = [] + for filename, table in outputs.items(): + path = output_dir / filename + table.to_csv(path, index=False) + written_paths.append(path) + + headline_path = output_dir / "first_sweep_headline_summary.json" + headline_path.write_text(json.dumps(headline, indent=2)) + written_paths.append(headline_path) + return written_paths + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Process first sweep CSV for paper tables" + ) + parser.add_argument("--input", type=Path, default=_default_input()) + parser.add_argument("--output-dir", type=Path, default=_default_output_dir()) + parser.add_argument("--include-non-finished", action="store_true") + parser.add_argument("--top-n", type=int, default=25) + args = parser.parse_args() + + written = run( + input_path=args.input, + output_dir=args.output_dir, + include_non_finished=bool(args.include_non_finished), + top_n=int(args.top_n), + ) + for path in written: + print(path) + + +if __name__ == "__main__": + main() diff --git a/paper/src/chapters/figures/results/process_ppo_benchmark.py b/paper/src/chapters/figures/results/process_ppo_benchmark.py new file mode 100644 index 0000000..dbced6a --- /dev/null +++ b/paper/src/chapters/figures/results/process_ppo_benchmark.py @@ -0,0 +1,277 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Iterable + +import numpy as np +import pandas as pd + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[5] + + +def _default_input() -> Path: + return _project_root() / "tpu_orchestration" / "results" / "ppo_benchmark.csv" + + +def _default_output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" + + +def _sanitize(key: str) -> str: + return key.replace("/", "_").replace("-", "_") + + +def _coerce_numeric(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame.columns: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + + +def _extract_alpha(frame: pd.DataFrame) -> pd.Series: + if "study/alpha" in frame.columns: + return pd.to_numeric(frame["study/alpha"], errors="coerce") + if "alpha" in frame.columns: + return pd.to_numeric(frame["alpha"], errors="coerce") + return pd.Series(np.nan, index=frame.index, dtype=float) + + +def _extract_mode(frame: pd.DataFrame) -> pd.Series: + if "study/mode" in frame.columns: + return frame["study/mode"].astype(str).str.strip().str.lower() + if "study/no_robust" in frame.columns: + no_robust = pd.to_numeric(frame["study/no_robust"], errors="coerce").fillna(0.0) + return pd.Series( + np.where(no_robust > 0.5, "no_robust", "robust"), + index=frame.index, + dtype="object", + ) + if "no_robust" in frame.columns: + no_robust = ( + frame["no_robust"].astype(str).str.lower().isin({"1", "true", "yes"}) + ) + return pd.Series( + np.where(no_robust, "no_robust", "robust"), + index=frame.index, + dtype="object", + ) + return pd.Series("", index=frame.index, dtype="object") + + +def _prepare_frame(frame: pd.DataFrame, include_non_finished: bool) -> pd.DataFrame: + data = frame.copy() + if not include_non_finished and "State" in data.columns: + data = data[data["State"].astype(str).str.lower() == "finished"].copy() + + data["alpha"] = _extract_alpha(data) + data["mode"] = _extract_mode(data) + data = data[data["mode"].isin({"robust", "no_robust"})] + data = data[data["alpha"].notna()] + + numeric_cols = [ + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/volatility_mean", + "eval/margin_mean", + "train/alpha_adv", + "train/coi_penalty", + "train/ux_penalty", + "train/agent_prob", + ] + _coerce_numeric(data, numeric_cols) + return data.sort_values(["alpha", "mode"]).reset_index(drop=True) + + +def _summary_by_alpha_mode(frame: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + agg_spec: dict[str, tuple[str, str]] = {"runs": ("mode", "size")} + for metric in metrics: + safe = _sanitize(metric) + agg_spec[f"{safe}_mean"] = (metric, "mean") + agg_spec[f"{safe}_std"] = (metric, "std") + + return ( + frame.groupby(["alpha", "mode"], as_index=False) + .agg(**agg_spec) + .sort_values(["alpha", "mode"]) + .reset_index(drop=True) + ) + + +def _delta_by_alpha(summary: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + rows: list[dict[str, float]] = [] + for alpha, alpha_group in summary.groupby("alpha", sort=True): + robust = alpha_group[alpha_group["mode"] == "robust"] + no_robust = alpha_group[alpha_group["mode"] == "no_robust"] + if robust.empty or no_robust.empty: + continue + + row: dict[str, float] = { + "alpha": float(alpha), + "runs_robust": float(robust["runs"].iloc[0]), + "runs_no_robust": float(no_robust["runs"].iloc[0]), + } + for metric in metrics: + safe = _sanitize(metric) + robust_value = float(robust[f"{safe}_mean"].iloc[0]) + no_robust_value = float(no_robust[f"{safe}_mean"].iloc[0]) + delta = robust_value - no_robust_value + row[f"{safe}_robust"] = robust_value + row[f"{safe}_no_robust"] = no_robust_value + row[f"{safe}_delta"] = delta + row[f"{safe}_delta_pct"] = ( + np.nan if no_robust_value == 0 else 100.0 * delta / no_robust_value + ) + rows.append(row) + + return pd.DataFrame(rows) + + +def _pairwise_win_rates(frame: pd.DataFrame) -> pd.DataFrame: + rules = { + "eval/revenue_mean": "higher", + "eval/reward_mean": "higher", + "eval/coi_leakage_mean": "lower", + "eval/volatility_mean": "lower", + } + rows: list[dict[str, float]] = [] + for alpha, alpha_group in frame.groupby("alpha", sort=True): + robust = alpha_group[alpha_group["mode"] == "robust"] + no_robust = alpha_group[alpha_group["mode"] == "no_robust"] + if robust.empty or no_robust.empty: + continue + + for metric, direction in rules.items(): + if metric not in frame.columns: + continue + robust_values = robust[metric].dropna().to_numpy(dtype=float) + no_robust_values = no_robust[metric].dropna().to_numpy(dtype=float) + if robust_values.size == 0 or no_robust_values.size == 0: + continue + + if direction == "higher": + wins = (robust_values[:, None] > no_robust_values[None, :]).sum() + else: + wins = (robust_values[:, None] < no_robust_values[None, :]).sum() + ties = (robust_values[:, None] == no_robust_values[None, :]).sum() + total = robust_values.size * no_robust_values.size + win_prob = (wins + 0.5 * ties) / total + rows.append( + { + "alpha": float(alpha), + "metric": metric, + "direction": direction, + "wins": int(wins), + "ties": int(ties), + "total_pairs": int(total), + "win_probability": float(win_prob), + } + ) + return pd.DataFrame(rows) + + +def _overall_mode_summary(frame: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + agg_spec: dict[str, tuple[str, str]] = {"runs": ("mode", "size")} + for metric in metrics: + safe = _sanitize(metric) + agg_spec[f"{safe}_mean"] = (metric, "mean") + agg_spec[f"{safe}_std"] = (metric, "std") + return frame.groupby("mode", as_index=False).agg(**agg_spec).sort_values("mode") + + +def _headline_json(overall: pd.DataFrame) -> dict[str, float | str]: + if {"robust", "no_robust"} - set(overall["mode"].tolist()): + return {"status": "incomplete_modes"} + + robust = overall[overall["mode"] == "robust"].iloc[0] + no_robust = overall[overall["mode"] == "no_robust"].iloc[0] + + revenue_delta = float( + robust["eval_revenue_mean_mean"] - no_robust["eval_revenue_mean_mean"] + ) + leakage_delta = float( + robust["eval_coi_leakage_mean_mean"] - no_robust["eval_coi_leakage_mean_mean"] + ) + return { + "status": "ok", + "revenue_delta": revenue_delta, + "revenue_delta_pct": float( + 100.0 * revenue_delta / no_robust["eval_revenue_mean_mean"] + ), + "coi_leakage_delta": leakage_delta, + "coi_leakage_delta_pct": float( + 100.0 * leakage_delta / no_robust["eval_coi_leakage_mean_mean"] + ), + } + + +def run(input_path: Path, output_dir: Path, include_non_finished: bool) -> list[Path]: + output_dir.mkdir(parents=True, exist_ok=True) + raw = pd.read_csv(input_path) + frame = _prepare_frame(raw, include_non_finished=include_non_finished) + + metrics = [ + metric + for metric in ( + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/volatility_mean", + "eval/margin_mean", + "train/alpha_adv", + "train/coi_penalty", + "train/ux_penalty", + "train/agent_prob", + ) + if metric in frame.columns + ] + + alpha_mode = _summary_by_alpha_mode(frame, metrics) + deltas = _delta_by_alpha(alpha_mode, metrics) + win_rates = _pairwise_win_rates(frame) + overall = _overall_mode_summary(frame, metrics) + headline = _headline_json(overall) + + outputs = { + "ppo_alpha_mode_summary.csv": alpha_mode, + "ppo_alpha_deltas.csv": deltas, + "ppo_pairwise_win_rates.csv": win_rates, + "ppo_overall_mode_summary.csv": overall, + } + written_paths: list[Path] = [] + for filename, table in outputs.items(): + path = output_dir / filename + table.to_csv(path, index=False) + written_paths.append(path) + + headline_path = output_dir / "ppo_headline_summary.json" + headline_path.write_text(json.dumps(headline, indent=2)) + written_paths.append(headline_path) + return written_paths + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Process PPO benchmark CSV for paper tables" + ) + parser.add_argument("--input", type=Path, default=_default_input()) + parser.add_argument("--output-dir", type=Path, default=_default_output_dir()) + parser.add_argument("--include-non-finished", action="store_true") + args = parser.parse_args() + + written = run( + input_path=args.input, + output_dir=args.output_dir, + include_non_finished=bool(args.include_non_finished), + ) + for path in written: + print(path) + + +if __name__ == "__main__": + main() diff --git a/paper/src/chapters/hero_architecture_figure.tex b/paper/src/chapters/hero_architecture_figure.tex new file mode 100644 index 0000000..a706781 --- /dev/null +++ b/paper/src/chapters/hero_architecture_figure.tex @@ -0,0 +1,166 @@ +\definecolor{heroBlue}{RGB}{212, 228, 255} +\definecolor{heroBlueBorder}{RGB}{64, 103, 178} +\definecolor{heroGreen}{RGB}{214, 238, 216} +\definecolor{heroGreenBorder}{RGB}{48, 133, 66} +\definecolor{heroAmber}{RGB}{246, 230, 202} +\definecolor{heroAmberBorder}{RGB}{166, 121, 51} +\definecolor{heroGray}{RGB}{236, 236, 236} +\definecolor{heroGrayBorder}{RGB}{120, 120, 120} + +% Panels occupy y = 2.2 .. 10.0 +% Cross-panel connector gutter lives at y = 1.0 .. 2.2 (clearly below all nodes) +\begin{tikzpicture}[ + >=Stealth, + font=\small, + panel/.style={draw=black!65, dashed, rounded corners=4pt, line width=0.85pt}, + bB/.style={rectangle, rounded corners=3pt, draw=heroBlueBorder, fill=heroBlue, + line width=0.9pt, align=center, minimum height=0.85cm}, + bG/.style={rectangle, rounded corners=3pt, draw=heroGreenBorder, fill=heroGreen, + line width=0.9pt, align=center, minimum height=0.85cm}, + bA/.style={rectangle, rounded corners=3pt, draw=heroAmberBorder, fill=heroAmber, + line width=0.9pt, align=center, minimum height=0.85cm}, + bY/.style={rectangle, rounded corners=3pt, draw=heroGrayBorder, fill=heroGray, + line width=0.9pt, align=center, minimum height=0.82cm}, + pill/.style={ellipse, draw=black!50, fill=black!4, line width=0.75pt, + align=center, minimum width=1.6cm, minimum height=0.68cm}, + arr/.style={->, draw=black!80, line width=0.88pt}, + bidir/.style={<->, draw=black!80, line width=0.88pt}, + darr/.style={->, draw=black!60, line width=0.80pt, densely dashed}, + crossA/.style={->, draw=heroAmberBorder!90!black, line width=1.15pt, dash pattern=on 3.5pt off 2pt}, + crossG/.style={->, draw=heroGreenBorder!90!black, line width=1.15pt, dash pattern=on 3.5pt off 2pt}, + arrG/.style={->, draw=heroGreenBorder!90!black, line width=1.15pt}, + lbl/.style={font=\scriptsize, align=center, fill=white, inner sep=1.5pt, text=black} +] + +%% ============================================================ +%% Panel A x: 0.2–11.2 y: 2.2–10.0 +%% ============================================================ +\draw[panel] (0.2,2.2) rectangle (11.2,10.0); +\node[anchor=west, font=\small\bfseries] at (0.45,9.72) {(a) Online platform and data plane}; + +\node[pill] (human) at (1.3, 8.55) {Human}; +\node[pill] (agent) at (1.3, 7.45) {Agent}; + +\node[bB, minimum width=2.75cm] (web) at (4.2, 8.0) {Next.js\\Web App}; +\node[bB, minimum width=2.75cm] (provider) at (7.35, 8.0) {Pricing\\Provider}; +\node[bY, minimum width=1.85cm] (redis) at (9.85, 8.0) {Redis}; + +\node[bG, minimum width=3.1cm] (kBehav) at (4.0, 6.2) {Kafka stream\\Behavior events}; +\node[bG, minimum width=3.0cm] (kQuotes) at (7.5, 6.2) {Kafka stream\\Price quotes}; + +\node[bA, minimum width=3.1cm] (worker) at (4.0, 4.4) {Worker / ETL\\Feature jobs}; +\node[bA, minimum width=2.65cm] (registry) at (8.45, 4.4) {Model\\Registry}; + +% service row +\draw[arr] (human.east) -- (web.west); +\draw[arr] (agent.east) -- (web.west); +\draw[arr] (web.east) -- (provider.west); +\draw[bidir] (provider.east) -- (redis.west); + +% web/provider -> kafka +\draw[arr] (web.south) -- (kBehav.north) + node[midway, left, lbl] {$e=(a,i,t,\mu,\delta)$}; +\draw[arr] (provider.south) -- (kQuotes.north) + node[midway, right, lbl] {$(i,p,\mathrm{sid},\phi,t)$}; + +% kafka -> worker (straight south) +\draw[arr] (kBehav.south) -- (worker.north); +\draw[arr] (kQuotes.south) -- (worker.north); + +% worker -> registry +\draw[arr] (worker.east) -- (registry.west); + +% model refresh: registry east -> goes right to x=11.0, north to y=9.2, left to provider +% this keeps it entirely inside panel A with no crossing of nodes +\draw[crossA, rounded corners=6pt] + (registry.east) -- (11.0, 4.4) + -- (11.0, 9.2) + -- node[midway, lbl] {model refresh} (provider.north |- 0, 9.2) + -- (provider.north); + +%% ============================================================ +%% Panel B x: 11.6–20.4 y: 2.2–10.0 +%% ============================================================ +\draw[panel] (11.6,2.2) rectangle (19.8,10.0); +\node[anchor=west, font=\small\bfseries] at (11.85,9.72) {(b) Distinguishability layer}; + +\node[bG, minimum width=2.4cm] (session) at (14.0, 8.9) {Session prefix\\$\tau'$}; +\node[bB, minimum width=2.4cm] (empKern) at (13.65,7.45) {Empirical kernel\\$\hat T'$}; +\node[bY, minimum width=2.4cm] (weakLab) at (17.55,8.9) {Weak labels\\$\mathcal{D}_H,\mathcal{D}_A$}; +\node[bY, minimum width=2.2cm] (protoH) at (12.8, 5.9) {Prototype\\$\bar T_H$}; +\node[bA, minimum width=2.4cm] (kldist) at (15.55,5.9) {KL distances\\$\Delta_H,\Delta_A$}; +\node[bY, minimum width=2.2cm] (protoA) at (18.3, 5.9) {Prototype\\$\bar T_A$}; +\node[bB, minimum width=2.9cm] (calHead) at (13.55,4.25) {Contrastive\\calibration head}; +\node[bG, minimum width=2.55cm] (score) at (17.75,4.25) {Session score\\$f(\tau'),\hat\alpha(\tau')$}; + +\node[lbl] at (15.55, 3.15) {$\hat\alpha(\tau')=\sigma\!\left(\beta(\Delta_H-\Delta_A)\right)$}; + +\draw[arr, rounded corners=4pt] (session.south) -- (empKern.north); +\draw[arr, rounded corners=4pt] (empKern.south) -- (13.65, 6.8) -| (protoH.north); +\draw[arr, rounded corners=4pt] (weakLab.south) -- (17.55, 6.8) -| (protoA.north); +% weak labels -> protoH: go south then hard-left below weakLab +\draw[arr, rounded corners=4pt] (weakLab.south) -- (17.55,6.8) -| (protoH.north east); +\draw[arr] (protoH.east) -- (kldist.west); +\draw[arr] (protoA.west) -- (kldist.east); +\draw[arr] (kldist.south) -- (calHead.north east); +\draw[arr] (calHead.east) -- (score.west); + +%% ============================================================ +%% Panel C x: 20.8–31.0 y: 2.2–10.0 +%% ============================================================ +\draw[panel] (20.8,2.2) rectangle (31.0,10.0); +\node[anchor=west, font=\small\bfseries] at (21.05,9.72) {(c) Distributionally robust control}; + +\node[bB, minimum width=3.1cm] (state) at (23.15, 8.9) + {State summary\\$[p_{t-1},\hat q_{t-1},f(\tau')]$}; +\node[bY, minimum width=2.9cm] (ambSet) at (23.15, 7.45) {Ambiguity set\\$\mathcal U_\epsilon(\hat P_N)$}; +\node[bG, minimum width=2.9cm] (innerMin) at (28.55, 7.45) {Inner minimisation\\$\min_{Q\in\mathcal U_\epsilon}$}; +\node[bY, minimum width=8.2cm] (contScen) at (25.9, 5.9) + {Contamination scenarios $\;\alpha_k\in\mathcal A_{\epsilon_\alpha}(\alpha_0)$}; +\node[bA, minimum width=8.8cm] (reward) at (25.9, 4.45) + {$r_t = R(p_t,\hat q_t) - \lambda\,\mathrm{COI}_{\mathrm{leak}}(p_t,\tau_t') - \eta\,UX_t$}; +\node[bB, minimum width=2.85cm] (policy) at (22.75, 3.05) {Robust policy $\pi^*$}; +\node[bG, minimum width=2.85cm] (publish) at (29.05, 3.05) {Publish price\\vector $p_t$}; + +\node[lbl] at (25.9, 2.55) {$\pi^*=\arg\max_\pi\min_{Q\in\mathcal U_\epsilon}\mathbb{E}[r_t]$}; + +\draw[arr] (state.south) -- (ambSet.north); +\draw[arr] (ambSet.east) -- (innerMin.west); +\draw[arr, rounded corners=4pt] (ambSet.south) -- (23.15, 6.6) -| ([xshift=-2cm]contScen.north); +\draw[arr, rounded corners=4pt] (innerMin.south) -- (28.55, 6.6) -| ([xshift=2cm]contScen.north); +\draw[arr] (contScen.south) -- (reward.north); +\draw[arr, rounded corners=6pt] (reward.south) -- (25.9, 3.7) -| (policy.north); +\draw[arr] (policy.east) -- (publish.west); +% market response: up the right edge of panel C, entirely inside, rounded +\draw[arrG, rounded corners=6pt] (publish.east) -- (30.6, 3.05) + -- (30.6, 9.8) + -- node[midway, lbl] {market response} (state.north |- 0, 9.8) + -- (state.north); + +%% ============================================================ +%% Cross-panel connectors – gutter at y = 1.0..2.2 +%% Three separate depths: 1.85, 1.45, 1.05 (no overlaps) +%% ============================================================ + +% 1. Worker -> Session (depth y=1.85, shallowest) +\draw[crossA, rounded corners=6pt] + (worker.south) -- (worker.south |- 0, 1.85) + -- node[pos=0.5, lbl] {offline extraction} (11.4, 1.85) + -- (11.4, 8.9) + -- (session.west); + +% 2. Score -> State (depth y=1.45) +\draw[crossG, rounded corners=6pt] + (score.south) -- (score.south |- 0, 1.45) + -- node[pos=0.5, lbl] {contamination signal} (20.6, 1.45) + -- (20.6, 8.9) + -- (state.west); + +% 3. Publish -> Provider (depth y=1.05, deepest) +\draw[crossG, rounded corners=3pt] + (publish.south) -- (publish.south |- 0, 1.05) + -- node[pos=0.4, lbl] {serve online} (5.8, 1.05) + -- (5.8, 7.7) + -- ([yshift=-0.3cm]provider.west); + +\end{tikzpicture} diff --git a/paper/src/preamble.tex b/paper/src/preamble.tex index d8f9876..9b680c1 100644 --- a/paper/src/preamble.tex +++ b/paper/src/preamble.tex @@ -40,7 +40,7 @@ % Configure cleveref for algorithm2e \crefname{algocf}{Algorithm}{Algorithms} -\usetikzlibrary{positioning, shapes, arrows.meta, fit, backgrounds} +\usetikzlibrary{positioning, shapes, arrows.meta, fit, backgrounds, calc} \lstset{ basicstyle=\ttfamily\footnotesize, breaklines=true, From 63f1aad0b9df87f33f35ba54b0321c50069924a9 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:18:38 +0100 Subject: [PATCH 22/35] chore: including new scritps for automation --- scripts/launch_calibration_screen.sh | 38 +++ scripts/setuptpu.sh | 9 + scripts/wandb_compare_best.py | 333 ++++++++++++++++++++++ scripts/wandb_create_sweep.py | 313 ++++++++++++++++++++ scripts/whoclicked_card.py | 342 ++++++++++++++++++++++ scripts/whoclicked_etl.py | 412 +++++++++++++++++++++++++++ 6 files changed, 1447 insertions(+) create mode 100755 scripts/launch_calibration_screen.sh create mode 100644 scripts/setuptpu.sh create mode 100644 scripts/wandb_compare_best.py create mode 100644 scripts/wandb_create_sweep.py create mode 100644 scripts/whoclicked_card.py create mode 100644 scripts/whoclicked_etl.py diff --git a/scripts/launch_calibration_screen.sh b/scripts/launch_calibration_screen.sh new file mode 100755 index 0000000..6e312a5 --- /dev/null +++ b/scripts/launch_calibration_screen.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +export RAY_MODE="${RAY_MODE:-sweep}" +export SWEEP_KIND="${SWEEP_KIND:-ppo_block_a}" +export SWEEP_METHOD="${SWEEP_METHOD:-grid}" +export SWEEP_PROFILE="${SWEEP_PROFILE:-default}" +export SWEEP_RUN_CAP="${SWEEP_RUN_CAP:-27}" +export COMPARE_ROBUST="${COMPARE_ROBUST:-1}" +export NUM_NODES="${NUM_NODES:-3}" +export AGENTS_PER_NODE="${AGENTS_PER_NODE:-4}" +export AGENT_COUNT="${AGENT_COUNT:-0}" +export INNER_THREADS="${INNER_THREADS:-1}" +export PHANTOM_JAX_PLATFORM="${PHANTOM_JAX_PLATFORM:-cpu}" +export OUTPUT_ROOT="${OUTPUT_ROOT:-engine/studies/results/block_a_sweep}" + +if [ -z "${WORKER_CPUS:-}" ]; then + export WORKER_CPUS="$((AGENTS_PER_NODE * INNER_THREADS))" +fi + +printf '%s\n' "Launching Block A PPO calibration sweep" +printf '%s\n' "RAY_MODE=$RAY_MODE" +printf '%s\n' "SWEEP_KIND=$SWEEP_KIND" +printf '%s\n' "SWEEP_METHOD=$SWEEP_METHOD" +printf '%s\n' "SWEEP_RUN_CAP=$SWEEP_RUN_CAP" +printf '%s\n' "COMPARE_ROBUST=$COMPARE_ROBUST" +printf '%s\n' "NUM_NODES=$NUM_NODES" +printf '%s\n' "AGENTS_PER_NODE=$AGENTS_PER_NODE" +printf '%s\n' "AGENT_COUNT=$AGENT_COUNT" +printf '%s\n' "INNER_THREADS=$INNER_THREADS" +printf '%s\n' "WORKER_CPUS=$WORKER_CPUS" +printf '%s\n' "OUTPUT_ROOT=$OUTPUT_ROOT" + +cd "$ROOT" +bash ./submit_ray_job.sh diff --git a/scripts/setuptpu.sh b/scripts/setuptpu.sh new file mode 100644 index 0000000..041266d --- /dev/null +++ b/scripts/setuptpu.sh @@ -0,0 +1,9 @@ +commands = ( + "pip install \"jax[tpu]\" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html" + "pip install stable-baselines3>=2.2.0 gymnasium wandb tensorboard" + + +" + + +) diff --git a/scripts/wandb_compare_best.py b/scripts/wandb_compare_best.py new file mode 100644 index 0000000..544f9d8 --- /dev/null +++ b/scripts/wandb_compare_best.py @@ -0,0 +1,333 @@ +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _as_float(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float(default) + + +def _as_int(value: Any, default: int) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return int(default) + + +def _normalize_sweep_id( + raw: str, entity: str, project: str +) -> tuple[str, str, str, str]: + sweep_raw = str(raw).strip() + if not sweep_raw: + raise ValueError("--sweep-id is required") + parts = [piece.strip() for piece in sweep_raw.split("/") if piece.strip()] + if len(parts) == 3: + return f"{parts[0]}/{parts[1]}/{parts[2]}", parts[0], parts[1], parts[2] + if len(parts) == 2: + if not entity.strip(): + raise ValueError("--entity is required when --sweep-id is '/'") + return f"{entity}/{parts[0]}/{parts[1]}", entity, parts[0], parts[1] + if len(parts) == 1: + if not entity.strip() or not project.strip(): + raise ValueError( + "--entity and --project are required when --sweep-id is ''" + ) + return f"{entity}/{project}/{parts[0]}", entity, project, parts[0] + raise ValueError(f"invalid --sweep-id value: '{raw}'") + + +def _pick_best_defended_run( + sweep: Any, + metric: str, + *, + min_margin: float, + min_coi: float, +) -> tuple[Any, float]: + ranked: list[tuple[float, Any]] = [] + for run in list(sweep.runs): + if str(getattr(run, "state", "")).lower() != "finished": + continue + cfg = dict(getattr(run, "config", {}) or {}) + is_baseline = ( + _truthy(cfg.get("baseline_mode")) + if "baseline_mode" in cfg + else _truthy(cfg.get("no_robust")) + ) + if is_baseline: + continue + summary = dict(getattr(run, "summary", {}) or {}) + margin = _as_float(summary.get("eval/margin_mean"), -1.0) + coi_level = _as_float(summary.get("eval/coi_level_mean"), -1.0) + if margin < float(min_margin): + continue + if coi_level < float(min_coi): + continue + score = summary.get(metric) + if score is None and str(metric) == "eval/stress_revenue_worst": + score = summary.get("eval/robust_revenue_worst") + if score is None: + continue + try: + ranked.append((float(score), run)) + except (TypeError, ValueError): + continue + if not ranked: + raise RuntimeError( + f"no finished defended runs found with summary metric '{metric}' and constraints " + f"margin>={min_margin}, coi>={min_coi}" + ) + ranked.sort(key=lambda item: item[0], reverse=True) + return ranked[0][1], ranked[0][0] + + +def _format_alpha_values(raw: str, fallback_alpha: float) -> str: + cleaned = str(raw).strip() + if cleaned: + return cleaned + return f"{float(fallback_alpha):.6g}" + + +def _benchmark_tokens( + *, + project: str, + cfg: dict[str, Any], + alpha_values: str, + episodes: int, +) -> list[str]: + algo = str(cfg.get("algo", "")).strip().lower() + if algo not in {"qtable", "ppo", "a2c", "dqn"}: + raise ValueError(f"unsupported algo in best run: '{algo}'") + + total_timesteps = _as_int(cfg.get("total_timesteps"), 80_000) + max_steps = _as_int(cfg.get("max_steps"), 100) + ambiguity_radius = _as_float( + cfg.get("ambiguity_radius", cfg.get("robust_radius")), 0.2 + ) + ambiguity_points = _as_int(cfg.get("ambiguity_points", cfg.get("robust_points")), 7) + ambiguity_rollouts = _as_int( + cfg.get("ambiguity_rollouts", cfg.get("robust_rollouts")), 1 + ) + lambda_coi = _as_float(cfg.get("lambda_coi"), 0.2) + eta_ux = _as_float(cfg.get("eta_ux"), 0.5) + reward_profit_weight = _as_float(cfg.get("reward_profit_weight"), 1.0) + learning_rate = _as_float(cfg.get("learning_rate"), 3e-4) + batch_size = _as_int(cfg.get("batch_size"), 256) + n_steps = _as_int(cfg.get("n_steps"), 2048) + sessions = _as_int(cfg.get("N"), 100) + action_levels = _as_int(cfg.get("action_levels"), 9) + margin_floor = _as_float(cfg.get("margin_floor"), 0.85) + seed = _as_int(cfg.get("seed"), 42) + + return [ + "--project", + project, + "--tiers", + algo, + "--alpha-values", + alpha_values, + "--episodes", + str(int(episodes)), + "--seed", + str(seed), + "--total-timesteps", + str(total_timesteps), + "--max-steps", + str(max_steps), + "--robust-radius", + str(ambiguity_radius), + "--robust-points", + str(ambiguity_points), + "--robust-rollouts", + str(ambiguity_rollouts), + "--lambda-coi", + str(lambda_coi), + "--eta-ux", + str(eta_ux), + "--reward-profit-weight", + str(reward_profit_weight), + "--learning-rate", + str(learning_rate), + "--batch-size", + str(batch_size), + "--n-steps", + str(n_steps), + "--N", + str(sessions), + "--action-levels", + str(action_levels), + "--margin-floor", + str(margin_floor), + "--device", + "cpu", + ] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Find best defended sweep run and prepare defended-vs-baseline benchmark" + ) + parser.add_argument("--sweep-id", required=True) + parser.add_argument("--entity", default="") + parser.add_argument("--project", default="") + parser.add_argument("--metric", default="eval/stress_revenue_worst") + parser.add_argument("--min-margin", type=float, default=0.90) + parser.add_argument("--min-coi", type=float, default=120.0) + parser.add_argument("--alpha-values", default="") + parser.add_argument("--episodes", type=int, default=15) + parser.add_argument("--num-nodes", type=int, default=4) + parser.add_argument("--tpu-per-task", type=float, default=0.0) + parser.add_argument("--inner-workers", type=int, default=12) + parser.add_argument("--inner-threads", type=int, default=1) + parser.add_argument("--max-heavy-workers", type=int, default=3) + parser.add_argument("--worker-cpus", type=int, default=24) + parser.add_argument( + "--output-root", default="engine/studies/results/overnight/best_compare" + ) + parser.add_argument("--timeout", type=int, default=120) + parser.add_argument("--submit", action="store_true") + parser.add_argument("--ray-no-wait", action="store_true") + parser.add_argument("--submission-id", default="") + parser.add_argument("--output-json", default="") + args = parser.parse_args() + + root = Path(__file__).resolve().parents[1] + cwd = str(Path.cwd()) + sys.path = [p for p in sys.path if p not in {"", cwd}] + + try: + import wandb + except ImportError as exc: + raise ImportError("wandb is required") from exc + + full_sweep_id, entity, project, _ = _normalize_sweep_id( + raw=str(args.sweep_id), + entity=str(args.entity).strip(), + project=str(args.project).strip(), + ) + api = wandb.Api(timeout=int(args.timeout)) + sweep = api.sweep(full_sweep_id) + best_run, best_score = _pick_best_defended_run( + sweep, + str(args.metric), + min_margin=float(args.min_margin), + min_coi=float(args.min_coi), + ) + + best_cfg = dict(getattr(best_run, "config", {}) or {}) + best_alpha = _as_float( + best_cfg.get( + "alpha", + getattr(best_run, "summary", {}).get("study/alpha", 0.6), + ), + 0.6, + ) + alpha_values = _format_alpha_values( + str(args.alpha_values), fallback_alpha=best_alpha + ) + benchmark_tokens = _benchmark_tokens( + project=project, + cfg=best_cfg, + alpha_values=alpha_values, + episodes=int(args.episodes), + ) + benchmark_args = shlex.join(benchmark_tokens) + + submission_id = str(args.submission_id).strip() + if not submission_id: + stamp = datetime.now(timezone.utc).strftime("%m%d-%H%M") + submission_id = f"best-compare-{stamp}" + + env_overrides = { + "RAY_MODE": "benchmark", + "COMPARE_ROBUST": "1", + "NUM_NODES": str(int(args.num_nodes)), + "TPU_PER_TASK": str(float(args.tpu_per_task)), + "PHANTOM_JAX_PLATFORM": "cpu", + "WANDB_ENTITY": entity, + "WANDB_PROJECT": project, + "BENCHMARK_ARGS": benchmark_args, + "INNER_WORKERS": str(int(args.inner_workers)), + "INNER_THREADS": str(int(args.inner_threads)), + "MAX_HEAVY_WORKERS": str(int(args.max_heavy_workers)), + "WORKER_CPUS": str(int(args.worker_cpus)), + "OUTPUT_ROOT": str(args.output_root), + "SUBMISSION_ID": submission_id, + } + if bool(args.ray_no_wait): + env_overrides["RAY_NO_WAIT"] = "1" + + command_str = ( + "cd " + + shlex.quote(str(root)) + + " && " + + " ".join( + f"{key}={shlex.quote(str(value))}" for key, value in env_overrides.items() + ) + + " bash ./submit_ray_job.sh" + ) + + payload = { + "sweep_id": full_sweep_id, + "selection_metric": str(args.metric), + "constraints": { + "min_margin": float(args.min_margin), + "min_coi": float(args.min_coi), + }, + "best_run": { + "id": str(getattr(best_run, "id", "")), + "name": str(getattr(best_run, "name", "")), + "url": str(getattr(best_run, "url", "")), + "score": float(best_score), + "algo": str(best_cfg.get("algo", "")), + "alpha": float(best_alpha), + "eval_margin_mean": _as_float( + getattr(best_run, "summary", {}).get("eval/margin_mean"), 0.0 + ), + "eval_coi_level_mean": _as_float( + getattr(best_run, "summary", {}).get("eval/coi_level_mean"), 0.0 + ), + }, + "benchmark_compare_command": command_str, + } + print(json.dumps(payload, indent=2)) + + output_json = str(args.output_json).strip() + if output_json: + out_path = Path(output_json) + if not out_path.is_absolute(): + out_path = root / out_path + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, indent=2) + "\n") + + if bool(args.submit): + run_env = dict(os.environ) + run_env.update({key: str(value) for key, value in env_overrides.items()}) + subprocess.run( + ["bash", "./submit_ray_job.sh"], + cwd=str(root), + env=run_env, + check=True, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/wandb_create_sweep.py b/scripts/wandb_create_sweep.py new file mode 100644 index 0000000..e44354a --- /dev/null +++ b/scripts/wandb_create_sweep.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import argparse +import contextlib +import io +import json +import sys +from pathlib import Path +from typing import Any + + +def _base_sweep(method: str, metric_name: str) -> dict[str, Any]: + return { + "method": str(method), + "metric": {"name": str(metric_name), "goal": "maximize"}, + } + + +def _benchmark_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="objective/score") + cfg["name"] = "benchmark-all-algos-defense" + cfg["parameters"] = { + "tiers": { + "values": [ + "static", + "surge", + "linear", + "qtable", + "ppo", + "a2c", + "dqn", + ] + }, + "alpha_values": {"values": ["0.0", "0.1", "0.25", "0.4", "0.6", "0.8"]}, + "baseline_mode": {"values": [False, True]}, + "seed": {"values": [42, 1337, 2026, 7777]}, + "episodes": {"values": [8, 12]}, + "total_timesteps": {"values": [15000, 30000, 50000]}, + "lambda_coi": {"values": [0.1, 0.2, 0.4]}, + "ambiguity_radius": {"values": [0.1, 0.2, 0.3]}, + "ambiguity_points": {"values": [5, 7]}, + "ambiguity_rollouts": {"values": [1, 2]}, + "eta_ux": {"values": [0.25, 0.5, 0.75]}, + "reward_profit_weight": {"values": [0.75, 1.0, 1.25]}, + "learning_rate": {"values": [1e-4, 3e-4, 1e-3]}, + "batch_size": {"values": [128, 256, 512]}, + "n_steps": {"values": [1024, 2048, 4096]}, + "device": {"value": "cpu"}, + } + return cfg + + +def _train_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="objective/score") + cfg["name"] = "train-all-algos-defense" + cfg["parameters"] = { + "algo": {"values": ["qtable", "ppo", "a2c", "dqn"]}, + "alpha": {"values": [0.0, 0.1, 0.25, 0.4, 0.6]}, + "baseline_mode": {"values": [False, True]}, + "seed": {"values": [42, 1337, 2026, 7777]}, + "total_timesteps": {"values": [30000, 50000, 80000]}, + "learning_rate": {"values": [1e-4, 3e-4, 1e-3]}, + "batch_size": {"values": [128, 256, 512]}, + "n_steps": {"values": [1024, 2048, 4096]}, + "lambda_coi": {"values": [0.1, 0.2, 0.4]}, + "ambiguity_radius": {"values": [0.1, 0.2, 0.3]}, + "ambiguity_points": {"values": [3, 5, 7]}, + "ambiguity_rollouts": {"values": [1, 2]}, + "eta_ux": {"values": [0.25, 0.5, 0.75]}, + "reward_profit_weight": {"values": [0.75, 1.0, 1.25]}, + "N": {"values": [80, 100, 140]}, + "max_steps": {"values": [80, 100, 120]}, + "action_levels": {"values": [7, 9, 11]}, + "device": {"value": "cpu"}, + } + return cfg + + +def _train_robust_revenue_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="eval/stress_revenue_worst") + cfg["name"] = "train-defense-revenue-search" + cfg["parameters"] = { + "algo": {"values": ["qtable", "ppo", "a2c", "dqn"]}, + "alpha": {"values": [0.4, 0.6, 0.8]}, + "baseline_mode": {"value": False}, + "seed": {"values": [42, 1337, 2026, 7777]}, + "total_timesteps": {"values": [60_000, 80_000, 120_000]}, + "learning_rate": {"values": [1e-4, 3e-4, 1e-3]}, + "batch_size": {"values": [128, 256, 512]}, + "n_steps": {"values": [1024, 2048, 4096]}, + "lambda_coi": {"values": [0.2, 0.4, 0.6]}, + "ambiguity_radius": {"values": [0.1, 0.2, 0.3]}, + "ambiguity_points": {"values": [5, 7, 9]}, + "ambiguity_rollouts": {"values": [1, 2]}, + "eta_ux": {"values": [0.25, 0.5, 0.75]}, + "reward_profit_weight": {"values": [1.0, 1.25]}, + "N": {"values": [80, 100, 140]}, + "max_steps": {"values": [80, 100, 120]}, + "action_levels": {"values": [7, 9, 11]}, + "margin_floor": {"value": 0.85}, + "device": {"value": "cpu"}, + } + return cfg + + +def _ppo_calibration_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="objective/score") + cfg["name"] = "benchmark-ppo-calibration" + cfg["parameters"] = { + "tiers": {"value": "ppo"}, + "alpha_values": {"values": ["0.0", "0.1", "0.25", "0.4", "0.6", "0.8"]}, + "baseline_mode": {"values": [False, True]}, + "seed": {"values": [42, 1337, 2026, 7777]}, + "episodes": {"value": 12}, + "total_timesteps": {"value": 60000}, + "lambda_coi": { + "distribution": "uniform", + "min": 0.05, + "max": 0.6, + }, + "ambiguity_radius": { + "distribution": "uniform", + "min": 0.05, + "max": 0.45, + }, + "ambiguity_points": {"value": 7}, + "ambiguity_rollouts": {"value": 1}, + "eta_ux": {"value": 0.5}, + "reward_profit_weight": {"value": 1.0}, + "learning_rate": { + "distribution": "log_uniform_values", + "min": 1e-4, + "max": 1e-3, + }, + "batch_size": {"values": [128, 256, 512]}, + "n_steps": {"values": [1024, 2048, 4096]}, + "device": {"value": "cpu"}, + } + return cfg + + +def _ppo_block_a_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="objective/score") + cfg["name"] = "benchmark-ppo-block-a-calibration" + cfg["parameters"] = { + "tiers": {"value": "ppo"}, + "alpha_values": {"value": "0.25,0.6,0.8"}, + "seed": {"values": [42, 1337, 2026]}, + "episodes": {"value": 12}, + "total_timesteps": {"value": 80000}, + "lambda_coi": {"values": [0.05, 0.1, 0.2]}, + "ambiguity_radius": {"values": [0.05, 0.1, 0.2]}, + "ambiguity_points": {"value": 7}, + "ambiguity_rollouts": {"value": 1}, + "eta_ux": {"value": 0.5}, + "reward_profit_weight": {"value": 1.0}, + "learning_rate": {"value": 3e-4}, + "batch_size": {"value": 256}, + "n_steps": {"value": 2048}, + "device": {"value": "cpu"}, + } + return cfg + + +def _ppo_shift_screen_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="objective/score") + cfg["name"] = "benchmark-ppo-shift-screen" + cfg["parameters"] = { + "tiers": {"value": "ppo"}, + "alpha_values": {"value": "0.25"}, + "eval_alpha_values": {"value": "0.6,0.8"}, + "seed": {"values": [42, 1337, 2026]}, + "episodes": {"value": 20}, + "total_timesteps": {"value": 80000}, + "lambda_coi": {"values": [0.0, 0.02, 0.05, 0.1]}, + "ambiguity_radius": {"values": [0.0, 0.02, 0.05, 0.1]}, + "ambiguity_points": {"value": 5}, + "ambiguity_rollouts": {"value": 1}, + "eta_ux": {"value": 0.0}, + "reward_profit_weight": {"value": 1.0}, + "learning_rate": {"value": 3e-4}, + "batch_size": {"value": 256}, + "n_steps": {"value": 2048}, + "device": {"value": "cpu"}, + } + return cfg + + +def _ppo_rl_study_sweep(method: str) -> dict[str, Any]: + cfg = _base_sweep(method=method, metric_name="eval/stress_revenue_worst") + cfg["name"] = "train-ppo-standard-vs-defended-equilibrium" + cfg["parameters"] = { + "algo": {"value": "ppo"}, + "seed": {"values": [42, 1337, 7777]}, + "alpha": {"values": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]}, + "n_products": {"values": [5, 25, 50, 100]}, + "N": {"value": 100}, + "no_robust": {"values": [False, True]}, + "lambda_coi": {"values": [0.05, 0.15, 0.3]}, + "ambiguity_radius": {"values": [0.1, 0.2, 0.3]}, + "ambiguity_points": {"value": 7}, + "ambiguity_rollouts": {"value": 1}, + "eta_ux": {"value": 0.0}, + "reward_profit_weight": {"value": 1.0}, + "total_timesteps": {"value": 100000}, + "eval_episodes": {"value": 10}, + "eval_freq": {"value": 1000}, + "log_freq": {"value": 100}, + "hist_freq": {"value": 500}, + "learning_rate": {"value": 3e-4}, + "batch_size": {"value": 256}, + "n_steps": {"value": 2048}, + "device": {"value": "cpu"}, + } + return cfg + + +def main() -> None: + parser = argparse.ArgumentParser(description="Create W&B sweep for PHANTOM") + parser.add_argument( + "--kind", + choices=[ + "benchmark", + "train", + "ppo_calibration", + "ppo_block_a", + "ppo_shift_screen", + "ppo_rl_study", + ], + default="benchmark", + ) + parser.add_argument( + "--profile", + choices=["default", "robust_revenue"], + default="default", + ) + parser.add_argument("--project", required=True) + parser.add_argument("--entity", default="") + parser.add_argument( + "--method", choices=["random", "bayes", "grid"], default="random" + ) + parser.add_argument("--run-cap", type=int, default=0) + parser.add_argument("--json", action="store_true") + parser.add_argument("--full-id", action="store_true") + args = parser.parse_args() + + cwd = str(Path.cwd()) + sys.path = [p for p in sys.path if p not in {"", cwd}] + + try: + import wandb + except ImportError as exc: + raise ImportError("wandb is required to create sweeps") from exc + + if str(args.kind) == "benchmark": + if str(args.profile) != "default": + raise ValueError("benchmark sweeps only support --profile default") + sweep_cfg = _benchmark_sweep(args.method) + elif str(args.kind) == "train": + if str(args.profile) == "robust_revenue": + sweep_cfg = _train_robust_revenue_sweep(args.method) + else: + sweep_cfg = _train_sweep(args.method) + elif str(args.kind) == "ppo_calibration": + if str(args.profile) != "default": + raise ValueError("ppo_calibration sweeps only support --profile default") + sweep_cfg = _ppo_calibration_sweep(args.method) + elif str(args.kind) == "ppo_block_a": + if str(args.profile) != "default": + raise ValueError("ppo_block_a sweeps only support --profile default") + sweep_cfg = _ppo_block_a_sweep(args.method) + elif str(args.kind) == "ppo_shift_screen": + if str(args.profile) != "default": + raise ValueError("ppo_shift_screen sweeps only support --profile default") + sweep_cfg = _ppo_shift_screen_sweep(args.method) + else: + if str(args.profile) != "default": + raise ValueError("ppo_rl_study sweeps only support --profile default") + sweep_cfg = _ppo_rl_study_sweep(args.method) + if int(args.run_cap) > 0: + sweep_cfg["run_cap"] = int(args.run_cap) + + with contextlib.redirect_stdout(io.StringIO()): + sweep_id = wandb.sweep( + sweep=sweep_cfg, + project=str(args.project), + entity=str(args.entity) if str(args.entity).strip() else None, + ) + full_id = ( + f"{args.entity}/{args.project}/{sweep_id}" + if str(args.entity).strip() + else f"{args.project}/{sweep_id}" + ) + + if bool(args.json): + print( + json.dumps( + { + "kind": str(args.kind), + "profile": str(args.profile), + "project": str(args.project), + "entity": str(args.entity), + "sweep_id": str(sweep_id), + "full_id": str(full_id), + } + ) + ) + return + print(full_id if bool(args.full_id) else sweep_id) + + +if __name__ == "__main__": + main() diff --git a/scripts/whoclicked_card.py b/scripts/whoclicked_card.py new file mode 100644 index 0000000..8b5e4b2 --- /dev/null +++ b/scripts/whoclicked_card.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +"""Build and upload a Hugging Face dataset card for whoclickedit.""" + +from __future__ import annotations + +import argparse +import os +import sys +from pathlib import Path +from typing import Any + +import pandas as pd +from huggingface_hub import HfApi + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_INPUT = PROJECT_ROOT / "experiments" / "exports" / "whoclicked.csv" +DEFAULT_OUTPUT = PROJECT_ROOT / "experiments" / "exports" / "whoclicked_dataset_card.md" +DEFAULT_REPO = os.getenv("HF_WHOCLICKED_REPO", "velocitatem/whoclickedit") + + +def _token() -> str | None: + return os.getenv("HF_TOKEN") or None + + +def _exception_details(exc: Exception) -> str: + parts = [str(exc).strip()] + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status_code", None) + if status is not None: + parts.append(f"HTTP {status}") + text = getattr(response, "text", "") + if text: + parts.append(text.strip()[:500]) + return " | ".join(p for p in parts if p) + + +def _size_category(n_rows: int) -> str: + if n_rows < 1_000: + return "n<1K" + if n_rows < 10_000: + return "1K dict[str, int]: + if col not in df.columns: + return {} + vc = df[col].fillna("").astype(str).value_counts(dropna=False) + return {k: int(v) for k, v in vc.items()} + + +def _group_count(df: pd.DataFrame, left: str, right: str) -> dict[tuple[str, str], int]: + if left not in df.columns or right not in df.columns: + return {} + grouped = ( + df.groupby([left, right], dropna=False) + .size() + .reset_index(name="count") + .sort_values([left, right]) + ) + out: dict[tuple[str, str], int] = {} + for _, row in grouped.iterrows(): + out[(str(row[left]), str(row[right]))] = int(row["count"]) + return out + + +def _session_count_by_actor(df: pd.DataFrame) -> dict[str, int]: + if "actor_type" not in df.columns or "sessionId" not in df.columns: + return {} + grouped = ( + df[["actor_type", "sessionId"]] + .dropna(subset=["sessionId"]) + .drop_duplicates() + .groupby("actor_type") + .size() + ) + return {str(k): int(v) for k, v in grouped.items()} + + +def _time_range(df: pd.DataFrame) -> tuple[str, str]: + if "ts" not in df.columns: + return "unknown", "unknown" + ts = pd.to_datetime(df["ts"], errors="coerce", utc=True) + ts = ts.dropna() + if ts.empty: + return "unknown", "unknown" + return ts.min().isoformat(), ts.max().isoformat() + + +def _render_card(df: pd.DataFrame) -> str: + total_rows = len(df) + total_cols = len(df.columns) + size_cat = _size_category(total_rows) + + actor_counts = _series_count(df, "actor_type") + record_counts = _series_count(df, "record_type") + by_actor_record = _group_count(df, "actor_type", "record_type") + store_counts = _series_count(df, "storeMode") + session_counts = _session_count_by_actor(df) + t_min, t_max = _time_range(df) + + event_counts: dict[str, int] = {} + if "record_type" in df.columns and "eventName" in df.columns: + interactions = df[df["record_type"] == "interaction"] + event_counts = _series_count(interactions, "eventName") + + metadata_cols = sorted(c for c in df.columns if c.startswith("metadata_")) + + actor_lines = ( + "\n".join(f"- `{k}`: {v}" for k, v in actor_counts.items()) or "- none" + ) + record_lines = ( + "\n".join(f"- `{k}`: {v}" for k, v in record_counts.items()) or "- none" + ) + pair_lines = ( + "\n".join( + f"- `{a}` / `{r}`: {n}" + for (a, r), n in sorted( + by_actor_record.items(), key=lambda x: (x[0][0], x[0][1]) + ) + ) + or "- none" + ) + store_lines = ( + "\n".join(f"- `{k}`: {v}" for k, v in store_counts.items()) or "- none" + ) + session_lines = ( + "\n".join(f"- `{k}`: {v}" for k, v in session_counts.items()) or "- none" + ) + top_events = list(event_counts.items())[:10] + event_lines = "\n".join(f"- `{k}`: {v}" for k, v in top_events) or "- none" + metadata_lines = "\n".join(f"- `{c}`" for c in metadata_cols) or "- none" + + return f"""--- +pretty_name: whoclickedit +license: mit +language: +- en +task_categories: +- tabular-classification +task_ids: +- tabular-multi-class-classification +tags: +- e-commerce +- dynamic-pricing +- behavioral-telemetry +- human-vs-agent +- session-data +size_categories: +- {size_cat} +--- + +# Dataset Card for whoclickedit + +## Dataset Summary +whoclickedit is an event-level behavioral dataset for human versus agent interaction analysis in dynamic pricing experiments. +It merges interaction logs and price quote logs into one flat CSV (`whoclicked.csv`) with explicit labels for actor type. + +## Dataset Snapshot +- Rows: `{total_rows}` +- Columns: `{total_cols}` +- Time range (UTC): `{t_min}` to `{t_max}` +- Unique sessions by actor: +{session_lines} +- Rows by actor: +{actor_lines} +- Rows by record type: +{record_lines} +- Rows by actor x record type: +{pair_lines} +- Store modes: +{store_lines} + +## Source and Processing +Data is collected from two local roots in the PHANTOM project: +- `experiments/collected_data` (human sessions) +- `experiments/agents/collected_data` (agent sessions) + +Each session folder contains: +- `int.json` (interaction events) +- `price.json` (price quote logs) + +The ETL does the following: +- Normalizes both Kafka-envelope and flat payload formats +- Flattens nested metadata fields into `metadata_*` columns +- Preserves all raw rows (no deduplication) +- Adds labels: + - `actor_type` in `{{human, agent}}` + - `is_agent` in `{{0, 1}}` + - `record_type` in `{{interaction, price_log}}` + +## Data Fields +Core fields used for modeling: +- `actor_type`, `is_agent`, `record_type` +- `sessionId`, `experimentId`, `storeMode`, `ts` +- `eventName`, `page`, `productId`, `price`, `userAgent` + +Kafka provenance fields: +- `kafka_partition_id`, `kafka_offset`, `kafka_timestamp_ms`, `kafka_compression` +- `kafka_is_transactional`, `kafka_headers`, `kafka_key_*`, `kafka_value_*` + +Flattened metadata fields currently present: +{metadata_lines} + +Top interaction events: +{event_lines} + +## Intended Uses +- Human-vs-agent traffic classification +- Session-level behavioral modeling +- Dynamic pricing robustness analysis under agent-mediated reconnaissance + +## Out-of-Scope Uses +- Identity inference or user-level profiling +- Credit, employment, insurance, or legal decision making + +## Data Splits +No official train/validation/test split is provided in the current release. +Users should create time-aware or session-aware splits to avoid leakage. + +## Privacy and Sensitive Content +- `userAgent` and referrer metadata can be quasi-identifying in small samples. +- Use care before publishing derived artifacts that can re-identify participants. + +## Limitations +- Data is generated in a controlled experiment platform, not a full production marketplace. +- Agent traffic currently reflects the configured tasking and browser automation setup. +- Coverage is stronger for `hotel` than `airline` in the current release. + +## Citation +If you use this dataset, cite the PHANTOM thesis project and link this dataset page. +""" + + +def build_card(input_csv: Path, output_md: Path) -> None: + if not input_csv.exists(): + raise FileNotFoundError(f"Input CSV not found: {input_csv}") + df = pd.read_csv(input_csv) + card = _render_card(df) + output_md.parent.mkdir(parents=True, exist_ok=True) + output_md.write_text(card) + print(f"wrote dataset card to {output_md}") + + +def upload_card( + card_path: Path, repo_id: str, path_in_repo: str, commit_message: str +) -> None: + if not card_path.exists(): + raise FileNotFoundError(f"Card file not found: {card_path}") + + api = HfApi(token=_token()) + try: + me = api.whoami(token=_token()) + except Exception as exc: + detail = _exception_details(exc) + raise RuntimeError(f"Hugging Face auth failed. Details: {detail}") from exc + + user_name = me.get("name") or me.get("fullname") or "unknown" + print(f"authenticated to HF as: {user_name}") + + try: + api.repo_info(repo_id=repo_id, repo_type="dataset") + except Exception as exc: + detail = _exception_details(exc) + raise RuntimeError( + f"Dataset repo '{repo_id}' is not accessible. Details: {detail}" + ) from exc + + try: + commit = api.upload_file( + path_or_fileobj=str(card_path), + path_in_repo=path_in_repo, + repo_id=repo_id, + repo_type="dataset", + commit_message=commit_message, + ) + except Exception as exc: + detail = _exception_details(exc) + raise RuntimeError( + f"Card upload failed for '{repo_id}'. Details: {detail}" + ) from exc + + print(f"uploaded dataset card to https://huggingface.co/datasets/{repo_id}") + print(f"commit: {commit}") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Build or upload whoclickedit dataset card" + ) + sub = parser.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build", help="build card markdown from CSV") + build.add_argument("--input", type=Path, default=DEFAULT_INPUT) + build.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + + upload = sub.add_parser("upload", help="upload existing card as dataset README.md") + upload.add_argument("--input", type=Path, default=DEFAULT_OUTPUT) + upload.add_argument("--repo", default=DEFAULT_REPO) + upload.add_argument("--path-in-repo", default="README.md") + upload.add_argument("--message", default="Add dataset card for whoclickedit") + + both = sub.add_parser("build-upload", help="build card and upload to dataset repo") + both.add_argument("--csv", type=Path, default=DEFAULT_INPUT) + both.add_argument("--card", type=Path, default=DEFAULT_OUTPUT) + both.add_argument("--repo", default=DEFAULT_REPO) + both.add_argument("--path-in-repo", default="README.md") + both.add_argument("--message", default="Add dataset card for whoclickedit") + + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + try: + if args.command == "build": + build_card(args.input, args.output) + return 0 + + if args.command == "upload": + upload_card(args.input, args.repo, args.path_in_repo, args.message) + return 0 + + if args.command == "build-upload": + build_card(args.csv, args.card) + upload_card(args.card, args.repo, args.path_in_repo, args.message) + return 0 + + raise ValueError(f"Unknown command: {args.command}") + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/whoclicked_etl.py b/scripts/whoclicked_etl.py new file mode 100644 index 0000000..105f15a --- /dev/null +++ b/scripts/whoclicked_etl.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""Build and upload a flattened who-clicked dataset from local collected_data.""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +import pandas as pd +from huggingface_hub import HfApi + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +DEFAULT_HUMAN_DIR = PROJECT_ROOT / "experiments" / "collected_data" +DEFAULT_AGENT_DIR = PROJECT_ROOT / "experiments" / "agents" / "collected_data" +DEFAULT_OUTPUT = PROJECT_ROOT / "experiments" / "exports" / "whoclicked.csv" +DEFAULT_REPO = os.getenv("HF_WHOCLICKED_REPO", "velocitatem/whoclickedit") + +BASE_COLUMNS = [ + "actor_type", + "is_agent", + "record_type", + "topic", + "source_session_dir", + "source_file", + "source_row_index", + "ingest_format", + "sessionId", + "experimentId", + "storeMode", + "ts", + "eventName", + "page", + "productId", + "price", + "userAgent", + "kafka_partition_id", + "kafka_offset", + "kafka_timestamp_ms", + "kafka_compression", + "kafka_is_transactional", + "kafka_headers", + "kafka_key_payload", + "kafka_key_encoding", + "kafka_key_schema_id", + "kafka_value_encoding", + "kafka_value_schema_id", + "kafka_value_size", +] + + +def _token() -> str | None: + return os.getenv("HF_TOKEN") or None + + +def _exception_details(exc: Exception) -> str: + parts = [str(exc).strip()] + response = getattr(exc, "response", None) + if response is not None: + status = getattr(response, "status_code", None) + if status is not None: + parts.append(f"HTTP {status}") + text = getattr(response, "text", "") + if text: + text = text.strip() + if text: + parts.append(text[:500]) + return " | ".join(p for p in parts if p) + + +def _flatten_dict(data: dict[str, Any], prefix: str = "") -> dict[str, Any]: + flat: dict[str, Any] = {} + for key, value in data.items(): + normalized_key = str(key).strip().replace(" ", "_") + next_key = f"{prefix}_{normalized_key}" if prefix else normalized_key + if isinstance(value, dict): + flat.update(_flatten_dict(value, next_key)) + else: + flat[next_key] = value + return flat + + +def _as_scalar(value: Any) -> Any: + if isinstance(value, (dict, list, tuple)): + return json.dumps(value, ensure_ascii=True, sort_keys=True) + return value + + +def _empty_envelope() -> dict[str, Any]: + return { + "kafka_partition_id": None, + "kafka_offset": None, + "kafka_timestamp_ms": None, + "kafka_compression": None, + "kafka_is_transactional": None, + "kafka_headers": None, + "kafka_key_payload": None, + "kafka_key_encoding": None, + "kafka_key_schema_id": None, + "kafka_value_encoding": None, + "kafka_value_schema_id": None, + "kafka_value_size": None, + } + + +def _extract_payload_and_envelope( + record: Any, +) -> tuple[dict[str, Any], dict[str, Any], str]: + if ( + isinstance(record, dict) + and isinstance(record.get("value"), dict) + and isinstance(record["value"].get("payload"), dict) + ): + key = record.get("key") if isinstance(record.get("key"), dict) else {} + value = record["value"] + envelope = { + "kafka_partition_id": record.get("partitionID"), + "kafka_offset": record.get("offset"), + "kafka_timestamp_ms": record.get("timestamp"), + "kafka_compression": record.get("compression"), + "kafka_is_transactional": record.get("isTransactional"), + "kafka_headers": _as_scalar(record.get("headers")), + "kafka_key_payload": key.get("payload"), + "kafka_key_encoding": key.get("encoding"), + "kafka_key_schema_id": key.get("schemaId"), + "kafka_value_encoding": value.get("encoding"), + "kafka_value_schema_id": value.get("schemaId"), + "kafka_value_size": value.get("size"), + } + return dict(value["payload"]), envelope, "kafka_envelope" + + if isinstance(record, dict): + return dict(record), _empty_envelope(), "flat_payload" + + return {}, _empty_envelope(), "unknown" + + +def _load_json_list(path: Path) -> list[Any]: + raw = json.loads(path.read_text()) + if not isinstance(raw, list): + raise ValueError(f"Expected list in {path}, got {type(raw).__name__}") + return raw + + +def _normalize_file_rows( + actor_type: str, + is_agent: int, + session_dir_name: str, + source_file: str, + records: list[Any], +) -> list[dict[str, Any]]: + record_type = "interaction" if source_file == "int.json" else "price_log" + topic = "user-interactions" if record_type == "interaction" else "price-logs" + + rows: list[dict[str, Any]] = [] + for idx, raw_record in enumerate(records): + payload, envelope, ingest_format = _extract_payload_and_envelope(raw_record) + metadata = payload.pop("metadata", None) + + payload_flat = _flatten_dict(payload) + row: dict[str, Any] = { + "actor_type": actor_type, + "is_agent": is_agent, + "record_type": record_type, + "topic": topic, + "source_session_dir": session_dir_name, + "source_file": source_file, + "source_row_index": idx, + "ingest_format": ingest_format, + **envelope, + } + row.update({k: _as_scalar(v) for k, v in payload_flat.items()}) + + if isinstance(metadata, dict): + metadata_flat = _flatten_dict(metadata, "metadata") + row.update({k: _as_scalar(v) for k, v in metadata_flat.items()}) + elif metadata is not None: + row["metadata_raw"] = _as_scalar(metadata) + + rows.append(row) + + return rows + + +def _collect_rows_for_actor( + actor_type: str, is_agent: int, base_dir: Path +) -> list[dict[str, Any]]: + if not base_dir.exists(): + raise FileNotFoundError(f"Directory not found: {base_dir}") + + rows: list[dict[str, Any]] = [] + for session_dir in sorted( + (p for p in base_dir.iterdir() if p.is_dir()), key=lambda p: p.name + ): + for source_file in ("int.json", "price.json"): + file_path = session_dir / source_file + if not file_path.exists(): + continue + records = _load_json_list(file_path) + rows.extend( + _normalize_file_rows( + actor_type=actor_type, + is_agent=is_agent, + session_dir_name=session_dir.name, + source_file=source_file, + records=records, + ) + ) + return rows + + +def build_dataframe(human_dir: Path, agent_dir: Path) -> pd.DataFrame: + rows = [ + *_collect_rows_for_actor("human", 0, human_dir), + *_collect_rows_for_actor("agent", 1, agent_dir), + ] + if not rows: + return pd.DataFrame(columns=BASE_COLUMNS) + + df = pd.DataFrame(rows) + ordered_columns = [ + *BASE_COLUMNS, + *sorted(c for c in df.columns if c not in BASE_COLUMNS), + ] + return df[ordered_columns] + + +def _print_summary(df: pd.DataFrame, output_path: Path) -> None: + print(f"wrote {len(df)} rows and {len(df.columns)} columns to {output_path}") + if df.empty: + return + + print("rows by actor/record_type:") + grouped = ( + df.groupby(["actor_type", "record_type"], dropna=False) + .size() + .reset_index(name="count") + .sort_values(["actor_type", "record_type"]) + ) + for _, row in grouped.iterrows(): + print(f" - {row['actor_type']} / {row['record_type']}: {int(row['count'])}") + + required = ["actor_type", "is_agent", "record_type", "sessionId", "ts"] + missing = {col: int(df[col].isna().sum()) for col in required if col in df.columns} + print(f"missing in required columns: {missing}") + + +def build_csv(human_dir: Path, agent_dir: Path, output: Path) -> pd.DataFrame: + df = build_dataframe(human_dir=human_dir, agent_dir=agent_dir) + output.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(output, index=False) + _print_summary(df, output) + return df + + +def _resolve_repo_id(api: HfApi, repo_id: str) -> str: + if "/" in repo_id: + return repo_id + try: + me = api.whoami(token=_token()) + username = me.get("name") + if username: + return f"{username}/{repo_id}" + except Exception: + pass + return repo_id + + +def upload_csv( + input_path: Path, + repo_id: str, + path_in_repo: str, + commit_message: str, + create_if_missing: bool = False, +) -> None: + if not input_path.exists(): + raise FileNotFoundError(f"Input CSV not found: {input_path}") + + api = HfApi(token=_token()) + + try: + me = api.whoami(token=_token()) + except Exception as exc: + detail = _exception_details(exc) + hint = "Set HF_TOKEN with write access or run huggingface-cli login." + raise RuntimeError( + f"Hugging Face auth failed. {hint} Details: {detail}" + ) from exc + + user_name = me.get("name") or me.get("fullname") or "unknown" + print(f"authenticated to HF as: {user_name}") + + resolved_repo_id = _resolve_repo_id(api, repo_id) + if create_if_missing: + api.create_repo(repo_id=resolved_repo_id, repo_type="dataset", exist_ok=True) + else: + try: + api.repo_info(repo_id=resolved_repo_id, repo_type="dataset") + except Exception as exc: + detail = _exception_details(exc) + hint = ( + "Check owner/repo spelling, ensure it is a dataset repo, " + "or pass --create-if-missing." + ) + raise RuntimeError( + f"Dataset repo '{resolved_repo_id}' is not accessible. {hint} Details: {detail}" + ) from exc + + try: + commit = api.upload_file( + path_or_fileobj=str(input_path), + path_in_repo=path_in_repo, + repo_id=resolved_repo_id, + repo_type="dataset", + commit_message=commit_message, + ) + except Exception as exc: + detail = _exception_details(exc) + hint = ( + "Pass --repo /whoclickedit and ensure HF_TOKEN is set " + "(or run huggingface-cli login)." + ) + raise RuntimeError( + f"Upload failed for '{resolved_repo_id}'. {hint} Details: {detail}" + ) from exc + + print( + f"uploaded {input_path} to https://huggingface.co/datasets/{resolved_repo_id}" + ) + print(f"commit: {commit}") + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="ETL for whoclickedit: flatten local collected_data and upload to HF" + ) + sub = parser.add_subparsers(dest="command", required=True) + + build = sub.add_parser("build", help="build flattened CSV locally") + build.add_argument("--human-dir", type=Path, default=DEFAULT_HUMAN_DIR) + build.add_argument("--agent-dir", type=Path, default=DEFAULT_AGENT_DIR) + build.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + + upload = sub.add_parser("upload", help="upload an existing CSV to HF dataset") + upload.add_argument("--input", type=Path, default=DEFAULT_OUTPUT) + upload.add_argument("--repo", default=DEFAULT_REPO) + upload.add_argument("--path-in-repo", default="whoclicked.csv") + upload.add_argument("--message", default="Update flattened whoclickedit dataset") + upload.add_argument("--create-if-missing", action="store_true") + + build_upload = sub.add_parser( + "build-upload", help="build CSV and upload to HF dataset" + ) + build_upload.add_argument("--human-dir", type=Path, default=DEFAULT_HUMAN_DIR) + build_upload.add_argument("--agent-dir", type=Path, default=DEFAULT_AGENT_DIR) + build_upload.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + build_upload.add_argument("--repo", default=DEFAULT_REPO) + build_upload.add_argument("--path-in-repo", default="whoclicked.csv") + build_upload.add_argument( + "--message", default="Update flattened whoclickedit dataset" + ) + build_upload.add_argument("--create-if-missing", action="store_true") + + return parser.parse_args() + + +def main() -> int: + args = _parse_args() + + try: + if args.command == "build": + build_csv( + human_dir=args.human_dir, agent_dir=args.agent_dir, output=args.output + ) + return 0 + + if args.command == "upload": + upload_csv( + input_path=args.input, + repo_id=args.repo, + path_in_repo=args.path_in_repo, + commit_message=args.message, + create_if_missing=args.create_if_missing, + ) + return 0 + + if args.command == "build-upload": + build_csv( + human_dir=args.human_dir, agent_dir=args.agent_dir, output=args.output + ) + upload_csv( + input_path=args.output, + repo_id=args.repo, + path_in_repo=args.path_in_repo, + commit_message=args.message, + create_if_missing=args.create_if_missing, + ) + return 0 + + raise ValueError(f"Unknown command: {args.command}") + + except Exception as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) From a3e2a337ed52921b31e8cae5fa4ff21226217e09 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:19:01 +0100 Subject: [PATCH 23/35] chore: bootstrap push --- tpu_orchestration/bootstrap_ray.sh | 280 +++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100755 tpu_orchestration/bootstrap_ray.sh diff --git a/tpu_orchestration/bootstrap_ray.sh b/tpu_orchestration/bootstrap_ray.sh new file mode 100755 index 0000000..0de5f26 --- /dev/null +++ b/tpu_orchestration/bootstrap_ray.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEFAULT_CONF="${SCRIPT_DIR}/configs/v4_spot_us.conf" + +RAY_PORT="${RAY_PORT:-6379}" +RAY_DASHBOARD_HOST="${RAY_DASHBOARD_HOST:-0.0.0.0}" +RAY_DASHBOARD_LOCAL_PORT="${RAY_DASHBOARD_LOCAL_PORT:-8265}" +RAY_CLIENT_LOCAL_PORT="${RAY_CLIENT_LOCAL_PORT:-10001}" +TPU_CHIPS_PER_HOST="${TPU_CHIPS_PER_HOST:-8}" +TPU_RESOURCE_PER_HOST="${TPU_RESOURCE_PER_HOST:-8}" + +CONF_FILE="$DEFAULT_CONF" +DEPS_ONLY=0 +VERIFY_ONLY=0 +TEARDOWN=0 + +usage() { + cat <<'EOF' +Usage: bootstrap_ray.sh [options] + +Options: + --conf Path to TPU config (default: tpu_orchestration/configs/v4_spot_us.conf) + --deps-only Install TPU dependencies on all workers and exit + --verify-only Run JAX distributed smoke test on all workers and exit + --teardown Stop Ray on all workers and head, then exit + -h, --help Show this help + +Config file keys expected: + ZONE, QR_NAME, ACCEL_TYPE + +Optional env overrides: + PROJECT_ID, TPU_CHIPS_PER_HOST, TPU_RESOURCE_PER_HOST, + RAY_PORT, RAY_DASHBOARD_HOST, RAY_DASHBOARD_LOCAL_PORT, RAY_CLIENT_LOCAL_PORT +EOF +} + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" +} + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +require_cmd() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: ${cmd}" +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --conf) + [ "$#" -ge 2 ] || die "--conf requires a path" + CONF_FILE="$2" + shift 2 + ;; + --deps-only) + DEPS_ONLY=1 + shift + ;; + --verify-only) + VERIFY_ONLY=1 + shift + ;; + --teardown) + TEARDOWN=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + die "Unknown option: $1" + ;; + esac + done +} + +load_optional_sweep_env() { + if [ -n "${SWEEP_ENV_FILE:-}" ] && [ -f "${SWEEP_ENV_FILE}" ]; then + set -a + . "${SWEEP_ENV_FILE}" + set +a + return + fi + + local fallback_env="${SCRIPT_DIR}/../.env.sweep" + if [ -f "$fallback_env" ]; then + set -a + . "$fallback_env" + set +a + fi +} + +load_config() { + [ -f "$CONF_FILE" ] || die "Config file not found: $CONF_FILE" + # shellcheck disable=SC1090 + . "$CONF_FILE" + + [ -n "${ZONE:-}" ] || die "ZONE is required in config" + [ -n "${QR_NAME:-}" ] || die "QR_NAME is required in config" + [ -n "${ACCEL_TYPE:-}" ] || die "ACCEL_TYPE is required in config" +} + +resolve_project() { + if [ -n "${PROJECT_ID:-}" ]; then + return + fi + + local active_project + active_project="$(gcloud config get-value project 2>/dev/null || true)" + if [ -n "$active_project" ] && [ "$active_project" != "(unset)" ]; then + PROJECT_ID="$active_project" + return + fi + + die "PROJECT_ID is not set and gcloud has no active project" +} + +resolve_worker_count() { + [ -n "$TPU_CHIPS_PER_HOST" ] || die "TPU_CHIPS_PER_HOST must be set" + [[ "$TPU_CHIPS_PER_HOST" =~ ^[0-9]+$ ]] || die "TPU_CHIPS_PER_HOST must be numeric" + [ "$TPU_CHIPS_PER_HOST" -gt 0 ] || die "TPU_CHIPS_PER_HOST must be > 0" + + local total_chips + if [[ "$ACCEL_TYPE" =~ ([0-9]+)$ ]]; then + total_chips="${BASH_REMATCH[1]}" + else + die "Unable to parse total chips from ACCEL_TYPE=$ACCEL_TYPE" + fi + + if [ $((total_chips % TPU_CHIPS_PER_HOST)) -ne 0 ]; then + die "ACCEL_TYPE=$ACCEL_TYPE is not divisible by TPU_CHIPS_PER_HOST=$TPU_CHIPS_PER_HOST" + fi + + WORKER_COUNT=$((total_chips / TPU_CHIPS_PER_HOST)) + [ "$WORKER_COUNT" -gt 0 ] || die "Computed worker count must be > 0" +} + +run_tpu_ssh() { + local worker="$1" + local remote_cmd="$2" + local args=(compute tpus tpu-vm ssh "$QR_NAME" --zone "$ZONE" --project "$PROJECT_ID" --worker="$worker" --quiet --command "$remote_cmd") + gcloud "${args[@]}" +} + +install_deps() { + local cmd='python3 -m pip install --user --upgrade "jax[tpu]" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html stable-baselines3 gymnasium wandb tensorboard "ray[default]"' + log "Installing JAX and Ray dependencies on all workers" + run_tpu_ssh "all" "$cmd" +} + +verify_jax() { + local cmd='python3 -c "import jax; jax.distributed.initialize(); print(f\"process_index={jax.process_index()} local_devices={jax.local_device_count()} global_devices={jax.device_count()}\")"' + log "Running JAX distributed smoke test on all workers" + run_tpu_ssh "all" "$cmd" +} + +start_ray_head() { + local resources_json="{\"TPU\":${TPU_RESOURCE_PER_HOST}}" + local cmd="export PATH=\$HOME/.local/bin:\$PATH; ray stop >/dev/null 2>&1 || true; ray start --head --port=${RAY_PORT} --dashboard-host=${RAY_DASHBOARD_HOST} --resources='${resources_json}' --disable-usage-stats" + log "Starting Ray head on worker 0" + run_tpu_ssh "0" "$cmd" +} + +get_head_ip() { + local cmd="hostname -I | awk '{print \$1}'" + local head_ip + head_ip="$(run_tpu_ssh "0" "$cmd" | awk 'NF { ip=$1 } END { print ip }')" + [ -n "$head_ip" ] || die "Failed to resolve Ray head IP" + printf '%s\n' "$head_ip" +} + +start_ray_workers() { + local head_ip="$1" + local resources_json="{\"TPU\":${TPU_RESOURCE_PER_HOST}}" + local cmd + cmd="export PATH=\$HOME/.local/bin:\$PATH; ray stop >/dev/null 2>&1 || true; ray start --address=${head_ip}:${RAY_PORT} --resources='${resources_json}' --disable-usage-stats" + + if [ "$WORKER_COUNT" -le 1 ]; then + log "Single-worker topology detected; skipping worker join step" + return + fi + + local worker + for ((worker = 1; worker < WORKER_COUNT; worker++)); do + log "Starting Ray worker on worker ${worker}" + run_tpu_ssh "$worker" "$cmd" + done +} + +verify_ray_cluster() { + local cmd='export PATH=$HOME/.local/bin:$PATH; ray status' + log "Checking Ray cluster status from worker 0" + run_tpu_ssh "0" "$cmd" +} + +print_tunnel_hint() { + cat <= 1; worker--)); do + log "Stopping Ray on worker ${worker}" + if ! run_tpu_ssh "$worker" "$cmd"; then + failures=$((failures + 1)) + fi + done + fi + + log "Stopping Ray head on worker 0" + if ! run_tpu_ssh "0" "$cmd"; then + failures=$((failures + 1)) + fi + + [ "$failures" -eq 0 ] || die "Teardown completed with ${failures} failure(s)" +} + +main() { + parse_args "$@" + require_cmd gcloud + + load_optional_sweep_env + load_config + resolve_project + resolve_worker_count + + log "Target TPU: ${QR_NAME} (${ACCEL_TYPE}) in ${ZONE}" + log "Computed workers: ${WORKER_COUNT} (chips per host: ${TPU_CHIPS_PER_HOST})" + + if [ "$TEARDOWN" -eq 1 ]; then + teardown_ray + return + fi + + if [ "$DEPS_ONLY" -eq 1 ] && [ "$VERIFY_ONLY" -eq 1 ]; then + install_deps + verify_jax + return + fi + + if [ "$DEPS_ONLY" -eq 1 ]; then + install_deps + return + fi + + if [ "$VERIFY_ONLY" -eq 1 ]; then + verify_jax + return + fi + + install_deps + verify_jax + + start_ray_head + local head_ip + head_ip="$(get_head_ip)" + log "Ray head IP: ${head_ip}" + + start_ray_workers "$head_ip" + verify_ray_cluster + print_tunnel_hint +} + +main "$@" From e867c4d883d1880558fc6b28fee007d9d3fc704d Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:21:18 +0100 Subject: [PATCH 24/35] chore: updating ray --- .rayignore | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .rayignore diff --git a/.rayignore b/.rayignore new file mode 100644 index 0000000..c71492e --- /dev/null +++ b/.rayignore @@ -0,0 +1,35 @@ +# Virtual environments +.venv +.venv* +venv +venv* +**/.venv +**/venv +**/node_modules +node_modules/ + +# Python caches +__pycache__/ +*.pyc +.ruff_cache/ +.pytest_cache/ + +# Git +.git/ + +# Large data and logs +data/ +experiments/ +wandb/ +dumplogs* +*.zip +*.pdf +*.log +*.dot + +# Other large dirs +PHANTOM_web/ +web/ +docs/ +paper/ +.nx/ From 2adb4f07b490f4d24bbf363ab8a04a4ffe17acf2 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:23:06 +0100 Subject: [PATCH 25/35] feat: updating readme w badge for datset --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 17a8c45..6f744c3 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ ### PHANTOM +[![Dataset on HF](https://huggingface.co/datasets/huggingface/badges/resolve/main/dataset-on-hf-sm.svg)](https://huggingface.co/datasets/velocitatem/whoclickedit) [![Build PDF](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml/badge.svg)](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml) [![Paper](https://img.shields.io/badge/Paper-PDF-red?logo=adobe-acrobat-reader)](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf) [![TPU Research Cloud](https://img.shields.io/badge/TPU%20Research%20Cloud-TRC%20supported-4285F4?logo=googlecloud&logoColor=white)](https://sites.research.google/trc/faq/) From 43b952cf2bd252b59267bf1b743c260a521ccd8b Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 16 Mar 2026 15:30:09 +0100 Subject: [PATCH 26/35] adding the markdown to auto --- .../chapters/auto/whoclicked_dataset_card.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 paper/src/chapters/auto/whoclicked_dataset_card.md diff --git a/paper/src/chapters/auto/whoclicked_dataset_card.md b/paper/src/chapters/auto/whoclicked_dataset_card.md new file mode 100644 index 0000000..507f082 --- /dev/null +++ b/paper/src/chapters/auto/whoclicked_dataset_card.md @@ -0,0 +1,125 @@ +--- +pretty_name: whoclickedit +license: mit +language: +- en +task_categories: +- tabular-classification +task_ids: +- tabular-multi-class-classification +tags: +- e-commerce +- dynamic-pricing +- behavioral-telemetry +- human-vs-agent +- session-data +size_categories: +- 1K Date: Mon, 16 Mar 2026 15:58:05 +0100 Subject: [PATCH 27/35] fix: supra reward adjustment and sweep --- engine/backends/common.py | 25 ++++++++++++++ engine/lib/wrappers.py | 4 +++ engine/sweeps/ppo_supra_guard.yaml | 53 ++++++++++++++++++++++++++++++ engine/wrapper.py | 29 +++++++++++----- 4 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 engine/sweeps/ppo_supra_guard.yaml diff --git a/engine/backends/common.py b/engine/backends/common.py index 45f03e7..f754342 100644 --- a/engine/backends/common.py +++ b/engine/backends/common.py @@ -54,6 +54,9 @@ def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]: coi_levels: list[float] = [] coi_leakages: list[float] = [] volatilities: list[float] = [] + upward_volatilities: list[float] = [] + supra_shares: list[float] = [] + supra_penalties: list[float] = [] agent_probs: list[float] = [] for _ in range(int(episodes)): @@ -65,6 +68,9 @@ def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]: ep_coi = 0.0 ep_coi_leakage = 0.0 ep_volatility = 0.0 + ep_upward_volatility = 0.0 + ep_supra_share = 0.0 + ep_supra_penalty = 0.0 ep_agent_prob = 0.0 steps = 0 @@ -78,6 +84,15 @@ def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]: ep_coi += float(econ.get("coi_level", 0.0)) ep_coi_leakage += float(econ.get("coi_leakage", 0.0)) ep_volatility += float(econ.get("volatility", 0.0)) + ep_upward_volatility += float( + info.get("upward_volatility", econ.get("upward_volatility", 0.0)) + ) + ep_supra_share += float( + info.get("supra_share", econ.get("supra_share", 0.0)) + ) + ep_supra_penalty += float( + info.get("supra_penalty", econ.get("supra_penalty", 0.0)) + ) ep_agent_prob += float(econ.get("agent_prob", info.get("agent_prob", 0.0))) steps += 1 @@ -88,6 +103,9 @@ def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]: coi_levels.append(ep_coi / denom) coi_leakages.append(ep_coi_leakage / denom) volatilities.append(ep_volatility / denom) + upward_volatilities.append(ep_upward_volatility / denom) + supra_shares.append(ep_supra_share / denom) + supra_penalties.append(ep_supra_penalty / denom) agent_probs.append(ep_agent_prob / denom) return { @@ -99,6 +117,13 @@ def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]: "eval/coi_level_mean": float(np.mean(coi_levels)) if coi_levels else 0.0, "eval/coi_leakage_mean": float(np.mean(coi_leakages)) if coi_leakages else 0.0, "eval/volatility_mean": float(np.mean(volatilities)) if volatilities else 0.0, + "eval/upward_volatility_mean": ( + float(np.mean(upward_volatilities)) if upward_volatilities else 0.0 + ), + "eval/supra_share_mean": float(np.mean(supra_shares)) if supra_shares else 0.0, + "eval/supra_penalty_mean": ( + float(np.mean(supra_penalties)) if supra_penalties else 0.0 + ), "eval/agent_prob_mean": float(np.mean(agent_probs)) if agent_probs else 0.0, } diff --git a/engine/lib/wrappers.py b/engine/lib/wrappers.py index 4cfd706..dcb4fd1 100644 --- a/engine/lib/wrappers.py +++ b/engine/lib/wrappers.py @@ -71,6 +71,10 @@ class EconomicMetricsWrapper(gym.Wrapper): "coi_penalty", "ux_penalty", "volatility", + "upward_volatility", + "supra_penalty", + "supra_share", + "competitive_anchor", "profit", "cost_floor", "reward_revenue", diff --git a/engine/sweeps/ppo_supra_guard.yaml b/engine/sweeps/ppo_supra_guard.yaml new file mode 100644 index 0000000..05131be --- /dev/null +++ b/engine/sweeps/ppo_supra_guard.yaml @@ -0,0 +1,53 @@ +method: random +metric: + name: eval/supra_share_mean + goal: minimize +run_cap: 256 +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + value: ppo + seed: + values: [42, 1337, 7777] + alpha: + values: [0.1, 0.2, 0.3, 0.4, 0.6] + n_products: + values: [25, 50] + N: + value: 100 + no_robust: + values: [false, true] + lambda_coi: + values: [0.05, 0.15, 0.3] + robust_radius: + values: [0.1, 0.2, 0.3] + robust_points: + value: 7 + robust_rollouts: + value: 1 + eta_ux: + values: [0.05, 0.15, 0.3, 0.5, 0.75] + reward_profit_weight: + value: 1.0 + total_timesteps: + value: 100000 + eval_episodes: + value: 10 + eval_freq: + value: 1000 + log_freq: + value: 100 + hist_freq: + value: 500 + learning_rate: + value: 0.0003 + batch_size: + value: 256 + n_steps: + value: 2048 + device: + value: cpu diff --git a/engine/wrapper.py b/engine/wrapper.py index 2786780..1748617 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -216,18 +216,27 @@ class PHANTOM(gym.Env): coi_penalty = self.lambda_coi * coi_leakage * info_budget if len(self._price_history) > 0: - volatility = float( - np.mean( - np.abs(prices - self._price_history[-1]) - / np.maximum(self.baseline_prices, 1.0) - ) - ) + prev_prices = np.asarray(self._price_history[-1], dtype=float) + rel_change = (prices - prev_prices) / np.maximum(prev_prices, 1.0) + volatility = float(np.mean(np.abs(rel_change))) + upward_volatility = float(np.mean(np.clip(rel_change, 0.0, None))) else: volatility = 0.0 - ux_penalty = self.eta_ux * info_budget * volatility + upward_volatility = 0.0 + ux_penalty = self.eta_ux * info_budget * (volatility + 0.5 * upward_volatility) + + competitive_anchor = float( + np.clip(float(self.human_params[0]) * 1.2, *self.price_bounds) + ) + price_ratio = prices / max(competitive_anchor, 1.0) + supra_excess = np.clip(price_ratio - 1.0, 0.0, None) + supra_penalty = ( + 0.5 * self.eta_ux * info_budget * float(np.mean(np.square(supra_excess))) + ) + supra_share = float(np.mean(supra_excess > 0.0)) reward_revenue = self.reward_profit_weight * profit - reward = reward_revenue - coi_penalty - ux_penalty + reward = reward_revenue - coi_penalty - ux_penalty - supra_penalty return reward, { "revenue": revenue, @@ -240,6 +249,10 @@ class PHANTOM(gym.Env): "coi_info_budget": info_budget, "ux_penalty": ux_penalty, "volatility": volatility, + "upward_volatility": upward_volatility, + "supra_penalty": supra_penalty, + "supra_share": supra_share, + "competitive_anchor": competitive_anchor, "reward_revenue": reward_revenue, "reward_total": reward, } From fb09ea2b680215fe925fa0108cd631ad22b9bc8d Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Tue, 17 Mar 2026 09:47:40 +0100 Subject: [PATCH 28/35] chore: refactoring fitgures directory --- paper/src/chapters/04-results.tex | 2 +- paper/src/chapters/figures/.gitignore | 3 + .../first_sweep_headline_summary.json | 0 .../first_sweep_tier_alpha_deltas.csv | 0 .../first_sweep_tier_alpha_mode_summary.csv | 0 .../first_sweep_tier_mode_summary.csv | 0 .../{ => legacy}/first_sweep_top_configs.csv | 0 .../plots/first_sweep_tier_revenue.pdf | Bin 17510 -> 17510 bytes .../{ => legacy}/plots/ppo_alpha_curves.pdf | Bin 25398 -> 25398 bytes .../{ => legacy}/plots/ppo_delta_curves.pdf | Bin 21999 -> 21999 bytes .../plots/ppo_tradeoff_scatter.pdf | Bin 26824 -> 26824 bytes .../{ => legacy}/ppo_alpha_deltas.csv | 0 .../{ => legacy}/ppo_alpha_mode_summary.csv | 0 .../{ => legacy}/ppo_headline_summary.json | 0 .../{ => legacy}/ppo_overall_mode_summary.csv | 0 .../{ => legacy}/ppo_pairwise_win_rates.csv | 0 .../legacy}/first_sweep_tier_revenue.tex | 2 +- .../legacy}/ppo_alpha_curves.tex | 2 +- .../legacy}/ppo_delta_curves.tex | 2 +- .../legacy}/ppo_tradeoff_scatter.tex | 2 +- .../chapters/figures/results/plot_results.py | 2 +- .../figures/results/plot_wandb_export.py | 658 ++++++++++++++++++ .../figures/results/process_all_results.py | 2 +- .../figures/results/process_first_sweep.py | 2 +- .../figures/results/process_ppo_benchmark.py | 2 +- .../figures/{ => supra}/process_supra.py | 0 .../chapters/figures/{ => supra}/supra.csv | 0 .../chapters/figures/{ => supra}/supra.tex | 2 +- .../figures/{ => supra}/supra_data.csv | 1 - paper/src/mirrors/genpop/04-results.tex | 2 +- 30 files changed, 672 insertions(+), 12 deletions(-) create mode 100644 paper/src/chapters/figures/.gitignore rename paper/src/chapters/figures/results/generated/{ => legacy}/first_sweep_headline_summary.json (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/first_sweep_tier_alpha_deltas.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/first_sweep_tier_alpha_mode_summary.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/first_sweep_tier_mode_summary.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/first_sweep_top_configs.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/plots/first_sweep_tier_revenue.pdf (99%) rename paper/src/chapters/figures/results/generated/{ => legacy}/plots/ppo_alpha_curves.pdf (99%) rename paper/src/chapters/figures/results/generated/{ => legacy}/plots/ppo_delta_curves.pdf (99%) rename paper/src/chapters/figures/results/generated/{ => legacy}/plots/ppo_tradeoff_scatter.pdf (99%) rename paper/src/chapters/figures/results/generated/{ => legacy}/ppo_alpha_deltas.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/ppo_alpha_mode_summary.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/ppo_headline_summary.json (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/ppo_overall_mode_summary.csv (100%) rename paper/src/chapters/figures/results/generated/{ => legacy}/ppo_pairwise_win_rates.csv (100%) rename paper/src/chapters/figures/results/{ => includes/legacy}/first_sweep_tier_revenue.tex (54%) rename paper/src/chapters/figures/results/{ => includes/legacy}/ppo_alpha_curves.tex (58%) rename paper/src/chapters/figures/results/{ => includes/legacy}/ppo_delta_curves.tex (58%) rename paper/src/chapters/figures/results/{ => includes/legacy}/ppo_tradeoff_scatter.tex (56%) create mode 100644 paper/src/chapters/figures/results/plot_wandb_export.py rename paper/src/chapters/figures/{ => supra}/process_supra.py (100%) rename paper/src/chapters/figures/{ => supra}/supra.csv (100%) rename paper/src/chapters/figures/{ => supra}/supra.tex (95%) rename paper/src/chapters/figures/{ => supra}/supra_data.csv (99%) diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index 725dc7f..3a26d49 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -1,7 +1,7 @@ \section{Results} \begin{figure}[ht] \centering - \input{chapters/figures/supra.tex} + \input{chapters/figures/supra/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} diff --git a/paper/src/chapters/figures/.gitignore b/paper/src/chapters/figures/.gitignore new file mode 100644 index 0000000..a467b0f --- /dev/null +++ b/paper/src/chapters/figures/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pdf-view-restore diff --git a/paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json b/paper/src/chapters/figures/results/generated/legacy/first_sweep_headline_summary.json similarity index 100% rename from paper/src/chapters/figures/results/generated/first_sweep_headline_summary.json rename to paper/src/chapters/figures/results/generated/legacy/first_sweep_headline_summary.json diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv b/paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_alpha_deltas.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_deltas.csv rename to paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_alpha_deltas.csv diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv b/paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_alpha_mode_summary.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/first_sweep_tier_alpha_mode_summary.csv rename to paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_alpha_mode_summary.csv diff --git a/paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv b/paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_mode_summary.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/first_sweep_tier_mode_summary.csv rename to paper/src/chapters/figures/results/generated/legacy/first_sweep_tier_mode_summary.csv diff --git a/paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv b/paper/src/chapters/figures/results/generated/legacy/first_sweep_top_configs.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/first_sweep_top_configs.csv rename to paper/src/chapters/figures/results/generated/legacy/first_sweep_top_configs.csv diff --git a/paper/src/chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf b/paper/src/chapters/figures/results/generated/legacy/plots/first_sweep_tier_revenue.pdf similarity index 99% rename from paper/src/chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf rename to paper/src/chapters/figures/results/generated/legacy/plots/first_sweep_tier_revenue.pdf index a019aee4615d177aa840da1f218351311de822a5..a2787265ff4344842a889b0c132a1c1d8850dad5 100644 GIT binary patch delta 22 dcmaFX!T79$aYKncyP2Vxp^1UX=4$)bEC6H#2nzrJ delta 22 dcmaFX!T79$aYKncyRo6UiLrs<=4$)bEC6Hc2nYZG diff --git a/paper/src/chapters/figures/results/generated/plots/ppo_alpha_curves.pdf b/paper/src/chapters/figures/results/generated/legacy/plots/ppo_alpha_curves.pdf similarity index 99% rename from paper/src/chapters/figures/results/generated/plots/ppo_alpha_curves.pdf rename to paper/src/chapters/figures/results/generated/legacy/plots/ppo_alpha_curves.pdf index d012154f84dbfb68caa05c7cc37a6838173f4046..5f97b1992d0db7f8a8024fa2593ff3a245335a3d 100644 GIT binary patch delta 22 dcmdmXjB(pB#tkg7>}G~$h9(Bao4I4P*#KSe2E+gW delta 22 dcmdmXjB(pB#tkg7?8b)XCdLK^o4I4P*#KSF2EhOT diff --git a/paper/src/chapters/figures/results/generated/plots/ppo_delta_curves.pdf b/paper/src/chapters/figures/results/generated/legacy/plots/ppo_delta_curves.pdf similarity index 99% rename from paper/src/chapters/figures/results/generated/plots/ppo_delta_curves.pdf rename to paper/src/chapters/figures/results/generated/legacy/plots/ppo_delta_curves.pdf index b42b144edcb7a305ace5ec7969202ffb189d6e5b..3f1be83f7e5146a7acb95650835b1a1a6b6467d8 100644 GIT binary patch delta 22 ecmaFAn(_T=#tn)A>}G~$h9(Ban>7NCvjPBRbqBiu delta 22 ecmaFAn(_T=#tn)A?8b)XCdLK^n>7NCvjPBRTnD%S diff --git a/paper/src/chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf b/paper/src/chapters/figures/results/generated/legacy/plots/ppo_tradeoff_scatter.pdf similarity index 99% rename from paper/src/chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf rename to paper/src/chapters/figures/results/generated/legacy/plots/ppo_tradeoff_scatter.pdf index 34da3accf8ed857cd42c0291a2e41dcfd8af8299..49ecc666eeae00fdde39920b4c894d02b6872c53 100644 GIT binary patch delta 22 ecmX?ck@3Vu#tm{w>}G~$h9(Ban^lt*vjG5QvIn#P delta 22 ecmX?ck@3Vu#tm{w?8b)XCdLMan^lt*vjG5Qp9iu4 diff --git a/paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv b/paper/src/chapters/figures/results/generated/legacy/ppo_alpha_deltas.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/ppo_alpha_deltas.csv rename to paper/src/chapters/figures/results/generated/legacy/ppo_alpha_deltas.csv diff --git a/paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv b/paper/src/chapters/figures/results/generated/legacy/ppo_alpha_mode_summary.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/ppo_alpha_mode_summary.csv rename to paper/src/chapters/figures/results/generated/legacy/ppo_alpha_mode_summary.csv diff --git a/paper/src/chapters/figures/results/generated/ppo_headline_summary.json b/paper/src/chapters/figures/results/generated/legacy/ppo_headline_summary.json similarity index 100% rename from paper/src/chapters/figures/results/generated/ppo_headline_summary.json rename to paper/src/chapters/figures/results/generated/legacy/ppo_headline_summary.json diff --git a/paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv b/paper/src/chapters/figures/results/generated/legacy/ppo_overall_mode_summary.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/ppo_overall_mode_summary.csv rename to paper/src/chapters/figures/results/generated/legacy/ppo_overall_mode_summary.csv diff --git a/paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv b/paper/src/chapters/figures/results/generated/legacy/ppo_pairwise_win_rates.csv similarity index 100% rename from paper/src/chapters/figures/results/generated/ppo_pairwise_win_rates.csv rename to paper/src/chapters/figures/results/generated/legacy/ppo_pairwise_win_rates.csv diff --git a/paper/src/chapters/figures/results/first_sweep_tier_revenue.tex b/paper/src/chapters/figures/results/includes/legacy/first_sweep_tier_revenue.tex similarity index 54% rename from paper/src/chapters/figures/results/first_sweep_tier_revenue.tex rename to paper/src/chapters/figures/results/includes/legacy/first_sweep_tier_revenue.tex index f319a81..52a61b4 100644 --- a/paper/src/chapters/figures/results/first_sweep_tier_revenue.tex +++ b/paper/src/chapters/figures/results/includes/legacy/first_sweep_tier_revenue.tex @@ -1 +1 @@ -\includegraphics[width=0.99\linewidth]{chapters/figures/results/generated/plots/first_sweep_tier_revenue.pdf} +\includegraphics[width=0.99\linewidth]{chapters/figures/results/generated/legacy/plots/first_sweep_tier_revenue.pdf} diff --git a/paper/src/chapters/figures/results/ppo_alpha_curves.tex b/paper/src/chapters/figures/results/includes/legacy/ppo_alpha_curves.tex similarity index 58% rename from paper/src/chapters/figures/results/ppo_alpha_curves.tex rename to paper/src/chapters/figures/results/includes/legacy/ppo_alpha_curves.tex index 2496584..b4f6618 100644 --- a/paper/src/chapters/figures/results/ppo_alpha_curves.tex +++ b/paper/src/chapters/figures/results/includes/legacy/ppo_alpha_curves.tex @@ -1 +1 @@ -\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/plots/ppo_alpha_curves.pdf} +\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/legacy/plots/ppo_alpha_curves.pdf} diff --git a/paper/src/chapters/figures/results/ppo_delta_curves.tex b/paper/src/chapters/figures/results/includes/legacy/ppo_delta_curves.tex similarity index 58% rename from paper/src/chapters/figures/results/ppo_delta_curves.tex rename to paper/src/chapters/figures/results/includes/legacy/ppo_delta_curves.tex index 8c4eec0..2b37f92 100644 --- a/paper/src/chapters/figures/results/ppo_delta_curves.tex +++ b/paper/src/chapters/figures/results/includes/legacy/ppo_delta_curves.tex @@ -1 +1 @@ -\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/plots/ppo_delta_curves.pdf} +\includegraphics[width=0.98\linewidth]{chapters/figures/results/generated/legacy/plots/ppo_delta_curves.pdf} diff --git a/paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex b/paper/src/chapters/figures/results/includes/legacy/ppo_tradeoff_scatter.tex similarity index 56% rename from paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex rename to paper/src/chapters/figures/results/includes/legacy/ppo_tradeoff_scatter.tex index 0117970..7b795d1 100644 --- a/paper/src/chapters/figures/results/ppo_tradeoff_scatter.tex +++ b/paper/src/chapters/figures/results/includes/legacy/ppo_tradeoff_scatter.tex @@ -1 +1 @@ -\includegraphics[width=0.88\linewidth]{chapters/figures/results/generated/plots/ppo_tradeoff_scatter.pdf} +\includegraphics[width=0.88\linewidth]{chapters/figures/results/generated/legacy/plots/ppo_tradeoff_scatter.pdf} diff --git a/paper/src/chapters/figures/results/plot_results.py b/paper/src/chapters/figures/results/plot_results.py index 0b80926..0476948 100644 --- a/paper/src/chapters/figures/results/plot_results.py +++ b/paper/src/chapters/figures/results/plot_results.py @@ -16,7 +16,7 @@ from process_ppo_benchmark import run as run_ppo_benchmark def _output_dir() -> Path: - return Path(__file__).resolve().parent / "generated" + return Path(__file__).resolve().parent / "generated" / "legacy" def _plot_dir() -> Path: diff --git a/paper/src/chapters/figures/results/plot_wandb_export.py b/paper/src/chapters/figures/results/plot_wandb_export.py new file mode 100644 index 0000000..cda7faf --- /dev/null +++ b/paper/src/chapters/figures/results/plot_wandb_export.py @@ -0,0 +1,658 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Iterable + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.ticker import FuncFormatter +import numpy as np +import pandas as pd + + +def _load_tikzplotlib(): + def _patch_webcolors() -> None: + try: + import webcolors + + if hasattr(webcolors, "CSS3_HEX_TO_NAMES"): + return + css3 = getattr(webcolors, "CSS3", "css3") + webcolors.CSS3_HEX_TO_NAMES = { + webcolors.name_to_hex(name, spec=css3): name + for name in webcolors.names(spec=css3) + } + except Exception: + return + + _patch_webcolors() + + try: + from matplotlib.legend import Legend + + if not hasattr(Legend, "_ncol") and hasattr(Legend, "_ncols"): + Legend._ncol = property(lambda self: self._ncols) + except Exception: + pass + + try: + import tikzplotlib as module + + return module, None + except Exception: + pass + + try: + from matplotlib.backends import backend_pgf + + if not hasattr(backend_pgf, "common_texification") and hasattr( + backend_pgf, "_tex_escape" + ): + backend_pgf.common_texification = backend_pgf._tex_escape + + _patch_webcolors() + + import tikzplotlib as module + + return module, None + except Exception as exc: + return None, exc + + +TIKZPLOTLIB, TIKZPLOTLIB_IMPORT_ERROR = _load_tikzplotlib() + + +def _default_output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" / "wandb" + + +def _default_plot_dir(output_dir: Path) -> Path: + return output_dir / "plots" + + +def _sanitize(key: str) -> str: + return key.replace("/", "_").replace("-", "_") + + +def _configure_style() -> None: + plt.rcParams.update( + { + "font.family": "serif", + "font.size": 10, + "axes.titlesize": 10, + "axes.labelsize": 9, + "legend.fontsize": 8, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "figure.dpi": 220, + "savefig.dpi": 320, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.alpha": 0.22, + } + ) + + +def _fmt_thousands(value: float, _: int) -> str: + return f"{int(value):,}" + + +def _coerce_numeric(frame: pd.DataFrame, columns: Iterable[str]) -> None: + for column in columns: + if column in frame.columns: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + + +def _extract_alpha(frame: pd.DataFrame) -> pd.Series: + if "study/alpha" in frame.columns: + return pd.to_numeric(frame["study/alpha"], errors="coerce") + if "alpha" in frame.columns: + return pd.to_numeric(frame["alpha"], errors="coerce") + return pd.Series(np.nan, index=frame.index, dtype=float) + + +def _extract_mode(frame: pd.DataFrame) -> pd.Series: + if "study/mode" in frame.columns: + mode = frame["study/mode"].astype(str).str.strip().str.lower() + mapping = { + "baseline": "baseline", + "no_robust": "baseline", + "defended": "defended", + "robust": "defended", + } + return mode.map(mapping).fillna("") + + if "study/no_robust" in frame.columns: + no_robust = pd.to_numeric(frame["study/no_robust"], errors="coerce").fillna(0.0) + return pd.Series( + np.where(no_robust > 0.5, "baseline", "defended"), + index=frame.index, + dtype="object", + ) + + if "no_robust" in frame.columns: + no_robust = ( + frame["no_robust"].astype(str).str.lower().isin({"1", "true", "yes"}) + ) + return pd.Series( + np.where(no_robust, "baseline", "defended"), + index=frame.index, + dtype="object", + ) + + return pd.Series("", index=frame.index, dtype="object") + + +def _prepare_frame(frame: pd.DataFrame, include_non_finished: bool) -> pd.DataFrame: + data = frame.copy() + if not include_non_finished and "State" in data.columns: + data = data[data["State"].astype(str).str.lower() == "finished"].copy() + + data["alpha"] = _extract_alpha(data) + data["mode"] = _extract_mode(data) + data = data[data["mode"].isin({"baseline", "defended"})] + data = data[data["alpha"].notna()] + + _coerce_numeric( + data, + [ + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/volatility_mean", + "eval/revenue_std", + "eval/reward_std", + "eval/margin_mean", + "train/agent_prob", + "train/alpha_adv", + "lambda_coi", + "ambiguity_radius", + "n_products", + ], + ) + + return data.sort_values(["alpha", "mode"]).reset_index(drop=True) + + +def _summary_by_alpha_mode(frame: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + agg_spec: dict[str, tuple[str, str]] = {"runs": ("mode", "size")} + for metric in metrics: + safe = _sanitize(metric) + agg_spec[f"{safe}_mean"] = (metric, "mean") + agg_spec[f"{safe}_std"] = (metric, "std") + + return ( + frame.groupby(["alpha", "mode"], as_index=False) + .agg(**agg_spec) + .sort_values(["alpha", "mode"]) + .reset_index(drop=True) + ) + + +def _delta_by_alpha(summary: pd.DataFrame, metrics: list[str]) -> pd.DataFrame: + rows: list[dict[str, float]] = [] + for alpha, alpha_group in summary.groupby("alpha", sort=True): + defended = alpha_group[alpha_group["mode"] == "defended"] + baseline = alpha_group[alpha_group["mode"] == "baseline"] + if defended.empty or baseline.empty: + continue + + row: dict[str, float] = { + "alpha": float(alpha), + "runs_defended": float(defended["runs"].iloc[0]), + "runs_baseline": float(baseline["runs"].iloc[0]), + } + for metric in metrics: + safe = _sanitize(metric) + defended_value = float(defended[f"{safe}_mean"].iloc[0]) + baseline_value = float(baseline[f"{safe}_mean"].iloc[0]) + delta = defended_value - baseline_value + row[f"{safe}_defended"] = defended_value + row[f"{safe}_baseline"] = baseline_value + row[f"{safe}_delta"] = delta + row[f"{safe}_delta_pct"] = ( + np.nan if baseline_value == 0 else 100.0 * delta / baseline_value + ) + rows.append(row) + + return pd.DataFrame(rows) + + +def _summary_by_parameter( + frame: pd.DataFrame, parameter: str, metrics: list[str] +) -> pd.DataFrame: + defended = frame[frame["mode"] == "defended"].copy() + defended = defended[defended[parameter].notna()].copy() + agg_spec: dict[str, tuple[str, str]] = {"runs": ("mode", "size")} + for metric in metrics: + safe = _sanitize(metric) + agg_spec[f"{safe}_mean"] = (metric, "mean") + agg_spec[f"{safe}_std"] = (metric, "std") + + return ( + defended.groupby(["alpha", parameter], as_index=False) + .agg(**agg_spec) + .sort_values(["alpha", parameter]) + .reset_index(drop=True) + ) + + +def _save_table(frame: pd.DataFrame, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(path, index=False) + return path + + +def _save_figure(fig: plt.Figure, pdf_path: Path, export_tikz: bool) -> list[Path]: + pdf_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(pdf_path, bbox_inches="tight") + written = [pdf_path] + + if export_tikz: + if TIKZPLOTLIB is None: + raise RuntimeError( + "tikzplotlib import failed. Install/upgrade tikzplotlib and matplotlib-compatible dependencies. " + f"Original error: {TIKZPLOTLIB_IMPORT_ERROR}" + ) + + try: + from matplotlib.legend import Legend + from matplotlib.lines import Line2D + + for legend in fig.findobj(Legend): + if not hasattr(legend, "_ncol") and hasattr(legend, "_ncols"): + setattr(legend, "_ncol", legend._ncols) + if not hasattr(legend, "legendHandles") and hasattr( + legend, "legend_handles" + ): + setattr(legend, "legendHandles", legend.legend_handles) + + for line in fig.findobj(Line2D): + if hasattr(line, "_us_dashSeq"): + continue + if not hasattr(line, "_dash_pattern"): + continue + dash_pattern = getattr(line, "_dash_pattern") + if not isinstance(dash_pattern, tuple) or len(dash_pattern) != 2: + continue + setattr(line, "_us_dashOffset", dash_pattern[0]) + setattr(line, "_us_dashSeq", dash_pattern[1]) + except Exception: + pass + + tikz_path = pdf_path.with_suffix(".tikz.tex") + TIKZPLOTLIB.save(str(tikz_path), figure=fig) + written.append(tikz_path) + + plt.close(fig) + return written + + +def _plot_alpha_curves( + alpha_mode: pd.DataFrame, out_dir: Path, export_tikz: bool +) -> list[Path]: + fig, axes = plt.subplots(2, 2, figsize=(9.3, 6.4), constrained_layout=True) + mode_colors = {"baseline": "#4C72B0", "defended": "#C44E52"} + mode_labels = {"baseline": "Baseline", "defended": "Defended"} + + panels = [ + ("eval_revenue_mean", "Mean Episode Revenue", "Revenue"), + ("eval_reward_mean", "Mean Episode Reward", "Reward"), + ("eval_coi_leakage_mean", "Mean COI Leakage", "COI Leakage"), + ("eval_volatility_mean", "Mean Price Volatility", "Volatility"), + ] + + for ax, (metric_prefix, title, ylabel) in zip(axes.flat, panels): + mean_col = f"{metric_prefix}_mean" + std_col = f"{metric_prefix}_std" + for mode in ("baseline", "defended"): + sub = alpha_mode[alpha_mode["mode"] == mode].sort_values("alpha") + if sub.empty: + continue + x = sub["alpha"].to_numpy(dtype=float) + y = sub[mean_col].to_numpy(dtype=float) + ax.plot( + x, + y, + marker="o", + linewidth=1.8, + markersize=4, + color=mode_colors[mode], + label=mode_labels[mode], + ) + sigma = sub[std_col].fillna(0.0).to_numpy(dtype=float) + ax.fill_between( + x, + y - sigma, + y + sigma, + color=mode_colors[mode], + alpha=0.14, + linewidth=0, + ) + + ax.set_title(title) + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel(ylabel) + ax.set_xticks(sorted(alpha_mode["alpha"].unique())) + if metric_prefix in {"eval_revenue_mean", "eval_reward_mean"}: + ax.yaxis.set_major_formatter(FuncFormatter(_fmt_thousands)) + + handles, labels = axes.flat[0].get_legend_handles_labels() + fig.legend(handles, labels, ncol=2, loc="upper center", bbox_to_anchor=(0.5, 1.02)) + return _save_figure(fig, out_dir / "wandb_alpha_curves.pdf", export_tikz) + + +def _plot_delta_curves( + deltas: pd.DataFrame, out_dir: Path, export_tikz: bool +) -> list[Path]: + fig, axes = plt.subplots(2, 1, figsize=(8.6, 6.0), constrained_layout=True) + deltas = deltas.sort_values("alpha") + x = deltas["alpha"].to_numpy(dtype=float) + + top_metrics = [ + ("eval_revenue_mean_delta_pct", "Revenue", "#4C72B0"), + ("eval_reward_mean_delta_pct", "Reward", "#8172B3"), + ] + for col, label, color in top_metrics: + axes[0].plot( + x, + deltas[col].to_numpy(dtype=float), + marker="o", + linewidth=1.8, + markersize=4, + color=color, + label=label, + ) + axes[0].axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + axes[0].set_title("Defended Minus Baseline Delta by Contamination") + axes[0].set_ylabel("Delta (%)") + axes[0].set_xlabel(r"Contamination $\alpha$") + axes[0].set_xticks(x) + axes[0].legend(loc="lower left") + + bottom_metrics = [ + ("eval_coi_leakage_mean_delta_pct", "COI Leakage", "#55A868"), + ("eval_volatility_mean_delta_pct", "Volatility", "#DD8452"), + ] + for col, label, color in bottom_metrics: + axes[1].plot( + x, + deltas[col].to_numpy(dtype=float), + marker="o", + linewidth=1.8, + markersize=4, + color=color, + label=label, + ) + axes[1].axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + axes[1].set_ylabel("Delta (%)") + axes[1].set_xlabel(r"Contamination $\alpha$") + axes[1].set_xticks(x) + axes[1].legend(loc="lower left") + + return _save_figure(fig, out_dir / "wandb_delta_curves.pdf", export_tikz) + + +def _plot_tradeoff_scatter( + deltas: pd.DataFrame, out_dir: Path, export_tikz: bool +) -> list[Path]: + fig, ax = plt.subplots(figsize=(6.4, 5.2), constrained_layout=True) + data = deltas.sort_values("alpha") + x = data["eval_coi_leakage_mean_delta_pct"].to_numpy(dtype=float) + y = data["eval_revenue_mean_delta_pct"].to_numpy(dtype=float) + alphas = data["alpha"].to_numpy(dtype=float) + + scatter = ax.scatter( + x, + y, + c=alphas, + cmap="viridis", + s=72, + edgecolor="#222222", + linewidth=0.5, + ) + for x_i, y_i, alpha in zip(x, y, alphas): + ax.annotate( + rf"$\alpha={alpha:.2f}$", + (x_i, y_i), + textcoords="offset points", + xytext=(5, 4), + fontsize=8, + ) + + ax.axhline(0.0, color="#555555", linewidth=1.0, linestyle="--") + ax.axvline(0.0, color="#555555", linewidth=1.0, linestyle="--") + ax.set_xlabel("COI Leakage Delta (%)") + ax.set_ylabel("Revenue Delta (%)") + ax.set_title("Defended Tradeoff Frontier") + cbar = fig.colorbar(scatter, ax=ax) + cbar.set_label(r"Contamination $\alpha$") + + return _save_figure(fig, out_dir / "wandb_tradeoff_scatter.pdf", export_tikz) + + +def _plot_reward_robustness( + alpha_mode: pd.DataFrame, out_dir: Path, export_tikz: bool +) -> list[Path]: + fig, ax = plt.subplots(figsize=(7.6, 4.5), constrained_layout=True) + mode_colors = {"baseline": "#4C72B0", "defended": "#C44E52"} + mode_labels = {"baseline": "Baseline", "defended": "Defended"} + + for mode in ("baseline", "defended"): + sub = alpha_mode[alpha_mode["mode"] == mode].sort_values("alpha") + x = sub["alpha"].to_numpy(dtype=float) + y = sub["eval_reward_mean_std"].fillna(0.0).to_numpy(dtype=float) + ax.plot( + x, + y, + marker="o", + linewidth=1.8, + markersize=4, + color=mode_colors[mode], + label=mode_labels[mode], + ) + + ax.set_title("Reward Robustness Across Contamination") + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel("Reward Std Across Runs") + ax.set_xticks(sorted(alpha_mode["alpha"].unique())) + ax.yaxis.set_major_formatter(FuncFormatter(_fmt_thousands)) + ax.legend(loc="upper left") + return _save_figure(fig, out_dir / "wandb_reward_robustness.pdf", export_tikz) + + +def _plot_parameter_sensitivity( + summary: pd.DataFrame, + parameter: str, + out_name: str, + out_dir: Path, + export_tikz: bool, +) -> list[Path]: + fig, axes = plt.subplots(1, 2, figsize=(10.0, 4.2), constrained_layout=True) + values = sorted(summary[parameter].dropna().unique()) + cmap = plt.get_cmap("viridis") + colors = [cmap(i) for i in np.linspace(0.1, 0.9, len(values))] + + panels = [ + ("eval_revenue_mean", "Revenue"), + ("eval_coi_leakage_mean", "COI Leakage"), + ] + for ax, (metric_prefix, ylabel) in zip(axes, panels): + mean_col = f"{metric_prefix}_mean" + std_col = f"{metric_prefix}_std" + for value, color in zip(values, colors): + sub = summary[summary[parameter] == value].sort_values("alpha") + if sub.empty: + continue + x = sub["alpha"].to_numpy(dtype=float) + y = sub[mean_col].to_numpy(dtype=float) + sigma = sub[std_col].fillna(0.0).to_numpy(dtype=float) + ax.plot( + x, + y, + marker="o", + linewidth=1.6, + markersize=3.6, + color=color, + label=f"{parameter}={value:.2f}", + ) + ax.fill_between( + x, y - sigma, y + sigma, color=color, alpha=0.10, linewidth=0 + ) + + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel(ylabel) + ax.set_xticks(sorted(summary["alpha"].unique())) + if metric_prefix == "eval_revenue_mean": + ax.yaxis.set_major_formatter(FuncFormatter(_fmt_thousands)) + + axes[0].set_title(f"{parameter} Sensitivity (Defended)") + axes[1].set_title("Leakage Side-Effect") + handles, labels = axes[0].get_legend_handles_labels() + fig.legend( + handles, + labels, + ncol=max(1, len(values) // 2), + loc="upper center", + bbox_to_anchor=(0.5, 1.06), + ) + + return _save_figure(fig, out_dir / f"{out_name}.pdf", export_tikz) + + +def _plot_delta_summary( + deltas: pd.DataFrame, out_dir: Path, export_tikz: bool +) -> list[Path]: + data = deltas.sort_values("alpha") + x = np.arange(len(data)) + labels = [f"{alpha:.1f}" for alpha in data["alpha"].to_numpy(dtype=float)] + + fig, axes = plt.subplots(1, 3, figsize=(11.0, 3.8), constrained_layout=True) + panels = [ + ("eval_revenue_mean_delta_pct", "Revenue Delta (%)", "#4C72B0"), + ("eval_reward_mean_delta_pct", "Reward Delta (%)", "#8172B3"), + ("eval_coi_leakage_mean_delta_pct", "COI Leakage Delta (%)", "#55A868"), + ] + for ax, (column, title, color) in zip(axes, panels): + values = data[column].to_numpy(dtype=float) + ax.bar(x, values, color=color, alpha=0.85) + ax.axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + ax.set_xticks(x) + ax.set_xticklabels(labels) + ax.set_xlabel(r"$\alpha$") + ax.set_title(title) + + return _save_figure(fig, out_dir / "wandb_delta_summary.pdf", export_tikz) + + +def build_artifacts( + input_path: Path, + output_dir: Path, + plot_dir: Path, + include_non_finished: bool, + export_tikz: bool, +) -> list[Path]: + raw = pd.read_csv(input_path) + frame = _prepare_frame(raw, include_non_finished=include_non_finished) + + metrics = [ + metric + for metric in ( + "eval/revenue_mean", + "eval/reward_mean", + "eval/coi_level_mean", + "eval/coi_leakage_mean", + "eval/volatility_mean", + "eval/margin_mean", + "train/agent_prob", + "train/alpha_adv", + ) + if metric in frame.columns + ] + + alpha_mode = _summary_by_alpha_mode(frame, metrics) + deltas = _delta_by_alpha(alpha_mode, metrics) + lambda_summary = _summary_by_parameter(frame, "lambda_coi", metrics) + radius_summary = _summary_by_parameter(frame, "ambiguity_radius", metrics) + + output_dir.mkdir(parents=True, exist_ok=True) + plot_dir.mkdir(parents=True, exist_ok=True) + + written: list[Path] = [] + written.append(_save_table(alpha_mode, output_dir / "wandb_alpha_mode_summary.csv")) + written.append(_save_table(deltas, output_dir / "wandb_alpha_deltas.csv")) + written.append( + _save_table(lambda_summary, output_dir / "wandb_lambda_alpha_summary.csv") + ) + written.append( + _save_table(radius_summary, output_dir / "wandb_radius_alpha_summary.csv") + ) + + written.extend(_plot_alpha_curves(alpha_mode, plot_dir, export_tikz)) + written.extend(_plot_delta_curves(deltas, plot_dir, export_tikz)) + written.extend(_plot_tradeoff_scatter(deltas, plot_dir, export_tikz)) + written.extend(_plot_reward_robustness(alpha_mode, plot_dir, export_tikz)) + written.extend( + _plot_parameter_sensitivity( + summary=lambda_summary, + parameter="lambda_coi", + out_name="wandb_lambda_sensitivity", + out_dir=plot_dir, + export_tikz=export_tikz, + ) + ) + written.extend( + _plot_parameter_sensitivity( + summary=radius_summary, + parameter="ambiguity_radius", + out_name="wandb_radius_sensitivity", + out_dir=plot_dir, + export_tikz=export_tikz, + ) + ) + written.extend(_plot_delta_summary(deltas, plot_dir, export_tikz)) + return written + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate W&B sweep visualizations for PHANTOM results" + ) + parser.add_argument( + "--input", type=Path, required=True, help="Path to W&B export CSV" + ) + parser.add_argument("--output-dir", type=Path, default=_default_output_dir()) + parser.add_argument("--plot-dir", type=Path, default=None) + parser.add_argument("--include-non-finished", action="store_true") + parser.add_argument( + "--export-tikz", + action="store_true", + help="Export matplotlib figures to TikZ via tikzplotlib", + ) + args = parser.parse_args() + + _configure_style() + plot_dir = ( + args.plot_dir + if args.plot_dir is not None + else _default_plot_dir(args.output_dir) + ) + + outputs = build_artifacts( + input_path=args.input, + output_dir=args.output_dir, + plot_dir=plot_dir, + include_non_finished=bool(args.include_non_finished), + export_tikz=bool(args.export_tikz), + ) + for path in outputs: + print(path) + + +if __name__ == "__main__": + main() diff --git a/paper/src/chapters/figures/results/process_all_results.py b/paper/src/chapters/figures/results/process_all_results.py index 2dc2a4d..78ca65f 100644 --- a/paper/src/chapters/figures/results/process_all_results.py +++ b/paper/src/chapters/figures/results/process_all_results.py @@ -8,7 +8,7 @@ from process_ppo_benchmark import run as run_ppo_benchmark def _default_output_dir() -> Path: - return Path(__file__).resolve().parent / "generated" + return Path(__file__).resolve().parent / "generated" / "legacy" def main() -> None: diff --git a/paper/src/chapters/figures/results/process_first_sweep.py b/paper/src/chapters/figures/results/process_first_sweep.py index fd650b1..0e62525 100644 --- a/paper/src/chapters/figures/results/process_first_sweep.py +++ b/paper/src/chapters/figures/results/process_first_sweep.py @@ -18,7 +18,7 @@ def _default_input() -> Path: def _default_output_dir() -> Path: - return Path(__file__).resolve().parent / "generated" + return Path(__file__).resolve().parent / "generated" / "legacy" def _sanitize(key: str) -> str: diff --git a/paper/src/chapters/figures/results/process_ppo_benchmark.py b/paper/src/chapters/figures/results/process_ppo_benchmark.py index dbced6a..85f48b2 100644 --- a/paper/src/chapters/figures/results/process_ppo_benchmark.py +++ b/paper/src/chapters/figures/results/process_ppo_benchmark.py @@ -18,7 +18,7 @@ def _default_input() -> Path: def _default_output_dir() -> Path: - return Path(__file__).resolve().parent / "generated" + return Path(__file__).resolve().parent / "generated" / "legacy" def _sanitize(key: str) -> str: diff --git a/paper/src/chapters/figures/process_supra.py b/paper/src/chapters/figures/supra/process_supra.py similarity index 100% rename from paper/src/chapters/figures/process_supra.py rename to paper/src/chapters/figures/supra/process_supra.py diff --git a/paper/src/chapters/figures/supra.csv b/paper/src/chapters/figures/supra/supra.csv similarity index 100% rename from paper/src/chapters/figures/supra.csv rename to paper/src/chapters/figures/supra/supra.csv diff --git a/paper/src/chapters/figures/supra.tex b/paper/src/chapters/figures/supra/supra.tex similarity index 95% rename from paper/src/chapters/figures/supra.tex rename to paper/src/chapters/figures/supra/supra.tex index 290a2a1..9e815b7 100644 --- a/paper/src/chapters/figures/supra.tex +++ b/paper/src/chapters/figures/supra/supra.tex @@ -21,7 +21,7 @@ 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}; + ] table [col sep=comma, x=step, y=price, z=density] {chapters/figures/supra/supra_data.csv}; \end{axis} \end{tikzpicture} diff --git a/paper/src/chapters/figures/supra_data.csv b/paper/src/chapters/figures/supra/supra_data.csv similarity index 99% rename from paper/src/chapters/figures/supra_data.csv rename to paper/src/chapters/figures/supra/supra_data.csv index f005217..6216cac 100644 --- a/paper/src/chapters/figures/supra_data.csv +++ b/paper/src/chapters/figures/supra/supra_data.csv @@ -4038,4 +4038,3 @@ step,price,density 4000,146.51098761558535,0.0 4000,147.9065925693512,0.0 4000,149.30219752311706,10.0 - diff --git a/paper/src/mirrors/genpop/04-results.tex b/paper/src/mirrors/genpop/04-results.tex index 5950b35..af37e8c 100644 --- a/paper/src/mirrors/genpop/04-results.tex +++ b/paper/src/mirrors/genpop/04-results.tex @@ -1,7 +1,7 @@ \section{Results} \begin{figure}[ht] \centering - \input{chapters/figures/supra.tex} + \input{chapters/figures/supra/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} From ee26954faeecc821d1caf5b5b4e64f7d2169e039 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 18 Mar 2026 11:39:38 +0100 Subject: [PATCH 29/35] finishing finish conclision --- paper/src/chapters/04-results.tex | 55 ++++++++++++++++--------- paper/src/chapters/05-discussion.tex | 4 +- paper/src/chapters/06-conclusion.tex | 4 +- paper/src/mirrors/genpop/04-results.tex | 4 +- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/paper/src/chapters/04-results.tex b/paper/src/chapters/04-results.tex index 3a26d49..f1e4f56 100644 --- a/paper/src/chapters/04-results.tex +++ b/paper/src/chapters/04-results.tex @@ -33,39 +33,54 @@ The sign structure is consistent with the theoretical expectation: human session \subsection{Experimental Outcomes} -To evaluate robustness contributions, we compare two policies on the same environment family: (i) robust pricing with COI-aware reward and adversarial contamination step, and (ii) non-robust baseline with revenue-only reward (\texttt{--no-robust}). +To evaluate robustness contributions, we compare two policies on the same environment family: (i) robust pricing with COI-aware reward and adversarial contamination step, and (ii) a baseline policy with revenue-only reward. We report two preliminary stages before the full factorial interpretation. First, we executed a short calibration run at $\alpha=0.3$ (2 evaluation episodes, 3000 training timesteps per tier) across \texttt{qtable}, \texttt{ppo}, \texttt{a2c}, and \texttt{dqn}. In that first run, \texttt{ppo} produced the highest objective score and revenue (objective $=3.76\mathrm{e}5$, revenue $=4.15\mathrm{e}5$), while the remaining tiers stayed lower in this small-budget regime. The corresponding price traces show a monotone escalation for \texttt{ppo} (mean price from $8.61\mathrm{e}1$ to $1.49\mathrm{e}2$), whereas \texttt{qtable}, \texttt{a2c}, and \texttt{dqn} remained nearly flat over the episode horizon. This confirms that the simulation loop is able to express policy-dependent pricing dynamics rather than collapsing into a single trajectory shape. -Second, we launched an overnight paired benchmark over $\alpha \in \{0.00,0.15,0.30,0.45,0.60\}$ with 8 evaluation episodes and 8000 timesteps, comparing robust and non-robust settings at fixed seed/tier/contamination tuples. At the time of writing, two seeds (11 and 22) are complete and one additional seed is still running. We therefore frame the numbers below as an initial signal, not a final claim. - -\begin{table}[ht] -\centering -\caption{Early overnight aggregate over completed seeds ($n=2$; seeds 11 and 22).} -\label{tab:pricing_benchmark} -\begin{tabular}{lcccc} -\toprule -Mode & Mean objective score & Mean revenue & Mean COI level & Mean margin \\ -\midrule -Robust & $3.41\mathrm{e}5$ & $3.80\mathrm{e}5$ & $1.08\mathrm{e}2$ & 0.901 \\ -Non-robust (\texttt{--no-robust}) & $3.91\mathrm{e}5$ & $4.18\mathrm{e}5$ & $1.11\mathrm{e}2$ & 0.906 \\ -\bottomrule -\end{tabular} -\end{table} - -At pair level (same seed, tier, and contamination), robust exceeds non-robust in $13/40$ configurations on objective score and in $16/40$ configurations on revenue. The current early evidence therefore suggests a conditional robustness effect: the defense is active and measurable, but not yet uniformly beneficial without further calibration. \subsubsection{The Impact of Contamination on Revenue} -A linear slope test on run-level data ($n=95$) shows a strong negative association between contamination and mean revenue. The fitted model mapping $\alpha \to \text{revenue}$ result in $t(93)=-8.2148$, $p=1.20\times 10^{-12}$, $R^2=0.4205$, and a 95\% confidence interval for the slope of $[-75{,}288.76,\,-45{,}975.13]$. In practical terms, a $+0.1$ increase in $\alpha$ corresponds to an average decrease of about $6{,}063$ revenue units. A compact Appendix~\ref{app:alpha_revenue_slope} expansion can be found for these values using standard Python test methods. +A linear fit test on run-level data ($n=95$) shows a strong negative association between contamination and mean revenue. The fitted model mapping $\alpha \to \text{revenue}$ result in $t(93)=-8.2148$, $p=1.20\times 10^{-12}$, $R^2=0.4205$, and a 95\% confidence interval for the slope of $[-75{,}288.76,\,-45{,}975.13]$. In practical terms, a $+0.1$ increase in $\alpha$ corresponds to an average decrease of about $6{,}063$ revenue units within our environment. +\subsubsection{Large Scale Factorial Training} + +In our complete training runs we logged $\approx 180$ days of net compute time. The results we draw from extensive training are +\begin{enumerate*}[label=(\roman*)] + \item the ability to extract COI is greater in the presence of robustness within the training loop + \item short term revenue measurements suffer $\approx 3\%$ loss but COI margin compensates for this loss in the long run + \item a larger catalog size contributes positively to COI preservation under higher contamination ratios + \item supra-competitive pricing is a natural reward hacking tendency which is drastically reduced by a balanced UX penalty +\end{enumerate*} + +\begin{figure}[ht] + \centering + \input{chapters/figures/results/includes/final/final_focus_revenue_by_alpha.tex} + \caption{Revenue curves by contamination for the final cohort. The baseline remains above the defended curve in most cells, but the gap narrows in the high-contamination region.} + \label{fig:final_focus_revenue_by_alpha} +\end{figure} +% TODO: we need a similar plot which shows the COI preserved (what we gain across teh multiple conatmination leves, showing that the robust method has better COI optimization.) + +\begin{figure}[ht] + \centering + \input{chapters/figures/results/includes/final/final_focus_revenue_delta.tex} + \caption{Defended-minus-baseline revenue delta over contamination for the final cohort. The strongest high-contamination deviation begins at $\alpha=0.7$, followed by recovery toward near parity by $\alpha=1.0$.} + \label{fig:final_focus_revenue_delta} +\end{figure} + +\begin{figure}[ht] + \centering + \input{chapters/figures/results/includes/final/final_focus_risk_deltas.tex} + \caption{Defended-minus-baseline leakage and volatility deltas for the final cohort. Leakage remains lower for the defended policy across the full contamination range.} + \label{fig:final_focus_risk_deltas} +\end{figure} \subsection{Interpretation and Insights} The Mann-Whitney result ($p<0.001$) confirms that per-session divergence gaps distinguish the two actor classes with near-zero overlap in rank ordering. This is the condition required for distinguishability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score. -The first calibration and overnight runs additionally confirm three practical points aligned with the thesis mechanism. First, the control loop is reproducible end-to-end (training, evaluation, artifact generation) across algorithms and contamination levels. Second, policy class materially changes price trajectories and resulting COI/revenue profiles under identical environment settings. Third, objective improvements from robustness are regime-dependent in the current baseline, which is consistent with the thesis claim that contamination-aware pricing needs explicit calibration rather than a one-size-fits-all penalty. +The first calibration and paired benchmark runs additionally confirm three practical points aligned with the thesis. First, the control loop is reproducible end-to-end (training, evaluation, artifact generation) across algorithms and contamination levels. Second, policy class materially changes price trajectories and resulting COI/revenue profiles under identical environment settings. Third, objective improvements from robustness are regime-dependent in the current baseline, which is consistent with the thesis claim that contamination-aware pricing needs explicit calibration rather than a one-size-fits-all penalty. We also note that maximizing revenue in isolation can favor aggressive high-price behavior; even in these early runs, the non-robust aggregate shows slightly higher mean COI and margin. For this reason, all subsequent reporting in this thesis is interpreted on a multi-metric basis (objective, revenue, COI, and stability), and not by revenue alone. + \subsection{Anomalies} In our initial runs, we observed an instability pocket in one completed run (A2C, robust, seed 11, $\alpha=0.30$) with a large performance drop relative to neighboring configurations. We retain this run in the preliminary summary to avoid survivorship bias and treat it as evidence that robustness sensitivity analysis is necessary before final conclusions. diff --git a/paper/src/chapters/05-discussion.tex b/paper/src/chapters/05-discussion.tex index 51f6600..5b2512f 100644 --- a/paper/src/chapters/05-discussion.tex +++ b/paper/src/chapters/05-discussion.tex @@ -16,6 +16,4 @@ This technology does not come without a more bitter side, ethical concerns do ar With a system like this there is potential for strong drift given the rapid advance of agentic systems and user preference. Our intent behind adding the UX term into the reward shaping process was to further address the risk of degraded user experience. Looking deeper at the underlying methodology, reinforcement learning does not come without it's complications such as reward hacking and often the lack of intepretability which is quite critical in systems that have a strong impact on the revenue of a company. -\subsection{Implications of Findings} - -Interpretation of results and altenrative scenarios with broader market implications. +% \subsection{Implications of Findings} Interpretation of results and altenrative scenarios with broader market implications. diff --git a/paper/src/chapters/06-conclusion.tex b/paper/src/chapters/06-conclusion.tex index 95fd320..a905bbb 100644 --- a/paper/src/chapters/06-conclusion.tex +++ b/paper/src/chapters/06-conclusion.tex @@ -1,9 +1,9 @@ \section{Conclusion} -For our troubles, we now conclude that... +Our research has explored how reinforcement learning works within pricing systems and environments which are substantially disrupted by an adversarial participant. Our findings include the optimization for our newly introduced metrics. \subsection{Summary of contributions} -The authors contribution was not without the advice of many experienced experts in the field. We thank Marco Casalaina VP Products, Core AI and AI Futurist at Microsoft for the initial critical discussion on the topic of dynamic pricing systems and the spark which has lead to this work. Eugene Bykovets, PhD pointing out the parallels in blockchain systems and the complexity of anonymous interaction and understanding of intent. Importantly, the contributions of Alberto Martín Izquierdo, my academic advisor for the support over and for taking on the challenge of this ambitious work. Many breakthroughs were thanks to numerous discussions with my peers on the topics covered here. +The contribution was not without the advice of many experienced experts in the field. We thank Marco Casalaina VP Products, Core AI and AI Futurist at Microsoft for the initial critical discussion on the topic of dynamic pricing systems and the spark which has lead to this work. Eugene Bykovets, PhD pointing out the parallels in blockchain systems and the complexity of anonymous interaction and understanding of intent. Importantly, the contributions of Alberto Martín Izquierdo, my academic advisor for the support over and for taking on the challenge of this ambitious work. Many breakthroughs were thanks to numerous discussions with my peers on the topics covered here. A thanks to the head of innovation at Amadeus for insight into the industry split on the topic of collapsing margins. Finally we acknowledge the power and use of generative AI technologies for in depth research, rapid prototyping and surfacing of key topics and niches. Now we very explicitly mention what we contribute in this paper: diff --git a/paper/src/mirrors/genpop/04-results.tex b/paper/src/mirrors/genpop/04-results.tex index af37e8c..6a2dc74 100644 --- a/paper/src/mirrors/genpop/04-results.tex +++ b/paper/src/mirrors/genpop/04-results.tex @@ -30,7 +30,7 @@ The sign structure is consistent with the theoretical expectation: human session \subsection{Experimental Outcomes} -To evaluate robustness contributions, we compare two policies on the same environment family: (i) robust pricing with COI-aware reward and adversarial contamination step, and (ii) non-robust baseline with revenue-only reward (no-robust flag). +To evaluate robustness contributions, we compare two policies on the same environment family: (i) robust pricing with COI-aware reward and adversarial contamination step, and (ii) a baseline policy with revenue-only reward. \begin{table}[ht] \centering @@ -41,7 +41,7 @@ To evaluate robustness contributions, we compare two policies on the same enviro Policy & Eval reward & Eval revenue & COI leakage & Margin collapse rate \\ \midrule Robust policy & \textit{TBD} & \textit{TBD} & \textit{TBD} & \textit{TBD} \\ -Non-robust baseline (\texttt{--no-robust}) & \textit{TBD} & \textit{TBD} & \textit{TBD} & \textit{TBD} \\ +Baseline policy & \textit{TBD} & \textit{TBD} & \textit{TBD} & \textit{TBD} \\ \bottomrule \end{tabular} \end{table} From 8aa4db1c9e5572aa1eeaffee498d85f8fee49326 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 18 Mar 2026 11:39:51 +0100 Subject: [PATCH 30/35] chor> competitive wrapping --- engine/engine.py | 20 +++++++++++++++++++- engine/wrapper.py | 42 +++++++++++++++++++++++++++--------------- requirements.txt | 1 + 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/engine/engine.py b/engine/engine.py index d548177..0e6f143 100644 --- a/engine/engine.py +++ b/engine/engine.py @@ -60,7 +60,25 @@ class MarketEngine: ] # store trajectories for agent probability calculation self.last_trajectories = human_t + agent_t - return estimate_demand(self.last_trajectories, self.action_weights) + + demand_proxy = estimate_demand( + self.last_trajectories, + self.action_weights, + normalize=True, + per_session=False, + ) + raw_mix = ((1.0 - float(self.alpha)) * demand_h) + ( + float(self.alpha) * demand_a + ) + total_raw_demand = float(np.sum(raw_mix)) + if not demand_proxy: + return {i: float(raw_mix[i]) for i in range(len(prices))} + if total_raw_demand <= 0.0: + return {i: 0.0 for i in range(len(prices))} + return { + i: total_raw_demand * float(demand_proxy.get(i, 0.0)) / 100.0 + for i in range(len(prices)) + } def measure(self): pass diff --git a/engine/wrapper.py b/engine/wrapper.py index 1748617..0ff75d1 100644 --- a/engine/wrapper.py +++ b/engine/wrapper.py @@ -130,6 +130,13 @@ 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.anchor_prices = np.full( + self.n_products, + float(np.clip(float(self.human_params[0]), *self.price_bounds)), + ) + self.competitive_cap = float( + min(self.price_bounds[1], float(np.mean(self.anchor_prices)) * 1.15) + ) self._low_margin_streak = 0 # consecutive steps below margin_floor self._last_agent_prob = float(self.alpha) self._last_alpha_adv = float(self.alpha) @@ -169,19 +176,28 @@ class PHANTOM(gym.Env): 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) - ) + prev = self._prices + base = self.anchor_prices + + def _blend(target: np.ndarray) -> np.ndarray: + if prev is None: + lower = float(self.price_bounds[0]) + return np.clip(target, lower, self.competitive_cap) + blended = 0.75 * np.asarray(prev, dtype=float) + 0.25 * target + lower = float(self.price_bounds[0]) + return np.clip(blended, lower, self.competitive_cap) + 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) + target = base * self._action_scales[idx] + return _blend(target) 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) + target = base * self._action_scales[idx] + return _blend(target) + lower = float(self.price_bounds[0]) + return np.clip(a.astype(float), lower, self.competitive_cap) def _compute_agent_prob(self, trajectories=None) -> float: trajectories = ( @@ -225,14 +241,10 @@ class PHANTOM(gym.Env): upward_volatility = 0.0 ux_penalty = self.eta_ux * info_budget * (volatility + 0.5 * upward_volatility) - competitive_anchor = float( - np.clip(float(self.human_params[0]) * 1.2, *self.price_bounds) - ) + competitive_anchor = float(np.mean(self.anchor_prices)) price_ratio = prices / max(competitive_anchor, 1.0) - supra_excess = np.clip(price_ratio - 1.0, 0.0, None) - supra_penalty = ( - 0.5 * self.eta_ux * info_budget * float(np.mean(np.square(supra_excess))) - ) + supra_excess = np.clip(price_ratio - 1.15, 0.0, None) + supra_penalty = 4.0 * info_budget * float(np.mean(np.square(supra_excess))) supra_share = float(np.mean(supra_excess > 0.0)) reward_revenue = self.reward_profit_weight * profit diff --git a/requirements.txt b/requirements.txt index c1a8686..71af617 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ pandas jupyter ipykernel matplotlib +tikzplotlib graphviz browser-use pytest From f70c51f223d324107bf93a7ea80eb7a5428e8bad Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 13:23:03 +0100 Subject: [PATCH 31/35] chore: update datset link properly --- docs/index.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/index.html b/docs/index.html index 863120c..151dc9c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -272,12 +272,12 @@ - - + - Goal Set + Dataset From 87060729665137f9341bd3f58d7841792d2348bb Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 13:28:31 +0100 Subject: [PATCH 32/35] chore: updating datset card with releveant updates nad data --- .../chapters/auto/whoclicked_dataset_card.md | 178 ++++++++----- scripts/whoclicked_card.py | 237 ++++++++++++------ 2 files changed, 276 insertions(+), 139 deletions(-) diff --git a/paper/src/chapters/auto/whoclicked_dataset_card.md b/paper/src/chapters/auto/whoclicked_dataset_card.md index 507f082..6a743bd 100644 --- a/paper/src/chapters/auto/whoclicked_dataset_card.md +++ b/paper/src/chapters/auto/whoclicked_dataset_card.md @@ -17,64 +17,107 @@ size_categories: - 1K -## Dataset Summary -whoclickedit is an event-level behavioral dataset for human versus agent interaction analysis in dynamic pricing experiments. -It merges interaction logs and price quote logs into one flat CSV (`whoclicked.csv`) with explicit labels for actor type. +# [whoclickedit](https://huggingface.co/datasets/velocitatem/whoclickedit) -## Dataset Snapshot -- Rows: `3838` -- Columns: `42` -- Time range (UTC): `2025-12-05T09:43:31.301000+00:00` to `2026-02-28T19:32:06.444000+00:00` -- Unique sessions by actor: -- `agent`: 7 -- `human`: 25 -- Rows by actor: -- `agent`: 3076 -- `human`: 762 -- Rows by record type: -- `price_log`: 3331 -- `interaction`: 507 -- Rows by actor x record type: -- `agent` / `interaction`: 197 -- `agent` / `price_log`: 2879 -- `human` / `interaction`: 310 -- `human` / `price_log`: 452 -- Store modes: -- `hotel`: 3592 -- `airline`: 196 -- `shop`: 50 +[![Dataset on HF](https://huggingface.co/datasets/huggingface/badges/resolve/main/dataset-on-hf-sm.svg)](https://huggingface.co/datasets/velocitatem/whoclickedit) +![Rows](https://img.shields.io/badge/Rows-3874-0A9396?style=flat-square) +![Columns](https://img.shields.io/badge/Columns-42-005F73?style=flat-square) +![Sessions](https://img.shields.io/badge/Sessions-36-1D3557?style=flat-square) +![Human rows](https://img.shields.io/badge/Human%20rows-798-2A9D8F?style=flat-square) +![Agent rows](https://img.shields.io/badge/Agent%20rows-3076-E76F51?style=flat-square) +![License](https://img.shields.io/badge/License-MIT-111827?style=flat-square) + +> **Event-level behavior data for dynamic pricing research.** +> This dataset captures how humans and automated agents browse, query prices, and move through the PHANTOM storefronts during controlled experiments. + +## What this dataset gives you + +- A single flat file (`whoclicked.csv`) with both interaction and price-log events. +- Explicit labels for actor origin: `actor_type` and `is_agent`. +- Provenance fields from Kafka envelopes when available. +- Metadata flattened into feature-ready `metadata_*` columns. + +## Snapshot + +| Metric | Value | +| --- | --- | +| Rows | `3874` | +| Columns | `42` | +| Time range (UTC) | `2025-12-05T09:43:31.301000+00:00` -> `2026-03-23T12:08:30.151000+00:00` | +| Unique sessions | `36` | + +## Composition + +### Rows by actor +| Actor | Rows | Share | +| --- | --- | --- | +| `human` | 798 | 20.6% | +| `agent` | 3076 | 79.4% | + +### Rows by actor and record type +| Actor | Record type | Rows | +| --- | --- | --- | +| `agent` | `interaction` | 197 | +| `agent` | `price_log` | 2879 | +| `human` | `interaction` | 328 | +| `human` | `price_log` | 470 | + +### Store mode coverage +| Store mode | Rows | +| --- | --- | +| `hotel` | 3628 | +| `airline` | 196 | +| `shop` | 50 | + +### Top interaction events +| Interaction event | Count | +| --- | --- | +| `page_view` | 246 | +| `learn_more_about_item` | 91 | +| `view_item_page` | 88 | +| `add_item_to_cart` | 47 | +| `hover_over_title` | 23 | +| `checkout_start` | 20 | +| `hover_over_paragraph` | 6 | +| `remove_item` | 4 | + +## Collection pipeline + +Data is sourced from two roots inside PHANTOM: -## Source and Processing -Data is collected from two local roots in the PHANTOM project: - `experiments/collected_data` (human sessions) - `experiments/agents/collected_data` (agent sessions) -Each session folder contains: -- `int.json` (interaction events) -- `price.json` (price quote logs) +Each session directory contains: -The ETL does the following: -- Normalizes both Kafka-envelope and flat payload formats -- Flattens nested metadata fields into `metadata_*` columns -- Preserves all raw rows (no deduplication) -- Adds labels: - - `actor_type` in `{human, agent}` - - `is_agent` in `{0, 1}` - - `record_type` in `{interaction, price_log}` +- `int.json`: user interaction events +- `price.json`: price quote observations + +ETL behavior: + +1. Accepts both Kafka-envelope records and flat payload records. +2. Flattens nested JSON to a tabular schema. +3. Preserves row-level provenance (`source_session_dir`, `source_row_index`, topic fields). +4. Adds modeling labels (`actor_type`, `is_agent`, `record_type`). + +## Schema highlights + +Core modeling fields: -## Data Fields -Core fields used for modeling: - `actor_type`, `is_agent`, `record_type` - `sessionId`, `experimentId`, `storeMode`, `ts` - `eventName`, `page`, `productId`, `price`, `userAgent` Kafka provenance fields: + - `kafka_partition_id`, `kafka_offset`, `kafka_timestamp_ms`, `kafka_compression` - `kafka_is_transactional`, `kafka_headers`, `kafka_key_*`, `kafka_value_*` -Flattened metadata fields currently present: +
+Metadata columns in this release + - `metadata_cabinClass` - `metadata_dateIndex` - `metadata_dwellTime` @@ -89,37 +132,34 @@ Flattened metadata fields currently present: - `metadata_total` - `metadata_type` -Top interaction events: -- `page_view`: 236 -- `learn_more_about_item`: 88 -- `view_item_page`: 85 -- `add_item_to_cart`: 46 -- `hover_over_title`: 23 -- `checkout_start`: 19 -- `hover_over_paragraph`: 6 -- `remove_item`: 4 +
-## Intended Uses -- Human-vs-agent traffic classification -- Session-level behavioral modeling -- Dynamic pricing robustness analysis under agent-mediated reconnaissance +## Quick start -## Out-of-Scope Uses -- Identity inference or user-level profiling -- Credit, employment, insurance, or legal decision making +```python +from datasets import load_dataset -## Data Splits -No official train/validation/test split is provided in the current release. -Users should create time-aware or session-aware splits to avoid leakage. +ds = load_dataset("velocitatem/whoclickedit") +``` -## Privacy and Sensitive Content -- `userAgent` and referrer metadata can be quasi-identifying in small samples. -- Use care before publishing derived artifacts that can re-identify participants. +Recommended split strategy: -## Limitations -- Data is generated in a controlled experiment platform, not a full production marketplace. -- Agent traffic currently reflects the configured tasking and browser automation setup. -- Coverage is stronger for `hotel` than `airline` in the current release. +- Prefer session-aware or time-aware splits. +- Do not split rows from the same `sessionId` across train and test. + +## Intended use + +- Human-vs-agent behavior classification. +- Session-level telemetry modeling for dynamic pricing defenses. +- Robustness experiments under agent-mediated reconnaissance. + +## Safety and limitations + +- `userAgent` and referrer metadata can be quasi-identifying in very small samples. +- Data comes from a controlled research platform, not a full production marketplace. +- Current release has stronger coverage for `hotel` flows than `airline` flows. ## Citation -If you use this dataset, cite the PHANTOM thesis project and link this dataset page. + +If you use this dataset, cite the PHANTOM thesis project and link this page: +`https://huggingface.co/datasets/velocitatem/whoclickedit` diff --git a/scripts/whoclicked_card.py b/scripts/whoclicked_card.py index 8b5e4b2..f6829d7 100644 --- a/scripts/whoclicked_card.py +++ b/scripts/whoclicked_card.py @@ -8,6 +8,7 @@ import os import sys from pathlib import Path from typing import Any +from urllib.parse import quote import pandas as pd from huggingface_hub import HfApi @@ -93,6 +94,28 @@ def _time_range(df: pd.DataFrame) -> tuple[str, str]: return ts.min().isoformat(), ts.max().isoformat() +def _badge(label: str, value: str, color: str, logo: str | None = None) -> str: + encoded_label = quote(label, safe="") + encoded_value = quote(value, safe="") + base = ( + "https://img.shields.io/badge/" + f"{encoded_label}-{encoded_value}-{color}?style=flat-square" + ) + if logo: + base = f"{base}&logo={quote(logo, safe='')}&logoColor=white" + return f"![{label}]({base})" + + +def _md_table(headers: list[str], rows: list[list[str]]) -> str: + header = f"| {' | '.join(headers)} |" + separator = f"| {' | '.join('---' for _ in headers)} |" + if not rows: + empty = f"| {' | '.join('n/a' for _ in headers)} |" + return "\n".join([header, separator, empty]) + body = "\n".join(f"| {' | '.join(row)} |" for row in rows) + return "\n".join([header, separator, body]) + + def _render_card(df: pd.DataFrame) -> str: total_rows = len(df) total_cols = len(df.columns) @@ -112,31 +135,76 @@ def _render_card(df: pd.DataFrame) -> str: metadata_cols = sorted(c for c in df.columns if c.startswith("metadata_")) - actor_lines = ( - "\n".join(f"- `{k}`: {v}" for k, v in actor_counts.items()) or "- none" + total_sessions = sum(session_counts.values()) + human_rows = actor_counts.get("human", 0) + agent_rows = actor_counts.get("agent", 0) + + top_events = list(event_counts.items())[:10] + + snapshot_table = _md_table( + ["Metric", "Value"], + [ + ["Rows", f"`{total_rows}`"], + ["Columns", f"`{total_cols}`"], + ["Time range (UTC)", f"`{t_min}` -> `{t_max}`"], + ["Unique sessions", f"`{total_sessions}`"], + ], ) - record_lines = ( - "\n".join(f"- `{k}`: {v}" for k, v in record_counts.items()) or "- none" + + actor_table = _md_table( + ["Actor", "Rows", "Share"], + [ + [ + "`human`", + str(human_rows), + f"{(human_rows / total_rows * 100):.1f}%" if total_rows else "0.0%", + ], + [ + "`agent`", + str(agent_rows), + f"{(agent_rows / total_rows * 100):.1f}%" if total_rows else "0.0%", + ], + ], ) - pair_lines = ( - "\n".join( - f"- `{a}` / `{r}`: {n}" - for (a, r), n in sorted( + + pair_table = _md_table( + ["Actor", "Record type", "Rows"], + [ + [f"`{actor}`", f"`{record}`", str(n)] + for (actor, record), n in sorted( by_actor_record.items(), key=lambda x: (x[0][0], x[0][1]) ) - ) - or "- none" + ], ) - store_lines = ( - "\n".join(f"- `{k}`: {v}" for k, v in store_counts.items()) or "- none" + + store_table = _md_table( + ["Store mode", "Rows"], + [ + [f"`{mode}`", str(n)] + for mode, n in sorted( + store_counts.items(), key=lambda x: x[1], reverse=True + ) + ], ) - session_lines = ( - "\n".join(f"- `{k}`: {v}" for k, v in session_counts.items()) or "- none" + + event_table = _md_table( + ["Interaction event", "Count"], + [[f"`{name}`", str(n)] for name, n in top_events], ) - top_events = list(event_counts.items())[:10] - event_lines = "\n".join(f"- `{k}`: {v}" for k, v in top_events) or "- none" + metadata_lines = "\n".join(f"- `{c}`" for c in metadata_cols) or "- none" + dataset_badge = ( + "[![Dataset on HF](https://huggingface.co/datasets/huggingface/badges/resolve/main/" + "dataset-on-hf-sm.svg)](https://huggingface.co/datasets/velocitatem/whoclickedit)" + ) + rows_badge = _badge("Rows", str(total_rows), "0A9396") + cols_badge = _badge("Columns", str(total_cols), "005F73") + sessions_badge = _badge("Sessions", str(total_sessions), "1D3557") + human_badge = _badge("Human rows", str(human_rows), "2A9D8F") + agent_badge = _badge("Agent rows", str(agent_rows), "E76F51") + license_badge = _badge("License", "MIT", "111827") + return f"""--- pretty_name: whoclickedit license: mit @@ -156,85 +224,114 @@ size_categories: - {size_cat} --- -# Dataset Card for whoclickedit +PHANTOM research banner -## Dataset Summary -whoclickedit is an event-level behavioral dataset for human versus agent interaction analysis in dynamic pricing experiments. -It merges interaction logs and price quote logs into one flat CSV (`whoclicked.csv`) with explicit labels for actor type. +# [whoclickedit](https://huggingface.co/datasets/velocitatem/whoclickedit) -## Dataset Snapshot -- Rows: `{total_rows}` -- Columns: `{total_cols}` -- Time range (UTC): `{t_min}` to `{t_max}` -- Unique sessions by actor: -{session_lines} -- Rows by actor: -{actor_lines} -- Rows by record type: -{record_lines} -- Rows by actor x record type: -{pair_lines} -- Store modes: -{store_lines} +{dataset_badge} +{rows_badge} +{cols_badge} +{sessions_badge} +{human_badge} +{agent_badge} +{license_badge} + +> **Event-level behavior data for dynamic pricing research.** +> This dataset captures how humans and automated agents browse, query prices, and move through the PHANTOM storefronts during controlled experiments. + +## What this dataset gives you + +- A single flat file (`whoclicked.csv`) with both interaction and price-log events. +- Explicit labels for actor origin: `actor_type` and `is_agent`. +- Provenance fields from Kafka envelopes when available. +- Metadata flattened into feature-ready `metadata_*` columns. + +## Snapshot + +{snapshot_table} + +## Composition + +### Rows by actor +{actor_table} + +### Rows by actor and record type +{pair_table} + +### Store mode coverage +{store_table} + +### Top interaction events +{event_table} + +## Collection pipeline + +Data is sourced from two roots inside PHANTOM: -## Source and Processing -Data is collected from two local roots in the PHANTOM project: - `experiments/collected_data` (human sessions) - `experiments/agents/collected_data` (agent sessions) -Each session folder contains: -- `int.json` (interaction events) -- `price.json` (price quote logs) +Each session directory contains: -The ETL does the following: -- Normalizes both Kafka-envelope and flat payload formats -- Flattens nested metadata fields into `metadata_*` columns -- Preserves all raw rows (no deduplication) -- Adds labels: - - `actor_type` in `{{human, agent}}` - - `is_agent` in `{{0, 1}}` - - `record_type` in `{{interaction, price_log}}` +- `int.json`: user interaction events +- `price.json`: price quote observations + +ETL behavior: + +1. Accepts both Kafka-envelope records and flat payload records. +2. Flattens nested JSON to a tabular schema. +3. Preserves row-level provenance (`source_session_dir`, `source_row_index`, topic fields). +4. Adds modeling labels (`actor_type`, `is_agent`, `record_type`). + +## Schema highlights + +Core modeling fields: -## Data Fields -Core fields used for modeling: - `actor_type`, `is_agent`, `record_type` - `sessionId`, `experimentId`, `storeMode`, `ts` - `eventName`, `page`, `productId`, `price`, `userAgent` Kafka provenance fields: + - `kafka_partition_id`, `kafka_offset`, `kafka_timestamp_ms`, `kafka_compression` - `kafka_is_transactional`, `kafka_headers`, `kafka_key_*`, `kafka_value_*` -Flattened metadata fields currently present: +
+Metadata columns in this release + {metadata_lines} -Top interaction events: -{event_lines} +
-## Intended Uses -- Human-vs-agent traffic classification -- Session-level behavioral modeling -- Dynamic pricing robustness analysis under agent-mediated reconnaissance +## Quick start -## Out-of-Scope Uses -- Identity inference or user-level profiling -- Credit, employment, insurance, or legal decision making +```python +from datasets import load_dataset -## Data Splits -No official train/validation/test split is provided in the current release. -Users should create time-aware or session-aware splits to avoid leakage. +ds = load_dataset("velocitatem/whoclickedit") +``` -## Privacy and Sensitive Content -- `userAgent` and referrer metadata can be quasi-identifying in small samples. -- Use care before publishing derived artifacts that can re-identify participants. +Recommended split strategy: -## Limitations -- Data is generated in a controlled experiment platform, not a full production marketplace. -- Agent traffic currently reflects the configured tasking and browser automation setup. -- Coverage is stronger for `hotel` than `airline` in the current release. +- Prefer session-aware or time-aware splits. +- Do not split rows from the same `sessionId` across train and test. + +## Intended use + +- Human-vs-agent behavior classification. +- Session-level telemetry modeling for dynamic pricing defenses. +- Robustness experiments under agent-mediated reconnaissance. + +## Safety and limitations + +- `userAgent` and referrer metadata can be quasi-identifying in very small samples. +- Data comes from a controlled research platform, not a full production marketplace. +- Current release has stronger coverage for `hotel` flows than `airline` flows. ## Citation -If you use this dataset, cite the PHANTOM thesis project and link this dataset page. + +If you use this dataset, cite the PHANTOM thesis project and link this page: +`https://huggingface.co/datasets/velocitatem/whoclickedit` """ From 810d823710d562d7dd238a72e48bed8d387897d1 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 13:28:56 +0100 Subject: [PATCH 33/35] update final sweep --- engine/sweeps/final_thesis_proof.yaml | 60 +++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 engine/sweeps/final_thesis_proof.yaml diff --git a/engine/sweeps/final_thesis_proof.yaml b/engine/sweeps/final_thesis_proof.yaml new file mode 100644 index 0000000..2beaa20 --- /dev/null +++ b/engine/sweeps/final_thesis_proof.yaml @@ -0,0 +1,60 @@ +method: grid +metric: + name: eval/stress_reward_worst + goal: maximize +command: + - ${env} + - python + - -m + - engine.train +parameters: + algo: + value: ppo + backend: + value: sb3 + device: + value: cpu + seed: + values: [42, 1337, 7777] + alpha: + values: [0.1, 0.2, 0.3, 0.4, 0.6, 0.8] + n_products: + values: [25, 50, 100] + N: + value: 100 + no_robust: + values: [false, true] + lambda_coi: + values: [0.15, 0.30] + robust_radius: + value: 0.2 + robust_points: + value: 7 + robust_rollouts: + value: 1 + eta_ux: + value: 0.5 + reward_profit_weight: + value: 1.0 + action_levels: + value: 9 + action_scale_low: + value: 0.8 + action_scale_high: + value: 1.2 + total_timesteps: + value: 100000 + eval_episodes: + value: 12 + eval_freq: + value: 1000 + log_freq: + value: 100 + hist_freq: + value: 500 + learning_rate: + value: 0.0003 + batch_size: + value: 256 + n_steps: + value: 2048 From c87b800793981e3ea54a4cafcd4766968fe2f162 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 14:52:02 +0100 Subject: [PATCH 34/35] fixing build in prod --- web/project.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/project.json b/web/project.json index 41f561d..49fc1d5 100644 --- a/web/project.json +++ b/web/project.json @@ -7,7 +7,7 @@ "install": { "executor": "nx:run-commands", "options": { - "command": "npm install", + "command": "npm install --include=dev", "cwd": "web" } }, From ae2860a0ee28cbd4c1ae37efe45673f28e96b68e Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Mon, 23 Mar 2026 15:04:46 +0100 Subject: [PATCH 35/35] chore: adding the data and figure procssing --- .../final/final_focus_alpha_deltas.csv | 12 + .../final/final_focus_alpha_mode_summary.csv | 23 + .../final/final_focus_headline_summary.json | 27 ++ .../final/final_focus_zone_summary.csv | 3 + .../plots/final_focus_revenue_by_alpha.pdf | Bin 0 -> 17520 bytes .../final/plots/final_focus_revenue_delta.pdf | Bin 0 -> 19800 bytes .../final/plots/final_focus_risk_deltas.pdf | Bin 0 -> 19553 bytes .../final/final_focus_revenue_by_alpha.tex | 1 + .../final/final_focus_revenue_delta.tex | 1 + .../final/final_focus_risk_deltas.tex | 1 + .../figures/results/process_final_sweeps.py | 409 ++++++++++++++++++ 11 files changed, 477 insertions(+) create mode 100644 paper/src/chapters/figures/results/generated/final/final_focus_alpha_deltas.csv create mode 100644 paper/src/chapters/figures/results/generated/final/final_focus_alpha_mode_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/final/final_focus_headline_summary.json create mode 100644 paper/src/chapters/figures/results/generated/final/final_focus_zone_summary.csv create mode 100644 paper/src/chapters/figures/results/generated/final/plots/final_focus_revenue_by_alpha.pdf create mode 100644 paper/src/chapters/figures/results/generated/final/plots/final_focus_revenue_delta.pdf create mode 100644 paper/src/chapters/figures/results/generated/final/plots/final_focus_risk_deltas.pdf create mode 100644 paper/src/chapters/figures/results/includes/final/final_focus_revenue_by_alpha.tex create mode 100644 paper/src/chapters/figures/results/includes/final/final_focus_revenue_delta.tex create mode 100644 paper/src/chapters/figures/results/includes/final/final_focus_risk_deltas.tex create mode 100644 paper/src/chapters/figures/results/process_final_sweeps.py diff --git a/paper/src/chapters/figures/results/generated/final/final_focus_alpha_deltas.csv b/paper/src/chapters/figures/results/generated/final/final_focus_alpha_deltas.csv new file mode 100644 index 0000000..32bbd73 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/final/final_focus_alpha_deltas.csv @@ -0,0 +1,12 @@ +alpha,revenue_delta,revenue_delta_pct,reward_delta,reward_delta_pct,volatility_delta,supra_delta,coi_leakage_delta +0.0,-17982.383542886935,-5.11072862876989,-17145.799161982606,-5.235033672101227,0.001232973729699119,0.0,-0.0030412479577408003 +0.1,-14962.041501283413,-4.410637208586118,-14303.760282736213,-4.531344436782669,0.0011858665298920962,0.0,-0.004133727080174038 +0.2,-16153.416666167905,-4.826514761457546,-15398.621298776357,-4.9418165571901715,0.00200624274016295,0.0,-0.0033201883450373615 +0.3,-17294.9275360335,-5.382423616385397,-16544.91845114401,-5.533399709364953,-0.0011022484400295268,0.0,-0.0029151149203366505 +0.4,-19661.294346174283,-6.250307313590199,-18728.35578200908,-6.3953153560217535,3.582812967113658e-05,0.0,-0.0038123361988749577 +0.5,-16411.03168918495,-5.3630681206030015,-15638.77510066732,-5.4888928630525315,0.00015428950526953644,0.0,-0.00439661338956944 +0.6,-14729.668247641937,-5.069964928178309,-13912.22417824401,-5.148827377884945,-0.002735776807082743,0.0,-0.004310129386364658 +0.7,-21160.81910514756,-7.351404104505076,-20171.762105623755,-7.525169314210056,-0.0008903632602569461,0.0,-0.0026198461183787186 +0.8,-16404.76825612632,-5.9342582959227075,-15645.025250480074,-6.078699946285722,0.0010338614665691137,0.0,-0.002542765270289696 +0.9,-8674.090655496111,-3.2592966246269577,-8371.30734891587,-3.378943339994106,-0.0005579187914590139,0.0,-0.0013720835439427759 +1.0,768.8099906174757,0.2991618705853567,399.7394696234842,0.16706914330070038,0.0014659834822295797,0.0,-0.0007600066499474645 diff --git a/paper/src/chapters/figures/results/generated/final/final_focus_alpha_mode_summary.csv b/paper/src/chapters/figures/results/generated/final/final_focus_alpha_mode_summary.csv new file mode 100644 index 0000000..50051aa --- /dev/null +++ b/paper/src/chapters/figures/results/generated/final/final_focus_alpha_mode_summary.csv @@ -0,0 +1,23 @@ +alpha,mode,runs,revenue_mean,reward_mean,supra_mean,volatility_mean,coi_leakage_mean,coi_level_mean +0.0,baseline,36,351855.57381502265,327520.32242613373,0.0,0.06922494093544151,0.11931704468268205,136.80105514058158 +0.0,defended,35,333873.1902721357,310374.5232641511,0.0,0.07045791466514063,0.11627579672494125,136.81832905386602 +0.1,baseline,32,339226.3020897988,315662.6136522988,0.0,0.06952778671756812,0.11924519238669087,136.47864859317326 +0.1,defended,33,324264.2605885154,301358.8533695626,0.0,0.07071365324746022,0.11511146530651684,136.7200845824852 +0.2,baseline,31,334680.76789409376,311598.399506997,0.0,0.06848006194428993,0.11597869134898402,136.83684469591932 +0.2,defended,35,318527.35122792586,296199.77820822067,0.0,0.07048630468445288,0.11265850300394666,137.2758153292305 +0.3,baseline,30,321322.30327214615,299000.9636054795,0.0,0.07085669473747759,0.11527347603412934,136.4452630715689 +0.3,defended,44,304027.37573611265,282456.0451543355,0.0,0.06975444629744806,0.11235836111379269,136.4704115371568 +0.4,baseline,33,314565.2423109539,292844.914432166,0.0,0.07031811881503117,0.11300307992768284,136.72547178046122 +0.4,defended,38,294903.9479647796,274116.55865015695,0.0,0.0703539469447023,0.10919074372880788,136.75671002806396 +0.5,baseline,33,306000.80625751516,284916.7489847879,0.0,0.06938663916591635,0.11118137138243217,136.9528780620641 +0.5,defended,35,289589.7745683302,269277.9738841206,0.0,0.06954092867118589,0.10678475799286273,136.65018588845163 +0.6,baseline,28,290528.0106727377,270201.7985298805,0.0,0.07139577980623227,0.11081647254398667,135.258395468266 +0.6,defended,41,275798.3424250958,256289.57435163652,0.0,0.06866000299914952,0.10650634315762202,136.3194947785247 +0.7,baseline,40,287847.3119465684,268057.25244656845,0.0,0.07132313199532896,0.10746267580456732,137.0170522633547 +0.7,defended,40,266686.49284142087,247885.4903409447,0.0,0.07043276873507201,0.1048428296861886,136.56834095392904 +0.8,baseline,26,276441.76303208206,257374.52726285128,0.0,0.06945655282263205,0.1063246766773884,136.66765260798618 +0.8,defended,39,260036.99477595574,241729.5020123712,0.0,0.07049041428920116,0.1037819114070987,136.61222667078658 +0.9,baseline,35,266133.8213268301,247749.2667554015,0.0,0.0709569180547784,0.10455882265976374,136.5370653814206 +0.9,defended,39,257459.73067133396,239377.95940648564,0.0,0.07039899926331938,0.10318673911582096,136.7368893225831 +1.0,baseline,35,256987.96076959255,239265.888198164,0.0,0.06888231148034313,0.10369761394735275,136.68691718467974 +1.0,defended,30,257756.77076021003,239665.62766778748,0.0,0.07034829496257271,0.10293760729740528,136.65287739235566 diff --git a/paper/src/chapters/figures/results/generated/final/final_focus_headline_summary.json b/paper/src/chapters/figures/results/generated/final/final_focus_headline_summary.json new file mode 100644 index 0000000..e257560 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/final/final_focus_headline_summary.json @@ -0,0 +1,27 @@ +{ + "bundle": "engine/studies/results/wandb_sweep_bundles/bundle_20260317_093826", + "focus_cohort": "max_alpha_coverage", + "alpha_cells": 11, + "alpha_min": 0.0, + "alpha_max": 1.0, + "mean_revenue_delta_pct": -4.787221975639986, + "mean_reward_delta_pct": -4.91730667541704, + "zone_summary": [ + { + "zone": "high_alpha_0_7_plus", + "alpha_cells": 4, + "revenue_delta_pct_mean": -4.0614492886173466, + "reward_delta_pct_mean": -4.2039358642972955, + "coi_leakage_delta_mean": -0.0018236753956396637, + "volatility_delta_mean": 0.00026289072427068336 + }, + { + "zone": "low_alpha_below_0_7", + "alpha_cells": 7, + "revenue_delta_pct_mean": -5.201949225367208, + "reward_delta_pct_mean": -5.324947138914036, + "coi_leakage_delta_mean": -0.0037041938968711296, + "volatility_delta_mean": 0.00011102505536893832 + } + ] +} diff --git a/paper/src/chapters/figures/results/generated/final/final_focus_zone_summary.csv b/paper/src/chapters/figures/results/generated/final/final_focus_zone_summary.csv new file mode 100644 index 0000000..224a022 --- /dev/null +++ b/paper/src/chapters/figures/results/generated/final/final_focus_zone_summary.csv @@ -0,0 +1,3 @@ +zone,alpha_cells,revenue_delta_pct_mean,reward_delta_pct_mean,coi_leakage_delta_mean,volatility_delta_mean +high_alpha_0_7_plus,4,-4.0614492886173466,-4.2039358642972955,-0.0018236753956396637,0.00026289072427068336 +low_alpha_below_0_7,7,-5.201949225367208,-5.324947138914036,-0.0037041938968711296,0.00011102505536893832 diff --git a/paper/src/chapters/figures/results/generated/final/plots/final_focus_revenue_by_alpha.pdf b/paper/src/chapters/figures/results/generated/final/plots/final_focus_revenue_by_alpha.pdf new file mode 100644 index 0000000000000000000000000000000000000000..343539eb657d0ac16452d3182e9f79695454bfa2 GIT binary patch literal 17520 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 literal 0 HcmV?d00001 diff --git a/paper/src/chapters/figures/results/generated/final/plots/final_focus_risk_deltas.pdf b/paper/src/chapters/figures/results/generated/final/plots/final_focus_risk_deltas.pdf new file mode 100644 index 0000000000000000000000000000000000000000..9bbbf7a32807c783724f020fd95a69bbcf0ffd4c GIT binary patch literal 19553 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 Path: + return Path(__file__).resolve().parents[5] + + +def _default_bundle_dir() -> Path: + base = _project_root() / "engine" / "studies" / "results" / "wandb_sweep_bundles" + bundles = sorted( + [path for path in base.glob("bundle_*") if path.is_dir()], + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not bundles: + raise FileNotFoundError(f"No sweep bundle directories found in {base}") + return bundles[0] + + +def _default_output_dir() -> Path: + return Path(__file__).resolve().parent / "generated" / "final" + + +def _default_plot_dir(output_dir: Path) -> Path: + return output_dir / "plots" + + +def _truthy(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _mode_of(row: pd.Series) -> str: + mode_hint = str(row.get("study_mode", "")).strip().lower() + if mode_hint in {"baseline", "no_robust"}: + return "baseline" + if mode_hint in {"defended", "robust"}: + return "defended" + if _truthy(row.get("baseline_mode")) or _truthy(row.get("no_robust")): + return "baseline" + return "defended" + + +def _coerce_numeric(frame: pd.DataFrame, columns: list[str]) -> None: + for column in columns: + if column in frame.columns: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + + +def _configure_style() -> None: + plt.rcParams.update( + { + "font.family": "serif", + "font.size": 10, + "axes.titlesize": 10, + "axes.labelsize": 9, + "legend.fontsize": 8, + "xtick.labelsize": 8, + "ytick.labelsize": 8, + "figure.dpi": 220, + "savefig.dpi": 320, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.grid": True, + "grid.alpha": 0.22, + } + ) + + +def _load_runs(bundle_dir: Path) -> pd.DataFrame: + path = bundle_dir / "runs_finished.csv" + if not path.exists(): + raise FileNotFoundError(f"Missing required file: {path}") + frame = pd.read_csv(path) + frame["mode"] = frame.apply(_mode_of, axis=1) + _coerce_numeric( + frame, + [ + "alpha", + "n_products", + "eval_revenue_mean", + "eval_reward_mean", + "eval_supra_share_mean", + "eval_volatility_mean", + "eval_coi_level_mean", + "eval_coi_leakage_mean", + "objective_score", + ], + ) + return frame + + +def _focus_sweep(runs: pd.DataFrame) -> str: + coverage = ( + runs.groupby("sweep_id", as_index=False) + .agg( + n_alpha=("alpha", lambda s: int(pd.Series(s).dropna().nunique())), + max_alpha=("alpha", "max"), + run_count=("run_id", "size"), + ) + .sort_values( + ["n_alpha", "max_alpha", "run_count"], ascending=[False, False, False] + ) + ) + if coverage.empty: + raise ValueError("No sweep rows available in runs_finished.csv") + return str(coverage.iloc[0]["sweep_id"]) + + +def _alpha_mode_summary(runs: pd.DataFrame) -> pd.DataFrame: + return ( + runs.groupby(["alpha", "mode"], as_index=False) + .agg( + runs=("run_id", "size"), + revenue_mean=("eval_revenue_mean", "mean"), + reward_mean=("eval_reward_mean", "mean"), + supra_mean=("eval_supra_share_mean", "mean"), + volatility_mean=("eval_volatility_mean", "mean"), + coi_leakage_mean=("eval_coi_leakage_mean", "mean"), + coi_level_mean=("eval_coi_level_mean", "mean"), + ) + .sort_values(["alpha", "mode"]) + .reset_index(drop=True) + ) + + +def _alpha_deltas(alpha_mode: pd.DataFrame) -> pd.DataFrame: + rows: list[dict[str, float]] = [] + for alpha, group in alpha_mode.groupby("alpha", sort=True): + defended = group[group["mode"] == "defended"] + baseline = group[group["mode"] == "baseline"] + if defended.empty or baseline.empty: + continue + d_rev = float(defended["revenue_mean"].iloc[0]) + b_rev = float(baseline["revenue_mean"].iloc[0]) + d_reward = float(defended["reward_mean"].iloc[0]) + b_reward = float(baseline["reward_mean"].iloc[0]) + d_vol = float(defended["volatility_mean"].iloc[0]) + b_vol = float(baseline["volatility_mean"].iloc[0]) + d_supra = float(defended["supra_mean"].iloc[0]) + b_supra = float(baseline["supra_mean"].iloc[0]) + d_coi_leak = float(defended["coi_leakage_mean"].iloc[0]) + b_coi_leak = float(baseline["coi_leakage_mean"].iloc[0]) + rows.append( + { + "alpha": float(alpha), + "revenue_delta": d_rev - b_rev, + "revenue_delta_pct": 0.0 + if b_rev == 0.0 + else 100.0 * (d_rev - b_rev) / b_rev, + "reward_delta": d_reward - b_reward, + "reward_delta_pct": 0.0 + if b_reward == 0.0 + else 100.0 * (d_reward - b_reward) / b_reward, + "volatility_delta": d_vol - b_vol, + "supra_delta": d_supra - b_supra, + "coi_leakage_delta": d_coi_leak - b_coi_leak, + } + ) + return pd.DataFrame(rows).sort_values("alpha").reset_index(drop=True) + + +def _zone_summary(alpha_deltas: pd.DataFrame) -> pd.DataFrame: + if alpha_deltas.empty: + return pd.DataFrame() + data = alpha_deltas.copy() + data["zone"] = np.where( + data["alpha"] >= 0.7, "high_alpha_0_7_plus", "low_alpha_below_0_7" + ) + return ( + data.groupby("zone", as_index=False) + .agg( + alpha_cells=("alpha", "size"), + revenue_delta_pct_mean=("revenue_delta_pct", "mean"), + reward_delta_pct_mean=("reward_delta_pct", "mean"), + coi_leakage_delta_mean=("coi_leakage_delta", "mean"), + volatility_delta_mean=("volatility_delta", "mean"), + ) + .sort_values("zone") + ) + + +def _save_plot(fig: plt.Figure, path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, bbox_inches="tight") + plt.close(fig) + return path + + +def _plot_focus_revenue_by_alpha(alpha_mode: pd.DataFrame, out_path: Path) -> Path: + fig, ax = plt.subplots(figsize=(7.8, 4.8), constrained_layout=True) + for mode, color, label in ( + ("baseline", "#4C72B0", "Baseline"), + ("defended", "#C44E52", "Defended"), + ): + sub = alpha_mode[alpha_mode["mode"] == mode].sort_values("alpha") + if sub.empty: + continue + ax.plot( + sub["alpha"], + sub["revenue_mean"], + marker="o", + linewidth=1.9, + markersize=4, + color=color, + label=label, + ) + ax.axvline(0.7, color="#666666", linewidth=1.0, linestyle="--") + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel("Mean episode revenue") + ax.set_title("Final Cohort Revenue Curves") + ax.legend(loc="lower left") + return _save_plot(fig, out_path) + + +def _plot_focus_revenue_delta(alpha_deltas: pd.DataFrame, out_path: Path) -> Path: + fig, ax = plt.subplots(figsize=(7.8, 4.8), constrained_layout=True) + x = alpha_deltas["alpha"].to_numpy(dtype=float) + y = alpha_deltas["revenue_delta_pct"].to_numpy(dtype=float) + ax.plot(x, y, marker="o", linewidth=2.0, markersize=4, color="#C44E52") + ax.fill_between(x, y, 0.0, color="#C44E52", alpha=0.12) + ax.axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + ax.axvline(0.7, color="#666666", linewidth=1.0, linestyle="--") + high = alpha_deltas[alpha_deltas["alpha"] >= 0.7] + if not high.empty: + best = high.reindex( + high["revenue_delta_pct"].abs().sort_values(ascending=False).index + ).iloc[0] + ax.scatter( + [best["alpha"]], + [best["revenue_delta_pct"]], + color="#1f77b4", + s=45, + zorder=3, + ) + ax.annotate( + f"high-alpha peak {best['revenue_delta_pct']:.2f}%", + (float(best["alpha"]), float(best["revenue_delta_pct"])), + textcoords="offset points", + xytext=(6, 6), + fontsize=8, + ) + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel("Defended minus baseline revenue (%)") + ax.set_title("Revenue Delta by Contamination (Final Cohort)") + return _save_plot(fig, out_path) + + +def _plot_focus_risk_deltas(alpha_deltas: pd.DataFrame, out_path: Path) -> Path: + fig, ax = plt.subplots(figsize=(7.8, 4.8), constrained_layout=True) + x = alpha_deltas["alpha"].to_numpy(dtype=float) + ax.plot( + x, + alpha_deltas["coi_leakage_delta"].to_numpy(dtype=float), + marker="o", + linewidth=1.8, + markersize=4, + color="#55A868", + label="COI leakage delta", + ) + ax.plot( + x, + alpha_deltas["volatility_delta"].to_numpy(dtype=float), + marker="s", + linewidth=1.8, + markersize=3.8, + color="#8172B3", + label="Volatility delta", + ) + ax.axhline(0.0, color="#444444", linewidth=1.0, linestyle="--") + ax.axvline(0.7, color="#666666", linewidth=1.0, linestyle="--") + ax.set_xlabel(r"Contamination $\alpha$") + ax.set_ylabel("Defended minus baseline") + ax.set_title("Leakage and Stability Deltas (Final Cohort)") + ax.legend(loc="lower left") + return _save_plot(fig, out_path) + + +def _write_include(path: Path, figure_rel_path: str, width: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"\\includegraphics[width={width}]{{{figure_rel_path}}}\n") + return path + + +def run(bundle_dir: Path, output_dir: Path, plot_dir: Path) -> list[Path]: + all_runs = _load_runs(bundle_dir) + focus_id = _focus_sweep(all_runs) + focus_runs = all_runs[all_runs["sweep_id"] == focus_id].copy() + alpha_mode = _alpha_mode_summary(focus_runs) + deltas = _alpha_deltas(alpha_mode) + zones = _zone_summary(deltas) + + output_dir.mkdir(parents=True, exist_ok=True) + plot_dir.mkdir(parents=True, exist_ok=True) + + written: list[Path] = [] + alpha_mode_path = output_dir / "final_focus_alpha_mode_summary.csv" + alpha_mode.to_csv(alpha_mode_path, index=False) + written.append(alpha_mode_path) + + delta_path = output_dir / "final_focus_alpha_deltas.csv" + deltas.to_csv(delta_path, index=False) + written.append(delta_path) + + zone_path = output_dir / "final_focus_zone_summary.csv" + zones.to_csv(zone_path, index=False) + written.append(zone_path) + + headline = { + "bundle": str(bundle_dir), + "focus_cohort": "max_alpha_coverage", + "alpha_cells": int(deltas["alpha"].nunique()) if not deltas.empty else 0, + "alpha_min": float(deltas["alpha"].min()) if not deltas.empty else None, + "alpha_max": float(deltas["alpha"].max()) if not deltas.empty else None, + "mean_revenue_delta_pct": float(deltas["revenue_delta_pct"].mean()) + if not deltas.empty + else None, + "mean_reward_delta_pct": float(deltas["reward_delta_pct"].mean()) + if not deltas.empty + else None, + "zone_summary": zones.to_dict(orient="records"), + } + headline_path = output_dir / "final_focus_headline_summary.json" + headline_path.write_text(json.dumps(headline, indent=2) + "\n") + written.append(headline_path) + + written.append( + _plot_focus_revenue_by_alpha( + alpha_mode, + plot_dir / "final_focus_revenue_by_alpha.pdf", + ) + ) + written.append( + _plot_focus_revenue_delta( + deltas, + plot_dir / "final_focus_revenue_delta.pdf", + ) + ) + written.append( + _plot_focus_risk_deltas( + deltas, + plot_dir / "final_focus_risk_deltas.pdf", + ) + ) + + include_dir = Path(__file__).resolve().parent / "includes" / "final" + written.append( + _write_include( + include_dir / "final_focus_revenue_by_alpha.tex", + "chapters/figures/results/generated/final/plots/final_focus_revenue_by_alpha.pdf", + "0.98\\linewidth", + ) + ) + written.append( + _write_include( + include_dir / "final_focus_revenue_delta.tex", + "chapters/figures/results/generated/final/plots/final_focus_revenue_delta.pdf", + "0.95\\linewidth", + ) + ) + written.append( + _write_include( + include_dir / "final_focus_risk_deltas.tex", + "chapters/figures/results/generated/final/plots/final_focus_risk_deltas.pdf", + "0.95\\linewidth", + ) + ) + return written + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate final paper figures/tables from the final sweep cohort" + ) + parser.add_argument("--bundle-dir", type=Path, default=_default_bundle_dir()) + parser.add_argument("--output-dir", type=Path, default=_default_output_dir()) + parser.add_argument("--plot-dir", type=Path, default=None) + args = parser.parse_args() + + _configure_style() + plot_dir = ( + args.plot_dir + if args.plot_dir is not None + else _default_plot_dir(args.output_dir) + ) + outputs = run( + bundle_dir=args.bundle_dir, output_dir=args.output_dir, plot_dir=plot_dir + ) + for path in outputs: + print(path) + + +if __name__ == "__main__": + main()