Compare commits
211 Commits
baseline-s
...
baseline-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 43bcad2a98 | |||
| 8404a88ef1 | |||
| 1c2935dc87 | |||
| 14aae3dc9a | |||
| a9f1e19488 | |||
| 4c7d911043 | |||
| be03b2d4d5 | |||
| ee32ab7d1d | |||
| 969ef4c363 | |||
| 3cc2dc40d5 | |||
| 529d00cb80 | |||
| b62d29cfaf | |||
| 77f45ed0b3 | |||
| 73a1dafc6e | |||
| 4c658a93a7 | |||
| 73246d7dd8 | |||
| 9fafb26ec8 | |||
| b7b871f9aa | |||
| 840a13ca4a | |||
| 04fa7cbab5 | |||
| ec7486ee85 | |||
| 916e72f0ff | |||
| 69b2d5aceb | |||
| 28dbcacd95 | |||
| 17c128cbc0 | |||
| cc24ac72f7 | |||
| 4b89b64674 | |||
| ec880db444 | |||
| 803e3a2972 | |||
| 233ce3be34 | |||
|
|
8f20359c8c | ||
| 56585b3de8 | |||
| 5444a4ea13 | |||
| e8a9716f69 | |||
| e50d643fbf | |||
|
|
dac1e58a0d | ||
| 4667a1678f | |||
| a4b7b5b4b2 | |||
| 1a9901f118 | |||
| 5912062dc0 | |||
| 843564eeb0 | |||
| 9acc998cc9 | |||
| 802f31b4a1 | |||
| 66c4a0cd1d | |||
| 244af9ac09 | |||
| 76c31a2abd | |||
| 64ee7e6d9b | |||
| 1e04a928aa | |||
| 9b133cddfd | |||
| ded7290935 | |||
| 8e4dd59f90 | |||
| 024f6d4132 | |||
| 2b47c3499a | |||
| ef1d1f6557 | |||
| d7657db287 | |||
| e8229ac313 | |||
| bc6c481d03 | |||
| 895eea5674 | |||
| fba2a9739e | |||
| d1aa13360f | |||
| f6f9729424 | |||
| 29a13340b9 | |||
| e22286371f | |||
| e44feb7da0 | |||
| ebd2378859 | |||
| c4d82b2ecc | |||
| a9e2e7cbf3 | |||
| e0b074161b | |||
| 08c0afb55a | |||
| c4fd1352c9 | |||
| 4abef97bf7 | |||
| 33cb0d7e95 | |||
| e8ef850089 | |||
| e7cb48e9cd | |||
|
|
dba8f3fafa | ||
|
|
9843c5deab | ||
| 13959e4b28 | |||
|
|
2f481bd94b | ||
| 72877439ca | |||
| 0f5f8affab | |||
| ee70f02a1f | |||
| 22a2c255bd | |||
| ccc19f3493 | |||
| 00e3eff2fa | |||
| 440371dba4 | |||
| b05b510f70 | |||
| 04907df393 | |||
| b2f0746c01 | |||
| 7b2d80ac4c | |||
| 0ce12fbc3b | |||
| e9cf5f0736 | |||
| 82b54428b7 | |||
| 87a35fad2c | |||
| af23d2f736 | |||
| 9cb2b0fc44 | |||
| 7c330a19c6 | |||
|
|
eb95060380 | ||
| 61dd621532 | |||
| 4c368d48f2 | |||
| 3c141a4b6c | |||
| e89cb263d4 | |||
| 62a4008c29 | |||
| 8b429b7a8e | |||
| f9bf3de71e | |||
| 131323ef56 | |||
| ec4cf074e6 | |||
| 6a06a8af4a | |||
| 3fa98f375d | |||
| 201c98bcac | |||
| 8a08458478 | |||
| 7d09232e48 | |||
| 20132c084c | |||
| 26abff5864 | |||
| 4c7d9362af | |||
| ea45801845 | |||
|
|
574e05d9e0 | ||
| 52fe865598 | |||
| 28d3f6853e | |||
| 10e8397eec | |||
| 772772b5b9 | |||
| 6e06081d60 | |||
| 83d9bb2552 | |||
| fa2aca8b13 | |||
| cd6c3d6006 | |||
|
|
b5f19e04b7 | ||
| 98a9a3738c | |||
| 1224841a82 | |||
| 4033e73ba1 | |||
| bae51daa1c | |||
| c5eae17924 | |||
| 28669ea4c3 | |||
| b0a1647956 | |||
| 19bb4fd517 | |||
| 4e2e41d943 | |||
| a033e77697 | |||
| 40e0b201e6 | |||
| a217d53556 | |||
| a6e6cc5d60 | |||
| fa89347c4e | |||
| 2b3d937be6 | |||
| 20c47fe85f | |||
| b7161573d7 | |||
| c15bb1882e | |||
| dee6f573e3 | |||
| 2ed200f870 | |||
| 56308ecb10 | |||
| 7fcd18c3cb | |||
| 5f607a58eb | |||
| 6aad196234 | |||
| e5060babfa | |||
| 80863e9b17 | |||
| a5029f2eab | |||
| c102ac482e | |||
| 08ade8dc89 | |||
| 95d4f0cee2 | |||
|
|
a9d73ccce5 | ||
| 3072e5f46e | |||
| a1e3166322 | |||
| 6f361b96a8 | |||
| eea019ab3f | |||
| a36973cb42 | |||
| 96180e9af1 | |||
|
|
e60c0c64e1 | ||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d | |||
|
|
221e71a503 | ||
|
|
f2271e368e | ||
|
|
a1916c966c | ||
|
|
a2a443c027 | ||
|
|
ef98141ca8 | ||
| d45b344264 | |||
| a0b956b242 | |||
|
|
8751583764 | ||
| 59d4fb7891 | |||
| 7c2a819122 | |||
| 5941ffd085 | |||
| 955102090d | |||
| d654bbf4b4 | |||
|
|
ad9423bf59 | ||
|
|
2a0e44ab24 | ||
|
|
c432c45343 | ||
|
|
8b76d24ade | ||
|
|
894ce87a5d | ||
|
|
ab8b8787a8 | ||
| 9bb6f842f4 | |||
| 53a39b07dd | |||
| 4acfb019f8 | |||
|
|
37b2099ee0 | ||
|
|
7ece6e82cb | ||
| 6b7060450c | |||
|
|
f6e780fdf1 | ||
|
|
f427943ebc | ||
| aa98b2d169 | |||
| 5777437540 | |||
| e677170fd2 | |||
|
|
811c4334e1 | ||
|
|
e02be30748 |
17
.dockerignore
Normal 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
|
||||
21
.env.example
@@ -1,5 +1,18 @@
|
||||
HOSTNAME=localhost
|
||||
# Network configuration
|
||||
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||
|
||||
# PORTS
|
||||
KAFKA_PORT=9092
|
||||
REDIS_PORT=6377
|
||||
# Application configuration
|
||||
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
||||
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
|
||||
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
|
||||
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
|
||||
|
||||
# Backend service
|
||||
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
|
||||
|
||||
# Service ports - used by docker-compose and service communication
|
||||
BACKEND_PORT=5000 # backend server port for kafka ingestion API
|
||||
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
|
||||
KAFKA_PORT=9092 # kafka broker port for event streaming
|
||||
REDIS_PORT=6377 # redis port for worker queue and caching
|
||||
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
|
||||
|
||||
24
.env.sweep.example
Normal 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
|
||||
163
.github/workflows/latex.yml
vendored
@@ -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
|
||||
|
||||
30
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Run Tests
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'experiments/**'
|
||||
- 'backend/**'
|
||||
- 'requirements.txt'
|
||||
- '.github/workflows/pytest.yml'
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: 'pip'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
- name: Run tests
|
||||
run: .venv/bin/pytest -v
|
||||
89
.gitignore
vendored
@@ -1,2 +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
|
||||
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/*
|
||||
|
||||
190
Makefile
@@ -4,36 +4,184 @@ BUILDDIR := build
|
||||
TEX := main.tex
|
||||
JOBNAME := main
|
||||
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
||||
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
|
||||
|
||||
all: pdf
|
||||
|
||||
run.webapp:
|
||||
@cd web && npm install && npm run dev
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines"
|
||||
@echo "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)
|
||||
|
||||
pdf: $(BUILDDIR)
|
||||
@echo "Concatenating source code..."
|
||||
@bash paper/concat_code.sh
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
.PHONY: pdf.build
|
||||
pdf.build:
|
||||
@$(NX) run paper:build
|
||||
|
||||
watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
.PHONY: pdf.watch
|
||||
pdf.watch:
|
||||
@$(NX) run paper:watch
|
||||
|
||||
.PHONY: pdf.clean
|
||||
pdf.clean:
|
||||
@$(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:
|
||||
@$(NX) run research:test
|
||||
|
||||
.PHONY: test.e2e
|
||||
test.e2e:
|
||||
@$(NX) run e2e:test
|
||||
|
||||
.PHONY: test.all
|
||||
test.all:
|
||||
@$(NX) run-many -t test --projects=research,e2e --parallel=1
|
||||
|
||||
.PHONY: web.dev
|
||||
web.dev:
|
||||
@$(NX) run web:dev
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
.PHONY: install
|
||||
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:
|
||||
@$(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:
|
||||
@$(NX) run paper:build
|
||||
|
||||
clean:
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
rm -rf paper/$(BUILDDIR)/*
|
||||
@$(NX) run paper:clean
|
||||
|
||||
watch:
|
||||
@$(NX) run paper:watch
|
||||
|
||||
.PHONY: all pdf clean watch run.webapp
|
||||
run.webapp:
|
||||
@$(NX) run web:dev
|
||||
|
||||
test:
|
||||
@$(NX) run research:test
|
||||
|
||||
count-lines:
|
||||
@$(NX) run research:stats
|
||||
|
||||
all:
|
||||
@$(NX) run paper:build
|
||||
|
||||
93
README.md
@@ -1 +1,94 @@
|
||||
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||
|
||||
### PHANTOM
|
||||
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||
[](https://sites.research.google/trc/faq/)
|
||||
[](https://phantom-hotel.vercel.app)
|
||||
[](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 segment’s 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
@@ -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
|
||||
22
TPUS/v4_32_spot_uscentral2b.sh
Normal 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
@@ -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}
|
||||
22
TPUS/v5e_64_spot_europewest4b.sh
Normal 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
|
||||
22
TPUS/v5e_64_spot_uscentral1a.sh
Normal 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
|
||||
22
TPUS/v6e_64_spot_europewest4a.sh
Normal 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
|
||||
22
TPUS/v6e_64_spot_useast1d.sh
Normal 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
@@ -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"
|
||||
]
|
||||
}
|
||||
112
backend/provider/app.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional
|
||||
import uvicorn, os, sys
|
||||
from supabase import create_client, Client
|
||||
from dotenv import load_dotenv
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
load_dotenv()
|
||||
|
||||
# Local imports of registry and pricing function
|
||||
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../experiments/")
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.pricers import (
|
||||
StaticPricer,
|
||||
RandomPricer,
|
||||
ElasticityBasedPricer
|
||||
)
|
||||
from procesing.steps import (
|
||||
PredictPricesStep
|
||||
)
|
||||
from procesing import PipelineContext
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
# Config
|
||||
app = FastAPI(title="PHANTOM Pricing Provider")
|
||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||
|
||||
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
||||
registry = ModelRegistry()
|
||||
|
||||
class PriceResponse(BaseModel):
|
||||
productId: str
|
||||
price: float
|
||||
base_price: float
|
||||
markup: float
|
||||
elasticity: Optional[float] = None
|
||||
model_version: str = 'latest'
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "healthy", "redis": registry.health_check()}
|
||||
|
||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||
"""
|
||||
THIS is the fast lookup service (mechanism).
|
||||
Priority: session-keyed price > global optimal price > base price
|
||||
"""
|
||||
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
# PRIORITY 1: session-aware price (computed by Airflow worker)
|
||||
if sessionId:
|
||||
session_price = registry.get_session_price(sessionId, productId)
|
||||
if session_price is not None:
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=session_price,
|
||||
base_price=base_price,
|
||||
markup=session_price/base_price,
|
||||
elasticity=None,
|
||||
model_version='session-aware'
|
||||
)
|
||||
|
||||
# PRIORITY 2: global pre-computed prices (surge pricing)
|
||||
prices_df = registry.get_prices('latest')
|
||||
if prices_df is not None:
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if not product_price_row.empty:
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=None,
|
||||
model_version='surge'
|
||||
)
|
||||
|
||||
# PRIORITY 3: fallback to base price
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None,
|
||||
model_version='base'
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
def list_models(): return registry.list_models()
|
||||
|
||||
@app.post("/models/reload")
|
||||
def reload_models():
|
||||
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
|
||||
return {
|
||||
"elasticity_loaded": bool(elasticity),
|
||||
"n_products": len(elasticity) if elasticity is not None else 0,
|
||||
"pricing_model_loaded": bool(pricing_model),
|
||||
"model_class": pricing_model.__class__.__name__ if pricing_model else None
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))
|
||||
39
backend/provider/project.json
Normal 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"
|
||||
]
|
||||
}
|
||||
16
backend/provider/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
fastapi
|
||||
uvicorn[standard]
|
||||
pydantic
|
||||
numpy
|
||||
pandas
|
||||
scikit-learn
|
||||
redis
|
||||
supabase
|
||||
confluent-kafka>=2.3.0
|
||||
kafka-python
|
||||
graphviz
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
typing-extensions>=4.8.0
|
||||
pypickle
|
||||
pymc
|
||||
367
backend/server/app.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# boilerplate code
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Any
|
||||
import uvicorn
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime
|
||||
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
||||
from kafka.admin import NewTopic
|
||||
from kafka.errors import TopicAlreadyExistsError
|
||||
from dotenv import load_dotenv
|
||||
from supabase import create_client, Client
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# kafka producer - lazy init
|
||||
_producer: Optional[KafkaProducer] = None
|
||||
|
||||
# supabase client - lazy init
|
||||
_supabase: Optional[Client] = None
|
||||
|
||||
def get_supabase() -> Client:
|
||||
global _supabase
|
||||
if _supabase is None:
|
||||
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
||||
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||
if not url or not key:
|
||||
raise ValueError("Supabase credentials not configured")
|
||||
_supabase = create_client(url, key)
|
||||
return _supabase
|
||||
|
||||
def get_producer() -> KafkaProducer:
|
||||
global _producer
|
||||
if _producer is None:
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}' if port else host
|
||||
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
||||
_producer = KafkaProducer(
|
||||
bootstrap_servers=[broker],
|
||||
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
||||
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
||||
acks=1,
|
||||
retries=3,
|
||||
max_in_flight_requests_per_connection=5,
|
||||
request_timeout_ms=30000,
|
||||
api_version_auto_timeout_ms=10000,
|
||||
max_block_ms=5000, # don't block send() for more than 5s
|
||||
)
|
||||
print(f"[KAFKA_INIT] Producer created successfully")
|
||||
return _producer
|
||||
|
||||
class EventPayload(BaseModel):
|
||||
sessionId: str
|
||||
experimentId: Optional[str] = None
|
||||
eventName: str
|
||||
page: str
|
||||
productId: Optional[str] = None
|
||||
metadata: Optional[dict[str, Any]] = None
|
||||
storeMode: str
|
||||
userAgent: Optional[str] = None
|
||||
ts: Optional[str] = None
|
||||
|
||||
class PriceLogPayload(BaseModel):
|
||||
productId: str
|
||||
price: float
|
||||
sessionId: str
|
||||
experimentId: Optional[str] = None
|
||||
storeMode: str
|
||||
ts: Optional[str] = None
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""create kafka topics on startup"""
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
print(f"[STARTUP] Creating Kafka topics on {broker}")
|
||||
admin = KafkaAdminClient(
|
||||
bootstrap_servers=[broker],
|
||||
request_timeout_ms=10000,
|
||||
)
|
||||
|
||||
topics = [
|
||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||
]
|
||||
|
||||
admin.create_topics(new_topics=topics, validate_only=False)
|
||||
print(f"[STARTUP] Topics created successfully")
|
||||
admin.close()
|
||||
except TopicAlreadyExistsError:
|
||||
print(f"[STARTUP] Topics already exist, skipping creation")
|
||||
except Exception as e:
|
||||
print(f"[STARTUP] Failed to create topics: {e}")
|
||||
print(f"[STARTUP] Will rely on auto-creation on first message")
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
kafka_status = "unknown"
|
||||
try:
|
||||
producer = get_producer()
|
||||
# attempt to get cluster metadata to verify connection
|
||||
producer.bootstrap_connected()
|
||||
kafka_status = "connected"
|
||||
except Exception as e:
|
||||
kafka_status = f"error: {str(e)}"
|
||||
|
||||
return {
|
||||
"status": "healthy",
|
||||
"kafka": kafka_status,
|
||||
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/kafka/ingest")
|
||||
async def ingest_logs(event: EventPayload):
|
||||
try:
|
||||
if not event.ts:
|
||||
event.ts = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
producer = get_producer()
|
||||
future = producer.send(
|
||||
'user-interactions',
|
||||
key=event.sessionId,
|
||||
value=event.model_dump()
|
||||
)
|
||||
# add callback for error logging but don't block
|
||||
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post("/api/kafka/price-log")
|
||||
async def ingest_price_log(price_log: PriceLogPayload):
|
||||
try:
|
||||
if not price_log.ts:
|
||||
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
||||
|
||||
producer = get_producer()
|
||||
future = producer.send(
|
||||
'price-logs',
|
||||
key=price_log.productId,
|
||||
value=price_log.model_dump()
|
||||
)
|
||||
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
||||
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRICE_LOG_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/kafka/dump")
|
||||
def dump_logs(
|
||||
topic: str = 'user-interactions',
|
||||
last_n: Optional[int] = None,
|
||||
t_start: Optional[str] = None,
|
||||
t_end: Optional[str] = None
|
||||
):
|
||||
"""dump all messages from specified kafka topic
|
||||
|
||||
params:
|
||||
topic: kafka topic to dump (default: user-interactions)
|
||||
last_n: return only last n messages (default: all)
|
||||
t_start: filter by start timestamp iso format
|
||||
t_end: filter by end timestamp iso format
|
||||
"""
|
||||
if topic not in ['user-interactions', 'price-logs']:
|
||||
raise HTTPException(status_code=400, detail="Invalid topic")
|
||||
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{host}:{port}'
|
||||
|
||||
try:
|
||||
consumer = KafkaConsumer(
|
||||
topic,
|
||||
bootstrap_servers=[broker],
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=30000,
|
||||
fetch_max_wait_ms=10000,
|
||||
max_poll_records=1000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if last_n and len(events) >= last_n * 2:
|
||||
break
|
||||
|
||||
consumer.close()
|
||||
|
||||
# apply filters
|
||||
if t_start or t_end:
|
||||
filtered = []
|
||||
for e in events:
|
||||
ts = e.get('ts')
|
||||
if ts:
|
||||
if t_start and ts < t_start:
|
||||
continue
|
||||
if t_end and ts > t_end:
|
||||
continue
|
||||
filtered.append(e)
|
||||
events = filtered
|
||||
|
||||
if last_n and last_n > 0:
|
||||
events = events[-last_n:]
|
||||
|
||||
return {"success": True, "count": len(events), "data": events}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[DUMP_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/products/{product_id}")
|
||||
async def get_product_by_id(product_id: str):
|
||||
"""fetch single product by id from either hotel_products or airline_products"""
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
|
||||
# try hotel_products first
|
||||
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
|
||||
if response.data and len(response.data) > 0:
|
||||
return {"success": True, "data": response.data[0]}
|
||||
|
||||
# try airline_products
|
||||
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
|
||||
if response.data and len(response.data) > 0:
|
||||
return {"success": True, "data": response.data[0]}
|
||||
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRODUCT_BY_ID_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get("/api/products/type/{product_type}")
|
||||
async def get_products(
|
||||
product_type: str,
|
||||
dateIndex: Optional[int] = None,
|
||||
origin: Optional[str] = None,
|
||||
destination: Optional[str] = None,
|
||||
tripType: Optional[str] = None,
|
||||
adults: Optional[int] = None,
|
||||
children: Optional[int] = None,
|
||||
infants: Optional[int] = None,
|
||||
rooms: Optional[int] = None
|
||||
):
|
||||
"""fetch products from supabase based on type (hotel or airline)
|
||||
|
||||
params:
|
||||
product_type: either 'hotel' or 'airline'
|
||||
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
||||
origin: (airline) departure airport code
|
||||
destination: (airline/hotel) arrival airport or hotel location
|
||||
tripType: (airline) roundtrip, oneway, multicity
|
||||
adults, children, infants: passenger counts
|
||||
rooms: (hotel) number of rooms
|
||||
"""
|
||||
if product_type not in ['hotel', 'airline']:
|
||||
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
|
||||
|
||||
try:
|
||||
supabase = get_supabase()
|
||||
table = f'{product_type}_products'
|
||||
|
||||
query = supabase.table(table).select('*')
|
||||
|
||||
# filter by exact date_index if provided
|
||||
# dateIndex from frontend is days from today, convert to days since epoch
|
||||
if dateIndex is not None:
|
||||
query = query.eq('date_index', dateIndex)
|
||||
|
||||
response = query.execute()
|
||||
results = response.data
|
||||
|
||||
# apply in-memory filters based on metadata for airline products
|
||||
if product_type == 'airline' and results:
|
||||
filtered = []
|
||||
for product in results:
|
||||
metadata = product.get('metadata', {})
|
||||
|
||||
# filter by origin airport
|
||||
if origin:
|
||||
dep = metadata.get('departure', {})
|
||||
if dep.get('airport') != origin:
|
||||
continue
|
||||
|
||||
# filter by destination airport
|
||||
if destination:
|
||||
arr = metadata.get('arrival', {})
|
||||
if arr.get('airport') != destination:
|
||||
continue
|
||||
|
||||
# passenger count validation (ensure total capacity)
|
||||
if adults is not None or children is not None or infants is not None:
|
||||
total_pax = (adults or 0) + (children or 0) + (infants or 0)
|
||||
avail = product.get('availability', 0)
|
||||
if avail < total_pax:
|
||||
continue
|
||||
|
||||
filtered.append(product)
|
||||
|
||||
results = filtered
|
||||
|
||||
# apply in-memory filters for hotel products
|
||||
elif product_type == 'hotel' and results:
|
||||
filtered = []
|
||||
for product in results:
|
||||
metadata = product.get('metadata', {})
|
||||
|
||||
# filter by occupancy capacity
|
||||
if adults is not None:
|
||||
max_occ = metadata.get('max_occupancy', 2)
|
||||
if max_occ < adults:
|
||||
continue
|
||||
|
||||
# filter by room availability
|
||||
if rooms is not None:
|
||||
avail = product.get('availability', 0)
|
||||
if avail < rooms:
|
||||
continue
|
||||
|
||||
filtered.append(product)
|
||||
|
||||
results = filtered
|
||||
|
||||
return {"success": True, "count": len(results), "data": results}
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[PRODUCTS_ERROR] {e}")
|
||||
print(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
||||
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||
39
backend/server/project.json
Normal 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"
|
||||
]
|
||||
}
|
||||
6
backend/server/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
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
|
||||
39
backend/worker/project.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
backend/worker/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
celery>=5.3,<6
|
||||
python-dotenv>=1.0.0
|
||||
redis>=5.0.0
|
||||
@@ -1,15 +1,57 @@
|
||||
services:
|
||||
tensorboard-rl:
|
||||
image: tensorflow/tensorflow:latest
|
||||
container_name: "PHANTOM-tensorboard-rl"
|
||||
ports:
|
||||
- "6007:6006"
|
||||
volumes:
|
||||
- ./sim/rl/runs:/logs
|
||||
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||
restart: unless-stopped
|
||||
|
||||
tensorboard-ml:
|
||||
image: tensorflow/tensorflow:latest
|
||||
container_name: "PHANTOM-tensorboard-ml"
|
||||
ports:
|
||||
- "6006:6006"
|
||||
volumes:
|
||||
- ./experiments/ml/runs:/logs
|
||||
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/backend.Dockerfile
|
||||
ports:
|
||||
- "${BACKEND_PORT:-5000}:5000"
|
||||
environment:
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_PORT=5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
depends_on:
|
||||
- kafka
|
||||
restart: unless-stopped
|
||||
|
||||
redis:
|
||||
container_name: "PHANTOM-redis"
|
||||
image: redis:7-alpine
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Redis.dockerfile
|
||||
ports:
|
||||
- "${REDIS_PORT:-6378}:6379"
|
||||
volumes:
|
||||
- phantom_redis_data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
zookeeper:
|
||||
container_name: "PHANTOM-zookeeper"
|
||||
image: confluentinc/cp-zookeeper:latest
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Zookeeper.dockerfile
|
||||
environment:
|
||||
ZOOKEEPER_CLIENT_PORT: 2181
|
||||
ports:
|
||||
@@ -17,7 +59,9 @@ services:
|
||||
|
||||
kafka:
|
||||
container_name: "PHANTOM-kafka"
|
||||
image: confluentinc/cp-kafka:7.5.0
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: Kafka.dockerfile
|
||||
depends_on:
|
||||
- zookeeper
|
||||
environment:
|
||||
@@ -36,7 +80,9 @@ services:
|
||||
|
||||
redpanda-console:
|
||||
container_name: "PHANTOM-redpanda-console"
|
||||
image: docker.redpanda.com/redpandadata/console:latest
|
||||
build:
|
||||
context: ./docker
|
||||
dockerfile: RedpandaConsole.dockerfile
|
||||
depends_on:
|
||||
- kafka
|
||||
environment:
|
||||
@@ -45,6 +91,149 @@ services:
|
||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
container_name: "PHANTOM-postgres"
|
||||
image: postgres:13
|
||||
environment:
|
||||
- POSTGRES_USER=airflow
|
||||
- POSTGRES_PASSWORD=airflow
|
||||
- POSTGRES_DB=airflow
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
airflow-init:
|
||||
container_name: "PHANTOM-airflow-init"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- _AIRFLOW_DB_MIGRATE=true
|
||||
- _AIRFLOW_WWW_USER_CREATE=true
|
||||
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
command: version
|
||||
restart: "no"
|
||||
|
||||
airflow-webserver:
|
||||
container_name: "PHANTOM-airflow-webserver"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
- postgres
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
ports:
|
||||
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||
command: webserver
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
airflow-scheduler:
|
||||
container_name: "PHANTOM-airflow-scheduler"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Airflow.dockerfile
|
||||
depends_on:
|
||||
airflow-webserver:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
command: scheduler
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
pricing-provider:
|
||||
container_name: "PHANTOM-pricing-provider"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/Provider.dockerfile
|
||||
depends_on:
|
||||
- redis
|
||||
- kafka
|
||||
environment:
|
||||
- PROVIDER_PORT=5001
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- BACKEND_URL=http://localhost:5000
|
||||
ports:
|
||||
- "${PROVIDER_PORT:-5001}:5001"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
phantom_kafka_data:
|
||||
phantom_redis_data:
|
||||
postgres_data:
|
||||
|
||||
30
docker/Airflow.dockerfile
Normal file
@@ -0,0 +1,30 @@
|
||||
FROM apache/airflow:2.7.3-python3.11
|
||||
|
||||
USER root
|
||||
|
||||
# install system deps if needed
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER airflow
|
||||
|
||||
# copy requirements for pipeline dependencies
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
# install postgres driver and providers
|
||||
RUN pip install --no-cache-dir \
|
||||
psycopg2-binary \
|
||||
apache-airflow-providers-postgres
|
||||
|
||||
# set airflow home
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
# create logs and plugins dirs (airflow expects them)
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
41
docker/Airflow.railway.dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
FROM apache/airflow:2.7.3-python3.11
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
supervisor \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER airflow
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
psycopg2-binary \
|
||||
apache-airflow-providers-postgres
|
||||
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
|
||||
# copy all code into image (standalone - no volume mounts needed)
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
|
||||
# copy entrypoint script
|
||||
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||
USER root
|
||||
RUN chmod +x /entrypoint.sh
|
||||
USER airflow
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
7
docker/Kafka.dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM confluentinc/cp-kafka:7.5.0
|
||||
|
||||
# Expose Kafka ports
|
||||
# 9092: External client connections
|
||||
# 29092: Internal broker communication
|
||||
# 9999: JMX monitoring port
|
||||
EXPOSE 9092 29092 9999
|
||||
26
docker/Provider.dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies including graphviz
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
graphviz \
|
||||
libgraphviz-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy and install Python dependencies
|
||||
COPY backend/provider/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code into image
|
||||
COPY lib/ /app/lib/
|
||||
COPY experiments/procesing/ /app/procesing/
|
||||
COPY backend/provider/ /app/provider/
|
||||
|
||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||
|
||||
WORKDIR /app/provider
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
4
docker/Redis.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM redis:7-alpine
|
||||
|
||||
# Expose Redis port
|
||||
EXPOSE 6379
|
||||
4
docker/RedpandaConsole.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM docker.redpanda.com/redpandadata/console:latest
|
||||
|
||||
# Expose Redpanda Console web UI port
|
||||
EXPOSE 8080
|
||||
15
docker/Trainer.dockerfile
Normal 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"]
|
||||
4
docker/Zookeeper.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
||||
FROM confluentinc/cp-zookeeper:latest
|
||||
|
||||
# Expose Zookeeper client port
|
||||
EXPOSE 2181
|
||||
20
docker/airflow-railway-entrypoint.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# init db and create admin user on first run
|
||||
airflow db migrate
|
||||
|
||||
# create admin user if not exists
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||
--firstname Admin \
|
||||
--lastname User \
|
||||
--role Admin \
|
||||
--email admin@example.com || true
|
||||
|
||||
# start scheduler in background
|
||||
airflow scheduler &
|
||||
|
||||
# start webserver in foreground (Railway needs one foreground process)
|
||||
exec airflow webserver --port ${PORT:-8080}
|
||||
12
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY backend/server/requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/server/app.py .
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||
23
docker/trainer-agent-entrypoint.sh
Normal 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 "$@"
|
||||
7
docker/trainer.requirements.txt
Normal 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
@@ -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."
|
||||
|
@@ -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
@@ -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̂(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̂' || T̄</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̂' || T̄</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 & 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̂<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 |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
BIN
docs/static/videos/BehaviorKernelConstructionScene.mp4
vendored
Normal file
BIN
docs/static/videos/COIFirstPrinciplesScene.mp4
vendored
Normal file
BIN
docs/static/videos/COIOrderStatisticProofScene.mp4
vendored
Normal file
BIN
docs/static/videos/CardMarketAnalogyScene.mp4
vendored
Normal file
BIN
docs/static/videos/ContaminationGeneratorScene.mp4
vendored
Normal file
BIN
docs/static/videos/DefenseOpening.mp4
vendored
Normal file
BIN
docs/static/videos/ObjectiveAndResultsScene.mp4
vendored
Normal file
BIN
docs/static/videos/RobustControlScene.mp4
vendored
Normal file
BIN
docs/static/videos/SeparabilitySignalScene.mp4
vendored
Normal file
BIN
docs/static/videos/SystemLoopScene.mp4
vendored
Normal file
BIN
docs/static/videos/TakeawayScene.mp4
vendored
Normal file
1
engine/backends/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__all__ = ["evaluate", "make_env", "train_qtable", "train_sb3"]
|
||||
152
engine/backends/common.py
Normal 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
@@ -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
@@ -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
@@ -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()
|
||||
105
engine/engine.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from sys import platform
|
||||
import numpy as np
|
||||
from .lib.demand import generate_demand_for_actor, estimate_demand
|
||||
from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions
|
||||
from logging import INFO, getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.setLevel(INFO)
|
||||
|
||||
|
||||
class MarketEngine:
|
||||
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
alpha: 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.human_params = human_params
|
||||
self.agent_params = agent_params
|
||||
self.noise_std = noise_std
|
||||
self.demand_dist = demand_distribution
|
||||
self.action_weights = action_weights
|
||||
|
||||
def act(self, prices):
|
||||
# generate separate demands d() per actor type
|
||||
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,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def act(self, demand):
|
||||
return np.random.uniform(low=25, high=100, size=10)
|
||||
|
||||
|
||||
class Limbo:
|
||||
def __init__(self, platform, market) -> None:
|
||||
self.platform_turn = True
|
||||
self.platform = platform
|
||||
self.market = market
|
||||
self.output = None
|
||||
|
||||
def step(self):
|
||||
if self.platform_turn:
|
||||
self.output = self.platform.act(self.output)
|
||||
else:
|
||||
self.output = self.market.act(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(
|
||||
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
|
||||
)
|
||||
limbo = Limbo(platform, market)
|
||||
for _ in range(10):
|
||||
limbo.step()
|
||||
38
engine/lib/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
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
|
||||
141
engine/lib/behavior.py
Normal file
@@ -0,0 +1,141 @@
|
||||
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_for_actor
|
||||
|
||||
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):
|
||||
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
|
||||
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(),
|
||||
)
|
||||
|
||||
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
|
||||
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
||||
new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)]
|
||||
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
||||
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||
|
||||
|
||||
def get_adjusted_transitions(condition, human=True):
|
||||
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 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_for_actor(np.array([10, 20, 30])), human=True)
|
||||
print(t)
|
||||
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
|
||||
print(t)
|
||||
148
engine/lib/callbacks.py
Normal 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
@@ -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())
|
||||
)
|
||||
92
engine/lib/demand.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import numpy as np
|
||||
|
||||
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)
|
||||
return demand / total * 100 if total > 0 else demand
|
||||
|
||||
|
||||
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 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])
|
||||
# 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, 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)
|
||||
70
engine/lib/discrete.py
Normal 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
@@ -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",
|
||||
)
|
||||
165
engine/lib/render.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""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)
|
||||
|
||||
|
||||
class DashboardRenderer:
|
||||
"""stateful renderer for PHANTOM market dynamics visualization"""
|
||||
|
||||
def __init__(self):
|
||||
self.fig = None
|
||||
self.gs = None
|
||||
|
||||
def render(self, env) -> None:
|
||||
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,
|
||||
)
|
||||
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",
|
||||
)
|
||||
|
||||
demand_mat = np.array(env._demand_history).T
|
||||
price_mat = np.array(env._price_history).T
|
||||
elasticity = env._compute_elasticity()
|
||||
|
||||
self._render_scatter(env)
|
||||
self._render_elasticity_bar(env, elasticity)
|
||||
self._render_session_pie(env)
|
||||
self._render_price_heatmap(price_mat)
|
||||
self._render_demand_heatmap(demand_mat)
|
||||
self._render_correlation(env.n_products, price_mat, demand_mat)
|
||||
self._render_revenue(env)
|
||||
|
||||
self.fig.canvas.draw_idle()
|
||||
self.fig.canvas.flush_events()
|
||||
|
||||
def _render_scatter(self, env):
|
||||
ax = self.fig.add_subplot(self.gs[0, 0])
|
||||
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",
|
||||
)
|
||||
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)
|
||||
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.set_yticks(range(env.n_products))
|
||||
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")
|
||||
|
||||
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")
|
||||
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)
|
||||
|
||||
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")
|
||||
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||
|
||||
def _render_correlation(self, n_products, price_mat, demand_mat):
|
||||
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")
|
||||
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)
|
||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||
style_axis(ax, "Price-Demand Correlation", None, None)
|
||||
|
||||
def _render_revenue(self, env):
|
||||
ax = self.fig.add_subplot(self.gs[2, 1:])
|
||||
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.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)",
|
||||
)
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def close(self):
|
||||
if self.fig:
|
||||
plt.close(self.fig)
|
||||
self.fig = None
|
||||
101
engine/lib/tiers.py
Normal 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
@@ -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
@@ -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
|
||||
5
engine/orchestrators/__init__.py
Normal 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"]
|
||||
7
engine/orchestrators/benchmark.py
Normal 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)
|
||||
60
engine/orchestrators/sweep_agent.py
Normal 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,
|
||||
)
|
||||
124
engine/orchestrators/train.py
Normal 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
@@ -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
@@ -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
|
||||
33
engine/studies/factors.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""shared factor definitions for experimental designs"""
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Factor:
|
||||
name: str
|
||||
levels: list
|
||||
primary: bool = True # full cross vs sampled
|
||||
|
||||
# demand functions with compatible signatures
|
||||
def demand_linear(mu, sigma, size): return np.maximum(0, np.random.normal(mu, sigma, size))
|
||||
def demand_uniform(mu, sigma, size): return np.random.uniform(mu - sigma, mu + sigma, size)
|
||||
def demand_exponential(mu, sigma, size): return np.random.exponential(mu, size)
|
||||
def demand_logistic(mu, sigma, size): return np.random.logistic(mu, sigma, size)
|
||||
|
||||
DEMAND_FUNCTIONS = {
|
||||
"linear": demand_linear,
|
||||
"uniform": demand_uniform,
|
||||
"exponential": demand_exponential,
|
||||
"logistic": demand_logistic,
|
||||
}
|
||||
|
||||
FACTORS = [
|
||||
Factor("demand_fn", list(DEMAND_FUNCTIONS.keys()), primary=True),
|
||||
Factor("alpha", [0.1, 0.3, 0.5, 0.7], primary=True),
|
||||
Factor("n_products", [5, 15, 30, 50], primary=True),
|
||||
Factor("demand_mu", [30.0, 50.0, 70.0], primary=False),
|
||||
Factor("demand_sigma", [5.0, 10.0, 20.0], primary=False),
|
||||
Factor("N", [100, 500, 1000], primary=False),
|
||||
]
|
||||
|
||||
SEEDS_PER_CONFIG = 5
|
||||