mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
87 Commits
new-simula
...
cleanup
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | ||
|
|
b5f19e04b7 | ||
|
|
a9d73ccce5 |
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
|
||||||
18
.env.sweep.example
Normal file
18
.env.sweep.example
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# 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=phantom-pricing
|
||||||
|
|
||||||
|
# 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
|
||||||
48
.github/workflows/latex.yml
vendored
48
.github/workflows/latex.yml
vendored
@@ -19,10 +19,56 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
root_file: main.tex
|
root_file: main.tex
|
||||||
working_directory: paper/src
|
working_directory: paper/src
|
||||||
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
|
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
||||||
pre_compile: bash ../concat_code.sh
|
pre_compile: bash ../concat_code.sh
|
||||||
- name: Upload PDF
|
- name: Upload PDF
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: thesis-pdf
|
name: thesis-pdf
|
||||||
path: paper/build/main.pdf
|
path: paper/build/main.pdf
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to Cloudflare R2
|
||||||
|
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 }}
|
||||||
|
DATE: ${{ steps.date.outputs.date }}
|
||||||
|
BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||||
|
run: |
|
||||||
|
pip install boto3
|
||||||
|
python3 << 'EOF'
|
||||||
|
import boto3
|
||||||
|
import os
|
||||||
|
|
||||||
|
s3 = boto3.client('s3',
|
||||||
|
endpoint_url=os.environ['AWS_ENDPOINT_URL'],
|
||||||
|
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
||||||
|
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY']
|
||||||
|
)
|
||||||
|
|
||||||
|
date = os.environ['DATE']
|
||||||
|
bucket = os.environ['BUCKET_NAME']
|
||||||
|
|
||||||
|
# upload dated version
|
||||||
|
dated_filename = f"thesis-{date}.pdf"
|
||||||
|
s3.upload_file(
|
||||||
|
'paper/build/main.pdf',
|
||||||
|
bucket,
|
||||||
|
dated_filename,
|
||||||
|
ExtraArgs={'ContentType': 'application/pdf'}
|
||||||
|
)
|
||||||
|
print(f"Uploaded {dated_filename}")
|
||||||
|
|
||||||
|
# upload latest version
|
||||||
|
s3.upload_file(
|
||||||
|
'paper/build/main.pdf',
|
||||||
|
bucket,
|
||||||
|
'thesis-latest.pdf',
|
||||||
|
ExtraArgs={'ContentType': 'application/pdf'}
|
||||||
|
)
|
||||||
|
print(f"Uploaded thesis-latest.pdf")
|
||||||
|
EOF
|
||||||
|
|||||||
62
.gitignore
vendored
62
.gitignore
vendored
@@ -1,26 +1,86 @@
|
|||||||
|
# environment and secrets
|
||||||
**/.env
|
**/.env
|
||||||
|
.env.*
|
||||||
|
!.env.*.example
|
||||||
**/.venv
|
**/.venv
|
||||||
|
|
||||||
|
# python build/cache artifacts
|
||||||
**/__pycache__
|
**/__pycache__
|
||||||
|
phantom.egg-info/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# notebook artifacts
|
||||||
**/.ipynb_checkpoints/
|
**/.ipynb_checkpoints/
|
||||||
**/.virtual_documents/
|
**/.virtual_documents/
|
||||||
|
|
||||||
|
# editor/tool state
|
||||||
|
**/.pdf-view-restore
|
||||||
|
.nextstep
|
||||||
|
.ignore-gitlogue
|
||||||
|
.cloudflare
|
||||||
|
|
||||||
|
# generated svg/graphics
|
||||||
**/session_*.svg
|
**/session_*.svg
|
||||||
**/*graph.svg
|
**/*graph.svg
|
||||||
**/auto/*.el
|
**/auto/*.el
|
||||||
|
|
||||||
|
# misc generated
|
||||||
*.old
|
*.old
|
||||||
**/package-lock.json
|
**/package-lock.json
|
||||||
**/*.parquet
|
**/*.parquet
|
||||||
**/_build/
|
**/_build/
|
||||||
|
|
||||||
|
# paper build artifacts
|
||||||
paper/src/bib/auto
|
paper/src/bib/auto
|
||||||
|
paper/src/auto/*
|
||||||
|
paper/src/bib/auto
|
||||||
|
paper/template/*
|
||||||
|
paper/build-cais/
|
||||||
|
paper/src/main.pdf
|
||||||
|
paper/src/main-blx.bib
|
||||||
|
paper/src/svg-inkscape/
|
||||||
|
paper/src/mirrors/
|
||||||
|
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/*
|
||||||
experiments/airflow/logs/scheduler/
|
experiments/airflow/logs/scheduler/
|
||||||
experiments/airflow/logs/dag_processor_manager/
|
experiments/airflow/logs/dag_processor_manager/
|
||||||
experiments/collected_data/
|
experiments/collected_data/
|
||||||
experiments/agents/collected_data/
|
experiments/agents/collected_data/
|
||||||
|
tests/e2e/test-results/
|
||||||
|
tests/e2e/node_modules/**
|
||||||
|
|
||||||
|
# rl/sim run outputs
|
||||||
sim/rl/behavior_loader/*.dot
|
sim/rl/behavior_loader/*.dot
|
||||||
sim/rl/behavior_loader/*.png
|
sim/rl/behavior_loader/*.png
|
||||||
sim/rl/behavior_loader/*.svg
|
sim/rl/behavior_loader/*.svg
|
||||||
sim/rl/behavior_loader/*.pdf
|
sim/rl/behavior_loader/*.pdf
|
||||||
tests/e2e/node_modules/**
|
sim/rl/runs/
|
||||||
lab/case/thesis/runs*/
|
lab/case/thesis/runs*/
|
||||||
sim/case/thesis_simplified/runs*/
|
sim/case/thesis_simplified/runs*/
|
||||||
|
|
||||||
|
# model binaries
|
||||||
|
engine/models/*.zip
|
||||||
|
*.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/*
|
||||||
|
|||||||
140
Makefile
140
Makefile
@@ -9,11 +9,45 @@ PYTHON := $(VENV)/bin/python
|
|||||||
PIP := $(VENV)/bin/pip
|
PIP := $(VENV)/bin/pip
|
||||||
PYTEST := $(VENV)/bin/pytest
|
PYTEST := $(VENV)/bin/pytest
|
||||||
|
|
||||||
|
SWEEP_ENV_FILE ?= .env.sweep
|
||||||
|
|
||||||
|
WANDB_ENTITY ?=
|
||||||
|
WANDB_PROJECT ?= phantom-pricing
|
||||||
|
SWEEP_ID ?=
|
||||||
|
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
|
||||||
|
AGENT_COUNT ?= 0
|
||||||
|
|
||||||
|
REPO_URL ?=
|
||||||
|
BRANCH ?= main
|
||||||
|
WORKDIR ?= $(HOME)/PHANTOM-agent
|
||||||
|
AGENT_LOOP ?= 1
|
||||||
|
RETRY_SECONDS ?= 20
|
||||||
|
|
||||||
|
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
|
||||||
|
TPU_NAME ?=
|
||||||
|
TPU_ZONE ?= us-central2-b
|
||||||
|
TPU_PROJECT ?= phantom-trc
|
||||||
|
TPU_REPO_DIR ?= /tmp/PHANTOM
|
||||||
|
|
||||||
|
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
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 | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | train.tpu.vm | train.tpu.vm.sweep | stats.lines"
|
||||||
|
@echo "docker.train.publish"
|
||||||
|
@echo ""
|
||||||
|
@echo "Local wandb run:"
|
||||||
|
@echo " make train LOCAL_TRAIN_ARGS='--algo ppo --total-timesteps 50000'"
|
||||||
|
@echo ""
|
||||||
|
@echo "Local sweep agent from this repo:"
|
||||||
|
@echo " make train.agent SWEEP_ID=entity/project/id AGENT_COUNT=5"
|
||||||
|
@echo ""
|
||||||
|
@echo "Bootstrap private repo worker from anywhere:"
|
||||||
|
@echo " make train.bootstrap REPO_URL=https://github.com/org/repo.git BRANCH=main SWEEP_ID=entity/project/id"
|
||||||
|
@echo ""
|
||||||
|
@echo "Config source: $(SWEEP_ENV_FILE) (auto-loaded)"
|
||||||
|
|
||||||
$(BUILDDIR):
|
$(BUILDDIR):
|
||||||
mkdir -p paper/$(BUILDDIR)
|
mkdir -p paper/$(BUILDDIR)
|
||||||
@@ -22,14 +56,15 @@ $(BUILDDIR):
|
|||||||
pdf.build: $(BUILDDIR)
|
pdf.build: $(BUILDDIR)
|
||||||
@bash paper/concat_code.sh
|
@bash paper/concat_code.sh
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
.PHONY: pdf.watch
|
.PHONY: pdf.watch
|
||||||
pdf.watch: $(BUILDDIR)
|
pdf.watch: $(BUILDDIR)
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
@@ -69,11 +104,110 @@ $(VENV):
|
|||||||
install: $(VENV)
|
install: $(VENV)
|
||||||
$(PIP) install -r requirements.txt
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
|
.PHONY: train
|
||||||
|
train: install
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
|
||||||
|
$(PYTHON) -m engine.train $(LOCAL_TRAIN_ARGS)
|
||||||
|
|
||||||
|
.PHONY: train.agent
|
||||||
|
train.agent: install
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
|
||||||
|
$(PYTHON) -m engine.train --sweep-agent --sweep-id "$(SWEEP_ID)" \
|
||||||
|
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
|
||||||
|
|
||||||
|
.PHONY: train.bootstrap
|
||||||
|
train.bootstrap:
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$GITHUB_TOKEN" || (echo "GITHUB_TOKEN required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
@test -n "$(REPO_URL)" || (echo "REPO_URL required, e.g. REPO_URL=https://github.com/org/repo.git" && exit 1)
|
||||||
|
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); \
|
||||||
|
WANDB_API_KEY="$$WANDB_API_KEY" \
|
||||||
|
WANDB_ENTITY="$(WANDB_ENTITY)" \
|
||||||
|
WANDB_PROJECT="$(WANDB_PROJECT)" \
|
||||||
|
GITHUB_TOKEN="$$GITHUB_TOKEN" \
|
||||||
|
REPO_URL="$(REPO_URL)" \
|
||||||
|
BRANCH="$(BRANCH)" \
|
||||||
|
WORKDIR="$(WORKDIR)" \
|
||||||
|
SWEEP_ID="$(SWEEP_ID)" \
|
||||||
|
AGENT_COUNT="$(AGENT_COUNT)" \
|
||||||
|
AGENT_LOOP="$(AGENT_LOOP)" \
|
||||||
|
RETRY_SECONDS="$(RETRY_SECONDS)" \
|
||||||
|
bash scripts/wandb_agent_bootstrap.sh
|
||||||
|
|
||||||
.PHONY: stats.lines
|
.PHONY: stats.lines
|
||||||
stats.lines:
|
stats.lines:
|
||||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
@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
|
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||||
|
|
||||||
|
.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
|
||||||
|
|
||||||
|
.PHONY: docker.train.publish
|
||||||
|
docker.train.publish:
|
||||||
|
docker build -f docker/Trainer.dockerfile --target gpu -t $(TRAIN_IMAGE_REF):gpu-latest .
|
||||||
|
docker push $(TRAIN_IMAGE_REF):gpu-latest
|
||||||
|
docker build -f docker/Trainer.dockerfile --target tpu -t $(TRAIN_IMAGE_REF):tpu-latest .
|
||||||
|
docker push $(TRAIN_IMAGE_REF):tpu-latest
|
||||||
|
|
||||||
|
.PHONY: train.tpu.pod
|
||||||
|
train.tpu.pod:
|
||||||
|
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||||
|
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=entity/project/id" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
gcloud compute tpus tpu-vm scp scripts/tpu_pod_run.sh $(TPU_NAME):/tmp/tpu_pod_run.sh \
|
||||||
|
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all
|
||||||
|
@$(SWEEP_ENV_LOAD); \
|
||||||
|
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
||||||
|
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
||||||
|
--command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh"
|
||||||
|
|
||||||
|
.PHONY: train.tpu.vm.prepare
|
||||||
|
train.tpu.vm.prepare:
|
||||||
|
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||||
|
TPU_NAME="$(TPU_NAME)" TPU_ZONE="$(TPU_ZONE)" TPU_PROJECT="$(TPU_PROJECT)" \
|
||||||
|
LOCAL_REPO_DIR="$(CURDIR)" REMOTE_REPO_DIR="$(TPU_REPO_DIR)" \
|
||||||
|
sh scripts/tpu_sync_repo.sh
|
||||||
|
gcloud compute tpus tpu-vm scp scripts/tpu_vm_train.sh $(TPU_NAME):/tmp/tpu_vm_train.sh \
|
||||||
|
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all
|
||||||
|
|
||||||
|
.PHONY: train.tpu.vm.run
|
||||||
|
train.tpu.vm.run:
|
||||||
|
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||||
|
@test -n "$(LOCAL_TRAIN_ARGS)" || (echo "LOCAL_TRAIN_ARGS required, e.g. --algo ppo --jax --total-timesteps 200000" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); \
|
||||||
|
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
||||||
|
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
||||||
|
--command="REPO_DIR='$(TPU_REPO_DIR)' TRAIN_ARGS='$(LOCAL_TRAIN_ARGS)' WANDB_API_KEY='$$WANDB_API_KEY' sh /tmp/tpu_vm_train.sh"
|
||||||
|
|
||||||
|
.PHONY: train.tpu.vm
|
||||||
|
train.tpu.vm: train.tpu.vm.prepare train.tpu.vm.run
|
||||||
|
|
||||||
|
.PHONY: train.tpu.vm.sweep
|
||||||
|
train.tpu.vm.sweep:
|
||||||
|
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||||
|
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=lusiana/phantom-pricing/abc123" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||||
|
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" \
|
||||||
|
python3 scripts/tpu_vm_sweep_agent.py \
|
||||||
|
--sweep-id "$(SWEEP_ID)" \
|
||||||
|
--tpu-name "$(TPU_NAME)" \
|
||||||
|
--tpu-zone "$(TPU_ZONE)" \
|
||||||
|
--tpu-project "$(TPU_PROJECT)" \
|
||||||
|
--tpu-repo-dir "$(TPU_REPO_DIR)" \
|
||||||
|
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
|
||||||
|
|
||||||
.PHONY: pdf clean watch run.webapp test count-lines all
|
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||||
pdf: pdf.build
|
pdf: pdf.build
|
||||||
clean: pdf.clean
|
clean: pdf.clean
|
||||||
|
|||||||
84
README.md
84
README.md
@@ -3,10 +3,92 @@
|
|||||||
### PHANTOM
|
### PHANTOM
|
||||||
|
|
||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
[](https://sites.research.google/trc/faq/)
|
[](https://sites.research.google/trc/faq/)
|
||||||
[](https://phantom-hotel.vercel.app)
|
[](https://phantom-hotel.vercel.app)
|
||||||
[](https://phantom-airline.vercel.app)
|
[](https://phantom-airline.vercel.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
mindmap
|
||||||
|
PHANTOM((PHANTOM Project))
|
||||||
|
North Star
|
||||||
|
Study how automated actors change markets
|
||||||
|
Build an experimentation platform for real-world-like commerce
|
||||||
|
Two-loop learning system
|
||||||
|
Online observation loop
|
||||||
|
Offline "defense gym" loop
|
||||||
|
Core Economic Questions
|
||||||
|
Price Discovery
|
||||||
|
How prices respond to demand signals
|
||||||
|
How signal quality changes with bots/agents
|
||||||
|
Demand & Elasticity
|
||||||
|
Shifts in willingness-to-pay
|
||||||
|
Short-run vs long-run elasticity
|
||||||
|
Market Efficiency & Welfare
|
||||||
|
Consumer surplus vs producer surplus
|
||||||
|
Deadweight loss from frictions/manipulation
|
||||||
|
Price Discrimination & Segmentation
|
||||||
|
Behavioral feature-based segmentation
|
||||||
|
Fairness vs profitability tradeoffs
|
||||||
|
Information Asymmetry
|
||||||
|
Agents amplify search and arbitrage
|
||||||
|
Sellers infer more about buyers; buyers infer more about sellers
|
||||||
|
Strategic Interaction
|
||||||
|
Consumers vs firms vs agents
|
||||||
|
Feedback loops: policy ↔ behavior ↔ price
|
||||||
|
Market Power & Competition
|
||||||
|
Algorithmic pricing as competitive tool
|
||||||
|
Risks: tacit coordination / "algorithmic collusion"
|
||||||
|
Externalities
|
||||||
|
Congestion and attention costs
|
||||||
|
Spillovers: one segment’s behavior affects others’ prices
|
||||||
|
System-Level View
|
||||||
|
Participants
|
||||||
|
Humans
|
||||||
|
Agents (automated buyers/actors)
|
||||||
|
Firms (pricing decision-makers)
|
||||||
|
Platform (measurement + control layer)
|
||||||
|
Markets Simulated
|
||||||
|
Repeated transactions
|
||||||
|
Limited inventory / capacity constraints (conceptually)
|
||||||
|
Time dynamics (learning over time)
|
||||||
|
Interventions
|
||||||
|
Pricing policies
|
||||||
|
Experiment assignment / randomized exposure
|
||||||
|
Agent behavioral policies (task-driven)
|
||||||
|
Measurement & Causal Inference
|
||||||
|
What is observed
|
||||||
|
Actions (search, click, purchase intent)
|
||||||
|
Context (product attributes, time, exposure)
|
||||||
|
Outcomes (conversion, revenue, churn proxies)
|
||||||
|
Identification strategy
|
||||||
|
A/B tests and randomization
|
||||||
|
Counterfactual baselines
|
||||||
|
Robustness checks (offline replay)
|
||||||
|
Key metrics
|
||||||
|
Revenue / profit proxies
|
||||||
|
Conversion & bounce
|
||||||
|
Price volatility / stability
|
||||||
|
Welfare proxies (e.g., dispersion, access)
|
||||||
|
Risk, Governance, and Ethics
|
||||||
|
Manipulation & Integrity
|
||||||
|
Bot-driven demand distortion
|
||||||
|
Measurement contamination
|
||||||
|
Fairness & Transparency
|
||||||
|
Differential pricing concerns
|
||||||
|
Explainability and auditability
|
||||||
|
Safety Constraints
|
||||||
|
Guardrails on price moves
|
||||||
|
Monitoring for runaway feedback loops
|
||||||
|
Outputs
|
||||||
|
Insights
|
||||||
|
When do agents raise/lower prices via behavior shifts?
|
||||||
|
Which market designs are robust to automation?
|
||||||
|
Defenses
|
||||||
|
Agent-aware pricing policies (robust control)
|
||||||
|
Detection + mitigation strategies (feature-level separability)
|
||||||
|
Platform Value
|
||||||
|
Reusable testbed for market + AI-agent research
|
||||||
|
```
|
||||||
|
|||||||
6
TPUS/README.md
Normal file
6
TPUS/README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
64 spot Cloud TPU v6e chips in zone europe-west4-a
|
||||||
|
32 spot Cloud TPU v4 chips in zone us-central2-b
|
||||||
|
64 spot Cloud TPU v5e chips in zone us-central1-a
|
||||||
|
64 spot Cloud TPU v6e chips in zone us-east1-d
|
||||||
|
32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
||||||
|
64 spot Cloud TPU v5e chips in zone europe-west4-b
|
||||||
22
TPUS/v4_32_spot_uscentral2b.sh
Normal file
22
TPUS/v4_32_spot_uscentral2b.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 32 spot Cloud TPU v4 chips in zone us-central2-b
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUv4s32spotUC2B
|
||||||
|
export TPU_NAME=tpu-v4-32-uc2b-spot
|
||||||
|
export ZONE=us-central2-b
|
||||||
|
export ACCELERATOR_TYPE=v4-32
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv4
|
||||||
|
|
||||||
|
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--version=${RUNTIME_VERSION} \
|
||||||
|
--spot \
|
||||||
|
|| \
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION} \
|
||||||
|
--spot
|
||||||
13
TPUS/v4_uscentral2b.sh
Normal file
13
TPUS/v4_uscentral2b.sh
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# 32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUlong
|
||||||
|
export ZONE=us-central2-b
|
||||||
|
export ACCELERATOR_TYPE=v4-32
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv4
|
||||||
|
#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION}
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION}
|
||||||
22
TPUS/v5e_64_spot_europewest4b.sh
Normal file
22
TPUS/v5e_64_spot_europewest4b.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 64 spot Cloud TPU v5e chips in zone europe-west4-b
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUv5e64spotEW4B
|
||||||
|
export TPU_NAME=tpu-v5e-64-ew4b
|
||||||
|
export ZONE=europe-west4-b
|
||||||
|
export ACCELERATOR_TYPE=v5e-64
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
||||||
|
|
||||||
|
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--version=${RUNTIME_VERSION} \
|
||||||
|
--spot \
|
||||||
|
|| \
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION} \
|
||||||
|
--spot
|
||||||
22
TPUS/v5e_64_spot_uscentral1a.sh
Normal file
22
TPUS/v5e_64_spot_uscentral1a.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 64 spot Cloud TPU v5e chips in zone us-central1-a
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUv5e64spotUC1A
|
||||||
|
export TPU_NAME=tpu-v5e-64-uc1a
|
||||||
|
export ZONE=us-central1-a
|
||||||
|
export ACCELERATOR_TYPE=v5e-64
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
||||||
|
|
||||||
|
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--version=${RUNTIME_VERSION} \
|
||||||
|
--spot \
|
||||||
|
|| \
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION} \
|
||||||
|
--spot
|
||||||
22
TPUS/v6e_64_spot_europewest4a.sh
Normal file
22
TPUS/v6e_64_spot_europewest4a.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 64 spot Cloud TPU v6e chips in zone europe-west4-a
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUv6e64spotEW4A
|
||||||
|
export TPU_NAME=tpu-v6e-64-ew4a
|
||||||
|
export ZONE=europe-west4-a
|
||||||
|
export ACCELERATOR_TYPE=v6e-64
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
||||||
|
|
||||||
|
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--version=${RUNTIME_VERSION} \
|
||||||
|
--spot \
|
||||||
|
|| \
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION} \
|
||||||
|
--spot
|
||||||
22
TPUS/v6e_64_spot_useast1d.sh
Normal file
22
TPUS/v6e_64_spot_useast1d.sh
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# 64 spot Cloud TPU v6e chips in zone us-east1-d
|
||||||
|
export PROJECT_ID=phantom-trc
|
||||||
|
export QR_NAME=TPUv6e64spotUE1D
|
||||||
|
export TPU_NAME=tpu-v6e-64-ue1d
|
||||||
|
export ZONE=us-east1-d
|
||||||
|
export ACCELERATOR_TYPE=v6e-64
|
||||||
|
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
||||||
|
|
||||||
|
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--version=${RUNTIME_VERSION} \
|
||||||
|
--spot \
|
||||||
|
|| \
|
||||||
|
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||||
|
--project=${PROJECT_ID} \
|
||||||
|
--zone=${ZONE} \
|
||||||
|
--node-id=${TPU_NAME} \
|
||||||
|
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||||
|
--runtime-version=${RUNTIME_VERSION} \
|
||||||
|
--spot
|
||||||
42
docker/Trainer.dockerfile
Normal file
42
docker/Trainer.dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Optional for JAX-on-GPU workflows.
|
||||||
|
ARG INSTALL_JAX_GPU=false
|
||||||
|
RUN if [ "${INSTALL_JAX_GPU}" = "true" ]; then \
|
||||||
|
pip install --no-cache-dir "jax[cuda12]==0.4.30" -f https://storage.googleapis.com/jax-releases/jax_cuda_releases.html; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
||||||
|
COPY engine /app/engine
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app \
|
||||||
|
XLA_PYTHON_CLIENT_PREALLOCATE=false
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.11-slim AS tpu
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY docker/trainer.requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir "jax[tpu]==0.4.30" -f https://storage.googleapis.com/jax-releases/libtpu_releases.html
|
||||||
|
|
||||||
|
COPY --chmod=755 docker/trainer-agent-entrypoint.sh /usr/local/bin/trainer-agent-entrypoint
|
||||||
|
COPY engine /app/engine
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app \
|
||||||
|
PHANTOM_USE_JAX=1 \
|
||||||
|
PHANTOM_DEFAULT_AGENT_ARGS="--jax" \
|
||||||
|
XLA_PYTHON_CLIENT_PREALLOCATE=false
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/trainer-agent-entrypoint"]
|
||||||
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 "$@"
|
||||||
13
docker/trainer.requirements.txt
Normal file
13
docker/trainer.requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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
|
||||||
|
tensorflow-probability==0.24.0
|
||||||
|
flax==0.10.7
|
||||||
|
optax==0.2.7
|
||||||
|
distrax==0.1.5
|
||||||
|
orbax-checkpoint==0.11.32
|
||||||
|
chex==0.1.90
|
||||||
21
docs/goals/goals.csv
Normal file
21
docs/goals/goals.csv
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
store_mode,task_name,task_description,definition_of_done
|
||||||
|
airline,The Indecisive Executive (SEA-LAX),"You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the ""Flexible"" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket.","Booking for SEA-LAX Business Class is completed."
|
||||||
|
airline,The Cross-Country Splurge (LAX-JFK),"You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class.","Booking for LAX-JFK First Class is completed."
|
||||||
|
airline,The Budget Student (DFW-ORD),"You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the ""Premium"" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket.","Booking for DFW-ORD Economy Class is completed."
|
||||||
|
airline,The Quick Hop Commuter (LAX-SFO),"You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it.","Booking for LAX-SFO is completed."
|
||||||
|
airline,The Status Chaser (SFO-SEA),"You are trying to earn airline points and need a ""Premium"" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the ""Premium"" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat.","Booking for SFO-SEA Premium Economy is completed."
|
||||||
|
airline,The Family Reunion (MIA-ATL),"You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option.","Booking for MIA-ATL (Premium) is completed."
|
||||||
|
airline,The Red Eye Skeptic (LAX-JFK),"You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time.","Booking for LAX-JFK is completed."
|
||||||
|
airline,The Refundable Requirement (ATL-DFW),"Your meeting in Dallas might get cancelled, so you strictly need a ""Refundable"" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists ""Refundable"". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind.","Booking for ATL-DFW First Class is completed."
|
||||||
|
airline,The Hub Connector (ORD-MIA),"You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the ""stops"" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy.","Booking for ORD-MIA Economy is completed."
|
||||||
|
airline,The West Coast Hopper (SEA-LAX Business),"You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat.","Booking for SEA-LAX Business is completed."
|
||||||
|
hotel,The Honeymoon Suite (Presidential),"It is your honeymoon. You want the best room available, specifically one with a ""jacuzzi"". 1) Search for a room for 2 people. 2) Identify the ""Presidential Suite"". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the ""Executive Suite"" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it.","Booking for the Presidential Suite is completed."
|
||||||
|
hotel,The Digital Nomad (Executive),"You are working remotely and strictly need a ""workspace"". 1) Search for a room. 2) Check the ""Executive Suite"" details for a workspace. 3) Check the ""Deluxe Room"" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it.","Booking for the Executive Suite is completed."
|
||||||
|
hotel,The Safety First (Superior),"You are traveling with valuables and need a ""safe"" in the room. 1) Search for a room. 2) Look at the ""Standard Room"" amenities. Does it have a safe? 3) Look at the ""Superior Room"". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room.","Booking for the Superior Room is completed."
|
||||||
|
hotel,The Bachelor Party (Max Occupancy),"You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a ""Standard Room"". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite.","Booking for the Presidential Suite is completed."
|
||||||
|
hotel,The Budget Refundable (Junior),"You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the ""Standard"" and ""Superior"" rooms. Notice they are likely Non-Refundable. 4) Find the ""Junior Suite"" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility.","Booking for the Junior Suite is completed."
|
||||||
|
hotel,The View Hunter (Executive),"You want a room with a ""city_view"" or balcony. 1) Search for a room. 2) Check the amenities of the ""Deluxe Room"". 3) Check the amenities of the ""Executive Suite"". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it.","Booking for the Executive Suite is completed."
|
||||||
|
hotel,The Just-A-Bed (Standard),"You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has ""wifi"". 4) Briefly glance at the ""Superior Room"" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately.","Booking for the Standard Room is completed."
|
||||||
|
hotel,The Family Vacation (Deluxe),"You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the ""Deluxe Room"". 3) Check the amenities for ""coffee_maker"" (parents need coffee). 4) Compare it with the ""Junior Suite"". 5) Decide the Deluxe Room is sufficient value and book it.","Booking for the Deluxe Room is completed."
|
||||||
|
hotel,The Long Stay (Junior),"You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the ""Junior Suite"". 3) Check the amenities for a ""mini_fridge"" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the ""Standard Room"" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it.","Booking for the Junior Suite is completed."
|
||||||
|
hotel,The Last Minute Panic (Superior),"It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the ""Superior Room"" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible.","Booking for the Superior Room is completed."
|
||||||
|
@@ -47,7 +47,7 @@
|
|||||||
<meta name="citation_author" content="Rösel, Daniel">
|
<meta name="citation_author" content="Rösel, Daniel">
|
||||||
<meta name="citation_publication_date" content="2025">
|
<meta name="citation_publication_date" content="2025">
|
||||||
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
||||||
<meta name="citation_pdf_url" content="TODO">
|
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
||||||
|
|
||||||
<!-- Additional SEO -->
|
<!-- Additional SEO -->
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
@@ -233,14 +233,13 @@
|
|||||||
|
|
||||||
<div class="is-size-5 publication-authors">
|
<div class="is-size-5 publication-authors">
|
||||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
||||||
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
|
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column has-text-centered">
|
<div class="column has-text-centered">
|
||||||
<div class="publication-links">
|
<div class="publication-links">
|
||||||
<!-- TODO: Update with your arXiv paper ID -->
|
|
||||||
<span class="link-block">
|
<span class="link-block">
|
||||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-file-pdf"></i>
|
<i class="fas fa-file-pdf"></i>
|
||||||
@@ -315,7 +314,10 @@
|
|||||||
<h2 class="title is-3">Abstract</h2>
|
<h2 class="title is-3">Abstract</h2>
|
||||||
<div class="content has-text-justified">
|
<div class="content has-text-justified">
|
||||||
<p>
|
<p>
|
||||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
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.
|
||||||
|
</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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,8 +435,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Poster</h2>
|
<h2 class="title">Poster</h2>
|
||||||
|
|
||||||
<!-- TODO: Replace with your poster PDF -->
|
<iframe src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
||||||
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
|
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
from sys import platform
|
from sys import platform
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from .lib.demand import generate_demand, estimate_demand
|
from .lib.demand import generate_demand_for_actor, estimate_demand
|
||||||
from .lib.behavior import sample_behavior
|
from .lib.behavior import sample_behavior
|
||||||
from logging import INFO, getLogger
|
from logging import INFO, getLogger
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
logger.setLevel(INFO)
|
logger.setLevel(INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEngine:
|
||||||
|
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
|
||||||
|
|
||||||
class MarketEngine():
|
def __init__(
|
||||||
def __init__(self,
|
self,
|
||||||
alpha = 0.5,
|
alpha: float,
|
||||||
N = 100,
|
N: int,
|
||||||
demand_distribution = (50, 10),
|
human_params: tuple,
|
||||||
demand_sampling_function = np.random.normal):
|
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.Nagents = int(N * alpha)
|
||||||
self.Nhumans = int(N * (1 - alpha))
|
self.Nhumans = int(N * (1 - alpha))
|
||||||
self.demand = (demand_sampling_function, demand_distribution)
|
self.human_params = human_params
|
||||||
|
self.agent_params = agent_params
|
||||||
|
self.noise_std = noise_std
|
||||||
|
self.demand_dist = demand_distribution
|
||||||
|
self.action_weights = action_weights
|
||||||
|
|
||||||
def act(self, prices):
|
def act(self, prices):
|
||||||
demand = generate_demand(prices, *self.demand)
|
# generate separate demands d() per actor type
|
||||||
sample_n = lambda n, human: [sample_behavior(demand, human=human) for _ in range(n)]
|
demand_h = generate_demand_for_actor(
|
||||||
human_t, agent_t = sample_n(100, True), sample_n(100, False)
|
prices,
|
||||||
trajectories = human_t + agent_t
|
self.human_params,
|
||||||
demand_estimate = estimate_demand(trajectories)
|
self.noise_std,
|
||||||
return demand_estimate
|
distribution_method=self.demand_dist,
|
||||||
|
)
|
||||||
|
demand_a = generate_demand_for_actor(
|
||||||
|
prices,
|
||||||
|
self.agent_params,
|
||||||
|
self.noise_std,
|
||||||
|
distribution_method=self.demand_dist,
|
||||||
|
)
|
||||||
|
# sample behavior trajectories from each demand distribution
|
||||||
|
human_t = [sample_behavior(demand_h, human=True) for _ in range(self.Nhumans)]
|
||||||
|
agent_t = [sample_behavior(demand_a, human=False) for _ in range(self.Nagents)]
|
||||||
|
# store trajectories for agent probability calculation
|
||||||
|
self.last_trajectories = human_t + agent_t
|
||||||
|
return estimate_demand(self.last_trajectories, self.action_weights)
|
||||||
|
|
||||||
def measure(self):
|
def measure(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class PricingEngine():
|
|
||||||
def __init__(self,
|
class PricingEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -38,29 +67,31 @@ class PricingEngine():
|
|||||||
return np.random.uniform(low=25, high=100, size=10)
|
return np.random.uniform(low=25, high=100, size=10)
|
||||||
|
|
||||||
|
|
||||||
|
class Limbo:
|
||||||
class Limbo():
|
def __init__(self, platform, market) -> None:
|
||||||
def __init__(self,
|
|
||||||
platform,
|
|
||||||
market
|
|
||||||
) -> None:
|
|
||||||
self.platform_turn = True
|
self.platform_turn = True
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.market = market
|
self.market = market
|
||||||
self.output = None
|
self.output = None
|
||||||
|
|
||||||
def step(self):
|
def step(self):
|
||||||
# we could code golf this a little bit
|
|
||||||
if self.platform_turn:
|
if self.platform_turn:
|
||||||
self.output = self.platform.act(self.output)
|
self.output = self.platform.act(self.output)
|
||||||
else:
|
else:
|
||||||
self.output = self.market.act(self.output)
|
self.output = self.market.act(self.output)
|
||||||
print(self.output)
|
|
||||||
self.platform_turn = not self.platform_turn
|
self.platform_turn = not self.platform_turn
|
||||||
|
return self.output
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.platform_turn = True
|
||||||
|
self.output = None
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
platform = PricingEngine()
|
platform = PricingEngine()
|
||||||
market = MarketEngine()
|
market = MarketEngine(
|
||||||
|
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
|
||||||
|
)
|
||||||
limbo = Limbo(platform, market)
|
limbo = Limbo(platform, market)
|
||||||
for _ in range(10):
|
for _ in range(10):
|
||||||
limbo.step()
|
limbo.step()
|
||||||
|
|||||||
13
engine/jax/__init__.py
Normal file
13
engine/jax/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""JAX-compatible training and environment modules for PHANTOM."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jax # noqa: F401
|
||||||
|
import jax.numpy as jnp # noqa: F401
|
||||||
|
|
||||||
|
JAX_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
JAX_AVAILABLE = False
|
||||||
|
|
||||||
|
__all__ = ["JAX_AVAILABLE"]
|
||||||
49
engine/jax/checkpoint.py
Normal file
49
engine/jax/checkpoint.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Orbax checkpoint helpers for JAX training runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
try:
|
||||||
|
import orbax.checkpoint as ocp
|
||||||
|
|
||||||
|
HAS_ORBAX = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_ORBAX = False
|
||||||
|
|
||||||
|
|
||||||
|
def _require_orbax() -> None:
|
||||||
|
if not HAS_ORBAX:
|
||||||
|
raise ImportError(
|
||||||
|
"orbax-checkpoint is required for checkpoint support. "
|
||||||
|
"Install engine/jax/requirements.txt first."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_manager(directory: str | Path, max_to_keep: int = 5):
|
||||||
|
_require_orbax()
|
||||||
|
root = Path(directory)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
options = ocp.CheckpointManagerOptions(
|
||||||
|
max_to_keep=max(1, int(max_to_keep)), create=True
|
||||||
|
)
|
||||||
|
return ocp.CheckpointManager(root.as_posix(), ocp.PyTreeCheckpointer(), options)
|
||||||
|
|
||||||
|
|
||||||
|
def save(manager, *, step: int, payload: Any) -> bool:
|
||||||
|
_require_orbax()
|
||||||
|
return bool(manager.save(int(step), payload))
|
||||||
|
|
||||||
|
|
||||||
|
def latest_step(manager) -> int | None:
|
||||||
|
_require_orbax()
|
||||||
|
return manager.latest_step()
|
||||||
|
|
||||||
|
|
||||||
|
def restore(manager, *, target: Any, step: int | None = None) -> Any:
|
||||||
|
_require_orbax()
|
||||||
|
step_to_restore = manager.latest_step() if step is None else int(step)
|
||||||
|
if step_to_restore is None:
|
||||||
|
return target
|
||||||
|
return manager.restore(step_to_restore, items=target)
|
||||||
287
engine/jax/env.py
Normal file
287
engine/jax/env.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""JAX-native PHANTOM environment with robust contamination step."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
import jax.numpy as jnp
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise ImportError("engine.jax.env requires JAX") from exc
|
||||||
|
|
||||||
|
from .primitives import (
|
||||||
|
_sample_sessions_jax,
|
||||||
|
agent_probability_from_kl,
|
||||||
|
batch_kl,
|
||||||
|
compute_session_transitions,
|
||||||
|
load_transition_data,
|
||||||
|
purchase_flags,
|
||||||
|
reward_with_coi_penalty,
|
||||||
|
revenue_from_demand,
|
||||||
|
weighted_demand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EnvParams(NamedTuple):
|
||||||
|
n_products: int
|
||||||
|
n_sessions: int
|
||||||
|
max_episode_steps: int
|
||||||
|
max_session_steps: int
|
||||||
|
price_low: float
|
||||||
|
price_high: float
|
||||||
|
lambda_coi: float
|
||||||
|
info_value: float
|
||||||
|
robust_radius: float
|
||||||
|
margin_floor: float
|
||||||
|
margin_floor_patience: int
|
||||||
|
action_scales: jax.Array
|
||||||
|
alpha_nominal: float
|
||||||
|
alpha_candidates: jax.Array
|
||||||
|
human_T: jax.Array
|
||||||
|
agent_T: jax.Array
|
||||||
|
terminal_mask: jax.Array
|
||||||
|
purchase_mask: jax.Array
|
||||||
|
event_weights: jax.Array
|
||||||
|
start_idx: int
|
||||||
|
term_idx: int
|
||||||
|
|
||||||
|
|
||||||
|
class EnvState(NamedTuple):
|
||||||
|
prices: jax.Array
|
||||||
|
demand: jax.Array
|
||||||
|
step_count: jax.Array
|
||||||
|
low_margin_streak: jax.Array
|
||||||
|
last_agent_prob: jax.Array
|
||||||
|
last_alpha_adv: jax.Array
|
||||||
|
|
||||||
|
|
||||||
|
class CandidateEval(NamedTuple):
|
||||||
|
reward: jax.Array
|
||||||
|
revenue: jax.Array
|
||||||
|
demand: jax.Array
|
||||||
|
agent_prob: jax.Array
|
||||||
|
leakage: jax.Array
|
||||||
|
discount: jax.Array
|
||||||
|
n_purchases: jax.Array
|
||||||
|
n_agents: jax.Array
|
||||||
|
|
||||||
|
|
||||||
|
def make_env_params(
|
||||||
|
*,
|
||||||
|
n_products: int,
|
||||||
|
alpha: float,
|
||||||
|
n_sessions: int,
|
||||||
|
lambda_coi: float,
|
||||||
|
robust_radius: float,
|
||||||
|
robust_points: int,
|
||||||
|
info_value: float,
|
||||||
|
action_levels: int,
|
||||||
|
action_scale_low: float,
|
||||||
|
action_scale_high: float,
|
||||||
|
price_low: float,
|
||||||
|
price_high: float,
|
||||||
|
max_episode_steps: int,
|
||||||
|
max_session_steps: int = 40,
|
||||||
|
margin_floor: float = 0.05,
|
||||||
|
margin_floor_patience: int = 5,
|
||||||
|
prefer_behavior_data: bool = True,
|
||||||
|
) -> EnvParams:
|
||||||
|
transition = load_transition_data(prefer_data=prefer_behavior_data).to_jax()
|
||||||
|
if robust_radius <= 0.0 or robust_points <= 1:
|
||||||
|
alpha_candidates = jnp.asarray([float(alpha)], dtype=jnp.float32)
|
||||||
|
else:
|
||||||
|
lo = max(0.0, float(alpha) - float(robust_radius))
|
||||||
|
hi = min(1.0, float(alpha) + float(robust_radius))
|
||||||
|
alpha_candidates = jnp.linspace(lo, hi, int(robust_points), dtype=jnp.float32)
|
||||||
|
|
||||||
|
action_scales = jnp.linspace(
|
||||||
|
float(action_scale_low),
|
||||||
|
float(action_scale_high),
|
||||||
|
int(action_levels),
|
||||||
|
dtype=jnp.float32,
|
||||||
|
)
|
||||||
|
return EnvParams(
|
||||||
|
n_products=int(n_products),
|
||||||
|
n_sessions=int(n_sessions),
|
||||||
|
max_episode_steps=int(max_episode_steps),
|
||||||
|
max_session_steps=int(max_session_steps),
|
||||||
|
price_low=float(price_low),
|
||||||
|
price_high=float(price_high),
|
||||||
|
lambda_coi=float(lambda_coi),
|
||||||
|
info_value=float(info_value),
|
||||||
|
robust_radius=float(robust_radius),
|
||||||
|
margin_floor=float(margin_floor),
|
||||||
|
margin_floor_patience=int(margin_floor_patience),
|
||||||
|
action_scales=action_scales,
|
||||||
|
alpha_nominal=float(alpha),
|
||||||
|
alpha_candidates=alpha_candidates,
|
||||||
|
human_T=jnp.asarray(transition.human_T),
|
||||||
|
agent_T=jnp.asarray(transition.agent_T),
|
||||||
|
terminal_mask=jnp.asarray(transition.terminal_mask),
|
||||||
|
purchase_mask=jnp.asarray(transition.purchase_mask),
|
||||||
|
event_weights=jnp.asarray(transition.event_weights),
|
||||||
|
start_idx=int(transition.start_idx),
|
||||||
|
term_idx=int(transition.term_idx),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_obs(demand: jax.Array, prices: jax.Array) -> jax.Array:
|
||||||
|
return jnp.concatenate([demand.astype(jnp.float32), prices.astype(jnp.float32)])
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_action(
|
||||||
|
prices: jax.Array, action: jax.Array, params: EnvParams
|
||||||
|
) -> jax.Array:
|
||||||
|
idx = jnp.clip(action.astype(jnp.int32), 0, params.action_scales.shape[0] - 1)
|
||||||
|
scale = params.action_scales[idx]
|
||||||
|
next_prices = prices * scale
|
||||||
|
return jnp.clip(next_prices, params.price_low, params.price_high)
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_candidate(
|
||||||
|
key: jax.Array,
|
||||||
|
alpha_candidate: jax.Array,
|
||||||
|
prices: jax.Array,
|
||||||
|
params: EnvParams,
|
||||||
|
) -> CandidateEval:
|
||||||
|
states, products, actors, lengths = _sample_sessions_jax(
|
||||||
|
key,
|
||||||
|
params.human_T,
|
||||||
|
params.agent_T,
|
||||||
|
params.terminal_mask,
|
||||||
|
params.start_idx,
|
||||||
|
params.term_idx,
|
||||||
|
alpha_candidate,
|
||||||
|
params.n_products,
|
||||||
|
params.n_sessions,
|
||||||
|
params.max_session_steps,
|
||||||
|
int(params.human_T.shape[0]),
|
||||||
|
)
|
||||||
|
session_trans = compute_session_transitions(
|
||||||
|
states, lengths, int(params.human_T.shape[0])
|
||||||
|
)
|
||||||
|
delta_h, delta_a = batch_kl(session_trans, params.human_T, params.agent_T)
|
||||||
|
agent_probs = agent_probability_from_kl(delta_h, delta_a)
|
||||||
|
agent_prob = jnp.mean(agent_probs)
|
||||||
|
|
||||||
|
demand = weighted_demand(states, products, params.n_products, params.event_weights)
|
||||||
|
revenue = revenue_from_demand(prices, demand)
|
||||||
|
reward, leakage, discount = reward_with_coi_penalty(
|
||||||
|
revenue,
|
||||||
|
agent_prob,
|
||||||
|
params.lambda_coi,
|
||||||
|
params.info_value,
|
||||||
|
)
|
||||||
|
purchases = purchase_flags(states, params.purchase_mask)
|
||||||
|
return CandidateEval(
|
||||||
|
reward=reward,
|
||||||
|
revenue=revenue,
|
||||||
|
demand=demand,
|
||||||
|
agent_prob=agent_prob,
|
||||||
|
leakage=leakage,
|
||||||
|
discount=discount,
|
||||||
|
n_purchases=jnp.sum(purchases.astype(jnp.float32)),
|
||||||
|
n_agents=jnp.sum(actors.astype(jnp.float32)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reset_env(key: jax.Array, params: EnvParams) -> tuple[jax.Array, EnvState]:
|
||||||
|
prices = jax.random.uniform(
|
||||||
|
key,
|
||||||
|
shape=(params.n_products,),
|
||||||
|
minval=params.price_low,
|
||||||
|
maxval=params.price_high,
|
||||||
|
)
|
||||||
|
demand = jnp.zeros((params.n_products,), dtype=jnp.float32)
|
||||||
|
state = EnvState(
|
||||||
|
prices=prices,
|
||||||
|
demand=demand,
|
||||||
|
step_count=jnp.asarray(0, dtype=jnp.int32),
|
||||||
|
low_margin_streak=jnp.asarray(0, dtype=jnp.int32),
|
||||||
|
last_agent_prob=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
||||||
|
last_alpha_adv=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
||||||
|
)
|
||||||
|
return _flatten_obs(demand, prices), state
|
||||||
|
|
||||||
|
|
||||||
|
def step_env(
|
||||||
|
key: jax.Array,
|
||||||
|
state: EnvState,
|
||||||
|
action: jax.Array,
|
||||||
|
params: EnvParams,
|
||||||
|
) -> tuple[jax.Array, EnvState, jax.Array, jax.Array, dict[str, jax.Array]]:
|
||||||
|
prices = _decode_action(state.prices, action, params)
|
||||||
|
n_candidates = params.alpha_candidates.shape[0]
|
||||||
|
cand_keys = jax.random.split(key, n_candidates)
|
||||||
|
evals = jax.vmap(
|
||||||
|
lambda k, a: _evaluate_candidate(k, a, prices, params),
|
||||||
|
in_axes=(0, 0),
|
||||||
|
)(cand_keys, params.alpha_candidates)
|
||||||
|
idx = jnp.argmin(evals.reward)
|
||||||
|
|
||||||
|
demand = evals.demand[idx]
|
||||||
|
reward = evals.reward[idx]
|
||||||
|
revenue = evals.revenue[idx]
|
||||||
|
agent_prob = evals.agent_prob[idx]
|
||||||
|
leakage = evals.leakage[idx]
|
||||||
|
discount = evals.discount[idx]
|
||||||
|
n_purchases = evals.n_purchases[idx]
|
||||||
|
n_agents = evals.n_agents[idx]
|
||||||
|
alpha_adv = params.alpha_candidates[idx]
|
||||||
|
|
||||||
|
step_count = state.step_count + 1
|
||||||
|
avg_price = jnp.maximum(jnp.mean(prices), 1e-6)
|
||||||
|
avg_margin = (avg_price - params.price_low) / avg_price
|
||||||
|
next_streak = jnp.where(
|
||||||
|
avg_margin < params.margin_floor, state.low_margin_streak + 1, 0
|
||||||
|
)
|
||||||
|
|
||||||
|
margin_collapsed = next_streak >= params.margin_floor_patience
|
||||||
|
done = (step_count >= params.max_episode_steps) | margin_collapsed
|
||||||
|
|
||||||
|
next_state = EnvState(
|
||||||
|
prices=prices,
|
||||||
|
demand=demand,
|
||||||
|
step_count=step_count,
|
||||||
|
low_margin_streak=next_streak,
|
||||||
|
last_agent_prob=agent_prob,
|
||||||
|
last_alpha_adv=alpha_adv,
|
||||||
|
)
|
||||||
|
obs = _flatten_obs(demand, prices)
|
||||||
|
info = {
|
||||||
|
"revenue": revenue,
|
||||||
|
"agent_prob": agent_prob,
|
||||||
|
"alpha_adv": alpha_adv,
|
||||||
|
"coi_leakage": leakage,
|
||||||
|
"coi_discount": discount,
|
||||||
|
"n_purchases": n_purchases,
|
||||||
|
"n_agents": n_agents,
|
||||||
|
"avg_margin": avg_margin,
|
||||||
|
}
|
||||||
|
return obs, next_state, reward, done, info
|
||||||
|
|
||||||
|
|
||||||
|
class PHANTOMJAXEnv:
|
||||||
|
def __init__(self, params: EnvParams):
|
||||||
|
self.params = params
|
||||||
|
|
||||||
|
def reset(self, key: jax.Array, params: EnvParams | None = None):
|
||||||
|
return reset_env(key, self.params if params is None else params)
|
||||||
|
|
||||||
|
def step(
|
||||||
|
self,
|
||||||
|
key: jax.Array,
|
||||||
|
state: EnvState,
|
||||||
|
action: jax.Array,
|
||||||
|
params: EnvParams | None = None,
|
||||||
|
):
|
||||||
|
return step_env(key, state, action, self.params if params is None else params)
|
||||||
|
|
||||||
|
def action_space_n(self, params: EnvParams | None = None) -> int:
|
||||||
|
p = self.params if params is None else params
|
||||||
|
return int(p.action_scales.shape[0])
|
||||||
|
|
||||||
|
def observation_dim(self, params: EnvParams | None = None) -> int:
|
||||||
|
p = self.params if params is None else params
|
||||||
|
return int(p.n_products * 2)
|
||||||
495
engine/jax/primitives.py
Normal file
495
engine/jax/primitives.py
Normal file
@@ -0,0 +1,495 @@
|
|||||||
|
"""JAX-compatible primitives for PHANTOM session simulation and separability."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import partial
|
||||||
|
from typing import Mapping, Sequence
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
import jax.numpy as jnp
|
||||||
|
|
||||||
|
JAX_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
jax = None # type: ignore[assignment]
|
||||||
|
jnp = np # type: ignore[assignment]
|
||||||
|
JAX_AVAILABLE = False
|
||||||
|
|
||||||
|
|
||||||
|
STATE_START_KEYS = ("session_start", "start")
|
||||||
|
TERMINAL_EVENT_TOKENS = (
|
||||||
|
"session_end",
|
||||||
|
"end",
|
||||||
|
"purchase_complete",
|
||||||
|
"checkout_start",
|
||||||
|
"checkout",
|
||||||
|
)
|
||||||
|
PURCHASE_EVENT_TOKENS = (
|
||||||
|
"purchase_complete",
|
||||||
|
"purchase",
|
||||||
|
"checkout_start",
|
||||||
|
"checkout",
|
||||||
|
)
|
||||||
|
|
||||||
|
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
||||||
|
ACTION_CATEGORIES = {
|
||||||
|
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
||||||
|
"dwell": {
|
||||||
|
"hover_title",
|
||||||
|
"hover_paragraph",
|
||||||
|
"hover_link",
|
||||||
|
"hover_over_title",
|
||||||
|
"hover_over_paragraph",
|
||||||
|
"hover_over_link",
|
||||||
|
"hover_over_button",
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"page_view",
|
||||||
|
"view_item",
|
||||||
|
"view",
|
||||||
|
"learn_more",
|
||||||
|
"learn_more_about_item",
|
||||||
|
"view_item_page",
|
||||||
|
"session_start",
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"search",
|
||||||
|
"filter_date",
|
||||||
|
"filter_price",
|
||||||
|
"sort",
|
||||||
|
"filter_for_date",
|
||||||
|
"filter_for_price",
|
||||||
|
"filter_for_amenities",
|
||||||
|
"sort_change",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
DEFAULT_ACTION_WEIGHTS = {
|
||||||
|
action: CATEGORY_WEIGHTS[group]
|
||||||
|
for group, actions in ACTION_CATEGORIES.items()
|
||||||
|
for action in actions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TransitionData:
|
||||||
|
"""Dense transition kernels and per-state metadata."""
|
||||||
|
|
||||||
|
human_T: np.ndarray
|
||||||
|
agent_T: np.ndarray
|
||||||
|
terminal_mask: np.ndarray
|
||||||
|
purchase_mask: np.ndarray
|
||||||
|
event_weights: np.ndarray
|
||||||
|
event_names: tuple[str, ...]
|
||||||
|
start_idx: int
|
||||||
|
term_idx: int
|
||||||
|
|
||||||
|
def to_jax(self) -> "TransitionData":
|
||||||
|
if not JAX_AVAILABLE:
|
||||||
|
return self
|
||||||
|
return TransitionData(
|
||||||
|
human_T=jnp.asarray(self.human_T),
|
||||||
|
agent_T=jnp.asarray(self.agent_T),
|
||||||
|
terminal_mask=jnp.asarray(self.terminal_mask),
|
||||||
|
purchase_mask=jnp.asarray(self.purchase_mask),
|
||||||
|
event_weights=jnp.asarray(self.event_weights),
|
||||||
|
event_names=self.event_names,
|
||||||
|
start_idx=int(self.start_idx),
|
||||||
|
term_idx=int(self.term_idx),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SessionBatch:
|
||||||
|
states: np.ndarray
|
||||||
|
products: np.ndarray
|
||||||
|
actors: np.ndarray
|
||||||
|
lengths: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
|
def _event_weight(name: str) -> float:
|
||||||
|
if name in DEFAULT_ACTION_WEIGHTS:
|
||||||
|
return float(DEFAULT_ACTION_WEIGHTS[name])
|
||||||
|
if name.startswith("hover"):
|
||||||
|
return float(CATEGORY_WEIGHTS["dwell"])
|
||||||
|
if name.startswith("filter") or name in {"search", "sort", "sort_change"}:
|
||||||
|
return float(CATEGORY_WEIGHTS["filter"])
|
||||||
|
if name.startswith("add") or name in {
|
||||||
|
"checkout",
|
||||||
|
"checkout_start",
|
||||||
|
"purchase",
|
||||||
|
"remove_item",
|
||||||
|
"purchase_complete",
|
||||||
|
}:
|
||||||
|
return float(CATEGORY_WEIGHTS["cart"])
|
||||||
|
if any(token in name for token in TERMINAL_EVENT_TOKENS):
|
||||||
|
return 0.0
|
||||||
|
return float(CATEGORY_WEIGHTS["nav"])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_terminal(name: str) -> bool:
|
||||||
|
return any(token in name for token in TERMINAL_EVENT_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_purchase(name: str) -> bool:
|
||||||
|
return any(token in name for token in PURCHASE_EVENT_TOKENS)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_events(*transitions: Mapping[str, Mapping[str, float]]) -> tuple[str, ...]:
|
||||||
|
names: set[str] = set()
|
||||||
|
for trans in transitions:
|
||||||
|
for src, dsts in trans.items():
|
||||||
|
names.add(src)
|
||||||
|
names.update(dsts.keys())
|
||||||
|
names.discard("__terminal__")
|
||||||
|
return tuple(sorted(names))
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_rows(matrix: np.ndarray, term_idx: int) -> np.ndarray:
|
||||||
|
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||||
|
dead_rows = np.isclose(row_sums.squeeze(-1), 0.0)
|
||||||
|
if np.any(dead_rows):
|
||||||
|
matrix[dead_rows] = 0.0
|
||||||
|
matrix[dead_rows, term_idx] = 1.0
|
||||||
|
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||||
|
return matrix / np.maximum(row_sums, 1e-8)
|
||||||
|
|
||||||
|
|
||||||
|
def _dense_from_dict(
|
||||||
|
transitions: Mapping[str, Mapping[str, float]],
|
||||||
|
event_to_idx: Mapping[str, int],
|
||||||
|
term_idx: int,
|
||||||
|
) -> np.ndarray:
|
||||||
|
n_states = len(event_to_idx)
|
||||||
|
matrix = np.zeros((n_states, n_states), dtype=np.float32)
|
||||||
|
for src, dsts in transitions.items():
|
||||||
|
i = event_to_idx.get(src)
|
||||||
|
if i is None:
|
||||||
|
continue
|
||||||
|
for dst, prob in dsts.items():
|
||||||
|
j = event_to_idx.get(dst)
|
||||||
|
if j is None:
|
||||||
|
continue
|
||||||
|
matrix[i, j] += float(prob)
|
||||||
|
return _normalize_rows(matrix, term_idx)
|
||||||
|
|
||||||
|
|
||||||
|
def compile_transition_data(
|
||||||
|
human_transitions: Mapping[str, Mapping[str, float]],
|
||||||
|
agent_transitions: Mapping[str, Mapping[str, float]],
|
||||||
|
) -> TransitionData:
|
||||||
|
event_names = _collect_events(human_transitions, agent_transitions)
|
||||||
|
if not event_names:
|
||||||
|
return fallback_transition_data()
|
||||||
|
|
||||||
|
event_names = tuple([*event_names, "__terminal__"])
|
||||||
|
term_idx = len(event_names) - 1
|
||||||
|
event_to_idx = {name: i for i, name in enumerate(event_names)}
|
||||||
|
|
||||||
|
human_T = _dense_from_dict(human_transitions, event_to_idx, term_idx)
|
||||||
|
agent_T = _dense_from_dict(agent_transitions, event_to_idx, term_idx)
|
||||||
|
|
||||||
|
terminal_mask = np.array([_is_terminal(name) for name in event_names], dtype=bool)
|
||||||
|
purchase_mask = np.array([_is_purchase(name) for name in event_names], dtype=bool)
|
||||||
|
event_weights = np.array(
|
||||||
|
[_event_weight(name) for name in event_names], dtype=np.float32
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal_mask[term_idx] = True
|
||||||
|
|
||||||
|
for idx, is_term in enumerate(terminal_mask):
|
||||||
|
if not is_term:
|
||||||
|
continue
|
||||||
|
human_T[idx] = 0.0
|
||||||
|
agent_T[idx] = 0.0
|
||||||
|
human_T[idx, idx] = 1.0
|
||||||
|
agent_T[idx, idx] = 1.0
|
||||||
|
|
||||||
|
start_idx = 0
|
||||||
|
for key in STATE_START_KEYS:
|
||||||
|
if key in event_to_idx:
|
||||||
|
start_idx = int(event_to_idx[key])
|
||||||
|
break
|
||||||
|
|
||||||
|
return TransitionData(
|
||||||
|
human_T=human_T,
|
||||||
|
agent_T=agent_T,
|
||||||
|
terminal_mask=terminal_mask,
|
||||||
|
purchase_mask=purchase_mask,
|
||||||
|
event_weights=event_weights,
|
||||||
|
event_names=event_names,
|
||||||
|
start_idx=start_idx,
|
||||||
|
term_idx=term_idx,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fallback_transition_data() -> TransitionData:
|
||||||
|
human = {
|
||||||
|
"session_start": {
|
||||||
|
"page_view": 0.80,
|
||||||
|
"view_item_page": 0.15,
|
||||||
|
"session_end": 0.05,
|
||||||
|
},
|
||||||
|
"page_view": {"view_item_page": 0.55, "search": 0.25, "session_end": 0.20},
|
||||||
|
"view_item_page": {
|
||||||
|
"learn_more_about_item": 0.40,
|
||||||
|
"add_item_to_cart": 0.28,
|
||||||
|
"session_end": 0.32,
|
||||||
|
},
|
||||||
|
"learn_more_about_item": {
|
||||||
|
"add_item_to_cart": 0.50,
|
||||||
|
"view_item_page": 0.30,
|
||||||
|
"session_end": 0.20,
|
||||||
|
},
|
||||||
|
"add_item_to_cart": {
|
||||||
|
"checkout_start": 0.58,
|
||||||
|
"view_item_page": 0.24,
|
||||||
|
"session_end": 0.18,
|
||||||
|
},
|
||||||
|
"checkout_start": {"purchase_complete": 0.70, "session_end": 0.30},
|
||||||
|
"purchase_complete": {"session_end": 1.0},
|
||||||
|
}
|
||||||
|
agent = {
|
||||||
|
"session_start": {
|
||||||
|
"page_view": 0.90,
|
||||||
|
"view_item_page": 0.08,
|
||||||
|
"session_end": 0.02,
|
||||||
|
},
|
||||||
|
"page_view": {"view_item_page": 0.40, "search": 0.35, "session_end": 0.25},
|
||||||
|
"view_item_page": {
|
||||||
|
"learn_more_about_item": 0.55,
|
||||||
|
"add_item_to_cart": 0.15,
|
||||||
|
"session_end": 0.30,
|
||||||
|
},
|
||||||
|
"learn_more_about_item": {
|
||||||
|
"view_item_page": 0.45,
|
||||||
|
"add_item_to_cart": 0.20,
|
||||||
|
"session_end": 0.35,
|
||||||
|
},
|
||||||
|
"add_item_to_cart": {
|
||||||
|
"checkout_start": 0.42,
|
||||||
|
"view_item_page": 0.28,
|
||||||
|
"session_end": 0.30,
|
||||||
|
},
|
||||||
|
"checkout_start": {"purchase_complete": 0.52, "session_end": 0.48},
|
||||||
|
"purchase_complete": {"session_end": 1.0},
|
||||||
|
}
|
||||||
|
return compile_transition_data(human, agent)
|
||||||
|
|
||||||
|
|
||||||
|
def load_transition_data(prefer_data: bool = True) -> TransitionData:
|
||||||
|
if not prefer_data:
|
||||||
|
return fallback_transition_data()
|
||||||
|
try:
|
||||||
|
from ..lib.behavior import get_transition_models
|
||||||
|
|
||||||
|
human_trans, agent_trans = get_transition_models()
|
||||||
|
return compile_transition_data(human_trans, agent_trans)
|
||||||
|
except Exception:
|
||||||
|
return fallback_transition_data()
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
|
||||||
|
@partial(jax.jit, static_argnums=(8, 9, 10))
|
||||||
|
def _sample_sessions_jax(
|
||||||
|
key: jax.Array,
|
||||||
|
human_T: jax.Array,
|
||||||
|
agent_T: jax.Array,
|
||||||
|
terminal_mask: jax.Array,
|
||||||
|
start_idx: int,
|
||||||
|
term_idx: int,
|
||||||
|
alpha: float,
|
||||||
|
n_products: int,
|
||||||
|
n_sessions: int,
|
||||||
|
max_steps: int,
|
||||||
|
n_states: int,
|
||||||
|
) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
|
||||||
|
k_actor, k_product, k_step = jax.random.split(key, 3)
|
||||||
|
start_idx_i32 = jnp.asarray(start_idx, dtype=jnp.int32)
|
||||||
|
term_idx_i32 = jnp.asarray(term_idx, dtype=jnp.int32)
|
||||||
|
actor_draw = jax.random.uniform(k_actor, (n_sessions,))
|
||||||
|
actors = (actor_draw < alpha).astype(jnp.int32)
|
||||||
|
products = jax.random.randint(
|
||||||
|
k_product, (n_sessions,), 0, n_products, dtype=jnp.int32
|
||||||
|
)
|
||||||
|
|
||||||
|
active_init = jnp.ones((n_sessions,), dtype=jnp.bool_)
|
||||||
|
state_init = jnp.full((n_sessions,), start_idx_i32, dtype=jnp.int32)
|
||||||
|
|
||||||
|
def _scan_step(carry, _):
|
||||||
|
states, active, rng = carry
|
||||||
|
rng, k = jax.random.split(rng)
|
||||||
|
probs_h = human_T[states]
|
||||||
|
probs_a = agent_T[states]
|
||||||
|
probs = jnp.where(actors[:, None] == 0, probs_h, probs_a)
|
||||||
|
next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1)
|
||||||
|
next_state = jnp.where(active, next_state, term_idx_i32)
|
||||||
|
emitted = jnp.where(active, next_state, -1)
|
||||||
|
is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)]
|
||||||
|
next_active = active & (~is_terminal)
|
||||||
|
carry_states = jnp.where(next_active, next_state, term_idx_i32)
|
||||||
|
return (carry_states, next_active, rng), emitted
|
||||||
|
|
||||||
|
_, state_t = jax.lax.scan(
|
||||||
|
_scan_step, (state_init, active_init, k_step), None, length=max_steps
|
||||||
|
)
|
||||||
|
states = state_t.T
|
||||||
|
lengths = jnp.sum(states >= 0, axis=1, dtype=jnp.int32)
|
||||||
|
return states, products, actors, lengths
|
||||||
|
|
||||||
|
|
||||||
|
def sample_sessions(
|
||||||
|
key,
|
||||||
|
transition_data: TransitionData,
|
||||||
|
alpha: float,
|
||||||
|
n_products: int,
|
||||||
|
n_sessions: int,
|
||||||
|
max_steps: int,
|
||||||
|
) -> SessionBatch:
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
td = transition_data.to_jax()
|
||||||
|
states, products, actors, lengths = _sample_sessions_jax(
|
||||||
|
key,
|
||||||
|
td.human_T,
|
||||||
|
td.agent_T,
|
||||||
|
td.terminal_mask,
|
||||||
|
int(td.start_idx),
|
||||||
|
int(td.term_idx),
|
||||||
|
float(alpha),
|
||||||
|
int(n_products),
|
||||||
|
int(n_sessions),
|
||||||
|
int(max_steps),
|
||||||
|
int(td.human_T.shape[0]),
|
||||||
|
)
|
||||||
|
return SessionBatch(
|
||||||
|
states=states, products=products, actors=actors, lengths=lengths
|
||||||
|
)
|
||||||
|
|
||||||
|
rng = np.random.default_rng(int(np.asarray(key).reshape(-1)[0]))
|
||||||
|
n_states = transition_data.human_T.shape[0]
|
||||||
|
products = rng.integers(0, n_products, size=n_sessions, dtype=np.int32)
|
||||||
|
actors = (rng.random(size=n_sessions) < alpha).astype(np.int32)
|
||||||
|
states = np.full((n_sessions, max_steps), -1, dtype=np.int32)
|
||||||
|
lengths = np.zeros((n_sessions,), dtype=np.int32)
|
||||||
|
for i in range(n_sessions):
|
||||||
|
current = int(transition_data.start_idx)
|
||||||
|
mat = transition_data.agent_T if actors[i] == 1 else transition_data.human_T
|
||||||
|
for t in range(max_steps):
|
||||||
|
nxt = int(rng.choice(n_states, p=mat[current]))
|
||||||
|
states[i, t] = nxt
|
||||||
|
if transition_data.terminal_mask[nxt]:
|
||||||
|
lengths[i] = t + 1
|
||||||
|
break
|
||||||
|
current = nxt
|
||||||
|
if lengths[i] == 0:
|
||||||
|
lengths[i] = max_steps
|
||||||
|
return SessionBatch(
|
||||||
|
states=states, products=products, actors=actors, lengths=lengths
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
|
||||||
|
@partial(jax.jit, static_argnums=(2,))
|
||||||
|
def compute_session_transitions(states, lengths, n_states: int):
|
||||||
|
src = states[:, :-1]
|
||||||
|
dst = states[:, 1:]
|
||||||
|
time_idx = jnp.arange(src.shape[1])[None, :]
|
||||||
|
valid = (src >= 0) & (dst >= 0) & (time_idx < (lengths[:, None] - 1))
|
||||||
|
src_clip = jnp.clip(src, 0, n_states - 1)
|
||||||
|
dst_clip = jnp.clip(dst, 0, n_states - 1)
|
||||||
|
src_oh = jax.nn.one_hot(src_clip, n_states)
|
||||||
|
dst_oh = jax.nn.one_hot(dst_clip, n_states)
|
||||||
|
counts = jnp.einsum(
|
||||||
|
"nti,ntj,nt->nij", src_oh, dst_oh, valid.astype(jnp.float32)
|
||||||
|
)
|
||||||
|
row_sums = jnp.sum(counts, axis=-1, keepdims=True)
|
||||||
|
return counts / (row_sums + 1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
def compute_session_transitions(states, lengths, n_states: int):
|
||||||
|
trans = np.zeros((states.shape[0], n_states, n_states), dtype=np.float32)
|
||||||
|
for i in range(states.shape[0]):
|
||||||
|
for t in range(max(int(lengths[i]) - 1, 0)):
|
||||||
|
s = int(states[i, t])
|
||||||
|
d = int(states[i, t + 1])
|
||||||
|
if s >= 0 and d >= 0:
|
||||||
|
trans[i, s, d] += 1.0
|
||||||
|
row_sums = trans.sum(axis=-1, keepdims=True)
|
||||||
|
return trans / (row_sums + 1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def batch_kl(P, Q_human, Q_agent, eps: float = 1e-10):
|
||||||
|
p = P + eps
|
||||||
|
p = p / jnp.sum(p, axis=-1, keepdims=True)
|
||||||
|
qh = Q_human[None, ...] + eps
|
||||||
|
qa = Q_agent[None, ...] + eps
|
||||||
|
delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2))
|
||||||
|
delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2))
|
||||||
|
return delta_h, delta_a
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
batch_kl = jax.jit(batch_kl)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_probability_from_kl(delta_h, delta_a, temperature: float = 1.0):
|
||||||
|
t = jnp.maximum(float(temperature), 1e-6)
|
||||||
|
exp_h = jnp.exp(-delta_h / t)
|
||||||
|
exp_a = jnp.exp(-delta_a / t)
|
||||||
|
return exp_a / (exp_h + exp_a + 1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_alpha_from_kl(delta_h, delta_a, beta: float = 2.0):
|
||||||
|
logits = beta * (delta_h - delta_a)
|
||||||
|
return 1.0 / (1.0 + jnp.exp(-logits))
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_demand(states, products, n_products: int, event_weights):
|
||||||
|
valid = states >= 0
|
||||||
|
state_clip = jnp.clip(states, 0, event_weights.shape[0] - 1)
|
||||||
|
weights = event_weights[state_clip] * valid
|
||||||
|
per_session = jnp.sum(weights, axis=1)
|
||||||
|
demand = jnp.zeros((n_products,), dtype=jnp.float32)
|
||||||
|
demand = demand.at[products].add(per_session)
|
||||||
|
total = jnp.sum(demand)
|
||||||
|
return jnp.where(total > 0.0, (demand / total) * 100.0, demand)
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
weighted_demand = jax.jit(weighted_demand, static_argnums=(2,))
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_flags(states, purchase_mask):
|
||||||
|
state_clip = jnp.clip(states, 0, purchase_mask.shape[0] - 1)
|
||||||
|
hits = purchase_mask[state_clip] & (states >= 0)
|
||||||
|
return jnp.any(hits, axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
purchase_flags = jax.jit(purchase_flags)
|
||||||
|
|
||||||
|
|
||||||
|
def revenue_from_demand(prices, demand):
|
||||||
|
return jnp.dot(prices, demand)
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
revenue_from_demand = jax.jit(revenue_from_demand)
|
||||||
|
|
||||||
|
|
||||||
|
def reward_with_coi_penalty(
|
||||||
|
revenue, agent_prob: float, lambda_coi: float, info_value: float
|
||||||
|
):
|
||||||
|
leakage = agent_prob * info_value
|
||||||
|
discount = jnp.clip(1.0 - lambda_coi * leakage, 0.0, 1.0)
|
||||||
|
return revenue * discount, leakage, discount
|
||||||
|
|
||||||
|
|
||||||
|
if JAX_AVAILABLE:
|
||||||
|
reward_with_coi_penalty = jax.jit(reward_with_coi_penalty)
|
||||||
5
engine/jax/requirements.txt
Normal file
5
engine/jax/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
flax==0.10.7
|
||||||
|
optax==0.2.7
|
||||||
|
distrax==0.1.5
|
||||||
|
orbax-checkpoint==0.11.32
|
||||||
|
chex==0.1.90
|
||||||
1319
engine/jax/train.py
Normal file
1319
engine/jax/train.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,14 @@
|
|||||||
from .demand import generate_demand, estimate_demand
|
from .demand import estimate_demand, estimate_weighted_demand, generate_demand_for_actor
|
||||||
from .behavior import sample_behavior
|
from .behavior import sample_behavior, get_transition_models, trajectory_to_events
|
||||||
from .render import DashboardRenderer, style_axis
|
from .render import DashboardRenderer, style_axis
|
||||||
|
from .wrappers import EconomicMetricsWrapper
|
||||||
|
from .callbacks import MetricsCallback, EvalMetricsCallback, CheckpointArtifactCallback
|
||||||
|
from .providers import (
|
||||||
|
ProviderBenchmark,
|
||||||
|
ProviderResult,
|
||||||
|
BenchmarkConfig,
|
||||||
|
RandomBaseline,
|
||||||
|
SurgeBaseline,
|
||||||
|
)
|
||||||
|
from .coi import compute_uplift_coi, extract_purchases, compute_agent_probability
|
||||||
|
from .discrete import EventQTable
|
||||||
|
|||||||
@@ -1,27 +1,107 @@
|
|||||||
from sim.rl.behavior_loader.models import BehaviorModel, AgentBehaviorModel, aggregate_event_transitions
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sim.rl.behavior_loader.models import (
|
||||||
|
BehaviorModel,
|
||||||
|
AgentBehaviorModel,
|
||||||
|
aggregate_event_transitions,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
BehaviorModel = None
|
||||||
|
AgentBehaviorModel = None
|
||||||
|
aggregate_event_transitions = None
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from .demand import generate_demand
|
from .demand import generate_demand_for_actor
|
||||||
|
|
||||||
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
|
base_dir = Path(__file__).parents[2] / "experiments"
|
||||||
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
|
human_dir = str(base_dir / "collected_data")
|
||||||
|
agent_dir = str(base_dir / "agents" / "collected_data")
|
||||||
|
|
||||||
_cache = {} # lazy cache for models and base pivots
|
_cache = {} # lazy cache for models and base pivots
|
||||||
|
|
||||||
|
|
||||||
def _get_base_pivot(human: bool):
|
def _get_base_pivot(human: bool):
|
||||||
key = 'human' if human else 'agent'
|
if (
|
||||||
|
BehaviorModel is None
|
||||||
|
or AgentBehaviorModel is None
|
||||||
|
or aggregate_event_transitions is None
|
||||||
|
):
|
||||||
|
raise ImportError("behavior loader dependencies are unavailable")
|
||||||
|
key = "human" if human else "agent"
|
||||||
if key not in _cache:
|
if key not in _cache:
|
||||||
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
|
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
|
||||||
mdp = model.build_MDP()
|
mdp = model.build_MDP()
|
||||||
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
|
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
|
||||||
return _cache[key]
|
return _cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_transition_models():
|
||||||
|
"""load human and agent transition models for agent probability calculation
|
||||||
|
|
||||||
|
returns:
|
||||||
|
tuple: (human_transitions, agent_transitions) as dicts of event->event->prob
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
BehaviorModel is None
|
||||||
|
or AgentBehaviorModel is None
|
||||||
|
or aggregate_event_transitions is None
|
||||||
|
):
|
||||||
|
raise ImportError("behavior loader dependencies are unavailable")
|
||||||
|
|
||||||
|
human_model = BehaviorModel(human_dir)
|
||||||
|
agent_model = AgentBehaviorModel(agent_dir)
|
||||||
|
|
||||||
|
human_mdp = human_model.build_MDP()
|
||||||
|
agent_mdp = agent_model.build_MDP()
|
||||||
|
|
||||||
|
human_trans = aggregate_event_transitions(human_mdp)
|
||||||
|
agent_trans = aggregate_event_transitions(agent_mdp)
|
||||||
|
|
||||||
|
return human_trans, agent_trans
|
||||||
|
|
||||||
|
|
||||||
|
def trajectory_to_events(trajectory: list) -> list:
|
||||||
|
"""extract event names from trajectory for KL divergence calculation
|
||||||
|
|
||||||
|
trajectories are in format 'eventName_product0', extract just eventName
|
||||||
|
|
||||||
|
args:
|
||||||
|
trajectory: list like ['view_product0', 'add_to_cart_product1', 'checkout_product1']
|
||||||
|
|
||||||
|
returns:
|
||||||
|
list: event names like ['view', 'add_to_cart', 'checkout']
|
||||||
|
"""
|
||||||
|
events = []
|
||||||
|
for state in trajectory:
|
||||||
|
# state format from sample_behavior: 'eventName_productX'
|
||||||
|
if "_product" in state:
|
||||||
|
event = state.rsplit("_product", 1)[0]
|
||||||
|
else:
|
||||||
|
event = state
|
||||||
|
events.append(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
def adjust_behavior_to_condition(condition, transition_matrix):
|
def adjust_behavior_to_condition(condition, transition_matrix):
|
||||||
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
|
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
|
||||||
cond_norm = condition / np.sum(condition)
|
condition = np.asarray(condition, dtype=float)
|
||||||
|
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
condition = np.clip(condition, 0.0, None)
|
||||||
|
s = float(np.sum(condition))
|
||||||
|
if not np.isfinite(s) or s <= 0:
|
||||||
|
cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
|
||||||
|
else:
|
||||||
|
cond_norm = condition / s
|
||||||
n_products = len(condition)
|
n_products = len(condition)
|
||||||
base_vals = transition_matrix.values
|
base_vals = transition_matrix.values
|
||||||
base_cols, base_rows = transition_matrix.columns.tolist(), transition_matrix.index.tolist()
|
base_cols, base_rows = (
|
||||||
|
transition_matrix.columns.tolist(),
|
||||||
|
transition_matrix.index.tolist(),
|
||||||
|
)
|
||||||
|
|
||||||
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
|
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
|
||||||
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
||||||
@@ -29,19 +109,26 @@ def adjust_behavior_to_condition(condition, transition_matrix):
|
|||||||
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
||||||
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||||
|
|
||||||
|
|
||||||
def sample_behavior(condition, human=True, max_len=40):
|
def sample_behavior(condition, human=True, max_len=40):
|
||||||
base_pivot = _get_base_pivot(human)
|
base_pivot = _get_base_pivot(human)
|
||||||
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
|
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
|
||||||
|
|
||||||
trajectory = [np.random.choice(adjusted_transitions.index)]
|
trajectory = [np.random.choice(adjusted_transitions.index)]
|
||||||
while len(trajectory) < max_len or 'checkout' in trajectory[-1]:
|
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
|
||||||
probs = adjusted_transitions.loc[trajectory[-1]].values
|
probs = np.asarray(adjusted_transitions.loc[trajectory[-1]].values, dtype=float)
|
||||||
sample = np.random.choice(adjusted_transitions.columns, p=probs/np.sum(probs) if np.sum(probs) > 0 else None)
|
probs = np.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
probs = np.clip(probs, 0.0, None)
|
||||||
|
s = float(np.sum(probs))
|
||||||
|
sample = np.random.choice(
|
||||||
|
adjusted_transitions.columns, p=(probs / s) if s > 0 else None
|
||||||
|
)
|
||||||
trajectory.append(sample)
|
trajectory.append(sample)
|
||||||
return trajectory
|
return trajectory
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
t=sample_behavior(generate_demand(np.array([10,20,30])), human=True)
|
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
|
||||||
print(t)
|
print(t)
|
||||||
t=sample_behavior(generate_demand(np.array([10,20,30])), human=False)
|
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
|
||||||
print(t)
|
print(t)
|
||||||
|
|||||||
182
engine/lib/callbacks.py
Normal file
182
engine/lib/callbacks.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Training callbacks for W&B/TensorBoard logging - reads from info dict."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from ..wandb_checkpoint import checkpoint_artifact_name, log_checkpoint_file
|
||||||
|
|
||||||
|
try:
|
||||||
|
import wandb
|
||||||
|
|
||||||
|
HAS_WANDB = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_WANDB = False
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsCallback(BaseCallback):
|
||||||
|
"""Training metrics logger - reads info['economics'], logs to W&B."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, log_histograms: bool = True, log_freq: int = 100, verbose: int = 0
|
||||||
|
):
|
||||||
|
super().__init__(verbose)
|
||||||
|
self.log_histograms = log_histograms
|
||||||
|
self.log_freq = log_freq
|
||||||
|
self._episode_revenues: list[float] = []
|
||||||
|
|
||||||
|
def _on_step(self) -> bool:
|
||||||
|
if not HAS_WANDB or wandb.run is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
for info in self.locals.get("infos", []):
|
||||||
|
if "economics" not in info:
|
||||||
|
continue
|
||||||
|
|
||||||
|
econ = info["economics"]
|
||||||
|
t = self.num_timesteps
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"economics/revenue": econ["revenue"],
|
||||||
|
"economics/margin": econ["margin"],
|
||||||
|
"coi/level": econ["coi_level"],
|
||||||
|
"economics/regret": econ["regret"],
|
||||||
|
}
|
||||||
|
if "coi_mix" in econ:
|
||||||
|
payload["coi/mix"] = econ["coi_mix"]
|
||||||
|
if "coi_base" in econ:
|
||||||
|
payload["coi/base"] = econ["coi_base"]
|
||||||
|
if "coi_leakage" in econ:
|
||||||
|
payload["coi/leakage"] = econ["coi_leakage"]
|
||||||
|
if "coi_penalty" in econ:
|
||||||
|
payload["coi/penalty"] = econ["coi_penalty"]
|
||||||
|
wandb.log(payload, step=t)
|
||||||
|
|
||||||
|
self._episode_revenues.append(econ["revenue"])
|
||||||
|
|
||||||
|
# histograms at log_freq intervals
|
||||||
|
if self.log_histograms and self.num_timesteps % self.log_freq == 0:
|
||||||
|
for info in self.locals.get("infos", []):
|
||||||
|
if "prices" in info:
|
||||||
|
wandb.log(
|
||||||
|
{"distributions/prices": wandb.Histogram(info["prices"])},
|
||||||
|
step=self.num_timesteps,
|
||||||
|
)
|
||||||
|
if "demand" in info:
|
||||||
|
wandb.log(
|
||||||
|
{"distributions/demand": wandb.Histogram(info["demand"])},
|
||||||
|
step=self.num_timesteps,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_rollout_end(self) -> None:
|
||||||
|
if not HAS_WANDB or wandb.run is None or not self._episode_revenues:
|
||||||
|
return
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
"episode/mean_revenue": np.mean(self._episode_revenues),
|
||||||
|
"episode/total_revenue": np.sum(self._episode_revenues),
|
||||||
|
},
|
||||||
|
step=self.num_timesteps,
|
||||||
|
)
|
||||||
|
self._episode_revenues = []
|
||||||
|
|
||||||
|
|
||||||
|
class CheckpointArtifactCallback(BaseCallback):
|
||||||
|
"""Periodic SB3 checkpoint uploader backed by W&B artifacts."""
|
||||||
|
|
||||||
|
def __init__(self, cfg: dict, interval: int = 10_000, verbose: int = 0):
|
||||||
|
super().__init__(verbose)
|
||||||
|
self.cfg = dict(cfg)
|
||||||
|
self.interval = max(1, int(interval))
|
||||||
|
self.model_dir = Path(str(self.cfg.get("model_dir", "engine/models")))
|
||||||
|
self.model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._next_checkpoint = self.interval
|
||||||
|
self._last_saved_step = -1
|
||||||
|
|
||||||
|
def _artifact_name(self) -> str:
|
||||||
|
sweep_id = (
|
||||||
|
getattr(wandb.run, "sweep_id", None)
|
||||||
|
if HAS_WANDB and wandb.run is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return checkpoint_artifact_name(self.cfg, backend="sb3", sweep_id=sweep_id)
|
||||||
|
|
||||||
|
def _checkpoint_file(self) -> Path:
|
||||||
|
algo = str(self.cfg.get("algo", "model"))
|
||||||
|
base = self.model_dir / f"phantom_{algo}_checkpoint"
|
||||||
|
self.model.save(str(base))
|
||||||
|
return base.with_suffix(".zip")
|
||||||
|
|
||||||
|
def _save_checkpoint(self) -> None:
|
||||||
|
if not HAS_WANDB or wandb.run is None:
|
||||||
|
return
|
||||||
|
step = int(self.num_timesteps)
|
||||||
|
if step <= self._last_saved_step:
|
||||||
|
return
|
||||||
|
checkpoint_path = self._checkpoint_file()
|
||||||
|
metadata = {
|
||||||
|
"step": step,
|
||||||
|
"algo": str(self.cfg.get("algo", "unknown")),
|
||||||
|
"sweep_id": getattr(wandb.run, "sweep_id", None),
|
||||||
|
}
|
||||||
|
saved = log_checkpoint_file(
|
||||||
|
self._artifact_name(),
|
||||||
|
file_path=checkpoint_path,
|
||||||
|
artifact_file_name=checkpoint_path.name,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
if saved:
|
||||||
|
self._last_saved_step = step
|
||||||
|
|
||||||
|
def _on_step(self) -> bool:
|
||||||
|
if self.num_timesteps < self._next_checkpoint:
|
||||||
|
return True
|
||||||
|
self._save_checkpoint()
|
||||||
|
while self._next_checkpoint <= self.num_timesteps:
|
||||||
|
self._next_checkpoint += self.interval
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _on_training_end(self) -> None:
|
||||||
|
self._save_checkpoint()
|
||||||
|
|
||||||
|
|
||||||
|
class EvalMetricsCallback(EvalCallback):
|
||||||
|
"""Deterministic evaluation - true performance without exploration noise."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, eval_env, eval_freq: int = 1000, n_eval_episodes: int = 5, **kwargs
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
eval_env, eval_freq=eval_freq, n_eval_episodes=n_eval_episodes, **kwargs
|
||||||
|
)
|
||||||
|
self._eval_revenues: list[float] = []
|
||||||
|
|
||||||
|
def _on_step(self) -> bool:
|
||||||
|
result = super()._on_step()
|
||||||
|
|
||||||
|
if not HAS_WANDB or wandb.run is None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
# log eval metrics after evaluation runs
|
||||||
|
if self.n_calls % self.eval_freq == 0 and hasattr(self, "last_mean_reward"):
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
"eval/mean_reward": self.last_mean_reward,
|
||||||
|
"eval/mean_revenue": np.mean(self._eval_revenues)
|
||||||
|
if self._eval_revenues
|
||||||
|
else 0,
|
||||||
|
},
|
||||||
|
step=self.num_timesteps,
|
||||||
|
)
|
||||||
|
self._eval_revenues = []
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _log_success_callback(self, locals_: dict, globals_: dict) -> None:
|
||||||
|
# called after each eval episode
|
||||||
|
info = locals_.get("info", {})
|
||||||
|
if "economics" in info:
|
||||||
|
self._eval_revenues.append(info["economics"]["revenue"])
|
||||||
76
engine/lib/coi.py
Normal file
76
engine/lib/coi.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import numpy as np
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
|
||||||
|
def compute_agent_probability(
|
||||||
|
trajectory: list, human_transitions: Dict, agent_transitions: Dict
|
||||||
|
) -> 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)
|
||||||
|
# agent_prob = exp(-kl_agent) / (exp(-kl_human) + exp(-kl_agent))
|
||||||
|
exp_h = np.exp(-kl_human)
|
||||||
|
exp_a = np.exp(-kl_agent)
|
||||||
|
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())
|
||||||
|
)
|
||||||
@@ -1,45 +1,92 @@
|
|||||||
import logging
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from logging import getLogger
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
def generate_demand(prices, distribution_method = np.random.normal, distribution_params = (50.0, 10.0)):
|
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
||||||
# assumption 1: each product has an intrinsic valuation drawn from a normal distribution centered at 50
|
ACTION_CATEGORIES = {
|
||||||
product_valuations = distribution_method(*distribution_params, size=len(prices))
|
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
||||||
# assumption 2: demand decreases as price increases, following a simple linear model
|
"dwell": {"hover_title", "hover_paragraph", "hover_link"},
|
||||||
demand = np.maximum(0, product_valuations - prices) # demand cannot be negative
|
"nav": {"page_view", "view_item", "view", "learn_more"},
|
||||||
|
"filter": {"search", "filter_date", "filter_price", "sort"},
|
||||||
|
}
|
||||||
|
DEFAULT_ACTION_WEIGHTS = {
|
||||||
|
a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_demand_for_actor(
|
||||||
|
prices: np.ndarray,
|
||||||
|
params: tuple,
|
||||||
|
noise_std: float = 1.0,
|
||||||
|
distribution_method=np.random.normal,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
|
||||||
|
params: (mean, std) for valuation distribution D_H or D_A"""
|
||||||
|
val = distribution_method(*params, size=len(prices))
|
||||||
|
noise = distribution_method(0, noise_std, len(prices))
|
||||||
|
demand = np.maximum(0, val - prices + noise)
|
||||||
total = np.sum(demand)
|
total = np.sum(demand)
|
||||||
demand = demand / total * 100 if total > 0 else demand # normalize to percentage, avoid div by zero
|
return demand / total * 100 if total > 0 else demand
|
||||||
logger.info(f"Generated demand for prices {prices}: {demand} with valuations from distribution {distribution_params}")
|
|
||||||
return demand
|
|
||||||
|
|
||||||
def estimate_demand(trajectories):
|
|
||||||
demand_estimate = {}
|
def estimate_demand(trajectories, action_weights=None):
|
||||||
|
return estimate_weighted_demand(trajectories, action_weights)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_state(state: str):
|
||||||
|
if "_product" not in state:
|
||||||
|
return state, None
|
||||||
|
action, raw_pid = state.rsplit("_product", 1)
|
||||||
|
return action, int(raw_pid) if raw_pid.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _weight_for_action(action: str, action_weights: dict) -> float:
|
||||||
|
if action in action_weights:
|
||||||
|
return action_weights[action]
|
||||||
|
if action.startswith("hover"):
|
||||||
|
return CATEGORY_WEIGHTS["dwell"]
|
||||||
|
if action.startswith("filter") or action in {"search", "sort"}:
|
||||||
|
return CATEGORY_WEIGHTS["filter"]
|
||||||
|
if action.startswith("add") or action in {"checkout", "purchase", "remove"}:
|
||||||
|
return CATEGORY_WEIGHTS["cart"]
|
||||||
|
return CATEGORY_WEIGHTS["nav"]
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_weighted_demand(trajectories, action_weights=None):
|
||||||
|
action_weights = (
|
||||||
|
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
|
||||||
|
)
|
||||||
|
scores = {}
|
||||||
for traj in trajectories:
|
for traj in trajectories:
|
||||||
for event in traj:
|
for state in traj:
|
||||||
if 'view_product' in event:
|
action, product_id = _parse_event_state(state)
|
||||||
product_id = int(event.split('_')[-1].replace('product', ''))
|
if product_id is None:
|
||||||
demand_estimate[product_id] = demand_estimate.get(product_id, 0) + 1
|
continue
|
||||||
total_views = sum(demand_estimate.values())
|
w = _weight_for_action(action, action_weights)
|
||||||
for product_id in demand_estimate:
|
if w <= 0:
|
||||||
demand_estimate[product_id] = (demand_estimate[product_id] / total_views) * 100 # normalize to percentage
|
continue
|
||||||
return demand_estimate
|
scores[product_id] = scores.get(product_id, 0.0) + w
|
||||||
|
total = sum(scores.values())
|
||||||
|
return (
|
||||||
|
{pid: (score / total) * 100 for pid, score in scores.items()}
|
||||||
|
if total > 0
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Example usage
|
# Example usage
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
np.random.seed(42)
|
np.random.seed(42)
|
||||||
prices = np.array([20.0, 35.0, 50.0, 65.0])
|
prices = np.array([20.0, 35.0, 50.0, 65.0])
|
||||||
demand = generate_demand(prices)
|
# demo actor-specific demands
|
||||||
print("Generated Demand:", demand)
|
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
|
from .behavior import sample_behavior
|
||||||
N, alphat =200, 0.1
|
|
||||||
trajectories = []
|
N, alpha = 200, 0.3
|
||||||
for _ in range(int(N*(1 - alphat))):
|
n_h, n_a = int(N * (1 - alpha)), int(N * alpha)
|
||||||
trajectories.append(sample_behavior(demand, human=True))
|
human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)]
|
||||||
for _ in range(int(N*alphat)):
|
agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)]
|
||||||
trajectories.append(sample_behavior(demand, human=False))
|
demand_estimate = estimate_demand(human_t + agent_t)
|
||||||
demand_estimate = estimate_demand(trajectories)
|
|
||||||
print("Estimated Demand from Behavior:", demand_estimate)
|
print("Estimated Demand from Behavior:", demand_estimate)
|
||||||
delta = {k: demand_estimate.get(k, 0) - demand[i] for i, k in enumerate(range(len(prices)))}
|
|
||||||
delta = np.mean([np.abs(v) for v in delta.values()])
|
|
||||||
print("Demand Delta:", delta)
|
|
||||||
|
|||||||
70
engine/lib/discrete.py
Normal file
70
engine/lib/discrete.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
import gymnasium as gym
|
||||||
|
from gymnasium import spaces
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class DiscretePriceActionWrapper(gym.ActionWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
env: gym.Env,
|
||||||
|
n_levels: int = 9,
|
||||||
|
min_scale: float = 0.8,
|
||||||
|
max_scale: float = 1.2,
|
||||||
|
):
|
||||||
|
super().__init__(env)
|
||||||
|
self.scales = np.linspace(min_scale, max_scale, n_levels, dtype=np.float32)
|
||||||
|
self.action_space = spaces.Discrete(n_levels)
|
||||||
|
|
||||||
|
def action(self, action: int):
|
||||||
|
scale = float(self.scales[int(action)])
|
||||||
|
cur = np.asarray(self.env.unwrapped._prices, dtype=np.float32)
|
||||||
|
lo, hi = self.env.unwrapped.price_bounds
|
||||||
|
return np.clip(cur * scale, lo, hi).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class EventQTable:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
n_actions: int,
|
||||||
|
n_products: int,
|
||||||
|
price_bounds: tuple,
|
||||||
|
lr: float = 0.1,
|
||||||
|
gamma: float = 0.99,
|
||||||
|
n_bins: int = 6,
|
||||||
|
):
|
||||||
|
self.n_actions = int(n_actions)
|
||||||
|
self.n_products = int(n_products)
|
||||||
|
self.lr = float(lr)
|
||||||
|
self.gamma = float(gamma)
|
||||||
|
self.q = defaultdict(lambda: np.zeros(self.n_actions, dtype=np.float32))
|
||||||
|
lo, hi = price_bounds
|
||||||
|
self.demand_bins = np.linspace(0.0, 100.0, n_bins + 1)[1:-1]
|
||||||
|
self.price_bins = np.linspace(lo, hi, n_bins + 1)[1:-1]
|
||||||
|
|
||||||
|
def encode(self, obs: np.ndarray) -> tuple:
|
||||||
|
obs = np.asarray(obs, dtype=np.float32)
|
||||||
|
d = obs[: self.n_products]
|
||||||
|
p = obs[self.n_products : 2 * self.n_products]
|
||||||
|
d_mean = float(np.mean(d)) if d.size else 0.0
|
||||||
|
d_std = float(np.std(d)) if d.size else 0.0
|
||||||
|
p_mean = float(np.mean(p)) if p.size else 0.0
|
||||||
|
return (
|
||||||
|
int(np.digitize(d_mean, self.demand_bins)),
|
||||||
|
int(np.digitize(d_std, self.demand_bins)),
|
||||||
|
int(np.digitize(p_mean, self.price_bins)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def act(self, obs: np.ndarray, eps: float = 0.0) -> tuple[int, tuple]:
|
||||||
|
s = self.encode(obs)
|
||||||
|
if np.random.random() < eps:
|
||||||
|
return int(np.random.randint(self.n_actions)), s
|
||||||
|
return int(np.argmax(self.q[s])), s
|
||||||
|
|
||||||
|
def update(self, s: tuple, a: int, r: float, s2: tuple, done: bool):
|
||||||
|
target = r + (0.0 if done else self.gamma * float(np.max(self.q[s2])))
|
||||||
|
self.q[s][a] += self.lr * (target - self.q[s][a])
|
||||||
|
|
||||||
|
def predict(self, obs: np.ndarray, deterministic: bool = True):
|
||||||
|
a, _ = self.act(obs, 0.0 if deterministic else 0.05)
|
||||||
|
return a, None
|
||||||
182
engine/lib/providers.py
Normal file
182
engine/lib/providers.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
"""Provider benchmarking - compare pricing strategies across contamination levels."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Any
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
try:
|
||||||
|
import wandb
|
||||||
|
|
||||||
|
HAS_WANDB = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_WANDB = False
|
||||||
|
|
||||||
|
|
||||||
|
class RandomBaseline:
|
||||||
|
"""uniform random action selection as a lower-bound baseline"""
|
||||||
|
|
||||||
|
def __init__(self, n_actions: int):
|
||||||
|
self.n = n_actions
|
||||||
|
|
||||||
|
def __call__(self, obs):
|
||||||
|
return int(np.random.randint(self.n))
|
||||||
|
|
||||||
|
def predict(self, obs, **kw):
|
||||||
|
return self(obs), None
|
||||||
|
|
||||||
|
|
||||||
|
class SurgeBaseline:
|
||||||
|
"""heuristic surge pricing: boost price when demand is above threshold, discount when below.
|
||||||
|
matches the naive pricing rule from thesis Section 3.3.2"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, n_actions: int, high_threshold: float = 60.0, low_threshold: float = 30.0
|
||||||
|
):
|
||||||
|
self.n = n_actions
|
||||||
|
self.mid = n_actions // 2 # identity action (scale ~1.0)
|
||||||
|
self.high_t = high_threshold
|
||||||
|
self.low_t = low_threshold
|
||||||
|
|
||||||
|
def __call__(self, obs):
|
||||||
|
obs = np.asarray(obs, dtype=np.float32)
|
||||||
|
n_prod = len(obs) // 2
|
||||||
|
demand_mean = float(np.mean(obs[:n_prod])) if n_prod > 0 else 0.0
|
||||||
|
if demand_mean >= self.high_t:
|
||||||
|
return min(self.mid + 2, self.n - 1) # surge: two levels above identity
|
||||||
|
if demand_mean <= self.low_t:
|
||||||
|
return max(self.mid - 2, 0) # discount: two levels below identity
|
||||||
|
return self.mid # hold
|
||||||
|
|
||||||
|
def predict(self, obs, **kw):
|
||||||
|
return self(obs), None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderResult:
|
||||||
|
"""Single benchmark result for one provider at one alpha level."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
alpha: float
|
||||||
|
total_revenue: float
|
||||||
|
mean_revenue: float
|
||||||
|
coi_level: float
|
||||||
|
coi_preserved_pct: float # vs alpha=0 baseline
|
||||||
|
margin_integrity: float
|
||||||
|
regret: float
|
||||||
|
episodes: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BenchmarkConfig:
|
||||||
|
"""Configuration for provider benchmark runs."""
|
||||||
|
|
||||||
|
n_episodes: int = 100
|
||||||
|
alpha_range: list[float] = field(default_factory=lambda: [0.0, 0.1, 0.3, 0.5])
|
||||||
|
baseline_name: str = "fixed"
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderBenchmark:
|
||||||
|
"""Compare pricing providers to prove margin preservation across contamination levels.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
def env_factory(alpha):
|
||||||
|
return EconomicMetricsWrapper(PHANTOM(alpha=alpha))
|
||||||
|
|
||||||
|
providers = {
|
||||||
|
"fixed": lambda obs: np.ones(10) * 50,
|
||||||
|
"learned": model.predict,
|
||||||
|
}
|
||||||
|
|
||||||
|
benchmark = ProviderBenchmark(env_factory, providers)
|
||||||
|
results = benchmark.run()
|
||||||
|
print(benchmark.summary_table())
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
env_factory: Callable[[float], Any],
|
||||||
|
providers: dict[str, Callable],
|
||||||
|
config: BenchmarkConfig | None = None,
|
||||||
|
):
|
||||||
|
self.env_factory = env_factory # fn(alpha) -> wrapped env
|
||||||
|
self.providers = providers # {name: fn(obs) -> action}
|
||||||
|
self.config = config or BenchmarkConfig()
|
||||||
|
self.results: list[ProviderResult] = []
|
||||||
|
|
||||||
|
def run(self) -> list[ProviderResult]:
|
||||||
|
"""Run benchmark across all providers and alpha levels."""
|
||||||
|
baseline_coi: dict[str, float] = {} # {provider: coi at alpha=0}
|
||||||
|
|
||||||
|
for alpha in self.config.alpha_range:
|
||||||
|
env = self.env_factory(alpha)
|
||||||
|
|
||||||
|
for name, policy_fn in self.providers.items():
|
||||||
|
revenues, coi_levels, margins = [], [], []
|
||||||
|
|
||||||
|
for _ in range(self.config.n_episodes):
|
||||||
|
obs, _ = env.reset()
|
||||||
|
episode_revenue = 0.0
|
||||||
|
done = False
|
||||||
|
|
||||||
|
while not done:
|
||||||
|
action = policy_fn(obs)
|
||||||
|
# handle sb3 model.predict returning tuple
|
||||||
|
if isinstance(action, tuple):
|
||||||
|
action = action[0]
|
||||||
|
obs, reward, term, trunc, info = env.step(action)
|
||||||
|
done = term or trunc
|
||||||
|
|
||||||
|
econ = info.get("economics", {})
|
||||||
|
episode_revenue += econ.get("revenue", 0)
|
||||||
|
coi_levels.append(econ.get("coi_level", 0))
|
||||||
|
margins.append(econ.get("margin", 0))
|
||||||
|
|
||||||
|
revenues.append(episode_revenue)
|
||||||
|
|
||||||
|
mean_coi = np.mean(coi_levels) if coi_levels else 0.0
|
||||||
|
if alpha == 0.0:
|
||||||
|
baseline_coi[name] = mean_coi
|
||||||
|
|
||||||
|
base = baseline_coi.get(name, mean_coi)
|
||||||
|
coi_preserved = mean_coi / base if base > 0 else 1.0
|
||||||
|
|
||||||
|
result = ProviderResult(
|
||||||
|
name=name,
|
||||||
|
alpha=alpha,
|
||||||
|
total_revenue=float(np.sum(revenues)),
|
||||||
|
mean_revenue=float(np.mean(revenues)),
|
||||||
|
coi_level=mean_coi,
|
||||||
|
coi_preserved_pct=coi_preserved * 100,
|
||||||
|
margin_integrity=float(np.mean(margins)) if margins else 0.0,
|
||||||
|
regret=0.0, # compute vs optimal if known
|
||||||
|
episodes=self.config.n_episodes,
|
||||||
|
)
|
||||||
|
self.results.append(result)
|
||||||
|
|
||||||
|
# log to wandb if available
|
||||||
|
if HAS_WANDB and wandb.run is not None:
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
f"benchmark/{name}/revenue": result.mean_revenue,
|
||||||
|
f"benchmark/{name}/coi_preserved": result.coi_preserved_pct,
|
||||||
|
f"benchmark/{name}/margin": result.margin_integrity,
|
||||||
|
"benchmark/alpha": alpha,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.results
|
||||||
|
|
||||||
|
def to_dataframe(self) -> pd.DataFrame:
|
||||||
|
"""Convert results to pandas DataFrame."""
|
||||||
|
return pd.DataFrame([r.__dict__ for r in self.results])
|
||||||
|
|
||||||
|
def summary_table(self) -> pd.DataFrame:
|
||||||
|
"""Pivot table: providers x alpha with revenue/COI metrics."""
|
||||||
|
df = self.to_dataframe()
|
||||||
|
return df.pivot_table(
|
||||||
|
index="name",
|
||||||
|
columns="alpha",
|
||||||
|
values=["mean_revenue", "coi_preserved_pct", "margin_integrity"],
|
||||||
|
aggfunc="mean",
|
||||||
|
)
|
||||||
77
engine/lib/wrappers.py
Normal file
77
engine/lib/wrappers.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""Economic metrics wrapper - calculates thesis-aligned KPIs and injects into info dict."""
|
||||||
|
|
||||||
|
import gymnasium as gym
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class EconomicMetricsWrapper(gym.Wrapper):
|
||||||
|
"""Calculates thesis-aligned economic metrics per step, injects into info.
|
||||||
|
|
||||||
|
Metrics follow thesis definitions:
|
||||||
|
- COI level: E[P] - p_min (Definition 1)
|
||||||
|
- Margin: (avg_price - p_min) / avg_price
|
||||||
|
- Regret: 1 - (revenue / baseline_revenue)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, env: gym.Env, p_min: float = 10.0, baseline_revenue: float | None = None
|
||||||
|
):
|
||||||
|
super().__init__(env)
|
||||||
|
self.p_min = p_min
|
||||||
|
self.baseline_revenue = baseline_revenue
|
||||||
|
self._price_history: list[np.ndarray] = []
|
||||||
|
self._revenue_history: list[float] = []
|
||||||
|
|
||||||
|
def reset(self, **kwargs):
|
||||||
|
obs, info = self.env.reset(**kwargs)
|
||||||
|
self._price_history = []
|
||||||
|
self._revenue_history = []
|
||||||
|
return obs, info
|
||||||
|
|
||||||
|
def step(self, action):
|
||||||
|
obs, reward, terminated, truncated, info = self.env.step(action)
|
||||||
|
|
||||||
|
# extract from unwrapped env
|
||||||
|
prices = self.env.unwrapped._prices
|
||||||
|
demand_dict = self.env.unwrapped._demand
|
||||||
|
demand = np.array([demand_dict.get(i, 0.0) for i in range(len(prices))])
|
||||||
|
alpha = self.env.unwrapped.alpha
|
||||||
|
|
||||||
|
# core calculations
|
||||||
|
revenue = float(np.sum(prices * demand))
|
||||||
|
avg_price = float(np.mean(prices))
|
||||||
|
margin = (avg_price - self.p_min) / max(avg_price, 1e-6)
|
||||||
|
coi_level = avg_price - self.p_min # E[P] - p_min per thesis Def 1
|
||||||
|
|
||||||
|
self._price_history.append(prices.copy())
|
||||||
|
self._revenue_history.append(revenue)
|
||||||
|
|
||||||
|
# regret vs baseline (golden path)
|
||||||
|
regret = 0.0
|
||||||
|
if self.baseline_revenue and self.baseline_revenue > 0:
|
||||||
|
regret = 1.0 - (revenue / self.baseline_revenue)
|
||||||
|
|
||||||
|
# inject structured metrics into info
|
||||||
|
info["economics"] = {
|
||||||
|
"revenue": revenue,
|
||||||
|
"margin": margin,
|
||||||
|
"coi_level": coi_level,
|
||||||
|
"regret": regret,
|
||||||
|
}
|
||||||
|
for key in ("coi_mix", "coi_base", "coi_leakage", "coi_penalty"):
|
||||||
|
if key in info:
|
||||||
|
info["economics"][key] = info[key]
|
||||||
|
info["prices"] = prices.copy()
|
||||||
|
info["demand"] = demand.copy()
|
||||||
|
|
||||||
|
return obs, reward, terminated, truncated, info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def episode_revenue(self) -> float:
|
||||||
|
return sum(self._revenue_history)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def episode_mean_price(self) -> float:
|
||||||
|
if not self._price_history:
|
||||||
|
return 0.0
|
||||||
|
return float(np.mean([np.mean(p) for p in self._price_history]))
|
||||||
@@ -56,7 +56,7 @@ def run_single(cfg: dict) -> dict:
|
|||||||
"id": cfg["id"],
|
"id": cfg["id"],
|
||||||
"config": cfg,
|
"config": cfg,
|
||||||
"total_reward": total_reward,
|
"total_reward": total_reward,
|
||||||
"avg_reward": total_reward / steps,
|
"avg_reward": total_reward / steps if steps > 0 else 0.0,
|
||||||
"steps": steps,
|
"steps": steps,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
engine/sweeps/model_mix.yaml
Normal file
84
engine/sweeps/model_mix.yaml
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
method: random
|
||||||
|
metric:
|
||||||
|
name: sweep/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: sweep/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
|
||||||
54
engine/sweeps/sac_tune.yaml
Normal file
54
engine/sweeps/sac_tune.yaml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
method: bayes
|
||||||
|
metric:
|
||||||
|
name: sweep/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: sweep/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]
|
||||||
93
engine/sweeps/tpu_jax.yaml
Normal file
93
engine/sweeps/tpu_jax.yaml
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
method: bayes
|
||||||
|
metric:
|
||||||
|
name: sweep/score
|
||||||
|
goal: maximize
|
||||||
|
command:
|
||||||
|
- ${env}
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- engine.train
|
||||||
|
parameters:
|
||||||
|
# fixed: always use JAX backend so TPU chips are actually exercised
|
||||||
|
use_jax:
|
||||||
|
value: true
|
||||||
|
# all four algos have JAX implementations
|
||||||
|
algo:
|
||||||
|
values: [ppo, a2c, dqn, qtable]
|
||||||
|
total_timesteps:
|
||||||
|
values: [50000, 80000, 120000]
|
||||||
|
checkpoint_interval:
|
||||||
|
value: 200000
|
||||||
|
seed:
|
||||||
|
values: [13, 42, 77]
|
||||||
|
n_products:
|
||||||
|
values: [8, 10, 12]
|
||||||
|
# COI framework parameters -- primary research variables
|
||||||
|
alpha:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.1
|
||||||
|
max: 0.6
|
||||||
|
lambda_coi:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.05
|
||||||
|
max: 0.6
|
||||||
|
robust_radius:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.0
|
||||||
|
max: 0.3
|
||||||
|
robust_points:
|
||||||
|
values: [3, 5, 7]
|
||||||
|
info_value:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.5
|
||||||
|
max: 2.0
|
||||||
|
revenue_weight:
|
||||||
|
values: [0.005, 0.01, 0.02]
|
||||||
|
# shared hyperparameters
|
||||||
|
learning_rate:
|
||||||
|
distribution: log_uniform_values
|
||||||
|
min: 1.0e-5
|
||||||
|
max: 1.0e-3
|
||||||
|
gamma:
|
||||||
|
values: [0.97, 0.99, 0.995]
|
||||||
|
# JAX parallelism -- key lever for TPU throughput
|
||||||
|
jax_num_envs:
|
||||||
|
values: [8, 16, 32]
|
||||||
|
jax_num_steps:
|
||||||
|
values: [64, 128, 256]
|
||||||
|
jax_num_minibatches:
|
||||||
|
values: [2, 4, 8]
|
||||||
|
jax_update_epochs:
|
||||||
|
values: [2, 4, 8]
|
||||||
|
# PPO/A2C specific
|
||||||
|
gae_lambda:
|
||||||
|
values: [0.9, 0.95, 0.98]
|
||||||
|
clip_range:
|
||||||
|
values: [0.1, 0.2, 0.3]
|
||||||
|
ent_coef:
|
||||||
|
values: [0.0, 0.005, 0.01]
|
||||||
|
# DQN specific
|
||||||
|
buffer_size:
|
||||||
|
values: [20000, 50000, 100000]
|
||||||
|
batch_size:
|
||||||
|
values: [128, 256, 512]
|
||||||
|
learning_starts:
|
||||||
|
values: [500, 1000, 3000]
|
||||||
|
exploration_fraction:
|
||||||
|
values: [0.1, 0.2, 0.3]
|
||||||
|
exploration_final_eps:
|
||||||
|
values: [0.01, 0.03, 0.05]
|
||||||
|
# QTable specific
|
||||||
|
q_lr:
|
||||||
|
values: [0.03, 0.05, 0.1, 0.2]
|
||||||
|
eps_end:
|
||||||
|
values: [0.02, 0.05, 0.1]
|
||||||
|
eps_decay:
|
||||||
|
values: [0.999, 0.9995, 0.9999]
|
||||||
|
# action space
|
||||||
|
action_levels:
|
||||||
|
values: [7, 9, 11]
|
||||||
|
action_scale_low:
|
||||||
|
values: [0.75, 0.8, 0.85]
|
||||||
|
action_scale_high:
|
||||||
|
values: [1.15, 1.2, 1.25]
|
||||||
64
engine/sweeps/tpu_pod.yaml
Normal file
64
engine/sweeps/tpu_pod.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
method: bayes
|
||||||
|
metric:
|
||||||
|
name: sweep/score
|
||||||
|
goal: maximize
|
||||||
|
command:
|
||||||
|
- ${env}
|
||||||
|
- python
|
||||||
|
- -m
|
||||||
|
- engine.train
|
||||||
|
parameters:
|
||||||
|
use_jax:
|
||||||
|
value: true
|
||||||
|
# pmap requires all workers to compile the same computation graph shape,
|
||||||
|
# so structural params are fixed -- only research/scalar params are swept
|
||||||
|
algo:
|
||||||
|
values: [ppo, a2c]
|
||||||
|
jax_num_envs:
|
||||||
|
value: 32
|
||||||
|
jax_num_steps:
|
||||||
|
value: 128
|
||||||
|
jax_num_minibatches:
|
||||||
|
value: 4
|
||||||
|
jax_update_epochs:
|
||||||
|
value: 4
|
||||||
|
total_timesteps:
|
||||||
|
value: 100000
|
||||||
|
checkpoint_interval:
|
||||||
|
value: 200000
|
||||||
|
n_products:
|
||||||
|
value: 10
|
||||||
|
action_levels:
|
||||||
|
value: 9
|
||||||
|
# research parameters -- primary sweep targets
|
||||||
|
alpha:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.1
|
||||||
|
max: 0.6
|
||||||
|
lambda_coi:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.05
|
||||||
|
max: 0.6
|
||||||
|
robust_radius:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.0
|
||||||
|
max: 0.3
|
||||||
|
info_value:
|
||||||
|
distribution: uniform
|
||||||
|
min: 0.5
|
||||||
|
max: 2.0
|
||||||
|
revenue_weight:
|
||||||
|
values: [0.005, 0.01, 0.02]
|
||||||
|
# training hyperparameters
|
||||||
|
learning_rate:
|
||||||
|
distribution: log_uniform_values
|
||||||
|
min: 1.0e-5
|
||||||
|
max: 1.0e-3
|
||||||
|
gamma:
|
||||||
|
values: [0.97, 0.99, 0.995]
|
||||||
|
gae_lambda:
|
||||||
|
values: [0.9, 0.95, 0.98]
|
||||||
|
clip_range:
|
||||||
|
values: [0.1, 0.2, 0.3]
|
||||||
|
ent_coef:
|
||||||
|
values: [0.0, 0.005, 0.01]
|
||||||
591
engine/train.py
591
engine/train.py
@@ -1,45 +1,568 @@
|
|||||||
from stable_baselines3 import SAC
|
from __future__ import annotations
|
||||||
from stable_baselines3.common.callbacks import EvalCallback, BaseCallback
|
|
||||||
from .wrapper import PHANTOM
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .wandb_checkpoint import checkpoint_artifact_name, download_latest_checkpoint
|
||||||
|
|
||||||
|
try:
|
||||||
|
import wandb as _wandb
|
||||||
|
|
||||||
|
if hasattr(_wandb, "init") and callable(_wandb.init):
|
||||||
|
wandb = _wandb
|
||||||
|
HAS_WANDB = True
|
||||||
|
else:
|
||||||
|
wandb = None
|
||||||
|
HAS_WANDB = False
|
||||||
|
except ImportError:
|
||||||
|
wandb = None
|
||||||
|
HAS_WANDB = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from stable_baselines3 import PPO, A2C, DQN
|
||||||
|
from stable_baselines3.common.callbacks import EvalCallback
|
||||||
|
from stable_baselines3.common.monitor import Monitor
|
||||||
|
|
||||||
|
HAS_SB3 = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_SB3 = False
|
||||||
|
|
||||||
|
from .jax import JAX_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
class RenderCallback(BaseCallback):
|
DEFAULT_CFG = {
|
||||||
"""Renders environment on every step for live visualization."""
|
"project": "phantom-pricing",
|
||||||
def __init__(self, env: PHANTOM):
|
"algo": "ppo",
|
||||||
super().__init__()
|
"seed": 42,
|
||||||
self.env = env
|
"total_timesteps": 50_000,
|
||||||
|
"eval_episodes": 5,
|
||||||
def _on_step(self) -> bool:
|
"eval_freq": 1_000,
|
||||||
self.env.render()
|
"log_freq": 100,
|
||||||
return True
|
"revenue_weight": 0.01,
|
||||||
|
"n_products": 10,
|
||||||
|
"N": 100,
|
||||||
|
"alpha": 0.3,
|
||||||
|
"lambda_coi": 0.2,
|
||||||
|
"robust_radius": 0.15,
|
||||||
|
"robust_points": 5,
|
||||||
|
"info_value": 1.0,
|
||||||
|
"price_low": 10.0,
|
||||||
|
"price_high": 150.0,
|
||||||
|
"action_levels": 9,
|
||||||
|
"action_scale_low": 0.8,
|
||||||
|
"action_scale_high": 1.2,
|
||||||
|
"learning_rate": 3e-4,
|
||||||
|
"gamma": 0.99,
|
||||||
|
"buffer_size": 50_000,
|
||||||
|
"batch_size": 256,
|
||||||
|
"tau": 0.005,
|
||||||
|
"train_freq": 1,
|
||||||
|
"learning_starts": 1_000,
|
||||||
|
"target_update_interval": 1_000,
|
||||||
|
"exploration_fraction": 0.2,
|
||||||
|
"exploration_final_eps": 0.05,
|
||||||
|
"n_steps": 2_048,
|
||||||
|
"n_epochs": 10,
|
||||||
|
"gae_lambda": 0.95,
|
||||||
|
"clip_range": 0.2,
|
||||||
|
"ent_coef": 0.0,
|
||||||
|
"q_lr": 0.1,
|
||||||
|
"eps_start": 1.0,
|
||||||
|
"eps_end": 0.05,
|
||||||
|
"eps_decay": 0.9995,
|
||||||
|
"model_dir": "engine/models",
|
||||||
|
"arch": "small",
|
||||||
|
"activation": "relu",
|
||||||
|
"q_bins": 6,
|
||||||
|
"max_steps": 100,
|
||||||
|
"margin_floor": 0.05,
|
||||||
|
"margin_floor_patience": 5,
|
||||||
|
"use_jax": False,
|
||||||
|
"jax_num_envs": 16,
|
||||||
|
"jax_num_steps": 128,
|
||||||
|
"jax_num_minibatches": 4,
|
||||||
|
"jax_update_epochs": 4,
|
||||||
|
"jax_anneal_lr": True,
|
||||||
|
"checkpoint_interval": 200_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
|
def _truthy(value: str | bool | None) -> bool:
|
||||||
eval_env = PHANTOM(n_products=10, alpha=0.3, render_mode=None)
|
if isinstance(value, bool): return value
|
||||||
|
if value is None: return False
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
model = SAC(
|
|
||||||
"MultiInputPolicy",
|
def _cfg(raw: dict | None = None) -> dict:
|
||||||
env,
|
cfg = dict(DEFAULT_CFG)
|
||||||
verbose=1,
|
if raw:
|
||||||
learning_rate=3e-4,
|
cfg.update({k: v for k, v in raw.items() if v is not None})
|
||||||
buffer_size=50000,
|
cfg["algo"] = str(cfg["algo"]).lower()
|
||||||
batch_size=256,
|
cfg["use_jax"] = _truthy(cfg.get("use_jax")) or _truthy(
|
||||||
tau=0.005,
|
os.environ.get("PHANTOM_USE_JAX")
|
||||||
gamma=0.99,
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _wandb_cfg_dict() -> dict:
|
||||||
|
return (
|
||||||
|
{k: wandb.config[k] for k in wandb.config.keys()}
|
||||||
|
if HAS_WANDB and wandb.run
|
||||||
|
else {}
|
||||||
)
|
)
|
||||||
|
|
||||||
render_cb = RenderCallback(env)
|
|
||||||
eval_cb = EvalCallback(eval_env, eval_freq=1000, n_eval_episodes=5, verbose=1)
|
|
||||||
|
|
||||||
model.learn(total_timesteps=50000, callback=[render_cb, eval_cb])
|
def make_env(cfg: dict):
|
||||||
model.save("phantom_sac")
|
from gymnasium.wrappers import FlattenObservation
|
||||||
|
|
||||||
# test trained policy
|
from .wrapper import PHANTOM
|
||||||
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
|
from .lib.wrappers import EconomicMetricsWrapper
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=int(cfg["n_products"]),
|
||||||
|
alpha=float(cfg["alpha"]),
|
||||||
|
N=int(cfg["N"]),
|
||||||
|
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
|
||||||
|
lambda_coi=float(cfg["lambda_coi"]),
|
||||||
|
robust_radius=float(cfg["robust_radius"]),
|
||||||
|
robust_points=int(cfg["robust_points"]),
|
||||||
|
info_value=float(cfg["info_value"]),
|
||||||
|
action_levels=int(cfg["action_levels"]),
|
||||||
|
action_scale_low=float(cfg["action_scale_low"]),
|
||||||
|
action_scale_high=float(cfg["action_scale_high"]),
|
||||||
|
max_steps=int(cfg.get("max_steps", 100)),
|
||||||
|
margin_floor=float(cfg.get("margin_floor", 0.05)),
|
||||||
|
margin_floor_patience=int(cfg.get("margin_floor_patience", 5)),
|
||||||
|
render_mode=None,
|
||||||
|
)
|
||||||
|
env = EconomicMetricsWrapper(env)
|
||||||
|
env = FlattenObservation(env)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _net_arch(name) -> list[int]:
|
||||||
|
presets = {
|
||||||
|
"tiny": [32, 32],
|
||||||
|
"small": [64, 64],
|
||||||
|
"medium": [128, 128],
|
||||||
|
"large": [256, 256],
|
||||||
|
}
|
||||||
|
if isinstance(name, (list, tuple)):
|
||||||
|
return [int(v) for v in name]
|
||||||
|
s = str(name).lower().strip()
|
||||||
|
if s in presets:
|
||||||
|
return presets[s]
|
||||||
|
if "x" in s:
|
||||||
|
try:
|
||||||
|
vals = [int(v) for v in s.split("x") if v]
|
||||||
|
return vals if vals else presets["small"]
|
||||||
|
except ValueError:
|
||||||
|
return presets["small"]
|
||||||
|
return presets["small"]
|
||||||
|
|
||||||
|
|
||||||
|
def _activation(name):
|
||||||
|
try:
|
||||||
|
import torch.nn as nn
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"relu": nn.ReLU,
|
||||||
|
"tanh": nn.Tanh,
|
||||||
|
"elu": nn.ELU,
|
||||||
|
"leaky_relu": nn.LeakyReLU,
|
||||||
|
}.get(str(name).lower().strip(), nn.ReLU)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_kwargs(cfg: dict) -> dict:
|
||||||
|
kw = {"net_arch": _net_arch(cfg.get("arch", "small"))}
|
||||||
|
act = _activation(cfg.get("activation", "relu"))
|
||||||
|
if act is not None:
|
||||||
|
kw["activation_fn"] = act
|
||||||
|
return kw
|
||||||
|
|
||||||
|
|
||||||
|
def _action(agent, obs, deterministic: bool = True):
|
||||||
|
out = agent.predict(obs, deterministic=deterministic)
|
||||||
|
a = out[0] if isinstance(out, tuple) else out
|
||||||
|
if isinstance(a, np.ndarray) and a.size == 1:
|
||||||
|
return int(a.reshape(-1)[0])
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(agent, env, episodes: int) -> dict:
|
||||||
|
rewards, revenues = [], []
|
||||||
|
for _ in range(int(episodes)):
|
||||||
obs, _ = env.reset()
|
obs, _ = env.reset()
|
||||||
for _ in range(100):
|
done, ep_r, ep_rev = False, 0.0, 0.0
|
||||||
action, _ = model.predict(obs, deterministic=True)
|
while not done:
|
||||||
obs, reward, term, trunc, _ = env.step(action)
|
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
|
||||||
env.render()
|
done = term or trunc
|
||||||
if term or trunc: break
|
ep_r += float(reward)
|
||||||
|
ep_rev += float(
|
||||||
|
info.get("economics", {}).get("revenue", info.get("revenue", 0.0))
|
||||||
|
)
|
||||||
|
rewards.append(ep_r)
|
||||||
|
revenues.append(ep_rev)
|
||||||
|
return {
|
||||||
|
"eval/reward": float(np.mean(rewards)),
|
||||||
|
"eval/revenue": float(np.mean(revenues)),
|
||||||
|
"eval/reward_std": float(np.std(rewards)),
|
||||||
|
"eval/revenue_std": float(np.std(revenues)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_model(cfg: dict, env):
|
||||||
|
algo = cfg["algo"]
|
||||||
|
policy_kwargs = _policy_kwargs(cfg)
|
||||||
|
if algo == "sac":
|
||||||
|
raise ValueError("sac is not supported with the discrete core env")
|
||||||
|
if algo == "ppo":
|
||||||
|
return PPO(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
n_steps=int(cfg["n_steps"]),
|
||||||
|
batch_size=int(cfg["batch_size"]),
|
||||||
|
n_epochs=int(cfg["n_epochs"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
gae_lambda=float(cfg["gae_lambda"]),
|
||||||
|
clip_range=float(cfg["clip_range"]),
|
||||||
|
ent_coef=float(cfg["ent_coef"]),
|
||||||
|
)
|
||||||
|
if algo == "a2c":
|
||||||
|
return A2C(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
n_steps=max(5, int(cfg["n_steps"]) // 32),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
gae_lambda=float(cfg["gae_lambda"]),
|
||||||
|
ent_coef=float(cfg["ent_coef"]),
|
||||||
|
)
|
||||||
|
if algo == "dqn":
|
||||||
|
return DQN(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
buffer_size=int(cfg["buffer_size"]),
|
||||||
|
batch_size=int(cfg["batch_size"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
train_freq=int(cfg["train_freq"]),
|
||||||
|
learning_starts=int(cfg["learning_starts"]),
|
||||||
|
target_update_interval=int(cfg["target_update_interval"]),
|
||||||
|
exploration_fraction=float(cfg["exploration_fraction"]),
|
||||||
|
exploration_final_eps=float(cfg["exploration_final_eps"]),
|
||||||
|
)
|
||||||
|
raise ValueError(f"unsupported algo '{algo}'")
|
||||||
|
|
||||||
|
|
||||||
|
def _sb3_model_cls(algo: str):
|
||||||
|
if algo == "ppo":
|
||||||
|
return PPO
|
||||||
|
if algo == "a2c":
|
||||||
|
return A2C
|
||||||
|
if algo == "dqn":
|
||||||
|
return DQN
|
||||||
|
raise ValueError(f"unsupported algo '{algo}'")
|
||||||
|
|
||||||
|
|
||||||
|
def train_qtable(cfg: dict) -> tuple[EventQTable, dict]:
|
||||||
|
from .lib.discrete import EventQTable
|
||||||
|
|
||||||
|
np.random.seed(int(cfg["seed"]))
|
||||||
|
env = make_env(cfg)
|
||||||
|
eval_env = make_env(cfg)
|
||||||
|
agent = EventQTable(
|
||||||
|
env.action_space.n,
|
||||||
|
int(cfg["n_products"]),
|
||||||
|
(float(cfg["price_low"]), float(cfg["price_high"])),
|
||||||
|
lr=float(cfg["q_lr"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
n_bins=int(cfg["q_bins"]),
|
||||||
|
)
|
||||||
|
eps = float(cfg["eps_start"])
|
||||||
|
obs, _ = env.reset(seed=int(cfg["seed"]))
|
||||||
|
for t in range(int(cfg["total_timesteps"])):
|
||||||
|
a, s = agent.act(obs, eps)
|
||||||
|
nxt, reward, term, trunc, info = env.step(a)
|
||||||
|
done = term or trunc
|
||||||
|
agent.update(s, a, float(reward), agent.encode(nxt), done)
|
||||||
|
eps = max(float(cfg["eps_end"]), eps * float(cfg["eps_decay"]))
|
||||||
|
if HAS_WANDB and wandb.run and (t + 1) % int(cfg["log_freq"]) == 0:
|
||||||
|
econ = info.get("economics", {})
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
"train/reward": float(reward),
|
||||||
|
"train/revenue": float(econ.get("revenue", 0.0)),
|
||||||
|
"train/epsilon": float(eps),
|
||||||
|
},
|
||||||
|
step=t + 1,
|
||||||
|
)
|
||||||
|
obs = env.reset()[0] if done else nxt
|
||||||
|
metrics = evaluate(agent, eval_env, int(cfg["eval_episodes"]))
|
||||||
|
metrics["train/global_step"] = int(cfg["total_timesteps"])
|
||||||
env.close()
|
env.close()
|
||||||
|
eval_env.close()
|
||||||
|
return agent, metrics
|
||||||
|
|
||||||
|
|
||||||
|
def train_sb3(cfg: dict) -> tuple[object, dict]:
|
||||||
|
if not HAS_SB3:
|
||||||
|
raise ImportError("stable-baselines3 is required for SB3 models")
|
||||||
|
from .lib.callbacks import CheckpointArtifactCallback, MetricsCallback
|
||||||
|
|
||||||
|
env = make_env(cfg)
|
||||||
|
eval_env = make_env(cfg)
|
||||||
|
env = Monitor(env)
|
||||||
|
eval_env = Monitor(eval_env)
|
||||||
|
model = build_model(cfg, env)
|
||||||
|
resume_step = 0
|
||||||
|
if HAS_WANDB and wandb.run is not None:
|
||||||
|
sweep_id = getattr(wandb.run, "sweep_id", None)
|
||||||
|
artifact_name = checkpoint_artifact_name(cfg, backend="sb3", sweep_id=sweep_id)
|
||||||
|
checkpoint_file = f"phantom_{cfg['algo']}_checkpoint.zip"
|
||||||
|
restored = download_latest_checkpoint(artifact_name, file_name=checkpoint_file)
|
||||||
|
if restored is not None:
|
||||||
|
checkpoint_path, metadata = restored
|
||||||
|
model = _sb3_model_cls(cfg["algo"]).load(
|
||||||
|
checkpoint_path.as_posix(), env=env
|
||||||
|
)
|
||||||
|
resume_step = int(metadata.get("step", getattr(model, "num_timesteps", 0)))
|
||||||
|
model.num_timesteps = max(
|
||||||
|
int(getattr(model, "num_timesteps", 0)), resume_step
|
||||||
|
)
|
||||||
|
|
||||||
|
cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))]
|
||||||
|
cbs.append(
|
||||||
|
CheckpointArtifactCallback(
|
||||||
|
cfg,
|
||||||
|
interval=int(cfg.get("checkpoint_interval", 10_000)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cbs.append(
|
||||||
|
EvalCallback(
|
||||||
|
eval_env,
|
||||||
|
eval_freq=int(cfg["eval_freq"]),
|
||||||
|
n_eval_episodes=int(cfg["eval_episodes"]),
|
||||||
|
deterministic=True,
|
||||||
|
verbose=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_steps = int(cfg["total_timesteps"])
|
||||||
|
remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0)))
|
||||||
|
if remaining_steps > 0:
|
||||||
|
model.learn(
|
||||||
|
total_timesteps=remaining_steps,
|
||||||
|
callback=cbs,
|
||||||
|
reset_num_timesteps=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = Path(cfg["model_dir"])
|
||||||
|
model_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
model.save(str(model_path / f"phantom_{cfg['algo']}"))
|
||||||
|
metrics = evaluate(model, eval_env, int(cfg["eval_episodes"]))
|
||||||
|
metrics["train/global_step"] = int(model.num_timesteps)
|
||||||
|
env.close()
|
||||||
|
eval_env.close()
|
||||||
|
return model, metrics
|
||||||
|
|
||||||
|
|
||||||
|
def train_once(cfg: dict) -> dict:
|
||||||
|
algo = cfg["algo"]
|
||||||
|
if cfg.get("use_jax"):
|
||||||
|
if not JAX_AVAILABLE:
|
||||||
|
raise ImportError(
|
||||||
|
"JAX backend requested but JAX is not installed. "
|
||||||
|
"Install engine/jax/requirements.txt and jax[tpu] for TPU runs."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from .jax.train import train_jax
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
raise ImportError(f"Failed to import JAX trainer: {exc}") from exc
|
||||||
|
_, metrics = train_jax(cfg)
|
||||||
|
elif algo == "qtable":
|
||||||
|
_, metrics = train_qtable(cfg)
|
||||||
|
else:
|
||||||
|
_, metrics = train_sb3(cfg)
|
||||||
|
metrics["sweep/score"] = float(
|
||||||
|
metrics["eval/reward"] + float(cfg["revenue_weight"]) * metrics["eval/revenue"]
|
||||||
|
)
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def run_wandb(
|
||||||
|
project: str, overrides: dict, mode: str = "online", sweep_mode: bool = False
|
||||||
|
) -> dict:
|
||||||
|
if not HAS_WANDB:
|
||||||
|
raise ImportError("wandb is required for sweep runs")
|
||||||
|
if not sweep_mode:
|
||||||
|
pre_cfg = _cfg(overrides)
|
||||||
|
if pre_cfg.get("use_jax"):
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
|
||||||
|
if jax.process_count() > 1 and jax.process_index() != 0:
|
||||||
|
return train_once(pre_cfg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
init_kwargs = {"mode": mode}
|
||||||
|
if sweep_mode:
|
||||||
|
run = wandb.init(**init_kwargs)
|
||||||
|
else:
|
||||||
|
run = wandb.init(project=project, config=overrides, **init_kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = _cfg(_wandb_cfg_dict())
|
||||||
|
if sweep_mode:
|
||||||
|
for k, v in overrides.items():
|
||||||
|
if k not in wandb.config:
|
||||||
|
cfg[k] = v
|
||||||
|
|
||||||
|
metrics = train_once(cfg)
|
||||||
|
step = int(metrics.get("train/global_step", cfg["total_timesteps"]))
|
||||||
|
wandb.log(metrics, step=step)
|
||||||
|
for k, v in metrics.items():
|
||||||
|
run.summary[k] = v
|
||||||
|
return metrics
|
||||||
|
finally:
|
||||||
|
if wandb.run is not None:
|
||||||
|
wandb.finish()
|
||||||
|
|
||||||
|
|
||||||
|
def run_local(overrides: dict) -> dict:
|
||||||
|
cfg = _cfg(overrides)
|
||||||
|
metrics = train_once(cfg)
|
||||||
|
should_print = True
|
||||||
|
if cfg.get("use_jax"):
|
||||||
|
try:
|
||||||
|
import jax
|
||||||
|
|
||||||
|
should_print = jax.process_index() == 0
|
||||||
|
except Exception:
|
||||||
|
should_print = True
|
||||||
|
if should_print:
|
||||||
|
print(json.dumps(metrics, indent=2))
|
||||||
|
# sentinel line for machine-readable extraction; must stay on one line
|
||||||
|
print("PHANTOM_METRICS:" + json.dumps(metrics))
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
p = argparse.ArgumentParser(description="PHANTOM training and W&B sweeps")
|
||||||
|
p.add_argument("--project", default=DEFAULT_CFG["project"])
|
||||||
|
p.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable"])
|
||||||
|
p.add_argument("--seed", type=int)
|
||||||
|
p.add_argument("--total-timesteps", type=int)
|
||||||
|
p.add_argument("--alpha", type=float)
|
||||||
|
p.add_argument("--N", type=int)
|
||||||
|
p.add_argument("--n-products", type=int)
|
||||||
|
p.add_argument("--lambda-coi", type=float)
|
||||||
|
p.add_argument("--info-value", type=float)
|
||||||
|
p.add_argument("--robust-radius", type=float)
|
||||||
|
p.add_argument("--robust-points", type=int)
|
||||||
|
p.add_argument("--learning-rate", type=float)
|
||||||
|
p.add_argument("--gamma", type=float)
|
||||||
|
p.add_argument("--gae-lambda", type=float)
|
||||||
|
p.add_argument("--clip-range", type=float)
|
||||||
|
p.add_argument("--ent-coef", type=float)
|
||||||
|
p.add_argument("--revenue-weight", type=float)
|
||||||
|
p.add_argument("--price-low", type=float)
|
||||||
|
p.add_argument("--price-high", type=float)
|
||||||
|
p.add_argument("--action-levels", type=int)
|
||||||
|
p.add_argument("--action-scale-low", type=float)
|
||||||
|
p.add_argument("--action-scale-high", type=float)
|
||||||
|
p.add_argument("--max-steps", type=int)
|
||||||
|
p.add_argument("--margin-floor", type=float)
|
||||||
|
p.add_argument("--margin-floor-patience", type=int)
|
||||||
|
p.add_argument("--arch", type=str)
|
||||||
|
p.add_argument("--activation", type=str)
|
||||||
|
p.add_argument("--jax", action="store_true")
|
||||||
|
p.add_argument("--jax-num-envs", type=int)
|
||||||
|
p.add_argument("--jax-num-steps", type=int)
|
||||||
|
p.add_argument("--jax-num-minibatches", type=int)
|
||||||
|
p.add_argument("--jax-update-epochs", type=int)
|
||||||
|
p.add_argument("--jax-anneal-lr", type=str)
|
||||||
|
p.add_argument("--checkpoint-interval", type=int)
|
||||||
|
p.add_argument("--sweep-agent", action="store_true")
|
||||||
|
p.add_argument("--sweep-id", type=str)
|
||||||
|
p.add_argument("--count", type=int, default=0)
|
||||||
|
p.add_argument("--offline", action="store_true")
|
||||||
|
p.add_argument("--no-wandb", action="store_true")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
overrides = {
|
||||||
|
"algo": args.algo,
|
||||||
|
"seed": args.seed,
|
||||||
|
"total_timesteps": args.total_timesteps,
|
||||||
|
"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,
|
||||||
|
"learning_rate": args.learning_rate,
|
||||||
|
"gamma": args.gamma,
|
||||||
|
"gae_lambda": args.gae_lambda,
|
||||||
|
"clip_range": args.clip_range,
|
||||||
|
"ent_coef": args.ent_coef,
|
||||||
|
"revenue_weight": args.revenue_weight,
|
||||||
|
"price_low": args.price_low,
|
||||||
|
"price_high": args.price_high,
|
||||||
|
"action_levels": args.action_levels,
|
||||||
|
"action_scale_low": args.action_scale_low,
|
||||||
|
"action_scale_high": args.action_scale_high,
|
||||||
|
"max_steps": args.max_steps,
|
||||||
|
"margin_floor": args.margin_floor,
|
||||||
|
"margin_floor_patience": args.margin_floor_patience,
|
||||||
|
"arch": args.arch,
|
||||||
|
"activation": args.activation,
|
||||||
|
"use_jax": args.jax,
|
||||||
|
"jax_num_envs": args.jax_num_envs,
|
||||||
|
"jax_num_steps": args.jax_num_steps,
|
||||||
|
"jax_num_minibatches": args.jax_num_minibatches,
|
||||||
|
"jax_update_epochs": args.jax_update_epochs,
|
||||||
|
"checkpoint_interval": args.checkpoint_interval,
|
||||||
|
"jax_anneal_lr": _truthy(args.jax_anneal_lr)
|
||||||
|
if args.jax_anneal_lr is not None
|
||||||
|
else None,
|
||||||
|
}
|
||||||
|
overrides = {k: v for k, v in overrides.items() if v is not None}
|
||||||
|
|
||||||
|
if args.sweep_agent:
|
||||||
|
if args.no_wandb:
|
||||||
|
raise ValueError("sweep agent requires wandb")
|
||||||
|
if not args.sweep_id:
|
||||||
|
raise ValueError("--sweep-id is required with --sweep-agent")
|
||||||
|
mode = "offline" if args.offline else "online"
|
||||||
|
wandb.agent(
|
||||||
|
args.sweep_id,
|
||||||
|
function=lambda: run_wandb(
|
||||||
|
args.project, overrides, mode=mode, sweep_mode=True
|
||||||
|
),
|
||||||
|
count=args.count if args.count > 0 else None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.no_wandb or not HAS_WANDB:
|
||||||
|
run_local(overrides)
|
||||||
|
return
|
||||||
|
|
||||||
|
run_wandb(args.project, overrides, mode="offline" if args.offline else "online")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
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
|
||||||
@@ -3,39 +3,108 @@ from gymnasium import spaces
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from .engine import Limbo, MarketEngine, PricingEngine
|
from .engine import Limbo, MarketEngine, PricingEngine
|
||||||
from .lib.render import DashboardRenderer
|
from .lib.render import DashboardRenderer
|
||||||
|
from .lib.coi import (
|
||||||
|
compute_uplift_coi,
|
||||||
|
extract_purchases,
|
||||||
|
compute_agent_probability,
|
||||||
|
)
|
||||||
|
from .lib.behavior import get_transition_models, trajectory_to_events
|
||||||
|
from .lib.wrappers import EconomicMetricsWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class _ActionPricingEngine(PricingEngine):
|
||||||
|
def __init__(self, n_products: int, price_bounds: tuple):
|
||||||
|
self._prices = np.full(n_products, price_bounds[0], dtype=float)
|
||||||
|
|
||||||
|
def set_prices(self, prices: np.ndarray):
|
||||||
|
self._prices = np.asarray(prices, dtype=float)
|
||||||
|
|
||||||
|
def act(self, _):
|
||||||
|
return self._prices
|
||||||
|
|
||||||
|
|
||||||
class PHANTOM(gym.Env):
|
class PHANTOM(gym.Env):
|
||||||
"""Gymnasium wrapper for the Limbo pricing-market simulation. Platform sets prices, market responds with demand."""
|
"""Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework
|
||||||
|
|
||||||
|
reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL
|
||||||
|
COI_leak uses behavioral divergence to estimate agent probability f(τ')
|
||||||
|
robust inner step: min over alpha in Wasserstein interval around nominal alpha
|
||||||
|
actions are discrete global price-scale moves
|
||||||
|
"""
|
||||||
|
|
||||||
metadata = {"render_modes": ["human", "ansi"]}
|
metadata = {"render_modes": ["human", "ansi"]}
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(
|
||||||
|
self,
|
||||||
n_products: int = 10,
|
n_products: int = 10,
|
||||||
alpha: float = 0.3,
|
alpha: float = 0.3,
|
||||||
N: int = 100,
|
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),
|
price_bounds: tuple = (10.0, 150.0),
|
||||||
lambda_coi: float = 0.1,
|
lambda_coi: float = 0.1,
|
||||||
render_mode: str = None):
|
coi_window: int = 10,
|
||||||
|
robust_radius: float = 0.0,
|
||||||
|
robust_points: int = 5,
|
||||||
|
info_value: 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__()
|
super().__init__()
|
||||||
self.n_products = n_products
|
self.n_products = n_products
|
||||||
self.price_bounds = price_bounds
|
self.price_bounds = price_bounds
|
||||||
self.lambda_coi = lambda_coi
|
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.render_mode = render_mode
|
||||||
self.alpha = alpha
|
self.alpha = float(alpha)
|
||||||
|
self.nominal_alpha = float(alpha)
|
||||||
self.N = N
|
self.N = N
|
||||||
|
self.human_params = human_params
|
||||||
self.market = MarketEngine(alpha=alpha, N=N)
|
self.agent_params = agent_params
|
||||||
self._platform_stub = PricingEngine()
|
self.robust_radius = max(0.0, float(robust_radius))
|
||||||
self._limbo = Limbo(self._platform_stub, self.market)
|
self.robust_points = max(1, int(robust_points))
|
||||||
|
self.info_value = float(info_value)
|
||||||
self.action_space = spaces.Box(
|
self.action_levels = max(2, int(action_levels))
|
||||||
low=price_bounds[0], high=price_bounds[1],
|
self._action_scales = np.linspace(
|
||||||
shape=(n_products,), dtype=np.float32
|
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,
|
||||||
|
),
|
||||||
|
}
|
||||||
)
|
)
|
||||||
self.observation_space = spaces.Dict({
|
|
||||||
"demand": spaces.Box(low=0.0, high=100.0, shape=(n_products,), dtype=np.float32),
|
|
||||||
"prices": spaces.Box(low=price_bounds[0], high=price_bounds[1], shape=(n_products,), dtype=np.float32),
|
|
||||||
})
|
|
||||||
|
|
||||||
self._prices = None
|
self._prices = None
|
||||||
self._demand = None
|
self._demand = None
|
||||||
@@ -44,41 +113,179 @@ class PHANTOM(gym.Env):
|
|||||||
self._price_history = []
|
self._price_history = []
|
||||||
self._revenue_history = []
|
self._revenue_history = []
|
||||||
self._renderer = None
|
self._renderer = None
|
||||||
|
self._initial_episode_prices = None
|
||||||
|
self._trajectories = [] # session trajectories for agent prob calculation
|
||||||
|
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
|
||||||
|
self._low_margin_streak = 0 # consecutive steps below margin_floor
|
||||||
|
|
||||||
|
# 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:
|
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)
|
demand_arr = np.array(
|
||||||
|
[self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32
|
||||||
|
)
|
||||||
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
|
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
|
||||||
|
|
||||||
def _compute_reward(self, prices: np.ndarray, demand: dict) -> float:
|
def _set_market_mix(self, alpha: float):
|
||||||
revenue = np.sum(prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)]))
|
alpha = float(np.clip(alpha, 0.0, 1.0))
|
||||||
# TODO: implement supra-competitive price punishment
|
n_agents = int(self.N * alpha)
|
||||||
return float(revenue)
|
self.alpha = alpha
|
||||||
|
self.market.alpha = alpha
|
||||||
|
self.market.Nagents = n_agents
|
||||||
|
self.market.Nhumans = self.N - n_agents
|
||||||
|
|
||||||
|
def _decode_action(self, action) -> np.ndarray:
|
||||||
|
base = (
|
||||||
|
self._prices
|
||||||
|
if self._prices is not None
|
||||||
|
else np.full(self.n_products, self.price_bounds[0], dtype=float)
|
||||||
|
)
|
||||||
|
if np.isscalar(action):
|
||||||
|
idx = int(np.clip(int(action), 0, self.action_levels - 1))
|
||||||
|
return np.clip(base * self._action_scales[idx], *self.price_bounds)
|
||||||
|
a = np.asarray(action)
|
||||||
|
if a.size == 1:
|
||||||
|
idx = int(np.clip(int(a.reshape(-1)[0]), 0, self.action_levels - 1))
|
||||||
|
return np.clip(base * self._action_scales[idx], *self.price_bounds)
|
||||||
|
return np.clip(a.astype(float), *self.price_bounds)
|
||||||
|
|
||||||
|
def _compute_agent_prob(self, trajectories=None) -> float:
|
||||||
|
trajectories = (
|
||||||
|
self.market.last_trajectories if trajectories is None else trajectories
|
||||||
|
)
|
||||||
|
if not trajectories or self._human_trans is None or self._agent_trans is None:
|
||||||
|
return float(self.market.alpha)
|
||||||
|
probs = []
|
||||||
|
for traj in trajectories:
|
||||||
|
events = trajectory_to_events(traj)
|
||||||
|
if len(events) < 2:
|
||||||
|
continue
|
||||||
|
probs.append(
|
||||||
|
compute_agent_probability(events, self._human_trans, self._agent_trans)
|
||||||
|
)
|
||||||
|
return float(np.mean(probs)) if probs else float(self.market.alpha)
|
||||||
|
|
||||||
|
def _compute_reward(
|
||||||
|
self, prices: np.ndarray, demand: dict, agent_prob: float, trajectories: list
|
||||||
|
) -> tuple[float, dict]:
|
||||||
|
demand_arr = np.array(
|
||||||
|
[demand.get(i, 0.0) for i in range(self.n_products)], dtype=float
|
||||||
|
)
|
||||||
|
revenue = float(np.dot(prices, demand_arr))
|
||||||
|
purchases = extract_purchases(trajectories)
|
||||||
|
coi_mix = compute_uplift_coi(prices, purchases, self.baseline_prices)
|
||||||
|
# multiplicative penalty so COI term scales with revenue magnitude
|
||||||
|
coi_leakage = float(agent_prob * self.info_value)
|
||||||
|
discount = float(np.clip(1.0 - self.lambda_coi * coi_leakage, 0.0, 1.0))
|
||||||
|
coi_penalty = revenue * (1.0 - discount) # absolute penalty in revenue units
|
||||||
|
reward = revenue * discount
|
||||||
|
return reward, {
|
||||||
|
"revenue": revenue,
|
||||||
|
"coi_mix": float(coi_mix),
|
||||||
|
"coi_base": 0.0,
|
||||||
|
"coi_leakage": coi_leakage,
|
||||||
|
"coi_penalty": coi_penalty,
|
||||||
|
"coi_discount": discount,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _select_adversarial_alpha(
|
||||||
|
self, prices: np.ndarray
|
||||||
|
) -> tuple[float, dict, list, float]:
|
||||||
|
"""inner robust step: pick worst-case alpha and return its outcome directly to avoid double-sampling"""
|
||||||
|
candidates = self._alpha_candidates()
|
||||||
|
best_alpha, worst_reward = float(candidates[0]), np.inf
|
||||||
|
best_demand, best_trajectories, best_agent_prob = None, [], 0.0
|
||||||
|
for alpha in candidates:
|
||||||
|
self._set_market_mix(float(alpha))
|
||||||
|
demand = self.market.act(prices)
|
||||||
|
trajectories = list(self.market.last_trajectories)
|
||||||
|
agent_prob = self._compute_agent_prob(trajectories)
|
||||||
|
reward, _ = self._compute_reward(prices, demand, agent_prob, trajectories)
|
||||||
|
if reward < worst_reward:
|
||||||
|
worst_reward = reward
|
||||||
|
best_alpha, best_demand, best_trajectories, best_agent_prob = (
|
||||||
|
float(alpha),
|
||||||
|
demand,
|
||||||
|
trajectories,
|
||||||
|
agent_prob,
|
||||||
|
)
|
||||||
|
return best_alpha, best_demand, best_trajectories, best_agent_prob
|
||||||
|
|
||||||
def _record_history(self):
|
def _record_history(self):
|
||||||
demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
|
demand_arr = np.array(
|
||||||
|
[self._demand.get(i, 0.0) for i in range(self.n_products)]
|
||||||
|
)
|
||||||
self._demand_history.append(demand_arr)
|
self._demand_history.append(demand_arr)
|
||||||
self._price_history.append(self._prices.copy())
|
self._price_history.append(self._prices.copy())
|
||||||
self._revenue_history.append(np.sum(self._prices * demand_arr))
|
self._revenue_history.append(np.sum(self._prices * demand_arr))
|
||||||
|
|
||||||
def reset(self, seed=None, options=None):
|
def reset(self, seed=None, options=None):
|
||||||
super().reset(seed=seed)
|
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._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
|
||||||
self._demand = self.market.act(self._prices)
|
self._platform_stub.set_prices(self._prices)
|
||||||
|
self._limbo.step()
|
||||||
|
self._demand = self._limbo.step()
|
||||||
|
self._initial_episode_prices = self._prices.copy()
|
||||||
self._step_count = 0
|
self._step_count = 0
|
||||||
|
self._low_margin_streak = 0
|
||||||
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
||||||
|
self._trajectories = list(getattr(self.market, "last_trajectories", []))
|
||||||
self._record_history()
|
self._record_history()
|
||||||
return self._get_obs(), {}
|
return self._get_obs(), {}
|
||||||
|
|
||||||
def step(self, action: np.ndarray):
|
def step(self, action):
|
||||||
self._prices = np.clip(action, *self.price_bounds)
|
self._prices = self._decode_action(action)
|
||||||
self._demand = self.market.act(self._prices)
|
# inner robust step returns worst-case outcome directly, no re-sampling
|
||||||
|
alpha_adv, self._demand, trajectories, agent_prob = (
|
||||||
|
self._select_adversarial_alpha(self._prices)
|
||||||
|
)
|
||||||
|
self._set_market_mix(alpha_adv)
|
||||||
|
self._platform_stub.set_prices(self._prices)
|
||||||
self._step_count += 1
|
self._step_count += 1
|
||||||
|
self._trajectories.extend(trajectories)
|
||||||
|
|
||||||
|
reward, metrics = self._compute_reward(
|
||||||
|
self._prices, self._demand, agent_prob, trajectories
|
||||||
|
)
|
||||||
self._record_history()
|
self._record_history()
|
||||||
|
|
||||||
reward = self._compute_reward(self._prices, self._demand)
|
# soft early termination when margin collapses for too long
|
||||||
terminated = self._step_count >= 100
|
avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max(
|
||||||
|
float(np.mean(self._prices)), 1e-6
|
||||||
|
)
|
||||||
|
if avg_margin < self.margin_floor:
|
||||||
|
self._low_margin_streak += 1
|
||||||
|
else:
|
||||||
|
self._low_margin_streak = 0
|
||||||
|
margin_collapsed = self._low_margin_streak >= self.margin_floor_patience
|
||||||
|
terminated = self._step_count >= self.max_steps or margin_collapsed
|
||||||
|
|
||||||
return self._get_obs(), reward, terminated, False, {"step": self._step_count}
|
info = {
|
||||||
|
"step": self._step_count,
|
||||||
|
"agent_prob": agent_prob,
|
||||||
|
"alpha_adv": float(alpha_adv),
|
||||||
|
"wasserstein_radius": float(self.robust_radius),
|
||||||
|
**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:
|
def _compute_elasticity(self) -> np.ndarray:
|
||||||
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
|
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
|
||||||
@@ -87,10 +294,16 @@ class PHANTOM(gym.Env):
|
|||||||
p, q = np.array(self._price_history), np.array(self._demand_history)
|
p, q = np.array(self._price_history), np.array(self._demand_history)
|
||||||
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
|
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
|
||||||
valid = np.abs(dp) > 0.5
|
valid = np.abs(dp) > 0.5
|
||||||
with np.errstate(divide='ignore', invalid='ignore'):
|
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.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)
|
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
|
||||||
return np.mean(elasticity, axis=0) if len(elasticity) > 0 else np.zeros(self.n_products)
|
return (
|
||||||
|
np.mean(elasticity, axis=0)
|
||||||
|
if len(elasticity) > 0
|
||||||
|
else np.zeros(self.n_products)
|
||||||
|
)
|
||||||
|
|
||||||
def render(self):
|
def render(self):
|
||||||
if self.render_mode == "human":
|
if self.render_mode == "human":
|
||||||
@@ -98,7 +311,9 @@ class PHANTOM(gym.Env):
|
|||||||
self._renderer = DashboardRenderer()
|
self._renderer = DashboardRenderer()
|
||||||
self._renderer.render(self)
|
self._renderer.render(self)
|
||||||
elif self.render_mode == "ansi":
|
elif self.render_mode == "ansi":
|
||||||
return f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
|
return (
|
||||||
|
f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
@@ -108,11 +323,44 @@ class PHANTOM(gym.Env):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
env = PHANTOM(n_products=15, alpha=0.3, N=100, render_mode="human")
|
import wandb
|
||||||
obs, _ = env.reset()
|
from .lib import MetricsCallback
|
||||||
for step in range(100):
|
|
||||||
action = env.action_space.sample()
|
class RandomPolicy:
|
||||||
obs, reward, term, trunc, info = env.step(action)
|
"""Minimal SB3-compatible random policy for baseline testing."""
|
||||||
env.render()
|
|
||||||
if term: break
|
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="phantom-pricing", 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()
|
env.close()
|
||||||
|
|||||||
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
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)
|
||||||
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
@@ -9,6 +9,7 @@ import pandas as pd
|
|||||||
|
|
||||||
from lib.separability import estimate_alpha, load_artifacts, score_session
|
from lib.separability import estimate_alpha, load_artifacts, score_session
|
||||||
|
|
||||||
|
|
||||||
# use relative import when in package context, fallback for standalone
|
# use relative import when in package context, fallback for standalone
|
||||||
try:
|
try:
|
||||||
from sim.rl.behavior_loader.models import AgentBehaviorModel
|
from sim.rl.behavior_loader.models import AgentBehaviorModel
|
||||||
@@ -51,7 +52,6 @@ def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
|
|||||||
)
|
)
|
||||||
return events
|
return events
|
||||||
|
|
||||||
|
|
||||||
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
||||||
contamination_rate: float = 0.1,
|
contamination_rate: float = 0.1,
|
||||||
agent_data_dir: Path = None) -> pd.DataFrame:
|
agent_data_dir: Path = None) -> pd.DataFrame:
|
||||||
@@ -78,6 +78,7 @@ def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
|||||||
# generate synthetic trajectories
|
# generate synthetic trajectories
|
||||||
new_rows = []
|
new_rows = []
|
||||||
alpha_estimates = []
|
alpha_estimates = []
|
||||||
|
|
||||||
for start_event in start_events:
|
for start_event in start_events:
|
||||||
# sample trajectory from agent model, using a state that contains the event type
|
# sample trajectory from agent model, using a state that contains the event type
|
||||||
mdp_states = model.mdp.get('states', []) if model.mdp else []
|
mdp_states = model.mdp.get('states', []) if model.mdp else []
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from procesing.steps import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_compute_demand(pipeline_context):
|
def test_compute_demand(pipeline_context):
|
||||||
|
random.seed(42) # deterministic test
|
||||||
step = ComputeDemandStep(context=pipeline_context)
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
# Test with normal interaction data
|
# Test with normal interaction data
|
||||||
@@ -26,6 +27,7 @@ def test_compute_demand(pipeline_context):
|
|||||||
|
|
||||||
|
|
||||||
def test_compute_demand_skewed(pipeline_context):
|
def test_compute_demand_skewed(pipeline_context):
|
||||||
|
random.seed(42) # deterministic test
|
||||||
step = ComputeDemandStep(context=pipeline_context)
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
# Test with normal interaction data
|
# 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
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# MOS (Money Operating System)
|
|
||||||
|
|
||||||
Research-grade quote-control simulator for studying dynamic pricing and market making policies.
|
|
||||||
The system models pricing as a closed loop of **Quote → Arrival → Execution → Position**, enabling
|
|
||||||
controlled experimentation with demand models, inventory constraints, and reward shaping.
|
|
||||||
|
|
||||||
## Core Loop
|
|
||||||
|
|
||||||
1. **Quote** – the policy posts prices (one-sided or two-sided depending on the mechanism).
|
|
||||||
2. **Arrival** – a population model generates purchase opportunities or market orders.
|
|
||||||
3. **Execution** – an execution model decides whether an arrival converts at the quoted price.
|
|
||||||
4. **Position** – inventory/position limits censor fills and generate holding/shortage costs.
|
|
||||||
5. **Observation & Reward** – censored fills and aggregate metrics are exposed to the agent, while
|
|
||||||
objectives turn metrics into a scalar reward.
|
|
||||||
|
|
||||||
Each stage is pluggable via light-weight protocols so you can swap in alternative mechanisms,
|
|
||||||
demand models, or objectives without rewriting the rest of the simulator.
|
|
||||||
|
|
||||||
## Package Layout
|
|
||||||
|
|
||||||
| Module | Purpose |
|
|
||||||
|-------------------|---------|
|
|
||||||
| `lab.outlet` | Core simulation engine, domain types, pricing mechanisms, objectives. |
|
|
||||||
| `lab.population` | Demand arrival models, execution probability models, competitor/market dynamics. |
|
|
||||||
| `lab.experiments` | Rollout utilities, baseline policies, and off-policy evaluation helpers. |
|
|
||||||
| `lab.config` | Convenience factories for preconfigured retail and market-making environments. |
|
|
||||||
|
|
||||||
## Preconfigured Scenarios
|
|
||||||
|
|
||||||
### Retail Dynamic Pricing
|
|
||||||
- Mechanism: posted prices with margin and delta constraints.
|
|
||||||
- Arrivals: browsing sessions with contamination support (scrapers).
|
|
||||||
- Execution: elasticity model with competitor cross-effects.
|
|
||||||
- Position: inventory tracking with holding and shortage costs.
|
|
||||||
- Market: reactive competitor that can trigger price wars.
|
|
||||||
- Objective: PnL minus volatility, holding cost, and lost opportunity penalties.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from lab.config import make_retail_platform
|
|
||||||
from lab.experiments import rollout, fixed_price_policy
|
|
||||||
|
|
||||||
platform = make_retail_platform()
|
|
||||||
policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
result = rollout(platform, policy, n_steps=100)
|
|
||||||
print(result.total_pnl)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Market Making
|
|
||||||
- Mechanism: two-sided quoting with bid/ask spreads.
|
|
||||||
- Arrivals: Hawkes order flow for clustered demand.
|
|
||||||
- Execution: Avellaneda–Stoikov style intensity model.
|
|
||||||
- Position: inventory risk limits and quadratic penalty objective.
|
|
||||||
- Market: geometric Brownian motion mid-price process.
|
|
||||||
- Objective: PnL plus spread capture minus inventory risk.
|
|
||||||
|
|
||||||
```python
|
|
||||||
from lab.config import make_market_making_platform
|
|
||||||
from lab.experiments import rollout
|
|
||||||
|
|
||||||
platform = make_market_making_platform()
|
|
||||||
mm_policy = lambda obs, t: (platform.instruments.refs, 1.0)
|
|
||||||
result = rollout(platform, mm_policy, n_steps=200, seed=42)
|
|
||||||
print(result.total_pnl)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Extending the Simulator
|
|
||||||
|
|
||||||
- Implement `lab.outlet.protocols.Mechanism` or `ArrivalModel` to introduce new pricing
|
|
||||||
domains or demand processes.
|
|
||||||
- Compose objectives with `lab.outlet.objectives.factory.make_composite` to study alternate
|
|
||||||
reward formulations.
|
|
||||||
- Use `lab.experiments.compare_policies` to benchmark candidate policies across multiple
|
|
||||||
random seeds.
|
|
||||||
|
|
||||||
Comprehensive API documentation lives in `lab/docs` (build with `make html`).
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
"""
|
|
||||||
Quote-Control Simulator: Research-grade platform for dynamic pricing and market making
|
|
||||||
|
|
||||||
The platform abstracts pricing as: Quote -> Arrival -> Execution -> Position
|
|
||||||
Supports multiple mechanisms:
|
|
||||||
- PostedPrice: retail dynamic pricing
|
|
||||||
- TwoSided: market making with bid-ask spreads
|
|
||||||
- Auction: reserve/shading for auction settings
|
|
||||||
|
|
||||||
Example usage:
|
|
||||||
from lab.config import make_retail_platform
|
|
||||||
from lab.experiments import rollout, fixed_price_policy
|
|
||||||
|
|
||||||
platform = make_retail_platform()
|
|
||||||
policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
result = rollout(platform, policy, n_steps=100)
|
|
||||||
print(f"Total PnL: {result.total_pnl:.2f}")
|
|
||||||
"""
|
|
||||||
|
|
||||||
from .config import make_retail_platform, make_market_making_platform, RetailConfig, MarketMakingConfig
|
|
||||||
from .outlet import Platform, PlatformConfig, Quote, Observation, StepResult
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'make_retail_platform', 'make_market_making_platform',
|
|
||||||
'RetailConfig', 'MarketMakingConfig',
|
|
||||||
'Platform', 'PlatformConfig', 'Quote', 'Observation', 'StepResult',
|
|
||||||
]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
"""
|
|
||||||
Case studies implementing specific research scenarios.
|
|
||||||
|
|
||||||
Available cases:
|
|
||||||
- thesis: PHANTOM thesis implementation with contaminated demand and DR-RL
|
|
||||||
"""
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""
|
|
||||||
Thesis-specific implementation of the PHANTOM pricing defense framework.
|
|
||||||
|
|
||||||
This module implements the mathematical models from the thesis:
|
|
||||||
- ContaminatedArrivalModel: Mixture demand Q(p) = (1-α)d_H + αd_A (Eq 3)
|
|
||||||
- HybridExecutionModel: Divergent H/A behavior with separability (Section 2.1)
|
|
||||||
- RobustStackelbergObjective: Maximin objective with COI penalty (Eq 23)
|
|
||||||
- COIMetrics: Cost of Information tracking (Definition 1)
|
|
||||||
|
|
||||||
The platform configuration creates a research environment that directly
|
|
||||||
maps to the thesis mathematical framework for DR-RL experiments.
|
|
||||||
"""
|
|
||||||
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
|
|
||||||
from .execution import HybridExecutionModel, HybridExecutionConfig
|
|
||||||
from .objectives import RobustStackelbergObjective, COIObjective
|
|
||||||
from .platform import make_thesis_platform, ThesisConfig
|
|
||||||
from .metrics import COIMetrics, compute_coi, compute_separability
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'ContaminatedArrivalModel', 'ContaminatedArrivalConfig',
|
|
||||||
'HybridExecutionModel', 'HybridExecutionConfig',
|
|
||||||
'RobustStackelbergObjective', 'COIObjective',
|
|
||||||
'make_thesis_platform', 'ThesisConfig',
|
|
||||||
'COIMetrics', 'compute_coi', 'compute_separability',
|
|
||||||
]
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
"""Contaminated arrivals using learned MDP kernels from behavior_loader.
|
|
||||||
|
|
||||||
Implements thesis demand model (Section 3.1):
|
|
||||||
- Aggregate demand Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3)
|
|
||||||
- Demand proxy q̂_{t,i} = Σ_s Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] (Eq 2)
|
|
||||||
- Per-session separability via KL divergence Δ_H, Δ_A (Eq 20-21)
|
|
||||||
|
|
||||||
The arrival model samples sessions from a mixture of human/agent behavioral profiles,
|
|
||||||
each session produces a trajectory τ_s and associated demand computation q(τ').
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Dict, List, Tuple, Optional
|
|
||||||
import numpy as np
|
|
||||||
from ...outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState
|
|
||||||
from ...outlet.constants import Side, OpportunityType
|
|
||||||
from ...outlet.math_util import poisson_arrivals
|
|
||||||
|
|
||||||
try:
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
||||||
from sim.rl.behavior_loader.models import (
|
|
||||||
BehaviorModel, AgentBehaviorModel, aggregate_event_transitions, kl_divergence
|
|
||||||
)
|
|
||||||
REAL_MDP = True
|
|
||||||
except ImportError:
|
|
||||||
REAL_MDP = False
|
|
||||||
kl_divergence = None
|
|
||||||
|
|
||||||
EVENT_PAGE = {"session_start": "/", "view_item_page": "/products", "learn_more_about_item": "/products/details",
|
|
||||||
"add_item_to_cart": "/cart", "purchase_complete": "/checkout", "session_end": "/checkout/success"}
|
|
||||||
EVENT_CANON = {"page_view": "session_start", "hover_over_paragraph": "view_item_page", "hover_over_title": "view_item_page",
|
|
||||||
"view_item_page": "view_item_page", "learn_more_about_item": "learn_more_about_item",
|
|
||||||
"add_item_to_cart": "add_item_to_cart", "checkout_start": "purchase_complete", "remove_item": "view_item_page"}
|
|
||||||
|
|
||||||
# action space partition A = A_nav ∪ A_cart ∪ A_filter ∪ A_dwell with signal weights ω (Table 1)
|
|
||||||
ACTION_WEIGHTS: Dict[str, float] = {
|
|
||||||
"add_item_to_cart": 0.8, "remove_item": 0.6, "checkout_start": 0.9, "purchase_complete": 1.0, # A_cart
|
|
||||||
"hover_over_title": 0.3, "hover_over_paragraph": 0.35, "hover_over_link": 0.25, # A_dwell
|
|
||||||
"page_view": 0.1, "session_start": 0.05, "view_item_page": 0.15, "learn_more_about_item": 0.2, # A_nav
|
|
||||||
"search": 0.05, "filter_date": 0.05, "filter_price": 0.08, "sort": 0.03, "session_end": 0.0, # A_filter
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SessionDemand:
|
|
||||||
"""Per-session demand computation per thesis formulation (Section 3.1).
|
|
||||||
|
|
||||||
Each session s ∈ S produces trajectory τ_s and demand proxy q̂. The platform uses
|
|
||||||
divergence signals Δ_H, Δ_A to estimate per-session contamination α̂(τ').
|
|
||||||
"""
|
|
||||||
session_id: str
|
|
||||||
q: Dict[int, float] # q̂_i demand proxy per product (Eq 2)
|
|
||||||
trajectory: List[Dict] # τ_s = (e_{s,1}, ..., e_{s,L_s})
|
|
||||||
delta_h: float = 0.0 # D_KL(T̂' || T̄_H) (Eq 20)
|
|
||||||
delta_a: float = 0.0 # D_KL(T̂' || T̄_A) (Eq 21)
|
|
||||||
alpha_hat: float = 0.0 # per-session contamination estimate
|
|
||||||
actor_class: str = "H" # ground truth Y_s ∈ {H, A}
|
|
||||||
theta: Dict[str, float] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
def compute_demand_proxy(events: List[Dict], n_products: int) -> Dict[int, float]:
|
|
||||||
"""Compute q̂_{t,i} = Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] per Eq 2."""
|
|
||||||
q = {i: 0.0 for i in range(n_products)}
|
|
||||||
for e in events:
|
|
||||||
action, pidx = e.get("eventName", ""), e.get("product_idx")
|
|
||||||
if pidx is not None and 0 <= pidx < n_products:
|
|
||||||
q[pidx] += ACTION_WEIGHTS.get(action, 0.1)
|
|
||||||
return q
|
|
||||||
|
|
||||||
|
|
||||||
def compute_session_divergence(events: List[Dict], ref_h: Dict, ref_a: Dict) -> Tuple[float, float]:
|
|
||||||
"""Compute Δ_H, Δ_A divergence signals from trajectory (Eq 20-21)."""
|
|
||||||
if not events or kl_divergence is None:
|
|
||||||
return 0.0, 0.0
|
|
||||||
# build empirical transition kernel from trajectory
|
|
||||||
trans: Dict[str, Dict[str, int]] = {}
|
|
||||||
prev = "session_start"
|
|
||||||
for e in events:
|
|
||||||
curr = e.get("eventName", "session_end")
|
|
||||||
trans.setdefault(prev, {})
|
|
||||||
trans[prev][curr] = trans[prev].get(curr, 0) + 1
|
|
||||||
prev = curr
|
|
||||||
# normalize to probabilities
|
|
||||||
kernel = {}
|
|
||||||
for s, dests in trans.items():
|
|
||||||
total = sum(dests.values())
|
|
||||||
kernel[s] = {d: c / total for d, c in dests.items()} if total > 0 else {}
|
|
||||||
# aggregate to event-level and compute KL divergence against reference kernels
|
|
||||||
delta_h = sum(kl_divergence(kernel.get(s, {}), ref_h.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
|
||||||
delta_a = sum(kl_divergence(kernel.get(s, {}), ref_a.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
|
||||||
return delta_h, delta_a
|
|
||||||
|
|
||||||
def _canonicalize(raw: Dict) -> Dict:
|
|
||||||
out = {}
|
|
||||||
for src, dsts in raw.items():
|
|
||||||
sc = EVENT_CANON.get(src, src)
|
|
||||||
out.setdefault(sc, {})
|
|
||||||
for dst, p in dsts.items():
|
|
||||||
dc = EVENT_CANON.get(dst, dst)
|
|
||||||
out[sc][dc] = out[sc].get(dc, 0.0) + p
|
|
||||||
return {s: {k: v/sum(d.values()) for k, v in d.items()} for s, d in out.items() if sum(d.values()) > 0}
|
|
||||||
|
|
||||||
|
|
||||||
class BehavioralProfile:
|
|
||||||
"""Markov profile from learned MDP kernels (Section 3.5.2).
|
|
||||||
|
|
||||||
Transition kernel T̂_Y estimated via MLE: P̂(s'|s) = N(s,s') / Σ_k N(s,k) (Eq 19)
|
|
||||||
"""
|
|
||||||
STATES = ["session_start", "view_item_page", "learn_more_about_item", "add_item_to_cart", "purchase_complete", "session_end"]
|
|
||||||
# fallback kernels T̄_H, T̄_A when real data unavailable
|
|
||||||
FALLBACK_H = {"session_start": {"view_item_page": 0.85, "session_end": 0.15},
|
|
||||||
"view_item_page": {"learn_more_about_item": 0.4, "add_item_to_cart": 0.3, "view_item_page": 0.2, "session_end": 0.1},
|
|
||||||
"learn_more_about_item": {"add_item_to_cart": 0.5, "view_item_page": 0.3, "session_end": 0.2},
|
|
||||||
"add_item_to_cart": {"purchase_complete": 0.6, "view_item_page": 0.25, "session_end": 0.15},
|
|
||||||
"purchase_complete": {"session_end": 1.0}}
|
|
||||||
FALLBACK_A = {"session_start": {"view_item_page": 0.95, "session_end": 0.05},
|
|
||||||
"view_item_page": {"learn_more_about_item": 0.6, "view_item_page": 0.25, "add_item_to_cart": 0.1, "session_end": 0.05},
|
|
||||||
"learn_more_about_item": {"view_item_page": 0.5, "add_item_to_cart": 0.15, "learn_more_about_item": 0.3, "session_end": 0.05},
|
|
||||||
"add_item_to_cart": {"view_item_page": 0.4, "purchase_complete": 0.2, "session_end": 0.4},
|
|
||||||
"purchase_complete": {"session_end": 1.0}}
|
|
||||||
|
|
||||||
def __init__(self, actor: str, pprobs: np.ndarray, data_dir: str = ""):
|
|
||||||
self.actor, self.pprobs = actor, np.clip(pprobs, 0.0, 0.95)
|
|
||||||
self.trans = self._load(data_dir) # T̂_Y transition kernel
|
|
||||||
self._ensure_terminal()
|
|
||||||
self.dwell = {s: (1.2, 0.5) if actor == "agents" else (2.0, 1.2) for s in self.STATES}
|
|
||||||
|
|
||||||
def _load(self, data_dir: str) -> Dict:
|
|
||||||
if not REAL_MDP or not data_dir:
|
|
||||||
print("using fallback")
|
|
||||||
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
|
||||||
try:
|
|
||||||
mdp = (AgentBehaviorModel if self.actor == "agents" else BehaviorModel)(data_dir).build_MDP()
|
|
||||||
raw = aggregate_event_transitions(mdp) if mdp.get("transitions") else {}
|
|
||||||
return _canonicalize(raw) if raw else dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
|
||||||
except Exception:
|
|
||||||
print("using fallback")
|
|
||||||
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
|
||||||
|
|
||||||
def _ensure_terminal(self):
|
|
||||||
self.trans.setdefault("purchase_complete", {})["session_end"] = self.trans.get("purchase_complete", {}).get("session_end", 1.0)
|
|
||||||
self.trans.setdefault("session_start", {"view_item_page": 0.7, "learn_more_about_item": 0.2, "session_end": 0.1})
|
|
||||||
|
|
||||||
def _tprobs(self, state: str, pidx: int) -> Dict[str, float]:
|
|
||||||
probs = dict(self.trans.get(state, {"session_end": 1.0}))
|
|
||||||
if state == "add_item_to_cart":
|
|
||||||
base = probs.get("purchase_complete", 0.0)
|
|
||||||
df = float(self.pprobs[pidx]) * (0.3 if self.actor == "agents" else 1.0)
|
|
||||||
adj = np.clip(base * 0.5 + df * 0.5, 0.0, 0.95)
|
|
||||||
rem = max(1e-6, 1.0 - adj)
|
|
||||||
other = sum(v for k, v in probs.items() if k != "purchase_complete")
|
|
||||||
probs = {k: (adj if k == "purchase_complete" else v * rem / max(other, 1e-6)) for k, v in probs.items()}
|
|
||||||
total = sum(probs.values())
|
|
||||||
return {k: v/total for k, v in probs.items()} if total > 0 else {"session_end": 1.0}
|
|
||||||
|
|
||||||
def sample(self, rng: np.random.Generator, sid: str, prices: np.ndarray, costs: np.ndarray) -> Tuple[List[Dict], List[SimpleNamespace]]:
|
|
||||||
events, fevts = [], []
|
|
||||||
state, t, pidx = "session_start", 0.0, int(rng.integers(0, len(prices)))
|
|
||||||
cost, cprice = float(costs[pidx]), max(float(prices[pidx]), float(costs[pidx]) * 1.05)
|
|
||||||
|
|
||||||
while state != "session_end" and len(events) < 40:
|
|
||||||
if state != "session_start":
|
|
||||||
row = {"session_id": sid, "actor": "agent" if self.actor == "agents" else "human",
|
|
||||||
"eventName": state, "product_idx": pidx, "productId": f"product-{pidx:04d}",
|
|
||||||
"price_offered": cprice, "price_paid": 0.0, "page": EVENT_PAGE.get(state, "/"),
|
|
||||||
"ts": t, "unit_cost": cost, "base_price": float(prices[pidx])}
|
|
||||||
if state == "purchase_complete":
|
|
||||||
row["price_paid"] = max(cprice * (1.0 + rng.normal(0.0, 0.015)), cost)
|
|
||||||
events.append(row)
|
|
||||||
fevts.append(SimpleNamespace(eventName=state, page=row["page"], productId=row["productId"], ts=t))
|
|
||||||
|
|
||||||
probs = self._tprobs(state, pidx)
|
|
||||||
state = rng.choice(list(probs.keys()), p=list(probs.values()))
|
|
||||||
sh, sc = self.dwell.get(state, (2.0, 1.0))
|
|
||||||
t += max(0.3, rng.gamma(shape=sh, scale=sc))
|
|
||||||
return events, fevts
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ContaminatedArrivalConfig:
|
|
||||||
base_rate: float = 20.0
|
|
||||||
alpha_contamination: float = 0.2
|
|
||||||
alpha_drift: float = 0.0
|
|
||||||
alpha_bounds: tuple[float, float] = (0.0, 0.5)
|
|
||||||
human_views_range: tuple[int, int] = (1, 4)
|
|
||||||
agent_views_range: tuple[int, int] = (3, 10)
|
|
||||||
agent_systematic: bool = True
|
|
||||||
use_real_behavior: bool = True
|
|
||||||
human_data_dir: str = ""
|
|
||||||
agent_data_dir: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
class ContaminatedArrivalModel:
|
|
||||||
"""Mixture model Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3).
|
|
||||||
|
|
||||||
Samples sessions from human/agent behavioral profiles, computes per-session
|
|
||||||
demand proxy q̂ and divergence signals Δ_H, Δ_A for separability.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: ContaminatedArrivalConfig | None = None):
|
|
||||||
self.cfg = cfg or ContaminatedArrivalConfig()
|
|
||||||
self._alpha = self.cfg.alpha_contamination
|
|
||||||
self._scount = 0
|
|
||||||
self._profiles: Dict[str, BehavioralProfile] = {}
|
|
||||||
self._ref_kernels: Dict[str, Dict] = {} # T̄_H, T̄_A reference kernels
|
|
||||||
self._session_demands: List[SessionDemand] = [] # collected session demands
|
|
||||||
|
|
||||||
@property
|
|
||||||
def alpha(self) -> float:
|
|
||||||
return self._alpha
|
|
||||||
|
|
||||||
def _profile(self, actor: str, pprobs: np.ndarray) -> BehavioralProfile:
|
|
||||||
key = actor
|
|
||||||
if key not in self._profiles:
|
|
||||||
ddir = self.cfg.agent_data_dir if actor == "agents" else self.cfg.human_data_dir
|
|
||||||
if not ddir and self.cfg.use_real_behavior:
|
|
||||||
base = Path(__file__).parent.parent.parent.parent / "experiments"
|
|
||||||
ddir = str(base / ("agents/collected_data" if actor == "agents" else "collected_data"))
|
|
||||||
profile = BehavioralProfile(actor, pprobs, ddir if self.cfg.use_real_behavior else "")
|
|
||||||
self._profiles[key] = profile
|
|
||||||
self._ref_kernels[key] = profile.trans # cache T̄_Y for divergence
|
|
||||||
return self._profiles[key]
|
|
||||||
|
|
||||||
def get_ref_kernels(self) -> Tuple[Dict, Dict]:
|
|
||||||
"""Return reference transition kernels T̄_H, T̄_A for divergence computation."""
|
|
||||||
return (self._ref_kernels.get("humans", BehavioralProfile.FALLBACK_H),
|
|
||||||
self._ref_kernels.get("agents", BehavioralProfile.FALLBACK_A))
|
|
||||||
|
|
||||||
def get_session_demands(self) -> List[SessionDemand]:
|
|
||||||
"""Return collected session demands for downstream analysis."""
|
|
||||||
return self._session_demands
|
|
||||||
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
"""Sample arrivals as per Eq 3: mixture of human/agent demand distributions.
|
|
||||||
|
|
||||||
For each session s, computes:
|
|
||||||
- Trajectory τ_s from behavioral profile sampling
|
|
||||||
- Demand proxy q̂ via weighted action aggregation (Eq 2)
|
|
||||||
- Divergence signals Δ_H, Δ_A for separability (Eq 20-21)
|
|
||||||
- Per-session contamination estimate α̂(τ')
|
|
||||||
"""
|
|
||||||
cfg = self.cfg
|
|
||||||
if cfg.alpha_drift != 0:
|
|
||||||
self._alpha = np.clip(self._alpha + cfg.alpha_drift * rng.normal(), *cfg.alpha_bounds)
|
|
||||||
hidden.contamination = self._alpha
|
|
||||||
|
|
||||||
n_sess = poisson_arrivals(cfg.base_rate * hidden.true_demand_intensity, dt, rng)
|
|
||||||
prices, costs = instruments.refs, instruments.costs
|
|
||||||
margin = np.clip((prices - costs) / np.maximum(costs, 1e-3), -0.9, 2.0)
|
|
||||||
hprob, aprob = 0.08 * np.exp(-1.2 * margin), 0.05 * np.exp(-0.6 * margin)
|
|
||||||
ref_h, ref_a = self.get_ref_kernels()
|
|
||||||
|
|
||||||
opps = []
|
|
||||||
for _ in range(n_sess):
|
|
||||||
self._scount += 1
|
|
||||||
sid = f"s{self._scount:06d}"
|
|
||||||
is_agent = rng.random() < self._alpha
|
|
||||||
actor, probs = ("agents", aprob) if is_agent else ("humans", hprob)
|
|
||||||
profile = self._profile(actor, probs)
|
|
||||||
events, fevts = profile.sample(rng, sid, prices, costs)
|
|
||||||
|
|
||||||
# compute demand proxy q̂ per Eq 2
|
|
||||||
q = compute_demand_proxy(events, instruments.n)
|
|
||||||
|
|
||||||
# compute divergence signals Δ_H, Δ_A per Eq 20-21
|
|
||||||
delta_h, delta_a = compute_session_divergence(events, ref_h, ref_a)
|
|
||||||
# per-session contamination estimate α̂(τ') = σ(β(Δ_H - Δ_A))
|
|
||||||
alpha_hat = 1.0 / (1.0 + np.exp(-2.0 * (delta_h - delta_a))) if (delta_h + delta_a) > 0 else 0.5
|
|
||||||
|
|
||||||
theta = ({'price_sensitivity': rng.uniform(0.05, 0.2), 'base_conversion': 0.01, 'info_value': 1.0} if is_agent
|
|
||||||
else {'price_sensitivity': rng.uniform(1.5, 4.0), 'base_conversion': rng.uniform(0.2, 0.5), 'info_value': 0.0})
|
|
||||||
|
|
||||||
# store session demand for downstream analysis
|
|
||||||
self._session_demands.append(SessionDemand(
|
|
||||||
session_id=sid, q=q, trajectory=events, delta_h=delta_h, delta_a=delta_a,
|
|
||||||
alpha_hat=alpha_hat, actor_class="A" if is_agent else "H", theta=theta))
|
|
||||||
|
|
||||||
viewed = list({e["product_idx"] for e in events if "product_idx" in e})
|
|
||||||
if not viewed:
|
|
||||||
vr = cfg.agent_views_range if is_agent else cfg.human_views_range
|
|
||||||
viewed = list(rng.choice(instruments.n, size=min(rng.integers(*vr), instruments.n), replace=False))
|
|
||||||
|
|
||||||
for vi, iid in enumerate(viewed):
|
|
||||||
opps.append(Opportunity(
|
|
||||||
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
|
|
||||||
instrument_id=int(iid), size=1.0, t=t + rng.uniform(0, dt),
|
|
||||||
context={'session_id': sid, 'actor_class': 'AGENT' if is_agent else 'HUMAN', 'is_agent': is_agent,
|
|
||||||
'reconnaissance_intent': is_agent, 'view_index': vi, 'total_views': len(viewed),
|
|
||||||
'theta': theta, 'trajectory_events': fevts, 'mdp_trajectory': events,
|
|
||||||
'demand_proxy': q, 'alpha_hat': alpha_hat, 'delta_h': delta_h, 'delta_a': delta_a}))
|
|
||||||
return opps
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AdversarialArrivalConfig:
|
|
||||||
base_rate: float = 5.0
|
|
||||||
n_parallel_agents: int = 3
|
|
||||||
query_all_products: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
class AdversarialArrivalModel:
|
|
||||||
"""Adversarial coordination (Theorem 1): as N->inf, COI->0."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: AdversarialArrivalConfig | None = None):
|
|
||||||
self.cfg = cfg or AdversarialArrivalConfig()
|
|
||||||
self._qcount = 0
|
|
||||||
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
cfg, opps = self.cfg, []
|
|
||||||
for _ in range(poisson_arrivals(cfg.base_rate, dt, rng)):
|
|
||||||
self._qcount += 1
|
|
||||||
for ai in range(cfg.n_parallel_agents):
|
|
||||||
sid = f"adv{self._qcount:06d}-{ai}"
|
|
||||||
prods = np.arange(instruments.n) if cfg.query_all_products else rng.choice(instruments.n, size=1)
|
|
||||||
for iid in prods:
|
|
||||||
opps.append(Opportunity(
|
|
||||||
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
|
|
||||||
instrument_id=int(iid), size=1.0, t=t,
|
|
||||||
context={'session_id': sid, 'actor_class': 'AGENT', 'is_agent': True, 'adversarial': True,
|
|
||||||
'agent_index': ai, 'query_group': self._qcount,
|
|
||||||
'theta': {'price_sensitivity': 0.0, 'base_conversion': 0.0, 'info_value': 1.0}}))
|
|
||||||
return opps
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
"""Execution models with divergent H/A behavior using ground truth labels."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any, Dict
|
|
||||||
import numpy as np
|
|
||||||
from ...outlet.types import Opportunity, Quote, InstrumentSet, MarketState
|
|
||||||
from ...outlet.math_util import sigmoid, safe_log, EPS
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HybridExecutionConfig:
|
|
||||||
human_base_prob: float = 0.3
|
|
||||||
human_elasticity: float = 2.5
|
|
||||||
agent_conversion: float = 0.01
|
|
||||||
cross_elasticity: float = 0.4
|
|
||||||
quality_weight: float = 0.2
|
|
||||||
use_separability: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class HybridExecutionModel:
|
|
||||||
"""Execution with divergent H/A behavior using ground truth labels."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: HybridExecutionConfig | None = None):
|
|
||||||
self.cfg = cfg or HybridExecutionConfig()
|
|
||||||
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
cfg, idx = self.cfg, int(opp.instrument_id)
|
|
||||||
price, ref, cost = float(quote.prices[idx]), float(instruments.refs[idx]), float(instruments.costs[idx])
|
|
||||||
ctx = opp.context
|
|
||||||
theta = ctx.get('theta', {})
|
|
||||||
is_agent = ctx.get('is_agent', False)
|
|
||||||
|
|
||||||
if is_agent:
|
|
||||||
return cfg.agent_conversion * theta.get('base_conversion', 1.0)
|
|
||||||
|
|
||||||
# human logit discrete choice
|
|
||||||
sens = theta.get('price_sensitivity', cfg.human_elasticity)
|
|
||||||
base = theta.get('base_conversion', cfg.human_base_prob)
|
|
||||||
u_price = -sens * safe_log(price / (ref + EPS))
|
|
||||||
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
|
|
||||||
u_quality = cfg.quality_weight * quality
|
|
||||||
|
|
||||||
u_comp = 0.0
|
|
||||||
if market and market.competitor_quotes is not None:
|
|
||||||
cp = market.competitor_quotes[idx]
|
|
||||||
if cp < price:
|
|
||||||
u_comp = -cfg.cross_elasticity * (price - cp) / ref
|
|
||||||
|
|
||||||
utility = safe_log(base / (1 - base + EPS)) + u_price + u_quality + u_comp
|
|
||||||
return float(sigmoid(utility))
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
if context is None:
|
|
||||||
return fills / (self.cfg.human_base_prob + EPS)
|
|
||||||
agent_frac = context.get('contamination', 0.0)
|
|
||||||
return fills / (self.cfg.human_base_prob * (1 - agent_frac) + EPS)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SeparableExecutionConfig:
|
|
||||||
human_funnel: Dict[str, float] = None
|
|
||||||
agent_funnel: Dict[str, float] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
self.human_funnel = self.human_funnel or {'view_to_detail': 0.4, 'detail_to_cart': 0.3, 'cart_to_purchase': 0.6}
|
|
||||||
self.agent_funnel = self.agent_funnel or {'view_to_detail': 0.8, 'detail_to_cart': 0.05, 'cart_to_purchase': 0.1}
|
|
||||||
|
|
||||||
|
|
||||||
class SeparableExecutionModel:
|
|
||||||
"""Execution with Markov funnel kernels using ground truth labels."""
|
|
||||||
|
|
||||||
def __init__(self, cfg: SeparableExecutionConfig | None = None):
|
|
||||||
self.cfg = cfg or SeparableExecutionConfig()
|
|
||||||
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
is_agent = opp.context.get('is_agent', False)
|
|
||||||
probs = self.cfg.agent_funnel if is_agent else self.cfg.human_funnel
|
|
||||||
p = probs['view_to_detail'] * probs['detail_to_cart'] * probs['cart_to_purchase']
|
|
||||||
|
|
||||||
if not is_agent:
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
price_ratio = quote.prices[idx] / (instruments.refs[idx] + EPS)
|
|
||||||
p *= np.exp(-0.5 * (price_ratio - 1.0))
|
|
||||||
return float(np.clip(p, 0, 1))
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
h = self.cfg.human_funnel
|
|
||||||
exp_conv = h['view_to_detail'] * h['detail_to_cart'] * h['cart_to_purchase']
|
|
||||||
return fills / (exp_conv + EPS)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
"""Thesis metrics for COI and behavioral analysis using ground truth labels."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Dict
|
|
||||||
import numpy as np
|
|
||||||
from ...outlet.types import StepLogs, StepMetrics, Quote, InstrumentSet
|
|
||||||
from ...outlet.math_util import safe_log, EPS
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class COIMetrics:
|
|
||||||
coi_level: float = 0.0
|
|
||||||
coi_leakage: float = 0.0
|
|
||||||
realized_premium: float = 0.0
|
|
||||||
theoretical_max: float = 0.0
|
|
||||||
erosion_rate: float = 0.0
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, float]:
|
|
||||||
return {k: getattr(self, k) for k in ['coi_level', 'coi_leakage', 'realized_premium', 'theoretical_max', 'erosion_rate']}
|
|
||||||
|
|
||||||
|
|
||||||
def compute_coi(quote: Quote, instruments: InstrumentSet, metrics: StepMetrics, contamination: float) -> COIMetrics:
|
|
||||||
prices, costs, refs = quote.prices, instruments.costs, instruments.refs
|
|
||||||
margins = prices - costs
|
|
||||||
coi_level = float(np.mean(margins))
|
|
||||||
theoretical_max = float(np.mean(costs))
|
|
||||||
realized_premium = (metrics.revenue - metrics.cost) / metrics.units_traded if metrics.units_traded > 0 else 0.0
|
|
||||||
price_var = float(np.var(prices / refs))
|
|
||||||
coi_leakage = contamination * (coi_level + price_var)
|
|
||||||
erosion_rate = contamination * coi_level / (theoretical_max + EPS)
|
|
||||||
return COIMetrics(coi_level=coi_level, coi_leakage=coi_leakage, realized_premium=realized_premium,
|
|
||||||
theoretical_max=theoretical_max, erosion_rate=erosion_rate)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SeparabilityMetrics:
|
|
||||||
classification_accuracy: float = 0.0
|
|
||||||
estimated_alpha: float = 0.0
|
|
||||||
n_human_sessions: int = 0
|
|
||||||
n_agent_sessions: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
def compute_separability(logs: StepLogs, true_alpha: float) -> SeparabilityMetrics:
|
|
||||||
"""Compute separability using ground truth labels only."""
|
|
||||||
if logs.events is None or len(logs.events) == 0:
|
|
||||||
return SeparabilityMetrics(estimated_alpha=true_alpha)
|
|
||||||
|
|
||||||
sessions: Dict[str, bool] = {}
|
|
||||||
for evt in logs.events:
|
|
||||||
sid = evt.metadata.get('session_id', evt.opportunity_id)
|
|
||||||
if sid not in sessions:
|
|
||||||
sessions[sid] = evt.metadata.get('is_agent', False)
|
|
||||||
|
|
||||||
n_agent = sum(1 for is_agent in sessions.values() if is_agent)
|
|
||||||
n_human = len(sessions) - n_agent
|
|
||||||
est_alpha = n_agent / len(sessions) if sessions else 0.0
|
|
||||||
|
|
||||||
return SeparabilityMetrics(
|
|
||||||
classification_accuracy=1.0, # ground truth is always correct
|
|
||||||
estimated_alpha=est_alpha,
|
|
||||||
n_human_sessions=n_human,
|
|
||||||
n_agent_sessions=n_agent)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RevenueAttribution:
|
|
||||||
total_revenue: float = 0.0
|
|
||||||
human_revenue: float = 0.0
|
|
||||||
agent_revenue: float = 0.0
|
|
||||||
human_conversion: float = 0.0
|
|
||||||
agent_conversion: float = 0.0
|
|
||||||
|
|
||||||
|
|
||||||
def compute_attribution(logs: StepLogs, metrics: StepMetrics) -> RevenueAttribution:
|
|
||||||
if logs.executions is None:
|
|
||||||
return RevenueAttribution(total_revenue=metrics.revenue)
|
|
||||||
|
|
||||||
human_rev, agent_rev, human_cnt, agent_cnt = 0.0, 0.0, 0, 0
|
|
||||||
for exe in logs.executions:
|
|
||||||
if exe.propensity < 0.05:
|
|
||||||
agent_rev += exe.price * exe.size_filled
|
|
||||||
agent_cnt += 1
|
|
||||||
else:
|
|
||||||
human_rev += exe.price * exe.size_filled
|
|
||||||
human_cnt += 1
|
|
||||||
|
|
||||||
total_exp = logs.aggregates.get('n_arrivals', 1)
|
|
||||||
return RevenueAttribution(
|
|
||||||
total_revenue=metrics.revenue, human_revenue=human_rev, agent_revenue=agent_rev,
|
|
||||||
human_conversion=human_cnt / (total_exp * 0.8 + EPS),
|
|
||||||
agent_conversion=agent_cnt / (total_exp * 0.2 + EPS))
|
|
||||||
|
|
||||||
|
|
||||||
def order_statistic_erosion(n_agents: int, price_variance: float) -> float:
|
|
||||||
"""COI erosion from Theorem 1: as N->inf, min(p_1..p_N)->p_min."""
|
|
||||||
if n_agents <= 1:
|
|
||||||
return 0.0
|
|
||||||
sigma, log_n = np.sqrt(price_variance), safe_log(n_agents)
|
|
||||||
if log_n < 1:
|
|
||||||
return 0.0
|
|
||||||
shift = sigma * (np.sqrt(2 * log_n) - (safe_log(log_n) + safe_log(4 * np.pi)) / (2 * np.sqrt(2 * log_n) + EPS))
|
|
||||||
return float(min(shift / (sigma * 2 + EPS), 1.0))
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
"""
|
|
||||||
Thesis-specific objectives implementing robust pricing under contamination.
|
|
||||||
|
|
||||||
Implements the Maximin objective from Eq 23:
|
|
||||||
π* = argmax_π min_{Q ∈ U_ε} E_d~Q[R(p,d) - λ·COI(p)]
|
|
||||||
|
|
||||||
Key components:
|
|
||||||
- COIObjective: Cost of Information penalty (Definition 1)
|
|
||||||
- RobustStackelbergObjective: Full maximin objective with Wasserstein robustness
|
|
||||||
- UXPenalty: User experience degradation from volatility
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from ...outlet.objectives.base import BaseObjective, CompositeObjective
|
|
||||||
from ...outlet.types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
|
||||||
from ...outlet.math_util import safe_log, EPS
|
|
||||||
|
|
||||||
class COIObjective(BaseObjective):
|
|
||||||
"""Cost of Information penalty from Definition 1.
|
|
||||||
|
|
||||||
COI(π) = E[P] - p_min
|
|
||||||
|
|
||||||
The expected price premium over marginal cost represents the platform's
|
|
||||||
pricing power. Agent reconnaissance erodes this by revealing price
|
|
||||||
distribution to buyers.
|
|
||||||
|
|
||||||
We implement COI_leakage = f(τ') · InfoValue(p, τ')
|
|
||||||
where f(τ') is the estimated agent probability.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, lambda_coi: float = 1.0, use_revelation: bool = False):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
lambda_coi: Weight on COI penalty
|
|
||||||
use_revelation: If True, use -log(π(p)) as info value (penalizes rare prices)
|
|
||||||
"""
|
|
||||||
self.lambda_coi = lambda_coi
|
|
||||||
self.use_revelation = use_revelation
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
# COI_leakage = α · InfoValue
|
|
||||||
alpha = hidden.contamination
|
|
||||||
|
|
||||||
if self.use_revelation:
|
|
||||||
# revelation surrogate: rare prices reveal more about policy
|
|
||||||
# InfoValue = -log(π(p|τ')) ≈ surprise of the price
|
|
||||||
price_surprise = np.mean(np.abs(quote.prices - instruments.refs) / (instruments.refs + EPS))
|
|
||||||
info_value = price_surprise
|
|
||||||
else:
|
|
||||||
# query-tax surrogate: each agent query incurs constant leakage
|
|
||||||
info_value = 1.0
|
|
||||||
|
|
||||||
leakage = alpha * info_value
|
|
||||||
return -self.lambda_coi * leakage
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
alpha = hidden.contamination
|
|
||||||
margins = (quote.prices - instruments.costs) / (instruments.costs + EPS)
|
|
||||||
return {
|
|
||||||
'coi_penalty': self.reward(quote, instruments, metrics, hidden, obs),
|
|
||||||
'contamination': alpha,
|
|
||||||
'avg_margin': float(np.mean(margins)),
|
|
||||||
}
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RobustObjectiveConfig:
|
|
||||||
"""Configuration for robust Stackelberg objective.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
lambda_coi: Weight on COI penalty (λ in Eq 23)
|
|
||||||
lambda_ux: Weight on UX penalty
|
|
||||||
lambda_volatility: Weight on price volatility penalty
|
|
||||||
gamma_inventory: Inventory risk aversion
|
|
||||||
wasserstein_epsilon: Ambiguity set radius (ε in Eq 21)
|
|
||||||
"""
|
|
||||||
lambda_coi: float = 0.5
|
|
||||||
lambda_ux: float = 0.1
|
|
||||||
lambda_volatility: float = 0.2
|
|
||||||
gamma_inventory: float = 0.1
|
|
||||||
wasserstein_epsilon: float = 0.1
|
|
||||||
|
|
||||||
class RobustStackelbergObjective(BaseObjective):
|
|
||||||
"""Implements the Maximin Objective from thesis Eq 23.
|
|
||||||
|
|
||||||
π* = argmax_π min_{Q ∈ U_ε(P̂_N)} E_d~Q[R(p,d) - λ·COI(p)]
|
|
||||||
|
|
||||||
The objective balances:
|
|
||||||
1. Revenue R(p,d) from human purchases
|
|
||||||
2. COI penalty for information leakage to agents
|
|
||||||
3. UX penalty for price volatility
|
|
||||||
4. Inventory/holding costs
|
|
||||||
|
|
||||||
The min over ambiguity set U_ε is approximated by penalizing
|
|
||||||
high contamination scenarios more heavily.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: RobustObjectiveConfig | None = None):
|
|
||||||
self.cfg = cfg or RobustObjectiveConfig()
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
cfg = self.cfg
|
|
||||||
|
|
||||||
# 1. base revenue (R(p,d))
|
|
||||||
revenue = metrics.revenue
|
|
||||||
cost = metrics.cost
|
|
||||||
profit = revenue - cost
|
|
||||||
|
|
||||||
# 2. COI penalty: scales with contamination and margin extraction
|
|
||||||
# high margins + high contamination = high leakage
|
|
||||||
alpha = hidden.contamination
|
|
||||||
margins = quote.prices - instruments.costs
|
|
||||||
avg_margin = float(np.mean(margins))
|
|
||||||
coi_penalty = cfg.lambda_coi * avg_margin * alpha
|
|
||||||
|
|
||||||
# 3. UX penalty: price volatility harms legitimate users
|
|
||||||
volatility_penalty = cfg.lambda_volatility * metrics.volatility
|
|
||||||
|
|
||||||
# 4. inventory/position cost
|
|
||||||
position_penalty = cfg.gamma_inventory * metrics.position_cost
|
|
||||||
|
|
||||||
# 5. lost opportunity cost (stockouts)
|
|
||||||
lost_penalty = 0.1 * metrics.lost_opportunity
|
|
||||||
|
|
||||||
# robust adjustment: under adversarial distribution Q,
|
|
||||||
# expect lower revenue and higher costs
|
|
||||||
# approximate via worst-case contamination within ε-ball
|
|
||||||
worst_case_alpha = min(alpha + cfg.wasserstein_epsilon, 1.0)
|
|
||||||
robustness_penalty = cfg.wasserstein_epsilon * avg_margin * worst_case_alpha
|
|
||||||
|
|
||||||
total = profit - coi_penalty - volatility_penalty - position_penalty - lost_penalty - robustness_penalty
|
|
||||||
|
|
||||||
return total
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
cfg = self.cfg
|
|
||||||
alpha = hidden.contamination
|
|
||||||
margins = quote.prices - instruments.costs
|
|
||||||
avg_margin = float(np.mean(margins))
|
|
||||||
|
|
||||||
return {
|
|
||||||
'revenue': metrics.revenue,
|
|
||||||
'cost': metrics.cost,
|
|
||||||
'profit': metrics.revenue - metrics.cost,
|
|
||||||
'coi_penalty': -cfg.lambda_coi * avg_margin * alpha,
|
|
||||||
'volatility_penalty': -cfg.lambda_volatility * metrics.volatility,
|
|
||||||
'position_penalty': -cfg.gamma_inventory * metrics.position_cost,
|
|
||||||
'lost_penalty': -0.1 * metrics.lost_opportunity,
|
|
||||||
'robustness_penalty': -cfg.wasserstein_epsilon * avg_margin * min(alpha + cfg.wasserstein_epsilon, 1.0),
|
|
||||||
'contamination': alpha,
|
|
||||||
'avg_margin_pct': avg_margin / (float(np.mean(instruments.costs)) + EPS),
|
|
||||||
}
|
|
||||||
|
|
||||||
class UXPenalty(BaseObjective):
|
|
||||||
"""User experience penalty from price volatility.
|
|
||||||
|
|
||||||
High price volatility degrades UX for legitimate human users.
|
|
||||||
This term ensures the defense doesn't harm real customers while
|
|
||||||
protecting against agent reconnaissance.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, scale: float = 1.0, max_acceptable_volatility: float = 0.1):
|
|
||||||
self.scale = scale
|
|
||||||
self.max_vol = max_acceptable_volatility
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
# penalty increases quadratically beyond threshold
|
|
||||||
excess_vol = max(0, metrics.volatility - self.max_vol)
|
|
||||||
return -self.scale * (excess_vol ** 2)
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {
|
|
||||||
'ux_penalty': self.reward(quote, instruments, metrics, hidden, obs),
|
|
||||||
'volatility': metrics.volatility,
|
|
||||||
}
|
|
||||||
|
|
||||||
class AdaptiveObjective(BaseObjective):
|
|
||||||
"""Objective that adapts weights based on estimated contamination.
|
|
||||||
|
|
||||||
When contamination is low, focus on revenue maximization.
|
|
||||||
When contamination is high, increase COI defense weight.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, base_lambda_coi: float = 0.3, max_lambda_coi: float = 2.0,
|
|
||||||
adaptation_rate: float = 2.0):
|
|
||||||
self.base_lambda = base_lambda_coi
|
|
||||||
self.max_lambda = max_lambda_coi
|
|
||||||
self.rate = adaptation_rate
|
|
||||||
|
|
||||||
def _adaptive_lambda(self, alpha: float) -> float:
|
|
||||||
# sigmoid scaling: λ(α) = base + (max-base) * sigmoid(rate*(α-0.5))
|
|
||||||
from ...outlet.math_util import sigmoid
|
|
||||||
scale = sigmoid(self.rate * (alpha - 0.3))
|
|
||||||
return self.base_lambda + (self.max_lambda - self.base_lambda) * scale
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
alpha = hidden.contamination
|
|
||||||
lambda_coi = self._adaptive_lambda(alpha)
|
|
||||||
|
|
||||||
profit = metrics.revenue - metrics.cost
|
|
||||||
margins = quote.prices - instruments.costs
|
|
||||||
coi_penalty = lambda_coi * float(np.mean(margins)) * alpha
|
|
||||||
|
|
||||||
return profit - coi_penalty
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
alpha = hidden.contamination
|
|
||||||
return {
|
|
||||||
'profit': metrics.revenue - metrics.cost,
|
|
||||||
'adaptive_lambda': self._adaptive_lambda(alpha),
|
|
||||||
'contamination': alpha,
|
|
||||||
}
|
|
||||||
|
|
||||||
def make_thesis_objective(lambda_coi: float = 0.5, lambda_ux: float = 0.1,
|
|
||||||
lambda_vol: float = 0.2) -> CompositeObjective:
|
|
||||||
"""Create the standard thesis objective composition."""
|
|
||||||
return CompositeObjective([
|
|
||||||
(RobustStackelbergObjective(RobustObjectiveConfig(
|
|
||||||
lambda_coi=lambda_coi, lambda_ux=lambda_ux, lambda_volatility=lambda_vol)), 1.0),
|
|
||||||
])
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""Thesis platform with real MDP behavioral models and separability scoring."""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
import numpy as np
|
|
||||||
from ...outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
|
|
||||||
PostedPriceMechanism, make_instruments, InstrumentType, LogLevel)
|
|
||||||
from ...outlet.mechanisms.posted_price import PostedPriceConfig
|
|
||||||
from ...outlet.observation import DefaultObservationBuilder, ObservationConfig
|
|
||||||
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
|
|
||||||
from .execution import HybridExecutionModel, HybridExecutionConfig
|
|
||||||
from .objectives import RobustStackelbergObjective, RobustObjectiveConfig
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ThesisConfig:
|
|
||||||
# instruments
|
|
||||||
n_instruments: int = 10
|
|
||||||
cost_range: tuple[float, float] = (5.0, 50.0)
|
|
||||||
margin_range: tuple[float, float] = (0.2, 0.5)
|
|
||||||
|
|
||||||
# contamination (Section 3.1)
|
|
||||||
alpha_contamination: float = 0.2
|
|
||||||
alpha_drift: float = 0.0
|
|
||||||
alpha_bounds: tuple[float, float] = (0.0, 0.5)
|
|
||||||
|
|
||||||
# objectives (Eq 23)
|
|
||||||
lambda_coi: float = 0.5
|
|
||||||
lambda_ux: float = 0.1
|
|
||||||
lambda_volatility: float = 0.2
|
|
||||||
wasserstein_epsilon: float = 0.1
|
|
||||||
|
|
||||||
# arrivals
|
|
||||||
sessions_per_step: int = 30
|
|
||||||
human_views_range: tuple[int, int] = (1, 4)
|
|
||||||
agent_views_range: tuple[int, int] = (3, 10)
|
|
||||||
|
|
||||||
# inventory
|
|
||||||
initial_inventory: float = 100.0
|
|
||||||
holding_cost_rate: float = 0.002
|
|
||||||
|
|
||||||
# real behavioral models (from sim.rl)
|
|
||||||
use_real_behavior: bool = True
|
|
||||||
use_separability: bool = False # disabled until classifier trained
|
|
||||||
human_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data"
|
|
||||||
agent_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data"
|
|
||||||
|
|
||||||
# simulation
|
|
||||||
max_steps: int = 500
|
|
||||||
seed: int | None = 24
|
|
||||||
log_level: LogLevel = LogLevel.AGG_ONLY
|
|
||||||
|
|
||||||
|
|
||||||
def _resolve_data_dirs(cfg: ThesisConfig) -> tuple[str, str]:
|
|
||||||
"""Resolve data directories for behavioral models."""
|
|
||||||
base = Path(__file__).parent.parent.parent.parent / "experiments"
|
|
||||||
human = cfg.human_data_dir or str(base / "collected_data")
|
|
||||||
agent = cfg.agent_data_dir or str(base / "agents/collected_data")
|
|
||||||
return human, agent
|
|
||||||
|
|
||||||
|
|
||||||
def make_thesis_platform(cfg: ThesisConfig | None = None) -> Platform:
|
|
||||||
"""Create platform with real MDP behavioral models.
|
|
||||||
|
|
||||||
Implements:
|
|
||||||
- Contaminated arrivals using learned MDP kernels from behavior_loader
|
|
||||||
- Hybrid execution with real separability scoring from lib.separability
|
|
||||||
- Robust Stackelberg objective (Eq 23)
|
|
||||||
"""
|
|
||||||
cfg = cfg or ThesisConfig()
|
|
||||||
rng = np.random.default_rng(cfg.seed)
|
|
||||||
human_dir, agent_dir = _resolve_data_dirs(cfg)
|
|
||||||
|
|
||||||
instruments = make_instruments(
|
|
||||||
n=cfg.n_instruments, cost_range=cfg.cost_range, margin_range=cfg.margin_range,
|
|
||||||
inst_type=InstrumentType.SKU, rng=rng)
|
|
||||||
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
|
|
||||||
|
|
||||||
arrival = ContaminatedArrivalModel(ContaminatedArrivalConfig(
|
|
||||||
base_rate=cfg.sessions_per_step,
|
|
||||||
alpha_contamination=cfg.alpha_contamination,
|
|
||||||
alpha_drift=cfg.alpha_drift,
|
|
||||||
alpha_bounds=cfg.alpha_bounds,
|
|
||||||
human_views_range=cfg.human_views_range,
|
|
||||||
agent_views_range=cfg.agent_views_range,
|
|
||||||
use_real_behavior=cfg.use_real_behavior,
|
|
||||||
human_data_dir=human_dir,
|
|
||||||
agent_data_dir=agent_dir,
|
|
||||||
))
|
|
||||||
|
|
||||||
execution = HybridExecutionModel(HybridExecutionConfig(
|
|
||||||
use_separability=cfg.use_separability,
|
|
||||||
))
|
|
||||||
|
|
||||||
mechanism = PostedPriceMechanism(PostedPriceConfig(max_delta_pct=0.15, min_margin_pct=0.05))
|
|
||||||
position = PositionModel(PositionConfig(initial_position=cfg.initial_inventory, holding_cost_rate=cfg.holding_cost_rate))
|
|
||||||
|
|
||||||
market = None
|
|
||||||
objective = RobustStackelbergObjective(RobustObjectiveConfig(
|
|
||||||
lambda_coi=cfg.lambda_coi, lambda_ux=cfg.lambda_ux,
|
|
||||||
lambda_volatility=cfg.lambda_volatility, wasserstein_epsilon=cfg.wasserstein_epsilon))
|
|
||||||
|
|
||||||
obs_builder = DefaultObservationBuilder(ObservationConfig(mask_true_demand=True))
|
|
||||||
platform_cfg = PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
|
||||||
seed=cfg.seed, log_level=cfg.log_level, mask_demand=True)
|
|
||||||
|
|
||||||
return Platform(instruments=instruments, mechanism=mechanism, arrival=arrival, execution=execution,
|
|
||||||
position=position, market=market, obs_builder=obs_builder, objective=objective, cfg=platform_cfg)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AblationConfig(ThesisConfig):
|
|
||||||
disable_coi_penalty: bool = False
|
|
||||||
disable_ux_penalty: bool = False
|
|
||||||
disable_contamination: bool = False
|
|
||||||
disable_real_behavior: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
def make_ablation_platform(cfg: AblationConfig) -> Platform:
|
|
||||||
if cfg.disable_coi_penalty:
|
|
||||||
cfg.lambda_coi = 0.0
|
|
||||||
if cfg.disable_ux_penalty:
|
|
||||||
cfg.lambda_ux = 0.0
|
|
||||||
if cfg.disable_contamination:
|
|
||||||
cfg.alpha_contamination = 0.0
|
|
||||||
if cfg.disable_real_behavior:
|
|
||||||
cfg.use_real_behavior = False
|
|
||||||
cfg.use_separability = False
|
|
||||||
return make_thesis_platform(cfg)
|
|
||||||
|
|
||||||
|
|
||||||
def sweep_contamination(alpha_values: list[float], base_cfg: ThesisConfig | None = None,
|
|
||||||
n_steps: int = 100, seed: int = 42) -> dict[float, dict]:
|
|
||||||
"""Test performance across contamination levels (Theorem 1 validation)."""
|
|
||||||
from ...experiments.eval import rollout, fixed_price_policy
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
base_cfg = base_cfg or ThesisConfig()
|
|
||||||
|
|
||||||
for alpha in alpha_values:
|
|
||||||
cfg = ThesisConfig(**{k: v for k, v in base_cfg.__dict__.items() if k != 'alpha_contamination'},
|
|
||||||
alpha_contamination=alpha)
|
|
||||||
platform = make_thesis_platform(cfg)
|
|
||||||
policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
result = rollout(platform, policy, n_steps, seed=seed)
|
|
||||||
results[alpha] = {
|
|
||||||
'total_reward': result.total_reward,
|
|
||||||
'total_pnl': result.total_pnl,
|
|
||||||
'avg_conversion': result.avg_conversion,
|
|
||||||
'final_contamination': platform._hidden.contamination,
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def sweep_behavior_modes(base_cfg: ThesisConfig | None = None, n_steps: int = 100, seed: int = 42) -> dict[str, dict]:
|
|
||||||
"""Compare real vs synthetic behavioral models."""
|
|
||||||
from ...experiments.eval import rollout, fixed_price_policy
|
|
||||||
|
|
||||||
base_cfg = base_cfg or ThesisConfig()
|
|
||||||
modes = {
|
|
||||||
'real_mdp': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': True}),
|
|
||||||
'synthetic': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': False, 'use_separability': False}),
|
|
||||||
'real_mdp_no_sep': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': False}),
|
|
||||||
}
|
|
||||||
|
|
||||||
results = {}
|
|
||||||
for name, cfg in modes.items():
|
|
||||||
platform = make_thesis_platform(cfg)
|
|
||||||
policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
result = rollout(platform, policy, n_steps, seed=seed)
|
|
||||||
results[name] = {
|
|
||||||
'total_reward': result.total_reward,
|
|
||||||
'total_pnl': result.total_pnl,
|
|
||||||
'avg_conversion': result.avg_conversion,
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Thesis simulation experiments with real MDP behavioral models."""
|
|
||||||
from __future__ import annotations
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
|
||||||
|
|
||||||
from lab.case.thesis.platform import make_thesis_platform, ThesisConfig
|
|
||||||
from lab.case.thesis.metrics import compute_coi, compute_separability
|
|
||||||
from lab.experiments.eval import compare_policies
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
|
|
||||||
def demo_basic_simulation():
|
|
||||||
print("=" * 70)
|
|
||||||
print("THESIS SIMULATION: Contaminated Dynamic Pricing (Real MDP Kernels)")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, lambda_coi=0.5,
|
|
||||||
max_steps=100, seed=42, use_real_behavior=True)
|
|
||||||
platform = make_thesis_platform(cfg)
|
|
||||||
|
|
||||||
print(f"\nInstruments: {platform.instruments.n}")
|
|
||||||
print(f"Reference prices: {platform.instruments.refs.round(2)}")
|
|
||||||
print(f"Costs: {platform.instruments.costs.round(2)}")
|
|
||||||
print(f"Initial contamination alpha={cfg.alpha_contamination}")
|
|
||||||
print(f"Using real behavior: {cfg.use_real_behavior}")
|
|
||||||
|
|
||||||
result = platform.reset(seed=42)
|
|
||||||
total_reward, coi_history = 0, []
|
|
||||||
|
|
||||||
print(f"\n{'Step':>5} {'Reward':>10} {'PnL':>10} {'COI':>8} {'alpha':>6} {'Conv':>8}")
|
|
||||||
print("-" * 55)
|
|
||||||
|
|
||||||
for t in range(cfg.max_steps):
|
|
||||||
action = platform.instruments.refs * np.random.uniform(0.95, 1.15, size=platform.instruments.n)
|
|
||||||
result = platform.step(action)
|
|
||||||
total_reward += result.reward
|
|
||||||
coi = compute_coi(platform._quote, platform.instruments, result.metrics, result.hidden.contamination)
|
|
||||||
coi_history.append(coi.coi_level)
|
|
||||||
|
|
||||||
if t % 20 == 0:
|
|
||||||
print(f"{t:5d} {result.reward:10.2f} {result.metrics.pnl:10.2f} "
|
|
||||||
f"{coi.coi_level:8.2f} {result.hidden.contamination:6.2f} {result.metrics.conversion:8.3f}")
|
|
||||||
|
|
||||||
print("-" * 55)
|
|
||||||
print(f"Total Reward: {total_reward:.2f}")
|
|
||||||
print(f"Average COI: {np.mean(coi_history):.2f}")
|
|
||||||
print(f"COI Trend: {coi_history[-1] - coi_history[0]:+.2f}")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_contamination_sweep():
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("EXPERIMENT: COI Erosion vs Contamination (Theorem 1)")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
from lab.case.thesis.platform import sweep_contamination
|
|
||||||
trials = 20
|
|
||||||
alpha_values = [i/trials for i in range(trials)]
|
|
||||||
results = sweep_contamination(alpha_values, n_steps=100, seed=42)
|
|
||||||
|
|
||||||
print(f"\n{'alpha':>6} {'Reward':>12} {'PnL':>12} {'Conv':>10}")
|
|
||||||
print("-" * 45)
|
|
||||||
for alpha, m in sorted(results.items()):
|
|
||||||
print(f"{alpha:6.2f} {m['total_reward']:12.2f} {m['total_pnl']:12.2f} {m['avg_conversion']:10.3f}")
|
|
||||||
|
|
||||||
rewards = [results[a]['total_reward'] for a in sorted(results.keys())]
|
|
||||||
dataset = np.array([[a, r] for a, r in zip(alpha_values, rewards)])
|
|
||||||
trend = np.corrcoef(dataset[:, 0], dataset[:, 1])[0, 1]
|
|
||||||
print(f"Trend (alpha~reward correlation): {trend:.3f}")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_policy_comparison():
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("EXPERIMENT: Policy Comparison under Contamination")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.25, max_steps=100, seed=42)
|
|
||||||
platform = make_thesis_platform(cfg)
|
|
||||||
|
|
||||||
def fixed_policy(obs, t): return platform.instruments.refs.copy(), 1.0
|
|
||||||
def aggressive_policy(obs, t): return platform.instruments.refs * 1.3, 1.0
|
|
||||||
def conservative_policy(obs, t): return platform.instruments.refs * 1.05, 1.0
|
|
||||||
def adaptive_policy(obs, t):
|
|
||||||
fills = obs[platform.instruments.n:2*platform.instruments.n]
|
|
||||||
exp = obs[2*platform.instruments.n:3*platform.instruments.n]
|
|
||||||
conv = np.sum(fills) / (np.sum(exp) + 1e-8)
|
|
||||||
return platform.instruments.refs * (1.0 + 0.2 * conv), 1.0
|
|
||||||
|
|
||||||
policies = {'fixed': fixed_policy, 'aggressive': aggressive_policy,
|
|
||||||
'conservative': conservative_policy, 'adaptive': adaptive_policy}
|
|
||||||
results = compare_policies(platform, policies, n_steps=100, n_runs=3, seed=42)
|
|
||||||
|
|
||||||
print(f"\n{'Policy':>15} {'Reward':>12} {'Std':>10} {'PnL':>12} {'Conv':>10}")
|
|
||||||
print("-" * 65)
|
|
||||||
for name, r in sorted(results.items(), key=lambda x: -x[1]['mean_reward']):
|
|
||||||
print(f"{name:>15} {r['mean_reward']:12.2f} {r['std_reward']:10.2f} "
|
|
||||||
f"{r['mean_pnl']:12.2f} {r['mean_conversion']:10.3f}")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_session_analysis():
|
|
||||||
"""Analyze session-level behavior from MDP trajectories."""
|
|
||||||
print("\n" + "=" * 70)
|
|
||||||
print("EXPERIMENT: Session Analysis (Ground Truth)")
|
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
from lab.outlet.constants import LogLevel
|
|
||||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, max_steps=50,
|
|
||||||
log_level=LogLevel.FULL, seed=42, use_real_behavior=True)
|
|
||||||
platform = make_thesis_platform(cfg)
|
|
||||||
|
|
||||||
result = platform.reset(seed=42)
|
|
||||||
human_sessions, agent_sessions = 0, 0
|
|
||||||
|
|
||||||
for t in range(cfg.max_steps):
|
|
||||||
action = platform.instruments.refs * 1.1
|
|
||||||
result = platform.step(action)
|
|
||||||
sep = compute_separability(result.logs, result.hidden.contamination)
|
|
||||||
human_sessions += sep.n_human_sessions
|
|
||||||
agent_sessions += sep.n_agent_sessions
|
|
||||||
|
|
||||||
total = human_sessions + agent_sessions
|
|
||||||
print(f"\nTotal sessions: {total}")
|
|
||||||
print(f"Human sessions: {human_sessions} ({100*human_sessions/total:.1f}%)")
|
|
||||||
print(f"Agent sessions: {agent_sessions} ({100*agent_sessions/total:.1f}%)")
|
|
||||||
print(f"True contamination: {cfg.alpha_contamination:.1%}")
|
|
||||||
print(f"Observed contamination: {agent_sessions/total:.1%}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
demo_basic_simulation()
|
|
||||||
demo_contamination_sweep()
|
|
||||||
# demo_policy_comparison()
|
|
||||||
# demo_session_analysis()
|
|
||||||
156
lab/config.py
156
lab/config.py
@@ -1,156 +0,0 @@
|
|||||||
"""
|
|
||||||
Configuration and factory functions for creating pre-configured platforms.
|
|
||||||
|
|
||||||
This module provides:
|
|
||||||
- RetailConfig, MarketMakingConfig: Configuration dataclasses
|
|
||||||
- make_retail_platform: Factory for retail dynamic pricing scenarios
|
|
||||||
- make_market_making_platform: Factory for market making scenarios
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> from lab.config import make_retail_platform
|
|
||||||
>>> platform = make_retail_platform(RetailConfig(n_instruments=5))
|
|
||||||
>>> result = platform.reset(seed=42)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from .outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
|
|
||||||
PostedPriceMechanism, TwoSidedMechanism, make_instruments,
|
|
||||||
InstrumentType, LogLevel)
|
|
||||||
from .outlet.mechanisms.posted_price import PostedPriceConfig
|
|
||||||
from .outlet.mechanisms.two_sided import TwoSidedConfig
|
|
||||||
from .population import (SessionArrivalModel, PoissonArrivalModel, HawkesArrivalModel,
|
|
||||||
ElasticityExecutionModel, IntensityExecutionModel,
|
|
||||||
ReactiveCompetitorModel, GBMMarketModel)
|
|
||||||
from .population.arrivals import SessionArrivalConfig, PoissonArrivalConfig, HawkesArrivalConfig
|
|
||||||
from .population.execution import ElasticityConfig, IntensityConfig
|
|
||||||
from .population.competitors import ReactiveCompetitorConfig, GBMMarketConfig
|
|
||||||
from .outlet.objectives.factory import retail_objective, market_making_objective
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RetailConfig:
|
|
||||||
"""Configuration for retail dynamic pricing scenario.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
n_instruments: Number of products to price
|
|
||||||
cost_range: (min, max) for random product costs
|
|
||||||
margin_range: (min, max) for random initial margins
|
|
||||||
initial_inventory: Starting inventory per product
|
|
||||||
holding_cost_rate: Cost per unit per step for holding
|
|
||||||
sessions_per_step: Number of browsing sessions per step
|
|
||||||
contamination: Fraction of sessions that are scrapers
|
|
||||||
max_steps: Maximum episode length
|
|
||||||
seed: Random seed for reproducibility
|
|
||||||
"""
|
|
||||||
n_instruments: int = 10
|
|
||||||
cost_range: tuple[float, float] = (5.0, 50.0)
|
|
||||||
margin_range: tuple[float, float] = (0.2, 0.5)
|
|
||||||
initial_inventory: float = 100.0
|
|
||||||
holding_cost_rate: float = 0.002
|
|
||||||
sessions_per_step: int = 30
|
|
||||||
contamination: float = 0.1
|
|
||||||
max_steps: int = 500
|
|
||||||
seed: int | None = None
|
|
||||||
|
|
||||||
def make_retail_platform(cfg: RetailConfig | None = None) -> Platform:
|
|
||||||
"""Create a pre-configured retail dynamic pricing platform.
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- Mechanism: PostedPriceMechanism (single price per product)
|
|
||||||
- Arrivals: SessionArrivalModel (browsing sessions with views)
|
|
||||||
- Execution: ElasticityExecutionModel (price sensitivity)
|
|
||||||
- Market: ReactiveCompetitorModel (can trigger price wars)
|
|
||||||
- Objective: PnL - holding_cost - volatility - lost_opportunity
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cfg: Configuration (uses defaults if None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured Platform instance
|
|
||||||
"""
|
|
||||||
cfg = cfg or RetailConfig()
|
|
||||||
rng = np.random.default_rng(cfg.seed)
|
|
||||||
|
|
||||||
instruments = make_instruments(cfg.n_instruments, cfg.cost_range, cfg.margin_range,
|
|
||||||
InstrumentType.SKU, rng)
|
|
||||||
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
|
|
||||||
|
|
||||||
mechanism = PostedPriceMechanism(PostedPriceConfig())
|
|
||||||
arrival = SessionArrivalModel(SessionArrivalConfig(
|
|
||||||
sessions_per_step=cfg.sessions_per_step, contamination=cfg.contamination))
|
|
||||||
execution = ElasticityExecutionModel(ElasticityConfig())
|
|
||||||
position = PositionModel(PositionConfig(
|
|
||||||
initial_position=cfg.initial_inventory,
|
|
||||||
holding_cost_rate=cfg.holding_cost_rate))
|
|
||||||
market = ReactiveCompetitorModel(ReactiveCompetitorConfig(), refs=instruments.refs)
|
|
||||||
objective = retail_objective()
|
|
||||||
|
|
||||||
return Platform(
|
|
||||||
instruments=instruments, mechanism=mechanism, arrival=arrival,
|
|
||||||
execution=execution, position=position, market=market, objective=objective,
|
|
||||||
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
|
||||||
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
|
|
||||||
)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MarketMakingConfig:
|
|
||||||
"""Configuration for market making scenario.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
n_instruments: Number of assets to quote
|
|
||||||
initial_mid: Initial mid-price for assets
|
|
||||||
mu: Price drift (expected return)
|
|
||||||
sigma: Price volatility
|
|
||||||
gamma: Inventory risk aversion parameter
|
|
||||||
base_arrival_rate: Order arrival rate (Hawkes baseline)
|
|
||||||
max_steps: Maximum episode length
|
|
||||||
seed: Random seed for reproducibility
|
|
||||||
"""
|
|
||||||
n_instruments: int = 5
|
|
||||||
initial_mid: float = 100.0
|
|
||||||
mu: float = 0.0
|
|
||||||
sigma: float = 0.02
|
|
||||||
gamma: float = 0.1
|
|
||||||
base_arrival_rate: float = 20.0
|
|
||||||
max_steps: int = 1000
|
|
||||||
seed: int | None = None
|
|
||||||
|
|
||||||
def make_market_making_platform(cfg: MarketMakingConfig | None = None) -> Platform:
|
|
||||||
"""Create a pre-configured market making platform.
|
|
||||||
|
|
||||||
Components:
|
|
||||||
- Mechanism: TwoSidedMechanism (bid-ask spread quoting)
|
|
||||||
- Arrivals: HawkesArrivalModel (clustered order flow)
|
|
||||||
- Execution: IntensityExecutionModel (distance-based fills)
|
|
||||||
- Market: GBMMarketModel (geometric Brownian motion mid-prices)
|
|
||||||
- Objective: PnL + spread_capture - inventory_risk
|
|
||||||
|
|
||||||
Args:
|
|
||||||
cfg: Configuration (uses defaults if None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Configured Platform instance
|
|
||||||
"""
|
|
||||||
cfg = cfg or MarketMakingConfig()
|
|
||||||
rng = np.random.default_rng(cfg.seed)
|
|
||||||
|
|
||||||
instruments = make_instruments(cfg.n_instruments, (cfg.initial_mid*0.9, cfg.initial_mid*1.1),
|
|
||||||
(0.0, 0.0), InstrumentType.ASSET, rng)
|
|
||||||
instruments.position = np.zeros(cfg.n_instruments)
|
|
||||||
|
|
||||||
mechanism = TwoSidedMechanism(TwoSidedConfig())
|
|
||||||
arrival = HawkesArrivalModel(HawkesArrivalConfig(base_rate=cfg.base_arrival_rate))
|
|
||||||
execution = IntensityExecutionModel(IntensityConfig())
|
|
||||||
position = PositionModel(PositionConfig(
|
|
||||||
initial_position=0.0, min_position=-500, max_position=500,
|
|
||||||
holding_cost_rate=0.0)) # use inventory risk penalty instead
|
|
||||||
market = GBMMarketModel(GBMMarketConfig(mu=cfg.mu, sigma=cfg.sigma),
|
|
||||||
initial=instruments.refs)
|
|
||||||
objective = market_making_objective(gamma=cfg.gamma, sigma=cfg.sigma)
|
|
||||||
|
|
||||||
return Platform(
|
|
||||||
instruments=instruments, mechanism=mechanism, arrival=arrival,
|
|
||||||
execution=execution, position=position, market=market, objective=objective,
|
|
||||||
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
|
||||||
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
SPHINXOPTS ?=
|
|
||||||
SPHINXBUILD ?= sphinx-build
|
|
||||||
SOURCEDIR = .
|
|
||||||
BUILDDIR = _build
|
|
||||||
|
|
||||||
help:
|
|
||||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
|
|
||||||
.PHONY: help Makefile
|
|
||||||
|
|
||||||
%: Makefile
|
|
||||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import os
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, os.path.abspath('../..'))
|
|
||||||
|
|
||||||
project = 'Quote-Control Simulator'
|
|
||||||
copyright = '2025, PHANTOM Research'
|
|
||||||
author = 'PHANTOM Research'
|
|
||||||
release = '0.1.0'
|
|
||||||
|
|
||||||
extensions = [
|
|
||||||
'sphinx.ext.autodoc',
|
|
||||||
'sphinx.ext.napoleon',
|
|
||||||
'sphinx.ext.viewcode',
|
|
||||||
'sphinx.ext.intersphinx',
|
|
||||||
'sphinx.ext.autosummary',
|
|
||||||
]
|
|
||||||
|
|
||||||
templates_path = ['_templates']
|
|
||||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
|
||||||
|
|
||||||
html_theme = 'alabaster'
|
|
||||||
html_static_path = ['_static']
|
|
||||||
|
|
||||||
autodoc_default_options = {
|
|
||||||
'members': True,
|
|
||||||
'undoc-members': True,
|
|
||||||
'show-inheritance': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
napoleon_google_docstring = True
|
|
||||||
napoleon_numpy_docstring = True
|
|
||||||
napoleon_include_init_with_doc = True
|
|
||||||
|
|
||||||
intersphinx_mapping = {
|
|
||||||
'python': ('https://docs.python.org/3', None),
|
|
||||||
'numpy': ('https://numpy.org/doc/stable/', None),
|
|
||||||
}
|
|
||||||
|
|
||||||
autosummary_generate = True
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
Quote-Control Simulator
|
|
||||||
=======================
|
|
||||||
|
|
||||||
Research-grade platform for dynamic pricing and market making experiments.
|
|
||||||
|
|
||||||
The platform abstracts pricing as: **Quote → Arrival → Execution → Position**
|
|
||||||
|
|
||||||
Supports multiple mechanisms:
|
|
||||||
|
|
||||||
* **PostedPrice**: retail dynamic pricing
|
|
||||||
* **TwoSided**: market making with bid-ask spreads
|
|
||||||
* **Auction**: reserve/shading for auction settings
|
|
||||||
|
|
||||||
Quick Start
|
|
||||||
-----------
|
|
||||||
|
|
||||||
.. code-block:: python
|
|
||||||
|
|
||||||
from lab.config import make_retail_platform
|
|
||||||
from lab.experiments import rollout, fixed_price_policy
|
|
||||||
|
|
||||||
platform = make_retail_platform()
|
|
||||||
policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
result = rollout(platform, policy, n_steps=100)
|
|
||||||
print(f"Total PnL: {result.total_pnl:.2f}")
|
|
||||||
|
|
||||||
.. toctree::
|
|
||||||
:maxdepth: 2
|
|
||||||
:caption: Contents:
|
|
||||||
|
|
||||||
system_overview
|
|
||||||
modules/outlet
|
|
||||||
modules/population
|
|
||||||
modules/experiments
|
|
||||||
|
|
||||||
Indices
|
|
||||||
-------
|
|
||||||
|
|
||||||
* :ref:`genindex`
|
|
||||||
* :ref:`modindex`
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
Experiments
|
|
||||||
===========
|
|
||||||
|
|
||||||
Evaluation & OPE
|
|
||||||
----------------
|
|
||||||
|
|
||||||
.. automodule:: lab.experiments.eval
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Configuration
|
|
||||||
-------------
|
|
||||||
|
|
||||||
.. automodule:: lab.config
|
|
||||||
:members:
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
Outlet (Core Simulator)
|
|
||||||
=======================
|
|
||||||
|
|
||||||
Types
|
|
||||||
-----
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.types
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Constants
|
|
||||||
---------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.constants
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Protocols
|
|
||||||
---------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.protocols
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Platform
|
|
||||||
--------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.platform
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Stock & Position
|
|
||||||
----------------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.stock
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Observation
|
|
||||||
-----------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.observation
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Mechanisms
|
|
||||||
----------
|
|
||||||
|
|
||||||
Posted Price
|
|
||||||
~~~~~~~~~~~~
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.mechanisms.posted_price
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Two-Sided (Market Making)
|
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.mechanisms.two_sided
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Auction
|
|
||||||
~~~~~~~
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.mechanisms.auction
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Objectives
|
|
||||||
----------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.objectives.base
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.objectives.penalties
|
|
||||||
:members:
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.objectives.factory
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Math Utilities
|
|
||||||
--------------
|
|
||||||
|
|
||||||
.. automodule:: lab.outlet.math_util
|
|
||||||
:members:
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
Population Models
|
|
||||||
=================
|
|
||||||
|
|
||||||
Arrival Models
|
|
||||||
--------------
|
|
||||||
|
|
||||||
.. automodule:: lab.population.arrivals
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Execution Models
|
|
||||||
----------------
|
|
||||||
|
|
||||||
.. automodule:: lab.population.execution
|
|
||||||
:members:
|
|
||||||
|
|
||||||
Competitor / Market Models
|
|
||||||
--------------------------
|
|
||||||
|
|
||||||
.. automodule:: lab.population.competitors
|
|
||||||
:members:
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
System Overview
|
|
||||||
===============
|
|
||||||
|
|
||||||
The simulator organises dynamic pricing and market-making experiments as a
|
|
||||||
closed loop with the following stages:
|
|
||||||
|
|
||||||
* **Quote** – a policy or agent emits a :class:`lab.outlet.types.Quote`. The
|
|
||||||
quote is normalised and validated by a concrete
|
|
||||||
:class:`lab.outlet.protocols.Mechanism` implementation
|
|
||||||
(posted-price, two-sided, auction).
|
|
||||||
* **Arrival** – a :class:`lab.outlet.protocols.ArrivalModel` samples a stream of
|
|
||||||
:class:`lab.outlet.types.Opportunity` objects given the current time,
|
|
||||||
instrument catalogue, and market state.
|
|
||||||
* **Execution** – the :class:`lab.outlet.protocols.ExecutionModel` converts an
|
|
||||||
opportunity into a probabilistic fill using the active quote, optional
|
|
||||||
competitor prices, and demand-side context.
|
|
||||||
* **Position** – a :class:`lab.outlet.protocols.PositionModel` enforces
|
|
||||||
inventory or position constraints, censors oversized fills, and accrues
|
|
||||||
holding and shortage costs.
|
|
||||||
* **Observation & Reward** – the
|
|
||||||
:class:`lab.outlet.protocols.ObservationBuilder` constructs the censored view
|
|
||||||
exposed to the agent, while a :class:`lab.outlet.protocols.Objective`
|
|
||||||
transforms :class:`lab.outlet.types.StepMetrics` into a scalar reward with an
|
|
||||||
optional breakdown per term.
|
|
||||||
|
|
||||||
These components are orchestrated by :class:`lab.outlet.platform.Platform`,
|
|
||||||
which manages internal hidden state, deterministic seeding, and logging.
|
|
||||||
|
|
||||||
Component Matrix
|
|
||||||
----------------
|
|
||||||
|
|
||||||
=============================== ==============================================
|
|
||||||
Layer Responsibilities / Examples
|
|
||||||
=============================== ==============================================
|
|
||||||
Mechanisms Quote normalisation, execution semantics
|
|
||||||
(`posted_price`, `two_sided`, `auction`).
|
|
||||||
Population models Arrivals (:mod:`lab.population.arrivals`),
|
|
||||||
execution probability models
|
|
||||||
(:mod:`lab.population.execution`), and
|
|
||||||
competitor or market dynamics
|
|
||||||
(:mod:`lab.population.competitors`).
|
|
||||||
Position management Inventory limits, replenishment, holding and
|
|
||||||
shortage costs (:mod:`lab.outlet.stock`).
|
|
||||||
Observation & logging Censored observations and optional event logs
|
|
||||||
(:mod:`lab.outlet.observation`).
|
|
||||||
Objectives Reward composition utilities
|
|
||||||
(:mod:`lab.outlet.objectives`).
|
|
||||||
Experiments Rollout helpers, baseline policies, off-policy
|
|
||||||
evaluation (:mod:`lab.experiments.eval`).
|
|
||||||
=============================== ==============================================
|
|
||||||
|
|
||||||
Preconfigured Platforms
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
Two high-level factories in :mod:`lab.config` wire common combinations of the
|
|
||||||
building blocks:
|
|
||||||
|
|
||||||
* **Retail dynamic pricing** – posted-price mechanism, session arrivals with
|
|
||||||
contamination, elasticity-based executions, reactive competitor model, and a
|
|
||||||
composite objective that penalises volatility, holding costs, and lost
|
|
||||||
opportunities.
|
|
||||||
* **Market making** – two-sided quoting, Hawkes order flow, intensity-based
|
|
||||||
executions, geometric Brownian motion mid-prices, and an objective combining
|
|
||||||
PnL, spread capture, and quadratic inventory risk.
|
|
||||||
|
|
||||||
State & Reset Behaviour
|
|
||||||
-----------------------
|
|
||||||
|
|
||||||
When you call :meth:`lab.outlet.platform.Platform.reset`, the platform resets
|
|
||||||
instrument positions, quotes, and hidden state, but component implementations
|
|
||||||
may maintain their own internal buffers. For reproducible experiments:
|
|
||||||
|
|
||||||
* Reuse freshly instantiated arrival/market models per episode, or add explicit
|
|
||||||
``reset`` methods if the model keeps history (for example,
|
|
||||||
:class:`lab.population.arrivals.HawkesArrivalModel` maintains an event
|
|
||||||
history, while :class:`lab.population.competitors.ReactiveCompetitorModel`
|
|
||||||
tracks prior competitor quotes).
|
|
||||||
* Seed randomness through the factory configuration (``RetailConfig.seed`` or
|
|
||||||
``MarketMakingConfig.seed``) or pass a seed to ``Platform.reset`` for
|
|
||||||
deterministic rollouts.
|
|
||||||
|
|
||||||
Extending the Platform
|
|
||||||
----------------------
|
|
||||||
|
|
||||||
To support a new domain:
|
|
||||||
|
|
||||||
1. Create custom Mechanism/Arrival/Execution/Market/Observation components by
|
|
||||||
implementing the respective protocol in :mod:`lab.outlet.protocols`.
|
|
||||||
2. Compose a new objective with
|
|
||||||
:func:`lab.outlet.objectives.factory.make_composite` or write a bespoke
|
|
||||||
:class:`lab.outlet.objectives.base.BaseObjective`.
|
|
||||||
3. Wire everything together via :class:`lab.outlet.platform.Platform` directly
|
|
||||||
or expose a helper factory in :mod:`lab.config`.
|
|
||||||
|
|
||||||
Use :func:`lab.experiments.rollout` and
|
|
||||||
:func:`lab.experiments.compare_policies` to benchmark candidate policies under
|
|
||||||
multiple random seeds, collecting per-step logs for analysis or OPE.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
from .eval import (rollout, RolloutResult, compare_policies, compute_ips, OPEResult,
|
|
||||||
fixed_price_policy, cost_plus_margin_policy, random_walk_policy, epsilon_greedy_policy)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'rollout', 'RolloutResult', 'compare_policies', 'compute_ips', 'OPEResult',
|
|
||||||
'fixed_price_policy', 'cost_plus_margin_policy', 'random_walk_policy', 'epsilon_greedy_policy',
|
|
||||||
]
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
"""
|
|
||||||
Evaluation utilities for policy testing and off-policy evaluation.
|
|
||||||
|
|
||||||
This module provides:
|
|
||||||
- rollout: Run a policy on the platform for multiple steps
|
|
||||||
- compare_policies: Compare multiple policies with statistics
|
|
||||||
- Baseline policies: fixed_price, cost_plus_margin, random_walk, epsilon_greedy
|
|
||||||
- OPE estimators: IPS and SNIPS for off-policy evaluation
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> from lab.config import make_retail_platform
|
|
||||||
>>> from lab.experiments.eval import rollout, fixed_price_policy
|
|
||||||
>>> platform = make_retail_platform()
|
|
||||||
>>> policy = fixed_price_policy(platform.instruments.refs)
|
|
||||||
>>> result = rollout(platform, policy, n_steps=100)
|
|
||||||
>>> print(f"Total PnL: {result.total_pnl:.2f}")
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable, Any
|
|
||||||
import numpy as np
|
|
||||||
from ..outlet.platform import Platform
|
|
||||||
from ..outlet.types import StepResult, StepLogs, Quote
|
|
||||||
|
|
||||||
# Policy signature: takes (observation_flat, timestep) -> (action_prices, propensity)
|
|
||||||
Policy = Callable[[np.ndarray, int], tuple[np.ndarray, float]]
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class RolloutResult:
|
|
||||||
"""Results from a policy rollout.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
rewards: Per-step rewards
|
|
||||||
metrics: Per-step StepMetrics objects
|
|
||||||
logs: Per-step StepLogs objects
|
|
||||||
total_reward: Sum of rewards
|
|
||||||
total_pnl: Sum of PnL from metrics
|
|
||||||
avg_conversion: Average conversion rate
|
|
||||||
"""
|
|
||||||
rewards: list[float]
|
|
||||||
metrics: list[Any]
|
|
||||||
logs: list[StepLogs]
|
|
||||||
total_reward: float
|
|
||||||
total_pnl: float
|
|
||||||
avg_conversion: float
|
|
||||||
|
|
||||||
def rollout(platform: Platform, policy: Policy, n_steps: int, seed: int | None = None) -> RolloutResult:
|
|
||||||
"""Execute a policy on the platform for n_steps.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: The simulation platform
|
|
||||||
policy: Function (obs, t) -> (action, propensity)
|
|
||||||
n_steps: Number of steps to run
|
|
||||||
seed: Random seed for reproducibility
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
RolloutResult with rewards, metrics, and summary statistics
|
|
||||||
"""
|
|
||||||
result = platform.reset(seed)
|
|
||||||
rewards, metrics, logs = [], [], []
|
|
||||||
|
|
||||||
for t in range(n_steps):
|
|
||||||
obs_flat = result.obs.to_flat()
|
|
||||||
action, propensity = policy(obs_flat, t)
|
|
||||||
result = platform.step(action, propensity)
|
|
||||||
rewards.append(result.reward)
|
|
||||||
metrics.append(result.metrics)
|
|
||||||
logs.append(result.logs)
|
|
||||||
if result.terminated or result.truncated:
|
|
||||||
break
|
|
||||||
|
|
||||||
return RolloutResult(
|
|
||||||
rewards=rewards, metrics=metrics, logs=logs,
|
|
||||||
total_reward=sum(rewards),
|
|
||||||
total_pnl=sum(m.pnl for m in metrics),
|
|
||||||
avg_conversion=np.mean([m.conversion for m in metrics])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Baseline policies for comparison
|
|
||||||
|
|
||||||
def fixed_price_policy(refs: np.ndarray) -> Policy:
|
|
||||||
"""Policy that always quotes at reference prices."""
|
|
||||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
|
||||||
return refs.copy(), 1.0
|
|
||||||
return policy
|
|
||||||
|
|
||||||
def cost_plus_margin_policy(costs: np.ndarray, margin: float = 0.3) -> Policy:
|
|
||||||
"""Policy that quotes at cost * (1 + margin)."""
|
|
||||||
prices = costs * (1 + margin)
|
|
||||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
|
||||||
return prices.copy(), 1.0
|
|
||||||
return policy
|
|
||||||
|
|
||||||
def random_walk_policy(refs: np.ndarray, volatility: float = 0.05,
|
|
||||||
rng: np.random.Generator | None = None) -> Policy:
|
|
||||||
"""Policy that performs a random walk around reference prices."""
|
|
||||||
rng = rng or np.random.default_rng()
|
|
||||||
prices = refs.copy()
|
|
||||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
|
||||||
nonlocal prices
|
|
||||||
delta = rng.normal(0, volatility, len(prices))
|
|
||||||
prices = prices * (1 + delta)
|
|
||||||
prices = np.clip(prices, refs * 0.5, refs * 2.0)
|
|
||||||
return prices.copy(), 1.0
|
|
||||||
return policy
|
|
||||||
|
|
||||||
def epsilon_greedy_policy(base_policy: Policy, refs: np.ndarray,
|
|
||||||
epsilon: float = 0.1, rng: np.random.Generator | None = None) -> Policy:
|
|
||||||
"""Wrap a policy with epsilon-greedy exploration."""
|
|
||||||
rng = rng or np.random.default_rng()
|
|
||||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
|
||||||
if rng.random() < epsilon:
|
|
||||||
action = refs * rng.uniform(0.8, 1.2, len(refs))
|
|
||||||
return action, epsilon / len(refs)
|
|
||||||
else:
|
|
||||||
action, _ = base_policy(obs, t)
|
|
||||||
return action, 1 - epsilon
|
|
||||||
return policy
|
|
||||||
|
|
||||||
# Off-Policy Evaluation (OPE)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class OPEResult:
|
|
||||||
"""Results from off-policy evaluation.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
ips_estimate: Inverse Propensity Scoring estimate
|
|
||||||
snips_estimate: Self-normalized IPS estimate (more stable)
|
|
||||||
n_samples: Number of samples used
|
|
||||||
effective_samples: Effective sample size (accounts for variance)
|
|
||||||
"""
|
|
||||||
ips_estimate: float
|
|
||||||
snips_estimate: float
|
|
||||||
n_samples: int
|
|
||||||
effective_samples: float
|
|
||||||
|
|
||||||
def compute_ips(logs: list[StepLogs], rewards: list[float],
|
|
||||||
target_policy: Policy, behavior_propensities: list[float] | None = None) -> OPEResult:
|
|
||||||
"""Compute IPS and SNIPS estimators for off-policy evaluation.
|
|
||||||
|
|
||||||
Uses logged propensities to estimate expected reward under a target
|
|
||||||
policy from data collected under a behavior policy.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
logs: Step logs containing propensities
|
|
||||||
rewards: Observed rewards from behavior policy
|
|
||||||
target_policy: Policy to evaluate (not currently used, assumes deterministic)
|
|
||||||
behavior_propensities: Override propensities if not in logs
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
OPEResult with IPS, SNIPS estimates and sample statistics
|
|
||||||
"""
|
|
||||||
if behavior_propensities is None:
|
|
||||||
# extract from logs
|
|
||||||
behavior_propensities = []
|
|
||||||
for log in logs:
|
|
||||||
if log.executions:
|
|
||||||
avg_prop = np.mean([e.propensity for e in log.executions])
|
|
||||||
else:
|
|
||||||
avg_prop = 1.0
|
|
||||||
behavior_propensities.append(avg_prop)
|
|
||||||
|
|
||||||
# compute importance weights
|
|
||||||
weights = []
|
|
||||||
for i, (log, bp) in enumerate(zip(logs, behavior_propensities)):
|
|
||||||
# target propensity would need obs reconstruction - simplified here
|
|
||||||
tp = 1.0 # assume deterministic target
|
|
||||||
w = tp / (bp + 1e-8)
|
|
||||||
weights.append(w)
|
|
||||||
|
|
||||||
weights = np.array(weights)
|
|
||||||
rewards = np.array(rewards)
|
|
||||||
|
|
||||||
# IPS estimate
|
|
||||||
ips = np.sum(weights * rewards) / len(rewards)
|
|
||||||
|
|
||||||
# SNIPS (self-normalized)
|
|
||||||
snips = np.sum(weights * rewards) / (np.sum(weights) + 1e-8)
|
|
||||||
|
|
||||||
# effective sample size
|
|
||||||
ess = (np.sum(weights) ** 2) / (np.sum(weights ** 2) + 1e-8)
|
|
||||||
|
|
||||||
return OPEResult(ips_estimate=ips, snips_estimate=snips,
|
|
||||||
n_samples=len(rewards), effective_samples=ess)
|
|
||||||
|
|
||||||
def compare_policies(platform: Platform, policies: dict[str, Policy],
|
|
||||||
n_steps: int = 100, n_runs: int = 5, seed: int = 42) -> dict[str, dict]:
|
|
||||||
"""Compare multiple policies with statistical summary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
platform: Simulation platform
|
|
||||||
policies: Dict mapping policy names to policy functions
|
|
||||||
n_steps: Steps per rollout
|
|
||||||
n_runs: Number of rollouts per policy (different seeds)
|
|
||||||
seed: Base random seed
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping policy names to result dicts with mean/std statistics
|
|
||||||
"""
|
|
||||||
results = {}
|
|
||||||
for name, policy in policies.items():
|
|
||||||
run_results = []
|
|
||||||
for i in range(n_runs):
|
|
||||||
r = rollout(platform, policy, n_steps, seed=seed + i)
|
|
||||||
run_results.append(r)
|
|
||||||
|
|
||||||
results[name] = {
|
|
||||||
'mean_reward': np.mean([r.total_reward for r in run_results]),
|
|
||||||
'std_reward': np.std([r.total_reward for r in run_results]),
|
|
||||||
'mean_pnl': np.mean([r.total_pnl for r in run_results]),
|
|
||||||
'mean_conversion': np.mean([r.avg_conversion for r in run_results]),
|
|
||||||
}
|
|
||||||
return results
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from .constants import Side, MechanismType, InstrumentType, OpportunityType, EventType, LogLevel
|
|
||||||
from .types import (Instrument, InstrumentSet, Quote, Opportunity, Execution,
|
|
||||||
StepEvent, StepLogs, StepMetrics, MarketState, HiddenState, Observation, StepResult)
|
|
||||||
from .stock import PositionModel, PositionConfig, make_instruments
|
|
||||||
from .platform import Platform, PlatformConfig
|
|
||||||
from .observation import DefaultObservationBuilder, ObservationConfig
|
|
||||||
from .mechanisms import PostedPriceMechanism, TwoSidedMechanism, AuctionMechanism
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'Side', 'MechanismType', 'InstrumentType', 'OpportunityType', 'EventType', 'LogLevel',
|
|
||||||
'Instrument', 'InstrumentSet', 'Quote', 'Opportunity', 'Execution',
|
|
||||||
'StepEvent', 'StepLogs', 'StepMetrics', 'MarketState', 'HiddenState', 'Observation', 'StepResult',
|
|
||||||
'PositionModel', 'PositionConfig', 'make_instruments',
|
|
||||||
'Platform', 'PlatformConfig',
|
|
||||||
'DefaultObservationBuilder', 'ObservationConfig',
|
|
||||||
'PostedPriceMechanism', 'TwoSidedMechanism', 'AuctionMechanism',
|
|
||||||
]
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
"""
|
|
||||||
Constants and enumerations for the Quote-Control simulator.
|
|
||||||
|
|
||||||
This module defines the core enums used throughout the platform to ensure
|
|
||||||
type safety and consistent semantics across different pricing mechanisms.
|
|
||||||
"""
|
|
||||||
from enum import Enum, auto
|
|
||||||
|
|
||||||
class Side(Enum):
|
|
||||||
"""Transaction side indicator.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
BUY: Buyer-initiated transaction (customer purchases, market buy order)
|
|
||||||
SELL: Seller-initiated transaction (market sell order, short sale)
|
|
||||||
"""
|
|
||||||
BUY = auto()
|
|
||||||
SELL = auto()
|
|
||||||
|
|
||||||
class MechanismType(Enum):
|
|
||||||
"""Pricing mechanism type defining how quotes translate to executions.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
POSTED_PRICE: Single posted price per instrument (retail dynamic pricing)
|
|
||||||
TWO_SIDED_QUOTE: Bid-ask spread quoting (market making, liquidity provision)
|
|
||||||
AUCTION: Reserve price or bid shading (ad auctions, marketplaces)
|
|
||||||
"""
|
|
||||||
POSTED_PRICE = auto()
|
|
||||||
TWO_SIDED_QUOTE = auto()
|
|
||||||
AUCTION = auto()
|
|
||||||
|
|
||||||
class InstrumentType(Enum):
|
|
||||||
"""Type of instrument being priced.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
SKU: Retail product with inventory constraints
|
|
||||||
ASSET: Financial instrument with position limits
|
|
||||||
LOAN: Credit product with interest rate pricing
|
|
||||||
SUBSCRIPTION: Recurring service with periodic fees
|
|
||||||
"""
|
|
||||||
SKU = auto()
|
|
||||||
ASSET = auto()
|
|
||||||
LOAN = auto()
|
|
||||||
SUBSCRIPTION = auto()
|
|
||||||
|
|
||||||
class OpportunityType(Enum):
|
|
||||||
"""Type of arrival opportunity.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
SESSION: Retail browsing session with potential purchase intent
|
|
||||||
MARKET_ORDER: Financial market order arrival (buy or sell)
|
|
||||||
REQUEST: Service or credit request requiring quote response
|
|
||||||
"""
|
|
||||||
SESSION = auto()
|
|
||||||
MARKET_ORDER = auto()
|
|
||||||
REQUEST = auto()
|
|
||||||
|
|
||||||
class EventType(Enum):
|
|
||||||
"""Type of logged event during simulation.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
ARRIVAL: New opportunity arrived in the system
|
|
||||||
EXPOSURE: Quote was shown to an arrival
|
|
||||||
EXECUTION: Transaction was executed
|
|
||||||
ABANDON: Opportunity abandoned without execution
|
|
||||||
CANCEL: Pending order was cancelled
|
|
||||||
"""
|
|
||||||
ARRIVAL = auto()
|
|
||||||
EXPOSURE = auto()
|
|
||||||
EXECUTION = auto()
|
|
||||||
ABANDON = auto()
|
|
||||||
CANCEL = auto()
|
|
||||||
|
|
||||||
class LogLevel(Enum):
|
|
||||||
"""Verbosity level for step logging.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
NONE: No logging, fastest execution
|
|
||||||
AGG_ONLY: Only aggregate statistics per step
|
|
||||||
FULL: Full event-level logging with propensities for OPE
|
|
||||||
"""
|
|
||||||
NONE = auto()
|
|
||||||
AGG_ONLY = auto()
|
|
||||||
FULL = auto()
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
"""
|
|
||||||
Gymnasium-compatible wrapper for the Quote-Control platform.
|
|
||||||
|
|
||||||
Provides a standard Gym interface for RL training:
|
|
||||||
- observation_space: Box space with flattened observation
|
|
||||||
- action_space: Box space with price multipliers [0.5, 2.0]
|
|
||||||
- reset(), step(), render(), close() methods
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> from lab.config import make_retail_platform
|
|
||||||
>>> from lab.outlet.gym_wrapper import QuoteGymEnv
|
|
||||||
>>> env = QuoteGymEnv(make_retail_platform())
|
|
||||||
>>> obs, info = env.reset()
|
|
||||||
>>> obs, reward, done, truncated, info = env.step(env.action_space.sample())
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from typing import Any
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
try:
|
|
||||||
import gymnasium as gym
|
|
||||||
from gymnasium import spaces
|
|
||||||
HAS_GYM = True
|
|
||||||
except ImportError:
|
|
||||||
HAS_GYM = False
|
|
||||||
|
|
||||||
from .platform import Platform, PlatformConfig
|
|
||||||
from .types import Quote, InstrumentSet, StepResult
|
|
||||||
|
|
||||||
class QuoteGymEnv:
|
|
||||||
"""Gymnasium-compatible environment wrapper.
|
|
||||||
|
|
||||||
Wraps a Platform instance with standard Gym interface.
|
|
||||||
Actions are price multipliers in [0.5, 2.0] applied to reference prices.
|
|
||||||
Observations are flattened numpy arrays containing quotes, fills, exposures.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, platform: Platform):
|
|
||||||
if not HAS_GYM:
|
|
||||||
raise ImportError("gymnasium required for QuoteGymEnv")
|
|
||||||
self.platform = platform
|
|
||||||
self.n = platform.instruments.n
|
|
||||||
self._last_result: StepResult | None = None
|
|
||||||
|
|
||||||
# action space: price adjustments as multipliers [0.5, 2.0]
|
|
||||||
self.action_space = spaces.Box(low=0.5, high=2.0, shape=(self.n,), dtype=np.float32)
|
|
||||||
|
|
||||||
# observation space
|
|
||||||
obs_dim = self.n * 4 # quotes + fills + exposures + position
|
|
||||||
if platform.market:
|
|
||||||
obs_dim += self.n # competitor quotes
|
|
||||||
self.observation_space = spaces.Box(low=-np.inf, high=np.inf,
|
|
||||||
shape=(obs_dim,), dtype=np.float32)
|
|
||||||
|
|
||||||
def reset(self, seed: int | None = None, options: dict | None = None) -> tuple[np.ndarray, dict]:
|
|
||||||
result = self.platform.reset(seed)
|
|
||||||
self._last_result = result
|
|
||||||
return result.obs.to_flat().astype(np.float32), result.info
|
|
||||||
|
|
||||||
def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]:
|
|
||||||
# convert action (multipliers) to absolute prices
|
|
||||||
refs = self.platform.instruments.refs
|
|
||||||
prices = refs * action
|
|
||||||
result = self.platform.step(prices)
|
|
||||||
self._last_result = result
|
|
||||||
return (result.obs.to_flat().astype(np.float32), result.reward,
|
|
||||||
result.terminated, result.truncated, result.info)
|
|
||||||
|
|
||||||
def render(self) -> None:
|
|
||||||
if self._last_result:
|
|
||||||
m = self._last_result.metrics
|
|
||||||
print(f"t={self.platform._t} pnl={m.pnl:.2f} units={m.units_traded:.0f} "
|
|
||||||
f"conv={m.conversion:.3f} vol={m.volatility:.3f}")
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def make_env(platform: Platform) -> QuoteGymEnv:
|
|
||||||
return QuoteGymEnv(platform)
|
|
||||||
|
|
||||||
if HAS_GYM:
|
|
||||||
# register if gymnasium available
|
|
||||||
try:
|
|
||||||
gym.register(id='QuoteControl-v0', entry_point='outlet.gym_wrapper:QuoteGymEnv')
|
|
||||||
except:
|
|
||||||
pass # already registered or other issue
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
"""
|
|
||||||
Numerical utilities for stable computation.
|
|
||||||
|
|
||||||
This module provides numerically stable implementations of common operations:
|
|
||||||
- safe_exp, safe_log: Avoid overflow/underflow
|
|
||||||
- softmax: Numerically stable softmax
|
|
||||||
- sigmoid, clamp: Standard transformations
|
|
||||||
- intensity_decay: Avellaneda-Stoikov fill intensity
|
|
||||||
- inventory_penalty: Quadratic inventory risk
|
|
||||||
- poisson_arrivals, hawkes_intensity: Arrival process helpers
|
|
||||||
|
|
||||||
All functions accept both scalars and numpy arrays.
|
|
||||||
"""
|
|
||||||
import numpy as np
|
|
||||||
|
|
||||||
EPS = 1e-8 # small constant to avoid division by zero
|
|
||||||
MAX_EXP = 700.0 # maximum safe exponent to avoid overflow
|
|
||||||
|
|
||||||
def safe_exp(x: np.ndarray | float) -> np.ndarray | float:
|
|
||||||
return np.exp(np.clip(x, -MAX_EXP, MAX_EXP))
|
|
||||||
|
|
||||||
def safe_log(x: np.ndarray | float) -> np.ndarray | float:
|
|
||||||
return np.log(np.maximum(x, EPS))
|
|
||||||
|
|
||||||
def clamp(x: np.ndarray | float, lo: float, hi: float) -> np.ndarray | float:
|
|
||||||
return np.clip(x, lo, hi)
|
|
||||||
|
|
||||||
def sigmoid(x: np.ndarray | float) -> np.ndarray | float:
|
|
||||||
return 1.0 / (1.0 + safe_exp(-x))
|
|
||||||
|
|
||||||
def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
|
|
||||||
x_max = np.max(x, axis=axis, keepdims=True)
|
|
||||||
exp_x = safe_exp(x - x_max)
|
|
||||||
return exp_x / (np.sum(exp_x, axis=axis, keepdims=True) + EPS)
|
|
||||||
|
|
||||||
def geometric_series(base: float, ratio: float, n: int) -> np.ndarray:
|
|
||||||
return base * (ratio ** np.arange(n))
|
|
||||||
|
|
||||||
def ema(old: float, new: float, alpha: float = 0.1) -> float:
|
|
||||||
return alpha * new + (1 - alpha) * old
|
|
||||||
|
|
||||||
def intensity_decay(distance: float, kappa: float = 1.0) -> float:
|
|
||||||
"""Avellaneda-Stoikov style fill intensity decay with quote distance"""
|
|
||||||
return safe_exp(-kappa * distance)
|
|
||||||
|
|
||||||
def inventory_penalty(q: float, gamma: float = 0.1, sigma: float = 1.0) -> float:
|
|
||||||
"""Quadratic inventory risk penalty"""
|
|
||||||
return gamma * sigma**2 * q**2 / 2
|
|
||||||
|
|
||||||
def poisson_arrivals(rate: float, dt: float, rng: np.random.Generator) -> int:
|
|
||||||
return rng.poisson(rate * dt)
|
|
||||||
|
|
||||||
def hawkes_intensity(base: float, history: np.ndarray, alpha: float, beta: float, t: float) -> float:
|
|
||||||
"""Self-exciting Hawkes process intensity"""
|
|
||||||
if len(history) == 0: return base
|
|
||||||
decays = safe_exp(-beta * (t - history[history < t]))
|
|
||||||
return base + alpha * np.sum(decays)
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
from .posted_price import PostedPriceMechanism
|
|
||||||
from .two_sided import TwoSidedMechanism
|
|
||||||
from .auction import AuctionMechanism
|
|
||||||
|
|
||||||
__all__ = ['PostedPriceMechanism', 'TwoSidedMechanism', 'AuctionMechanism']
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
"""
|
|
||||||
Auction mechanism for reserve pricing and bid shading.
|
|
||||||
|
|
||||||
In this mechanism, the agent sets reserve prices that affect
|
|
||||||
win probability and clearing prices. Used for ad auctions,
|
|
||||||
marketplace auctions, and similar settings.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
|
||||||
from ..constants import Side
|
|
||||||
from ..math_util import clamp, sigmoid
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class AuctionConfig:
|
|
||||||
"""Configuration for auction mechanism.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
min_reserve: Minimum reserve price
|
|
||||||
max_reserve: Maximum reserve price
|
|
||||||
base_win_prob: Baseline win probability at reference reserve
|
|
||||||
sensitivity: How much higher reserves reduce win probability
|
|
||||||
"""
|
|
||||||
min_reserve: float = 0.0
|
|
||||||
max_reserve: float = 100.0
|
|
||||||
base_win_prob: float = 0.3
|
|
||||||
sensitivity: float = 2.0
|
|
||||||
|
|
||||||
class AuctionMechanism:
|
|
||||||
"""Auction mechanism for reserve pricing.
|
|
||||||
|
|
||||||
The agent sets reserve prices that affect:
|
|
||||||
- Win probability: higher reserves reduce chance of winning
|
|
||||||
- Clearing price: bounded between reserve and simulated max bid
|
|
||||||
|
|
||||||
Win probability: base_prob * sigmoid(-sensitivity * (reserve - ref) / ref)
|
|
||||||
Clearing price: max(reserve, min(max_bid, reserve + random_increment))
|
|
||||||
|
|
||||||
Only BUY-side opportunities are processed (auction wins).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: AuctionConfig | None = None):
|
|
||||||
self.cfg = cfg or AuctionConfig()
|
|
||||||
|
|
||||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
rng: np.random.Generator) -> Quote:
|
|
||||||
reserves = clamp(quote.prices, self.cfg.min_reserve, self.cfg.max_reserve)
|
|
||||||
return Quote(prices=reserves, propensity=quote.propensity, metadata=quote.metadata)
|
|
||||||
|
|
||||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
|
||||||
instruments: InstrumentSet, market: MarketState | None,
|
|
||||||
rng: np.random.Generator) -> Execution | None:
|
|
||||||
if opp.side != Side.BUY: return None
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
reserve = float(quote.prices[idx])
|
|
||||||
ref = instruments.refs[idx]
|
|
||||||
|
|
||||||
# win probability decreases with higher reserve
|
|
||||||
relative_reserve = (reserve - ref) / (ref + 1e-8)
|
|
||||||
win_prob = self.cfg.base_win_prob * sigmoid(-self.cfg.sensitivity * relative_reserve)
|
|
||||||
|
|
||||||
if rng.random() > win_prob: return None
|
|
||||||
|
|
||||||
# clearing price is between reserve and some max bid (simulated)
|
|
||||||
max_bid = ref * (1 + rng.exponential(0.2))
|
|
||||||
clearing = max(reserve, min(max_bid, reserve + rng.exponential(0.1) * ref))
|
|
||||||
|
|
||||||
return Execution(
|
|
||||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
|
||||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
|
||||||
price=clearing, propensity=quote.propensity * win_prob, t=opp.t
|
|
||||||
)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""
|
|
||||||
Posted price mechanism for retail dynamic pricing.
|
|
||||||
|
|
||||||
In this mechanism, the agent posts a single price per instrument.
|
|
||||||
Buyers decide whether to purchase based on the posted price.
|
|
||||||
This is the standard e-commerce dynamic pricing model.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
|
||||||
from ..constants import Side
|
|
||||||
from ..math_util import clamp
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PostedPriceConfig:
|
|
||||||
"""Configuration for posted price mechanism.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
min_price: Absolute minimum price
|
|
||||||
max_price: Absolute maximum price
|
|
||||||
max_delta_pct: Maximum price change per step as fraction of previous
|
|
||||||
min_margin_pct: Minimum margin over cost basis
|
|
||||||
round_to: Price rounding granularity (None = no rounding)
|
|
||||||
"""
|
|
||||||
min_price: float = 0.01
|
|
||||||
max_price: float = 1000.0
|
|
||||||
max_delta_pct: float = 0.2
|
|
||||||
min_margin_pct: float = 0.05
|
|
||||||
round_to: float | None = 0.01
|
|
||||||
|
|
||||||
class PostedPriceMechanism:
|
|
||||||
"""Posted price mechanism for retail dynamic pricing.
|
|
||||||
|
|
||||||
The agent posts a single price per product. Constraints enforced:
|
|
||||||
- Prices within [min_price, max_price]
|
|
||||||
- Margin at least min_margin_pct above cost
|
|
||||||
- Price changes limited to max_delta_pct per step
|
|
||||||
- Prices rounded to round_to granularity
|
|
||||||
|
|
||||||
Only BUY-side opportunities are processed (customers purchasing).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: PostedPriceConfig | None = None):
|
|
||||||
self.cfg = cfg or PostedPriceConfig()
|
|
||||||
|
|
||||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
rng: np.random.Generator) -> Quote:
|
|
||||||
prices = quote.prices.copy()
|
|
||||||
costs = instruments.costs
|
|
||||||
refs = instruments.refs
|
|
||||||
c = self.cfg
|
|
||||||
|
|
||||||
# enforce min margin
|
|
||||||
min_prices = costs * (1 + c.min_margin_pct)
|
|
||||||
prices = np.maximum(prices, min_prices)
|
|
||||||
|
|
||||||
# enforce absolute bounds
|
|
||||||
prices = clamp(prices, c.min_price, c.max_price)
|
|
||||||
|
|
||||||
# enforce max delta if we have history
|
|
||||||
if 'prev_prices' in quote.metadata:
|
|
||||||
prev = quote.metadata['prev_prices']
|
|
||||||
max_change = prev * c.max_delta_pct
|
|
||||||
prices = clamp(prices, prev - max_change, prev + max_change)
|
|
||||||
|
|
||||||
# round prices
|
|
||||||
if c.round_to:
|
|
||||||
prices = np.round(prices / c.round_to) * c.round_to
|
|
||||||
|
|
||||||
return Quote(prices=prices, propensity=quote.propensity,
|
|
||||||
metadata={**quote.metadata, 'prev_prices': prices})
|
|
||||||
|
|
||||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
|
||||||
instruments: InstrumentSet, market: MarketState | None,
|
|
||||||
rng: np.random.Generator) -> Execution | None:
|
|
||||||
if opp.side != Side.BUY: return None # posted price is buy-only
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
price = float(quote.prices[idx])
|
|
||||||
return Execution(
|
|
||||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
|
||||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
|
||||||
price=price, propensity=quote.propensity, t=opp.t
|
|
||||||
)
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""
|
|
||||||
Two-sided quoting mechanism for market making.
|
|
||||||
|
|
||||||
In this mechanism, the agent posts both bid and ask prices.
|
|
||||||
Execution depends on the distance from the market mid-price.
|
|
||||||
This models liquidity provision in financial markets.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
|
||||||
from ..constants import Side
|
|
||||||
from ..math_util import clamp, intensity_decay
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TwoSidedConfig:
|
|
||||||
"""Configuration for two-sided quoting mechanism.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
min_spread: Minimum bid-ask spread
|
|
||||||
max_spread: Maximum bid-ask spread
|
|
||||||
min_price: Absolute minimum price
|
|
||||||
max_price: Absolute maximum price
|
|
||||||
fill_kappa: Intensity decay parameter (higher = faster decay with distance)
|
|
||||||
"""
|
|
||||||
min_spread: float = 0.01
|
|
||||||
max_spread: float = 0.5
|
|
||||||
min_price: float = 0.01
|
|
||||||
max_price: float = 10000.0
|
|
||||||
fill_kappa: float = 1.5
|
|
||||||
|
|
||||||
class TwoSidedMechanism:
|
|
||||||
"""Two-sided quoting mechanism for market making.
|
|
||||||
|
|
||||||
The agent posts bid (buy) and ask (sell) prices around a mid-point.
|
|
||||||
Fill probability decays exponentially with distance from mid-price,
|
|
||||||
following the Avellaneda-Stoikov intensity model.
|
|
||||||
|
|
||||||
Both BUY and SELL opportunities are processed:
|
|
||||||
- BUY: customer buys at agent's ask price
|
|
||||||
- SELL: customer sells at agent's bid price
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: TwoSidedConfig | None = None):
|
|
||||||
self.cfg = cfg or TwoSidedConfig()
|
|
||||||
|
|
||||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
rng: np.random.Generator) -> Quote:
|
|
||||||
prices = quote.prices.copy()
|
|
||||||
spreads = quote.spreads.copy() if quote.spreads is not None else np.full_like(prices, 0.02)
|
|
||||||
c = self.cfg
|
|
||||||
|
|
||||||
prices = clamp(prices, c.min_price, c.max_price)
|
|
||||||
spreads = clamp(spreads, c.min_spread, c.max_spread)
|
|
||||||
|
|
||||||
# ensure bids < asks
|
|
||||||
half_spread = spreads / 2
|
|
||||||
bids = prices - half_spread
|
|
||||||
asks = prices + half_spread
|
|
||||||
bids = np.maximum(bids, c.min_price)
|
|
||||||
asks = np.minimum(asks, c.max_price)
|
|
||||||
spreads = asks - bids
|
|
||||||
prices = (bids + asks) / 2
|
|
||||||
|
|
||||||
return Quote(prices=prices, spreads=spreads, propensity=quote.propensity,
|
|
||||||
metadata=quote.metadata)
|
|
||||||
|
|
||||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
|
||||||
instruments: InstrumentSet, market: MarketState | None,
|
|
||||||
rng: np.random.Generator) -> Execution | None:
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
mid = market.mid_prices[idx] if market and market.mid_prices is not None else quote.prices[idx]
|
|
||||||
|
|
||||||
if opp.side == Side.BUY:
|
|
||||||
price = float(quote.asks[idx]) if quote.asks is not None else float(quote.prices[idx])
|
|
||||||
distance = price - mid
|
|
||||||
else:
|
|
||||||
price = float(quote.bids[idx]) if quote.bids is not None else float(quote.prices[idx])
|
|
||||||
distance = mid - price
|
|
||||||
|
|
||||||
# probabilistic fill based on distance from mid
|
|
||||||
fill_prob = intensity_decay(abs(distance), self.cfg.fill_kappa)
|
|
||||||
if rng.random() > fill_prob: return None
|
|
||||||
|
|
||||||
return Execution(
|
|
||||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
|
||||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
|
||||||
price=price, propensity=quote.propensity * fill_prob, t=opp.t
|
|
||||||
)
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
from .base import BaseObjective, CompositeObjective
|
|
||||||
from .penalties import (PnLObjective, VolatilityPenalty, HoldingCostPenalty,
|
|
||||||
LostOpportunityCostPenalty, InventoryRiskPenalty, SpreadCaptureReward)
|
|
||||||
from .factory import make_objective, make_composite, retail_objective, market_making_objective
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'BaseObjective', 'CompositeObjective',
|
|
||||||
'PnLObjective', 'VolatilityPenalty', 'HoldingCostPenalty',
|
|
||||||
'LostOpportunityCostPenalty', 'InventoryRiskPenalty', 'SpreadCaptureReward',
|
|
||||||
'make_objective', 'make_composite', 'retail_objective', 'market_making_objective',
|
|
||||||
]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"""
|
|
||||||
Base classes for reward objectives.
|
|
||||||
|
|
||||||
Objectives compute scalar rewards from step metrics. The CompositeObjective
|
|
||||||
allows combining multiple objectives with weights for multi-objective optimization.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from ..types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
|
||||||
|
|
||||||
class BaseObjective(ABC):
|
|
||||||
"""Abstract base class for reward objectives.
|
|
||||||
|
|
||||||
Subclasses must implement reward() and breakdown() methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float: ...
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]: ...
|
|
||||||
|
|
||||||
class CompositeObjective(BaseObjective):
|
|
||||||
"""Weighted sum of multiple objectives.
|
|
||||||
|
|
||||||
Allows combining multiple reward terms (e.g., PnL - holding_cost - volatility).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
objectives: List of (objective, weight) tuples
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, objectives: list[tuple[BaseObjective, float]]):
|
|
||||||
self.objectives = objectives
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return sum(w * obj.reward(quote, instruments, metrics, hidden, obs)
|
|
||||||
for obj, w in self.objectives)
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
bd = {}
|
|
||||||
for obj, w in self.objectives:
|
|
||||||
for k, v in obj.breakdown(quote, instruments, metrics, hidden, obs).items():
|
|
||||||
bd[k] = w * v
|
|
||||||
return bd
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
"""
|
|
||||||
Factory functions for creating objectives.
|
|
||||||
|
|
||||||
Provides:
|
|
||||||
- make_objective: Create single objective by name
|
|
||||||
- make_composite: Create weighted combination of objectives
|
|
||||||
- retail_objective: Default objective for retail pricing
|
|
||||||
- market_making_objective: Default objective for market making
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from .base import BaseObjective, CompositeObjective
|
|
||||||
from .penalties import (PnLObjective, VolatilityPenalty, HoldingCostPenalty,
|
|
||||||
LostOpportunityCostPenalty, InventoryRiskPenalty, SpreadCaptureReward)
|
|
||||||
|
|
||||||
REGISTRY: dict[str, type[BaseObjective]] = {
|
|
||||||
'pnl': PnLObjective,
|
|
||||||
'volatility': VolatilityPenalty,
|
|
||||||
'holding_cost': HoldingCostPenalty,
|
|
||||||
'lost_opportunity': LostOpportunityCostPenalty,
|
|
||||||
'inventory_risk': InventoryRiskPenalty,
|
|
||||||
'spread_capture': SpreadCaptureReward,
|
|
||||||
}
|
|
||||||
|
|
||||||
def make_objective(name: str, **kwargs) -> BaseObjective:
|
|
||||||
"""Create an objective by name.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
name: Objective name (pnl, volatility, holding_cost, lost_opportunity,
|
|
||||||
inventory_risk, spread_capture)
|
|
||||||
**kwargs: Passed to objective constructor
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Instantiated objective
|
|
||||||
"""
|
|
||||||
if name not in REGISTRY:
|
|
||||||
raise ValueError(f"Unknown objective: {name}. Available: {list(REGISTRY.keys())}")
|
|
||||||
return REGISTRY[name](**kwargs)
|
|
||||||
|
|
||||||
def make_composite(spec: list[tuple[str, float, dict]] | dict[str, float]) -> CompositeObjective:
|
|
||||||
"""Create composite objective from specification.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
spec: Either:
|
|
||||||
- list of (name, weight, kwargs) tuples for full control
|
|
||||||
- dict of {name: weight} for simple cases
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
CompositeObjective with specified components
|
|
||||||
"""
|
|
||||||
objectives = []
|
|
||||||
if isinstance(spec, dict):
|
|
||||||
for name, weight in spec.items():
|
|
||||||
objectives.append((make_objective(name), weight))
|
|
||||||
else:
|
|
||||||
for name, weight, kwargs in spec:
|
|
||||||
objectives.append((make_objective(name, **kwargs), weight))
|
|
||||||
return CompositeObjective(objectives)
|
|
||||||
|
|
||||||
def retail_objective(volatility_weight: float = 0.1, holding_weight: float = 0.5,
|
|
||||||
stockout_weight: float = 0.3) -> CompositeObjective:
|
|
||||||
"""Default objective for retail dynamic pricing.
|
|
||||||
|
|
||||||
Reward = PnL - volatility_weight*volatility - holding_weight*holding_cost
|
|
||||||
- stockout_weight*lost_opportunity
|
|
||||||
"""
|
|
||||||
return make_composite({
|
|
||||||
'pnl': 1.0,
|
|
||||||
'volatility': volatility_weight,
|
|
||||||
'holding_cost': holding_weight,
|
|
||||||
'lost_opportunity': stockout_weight,
|
|
||||||
})
|
|
||||||
|
|
||||||
def market_making_objective(gamma: float = 0.1, sigma: float = 1.0) -> CompositeObjective:
|
|
||||||
"""Default objective for market making.
|
|
||||||
|
|
||||||
Reward = PnL + 0.5*spread_capture - inventory_risk(gamma, sigma)
|
|
||||||
"""
|
|
||||||
return CompositeObjective([
|
|
||||||
(PnLObjective(), 1.0),
|
|
||||||
(SpreadCaptureReward(), 0.5),
|
|
||||||
(InventoryRiskPenalty(gamma=gamma, sigma=sigma), 1.0),
|
|
||||||
])
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
"""
|
|
||||||
Standard objective components and penalties.
|
|
||||||
|
|
||||||
This module provides common reward terms:
|
|
||||||
- PnLObjective: Basic profit and loss
|
|
||||||
- VolatilityPenalty: Penalize price volatility for UX
|
|
||||||
- HoldingCostPenalty: Inventory holding cost
|
|
||||||
- LostOpportunityCostPenalty: Stockout/missed fill cost
|
|
||||||
- InventoryRiskPenalty: Quadratic inventory risk (market making)
|
|
||||||
- SpreadCaptureReward: Bid-ask spread capture (market making)
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
import numpy as np
|
|
||||||
from .base import BaseObjective
|
|
||||||
from ..types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
|
||||||
from ..math_util import inventory_penalty
|
|
||||||
|
|
||||||
class PnLObjective(BaseObjective):
|
|
||||||
"""Profit and loss reward (revenue - cost)."""
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return metrics.pnl
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'pnl': metrics.pnl, 'revenue': metrics.revenue, 'cost': metrics.cost}
|
|
||||||
|
|
||||||
class VolatilityPenalty(BaseObjective):
|
|
||||||
"""Penalize price volatility for user experience."""
|
|
||||||
|
|
||||||
def __init__(self, scale: float = 1.0):
|
|
||||||
self.scale = scale
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return -self.scale * metrics.volatility
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'volatility_penalty': -self.scale * metrics.volatility}
|
|
||||||
|
|
||||||
class HoldingCostPenalty(BaseObjective):
|
|
||||||
"""Penalty for inventory holding costs."""
|
|
||||||
|
|
||||||
def __init__(self, scale: float = 1.0):
|
|
||||||
self.scale = scale
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return -self.scale * metrics.position_cost
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'holding_cost_penalty': -self.scale * metrics.position_cost}
|
|
||||||
|
|
||||||
class LostOpportunityCostPenalty(BaseObjective):
|
|
||||||
"""Penalty for lost sales due to stockouts or missed fills."""
|
|
||||||
|
|
||||||
def __init__(self, scale: float = 1.0):
|
|
||||||
self.scale = scale
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return -self.scale * metrics.lost_opportunity
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'lost_opportunity_penalty': -self.scale * metrics.lost_opportunity}
|
|
||||||
|
|
||||||
class InventoryRiskPenalty(BaseObjective):
|
|
||||||
"""Quadratic inventory risk penalty (Avellaneda-Stoikov style).
|
|
||||||
|
|
||||||
Penalty = gamma * sigma^2 * q^2 / 2, where q is total position.
|
|
||||||
Encourages market makers to keep inventory near zero.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, gamma: float = 0.1, sigma: float = 1.0):
|
|
||||||
self.gamma = gamma
|
|
||||||
self.sigma = sigma
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
if obs.position is None: return 0.0
|
|
||||||
q = np.sum(obs.position)
|
|
||||||
return -inventory_penalty(q, self.gamma, self.sigma)
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'inventory_risk_penalty': self.reward(quote, instruments, metrics, hidden, obs)}
|
|
||||||
|
|
||||||
class SpreadCaptureReward(BaseObjective):
|
|
||||||
"""Reward for capturing bid-ask spread in market making."""
|
|
||||||
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
|
||||||
return metrics.spread_capture
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
|
||||||
return {'spread_capture': metrics.spread_capture}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
"""
|
|
||||||
Observation construction with demand censoring.
|
|
||||||
|
|
||||||
This module provides the ObservationBuilder that constructs agent observations
|
|
||||||
from step data. The key invariant is that observations only contain censored
|
|
||||||
data (fills) and never true demand, ensuring proper research conditions.
|
|
||||||
|
|
||||||
The ObservationConfig controls what is included in observations:
|
|
||||||
- Position visibility
|
|
||||||
- Market/competitor visibility
|
|
||||||
- Demand proxy method
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from .types import Quote, InstrumentSet, StepLogs, StepMetrics, MarketState, HiddenState, Observation
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ObservationConfig:
|
|
||||||
"""Configuration for observation construction.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
include_position: Include current position in observation
|
|
||||||
include_market: Include market/competitor state in observation
|
|
||||||
mask_true_demand: If True, observation excludes true demand (research mode)
|
|
||||||
demand_proxy: Method for demand proxy ('fills', 'exposures', 'weighted')
|
|
||||||
exposure_weights: Weights for weighted demand proxy
|
|
||||||
"""
|
|
||||||
include_position: bool = True
|
|
||||||
include_market: bool = True
|
|
||||||
mask_true_demand: bool = True
|
|
||||||
demand_proxy: str = 'fills'
|
|
||||||
exposure_weights: dict[str, float] | None = None
|
|
||||||
|
|
||||||
class DefaultObservationBuilder:
|
|
||||||
"""Constructs censored observations for the agent.
|
|
||||||
|
|
||||||
Ensures the key research invariant: observations contain only
|
|
||||||
censored fills (realized sales), never true demand. True demand
|
|
||||||
is placed in the info dict for research analysis only.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: ObservationConfig | None = None):
|
|
||||||
self.cfg = cfg or ObservationConfig()
|
|
||||||
|
|
||||||
def build(self, quote: Quote, instruments: InstrumentSet, logs: StepLogs,
|
|
||||||
metrics: StepMetrics, market: MarketState | None,
|
|
||||||
hidden: HiddenState, mask_demand: bool, t: int) -> Observation:
|
|
||||||
n = instruments.n
|
|
||||||
cfg = self.cfg
|
|
||||||
|
|
||||||
# always show censored fills
|
|
||||||
fills = logs.censored_fills if logs.censored_fills is not None else np.zeros(n)
|
|
||||||
|
|
||||||
# compute exposures from logs
|
|
||||||
if logs.events:
|
|
||||||
exposures = np.zeros(n)
|
|
||||||
for e in logs.events:
|
|
||||||
if e.instrument_id is not None:
|
|
||||||
exposures[e.instrument_id] += 1
|
|
||||||
else:
|
|
||||||
exposures = logs.aggregates.get('exposures', np.zeros(n))
|
|
||||||
|
|
||||||
# position - only if configured and available
|
|
||||||
position = None
|
|
||||||
if cfg.include_position and instruments.position is not None:
|
|
||||||
position = instruments.position.copy()
|
|
||||||
|
|
||||||
# market state - only if configured
|
|
||||||
obs_market = market if cfg.include_market else None
|
|
||||||
|
|
||||||
return Observation(
|
|
||||||
quotes=quote.prices.copy(),
|
|
||||||
position=position,
|
|
||||||
fills=fills,
|
|
||||||
exposures=exposures,
|
|
||||||
market=obs_market,
|
|
||||||
t=t
|
|
||||||
)
|
|
||||||
|
|
||||||
def make_space(self, n_instruments: int, include_market: bool = True) -> dict:
|
|
||||||
"""Returns dict describing observation space for gym"""
|
|
||||||
space = {
|
|
||||||
'quotes': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
|
||||||
'fills': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
|
||||||
'exposures': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
|
||||||
}
|
|
||||||
if self.cfg.include_position:
|
|
||||||
space['position'] = {'shape': (n_instruments,), 'low': -np.inf, 'high': np.inf}
|
|
||||||
if include_market:
|
|
||||||
space['competitor_quotes'] = {'shape': (n_instruments,), 'low': 0, 'high': np.inf}
|
|
||||||
return space
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
"""
|
|
||||||
Main simulation platform orchestrating the Quote-Control loop.
|
|
||||||
|
|
||||||
The Platform class is the central coordinator that:
|
|
||||||
1. Receives pricing actions (quotes) from the agent
|
|
||||||
2. Generates arrivals via the ArrivalModel
|
|
||||||
3. Processes executions via Mechanism and ExecutionModel
|
|
||||||
4. Applies position censorship via PositionModel
|
|
||||||
5. Computes metrics and reward via Objective
|
|
||||||
6. Returns censored observations
|
|
||||||
|
|
||||||
Example:
|
|
||||||
>>> from lab.config import make_retail_platform
|
|
||||||
>>> platform = make_retail_platform()
|
|
||||||
>>> result = platform.reset(seed=42)
|
|
||||||
>>> result = platform.step(platform.instruments.refs * 1.1)
|
|
||||||
>>> print(f"PnL: {result.metrics.pnl:.2f}")
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
import numpy as np
|
|
||||||
from .types import (Quote, Opportunity, Execution, InstrumentSet, StepLogs, StepMetrics,
|
|
||||||
StepEvent, MarketState, HiddenState, Observation, StepResult)
|
|
||||||
from .constants import LogLevel, EventType, Side
|
|
||||||
from .protocols import Mechanism, ArrivalModel, ExecutionModel, PositionModel, MarketModel, ObservationBuilder, Objective
|
|
||||||
from .stock import PositionModel as DefaultPositionModel, PositionConfig
|
|
||||||
from .observation import DefaultObservationBuilder, ObservationConfig
|
|
||||||
from .objectives.factory import retail_objective
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PlatformConfig:
|
|
||||||
"""Configuration for the simulation platform.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
n_instruments: Number of instruments in the simulation
|
|
||||||
max_steps: Maximum steps before episode terminates
|
|
||||||
dt: Time duration per step (affects arrival rates)
|
|
||||||
log_level: Verbosity of logging (NONE, AGG_ONLY, FULL)
|
|
||||||
mask_demand: If True, observations exclude true demand (research mode)
|
|
||||||
seed: Random seed for reproducibility
|
|
||||||
"""
|
|
||||||
n_instruments: int = 10
|
|
||||||
max_steps: int = 1000
|
|
||||||
dt: float = 1.0
|
|
||||||
log_level: LogLevel = LogLevel.AGG_ONLY
|
|
||||||
mask_demand: bool = True
|
|
||||||
seed: int | None = None
|
|
||||||
|
|
||||||
class Platform:
|
|
||||||
"""Main simulation orchestrator implementing Quote -> Arrival -> Execution -> Position.
|
|
||||||
|
|
||||||
The Platform coordinates all components to simulate a pricing environment:
|
|
||||||
- Mechanism: validates quotes and determines execution logic
|
|
||||||
- ArrivalModel: generates demand opportunities
|
|
||||||
- ExecutionModel: computes acceptance probabilities
|
|
||||||
- PositionModel: manages inventory/position and censorship
|
|
||||||
- MarketModel: updates competitor/market state
|
|
||||||
- ObservationBuilder: constructs censored observations
|
|
||||||
- Objective: computes reward from metrics
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
instruments: The instrument set being priced
|
|
||||||
mechanism: Quote validation and execution mechanism
|
|
||||||
arrival: Demand arrival generator
|
|
||||||
execution: Acceptance probability model
|
|
||||||
position: Inventory/position manager
|
|
||||||
market: Competitor/market dynamics (optional)
|
|
||||||
obs_builder: Observation constructor
|
|
||||||
objective: Reward function
|
|
||||||
cfg: Platform configuration
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, instruments: InstrumentSet, mechanism: Mechanism,
|
|
||||||
arrival: ArrivalModel, execution: ExecutionModel,
|
|
||||||
position: PositionModel | None = None,
|
|
||||||
market: MarketModel | None = None,
|
|
||||||
obs_builder: ObservationBuilder | None = None,
|
|
||||||
objective: Objective | None = None,
|
|
||||||
cfg: PlatformConfig | None = None):
|
|
||||||
self.instruments = instruments
|
|
||||||
self.mechanism = mechanism
|
|
||||||
self.arrival = arrival
|
|
||||||
self.execution = execution
|
|
||||||
self.position = position or DefaultPositionModel(PositionConfig())
|
|
||||||
self.market = market
|
|
||||||
self.obs_builder = obs_builder or DefaultObservationBuilder()
|
|
||||||
self.objective = objective or retail_objective()
|
|
||||||
self.cfg = cfg or PlatformConfig(n_instruments=instruments.n)
|
|
||||||
|
|
||||||
self._t: int = 0
|
|
||||||
self._rng: np.random.Generator = np.random.default_rng(self.cfg.seed)
|
|
||||||
self._quote: Quote | None = None
|
|
||||||
self._market_state: MarketState | None = None
|
|
||||||
self._hidden: HiddenState = HiddenState()
|
|
||||||
self._prev_prices: np.ndarray | None = None
|
|
||||||
|
|
||||||
def reset(self, seed: int | None = None) -> StepResult:
|
|
||||||
"""Reset the platform to initial state.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
seed: Random seed (overrides config seed if provided)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Initial StepResult with zeroed metrics and initial observation
|
|
||||||
"""
|
|
||||||
self._t = 0
|
|
||||||
self._rng = np.random.default_rng(seed or self.cfg.seed)
|
|
||||||
self._hidden = HiddenState()
|
|
||||||
self._prev_prices = self.instruments.refs.copy()
|
|
||||||
|
|
||||||
# reset position
|
|
||||||
self.position.reset(self.instruments, self._rng)
|
|
||||||
self.instruments.position = self.position.position
|
|
||||||
|
|
||||||
# initial quote at reference prices
|
|
||||||
self._quote = Quote(prices=self.instruments.refs.copy(), propensity=1.0,
|
|
||||||
metadata={'prev_prices': self._prev_prices})
|
|
||||||
self._quote = self.mechanism.apply_quote(self._quote, self.instruments, self._rng)
|
|
||||||
|
|
||||||
# initial market state
|
|
||||||
if self.market:
|
|
||||||
self._market_state = self.market.step(0, self._quote, self._hidden, self._rng)
|
|
||||||
|
|
||||||
# build initial observation
|
|
||||||
logs = StepLogs(aggregates={'reset': True},
|
|
||||||
true_demand=np.zeros(self.instruments.n),
|
|
||||||
censored_fills=np.zeros(self.instruments.n))
|
|
||||||
metrics = StepMetrics()
|
|
||||||
obs = self.obs_builder.build(self._quote, self.instruments, logs, metrics,
|
|
||||||
self._market_state, self._hidden, self.cfg.mask_demand, 0)
|
|
||||||
|
|
||||||
return StepResult(obs=obs, reward=0.0, terminated=False, truncated=False,
|
|
||||||
info={'true_demand': logs.true_demand}, metrics=metrics,
|
|
||||||
logs=logs, hidden=self._hidden)
|
|
||||||
|
|
||||||
def step(self, action: np.ndarray, propensity: float = 1.0) -> StepResult:
|
|
||||||
"""Execute one simulation step with the given pricing action.
|
|
||||||
|
|
||||||
The step proceeds as follows:
|
|
||||||
1. Apply quote constraints via mechanism
|
|
||||||
2. Update market/competitor state
|
|
||||||
3. Generate arrivals
|
|
||||||
4. Process arrivals -> executions with acceptance check
|
|
||||||
5. Apply position censorship to executions
|
|
||||||
6. Update position state
|
|
||||||
7. Compute metrics (PnL, costs, etc.)
|
|
||||||
8. Build logs with propensities
|
|
||||||
9. Construct censored observation
|
|
||||||
10. Compute reward
|
|
||||||
|
|
||||||
Args:
|
|
||||||
action: Price vector for all instruments
|
|
||||||
propensity: P(action | behavior policy) for OPE logging
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
StepResult containing observation, reward, metrics, logs, and hidden state
|
|
||||||
"""
|
|
||||||
self._t += 1
|
|
||||||
cfg = self.cfg
|
|
||||||
|
|
||||||
# 1. apply quote from action
|
|
||||||
self._quote = Quote(prices=action, propensity=propensity,
|
|
||||||
metadata={'prev_prices': self._prev_prices})
|
|
||||||
self._quote = self.mechanism.apply_quote(self._quote, self.instruments, self._rng)
|
|
||||||
self._prev_prices = self._quote.prices.copy()
|
|
||||||
self._hidden.quote_history.append(self._quote.prices.copy())
|
|
||||||
|
|
||||||
# 2. update market/competitors
|
|
||||||
if self.market:
|
|
||||||
self._market_state = self.market.step(self._t, self._quote, self._hidden, self._rng)
|
|
||||||
self._hidden.market_history.append(self._market_state)
|
|
||||||
|
|
||||||
# 3. generate arrivals
|
|
||||||
opps = self.arrival.sample(self._t, cfg.dt, self.instruments,
|
|
||||||
self._market_state, self._hidden, self._rng)
|
|
||||||
|
|
||||||
# 4. process opportunities -> executions
|
|
||||||
executions: list[Execution] = []
|
|
||||||
events: list[StepEvent] = []
|
|
||||||
true_demand = np.zeros(self.instruments.n)
|
|
||||||
|
|
||||||
for opp in opps:
|
|
||||||
# log exposure
|
|
||||||
if cfg.log_level == LogLevel.FULL:
|
|
||||||
events.append(StepEvent(t=opp.t, type=EventType.EXPOSURE,
|
|
||||||
instrument_id=opp.instrument_id,
|
|
||||||
opportunity_id=opp.id,
|
|
||||||
price=float(self._quote.prices[opp.instrument_id]),
|
|
||||||
propensity=self._quote.propensity))
|
|
||||||
|
|
||||||
# check acceptance
|
|
||||||
prob = self.execution.prob(opp, self._quote, self.instruments,
|
|
||||||
self._market_state, self._rng)
|
|
||||||
if self._rng.random() < prob:
|
|
||||||
# create execution
|
|
||||||
exe = self.mechanism.process_opportunity(opp, self._quote, self.instruments,
|
|
||||||
self._market_state, self._rng)
|
|
||||||
if exe:
|
|
||||||
true_demand[exe.instrument_id] += exe.size_requested
|
|
||||||
# apply position censorship
|
|
||||||
exe = self.position.apply_execution(exe)
|
|
||||||
executions.append(exe)
|
|
||||||
if cfg.log_level == LogLevel.FULL:
|
|
||||||
events.append(StepEvent(t=exe.t, type=EventType.EXECUTION,
|
|
||||||
instrument_id=exe.instrument_id,
|
|
||||||
opportunity_id=exe.opportunity_id,
|
|
||||||
price=exe.price, size=exe.size_filled,
|
|
||||||
propensity=exe.propensity))
|
|
||||||
|
|
||||||
# 5. update position state
|
|
||||||
self.position.step(self._t)
|
|
||||||
self.instruments.position = self.position.position
|
|
||||||
|
|
||||||
# 6. compute metrics
|
|
||||||
censored_fills = np.zeros(self.instruments.n)
|
|
||||||
revenue = 0.0
|
|
||||||
cost = 0.0
|
|
||||||
spread_capture = 0.0
|
|
||||||
|
|
||||||
for exe in executions:
|
|
||||||
censored_fills[exe.instrument_id] += exe.size_filled
|
|
||||||
if exe.side == Side.BUY:
|
|
||||||
revenue += exe.price * exe.size_filled
|
|
||||||
cost += self.instruments.costs[exe.instrument_id] * exe.size_filled
|
|
||||||
else:
|
|
||||||
revenue -= exe.price * exe.size_filled
|
|
||||||
cost -= self.instruments.costs[exe.instrument_id] * exe.size_filled
|
|
||||||
# spread capture for market making
|
|
||||||
if self._quote.spreads is not None and self._market_state and self._market_state.mid_prices is not None:
|
|
||||||
mid = self._market_state.mid_prices[exe.instrument_id]
|
|
||||||
if exe.side == Side.BUY:
|
|
||||||
spread_capture += (exe.price - mid) * exe.size_filled
|
|
||||||
else:
|
|
||||||
spread_capture += (mid - exe.price) * exe.size_filled
|
|
||||||
|
|
||||||
pnl = revenue - cost
|
|
||||||
units = float(np.sum(censored_fills))
|
|
||||||
lost = float(np.sum(true_demand - censored_fills))
|
|
||||||
|
|
||||||
# volatility
|
|
||||||
volatility = 0.0
|
|
||||||
if len(self._hidden.quote_history) > 1:
|
|
||||||
prev = self._hidden.quote_history[-2]
|
|
||||||
volatility = float(np.mean(np.abs(self._quote.prices - prev) / (prev + 1e-8)))
|
|
||||||
|
|
||||||
metrics = StepMetrics(
|
|
||||||
pnl=pnl, revenue=revenue, cost=cost, units_traded=units,
|
|
||||||
position_cost=self.position.holding_cost,
|
|
||||||
lost_opportunity=self.position.shortage_cost + lost * np.mean(self._quote.prices) * 0.1,
|
|
||||||
spread_capture=spread_capture, volatility=volatility,
|
|
||||||
conversion=units / (len(opps) + 1e-8),
|
|
||||||
per_instrument={'fills': censored_fills, 'demand': true_demand}
|
|
||||||
)
|
|
||||||
|
|
||||||
# 7. build logs
|
|
||||||
logs = StepLogs(
|
|
||||||
events=events if cfg.log_level == LogLevel.FULL else None,
|
|
||||||
executions=executions if cfg.log_level == LogLevel.FULL else None,
|
|
||||||
aggregates={'n_arrivals': len(opps), 'n_executions': len(executions),
|
|
||||||
'exposures': np.bincount([o.instrument_id for o in opps],
|
|
||||||
minlength=self.instruments.n).astype(float)},
|
|
||||||
true_demand=true_demand,
|
|
||||||
censored_fills=censored_fills
|
|
||||||
)
|
|
||||||
|
|
||||||
# 8. build observation
|
|
||||||
obs = self.obs_builder.build(self._quote, self.instruments, logs, metrics,
|
|
||||||
self._market_state, self._hidden, cfg.mask_demand, self._t)
|
|
||||||
|
|
||||||
# 9. compute reward
|
|
||||||
reward = self.objective.reward(self._quote, self.instruments, metrics, self._hidden, obs)
|
|
||||||
breakdown = self.objective.breakdown(self._quote, self.instruments, metrics, self._hidden, obs)
|
|
||||||
# print(f"Step {self._t}: Reward={reward:.2f}, Breakdown={breakdown}")
|
|
||||||
|
|
||||||
|
|
||||||
# 10. check termination
|
|
||||||
terminated = self._t >= cfg.max_steps
|
|
||||||
truncated = False
|
|
||||||
|
|
||||||
info = {'true_demand': true_demand, 'breakdown': self.objective.breakdown(
|
|
||||||
self._quote, self.instruments, metrics, self._hidden, obs)}
|
|
||||||
|
|
||||||
return StepResult(obs=obs, reward=reward, terminated=terminated, truncated=truncated,
|
|
||||||
info=info, metrics=metrics, logs=logs, hidden=self._hidden)
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
"""
|
|
||||||
Protocol definitions for pluggable simulator components.
|
|
||||||
|
|
||||||
This module defines the interfaces (Protocols) that allow swapping different
|
|
||||||
implementations for each stage of the Quote -> Arrival -> Execution -> Position
|
|
||||||
pipeline. All protocols use structural subtyping (duck typing).
|
|
||||||
|
|
||||||
Protocols:
|
|
||||||
Mechanism: How quotes translate to executions (posted price, two-sided, auction)
|
|
||||||
ArrivalModel: How opportunities arrive (Poisson, Hawkes, sessions)
|
|
||||||
ExecutionModel: Acceptance probability given quote (elasticity, intensity)
|
|
||||||
PositionModel: Inventory/position management and censorship
|
|
||||||
MarketModel: Competitor/market dynamics
|
|
||||||
ObservationBuilder: Constructs agent observations with censoring
|
|
||||||
Objective: Computes reward from metrics
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from typing import Protocol, Any, TYPE_CHECKING
|
|
||||||
import numpy as np
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .types import (Quote, Opportunity, Execution, InstrumentSet, StepLogs,
|
|
||||||
StepMetrics, HiddenState, Observation, MarketState)
|
|
||||||
from .constants import LogLevel
|
|
||||||
|
|
||||||
class Mechanism(Protocol):
|
|
||||||
"""Defines how quotes translate to executions.
|
|
||||||
|
|
||||||
The Mechanism is the core abstraction that differentiates pricing domains:
|
|
||||||
- PostedPrice: single price, buyer decides to purchase or not
|
|
||||||
- TwoSided: bid/ask spread, execution depends on distance from mid
|
|
||||||
- Auction: reserve price affects win probability and clearing price
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
apply_quote: Enforce constraints and return valid quote
|
|
||||||
process_opportunity: Determine execution given opportunity and quote
|
|
||||||
"""
|
|
||||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
rng: np.random.Generator) -> Quote:
|
|
||||||
"""Apply mechanism-specific constraints to a quote.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
quote: Raw quote from policy
|
|
||||||
instruments: Current instrument set with costs/refs
|
|
||||||
rng: Random generator for stochastic constraints
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Constrained quote satisfying mechanism rules (min margin, max delta, etc.)
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
|
||||||
instruments: InstrumentSet, market: MarketState | None,
|
|
||||||
rng: np.random.Generator) -> Execution | None:
|
|
||||||
"""Process an opportunity against the current quote.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
opp: Incoming opportunity (session, order, request)
|
|
||||||
quote: Current posted quote
|
|
||||||
instruments: Instrument set
|
|
||||||
market: Current market state (competitor prices, mid-prices)
|
|
||||||
rng: Random generator
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Execution if opportunity converts, None otherwise
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
class ArrivalModel(Protocol):
|
|
||||||
"""Generates opportunities (demand arrivals) for each step.
|
|
||||||
|
|
||||||
Different arrival models capture different demand dynamics:
|
|
||||||
- Poisson: constant rate, memoryless
|
|
||||||
- Hawkes: self-exciting, clustered arrivals
|
|
||||||
- Session: retail browsing with multi-product views
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
sample: Generate opportunities for a time interval
|
|
||||||
"""
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
"""Sample opportunities for time interval [t, t+dt).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
t: Current time
|
|
||||||
dt: Time interval length
|
|
||||||
instruments: Available instruments
|
|
||||||
market: Current market state
|
|
||||||
hidden: Hidden state (contains demand intensity, contamination)
|
|
||||||
rng: Random generator
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of opportunities arriving in this interval
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
class ExecutionModel(Protocol):
|
|
||||||
"""Computes acceptance/execution probability given quote and context.
|
|
||||||
|
|
||||||
Different models capture different demand responses:
|
|
||||||
- Elasticity: price sensitivity with competitor cross-effects
|
|
||||||
- Intensity: distance-based fill probability (market making)
|
|
||||||
- Logit: discrete choice model
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
prob: Compute acceptance probability
|
|
||||||
uncensor: Estimate true demand from censored fills
|
|
||||||
"""
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
"""Compute probability that opportunity accepts the quote.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
opp: Opportunity to evaluate
|
|
||||||
quote: Current quote
|
|
||||||
instruments: Instrument set
|
|
||||||
market: Market state (competitor prices affect cross-elasticity)
|
|
||||||
rng: Random generator
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Probability in [0, 1] that opportunity executes
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
|
||||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
"""Estimate true demand from censored fills.
|
|
||||||
|
|
||||||
Used for demand estimation research under inventory censorship.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
fills: Observed (censored) fill counts
|
|
||||||
instruments: Instrument set
|
|
||||||
context: Additional context (exposures, prices shown)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Estimated true demand counts
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
class PositionModel(Protocol):
|
|
||||||
"""Manages inventory (retail) or position (finance).
|
|
||||||
|
|
||||||
Handles:
|
|
||||||
- Position constraints and censorship
|
|
||||||
- Holding costs (retail) or inventory risk (finance)
|
|
||||||
- Replenishment and order receipt
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
reset: Initialize position state
|
|
||||||
available: Query available capacity for a trade
|
|
||||||
apply_execution: Censor execution by available position
|
|
||||||
step: Process time-based updates (replenishment, holding cost)
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
position: Current position vector
|
|
||||||
holding_cost: Cost incurred this step from holding position
|
|
||||||
"""
|
|
||||||
def reset(self, instruments: InstrumentSet, rng: np.random.Generator) -> None:
|
|
||||||
"""Initialize position state for new episode."""
|
|
||||||
...
|
|
||||||
|
|
||||||
def available(self, instrument_id: int, side: Any) -> float:
|
|
||||||
"""Query available capacity for a trade.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
instrument_id: Which instrument
|
|
||||||
side: BUY or SELL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Maximum tradeable size given current position
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def apply_execution(self, exe: Execution) -> Execution:
|
|
||||||
"""Apply position constraints to an execution.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
exe: Proposed execution with size_requested
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Censored execution with size_filled <= available capacity
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def step(self, t: float) -> None:
|
|
||||||
"""Process time-based position updates.
|
|
||||||
|
|
||||||
Handles replenishment receipt, holding cost calculation, etc.
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def position(self) -> np.ndarray:
|
|
||||||
"""Current position vector (positive=long/inventory, negative=short)."""
|
|
||||||
...
|
|
||||||
|
|
||||||
@property
|
|
||||||
def holding_cost(self) -> float:
|
|
||||||
"""Holding cost incurred this step."""
|
|
||||||
...
|
|
||||||
|
|
||||||
class MarketModel(Protocol):
|
|
||||||
"""Models external market dynamics and competitor behavior.
|
|
||||||
|
|
||||||
For retail: competitor price dynamics (static, reactive, stochastic)
|
|
||||||
For finance: mid-price process (GBM, mean-reverting)
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
step: Update market state given agent's quotes
|
|
||||||
"""
|
|
||||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> MarketState:
|
|
||||||
"""Update market state for this timestep.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
t: Current time
|
|
||||||
self_quotes: Agent's current quotes (competitors may react)
|
|
||||||
hidden: Hidden state (regime info)
|
|
||||||
rng: Random generator
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated market state with competitor prices, mid-prices, volatility
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
class ObservationBuilder(Protocol):
|
|
||||||
"""Constructs agent observations with appropriate censoring.
|
|
||||||
|
|
||||||
Critical for research: ensures agent only sees censored fills,
|
|
||||||
never true demand (which goes in info dict).
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
build: Construct observation from step data
|
|
||||||
"""
|
|
||||||
def build(self, quote: Quote, instruments: InstrumentSet, logs: StepLogs,
|
|
||||||
metrics: StepMetrics, market: MarketState | None,
|
|
||||||
hidden: HiddenState, mask_demand: bool, t: int) -> Observation:
|
|
||||||
"""Build observation for agent.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
quote: Current quote
|
|
||||||
instruments: Instrument set with positions
|
|
||||||
logs: Step logs with true_demand and censored_fills
|
|
||||||
metrics: Computed metrics
|
|
||||||
market: Market state
|
|
||||||
hidden: Hidden state (not included in obs)
|
|
||||||
mask_demand: If True, exclude true demand from observation
|
|
||||||
t: Current timestep
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Observation containing only observable quantities
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
class Objective(Protocol):
|
|
||||||
"""Computes reward from step metrics.
|
|
||||||
|
|
||||||
Supports composite objectives with weighted terms:
|
|
||||||
- PnL (profit)
|
|
||||||
- Position costs (holding, inventory risk)
|
|
||||||
- Lost opportunity (stockouts)
|
|
||||||
- Volatility penalty (UX)
|
|
||||||
- Spread capture (market making)
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
reward: Compute scalar reward
|
|
||||||
breakdown: Get per-term contribution for analysis
|
|
||||||
"""
|
|
||||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState,
|
|
||||||
obs: Observation) -> float:
|
|
||||||
"""Compute scalar reward for this step.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
quote: Current quote
|
|
||||||
instruments: Instrument set
|
|
||||||
metrics: Step metrics (pnl, costs, etc.)
|
|
||||||
hidden: Hidden state
|
|
||||||
obs: Agent observation
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Scalar reward value
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
|
|
||||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
|
||||||
metrics: StepMetrics, hidden: HiddenState,
|
|
||||||
obs: Observation) -> dict[str, float]:
|
|
||||||
"""Get reward breakdown by component.
|
|
||||||
|
|
||||||
Useful for analyzing which terms dominate the reward.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping term names to their contributions
|
|
||||||
"""
|
|
||||||
...
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
"""
|
|
||||||
Inventory/position management and instrument factories.
|
|
||||||
|
|
||||||
This module provides:
|
|
||||||
- PositionConfig: Configuration for position constraints and costs
|
|
||||||
- PositionModel: Manages inventory (retail) or position (finance)
|
|
||||||
- make_instruments: Factory for creating instrument sets
|
|
||||||
|
|
||||||
The PositionModel handles demand censorship by limiting executions
|
|
||||||
to available inventory, computing holding costs, and managing replenishment.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
import numpy as np
|
|
||||||
from .types import Instrument, InstrumentSet, Execution
|
|
||||||
from .constants import Side, InstrumentType
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PositionConfig:
|
|
||||||
"""Configuration for position/inventory management.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
initial_position: Starting inventory (None = unlimited, float = same for all)
|
|
||||||
max_position: Maximum long position per instrument
|
|
||||||
min_position: Maximum short position (negative, for finance)
|
|
||||||
holding_cost_rate: Cost per unit per step for holding inventory
|
|
||||||
shortage_cost_rate: Opportunity cost rate for stockouts
|
|
||||||
lead_time: Steps until replenishment orders arrive
|
|
||||||
"""
|
|
||||||
initial_position: np.ndarray | float | None = None
|
|
||||||
max_position: float = 1000.0
|
|
||||||
min_position: float = -1000.0
|
|
||||||
holding_cost_rate: float = 0.001
|
|
||||||
shortage_cost_rate: float = 0.05
|
|
||||||
lead_time: int = 0
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PositionModel:
|
|
||||||
"""Manages inventory (retail) or position (finance) with censorship.
|
|
||||||
|
|
||||||
Key responsibilities:
|
|
||||||
- Track current position per instrument
|
|
||||||
- Censor executions when position is insufficient
|
|
||||||
- Compute holding costs per step
|
|
||||||
- Track shortage/stockout costs
|
|
||||||
- Handle replenishment orders with lead time
|
|
||||||
|
|
||||||
For retail: position is inventory (positive), selling reduces it
|
|
||||||
For finance: position can be positive (long) or negative (short)
|
|
||||||
"""
|
|
||||||
cfg: PositionConfig
|
|
||||||
n: int = 0
|
|
||||||
_position: np.ndarray = field(default_factory=lambda: np.array([]))
|
|
||||||
_pending_orders: list[tuple[int, np.ndarray]] = field(default_factory=list)
|
|
||||||
_step_holding_cost: float = 0.0
|
|
||||||
_step_shortage_cost: float = 0.0
|
|
||||||
|
|
||||||
def reset(self, instruments: InstrumentSet, rng: np.random.Generator) -> None:
|
|
||||||
self.n = instruments.n
|
|
||||||
if self.cfg.initial_position is None:
|
|
||||||
self._position = np.full(self.n, np.inf) # unlimited
|
|
||||||
elif isinstance(self.cfg.initial_position, (int, float)):
|
|
||||||
self._position = np.full(self.n, float(self.cfg.initial_position))
|
|
||||||
else:
|
|
||||||
self._position = self.cfg.initial_position.copy().astype(np.float64)
|
|
||||||
self._pending_orders = []
|
|
||||||
self._step_holding_cost = 0.0
|
|
||||||
self._step_shortage_cost = 0.0
|
|
||||||
|
|
||||||
def available(self, instrument_id: int, side: Side) -> float:
|
|
||||||
pos = self._position[instrument_id]
|
|
||||||
if np.isinf(pos): return np.inf
|
|
||||||
if side == Side.BUY:
|
|
||||||
return max(0, pos) # can sell up to current inventory
|
|
||||||
else:
|
|
||||||
return max(0, self.cfg.max_position - pos) # can buy up to max
|
|
||||||
|
|
||||||
def apply_execution(self, exe: Execution) -> Execution:
|
|
||||||
idx = int(exe.instrument_id)
|
|
||||||
avail = self.available(idx, exe.side)
|
|
||||||
filled = min(exe.size_requested, avail)
|
|
||||||
shortage = exe.size_requested - filled
|
|
||||||
|
|
||||||
if exe.side == Side.BUY:
|
|
||||||
self._position[idx] -= filled # sold from inventory
|
|
||||||
else:
|
|
||||||
self._position[idx] += filled # bought into inventory
|
|
||||||
|
|
||||||
if shortage > 0:
|
|
||||||
self._step_shortage_cost += shortage * exe.price * self.cfg.shortage_cost_rate
|
|
||||||
|
|
||||||
return Execution(
|
|
||||||
opportunity_id=exe.opportunity_id, instrument_id=exe.instrument_id,
|
|
||||||
side=exe.side, size_requested=exe.size_requested,
|
|
||||||
size_filled=filled, price=exe.price, propensity=exe.propensity, t=exe.t
|
|
||||||
)
|
|
||||||
|
|
||||||
def order(self, quantity: np.ndarray) -> None:
|
|
||||||
if self.cfg.lead_time > 0:
|
|
||||||
self._pending_orders.append((self.cfg.lead_time, quantity.copy()))
|
|
||||||
else:
|
|
||||||
self._position += quantity
|
|
||||||
|
|
||||||
def step(self, t: float) -> None:
|
|
||||||
# compute holding cost
|
|
||||||
pos = np.where(np.isinf(self._position), 0, self._position)
|
|
||||||
self._step_holding_cost = float(np.sum(np.abs(pos)) * self.cfg.holding_cost_rate)
|
|
||||||
|
|
||||||
# receive pending orders
|
|
||||||
new_pending = []
|
|
||||||
for (remaining, qty) in self._pending_orders:
|
|
||||||
if remaining <= 1:
|
|
||||||
self._position += qty
|
|
||||||
else:
|
|
||||||
new_pending.append((remaining - 1, qty))
|
|
||||||
self._pending_orders = new_pending
|
|
||||||
|
|
||||||
@property
|
|
||||||
def position(self) -> np.ndarray:
|
|
||||||
return np.where(np.isinf(self._position), -1, self._position)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def holding_cost(self) -> float:
|
|
||||||
return self._step_holding_cost
|
|
||||||
|
|
||||||
@property
|
|
||||||
def shortage_cost(self) -> float:
|
|
||||||
return self._step_shortage_cost
|
|
||||||
|
|
||||||
def make_instruments(n: int, cost_range: tuple[float, float] = (1.0, 10.0),
|
|
||||||
margin_range: tuple[float, float] = (0.2, 0.5),
|
|
||||||
inst_type: InstrumentType = InstrumentType.SKU,
|
|
||||||
rng: np.random.Generator | None = None) -> InstrumentSet:
|
|
||||||
"""Factory function to create a random instrument set.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
n: Number of instruments to create
|
|
||||||
cost_range: (min, max) for uniform cost sampling
|
|
||||||
margin_range: (min, max) for uniform margin sampling
|
|
||||||
inst_type: Type of instruments (SKU, ASSET, etc.)
|
|
||||||
rng: Random generator (uses default if None)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
InstrumentSet with n instruments having random costs and margins
|
|
||||||
"""
|
|
||||||
rng = rng or np.random.default_rng()
|
|
||||||
costs = rng.uniform(*cost_range, n)
|
|
||||||
margins = rng.uniform(*margin_range, n)
|
|
||||||
items = [Instrument(id=i, type=inst_type, cost_basis=c, reference_price=c*(1+m))
|
|
||||||
for i, (c, m) in enumerate(zip(costs, margins))]
|
|
||||||
return InstrumentSet(instruments=items)
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
"""
|
|
||||||
Core data types for the Quote-Control simulator.
|
|
||||||
|
|
||||||
This module defines the fundamental data structures used throughout the platform:
|
|
||||||
- Identifiers (InstrumentId, OpportunityId, AgentId)
|
|
||||||
- Domain objects (Instrument, Quote, Opportunity, Execution)
|
|
||||||
- Logging structures (StepEvent, StepLogs, StepMetrics)
|
|
||||||
- State containers (MarketState, HiddenState, Observation, StepResult)
|
|
||||||
|
|
||||||
All dataclasses are designed to be serializable and numpy-compatible.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any, NewType
|
|
||||||
import numpy as np
|
|
||||||
from .constants import Side, InstrumentType, OpportunityType, EventType
|
|
||||||
|
|
||||||
InstrumentId = NewType('InstrumentId', int) # unique instrument index
|
|
||||||
OpportunityId = NewType('OpportunityId', str) # unique opportunity/session ID
|
|
||||||
AgentId = NewType('AgentId', str) # unique agent/actor ID
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Instrument:
|
|
||||||
"""Represents a priceable entity in the simulation.
|
|
||||||
|
|
||||||
An instrument can be a retail SKU, financial asset, loan product, or subscription.
|
|
||||||
The cost_basis represents the fundamental value (marginal cost for retail,
|
|
||||||
mid-price for assets, funding rate for loans).
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Unique identifier for this instrument
|
|
||||||
type: Category of instrument (SKU, ASSET, LOAN, SUBSCRIPTION)
|
|
||||||
cost_basis: Fundamental cost or value (marginal cost, mid-price, funding rate)
|
|
||||||
reference_price: Base or fair price used for action scaling
|
|
||||||
attrs: Additional attributes (quality score, category, volatility, etc.)
|
|
||||||
"""
|
|
||||||
id: InstrumentId
|
|
||||||
type: InstrumentType
|
|
||||||
cost_basis: float
|
|
||||||
reference_price: float
|
|
||||||
attrs: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class InstrumentSet:
|
|
||||||
"""Collection of instruments with optional position tracking.
|
|
||||||
|
|
||||||
Provides vectorized access to instrument properties for efficient computation.
|
|
||||||
Position can be positive (long/inventory) or negative (short) for financial assets.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
instruments: List of Instrument objects
|
|
||||||
position: Current position per instrument (None = unlimited capacity)
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
n: Number of instruments
|
|
||||||
costs: Vector of cost bases
|
|
||||||
refs: Vector of reference prices
|
|
||||||
"""
|
|
||||||
instruments: list[Instrument]
|
|
||||||
position: np.ndarray | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def n(self) -> int: return len(self.instruments)
|
|
||||||
@property
|
|
||||||
def costs(self) -> np.ndarray: return np.array([i.cost_basis for i in self.instruments], np.float32)
|
|
||||||
@property
|
|
||||||
def refs(self) -> np.ndarray: return np.array([i.reference_price for i in self.instruments], np.float32)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Quote:
|
|
||||||
"""Price quote set by the policy - the action in the MDP.
|
|
||||||
|
|
||||||
Supports multiple quoting mechanisms:
|
|
||||||
- Posted price: only `prices` field used
|
|
||||||
- Two-sided: `prices` as mid, `spreads` for bid-ask width
|
|
||||||
- Auction: `prices` as reserve prices
|
|
||||||
|
|
||||||
The propensity field is critical for off-policy evaluation (OPE).
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
prices: Posted prices (retail) or mid-quotes (market making)
|
|
||||||
spreads: Bid-ask spread width for two-sided quoting (None for posted price)
|
|
||||||
propensity: P(this quote | behavior policy) for importance sampling
|
|
||||||
metadata: Additional info (prev_prices for delta constraints, etc.)
|
|
||||||
|
|
||||||
Properties:
|
|
||||||
bids: Computed bid prices (mid - spread/2)
|
|
||||||
asks: Computed ask prices (mid + spread/2)
|
|
||||||
"""
|
|
||||||
prices: np.ndarray
|
|
||||||
spreads: np.ndarray | None = None
|
|
||||||
propensity: float = 1.0
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def bids(self) -> np.ndarray | None:
|
|
||||||
return self.prices - self.spreads/2 if self.spreads is not None else None
|
|
||||||
@property
|
|
||||||
def asks(self) -> np.ndarray | None:
|
|
||||||
return self.prices + self.spreads/2 if self.spreads is not None else None
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Opportunity:
|
|
||||||
"""An arrival event that may result in a transaction.
|
|
||||||
|
|
||||||
Opportunities are the demand side of the simulation:
|
|
||||||
- Retail: browsing session with purchase intent
|
|
||||||
- Market making: incoming market order
|
|
||||||
- Lending: loan application
|
|
||||||
|
|
||||||
The context dict carries segment/type information used by execution models.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
id: Unique identifier for this opportunity
|
|
||||||
type: Category (SESSION, MARKET_ORDER, REQUEST)
|
|
||||||
side: BUY or SELL intent
|
|
||||||
instrument_id: Which instrument the opportunity targets
|
|
||||||
size: Requested transaction size (units, shares, principal)
|
|
||||||
t: Arrival timestamp
|
|
||||||
context: Segment info (is_scraper, credit_score, urgency, etc.)
|
|
||||||
"""
|
|
||||||
id: OpportunityId
|
|
||||||
type: OpportunityType
|
|
||||||
side: Side
|
|
||||||
instrument_id: InstrumentId
|
|
||||||
size: float = 1.0
|
|
||||||
t: float = 0.0
|
|
||||||
context: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Execution:
|
|
||||||
"""A realized transaction after acceptance and position censorship.
|
|
||||||
|
|
||||||
The difference between size_requested and size_filled represents
|
|
||||||
censored demand due to inventory/position constraints.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
opportunity_id: Links back to the originating Opportunity
|
|
||||||
instrument_id: Which instrument was traded
|
|
||||||
side: BUY or SELL
|
|
||||||
size_requested: Original requested size (true demand)
|
|
||||||
size_filled: Actual filled size after censorship
|
|
||||||
price: Execution price
|
|
||||||
propensity: Combined propensity for OPE (quote * acceptance)
|
|
||||||
t: Execution timestamp
|
|
||||||
"""
|
|
||||||
opportunity_id: OpportunityId
|
|
||||||
instrument_id: InstrumentId
|
|
||||||
side: Side
|
|
||||||
size_requested: float
|
|
||||||
size_filled: float
|
|
||||||
price: float
|
|
||||||
propensity: float = 1.0
|
|
||||||
t: float = 0.0
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StepEvent:
|
|
||||||
"""Generic logged event"""
|
|
||||||
t: float
|
|
||||||
type: EventType
|
|
||||||
instrument_id: InstrumentId | None = None
|
|
||||||
opportunity_id: OpportunityId | None = None
|
|
||||||
price: float | None = None
|
|
||||||
size: float | None = None
|
|
||||||
propensity: float = 1.0
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StepLogs:
|
|
||||||
"""Container for all logging data from a simulation step.
|
|
||||||
|
|
||||||
Supports both detailed event logging (for OPE) and aggregate-only mode
|
|
||||||
(for fast simulation). The true_demand vs censored_fills distinction
|
|
||||||
is critical for research on demand estimation under censorship.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
events: Detailed event log (None if LogLevel != FULL)
|
|
||||||
executions: List of executed transactions (None if LogLevel != FULL)
|
|
||||||
aggregates: Always-available aggregate statistics
|
|
||||||
true_demand: Oracle demand before censorship (for research, not in obs)
|
|
||||||
censored_fills: Realized fills after position constraints (observable)
|
|
||||||
"""
|
|
||||||
events: list[StepEvent] | None = None
|
|
||||||
executions: list[Execution] | None = None
|
|
||||||
aggregates: dict[str, Any] = field(default_factory=dict)
|
|
||||||
true_demand: np.ndarray | None = None
|
|
||||||
censored_fills: np.ndarray | None = None
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StepMetrics:
|
|
||||||
"""Computed metrics for a single simulation step.
|
|
||||||
|
|
||||||
Metrics are domain-aware: retail uses revenue/cost/holding_cost,
|
|
||||||
market making uses spread_capture and inventory risk.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
pnl: Profit and loss (revenue - cost for retail, mark-to-market for finance)
|
|
||||||
revenue: Gross revenue from sales/executions
|
|
||||||
cost: Cost of goods sold or position acquisition cost
|
|
||||||
units_traded: Total units/shares transacted
|
|
||||||
position_cost: Holding cost (retail) or inventory risk penalty (finance)
|
|
||||||
lost_opportunity: Cost of stockouts or missed fills
|
|
||||||
spread_capture: Bid-ask spread captured (market making)
|
|
||||||
volatility: Price volatility metric for UX consideration
|
|
||||||
conversion: Fill rate (executions / opportunities)
|
|
||||||
per_instrument: Per-instrument breakdowns (fills, demand, etc.)
|
|
||||||
"""
|
|
||||||
pnl: float = 0.0
|
|
||||||
revenue: float = 0.0
|
|
||||||
cost: float = 0.0
|
|
||||||
units_traded: float = 0.0
|
|
||||||
position_cost: float = 0.0
|
|
||||||
lost_opportunity: float = 0.0
|
|
||||||
spread_capture: float = 0.0
|
|
||||||
volatility: float = 0.0
|
|
||||||
conversion: float = 0.0
|
|
||||||
per_instrument: dict[str, np.ndarray] = field(default_factory=dict)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MarketState:
|
|
||||||
"""External market conditions and competitor state.
|
|
||||||
|
|
||||||
For retail: competitor_quotes drives cross-elasticity effects.
|
|
||||||
For finance: mid_prices and volatility drive execution dynamics.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
competitor_quotes: Competitor posted prices (retail)
|
|
||||||
mid_prices: Market mid-prices for assets (finance)
|
|
||||||
volatility: Per-instrument volatility estimate
|
|
||||||
regime: Market regime identifier (normal, price_war, high_vol, etc.)
|
|
||||||
t: Timestamp of this market state
|
|
||||||
"""
|
|
||||||
competitor_quotes: np.ndarray | None = None
|
|
||||||
mid_prices: np.ndarray | None = None
|
|
||||||
volatility: np.ndarray | None = None
|
|
||||||
regime: str = 'normal'
|
|
||||||
t: float = 0.0
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HiddenState:
|
|
||||||
"""Internal simulator state not exposed to the agent.
|
|
||||||
|
|
||||||
Contains oracle information for research analysis and
|
|
||||||
history needed for non-stationary dynamics.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
true_demand_intensity: Latent demand multiplier
|
|
||||||
contamination: Fraction of arrivals that are adversarial/scraper
|
|
||||||
regime: Current market/competitor regime
|
|
||||||
quote_history: History of agent quotes for volatility calculation
|
|
||||||
market_history: History of market states for analysis
|
|
||||||
"""
|
|
||||||
true_demand_intensity: float = 1.0
|
|
||||||
contamination: float = 0.0
|
|
||||||
regime: str = 'normal'
|
|
||||||
quote_history: list[np.ndarray] = field(default_factory=list)
|
|
||||||
market_history: list[MarketState] = field(default_factory=list)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Observation:
|
|
||||||
"""Observable state provided to the agent - censored view only.
|
|
||||||
|
|
||||||
Critical invariant: Observation never contains true_demand, only
|
|
||||||
censored fills. This enforces the censorship research setting.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
quotes: Current posted quotes (the agent's last action)
|
|
||||||
position: Current inventory/position state
|
|
||||||
fills: Censored execution counts per instrument
|
|
||||||
exposures: Opportunity exposure counts per instrument
|
|
||||||
market: Observable market state (competitor prices, volatility)
|
|
||||||
t: Current timestep
|
|
||||||
extra: Additional observable features
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
to_flat: Flatten to numpy array for gym compatibility
|
|
||||||
"""
|
|
||||||
quotes: np.ndarray
|
|
||||||
position: np.ndarray | None
|
|
||||||
fills: np.ndarray
|
|
||||||
exposures: np.ndarray
|
|
||||||
market: MarketState | None
|
|
||||||
t: int
|
|
||||||
extra: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
def to_flat(self) -> np.ndarray:
|
|
||||||
"""Flatten observation to 1D numpy array for gym environments."""
|
|
||||||
parts = [self.quotes, self.fills, self.exposures]
|
|
||||||
if self.position is not None: parts.append(self.position)
|
|
||||||
if self.market and self.market.competitor_quotes is not None:
|
|
||||||
parts.append(self.market.competitor_quotes)
|
|
||||||
return np.concatenate([p.flatten() for p in parts])
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StepResult:
|
|
||||||
"""Complete result from a simulation step.
|
|
||||||
|
|
||||||
Follows gymnasium convention for obs, reward, terminated, truncated, info.
|
|
||||||
Additionally provides metrics, logs, and hidden state for research.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
obs: Observable state (censored)
|
|
||||||
reward: Scalar reward from objective function
|
|
||||||
terminated: Episode ended naturally (max_steps reached)
|
|
||||||
truncated: Episode ended early (bankruptcy, constraint violation)
|
|
||||||
info: Additional info dict (contains true_demand for research)
|
|
||||||
metrics: Computed metrics for this step
|
|
||||||
logs: Event logs and aggregates
|
|
||||||
hidden: Internal simulator state (oracle info)
|
|
||||||
"""
|
|
||||||
obs: Observation
|
|
||||||
reward: float
|
|
||||||
terminated: bool
|
|
||||||
truncated: bool
|
|
||||||
info: dict[str, Any]
|
|
||||||
metrics: StepMetrics
|
|
||||||
logs: StepLogs
|
|
||||||
hidden: HiddenState
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
from .arrivals import PoissonArrivalModel, HawkesArrivalModel, SessionArrivalModel
|
|
||||||
from .execution import ElasticityExecutionModel, IntensityExecutionModel, LogitExecutionModel
|
|
||||||
from .competitors import (StaticCompetitorModel, ReactiveCompetitorModel,
|
|
||||||
StochasticCompetitorModel, GBMMarketModel)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'PoissonArrivalModel', 'HawkesArrivalModel', 'SessionArrivalModel',
|
|
||||||
'ElasticityExecutionModel', 'IntensityExecutionModel', 'LogitExecutionModel',
|
|
||||||
'StaticCompetitorModel', 'ReactiveCompetitorModel', 'StochasticCompetitorModel', 'GBMMarketModel',
|
|
||||||
]
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
"""
|
|
||||||
Arrival models for generating demand opportunities.
|
|
||||||
|
|
||||||
This module provides different arrival processes:
|
|
||||||
- PoissonArrivalModel: Constant-rate memoryless arrivals
|
|
||||||
- HawkesArrivalModel: Self-exciting clustered arrivals (market orders)
|
|
||||||
- SessionArrivalModel: Retail browsing sessions with multi-product views
|
|
||||||
|
|
||||||
Each model implements the ArrivalModel protocol and generates Opportunity objects
|
|
||||||
that flow through the execution pipeline.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable
|
|
||||||
import numpy as np
|
|
||||||
from uuid import uuid4
|
|
||||||
from ..outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState
|
|
||||||
from ..outlet.constants import Side, OpportunityType
|
|
||||||
from ..outlet.math_util import poisson_arrivals, hawkes_intensity
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PoissonArrivalConfig:
|
|
||||||
"""Configuration for Poisson arrival process.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
base_rate: Expected arrivals per unit time (scaled by hidden.true_demand_intensity)
|
|
||||||
side_probs: Probability distribution over BUY/SELL sides
|
|
||||||
"""
|
|
||||||
base_rate: float = 10.0
|
|
||||||
side_probs: dict[Side, float] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.side_probs is None:
|
|
||||||
self.side_probs = {Side.BUY: 1.0}
|
|
||||||
|
|
||||||
class PoissonArrivalModel:
|
|
||||||
"""Homogeneous Poisson arrival process.
|
|
||||||
|
|
||||||
Generates arrivals at a constant rate (modulated by demand intensity).
|
|
||||||
Suitable for stationary demand or as a baseline model.
|
|
||||||
|
|
||||||
The actual arrival count follows Poisson(rate * dt * intensity).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: PoissonArrivalConfig | None = None):
|
|
||||||
self.cfg = cfg or PoissonArrivalConfig()
|
|
||||||
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
n_arrivals = poisson_arrivals(self.cfg.base_rate * hidden.true_demand_intensity, dt, rng)
|
|
||||||
opps = []
|
|
||||||
for _ in range(n_arrivals):
|
|
||||||
inst_id = rng.integers(0, instruments.n)
|
|
||||||
side = rng.choice(list(self.cfg.side_probs.keys()),
|
|
||||||
p=list(self.cfg.side_probs.values()))
|
|
||||||
opps.append(Opportunity(
|
|
||||||
id=str(uuid4())[:8], type=OpportunityType.SESSION,
|
|
||||||
side=side, instrument_id=inst_id, size=1.0, t=t,
|
|
||||||
context={'segment': 'default'}
|
|
||||||
))
|
|
||||||
return opps
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HawkesArrivalConfig:
|
|
||||||
"""Configuration for Hawkes self-exciting process.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
base_rate: Baseline arrival intensity
|
|
||||||
alpha: Excitation strength (how much each arrival increases intensity)
|
|
||||||
beta: Decay rate (how quickly excitation fades)
|
|
||||||
side_probs: Probability distribution over BUY/SELL sides
|
|
||||||
"""
|
|
||||||
base_rate: float = 5.0
|
|
||||||
alpha: float = 0.5
|
|
||||||
beta: float = 1.0
|
|
||||||
side_probs: dict[Side, float] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.side_probs is None:
|
|
||||||
self.side_probs = {Side.BUY: 0.5, Side.SELL: 0.5}
|
|
||||||
|
|
||||||
class HawkesArrivalModel:
|
|
||||||
"""Self-exciting Hawkes point process for clustered arrivals.
|
|
||||||
|
|
||||||
Models order flow where arrivals cluster in time (momentum, herding).
|
|
||||||
Intensity: lambda(t) = base + alpha * sum(exp(-beta * (t - t_i)))
|
|
||||||
|
|
||||||
Used for market making scenarios where orders arrive in bursts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: HawkesArrivalConfig | None = None):
|
|
||||||
self.cfg = cfg or HawkesArrivalConfig()
|
|
||||||
self._history: np.ndarray = np.array([])
|
|
||||||
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
intensity = hawkes_intensity(
|
|
||||||
self.cfg.base_rate * hidden.true_demand_intensity,
|
|
||||||
self._history, self.cfg.alpha, self.cfg.beta, t
|
|
||||||
)
|
|
||||||
n_arrivals = poisson_arrivals(intensity, dt, rng)
|
|
||||||
opps = []
|
|
||||||
for i in range(n_arrivals):
|
|
||||||
arr_t = t + rng.uniform(0, dt)
|
|
||||||
self._history = np.append(self._history, arr_t)
|
|
||||||
inst_id = rng.integers(0, instruments.n)
|
|
||||||
side = rng.choice(list(self.cfg.side_probs.keys()),
|
|
||||||
p=list(self.cfg.side_probs.values()))
|
|
||||||
opps.append(Opportunity(
|
|
||||||
id=str(uuid4())[:8], type=OpportunityType.MARKET_ORDER,
|
|
||||||
side=side, instrument_id=inst_id,
|
|
||||||
size=rng.exponential(1.0), t=arr_t,
|
|
||||||
context={'intensity': intensity}
|
|
||||||
))
|
|
||||||
# decay old history
|
|
||||||
self._history = self._history[self._history > t - 10]
|
|
||||||
return opps
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SessionArrivalConfig:
|
|
||||||
"""Configuration for retail session arrivals.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
sessions_per_step: Number of browsing sessions per step
|
|
||||||
views_per_session: (min, max) product views per session
|
|
||||||
contamination: Fraction of sessions that are scrapers/bots
|
|
||||||
"""
|
|
||||||
sessions_per_step: int = 20
|
|
||||||
views_per_session: tuple[int, int] = (1, 5)
|
|
||||||
contamination: float = 0.0
|
|
||||||
|
|
||||||
class SessionArrivalModel:
|
|
||||||
"""Retail browsing session model with multi-product views.
|
|
||||||
|
|
||||||
Each session views multiple products, generating one opportunity per view.
|
|
||||||
Scraper sessions (controlled by contamination) view more products
|
|
||||||
but convert at lower rates (handled by ExecutionModel).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: SessionArrivalConfig | None = None):
|
|
||||||
self.cfg = cfg or SessionArrivalConfig()
|
|
||||||
|
|
||||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> list[Opportunity]:
|
|
||||||
n_sessions = self.cfg.sessions_per_step
|
|
||||||
contamination = hidden.contamination if hidden else self.cfg.contamination
|
|
||||||
opps = []
|
|
||||||
|
|
||||||
for _ in range(n_sessions):
|
|
||||||
is_scraper = rng.random() < contamination
|
|
||||||
n_views = rng.integers(*self.cfg.views_per_session)
|
|
||||||
sid = str(uuid4())[:8]
|
|
||||||
|
|
||||||
# scrapers view more products
|
|
||||||
if is_scraper:
|
|
||||||
n_views = min(instruments.n, n_views * 3)
|
|
||||||
|
|
||||||
viewed = rng.choice(instruments.n, size=min(n_views, instruments.n), replace=False)
|
|
||||||
for inst_id in viewed:
|
|
||||||
opps.append(Opportunity(
|
|
||||||
id=f"{sid}-{inst_id}", type=OpportunityType.SESSION,
|
|
||||||
side=Side.BUY, instrument_id=int(inst_id), size=1.0, t=t,
|
|
||||||
context={'session_id': sid, 'is_scraper': is_scraper, 'n_views': n_views}
|
|
||||||
))
|
|
||||||
return opps
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
"""
|
|
||||||
Market and competitor models for external dynamics.
|
|
||||||
|
|
||||||
This module provides models for competitor pricing (retail) and market dynamics (finance):
|
|
||||||
- StaticCompetitorModel: Fixed competitor prices
|
|
||||||
- ReactiveCompetitorModel: Competitor reacts to agent's prices, can trigger price wars
|
|
||||||
- StochasticCompetitorModel: Random walk competitor prices
|
|
||||||
- GBMMarketModel: Geometric Brownian Motion for asset mid-prices
|
|
||||||
|
|
||||||
Each model implements the MarketModel protocol.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import numpy as np
|
|
||||||
from ..outlet.types import Quote, MarketState, HiddenState
|
|
||||||
from ..outlet.math_util import clamp, ema
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StaticCompetitorConfig:
|
|
||||||
"""Configuration for static competitor.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
markup: Fixed percentage markup over reference prices
|
|
||||||
"""
|
|
||||||
markup: float = 0.1
|
|
||||||
|
|
||||||
class StaticCompetitorModel:
|
|
||||||
"""Static competitor with fixed markup pricing.
|
|
||||||
|
|
||||||
Competitor prices = reference * (1 + markup).
|
|
||||||
Useful as a baseline or for testing without competitor dynamics.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: StaticCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
|
||||||
self.cfg = cfg or StaticCompetitorConfig()
|
|
||||||
self.refs = refs
|
|
||||||
|
|
||||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> MarketState:
|
|
||||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
|
||||||
comp_prices = refs * (1 + self.cfg.markup)
|
|
||||||
return MarketState(competitor_quotes=comp_prices, regime='static', t=t)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ReactiveCompetitorConfig:
|
|
||||||
"""Configuration for reactive competitor.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
follow_weight: Smoothing weight for price following (0=ignore, 1=instant)
|
|
||||||
band_pct: Maximum deviation from reference prices
|
|
||||||
war_threshold: Relative price diff that triggers price war
|
|
||||||
war_aggression: How much competitor cuts prices during war
|
|
||||||
"""
|
|
||||||
follow_weight: float = 0.3
|
|
||||||
band_pct: float = 0.1
|
|
||||||
war_threshold: float = -0.15
|
|
||||||
war_aggression: float = 0.2
|
|
||||||
|
|
||||||
class ReactiveCompetitorModel:
|
|
||||||
"""Competitor that reacts to agent's prices with price war dynamics.
|
|
||||||
|
|
||||||
The competitor follows the agent's prices with smoothing.
|
|
||||||
If the agent undercuts significantly (beyond war_threshold),
|
|
||||||
a price war is triggered where the competitor becomes more aggressive.
|
|
||||||
|
|
||||||
This creates non-stationary dynamics that test policy robustness.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: ReactiveCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
|
||||||
self.cfg = cfg or ReactiveCompetitorConfig()
|
|
||||||
self.refs = refs
|
|
||||||
self._prices: np.ndarray | None = None
|
|
||||||
self._in_war: bool = False
|
|
||||||
|
|
||||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> MarketState:
|
|
||||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
|
||||||
c = self.cfg
|
|
||||||
|
|
||||||
if self._prices is None:
|
|
||||||
self._prices = refs.copy()
|
|
||||||
|
|
||||||
# check for price war trigger
|
|
||||||
relative_diff = (self_quotes.prices - self._prices) / (self._prices + 1e-8)
|
|
||||||
if np.any(relative_diff < c.war_threshold):
|
|
||||||
self._in_war = True
|
|
||||||
elif np.all(relative_diff > -c.war_threshold / 2):
|
|
||||||
self._in_war = False
|
|
||||||
|
|
||||||
# update prices
|
|
||||||
if self._in_war:
|
|
||||||
target = self_quotes.prices * (1 - c.war_aggression)
|
|
||||||
hidden.regime = 'price_war'
|
|
||||||
else:
|
|
||||||
target = self_quotes.prices * (1 + c.follow_weight * 0.05)
|
|
||||||
hidden.regime = 'normal'
|
|
||||||
|
|
||||||
# follow with smoothing
|
|
||||||
new_prices = np.array([ema(old, new, c.follow_weight)
|
|
||||||
for old, new in zip(self._prices, target)])
|
|
||||||
|
|
||||||
# stay within band
|
|
||||||
new_prices = clamp(new_prices, refs * (1 - c.band_pct), refs * (1 + c.band_pct))
|
|
||||||
self._prices = new_prices
|
|
||||||
|
|
||||||
return MarketState(competitor_quotes=new_prices, regime=hidden.regime, t=t)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StochasticCompetitorConfig:
|
|
||||||
"""Configuration for stochastic competitor.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
drift: Price drift per step
|
|
||||||
volatility: Price volatility (std of random shocks)
|
|
||||||
mean_revert: Mean reversion strength toward reference
|
|
||||||
"""
|
|
||||||
drift: float = 0.0
|
|
||||||
volatility: float = 0.02
|
|
||||||
mean_revert: float = 0.1
|
|
||||||
|
|
||||||
class StochasticCompetitorModel:
|
|
||||||
"""Ornstein-Uhlenbeck style stochastic competitor prices.
|
|
||||||
|
|
||||||
Prices follow: dP = drift + mean_revert*(ref - P) + volatility*P*dW
|
|
||||||
|
|
||||||
Provides non-stationary competitor dynamics independent of agent actions.
|
|
||||||
Useful for testing robustness to market noise.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: StochasticCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
|
||||||
self.cfg = cfg or StochasticCompetitorConfig()
|
|
||||||
self.refs = refs
|
|
||||||
self._prices: np.ndarray | None = None
|
|
||||||
|
|
||||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> MarketState:
|
|
||||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
|
||||||
c = self.cfg
|
|
||||||
|
|
||||||
if self._prices is None:
|
|
||||||
self._prices = refs.copy()
|
|
||||||
|
|
||||||
# Ornstein-Uhlenbeck style dynamics
|
|
||||||
n = len(self._prices)
|
|
||||||
noise = rng.normal(0, c.volatility, n)
|
|
||||||
reversion = c.mean_revert * (refs - self._prices)
|
|
||||||
self._prices = self._prices + c.drift + reversion + noise * self._prices
|
|
||||||
self._prices = np.maximum(self._prices, refs * 0.5)
|
|
||||||
|
|
||||||
return MarketState(competitor_quotes=self._prices.copy(), regime='stochastic', t=t)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GBMMarketConfig:
|
|
||||||
"""Configuration for GBM market model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
mu: Price drift (expected return)
|
|
||||||
sigma: Price volatility
|
|
||||||
dt: Time step size
|
|
||||||
"""
|
|
||||||
mu: float = 0.0
|
|
||||||
sigma: float = 0.1
|
|
||||||
dt: float = 1.0
|
|
||||||
|
|
||||||
class GBMMarketModel:
|
|
||||||
"""Geometric Brownian Motion model for asset mid-prices.
|
|
||||||
|
|
||||||
Standard Black-Scholes dynamics: dS = mu*S*dt + sigma*S*dW
|
|
||||||
|
|
||||||
Used for market making scenarios where the underlying asset price
|
|
||||||
follows a random walk. The agent quotes around this moving mid-price.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: GBMMarketConfig | None = None, initial: np.ndarray | None = None):
|
|
||||||
self.cfg = cfg or GBMMarketConfig()
|
|
||||||
self._mids = initial
|
|
||||||
|
|
||||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
|
||||||
rng: np.random.Generator) -> MarketState:
|
|
||||||
if self._mids is None:
|
|
||||||
self._mids = self_quotes.prices.copy()
|
|
||||||
|
|
||||||
c = self.cfg
|
|
||||||
n = len(self._mids)
|
|
||||||
z = rng.standard_normal(n)
|
|
||||||
self._mids = self._mids * np.exp((c.mu - 0.5*c.sigma**2)*c.dt + c.sigma*np.sqrt(c.dt)*z)
|
|
||||||
|
|
||||||
vol = np.full(n, c.sigma)
|
|
||||||
return MarketState(mid_prices=self._mids.copy(), volatility=vol, regime='gbm', t=t)
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
"""
|
|
||||||
Execution models for computing acceptance/fill probabilities.
|
|
||||||
|
|
||||||
This module provides different models for how opportunities convert to executions:
|
|
||||||
- ElasticityExecutionModel: Price elasticity with competitor cross-effects (retail)
|
|
||||||
- IntensityExecutionModel: Distance-based fill intensity (market making)
|
|
||||||
- LogitExecutionModel: Discrete choice model
|
|
||||||
|
|
||||||
Each model implements the ExecutionModel protocol.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Any
|
|
||||||
import numpy as np
|
|
||||||
from ..outlet.types import Opportunity, Quote, InstrumentSet, MarketState
|
|
||||||
from ..outlet.constants import Side
|
|
||||||
from ..outlet.math_util import sigmoid, safe_log, intensity_decay, EPS
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ElasticityConfig:
|
|
||||||
"""Configuration for price elasticity execution model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
base_prob: Baseline purchase probability at reference price
|
|
||||||
price_sensitivity: Own-price elasticity coefficient
|
|
||||||
cross_elasticity: Competitor price cross-elasticity
|
|
||||||
scraper_conversion: Multiplier for scraper conversion (typically << 1)
|
|
||||||
"""
|
|
||||||
base_prob: float = 0.3
|
|
||||||
price_sensitivity: float = 2.0
|
|
||||||
cross_elasticity: float = 0.5
|
|
||||||
scraper_conversion: float = 0.01
|
|
||||||
|
|
||||||
class ElasticityExecutionModel:
|
|
||||||
"""Price elasticity model for retail dynamic pricing.
|
|
||||||
|
|
||||||
P(buy) = base_prob * exp(-sensitivity * log(price/ref)) * cross_effect * scraper_mult
|
|
||||||
|
|
||||||
Higher prices reduce purchase probability exponentially.
|
|
||||||
Competitor undercutting shifts demand away from the platform.
|
|
||||||
Scrapers convert at a much lower rate (reconnaissance, not purchase).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: ElasticityConfig | None = None):
|
|
||||||
self.cfg = cfg or ElasticityConfig()
|
|
||||||
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
price = quote.prices[idx]
|
|
||||||
ref = instruments.refs[idx]
|
|
||||||
|
|
||||||
# base probability adjusted by price ratio
|
|
||||||
log_ratio = safe_log(price / ref)
|
|
||||||
prob = self.cfg.base_prob * np.exp(-self.cfg.price_sensitivity * log_ratio)
|
|
||||||
|
|
||||||
# cross-elasticity: competitor undercutting increases their share
|
|
||||||
if market and market.competitor_quotes is not None:
|
|
||||||
comp_price = market.competitor_quotes[idx]
|
|
||||||
if comp_price < price:
|
|
||||||
prob *= np.exp(-self.cfg.cross_elasticity * (price - comp_price) / ref)
|
|
||||||
|
|
||||||
# scrapers convert at much lower rate
|
|
||||||
if opp.context.get('is_scraper', False):
|
|
||||||
prob *= self.cfg.scraper_conversion
|
|
||||||
|
|
||||||
return float(np.clip(prob, 0, 1))
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
|
||||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
# simple imputation: assume fills = prob * exposures, invert
|
|
||||||
exposures = context.get('exposures', fills) if context else fills
|
|
||||||
avg_prob = self.cfg.base_prob
|
|
||||||
return fills / (avg_prob + EPS)
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class IntensityConfig:
|
|
||||||
"""Configuration for intensity-based execution model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
base_intensity: Baseline fill intensity
|
|
||||||
kappa: Decay rate with distance from mid-price
|
|
||||||
vol_scale: Volatility multiplier for fill intensity
|
|
||||||
"""
|
|
||||||
base_intensity: float = 1.0
|
|
||||||
kappa: float = 1.5
|
|
||||||
vol_scale: float = 0.5
|
|
||||||
|
|
||||||
class IntensityExecutionModel:
|
|
||||||
"""Avellaneda-Stoikov style fill intensity for market making.
|
|
||||||
|
|
||||||
Fill probability decays exponentially with distance from mid-price:
|
|
||||||
P(fill) = base * exp(-kappa * |quote - mid|) * (1 + vol_scale * sigma)
|
|
||||||
|
|
||||||
Tighter spreads (closer to mid) have higher fill probability.
|
|
||||||
Higher volatility increases fill probability (more aggressive traders).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: IntensityConfig | None = None):
|
|
||||||
self.cfg = cfg or IntensityConfig()
|
|
||||||
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
|
|
||||||
# get mid price from market or use quote price
|
|
||||||
if market and market.mid_prices is not None:
|
|
||||||
mid = market.mid_prices[idx]
|
|
||||||
else:
|
|
||||||
mid = quote.prices[idx]
|
|
||||||
|
|
||||||
# compute distance from mid
|
|
||||||
if opp.side == Side.BUY:
|
|
||||||
exec_price = quote.asks[idx] if quote.asks is not None else quote.prices[idx]
|
|
||||||
distance = exec_price - mid
|
|
||||||
else:
|
|
||||||
exec_price = quote.bids[idx] if quote.bids is not None else quote.prices[idx]
|
|
||||||
distance = mid - exec_price
|
|
||||||
|
|
||||||
# intensity decays with distance
|
|
||||||
intensity = self.cfg.base_intensity * intensity_decay(abs(distance), self.cfg.kappa)
|
|
||||||
|
|
||||||
# volatility increases fill probability
|
|
||||||
if market and market.volatility is not None:
|
|
||||||
vol = market.volatility[idx]
|
|
||||||
intensity *= (1 + self.cfg.vol_scale * vol)
|
|
||||||
|
|
||||||
return float(np.clip(intensity, 0, 1))
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
|
||||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
return fills # market making doesn't have same censorship concept
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class LogitConfig:
|
|
||||||
"""Configuration for logit discrete choice model.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
beta_0: Intercept (base utility)
|
|
||||||
beta_price: Price coefficient (typically negative)
|
|
||||||
beta_quality: Quality attribute coefficient
|
|
||||||
"""
|
|
||||||
beta_0: float = 0.5
|
|
||||||
beta_price: float = -1.5
|
|
||||||
beta_quality: float = 0.3
|
|
||||||
|
|
||||||
class LogitExecutionModel:
|
|
||||||
"""Discrete choice logit model for purchase probability.
|
|
||||||
|
|
||||||
Utility: U = beta_0 + beta_price * (price/ref) + beta_quality * quality
|
|
||||||
P(buy) = sigmoid(U)
|
|
||||||
|
|
||||||
Provides a theoretically grounded demand model from economics literature.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, cfg: LogitConfig | None = None):
|
|
||||||
self.cfg = cfg or LogitConfig()
|
|
||||||
|
|
||||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
|
||||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
|
||||||
idx = int(opp.instrument_id)
|
|
||||||
price = quote.prices[idx]
|
|
||||||
ref = instruments.refs[idx]
|
|
||||||
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
|
|
||||||
|
|
||||||
# utility
|
|
||||||
u = self.cfg.beta_0 + self.cfg.beta_price * (price / ref) + self.cfg.beta_quality * quality
|
|
||||||
|
|
||||||
# choice probability via sigmoid
|
|
||||||
return float(sigmoid(u))
|
|
||||||
|
|
||||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
|
||||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
|
||||||
return fills / (self.cfg.beta_0 + EPS)
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
"""Example script demonstrating the Quote-Control platform"""
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from lab.config import make_retail_platform, make_market_making_platform
|
|
||||||
from lab.experiments.eval import (rollout, compare_policies, fixed_price_policy,
|
|
||||||
cost_plus_margin_policy, random_walk_policy)
|
|
||||||
|
|
||||||
def demo_retail():
|
|
||||||
print("=" * 60)
|
|
||||||
print("RETAIL DYNAMIC PRICING DEMO")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
platform = make_retail_platform()
|
|
||||||
print(f"Instruments: {platform.instruments.n}")
|
|
||||||
print(f"Reference prices: {platform.instruments.refs[:5].round(2)}...")
|
|
||||||
|
|
||||||
# compare policies
|
|
||||||
policies = {
|
|
||||||
'fixed': fixed_price_policy(platform.instruments.refs),
|
|
||||||
'cost_plus_30%': cost_plus_margin_policy(platform.instruments.costs, 0.3),
|
|
||||||
'cost_plus_50%': cost_plus_margin_policy(platform.instruments.costs, 0.5),
|
|
||||||
'random_walk': random_walk_policy(platform.instruments.refs, 0.03),
|
|
||||||
}
|
|
||||||
|
|
||||||
results = compare_policies(platform, policies, n_steps=100, n_runs=3)
|
|
||||||
|
|
||||||
print("\nPolicy Comparison (100 steps, 3 runs):")
|
|
||||||
print("-" * 50)
|
|
||||||
for name, r in sorted(results.items(), key=lambda x: -x[1]['mean_pnl']):
|
|
||||||
print(f"{name:20s} PnL={r['mean_pnl']:8.1f} +/- {r['std_reward']:6.1f} "
|
|
||||||
f"conv={r['mean_conversion']:.3f}")
|
|
||||||
|
|
||||||
def demo_market_making():
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("MARKET MAKING DEMO")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
platform = make_market_making_platform()
|
|
||||||
print(f"Instruments: {platform.instruments.n}")
|
|
||||||
print(f"Initial mids: {platform.instruments.refs.round(2)}")
|
|
||||||
|
|
||||||
# simple policy: quote at mid with fixed spread
|
|
||||||
def mm_policy(obs: np.ndarray, t: int):
|
|
||||||
mids = platform.instruments.refs # would use obs in real policy
|
|
||||||
return mids, 1.0
|
|
||||||
|
|
||||||
result = rollout(platform, mm_policy, n_steps=200, seed=42)
|
|
||||||
print(f"\nRollout (200 steps):")
|
|
||||||
print(f" Total PnL: {result.total_pnl:.2f}")
|
|
||||||
print(f" Avg conversion: {result.avg_conversion:.3f}")
|
|
||||||
print(f" Total spread capture: {sum(m.spread_capture for m in result.metrics):.2f}")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
demo_retail()
|
|
||||||
demo_market_making()
|
|
||||||
@@ -2,11 +2,14 @@ import redis
|
|||||||
import pickle
|
import pickle
|
||||||
import json
|
import json
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
from io import StringIO
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class ModelRegistry:
|
class ModelRegistry:
|
||||||
"""
|
"""
|
||||||
Lightweight model registry using Redis for storing pricing models and elasticity data.
|
Lightweight model registry using Redis for storing pricing models and elasticity data.
|
||||||
@@ -14,24 +17,23 @@ class ModelRegistry:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, redis_host: str = None, redis_port: int = None):
|
def __init__(self, redis_host: str = None, redis_port: int = None):
|
||||||
host = redis_host or os.getenv('REDIS_HOST', 'localhost')
|
host = redis_host or os.getenv("REDIS_HOST", "localhost")
|
||||||
port = redis_port or int(os.getenv('REDIS_PORT', '6378'))
|
port = redis_port or int(os.getenv("REDIS_PORT", "6378"))
|
||||||
|
|
||||||
self.redis_client = redis.Redis(
|
self.redis_client = redis.Redis(
|
||||||
host=host,
|
host=host, port=port, db=0, decode_responses=False
|
||||||
port=port,
|
|
||||||
db=0,
|
|
||||||
decode_responses=False
|
|
||||||
)
|
)
|
||||||
self.metadata_prefix = "model:meta:"
|
self.metadata_prefix = "model:meta:"
|
||||||
self.data_prefix = "model:data:"
|
self.data_prefix = "model:data:"
|
||||||
self.elasticity_prefix = "elasticity:"
|
self.elasticity_prefix = "elasticity:"
|
||||||
self.prices_prefix = "prices:"
|
self.prices_prefix = "prices:"
|
||||||
|
|
||||||
def publish_elasticity(self,
|
def publish_elasticity(
|
||||||
|
self,
|
||||||
elasticity_df: pd.DataFrame,
|
elasticity_df: pd.DataFrame,
|
||||||
model_name: str = 'latest',
|
model_name: str = "latest",
|
||||||
metadata: Optional[Dict[str, Any]] = None):
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Store elasticity estimates in registry.
|
Store elasticity estimates in registry.
|
||||||
|
|
||||||
@@ -43,25 +45,29 @@ class ModelRegistry:
|
|||||||
key = f"{self.elasticity_prefix}{model_name}"
|
key = f"{self.elasticity_prefix}{model_name}"
|
||||||
|
|
||||||
# serialize dataframe as JSON
|
# serialize dataframe as JSON
|
||||||
data_json = elasticity_df.to_json(orient='records')
|
data_json = elasticity_df.to_json(orient="records")
|
||||||
|
|
||||||
# store data
|
# store data
|
||||||
self.redis_client.set(key, data_json)
|
self.redis_client.set(key, data_json)
|
||||||
|
|
||||||
# store metadata
|
# store metadata
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
meta.update({
|
meta.update(
|
||||||
'n_products': len(elasticity_df),
|
{
|
||||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
"n_products": len(elasticity_df),
|
||||||
'model_type': 'elasticity_snapshot'
|
"mean_elasticity": float(elasticity_df["elasticity"].mean()),
|
||||||
})
|
"model_type": "elasticity_snapshot",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||||
self.redis_client.set(meta_key, json.dumps(meta))
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
log.info(f"Published elasticity model '{model_name}' with {len(elasticity_df)} products")
|
log.info(
|
||||||
|
f"Published elasticity model '{model_name}' with {len(elasticity_df)} products"
|
||||||
|
)
|
||||||
|
|
||||||
def get_elasticity(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
def get_elasticity(self, model_name: str = "latest") -> Optional[pd.DataFrame]:
|
||||||
"""Retrieve elasticity estimates from registry."""
|
"""Retrieve elasticity estimates from registry."""
|
||||||
key = f"{self.elasticity_prefix}{model_name}"
|
key = f"{self.elasticity_prefix}{model_name}"
|
||||||
data_json = self.redis_client.get(key)
|
data_json = self.redis_client.get(key)
|
||||||
@@ -71,14 +77,16 @@ class ModelRegistry:
|
|||||||
|
|
||||||
# decode bytes to string if needed
|
# decode bytes to string if needed
|
||||||
if isinstance(data_json, bytes):
|
if isinstance(data_json, bytes):
|
||||||
data_json = data_json.decode('utf-8')
|
data_json = data_json.decode("utf-8")
|
||||||
|
|
||||||
return pd.read_json(data_json, orient='records')
|
return pd.read_json(StringIO(data_json), orient="records")
|
||||||
|
|
||||||
def publish_pricing_model(self,
|
def publish_pricing_model(
|
||||||
|
self,
|
||||||
pricing_function,
|
pricing_function,
|
||||||
model_name: str = 'latest',
|
model_name: str = "latest",
|
||||||
metadata: Optional[Dict[str, Any]] = None):
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Store a fitted pricing function object.
|
Store a fitted pricing function object.
|
||||||
|
|
||||||
@@ -95,17 +103,19 @@ class ModelRegistry:
|
|||||||
|
|
||||||
# store metadata
|
# store metadata
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
meta.update({
|
meta.update(
|
||||||
'model_class': pricing_function.__class__.__name__,
|
{
|
||||||
'model_type': 'pricing_function'
|
"model_class": pricing_function.__class__.__name__,
|
||||||
})
|
"model_type": "pricing_function",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||||
self.redis_client.set(meta_key, json.dumps(meta))
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
log.info(f"Published pricing model '{model_name}' ({meta['model_class']})")
|
log.info(f"Published pricing model '{model_name}' ({meta['model_class']})")
|
||||||
|
|
||||||
def get_pricing_model(self, model_name: str = 'latest'):
|
def get_pricing_model(self, model_name: str = "latest"):
|
||||||
"""Retrieve a pricing function from registry."""
|
"""Retrieve a pricing function from registry."""
|
||||||
key = f"{self.data_prefix}{model_name}"
|
key = f"{self.data_prefix}{model_name}"
|
||||||
model_bytes = self.redis_client.get(key)
|
model_bytes = self.redis_client.get(key)
|
||||||
@@ -120,21 +130,23 @@ class ModelRegistry:
|
|||||||
models = {}
|
models = {}
|
||||||
|
|
||||||
for key in self.redis_client.scan_iter(f"{self.metadata_prefix}*"):
|
for key in self.redis_client.scan_iter(f"{self.metadata_prefix}*"):
|
||||||
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
|
||||||
model_name = key_str.replace(self.metadata_prefix, '')
|
model_name = key_str.replace(self.metadata_prefix, "")
|
||||||
meta_json = self.redis_client.get(key)
|
meta_json = self.redis_client.get(key)
|
||||||
|
|
||||||
if meta_json:
|
if meta_json:
|
||||||
if isinstance(meta_json, bytes):
|
if isinstance(meta_json, bytes):
|
||||||
meta_json = meta_json.decode('utf-8')
|
meta_json = meta_json.decode("utf-8")
|
||||||
models[model_name] = json.loads(meta_json)
|
models[model_name] = json.loads(meta_json)
|
||||||
|
|
||||||
return models
|
return models
|
||||||
|
|
||||||
def publish_prices(self,
|
def publish_prices(
|
||||||
|
self,
|
||||||
prices_df: pd.DataFrame,
|
prices_df: pd.DataFrame,
|
||||||
model_name: str = 'latest',
|
model_name: str = "latest",
|
||||||
metadata: Optional[Dict[str, Any]] = None):
|
metadata: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
"""Store predicted prices in registry.
|
"""Store predicted prices in registry.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -143,22 +155,19 @@ class ModelRegistry:
|
|||||||
metadata: additional info
|
metadata: additional info
|
||||||
"""
|
"""
|
||||||
key = f"{self.prices_prefix}{model_name}"
|
key = f"{self.prices_prefix}{model_name}"
|
||||||
data_json = prices_df.to_json(orient='records')
|
data_json = prices_df.to_json(orient="records")
|
||||||
|
|
||||||
self.redis_client.set(key, data_json)
|
self.redis_client.set(key, data_json)
|
||||||
|
|
||||||
meta = metadata or {}
|
meta = metadata or {}
|
||||||
meta.update({
|
meta.update({"n_products": len(prices_df), "model_type": "predicted_prices"})
|
||||||
'n_products': len(prices_df),
|
|
||||||
'model_type': 'predicted_prices'
|
|
||||||
})
|
|
||||||
|
|
||||||
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
||||||
self.redis_client.set(meta_key, json.dumps(meta))
|
self.redis_client.set(meta_key, json.dumps(meta))
|
||||||
|
|
||||||
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
||||||
|
|
||||||
def get_prices(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
def get_prices(self, model_name: str = "latest") -> Optional[pd.DataFrame]:
|
||||||
"""Retrieve predicted prices from registry."""
|
"""Retrieve predicted prices from registry."""
|
||||||
key = f"{self.prices_prefix}{model_name}"
|
key = f"{self.prices_prefix}{model_name}"
|
||||||
data_json = self.redis_client.get(key)
|
data_json = self.redis_client.get(key)
|
||||||
@@ -167,9 +176,9 @@ class ModelRegistry:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(data_json, bytes):
|
if isinstance(data_json, bytes):
|
||||||
data_json = data_json.decode('utf-8')
|
data_json = data_json.decode("utf-8")
|
||||||
|
|
||||||
return pd.read_json(data_json, orient='records')
|
return pd.read_json(StringIO(data_json), orient="records")
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
"""Check if Redis connection is alive."""
|
"""Check if Redis connection is alive."""
|
||||||
@@ -179,7 +188,9 @@ class ModelRegistry:
|
|||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def set_session_prices(self, session_id: str, prices: Dict[str, float], ttl: int = 1800):
|
def set_session_prices(
|
||||||
|
self, session_id: str, prices: Dict[str, float], ttl: int = 1800
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Store prices for a specific session.
|
Store prices for a specific session.
|
||||||
THIS is the write path for session-aware pricing.
|
THIS is the write path for session-aware pricing.
|
||||||
@@ -210,7 +221,9 @@ class ModelRegistry:
|
|||||||
if price_str is None:
|
if price_str is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return float(price_str.decode('utf-8') if isinstance(price_str, bytes) else price_str)
|
return float(
|
||||||
|
price_str.decode("utf-8") if isinstance(price_str, bytes) else price_str
|
||||||
|
)
|
||||||
|
|
||||||
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
|
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
|
||||||
"""Get all prices for a session."""
|
"""Get all prices for a session."""
|
||||||
@@ -221,6 +234,8 @@ class ModelRegistry:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
(k.decode('utf-8') if isinstance(k, bytes) else k): float(v.decode('utf-8') if isinstance(v, bytes) else v)
|
(k.decode("utf-8") if isinstance(k, bytes) else k): float(
|
||||||
|
v.decode("utf-8") if isinstance(v, bytes) else v
|
||||||
|
)
|
||||||
for k, v in prices_raw.items()
|
for k, v in prices_raw.items()
|
||||||
}
|
}
|
||||||
|
|||||||
128
lib/separability.py
Normal file
128
lib/separability.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"""Utilities for loading separability artifacts and scoring interaction sessions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Iterable, List, Sequence
|
||||||
|
|
||||||
|
import joblib
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from experiments.ml.arch import featurize_trajectory
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_ARTIFACT_DIR = Path("data/separability")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SeparabilityArtifacts:
|
||||||
|
scaler: object
|
||||||
|
classifier: object
|
||||||
|
states: List[str]
|
||||||
|
event_transitions: Dict[str, Dict[str, float]]
|
||||||
|
feature_dim: int
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_events(raw_events: Sequence[object]) -> List[object]:
|
||||||
|
events: List[object] = []
|
||||||
|
for evt in raw_events:
|
||||||
|
if hasattr(evt, "value") and hasattr(evt.value, "payload"):
|
||||||
|
events.append(evt.value.payload)
|
||||||
|
else:
|
||||||
|
events.append(evt)
|
||||||
|
events.sort(key=lambda e: getattr(e, "ts", ""))
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _event_transition_distribution(events: Sequence[object]) -> Dict[str, Dict[str, float]]:
|
||||||
|
counts: Dict[str, Dict[str, int]] = {}
|
||||||
|
for src_evt, dst_evt in zip(events, events[1:]):
|
||||||
|
src_name = getattr(src_evt, "eventName", "unknown")
|
||||||
|
dst_name = getattr(dst_evt, "eventName", "unknown")
|
||||||
|
counts.setdefault(src_name, {})
|
||||||
|
counts[src_name][dst_name] = counts[src_name].get(dst_name, 0) + 1
|
||||||
|
|
||||||
|
distribution: Dict[str, Dict[str, float]] = {}
|
||||||
|
for src, dsts in counts.items():
|
||||||
|
total = float(sum(dsts.values()))
|
||||||
|
distribution[src] = {dst: val / total for dst, val in dsts.items()} if total else {}
|
||||||
|
return distribution
|
||||||
|
|
||||||
|
|
||||||
|
def _kl_divergence(p: Dict[str, Dict[str, float]], q: Dict[str, Dict[str, float]]) -> float:
|
||||||
|
eps = 1e-10
|
||||||
|
total = 0.0
|
||||||
|
for src, dsts in p.items():
|
||||||
|
for dst, prob in dsts.items():
|
||||||
|
ref = q.get(src, {}).get(dst, 0.0)
|
||||||
|
total += (prob + eps) * np.log((prob + eps) / (ref + eps))
|
||||||
|
return float(total)
|
||||||
|
|
||||||
|
|
||||||
|
def load_artifacts(artifact_dir: Path | str = DEFAULT_ARTIFACT_DIR) -> SeparabilityArtifacts:
|
||||||
|
artifact_dir = Path(artifact_dir)
|
||||||
|
scaler_path = artifact_dir / "scaler.joblib"
|
||||||
|
model_path = artifact_dir / "classifier.joblib"
|
||||||
|
metadata_path = artifact_dir / "metadata.json"
|
||||||
|
|
||||||
|
if not (scaler_path.exists() and model_path.exists() and metadata_path.exists()):
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Separability artifacts not found in {artifact_dir}. Run sim.strong_learner.train first."
|
||||||
|
)
|
||||||
|
|
||||||
|
scaler = joblib.load(scaler_path)
|
||||||
|
classifier = joblib.load(model_path)
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as fin:
|
||||||
|
metadata = json.load(fin)
|
||||||
|
|
||||||
|
return SeparabilityArtifacts(
|
||||||
|
scaler=scaler,
|
||||||
|
classifier=classifier,
|
||||||
|
states=list(metadata["reference_states"]),
|
||||||
|
event_transitions=metadata["event_transitions"],
|
||||||
|
feature_dim=int(metadata["feature_dim"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def score_session(
|
||||||
|
raw_events: Sequence[object],
|
||||||
|
artifacts: SeparabilityArtifacts,
|
||||||
|
) -> dict:
|
||||||
|
events = _normalize_events(raw_events)
|
||||||
|
if not events:
|
||||||
|
return {"prob_agent": 0.0, "delta_h": 0.0, "delta_a": 0.0}
|
||||||
|
|
||||||
|
reference_mdp = {"states": artifacts.states}
|
||||||
|
features = featurize_trajectory(events, mdp=reference_mdp, input_dim=artifacts.feature_dim)
|
||||||
|
scaled = artifacts.scaler.transform(features.reshape(1, -1))
|
||||||
|
prob_agent = float(artifacts.classifier.predict_proba(scaled)[0, 1])
|
||||||
|
|
||||||
|
session_dist = _event_transition_distribution(events)
|
||||||
|
delta_h = _kl_divergence(session_dist, artifacts.event_transitions.get("human", {}))
|
||||||
|
delta_a = _kl_divergence(session_dist, artifacts.event_transitions.get("agent", {}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"prob_agent": prob_agent,
|
||||||
|
"delta_h": delta_h,
|
||||||
|
"delta_a": delta_a,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_alpha(prob_agent: float, delta_h: float, delta_a: float, temperature: float = 1.0) -> float:
|
||||||
|
divergence_mass = delta_h + delta_a
|
||||||
|
if divergence_mass <= 1e-8:
|
||||||
|
return float(prob_agent)
|
||||||
|
|
||||||
|
ratio = delta_a / divergence_mass
|
||||||
|
blended = 0.5 * prob_agent + 0.5 * ratio
|
||||||
|
if temperature <= 0:
|
||||||
|
return float(np.clip(blended, 0.0, 1.0))
|
||||||
|
|
||||||
|
scaled = 1.0 / (1.0 + np.exp(-temperature * (blended - 0.5)))
|
||||||
|
return float(np.clip(scaled, 0.0, 1.0))
|
||||||
|
|
||||||
|
|
||||||
|
def score_sessions(raw_sessions: Iterable[Sequence[object]], artifacts: SeparabilityArtifacts) -> List[dict]:
|
||||||
|
return [score_session(events, artifacts) for events in raw_sessions]
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
$pdf_mode = 1;
|
$pdf_mode = 1;
|
||||||
$pdflatex = 'pdflatex -synctex=1 -interaction=nonstopmode -file-line-error %O %S';
|
$pdflatex = 'pdflatex -synctex=1 -interaction=nonstopmode -file-line-error %O %S';
|
||||||
$aux_dir = 'build';
|
$bibtex_use = 2; # run bibtex when needed
|
||||||
$out_dir = 'build';
|
|
||||||
$use_biber = 0; # force bibtex
|
|
||||||
$bibtex = 'bibtex %O %B';
|
$bibtex = 'bibtex %O %B';
|
||||||
$pdf_previewer = 'zathura %O %S';
|
$pdf_previewer = 'zathura %O %S';
|
||||||
$clean_ext = 'synctex.gz bbl bcf run.xml fls fdb_latexmk glg glo gls ist blg lof lot out toc';
|
$clean_ext = 'synctex.gz bbl bcf run.xml fls fdb_latexmk glg glo gls ist blg lof lot out toc';
|
||||||
|
|||||||
@@ -42,23 +42,27 @@ EOF
|
|||||||
# Process each directory
|
# Process each directory
|
||||||
echo "Concatenating code from source directories..."
|
echo "Concatenating code from source directories..."
|
||||||
|
|
||||||
|
# Engine
|
||||||
|
find "$PROJECT_ROOT/engine" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||||
|
add_file "$file"
|
||||||
|
done
|
||||||
# Backend
|
# Backend
|
||||||
find "$PROJECT_ROOT/backend" -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) | sort | while read -r file; do
|
find "$PROJECT_ROOT/backend" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||||
add_file "$file"
|
add_file "$file"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Experiments
|
# Experiments
|
||||||
find "$PROJECT_ROOT/experiments" -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) | sort | while read -r file; do
|
find "$PROJECT_ROOT/experiments" -type d \( -name ".venv" -o -name "__pycache__" -o -name "*.egg-info" -o -name "node_modules" -o -name ".pytest_cache" -o -name ".ipynb_checkpoints" \) -prune -o -type f \( -name "*.py" -o -name "*.js" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||||
add_file "$file"
|
add_file "$file"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
find "$PROJECT_ROOT/docker" -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "Dockerfile*" \) | sort | while read -r file; do
|
find "$PROJECT_ROOT/docker" -type d \( -name ".venv" -o -name "__pycache__" -o -name "node_modules" \) -prune -o -type f \( -name "*.py" -o -name "*.sh" -o -name "*.yml" -o -name "*.yaml" -o -name "*.Dockerfile*" \) ! -name "*.pyc" ! -name "*.pyo" -print | sort | while read -r file; do
|
||||||
add_file "$file"
|
add_file "$file"
|
||||||
done
|
done
|
||||||
|
|
||||||
# Web/src
|
# Web/src
|
||||||
find "$PROJECT_ROOT/web/src" -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" \) | sort | while read -r file; do
|
find "$PROJECT_ROOT/web/src" -type d \( -name "node_modules" -o -name ".next" -o -name "dist" -o -name "build" \) -prune -o -type f \( -name "*.js" -o -name "*.jsx" -o -name "*.ts" -o -name "*.tsx" \) -print | sort | while read -r file; do
|
||||||
add_file "$file"
|
add_file "$file"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
(setq TeX-command-extra-options
|
(setq TeX-command-extra-options
|
||||||
"-file-line-error -interaction=nonstopmode")
|
"-file-line-error -interaction=nonstopmode")
|
||||||
(TeX-add-to-alist 'LaTeX-provided-class-options
|
(TeX-add-to-alist 'LaTeX-provided-class-options
|
||||||
'(("report" "12pt") ("article" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false")))
|
'(("report" "12pt") ("acmart" "sigconf" "nonacm" "natbib=false" "manuscript") ("article" "12pt" "letterpaper")))
|
||||||
(TeX-run-style-hooks
|
(TeX-run-style-hooks
|
||||||
"latex2e"
|
"latex2e"
|
||||||
"preamble"
|
"preamble"
|
||||||
@@ -16,9 +16,7 @@
|
|||||||
"chapters/04-results"
|
"chapters/04-results"
|
||||||
"chapters/05-discussion"
|
"chapters/05-discussion"
|
||||||
"chapters/06-conclusion"
|
"chapters/06-conclusion"
|
||||||
"../build/concatenated_code"
|
"article"
|
||||||
"acmart"
|
"art12"))
|
||||||
"acmart10")
|
|
||||||
(TeX-add-symbols
|
|
||||||
'("footnotetextcopyrightpermission" 1)))
|
|
||||||
:latex)
|
:latex)
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user