Compare commits
131 Commits
cleanup
...
a5d5919b40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d5919b40 | ||
| 01aac1d3c2 | |||
| 38eff1a4cc | |||
| b8b8748531 | |||
| b81d5f231f | |||
| 8c8a810a92 | |||
| fec9aa24fb | |||
| 172383b59f | |||
| 628ffdc464 | |||
| 0b1f59e49f | |||
| b677e80b80 | |||
| acf5bb5409 | |||
| 9bbacd6bdc | |||
| 29920aa56c | |||
| adf760162d | |||
|
|
d8907eb353 | ||
| d36a34ead9 | |||
| 03b4996bea | |||
| b69c3a87fd | |||
| 6427ae63ec | |||
| 5460f34426 | |||
| ece06a5a50 | |||
| f027ac1736 | |||
| 0fd1459011 | |||
| a3dc5125df | |||
| 3eea137f49 | |||
| 51de0cbdc5 | |||
| 895a004807 | |||
| 0ff0c0432c | |||
| 2ba5d6d6af | |||
| c0c375548c | |||
| e694d38bce | |||
| d5d8ea5870 | |||
| 840f80db39 | |||
| 8dac0905fc | |||
| 835e10d6ef | |||
| 02328b20f2 | |||
| eebd44db28 | |||
| ace52e8e14 | |||
| 47b07daa6c | |||
| 7e3bcf2520 | |||
| 97902f39a3 | |||
| 86c06176ae | |||
| 97a6bf3974 | |||
| e72e3c81c1 | |||
| cc823ec63c | |||
| 392f9b1549 | |||
| b287642ed0 | |||
| 5f0d3f4118 | |||
| e18a3e7363 | |||
| 291472295b | |||
| eab9203111 | |||
| 59b2b46f6e | |||
|
|
473342f103 | ||
| e77f037d62 | |||
| 9c464eaf3b | |||
| 58042ba4f2 | |||
| 18b41ff802 | |||
| 105b014976 | |||
| 220b6ce8c1 | |||
| 910dba0a7d | |||
| e62e842faa | |||
| 661a80b655 | |||
|
|
128911decc | ||
| ae2860a0ee | |||
| c87b800793 | |||
| 810d823710 | |||
| 8706072966 | |||
| f70c51f223 | |||
| 8aa4db1c9e | |||
| ee26954fae | |||
| fb09ea2b68 | |||
| 3439775fbd | |||
| 43b952cf2b | |||
| 2adb4f07b4 | |||
| e867c4d883 | |||
| a3e2a337ed | |||
| 63f1aad0b9 | |||
| 253364acae | |||
| 9642edd1b1 | |||
| c8df2e9cbd | |||
| 1393795359 | |||
| 375445f260 | |||
| 0521a63937 | |||
| a9c091050c | |||
| 52b4dcdce3 | |||
| 19b47aa699 | |||
| 88155d22a7 | |||
| b1f583be39 | |||
| 22e50aac4a | |||
| d748733231 | |||
| 9caad4de4e | |||
| 745792683e | |||
| 631b6d698c | |||
| d3a4febfde | |||
| fa2dde8307 | |||
| 0f708aab15 | |||
| 974498dab2 | |||
| 6d9613c0b6 | |||
|
|
ae6cffe825 | ||
| 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 |
@@ -3,7 +3,7 @@
|
|||||||
# Required for wandb runs and sweep agent workers.
|
# Required for wandb runs and sweep agent workers.
|
||||||
WANDB_API_KEY=
|
WANDB_API_KEY=
|
||||||
WANDB_ENTITY=
|
WANDB_ENTITY=
|
||||||
WANDB_PROJECT=phantom-pricing
|
WANDB_PROJECT=capstone
|
||||||
|
|
||||||
# Required for private repo bootstrap workers.
|
# Required for private repo bootstrap workers.
|
||||||
GITHUB_TOKEN=
|
GITHUB_TOKEN=
|
||||||
@@ -16,3 +16,9 @@ GITHUB_TOKEN=
|
|||||||
# AGENT_COUNT=0
|
# AGENT_COUNT=0
|
||||||
# AGENT_LOOP=1
|
# AGENT_LOOP=1
|
||||||
# RETRY_SECONDS=20
|
# 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
|
||||||
|
|||||||
133
.github/workflows/latex.yml
vendored
@@ -9,35 +9,103 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'paper/**'
|
- 'paper/**'
|
||||||
- '.github/**'
|
- '.github/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
skip_mirrors:
|
||||||
|
description: Skip Codex mirror generation (avoids API quota use)
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Compile LaTeX document
|
|
||||||
|
- name: Prepare appendix code snapshot
|
||||||
|
run: bash paper/concat_code.sh
|
||||||
|
|
||||||
|
# Repo variable SKIP_CODEX_MIRRORS=true skips on push/PR; workflow_dispatch can set skip_mirrors.
|
||||||
|
- name: Generate mirrors with Codex
|
||||||
|
if: ${{ env.OPENAI_API_KEY != '' && vars.SKIP_CODEX_MIRRORS != 'true' && (github.event_name != 'workflow_dispatch' || github.event.inputs.skip_mirrors != 'true') }}
|
||||||
|
continue-on-error: true
|
||||||
|
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
|
uses: xu-cheng/latex-action@v3
|
||||||
with:
|
with:
|
||||||
root_file: main.tex
|
root_file: ${{ steps.roots.outputs.root_files }}
|
||||||
working_directory: paper/src
|
working_directory: paper/src
|
||||||
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
args: -pdf -f -interaction=nonstopmode -file-line-error -r ../.latexmkrc -outdir=../build
|
||||||
pre_compile: bash ../concat_code.sh
|
|
||||||
- name: Upload PDF
|
- name: Upload PDF artifacts
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: thesis-pdf
|
name: thesis-pdf
|
||||||
path: paper/build/main.pdf
|
path: |
|
||||||
|
paper/build/main.pdf
|
||||||
|
paper/build/main-mirror-*.pdf
|
||||||
|
|
||||||
- name: Get current date
|
- name: Get current date
|
||||||
id: date
|
id: date
|
||||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload to Cloudflare R2
|
- name: Upload to Cloudflare R2
|
||||||
|
if: ${{ env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' && env.R2_ENDPOINT != '' && env.R2_BUCKET_NAME != '' }}
|
||||||
env:
|
env:
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
AWS_ACCESS_KEY_ID: ${{ env.R2_ACCESS_KEY_ID }}
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
AWS_SECRET_ACCESS_KEY: ${{ env.R2_SECRET_ACCESS_KEY }}
|
||||||
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
|
AWS_ENDPOINT_URL: ${{ env.R2_ENDPOINT }}
|
||||||
DATE: ${{ steps.date.outputs.date }}
|
DATE: ${{ steps.date.outputs.date }}
|
||||||
BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
BUCKET_NAME: ${{ env.R2_BUCKET_NAME }}
|
||||||
run: |
|
run: |
|
||||||
pip install boto3
|
pip install boto3
|
||||||
python3 << 'EOF'
|
python3 << 'EOF'
|
||||||
@@ -71,4 +139,49 @@ jobs:
|
|||||||
ExtraArgs={'ContentType': 'application/pdf'}
|
ExtraArgs={'ContentType': 'application/pdf'}
|
||||||
)
|
)
|
||||||
print(f"Uploaded thesis-latest.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
|
EOF
|
||||||
|
|||||||
15
.gitignore
vendored
@@ -3,6 +3,7 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
**/.venv
|
**/.venv
|
||||||
|
**/.venv-ray
|
||||||
|
|
||||||
# python build/cache artifacts
|
# python build/cache artifacts
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
@@ -18,6 +19,9 @@ phantom.egg-info/
|
|||||||
.nextstep
|
.nextstep
|
||||||
.ignore-gitlogue
|
.ignore-gitlogue
|
||||||
.cloudflare
|
.cloudflare
|
||||||
|
.nx/
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
|
||||||
# generated svg/graphics
|
# generated svg/graphics
|
||||||
**/session_*.svg
|
**/session_*.svg
|
||||||
@@ -30,16 +34,20 @@ phantom.egg-info/
|
|||||||
**/*.parquet
|
**/*.parquet
|
||||||
**/_build/
|
**/_build/
|
||||||
|
|
||||||
|
# mkdocs output (run make docs.platform locally or rely on CI)
|
||||||
|
docs/documentation/
|
||||||
|
|
||||||
# paper build artifacts
|
# paper build artifacts
|
||||||
paper/src/bib/auto
|
paper/src/bib/auto
|
||||||
paper/src/auto/*
|
paper/src/auto/*
|
||||||
paper/src/bib/auto
|
paper/src/bib/auto
|
||||||
paper/template/*
|
paper/template/*
|
||||||
paper/build-cais/
|
paper/build-cais/
|
||||||
|
paper/defense/manim/media/
|
||||||
|
paper/defense/manim/.manim/
|
||||||
paper/src/main.pdf
|
paper/src/main.pdf
|
||||||
paper/src/main-blx.bib
|
paper/src/main-blx.bib
|
||||||
paper/src/svg-inkscape/
|
paper/src/svg-inkscape/
|
||||||
paper/src/mirrors/
|
|
||||||
paper/variations/
|
paper/variations/
|
||||||
paper/src/graphics/test_*.png
|
paper/src/graphics/test_*.png
|
||||||
thesis-latest.pdf
|
thesis-latest.pdf
|
||||||
@@ -56,8 +64,8 @@ tests/e2e/test-results/
|
|||||||
tests/e2e/node_modules/**
|
tests/e2e/node_modules/**
|
||||||
|
|
||||||
# rl/sim run outputs
|
# rl/sim run outputs
|
||||||
sim/rl/behavior_loader/*.dot
|
# sim/rl/behavior_loader/*.dot
|
||||||
sim/rl/behavior_loader/*.png
|
# sim/rl/behavior_loader/*.png
|
||||||
sim/rl/behavior_loader/*.svg
|
sim/rl/behavior_loader/*.svg
|
||||||
sim/rl/behavior_loader/*.pdf
|
sim/rl/behavior_loader/*.pdf
|
||||||
sim/rl/runs/
|
sim/rl/runs/
|
||||||
@@ -66,6 +74,7 @@ sim/case/thesis_simplified/runs*/
|
|||||||
|
|
||||||
# model binaries
|
# model binaries
|
||||||
engine/models/*.zip
|
engine/models/*.zip
|
||||||
|
engine/studies/results/*
|
||||||
*.zip
|
*.zip
|
||||||
|
|
||||||
# wandb local state
|
# wandb local state
|
||||||
|
|||||||
35
.rayignore
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Virtual environments
|
||||||
|
.venv
|
||||||
|
.venv*
|
||||||
|
venv
|
||||||
|
venv*
|
||||||
|
**/.venv
|
||||||
|
**/venv
|
||||||
|
**/node_modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Python caches
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.ruff_cache/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
|
||||||
|
# Large data and logs
|
||||||
|
data/
|
||||||
|
experiments/
|
||||||
|
wandb/
|
||||||
|
dumplogs*
|
||||||
|
*.zip
|
||||||
|
*.pdf
|
||||||
|
*.log
|
||||||
|
*.dot
|
||||||
|
|
||||||
|
# Other large dirs
|
||||||
|
PHANTOM_web/
|
||||||
|
web/
|
||||||
|
docs/
|
||||||
|
paper/
|
||||||
|
.nx/
|
||||||
322
Makefile
@@ -8,15 +8,28 @@ VENV := .venv
|
|||||||
PYTHON := $(VENV)/bin/python
|
PYTHON := $(VENV)/bin/python
|
||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
PYTEST := $(VENV)/bin/pytest
|
PYTEST := $(VENV)/bin/pytest
|
||||||
|
NX := npx nx
|
||||||
|
|
||||||
SWEEP_ENV_FILE ?= .env.sweep
|
SWEEP_ENV_FILE ?= .env.sweep
|
||||||
|
TPU_CONF ?= tpu_orchestration/configs/v4_spot_us.conf
|
||||||
|
|
||||||
WANDB_ENTITY ?=
|
WANDB_ENTITY ?=
|
||||||
WANDB_PROJECT ?= phantom-pricing
|
WANDB_PROJECT ?= capstone
|
||||||
SWEEP_ID ?=
|
SWEEP_ID ?=
|
||||||
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
|
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
|
AGENT_COUNT ?= 0
|
||||||
|
|
||||||
|
WHOCLICKED_REPO ?= velocitatem/whoclickedit
|
||||||
|
WHOCLICKED_CSV ?= experiments/exports/whoclicked.csv
|
||||||
|
WHOCLICKED_CARD ?= experiments/exports/whoclicked_dataset_card.md
|
||||||
|
WHOCLICKED_CSV_PATH_IN_REPO ?= whoclicked.csv
|
||||||
|
WHOCLICKED_CARD_PATH_IN_REPO ?= README.md
|
||||||
|
WHOCLICKED_DATASET_MESSAGE ?= Update flattened whoclickedit dataset
|
||||||
|
WHOCLICKED_CARD_MESSAGE ?= Update dataset card for whoclickedit
|
||||||
|
|
||||||
REPO_URL ?=
|
REPO_URL ?=
|
||||||
BRANCH ?= main
|
BRANCH ?= main
|
||||||
WORKDIR ?= $(HOME)/PHANTOM-agent
|
WORKDIR ?= $(HOME)/PHANTOM-agent
|
||||||
@@ -24,10 +37,6 @@ AGENT_LOOP ?= 1
|
|||||||
RETRY_SECONDS ?= 20
|
RETRY_SECONDS ?= 20
|
||||||
|
|
||||||
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
|
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
|
||||||
TPU_NAME ?=
|
|
||||||
TPU_ZONE ?= us-central2-b
|
|
||||||
TPU_PROJECT ?= phantom-trc
|
|
||||||
TPU_REPO_DIR ?= /tmp/PHANTOM
|
|
||||||
|
|
||||||
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
||||||
|
|
||||||
@@ -35,184 +44,245 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" ||
|
|||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help:
|
help:
|
||||||
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | train.tpu.vm | train.tpu.vm.sweep | stats.lines"
|
@echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.summary pdf.summary.watch pdf.arxiv pdf.defense pdf.defense.html | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | docs.platform | manim.defense manim.defense.hq manim.render manim.render.full manim.render.poster manim.render.appendix manim.render.all"
|
||||||
@echo "docker.train.publish"
|
@echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish"
|
||||||
|
@echo "data.pull data.push data.whoclicked.publish | study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot"
|
||||||
|
@echo "tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown"
|
||||||
|
@echo ""
|
||||||
|
@echo "Build general public version:"
|
||||||
|
@echo " make pdf.genpop"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Local wandb run:"
|
@echo "Local wandb run:"
|
||||||
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
|
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
|
||||||
@echo ""
|
@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 "Local sweep agent from this repo:"
|
||||||
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
|
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Bootstrap private repo worker from anywhere:"
|
@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 " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Bootstrap Ray on TPU slice from config:"
|
||||||
|
@echo " make tpu.ray.bootstrap TPU_CONF=tpu_orchestration/configs/v4_spot_us.conf"
|
||||||
|
@echo ""
|
||||||
|
@echo "Publish whoclickedit dataset + card:"
|
||||||
|
@echo " make data.whoclicked.publish HF_TOKEN=... WHOCLICKED_REPO=velocitatem/whoclickedit"
|
||||||
|
@echo ""
|
||||||
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
|
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
|
||||||
|
|
||||||
$(BUILDDIR):
|
$(BUILDDIR):
|
||||||
mkdir -p paper/$(BUILDDIR)
|
mkdir -p paper/$(BUILDDIR)
|
||||||
|
|
||||||
.PHONY: pdf.build
|
.PHONY: pdf.build
|
||||||
pdf.build: $(BUILDDIR)
|
pdf.build:
|
||||||
@bash paper/concat_code.sh
|
@$(NX) run paper:build
|
||||||
@cd $(SRCDIR) && \
|
|
||||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
|
||||||
-interaction=nonstopmode -file-line-error \
|
|
||||||
-r ../.latexmkrc \
|
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
|
||||||
|
|
||||||
.PHONY: pdf.watch
|
.PHONY: pdf.watch
|
||||||
pdf.watch: $(BUILDDIR)
|
pdf.watch:
|
||||||
@cd $(SRCDIR) && \
|
@$(NX) run paper:watch
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
|
||||||
-interaction=nonstopmode -file-line-error \
|
|
||||||
-r ../.latexmkrc \
|
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
|
||||||
|
|
||||||
.PHONY: pdf.clean
|
.PHONY: pdf.clean
|
||||||
pdf.clean:
|
pdf.clean:
|
||||||
@cd $(SRCDIR) && \
|
@$(NX) run paper:clean
|
||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
.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: pdf.summary
|
||||||
|
pdf.summary:
|
||||||
|
@bash scripts/nx_paper.sh build-summary
|
||||||
|
|
||||||
|
.PHONY: pdf.summary.watch
|
||||||
|
pdf.summary.watch:
|
||||||
|
@bash scripts/nx_paper.sh watch-summary
|
||||||
|
|
||||||
|
.PHONY: pdf.defense
|
||||||
|
pdf.defense:
|
||||||
|
@cd paper/defense && pdflatex -interaction=nonstopmode defense.tex && pdflatex -interaction=nonstopmode defense.tex
|
||||||
|
|
||||||
|
.PHONY: pdf.defense.html
|
||||||
|
pdf.defense.html:
|
||||||
|
@bash paper/defense/build_html.sh
|
||||||
|
|
||||||
.PHONY: test.backend
|
.PHONY: test.backend
|
||||||
test.backend: $(VENV)
|
test.backend:
|
||||||
$(PYTEST) -v
|
@$(NX) run research:test
|
||||||
|
|
||||||
.PHONY: test.e2e
|
.PHONY: test.e2e
|
||||||
test.e2e:
|
test.e2e:
|
||||||
@cd tests/e2e && npm install
|
@$(NX) run e2e:test
|
||||||
@cd tests/e2e && npx playwright install chromium
|
|
||||||
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
|
|
||||||
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
|
|
||||||
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
|
|
||||||
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
|
|
||||||
@cd tests/e2e && npm test
|
|
||||||
|
|
||||||
.PHONY: test.all
|
.PHONY: test.all
|
||||||
test.all: test.backend test.e2e
|
test.all:
|
||||||
|
@$(NX) run-many -t test --projects=research,e2e --parallel=1
|
||||||
|
|
||||||
.PHONY: web.dev
|
.PHONY: web.dev
|
||||||
web.dev:
|
web.dev:
|
||||||
@cd web && npm install && npm run dev
|
@$(NX) run web:dev
|
||||||
|
|
||||||
$(VENV):
|
$(VENV):
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
$(PIP) install --upgrade pip
|
$(PIP) install --upgrade pip
|
||||||
|
|
||||||
.PHONY: install
|
.PHONY: install
|
||||||
install: $(VENV)
|
install:
|
||||||
$(PIP) install -r requirements.txt
|
@$(NX) run research:install
|
||||||
|
|
||||||
.PHONY: train
|
.PHONY: train
|
||||||
train: install
|
train:
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_TRAIN_ARGS="$(LOCAL_TRAIN_ARGS)" $(NX) run research:train
|
||||||
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
|
|
||||||
$(PYTHON) -m engine.train $(LOCAL_TRAIN_ARGS)
|
.PHONY: 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
|
.PHONY: train.agent
|
||||||
train.agent: install
|
train.agent:
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
@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
|
||||||
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
|
||||||
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
|
|
||||||
$(PYTHON) -m engine.train --sweep-agent --sweep-id "$(SWEEP_ID)" \
|
|
||||||
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
|
|
||||||
|
|
||||||
.PHONY: train.bootstrap
|
.PHONY: train.bootstrap
|
||||||
train.bootstrap:
|
train.bootstrap:
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
@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
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$GITHUB_TOKEN" || (echo "GITHUB_TOKEN required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
|
||||||
@test -n "$(REPO_URL)" || (echo "REPO_URL required, e.g. REPO_URL=https://github.com/org/repo.git" && exit 1)
|
.PHONY: tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown
|
||||||
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
tpu.ray.bootstrap:
|
||||||
@$(SWEEP_ENV_LOAD); \
|
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-bootstrap
|
||||||
WANDB_API_KEY="$$WANDB_API_KEY" \
|
|
||||||
WANDB_ENTITY="$(WANDB_ENTITY)" \
|
tpu.ray.deps:
|
||||||
WANDB_PROJECT="$(WANDB_PROJECT)" \
|
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-deps
|
||||||
GITHUB_TOKEN="$$GITHUB_TOKEN" \
|
|
||||||
REPO_URL="$(REPO_URL)" \
|
tpu.ray.verify:
|
||||||
BRANCH="$(BRANCH)" \
|
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-verify
|
||||||
WORKDIR="$(WORKDIR)" \
|
|
||||||
SWEEP_ID="$(SWEEP_ID)" \
|
tpu.ray.teardown:
|
||||||
AGENT_COUNT="$(AGENT_COUNT)" \
|
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-teardown
|
||||||
AGENT_LOOP="$(AGENT_LOOP)" \
|
|
||||||
RETRY_SECONDS="$(RETRY_SECONDS)" \
|
.PHONY: data.pull data.push
|
||||||
bash scripts/wandb_agent_bootstrap.sh
|
data.pull:
|
||||||
|
python scripts/hf_data.py pull
|
||||||
|
|
||||||
|
data.push:
|
||||||
|
python scripts/hf_data.py push
|
||||||
|
|
||||||
|
.PHONY: data.whoclicked.publish
|
||||||
|
data.whoclicked.publish:
|
||||||
|
@HF_TOKEN="$(HF_TOKEN)" WHOCLICKED_REPO="$(WHOCLICKED_REPO)" WHOCLICKED_CSV="$(WHOCLICKED_CSV)" WHOCLICKED_CARD="$(WHOCLICKED_CARD)" WHOCLICKED_CSV_PATH_IN_REPO="$(WHOCLICKED_CSV_PATH_IN_REPO)" WHOCLICKED_CARD_PATH_IN_REPO="$(WHOCLICKED_CARD_PATH_IN_REPO)" WHOCLICKED_DATASET_MESSAGE="$(WHOCLICKED_DATASET_MESSAGE)" WHOCLICKED_CARD_MESSAGE="$(WHOCLICKED_CARD_MESSAGE)" $(NX) run research:whoclicked-publish
|
||||||
|
|
||||||
.PHONY: stats.lines
|
.PHONY: stats.lines
|
||||||
stats.lines:
|
stats.lines:
|
||||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
@$(NX) run research:stats
|
||||||
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
|
||||||
|
.PHONY: study.margin-erosion
|
||||||
|
study.margin-erosion:
|
||||||
|
python -m engine.studies.margin_erosion_alpha
|
||||||
|
|
||||||
|
.PHONY: study.margin-erosion.quick
|
||||||
|
study.margin-erosion.quick:
|
||||||
|
python -m engine.studies.margin_erosion_alpha --quick
|
||||||
|
|
||||||
|
DOCS_VENV ?= docs/.venv
|
||||||
|
DOCS_MKDOCS := $(DOCS_VENV)/bin/mkdocs
|
||||||
|
DOCS_PIP := $(DOCS_VENV)/bin/pip
|
||||||
|
|
||||||
|
.PHONY: docs.platform
|
||||||
|
docs.platform: $(DOCS_VENV)
|
||||||
|
$(DOCS_MKDOCS) build -f docs/mkdocs.yml
|
||||||
|
|
||||||
|
$(DOCS_VENV):
|
||||||
|
python3 -m venv $(DOCS_VENV)
|
||||||
|
$(DOCS_PIP) install --upgrade pip
|
||||||
|
$(DOCS_PIP) install -r docs/requirements.txt
|
||||||
|
|
||||||
.PHONY: wordcount
|
.PHONY: wordcount
|
||||||
wordcount:
|
wordcount:
|
||||||
@echo "Counting words in main text (excluding appendix)..."
|
@$(NX) run paper:wordcount
|
||||||
@texcount -nosub -total -sum -1 \
|
|
||||||
$(SRCDIR)/chapters/01-intro.tex \
|
|
||||||
$(SRCDIR)/chapters/02-literature-review.tex \
|
|
||||||
$(SRCDIR)/chapters/03-methodology.tex \
|
|
||||||
$(SRCDIR)/chapters/04-results.tex \
|
|
||||||
$(SRCDIR)/chapters/05-discussion.tex \
|
|
||||||
$(SRCDIR)/chapters/06-conclusion.tex
|
|
||||||
|
|
||||||
.PHONY: docker.train.publish
|
.PHONY: docker.train.publish
|
||||||
docker.train.publish:
|
docker.train.publish:
|
||||||
docker build -f docker/Trainer.dockerfile --target gpu -t $(TRAIN_IMAGE_REF):gpu-latest .
|
@TRAIN_IMAGE_REF="$(TRAIN_IMAGE_REF)" $(NX) run research:docker-train-publish
|
||||||
docker push $(TRAIN_IMAGE_REF):gpu-latest
|
|
||||||
docker build -f docker/Trainer.dockerfile --target tpu -t $(TRAIN_IMAGE_REF):tpu-latest .
|
|
||||||
docker push $(TRAIN_IMAGE_REF):tpu-latest
|
|
||||||
|
|
||||||
.PHONY: train.tpu.pod
|
.PHONY: backend.server backend.provider backend.worker platform.up platform.down platform.logs
|
||||||
train.tpu.pod:
|
backend.server:
|
||||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
@$(NX) run backend-server:dev
|
||||||
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
|
||||||
gcloud compute tpus tpu-vm scp scripts/tpu_pod_run.sh $(TPU_NAME):/tmp/tpu_pod_run.sh \
|
|
||||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all
|
|
||||||
@$(SWEEP_ENV_LOAD); \
|
|
||||||
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
|
||||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
|
||||||
--command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh"
|
|
||||||
|
|
||||||
.PHONY: train.tpu.vm.prepare
|
backend.provider:
|
||||||
train.tpu.vm.prepare:
|
@$(NX) run pricing-provider:dev
|
||||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
|
||||||
TPU_NAME="$(TPU_NAME)" TPU_ZONE="$(TPU_ZONE)" TPU_PROJECT="$(TPU_PROJECT)" \
|
|
||||||
LOCAL_REPO_DIR="$(CURDIR)" REMOTE_REPO_DIR="$(TPU_REPO_DIR)" \
|
|
||||||
sh scripts/tpu_sync_repo.sh
|
|
||||||
gcloud compute tpus tpu-vm scp scripts/tpu_vm_train.sh $(TPU_NAME):/tmp/tpu_vm_train.sh \
|
|
||||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all
|
|
||||||
|
|
||||||
.PHONY: train.tpu.vm.run
|
backend.worker:
|
||||||
train.tpu.vm.run:
|
@$(NX) run backend-worker:dev
|
||||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
|
||||||
@test -n "$(LOCAL_TRAIN_ARGS)" || (echo "LOCAL_TRAIN_ARGS required, e.g. --algo ppo --jax --total-timesteps 200000" && exit 1)
|
|
||||||
@$(SWEEP_ENV_LOAD); \
|
|
||||||
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
|
||||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
|
||||||
--command="REPO_DIR='$(TPU_REPO_DIR)' TRAIN_ARGS='$(LOCAL_TRAIN_ARGS)' WANDB_API_KEY='$$WANDB_API_KEY' sh /tmp/tpu_vm_train.sh"
|
|
||||||
|
|
||||||
.PHONY: train.tpu.vm
|
platform.up:
|
||||||
train.tpu.vm: train.tpu.vm.prepare train.tpu.vm.run
|
@$(NX) run platform:up
|
||||||
|
|
||||||
.PHONY: train.tpu.vm.sweep
|
platform.down:
|
||||||
train.tpu.vm.sweep:
|
@$(NX) run platform:down
|
||||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
|
||||||
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=lusiana/phantom-pricing/abc123" && exit 1)
|
platform.logs:
|
||||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
@$(NX) run platform:logs
|
||||||
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" \
|
|
||||||
python3 scripts/tpu_vm_sweep_agent.py \
|
|
||||||
--sweep-id "$(SWEEP_ID)" \
|
|
||||||
--tpu-name "$(TPU_NAME)" \
|
|
||||||
--tpu-zone "$(TPU_ZONE)" \
|
|
||||||
--tpu-project "$(TPU_PROJECT)" \
|
|
||||||
--tpu-repo-dir "$(TPU_REPO_DIR)" \
|
|
||||||
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
|
|
||||||
|
|
||||||
.PHONY: pdf clean watch run.webapp test count-lines all
|
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||||
pdf: pdf.build
|
pdf:
|
||||||
clean: pdf.clean
|
@$(NX) run paper:build
|
||||||
watch: pdf.watch
|
|
||||||
run.webapp: web.dev
|
clean:
|
||||||
test: test.backend
|
@$(NX) run paper:clean
|
||||||
count-lines: stats.lines
|
|
||||||
all: pdf.build
|
watch:
|
||||||
|
@$(NX) run paper:watch
|
||||||
|
|
||||||
|
run.webapp:
|
||||||
|
@$(NX) run web:dev
|
||||||
|
|
||||||
|
test:
|
||||||
|
@$(NX) run research:test
|
||||||
|
|
||||||
|
count-lines:
|
||||||
|
@$(NX) run research:stats
|
||||||
|
|
||||||
|
# Default artifact set for this repo: thesis PDF (same as pdf).
|
||||||
|
all: pdf
|
||||||
|
|
||||||
|
.PHONY: manim.defense manim.defense.hq manim.render manim.render.full manim.render.poster manim.render.appendix manim.render.all
|
||||||
|
# Main defense reel (paper/defense/manim/render_defense); uses paper/defense/.venv when present
|
||||||
|
manim.defense:
|
||||||
|
@cd paper/defense/manim && ./render_defense full
|
||||||
|
|
||||||
|
manim.defense.hq:
|
||||||
|
@cd paper/defense/manim && ./render_defense full --quality qh
|
||||||
|
|
||||||
|
manim.render:
|
||||||
|
@$(NX) run manim:render
|
||||||
|
|
||||||
|
manim.render.full:
|
||||||
|
@$(NX) run manim:render-full
|
||||||
|
|
||||||
|
manim.render.poster:
|
||||||
|
@$(NX) run manim:render-poster
|
||||||
|
|
||||||
|
manim.render.appendix:
|
||||||
|
@$(NX) run manim:render-appendix
|
||||||
|
|
||||||
|
manim.render.all:
|
||||||
|
@$(NX) run manim:render-all
|
||||||
|
|||||||
250
README.md
@@ -1,94 +1,170 @@
|
|||||||
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
<!-- <p align="center"> -->
|
||||||
|
<!-- <img width="180" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" alt="PHANTOM logo" /> -->
|
||||||
|
<!-- </p> -->
|
||||||
|
|
||||||
### PHANTOM
|

|
||||||
|
|
||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
Agent-aware dynamic pricing research platform for studying how automated transaction orchestration changes pricing power, and for testing defenses that recover margin while protecting legitimate user experience.
|
||||||
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
|
||||||
[](https://sites.research.google/trc/faq/)
|
|
||||||
[](https://phantom-hotel.vercel.app)
|
|
||||||
[](https://phantom-airline.vercel.app)
|
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml"><img src="https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml/badge.svg" alt="Build PDF" style="vertical-align: middle;" /></a>
|
||||||
|
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf"><img src="https://img.shields.io/badge/Paper-PDF-red?logo=adobe-acrobat-reader" alt="Paper PDF" style="vertical-align: middle;" /></a>
|
||||||
|
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit"><img src="https://huggingface.co/datasets/huggingface/badges/resolve/main/dataset-on-hf-sm.svg" alt="Dataset on Hugging Face" style="vertical-align: middle; position: relative; top: 1px;" /></a>
|
||||||
|
<a href="https://sites.research.google/trc/faq/"><img src="https://img.shields.io/badge/TPU%20Research%20Cloud-TRC%20supported-4285F4?logo=googlecloud&logoColor=white" alt="TPU Research Cloud" style="vertical-align: middle;" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**Live demos:** [Hotel](https://phantom-hotel.vercel.app) | [Airline](https://phantom-airline.vercel.app) | [Academic page](https://velocitatem.github.io/PHANTOM/)
|
||||||
|
|
||||||
|
## What this repository includes
|
||||||
|
|
||||||
|
PHANTOM is a mixed research + engineering monorepo with:
|
||||||
|
|
||||||
|
- a thesis (LaTeX) formalizing Cost of Information (COI) erosion under agentic reconnaissance,
|
||||||
|
- a mode-switching web storefront (`hotel` and `airline`) for controlled human/agent interaction collection,
|
||||||
|
- backend services for event ingestion and pricing,
|
||||||
|
- an experimentation stack for benchmarks, contamination studies, and robust policy training.
|
||||||
|
|
||||||
|
## Why this matters
|
||||||
|
|
||||||
|
Dynamic pricing relies on demand signals collected during browsing. LLM-driven agents can split reconnaissance and execution into separate sessions, which weakens those signals and can collapse extractable price premium. PHANTOM exists to measure that mechanism directly and evaluate practical defenses in a controlled environment.
|
||||||
|
|
||||||
|
## Quick start (local platform)
|
||||||
|
|
||||||
|
### 1) Prerequisites
|
||||||
|
|
||||||
|
- Docker + Docker Compose
|
||||||
|
- Node.js + npm
|
||||||
|
- Python 3.8+
|
||||||
|
- `latexmk` (only if you want to build the paper locally)
|
||||||
|
|
||||||
|
### 2) Install workspace tooling and create env files
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
cp .env.sweep.example .env.sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3) Fill required values in `.env`
|
||||||
|
|
||||||
|
At minimum, set these before starting services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=...
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
||||||
|
AIRFLOW_FERNET_KEY=...
|
||||||
|
AIRFLOW_SECRET_KEY=...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4) Start the platform and web app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make platform.up
|
||||||
|
make web.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5) Verify
|
||||||
|
|
||||||
|
- Web app: `http://localhost:3000`
|
||||||
|
- Backend health: `http://localhost:5000/health`
|
||||||
|
- Pricing provider health: `http://localhost:5001/health`
|
||||||
|
- Airflow UI: `http://localhost:8085`
|
||||||
|
- Kafka console (Redpanda): `http://localhost:8084` (using `.env.example` defaults)
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
| --- | --- |
|
||||||
|
| Show all available workflows | `make help` |
|
||||||
|
| Start/stop platform services | `make platform.up` / `make platform.down` |
|
||||||
|
| Stream docker logs | `make platform.logs` |
|
||||||
|
| Run backend tests | `make test.backend` |
|
||||||
|
| Run end-to-end tests | `make test.e2e` |
|
||||||
|
| Build thesis PDF | `make pdf.build` |
|
||||||
|
| Watch thesis while editing | `make pdf.watch` |
|
||||||
|
| Build general-public thesis variant | `make pdf.genpop` |
|
||||||
|
| Run quick margin-erosion study | `make study.margin-erosion.quick` |
|
||||||
|
| Run benchmark without W&B logging | `make benchmark LOCAL_BENCHMARK_ARGS='--tiers static,surge,linear --alpha-values 0.0,0.3 --episodes 3 --no-wandb'` |
|
||||||
|
|
||||||
|
## System map
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
mindmap
|
flowchart LR
|
||||||
PHANTOM((PHANTOM Project))
|
U[Human / Agent Browser] --> W[Next.js Web App]
|
||||||
North Star
|
W -->|Price requests| P[Pricing Provider]
|
||||||
Study how automated actors change markets
|
W -->|Interaction events| B[Backend Ingest API]
|
||||||
Build an experimentation platform for real-world-like commerce
|
B --> K[Kafka]
|
||||||
Two-loop learning system
|
K --> A[Airflow + Worker Jobs]
|
||||||
Online observation loop
|
A --> R[Redis Model Registry]
|
||||||
Offline "defense gym" loop
|
P -->|Session/global prices| W
|
||||||
Core Economic Questions
|
E[Research Engine + Experiments] --> A
|
||||||
Price Discovery
|
E --> R
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Core runtime (`.env`)
|
||||||
|
|
||||||
|
| Variable | Purpose | Typical value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `STORE_MODE` | Web mode switch (`hotel` or `airline`) | `hotel` |
|
||||||
|
| `BACKEND_PORT` | Backend API port | `5000` |
|
||||||
|
| `PROVIDER_PORT` | Pricing provider port | `5001` |
|
||||||
|
| `KAFKA_HOST` | Kafka host for local runtime | `localhost` |
|
||||||
|
| `KAFKA_PORT` | Kafka external port | `9092` |
|
||||||
|
| `REDIS_PORT` | Redis exposed port | `6377` |
|
||||||
|
| `REDPANDA_CONSOLE_PORT` | Kafka console UI port | `8084` |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | Product catalog/data source URL | required |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Product catalog/data source key | required |
|
||||||
|
| `AIRFLOW_FERNET_KEY` | Airflow crypto key | required |
|
||||||
|
| `AIRFLOW_SECRET_KEY` | Airflow webserver secret | required |
|
||||||
|
|
||||||
|
### Training and sweep settings (`.env.sweep`)
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `WANDB_API_KEY` | Required for training/benchmark runs that log to Weights & Biases |
|
||||||
|
| `WANDB_ENTITY` | Optional W&B entity override |
|
||||||
|
| `WANDB_PROJECT` | W&B project name (default: `capstone`) |
|
||||||
|
| `GITHUB_TOKEN` | Required for `make train.bootstrap` |
|
||||||
|
| `SWEEP_ID` | Required for sweep-agent workflows (`train.agent`, `benchmark.agent`) |
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `paper/` | Thesis source, bibliography, and build artifacts |
|
||||||
|
| `web/` | Next.js storefront and experiment interaction surface |
|
||||||
|
| `backend/server/` | FastAPI ingestion API and product retrieval endpoints |
|
||||||
|
| `backend/provider/` | FastAPI pricing service backed by model registry data |
|
||||||
|
| `backend/worker/` | Celery worker for asynchronous jobs |
|
||||||
|
| `engine/` | Training and benchmarking entrypoints |
|
||||||
|
| `experiments/` | Data processing, ETL ideas, and analysis assets |
|
||||||
|
| `docker/` | Dockerfiles for platform services |
|
||||||
|
| `tests/e2e/` | Playwright end-to-end tests |
|
||||||
|
| `docs/` | Academic project page (GitHub Pages root) + MkDocs config |
|
||||||
|
| `docs/src/` | Markdown sources for the operator documentation site |
|
||||||
|
| `docs/documentation/` | MkDocs build output (gitignored; run `make docs.platform`; served at `/documentation/` on Pages) |
|
||||||
|
| `SETUP.md` | Unified operator guide: stack, kernels, RL training, thesis refs by chapter |
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- `make platform.up` starts the dockerized backend stack; the Next.js app is run separately with `make web.dev`.
|
||||||
|
- `make test.e2e` expects backend (`5000`), web (`3000`), and Airflow (`8085`) to be up.
|
||||||
|
- Research commands (`make train`, `make benchmark*`, `make train.agent`) auto-load `.env.sweep`.
|
||||||
|
- Paper builds call `paper/concat_code.sh` before compilation to flatten code into the appendix.
|
||||||
|
|
||||||
|
## Operator documentation
|
||||||
|
|
||||||
|
- Full setup guide (platform + research): [`SETUP.md`](SETUP.md)
|
||||||
|
- Hosted operator docs (after `make docs.platform`): […/PHANTOM/documentation/](https://velocitatem.github.io/PHANTOM/documentation/) on GitHub Pages
|
||||||
|
|
||||||
|
## Research artifacts
|
||||||
|
|
||||||
|
- Thesis PDF: `thesis-latest.pdf` or [hosted PDF](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
|
- Public dataset: [velocitatem/whoclickedit](https://huggingface.co/datasets/velocitatem/whoclickedit)
|
||||||
|
- Project page: [velocitatem.github.io/PHANTOM](https://velocitatem.github.io/PHANTOM/)
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
This work is supported by Google TPU Research Cloud resources.
|
||||||
|
|||||||
300
SETUP.md
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
# PHANTOM: setup for operators and partners
|
||||||
|
|
||||||
|
This guide walks a team from **business context** (what you sell, how you price, what traffic you worry about) through a **running PHANTOM stack**, **behavioral kernels and contamination**, and **RL training / benchmarking**. The math lives in the thesis PDF; here we tie operations to that math without re-deriving it. References to the thesis use **chapter numbers** only (build the PDF locally if you need line-level citations).
|
||||||
|
|
||||||
|
**Thesis (PDF):** [thesis-latest.pdf](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Who this is for / prerequisites
|
||||||
|
|
||||||
|
**Audience:** Engineers and researchers who run Docker, a Next.js app, and Python tooling; product or risk stakeholders who define experiment goals and acceptable UX tradeoffs.
|
||||||
|
|
||||||
|
**Skills:** Docker Compose, Node/npm, Python 3.8+, basic Kafka/Redis mental model.
|
||||||
|
|
||||||
|
**Decide up front:**
|
||||||
|
|
||||||
|
- **Vertical vs demo:** The repo ships `hotel` and `airline` storefront modes (`STORE_MODE`). Anything beyond that is custom integration work.
|
||||||
|
- **Data residency:** Event streams and training artifacts default to paths under the repo (overridable via `PHANTOM_`* env vars in `lib/config.py`). Decide where logs and models may live before you point production-like traffic at the stack.
|
||||||
|
- **Experiment governance:** Who may run human vs agent sessions, how sessions are labeled or weak-labeled for research, and retention policy for interaction logs.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
The formal model assumes each session is generated by a latent **actor class** $Y \in H,A$ (human vs agent). Your deployment choices implicitly assert **which sessions are valid for estimating human vs agent behavior** and whether experimental conditions are stable. If you mix exploratory QA traffic with labeled experiments without recording that fact, you blur the empirical partitions $D_H$ and $D_A$ that the methodology needs for transition kernels and contamination studies. See the **Introduction** (research questions) and **Methodology**, Problem Formalization, in the thesis PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Business fit framing
|
||||||
|
|
||||||
|
**The problem PHANTOM addresses:** Session-based pricing accumulates demand signals across a user's browsing history and raises quoted prices accordingly—the **Cost of Information (COI)** premium. LLM agents undercut this by separating reconnaissance (many isolated sessions, no signal accumulation) from execution (a clean session that quotes a floor price). The thesis proves that as the number of independent querying agents grows, the realizable price collapses to a minimum order statistic and COI approaches zero.
|
||||||
|
|
||||||
|
**What PHANTOM gives you:** A controlled platform to measure how much COI is at risk under real agent traffic, simulate that risk across contamination levels $\alpha \in [0,1]$, and train pricing policies that remain robust. The pipeline runs from raw interaction logs through behavioral kernel estimation and a contamination generator to a DR-RL gym.
|
||||||
|
|
||||||
|
**What you must supply:**
|
||||||
|
|
||||||
|
- A **product catalog** path: defaults assume Supabase-backed product data (`NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`).
|
||||||
|
- A plan for **interaction and price events** reaching the ingestion path (backend → Kafka) or an adapter you maintain.
|
||||||
|
- Clear **experiment goals:** e.g. compare human vs agent KPIs under the same task, measure margin under varying contamination $\alpha$.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Aggregate demand in the thesis is a **mixture** over human and agent types with contamination $\alpha$ plus noise $\epsilon_t$; see the mixture demand discussion in **Chapter 3 (Methodology)**. COI is defined as $\mathbb{E}[P]-\underline{p}$; the **COI framework** and theorem in the same chapter explain why saturated agent querying collapses extractable premium. Your business scenario determines which **actions** enter $\hat{q}$ and how interpretable $\alpha$ is for your traffic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Environment and secrets
|
||||||
|
|
||||||
|
**Bootstrap files (from repo root):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
cp .env.sweep.example .env.sweep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Core `.env` (platform + web + docker):** See `[.env.example](.env.example)`. You must also set the variables called out in `[README.md](README.md)` for a full stack: `NEXT_PUBLIC_SUPABASE_URL`, `NEXT_PUBLIC_SUPABASE_ANON_KEY`, `AIRFLOW_FERNET_KEY`, `AIRFLOW_SECRET_KEY` (and provider ports per your compose file).
|
||||||
|
|
||||||
|
**Training / sweeps (`.env.sweep`):** Used by `make train`, `make benchmark`, sweep agents. Typically `WANDB_API_KEY`, optional `WANDB_ENTITY` / `WANDB_PROJECT`, `GITHUB_TOKEN` for bootstrap flows, `SWEEP_ID` for W&B sweep workers. See `[.env.sweep.example](.env.sweep.example)`.
|
||||||
|
|
||||||
|
**Security:** Never commit real `.env` or `.env.sweep` files. Rotate keys if they leak.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Splitting **online platform credentials** (ingestion, catalog, Kafka) from **offline training credentials** (W&B, cloud TPUs, GitHub tokens for workers) mirrors the **hybrid Kappa–Lambda** data loop in the thesis: streaming observation vs batch / long-running training jobs. That split is named in the **Terminology** appendix of the thesis PDF.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bring-up (commands)
|
||||||
|
|
||||||
|
Aligned with `[README.md](README.md)`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
cp .env.sweep.example .env.sweep
|
||||||
|
# edit .env: Supabase, Airflow keys, etc.
|
||||||
|
|
||||||
|
make platform.up
|
||||||
|
make web.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sanity checks:**
|
||||||
|
|
||||||
|
|
||||||
|
| Endpoint | Role |
|
||||||
|
| ------------------------------------------------------------- | --------------------------------- |
|
||||||
|
| `http://localhost:3000` | Next.js storefront |
|
||||||
|
| `http://localhost:5000/health` | Backend ingest API |
|
||||||
|
| `http://localhost:5001/health` | Pricing provider |
|
||||||
|
| `http://localhost:8085` | Airflow UI (default compose port) |
|
||||||
|
| `http://localhost:8084` or configured `REDPANDA_CONSOLE_PORT` | Kafka console (see your `.env`) |
|
||||||
|
|
||||||
|
|
||||||
|
**Optional tests:** `make test.backend` (with venv/tooling as in Makefile); `make test.e2e` requires backend, web, and Airflow up per README.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
A correctly wired stack logs **trajectories** $\tau_s$ (sequences of events) and **price exposure** together. **Chapter 3** defines events $e_{s,k}=(a,i,t)$ and proxies $\hat{q}$ from weighted actions—without joint logging of behavior and quotes, you cannot recover the objects the theory reasons about (Problem Formalization).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Service map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[Human / Agent Browser] --> W[Next.js Web App]
|
||||||
|
W -->|Price requests| P[Pricing Provider]
|
||||||
|
W -->|Interaction events| B[Backend Ingest API]
|
||||||
|
B --> K[Kafka]
|
||||||
|
K --> A[Airflow + Worker Jobs]
|
||||||
|
A --> R[Redis Model Registry]
|
||||||
|
P -->|Session/global prices| W
|
||||||
|
E[Research Engine + Experiments] --> A
|
||||||
|
E --> R
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
**Ports (typical; confirm in `docker-compose` and `.env`):** `BACKEND_PORT` (5000), `PROVIDER_PORT` (5001), `KAFKA_PORT`, `REDIS_PORT`, Airflow `AIRFLOW_WEBSERVER_PORT` (8085 default), Redpanda console.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
The platform **observes** behavioral proxies and quoted prices, not the latent demand curve $d(p\mid\theta)$. The distinction between $\hat{q}$ and true demand is explicit in **Chapter 3**. Misattributing proxy noise to “true” elasticity breaks both estimation and any causal story about COI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Tailoring to your business
|
||||||
|
|
||||||
|
**Storefront mode:** `STORE_MODE=hotel` or `airline` (see `[web/src/lib/config.ts](web/src/lib/config.ts)` and env). This switches catalog and UI, not the core ingestion pattern.
|
||||||
|
|
||||||
|
**API base / environment:** `NEXT_PUBLIC_API_BASE`, `NEXT_PUBLIC_APP_ENV` (validated in `config.ts`).
|
||||||
|
|
||||||
|
**Paths for data and runs:** Override with `PHANTOM_DATA_DIR`, `PHANTOM_SIM_RUNS_DIR`, `PHANTOM_MODEL_REGISTRY_DIR`, `PHANTOM_COLLECTED_DATA_DIR`, etc. (`[lib/config.py](lib/config.py)`).
|
||||||
|
|
||||||
|
**Scope:** A new vertical (custom product ontology, checkout rules, pricing rules) means **new UI, events, and possibly new reward features** in the engine. Budget engineering time; the repo is a research platform, not a turnkey SaaS skin for arbitrary catalogs without code changes.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Transition kernels $\hat{\mathcal{T}}_H,\hat{\mathcal{T}}_A$ are estimated on a **finite action / state space** derived from your instrumentation. Changing catalog depth or event taxonomy changes the MDP state space; old kernel estimates are not portable. See the transition kernel discussion in **Chapter 3**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data collection and experiments
|
||||||
|
|
||||||
|
**Flow:** Browser → backend → **Kafka** → downstream consumers (Airflow DAGs, notebooks, ETL under `experiments/`). Ensure **session identity**, **item identifiers**, and **action types** are consistent enough to build trajectories.
|
||||||
|
|
||||||
|
**Weak labels:** The thesis discusses partitioning data into human vs agent subsets for MLE transition counts. In production you may only have heuristic labels—document bias explicitly.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Distinguishability (sub-question SQ1 in the **Introduction**) asks whether $H$ vs $A$ is identifiable from behavior alone. Your labeling and experimental design determine whether $\Delta_H,\Delta_A$ and $f(\tau)$ are meaningful or dominated by noise. Symbols appear in the **Terminology** appendix ($\Delta_H,\Delta_A$, $f(\tau)$, contamination generator $\mathcal{G}(\alpha)$).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Transition kernels and agent scoring (theory → practice)
|
||||||
|
|
||||||
|
**Theory:** Sessions yield trajectories $\tau_s$. For each actor class $y\inH,A$, the thesis estimates a **Markov transition kernel** by counting transitions and normalizing (MLE):
|
||||||
|
|
||||||
|
$$
|
||||||
|
\hat{P}(s' \mid s) = \frac{N(s,s')}{\sum_k N(s,k)}
|
||||||
|
$$
|
||||||
|
|
||||||
|
Human and agent prototypes $\hat{\mathcal{T}}_H,\hat{\mathcal{T}}_A$ support comparing an empirical kernel from a partial trajectory to prototypes (e.g. KL-style divergences $\Delta_H,\Delta_A$) and mapping to a **weak agent probability** $f(\tau)$. See **Chapter 3** and the **Terminology** appendix.
|
||||||
|
|
||||||
|
**Code:** `[engine/lib/coi.py](engine/lib/coi.py)` (`compute_agent_probability`: empirical transition counts vs human/agent reference dicts, KL-style terms, mapped via `[lib/agent_probability.py](lib/agent_probability.py)`).
|
||||||
|
|
||||||
|
**Optional narrative:** `[blog/02-behavioral-fingerprinting.md](blog/02-behavioral-fingerprinting.md)` walks a concrete study design (not required for operators).
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
If reference kernels are fit on **stale** or **mislabeled** partitions, $\Delta_H-\Delta_A$ is not interpretable as distinguishability. Ground claims in SQ1 (**Introduction**) and the kernel subsection of **Chapter 3**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Contamination generator $\mathcal{G}(\alpha)$
|
||||||
|
|
||||||
|
**Theory:** Given clean trajectories, $\mathcal{G}(\alpha)$ injects synthetic agent trajectories until the effective mixture reaches contamination $\alpha\in[0,1]$, defining training scenarios for robust policies (**Chapter 3**). Catalog-scale block expansion of kernels is discussed there with validation caveats—treat large product spaces as **research-grade** until your team signs off.
|
||||||
|
|
||||||
|
**Code:** `[engine/engine.py](engine/engine.py)` — `MarketEngine` mixes human/agent demand, uses `get_adjusted_transitions` / `sample_behavior_from_transitions`, and `alpha` when combining actor types and building demand proxies (`estimate_demand`). This is the **simulator** path, not a drop-in replacement for your production database.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
$\alpha$ in mixture $Q(p)$ is **agentic demand contribution** in the formal model, not necessarily “bot share of page views” unless your instrumentation equates them. Mismeasuring $\alpha$ biases robust objectives tied to a fixed contamination level.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Training and evaluation — local workflow
|
||||||
|
|
||||||
|
**Environment:** Python venv via Nx (`make install` / `nx run research:install`). Training commands load `.env.sweep`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'
|
||||||
|
make benchmark LOCAL_BENCHMARK_ARGS='--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --no-wandb'
|
||||||
|
make benchmark.simple
|
||||||
|
```
|
||||||
|
|
||||||
|
Entrypoints: `[engine/train.py](engine/train.py)`, `[engine/benchmark.py](engine/benchmark.py)`, `[engine/spec.py](engine/spec.py)` (Nx wraps these—see `project.json` / research targets).
|
||||||
|
|
||||||
|
**Artifacts:** `[lib/config.py](lib/config.py)` — `PHANTOM_SIM_RUNS_DIR` (default `sim/rl/runs`), `PHANTOM_MODEL_REGISTRY_DIR`, etc.
|
||||||
|
|
||||||
|
**TensorBoard (optional):** `[docker-compose.yml](docker-compose.yml)` includes `tensorboard-rl` on host port **6007** (`./sim/rl/runs`) and `tensorboard-ml` on **6006** (`./experiments/ml/runs`).
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Local runs instantiate the **offline defense gym**: policies trained on simulator-induced distributions approximate the DR-RL narrative in **Chapter 3**, but hyperparameters ($\lambda$ on COI leakage, $\eta$ on UX, robust radius) change the effective ambiguity set. Cross-check `engine/` against the thesis before claiming figure-for-figure replication.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Training and evaluation — remote / scaled deployment
|
||||||
|
|
||||||
|
For **research at scale** (cloud quota and secrets required):
|
||||||
|
|
||||||
|
|
||||||
|
| Mechanism | Role |
|
||||||
|
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `[submit_ray_job.sh](submit_ray_job.sh)` | Ray jobs with `.env` injected; `RAY_MODE=single|distributed|benchmark|sweep`. Set the script’s `ROOT` to your clone path. |
|
||||||
|
| `make tpu.ray.bootstrap` / `tpu.ray.`* | TPU Ray bootstrap (`TPU_CONF`, e.g. `tpu_orchestration/configs/v4_spot_us.conf`). |
|
||||||
|
| `make train.agent` / `make benchmark.agent` | W&B sweeps: `SWEEP_ID` in `.env.sweep`. |
|
||||||
|
| `make train.bootstrap` | Worker bootstrap: `REPO_URL`, `SWEEP_ID`, `GITHUB_TOKEN`. |
|
||||||
|
| `make docker.train.publish` | Trainer image (`TRAIN_IMAGE_REF` in Makefile). |
|
||||||
|
|
||||||
|
|
||||||
|
See `submit_ray_job.sh` for env vars (`WANDB_*`, `PHANTOM_*` TPU toggles).
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Distributed training does not change the **definitions** of the Stackelberg game or Wasserstein ambiguity; it changes compute and variance of empirical estimates. Align random seeds and data protocol across nodes or split results explicitly—otherwise you mix distributions in a way a single empirical law $\hat{P}_N$ in the thesis does not describe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Evaluation, artifacts, and audit trail
|
||||||
|
|
||||||
|
**Benchmarks:** `make benchmark`* sweeps tiers and $\alpha$; CLI includes robustness knobs (see default `BENCHMARK_ARGS` in `submit_ray_job.sh`: `--robust-radius`, `--lambda-coi`, `--eta-ux`, etc.).
|
||||||
|
|
||||||
|
**Audit trail:** Store `git` SHA, CLI argv, non-secret `.env.sweep` keys, and W&B run IDs with published tables. For scientific claims, cite **Chapters 4–5 (Results, Discussion)** in the thesis PDF.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Evaluation quality equals **simulator fidelity** plus **contamination modeling**. Separate theorem statements (assumption-based) from empirical curves (`engine`-dependent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Operational suggestions
|
||||||
|
|
||||||
|
- **Staging:** Non-production namespaces; separate Kafka topics and Supabase projects where possible.
|
||||||
|
- **Rate limits / abuse:** Protect ingest endpoints; respect participant privacy.
|
||||||
|
- **Human vs agent sessions:** Comparable cohorts; record experimental condition in metadata.
|
||||||
|
- **Contracts:** `tests/e2e/` encodes minimal flows—use when APIs change.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Non-stationary noise $\epsilon_t$ and drifting $\alpha$ confound benchmark interpretation. **Chapter 3** discusses mixture identification: isolate treatments when possible and document confounders when not.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Roadmap and gaps
|
||||||
|
|
||||||
|
**In repo:** Local dockerized stack, demo verticals, engine benchmarks, documented env and paths.
|
||||||
|
|
||||||
|
**Usually custom:** Production catalog without Supabase, identity/fraud layers, legal review of logging, Kafka/Airflow SLAs, hardening the pricing provider for real money.
|
||||||
|
|
||||||
|
**Thesis vs code:** The PDF is the **spec**; not every robustness term or large-catalog kernel construction is production-verified—see caveats in **Chapter 3**.
|
||||||
|
|
||||||
|
### Theoretical implications
|
||||||
|
|
||||||
|
Theorems in the thesis can be **stronger** than what observational firm logs support. The COI result assumes a clean experimental reading of the pricing policy; live market data may only support weaker claims.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Theory and thesis cross-references (quick index)
|
||||||
|
|
||||||
|
Use the **PDF table of contents** with these anchors:
|
||||||
|
|
||||||
|
|
||||||
|
| Topic | Thesis location |
|
||||||
|
| -------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||||
|
| Research questions (margin, distinguishability, contamination, mitigation) | **Introduction** |
|
||||||
|
| Sessions, events, $\hat{q}$, mixture $Q(p)$, $\alpha$ | **Chapter 3** — Problem Formalization, mixture demand |
|
||||||
|
| COI definition and erosion theorem | **Chapter 3** — COI framework |
|
||||||
|
| Transition kernels, MLE, $\mathcal{G}(\alpha)$ | **Chapter 3** |
|
||||||
|
| DR-RL, ambiguity sets, Stackelberg | **Chapter 3** |
|
||||||
|
| Symbol glossary (COI leakage, $f(\tau)$, UX, surrogates) | **Appendix — Terminology** |
|
||||||
|
| Empirical results and limitations | **Chapters 4–5** |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Quick file index (code)
|
||||||
|
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
| ---------------------------------------------------------------------------------- | -------------------------------------------------- |
|
||||||
|
| `[engine/lib/coi.py](engine/lib/coi.py)` | KL-style trajectory comparison; agent probability. |
|
||||||
|
| `[engine/engine.py](engine/engine.py)` | `MarketEngine`, mixture, demand proxy path. |
|
||||||
|
| `[lib/agent_probability.py](lib/agent_probability.py)` | Divergence → probability score. |
|
||||||
|
| `[lib/config.py](lib/config.py)` | Paths and ports for artifacts. |
|
||||||
|
| `[engine/train.py](engine/train.py)`, `[engine/benchmark.py](engine/benchmark.py)` | CLI entrypoints. |
|
||||||
|
| `[tpu_orchestration/](tpu_orchestration/)` | TPU configs and helpers. |
|
||||||
|
|
||||||
|
|
||||||
|
Many offline benchmarks run without a storefront once the research Python environment is installed; connecting production trajectories to kernel estimation still requires aligned instrumentation.
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
64 spot Cloud TPU v6e chips in zone europe-west4-a
|
|
||||||
32 spot Cloud TPU v4 chips in zone us-central2-b
|
|
||||||
64 spot Cloud TPU v5e chips in zone us-central1-a
|
|
||||||
64 spot Cloud TPU v6e chips in zone us-east1-d
|
|
||||||
32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
|
||||||
64 spot Cloud TPU v5e chips in zone europe-west4-b
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 32 spot Cloud TPU v4 chips in zone us-central2-b
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUv4s32spotUC2B
|
|
||||||
export TPU_NAME=tpu-v4-32-uc2b-spot
|
|
||||||
export ZONE=us-central2-b
|
|
||||||
export ACCELERATOR_TYPE=v4-32
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv4
|
|
||||||
|
|
||||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--version=${RUNTIME_VERSION} \
|
|
||||||
--spot \
|
|
||||||
|| \
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION} \
|
|
||||||
--spot
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# 32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUlong
|
|
||||||
export ZONE=us-central2-b
|
|
||||||
export ACCELERATOR_TYPE=v4-32
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv4
|
|
||||||
#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION}
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 64 spot Cloud TPU v5e chips in zone europe-west4-b
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUv5e64spotEW4B
|
|
||||||
export TPU_NAME=tpu-v5e-64-ew4b
|
|
||||||
export ZONE=europe-west4-b
|
|
||||||
export ACCELERATOR_TYPE=v5e-64
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
|
||||||
|
|
||||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--version=${RUNTIME_VERSION} \
|
|
||||||
--spot \
|
|
||||||
|| \
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION} \
|
|
||||||
--spot
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 64 spot Cloud TPU v5e chips in zone us-central1-a
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUv5e64spotUC1A
|
|
||||||
export TPU_NAME=tpu-v5e-64-uc1a
|
|
||||||
export ZONE=us-central1-a
|
|
||||||
export ACCELERATOR_TYPE=v5e-64
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
|
||||||
|
|
||||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--version=${RUNTIME_VERSION} \
|
|
||||||
--spot \
|
|
||||||
|| \
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION} \
|
|
||||||
--spot
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 64 spot Cloud TPU v6e chips in zone europe-west4-a
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUv6e64spotEW4A
|
|
||||||
export TPU_NAME=tpu-v6e-64-ew4a
|
|
||||||
export ZONE=europe-west4-a
|
|
||||||
export ACCELERATOR_TYPE=v6e-64
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
|
||||||
|
|
||||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--version=${RUNTIME_VERSION} \
|
|
||||||
--spot \
|
|
||||||
|| \
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION} \
|
|
||||||
--spot
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# 64 spot Cloud TPU v6e chips in zone us-east1-d
|
|
||||||
export PROJECT_ID=phantom-trc
|
|
||||||
export QR_NAME=TPUv6e64spotUE1D
|
|
||||||
export TPU_NAME=tpu-v6e-64-ue1d
|
|
||||||
export ZONE=us-east1-d
|
|
||||||
export ACCELERATOR_TYPE=v6e-64
|
|
||||||
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
|
||||||
|
|
||||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--version=${RUNTIME_VERSION} \
|
|
||||||
--spot \
|
|
||||||
|| \
|
|
||||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
|
||||||
--project=${PROJECT_ID} \
|
|
||||||
--zone=${ZONE} \
|
|
||||||
--node-id=${TPU_NAME} \
|
|
||||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
|
||||||
--runtime-version=${RUNTIME_VERSION} \
|
|
||||||
--spot
|
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
fastapi==0.104.1
|
fastapi>=0.135,<0.136
|
||||||
uvicorn[standard]==0.24.0
|
uvicorn[standard]>=0.41,<0.42
|
||||||
kafka-python==2.0.2
|
kafka-python>=2.3,<2.4
|
||||||
pydantic==2.5.0
|
pydantic>=2.12,<3
|
||||||
python-dotenv==1.0.0
|
python-dotenv>=1.0,<2
|
||||||
supabase==2.9.1
|
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
|
||||||
BIN
banner.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
@@ -1,4 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
tpu-watchdogs:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/TPUWatchdog.dockerfile
|
||||||
|
container_name: "PHANTOM-tpu-watchdogs"
|
||||||
|
restart: unless-stopped
|
||||||
|
user: "${UID:-1000}:${GID:-1000}"
|
||||||
|
environment:
|
||||||
|
- HF_TOKEN=${HF_TOKEN}
|
||||||
|
- WANDB_API_KEY=${WANDB_API_KEY}
|
||||||
|
- GITHUB_TOKEN=${GITHUB_TOKEN}
|
||||||
|
- GOOGLE_APPLICATION_CREDENTIALS=/secrets/gcp-sa.json
|
||||||
|
- GCP_ACCOUNT=${GCP_ACCOUNT:-}
|
||||||
|
- WATCHDOG_CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-v[46]*.conf}
|
||||||
|
- CLOUDSDK_CONFIG=/.config/gcloud
|
||||||
|
volumes:
|
||||||
|
- ~/.config/gcloud:/.config/gcloud:rw
|
||||||
|
- ./secrets/gcp-sa.json:/secrets/gcp-sa.json:ro
|
||||||
|
|
||||||
tensorboard-rl:
|
tensorboard-rl:
|
||||||
image: tensorflow/tensorflow:latest
|
image: tensorflow/tensorflow:latest
|
||||||
container_name: "PHANTOM-tensorboard-rl"
|
container_name: "PHANTOM-tensorboard-rl"
|
||||||
|
|||||||
112
docker/TPUWatchdog.dockerfile
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
FROM google/cloud-sdk:slim
|
||||||
|
|
||||||
|
# Install tmux to manage multiple watchdogs and jq for json parsing
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y tmux jq && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy the orchestration scripts and configs
|
||||||
|
COPY tpu_orchestration/ /app/tpu_orchestration/
|
||||||
|
|
||||||
|
# Make sure scripts are executable
|
||||||
|
RUN chmod +x /app/tpu_orchestration/watchdog.sh
|
||||||
|
RUN chmod +x /app/tpu_orchestration/tpu_startup.sh
|
||||||
|
|
||||||
|
# Create an entrypoint script that launches a watchdog for each config
|
||||||
|
COPY <<-'EOF' /app/entrypoint.sh
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Make sure required variables are set
|
||||||
|
if [ -z "$HF_TOKEN" ]; then
|
||||||
|
echo "Error: HF_TOKEN environment variable is required."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$WANDB_API_KEY" ]; then
|
||||||
|
echo "Warning: WANDB_API_KEY environment variable is not set. Wandb logging may fail on TPUs."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Authenticate gcloud if credentials are provided
|
||||||
|
if [ -n "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -f "$GOOGLE_APPLICATION_CREDENTIALS" ]; then
|
||||||
|
CRED_TYPE=$(jq -r '.type' "$GOOGLE_APPLICATION_CREDENTIALS" 2>/dev/null || echo "unknown")
|
||||||
|
if [ "$CRED_TYPE" = "service_account" ]; then
|
||||||
|
echo "Authenticating gcloud using service account key..."
|
||||||
|
gcloud auth activate-service-account --key-file="$GOOGLE_APPLICATION_CREDENTIALS"
|
||||||
|
|
||||||
|
if [ -z "$PROJECT_ID" ]; then
|
||||||
|
PROJECT_ID=$(jq -r '.project_id // empty' "$GOOGLE_APPLICATION_CREDENTIALS")
|
||||||
|
fi
|
||||||
|
elif [ "$CRED_TYPE" = "authorized_user" ]; then
|
||||||
|
echo "Using authorized_user credentials via credential file override..."
|
||||||
|
export CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE="$GOOGLE_APPLICATION_CREDENTIALS"
|
||||||
|
|
||||||
|
if gcloud auth print-access-token >/dev/null 2>&1; then
|
||||||
|
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true)
|
||||||
|
if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then
|
||||||
|
ACTIVE_ACCOUNT=$(jq -r '.account // empty' "$GOOGLE_APPLICATION_CREDENTIALS")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$ACTIVE_ACCOUNT" ] && [ "$ACTIVE_ACCOUNT" != "(unset)" ]; then
|
||||||
|
echo "Using gcloud account: $ACTIVE_ACCOUNT"
|
||||||
|
else
|
||||||
|
echo "Using gcloud credential override from $GOOGLE_APPLICATION_CREDENTIALS"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: credential file override token check failed. Falling back to mounted gcloud config."
|
||||||
|
unset CLOUDSDK_AUTH_CREDENTIAL_FILE_OVERRIDE
|
||||||
|
|
||||||
|
if [ -n "$GCP_ACCOUNT" ]; then
|
||||||
|
gcloud config set account "$GCP_ACCOUNT" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTIVE_ACCOUNT=$(gcloud config get-value account 2>/dev/null || true)
|
||||||
|
if [ -z "$ACTIVE_ACCOUNT" ] || [ "$ACTIVE_ACCOUNT" = "(unset)" ]; then
|
||||||
|
echo "Error: no active gcloud account available. Run 'gcloud auth login' on host and mount ~/.config/gcloud, or use a service account key."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Using gcloud account: $ACTIVE_ACCOUNT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Warning: unsupported credential file type '$CRED_TYPE'. Falling back to mounted gcloud config."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Note: Assuming gcloud config is mounted from host."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$PROJECT_ID" ]; then
|
||||||
|
gcloud config set project "$PROJECT_ID"
|
||||||
|
echo "Set project to $PROJECT_ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the watchdogs in the background using bash instead of tmux
|
||||||
|
# Tmux needs a TTY to attach properly which we might not have in docker
|
||||||
|
# Stagger startups by 15s to prevent simultaneous TPU creation quota hits
|
||||||
|
CONFIG_PATTERN=${WATCHDOG_CONFIG_PATTERN:-"*.conf"}
|
||||||
|
shopt -s nullglob
|
||||||
|
CONFIGS=(/app/tpu_orchestration/configs/$CONFIG_PATTERN)
|
||||||
|
|
||||||
|
if [ ${#CONFIGS[@]} -eq 0 ]; then
|
||||||
|
echo "Error: no watchdog configs matched pattern '$CONFIG_PATTERN'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Using watchdog config pattern: $CONFIG_PATTERN"
|
||||||
|
DELAY=0
|
||||||
|
for conf in "${CONFIGS[@]}"; do
|
||||||
|
echo "Starting watchdog for $(basename "$conf" .conf) (delay: ${DELAY}s)"
|
||||||
|
(sleep $DELAY && /app/tpu_orchestration/watchdog.sh "$conf") &
|
||||||
|
DELAY=$((DELAY + 15))
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "All watchdogs queued with staggered startup."
|
||||||
|
|
||||||
|
# Keep the container running
|
||||||
|
wait
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RUN chmod +x /app/entrypoint.sh
|
||||||
|
|
||||||
|
CMD ["/app/entrypoint.sh"]
|
||||||
@@ -7,36 +7,9 @@ WORKDIR /app
|
|||||||
COPY docker/trainer.requirements.txt /tmp/requirements.txt
|
COPY docker/trainer.requirements.txt /tmp/requirements.txt
|
||||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
# Optional for JAX-on-GPU workflows.
|
|
||||||
ARG INSTALL_JAX_GPU=false
|
|
||||||
RUN if [ "${INSTALL_JAX_GPU}" = "true" ]; then \
|
|
||||||
pip install --no-cache-dir "jax[cuda12]==0.4.30" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html; \
|
|
||||||
fi
|
|
||||||
|
|
||||||
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
||||||
COPY engine /app/engine
|
COPY engine /app/engine
|
||||||
|
|
||||||
ENV PYTHONPATH=/app \
|
ENV PYTHONPATH=/app
|
||||||
XLA_PYTHON_CLIENT_PREALLOCATE=false
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.11-slim AS tpu
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY docker/trainer.requirements.txt /tmp/requirements.txt
|
|
||||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
|
||||||
|
|
||||||
RUN pip install --no-cache-dir "jax[tpu]==0.4.30" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html
|
|
||||||
|
|
||||||
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
|
||||||
COPY engine /app/engine
|
|
||||||
|
|
||||||
ENV PYTHONPATH=/app \
|
|
||||||
PHANTOM_USE_JAX=1 \
|
|
||||||
PHANTOM_DEFAULT_AGENT_ARGS="--jax" \
|
|
||||||
XLA_PYTHON_CLIENT_PREALLOCATE=false
|
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
||||||
|
|||||||
@@ -5,9 +5,3 @@ gymnasium>=0.29.0
|
|||||||
stable-baselines3>=2.2.0
|
stable-baselines3>=2.2.0
|
||||||
tensorboard>=2.15.0
|
tensorboard>=2.15.0
|
||||||
wandb>=0.17.0
|
wandb>=0.17.0
|
||||||
tensorflow-probability==0.24.0
|
|
||||||
flax==0.10.7
|
|
||||||
optax==0.2.7
|
|
||||||
distrax==0.1.5
|
|
||||||
orbax-checkpoint==0.11.32
|
|
||||||
chex==0.1.90
|
|
||||||
|
|||||||
485
docs/index.html
@@ -17,8 +17,8 @@
|
|||||||
<meta property="og:site_name" content="PHANTOM Research">
|
<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: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: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:url" content="https://velocitatem.github.io/PHANTOM/">
|
||||||
<meta property="og:image" content="TODO">
|
<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:width" content="1200">
|
||||||
<meta property="og:image:height" content="630">
|
<meta property="og:image:height" content="630">
|
||||||
<meta property="og:image:alt" content="PHANTOM Research Preview">
|
<meta property="og:image:alt" content="PHANTOM Research Preview">
|
||||||
@@ -30,17 +30,12 @@
|
|||||||
|
|
||||||
<!-- Twitter -->
|
<!-- Twitter -->
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<!-- TODO: Replace with your lab/institution Twitter handle -->
|
<meta name="twitter:site" content="@velocitatem">
|
||||||
<meta name="twitter:site" content="@YOUR_TWITTER_HANDLE">
|
<meta name="twitter:creator" content="@velocitatem">
|
||||||
<!-- TODO: Replace with first author's Twitter handle -->
|
<meta name="twitter:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
||||||
<meta name="twitter:creator" content="@AUTHOR_TWITTER_HANDLE">
|
<meta name="twitter:description" content="A thesis project on defending dynamic pricing against LLM-driven reconnaissance and transaction orchestration.">
|
||||||
<!-- TODO: Same as paper title above -->
|
<meta name="twitter:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
|
||||||
<meta name="twitter:title" content="PAPER_TITLE">
|
<meta name="twitter:image:alt" content="PHANTOM research visual">
|
||||||
<!-- 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">
|
|
||||||
|
|
||||||
<!-- Academic/Research Specific -->
|
<!-- Academic/Research Specific -->
|
||||||
<meta name="citation_title" content="Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
<meta name="citation_title" content="Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
||||||
@@ -50,14 +45,12 @@
|
|||||||
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
||||||
|
|
||||||
<!-- Additional SEO -->
|
<!-- Additional SEO -->
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#1f2a38">
|
||||||
<meta name="msapplication-TileColor" content="#2563eb">
|
<meta name="msapplication-TileColor" content="#1f2a38">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
|
||||||
<!-- Preconnect for performance -->
|
<!-- Preconnect for performance -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link rel="preconnect" href="https://ajax.googleapis.com">
|
<link rel="preconnect" href="https://ajax.googleapis.com">
|
||||||
<link rel="preconnect" href="https://documentcloud.adobe.com">
|
<link rel="preconnect" href="https://documentcloud.adobe.com">
|
||||||
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
||||||
@@ -66,12 +59,19 @@
|
|||||||
<title>PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms - Daniel Rösel | Academic Research</title>
|
<title>PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms - Daniel Rösel | Academic Research</title>
|
||||||
|
|
||||||
<!-- Favicon and App Icons -->
|
<!-- Favicon and App Icons -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="static/images/favicon.svg">
|
||||||
<link rel="icon" type="image/x-icon" href="static/images/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="static/images/favicon.ico">
|
||||||
<link rel="apple-touch-icon" href="static/images/favicon.ico">
|
<link rel="apple-touch-icon" href="static/images/apple-touch-icon.png">
|
||||||
|
|
||||||
<!-- Critical CSS - Load synchronously -->
|
<!-- Critical CSS - Load synchronously -->
|
||||||
<link rel="stylesheet" href="static/css/bulma.min.css">
|
<link rel="stylesheet" href="static/css/bulma.min.css">
|
||||||
<link rel="stylesheet" href="static/css/index.css">
|
<link rel="stylesheet" href="static/css/index.css">
|
||||||
|
<link rel="stylesheet" href="static/css/defense-theme.css">
|
||||||
|
|
||||||
|
<!-- Defense-style monospace tagline font -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Non-critical CSS - Load asynchronously -->
|
<!-- Non-critical CSS - Load asynchronously -->
|
||||||
<link rel="preload" href="static/css/bulma-carousel.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
<link rel="preload" href="static/css/bulma-carousel.min.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
|
||||||
@@ -87,9 +87,6 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jpswalsh/academicons@1/css/academicons.min.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/jpswalsh/academicons@1/css/academicons.min.css">
|
||||||
</noscript>
|
</noscript>
|
||||||
|
|
||||||
<!-- Fonts - Optimized loading -->
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Defer non-critical JavaScript -->
|
<!-- Defer non-critical JavaScript -->
|
||||||
<script defer src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
<script defer src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
|
||||||
<script defer src="https://documentcloud.adobe.com/view-sdk/main.js"></script>
|
<script defer src="https://documentcloud.adobe.com/view-sdk/main.js"></script>
|
||||||
@@ -103,50 +100,42 @@
|
|||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "ScholarlyArticle",
|
"@type": "ScholarlyArticle",
|
||||||
"headline": "PAPER_TITLE",
|
"headline": "PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms",
|
||||||
"description": "BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS",
|
"description": "Research on preserving dynamic pricing integrity under LLM-mediated reconnaissance and purchasing behavior.",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
"name": "FIRST_AUTHOR_NAME",
|
"name": "Daniel Rösel",
|
||||||
"affiliation": {
|
"affiliation": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "INSTITUTION_NAME"
|
"name": "IE University"
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Person",
|
|
||||||
"name": "SECOND_AUTHOR_NAME",
|
|
||||||
"affiliation": {
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": "INSTITUTION_NAME"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"datePublished": "2024-01-01",
|
"datePublished": "2025-01-01",
|
||||||
"publisher": {
|
"publisher": {
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "CONFERENCE_OR_JOURNAL_NAME"
|
"name": "IE University"
|
||||||
},
|
},
|
||||||
"url": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE",
|
"url": "https://velocitatem.github.io/PHANTOM/",
|
||||||
"image": "https://YOUR_DOMAIN.com/static/images/social_preview.png",
|
"image": "https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg",
|
||||||
"keywords": ["KEYWORD1", "KEYWORD2", "KEYWORD3", "machine learning", "computer vision"],
|
"keywords": ["dynamic pricing", "llm agents", "e-commerce", "distributionally robust optimization", "reinforcement learning"],
|
||||||
"abstract": "FULL_ABSTRACT_TEXT_HERE",
|
"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": "BIBTEX_CITATION_HERE",
|
"citation": "Rösel, Daniel. PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms. IE University, 2025.",
|
||||||
"isAccessibleForFree": true,
|
"isAccessibleForFree": true,
|
||||||
"license": "https://creativecommons.org/licenses/by/4.0/",
|
"license": "https://creativecommons.org/licenses/by/4.0/",
|
||||||
"mainEntity": {
|
"mainEntity": {
|
||||||
"@type": "WebPage",
|
"@type": "WebPage",
|
||||||
"@id": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE"
|
"@id": "https://velocitatem.github.io/PHANTOM/"
|
||||||
},
|
},
|
||||||
"about": [
|
"about": [
|
||||||
{
|
{
|
||||||
"@type": "Thing",
|
"@type": "Thing",
|
||||||
"name": "RESEARCH_AREA_1"
|
"name": "Dynamic Pricing"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": "Thing",
|
"@type": "Thing",
|
||||||
"name": "RESEARCH_AREA_2"
|
"name": "Agent Behavior Modeling"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -158,8 +147,7 @@
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Organization",
|
"@type": "Organization",
|
||||||
"name": "IE University",
|
"name": "IE University",
|
||||||
"url": "https://www.ie.edu",
|
"url": "https://www.ie.edu"
|
||||||
"logo": "TODO"
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
@@ -173,45 +161,80 @@
|
|||||||
|
|
||||||
<!-- More Works Dropdown -->
|
<!-- More Works Dropdown -->
|
||||||
<div class="more-works-container">
|
<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>
|
<i class="fas fa-flask"></i>
|
||||||
More Works
|
Project Links
|
||||||
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="more-works-dropdown" id="moreWorksDropdown">
|
<div class="more-works-dropdown" id="moreWorksDropdown">
|
||||||
<div class="dropdown-header">
|
<div class="dropdown-header">
|
||||||
<h4>More Works from Our Lab</h4>
|
<h4>Project Links</h4>
|
||||||
<button class="close-btn" onclick="toggleMoreWorks()">
|
<button class="close-btn" onclick="toggleMoreWorks()">
|
||||||
<i class="fas fa-times"></i>
|
<i class="fas fa-times"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="works-list">
|
<div class="works-list">
|
||||||
<!-- TODO: Replace with your lab's related works -->
|
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" class="work-item" target="_blank">
|
||||||
<a href="https://arxiv.org/abs/PAPER_ID_1" class="work-item" target="_blank">
|
|
||||||
<div class="work-info">
|
<div class="work-info">
|
||||||
<!-- TODO: Replace with actual paper title -->
|
<h5>Thesis PDF</h5>
|
||||||
<h5>Paper Title 1</h5>
|
<p>Latest public build of the full thesis document.</p>
|
||||||
<!-- TODO: Replace with brief description -->
|
<span class="work-venue">IE University, 2025</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
<!-- TODO: Add more related works or remove extra items -->
|
<a href="https://github.com/velocitatem/PHANTOM" class="work-item" target="_blank">
|
||||||
<a href="https://arxiv.org/abs/PAPER_ID_2" class="work-item" target="_blank">
|
|
||||||
<div class="work-info">
|
<div class="work-info">
|
||||||
<h5>Paper Title 2</h5>
|
<h5>PHANTOM Repository</h5>
|
||||||
<p>Brief description of the work and its main contribution.</p>
|
<p>Monorepo with paper source, platform code, and experiments.</p>
|
||||||
<span class="work-venue">Conference/Journal 2023</span>
|
<span class="work-venue">Open Source</span>
|
||||||
</div>
|
</div>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://arxiv.org/abs/PAPER_ID_3" class="work-item" target="_blank">
|
<a href="documentation/" class="work-item">
|
||||||
<div class="work-info">
|
<div class="work-info">
|
||||||
<h5>Paper Title 3</h5>
|
<h5>Documentation</h5>
|
||||||
<p>Brief description of the work and its main contribution.</p>
|
<p>Operator setup, configuration, architecture, and research pipeline (MkDocs).</p>
|
||||||
<span class="work-venue">Conference/Journal 2023</span>
|
<span class="work-venue">Platform</span>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-book"></i>
|
||||||
|
</a>
|
||||||
|
<a href="https://github.com/velocitatem/p4p" class="work-item" target="_blank">
|
||||||
|
<div class="work-info">
|
||||||
|
<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>
|
</div>
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
@@ -220,104 +243,112 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main id="main-content">
|
<main id="main-content">
|
||||||
<section class="hero">
|
<section class="hero defense-cover" id="top">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container is-max-desktop">
|
<div class="container is-max-desktop">
|
||||||
<div class="columns is-centered">
|
<div class="defense-hero-grid">
|
||||||
<div class="column has-text-centered">
|
<div class="defense-copy">
|
||||||
<h1 class="title is-1 publication-title">Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms</h1>
|
<p class="defense-kicker">IE University Bachelor's Thesis · 2025</p>
|
||||||
<div class="is-size-5 publication-authors">
|
<h1 class="title publication-title defense-title">PHANTOM</h1>
|
||||||
<span class="author-block">
|
<p class="defense-subtitle">Revenue management in the age of <span class="mark">AI agents</span>.</p>
|
||||||
<a href="https://alves.world" target="_blank">Daniel Rösel</a></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="is-size-5 publication-authors">
|
<div class="defense-chip-row" aria-label="Core thesis dimensions">
|
||||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
<span class="defense-chip">Private Valuation</span>
|
||||||
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
<span class="defense-chip">True Demand</span>
|
||||||
</div>
|
<span class="defense-chip">Constraints</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="column has-text-centered">
|
<div class="defense-meta-card" aria-label="Project authorship">
|
||||||
<div class="publication-links">
|
<span>Written by Daniel Rösel</span>
|
||||||
<span class="link-block">
|
<span class="dot" aria-hidden="true"></span>
|
||||||
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
<span>Advised by Alberto Martín Izquierdo</span>
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
</div>
|
||||||
<span class="icon">
|
|
||||||
<i class="fas fa-file-pdf"></i>
|
|
||||||
</span>
|
|
||||||
<span>Paper</span>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- TODO: Add your supplementary material PDF or remove this section -->
|
<div class="defense-links publication-links" aria-label="Project links">
|
||||||
<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">
|
||||||
<a href="static/pdfs/supplementary_material.pdf" target="_blank"
|
<span class="icon"><i class="fas fa-file-pdf"></i></span>
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
<span>Thesis</span>
|
||||||
<span class="icon">
|
</a>
|
||||||
<i class="fas fa-file-pdf"></i>
|
<a href="https://github.com/velocitatem/PHANTOM" target="_blank" class="external-link button is-normal is-rounded is-dark">
|
||||||
</span>
|
<span class="icon"><i class="fab fa-github"></i></span>
|
||||||
<span>Supplementary</span>
|
<span>Code</span>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit" target="_blank" class="external-link button is-normal is-rounded is-dark">
|
||||||
|
<span class="icon"><i class="fas fa-database"></i></span>
|
||||||
|
<span>Dataset</span>
|
||||||
|
</a>
|
||||||
|
<a href="documentation/" class="external-link button is-normal is-rounded is-light-outline">
|
||||||
|
<span class="icon"><i class="fas fa-book"></i></span>
|
||||||
|
<span>Docs</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="link-block">
|
<p class="tpu-credit">Powered by <span class="accent">Google</span> TPU Research Cloud.</p>
|
||||||
<a href="https://github.com/velocitatem/PHANTOM" target="_blank"
|
</div>
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
|
||||||
<span class="icon">
|
|
||||||
<i class="fab fa-github"></i>
|
|
||||||
</span>
|
|
||||||
<span>Code</span>
|
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<!-- TODO: Update with your arXiv paper ID -->
|
<div class="defense-visual" aria-hidden="true">
|
||||||
<span class="link-block">
|
<div class="defense-orbit-card">
|
||||||
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
|
<div class="defense-art-stack">
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
<img class="agent-art" src="static/images/agent.svg" alt="" loading="eager">
|
||||||
<span class="icon">
|
<span class="mini-token"><i class="fas fa-dollar-sign"></i></span>
|
||||||
<i class="ai ai-arxiv"></i>
|
<span class="mini-token"><i class="fas fa-wave-square"></i></span>
|
||||||
</span>
|
<span class="mini-token"><i class="fas fa-shield-alt"></i></span>
|
||||||
<span>arXiv</span>
|
</div>
|
||||||
</a>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="defense-overview-strip" aria-label="PHANTOM defense overview">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<div class="defense-overview-grid">
|
||||||
|
<article class="defense-overview-card">
|
||||||
|
<span class="num">01</span>
|
||||||
|
<h3>The vulnerability</h3>
|
||||||
|
<p>Repeated agent price queries collapse the Cost of Information that dynamic pricing depends on.</p>
|
||||||
|
</article>
|
||||||
|
<article class="defense-overview-card">
|
||||||
|
<span class="num">02</span>
|
||||||
|
<h3>The signal</h3>
|
||||||
|
<p>Human and agent sessions separate through transition-kernel behavior, not brittle bot flags.</p>
|
||||||
|
</article>
|
||||||
|
<article class="defense-overview-card">
|
||||||
|
<span class="num">03</span>
|
||||||
|
<h3>The defense</h3>
|
||||||
|
<p>Distributionally robust RL preserves pricing power under contaminated demand.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hero teaser defense-teaser">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<div class="hero-body">
|
||||||
|
<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>
|
</div>
|
||||||
</section>
|
</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>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- End teaser video -->
|
|
||||||
|
|
||||||
<!-- Paper abstract -->
|
<!-- Paper abstract -->
|
||||||
<section class="section hero is-light">
|
<section class="section hero is-light">
|
||||||
<div class="container is-max-desktop">
|
<div class="container is-max-desktop">
|
||||||
<div class="columns is-centered has-text-centered">
|
<div class="columns is-centered has-text-centered">
|
||||||
<div class="column is-four-fifths">
|
<div class="column is-four-fifths">
|
||||||
<h2 class="title is-3">Abstract</h2>
|
<h2 class="title is-3">The thesis, compressed.</h2>
|
||||||
<div class="content has-text-justified">
|
<div class="content has-text-justified">
|
||||||
<p>
|
<p>
|
||||||
This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model to prove separability as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
Dynamic pricing extracts margin by exploiting the gap between what a platform knows and what a buyer knows. A user who browses a hotel across several sessions signals intent; the platform raises the price accordingly. That information asymmetry — the <em>Cost of Information</em> — is the economic engine behind session-based pricing in travel, hospitality, and e-commerce.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
This work develops behavioral signature models using recommendation system techniques to profile session-level interaction, temporal engagement, and cross-session correlation. The AI Agent market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030, raising the question of how these systems should be designed for future robustness and how to maintain a competitive edge in the analytical components of e-commerce platforms.
|
LLM agents break the engine. An agent conducting reconnaissance in isolated sessions accumulates zero demand signal, then routes the purchase through a clean session at the floor price. As the number of independent querying agents grows, the realizable price converges to its minimum order statistic and COI collapses to zero. This is not a future risk; it is a structural failure mode in any pricing system that treats sessions independently.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
PHANTOM formalizes the failure, measures it on real human and agent interaction data, and builds a defense. We prove the COI erosion theorem, collect 29 labeled sessions (13 human, 16 agent) across hotel and airline storefronts under goal-driven tasks, learn class-specific Markov transition kernels, and train a Distributionally Robust RL pricing policy over a Wasserstein ambiguity set. Behavioral separability is statistically significant (Mann–Whitney <em>U</em> = 2.0, <em>p</em> = 0.0006). The per-session agent probability signal <em>f</em>(τ) feeds directly into the robust policy reward as a COI-leakage penalty.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,102 +358,148 @@
|
|||||||
<!-- End paper abstract -->
|
<!-- End paper abstract -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Defense-styled: new interaction environment (actor triptych) -->
|
||||||
|
<section class="section defense-block">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<h2 class="defense-heading">New interaction environment of <span class="mark">future commerce</span>.</h2>
|
||||||
|
<div class="actor-grid">
|
||||||
|
<div class="actor-card">
|
||||||
|
<div class="actor-art">
|
||||||
|
<img src="static/images/human.svg" alt="Isometric illustration of a human user as a cube character" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<h3>Users</h3>
|
||||||
|
<p>Have new needs and <strong>means of research</strong> & acquisition.</p>
|
||||||
|
</div>
|
||||||
|
<div class="actor-card">
|
||||||
|
<div class="actor-art">
|
||||||
|
<img src="static/images/agent.svg" alt="Isometric illustration of an LLM agent depicted as a cube robot" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<h3>Agents</h3>
|
||||||
|
<p>Use browsers (C/BUA) to look human and create <strong>clean sessions</strong>.</p>
|
||||||
|
</div>
|
||||||
|
<div class="actor-card">
|
||||||
|
<div class="actor-art">
|
||||||
|
<div class="actor-icon" aria-hidden="true"><i class="fas fa-store"></i></div>
|
||||||
|
</div>
|
||||||
|
<h3>Platforms</h3>
|
||||||
|
<p>Run <strong>standard pricing</strong> algorithms and experience revenue loss.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- End actor triptych -->
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Defense-styled: COI vulnerability -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<h2 class="defense-heading">When agents can repeatedly query prices, realizable <span class="underline">markup disappears</span>.</h2>
|
||||||
|
<div class="coi-equation">
|
||||||
|
<div class="formula">COI = <em>E</em>[P] − <u>p</u></div>
|
||||||
|
<p class="caption">Cost of Information — the expected premium dynamic pricing earns over the reservation price — collapses to zero as the number of independent querying agents grows.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- End COI vulnerability -->
|
||||||
|
|
||||||
|
|
||||||
|
<section class="section defense-method-section">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<h2 class="defense-heading">We study behavior, convert it into a control signal, and train a pricing policy that survives contamination.</h2>
|
||||||
|
<div class="defense-method-grid">
|
||||||
|
<article class="defense-step">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<h3>Observe</h3>
|
||||||
|
<p>Human participants and LLM agents complete goal-driven hotel and airline tasks. The storefront records behavior events and price quotes as timestamped trajectories.</p>
|
||||||
|
</article>
|
||||||
|
<article class="defense-step">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<h3>Distinguish</h3>
|
||||||
|
<p>Session paths become transition kernels. KL distance to human and agent prototypes yields a continuous agent-probability signal.</p>
|
||||||
|
</article>
|
||||||
|
<article class="defense-step">
|
||||||
|
<span class="step-num">03</span>
|
||||||
|
<h3>Defend</h3>
|
||||||
|
<p>A contamination generator mixes human and synthetic agent trajectories. A distributionally robust RL policy optimizes price under worst-case demand shifts.</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Defense-styled: three takeaways and forward-deploy line -->
|
||||||
|
<section class="section defense-block">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<h2 class="defense-heading">Agents <span class="mark">distort marketplace signals</span>. PHANTOM uses behavioral distinguishability and DR–RL to <span class="mark">preserve pricing power</span>.</h2>
|
||||||
|
<ol class="takeaways">
|
||||||
|
<li>
|
||||||
|
<span class="num">01</span>
|
||||||
|
<span>We can <strong>distinguish humans from agents</strong> at the transition-kernel level.<span class="stat">Mann–Whitney U = 2.0, p = 0.0006 across 29 labeled sessions.</span></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">02</span>
|
||||||
|
<span>Revenue <strong>declines monotonically</strong> in agent-contaminated systems.<span class="stat">Each 1.0 step of contamination α removes ~90,140 in cohort revenue (p < 10<sup>-77</sup>).</span></span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="num">03</span>
|
||||||
|
<span>Distributionally robust RL <strong>preserves margin</strong> under worst-case contamination.<span class="stat">Defended policy holds positive COI gap over baseline across α ∈ [0, 1].</span></span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p class="deploy-line">Our solution can be forward-deployed to any e-commerce platform to <strong>preserve their COI</strong>.</p>
|
||||||
|
<div class="hf-callout">
|
||||||
|
<div class="hf-emoji" aria-hidden="true">🤗</div>
|
||||||
|
<div>
|
||||||
|
<h4>WhoClickedIt — published on Hugging Face.</h4>
|
||||||
|
<p>~4k rows of labeled human and agent interaction data across hotel and airline tasks. Open dataset used for training the behavioral kernels.</p>
|
||||||
|
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit" target="_blank" rel="noopener">huggingface.co/datasets/velocitatem/whoclickedit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<!-- End takeaways -->
|
||||||
|
|
||||||
|
|
||||||
<!-- Image carousel -->
|
<!-- Image carousel -->
|
||||||
|
<!--
|
||||||
<section class="hero is-small">
|
<section class="hero is-small">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div id="results-carousel" class="carousel results-carousel">
|
<div id="results-carousel" class="carousel results-carousel">
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<!-- TODO: Replace with your research result images -->
|
|
||||||
<img src="static/images/carousel1.jpg" alt="First research result visualization" loading="lazy"/>
|
<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">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<!-- Your image here -->
|
|
||||||
<img src="static/images/carousel2.jpg" alt="Second research result visualization" loading="lazy"/>
|
<img src="static/images/carousel2.jpg" alt="Second research result visualization" loading="lazy"/>
|
||||||
<h2 class="subtitle has-text-centered">
|
<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>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<!-- Your image here -->
|
|
||||||
<img src="static/images/carousel3.jpg" alt="Third research result visualization" loading="lazy"/>
|
<img src="static/images/carousel3.jpg" alt="Third research result visualization" loading="lazy"/>
|
||||||
<h2 class="subtitle has-text-centered">
|
<h2 class="subtitle has-text-centered">
|
||||||
Third image description.
|
End-to-end architecture linking web interactions, pricing queries, event streams, and model updates.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<!-- Your image here -->
|
|
||||||
<img src="static/images/carousel4.jpg" alt="Fourth research result visualization" loading="lazy"/>
|
<img src="static/images/carousel4.jpg" alt="Fourth research result visualization" loading="lazy"/>
|
||||||
<h2 class="subtitle has-text-centered">
|
<h2 class="subtitle has-text-centered">
|
||||||
Fourth image description.
|
Contamination-aware evaluation compares robust and non-robust pricing behavior across alpha sweeps.
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
-->
|
||||||
<!-- End image carousel -->
|
<!-- End image carousel -->
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<!-- Youtube video -->
|
<!-- Defense Scenes video carousel removed -->
|
||||||
<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">
|
|
||||||
<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">
|
|
||||||
</video>
|
|
||||||
</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">
|
|
||||||
</video>
|
|
||||||
</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">
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<!-- End video carousel -->
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -432,10 +509,10 @@
|
|||||||
<!-- Paper poster -->
|
<!-- Paper poster -->
|
||||||
<section class="hero is-small is-light">
|
<section class="hero is-small is-light">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Poster</h2>
|
<h2 class="title is-3">Full thesis.</h2>
|
||||||
|
|
||||||
<iframe src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
<iframe title="PHANTOM thesis PDF" src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -457,7 +534,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<pre id="bibtex-code"><code>@thesis{Rosel2025PHANTOM,
|
<pre id="bibtex-code"><code>@thesis{Rosel2025PHANTOM,
|
||||||
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
|
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
|
||||||
author={R{\"o}sel, Daniel},
|
author={Rösel, Daniel},
|
||||||
school={IE University},
|
school={IE University},
|
||||||
year={2025},
|
year={2025},
|
||||||
address={Madrid, Spain},
|
address={Madrid, Spain},
|
||||||
|
|||||||
53
docs/mkdocs.yml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
site_name: PHANTOM Platform
|
||||||
|
site_description: Operator and research documentation for the PHANTOM dynamic pricing research platform.
|
||||||
|
site_url: https://velocitatem.github.io/PHANTOM/documentation/
|
||||||
|
site_author: Daniel Rösel
|
||||||
|
|
||||||
|
repo_url: https://github.com/velocitatem/PHANTOM
|
||||||
|
repo_name: velocitatem/PHANTOM
|
||||||
|
|
||||||
|
docs_dir: src
|
||||||
|
site_dir: documentation
|
||||||
|
strict: true
|
||||||
|
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
palette:
|
||||||
|
- scheme: default
|
||||||
|
primary: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-7
|
||||||
|
name: Switch to dark mode
|
||||||
|
- scheme: slate
|
||||||
|
primary: indigo
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-4
|
||||||
|
name: Switch to light mode
|
||||||
|
features:
|
||||||
|
- navigation.instant
|
||||||
|
- navigation.tracking
|
||||||
|
- content.code.copy
|
||||||
|
- search.suggest
|
||||||
|
- search.highlight
|
||||||
|
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- Setup: platform-setup.md
|
||||||
|
- Business overview: business.md
|
||||||
|
- Architecture: architecture.md
|
||||||
|
- Configuration: configuration.md
|
||||||
|
- Glossary: glossary.md
|
||||||
|
- Roadmap & implementation notes: roadmap.md
|
||||||
|
|
||||||
|
markdown_extensions:
|
||||||
|
- pymdownx.snippets:
|
||||||
|
base_path:
|
||||||
|
- ..
|
||||||
|
- pymdownx.superfences
|
||||||
|
- admonition
|
||||||
|
- tables
|
||||||
|
- toc:
|
||||||
|
permalink: true
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
1
docs/requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mkdocs-material>=9.5,<10
|
||||||
30
docs/src/architecture.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## System map
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
U[Human / Agent Browser] --> W[Next.js Web App]
|
||||||
|
W -->|Price requests| P[Pricing Provider]
|
||||||
|
W -->|Interaction events| B[Backend Ingest API]
|
||||||
|
B --> K[Kafka]
|
||||||
|
K --> A[Airflow + Worker Jobs]
|
||||||
|
A --> R[Redis Model Registry]
|
||||||
|
P -->|Session/global prices| W
|
||||||
|
E[Research Engine + Experiments] --> A
|
||||||
|
E --> R
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Event and training path (conceptual)
|
||||||
|
|
||||||
|
1. **Online:** The browser emits events; the backend publishes to **Kafka**; schedulers and workers consume for ETL and model registry updates.
|
||||||
|
2. **Offline:** Notebooks and scripts under `experiments/` transform logs; `**engine/`** runs simulations, training, and benchmarks; artifacts land under paths from `[lib/config.py](https://github.com/velocitatem/PHANTOM/blob/main/lib/config.py)`.
|
||||||
|
3. **Feedback:** Trained or rule-based policies surface through the **pricing provider** to the web app.
|
||||||
|
|
||||||
|
## Where to read more
|
||||||
|
|
||||||
|
- Ports and health checks: [README](https://github.com/velocitatem/PHANTOM/blob/main/README.md) and [Configuration](configuration.md).
|
||||||
|
- Formal notation for sessions, $\hat{q}$, and mixture demand: **Chapter 3 (Methodology)** in the thesis PDF.
|
||||||
|
|
||||||
39
docs/src/business.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Business overview
|
||||||
|
|
||||||
|
Dynamic pricing extracts margin by exploiting the information asymmetry between buyer and seller. When a user browses a flight or hotel across multiple sessions, each interaction accumulates demand signals that push the quoted price upward. That is the mechanism working as intended.
|
||||||
|
|
||||||
|
LLM agents break it. An agent can conduct reconnaissance—across dozens of isolated sessions, at machine speed—and then execute a purchase through a clean session that looks like a first-time visitor. The platform sees a low-engagement session and quotes a floor price. The margin that should have been captured, the **Cost of Information (COI)**, vanishes. At scale this is not a theoretical concern; it is a structural leak in any session-based pricing system.
|
||||||
|
|
||||||
|
**PHANTOM is a research platform for studying and defending against that leak.**
|
||||||
|
|
||||||
|
## Who it is for
|
||||||
|
|
||||||
|
| Role | What they get |
|
||||||
|
|---|---|
|
||||||
|
| Pricing and revenue researchers | A controlled lab with instrumented human and agent sessions, behavioral kernel estimation, and contamination simulation at configurable levels |
|
||||||
|
| Platform engineers evaluating agent risk | A concrete pipeline from behavioral event logs to a per-session agent-probability signal, ready to feed into an existing pricing provider |
|
||||||
|
| RL practitioners | A Distributionally Robust RL gym built on a Wasserstein ambiguity set, with benchmark tiers and sweep tooling out of the box |
|
||||||
|
|
||||||
|
## Core capabilities
|
||||||
|
|
||||||
|
**Behavioral fingerprinting.** PHANTOM logs interaction trajectories at the event level (action, item, timestamp) and fits separate Markov transition kernels for human and agent sessions via MLE. Per-session divergence scores (Δ_H, Δ_A) and a learned agent-probability signal f(τ) are computed on partial trajectories in real time, giving the pricing layer a continuous signal rather than a binary bot flag.
|
||||||
|
|
||||||
|
**Contamination simulation.** The contamination generator G(α) mixes real human trajectories with synthetic agent trajectories at a configurable ratio α. This lets you evaluate pricing robustness across the full spectrum from purely human traffic to fully automated demand, without needing live agent traffic in production.
|
||||||
|
|
||||||
|
**Robust policy training.** The defense gym trains pricing policies against the worst-case demand distribution within a Wasserstein ball around the generator's empirical distribution. The reward function penalizes COI leakage (weighted by agent probability) while bounding UX degradation for legitimate users.
|
||||||
|
|
||||||
|
## The path from logs to defense
|
||||||
|
|
||||||
|
A team: connects their catalog and ingest path → streams interaction events through Kafka → labels or weak-labels sessions → estimates behavioral kernels → varies α in simulation → trains and benchmarks robust policies. The full walkthrough is in [Setup](platform-setup.md).
|
||||||
|
|
||||||
|
## Scope and honest caveats
|
||||||
|
|
||||||
|
This is a **research stack**, not a hosted service:
|
||||||
|
|
||||||
|
- It ships two demo verticals (`hotel`, `airline`); a new catalog requires engineering work on events and reward features.
|
||||||
|
- Kernel estimates are research-grade until validated on your traffic distribution.
|
||||||
|
- There is no built-in compliance layer for regulated pricing markets.
|
||||||
|
|
||||||
|
The thesis PDF contains the formal proofs, the COI erosion theorem, and the full DR-RL formulation. The code operationalizes those constructs—every term in the reward function maps to something computed from your logs.
|
||||||
|
|
||||||
|
**Thesis PDF:** [thesis-latest.pdf](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf) — Introduction and Chapter 3 cover the problem statement, contributions, and formal model.
|
||||||
63
docs/src/configuration.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Configuration reference
|
||||||
|
|
||||||
|
This page condenses tables from `[README.md](https://github.com/velocitatem/PHANTOM/blob/main/README.md)` and points to code. Authoritative env templates: `[.env.example](https://github.com/velocitatem/PHANTOM/blob/main/.env.example)`, `[.env.sweep.example](https://github.com/velocitatem/PHANTOM/blob/main/.env.sweep.example)`.
|
||||||
|
|
||||||
|
## Core runtime (`.env`)
|
||||||
|
|
||||||
|
|
||||||
|
| Variable | Purpose | Typical value |
|
||||||
|
| ------------------------------- | ------------------------------ | ----------------------- |
|
||||||
|
| `STORE_MODE` | Web mode (`hotel` / `airline`) | `hotel` |
|
||||||
|
| `BACKEND_PORT` | Backend API | `5000` |
|
||||||
|
| `PROVIDER_PORT` | Pricing provider | `5001` |
|
||||||
|
| `KAFKA_HOST` | Kafka broker host | `localhost` |
|
||||||
|
| `KAFKA_PORT` | Kafka port | `9092` |
|
||||||
|
| `REDIS_PORT` | Redis port | `6377` |
|
||||||
|
| `REDPANDA_CONSOLE_PORT` | Kafka UI | `8084` (see compose) |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | Catalog / data | required for full stack |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Catalog / data | required |
|
||||||
|
| `AIRFLOW_FERNET_KEY` | Airflow | required |
|
||||||
|
| `AIRFLOW_SECRET_KEY` | Airflow web | required |
|
||||||
|
|
||||||
|
|
||||||
|
Web client validation: `[web/src/lib/config.ts](https://github.com/velocitatem/PHANTOM/blob/main/web/src/lib/config.ts)`.
|
||||||
|
|
||||||
|
## Training / sweeps (`.env.sweep`)
|
||||||
|
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --------------- | ----------------------------------------------- |
|
||||||
|
| `WANDB_API_KEY` | Weights & Biases |
|
||||||
|
| `WANDB_ENTITY` | Optional override |
|
||||||
|
| `WANDB_PROJECT` | Project name (default `capstone`) |
|
||||||
|
| `GITHUB_TOKEN` | Bootstrap / workers |
|
||||||
|
| `SWEEP_ID` | Sweep agents (`train.agent`, `benchmark.agent`) |
|
||||||
|
|
||||||
|
|
||||||
|
## Path overrides (`PHANTOM_*`)
|
||||||
|
|
||||||
|
Defined in `[lib/config.py](https://github.com/velocitatem/PHANTOM/blob/main/lib/config.py)`:
|
||||||
|
|
||||||
|
|
||||||
|
| Variable | Default (conceptual) |
|
||||||
|
| ---------------------------- | ----------------------------------- |
|
||||||
|
| `PHANTOM_DATA_DIR` | `data/` |
|
||||||
|
| `PHANTOM_EXPERIMENTS_DIR` | `experiments/` |
|
||||||
|
| `PHANTOM_SIM_RUNS_DIR` | `sim/rl/runs` |
|
||||||
|
| `PHANTOM_MODEL_REGISTRY_DIR` | `data/models` |
|
||||||
|
| `PHANTOM_COLLECTED_DATA_DIR` | `experiments/agents/collected_data` |
|
||||||
|
|
||||||
|
|
||||||
|
## Makefile entrypoints
|
||||||
|
|
||||||
|
|
||||||
|
| Goal | Command |
|
||||||
|
| ---------------- | ------------------------------------------- |
|
||||||
|
| Platform up/down | `make platform.up` / `make platform.down` |
|
||||||
|
| Web dev | `make web.dev` |
|
||||||
|
| Train | `make train` (+ `LOCAL_TRAIN_ARGS`) |
|
||||||
|
| Benchmark | `make benchmark` (+ `LOCAL_BENCHMARK_ARGS`) |
|
||||||
|
| Docs site | `make docs.platform` |
|
||||||
|
|
||||||
|
|
||||||
|
See `make help` for the full list.
|
||||||
17
docs/src/glossary.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Glossary
|
||||||
|
|
||||||
|
Short definitions point to the thesis **Terminology** appendix in the [PDF](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf) for full precision.
|
||||||
|
|
||||||
|
| Term | Meaning (operational) |
|
||||||
|
| --- | --- |
|
||||||
|
| **COI (Cost of Information)** | Expected price premium above a floor under the platform’s policy; thesis KPI for pricing power. |
|
||||||
|
| **Trajectory \(\tau_s\)** | Ordered session events used as the behavioral record. |
|
||||||
|
| **Demand proxy \(\hat{q}\)** | Weighted aggregation of actions—what the platform observes instead of true demand. |
|
||||||
|
| **Contamination \(\alpha\)** | Agent share in the mixture demand model (thesis); not automatically “% of bots” in raw logs. |
|
||||||
|
| **Transition kernel \(\hat{\mathcal{T}}\)** | MLE Markov model over behavioral states / events for class \(H\) or \(A\). |
|
||||||
|
| **\(\Delta_H,\Delta_A\)** | Divergence scores vs human/agent prototypes (thesis notation). |
|
||||||
|
| **\(f(\tau)\)** | Weak agent probability from trajectory (implementation: `engine/lib/coi.py`). |
|
||||||
|
| **\(\mathcal{G}(\alpha)\)** | Contamination generator: synthetic agent trajectories to reach mixture level \(\alpha\). |
|
||||||
|
| **DR-RL** | Distributionally robust reinforcement learning training narrative in the thesis. |
|
||||||
|
| **Ambiguity set / Wasserstein** | Robust optimization neighborhood around an empirical demand law. |
|
||||||
|
| **Kappa–Lambda architecture** | Thesis term for streaming (online) vs batch/offline learning loops. |
|
||||||
23
docs/src/index.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# PHANTOM
|
||||||
|
|
||||||
|
LLM agents are quietly eroding the pricing power of dynamic pricing systems. They conduct reconnaissance across isolated sessions at machine speed and execute purchases through clean sessions that quote floor prices. The margin that should have accumulated never does.
|
||||||
|
|
||||||
|
PHANTOM is a research platform for measuring, simulating, and defending against that erosion. It provides behavioral fingerprinting of human vs agent sessions, a contamination generator for controlled experiments, and a Distributionally Robust RL gym for training pricing policies that hold up under automated demand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to start
|
||||||
|
|
||||||
|
| Document | What it covers |
|
||||||
|
| --- | --- |
|
||||||
|
| [Business overview](business.md) | The problem, capabilities, and who this is for |
|
||||||
|
| [Setup](platform-setup.md) | Full bring-up: Docker stack, ingest, behavioral kernels, contamination, RL training |
|
||||||
|
| [Architecture](architecture.md) | Service map and data flow |
|
||||||
|
| [Configuration reference](configuration.md) | Env vars, paths, and Makefile targets |
|
||||||
|
| [Roadmap & notes](roadmap.md) | What is turnkey vs research-grade |
|
||||||
|
|
||||||
|
## Key references
|
||||||
|
|
||||||
|
- **Thesis PDF:** [thesis-latest.pdf](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf) — formal model, COI erosion proof, DR-RL formulation
|
||||||
|
- **Repo root:** [`SETUP.md`](https://github.com/velocitatem/PHANTOM/blob/main/SETUP.md) | [`README.md`](https://github.com/velocitatem/PHANTOM/blob/main/README.md)
|
||||||
|
- **Academic landing page:** [velocitatem.github.io/PHANTOM/](https://velocitatem.github.io/PHANTOM/)
|
||||||
5
docs/src/platform-setup.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Setup
|
||||||
|
|
||||||
|
The content below is included from the repository root file `SETUP.md` (single source of truth: platform bring-up, kernels, contamination, RL training, and thesis pointers by chapter).
|
||||||
|
|
||||||
|
--8<-- "SETUP.md"
|
||||||
26
docs/src/roadmap.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Roadmap & implementation notes
|
||||||
|
|
||||||
|
This page is the **honesty pass** from the documentation plan: what clients can expect today versus what remains research-heavy.
|
||||||
|
|
||||||
|
## Turnkey in this repository
|
||||||
|
|
||||||
|
- **Local stack:** Docker Compose services for backend, Kafka, Redis, Airflow, pricing provider, etc.; Next.js via `make web.dev` (see [Platform setup](platform-setup.md)).
|
||||||
|
- **Demo verticals:** `hotel` and `airline` storefront modes.
|
||||||
|
- **Engine:** Benchmarks and training entrypoints (`make train`, `make benchmark`), KL-based agent scoring in `[engine/lib/coi.py](https://github.com/velocitatem/PHANTOM/blob/main/engine/lib/coi.py)`, simulator mixing in `[engine/engine.py](https://github.com/velocitatem/PHANTOM/blob/main/engine/engine.py)`.
|
||||||
|
- **Orchestration hooks:** Ray/TPU scripts (`submit_ray_job.sh`, `make tpu.ray.`*), W&B sweep agents, Docker trainer publish target.
|
||||||
|
|
||||||
|
## Usually requires custom engineering
|
||||||
|
|
||||||
|
- **Non-Supabase catalog** or checkout flows without adapting the web + backend contracts.
|
||||||
|
- **Production SLAs** on Kafka, schema registry, or PII boundaries for your jurisdiction.
|
||||||
|
- **Tight coupling** to a legacy pricing engine without mapping its API to the provider abstraction.
|
||||||
|
|
||||||
|
## Thesis vs code
|
||||||
|
|
||||||
|
- The **thesis** states theorems and constructions (COI erosion, kernels, \mathcal{G}(\alpha), DR-RL).
|
||||||
|
- The **codebase** implements a **subset** of that story for experiments: verify CLI flags and simulator assumptions before claiming 1:1 equivalence with every equation.
|
||||||
|
- **Catalog-scale kernel expansion** is discussed in **Chapter 3** with explicit validation caveats—do not assume row-stochasticity and Markov structure are automatically preserved at full product cardinality without review.
|
||||||
|
|
||||||
|
## Suggested client messaging
|
||||||
|
|
||||||
|
Position PHANTOM as a **reproducible research and evaluation stack** for agent-aware pricing, with a path to custom integration—not as a black-box “turn on anti-agent pricing” product without data and engineering investment.
|
||||||
740
docs/static/css/defense-theme.css
vendored
Normal file
@@ -0,0 +1,740 @@
|
|||||||
|
:root {
|
||||||
|
--phantom-bg: #eef3f7;
|
||||||
|
--phantom-paper: rgba(255, 255, 255, 0.78);
|
||||||
|
--phantom-paper-solid: #ffffff;
|
||||||
|
--phantom-ink: #1f2a38;
|
||||||
|
--phantom-muted: #59636e;
|
||||||
|
--phantom-faint: #dce5eb;
|
||||||
|
--phantom-line: rgba(31, 42, 56, 0.13);
|
||||||
|
--phantom-teal: #28aaa5;
|
||||||
|
--phantom-teal-dark: #16837f;
|
||||||
|
--phantom-blue: #527dad;
|
||||||
|
--phantom-blue-soft: rgba(82, 125, 173, 0.18);
|
||||||
|
--phantom-shadow: 0 28px 80px rgba(31, 42, 56, 0.12);
|
||||||
|
--phantom-soft-shadow: 0 14px 45px rgba(31, 42, 56, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
background: var(--phantom-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
background:
|
||||||
|
radial-gradient(70rem 22rem at 72% 8%, rgba(31, 42, 56, 0.15), transparent 58%),
|
||||||
|
radial-gradient(54rem 24rem at 20% 62%, rgba(31, 42, 56, 0.12), transparent 62%),
|
||||||
|
linear-gradient(180deg, #f7fafc 0%, var(--phantom-bg) 48%, #f8fafb 100%);
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: -20vh -10vw auto -10vw;
|
||||||
|
height: 72vh;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.88;
|
||||||
|
filter: blur(30px);
|
||||||
|
background:
|
||||||
|
radial-gradient(45rem 16rem at 9% 34%, rgba(18, 23, 31, 0.11), transparent 62%),
|
||||||
|
radial-gradient(35rem 11rem at 65% 24%, rgba(18, 23, 31, 0.13), transparent 65%),
|
||||||
|
radial-gradient(42rem 17rem at 45% 88%, rgba(82, 125, 173, 0.13), transparent 68%);
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--phantom-blue);
|
||||||
|
text-decoration-thickness: 0.08em;
|
||||||
|
text-underline-offset: 0.18em;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container.is-max-desktop {
|
||||||
|
max-width: 1180px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section,
|
||||||
|
.hero-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 5rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title,
|
||||||
|
.subtitle,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
button,
|
||||||
|
.button {
|
||||||
|
/* important needed to beat index.css serif heading rule */
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace !important;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Defense cover */
|
||||||
|
.defense-cover {
|
||||||
|
min-height: 92vh;
|
||||||
|
padding: 2rem 0 3.5rem;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.68), rgba(238, 243, 247, 0.92));
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-cover::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -10vw;
|
||||||
|
bottom: -13rem;
|
||||||
|
width: 64vw;
|
||||||
|
height: 26rem;
|
||||||
|
background: rgba(31, 42, 56, 0.12);
|
||||||
|
filter: blur(38px);
|
||||||
|
border-radius: 65% 35% 47% 53%;
|
||||||
|
transform: rotate(-8deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-cover .hero-body {
|
||||||
|
padding: 5.5rem 1.5rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-hero-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(280px, 0.72fr);
|
||||||
|
gap: clamp(2rem, 6vw, 7rem);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-kicker,
|
||||||
|
.defense-meta-row,
|
||||||
|
.defense-chip-row,
|
||||||
|
.defense-mini-label,
|
||||||
|
.tpu-credit {
|
||||||
|
color: rgba(31, 42, 56, 0.66);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-kicker {
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-title.defense-title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: clamp(4.25rem, 12vw, 10rem);
|
||||||
|
line-height: 0.82;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-subtitle {
|
||||||
|
max-width: 950px;
|
||||||
|
margin: clamp(1.5rem, 3vw, 2.3rem) 0 0;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: clamp(1.55rem, 3.2vw, 3.35rem);
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 400;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark,
|
||||||
|
mark,
|
||||||
|
.defense-highlight {
|
||||||
|
background: linear-gradient(0deg, var(--phantom-blue) 0%, var(--phantom-blue) 100%);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0 0.1em;
|
||||||
|
line-height: inherit;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-chip-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.7rem 0.9rem;
|
||||||
|
margin-top: 2.15rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-size: clamp(0.92rem, 1.4vw, 1.2rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
color: rgba(31, 42, 56, 0.70);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-chip::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.42rem;
|
||||||
|
height: 0.42rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--phantom-blue);
|
||||||
|
box-shadow: 0 0 0 0.35rem var(--phantom-blue-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-meta-card {
|
||||||
|
margin-top: 2.4rem;
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.6rem 1rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border: 1px solid var(--phantom-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.58);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
box-shadow: var(--phantom-soft-shadow);
|
||||||
|
color: rgba(31, 42, 56, 0.74);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-meta-card .dot {
|
||||||
|
width: 0.26rem;
|
||||||
|
height: 0.26rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--phantom-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-links .button,
|
||||||
|
.publication-links .button {
|
||||||
|
border: 1px solid rgba(31, 42, 56, 0.18) !important;
|
||||||
|
background: rgba(31, 42, 56, 0.92) !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
box-shadow: 0 12px 30px rgba(31, 42, 56, 0.16);
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease, background 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-links .button:hover,
|
||||||
|
.publication-links .button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 16px 40px rgba(31, 42, 56, 0.20);
|
||||||
|
background: var(--phantom-ink) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-links .button.is-light-outline {
|
||||||
|
background: rgba(255, 255, 255, 0.72) !important;
|
||||||
|
color: var(--phantom-ink) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpu-credit {
|
||||||
|
margin-top: 1.35rem;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpu-credit .accent {
|
||||||
|
color: var(--phantom-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-visual {
|
||||||
|
justify-self: end;
|
||||||
|
width: min(100%, 430px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-orbit-card {
|
||||||
|
position: relative;
|
||||||
|
min-height: 435px;
|
||||||
|
border: 1px solid var(--phantom-line);
|
||||||
|
border-radius: 2rem;
|
||||||
|
background: linear-gradient(145deg, rgba(255, 255, 255, 0.84), rgba(238, 243, 247, 0.68));
|
||||||
|
box-shadow: var(--phantom-shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-orbit-card::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 2rem;
|
||||||
|
border: 1px dashed rgba(31, 42, 56, 0.22);
|
||||||
|
border-radius: 44% 56% 58% 42%;
|
||||||
|
transform: rotate(-14deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-orbit-card::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -4rem;
|
||||||
|
bottom: -5rem;
|
||||||
|
width: 18rem;
|
||||||
|
height: 12rem;
|
||||||
|
background: rgba(40, 170, 165, 0.14);
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-art-stack {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
min-height: 435px;
|
||||||
|
place-items: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-art-stack .agent-art {
|
||||||
|
width: min(70%, 250px);
|
||||||
|
transform: translateY(-12px);
|
||||||
|
filter: drop-shadow(0 28px 28px rgba(31, 42, 56, 0.12));
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-art-stack .mini-token {
|
||||||
|
position: absolute;
|
||||||
|
width: 4.8rem;
|
||||||
|
height: 4.8rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1.3rem;
|
||||||
|
border: 1px solid rgba(82, 125, 173, 0.28);
|
||||||
|
background: rgba(255, 255, 255, 0.72);
|
||||||
|
color: var(--phantom-blue);
|
||||||
|
box-shadow: var(--phantom-soft-shadow);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-art-stack .mini-token:nth-child(2) { top: 3.1rem; right: 3.2rem; }
|
||||||
|
.defense-art-stack .mini-token:nth-child(3) { left: 2.8rem; bottom: 6.1rem; color: var(--phantom-teal-dark); }
|
||||||
|
.defense-art-stack .mini-token:nth-child(4) { right: 5.6rem; bottom: 3.2rem; color: var(--phantom-ink); }
|
||||||
|
|
||||||
|
/* Defense overview strip */
|
||||||
|
.defense-overview-strip {
|
||||||
|
margin-top: -3.8rem;
|
||||||
|
padding: 0 1.5rem 4.6rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-card,
|
||||||
|
.actor-card,
|
||||||
|
.defense-step,
|
||||||
|
.hf-callout,
|
||||||
|
.coi-equation,
|
||||||
|
pre,
|
||||||
|
.publication-banner {
|
||||||
|
border: 1px solid var(--phantom-line);
|
||||||
|
background: var(--phantom-paper);
|
||||||
|
box-shadow: var(--phantom-soft-shadow);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-card {
|
||||||
|
min-height: 9rem;
|
||||||
|
padding: 1.3rem;
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-card .num {
|
||||||
|
color: var(--phantom-blue);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-card h3 {
|
||||||
|
margin: 1rem 0 0.45rem;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: clamp(1.15rem, 2vw, 1.65rem);
|
||||||
|
line-height: 1.05;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-overview-card p {
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main sections */
|
||||||
|
.hero.teaser,
|
||||||
|
.hero.is-small,
|
||||||
|
.hero.is-small.is-light,
|
||||||
|
.hero.is-light,
|
||||||
|
.section.hero.is-light,
|
||||||
|
.defense-block {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-banner {
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-banner img,
|
||||||
|
.actor-art img {
|
||||||
|
filter: drop-shadow(0 18px 22px rgba(31, 42, 56, 0.10));
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-heading,
|
||||||
|
.title.is-3,
|
||||||
|
.content h2.title {
|
||||||
|
color: var(--phantom-ink) !important;
|
||||||
|
font-size: clamp(2rem, 4.5vw, 4.8rem) !important;
|
||||||
|
/* enough leading that .mark backgrounds on wrapped lines don't overlap adjacent text */
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
letter-spacing: -0.06em !important;
|
||||||
|
text-align: left !important;
|
||||||
|
margin-bottom: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title.is-4,
|
||||||
|
.content h3.title {
|
||||||
|
color: var(--phantom-ink) !important;
|
||||||
|
font-size: clamp(1.35rem, 2.2vw, 2rem) !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
letter-spacing: -0.05em !important;
|
||||||
|
margin-top: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
line-height: 1.68;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content.has-text-justified,
|
||||||
|
.content.has-text-justified p {
|
||||||
|
text-align: left !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p + p {
|
||||||
|
margin-top: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-block,
|
||||||
|
.section.hero.is-light {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-block::before,
|
||||||
|
.section.hero.is-light::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 1rem 0 auto 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(31, 42, 56, 0.13), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-block .defense-heading {
|
||||||
|
margin-bottom: 3.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1.1rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 25rem;
|
||||||
|
padding: 1.4rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-card h3 {
|
||||||
|
margin-top: 1.1rem;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: clamp(1.6rem, 3vw, 2.55rem);
|
||||||
|
line-height: 1;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-card p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-art {
|
||||||
|
min-height: 12rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-art img {
|
||||||
|
max-height: 10.5rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actor-icon {
|
||||||
|
display: grid;
|
||||||
|
width: 8.75rem;
|
||||||
|
height: 8.75rem;
|
||||||
|
place-items: center;
|
||||||
|
border: 2px solid rgba(82, 125, 173, 0.55);
|
||||||
|
border-radius: 1.7rem;
|
||||||
|
background: linear-gradient(145deg, rgba(40, 170, 165, 0.20), rgba(255, 255, 255, 0.94));
|
||||||
|
color: var(--phantom-teal-dark);
|
||||||
|
font-size: 3.6rem;
|
||||||
|
transform: rotate(-6deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 0.09em;
|
||||||
|
text-underline-offset: 0.13em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coi-equation {
|
||||||
|
border-radius: 1.7rem;
|
||||||
|
padding: clamp(1.6rem, 4vw, 3rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.coi-equation .formula {
|
||||||
|
color: #111111;
|
||||||
|
font-family: Georgia, "Times New Roman", serif;
|
||||||
|
font-size: clamp(3rem, 9vw, 7.6rem);
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: -0.07em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coi-equation .caption {
|
||||||
|
max-width: 780px;
|
||||||
|
margin-top: 1.3rem;
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-method-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-step {
|
||||||
|
border-radius: 1.4rem;
|
||||||
|
padding: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-step .step-num {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 2.6rem;
|
||||||
|
height: 2.6rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid rgba(40, 170, 165, 0.38);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--phantom-teal-dark);
|
||||||
|
background: rgba(40, 170, 165, 0.10);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-step h3 {
|
||||||
|
margin: 0 0 0.55rem !important;
|
||||||
|
font-size: 1.45rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-step p {
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeaways {
|
||||||
|
list-style: none;
|
||||||
|
margin: 2rem 0 0 !important;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeaways li {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5rem minmax(0, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 1.2rem 1.3rem;
|
||||||
|
border: 1px solid var(--phantom-line);
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeaways .num {
|
||||||
|
color: var(--phantom-blue);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeaways .stat {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.45rem;
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-line {
|
||||||
|
margin: 2rem 0 0;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: clamp(1.45rem, 3vw, 2.5rem);
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.deploy-line strong {
|
||||||
|
background: var(--phantom-blue);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 0 0.1em;
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-callout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
padding: 1.2rem;
|
||||||
|
border-radius: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-emoji {
|
||||||
|
width: 3.6rem;
|
||||||
|
height: 3.6rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: rgba(255, 215, 0, 0.20);
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-callout h4 {
|
||||||
|
margin: 0 0 0.3rem;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
font-size: 1.12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hf-callout p {
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre#bibtex-code,
|
||||||
|
pre {
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
color: var(--phantom-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
background: rgba(255, 255, 255, 0.54);
|
||||||
|
border-top: 1px solid var(--phantom-line);
|
||||||
|
color: var(--phantom-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-works-container,
|
||||||
|
.scroll-to-top {
|
||||||
|
font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-works-btn,
|
||||||
|
.scroll-to-top {
|
||||||
|
background: rgba(255, 255, 255, 0.78) !important;
|
||||||
|
color: var(--phantom-ink) !important;
|
||||||
|
border: 1px solid var(--phantom-line) !important;
|
||||||
|
box-shadow: var(--phantom-soft-shadow) !important;
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-works-dropdown {
|
||||||
|
border: 1px solid var(--phantom-line) !important;
|
||||||
|
border-radius: 1.2rem !important;
|
||||||
|
box-shadow: var(--phantom-shadow) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.defense-hero-grid,
|
||||||
|
.defense-overview-grid,
|
||||||
|
.actor-grid,
|
||||||
|
.defense-method-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-cover .hero-body {
|
||||||
|
padding-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-visual {
|
||||||
|
justify-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-orbit-card,
|
||||||
|
.defense-art-stack {
|
||||||
|
min-height: 330px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-art-stack .agent-art {
|
||||||
|
width: min(56%, 205px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-meta-card {
|
||||||
|
border-radius: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.publication-title.defense-title {
|
||||||
|
font-size: clamp(4rem, 18vw, 6.2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.section {
|
||||||
|
padding: 3.6rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-cover .hero-body {
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
padding-right: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-subtitle {
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.takeaways li,
|
||||||
|
.hf-callout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-chip-row,
|
||||||
|
.defense-links {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.defense-links .button {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
1024
docs/static/css/index.css
vendored
123
docs/static/images/agent.svg
vendored
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="bgShadow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#143547" stop-opacity="0.15" />
|
||||||
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<radialGradient id="innerShadow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#15334c" stop-opacity="0.35" />
|
||||||
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeTop" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#e2f1f7" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeLeft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5caec4" />
|
||||||
|
<stop offset="100%" stop-color="#337899" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeRight" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9ce6d7" />
|
||||||
|
<stop offset="100%" stop-color="#5fbfa9" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="headTop" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#eef6fa" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="headLeft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#d9eaf0" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="headRight" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#e3eff5" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="screenGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#0c2340" />
|
||||||
|
<stop offset="100%" stop-color="#16355c" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="recessGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#bcdde8" />
|
||||||
|
<stop offset="100%" stop-color="#edf6f9" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stroke-main { stroke: #1a446c; stroke-width: 7px; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.stroke-medium { stroke: #1a446c; stroke-width: 5.5px; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.stroke-thin { stroke: #1a446c; stroke-width: 4px; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.circuit-line { fill: none; stroke: #ffffff; stroke-width: 4px; stroke-linecap: round; stroke-linejoin: round; stroke-opacity: 0.9; }
|
||||||
|
.circuit-node { fill: #ffffff; stroke: #1a446c; stroke-width: 3.5px; }
|
||||||
|
.glow-eye { fill: #6bf2ff; filter: drop-shadow(0px 0px 3px rgba(107,242,255,0.8)); }
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ellipse cx="400" cy="700" rx="220" ry="75" fill="url(#bgShadow)" />
|
||||||
|
|
||||||
|
<polygon points="220,415 400,519 400,710 220,606" fill="url(#cubeLeft)" />
|
||||||
|
<polygon points="400,519 580,415 580,606 400,710" fill="url(#cubeRight)" />
|
||||||
|
|
||||||
|
<polygon points="220,415 400,519 400,450 220,346" fill="#ffffff" fill-opacity="0.35" />
|
||||||
|
<polygon points="400,519 580,415 580,346 400,450" fill="#ffffff" fill-opacity="0.15" />
|
||||||
|
|
||||||
|
<polygon points="220,346 400,242 580,346 400,450" fill="url(#cubeTop)" />
|
||||||
|
|
||||||
|
<polygon points="220,346 400,242 580,346 580,606 400,710 220,606" fill="none" class="stroke-main" />
|
||||||
|
<line x1="400" y1="450" x2="400" y2="710" class="stroke-main" />
|
||||||
|
<line x1="220" y1="415" x2="400" y2="519" class="stroke-main" />
|
||||||
|
<line x1="400" y1="519" x2="580" y2="415" class="stroke-main" />
|
||||||
|
|
||||||
|
<path d="M 260,510 L 315,542 L 315,615" class="circuit-line" />
|
||||||
|
<circle cx="260" cy="510" r="6" class="circuit-node" />
|
||||||
|
|
||||||
|
<path d="M 285,575 L 340,607 L 340,670" class="circuit-line" />
|
||||||
|
<circle cx="285" cy="575" r="6" class="circuit-node" />
|
||||||
|
|
||||||
|
<path d="M 365,550 L 365,685" class="circuit-line" />
|
||||||
|
<circle cx="365" cy="550" r="6" class="circuit-node" />
|
||||||
|
|
||||||
|
<path d="M 540,510 L 460,556 L 460,640" class="circuit-line" />
|
||||||
|
<circle cx="540" cy="510" r="6" class="circuit-node" />
|
||||||
|
|
||||||
|
<path d="M 520,570 L 485,590 L 485,665" class="circuit-line" />
|
||||||
|
<circle cx="520" cy="570" r="6" class="circuit-node" />
|
||||||
|
|
||||||
|
|
||||||
|
<polygon points="275,346 400,274 525,346 400,418" fill="url(#recessGrad)" class="stroke-thin" />
|
||||||
|
<ellipse cx="400" cy="346" rx="65" ry="28" fill="url(#innerShadow)" />
|
||||||
|
|
||||||
|
|
||||||
|
<polygon points="340,335 400,300 460,335 400,370" fill="#142d42" fill-opacity="0.25" />
|
||||||
|
|
||||||
|
<polygon points="310,250 400,302 400,397 310,345" fill="url(#headLeft)" />
|
||||||
|
<polygon points="400,302 490,250 490,345 400,397" fill="url(#headRight)" />
|
||||||
|
<polygon points="310,250 400,198 490,250 400,302" fill="url(#headTop)" />
|
||||||
|
|
||||||
|
<polygon points="310,250 400,198 490,250 490,345 400,397 310,345" fill="none" class="stroke-medium" />
|
||||||
|
<line x1="400" y1="302" x2="400" y2="397" class="stroke-medium" />
|
||||||
|
|
||||||
|
<polygon points="325,268 385,303 385,370 325,335" fill="url(#screenGrad)" class="stroke-thin" />
|
||||||
|
|
||||||
|
<line x1="343" y1="306" x2="343" y2="322" stroke="#6bf2ff" stroke-width="7.5" stroke-linecap="round" class="glow-eye" />
|
||||||
|
<line x1="367" y1="320" x2="367" y2="336" stroke="#6bf2ff" stroke-width="7.5" stroke-linecap="round" class="glow-eye" />
|
||||||
|
|
||||||
|
|
||||||
|
<ellipse cx="400" cy="230" rx="12" ry="6" fill="#325b7f" />
|
||||||
|
|
||||||
|
<path d="M 320,165 C 320,142 480,142 480,165" fill="none" stroke="#63cbd1" stroke-width="6" stroke-linecap="round" stroke-opacity="0.8" />
|
||||||
|
|
||||||
|
<line x1="400" y1="230" x2="400" y2="135" class="stroke-medium" />
|
||||||
|
|
||||||
|
<path d="M 480,165 C 480,188 320,188 320,165" fill="none" stroke="#63cbd1" stroke-width="6" stroke-linecap="round" />
|
||||||
|
|
||||||
|
<circle cx="400" cy="135" r="10" fill="#5cbfa9" class="stroke-medium" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.5 KiB |
BIN
docs/static/images/apple-touch-icon.png
vendored
Normal file
|
After Width: | Height: | Size: 25 KiB |
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"><tspan text-decoration="underline">p</tspan></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] - <tspan text-decoration="underline">p</tspan></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: Distinguishability Distributions -->
|
||||||
|
<g transform="translate(700, 600)">
|
||||||
|
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Distinguishability 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 |
BIN
docs/static/images/favicon.ico
vendored
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 15 KiB |
87
docs/static/images/human.svg
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="100%" height="100%">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="dropShadow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#143547" stop-opacity="0.15" />
|
||||||
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<radialGradient id="sphereShadow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#1c4561" stop-opacity="0.4" />
|
||||||
|
<stop offset="60%" stop-color="#2a5c7c" stop-opacity="0.15" />
|
||||||
|
<stop offset="100%" stop-color="#ffffff" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeTop" x1="25%" y1="0%" x2="75%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#e1eff7" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeLeft" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#5faec4" />
|
||||||
|
<stop offset="100%" stop-color="#367fa3" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="cubeRight" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#9ce6d7" />
|
||||||
|
<stop offset="100%" stop-color="#67c7b3" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="rimLeft" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#cbdfe8" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="rimRight" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="100%" stop-color="#d5e6ed" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<linearGradient id="sphereGrad" x1="30%" y1="10%" x2="70%" y2="90%">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="15%" stop-color="#fffdf3" />
|
||||||
|
<stop offset="65%" stop-color="#fbf2d5" />
|
||||||
|
<stop offset="100%" stop-color="#eadebe" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.outline {
|
||||||
|
stroke: #1e517b;
|
||||||
|
stroke-width: 8px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.inner-line {
|
||||||
|
stroke: #1e517b;
|
||||||
|
stroke-width: 6px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<ellipse cx="430" cy="710" rx="180" ry="60" transform="rotate(-15, 430, 710)" fill="url(#dropShadow)" />
|
||||||
|
|
||||||
|
<polygon points="310,470 500,570 500,750 310,650" fill="url(#cubeLeft)" />
|
||||||
|
|
||||||
|
<polygon points="500,570 690,470 690,650 500,750" fill="url(#cubeRight)" />
|
||||||
|
|
||||||
|
<polygon points="310,400 500,500 500,570 310,470" fill="url(#rimLeft)" />
|
||||||
|
|
||||||
|
<polygon points="500,500 690,400 690,470 500,570" fill="url(#rimRight)" />
|
||||||
|
|
||||||
|
<polygon points="310,400 500,300 690,400 500,500" fill="url(#cubeTop)" />
|
||||||
|
|
||||||
|
<ellipse cx="500" cy="370" rx="75" ry="35" fill="url(#sphereShadow)" />
|
||||||
|
|
||||||
|
<polygon points="310,400 500,300 690,400 690,650 500,750 310,650" fill="none" class="outline" />
|
||||||
|
|
||||||
|
<line x1="310" y1="470" x2="500" y2="570" class="inner-line" />
|
||||||
|
<line x1="500" y1="570" x2="690" y2="470" class="inner-line" />
|
||||||
|
<line x1="500" y1="500" x2="500" y2="750" class="inner-line" />
|
||||||
|
<line x1="310" y1="400" x2="500" y2="500" class="inner-line" />
|
||||||
|
<line x1="500" y1="500" x2="690" y2="400" class="inner-line" />
|
||||||
|
|
||||||
|
<g id="sphere">
|
||||||
|
<circle cx="500" cy="266" r="90" fill="url(#sphereGrad)" class="outline" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.2 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
0
engine/__init__.py
Normal file
1
engine/backends/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
__all__ = ["evaluate", "make_env", "train_qtable", "train_sb3"]
|
||||||
181
engine/backends/common.py
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
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"]),
|
||||||
|
agent_params=(
|
||||||
|
float(cfg.get("agent_mu", 45.0)),
|
||||||
|
float(cfg.get("agent_std", 15.0)),
|
||||||
|
),
|
||||||
|
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
|
||||||
|
lambda_coi=float(cfg["lambda_coi"]),
|
||||||
|
robust_radius=float(cfg["robust_radius"]),
|
||||||
|
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] = []
|
||||||
|
upward_volatilities: list[float] = []
|
||||||
|
supra_shares: list[float] = []
|
||||||
|
supra_penalties: 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_upward_volatility = 0.0
|
||||||
|
ep_supra_share = 0.0
|
||||||
|
ep_supra_penalty = 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_upward_volatility += float(
|
||||||
|
info.get("upward_volatility", econ.get("upward_volatility", 0.0))
|
||||||
|
)
|
||||||
|
ep_supra_share += float(
|
||||||
|
info.get("supra_share", econ.get("supra_share", 0.0))
|
||||||
|
)
|
||||||
|
ep_supra_penalty += float(
|
||||||
|
info.get("supra_penalty", econ.get("supra_penalty", 0.0))
|
||||||
|
)
|
||||||
|
ep_agent_prob += float(econ.get("agent_prob", info.get("agent_prob", 0.0)))
|
||||||
|
steps += 1
|
||||||
|
|
||||||
|
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)
|
||||||
|
upward_volatilities.append(ep_upward_volatility / denom)
|
||||||
|
supra_shares.append(ep_supra_share / denom)
|
||||||
|
supra_penalties.append(ep_supra_penalty / denom)
|
||||||
|
agent_probs.append(ep_agent_prob / denom)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"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/upward_volatility_mean": (
|
||||||
|
float(np.mean(upward_volatilities)) if upward_volatilities else 0.0
|
||||||
|
),
|
||||||
|
"eval/supra_share_mean": float(np.mean(supra_shares)) if supra_shares else 0.0,
|
||||||
|
"eval/supra_penalty_mean": (
|
||||||
|
float(np.mean(supra_penalties)) if supra_penalties else 0.0
|
||||||
|
),
|
||||||
|
"eval/agent_prob_mean": float(np.mean(agent_probs)) if agent_probs else 0.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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/stress_alpha_low"] = low_alpha
|
||||||
|
metrics["eval/stress_alpha_high"] = high_alpha
|
||||||
|
metrics["eval/stress_reward_worst"] = float(
|
||||||
|
min(row[2]["eval/reward_mean"] for row in shifted_rows)
|
||||||
|
)
|
||||||
|
metrics["eval/stress_revenue_worst"] = float(
|
||||||
|
min(row[2]["eval/revenue_mean"] for row in shifted_rows)
|
||||||
|
)
|
||||||
|
metrics["eval/stress_coi_leakage_worst"] = float(
|
||||||
|
max(row[2]["eval/coi_leakage_mean"] for row in shifted_rows)
|
||||||
|
)
|
||||||
|
for tag, alpha, shifted_metrics in shifted_rows:
|
||||||
|
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
|
||||||
139
engine/backends/qtable.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
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:
|
||||||
|
try:
|
||||||
|
wandb.log(dict(event), step=step_offset + int(steps))
|
||||||
|
except Exception:
|
||||||
|
wandb_live = False
|
||||||
|
train_events.append(event)
|
||||||
|
else:
|
||||||
|
train_events.append(event)
|
||||||
|
if console_progress:
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
wandb.log(dict(tail_event), step=step_offset + int(steps))
|
||||||
|
except Exception:
|
||||||
|
wandb_live = False
|
||||||
|
train_events.append(tail_event)
|
||||||
|
else:
|
||||||
|
train_events.append(tail_event)
|
||||||
|
|
||||||
|
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
|
||||||
217
engine/backends/sb3.py
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..lib.callbacks import EvalMetricsCallback, MetricsCallback
|
||||||
|
from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file
|
||||||
|
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.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=True,
|
||||||
|
log_freq=int(cfg["log_freq"]),
|
||||||
|
hist_freq=int(cfg.get("hist_freq", 500)),
|
||||||
|
step_offset=int(cfg.get("wandb_step_offset", 0)),
|
||||||
|
)
|
||||||
|
eval_callback = EvalMetricsCallback(
|
||||||
|
eval_env,
|
||||||
|
eval_freq=int(cfg["eval_freq"]),
|
||||||
|
n_eval_episodes=int(cfg["eval_episodes"]),
|
||||||
|
step_offset=int(cfg.get("wandb_step_offset", 0)),
|
||||||
|
deterministic=True,
|
||||||
|
verbose=0,
|
||||||
|
)
|
||||||
|
callbacks = [metrics_callback, eval_callback]
|
||||||
|
|
||||||
|
target_steps = int(cfg["total_timesteps"])
|
||||||
|
remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0)))
|
||||||
|
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))
|
||||||
|
|
||||||
|
artifact_name = checkpoint_artifact_name(
|
||||||
|
cfg,
|
||||||
|
backend="sb3",
|
||||||
|
sweep_id=os.getenv("WANDB_SWEEP_ID"),
|
||||||
|
)
|
||||||
|
artifact_logged = False
|
||||||
|
try:
|
||||||
|
artifact_logged = bool(
|
||||||
|
log_checkpoint_file(
|
||||||
|
artifact_name,
|
||||||
|
file_path=model_path.with_suffix(".zip"),
|
||||||
|
artifact_file_name="model.zip",
|
||||||
|
metadata={
|
||||||
|
"algo": str(cfg.get("algo", "ppo")),
|
||||||
|
"backend": "sb3",
|
||||||
|
"seed": int(cfg.get("seed", 0)),
|
||||||
|
"step": int(getattr(model, "num_timesteps", 0)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
artifact_logged = False
|
||||||
|
|
||||||
|
metrics: dict[str, Any] = evaluate(
|
||||||
|
model,
|
||||||
|
eval_env,
|
||||||
|
int(cfg["eval_episodes"]),
|
||||||
|
cfg=cfg,
|
||||||
|
)
|
||||||
|
metrics["train/global_step"] = int(model.num_timesteps)
|
||||||
|
metrics["model/path"] = str(model_path.with_suffix(".zip"))
|
||||||
|
metrics["model/artifact_name"] = str(artifact_name)
|
||||||
|
metrics["model/artifact_logged"] = float(artifact_logged)
|
||||||
|
metrics["_train_events"] = sorted(
|
||||||
|
[*metrics_callback.events, *eval_callback.events],
|
||||||
|
key=lambda event: int(event.get("train/global_step", 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
eval_env.close()
|
||||||
|
return model, metrics
|
||||||
702
engine/benchmark.py
Normal file
@@ -0,0 +1,702 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# clear stale TPU locks on startup
|
||||||
|
if os.path.exists("/dev/accel0"):
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["rm", "-f", "/tmp/.libtpu_lockfile", "/tmp/libtpu_lockfile"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
|
||||||
|
jax.config.update("jax_threefry_partitionable", True)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
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 _wandb_run_active() -> bool:
|
||||||
|
return bool(HAS_WANDB and getattr(wandb, "run", None) is not None)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_list(raw: str) -> list[str]:
|
||||||
|
return [x.strip().lower() for x in str(raw).split(",") if x.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
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 _mode_label_from_baseline(is_baseline: bool) -> str:
|
||||||
|
return "baseline" if bool(is_baseline) else "defended"
|
||||||
|
|
||||||
|
|
||||||
|
def _action(policy, obs: np.ndarray):
|
||||||
|
out = policy.predict(obs, deterministic=True)
|
||||||
|
action = out[0] if isinstance(out, tuple) else out
|
||||||
|
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 _wandb_run_active():
|
||||||
|
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/baseline_mode": float(mode_label == "baseline"),
|
||||||
|
"study/alpha": float(alpha),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
wandb.log(payload, step=cursor + rel_step)
|
||||||
|
except Exception:
|
||||||
|
return int(step_offset)
|
||||||
|
max_rel = max(max(1, int(evt.get("train/global_step", 0))) for evt in ordered)
|
||||||
|
return cursor + max_rel + 1
|
||||||
|
|
||||||
|
|
||||||
|
def run_benchmark(
|
||||||
|
cfg: dict,
|
||||||
|
tiers: list[str],
|
||||||
|
alpha_values: list[float],
|
||||||
|
n_episodes: int,
|
||||||
|
mode_label: str,
|
||||||
|
step_cursor_start: int = 0,
|
||||||
|
eval_alpha_values: list[float] | None = None,
|
||||||
|
):
|
||||||
|
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
|
||||||
|
eval_targets = (
|
||||||
|
[float(value) for value in eval_alpha_values]
|
||||||
|
if eval_alpha_values
|
||||||
|
else [float(alpha)]
|
||||||
|
)
|
||||||
|
for eval_alpha in eval_targets:
|
||||||
|
env = make_env({**cfg, "alpha": float(eval_alpha)})
|
||||||
|
eps = [_run_eval_episode(env, policy) for _ in range(int(n_episodes))]
|
||||||
|
env.close()
|
||||||
|
|
||||||
|
row = {
|
||||||
|
"tier": tier_name,
|
||||||
|
"mode": mode_label,
|
||||||
|
"alpha": float(eval_alpha),
|
||||||
|
"train_alpha": float(alpha),
|
||||||
|
"eval_alpha": float(eval_alpha),
|
||||||
|
"episodes": int(n_episodes),
|
||||||
|
"mean_reward": float(np.mean([e["reward"] for e in eps])),
|
||||||
|
"mean_revenue": float(np.mean([e["revenue"] for e in eps])),
|
||||||
|
"mean_margin": float(np.mean([e["mean_margin"] for e in eps])),
|
||||||
|
"mean_coi": float(np.mean([e["mean_coi"] for e in eps])),
|
||||||
|
"std_revenue": float(np.std([e["revenue"] for e in eps])),
|
||||||
|
}
|
||||||
|
row["objective_score"] = row["mean_reward"]
|
||||||
|
rows.append(row)
|
||||||
|
_log(
|
||||||
|
f"[{run_index}/{total_runs}] train_alpha={float(alpha):.2f} "
|
||||||
|
f"eval_alpha={float(eval_alpha):.2f} tier={tier_name}: "
|
||||||
|
f"reward={row['mean_reward']:.3f} revenue={row['mean_revenue']:.3f} "
|
||||||
|
f"coi={row['mean_coi']:.4f} score={row['objective_score']:.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
max_len = max((len(e["price_trace"]) for e in eps), default=0)
|
||||||
|
step_means = []
|
||||||
|
for step in range(max_len):
|
||||||
|
vals = [
|
||||||
|
e["price_trace"][step]
|
||||||
|
for e in eps
|
||||||
|
if step < len(e["price_trace"])
|
||||||
|
]
|
||||||
|
step_means.append(float(np.mean(vals)) if vals else np.nan)
|
||||||
|
traces.append(
|
||||||
|
{
|
||||||
|
"tier": tier_name,
|
||||||
|
"alpha": float(eval_alpha),
|
||||||
|
"train_alpha": float(alpha),
|
||||||
|
"eval_alpha": float(eval_alpha),
|
||||||
|
"mean_price_trace": step_means,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if _wandb_run_active():
|
||||||
|
try:
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
"run.kind": "benchmark",
|
||||||
|
"runtime/backend": tier_name,
|
||||||
|
"study/mode": mode_label,
|
||||||
|
"study/baseline_mode": float(mode_label == "baseline"),
|
||||||
|
"study/alpha": float(eval_alpha),
|
||||||
|
"study/train_alpha": float(alpha),
|
||||||
|
"study/eval_alpha": float(eval_alpha),
|
||||||
|
"eval/reward_mean": row["mean_reward"],
|
||||||
|
"eval/revenue_mean": row["mean_revenue"],
|
||||||
|
"eval/margin_mean": row["mean_margin"],
|
||||||
|
"eval/coi_level_mean": row["mean_coi"],
|
||||||
|
"objective/score": row["objective_score"],
|
||||||
|
"objective/coi_preserved": row["mean_coi"],
|
||||||
|
},
|
||||||
|
step=wandb_step_cursor,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
wandb_step_cursor += 1
|
||||||
|
|
||||||
|
return pd.DataFrame(rows), traces, int(wandb_step_cursor)
|
||||||
|
|
||||||
|
|
||||||
|
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"))
|
||||||
|
)
|
||||||
|
baseline_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,
|
||||||
|
"margin_floor": args.margin_floor,
|
||||||
|
"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)
|
||||||
|
eval_alpha_values = (
|
||||||
|
_parse_float_list(args.eval_alpha_values)
|
||||||
|
if str(getattr(args, "eval_alpha_values", "")).strip()
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
_log(
|
||||||
|
"starting run "
|
||||||
|
+ json.dumps(
|
||||||
|
{
|
||||||
|
"tiers": tiers,
|
||||||
|
"alpha_values": alpha_values,
|
||||||
|
"eval_alpha_values": (
|
||||||
|
eval_alpha_values if eval_alpha_values else alpha_values
|
||||||
|
),
|
||||||
|
"episodes": int(args.episodes),
|
||||||
|
"total_timesteps": int(args.total_timesteps),
|
||||||
|
"device": str(args.device),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
all_frames: list[pd.DataFrame] = []
|
||||||
|
all_traces: list[dict] = []
|
||||||
|
wandb_step_cursor = 0
|
||||||
|
for baseline_mode in baseline_modes:
|
||||||
|
overrides = dict(base_overrides)
|
||||||
|
overrides["baseline_mode"] = bool(baseline_mode)
|
||||||
|
cfg = TrainSpec.from_flat(
|
||||||
|
{k: v for k, v in overrides.items() if v is not None}
|
||||||
|
).to_flat_dict()
|
||||||
|
cfg["linear_warmup_steps"] = int(args.linear_warmup_steps)
|
||||||
|
mode_label = _mode_label_from_baseline(bool(baseline_mode))
|
||||||
|
_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,
|
||||||
|
eval_alpha_values=eval_alpha_values,
|
||||||
|
)
|
||||||
|
_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(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||||
|
csv_path = out_dir / f"benchmark_{stamp}.csv"
|
||||||
|
trace_path = out_dir / f"benchmark_traces_{stamp}.json"
|
||||||
|
df.to_csv(csv_path, index=False)
|
||||||
|
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", "defended"),
|
||||||
|
"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("--eval-alpha-values", default="")
|
||||||
|
parser.add_argument("--episodes", type=int, default=10)
|
||||||
|
parser.add_argument("--output-dir", default="engine/studies/results")
|
||||||
|
parser.add_argument("--seed", type=int, default=42)
|
||||||
|
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("--margin-floor", type=float, default=0.85)
|
||||||
|
parser.add_argument("--eta-ux", type=float, default=0.5)
|
||||||
|
parser.add_argument("--reward-profit-weight", type=float, default=1.0)
|
||||||
|
parser.add_argument("--price-low", type=float, default=10.0)
|
||||||
|
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",
|
||||||
|
"eval_alpha_values": "eval_alpha_values",
|
||||||
|
"episodes": "episodes",
|
||||||
|
"total_timesteps": "total_timesteps",
|
||||||
|
"lambda_coi": "lambda_coi",
|
||||||
|
"robust_radius": "robust_radius",
|
||||||
|
"robust_points": "robust_points",
|
||||||
|
"robust_rollouts": "robust_rollouts",
|
||||||
|
"ambiguity_radius": "robust_radius",
|
||||||
|
"ambiguity_points": "robust_points",
|
||||||
|
"ambiguity_rollouts": "robust_rollouts",
|
||||||
|
"eta_ux": "eta_ux",
|
||||||
|
"reward_profit_weight": "reward_profit_weight",
|
||||||
|
"learning_rate": "learning_rate",
|
||||||
|
"batch_size": "batch_size",
|
||||||
|
"n_steps": "n_steps",
|
||||||
|
"baseline_mode": "no_robust",
|
||||||
|
"no_robust": "no_robust",
|
||||||
|
"margin_floor": "margin_floor",
|
||||||
|
"device": "device",
|
||||||
|
}
|
||||||
|
for key in (
|
||||||
|
"tiers",
|
||||||
|
"alpha_values",
|
||||||
|
"eval_alpha_values",
|
||||||
|
"episodes",
|
||||||
|
"total_timesteps",
|
||||||
|
"lambda_coi",
|
||||||
|
"robust_radius",
|
||||||
|
"robust_points",
|
||||||
|
"robust_rollouts",
|
||||||
|
"ambiguity_radius",
|
||||||
|
"ambiguity_points",
|
||||||
|
"ambiguity_rollouts",
|
||||||
|
"eta_ux",
|
||||||
|
"reward_profit_weight",
|
||||||
|
"learning_rate",
|
||||||
|
"batch_size",
|
||||||
|
"n_steps",
|
||||||
|
"baseline_mode",
|
||||||
|
"no_robust",
|
||||||
|
"margin_floor",
|
||||||
|
"device",
|
||||||
|
):
|
||||||
|
if key in wandb.config:
|
||||||
|
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(timezone.utc).strftime("%m%d-%H%M%S")
|
||||||
|
compare_enabled = _truthy(os.environ.get("PHANTOM_BENCHMARK_COMPARE_ROBUST"))
|
||||||
|
compare_tag = "defended-compare" if compare_enabled else "single-mode"
|
||||||
|
modes = (
|
||||||
|
[("baseline", True), ("defended", False)]
|
||||||
|
if compare_enabled
|
||||||
|
else [(_mode_label_from_baseline(bool(args.no_robust)), bool(args.no_robust))]
|
||||||
|
)
|
||||||
|
|
||||||
|
run_idx = 0
|
||||||
|
for tier in tiers:
|
||||||
|
for mode_label, baseline_mode 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(baseline_mode)
|
||||||
|
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/baseline_mode": float(baseline_mode),
|
||||||
|
"study/alpha": float(alpha),
|
||||||
|
"tiers": tier,
|
||||||
|
"alpha_values": str(float(alpha)),
|
||||||
|
"eval_alpha_values": args.eval_alpha_values,
|
||||||
|
"episodes": args.episodes,
|
||||||
|
"total_timesteps": args.total_timesteps,
|
||||||
|
"lambda_coi": args.lambda_coi,
|
||||||
|
"ambiguity_radius": args.robust_radius,
|
||||||
|
"ambiguity_points": args.robust_points,
|
||||||
|
"ambiguity_rollouts": args.robust_rollouts,
|
||||||
|
"margin_floor": args.margin_floor,
|
||||||
|
"baseline_mode": float(baseline_mode),
|
||||||
|
"eta_ux": args.eta_ux,
|
||||||
|
"reward_profit_weight": args.reward_profit_weight,
|
||||||
|
"learning_rate": args.learning_rate,
|
||||||
|
"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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
from sys import platform
|
from sys import platform
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from .lib.demand import generate_demand_for_actor, estimate_demand
|
from .lib.demand import generate_demand_for_actor, estimate_demand
|
||||||
from .lib.behavior import sample_behavior
|
from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions
|
||||||
from logging import INFO, getLogger
|
from logging import INFO, getLogger
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -46,12 +46,39 @@ class MarketEngine:
|
|||||||
self.noise_std,
|
self.noise_std,
|
||||||
distribution_method=self.demand_dist,
|
distribution_method=self.demand_dist,
|
||||||
)
|
)
|
||||||
# sample behavior trajectories from each demand distribution
|
human_transitions = get_adjusted_transitions(demand_h, human=True)
|
||||||
human_t = [sample_behavior(demand_h, human=True) for _ in range(self.Nhumans)]
|
agent_transitions = get_adjusted_transitions(demand_a, human=False)
|
||||||
agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)]
|
# sample N trajectories in parallel; each chain is independent so threads
|
||||||
|
# do not share state and numpy's per-call RNG is thread-safe
|
||||||
|
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
|
# store trajectories for agent probability calculation
|
||||||
self.last_trajectories = human_t + agent_t
|
self.last_trajectories = human_t + agent_t
|
||||||
return estimate_demand(self.last_trajectories, self.action_weights)
|
|
||||||
|
demand_proxy = estimate_demand(
|
||||||
|
self.last_trajectories,
|
||||||
|
self.action_weights,
|
||||||
|
normalize=True,
|
||||||
|
per_session=False,
|
||||||
|
)
|
||||||
|
raw_mix = ((1.0 - float(self.alpha)) * demand_h) + (
|
||||||
|
float(self.alpha) * demand_a
|
||||||
|
)
|
||||||
|
total_raw_demand = float(np.sum(raw_mix))
|
||||||
|
if not demand_proxy:
|
||||||
|
return {i: float(raw_mix[i]) for i in range(len(prices))}
|
||||||
|
if total_raw_demand <= 0.0:
|
||||||
|
return {i: 0.0 for i in range(len(prices))}
|
||||||
|
return {
|
||||||
|
i: total_raw_demand * float(demand_proxy.get(i, 0.0)) / 100.0
|
||||||
|
for i in range(len(prices))
|
||||||
|
}
|
||||||
|
|
||||||
def measure(self):
|
def measure(self):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
"""JAX-compatible training and environment modules for PHANTOM."""
|
from .robust import select_adversarial_alpha_jax, _JAX_OK
|
||||||
|
|
||||||
from __future__ import annotations
|
__all__ = ["select_adversarial_alpha_jax", "_JAX_OK"]
|
||||||
|
|
||||||
try:
|
|
||||||
import jax # noqa: F401
|
|
||||||
import jax.numpy as jnp # noqa: F401
|
|
||||||
|
|
||||||
JAX_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
JAX_AVAILABLE = False
|
|
||||||
|
|
||||||
__all__ = ["JAX_AVAILABLE"]
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
"""Orbax checkpoint helpers for JAX training runs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
try:
|
|
||||||
import orbax.checkpoint as ocp
|
|
||||||
|
|
||||||
HAS_ORBAX = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_ORBAX = False
|
|
||||||
|
|
||||||
|
|
||||||
def _require_orbax() -> None:
|
|
||||||
if not HAS_ORBAX:
|
|
||||||
raise ImportError(
|
|
||||||
"orbax-checkpoint is required for checkpoint support. "
|
|
||||||
"Install engine/jax/requirements.txt first."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def create_manager(directory: str | Path, max_to_keep: int = 5):
|
|
||||||
_require_orbax()
|
|
||||||
root = Path(directory)
|
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
|
||||||
options = ocp.CheckpointManagerOptions(
|
|
||||||
max_to_keep=max(1, int(max_to_keep)), create=True
|
|
||||||
)
|
|
||||||
return ocp.CheckpointManager(root.as_posix(), ocp.PyTreeCheckpointer(), options)
|
|
||||||
|
|
||||||
|
|
||||||
def save(manager, *, step: int, payload: Any) -> bool:
|
|
||||||
_require_orbax()
|
|
||||||
return bool(manager.save(int(step), payload))
|
|
||||||
|
|
||||||
|
|
||||||
def latest_step(manager) -> int | None:
|
|
||||||
_require_orbax()
|
|
||||||
return manager.latest_step()
|
|
||||||
|
|
||||||
|
|
||||||
def restore(manager, *, target: Any, step: int | None = None) -> Any:
|
|
||||||
_require_orbax()
|
|
||||||
step_to_restore = manager.latest_step() if step is None else int(step)
|
|
||||||
if step_to_restore is None:
|
|
||||||
return target
|
|
||||||
return manager.restore(step_to_restore, items=target)
|
|
||||||
@@ -1,287 +0,0 @@
|
|||||||
"""JAX-native PHANTOM environment with robust contamination step."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import NamedTuple
|
|
||||||
|
|
||||||
try:
|
|
||||||
import jax
|
|
||||||
import jax.numpy as jnp
|
|
||||||
except ImportError as exc: # pragma: no cover
|
|
||||||
raise ImportError("engine.jax.env requires JAX") from exc
|
|
||||||
|
|
||||||
from .primitives import (
|
|
||||||
_sample_sessions_jax,
|
|
||||||
agent_probability_from_kl,
|
|
||||||
batch_kl,
|
|
||||||
compute_session_transitions,
|
|
||||||
load_transition_data,
|
|
||||||
purchase_flags,
|
|
||||||
reward_with_coi_penalty,
|
|
||||||
revenue_from_demand,
|
|
||||||
weighted_demand,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class EnvParams(NamedTuple):
|
|
||||||
n_products: int
|
|
||||||
n_sessions: int
|
|
||||||
max_episode_steps: int
|
|
||||||
max_session_steps: int
|
|
||||||
price_low: float
|
|
||||||
price_high: float
|
|
||||||
lambda_coi: float
|
|
||||||
info_value: float
|
|
||||||
robust_radius: float
|
|
||||||
margin_floor: float
|
|
||||||
margin_floor_patience: int
|
|
||||||
action_scales: jax.Array
|
|
||||||
alpha_nominal: float
|
|
||||||
alpha_candidates: jax.Array
|
|
||||||
human_T: jax.Array
|
|
||||||
agent_T: jax.Array
|
|
||||||
terminal_mask: jax.Array
|
|
||||||
purchase_mask: jax.Array
|
|
||||||
event_weights: jax.Array
|
|
||||||
start_idx: int
|
|
||||||
term_idx: int
|
|
||||||
|
|
||||||
|
|
||||||
class EnvState(NamedTuple):
|
|
||||||
prices: jax.Array
|
|
||||||
demand: jax.Array
|
|
||||||
step_count: jax.Array
|
|
||||||
low_margin_streak: jax.Array
|
|
||||||
last_agent_prob: jax.Array
|
|
||||||
last_alpha_adv: jax.Array
|
|
||||||
|
|
||||||
|
|
||||||
class CandidateEval(NamedTuple):
|
|
||||||
reward: jax.Array
|
|
||||||
revenue: jax.Array
|
|
||||||
demand: jax.Array
|
|
||||||
agent_prob: jax.Array
|
|
||||||
leakage: jax.Array
|
|
||||||
discount: jax.Array
|
|
||||||
n_purchases: jax.Array
|
|
||||||
n_agents: jax.Array
|
|
||||||
|
|
||||||
|
|
||||||
def make_env_params(
|
|
||||||
*,
|
|
||||||
n_products: int,
|
|
||||||
alpha: float,
|
|
||||||
n_sessions: int,
|
|
||||||
lambda_coi: float,
|
|
||||||
robust_radius: float,
|
|
||||||
robust_points: int,
|
|
||||||
info_value: float,
|
|
||||||
action_levels: int,
|
|
||||||
action_scale_low: float,
|
|
||||||
action_scale_high: float,
|
|
||||||
price_low: float,
|
|
||||||
price_high: float,
|
|
||||||
max_episode_steps: int,
|
|
||||||
max_session_steps: int = 40,
|
|
||||||
margin_floor: float = 0.05,
|
|
||||||
margin_floor_patience: int = 5,
|
|
||||||
prefer_behavior_data: bool = True,
|
|
||||||
) -> EnvParams:
|
|
||||||
transition = load_transition_data(prefer_data=prefer_behavior_data).to_jax()
|
|
||||||
if robust_radius <= 0.0 or robust_points <= 1:
|
|
||||||
alpha_candidates = jnp.asarray([float(alpha)], dtype=jnp.float32)
|
|
||||||
else:
|
|
||||||
lo = max(0.0, float(alpha) - float(robust_radius))
|
|
||||||
hi = min(1.0, float(alpha) + float(robust_radius))
|
|
||||||
alpha_candidates = jnp.linspace(lo, hi, int(robust_points), dtype=jnp.float32)
|
|
||||||
|
|
||||||
action_scales = jnp.linspace(
|
|
||||||
float(action_scale_low),
|
|
||||||
float(action_scale_high),
|
|
||||||
int(action_levels),
|
|
||||||
dtype=jnp.float32,
|
|
||||||
)
|
|
||||||
return EnvParams(
|
|
||||||
n_products=int(n_products),
|
|
||||||
n_sessions=int(n_sessions),
|
|
||||||
max_episode_steps=int(max_episode_steps),
|
|
||||||
max_session_steps=int(max_session_steps),
|
|
||||||
price_low=float(price_low),
|
|
||||||
price_high=float(price_high),
|
|
||||||
lambda_coi=float(lambda_coi),
|
|
||||||
info_value=float(info_value),
|
|
||||||
robust_radius=float(robust_radius),
|
|
||||||
margin_floor=float(margin_floor),
|
|
||||||
margin_floor_patience=int(margin_floor_patience),
|
|
||||||
action_scales=action_scales,
|
|
||||||
alpha_nominal=float(alpha),
|
|
||||||
alpha_candidates=alpha_candidates,
|
|
||||||
human_T=jnp.asarray(transition.human_T),
|
|
||||||
agent_T=jnp.asarray(transition.agent_T),
|
|
||||||
terminal_mask=jnp.asarray(transition.terminal_mask),
|
|
||||||
purchase_mask=jnp.asarray(transition.purchase_mask),
|
|
||||||
event_weights=jnp.asarray(transition.event_weights),
|
|
||||||
start_idx=int(transition.start_idx),
|
|
||||||
term_idx=int(transition.term_idx),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _flatten_obs(demand: jax.Array, prices: jax.Array) -> jax.Array:
|
|
||||||
return jnp.concatenate([demand.astype(jnp.float32), prices.astype(jnp.float32)])
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_action(
|
|
||||||
prices: jax.Array, action: jax.Array, params: EnvParams
|
|
||||||
) -> jax.Array:
|
|
||||||
idx = jnp.clip(action.astype(jnp.int32), 0, params.action_scales.shape[0] - 1)
|
|
||||||
scale = params.action_scales[idx]
|
|
||||||
next_prices = prices * scale
|
|
||||||
return jnp.clip(next_prices, params.price_low, params.price_high)
|
|
||||||
|
|
||||||
|
|
||||||
def _evaluate_candidate(
|
|
||||||
key: jax.Array,
|
|
||||||
alpha_candidate: jax.Array,
|
|
||||||
prices: jax.Array,
|
|
||||||
params: EnvParams,
|
|
||||||
) -> CandidateEval:
|
|
||||||
states, products, actors, lengths = _sample_sessions_jax(
|
|
||||||
key,
|
|
||||||
params.human_T,
|
|
||||||
params.agent_T,
|
|
||||||
params.terminal_mask,
|
|
||||||
params.start_idx,
|
|
||||||
params.term_idx,
|
|
||||||
alpha_candidate,
|
|
||||||
params.n_products,
|
|
||||||
params.n_sessions,
|
|
||||||
params.max_session_steps,
|
|
||||||
int(params.human_T.shape[0]),
|
|
||||||
)
|
|
||||||
session_trans = compute_session_transitions(
|
|
||||||
states, lengths, int(params.human_T.shape[0])
|
|
||||||
)
|
|
||||||
delta_h, delta_a = batch_kl(session_trans, params.human_T, params.agent_T)
|
|
||||||
agent_probs = agent_probability_from_kl(delta_h, delta_a)
|
|
||||||
agent_prob = jnp.mean(agent_probs)
|
|
||||||
|
|
||||||
demand = weighted_demand(states, products, params.n_products, params.event_weights)
|
|
||||||
revenue = revenue_from_demand(prices, demand)
|
|
||||||
reward, leakage, discount = reward_with_coi_penalty(
|
|
||||||
revenue,
|
|
||||||
agent_prob,
|
|
||||||
params.lambda_coi,
|
|
||||||
params.info_value,
|
|
||||||
)
|
|
||||||
purchases = purchase_flags(states, params.purchase_mask)
|
|
||||||
return CandidateEval(
|
|
||||||
reward=reward,
|
|
||||||
revenue=revenue,
|
|
||||||
demand=demand,
|
|
||||||
agent_prob=agent_prob,
|
|
||||||
leakage=leakage,
|
|
||||||
discount=discount,
|
|
||||||
n_purchases=jnp.sum(purchases.astype(jnp.float32)),
|
|
||||||
n_agents=jnp.sum(actors.astype(jnp.float32)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_env(key: jax.Array, params: EnvParams) -> tuple[jax.Array, EnvState]:
|
|
||||||
prices = jax.random.uniform(
|
|
||||||
key,
|
|
||||||
shape=(params.n_products,),
|
|
||||||
minval=params.price_low,
|
|
||||||
maxval=params.price_high,
|
|
||||||
)
|
|
||||||
demand = jnp.zeros((params.n_products,), dtype=jnp.float32)
|
|
||||||
state = EnvState(
|
|
||||||
prices=prices,
|
|
||||||
demand=demand,
|
|
||||||
step_count=jnp.asarray(0, dtype=jnp.int32),
|
|
||||||
low_margin_streak=jnp.asarray(0, dtype=jnp.int32),
|
|
||||||
last_agent_prob=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
|
||||||
last_alpha_adv=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
|
||||||
)
|
|
||||||
return _flatten_obs(demand, prices), state
|
|
||||||
|
|
||||||
|
|
||||||
def step_env(
|
|
||||||
key: jax.Array,
|
|
||||||
state: EnvState,
|
|
||||||
action: jax.Array,
|
|
||||||
params: EnvParams,
|
|
||||||
) -> tuple[jax.Array, EnvState, jax.Array, jax.Array, dict[str, jax.Array]]:
|
|
||||||
prices = _decode_action(state.prices, action, params)
|
|
||||||
n_candidates = params.alpha_candidates.shape[0]
|
|
||||||
cand_keys = jax.random.split(key, n_candidates)
|
|
||||||
evals = jax.vmap(
|
|
||||||
lambda k, a: _evaluate_candidate(k, a, prices, params),
|
|
||||||
in_axes=(0, 0),
|
|
||||||
)(cand_keys, params.alpha_candidates)
|
|
||||||
idx = jnp.argmin(evals.reward)
|
|
||||||
|
|
||||||
demand = evals.demand[idx]
|
|
||||||
reward = evals.reward[idx]
|
|
||||||
revenue = evals.revenue[idx]
|
|
||||||
agent_prob = evals.agent_prob[idx]
|
|
||||||
leakage = evals.leakage[idx]
|
|
||||||
discount = evals.discount[idx]
|
|
||||||
n_purchases = evals.n_purchases[idx]
|
|
||||||
n_agents = evals.n_agents[idx]
|
|
||||||
alpha_adv = params.alpha_candidates[idx]
|
|
||||||
|
|
||||||
step_count = state.step_count + 1
|
|
||||||
avg_price = jnp.maximum(jnp.mean(prices), 1e-6)
|
|
||||||
avg_margin = (avg_price - params.price_low) / avg_price
|
|
||||||
next_streak = jnp.where(
|
|
||||||
avg_margin < params.margin_floor, state.low_margin_streak + 1, 0
|
|
||||||
)
|
|
||||||
|
|
||||||
margin_collapsed = next_streak >= params.margin_floor_patience
|
|
||||||
done = (step_count >= params.max_episode_steps) | margin_collapsed
|
|
||||||
|
|
||||||
next_state = EnvState(
|
|
||||||
prices=prices,
|
|
||||||
demand=demand,
|
|
||||||
step_count=step_count,
|
|
||||||
low_margin_streak=next_streak,
|
|
||||||
last_agent_prob=agent_prob,
|
|
||||||
last_alpha_adv=alpha_adv,
|
|
||||||
)
|
|
||||||
obs = _flatten_obs(demand, prices)
|
|
||||||
info = {
|
|
||||||
"revenue": revenue,
|
|
||||||
"agent_prob": agent_prob,
|
|
||||||
"alpha_adv": alpha_adv,
|
|
||||||
"coi_leakage": leakage,
|
|
||||||
"coi_discount": discount,
|
|
||||||
"n_purchases": n_purchases,
|
|
||||||
"n_agents": n_agents,
|
|
||||||
"avg_margin": avg_margin,
|
|
||||||
}
|
|
||||||
return obs, next_state, reward, done, info
|
|
||||||
|
|
||||||
|
|
||||||
class PHANTOMJAXEnv:
|
|
||||||
def __init__(self, params: EnvParams):
|
|
||||||
self.params = params
|
|
||||||
|
|
||||||
def reset(self, key: jax.Array, params: EnvParams | None = None):
|
|
||||||
return reset_env(key, self.params if params is None else params)
|
|
||||||
|
|
||||||
def step(
|
|
||||||
self,
|
|
||||||
key: jax.Array,
|
|
||||||
state: EnvState,
|
|
||||||
action: jax.Array,
|
|
||||||
params: EnvParams | None = None,
|
|
||||||
):
|
|
||||||
return step_env(key, state, action, self.params if params is None else params)
|
|
||||||
|
|
||||||
def action_space_n(self, params: EnvParams | None = None) -> int:
|
|
||||||
p = self.params if params is None else params
|
|
||||||
return int(p.action_scales.shape[0])
|
|
||||||
|
|
||||||
def observation_dim(self, params: EnvParams | None = None) -> int:
|
|
||||||
p = self.params if params is None else params
|
|
||||||
return int(p.n_products * 2)
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
"""JAX-compatible primitives for PHANTOM session simulation and separability."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import partial
|
|
||||||
from typing import Mapping, Sequence
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
try:
|
|
||||||
import jax
|
|
||||||
import jax.numpy as jnp
|
|
||||||
|
|
||||||
JAX_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
jax = None # type: ignore[assignment]
|
|
||||||
jnp = np # type: ignore[assignment]
|
|
||||||
JAX_AVAILABLE = False
|
|
||||||
|
|
||||||
|
|
||||||
STATE_START_KEYS = ("session_start", "start")
|
|
||||||
TERMINAL_EVENT_TOKENS = (
|
|
||||||
"session_end",
|
|
||||||
"end",
|
|
||||||
"purchase_complete",
|
|
||||||
"checkout_start",
|
|
||||||
"checkout",
|
|
||||||
)
|
|
||||||
PURCHASE_EVENT_TOKENS = (
|
|
||||||
"purchase_complete",
|
|
||||||
"purchase",
|
|
||||||
"checkout_start",
|
|
||||||
"checkout",
|
|
||||||
)
|
|
||||||
|
|
||||||
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
|
||||||
ACTION_CATEGORIES = {
|
|
||||||
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
|
||||||
"dwell": {
|
|
||||||
"hover_title",
|
|
||||||
"hover_paragraph",
|
|
||||||
"hover_link",
|
|
||||||
"hover_over_title",
|
|
||||||
"hover_over_paragraph",
|
|
||||||
"hover_over_link",
|
|
||||||
"hover_over_button",
|
|
||||||
},
|
|
||||||
"nav": {
|
|
||||||
"page_view",
|
|
||||||
"view_item",
|
|
||||||
"view",
|
|
||||||
"learn_more",
|
|
||||||
"learn_more_about_item",
|
|
||||||
"view_item_page",
|
|
||||||
"session_start",
|
|
||||||
},
|
|
||||||
"filter": {
|
|
||||||
"search",
|
|
||||||
"filter_date",
|
|
||||||
"filter_price",
|
|
||||||
"sort",
|
|
||||||
"filter_for_date",
|
|
||||||
"filter_for_price",
|
|
||||||
"filter_for_amenities",
|
|
||||||
"sort_change",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
DEFAULT_ACTION_WEIGHTS = {
|
|
||||||
action: CATEGORY_WEIGHTS[group]
|
|
||||||
for group, actions in ACTION_CATEGORIES.items()
|
|
||||||
for action in actions
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TransitionData:
|
|
||||||
"""Dense transition kernels and per-state metadata."""
|
|
||||||
|
|
||||||
human_T: np.ndarray
|
|
||||||
agent_T: np.ndarray
|
|
||||||
terminal_mask: np.ndarray
|
|
||||||
purchase_mask: np.ndarray
|
|
||||||
event_weights: np.ndarray
|
|
||||||
event_names: tuple[str, ...]
|
|
||||||
start_idx: int
|
|
||||||
term_idx: int
|
|
||||||
|
|
||||||
def to_jax(self) -> "TransitionData":
|
|
||||||
if not JAX_AVAILABLE:
|
|
||||||
return self
|
|
||||||
return TransitionData(
|
|
||||||
human_T=jnp.asarray(self.human_T),
|
|
||||||
agent_T=jnp.asarray(self.agent_T),
|
|
||||||
terminal_mask=jnp.asarray(self.terminal_mask),
|
|
||||||
purchase_mask=jnp.asarray(self.purchase_mask),
|
|
||||||
event_weights=jnp.asarray(self.event_weights),
|
|
||||||
event_names=self.event_names,
|
|
||||||
start_idx=int(self.start_idx),
|
|
||||||
term_idx=int(self.term_idx),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SessionBatch:
|
|
||||||
states: np.ndarray
|
|
||||||
products: np.ndarray
|
|
||||||
actors: np.ndarray
|
|
||||||
lengths: np.ndarray
|
|
||||||
|
|
||||||
|
|
||||||
def _event_weight(name: str) -> float:
|
|
||||||
if name in DEFAULT_ACTION_WEIGHTS:
|
|
||||||
return float(DEFAULT_ACTION_WEIGHTS[name])
|
|
||||||
if name.startswith("hover"):
|
|
||||||
return float(CATEGORY_WEIGHTS["dwell"])
|
|
||||||
if name.startswith("filter") or name in {"search", "sort", "sort_change"}:
|
|
||||||
return float(CATEGORY_WEIGHTS["filter"])
|
|
||||||
if name.startswith("add") or name in {
|
|
||||||
"checkout",
|
|
||||||
"checkout_start",
|
|
||||||
"purchase",
|
|
||||||
"remove_item",
|
|
||||||
"purchase_complete",
|
|
||||||
}:
|
|
||||||
return float(CATEGORY_WEIGHTS["cart"])
|
|
||||||
if any(token in name for token in TERMINAL_EVENT_TOKENS):
|
|
||||||
return 0.0
|
|
||||||
return float(CATEGORY_WEIGHTS["nav"])
|
|
||||||
|
|
||||||
|
|
||||||
def _is_terminal(name: str) -> bool:
|
|
||||||
return any(token in name for token in TERMINAL_EVENT_TOKENS)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_purchase(name: str) -> bool:
|
|
||||||
return any(token in name for token in PURCHASE_EVENT_TOKENS)
|
|
||||||
|
|
||||||
|
|
||||||
def _collect_events(*transitions: Mapping[str, Mapping[str, float]]) -> tuple[str, ...]:
|
|
||||||
names: set[str] = set()
|
|
||||||
for trans in transitions:
|
|
||||||
for src, dsts in trans.items():
|
|
||||||
names.add(src)
|
|
||||||
names.update(dsts.keys())
|
|
||||||
names.discard("__terminal__")
|
|
||||||
return tuple(sorted(names))
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_rows(matrix: np.ndarray, term_idx: int) -> np.ndarray:
|
|
||||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
|
||||||
dead_rows = np.isclose(row_sums.squeeze(-1), 0.0)
|
|
||||||
if np.any(dead_rows):
|
|
||||||
matrix[dead_rows] = 0.0
|
|
||||||
matrix[dead_rows, term_idx] = 1.0
|
|
||||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
|
||||||
return matrix / np.maximum(row_sums, 1e-8)
|
|
||||||
|
|
||||||
|
|
||||||
def _dense_from_dict(
|
|
||||||
transitions: Mapping[str, Mapping[str, float]],
|
|
||||||
event_to_idx: Mapping[str, int],
|
|
||||||
term_idx: int,
|
|
||||||
) -> np.ndarray:
|
|
||||||
n_states = len(event_to_idx)
|
|
||||||
matrix = np.zeros((n_states, n_states), dtype=np.float32)
|
|
||||||
for src, dsts in transitions.items():
|
|
||||||
i = event_to_idx.get(src)
|
|
||||||
if i is None:
|
|
||||||
continue
|
|
||||||
for dst, prob in dsts.items():
|
|
||||||
j = event_to_idx.get(dst)
|
|
||||||
if j is None:
|
|
||||||
continue
|
|
||||||
matrix[i, j] += float(prob)
|
|
||||||
return _normalize_rows(matrix, term_idx)
|
|
||||||
|
|
||||||
|
|
||||||
def compile_transition_data(
|
|
||||||
human_transitions: Mapping[str, Mapping[str, float]],
|
|
||||||
agent_transitions: Mapping[str, Mapping[str, float]],
|
|
||||||
) -> TransitionData:
|
|
||||||
event_names = _collect_events(human_transitions, agent_transitions)
|
|
||||||
if not event_names:
|
|
||||||
return fallback_transition_data()
|
|
||||||
|
|
||||||
event_names = tuple([*event_names, "__terminal__"])
|
|
||||||
term_idx = len(event_names) - 1
|
|
||||||
event_to_idx = {name: i for i, name in enumerate(event_names)}
|
|
||||||
|
|
||||||
human_T = _dense_from_dict(human_transitions, event_to_idx, term_idx)
|
|
||||||
agent_T = _dense_from_dict(agent_transitions, event_to_idx, term_idx)
|
|
||||||
|
|
||||||
terminal_mask = np.array([_is_terminal(name) for name in event_names], dtype=bool)
|
|
||||||
purchase_mask = np.array([_is_purchase(name) for name in event_names], dtype=bool)
|
|
||||||
event_weights = np.array(
|
|
||||||
[_event_weight(name) for name in event_names], dtype=np.float32
|
|
||||||
)
|
|
||||||
|
|
||||||
terminal_mask[term_idx] = True
|
|
||||||
|
|
||||||
for idx, is_term in enumerate(terminal_mask):
|
|
||||||
if not is_term:
|
|
||||||
continue
|
|
||||||
human_T[idx] = 0.0
|
|
||||||
agent_T[idx] = 0.0
|
|
||||||
human_T[idx, idx] = 1.0
|
|
||||||
agent_T[idx, idx] = 1.0
|
|
||||||
|
|
||||||
start_idx = 0
|
|
||||||
for key in STATE_START_KEYS:
|
|
||||||
if key in event_to_idx:
|
|
||||||
start_idx = int(event_to_idx[key])
|
|
||||||
break
|
|
||||||
|
|
||||||
return TransitionData(
|
|
||||||
human_T=human_T,
|
|
||||||
agent_T=agent_T,
|
|
||||||
terminal_mask=terminal_mask,
|
|
||||||
purchase_mask=purchase_mask,
|
|
||||||
event_weights=event_weights,
|
|
||||||
event_names=event_names,
|
|
||||||
start_idx=start_idx,
|
|
||||||
term_idx=term_idx,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def fallback_transition_data() -> TransitionData:
|
|
||||||
human = {
|
|
||||||
"session_start": {
|
|
||||||
"page_view": 0.80,
|
|
||||||
"view_item_page": 0.15,
|
|
||||||
"session_end": 0.05,
|
|
||||||
},
|
|
||||||
"page_view": {"view_item_page": 0.55, "search": 0.25, "session_end": 0.20},
|
|
||||||
"view_item_page": {
|
|
||||||
"learn_more_about_item": 0.40,
|
|
||||||
"add_item_to_cart": 0.28,
|
|
||||||
"session_end": 0.32,
|
|
||||||
},
|
|
||||||
"learn_more_about_item": {
|
|
||||||
"add_item_to_cart": 0.50,
|
|
||||||
"view_item_page": 0.30,
|
|
||||||
"session_end": 0.20,
|
|
||||||
},
|
|
||||||
"add_item_to_cart": {
|
|
||||||
"checkout_start": 0.58,
|
|
||||||
"view_item_page": 0.24,
|
|
||||||
"session_end": 0.18,
|
|
||||||
},
|
|
||||||
"checkout_start": {"purchase_complete": 0.70, "session_end": 0.30},
|
|
||||||
"purchase_complete": {"session_end": 1.0},
|
|
||||||
}
|
|
||||||
agent = {
|
|
||||||
"session_start": {
|
|
||||||
"page_view": 0.90,
|
|
||||||
"view_item_page": 0.08,
|
|
||||||
"session_end": 0.02,
|
|
||||||
},
|
|
||||||
"page_view": {"view_item_page": 0.40, "search": 0.35, "session_end": 0.25},
|
|
||||||
"view_item_page": {
|
|
||||||
"learn_more_about_item": 0.55,
|
|
||||||
"add_item_to_cart": 0.15,
|
|
||||||
"session_end": 0.30,
|
|
||||||
},
|
|
||||||
"learn_more_about_item": {
|
|
||||||
"view_item_page": 0.45,
|
|
||||||
"add_item_to_cart": 0.20,
|
|
||||||
"session_end": 0.35,
|
|
||||||
},
|
|
||||||
"add_item_to_cart": {
|
|
||||||
"checkout_start": 0.42,
|
|
||||||
"view_item_page": 0.28,
|
|
||||||
"session_end": 0.30,
|
|
||||||
},
|
|
||||||
"checkout_start": {"purchase_complete": 0.52, "session_end": 0.48},
|
|
||||||
"purchase_complete": {"session_end": 1.0},
|
|
||||||
}
|
|
||||||
return compile_transition_data(human, agent)
|
|
||||||
|
|
||||||
|
|
||||||
def load_transition_data(prefer_data: bool = True) -> TransitionData:
|
|
||||||
if not prefer_data:
|
|
||||||
return fallback_transition_data()
|
|
||||||
try:
|
|
||||||
from ..lib.behavior import get_transition_models
|
|
||||||
|
|
||||||
human_trans, agent_trans = get_transition_models()
|
|
||||||
return compile_transition_data(human_trans, agent_trans)
|
|
||||||
except Exception:
|
|
||||||
return fallback_transition_data()
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
|
|
||||||
@partial(jax.jit, static_argnums=(8, 9, 10))
|
|
||||||
def _sample_sessions_jax(
|
|
||||||
key: jax.Array,
|
|
||||||
human_T: jax.Array,
|
|
||||||
agent_T: jax.Array,
|
|
||||||
terminal_mask: jax.Array,
|
|
||||||
start_idx: int,
|
|
||||||
term_idx: int,
|
|
||||||
alpha: float,
|
|
||||||
n_products: int,
|
|
||||||
n_sessions: int,
|
|
||||||
max_steps: int,
|
|
||||||
n_states: int,
|
|
||||||
) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
|
|
||||||
k_actor, k_product, k_step = jax.random.split(key, 3)
|
|
||||||
start_idx_i32 = jnp.asarray(start_idx, dtype=jnp.int32)
|
|
||||||
term_idx_i32 = jnp.asarray(term_idx, dtype=jnp.int32)
|
|
||||||
actor_draw = jax.random.uniform(k_actor, (n_sessions,))
|
|
||||||
actors = (actor_draw < alpha).astype(jnp.int32)
|
|
||||||
products = jax.random.randint(
|
|
||||||
k_product, (n_sessions,), 0, n_products, dtype=jnp.int32
|
|
||||||
)
|
|
||||||
|
|
||||||
active_init = jnp.ones((n_sessions,), dtype=jnp.bool_)
|
|
||||||
state_init = jnp.full((n_sessions,), start_idx_i32, dtype=jnp.int32)
|
|
||||||
|
|
||||||
def _scan_step(carry, _):
|
|
||||||
states, active, rng = carry
|
|
||||||
rng, k = jax.random.split(rng)
|
|
||||||
probs_h = human_T[states]
|
|
||||||
probs_a = agent_T[states]
|
|
||||||
probs = jnp.where(actors[:, None] == 0, probs_h, probs_a)
|
|
||||||
next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1)
|
|
||||||
next_state = jnp.where(active, next_state, term_idx_i32)
|
|
||||||
emitted = jnp.where(active, next_state, -1)
|
|
||||||
is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)]
|
|
||||||
next_active = active & (~is_terminal)
|
|
||||||
carry_states = jnp.where(next_active, next_state, term_idx_i32)
|
|
||||||
return (carry_states, next_active, rng), emitted
|
|
||||||
|
|
||||||
_, state_t = jax.lax.scan(
|
|
||||||
_scan_step, (state_init, active_init, k_step), None, length=max_steps
|
|
||||||
)
|
|
||||||
states = state_t.T
|
|
||||||
lengths = jnp.sum(states >= 0, axis=1, dtype=jnp.int32)
|
|
||||||
return states, products, actors, lengths
|
|
||||||
|
|
||||||
|
|
||||||
def sample_sessions(
|
|
||||||
key,
|
|
||||||
transition_data: TransitionData,
|
|
||||||
alpha: float,
|
|
||||||
n_products: int,
|
|
||||||
n_sessions: int,
|
|
||||||
max_steps: int,
|
|
||||||
) -> SessionBatch:
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
td = transition_data.to_jax()
|
|
||||||
states, products, actors, lengths = _sample_sessions_jax(
|
|
||||||
key,
|
|
||||||
td.human_T,
|
|
||||||
td.agent_T,
|
|
||||||
td.terminal_mask,
|
|
||||||
int(td.start_idx),
|
|
||||||
int(td.term_idx),
|
|
||||||
float(alpha),
|
|
||||||
int(n_products),
|
|
||||||
int(n_sessions),
|
|
||||||
int(max_steps),
|
|
||||||
int(td.human_T.shape[0]),
|
|
||||||
)
|
|
||||||
return SessionBatch(
|
|
||||||
states=states, products=products, actors=actors, lengths=lengths
|
|
||||||
)
|
|
||||||
|
|
||||||
rng = np.random.default_rng(int(np.asarray(key).reshape(-1)[0]))
|
|
||||||
n_states = transition_data.human_T.shape[0]
|
|
||||||
products = rng.integers(0, n_products, size=n_sessions, dtype=np.int32)
|
|
||||||
actors = (rng.random(size=n_sessions) < alpha).astype(np.int32)
|
|
||||||
states = np.full((n_sessions, max_steps), -1, dtype=np.int32)
|
|
||||||
lengths = np.zeros((n_sessions,), dtype=np.int32)
|
|
||||||
for i in range(n_sessions):
|
|
||||||
current = int(transition_data.start_idx)
|
|
||||||
mat = transition_data.agent_T if actors[i] == 1 else transition_data.human_T
|
|
||||||
for t in range(max_steps):
|
|
||||||
nxt = int(rng.choice(n_states, p=mat[current]))
|
|
||||||
states[i, t] = nxt
|
|
||||||
if transition_data.terminal_mask[nxt]:
|
|
||||||
lengths[i] = t + 1
|
|
||||||
break
|
|
||||||
current = nxt
|
|
||||||
if lengths[i] == 0:
|
|
||||||
lengths[i] = max_steps
|
|
||||||
return SessionBatch(
|
|
||||||
states=states, products=products, actors=actors, lengths=lengths
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
|
|
||||||
@partial(jax.jit, static_argnums=(2,))
|
|
||||||
def compute_session_transitions(states, lengths, n_states: int):
|
|
||||||
src = states[:, :-1]
|
|
||||||
dst = states[:, 1:]
|
|
||||||
time_idx = jnp.arange(src.shape[1])[None, :]
|
|
||||||
valid = (src >= 0) & (dst >= 0) & (time_idx < (lengths[:, None] - 1))
|
|
||||||
src_clip = jnp.clip(src, 0, n_states - 1)
|
|
||||||
dst_clip = jnp.clip(dst, 0, n_states - 1)
|
|
||||||
src_oh = jax.nn.one_hot(src_clip, n_states)
|
|
||||||
dst_oh = jax.nn.one_hot(dst_clip, n_states)
|
|
||||||
counts = jnp.einsum(
|
|
||||||
"nti,ntj,nt->nij", src_oh, dst_oh, valid.astype(jnp.float32)
|
|
||||||
)
|
|
||||||
row_sums = jnp.sum(counts, axis=-1, keepdims=True)
|
|
||||||
return counts / (row_sums + 1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
else:
|
|
||||||
|
|
||||||
def compute_session_transitions(states, lengths, n_states: int):
|
|
||||||
trans = np.zeros((states.shape[0], n_states, n_states), dtype=np.float32)
|
|
||||||
for i in range(states.shape[0]):
|
|
||||||
for t in range(max(int(lengths[i]) - 1, 0)):
|
|
||||||
s = int(states[i, t])
|
|
||||||
d = int(states[i, t + 1])
|
|
||||||
if s >= 0 and d >= 0:
|
|
||||||
trans[i, s, d] += 1.0
|
|
||||||
row_sums = trans.sum(axis=-1, keepdims=True)
|
|
||||||
return trans / (row_sums + 1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def batch_kl(P, Q_human, Q_agent, eps: float = 1e-10):
|
|
||||||
p = P + eps
|
|
||||||
p = p / jnp.sum(p, axis=-1, keepdims=True)
|
|
||||||
qh = Q_human[None, ...] + eps
|
|
||||||
qa = Q_agent[None, ...] + eps
|
|
||||||
delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2))
|
|
||||||
delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2))
|
|
||||||
return delta_h, delta_a
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
batch_kl = jax.jit(batch_kl)
|
|
||||||
|
|
||||||
|
|
||||||
def agent_probability_from_kl(delta_h, delta_a, temperature: float = 1.0):
|
|
||||||
t = jnp.maximum(float(temperature), 1e-6)
|
|
||||||
exp_h = jnp.exp(-delta_h / t)
|
|
||||||
exp_a = jnp.exp(-delta_a / t)
|
|
||||||
return exp_a / (exp_h + exp_a + 1e-10)
|
|
||||||
|
|
||||||
|
|
||||||
def estimate_alpha_from_kl(delta_h, delta_a, beta: float = 2.0):
|
|
||||||
logits = beta * (delta_h - delta_a)
|
|
||||||
return 1.0 / (1.0 + jnp.exp(-logits))
|
|
||||||
|
|
||||||
|
|
||||||
def weighted_demand(states, products, n_products: int, event_weights):
|
|
||||||
valid = states >= 0
|
|
||||||
state_clip = jnp.clip(states, 0, event_weights.shape[0] - 1)
|
|
||||||
weights = event_weights[state_clip] * valid
|
|
||||||
per_session = jnp.sum(weights, axis=1)
|
|
||||||
demand = jnp.zeros((n_products,), dtype=jnp.float32)
|
|
||||||
demand = demand.at[products].add(per_session)
|
|
||||||
total = jnp.sum(demand)
|
|
||||||
return jnp.where(total > 0.0, (demand / total) * 100.0, demand)
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
weighted_demand = jax.jit(weighted_demand, static_argnums=(2,))
|
|
||||||
|
|
||||||
|
|
||||||
def purchase_flags(states, purchase_mask):
|
|
||||||
state_clip = jnp.clip(states, 0, purchase_mask.shape[0] - 1)
|
|
||||||
hits = purchase_mask[state_clip] & (states >= 0)
|
|
||||||
return jnp.any(hits, axis=1)
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
purchase_flags = jax.jit(purchase_flags)
|
|
||||||
|
|
||||||
|
|
||||||
def revenue_from_demand(prices, demand):
|
|
||||||
return jnp.dot(prices, demand)
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
revenue_from_demand = jax.jit(revenue_from_demand)
|
|
||||||
|
|
||||||
|
|
||||||
def reward_with_coi_penalty(
|
|
||||||
revenue, agent_prob: float, lambda_coi: float, info_value: float
|
|
||||||
):
|
|
||||||
leakage = agent_prob * info_value
|
|
||||||
discount = jnp.clip(1.0 - lambda_coi * leakage, 0.0, 1.0)
|
|
||||||
return revenue * discount, leakage, discount
|
|
||||||
|
|
||||||
|
|
||||||
if JAX_AVAILABLE:
|
|
||||||
reward_with_coi_penalty = jax.jit(reward_with_coi_penalty)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
flax==0.10.7
|
|
||||||
optax==0.2.7
|
|
||||||
distrax==0.1.5
|
|
||||||
orbax-checkpoint==0.11.32
|
|
||||||
chex==0.1.90
|
|
||||||
197
engine/jax/robust.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
"""JAX-accelerated robust inner loop for PHANTOM.
|
||||||
|
|
||||||
|
provides a drop-in replacement for the sequential alpha-candidate evaluation in
|
||||||
|
wrapper.py::_select_adversarial_alpha. the demand generation and reward
|
||||||
|
computation are vmapped over the K candidate alpha values so all candidates are
|
||||||
|
evaluated in a single vectorized pass instead of K sequential Python calls.
|
||||||
|
|
||||||
|
public surface:
|
||||||
|
select_adversarial_alpha_jax(candidates, prices, human_params, agent_params,
|
||||||
|
noise_std, n_sessions, n_products,
|
||||||
|
baseline_prices, lambda_coi, info_value,
|
||||||
|
reward_profit_weight, rng_key)
|
||||||
|
-> (best_alpha: float, rewards: np.ndarray)
|
||||||
|
|
||||||
|
falls back gracefully when JAX is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
import jax.numpy as jnp
|
||||||
|
from jax import vmap, jit
|
||||||
|
|
||||||
|
_JAX_OK = True
|
||||||
|
except ImportError:
|
||||||
|
_JAX_OK = False
|
||||||
|
|
||||||
|
_JAX_RUNTIME_OK = True
|
||||||
|
|
||||||
|
|
||||||
|
def _demand_for_actor_jax(prices, mean, std, noise_std, key):
|
||||||
|
"""d(p;theta) = max(0, val - price + noise), normalized to sum 100."""
|
||||||
|
k1, k2 = jax.random.split(key)
|
||||||
|
val = jax.random.normal(k1, shape=prices.shape) * std + mean
|
||||||
|
noise = jax.random.normal(k2, shape=prices.shape) * noise_std
|
||||||
|
demand = jnp.maximum(0.0, val - prices + noise)
|
||||||
|
total = demand.sum()
|
||||||
|
return jnp.where(total > 0, demand / total * 100.0, demand)
|
||||||
|
|
||||||
|
|
||||||
|
def _reward_for_candidate(
|
||||||
|
alpha,
|
||||||
|
prices,
|
||||||
|
human_mean,
|
||||||
|
human_std,
|
||||||
|
agent_mean,
|
||||||
|
agent_std,
|
||||||
|
noise_std,
|
||||||
|
baseline_prices,
|
||||||
|
lambda_coi,
|
||||||
|
info_value,
|
||||||
|
reward_profit_weight,
|
||||||
|
key,
|
||||||
|
):
|
||||||
|
"""compute a scalar reward for a single alpha candidate (pure JAX, vmappable)."""
|
||||||
|
k_h, k_a = jax.random.split(key)
|
||||||
|
# mixed demand proxy: weighted sum of human and agent demand signals
|
||||||
|
demand_h = _demand_for_actor_jax(prices, human_mean, human_std, noise_std, k_h)
|
||||||
|
demand_a = _demand_for_actor_jax(prices, agent_mean, agent_std, noise_std, k_a)
|
||||||
|
demand = (1.0 - alpha) * demand_h + alpha * demand_a
|
||||||
|
|
||||||
|
revenue = jnp.dot(prices, demand)
|
||||||
|
floor_cost = jnp.dot(baseline_prices, demand)
|
||||||
|
profit = revenue - floor_cost
|
||||||
|
|
||||||
|
# agent_prob proxy: use alpha directly (no trajectory available in vectorized path)
|
||||||
|
coi_leakage = alpha * info_value
|
||||||
|
info_budget = jnp.maximum(floor_cost, 1.0)
|
||||||
|
coi_penalty = lambda_coi * coi_leakage * info_budget
|
||||||
|
|
||||||
|
return reward_profit_weight * profit - coi_penalty
|
||||||
|
|
||||||
|
|
||||||
|
if _JAX_OK:
|
||||||
|
# compile once; retracing only happens on shape/dtype changes
|
||||||
|
# 12 args: alpha, prices, h_mean, h_std, a_mean, a_std, noise_std,
|
||||||
|
# baseline_prices, lambda_coi, info_value, reward_profit_weight, key
|
||||||
|
_reward_batched = jit(
|
||||||
|
vmap(
|
||||||
|
_reward_for_candidate,
|
||||||
|
in_axes=(0, None, None, None, None, None, None, None, None, None, None, 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def select_adversarial_alpha_jax(
|
||||||
|
candidates: np.ndarray,
|
||||||
|
prices: np.ndarray,
|
||||||
|
human_params: tuple,
|
||||||
|
agent_params: tuple,
|
||||||
|
noise_std: float,
|
||||||
|
baseline_prices: np.ndarray,
|
||||||
|
lambda_coi: float,
|
||||||
|
info_value: float,
|
||||||
|
reward_profit_weight: float,
|
||||||
|
rng_seed: int = 0,
|
||||||
|
) -> tuple[float, np.ndarray]:
|
||||||
|
"""evaluate all alpha candidates in a single vmapped pass.
|
||||||
|
|
||||||
|
returns (best_alpha, rewards_array) where best_alpha minimizes reward
|
||||||
|
(worst case for the platform, driving robust policy training).
|
||||||
|
|
||||||
|
falls back to a pure-numpy sequential loop when JAX is unavailable so the
|
||||||
|
wrapper can call this function unconditionally.
|
||||||
|
"""
|
||||||
|
global _JAX_RUNTIME_OK
|
||||||
|
|
||||||
|
if not _JAX_OK or not _JAX_RUNTIME_OK:
|
||||||
|
return _fallback(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
human_params,
|
||||||
|
agent_params,
|
||||||
|
noise_std,
|
||||||
|
baseline_prices,
|
||||||
|
lambda_coi,
|
||||||
|
info_value,
|
||||||
|
reward_profit_weight,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
k = len(candidates)
|
||||||
|
key = jax.random.PRNGKey(rng_seed)
|
||||||
|
keys = jax.random.split(key, k)
|
||||||
|
|
||||||
|
rewards = np.asarray(
|
||||||
|
_reward_batched(
|
||||||
|
jnp.asarray(candidates, dtype=jnp.float32),
|
||||||
|
jnp.asarray(prices, dtype=jnp.float32),
|
||||||
|
float(human_params[0]),
|
||||||
|
float(human_params[1]),
|
||||||
|
float(agent_params[0]),
|
||||||
|
float(agent_params[1]),
|
||||||
|
float(noise_std),
|
||||||
|
jnp.asarray(baseline_prices, dtype=jnp.float32),
|
||||||
|
float(lambda_coi),
|
||||||
|
float(info_value),
|
||||||
|
float(reward_profit_weight),
|
||||||
|
keys,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
best_idx = int(np.argmin(rewards))
|
||||||
|
return float(candidates[best_idx]), rewards
|
||||||
|
except Exception as exc:
|
||||||
|
# TPU contention / backend init failures can happen in distributed schedulers.
|
||||||
|
# Degrade to numpy path for the remainder of the process.
|
||||||
|
_JAX_RUNTIME_OK = False
|
||||||
|
print(f"PHANTOM_JAX_FALLBACK: {exc}")
|
||||||
|
return _fallback(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
human_params,
|
||||||
|
agent_params,
|
||||||
|
noise_std,
|
||||||
|
baseline_prices,
|
||||||
|
lambda_coi,
|
||||||
|
info_value,
|
||||||
|
reward_profit_weight,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback(
|
||||||
|
candidates,
|
||||||
|
prices,
|
||||||
|
human_params,
|
||||||
|
agent_params,
|
||||||
|
noise_std,
|
||||||
|
baseline_prices,
|
||||||
|
lambda_coi,
|
||||||
|
info_value,
|
||||||
|
reward_profit_weight,
|
||||||
|
):
|
||||||
|
"""numpy fallback matching the reward formula above."""
|
||||||
|
rewards = []
|
||||||
|
for alpha in candidates:
|
||||||
|
rng = np.random.default_rng()
|
||||||
|
val_h = rng.normal(*human_params, size=len(prices))
|
||||||
|
val_a = rng.normal(*agent_params, size=len(prices))
|
||||||
|
noise_h = rng.normal(0, noise_std, len(prices))
|
||||||
|
noise_a = rng.normal(0, noise_std, len(prices))
|
||||||
|
d_h = np.maximum(0, val_h - prices + noise_h)
|
||||||
|
d_a = np.maximum(0, val_a - prices + noise_a)
|
||||||
|
s_h, s_a = d_h.sum(), d_a.sum()
|
||||||
|
d_h = d_h / s_h * 100 if s_h > 0 else d_h
|
||||||
|
d_a = d_a / s_a * 100 if s_a > 0 else d_a
|
||||||
|
demand = (1.0 - alpha) * d_h + alpha * d_a
|
||||||
|
revenue = float(np.dot(prices, demand))
|
||||||
|
floor_cost = float(np.dot(baseline_prices, demand))
|
||||||
|
profit = revenue - floor_cost
|
||||||
|
coi_penalty = lambda_coi * alpha * info_value * max(floor_cost, 1.0)
|
||||||
|
rewards.append(reward_profit_weight * profit - coi_penalty)
|
||||||
|
rewards = np.array(rewards)
|
||||||
|
best_idx = int(np.argmin(rewards))
|
||||||
|
return float(candidates[best_idx]), rewards
|
||||||
1319
engine/jax/train.py
@@ -1,14 +1,38 @@
|
|||||||
from .demand import estimate_demand, estimate_weighted_demand, generate_demand_for_actor
|
from __future__ import annotations
|
||||||
from .behavior import sample_behavior, get_transition_models, trajectory_to_events
|
|
||||||
from .render import DashboardRenderer, style_axis
|
from importlib import import_module
|
||||||
from .wrappers import EconomicMetricsWrapper
|
|
||||||
from .callbacks import MetricsCallback, EvalMetricsCallback, CheckpointArtifactCallback
|
_EXPORTS: dict[str, tuple[str, str]] = {
|
||||||
from .providers import (
|
"estimate_demand": (".demand", "estimate_demand"),
|
||||||
ProviderBenchmark,
|
"estimate_weighted_demand": (".demand", "estimate_weighted_demand"),
|
||||||
ProviderResult,
|
"generate_demand_for_actor": (".demand", "generate_demand_for_actor"),
|
||||||
BenchmarkConfig,
|
"sample_behavior": (".behavior", "sample_behavior"),
|
||||||
RandomBaseline,
|
"get_transition_models": (".behavior", "get_transition_models"),
|
||||||
SurgeBaseline,
|
"trajectory_to_events": (".behavior", "trajectory_to_events"),
|
||||||
)
|
"DashboardRenderer": (".render", "DashboardRenderer"),
|
||||||
from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability
|
"style_axis": (".render", "style_axis"),
|
||||||
from .discrete import EventQTable
|
"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
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ human_dir = str(base_dir / "collected_data")
|
|||||||
agent_dir = str(base_dir / "agents" / "collected_data")
|
agent_dir = str(base_dir / "agents" / "collected_data")
|
||||||
|
|
||||||
_cache = {} # lazy cache for models and base pivots
|
_cache = {} # lazy cache for models and base pivots
|
||||||
|
# cache keyed by (human: bool, condition_tuple) so we skip Kronecker re-expansion
|
||||||
|
# for repeated calls with the same demand condition inside the robustness inner loop
|
||||||
|
_transition_cache: dict = {}
|
||||||
|
|
||||||
|
|
||||||
def _get_base_pivot(human: bool):
|
def _get_base_pivot(human: bool):
|
||||||
@@ -68,22 +71,41 @@ def trajectory_to_events(trajectory: list) -> list:
|
|||||||
"""extract event names from trajectory for KL divergence calculation
|
"""extract event names from trajectory for KL divergence calculation
|
||||||
|
|
||||||
trajectories are in format 'eventName_product0', extract just eventName
|
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 = []
|
return [s.rsplit("_product", 1)[0] if "_product" in s else s for s in trajectory]
|
||||||
for state in trajectory:
|
|
||||||
# state format from sample_behavior: 'eventName_productX'
|
|
||||||
if "_product" in state:
|
class _TransitionTable:
|
||||||
event = state.rsplit("_product", 1)[0]
|
"""numpy-backed transition table; replaces per-step pandas .loc[] indexing.
|
||||||
else:
|
|
||||||
event = state
|
the profiling hotspot was DataFrame.xs called ~4-16k times per outer step.
|
||||||
events.append(event)
|
converting once to a dense float32 array with an int-keyed state index map
|
||||||
return events
|
reduces each row lookup to a single array slice with no pandas overhead.
|
||||||
|
rows are pre-normalized so sampling requires no per-step division.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ("matrix", "states", "state_index", "n_states")
|
||||||
|
|
||||||
|
def __init__(self, df: pd.DataFrame):
|
||||||
|
self.states: list[str] = df.index.tolist()
|
||||||
|
self.state_index: dict[str, int] = {s: i for i, s in enumerate(self.states)}
|
||||||
|
# float64 throughout: float32 row-sums can drift enough to break np.random.choice
|
||||||
|
mat = np.nan_to_num(
|
||||||
|
df.values.astype(np.float64), nan=0.0, posinf=0.0, neginf=0.0
|
||||||
|
)
|
||||||
|
mat = np.clip(mat, 0.0, None)
|
||||||
|
row_sums = mat.sum(axis=1)
|
||||||
|
# dead rows (all zero) get uniform distribution so sampling never receives NaN
|
||||||
|
dead = row_sums <= 0
|
||||||
|
mat[dead] = 1.0
|
||||||
|
row_sums[dead] = float(mat.shape[1])
|
||||||
|
mat = mat / row_sums[:, np.newaxis]
|
||||||
|
# final nan guard in case fp still drifts
|
||||||
|
np.nan_to_num(mat, nan=0.0, copy=False)
|
||||||
|
row_sums2 = mat.sum(axis=1, keepdims=True)
|
||||||
|
row_sums2[row_sums2 <= 0] = 1.0
|
||||||
|
self.matrix: np.ndarray = mat / row_sums2
|
||||||
|
self.n_states: int = len(self.states)
|
||||||
|
|
||||||
|
|
||||||
def adjust_behavior_to_condition(condition, transition_matrix):
|
def adjust_behavior_to_condition(condition, transition_matrix):
|
||||||
@@ -92,41 +114,75 @@ def adjust_behavior_to_condition(condition, transition_matrix):
|
|||||||
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
|
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
condition = np.clip(condition, 0.0, None)
|
condition = np.clip(condition, 0.0, None)
|
||||||
s = float(np.sum(condition))
|
s = float(np.sum(condition))
|
||||||
if not np.isfinite(s) or s <= 0:
|
cond_norm = (
|
||||||
cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
|
condition / s
|
||||||
else:
|
if np.isfinite(s) and s > 0
|
||||||
cond_norm = condition / s
|
else np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
|
||||||
|
)
|
||||||
n_products = len(condition)
|
n_products = len(condition)
|
||||||
base_vals = transition_matrix.values
|
base_vals = transition_matrix.values
|
||||||
base_cols, base_rows = (
|
base_cols, base_rows = (
|
||||||
transition_matrix.columns.tolist(),
|
transition_matrix.columns.tolist(),
|
||||||
transition_matrix.index.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))
|
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_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)]
|
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)
|
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||||
|
|
||||||
|
|
||||||
def sample_behavior(condition, human=True, max_len=40):
|
def get_adjusted_transitions(condition, human=True) -> _TransitionTable:
|
||||||
base_pivot = _get_base_pivot(human)
|
"""return a _TransitionTable for the given demand condition.
|
||||||
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
|
|
||||||
|
|
||||||
trajectory = [np.random.choice(adjusted_transitions.index)]
|
results are cached by (human, rounded-condition) so that repeated calls with
|
||||||
|
the same condition inside the robustness inner loop (K candidates, same prices)
|
||||||
|
skip the Kronecker expansion entirely.
|
||||||
|
"""
|
||||||
|
condition = np.asarray(condition, dtype=float)
|
||||||
|
# round to 4 significant digits for cache key stability
|
||||||
|
cache_key = (human, tuple(np.round(condition, 4).tolist()))
|
||||||
|
if cache_key in _transition_cache:
|
||||||
|
return _transition_cache[cache_key]
|
||||||
|
|
||||||
|
# prevent OOM by capping cache size
|
||||||
|
if len(_transition_cache) > 100:
|
||||||
|
_transition_cache.clear()
|
||||||
|
|
||||||
|
base_pivot = _get_base_pivot(human)
|
||||||
|
df = adjust_behavior_to_condition(condition, base_pivot)
|
||||||
|
table = _TransitionTable(df)
|
||||||
|
_transition_cache[cache_key] = table
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def clear_transition_cache():
|
||||||
|
"""drop cached transition tables; call between episodes if condition space is large."""
|
||||||
|
_transition_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def sample_behavior_from_transitions(table, max_len=40):
|
||||||
|
"""sample a Markov trajectory.
|
||||||
|
|
||||||
|
accepts _TransitionTable (fast path) or a legacy pandas DataFrame so existing
|
||||||
|
call sites that pass a DataFrame directly continue to work unchanged.
|
||||||
|
"""
|
||||||
|
if isinstance(table, pd.DataFrame):
|
||||||
|
table = _TransitionTable(table)
|
||||||
|
|
||||||
|
idx = np.random.randint(table.n_states)
|
||||||
|
trajectory = [table.states[idx]]
|
||||||
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
|
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
|
||||||
probs = np.asarray(adjusted_transitions.loc[trajectory[-1]].values, dtype=float)
|
row = table.matrix[table.state_index[trajectory[-1]]]
|
||||||
probs = np.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0)
|
idx = int(np.random.choice(table.n_states, p=row))
|
||||||
probs = np.clip(probs, 0.0, None)
|
trajectory.append(table.states[idx])
|
||||||
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
|
return trajectory
|
||||||
|
|
||||||
|
|
||||||
|
def sample_behavior(condition, human=True, max_len=40):
|
||||||
|
table = get_adjusted_transitions(condition, human=human)
|
||||||
|
return sample_behavior_from_transitions(table, max_len=max_len)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
|
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
|
||||||
print(t)
|
print(t)
|
||||||
|
|||||||
@@ -1,182 +1,259 @@
|
|||||||
"""Training callbacks for W&B/TensorBoard logging - reads from info dict."""
|
"""Training callbacks with algorithm-agnostic metric extraction."""
|
||||||
|
|
||||||
from pathlib import Path
|
from typing import Any
|
||||||
|
|
||||||
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
|
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file
|
from ..telemetry.wandb import get_wandb_module
|
||||||
|
|
||||||
try:
|
|
||||||
import wandb
|
|
||||||
|
|
||||||
HAS_WANDB = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_WANDB = False
|
|
||||||
|
|
||||||
|
|
||||||
class MetricsCallback(BaseCallback):
|
class MetricsCallback(BaseCallback):
|
||||||
"""Training metrics logger - reads info['economics'], logs to W&B."""
|
"""Collects interval train metrics from env info dictionaries."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, log_histograms: bool = True, log_freq: int = 100, verbose: int = 0
|
self,
|
||||||
|
log_histograms: bool = False,
|
||||||
|
log_freq: int = 100,
|
||||||
|
hist_freq: int = 500,
|
||||||
|
step_offset: int = 0,
|
||||||
|
verbose: int = 0,
|
||||||
):
|
):
|
||||||
super().__init__(verbose)
|
super().__init__(verbose)
|
||||||
self.log_histograms = log_histograms
|
self.log_histograms = log_histograms
|
||||||
self.log_freq = log_freq
|
self.log_freq = max(1, int(log_freq))
|
||||||
self._episode_revenues: list[float] = []
|
self.hist_freq = max(1, int(hist_freq))
|
||||||
|
self.step_offset = max(0, int(step_offset))
|
||||||
def _on_step(self) -> bool:
|
self._wandb = get_wandb_module()
|
||||||
if not HAS_WANDB or wandb.run is None:
|
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
|
||||||
return True
|
self._price_samples: list[float] = []
|
||||||
|
self._demand_samples: list[float] = []
|
||||||
for info in self.locals.get("infos", []):
|
self._window_sums = {
|
||||||
if "economics" not in info:
|
"train/revenue_mean": 0.0,
|
||||||
continue
|
"train/margin_mean": 0.0,
|
||||||
|
"train/coi_level_mean": 0.0,
|
||||||
econ = info["economics"]
|
"train/regret_mean": 0.0,
|
||||||
t = self.num_timesteps
|
"train/profit_mean": 0.0,
|
||||||
|
"train/agent_prob": 0.0,
|
||||||
payload = {
|
"train/alpha_adv": 0.0,
|
||||||
"economics/revenue": econ["revenue"],
|
"train/ux_penalty": 0.0,
|
||||||
"economics/margin": econ["margin"],
|
"train/volatility": 0.0,
|
||||||
"coi/level": econ["coi_level"],
|
"train/coi_mix": 0.0,
|
||||||
"economics/regret": econ["regret"],
|
"train/coi_base": 0.0,
|
||||||
}
|
"train/coi_leakage": 0.0,
|
||||||
if "coi_mix" in econ:
|
"train/coi_penalty": 0.0,
|
||||||
payload["coi/mix"] = econ["coi_mix"]
|
|
||||||
if "coi_base" in econ:
|
|
||||||
payload["coi/base"] = econ["coi_base"]
|
|
||||||
if "coi_leakage" in econ:
|
|
||||||
payload["coi/leakage"] = econ["coi_leakage"]
|
|
||||||
if "coi_penalty" in econ:
|
|
||||||
payload["coi/penalty"] = econ["coi_penalty"]
|
|
||||||
wandb.log(payload, step=t)
|
|
||||||
|
|
||||||
self._episode_revenues.append(econ["revenue"])
|
|
||||||
|
|
||||||
# histograms at log_freq intervals
|
|
||||||
if self.log_histograms and self.num_timesteps % self.log_freq == 0:
|
|
||||||
for info in self.locals.get("infos", []):
|
|
||||||
if "prices" in info:
|
|
||||||
wandb.log(
|
|
||||||
{"distributions/prices": wandb.Histogram(info["prices"])},
|
|
||||||
step=self.num_timesteps,
|
|
||||||
)
|
|
||||||
if "demand" in info:
|
|
||||||
wandb.log(
|
|
||||||
{"distributions/demand": wandb.Histogram(info["demand"])},
|
|
||||||
step=self.num_timesteps,
|
|
||||||
)
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _on_rollout_end(self) -> None:
|
|
||||||
if not HAS_WANDB or wandb.run is None or not self._episode_revenues:
|
|
||||||
return
|
|
||||||
wandb.log(
|
|
||||||
{
|
|
||||||
"episode/mean_revenue": np.mean(self._episode_revenues),
|
|
||||||
"episode/total_revenue": np.sum(self._episode_revenues),
|
|
||||||
},
|
|
||||||
step=self.num_timesteps,
|
|
||||||
)
|
|
||||||
self._episode_revenues = []
|
|
||||||
|
|
||||||
|
|
||||||
class CheckpointArtifactCallback(BaseCallback):
|
|
||||||
"""Periodic SB3 checkpoint uploader backed by W&B artifacts."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: dict, interval: int = 10_000, verbose: int = 0):
|
|
||||||
super().__init__(verbose)
|
|
||||||
self.cfg = dict(cfg)
|
|
||||||
self.interval = max(1, int(interval))
|
|
||||||
self.model_dir = Path(str(self.cfg.get("model_dir", "engine/models")))
|
|
||||||
self.model_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
self._next_checkpoint = self.interval
|
|
||||||
self._last_saved_step = -1
|
|
||||||
|
|
||||||
def _artifact_name(self) -> str:
|
|
||||||
sweep_id = (
|
|
||||||
getattr(wandb.run, "sweep_id", None)
|
|
||||||
if HAS_WANDB and wandb.run is not None
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
return checkpoint_artifact_name(self.cfg, backend="sb3", sweep_id=sweep_id)
|
|
||||||
|
|
||||||
def _checkpoint_file(self) -> Path:
|
|
||||||
algo = str(self.cfg.get("algo", "model"))
|
|
||||||
base = self.model_dir / f"phantom_{algo}_checkpoint"
|
|
||||||
self.model.save(str(base))
|
|
||||||
return base.with_suffix(".zip")
|
|
||||||
|
|
||||||
def _save_checkpoint(self) -> None:
|
|
||||||
if not HAS_WANDB or wandb.run is None:
|
|
||||||
return
|
|
||||||
step = int(self.num_timesteps)
|
|
||||||
if step <= self._last_saved_step:
|
|
||||||
return
|
|
||||||
checkpoint_path = self._checkpoint_file()
|
|
||||||
metadata = {
|
|
||||||
"step": step,
|
|
||||||
"algo": str(self.cfg.get("algo", "unknown")),
|
|
||||||
"sweep_id": getattr(wandb.run, "sweep_id", None),
|
|
||||||
}
|
}
|
||||||
saved = log_checkpoint_file(
|
self._window_count = 0
|
||||||
self._artifact_name(),
|
self.events: list[dict[str, Any]] = []
|
||||||
file_path=checkpoint_path,
|
|
||||||
artifact_file_name=checkpoint_path.name,
|
def _accumulate(self, info: dict[str, Any]) -> None:
|
||||||
metadata=metadata,
|
econ = info.get("economics")
|
||||||
)
|
if not isinstance(econ, dict):
|
||||||
if saved:
|
return
|
||||||
self._last_saved_step = step
|
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 _accumulate_histograms(self, info: dict[str, Any]) -> None:
|
||||||
|
if not self.log_histograms:
|
||||||
|
return
|
||||||
|
|
||||||
|
for key in ("effective_prices", "prices"):
|
||||||
|
if key not in info:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values = np.asarray(info.get(key), dtype=float).reshape(-1)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if values.size <= 0:
|
||||||
|
continue
|
||||||
|
finite_values = values[np.isfinite(values)]
|
||||||
|
if finite_values.size > 0:
|
||||||
|
self._price_samples.extend(finite_values.tolist())
|
||||||
|
break
|
||||||
|
|
||||||
|
if "demand" in info:
|
||||||
|
try:
|
||||||
|
demand_values = np.asarray(info.get("demand"), dtype=float).reshape(-1)
|
||||||
|
except Exception:
|
||||||
|
demand_values = np.array([], dtype=float)
|
||||||
|
if demand_values.size > 0:
|
||||||
|
finite_demand = demand_values[np.isfinite(demand_values)]
|
||||||
|
if finite_demand.size > 0:
|
||||||
|
self._demand_samples.extend(finite_demand.tolist())
|
||||||
|
|
||||||
|
def _flush_histograms(self, step: int, force: bool = False) -> None:
|
||||||
|
if not self.log_histograms:
|
||||||
|
return
|
||||||
|
if not force and step % self.hist_freq != 0:
|
||||||
|
return
|
||||||
|
if not self._price_samples and not self._demand_samples:
|
||||||
|
return
|
||||||
|
if self._wandb is None:
|
||||||
|
self._price_samples.clear()
|
||||||
|
self._demand_samples.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {}
|
||||||
|
if self._price_samples:
|
||||||
|
payload["train/price_dist"] = self._wandb.Histogram(
|
||||||
|
np.asarray(self._price_samples, dtype=np.float32)
|
||||||
|
)
|
||||||
|
if self._demand_samples:
|
||||||
|
payload["train/demand_dist"] = self._wandb.Histogram(
|
||||||
|
np.asarray(self._demand_samples, dtype=np.float32)
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload and self._wandb_live:
|
||||||
|
try:
|
||||||
|
self._wandb.log(payload, step=self.step_offset + int(step))
|
||||||
|
except Exception:
|
||||||
|
self._wandb_live = False
|
||||||
|
|
||||||
|
self._price_samples.clear()
|
||||||
|
self._demand_samples.clear()
|
||||||
|
|
||||||
|
def _flush(self, step: int, *, force_hist: bool = False) -> None:
|
||||||
|
if self._window_count > 0:
|
||||||
|
denom = float(self._window_count)
|
||||||
|
payload = {
|
||||||
|
key: (value / denom)
|
||||||
|
for key, value in self._window_sums.items()
|
||||||
|
if value != 0.0
|
||||||
|
or key
|
||||||
|
in {
|
||||||
|
"train/revenue_mean",
|
||||||
|
"train/margin_mean",
|
||||||
|
"train/coi_level_mean",
|
||||||
|
"train/regret_mean",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload["train/global_step"] = int(step)
|
||||||
|
if self._wandb_live:
|
||||||
|
try:
|
||||||
|
self._wandb.log(dict(payload), step=self.step_offset + int(step))
|
||||||
|
except Exception:
|
||||||
|
self._wandb_live = False
|
||||||
|
self.events.append(payload)
|
||||||
|
else:
|
||||||
|
self.events.append(payload)
|
||||||
|
for key in self._window_sums:
|
||||||
|
self._window_sums[key] = 0.0
|
||||||
|
self._window_count = 0
|
||||||
|
|
||||||
|
self._flush_histograms(step=step, force=force_hist)
|
||||||
|
|
||||||
def _on_step(self) -> bool:
|
def _on_step(self) -> bool:
|
||||||
if self.num_timesteps < self._next_checkpoint:
|
for info in self.locals.get("infos", []):
|
||||||
return True
|
if isinstance(info, dict):
|
||||||
self._save_checkpoint()
|
self._accumulate(info)
|
||||||
while self._next_checkpoint <= self.num_timesteps:
|
self._accumulate_histograms(info)
|
||||||
self._next_checkpoint += self.interval
|
|
||||||
|
if self.num_timesteps % self.log_freq == 0:
|
||||||
|
self._flush(step=self.num_timesteps)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _on_training_end(self) -> None:
|
def _on_training_end(self) -> None:
|
||||||
self._save_checkpoint()
|
self._flush(step=self.num_timesteps, force_hist=True)
|
||||||
|
|
||||||
|
|
||||||
class EvalMetricsCallback(EvalCallback):
|
class EvalMetricsCallback(EvalCallback):
|
||||||
"""Deterministic evaluation - true performance without exploration noise."""
|
"""Deterministic evaluation collector detached from logging backends."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, eval_env, eval_freq: int = 1000, n_eval_episodes: int = 5, **kwargs
|
self,
|
||||||
|
eval_env,
|
||||||
|
eval_freq: int = 1000,
|
||||||
|
n_eval_episodes: int = 5,
|
||||||
|
step_offset: int = 0,
|
||||||
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
|
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
|
||||||
)
|
)
|
||||||
self._eval_revenues: list[float] = []
|
self.step_offset = max(0, int(step_offset))
|
||||||
|
self._wandb = get_wandb_module()
|
||||||
|
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
|
||||||
|
self._eval_stats: dict[str, list[float]] = {
|
||||||
|
"eval/revenue_mean": [],
|
||||||
|
"eval/margin_mean": [],
|
||||||
|
"eval/coi_level_mean": [],
|
||||||
|
"eval/coi_leakage_mean": [],
|
||||||
|
"eval/volatility_mean": [],
|
||||||
|
"eval/agent_prob_mean": [],
|
||||||
|
}
|
||||||
|
self.events: list[dict[str, float | int]] = []
|
||||||
|
|
||||||
def _on_step(self) -> bool:
|
def _on_step(self) -> bool:
|
||||||
result = super()._on_step()
|
result = super()._on_step()
|
||||||
|
|
||||||
if not HAS_WANDB or wandb.run is None:
|
|
||||||
return result
|
|
||||||
|
|
||||||
# log eval metrics after evaluation runs
|
|
||||||
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
|
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
|
||||||
wandb.log(
|
payload: dict[str, float | int] = {
|
||||||
{
|
"eval/reward_mean": float(self.last_mean_reward),
|
||||||
"eval/mean_reward": self.last_mean_reward,
|
"train/global_step": int(self.num_timesteps),
|
||||||
"eval/mean_revenue": np.mean(self._eval_revenues)
|
}
|
||||||
if self._eval_revenues
|
for key, values in self._eval_stats.items():
|
||||||
else 0,
|
payload[key] = float(np.mean(values)) if values else 0.0
|
||||||
},
|
|
||||||
step=self.num_timesteps,
|
if self._wandb_live:
|
||||||
)
|
try:
|
||||||
self._eval_revenues = []
|
self._wandb.log(
|
||||||
|
dict(payload),
|
||||||
|
step=self.step_offset + int(self.num_timesteps),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._wandb_live = False
|
||||||
|
self.events.append(payload)
|
||||||
|
else:
|
||||||
|
self.events.append(payload)
|
||||||
|
|
||||||
|
for values in self._eval_stats.values():
|
||||||
|
values.clear()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
|
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
|
||||||
# called after each eval episode
|
# called after each eval episode
|
||||||
info = locals_.get("info", {})
|
info = locals_.get("info", {})
|
||||||
if "economics" in info:
|
econ = info.get("economics") if isinstance(info, dict) else None
|
||||||
self._eval_revenues.append(info["economics"]["revenue"])
|
if not isinstance(econ, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._eval_stats["eval/revenue_mean"].append(float(econ.get("revenue", 0.0)))
|
||||||
|
self._eval_stats["eval/margin_mean"].append(float(econ.get("margin", 0.0)))
|
||||||
|
self._eval_stats["eval/coi_level_mean"].append(
|
||||||
|
float(econ.get("coi_level", 0.0))
|
||||||
|
)
|
||||||
|
self._eval_stats["eval/coi_leakage_mean"].append(
|
||||||
|
float(econ.get("coi_leakage", 0.0))
|
||||||
|
)
|
||||||
|
self._eval_stats["eval/volatility_mean"].append(
|
||||||
|
float(econ.get("volatility", 0.0))
|
||||||
|
)
|
||||||
|
self._eval_stats["eval/agent_prob_mean"].append(
|
||||||
|
float(econ.get("agent_prob", 0.0))
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
|
from lib.agent_probability import DEFAULT_AGENT_PRIOR, estimate_agent_probability
|
||||||
|
|
||||||
|
|
||||||
def compute_agent_probability(
|
def compute_agent_probability(
|
||||||
trajectory: list, human_transitions: Dict, agent_transitions: Dict
|
trajectory: list,
|
||||||
|
human_transitions: Dict,
|
||||||
|
agent_transitions: Dict,
|
||||||
|
temperature: float = 1.0,
|
||||||
|
prior_agent: float = DEFAULT_AGENT_PRIOR,
|
||||||
) -> float:
|
) -> float:
|
||||||
"""estimate agent probability via KL divergence between trajectory transitions and reference models
|
"""estimate agent probability via KL divergence between trajectory transitions and reference models
|
||||||
|
|
||||||
@@ -15,10 +21,10 @@ def compute_agent_probability(
|
|||||||
agent_transitions: reference transition dict from agent MDP (event->event->prob)
|
agent_transitions: reference transition dict from agent MDP (event->event->prob)
|
||||||
|
|
||||||
returns:
|
returns:
|
||||||
agent probability in [0, 1] via softmax over KL divergences
|
agent probability in [0, 1] via sigma((delta_h - delta_a) / T)
|
||||||
"""
|
"""
|
||||||
if len(trajectory) < 2:
|
if len(trajectory) < 2:
|
||||||
return 0.0 # insufficient data, assume human
|
return float(prior_agent)
|
||||||
|
|
||||||
# build empirical transition distribution from trajectory
|
# build empirical transition distribution from trajectory
|
||||||
trans_counts = {}
|
trans_counts = {}
|
||||||
@@ -51,11 +57,12 @@ def compute_agent_probability(
|
|||||||
kl_human = kl_div(empirical, human_transitions)
|
kl_human = kl_div(empirical, human_transitions)
|
||||||
kl_agent = kl_div(empirical, agent_transitions)
|
kl_agent = kl_div(empirical, agent_transitions)
|
||||||
|
|
||||||
# convert to probability via softmax (lower KL = higher prob)
|
return estimate_agent_probability(
|
||||||
# agent_prob = exp(-kl_agent) / (exp(-kl_human) + exp(-kl_agent))
|
delta_h=kl_human,
|
||||||
exp_h = np.exp(-kl_human)
|
delta_a=kl_agent,
|
||||||
exp_a = np.exp(-kl_agent)
|
temperature=temperature,
|
||||||
return float(exp_a / (exp_h + exp_a + 1e-10))
|
prior_agent=prior_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_purchases(trajectories: list) -> Dict[int, int]:
|
def extract_purchases(trajectories: list) -> Dict[int, int]:
|
||||||
|
|||||||
@@ -17,18 +17,32 @@ def generate_demand_for_actor(
|
|||||||
params: tuple,
|
params: tuple,
|
||||||
noise_std: float = 1.0,
|
noise_std: float = 1.0,
|
||||||
distribution_method=np.random.normal,
|
distribution_method=np.random.normal,
|
||||||
|
normalize: bool = False,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
|
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
|
||||||
params: (mean, std) for valuation distribution D_H or D_A"""
|
params: (mean, std) for valuation distribution D_H or D_A"""
|
||||||
val = distribution_method(*params, size=len(prices))
|
val = distribution_method(*params, size=len(prices))
|
||||||
noise = distribution_method(0, noise_std, len(prices))
|
noise = distribution_method(0, noise_std, len(prices))
|
||||||
demand = np.maximum(0, val - prices + noise)
|
demand = np.maximum(0, val - prices + noise)
|
||||||
|
if not normalize:
|
||||||
|
return demand
|
||||||
total = np.sum(demand)
|
total = np.sum(demand)
|
||||||
return demand / total * 100 if total > 0 else demand
|
return demand / total * 100 if total > 0 else demand
|
||||||
|
|
||||||
|
|
||||||
def estimate_demand(trajectories, action_weights=None):
|
def estimate_demand(
|
||||||
return estimate_weighted_demand(trajectories, action_weights)
|
trajectories,
|
||||||
|
action_weights=None,
|
||||||
|
*,
|
||||||
|
normalize: bool = False,
|
||||||
|
per_session: bool = True,
|
||||||
|
):
|
||||||
|
return estimate_weighted_demand(
|
||||||
|
trajectories,
|
||||||
|
action_weights,
|
||||||
|
normalize=normalize,
|
||||||
|
per_session=per_session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _parse_event_state(state: str):
|
def _parse_event_state(state: str):
|
||||||
@@ -50,7 +64,13 @@ def _weight_for_action(action: str, action_weights: dict) -> float:
|
|||||||
return CATEGORY_WEIGHTS["nav"]
|
return CATEGORY_WEIGHTS["nav"]
|
||||||
|
|
||||||
|
|
||||||
def estimate_weighted_demand(trajectories, action_weights=None):
|
def estimate_weighted_demand(
|
||||||
|
trajectories,
|
||||||
|
action_weights=None,
|
||||||
|
*,
|
||||||
|
normalize: bool = False,
|
||||||
|
per_session: bool = True,
|
||||||
|
):
|
||||||
action_weights = (
|
action_weights = (
|
||||||
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
|
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
|
||||||
)
|
)
|
||||||
@@ -64,12 +84,20 @@ def estimate_weighted_demand(trajectories, action_weights=None):
|
|||||||
if w <= 0:
|
if w <= 0:
|
||||||
continue
|
continue
|
||||||
scores[product_id] = scores.get(product_id, 0.0) + w
|
scores[product_id] = scores.get(product_id, 0.0) + w
|
||||||
total = sum(scores.values())
|
if not scores:
|
||||||
return (
|
return {}
|
||||||
{pid: (score / total) * 100 for pid, score in scores.items()}
|
|
||||||
if total > 0
|
if per_session and len(trajectories) > 0:
|
||||||
else {}
|
inv_n = 1.0 / float(len(trajectories))
|
||||||
)
|
scores = {pid: score * inv_n for pid, score in scores.items()}
|
||||||
|
|
||||||
|
if not normalize:
|
||||||
|
return scores
|
||||||
|
|
||||||
|
total = float(sum(scores.values()))
|
||||||
|
if total <= 0:
|
||||||
|
return {}
|
||||||
|
return {pid: (score / total) * 100.0 for pid, score in scores.items()}
|
||||||
|
|
||||||
|
|
||||||
# Example usage
|
# Example usage
|
||||||
|
|||||||
@@ -156,14 +156,17 @@ class ProviderBenchmark:
|
|||||||
|
|
||||||
# log to wandb if available
|
# log to wandb if available
|
||||||
if HAS_WANDB and wandb.run is not None:
|
if HAS_WANDB and wandb.run is not None:
|
||||||
wandb.log(
|
try:
|
||||||
{
|
wandb.log(
|
||||||
f"benchmark/{name}/revenue": result.mean_revenue,
|
{
|
||||||
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
|
f"benchmark/{name}/revenue": result.mean_revenue,
|
||||||
f"benchmark/{name}/margin": result.margin_integrity,
|
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
|
||||||
"benchmark/alpha": alpha,
|
f"benchmark/{name}/margin": result.margin_integrity,
|
||||||
}
|
"benchmark/alpha": alpha,
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return self.results
|
return self.results
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
"""rendering logic for PHANTOM environment dashboard"""
|
"""rendering logic for PHANTOM environment dashboard"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from matplotlib.gridspec import GridSpec
|
from matplotlib.gridspec import GridSpec
|
||||||
|
|
||||||
|
|
||||||
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
|
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
|
||||||
ax.spines['top'].set_visible(False)
|
ax.spines["top"].set_visible(False)
|
||||||
ax.spines['right'].set_visible(False)
|
ax.spines["right"].set_visible(False)
|
||||||
if title: ax.set_title(title, fontsize=11, fontweight='bold', pad=8)
|
if title:
|
||||||
if xlabel: ax.set_xlabel(xlabel, fontsize=9)
|
ax.set_title(title, fontsize=11, fontweight="bold", pad=8)
|
||||||
if ylabel: ax.set_ylabel(ylabel, fontsize=9)
|
if xlabel:
|
||||||
|
ax.set_xlabel(xlabel, fontsize=9)
|
||||||
|
if ylabel:
|
||||||
|
ax.set_ylabel(ylabel, fontsize=9)
|
||||||
|
|
||||||
|
|
||||||
class DashboardRenderer:
|
class DashboardRenderer:
|
||||||
@@ -23,13 +27,25 @@ class DashboardRenderer:
|
|||||||
if self.fig is None:
|
if self.fig is None:
|
||||||
plt.ion()
|
plt.ion()
|
||||||
self.fig = plt.figure(figsize=(14, 10))
|
self.fig = plt.figure(figsize=(14, 10))
|
||||||
self.gs = GridSpec(3, 3, figure=self.fig, hspace=0.35, wspace=0.3,
|
self.gs = GridSpec(
|
||||||
left=0.07, right=0.95, top=0.92, bottom=0.08)
|
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)
|
plt.show(block=False)
|
||||||
|
|
||||||
self.fig.clear()
|
self.fig.clear()
|
||||||
self.fig.suptitle(f'PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]',
|
self.fig.suptitle(
|
||||||
fontsize=14, fontweight='bold')
|
f"PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]",
|
||||||
|
fontsize=14,
|
||||||
|
fontweight="bold",
|
||||||
|
)
|
||||||
|
|
||||||
demand_mat = np.array(env._demand_history).T
|
demand_mat = np.array(env._demand_history).T
|
||||||
price_mat = np.array(env._price_history).T
|
price_mat = np.array(env._price_history).T
|
||||||
@@ -51,40 +67,56 @@ class DashboardRenderer:
|
|||||||
prices_flat = np.array(env._price_history).flatten()
|
prices_flat = np.array(env._price_history).flatten()
|
||||||
demands_flat = np.array(env._demand_history).flatten()
|
demands_flat = np.array(env._demand_history).flatten()
|
||||||
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
|
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
|
||||||
ax.scatter(prices_flat, demands_flat, c=product_ids, cmap='plasma', alpha=0.6, s=15, edgecolors='none')
|
ax.scatter(
|
||||||
|
prices_flat,
|
||||||
|
demands_flat,
|
||||||
|
c=product_ids,
|
||||||
|
cmap="plasma",
|
||||||
|
alpha=0.6,
|
||||||
|
s=15,
|
||||||
|
edgecolors="none",
|
||||||
|
)
|
||||||
if len(prices_flat) > 1:
|
if len(prices_flat) > 1:
|
||||||
z = np.polyfit(prices_flat, demands_flat, 1)
|
z = np.polyfit(prices_flat, demands_flat, 1)
|
||||||
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
|
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
|
||||||
ax.plot(p_line, np.polyval(z, p_line), '--', lw=1.5, alpha=0.8)
|
ax.plot(p_line, np.polyval(z, p_line), "--", lw=1.5, alpha=0.8)
|
||||||
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
|
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
|
||||||
|
|
||||||
def _render_elasticity_bar(self, env, elasticity):
|
def _render_elasticity_bar(self, env, elasticity):
|
||||||
ax = self.fig.add_subplot(self.gs[0, 1])
|
ax = self.fig.add_subplot(self.gs[0, 1])
|
||||||
ax.barh(range(env.n_products), elasticity, alpha=0.8)
|
ax.barh(range(env.n_products), elasticity, alpha=0.8)
|
||||||
ax.axvline(0, lw=0.8, alpha=0.5)
|
ax.axvline(0, lw=0.8, alpha=0.5)
|
||||||
ax.axvline(-1, lw=1, ls='--', alpha=0.5)
|
ax.axvline(-1, lw=1, ls="--", alpha=0.5)
|
||||||
ax.set_yticks(range(env.n_products))
|
ax.set_yticks(range(env.n_products))
|
||||||
ax.set_yticklabels([f'P{i}' for i in range(env.n_products)], fontsize=7)
|
ax.set_yticklabels([f"P{i}" for i in range(env.n_products)], fontsize=7)
|
||||||
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
|
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
|
||||||
|
|
||||||
def _render_session_pie(self, env):
|
def _render_session_pie(self, env):
|
||||||
ax = self.fig.add_subplot(self.gs[0, 2])
|
ax = self.fig.add_subplot(self.gs[0, 2])
|
||||||
n_h, n_a = env.market.Nhumans, env.market.Nagents
|
n_h, n_a = env.market.Nhumans, env.market.Nagents
|
||||||
wedges, _ = ax.pie([n_h, n_a], startangle=90, wedgeprops={'linewidth': 2, 'edgecolor': 'white'})
|
wedges, _ = ax.pie(
|
||||||
ax.legend(wedges, [f'H ({n_h})', f'A ({n_a})'], loc='lower center', fontsize=8,
|
[n_h, n_a], startangle=90, wedgeprops={"linewidth": 2, "edgecolor": "white"}
|
||||||
frameon=False, bbox_to_anchor=(0.5, -0.05))
|
)
|
||||||
ax.set_title("Session Mix", fontsize=11, fontweight='bold')
|
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):
|
def _render_price_heatmap(self, price_mat):
|
||||||
ax = self.fig.add_subplot(self.gs[1, :2])
|
ax = self.fig.add_subplot(self.gs[1, :2])
|
||||||
im = ax.imshow(price_mat, aspect='auto', cmap='viridis', origin='lower')
|
im = ax.imshow(price_mat, aspect="auto", cmap="viridis", origin="lower")
|
||||||
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
|
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
|
||||||
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
||||||
cbar.set_label('$', fontsize=8)
|
cbar.set_label("$", fontsize=8)
|
||||||
|
|
||||||
def _render_demand_heatmap(self, demand_mat):
|
def _render_demand_heatmap(self, demand_mat):
|
||||||
ax = self.fig.add_subplot(self.gs[1, 2])
|
ax = self.fig.add_subplot(self.gs[1, 2])
|
||||||
im = ax.imshow(demand_mat, aspect='auto', cmap='Blues', origin='lower')
|
im = ax.imshow(demand_mat, aspect="auto", cmap="Blues", origin="lower")
|
||||||
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
||||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
|
|
||||||
@@ -92,11 +124,11 @@ class DashboardRenderer:
|
|||||||
ax = self.fig.add_subplot(self.gs[2, 0])
|
ax = self.fig.add_subplot(self.gs[2, 0])
|
||||||
if price_mat.shape[1] > 2:
|
if price_mat.shape[1] > 2:
|
||||||
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
|
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
|
||||||
im = ax.imshow(corr, cmap='RdBu', vmin=-1, vmax=1, aspect='auto')
|
im = ax.imshow(corr, cmap="RdBu", vmin=-1, vmax=1, aspect="auto")
|
||||||
ax.set_xticks(range(n_products))
|
ax.set_xticks(range(n_products))
|
||||||
ax.set_yticks(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_xticklabels([f"Q{i}" for i in range(n_products)], fontsize=6)
|
||||||
ax.set_yticklabels([f'P{i}' for i in range(n_products)], fontsize=6)
|
ax.set_yticklabels([f"P{i}" for i in range(n_products)], fontsize=6)
|
||||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
style_axis(ax, "Price-Demand Correlation", None, None)
|
style_axis(ax, "Price-Demand Correlation", None, None)
|
||||||
|
|
||||||
@@ -105,20 +137,27 @@ class DashboardRenderer:
|
|||||||
n_steps = len(env._revenue_history)
|
n_steps = len(env._revenue_history)
|
||||||
demand_std = [np.std(d) for d in env._demand_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.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
|
||||||
ax.plot(env._revenue_history, linewidth=2, label='Revenue')
|
ax.plot(env._revenue_history, linewidth=2, label="Revenue")
|
||||||
ax.set_xlim(0, max(n_steps, 1))
|
ax.set_xlim(0, max(n_steps, 1))
|
||||||
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
|
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
|
||||||
|
|
||||||
ax2 = ax.twinx()
|
ax2 = ax.twinx()
|
||||||
ax2.plot(range(n_steps), demand_std, linewidth=2, ls='-', alpha=0.9, label='sigma(Demand)')
|
ax2.plot(
|
||||||
|
range(n_steps),
|
||||||
|
demand_std,
|
||||||
|
linewidth=2,
|
||||||
|
ls="-",
|
||||||
|
alpha=0.9,
|
||||||
|
label="sigma(Demand)",
|
||||||
|
)
|
||||||
d_min, d_max = min(demand_std), max(demand_std)
|
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
|
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_ylim(max(0, d_min - margin), d_max + margin)
|
||||||
ax2.set_ylabel('Demand sigma', fontsize=9)
|
ax2.set_ylabel("Demand sigma", fontsize=9)
|
||||||
|
|
||||||
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
|
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
|
||||||
ax.legend(loc='upper left', fontsize=7, frameon=False)
|
ax.legend(loc="upper left", fontsize=7, frameon=False)
|
||||||
ax2.legend(loc='upper right', fontsize=7, frameon=False)
|
ax2.legend(loc="upper right", fontsize=7, frameon=False)
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
if self.fig:
|
if self.fig:
|
||||||
|
|||||||
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
|
||||||
@@ -32,18 +32,23 @@ class EconomicMetricsWrapper(gym.Wrapper):
|
|||||||
obs, reward, terminated, truncated, info = self.env.step(action)
|
obs, reward, terminated, truncated, info = self.env.step(action)
|
||||||
|
|
||||||
# extract from unwrapped env
|
# extract from unwrapped env
|
||||||
prices = self.env.unwrapped._prices
|
quoted_prices = np.asarray(self.env.unwrapped._prices, dtype=float)
|
||||||
|
effective_prices = np.asarray(
|
||||||
|
info.get("effective_prices", quoted_prices), dtype=float
|
||||||
|
)
|
||||||
|
if effective_prices.shape != quoted_prices.shape:
|
||||||
|
effective_prices = quoted_prices
|
||||||
demand_dict = self.env.unwrapped._demand
|
demand_dict = self.env.unwrapped._demand
|
||||||
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))])
|
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(quoted_prices))])
|
||||||
alpha = self.env.unwrapped.alpha
|
|
||||||
|
|
||||||
# core calculations
|
# core calculations
|
||||||
revenue = float(np.sum(prices * demand))
|
revenue = float(info.get("revenue", np.sum(effective_prices * demand)))
|
||||||
avg_price = float(np.mean(prices))
|
quoted_revenue = float(np.sum(quoted_prices * demand))
|
||||||
|
avg_price = float(np.mean(effective_prices))
|
||||||
margin = (avg_price - self.p_min) / max(avg_price, 1e-6)
|
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
|
coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1
|
||||||
|
|
||||||
self._price_history.append(prices.copy())
|
self._price_history.append(effective_prices.copy())
|
||||||
self._revenue_history.append(revenue)
|
self._revenue_history.append(revenue)
|
||||||
|
|
||||||
# regret vs baseline (golden path)
|
# regret vs baseline (golden path)
|
||||||
@@ -54,14 +59,36 @@ class EconomicMetricsWrapper(gym.Wrapper):
|
|||||||
# inject structured metrics into info
|
# inject structured metrics into info
|
||||||
info["economics"] = {
|
info["economics"] = {
|
||||||
"revenue": revenue,
|
"revenue": revenue,
|
||||||
|
"quoted_revenue": quoted_revenue,
|
||||||
"margin": margin,
|
"margin": margin,
|
||||||
"coi_level": coi_level,
|
"coi_level": coi_level,
|
||||||
"regret": regret,
|
"regret": regret,
|
||||||
}
|
}
|
||||||
for key in ("coi_mix", "coi_base", "coi_leakage", "coi_penalty"):
|
for key in (
|
||||||
|
"coi_mix",
|
||||||
|
"coi_base",
|
||||||
|
"coi_leakage",
|
||||||
|
"coi_penalty",
|
||||||
|
"ux_penalty",
|
||||||
|
"volatility",
|
||||||
|
"upward_volatility",
|
||||||
|
"supra_penalty",
|
||||||
|
"supra_share",
|
||||||
|
"competitive_anchor",
|
||||||
|
"profit",
|
||||||
|
"cost_floor",
|
||||||
|
"reward_revenue",
|
||||||
|
"reward_total",
|
||||||
|
"agent_prob",
|
||||||
|
"alpha_adv",
|
||||||
|
"alpha_nominal",
|
||||||
|
"erosion_share",
|
||||||
|
"effective_price_mean",
|
||||||
|
):
|
||||||
if key in info:
|
if key in info:
|
||||||
info["economics"][key] = info[key]
|
info["economics"][key] = info[key]
|
||||||
info["prices"] = prices.copy()
|
info["prices"] = quoted_prices.copy()
|
||||||
|
info["effective_prices"] = effective_prices.copy()
|
||||||
info["demand"] = demand.copy()
|
info["demand"] = demand.copy()
|
||||||
|
|
||||||
return obs, reward, terminated, truncated, info
|
return obs, reward, terminated, truncated, info
|
||||||
|
|||||||
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)
|
||||||
71
engine/orchestrators/sweep_agent.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
update_summary,
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
run_with_active_sweep_run(
|
||||||
|
spec,
|
||||||
|
kind=kind,
|
||||||
|
scenario=scenario,
|
||||||
|
group=group,
|
||||||
|
extra_tags=extra_tags,
|
||||||
|
)
|
||||||
|
update_summary({"run/status": "finished"})
|
||||||
|
except Exception as exc:
|
||||||
|
update_summary(
|
||||||
|
{
|
||||||
|
"run/status": "crashed",
|
||||||
|
"run/error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
finish_run()
|
||||||
|
|
||||||
|
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,
|
||||||
|
"baseline" if spec.study.no_robust else "defended",
|
||||||
|
]
|
||||||
|
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
|
||||||
138
engine/project.json
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
{
|
||||||
|
"$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": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"whoclicked-publish": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"dependsOn": [
|
||||||
|
"install"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_research.sh whoclicked-publish",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tpu-ray-bootstrap": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_research.sh tpu-ray-bootstrap",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tpu-ray-deps": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_research.sh tpu-ray-deps",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tpu-ray-verify": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_research.sh tpu-ray-verify",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tpu-ray-teardown": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_research.sh tpu-ray-teardown",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"scope:research",
|
||||||
|
"type:python"
|
||||||
|
]
|
||||||
|
}
|
||||||
353
engine/spec.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
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.ambiguity_radius": "robust_radius",
|
||||||
|
"study.ambiguity_points": "robust_points",
|
||||||
|
"study.ambiguity_rollouts": "robust_rollouts",
|
||||||
|
"study.info_value": "info_value",
|
||||||
|
"study.eta_ux": "eta_ux",
|
||||||
|
"study.reward_profit_weight": "reward_profit_weight",
|
||||||
|
"ambiguity_radius": "robust_radius",
|
||||||
|
"ambiguity_points": "robust_points",
|
||||||
|
"ambiguity_rollouts": "robust_rollouts",
|
||||||
|
"baseline_mode": "no_robust",
|
||||||
|
"stress_eval_enabled": "robust_eval_enabled",
|
||||||
|
"optimizer.learning_rate": "learning_rate",
|
||||||
|
"optimizer.gamma": "gamma",
|
||||||
|
"optimizer.batch_size": "batch_size",
|
||||||
|
"optimizer.n_steps": "n_steps",
|
||||||
|
"runtime.backend": "backend",
|
||||||
|
"runtime.device": "device",
|
||||||
|
"runtime.seed": "seed",
|
||||||
|
"runtime.total_timesteps": "total_timesteps",
|
||||||
|
"runtime.checkpoint_interval": "checkpoint_interval",
|
||||||
|
"runtime.hist_freq": "hist_freq",
|
||||||
|
"eval.eval_freq": "eval_freq",
|
||||||
|
"eval.eval_episodes": "eval_episodes",
|
||||||
|
}
|
||||||
|
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
|
||||||
|
agent_mu: float = 45.0
|
||||||
|
agent_std: float = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
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
|
||||||
|
hist_freq: int = 500
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
"hist_freq": self.runtime.hist_freq,
|
||||||
|
"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,
|
||||||
|
"agent_mu": self.env.agent_mu,
|
||||||
|
"agent_std": self.env.agent_std,
|
||||||
|
"alpha": self.study.alpha,
|
||||||
|
"lambda_coi": self.study.lambda_coi,
|
||||||
|
"robust_radius": self.study.robust_radius,
|
||||||
|
"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,
|
||||||
|
"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"]),
|
||||||
|
agent_mu=float(base.get("agent_mu", 45.0)),
|
||||||
|
agent_std=float(base.get("agent_std", 15.0)),
|
||||||
|
),
|
||||||
|
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"]),
|
||||||
|
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"]),
|
||||||
|
hist_freq=int(base["hist_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:
|
||||||
|
alpha_token = f"{float(spec.study.alpha):.2f}".rstrip("0").rstrip(".")
|
||||||
|
mode = "baseline" if bool(spec.study.no_robust) else "defended"
|
||||||
|
return (
|
||||||
|
f"{kind}/{spec.algorithm.name}/{spec.runtime.backend}/"
|
||||||
|
f"{spec.runtime.device}/{scenario}/a{alpha_token}/{mode}/s{spec.runtime.seed}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_metadata(
|
||||||
|
spec: TrainSpec,
|
||||||
|
*,
|
||||||
|
kind: str,
|
||||||
|
scenario: str,
|
||||||
|
group: str | None = None,
|
||||||
|
tags: Sequence[str] = (),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
mode = "baseline" if bool(spec.study.no_robust) else "defended"
|
||||||
|
metadata: dict[str, Any] = {
|
||||||
|
"run.kind": str(kind),
|
||||||
|
"run.algo": spec.algorithm.name,
|
||||||
|
"run.backend": spec.runtime.backend,
|
||||||
|
"run.device": spec.runtime.device,
|
||||||
|
"run.scenario": str(scenario),
|
||||||
|
"run.seed": spec.runtime.seed,
|
||||||
|
"run.tags": list(tags),
|
||||||
|
"study/alpha": float(spec.study.alpha),
|
||||||
|
"study/mode": mode,
|
||||||
|
"study/baseline_mode": float(bool(spec.study.no_robust)),
|
||||||
|
"tiers": spec.algorithm.name,
|
||||||
|
}
|
||||||
|
if group:
|
||||||
|
metadata["run.group"] = group
|
||||||
|
return metadata
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
"""shared factor definitions for experimental designs"""
|
"""shared factor definitions for experimental designs"""
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Callable, Any
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Factor:
|
class Factor:
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
"""full factorial design - all factor combinations"""
|
"""full factorial design - all factor combinations"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, "..")
|
sys.path.insert(0, "..")
|
||||||
import logging
|
import logging
|
||||||
from itertools import product
|
from itertools import product
|
||||||
@@ -12,6 +14,7 @@ from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
|||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def generate_configs():
|
def generate_configs():
|
||||||
"""generate all factor combinations with seeds"""
|
"""generate all factor combinations with seeds"""
|
||||||
all_levels = [f.levels for f in FACTORS]
|
all_levels = [f.levels for f in FACTORS]
|
||||||
@@ -22,10 +25,13 @@ def generate_configs():
|
|||||||
base = {names[i]: combo[i] for i in range(len(names))}
|
base = {names[i]: combo[i] for i in range(len(names))}
|
||||||
for seed in range(SEEDS_PER_CONFIG):
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
cfg = {**base, "seed": seed}
|
cfg = {**base, "seed": seed}
|
||||||
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
cfg["id"] = hashlib.md5(
|
||||||
|
json.dumps(cfg, sort_keys=True).encode()
|
||||||
|
).hexdigest()[:8]
|
||||||
configs.append(cfg)
|
configs.append(cfg)
|
||||||
return configs
|
return configs
|
||||||
|
|
||||||
|
|
||||||
def run_single(cfg: dict) -> dict:
|
def run_single(cfg: dict) -> dict:
|
||||||
"""execute one experiment config, return metrics"""
|
"""execute one experiment config, return metrics"""
|
||||||
from engine.wrapper import PHANTOM
|
from engine.wrapper import PHANTOM
|
||||||
@@ -49,7 +55,8 @@ def run_single(cfg: dict) -> dict:
|
|||||||
obs, reward, term, trunc, _ = env.step(action)
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
total_reward += reward
|
total_reward += reward
|
||||||
steps += 1
|
steps += 1
|
||||||
if term: break
|
if term:
|
||||||
|
break
|
||||||
|
|
||||||
env.close()
|
env.close()
|
||||||
return {
|
return {
|
||||||
@@ -60,22 +67,28 @@ def run_single(cfg: dict) -> dict:
|
|||||||
"steps": steps,
|
"steps": steps,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
|
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
|
||||||
configs = generate_configs()
|
configs = generate_configs()
|
||||||
log.info(f"full factorial: {len(configs)} configs ({len(configs)//SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)")
|
log.info(
|
||||||
|
f"full factorial: {len(configs)} configs ({len(configs) // SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)"
|
||||||
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
for i, result in enumerate(ex.map(run_single, configs)):
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
results.append(result)
|
results.append(result)
|
||||||
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
if (i + 1) % 100 == 0:
|
||||||
|
log.info(f"progress: {i + 1}/{len(configs)}")
|
||||||
|
|
||||||
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
log.info(f"wrote {len(results)} results to {output}")
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--workers", type=int, default=None)
|
p.add_argument("--workers", type=int, default=None)
|
||||||
p.add_argument("--output", default="results_full.jsonl")
|
p.add_argument("--output", default="results_full.jsonl")
|
||||||
@@ -83,7 +96,9 @@ if __name__ == "__main__":
|
|||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
configs = generate_configs()
|
configs = generate_configs()
|
||||||
log.info(f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}")
|
log.info(
|
||||||
|
f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}"
|
||||||
|
)
|
||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
run_study(args.workers, args.output)
|
run_study(args.workers, args.output)
|
||||||
|
|||||||
136
engine/studies/local_comparison.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import sys
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from pathlib import Path
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from gymnasium.wrappers import FlattenObservation
|
||||||
|
from stable_baselines3 import PPO
|
||||||
|
|
||||||
|
# Add parent directory to path to allow importing engine
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
|
||||||
|
from engine.wrapper import PHANTOM
|
||||||
|
from engine.lib.wrappers import EconomicMetricsWrapper
|
||||||
|
from engine.lib.providers import (
|
||||||
|
ProviderBenchmark,
|
||||||
|
BenchmarkConfig,
|
||||||
|
RandomBaseline,
|
||||||
|
SurgeBaseline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def env_factory(alpha: float):
|
||||||
|
"""Creates a wrapped PHANTOM environment for testing at a specific alpha level."""
|
||||||
|
# Action levels=9 matches the trained PPO model
|
||||||
|
# n_products=8 matches the pretrained model's expectation of Box(16,)
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=8,
|
||||||
|
alpha=alpha,
|
||||||
|
N=100,
|
||||||
|
action_levels=9,
|
||||||
|
action_scale_low=0.8,
|
||||||
|
action_scale_high=1.2,
|
||||||
|
max_steps=20, # Short episodes so simulation goes fast
|
||||||
|
robust_points=1, # disable expensive adversarial lookaheads
|
||||||
|
render_mode=None,
|
||||||
|
)
|
||||||
|
env = EconomicMetricsWrapper(env)
|
||||||
|
return FlattenObservation(env)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("Loading pre-trained Robust RL model...")
|
||||||
|
model_path = Path(__file__).parent.parent / "models" / "phantom_ppo.zip"
|
||||||
|
if not model_path.exists():
|
||||||
|
print(f"Error: Model not found at {model_path}")
|
||||||
|
print("Please ensure you have a trained model before running this script.")
|
||||||
|
return
|
||||||
|
|
||||||
|
rl_model = PPO.load(model_path)
|
||||||
|
|
||||||
|
# The action space is Discrete(9). Index 4 is the middle (1.0 scale).
|
||||||
|
n_actions = 9
|
||||||
|
mid_action = n_actions // 2
|
||||||
|
|
||||||
|
providers = {
|
||||||
|
"Static (Base)": lambda obs: mid_action,
|
||||||
|
"Random": RandomBaseline(n_actions),
|
||||||
|
"Heuristic Surge": SurgeBaseline(
|
||||||
|
n_actions, high_threshold=60.0, low_threshold=30.0
|
||||||
|
),
|
||||||
|
"Robust RL (PPO)": lambda obs: rl_model.predict(obs, deterministic=True)[0],
|
||||||
|
}
|
||||||
|
|
||||||
|
config = BenchmarkConfig(
|
||||||
|
n_episodes=10, # Lower episodes to run faster
|
||||||
|
alpha_range=[0.0, 0.5, 1.0], # Fewer alpha levels
|
||||||
|
baseline_name="Static (Base)",
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\nStarting benchmark across alpha levels: {config.alpha_range}")
|
||||||
|
print(
|
||||||
|
f"Testing {len(providers)} strategies for {config.n_episodes} episodes each...\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
benchmark = ProviderBenchmark(env_factory, providers, config)
|
||||||
|
results = benchmark.run()
|
||||||
|
|
||||||
|
# 1. Print tabular results
|
||||||
|
df = benchmark.to_dataframe()
|
||||||
|
summary = benchmark.summary_table()
|
||||||
|
print("\n--- Benchmark Summary Table ---")
|
||||||
|
print(summary)
|
||||||
|
|
||||||
|
# 2. Save results to CSV for thesis inclusion
|
||||||
|
out_dir = Path(__file__).parent / "results"
|
||||||
|
out_dir.mkdir(exist_ok=True)
|
||||||
|
csv_path = out_dir / "provider_comparison.csv"
|
||||||
|
df.to_csv(csv_path, index=False)
|
||||||
|
print(f"\nSaved raw results to {csv_path}")
|
||||||
|
|
||||||
|
# 3. Plot the degradation of COI / Revenue as alpha increases
|
||||||
|
plt.figure(figsize=(12, 5))
|
||||||
|
|
||||||
|
# Plot 1: Revenue vs Alpha
|
||||||
|
plt.subplot(1, 2, 1)
|
||||||
|
for name in providers.keys():
|
||||||
|
provider_data = df[df["name"] == name]
|
||||||
|
plt.plot(
|
||||||
|
provider_data["alpha"],
|
||||||
|
provider_data["mean_revenue"],
|
||||||
|
marker="o",
|
||||||
|
label=name,
|
||||||
|
linewidth=2,
|
||||||
|
)
|
||||||
|
plt.title("Revenue under Agent Contamination")
|
||||||
|
plt.xlabel("Contamination Level (α)")
|
||||||
|
plt.ylabel("Mean Episode Revenue ($)")
|
||||||
|
plt.grid(True, linestyle="--", alpha=0.7)
|
||||||
|
plt.legend()
|
||||||
|
|
||||||
|
# Plot 2: COI Preservation vs Alpha
|
||||||
|
plt.subplot(1, 2, 2)
|
||||||
|
for name in providers.keys():
|
||||||
|
provider_data = df[df["name"] == name]
|
||||||
|
plt.plot(
|
||||||
|
provider_data["alpha"],
|
||||||
|
provider_data["coi_preserved_pct"],
|
||||||
|
marker="s",
|
||||||
|
label=name,
|
||||||
|
linewidth=2,
|
||||||
|
)
|
||||||
|
plt.title("Cost of Information (COI) Preservation")
|
||||||
|
plt.xlabel("Contamination Level (α)")
|
||||||
|
plt.ylabel("COI Preserved (%)")
|
||||||
|
plt.grid(True, linestyle="--", alpha=0.7)
|
||||||
|
plt.legend()
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plot_path = out_dir / "alpha_degradation_plot.png"
|
||||||
|
plt.savefig(plot_path, dpi=300)
|
||||||
|
print(f"Saved visualization to {plot_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
133
engine/studies/margin_erosion_alpha.py
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
"""validate core thesis problem: margin erosion under agent contamination
|
||||||
|
trains standard RL (no robust components) across α levels to demonstrate systematic failure
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
import json, sys, time
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||||
|
from engine.spec import TrainSpec
|
||||||
|
from engine.orchestrators import run_train_once
|
||||||
|
|
||||||
|
|
||||||
|
def _run_baseline(alpha: float, algo: str, seed: int, steps: int) -> dict:
|
||||||
|
spec = TrainSpec.from_flat(
|
||||||
|
{
|
||||||
|
"algo": algo,
|
||||||
|
"seed": seed,
|
||||||
|
"alpha": alpha,
|
||||||
|
"total_timesteps": steps,
|
||||||
|
"lambda_coi": 0.0,
|
||||||
|
"robust_radius": 0.0,
|
||||||
|
"robust_points": 1,
|
||||||
|
"robust_rollouts": 1,
|
||||||
|
"no_robust": True,
|
||||||
|
"arch": "small",
|
||||||
|
"n_products": 10,
|
||||||
|
"N": 100,
|
||||||
|
"max_steps": 50,
|
||||||
|
"eval_freq": 5000,
|
||||||
|
"eval_episodes": 10,
|
||||||
|
"log_freq": 500,
|
||||||
|
"robust_eval_enabled": False,
|
||||||
|
"agent_mu": 12.0,
|
||||||
|
"agent_std": 2.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = run_train_once(
|
||||||
|
spec,
|
||||||
|
project="phantom-margin-erosion",
|
||||||
|
offline=True,
|
||||||
|
no_wandb=True,
|
||||||
|
kind="study",
|
||||||
|
scenario=f"alpha{int(alpha * 100):02d}",
|
||||||
|
group=f"baseline_{algo}",
|
||||||
|
extra_tags=("margin_erosion", "baseline"),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"alpha": alpha,
|
||||||
|
"algo": algo,
|
||||||
|
"seed": seed,
|
||||||
|
"eval_reward": result.get("eval/reward_mean", np.nan),
|
||||||
|
"eval_revenue": result.get("eval/revenue_mean", np.nan),
|
||||||
|
"eval_coi_level": result.get("eval/coi_level_mean", np.nan),
|
||||||
|
"eval_margin": result.get("eval/margin_mean", np.nan),
|
||||||
|
"eval_agent_prob": result.get("eval/agent_prob_mean", np.nan),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_margin_erosion_study(
|
||||||
|
alphas: list[float] | None = None,
|
||||||
|
algos: list[str] | None = None,
|
||||||
|
seeds: int = 3,
|
||||||
|
steps: int = 30_000,
|
||||||
|
) -> dict:
|
||||||
|
alphas = alphas or [0.1, 0.3, 0.5, 0.7, 0.9]
|
||||||
|
algos = algos or ["ppo", "dqn", "qtable"]
|
||||||
|
output_dir = Path(__file__).parent / "results"
|
||||||
|
output_dir.mkdir(exist_ok=True)
|
||||||
|
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for α in alphas:
|
||||||
|
for algo in algos:
|
||||||
|
for si in range(seeds):
|
||||||
|
seed = 42 + si
|
||||||
|
print(f"α={α:.1f} {algo} seed={seed}")
|
||||||
|
m = _run_baseline(α, algo, seed, steps)
|
||||||
|
results.append(m)
|
||||||
|
print(
|
||||||
|
f" margin={m['eval_margin']:.3f} rev={m['eval_revenue']:.0f} coi={m['eval_coi_level']:.1f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = {}
|
||||||
|
for α in alphas:
|
||||||
|
runs = [r for r in results if abs(r["alpha"] - α) < 0.01]
|
||||||
|
if not runs:
|
||||||
|
continue
|
||||||
|
s = {}
|
||||||
|
for metric in ["margin", "revenue", "coi_level", "agent_prob"]:
|
||||||
|
vals = [r[f"eval_{metric}"] for r in runs]
|
||||||
|
s[f"{metric}_mean"] = float(np.mean(vals))
|
||||||
|
s[f"{metric}_std"] = float(np.std(vals))
|
||||||
|
s["n_runs"] = len(runs)
|
||||||
|
summary[f"alpha_{α:.1f}"] = s
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"timestamp": ts,
|
||||||
|
"config": {"alphas": alphas, "algos": algos, "seeds": seeds, "steps": steps},
|
||||||
|
"results": results,
|
||||||
|
"summary": summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
path = output_dir / f"margin_erosion_alpha_{ts}.json"
|
||||||
|
with open(path, "w") as f:
|
||||||
|
json.dump(output, f, indent=2)
|
||||||
|
|
||||||
|
print(f"\n→ {path}")
|
||||||
|
for α in alphas:
|
||||||
|
k = f"alpha_{α:.1f}"
|
||||||
|
if k in summary:
|
||||||
|
s = summary[k]
|
||||||
|
print(
|
||||||
|
f" {k}: margin={s['margin_mean']:.3f}±{s['margin_std']:.3f} "
|
||||||
|
f"coi={s['coi_level_mean']:.1f}±{s['coi_level_std']:.1f}"
|
||||||
|
)
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
p = argparse.ArgumentParser(description="margin erosion vs α")
|
||||||
|
p.add_argument("--quick", action="store_true", help="fast test")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
run_margin_erosion_study(
|
||||||
|
alphas=[0.1, 0.7] if args.quick else [0.1, 0.3, 0.5, 0.7, 0.9],
|
||||||
|
algos=["qtable"] if args.quick else ["ppo", "dqn", "qtable"],
|
||||||
|
seeds=1 if args.quick else 3,
|
||||||
|
steps=5_000 if args.quick else 30_000,
|
||||||
|
)
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
|
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
sys.path.insert(0, "..")
|
sys.path.insert(0, "..")
|
||||||
import logging
|
import logging
|
||||||
from itertools import product
|
from itertools import product
|
||||||
@@ -16,6 +18,7 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
LH_SAMPLES = 10
|
LH_SAMPLES = 10
|
||||||
|
|
||||||
|
|
||||||
def generate_configs(lh_samples: int = LH_SAMPLES):
|
def generate_configs(lh_samples: int = LH_SAMPLES):
|
||||||
primary = [f for f in FACTORS if f.primary]
|
primary = [f for f in FACTORS if f.primary]
|
||||||
secondary = [f for f in FACTORS if not f.primary]
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
@@ -28,7 +31,9 @@ def generate_configs(lh_samples: int = LH_SAMPLES):
|
|||||||
samples = lhs.random(n=lh_samples)
|
samples = lhs.random(n=lh_samples)
|
||||||
for s in samples:
|
for s in samples:
|
||||||
sec_vals = {
|
sec_vals = {
|
||||||
secondary[i].name: secondary[i].levels[int(s[i] * len(secondary[i].levels))]
|
secondary[i].name: secondary[i].levels[
|
||||||
|
int(s[i] * len(secondary[i].levels))
|
||||||
|
]
|
||||||
for i in range(len(secondary))
|
for i in range(len(secondary))
|
||||||
}
|
}
|
||||||
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
|
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
|
||||||
@@ -36,10 +41,13 @@ def generate_configs(lh_samples: int = LH_SAMPLES):
|
|||||||
|
|
||||||
for seed in range(SEEDS_PER_CONFIG):
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
cfg = {**base, "seed": seed}
|
cfg = {**base, "seed": seed}
|
||||||
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
cfg["id"] = hashlib.md5(
|
||||||
|
json.dumps(cfg, sort_keys=True).encode()
|
||||||
|
).hexdigest()[:8]
|
||||||
configs.append(cfg)
|
configs.append(cfg)
|
||||||
return configs
|
return configs
|
||||||
|
|
||||||
|
|
||||||
def run_single(cfg: dict) -> dict:
|
def run_single(cfg: dict) -> dict:
|
||||||
from engine.wrapper import PHANTOM
|
from engine.wrapper import PHANTOM
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -62,7 +70,8 @@ def run_single(cfg: dict) -> dict:
|
|||||||
obs, reward, term, trunc, _ = env.step(action)
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
total_reward += reward
|
total_reward += reward
|
||||||
steps += 1
|
steps += 1
|
||||||
if term: break
|
if term:
|
||||||
|
break
|
||||||
|
|
||||||
env.close()
|
env.close()
|
||||||
return {
|
return {
|
||||||
@@ -73,23 +82,33 @@ def run_single(cfg: dict) -> dict:
|
|||||||
"steps": steps,
|
"steps": steps,
|
||||||
}
|
}
|
||||||
|
|
||||||
def run_study(max_workers: int = None, output: str = "results_mixed.jsonl", lh_samples: int = LH_SAMPLES):
|
|
||||||
|
def run_study(
|
||||||
|
max_workers: int = None,
|
||||||
|
output: str = "results_mixed.jsonl",
|
||||||
|
lh_samples: int = LH_SAMPLES,
|
||||||
|
):
|
||||||
configs = generate_configs(lh_samples)
|
configs = generate_configs(lh_samples)
|
||||||
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
|
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
|
||||||
log.info(f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)")
|
log.info(
|
||||||
|
f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)"
|
||||||
|
)
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
for i, result in enumerate(ex.map(run_single, configs)):
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
results.append(result)
|
results.append(result)
|
||||||
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
if (i + 1) % 100 == 0:
|
||||||
|
log.info(f"progress: {i + 1}/{len(configs)}")
|
||||||
|
|
||||||
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
log.info(f"wrote {len(results)} results to {output}")
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
||||||
p = argparse.ArgumentParser()
|
p = argparse.ArgumentParser()
|
||||||
p.add_argument("--workers", type=int, default=None)
|
p.add_argument("--workers", type=int, default=None)
|
||||||
p.add_argument("--output", default="results_mixed.jsonl")
|
p.add_argument("--output", default="results_mixed.jsonl")
|
||||||
@@ -100,7 +119,9 @@ if __name__ == "__main__":
|
|||||||
primary = [f for f in FACTORS if f.primary]
|
primary = [f for f in FACTORS if f.primary]
|
||||||
secondary = [f for f in FACTORS if not f.primary]
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
configs = generate_configs(args.lh_samples)
|
configs = generate_configs(args.lh_samples)
|
||||||
log.info(f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}")
|
log.info(
|
||||||
|
f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}"
|
||||||
|
)
|
||||||
|
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
run_study(args.workers, args.output, args.lh_samples)
|
run_study(args.workers, args.output, args.lh_samples)
|
||||||
|
|||||||
60
engine/sweeps/final_thesis_proof.yaml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
method: grid
|
||||||
|
metric:
|
||||||
|
name: eval/stress_reward_worst
|
||||||
|
goal: maximize
|
||||||
|
command:
|
||||||
|
- ${env}
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- engine.train
|
||||||
|
parameters:
|
||||||
|
algo:
|
||||||
|
value: ppo
|
||||||
|
backend:
|
||||||
|
value: sb3
|
||||||
|
device:
|
||||||
|
value: cpu
|
||||||
|
seed:
|
||||||
|
values: [42, 1337, 7777]
|
||||||
|
alpha:
|
||||||
|
values: [0.1, 0.2, 0.3, 0.4, 0.6, 0.8]
|
||||||
|
n_products:
|
||||||
|
values: [25, 50, 100]
|
||||||
|
N:
|
||||||
|
value: 100
|
||||||
|
no_robust:
|
||||||
|
values: [false, true]
|
||||||
|
lambda_coi:
|
||||||
|
values: [0.15, 0.30]
|
||||||
|
robust_radius:
|
||||||
|
value: 0.2
|
||||||
|
robust_points:
|
||||||
|
value: 7
|
||||||
|
robust_rollouts:
|
||||||
|
value: 1
|
||||||
|
eta_ux:
|
||||||
|
value: 0.5
|
||||||
|
reward_profit_weight:
|
||||||
|
value: 1.0
|
||||||
|
action_levels:
|
||||||
|
value: 9
|
||||||
|
action_scale_low:
|
||||||
|
value: 0.8
|
||||||
|
action_scale_high:
|
||||||
|
value: 1.2
|
||||||
|
total_timesteps:
|
||||||
|
value: 100000
|
||||||
|
eval_episodes:
|
||||||
|
value: 12
|
||||||
|
eval_freq:
|
||||||
|
value: 1000
|
||||||
|
log_freq:
|
||||||
|
value: 100
|
||||||
|
hist_freq:
|
||||||
|
value: 500
|
||||||
|
learning_rate:
|
||||||
|
value: 0.0003
|
||||||
|
batch_size:
|
||||||
|
value: 256
|
||||||
|
n_steps:
|
||||||
|
value: 2048
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
method: random
|
method: random
|
||||||
metric:
|
metric:
|
||||||
name: sweep/score
|
name: objective/score
|
||||||
goal: maximize
|
goal: maximize
|
||||||
command:
|
command:
|
||||||
- ${env}
|
- ${env}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
method: grid
|
method: grid
|
||||||
metric:
|
metric:
|
||||||
name: sweep/score
|
name: objective/score
|
||||||
goal: maximize
|
goal: maximize
|
||||||
run_cap: 4
|
run_cap: 4
|
||||||
command:
|
command:
|
||||||
|
|||||||
53
engine/sweeps/ppo_supra_guard.yaml
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
method: random
|
||||||
|
metric:
|
||||||
|
name: eval/supra_share_mean
|
||||||
|
goal: minimize
|
||||||
|
run_cap: 256
|
||||||
|
command:
|
||||||
|
- ${env}
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- engine.train
|
||||||
|
parameters:
|
||||||
|
algo:
|
||||||
|
value: ppo
|
||||||
|
seed:
|
||||||
|
values: [42, 1337, 7777]
|
||||||
|
alpha:
|
||||||
|
values: [0.1, 0.2, 0.3, 0.4, 0.6]
|
||||||
|
n_products:
|
||||||
|
values: [25, 50]
|
||||||
|
N:
|
||||||
|
value: 100
|
||||||
|
no_robust:
|
||||||
|
values: [false, true]
|
||||||
|
lambda_coi:
|
||||||
|
values: [0.05, 0.15, 0.3]
|
||||||
|
robust_radius:
|
||||||
|
values: [0.1, 0.2, 0.3]
|
||||||
|
robust_points:
|
||||||
|
value: 7
|
||||||
|
robust_rollouts:
|
||||||
|
value: 1
|
||||||
|
eta_ux:
|
||||||
|
values: [0.05, 0.15, 0.3, 0.5, 0.75]
|
||||||
|
reward_profit_weight:
|
||||||
|
value: 1.0
|
||||||
|
total_timesteps:
|
||||||
|
value: 100000
|
||||||
|
eval_episodes:
|
||||||
|
value: 10
|
||||||
|
eval_freq:
|
||||||
|
value: 1000
|
||||||
|
log_freq:
|
||||||
|
value: 100
|
||||||
|
hist_freq:
|
||||||
|
value: 500
|
||||||
|
learning_rate:
|
||||||
|
value: 0.0003
|
||||||
|
batch_size:
|
||||||
|
value: 256
|
||||||
|
n_steps:
|
||||||
|
value: 2048
|
||||||
|
device:
|
||||||
|
value: cpu
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
method: bayes
|
method: bayes
|
||||||
metric:
|
metric:
|
||||||
name: sweep/score
|
name: objective/score
|
||||||
goal: maximize
|
goal: maximize
|
||||||
command:
|
command:
|
||||||
- ${env}
|
- ${env}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
method: random
|
method: random
|
||||||
metric:
|
metric:
|
||||||
name: sweep/score
|
name: objective/score
|
||||||
goal: maximize
|
goal: maximize
|
||||||
command:
|
command:
|
||||||
- ${env}
|
- ${env}
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
method: bayes
|
|
||||||
metric:
|
|
||||||
name: sweep/score
|
|
||||||
goal: maximize
|
|
||||||
command:
|
|
||||||
- ${env}
|
|
||||||
- python
|
|
||||||
- -m
|
|
||||||
- engine.train
|
|
||||||
parameters:
|
|
||||||
# fixed: always use JAX backend so TPU chips are actually exercised
|
|
||||||
use_jax:
|
|
||||||
value: true
|
|
||||||
# all four algos have JAX implementations
|
|
||||||
algo:
|
|
||||||
values: [ppo, a2c, dqn, qtable]
|
|
||||||
total_timesteps:
|
|
||||||
values: [50000, 80000, 120000]
|
|
||||||
checkpoint_interval:
|
|
||||||
value: 200000
|
|
||||||
seed:
|
|
||||||
values: [13, 42, 77]
|
|
||||||
n_products:
|
|
||||||
values: [8, 10, 12]
|
|
||||||
# COI framework parameters -- primary research variables
|
|
||||||
alpha:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.1
|
|
||||||
max: 0.6
|
|
||||||
lambda_coi:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.05
|
|
||||||
max: 0.6
|
|
||||||
robust_radius:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.0
|
|
||||||
max: 0.3
|
|
||||||
robust_points:
|
|
||||||
values: [3, 5, 7]
|
|
||||||
info_value:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.5
|
|
||||||
max: 2.0
|
|
||||||
revenue_weight:
|
|
||||||
values: [0.005, 0.01, 0.02]
|
|
||||||
# shared hyperparameters
|
|
||||||
learning_rate:
|
|
||||||
distribution: log_uniform_values
|
|
||||||
min: 1.0e-5
|
|
||||||
max: 1.0e-3
|
|
||||||
gamma:
|
|
||||||
values: [0.97, 0.99, 0.995]
|
|
||||||
# JAX parallelism -- key lever for TPU throughput
|
|
||||||
jax_num_envs:
|
|
||||||
values: [8, 16, 32]
|
|
||||||
jax_num_steps:
|
|
||||||
values: [64, 128, 256]
|
|
||||||
jax_num_minibatches:
|
|
||||||
values: [2, 4, 8]
|
|
||||||
jax_update_epochs:
|
|
||||||
values: [2, 4, 8]
|
|
||||||
# PPO/A2C specific
|
|
||||||
gae_lambda:
|
|
||||||
values: [0.9, 0.95, 0.98]
|
|
||||||
clip_range:
|
|
||||||
values: [0.1, 0.2, 0.3]
|
|
||||||
ent_coef:
|
|
||||||
values: [0.0, 0.005, 0.01]
|
|
||||||
# DQN specific
|
|
||||||
buffer_size:
|
|
||||||
values: [20000, 50000, 100000]
|
|
||||||
batch_size:
|
|
||||||
values: [128, 256, 512]
|
|
||||||
learning_starts:
|
|
||||||
values: [500, 1000, 3000]
|
|
||||||
exploration_fraction:
|
|
||||||
values: [0.1, 0.2, 0.3]
|
|
||||||
exploration_final_eps:
|
|
||||||
values: [0.01, 0.03, 0.05]
|
|
||||||
# QTable specific
|
|
||||||
q_lr:
|
|
||||||
values: [0.03, 0.05, 0.1, 0.2]
|
|
||||||
eps_end:
|
|
||||||
values: [0.02, 0.05, 0.1]
|
|
||||||
eps_decay:
|
|
||||||
values: [0.999, 0.9995, 0.9999]
|
|
||||||
# action space
|
|
||||||
action_levels:
|
|
||||||
values: [7, 9, 11]
|
|
||||||
action_scale_low:
|
|
||||||
values: [0.75, 0.8, 0.85]
|
|
||||||
action_scale_high:
|
|
||||||
values: [1.15, 1.2, 1.25]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
method: bayes
|
|
||||||
metric:
|
|
||||||
name: sweep/score
|
|
||||||
goal: maximize
|
|
||||||
command:
|
|
||||||
- ${env}
|
|
||||||
- python
|
|
||||||
- -m
|
|
||||||
- engine.train
|
|
||||||
parameters:
|
|
||||||
use_jax:
|
|
||||||
value: true
|
|
||||||
# pmap requires all workers to compile the same computation graph shape,
|
|
||||||
# so structural params are fixed -- only research/scalar params are swept
|
|
||||||
algo:
|
|
||||||
values: [ppo, a2c]
|
|
||||||
jax_num_envs:
|
|
||||||
value: 32
|
|
||||||
jax_num_steps:
|
|
||||||
value: 128
|
|
||||||
jax_num_minibatches:
|
|
||||||
value: 4
|
|
||||||
jax_update_epochs:
|
|
||||||
value: 4
|
|
||||||
total_timesteps:
|
|
||||||
value: 100000
|
|
||||||
checkpoint_interval:
|
|
||||||
value: 200000
|
|
||||||
n_products:
|
|
||||||
value: 10
|
|
||||||
action_levels:
|
|
||||||
value: 9
|
|
||||||
# research parameters -- primary sweep targets
|
|
||||||
alpha:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.1
|
|
||||||
max: 0.6
|
|
||||||
lambda_coi:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.05
|
|
||||||
max: 0.6
|
|
||||||
robust_radius:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.0
|
|
||||||
max: 0.3
|
|
||||||
info_value:
|
|
||||||
distribution: uniform
|
|
||||||
min: 0.5
|
|
||||||
max: 2.0
|
|
||||||
revenue_weight:
|
|
||||||
values: [0.005, 0.01, 0.02]
|
|
||||||
# training hyperparameters
|
|
||||||
learning_rate:
|
|
||||||
distribution: log_uniform_values
|
|
||||||
min: 1.0e-5
|
|
||||||
max: 1.0e-3
|
|
||||||
gamma:
|
|
||||||
values: [0.97, 0.99, 0.995]
|
|
||||||
gae_lambda:
|
|
||||||
values: [0.9, 0.95, 0.98]
|
|
||||||
clip_range:
|
|
||||||
values: [0.1, 0.2, 0.3]
|
|
||||||
ent_coef:
|
|
||||||
values: [0.0, 0.005, 0.01]
|
|
||||||
23
engine/telemetry/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from .metrics import canonicalize_metrics
|
||||||
|
from .wandb import (
|
||||||
|
current_config,
|
||||||
|
finish_run,
|
||||||
|
get_wandb_module,
|
||||||
|
init_run,
|
||||||
|
log_metrics,
|
||||||
|
run_agent,
|
||||||
|
update_run_config,
|
||||||
|
update_summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"canonicalize_metrics",
|
||||||
|
"current_config",
|
||||||
|
"finish_run",
|
||||||
|
"get_wandb_module",
|
||||||
|
"init_run",
|
||||||
|
"log_metrics",
|
||||||
|
"run_agent",
|
||||||
|
"update_run_config",
|
||||||
|
"update_summary",
|
||||||
|
]
|
||||||
70
engine/telemetry/metrics.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ..spec import TrainSpec
|
||||||
|
|
||||||
|
|
||||||
|
_ALIASES = {
|
||||||
|
"train/reward": "train/reward_mean",
|
||||||
|
"train/revenue": "train/revenue_mean",
|
||||||
|
"train/dqn_loss": "train/loss",
|
||||||
|
"eval/reward": "eval/reward_mean",
|
||||||
|
"eval/revenue": "eval/revenue_mean",
|
||||||
|
"train/steps_per_second": "runtime/steps_per_second",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_float(value: Any, default: float | None = None) -> float | None:
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_metrics(raw: Mapping[str, Any], spec: TrainSpec) -> dict[str, Any]:
|
||||||
|
metrics: dict[str, Any] = {}
|
||||||
|
for key, value in raw.items():
|
||||||
|
canonical = _ALIASES.get(str(key), str(key))
|
||||||
|
if canonical in metrics and canonical != key:
|
||||||
|
continue
|
||||||
|
metrics[canonical] = value
|
||||||
|
|
||||||
|
metrics.setdefault("train/global_step", spec.runtime.total_timesteps)
|
||||||
|
|
||||||
|
eval_reward = (
|
||||||
|
_as_float(
|
||||||
|
metrics.get(
|
||||||
|
"eval/stress_reward_worst",
|
||||||
|
metrics.get(
|
||||||
|
"eval/robust_reward_worst", metrics.get("eval/reward_mean")
|
||||||
|
),
|
||||||
|
),
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
or 0.0
|
||||||
|
)
|
||||||
|
metrics["objective/score"] = eval_reward
|
||||||
|
|
||||||
|
margin_mean = _as_float(metrics.get("eval/margin_mean"), None)
|
||||||
|
if margin_mean is not None:
|
||||||
|
metrics["objective/constraint_margin"] = margin_mean - spec.env.margin_floor
|
||||||
|
|
||||||
|
coi_level = _as_float(metrics.get("eval/coi_level_mean"), None)
|
||||||
|
metrics["objective/coi_preserved"] = 0.0 if coi_level is None else coi_level
|
||||||
|
|
||||||
|
metrics["study/alpha"] = spec.study.alpha
|
||||||
|
metrics["study/mode"] = "baseline" if bool(spec.study.no_robust) else "defended"
|
||||||
|
metrics["study/baseline_mode"] = float(bool(spec.study.no_robust))
|
||||||
|
metrics["study/lambda_coi"] = spec.study.lambda_coi
|
||||||
|
metrics["study/ambiguity_radius"] = spec.study.robust_radius
|
||||||
|
metrics["study/info_value"] = spec.study.info_value
|
||||||
|
metrics["tiers"] = spec.algorithm.name
|
||||||
|
|
||||||
|
metrics["runtime/backend"] = spec.runtime.backend
|
||||||
|
metrics["runtime/device"] = spec.runtime.device
|
||||||
|
metrics["runtime/seed"] = spec.runtime.seed
|
||||||
|
|
||||||
|
return metrics
|
||||||
202
engine/telemetry/wandb.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Iterable, Mapping
|
||||||
|
|
||||||
|
|
||||||
|
def get_wandb_module():
|
||||||
|
try:
|
||||||
|
import wandb
|
||||||
|
|
||||||
|
return wandb
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _require_wandb():
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is None:
|
||||||
|
raise ImportError("wandb is required for this workflow")
|
||||||
|
return wandb
|
||||||
|
|
||||||
|
|
||||||
|
def _warn(message: str) -> None:
|
||||||
|
print(f"PHANTOM_WANDB_WARNING: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_key(raw_key: str) -> str | None:
|
||||||
|
key = str(raw_key)
|
||||||
|
replacements = {
|
||||||
|
"no_robust": "baseline_mode",
|
||||||
|
"study/no_robust": "study/baseline_mode",
|
||||||
|
"study/robust_radius": "study/ambiguity_radius",
|
||||||
|
"robust_radius": "ambiguity_radius",
|
||||||
|
"robust_points": "ambiguity_points",
|
||||||
|
"robust_rollouts": "ambiguity_rollouts",
|
||||||
|
"robust_eval_enabled": "stress_eval_enabled",
|
||||||
|
"eval/robust_alpha_high": "eval/stress_alpha_high",
|
||||||
|
"eval/robust_alpha_low": "eval/stress_alpha_low",
|
||||||
|
"eval/robust_reward_worst": "eval/stress_reward_worst",
|
||||||
|
"eval/robust_revenue_worst": "eval/stress_revenue_worst",
|
||||||
|
"eval/robust_coi_leakage_worst": "eval/stress_coi_leakage_worst",
|
||||||
|
}
|
||||||
|
key = replacements.get(key, key)
|
||||||
|
if "robust" in key.lower():
|
||||||
|
return None
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_payload(payload: Mapping[str, Any]) -> dict[str, Any]:
|
||||||
|
sanitized: dict[str, Any] = {}
|
||||||
|
for key, value in payload.items():
|
||||||
|
clean_key = _sanitize_key(str(key))
|
||||||
|
if clean_key is None:
|
||||||
|
continue
|
||||||
|
sanitized[clean_key] = value
|
||||||
|
return sanitized
|
||||||
|
|
||||||
|
|
||||||
|
def init_run(
|
||||||
|
*,
|
||||||
|
mode: str,
|
||||||
|
project: str | None = None,
|
||||||
|
config: Mapping[str, Any] | None = None,
|
||||||
|
name: str | None = None,
|
||||||
|
tags: Iterable[str] | None = None,
|
||||||
|
group: str | None = None,
|
||||||
|
sweep_mode: bool = False,
|
||||||
|
):
|
||||||
|
wandb = _require_wandb()
|
||||||
|
kwargs: dict[str, Any] = {"mode": mode}
|
||||||
|
if group:
|
||||||
|
kwargs["group"] = group
|
||||||
|
if sweep_mode:
|
||||||
|
try:
|
||||||
|
run = wandb.init(**kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"init failed in sweep mode ({exc})")
|
||||||
|
return None
|
||||||
|
if name and run is not None:
|
||||||
|
run.name = name
|
||||||
|
return run
|
||||||
|
|
||||||
|
init_kwargs = dict(kwargs)
|
||||||
|
init_kwargs["project"] = project
|
||||||
|
if config is not None:
|
||||||
|
init_kwargs["config"] = _sanitize_payload(dict(config))
|
||||||
|
if name:
|
||||||
|
init_kwargs["name"] = name
|
||||||
|
if tags:
|
||||||
|
init_kwargs["tags"] = list(tags)
|
||||||
|
try:
|
||||||
|
return wandb.init(**init_kwargs)
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"init failed ({exc})")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def finish_run() -> None:
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is not None and wandb.run is not None:
|
||||||
|
try:
|
||||||
|
wandb.finish()
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"finish failed ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
def current_config() -> dict[str, Any]:
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is None or wandb.run is None:
|
||||||
|
return {}
|
||||||
|
return {key: wandb.config[key] for key in wandb.config.keys()}
|
||||||
|
|
||||||
|
|
||||||
|
def update_run_config(config: Mapping[str, Any]) -> None:
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is None or wandb.run is None:
|
||||||
|
return
|
||||||
|
payload = _sanitize_payload(dict(config))
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wandb.config.update(payload, allow_val_change=True)
|
||||||
|
except TypeError:
|
||||||
|
try:
|
||||||
|
wandb.config.update(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"config update failed ({exc})")
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"config update failed ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
def log_metrics(metrics: Mapping[str, Any], *, step: int) -> None:
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is None or wandb.run is None:
|
||||||
|
return
|
||||||
|
payload = _sanitize_payload(dict(metrics))
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wandb.log(payload, step=step)
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"log failed at step {step} ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
def update_summary(metrics: Mapping[str, Any]) -> None:
|
||||||
|
wandb = get_wandb_module()
|
||||||
|
if wandb is None or wandb.run is None:
|
||||||
|
return
|
||||||
|
payload = _sanitize_payload(dict(metrics))
|
||||||
|
if not payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for key, value in payload.items():
|
||||||
|
wandb.run.summary[key] = value
|
||||||
|
except Exception as exc:
|
||||||
|
_warn(f"summary update failed ({exc})")
|
||||||
|
|
||||||
|
|
||||||
|
def run_agent(
|
||||||
|
sweep_id: str,
|
||||||
|
fn: Callable[[], None],
|
||||||
|
*,
|
||||||
|
count: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
wandb = _require_wandb()
|
||||||
|
retry_max = max(0, int(os.getenv("PHANTOM_WANDB_AGENT_RETRIES", "8")))
|
||||||
|
retry_delay = max(1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_DELAY", "5")))
|
||||||
|
retry_backoff = max(
|
||||||
|
1.0, float(os.getenv("PHANTOM_WANDB_AGENT_RETRY_BACKOFF", "1.5"))
|
||||||
|
)
|
||||||
|
retry_max_delay = max(
|
||||||
|
retry_delay,
|
||||||
|
float(os.getenv("PHANTOM_WANDB_AGENT_MAX_RETRY_DELAY", "60")),
|
||||||
|
)
|
||||||
|
|
||||||
|
target = None if count is None else max(0, int(count))
|
||||||
|
completed = 0
|
||||||
|
|
||||||
|
def _wrapped() -> None:
|
||||||
|
nonlocal completed
|
||||||
|
fn()
|
||||||
|
completed += 1
|
||||||
|
|
||||||
|
attempt = 0
|
||||||
|
while True:
|
||||||
|
remaining = None if target is None else max(0, int(target - completed))
|
||||||
|
if target is not None and remaining == 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
wandb.agent(sweep_id, function=_wrapped, count=remaining)
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
attempt += 1
|
||||||
|
if attempt > retry_max:
|
||||||
|
raise
|
||||||
|
wait = min(retry_max_delay, retry_delay * (retry_backoff ** (attempt - 1)))
|
||||||
|
_warn(
|
||||||
|
f"agent disconnected (attempt {attempt}/{retry_max}, "
|
||||||
|
f"completed={completed}, remaining={remaining}): {exc}"
|
||||||
|
)
|
||||||
|
time.sleep(wait)
|
||||||
727
engine/train.py
@@ -1,512 +1,134 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
from typing import Any
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
from .wandb_checkpoint import checkpoint_artifact_name, download_latest_checkpoint
|
from .logging_utils import configure_logging
|
||||||
|
from .orchestrators import run_benchmark_cli, run_sweep_agent, run_train_once
|
||||||
try:
|
from .spec import TrainSpec
|
||||||
import wandb as _wandb
|
|
||||||
|
|
||||||
if hasattr(_wandb, "init") and callable(_wandb.init):
|
|
||||||
wandb = _wandb
|
|
||||||
HAS_WANDB = True
|
|
||||||
else:
|
|
||||||
wandb = None
|
|
||||||
HAS_WANDB = False
|
|
||||||
except ImportError:
|
|
||||||
wandb = None
|
|
||||||
HAS_WANDB = False
|
|
||||||
|
|
||||||
try:
|
|
||||||
from stable_baselines3 import PPO, A2C, DQN
|
|
||||||
from stable_baselines3.common.callbacks import EvalCallback
|
|
||||||
from stable_baselines3.common.monitor import Monitor
|
|
||||||
|
|
||||||
HAS_SB3 = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_SB3 = False
|
|
||||||
|
|
||||||
from .jax import JAX_AVAILABLE
|
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_CFG = {
|
def _parse_tags(raw: str | None) -> list[str]:
|
||||||
"project": "phantom-pricing",
|
if raw is None:
|
||||||
"algo": "ppo",
|
return []
|
||||||
"seed": 42,
|
return [piece.strip() for piece in str(raw).split(",") if piece.strip()]
|
||||||
"total_timesteps": 50_000,
|
|
||||||
"eval_episodes": 5,
|
|
||||||
"eval_freq": 1_000,
|
|
||||||
"log_freq": 100,
|
|
||||||
"revenue_weight": 0.01,
|
|
||||||
"n_products": 10,
|
|
||||||
"N": 100,
|
|
||||||
"alpha": 0.3,
|
|
||||||
"lambda_coi": 0.2,
|
|
||||||
"robust_radius": 0.15,
|
|
||||||
"robust_points": 5,
|
|
||||||
"info_value": 1.0,
|
|
||||||
"price_low": 10.0,
|
|
||||||
"price_high": 150.0,
|
|
||||||
"action_levels": 9,
|
|
||||||
"action_scale_low": 0.8,
|
|
||||||
"action_scale_high": 1.2,
|
|
||||||
"learning_rate": 3e-4,
|
|
||||||
"gamma": 0.99,
|
|
||||||
"buffer_size": 50_000,
|
|
||||||
"batch_size": 256,
|
|
||||||
"tau": 0.005,
|
|
||||||
"train_freq": 1,
|
|
||||||
"learning_starts": 1_000,
|
|
||||||
"target_update_interval": 1_000,
|
|
||||||
"exploration_fraction": 0.2,
|
|
||||||
"exploration_final_eps": 0.05,
|
|
||||||
"n_steps": 2_048,
|
|
||||||
"n_epochs": 10,
|
|
||||||
"gae_lambda": 0.95,
|
|
||||||
"clip_range": 0.2,
|
|
||||||
"ent_coef": 0.0,
|
|
||||||
"q_lr": 0.1,
|
|
||||||
"eps_start": 1.0,
|
|
||||||
"eps_end": 0.05,
|
|
||||||
"eps_decay": 0.9995,
|
|
||||||
"model_dir": "engine/models",
|
|
||||||
"arch": "small",
|
|
||||||
"activation": "relu",
|
|
||||||
"q_bins": 6,
|
|
||||||
"max_steps": 100,
|
|
||||||
"margin_floor": 0.05,
|
|
||||||
"margin_floor_patience": 5,
|
|
||||||
"use_jax": False,
|
|
||||||
"jax_num_envs": 16,
|
|
||||||
"jax_num_steps": 128,
|
|
||||||
"jax_num_minibatches": 4,
|
|
||||||
"jax_update_epochs": 4,
|
|
||||||
"jax_anneal_lr": True,
|
|
||||||
"checkpoint_interval": 200_000,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _truthy(value: str | bool | None) -> bool:
|
def _probe_run_kind(argv: list[str]) -> str:
|
||||||
if isinstance(value, bool): return value
|
probe = argparse.ArgumentParser(add_help=False)
|
||||||
if value is None: return False
|
probe.add_argument("--run-kind", choices=["train", "benchmark"])
|
||||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
probe.add_argument("--run-mode", choices=["train", "benchmark"])
|
||||||
|
args, _ = probe.parse_known_args(argv)
|
||||||
|
return str(args.run_kind or args.run_mode or "train")
|
||||||
|
|
||||||
|
|
||||||
def _cfg(raw: dict | None = None) -> dict:
|
def _strip_run_kind(argv: list[str]) -> list[str]:
|
||||||
cfg = dict(DEFAULT_CFG)
|
stripped: list[str] = []
|
||||||
if raw:
|
skip_next = False
|
||||||
cfg.update({k: v for k, v in raw.items() if v is not None})
|
for item in argv:
|
||||||
cfg["algo"] = str(cfg["algo"]).lower()
|
if skip_next:
|
||||||
cfg["use_jax"] = _truthy(cfg.get("use_jax")) or _truthy(
|
skip_next = False
|
||||||
os.environ.get("PHANTOM_USE_JAX")
|
continue
|
||||||
)
|
if item in {"--run-kind", "--run-mode"}:
|
||||||
return cfg
|
skip_next = True
|
||||||
|
continue
|
||||||
|
if item.startswith("--run-kind=") or item.startswith("--run-mode="):
|
||||||
|
continue
|
||||||
|
stripped.append(item)
|
||||||
|
return stripped
|
||||||
|
|
||||||
|
|
||||||
def _wandb_cfg_dict() -> dict:
|
def _build_parser() -> argparse.ArgumentParser:
|
||||||
return (
|
parser = argparse.ArgumentParser(description="PHANTOM unified training entrypoint")
|
||||||
{k: wandb.config[k] for k in wandb.config.keys()}
|
parser.add_argument("--run-kind", choices=["train", "benchmark"], default="train")
|
||||||
if HAS_WANDB and wandb.run
|
parser.add_argument("--run-mode", choices=["train", "benchmark"])
|
||||||
else {}
|
|
||||||
)
|
parser.add_argument("--project", default="capstone")
|
||||||
|
parser.add_argument("--scenario", default="default")
|
||||||
|
parser.add_argument("--group", type=str)
|
||||||
|
parser.add_argument("--tags", type=str)
|
||||||
|
|
||||||
|
parser.add_argument("--backend", choices=["auto", "sb3"], default="auto")
|
||||||
|
parser.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable", "sac"])
|
||||||
|
parser.add_argument("--seed", type=int)
|
||||||
|
parser.add_argument("--total-timesteps", type=int)
|
||||||
|
parser.add_argument("--model-dir", type=str)
|
||||||
|
parser.add_argument("--log-freq", type=int)
|
||||||
|
parser.add_argument("--hist-freq", type=int)
|
||||||
|
parser.add_argument("--checkpoint-interval", type=int)
|
||||||
|
parser.add_argument("--device", type=str)
|
||||||
|
|
||||||
|
parser.add_argument("--alpha", type=float)
|
||||||
|
parser.add_argument("--N", type=int)
|
||||||
|
parser.add_argument("--n-products", type=int)
|
||||||
|
parser.add_argument("--lambda-coi", type=float)
|
||||||
|
parser.add_argument("--info-value", type=float)
|
||||||
|
parser.add_argument("--robust-radius", type=float)
|
||||||
|
parser.add_argument("--robust-points", type=int)
|
||||||
|
parser.add_argument("--robust-rollouts", type=int)
|
||||||
|
parser.add_argument("--no-robust", action="store_true")
|
||||||
|
parser.add_argument("--eta-ux", type=float)
|
||||||
|
parser.add_argument("--reward-profit-weight", type=float)
|
||||||
|
|
||||||
|
parser.add_argument("--price-low", type=float)
|
||||||
|
parser.add_argument("--price-high", type=float)
|
||||||
|
parser.add_argument("--action-levels", type=int)
|
||||||
|
parser.add_argument("--action-scale-low", type=float)
|
||||||
|
parser.add_argument("--action-scale-high", type=float)
|
||||||
|
parser.add_argument("--max-steps", type=int)
|
||||||
|
parser.add_argument("--margin-floor", type=float)
|
||||||
|
parser.add_argument("--margin-floor-patience", type=int)
|
||||||
|
|
||||||
|
parser.add_argument("--learning-rate", type=float)
|
||||||
|
parser.add_argument("--gamma", type=float)
|
||||||
|
parser.add_argument("--buffer-size", type=int)
|
||||||
|
parser.add_argument("--batch-size", type=int)
|
||||||
|
parser.add_argument("--tau", type=float)
|
||||||
|
parser.add_argument("--train-freq", type=int)
|
||||||
|
parser.add_argument("--learning-starts", type=int)
|
||||||
|
parser.add_argument("--target-update-interval", type=int)
|
||||||
|
parser.add_argument("--exploration-fraction", type=float)
|
||||||
|
parser.add_argument("--exploration-final-eps", type=float)
|
||||||
|
parser.add_argument("--n-steps", type=int)
|
||||||
|
parser.add_argument("--n-epochs", type=int)
|
||||||
|
parser.add_argument("--gae-lambda", type=float)
|
||||||
|
parser.add_argument("--clip-range", type=float)
|
||||||
|
parser.add_argument("--ent-coef", type=float)
|
||||||
|
parser.add_argument("--q-lr", type=float)
|
||||||
|
parser.add_argument("--q-bins", type=int)
|
||||||
|
parser.add_argument("--eps-start", type=float)
|
||||||
|
parser.add_argument("--eps-end", type=float)
|
||||||
|
parser.add_argument("--eps-decay", type=float)
|
||||||
|
parser.add_argument("--arch", type=str)
|
||||||
|
parser.add_argument("--activation", type=str)
|
||||||
|
parser.add_argument("--vf-coef", type=float)
|
||||||
|
parser.add_argument("--max-grad-norm", type=float)
|
||||||
|
|
||||||
|
parser.add_argument("--eval-freq", type=int)
|
||||||
|
parser.add_argument("--eval-episodes", type=int)
|
||||||
|
|
||||||
|
parser.add_argument("--sweep-agent", action="store_true")
|
||||||
|
parser.add_argument("--sweep-id", type=str)
|
||||||
|
parser.add_argument("--count", type=int, default=0)
|
||||||
|
parser.add_argument("--offline", action="store_true")
|
||||||
|
parser.add_argument("--no-wandb", action="store_true")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
def make_env(cfg: dict):
|
def _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
|
||||||
from gymnasium.wrappers import FlattenObservation
|
backend = None if args.backend == "auto" else args.backend
|
||||||
|
|
||||||
from .wrapper import PHANTOM
|
|
||||||
from .lib.wrappers import EconomicMetricsWrapper
|
|
||||||
|
|
||||||
env = PHANTOM(
|
|
||||||
n_products=int(cfg["n_products"]),
|
|
||||||
alpha=float(cfg["alpha"]),
|
|
||||||
N=int(cfg["N"]),
|
|
||||||
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
|
|
||||||
lambda_coi=float(cfg["lambda_coi"]),
|
|
||||||
robust_radius=float(cfg["robust_radius"]),
|
|
||||||
robust_points=int(cfg["robust_points"]),
|
|
||||||
info_value=float(cfg["info_value"]),
|
|
||||||
action_levels=int(cfg["action_levels"]),
|
|
||||||
action_scale_low=float(cfg["action_scale_low"]),
|
|
||||||
action_scale_high=float(cfg["action_scale_high"]),
|
|
||||||
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)
|
|
||||||
env = FlattenObservation(env)
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def _net_arch(name) -> list[int]:
|
|
||||||
presets = {
|
|
||||||
"tiny": [32, 32],
|
|
||||||
"small": [64, 64],
|
|
||||||
"medium": [128, 128],
|
|
||||||
"large": [256, 256],
|
|
||||||
}
|
|
||||||
if isinstance(name, (list, tuple)):
|
|
||||||
return [int(v) for v in name]
|
|
||||||
s = str(name).lower().strip()
|
|
||||||
if s in presets:
|
|
||||||
return presets[s]
|
|
||||||
if "x" in s:
|
|
||||||
try:
|
|
||||||
vals = [int(v) for v in s.split("x") if v]
|
|
||||||
return vals if vals else presets["small"]
|
|
||||||
except ValueError:
|
|
||||||
return presets["small"]
|
|
||||||
return presets["small"]
|
|
||||||
|
|
||||||
|
|
||||||
def _activation(name):
|
|
||||||
try:
|
|
||||||
import torch.nn as nn
|
|
||||||
except ImportError:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"relu": nn.ReLU,
|
|
||||||
"tanh": nn.Tanh,
|
|
||||||
"elu": nn.ELU,
|
|
||||||
"leaky_relu": nn.LeakyReLU,
|
|
||||||
}.get(str(name).lower().strip(), nn.ReLU)
|
|
||||||
|
|
||||||
|
|
||||||
def _policy_kwargs(cfg: dict) -> dict:
|
|
||||||
kw = {"net_arch": _net_arch(cfg.get("arch", "small"))}
|
|
||||||
act = _activation(cfg.get("activation", "relu"))
|
|
||||||
if act is not None:
|
|
||||||
kw["activation_fn"] = act
|
|
||||||
return kw
|
|
||||||
|
|
||||||
|
|
||||||
def _action(agent, obs, deterministic: bool = True):
|
|
||||||
out = agent.predict(obs, deterministic=deterministic)
|
|
||||||
a = out[0] if isinstance(out, tuple) else out
|
|
||||||
if isinstance(a, np.ndarray) and a.size == 1:
|
|
||||||
return int(a.reshape(-1)[0])
|
|
||||||
return a
|
|
||||||
|
|
||||||
|
|
||||||
def evaluate(agent, env, episodes: int) -> dict:
|
|
||||||
rewards, revenues = [], []
|
|
||||||
for _ in range(int(episodes)):
|
|
||||||
obs, _ = env.reset()
|
|
||||||
done, ep_r, ep_rev = False, 0.0, 0.0
|
|
||||||
while not done:
|
|
||||||
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
|
|
||||||
done = term or trunc
|
|
||||||
ep_r += float(reward)
|
|
||||||
ep_rev += float(
|
|
||||||
info.get("economics", {}).get("revenue", info.get("revenue", 0.0))
|
|
||||||
)
|
|
||||||
rewards.append(ep_r)
|
|
||||||
revenues.append(ep_rev)
|
|
||||||
return {
|
|
||||||
"eval/reward": float(np.mean(rewards)),
|
|
||||||
"eval/revenue": float(np.mean(revenues)),
|
|
||||||
"eval/reward_std": float(np.std(rewards)),
|
|
||||||
"eval/revenue_std": float(np.std(revenues)),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_model(cfg: dict, env):
|
|
||||||
algo = cfg["algo"]
|
|
||||||
policy_kwargs = _policy_kwargs(cfg)
|
|
||||||
if algo == "sac":
|
|
||||||
raise ValueError("sac is not supported with the discrete core env")
|
|
||||||
if algo == "ppo":
|
|
||||||
return PPO(
|
|
||||||
"MlpPolicy",
|
|
||||||
env,
|
|
||||||
verbose=1,
|
|
||||||
policy_kwargs=policy_kwargs,
|
|
||||||
seed=int(cfg["seed"]),
|
|
||||||
learning_rate=float(cfg["learning_rate"]),
|
|
||||||
n_steps=int(cfg["n_steps"]),
|
|
||||||
batch_size=int(cfg["batch_size"]),
|
|
||||||
n_epochs=int(cfg["n_epochs"]),
|
|
||||||
gamma=float(cfg["gamma"]),
|
|
||||||
gae_lambda=float(cfg["gae_lambda"]),
|
|
||||||
clip_range=float(cfg["clip_range"]),
|
|
||||||
ent_coef=float(cfg["ent_coef"]),
|
|
||||||
)
|
|
||||||
if algo == "a2c":
|
|
||||||
return A2C(
|
|
||||||
"MlpPolicy",
|
|
||||||
env,
|
|
||||||
verbose=1,
|
|
||||||
policy_kwargs=policy_kwargs,
|
|
||||||
seed=int(cfg["seed"]),
|
|
||||||
learning_rate=float(cfg["learning_rate"]),
|
|
||||||
n_steps=max(5, int(cfg["n_steps"]) // 32),
|
|
||||||
gamma=float(cfg["gamma"]),
|
|
||||||
gae_lambda=float(cfg["gae_lambda"]),
|
|
||||||
ent_coef=float(cfg["ent_coef"]),
|
|
||||||
)
|
|
||||||
if algo == "dqn":
|
|
||||||
return DQN(
|
|
||||||
"MlpPolicy",
|
|
||||||
env,
|
|
||||||
verbose=1,
|
|
||||||
policy_kwargs=policy_kwargs,
|
|
||||||
seed=int(cfg["seed"]),
|
|
||||||
learning_rate=float(cfg["learning_rate"]),
|
|
||||||
buffer_size=int(cfg["buffer_size"]),
|
|
||||||
batch_size=int(cfg["batch_size"]),
|
|
||||||
gamma=float(cfg["gamma"]),
|
|
||||||
train_freq=int(cfg["train_freq"]),
|
|
||||||
learning_starts=int(cfg["learning_starts"]),
|
|
||||||
target_update_interval=int(cfg["target_update_interval"]),
|
|
||||||
exploration_fraction=float(cfg["exploration_fraction"]),
|
|
||||||
exploration_final_eps=float(cfg["exploration_final_eps"]),
|
|
||||||
)
|
|
||||||
raise ValueError(f"unsupported algo '{algo}'")
|
|
||||||
|
|
||||||
|
|
||||||
def _sb3_model_cls(algo: str):
|
|
||||||
if algo == "ppo":
|
|
||||||
return PPO
|
|
||||||
if algo == "a2c":
|
|
||||||
return A2C
|
|
||||||
if algo == "dqn":
|
|
||||||
return DQN
|
|
||||||
raise ValueError(f"unsupported algo '{algo}'")
|
|
||||||
|
|
||||||
|
|
||||||
def train_qtable(cfg: dict) -> tuple[EventQTable, dict]:
|
|
||||||
from .lib.discrete import EventQTable
|
|
||||||
|
|
||||||
np.random.seed(int(cfg["seed"]))
|
|
||||||
env = make_env(cfg)
|
|
||||||
eval_env = make_env(cfg)
|
|
||||||
agent = EventQTable(
|
|
||||||
env.action_space.n,
|
|
||||||
int(cfg["n_products"]),
|
|
||||||
(float(cfg["price_low"]), float(cfg["price_high"])),
|
|
||||||
lr=float(cfg["q_lr"]),
|
|
||||||
gamma=float(cfg["gamma"]),
|
|
||||||
n_bins=int(cfg["q_bins"]),
|
|
||||||
)
|
|
||||||
eps = float(cfg["eps_start"])
|
|
||||||
obs, _ = env.reset(seed=int(cfg["seed"]))
|
|
||||||
for t in range(int(cfg["total_timesteps"])):
|
|
||||||
a, s = agent.act(obs, eps)
|
|
||||||
nxt, reward, term, trunc, info = env.step(a)
|
|
||||||
done = term or trunc
|
|
||||||
agent.update(s, a, float(reward), agent.encode(nxt), done)
|
|
||||||
eps = max(float(cfg["eps_end"]), eps * float(cfg["eps_decay"]))
|
|
||||||
if HAS_WANDB and wandb.run and (t + 1) % int(cfg["log_freq"]) == 0:
|
|
||||||
econ = info.get("economics", {})
|
|
||||||
wandb.log(
|
|
||||||
{
|
|
||||||
"train/reward": float(reward),
|
|
||||||
"train/revenue": float(econ.get("revenue", 0.0)),
|
|
||||||
"train/epsilon": float(eps),
|
|
||||||
},
|
|
||||||
step=t + 1,
|
|
||||||
)
|
|
||||||
obs = env.reset()[0] if done else nxt
|
|
||||||
metrics = evaluate(agent, eval_env, int(cfg["eval_episodes"]))
|
|
||||||
metrics["train/global_step"] = int(cfg["total_timesteps"])
|
|
||||||
env.close()
|
|
||||||
eval_env.close()
|
|
||||||
return agent, metrics
|
|
||||||
|
|
||||||
|
|
||||||
def train_sb3(cfg: dict) -> tuple[object, dict]:
|
|
||||||
if not HAS_SB3:
|
|
||||||
raise ImportError("stable-baselines3 is required for SB3 models")
|
|
||||||
from .lib.callbacks import CheckpointArtifactCallback, MetricsCallback
|
|
||||||
|
|
||||||
env = make_env(cfg)
|
|
||||||
eval_env = make_env(cfg)
|
|
||||||
env = Monitor(env)
|
|
||||||
eval_env = Monitor(eval_env)
|
|
||||||
model = build_model(cfg, env)
|
|
||||||
resume_step = 0
|
|
||||||
if HAS_WANDB and wandb.run is not None:
|
|
||||||
sweep_id = getattr(wandb.run, "sweep_id", None)
|
|
||||||
artifact_name = checkpoint_artifact_name(cfg, backend="sb3", sweep_id=sweep_id)
|
|
||||||
checkpoint_file = f"phantom_{cfg['algo']}_checkpoint.zip"
|
|
||||||
restored = download_latest_checkpoint(artifact_name, file_name=checkpoint_file)
|
|
||||||
if restored is not None:
|
|
||||||
checkpoint_path, metadata = restored
|
|
||||||
model = _sb3_model_cls(cfg["algo"]).load(
|
|
||||||
checkpoint_path.as_posix(), env=env
|
|
||||||
)
|
|
||||||
resume_step = int(metadata.get("step", getattr(model, "num_timesteps", 0)))
|
|
||||||
model.num_timesteps = max(
|
|
||||||
int(getattr(model, "num_timesteps", 0)), resume_step
|
|
||||||
)
|
|
||||||
|
|
||||||
cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))]
|
|
||||||
cbs.append(
|
|
||||||
CheckpointArtifactCallback(
|
|
||||||
cfg,
|
|
||||||
interval=int(cfg.get("checkpoint_interval", 10_000)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
cbs.append(
|
|
||||||
EvalCallback(
|
|
||||||
eval_env,
|
|
||||||
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=cbs,
|
|
||||||
reset_num_timesteps=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
model_path = Path(cfg["model_dir"])
|
|
||||||
model_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
model.save(str(model_path / f"phantom_{cfg['algo']}"))
|
|
||||||
metrics = evaluate(model, eval_env, int(cfg["eval_episodes"]))
|
|
||||||
metrics["train/global_step"] = int(model.num_timesteps)
|
|
||||||
env.close()
|
|
||||||
eval_env.close()
|
|
||||||
return model, metrics
|
|
||||||
|
|
||||||
|
|
||||||
def train_once(cfg: dict) -> dict:
|
|
||||||
algo = cfg["algo"]
|
|
||||||
if cfg.get("use_jax"):
|
|
||||||
if not JAX_AVAILABLE:
|
|
||||||
raise ImportError(
|
|
||||||
"JAX backend requested but JAX is not installed. "
|
|
||||||
"Install engine/jax/requirements.txt and jax[tpu] for TPU runs."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
from .jax.train import train_jax
|
|
||||||
except Exception as exc: # pragma: no cover
|
|
||||||
raise ImportError(f"Failed to import JAX trainer: {exc}") from exc
|
|
||||||
_, metrics = train_jax(cfg)
|
|
||||||
elif algo == "qtable":
|
|
||||||
_, metrics = train_qtable(cfg)
|
|
||||||
else:
|
|
||||||
_, metrics = train_sb3(cfg)
|
|
||||||
metrics["sweep/score"] = float(
|
|
||||||
metrics["eval/reward"] + float(cfg["revenue_weight"]) * metrics["eval/revenue"]
|
|
||||||
)
|
|
||||||
return metrics
|
|
||||||
|
|
||||||
|
|
||||||
def run_wandb(
|
|
||||||
project: str, overrides: dict, mode: str = "online", sweep_mode: bool = False
|
|
||||||
) -> dict:
|
|
||||||
if not HAS_WANDB:
|
|
||||||
raise ImportError("wandb is required for sweep runs")
|
|
||||||
if not sweep_mode:
|
|
||||||
pre_cfg = _cfg(overrides)
|
|
||||||
if pre_cfg.get("use_jax"):
|
|
||||||
try:
|
|
||||||
import jax
|
|
||||||
|
|
||||||
if jax.process_count() > 1 and jax.process_index() != 0:
|
|
||||||
return train_once(pre_cfg)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
init_kwargs = {"mode": mode}
|
|
||||||
if sweep_mode:
|
|
||||||
run = wandb.init(**init_kwargs)
|
|
||||||
else:
|
|
||||||
run = wandb.init(project=project, config=overrides, **init_kwargs)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cfg = _cfg(_wandb_cfg_dict())
|
|
||||||
if sweep_mode:
|
|
||||||
for k, v in overrides.items():
|
|
||||||
if k not in wandb.config:
|
|
||||||
cfg[k] = v
|
|
||||||
|
|
||||||
metrics = train_once(cfg)
|
|
||||||
step = int(metrics.get("train/global_step", cfg["total_timesteps"]))
|
|
||||||
wandb.log(metrics, step=step)
|
|
||||||
for k, v in metrics.items():
|
|
||||||
run.summary[k] = v
|
|
||||||
return metrics
|
|
||||||
finally:
|
|
||||||
if wandb.run is not None:
|
|
||||||
wandb.finish()
|
|
||||||
|
|
||||||
|
|
||||||
def run_local(overrides: dict) -> dict:
|
|
||||||
cfg = _cfg(overrides)
|
|
||||||
metrics = train_once(cfg)
|
|
||||||
should_print = True
|
|
||||||
if cfg.get("use_jax"):
|
|
||||||
try:
|
|
||||||
import jax
|
|
||||||
|
|
||||||
should_print = jax.process_index() == 0
|
|
||||||
except Exception:
|
|
||||||
should_print = True
|
|
||||||
if should_print:
|
|
||||||
print(json.dumps(metrics, indent=2))
|
|
||||||
# sentinel line for machine-readable extraction; must stay on one line
|
|
||||||
print("PHANTOM_METRICS:" + json.dumps(metrics))
|
|
||||||
return metrics
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
p = argparse.ArgumentParser(description="PHANTOM training and W&B sweeps")
|
|
||||||
p.add_argument("--project", default=DEFAULT_CFG["project"])
|
|
||||||
p.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable"])
|
|
||||||
p.add_argument("--seed", type=int)
|
|
||||||
p.add_argument("--total-timesteps", type=int)
|
|
||||||
p.add_argument("--alpha", type=float)
|
|
||||||
p.add_argument("--N", type=int)
|
|
||||||
p.add_argument("--n-products", type=int)
|
|
||||||
p.add_argument("--lambda-coi", type=float)
|
|
||||||
p.add_argument("--info-value", type=float)
|
|
||||||
p.add_argument("--robust-radius", type=float)
|
|
||||||
p.add_argument("--robust-points", type=int)
|
|
||||||
p.add_argument("--learning-rate", type=float)
|
|
||||||
p.add_argument("--gamma", type=float)
|
|
||||||
p.add_argument("--gae-lambda", type=float)
|
|
||||||
p.add_argument("--clip-range", type=float)
|
|
||||||
p.add_argument("--ent-coef", type=float)
|
|
||||||
p.add_argument("--revenue-weight", type=float)
|
|
||||||
p.add_argument("--price-low", type=float)
|
|
||||||
p.add_argument("--price-high", type=float)
|
|
||||||
p.add_argument("--action-levels", type=int)
|
|
||||||
p.add_argument("--action-scale-low", type=float)
|
|
||||||
p.add_argument("--action-scale-high", type=float)
|
|
||||||
p.add_argument("--max-steps", type=int)
|
|
||||||
p.add_argument("--margin-floor", type=float)
|
|
||||||
p.add_argument("--margin-floor-patience", type=int)
|
|
||||||
p.add_argument("--arch", type=str)
|
|
||||||
p.add_argument("--activation", type=str)
|
|
||||||
p.add_argument("--jax", action="store_true")
|
|
||||||
p.add_argument("--jax-num-envs", type=int)
|
|
||||||
p.add_argument("--jax-num-steps", type=int)
|
|
||||||
p.add_argument("--jax-num-minibatches", type=int)
|
|
||||||
p.add_argument("--jax-update-epochs", type=int)
|
|
||||||
p.add_argument("--jax-anneal-lr", type=str)
|
|
||||||
p.add_argument("--checkpoint-interval", type=int)
|
|
||||||
p.add_argument("--sweep-agent", action="store_true")
|
|
||||||
p.add_argument("--sweep-id", type=str)
|
|
||||||
p.add_argument("--count", type=int, default=0)
|
|
||||||
p.add_argument("--offline", action="store_true")
|
|
||||||
p.add_argument("--no-wandb", action="store_true")
|
|
||||||
args = p.parse_args()
|
|
||||||
|
|
||||||
overrides = {
|
overrides = {
|
||||||
|
"project": args.project,
|
||||||
|
"backend": backend,
|
||||||
"algo": args.algo,
|
"algo": args.algo,
|
||||||
"seed": args.seed,
|
"seed": args.seed,
|
||||||
"total_timesteps": args.total_timesteps,
|
"total_timesteps": args.total_timesteps,
|
||||||
|
"model_dir": args.model_dir,
|
||||||
|
"log_freq": args.log_freq,
|
||||||
|
"hist_freq": args.hist_freq,
|
||||||
|
"checkpoint_interval": args.checkpoint_interval,
|
||||||
|
"device": args.device,
|
||||||
"alpha": args.alpha,
|
"alpha": args.alpha,
|
||||||
"N": args.N,
|
"N": args.N,
|
||||||
"n_products": args.n_products,
|
"n_products": args.n_products,
|
||||||
@@ -514,12 +136,10 @@ def main():
|
|||||||
"info_value": args.info_value,
|
"info_value": args.info_value,
|
||||||
"robust_radius": args.robust_radius,
|
"robust_radius": args.robust_radius,
|
||||||
"robust_points": args.robust_points,
|
"robust_points": args.robust_points,
|
||||||
"learning_rate": args.learning_rate,
|
"robust_rollouts": args.robust_rollouts,
|
||||||
"gamma": args.gamma,
|
"no_robust": args.no_robust,
|
||||||
"gae_lambda": args.gae_lambda,
|
"eta_ux": args.eta_ux,
|
||||||
"clip_range": args.clip_range,
|
"reward_profit_weight": args.reward_profit_weight,
|
||||||
"ent_coef": args.ent_coef,
|
|
||||||
"revenue_weight": args.revenue_weight,
|
|
||||||
"price_low": args.price_low,
|
"price_low": args.price_low,
|
||||||
"price_high": args.price_high,
|
"price_high": args.price_high,
|
||||||
"action_levels": args.action_levels,
|
"action_levels": args.action_levels,
|
||||||
@@ -528,40 +148,103 @@ def main():
|
|||||||
"max_steps": args.max_steps,
|
"max_steps": args.max_steps,
|
||||||
"margin_floor": args.margin_floor,
|
"margin_floor": args.margin_floor,
|
||||||
"margin_floor_patience": args.margin_floor_patience,
|
"margin_floor_patience": args.margin_floor_patience,
|
||||||
|
"learning_rate": args.learning_rate,
|
||||||
|
"gamma": args.gamma,
|
||||||
|
"buffer_size": args.buffer_size,
|
||||||
|
"batch_size": args.batch_size,
|
||||||
|
"tau": args.tau,
|
||||||
|
"train_freq": args.train_freq,
|
||||||
|
"learning_starts": args.learning_starts,
|
||||||
|
"target_update_interval": args.target_update_interval,
|
||||||
|
"exploration_fraction": args.exploration_fraction,
|
||||||
|
"exploration_final_eps": args.exploration_final_eps,
|
||||||
|
"n_steps": args.n_steps,
|
||||||
|
"n_epochs": args.n_epochs,
|
||||||
|
"gae_lambda": args.gae_lambda,
|
||||||
|
"clip_range": args.clip_range,
|
||||||
|
"ent_coef": args.ent_coef,
|
||||||
|
"q_lr": args.q_lr,
|
||||||
|
"q_bins": args.q_bins,
|
||||||
|
"eps_start": args.eps_start,
|
||||||
|
"eps_end": args.eps_end,
|
||||||
|
"eps_decay": args.eps_decay,
|
||||||
"arch": args.arch,
|
"arch": args.arch,
|
||||||
"activation": args.activation,
|
"activation": args.activation,
|
||||||
"use_jax": args.jax,
|
"vf_coef": args.vf_coef,
|
||||||
"jax_num_envs": args.jax_num_envs,
|
"max_grad_norm": args.max_grad_norm,
|
||||||
"jax_num_steps": args.jax_num_steps,
|
"eval_freq": args.eval_freq,
|
||||||
"jax_num_minibatches": args.jax_num_minibatches,
|
"eval_episodes": args.eval_episodes,
|
||||||
"jax_update_epochs": args.jax_update_epochs,
|
|
||||||
"checkpoint_interval": args.checkpoint_interval,
|
|
||||||
"jax_anneal_lr": _truthy(args.jax_anneal_lr)
|
|
||||||
if args.jax_anneal_lr is not None
|
|
||||||
else None,
|
|
||||||
}
|
}
|
||||||
overrides = {k: v for k, v in overrides.items() if v is not None}
|
return {key: value for key, value in overrides.items() if value is not None}
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure data is downloaded
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
project_root = Path(__file__).parents[1]
|
||||||
|
data_dir = project_root / "experiments" / "collected_data"
|
||||||
|
needs_pull = (not data_dir.exists()) or (not any(data_dir.iterdir()))
|
||||||
|
if needs_pull:
|
||||||
|
try:
|
||||||
|
subprocess.run(["make", "data.pull"], cwd=str(project_root), check=True)
|
||||||
|
except (subprocess.SubprocessError, OSError) as exc:
|
||||||
|
sys.path.insert(0, str(project_root))
|
||||||
|
try:
|
||||||
|
from scripts.hf_data import pull
|
||||||
|
|
||||||
|
pull()
|
||||||
|
except (ImportError, OSError, RuntimeError, ValueError) as fallback_exc:
|
||||||
|
print(
|
||||||
|
f"Warning: data.pull failed ({exc}); fallback pull failed ({fallback_exc})"
|
||||||
|
)
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
raw_args = list(sys.argv[1:] if argv is None else argv)
|
||||||
|
run_kind = _probe_run_kind(raw_args)
|
||||||
|
if run_kind == "benchmark":
|
||||||
|
run_benchmark_cli(_strip_run_kind(raw_args))
|
||||||
|
return
|
||||||
|
|
||||||
|
parser = _build_parser()
|
||||||
|
args, unknown = parser.parse_known_args(raw_args)
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"Unknown arguments for training mode: {' '.join(unknown)}")
|
||||||
|
|
||||||
|
overrides = _overrides_from_args(args)
|
||||||
|
scenario = str(args.scenario)
|
||||||
|
group = args.group
|
||||||
|
extra_tags = tuple(_parse_tags(args.tags))
|
||||||
|
|
||||||
if args.sweep_agent:
|
if args.sweep_agent:
|
||||||
if args.no_wandb:
|
run_sweep_agent(
|
||||||
raise ValueError("sweep agent requires wandb")
|
project=args.project,
|
||||||
if not args.sweep_id:
|
sweep_id=str(args.sweep_id or ""),
|
||||||
raise ValueError("--sweep-id is required with --sweep-agent")
|
count=int(args.count),
|
||||||
mode = "offline" if args.offline else "online"
|
offline=bool(args.offline),
|
||||||
wandb.agent(
|
no_wandb=bool(args.no_wandb),
|
||||||
args.sweep_id,
|
base_overrides=overrides,
|
||||||
function=lambda: run_wandb(
|
kind="sweep",
|
||||||
args.project, overrides, mode=mode, sweep_mode=True
|
scenario=scenario,
|
||||||
),
|
group=group,
|
||||||
count=args.count if args.count > 0 else None,
|
extra_tags=extra_tags,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if args.no_wandb or not HAS_WANDB:
|
spec = TrainSpec.from_flat(overrides)
|
||||||
run_local(overrides)
|
run_train_once(
|
||||||
return
|
spec,
|
||||||
|
project=args.project,
|
||||||
run_wandb(args.project, overrides, mode="offline" if args.offline else "online")
|
offline=bool(args.offline),
|
||||||
|
no_wandb=bool(args.no_wandb),
|
||||||
|
kind="train",
|
||||||
|
scenario=scenario,
|
||||||
|
group=group,
|
||||||
|
extra_tags=extra_tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||