Compare commits

..

118 Commits

Author SHA1 Message Date
43bcad2a98 arxiv mirror 2026-03-10 15:03:57 +01:00
8404a88ef1 fix: logging into benchmark of wandb 2026-03-10 14:54:44 +01:00
1c2935dc87 feat: reference of blog series 2026-03-10 14:54:35 +01:00
14aae3dc9a improving understandable aspects of the abstract 2026-03-10 14:52:17 +01:00
a9f1e19488 feat: docs 2026-03-10 14:23:49 +01:00
4c7d911043 feature: telemetry logging 2026-03-10 14:23:17 +01:00
be03b2d4d5 updating docs and paper 2026-03-10 14:11:41 +01:00
ee32ab7d1d chore: change colors 2026-03-09 22:47:51 +01:00
969ef4c363 feat: working full flow of diff scenes 2026-03-09 20:59:22 +01:00
3cc2dc40d5 initaial defense scenes 2026-03-09 13:52:11 +01:00
529d00cb80 chore: proper naming of authoers 2026-03-09 12:18:34 +01:00
b62d29cfaf updating workflow 2026-03-09 11:45:49 +01:00
77f45ed0b3 initial results :/ 2026-03-09 11:37:22 +01:00
73a1dafc6e first meaningful runs 2026-03-08 21:37:13 +01:00
4c658a93a7 cleaning up jax bs 2026-03-08 19:15:58 +01:00
73246d7dd8 refactoring training spc setup and benchmarking 2026-03-08 18:30:53 +01:00
9fafb26ec8 mirror cloudflare sync 2026-03-08 16:04:49 +01:00
b7b871f9aa fixing line break 2026-03-08 15:52:30 +01:00
840a13ca4a fixing root file passsing 2026-03-08 15:46:21 +01:00
04fa7cbab5 workflow fix 2026-03-08 15:24:56 +01:00
ec7486ee85 genpop workflow 2026-03-08 15:22:09 +01:00
916e72f0ff helper scripts 2026-03-08 14:36:24 +01:00
69b2d5aceb adding task details 2026-03-08 14:32:16 +01:00
28dbcacd95 updating computation power graph 2026-03-08 14:22:54 +01:00
17c128cbc0 implementing nx management for the framework 2026-03-08 13:54:16 +01:00
cc24ac72f7 changed to new test method for singificance 2026-03-08 13:53:31 +01:00
4b89b64674 monestary updates 2026-03-08 13:27:17 +01:00
ec880db444 chore: cleaning the code 2026-02-28 23:38:38 +01:00
803e3a2972 chore: cleaning some code 2026-02-28 23:30:16 +01:00
233ce3be34 class separaiblity significance 2026-02-28 21:38:46 +01:00
Daniel Alves Rösel
8f20359c8c Merge pull request #53 from velocitatem/cleanup
Cleanup
2026-02-28 14:15:24 +01:00
56585b3de8 cleaning path for intergations 2026-02-28 14:11:39 +01:00
5444a4ea13 catchup: rogue scripts 2026-02-27 12:45:46 +01:00
e8a9716f69 hotfix of machines 2026-02-27 11:59:08 +01:00
e50d643fbf feat: training update 2026-02-27 09:33:04 +01:00
Daniel Alves Rösel
dac1e58a0d Merge pull request #52 from velocitatem/enriching-engine-simulation
Enriching engine simulation
2026-02-25 09:21:26 +01:00
4667a1678f chore minor paper edits 2026-02-25 09:16:00 +01:00
a4b7b5b4b2 improements of the methodology for now almost ready tosubmit 2026-02-19 18:28:40 +01:00
1a9901f118 refactored training approaches 2026-02-19 18:23:08 +01:00
5912062dc0 new trainer image 2026-02-19 13:03:25 +01:00
843564eeb0 TPU startup scripts 2026-02-19 13:03:03 +01:00
9acc998cc9 fixing models for gcp 2026-02-17 16:54:55 +01:00
802f31b4a1 adding naive jax and libraries and make adjustments 2026-02-17 14:48:18 +01:00
66c4a0cd1d chore: fix chips used 2026-02-17 14:46:43 +01:00
244af9ac09 citing compute 2026-02-17 14:46:34 +01:00
76c31a2abd citing marc and 2026-02-17 09:40:20 +01:00
64ee7e6d9b forcing light mode 2026-02-16 11:30:18 +01:00
1e04a928aa migrated new banner 2026-02-15 17:31:31 +01:00
9b133cddfd introduce penalized sessions to episodes 2026-02-15 17:15:25 +01:00
ded7290935 hidef banner rendering 2026-02-15 17:12:12 +01:00
8e4dd59f90 banner rendering 2026-02-15 17:10:16 +01:00
024f6d4132 banner addition 2026-02-15 17:10:13 +01:00
2b47c3499a chore: fixing discretization of actions 2026-02-15 15:45:46 +01:00
ef1d1f6557 fixing assumption definition 2026-02-14 21:54:42 +01:00
d7657db287 reintroducing our note :) 2026-02-14 21:49:40 +01:00
e8229ac313 updating methodology with better refelction 2026-02-14 15:20:38 +01:00
bc6c481d03 minor refactors to codebase to implement DRO 2026-02-14 14:53:30 +01:00
895eea5674 imporving methodology and adding onto it 2026-02-14 14:28:18 +01:00
fba2a9739e updating paper details 2026-02-14 13:13:00 +01:00
d1aa13360f cleaning refactors 2026-02-13 21:03:02 +01:00
f6f9729424 improving expression of ideas from dump 2026-02-10 18:12:49 +01:00
29a13340b9 hotfix: updating pricing provider to better read data 2026-02-06 12:01:12 +01:00
e22286371f feat: proportiona lrevenu 2026-02-06 11:54:23 +01:00
e44feb7da0 updaing coi definition 2026-02-05 12:47:13 +01:00
ebd2378859 yapping 2026-02-05 12:28:26 +01:00
c4d82b2ecc rescaling the graph 2026-02-02 16:55:06 +01:00
a9e2e7cbf3 improving on the methodlology 2026-02-02 16:52:50 +01:00
e0b074161b fix: typo 2026-02-02 12:08:24 +01:00
08c0afb55a chore: add chart of supra competive pricing 2026-02-02 12:03:30 +01:00
c4fd1352c9 naoice COI implementation 2026-02-02 11:18:37 +01:00
4abef97bf7 chore: adding simulation logging with wandb 2026-01-31 16:21:10 +01:00
33cb0d7e95 feature: refactored demand splitting and implementation 2026-01-31 12:56:48 +01:00
e8ef850089 feat: introduced simple COI proxy 2026-01-31 12:06:48 +01:00
e7cb48e9cd chore: updating paper 2026-01-31 10:47:12 +01:00
Daniel Alves Rösel
dba8f3fafa Merge pull request #44 from velocitatem/agent-behavior-loader-developemen
Agent behavior loader developement + rl loop definition and e2e tests.
2026-01-31 10:21:54 +01:00
Daniel Alves Rösel
9843c5deab Merge pull request #51 from velocitatem/feat-strong-learning-implementation-with-data-contamination
Feat strong learning implementation with data contamination
2026-01-31 10:15:09 +01:00
13959e4b28 chore: bug fixes 2026-01-31 10:13:07 +01:00
Daniel Alves Rösel
2f481bd94b Merge branch 'agent-behavior-loader-developemen' into feat-strong-learning-implementation-with-data-contamination 2026-01-31 10:08:59 +01:00
72877439ca feat: contaminator and training 2026-01-31 09:48:20 +01:00
0f5f8affab chore: make lib backwards compatible 2026-01-31 09:48:20 +01:00
ee70f02a1f chore: export repeated methods into lib 2026-01-31 09:48:20 +01:00
22a2c255bd chore: remove boilerplate 2026-01-31 09:48:20 +01:00
ccc19f3493 acapting some architectures 2026-01-31 09:48:20 +01:00
00e3eff2fa migrating weak learning 2026-01-31 09:48:20 +01:00
440371dba4 feat: initial feature engineering of trajectories 2026-01-31 09:48:20 +01:00
b05b510f70 strong dataset gathering 2026-01-31 09:48:20 +01:00
04907df393 feat: weak train scaffold 2026-01-31 09:48:20 +01:00
b2f0746c01 chore: extra commenting 2026-01-31 09:48:20 +01:00
7b2d80ac4c feat: wip contaminator 2026-01-31 09:48:20 +01:00
0ce12fbc3b chore: ignores 2026-01-31 09:48:17 +01:00
e9cf5f0736 refactor models computations 2026-01-31 09:46:44 +01:00
82b54428b7 chore: refactor the loader class 2026-01-31 09:46:44 +01:00
87a35fad2c feat: joint loader 2026-01-31 09:46:44 +01:00
af23d2f736 feat: introduction of agentinc MDPs and KL divergence of > 2 2026-01-31 09:46:44 +01:00
9cb2b0fc44 feat: forgot airflow helper staging 2026-01-31 09:46:44 +01:00
7c330a19c6 feat: added a runner script for agent orchestration 2026-01-31 09:46:44 +01:00
Daniel Alves Rösel
eb95060380 Pre run web refactors (#43)
* chore: refactor date utilities

* feat: improve images of hotel rooms

* fix: adding date utils
2026-01-31 09:46:44 +01:00
61dd621532 chore: styling and title updates 2026-01-31 09:46:44 +01:00
4c368d48f2 chore: fixing visual bugs in cart 2026-01-31 09:46:44 +01:00
3c141a4b6c chore: better test consistency before agnet 2026-01-31 09:46:44 +01:00
e89cb263d4 planning 2026-01-31 09:46:44 +01:00
62a4008c29 feat: integration of pipeline hooks into testing 2026-01-31 09:46:44 +01:00
8b429b7a8e chore: refactor to better map end to end 2026-01-31 09:46:44 +01:00
f9bf3de71e pdf rendering 2026-01-31 09:46:44 +01:00
131323ef56 featuer: dot exporter 2026-01-31 09:46:44 +01:00
ec4cf074e6 feature: MDP behavior mappers (unlinked) 2026-01-31 09:46:44 +01:00
6a06a8af4a simple code cleanup 2026-01-31 09:46:44 +01:00
3fa98f375d refactor to align moer with research in the env sims 2026-01-31 09:46:44 +01:00
201c98bcac improved implementation 2026-01-31 09:46:44 +01:00
8a08458478 formlating the reward simply 2026-01-31 09:46:44 +01:00
7d09232e48 high level defintion 2026-01-31 09:46:44 +01:00
20132c084c initial environemnt definitions 2026-01-31 09:46:41 +01:00
26abff5864 chore: fixing tests with seed determinism 2026-01-30 13:57:40 +01:00
4c7d9362af chore: envs for e2e 2026-01-30 13:55:22 +01:00
ea45801845 chore: removing the lab byproduct 2026-01-30 13:22:22 +01:00
Daniel Alves Rösel
574e05d9e0 Merge pull request #50 from velocitatem/new-simulation-environment-development
New simulation environment development
2026-01-30 13:19:53 +01:00
Daniel Alves Rösel
b5f19e04b7 Paper lit review (#45)
* chore: updating apa citation and fixing citation in-text and parent

* fixing in lit review

* adjusting citations and improving schema

* chore: fixed formating and adjusting other components

* refined abstract

* one page fitting

* constrainative proposals

* fix: syntax of transtion probs

* refined lit review and soruces

* research Objectives

* adding logo graphics

* chore: fixing citation completeness

* updating with newly built algoerith

* lit review document setup
2026-01-26 13:04:32 +01:00
Daniel Alves Rösel
a9d73ccce5 Paper first fillout (#39)
* initial environemnt definitions

* high level defintion

* formlating the reward simply

* improved implementation

* tailored docker compose image for secondary tenaordboard

* preliminary desriptions and babble

* details on formulation and defintion of agent and its loop

* typos one

* more grammar issues

* fluidity improvements and refactors

* more decluttering and dnoising

* finalizing introduction review

* some methodology

* somehow this disappeared

* bit more of this and that

* methodology of how we do architectuer and online DP

* fix: compilation

* expanding on the taxonomy and economic references

* authoer notes

* acks + google GCP

* making space w new format nada lit review

* stronger lit review and more sources

* forgot about tables and graphs

* dedupe citations

* adding cloudflare

* fixing env vars

* updating docs with url

* upating embed

* fixing the url

* paper badge

* formaliztaion of rewards and adding definitions

* noisy formulations

* connecting some more dots here

* adding significant weight in prices

* fixing error

* fixing typos and consistency

* extra math formulations and refferenceot DRO

* fixing diagram of loops

* github mindmap

* fixing erro and thiknig about big picture

* enhancing the website

* goals methodology and gitignore

* some more references and theory links

* talking about some wtp

* feature: added wordcounter

* forcing latex builds and fixining the bib #

* refactor: update Cost of Information equations and notation for clarity

* some more math and refactors

* refactor: unify notation and improve clarity in COI equations

* refactor: generalize master function for demand estimation and pricing strategies

* we dont like math but we have to do it :(

* refactor: enhance Cost of Information framework with additional context and illustration

* refactor: enhance literature review and methodology sections with economic theory insights and system architecture details

* alining format to fit the rubric

* refactoring bibliography

* fix: align

* mdp additionally

* trying different title

* adding balance figure

* agentic givergence, finally

* fix: figure fonts adjusted to match
2026-01-13 17:07:29 +01:00
188 changed files with 21254 additions and 4962 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
.git
.venv
.venv-tpu
**/__pycache__
**/*.pyc
**/*.pyo
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
**/.ipynb_checkpoints
wandb
build
paper/build
paper/build-cais
node_modules
**/node_modules
*.egg-info

24
.env.sweep.example Normal file
View File

@@ -0,0 +1,24 @@
# Copy this file to .env.sweep and fill in values.
# Required for wandb runs and sweep agent workers.
WANDB_API_KEY=
WANDB_ENTITY=
WANDB_PROJECT=capstone
# Required for private repo bootstrap workers.
GITHUB_TOKEN=
# Optional defaults for bootstrap mode.
# REPO_URL=https://github.com/org/repo.git
# BRANCH=main
# WORKDIR=$HOME/PHANTOM-agent
# SWEEP_ID=entity/project/id
# AGENT_COUNT=0
# AGENT_LOOP=1
# RETRY_SECONDS=20
# Optional local benchmark defaults.
# LOCAL_BENCHMARK_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
# SIMPLE_BENCHMARK_ARGS=--tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
# PHANTOM_BENCHMARK_COMPARE_ROBUST=1
# BENCHMARK_AGENT_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3,0.6 --episodes 5

View File

@@ -12,17 +12,168 @@ on:
jobs:
build:
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
steps:
- uses: actions/checkout@v4
- name: Compile LaTeX document
- name: Prepare appendix code snapshot
run: bash paper/concat_code.sh
- name: Generate mirrors with Codex
if: ${{ env.OPENAI_API_KEY != '' }}
uses: openai/codex-action@v1
with:
openai-api-key: ${{ env.OPENAI_API_KEY }}
sandbox: workspace-write
safety-strategy: drop-sudo
working-directory: .
prompt: |
Read and follow the mirror instructions in `paper/src/mirrors/genpop/INSTRUCTIONS.md`.
Source chapters are in `paper/src/chapters/`:
- 01-intro.tex
- 02-literature-review.tex
- 03-methodology.tex
- 04-results.tex
- 05-discussion.tex
- 06-conclusion.tex
Update `paper/src/mirrors/genpop/*.tex` so they mirror the thesis for a general audience according to the instruction file.
Keep LaTeX valid and preserve citation commands and section order.
Then create or update `paper/src/main-mirror-genpop.tex` by using `paper/src/main.tex` as the base and replacing chapter inputs from `chapters/...` to `mirrors/genpop/...`.
Do not change any other project files.
- name: Compute LaTeX roots
id: roots
run: |
{
echo "root_files<<EOF"
echo "main.tex"
for file in paper/src/main-mirror-*.tex; do
if [ -f "$file" ]; then
basename "$file"
fi
done
echo "EOF"
} >> "$GITHUB_OUTPUT"
echo "Compiling roots:"
echo "main.tex"
for file in paper/src/main-mirror-*.tex; do
if [ -f "$file" ]; then
basename "$file"
fi
done
- name: Compile LaTeX documents
uses: xu-cheng/latex-action@v3
with:
root_file: main.tex
root_file: ${{ steps.roots.outputs.root_files }}
working_directory: paper/src
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
pre_compile: bash ../concat_code.sh
- name: Upload PDF
args: -pdf -f -interaction=nonstopmode -file-line-error -r ../.latexmkrc -outdir=../build
- name: Upload PDF artifacts
uses: actions/upload-artifact@v4
with:
name: thesis-pdf
path: paper/build/main.pdf
path: |
paper/build/main.pdf
paper/build/main-mirror-*.pdf
- name: Get current date
id: date
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Upload to Cloudflare R2
if: ${{ env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' && env.R2_ENDPOINT != '' && env.R2_BUCKET_NAME != '' }}
env:
AWS_ACCESS_KEY_ID: ${{ env.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ env.R2_SECRET_ACCESS_KEY }}
AWS_ENDPOINT_URL: ${{ env.R2_ENDPOINT }}
DATE: ${{ steps.date.outputs.date }}
BUCKET_NAME: ${{ env.R2_BUCKET_NAME }}
run: |
pip install boto3
python3 << 'EOF'
import boto3
import os
s3 = boto3.client('s3',
endpoint_url=os.environ['AWS_ENDPOINT_URL'],
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY']
)
date = os.environ['DATE']
bucket = os.environ['BUCKET_NAME']
# upload dated version
dated_filename = f"thesis-{date}.pdf"
s3.upload_file(
'paper/build/main.pdf',
bucket,
dated_filename,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {dated_filename}")
# upload latest version
s3.upload_file(
'paper/build/main.pdf',
bucket,
'thesis-latest.pdf',
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded thesis-latest.pdf")
# upload mirror versions (if generated)
build_dir = 'paper/build'
for filename in os.listdir(build_dir):
if not filename.startswith('main-mirror-') or not filename.endswith('.pdf'):
continue
mirror_name = filename[len('main-mirror-'):-4]
source_path = os.path.join(build_dir, filename)
dated_mirror = f"thesis-{mirror_name}-{date}.pdf"
latest_mirror = f"thesis-{mirror_name}-latest.pdf"
namespaced_dated = f"mirrors/{mirror_name}/thesis-{date}.pdf"
namespaced_latest = f"mirrors/{mirror_name}/thesis-latest.pdf"
s3.upload_file(
source_path,
bucket,
dated_mirror,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {dated_mirror}")
s3.upload_file(
source_path,
bucket,
latest_mirror,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {latest_mirror}")
s3.upload_file(
source_path,
bucket,
namespaced_dated,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {namespaced_dated}")
s3.upload_file(
source_path,
bucket,
namespaced_latest,
ExtraArgs={'ContentType': 'application/pdf'}
)
print(f"Uploaded {namespaced_latest}")
EOF

67
.gitignore vendored
View File

@@ -1,26 +1,91 @@
# environment and secrets
**/.env
.env.*
!.env.*.example
**/.venv
# python build/cache artifacts
**/__pycache__
phantom.egg-info/
*.egg-info/
# notebook artifacts
**/.ipynb_checkpoints/
**/.virtual_documents/
# editor/tool state
**/.pdf-view-restore
.nextstep
.ignore-gitlogue
.cloudflare
.nx/
node_modules/
dist/
# generated svg/graphics
**/session_*.svg
**/*graph.svg
**/auto/*.el
# misc generated
*.old
**/package-lock.json
**/*.parquet
**/_build/
# paper build artifacts
paper/src/bib/auto
paper/src/auto/*
paper/src/bib/auto
paper/template/*
paper/build-cais/
paper/defense/manim/media/
paper/defense/manim/.manim/
paper/src/main.pdf
paper/src/main-blx.bib
paper/src/svg-inkscape/
paper/variations/
paper/src/graphics/test_*.png
thesis-latest.pdf
# experiment run artifacts and logs
docs/goals/*.md
PHANTOM.wiki/
experiments/airflow/logs/*
experiments/airflow/logs/scheduler/
experiments/airflow/logs/dag_processor_manager/
experiments/collected_data/
experiments/agents/collected_data/
tests/e2e/test-results/
tests/e2e/node_modules/**
# rl/sim run outputs
sim/rl/behavior_loader/*.dot
sim/rl/behavior_loader/*.png
sim/rl/behavior_loader/*.svg
sim/rl/behavior_loader/*.pdf
tests/e2e/node_modules/**
sim/rl/runs/
lab/case/thesis/runs*/
sim/case/thesis_simplified/runs*/
# model binaries
engine/models/*.zip
engine/studies/results/*
*.zip
# wandb local state
wandb/
# data directory (large datasets)
data/
# ktem local app data
ktem_app_data/
# generated visualization pdfs
*_mdp_viz.pdf
phantom_env_comparison.png
sim/phantom_env_comparison.png
# web clone
PHANTOM_web/*

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
CLAUDE.md

179
Makefile
View File

@@ -8,77 +8,180 @@ VENV := .venv
PYTHON := $(VENV)/bin/python
PIP := $(VENV)/bin/pip
PYTEST := $(VENV)/bin/pytest
NX := npx nx
SWEEP_ENV_FILE ?= .env.sweep
WANDB_ENTITY ?=
WANDB_PROJECT ?= capstone
SWEEP_ID ?=
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
LOCAL_BENCHMARK_ARGS ?= --tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
SIMPLE_BENCHMARK_ARGS ?= --tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
BENCHMARK_AGENT_ARGS ?=
AGENT_COUNT ?= 0
REPO_URL ?=
BRANCH ?= main
WORKDIR ?= $(HOME)/PHANTOM-agent
AGENT_LOOP ?= 1
RETRY_SECONDS ?= 20
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
.DEFAULT_GOAL := help
.PHONY: help
help:
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
@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 ""
@echo "Build general public version:"
@echo " make pdf.genpop"
@echo ""
@echo "Local wandb run:"
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
@echo ""
@echo "Local benchmark run:"
@echo " make benchmark LOCAL_BENCHMARK_ARGS='--tiers static,surge,linear --alpha-values 0.0,0.3 --episodes 3 --no-wandb'"
@echo ""
@echo "Simple benchmark run (.env.sweep defaults, robust+no_robust compare by default):"
@echo " make benchmark.simple"
@echo ""
@echo "Local sweep agent from this repo:"
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
@echo ""
@echo "Bootstrap private repo worker from anywhere:"
@echo " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id"
@echo ""
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
$(BUILDDIR):
mkdir -p paper/$(BUILDDIR)
.PHONY: pdf.build
pdf.build: $(BUILDDIR)
@bash paper/concat_code.sh
@cd $(SRCDIR) && \
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
-interaction=nonstopmode -file-line-error \
-outdir=../$(BUILDDIR) $(TEX)
pdf.build:
@$(NX) run paper:build
.PHONY: pdf.watch
pdf.watch: $(BUILDDIR)
@cd $(SRCDIR) && \
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
-interaction=nonstopmode -file-line-error \
-r ../.latexmkrc \
-outdir=../$(BUILDDIR) $(TEX)
pdf.watch:
@$(NX) run paper:watch
.PHONY: pdf.clean
pdf.clean:
@cd $(SRCDIR) && \
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
rm -rf paper/$(BUILDDIR)/*
@$(NX) run paper:clean
.PHONY: pdf.genpop
pdf.genpop:
@bash scripts/nx_paper.sh build-genpop
.PHONY: pdf.genpop.watch
pdf.genpop.watch:
@bash scripts/nx_paper.sh watch-genpop
.PHONY: pdf.arxiv
pdf.arxiv:
@bash scripts/nx_paper.sh build-arxiv
.PHONY: test.backend
test.backend: $(VENV)
$(PYTEST) -v
test.backend:
@$(NX) run research:test
.PHONY: test.e2e
test.e2e:
@cd tests/e2e && npm install
@cd tests/e2e && npx playwright install chromium
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
@cd tests/e2e && npm test
@$(NX) run e2e:test
.PHONY: test.all
test.all: test.backend test.e2e
test.all:
@$(NX) run-many -t test --projects=research,e2e --parallel=1
.PHONY: web.dev
web.dev:
@cd web && npm install && npm run dev
@$(NX) run web:dev
$(VENV):
python3 -m venv $(VENV)
$(PIP) install --upgrade pip
.PHONY: install
install: $(VENV)
$(PIP) install -r requirements.txt
install:
@$(NX) run research:install
.PHONY: train
train:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_TRAIN_ARGS="$(LOCAL_TRAIN_ARGS)" $(NX) run research:train
.PHONY: benchmark
benchmark:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_BENCHMARK_ARGS="$(LOCAL_BENCHMARK_ARGS)" $(NX) run research:benchmark
.PHONY: benchmark.simple
benchmark.simple:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SIMPLE_BENCHMARK_ARGS="$(SIMPLE_BENCHMARK_ARGS)" PHANTOM_BENCHMARK_COMPARE_ROBUST="$(PHANTOM_BENCHMARK_COMPARE_ROBUST)" $(NX) run research:benchmark-simple
.PHONY: benchmark.agent
benchmark.agent:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" BENCHMARK_AGENT_ARGS="$(BENCHMARK_AGENT_ARGS)" $(NX) run research:benchmark-agent
.PHONY: train.agent
train.agent:
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" $(NX) run research:train-agent
.PHONY: train.bootstrap
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: stats.lines
stats.lines:
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
@$(NX) run research:stats
.PHONY: wordcount
wordcount:
@$(NX) run paper:wordcount
.PHONY: docker.train.publish
docker.train.publish:
@TRAIN_IMAGE_REF="$(TRAIN_IMAGE_REF)" $(NX) run research:docker-train-publish
.PHONY: backend.server backend.provider backend.worker platform.up platform.down platform.logs
backend.server:
@$(NX) run backend-server:dev
backend.provider:
@$(NX) run pricing-provider:dev
backend.worker:
@$(NX) run backend-worker:dev
platform.up:
@$(NX) run platform:up
platform.down:
@$(NX) run platform:down
platform.logs:
@$(NX) run platform:logs
.PHONY: pdf clean watch run.webapp test count-lines all
pdf: pdf.build
clean: pdf.clean
watch: pdf.watch
run.webapp: web.dev
test: test.backend
count-lines: stats.lines
all: pdf.build
pdf:
@$(NX) run paper:build
clean:
@$(NX) run paper:clean
watch:
@$(NX) run paper:watch
run.webapp:
@$(NX) run web:dev
test:
@$(NX) run research:test
count-lines:
@$(NX) run research:stats
all:
@$(NX) run paper:build

View File

@@ -3,10 +3,92 @@
### PHANTOM
[![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/)
[![Vercel Deploy](https://deploy-badge.vercel.app/?url=https://phantom-hotel.vercel.app&name=Hotel)](https://phantom-hotel.vercel.app)
[![Vercel Deploy](https://deploy-badge.vercel.app/?url=https://phantom-airline.vercel.app&name=Airline)](https://phantom-airline.vercel.app)
```mermaid
mindmap
PHANTOM((PHANTOM Project))
North Star
Study how automated actors change markets
Build an experimentation platform for real-world-like commerce
Two-loop learning system
Online observation loop
Offline "defense gym" loop
Core Economic Questions
Price Discovery
How prices respond to demand signals
How signal quality changes with bots/agents
Demand & Elasticity
Shifts in willingness-to-pay
Short-run vs long-run elasticity
Market Efficiency & Welfare
Consumer surplus vs producer surplus
Deadweight loss from frictions/manipulation
Price Discrimination & Segmentation
Behavioral feature-based segmentation
Fairness vs profitability tradeoffs
Information Asymmetry
Agents amplify search and arbitrage
Sellers infer more about buyers; buyers infer more about sellers
Strategic Interaction
Consumers vs firms vs agents
Feedback loops: policy ↔ behavior ↔ price
Market Power & Competition
Algorithmic pricing as competitive tool
Risks: tacit coordination / "algorithmic collusion"
Externalities
Congestion and attention costs
Spillovers: one segments behavior affects others prices
System-Level View
Participants
Humans
Agents (automated buyers/actors)
Firms (pricing decision-makers)
Platform (measurement + control layer)
Markets Simulated
Repeated transactions
Limited inventory / capacity constraints (conceptually)
Time dynamics (learning over time)
Interventions
Pricing policies
Experiment assignment / randomized exposure
Agent behavioral policies (task-driven)
Measurement & Causal Inference
What is observed
Actions (search, click, purchase intent)
Context (product attributes, time, exposure)
Outcomes (conversion, revenue, churn proxies)
Identification strategy
A/B tests and randomization
Counterfactual baselines
Robustness checks (offline replay)
Key metrics
Revenue / profit proxies
Conversion & bounce
Price volatility / stability
Welfare proxies (e.g., dispersion, access)
Risk, Governance, and Ethics
Manipulation & Integrity
Bot-driven demand distortion
Measurement contamination
Fairness & Transparency
Differential pricing concerns
Explainability and auditability
Safety Constraints
Guardrails on price moves
Monitoring for runaway feedback loops
Outputs
Insights
When do agents raise/lower prices via behavior shifts?
Which market designs are robust to automation?
Defenses
Agent-aware pricing policies (robust control)
Detection + mitigation strategies (feature-level separability)
Platform Value
Reusable testbed for market + AI-agent research
```

6
TPUS/README.md Normal file
View File

@@ -0,0 +1,6 @@
64 spot Cloud TPU v6e chips in zone europe-west4-a
32 spot Cloud TPU v4 chips in zone us-central2-b
64 spot Cloud TPU v5e chips in zone us-central1-a
64 spot Cloud TPU v6e chips in zone us-east1-d
32 on-demand Cloud TPU v4 chips in zone us-central2-b
64 spot Cloud TPU v5e chips in zone europe-west4-b

View File

@@ -0,0 +1,22 @@
# 32 spot Cloud TPU v4 chips in zone us-central2-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv4s32spotUC2B
export TPU_NAME=tpu-v4-32-uc2b-spot
export ZONE=us-central2-b
export ACCELERATOR_TYPE=v4-32
export RUNTIME_VERSION=v2-alpha-tpuv4
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

13
TPUS/v4_uscentral2b.sh Normal file
View File

@@ -0,0 +1,13 @@
# 32 on-demand Cloud TPU v4 chips in zone us-central2-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUlong
export ZONE=us-central2-b
export ACCELERATOR_TYPE=v4-32
export RUNTIME_VERSION=v2-alpha-tpuv4
#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION}
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION}

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v5e chips in zone europe-west4-b
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv5e64spotEW4B
export TPU_NAME=tpu-v5e-64-ew4b
export ZONE=europe-west4-b
export ACCELERATOR_TYPE=v5e-64
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v5e chips in zone us-central1-a
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv5e64spotUC1A
export TPU_NAME=tpu-v5e-64-uc1a
export ZONE=us-central1-a
export ACCELERATOR_TYPE=v5e-64
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v6e chips in zone europe-west4-a
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv6e64spotEW4A
export TPU_NAME=tpu-v6e-64-ew4a
export ZONE=europe-west4-a
export ACCELERATOR_TYPE=v6e-64
export RUNTIME_VERSION=v2-alpha-tpuv6e
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

View File

@@ -0,0 +1,22 @@
# 64 spot Cloud TPU v6e chips in zone us-east1-d
export PROJECT_ID=phantom-trc
export QR_NAME=TPUv6e64spotUE1D
export TPU_NAME=tpu-v6e-64-ue1d
export ZONE=us-east1-d
export ACCELERATOR_TYPE=v6e-64
export RUNTIME_VERSION=v2-alpha-tpuv6e
gcloud compute tpus tpu-vm create ${TPU_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--accelerator-type=${ACCELERATOR_TYPE} \
--version=${RUNTIME_VERSION} \
--spot \
|| \
gcloud compute tpus queued-resources create ${QR_NAME} \
--project=${PROJECT_ID} \
--zone=${ZONE} \
--node-id=${TPU_NAME} \
--accelerator-type=${ACCELERATOR_TYPE} \
--runtime-version=${RUNTIME_VERSION} \
--spot

33
backend/project.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$schema": "../node_modules/nx/schemas/project-schema.json",
"name": "platform",
"projectType": "application",
"sourceRoot": "backend",
"targets": {
"up": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose up -d",
"cwd": "."
}
},
"down": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose down",
"cwd": "."
}
},
"logs": {
"executor": "nx:run-commands",
"options": {
"command": "docker compose logs --tail=100 -f",
"cwd": "."
}
}
},
"tags": [
"scope:platform",
"type:infra"
]
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "pricing-provider",
"projectType": "application",
"sourceRoot": "backend/provider",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/provider"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${PROVIDER_PORT:-5001} --reload",
"cwd": "backend/provider"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${PROVIDER_PORT:-5001}",
"cwd": "backend/provider"
}
}
},
"tags": [
"scope:backend",
"type:provider"
]
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "backend-server",
"projectType": "application",
"sourceRoot": "backend/server",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/server"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${BACKEND_PORT:-5000} --reload",
"cwd": "backend/server"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/uvicorn app:app --host 0.0.0.0 --port ${BACKEND_PORT:-5000}",
"cwd": "backend/server"
}
}
},
"tags": [
"scope:backend",
"type:api"
]
}

View File

@@ -1,6 +1,6 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
kafka-python==2.0.2
pydantic==2.5.0
python-dotenv==1.0.0
supabase==2.9.1
fastapi>=0.135,<0.136
uvicorn[standard]>=0.41,<0.42
kafka-python>=2.3,<2.4
pydantic>=2.12,<3
python-dotenv>=1.0,<2
supabase>=2.28,<3

View File

@@ -0,0 +1,39 @@
{
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"name": "backend-worker",
"projectType": "application",
"sourceRoot": "backend/worker",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash -lc '[ -x ../../.venv/bin/python ] || python3 -m venv ../../.venv; ../../.venv/bin/python -m ensurepip --upgrade; ../../.venv/bin/python -m pip install -r requirements.txt'",
"cwd": "backend/worker"
}
},
"dev": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/celery -A main:app worker --loglevel=info",
"cwd": "backend/worker"
}
},
"start": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "../../.venv/bin/python main.py",
"cwd": "backend/worker"
}
}
},
"tags": [
"scope:backend",
"type:worker"
]
}

View File

@@ -0,0 +1,3 @@
celery>=5.3,<6
python-dotenv>=1.0.0
redis>=5.0.0

15
docker/Trainer.dockerfile Normal file
View File

@@ -0,0 +1,15 @@
# syntax=docker/dockerfile:1.7
FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime AS gpu
WORKDIR /app
COPY docker/trainer.requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
COPY engine /app/engine
ENV PYTHONPATH=/app
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env sh
set -eu
if [ -z "${SWEEP_ID:-}" ]; then
echo "SWEEP_ID is required"
exit 1
fi
set -- python -m engine.train --sweep-agent --sweep-id "${SWEEP_ID}"
if [ -n "${PHANTOM_DEFAULT_AGENT_ARGS:-}" ]; then
set -- "$@" ${PHANTOM_DEFAULT_AGENT_ARGS}
fi
if [ -n "${TRAIN_ARGS:-}" ]; then
set -- "$@" ${TRAIN_ARGS}
fi
if [ "${AGENT_COUNT:-0}" != "0" ]; then
set -- "$@" --count "${AGENT_COUNT}"
fi
exec "$@"

View File

@@ -0,0 +1,7 @@
numpy>=1.24.0
pandas>=2.0.0
scipy>=1.11.0
gymnasium>=0.29.0
stable-baselines3>=2.2.0
tensorboard>=2.15.0
wandb>=0.17.0

21
docs/goals/goals.csv Normal file
View File

@@ -0,0 +1,21 @@
store_mode,task_name,task_description,definition_of_done
airline,The Indecisive Executive (SEA-LAX),"You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the ""Flexible"" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket.","Booking for SEA-LAX Business Class is completed."
airline,The Cross-Country Splurge (LAX-JFK),"You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class.","Booking for LAX-JFK First Class is completed."
airline,The Budget Student (DFW-ORD),"You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the ""Premium"" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket.","Booking for DFW-ORD Economy Class is completed."
airline,The Quick Hop Commuter (LAX-SFO),"You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it.","Booking for LAX-SFO is completed."
airline,The Status Chaser (SFO-SEA),"You are trying to earn airline points and need a ""Premium"" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the ""Premium"" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat.","Booking for SFO-SEA Premium Economy is completed."
airline,The Family Reunion (MIA-ATL),"You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option.","Booking for MIA-ATL (Premium) is completed."
airline,The Red Eye Skeptic (LAX-JFK),"You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time.","Booking for LAX-JFK is completed."
airline,The Refundable Requirement (ATL-DFW),"Your meeting in Dallas might get cancelled, so you strictly need a ""Refundable"" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists ""Refundable"". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind.","Booking for ATL-DFW First Class is completed."
airline,The Hub Connector (ORD-MIA),"You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the ""stops"" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy.","Booking for ORD-MIA Economy is completed."
airline,The West Coast Hopper (SEA-LAX Business),"You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat.","Booking for SEA-LAX Business is completed."
hotel,The Honeymoon Suite (Presidential),"It is your honeymoon. You want the best room available, specifically one with a ""jacuzzi"". 1) Search for a room for 2 people. 2) Identify the ""Presidential Suite"". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the ""Executive Suite"" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it.","Booking for the Presidential Suite is completed."
hotel,The Digital Nomad (Executive),"You are working remotely and strictly need a ""workspace"". 1) Search for a room. 2) Check the ""Executive Suite"" details for a workspace. 3) Check the ""Deluxe Room"" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it.","Booking for the Executive Suite is completed."
hotel,The Safety First (Superior),"You are traveling with valuables and need a ""safe"" in the room. 1) Search for a room. 2) Look at the ""Standard Room"" amenities. Does it have a safe? 3) Look at the ""Superior Room"". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room.","Booking for the Superior Room is completed."
hotel,The Bachelor Party (Max Occupancy),"You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a ""Standard Room"". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite.","Booking for the Presidential Suite is completed."
hotel,The Budget Refundable (Junior),"You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the ""Standard"" and ""Superior"" rooms. Notice they are likely Non-Refundable. 4) Find the ""Junior Suite"" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility.","Booking for the Junior Suite is completed."
hotel,The View Hunter (Executive),"You want a room with a ""city_view"" or balcony. 1) Search for a room. 2) Check the amenities of the ""Deluxe Room"". 3) Check the amenities of the ""Executive Suite"". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it.","Booking for the Executive Suite is completed."
hotel,The Just-A-Bed (Standard),"You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has ""wifi"". 4) Briefly glance at the ""Superior Room"" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately.","Booking for the Standard Room is completed."
hotel,The Family Vacation (Deluxe),"You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the ""Deluxe Room"". 3) Check the amenities for ""coffee_maker"" (parents need coffee). 4) Compare it with the ""Junior Suite"". 5) Decide the Deluxe Room is sufficient value and book it.","Booking for the Deluxe Room is completed."
hotel,The Long Stay (Junior),"You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the ""Junior Suite"". 3) Check the amenities for a ""mini_fridge"" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the ""Standard Room"" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it.","Booking for the Junior Suite is completed."
hotel,The Last Minute Panic (Superior),"It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the ""Superior Room"" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible.","Booking for the Superior Room is completed."
1 store_mode task_name task_description definition_of_done
2 airline The Indecisive Executive (SEA-LAX) You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the "Flexible" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket. Booking for SEA-LAX Business Class is completed.
3 airline The Cross-Country Splurge (LAX-JFK) You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class. Booking for LAX-JFK First Class is completed.
4 airline The Budget Student (DFW-ORD) You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the "Premium" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket. Booking for DFW-ORD Economy Class is completed.
5 airline The Quick Hop Commuter (LAX-SFO) You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it. Booking for LAX-SFO is completed.
6 airline The Status Chaser (SFO-SEA) You are trying to earn airline points and need a "Premium" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the "Premium" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat. Booking for SFO-SEA Premium Economy is completed.
7 airline The Family Reunion (MIA-ATL) You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option. Booking for MIA-ATL (Premium) is completed.
8 airline The Red Eye Skeptic (LAX-JFK) You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time. Booking for LAX-JFK is completed.
9 airline The Refundable Requirement (ATL-DFW) Your meeting in Dallas might get cancelled, so you strictly need a "Refundable" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists "Refundable". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind. Booking for ATL-DFW First Class is completed.
10 airline The Hub Connector (ORD-MIA) You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the "stops" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy. Booking for ORD-MIA Economy is completed.
11 airline The West Coast Hopper (SEA-LAX Business) You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat. Booking for SEA-LAX Business is completed.
12 hotel The Honeymoon Suite (Presidential) It is your honeymoon. You want the best room available, specifically one with a "jacuzzi". 1) Search for a room for 2 people. 2) Identify the "Presidential Suite". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the "Executive Suite" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it. Booking for the Presidential Suite is completed.
13 hotel The Digital Nomad (Executive) You are working remotely and strictly need a "workspace". 1) Search for a room. 2) Check the "Executive Suite" details for a workspace. 3) Check the "Deluxe Room" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it. Booking for the Executive Suite is completed.
14 hotel The Safety First (Superior) You are traveling with valuables and need a "safe" in the room. 1) Search for a room. 2) Look at the "Standard Room" amenities. Does it have a safe? 3) Look at the "Superior Room". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room. Booking for the Superior Room is completed.
15 hotel The Bachelor Party (Max Occupancy) You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a "Standard Room". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite. Booking for the Presidential Suite is completed.
16 hotel The Budget Refundable (Junior) You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the "Standard" and "Superior" rooms. Notice they are likely Non-Refundable. 4) Find the "Junior Suite" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility. Booking for the Junior Suite is completed.
17 hotel The View Hunter (Executive) You want a room with a "city_view" or balcony. 1) Search for a room. 2) Check the amenities of the "Deluxe Room". 3) Check the amenities of the "Executive Suite". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it. Booking for the Executive Suite is completed.
18 hotel The Just-A-Bed (Standard) You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has "wifi". 4) Briefly glance at the "Superior Room" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately. Booking for the Standard Room is completed.
19 hotel The Family Vacation (Deluxe) You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the "Deluxe Room". 3) Check the amenities for "coffee_maker" (parents need coffee). 4) Compare it with the "Junior Suite". 5) Decide the Deluxe Room is sufficient value and book it. Booking for the Deluxe Room is completed.
20 hotel The Long Stay (Junior) You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the "Junior Suite". 3) Check the amenities for a "mini_fridge" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the "Standard Room" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it. Booking for the Junior Suite is completed.
21 hotel The Last Minute Panic (Superior) It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the "Superior Room" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible. Booking for the Superior Room is completed.

View File

@@ -17,8 +17,8 @@
<meta property="og:site_name" content="PHANTOM Research">
<meta property="og:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta property="og:description" content="Developing pricing heuristics to protect e-commerce platforms from systematic exploitation by LLM agents in dynamic pricing environments through behavioral signature detection.">
<meta property="og:url" content="TODO">
<meta property="og:image" content="TODO">
<meta property="og:url" content="https://velocitatem.github.io/PHANTOM/">
<meta property="og:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="PHANTOM Research Preview">
@@ -30,24 +30,19 @@
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<!-- TODO: Replace with your lab/institution Twitter handle -->
<meta name="twitter:site" content="@YOUR_TWITTER_HANDLE">
<!-- TODO: Replace with first author's Twitter handle -->
<meta name="twitter:creator" content="@AUTHOR_TWITTER_HANDLE">
<!-- TODO: Same as paper title above -->
<meta name="twitter:title" content="PAPER_TITLE">
<!-- TODO: Same as description above -->
<meta name="twitter:description" content="BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS">
<!-- TODO: Same as social preview image above -->
<meta name="twitter:image" content="https://YOUR_DOMAIN.com/static/images/social_preview.png">
<meta name="twitter:image:alt" content="PAPER_TITLE - Research Preview">
<meta name="twitter:site" content="@velocitatem">
<meta name="twitter:creator" content="@velocitatem">
<meta name="twitter:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta name="twitter:description" content="A thesis project on defending dynamic pricing against LLM-driven reconnaissance and transaction orchestration.">
<meta name="twitter:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
<meta name="twitter:image:alt" content="PHANTOM research visual">
<!-- Academic/Research Specific -->
<meta name="citation_title" content="Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
<meta name="citation_author" content="Rösel, Daniel">
<meta name="citation_publication_date" content="2025">
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
<meta name="citation_pdf_url" content="TODO">
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
<!-- Additional SEO -->
<meta name="theme-color" content="#2563eb">
@@ -103,50 +98,42 @@
{
"@context": "https://schema.org",
"@type": "ScholarlyArticle",
"headline": "PAPER_TITLE",
"description": "BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS",
"headline": "PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms",
"description": "Research on preserving dynamic pricing integrity under LLM-mediated reconnaissance and purchasing behavior.",
"author": [
{
"@type": "Person",
"name": "FIRST_AUTHOR_NAME",
"name": "Daniel Rösel",
"affiliation": {
"@type": "Organization",
"name": "INSTITUTION_NAME"
}
},
{
"@type": "Person",
"name": "SECOND_AUTHOR_NAME",
"affiliation": {
"@type": "Organization",
"name": "INSTITUTION_NAME"
"name": "IE University"
}
}
],
"datePublished": "2024-01-01",
"datePublished": "2025-01-01",
"publisher": {
"@type": "Organization",
"name": "CONFERENCE_OR_JOURNAL_NAME"
"name": "IE University"
},
"url": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE",
"image": "https://YOUR_DOMAIN.com/static/images/social_preview.png",
"keywords": ["KEYWORD1", "KEYWORD2", "KEYWORD3", "machine learning", "computer vision"],
"abstract": "FULL_ABSTRACT_TEXT_HERE",
"citation": "BIBTEX_CITATION_HERE",
"url": "https://velocitatem.github.io/PHANTOM/",
"image": "https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg",
"keywords": ["dynamic pricing", "llm agents", "e-commerce", "distributionally robust optimization", "reinforcement learning"],
"abstract": "This thesis formalizes Cost of Information erosion under agentic reconnaissance, learns separable human and agent behavior kernels, and trains contamination-aware robust pricing policies.",
"citation": "Rösel, Daniel. PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms. IE University, 2025.",
"isAccessibleForFree": true,
"license": "https://creativecommons.org/licenses/by/4.0/",
"mainEntity": {
"@type": "WebPage",
"@id": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE"
"@id": "https://velocitatem.github.io/PHANTOM/"
},
"about": [
{
"@type": "Thing",
"name": "RESEARCH_AREA_1"
"name": "Dynamic Pricing"
},
{
"@type": "Thing",
"name": "RESEARCH_AREA_2"
"name": "Agent Behavior Modeling"
}
]
}
@@ -158,8 +145,7 @@
"@context": "https://schema.org",
"@type": "Organization",
"name": "IE University",
"url": "https://www.ie.edu",
"logo": "TODO"
"url": "https://www.ie.edu"
}
</script>
</head>
@@ -173,45 +159,72 @@
<!-- More Works Dropdown -->
<div class="more-works-container">
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View More Works from Our Lab">
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View project links and artifacts">
<i class="fas fa-flask"></i>
More Works
Project Links
<i class="fas fa-chevron-down dropdown-arrow"></i>
</button>
<div class="more-works-dropdown" id="moreWorksDropdown">
<div class="dropdown-header">
<h4>More Works from Our Lab</h4>
<h4>Project Links</h4>
<button class="close-btn" onclick="toggleMoreWorks()">
<i class="fas fa-times"></i>
</button>
</div>
<div class="works-list">
<!-- TODO: Replace with your lab's related works -->
<a href="https://arxiv.org/abs/PAPER_ID_1" class="work-item" target="_blank">
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" class="work-item" target="_blank">
<div class="work-info">
<!-- TODO: Replace with actual paper title -->
<h5>Paper Title 1</h5>
<!-- TODO: Replace with brief description -->
<p>Brief description of the work and its main contribution.</p>
<!-- TODO: Replace with venue and year -->
<span class="work-venue">Conference/Journal 2024</span>
<h5>Thesis PDF</h5>
<p>Latest public build of the full thesis document.</p>
<span class="work-venue">IE University, 2025</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<!-- TODO: Add more related works or remove extra items -->
<a href="https://arxiv.org/abs/PAPER_ID_2" class="work-item" target="_blank">
<a href="https://github.com/velocitatem/PHANTOM" class="work-item" target="_blank">
<div class="work-info">
<h5>Paper Title 2</h5>
<p>Brief description of the work and its main contribution.</p>
<span class="work-venue">Conference/Journal 2023</span>
<h5>PHANTOM Repository</h5>
<p>Monorepo with paper source, platform code, and experiments.</p>
<span class="work-venue">Open Source</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://arxiv.org/abs/PAPER_ID_3" class="work-item" target="_blank">
<a href="https://github.com/velocitatem/p4p" class="work-item" target="_blank">
<div class="work-info">
<h5>Paper Title 3</h5>
<p>Brief description of the work and its main contribution.</p>
<span class="work-venue">Conference/Journal 2023</span>
<h5>P4P Interaction Layer</h5>
<p>Reusable storefront and logging layer released for replication.</p>
<span class="work-venue">Public Artifact</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://phantom-hotel.vercel.app" class="work-item" target="_blank">
<div class="work-info">
<h5>Hotel Mode Demo</h5>
<p>Public deployment of the hotel-style experiment interface.</p>
<span class="work-venue">Live Demo</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://phantom-airline.vercel.app" class="work-item" target="_blank">
<div class="work-info">
<h5>Airline Mode Demo</h5>
<p>Public deployment of the airline-style experiment interface.</p>
<span class="work-venue">Live Demo</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="https://blog.alves.world/series/phantom" class="work-item" target="_blank">
<div class="work-info">
<h5>Blog Series</h5>
<p>Behind-the-scenes posts covering thesis process, tooling, and insights.</p>
<span class="work-venue">To Boldly Code</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
<a href="goals/README.md" class="work-item" target="_blank">
<div class="work-info">
<h5>Goal Library</h5>
<p>Task definitions used to assign actor objectives in experiments.</p>
<span class="work-venue">Experiment Design</span>
</div>
<i class="fas fa-external-link-alt"></i>
</a>
@@ -233,14 +246,23 @@
<div class="is-size-5 publication-authors">
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
</div>
<div class="column has-text-centered">
<div class="publication-links">
<!-- TODO: Update with your arXiv paper ID -->
<span class="link-block">
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
<a href="https://blog.alves.world/series/phantom" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-blog"></i>
</span>
<span>Blog Series</span>
</a>
</span>
<span class="link-block">
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-file-pdf"></i>
@@ -249,14 +271,13 @@
</a>
</span>
<!-- TODO: Add your supplementary material PDF or remove this section -->
<span class="link-block">
<a href="static/pdfs/supplementary_material.pdf" target="_blank"
<a href="goals/goals.csv" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-file-pdf"></i>
<i class="fas fa-list"></i>
</span>
<span>Supplementary</span>
<span>Goal Set</span>
</a>
</span>
@@ -270,14 +291,23 @@
</a>
</span>
<!-- TODO: Update with your arXiv paper ID -->
<span class="link-block">
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
<a href="https://phantom-hotel.vercel.app" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="ai ai-arxiv"></i>
<i class="fas fa-globe"></i>
</span>
<span>arXiv</span>
<span>Hotel Demo</span>
</a>
</span>
<span class="link-block">
<a href="https://phantom-airline.vercel.app" target="_blank"
class="external-link button is-normal is-rounded is-dark">
<span class="icon">
<i class="fas fa-plane"></i>
</span>
<span>Airline Demo</span>
</a>
</span>
</div>
@@ -285,27 +315,19 @@
</div>
</div>
</div>
</div>
</section>
<!-- Teaser video-->
<section class="hero teaser">
<div class="container is-max-desktop">
<div class="hero-body">
<!-- TODO: Replace with your teaser video -->
<video poster="" id="tree" autoplay controls muted loop height="100%" preload="metadata">
<!-- TODO: Add your video file path here -->
<source src="static/videos/banner_video.mp4" type="video/mp4">
</video>
<!-- TODO: Replace with your video description -->
<h2 class="subtitle has-text-centered">
Aliquam vitae elit ullamcorper tellus egestas pellentesque. Ut lacus tellus, maximus vel lectus at, placerat pretium mi. Maecenas dignissim tincidunt vestibulum. Sed consequat hendrerit nisl ut maximus.
</h2>
<div class="publication-banner">
<img src="static/images/banner.svg" alt="PHANTOM teaser diagram connecting vulnerability, behavioral signal, and robust control" width="1920" height="1080" decoding="async" style="display:block; width:100%; height:auto;" onerror="this.onerror=null;this.src='static/images/carousel2.jpg';"/>
</div>
</div>
</div>
</section>
<!-- End teaser video -->
<!-- Paper abstract -->
<section class="section hero is-light">
@@ -315,7 +337,10 @@
<h2 class="title is-3">Abstract</h2>
<div class="content has-text-justified">
<p>
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
When you shop online, prices often change based on how much interest you show — the more you browse, the more the site learns about your intent and may raise prices accordingly. This works because stores assume that a curious, engaged shopper is more likely to buy. But AI assistants are now doing the shopping research on behalf of users: they browse in one session to gather price information and then let the user purchase in a fresh session at the lower, unadjusted price. The store never sees the connection between the two, so it never gets to factor in that genuine intent — and loses the revenue it would have earned.
</p>
<p>
PHANTOM studies this problem and builds defenses against it. We created a realistic fake store (in hotel and airline modes) where both real people and AI agents were given shopping tasks, and we recorded every click, scroll, and page visit. By comparing how humans and AI agents move through a site, we found clear patterns that tell them apart. We then used those patterns to build a smarter pricing system that can recognize when it is likely talking to an AI scout and adjust its strategy accordingly — protecting the store's margins without making things worse for genuine shoppers.
</p>
</div>
</div>
@@ -324,97 +349,90 @@
</section>
<!-- End paper abstract -->
<section class="section">
<div class="container is-max-desktop">
<div class="content has-text-justified">
<h2 class="title is-3 has-text-centered">Project Scope</h2>
<p>
The current thesis revision extends both theory and implementation. The main research question is how a pricing system can preserve margin integrity when browsing and purchasing are increasingly orchestrated by AI agents.
</p>
<ul>
<li>Formal contribution: a Cost of Information erosion theorem showing why price-query saturation can collapse dynamic pricing power.</li>
<li>System contribution: a hybrid online/offline stack (Next.js storefront, pricing provider, Kafka event streams, Airflow ETL, Redis serving layer).</li>
<li>Modeling contribution: class-specific transition kernels for human and agent behavior, with KL-divergence based separability scores.</li>
<li>Control contribution: a contamination-aware DR-RL pricing policy trained under distributional uncertainty using Wasserstein-style robustness.</li>
</ul>
<p>
Controlled trials currently include balanced human and agent sessions with goal-driven tasks across hotel and airline interfaces. Early separability results are strong (Mann-Whitney U=2.0, p=0.0006), while robust pricing gains remain regime-dependent and are being calibrated in larger sweeps.
</p>
</div>
</div>
</section>
<!-- Image carousel -->
<!--
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<div id="results-carousel" class="carousel results-carousel">
<div class="item">
<!-- TODO: Replace with your research result images -->
<img src="static/images/carousel1.jpg" alt="First research result visualization" loading="lazy"/>
<!-- TODO: Replace with description of this result -->
<h2 class="subtitle has-text-centered">
First image description.
Early simulator traces showing how policy choice can push prices toward aggressive high-end regimes.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel2.jpg" alt="Second research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Second image description.
Human and agent behavior diverge at the transition-kernel level, enabling usable session-level separability.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel3.jpg" alt="Third research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Third image description.
End-to-end architecture linking web interactions, pricing queries, event streams, and model updates.
</h2>
</div>
<div class="item">
<!-- Your image here -->
<img src="static/images/carousel4.jpg" alt="Fourth research result visualization" loading="lazy"/>
<h2 class="subtitle has-text-centered">
Fourth image description.
Contamination-aware evaluation compares robust and non-robust pricing behavior across alpha sweeps.
</h2>
</div>
</div>
</div>
</div>
</section>
-->
<!-- End image carousel -->
<!-- Youtube video -->
<section class="hero is-small is-light">
<div class="hero-body">
<div class="container">
<!-- Paper video. -->
<h2 class="title is-3">Video Presentation</h2>
<div class="columns is-centered has-text-centered">
<div class="column is-four-fifths">
<div class="publication-video">
<!-- TODO: Replace with your YouTube video ID -->
<iframe src="https://www.youtube.com/embed/JkaxUblCGz0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- End youtube video -->
<!-- Video carousel -->
<section class="hero is-small">
<div class="hero-body">
<div class="container">
<h2 class="title is-3">Another Carousel</h2>
<div id="results-carousel" class="carousel results-carousel">
<h2 class="title is-3">Defense Scenes</h2>
<div id="videos-carousel" class="carousel results-carousel">
<div class="item item-video1">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video1" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel1.mp4" type="video/mp4">
<source src="static/videos/COIFirstPrinciplesScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">COI from first principles.</h2>
</div>
<div class="item item-video2">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video2" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel2.mp4" type="video/mp4">
<source src="static/videos/BehaviorKernelConstructionScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">Behavioral kernel construction: learning how humans and agents differ.</h2>
</div>
<div class="item item-video3">
<!-- TODO: Add poster image for better preview -->
<video poster="" id="video3" controls muted loop height="100%" preload="metadata">
<!-- Your video file here -->
<source src="static/videos/carousel3.mp4" type="video/mp4">
<source src="static/videos/RobustControlScene.mp4" type="video/mp4">
</video>
<h2 class="subtitle has-text-centered">Distributionally robust control loop.</h2>
</div>
</div>
</div>
@@ -431,10 +449,9 @@
<section class="hero is-small is-light">
<div class="hero-body">
<div class="container">
<h2 class="title">Poster</h2>
<h2 class="title">Full Thesis</h2>
<!-- TODO: Replace with your poster PDF -->
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
<iframe title="PHANTOM thesis PDF" src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
</iframe>
</div>
@@ -456,7 +473,7 @@
</div>
<pre id="bibtex-code"><code>@thesis{Rosel2025PHANTOM,
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
author={R{\"o}sel, Daniel},
author={Rösel, Daniel},
school={IE University},
year={2025},
address={Madrid, Spain},

246
docs/static/images/banner.svg vendored Normal file
View File

@@ -0,0 +1,246 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080" style="background-color: #FAFAFA; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
<defs>
<!-- Soft Drop Shadow for Panels -->
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%">
<feDropShadow dx="2" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.06"/>
</filter>
<filter id="light-shadow" x="-5%" y="-5%" width="110%" height="110%">
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.04"/>
</filter>
<!-- Arrowhead Marker -->
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888" />
</marker>
<marker id="arrow-dark" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555555" />
</marker>
</defs>
<!-- COLUMN DIVIDERS -->
<line x1="640" y1="60" x2="640" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
<line x1="1280" y1="60" x2="1280" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
<!-- ========================================================= -->
<!-- COLUMN 1: THE THREAT (COI & SATURATION) -->
<!-- ========================================================= -->
<text x="60" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">1. The Vulnerability</text>
<line x1="60" y1="100" x2="580" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: COI Bell Curve -->
<g transform="translate(60, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Cost of Information from First Principles</text>
<text x="0" y="70" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P ~ π(τ)</text>
<text x="0" y="105" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B"><tspan text-decoration="underline">p</tspan> = reservation price</text>
<text x="0" y="140" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">M = P - <tspan text-decoration="underline">p</tspan></text>
<!-- Bell Curve -->
<path d="M 40 340 C 140 340, 160 160, 260 160 C 360 160, 380 340, 480 340" stroke="#3AB09E" stroke-width="5" fill="none"/>
<line x1="40" y1="340" x2="500" y2="340" stroke="#333" stroke-width="2"/>
<!-- Markers p and E[P] -->
<line x1="150" y1="340" x2="150" y2="160" stroke="#E37862" stroke-width="2" stroke-dasharray="6,4"/>
<text x="150" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" text-anchor="middle">p</text>
<line x1="260" y1="340" x2="260" y2="160" stroke="#85B589" stroke-width="2" stroke-dasharray="6,4"/>
<text x="260" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#85B589" text-anchor="middle">E[P]</text>
<!-- COI Annotation -->
<line x1="150" y1="150" x2="260" y2="150" stroke="#E37862" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
<text x="310" y="138" font-size="16" fill="#E37862" text-anchor="middle">average information rent</text>
<text x="310" y="118" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" font-weight="bold" text-anchor="middle">COI := E[P] - p</text>
</g>
<!-- Bottom: Agent Saturation -->
<g transform="translate(60, 580)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Why COI Erodes with Agent Saturation</text>
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> = min(p</tspan><tspan font-size="14" dy="5">1</tspan><tspan dy="-5">, ..., p</tspan><tspan font-size="14" dy="5">N</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P(p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> > t) = [1 - F(t)]</tspan><tspan font-size="14" dy="-10">N</tspan></text>
<!-- Erosion Graph -->
<rect x="120" y="150" width="280" height="230" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
<line x1="140" y1="350" x2="380" y2="350" stroke="#333" stroke-width="2"/>
<line x1="140" y1="350" x2="140" y2="170" stroke="#333" stroke-width="2"/>
<text x="260" y="375" font-size="16" font-style="italic" fill="#555" text-anchor="middle">F(t)</text>
<text x="120" y="260" font-size="16" font-style="italic" fill="#555" text-anchor="middle" transform="rotate(-90 120 260)">[1 - F(t)]^N</text>
<!-- Curves -->
<path d="M 140 170 C 220 250, 300 320, 380 350" stroke="#4EA5D9" stroke-width="3" fill="none"/>
<text x="390" y="220" font-size="16" fill="#4EA5D9" font-weight="bold">N=1</text>
<path d="M 140 170 C 180 260, 240 330, 380 350" stroke="#85B589" stroke-width="3" fill="none"/>
<text x="390" y="250" font-size="16" fill="#85B589" font-weight="bold">N=4</text>
<path d="M 140 170 C 150 290, 180 340, 380 350" stroke="#E37862" stroke-width="3" fill="none"/>
<text x="390" y="280" font-size="16" fill="#E37862" font-weight="bold">N=16</text>
<text x="260" y="420" font-size="20" fill="#555" text-anchor="middle">As independent query count grows,</text>
<text x="260" y="445" font-size="20" fill="#E37862" font-weight="bold" text-anchor="middle">realizable markup collapses.</text>
</g>
<!-- ========================================================= -->
<!-- COLUMN 2: THE BEHAVIORAL SIGNAL -->
<!-- ========================================================= -->
<text x="700" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">2. The Behavioral Signals</text>
<line x1="700" y1="100" x2="1220" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: Transition Kernels -->
<g transform="translate(700, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">From Session Paths to Transition Kernels</text>
<text x="0" y="75" font-size="20" fill="#85B589" font-weight="bold">human: start → view → detail → cart → purchase</text>
<text x="0" y="115" font-size="20" fill="#E37862" font-weight="bold">agent: start → view → detail → view → detail</text>
<text x="0" y="170" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">
P&#770;(s'|s) = <tspan font-size="18" dy="-12">N(s,s')</tspan> / <tspan font-size="18" dy="12">Σ N(s,k)</tspan>
</text>
<!-- Matrix Representation -->
<rect x="0" y="220" width="500" height="180" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
<text x="125" y="250" font-size="16" fill="#4EA5D9" text-anchor="middle">transition counts N(s,s')</text>
<text x="375" y="250" font-size="16" fill="#85B589" text-anchor="middle">normalized kernel T</text>
<!-- Matrix 1 -->
<g transform="translate(45, 270)">
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
<text x="80" y="20" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 8.00 0.00 0.00</text>
<text x="80" y="50" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 2.00 5.00 1.00</text>
<text x="80" y="80" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 3.00 2.00 4.00</text>
<text x="80" y="110" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 6.00</text>
</g>
<!-- Arrow -->
<line x1="225" y1="320" x2="265" y2="320" stroke="#999" stroke-width="3" marker-end="url(#arrow-dark)"/>
<!-- Matrix 2 -->
<g transform="translate(295, 270)">
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
<text x="80" y="20" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 0.00</text>
<text x="80" y="50" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.25 0.62 0.13</text>
<text x="80" y="80" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.33 0.22 0.45</text>
<text x="80" y="110" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.14 0.00 0.86</text>
</g>
<text x="250" y="440" font-size="18" fill="#777" text-anchor="middle">Kernel shape is the compact behavioral signature used downstream.</text>
</g>
<!-- Bottom: Separability Distributions -->
<g transform="translate(700, 600)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Separability into a Control Signal</text>
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T&#770;' || T&#772;</tspan><tspan font-size="16" dy="5">H</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">A</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T&#770;' || T&#772;</tspan><tspan font-size="16" dy="5">A</tspan><tspan dy="-5">)</tspan></text>
<text x="0" y="155" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
<!-- Curves -->
<g transform="translate(80, 160)">
<line x1="0" y1="200" x2="360" y2="200" stroke="#333" stroke-width="2"/>
<text x="180" y="235" font-family="Georgia, serif" font-style="italic" font-size="22" text-anchor="middle">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
<!-- Human Curve -->
<path d="M 0 200 C 50 200, 80 40, 130 40 C 180 40, 210 200, 260 200" stroke="#4EA5D9" stroke-width="5" fill="none"/>
<text x="70" y="110" font-size="22" fill="#4EA5D9" font-weight="bold">human</text>
<!-- Agent Curve -->
<path d="M 100 200 C 150 200, 180 40, 230 40 C 280 40, 310 200, 360 200" stroke="#E37862" stroke-width="5" fill="none"/>
<text x="290" y="110" font-size="22" fill="#E37862" font-weight="bold">agent</text>
<!-- Decision Boundary -->
<line x1="180" y1="200" x2="180" y2="10" stroke="#999" stroke-width="2" stroke-dasharray="8,5"/>
<text x="180" y="-5" font-size="16" fill="#777" text-anchor="middle">decision boundary</text>
<circle cx="210" cy="200" r="6" fill="#ECA233"/>
<text x="210" y="180" font-family="Georgia" font-style="italic" font-size="20" fill="#ECA233" text-anchor="middle">g_obs</text>
<text x="180" y="280" font-size="18" fill="#555" text-anchor="middle">Positive gap shifts score toward agent traffic.</text>
</g>
</g>
<!-- ========================================================= -->
<!-- COLUMN 3: THE SOLUTION (CONTAMINATION & DR-RL) -->
<!-- ========================================================= -->
<text x="1340" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">3. Robust Control &amp; Contamination</text>
<line x1="1340" y1="100" x2="1860" y2="100" stroke="#DDDDDD" stroke-width="2"/>
<!-- Top: Contamination Generator -->
<g transform="translate(1340, 130)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Contamination Generator G(α)</text>
<!-- Boxes -->
<rect x="20" y="70" width="200" height="50" fill="#D0E5E0" filter="url(#shadow)" rx="6"/>
<text x="120" y="100" font-size="18" fill="#222" text-anchor="middle">labeled human sessions</text>
<rect x="280" y="70" width="200" height="50" fill="#EAD0C8" filter="url(#shadow)" rx="6"/>
<text x="380" y="100" font-size="18" fill="#222" text-anchor="middle">synthetic agent sessions</text>
<!-- Arrows -->
<line x1="120" y1="130" x2="200" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
<line x1="380" y1="130" x2="300" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
<!-- Mixed Batch -->
<rect x="150" y="190" width="200" height="50" fill="#F4E9CD" filter="url(#shadow)" rx="6"/>
<text x="250" y="220" font-size="18" fill="#222" text-anchor="middle">mixed batch for training</text>
<!-- Alpha Bar -->
<text x="250" y="275" font-family="Georgia, serif" font-size="20" fill="#555" text-anchor="middle">alpha = 0.33</text>
<rect x="50" y="290" width="268" height="30" fill="#4EA5D9"/>
<rect x="318" y="290" width="132" height="30" fill="#E37862"/>
<text x="184" y="340" font-size="18" fill="#4EA5D9" text-anchor="middle">human share (1-α)</text>
<text x="384" y="340" font-size="18" fill="#E37862" text-anchor="middle">agent share (α)</text>
</g>
<!-- Bottom: Distributionally Robust Control -->
<g transform="translate(1340, 600)">
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Distributionally Robust Control Layer</text>
<text x="0" y="80" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">
π* = arg max<tspan font-size="16" dy="5">π</tspan> min<tspan font-size="16" dy="0">Q ∈ U<tspan font-size="12" dy="5">ε</tspan></tspan>
<tspan dy="-10"> E</tspan><tspan font-size="16" dy="5">d ~ Q</tspan>
<tspan dy="-5">[ R(p,d) - λ COI</tspan><tspan font-size="16" dy="5">leak</tspan><tspan dy="-5">(p,τ') ]</tspan>
</text>
<!-- Ambiguity Ball -->
<g transform="translate(140, 260)">
<line x1="-130" y1="0" x2="130" y2="0" stroke="#CCC" stroke-width="2"/>
<line x1="0" y1="-130" x2="0" y2="130" stroke="#CCC" stroke-width="2"/>
<circle cx="0" cy="0" r="110" stroke="#C4A45B" stroke-width="4" fill="rgba(196,164,91,0.06)"/>
<text x="-95" y="-120" font-family="Georgia" font-style="italic" font-size="24" fill="#C4A45B">U<tspan font-size="16" dy="5">ε</tspan></text>
<!-- Points -->
<circle cx="0" cy="0" r="7" fill="#4EA5D9"/>
<text x="12" y="24" font-family="Georgia" font-style="italic" font-size="22" fill="#4EA5D9">P&#770;<tspan font-size="14" dy="5">N</tspan></text>
<circle cx="-60" cy="-40" r="7" fill="#E37862"/>
<text x="-140" y="-50" font-family="Georgia" font-style="italic" font-size="18" fill="#E37862">worst-case Q*</text>
<circle cx="50" cy="-70" r="6" fill="#85B589"/>
<circle cx="70" cy="50" r="6" fill="#85B589"/>
<circle cx="-40" cy="80" r="6" fill="#85B589"/>
</g>
<!-- Process Steps -->
<g transform="translate(320, 140)">
<rect x="0" y="0" width="220" height="45" fill="#FDEFEF" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="28" font-size="16" fill="#E37862" font-weight="bold" text-anchor="middle">inner min picks Q*</text>
<line x1="110" y1="55" x2="110" y2="85" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
<rect x="0" y="95" width="220" height="45" fill="#F4E9CD" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="123" font-size="16" fill="#9E8033" font-weight="bold" text-anchor="middle">sample demand from Q*</text>
<line x1="110" y1="150" x2="110" y2="180" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
<rect x="0" y="190" width="220" height="45" fill="#E6F2ED" filter="url(#light-shadow)" rx="6"/>
<text x="110" y="218" font-size="16" fill="#428062" font-weight="bold" text-anchor="middle">outer max updates policy</text>
</g>
<text x="250" y="440" font-size="18" fill="#555" text-anchor="middle">Reward is evaluated on demand drawn from Q*, then used for the policy step.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
docs/static/videos/DefenseOpening.mp4 vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
docs/static/videos/SystemLoopScene.mp4 vendored Normal file

Binary file not shown.

BIN
docs/static/videos/TakeawayScene.mp4 vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
__all__ = ["evaluate", "make_env", "train_qtable", "train_sb3"]

152
engine/backends/common.py Normal file
View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from typing import Any, Mapping
import numpy as np
def make_env(cfg: Mapping[str, Any]):
from gymnasium.wrappers import FlattenObservation
from ..lib.wrappers import EconomicMetricsWrapper
from ..wrapper import PHANTOM
env = PHANTOM(
n_products=int(cfg["n_products"]),
alpha=float(cfg["alpha"]),
N=int(cfg["N"]),
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
lambda_coi=float(cfg["lambda_coi"]),
robust_radius=float(cfg["robust_radius"]),
robust_points=int(cfg["robust_points"]),
robust_rollouts=int(cfg.get("robust_rollouts", 1)),
info_value=float(cfg["info_value"]),
eta_ux=float(cfg.get("eta_ux", 0.5)),
reward_profit_weight=float(cfg.get("reward_profit_weight", 1.0)),
action_levels=int(cfg["action_levels"]),
action_scale_low=float(cfg["action_scale_low"]),
action_scale_high=float(cfg["action_scale_high"]),
max_steps=int(cfg.get("max_steps", 100)),
margin_floor=float(cfg.get("margin_floor", 0.05)),
margin_floor_patience=int(cfg.get("margin_floor_patience", 5)),
render_mode=None,
)
env = EconomicMetricsWrapper(env)
return FlattenObservation(env)
def _action(agent: Any, obs: Any, deterministic: bool = True):
out = agent.predict(obs, deterministic=deterministic)
action = out[0] if isinstance(out, tuple) else out
if isinstance(action, np.ndarray) and action.size == 1:
return int(action.reshape(-1)[0])
return action
def _evaluate_env(agent: Any, env: Any, episodes: int) -> dict[str, float]:
rewards: list[float] = []
revenues: list[float] = []
margins: list[float] = []
coi_levels: list[float] = []
coi_leakages: list[float] = []
volatilities: list[float] = []
agent_probs: list[float] = []
for _ in range(int(episodes)):
obs, _ = env.reset()
done = False
ep_reward = 0.0
ep_revenue = 0.0
ep_margin = 0.0
ep_coi = 0.0
ep_coi_leakage = 0.0
ep_volatility = 0.0
ep_agent_prob = 0.0
steps = 0
while not done:
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
done = bool(term or trunc)
econ = info.get("economics", {})
ep_reward += float(reward)
ep_revenue += float(econ.get("revenue", info.get("revenue", 0.0)))
ep_margin += float(econ.get("margin", 0.0))
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_agent_prob += float(econ.get("agent_prob", info.get("agent_prob", 0.0)))
steps += 1
rewards.append(ep_reward)
revenues.append(ep_revenue)
denom = max(steps, 1)
margins.append(ep_margin / denom)
coi_levels.append(ep_coi / denom)
coi_leakages.append(ep_coi_leakage / denom)
volatilities.append(ep_volatility / denom)
agent_probs.append(ep_agent_prob / denom)
return {
"eval/reward_mean": float(np.mean(rewards)) if rewards else 0.0,
"eval/reward_std": float(np.std(rewards)) if rewards else 0.0,
"eval/revenue_mean": float(np.mean(revenues)) if revenues else 0.0,
"eval/revenue_std": float(np.std(revenues)) if revenues else 0.0,
"eval/margin_mean": float(np.mean(margins)) if margins else 0.0,
"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/agent_prob_mean": float(np.mean(agent_probs)) if agent_probs else 0.0,
}
def evaluate(
agent: Any,
env: Any,
episodes: int,
cfg: Mapping[str, Any] | None = None,
) -> dict[str, float]:
metrics = _evaluate_env(agent, env, episodes)
if cfg is None or not bool(cfg.get("robust_eval_enabled", True)):
return metrics
nominal_alpha = float(cfg.get("alpha", 0.0))
eval_radius = max(float(cfg.get("robust_radius", 0.0)), 0.15)
low_alpha = float(np.clip(nominal_alpha - eval_radius, 0.0, 1.0))
high_alpha = float(np.clip(nominal_alpha + eval_radius, 0.0, 1.0))
shifted_episodes = max(1, int(np.ceil(int(episodes) / 2)))
shifted_rows = []
for tag, alpha in (
("low", low_alpha),
("nominal", nominal_alpha),
("high", high_alpha),
):
eval_cfg = dict(cfg)
eval_cfg["alpha"] = float(alpha)
shifted_env = make_env(eval_cfg)
shifted_metrics = _evaluate_env(agent, shifted_env, shifted_episodes)
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(
min(row[2]["eval/reward_mean"] for row in shifted_rows)
)
metrics["eval/robust_revenue_worst"] = float(
min(row[2]["eval/revenue_mean"] for row in shifted_rows)
)
metrics["eval/robust_coi_leakage_worst"] = float(
max(row[2]["eval/coi_leakage_mean"] for row in shifted_rows)
)
for tag, alpha, shifted_metrics in shifted_rows:
metrics[f"eval/{tag}_alpha"] = float(alpha)
metrics[f"eval/{tag}_reward_mean"] = float(shifted_metrics["eval/reward_mean"])
metrics[f"eval/{tag}_revenue_mean"] = float(
shifted_metrics["eval/revenue_mean"]
)
metrics[f"eval/{tag}_coi_leakage_mean"] = float(
shifted_metrics["eval/coi_leakage_mean"]
)
return metrics

131
engine/backends/qtable.py Normal file
View File

@@ -0,0 +1,131 @@
from __future__ import annotations
import logging
import time
from typing import Any, Mapping
import numpy as np
from .common import evaluate, make_env
from ..telemetry.wandb import get_wandb_module
logger = logging.getLogger(__name__)
def train_qtable(
cfg: Mapping[str, Any],
) -> tuple[object, dict[str, Any]]:
from ..lib.discrete import EventQTable
np.random.seed(int(cfg["seed"]))
env = make_env(cfg)
eval_env = make_env(cfg)
agent = EventQTable(
env.action_space.n,
int(cfg["n_products"]),
(float(cfg["price_low"]), float(cfg["price_high"])),
lr=float(cfg["q_lr"]),
gamma=float(cfg["gamma"]),
n_bins=int(cfg["q_bins"]),
)
total_reward = 0.0
total_revenue = 0.0
steps = 0
epsilon = float(cfg["eps_start"])
log_freq = max(1, int(cfg.get("log_freq", 100)))
console_progress = bool(cfg.get("console_progress", False))
obs, _ = env.reset(seed=int(cfg["seed"]))
started_at = time.perf_counter()
wandb = get_wandb_module()
wandb_live = bool(wandb is not None and wandb.run is not None)
step_offset = max(0, int(cfg.get("wandb_step_offset", 0)))
interval_sums = {
"reward": 0.0,
"revenue": 0.0,
"agent_prob": 0.0,
"alpha_adv": 0.0,
"coi_leakage": 0.0,
}
interval_count = 0
train_events: list[dict[str, float | int]] = []
for _ in range(int(cfg["total_timesteps"])):
action, state = agent.act(obs, epsilon)
nxt, reward, term, trunc, info = env.step(action)
done = bool(term or trunc)
agent.update(state, action, float(reward), agent.encode(nxt), done)
total_reward += float(reward)
revenue = float(info.get("economics", {}).get("revenue", 0.0))
total_revenue += revenue
steps += 1
interval_sums["reward"] += float(reward)
interval_sums["revenue"] += revenue
interval_sums["agent_prob"] += float(info.get("agent_prob", 0.0))
interval_sums["alpha_adv"] += float(info.get("alpha_adv", 0.0))
interval_sums["coi_leakage"] += float(info.get("coi_leakage", 0.0))
interval_count += 1
if steps % log_freq == 0 and interval_count > 0:
denom = float(interval_count)
event = {
"train/reward_mean": interval_sums["reward"] / denom,
"train/revenue_mean": interval_sums["revenue"] / denom,
"train/agent_prob": interval_sums["agent_prob"] / denom,
"train/alpha_adv": interval_sums["alpha_adv"] / denom,
"train/coi_leakage": interval_sums["coi_leakage"] / denom,
"train/epsilon": float(epsilon),
"train/global_step": int(steps),
}
if wandb_live:
wandb.log(dict(event), step=step_offset + int(steps))
else:
train_events.append(event)
if console_progress:
elapsed = max(time.perf_counter() - started_at, 1e-6)
speed = steps / elapsed
logger.info(
"step=%d/%d reward=%.3f revenue=%.3f eps=%.4f speed=%.1f steps/s",
steps,
int(cfg["total_timesteps"]),
event["train/reward_mean"],
event["train/revenue_mean"],
event["train/epsilon"],
speed,
)
interval_sums = {key: 0.0 for key in interval_sums}
interval_count = 0
epsilon = max(float(cfg["eps_end"]), epsilon * float(cfg["eps_decay"]))
obs = env.reset()[0] if done else nxt
if interval_count > 0:
denom = float(interval_count)
tail_event = {
"train/reward_mean": interval_sums["reward"] / denom,
"train/revenue_mean": interval_sums["revenue"] / denom,
"train/agent_prob": interval_sums["agent_prob"] / denom,
"train/alpha_adv": interval_sums["alpha_adv"] / denom,
"train/coi_leakage": interval_sums["coi_leakage"] / denom,
"train/epsilon": float(epsilon),
"train/global_step": int(steps),
}
if wandb_live:
wandb.log(dict(tail_event), step=step_offset + int(steps))
else:
train_events.append(tail_event)
metrics: dict[str, Any] = {
"train/reward_mean": total_reward / max(steps, 1),
"train/revenue_mean": total_revenue / max(steps, 1),
"train/epsilon": float(epsilon),
"train/global_step": int(cfg["total_timesteps"]),
}
metrics.update(evaluate(agent, eval_env, int(cfg["eval_episodes"]), cfg=cfg))
metrics["_train_events"] = train_events
env.close()
eval_env.close()
return agent, metrics

188
engine/backends/sb3.py Normal file
View File

@@ -0,0 +1,188 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
from ..lib.callbacks import MetricsCallback
from .common import evaluate, make_env
def _net_arch(name: Any) -> list[int]:
presets = {
"tiny": [32, 32],
"small": [64, 64],
"medium": [128, 128],
"large": [256, 256],
}
if isinstance(name, (list, tuple)):
return [int(v) for v in name]
raw = str(name).lower().strip()
if raw in presets:
return presets[raw]
if "x" in raw:
try:
parsed = [int(v) for v in raw.split("x") if v]
return parsed if parsed else presets["small"]
except ValueError:
return presets["small"]
return presets["small"]
def _activation(name: Any):
try:
import torch.nn as nn
except ImportError:
return None
return {
"relu": nn.ReLU,
"tanh": nn.Tanh,
"elu": nn.ELU,
"leaky_relu": nn.LeakyReLU,
}.get(str(name).lower().strip(), nn.ReLU)
def _policy_kwargs(cfg: Mapping[str, Any]) -> dict[str, Any]:
kwargs: dict[str, Any] = {"net_arch": _net_arch(cfg.get("arch", "small"))}
activation = _activation(cfg.get("activation", "relu"))
if activation is not None:
kwargs["activation_fn"] = activation
return kwargs
def build_model(cfg: Mapping[str, Any], env: Any):
try:
from stable_baselines3 import A2C, DQN, PPO
except ImportError as exc:
raise ImportError("stable-baselines3 is required for SB3 algorithms") from exc
algo = str(cfg["algo"])
policy_kwargs = _policy_kwargs(cfg)
device = str(cfg.get("device", "auto"))
seed = int(cfg["seed"])
if algo == "sac":
raise ValueError("sac is not supported with the discrete core env")
if algo == "ppo":
return PPO(
"MlpPolicy",
env,
verbose=1,
device=device,
policy_kwargs=policy_kwargs,
seed=seed,
learning_rate=float(cfg["learning_rate"]),
n_steps=int(cfg["n_steps"]),
batch_size=int(cfg["batch_size"]),
n_epochs=int(cfg["n_epochs"]),
gamma=float(cfg["gamma"]),
gae_lambda=float(cfg["gae_lambda"]),
clip_range=float(cfg["clip_range"]),
ent_coef=float(cfg["ent_coef"]),
)
if algo == "a2c":
return A2C(
"MlpPolicy",
env,
verbose=1,
device=device,
policy_kwargs=policy_kwargs,
seed=seed,
learning_rate=float(cfg["learning_rate"]),
n_steps=max(5, int(cfg["n_steps"]) // 32),
gamma=float(cfg["gamma"]),
gae_lambda=float(cfg["gae_lambda"]),
ent_coef=float(cfg["ent_coef"]),
)
if algo == "dqn":
return DQN(
"MlpPolicy",
env,
verbose=1,
device=device,
policy_kwargs=policy_kwargs,
seed=seed,
learning_rate=float(cfg["learning_rate"]),
buffer_size=int(cfg["buffer_size"]),
batch_size=int(cfg["batch_size"]),
gamma=float(cfg["gamma"]),
train_freq=int(cfg["train_freq"]),
learning_starts=int(cfg["learning_starts"]),
target_update_interval=int(cfg["target_update_interval"]),
exploration_fraction=float(cfg["exploration_fraction"]),
exploration_final_eps=float(cfg["exploration_final_eps"]),
)
raise ValueError(f"unsupported algo '{algo}'")
def train_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
env = Monitor(make_env(cfg))
eval_env = Monitor(make_env(cfg))
model = build_model(cfg, env)
try:
import torch
print(
"PHANTOM_DEVICE: "
+ json.dumps(
{
"requested": str(cfg.get("device", "auto")),
"torch_cuda_available": bool(torch.cuda.is_available()),
"torch_device_count": int(torch.cuda.device_count()),
"sb3_device": str(getattr(model, "device", "unknown")),
}
)
)
except Exception:
pass
metrics_callback = MetricsCallback(
log_histograms=False,
log_freq=int(cfg["log_freq"]),
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,
)
)
target_steps = int(cfg["total_timesteps"])
remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0)))
if remaining_steps > 0:
model.learn(
total_timesteps=remaining_steps,
callback=callbacks,
reset_num_timesteps=False,
)
model_dir = Path(str(cfg["model_dir"]))
model_dir.mkdir(parents=True, exist_ok=True)
model_path = model_dir / f"phantom_{cfg['algo']}"
model.save(str(model_path))
metrics: dict[str, Any] = evaluate(
model,
eval_env,
int(cfg["eval_episodes"]),
cfg=cfg,
)
metrics["train/global_step"] = int(model.num_timesteps)
metrics["model/path"] = str(model_path.with_suffix(".zip"))
metrics["_train_events"] = list(metrics_callback.events)
env.close()
eval_env.close()
return model, metrics

625
engine/benchmark.py Normal file
View File

@@ -0,0 +1,625 @@
from __future__ import annotations
import argparse
import json
import logging
import os
from datetime import datetime, UTC
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from .lib.tiers import LinearElasticityPolicy, StaticPolicy, SurgePolicy
from .logging_utils import configure_logging
from .spec import TrainSpec
from .telemetry.wandb import get_wandb_module
wandb = get_wandb_module()
HAS_WANDB = wandb is not None
logger = logging.getLogger(__name__)
def _log(message: str) -> None:
logger.info(message)
def _parse_list(raw: str) -> list[str]:
return [x.strip().lower() for x in str(raw).split(",") if x.strip()]
def _parse_float_list(raw: str) -> list[float]:
return [float(x.strip()) for x in str(raw).split(",") if x.strip()]
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 _action(policy, obs: np.ndarray):
out = policy.predict(obs, deterministic=True)
action = out[0] if isinstance(out, tuple) else out
if isinstance(action, np.ndarray) and action.size == 1:
return int(action.reshape(-1)[0])
return int(action)
def _run_eval_episode(env, policy) -> dict:
obs, _ = env.reset()
done = False
total_reward = 0.0
total_revenue = 0.0
total_margin = 0.0
total_coi = 0.0
price_trace: list[float] = []
step_count = 0
while not done:
action = _action(policy, obs)
obs, reward, term, trunc, info = env.step(action)
done = bool(term or trunc)
econ = info.get("economics", {})
total_reward += float(reward)
total_revenue += float(econ.get("revenue", 0.0))
total_margin += float(econ.get("margin", 0.0))
total_coi += float(econ.get("coi_level", 0.0))
prices = np.asarray(info.get("prices", []), dtype=np.float32)
if prices.size > 0:
price_trace.append(float(np.mean(prices)))
step_count += 1
denom = max(step_count, 1)
return {
"reward": total_reward,
"revenue": total_revenue,
"mean_margin": total_margin / denom,
"mean_coi": total_coi / denom,
"price_trace": price_trace,
}
def _build_tier(name: str, cfg: dict, alpha: float, *, step_offset: int = 0):
from .backends.common import make_env
tier = name.lower().strip()
run_cfg = dict(cfg)
run_cfg["alpha"] = float(alpha)
run_cfg["wandb_step_offset"] = int(step_offset)
if tier == "static":
return StaticPolicy(int(run_cfg["action_levels"])), []
if tier == "surge":
return (
SurgePolicy(
n_actions=int(run_cfg["action_levels"]),
n_products=int(run_cfg["n_products"]),
),
[],
)
if tier == "linear":
warmup_env = make_env(run_cfg)
policy = LinearElasticityPolicy(
n_actions=int(run_cfg["action_levels"]),
n_products=int(run_cfg["n_products"]),
price_low=float(run_cfg["price_low"]),
price_high=float(run_cfg["price_high"]),
)
policy.fit(
warmup_env,
warmup_steps=int(run_cfg.get("linear_warmup_steps", 800)),
seed=int(run_cfg["seed"]),
)
warmup_env.close()
return policy, []
if tier == "qtable":
from .backends.qtable import train_qtable
run_cfg["console_progress"] = True
agent, metrics = train_qtable(run_cfg)
events = metrics.get("_train_events", [])
return agent, events if isinstance(events, list) else []
if tier in {"ppo", "a2c", "dqn"}:
from .backends.sb3 import train_sb3
run_cfg["algo"] = tier
agent, metrics = train_sb3(run_cfg)
events = metrics.get("_train_events", [])
return agent, events if isinstance(events, list) else []
raise ValueError(f"unsupported tier '{name}'")
def _log_train_events(
events: list[dict],
*,
tier_name: str,
mode_label: str,
alpha: float,
step_offset: int,
) -> int:
if not (HAS_WANDB and wandb.run is not None):
return int(step_offset)
if not events:
return int(step_offset)
ordered = sorted(
[evt for evt in events if isinstance(evt, dict)],
key=lambda evt: int(evt.get("train/global_step", 0)),
)
if not ordered:
return int(step_offset)
cursor = int(step_offset)
for evt in ordered:
rel_step = max(1, int(evt.get("train/global_step", 0)))
payload = dict(evt)
payload.update(
{
"run.kind": "benchmark",
"runtime/backend": tier_name,
"study/mode": mode_label,
"study/no_robust": float(mode_label == "no_robust"),
"study/alpha": float(alpha),
}
)
wandb.log(payload, step=cursor + rel_step)
max_rel = max(max(1, int(evt.get("train/global_step", 0))) for evt in ordered)
return cursor + max_rel + 1
def run_benchmark(
cfg: dict,
tiers: list[str],
alpha_values: list[float],
n_episodes: int,
mode_label: str,
step_cursor_start: int = 0,
):
from .backends.common import make_env
rows: list[dict] = []
traces: list[dict] = []
total_runs = max(1, len(alpha_values) * len(tiers))
run_index = 0
wandb_step_cursor = int(step_cursor_start)
for alpha in alpha_values:
for tier_name in tiers:
run_index += 1
_log(
f"[{run_index}/{total_runs}] alpha={float(alpha):.2f} tier={tier_name}: training"
)
policy, train_events = _build_tier(
tier_name,
cfg,
alpha,
step_offset=wandb_step_cursor,
)
prev_cursor = int(wandb_step_cursor)
wandb_step_cursor = _log_train_events(
train_events,
tier_name=tier_name,
mode_label=mode_label,
alpha=float(alpha),
step_offset=wandb_step_cursor,
)
if wandb_step_cursor == prev_cursor and tier_name in {
"qtable",
"ppo",
"a2c",
"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}"
)
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(alpha),
"mean_price_trace": step_means,
}
)
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,
)
wandb_step_cursor += 1
return pd.DataFrame(rows), traces, int(wandb_step_cursor)
def _plot_outputs(df: pd.DataFrame, traces: list[dict], out_dir: Path, stamp: str):
fig1 = plt.figure(figsize=(11, 4.5))
if "mode" in df.columns:
groups = sorted(df[["tier", "mode"]].drop_duplicates().values.tolist())
for tier, mode in groups:
sub = df[(df["tier"] == tier) & (df["mode"] == mode)].sort_values("alpha")
plt.plot(
sub["alpha"],
sub["mean_revenue"],
marker="o",
label=f"{tier}:{mode}",
)
else:
for tier in sorted(df["tier"].unique()):
sub = df[df["tier"] == tier].sort_values("alpha")
plt.plot(sub["alpha"], sub["mean_revenue"], marker="o", label=tier)
plt.xlabel("contamination alpha")
plt.ylabel("mean episode revenue")
plt.title("Revenue under contamination")
plt.grid(alpha=0.3)
plt.legend()
fig1.tight_layout()
rev_path = out_dir / f"benchmark_revenue_{stamp}.png"
fig1.savefig(rev_path, dpi=220)
plt.close(fig1)
fig2 = plt.figure(figsize=(11, 4.5))
if "mode" in df.columns:
groups = sorted(df[["tier", "mode"]].drop_duplicates().values.tolist())
for tier, mode in groups:
sub = df[(df["tier"] == tier) & (df["mode"] == mode)].sort_values("alpha")
plt.plot(
sub["alpha"],
sub["mean_coi"],
marker="s",
label=f"{tier}:{mode}",
)
else:
for tier in sorted(df["tier"].unique()):
sub = df[df["tier"] == tier].sort_values("alpha")
plt.plot(sub["alpha"], sub["mean_coi"], marker="s", label=tier)
plt.xlabel("contamination alpha")
plt.ylabel("mean COI level")
plt.title("COI preservation")
plt.grid(alpha=0.3)
plt.legend()
fig2.tight_layout()
coi_path = out_dir / f"benchmark_coi_{stamp}.png"
fig2.savefig(coi_path, dpi=220)
plt.close(fig2)
focus_alpha = float(df["alpha"].min()) if not df.empty else 0.0
alpha_traces = [t for t in traces if abs(float(t["alpha"]) - focus_alpha) < 1e-9]
fig3 = plt.figure(figsize=(11, 4.5))
for item in alpha_traces:
xs = np.arange(len(item["mean_price_trace"]))
ys = np.asarray(item["mean_price_trace"], dtype=np.float32)
mode = item.get("mode")
label = f"{item['tier']}:{mode}" if mode is not None else str(item["tier"])
plt.plot(xs, ys, label=label)
plt.xlabel("step")
plt.ylabel("mean price")
plt.title(f"Price evolution (alpha={focus_alpha:.2f})")
plt.grid(alpha=0.3)
plt.legend()
fig3.tight_layout()
price_path = out_dir / f"benchmark_price_trace_{stamp}.png"
fig3.savefig(price_path, dpi=220)
plt.close(fig3)
return rev_path, coi_path, price_path
def _run_with_args(args, compare_robust_override: bool | None = None):
compare_robust = (
bool(compare_robust_override)
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)]
base_overrides = {
"seed": args.seed,
"total_timesteps": args.total_timesteps,
"n_products": args.n_products,
"N": args.N,
"lambda_coi": args.lambda_coi,
"robust_radius": args.robust_radius,
"robust_points": args.robust_points,
"robust_rollouts": args.robust_rollouts,
"eta_ux": args.eta_ux,
"reward_profit_weight": args.reward_profit_weight,
"price_low": args.price_low,
"price_high": args.price_high,
"action_levels": args.action_levels,
"action_scale_low": args.action_scale_low,
"action_scale_high": args.action_scale_high,
"max_steps": args.max_steps,
"learning_rate": args.learning_rate,
"batch_size": args.batch_size,
"n_steps": args.n_steps,
"linear_warmup_steps": args.linear_warmup_steps,
"device": args.device,
}
tiers = _parse_list(args.tiers)
alpha_values = _parse_float_list(args.alpha_values)
_log(
"starting run "
+ json.dumps(
{
"tiers": tiers,
"alpha_values": alpha_values,
"episodes": int(args.episodes),
"total_timesteps": int(args.total_timesteps),
"device": str(args.device),
}
)
)
all_frames: list[pd.DataFrame] = []
all_traces: list[dict] = []
wandb_step_cursor = 0
for no_robust in robust_modes:
overrides = dict(base_overrides)
overrides["no_robust"] = bool(no_robust)
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"
_log(f"mode={mode_label}: begin")
df_mode, traces_mode, wandb_step_cursor = run_benchmark(
cfg,
tiers,
alpha_values,
args.episodes,
mode_label=mode_label,
step_cursor_start=wandb_step_cursor,
)
_log(f"mode={mode_label}: complete ({len(df_mode)} rows)")
for trace in traces_mode:
trace["mode"] = mode_label
all_frames.append(df_mode)
all_traces.extend(traces_mode)
df = pd.concat(all_frames, ignore_index=True) if all_frames else pd.DataFrame()
traces = all_traces
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")
csv_path = out_dir / f"benchmark_{stamp}.csv"
trace_path = out_dir / f"benchmark_traces_{stamp}.json"
df.to_csv(csv_path, index=False)
trace_path.write_text(json.dumps(traces, indent=2))
rev_path, coi_path, price_path = _plot_outputs(df, traces, out_dir, stamp)
_log(f"artifacts written in {out_dir}")
if not df.empty:
best_idx = int(df["objective_score"].idxmax())
best = df.iloc[best_idx]
_log(
"BEST_TIER="
+ json.dumps(
{
"tier": best["tier"],
"mode": best.get("mode", "robust"),
"alpha": float(best["alpha"]),
"objective_score": float(best["objective_score"]),
"mean_revenue": float(best["mean_revenue"]),
"mean_coi": float(best["mean_coi"]),
}
)
)
_log(f"BENCHMARK_CSV={csv_path}")
_log(f"BENCHMARK_TRACES={trace_path}")
_log(f"BENCHMARK_PLOT_REVENUE={rev_path}")
_log(f"BENCHMARK_PLOT_COI={coi_path}")
_log(f"BENCHMARK_PLOT_PRICE={price_path}")
def run_cli(raw_args: list[str] | None = None):
configure_logging()
parser = argparse.ArgumentParser(description="PHANTOM benchmark orchestrator")
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("--episodes", type=int, default=10)
parser.add_argument("--output-dir", default="engine/studies/results")
parser.add_argument("--seed", type=int, default=42)
parser.add_argument("--total-timesteps", type=int, default=25_000)
parser.add_argument("--n-products", type=int, default=10)
parser.add_argument("--N", type=int, default=100)
parser.add_argument("--lambda-coi", type=float, default=0.2)
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("--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)
parser.add_argument("--price-high", type=float, default=150.0)
parser.add_argument("--action-levels", type=int, default=9)
parser.add_argument("--action-scale-low", type=float, default=0.8)
parser.add_argument("--action-scale-high", type=float, default=1.2)
parser.add_argument("--max-steps", type=int, default=100)
parser.add_argument("--learning-rate", type=float, default=3e-4)
parser.add_argument("--batch-size", type=int, default=256)
parser.add_argument("--n-steps", type=int, default=2048)
parser.add_argument("--linear-warmup-steps", type=int, default=800)
parser.add_argument("--device", type=str, default="auto")
parser.add_argument("--no-robust", action="store_true")
parser.add_argument("--no-wandb", action="store_true")
parser.add_argument("--offline", action="store_true")
parser.add_argument("--sweep-agent", action="store_true")
parser.add_argument("--sweep-id", type=str)
parser.add_argument("--count", type=int, default=0)
args = parser.parse_args(raw_args)
if args.sweep_agent:
if args.no_wandb or not HAS_WANDB:
raise ValueError("sweep agent requires wandb")
if not args.sweep_id:
raise ValueError("--sweep-id is required with --sweep-agent")
def _sweep_run():
run = wandb.init(mode="offline" if args.offline else "online")
try:
key_to_attr = {
"tiers": "tiers",
"alpha_values": "alpha_values",
"episodes": "episodes",
"total_timesteps": "total_timesteps",
"lambda_coi": "lambda_coi",
"robust_radius": "robust_radius",
"robust_points": "robust_points",
"robust_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",
"no_robust": "no_robust",
"device": "device",
}
for key in (
"tiers",
"alpha_values",
"episodes",
"total_timesteps",
"lambda_coi",
"robust_radius",
"robust_points",
"robust_rollouts",
"eta_ux",
"reward_profit_weight",
"learning_rate",
"batch_size",
"n_steps",
"no_robust",
"device",
):
if key in wandb.config:
setattr(args, key_to_attr[key], wandb.config[key])
_run_with_args(args)
finally:
if run is not None:
wandb.finish()
wandb.agent(
args.sweep_id,
function=_sweep_run,
count=args.count if args.count > 0 else None,
)
return
if args.no_wandb or not HAS_WANDB:
_run_with_args(args)
return
tiers = _parse_list(args.tiers)
alpha_values = _parse_float_list(args.alpha_values)
run_stamp = datetime.now(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 = (
[("no_robust", True), ("robust", False)]
if compare_enabled
else [("no_robust" if bool(args.no_robust) else "robust", bool(args.no_robust))]
)
run_idx = 0
for tier in tiers:
for mode_label, no_robust in modes:
for alpha in alpha_values:
run_idx += 1
alpha_token = (
f"{float(alpha):.2f}".rstrip("0").rstrip(".").replace(".", "p")
)
tier_args = argparse.Namespace(**vars(args))
tier_args.tiers = tier
tier_args.alpha_values = str(float(alpha))
tier_args.no_robust = bool(no_robust)
run = wandb.init(
project=args.project,
name=(
f"benchmark-{tier}-{mode_label}-a{alpha_token}-{run_stamp}-{run_idx}"
),
tags=[
"benchmark",
compare_tag,
f"backend:{tier}",
f"mode:{mode_label}",
f"alpha:{alpha_token}",
],
config={
"run.kind": "benchmark",
"runtime/backend": tier,
"study/mode": mode_label,
"study/no_robust": float(no_robust),
"study/alpha": float(alpha),
"tiers": tier,
"alpha_values": str(float(alpha)),
"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,
"eta_ux": args.eta_ux,
"reward_profit_weight": args.reward_profit_weight,
"learning_rate": args.learning_rate,
"device": args.device,
},
mode="offline" if args.offline else "online",
)
try:
_run_with_args(tier_args, compare_robust_override=False)
finally:
if run is not None:
wandb.finish()
if __name__ == "__main__":
run_cli()

View File

@@ -1,36 +1,73 @@
from sys import platform
import numpy as np
from .lib.demand import generate_demand, estimate_demand
from .lib.behavior import sample_behavior
from .lib.demand import generate_demand_for_actor, estimate_demand
from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions
from logging import INFO, getLogger
logger = getLogger(__name__)
logger.setLevel(INFO)
class MarketEngine:
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
class MarketEngine():
def __init__(self,
alpha = 0.5,
N = 100,
demand_distribution = (50, 10),
demand_sampling_function = np.random.normal):
def __init__(
self,
alpha: float,
N: int,
human_params: tuple,
agent_params: tuple,
demand_distribution=np.random.normal,
noise_std: float = 1.0,
action_weights: dict | None = None,
):
# no defaults for D_H, D_A - force explicit experiment design
self.alpha = alpha
self.N = int(N)
self.Nagents = int(N * alpha)
self.Nhumans = int(N * (1 - alpha))
self.demand = (demand_sampling_function, demand_distribution)
self.human_params = human_params
self.agent_params = agent_params
self.noise_std = noise_std
self.demand_dist = demand_distribution
self.action_weights = action_weights
def act(self, prices):
demand = generate_demand(prices, *self.demand)
sample_n = lambda n, human: [sample_behavior(demand, human=human) for _ in range(n)]
human_t, agent_t = sample_n(100, True), sample_n(100, False)
trajectories = human_t + agent_t
demand_estimate = estimate_demand(trajectories)
return demand_estimate
# generate separate demands d() per actor type
demand_h = generate_demand_for_actor(
prices,
self.human_params,
self.noise_std,
distribution_method=self.demand_dist,
)
demand_a = generate_demand_for_actor(
prices,
self.agent_params,
self.noise_std,
distribution_method=self.demand_dist,
)
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)
for _ in range(self.Nhumans)
]
agent_t = [
sample_behavior_from_transitions(agent_transitions)
for _ in range(self.Nagents)
]
# store trajectories for agent probability calculation
self.last_trajectories = human_t + agent_t
return estimate_demand(self.last_trajectories, self.action_weights)
def measure(self):
pass
class PricingEngine():
def __init__(self,
class PricingEngine:
def __init__(
self,
) -> None:
pass
@@ -38,29 +75,31 @@ class PricingEngine():
return np.random.uniform(low=25, high=100, size=10)
class Limbo():
def __init__(self,
platform,
market
) -> None:
class Limbo:
def __init__(self, platform, market) -> None:
self.platform_turn = True
self.platform = platform
self.market = market
self.output = None
def step(self):
# we could code golf this a little bit
if self.platform_turn:
self.output = self.platform.act(self.output)
else:
self.output = self.market.act(self.output)
print(self.output)
self.platform_turn = not self.platform_turn
return self.output
def reset(self):
self.platform_turn = True
self.output = None
if __name__ == "__main__":
platform = PricingEngine()
market = MarketEngine()
market = MarketEngine(
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
)
limbo = Limbo(platform, market)
for _ in range(10):
limbo.step()

View File

@@ -1,3 +1,38 @@
from .demand import generate_demand, estimate_demand
from .behavior import sample_behavior
from .render import DashboardRenderer, style_axis
from __future__ import annotations
from importlib import import_module
_EXPORTS: dict[str, tuple[str, str]] = {
"estimate_demand": (".demand", "estimate_demand"),
"estimate_weighted_demand": (".demand", "estimate_weighted_demand"),
"generate_demand_for_actor": (".demand", "generate_demand_for_actor"),
"sample_behavior": (".behavior", "sample_behavior"),
"get_transition_models": (".behavior", "get_transition_models"),
"trajectory_to_events": (".behavior", "trajectory_to_events"),
"DashboardRenderer": (".render", "DashboardRenderer"),
"style_axis": (".render", "style_axis"),
"EconomicMetricsWrapper": (".wrappers", "EconomicMetricsWrapper"),
"MetricsCallback": (".callbacks", "MetricsCallback"),
"EvalMetricsCallback": (".callbacks", "EvalMetricsCallback"),
"ProviderBenchmark": (".providers", "ProviderBenchmark"),
"ProviderResult": (".providers", "ProviderResult"),
"BenchmarkConfig": (".providers", "BenchmarkConfig"),
"RandomBaseline": (".providers", "RandomBaseline"),
"SurgeBaseline": (".providers", "SurgeBaseline"),
"compute_uplift_coi": (".coi", "compute_uplift_coi"),
"extract_purchases": (".coi", "extract_purchases"),
"compute_agent_probability": (".coi", "compute_agent_probability"),
"EventQTable": (".discrete", "EventQTable"),
}
__all__ = sorted(_EXPORTS)
def __getattr__(name: str):
if name not in _EXPORTS:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
module_name, attr_name = _EXPORTS[name]
module = import_module(module_name, package=__name__)
value = getattr(module, attr_name)
globals()[name] = value
return value

View File

@@ -1,27 +1,107 @@
from sim.rl.behavior_loader.models import BehaviorModel, AgentBehaviorModel, aggregate_event_transitions
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[2]))
try:
from sim.rl.behavior_loader.models import (
BehaviorModel,
AgentBehaviorModel,
aggregate_event_transitions,
)
except ImportError:
BehaviorModel = None
AgentBehaviorModel = None
aggregate_event_transitions = None
import pandas as pd
import numpy as np
from .demand import generate_demand
from .demand import generate_demand_for_actor
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
base_dir = Path(__file__).parents[2] / "experiments"
human_dir = str(base_dir / "collected_data")
agent_dir = str(base_dir / "agents" / "collected_data")
_cache = {} # lazy cache for models and base pivots
def _get_base_pivot(human: bool):
key = 'human' if human else 'agent'
if (
BehaviorModel is None
or AgentBehaviorModel is None
or aggregate_event_transitions is None
):
raise ImportError("behavior loader dependencies are unavailable")
key = "human" if human else "agent"
if key not in _cache:
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
mdp = model.build_MDP()
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
return _cache[key]
def get_transition_models():
"""load human and agent transition models for agent probability calculation
returns:
tuple: (human_transitions, agent_transitions) as dicts of event->event->prob
"""
if (
BehaviorModel is None
or AgentBehaviorModel is None
or aggregate_event_transitions is None
):
raise ImportError("behavior loader dependencies are unavailable")
human_model = BehaviorModel(human_dir)
agent_model = AgentBehaviorModel(agent_dir)
human_mdp = human_model.build_MDP()
agent_mdp = agent_model.build_MDP()
human_trans = aggregate_event_transitions(human_mdp)
agent_trans = aggregate_event_transitions(agent_mdp)
return human_trans, agent_trans
def trajectory_to_events(trajectory: list) -> list:
"""extract event names from trajectory for KL divergence calculation
trajectories are in format 'eventName_product0', extract just eventName
args:
trajectory: list like ['view_product0', 'add_to_cart_product1', 'checkout_product1']
returns:
list: event names like ['view', 'add_to_cart', 'checkout']
"""
events = []
for state in trajectory:
# state format from sample_behavior: 'eventName_productX'
if "_product" in state:
event = state.rsplit("_product", 1)[0]
else:
event = state
events.append(event)
return events
def adjust_behavior_to_condition(condition, transition_matrix):
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
cond_norm = condition / np.sum(condition)
condition = np.asarray(condition, dtype=float)
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
condition = np.clip(condition, 0.0, None)
s = float(np.sum(condition))
if not np.isfinite(s) or s <= 0:
cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
else:
cond_norm = condition / s
n_products = len(condition)
base_vals = transition_matrix.values
base_cols, base_rows = transition_matrix.columns.tolist(), transition_matrix.index.tolist()
base_cols, base_rows = (
transition_matrix.columns.tolist(),
transition_matrix.index.tolist(),
)
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
@@ -29,19 +109,33 @@ def adjust_behavior_to_condition(condition, transition_matrix):
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
def sample_behavior(condition, human=True, max_len=40):
base_pivot = _get_base_pivot(human)
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
def get_adjusted_transitions(condition, human=True):
base_pivot = _get_base_pivot(human)
return adjust_behavior_to_condition(condition, base_pivot)
def sample_behavior_from_transitions(adjusted_transitions, max_len=40):
trajectory = [np.random.choice(adjusted_transitions.index)]
while len(trajectory) < max_len or 'checkout' in trajectory[-1]:
probs = adjusted_transitions.loc[trajectory[-1]].values
sample = np.random.choice(adjusted_transitions.columns, p=probs/np.sum(probs) if np.sum(probs) > 0 else None)
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)
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)
if __name__ == "__main__":
t=sample_behavior(generate_demand(np.array([10,20,30])), human=True)
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
print(t)
t=sample_behavior(generate_demand(np.array([10,20,30])), human=False)
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
print(t)

148
engine/lib/callbacks.py Normal file
View File

@@ -0,0 +1,148 @@
"""Training callbacks with algorithm-agnostic metric extraction."""
from typing import Any
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
import numpy as np
from ..telemetry.wandb import get_wandb_module
class MetricsCallback(BaseCallback):
"""Collects interval train metrics from env info dictionaries."""
def __init__(
self,
log_histograms: bool = False,
log_freq: int = 100,
step_offset: int = 0,
verbose: int = 0,
):
super().__init__(verbose)
self.log_histograms = log_histograms
self.log_freq = max(1, int(log_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._window_sums = {
"train/revenue_mean": 0.0,
"train/margin_mean": 0.0,
"train/coi_level_mean": 0.0,
"train/regret_mean": 0.0,
"train/profit_mean": 0.0,
"train/agent_prob": 0.0,
"train/alpha_adv": 0.0,
"train/ux_penalty": 0.0,
"train/volatility": 0.0,
"train/coi_mix": 0.0,
"train/coi_base": 0.0,
"train/coi_leakage": 0.0,
"train/coi_penalty": 0.0,
}
self._window_count = 0
self.events: list[dict[str, Any]] = []
def _accumulate(self, info: dict[str, Any]) -> None:
econ = info.get("economics")
if not isinstance(econ, dict):
return
self._window_sums["train/revenue_mean"] += float(econ.get("revenue", 0.0))
self._window_sums["train/margin_mean"] += float(econ.get("margin", 0.0))
self._window_sums["train/coi_level_mean"] += float(econ.get("coi_level", 0.0))
self._window_sums["train/regret_mean"] += float(econ.get("regret", 0.0))
if "profit" in econ:
self._window_sums["train/profit_mean"] += float(econ.get("profit", 0.0))
if "agent_prob" in econ:
self._window_sums["train/agent_prob"] += float(econ.get("agent_prob", 0.0))
if "alpha_adv" in econ:
self._window_sums["train/alpha_adv"] += float(econ.get("alpha_adv", 0.0))
if "ux_penalty" in econ:
self._window_sums["train/ux_penalty"] += float(econ.get("ux_penalty", 0.0))
if "volatility" in econ:
self._window_sums["train/volatility"] += float(econ.get("volatility", 0.0))
if "coi_mix" in econ:
self._window_sums["train/coi_mix"] += float(econ.get("coi_mix", 0.0))
if "coi_base" in econ:
self._window_sums["train/coi_base"] += float(econ.get("coi_base", 0.0))
if "coi_leakage" in econ:
self._window_sums["train/coi_leakage"] += float(
econ.get("coi_leakage", 0.0)
)
if "coi_penalty" in econ:
self._window_sums["train/coi_penalty"] += float(
econ.get("coi_penalty", 0.0)
)
self._window_count += 1
def _flush(self, step: int) -> None:
if self._window_count <= 0:
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",
}
}
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
def _on_step(self) -> bool:
for info in self.locals.get("infos", []):
if isinstance(info, dict):
self._accumulate(info)
if self.num_timesteps % self.log_freq == 0:
self._flush(step=self.num_timesteps)
return True
def _on_training_end(self) -> None:
self._flush(step=self.num_timesteps)
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
):
super().__init__(
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
)
self._eval_revenues: list[float] = []
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 = []
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"])

79
engine/lib/coi.py Normal file
View File

@@ -0,0 +1,79 @@
import numpy as np
from typing import Dict
def compute_agent_probability(
trajectory: list,
human_transitions: Dict,
agent_transitions: Dict,
temperature: float = 1.0,
) -> float:
"""estimate agent probability via KL divergence between trajectory transitions and reference models
compares empirical trajectory transition distribution to human/agent prototypes
args:
trajectory: list of state/event strings from session
human_transitions: reference transition dict from human MDP (event->event->prob)
agent_transitions: reference transition dict from agent MDP (event->event->prob)
returns:
agent probability in [0, 1] via softmax over KL divergences
"""
if len(trajectory) < 2:
return 0.0 # insufficient data, assume human
# build empirical transition distribution from trajectory
trans_counts = {}
for s, s_next in zip(trajectory[:-1], trajectory[1:]):
if s not in trans_counts:
trans_counts[s] = {}
trans_counts[s][s_next] = trans_counts[s].get(s_next, 0) + 1
# normalize to probabilities
empirical = {}
for s, nxt in trans_counts.items():
total = sum(nxt.values())
empirical[s] = {s_n: cnt / total for s_n, cnt in nxt.items()}
# compute KL divergence to each prototype
def kl_div(p_dist: Dict, q_dist: Dict) -> float:
eps = 1e-10
# aggregate over all source states in empirical dist
kl = 0.0
for s in p_dist:
if s not in q_dist:
continue # skip states not in reference
p_trans, q_trans = p_dist[s], q_dist[s]
for k in p_trans:
p_val = p_trans[k] + eps
q_val = q_trans.get(k, 0.0) + eps
kl += p_val * np.log(p_val / q_val)
return kl
kl_human = kl_div(empirical, human_transitions)
kl_agent = kl_div(empirical, agent_transitions)
# convert to probability via softmax (lower KL = higher prob)
t = float(max(temperature, 1e-6))
exp_h = np.exp(-kl_human / t)
exp_a = np.exp(-kl_agent / t)
return float(exp_a / (exp_h + exp_a + 1e-10))
def extract_purchases(trajectories: list) -> Dict[int, int]:
purchases: Dict[int, int] = {}
for traj in trajectories:
if traj and "checkout" in traj[-1] and "_product" in traj[-1]:
prod_id = int(traj[-1].rsplit("_product", 1)[1])
purchases[prod_id] = purchases.get(prod_id, 0) + 1
return purchases
def compute_uplift_coi(
prices: np.ndarray, purchases: Dict[int, int], baseline_prices: np.ndarray
) -> float:
# TODO: consider view-weighted fractional purchase for denser signal
return float(
sum(max(0.0, prices[k] - baseline_prices[k]) * n for k, n in purchases.items())
)

View File

@@ -1,45 +1,92 @@
import logging
import numpy as np
from logging import getLogger
logger = getLogger(__name__)
def generate_demand(prices, distribution_method = np.random.normal, distribution_params = (50.0, 10.0)):
# assumption 1: each product has an intrinsic valuation drawn from a normal distribution centered at 50
product_valuations = distribution_method(*distribution_params, size=len(prices))
# assumption 2: demand decreases as price increases, following a simple linear model
demand = np.maximum(0, product_valuations - prices) # demand cannot be negative
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
ACTION_CATEGORIES = {
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
"dwell": {"hover_title", "hover_paragraph", "hover_link"},
"nav": {"page_view", "view_item", "view", "learn_more"},
"filter": {"search", "filter_date", "filter_price", "sort"},
}
DEFAULT_ACTION_WEIGHTS = {
a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions
}
def generate_demand_for_actor(
prices: np.ndarray,
params: tuple,
noise_std: float = 1.0,
distribution_method=np.random.normal,
) -> np.ndarray:
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
params: (mean, std) for valuation distribution D_H or D_A"""
val = distribution_method(*params, size=len(prices))
noise = distribution_method(0, noise_std, len(prices))
demand = np.maximum(0, val - prices + noise)
total = np.sum(demand)
demand = demand / total * 100 if total > 0 else demand # normalize to percentage, avoid div by zero
logger.info(f"Generated demand for prices {prices}: {demand} with valuations from distribution {distribution_params}")
return demand
return demand / total * 100 if total > 0 else demand
def estimate_demand(trajectories):
demand_estimate = {}
def estimate_demand(trajectories, action_weights=None):
return estimate_weighted_demand(trajectories, action_weights)
def _parse_event_state(state: str):
if "_product" not in state:
return state, None
action, raw_pid = state.rsplit("_product", 1)
return action, int(raw_pid) if raw_pid.isdigit() else None
def _weight_for_action(action: str, action_weights: dict) -> float:
if action in action_weights:
return action_weights[action]
if action.startswith("hover"):
return CATEGORY_WEIGHTS["dwell"]
if action.startswith("filter") or action in {"search", "sort"}:
return CATEGORY_WEIGHTS["filter"]
if action.startswith("add") or action in {"checkout", "purchase", "remove"}:
return CATEGORY_WEIGHTS["cart"]
return CATEGORY_WEIGHTS["nav"]
def estimate_weighted_demand(trajectories, action_weights=None):
action_weights = (
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
)
scores = {}
for traj in trajectories:
for event in traj:
if 'view_product' in event:
product_id = int(event.split('_')[-1].replace('product', ''))
demand_estimate[product_id] = demand_estimate.get(product_id, 0) + 1
total_views = sum(demand_estimate.values())
for product_id in demand_estimate:
demand_estimate[product_id] = (demand_estimate[product_id] / total_views) * 100 # normalize to percentage
return demand_estimate
for state in traj:
action, product_id = _parse_event_state(state)
if product_id is None:
continue
w = _weight_for_action(action, action_weights)
if w <= 0:
continue
scores[product_id] = scores.get(product_id, 0.0) + w
total = sum(scores.values())
return (
{pid: (score / total) * 100 for pid, score in scores.items()}
if total > 0
else {}
)
# Example usage
if __name__ == "__main__":
np.random.seed(42)
prices = np.array([20.0, 35.0, 50.0, 65.0])
demand = generate_demand(prices)
print("Generated Demand:", demand)
# demo actor-specific demands
human_params, agent_params = (50, 10), (45, 15)
demand_h = generate_demand_for_actor(prices, human_params)
demand_a = generate_demand_for_actor(prices, agent_params)
print("Human Demand:", demand_h)
print("Agent Demand:", demand_a)
from .behavior import sample_behavior
N, alphat =200, 0.1
trajectories = []
for _ in range(int(N*(1 - alphat))):
trajectories.append(sample_behavior(demand, human=True))
for _ in range(int(N*alphat)):
trajectories.append(sample_behavior(demand, human=False))
demand_estimate = estimate_demand(trajectories)
N, alpha = 200, 0.3
n_h, n_a = int(N * (1 - alpha)), int(N * alpha)
human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)]
agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)]
demand_estimate = estimate_demand(human_t + agent_t)
print("Estimated Demand from Behavior:", demand_estimate)
delta = {k: demand_estimate.get(k, 0) - demand[i] for i, k in enumerate(range(len(prices)))}
delta = np.mean([np.abs(v) for v in delta.values()])
print("Demand Delta:", delta)

70
engine/lib/discrete.py Normal file
View File

@@ -0,0 +1,70 @@
from collections import defaultdict
import gymnasium as gym
from gymnasium import spaces
import numpy as np
class DiscretePriceActionWrapper(gym.ActionWrapper):
def __init__(
self,
env: gym.Env,
n_levels: int = 9,
min_scale: float = 0.8,
max_scale: float = 1.2,
):
super().__init__(env)
self.scales = np.linspace(min_scale, max_scale, n_levels, dtype=np.float32)
self.action_space = spaces.Discrete(n_levels)
def action(self, action: int):
scale = float(self.scales[int(action)])
cur = np.asarray(self.env.unwrapped._prices, dtype=np.float32)
lo, hi = self.env.unwrapped.price_bounds
return np.clip(cur * scale, lo, hi).astype(np.float32)
class EventQTable:
def __init__(
self,
n_actions: int,
n_products: int,
price_bounds: tuple,
lr: float = 0.1,
gamma: float = 0.99,
n_bins: int = 6,
):
self.n_actions = int(n_actions)
self.n_products = int(n_products)
self.lr = float(lr)
self.gamma = float(gamma)
self.q = defaultdict(lambda: np.zeros(self.n_actions, dtype=np.float32))
lo, hi = price_bounds
self.demand_bins = np.linspace(0.0, 100.0, n_bins + 1)[1:-1]
self.price_bins = np.linspace(lo, hi, n_bins + 1)[1:-1]
def encode(self, obs: np.ndarray) -> tuple:
obs = np.asarray(obs, dtype=np.float32)
d = obs[: self.n_products]
p = obs[self.n_products : 2 * self.n_products]
d_mean = float(np.mean(d)) if d.size else 0.0
d_std = float(np.std(d)) if d.size else 0.0
p_mean = float(np.mean(p)) if p.size else 0.0
return (
int(np.digitize(d_mean, self.demand_bins)),
int(np.digitize(d_std, self.demand_bins)),
int(np.digitize(p_mean, self.price_bins)),
)
def act(self, obs: np.ndarray, eps: float = 0.0) -> tuple[int, tuple]:
s = self.encode(obs)
if np.random.random() < eps:
return int(np.random.randint(self.n_actions)), s
return int(np.argmax(self.q[s])), s
def update(self, s: tuple, a: int, r: float, s2: tuple, done: bool):
target = r + (0.0 if done else self.gamma * float(np.max(self.q[s2])))
self.q[s][a] += self.lr * (target - self.q[s][a])
def predict(self, obs: np.ndarray, deterministic: bool = True):
a, _ = self.act(obs, 0.0 if deterministic else 0.05)
return a, None

182
engine/lib/providers.py Normal file
View File

@@ -0,0 +1,182 @@
"""Provider benchmarking - compare pricing strategies across contamination levels."""
from dataclasses import dataclass, field
from typing import Callable, Any
import numpy as np
import pandas as pd
try:
import wandb
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
class RandomBaseline:
"""uniform random action selection as a lower-bound baseline"""
def __init__(self, n_actions: int):
self.n = n_actions
def __call__(self, obs):
return int(np.random.randint(self.n))
def predict(self, obs, **kw):
return self(obs), None
class SurgeBaseline:
"""heuristic surge pricing: boost price when demand is above threshold, discount when below.
matches the naive pricing rule from thesis Section 3.3.2"""
def __init__(
self, n_actions: int, high_threshold: float = 60.0, low_threshold: float = 30.0
):
self.n = n_actions
self.mid = n_actions // 2 # identity action (scale ~1.0)
self.high_t = high_threshold
self.low_t = low_threshold
def __call__(self, obs):
obs = np.asarray(obs, dtype=np.float32)
n_prod = len(obs) // 2
demand_mean = float(np.mean(obs[:n_prod])) if n_prod > 0 else 0.0
if demand_mean >= self.high_t:
return min(self.mid + 2, self.n - 1) # surge: two levels above identity
if demand_mean <= self.low_t:
return max(self.mid - 2, 0) # discount: two levels below identity
return self.mid # hold
def predict(self, obs, **kw):
return self(obs), None
@dataclass
class ProviderResult:
"""Single benchmark result for one provider at one alpha level."""
name: str
alpha: float
total_revenue: float
mean_revenue: float
coi_level: float
coi_preserved_pct: float # vs alpha=0 baseline
margin_integrity: float
regret: float
episodes: int
@dataclass
class BenchmarkConfig:
"""Configuration for provider benchmark runs."""
n_episodes: int = 100
alpha_range: list[float] = field(default_factory=lambda: [0.0, 0.1, 0.3, 0.5])
baseline_name: str = "fixed"
class ProviderBenchmark:
"""Compare pricing providers to prove margin preservation across contamination levels.
Usage:
def env_factory(alpha):
return EconomicMetricsWrapper(PHANTOM(alpha=alpha))
providers = {
"fixed": lambda obs: np.ones(10) * 50,
"learned": model.predict,
}
benchmark = ProviderBenchmark(env_factory, providers)
results = benchmark.run()
print(benchmark.summary_table())
"""
def __init__(
self,
env_factory: Callable[[float], Any],
providers: dict[str, Callable],
config: BenchmarkConfig | None = None,
):
self.env_factory = env_factory # fn(alpha) -> wrapped env
self.providers = providers # {name: fn(obs) -> action}
self.config = config or BenchmarkConfig()
self.results: list[ProviderResult] = []
def run(self) -> list[ProviderResult]:
"""Run benchmark across all providers and alpha levels."""
baseline_coi: dict[str, float] = {} # {provider: coi at alpha=0}
for alpha in self.config.alpha_range:
env = self.env_factory(alpha)
for name, policy_fn in self.providers.items():
revenues, coi_levels, margins = [], [], []
for _ in range(self.config.n_episodes):
obs, _ = env.reset()
episode_revenue = 0.0
done = False
while not done:
action = policy_fn(obs)
# handle sb3 model.predict returning tuple
if isinstance(action, tuple):
action = action[0]
obs, reward, term, trunc, info = env.step(action)
done = term or trunc
econ = info.get("economics", {})
episode_revenue += econ.get("revenue", 0)
coi_levels.append(econ.get("coi_level", 0))
margins.append(econ.get("margin", 0))
revenues.append(episode_revenue)
mean_coi = np.mean(coi_levels) if coi_levels else 0.0
if alpha == 0.0:
baseline_coi[name] = mean_coi
base = baseline_coi.get(name, mean_coi)
coi_preserved = mean_coi / base if base > 0 else 1.0
result = ProviderResult(
name=name,
alpha=alpha,
total_revenue=float(np.sum(revenues)),
mean_revenue=float(np.mean(revenues)),
coi_level=mean_coi,
coi_preserved_pct=coi_preserved * 100,
margin_integrity=float(np.mean(margins)) if margins else 0.0,
regret=0.0, # compute vs optimal if known
episodes=self.config.n_episodes,
)
self.results.append(result)
# log to wandb if available
if HAS_WANDB and wandb.run is not None:
wandb.log(
{
f"benchmark/{name}/revenue": result.mean_revenue,
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
f"benchmark/{name}/margin": result.margin_integrity,
"benchmark/alpha": alpha,
}
)
return self.results
def to_dataframe(self) -> pd.DataFrame:
"""Convert results to pandas DataFrame."""
return pd.DataFrame([r.__dict__ for r in self.results])
def summary_table(self) -> pd.DataFrame:
"""Pivot table: providers x alpha with revenue/COI metrics."""
df = self.to_dataframe()
return df.pivot_table(
index="name",
columns="alpha",
values=["mean_revenue", "coi_preserved_pct", "margin_integrity"],
aggfunc="mean",
)

View File

@@ -1,15 +1,19 @@
"""rendering logic for PHANTOM environment dashboard"""
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.gridspec import GridSpec
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
if title: ax.set_title(title, fontsize=11, fontweight='bold', pad=8)
if xlabel: ax.set_xlabel(xlabel, fontsize=9)
if ylabel: ax.set_ylabel(ylabel, fontsize=9)
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
if title:
ax.set_title(title, fontsize=11, fontweight="bold", pad=8)
if xlabel:
ax.set_xlabel(xlabel, fontsize=9)
if ylabel:
ax.set_ylabel(ylabel, fontsize=9)
class DashboardRenderer:
@@ -23,13 +27,25 @@ class DashboardRenderer:
if self.fig is None:
plt.ion()
self.fig = plt.figure(figsize=(14, 10))
self.gs = GridSpec(3, 3, figure=self.fig, hspace=0.35, wspace=0.3,
left=0.07, right=0.95, top=0.92, bottom=0.08)
self.gs = GridSpec(
3,
3,
figure=self.fig,
hspace=0.35,
wspace=0.3,
left=0.07,
right=0.95,
top=0.92,
bottom=0.08,
)
plt.show(block=False)
self.fig.clear()
self.fig.suptitle(f'PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]',
fontsize=14, fontweight='bold')
self.fig.suptitle(
f"PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]",
fontsize=14,
fontweight="bold",
)
demand_mat = np.array(env._demand_history).T
price_mat = np.array(env._price_history).T
@@ -51,40 +67,56 @@ class DashboardRenderer:
prices_flat = np.array(env._price_history).flatten()
demands_flat = np.array(env._demand_history).flatten()
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
ax.scatter(prices_flat, demands_flat, c=product_ids, cmap='plasma', alpha=0.6, s=15, edgecolors='none')
ax.scatter(
prices_flat,
demands_flat,
c=product_ids,
cmap="plasma",
alpha=0.6,
s=15,
edgecolors="none",
)
if len(prices_flat) > 1:
z = np.polyfit(prices_flat, demands_flat, 1)
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
ax.plot(p_line, np.polyval(z, p_line), '--', lw=1.5, alpha=0.8)
ax.plot(p_line, np.polyval(z, p_line), "--", lw=1.5, alpha=0.8)
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
def _render_elasticity_bar(self, env, elasticity):
ax = self.fig.add_subplot(self.gs[0, 1])
ax.barh(range(env.n_products), elasticity, alpha=0.8)
ax.axvline(0, lw=0.8, alpha=0.5)
ax.axvline(-1, lw=1, ls='--', alpha=0.5)
ax.axvline(-1, lw=1, ls="--", alpha=0.5)
ax.set_yticks(range(env.n_products))
ax.set_yticklabels([f'P{i}' for i in range(env.n_products)], fontsize=7)
ax.set_yticklabels([f"P{i}" for i in range(env.n_products)], fontsize=7)
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
def _render_session_pie(self, env):
ax = self.fig.add_subplot(self.gs[0, 2])
n_h, n_a = env.market.Nhumans, env.market.Nagents
wedges, _ = ax.pie([n_h, n_a], startangle=90, wedgeprops={'linewidth': 2, 'edgecolor': 'white'})
ax.legend(wedges, [f'H ({n_h})', f'A ({n_a})'], loc='lower center', fontsize=8,
frameon=False, bbox_to_anchor=(0.5, -0.05))
ax.set_title("Session Mix", fontsize=11, fontweight='bold')
wedges, _ = ax.pie(
[n_h, n_a], startangle=90, wedgeprops={"linewidth": 2, "edgecolor": "white"}
)
ax.legend(
wedges,
[f"H ({n_h})", f"A ({n_a})"],
loc="lower center",
fontsize=8,
frameon=False,
bbox_to_anchor=(0.5, -0.05),
)
ax.set_title("Session Mix", fontsize=11, fontweight="bold")
def _render_price_heatmap(self, price_mat):
ax = self.fig.add_subplot(self.gs[1, :2])
im = ax.imshow(price_mat, aspect='auto', cmap='viridis', origin='lower')
im = ax.imshow(price_mat, aspect="auto", cmap="viridis", origin="lower")
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
cbar.set_label('$', fontsize=8)
cbar.set_label("$", fontsize=8)
def _render_demand_heatmap(self, demand_mat):
ax = self.fig.add_subplot(self.gs[1, 2])
im = ax.imshow(demand_mat, aspect='auto', cmap='Blues', origin='lower')
im = ax.imshow(demand_mat, aspect="auto", cmap="Blues", origin="lower")
style_axis(ax, "Demand Q(product, t)", "Step", None)
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
@@ -92,11 +124,11 @@ class DashboardRenderer:
ax = self.fig.add_subplot(self.gs[2, 0])
if price_mat.shape[1] > 2:
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
im = ax.imshow(corr, cmap='RdBu', vmin=-1, vmax=1, aspect='auto')
im = ax.imshow(corr, cmap="RdBu", vmin=-1, vmax=1, aspect="auto")
ax.set_xticks(range(n_products))
ax.set_yticks(range(n_products))
ax.set_xticklabels([f'Q{i}' for i in range(n_products)], fontsize=6)
ax.set_yticklabels([f'P{i}' for i in range(n_products)], fontsize=6)
ax.set_xticklabels([f"Q{i}" for i in range(n_products)], fontsize=6)
ax.set_yticklabels([f"P{i}" for i in range(n_products)], fontsize=6)
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
style_axis(ax, "Price-Demand Correlation", None, None)
@@ -105,20 +137,27 @@ class DashboardRenderer:
n_steps = len(env._revenue_history)
demand_std = [np.std(d) for d in env._demand_history]
ax.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
ax.plot(env._revenue_history, linewidth=2, label='Revenue')
ax.plot(env._revenue_history, linewidth=2, label="Revenue")
ax.set_xlim(0, max(n_steps, 1))
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
ax2 = ax.twinx()
ax2.plot(range(n_steps), demand_std, linewidth=2, ls='-', alpha=0.9, label='sigma(Demand)')
ax2.plot(
range(n_steps),
demand_std,
linewidth=2,
ls="-",
alpha=0.9,
label="sigma(Demand)",
)
d_min, d_max = min(demand_std), max(demand_std)
margin = (d_max - d_min) * 0.2 if d_max > d_min else 0.5
ax2.set_ylim(max(0, d_min - margin), d_max + margin)
ax2.set_ylabel('Demand sigma', fontsize=9)
ax2.set_ylabel("Demand sigma", fontsize=9)
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
ax.legend(loc='upper left', fontsize=7, frameon=False)
ax2.legend(loc='upper right', fontsize=7, frameon=False)
ax.legend(loc="upper left", fontsize=7, frameon=False)
ax2.legend(loc="upper right", fontsize=7, frameon=False)
def close(self):
if self.fig:

101
engine/lib/tiers.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import numpy as np
class PolicyLike(Protocol):
def predict(self, obs: np.ndarray, deterministic: bool = True): ...
class StaticPolicy:
def __init__(self, n_actions: int):
self._action = int(max(0, n_actions // 2))
def predict(self, obs: np.ndarray, deterministic: bool = True):
return self._action, None
class SurgePolicy:
def __init__(
self,
n_actions: int,
n_products: int,
high_threshold: float = 60.0,
low_threshold: float = 30.0,
):
self.n_actions = int(n_actions)
self.n_products = int(n_products)
self.mid = self.n_actions // 2
self.high_t = float(high_threshold)
self.low_t = float(low_threshold)
def predict(self, obs: np.ndarray, deterministic: bool = True):
obs_arr = np.asarray(obs, dtype=np.float32)
demand = obs_arr[: self.n_products]
demand_mean = float(np.mean(demand)) if demand.size > 0 else 0.0
if demand_mean >= self.high_t:
return min(self.mid + 2, self.n_actions - 1), None
if demand_mean <= self.low_t:
return max(self.mid - 2, 0), None
return self.mid, None
@dataclass
class LinearElasticityPolicy:
n_actions: int
n_products: int
price_low: float
price_high: float
def __post_init__(self):
self.n_actions = int(self.n_actions)
self.n_products = int(self.n_products)
self.price_low = float(self.price_low)
self.price_high = float(self.price_high)
self._target_price = 0.5 * (self.price_low + self.price_high)
self._action_scales = np.linspace(0.8, 1.2, self.n_actions)
def fit(self, env, warmup_steps: int = 800, seed: int = 42):
rng = np.random.default_rng(int(seed))
obs, _ = env.reset(seed=int(seed))
prices: list[float] = []
demands: list[float] = []
for _ in range(int(max(10, warmup_steps))):
action = int(rng.integers(0, self.n_actions))
obs, _, term, trunc, info = env.step(action)
done = bool(term or trunc)
p = np.asarray(info.get("prices", []), dtype=np.float32)
d = np.asarray(info.get("demand", []), dtype=np.float32)
if p.size > 0 and d.size > 0:
prices.append(float(np.mean(p)))
demands.append(float(np.mean(d)))
if done:
obs, _ = env.reset()
if len(prices) < 8:
self._target_price = 0.5 * (self.price_low + self.price_high)
return self
slope, intercept = np.polyfit(np.asarray(prices), np.asarray(demands), 1)
if slope < -1e-6:
p_star = -intercept / (2.0 * slope)
self._target_price = float(np.clip(p_star, self.price_low, self.price_high))
else:
self._target_price = 0.5 * (self.price_low + self.price_high)
return self
def predict(self, obs: np.ndarray, deterministic: bool = True):
obs_arr = np.asarray(obs, dtype=np.float32)
cur_prices = obs_arr[self.n_products : 2 * self.n_products]
cur_mean = (
float(np.mean(cur_prices)) if cur_prices.size > 0 else self._target_price
)
scale = self._target_price / max(cur_mean, 1e-6)
action = int(np.argmin(np.abs(self._action_scales - scale)))
return int(np.clip(action, 0, self.n_actions - 1)), None

90
engine/lib/wrappers.py Normal file
View File

@@ -0,0 +1,90 @@
"""Economic metrics wrapper - calculates thesis-aligned KPIs and injects into info dict."""
import gymnasium as gym
import numpy as np
class EconomicMetricsWrapper(gym.Wrapper):
"""Calculates thesis-aligned economic metrics per step, injects into info.
Metrics follow thesis definitions:
- COI level: E[P] - p_min (Definition 1)
- Margin: (avg_price - p_min) / avg_price
- Regret: 1 - (revenue / baseline_revenue)
"""
def __init__(
self, env: gym.Env, p_min: float = 10.0, baseline_revenue: float | None = None
):
super().__init__(env)
self.p_min = p_min
self.baseline_revenue = baseline_revenue
self._price_history: list[np.ndarray] = []
self._revenue_history: list[float] = []
def reset(self, **kwargs):
obs, info = self.env.reset(**kwargs)
self._price_history = []
self._revenue_history = []
return obs, info
def step(self, action):
obs, reward, terminated, truncated, info = self.env.step(action)
# extract from unwrapped env
prices = self.env.unwrapped._prices
demand_dict = self.env.unwrapped._demand
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))])
# core calculations
revenue = float(np.sum(prices * demand))
avg_price = float(np.mean(prices))
margin = (avg_price - self.p_min) / max(avg_price, 1e-6)
coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1
self._price_history.append(prices.copy())
self._revenue_history.append(revenue)
# regret vs baseline (golden path)
regret = 0.0
if self.baseline_revenue and self.baseline_revenue > 0:
regret = 1.0 - (revenue / self.baseline_revenue)
# inject structured metrics into info
info["economics"] = {
"revenue": revenue,
"margin": margin,
"coi_level": coi_level,
"regret": regret,
}
for key in (
"coi_mix",
"coi_base",
"coi_leakage",
"coi_penalty",
"ux_penalty",
"volatility",
"profit",
"cost_floor",
"reward_revenue",
"reward_total",
"agent_prob",
"alpha_adv",
"alpha_nominal",
):
if key in info:
info["economics"][key] = info[key]
info["prices"] = prices.copy()
info["demand"] = demand.copy()
return obs, reward, terminated, truncated, info
@property
def episode_revenue(self) -> float:
return sum(self._revenue_history)
@property
def episode_mean_price(self) -> float:
if not self._price_history:
return 0.0
return float(np.mean([np.mean(p) for p in self._price_history]))

33
engine/logging_utils.py Normal file
View File

@@ -0,0 +1,33 @@
from __future__ import annotations
import logging
import os
import sys
_CONFIGURED = False
def _resolve_level(raw: str | None) -> int:
name = str(raw or os.environ.get("PHANTOM_LOG_LEVEL", "INFO")).upper().strip()
return int(getattr(logging, name, logging.INFO))
def configure_logging(level: str | None = None) -> None:
global _CONFIGURED
if _CONFIGURED:
return
logger = logging.getLogger("engine")
logger.setLevel(_resolve_level(level))
logger.propagate = False
if logger.handlers:
_CONFIGURED = True
return
handler = logging.StreamHandler(stream=sys.stdout)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s [%(name)s] %(message)s")
)
logger.addHandler(handler)
_CONFIGURED = True

View File

@@ -0,0 +1,5 @@
from .benchmark import run_benchmark_cli
from .sweep_agent import run_sweep_agent
from .train import run_train_once
__all__ = ["run_benchmark_cli", "run_sweep_agent", "run_train_once"]

View File

@@ -0,0 +1,7 @@
from __future__ import annotations
def run_benchmark_cli(raw_args: list[str] | None = None) -> None:
from ..benchmark import run_cli
run_cli(raw_args)

View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from typing import Any, Mapping, Sequence
from ..spec import TrainSpec, run_name
from ..telemetry.wandb import (
current_config,
finish_run,
get_wandb_module,
init_run,
run_agent,
)
from .train import run_with_active_sweep_run
def run_sweep_agent(
*,
project: str,
sweep_id: str,
count: int,
offline: bool,
no_wandb: bool,
base_overrides: Mapping[str, Any],
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> None:
if no_wandb:
raise ValueError("sweep agent requires wandb")
if not sweep_id:
raise ValueError("--sweep-id is required with --sweep-agent")
if get_wandb_module() is None:
raise ImportError("wandb is required for sweep runs")
mode = "offline" if offline else "online"
def _sweep_trial() -> None:
run = init_run(mode=mode, project=project, group=group, sweep_mode=True)
try:
merged = dict(base_overrides)
merged.update(current_config())
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,
)
finally:
finish_run()
run_agent(
sweep_id,
_sweep_trial,
count=count if count > 0 else None,
)

View File

@@ -0,0 +1,124 @@
from __future__ import annotations
import json
from typing import Any, Sequence
from ..spec import TrainSpec, run_metadata, run_name
from ..telemetry.wandb import (
finish_run,
get_wandb_module,
init_run,
log_metrics,
update_run_config,
update_summary,
)
from ..train_core import run_train
def _tags_for_run(spec: TrainSpec, kind: str, extra_tags: Sequence[str]) -> list[str]:
tags = [
kind,
spec.algorithm.name,
spec.runtime.backend,
"vanilla" if spec.study.no_robust else "robust",
]
tags.extend([tag for tag in extra_tags if tag])
return tags
def _print_local_metrics(metrics: dict[str, Any]) -> None:
print(json.dumps(metrics, indent=2))
print("PHANTOM_METRICS:" + json.dumps(metrics))
def _log_train_events(events: list[dict[str, Any]], log_freq: int) -> None:
if not events:
return
period = max(1, int(log_freq))
last_logged_step = -period
for event in sorted(
[evt for evt in events if isinstance(evt, dict)],
key=lambda evt: int(evt.get("train/global_step", 0)),
):
step = int(event.get("train/global_step", 0))
if step <= 0 or (step - last_logged_step) < period:
continue
log_metrics(event, step=step)
last_logged_step = step
def run_train_once(
spec: TrainSpec,
*,
project: str,
offline: bool,
no_wandb: bool,
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> dict[str, Any]:
wandb = get_wandb_module()
if no_wandb or wandb is None:
result = run_train(spec)
_print_local_metrics(result.metrics)
return result.metrics
mode = "offline" if offline else "online"
tags = _tags_for_run(spec, kind, extra_tags)
metadata = run_metadata(
spec,
kind=kind,
scenario=scenario,
group=group,
tags=tags,
)
config = spec.to_flat_dict()
config.update(metadata)
name = run_name(spec, kind=kind, scenario=scenario)
init_run(
mode=mode,
project=project,
config=config,
name=name,
tags=tags,
group=group,
sweep_mode=False,
)
try:
result = run_train(spec)
_log_train_events(result.events, spec.runtime.log_freq)
metrics = result.metrics
step = int(metrics.get("train/global_step", spec.runtime.total_timesteps))
log_metrics(metrics, step=step)
update_summary(metrics)
return metrics
finally:
finish_run()
def run_with_active_sweep_run(
spec: TrainSpec,
*,
kind: str,
scenario: str,
group: str | None,
extra_tags: Sequence[str],
) -> dict[str, Any]:
tags = _tags_for_run(spec, kind, extra_tags)
metadata = run_metadata(
spec,
kind=kind,
scenario=scenario,
group=group,
tags=tags,
)
update_run_config({**spec.to_flat_dict(), **metadata})
result = run_train(spec)
_log_train_events(result.events, spec.runtime.log_freq)
metrics = result.metrics
step = int(metrics.get("train/global_step", spec.runtime.total_timesteps))
log_metrics(metrics, step=step)
update_summary(metrics)
return metrics

100
engine/project.json Normal file
View File

@@ -0,0 +1,100 @@
{
"$schema": "../node_modules/nx/schemas/project-schema.json",
"name": "research",
"projectType": "application",
"sourceRoot": "engine",
"targets": {
"install": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh install",
"cwd": "."
}
},
"test": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": ".venv/bin/pytest -v",
"cwd": "."
}
},
"train": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh train",
"cwd": "."
}
},
"benchmark": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark",
"cwd": "."
}
},
"benchmark-simple": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark-simple",
"cwd": "."
}
},
"benchmark-agent": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh benchmark-agent",
"cwd": "."
}
},
"train-agent": {
"executor": "nx:run-commands",
"dependsOn": [
"install"
],
"options": {
"command": "bash scripts/nx_research.sh train-agent",
"cwd": "."
}
},
"train-bootstrap": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh train-bootstrap",
"cwd": "."
}
},
"stats": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh stats",
"cwd": "."
}
},
"docker-train-publish": {
"executor": "nx:run-commands",
"options": {
"command": "bash scripts/nx_research.sh docker-train-publish",
"cwd": "."
}
}
},
"tags": [
"scope:research",
"type:python"
]
}

332
engine/spec.py Normal file
View File

@@ -0,0 +1,332 @@
from __future__ import annotations
from dataclasses import dataclass, field
import os
from typing import Any, Mapping, Sequence
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 _normalize_keys(raw: Mapping[str, Any]) -> dict[str, Any]:
alias_map = {
"algorithm": "algo",
"algorithm.name": "algo",
"env.n_products": "n_products",
"env.action_levels": "action_levels",
"env.action_scale_low": "action_scale_low",
"env.action_scale_high": "action_scale_high",
"env.price_low": "price_low",
"env.price_high": "price_high",
"env.max_steps": "max_steps",
"env.margin_floor": "margin_floor",
"env.margin_floor_patience": "margin_floor_patience",
"env.n_sessions": "N",
"study.alpha": "alpha",
"study.lambda_coi": "lambda_coi",
"study.robust_radius": "robust_radius",
"study.robust_points": "robust_points",
"study.robust_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",
"optimizer.learning_rate": "learning_rate",
"optimizer.gamma": "gamma",
"optimizer.batch_size": "batch_size",
"optimizer.n_steps": "n_steps",
"runtime.backend": "backend",
"runtime.device": "device",
"runtime.seed": "seed",
"runtime.total_timesteps": "total_timesteps",
"runtime.checkpoint_interval": "checkpoint_interval",
"eval.eval_freq": "eval_freq",
"eval.eval_episodes": "eval_episodes",
}
normalized: dict[str, Any] = {}
for key, value in raw.items():
canonical = alias_map.get(str(key), str(key))
normalized[canonical] = value
return normalized
@dataclass(frozen=True)
class AlgorithmSpec:
name: str = "ppo"
@dataclass(frozen=True)
class EnvSpec:
n_products: int = 10
n_sessions: int = 100
price_low: float = 10.0
price_high: float = 150.0
action_levels: int = 9
action_scale_low: float = 0.8
action_scale_high: float = 1.2
max_steps: int = 100
margin_floor: float = 0.05
margin_floor_patience: int = 5
@dataclass(frozen=True)
class StudySpec:
alpha: float = 0.3
lambda_coi: float = 0.2
robust_radius: float = 0.15
robust_points: int = 5
robust_rollouts: int = 1
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
@dataclass(frozen=True)
class OptimizerSpec:
learning_rate: float = 3e-4
gamma: float = 0.99
buffer_size: int = 50_000
batch_size: int = 256
tau: float = 0.005
train_freq: int = 1
learning_starts: int = 1_000
target_update_interval: int = 1_000
exploration_fraction: float = 0.2
exploration_final_eps: float = 0.05
n_steps: int = 2_048
n_epochs: int = 10
gae_lambda: float = 0.95
clip_range: float = 0.2
ent_coef: float = 0.0
q_lr: float = 0.1
q_bins: int = 6
eps_start: float = 1.0
eps_end: float = 0.05
eps_decay: float = 0.9995
arch: str = "small"
activation: str = "relu"
vf_coef: float = 0.5
max_grad_norm: float = 0.5
@dataclass(frozen=True)
class RuntimeSpec:
project: str = "capstone"
backend: str = "sb3"
device: str = "auto"
seed: int = 42
total_timesteps: int = 50_000
checkpoint_interval: int = 200_000
model_dir: str = "engine/models"
log_freq: int = 100
@dataclass(frozen=True)
class EvalSpec:
eval_freq: int = 1_000
eval_episodes: int = 5
robust_eval_enabled: bool = True
@dataclass(frozen=True)
class TrainSpec:
algorithm: AlgorithmSpec = field(default_factory=AlgorithmSpec)
env: EnvSpec = field(default_factory=EnvSpec)
study: StudySpec = field(default_factory=StudySpec)
optimizer: OptimizerSpec = field(default_factory=OptimizerSpec)
runtime: RuntimeSpec = field(default_factory=RuntimeSpec)
eval: EvalSpec = field(default_factory=EvalSpec)
def to_flat_dict(self) -> dict[str, Any]:
return {
"project": self.runtime.project,
"algo": self.algorithm.name,
"seed": self.runtime.seed,
"total_timesteps": self.runtime.total_timesteps,
"eval_episodes": self.eval.eval_episodes,
"eval_freq": self.eval.eval_freq,
"log_freq": self.runtime.log_freq,
"model_dir": self.runtime.model_dir,
"backend": self.runtime.backend,
"device": self.runtime.device,
"checkpoint_interval": self.runtime.checkpoint_interval,
"n_products": self.env.n_products,
"N": self.env.n_sessions,
"price_low": self.env.price_low,
"price_high": self.env.price_high,
"action_levels": self.env.action_levels,
"action_scale_low": self.env.action_scale_low,
"action_scale_high": self.env.action_scale_high,
"max_steps": self.env.max_steps,
"margin_floor": self.env.margin_floor,
"margin_floor_patience": self.env.margin_floor_patience,
"alpha": self.study.alpha,
"lambda_coi": self.study.lambda_coi,
"robust_radius": self.study.robust_radius,
"robust_points": self.study.robust_points,
"robust_rollouts": self.study.robust_rollouts,
"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,
"buffer_size": self.optimizer.buffer_size,
"batch_size": self.optimizer.batch_size,
"tau": self.optimizer.tau,
"train_freq": self.optimizer.train_freq,
"learning_starts": self.optimizer.learning_starts,
"target_update_interval": self.optimizer.target_update_interval,
"exploration_fraction": self.optimizer.exploration_fraction,
"exploration_final_eps": self.optimizer.exploration_final_eps,
"n_steps": self.optimizer.n_steps,
"n_epochs": self.optimizer.n_epochs,
"gae_lambda": self.optimizer.gae_lambda,
"clip_range": self.optimizer.clip_range,
"ent_coef": self.optimizer.ent_coef,
"q_lr": self.optimizer.q_lr,
"q_bins": self.optimizer.q_bins,
"eps_start": self.optimizer.eps_start,
"eps_end": self.optimizer.eps_end,
"eps_decay": self.optimizer.eps_decay,
"arch": self.optimizer.arch,
"activation": self.optimizer.activation,
"vf_coef": self.optimizer.vf_coef,
"max_grad_norm": self.optimizer.max_grad_norm,
"robust_eval_enabled": self.eval.robust_eval_enabled,
}
@classmethod
def from_flat(
cls,
raw: Mapping[str, Any] | None = None,
*,
env_vars: Mapping[str, str] | None = None,
) -> "TrainSpec":
base = cls().to_flat_dict()
incoming = _normalize_keys(raw or {})
base.update({k: v for k, v in incoming.items() if v is not None})
runtime_env = os.environ if env_vars is None else env_vars
base["device"] = str(
base.get("device", runtime_env.get("PHANTOM_DEVICE", "auto"))
)
backend = str(base.get("backend", "sb3")).lower()
if backend == "auto":
backend = "sb3"
if backend != "sb3":
backend = "sb3"
no_robust = _truthy(base.get("no_robust"))
if no_robust:
base["lambda_coi"] = 0.0
base["robust_radius"] = 0.0
base["robust_points"] = 1
base["robust_rollouts"] = 1
return cls(
algorithm=AlgorithmSpec(name=str(base["algo"]).lower().strip()),
env=EnvSpec(
n_products=int(base["n_products"]),
n_sessions=int(base["N"]),
price_low=float(base["price_low"]),
price_high=float(base["price_high"]),
action_levels=int(base["action_levels"]),
action_scale_low=float(base["action_scale_low"]),
action_scale_high=float(base["action_scale_high"]),
max_steps=int(base["max_steps"]),
margin_floor=float(base["margin_floor"]),
margin_floor_patience=int(base["margin_floor_patience"]),
),
study=StudySpec(
alpha=float(base["alpha"]),
lambda_coi=float(base["lambda_coi"]),
robust_radius=float(base["robust_radius"]),
robust_points=int(base["robust_points"]),
robust_rollouts=int(base["robust_rollouts"]),
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(
learning_rate=float(base["learning_rate"]),
gamma=float(base["gamma"]),
buffer_size=int(base["buffer_size"]),
batch_size=int(base["batch_size"]),
tau=float(base["tau"]),
train_freq=int(base["train_freq"]),
learning_starts=int(base["learning_starts"]),
target_update_interval=int(base["target_update_interval"]),
exploration_fraction=float(base["exploration_fraction"]),
exploration_final_eps=float(base["exploration_final_eps"]),
n_steps=int(base["n_steps"]),
n_epochs=int(base["n_epochs"]),
gae_lambda=float(base["gae_lambda"]),
clip_range=float(base["clip_range"]),
ent_coef=float(base["ent_coef"]),
q_lr=float(base["q_lr"]),
q_bins=int(base["q_bins"]),
eps_start=float(base["eps_start"]),
eps_end=float(base["eps_end"]),
eps_decay=float(base["eps_decay"]),
arch=str(base["arch"]),
activation=str(base["activation"]),
vf_coef=float(base["vf_coef"]),
max_grad_norm=float(base["max_grad_norm"]),
),
runtime=RuntimeSpec(
project=str(base["project"]),
backend=backend,
device=str(base["device"]),
seed=int(base["seed"]),
total_timesteps=int(base["total_timesteps"]),
checkpoint_interval=int(base["checkpoint_interval"]),
model_dir=str(base["model_dir"]),
log_freq=int(base["log_freq"]),
),
eval=EvalSpec(
eval_freq=int(base["eval_freq"]),
eval_episodes=int(base["eval_episodes"]),
robust_eval_enabled=_truthy(base.get("robust_eval_enabled", True)),
),
)
def run_name(spec: TrainSpec, *, kind: str, scenario: str) -> str:
return (
f"{kind}/{spec.algorithm.name}/{spec.runtime.backend}/"
f"{spec.runtime.device}/{scenario}/s{spec.runtime.seed}"
)
def run_metadata(
spec: TrainSpec,
*,
kind: str,
scenario: str,
group: str | None = None,
tags: Sequence[str] = (),
) -> dict[str, Any]:
metadata: dict[str, Any] = {
"run.kind": str(kind),
"run.algo": spec.algorithm.name,
"run.backend": spec.runtime.backend,
"run.device": spec.runtime.device,
"run.scenario": str(scenario),
"run.seed": spec.runtime.seed,
"run.tags": list(tags),
}
if group:
metadata["run.group"] = group
return metadata

View File

@@ -1,7 +1,6 @@
"""shared factor definitions for experimental designs"""
import numpy as np
from dataclasses import dataclass, field
from typing import Callable, Any
from dataclasses import dataclass
@dataclass
class Factor:

View File

@@ -1,5 +1,7 @@
"""full factorial design - all factor combinations"""
import sys
sys.path.insert(0, "..")
import logging
from itertools import product
@@ -12,6 +14,7 @@ from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
def generate_configs():
"""generate all factor combinations with seeds"""
all_levels = [f.levels for f in FACTORS]
@@ -22,10 +25,13 @@ def generate_configs():
base = {names[i]: combo[i] for i in range(len(names))}
for seed in range(SEEDS_PER_CONFIG):
cfg = {**base, "seed": seed}
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
cfg["id"] = hashlib.md5(
json.dumps(cfg, sort_keys=True).encode()
).hexdigest()[:8]
configs.append(cfg)
return configs
def run_single(cfg: dict) -> dict:
"""execute one experiment config, return metrics"""
from engine.wrapper import PHANTOM
@@ -49,33 +55,40 @@ def run_single(cfg: dict) -> dict:
obs, reward, term, trunc, _ = env.step(action)
total_reward += reward
steps += 1
if term: break
if term:
break
env.close()
return {
"id": cfg["id"],
"config": cfg,
"total_reward": total_reward,
"avg_reward": total_reward / steps,
"avg_reward": total_reward / steps if steps > 0 else 0.0,
"steps": steps,
}
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
configs = generate_configs()
log.info(f"full factorial: {len(configs)} configs ({len(configs)//SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)")
log.info(
f"full factorial: {len(configs)} configs ({len(configs) // SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)"
)
results = []
with ProcessPoolExecutor(max_workers=max_workers) as ex:
for i, result in enumerate(ex.map(run_single, configs)):
results.append(result)
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
if (i + 1) % 100 == 0:
log.info(f"progress: {i + 1}/{len(configs)}")
Path(output).write_text("\n".join(json.dumps(r) for r in results))
log.info(f"wrote {len(results)} results to {output}")
return results
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--workers", type=int, default=None)
p.add_argument("--output", default="results_full.jsonl")
@@ -83,7 +96,9 @@ if __name__ == "__main__":
args = p.parse_args()
configs = generate_configs()
log.info(f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}")
log.info(
f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}"
)
if not args.dry_run:
run_study(args.workers, args.output)

View File

@@ -0,0 +1,136 @@
import sys
import numpy as np
import pandas as pd
from pathlib import Path
import matplotlib.pyplot as plt
from gymnasium.wrappers import FlattenObservation
from stable_baselines3 import PPO
# Add parent directory to path to allow importing engine
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
from engine.wrapper import PHANTOM
from engine.lib.wrappers import EconomicMetricsWrapper
from engine.lib.providers import (
ProviderBenchmark,
BenchmarkConfig,
RandomBaseline,
SurgeBaseline,
)
def env_factory(alpha: float):
"""Creates a wrapped PHANTOM environment for testing at a specific alpha level."""
# Action levels=9 matches the trained PPO model
# n_products=8 matches the pretrained model's expectation of Box(16,)
env = PHANTOM(
n_products=8,
alpha=alpha,
N=100,
action_levels=9,
action_scale_low=0.8,
action_scale_high=1.2,
max_steps=20, # Short episodes so simulation goes fast
robust_points=1, # disable expensive adversarial lookaheads
render_mode=None,
)
env = EconomicMetricsWrapper(env)
return FlattenObservation(env)
def main():
print("Loading pre-trained Robust RL model...")
model_path = Path(__file__).parent.parent / "models" / "phantom_ppo.zip"
if not model_path.exists():
print(f"Error: Model not found at {model_path}")
print("Please ensure you have a trained model before running this script.")
return
rl_model = PPO.load(model_path)
# The action space is Discrete(9). Index 4 is the middle (1.0 scale).
n_actions = 9
mid_action = n_actions // 2
providers = {
"Static (Base)": lambda obs: mid_action,
"Random": RandomBaseline(n_actions),
"Heuristic Surge": SurgeBaseline(
n_actions, high_threshold=60.0, low_threshold=30.0
),
"Robust RL (PPO)": lambda obs: rl_model.predict(obs, deterministic=True)[0],
}
config = BenchmarkConfig(
n_episodes=10, # Lower episodes to run faster
alpha_range=[0.0, 0.5, 1.0], # Fewer alpha levels
baseline_name="Static (Base)",
)
print(f"\nStarting benchmark across alpha levels: {config.alpha_range}")
print(
f"Testing {len(providers)} strategies for {config.n_episodes} episodes each...\n"
)
benchmark = ProviderBenchmark(env_factory, providers, config)
results = benchmark.run()
# 1. Print tabular results
df = benchmark.to_dataframe()
summary = benchmark.summary_table()
print("\n--- Benchmark Summary Table ---")
print(summary)
# 2. Save results to CSV for thesis inclusion
out_dir = Path(__file__).parent / "results"
out_dir.mkdir(exist_ok=True)
csv_path = out_dir / "provider_comparison.csv"
df.to_csv(csv_path, index=False)
print(f"\nSaved raw results to {csv_path}")
# 3. Plot the degradation of COI / Revenue as alpha increases
plt.figure(figsize=(12, 5))
# Plot 1: Revenue vs Alpha
plt.subplot(1, 2, 1)
for name in providers.keys():
provider_data = df[df["name"] == name]
plt.plot(
provider_data["alpha"],
provider_data["mean_revenue"],
marker="o",
label=name,
linewidth=2,
)
plt.title("Revenue under Agent Contamination")
plt.xlabel("Contamination Level (α)")
plt.ylabel("Mean Episode Revenue ($)")
plt.grid(True, linestyle="--", alpha=0.7)
plt.legend()
# Plot 2: COI Preservation vs Alpha
plt.subplot(1, 2, 2)
for name in providers.keys():
provider_data = df[df["name"] == name]
plt.plot(
provider_data["alpha"],
provider_data["coi_preserved_pct"],
marker="s",
label=name,
linewidth=2,
)
plt.title("Cost of Information (COI) Preservation")
plt.xlabel("Contamination Level (α)")
plt.ylabel("COI Preserved (%)")
plt.grid(True, linestyle="--", alpha=0.7)
plt.legend()
plt.tight_layout()
plot_path = out_dir / "alpha_degradation_plot.png"
plt.savefig(plot_path, dpi=300)
print(f"Saved visualization to {plot_path}")
if __name__ == "__main__":
main()

View File

@@ -1,5 +1,7 @@
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
import sys
sys.path.insert(0, "..")
import logging
from itertools import product
@@ -16,6 +18,7 @@ log = logging.getLogger(__name__)
LH_SAMPLES = 10
def generate_configs(lh_samples: int = LH_SAMPLES):
primary = [f for f in FACTORS if f.primary]
secondary = [f for f in FACTORS if not f.primary]
@@ -28,7 +31,9 @@ def generate_configs(lh_samples: int = LH_SAMPLES):
samples = lhs.random(n=lh_samples)
for s in samples:
sec_vals = {
secondary[i].name: secondary[i].levels[int(s[i] * len(secondary[i].levels))]
secondary[i].name: secondary[i].levels[
int(s[i] * len(secondary[i].levels))
]
for i in range(len(secondary))
}
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
@@ -36,10 +41,13 @@ def generate_configs(lh_samples: int = LH_SAMPLES):
for seed in range(SEEDS_PER_CONFIG):
cfg = {**base, "seed": seed}
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
cfg["id"] = hashlib.md5(
json.dumps(cfg, sort_keys=True).encode()
).hexdigest()[:8]
configs.append(cfg)
return configs
def run_single(cfg: dict) -> dict:
from engine.wrapper import PHANTOM
import numpy as np
@@ -62,7 +70,8 @@ def run_single(cfg: dict) -> dict:
obs, reward, term, trunc, _ = env.step(action)
total_reward += reward
steps += 1
if term: break
if term:
break
env.close()
return {
@@ -73,23 +82,33 @@ def run_single(cfg: dict) -> dict:
"steps": steps,
}
def run_study(max_workers: int = None, output: str = "results_mixed.jsonl", lh_samples: int = LH_SAMPLES):
def run_study(
max_workers: int = None,
output: str = "results_mixed.jsonl",
lh_samples: int = LH_SAMPLES,
):
configs = generate_configs(lh_samples)
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
log.info(f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)")
log.info(
f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)"
)
results = []
with ProcessPoolExecutor(max_workers=max_workers) as ex:
for i, result in enumerate(ex.map(run_single, configs)):
results.append(result)
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
if (i + 1) % 100 == 0:
log.info(f"progress: {i + 1}/{len(configs)}")
Path(output).write_text("\n".join(json.dumps(r) for r in results))
log.info(f"wrote {len(results)} results to {output}")
return results
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--workers", type=int, default=None)
p.add_argument("--output", default="results_mixed.jsonl")
@@ -100,7 +119,9 @@ if __name__ == "__main__":
primary = [f for f in FACTORS if f.primary]
secondary = [f for f in FACTORS if not f.primary]
configs = generate_configs(args.lh_samples)
log.info(f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}")
log.info(
f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}"
)
if not args.dry_run:
run_study(args.workers, args.output, args.lh_samples)

View File

@@ -0,0 +1,84 @@
method: random
metric:
name: objective/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
total_timesteps:
values: [30000, 50000, 80000]
seed:
values: [13, 42, 77]
n_products:
values: [8, 10, 12]
alpha:
distribution: uniform
min: 0.1
max: 0.6
lambda_coi:
distribution: uniform
min: 0.05
max: 0.6
robust_radius:
distribution: uniform
min: 0.0
max: 0.3
robust_points:
values: [3, 5, 7]
info_value:
distribution: uniform
min: 0.5
max: 2.0
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 1.0e-5
max: 1.0e-3
gamma:
values: [0.97, 0.99, 0.995]
buffer_size:
values: [20000, 50000, 100000]
batch_size:
values: [128, 256, 512]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4, 8]
learning_starts:
values: [500, 1000, 3000]
n_steps:
values: [512, 1024, 2048]
n_epochs:
values: [5, 10, 20]
gae_lambda:
values: [0.9, 0.95, 0.98]
clip_range:
values: [0.1, 0.2, 0.3]
ent_coef:
values: [0.0, 0.005, 0.01]
target_update_interval:
values: [500, 1000, 2000]
exploration_fraction:
values: [0.1, 0.2, 0.3]
exploration_final_eps:
values: [0.01, 0.03, 0.05]
action_levels:
values: [7, 9, 11]
action_scale_low:
values: [0.75, 0.8, 0.85]
action_scale_high:
values: [1.15, 1.2, 1.25]
q_lr:
values: [0.03, 0.05, 0.1, 0.2]
eps_start:
value: 1.0
eps_end:
values: [0.02, 0.05, 0.1]
eps_decay:
values: [0.999, 0.9995, 0.9999]

View File

@@ -0,0 +1,85 @@
method: grid
metric:
name: objective/score
goal: maximize
run_cap: 4
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
seed:
value: 42
total_timesteps:
value: 12000
eval_episodes:
value: 3
eval_freq:
value: 500
log_freq:
value: 100
revenue_weight:
value: 0.01
n_products:
value: 8
N:
value: 80
alpha:
value: 0.3
lambda_coi:
value: 0.2
robust_radius:
value: 0.0
robust_points:
value: 1
info_value:
value: 1.0
learning_rate:
value: 0.0003
gamma:
value: 0.99
buffer_size:
value: 20000
batch_size:
value: 128
tau:
value: 0.005
train_freq:
value: 1
learning_starts:
value: 500
n_steps:
value: 512
n_epochs:
value: 10
gae_lambda:
value: 0.95
clip_range:
value: 0.2
ent_coef:
value: 0.0
target_update_interval:
value: 500
exploration_fraction:
value: 0.2
exploration_final_eps:
value: 0.05
action_levels:
value: 7
action_scale_low:
value: 0.9
action_scale_high:
value: 1.1
q_lr:
value: 0.1
q_bins:
value: 6
eps_start:
value: 1.0
eps_end:
value: 0.05
eps_decay:
value: 0.9995

View File

@@ -0,0 +1,54 @@
method: bayes
metric:
name: objective/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
value: sac
total_timesteps:
values: [50000, 80000, 120000]
seed:
values: [13, 42, 77]
alpha:
distribution: uniform
min: 0.15
max: 0.55
n_products:
values: [8, 10, 12]
lambda_coi:
distribution: uniform
min: 0.05
max: 0.5
robust_radius:
distribution: uniform
min: 0.05
max: 0.3
robust_points:
values: [3, 5, 7]
info_value:
distribution: uniform
min: 0.5
max: 2.0
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 3.0e-5
max: 1.0e-3
gamma:
values: [0.98, 0.99, 0.995]
buffer_size:
values: [50000, 100000, 200000]
batch_size:
values: [128, 256, 512]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4, 8]
learning_starts:
values: [1000, 3000, 5000]

View File

@@ -0,0 +1,86 @@
method: random
metric:
name: objective/score
goal: maximize
command:
- ${env}
- python
- -m
- engine.train
parameters:
algo:
values: [ppo, a2c, dqn, qtable]
arch:
values: [tiny, small, medium]
activation:
values: [relu, tanh]
total_timesteps:
values: [8000, 12000, 20000]
seed:
values: [13, 42, 77]
n_products:
values: [6, 8, 10]
alpha:
distribution: uniform
min: 0.1
max: 0.5
lambda_coi:
distribution: uniform
min: 0.05
max: 0.4
robust_radius:
values: [0.0, 0.1, 0.2]
robust_points:
values: [3, 5]
info_value:
values: [0.75, 1.0, 1.5]
revenue_weight:
values: [0.005, 0.01, 0.02]
learning_rate:
distribution: log_uniform_values
min: 1.0e-5
max: 5.0e-4
gamma:
values: [0.98, 0.99]
buffer_size:
values: [10000, 30000, 50000]
batch_size:
values: [64, 128, 256]
tau:
values: [0.002, 0.005, 0.01]
train_freq:
values: [1, 4]
learning_starts:
values: [500, 1000, 2000]
n_steps:
values: [256, 512, 1024]
n_epochs:
values: [5, 10]
gae_lambda:
values: [0.9, 0.95]
clip_range:
values: [0.1, 0.2]
ent_coef:
values: [0.0, 0.005]
target_update_interval:
values: [500, 1000]
exploration_fraction:
values: [0.1, 0.2]
exploration_final_eps:
values: [0.02, 0.05]
action_levels:
values: [5, 7, 9]
action_scale_low:
values: [0.85, 0.9]
action_scale_high:
values: [1.1, 1.15]
q_lr:
values: [0.05, 0.1, 0.2]
q_bins:
values: [4, 6, 8]
eps_start:
value: 1.0
eps_end:
values: [0.02, 0.05]
eps_decay:
values: [0.999, 0.9995]

View File

@@ -0,0 +1,23 @@
from .metrics import canonicalize_metrics
from .wandb import (
current_config,
finish_run,
get_wandb_module,
init_run,
log_metrics,
run_agent,
update_run_config,
update_summary,
)
__all__ = [
"canonicalize_metrics",
"current_config",
"finish_run",
"get_wandb_module",
"init_run",
"log_metrics",
"run_agent",
"update_run_config",
"update_summary",
]

View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any, Mapping
from ..spec import TrainSpec
_ALIASES = {
"train/reward": "train/reward_mean",
"train/revenue": "train/revenue_mean",
"train/dqn_loss": "train/loss",
"eval/reward": "eval/reward_mean",
"eval/revenue": "eval/revenue_mean",
"train/steps_per_second": "runtime/steps_per_second",
}
def _as_float(value: Any, default: float | None = None) -> float | None:
if value is None:
return default
try:
return float(value)
except (TypeError, ValueError):
return default
def canonicalize_metrics(raw: Mapping[str, Any], spec: TrainSpec) -> dict[str, Any]:
metrics: dict[str, Any] = {}
for key, value in raw.items():
canonical = _ALIASES.get(str(key), str(key))
if canonical in metrics and canonical != key:
continue
metrics[canonical] = value
metrics.setdefault("train/global_step", spec.runtime.total_timesteps)
eval_reward = (
_as_float(
metrics.get("eval/robust_reward_worst", metrics.get("eval/reward_mean")),
0.0,
)
or 0.0
)
metrics["objective/score"] = eval_reward
margin_mean = _as_float(metrics.get("eval/margin_mean"), None)
if margin_mean is not None:
metrics["objective/constraint_margin"] = margin_mean - spec.env.margin_floor
coi_level = _as_float(metrics.get("eval/coi_level_mean"), None)
metrics["objective/coi_preserved"] = 0.0 if coi_level is None else coi_level
metrics["study/alpha"] = spec.study.alpha
metrics["study/lambda_coi"] = spec.study.lambda_coi
metrics["study/robust_radius"] = spec.study.robust_radius
metrics["study/info_value"] = spec.study.info_value
metrics["runtime/backend"] = spec.runtime.backend
metrics["runtime/device"] = spec.runtime.device
metrics["runtime/seed"] = spec.runtime.seed
return metrics

98
engine/telemetry/wandb.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Any, Callable, Iterable, Mapping
def get_wandb_module():
try:
import wandb
return wandb
except ImportError:
return None
def _require_wandb():
wandb = get_wandb_module()
if wandb is None:
raise ImportError("wandb is required for this workflow")
return wandb
def init_run(
*,
mode: str,
project: str | None = None,
config: Mapping[str, Any] | None = None,
name: str | None = None,
tags: Iterable[str] | None = None,
group: str | None = None,
sweep_mode: bool = False,
):
wandb = _require_wandb()
kwargs: dict[str, Any] = {"mode": mode}
if group:
kwargs["group"] = group
if sweep_mode:
run = wandb.init(**kwargs)
if name and run is not None:
run.name = name
return run
init_kwargs = dict(kwargs)
init_kwargs["project"] = project
if config is not None:
init_kwargs["config"] = dict(config)
if name:
init_kwargs["name"] = name
if tags:
init_kwargs["tags"] = list(tags)
return wandb.init(**init_kwargs)
def finish_run() -> None:
wandb = get_wandb_module()
if wandb is not None and wandb.run is not None:
wandb.finish()
def current_config() -> dict[str, Any]:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return {}
return {key: wandb.config[key] for key in wandb.config.keys()}
def update_run_config(config: Mapping[str, Any]) -> None:
wandb = get_wandb_module()
if wandb is None or wandb.run is None:
return
try:
wandb.config.update(dict(config), allow_val_change=True)
except TypeError:
wandb.config.update(dict(config))
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)
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
def run_agent(
sweep_id: str,
fn: Callable[[], None],
*,
count: int | None = None,
) -> None:
wandb = _require_wandb()
wandb.agent(sweep_id, function=fn, count=count)

View File

@@ -1,45 +1,230 @@
from stable_baselines3 import SAC
from stable_baselines3.common.callbacks import EvalCallback, BaseCallback
from .wrapper import PHANTOM
from __future__ import annotations
import argparse
from typing import Any
from .logging_utils import configure_logging
from .orchestrators import run_benchmark_cli, run_sweep_agent, run_train_once
from .spec import TrainSpec
class RenderCallback(BaseCallback):
"""Renders environment on every step for live visualization."""
def __init__(self, env: PHANTOM):
super().__init__()
self.env = env
def _on_step(self) -> bool:
self.env.render()
return True
def _parse_tags(raw: str | None) -> list[str]:
if raw is None:
return []
return [piece.strip() for piece in str(raw).split(",") if piece.strip()]
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
eval_env = PHANTOM(n_products=10, alpha=0.3, render_mode=None)
def _probe_run_kind(argv: list[str]) -> str:
probe = argparse.ArgumentParser(add_help=False)
probe.add_argument("--run-kind", choices=["train", "benchmark"])
probe.add_argument("--run-mode", choices=["train", "benchmark"])
args, _ = probe.parse_known_args(argv)
return str(args.run_kind or args.run_mode or "train")
model = SAC(
"MultiInputPolicy",
env,
verbose=1,
learning_rate=3e-4,
buffer_size=50000,
batch_size=256,
tau=0.005,
gamma=0.99,
def _strip_run_kind(argv: list[str]) -> list[str]:
stripped: list[str] = []
skip_next = False
for item in argv:
if skip_next:
skip_next = False
continue
if item in {"--run-kind", "--run-mode"}:
skip_next = True
continue
if item.startswith("--run-kind=") or item.startswith("--run-mode="):
continue
stripped.append(item)
return stripped
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="PHANTOM unified training entrypoint")
parser.add_argument("--run-kind", choices=["train", "benchmark"], default="train")
parser.add_argument("--run-mode", choices=["train", "benchmark"])
parser.add_argument("--project", default="capstone")
parser.add_argument("--scenario", default="default")
parser.add_argument("--group", type=str)
parser.add_argument("--tags", type=str)
parser.add_argument("--backend", choices=["auto", "sb3"], default="auto")
parser.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable", "sac"])
parser.add_argument("--seed", type=int)
parser.add_argument("--total-timesteps", type=int)
parser.add_argument("--model-dir", type=str)
parser.add_argument("--log-freq", type=int)
parser.add_argument("--checkpoint-interval", type=int)
parser.add_argument("--device", type=str)
parser.add_argument("--alpha", type=float)
parser.add_argument("--N", type=int)
parser.add_argument("--n-products", type=int)
parser.add_argument("--lambda-coi", type=float)
parser.add_argument("--info-value", type=float)
parser.add_argument("--robust-radius", type=float)
parser.add_argument("--robust-points", type=int)
parser.add_argument("--robust-rollouts", type=int)
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)
parser.add_argument("--action-levels", type=int)
parser.add_argument("--action-scale-low", type=float)
parser.add_argument("--action-scale-high", type=float)
parser.add_argument("--max-steps", type=int)
parser.add_argument("--margin-floor", type=float)
parser.add_argument("--margin-floor-patience", type=int)
parser.add_argument("--learning-rate", type=float)
parser.add_argument("--gamma", type=float)
parser.add_argument("--buffer-size", type=int)
parser.add_argument("--batch-size", type=int)
parser.add_argument("--tau", type=float)
parser.add_argument("--train-freq", type=int)
parser.add_argument("--learning-starts", type=int)
parser.add_argument("--target-update-interval", type=int)
parser.add_argument("--exploration-fraction", type=float)
parser.add_argument("--exploration-final-eps", type=float)
parser.add_argument("--n-steps", type=int)
parser.add_argument("--n-epochs", type=int)
parser.add_argument("--gae-lambda", type=float)
parser.add_argument("--clip-range", type=float)
parser.add_argument("--ent-coef", type=float)
parser.add_argument("--q-lr", type=float)
parser.add_argument("--q-bins", type=int)
parser.add_argument("--eps-start", type=float)
parser.add_argument("--eps-end", type=float)
parser.add_argument("--eps-decay", type=float)
parser.add_argument("--arch", type=str)
parser.add_argument("--activation", type=str)
parser.add_argument("--vf-coef", type=float)
parser.add_argument("--max-grad-norm", type=float)
parser.add_argument("--eval-freq", type=int)
parser.add_argument("--eval-episodes", type=int)
parser.add_argument("--sweep-agent", action="store_true")
parser.add_argument("--sweep-id", type=str)
parser.add_argument("--count", type=int, default=0)
parser.add_argument("--offline", action="store_true")
parser.add_argument("--no-wandb", action="store_true")
return parser
def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
backend = None if args.backend == "auto" else args.backend
overrides = {
"project": args.project,
"backend": backend,
"algo": args.algo,
"seed": args.seed,
"total_timesteps": args.total_timesteps,
"model_dir": args.model_dir,
"log_freq": args.log_freq,
"checkpoint_interval": args.checkpoint_interval,
"device": args.device,
"alpha": args.alpha,
"N": args.N,
"n_products": args.n_products,
"lambda_coi": args.lambda_coi,
"info_value": args.info_value,
"robust_radius": args.robust_radius,
"robust_points": args.robust_points,
"robust_rollouts": args.robust_rollouts,
"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,
"action_scale_low": args.action_scale_low,
"action_scale_high": args.action_scale_high,
"max_steps": args.max_steps,
"margin_floor": args.margin_floor,
"margin_floor_patience": args.margin_floor_patience,
"learning_rate": args.learning_rate,
"gamma": args.gamma,
"buffer_size": args.buffer_size,
"batch_size": args.batch_size,
"tau": args.tau,
"train_freq": args.train_freq,
"learning_starts": args.learning_starts,
"target_update_interval": args.target_update_interval,
"exploration_fraction": args.exploration_fraction,
"exploration_final_eps": args.exploration_final_eps,
"n_steps": args.n_steps,
"n_epochs": args.n_epochs,
"gae_lambda": args.gae_lambda,
"clip_range": args.clip_range,
"ent_coef": args.ent_coef,
"q_lr": args.q_lr,
"q_bins": args.q_bins,
"eps_start": args.eps_start,
"eps_end": args.eps_end,
"eps_decay": args.eps_decay,
"arch": args.arch,
"activation": args.activation,
"vf_coef": args.vf_coef,
"max_grad_norm": args.max_grad_norm,
"eval_freq": args.eval_freq,
"eval_episodes": args.eval_episodes,
}
return {key: value for key, value in overrides.items() if value is not None}
def main(argv: list[str] | None = None) -> None:
import sys
configure_logging()
raw_args = list(sys.argv[1:] if argv is None else argv)
run_kind = _probe_run_kind(raw_args)
if run_kind == "benchmark":
run_benchmark_cli(_strip_run_kind(raw_args))
return
parser = _build_parser()
args, unknown = parser.parse_known_args(raw_args)
if unknown:
raise ValueError(f"Unknown arguments for training mode: {' '.join(unknown)}")
overrides = _overrides_from_args(args)
scenario = str(args.scenario)
group = args.group
extra_tags = tuple(_parse_tags(args.tags))
if args.sweep_agent:
run_sweep_agent(
project=args.project,
sweep_id=str(args.sweep_id or ""),
count=int(args.count),
offline=bool(args.offline),
no_wandb=bool(args.no_wandb),
base_overrides=overrides,
kind="sweep",
scenario=scenario,
group=group,
extra_tags=extra_tags,
)
return
spec = TrainSpec.from_flat(overrides)
run_train_once(
spec,
project=args.project,
offline=bool(args.offline),
no_wandb=bool(args.no_wandb),
kind="train",
scenario=scenario,
group=group,
extra_tags=extra_tags,
)
render_cb = RenderCallback(env)
eval_cb = EvalCallback(eval_env, eval_freq=1000, n_eval_episodes=5, verbose=1)
model.learn(total_timesteps=50000, callback=[render_cb, eval_cb])
model.save("phantom_sac")
# test trained policy
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
obs, _ = env.reset()
for _ in range(100):
action, _ = model.predict(obs, deterministic=True)
obs, reward, term, trunc, _ = env.step(action)
env.render()
if term or trunc: break
env.close()
if __name__ == "__main__":
main()

40
engine/train_core.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from .spec import TrainSpec
from .telemetry.metrics import canonicalize_metrics
@dataclass(frozen=True)
class TrainResult:
spec: TrainSpec
metrics: dict[str, Any]
artifacts: dict[str, str]
events: list[dict[str, Any]]
def run_train(spec: TrainSpec) -> TrainResult:
cfg = spec.to_flat_dict()
algo = spec.algorithm.name
if algo == "qtable":
from .backends.qtable import train_qtable
_, raw_metrics = train_qtable(cfg)
else:
from .backends.sb3 import train_sb3
_, raw_metrics = train_sb3(cfg)
events_raw = raw_metrics.pop("_train_events", [])
events = [evt for evt in events_raw if isinstance(evt, dict)]
metrics = canonicalize_metrics(raw_metrics, spec)
artifacts: dict[str, str] = {}
model_path = raw_metrics.get("model/path")
if isinstance(model_path, str):
artifacts["model/path"] = model_path
return TrainResult(spec=spec, metrics=metrics, artifacts=artifacts, events=events)

130
engine/wandb_checkpoint.py Normal file
View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import hashlib
import json
import re
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Any, Mapping
try:
import wandb
from wandb.errors import CommError
HAS_WANDB = True
except ImportError:
HAS_WANDB = False
wandb = None # type: ignore[assignment]
CommError = RuntimeError # type: ignore[assignment]
def _safe_value(value: Any) -> Any:
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, (list, tuple)):
return [_safe_value(v) for v in value]
if isinstance(value, dict):
return {str(k): _safe_value(value[k]) for k in sorted(value)}
return str(value)
def _safe_scope(scope: str | None) -> str:
raw = "manual" if scope in (None, "") else str(scope)
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-")
return cleaned or "manual"
def checkpoint_artifact_name(
cfg: Mapping[str, Any], *, backend: str, sweep_id: str | None = None
) -> str:
payload = {k: _safe_value(cfg[k]) for k in sorted(cfg)}
scope = _safe_scope(sweep_id)
canonical = json.dumps(
{"backend": backend, "scope": scope, "cfg": payload},
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:14]
return f"phantom-{backend}-ckpt-{scope}-{digest}"[:128]
def _is_missing_artifact_error(exc: Exception) -> bool:
if isinstance(exc, CommError):
msg = str(exc).lower()
return "not found" in msg or "does not exist" in msg
return False
def download_latest_checkpoint(
artifact_name: str, *, file_name: str
) -> tuple[Path, dict[str, Any]] | None:
if not HAS_WANDB or wandb.run is None:
return None
try:
artifact = wandb.run.use_artifact(f"{artifact_name}:latest")
except Exception as exc:
if _is_missing_artifact_error(exc):
return None
raise
directory = Path(artifact.download())
checkpoint_path = directory / file_name
if not checkpoint_path.exists():
return None
metadata = dict(getattr(artifact, "metadata", {}) or {})
return checkpoint_path, metadata
def _aliases_from_metadata(metadata: dict[str, Any] | None) -> list[str]:
aliases = ["latest"]
if metadata is None:
return aliases
if "step" in metadata:
try:
aliases.append(f"step-{int(metadata['step'])}")
except (TypeError, ValueError):
pass
return aliases
def log_checkpoint_bytes(
artifact_name: str,
*,
file_name: str,
payload: bytes,
metadata: dict[str, Any] | None = None,
) -> bool:
if not HAS_WANDB or wandb.run is None:
return False
with TemporaryDirectory(prefix="phantom-ckpt-") as tmpdir:
path = Path(tmpdir) / file_name
path.write_bytes(payload)
artifact = wandb.Artifact(
name=artifact_name,
type="checkpoint",
metadata=metadata or {},
)
artifact.add_file(path.as_posix(), name=file_name)
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
return True
def log_checkpoint_file(
artifact_name: str,
*,
file_path: str | Path,
artifact_file_name: str,
metadata: dict[str, Any] | None = None,
) -> bool:
if not HAS_WANDB or wandb.run is None:
return False
src = Path(file_path)
if not src.exists():
return False
artifact = wandb.Artifact(
name=artifact_name,
type="checkpoint",
metadata=metadata or {},
)
artifact.add_file(src.as_posix(), name=artifact_file_name)
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
return True

View File

@@ -3,39 +3,120 @@ from gymnasium import spaces
import numpy as np
from .engine import Limbo, MarketEngine, PricingEngine
from .lib.render import DashboardRenderer
from .lib.coi import (
compute_uplift_coi,
extract_purchases,
compute_agent_probability,
)
from .lib.behavior import get_transition_models, trajectory_to_events
from .lib.wrappers import EconomicMetricsWrapper
class _ActionPricingEngine(PricingEngine):
def __init__(self, n_products: int, price_bounds: tuple):
self._prices = np.full(n_products, price_bounds[0], dtype=float)
def set_prices(self, prices: np.ndarray):
self._prices = np.asarray(prices, dtype=float)
def act(self, _):
return self._prices
class PHANTOM(gym.Env):
"""Gymnasium wrapper for the Limbo pricing-market simulation. Platform sets prices, market responds with demand."""
"""Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework
reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL
COI_leak uses behavioral divergence to estimate agent probability f(τ')
robust inner step: min over alpha in Wasserstein interval around nominal alpha
actions are discrete global price-scale moves
"""
metadata = {"render_modes": ["human", "ansi"]}
def __init__(self,
def __init__(
self,
n_products: int = 10,
alpha: float = 0.3,
N: int = 100,
human_params: tuple = (50.0, 10.0),
agent_params: tuple = (45.0, 15.0),
noise_std: float = 1.0,
price_bounds: tuple = (10.0, 150.0),
lambda_coi: float = 0.1,
render_mode: str = None):
coi_window: int = 10,
robust_radius: float = 0.0,
robust_points: int = 5,
robust_rollouts: int = 1,
info_value: float = 1.0,
eta_ux: float = 0.5,
reward_profit_weight: float = 1.0,
action_levels: int = 9,
action_scale_low: float = 0.9,
action_scale_high: float = 1.1,
max_steps: int = 100,
margin_floor: float = 0.05,
margin_floor_patience: int = 5,
render_mode: str = None,
):
super().__init__()
self.n_products = n_products
self.price_bounds = price_bounds
self.lambda_coi = lambda_coi
self.coi_window = coi_window
self.max_steps = max(1, int(max_steps))
self.margin_floor = float(
margin_floor
) # terminate if avg margin stays below this for patience steps
self.margin_floor_patience = max(1, int(margin_floor_patience))
self.render_mode = render_mode
self.alpha = alpha
self.alpha = float(alpha)
self.nominal_alpha = float(alpha)
self.N = N
self.market = MarketEngine(alpha=alpha, N=N)
self._platform_stub = PricingEngine()
self._limbo = Limbo(self._platform_stub, self.market)
self.action_space = spaces.Box(
low=price_bounds[0], high=price_bounds[1],
shape=(n_products,), dtype=np.float32
self.human_params = human_params
self.agent_params = agent_params
self.robust_radius = max(0.0, float(robust_radius))
self.robust_points = max(1, int(robust_points))
self.robust_rollouts = max(1, int(robust_rollouts))
self.info_value = float(info_value)
self.eta_ux = float(eta_ux)
self.reward_profit_weight = float(reward_profit_weight)
self.action_levels = max(2, int(action_levels))
self._action_scales = np.linspace(
float(action_scale_low), float(action_scale_high), self.action_levels
)
self.market = MarketEngine(
alpha=alpha,
N=N,
human_params=human_params,
agent_params=agent_params,
noise_std=noise_std,
)
self._platform_stub = _ActionPricingEngine(n_products, price_bounds)
self._limbo = Limbo(self._platform_stub, self.market)
self._set_market_mix(self.nominal_alpha)
self.action_space = spaces.Discrete(self.action_levels)
self.observation_space = spaces.Dict(
{
"demand": spaces.Box(
low=0.0, high=100.0, shape=(n_products,), dtype=np.float32
),
"prices": spaces.Box(
low=price_bounds[0],
high=price_bounds[1],
shape=(n_products,),
dtype=np.float32,
),
"signals": spaces.Box(
low=np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32),
high=np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32),
shape=(4,),
dtype=np.float32,
),
}
)
self.observation_space = spaces.Dict({
"demand": spaces.Box(low=0.0, high=100.0, shape=(n_products,), dtype=np.float32),
"prices": spaces.Box(low=price_bounds[0], high=price_bounds[1], shape=(n_products,), dtype=np.float32),
})
self._prices = None
self._demand = None
@@ -44,41 +125,221 @@ class PHANTOM(gym.Env):
self._price_history = []
self._revenue_history = []
self._renderer = None
self._initial_episode_prices = None
self._trajectories = [] # session trajectories for agent prob calculation
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
self._low_margin_streak = 0 # consecutive steps below margin_floor
self._last_agent_prob = float(self.alpha)
self._last_alpha_adv = float(self.alpha)
# load behavioral models for agent probability estimation
try:
self._human_trans, self._agent_trans = get_transition_models()
except Exception:
# fallback if behavioral data unavailable
self._human_trans, self._agent_trans = None, None
def _get_obs(self) -> dict:
demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32)
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
demand_arr = np.array(
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
)
signals = np.array(
[
float(np.clip(self._last_agent_prob, 0.0, 1.0)),
float(np.clip(self._last_alpha_adv, 0.0, 1.0)),
float(np.clip(self.nominal_alpha, 0.0, 1.0)),
float(np.clip(self.robust_radius, 0.0, 1.0)),
],
dtype=np.float32,
)
return {
"demand": demand_arr,
"prices": self._prices.astype(np.float32),
"signals": signals,
}
def _compute_reward(self, prices: np.ndarray, demand: dict) -> float:
revenue = np.sum(prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)]))
# TODO: implement supra-competitive price punishment
return float(revenue)
def _set_market_mix(self, alpha: float):
alpha = float(np.clip(alpha, 0.0, 1.0))
n_agents = int(self.N * alpha)
self.alpha = alpha
self.market.alpha = alpha
self.market.Nagents = n_agents
self.market.Nhumans = self.N - n_agents
def _decode_action(self, action) -> np.ndarray:
base = (
self._prices
if self._prices is not None
else np.full(self.n_products, self.price_bounds[0], dtype=float)
)
if np.isscalar(action):
idx = int(np.clip(int(action), 0, self.action_levels - 1))
return np.clip(base * self._action_scales[idx], *self.price_bounds)
a = np.asarray(action)
if a.size == 1:
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
return np.clip(base * self._action_scales[idx], *self.price_bounds)
return np.clip(a.astype(float), *self.price_bounds)
def _compute_agent_prob(self, trajectories=None) -> float:
trajectories = (
self.market.last_trajectories if trajectories is None else trajectories
)
if not trajectories or self._human_trans is None or self._agent_trans is None:
return float(self.market.alpha)
probs = []
for traj in trajectories:
events = trajectory_to_events(traj)
if len(events) < 2:
continue
probs.append(
compute_agent_probability(events, self._human_trans, self._agent_trans)
)
return float(np.mean(probs)) if probs else float(self.market.alpha)
def _compute_reward(
self, prices: np.ndarray, demand: dict, agent_prob: float, trajectories: list
) -> tuple[float, dict]:
demand_arr = np.array(
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
)
revenue = float(np.dot(prices, demand_arr))
floor_cost = float(np.dot(self.baseline_prices, demand_arr))
profit = revenue - floor_cost
purchases = extract_purchases(trajectories)
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
coi_leakage = float(agent_prob * self.info_value)
info_budget = max(floor_cost, 1.0)
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)
)
)
else:
volatility = 0.0
ux_penalty = self.eta_ux * info_budget * volatility
reward_revenue = self.reward_profit_weight * profit
reward = reward_revenue - coi_penalty - ux_penalty
return reward, {
"revenue": revenue,
"cost_floor": floor_cost,
"profit": profit,
"coi_mix": float(coi_mix),
"coi_base": 0.0,
"coi_leakage": coi_leakage,
"coi_penalty": coi_penalty,
"coi_info_budget": info_budget,
"ux_penalty": ux_penalty,
"volatility": volatility,
"reward_revenue": reward_revenue,
"reward_total": reward,
}
def _alpha_candidates(self) -> np.ndarray:
if self.robust_radius <= 0.0 or self.robust_points == 1:
return np.array([self.nominal_alpha], dtype=float)
lo = max(0.0, self.nominal_alpha - self.robust_radius)
hi = min(1.0, self.nominal_alpha + self.robust_radius)
return np.linspace(lo, hi, self.robust_points)
def _evaluate_candidate(self, alpha: float, prices: np.ndarray) -> float:
self._set_market_mix(alpha)
rewards = []
for _ in range(self.robust_rollouts):
demand = self.market.act(prices)
trajectories = list(self.market.last_trajectories)
agent_prob = self._compute_agent_prob(trajectories)
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
rewards.append(float(reward))
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"""
candidates = self._alpha_candidates()
evaluations = [
(float(alpha), self._evaluate_candidate(float(alpha), prices))
for alpha in candidates
]
best_alpha, _ = min(evaluations, key=lambda x: x[1])
return best_alpha
def _record_history(self):
demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
demand_arr = np.array(
[self._demand.get(i, 0.0) for i in range(self.n_products)]
)
self._demand_history.append(demand_arr)
self._price_history.append(self._prices.copy())
self._revenue_history.append(np.sum(self._prices * demand_arr))
def reset(self, seed=None, options=None):
super().reset(seed=seed)
self._set_market_mix(self.nominal_alpha)
self._limbo.reset()
self._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
self._demand = self.market.act(self._prices)
self._platform_stub.set_prices(self._prices)
self._limbo.step()
self._demand = self._limbo.step()
self._initial_episode_prices = self._prices.copy()
self._step_count = 0
self._low_margin_streak = 0
self._demand_history, self._price_history, self._revenue_history = [], [], []
self._trajectories = list(getattr(self.market, "last_trajectories", []))
self._last_agent_prob = float(self.nominal_alpha)
self._last_alpha_adv = float(self.nominal_alpha)
self._record_history()
return self._get_obs(), {}
def step(self, action: np.ndarray):
self._prices = np.clip(action, *self.price_bounds)
self._demand = self.market.act(self._prices)
def step(self, action):
self._prices = self._decode_action(action)
alpha_adv = self._select_adversarial_alpha(self._prices)
self._set_market_mix(alpha_adv)
self._platform_stub.set_prices(self._prices)
self._step_count += 1
self._demand = self.market.act(self._prices)
trajectories = list(self.market.last_trajectories)
agent_prob = self._compute_agent_prob(trajectories)
self._trajectories.extend(trajectories)
self._last_agent_prob = float(agent_prob)
self._last_alpha_adv = float(alpha_adv)
reward, metrics = self._compute_reward(
self._prices, self._demand, agent_prob, trajectories
)
self._record_history()
reward = self._compute_reward(self._prices, self._demand)
terminated = self._step_count >= 100
# soft early termination when margin collapses for too long
avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max(
float(np.mean(self._prices)), 1e-6
)
if avg_margin < self.margin_floor:
self._low_margin_streak += 1
else:
self._low_margin_streak = 0
margin_collapsed = self._low_margin_streak >= self.margin_floor_patience
terminated = self._step_count >= self.max_steps or margin_collapsed
return self._get_obs(), reward, terminated, False, {"step": self._step_count}
info = {
"step": self._step_count,
"agent_prob": agent_prob,
"alpha_adv": float(alpha_adv),
"alpha_nominal": float(self.nominal_alpha),
"wasserstein_radius": float(self.robust_radius),
"robust_rollouts": int(self.robust_rollouts),
**metrics,
"raw_revenue": np.sum(
self._prices
* np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
),
}
return self._get_obs(), reward, terminated, False, info
def _compute_elasticity(self) -> np.ndarray:
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
@@ -87,10 +348,16 @@ class PHANTOM(gym.Env):
p, q = np.array(self._price_history), np.array(self._demand_history)
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
valid = np.abs(dp) > 0.5
with np.errstate(divide='ignore', invalid='ignore'):
elasticity = np.where(valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0)
with np.errstate(divide="ignore", invalid="ignore"):
elasticity = np.where(
valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0
)
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
return np.mean(elasticity, axis=0) if len(elasticity) > 0 else np.zeros(self.n_products)
return (
np.mean(elasticity, axis=0)
if len(elasticity) > 0
else np.zeros(self.n_products)
)
def render(self):
if self.render_mode == "human":
@@ -98,7 +365,9 @@ class PHANTOM(gym.Env):
self._renderer = DashboardRenderer()
self._renderer.render(self)
elif self.render_mode == "ansi":
return f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
return (
f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
)
return None
def close(self):
@@ -108,11 +377,44 @@ class PHANTOM(gym.Env):
if __name__ == "__main__":
env = PHANTOM(n_products=15, alpha=0.3, N=100, render_mode="human")
obs, _ = env.reset()
for step in range(100):
action = env.action_space.sample()
obs, reward, term, trunc, info = env.step(action)
env.render()
if term: break
import wandb
from .lib import MetricsCallback
class RandomPolicy:
"""Minimal SB3-compatible random policy for baseline testing."""
def __init__(self, env):
self.env = env
self.num_timesteps = 0
def learn(self, total_timesteps, callback=None):
callback.model = self
callback.num_timesteps = 0
callback.locals = {}
callback.on_training_start({}, {})
obs, _ = self.env.reset()
for step in range(total_timesteps):
action = self.env.action_space.sample()
obs, reward, term, trunc, info = self.env.step(action)
self.num_timesteps = step + 1
callback.num_timesteps = self.num_timesteps
callback.locals = {"infos": [info]}
callback.on_step()
if term or trunc:
callback.on_rollout_end()
obs, _ = self.env.reset()
return self
def predict(self, obs, **kwargs):
return self.env.action_space.sample(), None
wandb.init(project="capstone", config={"policy": "random", "alpha": 0.3})
env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None))
model = RandomPolicy(env)
model.learn(total_timesteps=1000, callback=MetricsCallback())
print(f"Episode revenue: {env.episode_revenue:.1f}")
wandb.finish()
env.close()

View File

@@ -0,0 +1,269 @@
"""
Session-Aware Pricing DAG
THIS implements the core pricing computation (policy layer).
Flow: τ → θ̂ → D → p*
1. Fetch recent sessions from Kafka (last 10 active)
2. Extract features per session (τ → θ̂)
3. Map features to demand proxy (θ̂ → D)
4. Compute optimal prices (D → p*)
5. Write to Redis session:{sessionId}:prices
Scheduled: every 1 minute when enabled
"""
from airflow import DAG
from airflow.operators.python import PythonOperator
from airflow.utils.dates import days_ago
from datetime import timedelta
import pandas as pd
import numpy as np
import logging
import sys
import pickle
sys.path.insert(0, '/opt/airflow')
from procesing.context import PipelineContext
from procesing.providers import SupabaseProvider, BackendAPIProvider
from procesing.steps.session import ExtractSessionFeaturesStep
from procesing.pricers.simple import SimpleSurgePricer, session_features_to_demand
from procesing.pricing import StateSpace
from lib.model_registry import ModelRegistry
DEFAULT_ARGS = {
'owner': 'phantom-research',
'depends_on_past': False,
'email_on_failure': False,
'email_on_retry': False,
'retries': 1,
'retry_delay': timedelta(seconds=30),
}
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
def __init__(self):
SupabaseProvider.__init__(self)
BackendAPIProvider.__init__(self)
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
def fetch_recent_sessions(**kwargs):
"""
Task: Fetch last N active sessions from Kafka.
Returns: DataFrame of interaction events for recent sessions.
"""
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
store_mode = dag_conf.get('store_mode', 'hotel')
session_limit = dag_conf.get('session_limit', 10)
ctx = _get_context(store_mode)
provider = ctx.provider
# fetch all recent interactions from Kafka
try:
interactions_df = provider.fetch_kafka_topic("user-interactions")
except Exception as e:
logging.error(f"Failed to fetch interactions: {e}")
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
return 0
if interactions_df.empty or 'sessionId' not in interactions_df.columns:
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
return 0
# identify last N active sessions (most recent by event count)
recent_sessions = interactions_df['sessionId'].value_counts().head(session_limit).index.tolist()
# filter to only those sessions
filtered_df = interactions_df[interactions_df['sessionId'].isin(recent_sessions)].copy()
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(filtered_df))
kwargs['ti'].xcom_push(key='session_ids', value=recent_sessions)
logging.info(f"Fetched {len(filtered_df)} events for {len(recent_sessions)} sessions")
return len(recent_sessions)
def extract_session_features(**kwargs):
"""
Task: Extract behavioral features from session trajectories.
THIS implements τ → θ̂ transformation.
"""
ti = kwargs['ti']
sessions_df = pickle.loads(ti.xcom_pull(key='sessions_data'))
if sessions_df.empty:
ti.xcom_push(key='session_features', value=pickle.dumps(pd.DataFrame()))
return 0
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
# extract features using vectorized pipeline
feature_extractor = ExtractSessionFeaturesStep(ctx)
features_df = feature_extractor.transform(sessions_df)
ti.xcom_push(key='session_features', value=pickle.dumps(features_df))
logging.info(f"Extracted {len(features_df.columns)} features for {len(features_df)} sessions")
logging.info(f"Feature columns: {list(features_df.columns)}")
logging.info(f"Sample features (first session):\n{features_df.iloc[0].to_dict()}")
return len(features_df)
def compute_session_prices(**kwargs):
"""
Task: Compute optimal prices for each session.
THIS implements θ̂ → D → p* transformation.
"""
ti = kwargs['ti']
features_df = pickle.loads(ti.xcom_pull(key='session_features'))
if features_df.empty:
ti.xcom_push(key='price_results', value=pickle.dumps({}))
return 0
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
store_mode = dag_conf.get('store_mode', 'hotel')
ctx = _get_context(store_mode)
# fetch product catalog for base prices
products_df = ctx.provider.fetch_products(store_mode)
if products_df.empty:
logging.error("No products found in catalog")
ti.xcom_push(key='price_results', value=pickle.dumps({}))
return 0
products_df['base_price'] = products_df['metadata'].apply(
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
)
# initialize pricing model
pricer = SimpleSurgePricer(
high_threshold=dag_conf.get('high_threshold', 10),
low_threshold=dag_conf.get('low_threshold', 2),
surge_multiplier=dag_conf.get('surge_multiplier', 1.15),
discount_multiplier=dag_conf.get('discount_multiplier', 0.95)
)
pricer.fit(products_df)
# compute prices per session
price_results = {}
n_products = len(products_df)
logging.info(f"Starting price computation for {len(features_df)} sessions, {n_products} products")
logging.info(f"Pricer config: high_thresh={pricer.high_threshold}, low_thresh={pricer.low_threshold}, surge_mult={pricer.surge_multiplier}")
for idx, session_row in features_df.iterrows():
session_id = session_row.get('sessionId')
if not session_id:
continue
# map features to demand proxy (θ̂ → D)
session_features_single = pd.DataFrame([session_row])
demand_proxy = session_features_to_demand(session_features_single)
logging.info(f"[Session {session_id}] Features → Demand: {demand_proxy:.2f}")
logging.info(f"[Session {session_id}] Key features: velocity={session_row.get('interaction_velocity', 0):.2f}, cart_ratio={session_row.get('cart_to_view_ratio', 0):.2f}, item_views={session_row.get('item_views', 0)}")
# build state space
state_space = StateSpace(
demand=np.full(n_products, demand_proxy), # broadcast session demand to all products
prices=products_df['base_price'].values,
session_features=session_features_single
)
# compute optimal prices (D → p*)
optimal_prices = pricer.predict(state_space)
base_avg = products_df['base_price'].mean()
optimal_avg = optimal_prices.mean()
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
logging.info(f"[Session {session_id}] Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
# store as dict {productId: price}
price_map = {
str(products_df.iloc[i]['id']): float(optimal_prices[i])
for i in range(n_products)
}
price_results[session_id] = price_map
ti.xcom_push(key='price_results', value=pickle.dumps(price_results))
logging.info(f"Computed prices for {len(price_results)} sessions, {n_products} products each")
return len(price_results)
def publish_to_registry(**kwargs):
"""
Task: Write session prices to Redis registry.
THIS is the write path: prices → session:{sessionId}:prices
"""
ti = kwargs['ti']
price_results = pickle.loads(ti.xcom_pull(key='price_results'))
if not price_results:
logging.warning("No prices to publish")
return 0
registry = ModelRegistry()
ttl = kwargs.get('dag_run').conf.get('ttl', 1800) if kwargs.get('dag_run') and kwargs.get('dag_run').conf else 1800
published_count = 0
for session_id, price_map in price_results.items():
registry.set_session_prices(session_id, price_map, ttl=ttl)
published_count += 1
logging.info(f"Published prices for {published_count} sessions to registry (TTL={ttl}s)")
return {
'sessions_published': published_count,
'products_per_session': len(next(iter(price_results.values()))) if price_results else 0,
'status': 'success'
}
# DAG definition
with DAG(
'session_pricing_pipeline',
default_args=DEFAULT_ARGS,
description='Session-aware pricing: extract features → compute prices → publish to registry',
schedule_interval='*/1 * * * *', # every 1 minute
start_date=days_ago(1),
catchup=False,
max_active_runs=1,
tags=['pricing', 'session-aware', 'research', 'real-time'],
) as dag:
t_fetch_sessions = PythonOperator(
task_id='fetch_recent_sessions',
python_callable=fetch_recent_sessions,
provide_context=True,
)
t_extract_features = PythonOperator(
task_id='extract_session_features',
python_callable=extract_session_features,
provide_context=True,
)
t_compute_prices = PythonOperator(
task_id='compute_session_prices',
python_callable=compute_session_prices,
provide_context=True,
)
t_publish = PythonOperator(
task_id='publish_to_registry',
python_callable=publish_to_registry,
provide_context=True,
)
# linear dependency: fetch → extract → compute → publish
t_fetch_sessions >> t_extract_features >> t_compute_prices >> t_publish

View File

@@ -0,0 +1 @@
from .encoder import Window, extract_windows, build_windows, WindowDataset, PrototypeClassifier, train, loocv

View File

@@ -0,0 +1,210 @@
"""Contrastive encoder via trajectory windowing. Classification by prototype distance."""
import sys
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
from sim.rl.behavior_loader.loader import JointLoader, PayloadModel
from arch import TrajectoryEncoder, featurize_trajectory, nt_xent_loss
from typing import List, Dict, Tuple
from dataclasses import dataclass
from datetime import datetime
import numpy as np, torch, torch.nn.functional as F, random, optuna
from torch.utils.data import Dataset, DataLoader
from torch.optim import Adam
from torch.utils.tensorboard import SummaryWriter
RUNS = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
AGENT_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
HUMAN_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
@dataclass
class Window:
events: List[PayloadModel]
traj_id: str
label: int # 0=human, 1=agent
def extract_windows(events: List[PayloadModel], traj_id: str, label: int,
sizes: List[int] = [5, 10, 15], stride: int = 2) -> List[Window]:
"""Multi-scale overlapping windows from trajectory"""
n = len(events)
wins = [Window(events[i:i+s], traj_id, label) for s in sizes if n >= s for i in range(0, n-s+1, stride)]
if n >= 3: wins.append(Window(events, traj_id, label)) # full traj
return wins
def build_windows(data: Dict[str, List], sizes=[5,10,15], stride=2) -> List[Window]:
return [w for tid, evts in data.items()
for w in extract_windows(evts, tid, 0 if tid.startswith('human_') else 1, sizes, stride)]
class WindowDataset(Dataset):
"""Yields (anchor, positive) pairs from same class"""
def __init__(self, windows: List[Window], dim: int = 64):
self.wins, self.dim = windows, dim
self.by_label = {0: [i for i,w in enumerate(windows) if w.label==0],
1: [i for i,w in enumerate(windows) if w.label==1]}
self.by_traj = {}
for i, w in enumerate(windows): self.by_traj.setdefault(w.traj_id, []).append(i)
def __len__(self): return len(self.wins)
def _feat(self, evts): return featurize_trajectory(evts, None, self.dim)
def _aug(self, evts): # subsample 70-100%
if len(evts) < 4: return evts
k = max(3, int(len(evts) * random.uniform(0.7, 1.0)))
start = random.randint(0, len(evts) - k)
return evts[start:start+k]
def __getitem__(self, idx):
w = self.wins[idx]
pool = [i for i in self.by_label[w.label] if self.wins[i].traj_id != w.traj_id]
pos_idx = random.choice(pool) if pool else idx
a = torch.tensor(self._feat(self._aug(w.events)), dtype=torch.float32)
p = torch.tensor(self._feat(self._aug(self.wins[pos_idx].events)), dtype=torch.float32)
return a, p, w.label
class PrototypeClassifier:
"""Classify by distance to class centroids"""
def __init__(self, encoder: TrajectoryEncoder, device = 'cuda', dim=64):
self.enc, self.dev, self.dim = encoder, device, dim
self.centroids = {0: None, 1: None}
def fit(self, windows: List[Window]):
self.enc.eval()
embs = {0: [], 1: []}
with torch.no_grad():
for w in windows:
x = torch.tensor(featurize_trajectory(w.events, None, self.dim), dtype=torch.float32)
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
embs[w.label].append(z)
self.centroids = {k: torch.cat(v).mean(0, keepdim=True) if v else None for k, v in embs.items()}
return self
def predict(self, events: List[PayloadModel]) -> Tuple[int, float, Dict]:
"""Returns (pred, confidence, debug). Confidence via softmax over -distances."""
self.enc.eval()
with torch.no_grad():
x = torch.tensor(featurize_trajectory(events, None, self.dim), dtype=torch.float32)
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
dists = {k: torch.norm(z - c, dim=1).item() for k, c in self.centroids.items() if c is not None}
if not dists: return 0, 0.0, {'d': {}, 'p': [0.5, 0.5]}
pred = min(dists, key=dists.get)
d0, d1 = dists.get(0, 1e6), dists.get(1, 1e6) # softmax(-d) gives higher prob to closer centroid
probs = F.softmax(torch.tensor([[-d0, -d1]]), dim=1).squeeze()
return pred, probs[pred].item(), {'d': dists, 'p': probs.tolist()}
def train(epochs=200, lr=5e-4, batch=16, dim=64, emb=32, temp=0.5,
sizes=[5,10,15], stride=2, name=None, verbose=True):
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
wins = build_windows(data, sizes, stride)
if verbose: print(f"Windows: {len(wins)} ({sum(w.label==0 for w in wins)}h/{sum(w.label==1 for w in wins)}a)")
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
enc = TrajectoryEncoder(dim, emb).to(dev)
opt = Adam(enc.parameters(), lr=lr)
loader = DataLoader(WindowDataset(wins, dim), batch_size=batch, shuffle=True, drop_last=True)
name = name or f"enc_{dim}_{emb}_{datetime.now():%Y%m%d_%H%M%S}"
writer = SummaryWriter(f"{RUNS}/encoder/{name}")
for ep in range(epochs):
enc.train()
total, n = 0.0, 0
for a, p, _ in loader:
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
opt.zero_grad(); loss.backward(); opt.step()
total += loss.item(); n += 1
avg = total / max(n, 1)
writer.add_scalar('loss-ntxent', avg, ep)
if verbose and (ep+1) % 20 == 0: print(f"Epoch {ep+1}: {avg:.4f}")
writer.close()
return enc, wins, dev
def loocv(epochs=100, lr=5e-4, dim=64, emb=32, temp=0.5, sizes=[5,10,15], stride=2, verbose=True):
"""Leave-one-trajectory-out CV"""
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
results = []
for test_id in data:
train_data = {k: v for k, v in data.items() if k != test_id}
if not any(k.startswith('human_') for k in train_data) or not any(k.startswith('agent_') for k in train_data):
continue
wins = build_windows(train_data, sizes, stride)
enc = TrajectoryEncoder(dim, emb).to(dev)
opt = Adam(enc.parameters(), lr=lr)
loader = DataLoader(WindowDataset(wins, dim), batch_size=min(16, len(wins)//2 or 1),
shuffle=True, drop_last=len(wins)>2)
for _ in range(epochs):
enc.train()
for a, p, _ in loader:
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
opt.zero_grad(); loss.backward(); opt.step()
clf = PrototypeClassifier(enc, dev, dim).fit(wins)
pred, conf, dbg = clf.predict(data[test_id])
actual = 0 if test_id.startswith('human_') else 1
results.append((pred, actual, conf))
if verbose: print(f"{test_id[:18]}: pred={pred} conf={conf:.2f} actual={actual} {'OK' if pred==actual else 'MISS'}")
if results:
acc = sum(p==a for p,a,_ in results) / len(results)
if verbose: print(f"\nAccuracy: {acc:.1%} ({sum(p==a for p,a,_ in results)}/{len(results)})")
return acc, results
return 0.0, []
def hparam_tune(n_trials=50, epochs=60, n_jobs=2, verbose=True):
"""Optuna hyperparameter search maximizing LOOCV accuracy"""
def objective(trial):
lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True)
dim = trial.suggest_categorical('dim', [32, 64, 128, 256])
emb = trial.suggest_categorical('emb', [16, 32, 64, 128])
temp = trial.suggest_float('temp', 0.05, 1.0)
stride = trial.suggest_int('stride', 1, 4)
sizes = [trial.suggest_int(f's{i}', 3, 20) for i in range(3)]
sizes = sorted(set(sizes)) # unique sorted
acc, _ = loocv(epochs, lr, dim, emb, temp, sizes, stride, verbose=False)
return acc
study = optuna.create_study(direction='maximize', study_name='encoder_hparam',
sampler=optuna.samplers.TPESampler(seed=42))
study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=verbose)
best = study.best_params
if verbose:
print(f"\nBest accuracy: {study.best_value:.1%}")
print(f"Best params: {best}")
return best, study
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument('--mode', choices=['train', 'eval', 'hparam'], default='train')
p.add_argument('--epochs', type=int, default=200)
p.add_argument('--lr', type=float, default=5e-4)
p.add_argument('--dim', type=int, default=128)
p.add_argument('--emb', type=int, default=64)
p.add_argument('--temp', type=float, default=0.1)
p.add_argument('--sizes', type=str, default='5,10,15')
p.add_argument('--stride', type=int, default=2)
p.add_argument('--n_trials', type=int, default=50)
args = p.parse_args()
sizes = [int(x) for x in args.sizes.split(',')]
if args.mode == 'train':
enc, wins, dev = train(args.epochs, args.lr, 16, args.dim, args.emb, args.temp, sizes, args.stride)
elif args.mode == 'hparam':
best, study = hparam_tune(args.n_trials, min(args.epochs, 60))
else:
loocv(args.epochs, args.lr, args.dim, args.emb, args.temp, sizes, args.stride)

View File

@@ -0,0 +1,957 @@
{
"cells": [
{
"cell_type": "code",
"execution_count": 10,
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"True"
]
},
"execution_count": 10,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"from kafka import KafkaConsumer\n",
"import pandas as pd\n",
"import json\n",
"import numpy as np\n",
"import os\n",
"from dotenv import load_dotenv\n",
"import matplotlib.pyplot as plt\n",
"from IPython.display import display, SVG, Image\n",
"load_dotenv()"
]
},
{
"cell_type": "code",
"execution_count": 11,
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"<class 'pandas.core.frame.DataFrame'>\n",
"RangeIndex: 73 entries, 0 to 72\n",
"Data columns (total 13 columns):\n",
" # Column Non-Null Count Dtype \n",
"--- ------ -------------- ----- \n",
" 0 sessionId 73 non-null object \n",
" 1 eventName 73 non-null object \n",
" 2 page 73 non-null object \n",
" 3 productId 67 non-null object \n",
" 4 storeMode 73 non-null object \n",
" 5 userAgent 73 non-null object \n",
" 6 ts 73 non-null object \n",
" 7 metadata_referrer 6 non-null object \n",
" 8 metadata_roomType 45 non-null object \n",
" 9 metadata_price 45 non-null float64\n",
" 10 metadata_nights 45 non-null float64\n",
" 11 metadata_elementText 22 non-null object \n",
" 12 metadata_dwellTime 22 non-null float64\n",
"dtypes: float64(3), object(10)\n",
"memory usage: 7.5+ KB\n"
]
}
],
"source": [
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
"topic = \"user-interactions\"\n",
"consumer = KafkaConsumer(\n",
" topic, \n",
" enable_auto_commit=True,\n",
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
" auto_offset_reset='earliest', \n",
" bootstrap_servers=['localhost:9092'])\n",
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
"df = []\n",
"for m in messages.values():\n",
" for i in m:\n",
" df.append(i.value)\n",
"df = pd.DataFrame(df)\n",
"# explode metadata col json\n",
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
"df.info()"
]
},
{
"cell_type": "code",
"execution_count": 12,
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
"metadata": {},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>sessionId</th>\n",
" <th>eventName</th>\n",
" <th>page</th>\n",
" <th>productId</th>\n",
" <th>storeMode</th>\n",
" <th>userAgent</th>\n",
" <th>ts</th>\n",
" <th>metadata_referrer</th>\n",
" <th>metadata_roomType</th>\n",
" <th>metadata_price</th>\n",
" <th>metadata_nights</th>\n",
" <th>metadata_elementText</th>\n",
" <th>metadata_dwellTime</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>0</th>\n",
" <td>d176d7c9-4027-4702-9e31-2a71395cdda0</td>\n",
" <td>page_view</td>\n",
" <td>/products</td>\n",
" <td>None</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:23:46.270Z</td>\n",
" <td></td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>1</th>\n",
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
" <td>page_view</td>\n",
" <td>/</td>\n",
" <td>None</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
" <td>2025-11-14T13:26:00.291Z</td>\n",
" <td></td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>2</th>\n",
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
" <td>page_view</td>\n",
" <td>/products</td>\n",
" <td>None</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
" <td>2025-11-14T13:26:07.769Z</td>\n",
" <td></td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>3</th>\n",
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
" <td>2025-11-14T13:26:15.010Z</td>\n",
" <td>NaN</td>\n",
" <td>Premium Room</td>\n",
" <td>269.0</td>\n",
" <td>1.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>4</th>\n",
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
" <td>page_view</td>\n",
" <td>/products</td>\n",
" <td>None</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
" <td>2025-11-14T13:27:15.457Z</td>\n",
" <td></td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>5</th>\n",
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
" <td>2025-11-14T13:27:15.591Z</td>\n",
" <td>NaN</td>\n",
" <td>Premium Room</td>\n",
" <td>264.0</td>\n",
" <td>2.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>432</th>\n",
" <td>214d9fad-9b00-40c3-bd0e-7739b6acd654</td>\n",
" <td>click</td>\n",
" <td>1762448192425</td>\n",
" <td>DIV</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>/</td>\n",
" <td>NaN</td>\n",
" <td>1623.0</td>\n",
" <td>493.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>6</th>\n",
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
" <td>2025-11-14T13:27:21.483Z</td>\n",
" <td>NaN</td>\n",
" <td>Premium Room</td>\n",
" <td>264.0</td>\n",
" <td>2.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>7</th>\n",
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
" <td>hover_over_title</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
" <td>2025-11-14T13:27:22.646Z</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>Grand Plaza Hotel</td>\n",
" <td>1200.0</td>\n",
" </tr>\n",
" <tr>\n",
" <th>8</th>\n",
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
" <td>2025-11-14T13:27:25.889Z</td>\n",
" <td>NaN</td>\n",
" <td>Premium Room</td>\n",
" <td>264.0</td>\n",
" <td>2.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>35</th>\n",
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
" <td>page_view</td>\n",
" <td>/products</td>\n",
" <td>None</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:53:59.993Z</td>\n",
" <td></td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>36</th>\n",
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:54:10.705Z</td>\n",
" <td>NaN</td>\n",
" <td>Premium Room</td>\n",
" <td>223.0</td>\n",
" <td>3.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>37</th>\n",
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
" <td>hover_over_title</td>\n",
" <td>/products</td>\n",
" <td>htl-0</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:54:11.771Z</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>416.0</td>\n",
" <td>397.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>Grand Plaza Hotel</td>\n",
" <td>1200.0</td>\n",
" </tr>\n",
" <tr>\n",
" <th>38</th>\n",
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
" <td>view_item_page</td>\n",
" <td>/products</td>\n",
" <td>htl-1</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:54:29.772Z</td>\n",
" <td>NaN</td>\n",
" <td>Standard Room</td>\n",
" <td>267.0</td>\n",
" <td>5.0</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" </tr>\n",
" <tr>\n",
" <th>39</th>\n",
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
" <td>hover_over_title</td>\n",
" <td>/products</td>\n",
" <td>htl-1</td>\n",
" <td>hotel</td>\n",
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
" <td>2025-11-14T13:54:30.833Z</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>NaN</td>\n",
" <td>Seaside Resort</td>\n",
" <td>1200.0</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" sessionId eventName page \\\n",
"0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n",
"1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n",
"2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n",
"3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n",
"4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n",
"5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
"6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
"7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n",
"8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
"35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n",
"36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
"37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
"38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
"39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
"\n",
" productId storeMode userAgent \\\n",
"0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
"2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
"3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
"4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
"5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
"6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
"7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
"8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
"35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
"\n",
" ts metadata_referrer metadata_roomType \\\n",
"0 2025-11-14T13:23:46.270Z NaN \n",
"1 2025-11-14T13:26:00.291Z NaN \n",
"2 2025-11-14T13:26:07.769Z NaN \n",
"3 2025-11-14T13:26:15.010Z NaN Premium Room \n",
"4 2025-11-14T13:27:15.457Z NaN \n",
"5 2025-11-14T13:27:15.591Z NaN Premium Room \n",
"6 2025-11-14T13:27:21.483Z NaN Premium Room \n",
"7 2025-11-14T13:27:22.646Z NaN NaN \n",
"8 2025-11-14T13:27:25.889Z NaN Premium Room \n",
"35 2025-11-14T13:53:59.993Z NaN \n",
"36 2025-11-14T13:54:10.705Z NaN Premium Room \n",
"37 2025-11-14T13:54:11.771Z NaN NaN \n",
"38 2025-11-14T13:54:29.772Z NaN Standard Room \n",
"39 2025-11-14T13:54:30.833Z NaN NaN \n",
"\n",
" metadata_price metadata_nights metadata_elementText metadata_dwellTime \n",
"0 NaN NaN NaN NaN \n",
"1 NaN NaN NaN NaN \n",
"2 NaN NaN NaN NaN \n",
"3 269.0 1.0 NaN NaN \n",
"4 NaN NaN NaN NaN \n",
"5 264.0 2.0 NaN NaN \n",
"6 264.0 2.0 NaN NaN \n",
"7 NaN NaN Grand Plaza Hotel 1200.0 \n",
"8 264.0 2.0 NaN NaN \n",
"35 NaN NaN NaN NaN \n",
"36 223.0 3.0 NaN NaN \n",
"37 NaN NaN Grand Plaza Hotel 1200.0 \n",
"38 267.0 5.0 NaN NaN \n",
"39 NaN NaN Seaside Resort 1200.0 "
]
},
"execution_count": 12,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"df.groupby('sessionId').head()"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
"metadata": {},
"outputs": [
{
"data": {
"text/plain": [
"['013fc334-4045-4d5a-8739-dd0a8766a63b',\n",
" '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n",
" 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n",
" 'f0317a5d-e424-44e9-b784-c8f7291ffe31']"
]
},
"execution_count": 13,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66"
]
},
{
"cell_type": "code",
"execution_count": 14,
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
"metadata": {},
"outputs": [],
"source": [
"# map sessions to experiments"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
"metadata": {},
"outputs": [],
"source": [
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
" df = df.dropna(subset=['eventName'])\n",
" events = df['eventName'].tolist()\n",
" labels = pd.Index(events).unique().tolist()\n",
" idx = {e:i for i,e in enumerate(labels)}\n",
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
" for a, b in zip(events, events[1:]):\n",
" M[idx[a], idx[b]] += 1\n",
" row_sums = M.sum(axis=1, keepdims=True)\n",
" with np.errstate(divide='ignore', invalid='ignore'):\n",
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
" return P, labels"
]
},
{
"cell_type": "code",
"execution_count": 16,
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
"metadata": {},
"outputs": [],
"source": [
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
"from graphviz import Digraph\n",
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"def _as_prob_df(matrix, labels=None):\n",
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
" if isinstance(matrix, pd.DataFrame):\n",
" # Ensure square and aligned\n",
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
" return matrix\n",
" matrix = np.asarray(matrix, dtype=float)\n",
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
" if labels is None:\n",
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
"\n",
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
" edges = []\n",
" for src in P.index:\n",
" for dst in P.columns:\n",
" w = float(P.loc[src, dst])\n",
" if w > threshold:\n",
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
" return edges\n",
"\n",
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
" \"\"\"\n",
" fname: output file stem (no extension)\n",
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
" threshold: hide edges with weight <= threshold\n",
" fmt: 'svg'|'png'|'pdf' etc.\n",
" view: open after rendering\n",
" \"\"\"\n",
" P = _as_prob_df(matrix, labels=ls_index)\n",
" edges = _df_to_edgelist(P, threshold=threshold)\n",
"\n",
" g = Digraph(format=fmt)\n",
" g.attr(rankdir=\"LR\", size=\"30\")\n",
" g.attr(\"node\", shape=\"circle\")\n",
"\n",
" # ensure isolated nodes appear\n",
" for node in P.index:\n",
" g.node(str(node), width=\"1\", height=\"1\")\n",
"\n",
" for src, dst, label in edges:\n",
" g.edge(src, dst, label=label)\n",
"\n",
" g.render(fname, view=view, cleanup=True)\n",
" return g\n"
]
},
{
"cell_type": "code",
"execution_count": 17,
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"013fc334-4045-4d5a-8739-dd0a8766a63b\n"
]
},
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 13.1.2 (0)\n",
" -->\n",
"<!-- Pages: 1 -->\n",
"<svg width=\"565pt\" height=\"354pt\"\n",
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
"<!-- page_view -->\n",
"<g id=\"node1\" class=\"node\">\n",
"<title>page_view</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-235.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
"</g>\n",
"<!-- view_item_page -->\n",
"<g id=\"node2\" class=\"node\">\n",
"<title>view_item_page</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-235.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
"</g>\n",
"<!-- page_view&#45;&gt;view_item_page -->\n",
"<g id=\"edge1\" class=\"edge\">\n",
"<title>page_view&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-235.83C113.69,-235.83 133.31,-235.83 152.25,-235.83\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-239.33 162.1,-235.83 152.1,-232.33 152.1,-239.33\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-239.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;view_item_page -->\n",
"<g id=\"edge2\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-302.59C217.1,-314.51 223.14,-322.84 232.88,-322.84 239.27,-322.84 244.07,-319.26 247.28,-313.42\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-314.62 250.52,-304.02 243.95,-312.33 250.57,-314.62\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-326.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.68</text>\n",
"</g>\n",
"<!-- hover_over_title -->\n",
"<g id=\"node3\" class=\"node\">\n",
"<title>hover_over_title</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;hover_over_title -->\n",
"<g id=\"edge3\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;hover_over_title</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M300.48,-250.14C307.03,-251.43 313.58,-252.69 319.89,-253.83 340.12,-257.51 362.05,-261.1 382.5,-264.27\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"381.77,-267.7 392.19,-265.76 382.83,-260.78 381.77,-267.7\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-263.17\" font-family=\"Times,serif\" font-size=\"14.00\">0.29</text>\n",
"</g>\n",
"<!-- hover_over_paragraph -->\n",
"<g id=\"node4\" class=\"node\">\n",
"<title>hover_over_paragraph</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;hover_over_paragraph -->\n",
"<g id=\"edge4\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;hover_over_paragraph</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M292.09,-199.63C316.79,-184.27 346.14,-166.02 373.44,-149.04\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"375.08,-152.15 381.72,-143.89 371.38,-146.2 375.08,-152.15\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-185.68\" font-family=\"Times,serif\" font-size=\"14.00\">0.04</text>\n",
"</g>\n",
"<!-- hover_over_title&#45;&gt;view_item_page -->\n",
"<g id=\"edge5\" class=\"edge\">\n",
"<title>hover_over_title&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M399.53,-246.73C384.12,-240.88 367.42,-235.6 351.39,-232.58 339.13,-230.28 326.03,-229.26 313.19,-229.04\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"313.51,-225.54 303.51,-229.04 313.51,-232.54 313.51,-225.54\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-236.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.graphs.Digraph at 0x7f0779e818b0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"[]\n"
]
},
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 13.1.2 (0)\n",
" -->\n",
"<!-- Pages: 1 -->\n",
"<svg width=\"8pt\" height=\"8pt\"\n",
" viewBox=\"0.00 0.00 8.00 8.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 4)\">\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-4 4,-4 4,4 -4,4\"/>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.graphs.Digraph at 0x7f6800fac980>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
" [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n",
" [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
" [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n",
"238dc588-a7ab-4c0e-bccd-6abca5076c66\n"
]
},
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 13.1.2 (0)\n",
" -->\n",
"<!-- Pages: 1 -->\n",
"<svg width=\"565pt\" height=\"354pt\"\n",
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
"<!-- page_view -->\n",
"<g id=\"node1\" class=\"node\">\n",
"<title>page_view</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-109.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-105.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
"</g>\n",
"<!-- view_item_page -->\n",
"<g id=\"node2\" class=\"node\">\n",
"<title>view_item_page</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-197.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-193.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
"</g>\n",
"<!-- page_view&#45;&gt;view_item_page -->\n",
"<g id=\"edge1\" class=\"edge\">\n",
"<title>page_view&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M92.02,-130.47C112.32,-140.25 137.13,-152.2 160.18,-163.3\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"158.39,-166.32 168.92,-167.51 161.43,-160.02 158.39,-166.32\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-157.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;view_item_page -->\n",
"<g id=\"edge2\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-264.59C217.1,-276.51 223.14,-284.84 232.88,-284.84 239.27,-284.84 244.07,-281.26 247.28,-275.42\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-276.62 250.52,-266.02 243.95,-274.33 250.57,-276.62\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-288.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.19</text>\n",
"</g>\n",
"<!-- hover_over_title -->\n",
"<g id=\"node3\" class=\"node\">\n",
"<title>hover_over_title</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;hover_over_title -->\n",
"<g id=\"edge3\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;hover_over_title</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M289.6,-237.16C299.36,-242.77 309.67,-247.94 319.89,-251.83 339.45,-259.28 361.4,-264.43 382.1,-267.98\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"381.52,-271.43 391.95,-269.55 382.62,-264.52 381.52,-271.43\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-265.16\" font-family=\"Times,serif\" font-size=\"14.00\">0.38</text>\n",
"</g>\n",
"<!-- hover_over_paragraph -->\n",
"<g id=\"node4\" class=\"node\">\n",
"<title>hover_over_paragraph</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
"</g>\n",
"<!-- view_item_page&#45;&gt;hover_over_paragraph -->\n",
"<g id=\"edge4\" class=\"edge\">\n",
"<title>view_item_page&#45;&gt;hover_over_paragraph</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M300.22,-180.71C317.22,-175.46 335.24,-169.12 351.39,-161.83 358.97,-158.41 366.67,-154.57 374.29,-150.49\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"375.84,-153.63 382.92,-145.75 372.47,-147.5 375.84,-153.63\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-178.15\" font-family=\"Times,serif\" font-size=\"14.00\">0.44</text>\n",
"</g>\n",
"<!-- hover_over_title&#45;&gt;view_item_page -->\n",
"<g id=\"edge5\" class=\"edge\">\n",
"<title>hover_over_title&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M398.52,-248.36C383.21,-242.16 366.82,-235.87 351.39,-230.58 338.42,-226.15 324.5,-221.86 310.94,-217.93\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"312.2,-214.65 301.62,-215.28 310.28,-221.39 312.2,-214.65\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-234.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
"</g>\n",
"<!-- hover_over_paragraph&#45;&gt;page_view -->\n",
"<g id=\"edge6\" class=\"edge\">\n",
"<title>hover_over_paragraph&#45;&gt;page_view</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M369.13,-95.76C310.26,-97.17 232.59,-99.41 163.87,-102.58 145.72,-103.42 125.98,-104.58 108.06,-105.73\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"107.86,-102.24 98.1,-106.38 108.31,-109.22 107.86,-102.24\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-106.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.14</text>\n",
"</g>\n",
"<!-- hover_over_paragraph&#45;&gt;view_item_page -->\n",
"<g id=\"edge7\" class=\"edge\">\n",
"<title>hover_over_paragraph&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M372.68,-119.15C354.84,-125.32 336.5,-132.51 319.89,-140.58 312.9,-143.98 305.81,-147.87 298.86,-151.98\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"297.49,-148.71 290.78,-156.91 301.14,-154.69 297.49,-148.71\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-144.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.86</text>\n",
"</g>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"[[0. 1. 0. 0. ]\n",
" [0. 0.1875 0.375 0.4375 ]\n",
" [0. 1. 0. 0. ]\n",
" [0.14285714 0.85714286 0. 0. ]]\n",
"d176d7c9-4027-4702-9e31-2a71395cdda0\n"
]
},
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 13.1.2 (0)\n",
" -->\n",
"<!-- Pages: 1 -->\n",
"<svg width=\"104pt\" height=\"104pt\"\n",
" viewBox=\"0.00 0.00 104.00 104.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 100.37)\">\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-100.37 100.37,-100.37 100.37,4 -4,4\"/>\n",
"<!-- page_view -->\n",
"<g id=\"node1\" class=\"node\">\n",
"<title>page_view</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-48.19\" rx=\"48.19\" ry=\"48.19\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-43.51\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
"</g>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"[[0.]]\n",
"f0317a5d-e424-44e9-b784-c8f7291ffe31\n"
]
},
{
"data": {
"image/svg+xml": [
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
"<!-- Generated by graphviz version 13.1.2 (0)\n",
" -->\n",
"<!-- Pages: 1 -->\n",
"<svg width=\"310pt\" height=\"160pt\"\n",
" viewBox=\"0.00 0.00 310.00 160.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 156.44)\">\n",
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-156.44 305.89,-156.44 305.89,4 -4,4\"/>\n",
"<!-- page_view -->\n",
"<g id=\"node1\" class=\"node\">\n",
"<title>page_view</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-69.01\" rx=\"48.19\" ry=\"48.19\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
"</g>\n",
"<!-- page_view&#45;&gt;page_view -->\n",
"<g id=\"edge1\" class=\"edge\">\n",
"<title>page_view&#45;&gt;page_view</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M33.03,-115.09C34.09,-126.6 39.14,-135.19 48.19,-135.19 53.98,-135.19 58.13,-131.66 60.65,-126.1\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"64.01,-127.11 62.98,-116.56 57.21,-125.45 64.01,-127.11\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-139.14\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
"</g>\n",
"<!-- view_item_page -->\n",
"<g id=\"node2\" class=\"node\">\n",
"<title>view_item_page</title>\n",
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-69.01\" rx=\"69.01\" ry=\"69.01\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
"</g>\n",
"<!-- page_view&#45;&gt;view_item_page -->\n",
"<g id=\"edge2\" class=\"edge\">\n",
"<title>page_view&#45;&gt;view_item_page</title>\n",
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-69.01C113.69,-69.01 133.31,-69.01 152.25,-69.01\"/>\n",
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-72.51 162.1,-69.01 152.1,-65.51 152.1,-72.51\"/>\n",
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-72.96\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
"</g>\n",
"</g>\n",
"</svg>\n"
],
"text/plain": [
"<graphviz.graphs.Digraph at 0x7f6800bf50f0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"[[5.0e-001 5.0e-001]\n",
" [9.9e-324 1.5e-323]]\n"
]
}
],
"source": [
"def explore_session(session_id: str):\n",
" subset = df[df['sessionId'] == session_id]\n",
" print(session_id)\n",
" P, labels = build_transition_prob_matrix(subset)\n",
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
" display(g)\n",
" return P\n",
"for session in sessions:\n",
" print(explore_session(session))"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python (PHANTOM)",
"language": "python",
"name": "phantom"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ import pandas as pd
from lib.separability import estimate_alpha, load_artifacts, score_session
# use relative import when in package context, fallback for standalone
try:
from sim.rl.behavior_loader.models import AgentBehaviorModel
@@ -51,7 +52,6 @@ def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
)
return events
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
contamination_rate: float = 0.1,
agent_data_dir: Path = None) -> pd.DataFrame:
@@ -78,6 +78,7 @@ def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
# generate synthetic trajectories
new_rows = []
alpha_estimates = []
for start_event in start_events:
# sample trajectory from agent model, using a state that contains the event type
mdp_states = model.mdp.get('states', []) if model.mdp else []

