mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
214 Commits
paper-lit-
...
optimizing
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
| 56585b3de8 | |||
| 5444a4ea13 | |||
| e8a9716f69 | |||
| e50d643fbf | |||
|
|
dac1e58a0d | ||
| 4667a1678f | |||
| a4b7b5b4b2 | |||
| 1a9901f118 | |||
| 5912062dc0 | |||
| 843564eeb0 | |||
| 9acc998cc9 | |||
| 802f31b4a1 | |||
| 66c4a0cd1d | |||
| 244af9ac09 | |||
| 76c31a2abd | |||
| 64ee7e6d9b | |||
| 1e04a928aa | |||
| 9b133cddfd | |||
| ded7290935 | |||
| 8e4dd59f90 | |||
| 024f6d4132 | |||
| 2b47c3499a | |||
| ef1d1f6557 | |||
| d7657db287 | |||
| e8229ac313 | |||
| bc6c481d03 | |||
| 895eea5674 | |||
| fba2a9739e | |||
| d1aa13360f | |||
| f6f9729424 | |||
| 29a13340b9 | |||
| e22286371f | |||
| e44feb7da0 | |||
| ebd2378859 | |||
| c4d82b2ecc | |||
| a9e2e7cbf3 | |||
| e0b074161b | |||
| 08c0afb55a | |||
| c4fd1352c9 | |||
| 4abef97bf7 | |||
| 33cb0d7e95 | |||
| e8ef850089 | |||
| e7cb48e9cd | |||
|
|
dba8f3fafa | ||
|
|
9843c5deab | ||
| 13959e4b28 | |||
|
|
2f481bd94b | ||
| 72877439ca | |||
| 0f5f8affab | |||
| ee70f02a1f | |||
| 22a2c255bd | |||
| ccc19f3493 | |||
| 00e3eff2fa | |||
| 440371dba4 | |||
| b05b510f70 | |||
| 04907df393 | |||
| b2f0746c01 | |||
| 7b2d80ac4c | |||
| 0ce12fbc3b | |||
| e9cf5f0736 | |||
| 82b54428b7 | |||
| 87a35fad2c | |||
| af23d2f736 | |||
| 9cb2b0fc44 | |||
| 7c330a19c6 | |||
|
|
eb95060380 | ||
| 61dd621532 | |||
| 4c368d48f2 | |||
| 3c141a4b6c | |||
| e89cb263d4 | |||
| 62a4008c29 | |||
| 8b429b7a8e | |||
| f9bf3de71e | |||
| 131323ef56 | |||
| ec4cf074e6 | |||
| 6a06a8af4a | |||
| 3fa98f375d | |||
| 201c98bcac | |||
| 8a08458478 | |||
| 7d09232e48 | |||
| 20132c084c | |||
| 26abff5864 | |||
| 4c7d9362af | |||
| ea45801845 | |||
|
|
574e05d9e0 | ||
| 52fe865598 | |||
| 28d3f6853e | |||
| 10e8397eec | |||
| 772772b5b9 | |||
| 6e06081d60 | |||
| 83d9bb2552 | |||
| fa2aca8b13 | |||
| cd6c3d6006 | |||
|
|
b5f19e04b7 | ||
| 98a9a3738c | |||
| 1224841a82 | |||
| 4033e73ba1 | |||
| bae51daa1c | |||
| c5eae17924 | |||
| 28669ea4c3 | |||
| b0a1647956 | |||
| 19bb4fd517 | |||
| 4e2e41d943 | |||
| a033e77697 | |||
| 40e0b201e6 | |||
| a217d53556 | |||
| a6e6cc5d60 | |||
| fa89347c4e | |||
| 2b3d937be6 | |||
| 20c47fe85f | |||
| b7161573d7 | |||
| c15bb1882e | |||
| dee6f573e3 | |||
| 2ed200f870 | |||
| 56308ecb10 | |||
| 7fcd18c3cb | |||
| 5f607a58eb | |||
| 6aad196234 | |||
| e5060babfa | |||
| 80863e9b17 | |||
| a5029f2eab | |||
| c102ac482e | |||
| 08ade8dc89 | |||
| 95d4f0cee2 | |||
| 3072e5f46e | |||
| a1e3166322 | |||
| 6f361b96a8 | |||
| eea019ab3f | |||
| a36973cb42 | |||
| 96180e9af1 | |||
|
|
e60c0c64e1 | ||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d |
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
||||
.git
|
||||
.venv
|
||||
.venv-tpu
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
**/.ipynb_checkpoints
|
||||
wandb
|
||||
build
|
||||
paper/build
|
||||
paper/build-cais
|
||||
node_modules
|
||||
**/node_modules
|
||||
*.egg-info
|
||||
24
.env.sweep.example
Normal file
24
.env.sweep.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Copy this file to .env.sweep and fill in values.
|
||||
|
||||
# Required for wandb runs and sweep agent workers.
|
||||
WANDB_API_KEY=
|
||||
WANDB_ENTITY=
|
||||
WANDB_PROJECT=capstone
|
||||
|
||||
# Required for private repo bootstrap workers.
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Optional defaults for bootstrap mode.
|
||||
# REPO_URL=https://github.com/org/repo.git
|
||||
# BRANCH=main
|
||||
# WORKDIR=$HOME/PHANTOM-agent
|
||||
# SWEEP_ID=entity/project/id
|
||||
# AGENT_COUNT=0
|
||||
# AGENT_LOOP=1
|
||||
# RETRY_SECONDS=20
|
||||
|
||||
# Optional local benchmark defaults.
|
||||
# LOCAL_BENCHMARK_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
|
||||
# SIMPLE_BENCHMARK_ARGS=--tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
|
||||
# PHANTOM_BENCHMARK_COMPARE_ROBUST=1
|
||||
# BENCHMARK_AGENT_ARGS=--tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3,0.6 --episodes 5
|
||||
125
.github/workflows/latex.yml
vendored
125
.github/workflows/latex.yml
vendored
@@ -12,32 +12,92 @@ on:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Compile LaTeX document
|
||||
|
||||
- name: Prepare appendix code snapshot
|
||||
run: bash paper/concat_code.sh
|
||||
|
||||
- name: Generate mirrors with Codex
|
||||
if: ${{ env.OPENAI_API_KEY != '' }}
|
||||
uses: openai/codex-action@v1
|
||||
with:
|
||||
openai-api-key: ${{ env.OPENAI_API_KEY }}
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
working-directory: .
|
||||
prompt: |
|
||||
Read and follow the mirror instructions in `paper/src/mirrors/genpop/INSTRUCTIONS.md`.
|
||||
|
||||
Source chapters are in `paper/src/chapters/`:
|
||||
- 01-intro.tex
|
||||
- 02-literature-review.tex
|
||||
- 03-methodology.tex
|
||||
- 04-results.tex
|
||||
- 05-discussion.tex
|
||||
- 06-conclusion.tex
|
||||
|
||||
Update `paper/src/mirrors/genpop/*.tex` so they mirror the thesis for a general audience according to the instruction file.
|
||||
Keep LaTeX valid and preserve citation commands and section order.
|
||||
|
||||
Then create or update `paper/src/main-mirror-genpop.tex` by using `paper/src/main.tex` as the base and replacing chapter inputs from `chapters/...` to `mirrors/genpop/...`.
|
||||
Do not change any other project files.
|
||||
|
||||
- name: Compute LaTeX roots
|
||||
id: roots
|
||||
run: |
|
||||
{
|
||||
echo "root_files<<EOF"
|
||||
echo "main.tex"
|
||||
for file in paper/src/main-mirror-*.tex; do
|
||||
if [ -f "$file" ]; then
|
||||
basename "$file"
|
||||
fi
|
||||
done
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Compiling roots:"
|
||||
echo "main.tex"
|
||||
for file in paper/src/main-mirror-*.tex; do
|
||||
if [ -f "$file" ]; then
|
||||
basename "$file"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Compile LaTeX documents
|
||||
uses: xu-cheng/latex-action@v3
|
||||
with:
|
||||
root_file: main.tex
|
||||
root_file: ${{ steps.roots.outputs.root_files }}
|
||||
working_directory: paper/src
|
||||
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
||||
pre_compile: bash ../concat_code.sh
|
||||
- name: Upload PDF
|
||||
args: -pdf -f -interaction=nonstopmode -file-line-error -r ../.latexmkrc -outdir=../build
|
||||
|
||||
- name: Upload PDF artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thesis-pdf
|
||||
path: paper/build/main.pdf
|
||||
path: |
|
||||
paper/build/main.pdf
|
||||
paper/build/main-mirror-*.pdf
|
||||
|
||||
- name: Get current date
|
||||
id: date
|
||||
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload to Cloudflare R2
|
||||
if: ${{ env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' && env.R2_ENDPOINT != '' && env.R2_BUCKET_NAME != '' }}
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
|
||||
AWS_ACCESS_KEY_ID: ${{ env.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ env.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_ENDPOINT_URL: ${{ env.R2_ENDPOINT }}
|
||||
DATE: ${{ steps.date.outputs.date }}
|
||||
BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
BUCKET_NAME: ${{ env.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
pip install boto3
|
||||
python3 << 'EOF'
|
||||
@@ -71,4 +131,49 @@ jobs:
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded thesis-latest.pdf")
|
||||
|
||||
# upload mirror versions (if generated)
|
||||
build_dir = 'paper/build'
|
||||
for filename in os.listdir(build_dir):
|
||||
if not filename.startswith('main-mirror-') or not filename.endswith('.pdf'):
|
||||
continue
|
||||
mirror_name = filename[len('main-mirror-'):-4]
|
||||
source_path = os.path.join(build_dir, filename)
|
||||
|
||||
dated_mirror = f"thesis-{mirror_name}-{date}.pdf"
|
||||
latest_mirror = f"thesis-{mirror_name}-latest.pdf"
|
||||
namespaced_dated = f"mirrors/{mirror_name}/thesis-{date}.pdf"
|
||||
namespaced_latest = f"mirrors/{mirror_name}/thesis-latest.pdf"
|
||||
|
||||
s3.upload_file(
|
||||
source_path,
|
||||
bucket,
|
||||
dated_mirror,
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded {dated_mirror}")
|
||||
|
||||
s3.upload_file(
|
||||
source_path,
|
||||
bucket,
|
||||
latest_mirror,
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded {latest_mirror}")
|
||||
|
||||
s3.upload_file(
|
||||
source_path,
|
||||
bucket,
|
||||
namespaced_dated,
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded {namespaced_dated}")
|
||||
|
||||
s3.upload_file(
|
||||
source_path,
|
||||
bucket,
|
||||
namespaced_latest,
|
||||
ExtraArgs={'ContentType': 'application/pdf'}
|
||||
)
|
||||
print(f"Uploaded {namespaced_latest}")
|
||||
EOF
|
||||
|
||||
90
.gitignore
vendored
90
.gitignore
vendored
@@ -1,22 +1,92 @@
|
||||
# environment and secrets
|
||||
**/.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
**/.venv
|
||||
**/.venv-ray
|
||||
|
||||
# python build/cache artifacts
|
||||
**/__pycache__
|
||||
phantom.egg-info/
|
||||
*.egg-info/
|
||||
|
||||
# notebook artifacts
|
||||
**/.ipynb_checkpoints/
|
||||
**/.virtual_documents/
|
||||
|
||||
# editor/tool state
|
||||
**/.pdf-view-restore
|
||||
.nextstep
|
||||
.ignore-gitlogue
|
||||
.cloudflare
|
||||
.nx/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# generated svg/graphics
|
||||
**/session_*.svg
|
||||
**/*graph.svg
|
||||
paper/src/bib/auto
|
||||
**/auto/*.el
|
||||
|
||||
# Airflow logs - exclude DAG run logs
|
||||
# misc generated
|
||||
*.old
|
||||
**/package-lock.json
|
||||
**/*.parquet
|
||||
**/_build/
|
||||
|
||||
# paper build artifacts
|
||||
paper/src/bib/auto
|
||||
paper/src/auto/*
|
||||
paper/src/bib/auto
|
||||
paper/template/*
|
||||
paper/build-cais/
|
||||
paper/defense/manim/media/
|
||||
paper/defense/manim/.manim/
|
||||
paper/src/main.pdf
|
||||
paper/src/main-blx.bib
|
||||
paper/src/svg-inkscape/
|
||||
paper/variations/
|
||||
paper/src/graphics/test_*.png
|
||||
thesis-latest.pdf
|
||||
|
||||
# experiment run artifacts and logs
|
||||
docs/goals/*.md
|
||||
PHANTOM.wiki/
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
experiments/collected_data/*
|
||||
|
||||
paper/src/auto/*
|
||||
lib/
|
||||
docs/goals/*.md
|
||||
PHANTOM.wiki/
|
||||
experiments/collected_data/
|
||||
experiments/agents/collected_data/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/node_modules/**
|
||||
**/auto/*.el
|
||||
*.old
|
||||
|
||||
# rl/sim run outputs
|
||||
sim/rl/behavior_loader/*.dot
|
||||
sim/rl/behavior_loader/*.png
|
||||
sim/rl/behavior_loader/*.svg
|
||||
sim/rl/behavior_loader/*.pdf
|
||||
sim/rl/runs/
|
||||
lab/case/thesis/runs*/
|
||||
sim/case/thesis_simplified/runs*/
|
||||
|
||||
# model binaries
|
||||
engine/models/*.zip
|
||||
engine/studies/results/*
|
||||
*.zip
|
||||
|
||||
# wandb local state
|
||||
wandb/
|
||||
|
||||
# data directory (large datasets)
|
||||
data/
|
||||
|
||||
# ktem local app data
|
||||
ktem_app_data/
|
||||
|
||||
# generated visualization pdfs
|
||||
*_mdp_viz.pdf
|
||||
phantom_env_comparison.png
|
||||
sim/phantom_env_comparison.png
|
||||
|
||||
# web clone
|
||||
PHANTOM_web/*
|
||||
|
||||
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/
|
||||
238
Makefile
238
Makefile
@@ -8,88 +8,236 @@ VENV := .venv
|
||||
PYTHON := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
PYTEST := $(VENV)/bin/pytest
|
||||
NX := npx nx
|
||||
|
||||
SWEEP_ENV_FILE ?= .env.sweep
|
||||
TPU_CONF ?= tpu_orchestration/configs/v4_spot_us.conf
|
||||
|
||||
WANDB_ENTITY ?=
|
||||
WANDB_PROJECT ?= capstone
|
||||
SWEEP_ID ?=
|
||||
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
|
||||
LOCAL_BENCHMARK_ARGS ?= --tiers static,surge,linear,qtable,ppo --alpha-values 0.0,0.3 --episodes 3 --total-timesteps 3000 --max-steps 40 --device cpu
|
||||
SIMPLE_BENCHMARK_ARGS ?= --tiers qtable,ppo,dqn,a2c --alpha-values 0.0,0.15,0.3,0.45,0.6 --episodes 8 --total-timesteps 8000 --max-steps 40 --device cpu
|
||||
BENCHMARK_AGENT_ARGS ?=
|
||||
AGENT_COUNT ?= 0
|
||||
|
||||
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 ?=
|
||||
BRANCH ?= main
|
||||
WORKDIR ?= $(HOME)/PHANTOM-agent
|
||||
AGENT_LOOP ?= 1
|
||||
RETRY_SECONDS ?= 20
|
||||
|
||||
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
|
||||
|
||||
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
|
||||
@echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines | manim.render manim.render.all"
|
||||
@echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish"
|
||||
@echo "data.pull data.push 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 "Local wandb run:"
|
||||
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
|
||||
@echo ""
|
||||
@echo "Local benchmark run:"
|
||||
@echo " make benchmark LOCAL_BENCHMARK_ARGS='--tiers static,surge,linear --alpha-values 0.0,0.3 --episodes 3 --no-wandb'"
|
||||
@echo ""
|
||||
@echo "Simple benchmark run (.env.sweep defaults, robust+no_robust compare by default):"
|
||||
@echo " make benchmark.simple"
|
||||
@echo ""
|
||||
@echo "Local sweep agent from this repo:"
|
||||
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
|
||||
@echo ""
|
||||
@echo "Bootstrap private repo worker from anywhere:"
|
||||
@echo " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id"
|
||||
@echo ""
|
||||
@echo "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)"
|
||||
|
||||
$(BUILDDIR):
|
||||
mkdir -p paper/$(BUILDDIR)
|
||||
|
||||
.PHONY: pdf.build
|
||||
pdf.build: $(BUILDDIR)
|
||||
@bash paper/concat_code.sh
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
pdf.build:
|
||||
@$(NX) run paper:build
|
||||
|
||||
.PHONY: pdf.watch
|
||||
pdf.watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
pdf.watch:
|
||||
@$(NX) run paper:watch
|
||||
|
||||
.PHONY: pdf.clean
|
||||
pdf.clean:
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
rm -rf paper/$(BUILDDIR)/*
|
||||
@$(NX) run paper:clean
|
||||
|
||||
.PHONY: pdf.genpop
|
||||
pdf.genpop:
|
||||
@bash scripts/nx_paper.sh build-genpop
|
||||
|
||||
.PHONY: pdf.genpop.watch
|
||||
pdf.genpop.watch:
|
||||
@bash scripts/nx_paper.sh watch-genpop
|
||||
|
||||
.PHONY: pdf.arxiv
|
||||
pdf.arxiv:
|
||||
@bash scripts/nx_paper.sh build-arxiv
|
||||
|
||||
.PHONY: test.backend
|
||||
test.backend: $(VENV)
|
||||
$(PYTEST) -v
|
||||
test.backend:
|
||||
@$(NX) run research:test
|
||||
|
||||
.PHONY: test.e2e
|
||||
test.e2e:
|
||||
@cd tests/e2e && npm install
|
||||
@cd tests/e2e && npx playwright install chromium
|
||||
@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)
|
||||
@cd tests/e2e && npm test
|
||||
@$(NX) run e2e:test
|
||||
|
||||
.PHONY: test.all
|
||||
test.all: test.backend test.e2e
|
||||
test.all:
|
||||
@$(NX) run-many -t test --projects=research,e2e --parallel=1
|
||||
|
||||
.PHONY: web.dev
|
||||
web.dev:
|
||||
@cd web && npm install && npm run dev
|
||||
@$(NX) run web:dev
|
||||
|
||||
$(VENV):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
.PHONY: install
|
||||
install: $(VENV)
|
||||
$(PIP) install -r requirements.txt
|
||||
install:
|
||||
@$(NX) run research:install
|
||||
|
||||
.PHONY: train
|
||||
train:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_TRAIN_ARGS="$(LOCAL_TRAIN_ARGS)" $(NX) run research:train
|
||||
|
||||
.PHONY: benchmark
|
||||
benchmark:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" LOCAL_BENCHMARK_ARGS="$(LOCAL_BENCHMARK_ARGS)" $(NX) run research:benchmark
|
||||
|
||||
.PHONY: benchmark.simple
|
||||
benchmark.simple:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SIMPLE_BENCHMARK_ARGS="$(SIMPLE_BENCHMARK_ARGS)" PHANTOM_BENCHMARK_COMPARE_ROBUST="$(PHANTOM_BENCHMARK_COMPARE_ROBUST)" $(NX) run research:benchmark-simple
|
||||
|
||||
.PHONY: benchmark.agent
|
||||
benchmark.agent:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" BENCHMARK_AGENT_ARGS="$(BENCHMARK_AGENT_ARGS)" $(NX) run research:benchmark-agent
|
||||
|
||||
.PHONY: train.agent
|
||||
train.agent:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" $(NX) run research:train-agent
|
||||
|
||||
.PHONY: train.bootstrap
|
||||
train.bootstrap:
|
||||
@WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" REPO_URL="$(REPO_URL)" BRANCH="$(BRANCH)" WORKDIR="$(WORKDIR)" SWEEP_ID="$(SWEEP_ID)" AGENT_COUNT="$(AGENT_COUNT)" AGENT_LOOP="$(AGENT_LOOP)" RETRY_SECONDS="$(RETRY_SECONDS)" $(NX) run research:train-bootstrap
|
||||
|
||||
.PHONY: tpu.ray.bootstrap tpu.ray.deps tpu.ray.verify tpu.ray.teardown
|
||||
tpu.ray.bootstrap:
|
||||
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-bootstrap
|
||||
|
||||
tpu.ray.deps:
|
||||
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-deps
|
||||
|
||||
tpu.ray.verify:
|
||||
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-verify
|
||||
|
||||
tpu.ray.teardown:
|
||||
@TPU_CONF="$(TPU_CONF)" SWEEP_ENV_FILE="$(SWEEP_ENV_FILE)" $(NX) run research:tpu-ray-teardown
|
||||
|
||||
.PHONY: data.pull data.push
|
||||
data.pull:
|
||||
python scripts/hf_data.py pull
|
||||
|
||||
data.push:
|
||||
python scripts/hf_data.py push
|
||||
|
||||
.PHONY: data.whoclicked.publish
|
||||
data.whoclicked.publish:
|
||||
@HF_TOKEN="$(HF_TOKEN)" WHOCLICKED_REPO="$(WHOCLICKED_REPO)" WHOCLICKED_CSV="$(WHOCLICKED_CSV)" WHOCLICKED_CARD="$(WHOCLICKED_CARD)" WHOCLICKED_CSV_PATH_IN_REPO="$(WHOCLICKED_CSV_PATH_IN_REPO)" WHOCLICKED_CARD_PATH_IN_REPO="$(WHOCLICKED_CARD_PATH_IN_REPO)" WHOCLICKED_DATASET_MESSAGE="$(WHOCLICKED_DATASET_MESSAGE)" WHOCLICKED_CARD_MESSAGE="$(WHOCLICKED_CARD_MESSAGE)" $(NX) run research:whoclicked-publish
|
||||
|
||||
.PHONY: stats.lines
|
||||
stats.lines:
|
||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
||||
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||
@$(NX) run research:stats
|
||||
|
||||
.PHONY: 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
|
||||
wordcount:
|
||||
@echo "Counting words in main text (excluding appendix)..."
|
||||
@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
|
||||
@$(NX) run paper:wordcount
|
||||
|
||||
.PHONY: docker.train.publish
|
||||
docker.train.publish:
|
||||
@TRAIN_IMAGE_REF="$(TRAIN_IMAGE_REF)" $(NX) run research:docker-train-publish
|
||||
|
||||
.PHONY: backend.server backend.provider backend.worker platform.up platform.down platform.logs
|
||||
backend.server:
|
||||
@$(NX) run backend-server:dev
|
||||
|
||||
backend.provider:
|
||||
@$(NX) run pricing-provider:dev
|
||||
|
||||
backend.worker:
|
||||
@$(NX) run backend-worker:dev
|
||||
|
||||
platform.up:
|
||||
@$(NX) run platform:up
|
||||
|
||||
platform.down:
|
||||
@$(NX) run platform:down
|
||||
|
||||
platform.logs:
|
||||
@$(NX) run platform:logs
|
||||
|
||||
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||
pdf: pdf.build
|
||||
clean: pdf.clean
|
||||
watch: pdf.watch
|
||||
run.webapp: web.dev
|
||||
test: test.backend
|
||||
count-lines: stats.lines
|
||||
all: pdf.build
|
||||
pdf:
|
||||
@$(NX) run paper:build
|
||||
|
||||
clean:
|
||||
@$(NX) run paper:clean
|
||||
|
||||
watch:
|
||||
@$(NX) run paper:watch
|
||||
|
||||
run.webapp:
|
||||
@$(NX) run web:dev
|
||||
|
||||
test:
|
||||
@$(NX) run research:test
|
||||
|
||||
count-lines:
|
||||
@$(NX) run research:stats
|
||||
|
||||
all:
|
||||
@$(NX) run paper:build
|
||||
|
||||
.PHONY: manim.render manim.render.all
|
||||
manim.render:
|
||||
@$(NX) run manim:render
|
||||
|
||||
manim.render.all:
|
||||
@$(NX) run manim:render-all
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
### PHANTOM
|
||||
|
||||
[](https://huggingface.co/datasets/velocitatem/whoclickedit)
|
||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||
[](https://sites.research.google/trc/faq/)
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -47,53 +47,52 @@ def health() -> dict:
|
||||
|
||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||
"""
|
||||
THIS is the fast lookup service (mechanism).
|
||||
Priority: session-keyed price > global optimal price > base price
|
||||
"""
|
||||
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
# fetch pre-computed prices from registry
|
||||
# PRIORITY 1: session-aware price (computed by Airflow worker)
|
||||
if sessionId:
|
||||
session_price = registry.get_session_price(sessionId, productId)
|
||||
if session_price is not None:
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=session_price,
|
||||
base_price=base_price,
|
||||
markup=session_price/base_price,
|
||||
elasticity=None,
|
||||
model_version='session-aware'
|
||||
)
|
||||
|
||||
# PRIORITY 2: global pre-computed prices (surge pricing)
|
||||
prices_df = registry.get_prices('latest')
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
if prices_df is None:
|
||||
# fallback: no pre-computed prices available
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
# lookup pre-computed price for this product
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if product_price_row.empty:
|
||||
# product not in pre-computed prices, fallback to base
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
||||
|
||||
# get elasticity if available
|
||||
product_elasticity = None
|
||||
if elasticity_df is not None:
|
||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||
if not product_elasticity_row.empty:
|
||||
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
|
||||
if prices_df is not None:
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if not product_price_row.empty:
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=None,
|
||||
model_version='surge'
|
||||
)
|
||||
|
||||
# PRIORITY 3: fallback to base price
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=product_elasticity
|
||||
markup=1.0,
|
||||
elasticity=None,
|
||||
model_version='base'
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
|
||||
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"
|
||||
]
|
||||
}
|
||||
@@ -198,12 +198,16 @@ def dump_logs(
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=5000
|
||||
consumer_timeout_ms=30000,
|
||||
fetch_max_wait_ms=10000,
|
||||
max_poll_records=1000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if last_n and len(events) >= last_n * 2:
|
||||
break
|
||||
|
||||
consumer.close()
|
||||
|
||||
|
||||
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
|
||||
uvicorn[standard]==0.24.0
|
||||
kafka-python==2.0.2
|
||||
pydantic==2.5.0
|
||||
python-dotenv==1.0.0
|
||||
supabase==2.9.1
|
||||
fastapi>=0.135,<0.136
|
||||
uvicorn[standard]>=0.41,<0.42
|
||||
kafka-python>=2.3,<2.4
|
||||
pydantic>=2.12,<3
|
||||
python-dotenv>=1.0,<2
|
||||
supabase>=2.28,<3
|
||||
|
||||
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:
|
||||
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:
|
||||
image: tensorflow/tensorflow:latest
|
||||
container_name: "PHANTOM-tensorboard-rl"
|
||||
@@ -112,11 +131,14 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- _AIRFLOW_DB_MIGRATE=true
|
||||
- _AIRFLOW_WWW_USER_CREATE=true
|
||||
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||
@@ -136,14 +158,20 @@ services:
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
@@ -173,13 +201,20 @@ services:
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
|
||||
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"]
|
||||
15
docker/Trainer.dockerfile
Normal file
15
docker/Trainer.dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM pytorch/pytorch:2.5.1-cuda12.4-cudnn9-runtime AS gpu
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY docker/trainer.requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
||||
COPY engine /app/engine
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
||||
23
docker/trainer-agent-entrypoint.sh
Normal file
23
docker/trainer-agent-entrypoint.sh
Normal file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
if [ -z "${SWEEP_ID:-}" ]; then
|
||||
echo "SWEEP_ID is required"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set -- python -m engine.train --sweep-agent --sweep-id "${SWEEP_ID}"
|
||||
|
||||
if [ -n "${PHANTOM_DEFAULT_AGENT_ARGS:-}" ]; then
|
||||
set -- "$@" ${PHANTOM_DEFAULT_AGENT_ARGS}
|
||||
fi
|
||||
|
||||
if [ -n "${TRAIN_ARGS:-}" ]; then
|
||||
set -- "$@" ${TRAIN_ARGS}
|
||||
fi
|
||||
|
||||
if [ "${AGENT_COUNT:-0}" != "0" ]; then
|
||||
set -- "$@" --count "${AGENT_COUNT}"
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
7
docker/trainer.requirements.txt
Normal file
7
docker/trainer.requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
numpy>=1.24.0
|
||||
pandas>=2.0.0
|
||||
scipy>=1.11.0
|
||||
gymnasium>=0.29.0
|
||||
stable-baselines3>=2.2.0
|
||||
tensorboard>=2.15.0
|
||||
wandb>=0.17.0
|
||||
264
docs/index.html
264
docs/index.html
@@ -17,8 +17,8 @@
|
||||
<meta property="og:site_name" content="PHANTOM Research">
|
||||
<meta property="og:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
||||
<meta property="og:description" content="Developing pricing heuristics to protect e-commerce platforms from systematic exploitation by LLM agents in dynamic pricing environments through behavioral signature detection.">
|
||||
<meta property="og:url" content="TODO">
|
||||
<meta property="og:image" content="TODO">
|
||||
<meta property="og:url" content="https://velocitatem.github.io/PHANTOM/">
|
||||
<meta property="og:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:image:alt" content="PHANTOM Research Preview">
|
||||
@@ -30,17 +30,12 @@
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<!-- TODO: Replace with your lab/institution Twitter handle -->
|
||||
<meta name="twitter:site" content="@YOUR_TWITTER_HANDLE">
|
||||
<!-- TODO: Replace with first author's Twitter handle -->
|
||||
<meta name="twitter:creator" content="@AUTHOR_TWITTER_HANDLE">
|
||||
<!-- TODO: Same as paper title above -->
|
||||
<meta name="twitter:title" content="PAPER_TITLE">
|
||||
<!-- TODO: Same as description above -->
|
||||
<meta name="twitter:description" content="BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS">
|
||||
<!-- TODO: Same as social preview image above -->
|
||||
<meta name="twitter:image" content="https://YOUR_DOMAIN.com/static/images/social_preview.png">
|
||||
<meta name="twitter:image:alt" content="PAPER_TITLE - Research Preview">
|
||||
<meta name="twitter:site" content="@velocitatem">
|
||||
<meta name="twitter:creator" content="@velocitatem">
|
||||
<meta name="twitter:title" content="PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
||||
<meta name="twitter:description" content="A thesis project on defending dynamic pricing against LLM-driven reconnaissance and transaction orchestration.">
|
||||
<meta name="twitter:image" content="https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg">
|
||||
<meta name="twitter:image:alt" content="PHANTOM research visual">
|
||||
|
||||
<!-- Academic/Research Specific -->
|
||||
<meta name="citation_title" content="Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms">
|
||||
@@ -103,50 +98,42 @@
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "ScholarlyArticle",
|
||||
"headline": "PAPER_TITLE",
|
||||
"description": "BRIEF_DESCRIPTION_OF_YOUR_RESEARCH_CONTRIBUTION_AND_FINDINGS",
|
||||
"headline": "PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms",
|
||||
"description": "Research on preserving dynamic pricing integrity under LLM-mediated reconnaissance and purchasing behavior.",
|
||||
"author": [
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "FIRST_AUTHOR_NAME",
|
||||
"name": "Daniel Rösel",
|
||||
"affiliation": {
|
||||
"@type": "Organization",
|
||||
"name": "INSTITUTION_NAME"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "Person",
|
||||
"name": "SECOND_AUTHOR_NAME",
|
||||
"affiliation": {
|
||||
"@type": "Organization",
|
||||
"name": "INSTITUTION_NAME"
|
||||
"name": "IE University"
|
||||
}
|
||||
}
|
||||
],
|
||||
"datePublished": "2024-01-01",
|
||||
"datePublished": "2025-01-01",
|
||||
"publisher": {
|
||||
"@type": "Organization",
|
||||
"name": "CONFERENCE_OR_JOURNAL_NAME"
|
||||
"name": "IE University"
|
||||
},
|
||||
"url": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE",
|
||||
"image": "https://YOUR_DOMAIN.com/static/images/social_preview.png",
|
||||
"keywords": ["KEYWORD1", "KEYWORD2", "KEYWORD3", "machine learning", "computer vision"],
|
||||
"abstract": "FULL_ABSTRACT_TEXT_HERE",
|
||||
"citation": "BIBTEX_CITATION_HERE",
|
||||
"url": "https://velocitatem.github.io/PHANTOM/",
|
||||
"image": "https://raw.githubusercontent.com/velocitatem/PHANTOM/main/docs/static/images/carousel1.jpg",
|
||||
"keywords": ["dynamic pricing", "llm agents", "e-commerce", "distributionally robust optimization", "reinforcement learning"],
|
||||
"abstract": "This thesis formalizes Cost of Information erosion under agentic reconnaissance, learns separable human and agent behavior kernels, and trains contamination-aware robust pricing policies.",
|
||||
"citation": "Rösel, Daniel. PHANTOM: Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms. IE University, 2025.",
|
||||
"isAccessibleForFree": true,
|
||||
"license": "https://creativecommons.org/licenses/by/4.0/",
|
||||
"mainEntity": {
|
||||
"@type": "WebPage",
|
||||
"@id": "https://YOUR_DOMAIN.com/YOUR_PROJECT_PAGE"
|
||||
"@id": "https://velocitatem.github.io/PHANTOM/"
|
||||
},
|
||||
"about": [
|
||||
{
|
||||
"@type": "Thing",
|
||||
"name": "RESEARCH_AREA_1"
|
||||
"name": "Dynamic Pricing"
|
||||
},
|
||||
{
|
||||
"@type": "Thing",
|
||||
"name": "RESEARCH_AREA_2"
|
||||
"name": "Agent Behavior Modeling"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -158,8 +145,7 @@
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "IE University",
|
||||
"url": "https://www.ie.edu",
|
||||
"logo": "TODO"
|
||||
"url": "https://www.ie.edu"
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
@@ -173,45 +159,72 @@
|
||||
|
||||
<!-- More Works Dropdown -->
|
||||
<div class="more-works-container">
|
||||
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View More Works from Our Lab">
|
||||
<button class="more-works-btn" onclick="toggleMoreWorks()" title="View project links and artifacts">
|
||||
<i class="fas fa-flask"></i>
|
||||
More Works
|
||||
Project Links
|
||||
<i class="fas fa-chevron-down dropdown-arrow"></i>
|
||||
</button>
|
||||
<div class="more-works-dropdown" id="moreWorksDropdown">
|
||||
<div class="dropdown-header">
|
||||
<h4>More Works from Our Lab</h4>
|
||||
<h4>Project Links</h4>
|
||||
<button class="close-btn" onclick="toggleMoreWorks()">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="works-list">
|
||||
<!-- TODO: Replace with your lab's related works -->
|
||||
<a href="https://arxiv.org/abs/PAPER_ID_1" class="work-item" target="_blank">
|
||||
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<!-- TODO: Replace with actual paper title -->
|
||||
<h5>Paper Title 1</h5>
|
||||
<!-- TODO: Replace with brief description -->
|
||||
<p>Brief description of the work and its main contribution.</p>
|
||||
<!-- TODO: Replace with venue and year -->
|
||||
<span class="work-venue">Conference/Journal 2024</span>
|
||||
<h5>Thesis PDF</h5>
|
||||
<p>Latest public build of the full thesis document.</p>
|
||||
<span class="work-venue">IE University, 2025</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<!-- TODO: Add more related works or remove extra items -->
|
||||
<a href="https://arxiv.org/abs/PAPER_ID_2" class="work-item" target="_blank">
|
||||
<a href="https://github.com/velocitatem/PHANTOM" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Paper Title 2</h5>
|
||||
<p>Brief description of the work and its main contribution.</p>
|
||||
<span class="work-venue">Conference/Journal 2023</span>
|
||||
<h5>PHANTOM Repository</h5>
|
||||
<p>Monorepo with paper source, platform code, and experiments.</p>
|
||||
<span class="work-venue">Open Source</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<a href="https://arxiv.org/abs/PAPER_ID_3" class="work-item" target="_blank">
|
||||
<a href="https://github.com/velocitatem/p4p" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Paper Title 3</h5>
|
||||
<p>Brief description of the work and its main contribution.</p>
|
||||
<span class="work-venue">Conference/Journal 2023</span>
|
||||
<h5>P4P Interaction Layer</h5>
|
||||
<p>Reusable storefront and logging layer released for replication.</p>
|
||||
<span class="work-venue">Public Artifact</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<a href="https://phantom-hotel.vercel.app" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Hotel Mode Demo</h5>
|
||||
<p>Public deployment of the hotel-style experiment interface.</p>
|
||||
<span class="work-venue">Live Demo</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<a href="https://phantom-airline.vercel.app" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Airline Mode Demo</h5>
|
||||
<p>Public deployment of the airline-style experiment interface.</p>
|
||||
<span class="work-venue">Live Demo</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<a href="https://blog.alves.world/series/phantom" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Blog Series</h5>
|
||||
<p>Behind-the-scenes posts covering thesis process, tooling, and insights.</p>
|
||||
<span class="work-venue">To Boldly Code</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
<a href="goals/README.md" class="work-item" target="_blank">
|
||||
<div class="work-info">
|
||||
<h5>Goal Library</h5>
|
||||
<p>Task definitions used to assign actor objectives in experiments.</p>
|
||||
<span class="work-venue">Experiment Design</span>
|
||||
</div>
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
@@ -239,6 +252,16 @@
|
||||
<div class="column has-text-centered">
|
||||
<div class="publication-links">
|
||||
<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"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
@@ -248,14 +271,13 @@
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<!-- TODO: Add your supplementary material PDF or remove this section -->
|
||||
<span class="link-block">
|
||||
<a href="static/pdfs/supplementary_material.pdf" target="_blank"
|
||||
<a href="https://huggingface.co/datasets/velocitatem/whoclickedit" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-pdf"></i>
|
||||
<i class="fas fa-database"></i>
|
||||
</span>
|
||||
<span>Supplementary</span>
|
||||
<span>Dataset</span>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -269,42 +291,43 @@
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<a href="https://arxiv.org/abs/<ARXIV PAPER ID>" target="_blank"
|
||||
<a href="https://phantom-hotel.vercel.app" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="ai ai-arxiv"></i>
|
||||
<i class="fas fa-globe"></i>
|
||||
</span>
|
||||
<span>arXiv</span>
|
||||
<span>Hotel Demo</span>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span class="link-block">
|
||||
<a href="https://phantom-airline.vercel.app" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="fas fa-plane"></i>
|
||||
</span>
|
||||
<span>Airline Demo</span>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Teaser video-->
|
||||
<section class="hero teaser">
|
||||
<div class="container is-max-desktop">
|
||||
<div class="hero-body">
|
||||
<!-- TODO: Replace with your teaser video -->
|
||||
<video poster="" id="tree" autoplay controls muted loop height="100%" preload="metadata">
|
||||
<!-- TODO: Add your video file path here -->
|
||||
<source src="static/videos/banner_video.mp4" type="video/mp4">
|
||||
</video>
|
||||
<!-- TODO: Replace with your video description -->
|
||||
<h2 class="subtitle has-text-centered">
|
||||
Aliquam vitae elit ullamcorper tellus egestas pellentesque. Ut lacus tellus, maximus vel lectus at, placerat pretium mi. Maecenas dignissim tincidunt vestibulum. Sed consequat hendrerit nisl ut maximus.
|
||||
</h2>
|
||||
<div class="publication-banner">
|
||||
<img src="static/images/banner.svg" alt="PHANTOM teaser diagram connecting vulnerability, behavioral signal, and robust control" width="1920" height="1080" decoding="async" style="display:block; width:100%; height:auto;" onerror="this.onerror=null;this.src='static/images/carousel2.jpg';"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- End teaser video -->
|
||||
|
||||
|
||||
<!-- Paper abstract -->
|
||||
<section class="section hero is-light">
|
||||
@@ -314,10 +337,10 @@
|
||||
<h2 class="title is-3">Abstract</h2>
|
||||
<div class="content has-text-justified">
|
||||
<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>
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,97 +349,90 @@
|
||||
</section>
|
||||
<!-- End paper abstract -->
|
||||
|
||||
<section class="section">
|
||||
<div class="container is-max-desktop">
|
||||
<div class="content has-text-justified">
|
||||
<h2 class="title is-3 has-text-centered">Project Scope</h2>
|
||||
<p>
|
||||
The current thesis revision extends both theory and implementation. The main research question is how a pricing system can preserve margin integrity when browsing and purchasing are increasingly orchestrated by AI agents.
|
||||
</p>
|
||||
<ul>
|
||||
<li>Formal contribution: a Cost of Information erosion theorem showing why price-query saturation can collapse dynamic pricing power.</li>
|
||||
<li>System contribution: a hybrid online/offline stack (Next.js storefront, pricing provider, Kafka event streams, Airflow ETL, Redis serving layer).</li>
|
||||
<li>Modeling contribution: class-specific transition kernels for human and agent behavior, with KL-divergence based separability scores.</li>
|
||||
<li>Control contribution: a contamination-aware DR-RL pricing policy trained under distributional uncertainty using Wasserstein-style robustness.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Controlled trials currently include balanced human and agent sessions with goal-driven tasks across hotel and airline interfaces. Early separability results are strong (Mann-Whitney U=2.0, p=0.0006), while robust pricing gains remain regime-dependent and are being calibrated in larger sweeps.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Image carousel -->
|
||||
<!--
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<div id="results-carousel" class="carousel results-carousel">
|
||||
<div class="item">
|
||||
<!-- TODO: Replace with your research result images -->
|
||||
<img src="static/images/carousel1.jpg" alt="First research result visualization" loading="lazy"/>
|
||||
<!-- TODO: Replace with description of this result -->
|
||||
<h2 class="subtitle has-text-centered">
|
||||
First image description.
|
||||
Early simulator traces showing how policy choice can push prices toward aggressive high-end regimes.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="item">
|
||||
<!-- Your image here -->
|
||||
<img src="static/images/carousel2.jpg" alt="Second research result visualization" loading="lazy"/>
|
||||
<h2 class="subtitle has-text-centered">
|
||||
Second image description.
|
||||
Human and agent behavior diverge at the transition-kernel level, enabling usable session-level separability.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="item">
|
||||
<!-- Your image here -->
|
||||
<img src="static/images/carousel3.jpg" alt="Third research result visualization" loading="lazy"/>
|
||||
<h2 class="subtitle has-text-centered">
|
||||
Third image description.
|
||||
End-to-end architecture linking web interactions, pricing queries, event streams, and model updates.
|
||||
</h2>
|
||||
</div>
|
||||
<div class="item">
|
||||
<!-- Your image here -->
|
||||
<img src="static/images/carousel4.jpg" alt="Fourth research result visualization" loading="lazy"/>
|
||||
<h2 class="subtitle has-text-centered">
|
||||
Fourth image description.
|
||||
Contamination-aware evaluation compares robust and non-robust pricing behavior across alpha sweeps.
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
-->
|
||||
<!-- End image carousel -->
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Youtube video -->
|
||||
<section class="hero is-small is-light">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<!-- Paper video. -->
|
||||
<h2 class="title is-3">Video Presentation</h2>
|
||||
<div class="columns is-centered has-text-centered">
|
||||
<div class="column is-four-fifths">
|
||||
|
||||
<div class="publication-video">
|
||||
<!-- TODO: Replace with your YouTube video ID -->
|
||||
<iframe src="https://www.youtube.com/embed/JkaxUblCGz0" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- End youtube video -->
|
||||
|
||||
|
||||
<!-- Video carousel -->
|
||||
<section class="hero is-small">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h2 class="title is-3">Another Carousel</h2>
|
||||
<div id="results-carousel" class="carousel results-carousel">
|
||||
<h2 class="title is-3">Defense Scenes</h2>
|
||||
<div id="videos-carousel" class="carousel results-carousel">
|
||||
<div class="item item-video1">
|
||||
<!-- TODO: Add poster image for better preview -->
|
||||
<video poster="" id="video1" controls muted loop height="100%" preload="metadata">
|
||||
<!-- Your video file here -->
|
||||
<source src="static/videos/carousel1.mp4" type="video/mp4">
|
||||
<source src="static/videos/COIFirstPrinciplesScene.mp4" type="video/mp4">
|
||||
</video>
|
||||
<h2 class="subtitle has-text-centered">COI from first principles.</h2>
|
||||
</div>
|
||||
<div class="item item-video2">
|
||||
<!-- TODO: Add poster image for better preview -->
|
||||
<video poster="" id="video2" controls muted loop height="100%" preload="metadata">
|
||||
<!-- Your video file here -->
|
||||
<source src="static/videos/carousel2.mp4" type="video/mp4">
|
||||
<source src="static/videos/BehaviorKernelConstructionScene.mp4" type="video/mp4">
|
||||
</video>
|
||||
<h2 class="subtitle has-text-centered">Behavioral kernel construction: learning how humans and agents differ.</h2>
|
||||
</div>
|
||||
<div class="item item-video3">
|
||||
<!-- TODO: Add poster image for better preview -->
|
||||
<video poster="" id="video3" controls muted loop height="100%" preload="metadata">
|
||||
<!-- Your video file here -->
|
||||
<source src="static/videos/carousel3.mp4" type="video/mp4">
|
||||
<source src="static/videos/RobustControlScene.mp4" type="video/mp4">
|
||||
</video>
|
||||
<h2 class="subtitle has-text-centered">Distributionally robust control loop.</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -432,10 +448,10 @@
|
||||
<!-- Paper poster -->
|
||||
<section class="hero is-small is-light">
|
||||
<div class="hero-body">
|
||||
<div class="container">
|
||||
<h2 class="title">Poster</h2>
|
||||
<div class="container">
|
||||
<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>
|
||||
|
||||
</div>
|
||||
@@ -457,7 +473,7 @@
|
||||
</div>
|
||||
<pre id="bibtex-code"><code>@thesis{Rosel2025PHANTOM,
|
||||
title={Pricing Heuristics Against Non-human Transaction Orchestration Mechanisms},
|
||||
author={R{\"o}sel, Daniel},
|
||||
author={Rösel, Daniel},
|
||||
school={IE University},
|
||||
year={2025},
|
||||
address={Madrid, Spain},
|
||||
|
||||
246
docs/static/images/banner.svg
vendored
Normal file
246
docs/static/images/banner.svg
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" width="1920" height="1080" style="background-color: #FAFAFA; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;">
|
||||
<defs>
|
||||
<!-- Soft Drop Shadow for Panels -->
|
||||
<filter id="shadow" x="-10%" y="-10%" width="130%" height="130%">
|
||||
<feDropShadow dx="2" dy="4" stdDeviation="6" flood-color="#000000" flood-opacity="0.06"/>
|
||||
</filter>
|
||||
<filter id="light-shadow" x="-5%" y="-5%" width="110%" height="110%">
|
||||
<feDropShadow dx="1" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.04"/>
|
||||
</filter>
|
||||
|
||||
<!-- Arrowhead Marker -->
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#888888" />
|
||||
</marker>
|
||||
<marker id="arrow-dark" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="#555555" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- COLUMN DIVIDERS -->
|
||||
<line x1="640" y1="60" x2="640" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
|
||||
<line x1="1280" y1="60" x2="1280" y2="1020" stroke="#EAEAEA" stroke-width="2" stroke-dasharray="10,10"/>
|
||||
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- COLUMN 1: THE THREAT (COI & SATURATION) -->
|
||||
<!-- ========================================================= -->
|
||||
<text x="60" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">1. The Vulnerability</text>
|
||||
<line x1="60" y1="100" x2="580" y2="100" stroke="#DDDDDD" stroke-width="2"/>
|
||||
|
||||
<!-- Top: COI Bell Curve -->
|
||||
<g transform="translate(60, 130)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Cost of Information from First Principles</text>
|
||||
<text x="0" y="70" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P ~ π(τ)</text>
|
||||
<text x="0" y="105" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B"><tspan text-decoration="underline">p</tspan> = reservation price</text>
|
||||
<text x="0" y="140" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">M = P - <tspan text-decoration="underline">p</tspan></text>
|
||||
|
||||
<!-- Bell Curve -->
|
||||
<path d="M 40 340 C 140 340, 160 160, 260 160 C 360 160, 380 340, 480 340" stroke="#3AB09E" stroke-width="5" fill="none"/>
|
||||
<line x1="40" y1="340" x2="500" y2="340" stroke="#333" stroke-width="2"/>
|
||||
|
||||
<!-- Markers p and E[P] -->
|
||||
<line x1="150" y1="340" x2="150" y2="160" stroke="#E37862" stroke-width="2" stroke-dasharray="6,4"/>
|
||||
<text x="150" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" text-anchor="middle">p</text>
|
||||
|
||||
<line x1="260" y1="340" x2="260" y2="160" stroke="#85B589" stroke-width="2" stroke-dasharray="6,4"/>
|
||||
<text x="260" y="375" font-family="Georgia" font-style="italic" font-size="22" fill="#85B589" text-anchor="middle">E[P]</text>
|
||||
|
||||
<!-- COI Annotation -->
|
||||
<line x1="150" y1="150" x2="260" y2="150" stroke="#E37862" stroke-width="2" marker-start="url(#arrow)" marker-end="url(#arrow)"/>
|
||||
<text x="310" y="138" font-size="16" fill="#E37862" text-anchor="middle">average information rent</text>
|
||||
<text x="310" y="118" font-family="Georgia" font-style="italic" font-size="22" fill="#E37862" font-weight="bold" text-anchor="middle">COI := E[P] - p</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom: Agent Saturation -->
|
||||
<g transform="translate(60, 580)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Why COI Erodes with Agent Saturation</text>
|
||||
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> = min(p</tspan><tspan font-size="14" dy="5">1</tspan><tspan dy="-5">, ..., p</tspan><tspan font-size="14" dy="5">N</tspan><tspan dy="-5">)</tspan></text>
|
||||
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">P(p<tspan font-size="14" dy="5">(1)</tspan><tspan dy="-5"> > t) = [1 - F(t)]</tspan><tspan font-size="14" dy="-10">N</tspan></text>
|
||||
|
||||
<!-- Erosion Graph -->
|
||||
<rect x="120" y="150" width="280" height="230" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
|
||||
<line x1="140" y1="350" x2="380" y2="350" stroke="#333" stroke-width="2"/>
|
||||
<line x1="140" y1="350" x2="140" y2="170" stroke="#333" stroke-width="2"/>
|
||||
<text x="260" y="375" font-size="16" font-style="italic" fill="#555" text-anchor="middle">F(t)</text>
|
||||
<text x="120" y="260" font-size="16" font-style="italic" fill="#555" text-anchor="middle" transform="rotate(-90 120 260)">[1 - F(t)]^N</text>
|
||||
|
||||
<!-- Curves -->
|
||||
<path d="M 140 170 C 220 250, 300 320, 380 350" stroke="#4EA5D9" stroke-width="3" fill="none"/>
|
||||
<text x="390" y="220" font-size="16" fill="#4EA5D9" font-weight="bold">N=1</text>
|
||||
|
||||
<path d="M 140 170 C 180 260, 240 330, 380 350" stroke="#85B589" stroke-width="3" fill="none"/>
|
||||
<text x="390" y="250" font-size="16" fill="#85B589" font-weight="bold">N=4</text>
|
||||
|
||||
<path d="M 140 170 C 150 290, 180 340, 380 350" stroke="#E37862" stroke-width="3" fill="none"/>
|
||||
<text x="390" y="280" font-size="16" fill="#E37862" font-weight="bold">N=16</text>
|
||||
|
||||
<text x="260" y="420" font-size="20" fill="#555" text-anchor="middle">As independent query count grows,</text>
|
||||
<text x="260" y="445" font-size="20" fill="#E37862" font-weight="bold" text-anchor="middle">realizable markup collapses.</text>
|
||||
</g>
|
||||
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- COLUMN 2: THE BEHAVIORAL SIGNAL -->
|
||||
<!-- ========================================================= -->
|
||||
<text x="700" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">2. The Behavioral Signals</text>
|
||||
<line x1="700" y1="100" x2="1220" y2="100" stroke="#DDDDDD" stroke-width="2"/>
|
||||
|
||||
<!-- Top: Transition Kernels -->
|
||||
<g transform="translate(700, 130)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">From Session Paths to Transition Kernels</text>
|
||||
|
||||
<text x="0" y="75" font-size="20" fill="#85B589" font-weight="bold">human: start → view → detail → cart → purchase</text>
|
||||
<text x="0" y="115" font-size="20" fill="#E37862" font-weight="bold">agent: start → view → detail → view → detail</text>
|
||||
|
||||
<text x="0" y="170" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">
|
||||
P̂(s'|s) = <tspan font-size="18" dy="-12">N(s,s')</tspan> / <tspan font-size="18" dy="12">Σ N(s,k)</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Matrix Representation -->
|
||||
<rect x="0" y="220" width="500" height="180" fill="#FFFFFF" filter="url(#shadow)" rx="8"/>
|
||||
|
||||
<text x="125" y="250" font-size="16" fill="#4EA5D9" text-anchor="middle">transition counts N(s,s')</text>
|
||||
<text x="375" y="250" font-size="16" fill="#85B589" text-anchor="middle">normalized kernel T</text>
|
||||
|
||||
<!-- Matrix 1 -->
|
||||
<g transform="translate(45, 270)">
|
||||
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
|
||||
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
|
||||
<text x="80" y="20" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 8.00 0.00 0.00</text>
|
||||
<text x="80" y="50" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 2.00 5.00 1.00</text>
|
||||
<text x="80" y="80" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 3.00 2.00 4.00</text>
|
||||
<text x="80" y="110" font-family="monospace" font-size="14" fill="#555" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 6.00</text>
|
||||
</g>
|
||||
|
||||
<!-- Arrow -->
|
||||
<line x1="225" y1="320" x2="265" y2="320" stroke="#999" stroke-width="3" marker-end="url(#arrow-dark)"/>
|
||||
|
||||
<!-- Matrix 2 -->
|
||||
<g transform="translate(295, 270)">
|
||||
<rect x="-6" y="-8" width="172" height="128" rx="6" fill="none" stroke="#DDDDDD" stroke-width="1.5"/>
|
||||
<path d="M 10 0 L 0 0 L 0 110 L 10 110 M 150 0 L 160 0 L 160 110 L 150 110" stroke="#A0A0A0" stroke-width="2.5" fill="none"/>
|
||||
<text x="80" y="20" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 1.00 0.00 0.00</text>
|
||||
<text x="80" y="50" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.25 0.62 0.13</text>
|
||||
<text x="80" y="80" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.33 0.22 0.45</text>
|
||||
<text x="80" y="110" font-family="monospace" font-size="14" fill="#333" text-anchor="middle" textLength="142" lengthAdjust="spacingAndGlyphs">0.00 0.14 0.00 0.86</text>
|
||||
</g>
|
||||
|
||||
<text x="250" y="440" font-size="18" fill="#777" text-anchor="middle">Kernel shape is the compact behavioral signature used downstream.</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom: Separability Distributions -->
|
||||
<g transform="translate(700, 600)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Separability into a Control Signal</text>
|
||||
<text x="0" y="75" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T̂' || T̄</tspan><tspan font-size="16" dy="5">H</tspan><tspan dy="-5">)</tspan></text>
|
||||
<text x="0" y="115" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">Δ<tspan font-size="16" dy="5">A</tspan><tspan dy="-5"> = D</tspan><tspan font-size="16" dy="5">KL</tspan><tspan dy="-5">(T̂' || T̄</tspan><tspan font-size="16" dy="5">A</tspan><tspan dy="-5">)</tspan></text>
|
||||
<text x="0" y="155" font-family="Georgia, serif" font-style="italic" font-size="24" fill="#8C7A6B">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
|
||||
|
||||
<!-- Curves -->
|
||||
<g transform="translate(80, 160)">
|
||||
<line x1="0" y1="200" x2="360" y2="200" stroke="#333" stroke-width="2"/>
|
||||
<text x="180" y="235" font-family="Georgia, serif" font-style="italic" font-size="22" text-anchor="middle">g = Δ<tspan font-size="16" dy="5">H</tspan><tspan dy="-5"> - Δ</tspan><tspan font-size="16" dy="5">A</tspan></text>
|
||||
|
||||
<!-- Human Curve -->
|
||||
<path d="M 0 200 C 50 200, 80 40, 130 40 C 180 40, 210 200, 260 200" stroke="#4EA5D9" stroke-width="5" fill="none"/>
|
||||
<text x="70" y="110" font-size="22" fill="#4EA5D9" font-weight="bold">human</text>
|
||||
|
||||
<!-- Agent Curve -->
|
||||
<path d="M 100 200 C 150 200, 180 40, 230 40 C 280 40, 310 200, 360 200" stroke="#E37862" stroke-width="5" fill="none"/>
|
||||
<text x="290" y="110" font-size="22" fill="#E37862" font-weight="bold">agent</text>
|
||||
|
||||
<!-- Decision Boundary -->
|
||||
<line x1="180" y1="200" x2="180" y2="10" stroke="#999" stroke-width="2" stroke-dasharray="8,5"/>
|
||||
<text x="180" y="-5" font-size="16" fill="#777" text-anchor="middle">decision boundary</text>
|
||||
|
||||
<circle cx="210" cy="200" r="6" fill="#ECA233"/>
|
||||
<text x="210" y="180" font-family="Georgia" font-style="italic" font-size="20" fill="#ECA233" text-anchor="middle">g_obs</text>
|
||||
|
||||
<text x="180" y="280" font-size="18" fill="#555" text-anchor="middle">Positive gap shifts score toward agent traffic.</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
|
||||
<!-- ========================================================= -->
|
||||
<!-- COLUMN 3: THE SOLUTION (CONTAMINATION & DR-RL) -->
|
||||
<!-- ========================================================= -->
|
||||
<text x="1340" y="80" font-family="Georgia, serif" font-size="28" font-weight="bold" fill="#333333">3. Robust Control & Contamination</text>
|
||||
<line x1="1340" y1="100" x2="1860" y2="100" stroke="#DDDDDD" stroke-width="2"/>
|
||||
|
||||
<!-- Top: Contamination Generator -->
|
||||
<g transform="translate(1340, 130)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Contamination Generator G(α)</text>
|
||||
|
||||
<!-- Boxes -->
|
||||
<rect x="20" y="70" width="200" height="50" fill="#D0E5E0" filter="url(#shadow)" rx="6"/>
|
||||
<text x="120" y="100" font-size="18" fill="#222" text-anchor="middle">labeled human sessions</text>
|
||||
|
||||
<rect x="280" y="70" width="200" height="50" fill="#EAD0C8" filter="url(#shadow)" rx="6"/>
|
||||
<text x="380" y="100" font-size="18" fill="#222" text-anchor="middle">synthetic agent sessions</text>
|
||||
|
||||
<!-- Arrows -->
|
||||
<line x1="120" y1="130" x2="200" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
|
||||
<line x1="380" y1="130" x2="300" y2="180" stroke="#888" stroke-width="3" marker-end="url(#arrow-dark)"/>
|
||||
|
||||
<!-- Mixed Batch -->
|
||||
<rect x="150" y="190" width="200" height="50" fill="#F4E9CD" filter="url(#shadow)" rx="6"/>
|
||||
<text x="250" y="220" font-size="18" fill="#222" text-anchor="middle">mixed batch for training</text>
|
||||
|
||||
<!-- Alpha Bar -->
|
||||
<text x="250" y="275" font-family="Georgia, serif" font-size="20" fill="#555" text-anchor="middle">alpha = 0.33</text>
|
||||
|
||||
<rect x="50" y="290" width="268" height="30" fill="#4EA5D9"/>
|
||||
<rect x="318" y="290" width="132" height="30" fill="#E37862"/>
|
||||
<text x="184" y="340" font-size="18" fill="#4EA5D9" text-anchor="middle">human share (1-α)</text>
|
||||
<text x="384" y="340" font-size="18" fill="#E37862" text-anchor="middle">agent share (α)</text>
|
||||
</g>
|
||||
|
||||
<!-- Bottom: Distributionally Robust Control -->
|
||||
<g transform="translate(1340, 600)">
|
||||
<text x="0" y="30" font-size="24" font-weight="bold" fill="#444">Distributionally Robust Control Layer</text>
|
||||
<text x="0" y="80" font-family="Georgia, serif" font-style="italic" font-size="22" fill="#8C7A6B">
|
||||
π* = arg max<tspan font-size="16" dy="5">π</tspan> min<tspan font-size="16" dy="0">Q ∈ U<tspan font-size="12" dy="5">ε</tspan></tspan>
|
||||
<tspan dy="-10"> E</tspan><tspan font-size="16" dy="5">d ~ Q</tspan>
|
||||
<tspan dy="-5">[ R(p,d) - λ COI</tspan><tspan font-size="16" dy="5">leak</tspan><tspan dy="-5">(p,τ') ]</tspan>
|
||||
</text>
|
||||
|
||||
<!-- Ambiguity Ball -->
|
||||
<g transform="translate(140, 260)">
|
||||
<line x1="-130" y1="0" x2="130" y2="0" stroke="#CCC" stroke-width="2"/>
|
||||
<line x1="0" y1="-130" x2="0" y2="130" stroke="#CCC" stroke-width="2"/>
|
||||
|
||||
<circle cx="0" cy="0" r="110" stroke="#C4A45B" stroke-width="4" fill="rgba(196,164,91,0.06)"/>
|
||||
<text x="-95" y="-120" font-family="Georgia" font-style="italic" font-size="24" fill="#C4A45B">U<tspan font-size="16" dy="5">ε</tspan></text>
|
||||
|
||||
<!-- Points -->
|
||||
<circle cx="0" cy="0" r="7" fill="#4EA5D9"/>
|
||||
<text x="12" y="24" font-family="Georgia" font-style="italic" font-size="22" fill="#4EA5D9">P̂<tspan font-size="14" dy="5">N</tspan></text>
|
||||
|
||||
<circle cx="-60" cy="-40" r="7" fill="#E37862"/>
|
||||
<text x="-140" y="-50" font-family="Georgia" font-style="italic" font-size="18" fill="#E37862">worst-case Q*</text>
|
||||
|
||||
<circle cx="50" cy="-70" r="6" fill="#85B589"/>
|
||||
<circle cx="70" cy="50" r="6" fill="#85B589"/>
|
||||
<circle cx="-40" cy="80" r="6" fill="#85B589"/>
|
||||
</g>
|
||||
|
||||
<!-- Process Steps -->
|
||||
<g transform="translate(320, 140)">
|
||||
<rect x="0" y="0" width="220" height="45" fill="#FDEFEF" filter="url(#light-shadow)" rx="6"/>
|
||||
<text x="110" y="28" font-size="16" fill="#E37862" font-weight="bold" text-anchor="middle">inner min picks Q*</text>
|
||||
|
||||
<line x1="110" y1="55" x2="110" y2="85" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
|
||||
|
||||
<rect x="0" y="95" width="220" height="45" fill="#F4E9CD" filter="url(#light-shadow)" rx="6"/>
|
||||
<text x="110" y="123" font-size="16" fill="#9E8033" font-weight="bold" text-anchor="middle">sample demand from Q*</text>
|
||||
|
||||
<line x1="110" y1="150" x2="110" y2="180" stroke="#999" stroke-width="2" marker-end="url(#arrow-dark)"/>
|
||||
|
||||
<rect x="0" y="190" width="220" height="45" fill="#E6F2ED" filter="url(#light-shadow)" rx="6"/>
|
||||
<text x="110" y="218" font-size="16" fill="#428062" font-weight="bold" text-anchor="middle">outer max updates policy</text>
|
||||
</g>
|
||||
|
||||
<text x="250" y="440" font-size="18" fill="#555" text-anchor="middle">Reward is evaluated on demand drawn from Q*, then used for the policy step.</text>
|
||||
</g>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
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()
|
||||
124
engine/engine.py
Normal file
124
engine/engine.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from sys import platform
|
||||
import numpy as np
|
||||
from .lib.demand import generate_demand_for_actor, estimate_demand
|
||||
from .lib.behavior import get_adjusted_transitions, sample_behavior_from_transitions
|
||||
from logging import INFO, getLogger
|
||||
|
||||
logger = getLogger(__name__)
|
||||
logger.setLevel(INFO)
|
||||
|
||||
|
||||
class MarketEngine:
|
||||
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
alpha: float,
|
||||
N: int,
|
||||
human_params: tuple,
|
||||
agent_params: tuple,
|
||||
demand_distribution=np.random.normal,
|
||||
noise_std: float = 1.0,
|
||||
action_weights: dict | None = None,
|
||||
):
|
||||
# no defaults for D_H, D_A - force explicit experiment design
|
||||
self.alpha = alpha
|
||||
self.N = int(N)
|
||||
self.Nagents = int(N * alpha)
|
||||
self.Nhumans = int(N * (1 - alpha))
|
||||
self.human_params = human_params
|
||||
self.agent_params = agent_params
|
||||
self.noise_std = noise_std
|
||||
self.demand_dist = demand_distribution
|
||||
self.action_weights = action_weights
|
||||
|
||||
def act(self, prices):
|
||||
# generate separate demands d() per actor type
|
||||
demand_h = generate_demand_for_actor(
|
||||
prices,
|
||||
self.human_params,
|
||||
self.noise_std,
|
||||
distribution_method=self.demand_dist,
|
||||
)
|
||||
demand_a = generate_demand_for_actor(
|
||||
prices,
|
||||
self.agent_params,
|
||||
self.noise_std,
|
||||
distribution_method=self.demand_dist,
|
||||
)
|
||||
human_transitions = get_adjusted_transitions(demand_h, human=True)
|
||||
agent_transitions = get_adjusted_transitions(demand_a, human=False)
|
||||
# sample 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
|
||||
self.last_trajectories = human_t + agent_t
|
||||
|
||||
demand_proxy = estimate_demand(
|
||||
self.last_trajectories,
|
||||
self.action_weights,
|
||||
normalize=True,
|
||||
per_session=False,
|
||||
)
|
||||
raw_mix = ((1.0 - float(self.alpha)) * demand_h) + (
|
||||
float(self.alpha) * demand_a
|
||||
)
|
||||
total_raw_demand = float(np.sum(raw_mix))
|
||||
if not demand_proxy:
|
||||
return {i: float(raw_mix[i]) for i in range(len(prices))}
|
||||
if total_raw_demand <= 0.0:
|
||||
return {i: 0.0 for i in range(len(prices))}
|
||||
return {
|
||||
i: total_raw_demand * float(demand_proxy.get(i, 0.0)) / 100.0
|
||||
for i in range(len(prices))
|
||||
}
|
||||
|
||||
def measure(self):
|
||||
pass
|
||||
|
||||
|
||||
class PricingEngine:
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
def act(self, demand):
|
||||
return np.random.uniform(low=25, high=100, size=10)
|
||||
|
||||
|
||||
class Limbo:
|
||||
def __init__(self, platform, market) -> None:
|
||||
self.platform_turn = True
|
||||
self.platform = platform
|
||||
self.market = market
|
||||
self.output = None
|
||||
|
||||
def step(self):
|
||||
if self.platform_turn:
|
||||
self.output = self.platform.act(self.output)
|
||||
else:
|
||||
self.output = self.market.act(self.output)
|
||||
self.platform_turn = not self.platform_turn
|
||||
return self.output
|
||||
|
||||
def reset(self):
|
||||
self.platform_turn = True
|
||||
self.output = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
platform = PricingEngine()
|
||||
market = MarketEngine(
|
||||
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
|
||||
)
|
||||
limbo = Limbo(platform, market)
|
||||
for _ in range(10):
|
||||
limbo.step()
|
||||
3
engine/jax/__init__.py
Normal file
3
engine/jax/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .robust import select_adversarial_alpha_jax, _JAX_OK
|
||||
|
||||
__all__ = ["select_adversarial_alpha_jax", "_JAX_OK"]
|
||||
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
|
||||
38
engine/lib/__init__.py
Normal file
38
engine/lib/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
_EXPORTS: dict[str, tuple[str, str]] = {
|
||||
"estimate_demand": (".demand", "estimate_demand"),
|
||||
"estimate_weighted_demand": (".demand", "estimate_weighted_demand"),
|
||||
"generate_demand_for_actor": (".demand", "generate_demand_for_actor"),
|
||||
"sample_behavior": (".behavior", "sample_behavior"),
|
||||
"get_transition_models": (".behavior", "get_transition_models"),
|
||||
"trajectory_to_events": (".behavior", "trajectory_to_events"),
|
||||
"DashboardRenderer": (".render", "DashboardRenderer"),
|
||||
"style_axis": (".render", "style_axis"),
|
||||
"EconomicMetricsWrapper": (".wrappers", "EconomicMetricsWrapper"),
|
||||
"MetricsCallback": (".callbacks", "MetricsCallback"),
|
||||
"EvalMetricsCallback": (".callbacks", "EvalMetricsCallback"),
|
||||
"ProviderBenchmark": (".providers", "ProviderBenchmark"),
|
||||
"ProviderResult": (".providers", "ProviderResult"),
|
||||
"BenchmarkConfig": (".providers", "BenchmarkConfig"),
|
||||
"RandomBaseline": (".providers", "RandomBaseline"),
|
||||
"SurgeBaseline": (".providers", "SurgeBaseline"),
|
||||
"compute_uplift_coi": (".coi", "compute_uplift_coi"),
|
||||
"extract_purchases": (".coi", "extract_purchases"),
|
||||
"compute_agent_probability": (".coi", "compute_agent_probability"),
|
||||
"EventQTable": (".discrete", "EventQTable"),
|
||||
}
|
||||
|
||||
__all__ = sorted(_EXPORTS)
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name not in _EXPORTS:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
module_name, attr_name = _EXPORTS[name]
|
||||
module = import_module(module_name, package=__name__)
|
||||
value = getattr(module, attr_name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
190
engine/lib/behavior.py
Normal file
190
engine/lib/behavior.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||
|
||||
try:
|
||||
from sim.rl.behavior_loader.models import (
|
||||
BehaviorModel,
|
||||
AgentBehaviorModel,
|
||||
aggregate_event_transitions,
|
||||
)
|
||||
except ImportError:
|
||||
BehaviorModel = None
|
||||
AgentBehaviorModel = None
|
||||
aggregate_event_transitions = None
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from .demand import generate_demand_for_actor
|
||||
|
||||
base_dir = Path(__file__).parents[2] / "experiments"
|
||||
human_dir = str(base_dir / "collected_data")
|
||||
agent_dir = str(base_dir / "agents" / "collected_data")
|
||||
|
||||
_cache = {} # lazy cache for models and base pivots
|
||||
# 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):
|
||||
if (
|
||||
BehaviorModel is None
|
||||
or AgentBehaviorModel is None
|
||||
or aggregate_event_transitions is None
|
||||
):
|
||||
raise ImportError("behavior loader dependencies are unavailable")
|
||||
key = "human" if human else "agent"
|
||||
if key not in _cache:
|
||||
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
|
||||
mdp = model.build_MDP()
|
||||
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
|
||||
return _cache[key]
|
||||
|
||||
|
||||
def get_transition_models():
|
||||
"""load human and agent transition models for agent probability calculation
|
||||
|
||||
returns:
|
||||
tuple: (human_transitions, agent_transitions) as dicts of event->event->prob
|
||||
"""
|
||||
if (
|
||||
BehaviorModel is None
|
||||
or AgentBehaviorModel is None
|
||||
or aggregate_event_transitions is None
|
||||
):
|
||||
raise ImportError("behavior loader dependencies are unavailable")
|
||||
|
||||
human_model = BehaviorModel(human_dir)
|
||||
agent_model = AgentBehaviorModel(agent_dir)
|
||||
|
||||
human_mdp = human_model.build_MDP()
|
||||
agent_mdp = agent_model.build_MDP()
|
||||
|
||||
human_trans = aggregate_event_transitions(human_mdp)
|
||||
agent_trans = aggregate_event_transitions(agent_mdp)
|
||||
|
||||
return human_trans, agent_trans
|
||||
|
||||
|
||||
def trajectory_to_events(trajectory: list) -> list:
|
||||
"""extract event names from trajectory for KL divergence calculation
|
||||
|
||||
trajectories are in format 'eventName_product0', extract just eventName
|
||||
"""
|
||||
return [s.rsplit("_product", 1)[0] if "_product" in s else s for s in trajectory]
|
||||
|
||||
|
||||
class _TransitionTable:
|
||||
"""numpy-backed transition table; replaces per-step pandas .loc[] indexing.
|
||||
|
||||
the profiling hotspot was DataFrame.xs called ~4-16k times per outer step.
|
||||
converting once to a dense float32 array with an int-keyed state index map
|
||||
reduces each row lookup to a single array slice with no pandas overhead.
|
||||
rows are pre-normalized so sampling requires no per-step division.
|
||||
"""
|
||||
|
||||
__slots__ = ("matrix", "states", "state_index", "n_states")
|
||||
|
||||
def __init__(self, df: pd.DataFrame):
|
||||
self.states: list[str] = df.index.tolist()
|
||||
self.state_index: dict[str, int] = {s: i for i, s in enumerate(self.states)}
|
||||
# float64 throughout: float32 row-sums can drift enough to break np.random.choice
|
||||
mat = np.nan_to_num(
|
||||
df.values.astype(np.float64), nan=0.0, posinf=0.0, neginf=0.0
|
||||
)
|
||||
mat = np.clip(mat, 0.0, None)
|
||||
row_sums = mat.sum(axis=1)
|
||||
# dead rows (all zero) get uniform distribution so sampling never receives NaN
|
||||
dead = row_sums <= 0
|
||||
mat[dead] = 1.0
|
||||
row_sums[dead] = float(mat.shape[1])
|
||||
mat = mat / row_sums[:, np.newaxis]
|
||||
# final nan guard in case fp still drifts
|
||||
np.nan_to_num(mat, nan=0.0, copy=False)
|
||||
row_sums2 = mat.sum(axis=1, keepdims=True)
|
||||
row_sums2[row_sums2 <= 0] = 1.0
|
||||
self.matrix: np.ndarray = mat / row_sums2
|
||||
self.n_states: int = len(self.states)
|
||||
|
||||
|
||||
def adjust_behavior_to_condition(condition, transition_matrix):
|
||||
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
|
||||
condition = np.asarray(condition, dtype=float)
|
||||
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
|
||||
condition = np.clip(condition, 0.0, None)
|
||||
s = float(np.sum(condition))
|
||||
cond_norm = (
|
||||
condition / s
|
||||
if np.isfinite(s) and s > 0
|
||||
else np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
|
||||
)
|
||||
n_products = len(condition)
|
||||
base_vals = transition_matrix.values
|
||||
base_cols, base_rows = (
|
||||
transition_matrix.columns.tolist(),
|
||||
transition_matrix.index.tolist(),
|
||||
)
|
||||
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
||||
new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)]
|
||||
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
||||
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||
|
||||
|
||||
def get_adjusted_transitions(condition, human=True) -> _TransitionTable:
|
||||
"""return a _TransitionTable for the given demand condition.
|
||||
|
||||
results are cached by (human, rounded-condition) so that repeated calls with
|
||||
the same condition inside the robustness inner loop (K candidates, same prices)
|
||||
skip the Kronecker expansion entirely.
|
||||
"""
|
||||
condition = np.asarray(condition, dtype=float)
|
||||
# round to 4 significant digits for cache key stability
|
||||
cache_key = (human, tuple(np.round(condition, 4).tolist()))
|
||||
if cache_key in _transition_cache:
|
||||
return _transition_cache[cache_key]
|
||||
|
||||
# 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]:
|
||||
row = table.matrix[table.state_index[trajectory[-1]]]
|
||||
idx = int(np.random.choice(table.n_states, p=row))
|
||||
trajectory.append(table.states[idx])
|
||||
return trajectory
|
||||
|
||||
|
||||
def sample_behavior(condition, human=True, max_len=40):
|
||||
table = get_adjusted_transitions(condition, human=human)
|
||||
return sample_behavior_from_transitions(table, max_len=max_len)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
|
||||
print(t)
|
||||
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
|
||||
print(t)
|
||||
259
engine/lib/callbacks.py
Normal file
259
engine/lib/callbacks.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""Training callbacks with algorithm-agnostic metric extraction."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
|
||||
import numpy as np
|
||||
|
||||
from ..telemetry.wandb import get_wandb_module
|
||||
|
||||
|
||||
class MetricsCallback(BaseCallback):
|
||||
"""Collects interval train metrics from env info dictionaries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
log_histograms: bool = False,
|
||||
log_freq: int = 100,
|
||||
hist_freq: int = 500,
|
||||
step_offset: int = 0,
|
||||
verbose: int = 0,
|
||||
):
|
||||
super().__init__(verbose)
|
||||
self.log_histograms = log_histograms
|
||||
self.log_freq = max(1, int(log_freq))
|
||||
self.hist_freq = max(1, int(hist_freq))
|
||||
self.step_offset = max(0, int(step_offset))
|
||||
self._wandb = get_wandb_module()
|
||||
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
|
||||
self._price_samples: list[float] = []
|
||||
self._demand_samples: list[float] = []
|
||||
self._window_sums = {
|
||||
"train/revenue_mean": 0.0,
|
||||
"train/margin_mean": 0.0,
|
||||
"train/coi_level_mean": 0.0,
|
||||
"train/regret_mean": 0.0,
|
||||
"train/profit_mean": 0.0,
|
||||
"train/agent_prob": 0.0,
|
||||
"train/alpha_adv": 0.0,
|
||||
"train/ux_penalty": 0.0,
|
||||
"train/volatility": 0.0,
|
||||
"train/coi_mix": 0.0,
|
||||
"train/coi_base": 0.0,
|
||||
"train/coi_leakage": 0.0,
|
||||
"train/coi_penalty": 0.0,
|
||||
}
|
||||
self._window_count = 0
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def _accumulate(self, info: dict[str, Any]) -> None:
|
||||
econ = info.get("economics")
|
||||
if not isinstance(econ, dict):
|
||||
return
|
||||
self._window_sums["train/revenue_mean"] += float(econ.get("revenue", 0.0))
|
||||
self._window_sums["train/margin_mean"] += float(econ.get("margin", 0.0))
|
||||
self._window_sums["train/coi_level_mean"] += float(econ.get("coi_level", 0.0))
|
||||
self._window_sums["train/regret_mean"] += float(econ.get("regret", 0.0))
|
||||
if "profit" in econ:
|
||||
self._window_sums["train/profit_mean"] += float(econ.get("profit", 0.0))
|
||||
if "agent_prob" in econ:
|
||||
self._window_sums["train/agent_prob"] += float(econ.get("agent_prob", 0.0))
|
||||
if "alpha_adv" in econ:
|
||||
self._window_sums["train/alpha_adv"] += float(econ.get("alpha_adv", 0.0))
|
||||
if "ux_penalty" in econ:
|
||||
self._window_sums["train/ux_penalty"] += float(econ.get("ux_penalty", 0.0))
|
||||
if "volatility" in econ:
|
||||
self._window_sums["train/volatility"] += float(econ.get("volatility", 0.0))
|
||||
if "coi_mix" in econ:
|
||||
self._window_sums["train/coi_mix"] += float(econ.get("coi_mix", 0.0))
|
||||
if "coi_base" in econ:
|
||||
self._window_sums["train/coi_base"] += float(econ.get("coi_base", 0.0))
|
||||
if "coi_leakage" in econ:
|
||||
self._window_sums["train/coi_leakage"] += float(
|
||||
econ.get("coi_leakage", 0.0)
|
||||
)
|
||||
if "coi_penalty" in econ:
|
||||
self._window_sums["train/coi_penalty"] += float(
|
||||
econ.get("coi_penalty", 0.0)
|
||||
)
|
||||
self._window_count += 1
|
||||
|
||||
def _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:
|
||||
for info in self.locals.get("infos", []):
|
||||
if isinstance(info, dict):
|
||||
self._accumulate(info)
|
||||
self._accumulate_histograms(info)
|
||||
|
||||
if self.num_timesteps % self.log_freq == 0:
|
||||
self._flush(step=self.num_timesteps)
|
||||
|
||||
return True
|
||||
|
||||
def _on_training_end(self) -> None:
|
||||
self._flush(step=self.num_timesteps, force_hist=True)
|
||||
|
||||
|
||||
class EvalMetricsCallback(EvalCallback):
|
||||
"""Deterministic evaluation collector detached from logging backends."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
eval_env,
|
||||
eval_freq: int = 1000,
|
||||
n_eval_episodes: int = 5,
|
||||
step_offset: int = 0,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
|
||||
)
|
||||
self.step_offset = max(0, int(step_offset))
|
||||
self._wandb = get_wandb_module()
|
||||
self._wandb_live = bool(self._wandb is not None and self._wandb.run is not None)
|
||||
self._eval_stats: dict[str, list[float]] = {
|
||||
"eval/revenue_mean": [],
|
||||
"eval/margin_mean": [],
|
||||
"eval/coi_level_mean": [],
|
||||
"eval/coi_leakage_mean": [],
|
||||
"eval/volatility_mean": [],
|
||||
"eval/agent_prob_mean": [],
|
||||
}
|
||||
self.events: list[dict[str, float | int]] = []
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
result = super()._on_step()
|
||||
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
|
||||
payload: dict[str, float | int] = {
|
||||
"eval/reward_mean": float(self.last_mean_reward),
|
||||
"train/global_step": int(self.num_timesteps),
|
||||
}
|
||||
for key, values in self._eval_stats.items():
|
||||
payload[key] = float(np.mean(values)) if values else 0.0
|
||||
|
||||
if self._wandb_live:
|
||||
try:
|
||||
self._wandb.log(
|
||||
dict(payload),
|
||||
step=self.step_offset + int(self.num_timesteps),
|
||||
)
|
||||
except Exception:
|
||||
self._wandb_live = False
|
||||
self.events.append(payload)
|
||||
else:
|
||||
self.events.append(payload)
|
||||
|
||||
for values in self._eval_stats.values():
|
||||
values.clear()
|
||||
|
||||
return result
|
||||
|
||||
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
|
||||
# called after each eval episode
|
||||
info = locals_.get("info", {})
|
||||
econ = info.get("economics") if isinstance(info, dict) else None
|
||||
if not isinstance(econ, dict):
|
||||
return
|
||||
|
||||
self._eval_stats["eval/revenue_mean"].append(float(econ.get("revenue", 0.0)))
|
||||
self._eval_stats["eval/margin_mean"].append(float(econ.get("margin", 0.0)))
|
||||
self._eval_stats["eval/coi_level_mean"].append(
|
||||
float(econ.get("coi_level", 0.0))
|
||||
)
|
||||
self._eval_stats["eval/coi_leakage_mean"].append(
|
||||
float(econ.get("coi_leakage", 0.0))
|
||||
)
|
||||
self._eval_stats["eval/volatility_mean"].append(
|
||||
float(econ.get("volatility", 0.0))
|
||||
)
|
||||
self._eval_stats["eval/agent_prob_mean"].append(
|
||||
float(econ.get("agent_prob", 0.0))
|
||||
)
|
||||
79
engine/lib/coi.py
Normal file
79
engine/lib/coi.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import numpy as np
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def compute_agent_probability(
|
||||
trajectory: list,
|
||||
human_transitions: Dict,
|
||||
agent_transitions: Dict,
|
||||
temperature: float = 1.0,
|
||||
) -> float:
|
||||
"""estimate agent probability via KL divergence between trajectory transitions and reference models
|
||||
|
||||
compares empirical trajectory transition distribution to human/agent prototypes
|
||||
|
||||
args:
|
||||
trajectory: list of state/event strings from session
|
||||
human_transitions: reference transition dict from human MDP (event->event->prob)
|
||||
agent_transitions: reference transition dict from agent MDP (event->event->prob)
|
||||
|
||||
returns:
|
||||
agent probability in [0, 1] via softmax over KL divergences
|
||||
"""
|
||||
if len(trajectory) < 2:
|
||||
return 0.0 # insufficient data, assume human
|
||||
|
||||
# build empirical transition distribution from trajectory
|
||||
trans_counts = {}
|
||||
for s, s_next in zip(trajectory[:-1], trajectory[1:]):
|
||||
if s not in trans_counts:
|
||||
trans_counts[s] = {}
|
||||
trans_counts[s][s_next] = trans_counts[s].get(s_next, 0) + 1
|
||||
|
||||
# normalize to probabilities
|
||||
empirical = {}
|
||||
for s, nxt in trans_counts.items():
|
||||
total = sum(nxt.values())
|
||||
empirical[s] = {s_n: cnt / total for s_n, cnt in nxt.items()}
|
||||
|
||||
# compute KL divergence to each prototype
|
||||
def kl_div(p_dist: Dict, q_dist: Dict) -> float:
|
||||
eps = 1e-10
|
||||
# aggregate over all source states in empirical dist
|
||||
kl = 0.0
|
||||
for s in p_dist:
|
||||
if s not in q_dist:
|
||||
continue # skip states not in reference
|
||||
p_trans, q_trans = p_dist[s], q_dist[s]
|
||||
for k in p_trans:
|
||||
p_val = p_trans[k] + eps
|
||||
q_val = q_trans.get(k, 0.0) + eps
|
||||
kl += p_val * np.log(p_val / q_val)
|
||||
return kl
|
||||
|
||||
kl_human = kl_div(empirical, human_transitions)
|
||||
kl_agent = kl_div(empirical, agent_transitions)
|
||||
|
||||
# convert to probability via softmax (lower KL = higher prob)
|
||||
t = float(max(temperature, 1e-6))
|
||||
exp_h = np.exp(-kl_human / t)
|
||||
exp_a = np.exp(-kl_agent / t)
|
||||
return float(exp_a / (exp_h + exp_a + 1e-10))
|
||||
|
||||
|
||||
def extract_purchases(trajectories: list) -> Dict[int, int]:
|
||||
purchases: Dict[int, int] = {}
|
||||
for traj in trajectories:
|
||||
if traj and "checkout" in traj[-1] and "_product" in traj[-1]:
|
||||
prod_id = int(traj[-1].rsplit("_product", 1)[1])
|
||||
purchases[prod_id] = purchases.get(prod_id, 0) + 1
|
||||
return purchases
|
||||
|
||||
|
||||
def compute_uplift_coi(
|
||||
prices: np.ndarray, purchases: Dict[int, int], baseline_prices: np.ndarray
|
||||
) -> float:
|
||||
# TODO: consider view-weighted fractional purchase for denser signal
|
||||
return float(
|
||||
sum(max(0.0, prices[k] - baseline_prices[k]) * n for k, n in purchases.items())
|
||||
)
|
||||
120
engine/lib/demand.py
Normal file
120
engine/lib/demand.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import numpy as np
|
||||
|
||||
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
||||
ACTION_CATEGORIES = {
|
||||
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
||||
"dwell": {"hover_title", "hover_paragraph", "hover_link"},
|
||||
"nav": {"page_view", "view_item", "view", "learn_more"},
|
||||
"filter": {"search", "filter_date", "filter_price", "sort"},
|
||||
}
|
||||
DEFAULT_ACTION_WEIGHTS = {
|
||||
a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions
|
||||
}
|
||||
|
||||
|
||||
def generate_demand_for_actor(
|
||||
prices: np.ndarray,
|
||||
params: tuple,
|
||||
noise_std: float = 1.0,
|
||||
distribution_method=np.random.normal,
|
||||
normalize: bool = False,
|
||||
) -> np.ndarray:
|
||||
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
|
||||
params: (mean, std) for valuation distribution D_H or D_A"""
|
||||
val = distribution_method(*params, size=len(prices))
|
||||
noise = distribution_method(0, noise_std, len(prices))
|
||||
demand = np.maximum(0, val - prices + noise)
|
||||
if not normalize:
|
||||
return demand
|
||||
total = np.sum(demand)
|
||||
return demand / total * 100 if total > 0 else demand
|
||||
|
||||
|
||||
def estimate_demand(
|
||||
trajectories,
|
||||
action_weights=None,
|
||||
*,
|
||||
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):
|
||||
if "_product" not in state:
|
||||
return state, None
|
||||
action, raw_pid = state.rsplit("_product", 1)
|
||||
return action, int(raw_pid) if raw_pid.isdigit() else None
|
||||
|
||||
|
||||
def _weight_for_action(action: str, action_weights: dict) -> float:
|
||||
if action in action_weights:
|
||||
return action_weights[action]
|
||||
if action.startswith("hover"):
|
||||
return CATEGORY_WEIGHTS["dwell"]
|
||||
if action.startswith("filter") or action in {"search", "sort"}:
|
||||
return CATEGORY_WEIGHTS["filter"]
|
||||
if action.startswith("add") or action in {"checkout", "purchase", "remove"}:
|
||||
return CATEGORY_WEIGHTS["cart"]
|
||||
return CATEGORY_WEIGHTS["nav"]
|
||||
|
||||
|
||||
def estimate_weighted_demand(
|
||||
trajectories,
|
||||
action_weights=None,
|
||||
*,
|
||||
normalize: bool = False,
|
||||
per_session: bool = True,
|
||||
):
|
||||
action_weights = (
|
||||
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
|
||||
)
|
||||
scores = {}
|
||||
for traj in trajectories:
|
||||
for state in traj:
|
||||
action, product_id = _parse_event_state(state)
|
||||
if product_id is None:
|
||||
continue
|
||||
w = _weight_for_action(action, action_weights)
|
||||
if w <= 0:
|
||||
continue
|
||||
scores[product_id] = scores.get(product_id, 0.0) + w
|
||||
if not scores:
|
||||
return {}
|
||||
|
||||
if per_session and len(trajectories) > 0:
|
||||
inv_n = 1.0 / float(len(trajectories))
|
||||
scores = {pid: score * inv_n for pid, score in scores.items()}
|
||||
|
||||
if not normalize:
|
||||
return scores
|
||||
|
||||
total = float(sum(scores.values()))
|
||||
if total <= 0:
|
||||
return {}
|
||||
return {pid: (score / total) * 100.0 for pid, score in scores.items()}
|
||||
|
||||
|
||||
# Example usage
|
||||
if __name__ == "__main__":
|
||||
np.random.seed(42)
|
||||
prices = np.array([20.0, 35.0, 50.0, 65.0])
|
||||
# demo actor-specific demands
|
||||
human_params, agent_params = (50, 10), (45, 15)
|
||||
demand_h = generate_demand_for_actor(prices, human_params)
|
||||
demand_a = generate_demand_for_actor(prices, agent_params)
|
||||
print("Human Demand:", demand_h)
|
||||
print("Agent Demand:", demand_a)
|
||||
from .behavior import sample_behavior
|
||||
|
||||
N, alpha = 200, 0.3
|
||||
n_h, n_a = int(N * (1 - alpha)), int(N * alpha)
|
||||
human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)]
|
||||
agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)]
|
||||
demand_estimate = estimate_demand(human_t + agent_t)
|
||||
print("Estimated Demand from Behavior:", demand_estimate)
|
||||
70
engine/lib/discrete.py
Normal file
70
engine/lib/discrete.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from collections import defaultdict
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
import numpy as np
|
||||
|
||||
|
||||
class DiscretePriceActionWrapper(gym.ActionWrapper):
|
||||
def __init__(
|
||||
self,
|
||||
env: gym.Env,
|
||||
n_levels: int = 9,
|
||||
min_scale: float = 0.8,
|
||||
max_scale: float = 1.2,
|
||||
):
|
||||
super().__init__(env)
|
||||
self.scales = np.linspace(min_scale, max_scale, n_levels, dtype=np.float32)
|
||||
self.action_space = spaces.Discrete(n_levels)
|
||||
|
||||
def action(self, action: int):
|
||||
scale = float(self.scales[int(action)])
|
||||
cur = np.asarray(self.env.unwrapped._prices, dtype=np.float32)
|
||||
lo, hi = self.env.unwrapped.price_bounds
|
||||
return np.clip(cur * scale, lo, hi).astype(np.float32)
|
||||
|
||||
|
||||
class EventQTable:
|
||||
def __init__(
|
||||
self,
|
||||
n_actions: int,
|
||||
n_products: int,
|
||||
price_bounds: tuple,
|
||||
lr: float = 0.1,
|
||||
gamma: float = 0.99,
|
||||
n_bins: int = 6,
|
||||
):
|
||||
self.n_actions = int(n_actions)
|
||||
self.n_products = int(n_products)
|
||||
self.lr = float(lr)
|
||||
self.gamma = float(gamma)
|
||||
self.q = defaultdict(lambda: np.zeros(self.n_actions, dtype=np.float32))
|
||||
lo, hi = price_bounds
|
||||
self.demand_bins = np.linspace(0.0, 100.0, n_bins + 1)[1:-1]
|
||||
self.price_bins = np.linspace(lo, hi, n_bins + 1)[1:-1]
|
||||
|
||||
def encode(self, obs: np.ndarray) -> tuple:
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
d = obs[: self.n_products]
|
||||
p = obs[self.n_products : 2 * self.n_products]
|
||||
d_mean = float(np.mean(d)) if d.size else 0.0
|
||||
d_std = float(np.std(d)) if d.size else 0.0
|
||||
p_mean = float(np.mean(p)) if p.size else 0.0
|
||||
return (
|
||||
int(np.digitize(d_mean, self.demand_bins)),
|
||||
int(np.digitize(d_std, self.demand_bins)),
|
||||
int(np.digitize(p_mean, self.price_bins)),
|
||||
)
|
||||
|
||||
def act(self, obs: np.ndarray, eps: float = 0.0) -> tuple[int, tuple]:
|
||||
s = self.encode(obs)
|
||||
if np.random.random() < eps:
|
||||
return int(np.random.randint(self.n_actions)), s
|
||||
return int(np.argmax(self.q[s])), s
|
||||
|
||||
def update(self, s: tuple, a: int, r: float, s2: tuple, done: bool):
|
||||
target = r + (0.0 if done else self.gamma * float(np.max(self.q[s2])))
|
||||
self.q[s][a] += self.lr * (target - self.q[s][a])
|
||||
|
||||
def predict(self, obs: np.ndarray, deterministic: bool = True):
|
||||
a, _ = self.act(obs, 0.0 if deterministic else 0.05)
|
||||
return a, None
|
||||
185
engine/lib/providers.py
Normal file
185
engine/lib/providers.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Provider benchmarking - compare pricing strategies across contamination levels."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable, Any
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
try:
|
||||
import wandb
|
||||
|
||||
HAS_WANDB = True
|
||||
except ImportError:
|
||||
HAS_WANDB = False
|
||||
|
||||
|
||||
class RandomBaseline:
|
||||
"""uniform random action selection as a lower-bound baseline"""
|
||||
|
||||
def __init__(self, n_actions: int):
|
||||
self.n = n_actions
|
||||
|
||||
def __call__(self, obs):
|
||||
return int(np.random.randint(self.n))
|
||||
|
||||
def predict(self, obs, **kw):
|
||||
return self(obs), None
|
||||
|
||||
|
||||
class SurgeBaseline:
|
||||
"""heuristic surge pricing: boost price when demand is above threshold, discount when below.
|
||||
matches the naive pricing rule from thesis Section 3.3.2"""
|
||||
|
||||
def __init__(
|
||||
self, n_actions: int, high_threshold: float = 60.0, low_threshold: float = 30.0
|
||||
):
|
||||
self.n = n_actions
|
||||
self.mid = n_actions // 2 # identity action (scale ~1.0)
|
||||
self.high_t = high_threshold
|
||||
self.low_t = low_threshold
|
||||
|
||||
def __call__(self, obs):
|
||||
obs = np.asarray(obs, dtype=np.float32)
|
||||
n_prod = len(obs) // 2
|
||||
demand_mean = float(np.mean(obs[:n_prod])) if n_prod > 0 else 0.0
|
||||
if demand_mean >= self.high_t:
|
||||
return min(self.mid + 2, self.n - 1) # surge: two levels above identity
|
||||
if demand_mean <= self.low_t:
|
||||
return max(self.mid - 2, 0) # discount: two levels below identity
|
||||
return self.mid # hold
|
||||
|
||||
def predict(self, obs, **kw):
|
||||
return self(obs), None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderResult:
|
||||
"""Single benchmark result for one provider at one alpha level."""
|
||||
|
||||
name: str
|
||||
alpha: float
|
||||
total_revenue: float
|
||||
mean_revenue: float
|
||||
coi_level: float
|
||||
coi_preserved_pct: float # vs alpha=0 baseline
|
||||
margin_integrity: float
|
||||
regret: float
|
||||
episodes: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class BenchmarkConfig:
|
||||
"""Configuration for provider benchmark runs."""
|
||||
|
||||
n_episodes: int = 100
|
||||
alpha_range: list[float] = field(default_factory=lambda: [0.0, 0.1, 0.3, 0.5])
|
||||
baseline_name: str = "fixed"
|
||||
|
||||
|
||||
class ProviderBenchmark:
|
||||
"""Compare pricing providers to prove margin preservation across contamination levels.
|
||||
|
||||
Usage:
|
||||
def env_factory(alpha):
|
||||
return EconomicMetricsWrapper(PHANTOM(alpha=alpha))
|
||||
|
||||
providers = {
|
||||
"fixed": lambda obs: np.ones(10) * 50,
|
||||
"learned": model.predict,
|
||||
}
|
||||
|
||||
benchmark = ProviderBenchmark(env_factory, providers)
|
||||
results = benchmark.run()
|
||||
print(benchmark.summary_table())
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
env_factory: Callable[[float], Any],
|
||||
providers: dict[str, Callable],
|
||||
config: BenchmarkConfig | None = None,
|
||||
):
|
||||
self.env_factory = env_factory # fn(alpha) -> wrapped env
|
||||
self.providers = providers # {name: fn(obs) -> action}
|
||||
self.config = config or BenchmarkConfig()
|
||||
self.results: list[ProviderResult] = []
|
||||
|
||||
def run(self) -> list[ProviderResult]:
|
||||
"""Run benchmark across all providers and alpha levels."""
|
||||
baseline_coi: dict[str, float] = {} # {provider: coi at alpha=0}
|
||||
|
||||
for alpha in self.config.alpha_range:
|
||||
env = self.env_factory(alpha)
|
||||
|
||||
for name, policy_fn in self.providers.items():
|
||||
revenues, coi_levels, margins = [], [], []
|
||||
|
||||
for _ in range(self.config.n_episodes):
|
||||
obs, _ = env.reset()
|
||||
episode_revenue = 0.0
|
||||
done = False
|
||||
|
||||
while not done:
|
||||
action = policy_fn(obs)
|
||||
# handle sb3 model.predict returning tuple
|
||||
if isinstance(action, tuple):
|
||||
action = action[0]
|
||||
obs, reward, term, trunc, info = env.step(action)
|
||||
done = term or trunc
|
||||
|
||||
econ = info.get("economics", {})
|
||||
episode_revenue += econ.get("revenue", 0)
|
||||
coi_levels.append(econ.get("coi_level", 0))
|
||||
margins.append(econ.get("margin", 0))
|
||||
|
||||
revenues.append(episode_revenue)
|
||||
|
||||
mean_coi = np.mean(coi_levels) if coi_levels else 0.0
|
||||
if alpha == 0.0:
|
||||
baseline_coi[name] = mean_coi
|
||||
|
||||
base = baseline_coi.get(name, mean_coi)
|
||||
coi_preserved = mean_coi / base if base > 0 else 1.0
|
||||
|
||||
result = ProviderResult(
|
||||
name=name,
|
||||
alpha=alpha,
|
||||
total_revenue=float(np.sum(revenues)),
|
||||
mean_revenue=float(np.mean(revenues)),
|
||||
coi_level=mean_coi,
|
||||
coi_preserved_pct=coi_preserved * 100,
|
||||
margin_integrity=float(np.mean(margins)) if margins else 0.0,
|
||||
regret=0.0, # compute vs optimal if known
|
||||
episodes=self.config.n_episodes,
|
||||
)
|
||||
self.results.append(result)
|
||||
|
||||
# log to wandb if available
|
||||
if HAS_WANDB and wandb.run is not None:
|
||||
try:
|
||||
wandb.log(
|
||||
{
|
||||
f"benchmark/{name}/revenue": result.mean_revenue,
|
||||
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
|
||||
f"benchmark/{name}/margin": result.margin_integrity,
|
||||
"benchmark/alpha": alpha,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return self.results
|
||||
|
||||
def to_dataframe(self) -> pd.DataFrame:
|
||||
"""Convert results to pandas DataFrame."""
|
||||
return pd.DataFrame([r.__dict__ for r in self.results])
|
||||
|
||||
def summary_table(self) -> pd.DataFrame:
|
||||
"""Pivot table: providers x alpha with revenue/COI metrics."""
|
||||
df = self.to_dataframe()
|
||||
return df.pivot_table(
|
||||
index="name",
|
||||
columns="alpha",
|
||||
values=["mean_revenue", "coi_preserved_pct", "margin_integrity"],
|
||||
aggfunc="mean",
|
||||
)
|
||||
165
engine/lib/render.py
Normal file
165
engine/lib/render.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""rendering logic for PHANTOM environment dashboard"""
|
||||
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.gridspec import GridSpec
|
||||
|
||||
|
||||
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
if title:
|
||||
ax.set_title(title, fontsize=11, fontweight="bold", pad=8)
|
||||
if xlabel:
|
||||
ax.set_xlabel(xlabel, fontsize=9)
|
||||
if ylabel:
|
||||
ax.set_ylabel(ylabel, fontsize=9)
|
||||
|
||||
|
||||
class DashboardRenderer:
|
||||
"""stateful renderer for PHANTOM market dynamics visualization"""
|
||||
|
||||
def __init__(self):
|
||||
self.fig = None
|
||||
self.gs = None
|
||||
|
||||
def render(self, env) -> None:
|
||||
if self.fig is None:
|
||||
plt.ion()
|
||||
self.fig = plt.figure(figsize=(14, 10))
|
||||
self.gs = GridSpec(
|
||||
3,
|
||||
3,
|
||||
figure=self.fig,
|
||||
hspace=0.35,
|
||||
wspace=0.3,
|
||||
left=0.07,
|
||||
right=0.95,
|
||||
top=0.92,
|
||||
bottom=0.08,
|
||||
)
|
||||
plt.show(block=False)
|
||||
|
||||
self.fig.clear()
|
||||
self.fig.suptitle(
|
||||
f"PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]",
|
||||
fontsize=14,
|
||||
fontweight="bold",
|
||||
)
|
||||
|
||||
demand_mat = np.array(env._demand_history).T
|
||||
price_mat = np.array(env._price_history).T
|
||||
elasticity = env._compute_elasticity()
|
||||
|
||||
self._render_scatter(env)
|
||||
self._render_elasticity_bar(env, elasticity)
|
||||
self._render_session_pie(env)
|
||||
self._render_price_heatmap(price_mat)
|
||||
self._render_demand_heatmap(demand_mat)
|
||||
self._render_correlation(env.n_products, price_mat, demand_mat)
|
||||
self._render_revenue(env)
|
||||
|
||||
self.fig.canvas.draw_idle()
|
||||
self.fig.canvas.flush_events()
|
||||
|
||||
def _render_scatter(self, env):
|
||||
ax = self.fig.add_subplot(self.gs[0, 0])
|
||||
prices_flat = np.array(env._price_history).flatten()
|
||||
demands_flat = np.array(env._demand_history).flatten()
|
||||
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
|
||||
ax.scatter(
|
||||
prices_flat,
|
||||
demands_flat,
|
||||
c=product_ids,
|
||||
cmap="plasma",
|
||||
alpha=0.6,
|
||||
s=15,
|
||||
edgecolors="none",
|
||||
)
|
||||
if len(prices_flat) > 1:
|
||||
z = np.polyfit(prices_flat, demands_flat, 1)
|
||||
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
|
||||
ax.plot(p_line, np.polyval(z, p_line), "--", lw=1.5, alpha=0.8)
|
||||
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
|
||||
|
||||
def _render_elasticity_bar(self, env, elasticity):
|
||||
ax = self.fig.add_subplot(self.gs[0, 1])
|
||||
ax.barh(range(env.n_products), elasticity, alpha=0.8)
|
||||
ax.axvline(0, lw=0.8, alpha=0.5)
|
||||
ax.axvline(-1, lw=1, ls="--", alpha=0.5)
|
||||
ax.set_yticks(range(env.n_products))
|
||||
ax.set_yticklabels([f"P{i}" for i in range(env.n_products)], fontsize=7)
|
||||
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
|
||||
|
||||
def _render_session_pie(self, env):
|
||||
ax = self.fig.add_subplot(self.gs[0, 2])
|
||||
n_h, n_a = env.market.Nhumans, env.market.Nagents
|
||||
wedges, _ = ax.pie(
|
||||
[n_h, n_a], startangle=90, wedgeprops={"linewidth": 2, "edgecolor": "white"}
|
||||
)
|
||||
ax.legend(
|
||||
wedges,
|
||||
[f"H ({n_h})", f"A ({n_a})"],
|
||||
loc="lower center",
|
||||
fontsize=8,
|
||||
frameon=False,
|
||||
bbox_to_anchor=(0.5, -0.05),
|
||||
)
|
||||
ax.set_title("Session Mix", fontsize=11, fontweight="bold")
|
||||
|
||||
def _render_price_heatmap(self, price_mat):
|
||||
ax = self.fig.add_subplot(self.gs[1, :2])
|
||||
im = ax.imshow(price_mat, aspect="auto", cmap="viridis", origin="lower")
|
||||
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
|
||||
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
||||
cbar.set_label("$", fontsize=8)
|
||||
|
||||
def _render_demand_heatmap(self, demand_mat):
|
||||
ax = self.fig.add_subplot(self.gs[1, 2])
|
||||
im = ax.imshow(demand_mat, aspect="auto", cmap="Blues", origin="lower")
|
||||
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||
|
||||
def _render_correlation(self, n_products, price_mat, demand_mat):
|
||||
ax = self.fig.add_subplot(self.gs[2, 0])
|
||||
if price_mat.shape[1] > 2:
|
||||
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
|
||||
im = ax.imshow(corr, cmap="RdBu", vmin=-1, vmax=1, aspect="auto")
|
||||
ax.set_xticks(range(n_products))
|
||||
ax.set_yticks(range(n_products))
|
||||
ax.set_xticklabels([f"Q{i}" for i in range(n_products)], fontsize=6)
|
||||
ax.set_yticklabels([f"P{i}" for i in range(n_products)], fontsize=6)
|
||||
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||
style_axis(ax, "Price-Demand Correlation", None, None)
|
||||
|
||||
def _render_revenue(self, env):
|
||||
ax = self.fig.add_subplot(self.gs[2, 1:])
|
||||
n_steps = len(env._revenue_history)
|
||||
demand_std = [np.std(d) for d in env._demand_history]
|
||||
ax.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
|
||||
ax.plot(env._revenue_history, linewidth=2, label="Revenue")
|
||||
ax.set_xlim(0, max(n_steps, 1))
|
||||
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
|
||||
|
||||
ax2 = ax.twinx()
|
||||
ax2.plot(
|
||||
range(n_steps),
|
||||
demand_std,
|
||||
linewidth=2,
|
||||
ls="-",
|
||||
alpha=0.9,
|
||||
label="sigma(Demand)",
|
||||
)
|
||||
d_min, d_max = min(demand_std), max(demand_std)
|
||||
margin = (d_max - d_min) * 0.2 if d_max > d_min else 0.5
|
||||
ax2.set_ylim(max(0, d_min - margin), d_max + margin)
|
||||
ax2.set_ylabel("Demand sigma", fontsize=9)
|
||||
|
||||
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
|
||||
ax.legend(loc="upper left", fontsize=7, frameon=False)
|
||||
ax2.legend(loc="upper right", fontsize=7, frameon=False)
|
||||
|
||||
def close(self):
|
||||
if self.fig:
|
||||
plt.close(self.fig)
|
||||
self.fig = None
|
||||
101
engine/lib/tiers.py
Normal file
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
|
||||
104
engine/lib/wrappers.py
Normal file
104
engine/lib/wrappers.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Economic metrics wrapper - calculates thesis-aligned KPIs and injects into info dict."""
|
||||
|
||||
import gymnasium as gym
|
||||
import numpy as np
|
||||
|
||||
|
||||
class EconomicMetricsWrapper(gym.Wrapper):
|
||||
"""Calculates thesis-aligned economic metrics per step, injects into info.
|
||||
|
||||
Metrics follow thesis definitions:
|
||||
- COI level: E[P] - p_min (Definition 1)
|
||||
- Margin: (avg_price - p_min) / avg_price
|
||||
- Regret: 1 - (revenue / baseline_revenue)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, env: gym.Env, p_min: float = 10.0, baseline_revenue: float | None = None
|
||||
):
|
||||
super().__init__(env)
|
||||
self.p_min = p_min
|
||||
self.baseline_revenue = baseline_revenue
|
||||
self._price_history: list[np.ndarray] = []
|
||||
self._revenue_history: list[float] = []
|
||||
|
||||
def reset(self, **kwargs):
|
||||
obs, info = self.env.reset(**kwargs)
|
||||
self._price_history = []
|
||||
self._revenue_history = []
|
||||
return obs, info
|
||||
|
||||
def step(self, action):
|
||||
obs, reward, terminated, truncated, info = self.env.step(action)
|
||||
|
||||
# extract from unwrapped env
|
||||
quoted_prices = np.asarray(self.env.unwrapped._prices, dtype=float)
|
||||
effective_prices = np.asarray(
|
||||
info.get("effective_prices", quoted_prices), dtype=float
|
||||
)
|
||||
if effective_prices.shape != quoted_prices.shape:
|
||||
effective_prices = quoted_prices
|
||||
demand_dict = self.env.unwrapped._demand
|
||||
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(quoted_prices))])
|
||||
|
||||
# core calculations
|
||||
revenue = float(info.get("revenue", np.sum(effective_prices * demand)))
|
||||
quoted_revenue = float(np.sum(quoted_prices * demand))
|
||||
avg_price = float(np.mean(effective_prices))
|
||||
margin = (avg_price - self.p_min) / max(avg_price, 1e-6)
|
||||
coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1
|
||||
|
||||
self._price_history.append(effective_prices.copy())
|
||||
self._revenue_history.append(revenue)
|
||||
|
||||
# regret vs baseline (golden path)
|
||||
regret = 0.0
|
||||
if self.baseline_revenue and self.baseline_revenue > 0:
|
||||
regret = 1.0 - (revenue / self.baseline_revenue)
|
||||
|
||||
# inject structured metrics into info
|
||||
info["economics"] = {
|
||||
"revenue": revenue,
|
||||
"quoted_revenue": quoted_revenue,
|
||||
"margin": margin,
|
||||
"coi_level": coi_level,
|
||||
"regret": regret,
|
||||
}
|
||||
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:
|
||||
info["economics"][key] = info[key]
|
||||
info["prices"] = quoted_prices.copy()
|
||||
info["effective_prices"] = effective_prices.copy()
|
||||
info["demand"] = demand.copy()
|
||||
|
||||
return obs, reward, terminated, truncated, info
|
||||
|
||||
@property
|
||||
def episode_revenue(self) -> float:
|
||||
return sum(self._revenue_history)
|
||||
|
||||
@property
|
||||
def episode_mean_price(self) -> float:
|
||||
if not self._price_history:
|
||||
return 0.0
|
||||
return float(np.mean([np.mean(p) for p in self._price_history]))
|
||||
33
engine/logging_utils.py
Normal file
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
|
||||
33
engine/studies/factors.py
Normal file
33
engine/studies/factors.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""shared factor definitions for experimental designs"""
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class Factor:
|
||||
name: str
|
||||
levels: list
|
||||
primary: bool = True # full cross vs sampled
|
||||
|
||||
# demand functions with compatible signatures
|
||||
def demand_linear(mu, sigma, size): return np.maximum(0, np.random.normal(mu, sigma, size))
|
||||
def demand_uniform(mu, sigma, size): return np.random.uniform(mu - sigma, mu + sigma, size)
|
||||
def demand_exponential(mu, sigma, size): return np.random.exponential(mu, size)
|
||||
def demand_logistic(mu, sigma, size): return np.random.logistic(mu, sigma, size)
|
||||
|
||||
DEMAND_FUNCTIONS = {
|
||||
"linear": demand_linear,
|
||||
"uniform": demand_uniform,
|
||||
"exponential": demand_exponential,
|
||||
"logistic": demand_logistic,
|
||||
}
|
||||
|
||||
FACTORS = [
|
||||
Factor("demand_fn", list(DEMAND_FUNCTIONS.keys()), primary=True),
|
||||
Factor("alpha", [0.1, 0.3, 0.5, 0.7], primary=True),
|
||||
Factor("n_products", [5, 15, 30, 50], primary=True),
|
||||
Factor("demand_mu", [30.0, 50.0, 70.0], primary=False),
|
||||
Factor("demand_sigma", [5.0, 10.0, 20.0], primary=False),
|
||||
Factor("N", [100, 500, 1000], primary=False),
|
||||
]
|
||||
|
||||
SEEDS_PER_CONFIG = 5
|
||||
104
engine/studies/full_factorial.py
Normal file
104
engine/studies/full_factorial.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""full factorial design - all factor combinations"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "..")
|
||||
import logging
|
||||
from itertools import product
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_configs():
|
||||
"""generate all factor combinations with seeds"""
|
||||
all_levels = [f.levels for f in FACTORS]
|
||||
names = [f.name for f in FACTORS]
|
||||
|
||||
configs = []
|
||||
for combo in product(*all_levels):
|
||||
base = {names[i]: combo[i] for i in range(len(names))}
|
||||
for seed in range(SEEDS_PER_CONFIG):
|
||||
cfg = {**base, "seed": seed}
|
||||
cfg["id"] = hashlib.md5(
|
||||
json.dumps(cfg, sort_keys=True).encode()
|
||||
).hexdigest()[:8]
|
||||
configs.append(cfg)
|
||||
return configs
|
||||
|
||||
|
||||
def run_single(cfg: dict) -> dict:
|
||||
"""execute one experiment config, return metrics"""
|
||||
from engine.wrapper import PHANTOM
|
||||
import numpy as np
|
||||
|
||||
np.random.seed(cfg["seed"])
|
||||
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||
|
||||
env = PHANTOM(
|
||||
n_products=cfg["n_products"],
|
||||
alpha=cfg["alpha"],
|
||||
N=cfg["N"],
|
||||
)
|
||||
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||
|
||||
obs, _ = env.reset()
|
||||
total_reward, steps = 0.0, 0
|
||||
|
||||
for _ in range(100):
|
||||
action = env.action_space.sample()
|
||||
obs, reward, term, trunc, _ = env.step(action)
|
||||
total_reward += reward
|
||||
steps += 1
|
||||
if term:
|
||||
break
|
||||
|
||||
env.close()
|
||||
return {
|
||||
"id": cfg["id"],
|
||||
"config": cfg,
|
||||
"total_reward": total_reward,
|
||||
"avg_reward": total_reward / steps if steps > 0 else 0.0,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
|
||||
configs = generate_configs()
|
||||
log.info(
|
||||
f"full factorial: {len(configs)} configs ({len(configs) // SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)"
|
||||
)
|
||||
|
||||
results = []
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||
for i, result in enumerate(ex.map(run_single, configs)):
|
||||
results.append(result)
|
||||
if (i + 1) % 100 == 0:
|
||||
log.info(f"progress: {i + 1}/{len(configs)}")
|
||||
|
||||
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||
log.info(f"wrote {len(results)} results to {output}")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--workers", type=int, default=None)
|
||||
p.add_argument("--output", default="results_full.jsonl")
|
||||
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||
args = p.parse_args()
|
||||
|
||||
configs = generate_configs()
|
||||
log.info(
|
||||
f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}"
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
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,
|
||||
)
|
||||
127
engine/studies/mixed_lh.py
Normal file
127
engine/studies/mixed_lh.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "..")
|
||||
import logging
|
||||
from itertools import product
|
||||
import json
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
import numpy as np
|
||||
from scipy.stats.qmc import LatinHypercube
|
||||
from factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LH_SAMPLES = 10
|
||||
|
||||
|
||||
def generate_configs(lh_samples: int = LH_SAMPLES):
|
||||
primary = [f for f in FACTORS if f.primary]
|
||||
secondary = [f for f in FACTORS if not f.primary]
|
||||
|
||||
primary_grid = list(product(*[f.levels for f in primary]))
|
||||
lhs = LatinHypercube(d=len(secondary), seed=42)
|
||||
|
||||
configs = []
|
||||
for p_combo in primary_grid:
|
||||
samples = lhs.random(n=lh_samples)
|
||||
for s in samples:
|
||||
sec_vals = {
|
||||
secondary[i].name: secondary[i].levels[
|
||||
int(s[i] * len(secondary[i].levels))
|
||||
]
|
||||
for i in range(len(secondary))
|
||||
}
|
||||
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
|
||||
base.update(sec_vals)
|
||||
|
||||
for seed in range(SEEDS_PER_CONFIG):
|
||||
cfg = {**base, "seed": seed}
|
||||
cfg["id"] = hashlib.md5(
|
||||
json.dumps(cfg, sort_keys=True).encode()
|
||||
).hexdigest()[:8]
|
||||
configs.append(cfg)
|
||||
return configs
|
||||
|
||||
|
||||
def run_single(cfg: dict) -> dict:
|
||||
from engine.wrapper import PHANTOM
|
||||
import numpy as np
|
||||
|
||||
np.random.seed(cfg["seed"])
|
||||
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||
|
||||
env = PHANTOM(
|
||||
n_products=cfg["n_products"],
|
||||
alpha=cfg["alpha"],
|
||||
N=cfg["N"],
|
||||
)
|
||||
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||
|
||||
obs, _ = env.reset()
|
||||
total_reward, steps = 0.0, 0
|
||||
|
||||
for _ in range(100):
|
||||
action = env.action_space.sample()
|
||||
obs, reward, term, trunc, _ = env.step(action)
|
||||
total_reward += reward
|
||||
steps += 1
|
||||
if term:
|
||||
break
|
||||
|
||||
env.close()
|
||||
return {
|
||||
"id": cfg["id"],
|
||||
"config": cfg,
|
||||
"total_reward": total_reward,
|
||||
"avg_reward": total_reward / steps,
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def run_study(
|
||||
max_workers: int = None,
|
||||
output: str = "results_mixed.jsonl",
|
||||
lh_samples: int = LH_SAMPLES,
|
||||
):
|
||||
configs = generate_configs(lh_samples)
|
||||
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
|
||||
log.info(
|
||||
f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)"
|
||||
)
|
||||
|
||||
results = []
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||
for i, result in enumerate(ex.map(run_single, configs)):
|
||||
results.append(result)
|
||||
if (i + 1) % 100 == 0:
|
||||
log.info(f"progress: {i + 1}/{len(configs)}")
|
||||
|
||||
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||
log.info(f"wrote {len(results)} results to {output}")
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--workers", type=int, default=None)
|
||||
p.add_argument("--output", default="results_mixed.jsonl")
|
||||
p.add_argument("--lh-samples", type=int, default=10)
|
||||
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||
args = p.parse_args()
|
||||
|
||||
primary = [f for f in FACTORS if f.primary]
|
||||
secondary = [f for f in FACTORS if not f.primary]
|
||||
configs = generate_configs(args.lh_samples)
|
||||
log.info(
|
||||
f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}"
|
||||
)
|
||||
|
||||
if not args.dry_run:
|
||||
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
|
||||
84
engine/sweeps/model_mix.yaml
Normal file
84
engine/sweeps/model_mix.yaml
Normal file
@@ -0,0 +1,84 @@
|
||||
method: random
|
||||
metric:
|
||||
name: objective/score
|
||||
goal: maximize
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
algo:
|
||||
values: [ppo, a2c, dqn, qtable]
|
||||
total_timesteps:
|
||||
values: [30000, 50000, 80000]
|
||||
seed:
|
||||
values: [13, 42, 77]
|
||||
n_products:
|
||||
values: [8, 10, 12]
|
||||
alpha:
|
||||
distribution: uniform
|
||||
min: 0.1
|
||||
max: 0.6
|
||||
lambda_coi:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.6
|
||||
robust_radius:
|
||||
distribution: uniform
|
||||
min: 0.0
|
||||
max: 0.3
|
||||
robust_points:
|
||||
values: [3, 5, 7]
|
||||
info_value:
|
||||
distribution: uniform
|
||||
min: 0.5
|
||||
max: 2.0
|
||||
revenue_weight:
|
||||
values: [0.005, 0.01, 0.02]
|
||||
learning_rate:
|
||||
distribution: log_uniform_values
|
||||
min: 1.0e-5
|
||||
max: 1.0e-3
|
||||
gamma:
|
||||
values: [0.97, 0.99, 0.995]
|
||||
buffer_size:
|
||||
values: [20000, 50000, 100000]
|
||||
batch_size:
|
||||
values: [128, 256, 512]
|
||||
tau:
|
||||
values: [0.002, 0.005, 0.01]
|
||||
train_freq:
|
||||
values: [1, 4, 8]
|
||||
learning_starts:
|
||||
values: [500, 1000, 3000]
|
||||
n_steps:
|
||||
values: [512, 1024, 2048]
|
||||
n_epochs:
|
||||
values: [5, 10, 20]
|
||||
gae_lambda:
|
||||
values: [0.9, 0.95, 0.98]
|
||||
clip_range:
|
||||
values: [0.1, 0.2, 0.3]
|
||||
ent_coef:
|
||||
values: [0.0, 0.005, 0.01]
|
||||
target_update_interval:
|
||||
values: [500, 1000, 2000]
|
||||
exploration_fraction:
|
||||
values: [0.1, 0.2, 0.3]
|
||||
exploration_final_eps:
|
||||
values: [0.01, 0.03, 0.05]
|
||||
action_levels:
|
||||
values: [7, 9, 11]
|
||||
action_scale_low:
|
||||
values: [0.75, 0.8, 0.85]
|
||||
action_scale_high:
|
||||
values: [1.15, 1.2, 1.25]
|
||||
q_lr:
|
||||
values: [0.03, 0.05, 0.1, 0.2]
|
||||
eps_start:
|
||||
value: 1.0
|
||||
eps_end:
|
||||
values: [0.02, 0.05, 0.1]
|
||||
eps_decay:
|
||||
values: [0.999, 0.9995, 0.9999]
|
||||
85
engine/sweeps/models_only.yaml
Normal file
85
engine/sweeps/models_only.yaml
Normal file
@@ -0,0 +1,85 @@
|
||||
method: grid
|
||||
metric:
|
||||
name: objective/score
|
||||
goal: maximize
|
||||
run_cap: 4
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
algo:
|
||||
values: [ppo, a2c, dqn, qtable]
|
||||
seed:
|
||||
value: 42
|
||||
total_timesteps:
|
||||
value: 12000
|
||||
eval_episodes:
|
||||
value: 3
|
||||
eval_freq:
|
||||
value: 500
|
||||
log_freq:
|
||||
value: 100
|
||||
revenue_weight:
|
||||
value: 0.01
|
||||
n_products:
|
||||
value: 8
|
||||
N:
|
||||
value: 80
|
||||
alpha:
|
||||
value: 0.3
|
||||
lambda_coi:
|
||||
value: 0.2
|
||||
robust_radius:
|
||||
value: 0.0
|
||||
robust_points:
|
||||
value: 1
|
||||
info_value:
|
||||
value: 1.0
|
||||
learning_rate:
|
||||
value: 0.0003
|
||||
gamma:
|
||||
value: 0.99
|
||||
buffer_size:
|
||||
value: 20000
|
||||
batch_size:
|
||||
value: 128
|
||||
tau:
|
||||
value: 0.005
|
||||
train_freq:
|
||||
value: 1
|
||||
learning_starts:
|
||||
value: 500
|
||||
n_steps:
|
||||
value: 512
|
||||
n_epochs:
|
||||
value: 10
|
||||
gae_lambda:
|
||||
value: 0.95
|
||||
clip_range:
|
||||
value: 0.2
|
||||
ent_coef:
|
||||
value: 0.0
|
||||
target_update_interval:
|
||||
value: 500
|
||||
exploration_fraction:
|
||||
value: 0.2
|
||||
exploration_final_eps:
|
||||
value: 0.05
|
||||
action_levels:
|
||||
value: 7
|
||||
action_scale_low:
|
||||
value: 0.9
|
||||
action_scale_high:
|
||||
value: 1.1
|
||||
q_lr:
|
||||
value: 0.1
|
||||
q_bins:
|
||||
value: 6
|
||||
eps_start:
|
||||
value: 1.0
|
||||
eps_end:
|
||||
value: 0.05
|
||||
eps_decay:
|
||||
value: 0.9995
|
||||
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
|
||||
54
engine/sweeps/sac_tune.yaml
Normal file
54
engine/sweeps/sac_tune.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
method: bayes
|
||||
metric:
|
||||
name: objective/score
|
||||
goal: maximize
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
algo:
|
||||
value: sac
|
||||
total_timesteps:
|
||||
values: [50000, 80000, 120000]
|
||||
seed:
|
||||
values: [13, 42, 77]
|
||||
alpha:
|
||||
distribution: uniform
|
||||
min: 0.15
|
||||
max: 0.55
|
||||
n_products:
|
||||
values: [8, 10, 12]
|
||||
lambda_coi:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.5
|
||||
robust_radius:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.3
|
||||
robust_points:
|
||||
values: [3, 5, 7]
|
||||
info_value:
|
||||
distribution: uniform
|
||||
min: 0.5
|
||||
max: 2.0
|
||||
revenue_weight:
|
||||
values: [0.005, 0.01, 0.02]
|
||||
learning_rate:
|
||||
distribution: log_uniform_values
|
||||
min: 3.0e-5
|
||||
max: 1.0e-3
|
||||
gamma:
|
||||
values: [0.98, 0.99, 0.995]
|
||||
buffer_size:
|
||||
values: [50000, 100000, 200000]
|
||||
batch_size:
|
||||
values: [128, 256, 512]
|
||||
tau:
|
||||
values: [0.002, 0.005, 0.01]
|
||||
train_freq:
|
||||
values: [1, 4, 8]
|
||||
learning_starts:
|
||||
values: [1000, 3000, 5000]
|
||||
86
engine/sweeps/small_arch_compare.yaml
Normal file
86
engine/sweeps/small_arch_compare.yaml
Normal file
@@ -0,0 +1,86 @@
|
||||
method: random
|
||||
metric:
|
||||
name: objective/score
|
||||
goal: maximize
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
algo:
|
||||
values: [ppo, a2c, dqn, qtable]
|
||||
arch:
|
||||
values: [tiny, small, medium]
|
||||
activation:
|
||||
values: [relu, tanh]
|
||||
total_timesteps:
|
||||
values: [8000, 12000, 20000]
|
||||
seed:
|
||||
values: [13, 42, 77]
|
||||
n_products:
|
||||
values: [6, 8, 10]
|
||||
alpha:
|
||||
distribution: uniform
|
||||
min: 0.1
|
||||
max: 0.5
|
||||
lambda_coi:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.4
|
||||
robust_radius:
|
||||
values: [0.0, 0.1, 0.2]
|
||||
robust_points:
|
||||
values: [3, 5]
|
||||
info_value:
|
||||
values: [0.75, 1.0, 1.5]
|
||||
revenue_weight:
|
||||
values: [0.005, 0.01, 0.02]
|
||||
learning_rate:
|
||||
distribution: log_uniform_values
|
||||
min: 1.0e-5
|
||||
max: 5.0e-4
|
||||
gamma:
|
||||
values: [0.98, 0.99]
|
||||
buffer_size:
|
||||
values: [10000, 30000, 50000]
|
||||
batch_size:
|
||||
values: [64, 128, 256]
|
||||
tau:
|
||||
values: [0.002, 0.005, 0.01]
|
||||
train_freq:
|
||||
values: [1, 4]
|
||||
learning_starts:
|
||||
values: [500, 1000, 2000]
|
||||
n_steps:
|
||||
values: [256, 512, 1024]
|
||||
n_epochs:
|
||||
values: [5, 10]
|
||||
gae_lambda:
|
||||
values: [0.9, 0.95]
|
||||
clip_range:
|
||||
values: [0.1, 0.2]
|
||||
ent_coef:
|
||||
values: [0.0, 0.005]
|
||||
target_update_interval:
|
||||
values: [500, 1000]
|
||||
exploration_fraction:
|
||||
values: [0.1, 0.2]
|
||||
exploration_final_eps:
|
||||
values: [0.02, 0.05]
|
||||
action_levels:
|
||||
values: [5, 7, 9]
|
||||
action_scale_low:
|
||||
values: [0.85, 0.9]
|
||||
action_scale_high:
|
||||
values: [1.1, 1.15]
|
||||
q_lr:
|
||||
values: [0.05, 0.1, 0.2]
|
||||
q_bins:
|
||||
values: [4, 6, 8]
|
||||
eps_start:
|
||||
value: 1.0
|
||||
eps_end:
|
||||
values: [0.02, 0.05]
|
||||
eps_decay:
|
||||
values: [0.999, 0.9995]
|
||||
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)
|
||||
251
engine/train.py
Normal file
251
engine/train.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from typing import Any
|
||||
|
||||
from .logging_utils import configure_logging
|
||||
from .orchestrators import run_benchmark_cli, run_sweep_agent, run_train_once
|
||||
from .spec import TrainSpec
|
||||
|
||||
|
||||
def _parse_tags(raw: str | None) -> list[str]:
|
||||
if raw is None:
|
||||
return []
|
||||
return [piece.strip() for piece in str(raw).split(",") if piece.strip()]
|
||||
|
||||
|
||||
def _probe_run_kind(argv: list[str]) -> str:
|
||||
probe = argparse.ArgumentParser(add_help=False)
|
||||
probe.add_argument("--run-kind", choices=["train", "benchmark"])
|
||||
probe.add_argument("--run-mode", choices=["train", "benchmark"])
|
||||
args, _ = probe.parse_known_args(argv)
|
||||
return str(args.run_kind or args.run_mode or "train")
|
||||
|
||||
|
||||
def _strip_run_kind(argv: list[str]) -> list[str]:
|
||||
stripped: list[str] = []
|
||||
skip_next = False
|
||||
for item in argv:
|
||||
if skip_next:
|
||||
skip_next = False
|
||||
continue
|
||||
if item in {"--run-kind", "--run-mode"}:
|
||||
skip_next = True
|
||||
continue
|
||||
if item.startswith("--run-kind=") or item.startswith("--run-mode="):
|
||||
continue
|
||||
stripped.append(item)
|
||||
return stripped
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="PHANTOM unified training entrypoint")
|
||||
parser.add_argument("--run-kind", choices=["train", "benchmark"], default="train")
|
||||
parser.add_argument("--run-mode", choices=["train", "benchmark"])
|
||||
|
||||
parser.add_argument("--project", default="capstone")
|
||||
parser.add_argument("--scenario", default="default")
|
||||
parser.add_argument("--group", type=str)
|
||||
parser.add_argument("--tags", type=str)
|
||||
|
||||
parser.add_argument("--backend", choices=["auto", "sb3"], default="auto")
|
||||
parser.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable", "sac"])
|
||||
parser.add_argument("--seed", type=int)
|
||||
parser.add_argument("--total-timesteps", type=int)
|
||||
parser.add_argument("--model-dir", type=str)
|
||||
parser.add_argument("--log-freq", type=int)
|
||||
parser.add_argument("--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 _overrides_from_args(args: argparse.Namespace) -> dict[str, Any]:
|
||||
backend = None if args.backend == "auto" else args.backend
|
||||
|
||||
overrides = {
|
||||
"project": args.project,
|
||||
"backend": backend,
|
||||
"algo": args.algo,
|
||||
"seed": args.seed,
|
||||
"total_timesteps": args.total_timesteps,
|
||||
"model_dir": args.model_dir,
|
||||
"log_freq": args.log_freq,
|
||||
"hist_freq": args.hist_freq,
|
||||
"checkpoint_interval": args.checkpoint_interval,
|
||||
"device": args.device,
|
||||
"alpha": args.alpha,
|
||||
"N": args.N,
|
||||
"n_products": args.n_products,
|
||||
"lambda_coi": args.lambda_coi,
|
||||
"info_value": args.info_value,
|
||||
"robust_radius": args.robust_radius,
|
||||
"robust_points": args.robust_points,
|
||||
"robust_rollouts": args.robust_rollouts,
|
||||
"no_robust": args.no_robust,
|
||||
"eta_ux": args.eta_ux,
|
||||
"reward_profit_weight": args.reward_profit_weight,
|
||||
"price_low": args.price_low,
|
||||
"price_high": args.price_high,
|
||||
"action_levels": args.action_levels,
|
||||
"action_scale_low": args.action_scale_low,
|
||||
"action_scale_high": args.action_scale_high,
|
||||
"max_steps": args.max_steps,
|
||||
"margin_floor": args.margin_floor,
|
||||
"margin_floor_patience": args.margin_floor_patience,
|
||||
"learning_rate": args.learning_rate,
|
||||
"gamma": args.gamma,
|
||||
"buffer_size": args.buffer_size,
|
||||
"batch_size": args.batch_size,
|
||||
"tau": args.tau,
|
||||
"train_freq": args.train_freq,
|
||||
"learning_starts": args.learning_starts,
|
||||
"target_update_interval": args.target_update_interval,
|
||||
"exploration_fraction": args.exploration_fraction,
|
||||
"exploration_final_eps": args.exploration_final_eps,
|
||||
"n_steps": args.n_steps,
|
||||
"n_epochs": args.n_epochs,
|
||||
"gae_lambda": args.gae_lambda,
|
||||
"clip_range": args.clip_range,
|
||||
"ent_coef": args.ent_coef,
|
||||
"q_lr": args.q_lr,
|
||||
"q_bins": args.q_bins,
|
||||
"eps_start": args.eps_start,
|
||||
"eps_end": args.eps_end,
|
||||
"eps_decay": args.eps_decay,
|
||||
"arch": args.arch,
|
||||
"activation": args.activation,
|
||||
"vf_coef": args.vf_coef,
|
||||
"max_grad_norm": args.max_grad_norm,
|
||||
"eval_freq": args.eval_freq,
|
||||
"eval_episodes": args.eval_episodes,
|
||||
}
|
||||
return {key: value for key, value in overrides.items() if value is not None}
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
import 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:
|
||||
run_sweep_agent(
|
||||
project=args.project,
|
||||
sweep_id=str(args.sweep_id or ""),
|
||||
count=int(args.count),
|
||||
offline=bool(args.offline),
|
||||
no_wandb=bool(args.no_wandb),
|
||||
base_overrides=overrides,
|
||||
kind="sweep",
|
||||
scenario=scenario,
|
||||
group=group,
|
||||
extra_tags=extra_tags,
|
||||
)
|
||||
return
|
||||
|
||||
spec = TrainSpec.from_flat(overrides)
|
||||
run_train_once(
|
||||
spec,
|
||||
project=args.project,
|
||||
offline=bool(args.offline),
|
||||
no_wandb=bool(args.no_wandb),
|
||||
kind="train",
|
||||
scenario=scenario,
|
||||
group=group,
|
||||
extra_tags=extra_tags,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
130
engine/wandb_checkpoint.py
Normal file
130
engine/wandb_checkpoint.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Mapping
|
||||
|
||||
try:
|
||||
import wandb
|
||||
from wandb.errors import CommError
|
||||
|
||||
HAS_WANDB = True
|
||||
except ImportError:
|
||||
HAS_WANDB = False
|
||||
wandb = None # type: ignore[assignment]
|
||||
CommError = RuntimeError # type: ignore[assignment]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _safe_value(value[k]) for k in sorted(value)}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _safe_scope(scope: str | None) -> str:
|
||||
raw = "manual" if scope in (None, "") else str(scope)
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-")
|
||||
return cleaned or "manual"
|
||||
|
||||
|
||||
def checkpoint_artifact_name(
|
||||
cfg: Mapping[str, Any], *, backend: str, sweep_id: str | None = None
|
||||
) -> str:
|
||||
payload = {k: _safe_value(cfg[k]) for k in sorted(cfg)}
|
||||
scope = _safe_scope(sweep_id)
|
||||
canonical = json.dumps(
|
||||
{"backend": backend, "scope": scope, "cfg": payload},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
digest = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:14]
|
||||
return f"phantom-{backend}-ckpt-{scope}-{digest}"[:128]
|
||||
|
||||
|
||||
def _is_missing_artifact_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, CommError):
|
||||
msg = str(exc).lower()
|
||||
return "not found" in msg or "does not exist" in msg
|
||||
return False
|
||||
|
||||
|
||||
def download_latest_checkpoint(
|
||||
artifact_name: str, *, file_name: str
|
||||
) -> tuple[Path, dict[str, Any]] | None:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return None
|
||||
try:
|
||||
artifact = wandb.run.use_artifact(f"{artifact_name}:latest")
|
||||
except Exception as exc:
|
||||
if _is_missing_artifact_error(exc):
|
||||
return None
|
||||
raise
|
||||
directory = Path(artifact.download())
|
||||
checkpoint_path = directory / file_name
|
||||
if not checkpoint_path.exists():
|
||||
return None
|
||||
metadata = dict(getattr(artifact, "metadata", {}) or {})
|
||||
return checkpoint_path, metadata
|
||||
|
||||
|
||||
def _aliases_from_metadata(metadata: dict[str, Any] | None) -> list[str]:
|
||||
aliases = ["latest"]
|
||||
if metadata is None:
|
||||
return aliases
|
||||
if "step" in metadata:
|
||||
try:
|
||||
aliases.append(f"step-{int(metadata['step'])}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return aliases
|
||||
|
||||
|
||||
def log_checkpoint_bytes(
|
||||
artifact_name: str,
|
||||
*,
|
||||
file_name: str,
|
||||
payload: bytes,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return False
|
||||
with TemporaryDirectory(prefix="phantom-ckpt-") as tmpdir:
|
||||
path = Path(tmpdir) / file_name
|
||||
path.write_bytes(payload)
|
||||
artifact = wandb.Artifact(
|
||||
name=artifact_name,
|
||||
type="checkpoint",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
artifact.add_file(path.as_posix(), name=file_name)
|
||||
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
|
||||
return True
|
||||
|
||||
|
||||
def log_checkpoint_file(
|
||||
artifact_name: str,
|
||||
*,
|
||||
file_path: str | Path,
|
||||
artifact_file_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return False
|
||||
src = Path(file_path)
|
||||
if not src.exists():
|
||||
return False
|
||||
artifact = wandb.Artifact(
|
||||
name=artifact_name,
|
||||
type="checkpoint",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
artifact.add_file(src.as_posix(), name=artifact_file_name)
|
||||
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
|
||||
return True
|
||||
477
engine/wrapper.py
Normal file
477
engine/wrapper.py
Normal file
@@ -0,0 +1,477 @@
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
import numpy as np
|
||||
from .engine import Limbo, MarketEngine, PricingEngine
|
||||
from .lib.render import DashboardRenderer
|
||||
from .lib.coi import (
|
||||
compute_uplift_coi,
|
||||
extract_purchases,
|
||||
compute_agent_probability,
|
||||
)
|
||||
from .lib.behavior import get_transition_models, trajectory_to_events
|
||||
from .lib.wrappers import EconomicMetricsWrapper
|
||||
from .jax.robust import select_adversarial_alpha_jax, _JAX_OK
|
||||
|
||||
|
||||
class _ActionPricingEngine(PricingEngine):
|
||||
def __init__(self, n_products: int, price_bounds: tuple):
|
||||
self._prices = np.full(n_products, price_bounds[0], dtype=float)
|
||||
|
||||
def set_prices(self, prices: np.ndarray):
|
||||
self._prices = np.asarray(prices, dtype=float)
|
||||
|
||||
def act(self, _):
|
||||
return self._prices
|
||||
|
||||
|
||||
class PHANTOM(gym.Env):
|
||||
"""Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework
|
||||
|
||||
reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL
|
||||
COI_leak uses behavioral divergence to estimate agent probability f(τ')
|
||||
robust inner step: min over alpha in Wasserstein interval around nominal alpha
|
||||
actions are discrete global price-scale moves
|
||||
"""
|
||||
|
||||
metadata = {"render_modes": ["human", "ansi"]}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
n_products: int = 10,
|
||||
alpha: float = 0.3,
|
||||
N: int = 100,
|
||||
human_params: tuple = (50.0, 10.0),
|
||||
agent_params: tuple = (45.0, 15.0),
|
||||
noise_std: float = 1.0,
|
||||
price_bounds: tuple = (10.0, 150.0),
|
||||
lambda_coi: float = 0.1,
|
||||
coi_window: int = 10,
|
||||
robust_radius: float = 0.0,
|
||||
robust_points: int = 5,
|
||||
robust_rollouts: int = 1,
|
||||
info_value: float = 1.0,
|
||||
eta_ux: float = 0.5,
|
||||
reward_profit_weight: float = 1.0,
|
||||
action_levels: int = 9,
|
||||
action_scale_low: float = 0.9,
|
||||
action_scale_high: float = 1.1,
|
||||
max_steps: int = 100,
|
||||
margin_floor: float = 0.05,
|
||||
margin_floor_patience: int = 5,
|
||||
render_mode: str = None,
|
||||
):
|
||||
super().__init__()
|
||||
self.n_products = n_products
|
||||
self.price_bounds = price_bounds
|
||||
self.lambda_coi = lambda_coi
|
||||
self.coi_window = coi_window
|
||||
self.max_steps = max(1, int(max_steps))
|
||||
self.margin_floor = float(
|
||||
margin_floor
|
||||
) # terminate if avg margin stays below this for patience steps
|
||||
self.margin_floor_patience = max(1, int(margin_floor_patience))
|
||||
self.render_mode = render_mode
|
||||
self.alpha = float(alpha)
|
||||
self.nominal_alpha = float(alpha)
|
||||
self.N = N
|
||||
self.human_params = human_params
|
||||
self.agent_params = agent_params
|
||||
self.robust_radius = max(0.0, float(robust_radius))
|
||||
self.robust_points = max(1, int(robust_points))
|
||||
self.robust_rollouts = max(1, int(robust_rollouts))
|
||||
self.info_value = float(info_value)
|
||||
self.eta_ux = float(eta_ux)
|
||||
self.reward_profit_weight = float(reward_profit_weight)
|
||||
self.action_levels = max(2, int(action_levels))
|
||||
self._action_scales = np.linspace(
|
||||
float(action_scale_low), float(action_scale_high), self.action_levels
|
||||
)
|
||||
|
||||
self.market = MarketEngine(
|
||||
alpha=alpha,
|
||||
N=N,
|
||||
human_params=human_params,
|
||||
agent_params=agent_params,
|
||||
noise_std=noise_std,
|
||||
)
|
||||
self._platform_stub = _ActionPricingEngine(n_products, price_bounds)
|
||||
self._limbo = Limbo(self._platform_stub, self.market)
|
||||
self._set_market_mix(self.nominal_alpha)
|
||||
|
||||
self.action_space = spaces.Discrete(self.action_levels)
|
||||
self.observation_space = spaces.Dict(
|
||||
{
|
||||
"demand": spaces.Box(
|
||||
low=0.0, high=100.0, shape=(n_products,), dtype=np.float32
|
||||
),
|
||||
"prices": spaces.Box(
|
||||
low=price_bounds[0],
|
||||
high=price_bounds[1],
|
||||
shape=(n_products,),
|
||||
dtype=np.float32,
|
||||
),
|
||||
"signals": spaces.Box(
|
||||
low=np.array([0.0, 0.0, 0.0, 0.0], dtype=np.float32),
|
||||
high=np.array([1.0, 1.0, 1.0, 1.0], dtype=np.float32),
|
||||
shape=(4,),
|
||||
dtype=np.float32,
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
self._prices = None
|
||||
self._demand = None
|
||||
self._step_count = 0
|
||||
self._global_step = 0 # monotonic; used as JAX RNG seed across resets
|
||||
self._demand_history = []
|
||||
self._price_history = []
|
||||
self._revenue_history = []
|
||||
self._renderer = None
|
||||
self._initial_episode_prices = None
|
||||
self._trajectories = [] # session trajectories for agent prob calculation
|
||||
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
|
||||
self.anchor_prices = np.full(
|
||||
self.n_products,
|
||||
float(np.clip(float(self.human_params[0]), *self.price_bounds)),
|
||||
)
|
||||
self.competitive_cap = float(
|
||||
min(self.price_bounds[1], float(np.mean(self.anchor_prices)) * 1.15)
|
||||
)
|
||||
self._low_margin_streak = 0 # consecutive steps below margin_floor
|
||||
self._last_agent_prob = float(self.alpha)
|
||||
self._last_alpha_adv = float(self.alpha)
|
||||
|
||||
# load behavioral models for agent probability estimation
|
||||
try:
|
||||
self._human_trans, self._agent_trans = get_transition_models()
|
||||
except Exception:
|
||||
# fallback if behavioral data unavailable
|
||||
self._human_trans, self._agent_trans = None, None
|
||||
|
||||
def _get_obs(self) -> dict:
|
||||
demand_arr = np.array(
|
||||
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
|
||||
)
|
||||
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):
|
||||
alpha = float(np.clip(alpha, 0.0, 1.0))
|
||||
n_agents = int(self.N * alpha)
|
||||
self.alpha = alpha
|
||||
self.market.alpha = alpha
|
||||
self.market.Nagents = n_agents
|
||||
self.market.Nhumans = self.N - n_agents
|
||||
|
||||
def _decode_action(self, action) -> np.ndarray:
|
||||
prev = self._prices
|
||||
base = self.anchor_prices
|
||||
|
||||
def _blend(target: np.ndarray) -> np.ndarray:
|
||||
if prev is None:
|
||||
lower = float(self.price_bounds[0])
|
||||
return np.clip(target, lower, self.competitive_cap)
|
||||
blended = 0.75 * np.asarray(prev, dtype=float) + 0.25 * target
|
||||
lower = float(self.price_bounds[0])
|
||||
return np.clip(blended, lower, self.competitive_cap)
|
||||
|
||||
if np.isscalar(action):
|
||||
idx = int(np.clip(int(action), 0, self.action_levels - 1))
|
||||
target = base * self._action_scales[idx]
|
||||
return _blend(target)
|
||||
a = np.asarray(action)
|
||||
if a.size == 1:
|
||||
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
|
||||
target = base * self._action_scales[idx]
|
||||
return _blend(target)
|
||||
lower = float(self.price_bounds[0])
|
||||
return np.clip(a.astype(float), lower, self.competitive_cap)
|
||||
|
||||
def _compute_agent_prob(self, trajectories=None) -> float:
|
||||
trajectories = (
|
||||
self.market.last_trajectories if trajectories is None else trajectories
|
||||
)
|
||||
if not trajectories or self._human_trans is None or self._agent_trans is None:
|
||||
return float(self.market.alpha)
|
||||
probs = []
|
||||
for traj in trajectories:
|
||||
events = trajectory_to_events(traj)
|
||||
if len(events) < 2:
|
||||
continue
|
||||
probs.append(
|
||||
compute_agent_probability(events, self._human_trans, self._agent_trans)
|
||||
)
|
||||
return float(np.mean(probs)) if probs else float(self.market.alpha)
|
||||
|
||||
def _compute_reward(
|
||||
self, prices: np.ndarray, demand: dict, agent_prob: float, trajectories: list
|
||||
) -> tuple[float, dict]:
|
||||
demand_arr = np.array(
|
||||
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
|
||||
)
|
||||
revenue = float(np.dot(prices, demand_arr))
|
||||
floor_cost = float(np.dot(self.baseline_prices, demand_arr))
|
||||
profit = revenue - floor_cost
|
||||
purchases = extract_purchases(trajectories)
|
||||
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
|
||||
|
||||
coi_leakage = float(agent_prob * self.info_value)
|
||||
info_budget = max(floor_cost, 1.0)
|
||||
coi_penalty = self.lambda_coi * coi_leakage * info_budget
|
||||
|
||||
if len(self._price_history) > 0:
|
||||
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, {
|
||||
"revenue": revenue,
|
||||
"cost_floor": floor_cost,
|
||||
"profit": profit,
|
||||
"coi_mix": float(coi_mix),
|
||||
"coi_base": 0.0,
|
||||
"coi_leakage": coi_leakage,
|
||||
"coi_penalty": coi_penalty,
|
||||
"coi_info_budget": info_budget,
|
||||
"ux_penalty": ux_penalty,
|
||||
"volatility": volatility,
|
||||
"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:
|
||||
if self.robust_radius <= 0.0 or self.robust_points == 1:
|
||||
return np.array([self.nominal_alpha], dtype=float)
|
||||
lo = max(0.0, self.nominal_alpha - self.robust_radius)
|
||||
hi = min(1.0, self.nominal_alpha + self.robust_radius)
|
||||
return np.linspace(lo, hi, self.robust_points)
|
||||
|
||||
def _evaluate_candidate(self, alpha: float, prices: np.ndarray) -> float:
|
||||
self._set_market_mix(alpha)
|
||||
rewards = []
|
||||
for _ in range(self.robust_rollouts):
|
||||
demand = self.market.act(prices)
|
||||
trajectories = list(self.market.last_trajectories)
|
||||
agent_prob = self._compute_agent_prob(trajectories)
|
||||
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
|
||||
rewards.append(float(reward))
|
||||
return float(np.mean(rewards)) if rewards else 0.0
|
||||
|
||||
def _select_adversarial_alpha(self, prices: np.ndarray) -> float:
|
||||
"""inner robust step: pick worst-case alpha from the ambiguity interval.
|
||||
|
||||
when JAX is available and robust_rollouts==1 we use a vmapped pass over
|
||||
all K candidates in a single call (no Python loop, no market.act overhead).
|
||||
the JAX path approximates demand as the mixed closed-form d(p;theta) signal
|
||||
rather than running full trajectory sampling, which is accurate for the
|
||||
alpha-selection decision while being dramatically cheaper.
|
||||
|
||||
when robust_rollouts>1 or JAX is unavailable we fall back to the sequential
|
||||
market.act() loop so behavior is identical to the original implementation.
|
||||
"""
|
||||
candidates = self._alpha_candidates()
|
||||
if len(candidates) == 1:
|
||||
return float(candidates[0])
|
||||
|
||||
if _JAX_OK and self.robust_rollouts == 1:
|
||||
best_alpha, _ = select_adversarial_alpha_jax(
|
||||
candidates=candidates,
|
||||
prices=prices,
|
||||
human_params=self.market.human_params,
|
||||
agent_params=self.market.agent_params,
|
||||
noise_std=self.market.noise_std,
|
||||
baseline_prices=self.baseline_prices,
|
||||
lambda_coi=self.lambda_coi,
|
||||
info_value=self.info_value,
|
||||
reward_profit_weight=self.reward_profit_weight,
|
||||
rng_seed=self._global_step,
|
||||
)
|
||||
return best_alpha
|
||||
|
||||
# fallback: full trajectory-based sequential evaluation
|
||||
evaluations = [
|
||||
(float(alpha), self._evaluate_candidate(float(alpha), prices))
|
||||
for alpha in candidates
|
||||
]
|
||||
best_alpha, _ = min(evaluations, key=lambda x: x[1])
|
||||
return best_alpha
|
||||
|
||||
def _record_history(self):
|
||||
demand_arr = np.array(
|
||||
[self._demand.get(i, 0.0) for i in range(self.n_products)]
|
||||
)
|
||||
self._demand_history.append(demand_arr)
|
||||
self._price_history.append(self._prices.copy())
|
||||
self._revenue_history.append(np.sum(self._prices * demand_arr))
|
||||
|
||||
def reset(self, seed=None, options=None):
|
||||
super().reset(seed=seed)
|
||||
self._set_market_mix(self.nominal_alpha)
|
||||
self._limbo.reset()
|
||||
self._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
|
||||
self._platform_stub.set_prices(self._prices)
|
||||
self._limbo.step()
|
||||
self._demand = self._limbo.step()
|
||||
self._initial_episode_prices = self._prices.copy()
|
||||
self._step_count = 0
|
||||
self._low_margin_streak = 0
|
||||
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
||||
self._trajectories = list(getattr(self.market, "last_trajectories", []))
|
||||
self._last_agent_prob = float(self.nominal_alpha)
|
||||
self._last_alpha_adv = float(self.nominal_alpha)
|
||||
self._record_history()
|
||||
return self._get_obs(), {}
|
||||
|
||||
def step(self, action):
|
||||
self._prices = self._decode_action(action)
|
||||
alpha_adv = self._select_adversarial_alpha(self._prices)
|
||||
self._global_step += 1 # always increment; JAX path may have already done so
|
||||
self._set_market_mix(alpha_adv)
|
||||
self._platform_stub.set_prices(self._prices)
|
||||
self._step_count += 1
|
||||
|
||||
self._demand = self.market.act(self._prices)
|
||||
trajectories = list(self.market.last_trajectories)
|
||||
agent_prob = self._compute_agent_prob(trajectories)
|
||||
self._trajectories.extend(trajectories)
|
||||
self._last_agent_prob = float(agent_prob)
|
||||
self._last_alpha_adv = float(alpha_adv)
|
||||
|
||||
reward, metrics = self._compute_reward(
|
||||
self._prices, self._demand, agent_prob, trajectories
|
||||
)
|
||||
self._record_history()
|
||||
|
||||
# soft early termination when margin collapses for too long
|
||||
avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max(
|
||||
float(np.mean(self._prices)), 1e-6
|
||||
)
|
||||
if avg_margin < self.margin_floor:
|
||||
self._low_margin_streak += 1
|
||||
else:
|
||||
self._low_margin_streak = 0
|
||||
margin_collapsed = self._low_margin_streak >= self.margin_floor_patience
|
||||
terminated = self._step_count >= self.max_steps or margin_collapsed
|
||||
|
||||
info = {
|
||||
"step": self._step_count,
|
||||
"agent_prob": agent_prob,
|
||||
"alpha_adv": float(alpha_adv),
|
||||
"alpha_nominal": float(self.nominal_alpha),
|
||||
"wasserstein_radius": float(self.robust_radius),
|
||||
"robust_rollouts": int(self.robust_rollouts),
|
||||
**metrics,
|
||||
"raw_revenue": np.sum(
|
||||
self._prices
|
||||
* np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
|
||||
),
|
||||
}
|
||||
return self._get_obs(), reward, terminated, False, info
|
||||
|
||||
def _compute_elasticity(self) -> np.ndarray:
|
||||
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
|
||||
if len(self._price_history) < 2:
|
||||
return np.zeros(self.n_products)
|
||||
p, q = np.array(self._price_history), np.array(self._demand_history)
|
||||
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
|
||||
valid = np.abs(dp) > 0.5
|
||||
with np.errstate(divide="ignore", invalid="ignore"):
|
||||
elasticity = np.where(
|
||||
valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0
|
||||
)
|
||||
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
|
||||
return (
|
||||
np.mean(elasticity, axis=0)
|
||||
if len(elasticity) > 0
|
||||
else np.zeros(self.n_products)
|
||||
)
|
||||
|
||||
def render(self):
|
||||
if self.render_mode == "human":
|
||||
if self._renderer is None:
|
||||
self._renderer = DashboardRenderer()
|
||||
self._renderer.render(self)
|
||||
elif self.render_mode == "ansi":
|
||||
return (
|
||||
f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
|
||||
)
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
if self._renderer:
|
||||
self._renderer.close()
|
||||
self._renderer = None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import wandb
|
||||
from .lib import MetricsCallback
|
||||
|
||||
class RandomPolicy:
|
||||
"""Minimal SB3-compatible random policy for baseline testing."""
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
self.num_timesteps = 0
|
||||
|
||||
def learn(self, total_timesteps, callback=None):
|
||||
callback.model = self
|
||||
callback.num_timesteps = 0
|
||||
callback.locals = {}
|
||||
callback.on_training_start({}, {})
|
||||
|
||||
obs, _ = self.env.reset()
|
||||
for step in range(total_timesteps):
|
||||
action = self.env.action_space.sample()
|
||||
obs, reward, term, trunc, info = self.env.step(action)
|
||||
self.num_timesteps = step + 1
|
||||
callback.num_timesteps = self.num_timesteps
|
||||
callback.locals = {"infos": [info]}
|
||||
callback.on_step()
|
||||
if term or trunc:
|
||||
callback.on_rollout_end()
|
||||
obs, _ = self.env.reset()
|
||||
return self
|
||||
|
||||
def predict(self, obs, **kwargs):
|
||||
return self.env.action_space.sample(), None
|
||||
|
||||
wandb.init(project="capstone", config={"policy": "random", "alpha": 0.3})
|
||||
env = EconomicMetricsWrapper(PHANTOM(n_products=15, alpha=0.3, render_mode=None))
|
||||
|
||||
model = RandomPolicy(env)
|
||||
model.learn(total_timesteps=1000, callback=MetricsCallback())
|
||||
|
||||
print(f"Episode revenue: {env.episode_revenue:.1f}")
|
||||
wandb.finish()
|
||||
env.close()
|
||||
117
experiments/agents/run.py
Normal file
117
experiments/agents/run.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from supabase import create_client, Client
|
||||
import os
|
||||
import random
|
||||
import asyncio
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from experiments.agents.agent import get_agent, AgentTypes
|
||||
from lib.kafka_client import get_interactions
|
||||
|
||||
load_dotenv()
|
||||
|
||||
RESULTS="/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
|
||||
client = create_client(
|
||||
os.getenv("NEXT_PUBLIC_SUPABASE_URL"),
|
||||
os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
)
|
||||
def pick_random_task():
|
||||
mode = 'hotel'
|
||||
tasks = client.table("tasks").select("*").execute().data
|
||||
if mode == 'hotel':
|
||||
# drop all that have 'flight' in the description
|
||||
tasks = [task for task in tasks if 'flight' not in task['task_description'].lower()]
|
||||
return random.choice(tasks) if tasks else None
|
||||
|
||||
def clear_kafka_data():
|
||||
"""Delete and recreate Kafka topics to clear all data"""
|
||||
from kafka.admin import KafkaAdminClient, NewTopic
|
||||
from kafka.errors import UnknownTopicOrPartitionError
|
||||
import time
|
||||
|
||||
kafka_host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
kafka_port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{kafka_host}:{kafka_port}'
|
||||
|
||||
admin = KafkaAdminClient(bootstrap_servers=broker)
|
||||
topics = ['user-interactions', 'price-logs']
|
||||
|
||||
try:
|
||||
admin.delete_topics(topics, timeout_ms=5000)
|
||||
print(f"Deleted topics: {topics}")
|
||||
time.sleep(2)
|
||||
except UnknownTopicOrPartitionError:
|
||||
print("Topics don't exist, skipping delete")
|
||||
except Exception as e:
|
||||
print(f"Error deleting topics: {e}")
|
||||
|
||||
new_topics = [
|
||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||
]
|
||||
|
||||
try:
|
||||
admin.create_topics(new_topics=new_topics, validate_only=False)
|
||||
print(f"Recreated topics: {topics}")
|
||||
except Exception as e:
|
||||
print(f"Error creating topics: {e}")
|
||||
finally:
|
||||
admin.close()
|
||||
|
||||
def create_new_experiment(task_id):
|
||||
import uuid
|
||||
subject_name = f"agent_{str(uuid.uuid4())[:8]}"
|
||||
experiment = {
|
||||
"subject_name": subject_name,
|
||||
"xp_human_only": False,
|
||||
"xp_market_mode": "hotel",
|
||||
"xp_task_id": task_id,
|
||||
}
|
||||
response = client.table("experiments").insert(experiment).execute()
|
||||
return response.data[0] if response.data else None
|
||||
|
||||
if __name__ == "__main__":
|
||||
clear_kafka_data()
|
||||
|
||||
task = pick_random_task()
|
||||
if not task:
|
||||
print("No tasks available")
|
||||
exit(1)
|
||||
|
||||
experiment = create_new_experiment(task['id'])
|
||||
exp_id = experiment['id']
|
||||
exp_dir = f"{RESULTS}{exp_id}"
|
||||
os.makedirs(exp_dir, exist_ok=True)
|
||||
|
||||
# construct experiment URL with uuid param
|
||||
base_url = os.getenv('NEXT_PUBLIC_API_BASE', 'http://localhost:3000')
|
||||
agent_url = f"{base_url}/start-task?uuid={exp_id}"
|
||||
|
||||
print(f"Created experiment {exp_id} for task {task['id']}")
|
||||
print(f"Agent will interact with: {agent_url}")
|
||||
|
||||
# instantiate and run agent
|
||||
agent = get_agent(
|
||||
AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||
goal=task['task_description'],
|
||||
url=agent_url,
|
||||
timeout=300,
|
||||
headless=True
|
||||
)
|
||||
|
||||
result = asyncio.run(agent.act())
|
||||
print(f"Agent result: {result}")
|
||||
|
||||
# export interaction and price data from kafka
|
||||
interactions = get_interactions(topic='user-interactions', timeout_ms=3000)
|
||||
prices = get_interactions(topic='price-logs', timeout_ms=3000)
|
||||
|
||||
with open(f"{exp_dir}/int.json", 'w') as f:
|
||||
json.dump(interactions, f, indent=2)
|
||||
|
||||
with open(f"{exp_dir}/price.json", 'w') as f:
|
||||
json.dump(prices, f, indent=2)
|
||||
|
||||
print(f"Experiment {exp_id} completed.")
|
||||
print(f"Exported {len(interactions)} interactions and {len(prices)} price logs to {exp_dir}")
|
||||
269
experiments/airflow/dags/session_pricing_pipeline.py
Normal file
269
experiments/airflow/dags/session_pricing_pipeline.py
Normal file
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Session-Aware Pricing DAG
|
||||
THIS implements the core pricing computation (policy layer).
|
||||
|
||||
Flow: τ → θ̂ → D → p*
|
||||
1. Fetch recent sessions from Kafka (last 10 active)
|
||||
2. Extract features per session (τ → θ̂)
|
||||
3. Map features to demand proxy (θ̂ → D)
|
||||
4. Compute optimal prices (D → p*)
|
||||
5. Write to Redis session:{sessionId}:prices
|
||||
|
||||
Scheduled: every 1 minute when enabled
|
||||
"""
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps.session import ExtractSessionFeaturesStep
|
||||
from procesing.pricers.simple import SimpleSurgePricer, session_features_to_demand
|
||||
from procesing.pricing import StateSpace
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 1,
|
||||
'retry_delay': timedelta(seconds=30),
|
||||
}
|
||||
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
|
||||
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
|
||||
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
|
||||
|
||||
|
||||
def fetch_recent_sessions(**kwargs):
|
||||
"""
|
||||
Task: Fetch last N active sessions from Kafka.
|
||||
Returns: DataFrame of interaction events for recent sessions.
|
||||
"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
store_mode = dag_conf.get('store_mode', 'hotel')
|
||||
session_limit = dag_conf.get('session_limit', 10)
|
||||
|
||||
ctx = _get_context(store_mode)
|
||||
provider = ctx.provider
|
||||
|
||||
# fetch all recent interactions from Kafka
|
||||
try:
|
||||
interactions_df = provider.fetch_kafka_topic("user-interactions")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch interactions: {e}")
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
if interactions_df.empty or 'sessionId' not in interactions_df.columns:
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
# identify last N active sessions (most recent by event count)
|
||||
recent_sessions = interactions_df['sessionId'].value_counts().head(session_limit).index.tolist()
|
||||
|
||||
# filter to only those sessions
|
||||
filtered_df = interactions_df[interactions_df['sessionId'].isin(recent_sessions)].copy()
|
||||
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(filtered_df))
|
||||
kwargs['ti'].xcom_push(key='session_ids', value=recent_sessions)
|
||||
|
||||
logging.info(f"Fetched {len(filtered_df)} events for {len(recent_sessions)} sessions")
|
||||
return len(recent_sessions)
|
||||
|
||||
|
||||
def extract_session_features(**kwargs):
|
||||
"""
|
||||
Task: Extract behavioral features from session trajectories.
|
||||
THIS implements τ → θ̂ transformation.
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
sessions_df = pickle.loads(ti.xcom_pull(key='sessions_data'))
|
||||
|
||||
if sessions_df.empty:
|
||||
ti.xcom_push(key='session_features', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
|
||||
# extract features using vectorized pipeline
|
||||
feature_extractor = ExtractSessionFeaturesStep(ctx)
|
||||
features_df = feature_extractor.transform(sessions_df)
|
||||
|
||||
ti.xcom_push(key='session_features', value=pickle.dumps(features_df))
|
||||
|
||||
logging.info(f"Extracted {len(features_df.columns)} features for {len(features_df)} sessions")
|
||||
logging.info(f"Feature columns: {list(features_df.columns)}")
|
||||
logging.info(f"Sample features (first session):\n{features_df.iloc[0].to_dict()}")
|
||||
|
||||
return len(features_df)
|
||||
|
||||
|
||||
def compute_session_prices(**kwargs):
|
||||
"""
|
||||
Task: Compute optimal prices for each session.
|
||||
THIS implements θ̂ → D → p* transformation.
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
features_df = pickle.loads(ti.xcom_pull(key='session_features'))
|
||||
|
||||
if features_df.empty:
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps({}))
|
||||
return 0
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
store_mode = dag_conf.get('store_mode', 'hotel')
|
||||
ctx = _get_context(store_mode)
|
||||
|
||||
# fetch product catalog for base prices
|
||||
products_df = ctx.provider.fetch_products(store_mode)
|
||||
if products_df.empty:
|
||||
logging.error("No products found in catalog")
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps({}))
|
||||
return 0
|
||||
|
||||
products_df['base_price'] = products_df['metadata'].apply(
|
||||
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
|
||||
)
|
||||
|
||||
# initialize pricing model
|
||||
pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.15),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.95)
|
||||
)
|
||||
pricer.fit(products_df)
|
||||
|
||||
# compute prices per session
|
||||
price_results = {}
|
||||
n_products = len(products_df)
|
||||
|
||||
logging.info(f"Starting price computation for {len(features_df)} sessions, {n_products} products")
|
||||
logging.info(f"Pricer config: high_thresh={pricer.high_threshold}, low_thresh={pricer.low_threshold}, surge_mult={pricer.surge_multiplier}")
|
||||
|
||||
for idx, session_row in features_df.iterrows():
|
||||
session_id = session_row.get('sessionId')
|
||||
if not session_id:
|
||||
continue
|
||||
|
||||
# map features to demand proxy (θ̂ → D)
|
||||
session_features_single = pd.DataFrame([session_row])
|
||||
demand_proxy = session_features_to_demand(session_features_single)
|
||||
|
||||
logging.info(f"[Session {session_id}] Features → Demand: {demand_proxy:.2f}")
|
||||
logging.info(f"[Session {session_id}] Key features: velocity={session_row.get('interaction_velocity', 0):.2f}, cart_ratio={session_row.get('cart_to_view_ratio', 0):.2f}, item_views={session_row.get('item_views', 0)}")
|
||||
|
||||
# build state space
|
||||
state_space = StateSpace(
|
||||
demand=np.full(n_products, demand_proxy), # broadcast session demand to all products
|
||||
prices=products_df['base_price'].values,
|
||||
session_features=session_features_single
|
||||
)
|
||||
|
||||
# compute optimal prices (D → p*)
|
||||
optimal_prices = pricer.predict(state_space)
|
||||
|
||||
base_avg = products_df['base_price'].mean()
|
||||
optimal_avg = optimal_prices.mean()
|
||||
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
|
||||
|
||||
logging.info(f"[Session {session_id}] Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
|
||||
|
||||
# store as dict {productId: price}
|
||||
price_map = {
|
||||
str(products_df.iloc[i]['id']): float(optimal_prices[i])
|
||||
for i in range(n_products)
|
||||
}
|
||||
|
||||
price_results[session_id] = price_map
|
||||
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps(price_results))
|
||||
|
||||
logging.info(f"Computed prices for {len(price_results)} sessions, {n_products} products each")
|
||||
return len(price_results)
|
||||
|
||||
|
||||
def publish_to_registry(**kwargs):
|
||||
"""
|
||||
Task: Write session prices to Redis registry.
|
||||
THIS is the write path: prices → session:{sessionId}:prices
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
price_results = pickle.loads(ti.xcom_pull(key='price_results'))
|
||||
|
||||
if not price_results:
|
||||
logging.warning("No prices to publish")
|
||||
return 0
|
||||
|
||||
registry = ModelRegistry()
|
||||
ttl = kwargs.get('dag_run').conf.get('ttl', 1800) if kwargs.get('dag_run') and kwargs.get('dag_run').conf else 1800
|
||||
|
||||
published_count = 0
|
||||
for session_id, price_map in price_results.items():
|
||||
registry.set_session_prices(session_id, price_map, ttl=ttl)
|
||||
published_count += 1
|
||||
|
||||
logging.info(f"Published prices for {published_count} sessions to registry (TTL={ttl}s)")
|
||||
|
||||
return {
|
||||
'sessions_published': published_count,
|
||||
'products_per_session': len(next(iter(price_results.values()))) if price_results else 0,
|
||||
'status': 'success'
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'session_pricing_pipeline',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description='Session-aware pricing: extract features → compute prices → publish to registry',
|
||||
schedule_interval='*/1 * * * *', # every 1 minute
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'session-aware', 'research', 'real-time'],
|
||||
) as dag:
|
||||
|
||||
t_fetch_sessions = PythonOperator(
|
||||
task_id='fetch_recent_sessions',
|
||||
python_callable=fetch_recent_sessions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_extract_features = PythonOperator(
|
||||
task_id='extract_session_features',
|
||||
python_callable=extract_session_features,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_compute_prices = PythonOperator(
|
||||
task_id='compute_session_prices',
|
||||
python_callable=compute_session_prices,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_to_registry',
|
||||
python_callable=publish_to_registry,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# linear dependency: fetch → extract → compute → publish
|
||||
t_fetch_sessions >> t_extract_features >> t_compute_prices >> t_publish
|
||||
@@ -1,3 +1,4 @@
|
||||
from pandas.core.algorithms import factorize_array
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
@@ -208,3 +209,12 @@ def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||
# instantiate DAGs for Airflow to discover
|
||||
dag_airline = create_surge_pricing_dag('airline')
|
||||
dag_hotel = create_surge_pricing_dag('hotel')
|
||||
|
||||
# TODO: Refactor this factory from a surge pricing factory to a general pricing factory
|
||||
# We will do this by passing a pricing strategy class to the factory, since the generic pipeline is:
|
||||
# take all interaction data, group by sessionId and assign a new price vector to each session
|
||||
# in the grouping we get a subset of the interactions per sessionId and we can map that to some Features
|
||||
# we define a custom _get_features(interactions .) methodin the strategy class
|
||||
# we then run only the inference which is the .predict(trajectory) per-session which will give us a new price vector
|
||||
# this we then publish for each sessionId group
|
||||
# this might include no deleting most of the pricers we have defined and starting with a super simple surge-pricing algorithm that is no-fit only predict. This we can then test end-to-end and observe changes to prices according to a desired strategy - we have to define this one as a very short term strategy because we run sessions that take only a few minutes.
|
||||
|
||||
@@ -120,15 +120,31 @@ def apply_surge_pricing(**kwargs):
|
||||
# rename demand_score to demand for pricer compatibility
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
|
||||
high_thresh = dag_conf.get('high_threshold', 10)
|
||||
low_thresh = dag_conf.get('low_threshold', 2)
|
||||
surge_mult = dag_conf.get('surge_multiplier', 1.2)
|
||||
discount_mult = dag_conf.get('discount_multiplier', 0.9)
|
||||
|
||||
logging.info(f"Surge pricing config: high_thresh={high_thresh}, low_thresh={low_thresh}, surge_mult={surge_mult}, discount_mult={discount_mult}")
|
||||
logging.info(f"Demand stats: min={data['demand'].min():.2f}, max={data['demand'].max():.2f}, mean={data['demand'].mean():.2f}")
|
||||
logging.info(f"Products with high demand (>={high_thresh}): {(data['demand'] >= high_thresh).sum()}")
|
||||
logging.info(f"Products with low demand (<={low_thresh}): {(data['demand'] <= low_thresh).sum()}")
|
||||
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
high_threshold=high_thresh,
|
||||
low_threshold=low_thresh,
|
||||
surge_multiplier=surge_mult,
|
||||
discount_multiplier=discount_mult
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
base_avg = data['base_price'].mean()
|
||||
optimal_avg = data['optimal_price'].mean()
|
||||
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
|
||||
|
||||
logging.info(f"Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price',
|
||||
'demand': 'demand_score'
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
from .evals import evaluate
|
||||
from .arch import (
|
||||
XGBoostAgentClassifier,
|
||||
LightGBMAgentClassifier
|
||||
LightGBMAgentClassifier,
|
||||
ContrastiveWeakClassifier,
|
||||
TrajectoryEncoder,
|
||||
WeakClassifier,
|
||||
contrastive_loss,
|
||||
featurize_trajectory,
|
||||
)
|
||||
|
||||
__all__ =[
|
||||
__all__ = [
|
||||
'evaluate',
|
||||
'XGBoostAgentClassifier',
|
||||
'LightGBMAgentClassifier'
|
||||
'LightGBMAgentClassifier',
|
||||
'ContrastiveWeakClassifier',
|
||||
'TrajectoryEncoder',
|
||||
'WeakClassifier',
|
||||
'contrastive_loss',
|
||||
'featurize_trajectory',
|
||||
]
|
||||
|
||||
@@ -1,122 +1,212 @@
|
||||
# sklearn compatible models for agent detection
|
||||
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||
from procesing.context import PipelineContext
|
||||
from typing import Any, Optional, Tuple
|
||||
from typing import Any, Optional, Tuple, Dict, List
|
||||
from abc import ABC, abstractmethod
|
||||
import xgboost as xgb
|
||||
import lightgbm as lgb
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# add lib to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'lib'))
|
||||
from lib.features import (
|
||||
transition_histogram as _lib_transition_histogram,
|
||||
temporal_signature as _lib_temporal_signature,
|
||||
state_coverage as _lib_state_coverage,
|
||||
transition_entropy as _lib_transition_entropy,
|
||||
featurize_trajectory as _lib_featurize_trajectory,
|
||||
parse_timestamp
|
||||
)
|
||||
from lib.state import event_to_state, get_event_name, get_timestamp
|
||||
|
||||
TASK = 'classification'
|
||||
LABELS = ['human', 'agent']
|
||||
|
||||
|
||||
class BaseAgentClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||
"""Base class for tree-based agent detection classifiers with common logic"""
|
||||
class WeakClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||
# a simple contrastive machine learning model learns to distinguish human/agent behavior
|
||||
# using weakly supervised contrastive learning + augmentation
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __init__(self, context: Optional[PipelineContext] = None, n_estimators: int = 200,
|
||||
max_depth: int = 6, learning_rate: float = 0.05,
|
||||
early_stopping_rounds: int = 20):
|
||||
self.context = context
|
||||
|
||||
class TrajectoryEncoder(nn.Module):
|
||||
"""Encode variable-length event sequences to fixed-dim embedding via bidirectional LSTM"""
|
||||
def __init__(self, input_dim: int, embed_dim: int = 32, hidden_dim: int = 64):
|
||||
super().__init__()
|
||||
self.event_embed = nn.Linear(input_dim, hidden_dim)
|
||||
self.lstm = nn.LSTM(hidden_dim, hidden_dim, batch_first=True, bidirectional=True)
|
||||
self.proj = nn.Linear(hidden_dim * 2, embed_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, input_dim)
|
||||
h = F.relu(self.event_embed(x))
|
||||
_, (hn, _) = self.lstm(h)
|
||||
hn = torch.cat([hn[-2], hn[-1]], dim=1) # concat bidirectional hidden states
|
||||
return F.normalize(self.proj(hn), dim=1) # L2 normalized
|
||||
|
||||
|
||||
class ContrastiveWeakClassifier(WeakClassifier):
|
||||
"""Contrastive learning classifier for human/agent trajectory discrimination"""
|
||||
def __init__(self, input_dim: int = 64, embed_dim: int = 32, margin: float = 1.0, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.input_dim = input_dim
|
||||
self.embed_dim = embed_dim
|
||||
self.margin = margin
|
||||
self.encoder = TrajectoryEncoder(input_dim, embed_dim)
|
||||
self.classifier = nn.Linear(embed_dim, 2)
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
self._fitted = False
|
||||
|
||||
def to_device(self):
|
||||
self.encoder.to(self.device)
|
||||
self.classifier.to(self.device)
|
||||
return self
|
||||
|
||||
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.encoder(x.to(self.device))
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
emb = self.encode(x)
|
||||
return self.classifier(emb)
|
||||
|
||||
def fit(self, X, y=None): # sklearn interface - actual training in weak.train.py
|
||||
self._fitted = True
|
||||
return self
|
||||
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
self.encoder.eval()
|
||||
self.classifier.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||
logits = self.forward(x)
|
||||
return torch.argmax(logits, dim=1).cpu().numpy()
|
||||
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
self.encoder.eval()
|
||||
self.classifier.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||
logits = self.forward(x)
|
||||
return F.softmax(logits, dim=1).cpu().numpy()
|
||||
|
||||
|
||||
def contrastive_loss(anchor: torch.Tensor, positive: torch.Tensor, negative: torch.Tensor, margin: float = 0.3) -> torch.Tensor:
|
||||
"""Triplet loss using cosine similarity (for L2-normalized embeddings). margin in [0,1] range."""
|
||||
pos_sim = F.cosine_similarity(anchor, positive) # higher = more similar
|
||||
neg_sim = F.cosine_similarity(anchor, negative)
|
||||
return F.relu(neg_sim - pos_sim + margin).mean() # want pos_sim > neg_sim + margin
|
||||
|
||||
|
||||
def nt_xent_loss(z_i: torch.Tensor, z_j: torch.Tensor, temperature: float = 0.5) -> torch.Tensor:
|
||||
"""Normalized temperature-scaled cross entropy loss (SimCLR style)"""
|
||||
batch_size = z_i.size(0)
|
||||
z = torch.cat([z_i, z_j], dim=0) # (2N, embed_dim)
|
||||
sim = F.cosine_similarity(z.unsqueeze(1), z.unsqueeze(0), dim=2) / temperature
|
||||
mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device)
|
||||
sim.masked_fill_(mask, -float('inf'))
|
||||
labels = torch.arange(batch_size, device=z.device)
|
||||
labels = torch.cat([labels + batch_size, labels]) # positive pairs
|
||||
return F.cross_entropy(sim, labels)
|
||||
|
||||
|
||||
# feature extraction utilities - delegating to lib.features for unified implementation
|
||||
# these wrappers maintain backwards compatibility for existing imports
|
||||
|
||||
def transition_histogram(events: List, state_fn, max_states: int = 50) -> np.ndarray:
|
||||
"""Compute normalized histogram of state transitions in trajectory"""
|
||||
return _lib_transition_histogram(events, state_fn, max_states)
|
||||
|
||||
|
||||
def temporal_signature(events: List, ts_fn) -> np.ndarray:
|
||||
"""Extract temporal features: mean/std/skew of inter-event times"""
|
||||
return _lib_temporal_signature(events, ts_fn)
|
||||
|
||||
|
||||
def state_coverage(events: List, state_fn, mdp_states: set) -> float:
|
||||
"""Fraction of MDP states visited by trajectory"""
|
||||
return _lib_state_coverage(events, state_fn, mdp_states)
|
||||
|
||||
|
||||
def transition_entropy(events: List, state_fn) -> float:
|
||||
"""Compute entropy of transition distribution (randomness of navigation)"""
|
||||
return _lib_transition_entropy(events, state_fn)
|
||||
|
||||
|
||||
def featurize_trajectory(events: List, mdp: Optional[Dict] = None, input_dim: int = 64) -> np.ndarray:
|
||||
"""Convert trajectory to fixed-dim feature vector - uses lib.features implementation"""
|
||||
mdp_states = set(mdp.get('states', [])) if mdp else set()
|
||||
|
||||
def _ts_fn(e):
|
||||
return parse_timestamp(get_timestamp(e))
|
||||
|
||||
def _event_name_fn(e):
|
||||
return get_event_name(e)
|
||||
|
||||
return _lib_featurize_trajectory(events, event_to_state, _ts_fn, _event_name_fn, mdp_states, input_dim)
|
||||
|
||||
|
||||
# gradient boosting classifiers for comparison baselines
|
||||
class XGBoostAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||
"""XGBoost classifier for human/agent detection from session features"""
|
||||
def __init__(self, n_estimators: int = 100, max_depth: int = 6, learning_rate: float = 0.1, **kwargs):
|
||||
self.n_estimators = n_estimators
|
||||
self.max_depth = max_depth
|
||||
self.learning_rate = learning_rate
|
||||
self.early_stopping_rounds = early_stopping_rounds
|
||||
self.model_ = None
|
||||
self.feature_names_ = None
|
||||
|
||||
def _to_array(self, X):
|
||||
"""Convert pandas structures to numpy arrays"""
|
||||
return X.values if isinstance(X, (pd.DataFrame, pd.Series)) else X
|
||||
|
||||
def _compute_pos_weight(self, y_arr):
|
||||
"""Calculate scale_pos_weight for class imbalance handling"""
|
||||
n_neg, n_pos = (y_arr == 0).sum(), (y_arr == 1).sum()
|
||||
return n_neg / n_pos if n_pos > 0 else 1.0
|
||||
|
||||
def _prepare_eval_set(self, eval_set):
|
||||
"""Convert eval_set to numpy arrays if needed"""
|
||||
if not eval_set:
|
||||
return None
|
||||
X_val, y_val = eval_set[0]
|
||||
return [(self._to_array(X_val), self._to_array(y_val))]
|
||||
|
||||
@abstractmethod
|
||||
def _build_model(self, scale_pos: float):
|
||||
"""Build the underlying model instance (must be implemented by subclasses)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
"""Fit model with evaluation set (must be implemented by subclasses)"""
|
||||
pass
|
||||
|
||||
def fit(self, X, y, eval_set=None):
|
||||
X_arr, y_arr = self._to_array(X), self._to_array(y)
|
||||
|
||||
if isinstance(X, pd.DataFrame):
|
||||
self.feature_names_ = X.columns.tolist()
|
||||
|
||||
scale_pos = self._compute_pos_weight(y_arr)
|
||||
self.model_ = self._build_model(scale_pos)
|
||||
|
||||
eval_arr = self._prepare_eval_set(eval_set)
|
||||
if eval_arr:
|
||||
self._fit_with_eval(X_arr, y_arr, eval_arr)
|
||||
else:
|
||||
self.model_.fit(X_arr, y_arr)
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||
try:
|
||||
import xgboost as xgb
|
||||
self.model = xgb.XGBClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate, **self.kwargs)
|
||||
self.model.fit(X, y)
|
||||
except ImportError:
|
||||
raise ImportError("xgboost required for XGBoostAgentClassifier")
|
||||
return self
|
||||
|
||||
def predict(self, X):
|
||||
return self.model_.predict(self._to_array(X))
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict(X)
|
||||
|
||||
def predict_proba(self, X):
|
||||
return self.model_.predict_proba(self._to_array(X))
|
||||
|
||||
@property
|
||||
def feature_importances_(self):
|
||||
return self.model_.feature_importances_ if self.model_ else None
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict_proba(X)
|
||||
|
||||
|
||||
class XGBoostAgentClassifier(BaseAgentClassifier):
|
||||
"""XGBoost binary classifier for agent detection with class imbalance handling"""
|
||||
class LightGBMAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||
"""LightGBM classifier for human/agent detection from session features"""
|
||||
def __init__(self, n_estimators: int = 100, max_depth: int = -1, learning_rate: float = 0.1, **kwargs):
|
||||
self.n_estimators = n_estimators
|
||||
self.max_depth = max_depth
|
||||
self.learning_rate = learning_rate
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def _build_model(self, scale_pos: float):
|
||||
return xgb.XGBClassifier(
|
||||
n_estimators=self.n_estimators,
|
||||
max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate,
|
||||
scale_pos_weight=scale_pos,
|
||||
eval_metric='auc',
|
||||
early_stopping_rounds=self.early_stopping_rounds,
|
||||
random_state=42,
|
||||
tree_method='hist',
|
||||
enable_categorical=False
|
||||
)
|
||||
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
self.model = lgb.LGBMClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate, verbose=-1, **self.kwargs)
|
||||
self.model.fit(X, y)
|
||||
except ImportError:
|
||||
raise ImportError("lightgbm required for LightGBMAgentClassifier")
|
||||
return self
|
||||
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
self.model_.fit(X_arr, y_arr, eval_set=eval_arr, verbose=False)
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict(X)
|
||||
|
||||
|
||||
class LightGBMAgentClassifier(BaseAgentClassifier):
|
||||
"""LightGBM binary classifier for agent detection with class imbalance handling"""
|
||||
|
||||
def _build_model(self, scale_pos: float):
|
||||
return lgb.LGBMClassifier(
|
||||
n_estimators=self.n_estimators,
|
||||
max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate,
|
||||
scale_pos_weight=scale_pos,
|
||||
metric='auc',
|
||||
random_state=42,
|
||||
verbosity=-1
|
||||
)
|
||||
|
||||
def _fit_with_eval(self, X_arr, y_arr, eval_arr):
|
||||
self.model_.fit(
|
||||
X_arr, y_arr,
|
||||
eval_set=eval_arr,
|
||||
callbacks=[lgb.early_stopping(self.early_stopping_rounds, verbose=False)]
|
||||
)
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict_proba(X)
|
||||
|
||||
1
experiments/ml/encoder/__init__.py
Normal file
1
experiments/ml/encoder/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .encoder import Window, extract_windows, build_windows, WindowDataset, PrototypeClassifier, train, loocv
|
||||
210
experiments/ml/encoder/encoder.py
Normal file
210
experiments/ml/encoder/encoder.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Contrastive encoder via trajectory windowing. Classification by prototype distance."""
|
||||
import sys
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
|
||||
|
||||
from sim.rl.behavior_loader.loader import JointLoader, PayloadModel
|
||||
from arch import TrajectoryEncoder, featurize_trajectory, nt_xent_loss
|
||||
from typing import List, Dict, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import numpy as np, torch, torch.nn.functional as F, random, optuna
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import Adam
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
RUNS = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
|
||||
AGENT_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
HUMAN_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Window:
|
||||
events: List[PayloadModel]
|
||||
traj_id: str
|
||||
label: int # 0=human, 1=agent
|
||||
|
||||
|
||||
def extract_windows(events: List[PayloadModel], traj_id: str, label: int,
|
||||
sizes: List[int] = [5, 10, 15], stride: int = 2) -> List[Window]:
|
||||
"""Multi-scale overlapping windows from trajectory"""
|
||||
n = len(events)
|
||||
wins = [Window(events[i:i+s], traj_id, label) for s in sizes if n >= s for i in range(0, n-s+1, stride)]
|
||||
if n >= 3: wins.append(Window(events, traj_id, label)) # full traj
|
||||
return wins
|
||||
|
||||
|
||||
def build_windows(data: Dict[str, List], sizes=[5,10,15], stride=2) -> List[Window]:
|
||||
return [w for tid, evts in data.items()
|
||||
for w in extract_windows(evts, tid, 0 if tid.startswith('human_') else 1, sizes, stride)]
|
||||
|
||||
|
||||
class WindowDataset(Dataset):
|
||||
"""Yields (anchor, positive) pairs from same class"""
|
||||
def __init__(self, windows: List[Window], dim: int = 64):
|
||||
self.wins, self.dim = windows, dim
|
||||
self.by_label = {0: [i for i,w in enumerate(windows) if w.label==0],
|
||||
1: [i for i,w in enumerate(windows) if w.label==1]}
|
||||
self.by_traj = {}
|
||||
for i, w in enumerate(windows): self.by_traj.setdefault(w.traj_id, []).append(i)
|
||||
|
||||
def __len__(self): return len(self.wins)
|
||||
|
||||
def _feat(self, evts): return featurize_trajectory(evts, None, self.dim)
|
||||
|
||||
def _aug(self, evts): # subsample 70-100%
|
||||
if len(evts) < 4: return evts
|
||||
k = max(3, int(len(evts) * random.uniform(0.7, 1.0)))
|
||||
start = random.randint(0, len(evts) - k)
|
||||
return evts[start:start+k]
|
||||
|
||||
def __getitem__(self, idx):
|
||||
w = self.wins[idx]
|
||||
pool = [i for i in self.by_label[w.label] if self.wins[i].traj_id != w.traj_id]
|
||||
pos_idx = random.choice(pool) if pool else idx
|
||||
a = torch.tensor(self._feat(self._aug(w.events)), dtype=torch.float32)
|
||||
p = torch.tensor(self._feat(self._aug(self.wins[pos_idx].events)), dtype=torch.float32)
|
||||
return a, p, w.label
|
||||
|
||||
|
||||
class PrototypeClassifier:
|
||||
"""Classify by distance to class centroids"""
|
||||
def __init__(self, encoder: TrajectoryEncoder, device = 'cuda', dim=64):
|
||||
self.enc, self.dev, self.dim = encoder, device, dim
|
||||
self.centroids = {0: None, 1: None}
|
||||
|
||||
def fit(self, windows: List[Window]):
|
||||
self.enc.eval()
|
||||
embs = {0: [], 1: []}
|
||||
with torch.no_grad():
|
||||
for w in windows:
|
||||
x = torch.tensor(featurize_trajectory(w.events, None, self.dim), dtype=torch.float32)
|
||||
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
|
||||
embs[w.label].append(z)
|
||||
self.centroids = {k: torch.cat(v).mean(0, keepdim=True) if v else None for k, v in embs.items()}
|
||||
return self
|
||||
|
||||
def predict(self, events: List[PayloadModel]) -> Tuple[int, float, Dict]:
|
||||
"""Returns (pred, confidence, debug). Confidence via softmax over -distances."""
|
||||
self.enc.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(featurize_trajectory(events, None, self.dim), dtype=torch.float32)
|
||||
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
|
||||
dists = {k: torch.norm(z - c, dim=1).item() for k, c in self.centroids.items() if c is not None}
|
||||
if not dists: return 0, 0.0, {'d': {}, 'p': [0.5, 0.5]}
|
||||
pred = min(dists, key=dists.get)
|
||||
d0, d1 = dists.get(0, 1e6), dists.get(1, 1e6) # softmax(-d) gives higher prob to closer centroid
|
||||
probs = F.softmax(torch.tensor([[-d0, -d1]]), dim=1).squeeze()
|
||||
return pred, probs[pred].item(), {'d': dists, 'p': probs.tolist()}
|
||||
|
||||
|
||||
def train(epochs=200, lr=5e-4, batch=16, dim=64, emb=32, temp=0.5,
|
||||
sizes=[5,10,15], stride=2, name=None, verbose=True):
|
||||
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
|
||||
wins = build_windows(data, sizes, stride)
|
||||
if verbose: print(f"Windows: {len(wins)} ({sum(w.label==0 for w in wins)}h/{sum(w.label==1 for w in wins)}a)")
|
||||
|
||||
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
enc = TrajectoryEncoder(dim, emb).to(dev)
|
||||
opt = Adam(enc.parameters(), lr=lr)
|
||||
loader = DataLoader(WindowDataset(wins, dim), batch_size=batch, shuffle=True, drop_last=True)
|
||||
|
||||
name = name or f"enc_{dim}_{emb}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS}/encoder/{name}")
|
||||
|
||||
for ep in range(epochs):
|
||||
enc.train()
|
||||
total, n = 0.0, 0
|
||||
for a, p, _ in loader:
|
||||
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
total += loss.item(); n += 1
|
||||
avg = total / max(n, 1)
|
||||
writer.add_scalar('loss-ntxent', avg, ep)
|
||||
if verbose and (ep+1) % 20 == 0: print(f"Epoch {ep+1}: {avg:.4f}")
|
||||
|
||||
writer.close()
|
||||
return enc, wins, dev
|
||||
|
||||
|
||||
def loocv(epochs=100, lr=5e-4, dim=64, emb=32, temp=0.5, sizes=[5,10,15], stride=2, verbose=True):
|
||||
"""Leave-one-trajectory-out CV"""
|
||||
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
|
||||
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
results = []
|
||||
|
||||
for test_id in data:
|
||||
train_data = {k: v for k, v in data.items() if k != test_id}
|
||||
if not any(k.startswith('human_') for k in train_data) or not any(k.startswith('agent_') for k in train_data):
|
||||
continue
|
||||
|
||||
wins = build_windows(train_data, sizes, stride)
|
||||
enc = TrajectoryEncoder(dim, emb).to(dev)
|
||||
opt = Adam(enc.parameters(), lr=lr)
|
||||
loader = DataLoader(WindowDataset(wins, dim), batch_size=min(16, len(wins)//2 or 1),
|
||||
shuffle=True, drop_last=len(wins)>2)
|
||||
|
||||
for _ in range(epochs):
|
||||
enc.train()
|
||||
for a, p, _ in loader:
|
||||
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
|
||||
clf = PrototypeClassifier(enc, dev, dim).fit(wins)
|
||||
pred, conf, dbg = clf.predict(data[test_id])
|
||||
actual = 0 if test_id.startswith('human_') else 1
|
||||
results.append((pred, actual, conf))
|
||||
if verbose: print(f"{test_id[:18]}: pred={pred} conf={conf:.2f} actual={actual} {'OK' if pred==actual else 'MISS'}")
|
||||
|
||||
if results:
|
||||
acc = sum(p==a for p,a,_ in results) / len(results)
|
||||
if verbose: print(f"\nAccuracy: {acc:.1%} ({sum(p==a for p,a,_ in results)}/{len(results)})")
|
||||
return acc, results
|
||||
return 0.0, []
|
||||
|
||||
|
||||
def hparam_tune(n_trials=50, epochs=60, n_jobs=2, verbose=True):
|
||||
"""Optuna hyperparameter search maximizing LOOCV accuracy"""
|
||||
def objective(trial):
|
||||
lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True)
|
||||
dim = trial.suggest_categorical('dim', [32, 64, 128, 256])
|
||||
emb = trial.suggest_categorical('emb', [16, 32, 64, 128])
|
||||
temp = trial.suggest_float('temp', 0.05, 1.0)
|
||||
stride = trial.suggest_int('stride', 1, 4)
|
||||
sizes = [trial.suggest_int(f's{i}', 3, 20) for i in range(3)]
|
||||
sizes = sorted(set(sizes)) # unique sorted
|
||||
acc, _ = loocv(epochs, lr, dim, emb, temp, sizes, stride, verbose=False)
|
||||
return acc
|
||||
|
||||
study = optuna.create_study(direction='maximize', study_name='encoder_hparam',
|
||||
sampler=optuna.samplers.TPESampler(seed=42))
|
||||
study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=verbose)
|
||||
|
||||
best = study.best_params
|
||||
if verbose:
|
||||
print(f"\nBest accuracy: {study.best_value:.1%}")
|
||||
print(f"Best params: {best}")
|
||||
return best, study
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--mode', choices=['train', 'eval', 'hparam'], default='train')
|
||||
p.add_argument('--epochs', type=int, default=200)
|
||||
p.add_argument('--lr', type=float, default=5e-4)
|
||||
p.add_argument('--dim', type=int, default=128)
|
||||
p.add_argument('--emb', type=int, default=64)
|
||||
p.add_argument('--temp', type=float, default=0.1)
|
||||
p.add_argument('--sizes', type=str, default='5,10,15')
|
||||
p.add_argument('--stride', type=int, default=2)
|
||||
p.add_argument('--n_trials', type=int, default=50)
|
||||
args = p.parse_args()
|
||||
sizes = [int(x) for x in args.sizes.split(',')]
|
||||
|
||||
if args.mode == 'train':
|
||||
enc, wins, dev = train(args.epochs, args.lr, 16, args.dim, args.emb, args.temp, sizes, args.stride)
|
||||
elif args.mode == 'hparam':
|
||||
best, study = hparam_tune(args.n_trials, min(args.epochs, 60))
|
||||
else:
|
||||
loocv(args.epochs, args.lr, args.dim, args.emb, args.temp, sizes, args.stride)
|
||||
246
experiments/ml/weak_train.py
Normal file
246
experiments/ml/weak_train.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
|
||||
|
||||
from sim.rl.behavior_loader.loader import AgentLoader, Loader, JointLoader, PayloadModel
|
||||
from sim.rl.behavior_loader.models import JointBehaviorModel
|
||||
from arch import ContrastiveWeakClassifier, contrastive_loss, featurize_trajectory
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime, timedelta
|
||||
from copy import deepcopy
|
||||
import numpy as np
|
||||
import random
|
||||
import torch
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import Adam
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
RUNS_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
|
||||
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
|
||||
def _perturb_ts(evt: PayloadModel, jitter_ms: int = 500) -> PayloadModel:
|
||||
"""Add random jitter to event timestamp"""
|
||||
new_evt = deepcopy(evt)
|
||||
try:
|
||||
ts = datetime.fromisoformat(evt.ts.replace('Z', '+00:00'))
|
||||
delta = timedelta(milliseconds=random.randint(-jitter_ms, jitter_ms))
|
||||
new_evt.ts = (ts + delta).isoformat()
|
||||
except:
|
||||
pass
|
||||
return new_evt
|
||||
|
||||
|
||||
def augment_trajectory(trajectory: List[PayloadModel], rate: float = 0.1) -> List[PayloadModel]:
|
||||
"""Apply random augmentation to trajectory for contrastive learning"""
|
||||
if len(trajectory) < 2:
|
||||
return trajectory
|
||||
|
||||
aug_type = random.choice(['window', 'shuffle', 'noise', 'drop'])
|
||||
|
||||
if aug_type == 'window': # random contiguous sub-sequence (70-100% length)
|
||||
min_len = max(2, int(len(trajectory) * 0.7))
|
||||
sub_len = random.randint(min_len, len(trajectory))
|
||||
start = random.randint(0, len(trajectory) - sub_len)
|
||||
return trajectory[start:start + sub_len]
|
||||
|
||||
elif aug_type == 'shuffle': # swap adjacent pairs with probability rate
|
||||
result = list(trajectory)
|
||||
for i in range(len(result) - 1):
|
||||
if random.random() < rate:
|
||||
result[i], result[i + 1] = result[i + 1], result[i]
|
||||
return result
|
||||
|
||||
elif aug_type == 'drop': # drop events with probability rate
|
||||
result = [e for e in trajectory if random.random() > rate]
|
||||
return result if len(result) >= 2 else trajectory[:2]
|
||||
|
||||
elif aug_type == 'noise': # perturb timestamps
|
||||
return [_perturb_ts(e, jitter_ms=500) for e in trajectory]
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
class TripletDataset(Dataset):
|
||||
"""Generate (anchor, positive, negative) triplets on-the-fly with augmentation"""
|
||||
def __init__(self, data: Dict[str, List[PayloadModel]], mdp: Optional[Dict], augment_fn, input_dim: int = 64, multiplier: int = 10):
|
||||
self.sessions = list(data.items())
|
||||
self.human_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('human_')]
|
||||
self.agent_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('agent_')]
|
||||
self.mdp = mdp
|
||||
self.augment = augment_fn
|
||||
self.input_dim = input_dim
|
||||
self.multiplier = multiplier
|
||||
|
||||
if not self.human_ids or not self.agent_ids:
|
||||
raise ValueError(f"Need both human ({len(self.human_ids)}) and agent ({len(self.agent_ids)}) sessions")
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.sessions) * self.multiplier
|
||||
|
||||
def __getitem__(self, idx: int):
|
||||
anchor_idx = idx % len(self.sessions)
|
||||
sid, events = self.sessions[anchor_idx]
|
||||
is_human = sid.startswith('human_')
|
||||
|
||||
anchor = featurize_trajectory(events, self.mdp, self.input_dim)
|
||||
positive = featurize_trajectory(self.augment(events), self.mdp, self.input_dim)
|
||||
|
||||
neg_pool = self.agent_ids if is_human else self.human_ids
|
||||
neg_idx = random.choice(neg_pool)
|
||||
negative = featurize_trajectory(self.sessions[neg_idx][1], self.mdp, self.input_dim)
|
||||
|
||||
label = 0 if is_human else 1 # 0=human, 1=agent
|
||||
return (torch.tensor(anchor, dtype=torch.float32),
|
||||
torch.tensor(positive, dtype=torch.float32),
|
||||
torch.tensor(negative, dtype=torch.float32),
|
||||
torch.tensor(label, dtype=torch.long))
|
||||
|
||||
|
||||
def train(epochs: int = 100, lr: float = 1e-3, batch_size: int = 4, input_dim: int = 64,
|
||||
embed_dim: int = 32, margin: float = 0.3, verbose: bool = True, run_name: str = None):
|
||||
"""Train contrastive weak classifier on human/agent trajectories"""
|
||||
joint = JointLoader(human_dir, agent_dir)
|
||||
data = joint.get_data()
|
||||
if verbose:
|
||||
print(f"Loaded {len(data)} sessions")
|
||||
|
||||
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||
ref_mdp = joint_model.build_MDP()
|
||||
|
||||
dataset = TripletDataset(data, ref_mdp, augment_trajectory, input_dim=input_dim)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
|
||||
|
||||
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||
model.to_device()
|
||||
|
||||
run_name = run_name or f"d{input_dim}_e{embed_dim}_lr{lr}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS_DIR}/train/{run_name}")
|
||||
|
||||
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||
ce_loss_fn = torch.nn.CrossEntropyLoss()
|
||||
|
||||
best_loss = float('inf')
|
||||
for epoch in range(epochs):
|
||||
model.encoder.train()
|
||||
model.classifier.train()
|
||||
total_loss, n_batches = 0.0, 0
|
||||
|
||||
for anchor, positive, negative, labels in loader:
|
||||
anchor, positive, negative, labels = [t.to(model.device) for t in [anchor, positive, negative, labels]]
|
||||
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1)) for t in [anchor, positive, negative]]
|
||||
|
||||
trip_loss = contrastive_loss(z_a, z_p, z_n, margin=model.margin)
|
||||
ce = ce_loss_fn(model.classifier(z_a), labels)
|
||||
loss = trip_loss + 0.5 * ce
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
avg_loss = total_loss / max(n_batches, 1)
|
||||
writer.add_scalar('loss', avg_loss, epoch)
|
||||
|
||||
if verbose and (epoch + 1) % 10 == 0:
|
||||
print(f"Epoch {epoch+1}/{epochs}: loss={avg_loss:.4f}")
|
||||
if avg_loss < best_loss:
|
||||
best_loss = avg_loss
|
||||
|
||||
writer.close()
|
||||
if verbose:
|
||||
print(f"Done. Best={best_loss:.4f} TB:{RUNS_DIR}/train/{run_name}")
|
||||
|
||||
return model, ref_mdp
|
||||
|
||||
|
||||
def evaluate_loocv(input_dim: int = 64, embed_dim: int = 32, epochs_per_fold: int = 50,
|
||||
lr: float = 1e-3, margin: float = 0.3, run_name: str = None):
|
||||
"""Leave-one-out cross-validation given limited samples"""
|
||||
joint = JointLoader(human_dir, agent_dir)
|
||||
data = joint.get_data()
|
||||
session_ids = list(data.keys())
|
||||
|
||||
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||
ref_mdp = joint_model.build_MDP()
|
||||
|
||||
run_name = run_name or f"loocv_d{input_dim}_e{embed_dim}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS_DIR}/eval/{run_name}")
|
||||
|
||||
predictions, actuals = [], []
|
||||
|
||||
for fold_idx, test_sid in enumerate(session_ids):
|
||||
train_data = {k: v for k, v in data.items() if k != test_sid}
|
||||
test_events = data[test_sid]
|
||||
test_label = 0 if test_sid.startswith('human_') else 1
|
||||
|
||||
n_human = sum(1 for k in train_data if k.startswith('human_'))
|
||||
n_agent = sum(1 for k in train_data if k.startswith('agent_'))
|
||||
if n_human == 0 or n_agent == 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dataset = TripletDataset(train_data, ref_mdp, augment_trajectory, input_dim=input_dim, multiplier=5)
|
||||
loader = DataLoader(dataset, batch_size=2, shuffle=True, drop_last=True)
|
||||
|
||||
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||
model.to_device()
|
||||
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||
|
||||
model.encoder.train()
|
||||
model.classifier.train()
|
||||
for _ in range(epochs_per_fold):
|
||||
for anchor, positive, negative, labels in loader:
|
||||
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1).to(model.device)) for t in [anchor, positive, negative]]
|
||||
loss = contrastive_loss(z_a, z_p, z_n, margin=margin)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
test_feat = featurize_trajectory(test_events, ref_mdp, input_dim)
|
||||
pred = model.predict(test_feat.reshape(1, -1))[0]
|
||||
predictions.append(pred)
|
||||
actuals.append(test_label)
|
||||
print(f" {test_sid[:12]}...: pred={pred}, actual={test_label}, {'OK' if pred == test_label else 'MISS'}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if predictions:
|
||||
acc = sum(p == a for p, a in zip(predictions, actuals)) / len(predictions)
|
||||
tp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 1)
|
||||
fp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 0)
|
||||
fn = sum(1 for p, a in zip(predictions, actuals) if p == 0 and a == 1)
|
||||
prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
||||
f1 = 2 * prec * rec / max(prec + rec, 1e-10)
|
||||
writer.add_scalar('accuracy', acc, 0)
|
||||
writer.add_scalar('f1', f1, 0)
|
||||
writer.add_scalar('precision', prec, 0)
|
||||
writer.add_scalar('recall', rec, 0)
|
||||
writer.close()
|
||||
print(f"\nAccuracy: {acc:.2%} F1: {f1:.3f} TB:{RUNS_DIR}/eval/{run_name}")
|
||||
return acc, predictions, actuals
|
||||
writer.close()
|
||||
return 0.0, [], []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--mode', choices=['train', 'eval'], default='train')
|
||||
parser.add_argument('--epochs', type=int, default=100)
|
||||
parser.add_argument('--lr', type=float, default=1e-3)
|
||||
parser.add_argument('--margin', type=float, default=0.3)
|
||||
parser.add_argument('--input-dim', type=int, default=64)
|
||||
parser.add_argument('--embed-dim', type=int, default=32)
|
||||
parser.add_argument('--run-name', type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == 'train':
|
||||
model, mdp = train(epochs=args.epochs, lr=args.lr, input_dim=args.input_dim,
|
||||
embed_dim=args.embed_dim, margin=args.margin, run_name=args.run_name)
|
||||
else:
|
||||
evaluate_loocv(input_dim=args.input_dim, embed_dim=args.embed_dim, epochs_per_fold=args.epochs,
|
||||
lr=args.lr, margin=args.margin, run_name=args.run_name)
|
||||
957
experiments/notebooks/data_export.ipynb
Normal file
957
experiments/notebooks/data_export.ipynb
Normal file
@@ -0,0 +1,957 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from kafka import KafkaConsumer\n",
|
||||
"import pandas as pd\n",
|
||||
"import json\n",
|
||||
"import numpy as np\n",
|
||||
"import os\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from IPython.display import display, SVG, Image\n",
|
||||
"load_dotenv()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||
"RangeIndex: 73 entries, 0 to 72\n",
|
||||
"Data columns (total 13 columns):\n",
|
||||
" # Column Non-Null Count Dtype \n",
|
||||
"--- ------ -------------- ----- \n",
|
||||
" 0 sessionId 73 non-null object \n",
|
||||
" 1 eventName 73 non-null object \n",
|
||||
" 2 page 73 non-null object \n",
|
||||
" 3 productId 67 non-null object \n",
|
||||
" 4 storeMode 73 non-null object \n",
|
||||
" 5 userAgent 73 non-null object \n",
|
||||
" 6 ts 73 non-null object \n",
|
||||
" 7 metadata_referrer 6 non-null object \n",
|
||||
" 8 metadata_roomType 45 non-null object \n",
|
||||
" 9 metadata_price 45 non-null float64\n",
|
||||
" 10 metadata_nights 45 non-null float64\n",
|
||||
" 11 metadata_elementText 22 non-null object \n",
|
||||
" 12 metadata_dwellTime 22 non-null float64\n",
|
||||
"dtypes: float64(3), object(10)\n",
|
||||
"memory usage: 7.5+ KB\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
||||
"topic = \"user-interactions\"\n",
|
||||
"consumer = KafkaConsumer(\n",
|
||||
" topic, \n",
|
||||
" enable_auto_commit=True,\n",
|
||||
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
||||
" auto_offset_reset='earliest', \n",
|
||||
" bootstrap_servers=['localhost:9092'])\n",
|
||||
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
||||
"df = []\n",
|
||||
"for m in messages.values():\n",
|
||||
" for i in m:\n",
|
||||
" df.append(i.value)\n",
|
||||
"df = pd.DataFrame(df)\n",
|
||||
"# explode metadata col json\n",
|
||||
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
||||
"df.info()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>sessionId</th>\n",
|
||||
" <th>eventName</th>\n",
|
||||
" <th>page</th>\n",
|
||||
" <th>productId</th>\n",
|
||||
" <th>storeMode</th>\n",
|
||||
" <th>userAgent</th>\n",
|
||||
" <th>ts</th>\n",
|
||||
" <th>metadata_referrer</th>\n",
|
||||
" <th>metadata_roomType</th>\n",
|
||||
" <th>metadata_price</th>\n",
|
||||
" <th>metadata_nights</th>\n",
|
||||
" <th>metadata_elementText</th>\n",
|
||||
" <th>metadata_dwellTime</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>d176d7c9-4027-4702-9e31-2a71395cdda0</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:23:46.270Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:00.291Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:07.769Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:15.010Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>269.0</td>\n",
|
||||
" <td>1.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>4</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.457Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>5</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.591Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>432</th>\n",
|
||||
" <td>214d9fad-9b00-40c3-bd0e-7739b6acd654</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1762448192425</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>1623.0</td>\n",
|
||||
" <td>493.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>6</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:21.483Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>7</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:22.646Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>8</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:25.889Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>35</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:53:59.993Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>36</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:10.705Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>223.0</td>\n",
|
||||
" <td>3.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>37</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:11.771Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>416.0</td>\n",
|
||||
" <td>397.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>38</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:29.772Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Standard Room</td>\n",
|
||||
" <td>267.0</td>\n",
|
||||
" <td>5.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>39</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:30.833Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Seaside Resort</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" sessionId eventName page \\\n",
|
||||
"0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n",
|
||||
"1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n",
|
||||
"2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n",
|
||||
"3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n",
|
||||
"4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n",
|
||||
"5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n",
|
||||
"8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n",
|
||||
"36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"\n",
|
||||
" productId storeMode userAgent \\\n",
|
||||
"0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"\n",
|
||||
" ts metadata_referrer metadata_roomType \\\n",
|
||||
"0 2025-11-14T13:23:46.270Z NaN \n",
|
||||
"1 2025-11-14T13:26:00.291Z NaN \n",
|
||||
"2 2025-11-14T13:26:07.769Z NaN \n",
|
||||
"3 2025-11-14T13:26:15.010Z NaN Premium Room \n",
|
||||
"4 2025-11-14T13:27:15.457Z NaN \n",
|
||||
"5 2025-11-14T13:27:15.591Z NaN Premium Room \n",
|
||||
"6 2025-11-14T13:27:21.483Z NaN Premium Room \n",
|
||||
"7 2025-11-14T13:27:22.646Z NaN NaN \n",
|
||||
"8 2025-11-14T13:27:25.889Z NaN Premium Room \n",
|
||||
"35 2025-11-14T13:53:59.993Z NaN \n",
|
||||
"36 2025-11-14T13:54:10.705Z NaN Premium Room \n",
|
||||
"37 2025-11-14T13:54:11.771Z NaN NaN \n",
|
||||
"38 2025-11-14T13:54:29.772Z NaN Standard Room \n",
|
||||
"39 2025-11-14T13:54:30.833Z NaN NaN \n",
|
||||
"\n",
|
||||
" metadata_price metadata_nights metadata_elementText metadata_dwellTime \n",
|
||||
"0 NaN NaN NaN NaN \n",
|
||||
"1 NaN NaN NaN NaN \n",
|
||||
"2 NaN NaN NaN NaN \n",
|
||||
"3 269.0 1.0 NaN NaN \n",
|
||||
"4 NaN NaN NaN NaN \n",
|
||||
"5 264.0 2.0 NaN NaN \n",
|
||||
"6 264.0 2.0 NaN NaN \n",
|
||||
"7 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"8 264.0 2.0 NaN NaN \n",
|
||||
"35 NaN NaN NaN NaN \n",
|
||||
"36 223.0 3.0 NaN NaN \n",
|
||||
"37 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"38 267.0 5.0 NaN NaN \n",
|
||||
"39 NaN NaN Seaside Resort 1200.0 "
|
||||
]
|
||||
},
|
||||
"execution_count": 12,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df.groupby('sessionId').head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['013fc334-4045-4d5a-8739-dd0a8766a63b',\n",
|
||||
" '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n",
|
||||
" 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n",
|
||||
" 'f0317a5d-e424-44e9-b784-c8f7291ffe31']"
|
||||
]
|
||||
},
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# map sessions to experiments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||
" df = df.dropna(subset=['eventName'])\n",
|
||||
" events = df['eventName'].tolist()\n",
|
||||
" labels = pd.Index(events).unique().tolist()\n",
|
||||
" idx = {e:i for i,e in enumerate(labels)}\n",
|
||||
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
||||
" for a, b in zip(events, events[1:]):\n",
|
||||
" M[idx[a], idx[b]] += 1\n",
|
||||
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
||||
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
||||
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
||||
" return P, labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
||||
"from graphviz import Digraph\n",
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def _as_prob_df(matrix, labels=None):\n",
|
||||
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
||||
" if isinstance(matrix, pd.DataFrame):\n",
|
||||
" # Ensure square and aligned\n",
|
||||
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
||||
" return matrix\n",
|
||||
" matrix = np.asarray(matrix, dtype=float)\n",
|
||||
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
||||
" if labels is None:\n",
|
||||
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
||||
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
||||
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
||||
"\n",
|
||||
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
||||
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
||||
" edges = []\n",
|
||||
" for src in P.index:\n",
|
||||
" for dst in P.columns:\n",
|
||||
" w = float(P.loc[src, dst])\n",
|
||||
" if w > threshold:\n",
|
||||
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
||||
" return edges\n",
|
||||
"\n",
|
||||
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
||||
" \"\"\"\n",
|
||||
" fname: output file stem (no extension)\n",
|
||||
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
||||
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
||||
" threshold: hide edges with weight <= threshold\n",
|
||||
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
||||
" view: open after rendering\n",
|
||||
" \"\"\"\n",
|
||||
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
||||
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
||||
"\n",
|
||||
" g = Digraph(format=fmt)\n",
|
||||
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
||||
" g.attr(\"node\", shape=\"circle\")\n",
|
||||
"\n",
|
||||
" # ensure isolated nodes appear\n",
|
||||
" for node in P.index:\n",
|
||||
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
||||
"\n",
|
||||
" for src, dst, label in edges:\n",
|
||||
" g.edge(src, dst, label=label)\n",
|
||||
"\n",
|
||||
" g.render(fname, view=view, cleanup=True)\n",
|
||||
" return g\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 17,
|
||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"013fc334-4045-4d5a-8739-dd0a8766a63b\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-235.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-235.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-235.83C113.69,-235.83 133.31,-235.83 152.25,-235.83\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-239.33 162.1,-235.83 152.1,-232.33 152.1,-239.33\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-239.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-302.59C217.1,-314.51 223.14,-322.84 232.88,-322.84 239.27,-322.84 244.07,-319.26 247.28,-313.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-314.62 250.52,-304.02 243.95,-312.33 250.57,-314.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-326.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.68</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.48,-250.14C307.03,-251.43 313.58,-252.69 319.89,-253.83 340.12,-257.51 362.05,-261.1 382.5,-264.27\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.77,-267.7 392.19,-265.76 382.83,-260.78 381.77,-267.7\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-263.17\" font-family=\"Times,serif\" font-size=\"14.00\">0.29</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M292.09,-199.63C316.79,-184.27 346.14,-166.02 373.44,-149.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.08,-152.15 381.72,-143.89 371.38,-146.2 375.08,-152.15\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-185.68\" font-family=\"Times,serif\" font-size=\"14.00\">0.04</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M399.53,-246.73C384.12,-240.88 367.42,-235.6 351.39,-232.58 339.13,-230.28 326.03,-229.26 313.19,-229.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"313.51,-225.54 303.51,-229.04 313.51,-232.54 313.51,-225.54\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-236.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f0779e818b0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[]\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"8pt\" height=\"8pt\"\n",
|
||||
" viewBox=\"0.00 0.00 8.00 8.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 4)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-4 4,-4 4,4 -4,4\"/>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800fac980>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n",
|
||||
" [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n",
|
||||
"238dc588-a7ab-4c0e-bccd-6abca5076c66\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-109.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-105.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-197.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-193.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M92.02,-130.47C112.32,-140.25 137.13,-152.2 160.18,-163.3\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"158.39,-166.32 168.92,-167.51 161.43,-160.02 158.39,-166.32\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-157.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-264.59C217.1,-276.51 223.14,-284.84 232.88,-284.84 239.27,-284.84 244.07,-281.26 247.28,-275.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-276.62 250.52,-266.02 243.95,-274.33 250.57,-276.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-288.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.19</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M289.6,-237.16C299.36,-242.77 309.67,-247.94 319.89,-251.83 339.45,-259.28 361.4,-264.43 382.1,-267.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.52,-271.43 391.95,-269.55 382.62,-264.52 381.52,-271.43\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-265.16\" font-family=\"Times,serif\" font-size=\"14.00\">0.38</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.22,-180.71C317.22,-175.46 335.24,-169.12 351.39,-161.83 358.97,-158.41 366.67,-154.57 374.29,-150.49\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.84,-153.63 382.92,-145.75 372.47,-147.5 375.84,-153.63\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-178.15\" font-family=\"Times,serif\" font-size=\"14.00\">0.44</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M398.52,-248.36C383.21,-242.16 366.82,-235.87 351.39,-230.58 338.42,-226.15 324.5,-221.86 310.94,-217.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"312.2,-214.65 301.62,-215.28 310.28,-221.39 312.2,-214.65\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-234.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->page_view -->\n",
|
||||
"<g id=\"edge6\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M369.13,-95.76C310.26,-97.17 232.59,-99.41 163.87,-102.58 145.72,-103.42 125.98,-104.58 108.06,-105.73\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"107.86,-102.24 98.1,-106.38 108.31,-109.22 107.86,-102.24\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-106.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.14</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->view_item_page -->\n",
|
||||
"<g id=\"edge7\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M372.68,-119.15C354.84,-125.32 336.5,-132.51 319.89,-140.58 312.9,-143.98 305.81,-147.87 298.86,-151.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"297.49,-148.71 290.78,-156.91 301.14,-154.69 297.49,-148.71\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-144.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.86</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1. 0. 0. ]\n",
|
||||
" [0. 0.1875 0.375 0.4375 ]\n",
|
||||
" [0. 1. 0. 0. ]\n",
|
||||
" [0.14285714 0.85714286 0. 0. ]]\n",
|
||||
"d176d7c9-4027-4702-9e31-2a71395cdda0\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"104pt\" height=\"104pt\"\n",
|
||||
" viewBox=\"0.00 0.00 104.00 104.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 100.37)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-100.37 100.37,-100.37 100.37,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-48.19\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-43.51\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.]]\n",
|
||||
"f0317a5d-e424-44e9-b784-c8f7291ffe31\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"310pt\" height=\"160pt\"\n",
|
||||
" viewBox=\"0.00 0.00 310.00 160.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 156.44)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-156.44 305.89,-156.44 305.89,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-69.01\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->page_view -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M33.03,-115.09C34.09,-126.6 39.14,-135.19 48.19,-135.19 53.98,-135.19 58.13,-131.66 60.65,-126.1\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"64.01,-127.11 62.98,-116.56 57.21,-125.45 64.01,-127.11\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-139.14\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-69.01\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-69.01C113.69,-69.01 133.31,-69.01 152.25,-69.01\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-72.51 162.1,-69.01 152.1,-65.51 152.1,-72.51\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-72.96\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800bf50f0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[5.0e-001 5.0e-001]\n",
|
||||
" [9.9e-324 1.5e-323]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def explore_session(session_id: str):\n",
|
||||
" subset = df[df['sessionId'] == session_id]\n",
|
||||
" print(session_id)\n",
|
||||
" P, labels = build_transition_prob_matrix(subset)\n",
|
||||
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
||||
" display(g)\n",
|
||||
" return P\n",
|
||||
"for session in sessions:\n",
|
||||
" print(explore_session(session))"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python (PHANTOM)",
|
||||
"language": "python",
|
||||
"name": "phantom"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
1740
experiments/notebooks/states.ipynb
Normal file
1740
experiments/notebooks/states.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
2320
experiments/notebooks/step_breakdown.ipynb
Normal file
2320
experiments/notebooks/step_breakdown.ipynb
Normal file
File diff suppressed because it is too large
Load Diff
114
experiments/procesing/contaminator.py
Normal file
114
experiments/procesing/contaminator.py
Normal file
@@ -0,0 +1,114 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from lib.separability import estimate_alpha, load_artifacts, score_session
|
||||
|
||||
|
||||
# use relative import when in package context, fallback for standalone
|
||||
try:
|
||||
from sim.rl.behavior_loader.models import AgentBehaviorModel
|
||||
except ImportError:
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "sim" / "rl" / "behavior_loader"))
|
||||
from models import AgentBehaviorModel
|
||||
|
||||
# paths should be configurable via environment or relative to project root
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', PROJECT_ROOT / "experiments" / "agents" / "collected_data"))
|
||||
|
||||
try:
|
||||
SEPARABILITY_ARTIFACTS = load_artifacts()
|
||||
except FileNotFoundError:
|
||||
SEPARABILITY_ARTIFACTS = None
|
||||
|
||||
|
||||
def remap_schema(df: pd.DataFrame, mapping: dict, on: str = "event_type") -> pd.DataFrame:
|
||||
"""remap column values according to mapping dict, preserving unmapped values"""
|
||||
df = df.copy()
|
||||
df[on] = df[on].map(mapping).fillna(df[on])
|
||||
return df
|
||||
|
||||
|
||||
def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
|
||||
events: list[SimpleNamespace] = []
|
||||
for idx, state in enumerate(states):
|
||||
parts = state.split("|") if isinstance(state, str) else ["page", "product", str(state)]
|
||||
page = f"/{parts[0]}" if parts else "/"
|
||||
product = parts[1] if len(parts) > 1 else "unknown"
|
||||
event_name = parts[2] if len(parts) > 2 else parts[-1]
|
||||
events.append(
|
||||
SimpleNamespace(
|
||||
eventName=event_name,
|
||||
page=page,
|
||||
productId=product,
|
||||
ts=float(idx),
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
||||
contamination_rate: float = 0.1,
|
||||
agent_data_dir: Path = None) -> pd.DataFrame:
|
||||
"""inject synthetic agent trajectories into a dataset
|
||||
contamination_rate: fraction of final dataset that should be agent data (0.1 = 10% agents)
|
||||
"""
|
||||
data_dir = agent_data_dir or AGENT_DATA_DIR
|
||||
model = AgentBehaviorModel(str(data_dir))
|
||||
model.build_MDP() # ensure MDP is built before sampling
|
||||
|
||||
# compute event distribution from original data
|
||||
event_dist = df[on].value_counts(normalize=True).to_dict()
|
||||
total = sum(event_dist.values())
|
||||
event_dist = {k: v / total for k, v in event_dist.items()}
|
||||
|
||||
# calculate how many synthetic events to add
|
||||
N = len(df)
|
||||
N_final = N / (1 - contamination_rate)
|
||||
N_contaminate = int(N_final - N)
|
||||
|
||||
# sample start states weighted by original distribution
|
||||
start_events = random.choices(list(event_dist.keys()), weights=list(event_dist.values()), k=N_contaminate)
|
||||
|
||||
# generate synthetic trajectories
|
||||
new_rows = []
|
||||
alpha_estimates = []
|
||||
|
||||
for start_event in start_events:
|
||||
# sample trajectory from agent model, using a state that contains the event type
|
||||
mdp_states = model.mdp.get('states', []) if model.mdp else []
|
||||
matching_starts = [s for s in mdp_states if start_event in s]
|
||||
if not matching_starts:
|
||||
continue # skip if no matching start state
|
||||
start_state = random.choice(matching_starts)
|
||||
trajectory = model.sample_traj(start_state, max_len=20)
|
||||
score_payload: list[SimpleNamespace] = []
|
||||
score: dict[str, float] = {}
|
||||
if SEPARABILITY_ARTIFACTS:
|
||||
score_payload = _states_to_events(trajectory)
|
||||
score = score_session(score_payload, SEPARABILITY_ARTIFACTS)
|
||||
alpha_estimates.append(
|
||||
estimate_alpha(score["prob_agent"], score["delta_h"], score["delta_a"], temperature=2.0)
|
||||
)
|
||||
|
||||
for state in trajectory:
|
||||
parts = state.split('|') if isinstance(state, str) else [start_event]
|
||||
new_rows.append({
|
||||
on: parts[-1] if parts else start_event,
|
||||
'source': 'synthetic_agent',
|
||||
'prob_agent': score.get('prob_agent') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||
'delta_h': score.get('delta_h') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||
'delta_a': score.get('delta_a') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||
})
|
||||
|
||||
if new_rows:
|
||||
contaminate_df = pd.DataFrame(new_rows)
|
||||
df = pd.concat([df, contaminate_df], ignore_index=True)
|
||||
if alpha_estimates:
|
||||
df['estimated_alpha'] = sum(alpha_estimates) / len(alpha_estimates)
|
||||
return df
|
||||
@@ -7,15 +7,6 @@ import pandas as pd
|
||||
class PricingFunction(ABC):
|
||||
"""
|
||||
Abstract base for pricing functions.
|
||||
|
||||
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
||||
|
||||
Where:
|
||||
Q_t ∈ R^n: demand vector at time t
|
||||
P_t ∈ R^n: price vector at time t
|
||||
S_t: session features (behavioral signals, interactions)
|
||||
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
||||
|
||||
Objective:
|
||||
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||
subject to:
|
||||
@@ -28,10 +19,10 @@ class PricingFunction(ABC):
|
||||
def fit(self, *kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
This is where we can think about some maximization of expected revenue
|
||||
over historical trajectories to learn parameters of the pricing function.
|
||||
(This however we cover move in the RL side of things)
|
||||
|
||||
Args:
|
||||
historical_data: DataFrame with elasticity, prices, demand signals
|
||||
**kwargs: additional training parameters
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -39,12 +30,18 @@ class PricingFunction(ABC):
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Generate optimal prices given current state.
|
||||
This is an abstract method that transitions from τ -> P*
|
||||
which is the mapping from the trajectory to optimal prices under
|
||||
some subset of session grouping (so, per sessionId)
|
||||
"""
|
||||
pass
|
||||
|
||||
Args:
|
||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||
|
||||
@abstractmethod
|
||||
def _get_features(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Extract features from trajectory for pricing decision.
|
||||
Returns:
|
||||
P_{t+1}: price vector in R^n
|
||||
np.ndarray of shape (n_products, n_features)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,3 +57,13 @@ class ElasticityBasedPricer(PricingFunction):
|
||||
# enforce bounds
|
||||
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 3))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||
|
||||
@@ -107,6 +107,36 @@ class SessionAwarePricer(PricingFunction):
|
||||
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and session features"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 5))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
n_products = len(demand)
|
||||
|
||||
# extract session features
|
||||
velocity = 0.0
|
||||
view_depth = 0.0
|
||||
cart_to_view = 0.0
|
||||
|
||||
if not state_space.session_features.empty:
|
||||
sf = state_space.session_features.iloc[0]
|
||||
velocity = sf.get('interaction_velocity', 0.0)
|
||||
view_depth = sf.get('product_view_depth', 0.0)
|
||||
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||
|
||||
# broadcast session features to all products
|
||||
features = np.column_stack([
|
||||
self.elasticity,
|
||||
demand,
|
||||
np.full(n_products, velocity),
|
||||
np.full(n_products, view_depth),
|
||||
np.full(n_products, cart_to_view)
|
||||
])
|
||||
return features
|
||||
|
||||
|
||||
class ProductSpecificSessionPricer(PricingFunction):
|
||||
"""
|
||||
@@ -170,3 +200,12 @@ class ProductSpecificSessionPricer(PricingFunction):
|
||||
|
||||
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity and demand features for product-specific pricing"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
return np.column_stack([self.elasticity, demand])
|
||||
|
||||
@@ -3,6 +3,46 @@ import pandas as pd
|
||||
from procesing.pricers.base import PricingFunction
|
||||
|
||||
|
||||
def session_features_to_demand(session_features: pd.DataFrame) -> float:
|
||||
"""
|
||||
Map session behavioral features to demand proxy.
|
||||
THIS is the critical θ̂ → D transformation for rule-based pricing.
|
||||
|
||||
Logic:
|
||||
- High velocity → agent behavior → price up (revenue recovery)
|
||||
- High cart ratio → purchase intent → price up
|
||||
- Low activity → discount to convert
|
||||
|
||||
Returns: demand proxy score (0-20 range, higher = more demand)
|
||||
"""
|
||||
if session_features.empty:
|
||||
return 1.0
|
||||
|
||||
feat = session_features.iloc[0] if len(session_features) > 0 else {}
|
||||
|
||||
velocity = feat.get('interaction_velocity', 0)
|
||||
cart_ratio = feat.get('cart_to_view_ratio', 0)
|
||||
item_views = feat.get('item_views', 0)
|
||||
cart_adds = feat.get('cart_adds', 0)
|
||||
|
||||
# baseline demand
|
||||
demand = 1.0
|
||||
|
||||
# agent detection: high velocity → treat as high "demand" to price up
|
||||
if velocity > 2.0:
|
||||
demand += 10.0 # strong agent signal
|
||||
|
||||
# conversion intent: cart interaction → price up
|
||||
if cart_ratio > 0.1 or cart_adds > 0:
|
||||
demand += 5.0
|
||||
|
||||
# browsing depth: many views → interest signal
|
||||
if item_views > 3:
|
||||
demand += min(item_views, 5.0)
|
||||
|
||||
return min(demand, 20.0) # cap at 20
|
||||
|
||||
|
||||
class StaticPricer(PricingFunction):
|
||||
"""Static pricing: always return fixed base prices"""
|
||||
|
||||
@@ -25,6 +65,11 @@ class StaticPricer(PricingFunction):
|
||||
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||
return self.base_prices.copy()
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Static pricer uses no features, returns empty array"""
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class RandomPricer(PricingFunction):
|
||||
"""Random pricing within bounds (for baseline comparison)"""
|
||||
@@ -47,6 +92,11 @@ class RandomPricer(PricingFunction):
|
||||
self.n_products = len(state_space.demand)
|
||||
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Random pricer uses no features"""
|
||||
n = self.n_products if self.n_products else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class SimpleSurgePricer(PricingFunction):
|
||||
"""
|
||||
@@ -67,21 +117,25 @@ class SimpleSurgePricer(PricingFunction):
|
||||
self.surge_multiplier = surge_multiplier
|
||||
self.discount_multiplier = discount_multiplier
|
||||
|
||||
def fit(self, market_data : pd.DataFrame):
|
||||
def fit(self, market_data: pd.DataFrame):
|
||||
"""Extract base prices from product catalog or historical averages"""
|
||||
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
||||
return self
|
||||
|
||||
def predict(self) -> np.ndarray:
|
||||
def predict(self, state_space) -> np.ndarray:
|
||||
"""
|
||||
Adjust prices based on current demand using surge rules.
|
||||
state_space.demand: demand counts per product
|
||||
state_space.prices: current prices (fallback if base_prices not set)
|
||||
state_space.demand: demand proxy per product (from session features)
|
||||
state_space.prices: base prices
|
||||
"""
|
||||
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99
|
||||
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices)
|
||||
new_prices = current_prices.copy()
|
||||
demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
|
||||
base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
|
||||
|
||||
if base is None:
|
||||
base = np.ones(len(demand)) * 99.99
|
||||
|
||||
# ensure float dtype to allow multiplication by float multipliers
|
||||
new_prices = base.astype(np.float64).copy()
|
||||
high_mask = demand >= self.high_threshold
|
||||
new_prices[high_mask] *= self.surge_multiplier
|
||||
|
||||
@@ -89,3 +143,16 @@ class SimpleSurgePricer(PricingFunction):
|
||||
new_prices[low_mask] *= self.discount_multiplier
|
||||
|
||||
return new_prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract demand and base price features for each product"""
|
||||
if state_space is None:
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand) if hasattr(state_space, 'demand') else np.array([0])
|
||||
base = np.asarray(state_space.prices) if hasattr(state_space, 'prices') else self.base_prices
|
||||
if base is None:
|
||||
base = np.ones(len(demand)) * 99.99
|
||||
|
||||
return np.column_stack([demand, base])
|
||||
|
||||
@@ -135,6 +135,7 @@ class ExtractSessionFeaturesStep(BaseContextStep):
|
||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||
Input: interactions_df
|
||||
Output: session-level feature matrix
|
||||
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
|
||||
"""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
|
||||
@@ -6,6 +6,7 @@ from procesing.steps import (
|
||||
)
|
||||
|
||||
def test_compute_demand(pipeline_context):
|
||||
random.seed(42) # deterministic test
|
||||
step = ComputeDemandStep(context=pipeline_context)
|
||||
|
||||
# Test with normal interaction data
|
||||
@@ -26,6 +27,7 @@ def test_compute_demand(pipeline_context):
|
||||
|
||||
|
||||
def test_compute_demand_skewed(pipeline_context):
|
||||
random.seed(42) # deterministic test
|
||||
step = ComputeDemandStep(context=pipeline_context)
|
||||
|
||||
# Test with normal interaction data
|
||||
|
||||
165
experiments/procesing/tests/test_session.py
Normal file
165
experiments/procesing/tests/test_session.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from procesing.steps.session import (
|
||||
TemporalFeatureStep,
|
||||
BehavioralFeatureStep,
|
||||
ProductFeatureStep,
|
||||
UserAgentFeatureStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
ValidateDataStep,
|
||||
)
|
||||
|
||||
|
||||
# TemporalFeatureStep tests
|
||||
def test_temporal_empty(pipeline_context):
|
||||
result = TemporalFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_temporal_basic(pipeline_context, session_interactions):
|
||||
result = TemporalFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'session_duration_sec' in result.columns
|
||||
assert 'interaction_velocity' in result.columns
|
||||
assert 'max_velocity_5min' in result.columns
|
||||
assert result['total_interactions'].sum() == len(session_interactions)
|
||||
|
||||
|
||||
def test_temporal_timeout(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's1'],
|
||||
'ts': ['2025-01-01T10:00:00Z', '2025-01-01T11:00:00Z'], # 1 hour gap
|
||||
})
|
||||
result = TemporalFeatureStep(pipeline_context, timeout_sec=900).transform(df)
|
||||
assert result.iloc[0]['session_duration_sec'] == 0 # gap exceeds timeout
|
||||
|
||||
|
||||
# BehavioralFeatureStep tests
|
||||
def test_behavioral_empty(pipeline_context):
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_behavioral_counts(pipeline_context, session_interactions):
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'page_views' in result.columns
|
||||
assert 'item_views' in result.columns
|
||||
assert 'hover_events' in result.columns
|
||||
assert result['total_events'].sum() == len(session_interactions)
|
||||
|
||||
|
||||
def test_behavioral_hover_prefix(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's1'],
|
||||
'eventName': ['hover_over_custom', 'hover_over_button'],
|
||||
'page': ['/products', '/products'],
|
||||
})
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(df)
|
||||
assert result.iloc[0]['hover_events'] == 2
|
||||
|
||||
|
||||
# ProductFeatureStep tests
|
||||
def test_product_empty(pipeline_context):
|
||||
result = ProductFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_product_features(pipeline_context, session_interactions):
|
||||
result = ProductFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'unique_products_viewed' in result.columns
|
||||
assert 'price_range' in result.columns
|
||||
assert result['unique_products_viewed'].sum() > 0
|
||||
|
||||
|
||||
# UserAgentFeatureStep tests
|
||||
def test_ua_empty(pipeline_context):
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_ua_headless_detection(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2'],
|
||||
'userAgent': ['Mozilla/5.0 Chrome/120', 'HeadlessChrome/120'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
assert 'is_headless' in result.columns
|
||||
headless = dict(zip(result['sessionId'], result['is_headless']))
|
||||
assert headless['s1'] == False
|
||||
assert headless['s2'] == True
|
||||
|
||||
|
||||
def test_ua_browser_family(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2', 's3'],
|
||||
'userAgent': ['Mozilla/5.0 Firefox/120', 'Safari/605.1.15', 'Unknown'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
browsers = dict(zip(result['sessionId'], result['browser_family']))
|
||||
assert browsers['s1'] == 'Firefox'
|
||||
assert browsers['s2'] == 'Safari'
|
||||
assert browsers['s3'] == 'Other'
|
||||
|
||||
|
||||
def test_ua_automation_detection(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2'],
|
||||
'userAgent': ['Selenium WebDriver', 'Normal Chrome/120'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
auto = dict(zip(result['sessionId'], result['is_automation']))
|
||||
assert auto['s1'] == True
|
||||
assert auto['s2'] == False
|
||||
|
||||
|
||||
# ExtractSessionFeaturesStep tests
|
||||
def test_extract_empty(pipeline_context):
|
||||
result = ExtractSessionFeaturesStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_extract_merges_all(pipeline_context, session_interactions):
|
||||
result = ExtractSessionFeaturesStep(pipeline_context).transform(session_interactions)
|
||||
expected = ['session_duration_sec', 'total_events', 'unique_products_viewed', 'is_headless']
|
||||
for col in expected:
|
||||
assert col in result.columns
|
||||
assert 'experimentId' in result.columns
|
||||
|
||||
|
||||
# JoinLabelsStep tests
|
||||
def test_join_labels_tuple_input(pipeline_context):
|
||||
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1'], 'total_events': [5]})
|
||||
experiments = pd.DataFrame({'id': ['exp1'], 'xp_human_only': [True]})
|
||||
result = JoinLabelsStep(pipeline_context).transform((features, experiments))
|
||||
assert 'is_agent' in result.columns
|
||||
assert result.iloc[0]['is_agent'] == False
|
||||
|
||||
|
||||
def test_join_labels_empty_experiments(pipeline_context):
|
||||
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1']})
|
||||
result = JoinLabelsStep(pipeline_context).transform((features, pd.DataFrame()))
|
||||
assert pd.isna(result.iloc[0]['is_agent'])
|
||||
|
||||
|
||||
# ValidateDataStep tests
|
||||
def test_validate_empty(pipeline_context):
|
||||
ValidateDataStep(pipeline_context).transform(pd.DataFrame())
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'empty'
|
||||
|
||||
|
||||
def test_validate_missing_cols(pipeline_context):
|
||||
df = pd.DataFrame({'sessionId': ['s1'], 'ts': ['2025-01-01']})
|
||||
ValidateDataStep(pipeline_context).transform(df)
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'invalid'
|
||||
assert 'eventName' in report['missing_cols']
|
||||
|
||||
|
||||
def test_validate_valid(pipeline_context, session_interactions):
|
||||
ValidateDataStep(pipeline_context).transform(session_interactions)
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'valid'
|
||||
assert report['sessions'] > 0
|
||||
41
lib/__init__.py
Normal file
41
lib/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""PHANTOM shared library
|
||||
Exports unified utilities for features, state, config, kafka, and model registry
|
||||
"""
|
||||
from .config import (
|
||||
PROJECT_ROOT, DATA_DIR, EXPERIMENTS_DIR,
|
||||
AGENT_DATA_DIR, HUMAN_DATA_DIR, SIM_RUNS_DIR, MODEL_REGISTRY_DIR,
|
||||
COLLECTED_DATA_DIR, NOTEBOOK_OUTPUT_DIR,
|
||||
ensure_dir, get_data_path, get_experiments_path, get_sim_path,
|
||||
KAFKA_HOST, KAFKA_PORT, KAFKA_BROKER,
|
||||
REDIS_HOST, REDIS_PORT,
|
||||
SUPABASE_URL, SUPABASE_ANON_KEY,
|
||||
BACKEND_PORT, PROVIDER_PORT
|
||||
)
|
||||
from .state import (
|
||||
make_state_repr, event_to_state, parse_state,
|
||||
get_event_name, get_timestamp,
|
||||
create_state_fn, create_event_name_fn, create_timestamp_fn
|
||||
)
|
||||
from .features import (
|
||||
transition_histogram, temporal_signature, state_coverage, transition_entropy,
|
||||
event_type_distribution, featurize_trajectory, parse_timestamp
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# config
|
||||
'PROJECT_ROOT', 'DATA_DIR', 'EXPERIMENTS_DIR',
|
||||
'AGENT_DATA_DIR', 'HUMAN_DATA_DIR', 'SIM_RUNS_DIR', 'MODEL_REGISTRY_DIR',
|
||||
'COLLECTED_DATA_DIR', 'NOTEBOOK_OUTPUT_DIR',
|
||||
'ensure_dir', 'get_data_path', 'get_experiments_path', 'get_sim_path',
|
||||
'KAFKA_HOST', 'KAFKA_PORT', 'KAFKA_BROKER',
|
||||
'REDIS_HOST', 'REDIS_PORT',
|
||||
'SUPABASE_URL', 'SUPABASE_ANON_KEY',
|
||||
'BACKEND_PORT', 'PROVIDER_PORT',
|
||||
# state
|
||||
'make_state_repr', 'event_to_state', 'parse_state',
|
||||
'get_event_name', 'get_timestamp',
|
||||
'create_state_fn', 'create_event_name_fn', 'create_timestamp_fn',
|
||||
# features
|
||||
'transition_histogram', 'temporal_signature', 'state_coverage', 'transition_entropy',
|
||||
'event_type_distribution', 'featurize_trajectory', 'parse_timestamp',
|
||||
]
|
||||
79
lib/config.py
Normal file
79
lib/config.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""Unified path configuration for PHANTOM project
|
||||
All hardcoded paths should reference this module
|
||||
Paths can be overridden via environment variables
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# project root (directory containing lib/, experiments/, sim/, web/, backend/)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# data directories
|
||||
DATA_DIR = Path(os.getenv("PHANTOM_DATA_DIR", PROJECT_ROOT / "data"))
|
||||
EXPERIMENTS_DIR = Path(
|
||||
os.getenv("PHANTOM_EXPERIMENTS_DIR", PROJECT_ROOT / "experiments")
|
||||
)
|
||||
|
||||
# agent/human interaction data
|
||||
AGENT_DATA_DIR = Path(os.getenv("PHANTOM_AGENT_DATA_DIR", DATA_DIR / "agents"))
|
||||
HUMAN_DATA_DIR = Path(os.getenv("PHANTOM_HUMAN_DATA_DIR", DATA_DIR / "humans"))
|
||||
|
||||
# RL simulation runs
|
||||
SIM_RUNS_DIR = Path(
|
||||
os.getenv("PHANTOM_SIM_RUNS_DIR", PROJECT_ROOT / "sim" / "rl" / "runs")
|
||||
)
|
||||
|
||||
# model artifacts
|
||||
MODEL_REGISTRY_DIR = Path(os.getenv("PHANTOM_MODEL_REGISTRY_DIR", DATA_DIR / "models"))
|
||||
|
||||
# collected experiment data
|
||||
COLLECTED_DATA_DIR = Path(
|
||||
os.getenv(
|
||||
"PHANTOM_COLLECTED_DATA_DIR", EXPERIMENTS_DIR / "agents" / "collected_data"
|
||||
)
|
||||
)
|
||||
|
||||
# notebook outputs
|
||||
NOTEBOOK_OUTPUT_DIR = Path(
|
||||
os.getenv("PHANTOM_NOTEBOOK_OUTPUT_DIR", EXPERIMENTS_DIR / "notebooks" / "outputs")
|
||||
)
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
"""ensure directory exists, create if needed"""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_data_path(*parts: str) -> Path:
|
||||
"""construct path relative to DATA_DIR"""
|
||||
return DATA_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
def get_experiments_path(*parts: str) -> Path:
|
||||
"""construct path relative to EXPERIMENTS_DIR"""
|
||||
return EXPERIMENTS_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
def get_sim_path(*parts: str) -> Path:
|
||||
"""construct path relative to SIM_RUNS_DIR"""
|
||||
return SIM_RUNS_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
# service configuration (from .env)
|
||||
KAFKA_HOST = os.getenv("KAFKA_HOST", "localhost")
|
||||
KAFKA_PORT = os.getenv("KAFKA_PORT", "9092")
|
||||
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
|
||||
|
||||
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
|
||||
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
|
||||
|
||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||
SUPABASE_ANON_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||
|
||||
BACKEND_PORT = int(os.getenv("BACKEND_PORT", "5000"))
|
||||
PROVIDER_PORT = int(os.getenv("PROVIDER_PORT", "5001"))
|
||||
|
||||
# huggingface dataset repo for collected behavioral data
|
||||
HF_DATASET_REPO = os.getenv("HF_DATASET_REPO", "velocitatem/phantom-collected-data")
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user