mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
77 Commits
cleanup
...
refactor-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||||
|
|||||||
125
.github/workflows/latex.yml
vendored
125
.github/workflows/latex.yml
vendored
@@ -12,32 +12,92 @@ on:
|
|||||||
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
|
||||||
|
|
||||||
|
- name: Generate mirrors with Codex
|
||||||
|
if: ${{ env.OPENAI_API_KEY != '' }}
|
||||||
|
uses: openai/codex-action@v1
|
||||||
|
with:
|
||||||
|
openai-api-key: ${{ env.OPENAI_API_KEY }}
|
||||||
|
sandbox: workspace-write
|
||||||
|
safety-strategy: drop-sudo
|
||||||
|
working-directory: .
|
||||||
|
prompt: |
|
||||||
|
Read and follow the mirror instructions in `paper/src/mirrors/genpop/INSTRUCTIONS.md`.
|
||||||
|
|
||||||
|
Source chapters are in `paper/src/chapters/`:
|
||||||
|
- 01-intro.tex
|
||||||
|
- 02-literature-review.tex
|
||||||
|
- 03-methodology.tex
|
||||||
|
- 04-results.tex
|
||||||
|
- 05-discussion.tex
|
||||||
|
- 06-conclusion.tex
|
||||||
|
|
||||||
|
Update `paper/src/mirrors/genpop/*.tex` so they mirror the thesis for a general audience according to the instruction file.
|
||||||
|
Keep LaTeX valid and preserve citation commands and section order.
|
||||||
|
|
||||||
|
Then create or update `paper/src/main-mirror-genpop.tex` by using `paper/src/main.tex` as the base and replacing chapter inputs from `chapters/...` to `mirrors/genpop/...`.
|
||||||
|
Do not change any other project files.
|
||||||
|
|
||||||
|
- name: Compute LaTeX roots
|
||||||
|
id: roots
|
||||||
|
run: |
|
||||||
|
{
|
||||||
|
echo "root_files<<EOF"
|
||||||
|
echo "main.tex"
|
||||||
|
for file in paper/src/main-mirror-*.tex; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
basename "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
echo "Compiling roots:"
|
||||||
|
echo "main.tex"
|
||||||
|
for file in paper/src/main-mirror-*.tex; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
basename "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Compile LaTeX documents
|
||||||
uses: xu-cheng/latex-action@v3
|
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 +131,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
|
||||||
|
|||||||
8
.gitignore
vendored
8
.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
|
||||||
@@ -36,10 +40,11 @@ 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
|
||||||
@@ -66,6 +71,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
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/
|
||||||
277
Makefile
277
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,200 @@ 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.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | manim.render manim.render.all"
|
||||||
@echo "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: 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
|
||||||
|
|
||||||
.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
|
||||||
|
|
||||||
|
all:
|
||||||
|
@$(NX) run paper:build
|
||||||
|
|
||||||
|
.PHONY: manim.render manim.render.all
|
||||||
|
manim.render:
|
||||||
|
@$(NX) run manim:render
|
||||||
|
|
||||||
|
manim.render.all:
|
||||||
|
@$(NX) run manim:render-all
|
||||||
|
|||||||
234
README.md
234
README.md
@@ -1,94 +1,160 @@
|
|||||||
<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
|
# PHANTOM
|
||||||
|
|
||||||
|
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://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
|
[](https://huggingface.co/datasets/velocitatem/whoclickedit)
|
||||||
[](https://sites.research.google/trc/faq/)
|
[](https://sites.research.google/trc/faq/)
|
||||||
[](https://phantom-hotel.vercel.app)
|
|
||||||
[](https://phantom-airline.vercel.app)
|
|
||||||
|
|
||||||
|
**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 source |
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -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
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
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
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
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
3
backend/worker/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
celery>=5.3,<6
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
redis>=5.0.0
|
||||||
@@ -1,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
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
|
|
||||||
|
|||||||
279
docs/index.html
279
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="#303030">
|
||||||
<meta name="msapplication-TileColor" content="#2563eb">
|
<meta name="msapplication-TileColor" content="#303030">
|
||||||
<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">
|
||||||
@@ -87,9 +80,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 +93,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 +140,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 +154,72 @@
|
|||||||
|
|
||||||
<!-- 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="https://github.com/velocitatem/p4p" class="work-item" target="_blank">
|
||||||
<div class="work-info">
|
<div class="work-info">
|
||||||
<h5>Paper Title 3</h5>
|
<h5>P4P Interaction Layer</h5>
|
||||||
<p>Brief description of the work and its main contribution.</p>
|
<p>Reusable storefront and logging layer released for replication.</p>
|
||||||
<span class="work-venue">Conference/Journal 2023</span>
|
<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>
|
||||||
@@ -226,19 +234,29 @@
|
|||||||
<div class="columns is-centered">
|
<div class="columns is-centered">
|
||||||
<div class="column has-text-centered">
|
<div class="column has-text-centered">
|
||||||
<h1 class="title is-1 publication-title">Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms</h1>
|
<h1 class="title is-1 publication-title">Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms</h1>
|
||||||
<div class="is-size-5 publication-authors">
|
<div class="is-size-5 publication-authors author-names">
|
||||||
<span class="author-block">
|
<span class="author-block">
|
||||||
<a href="https://alves.world" target="_blank">Daniel Rösel</a></span>
|
<a href="https://alves.world" target="_blank">Daniel Rösel</a></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="is-size-5 publication-authors">
|
<div class="is-size-5 publication-authors author-meta">
|
||||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
||||||
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
<span class="eql-cntrb">Advisor: Alberto Martín Izquierdo</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column has-text-centered">
|
<div class="column has-text-centered">
|
||||||
<div class="publication-links">
|
<div class="publication-links">
|
||||||
<span class="link-block">
|
<span class="link-block">
|
||||||
|
<a href="https://blog.alves.world/series/phantom" target="_blank"
|
||||||
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-blog"></i>
|
||||||
|
</span>
|
||||||
|
<span>Blog Series</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="link-block">
|
||||||
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
@@ -248,14 +266,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- TODO: Add your supplementary material PDF or remove this section -->
|
|
||||||
<span class="link-block">
|
<span class="link-block">
|
||||||
<a href="static/pdfs/supplementary_material.pdf" target="_blank"
|
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit" target="_blank"
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-file-pdf"></i>
|
<i class="fas fa-database"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>Supplementary</span>
|
<span>Dataset</span>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -269,42 +286,43 @@
|
|||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- TODO: Update with your arXiv paper ID -->
|
|
||||||
<span class="link-block">
|
<span class="link-block">
|
||||||
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
|
<a href="https://phantom-hotel.vercel.app" target="_blank"
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="ai ai-arxiv"></i>
|
<i class="fas fa-globe"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>arXiv</span>
|
<span>Hotel Demo</span>
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
<span class="link-block">
|
||||||
|
<a href="https://phantom-airline.vercel.app" target="_blank"
|
||||||
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
|
<span class="icon">
|
||||||
|
<i class="fas fa-plane"></i>
|
||||||
|
</span>
|
||||||
|
<span>Airline Demo</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<!-- Teaser video-->
|
|
||||||
<section class="hero teaser">
|
<section class="hero teaser">
|
||||||
<div class="container is-max-desktop">
|
<div class="container is-max-desktop">
|
||||||
<div class="hero-body">
|
<div class="hero-body">
|
||||||
<!-- TODO: Replace with your teaser video -->
|
<div class="publication-banner">
|
||||||
<video poster="" id="tree" autoplay controls muted loop height="100%" preload="metadata">
|
<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';"/>
|
||||||
<!-- TODO: Add your video file path here -->
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<!-- End teaser video -->
|
|
||||||
|
|
||||||
<!-- Paper abstract -->
|
<!-- Paper abstract -->
|
||||||
<section class="section hero is-light">
|
<section class="section hero is-light">
|
||||||
@@ -314,10 +332,10 @@
|
|||||||
<h2 class="title is-3">Abstract</h2>
|
<h2 class="title is-3">Abstract</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.
|
When you shop online, prices often change based on how much interest you show — the more you browse, the more the site learns about your intent and may raise prices accordingly. This works because stores assume that a curious, engaged shopper is more likely to buy. But AI assistants are now doing the shopping research on behalf of users: they browse in one session to gather price information and then let the user purchase in a fresh session at the lower, unadjusted price. The store never sees the connection between the two, so it never gets to factor in that genuine intent — and loses the revenue it would have earned.
|
||||||
</p>
|
</p>
|
||||||
<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.
|
PHANTOM studies this problem and builds defenses against it. We created a realistic fake store (in hotel and airline modes) where both real people and AI agents were given shopping tasks, and we recorded every click, scroll, and page visit. By comparing how humans and AI agents move through a site, we found clear patterns that tell them apart. We then used those patterns to build a smarter pricing system that can recognize when it is likely talking to an AI scout and adjust its strategy accordingly — protecting the store's margins without making things worse for genuine shoppers.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,97 +344,90 @@
|
|||||||
</section>
|
</section>
|
||||||
<!-- End paper abstract -->
|
<!-- End paper abstract -->
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div class="container is-max-desktop">
|
||||||
|
<div class="content has-text-justified">
|
||||||
|
<h2 class="title is-3 has-text-centered">Project Scope</h2>
|
||||||
|
<p>
|
||||||
|
The current thesis revision extends both theory and implementation. The main research question is how a pricing system can preserve margin integrity when browsing and purchasing are increasingly orchestrated by AI agents.
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>Formal contribution: a Cost of Information erosion theorem showing why price-query saturation can collapse dynamic pricing power.</li>
|
||||||
|
<li>System contribution: a hybrid online/offline stack (Next.js storefront, pricing provider, Kafka event streams, Airflow ETL, Redis serving layer).</li>
|
||||||
|
<li>Modeling contribution: class-specific transition kernels for human and agent behavior, with KL-divergence based separability scores.</li>
|
||||||
|
<li>Control contribution: a contamination-aware DR-RL pricing policy trained under distributional uncertainty using Wasserstein-style robustness.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Controlled trials currently include balanced human and agent sessions with goal-driven tasks across hotel and airline interfaces. Early separability results are strong (Mann-Whitney U=2.0, p=0.0006), while robust pricing gains remain regime-dependent and are being calibrated in larger sweeps.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
<!-- Image carousel -->
|
<!-- 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 -->
|
|
||||||
<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 -->
|
<!-- Video 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">
|
||||||
<h2 class="title is-3">Another Carousel</h2>
|
<h2 class="title is-3">Defense Scenes</h2>
|
||||||
<div id="results-carousel" class="carousel results-carousel">
|
<div id="videos-carousel" class="carousel results-carousel">
|
||||||
<div class="item item-video1">
|
<div class="item item-video1">
|
||||||
<!-- TODO: Add poster image for better preview -->
|
|
||||||
<video poster="" id="video1" controls muted loop height="100%" preload="metadata">
|
<video poster="" id="video1" controls muted loop height="100%" preload="metadata">
|
||||||
<!-- Your video file here -->
|
<source src="static/videos/COIFirstPrinciplesScene.mp4" type="video/mp4">
|
||||||
<source src="static/videos/carousel1.mp4" type="video/mp4">
|
|
||||||
</video>
|
</video>
|
||||||
|
<h2 class="subtitle has-text-centered">COI from first principles.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="item item-video2">
|
<div class="item item-video2">
|
||||||
<!-- TODO: Add poster image for better preview -->
|
|
||||||
<video poster="" id="video2" controls muted loop height="100%" preload="metadata">
|
<video poster="" id="video2" controls muted loop height="100%" preload="metadata">
|
||||||
<!-- Your video file here -->
|
<source src="static/videos/BehaviorKernelConstructionScene.mp4" type="video/mp4">
|
||||||
<source src="static/videos/carousel2.mp4" type="video/mp4">
|
|
||||||
</video>
|
</video>
|
||||||
|
<h2 class="subtitle has-text-centered">Behavioral kernel construction: learning how humans and agents differ.</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="item item-video3">
|
<div class="item item-video3">
|
||||||
<!-- TODO: Add poster image for better preview -->
|
|
||||||
<video poster="" id="video3" controls muted loop height="100%" preload="metadata">
|
<video poster="" id="video3" controls muted loop height="100%" preload="metadata">
|
||||||
<!-- Your video file here -->
|
<source src="static/videos/RobustControlScene.mp4" type="video/mp4">
|
||||||
<source src="static/videos/carousel3.mp4" type="video/mp4">
|
|
||||||
</video>
|
</video>
|
||||||
|
<h2 class="subtitle has-text-centered">Distributionally robust control loop.</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -432,10 +443,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">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 +468,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},
|
||||||
|
|||||||
989
docs/static/css/index.css
vendored
989
docs/static/css/index.css
vendored
File diff suppressed because it is too large
Load Diff
246
docs/static/images/banner.svg
vendored
Normal file
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: Separability Distributions -->
|
||||||
|
<g transform="translate(700, 600)">
|
||||||
|
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Separability into a Control Signal</text>
|
||||||
|
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T̂' || T̄</tspan><tspan font-size="16" dy="5">H</tspan><tspan dy="-5">)</tspan></text>
|
||||||
|
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">A</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T̂' || T̄</tspan><tspan font-size="16" dy="5">A</tspan><tspan dy="-5">)</tspan></text>
|
||||||
|
<text x="0" y="155" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
|
||||||
|
|
||||||
|
<!-- Curves -->
|
||||||
|
<g transform="translate(80, 160)">
|
||||||
|
<line x1="0" y1="200" x2="360" y2="200" stroke="#333" stroke-width="2"/>
|
||||||
|
<text x="180" y="235" font-family="Georgia, serif" font-style="italic" font-size="22" text-anchor="middle">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
|
||||||
|
|
||||||
|
<!-- Human Curve -->
|
||||||
|
<path d="M 0 200 C 50 200, 80 40, 130 40 C 180 40, 210 200, 260 200" stroke="#4EA5D9" stroke-width="5" fill="none"/>
|
||||||
|
<text x="70" y="110" font-size="22" fill="#4EA5D9" font-weight="bold">human</text>
|
||||||
|
|
||||||
|
<!-- Agent Curve -->
|
||||||
|
<path d="M 100 200 C 150 200, 180 40, 230 40 C 280 40, 310 200, 360 200" stroke="#E37862" stroke-width="5" fill="none"/>
|
||||||
|
<text x="290" y="110" font-size="22" fill="#E37862" font-weight="bold">agent</text>
|
||||||
|
|
||||||
|
<!-- Decision Boundary -->
|
||||||
|
<line x1="180" y1="200" x2="180" y2="10" stroke="#999" stroke-width="2" stroke-dasharray="8,5"/>
|
||||||
|
<text x="180" y="-5" font-size="16" fill="#777" text-anchor="middle">decision boundary</text>
|
||||||
|
|
||||||
|
<circle cx="210" cy="200" r="6" fill="#ECA233"/>
|
||||||
|
<text x="210" y="180" font-family="Georgia" font-style="italic" font-size="20" fill="#ECA233" text-anchor="middle">g_obs</text>
|
||||||
|
|
||||||
|
<text x="180" y="280" font-size="18" fill="#555" text-anchor="middle">Positive gap shifts score toward agent traffic.</text>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- ========================================================= -->
|
||||||
|
<!-- COLUMN 3: THE SOLUTION (CONTAMINATION & DR-RL) -->
|
||||||
|
<!-- ========================================================= -->
|
||||||
|
<text x="1340" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">3. Robust Control & Contamination</text>
|
||||||
|
<line x1="1340" y1="100" x2="1860" y2="100" stroke="#DDDDDD" stroke-width="2"/>
|
||||||
|
|
||||||
|
<!-- Top: Contamination Generator -->
|
||||||
|
<g transform="translate(1340, 130)">
|
||||||
|
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Contamination Generator G(α)</text>
|
||||||
|
|
||||||
|
<!-- Boxes -->
|
||||||
|
<rect x="20" y="70" width="200" height="50" fill="#D0E5E0" filter="url(#shadow)" rx="6"/>
|
||||||
|
<text x="120" y="100" font-size="18" fill="#222" text-anchor="middle">labeled human sessions</text>
|
||||||
|
|
||||||
|
<rect x="280" y="70" width="200" height="50" fill="#EAD0C8" filter="url(#shadow)" rx="6"/>
|
||||||
|
<text x="380" y="100" font-size="18" fill="#222" text-anchor="middle">synthetic agent sessions</text>
|
||||||
|
|
||||||
|
<!-- Arrows -->
|
||||||
|
<line x1="120" y1="130" x2="200" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
|
||||||
|
<line x1="380" y1="130" x2="300" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
|
||||||
|
|
||||||
|
<!-- Mixed Batch -->
|
||||||
|
<rect x="150" y="190" width="200" height="50" fill="#F4E9CD" filter="url(#shadow)" rx="6"/>
|
||||||
|
<text x="250" y="220" font-size="18" fill="#222" text-anchor="middle">mixed batch for training</text>
|
||||||
|
|
||||||
|
<!-- Alpha Bar -->
|
||||||
|
<text x="250" y="275" font-family="Georgia, serif" font-size="20" fill="#555" text-anchor="middle">alpha = 0.33</text>
|
||||||
|
|
||||||
|
<rect x="50" y="290" width="268" height="30" fill="#4EA5D9"/>
|
||||||
|
<rect x="318" y="290" width="132" height="30" fill="#E37862"/>
|
||||||
|
<text x="184" y="340" font-size="18" fill="#4EA5D9" text-anchor="middle">human share (1-α)</text>
|
||||||
|
<text x="384" y="340" font-size="18" fill="#E37862" text-anchor="middle">agent share (α)</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Bottom: Distributionally Robust Control -->
|
||||||
|
<g transform="translate(1340, 600)">
|
||||||
|
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Distributionally Robust Control Layer</text>
|
||||||
|
<text x="0" y="80" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">
|
||||||
|
π* = arg max<tspan font-size="16" dy="5">π</tspan> min<tspan font-size="16" dy="0">Q ∈ U<tspan font-size="12" dy="5">ε</tspan></tspan>
|
||||||
|
<tspan dy="-10"> E</tspan><tspan font-size="16" dy="5">d ~ Q</tspan>
|
||||||
|
<tspan dy="-5">[ R(p,d) - λ COI</tspan><tspan font-size="16" dy="5">leak</tspan><tspan dy="-5">(p,τ') ]</tspan>
|
||||||
|
</text>
|
||||||
|
|
||||||
|
<!-- Ambiguity Ball -->
|
||||||
|
<g transform="translate(140, 260)">
|
||||||
|
<line x1="-130" y1="0" x2="130" y2="0" stroke="#CCC" stroke-width="2"/>
|
||||||
|
<line x1="0" y1="-130" x2="0" y2="130" stroke="#CCC" stroke-width="2"/>
|
||||||
|
|
||||||
|
<circle cx="0" cy="0" r="110" stroke="#C4A45B" stroke-width="4" fill="rgba(196,164,91,0.06)"/>
|
||||||
|
<text x="-95" y="-120" font-family="Georgia" font-style="italic" font-size="24" fill="#C4A45B">U<tspan font-size="16" dy="5">ε</tspan></text>
|
||||||
|
|
||||||
|
<!-- Points -->
|
||||||
|
<circle cx="0" cy="0" r="7" fill="#4EA5D9"/>
|
||||||
|
<text x="12" y="24" font-family="Georgia" font-style="italic" font-size="22" fill="#4EA5D9">P̂<tspan font-size="14" dy="5">N</tspan></text>
|
||||||
|
|
||||||
|
<circle cx="-60" cy="-40" r="7" fill="#E37862"/>
|
||||||
|
<text x="-140" y="-50" font-family="Georgia" font-style="italic" font-size="18" fill="#E37862">worst-case Q*</text>
|
||||||
|
|
||||||
|
<circle cx="50" cy="-70" r="6" fill="#85B589"/>
|
||||||
|
<circle cx="70" cy="50" r="6" fill="#85B589"/>
|
||||||
|
<circle cx="-40" cy="80" r="6" fill="#85B589"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Process Steps -->
|
||||||
|
<g transform="translate(320, 140)">
|
||||||
|
<rect x="0" y="0" width="220" height="45" fill="#FDEFEF" filter="url(#light-shadow)" rx="6"/>
|
||||||
|
<text x="110" y="28" font-size="16" fill="#E37862" font-weight="bold" text-anchor="middle">inner min picks Q*</text>
|
||||||
|
|
||||||
|
<line x1="110" y1="55" x2="110" y2="85" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
|
||||||
|
|
||||||
|
<rect x="0" y="95" width="220" height="45" fill="#F4E9CD" filter="url(#light-shadow)" rx="6"/>
|
||||||
|
<text x="110" y="123" font-size="16" fill="#9E8033" font-weight="bold" text-anchor="middle">sample demand from Q*</text>
|
||||||
|
|
||||||
|
<line x1="110" y1="150" x2="110" y2="180" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
|
||||||
|
|
||||||
|
<rect x="0" y="190" width="220" height="45" fill="#E6F2ED" filter="url(#light-shadow)" rx="6"/>
|
||||||
|
<text x="110" y="218" font-size="16" fill="#428062" font-weight="bold" text-anchor="middle">outer max updates policy</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<text x="250" y="440" font-size="18" fill="#555" text-anchor="middle">Reward is evaluated on demand drawn from Q*, then used for the policy step.</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 17 KiB |
BIN
docs/static/videos/BehaviorKernelConstructionScene.mp4
vendored
Normal file
BIN
docs/static/videos/BehaviorKernelConstructionScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/COIFirstPrinciplesScene.mp4
vendored
Normal file
BIN
docs/static/videos/COIFirstPrinciplesScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/COIOrderStatisticProofScene.mp4
vendored
Normal file
BIN
docs/static/videos/COIOrderStatisticProofScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/CardMarketAnalogyScene.mp4
vendored
Normal file
BIN
docs/static/videos/CardMarketAnalogyScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/ContaminationGeneratorScene.mp4
vendored
Normal file
BIN
docs/static/videos/ContaminationGeneratorScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/DefenseOpening.mp4
vendored
Normal file
BIN
docs/static/videos/DefenseOpening.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/ObjectiveAndResultsScene.mp4
vendored
Normal file
BIN
docs/static/videos/ObjectiveAndResultsScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/RobustControlScene.mp4
vendored
Normal file
BIN
docs/static/videos/RobustControlScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/SeparabilitySignalScene.mp4
vendored
Normal file
BIN
docs/static/videos/SeparabilitySignalScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/SystemLoopScene.mp4
vendored
Normal file
BIN
docs/static/videos/SystemLoopScene.mp4
vendored
Normal file
Binary file not shown.
BIN
docs/static/videos/TakeawayScene.mp4
vendored
Normal file
BIN
docs/static/videos/TakeawayScene.mp4
vendored
Normal file
Binary file not shown.
0
engine/__init__.py
Normal file
0
engine/__init__.py
Normal file
1
engine/backends/__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
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
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
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
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
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
1319
engine/jax/train.py
File diff suppressed because it is too large
Load Diff
@@ -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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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__":
|
||||||
|
|||||||
40
engine/train_core.py
Normal file
40
engine/train_core.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .spec import TrainSpec
|
||||||
|
from .telemetry.metrics import canonicalize_metrics
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TrainResult:
|
||||||
|
spec: TrainSpec
|
||||||
|
metrics: dict[str, Any]
|
||||||
|
artifacts: dict[str, str]
|
||||||
|
events: list[dict[str, Any]]
|
||||||
|
|
||||||
|
|
||||||
|
def run_train(spec: TrainSpec) -> TrainResult:
|
||||||
|
cfg = spec.to_flat_dict()
|
||||||
|
algo = spec.algorithm.name
|
||||||
|
|
||||||
|
if algo == "qtable":
|
||||||
|
from .backends.qtable import train_qtable
|
||||||
|
|
||||||
|
_, raw_metrics = train_qtable(cfg)
|
||||||
|
else:
|
||||||
|
from .backends.sb3 import train_sb3
|
||||||
|
|
||||||
|
_, raw_metrics = train_sb3(cfg)
|
||||||
|
|
||||||
|
events_raw = raw_metrics.pop("_train_events", [])
|
||||||
|
events = [evt for evt in events_raw if isinstance(evt, dict)]
|
||||||
|
|
||||||
|
metrics = canonicalize_metrics(raw_metrics, spec)
|
||||||
|
artifacts: dict[str, str] = {}
|
||||||
|
model_path = raw_metrics.get("model/path")
|
||||||
|
if isinstance(model_path, str):
|
||||||
|
artifacts["model/path"] = model_path
|
||||||
|
|
||||||
|
return TrainResult(spec=spec, metrics=metrics, artifacts=artifacts, events=events)
|
||||||
@@ -10,6 +10,7 @@ from .lib.coi import (
|
|||||||
)
|
)
|
||||||
from .lib.behavior import get_transition_models, trajectory_to_events
|
from .lib.behavior import get_transition_models, trajectory_to_events
|
||||||
from .lib.wrappers import EconomicMetricsWrapper
|
from .lib.wrappers import EconomicMetricsWrapper
|
||||||
|
from .jax.robust import select_adversarial_alpha_jax, _JAX_OK
|
||||||
|
|
||||||
|
|
||||||
class _ActionPricingEngine(PricingEngine):
|
class _ActionPricingEngine(PricingEngine):
|
||||||
@@ -47,7 +48,10 @@ class PHANTOM(gym.Env):
|
|||||||
coi_window: int = 10,
|
coi_window: int = 10,
|
||||||
robust_radius: float = 0.0,
|
robust_radius: float = 0.0,
|
||||||
robust_points: int = 5,
|
robust_points: int = 5,
|
||||||
|
robust_rollouts: int = 1,
|
||||||
info_value: float = 1.0,
|
info_value: float = 1.0,
|
||||||
|
eta_ux: float = 0.5,
|
||||||
|
reward_profit_weight: float = 1.0,
|
||||||
action_levels: int = 9,
|
action_levels: int = 9,
|
||||||
action_scale_low: float = 0.9,
|
action_scale_low: float = 0.9,
|
||||||
action_scale_high: float = 1.1,
|
action_scale_high: float = 1.1,
|
||||||
@@ -74,7 +78,10 @@ class PHANTOM(gym.Env):
|
|||||||
self.agent_params = agent_params
|
self.agent_params = agent_params
|
||||||
self.robust_radius = max(0.0, float(robust_radius))
|
self.robust_radius = max(0.0, float(robust_radius))
|
||||||
self.robust_points = max(1, int(robust_points))
|
self.robust_points = max(1, int(robust_points))
|
||||||
|
self.robust_rollouts = max(1, int(robust_rollouts))
|
||||||
self.info_value = float(info_value)
|
self.info_value = float(info_value)
|
||||||
|
self.eta_ux = float(eta_ux)
|
||||||
|
self.reward_profit_weight = float(reward_profit_weight)
|
||||||
self.action_levels = max(2, int(action_levels))
|
self.action_levels = max(2, int(action_levels))
|
||||||
self._action_scales = np.linspace(
|
self._action_scales = np.linspace(
|
||||||
float(action_scale_low), float(action_scale_high), self.action_levels
|
float(action_scale_low), float(action_scale_high), self.action_levels
|
||||||
@@ -103,12 +110,19 @@ class PHANTOM(gym.Env):
|
|||||||
shape=(n_products,),
|
shape=(n_products,),
|
||||||
dtype=np.float32,
|
dtype=np.float32,
|
||||||
),
|
),
|
||||||
|
"signals": spaces.Box(
|
||||||
|
low=np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32),
|
||||||
|
high=np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32),
|
||||||
|
shape=(4,),
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self._prices = None
|
self._prices = None
|
||||||
self._demand = None
|
self._demand = None
|
||||||
self._step_count = 0
|
self._step_count = 0
|
||||||
|
self._global_step = 0 # monotonic; used as JAX RNG seed across resets
|
||||||
self._demand_history = []
|
self._demand_history = []
|
||||||
self._price_history = []
|
self._price_history = []
|
||||||
self._revenue_history = []
|
self._revenue_history = []
|
||||||
@@ -116,7 +130,16 @@ class PHANTOM(gym.Env):
|
|||||||
self._initial_episode_prices = None
|
self._initial_episode_prices = None
|
||||||
self._trajectories = [] # session trajectories for agent prob calculation
|
self._trajectories = [] # session trajectories for agent prob calculation
|
||||||
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
|
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
|
||||||
|
self.anchor_prices = np.full(
|
||||||
|
self.n_products,
|
||||||
|
float(np.clip(float(self.human_params[0]), *self.price_bounds)),
|
||||||
|
)
|
||||||
|
self.competitive_cap = float(
|
||||||
|
min(self.price_bounds[1], float(np.mean(self.anchor_prices)) * 1.15)
|
||||||
|
)
|
||||||
self._low_margin_streak = 0 # consecutive steps below margin_floor
|
self._low_margin_streak = 0 # consecutive steps below margin_floor
|
||||||
|
self._last_agent_prob = float(self.alpha)
|
||||||
|
self._last_alpha_adv = float(self.alpha)
|
||||||
|
|
||||||
# load behavioral models for agent probability estimation
|
# load behavioral models for agent probability estimation
|
||||||
try:
|
try:
|
||||||
@@ -129,7 +152,20 @@ class PHANTOM(gym.Env):
|
|||||||
demand_arr = np.array(
|
demand_arr = np.array(
|
||||||
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
|
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
|
||||||
)
|
)
|
||||||
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
|
signals = np.array(
|
||||||
|
[
|
||||||
|
float(np.clip(self._last_agent_prob, 0.0, 1.0)),
|
||||||
|
float(np.clip(self._last_alpha_adv, 0.0, 1.0)),
|
||||||
|
float(np.clip(self.nominal_alpha, 0.0, 1.0)),
|
||||||
|
float(np.clip(self.robust_radius, 0.0, 1.0)),
|
||||||
|
],
|
||||||
|
dtype=np.float32,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"demand": demand_arr,
|
||||||
|
"prices": self._prices.astype(np.float32),
|
||||||
|
"signals": signals,
|
||||||
|
}
|
||||||
|
|
||||||
def _set_market_mix(self, alpha: float):
|
def _set_market_mix(self, alpha: float):
|
||||||
alpha = float(np.clip(alpha, 0.0, 1.0))
|
alpha = float(np.clip(alpha, 0.0, 1.0))
|
||||||
@@ -140,19 +176,28 @@ class PHANTOM(gym.Env):
|
|||||||
self.market.Nhumans = self.N - n_agents
|
self.market.Nhumans = self.N - n_agents
|
||||||
|
|
||||||
def _decode_action(self, action) -> np.ndarray:
|
def _decode_action(self, action) -> np.ndarray:
|
||||||
base = (
|
prev = self._prices
|
||||||
self._prices
|
base = self.anchor_prices
|
||||||
if self._prices is not None
|
|
||||||
else np.full(self.n_products, self.price_bounds[0], dtype=float)
|
def _blend(target: np.ndarray) -> np.ndarray:
|
||||||
)
|
if prev is None:
|
||||||
|
lower = float(self.price_bounds[0])
|
||||||
|
return np.clip(target, lower, self.competitive_cap)
|
||||||
|
blended = 0.75 * np.asarray(prev, dtype=float) + 0.25 * target
|
||||||
|
lower = float(self.price_bounds[0])
|
||||||
|
return np.clip(blended, lower, self.competitive_cap)
|
||||||
|
|
||||||
if np.isscalar(action):
|
if np.isscalar(action):
|
||||||
idx = int(np.clip(int(action), 0, self.action_levels - 1))
|
idx = int(np.clip(int(action), 0, self.action_levels - 1))
|
||||||
return np.clip(base * self._action_scales[idx], *self.price_bounds)
|
target = base * self._action_scales[idx]
|
||||||
|
return _blend(target)
|
||||||
a = np.asarray(action)
|
a = np.asarray(action)
|
||||||
if a.size == 1:
|
if a.size == 1:
|
||||||
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
|
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
|
||||||
return np.clip(base * self._action_scales[idx], *self.price_bounds)
|
target = base * self._action_scales[idx]
|
||||||
return np.clip(a.astype(float), *self.price_bounds)
|
return _blend(target)
|
||||||
|
lower = float(self.price_bounds[0])
|
||||||
|
return np.clip(a.astype(float), lower, self.competitive_cap)
|
||||||
|
|
||||||
def _compute_agent_prob(self, trajectories=None) -> float:
|
def _compute_agent_prob(self, trajectories=None) -> float:
|
||||||
trajectories = (
|
trajectories = (
|
||||||
@@ -177,20 +222,51 @@ class PHANTOM(gym.Env):
|
|||||||
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
|
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
|
||||||
)
|
)
|
||||||
revenue = float(np.dot(prices, demand_arr))
|
revenue = float(np.dot(prices, demand_arr))
|
||||||
|
floor_cost = float(np.dot(self.baseline_prices, demand_arr))
|
||||||
|
profit = revenue - floor_cost
|
||||||
purchases = extract_purchases(trajectories)
|
purchases = extract_purchases(trajectories)
|
||||||
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
|
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
|
||||||
# multiplicative penalty so COI term scales with revenue magnitude
|
|
||||||
coi_leakage = float(agent_prob * self.info_value)
|
coi_leakage = float(agent_prob * self.info_value)
|
||||||
discount = float(np.clip(1.0 - self.lambda_coi * coi_leakage, 0.0, 1.0))
|
info_budget = max(floor_cost, 1.0)
|
||||||
coi_penalty = revenue * (1.0 - discount) # absolute penalty in revenue units
|
coi_penalty = self.lambda_coi * coi_leakage * info_budget
|
||||||
reward = revenue * discount
|
|
||||||
|
if len(self._price_history) > 0:
|
||||||
|
prev_prices = np.asarray(self._price_history[-1], dtype=float)
|
||||||
|
rel_change = (prices - prev_prices) / np.maximum(prev_prices, 1.0)
|
||||||
|
volatility = float(np.mean(np.abs(rel_change)))
|
||||||
|
upward_volatility = float(np.mean(np.clip(rel_change, 0.0, None)))
|
||||||
|
else:
|
||||||
|
volatility = 0.0
|
||||||
|
upward_volatility = 0.0
|
||||||
|
ux_penalty = self.eta_ux * info_budget * (volatility + 0.5 * upward_volatility)
|
||||||
|
|
||||||
|
competitive_anchor = float(np.mean(self.anchor_prices))
|
||||||
|
price_ratio = prices / max(competitive_anchor, 1.0)
|
||||||
|
supra_excess = np.clip(price_ratio - 1.15, 0.0, None)
|
||||||
|
supra_penalty = 4.0 * info_budget * float(np.mean(np.square(supra_excess)))
|
||||||
|
supra_share = float(np.mean(supra_excess > 0.0))
|
||||||
|
|
||||||
|
reward_revenue = self.reward_profit_weight * profit
|
||||||
|
reward = reward_revenue - coi_penalty - ux_penalty - supra_penalty
|
||||||
|
|
||||||
return reward, {
|
return reward, {
|
||||||
"revenue": revenue,
|
"revenue": revenue,
|
||||||
|
"cost_floor": floor_cost,
|
||||||
|
"profit": profit,
|
||||||
"coi_mix": float(coi_mix),
|
"coi_mix": float(coi_mix),
|
||||||
"coi_base": 0.0,
|
"coi_base": 0.0,
|
||||||
"coi_leakage": coi_leakage,
|
"coi_leakage": coi_leakage,
|
||||||
"coi_penalty": coi_penalty,
|
"coi_penalty": coi_penalty,
|
||||||
"coi_discount": discount,
|
"coi_info_budget": info_budget,
|
||||||
|
"ux_penalty": ux_penalty,
|
||||||
|
"volatility": volatility,
|
||||||
|
"upward_volatility": upward_volatility,
|
||||||
|
"supra_penalty": supra_penalty,
|
||||||
|
"supra_share": supra_share,
|
||||||
|
"competitive_anchor": competitive_anchor,
|
||||||
|
"reward_revenue": reward_revenue,
|
||||||
|
"reward_total": reward,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _alpha_candidates(self) -> np.ndarray:
|
def _alpha_candidates(self) -> np.ndarray:
|
||||||
@@ -200,28 +276,55 @@ class PHANTOM(gym.Env):
|
|||||||
hi = min(1.0, self.nominal_alpha + self.robust_radius)
|
hi = min(1.0, self.nominal_alpha + self.robust_radius)
|
||||||
return np.linspace(lo, hi, self.robust_points)
|
return np.linspace(lo, hi, self.robust_points)
|
||||||
|
|
||||||
def _select_adversarial_alpha(
|
def _evaluate_candidate(self, alpha: float, prices: np.ndarray) -> float:
|
||||||
self, prices: np.ndarray
|
self._set_market_mix(alpha)
|
||||||
) -> tuple[float, dict, list, float]:
|
rewards = []
|
||||||
"""inner robust step: pick worst-case alpha and return its outcome directly to avoid double-sampling"""
|
for _ in range(self.robust_rollouts):
|
||||||
candidates = self._alpha_candidates()
|
|
||||||
best_alpha, worst_reward = float(candidates[0]), np.inf
|
|
||||||
best_demand, best_trajectories, best_agent_prob = None, [], 0.0
|
|
||||||
for alpha in candidates:
|
|
||||||
self._set_market_mix(float(alpha))
|
|
||||||
demand = self.market.act(prices)
|
demand = self.market.act(prices)
|
||||||
trajectories = list(self.market.last_trajectories)
|
trajectories = list(self.market.last_trajectories)
|
||||||
agent_prob = self._compute_agent_prob(trajectories)
|
agent_prob = self._compute_agent_prob(trajectories)
|
||||||
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
|
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
|
||||||
if reward < worst_reward:
|
rewards.append(float(reward))
|
||||||
worst_reward = reward
|
return float(np.mean(rewards)) if rewards else 0.0
|
||||||
best_alpha, best_demand, best_trajectories, best_agent_prob = (
|
|
||||||
float(alpha),
|
def _select_adversarial_alpha(self, prices: np.ndarray) -> float:
|
||||||
demand,
|
"""inner robust step: pick worst-case alpha from the ambiguity interval.
|
||||||
trajectories,
|
|
||||||
agent_prob,
|
when JAX is available and robust_rollouts==1 we use a vmapped pass over
|
||||||
)
|
all K candidates in a single call (no Python loop, no market.act overhead).
|
||||||
return best_alpha, best_demand, best_trajectories, best_agent_prob
|
the JAX path approximates demand as the mixed closed-form d(p;theta) signal
|
||||||
|
rather than running full trajectory sampling, which is accurate for the
|
||||||
|
alpha-selection decision while being dramatically cheaper.
|
||||||
|
|
||||||
|
when robust_rollouts>1 or JAX is unavailable we fall back to the sequential
|
||||||
|
market.act() loop so behavior is identical to the original implementation.
|
||||||
|
"""
|
||||||
|
candidates = self._alpha_candidates()
|
||||||
|
if len(candidates) == 1:
|
||||||
|
return float(candidates[0])
|
||||||
|
|
||||||
|
if _JAX_OK and self.robust_rollouts == 1:
|
||||||
|
best_alpha, _ = select_adversarial_alpha_jax(
|
||||||
|
candidates=candidates,
|
||||||
|
prices=prices,
|
||||||
|
human_params=self.market.human_params,
|
||||||
|
agent_params=self.market.agent_params,
|
||||||
|
noise_std=self.market.noise_std,
|
||||||
|
baseline_prices=self.baseline_prices,
|
||||||
|
lambda_coi=self.lambda_coi,
|
||||||
|
info_value=self.info_value,
|
||||||
|
reward_profit_weight=self.reward_profit_weight,
|
||||||
|
rng_seed=self._global_step,
|
||||||
|
)
|
||||||
|
return best_alpha
|
||||||
|
|
||||||
|
# fallback: full trajectory-based sequential evaluation
|
||||||
|
evaluations = [
|
||||||
|
(float(alpha), self._evaluate_candidate(float(alpha), prices))
|
||||||
|
for alpha in candidates
|
||||||
|
]
|
||||||
|
best_alpha, _ = min(evaluations, key=lambda x: x[1])
|
||||||
|
return best_alpha
|
||||||
|
|
||||||
def _record_history(self):
|
def _record_history(self):
|
||||||
demand_arr = np.array(
|
demand_arr = np.array(
|
||||||
@@ -244,19 +347,25 @@ class PHANTOM(gym.Env):
|
|||||||
self._low_margin_streak = 0
|
self._low_margin_streak = 0
|
||||||
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
||||||
self._trajectories = list(getattr(self.market, "last_trajectories", []))
|
self._trajectories = list(getattr(self.market, "last_trajectories", []))
|
||||||
|
self._last_agent_prob = float(self.nominal_alpha)
|
||||||
|
self._last_alpha_adv = float(self.nominal_alpha)
|
||||||
self._record_history()
|
self._record_history()
|
||||||
return self._get_obs(), {}
|
return self._get_obs(), {}
|
||||||
|
|
||||||
def step(self, action):
|
def step(self, action):
|
||||||
self._prices = self._decode_action(action)
|
self._prices = self._decode_action(action)
|
||||||
# inner robust step returns worst-case outcome directly, no re-sampling
|
alpha_adv = self._select_adversarial_alpha(self._prices)
|
||||||
alpha_adv, self._demand, trajectories, agent_prob = (
|
self._global_step += 1 # always increment; JAX path may have already done so
|
||||||
self._select_adversarial_alpha(self._prices)
|
|
||||||
)
|
|
||||||
self._set_market_mix(alpha_adv)
|
self._set_market_mix(alpha_adv)
|
||||||
self._platform_stub.set_prices(self._prices)
|
self._platform_stub.set_prices(self._prices)
|
||||||
self._step_count += 1
|
self._step_count += 1
|
||||||
|
|
||||||
|
self._demand = self.market.act(self._prices)
|
||||||
|
trajectories = list(self.market.last_trajectories)
|
||||||
|
agent_prob = self._compute_agent_prob(trajectories)
|
||||||
self._trajectories.extend(trajectories)
|
self._trajectories.extend(trajectories)
|
||||||
|
self._last_agent_prob = float(agent_prob)
|
||||||
|
self._last_alpha_adv = float(alpha_adv)
|
||||||
|
|
||||||
reward, metrics = self._compute_reward(
|
reward, metrics = self._compute_reward(
|
||||||
self._prices, self._demand, agent_prob, trajectories
|
self._prices, self._demand, agent_prob, trajectories
|
||||||
@@ -278,7 +387,9 @@ class PHANTOM(gym.Env):
|
|||||||
"step": self._step_count,
|
"step": self._step_count,
|
||||||
"agent_prob": agent_prob,
|
"agent_prob": agent_prob,
|
||||||
"alpha_adv": float(alpha_adv),
|
"alpha_adv": float(alpha_adv),
|
||||||
|
"alpha_nominal": float(self.nominal_alpha),
|
||||||
"wasserstein_radius": float(self.robust_radius),
|
"wasserstein_radius": float(self.robust_radius),
|
||||||
|
"robust_rollouts": int(self.robust_rollouts),
|
||||||
**metrics,
|
**metrics,
|
||||||
"raw_revenue": np.sum(
|
"raw_revenue": np.sum(
|
||||||
self._prices
|
self._prices
|
||||||
@@ -355,7 +466,7 @@ if __name__ == "__main__":
|
|||||||
def predict(self, obs, **kwargs):
|
def predict(self, obs, **kwargs):
|
||||||
return self.env.action_space.sample(), None
|
return self.env.action_space.sample(), None
|
||||||
|
|
||||||
wandb.init(project="phantom-pricing", config={"policy": "random", "alpha": 0.3})
|
wandb.init(project="capstone", config={"policy": "random", "alpha": 0.3})
|
||||||
env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None))
|
env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None))
|
||||||
|
|
||||||
model = RandomPolicy(env)
|
model = RandomPolicy(env)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
All hardcoded paths should reference this module
|
All hardcoded paths should reference this module
|
||||||
Paths can be overridden via environment variables
|
Paths can be overridden via environment variables
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -9,24 +10,34 @@ from pathlib import Path
|
|||||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
# data directories
|
# data directories
|
||||||
DATA_DIR = Path(os.getenv('PHANTOM_DATA_DIR', PROJECT_ROOT / 'data'))
|
DATA_DIR = Path(os.getenv("PHANTOM_DATA_DIR", PROJECT_ROOT / "data"))
|
||||||
EXPERIMENTS_DIR = Path(os.getenv('PHANTOM_EXPERIMENTS_DIR', PROJECT_ROOT / 'experiments'))
|
EXPERIMENTS_DIR = Path(
|
||||||
|
os.getenv("PHANTOM_EXPERIMENTS_DIR", PROJECT_ROOT / "experiments")
|
||||||
|
)
|
||||||
|
|
||||||
# agent/human interaction data
|
# agent/human interaction data
|
||||||
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', DATA_DIR / 'agents'))
|
AGENT_DATA_DIR = Path(os.getenv("PHANTOM_AGENT_DATA_DIR", DATA_DIR / "agents"))
|
||||||
HUMAN_DATA_DIR = Path(os.getenv('PHANTOM_HUMAN_DATA_DIR', DATA_DIR / 'humans'))
|
HUMAN_DATA_DIR = Path(os.getenv("PHANTOM_HUMAN_DATA_DIR", DATA_DIR / "humans"))
|
||||||
|
|
||||||
# RL simulation runs
|
# RL simulation runs
|
||||||
SIM_RUNS_DIR = Path(os.getenv('PHANTOM_SIM_RUNS_DIR', PROJECT_ROOT / 'sim' / 'rl' / 'runs'))
|
SIM_RUNS_DIR = Path(
|
||||||
|
os.getenv("PHANTOM_SIM_RUNS_DIR", PROJECT_ROOT / "sim" / "rl" / "runs")
|
||||||
|
)
|
||||||
|
|
||||||
# model artifacts
|
# model artifacts
|
||||||
MODEL_REGISTRY_DIR = Path(os.getenv('PHANTOM_MODEL_REGISTRY_DIR', DATA_DIR / 'models'))
|
MODEL_REGISTRY_DIR = Path(os.getenv("PHANTOM_MODEL_REGISTRY_DIR", DATA_DIR / "models"))
|
||||||
|
|
||||||
# collected experiment data
|
# collected experiment data
|
||||||
COLLECTED_DATA_DIR = Path(os.getenv('PHANTOM_COLLECTED_DATA_DIR', EXPERIMENTS_DIR / 'agents' / 'collected_data'))
|
COLLECTED_DATA_DIR = Path(
|
||||||
|
os.getenv(
|
||||||
|
"PHANTOM_COLLECTED_DATA_DIR", EXPERIMENTS_DIR / "agents" / "collected_data"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# notebook outputs
|
# notebook outputs
|
||||||
NOTEBOOK_OUTPUT_DIR = Path(os.getenv('PHANTOM_NOTEBOOK_OUTPUT_DIR', EXPERIMENTS_DIR / 'notebooks' / 'outputs'))
|
NOTEBOOK_OUTPUT_DIR = Path(
|
||||||
|
os.getenv("PHANTOM_NOTEBOOK_OUTPUT_DIR", EXPERIMENTS_DIR / "notebooks" / "outputs")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def ensure_dir(path: Path) -> Path:
|
def ensure_dir(path: Path) -> Path:
|
||||||
@@ -51,15 +62,18 @@ def get_sim_path(*parts: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
# service configuration (from .env)
|
# service configuration (from .env)
|
||||||
KAFKA_HOST = os.getenv('KAFKA_HOST', 'localhost')
|
KAFKA_HOST = os.getenv("KAFKA_HOST", "localhost")
|
||||||
KAFKA_PORT = os.getenv('KAFKA_PORT', '9092')
|
KAFKA_PORT = os.getenv("KAFKA_PORT", "9092")
|
||||||
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
|
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
|
||||||
|
|
||||||
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
|
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||||
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
|
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv('NEXT_PUBLIC_SUPABASE_URL', '')
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||||
SUPABASE_ANON_KEY = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
|
SUPABASE_ANON_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||||
|
|
||||||
BACKEND_PORT = int(os.getenv('BACKEND_PORT', '5000'))
|
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "5000"))
|
||||||
PROVIDER_PORT = int(os.getenv('PROVIDER_PORT', '5001'))
|
PROVIDER_PORT = int(os.getenv("PROVIDER_PORT", "5001"))
|
||||||
|
|
||||||
|
# huggingface dataset repo for collected behavioral data
|
||||||
|
HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "velocitatem/phantom-collected-data")
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Iterable, List, Sequence
|
from typing import Dict, Iterable, List, Sequence
|
||||||
|
|
||||||
import joblib
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from experiments.ml.arch import featurize_trajectory
|
from lib.agent_probability import DEFAULT_AGENT_PRIOR, estimate_agent_probability
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ARTIFACT_DIR = Path("data/separability")
|
DEFAULT_ARTIFACT_DIR = Path("data/separability")
|
||||||
@@ -18,11 +17,7 @@ DEFAULT_ARTIFACT_DIR = Path("data/separability")
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SeparabilityArtifacts:
|
class SeparabilityArtifacts:
|
||||||
scaler: object
|
|
||||||
classifier: object
|
|
||||||
states: List[str]
|
|
||||||
event_transitions: Dict[str, Dict[str, float]]
|
event_transitions: Dict[str, Dict[str, float]]
|
||||||
feature_dim: int
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_events(raw_events: Sequence[object]) -> List[object]:
|
def _normalize_events(raw_events: Sequence[object]) -> List[object]:
|
||||||
@@ -36,7 +31,9 @@ def _normalize_events(raw_events: Sequence[object]) -> List[object]:
|
|||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def _event_transition_distribution(events: Sequence[object]) -> Dict[str, Dict[str, float]]:
|
def _event_transition_distribution(
|
||||||
|
events: Sequence[object],
|
||||||
|
) -> Dict[str, Dict[str, float]]:
|
||||||
counts: Dict[str, Dict[str, int]] = {}
|
counts: Dict[str, Dict[str, int]] = {}
|
||||||
for src_evt, dst_evt in zip(events, events[1:]):
|
for src_evt, dst_evt in zip(events, events[1:]):
|
||||||
src_name = getattr(src_evt, "eventName", "unknown")
|
src_name = getattr(src_evt, "eventName", "unknown")
|
||||||
@@ -47,11 +44,15 @@ def _event_transition_distribution(events: Sequence[object]) -> Dict[str, Dict[s
|
|||||||
distribution: Dict[str, Dict[str, float]] = {}
|
distribution: Dict[str, Dict[str, float]] = {}
|
||||||
for src, dsts in counts.items():
|
for src, dsts in counts.items():
|
||||||
total = float(sum(dsts.values()))
|
total = float(sum(dsts.values()))
|
||||||
distribution[src] = {dst: val / total for dst, val in dsts.items()} if total else {}
|
distribution[src] = (
|
||||||
|
{dst: val / total for dst, val in dsts.items()} if total else {}
|
||||||
|
)
|
||||||
return distribution
|
return distribution
|
||||||
|
|
||||||
|
|
||||||
def _kl_divergence(p: Dict[str, Dict[str, float]], q: Dict[str, Dict[str, float]]) -> float:
|
def _kl_divergence(
|
||||||
|
p: Dict[str, Dict[str, float]], q: Dict[str, Dict[str, float]]
|
||||||
|
) -> float:
|
||||||
eps = 1e-10
|
eps = 1e-10
|
||||||
total = 0.0
|
total = 0.0
|
||||||
for src, dsts in p.items():
|
for src, dsts in p.items():
|
||||||
@@ -61,28 +62,28 @@ def _kl_divergence(p: Dict[str, Dict[str, float]], q: Dict[str, Dict[str, float]
|
|||||||
return float(total)
|
return float(total)
|
||||||
|
|
||||||
|
|
||||||
def load_artifacts(artifact_dir: Path | str = DEFAULT_ARTIFACT_DIR) -> SeparabilityArtifacts:
|
def load_artifacts(
|
||||||
|
artifact_dir: Path | str = DEFAULT_ARTIFACT_DIR,
|
||||||
|
) -> SeparabilityArtifacts:
|
||||||
artifact_dir = Path(artifact_dir)
|
artifact_dir = Path(artifact_dir)
|
||||||
scaler_path = artifact_dir / "scaler.joblib"
|
|
||||||
model_path = artifact_dir / "classifier.joblib"
|
|
||||||
metadata_path = artifact_dir / "metadata.json"
|
metadata_path = artifact_dir / "metadata.json"
|
||||||
|
|
||||||
if not (scaler_path.exists() and model_path.exists() and metadata_path.exists()):
|
if not metadata_path.exists():
|
||||||
raise FileNotFoundError(
|
raise FileNotFoundError(
|
||||||
f"Separability artifacts not found in {artifact_dir}. Run sim.strong_learner.train first."
|
f"Separability metadata not found in {artifact_dir}. Provide metadata.json with event transitions."
|
||||||
)
|
)
|
||||||
|
|
||||||
scaler = joblib.load(scaler_path)
|
|
||||||
classifier = joblib.load(model_path)
|
|
||||||
with open(metadata_path, "r", encoding="utf-8") as fin:
|
with open(metadata_path, "r", encoding="utf-8") as fin:
|
||||||
metadata = json.load(fin)
|
metadata = json.load(fin)
|
||||||
|
|
||||||
|
transitions = metadata.get("event_transitions")
|
||||||
|
if not isinstance(transitions, dict):
|
||||||
|
raise ValueError(
|
||||||
|
"metadata.json must contain an 'event_transitions' object with 'human' and 'agent' kernels"
|
||||||
|
)
|
||||||
|
|
||||||
return SeparabilityArtifacts(
|
return SeparabilityArtifacts(
|
||||||
scaler=scaler,
|
event_transitions=transitions,
|
||||||
classifier=classifier,
|
|
||||||
states=list(metadata["reference_states"]),
|
|
||||||
event_transitions=metadata["event_transitions"],
|
|
||||||
feature_dim=int(metadata["feature_dim"]),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -92,37 +93,44 @@ def score_session(
|
|||||||
) -> dict:
|
) -> dict:
|
||||||
events = _normalize_events(raw_events)
|
events = _normalize_events(raw_events)
|
||||||
if not events:
|
if not events:
|
||||||
return {"prob_agent": 0.0, "delta_h": 0.0, "delta_a": 0.0}
|
return {
|
||||||
|
"prob_agent": float(DEFAULT_AGENT_PRIOR),
|
||||||
reference_mdp = {"states": artifacts.states}
|
"delta_h": 0.0,
|
||||||
features = featurize_trajectory(events, mdp=reference_mdp, input_dim=artifacts.feature_dim)
|
"delta_a": 0.0,
|
||||||
scaled = artifacts.scaler.transform(features.reshape(1, -1))
|
"gap": 0.0,
|
||||||
prob_agent = float(artifacts.classifier.predict_proba(scaled)[0, 1])
|
}
|
||||||
|
|
||||||
session_dist = _event_transition_distribution(events)
|
session_dist = _event_transition_distribution(events)
|
||||||
delta_h = _kl_divergence(session_dist, artifacts.event_transitions.get("human", {}))
|
delta_h = _kl_divergence(session_dist, artifacts.event_transitions.get("human", {}))
|
||||||
delta_a = _kl_divergence(session_dist, artifacts.event_transitions.get("agent", {}))
|
delta_a = _kl_divergence(session_dist, artifacts.event_transitions.get("agent", {}))
|
||||||
|
gap = float(delta_h - delta_a)
|
||||||
|
prob_agent = estimate_agent_probability(delta_h=delta_h, delta_a=delta_a)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"prob_agent": prob_agent,
|
"prob_agent": prob_agent,
|
||||||
"delta_h": delta_h,
|
"delta_h": delta_h,
|
||||||
"delta_a": delta_a,
|
"delta_a": delta_a,
|
||||||
|
"gap": gap,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def estimate_alpha(prob_agent: float, delta_h: float, delta_a: float, temperature: float = 1.0) -> float:
|
def estimate_alpha(
|
||||||
divergence_mass = delta_h + delta_a
|
prob_agent: float,
|
||||||
if divergence_mass <= 1e-8:
|
delta_h: float,
|
||||||
return float(prob_agent)
|
delta_a: float,
|
||||||
|
temperature: float = 1.0,
|
||||||
ratio = delta_a / divergence_mass
|
prior_agent: float = DEFAULT_AGENT_PRIOR,
|
||||||
blended = 0.5 * prob_agent + 0.5 * ratio
|
) -> float:
|
||||||
if temperature <= 0:
|
_ = prob_agent
|
||||||
return float(np.clip(blended, 0.0, 1.0))
|
return estimate_agent_probability(
|
||||||
|
delta_h=delta_h,
|
||||||
scaled = 1.0 / (1.0 + np.exp(-temperature * (blended - 0.5)))
|
delta_a=delta_a,
|
||||||
return float(np.clip(scaled, 0.0, 1.0))
|
temperature=temperature,
|
||||||
|
prior_agent=prior_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def score_sessions(raw_sessions: Iterable[Sequence[object]], artifacts: SeparabilityArtifacts) -> List[dict]:
|
def score_sessions(
|
||||||
|
raw_sessions: Iterable[Sequence[object]], artifacts: SeparabilityArtifacts
|
||||||
|
) -> List[dict]:
|
||||||
return [score_session(events, artifacts) for events in raw_sessions]
|
return [score_session(events, artifacts) for events in raw_sessions]
|
||||||
|
|||||||
86
nx.json
Normal file
86
nx.json
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/nx/schemas/nx-schema.json",
|
||||||
|
"useInferencePlugins": false,
|
||||||
|
"defaultBase": "main",
|
||||||
|
"namedInputs": {
|
||||||
|
"sharedGlobals": [
|
||||||
|
"{workspaceRoot}/nx.json",
|
||||||
|
"{workspaceRoot}/package.json",
|
||||||
|
"{workspaceRoot}/Makefile",
|
||||||
|
"{workspaceRoot}/pyproject.toml",
|
||||||
|
"{workspaceRoot}/docker-compose.yml"
|
||||||
|
],
|
||||||
|
"default": [
|
||||||
|
"{projectRoot}/**/*",
|
||||||
|
"sharedGlobals"
|
||||||
|
],
|
||||||
|
"production": [
|
||||||
|
"default",
|
||||||
|
"!{projectRoot}/node_modules/**/*",
|
||||||
|
"!{projectRoot}/.next/**/*",
|
||||||
|
"!{projectRoot}/test-results/**/*",
|
||||||
|
"!{projectRoot}/build/**/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"targetDefaults": {
|
||||||
|
"build": {
|
||||||
|
"cache": true,
|
||||||
|
"inputs": [
|
||||||
|
"production",
|
||||||
|
"^production"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"cache": false,
|
||||||
|
"inputs": [
|
||||||
|
"default",
|
||||||
|
"^production"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"install": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"dev": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"start": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"train": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"benchmark": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"whoclicked-publish": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"tpu-ray-bootstrap": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"tpu-ray-deps": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"tpu-ray-verify": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"tpu-ray-teardown": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"up": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"down": {
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "phantom-monorepo",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": [
|
||||||
|
"web",
|
||||||
|
"tests/e2e"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"nx": "nx",
|
||||||
|
"manim:render": "nx run manim:render",
|
||||||
|
"manim:render-all": "nx run manim:render-all",
|
||||||
|
"projects": "nx show projects",
|
||||||
|
"graph": "nx graph",
|
||||||
|
"web:dev": "nx run web:dev",
|
||||||
|
"web:build": "nx run web:build",
|
||||||
|
"backend:server": "nx run backend-server:dev",
|
||||||
|
"backend:provider": "nx run pricing-provider:dev",
|
||||||
|
"backend:worker": "nx run backend-worker:dev",
|
||||||
|
"paper:build": "nx run paper:build",
|
||||||
|
"platform:up": "nx run platform:up",
|
||||||
|
"platform:down": "nx run platform:down",
|
||||||
|
"platform:logs": "nx run platform:logs",
|
||||||
|
"research:test": "nx run research:test",
|
||||||
|
"research:benchmark": "nx run research:benchmark",
|
||||||
|
"research:benchmark:simple": "nx run research:benchmark-simple",
|
||||||
|
"e2e:test": "nx run e2e:test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nx": "^20.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
paper/defense/manim/requirements.txt
Normal file
2
paper/defense/manim/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
manim>=0.18,<1
|
||||||
|
numpy>=1.24
|
||||||
53
paper/project.json
Normal file
53
paper/project.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"name": "paper",
|
||||||
|
"projectType": "application",
|
||||||
|
"sourceRoot": "paper/src",
|
||||||
|
"targets": {
|
||||||
|
"build": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"outputs": [
|
||||||
|
"{projectRoot}/build"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_paper.sh build",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"watch": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_paper.sh watch",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clean": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_paper.sh clean",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wordcount": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_paper.sh wordcount",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"build-arxiv": {
|
||||||
|
"executor": "nx:run-commands",
|
||||||
|
"outputs": [
|
||||||
|
"{projectRoot}/build/main-arxiv.pdf"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"command": "bash scripts/nx_paper.sh build-arxiv",
|
||||||
|
"cwd": "."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"scope:paper",
|
||||||
|
"type:latex"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@
|
|||||||
"chapters/05-discussion"
|
"chapters/05-discussion"
|
||||||
"chapters/06-conclusion"
|
"chapters/06-conclusion"
|
||||||
"article"
|
"article"
|
||||||
"art12"))
|
"art12")
|
||||||
|
(LaTeX-add-labels
|
||||||
|
"app:compute_budget"
|
||||||
|
"tab:compute_derivation"
|
||||||
|
"app:whoclicked_card"))
|
||||||
:latex)
|
:latex)
|
||||||
|
|
||||||
|
|||||||
@@ -616,3 +616,55 @@ Volume: 21},
|
|||||||
year = {2026},
|
year = {2026},
|
||||||
file = {Snapshot:/home/velocitatem/Zotero/storage/N724QGF6/v4.html:text/html},
|
file = {Snapshot:/home/velocitatem/Zotero/storage/N724QGF6/v4.html:text/html},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@article{mann_test_1947,
|
||||||
|
title = {On a {Test} of {Whether} one of {Two} {Random} {Variables} is {Stochastically} {Larger} than the {Other}},
|
||||||
|
volume = {18},
|
||||||
|
url = {https://doi.org/10.1214/aoms/1177730491},
|
||||||
|
doi = {10.1214/aoms/1177730491},
|
||||||
|
abstract = {Let x and y be two random variables with continuous cumulative distribution functions f and g. A statistic U depending on the relative ranks of the x's and y's is proposed for testing the hypothesis f = g. Wilcoxon proposed an equivalent test in the Biometrics Bulletin, December, 1945, but gave only a few points of the distribution of his statistic. Under the hypothesis f = g the probability of obtaining a given U in a sample of n x's and m y's is the solution of a certain recurrence relation involving n and m. Using this recurrence relation tables have been computed giving the probability of U for samples up to n = m = 8. At this point the distribution is almost normal. From the recurrence relation explicit expressions for the mean, variance, and fourth moment are obtained. The 2rth moment is shown to have a certain form which enabled us to prove that the limit distribution is normal if m, n go to infinity in any arbitrary manner. The test is shown to be consistent with respect to the class of alternatives f(x) {\textgreater} g(x) for every x.},
|
||||||
|
number = {1},
|
||||||
|
journal = {The Annals of Mathematical Statistics},
|
||||||
|
author = {Mann, H. B. and Whitney, D. R.},
|
||||||
|
year = {1947},
|
||||||
|
note = {Publisher: Institute of Mathematical Statistics},
|
||||||
|
pages = {50 -- 60},
|
||||||
|
}
|
||||||
|
|
||||||
|
@article{horace_he_and_thinking_machines_lab_defeating_2025,
|
||||||
|
title = {Defeating {Nondeterminism} in {LLM} {Inference}},
|
||||||
|
url = {https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/},
|
||||||
|
doi = {10.64434/tml.20250910},
|
||||||
|
abstract = {Reproducibility is a bedrock of scientific progress. However, it’s remarkably difficult to get reproducible results out of large language models.
|
||||||
|
For example, you might observe that asking ChatGPT the same question multiple times provides different results. This by itself is not surprising, since getting a result from a language model involves “sampling”, a process that converts the language model’s output into a probability distribution and probabilistically selects a token.
|
||||||
|
What might be more surprising is that even when we adjust the temperature down to 0This means that the LLM always chooses the highest probability token, which is called greedy sampling. (thus making the sampling theoretically deterministic), LLM APIs are still not deterministic in practice (see past discussions here, here, or here). Even when running inference on your own hardware with an OSS inference library like vLLM or SGLang, sampling still isn’t deterministic (see here or here).},
|
||||||
|
language = {en},
|
||||||
|
urldate = {2026-03-10},
|
||||||
|
journal = {Thinking Machines Lab: Connectionism},
|
||||||
|
author = {{Horace He and Thinking Machines Lab}},
|
||||||
|
year = {2025},
|
||||||
|
file = {Snapshot:/home/velocitatem/Zotero/storage/U5JG4CNM/defeating-nondeterminism-in-llm-inference.html:text/html},
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{moritz_ray_2018,
|
||||||
|
title = {Ray: {A} {Distributed} {Framework} for {Emerging} {AI} {Applications}},
|
||||||
|
shorttitle = {Ray},
|
||||||
|
url = {http://arxiv.org/abs/1712.05889},
|
||||||
|
doi = {10.48550/arXiv.1712.05889},
|
||||||
|
abstract = {The next generation of AI applications will continuously interact with the environment and learn from these interactions. These applications impose new and demanding systems requirements, both in terms of performance and flexibility. In this paper, we consider these requirements and present Ray---a distributed system to address them. Ray implements a unified interface that can express both task-parallel and actor-based computations, supported by a single dynamic execution engine. To meet the performance requirements, Ray employs a distributed scheduler and a distributed and fault-tolerant store to manage the system's control state. In our experiments, we demonstrate scaling beyond 1.8 million tasks per second and better performance than existing specialized systems for several challenging reinforcement learning applications.},
|
||||||
|
urldate = {2026-03-13},
|
||||||
|
publisher = {arXiv},
|
||||||
|
author = {Moritz, Philipp and Nishihara, Robert and Wang, Stephanie and Tumanov, Alexey and Liaw, Richard and Liang, Eric and Elibol, Melih and Yang, Zongheng and Paul, William and Jordan, Michael I. and Stoica, Ion},
|
||||||
|
month = sep,
|
||||||
|
year = {2018},
|
||||||
|
note = {arXiv:1712.05889 [cs]},
|
||||||
|
keywords = {Computer Science - Machine Learning, Statistics - Machine Learning, Computer Science - Artificial Intelligence, Computer Science - Distributed, Parallel, and Cluster Computing},
|
||||||
|
file = {Preprint PDF:/home/velocitatem/Zotero/storage/SUTDF5BP/Moritz et al. - 2018 - Ray A Distributed Framework for Emerging AI Applications.pdf:application/pdf;Snapshot:/home/velocitatem/Zotero/storage/5GV2DUAA/1712.html:text/html},
|
||||||
|
}
|
||||||
|
|
||||||
|
@misc{biewald_experiment_2020,
|
||||||
|
title = {Experiment {Tracking} with {Weights} and {Biases}},
|
||||||
|
url = {https://www.wandb.com/},
|
||||||
|
author = {Biewald, Lukas},
|
||||||
|
year = {2020},
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
|
|
||||||
\section{Introduction}
|
\section{Introduction}
|
||||||
|
|
||||||
In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove separability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
In this paper we present an exploration and defense against the presence of new commercial entities in digitally powered platforms, preserving market equilibrium in the age of AI. This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model (to prove distinguishability) as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned distinguishability into existing dynamic pricing machine learning loops, and finally establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
||||||
|
|
||||||
This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned separability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 31st 2026.}
|
This research effort touches a large variety of domains, spanning behavioral economics for understanding the rationality of behavior as theorized by the concept of homo economicus, agent-based modeling to translate our learned distinguishability into disjoint dynamic pricing systems, reinforcement learning which serves as the SOTA for price-learners, and dynamic pricing and market equilibrium theory to understand the risks of possible supra-competitive pricing phenomena in cases of adversarial pricing systems driving the market out of equilibrium. \footnote{Given the rapid evolution of the field we acknowledge all developments with a cutoff set at the date of March 1st 2026.}
|
||||||
|
|
||||||
\subsection{Motivation and Market Context}
|
\subsection{Motivation and Market Context}
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ We formally define interaction data as coming from some actor which can either b
|
|||||||
This dissertation is organized around one main research question and three supporting sub-questions:
|
This dissertation is organized around one main research question and three supporting sub-questions:
|
||||||
\begin{enumerate}
|
\begin{enumerate}
|
||||||
\item[\textbf{Main RQ}] How can dynamic pricing systems preserve margin integrity when transaction orchestration is increasingly mediated by non-human agents?
|
\item[\textbf{Main RQ}] How can dynamic pricing systems preserve margin integrity when transaction orchestration is increasingly mediated by non-human agents?
|
||||||
\item[\textbf{SQ1}] \textit{Separability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting?
|
\item[\textbf{SQ1}] \textit{Distinguishability}: Can agent and human sessions be reliably distinguished from behavioral interaction signals alone, without relying on network-level or device fingerprinting?
|
||||||
\item[\textbf{SQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems?
|
\item[\textbf{SQ2}] \textit{Theoretical Impact}: What is the formal relationship between agent contamination levels and the erosion of pricing power in dynamic pricing systems?
|
||||||
\item[\textbf{SQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination?
|
\item[\textbf{SQ3}] \textit{Robust Mitigation}: How can pricing policies be constructed to maintain margin integrity under unknown and non-stationary levels of agent contamination?
|
||||||
\end{enumerate}
|
\end{enumerate}
|
||||||
@@ -64,4 +64,4 @@ Extract final result $r$ from terminal state\;
|
|||||||
\end{algorithm}
|
\end{algorithm}
|
||||||
|
|
||||||
|
|
||||||
The previously described goal of separability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary.
|
The previously described goal of distinguishability allows us to formulate a task which entails taking raw interaction data for either actor and creating a composite demand estimate $\hat{q}$. We propose a robust optimization objective defined in our methodology, transforming the pricing problem into a form of Distributionally Robust Optimization \parencite{kuhn_distributionally_2025} where the learner must guard against adversarial contamination in observed demand distributors. In this setting we must learn to make decision that perform under the assumption of not having a single estimated probability distribution but under an ambiguity set of any distribution, of which we have limited information. In our case as stated is a mixture of distributions with a parameter which is unknown and non-stationary.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
\section{Literature Review}
|
\section{Literature Review}
|
||||||
|
|
||||||
To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for separating non-human reconnaissance from genuine human demand expression and integrating that separation into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups.
|
To better understand all wedges of the current works, we must start by exploring the nature of agents, agentic computer use and web automation, complementing that with economic reasoning and strategic interaction. The final surface to cover, leads us to data-driven dynamic pricing under uncertainty. The key technical risk is not ``agents buying things'' per se, but agents shaping the behavioral and demand signals that downstream pricing systems consume and depend on. This latter case of agents shopping is currently pending legal action in the case of \textcite{noauthor_amazoncom_2026} which is currently being treated as a violation of the Computer Fraud and Abuse Act. The introduction of these mediating actor entities into economic systems, is further creating a threat of false-name bidding \parencite{yokoo_effect_2004}, which prior research has explored in a trading context. Other research on pseudonyms in dynamic systems, demonstrate whitewashing in AI agents which can ignore defensive mechanisms by re-entry with different identities \parencite{feldman_free-riding_2004}. Dynamic pricing assumes demand proxies are behaviorally meaningful, while bot detection aims at security and access control. The missing bridge is a principled framework for distinguishing non-human reconnaissance from genuine human demand expression and integrating that distinguishability into pricing heuristics without degrading legitimate user experience (in our research tracked by the user-experience index). This gap, is what our contribution aims to address, particularly for the aforementioned stakeholder groups.
|
||||||
|
|
||||||
\subsection{Agent Taxonomy and Definitions}
|
\subsection{Agent Taxonomy and Definitions}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
% Extra notes and clarifications: we observed some humans and get their transition probabilities between event types
|
% Extra notes and clarifications: we observed some humans and get their transition probabilities between event types
|
||||||
% We modify behavioral profiles of transition matrices with price elasticity matrices generated by sample valuations of a distributing.
|
% We modify behavioral profiles of transition matrices with price elasticity matrices generated by sample valuations of a distributing.
|
||||||
|
|
||||||
This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven separability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets.
|
This section details the theoretical and practical framework developed to address dynamic pricing under the influence of non-human actors. We begin by formalizing the problem environment and the nature of the actors. We then derive the \textit{Cost of Information} (COI) theorem, proving the erosion of pricing power in the limit of agent saturation. Following this, we outline our generative contamination strategy using GOFAI-driven distinguishability and transition probability learning. Finally, we formulate the robust control problem as a Stackelberg game solved via Distributionally Robust Reinforcement Learning (DR-RL) with constructed ambiguity sets.
|
||||||
|
|
||||||
\subsection{Problem Formalization}
|
\subsection{Problem Formalization}
|
||||||
|
|
||||||
We define a commercial environment where the platform interacts with a stream of sessions. Let $\mathcal{S}$ denote the set of all sessions. Each session $s \in \mathcal{S}$ is generated by an actor belonging to a latent class $Y_s \in \{H, A\}$, where $H$ denotes Human and $A$ denotes Agent.
|
We define a commercial environment where the platform interacts with a stream of sessions. Let $\mathcal{S}$ denote the set of all sessions. Each session $s \in \mathcal{S}$ is generated by an actor belonging to a latent class $\theta_s \in \{H, A\}$, where $H$ denotes Human and $A$ denotes Agent.
|
||||||
|
|
||||||
Each session produces a trajectory of observable events $\tau_s = (e_{s,1}, \ldots, e_{s,L_s})$. An event $e_{s,k}$ is a tuple defined as:
|
Each session produces a trajectory of observable events $\tau_s = (e_{s,1}, \ldots, e_{s,L_s})$. An event $e_{s,k}$ is a tuple defined as:
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
@@ -23,7 +23,7 @@ where:
|
|||||||
The platform does not directly observe the true underlying demand function $d(p)$. Instead, it observes a behavioral proxy $\hat{q}_t$, which is a composite signal derived from the mixture of actor types. We define the demand proxy for product $i$ at epoch $t$ as a weighted aggregation of events:
|
The platform does not directly observe the true underlying demand function $d(p)$. Instead, it observes a behavioral proxy $\hat{q}_t$, which is a composite signal derived from the mixture of actor types. We define the demand proxy for product $i$ at epoch $t$ as a weighted aggregation of events:
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
\label{eq:qhat}
|
\label{eq:qhat}
|
||||||
\hat{q}_{t,i} = \sum_{s \in \mathcal{S}_t} \sum_{k=1}^{L_s} \omega(a_{s,k}) \cdot \mathbb{1}[i_{s,k} = i]
|
\hat{q}_{t,i} = \sum_{s \in \mathcal{S}_t} \sum_{k=1}^{L_s} \omega(a_{s,k}) \cdot \mathbf{1}[i_{s,k} = i]
|
||||||
\end{equation}
|
\end{equation}
|
||||||
where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay.
|
where $\omega: \mathcal{A} \to \mathbb{R}_+$ assigns weights to actions based on their signal strength regarding willingness to pay.
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ We formalize the heterogeneity of actors by introducing a type space $\Theta$. A
|
|||||||
Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t
|
Q(p) = (1-\alpha) \cdot \mathbb{E}_{\theta \sim \mathcal{D}_H}[d(p; \theta)] + \alpha \cdot \mathbb{E}_{\theta \sim \mathcal{D}_A}[d(p; \theta)] + \epsilon_t
|
||||||
\end{equation}
|
\end{equation}
|
||||||
where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise.
|
where $\alpha \in [0, 1]$ represents the contamination parameter (proportion of agents) and $\epsilon_t$ is non-stationary market noise.
|
||||||
|
Accounting for behavioral and market variation, we also treat $\epsilon_t$ as absorbing serving-path variability from LLM infrastructure (e.g., batch-size-dependent inference behavior under changing load), which appears stochastic at the request level even under greedy decoding \parencite{horace_he_and_thinking_machines_lab_defeating_2025}.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +94,8 @@ where $\mathbb{E}[P]$ is the expected price charged by the policy and $\underlin
|
|||||||
|
|
||||||
We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes.
|
We now formally demonstrate that standard dynamic pricing mechanisms are not incentive-compatible with high-frequency agentic traffic. As the number of independent competitive agents $N$ querying the system grows, the platform's ability to sustain a COI vanishes.
|
||||||
|
|
||||||
A fundamental assumption for our claim lies in the alignment of the AI agent through its prompt which has been demonstrated by \cite{fish_algorithmic_2025} to cause strong collusive behavior under linguistic nudges. This assumption can be generalized to the human user asking the agent to research products with a minimizing objective.
|
\paragraph{Assumption Scope}
|
||||||
|
The theorem and core experiments in this thesis assume a non-collusive independent-session setting: each agent queries prices independently and does not share sampled quotes across agents. Collusive coordination is outside the current proof scope and is treated as an extension scenario.
|
||||||
|
|
||||||
\begin{theorem}[COI Erosion in the Limit]
|
\begin{theorem}[COI Erosion in the Limit]
|
||||||
Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0.
|
Let $N$ be the number of independent, utility-maximizing agents querying the platform. Let $p_{(1)}$ be the first order statistic (minimum) of the prices offered to these agents. As $N \to \infty$, the Cost of Information converges to 0.
|
||||||
@@ -140,6 +142,8 @@ The architecture of this platform begins with the deployed web-apps posting inte
|
|||||||
|
|
||||||
\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda architecture for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment.
|
\paragraph{Public Web Artifact} We transition the Kappa like architecture of the data collection to a Lambda architecture for actual learning in a surrogate environment. This allows us to move faster on data which is provided and helps us create a feedback loop for production deployment. To support further research in this intersection of fields we release P4P \footnote{\url{https://github.com/velocitatem/p4p}} as a public repository providing the interaction layer of the PHANTOM framework. This provides a configurable storefront which can be tailored to any commercial setting with a standardized session-level event tracking. We document the API adapters or what the framework expects in terms of schemas for pricing providers and log ingestion servicse. The repository is intended for controlled experimentation and method replication rather than production commerce deployment.
|
||||||
|
|
||||||
|
\paragraph{Public Dataset} For reproducibility of the behavioral analysis and distinguishability experiments, we also release the interaction dataset used in this thesis as \textit{WhoClickedIt}. The dataset is hosted on Hugging Face \footnote{\url{https://huggingface.co/datasets/velocitatem/whoclickedit}} and is distributed as one flattened event sheet (\texttt{whoclicked.csv}) with explicit labels (\texttt{actor\_type}, \texttt{is\_agent}, and \texttt{record\_type}). The associated dataset card specifies the schema, collection process, and known limitations; a full copy is included in Appendix~\ref{app:whoclicked_card}.
|
||||||
|
|
||||||
|
|
||||||
\subsubsection{DevOps Principles}
|
\subsubsection{DevOps Principles}
|
||||||
|
|
||||||
@@ -148,7 +152,10 @@ Reproducible results are key to quality research platforms, this is taken into m
|
|||||||
\subsubsection{Online Dynamic Pricing}
|
\subsubsection{Online Dynamic Pricing}
|
||||||
|
|
||||||
In order to collect data from actors under correct conditions we replicate a naive and simple dynamic pricing algorithm which runs in the background during the experiments.
|
In order to collect data from actors under correct conditions we replicate a naive and simple dynamic pricing algorithm which runs in the background during the experiments.
|
||||||
The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$. The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$):
|
The dynamic pricing done is handled by a pipeline which computes a demand estimate on a per-product basis of a specific window of the data, defined by the period $T$ which by default is 5 minutes. This dynamic pricing pipeline computes a demand estimate vector $\hat{q} \in \mathbb{R}^N$ by a weighted sum of interactions for each product, it additionally computes a price elasticity vector $\hat{\epsilon}$ in the same dimensions as our demand. The final features matrix is of the size $N \times 2$ which we translate to a new price vector $\hat{p} \in \mathbb{R}^N$.
|
||||||
|
|
||||||
|
|
||||||
|
The transformation that governs this dynamic pricing is a very simple surge-based pricing (a special case of our later defined policy $\pi$):
|
||||||
|
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
\hat{p}_i = \begin{cases}
|
\hat{p}_i = \begin{cases}
|
||||||
@@ -176,16 +183,27 @@ We start from a practical constraint: we do not have access to proprietary produ
|
|||||||
The interface is organized as a product catalog where each product belongs to a time-bounded price vector (for example, a daily pricing period). During each period we collect interaction data by instrumenting UI components and predefined action templates that are still customizable. This gives us control without losing realism.
|
The interface is organized as a product catalog where each product belongs to a time-bounded price vector (for example, a daily pricing period). During each period we collect interaction data by instrumenting UI components and predefined action templates that are still customizable. This gives us control without losing realism.
|
||||||
|
|
||||||
Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants.
|
Since users act with motivations, we define a pool of tasks (jobs to be done) and assign tasks randomly to participants.
|
||||||
% TODO: describe the task pool in detail here -- list the specific tasks used in the experiments
|
The task pool is stored as a structured table with fields \texttt{id}, \texttt{created\_at}, \texttt{task\_name}, \texttt{task\_description}, and \texttt{task\_def\_of\_done}. We formulate the tasks as compact jobs-to-be-done rather than as strict click scripts, because the target is to elicit realistic browsing and comparison behavior which can capture nuance of different people. In hotel mode the assigned tasks include \textit{Cheapest Room}, \textit{Cheapest Room w/ View}, \textit{MultiStep Cheapest Room}, \textit{The Digital Nomad (Executive)}, and \textit{The 3-Way Tradeoff (Desk + Quiet + Flexible)}. These prompts deliberately require critical thought in search, inspection of room details, comparison of amenities or images, return visits to the listing page, and a final booking decision which create a degree of cognitive load. In airline mode we use \textit{Last-Minute One-Way Flight}, where the actor must urgently travel to LAX from either SEA or JFK within the next 1--3 days, inspect at least a small set of candidate itineraries, and then book a reasonable earliest departure.
|
||||||
A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor.
|
A representative task is to find the cheapest feasible catalog item under explicit constraints while removing strict financial limits so we avoid trivial optimization behavior. Participants are also randomly assigned to one experimental platform mode (hotel or airline). Once assigned, they are dropped into the experiment with an actor ID. Under each experiment ID, we can observe multiple sessions across time and gather long interaction traces for the same actor.
|
||||||
|
|
||||||
The human data collection involved 18 participants, all of whom provided explicit informed consent prior to their session. Participants had an average age of 21 years and were recruited from a university population. Alongside the 18 human sessions we ran 18 agent sessions of equivalent task scope, giving a balanced dataset of 36 labeled trajectories. Each participant was assigned a single platform mode and a single task drawn from the pool, and completed the session independently without guidance on navigation or pricing strategy.
|
The human data collection involved 13 participants, all of whom provided explicit informed consent prior to their session. Participants had an average age of 21 years and were recruited from a university population. Alongside the 13 human sessions we ran 16 agent sessions of equivalent task scope, yielding 29 labeled trajectories in total (45\% human, 55\% agent). Each participant was assigned a single platform mode and a single task drawn from the pool, and completed the session independently without guidance on navigation or pricing strategy.
|
||||||
|
|
||||||
To evaluate quality and realism of the setup, we store both structured event logs and full interaction transcripts. This lets us combine quantitative analysis with transcript-level qualitative findings. The result is an isolated system where we can control the interaction process while preserving realistic behavior.
|
To evaluate quality and realism of the setup, we store both structured event logs and full interaction transcripts. This lets us combine quantitative analysis with transcript-level qualitative findings. The result is an isolated system where we can control the interaction process while preserving realistic behavior.
|
||||||
|
|
||||||
Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, run table, and assignment mapping). This data-acquisition phase is the first half of the methodology and is intentionally a disconnected component that feeds the later contributions. The second half uses collected behavioral traces to separate classes $y \in \{A,H\}$ with session-conditioned probability estimates, then injects those estimates into the pricing learner.
|
Operationally, goals and experiment runs are tracked in PostgreSQL (goal table, run table, and assignment mapping). This data-acquisition phase is the first half of the methodology and is intentionally a disconnected component that feeds the later contributions. The second half uses collected behavioral traces to distinguish classes $\theta \in \{A,H\}$ with session-conditioned probability estimates, then injects those estimates into the pricing learner.
|
||||||
|
|
||||||
Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn separability to characterize human versus agent patterns, and (3) use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator.
|
Our process follows three stages: (1) observe and \textit{vectorize} behavioral interactions, (2) learn distinguishability to characterize human versus agent patterns, and (3) use the learned signal to train a defensive policy in a controlled dynamic-pricing simulator.
|
||||||
|
|
||||||
|
Figure~\ref{fig:phantom_unified_architecture} summarizes the full mechanism from online interaction capture to divergence-based contamination scoring and robust control of pricing decisions.
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\resizebox{\textwidth}{!}{%
|
||||||
|
\input{chapters/hero_architecture_figure.tex}
|
||||||
|
}
|
||||||
|
\caption{Unified PHANTOM defense architecture. (a) Online serving and logging with behavioral and price-query streams. (b) Distinguishability layer that estimates KL divergence to human/agent prototypes and derives session-level contamination scores. (c) Distributionally robust pricing control that optimizes under an ambiguity set while penalizing COI leakage and tracking UX cost.}
|
||||||
|
\label{fig:phantom_unified_architecture}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
\begin{figure}[ht]
|
\begin{figure}[ht]
|
||||||
\resizebox{\columnwidth}{!}{%
|
\resizebox{\columnwidth}{!}{%
|
||||||
@@ -203,11 +221,11 @@ The dynamic pricing mechanism elicited immediate behavioral adjustments. Partici
|
|||||||
|
|
||||||
\subsubsection{Design of Training Factorial Study}
|
\subsubsection{Design of Training Factorial Study}
|
||||||
|
|
||||||
The simulator has multiple configurable factors. We design a multi-factor study across five axes derived from the sweep configurations: (1) RL algorithm (\texttt{ppo}, \texttt{a2c}, \texttt{dqn}, \texttt{qtable}; 4 levels), (2) contamination ratio $\alpha$ sampled from $[0.1, 0.6]$ at four representative levels, (3) robustness radius $\epsilon_\alpha \in \{0.0, 0.15, 0.3\}$ (3 levels), (4) COI penalty weight $\lambda_\text{coi}$ at two reference levels, and (5) pricing action granularity (two discretization settings for \texttt{action\_levels}); giving a grid of $4\times4\times3\times2\times2 = 192$ configurations. Statistical power for the behavioral comparisons is determined by a two-sample test over per-session KL divergence scores; a formal power analysis with minimum detectable effect size at $n=18+18$ is reported in the results.
|
The simulator has multiple configurable factors. We design a multi-factor study across five axes derived from the sweep configurations: (1) RL algorithm (\texttt{ppo}, \texttt{a2c}, \texttt{dqn}, \texttt{qtable}; 4 levels), (2) contamination ratio $\alpha$ sampled from $[0.1, 0.6]$ at four representative levels, (3) robustness radius $\epsilon_\alpha \in \{0.0, 0.15, 0.3\}$ (3 levels), (4) COI penalty weight $\lambda_\text{coi}$ at two reference levels, and (5) pricing action granularity (two discretization settings for \texttt{action\_levels}); giving a grid of $4\times4\times3\times2\times2 = 192$ configurations. Statistical power for the behavioral comparisons is determined by a two-sample test over per-session KL divergence scores; a formal power analysis with minimum detectable effect size at $n_H=13$, $n_A=16$ is reported in the results.
|
||||||
% Power analysis plan: apply a two-sample Mann-Whitney U (or permutation test) on per-session (delta_H - delta_A) divergence scores comparing the human and agent groups. Compute minimum detectable effect size at alpha=0.05, power=0.8, given n=18 per group. Bootstrap confidence intervals on mean KL are a cleaner complement given the non-normality of divergence distributions.
|
% Power analysis plan: apply a two-sample Mann-Whitney U (or permutation test) on per-session (delta_H - delta_A) divergence scores comparing the human and agent groups. Compute minimum detectable effect size at alpha=0.05, power=0.8, given n_H=13 and n_A=16. Bootstrap confidence intervals on mean KL are a cleaner complement given the non-normality of divergence distributions.
|
||||||
While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable.
|
While this scale is generally expensive for reinforcement learning, we execute it on a large TPU cluster to make the sweep tractable.
|
||||||
|
|
||||||
Our training budget is provisioned through TPU Research Cloud and spans 384 chips across TPU v4, v5e, and v6e generations, with a spot-heavy allocation plus an on-demand reserve. At peak BF16 throughput this corresponds to approximately 160 PFLOPS of aggregate compute, which makes repeated seeds, ablations, and sensitivity sweeps feasible within practical wall-clock limits. We allocate v6e capacity to the highest-intensity policy training jobs, use v5e for wider hyperparameter exploration where throughput-per-dollar is favorable, and reserve on-demand v4 capacity for runs that should not be interrupted.
|
Our training budget is provisioned through TPU Research Cloud and spans 384 chips across TPU v4, v5e, and v6e generations, with a spot-heavy allocation plus an on-demand reserve. At peak BF16 throughput this corresponds to approximately 160\,PFLOPS of aggregate compute (derivation in Appendix~\ref{app:compute_budget}), which makes repeated seeds, ablations, and sensitivity sweeps feasible within practical wall-clock limits. We allocate v6e capacity to the highest-intensity policy training jobs, use v5e for wider hyperparameter exploration where throughput-per-dollar is favorable, and reserve on-demand v4 capacity for runs that should not be interrupted.
|
||||||
|
|
||||||
\begin{table}[ht]
|
\begin{table}[ht]
|
||||||
\centering
|
\centering
|
||||||
@@ -242,7 +260,8 @@ v4 & 64 (32 + 32) & us-central2-b & 32 Spot + 32 On-demand \\
|
|||||||
\end{tabular}
|
\end{tabular}
|
||||||
\end{table}
|
\end{table}
|
||||||
|
|
||||||
For connections from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs with the benefit of having the most grouped chips within a single region. This regional grouping is important for the deployment of our Kubernetes cluster which cannot span multiple regions. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}.
|
For connections from Madrid, we prioritize the europe-west4 allocation for latency-sensitive runs with the benefit of having the most grouped chips within a single region. This regional grouping is important for the deployment of our Kubernetes cluster which cannot span multiple regions. All sweep metadata, model checkpoints, and reward traces are logged in Weights \& Biases. % TODO: cite this (from bib)
|
||||||
|
Hardware specifications are from the official Google Cloud TPU documentation \parencite{noauthor_tpu_2026,noauthor_tpu_2025-1,noauthor_tpu_2025}.
|
||||||
|
|
||||||
Design of training processes: we build docker image with the fact in mind of different caching over layers in order to most speed up docker re-building and such we place the most volatile steps towards the end of the image building. What is means in practice is that any dependency installations are isolated so edits to source code do no trigger rebuilds. Only if we update our entry point of training a sweep, Docker will also rebuild the source-code copy stage.
|
Design of training processes: we build docker image with the fact in mind of different caching over layers in order to most speed up docker re-building and such we place the most volatile steps towards the end of the image building. What is means in practice is that any dependency installations are isolated so edits to source code do no trigger rebuilds. Only if we update our entry point of training a sweep, Docker will also rebuild the source-code copy stage.
|
||||||
|
|
||||||
@@ -281,7 +300,7 @@ $\mathcal{A}_{\text{filter}}$ & \texttt{search}, \texttt{filter\_date}, \texttt{
|
|||||||
\end{table}
|
\end{table}
|
||||||
|
|
||||||
This partition enables the weight function $\omega$ from Eq.~\ref{eq:qhat} to assign category-specific signal strengths, with $\omega(\mathcal{A}_{\text{cart}}) > \omega(\mathcal{A}_{\text{dwell}}) > \omega(\mathcal{A}_{\text{nav}}) > \omega(\mathcal{A}_{\text{filter}})$ reflecting decreasing commitment.
|
This partition enables the weight function $\omega$ from Eq.~\ref{eq:qhat} to assign category-specific signal strengths, with $\omega(\mathcal{A}_{\text{cart}}) > \omega(\mathcal{A}_{\text{dwell}}) > \omega(\mathcal{A}_{\text{nav}}) > \omega(\mathcal{A}_{\text{filter}})$ reflecting decreasing commitment.
|
||||||
|
Its important to acknowledge that this creates a very blatant assumption in the weighting, we do motivate the scale of each weight by the per-category observed divergence between each behavioral profile.
|
||||||
In the simulator baseline this order is encoded with a compact fixed scale: cart $=4.0$, dwell $=2.0$, nav $=1.0$, filter $=0.5$. Unknown actions are mapped by prefix heuristics to the nearest category.
|
In the simulator baseline this order is encoded with a compact fixed scale: cart $=4.0$, dwell $=2.0$, nav $=1.0$, filter $=0.5$. Unknown actions are mapped by prefix heuristics to the nearest category.
|
||||||
|
|
||||||
The metadata record $\mu$ varies by action type. For product views, $\mu$ contains the observed price $p_{\text{obs}}$ and product attributes. For dwell events, $\mu$ includes the element text and accumulated hover duration. This heterogeneous structure is captured via a schema-on-read approach in our Kafka ingestion pipeline, where events are validated against type-specific schemas before storage.
|
The metadata record $\mu$ varies by action type. For product views, $\mu$ contains the observed price $p_{\text{obs}}$ and product attributes. For dwell events, $\mu$ includes the element text and accumulated hover duration. This heterogeneous structure is captured via a schema-on-read approach in our Kafka ingestion pipeline, where events are validated against type-specific schemas before storage.
|
||||||
@@ -289,16 +308,19 @@ The metadata record $\mu$ varies by action type. For product views, $\mu$ contai
|
|||||||
In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response.
|
In addition to behavioral events, the platform logs price observations to a separate Kafka topic. Each price query generates a record $(i, p, \text{sid}, \phi, t)$ associating the product, displayed price, requesting session, platform mode, and timestamp. This dual-stream architecture enables joint analysis of price exposure and behavioral response.
|
||||||
|
|
||||||
|
|
||||||
\subsection{Generative Contamination and Separability}
|
|
||||||
|
|
||||||
|
\subsection{Generative Contamination and Distinguishability}
|
||||||
|
|
||||||
To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from Phantom data using a two-stage approach.
|
To train a robust pricing learner, we need a simulator that can generate realistic interaction data under controlled contamination. We build this from Phantom data using a two-stage approach.
|
||||||
|
|
||||||
|
|
||||||
\subsubsection{Ground-Truth Separability}
|
\subsubsection{Ground-Truth Distinguishability}
|
||||||
Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $y_s \in \{H, A\}$ are available as ground truth rather than as the output of a heuristic classifier. We therefore estimate separate transition kernels directly from each labeled partition $\mathcal{D}_H$ and $\mathcal{D}_A$, treating the resulting $\hat{\mathcal{T}}_H$ and $\hat{\mathcal{T}}_A$ as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels separable enough to justify downstream pricing control that depends on that separability?
|
Because sessions are collected under controlled experimental conditions where each actor is assigned a known type at the start of the trial, labels $\theta_s \in \{H, A\}$ are available as ground truth rather than as the output of a heuristic classifier. We therefore estimate separate transition kernels directly from each labeled partition $\mathcal{D}_H$ and $\mathcal{D}_A$, treating the resulting $\hat{\mathcal{T}}_H$ and $\hat{\mathcal{T}}_A$ as the ground-truth behavioral profiles for each class. We then ask a direct methodological question: are the kernels distinguishable enough to justify downstream pricing control that depends on that distinguishability?
|
||||||
|
|
||||||
To answer this, we compute average KL divergence between transition probability matrices. This statistic gives global separability and event-level diagnostics at the same time. In our balanced dataset (50\% human, 50\% agent), the average divergence is approximately $1.8$. To contextualize this divergence metric we compare with an intra-class comparison baseline of randomly selected transitions.
|
To answer this, we compute per-session KL divergence scores against both class-level centroids. For each session $s$ in either partition, we fit a session-level event transition kernel $\hat{\mathcal{T}}_s$ from that session's trajectory alone, then compute its average KL divergence to the human centroid ($\Delta_{H,s}$) and to the agent centroid ($\Delta_{A,s}$). The per-session distinguishability score is the gap $\Delta_{H,s} - \Delta_{A,s}$: a negative value indicates proximity to human behavior, a positive value indicates proximity to agent behavior. The reason behind KL divergence for profile analysis is grounded in its nature and tailored characteristics for probability distributions.
|
||||||
% To contextualize this figure a useful intra-class baseline is to randomly split D_H into two equal halves, estimate a kernel from each half, compute the same average KL statistic, and repeat for B bootstrap samples (e.g. B=100). The resulting null distribution (mean +/- std) gives the divergence expected purely from estimation noise at this sample size. A between-class KL substantially above this null confirms the separation is real and not a finite-sample artefact. In practice: for each of B splits, partition D_H 50/50 without replacement, run build_kernel() on each half, average the per-state KL values, and collect the B scores into a reference distribution to compare against the 1.8 figure.
|
|
||||||
|
The normality assumption cannot be made for KL divergence distributions, which are right-skewed and bounded below by zero, so we do not use a Student's $t$-test. Instead we apply a Mann-Whitney $U$ test \parencite{mann_test_1947} on the per-session gap scores between the two groups. The Mann-Whitney test is a rank-based nonparametric test that compares the stochastic ordering of two independent samples without distributional assumptions, making it appropriate for small samples drawn from skewed populations. We report $U$, the exact two-sided $p$-value, and group-level descriptive statistics for the gap scores.
|
||||||
|
|
||||||
\begin{definition}[Kullback-Leibler Divergence for Transition Distributions]
|
\begin{definition}[Kullback-Leibler Divergence for Transition Distributions]
|
||||||
Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is:
|
Let $P_e$ and $Q_e$ be categorical distributions over destination states following event $e$, derived from human and agent trajectories respectively. The KL divergence between these distributions is:
|
||||||
@@ -310,7 +332,7 @@ where $\mathcal{S}_e$ denotes the set of destination events that follow $e$ in t
|
|||||||
|
|
||||||
To obtain this statistic, we aggregate transitions by triggering event $e$ and treat normalized outgoing probabilities as categorical distributions $P_e$ (human) and $Q_e$ (agent). We intersect shared event labels, then accumulate log-ratio contributions over shared destinations. Large contributions, including near-zero $Q_e(k)$ cases, identify transitions where one actor class is difficult to mimic.
|
To obtain this statistic, we aggregate transitions by triggering event $e$ and treat normalized outgoing probabilities as categorical distributions $P_e$ (human) and $Q_e$ (agent). We intersect shared event labels, then accumulate log-ratio contributions over shared destinations. Large contributions, including near-zero $Q_e(k)$ cases, identify transitions where one actor class is difficult to mimic.
|
||||||
|
|
||||||
With these divergence features we train a contrastive model to estimate a weak agent probability $f(\tau)\in[0,1]$, which we later use as a weighting and control signal.
|
With these divergence features we compute a weak agent probability $f(\tau')\in[0,1]$ directly from divergence gaps, which we later use as a weighting and control signal.
|
||||||
|
|
||||||
|
|
||||||
\subsubsection{Transition Probability Estimation}
|
\subsubsection{Transition Probability Estimation}
|
||||||
@@ -341,9 +363,6 @@ To scale this to catalog-level pricing, we expand the base event transition matr
|
|||||||
\end{figure}
|
\end{figure}
|
||||||
|
|
||||||
|
|
||||||
\subsection{Second-Stage Classification}
|
|
||||||
After contamination, we run a second classification stage. We remap events into a semantically aligned feature space, apply richer feature engineering, and retrain to obtain cleaner label probabilities across the full dataset. This classifier is then used directly in the reinforcement-learning reward structure.
|
|
||||||
|
|
||||||
\subsection{Distributionally Robust Reinforcement Learning (DR-RL)}
|
\subsection{Distributionally Robust Reinforcement Learning (DR-RL)}
|
||||||
|
|
||||||
We formulate pricing as a Stackelberg game: the platform (leader) sets prices $p_t$, and the population (follower) responds through trajectories and demand. A useful intuition is that the platform behaves like a distorted mirror at a 45-degree angle: what it mirrors is population demand into an estimated demand proxy, and that proxy drives revenue.
|
We formulate pricing as a Stackelberg game: the platform (leader) sets prices $p_t$, and the population (follower) responds through trajectories and demand. A useful intuition is that the platform behaves like a distorted mirror at a 45-degree angle: what it mirrors is population demand into an estimated demand proxy, and that proxy drives revenue.
|
||||||
@@ -357,10 +376,36 @@ Because contamination level $\alpha$ and demand shift are non-stationary online,
|
|||||||
\Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A)
|
\Delta_A &= D_{KL}(\hat{\mathcal{T}}^\prime \parallel \bar{\mathcal{T}}_A)
|
||||||
\end{align}
|
\end{align}
|
||||||
|
|
||||||
This yields two centroid-like heuristics that act as a session-level agent score in the engine. On a per-customer or use-case basis a similar study should be done in order to obtain ground truth behavior models for humans and agents and their specific interaction with a given products website.
|
From these two divergences we define the gap score:
|
||||||
|
\begin{equation}
|
||||||
|
g(\tau') := \Delta_H(\tau') - \Delta_A(\tau').
|
||||||
|
\end{equation}
|
||||||
|
Positive values indicate trajectories farther from the human centroid and closer to the agent centroid.
|
||||||
|
|
||||||
|
We map this gap to a weak agent probability using a temperature-controlled logistic map:
|
||||||
|
\begin{equation}
|
||||||
|
f(\tau') := P(Y=A\mid\tau') = \operatorname{softmax}(-\Delta_A,-\Delta_H)_A = \sigma\left(\frac{\Delta_H-\Delta_A}{T}\right), \quad T>0.
|
||||||
|
\end{equation}
|
||||||
|
The session-level control signal injected into pricing is then
|
||||||
|
\begin{equation}
|
||||||
|
\hat{\alpha}(\tau') := f(\tau').
|
||||||
|
\end{equation}
|
||||||
|
|
||||||
|
This turns distinguishability into an operational control input in the engine. On a per-customer or use-case basis, a similar data collection and fitting process should be repeated to obtain domain-specific behavior kernels.
|
||||||
|
|
||||||
In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move).
|
In implementation, we maintain an alternating game-history stack (our \textit{Limbo} stack) and execute it explicitly every epoch with exactly two transitions: first the platform publishes a price vector (leader move), then the market responds with trajectory-derived demand (follower move).
|
||||||
|
|
||||||
|
To avoid notation drift, we separate two COI objects used for different purposes:
|
||||||
|
\begin{align}
|
||||||
|
\text{COI}_{\text{level}}(\pi) &:= \mathbb{E}[P]-\underline{p} \quad \text{(global reporting KPI)} \\
|
||||||
|
\text{COI}_{\text{leak}}(p,\tau') &:= f(\tau')\cdot \text{InfoValue}(p,\tau') \quad \text{(local control penalty)}
|
||||||
|
\end{align}
|
||||||
|
where $\text{COI}_{\text{level}}$ is evaluated at policy level and $\text{COI}_{\text{leak}}$ is evaluated per observed quote during training. We connect local leakage to expected global erosion with the operational assumption
|
||||||
|
\begin{equation}
|
||||||
|
\mathbb{E}[\Delta\text{COI}_{\text{level},t} \mid \tau_t'] \approx -\kappa\,\text{COI}_{\text{leak}}(p_t,\tau_t') + \xi_t,
|
||||||
|
\end{equation}
|
||||||
|
where $\kappa>0$ and $\xi_t$ is residual noise. This keeps theorem-level COI erosion (global, asymptotic) distinct from training-time leakage control (local surrogate).
|
||||||
|
|
||||||
% Mention discretized action space and the clipping and over shotting in continuous action spaces
|
% Mention discretized action space and the clipping and over shotting in continuous action spaces
|
||||||
% Also talk about catastrophic economics, we add termination on bankrupcy or zero demand so market collaps
|
% Also talk about catastrophic economics, we add termination on bankrupcy or zero demand so market collaps
|
||||||
|
|
||||||
@@ -378,13 +423,56 @@ For the current engine baseline, we use a compact inner-robust approximation by
|
|||||||
and we evaluate a small fixed grid in $\mathcal{A}_{\epsilon_\alpha}(\alpha_0)$ per step, selecting the worst-case candidate for the learner.
|
and we evaluate a small fixed grid in $\mathcal{A}_{\epsilon_\alpha}(\alpha_0)$ per step, selecting the worst-case candidate for the learner.
|
||||||
% A proper Wasserstein ball implementation over the full demand distribution (rather than a scalar alpha interval) would use the POT library (Python Optimal Transport): compute W_2 between the empirical reference P_hat and each candidate Q using ot.emd2() or ot.sliced_wasserstein_distance() for scalability, then accept only candidates within epsilon. In practice the inner minimization becomes: candidates = [G(alpha) for alpha in linspace]; dists = [ot.emd2(p_hat, q, M) for q in candidates]; worst = candidates[argmin(reward[dists <= epsilon])]. The current grid-on-alpha approximation is a computationally cheap substitute; moving to a true Wasserstein ball would tighten the worst-case guarantee but requires specifying the ground metric M over the demand space.
|
% A proper Wasserstein ball implementation over the full demand distribution (rather than a scalar alpha interval) would use the POT library (Python Optimal Transport): compute W_2 between the empirical reference P_hat and each candidate Q using ot.emd2() or ot.sliced_wasserstein_distance() for scalability, then accept only candidates within epsilon. In practice the inner minimization becomes: candidates = [G(alpha) for alpha in linspace]; dists = [ot.emd2(p_hat, q, M) for q in candidates]; worst = candidates[argmin(reward[dists <= epsilon])]. The current grid-on-alpha approximation is a computationally cheap substitute; moving to a true Wasserstein ball would tighten the worst-case guarantee but requires specifying the ground metric M over the demand space.
|
||||||
|
|
||||||
|
|
||||||
|
\subsubsection{Environment Setup for Dynamic Pricing}
|
||||||
|
The complete pricing-demand-trajectory loop is illustrated in Figure~\ref{fig:oracle_flow}. The Oracle maps historical price and demand state to a new price vector, which is exposed to a distribution of demand curves. Each product generates trajectories weighted by behavioral kernels $\tau_\theta$, producing a full transition matrix $\tau'$ over sessions. Sampled trajectories $\{\tau_k\}$ are aggregated through the demand proxy function $Q(\cdot)$ to yield the next demand vector, which feeds back into the Oracle.
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
{\setlength{\arraycolsep}{4pt}%
|
||||||
|
\resizebox{0.85\linewidth}{!}{$
|
||||||
|
\begin{aligned}
|
||||||
|
&\text{Oracle}(\vec{p}_{t-1},\vec{\hat{q}})\to
|
||||||
|
\begin{pmatrix}
|
||||||
|
p_0\\
|
||||||
|
p_1\\
|
||||||
|
\cdots\\
|
||||||
|
p_N
|
||||||
|
\end{pmatrix}
|
||||||
|
\underrightarrow{d_i \sim \mathcal{N}_{\vec{p}}}
|
||||||
|
\begin{pmatrix}d_0\\ d_1\\ \cdots \\ d_N\end{pmatrix}
|
||||||
|
\underrightarrow{\vec{d}\otimes \tau_\theta}
|
||||||
|
\begin{bmatrix}
|
||||||
|
0.01 & 0.02 & \cdots & 0.3 \\
|
||||||
|
0.41 & 0.24 & \cdots & 0.0 \\
|
||||||
|
\cdots & \cdots & \cdots & \cdots \\
|
||||||
|
0.51 & 0.09 & \cdots & 0.1 \\
|
||||||
|
\end{bmatrix}
|
||||||
|
\\
|
||||||
|
&\underrightarrow{\tau_k \sim \tau^\prime}
|
||||||
|
\{\tau_k\}_{k=0}^K \to \hat{Q}(\tau_k)
|
||||||
|
\to \begin{pmatrix}
|
||||||
|
\hat{q}_0 \\
|
||||||
|
\hat{q}_1 \\
|
||||||
|
\cdots \\
|
||||||
|
\hat{q}_N \\
|
||||||
|
\end{pmatrix}
|
||||||
|
\to \text{Oracle}(\cdot)
|
||||||
|
\end{aligned}
|
||||||
|
$}%
|
||||||
|
}
|
||||||
|
\caption{Oracle-based pricing loop: historical price and demand state map to a new price vector; each product samples demand curves from $\mathcal{N}_{\vec{p}}$; trajectories are generated via the Kronecker product $\vec{d}\otimes\tau_\theta$ into transition matrix $\tau'$; sampled trajectories $\{\tau_k\}$ aggregate through proxy $Q(\cdot)$ to yield updated demand $\vec{\hat{q}}$, closing the feedback loop.}
|
||||||
|
\label{fig:oracle_flow}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
\subsubsection{The Min-Max Objective}
|
\subsubsection{The Min-Max Objective}
|
||||||
The robust policy $\pi^*$ is obtained by solving the maximin problem:
|
The robust policy $\pi^*$ is obtained by solving the maximin problem:
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
\label{eq:robust_policy}
|
\label{eq:robust_policy}
|
||||||
\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}_{\text{leak}}(p,\tau') \right]
|
\pi^* = \arg \max_{\pi} \min_{Q \in \mathcal{U}_\epsilon} \mathbb{E}_{d \sim Q} \left[ R(p, d) - \lambda \cdot \text{COI}_{\text{leak}}(p,\tau') \right]
|
||||||
\end{equation}
|
\end{equation}
|
||||||
where $R(p, d)$ is the revenue function and $\lambda$ weighs the information-leakage penalty.
|
where $R(p, d)$ is the revenue function and $\lambda$ weighs the information-leakage penalty. We note that $p$ is directly dependent on $\pi$ which is the one deicing that as its action.
|
||||||
|
|
||||||
|
|
||||||
In practice, we parameterize this with a session-level leakage term:
|
In practice, we parameterize this with a session-level leakage term:
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
@@ -392,6 +480,8 @@ In practice, we parameterize this with a session-level leakage term:
|
|||||||
\end{equation}
|
\end{equation}
|
||||||
where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$.
|
where $f(\tau')$ is the weak agent probability and $\text{InfoValue}$ is implemented either as a constant query-tax surrogate or as a revelation surrogate $-\log\pi(p\mid\tau')$.
|
||||||
|
|
||||||
|
To make the intuition of our $\max \min$ easier in connection to the COI term which we are subtracting, we introduce the strongest possible penalization and try to maximize only for the worst case scenario in which the leakage is extremely high and that negation sends a signal to pick the candidate of the hardest problem.
|
||||||
|
|
||||||
For the baseline engine reported here, we intentionally use the constant query-tax surrogate to keep the mechanism minimal:
|
For the baseline engine reported here, we intentionally use the constant query-tax surrogate to keep the mechanism minimal:
|
||||||
\begin{equation}
|
\begin{equation}
|
||||||
r_t = R(p_t,\tilde q_t) - \lambda\,f(\tau_t')\,c_{\text{info}}
|
r_t = R(p_t,\tilde q_t) - \lambda\,f(\tau_t')\,c_{\text{info}}
|
||||||
@@ -414,14 +504,14 @@ As part of reward engineering, we keep a UX factor ($UX\in[0,1]$) as an auxiliar
|
|||||||
\resizebox{0.5\columnwidth}{!}{%
|
\resizebox{0.5\columnwidth}{!}{%
|
||||||
\input{chapters/balance_figure.tex}
|
\input{chapters/balance_figure.tex}
|
||||||
}
|
}
|
||||||
\caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-like scale.}
|
\caption{Introducing the UX index allows us to better distinguish the kind of impact different methods have and allows us to compare them on this Pareto-efficiency-like scale.}
|
||||||
\end{figure}
|
\end{figure}
|
||||||
|
|
||||||
We also consider taxation-like overlays for agent traffic under strategy-proof mechanism design (e.g., Vickrey-Clarke-Groves style rules). This remains an extension path and is not part of the main implementation in this thesis.
|
We also consider taxation-like overlays for agent traffic under strategy-proof mechanism design (e.g., Vickrey-Clarke-Groves style rules). This remains an extension path and is not part of the main implementation in this thesis.
|
||||||
|
|
||||||
\subsubsection{Pricing Mechanism Summary}
|
\subsubsection{Pricing Mechanism Summary}
|
||||||
|
|
||||||
We now present the complete pricing mechanism that integrates the behavioral separability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_loop_clean} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories.
|
We now present the complete pricing mechanism that integrates the behavioral distinguishability, contamination estimation, and robust optimization components developed in the preceding sections. Algorithm~\ref{alg:phantom_loop_clean} formalizes the defensive pricing loop as a Stackelberg game where the platform (leader) sets prices and the aggregate demand (follower) responds through observed session trajectories.
|
||||||
|
|
||||||
\begin{algorithm}[t]
|
\begin{algorithm}[t]
|
||||||
\caption{PHANTOM defensive pricing loop}
|
\caption{PHANTOM defensive pricing loop}
|
||||||
@@ -454,3 +544,47 @@ We now present the complete pricing mechanism that integrates the behavioral sep
|
|||||||
The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform applies one discrete multiplicative price action, the environment samples a batch of sessions, and demand is recomputed from weighted events. Robustness is implemented as an inner minimization over a small local grid of contamination candidates around nominal $\alpha_0$, matching the current engine implementation. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations.
|
The algorithm operates in discrete epochs indexed by $t$. At each epoch, the platform applies one discrete multiplicative price action, the environment samples a batch of sessions, and demand is recomputed from weighted events. Robustness is implemented as an inner minimization over a small local grid of contamination candidates around nominal $\alpha_0$, matching the current engine implementation. The history buffer $\mathcal{L}$ (``Limbo'' in our implementation) enforces the alternating Stackelberg structure by preserving the temporal sequence of price publications and demand observations.
|
||||||
|
|
||||||
%The defensive price update in Line 24 implements contamination-aware margin shrinkage: as estimated contamination $\hat{\alpha}_t$ rises, the margin $(p^{\mathrm{ref}} - c)$ is reduced by factor $\kappa\in[0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring feasibility. In subsequent experiments this heuristic rule is replaced by DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}.
|
%The defensive price update in Line 24 implements contamination-aware margin shrinkage: as estimated contamination $\hat{\alpha}_t$ rises, the margin $(p^{\mathrm{ref}} - c)$ is reduced by factor $\kappa\in[0,1]$, with projection $\Pi_{\mathcal{P}}$ ensuring feasibility. In subsequent experiments this heuristic rule is replaced by DR-RL policy $\pi^*$ from Eq.~\ref{eq:robust_policy}.
|
||||||
|
|
||||||
|
\subsection{Parallelization Strategy}
|
||||||
|
|
||||||
|
To avoid preemption of compute mid-training we settle on using a v4 generation, 40 chip compute node with 5 parallel workers. The login node creates an orchestration node with Ray \parencite{moritz_ray_2018} and we distribute ray compute nodes per each other worker.
|
||||||
|
|
||||||
|
\subsubsection{Computational Cost Analysis of the Simulation Step}
|
||||||
|
The per-step cost of Algorithm~\ref{alg:phantom_loop_clean} is not uniform across its components. To inform hardware provisioning and to identify where algorithmic improvements are most impactful, we profile the hot path of the engine using Python's \texttt{cProfile} instrumentation over 20 environment steps under two configurations: a baseline with the robustness inner loop disabled ($K=1$, $\epsilon_\alpha=0$) and a standard robust setting ($K=5$, $\epsilon_\alpha=0.2$). Both runs use $M=10$ sessions per market call and $N=3$ products.
|
||||||
|
|
||||||
|
The baseline achieves approximately 26 steps per second. Enabling the robustness inner loop with $K=5$ candidates drops throughput to 7.2 steps per second, a $3.6\times$ slowdown that is directly proportional to $K$, consistent with the $O(K)$ scaling of the adversarial alpha selection in the implementation.
|
||||||
|
|
||||||
|
\begin{table}[ht]
|
||||||
|
\centering
|
||||||
|
\caption{Per-step profiling results (20 steps, $M=10$ sessions, $N=3$ products). Self-time measures time spent inside the function excluding callees; cumulative time includes the full call subtree.}
|
||||||
|
\label{tab:profile_results}
|
||||||
|
\begingroup
|
||||||
|
\small
|
||||||
|
\setlength{\tabcolsep}{4pt}
|
||||||
|
\begin{tabular}{@{}lrrrr@{}}
|
||||||
|
\toprule
|
||||||
|
\textbf{Function} & \textbf{Calls} & \textbf{Self (ms)} & \textbf{Cum. (ms)} & \textbf{Cum. \%} \\
|
||||||
|
\midrule
|
||||||
|
\multicolumn{5}{l}{\textit{Baseline ($K=1$, 0.77\,s total, 26 steps/s)}} \\
|
||||||
|
\texttt{sample\_behavior\_from\_transitions} & 420 & 131 & 658 & 86\% \\
|
||||||
|
\texttt{DataFrame.xs} & 4,820 & 30 & 201 & 26\% \\
|
||||||
|
\texttt{numpy.nan\_to\_num} & 4,904 & 43 & 97 & 13\% \\
|
||||||
|
\texttt{adjust\_behavior\_to\_condition} & 84 & 3 & 54 & 7\% \\
|
||||||
|
\midrule
|
||||||
|
\multicolumn{5}{l}{\textit{Robust ($K=5$, 2.79\,s total, 7.2 steps/s)}} \\
|
||||||
|
\texttt{sample\_behavior\_from\_transitions} & 1,220 & 519 & 2,447 & 88\% \\
|
||||||
|
\texttt{DataFrame.xs} & 16,668 & 108 & 729 & 26\% \\
|
||||||
|
\texttt{numpy.nan\_to\_num} & 16,912 & 164 & 363 & 13\% \\
|
||||||
|
\texttt{adjust\_behavior\_to\_condition} & 244 & 11 & 108 & 4\% \\
|
||||||
|
\bottomrule
|
||||||
|
\end{tabular}
|
||||||
|
\endgroup
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
Across both configurations, \texttt{sample\_behavior\_from\_transitions} accounts for 86--88\% of total wall time. The function implements the Markov chain sampler described in Section~\ref{sec:tpe}: at each transition it retrieves the current-state row from the expanded transition \texttt{DataFrame} via label-based indexing, which internally dispatches through the pandas \texttt{xs} and \texttt{fast\_xs} code paths. For $M$ sessions each running up to $L_{\max}=40$ transitions, a single \texttt{market.act()} call issues up to $M \cdot L_{\max}$ individual row lookups. With $K=5$ robustness candidates per outer step this accumulates to $5 \times 10 \times 40 = 2{,}000$ row accesses per outer step, producing the 16k \texttt{xs} invocations observed in Table~\ref{tab:profile_results}.
|
||||||
|
|
||||||
|
The \texttt{numpy.nan\_to\_num} calls, accounting for 13\% of self-time, occur once per row lookup to sanitize sampled probability vectors before normalization; their call count therefore tracks the \texttt{xs} count exactly.
|
||||||
|
|
||||||
|
\texttt{adjust\_behavior\_to\_condition} expands the base $E \times E$ event transition matrix to a $(E \cdot N) \times (E \cdot N)$ product-specific matrix via a Kronecker product. At $N=3$ this is inexpensive, but the cost scales as $O(E^2 N^2)$, so at the $N=10$ default it becomes a more significant contributor. The result is not cached across the $K$ robustness candidates inside a single outer step, meaning the Kronecker expansion is recomputed $2K$ times per step (once for the human kernel and once for the agent kernel at each candidate $\alpha_k$).
|
||||||
|
|
||||||
|
The dominant bottleneck therefore has a clear structural cause: the expanded transition matrix is a string-keyed \texttt{DataFrame}, and pandas object-level indexing carries substantial per-call overhead relative to the arithmetic being performed. Converting the expanded matrix to a \texttt{numpy} array with an accompanying integer state-to-index map, computed once per \texttt{market.act()} call and cached for the duration of the robustness inner loop, eliminates the entire pandas dispatch chain. We leverage this bottleneck identified as an opportunity to squeeze the gap which is left by the computational needs of the pricing learner. We make use of JAX to parallelize on the TPU, and surprisingly we open up a large speedup even on CPU-only compute, improving throughput from 26 to 220 steps/s in the baseline configuration and from 7.2 to 136 steps/s under the full robust inner loop, an 8.5$\times$ and 19$\times$ speedup respectively.
|
||||||
|
|||||||
@@ -1,22 +1,104 @@
|
|||||||
\section{Results}
|
\section{Results}
|
||||||
\begin{figure}[ht]
|
\begin{figure}[ht]
|
||||||
\centering
|
\centering
|
||||||
\input{chapters/figures/supra.tex}
|
\input{chapters/figures/supra/supra.tex}
|
||||||
\caption{Evolution of price distributions over experiment steps. The heatmap illustrates the density of price offerings. This is an early baseline simulation which demonstrates supra-competitive price-setting in deep learning agents such as SAC as can be clearly seen by the high density at the highest available price.}
|
\caption{Evolution of price distributions over experiment steps. The heatmap illustrates the density of price offerings. This is an early baseline simulation which demonstrates supra-competitive price-setting in deep learning agents such as SAC as can be clearly seen by the high density at the highest available price.}
|
||||||
\label{fig:supra_heatmap}
|
\label{fig:supra_heatmap}
|
||||||
\end{figure}
|
\end{figure}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\subsection{Behavioral Analysis}
|
\subsection{Behavioral Analysis}
|
||||||
|
|
||||||
Include markov chains of transition matrices, compare distributions (look at Divergence metrics)
|
Distinguishability between human and agent sessions is evaluated by computing per-session divergence gap scores $\Delta_{H,s} - \Delta_{A,s}$ and comparing the two groups with a Mann-Whitney $U$ test. The full recorded cohort contains $n_H=13$ human sessions and $n_A=16$ agent sessions, and Table~\ref{tab:divergence_significance} reports the corresponding group-level statistics and test result.
|
||||||
|
|
||||||
|
\begin{table}[ht]
|
||||||
|
\centering
|
||||||
|
\caption{Per-session divergence gap ($\Delta_H - \Delta_A$) by actor class with Mann-Whitney $U$ test.}
|
||||||
|
\label{tab:divergence_significance}
|
||||||
|
\begin{tabular}{lccc}
|
||||||
|
\toprule
|
||||||
|
Group & $n$ & Mean gap & Std \\
|
||||||
|
\midrule
|
||||||
|
Human sessions & 13 & $-3.35$ & $2.67$ \\
|
||||||
|
Agent sessions & 16 & $+1.65$ & $2.83$ \\
|
||||||
|
\midrule
|
||||||
|
\multicolumn{4}{l}{Mann-Whitney two-sided test: $p<0.001$} \\
|
||||||
|
\bottomrule
|
||||||
|
\end{tabular}
|
||||||
|
\end{table}
|
||||||
|
|
||||||
|
The sign structure is consistent with the theoretical expectation: human sessions produce negative gap scores (closer to the human centroid, far from the agent centroid) while agent sessions produce positive gap scores (closer to the agent centroid). The two-sided test result ($p<0.001$) at $n_H=13$, $n_A=16$ indicates strong rank distinction between groups, providing evidence that the transition kernels are distinguishable enough to justify their use as a control signal in downstream pricing.
|
||||||
|
|
||||||
|
|
||||||
\subsection{Experimental Outcomes}
|
\subsection{Experimental Outcomes}
|
||||||
|
|
||||||
Align with defined objectives, show results and statistical significance (or not).
|
To evaluate robustness contributions, we compare two policies on the same environment family: (i) robust pricing with COI-aware reward and adversarial contamination step, and (ii) a baseline policy with revenue-only reward.
|
||||||
|
|
||||||
|
We report two preliminary stages before the full factorial interpretation. First, we executed a short calibration run at $\alpha=0.3$ (2 evaluation episodes, 3000 training timesteps per tier) across \texttt{qtable}, \texttt{ppo}, \texttt{a2c}, and \texttt{dqn}. In that first run, \texttt{ppo} produced the highest objective score and revenue (objective $=3.76\mathrm{e}5$, revenue $=4.15\mathrm{e}5$), while the remaining tiers stayed lower in this small-budget regime. The corresponding price traces show a monotone escalation for \texttt{ppo} (mean price from $8.61\mathrm{e}1$ to $1.49\mathrm{e}2$), whereas \texttt{qtable}, \texttt{a2c}, and \texttt{dqn} remained nearly flat over the episode horizon. This confirms that the simulation loop is able to express policy-dependent pricing dynamics rather than collapsing into a single trajectory shape.
|
||||||
|
|
||||||
|
|
||||||
|
\subsubsection{The Impact of Contamination on Revenue}
|
||||||
|
|
||||||
|
The contamination--revenue slope is estimated on a controlled cohort (single sweep, baseline policy, $n_{\text{products}}=100$, $n=95$). In this setting, contamination $\alpha$ is set exogenously by the experiment, so the slope identifies the within-sweep causal effect of contamination on revenue under fixed policy and environment settings. The fitted linear model is
|
||||||
|
|
||||||
|
\[
|
||||||
|
\widehat{y}=348{,}823.41-90{,}140.53\,\alpha,
|
||||||
|
\]
|
||||||
|
with $t(93)=-61.45$, $p=4.27\times10^{-77}$, $R^2=0.976$, and a 95\% confidence interval for the slope of $[-93{,}053.38,\,-87{,}227.68]$. Interpreted on the contamination grid, a $+0.1$ increase in $\alpha$ corresponds to an average revenue decrease of about $9{,}014$ units. A heteroskedasticity-robust check (HC1) preserves the same direction and significance ($t=-41.25$, $p=1.42\times10^{-61}$), supporting a large and statistically stable impact in this controlled regime.
|
||||||
|
|
||||||
|
\subsubsection{Large Scale Factorial Training}
|
||||||
|
|
||||||
|
In our complete training runs we logged $\approx 180$ days of net compute time. The results we draw from extensive training are
|
||||||
|
\begin{enumerate*}[label=(\roman*)]
|
||||||
|
\item the ability to extract COI is greater in the presence of robustness within the training loop
|
||||||
|
\item short term revenue measurements suffer $\approx 3\%$ loss but COI margin compensates for this loss in the long run
|
||||||
|
\item a larger catalog size contributes positively to COI preservation under higher contamination ratios
|
||||||
|
\item supra-competitive pricing is a natural reward hacking tendency which is drastically reduced by a balanced UX penalty
|
||||||
|
\end{enumerate*}
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\input{chapters/figures/results/includes/final/final_focus_revenue_by_alpha.tex}
|
||||||
|
\caption{Revenue curves by contamination for the final cohort. The baseline remains above the defended curve in most cells, but the gap narrows in the high-contamination region.}
|
||||||
|
\label{fig:final_focus_revenue_by_alpha}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\input{chapters/figures/results/includes/final/final_focus_coi_by_alpha.tex}
|
||||||
|
\caption{COI level curves by contamination for the final cohort. The shaded band marks the per-$\alpha$ gap between defended and baseline policies.}
|
||||||
|
\label{fig:final_focus_coi_by_alpha}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\input{chapters/figures/results/includes/final/final_focus_coi_preservation_grid.tex}
|
||||||
|
\caption{COI preservation by product count at the contamination endpoints ($\alpha=0.0$ and $\alpha=1.0$). Bars report defended-minus-baseline mean COI level, with the zero line separating preservation from erosion.}
|
||||||
|
\label{fig:final_focus_coi_preservation_grid}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\input{chapters/figures/results/includes/final/final_focus_revenue_delta.tex}
|
||||||
|
\caption{Defended-minus-baseline revenue delta over contamination for the final cohort. The strongest high-contamination deviation begins at $\alpha=0.7$, followed by recovery toward near parity by $\alpha=1.0$.}
|
||||||
|
\label{fig:final_focus_revenue_delta}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
|
\begin{figure}[ht]
|
||||||
|
\centering
|
||||||
|
\input{chapters/figures/results/includes/final/final_focus_risk_deltas.tex}
|
||||||
|
\caption{Defended-minus-baseline leakage and volatility deltas for the final cohort. Leakage remains lower for the defended policy across the full contamination range.}
|
||||||
|
\label{fig:final_focus_risk_deltas}
|
||||||
|
\end{figure}
|
||||||
|
|
||||||
\subsection{Interpretation and Insights}
|
\subsection{Interpretation and Insights}
|
||||||
Inference from given patterns and show key findings.
|
The Mann-Whitney result ($p<0.001$) confirms that per-session divergence gaps distinguish the two actor classes with near-zero overlap in rank ordering. This is the condition required for distinguishability to act as a useful control signal in the pricing loop rather than just an auxiliary classifier score.
|
||||||
|
|
||||||
|
The first calibration and paired benchmark runs additionally confirm three practical points aligned with the thesis. First, the control loop is reproducible end-to-end (training, evaluation, artifact generation) across algorithms and contamination levels. Second, policy class materially changes price trajectories and resulting COI/revenue profiles under identical environment settings. Third, objective improvements from robustness are regime-dependent in the current baseline, which is consistent with the thesis claim that contamination-aware pricing needs explicit calibration rather than a one-size-fits-all penalty.
|
||||||
|
|
||||||
|
We also note that maximizing revenue in isolation can favor aggressive high-price behavior; even in these early runs, the non-robust aggregate shows slightly higher mean COI and margin. For this reason, all subsequent reporting in this thesis is interpreted on a multi-metric basis (objective, revenue, COI, and stability), and not by revenue alone.
|
||||||
|
|
||||||
|
|
||||||
\subsection{Anomalies}
|
\subsection{Anomalies}
|
||||||
|
In our initial runs, we observed an instability pocket in one completed run (A2C, robust, seed 11, $\alpha=0.30$) with a large performance drop relative to neighboring configurations. We retain this run in the preliminary summary to avoid survivorship bias and treat it as evidence that robustness sensitivity analysis is necessary before final conclusions.
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
\section{Discussion}
|
\section{Discussion}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\subsection{Transition to Agentic Market Microstructure}
|
\subsection{Transition to Agentic Market Microstructure}
|
||||||
|
|
||||||
Our analysis of the interaction dynamics between the platform and non-human actors suggests that the current static pricing models are insufficient for an agent-mediated economy. If we assume a transition toward a direct revelation mechanism, where actors must reveal their true valuation of a good through bidding dynamics, we inevitably introduce significant stochasticity into the pricing system. Unlike traditional e-commerce where prices are relatively sticky, such a mechanism implies a high volatility characteristic of financial equity markets (without the fungability however).
|
Our analysis of the interaction dynamics between the platform and non-human actors suggests that the current static pricing models are insufficient for an agent-mediated economy. If we assume a transition toward a direct revelation mechanism, where actors must reveal their true valuation of a good through bidding dynamics, we inevitably introduce significant stochasticity into the pricing system. Unlike traditional e-commerce where prices are relatively sticky, such a mechanism implies a high volatility characteristic of financial equity markets (without the fungability however).
|
||||||
|
|
||||||
However, ecommerce commodities differ fundamentally from financial securities: they possess a hard floor defined by unit economics and reservation prices. The market might react enthusiastically to an iPhone priced at \$1, such a transaction is not permissible. The platform must establish an initial valuation anchor ($P_{0}$) defined by the marginal cost plus a target margin, around which the market price is permitted to fluctuate. We propose the introduction of GenAI Agents as Institutional Market Makers.
|
However, ecommerce commodities differ fundamentally from financial securities: they possess a hard floor defined by unit economics and reservation prices. The market might react enthusiastically to an iPhone priced at \$1, such a transaction is not permissible. The platform must establish an initial valuation anchor ($P_{0}$) defined by the marginal cost plus a target margin, around which the market price is permitted to fluctuate. We float the introduction of GenAI Agents as Institutional Market Makers. As the arms race for greater autonomy of agnetic systems grows, the commercial viability of AI agents has the potential to disseminate into every-day users directly interacting with them rather than e-commerce platforms. This is also under the assumption of expected transactional capabilities being given to AI Agents.
|
||||||
|
|
||||||
This is also under the assumption of expected transactional capabilities being given to AI Agents.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
\subsection{Risk Assessment and Limitations}
|
\subsection{Risk Assessment and Limitations}
|
||||||
|
|
||||||
Acknowledge risks and constraints and data sizes.
|
This technology does not come without a more bitter side, ethical concerns do arise from the idea of deploying black-box like solutions to set prices based on a behavioral attributes. Approaches like universal behavioral profile modeling (UBPM) used in recommendation systems is very broadly utilized.
|
||||||
|
|
||||||
\subsection{Implications of Findings}
|
With a system like this there is potential for strong drift given the rapid advance of agentic systems and user preference. Our intent behind adding the UX term into the reward shaping process was to further address the risk of degraded user experience. Looking deeper at the underlying methodology, reinforcement learning does not come without it's complications such as reward hacking and often the lack of intepretability which is quite critical in systems that have a strong impact on the revenue of a company.
|
||||||
|
|
||||||
Interpretation of results and altenrative scenarios with broader market implications.
|
% \subsection{Implications of Findings} Interpretation of results and altenrative scenarios with broader market implications.
|
||||||
|
|||||||
@@ -1,8 +1,24 @@
|
|||||||
\section{Conclusion}
|
\section{Conclusion}
|
||||||
|
|
||||||
|
Our research has explored how reinforcement learning works within pricing systems and environments which are substantially disrupted by an adversarial participant. Our findings include the optimization for our newly introduced metrics.
|
||||||
|
|
||||||
\subsection{Summary of contributions}
|
\subsection{Summary of contributions}
|
||||||
Restate the thesis and key findings with validation of research objectives.
|
The contribution was not without the advice of many experienced experts in the field. We thank Marco Casalaina VP Products, Core AI and AI Futurist at Microsoft for the initial critical discussion on the topic of dynamic pricing systems and the spark which has lead to this work. Eugene Bykovets, PhD pointing out the parallels in blockchain systems and the complexity of anonymous interaction and understanding of intent. Importantly, the contributions of Alberto Martín Izquierdo, my academic advisor for the support over and for taking on the challenge of this ambitious work. Many breakthroughs were thanks to numerous discussions with my peers on the topics covered here.
|
||||||
|
A thanks to the head of innovation at Amadeus for insight into the industry split on the topic of collapsing margins. Finally we acknowledge the power and use of generative AI technologies for in depth research, rapid prototyping and surfacing of key topics and niches.
|
||||||
|
|
||||||
|
Now we very explicitly mention what we contribute in this paper:
|
||||||
|
\begin{itemize}
|
||||||
|
\item TPU-accelerated parallelization of the behavioral simulation and reinforcement learning pipeline, making large-scale factorial sweeps tractable.
|
||||||
|
\item Formalization of non-human transaction orchestration in e-commerce as a distinct source of contamination in dynamic pricing systems.
|
||||||
|
\item Definition of the Cost of Information (COI) as a mechanism-level quantity for pricing power, together with a theorem showing its erosion under increasing agent saturation.
|
||||||
|
\item Design and implementation of a controlled e-commerce research platform, built on a hybrid Kappa-Lambda architecture, for collecting and replaying high-fidelity interaction trajectories.
|
||||||
|
\item Construction and empirical validation of a behavioral distinguishability framework that distinguishes human and agent sessions from interaction signals alone using transition kernels and KL-based divergence.
|
||||||
|
\item Development of a generative contamination mechanism that injects learned agent behavior into the pricing environment for controlled robustness experiments.
|
||||||
|
\item Translation of behavioral distinguishability into a defensive pricing mechanism through a distributionally robust reinforcement learning formulation of pricing under non-stationary contamination.
|
||||||
|
\item Empirical evidence that agent contamination reduces revenue and that robustness is condition-dependent, requiring explicit calibration rather than a one-size-fits-all penalty.
|
||||||
|
\item Release of a reusable public experimental artifact for reproducing and extending research on dynamic pricing under agent-mediated traffic.
|
||||||
|
\end{itemize}
|
||||||
|
|
||||||
\subsection{Future Works and Next Steps}
|
\subsection{Future Works and Next Steps}
|
||||||
|
|
||||||
Identify the research gaps here and potential business implications and setup of business + Proposed extensions and a long term agenda.
|
During the eights months of research dedicated to this work, a plethora of opportunities and industry gaps was identified, sadly a majority of which could not be addressed directly.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user