View File

@@ -6,6 +6,7 @@ from procesing.steps import (
)
def test_compute_demand(pipeline_context):
random.seed(42) # deterministic test
step = ComputeDemandStep(context=pipeline_context)
# Test with normal interaction data
@@ -26,6 +27,7 @@ def test_compute_demand(pipeline_context):
def test_compute_demand_skewed(pipeline_context):
random.seed(42) # deterministic test
step = ComputeDemandStep(context=pipeline_context)
# Test with normal interaction data

View File

@@ -0,0 +1,165 @@
import pytest
import pandas as pd
import numpy as np
from procesing.steps.session import (
TemporalFeatureStep,
BehavioralFeatureStep,
ProductFeatureStep,
UserAgentFeatureStep,
ExtractSessionFeaturesStep,
JoinLabelsStep,
ValidateDataStep,
)
# TemporalFeatureStep tests
def test_temporal_empty(pipeline_context):
result = TemporalFeatureStep(pipeline_context).transform(pd.DataFrame())
assert 'sessionId' in result.columns
assert result.empty
def test_temporal_basic(pipeline_context, session_interactions):
result = TemporalFeatureStep(pipeline_context).transform(session_interactions)
assert 'session_duration_sec' in result.columns
assert 'interaction_velocity' in result.columns
assert 'max_velocity_5min' in result.columns
assert result['total_interactions'].sum() == len(session_interactions)
def test_temporal_timeout(pipeline_context):
df = pd.DataFrame({
'sessionId': ['s1', 's1'],
'ts': ['2025-01-01T10:00:00Z', '2025-01-01T11:00:00Z'], # 1 hour gap
})
result = TemporalFeatureStep(pipeline_context, timeout_sec=900).transform(df)
assert result.iloc[0]['session_duration_sec'] == 0 # gap exceeds timeout
# BehavioralFeatureStep tests
def test_behavioral_empty(pipeline_context):
result = BehavioralFeatureStep(pipeline_context).transform(pd.DataFrame())
assert 'sessionId' in result.columns
def test_behavioral_counts(pipeline_context, session_interactions):
result = BehavioralFeatureStep(pipeline_context).transform(session_interactions)
assert 'page_views' in result.columns
assert 'item_views' in result.columns
assert 'hover_events' in result.columns
assert result['total_events'].sum() == len(session_interactions)
def test_behavioral_hover_prefix(pipeline_context):
df = pd.DataFrame({
'sessionId': ['s1', 's1'],
'eventName': ['hover_over_custom', 'hover_over_button'],
'page': ['/products', '/products'],
})
result = BehavioralFeatureStep(pipeline_context).transform(df)
assert result.iloc[0]['hover_events'] == 2
# ProductFeatureStep tests
def test_product_empty(pipeline_context):
result = ProductFeatureStep(pipeline_context).transform(pd.DataFrame())
assert 'sessionId' in result.columns
def test_product_features(pipeline_context, session_interactions):
result = ProductFeatureStep(pipeline_context).transform(session_interactions)
assert 'unique_products_viewed' in result.columns
assert 'price_range' in result.columns
assert result['unique_products_viewed'].sum() > 0
# UserAgentFeatureStep tests
def test_ua_empty(pipeline_context):
result = UserAgentFeatureStep(pipeline_context).transform(pd.DataFrame())
assert 'sessionId' in result.columns
def test_ua_headless_detection(pipeline_context):
df = pd.DataFrame({
'sessionId': ['s1', 's2'],
'userAgent': ['Mozilla/5.0 Chrome/120', 'HeadlessChrome/120'],
})
result = UserAgentFeatureStep(pipeline_context).transform(df)
assert 'is_headless' in result.columns
headless = dict(zip(result['sessionId'], result['is_headless']))
assert headless['s1'] == False
assert headless['s2'] == True
def test_ua_browser_family(pipeline_context):
df = pd.DataFrame({
'sessionId': ['s1', 's2', 's3'],
'userAgent': ['Mozilla/5.0 Firefox/120', 'Safari/605.1.15', 'Unknown'],
})
result = UserAgentFeatureStep(pipeline_context).transform(df)
browsers = dict(zip(result['sessionId'], result['browser_family']))
assert browsers['s1'] == 'Firefox'
assert browsers['s2'] == 'Safari'
assert browsers['s3'] == 'Other'
def test_ua_automation_detection(pipeline_context):
df = pd.DataFrame({
'sessionId': ['s1', 's2'],
'userAgent': ['Selenium WebDriver', 'Normal Chrome/120'],
})
result = UserAgentFeatureStep(pipeline_context).transform(df)
auto = dict(zip(result['sessionId'], result['is_automation']))
assert auto['s1'] == True
assert auto['s2'] == False
# ExtractSessionFeaturesStep tests
def test_extract_empty(pipeline_context):
result = ExtractSessionFeaturesStep(pipeline_context).transform(pd.DataFrame())
assert result.empty
def test_extract_merges_all(pipeline_context, session_interactions):
result = ExtractSessionFeaturesStep(pipeline_context).transform(session_interactions)
expected = ['session_duration_sec', 'total_events', 'unique_products_viewed', 'is_headless']
for col in expected:
assert col in result.columns
assert 'experimentId' in result.columns
# JoinLabelsStep tests
def test_join_labels_tuple_input(pipeline_context):
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1'], 'total_events': [5]})
experiments = pd.DataFrame({'id': ['exp1'], 'xp_human_only': [True]})
result = JoinLabelsStep(pipeline_context).transform((features, experiments))
assert 'is_agent' in result.columns
assert result.iloc[0]['is_agent'] == False
def test_join_labels_empty_experiments(pipeline_context):
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1']})
result = JoinLabelsStep(pipeline_context).transform((features, pd.DataFrame()))
assert pd.isna(result.iloc[0]['is_agent'])
# ValidateDataStep tests
def test_validate_empty(pipeline_context):
ValidateDataStep(pipeline_context).transform(pd.DataFrame())
report = pipeline_context.get_cached('validation_report')
assert report['status'] == 'empty'
def test_validate_missing_cols(pipeline_context):
df = pd.DataFrame({'sessionId': ['s1'], 'ts': ['2025-01-01']})
ValidateDataStep(pipeline_context).transform(df)
report = pipeline_context.get_cached('validation_report')
assert report['status'] == 'invalid'
assert 'eventName' in report['missing_cols']
def test_validate_valid(pipeline_context, session_interactions):
ValidateDataStep(pipeline_context).transform(session_interactions)
report = pipeline_context.get_cached('validation_report')
assert report['status'] == 'valid'
assert report['sessions'] > 0

View File

@@ -1,75 +0,0 @@
# MOS (Money Operating System)
Research-grade quote-control simulator for studying dynamic pricing and market making policies.
The system models pricing as a closed loop of **Quote → Arrival → Execution → Position**, enabling
controlled experimentation with demand models, inventory constraints, and reward shaping.
## Core Loop
1. **Quote** the policy posts prices (one-sided or two-sided depending on the mechanism).
2. **Arrival** a population model generates purchase opportunities or market orders.
3. **Execution** an execution model decides whether an arrival converts at the quoted price.
4. **Position** inventory/position limits censor fills and generate holding/shortage costs.
5. **Observation & Reward** censored fills and aggregate metrics are exposed to the agent, while
objectives turn metrics into a scalar reward.
Each stage is pluggable via light-weight protocols so you can swap in alternative mechanisms,
demand models, or objectives without rewriting the rest of the simulator.
## Package Layout
| Module | Purpose |
|-------------------|---------|
| `lab.outlet` | Core simulation engine, domain types, pricing mechanisms, objectives. |
| `lab.population` | Demand arrival models, execution probability models, competitor/market dynamics. |
| `lab.experiments` | Rollout utilities, baseline policies, and off-policy evaluation helpers. |
| `lab.config` | Convenience factories for preconfigured retail and market-making environments. |
## Preconfigured Scenarios
### Retail Dynamic Pricing
- Mechanism: posted prices with margin and delta constraints.
- Arrivals: browsing sessions with contamination support (scrapers).
- Execution: elasticity model with competitor cross-effects.
- Position: inventory tracking with holding and shortage costs.
- Market: reactive competitor that can trigger price wars.
- Objective: PnL minus volatility, holding cost, and lost opportunity penalties.
```python
from lab.config import make_retail_platform
from lab.experiments import rollout, fixed_price_policy
platform = make_retail_platform()
policy = fixed_price_policy(platform.instruments.refs)
result = rollout(platform, policy, n_steps=100)
print(result.total_pnl)
```
### Market Making
- Mechanism: two-sided quoting with bid/ask spreads.
- Arrivals: Hawkes order flow for clustered demand.
- Execution: AvellanedaStoikov style intensity model.
- Position: inventory risk limits and quadratic penalty objective.
- Market: geometric Brownian motion mid-price process.
- Objective: PnL plus spread capture minus inventory risk.
```python
from lab.config import make_market_making_platform
from lab.experiments import rollout
platform = make_market_making_platform()
mm_policy = lambda obs, t: (platform.instruments.refs, 1.0)
result = rollout(platform, mm_policy, n_steps=200, seed=42)
print(result.total_pnl)
```
## Extending the Simulator
- Implement `lab.outlet.protocols.Mechanism` or `ArrivalModel` to introduce new pricing
domains or demand processes.
- Compose objectives with `lab.outlet.objectives.factory.make_composite` to study alternate
reward formulations.
- Use `lab.experiments.compare_policies` to benchmark candidate policies across multiple
random seeds.
Comprehensive API documentation lives in `lab/docs` (build with `make html`).

View File

@@ -1,27 +0,0 @@
"""
Quote-Control Simulator: Research-grade platform for dynamic pricing and market making
The platform abstracts pricing as: Quote -> Arrival -> Execution -> Position
Supports multiple mechanisms:
- PostedPrice: retail dynamic pricing
- TwoSided: market making with bid-ask spreads
- Auction: reserve/shading for auction settings
Example usage:
from lab.config import make_retail_platform
from lab.experiments import rollout, fixed_price_policy
platform = make_retail_platform()
policy = fixed_price_policy(platform.instruments.refs)
result = rollout(platform, policy, n_steps=100)
print(f"Total PnL: {result.total_pnl:.2f}")
"""
from .config import make_retail_platform, make_market_making_platform, RetailConfig, MarketMakingConfig
from .outlet import Platform, PlatformConfig, Quote, Observation, StepResult
__all__ = [
'make_retail_platform', 'make_market_making_platform',
'RetailConfig', 'MarketMakingConfig',
'Platform', 'PlatformConfig', 'Quote', 'Observation', 'StepResult',
]

View File

@@ -1,6 +0,0 @@
"""
Case studies implementing specific research scenarios.
Available cases:
- thesis: PHANTOM thesis implementation with contaminated demand and DR-RL
"""

View File

@@ -1,25 +0,0 @@
"""
Thesis-specific implementation of the PHANTOM pricing defense framework.
This module implements the mathematical models from the thesis:
- ContaminatedArrivalModel: Mixture demand Q(p) = (1-α)d_H + αd_A (Eq 3)
- HybridExecutionModel: Divergent H/A behavior with separability (Section 2.1)
- RobustStackelbergObjective: Maximin objective with COI penalty (Eq 23)
- COIMetrics: Cost of Information tracking (Definition 1)
The platform configuration creates a research environment that directly
maps to the thesis mathematical framework for DR-RL experiments.
"""
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
from .execution import HybridExecutionModel, HybridExecutionConfig
from .objectives import RobustStackelbergObjective, COIObjective
from .platform import make_thesis_platform, ThesisConfig
from .metrics import COIMetrics, compute_coi, compute_separability
__all__ = [
'ContaminatedArrivalModel', 'ContaminatedArrivalConfig',
'HybridExecutionModel', 'HybridExecutionConfig',
'RobustStackelbergObjective', 'COIObjective',
'make_thesis_platform', 'ThesisConfig',
'COIMetrics', 'compute_coi', 'compute_separability',
]

View File

@@ -1,327 +0,0 @@
"""Contaminated arrivals using learned MDP kernels from behavior_loader.
Implements thesis demand model (Section 3.1):
- Aggregate demand Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3)
- Demand proxy q̂_{t,i} = Σ_s Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] (Eq 2)
- Per-session separability via KL divergence Δ_H, Δ_A (Eq 20-21)
The arrival model samples sessions from a mixture of human/agent behavioral profiles,
each session produces a trajectory τ_s and associated demand computation q(τ').
"""
from __future__ import annotations
from dataclasses import dataclass, field
from types import SimpleNamespace
from typing import Dict, List, Tuple, Optional
import numpy as np
from ...outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState
from ...outlet.constants import Side, OpportunityType
from ...outlet.math_util import poisson_arrivals
try:
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from sim.rl.behavior_loader.models import (
BehaviorModel, AgentBehaviorModel, aggregate_event_transitions, kl_divergence
)
REAL_MDP = True
except ImportError:
REAL_MDP = False
kl_divergence = None
EVENT_PAGE = {"session_start": "/", "view_item_page": "/products", "learn_more_about_item": "/products/details",
"add_item_to_cart": "/cart", "purchase_complete": "/checkout", "session_end": "/checkout/success"}
EVENT_CANON = {"page_view": "session_start", "hover_over_paragraph": "view_item_page", "hover_over_title": "view_item_page",
"view_item_page": "view_item_page", "learn_more_about_item": "learn_more_about_item",
"add_item_to_cart": "add_item_to_cart", "checkout_start": "purchase_complete", "remove_item": "view_item_page"}
# action space partition A = A_nav A_cart A_filter A_dwell with signal weights ω (Table 1)
ACTION_WEIGHTS: Dict[str, float] = {
"add_item_to_cart": 0.8, "remove_item": 0.6, "checkout_start": 0.9, "purchase_complete": 1.0, # A_cart
"hover_over_title": 0.3, "hover_over_paragraph": 0.35, "hover_over_link": 0.25, # A_dwell
"page_view": 0.1, "session_start": 0.05, "view_item_page": 0.15, "learn_more_about_item": 0.2, # A_nav
"search": 0.05, "filter_date": 0.05, "filter_price": 0.08, "sort": 0.03, "session_end": 0.0, # A_filter
}
@dataclass
class SessionDemand:
"""Per-session demand computation per thesis formulation (Section 3.1).
Each session s ∈ S produces trajectory τ_s and demand proxy q̂. The platform uses
divergence signals Δ_H, Δ_A to estimate per-session contamination α̂(τ').
"""
session_id: str
q: Dict[int, float] # q̂_i demand proxy per product (Eq 2)
trajectory: List[Dict] # τ_s = (e_{s,1}, ..., e_{s,L_s})
delta_h: float = 0.0 # D_KL(T̂' || T̄_H) (Eq 20)
delta_a: float = 0.0 # D_KL(T̂' || T̄_A) (Eq 21)
alpha_hat: float = 0.0 # per-session contamination estimate
actor_class: str = "H" # ground truth Y_s ∈ {H, A}
theta: Dict[str, float] = field(default_factory=dict)
def compute_demand_proxy(events: List[Dict], n_products: int) -> Dict[int, float]:
"""Compute q̂_{t,i} = Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] per Eq 2."""
q = {i: 0.0 for i in range(n_products)}
for e in events:
action, pidx = e.get("eventName", ""), e.get("product_idx")
if pidx is not None and 0 <= pidx < n_products:
q[pidx] += ACTION_WEIGHTS.get(action, 0.1)
return q
def compute_session_divergence(events: List[Dict], ref_h: Dict, ref_a: Dict) -> Tuple[float, float]:
"""Compute Δ_H, Δ_A divergence signals from trajectory (Eq 20-21)."""
if not events or kl_divergence is None:
return 0.0, 0.0
# build empirical transition kernel from trajectory
trans: Dict[str, Dict[str, int]] = {}
prev = "session_start"
for e in events:
curr = e.get("eventName", "session_end")
trans.setdefault(prev, {})
trans[prev][curr] = trans[prev].get(curr, 0) + 1
prev = curr
# normalize to probabilities
kernel = {}
for s, dests in trans.items():
total = sum(dests.values())
kernel[s] = {d: c / total for d, c in dests.items()} if total > 0 else {}
# aggregate to event-level and compute KL divergence against reference kernels
delta_h = sum(kl_divergence(kernel.get(s, {}), ref_h.get(s, {})) for s in kernel) / max(len(kernel), 1)
delta_a = sum(kl_divergence(kernel.get(s, {}), ref_a.get(s, {})) for s in kernel) / max(len(kernel), 1)
return delta_h, delta_a
def _canonicalize(raw: Dict) -> Dict:
out = {}
for src, dsts in raw.items():
sc = EVENT_CANON.get(src, src)
out.setdefault(sc, {})
for dst, p in dsts.items():
dc = EVENT_CANON.get(dst, dst)
out[sc][dc] = out[sc].get(dc, 0.0) + p
return {s: {k: v/sum(d.values()) for k, v in d.items()} for s, d in out.items() if sum(d.values()) > 0}
class BehavioralProfile:
"""Markov profile from learned MDP kernels (Section 3.5.2).
Transition kernel T̂_Y estimated via MLE: P̂(s'|s) = N(s,s') / Σ_k N(s,k) (Eq 19)
"""
STATES = ["session_start", "view_item_page", "learn_more_about_item", "add_item_to_cart", "purchase_complete", "session_end"]
# fallback kernels T̄_H, T̄_A when real data unavailable
FALLBACK_H = {"session_start": {"view_item_page": 0.85, "session_end": 0.15},
"view_item_page": {"learn_more_about_item": 0.4, "add_item_to_cart": 0.3, "view_item_page": 0.2, "session_end": 0.1},
"learn_more_about_item": {"add_item_to_cart": 0.5, "view_item_page": 0.3, "session_end": 0.2},
"add_item_to_cart": {"purchase_complete": 0.6, "view_item_page": 0.25, "session_end": 0.15},
"purchase_complete": {"session_end": 1.0}}
FALLBACK_A = {"session_start": {"view_item_page": 0.95, "session_end": 0.05},
"view_item_page": {"learn_more_about_item": 0.6, "view_item_page": 0.25, "add_item_to_cart": 0.1, "session_end": 0.05},
"learn_more_about_item": {"view_item_page": 0.5, "add_item_to_cart": 0.15, "learn_more_about_item": 0.3, "session_end": 0.05},
"add_item_to_cart": {"view_item_page": 0.4, "purchase_complete": 0.2, "session_end": 0.4},
"purchase_complete": {"session_end": 1.0}}
def __init__(self, actor: str, pprobs: np.ndarray, data_dir: str = ""):
self.actor, self.pprobs = actor, np.clip(pprobs, 0.0, 0.95)
self.trans = self._load(data_dir) # T̂_Y transition kernel
self._ensure_terminal()
self.dwell = {s: (1.2, 0.5) if actor == "agents" else (2.0, 1.2) for s in self.STATES}
def _load(self, data_dir: str) -> Dict:
if not REAL_MDP or not data_dir:
print("using fallback")
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
try:
mdp = (AgentBehaviorModel if self.actor == "agents" else BehaviorModel)(data_dir).build_MDP()
raw = aggregate_event_transitions(mdp) if mdp.get("transitions") else {}
return _canonicalize(raw) if raw else dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
except Exception:
print("using fallback")
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
def _ensure_terminal(self):
self.trans.setdefault("purchase_complete", {})["session_end"] = self.trans.get("purchase_complete", {}).get("session_end", 1.0)
self.trans.setdefault("session_start", {"view_item_page": 0.7, "learn_more_about_item": 0.2, "session_end": 0.1})
def _tprobs(self, state: str, pidx: int) -> Dict[str, float]:
probs = dict(self.trans.get(state, {"session_end": 1.0}))
if state == "add_item_to_cart":
base = probs.get("purchase_complete", 0.0)
df = float(self.pprobs[pidx]) * (0.3 if self.actor == "agents" else 1.0)
adj = np.clip(base * 0.5 + df * 0.5, 0.0, 0.95)
rem = max(1e-6, 1.0 - adj)
other = sum(v for k, v in probs.items() if k != "purchase_complete")
probs = {k: (adj if k == "purchase_complete" else v * rem / max(other, 1e-6)) for k, v in probs.items()}
total = sum(probs.values())
return {k: v/total for k, v in probs.items()} if total > 0 else {"session_end": 1.0}
def sample(self, rng: np.random.Generator, sid: str, prices: np.ndarray, costs: np.ndarray) -> Tuple[List[Dict], List[SimpleNamespace]]:
events, fevts = [], []
state, t, pidx = "session_start", 0.0, int(rng.integers(0, len(prices)))
cost, cprice = float(costs[pidx]), max(float(prices[pidx]), float(costs[pidx]) * 1.05)
while state != "session_end" and len(events) < 40:
if state != "session_start":
row = {"session_id": sid, "actor": "agent" if self.actor == "agents" else "human",
"eventName": state, "product_idx": pidx, "productId": f"product-{pidx:04d}",
"price_offered": cprice, "price_paid": 0.0, "page": EVENT_PAGE.get(state, "/"),
"ts": t, "unit_cost": cost, "base_price": float(prices[pidx])}
if state == "purchase_complete":
row["price_paid"] = max(cprice * (1.0 + rng.normal(0.0, 0.015)), cost)
events.append(row)
fevts.append(SimpleNamespace(eventName=state, page=row["page"], productId=row["productId"], ts=t))
probs = self._tprobs(state, pidx)
state = rng.choice(list(probs.keys()), p=list(probs.values()))
sh, sc = self.dwell.get(state, (2.0, 1.0))
t += max(0.3, rng.gamma(shape=sh, scale=sc))
return events, fevts
@dataclass
class ContaminatedArrivalConfig:
base_rate: float = 20.0
alpha_contamination: float = 0.2
alpha_drift: float = 0.0
alpha_bounds: tuple[float, float] = (0.0, 0.5)
human_views_range: tuple[int, int] = (1, 4)
agent_views_range: tuple[int, int] = (3, 10)
agent_systematic: bool = True
use_real_behavior: bool = True
human_data_dir: str = ""
agent_data_dir: str = ""
class ContaminatedArrivalModel:
"""Mixture model Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3).
Samples sessions from human/agent behavioral profiles, computes per-session
demand proxy q̂ and divergence signals Δ_H, Δ_A for separability.
"""
def __init__(self, cfg: ContaminatedArrivalConfig | None = None):
self.cfg = cfg or ContaminatedArrivalConfig()
self._alpha = self.cfg.alpha_contamination
self._scount = 0
self._profiles: Dict[str, BehavioralProfile] = {}
self._ref_kernels: Dict[str, Dict] = {} # T̄_H, T̄_A reference kernels
self._session_demands: List[SessionDemand] = [] # collected session demands
@property
def alpha(self) -> float:
return self._alpha
def _profile(self, actor: str, pprobs: np.ndarray) -> BehavioralProfile:
key = actor
if key not in self._profiles:
ddir = self.cfg.agent_data_dir if actor == "agents" else self.cfg.human_data_dir
if not ddir and self.cfg.use_real_behavior:
base = Path(__file__).parent.parent.parent.parent / "experiments"
ddir = str(base / ("agents/collected_data" if actor == "agents" else "collected_data"))
profile = BehavioralProfile(actor, pprobs, ddir if self.cfg.use_real_behavior else "")
self._profiles[key] = profile
self._ref_kernels[key] = profile.trans # cache T̄_Y for divergence
return self._profiles[key]
def get_ref_kernels(self) -> Tuple[Dict, Dict]:
"""Return reference transition kernels T̄_H, T̄_A for divergence computation."""
return (self._ref_kernels.get("humans", BehavioralProfile.FALLBACK_H),
self._ref_kernels.get("agents", BehavioralProfile.FALLBACK_A))
def get_session_demands(self) -> List[SessionDemand]:
"""Return collected session demands for downstream analysis."""
return self._session_demands
def sample(self, t: float, dt: float, instruments: InstrumentSet,
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
"""Sample arrivals as per Eq 3: mixture of human/agent demand distributions.
For each session s, computes:
- Trajectory τ_s from behavioral profile sampling
- Demand proxy q̂ via weighted action aggregation (Eq 2)
- Divergence signals Δ_H, Δ_A for separability (Eq 20-21)
- Per-session contamination estimate α̂(τ')
"""
cfg = self.cfg
if cfg.alpha_drift != 0:
self._alpha = np.clip(self._alpha + cfg.alpha_drift * rng.normal(), *cfg.alpha_bounds)
hidden.contamination = self._alpha
n_sess = poisson_arrivals(cfg.base_rate * hidden.true_demand_intensity, dt, rng)
prices, costs = instruments.refs, instruments.costs
margin = np.clip((prices - costs) / np.maximum(costs, 1e-3), -0.9, 2.0)
hprob, aprob = 0.08 * np.exp(-1.2 * margin), 0.05 * np.exp(-0.6 * margin)
ref_h, ref_a = self.get_ref_kernels()
opps = []
for _ in range(n_sess):
self._scount += 1
sid = f"s{self._scount:06d}"
is_agent = rng.random() < self._alpha
actor, probs = ("agents", aprob) if is_agent else ("humans", hprob)
profile = self._profile(actor, probs)
events, fevts = profile.sample(rng, sid, prices, costs)
# compute demand proxy q̂ per Eq 2
q = compute_demand_proxy(events, instruments.n)
# compute divergence signals Δ_H, Δ_A per Eq 20-21
delta_h, delta_a = compute_session_divergence(events, ref_h, ref_a)
# per-session contamination estimate α̂(τ') = σ(β(Δ_H - Δ_A))
alpha_hat = 1.0 / (1.0 + np.exp(-2.0 * (delta_h - delta_a))) if (delta_h + delta_a) > 0 else 0.5
theta = ({'price_sensitivity': rng.uniform(0.05, 0.2), 'base_conversion': 0.01, 'info_value': 1.0} if is_agent
else {'price_sensitivity': rng.uniform(1.5, 4.0), 'base_conversion': rng.uniform(0.2, 0.5), 'info_value': 0.0})
# store session demand for downstream analysis
self._session_demands.append(SessionDemand(
session_id=sid, q=q, trajectory=events, delta_h=delta_h, delta_a=delta_a,
alpha_hat=alpha_hat, actor_class="A" if is_agent else "H", theta=theta))
viewed = list({e["product_idx"] for e in events if "product_idx" in e})
if not viewed:
vr = cfg.agent_views_range if is_agent else cfg.human_views_range
viewed = list(rng.choice(instruments.n, size=min(rng.integers(*vr), instruments.n), replace=False))
for vi, iid in enumerate(viewed):
opps.append(Opportunity(
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
instrument_id=int(iid), size=1.0, t=t + rng.uniform(0, dt),
context={'session_id': sid, 'actor_class': 'AGENT' if is_agent else 'HUMAN', 'is_agent': is_agent,
'reconnaissance_intent': is_agent, 'view_index': vi, 'total_views': len(viewed),
'theta': theta, 'trajectory_events': fevts, 'mdp_trajectory': events,
'demand_proxy': q, 'alpha_hat': alpha_hat, 'delta_h': delta_h, 'delta_a': delta_a}))
return opps
@dataclass
class AdversarialArrivalConfig:
base_rate: float = 5.0
n_parallel_agents: int = 3
query_all_products: bool = True
class AdversarialArrivalModel:
"""Adversarial coordination (Theorem 1): as N->inf, COI->0."""
def __init__(self, cfg: AdversarialArrivalConfig | None = None):
self.cfg = cfg or AdversarialArrivalConfig()
self._qcount = 0
def sample(self, t: float, dt: float, instruments: InstrumentSet,
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
cfg, opps = self.cfg, []
for _ in range(poisson_arrivals(cfg.base_rate, dt, rng)):
self._qcount += 1
for ai in range(cfg.n_parallel_agents):
sid = f"adv{self._qcount:06d}-{ai}"
prods = np.arange(instruments.n) if cfg.query_all_products else rng.choice(instruments.n, size=1)
for iid in prods:
opps.append(Opportunity(
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
instrument_id=int(iid), size=1.0, t=t,
context={'session_id': sid, 'actor_class': 'AGENT', 'is_agent': True, 'adversarial': True,
'agent_index': ai, 'query_group': self._qcount,
'theta': {'price_sensitivity': 0.0, 'base_conversion': 0.0, 'info_value': 1.0}}))
return opps

View File

@@ -1,91 +0,0 @@
"""Execution models with divergent H/A behavior using ground truth labels."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict
import numpy as np
from ...outlet.types import Opportunity, Quote, InstrumentSet, MarketState
from ...outlet.math_util import sigmoid, safe_log, EPS
@dataclass
class HybridExecutionConfig:
human_base_prob: float = 0.3
human_elasticity: float = 2.5
agent_conversion: float = 0.01
cross_elasticity: float = 0.4
quality_weight: float = 0.2
use_separability: bool = False
class HybridExecutionModel:
"""Execution with divergent H/A behavior using ground truth labels."""
def __init__(self, cfg: HybridExecutionConfig | None = None):
self.cfg = cfg or HybridExecutionConfig()
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
market: MarketState | None, rng: np.random.Generator) -> float:
cfg, idx = self.cfg, int(opp.instrument_id)
price, ref, cost = float(quote.prices[idx]), float(instruments.refs[idx]), float(instruments.costs[idx])
ctx = opp.context
theta = ctx.get('theta', {})
is_agent = ctx.get('is_agent', False)
if is_agent:
return cfg.agent_conversion * theta.get('base_conversion', 1.0)
# human logit discrete choice
sens = theta.get('price_sensitivity', cfg.human_elasticity)
base = theta.get('base_conversion', cfg.human_base_prob)
u_price = -sens * safe_log(price / (ref + EPS))
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
u_quality = cfg.quality_weight * quality
u_comp = 0.0
if market and market.competitor_quotes is not None:
cp = market.competitor_quotes[idx]
if cp < price:
u_comp = -cfg.cross_elasticity * (price - cp) / ref
utility = safe_log(base / (1 - base + EPS)) + u_price + u_quality + u_comp
return float(sigmoid(utility))
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
if context is None:
return fills / (self.cfg.human_base_prob + EPS)
agent_frac = context.get('contamination', 0.0)
return fills / (self.cfg.human_base_prob * (1 - agent_frac) + EPS)
@dataclass
class SeparableExecutionConfig:
human_funnel: Dict[str, float] = None
agent_funnel: Dict[str, float] = None
def __post_init__(self):
self.human_funnel = self.human_funnel or {'view_to_detail': 0.4, 'detail_to_cart': 0.3, 'cart_to_purchase': 0.6}
self.agent_funnel = self.agent_funnel or {'view_to_detail': 0.8, 'detail_to_cart': 0.05, 'cart_to_purchase': 0.1}
class SeparableExecutionModel:
"""Execution with Markov funnel kernels using ground truth labels."""
def __init__(self, cfg: SeparableExecutionConfig | None = None):
self.cfg = cfg or SeparableExecutionConfig()
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
market: MarketState | None, rng: np.random.Generator) -> float:
is_agent = opp.context.get('is_agent', False)
probs = self.cfg.agent_funnel if is_agent else self.cfg.human_funnel
p = probs['view_to_detail'] * probs['detail_to_cart'] * probs['cart_to_purchase']
if not is_agent:
idx = int(opp.instrument_id)
price_ratio = quote.prices[idx] / (instruments.refs[idx] + EPS)
p *= np.exp(-0.5 * (price_ratio - 1.0))
return float(np.clip(p, 0, 1))
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
h = self.cfg.human_funnel
exp_conv = h['view_to_detail'] * h['detail_to_cart'] * h['cart_to_purchase']
return fills / (exp_conv + EPS)

View File

@@ -1,102 +0,0 @@
"""Thesis metrics for COI and behavioral analysis using ground truth labels."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict
import numpy as np
from ...outlet.types import StepLogs, StepMetrics, Quote, InstrumentSet
from ...outlet.math_util import safe_log, EPS
@dataclass
class COIMetrics:
coi_level: float = 0.0
coi_leakage: float = 0.0
realized_premium: float = 0.0
theoretical_max: float = 0.0
erosion_rate: float = 0.0
def to_dict(self) -> dict[str, float]:
return {k: getattr(self, k) for k in ['coi_level', 'coi_leakage', 'realized_premium', 'theoretical_max', 'erosion_rate']}
def compute_coi(quote: Quote, instruments: InstrumentSet, metrics: StepMetrics, contamination: float) -> COIMetrics:
prices, costs, refs = quote.prices, instruments.costs, instruments.refs
margins = prices - costs
coi_level = float(np.mean(margins))
theoretical_max = float(np.mean(costs))
realized_premium = (metrics.revenue - metrics.cost) / metrics.units_traded if metrics.units_traded > 0 else 0.0
price_var = float(np.var(prices / refs))
coi_leakage = contamination * (coi_level + price_var)
erosion_rate = contamination * coi_level / (theoretical_max + EPS)
return COIMetrics(coi_level=coi_level, coi_leakage=coi_leakage, realized_premium=realized_premium,
theoretical_max=theoretical_max, erosion_rate=erosion_rate)
@dataclass
class SeparabilityMetrics:
classification_accuracy: float = 0.0
estimated_alpha: float = 0.0
n_human_sessions: int = 0
n_agent_sessions: int = 0
def compute_separability(logs: StepLogs, true_alpha: float) -> SeparabilityMetrics:
"""Compute separability using ground truth labels only."""
if logs.events is None or len(logs.events) == 0:
return SeparabilityMetrics(estimated_alpha=true_alpha)
sessions: Dict[str, bool] = {}
for evt in logs.events:
sid = evt.metadata.get('session_id', evt.opportunity_id)
if sid not in sessions:
sessions[sid] = evt.metadata.get('is_agent', False)
n_agent = sum(1 for is_agent in sessions.values() if is_agent)
n_human = len(sessions) - n_agent
est_alpha = n_agent / len(sessions) if sessions else 0.0
return SeparabilityMetrics(
classification_accuracy=1.0, # ground truth is always correct
estimated_alpha=est_alpha,
n_human_sessions=n_human,
n_agent_sessions=n_agent)
@dataclass
class RevenueAttribution:
total_revenue: float = 0.0
human_revenue: float = 0.0
agent_revenue: float = 0.0
human_conversion: float = 0.0
agent_conversion: float = 0.0
def compute_attribution(logs: StepLogs, metrics: StepMetrics) -> RevenueAttribution:
if logs.executions is None:
return RevenueAttribution(total_revenue=metrics.revenue)
human_rev, agent_rev, human_cnt, agent_cnt = 0.0, 0.0, 0, 0
for exe in logs.executions:
if exe.propensity < 0.05:
agent_rev += exe.price * exe.size_filled
agent_cnt += 1
else:
human_rev += exe.price * exe.size_filled
human_cnt += 1
total_exp = logs.aggregates.get('n_arrivals', 1)
return RevenueAttribution(
total_revenue=metrics.revenue, human_revenue=human_rev, agent_revenue=agent_rev,
human_conversion=human_cnt / (total_exp * 0.8 + EPS),
agent_conversion=agent_cnt / (total_exp * 0.2 + EPS))
def order_statistic_erosion(n_agents: int, price_variance: float) -> float:
"""COI erosion from Theorem 1: as N->inf, min(p_1..p_N)->p_min."""
if n_agents <= 1:
return 0.0
sigma, log_n = np.sqrt(price_variance), safe_log(n_agents)
if log_n < 1:
return 0.0
shift = sigma * (np.sqrt(2 * log_n) - (safe_log(log_n) + safe_log(4 * np.pi)) / (2 * np.sqrt(2 * log_n) + EPS))
return float(min(shift / (sigma * 2 + EPS), 1.0))

View File

@@ -1,228 +0,0 @@
"""
Thesis-specific objectives implementing robust pricing under contamination.
Implements the Maximin objective from Eq 23:
π* = argmax_π min_{Q ∈ U_ε} E_d~Q[R(p,d) - λ·COI(p)]
Key components:
- COIObjective: Cost of Information penalty (Definition 1)
- RobustStackelbergObjective: Full maximin objective with Wasserstein robustness
- UXPenalty: User experience degradation from volatility
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from ...outlet.objectives.base import BaseObjective, CompositeObjective
from ...outlet.types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
from ...outlet.math_util import safe_log, EPS
class COIObjective(BaseObjective):
"""Cost of Information penalty from Definition 1.
COI(π) = E[P] - p_min
The expected price premium over marginal cost represents the platform's
pricing power. Agent reconnaissance erodes this by revealing price
distribution to buyers.
We implement COI_leakage = f(τ') · InfoValue(p, τ')
where f(τ') is the estimated agent probability.
"""
def __init__(self, lambda_coi: float = 1.0, use_revelation: bool = False):
"""
Args:
lambda_coi: Weight on COI penalty
use_revelation: If True, use -log(π(p)) as info value (penalizes rare prices)
"""
self.lambda_coi = lambda_coi
self.use_revelation = use_revelation
def reward(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
# COI_leakage = α · InfoValue
alpha = hidden.contamination
if self.use_revelation:
# revelation surrogate: rare prices reveal more about policy
# InfoValue = -log(π(p|τ')) ≈ surprise of the price
price_surprise = np.mean(np.abs(quote.prices - instruments.refs) / (instruments.refs + EPS))
info_value = price_surprise
else:
# query-tax surrogate: each agent query incurs constant leakage
info_value = 1.0
leakage = alpha * info_value
return -self.lambda_coi * leakage
def breakdown(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
alpha = hidden.contamination
margins = (quote.prices - instruments.costs) / (instruments.costs + EPS)
return {
'coi_penalty': self.reward(quote, instruments, metrics, hidden, obs),
'contamination': alpha,
'avg_margin': float(np.mean(margins)),
}
@dataclass
class RobustObjectiveConfig:
"""Configuration for robust Stackelberg objective.
Attributes:
lambda_coi: Weight on COI penalty (λ in Eq 23)
lambda_ux: Weight on UX penalty
lambda_volatility: Weight on price volatility penalty
gamma_inventory: Inventory risk aversion
wasserstein_epsilon: Ambiguity set radius (ε in Eq 21)
"""
lambda_coi: float = 0.5
lambda_ux: float = 0.1
lambda_volatility: float = 0.2
gamma_inventory: float = 0.1
wasserstein_epsilon: float = 0.1
class RobustStackelbergObjective(BaseObjective):
"""Implements the Maximin Objective from thesis Eq 23.
π* = argmax_π min_{Q ∈ U_ε(P̂_N)} E_d~Q[R(p,d) - λ·COI(p)]
The objective balances:
1. Revenue R(p,d) from human purchases
2. COI penalty for information leakage to agents
3. UX penalty for price volatility
4. Inventory/holding costs
The min over ambiguity set U_ε is approximated by penalizing
high contamination scenarios more heavily.
"""
def __init__(self, cfg: RobustObjectiveConfig | None = None):
self.cfg = cfg or RobustObjectiveConfig()
def reward(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
cfg = self.cfg
# 1. base revenue (R(p,d))
revenue = metrics.revenue
cost = metrics.cost
profit = revenue - cost
# 2. COI penalty: scales with contamination and margin extraction
# high margins + high contamination = high leakage
alpha = hidden.contamination
margins = quote.prices - instruments.costs
avg_margin = float(np.mean(margins))
coi_penalty = cfg.lambda_coi * avg_margin * alpha
# 3. UX penalty: price volatility harms legitimate users
volatility_penalty = cfg.lambda_volatility * metrics.volatility
# 4. inventory/position cost
position_penalty = cfg.gamma_inventory * metrics.position_cost
# 5. lost opportunity cost (stockouts)
lost_penalty = 0.1 * metrics.lost_opportunity
# robust adjustment: under adversarial distribution Q,
# expect lower revenue and higher costs
# approximate via worst-case contamination within ε-ball
worst_case_alpha = min(alpha + cfg.wasserstein_epsilon, 1.0)
robustness_penalty = cfg.wasserstein_epsilon * avg_margin * worst_case_alpha
total = profit - coi_penalty - volatility_penalty - position_penalty - lost_penalty - robustness_penalty
return total
def breakdown(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
cfg = self.cfg
alpha = hidden.contamination
margins = quote.prices - instruments.costs
avg_margin = float(np.mean(margins))
return {
'revenue': metrics.revenue,
'cost': metrics.cost,
'profit': metrics.revenue - metrics.cost,
'coi_penalty': -cfg.lambda_coi * avg_margin * alpha,
'volatility_penalty': -cfg.lambda_volatility * metrics.volatility,
'position_penalty': -cfg.gamma_inventory * metrics.position_cost,
'lost_penalty': -0.1 * metrics.lost_opportunity,
'robustness_penalty': -cfg.wasserstein_epsilon * avg_margin * min(alpha + cfg.wasserstein_epsilon, 1.0),
'contamination': alpha,
'avg_margin_pct': avg_margin / (float(np.mean(instruments.costs)) + EPS),
}
class UXPenalty(BaseObjective):
"""User experience penalty from price volatility.
High price volatility degrades UX for legitimate human users.
This term ensures the defense doesn't harm real customers while
protecting against agent reconnaissance.
"""
def __init__(self, scale: float = 1.0, max_acceptable_volatility: float = 0.1):
self.scale = scale
self.max_vol = max_acceptable_volatility
def reward(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
# penalty increases quadratically beyond threshold
excess_vol = max(0, metrics.volatility - self.max_vol)
return -self.scale * (excess_vol ** 2)
def breakdown(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
return {
'ux_penalty': self.reward(quote, instruments, metrics, hidden, obs),
'volatility': metrics.volatility,
}
class AdaptiveObjective(BaseObjective):
"""Objective that adapts weights based on estimated contamination.
When contamination is low, focus on revenue maximization.
When contamination is high, increase COI defense weight.
"""
def __init__(self, base_lambda_coi: float = 0.3, max_lambda_coi: float = 2.0,
adaptation_rate: float = 2.0):
self.base_lambda = base_lambda_coi
self.max_lambda = max_lambda_coi
self.rate = adaptation_rate
def _adaptive_lambda(self, alpha: float) -> float:
# sigmoid scaling: λ(α) = base + (max-base) * sigmoid(rate*(α-0.5))
from ...outlet.math_util import sigmoid
scale = sigmoid(self.rate * (alpha - 0.3))
return self.base_lambda + (self.max_lambda - self.base_lambda) * scale
def reward(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
alpha = hidden.contamination
lambda_coi = self._adaptive_lambda(alpha)
profit = metrics.revenue - metrics.cost
margins = quote.prices - instruments.costs
coi_penalty = lambda_coi * float(np.mean(margins)) * alpha
return profit - coi_penalty
def breakdown(self, quote: Quote, instruments: InstrumentSet,
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
alpha = hidden.contamination
return {
'profit': metrics.revenue - metrics.cost,
'adaptive_lambda': self._adaptive_lambda(alpha),
'contamination': alpha,
}
def make_thesis_objective(lambda_coi: float = 0.5, lambda_ux: float = 0.1,
lambda_vol: float = 0.2) -> CompositeObjective:
"""Create the standard thesis objective composition."""
return CompositeObjective([
(RobustStackelbergObjective(RobustObjectiveConfig(
lambda_coi=lambda_coi, lambda_ux=lambda_ux, lambda_volatility=lambda_vol)), 1.0),
])

View File

@@ -1,176 +0,0 @@
"""Thesis platform with real MDP behavioral models and separability scoring."""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import numpy as np
from ...outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
PostedPriceMechanism, make_instruments, InstrumentType, LogLevel)
from ...outlet.mechanisms.posted_price import PostedPriceConfig
from ...outlet.observation import DefaultObservationBuilder, ObservationConfig
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
from .execution import HybridExecutionModel, HybridExecutionConfig
from .objectives import RobustStackelbergObjective, RobustObjectiveConfig
@dataclass
class ThesisConfig:
# instruments
n_instruments: int = 10
cost_range: tuple[float, float] = (5.0, 50.0)
margin_range: tuple[float, float] = (0.2, 0.5)
# contamination (Section 3.1)
alpha_contamination: float = 0.2
alpha_drift: float = 0.0
alpha_bounds: tuple[float, float] = (0.0, 0.5)
# objectives (Eq 23)
lambda_coi: float = 0.5
lambda_ux: float = 0.1
lambda_volatility: float = 0.2
wasserstein_epsilon: float = 0.1
# arrivals
sessions_per_step: int = 30
human_views_range: tuple[int, int] = (1, 4)
agent_views_range: tuple[int, int] = (3, 10)
# inventory
initial_inventory: float = 100.0
holding_cost_rate: float = 0.002
# real behavioral models (from sim.rl)
use_real_behavior: bool = True
use_separability: bool = False # disabled until classifier trained
human_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data"
agent_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data"
# simulation
max_steps: int = 500
seed: int | None = 24
log_level: LogLevel = LogLevel.AGG_ONLY
def _resolve_data_dirs(cfg: ThesisConfig) -> tuple[str, str]:
"""Resolve data directories for behavioral models."""
base = Path(__file__).parent.parent.parent.parent / "experiments"
human = cfg.human_data_dir or str(base / "collected_data")
agent = cfg.agent_data_dir or str(base / "agents/collected_data")
return human, agent
def make_thesis_platform(cfg: ThesisConfig | None = None) -> Platform:
"""Create platform with real MDP behavioral models.
Implements:
- Contaminated arrivals using learned MDP kernels from behavior_loader
- Hybrid execution with real separability scoring from lib.separability
- Robust Stackelberg objective (Eq 23)
"""
cfg = cfg or ThesisConfig()
rng = np.random.default_rng(cfg.seed)
human_dir, agent_dir = _resolve_data_dirs(cfg)
instruments = make_instruments(
n=cfg.n_instruments, cost_range=cfg.cost_range, margin_range=cfg.margin_range,
inst_type=InstrumentType.SKU, rng=rng)
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
arrival = ContaminatedArrivalModel(ContaminatedArrivalConfig(
base_rate=cfg.sessions_per_step,
alpha_contamination=cfg.alpha_contamination,
alpha_drift=cfg.alpha_drift,
alpha_bounds=cfg.alpha_bounds,
human_views_range=cfg.human_views_range,
agent_views_range=cfg.agent_views_range,
use_real_behavior=cfg.use_real_behavior,
human_data_dir=human_dir,
agent_data_dir=agent_dir,
))
execution = HybridExecutionModel(HybridExecutionConfig(
use_separability=cfg.use_separability,
))
mechanism = PostedPriceMechanism(PostedPriceConfig(max_delta_pct=0.15, min_margin_pct=0.05))
position = PositionModel(PositionConfig(initial_position=cfg.initial_inventory, holding_cost_rate=cfg.holding_cost_rate))
market = None
objective = RobustStackelbergObjective(RobustObjectiveConfig(
lambda_coi=cfg.lambda_coi, lambda_ux=cfg.lambda_ux,
lambda_volatility=cfg.lambda_volatility, wasserstein_epsilon=cfg.wasserstein_epsilon))
obs_builder = DefaultObservationBuilder(ObservationConfig(mask_true_demand=True))
platform_cfg = PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
seed=cfg.seed, log_level=cfg.log_level, mask_demand=True)
return Platform(instruments=instruments, mechanism=mechanism, arrival=arrival, execution=execution,
position=position, market=market, obs_builder=obs_builder, objective=objective, cfg=platform_cfg)
@dataclass
class AblationConfig(ThesisConfig):
disable_coi_penalty: bool = False
disable_ux_penalty: bool = False
disable_contamination: bool = False
disable_real_behavior: bool = False
def make_ablation_platform(cfg: AblationConfig) -> Platform:
if cfg.disable_coi_penalty:
cfg.lambda_coi = 0.0
if cfg.disable_ux_penalty:
cfg.lambda_ux = 0.0
if cfg.disable_contamination:
cfg.alpha_contamination = 0.0
if cfg.disable_real_behavior:
cfg.use_real_behavior = False
cfg.use_separability = False
return make_thesis_platform(cfg)
def sweep_contamination(alpha_values: list[float], base_cfg: ThesisConfig | None = None,
n_steps: int = 100, seed: int = 42) -> dict[float, dict]:
"""Test performance across contamination levels (Theorem 1 validation)."""
from ...experiments.eval import rollout, fixed_price_policy
results = {}
base_cfg = base_cfg or ThesisConfig()
for alpha in alpha_values:
cfg = ThesisConfig(**{k: v for k, v in base_cfg.__dict__.items() if k != 'alpha_contamination'},
alpha_contamination=alpha)
platform = make_thesis_platform(cfg)
policy = fixed_price_policy(platform.instruments.refs)
result = rollout(platform, policy, n_steps, seed=seed)
results[alpha] = {
'total_reward': result.total_reward,
'total_pnl': result.total_pnl,
'avg_conversion': result.avg_conversion,
'final_contamination': platform._hidden.contamination,
}
return results
def sweep_behavior_modes(base_cfg: ThesisConfig | None = None, n_steps: int = 100, seed: int = 42) -> dict[str, dict]:
"""Compare real vs synthetic behavioral models."""
from ...experiments.eval import rollout, fixed_price_policy
base_cfg = base_cfg or ThesisConfig()
modes = {
'real_mdp': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': True}),
'synthetic': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': False, 'use_separability': False}),
'real_mdp_no_sep': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': False}),
}
results = {}
for name, cfg in modes.items():
platform = make_thesis_platform(cfg)
policy = fixed_price_policy(platform.instruments.refs)
result = rollout(platform, policy, n_steps, seed=seed)
results[name] = {
'total_reward': result.total_reward,
'total_pnl': result.total_pnl,
'avg_conversion': result.avg_conversion,
}
return results

View File

@@ -1,136 +0,0 @@
#!/usr/bin/env python
"""Thesis simulation experiments with real MDP behavioral models."""
from __future__ import annotations
import sys
from pathlib import Path
if __name__ == '__main__':
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
from lab.case.thesis.platform import make_thesis_platform, ThesisConfig
from lab.case.thesis.metrics import compute_coi, compute_separability
from lab.experiments.eval import compare_policies
import numpy as np
def demo_basic_simulation():
print("=" * 70)
print("THESIS SIMULATION: Contaminated Dynamic Pricing (Real MDP Kernels)")
print("=" * 70)
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, lambda_coi=0.5,
max_steps=100, seed=42, use_real_behavior=True)
platform = make_thesis_platform(cfg)
print(f"\nInstruments: {platform.instruments.n}")
print(f"Reference prices: {platform.instruments.refs.round(2)}")
print(f"Costs: {platform.instruments.costs.round(2)}")
print(f"Initial contamination alpha={cfg.alpha_contamination}")
print(f"Using real behavior: {cfg.use_real_behavior}")
result = platform.reset(seed=42)
total_reward, coi_history = 0, []
print(f"\n{'Step':>5} {'Reward':>10} {'PnL':>10} {'COI':>8} {'alpha':>6} {'Conv':>8}")
print("-" * 55)
for t in range(cfg.max_steps):
action = platform.instruments.refs * np.random.uniform(0.95, 1.15, size=platform.instruments.n)
result = platform.step(action)
total_reward += result.reward
coi = compute_coi(platform._quote, platform.instruments, result.metrics, result.hidden.contamination)
coi_history.append(coi.coi_level)
if t % 20 == 0:
print(f"{t:5d} {result.reward:10.2f} {result.metrics.pnl:10.2f} "
f"{coi.coi_level:8.2f} {result.hidden.contamination:6.2f} {result.metrics.conversion:8.3f}")
print("-" * 55)
print(f"Total Reward: {total_reward:.2f}")
print(f"Average COI: {np.mean(coi_history):.2f}")
print(f"COI Trend: {coi_history[-1] - coi_history[0]:+.2f}")
def demo_contamination_sweep():
print("\n" + "=" * 70)
print("EXPERIMENT: COI Erosion vs Contamination (Theorem 1)")
print("=" * 70)
from lab.case.thesis.platform import sweep_contamination
trials = 20
alpha_values = [i/trials for i in range(trials)]
results = sweep_contamination(alpha_values, n_steps=100, seed=42)
print(f"\n{'alpha':>6} {'Reward':>12} {'PnL':>12} {'Conv':>10}")
print("-" * 45)
for alpha, m in sorted(results.items()):
print(f"{alpha:6.2f} {m['total_reward']:12.2f} {m['total_pnl']:12.2f} {m['avg_conversion']:10.3f}")
rewards = [results[a]['total_reward'] for a in sorted(results.keys())]
dataset = np.array([[a, r] for a, r in zip(alpha_values, rewards)])
trend = np.corrcoef(dataset[:, 0], dataset[:, 1])[0, 1]
print(f"Trend (alpha~reward correlation): {trend:.3f}")
def demo_policy_comparison():
print("\n" + "=" * 70)
print("EXPERIMENT: Policy Comparison under Contamination")
print("=" * 70)
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.25, max_steps=100, seed=42)
platform = make_thesis_platform(cfg)
def fixed_policy(obs, t): return platform.instruments.refs.copy(), 1.0
def aggressive_policy(obs, t): return platform.instruments.refs * 1.3, 1.0
def conservative_policy(obs, t): return platform.instruments.refs * 1.05, 1.0
def adaptive_policy(obs, t):
fills = obs[platform.instruments.n:2*platform.instruments.n]
exp = obs[2*platform.instruments.n:3*platform.instruments.n]
conv = np.sum(fills) / (np.sum(exp) + 1e-8)
return platform.instruments.refs * (1.0 + 0.2 * conv), 1.0
policies = {'fixed': fixed_policy, 'aggressive': aggressive_policy,
'conservative': conservative_policy, 'adaptive': adaptive_policy}
results = compare_policies(platform, policies, n_steps=100, n_runs=3, seed=42)
print(f"\n{'Policy':>15} {'Reward':>12} {'Std':>10} {'PnL':>12} {'Conv':>10}")
print("-" * 65)
for name, r in sorted(results.items(), key=lambda x: -x[1]['mean_reward']):
print(f"{name:>15} {r['mean_reward']:12.2f} {r['std_reward']:10.2f} "
f"{r['mean_pnl']:12.2f} {r['mean_conversion']:10.3f}")
def demo_session_analysis():
"""Analyze session-level behavior from MDP trajectories."""
print("\n" + "=" * 70)
print("EXPERIMENT: Session Analysis (Ground Truth)")
print("=" * 70)
from lab.outlet.constants import LogLevel
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, max_steps=50,
log_level=LogLevel.FULL, seed=42, use_real_behavior=True)
platform = make_thesis_platform(cfg)
result = platform.reset(seed=42)
human_sessions, agent_sessions = 0, 0
for t in range(cfg.max_steps):
action = platform.instruments.refs * 1.1
result = platform.step(action)
sep = compute_separability(result.logs, result.hidden.contamination)
human_sessions += sep.n_human_sessions
agent_sessions += sep.n_agent_sessions
total = human_sessions + agent_sessions
print(f"\nTotal sessions: {total}")
print(f"Human sessions: {human_sessions} ({100*human_sessions/total:.1f}%)")
print(f"Agent sessions: {agent_sessions} ({100*agent_sessions/total:.1f}%)")
print(f"True contamination: {cfg.alpha_contamination:.1%}")
print(f"Observed contamination: {agent_sessions/total:.1%}")
if __name__ == '__main__':
demo_basic_simulation()
demo_contamination_sweep()
# demo_policy_comparison()
# demo_session_analysis()

View File

@@ -1,156 +0,0 @@
"""
Configuration and factory functions for creating pre-configured platforms.
This module provides:
- RetailConfig, MarketMakingConfig: Configuration dataclasses
- make_retail_platform: Factory for retail dynamic pricing scenarios
- make_market_making_platform: Factory for market making scenarios
Example:
>>> from lab.config import make_retail_platform
>>> platform = make_retail_platform(RetailConfig(n_instruments=5))
>>> result = platform.reset(seed=42)
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
PostedPriceMechanism, TwoSidedMechanism, make_instruments,
InstrumentType, LogLevel)
from .outlet.mechanisms.posted_price import PostedPriceConfig
from .outlet.mechanisms.two_sided import TwoSidedConfig
from .population import (SessionArrivalModel, PoissonArrivalModel, HawkesArrivalModel,
ElasticityExecutionModel, IntensityExecutionModel,
ReactiveCompetitorModel, GBMMarketModel)
from .population.arrivals import SessionArrivalConfig, PoissonArrivalConfig, HawkesArrivalConfig
from .population.execution import ElasticityConfig, IntensityConfig
from .population.competitors import ReactiveCompetitorConfig, GBMMarketConfig
from .outlet.objectives.factory import retail_objective, market_making_objective
@dataclass
class RetailConfig:
"""Configuration for retail dynamic pricing scenario.
Attributes:
n_instruments: Number of products to price
cost_range: (min, max) for random product costs
margin_range: (min, max) for random initial margins
initial_inventory: Starting inventory per product
holding_cost_rate: Cost per unit per step for holding
sessions_per_step: Number of browsing sessions per step
contamination: Fraction of sessions that are scrapers
max_steps: Maximum episode length
seed: Random seed for reproducibility
"""
n_instruments: int = 10
cost_range: tuple[float, float] = (5.0, 50.0)
margin_range: tuple[float, float] = (0.2, 0.5)
initial_inventory: float = 100.0
holding_cost_rate: float = 0.002
sessions_per_step: int = 30
contamination: float = 0.1
max_steps: int = 500
seed: int | None = None
def make_retail_platform(cfg: RetailConfig | None = None) -> Platform:
"""Create a pre-configured retail dynamic pricing platform.
Components:
- Mechanism: PostedPriceMechanism (single price per product)
- Arrivals: SessionArrivalModel (browsing sessions with views)
- Execution: ElasticityExecutionModel (price sensitivity)
- Market: ReactiveCompetitorModel (can trigger price wars)
- Objective: PnL - holding_cost - volatility - lost_opportunity
Args:
cfg: Configuration (uses defaults if None)
Returns:
Configured Platform instance
"""
cfg = cfg or RetailConfig()
rng = np.random.default_rng(cfg.seed)
instruments = make_instruments(cfg.n_instruments, cfg.cost_range, cfg.margin_range,
InstrumentType.SKU, rng)
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
mechanism = PostedPriceMechanism(PostedPriceConfig())
arrival = SessionArrivalModel(SessionArrivalConfig(
sessions_per_step=cfg.sessions_per_step, contamination=cfg.contamination))
execution = ElasticityExecutionModel(ElasticityConfig())
position = PositionModel(PositionConfig(
initial_position=cfg.initial_inventory,
holding_cost_rate=cfg.holding_cost_rate))
market = ReactiveCompetitorModel(ReactiveCompetitorConfig(), refs=instruments.refs)
objective = retail_objective()
return Platform(
instruments=instruments, mechanism=mechanism, arrival=arrival,
execution=execution, position=position, market=market, objective=objective,
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
)
@dataclass
class MarketMakingConfig:
"""Configuration for market making scenario.
Attributes:
n_instruments: Number of assets to quote
initial_mid: Initial mid-price for assets
mu: Price drift (expected return)
sigma: Price volatility
gamma: Inventory risk aversion parameter
base_arrival_rate: Order arrival rate (Hawkes baseline)
max_steps: Maximum episode length
seed: Random seed for reproducibility
"""
n_instruments: int = 5
initial_mid: float = 100.0
mu: float = 0.0
sigma: float = 0.02
gamma: float = 0.1
base_arrival_rate: float = 20.0
max_steps: int = 1000
seed: int | None = None
def make_market_making_platform(cfg: MarketMakingConfig | None = None) -> Platform:
"""Create a pre-configured market making platform.
Components:
- Mechanism: TwoSidedMechanism (bid-ask spread quoting)
- Arrivals: HawkesArrivalModel (clustered order flow)
- Execution: IntensityExecutionModel (distance-based fills)
- Market: GBMMarketModel (geometric Brownian motion mid-prices)
- Objective: PnL + spread_capture - inventory_risk
Args:
cfg: Configuration (uses defaults if None)
Returns:
Configured Platform instance
"""
cfg = cfg or MarketMakingConfig()
rng = np.random.default_rng(cfg.seed)
instruments = make_instruments(cfg.n_instruments, (cfg.initial_mid*0.9, cfg.initial_mid*1.1),
(0.0, 0.0), InstrumentType.ASSET, rng)
instruments.position = np.zeros(cfg.n_instruments)
mechanism = TwoSidedMechanism(TwoSidedConfig())
arrival = HawkesArrivalModel(HawkesArrivalConfig(base_rate=cfg.base_arrival_rate))
execution = IntensityExecutionModel(IntensityConfig())
position = PositionModel(PositionConfig(
initial_position=0.0, min_position=-500, max_position=500,
holding_cost_rate=0.0)) # use inventory risk penalty instead
market = GBMMarketModel(GBMMarketConfig(mu=cfg.mu, sigma=cfg.sigma),
initial=instruments.refs)
objective = market_making_objective(gamma=cfg.gamma, sigma=cfg.sigma)
return Platform(
instruments=instruments, mechanism=mechanism, arrival=arrival,
execution=execution, position=position, market=market, objective=objective,
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
)

View File

@@ -1,12 +0,0 @@
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = _build
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

View File

@@ -1,39 +0,0 @@
import os
import sys
sys.path.insert(0, os.path.abspath('../..'))
project = 'Quote-Control Simulator'
copyright = '2025, PHANTOM Research'
author = 'PHANTOM Research'
release = '0.1.0'
extensions = [
'sphinx.ext.autodoc',
'sphinx.ext.napoleon',
'sphinx.ext.viewcode',
'sphinx.ext.intersphinx',
'sphinx.ext.autosummary',
]
templates_path = ['_templates']
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
html_theme = 'alabaster'
html_static_path = ['_static']
autodoc_default_options = {
'members': True,
'undoc-members': True,
'show-inheritance': True,
}
napoleon_google_docstring = True
napoleon_numpy_docstring = True
napoleon_include_init_with_doc = True
intersphinx_mapping = {
'python': ('https://docs.python.org/3', None),
'numpy': ('https://numpy.org/doc/stable/', None),
}
autosummary_generate = True

View File

@@ -1,40 +0,0 @@
Quote-Control Simulator
=======================
Research-grade platform for dynamic pricing and market making experiments.
The platform abstracts pricing as: **Quote → Arrival → Execution → Position**
Supports multiple mechanisms:
* **PostedPrice**: retail dynamic pricing
* **TwoSided**: market making with bid-ask spreads
* **Auction**: reserve/shading for auction settings
Quick Start
-----------
.. code-block:: python
from lab.config import make_retail_platform
from lab.experiments import rollout, fixed_price_policy
platform = make_retail_platform()
policy = fixed_price_policy(platform.instruments.refs)
result = rollout(platform, policy, n_steps=100)
print(f"Total PnL: {result.total_pnl:.2f}")
.. toctree::
:maxdepth: 2
:caption: Contents:
system_overview
modules/outlet
modules/population
modules/experiments
Indices
-------
* :ref:`genindex`
* :ref:`modindex`

View File

@@ -1,14 +0,0 @@
Experiments
===========
Evaluation & OPE
----------------
.. automodule:: lab.experiments.eval
:members:
Configuration
-------------
.. automodule:: lab.config
:members:

View File

@@ -1,77 +0,0 @@
Outlet (Core Simulator)
=======================
Types
-----
.. automodule:: lab.outlet.types
:members:
Constants
---------
.. automodule:: lab.outlet.constants
:members:
Protocols
---------
.. automodule:: lab.outlet.protocols
:members:
Platform
--------
.. automodule:: lab.outlet.platform
:members:
Stock & Position
----------------
.. automodule:: lab.outlet.stock
:members:
Observation
-----------
.. automodule:: lab.outlet.observation
:members:
Mechanisms
----------
Posted Price
~~~~~~~~~~~~
.. automodule:: lab.outlet.mechanisms.posted_price
:members:
Two-Sided (Market Making)
~~~~~~~~~~~~~~~~~~~~~~~~~
.. automodule:: lab.outlet.mechanisms.two_sided
:members:
Auction
~~~~~~~
.. automodule:: lab.outlet.mechanisms.auction
:members:
Objectives
----------
.. automodule:: lab.outlet.objectives.base
:members:
.. automodule:: lab.outlet.objectives.penalties
:members:
.. automodule:: lab.outlet.objectives.factory
:members:
Math Utilities
--------------
.. automodule:: lab.outlet.math_util
:members:

Some files were not shown because too many files have changed in this diff Show More