mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
158 Commits
first-pric
...
enriching-
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| e22286371f | |||
| e44feb7da0 | |||
| ebd2378859 | |||
| c4d82b2ecc | |||
| a9e2e7cbf3 | |||
| e0b074161b | |||
| 08c0afb55a | |||
| c4fd1352c9 | |||
| 4abef97bf7 | |||
| 33cb0d7e95 | |||
| e8ef850089 | |||
| e7cb48e9cd | |||
|
|
dba8f3fafa | ||
|
|
9843c5deab | ||
| 13959e4b28 | |||
|
|
2f481bd94b | ||
| 72877439ca | |||
| 0f5f8affab | |||
| ee70f02a1f | |||
| 22a2c255bd | |||
| ccc19f3493 | |||
| 00e3eff2fa | |||
| 440371dba4 | |||
| b05b510f70 | |||
| 04907df393 | |||
| b2f0746c01 | |||
| 7b2d80ac4c | |||
| 0ce12fbc3b | |||
| e9cf5f0736 | |||
| 82b54428b7 | |||
| 87a35fad2c | |||
| af23d2f736 | |||
| 9cb2b0fc44 | |||
| 7c330a19c6 | |||
|
|
eb95060380 | ||
| 61dd621532 | |||
| 4c368d48f2 | |||
| 3c141a4b6c | |||
| e89cb263d4 | |||
| 62a4008c29 | |||
| 8b429b7a8e | |||
| f9bf3de71e | |||
| 131323ef56 | |||
| ec4cf074e6 | |||
| 6a06a8af4a | |||
| 3fa98f375d | |||
| 201c98bcac | |||
| 8a08458478 | |||
| 7d09232e48 | |||
| 20132c084c | |||
| 26abff5864 | |||
| 4c7d9362af | |||
| ea45801845 | |||
|
|
574e05d9e0 | ||
| 52fe865598 | |||
| 28d3f6853e | |||
| 10e8397eec | |||
| 772772b5b9 | |||
| 6e06081d60 | |||
| 83d9bb2552 | |||
| fa2aca8b13 | |||
| cd6c3d6006 | |||
|
|
b5f19e04b7 | ||
| 98a9a3738c | |||
| 1224841a82 | |||
| 4033e73ba1 | |||
| bae51daa1c | |||
| c5eae17924 | |||
| 28669ea4c3 | |||
| b0a1647956 | |||
| 19bb4fd517 | |||
| 4e2e41d943 | |||
| a033e77697 | |||
| 40e0b201e6 | |||
| a217d53556 | |||
| a6e6cc5d60 | |||
| fa89347c4e | |||
| 2b3d937be6 | |||
| 20c47fe85f | |||
| b7161573d7 | |||
| c15bb1882e | |||
| dee6f573e3 | |||
| 2ed200f870 | |||
| 56308ecb10 | |||
| 7fcd18c3cb | |||
| 5f607a58eb | |||
| 6aad196234 | |||
| e5060babfa | |||
| 80863e9b17 | |||
| a5029f2eab | |||
| c102ac482e | |||
| 08ade8dc89 | |||
| 95d4f0cee2 | |||
|
|
a9d73ccce5 | ||
| 3072e5f46e | |||
| a1e3166322 | |||
| 6f361b96a8 | |||
| eea019ab3f | |||
| a36973cb42 | |||
| 96180e9af1 | |||
|
|
e60c0c64e1 | ||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d | |||
|
|
221e71a503 | ||
|
|
f2271e368e | ||
|
|
a1916c966c | ||
|
|
a2a443c027 | ||
|
|
ef98141ca8 | ||
| d45b344264 | |||
| a0b956b242 | |||
|
|
8751583764 | ||
| 59d4fb7891 | |||
| 7c2a819122 | |||
| 5941ffd085 | |||
| 955102090d | |||
| d654bbf4b4 | |||
|
|
ad9423bf59 | ||
|
|
2a0e44ab24 | ||
|
|
c432c45343 |
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
|
||||||
|
|||||||
25
.gitignore
vendored
25
.gitignore
vendored
@@ -5,4 +5,29 @@
|
|||||||
**/.virtual_documents/
|
**/.virtual_documents/
|
||||||
**/session_*.svg
|
**/session_*.svg
|
||||||
**/*graph.svg
|
**/*graph.svg
|
||||||
|
**/auto/*.el
|
||||||
|
*.old
|
||||||
|
**/package-lock.json
|
||||||
|
**/*.parquet
|
||||||
|
**/_build/
|
||||||
|
|
||||||
paper/src/bib/auto
|
paper/src/bib/auto
|
||||||
|
**/_build/
|
||||||
|
paper/src/auto/*
|
||||||
|
paper/src/bib/auto
|
||||||
|
paper/template/*
|
||||||
|
docs/goals/*.md
|
||||||
|
PHANTOM.wiki/
|
||||||
|
experiments/airflow/logs/*
|
||||||
|
experiments/airflow/logs/scheduler/
|
||||||
|
experiments/airflow/logs/dag_processor_manager/
|
||||||
|
experiments/collected_data/
|
||||||
|
experiments/agents/collected_data/
|
||||||
|
sim/rl/behavior_loader/*.dot
|
||||||
|
sim/rl/behavior_loader/*.png
|
||||||
|
sim/rl/behavior_loader/*.svg
|
||||||
|
sim/rl/behavior_loader/*.pdf
|
||||||
|
tests/e2e/node_modules/**
|
||||||
|
lab/case/thesis/runs*/
|
||||||
|
sim/case/thesis_simplified/runs*/
|
||||||
|
PHANTOM_web/*
|
||||||
|
|||||||
163
Makefile
163
Makefile
@@ -9,44 +9,173 @@ 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
|
||||||
|
|
||||||
|
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
all: pdf
|
.PHONY: help
|
||||||
|
help:
|
||||||
run.webapp:
|
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | stats.lines"
|
||||||
@cd web && npm install && npm run dev
|
@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)
|
||||||
|
|
||||||
pdf: $(BUILDDIR)
|
.PHONY: pdf.build
|
||||||
@echo "Concatenating source code..."
|
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 \
|
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
|
||||||
|
|
||||||
watch: $(BUILDDIR)
|
|
||||||
@cd $(SRCDIR) && \
|
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
clean:
|
.PHONY: pdf.watch
|
||||||
|
pdf.watch: $(BUILDDIR)
|
||||||
|
@cd $(SRCDIR) && \
|
||||||
|
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||||
|
-interaction=nonstopmode -file-line-error \
|
||||||
|
-r ../.latexmkrc \
|
||||||
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
|
.PHONY: pdf.clean
|
||||||
|
pdf.clean:
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
rm -rf paper/$(BUILDDIR)/*
|
||||||
|
|
||||||
|
.PHONY: test.backend
|
||||||
|
test.backend: $(VENV)
|
||||||
|
$(PYTEST) -v
|
||||||
|
|
||||||
|
.PHONY: test.e2e
|
||||||
|
test.e2e:
|
||||||
|
@cd tests/e2e && npm install
|
||||||
|
@cd tests/e2e && npx playwright install chromium
|
||||||
|
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
|
||||||
|
@cd tests/e2e && npm test
|
||||||
|
|
||||||
|
.PHONY: test.all
|
||||||
|
test.all: test.backend test.e2e
|
||||||
|
|
||||||
|
.PHONY: web.dev
|
||||||
|
web.dev:
|
||||||
|
@cd web && npm install && npm run dev
|
||||||
|
|
||||||
$(VENV):
|
$(VENV):
|
||||||
python3 -m venv $(VENV)
|
python3 -m venv $(VENV)
|
||||||
$(PIP) install --upgrade pip
|
$(PIP) install --upgrade pip
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
install: $(VENV)
|
install: $(VENV)
|
||||||
$(PIP) install -r requirements.txt
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
test: $(VENV)
|
.PHONY: train
|
||||||
$(PYTEST) -v
|
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: all pdf clean watch run.webapp install test
|
.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
|
||||||
|
stats.lines:
|
||||||
|
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
||||||
|
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||||
|
|
||||||
|
.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=phantom-trc --worker=all
|
||||||
|
@$(SWEEP_ENV_LOAD); \
|
||||||
|
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
||||||
|
--zone=$(TPU_ZONE) --project=phantom-trc --worker=all \
|
||||||
|
--command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh"
|
||||||
|
|
||||||
|
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||||
|
pdf: pdf.build
|
||||||
|
clean: pdf.clean
|
||||||
|
watch: pdf.watch
|
||||||
|
run.webapp: web.dev
|
||||||
|
test: test.backend
|
||||||
|
count-lines: stats.lines
|
||||||
|
all: pdf.build
|
||||||
|
|||||||
93
README.md
93
README.md
@@ -1,5 +1,94 @@
|
|||||||
|
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||||
|
|
||||||
|
### PHANTOM
|
||||||
|
|
||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
|
[](https://sites.research.google/trc/faq/)
|
||||||
|
[](https://phantom-hotel.vercel.app)
|
||||||
|
[](https://phantom-airline.vercel.app)
|
||||||
|
|
||||||
- https://phantom-hotel.vercel.app/
|
|
||||||
- https://phantom-airline.vercel.app/
|
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
mindmap
|
||||||
|
PHANTOM((PHANTOM Project))
|
||||||
|
North Star
|
||||||
|
Study how automated actors change markets
|
||||||
|
Build an experimentation platform for real-world-like commerce
|
||||||
|
Two-loop learning system
|
||||||
|
Online observation loop
|
||||||
|
Offline "defense gym" loop
|
||||||
|
Core Economic Questions
|
||||||
|
Price Discovery
|
||||||
|
How prices respond to demand signals
|
||||||
|
How signal quality changes with bots/agents
|
||||||
|
Demand & Elasticity
|
||||||
|
Shifts in willingness-to-pay
|
||||||
|
Short-run vs long-run elasticity
|
||||||
|
Market Efficiency & Welfare
|
||||||
|
Consumer surplus vs producer surplus
|
||||||
|
Deadweight loss from frictions/manipulation
|
||||||
|
Price Discrimination & Segmentation
|
||||||
|
Behavioral feature-based segmentation
|
||||||
|
Fairness vs profitability tradeoffs
|
||||||
|
Information Asymmetry
|
||||||
|
Agents amplify search and arbitrage
|
||||||
|
Sellers infer more about buyers; buyers infer more about sellers
|
||||||
|
Strategic Interaction
|
||||||
|
Consumers vs firms vs agents
|
||||||
|
Feedback loops: policy ↔ behavior ↔ price
|
||||||
|
Market Power & Competition
|
||||||
|
Algorithmic pricing as competitive tool
|
||||||
|
Risks: tacit coordination / "algorithmic collusion"
|
||||||
|
Externalities
|
||||||
|
Congestion and attention costs
|
||||||
|
Spillovers: one segment’s behavior affects others’ prices
|
||||||
|
System-Level View
|
||||||
|
Participants
|
||||||
|
Humans
|
||||||
|
Agents (automated buyers/actors)
|
||||||
|
Firms (pricing decision-makers)
|
||||||
|
Platform (measurement + control layer)
|
||||||
|
Markets Simulated
|
||||||
|
Repeated transactions
|
||||||
|
Limited inventory / capacity constraints (conceptually)
|
||||||
|
Time dynamics (learning over time)
|
||||||
|
Interventions
|
||||||
|
Pricing policies
|
||||||
|
Experiment assignment / randomized exposure
|
||||||
|
Agent behavioral policies (task-driven)
|
||||||
|
Measurement & Causal Inference
|
||||||
|
What is observed
|
||||||
|
Actions (search, click, purchase intent)
|
||||||
|
Context (product attributes, time, exposure)
|
||||||
|
Outcomes (conversion, revenue, churn proxies)
|
||||||
|
Identification strategy
|
||||||
|
A/B tests and randomization
|
||||||
|
Counterfactual baselines
|
||||||
|
Robustness checks (offline replay)
|
||||||
|
Key metrics
|
||||||
|
Revenue / profit proxies
|
||||||
|
Conversion & bounce
|
||||||
|
Price volatility / stability
|
||||||
|
Welfare proxies (e.g., dispersion, access)
|
||||||
|
Risk, Governance, and Ethics
|
||||||
|
Manipulation & Integrity
|
||||||
|
Bot-driven demand distortion
|
||||||
|
Measurement contamination
|
||||||
|
Fairness & Transparency
|
||||||
|
Differential pricing concerns
|
||||||
|
Explainability and auditability
|
||||||
|
Safety Constraints
|
||||||
|
Guardrails on price moves
|
||||||
|
Monitoring for runaway feedback loops
|
||||||
|
Outputs
|
||||||
|
Insights
|
||||||
|
When do agents raise/lower prices via behavior shifts?
|
||||||
|
Which market designs are robust to automation?
|
||||||
|
Defenses
|
||||||
|
Agent-aware pricing policies (robust control)
|
||||||
|
Detection + mitigation strategies (feature-level separability)
|
||||||
|
Platform Value
|
||||||
|
Reusable testbed for market + AI-agent research
|
||||||
|
```
|
||||||
|
|||||||
6
TPUS/README.md
Normal file
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
|
||||||
112
backend/provider/app.py
Normal file
112
backend/provider/app.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Literal, Optional
|
||||||
|
import uvicorn, os, sys
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Local imports of registry and pricing function
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../experiments/")
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
from procesing.steps import (
|
||||||
|
PredictPricesStep
|
||||||
|
)
|
||||||
|
from procesing import PipelineContext
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
# Config
|
||||||
|
app = FastAPI(title="PHANTOM Pricing Provider")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
||||||
|
registry = ModelRegistry()
|
||||||
|
|
||||||
|
class PriceResponse(BaseModel):
|
||||||
|
productId: str
|
||||||
|
price: float
|
||||||
|
base_price: float
|
||||||
|
markup: float
|
||||||
|
elasticity: Optional[float] = None
|
||||||
|
model_version: str = 'latest'
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "healthy", "redis": registry.health_check()}
|
||||||
|
|
||||||
|
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||||
|
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||||
|
"""
|
||||||
|
THIS is the fast lookup service (mechanism).
|
||||||
|
Priority: session-keyed price > global optimal price > base price
|
||||||
|
"""
|
||||||
|
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||||
|
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||||
|
|
||||||
|
metadata = product['metadata']
|
||||||
|
base_price = metadata.get('base_price', 100.0)
|
||||||
|
|
||||||
|
# PRIORITY 1: session-aware price (computed by Airflow worker)
|
||||||
|
if sessionId:
|
||||||
|
session_price = registry.get_session_price(sessionId, productId)
|
||||||
|
if session_price is not None:
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=session_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=session_price/base_price,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='session-aware'
|
||||||
|
)
|
||||||
|
|
||||||
|
# PRIORITY 2: global pre-computed prices (surge pricing)
|
||||||
|
prices_df = registry.get_prices('latest')
|
||||||
|
if prices_df is not None:
|
||||||
|
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||||
|
if not product_price_row.empty:
|
||||||
|
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=optimal_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=optimal_price/base_price,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='surge'
|
||||||
|
)
|
||||||
|
|
||||||
|
# PRIORITY 3: fallback to base price
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=base_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=1.0,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='base'
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/models")
|
||||||
|
def list_models(): return registry.list_models()
|
||||||
|
|
||||||
|
@app.post("/models/reload")
|
||||||
|
def reload_models():
|
||||||
|
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
|
||||||
|
return {
|
||||||
|
"elasticity_loaded": bool(elasticity),
|
||||||
|
"n_products": len(elasticity) if elasticity is not None else 0,
|
||||||
|
"pricing_model_loaded": bool(pricing_model),
|
||||||
|
"model_class": pricing_model.__class__.__name__ if pricing_model else None
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))
|
||||||
16
backend/provider/requirements.txt
Normal file
16
backend/provider/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
pydantic
|
||||||
|
numpy
|
||||||
|
pandas
|
||||||
|
scikit-learn
|
||||||
|
redis
|
||||||
|
supabase
|
||||||
|
confluent-kafka>=2.3.0
|
||||||
|
kafka-python
|
||||||
|
graphviz
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
typing-extensions>=4.8.0
|
||||||
|
pypickle
|
||||||
|
pymc
|
||||||
@@ -198,12 +198,16 @@ def dump_logs(
|
|||||||
auto_offset_reset='earliest',
|
auto_offset_reset='earliest',
|
||||||
enable_auto_commit=False,
|
enable_auto_commit=False,
|
||||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||||
consumer_timeout_ms=5000
|
consumer_timeout_ms=30000,
|
||||||
|
fetch_max_wait_ms=10000,
|
||||||
|
max_poll_records=1000
|
||||||
)
|
)
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for msg in consumer:
|
for msg in consumer:
|
||||||
events.append(msg.value)
|
events.append(msg.value)
|
||||||
|
if last_n and len(events) >= last_n * 2:
|
||||||
|
break
|
||||||
|
|
||||||
consumer.close()
|
consumer.close()
|
||||||
|
|
||||||
@@ -290,6 +294,7 @@ async def get_products(
|
|||||||
query = supabase.table(table).select('*')
|
query = supabase.table(table).select('*')
|
||||||
|
|
||||||
# filter by exact date_index if provided
|
# filter by exact date_index if provided
|
||||||
|
# dateIndex from frontend is days from today, convert to days since epoch
|
||||||
if dateIndex is not None:
|
if dateIndex is not None:
|
||||||
query = query.eq('date_index', dateIndex)
|
query = query.eq('date_index', dateIndex)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,24 @@
|
|||||||
services:
|
services:
|
||||||
|
tensorboard-rl:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
container_name: "PHANTOM-tensorboard-rl"
|
||||||
|
ports:
|
||||||
|
- "6007:6006"
|
||||||
|
volumes:
|
||||||
|
- ./sim/rl/runs:/logs
|
||||||
|
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
tensorboard-ml:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
container_name: "PHANTOM-tensorboard-ml"
|
||||||
|
ports:
|
||||||
|
- "6006:6006"
|
||||||
|
volumes:
|
||||||
|
- ./experiments/ml/runs:/logs
|
||||||
|
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
container_name: "PHANTOM-backend"
|
container_name: "PHANTOM-backend"
|
||||||
build:
|
build:
|
||||||
@@ -71,6 +91,149 @@ services:
|
|||||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
container_name: "PHANTOM-postgres"
|
||||||
|
image: postgres:13
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=airflow
|
||||||
|
- POSTGRES_PASSWORD=airflow
|
||||||
|
- POSTGRES_DB=airflow
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
airflow-init:
|
||||||
|
container_name: "PHANTOM-airflow-init"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- _AIRFLOW_DB_MIGRATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_CREATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||||
|
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: version
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
container_name: "PHANTOM-airflow-webserver"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- airflow-init
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||||
|
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||||
|
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
ports:
|
||||||
|
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||||
|
command: webserver
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
airflow-scheduler:
|
||||||
|
container_name: "PHANTOM-airflow-scheduler"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
airflow-webserver:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||||
|
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||||
|
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
pricing-provider:
|
||||||
|
container_name: "PHANTOM-pricing-provider"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Provider.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- kafka
|
||||||
|
environment:
|
||||||
|
- PROVIDER_PORT=5001
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- BACKEND_URL=http://localhost:5000
|
||||||
|
ports:
|
||||||
|
- "${PROVIDER_PORT:-5001}:5001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
phantom_kafka_data:
|
phantom_kafka_data:
|
||||||
phantom_redis_data:
|
phantom_redis_data:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
30
docker/Airflow.dockerfile
Normal file
30
docker/Airflow.dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM apache/airflow:2.7.3-python3.11
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# install system deps if needed
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
# copy requirements for pipeline dependencies
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
# install postgres driver and providers
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
apache-airflow-providers-postgres
|
||||||
|
|
||||||
|
# set airflow home
|
||||||
|
ENV AIRFLOW_HOME=/opt/airflow
|
||||||
|
|
||||||
|
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||||
|
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||||
|
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||||
|
|
||||||
|
# create logs and plugins dirs (airflow expects them)
|
||||||
|
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||||
41
docker/Airflow.railway.dockerfile
Normal file
41
docker/Airflow.railway.dockerfile
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
FROM apache/airflow:2.7.3-python3.11
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
supervisor \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
apache-airflow-providers-postgres
|
||||||
|
|
||||||
|
ENV AIRFLOW_HOME=/opt/airflow
|
||||||
|
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||||
|
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||||
|
|
||||||
|
# copy all code into image (standalone - no volume mounts needed)
|
||||||
|
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||||
|
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||||
|
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||||
|
|
||||||
|
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||||
|
|
||||||
|
# copy entrypoint script
|
||||||
|
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||||
|
USER root
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
26
docker/Provider.dockerfile
Normal file
26
docker/Provider.dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies including graphviz
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
graphviz \
|
||||||
|
libgraphviz-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy and install Python dependencies
|
||||||
|
COPY backend/provider/requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code into image
|
||||||
|
COPY lib/ /app/lib/
|
||||||
|
COPY experiments/procesing/ /app/procesing/
|
||||||
|
COPY backend/provider/ /app/provider/
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||||
|
|
||||||
|
WORKDIR /app/provider
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||||
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"]
|
||||||
20
docker/airflow-railway-entrypoint.sh
Normal file
20
docker/airflow-railway-entrypoint.sh
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# init db and create admin user on first run
|
||||||
|
airflow db migrate
|
||||||
|
|
||||||
|
# create admin user if not exists
|
||||||
|
airflow users create \
|
||||||
|
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||||
|
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||||
|
--firstname Admin \
|
||||||
|
--lastname User \
|
||||||
|
--role Admin \
|
||||||
|
--email admin@example.com || true
|
||||||
|
|
||||||
|
# start scheduler in background
|
||||||
|
airflow scheduler &
|
||||||
|
|
||||||
|
# start webserver in foreground (Railway needs one foreground process)
|
||||||
|
exec airflow webserver --port ${PORT:-8080}
|
||||||
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>
|
||||||
|
|||||||
97
engine/engine.py
Normal file
97
engine/engine.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
from sys import platform
|
||||||
|
import numpy as np
|
||||||
|
from .lib.demand import generate_demand_for_actor, estimate_demand
|
||||||
|
from .lib.behavior import sample_behavior
|
||||||
|
from logging import INFO, getLogger
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
logger.setLevel(INFO)
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEngine:
|
||||||
|
"""implements separate demand distributions for humans and agents per Section 3.1.1"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
alpha: float,
|
||||||
|
N: int,
|
||||||
|
human_params: tuple,
|
||||||
|
agent_params: tuple,
|
||||||
|
demand_distribution=np.random.normal,
|
||||||
|
noise_std: float = 1.0,
|
||||||
|
action_weights: dict | None = None,
|
||||||
|
):
|
||||||
|
# no defaults for D_H, D_A - force explicit experiment design
|
||||||
|
self.alpha = alpha
|
||||||
|
self.N = int(N)
|
||||||
|
self.Nagents = int(N * alpha)
|
||||||
|
self.Nhumans = int(N * (1 - alpha))
|
||||||
|
self.human_params = human_params
|
||||||
|
self.agent_params = agent_params
|
||||||
|
self.noise_std = noise_std
|
||||||
|
self.demand_dist = demand_distribution
|
||||||
|
self.action_weights = action_weights
|
||||||
|
|
||||||
|
def act(self, prices):
|
||||||
|
# generate separate demands d() per actor type
|
||||||
|
demand_h = generate_demand_for_actor(
|
||||||
|
prices,
|
||||||
|
self.human_params,
|
||||||
|
self.noise_std,
|
||||||
|
distribution_method=self.demand_dist,
|
||||||
|
)
|
||||||
|
demand_a = generate_demand_for_actor(
|
||||||
|
prices,
|
||||||
|
self.agent_params,
|
||||||
|
self.noise_std,
|
||||||
|
distribution_method=self.demand_dist,
|
||||||
|
)
|
||||||
|
# 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):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PricingEngine:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def act(self, demand):
|
||||||
|
return np.random.uniform(low=25, high=100, size=10)
|
||||||
|
|
||||||
|
|
||||||
|
class Limbo:
|
||||||
|
def __init__(self, platform, market) -> None:
|
||||||
|
self.platform_turn = True
|
||||||
|
self.platform = platform
|
||||||
|
self.market = market
|
||||||
|
self.output = None
|
||||||
|
|
||||||
|
def step(self):
|
||||||
|
if self.platform_turn:
|
||||||
|
self.output = self.platform.act(self.output)
|
||||||
|
else:
|
||||||
|
self.output = self.market.act(self.output)
|
||||||
|
self.platform_turn = not self.platform_turn
|
||||||
|
return self.output
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.platform_turn = True
|
||||||
|
self.output = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
platform = PricingEngine()
|
||||||
|
market = MarketEngine(
|
||||||
|
alpha=0.3, N=100, human_params=(50, 10), agent_params=(45, 15)
|
||||||
|
)
|
||||||
|
limbo = Limbo(platform, market)
|
||||||
|
for _ in range(10):
|
||||||
|
limbo.step()
|
||||||
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
|
||||||
1304
engine/jax/train.py
Normal file
1304
engine/jax/train.py
Normal file
File diff suppressed because it is too large
Load Diff
14
engine/lib/__init__.py
Normal file
14
engine/lib/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from .demand import estimate_demand, estimate_weighted_demand, generate_demand_for_actor
|
||||||
|
from .behavior import sample_behavior, get_transition_models, trajectory_to_events
|
||||||
|
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
|
||||||
134
engine/lib/behavior.py
Normal file
134
engine/lib/behavior.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from sim.rl.behavior_loader.models import (
|
||||||
|
BehaviorModel,
|
||||||
|
AgentBehaviorModel,
|
||||||
|
aggregate_event_transitions,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
BehaviorModel = None
|
||||||
|
AgentBehaviorModel = None
|
||||||
|
aggregate_event_transitions = None
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from .demand import generate_demand_for_actor
|
||||||
|
|
||||||
|
base_dir = Path(__file__).parents[2] / "experiments"
|
||||||
|
human_dir = str(base_dir / "collected_data")
|
||||||
|
agent_dir = str(base_dir / "agents" / "collected_data")
|
||||||
|
|
||||||
|
_cache = {} # lazy cache for models and base pivots
|
||||||
|
|
||||||
|
|
||||||
|
def _get_base_pivot(human: bool):
|
||||||
|
if (
|
||||||
|
BehaviorModel is None
|
||||||
|
or AgentBehaviorModel is None
|
||||||
|
or aggregate_event_transitions is None
|
||||||
|
):
|
||||||
|
raise ImportError("behavior loader dependencies are unavailable")
|
||||||
|
key = "human" if human else "agent"
|
||||||
|
if key not in _cache:
|
||||||
|
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
|
||||||
|
mdp = model.build_MDP()
|
||||||
|
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
|
||||||
|
return _cache[key]
|
||||||
|
|
||||||
|
|
||||||
|
def get_transition_models():
|
||||||
|
"""load human and agent transition models for agent probability calculation
|
||||||
|
|
||||||
|
returns:
|
||||||
|
tuple: (human_transitions, agent_transitions) as dicts of event->event->prob
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
BehaviorModel is None
|
||||||
|
or AgentBehaviorModel is None
|
||||||
|
or aggregate_event_transitions is None
|
||||||
|
):
|
||||||
|
raise ImportError("behavior loader dependencies are unavailable")
|
||||||
|
|
||||||
|
human_model = BehaviorModel(human_dir)
|
||||||
|
agent_model = AgentBehaviorModel(agent_dir)
|
||||||
|
|
||||||
|
human_mdp = human_model.build_MDP()
|
||||||
|
agent_mdp = agent_model.build_MDP()
|
||||||
|
|
||||||
|
human_trans = aggregate_event_transitions(human_mdp)
|
||||||
|
agent_trans = aggregate_event_transitions(agent_mdp)
|
||||||
|
|
||||||
|
return human_trans, agent_trans
|
||||||
|
|
||||||
|
|
||||||
|
def trajectory_to_events(trajectory: list) -> list:
|
||||||
|
"""extract event names from trajectory for KL divergence calculation
|
||||||
|
|
||||||
|
trajectories are in format 'eventName_product0', extract just eventName
|
||||||
|
|
||||||
|
args:
|
||||||
|
trajectory: list like ['view_product0', 'add_to_cart_product1', 'checkout_product1']
|
||||||
|
|
||||||
|
returns:
|
||||||
|
list: event names like ['view', 'add_to_cart', 'checkout']
|
||||||
|
"""
|
||||||
|
events = []
|
||||||
|
for state in trajectory:
|
||||||
|
# state format from sample_behavior: 'eventName_productX'
|
||||||
|
if "_product" in state:
|
||||||
|
event = state.rsplit("_product", 1)[0]
|
||||||
|
else:
|
||||||
|
event = state
|
||||||
|
events.append(event)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def adjust_behavior_to_condition(condition, transition_matrix):
|
||||||
|
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
|
||||||
|
condition = np.asarray(condition, dtype=float)
|
||||||
|
condition = np.nan_to_num(condition, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
condition = np.clip(condition, 0.0, None)
|
||||||
|
s = float(np.sum(condition))
|
||||||
|
if not np.isfinite(s) or s <= 0:
|
||||||
|
cond_norm = np.full(len(condition), 1.0 / max(len(condition), 1), dtype=float)
|
||||||
|
else:
|
||||||
|
cond_norm = condition / s
|
||||||
|
n_products = len(condition)
|
||||||
|
base_vals = transition_matrix.values
|
||||||
|
base_cols, base_rows = (
|
||||||
|
transition_matrix.columns.tolist(),
|
||||||
|
transition_matrix.index.tolist(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
|
||||||
|
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
||||||
|
new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)]
|
||||||
|
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
||||||
|
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_behavior(condition, human=True, max_len=40):
|
||||||
|
base_pivot = _get_base_pivot(human)
|
||||||
|
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
|
||||||
|
|
||||||
|
trajectory = [np.random.choice(adjusted_transitions.index)]
|
||||||
|
while len(trajectory) < max_len and "checkout" not in trajectory[-1]:
|
||||||
|
probs = np.asarray(adjusted_transitions.loc[trajectory[-1]].values, dtype=float)
|
||||||
|
probs = np.nan_to_num(probs, nan=0.0, posinf=0.0, neginf=0.0)
|
||||||
|
probs = np.clip(probs, 0.0, None)
|
||||||
|
s = float(np.sum(probs))
|
||||||
|
sample = np.random.choice(
|
||||||
|
adjusted_transitions.columns, p=(probs / s) if s > 0 else None
|
||||||
|
)
|
||||||
|
trajectory.append(sample)
|
||||||
|
return trajectory
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=True)
|
||||||
|
print(t)
|
||||||
|
t = sample_behavior(generate_demand_for_actor(np.array([10, 20, 30])), human=False)
|
||||||
|
print(t)
|
||||||
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())
|
||||||
|
)
|
||||||
92
engine/lib/demand.py
Normal file
92
engine/lib/demand.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
||||||
|
ACTION_CATEGORIES = {
|
||||||
|
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
||||||
|
"dwell": {"hover_title", "hover_paragraph", "hover_link"},
|
||||||
|
"nav": {"page_view", "view_item", "view", "learn_more"},
|
||||||
|
"filter": {"search", "filter_date", "filter_price", "sort"},
|
||||||
|
}
|
||||||
|
DEFAULT_ACTION_WEIGHTS = {
|
||||||
|
a: CATEGORY_WEIGHTS[c] for c, actions in ACTION_CATEGORIES.items() for a in actions
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_demand_for_actor(
|
||||||
|
prices: np.ndarray,
|
||||||
|
params: tuple,
|
||||||
|
noise_std: float = 1.0,
|
||||||
|
distribution_method=np.random.normal,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""d(p;0) = max(0, valuation - price) + epsi for single actor type
|
||||||
|
params: (mean, std) for valuation distribution D_H or D_A"""
|
||||||
|
val = distribution_method(*params, size=len(prices))
|
||||||
|
noise = distribution_method(0, noise_std, len(prices))
|
||||||
|
demand = np.maximum(0, val - prices + noise)
|
||||||
|
total = np.sum(demand)
|
||||||
|
return demand / total * 100 if total > 0 else demand
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_demand(trajectories, action_weights=None):
|
||||||
|
return estimate_weighted_demand(trajectories, action_weights)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_event_state(state: str):
|
||||||
|
if "_product" not in state:
|
||||||
|
return state, None
|
||||||
|
action, raw_pid = state.rsplit("_product", 1)
|
||||||
|
return action, int(raw_pid) if raw_pid.isdigit() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _weight_for_action(action: str, action_weights: dict) -> float:
|
||||||
|
if action in action_weights:
|
||||||
|
return action_weights[action]
|
||||||
|
if action.startswith("hover"):
|
||||||
|
return CATEGORY_WEIGHTS["dwell"]
|
||||||
|
if action.startswith("filter") or action in {"search", "sort"}:
|
||||||
|
return CATEGORY_WEIGHTS["filter"]
|
||||||
|
if action.startswith("add") or action in {"checkout", "purchase", "remove"}:
|
||||||
|
return CATEGORY_WEIGHTS["cart"]
|
||||||
|
return CATEGORY_WEIGHTS["nav"]
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_weighted_demand(trajectories, action_weights=None):
|
||||||
|
action_weights = (
|
||||||
|
DEFAULT_ACTION_WEIGHTS if action_weights is None else action_weights
|
||||||
|
)
|
||||||
|
scores = {}
|
||||||
|
for traj in trajectories:
|
||||||
|
for state in traj:
|
||||||
|
action, product_id = _parse_event_state(state)
|
||||||
|
if product_id is None:
|
||||||
|
continue
|
||||||
|
w = _weight_for_action(action, action_weights)
|
||||||
|
if w <= 0:
|
||||||
|
continue
|
||||||
|
scores[product_id] = scores.get(product_id, 0.0) + w
|
||||||
|
total = sum(scores.values())
|
||||||
|
return (
|
||||||
|
{pid: (score / total) * 100 for pid, score in scores.items()}
|
||||||
|
if total > 0
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
if __name__ == "__main__":
|
||||||
|
np.random.seed(42)
|
||||||
|
prices = np.array([20.0, 35.0, 50.0, 65.0])
|
||||||
|
# demo actor-specific demands
|
||||||
|
human_params, agent_params = (50, 10), (45, 15)
|
||||||
|
demand_h = generate_demand_for_actor(prices, human_params)
|
||||||
|
demand_a = generate_demand_for_actor(prices, agent_params)
|
||||||
|
print("Human Demand:", demand_h)
|
||||||
|
print("Agent Demand:", demand_a)
|
||||||
|
from .behavior import sample_behavior
|
||||||
|
|
||||||
|
N, alpha = 200, 0.3
|
||||||
|
n_h, n_a = int(N * (1 - alpha)), int(N * alpha)
|
||||||
|
human_t = [sample_behavior(demand_h, human=True) for _ in range(n_h)]
|
||||||
|
agent_t = [sample_behavior(demand_a, human=False) for _ in range(n_a)]
|
||||||
|
demand_estimate = estimate_demand(human_t + agent_t)
|
||||||
|
print("Estimated Demand from Behavior:", demand_estimate)
|
||||||
70
engine/lib/discrete.py
Normal file
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",
|
||||||
|
)
|
||||||
126
engine/lib/render.py
Normal file
126
engine/lib/render.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""rendering logic for PHANTOM environment dashboard"""
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.gridspec import GridSpec
|
||||||
|
|
||||||
|
|
||||||
|
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
|
||||||
|
ax.spines['top'].set_visible(False)
|
||||||
|
ax.spines['right'].set_visible(False)
|
||||||
|
if title: ax.set_title(title, fontsize=11, fontweight='bold', pad=8)
|
||||||
|
if xlabel: ax.set_xlabel(xlabel, fontsize=9)
|
||||||
|
if ylabel: ax.set_ylabel(ylabel, fontsize=9)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardRenderer:
|
||||||
|
"""stateful renderer for PHANTOM market dynamics visualization"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.fig = None
|
||||||
|
self.gs = None
|
||||||
|
|
||||||
|
def render(self, env) -> None:
|
||||||
|
if self.fig is None:
|
||||||
|
plt.ion()
|
||||||
|
self.fig = plt.figure(figsize=(14, 10))
|
||||||
|
self.gs = GridSpec(3, 3, figure=self.fig, hspace=0.35, wspace=0.3,
|
||||||
|
left=0.07, right=0.95, top=0.92, bottom=0.08)
|
||||||
|
plt.show(block=False)
|
||||||
|
|
||||||
|
self.fig.clear()
|
||||||
|
self.fig.suptitle(f'PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]',
|
||||||
|
fontsize=14, fontweight='bold')
|
||||||
|
|
||||||
|
demand_mat = np.array(env._demand_history).T
|
||||||
|
price_mat = np.array(env._price_history).T
|
||||||
|
elasticity = env._compute_elasticity()
|
||||||
|
|
||||||
|
self._render_scatter(env)
|
||||||
|
self._render_elasticity_bar(env, elasticity)
|
||||||
|
self._render_session_pie(env)
|
||||||
|
self._render_price_heatmap(price_mat)
|
||||||
|
self._render_demand_heatmap(demand_mat)
|
||||||
|
self._render_correlation(env.n_products, price_mat, demand_mat)
|
||||||
|
self._render_revenue(env)
|
||||||
|
|
||||||
|
self.fig.canvas.draw_idle()
|
||||||
|
self.fig.canvas.flush_events()
|
||||||
|
|
||||||
|
def _render_scatter(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 0])
|
||||||
|
prices_flat = np.array(env._price_history).flatten()
|
||||||
|
demands_flat = np.array(env._demand_history).flatten()
|
||||||
|
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
|
||||||
|
ax.scatter(prices_flat, demands_flat, c=product_ids, cmap='plasma', alpha=0.6, s=15, edgecolors='none')
|
||||||
|
if len(prices_flat) > 1:
|
||||||
|
z = np.polyfit(prices_flat, demands_flat, 1)
|
||||||
|
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
|
||||||
|
ax.plot(p_line, np.polyval(z, p_line), '--', lw=1.5, alpha=0.8)
|
||||||
|
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
|
||||||
|
|
||||||
|
def _render_elasticity_bar(self, env, elasticity):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 1])
|
||||||
|
ax.barh(range(env.n_products), elasticity, alpha=0.8)
|
||||||
|
ax.axvline(0, lw=0.8, alpha=0.5)
|
||||||
|
ax.axvline(-1, lw=1, ls='--', alpha=0.5)
|
||||||
|
ax.set_yticks(range(env.n_products))
|
||||||
|
ax.set_yticklabels([f'P{i}' for i in range(env.n_products)], fontsize=7)
|
||||||
|
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
|
||||||
|
|
||||||
|
def _render_session_pie(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 2])
|
||||||
|
n_h, n_a = env.market.Nhumans, env.market.Nagents
|
||||||
|
wedges, _ = ax.pie([n_h, n_a], startangle=90, wedgeprops={'linewidth': 2, 'edgecolor': 'white'})
|
||||||
|
ax.legend(wedges, [f'H ({n_h})', f'A ({n_a})'], loc='lower center', fontsize=8,
|
||||||
|
frameon=False, bbox_to_anchor=(0.5, -0.05))
|
||||||
|
ax.set_title("Session Mix", fontsize=11, fontweight='bold')
|
||||||
|
|
||||||
|
def _render_price_heatmap(self, price_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[1, :2])
|
||||||
|
im = ax.imshow(price_mat, aspect='auto', cmap='viridis', origin='lower')
|
||||||
|
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
|
||||||
|
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
||||||
|
cbar.set_label('$', fontsize=8)
|
||||||
|
|
||||||
|
def _render_demand_heatmap(self, demand_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[1, 2])
|
||||||
|
im = ax.imshow(demand_mat, aspect='auto', cmap='Blues', origin='lower')
|
||||||
|
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
||||||
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
|
|
||||||
|
def _render_correlation(self, n_products, price_mat, demand_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[2, 0])
|
||||||
|
if price_mat.shape[1] > 2:
|
||||||
|
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
|
||||||
|
im = ax.imshow(corr, cmap='RdBu', vmin=-1, vmax=1, aspect='auto')
|
||||||
|
ax.set_xticks(range(n_products))
|
||||||
|
ax.set_yticks(range(n_products))
|
||||||
|
ax.set_xticklabels([f'Q{i}' for i in range(n_products)], fontsize=6)
|
||||||
|
ax.set_yticklabels([f'P{i}' for i in range(n_products)], fontsize=6)
|
||||||
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
|
style_axis(ax, "Price-Demand Correlation", None, None)
|
||||||
|
|
||||||
|
def _render_revenue(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[2, 1:])
|
||||||
|
n_steps = len(env._revenue_history)
|
||||||
|
demand_std = [np.std(d) for d in env._demand_history]
|
||||||
|
ax.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
|
||||||
|
ax.plot(env._revenue_history, linewidth=2, label='Revenue')
|
||||||
|
ax.set_xlim(0, max(n_steps, 1))
|
||||||
|
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
|
||||||
|
|
||||||
|
ax2 = ax.twinx()
|
||||||
|
ax2.plot(range(n_steps), demand_std, linewidth=2, ls='-', alpha=0.9, label='sigma(Demand)')
|
||||||
|
d_min, d_max = min(demand_std), max(demand_std)
|
||||||
|
margin = (d_max - d_min) * 0.2 if d_max > d_min else 0.5
|
||||||
|
ax2.set_ylim(max(0, d_min - margin), d_max + margin)
|
||||||
|
ax2.set_ylabel('Demand sigma', fontsize=9)
|
||||||
|
|
||||||
|
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
|
||||||
|
ax.legend(loc='upper left', fontsize=7, frameon=False)
|
||||||
|
ax2.legend(loc='upper right', fontsize=7, frameon=False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fig:
|
||||||
|
plt.close(self.fig)
|
||||||
|
self.fig = None
|
||||||
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]))
|
||||||
34
engine/studies/factors.py
Normal file
34
engine/studies/factors.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""shared factor definitions for experimental designs"""
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Factor:
|
||||||
|
name: str
|
||||||
|
levels: list
|
||||||
|
primary: bool = True # full cross vs sampled
|
||||||
|
|
||||||
|
# demand functions with compatible signatures
|
||||||
|
def demand_linear(mu, sigma, size): return np.maximum(0, np.random.normal(mu, sigma, size))
|
||||||
|
def demand_uniform(mu, sigma, size): return np.random.uniform(mu - sigma, mu + sigma, size)
|
||||||
|
def demand_exponential(mu, sigma, size): return np.random.exponential(mu, size)
|
||||||
|
def demand_logistic(mu, sigma, size): return np.random.logistic(mu, sigma, size)
|
||||||
|
|
||||||
|
DEMAND_FUNCTIONS = {
|
||||||
|
"linear": demand_linear,
|
||||||
|
"uniform": demand_uniform,
|
||||||
|
"exponential": demand_exponential,
|
||||||
|
"logistic": demand_logistic,
|
||||||
|
}
|
||||||
|
|
||||||
|
FACTORS = [
|
||||||
|
Factor("demand_fn", list(DEMAND_FUNCTIONS.keys()), primary=True),
|
||||||
|
Factor("alpha", [0.1, 0.3, 0.5, 0.7], primary=True),
|
||||||
|
Factor("n_products", [5, 15, 30, 50], primary=True),
|
||||||
|
Factor("demand_mu", [30.0, 50.0, 70.0], primary=False),
|
||||||
|
Factor("demand_sigma", [5.0, 10.0, 20.0], primary=False),
|
||||||
|
Factor("N", [100, 500, 1000], primary=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEEDS_PER_CONFIG = 5
|
||||||
89
engine/studies/full_factorial.py
Normal file
89
engine/studies/full_factorial.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""full factorial design - all factor combinations"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "..")
|
||||||
|
import logging
|
||||||
|
from itertools import product
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def generate_configs():
|
||||||
|
"""generate all factor combinations with seeds"""
|
||||||
|
all_levels = [f.levels for f in FACTORS]
|
||||||
|
names = [f.name for f in FACTORS]
|
||||||
|
|
||||||
|
configs = []
|
||||||
|
for combo in product(*all_levels):
|
||||||
|
base = {names[i]: combo[i] for i in range(len(names))}
|
||||||
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
|
cfg = {**base, "seed": seed}
|
||||||
|
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
||||||
|
configs.append(cfg)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
def run_single(cfg: dict) -> dict:
|
||||||
|
"""execute one experiment config, return metrics"""
|
||||||
|
from engine.wrapper import PHANTOM
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
np.random.seed(cfg["seed"])
|
||||||
|
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=cfg["n_products"],
|
||||||
|
alpha=cfg["alpha"],
|
||||||
|
N=cfg["N"],
|
||||||
|
)
|
||||||
|
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||||
|
|
||||||
|
obs, _ = env.reset()
|
||||||
|
total_reward, steps = 0.0, 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
|
total_reward += reward
|
||||||
|
steps += 1
|
||||||
|
if term: break
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
return {
|
||||||
|
"id": cfg["id"],
|
||||||
|
"config": cfg,
|
||||||
|
"total_reward": total_reward,
|
||||||
|
"avg_reward": total_reward / steps if steps > 0 else 0.0,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
|
||||||
|
configs = generate_configs()
|
||||||
|
log.info(f"full factorial: {len(configs)} configs ({len(configs)//SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
|
results.append(result)
|
||||||
|
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
||||||
|
|
||||||
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--workers", type=int, default=None)
|
||||||
|
p.add_argument("--output", default="results_full.jsonl")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
configs = generate_configs()
|
||||||
|
log.info(f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}")
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
run_study(args.workers, args.output)
|
||||||
106
engine/studies/mixed_lh.py
Normal file
106
engine/studies/mixed_lh.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "..")
|
||||||
|
import logging
|
||||||
|
from itertools import product
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
import numpy as np
|
||||||
|
from scipy.stats.qmc import LatinHypercube
|
||||||
|
from factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LH_SAMPLES = 10
|
||||||
|
|
||||||
|
def generate_configs(lh_samples: int = LH_SAMPLES):
|
||||||
|
primary = [f for f in FACTORS if f.primary]
|
||||||
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
|
|
||||||
|
primary_grid = list(product(*[f.levels for f in primary]))
|
||||||
|
lhs = LatinHypercube(d=len(secondary), seed=42)
|
||||||
|
|
||||||
|
configs = []
|
||||||
|
for p_combo in primary_grid:
|
||||||
|
samples = lhs.random(n=lh_samples)
|
||||||
|
for s in samples:
|
||||||
|
sec_vals = {
|
||||||
|
secondary[i].name: secondary[i].levels[int(s[i] * len(secondary[i].levels))]
|
||||||
|
for i in range(len(secondary))
|
||||||
|
}
|
||||||
|
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
|
||||||
|
base.update(sec_vals)
|
||||||
|
|
||||||
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
|
cfg = {**base, "seed": seed}
|
||||||
|
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
||||||
|
configs.append(cfg)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
def run_single(cfg: dict) -> dict:
|
||||||
|
from engine.wrapper import PHANTOM
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
np.random.seed(cfg["seed"])
|
||||||
|
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=cfg["n_products"],
|
||||||
|
alpha=cfg["alpha"],
|
||||||
|
N=cfg["N"],
|
||||||
|
)
|
||||||
|
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||||
|
|
||||||
|
obs, _ = env.reset()
|
||||||
|
total_reward, steps = 0.0, 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
|
total_reward += reward
|
||||||
|
steps += 1
|
||||||
|
if term: break
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
return {
|
||||||
|
"id": cfg["id"],
|
||||||
|
"config": cfg,
|
||||||
|
"total_reward": total_reward,
|
||||||
|
"avg_reward": total_reward / steps,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_study(max_workers: int = None, output: str = "results_mixed.jsonl", lh_samples: int = LH_SAMPLES):
|
||||||
|
configs = generate_configs(lh_samples)
|
||||||
|
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
|
||||||
|
log.info(f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
|
results.append(result)
|
||||||
|
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
||||||
|
|
||||||
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--workers", type=int, default=None)
|
||||||
|
p.add_argument("--output", default="results_mixed.jsonl")
|
||||||
|
p.add_argument("--lh-samples", type=int, default=10)
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
primary = [f for f in FACTORS if f.primary]
|
||||||
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
|
configs = generate_configs(args.lh_samples)
|
||||||
|
log.info(f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}")
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
run_study(args.workers, args.output, args.lh_samples)
|
||||||
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]
|
||||||
521
engine/train.py
Normal file
521
engine/train.py
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
HAS_WANDB = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_WANDB = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
from stable_baselines3 import PPO, A2C, DQN
|
||||||
|
from stable_baselines3.common.callbacks import EvalCallback
|
||||||
|
from stable_baselines3.common.monitor import Monitor
|
||||||
|
|
||||||
|
HAS_SB3 = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_SB3 = False
|
||||||
|
|
||||||
|
from .jax import JAX_AVAILABLE
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CFG = {
|
||||||
|
"project": "phantom-pricing",
|
||||||
|
"algo": "ppo",
|
||||||
|
"seed": 42,
|
||||||
|
"total_timesteps": 50_000,
|
||||||
|
"eval_episodes": 5,
|
||||||
|
"eval_freq": 1_000,
|
||||||
|
"log_freq": 100,
|
||||||
|
"revenue_weight": 0.01,
|
||||||
|
"n_products": 10,
|
||||||
|
"N": 100,
|
||||||
|
"alpha": 0.3,
|
||||||
|
"lambda_coi": 0.2,
|
||||||
|
"robust_radius": 0.15,
|
||||||
|
"robust_points": 5,
|
||||||
|
"info_value": 1.0,
|
||||||
|
"price_low": 10.0,
|
||||||
|
"price_high": 150.0,
|
||||||
|
"action_levels": 9,
|
||||||
|
"action_scale_low": 0.8,
|
||||||
|
"action_scale_high": 1.2,
|
||||||
|
"learning_rate": 3e-4,
|
||||||
|
"gamma": 0.99,
|
||||||
|
"buffer_size": 50_000,
|
||||||
|
"batch_size": 256,
|
||||||
|
"tau": 0.005,
|
||||||
|
"train_freq": 1,
|
||||||
|
"learning_starts": 1_000,
|
||||||
|
"target_update_interval": 1_000,
|
||||||
|
"exploration_fraction": 0.2,
|
||||||
|
"exploration_final_eps": 0.05,
|
||||||
|
"n_steps": 2_048,
|
||||||
|
"n_epochs": 10,
|
||||||
|
"gae_lambda": 0.95,
|
||||||
|
"clip_range": 0.2,
|
||||||
|
"ent_coef": 0.0,
|
||||||
|
"q_lr": 0.1,
|
||||||
|
"eps_start": 1.0,
|
||||||
|
"eps_end": 0.05,
|
||||||
|
"eps_decay": 0.9995,
|
||||||
|
"model_dir": "engine/models",
|
||||||
|
"arch": "small",
|
||||||
|
"activation": "relu",
|
||||||
|
"q_bins": 6,
|
||||||
|
"max_steps": 100,
|
||||||
|
"margin_floor": 0.05,
|
||||||
|
"margin_floor_patience": 5,
|
||||||
|
"use_jax": False,
|
||||||
|
"jax_num_envs": 16,
|
||||||
|
"jax_num_steps": 128,
|
||||||
|
"jax_num_minibatches": 4,
|
||||||
|
"jax_update_epochs": 4,
|
||||||
|
"jax_anneal_lr": True,
|
||||||
|
"checkpoint_interval": 10_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _truthy(value: str | bool | None) -> bool:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg(raw: dict | None = None) -> dict:
|
||||||
|
cfg = dict(DEFAULT_CFG)
|
||||||
|
if raw:
|
||||||
|
cfg.update({k: v for k, v in raw.items() if v is not None})
|
||||||
|
cfg["algo"] = str(cfg["algo"]).lower()
|
||||||
|
cfg["use_jax"] = _truthy(cfg.get("use_jax")) or _truthy(
|
||||||
|
os.environ.get("PHANTOM_USE_JAX")
|
||||||
|
)
|
||||||
|
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 {}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_env(cfg: dict):
|
||||||
|
from gymnasium.wrappers import FlattenObservation
|
||||||
|
|
||||||
|
from .wrapper import PHANTOM
|
||||||
|
from .lib.wrappers import EconomicMetricsWrapper
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=int(cfg["n_products"]),
|
||||||
|
alpha=float(cfg["alpha"]),
|
||||||
|
N=int(cfg["N"]),
|
||||||
|
price_bounds=(float(cfg["price_low"]), float(cfg["price_high"])),
|
||||||
|
lambda_coi=float(cfg["lambda_coi"]),
|
||||||
|
robust_radius=float(cfg["robust_radius"]),
|
||||||
|
robust_points=int(cfg["robust_points"]),
|
||||||
|
info_value=float(cfg["info_value"]),
|
||||||
|
action_levels=int(cfg["action_levels"]),
|
||||||
|
action_scale_low=float(cfg["action_scale_low"]),
|
||||||
|
action_scale_high=float(cfg["action_scale_high"]),
|
||||||
|
max_steps=int(cfg.get("max_steps", 100)),
|
||||||
|
margin_floor=float(cfg.get("margin_floor", 0.05)),
|
||||||
|
margin_floor_patience=int(cfg.get("margin_floor_patience", 5)),
|
||||||
|
render_mode=None,
|
||||||
|
)
|
||||||
|
env = EconomicMetricsWrapper(env)
|
||||||
|
env = FlattenObservation(env)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def _net_arch(name) -> list[int]:
|
||||||
|
presets = {
|
||||||
|
"tiny": [32, 32],
|
||||||
|
"small": [64, 64],
|
||||||
|
"medium": [128, 128],
|
||||||
|
"large": [256, 256],
|
||||||
|
}
|
||||||
|
if isinstance(name, (list, tuple)):
|
||||||
|
return [int(v) for v in name]
|
||||||
|
s = str(name).lower().strip()
|
||||||
|
if s in presets:
|
||||||
|
return presets[s]
|
||||||
|
if "x" in s:
|
||||||
|
try:
|
||||||
|
vals = [int(v) for v in s.split("x") if v]
|
||||||
|
return vals if vals else presets["small"]
|
||||||
|
except ValueError:
|
||||||
|
return presets["small"]
|
||||||
|
return presets["small"]
|
||||||
|
|
||||||
|
|
||||||
|
def _activation(name):
|
||||||
|
try:
|
||||||
|
import torch.nn as nn
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"relu": nn.ReLU,
|
||||||
|
"tanh": nn.Tanh,
|
||||||
|
"elu": nn.ELU,
|
||||||
|
"leaky_relu": nn.LeakyReLU,
|
||||||
|
}.get(str(name).lower().strip(), nn.ReLU)
|
||||||
|
|
||||||
|
|
||||||
|
def _policy_kwargs(cfg: dict) -> dict:
|
||||||
|
kw = {"net_arch": _net_arch(cfg.get("arch", "small"))}
|
||||||
|
act = _activation(cfg.get("activation", "relu"))
|
||||||
|
if act is not None:
|
||||||
|
kw["activation_fn"] = act
|
||||||
|
return kw
|
||||||
|
|
||||||
|
|
||||||
|
def _action(agent, obs, deterministic: bool = True):
|
||||||
|
out = agent.predict(obs, deterministic=deterministic)
|
||||||
|
a = out[0] if isinstance(out, tuple) else out
|
||||||
|
if isinstance(a, np.ndarray) and a.size == 1:
|
||||||
|
return int(a.reshape(-1)[0])
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(agent, env, episodes: int) -> dict:
|
||||||
|
rewards, revenues = [], []
|
||||||
|
for _ in range(int(episodes)):
|
||||||
|
obs, _ = env.reset()
|
||||||
|
done, ep_r, ep_rev = False, 0.0, 0.0
|
||||||
|
while not done:
|
||||||
|
obs, reward, term, trunc, info = env.step(_action(agent, obs, True))
|
||||||
|
done = term or trunc
|
||||||
|
ep_r += float(reward)
|
||||||
|
ep_rev += float(
|
||||||
|
info.get("economics", {}).get("revenue", info.get("revenue", 0.0))
|
||||||
|
)
|
||||||
|
rewards.append(ep_r)
|
||||||
|
revenues.append(ep_rev)
|
||||||
|
return {
|
||||||
|
"eval/reward": float(np.mean(rewards)),
|
||||||
|
"eval/revenue": float(np.mean(revenues)),
|
||||||
|
"eval/reward_std": float(np.std(rewards)),
|
||||||
|
"eval/revenue_std": float(np.std(revenues)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_model(cfg: dict, env):
|
||||||
|
algo = cfg["algo"]
|
||||||
|
policy_kwargs = _policy_kwargs(cfg)
|
||||||
|
if algo == "sac":
|
||||||
|
raise ValueError("sac is not supported with the discrete core env")
|
||||||
|
if algo == "ppo":
|
||||||
|
return PPO(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
n_steps=int(cfg["n_steps"]),
|
||||||
|
batch_size=int(cfg["batch_size"]),
|
||||||
|
n_epochs=int(cfg["n_epochs"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
gae_lambda=float(cfg["gae_lambda"]),
|
||||||
|
clip_range=float(cfg["clip_range"]),
|
||||||
|
ent_coef=float(cfg["ent_coef"]),
|
||||||
|
)
|
||||||
|
if algo == "a2c":
|
||||||
|
return A2C(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
n_steps=max(5, int(cfg["n_steps"]) // 32),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
gae_lambda=float(cfg["gae_lambda"]),
|
||||||
|
ent_coef=float(cfg["ent_coef"]),
|
||||||
|
)
|
||||||
|
if algo == "dqn":
|
||||||
|
return DQN(
|
||||||
|
"MlpPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
policy_kwargs=policy_kwargs,
|
||||||
|
seed=int(cfg["seed"]),
|
||||||
|
learning_rate=float(cfg["learning_rate"]),
|
||||||
|
buffer_size=int(cfg["buffer_size"]),
|
||||||
|
batch_size=int(cfg["batch_size"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
train_freq=int(cfg["train_freq"]),
|
||||||
|
learning_starts=int(cfg["learning_starts"]),
|
||||||
|
target_update_interval=int(cfg["target_update_interval"]),
|
||||||
|
exploration_fraction=float(cfg["exploration_fraction"]),
|
||||||
|
exploration_final_eps=float(cfg["exploration_final_eps"]),
|
||||||
|
)
|
||||||
|
raise ValueError(f"unsupported algo '{algo}'")
|
||||||
|
|
||||||
|
|
||||||
|
def _sb3_model_cls(algo: str):
|
||||||
|
if algo == "ppo":
|
||||||
|
return PPO
|
||||||
|
if algo == "a2c":
|
||||||
|
return A2C
|
||||||
|
if algo == "dqn":
|
||||||
|
return DQN
|
||||||
|
raise ValueError(f"unsupported algo '{algo}'")
|
||||||
|
|
||||||
|
|
||||||
|
def train_qtable(cfg: dict) -> tuple[EventQTable, dict]:
|
||||||
|
from .lib.discrete import EventQTable
|
||||||
|
|
||||||
|
np.random.seed(int(cfg["seed"]))
|
||||||
|
env = make_env(cfg)
|
||||||
|
eval_env = make_env(cfg)
|
||||||
|
agent = EventQTable(
|
||||||
|
env.action_space.n,
|
||||||
|
int(cfg["n_products"]),
|
||||||
|
(float(cfg["price_low"]), float(cfg["price_high"])),
|
||||||
|
lr=float(cfg["q_lr"]),
|
||||||
|
gamma=float(cfg["gamma"]),
|
||||||
|
n_bins=int(cfg["q_bins"]),
|
||||||
|
)
|
||||||
|
eps = float(cfg["eps_start"])
|
||||||
|
obs, _ = env.reset(seed=int(cfg["seed"]))
|
||||||
|
for t in range(int(cfg["total_timesteps"])):
|
||||||
|
a, s = agent.act(obs, eps)
|
||||||
|
nxt, reward, term, trunc, info = env.step(a)
|
||||||
|
done = term or trunc
|
||||||
|
agent.update(s, a, float(reward), agent.encode(nxt), done)
|
||||||
|
eps = max(float(cfg["eps_end"]), eps * float(cfg["eps_decay"]))
|
||||||
|
if HAS_WANDB and wandb.run and (t + 1) % int(cfg["log_freq"]) == 0:
|
||||||
|
econ = info.get("economics", {})
|
||||||
|
wandb.log(
|
||||||
|
{
|
||||||
|
"train/reward": float(reward),
|
||||||
|
"train/revenue": float(econ.get("revenue", 0.0)),
|
||||||
|
"train/epsilon": float(eps),
|
||||||
|
},
|
||||||
|
step=t + 1,
|
||||||
|
)
|
||||||
|
obs = env.reset()[0] if done else nxt
|
||||||
|
metrics = evaluate(agent, eval_env, int(cfg["eval_episodes"]))
|
||||||
|
metrics["train/global_step"] = int(cfg["total_timesteps"])
|
||||||
|
env.close()
|
||||||
|
eval_env.close()
|
||||||
|
return agent, metrics
|
||||||
|
|
||||||
|
|
||||||
|
def train_sb3(cfg: dict) -> tuple[object, dict]:
|
||||||
|
if not HAS_SB3:
|
||||||
|
raise ImportError("stable-baselines3 is required for SB3 models")
|
||||||
|
from .lib.callbacks import CheckpointArtifactCallback, MetricsCallback
|
||||||
|
|
||||||
|
env = make_env(cfg)
|
||||||
|
eval_env = make_env(cfg)
|
||||||
|
env = Monitor(env)
|
||||||
|
eval_env = Monitor(eval_env)
|
||||||
|
model = build_model(cfg, env)
|
||||||
|
resume_step = 0
|
||||||
|
if HAS_WANDB and wandb.run is not None:
|
||||||
|
sweep_id = getattr(wandb.run, "sweep_id", None)
|
||||||
|
artifact_name = checkpoint_artifact_name(cfg, backend="sb3", sweep_id=sweep_id)
|
||||||
|
checkpoint_file = f"phantom_{cfg['algo']}_checkpoint.zip"
|
||||||
|
restored = download_latest_checkpoint(artifact_name, file_name=checkpoint_file)
|
||||||
|
if restored is not None:
|
||||||
|
checkpoint_path, metadata = restored
|
||||||
|
model = _sb3_model_cls(cfg["algo"]).load(
|
||||||
|
checkpoint_path.as_posix(), env=env
|
||||||
|
)
|
||||||
|
resume_step = int(metadata.get("step", getattr(model, "num_timesteps", 0)))
|
||||||
|
model.num_timesteps = max(
|
||||||
|
int(getattr(model, "num_timesteps", 0)), resume_step
|
||||||
|
)
|
||||||
|
|
||||||
|
cbs = [MetricsCallback(log_histograms=True, log_freq=int(cfg["log_freq"]))]
|
||||||
|
cbs.append(
|
||||||
|
CheckpointArtifactCallback(
|
||||||
|
cfg,
|
||||||
|
interval=int(cfg.get("checkpoint_interval", 10_000)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cbs.append(
|
||||||
|
EvalCallback(
|
||||||
|
eval_env,
|
||||||
|
eval_freq=int(cfg["eval_freq"]),
|
||||||
|
n_eval_episodes=int(cfg["eval_episodes"]),
|
||||||
|
deterministic=True,
|
||||||
|
verbose=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
target_steps = int(cfg["total_timesteps"])
|
||||||
|
remaining_steps = max(0, target_steps - int(getattr(model, "num_timesteps", 0)))
|
||||||
|
if remaining_steps > 0:
|
||||||
|
model.learn(
|
||||||
|
total_timesteps=remaining_steps,
|
||||||
|
callback=cbs,
|
||||||
|
reset_num_timesteps=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = Path(cfg["model_dir"])
|
||||||
|
model_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
model.save(str(model_path / f"phantom_{cfg['algo']}"))
|
||||||
|
metrics = evaluate(model, eval_env, int(cfg["eval_episodes"]))
|
||||||
|
metrics["train/global_step"] = int(model.num_timesteps)
|
||||||
|
env.close()
|
||||||
|
eval_env.close()
|
||||||
|
return model, metrics
|
||||||
|
|
||||||
|
|
||||||
|
def train_once(cfg: dict) -> dict:
|
||||||
|
algo = cfg["algo"]
|
||||||
|
if cfg.get("use_jax"):
|
||||||
|
if not JAX_AVAILABLE:
|
||||||
|
raise ImportError(
|
||||||
|
"JAX backend requested but JAX is not installed. "
|
||||||
|
"Install engine/jax/requirements.txt and jax[tpu] for TPU runs."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from .jax.train import train_jax
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
raise ImportError(f"Failed to import JAX trainer: {exc}") from exc
|
||||||
|
_, metrics = train_jax(cfg)
|
||||||
|
elif algo == "qtable":
|
||||||
|
_, metrics = train_qtable(cfg)
|
||||||
|
else:
|
||||||
|
_, metrics = train_sb3(cfg)
|
||||||
|
metrics["sweep/score"] = float(
|
||||||
|
metrics["eval/reward"] + float(cfg["revenue_weight"]) * metrics["eval/revenue"]
|
||||||
|
)
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
|
||||||
|
def run_wandb(
|
||||||
|
project: str, overrides: dict, mode: str = "online", sweep_mode: bool = False
|
||||||
|
) -> dict:
|
||||||
|
if not HAS_WANDB:
|
||||||
|
raise ImportError("wandb is required for sweep runs")
|
||||||
|
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)
|
||||||
|
print(json.dumps(metrics, indent=2))
|
||||||
|
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("--total-timesteps", type=int)
|
||||||
|
p.add_argument("--alpha", type=float)
|
||||||
|
p.add_argument("--n-products", type=int)
|
||||||
|
p.add_argument("--lambda-coi", 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("--revenue-weight", 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,
|
||||||
|
"total_timesteps": args.total_timesteps,
|
||||||
|
"alpha": args.alpha,
|
||||||
|
"n_products": args.n_products,
|
||||||
|
"lambda_coi": args.lambda_coi,
|
||||||
|
"robust_radius": args.robust_radius,
|
||||||
|
"robust_points": args.robust_points,
|
||||||
|
"learning_rate": args.learning_rate,
|
||||||
|
"gamma": args.gamma,
|
||||||
|
"revenue_weight": args.revenue_weight,
|
||||||
|
"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()
|
||||||
366
engine/wrapper.py
Normal file
366
engine/wrapper.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import gymnasium as gym
|
||||||
|
from gymnasium import spaces
|
||||||
|
import numpy as np
|
||||||
|
from .engine import Limbo, MarketEngine, PricingEngine
|
||||||
|
from .lib.render import DashboardRenderer
|
||||||
|
from .lib.coi import (
|
||||||
|
compute_uplift_coi,
|
||||||
|
extract_purchases,
|
||||||
|
compute_agent_probability,
|
||||||
|
)
|
||||||
|
from .lib.behavior import get_transition_models, trajectory_to_events
|
||||||
|
from .lib.wrappers import EconomicMetricsWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class _ActionPricingEngine(PricingEngine):
|
||||||
|
def __init__(self, n_products: int, price_bounds: tuple):
|
||||||
|
self._prices = np.full(n_products, price_bounds[0], dtype=float)
|
||||||
|
|
||||||
|
def set_prices(self, prices: np.ndarray):
|
||||||
|
self._prices = np.asarray(prices, dtype=float)
|
||||||
|
|
||||||
|
def act(self, _):
|
||||||
|
return self._prices
|
||||||
|
|
||||||
|
|
||||||
|
class PHANTOM(gym.Env):
|
||||||
|
"""Gymnasium wrapper for Limbo pricing-market simulation implementing thesis COI framework
|
||||||
|
|
||||||
|
reward = R(p,d) - λ·COI_leak(p,τ') per thesis Section on DR-RL
|
||||||
|
COI_leak uses behavioral divergence to estimate agent probability f(τ')
|
||||||
|
robust inner step: min over alpha in Wasserstein interval around nominal alpha
|
||||||
|
actions are discrete global price-scale moves
|
||||||
|
"""
|
||||||
|
|
||||||
|
metadata = {"render_modes": ["human", "ansi"]}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
n_products: int = 10,
|
||||||
|
alpha: float = 0.3,
|
||||||
|
N: int = 100,
|
||||||
|
human_params: tuple = (50.0, 10.0),
|
||||||
|
agent_params: tuple = (45.0, 15.0),
|
||||||
|
noise_std: float = 1.0,
|
||||||
|
price_bounds: tuple = (10.0, 150.0),
|
||||||
|
lambda_coi: float = 0.1,
|
||||||
|
coi_window: int = 10,
|
||||||
|
robust_radius: float = 0.0,
|
||||||
|
robust_points: int = 5,
|
||||||
|
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__()
|
||||||
|
self.n_products = n_products
|
||||||
|
self.price_bounds = price_bounds
|
||||||
|
self.lambda_coi = lambda_coi
|
||||||
|
self.coi_window = coi_window
|
||||||
|
self.max_steps = max(1, int(max_steps))
|
||||||
|
self.margin_floor = float(
|
||||||
|
margin_floor
|
||||||
|
) # terminate if avg margin stays below this for patience steps
|
||||||
|
self.margin_floor_patience = max(1, int(margin_floor_patience))
|
||||||
|
self.render_mode = render_mode
|
||||||
|
self.alpha = float(alpha)
|
||||||
|
self.nominal_alpha = float(alpha)
|
||||||
|
self.N = N
|
||||||
|
self.human_params = human_params
|
||||||
|
self.agent_params = agent_params
|
||||||
|
self.robust_radius = max(0.0, float(robust_radius))
|
||||||
|
self.robust_points = max(1, int(robust_points))
|
||||||
|
self.info_value = float(info_value)
|
||||||
|
self.action_levels = max(2, int(action_levels))
|
||||||
|
self._action_scales = np.linspace(
|
||||||
|
float(action_scale_low), float(action_scale_high), self.action_levels
|
||||||
|
)
|
||||||
|
|
||||||
|
self.market = MarketEngine(
|
||||||
|
alpha=alpha,
|
||||||
|
N=N,
|
||||||
|
human_params=human_params,
|
||||||
|
agent_params=agent_params,
|
||||||
|
noise_std=noise_std,
|
||||||
|
)
|
||||||
|
self._platform_stub = _ActionPricingEngine(n_products, price_bounds)
|
||||||
|
self._limbo = Limbo(self._platform_stub, self.market)
|
||||||
|
self._set_market_mix(self.nominal_alpha)
|
||||||
|
|
||||||
|
self.action_space = spaces.Discrete(self.action_levels)
|
||||||
|
self.observation_space = spaces.Dict(
|
||||||
|
{
|
||||||
|
"demand": spaces.Box(
|
||||||
|
low=0.0, high=100.0, shape=(n_products,), dtype=np.float32
|
||||||
|
),
|
||||||
|
"prices": spaces.Box(
|
||||||
|
low=price_bounds[0],
|
||||||
|
high=price_bounds[1],
|
||||||
|
shape=(n_products,),
|
||||||
|
dtype=np.float32,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
self._prices = None
|
||||||
|
self._demand = None
|
||||||
|
self._step_count = 0
|
||||||
|
self._demand_history = []
|
||||||
|
self._price_history = []
|
||||||
|
self._revenue_history = []
|
||||||
|
self._renderer = None
|
||||||
|
self._initial_episode_prices = None
|
||||||
|
self._trajectories = [] # session trajectories for agent prob calculation
|
||||||
|
self.baseline_prices = np.full(self.n_products, self.price_bounds[0])
|
||||||
|
self._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:
|
||||||
|
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)}
|
||||||
|
|
||||||
|
def _set_market_mix(self, alpha: float):
|
||||||
|
alpha = float(np.clip(alpha, 0.0, 1.0))
|
||||||
|
n_agents = int(self.N * alpha)
|
||||||
|
self.alpha = alpha
|
||||||
|
self.market.alpha = alpha
|
||||||
|
self.market.Nagents = n_agents
|
||||||
|
self.market.Nhumans = self.N - n_agents
|
||||||
|
|
||||||
|
def _decode_action(self, action) -> np.ndarray:
|
||||||
|
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):
|
||||||
|
demand_arr = np.array(
|
||||||
|
[self._demand.get(i, 0.0) for i in range(self.n_products)]
|
||||||
|
)
|
||||||
|
self._demand_history.append(demand_arr)
|
||||||
|
self._price_history.append(self._prices.copy())
|
||||||
|
self._revenue_history.append(np.sum(self._prices * demand_arr))
|
||||||
|
|
||||||
|
def reset(self, seed=None, options=None):
|
||||||
|
super().reset(seed=seed)
|
||||||
|
self._set_market_mix(self.nominal_alpha)
|
||||||
|
self._limbo.reset()
|
||||||
|
self._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
|
||||||
|
self._platform_stub.set_prices(self._prices)
|
||||||
|
self._limbo.step()
|
||||||
|
self._demand = self._limbo.step()
|
||||||
|
self._initial_episode_prices = self._prices.copy()
|
||||||
|
self._step_count = 0
|
||||||
|
self._low_margin_streak = 0
|
||||||
|
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
||||||
|
self._trajectories = list(getattr(self.market, "last_trajectories", []))
|
||||||
|
self._record_history()
|
||||||
|
return self._get_obs(), {}
|
||||||
|
|
||||||
|
def step(self, action):
|
||||||
|
self._prices = self._decode_action(action)
|
||||||
|
# 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._trajectories.extend(trajectories)
|
||||||
|
|
||||||
|
reward, metrics = self._compute_reward(
|
||||||
|
self._prices, self._demand, agent_prob, trajectories
|
||||||
|
)
|
||||||
|
self._record_history()
|
||||||
|
|
||||||
|
# soft early termination when margin collapses for too long
|
||||||
|
avg_margin = float(np.mean(self._prices) - self.price_bounds[0]) / max(
|
||||||
|
float(np.mean(self._prices)), 1e-6
|
||||||
|
)
|
||||||
|
if avg_margin < self.margin_floor:
|
||||||
|
self._low_margin_streak += 1
|
||||||
|
else:
|
||||||
|
self._low_margin_streak = 0
|
||||||
|
margin_collapsed = self._low_margin_streak >= self.margin_floor_patience
|
||||||
|
terminated = self._step_count >= self.max_steps or margin_collapsed
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"step": self._step_count,
|
||||||
|
"agent_prob": agent_prob,
|
||||||
|
"alpha_adv": float(alpha_adv),
|
||||||
|
"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:
|
||||||
|
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
|
||||||
|
if len(self._price_history) < 2:
|
||||||
|
return np.zeros(self.n_products)
|
||||||
|
p, q = np.array(self._price_history), np.array(self._demand_history)
|
||||||
|
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
|
||||||
|
valid = np.abs(dp) > 0.5
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
elasticity = np.where(
|
||||||
|
valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0
|
||||||
|
)
|
||||||
|
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
|
||||||
|
return (
|
||||||
|
np.mean(elasticity, axis=0)
|
||||||
|
if len(elasticity) > 0
|
||||||
|
else np.zeros(self.n_products)
|
||||||
|
)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
if self.render_mode == "human":
|
||||||
|
if self._renderer is None:
|
||||||
|
self._renderer = DashboardRenderer()
|
||||||
|
self._renderer.render(self)
|
||||||
|
elif self.render_mode == "ansi":
|
||||||
|
return (
|
||||||
|
f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._renderer:
|
||||||
|
self._renderer.close()
|
||||||
|
self._renderer = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import wandb
|
||||||
|
from .lib import MetricsCallback
|
||||||
|
|
||||||
|
class RandomPolicy:
|
||||||
|
"""Minimal SB3-compatible random policy for baseline testing."""
|
||||||
|
|
||||||
|
def __init__(self, env):
|
||||||
|
self.env = env
|
||||||
|
self.num_timesteps = 0
|
||||||
|
|
||||||
|
def learn(self, total_timesteps, callback=None):
|
||||||
|
callback.model = self
|
||||||
|
callback.num_timesteps = 0
|
||||||
|
callback.locals = {}
|
||||||
|
callback.on_training_start({}, {})
|
||||||
|
|
||||||
|
obs, _ = self.env.reset()
|
||||||
|
for step in range(total_timesteps):
|
||||||
|
action = self.env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, info = self.env.step(action)
|
||||||
|
self.num_timesteps = step + 1
|
||||||
|
callback.num_timesteps = self.num_timesteps
|
||||||
|
callback.locals = {"infos": [info]}
|
||||||
|
callback.on_step()
|
||||||
|
if term or trunc:
|
||||||
|
callback.on_rollout_end()
|
||||||
|
obs, _ = self.env.reset()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, obs, **kwargs):
|
||||||
|
return self.env.action_space.sample(), None
|
||||||
|
|
||||||
|
wandb.init(project="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()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
# Products
|
||||||
|
# Agents
|
||||||
|
# Pipeline
|
||||||
|
|
||||||
|
Our pipeline technically should follow principles in a style like this:
|
||||||
|
- Each step should be defined as an inheriting child of an scikit pipeline step, the granularity of the steps is dictated by the following: a step should be a transformation, augmentation or computation independently, no single stage should run multiple in-itself. This way we can modularize properly all the components and track properly in airflow. A stage can be defined as an sklearn step but then must be transalted to a function that takes the context in our DAG of airflow. All parametrization must be done via contexts.
|
||||||
|
|
||||||
|
|||||||
117
experiments/agents/run.py
Normal file
117
experiments/agents/run.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
from supabase import create_client, Client
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from experiments.agents.agent import get_agent, AgentTypes
|
||||||
|
from lib.kafka_client import get_interactions
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
RESULTS="/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||||
|
|
||||||
|
client = create_client(
|
||||||
|
os.getenv("NEXT_PUBLIC_SUPABASE_URL"),
|
||||||
|
os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||||
|
)
|
||||||
|
def pick_random_task():
|
||||||
|
mode = 'hotel'
|
||||||
|
tasks = client.table("tasks").select("*").execute().data
|
||||||
|
if mode == 'hotel':
|
||||||
|
# drop all that have 'flight' in the description
|
||||||
|
tasks = [task for task in tasks if 'flight' not in task['task_description'].lower()]
|
||||||
|
return random.choice(tasks) if tasks else None
|
||||||
|
|
||||||
|
def clear_kafka_data():
|
||||||
|
"""Delete and recreate Kafka topics to clear all data"""
|
||||||
|
from kafka.admin import KafkaAdminClient, NewTopic
|
||||||
|
from kafka.errors import UnknownTopicOrPartitionError
|
||||||
|
import time
|
||||||
|
|
||||||
|
kafka_host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
|
kafka_port = os.getenv('KAFKA_PORT', '9092')
|
||||||
|
broker = f'{kafka_host}:{kafka_port}'
|
||||||
|
|
||||||
|
admin = KafkaAdminClient(bootstrap_servers=broker)
|
||||||
|
topics = ['user-interactions', 'price-logs']
|
||||||
|
|
||||||
|
try:
|
||||||
|
admin.delete_topics(topics, timeout_ms=5000)
|
||||||
|
print(f"Deleted topics: {topics}")
|
||||||
|
time.sleep(2)
|
||||||
|
except UnknownTopicOrPartitionError:
|
||||||
|
print("Topics don't exist, skipping delete")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error deleting topics: {e}")
|
||||||
|
|
||||||
|
new_topics = [
|
||||||
|
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||||
|
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
admin.create_topics(new_topics=new_topics, validate_only=False)
|
||||||
|
print(f"Recreated topics: {topics}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating topics: {e}")
|
||||||
|
finally:
|
||||||
|
admin.close()
|
||||||
|
|
||||||
|
def create_new_experiment(task_id):
|
||||||
|
import uuid
|
||||||
|
subject_name = f"agent_{str(uuid.uuid4())[:8]}"
|
||||||
|
experiment = {
|
||||||
|
"subject_name": subject_name,
|
||||||
|
"xp_human_only": False,
|
||||||
|
"xp_market_mode": "hotel",
|
||||||
|
"xp_task_id": task_id,
|
||||||
|
}
|
||||||
|
response = client.table("experiments").insert(experiment).execute()
|
||||||
|
return response.data[0] if response.data else None
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
clear_kafka_data()
|
||||||
|
|
||||||
|
task = pick_random_task()
|
||||||
|
if not task:
|
||||||
|
print("No tasks available")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
experiment = create_new_experiment(task['id'])
|
||||||
|
exp_id = experiment['id']
|
||||||
|
exp_dir = f"{RESULTS}{exp_id}"
|
||||||
|
os.makedirs(exp_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# construct experiment URL with uuid param
|
||||||
|
base_url = os.getenv('NEXT_PUBLIC_API_BASE', 'http://localhost:3000')
|
||||||
|
agent_url = f"{base_url}/start-task?uuid={exp_id}"
|
||||||
|
|
||||||
|
print(f"Created experiment {exp_id} for task {task['id']}")
|
||||||
|
print(f"Agent will interact with: {agent_url}")
|
||||||
|
|
||||||
|
# instantiate and run agent
|
||||||
|
agent = get_agent(
|
||||||
|
AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||||
|
goal=task['task_description'],
|
||||||
|
url=agent_url,
|
||||||
|
timeout=300,
|
||||||
|
headless=True
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(agent.act())
|
||||||
|
print(f"Agent result: {result}")
|
||||||
|
|
||||||
|
# export interaction and price data from kafka
|
||||||
|
interactions = get_interactions(topic='user-interactions', timeout_ms=3000)
|
||||||
|
prices = get_interactions(topic='price-logs', timeout_ms=3000)
|
||||||
|
|
||||||
|
with open(f"{exp_dir}/int.json", 'w') as f:
|
||||||
|
json.dump(interactions, f, indent=2)
|
||||||
|
|
||||||
|
with open(f"{exp_dir}/price.json", 'w') as f:
|
||||||
|
json.dump(prices, f, indent=2)
|
||||||
|
|
||||||
|
print(f"Experiment {exp_id} completed.")
|
||||||
|
print(f"Exported {len(interactions)} interactions and {len(prices)} price logs to {exp_dir}")
|
||||||
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from airflow import DAG, Dataset
|
||||||
|
from airflow.decorators import task
|
||||||
|
from airflow.utils.dates import days_ago
|
||||||
|
from datetime import timedelta
|
||||||
|
import pandas as pd
|
||||||
|
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 import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
ValidateDataStep,
|
||||||
|
ExtractSessionFeaturesStep,
|
||||||
|
JoinLabelsStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
TRAINING_DATASET = Dataset('phantom://ml/training-data')
|
||||||
|
|
||||||
|
DEFAULT_ARGS = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
with DAG(
|
||||||
|
'ml_training_pipeline',
|
||||||
|
default_args=DEFAULT_ARGS,
|
||||||
|
description='ML training data pipeline: fetch -> validate -> extract features -> label -> publish',
|
||||||
|
schedule=None,
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['ml', 'training', 'features', 'research'],
|
||||||
|
) as dag:
|
||||||
|
|
||||||
|
@task
|
||||||
|
def fetch_interactions(**kwargs) -> bytes:
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
df = FetchInteractionsStep(ctx).transform(None)
|
||||||
|
logging.info(f"Fetched {len(df)} interactions, {df['sessionId'].nunique()} sessions")
|
||||||
|
return pickle.dumps(df)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def validate_data(raw_data: bytes, **kwargs) -> bytes:
|
||||||
|
df = pickle.loads(raw_data)
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
validated = ValidateDataStep(ctx).transform(df)
|
||||||
|
report = ctx.get_cached('validation_report') or {}
|
||||||
|
logging.info(f"Validation: {report.get('status')}, {report.get('sessions', 0)} sessions")
|
||||||
|
return pickle.dumps(validated)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def extract_session_features(validated_data: bytes, **kwargs) -> bytes:
|
||||||
|
df = pickle.loads(validated_data)
|
||||||
|
if df.empty:
|
||||||
|
logging.warning("Empty input, skipping feature extraction")
|
||||||
|
return pickle.dumps(pd.DataFrame())
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
features = ExtractSessionFeaturesStep(ctx).transform(df)
|
||||||
|
logging.info(f"Extracted {len(features.columns)} features for {len(features)} sessions")
|
||||||
|
return pickle.dumps(features)
|
||||||
|
|
||||||
|
@task
|
||||||
|
def join_labels(features_data: bytes, **kwargs) -> bytes:
|
||||||
|
features_df = pickle.loads(features_data)
|
||||||
|
if features_df.empty:
|
||||||
|
logging.warning("Empty features, skipping label join")
|
||||||
|
return pickle.dumps(pd.DataFrame())
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||||
|
labeled = JoinLabelsStep(ctx).transform(features_df)
|
||||||
|
n_agents = labeled['is_agent'].sum() if 'is_agent' in labeled.columns else 0
|
||||||
|
logging.info(f"Labeled {len(labeled)} sessions: {n_agents} agents")
|
||||||
|
return pickle.dumps(labeled)
|
||||||
|
|
||||||
|
@task(outlets=[TRAINING_DATASET])
|
||||||
|
def publish_training_data(labeled_data: bytes, **kwargs) -> dict:
|
||||||
|
labeled_df = pickle.loads(labeled_data)
|
||||||
|
if labeled_df.empty:
|
||||||
|
return {'status': 'skipped', 'reason': 'empty_data'}
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
return {
|
||||||
|
'status': 'success',
|
||||||
|
'n_sessions': len(labeled_df),
|
||||||
|
'n_features': len([c for c in labeled_df.columns if c not in ['sessionId', 'experimentId', 'is_agent']]),
|
||||||
|
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = fetch_interactions()
|
||||||
|
validated = validate_data(raw)
|
||||||
|
features = extract_session_features(validated)
|
||||||
|
labeled = join_labels(features)
|
||||||
|
publish_training_data(labeled)
|
||||||
220
experiments/airflow/dags/surge_pricing_factory.py
Normal file
220
experiments/airflow/dags/surge_pricing_factory.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from pandas.core.algorithms import factorize_array
|
||||||
|
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 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 import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers.simple import SimpleSurgePricer
|
||||||
|
|
||||||
|
DEFAULT_ARGS = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def __init__(self):
|
||||||
|
SupabaseProvider.__init__(self)
|
||||||
|
BackendAPIProvider.__init__(self)
|
||||||
|
|
||||||
|
def _get_provider():
|
||||||
|
return CompositeProvider()
|
||||||
|
|
||||||
|
def _make_task_callables(store_mode: str):
|
||||||
|
"""Generate task callables bound to a specific store_mode."""
|
||||||
|
|
||||||
|
def get_context(**kwargs):
|
||||||
|
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
|
||||||
|
|
||||||
|
def fetch_interactions(**kwargs):
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
df = FetchInteractionsStep(ctx).transform(None)
|
||||||
|
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def fetch_price_logs(**kwargs):
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
df = FetchPriceLogsStep(ctx).transform(None)
|
||||||
|
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def compute_demand(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
demand_df = ComputeDemandStep(ctx).transform(df)
|
||||||
|
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||||
|
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
|
||||||
|
return len(demand_df)
|
||||||
|
|
||||||
|
def aggregate_price_logs(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
price_df = AggregatePriceLogsStep(ctx).transform(df)
|
||||||
|
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||||
|
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
|
||||||
|
return len(price_df)
|
||||||
|
|
||||||
|
def join_product_features(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||||
|
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||||
|
ctx = get_context(**kwargs)
|
||||||
|
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
|
||||||
|
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||||
|
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
|
||||||
|
return len(joined_df)
|
||||||
|
|
||||||
|
def apply_surge_pricing(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||||
|
surge_pricer = SimpleSurgePricer(
|
||||||
|
high_threshold=dag_conf.get('high_threshold', 10),
|
||||||
|
low_threshold=dag_conf.get('low_threshold', 2),
|
||||||
|
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
)
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
|
||||||
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
|
'price': 'current_price', 'demand': 'demand_score'
|
||||||
|
})
|
||||||
|
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||||
|
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
|
||||||
|
return len(prices_df)
|
||||||
|
|
||||||
|
def publish_results(**kwargs):
|
||||||
|
ti = kwargs['ti']
|
||||||
|
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
registry = ModelRegistry()
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
'store_mode': store_mode,
|
||||||
|
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||||
|
'pricing_method': 'surge',
|
||||||
|
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||||
|
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||||
|
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
}
|
||||||
|
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
|
||||||
|
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'n_products': len(prices_df),
|
||||||
|
'registry_status': 'success',
|
||||||
|
'store_mode': store_mode,
|
||||||
|
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'fetch_interactions': fetch_interactions,
|
||||||
|
'fetch_price_logs': fetch_price_logs,
|
||||||
|
'compute_demand': compute_demand,
|
||||||
|
'aggregate_price_logs': aggregate_price_logs,
|
||||||
|
'join_product_features': join_product_features,
|
||||||
|
'apply_surge_pricing': apply_surge_pricing,
|
||||||
|
'publish_results': publish_results,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||||
|
"""Factory: generates a surge pricing DAG for a given store_mode."""
|
||||||
|
callables = _make_task_callables(store_mode)
|
||||||
|
|
||||||
|
dag = DAG(
|
||||||
|
f'surge_pricing_{store_mode}',
|
||||||
|
default_args=DEFAULT_ARGS,
|
||||||
|
description=f'Surge pricing pipeline for {store_mode} store mode',
|
||||||
|
schedule_interval='*/15 * * * *',
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['pricing', 'surge', 'research', store_mode],
|
||||||
|
)
|
||||||
|
|
||||||
|
with dag:
|
||||||
|
t_fetch_interactions = PythonOperator(
|
||||||
|
task_id='fetch_interactions',
|
||||||
|
python_callable=callables['fetch_interactions'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_fetch_price_logs = PythonOperator(
|
||||||
|
task_id='fetch_price_logs',
|
||||||
|
python_callable=callables['fetch_price_logs'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_compute_demand = PythonOperator(
|
||||||
|
task_id='compute_demand',
|
||||||
|
python_callable=callables['compute_demand'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_aggregate_prices = PythonOperator(
|
||||||
|
task_id='aggregate_price_logs',
|
||||||
|
python_callable=callables['aggregate_price_logs'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_join_features = PythonOperator(
|
||||||
|
task_id='join_product_features',
|
||||||
|
python_callable=callables['join_product_features'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_surge_pricing = PythonOperator(
|
||||||
|
task_id='apply_surge_pricing',
|
||||||
|
python_callable=callables['apply_surge_pricing'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
t_publish = PythonOperator(
|
||||||
|
task_id='publish_results',
|
||||||
|
python_callable=callables['publish_results'],
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t_fetch_interactions >> t_compute_demand
|
||||||
|
t_fetch_price_logs >> t_aggregate_prices
|
||||||
|
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||||
|
|
||||||
|
return dag
|
||||||
|
|
||||||
|
|
||||||
|
# instantiate DAGs for Airflow to discover
|
||||||
|
dag_airline = create_surge_pricing_dag('airline')
|
||||||
|
dag_hotel = create_surge_pricing_dag('hotel')
|
||||||
|
|
||||||
|
# TODO: Refactor this factory from a surge pricing factory to a general pricing factory
|
||||||
|
# We will do this by passing a pricing strategy class to the factory, since the generic pipeline is:
|
||||||
|
# take all interaction data, group by sessionId and assign a new price vector to each session
|
||||||
|
# in the grouping we get a subset of the interactions per sessionId and we can map that to some Features
|
||||||
|
# we define a custom _get_features(interactions .) methodin the strategy class
|
||||||
|
# we then run only the inference which is the .predict(trajectory) per-session which will give us a new price vector
|
||||||
|
# this we then publish for each sessionId group
|
||||||
|
# this might include no deleting most of the pricers we have defined and starting with a super simple surge-pricing algorithm that is no-fit only predict. This we can then test end-to-end and observe changes to prices according to a desired strategy - we have to define this one as a very short term strategy because we run sessions that take only a few minutes.
|
||||||
253
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
253
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
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 logging
|
||||||
|
import sys
|
||||||
|
import pickle
|
||||||
|
import io
|
||||||
|
|
||||||
|
# add parent dir to path so procesing package can be imported
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers.simple import SimpleSurgePricer
|
||||||
|
|
||||||
|
default_args = {
|
||||||
|
'owner': 'phantom-research',
|
||||||
|
'depends_on_past': False,
|
||||||
|
'email_on_failure': False,
|
||||||
|
'email_on_retry': False,
|
||||||
|
'retries': 2,
|
||||||
|
'retry_delay': timedelta(minutes=5),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_provider():
|
||||||
|
"""Factory to create composite provider"""
|
||||||
|
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
|
||||||
|
def __init__(self):
|
||||||
|
SupabaseProvider.__init__(self)
|
||||||
|
BackendAPIProvider.__init__(self)
|
||||||
|
return CompositeProvider()
|
||||||
|
|
||||||
|
def get_context(**kwargs):
|
||||||
|
"""Build pipeline context from Airflow config"""
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
return PipelineContext(
|
||||||
|
provider=get_provider(),
|
||||||
|
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# atomic task functions (each wraps one sklearn step)
|
||||||
|
def fetch_interactions(**kwargs):
|
||||||
|
"""Task: Fetch interaction data from Kafka"""
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = FetchInteractionsStep(context)
|
||||||
|
df = step.transform(None)
|
||||||
|
|
||||||
|
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"Fetched {len(df)} interaction records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def fetch_price_logs(**kwargs):
|
||||||
|
"""Task: Fetch price logs from Kafka"""
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = FetchPriceLogsStep(context)
|
||||||
|
df = step.transform(None)
|
||||||
|
|
||||||
|
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||||
|
logging.info(f"Fetched {len(df)} price records")
|
||||||
|
return len(df)
|
||||||
|
|
||||||
|
def compute_demand(**kwargs):
|
||||||
|
"""Task: Compute demand scores from interactions"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = ComputeDemandStep(context)
|
||||||
|
demand_df = step.transform(df)
|
||||||
|
# TODO: clear the xcom
|
||||||
|
|
||||||
|
|
||||||
|
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||||
|
logging.info(f"Computed demand for {len(demand_df)} products")
|
||||||
|
return len(demand_df)
|
||||||
|
|
||||||
|
def aggregate_price_logs(**kwargs):
|
||||||
|
"""Task: Aggregate price logs"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = AggregatePriceLogsStep(context)
|
||||||
|
price_df = step.transform(df)
|
||||||
|
|
||||||
|
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||||
|
logging.info(f"Aggregated price logs for {len(price_df)} products")
|
||||||
|
return len(price_df)
|
||||||
|
|
||||||
|
def join_product_features(**kwargs):
|
||||||
|
"""Task: Join demand and price data"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||||
|
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||||
|
|
||||||
|
context = get_context(**kwargs)
|
||||||
|
step = JoinProductFeaturesStep(context)
|
||||||
|
joined_df = step.transform((demand_df, price_df))
|
||||||
|
|
||||||
|
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||||
|
logging.info(f"Joined features for {len(joined_df)} products")
|
||||||
|
return len(joined_df)
|
||||||
|
|
||||||
|
def apply_surge_pricing(**kwargs):
|
||||||
|
"""Task: Apply surge pricing rules to generate optimal prices"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||||
|
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
# rename demand_score to demand for pricer compatibility
|
||||||
|
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||||
|
|
||||||
|
high_thresh = dag_conf.get('high_threshold', 10)
|
||||||
|
low_thresh = dag_conf.get('low_threshold', 2)
|
||||||
|
surge_mult = dag_conf.get('surge_multiplier', 1.2)
|
||||||
|
discount_mult = dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
|
||||||
|
logging.info(f"Surge pricing config: high_thresh={high_thresh}, low_thresh={low_thresh}, surge_mult={surge_mult}, discount_mult={discount_mult}")
|
||||||
|
logging.info(f"Demand stats: min={data['demand'].min():.2f}, max={data['demand'].max():.2f}, mean={data['demand'].mean():.2f}")
|
||||||
|
logging.info(f"Products with high demand (>={high_thresh}): {(data['demand'] >= high_thresh).sum()}")
|
||||||
|
logging.info(f"Products with low demand (<={low_thresh}): {(data['demand'] <= low_thresh).sum()}")
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer(
|
||||||
|
high_threshold=high_thresh,
|
||||||
|
low_threshold=low_thresh,
|
||||||
|
surge_multiplier=surge_mult,
|
||||||
|
discount_multiplier=discount_mult
|
||||||
|
)
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
|
||||||
|
base_avg = data['base_price'].mean()
|
||||||
|
optimal_avg = data['optimal_price'].mean()
|
||||||
|
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
|
||||||
|
|
||||||
|
logging.info(f"Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
|
||||||
|
|
||||||
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
|
'price': 'current_price',
|
||||||
|
'demand': 'demand_score'
|
||||||
|
})
|
||||||
|
|
||||||
|
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||||
|
logging.info(f"Applied surge pricing for {len(prices_df)} products")
|
||||||
|
return len(prices_df)
|
||||||
|
|
||||||
|
def publish_results(**kwargs):
|
||||||
|
"""Task: Publish surge pricing results to registry"""
|
||||||
|
ti = kwargs['ti']
|
||||||
|
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||||
|
|
||||||
|
sys.path.insert(0, '/opt/airflow')
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
registry = ModelRegistry()
|
||||||
|
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
'timestamp': pd.Timestamp.now().isoformat(),
|
||||||
|
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||||
|
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||||
|
'pricing_method': 'surge',
|
||||||
|
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||||
|
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||||
|
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||||
|
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||||
|
|
||||||
|
logging.info(f"Published surge pricing for {len(prices_df)} products")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'n_products': len(prices_df),
|
||||||
|
'registry_status': 'success',
|
||||||
|
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# DAG definition
|
||||||
|
with DAG(
|
||||||
|
'surge_pricing_pipeline',
|
||||||
|
default_args=default_args,
|
||||||
|
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
|
||||||
|
schedule_interval='*/15 * * * *',
|
||||||
|
start_date=days_ago(1),
|
||||||
|
catchup=False,
|
||||||
|
max_active_runs=1,
|
||||||
|
tags=['pricing', 'surge', 'research', 'simplified'],
|
||||||
|
) as dag:
|
||||||
|
|
||||||
|
# parallel data fetching
|
||||||
|
t_fetch_interactions = PythonOperator(
|
||||||
|
task_id='fetch_interactions',
|
||||||
|
python_callable=fetch_interactions,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
t_fetch_price_logs = PythonOperator(
|
||||||
|
task_id='fetch_price_logs',
|
||||||
|
python_callable=fetch_price_logs,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# compute demand from interactions
|
||||||
|
t_compute_demand = PythonOperator(
|
||||||
|
task_id='compute_demand',
|
||||||
|
python_callable=compute_demand,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# aggregate price logs
|
||||||
|
t_aggregate_prices = PythonOperator(
|
||||||
|
task_id='aggregate_price_logs',
|
||||||
|
python_callable=aggregate_price_logs,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# join demand and prices
|
||||||
|
t_join_features = PythonOperator(
|
||||||
|
task_id='join_product_features',
|
||||||
|
python_callable=join_product_features,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# apply surge pricing
|
||||||
|
t_surge_pricing = PythonOperator(
|
||||||
|
task_id='apply_surge_pricing',
|
||||||
|
python_callable=apply_surge_pricing,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# publish to registry
|
||||||
|
t_publish = PythonOperator(
|
||||||
|
task_id='publish_results',
|
||||||
|
python_callable=publish_results,
|
||||||
|
provide_context=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# dependency graph: parallel fetch -> process -> join -> surge -> publish
|
||||||
|
t_fetch_interactions >> t_compute_demand
|
||||||
|
t_fetch_price_logs >> t_aggregate_prices
|
||||||
|
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||||
21
experiments/ml/__init__.py
Normal file
21
experiments/ml/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from .evals import evaluate
|
||||||
|
from .arch import (
|
||||||
|
XGBoostAgentClassifier,
|
||||||
|
LightGBMAgentClassifier,
|
||||||
|
ContrastiveWeakClassifier,
|
||||||
|
TrajectoryEncoder,
|
||||||
|
WeakClassifier,
|
||||||
|
contrastive_loss,
|
||||||
|
featurize_trajectory,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'evaluate',
|
||||||
|
'XGBoostAgentClassifier',
|
||||||
|
'LightGBMAgentClassifier',
|
||||||
|
'ContrastiveWeakClassifier',
|
||||||
|
'TrajectoryEncoder',
|
||||||
|
'WeakClassifier',
|
||||||
|
'contrastive_loss',
|
||||||
|
'featurize_trajectory',
|
||||||
|
]
|
||||||
212
experiments/ml/arch.py
Normal file
212
experiments/ml/arch.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
# sklearn compatible models for agent detection
|
||||||
|
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||||
|
from typing import Any, Optional, Tuple, Dict, List
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections import defaultdict
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# add lib to path for imports
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'lib'))
|
||||||
|
from lib.features import (
|
||||||
|
transition_histogram as _lib_transition_histogram,
|
||||||
|
temporal_signature as _lib_temporal_signature,
|
||||||
|
state_coverage as _lib_state_coverage,
|
||||||
|
transition_entropy as _lib_transition_entropy,
|
||||||
|
featurize_trajectory as _lib_featurize_trajectory,
|
||||||
|
parse_timestamp
|
||||||
|
)
|
||||||
|
from lib.state import event_to_state, get_event_name, get_timestamp
|
||||||
|
|
||||||
|
TASK = 'classification'
|
||||||
|
LABELS = ['human', 'agent']
|
||||||
|
|
||||||
|
|
||||||
|
class WeakClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||||
|
# a simple contrastive machine learning model learns to distinguish human/agent behavior
|
||||||
|
# using weakly supervised contrastive learning + augmentation
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__()
|
||||||
|
self.model = None
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
|
||||||
|
class TrajectoryEncoder(nn.Module):
|
||||||
|
"""Encode variable-length event sequences to fixed-dim embedding via bidirectional LSTM"""
|
||||||
|
def __init__(self, input_dim: int, embed_dim: int = 32, hidden_dim: int = 64):
|
||||||
|
super().__init__()
|
||||||
|
self.event_embed = nn.Linear(input_dim, hidden_dim)
|
||||||
|
self.lstm = nn.LSTM(hidden_dim, hidden_dim, batch_first=True, bidirectional=True)
|
||||||
|
self.proj = nn.Linear(hidden_dim * 2, embed_dim)
|
||||||
|
|
||||||
|
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, input_dim)
|
||||||
|
h = F.relu(self.event_embed(x))
|
||||||
|
_, (hn, _) = self.lstm(h)
|
||||||
|
hn = torch.cat([hn[-2], hn[-1]], dim=1) # concat bidirectional hidden states
|
||||||
|
return F.normalize(self.proj(hn), dim=1) # L2 normalized
|
||||||
|
|
||||||
|
|
||||||
|
class ContrastiveWeakClassifier(WeakClassifier):
|
||||||
|
"""Contrastive learning classifier for human/agent trajectory discrimination"""
|
||||||
|
def __init__(self, input_dim: int = 64, embed_dim: int = 32, margin: float = 1.0, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self.input_dim = input_dim
|
||||||
|
self.embed_dim = embed_dim
|
||||||
|
self.margin = margin
|
||||||
|
self.encoder = TrajectoryEncoder(input_dim, embed_dim)
|
||||||
|
self.classifier = nn.Linear(embed_dim, 2)
|
||||||
|
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||||
|
self._fitted = False
|
||||||
|
|
||||||
|
def to_device(self):
|
||||||
|
self.encoder.to(self.device)
|
||||||
|
self.classifier.to(self.device)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||||
|
return self.encoder(x.to(self.device))
|
||||||
|
|
||||||
|
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||||
|
emb = self.encode(x)
|
||||||
|
return self.classifier(emb)
|
||||||
|
|
||||||
|
def fit(self, X, y=None): # sklearn interface - actual training in weak.train.py
|
||||||
|
self._fitted = True
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
self.encoder.eval()
|
||||||
|
self.classifier.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||||
|
logits = self.forward(x)
|
||||||
|
return torch.argmax(logits, dim=1).cpu().numpy()
|
||||||
|
|
||||||
|
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
self.encoder.eval()
|
||||||
|
self.classifier.eval()
|
||||||
|
with torch.no_grad():
|
||||||
|
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||||
|
logits = self.forward(x)
|
||||||
|
return F.softmax(logits, dim=1).cpu().numpy()
|
||||||
|
|
||||||
|
|
||||||
|
def contrastive_loss(anchor: torch.Tensor, positive: torch.Tensor, negative: torch.Tensor, margin: float = 0.3) -> torch.Tensor:
|
||||||
|
"""Triplet loss using cosine similarity (for L2-normalized embeddings). margin in [0,1] range."""
|
||||||
|
pos_sim = F.cosine_similarity(anchor, positive) # higher = more similar
|
||||||
|
neg_sim = F.cosine_similarity(anchor, negative)
|
||||||
|
return F.relu(neg_sim - pos_sim + margin).mean() # want pos_sim > neg_sim + margin
|
||||||
|
|
||||||
|
|
||||||
|
def nt_xent_loss(z_i: torch.Tensor, z_j: torch.Tensor, temperature: float = 0.5) -> torch.Tensor:
|
||||||
|
"""Normalized temperature-scaled cross entropy loss (SimCLR style)"""
|
||||||
|
batch_size = z_i.size(0)
|
||||||
|
z = torch.cat([z_i, z_j], dim=0) # (2N, embed_dim)
|
||||||
|
sim = F.cosine_similarity(z.unsqueeze(1), z.unsqueeze(0), dim=2) / temperature
|
||||||
|
mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device)
|
||||||
|
sim.masked_fill_(mask, -float('inf'))
|
||||||
|
labels = torch.arange(batch_size, device=z.device)
|
||||||
|
labels = torch.cat([labels + batch_size, labels]) # positive pairs
|
||||||
|
return F.cross_entropy(sim, labels)
|
||||||
|
|
||||||
|
|
||||||
|
# feature extraction utilities - delegating to lib.features for unified implementation
|
||||||
|
# these wrappers maintain backwards compatibility for existing imports
|
||||||
|
|
||||||
|
def transition_histogram(events: List, state_fn, max_states: int = 50) -> np.ndarray:
|
||||||
|
"""Compute normalized histogram of state transitions in trajectory"""
|
||||||
|
return _lib_transition_histogram(events, state_fn, max_states)
|
||||||
|
|
||||||
|
|
||||||
|
def temporal_signature(events: List, ts_fn) -> np.ndarray:
|
||||||
|
"""Extract temporal features: mean/std/skew of inter-event times"""
|
||||||
|
return _lib_temporal_signature(events, ts_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def state_coverage(events: List, state_fn, mdp_states: set) -> float:
|
||||||
|
"""Fraction of MDP states visited by trajectory"""
|
||||||
|
return _lib_state_coverage(events, state_fn, mdp_states)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_entropy(events: List, state_fn) -> float:
|
||||||
|
"""Compute entropy of transition distribution (randomness of navigation)"""
|
||||||
|
return _lib_transition_entropy(events, state_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def featurize_trajectory(events: List, mdp: Optional[Dict] = None, input_dim: int = 64) -> np.ndarray:
|
||||||
|
"""Convert trajectory to fixed-dim feature vector - uses lib.features implementation"""
|
||||||
|
mdp_states = set(mdp.get('states', [])) if mdp else set()
|
||||||
|
|
||||||
|
def _ts_fn(e):
|
||||||
|
return parse_timestamp(get_timestamp(e))
|
||||||
|
|
||||||
|
def _event_name_fn(e):
|
||||||
|
return get_event_name(e)
|
||||||
|
|
||||||
|
return _lib_featurize_trajectory(events, event_to_state, _ts_fn, _event_name_fn, mdp_states, input_dim)
|
||||||
|
|
||||||
|
|
||||||
|
# gradient boosting classifiers for comparison baselines
|
||||||
|
class XGBoostAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||||
|
"""XGBoost classifier for human/agent detection from session features"""
|
||||||
|
def __init__(self, n_estimators: int = 100, max_depth: int = 6, learning_rate: float = 0.1, **kwargs):
|
||||||
|
self.n_estimators = n_estimators
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.learning_rate = learning_rate
|
||||||
|
self.model = None
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||||
|
try:
|
||||||
|
import xgboost as xgb
|
||||||
|
self.model = xgb.XGBClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||||
|
learning_rate=self.learning_rate, **self.kwargs)
|
||||||
|
self.model.fit(X, y)
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("xgboost required for XGBoostAgentClassifier")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
if self.model is None:
|
||||||
|
raise ValueError("fit the model first")
|
||||||
|
return self.model.predict(X)
|
||||||
|
|
||||||
|
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
if self.model is None:
|
||||||
|
raise ValueError("fit the model first")
|
||||||
|
return self.model.predict_proba(X)
|
||||||
|
|
||||||
|
|
||||||
|
class LightGBMAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||||
|
"""LightGBM classifier for human/agent detection from session features"""
|
||||||
|
def __init__(self, n_estimators: int = 100, max_depth: int = -1, learning_rate: float = 0.1, **kwargs):
|
||||||
|
self.n_estimators = n_estimators
|
||||||
|
self.max_depth = max_depth
|
||||||
|
self.learning_rate = learning_rate
|
||||||
|
self.model = None
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||||
|
try:
|
||||||
|
import lightgbm as lgb
|
||||||
|
self.model = lgb.LGBMClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||||
|
learning_rate=self.learning_rate, verbose=-1, **self.kwargs)
|
||||||
|
self.model.fit(X, y)
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("lightgbm required for LightGBMAgentClassifier")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
if self.model is None:
|
||||||
|
raise ValueError("fit the model first")
|
||||||
|
return self.model.predict(X)
|
||||||
|
|
||||||
|
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||||
|
if self.model is None:
|
||||||
|
raise ValueError("fit the model first")
|
||||||
|
return self.model.predict_proba(X)
|
||||||
103
experiments/ml/evals.py
Normal file
103
experiments/ml/evals.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
|
||||||
|
f1_score, roc_auc_score, confusion_matrix, roc_curve)
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
from logging import getLogger
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import io
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def log_feature_importance(writer, model, feature_names, epoch):
|
||||||
|
"""Visualize and log feature importance to TensorBoard"""
|
||||||
|
if not hasattr(model, 'feature_importances_') or model.feature_importances_ is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
importance = model.feature_importances_
|
||||||
|
indices = np.argsort(importance)[::-1][:20] # top 20
|
||||||
|
top_features = [feature_names[i] for i in indices]
|
||||||
|
top_importance = importance[indices]
|
||||||
|
|
||||||
|
for i, (feat, imp) in enumerate(zip(top_features, top_importance)):
|
||||||
|
writer.add_scalar(f'FeatureImportance/{feat}', imp, epoch)
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(10, 8))
|
||||||
|
ax.barh(range(len(top_features)), top_importance, align='center')
|
||||||
|
ax.set_yticks(range(len(top_features)))
|
||||||
|
ax.set_yticklabels(top_features)
|
||||||
|
ax.invert_yaxis()
|
||||||
|
ax.set_xlabel('Importance')
|
||||||
|
ax.set_title(f'Top 20 Feature Importance (Epoch {epoch})')
|
||||||
|
ax.grid(axis='x', alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(buf, format='png', dpi=100)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
img_arr = np.array(img)
|
||||||
|
writer.add_image('FeatureImportance/Chart', img_arr, epoch, dataformats='HWC')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
def evaluate(perdicted_class, predicted_proba, true_class, writer: SummaryWriter, epoch: int):
|
||||||
|
accuracy = accuracy_score(true_class, perdicted_class)
|
||||||
|
precision = precision_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
recall = recall_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
f1 = f1_score(true_class, perdicted_class, zero_division=0)
|
||||||
|
roc_auc = roc_auc_score(true_class, predicted_proba)
|
||||||
|
|
||||||
|
writer.add_scalar('Eval/Accuracy', accuracy, epoch)
|
||||||
|
writer.add_scalar('Eval/Precision', precision, epoch)
|
||||||
|
writer.add_scalar('Eval/Recall', recall, epoch)
|
||||||
|
writer.add_scalar('Eval/F1_Score', f1, epoch)
|
||||||
|
writer.add_scalar('Eval/ROC_AUC', roc_auc, epoch)
|
||||||
|
|
||||||
|
# confusion matrix
|
||||||
|
cm = confusion_matrix(true_class, perdicted_class)
|
||||||
|
tn, fp, fn, tp = cm.ravel()
|
||||||
|
writer.add_scalar('Eval/TrueNeg', tn, epoch)
|
||||||
|
writer.add_scalar('Eval/FalsePos', fp, epoch)
|
||||||
|
writer.add_scalar('Eval/FalseNeg', fn, epoch)
|
||||||
|
writer.add_scalar('Eval/TruePos', tp, epoch)
|
||||||
|
|
||||||
|
# specificity and sensitivity
|
||||||
|
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
|
||||||
|
sensitivity = recall # same as recall/TPR
|
||||||
|
writer.add_scalar('Eval/Specificity', specificity, epoch)
|
||||||
|
writer.add_scalar('Eval/Sensitivity', sensitivity, epoch)
|
||||||
|
|
||||||
|
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
|
||||||
|
ax1.matshow(cm, cmap='Blues', alpha=0.7)
|
||||||
|
for i in range(2):
|
||||||
|
for j in range(2):
|
||||||
|
ax1.text(j, i, str(cm[i, j]), ha='center', va='center', fontsize=14)
|
||||||
|
ax1.set_xlabel('Predicted')
|
||||||
|
ax1.set_ylabel('True')
|
||||||
|
ax1.set_title(f'Confusion Matrix (Epoch {epoch})')
|
||||||
|
ax1.set_xticks([0, 1])
|
||||||
|
ax1.set_yticks([0, 1])
|
||||||
|
ax1.set_xticklabels(['Human', 'Agent'])
|
||||||
|
ax1.set_yticklabels(['Human', 'Agent'])
|
||||||
|
|
||||||
|
# ROC curve
|
||||||
|
fpr, tpr, _ = roc_curve(true_class, predicted_proba)
|
||||||
|
ax2.plot(fpr, tpr, label=f'AUC={roc_auc:.3f}', linewidth=2)
|
||||||
|
ax2.plot([0, 1], [0, 1], 'k--', label='Random')
|
||||||
|
ax2.set_xlabel('False Positive Rate')
|
||||||
|
ax2.set_ylabel('True Positive Rate')
|
||||||
|
ax2.set_title('ROC Curve')
|
||||||
|
ax2.legend()
|
||||||
|
ax2.grid(alpha=0.3)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig(buf, format='png', dpi=100)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
img_arr = np.array(img)
|
||||||
|
writer.add_image('Eval/Metrics', img_arr, epoch, dataformats='HWC')
|
||||||
|
plt.close()
|
||||||
|
|
||||||
|
logger.info(f"Eval {epoch}: Acc={accuracy:.4f} Prec={precision:.4f} Rec={recall:.4f} F1={f1:.4f} AUC={roc_auc:.4f}")
|
||||||
6
experiments/ml/requirements.txt
Normal file
6
experiments/ml/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
torch
|
||||||
|
tensorboard
|
||||||
|
fastparquet
|
||||||
|
pyarrow
|
||||||
|
xgboost
|
||||||
|
lightgbm
|
||||||
137
experiments/ml/train.py
Normal file
137
experiments/ml/train.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
from sklearn.model_selection import train_test_split
|
||||||
|
from logging import getLogger
|
||||||
|
from pathlib import Path
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import joblib
|
||||||
|
from datetime import datetime
|
||||||
|
from ml.evals import evaluate, log_feature_importance
|
||||||
|
from ml.arch import XGBoostAgentClassifier, LightGBMAgentClassifier, LABELS
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
FEATURE_COLS_EXCLUDE = ['sessionId', 'experimentId', 'is_agent', 'xp_human_only', 'xp_market_mode', 'browser_family']
|
||||||
|
RUNS_DIR = Path('ml/runs')
|
||||||
|
CHECKPOINTS_DIR = Path('ml/checkpoints')
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_data(df):
|
||||||
|
"""
|
||||||
|
Prepare feature matrix and labels from raw dataframe
|
||||||
|
Handles missing labels, feature selection, and categorical encoding
|
||||||
|
Returns: (X, y, feature_cols)
|
||||||
|
"""
|
||||||
|
# drop rows with missing labels
|
||||||
|
n_before = len(df)
|
||||||
|
df = df[df['is_agent'].notna()].copy()
|
||||||
|
n_dropped = n_before - len(df)
|
||||||
|
if n_dropped > 0:
|
||||||
|
logger.warning(f"Dropped {n_dropped} sessions with missing labels")
|
||||||
|
|
||||||
|
if len(df) == 0:
|
||||||
|
logger.error("No labeled data available")
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
feature_cols = [c for c in df.columns if c not in FEATURE_COLS_EXCLUDE]
|
||||||
|
|
||||||
|
# handle categorical browser_family via one-hot encoding
|
||||||
|
if 'browser_family' in df.columns:
|
||||||
|
browser_dummies = pd.get_dummies(df['browser_family'], prefix='browser', drop_first=True)
|
||||||
|
df = pd.concat([df, browser_dummies], axis=1)
|
||||||
|
feature_cols.extend(browser_dummies.columns.tolist())
|
||||||
|
|
||||||
|
X = df[feature_cols].fillna(0)
|
||||||
|
y = df['is_agent'].astype(int)
|
||||||
|
|
||||||
|
return X, y, feature_cols
|
||||||
|
|
||||||
|
|
||||||
|
def train(data_path=None, model_type='xgboost', test_size=0.2, random_state=42,
|
||||||
|
n_estimators=200, max_depth=6, learning_rate=0.05):
|
||||||
|
"""
|
||||||
|
Train agent detection classifier
|
||||||
|
Args:
|
||||||
|
data_path: path to labeled feature matrix CSV or parquet
|
||||||
|
model_type: 'xgboost' or 'lightgbm'
|
||||||
|
test_size: fraction for test split
|
||||||
|
random_state: seed for reproducibility
|
||||||
|
"""
|
||||||
|
RUNS_DIR.mkdir(exist_ok=True)
|
||||||
|
CHECKPOINTS_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
run_name = f"{model_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||||
|
writer = SummaryWriter(log_dir=RUNS_DIR / run_name)
|
||||||
|
logger.info(f"Starting training run: {run_name}")
|
||||||
|
|
||||||
|
# load data
|
||||||
|
if data_path is None:
|
||||||
|
logger.error("data_path required")
|
||||||
|
return
|
||||||
|
df = pd.read_parquet(data_path)
|
||||||
|
logger.info(f"Loaded {len(df)} sessions from {data_path}")
|
||||||
|
|
||||||
|
# prepare features and labels
|
||||||
|
if 'is_agent' not in df.columns:
|
||||||
|
logger.error("Missing is_agent column")
|
||||||
|
return
|
||||||
|
|
||||||
|
X, y, feature_cols = prepare_data(df)
|
||||||
|
if X is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# class distribution
|
||||||
|
n_agents = y.sum()
|
||||||
|
n_humans = (y == 0).sum()
|
||||||
|
logger.info(f"Class distribution: {n_humans} humans, {n_agents} agents" + (f" (ratio {n_humans / n_agents:.2f})" if n_agents > 0 else ""))
|
||||||
|
|
||||||
|
# train/test split with stratification
|
||||||
|
X_train, X_test, y_train, y_test = train_test_split(
|
||||||
|
X, y, test_size=test_size, random_state=random_state, stratify=y
|
||||||
|
)
|
||||||
|
logger.info(f"Train: {len(X_train)}, Test: {len(X_test)}")
|
||||||
|
|
||||||
|
# init model
|
||||||
|
if model_type == 'xgboost':
|
||||||
|
model = XGBoostAgentClassifier(
|
||||||
|
n_estimators=n_estimators,
|
||||||
|
max_depth=max_depth,
|
||||||
|
learning_rate=learning_rate
|
||||||
|
)
|
||||||
|
elif model_type == 'lightgbm':
|
||||||
|
model = LightGBMAgentClassifier(
|
||||||
|
n_estimators=n_estimators,
|
||||||
|
max_depth=max_depth,
|
||||||
|
learning_rate=learning_rate
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"Unknown model type: {model_type}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# train with eval set for early stopping
|
||||||
|
model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
|
||||||
|
logger.info("Training complete")
|
||||||
|
|
||||||
|
# evaluate on test set
|
||||||
|
y_pred = model.predict(X_test)
|
||||||
|
y_prob = model.predict_proba(X_test)[:, 1]
|
||||||
|
|
||||||
|
evaluate(y_pred, y_prob, y_test, writer, epoch=0)
|
||||||
|
|
||||||
|
# log feature importance
|
||||||
|
log_feature_importance(writer, model, X.columns.tolist(), epoch=0)
|
||||||
|
|
||||||
|
# save model
|
||||||
|
model_path = CHECKPOINTS_DIR / f"{run_name}.pkl"
|
||||||
|
joblib.dump({'model': model, 'feature_cols': X.columns.tolist(), 'run_name': run_name}, model_path)
|
||||||
|
logger.info(f"Model saved to {model_path}")
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
return model, X.columns.tolist()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import sys
|
||||||
|
data_path = sys.argv[1]
|
||||||
|
model_type = sys.argv[2] if len(sys.argv) > 2 else 'xgboost'
|
||||||
|
train(data_path, model_type=model_type)
|
||||||
246
experiments/ml/weak_train.py
Normal file
246
experiments/ml/weak_train.py
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import sys
|
||||||
|
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
|
||||||
|
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
|
||||||
|
|
||||||
|
from sim.rl.behavior_loader.loader import AgentLoader, Loader, JointLoader, PayloadModel
|
||||||
|
from sim.rl.behavior_loader.models import JointBehaviorModel
|
||||||
|
from arch import ContrastiveWeakClassifier, contrastive_loss, featurize_trajectory
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from copy import deepcopy
|
||||||
|
import numpy as np
|
||||||
|
import random
|
||||||
|
import torch
|
||||||
|
from torch.utils.data import Dataset, DataLoader
|
||||||
|
from torch.optim import Adam
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
|
||||||
|
RUNS_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
|
||||||
|
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||||
|
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||||
|
|
||||||
|
|
||||||
|
def _perturb_ts(evt: PayloadModel, jitter_ms: int = 500) -> PayloadModel:
|
||||||
|
"""Add random jitter to event timestamp"""
|
||||||
|
new_evt = deepcopy(evt)
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(evt.ts.replace('Z', '+00:00'))
|
||||||
|
delta = timedelta(milliseconds=random.randint(-jitter_ms, jitter_ms))
|
||||||
|
new_evt.ts = (ts + delta).isoformat()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return new_evt
|
||||||
|
|
||||||
|
|
||||||
|
def augment_trajectory(trajectory: List[PayloadModel], rate: float = 0.1) -> List[PayloadModel]:
|
||||||
|
"""Apply random augmentation to trajectory for contrastive learning"""
|
||||||
|
if len(trajectory) < 2:
|
||||||
|
return trajectory
|
||||||
|
|
||||||
|
aug_type = random.choice(['window', 'shuffle', 'noise', 'drop'])
|
||||||
|
|
||||||
|
if aug_type == 'window': # random contiguous sub-sequence (70-100% length)
|
||||||
|
min_len = max(2, int(len(trajectory) * 0.7))
|
||||||
|
sub_len = random.randint(min_len, len(trajectory))
|
||||||
|
start = random.randint(0, len(trajectory) - sub_len)
|
||||||
|
return trajectory[start:start + sub_len]
|
||||||
|
|
||||||
|
elif aug_type == 'shuffle': # swap adjacent pairs with probability rate
|
||||||
|
result = list(trajectory)
|
||||||
|
for i in range(len(result) - 1):
|
||||||
|
if random.random() < rate:
|
||||||
|
result[i], result[i + 1] = result[i + 1], result[i]
|
||||||
|
return result
|
||||||
|
|
||||||
|
elif aug_type == 'drop': # drop events with probability rate
|
||||||
|
result = [e for e in trajectory if random.random() > rate]
|
||||||
|
return result if len(result) >= 2 else trajectory[:2]
|
||||||
|
|
||||||
|
elif aug_type == 'noise': # perturb timestamps
|
||||||
|
return [_perturb_ts(e, jitter_ms=500) for e in trajectory]
|
||||||
|
|
||||||
|
return trajectory
|
||||||
|
|
||||||
|
|
||||||
|
class TripletDataset(Dataset):
|
||||||
|
"""Generate (anchor, positive, negative) triplets on-the-fly with augmentation"""
|
||||||
|
def __init__(self, data: Dict[str, List[PayloadModel]], mdp: Optional[Dict], augment_fn, input_dim: int = 64, multiplier: int = 10):
|
||||||
|
self.sessions = list(data.items())
|
||||||
|
self.human_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('human_')]
|
||||||
|
self.agent_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('agent_')]
|
||||||
|
self.mdp = mdp
|
||||||
|
self.augment = augment_fn
|
||||||
|
self.input_dim = input_dim
|
||||||
|
self.multiplier = multiplier
|
||||||
|
|
||||||
|
if not self.human_ids or not self.agent_ids:
|
||||||
|
raise ValueError(f"Need both human ({len(self.human_ids)}) and agent ({len(self.agent_ids)}) sessions")
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.sessions) * self.multiplier
|
||||||
|
|
||||||
|
def __getitem__(self, idx: int):
|
||||||
|
anchor_idx = idx % len(self.sessions)
|
||||||
|
sid, events = self.sessions[anchor_idx]
|
||||||
|
is_human = sid.startswith('human_')
|
||||||
|
|
||||||
|
anchor = featurize_trajectory(events, self.mdp, self.input_dim)
|
||||||
|
positive = featurize_trajectory(self.augment(events), self.mdp, self.input_dim)
|
||||||
|
|
||||||
|
neg_pool = self.agent_ids if is_human else self.human_ids
|
||||||
|
neg_idx = random.choice(neg_pool)
|
||||||
|
negative = featurize_trajectory(self.sessions[neg_idx][1], self.mdp, self.input_dim)
|
||||||
|
|
||||||
|
label = 0 if is_human else 1 # 0=human, 1=agent
|
||||||
|
return (torch.tensor(anchor, dtype=torch.float32),
|
||||||
|
torch.tensor(positive, dtype=torch.float32),
|
||||||
|
torch.tensor(negative, dtype=torch.float32),
|
||||||
|
torch.tensor(label, dtype=torch.long))
|
||||||
|
|
||||||
|
|
||||||
|
def train(epochs: int = 100, lr: float = 1e-3, batch_size: int = 4, input_dim: int = 64,
|
||||||
|
embed_dim: int = 32, margin: float = 0.3, verbose: bool = True, run_name: str = None):
|
||||||
|
"""Train contrastive weak classifier on human/agent trajectories"""
|
||||||
|
joint = JointLoader(human_dir, agent_dir)
|
||||||
|
data = joint.get_data()
|
||||||
|
if verbose:
|
||||||
|
print(f"Loaded {len(data)} sessions")
|
||||||
|
|
||||||
|
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||||
|
ref_mdp = joint_model.build_MDP()
|
||||||
|
|
||||||
|
dataset = TripletDataset(data, ref_mdp, augment_trajectory, input_dim=input_dim)
|
||||||
|
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
|
||||||
|
|
||||||
|
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||||
|
model.to_device()
|
||||||
|
|
||||||
|
run_name = run_name or f"d{input_dim}_e{embed_dim}_lr{lr}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||||
|
writer = SummaryWriter(f"{RUNS_DIR}/train/{run_name}")
|
||||||
|
|
||||||
|
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||||
|
ce_loss_fn = torch.nn.CrossEntropyLoss()
|
||||||
|
|
||||||
|
best_loss = float('inf')
|
||||||
|
for epoch in range(epochs):
|
||||||
|
model.encoder.train()
|
||||||
|
model.classifier.train()
|
||||||
|
total_loss, n_batches = 0.0, 0
|
||||||
|
|
||||||
|
for anchor, positive, negative, labels in loader:
|
||||||
|
anchor, positive, negative, labels = [t.to(model.device) for t in [anchor, positive, negative, labels]]
|
||||||
|
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1)) for t in [anchor, positive, negative]]
|
||||||
|
|
||||||
|
trip_loss = contrastive_loss(z_a, z_p, z_n, margin=model.margin)
|
||||||
|
ce = ce_loss_fn(model.classifier(z_a), labels)
|
||||||
|
loss = trip_loss + 0.5 * ce
|
||||||
|
|
||||||
|
optimizer.zero_grad()
|
||||||
|
loss.backward()
|
||||||
|
optimizer.step()
|
||||||
|
total_loss += loss.item()
|
||||||
|
n_batches += 1
|
||||||
|
|
||||||
|
avg_loss = total_loss / max(n_batches, 1)
|
||||||
|
writer.add_scalar('loss', avg_loss, epoch)
|
||||||
|
|
||||||
|
if verbose and (epoch + 1) % 10 == 0:
|
||||||
|
print(f"Epoch {epoch+1}/{epochs}: loss={avg_loss:.4f}")
|
||||||
|
if avg_loss < best_loss:
|
||||||
|
best_loss = avg_loss
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
if verbose:
|
||||||
|
print(f"Done. Best={best_loss:.4f} TB:{RUNS_DIR}/train/{run_name}")
|
||||||
|
|
||||||
|
return model, ref_mdp
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_loocv(input_dim: int = 64, embed_dim: int = 32, epochs_per_fold: int = 50,
|
||||||
|
lr: float = 1e-3, margin: float = 0.3, run_name: str = None):
|
||||||
|
"""Leave-one-out cross-validation given limited samples"""
|
||||||
|
joint = JointLoader(human_dir, agent_dir)
|
||||||
|
data = joint.get_data()
|
||||||
|
session_ids = list(data.keys())
|
||||||
|
|
||||||
|
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||||
|
ref_mdp = joint_model.build_MDP()
|
||||||
|
|
||||||
|
run_name = run_name or f"loocv_d{input_dim}_e{embed_dim}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||||
|
writer = SummaryWriter(f"{RUNS_DIR}/eval/{run_name}")
|
||||||
|
|
||||||
|
predictions, actuals = [], []
|
||||||
|
|
||||||
|
for fold_idx, test_sid in enumerate(session_ids):
|
||||||
|
train_data = {k: v for k, v in data.items() if k != test_sid}
|
||||||
|
test_events = data[test_sid]
|
||||||
|
test_label = 0 if test_sid.startswith('human_') else 1
|
||||||
|
|
||||||
|
n_human = sum(1 for k in train_data if k.startswith('human_'))
|
||||||
|
n_agent = sum(1 for k in train_data if k.startswith('agent_'))
|
||||||
|
if n_human == 0 or n_agent == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
dataset = TripletDataset(train_data, ref_mdp, augment_trajectory, input_dim=input_dim, multiplier=5)
|
||||||
|
loader = DataLoader(dataset, batch_size=2, shuffle=True, drop_last=True)
|
||||||
|
|
||||||
|
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||||
|
model.to_device()
|
||||||
|
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||||
|
|
||||||
|
model.encoder.train()
|
||||||
|
model.classifier.train()
|
||||||
|
for _ in range(epochs_per_fold):
|
||||||
|
for anchor, positive, negative, labels in loader:
|
||||||
|
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1).to(model.device)) for t in [anchor, positive, negative]]
|
||||||
|
loss = contrastive_loss(z_a, z_p, z_n, margin=margin)
|
||||||
|
optimizer.zero_grad()
|
||||||
|
loss.backward()
|
||||||
|
optimizer.step()
|
||||||
|
|
||||||
|
test_feat = featurize_trajectory(test_events, ref_mdp, input_dim)
|
||||||
|
pred = model.predict(test_feat.reshape(1, -1))[0]
|
||||||
|
predictions.append(pred)
|
||||||
|
actuals.append(test_label)
|
||||||
|
print(f" {test_sid[:12]}...: pred={pred}, actual={test_label}, {'OK' if pred == test_label else 'MISS'}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
|
||||||
|
if predictions:
|
||||||
|
acc = sum(p == a for p, a in zip(predictions, actuals)) / len(predictions)
|
||||||
|
tp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 1)
|
||||||
|
fp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 0)
|
||||||
|
fn = sum(1 for p, a in zip(predictions, actuals) if p == 0 and a == 1)
|
||||||
|
prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
||||||
|
f1 = 2 * prec * rec / max(prec + rec, 1e-10)
|
||||||
|
writer.add_scalar('accuracy', acc, 0)
|
||||||
|
writer.add_scalar('f1', f1, 0)
|
||||||
|
writer.add_scalar('precision', prec, 0)
|
||||||
|
writer.add_scalar('recall', rec, 0)
|
||||||
|
writer.close()
|
||||||
|
print(f"\nAccuracy: {acc:.2%} F1: {f1:.3f} TB:{RUNS_DIR}/eval/{run_name}")
|
||||||
|
return acc, predictions, actuals
|
||||||
|
writer.close()
|
||||||
|
return 0.0, [], []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument('--mode', choices=['train', 'eval'], default='train')
|
||||||
|
parser.add_argument('--epochs', type=int, default=100)
|
||||||
|
parser.add_argument('--lr', type=float, default=1e-3)
|
||||||
|
parser.add_argument('--margin', type=float, default=0.3)
|
||||||
|
parser.add_argument('--input-dim', type=int, default=64)
|
||||||
|
parser.add_argument('--embed-dim', type=int, default=32)
|
||||||
|
parser.add_argument('--run-name', type=str, default=None)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.mode == 'train':
|
||||||
|
model, mdp = train(epochs=args.epochs, lr=args.lr, input_dim=args.input_dim,
|
||||||
|
embed_dim=args.embed_dim, margin=args.margin, run_name=args.run_name)
|
||||||
|
else:
|
||||||
|
evaluate_loocv(input_dim=args.input_dim, embed_dim=args.embed_dim, epochs_per_fold=args.epochs,
|
||||||
|
lr=args.lr, margin=args.margin, run_name=args.run_name)
|
||||||
@@ -1,19 +1,51 @@
|
|||||||
from .extract import (
|
from procesing.context import PipelineContext
|
||||||
KafkaDataFetcher,
|
from procesing.providers import DataProvider, SupabaseProvider, BackendAPIProvider
|
||||||
ExperimentJoiner,
|
from procesing.steps import (
|
||||||
EventTitleAugmenter,
|
BaseContextStep,
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
# StateSpace,
|
||||||
|
# BuildStateSpaceStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
)
|
||||||
|
from procesing.pipelines import (
|
||||||
|
interaction_extraction_pipeline,
|
||||||
|
price_extraction_pipeline,
|
||||||
|
pricing_pipeline,
|
||||||
|
full_pipeline,
|
||||||
)
|
)
|
||||||
from .demand import DemandEstimator
|
|
||||||
from .mapping import SessionTransitionProbMatrixTransformer, render_graph
|
|
||||||
from .pipeline import etl_pipeline, pricing_pipeline
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'KafkaDataFetcher',
|
'PipelineContext',
|
||||||
'ExperimentJoiner',
|
'DataProvider',
|
||||||
'EventTitleAugmenter',
|
'SupabaseProvider',
|
||||||
'DemandEstimator',
|
'BackendAPIProvider',
|
||||||
'SessionTransitionProbMatrixTransformer',
|
'BaseContextStep',
|
||||||
'render_graph',
|
'FetchInteractionsStep',
|
||||||
'etl_pipeline',
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
# 'StateSpace',
|
||||||
|
# 'BuildStateSpaceStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'interaction_extraction_pipeline',
|
||||||
|
'price_extraction_pipeline',
|
||||||
'pricing_pipeline',
|
'pricing_pipeline',
|
||||||
|
'full_pipeline',
|
||||||
]
|
]
|
||||||
|
|||||||
114
experiments/procesing/contaminator.py
Normal file
114
experiments/procesing/contaminator.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from lib.separability import estimate_alpha, load_artifacts, score_session
|
||||||
|
|
||||||
|
|
||||||
|
# use relative import when in package context, fallback for standalone
|
||||||
|
try:
|
||||||
|
from sim.rl.behavior_loader.models import AgentBehaviorModel
|
||||||
|
except ImportError:
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "sim" / "rl" / "behavior_loader"))
|
||||||
|
from models import AgentBehaviorModel
|
||||||
|
|
||||||
|
# paths should be configurable via environment or relative to project root
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', PROJECT_ROOT / "experiments" / "agents" / "collected_data"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
SEPARABILITY_ARTIFACTS = load_artifacts()
|
||||||
|
except FileNotFoundError:
|
||||||
|
SEPARABILITY_ARTIFACTS = None
|
||||||
|
|
||||||
|
|
||||||
|
def remap_schema(df: pd.DataFrame, mapping: dict, on: str = "event_type") -> pd.DataFrame:
|
||||||
|
"""remap column values according to mapping dict, preserving unmapped values"""
|
||||||
|
df = df.copy()
|
||||||
|
df[on] = df[on].map(mapping).fillna(df[on])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
|
||||||
|
events: list[SimpleNamespace] = []
|
||||||
|
for idx, state in enumerate(states):
|
||||||
|
parts = state.split("|") if isinstance(state, str) else ["page", "product", str(state)]
|
||||||
|
page = f"/{parts[0]}" if parts else "/"
|
||||||
|
product = parts[1] if len(parts) > 1 else "unknown"
|
||||||
|
event_name = parts[2] if len(parts) > 2 else parts[-1]
|
||||||
|
events.append(
|
||||||
|
SimpleNamespace(
|
||||||
|
eventName=event_name,
|
||||||
|
page=page,
|
||||||
|
productId=product,
|
||||||
|
ts=float(idx),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
||||||
|
contamination_rate: float = 0.1,
|
||||||
|
agent_data_dir: Path = None) -> pd.DataFrame:
|
||||||
|
"""inject synthetic agent trajectories into a dataset
|
||||||
|
contamination_rate: fraction of final dataset that should be agent data (0.1 = 10% agents)
|
||||||
|
"""
|
||||||
|
data_dir = agent_data_dir or AGENT_DATA_DIR
|
||||||
|
model = AgentBehaviorModel(str(data_dir))
|
||||||
|
model.build_MDP() # ensure MDP is built before sampling
|
||||||
|
|
||||||
|
# compute event distribution from original data
|
||||||
|
event_dist = df[on].value_counts(normalize=True).to_dict()
|
||||||
|
total = sum(event_dist.values())
|
||||||
|
event_dist = {k: v / total for k, v in event_dist.items()}
|
||||||
|
|
||||||
|
# calculate how many synthetic events to add
|
||||||
|
N = len(df)
|
||||||
|
N_final = N / (1 - contamination_rate)
|
||||||
|
N_contaminate = int(N_final - N)
|
||||||
|
|
||||||
|
# sample start states weighted by original distribution
|
||||||
|
start_events = random.choices(list(event_dist.keys()), weights=list(event_dist.values()), k=N_contaminate)
|
||||||
|
|
||||||
|
# generate synthetic trajectories
|
||||||
|
new_rows = []
|
||||||
|
alpha_estimates = []
|
||||||
|
|
||||||
|
for start_event in start_events:
|
||||||
|
# sample trajectory from agent model, using a state that contains the event type
|
||||||
|
mdp_states = model.mdp.get('states', []) if model.mdp else []
|
||||||
|
matching_starts = [s for s in mdp_states if start_event in s]
|
||||||
|
if not matching_starts:
|
||||||
|
continue # skip if no matching start state
|
||||||
|
start_state = random.choice(matching_starts)
|
||||||
|
trajectory = model.sample_traj(start_state, max_len=20)
|
||||||
|
score_payload: list[SimpleNamespace] = []
|
||||||
|
score: dict[str, float] = {}
|
||||||
|
if SEPARABILITY_ARTIFACTS:
|
||||||
|
score_payload = _states_to_events(trajectory)
|
||||||
|
score = score_session(score_payload, SEPARABILITY_ARTIFACTS)
|
||||||
|
alpha_estimates.append(
|
||||||
|
estimate_alpha(score["prob_agent"], score["delta_h"], score["delta_a"], temperature=2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
for state in trajectory:
|
||||||
|
parts = state.split('|') if isinstance(state, str) else [start_event]
|
||||||
|
new_rows.append({
|
||||||
|
on: parts[-1] if parts else start_event,
|
||||||
|
'source': 'synthetic_agent',
|
||||||
|
'prob_agent': score.get('prob_agent') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
'delta_h': score.get('delta_h') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
'delta_a': score.get('delta_a') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
if new_rows:
|
||||||
|
contaminate_df = pd.DataFrame(new_rows)
|
||||||
|
df = pd.concat([df, contaminate_df], ignore_index=True)
|
||||||
|
if alpha_estimates:
|
||||||
|
df['estimated_alpha'] = sum(alpha_estimates) / len(alpha_estimates)
|
||||||
|
return df
|
||||||
34
experiments/procesing/context.py
Normal file
34
experiments/procesing/context.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class PipelineContext:
|
||||||
|
"""
|
||||||
|
Context for pipeline execution holding config, provider, and cached data.
|
||||||
|
Enables dependency injection and eliminates global state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
provider: DataProvider,
|
||||||
|
store_mode: str,
|
||||||
|
window_size: str = '30s',
|
||||||
|
**config):
|
||||||
|
self.provider = provider
|
||||||
|
self.store_mode = store_mode
|
||||||
|
self.window_size = window_size
|
||||||
|
self.config = config
|
||||||
|
self._cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def get_cached(self, key: str, default=None):
|
||||||
|
return self._cache.get(key, default)
|
||||||
|
|
||||||
|
def cache(self, key: str, value):
|
||||||
|
self._cache[key] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def products(self) -> pd.DataFrame:
|
||||||
|
"""Lazy-load and cache product catalog, single fetch per pipeline run"""
|
||||||
|
if 'products' not in self._cache:
|
||||||
|
self._cache['products'] = self.provider.fetch_products(self.store_mode)
|
||||||
|
return self._cache['products']
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from typing import Optional, Literal
|
|
||||||
import os
|
|
||||||
import logging
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
||||||
|
|
||||||
class ChunkInteractionsIntoSteps(BaseEstimator, TransformerMixin):
|
|
||||||
"""
|
|
||||||
Split interaction data into time windows for temporal analysis.
|
|
||||||
Returns a list of dataframes, one per time window.
|
|
||||||
"""
|
|
||||||
def __init__(self,
|
|
||||||
window_size:str='1h',
|
|
||||||
ts_col:str='ts',
|
|
||||||
return_metadata:bool=True):
|
|
||||||
"""
|
|
||||||
Args:
|
|
||||||
window_size: pandas freq string ('1h', '30T', '1D', etc)
|
|
||||||
ts_col: timestamp column name
|
|
||||||
return_metadata: if True, return dict with metadata per chunk
|
|
||||||
"""
|
|
||||||
self.window_size = window_size
|
|
||||||
self.ts_col = ts_col
|
|
||||||
self.return_metadata = return_metadata
|
|
||||||
|
|
||||||
def fit(self, X):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, interactions: pd.DataFrame):
|
|
||||||
"""
|
|
||||||
Returns:
|
|
||||||
if return_metadata=False: list of dataframes, one per window
|
|
||||||
if return_metadata=True: list of dicts with keys:
|
|
||||||
- 'data': dataframe for this window
|
|
||||||
- 'window_start': start timestamp
|
|
||||||
- 'window_end': end timestamp
|
|
||||||
- 'window_idx': integer index
|
|
||||||
"""
|
|
||||||
if interactions.empty:
|
|
||||||
return []
|
|
||||||
|
|
||||||
df = interactions.copy()
|
|
||||||
|
|
||||||
# ensure timestamp is datetime
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(df[self.ts_col]):
|
|
||||||
df[self.ts_col] = pd.to_datetime(df[self.ts_col])
|
|
||||||
|
|
||||||
# sort by time
|
|
||||||
df = df.sort_values(self.ts_col)
|
|
||||||
|
|
||||||
# assign window
|
|
||||||
df['_window'] = df[self.ts_col].dt.floor(self.window_size)
|
|
||||||
|
|
||||||
# group by window
|
|
||||||
chunks = []
|
|
||||||
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
|
||||||
chunk_data = group.drop(columns=['_window'])
|
|
||||||
|
|
||||||
if self.return_metadata:
|
|
||||||
chunks.append({
|
|
||||||
'data': chunk_data,
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_start + pd.Timedelta(self.window_size),
|
|
||||||
'window_idx': idx
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
chunks.append(chunk_data)
|
|
||||||
|
|
||||||
return chunks
|
|
||||||
|
|
||||||
|
|
||||||
class DemandEstimator(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self,
|
|
||||||
store_mode:str='hotel',
|
|
||||||
session_filter:str="",
|
|
||||||
experiment_filter:str=""):
|
|
||||||
self.store=store_mode
|
|
||||||
self.session_filter=session_filter if len(session_filter)>0 else None
|
|
||||||
self.experiment_filter=experiment_filter if len(experiment_filter)>0 else None
|
|
||||||
def fit(self, X):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, interactions : pd.DataFrame):
|
|
||||||
if interactions.empty:
|
|
||||||
return pd.DataFrame(columns=["productId", "demand_score"])
|
|
||||||
if self.session_filter:
|
|
||||||
interactions = interactions[interactions['sessionId'] == self.session_filter]
|
|
||||||
if self.experiment_filter:
|
|
||||||
interactions = interactions[interactions['experimentId'] == self.experiment_filter]
|
|
||||||
|
|
||||||
products=supabase.table(f'{self.store}_products').select("id, room_type, date_index, metadata, availability").execute()
|
|
||||||
products = pd.DataFrame(products.data)
|
|
||||||
unique_products = products['id'].unique()
|
|
||||||
log.info(f"Demand estimator found {len(unique_products)} in data")
|
|
||||||
|
|
||||||
# filter out rows without productId
|
|
||||||
interactions_with_products = interactions.dropna(subset=['productId'])
|
|
||||||
|
|
||||||
if interactions_with_products.empty:
|
|
||||||
# no interactions with products, return all zeros
|
|
||||||
return pd.DataFrame({
|
|
||||||
'productId': unique_products,
|
|
||||||
'demand_score': 0
|
|
||||||
})
|
|
||||||
|
|
||||||
# TODO: improve demand score calculation rather than just counting interactions (use weights..)
|
|
||||||
# while maintaining simplicity of a simple cross tab approach
|
|
||||||
product_demand = pd.crosstab(interactions_with_products['productId'], "no_of_interactions")
|
|
||||||
product_demand = product_demand.reindex(unique_products, fill_value=0).reset_index()
|
|
||||||
product_demand.columns = ['productId', 'demand_score']
|
|
||||||
return product_demand
|
|
||||||
@@ -130,25 +130,24 @@ class TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
|||||||
|
|
||||||
def _build_product_timeseries(self, aligned_chunks):
|
def _build_product_timeseries(self, aligned_chunks):
|
||||||
"""Build time series [price, quantity] per product."""
|
"""Build time series [price, quantity] per product."""
|
||||||
series_by_product = {}
|
# vectorize chunk merging instead of iterating rows
|
||||||
|
all_merged = []
|
||||||
for chunk in aligned_chunks:
|
for chunk in aligned_chunks:
|
||||||
demand_df = chunk['demand']
|
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
||||||
price_df = chunk['prices']
|
merged['timestamp'] = chunk['window_start']
|
||||||
|
all_merged.append(merged[['productId', 'timestamp', 'price', 'demand_score']])
|
||||||
|
|
||||||
# merge on productId
|
if not all_merged:
|
||||||
merged = demand_df.merge(price_df, on='productId', how='inner')
|
return {}
|
||||||
|
|
||||||
for _, row in merged.iterrows():
|
# concat all chunks and group by productId in one pass
|
||||||
pid = row['productId']
|
combined = pd.concat(all_merged, ignore_index=True)
|
||||||
if pid not in series_by_product:
|
series_by_product = {
|
||||||
series_by_product[pid] = []
|
pid: group[['timestamp', 'price', 'demand_score']].rename(
|
||||||
|
columns={'demand_score': 'quantity'}
|
||||||
series_by_product[pid].append({
|
).to_dict('records')
|
||||||
'timestamp': chunk['window_start'],
|
for pid, group in combined.groupby('productId')
|
||||||
'price': row['price'],
|
}
|
||||||
'quantity': row['demand_score']
|
|
||||||
})
|
|
||||||
|
|
||||||
return series_by_product
|
return series_by_product
|
||||||
|
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import pandas as pd
|
|
||||||
import json
|
|
||||||
import numpy as np
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
from supabase import create_client, Client
|
|
||||||
from typing import Tuple, List, Dict
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:5000")
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
|
||||||
N_PRICE_BUCKETS = 5
|
|
||||||
|
|
||||||
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
||||||
|
|
||||||
|
|
||||||
class KafkaDataFetcher(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, topic: str = "user-interactions"):
|
|
||||||
self.topic = topic # also can be price-logs
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X=None):
|
|
||||||
resp = requests.get(f"{BACKEND_URL}/api/kafka/dump?topic={self.topic}")
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
|
|
||||||
if not data.get('success') or not data.get('data'):
|
|
||||||
return pd.DataFrame()
|
|
||||||
|
|
||||||
df = pd.DataFrame(data['data'])
|
|
||||||
if self.topic == 'user-interactions':
|
|
||||||
if 'metadata' in df.columns: # explode metadata col json
|
|
||||||
df = df.join(pd.json_normalize(df.pop("metadata"), sep=".").add_prefix("metadata_"))
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
# remape dateIndex
|
|
||||||
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class ExperimentJoiner(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
if df.empty or 'experimentId' not in df.columns:
|
|
||||||
return df
|
|
||||||
|
|
||||||
unique_exp_ids = df['experimentId'].dropna().unique()
|
|
||||||
if len(unique_exp_ids) == 0:
|
|
||||||
return df
|
|
||||||
|
|
||||||
resp = supabase.table('experiments').select(
|
|
||||||
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, task:tasks(task_name, task_description, task_def_of_done)'
|
|
||||||
).in_('id', unique_exp_ids.tolist()).execute()
|
|
||||||
|
|
||||||
if not resp.data:
|
|
||||||
return df
|
|
||||||
|
|
||||||
exp_df = pd.DataFrame(resp.data)
|
|
||||||
|
|
||||||
# flatten task nested object if present
|
|
||||||
if 'task' in exp_df.columns and exp_df['task'].notnull().any():
|
|
||||||
task_normalized = pd.json_normalize(exp_df['task'].dropna())
|
|
||||||
task_normalized.index = exp_df[exp_df['task'].notnull()].index
|
|
||||||
exp_df = exp_df.drop(columns=['task']).join(task_normalized, rsuffix='_task')
|
|
||||||
|
|
||||||
# rename experiment columns for clarity
|
|
||||||
exp_df = exp_df.rename(columns={
|
|
||||||
'id': 'experimentId',
|
|
||||||
'subject_name': 'exp_subject',
|
|
||||||
'xp_human_only': 'exp_human_only',
|
|
||||||
'xp_market_mode': 'exp_market_mode',
|
|
||||||
'xp_task_id': 'exp_task_id'
|
|
||||||
})
|
|
||||||
|
|
||||||
df = df.merge(exp_df, on='experimentId', how='left')
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
class EventTitleAugmenter(BaseEstimator, TransformerMixin):
|
|
||||||
def fit(self, X=None, y=None):
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, df):
|
|
||||||
# from taking standard view_item_page in eventName to view_item_page_{metadata_schema}
|
|
||||||
# we want metadata schema to create product specific event names
|
|
||||||
|
|
||||||
# only create price buckets if we have enough unique prices
|
|
||||||
if df["metadata_price"].notnull().sum() > 0:
|
|
||||||
try:
|
|
||||||
price_buckets = pd.qcut(
|
|
||||||
df["metadata_price"],
|
|
||||||
q=N_PRICE_BUCKETS,
|
|
||||||
labels=[f"PB_{i+1}" for i in range(N_PRICE_BUCKETS)],
|
|
||||||
duplicates='drop' # handle duplicate bin edges
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
# fallback: if still not enough unique values, use cut with fixed ranges or just use raw price
|
|
||||||
price_buckets = df["metadata_price"].apply(lambda x: f"P_{int(x)}" if pd.notnull(x) else "")
|
|
||||||
else:
|
|
||||||
price_buckets = pd.Series([""] * len(df), index=df.index)
|
|
||||||
|
|
||||||
# metadata_schema: _product_id@price_bucket_{i} only if we have product metadata otherswise keep original event name
|
|
||||||
# TODO: make this adaptive, if we have hover_over_title we append the title, if its view_page we say which page
|
|
||||||
df["metadata_schema"] = np.where(
|
|
||||||
df["productId"].notnull() & df["metadata_price"].notnull(),
|
|
||||||
"_" + df["productId"].astype(str) + "@" + price_buckets.astype(str),
|
|
||||||
""
|
|
||||||
)
|
|
||||||
df["eventName"] = df["eventName"] + df["metadata_schema"].astype(str)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def chunk_shared_data(interactions_df: pd.DataFrame,
|
|
||||||
price_logs_df: pd.DataFrame,
|
|
||||||
window_size: str = '30s',
|
|
||||||
ts_col: str = 'ts') -> Tuple[List[Dict], List[Dict]]:
|
|
||||||
"""
|
|
||||||
Chunk interaction and price data into aligned time windows.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interactions_df: interaction data with timestamp column
|
|
||||||
price_logs_df: price log data with timestamp column
|
|
||||||
window_size: pandas freq string ('30s', '1min', '1h', etc)
|
|
||||||
ts_col: name of timestamp column
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
tuple of (interaction_chunks, price_chunks) where each is list of dicts:
|
|
||||||
{
|
|
||||||
'window_start': timestamp,
|
|
||||||
'window_end': timestamp,
|
|
||||||
'data': dataframe for this window
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
if interactions_df.empty and price_logs_df.empty:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# convert timestamps to datetime
|
|
||||||
interactions_df = interactions_df.copy()
|
|
||||||
price_logs_df = price_logs_df.copy()
|
|
||||||
|
|
||||||
if not interactions_df.empty:
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(interactions_df[ts_col]):
|
|
||||||
interactions_df[ts_col] = pd.to_datetime(interactions_df[ts_col])
|
|
||||||
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
if not pd.api.types.is_datetime64_any_dtype(price_logs_df[ts_col]):
|
|
||||||
price_logs_df[ts_col] = pd.to_datetime(price_logs_df[ts_col])
|
|
||||||
|
|
||||||
# find global time bounds
|
|
||||||
times = []
|
|
||||||
if not interactions_df.empty:
|
|
||||||
times.extend([interactions_df[ts_col].min(), interactions_df[ts_col].max()])
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
times.extend([price_logs_df[ts_col].min(), price_logs_df[ts_col].max()])
|
|
||||||
|
|
||||||
if not times:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
earliest = min(times)
|
|
||||||
latest = max(times)
|
|
||||||
|
|
||||||
# create shared time windows
|
|
||||||
windows = pd.date_range(start=earliest, end=latest, freq=window_size)
|
|
||||||
|
|
||||||
if len(windows) < 2:
|
|
||||||
return [], []
|
|
||||||
|
|
||||||
# chunk both datasets
|
|
||||||
interaction_chunks = []
|
|
||||||
price_chunks = []
|
|
||||||
|
|
||||||
for i in range(len(windows) - 1):
|
|
||||||
window_start = windows[i]
|
|
||||||
window_end = windows[i + 1]
|
|
||||||
|
|
||||||
# filter interactions in this window
|
|
||||||
if not interactions_df.empty:
|
|
||||||
mask = (interactions_df[ts_col] >= window_start) & (interactions_df[ts_col] < window_end)
|
|
||||||
interaction_chunk = interactions_df[mask]
|
|
||||||
else:
|
|
||||||
interaction_chunk = pd.DataFrame()
|
|
||||||
|
|
||||||
interaction_chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_end,
|
|
||||||
'data': interaction_chunk
|
|
||||||
})
|
|
||||||
|
|
||||||
# filter price logs in this window
|
|
||||||
if not price_logs_df.empty:
|
|
||||||
mask = (price_logs_df[ts_col] >= window_start) & (price_logs_df[ts_col] < window_end)
|
|
||||||
price_chunk = price_logs_df[mask]
|
|
||||||
else:
|
|
||||||
price_chunk = pd.DataFrame()
|
|
||||||
|
|
||||||
price_chunks.append({
|
|
||||||
'window_start': window_start,
|
|
||||||
'window_end': window_end,
|
|
||||||
'data': price_chunk
|
|
||||||
})
|
|
||||||
|
|
||||||
return interaction_chunks, price_chunks
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
from sklearn.base import BaseEstimator, TransformerMixin
|
|
||||||
|
|
||||||
def build_transition_prob_matrix(df: pd.DataFrame):
|
|
||||||
df = df.dropna(subset=['eventName'])
|
|
||||||
events = df['eventName'].tolist()
|
|
||||||
labels = pd.Index(events).unique().tolist()
|
|
||||||
idx = {e:i for i,e in enumerate(labels)}
|
|
||||||
M = np.zeros((len(labels), len(labels)), dtype=float)
|
|
||||||
for a, b in zip(events, events[1:]):
|
|
||||||
M[idx[a], idx[b]] += 1
|
|
||||||
row_sums = M.sum(axis=1, keepdims=True)
|
|
||||||
with np.errstate(divide='ignore', invalid='ignore'):
|
|
||||||
P = np.divide(M, row_sums, where=row_sums>0) # row-normalized
|
|
||||||
return P, labels
|
|
||||||
|
|
||||||
# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b
|
|
||||||
from graphviz import Digraph
|
|
||||||
import numpy as np
|
|
||||||
import pandas as pd
|
|
||||||
|
|
||||||
def _as_prob_df(matrix, labels=None):
|
|
||||||
"""Return a square DataFrame with index=columns=labels."""
|
|
||||||
if isinstance(matrix, pd.DataFrame):
|
|
||||||
# Ensure square and aligned
|
|
||||||
assert (matrix.index == matrix.columns).all(), "Index/columns must match."
|
|
||||||
return matrix
|
|
||||||
matrix = np.asarray(matrix, dtype=float)
|
|
||||||
assert matrix.shape[0] == matrix.shape[1], "Matrix must be square."
|
|
||||||
if labels is None:
|
|
||||||
raise ValueError("labels are required when matrix is not a DataFrame")
|
|
||||||
assert len(labels) == matrix.shape[0], "labels length must match matrix size."
|
|
||||||
return pd.DataFrame(matrix, index=list(labels), columns=list(labels))
|
|
||||||
|
|
||||||
def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):
|
|
||||||
"""Build weighted edges > threshold."""
|
|
||||||
edges = []
|
|
||||||
for src in P.index:
|
|
||||||
for dst in P.columns:
|
|
||||||
w = float(P.loc[src, dst])
|
|
||||||
if w > threshold:
|
|
||||||
edges.append((str(src), str(dst), f"{w:.{round_digits}f}"))
|
|
||||||
return edges
|
|
||||||
|
|
||||||
def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt="svg", view=False):
|
|
||||||
"""
|
|
||||||
fname: output file stem (no extension)
|
|
||||||
matrix: NumPy array or pandas DataFrame of transition PROBABILITIES
|
|
||||||
ls_index: ordered labels (required if matrix is not a DataFrame)
|
|
||||||
threshold: hide edges with weight <= threshold
|
|
||||||
fmt: 'svg'|'png'|'pdf' etc.
|
|
||||||
view: open after rendering
|
|
||||||
"""
|
|
||||||
P = _as_prob_df(matrix, labels=ls_index)
|
|
||||||
edges = _df_to_edgelist(P, threshold=threshold)
|
|
||||||
|
|
||||||
g = Digraph(format=fmt)
|
|
||||||
g.attr(rankdir="LR", size="30")
|
|
||||||
g.attr("node", shape="circle")
|
|
||||||
|
|
||||||
# ensure isolated nodes appear
|
|
||||||
for node in P.index:
|
|
||||||
g.node(str(node), width="1", height="1")
|
|
||||||
|
|
||||||
for src, dst, label in edges:
|
|
||||||
g.edge(src, dst, label=label)
|
|
||||||
|
|
||||||
g.render(fname, view=view, cleanup=True)
|
|
||||||
return g
|
|
||||||
|
|
||||||
|
|
||||||
class TransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.P_ = None
|
|
||||||
self.labels_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
P, labels = build_transition_prob_matrix(X)
|
|
||||||
self.P_ = P
|
|
||||||
self.labels_ = labels
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
return self.P_, self.labels_
|
|
||||||
|
|
||||||
def render(self, fname: str, fmt="svg", view=False):
|
|
||||||
if self.P_ is None or self.labels_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
self.P_,
|
|
||||||
ls_index=self.labels_,
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SessionTransitionProbMatrixTransformer(BaseEstimator, TransformerMixin):
|
|
||||||
def __init__(self, threshold=0.0, session_col='sessionId'):
|
|
||||||
self.threshold = threshold
|
|
||||||
self.session_col = session_col
|
|
||||||
self.session_matrices_ = None
|
|
||||||
|
|
||||||
def fit(self, X: pd.DataFrame, y=None):
|
|
||||||
if self.session_col not in X.columns:
|
|
||||||
raise ValueError(f"Column '{self.session_col}' not found in DataFrame")
|
|
||||||
|
|
||||||
session_matrices = {}
|
|
||||||
for session_id, grp in X.groupby(self.session_col):
|
|
||||||
if len(grp) > 1: # need at least 2 events for transitions
|
|
||||||
P, labels = build_transition_prob_matrix(grp)
|
|
||||||
session_matrices[session_id] = {'matrix': P, 'labels': labels}
|
|
||||||
|
|
||||||
self.session_matrices_ = session_matrices
|
|
||||||
return self
|
|
||||||
|
|
||||||
def transform(self, X: pd.DataFrame = None):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
return pd.Series(self.session_matrices_)
|
|
||||||
|
|
||||||
def render_session(self, session_id: str, fname: str, fmt="svg", view=False):
|
|
||||||
if self.session_matrices_ is None:
|
|
||||||
raise ValueError("Transformer has not been fitted yet.")
|
|
||||||
if session_id not in self.session_matrices_:
|
|
||||||
raise ValueError(f"Session '{session_id}' not found in fitted data.")
|
|
||||||
|
|
||||||
sess_data = self.session_matrices_[session_id]
|
|
||||||
return render_graph(
|
|
||||||
fname,
|
|
||||||
sess_data['matrix'],
|
|
||||||
ls_index=sess_data['labels'],
|
|
||||||
threshold=self.threshold,
|
|
||||||
fmt=fmt,
|
|
||||||
view=view
|
|
||||||
)
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Example usage
|
|
||||||
data = {
|
|
||||||
'eventName': [
|
|
||||||
'A', 'B', 'A', 'C', 'B', 'A', 'A', 'C', 'B', 'C',
|
|
||||||
'A', 'B', 'C', 'A', 'B', 'C', 'A', 'B', 'C', 'A'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
df = pd.DataFrame(data)
|
|
||||||
|
|
||||||
transformer = TransitionProbMatrixTransformer(threshold=0.1)
|
|
||||||
transformer.fit(df)
|
|
||||||
P, labels = transformer.transform(None)
|
|
||||||
|
|
||||||
print("Transition Probability Matrix:")
|
|
||||||
print(pd.DataFrame(P, index=labels, columns=labels))
|
|
||||||
|
|
||||||
# Render the graph
|
|
||||||
transformer.render("transition_graph", fmt="svg", view=False)
|
|
||||||
245
experiments/procesing/metrics.py
Normal file
245
experiments/procesing/metrics.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Revenue and KPI benchmark framework for pricing strategies.
|
||||||
|
|
||||||
|
Computes session-level and aggregate metrics to compare pricing functions:
|
||||||
|
- Revenue: R_T = Σ P_t^T · Q_t
|
||||||
|
- Conversion rate
|
||||||
|
- Average order value (AOV)
|
||||||
|
- Agent exploitation loss: L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionMetrics:
|
||||||
|
"""KPIs for single session."""
|
||||||
|
session_id: str
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
|
||||||
|
# interaction metrics
|
||||||
|
total_interactions: int = 0
|
||||||
|
page_views: int = 0
|
||||||
|
item_views: int = 0
|
||||||
|
searches: int = 0
|
||||||
|
cart_adds: int = 0
|
||||||
|
|
||||||
|
# revenue metrics
|
||||||
|
items_purchased: int = 0
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_item_price: float = 0.0
|
||||||
|
conversion_rate: float = 0.0
|
||||||
|
|
||||||
|
# pricing signals
|
||||||
|
total_price_shown: float = 0.0 # sum of all prices displayed
|
||||||
|
avg_markup: float = 0.0 # avg (price / base_price)
|
||||||
|
|
||||||
|
# behavioral features (for agent detection)
|
||||||
|
interaction_velocity: float = 0.0 # interactions per minute
|
||||||
|
session_duration_sec: float = 0.0
|
||||||
|
unique_products_viewed: int = 0
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregateMetrics:
|
||||||
|
"""Aggregate KPIs across sessions/experiments."""
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
n_sessions: int = 0
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_revenue_per_session: float = 0.0
|
||||||
|
median_revenue_per_session: float = 0.0
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
total_conversions: int = 0
|
||||||
|
conversion_rate: float = 0.0 # purchases / sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
avg_markup: float = 0.0
|
||||||
|
median_markup: float = 0.0
|
||||||
|
|
||||||
|
# agent exploitation metrics
|
||||||
|
estimated_agent_sessions: int = 0 # sessions flagged as agent-driven
|
||||||
|
agent_revenue: float = 0.0
|
||||||
|
human_revenue: float = 0.0
|
||||||
|
agent_loss: float = 0.0 # L_agent = R_oracle - R_observed (if available)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsComputer:
|
||||||
|
"""Compute session and aggregate metrics from interaction/price logs."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_session_metrics(
|
||||||
|
session_id: str,
|
||||||
|
interactions: pd.DataFrame,
|
||||||
|
price_logs: pd.DataFrame,
|
||||||
|
purchases: Optional[pd.DataFrame] = None,
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
) -> SessionMetrics:
|
||||||
|
"""
|
||||||
|
Compute metrics for single session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: session identifier
|
||||||
|
interactions: user-interactions events for this session
|
||||||
|
price_logs: price-logs for this session
|
||||||
|
purchases: purchase events (if available)
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
"""
|
||||||
|
metrics = SessionMetrics(session_id=session_id, experiment_id=experiment_id)
|
||||||
|
|
||||||
|
if interactions.empty:
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
# interaction counts
|
||||||
|
event_counts = interactions['eventName'].value_counts().to_dict()
|
||||||
|
metrics.total_interactions = len(interactions)
|
||||||
|
metrics.page_views = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
||||||
|
metrics.item_views = event_counts.get('view_item_page', 0)
|
||||||
|
metrics.searches = event_counts.get('search', 0)
|
||||||
|
metrics.cart_adds = event_counts.get('add_item_to_cart', 0)
|
||||||
|
|
||||||
|
# unique products viewed
|
||||||
|
metrics.unique_products_viewed = interactions['productId'].dropna().nunique()
|
||||||
|
|
||||||
|
# session duration
|
||||||
|
if 'ts' in interactions.columns:
|
||||||
|
timestamps = pd.to_datetime(interactions['ts'])
|
||||||
|
metrics.session_duration_sec = (timestamps.max() - timestamps.min()).total_seconds()
|
||||||
|
if metrics.session_duration_sec > 0:
|
||||||
|
metrics.interaction_velocity = (metrics.total_interactions / metrics.session_duration_sec) * 60
|
||||||
|
|
||||||
|
# revenue from purchases
|
||||||
|
if purchases is not None and not purchases.empty:
|
||||||
|
metrics.items_purchased = len(purchases)
|
||||||
|
metrics.total_revenue = purchases['price'].sum() if 'price' in purchases.columns else 0.0
|
||||||
|
metrics.avg_item_price = metrics.total_revenue / metrics.items_purchased if metrics.items_purchased > 0 else 0.0
|
||||||
|
metrics.conversion_rate = 1.0 if metrics.items_purchased > 0 else 0.0
|
||||||
|
|
||||||
|
# pricing metrics
|
||||||
|
if not price_logs.empty:
|
||||||
|
metrics.total_price_shown = price_logs['price'].sum()
|
||||||
|
# compute markup if base_price available in price logs or join with product catalog
|
||||||
|
if 'base_price' in price_logs.columns:
|
||||||
|
valid_markup = price_logs[price_logs['base_price'] > 0]
|
||||||
|
if not valid_markup.empty:
|
||||||
|
metrics.avg_markup = (valid_markup['price'] / valid_markup['base_price']).mean()
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_aggregate_metrics(
|
||||||
|
session_metrics_list: List[SessionMetrics],
|
||||||
|
experiment_id: Optional[str] = None,
|
||||||
|
agent_detector_fn: Optional[callable] = None
|
||||||
|
) -> AggregateMetrics:
|
||||||
|
"""
|
||||||
|
Aggregate metrics across sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics_list: list of SessionMetrics
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
agent_detector_fn: optional function to classify session as agent (returns bool)
|
||||||
|
"""
|
||||||
|
agg = AggregateMetrics(experiment_id=experiment_id)
|
||||||
|
agg.n_sessions = len(session_metrics_list)
|
||||||
|
|
||||||
|
if agg.n_sessions == 0:
|
||||||
|
return agg
|
||||||
|
|
||||||
|
df = pd.DataFrame([m.to_dict() for m in session_metrics_list])
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
agg.total_revenue = df['total_revenue'].sum()
|
||||||
|
agg.avg_revenue_per_session = df['total_revenue'].mean()
|
||||||
|
agg.median_revenue_per_session = df['total_revenue'].median()
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
agg.total_conversions = (df['items_purchased'] > 0).sum()
|
||||||
|
agg.conversion_rate = agg.total_conversions / agg.n_sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
valid_markups = df[df['avg_markup'] > 0]
|
||||||
|
if not valid_markups.empty:
|
||||||
|
agg.avg_markup = valid_markups['avg_markup'].mean()
|
||||||
|
agg.median_markup = valid_markups['avg_markup'].median()
|
||||||
|
|
||||||
|
# agent detection (if detector provided)
|
||||||
|
if agent_detector_fn is not None:
|
||||||
|
agent_flags = [agent_detector_fn(m) for m in session_metrics_list]
|
||||||
|
agg.estimated_agent_sessions = sum(agent_flags)
|
||||||
|
|
||||||
|
agent_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if is_agent)
|
||||||
|
human_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if not is_agent)
|
||||||
|
|
||||||
|
agg.agent_revenue = agent_revenue
|
||||||
|
agg.human_revenue = human_revenue
|
||||||
|
|
||||||
|
return agg
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compare_pricing_strategies(
|
||||||
|
experiments: Dict[str, List[SessionMetrics]],
|
||||||
|
baseline_experiment_id: Optional[str] = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Compare multiple pricing strategies/experiments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
experiments: dict mapping experiment_id -> list of SessionMetrics
|
||||||
|
baseline_experiment_id: experiment to use as baseline for comparison
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with comparative metrics
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
baseline_agg = None
|
||||||
|
|
||||||
|
for exp_id, session_metrics in experiments.items():
|
||||||
|
agg = MetricsComputer.compute_aggregate_metrics(session_metrics, experiment_id=exp_id)
|
||||||
|
result = agg.to_dict()
|
||||||
|
|
||||||
|
if exp_id == baseline_experiment_id:
|
||||||
|
baseline_agg = agg
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
df = pd.DataFrame(results)
|
||||||
|
|
||||||
|
# add relative metrics if baseline exists
|
||||||
|
if baseline_agg is not None:
|
||||||
|
df['revenue_lift_pct'] = ((df['total_revenue'] - baseline_agg.total_revenue) / baseline_agg.total_revenue * 100)
|
||||||
|
df['conversion_lift_pct'] = ((df['conversion_rate'] - baseline_agg.conversion_rate) / baseline_agg.conversion_rate * 100)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def simple_agent_detector(session_metrics: SessionMetrics, velocity_threshold: float = 5.0) -> bool:
|
||||||
|
"""
|
||||||
|
Simple heuristic agent detector based on interaction velocity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics: SessionMetrics instance
|
||||||
|
velocity_threshold: interactions per minute threshold (default: 5.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session likely agent-driven
|
||||||
|
"""
|
||||||
|
# agents tend to have higher interaction velocity and lower session duration
|
||||||
|
if session_metrics.interaction_velocity > velocity_threshold:
|
||||||
|
return True
|
||||||
|
# agents often view many products quickly without converting
|
||||||
|
if session_metrics.unique_products_viewed > 10 and session_metrics.conversion_rate == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from sklearn.pipeline import Pipeline
|
|
||||||
from sklearn.preprocessing import StandardScaler
|
|
||||||
import pandas as pd
|
|
||||||
import logging
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from extract import KafkaDataFetcher, ExperimentJoiner, EventTitleAugmenter, chunk_shared_data
|
|
||||||
from mapping import SessionTransitionProbMatrixTransformer, render_graph
|
|
||||||
from demand import DemandEstimator, ChunkInteractionsIntoSteps
|
|
||||||
from elasticity import TemporalElasticityEstimator, aggregate_price_logs
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# elasticity pipeline components (not sklearn compatible, manual orchestration)
|
|
||||||
def elasticity_pipeline(interactions_df, price_logs_df, window_size='30s', store_mode='hotel'):
|
|
||||||
"""
|
|
||||||
Compute price elasticity from interaction and price data.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
interactions_df: raw interaction data from demand_data_pipeline
|
|
||||||
price_logs_df: price log data from price_data_pipeline
|
|
||||||
window_size: time window for chunking
|
|
||||||
store_mode: 'hotel' or 'airline'
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
df with [productId, elasticity, std_error, n_obs]
|
|
||||||
"""
|
|
||||||
# step 1: chunk interactions into time windows
|
|
||||||
chunker = ChunkInteractionsIntoSteps(window_size=window_size, return_metadata=True)
|
|
||||||
interaction_chunks = chunker.transform(interactions_df)
|
|
||||||
log.info(f"Chunked interactions into {len(interaction_chunks)} windows of size {window_size}")
|
|
||||||
|
|
||||||
if not interaction_chunks:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# step 2: compute demand per window
|
|
||||||
demand_estimator = DemandEstimator(store_mode=store_mode)
|
|
||||||
demand_chunks = []
|
|
||||||
for chunk in interaction_chunks:
|
|
||||||
demand_vector = demand_estimator.transform(chunk['data'])
|
|
||||||
demand_chunks.append({
|
|
||||||
'window_start': chunk['window_start'],
|
|
||||||
'window_end': chunk['window_end'],
|
|
||||||
'demand_vector': demand_vector # each has a full list of all products, even if demand is 0
|
|
||||||
})
|
|
||||||
# [q_chunk1, q_chunk2, ...]
|
|
||||||
|
|
||||||
# step 3: aggregate price logs into windows
|
|
||||||
price_chunks = aggregate_price_logs(price_logs_df, window_size=window_size)
|
|
||||||
|
|
||||||
# step 4: compute elasticity
|
|
||||||
elasticity_estimator = TemporalElasticityEstimator(method='point', min_observations=2)
|
|
||||||
elasticity_df = elasticity_estimator.transform(demand_chunks, price_chunks, store_mode=store_mode)
|
|
||||||
|
|
||||||
return elasticity_df
|
|
||||||
|
|
||||||
|
|
||||||
# exposable pipelines
|
|
||||||
interaction_pipeline = Pipeline([
|
|
||||||
('kafka_fetch', KafkaDataFetcher(topic='user-interactions')),
|
|
||||||
('experiment_join', ExperimentJoiner()),
|
|
||||||
('event_augment', EventTitleAugmenter()),
|
|
||||||
])
|
|
||||||
|
|
||||||
price_data_pipeline = Pipeline([
|
|
||||||
('kafka_fetch', KafkaDataFetcher(topic='price-logs')),
|
|
||||||
])
|
|
||||||
|
|
||||||
# interaction_data + price_data -> elasticity (demand)
|
|
||||||
# elasticity -> pricing
|
|
||||||
|
|
||||||
pricing_pipeline = Pipeline([
|
|
||||||
('demand_estimation', DemandEstimator()),
|
|
||||||
])
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# fetch both datasets
|
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
|
||||||
pricing_data = price_data_pipeline.fit_transform(None)
|
|
||||||
if interaction_data.empty or pricing_data.empty:
|
|
||||||
print("Insufficient data for elasticity computation"); exit(0)
|
|
||||||
# compute elasticity via unified pipeline
|
|
||||||
window_size = "30s"
|
|
||||||
elasticity_results = elasticity_pipeline(interaction_data, pricing_data, window_size=window_size)
|
|
||||||
elasticity_value_array = elasticity_results['elasticity'].values if elasticity_results is not None else np.array([])
|
|
||||||
print(elasticity_value_array)
|
|
||||||
|
|
||||||
if elasticity_results is not None and not elasticity_results.empty:
|
|
||||||
print(elasticity_results.to_string(index=False))
|
|
||||||
else:
|
|
||||||
print("\nInsufficient data for elasticity computation")
|
|
||||||
174
experiments/procesing/pipelines.py
Normal file
174
experiments/procesing/pipelines.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
import os
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
ExtractSessionFeaturesStep,
|
||||||
|
JoinLabelsStep,
|
||||||
|
ValidateDataStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers import SimpleSurgePricer
|
||||||
|
|
||||||
|
def interaction_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting and augmenting interaction data"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchInteractionsStep(context)),
|
||||||
|
('create_buckets', CreatePriceBucketsStep(context)),
|
||||||
|
('augment_events', AugmentEventNamesStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def price_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting price logs"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchPriceLogsStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def product_features_pipeline(context: PipelineContext,
|
||||||
|
interactions_df: pd.DataFrame,
|
||||||
|
price_logs_df: pd.DataFrame):
|
||||||
|
demand_step = ComputeDemandStep(context)
|
||||||
|
price_step = AggregatePriceLogsStep(context)
|
||||||
|
join_step = JoinProductFeaturesStep(context)
|
||||||
|
|
||||||
|
|
||||||
|
demand_data = demand_step.transform(interactions_df)
|
||||||
|
price_data= price_step.transform(price_logs_df)
|
||||||
|
joined_data = join_step.transform((demand_data, price_data))
|
||||||
|
|
||||||
|
return joined_data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def pricing_pipeline(context: "PipelineContext",
|
||||||
|
data: pd.DataFrame,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9) -> pd.DataFrame:
|
||||||
|
|
||||||
|
if data.empty or 'productId' not in data.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer()
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def full_pipeline(context: PipelineContext,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
"""
|
||||||
|
Complete end-to-end pipeline: data extraction -> demand/price aggregation -> surge pricing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Pipeline context
|
||||||
|
high_threshold: Demand threshold for surge pricing
|
||||||
|
low_threshold: Demand threshold for discounts
|
||||||
|
surge_multiplier: Price multiplier for high demand
|
||||||
|
discount_multiplier: Price multiplier for low demand
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (product_features_df, optimal_prices_df)
|
||||||
|
- product_features_df: [productId, demand_score, price]
|
||||||
|
- optimal_prices_df: [productId, current_price, optimal_price, demand_score]
|
||||||
|
"""
|
||||||
|
interaction_pipe = interaction_extraction_pipeline(context)
|
||||||
|
price_pipe = price_extraction_pipeline(context)
|
||||||
|
|
||||||
|
interactions_df = interaction_pipe.fit_transform(None)
|
||||||
|
price_logs_df = price_pipe.fit_transform(None)
|
||||||
|
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||||
|
print(product_features_df.to_string())
|
||||||
|
|
||||||
|
# generate optimal prices using surge rules
|
||||||
|
optimal_prices_df = pricing_pipeline(context, product_features_df,
|
||||||
|
high_threshold=high_threshold,
|
||||||
|
low_threshold=low_threshold,
|
||||||
|
surge_multiplier=surge_multiplier,
|
||||||
|
discount_multiplier=discount_multiplier)
|
||||||
|
|
||||||
|
return product_features_df, optimal_prices_df
|
||||||
|
|
||||||
|
|
||||||
|
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Build labeled session-level feature matrix for ML model training.
|
||||||
|
Pipeline: fetch -> validate -> extract features -> join labels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with ~25 features per session + is_agent label
|
||||||
|
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||||
|
"""
|
||||||
|
# fetch raw interactions
|
||||||
|
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||||
|
|
||||||
|
# validate data quality (report cached in context)
|
||||||
|
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||||
|
if interactions_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# extract vectorized session features
|
||||||
|
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||||
|
if features_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# join experiment labels (is_agent = ~xp_human_only)
|
||||||
|
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||||
|
|
||||||
|
return labeled_df
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
class ExperimentsProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
base_path = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/" # os.path.join(os.path.dirname(__file__), "collected_data")
|
||||||
|
if not os.path.isdir(base_path):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||||
|
file_to_read = files.get(topic, files["user-interactions"])
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
for d in os.listdir(base_path):
|
||||||
|
full_path = os.path.join(base_path, d, file_to_read)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = pd.read_json(full_path)
|
||||||
|
payloads = pd.DataFrame([r['payload'] for r in data['value'].to_list()])
|
||||||
|
frames.append(payloads)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not process {full_path}: {e}")
|
||||||
|
|
||||||
|
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||||
|
|
||||||
|
# demo: run ML training pipeline
|
||||||
|
context = PipelineContext(provider=ExperimentsProvider(), store_mode='hotel')
|
||||||
|
features = ml_training_pipeline(context)
|
||||||
|
print(f"Feature matrix: {features.shape}")
|
||||||
|
print(features.head())
|
||||||
|
print(features.info())
|
||||||
|
|
||||||
|
features.to_parquet("features.parquet")
|
||||||
14
experiments/procesing/pricers/__init__.py
Normal file
14
experiments/procesing/pricers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||||
|
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PricingFunction',
|
||||||
|
'ElasticityBasedPricer',
|
||||||
|
'StaticPricer',
|
||||||
|
'RandomPricer',
|
||||||
|
'SimpleSurgePricer',
|
||||||
|
'SessionAwarePricer',
|
||||||
|
'ProductSpecificSessionPricer'
|
||||||
|
]
|
||||||
67
experiments/procesing/pricers/base.py
Normal file
67
experiments/procesing/pricers/base.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class PricingFunction(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base for pricing functions.
|
||||||
|
Objective:
|
||||||
|
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||||
|
subject to:
|
||||||
|
Q_t = g(P_t, S_t) (demand response via elasticity)
|
||||||
|
P_t ≥ C (cost floor)
|
||||||
|
minimize L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fit(self, *kwargs):
|
||||||
|
"""
|
||||||
|
Offline training on historical data.
|
||||||
|
This is where we can think about some maximization of expected revenue
|
||||||
|
over historical trajectories to learn parameters of the pricing function.
|
||||||
|
(This however we cover move in the RL side of things)
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def predict(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Generate optimal prices given current state.
|
||||||
|
This is an abstract method that transitions from τ -> P*
|
||||||
|
which is the mapping from the trajectory to optimal prices under
|
||||||
|
some subset of session grouping (so, per sessionId)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get_features(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Extract features from trajectory for pricing decision.
|
||||||
|
Returns:
|
||||||
|
np.ndarray of shape (n_products, n_features)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, observation: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Online learning update (optional).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
observation: dict with {state, action, reward, next_state}
|
||||||
|
- state: StateSpace before pricing decision
|
||||||
|
- action: prices shown (P_t)
|
||||||
|
- reward: revenue/conversion signal
|
||||||
|
- next_state: StateSpace after user interaction
|
||||||
|
"""
|
||||||
|
pass # default: no online learning
|
||||||
|
|
||||||
|
def get_params(self) -> Dict[str, Any]:
|
||||||
|
"""Return pricing function parameters for serialization."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_params(self, params: Dict[str, Any]):
|
||||||
|
"""Load pricing function parameters from dict."""
|
||||||
|
pass
|
||||||
69
experiments/procesing/pricers/elasticity.py
Normal file
69
experiments/procesing/pricers/elasticity.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Pricing based on demand elasticity estimates.
|
||||||
|
f(Q, S) = base_price * (1 + alpha * elasticity * demand_deviation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, alpha: float = 0.1, price_floor: float = 0.0, price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Calibrate from historical elasticity estimates.
|
||||||
|
Expects: [productId, elasticity, base_price, mean_demand]
|
||||||
|
"""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' column")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on demand deviation and elasticity.
|
||||||
|
Higher demand -> increase price (but less for elastic goods)
|
||||||
|
"""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
if len(demand) != len(self.elasticity):
|
||||||
|
raise ValueError(f"Demand vector size {len(demand)} != elasticity size {len(self.elasticity)}")
|
||||||
|
|
||||||
|
# compute demand deviation from mean
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
|
||||||
|
# adjust price: if demand high and elastic, don't increase much
|
||||||
|
# if demand high and inelastic, increase more
|
||||||
|
price_multiplier = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
prices = self.base_prices * price_multiplier
|
||||||
|
|
||||||
|
# enforce bounds
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 3))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||||
211
experiments/procesing/pricers/session_aware.py
Normal file
211
experiments/procesing/pricers/session_aware.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Session-aware pricing functions that leverage behavioral features S_t.
|
||||||
|
These pricers aim to minimize L_agent = R_oracle - R_observed.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAwarePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Extends elasticity-based pricing with session behavioral signals.
|
||||||
|
|
||||||
|
f(Q, P, S) = base_price * elasticity_factor * session_factor
|
||||||
|
|
||||||
|
Where session_factor adjusts for:
|
||||||
|
- interaction_velocity (agent detection proxy)
|
||||||
|
- product_view_depth (interest signal)
|
||||||
|
- cart_to_view_ratio (conversion intent)
|
||||||
|
|
||||||
|
Strategy: charge higher prices to suspected agents (high velocity)
|
||||||
|
to recover oracle revenue from reconnaissance sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
beta_velocity: float = 0.05,
|
||||||
|
beta_attention: float = 0.03,
|
||||||
|
agent_velocity_threshold: float = 5.0,
|
||||||
|
agent_markup: float = 1.2,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
alpha: elasticity sensitivity
|
||||||
|
beta_velocity: interaction velocity weight
|
||||||
|
beta_attention: product attention weight
|
||||||
|
agent_velocity_threshold: velocity above which to apply agent markup
|
||||||
|
agent_markup: price multiplier for suspected agent sessions
|
||||||
|
price_floor, price_ceil: price bounds
|
||||||
|
"""
|
||||||
|
self.alpha = alpha
|
||||||
|
self.beta_velocity = beta_velocity
|
||||||
|
self.beta_attention = beta_attention
|
||||||
|
self.agent_velocity_threshold = agent_velocity_threshold
|
||||||
|
self.agent_markup = agent_markup
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
# fitted parameters
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
"""Calibrate from historical elasticity data."""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate prices with session awareness."""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base elasticity-driven pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
elasticity_factor = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
|
||||||
|
# session-aware adjustments
|
||||||
|
session_factor = np.ones(n_products)
|
||||||
|
|
||||||
|
if not state_space.session_features.empty:
|
||||||
|
sf = state_space.session_features.iloc[0] # single session features
|
||||||
|
|
||||||
|
# agent detection via velocity
|
||||||
|
velocity = sf.get('interaction_velocity', 0.0)
|
||||||
|
if velocity > self.agent_velocity_threshold:
|
||||||
|
# suspected agent: apply markup to recover oracle revenue
|
||||||
|
session_factor *= self.agent_markup
|
||||||
|
|
||||||
|
# attention signal: higher view depth -> user interested -> can charge more
|
||||||
|
view_depth = sf.get('product_view_depth', 0)
|
||||||
|
if view_depth > 0:
|
||||||
|
attention_boost = 1 + self.beta_attention * np.log1p(view_depth)
|
||||||
|
session_factor *= attention_boost
|
||||||
|
|
||||||
|
# cart presence: if user has items in cart, slightly increase prices
|
||||||
|
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||||
|
if cart_to_view > 0.1:
|
||||||
|
session_factor *= (1 + 0.02) # small boost for conversion intent
|
||||||
|
|
||||||
|
prices = self.base_prices * elasticity_factor * session_factor
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity, demand, and session features"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 5))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# extract session features
|
||||||
|
velocity = 0.0
|
||||||
|
view_depth = 0.0
|
||||||
|
cart_to_view = 0.0
|
||||||
|
|
||||||
|
if not state_space.session_features.empty:
|
||||||
|
sf = state_space.session_features.iloc[0]
|
||||||
|
velocity = sf.get('interaction_velocity', 0.0)
|
||||||
|
view_depth = sf.get('product_view_depth', 0.0)
|
||||||
|
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||||
|
|
||||||
|
# broadcast session features to all products
|
||||||
|
features = np.column_stack([
|
||||||
|
self.elasticity,
|
||||||
|
demand,
|
||||||
|
np.full(n_products, velocity),
|
||||||
|
np.full(n_products, view_depth),
|
||||||
|
np.full(n_products, cart_to_view)
|
||||||
|
])
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSpecificSessionPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Session-aware pricer with product-specific demand signals.
|
||||||
|
|
||||||
|
Uses S_t to extract per-product interaction counts and adjusts pricing
|
||||||
|
for products the user has already viewed/hovered.
|
||||||
|
|
||||||
|
Strategy: products viewed multiple times = high interest -> price up
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
view_boost: float = 0.02,
|
||||||
|
max_view_boost: float = 0.15,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.view_boost = view_boost
|
||||||
|
self.max_view_boost = max_view_boost
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
self.product_ids = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
if 'elasticity' not in historical_data.columns or 'productId' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' and 'productId'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
self.product_ids = historical_data['productId'].values
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
base_prices = self.base_prices * (1 + self.alpha * np.abs(self.elasticity) * demand_dev)
|
||||||
|
|
||||||
|
# product-specific session adjustments
|
||||||
|
if not state_space.session_features.empty and state_space.product_ids is not None:
|
||||||
|
# extract product interaction counts from session metadata
|
||||||
|
# (this would require session features to include per-product signals)
|
||||||
|
# for now, use uniform boost as placeholder
|
||||||
|
# TODO: extend session feature extraction to include product-specific counts
|
||||||
|
pass
|
||||||
|
|
||||||
|
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity and demand features for product-specific pricing"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 2))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
return np.column_stack([self.elasticity, demand])
|
||||||
158
experiments/procesing/pricers/simple.py
Normal file
158
experiments/procesing/pricers/simple.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
def session_features_to_demand(session_features: pd.DataFrame) -> float:
|
||||||
|
"""
|
||||||
|
Map session behavioral features to demand proxy.
|
||||||
|
THIS is the critical θ̂ → D transformation for rule-based pricing.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- High velocity → agent behavior → price up (revenue recovery)
|
||||||
|
- High cart ratio → purchase intent → price up
|
||||||
|
- Low activity → discount to convert
|
||||||
|
|
||||||
|
Returns: demand proxy score (0-20 range, higher = more demand)
|
||||||
|
"""
|
||||||
|
if session_features.empty:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
feat = session_features.iloc[0] if len(session_features) > 0 else {}
|
||||||
|
|
||||||
|
velocity = feat.get('interaction_velocity', 0)
|
||||||
|
cart_ratio = feat.get('cart_to_view_ratio', 0)
|
||||||
|
item_views = feat.get('item_views', 0)
|
||||||
|
cart_adds = feat.get('cart_adds', 0)
|
||||||
|
|
||||||
|
# baseline demand
|
||||||
|
demand = 1.0
|
||||||
|
|
||||||
|
# agent detection: high velocity → treat as high "demand" to price up
|
||||||
|
if velocity > 2.0:
|
||||||
|
demand += 10.0 # strong agent signal
|
||||||
|
|
||||||
|
# conversion intent: cart interaction → price up
|
||||||
|
if cart_ratio > 0.1 or cart_adds > 0:
|
||||||
|
demand += 5.0
|
||||||
|
|
||||||
|
# browsing depth: many views → interest signal
|
||||||
|
if item_views > 3:
|
||||||
|
demand += min(item_views, 5.0)
|
||||||
|
|
||||||
|
return min(demand, 20.0) # cap at 20
|
||||||
|
|
||||||
|
|
||||||
|
class StaticPricer(PricingFunction):
|
||||||
|
"""Static pricing: always return fixed base prices"""
|
||||||
|
|
||||||
|
def __init__(self, base_prices: np.ndarray = None):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Extract base prices from historical data"""
|
||||||
|
if 'base_price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['base_price'].values
|
||||||
|
elif 'price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['price'].values
|
||||||
|
else:
|
||||||
|
raise ValueError("historical_data must contain 'base_price' or 'price' column")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Return static base prices regardless of state"""
|
||||||
|
if self.base_prices is None:
|
||||||
|
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||||
|
return self.base_prices.copy()
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Static pricer uses no features, returns empty array"""
|
||||||
|
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||||
|
return np.zeros((n, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class RandomPricer(PricingFunction):
|
||||||
|
"""Random pricing within bounds (for baseline comparison)"""
|
||||||
|
|
||||||
|
def __init__(self, price_min: float = 50.0, price_max: float = 500.0, seed: int = None):
|
||||||
|
self.price_min = price_min
|
||||||
|
self.price_max = price_max
|
||||||
|
self.seed = seed
|
||||||
|
self.n_products = None
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Learn number of products"""
|
||||||
|
self.n_products = len(historical_data)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate random prices"""
|
||||||
|
if self.n_products is None:
|
||||||
|
self.n_products = len(state_space.demand)
|
||||||
|
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Random pricer uses no features"""
|
||||||
|
n = self.n_products if self.n_products else 0
|
||||||
|
return np.zeros((n, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleSurgePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Rule-based surge pricer adjusting prices via demand thresholds.
|
||||||
|
Logic: if demand > high_threshold -> surge, if demand < low_threshold -> discount.
|
||||||
|
Simpler and more controllable than curve fitting approaches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_prices: np.ndarray = None,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
self.high_threshold = high_threshold
|
||||||
|
self.low_threshold = low_threshold
|
||||||
|
self.surge_multiplier = surge_multiplier
|
||||||
|
self.discount_multiplier = discount_multiplier
|
||||||
|
|
||||||
|
def fit(self, market_data: pd.DataFrame):
|
||||||
|
"""Extract base prices from product catalog or historical averages"""
|
||||||
|
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on current demand using surge rules.
|
||||||
|
state_space.demand: demand proxy per product (from session features)
|
||||||
|
state_space.prices: base prices
|
||||||
|
"""
|
||||||
|
demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
|
||||||
|
base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
|
||||||
|
|
||||||
|
if base is None:
|
||||||
|
base = np.ones(len(demand)) * 99.99
|
||||||
|
|
||||||
|
# ensure float dtype to allow multiplication by float multipliers
|
||||||
|
new_prices = base.astype(np.float64).copy()
|
||||||
|
high_mask = demand >= self.high_threshold
|
||||||
|
new_prices[high_mask] *= self.surge_multiplier
|
||||||
|
|
||||||
|
low_mask = demand <= self.low_threshold
|
||||||
|
new_prices[low_mask] *= self.discount_multiplier
|
||||||
|
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract demand and base price features for each product"""
|
||||||
|
if state_space is None:
|
||||||
|
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||||
|
return np.zeros((n, 2))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand) if hasattr(state_space, 'demand') else np.array([0])
|
||||||
|
base = np.asarray(state_space.prices) if hasattr(state_space, 'prices') else self.base_prices
|
||||||
|
if base is None:
|
||||||
|
base = np.ones(len(demand)) * 99.99
|
||||||
|
|
||||||
|
return np.column_stack([demand, base])
|
||||||
@@ -35,8 +35,9 @@ from sklearn.base import BaseEstimator, TransformerMixin
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import os
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
from supabase import create_client, Client
|
from supabase import create_client, Client
|
||||||
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
|
||||||
|
|
||||||
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||||
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||||
@@ -79,18 +80,136 @@ class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
|||||||
class SimpleLinearPricingFunction(PricingFunction):
|
class SimpleLinearPricingFunction(PricingFunction):
|
||||||
def __init__(self, price_sensitivity: float = -0.1):
|
def __init__(self, price_sensitivity: float = -0.1):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.price_sensitivity = price_sensitivity # simple coefficient
|
self.price_sensitivity = price_sensitivity
|
||||||
|
|
||||||
def fit(self, historical_data):
|
def fit(self, historical_data):
|
||||||
return self
|
return self
|
||||||
|
|
||||||
def transform(self, state_space: StateSpace) -> np.ndarray:
|
def transform(self, state_space: StateSpace) -> np.ndarray:
|
||||||
# Simple linear adjustment: P_{t+1} = P_t + sensitivity * Q_t
|
new_prices = state_space.prices + self.price_sensitivity * state_space.demand
|
||||||
new_prices = state_space.prices + self.price_sensitivity * state_space.demand # this is not great
|
|
||||||
return np.maximum(new_prices, 0)
|
return np.maximum(new_prices, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricingFunction(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue-maximizing pricing using elasticity estimates.
|
||||||
|
|
||||||
|
For each product, optimal price P* maximizes R = P * Q(P)
|
||||||
|
where Q(P) follows power law: Q(P) = Q_0 * (P/P_0)^ε
|
||||||
|
|
||||||
|
Taking derivative dR/dP = 0 gives optimal markup:
|
||||||
|
P* = P_0 * (1 + 1/ε) if ε < -1 (elastic)
|
||||||
|
|
||||||
|
For inelastic demand (|ε| < 1), we apply bounded markup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
cost_floor: float = 0.5,
|
||||||
|
max_markup: float = 2.0,
|
||||||
|
min_markup: float = 1.0,
|
||||||
|
inelastic_markup: float = 1.3):
|
||||||
|
super().__init__()
|
||||||
|
self.cost_floor = cost_floor # prices as fraction of base
|
||||||
|
self.max_markup = max_markup # max price = base * max_markup
|
||||||
|
self.min_markup = min_markup # min price = base * min_markup
|
||||||
|
self.inelastic_markup = inelastic_markup # default for |ε| < 1
|
||||||
|
self.elasticity_map = {} # productId -> elasticity
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
elasticity_df: df with [productId, elasticity, std_error, n_obs]
|
||||||
|
"""
|
||||||
|
if elasticity_df is not None and not elasticity_df.empty:
|
||||||
|
self.elasticity_map = dict(zip(
|
||||||
|
elasticity_df['productId'],
|
||||||
|
elasticity_df['elasticity']
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
state_space: current state (prices = base prices)
|
||||||
|
product_ids: array of productIds aligned with state_space.prices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
optimized prices P_{t+1}
|
||||||
|
"""
|
||||||
|
base_prices = state_space.prices
|
||||||
|
|
||||||
|
if product_ids is None:
|
||||||
|
# fallback: use positional index as productId (not ideal)
|
||||||
|
product_ids = np.arange(len(base_prices))
|
||||||
|
|
||||||
|
new_prices = np.zeros_like(base_prices)
|
||||||
|
|
||||||
|
for i, (base_p, pid) in enumerate(zip(base_prices, product_ids)):
|
||||||
|
elasticity = self.elasticity_map.get(pid, 0.0)
|
||||||
|
|
||||||
|
if elasticity < -1: # elastic demand
|
||||||
|
# optimal markup: (1 + 1/ε)
|
||||||
|
markup = 1 + (1 / elasticity)
|
||||||
|
optimal_p = base_p * markup
|
||||||
|
elif elasticity > -1 and elasticity < 0: # inelastic
|
||||||
|
# conservative markup
|
||||||
|
optimal_p = base_p * self.inelastic_markup
|
||||||
|
else: # ε ≥ 0 (demand increases with price, or no data)
|
||||||
|
# no elasticity data or anomalous, keep base price
|
||||||
|
optimal_p = base_p
|
||||||
|
|
||||||
|
# apply bounds
|
||||||
|
optimal_p = np.clip(
|
||||||
|
optimal_p,
|
||||||
|
base_p * self.min_markup,
|
||||||
|
base_p * self.max_markup
|
||||||
|
)
|
||||||
|
optimal_p = max(optimal_p, self.cost_floor)
|
||||||
|
|
||||||
|
new_prices[i] = optimal_p
|
||||||
|
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualElasticityPricing(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue optimization with contextual adjustments based on session features.
|
||||||
|
|
||||||
|
Combines elasticity-based pricing with surge/demand-based multipliers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_pricer: ElasticityBasedPricingFunction = None,
|
||||||
|
demand_sensitivity: float = 0.1,
|
||||||
|
surge_threshold: float = 0.7):
|
||||||
|
super().__init__()
|
||||||
|
self.base_pricer = base_pricer or ElasticityBasedPricingFunction()
|
||||||
|
self.demand_sensitivity = demand_sensitivity
|
||||||
|
self.surge_threshold = surge_threshold
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
self.base_pricer.fit(elasticity_df)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
# get base optimal prices from elasticity
|
||||||
|
base_optimal = self.base_pricer.transform(state_space, product_ids)
|
||||||
|
|
||||||
|
# compute surge multiplier from demand
|
||||||
|
if len(state_space.demand) > 0:
|
||||||
|
demand_normalized = state_space.demand / (state_space.demand.max() + 1e-8)
|
||||||
|
surge_multiplier = 1 + self.demand_sensitivity * np.maximum(
|
||||||
|
demand_normalized - self.surge_threshold, 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
surge_multiplier = np.ones_like(base_optimal)
|
||||||
|
|
||||||
|
return base_optimal * surge_multiplier
|
||||||
|
|
||||||
# Example usage:
|
# Example usage:
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
||||||
|
|
||||||
store_mode = 'hotel'
|
store_mode = 'hotel'
|
||||||
interaction_data = interaction_pipeline.fit_transform(None)
|
interaction_data = interaction_pipeline.fit_transform(None)
|
||||||
price_data = price_data_pipeline.fit_transform(None)
|
price_data = price_data_pipeline.fit_transform(None)
|
||||||
|
|||||||
5
experiments/procesing/providers/__init__.py
Executable file
5
experiments/procesing/providers/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.providers.supabase import SupabaseProvider
|
||||||
|
from procesing.providers.backend import BackendAPIProvider
|
||||||
|
|
||||||
|
__all__ = ['DataProvider', 'SupabaseProvider', 'BackendAPIProvider']
|
||||||
19
experiments/procesing/providers/backend.py
Executable file
19
experiments/procesing/providers/backend.py
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class BackendAPIProvider(DataProvider):
|
||||||
|
"""Concrete backend API implementation"""
|
||||||
|
def __init__(self, backend_url: str = None):
|
||||||
|
self.backend_url = backend_url or os.getenv("BACKEND_URL", "http://localhost:5000")
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
resp = requests.get(f"{self.backend_url}/api/kafka/dump?topic={topic}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if not data.get('success') or not data.get('data'):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return pd.DataFrame(data['data'])
|
||||||
21
experiments/procesing/providers/base.py
Executable file
21
experiments/procesing/providers/base.py
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
class DataProvider(ABC):
|
||||||
|
"""Abstract interface for data access, enables DI and testing"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
"""Fetch product catalog for given store mode"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
"""Fetch experiment metadata for given IDs"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
"""Fetch data from Kafka topic via backend API"""
|
||||||
|
pass
|
||||||
42
experiments/procesing/providers/supabase.py
Executable file
42
experiments/procesing/providers/supabase.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
class SupabaseProvider(DataProvider):
|
||||||
|
"""Concrete Supabase + backend API implementation"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
supabase_url: str = None,
|
||||||
|
supabase_key: str = None,):
|
||||||
|
load_dotenv()
|
||||||
|
self.supabase_url = supabase_url or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
self.supabase_key = supabase_key or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||||
|
self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||||
|
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||||
|
if not resp.data:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(resp.data)
|
||||||
|
# normalize type column: hotel has room_type, airline has flight_type
|
||||||
|
if 'room_type' in df.columns:
|
||||||
|
df['product_type'] = df['room_type']
|
||||||
|
elif 'flight_type' in df.columns:
|
||||||
|
df['product_type'] = df['flight_type']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if not experiment_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
resp = self.supabase.table('experiments').select(
|
||||||
|
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, '
|
||||||
|
'task:tasks(task_name, task_description, task_def_of_done)'
|
||||||
|
).in_('id', experiment_ids).execute()
|
||||||
|
|
||||||
|
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||||
39
experiments/procesing/steps/__init__.py
Executable file
39
experiments/procesing/steps/__init__.py
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||||
|
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||||
|
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||||
|
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||||
|
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||||
|
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||||
|
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||||
|
from procesing.steps.session import (
|
||||||
|
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||||
|
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||||
|
_extract_features_for_session
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseContextStep',
|
||||||
|
'FetchInteractionsStep',
|
||||||
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'JoinProductFeaturesStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'AugmentInteractionsStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'ExtractSessionFeaturesStep',
|
||||||
|
'JoinLabelsStep',
|
||||||
|
'ValidateDataStep',
|
||||||
|
'TemporalFeatureStep',
|
||||||
|
'BehavioralFeatureStep',
|
||||||
|
'ProductFeatureStep',
|
||||||
|
'UserAgentFeatureStep',
|
||||||
|
'_extract_features_for_session',
|
||||||
|
]
|
||||||
140
experiments/procesing/steps/augment.py
Executable file
140
experiments/procesing/steps/augment.py
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentInteractionsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Consolidated step: create price buckets, augment event names, join experiments.
|
||||||
|
Input: (interactions_df, price_logs_df)
|
||||||
|
Output: enriched interactions_df
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
interactions_df, price_logs_df = data
|
||||||
|
|
||||||
|
if interactions_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Step 1: Create price buckets
|
||||||
|
interactions_df = self._create_price_buckets(interactions_df)
|
||||||
|
|
||||||
|
# Step 2: Augment event names
|
||||||
|
interactions_df = self._augment_event_names(interactions_df)
|
||||||
|
|
||||||
|
# Step 3: Join experiments (optional)
|
||||||
|
if 'experimentId' in interactions_df.columns:
|
||||||
|
interactions_df = self._join_experiments(interactions_df)
|
||||||
|
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
def _create_price_buckets(self, df: pd.DataFrame):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
if 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _augment_event_names(self, df: pd.DataFrame):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _join_experiments(self, df: pd.DataFrame):
|
||||||
|
"""Join experiment metadata if experimentId present"""
|
||||||
|
exp_ids = df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return df
|
||||||
|
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids)
|
||||||
|
if experiments_df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df.merge(
|
||||||
|
experiments_df,
|
||||||
|
left_on='experimentId',
|
||||||
|
right_on='id',
|
||||||
|
how='left',
|
||||||
|
suffixes=('', '_exp')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePriceBucketsStep(BaseContextStep):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty or 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentEventNamesStep(BaseContextStep):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
32
experiments/procesing/steps/base.py
Executable file
32
experiments/procesing/steps/base.py
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||||
|
"""
|
||||||
|
Base for all pipeline steps.
|
||||||
|
Each step is stateless, context-driven, and performs ONE transformation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, context: PipelineContext):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def fit(self, X=None, y=None):
|
||||||
|
"""Most steps don't need training"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def transform(self, X) -> Any:
|
||||||
|
"""Transform input using context. Must be implemented by subclass."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_params(self, deep=True):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
return {'context': self.context}
|
||||||
|
|
||||||
|
def set_params(self, **params):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
if 'context' in params:
|
||||||
|
self.context = params['context']
|
||||||
|
return self
|
||||||
34
experiments/procesing/steps/chunk.py
Executable file
34
experiments/procesing/steps/chunk.py
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ChunkByTimeWindowStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Chunk dataframe into time windows.
|
||||||
|
Returns list of dicts with window metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
window_size = self.context.window_size
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values(ts_col)
|
||||||
|
df['_window'] = df[ts_col].dt.floor(window_size)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
||||||
|
chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_start + pd.Timedelta(window_size),
|
||||||
|
'window_idx': idx,
|
||||||
|
'data': group.drop(columns=['_window'])
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
61
experiments/procesing/steps/demand.py
Executable file
61
experiments/procesing/steps/demand.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ComputeDemandStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Compute demand vector for a single time window or dataframe.
|
||||||
|
Input: single chunk dict OR raw dataframe
|
||||||
|
Output: demand dataframe with [productId, demand_score]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, chunk):
|
||||||
|
# handle both chunk dict and raw dataframe
|
||||||
|
if isinstance(chunk, dict):
|
||||||
|
interactions = chunk['data']
|
||||||
|
window_meta = {k: v for k, v in chunk.items() if k != 'data'}
|
||||||
|
else:
|
||||||
|
interactions = chunk
|
||||||
|
window_meta = {}
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
# apply filters if configured
|
||||||
|
session_filter = self.context.config.get('session_filter')
|
||||||
|
experiment_filter = self.context.config.get('experiment_filter')
|
||||||
|
|
||||||
|
if session_filter and 'sessionId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['sessionId'] == session_filter]
|
||||||
|
if experiment_filter and 'experimentId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['experimentId'] == experiment_filter]
|
||||||
|
|
||||||
|
interactions_with_products = interactions.dropna(subset=['productId'])
|
||||||
|
|
||||||
|
if interactions_with_products.empty:
|
||||||
|
demand_df = pd.DataFrame({
|
||||||
|
'productId': unique_products,
|
||||||
|
'demand_score': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# crosstab for simple demand count
|
||||||
|
demand_df = pd.crosstab(
|
||||||
|
interactions_with_products['productId'],
|
||||||
|
'count'
|
||||||
|
).reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
demand_df.columns = ['productId', 'demand_score']
|
||||||
|
|
||||||
|
# attach window metadata if present
|
||||||
|
if window_meta:
|
||||||
|
return {**window_meta, 'demand_vector': demand_df}
|
||||||
|
return demand_df
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeDemandForChunksStep(BaseContextStep):
|
||||||
|
"""Apply ComputeDemandStep to list of chunks"""
|
||||||
|
|
||||||
|
def transform(self, chunks: list):
|
||||||
|
if not chunks:
|
||||||
|
return []
|
||||||
|
|
||||||
|
demand_step = ComputeDemandStep(self.context)
|
||||||
|
return [demand_step.transform(chunk) for chunk in chunks]
|
||||||
42
experiments/procesing/steps/elasticity.py
Executable file
42
experiments/procesing/steps/elasticity.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, List
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class AggregatePriceLogsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Aggregate price logs into time windows using VECTORIZED operations.
|
||||||
|
Input: price_logs_df
|
||||||
|
Output: DataFrame with columns [productId, price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, price_logs_df: pd.DataFrame):
|
||||||
|
if price_logs_df.empty:
|
||||||
|
return pd.DataFrame(columns=['productId', 'price'])
|
||||||
|
|
||||||
|
df = price_logs_df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values([ts_col, 'productId'])
|
||||||
|
products = self.context.products
|
||||||
|
# get base price from metadata if available 1) read the metadata col as json and get the base_price
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: row['metadata'].get('base_price', 0) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
df_indexed = df.set_index(ts_col)
|
||||||
|
# we return a df of average price per product over the entire period
|
||||||
|
# TODO: maybe consider different opration to handle price aggregation over time
|
||||||
|
avg_prices = df_indexed.groupby('productId')['price'].mean().reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
avg_prices.columns = ['productId', 'price']
|
||||||
|
# fill 0s with base_price from products
|
||||||
|
base_price_map = products.set_index('id')['base_price'].to_dict()
|
||||||
|
return avg_prices
|
||||||
81
experiments/procesing/steps/fetch.py
Executable file
81
experiments/procesing/steps/fetch.py
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class FetchInteractionsStep(BaseContextStep):
|
||||||
|
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Explode metadata JSON column
|
||||||
|
if 'metadata' in df.columns:
|
||||||
|
df = df.join(
|
||||||
|
pd.json_normalize(df.pop('metadata'), sep='.').add_prefix('metadata_')
|
||||||
|
)
|
||||||
|
|
||||||
|
df = df.dropna(subset=['eventName'])
|
||||||
|
# drop all where page has /admin/
|
||||||
|
df = df[~df['page'].str.contains('/admin/', na=False)]
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Remap dateIndex if present
|
||||||
|
if 'metadata_dateIndex' in df.columns:
|
||||||
|
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchPriceLogsStep(BaseContextStep):
|
||||||
|
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchExperimentsStep(BaseContextStep):
|
||||||
|
"""Fetch experiment metadata for given interaction data"""
|
||||||
|
|
||||||
|
def transform(self, interactions_df: pd.DataFrame):
|
||||||
|
if interactions_df.empty or 'experimentId' not in interactions_df.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
exp_ids = interactions_df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return self.context.provider.fetch_experiments(exp_ids)
|
||||||
58
experiments/procesing/steps/join.py
Executable file
58
experiments/procesing/steps/join.py
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class JoinExperimentsStep(BaseContextStep):
|
||||||
|
"""Join experiment metadata to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, experiments_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
interactions_df, experiments_df = data
|
||||||
|
|
||||||
|
if experiments_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Flatten nested task field if present
|
||||||
|
if 'task' in experiments_df.columns and experiments_df['task'].notnull().any():
|
||||||
|
task_norm = pd.json_normalize(experiments_df['task'].dropna())
|
||||||
|
task_norm.index = experiments_df[experiments_df['task'].notnull()].index
|
||||||
|
experiments_df = experiments_df.drop('task', axis=1).join(task_norm, rsuffix='_task')
|
||||||
|
|
||||||
|
# Rename for clarity
|
||||||
|
experiments_df = experiments_df.rename(columns={
|
||||||
|
'id': 'experimentId',
|
||||||
|
'subject_name': 'exp_subject',
|
||||||
|
'xp_human_only': 'exp_human_only',
|
||||||
|
'xp_market_mode': 'exp_market_mode',
|
||||||
|
'xp_task_id': 'exp_task_id'
|
||||||
|
})
|
||||||
|
|
||||||
|
return interactions_df.merge(experiments_df, on='experimentId', how='left')
|
||||||
|
|
||||||
|
class JoinProductFeaturesStep(BaseContextStep):
|
||||||
|
"""Join product features to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, products_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
demand_df, price_df = data
|
||||||
|
|
||||||
|
# get base prices from products if available
|
||||||
|
products = self.context.products
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: float(row['metadata'].get('base_price', 0.0)) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
products = products[['id', 'base_price']].rename(columns={'id': 'productId'})
|
||||||
|
|
||||||
|
if price_df.empty:
|
||||||
|
return demand_df
|
||||||
|
return demand_df.merge(price_df, on='productId', how='left').merge(products, on='productId', how='left')
|
||||||
55
experiments/procesing/steps/pricing.py
Executable file
55
experiments/procesing/steps/pricing.py
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from procesing.pricers.simple import StaticPricer
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.pricers import ElasticityBasedPricer
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self,
|
||||||
|
last_action : str,
|
||||||
|
last_productId : str,
|
||||||
|
last_price : float,
|
||||||
|
session_features : np.ndarray
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FitPricingFunctionStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Fit pricing function using data.
|
||||||
|
Input: pricing_data
|
||||||
|
Output: fitted pricing function instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, pricing_data: pd.DataFrame):
|
||||||
|
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||||
|
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||||
|
|
||||||
|
pricer = pricing_class(**pricing_params)
|
||||||
|
pricer.fit(pricing_data)
|
||||||
|
|
||||||
|
return pricer
|
||||||
|
|
||||||
|
|
||||||
|
class PredictPricesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Predict optimal prices using fitted pricing function.
|
||||||
|
Input: (pricer, state_space)
|
||||||
|
Output: prices_df [productId, predicted_price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
pricer, state_space = data
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
product_ids = products['id'].values
|
||||||
|
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
return pd.DataFrame({
|
||||||
|
'productId': product_ids,
|
||||||
|
'predicted_price': predicted_prices
|
||||||
|
})
|
||||||
262
experiments/procesing/steps/session.py
Normal file
262
experiments/procesing/steps/session.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Session feature extraction for ML training pipeline.
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
EVENT_CATS = {
|
||||||
|
'page_view': ['page_view'],
|
||||||
|
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||||
|
'cart_add': ['add_item_to_cart'],
|
||||||
|
'purchase': ['purchase', 'checkout_complete'],
|
||||||
|
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||||
|
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||||
|
}
|
||||||
|
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||||
|
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||||
|
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||||
|
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_browser(s: str) -> str:
|
||||||
|
if pd.isna(s): return 'Unknown'
|
||||||
|
for name, pat in BROWSER_PATTERNS:
|
||||||
|
if re.search(pat, s): return name
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||||
|
|
||||||
|
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||||
|
super().__init__(context)
|
||||||
|
self.timeout_sec = timeout_sec
|
||||||
|
self.velocity_window = velocity_window
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'ts' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||||
|
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||||
|
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||||
|
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
session_duration_sec=('active_diff', 'sum'),
|
||||||
|
total_interactions=('sessionId', 'count'),
|
||||||
|
avg_time_between_events=('time_diff', 'mean'),
|
||||||
|
std_time_between_events=('time_diff', 'std'),
|
||||||
|
min_time_between_events=('time_diff', 'min'),
|
||||||
|
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||||
|
).reset_index()
|
||||||
|
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||||
|
agg['interaction_velocity'] = np.where(
|
||||||
|
agg['session_duration_sec'] > 0,
|
||||||
|
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||||
|
|
||||||
|
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||||
|
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||||
|
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||||
|
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class BehavioralFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized event counts and ratios per session."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'eventName' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
for cat, events in EVENT_CATS.items():
|
||||||
|
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||||
|
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||||
|
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||||
|
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||||
|
hover_events=('is_hover', 'sum'),
|
||||||
|
# filter_events=('is_filter', 'sum'),
|
||||||
|
).reset_index()
|
||||||
|
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||||
|
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||||
|
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||||
|
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||||
|
|
||||||
|
prod_df = df[df['productId'].notna()]
|
||||||
|
if prod_df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||||
|
|
||||||
|
agg = prod_df.groupby('sessionId').agg(
|
||||||
|
unique_products_viewed=('productId', 'nunique'),
|
||||||
|
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||||
|
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||||
|
max_price_seen=('price_seen', 'max'),
|
||||||
|
).reset_index()
|
||||||
|
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class UserAgentFeatureStep(BaseContextStep):
|
||||||
|
"""Parse userAgent into bot-detection signals."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'userAgent' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||||
|
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||||
|
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||||
|
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||||
|
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||||
|
Input: interactions_df
|
||||||
|
Output: session-level feature matrix
|
||||||
|
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
if X.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = X.copy()
|
||||||
|
|
||||||
|
# run all feature steps and merge on sessionId
|
||||||
|
temporal = TemporalFeatureStep(self.context).transform(df)
|
||||||
|
behavioral = BehavioralFeatureStep(self.context).transform(df)
|
||||||
|
product = ProductFeatureStep(self.context).transform(df)
|
||||||
|
ua = UserAgentFeatureStep(self.context).transform(df)
|
||||||
|
|
||||||
|
result = temporal
|
||||||
|
for other in [behavioral, product, ua]:
|
||||||
|
if not other.empty and 'sessionId' in other.columns:
|
||||||
|
result = result.merge(other, on='sessionId', how='left')
|
||||||
|
|
||||||
|
# carry forward experimentId for label joining
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
exp_map = df.groupby('sessionId')['experimentId'].first()
|
||||||
|
result = result.merge(exp_map, on='sessionId', how='left')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class JoinLabelsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Join experiment labels to session features.
|
||||||
|
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||||
|
Output: labeled feature matrix with is_agent column
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X : tuple) -> pd.DataFrame:
|
||||||
|
data = X;
|
||||||
|
if isinstance(data, tuple):
|
||||||
|
features_df, experiments_df = data
|
||||||
|
else:
|
||||||
|
features_df = data
|
||||||
|
if 'experimentId' not in features_df.columns:
|
||||||
|
return features_df
|
||||||
|
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||||
|
|
||||||
|
if features_df.empty:
|
||||||
|
return features_df
|
||||||
|
if experiments_df.empty:
|
||||||
|
features_df['is_agent'] = np.nan
|
||||||
|
return features_df
|
||||||
|
|
||||||
|
exp = experiments_df.copy()
|
||||||
|
if 'id' in exp.columns:
|
||||||
|
exp = exp.rename(columns={'id': 'experimentId'})
|
||||||
|
if 'xp_human_only' in exp.columns:
|
||||||
|
exp['is_agent'] = ~exp['xp_human_only']
|
||||||
|
|
||||||
|
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||||
|
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateDataStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Data quality checks before training.
|
||||||
|
Input: df
|
||||||
|
Output: df (unchanged, but logs validation report to context)
|
||||||
|
"""
|
||||||
|
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||||
|
if df.empty:
|
||||||
|
report['status'] = 'empty'
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||||
|
if missing:
|
||||||
|
report['status'] = 'invalid'
|
||||||
|
report['missing_cols'] = missing
|
||||||
|
|
||||||
|
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||||
|
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||||
|
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# legacy compat - kept for backwards compatibility with existing code
|
||||||
|
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||||
|
"""Single-session feature extraction (legacy interface)."""
|
||||||
|
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||||
|
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||||
|
'session_duration_sec', 'interaction_velocity',
|
||||||
|
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||||
|
if session_df.empty:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
session_df = session_df.copy()
|
||||||
|
if 'sessionId' not in session_df.columns:
|
||||||
|
session_df['sessionId'] = 'tmp'
|
||||||
|
|
||||||
|
# use a dummy context for the steps
|
||||||
|
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||||
|
ctx = DummyCtx()
|
||||||
|
|
||||||
|
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||||
|
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||||
|
p = ProductFeatureStep(ctx).transform(session_df)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for df in [t, b, p]:
|
||||||
|
if not df.empty:
|
||||||
|
for col in df.columns:
|
||||||
|
if col != 'sessionId':
|
||||||
|
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||||
|
|
||||||
|
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||||
|
for old, new in remap.items():
|
||||||
|
if old in result:
|
||||||
|
result[new] = result.pop(old)
|
||||||
|
return result
|
||||||
0
experiments/procesing/tests/__init__.py
Normal file
0
experiments/procesing/tests/__init__.py
Normal file
281
experiments/procesing/tests/conftest.py
Normal file
281
experiments/procesing/tests/conftest.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
|
||||||
|
|
||||||
|
class MockProvider(DataProvider):
|
||||||
|
"""Mock provider for testing, holds in-memory fixtures"""
|
||||||
|
|
||||||
|
def __init__(self, products_df=None, experiments_df=None, kafka_data=None):
|
||||||
|
self._products = products_df if products_df is not None else pd.DataFrame()
|
||||||
|
self._experiments = experiments_df if experiments_df is not None else pd.DataFrame()
|
||||||
|
self._kafka_data = kafka_data if kafka_data is not None else {}
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
return self._products.copy()
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if self._experiments.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
return self._experiments[
|
||||||
|
self._experiments['id'].isin(experiment_ids)
|
||||||
|
].copy()
|
||||||
|
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
return self._kafka_data.get(topic, pd.DataFrame()).copy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_products():
|
||||||
|
"""Standard product catalog fixture with realistic IDs from test data"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': [
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
],
|
||||||
|
'name': ['Junior Suite', 'Superior Room', 'Deluxe Room'],
|
||||||
|
'base_price': [200.0, 150.0, 180.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for interactions, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 203, 'timestamp': 1764102082676,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'learn_more_about_item',
|
||||||
|
'page': '/hotel/products/d018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'type': 'hotel', 'dateIndex': 1, 'roomType': 'Junior Suite'},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:22.674Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 204, 'timestamp': 1764102086982,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'page_view',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': None,
|
||||||
|
'metadata': {'referrer': ''},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:26.947Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 205, 'timestamp': 1764102091825,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'hover_over_title',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'Superior Room', 'dateIndex': 1, 'dwellTime': 1200},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:31.823Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 206, 'timestamp': 1764102094193,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1307},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:34.191Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 207, 'timestamp': 1764102101970,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1201},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:41.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions(mock_interactions_raw_kafka):
|
||||||
|
"""Processed interaction DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
records = [msg['value']['payload'] for msg in mock_interactions_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for price logs, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 32, 'timestamp': 1764104757969,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 162.47,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 33, 'timestamp': 1764104757995,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 743.49,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.993Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 34, 'timestamp': 1764104758011,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 163.87,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.009Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 35, 'timestamp': 1764104758050,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 397.46,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.049Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 36, 'timestamp': 1764104768865,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 401.66,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:06:08.864Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs(mock_price_logs_raw_kafka):
|
||||||
|
"""Processed price logs DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
# extract payloads and flatten
|
||||||
|
records = [msg['value']['payload'] for msg in mock_price_logs_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_experiments():
|
||||||
|
"""Standard experiment metadata fixture matching Supabase schema"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': ['53aefd07-f66a-4d7f-ba8b-7ea1fc562d35', 'bbbbcccc-dddd-eeee-ffff-000011112222'],
|
||||||
|
'created_at': pd.to_datetime(['2025-11-25T20:00:00Z', '2025-11-26T10:00:00Z']),
|
||||||
|
'subject_name': ['Session A', 'Session B'],
|
||||||
|
'xp_human_only': [True, False],
|
||||||
|
'xp_market_mode': ['hotel', 'airline'],
|
||||||
|
'xp_task_id': [None, None]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider(mock_products, mock_experiments, mock_interactions, mock_price_logs):
|
||||||
|
"""Fully configured mock provider"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=mock_products,
|
||||||
|
experiments_df=mock_experiments,
|
||||||
|
kafka_data={
|
||||||
|
'user-interactions': mock_interactions,
|
||||||
|
'price-logs': mock_price_logs
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipeline_context(mock_provider):
|
||||||
|
"""Standard pipeline context for testing"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=mock_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s',
|
||||||
|
n_price_buckets=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_provider():
|
||||||
|
"""Provider with no data, for edge case testing"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=pd.DataFrame(columns=['id', 'name', 'base_price']),
|
||||||
|
experiments_df=pd.DataFrame(columns=['id', 'created_at', 'subject_name', 'xp_human_only', 'xp_market_mode', 'xp_task_id']),
|
||||||
|
kafka_data={'user-interactions': pd.DataFrame(), 'price-logs': pd.DataFrame()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_context(empty_provider):
|
||||||
|
"""Context with empty provider"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=empty_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_interactions(mock_interactions):
|
||||||
|
"""Enriched interaction data for session feature extraction tests"""
|
||||||
|
df = mock_interactions.copy()
|
||||||
|
df['userAgent'] = ['Mozilla/5.0 Chrome/120', 'Mozilla/5.0 Chrome/120',
|
||||||
|
'HeadlessChrome/120', 'HeadlessChrome/120', 'HeadlessChrome/120']
|
||||||
|
df['metadata_base_price'] = [None, None, 150.0, 150.0, 200.0]
|
||||||
|
return df
|
||||||
45
experiments/procesing/tests/test_augement.py
Normal file
45
experiments/procesing/tests/test_augement.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bucketing(pipeline_context):
|
||||||
|
step = CreatePriceBucketsStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal price data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'metadata_price': random.sample(range(10, 1000), 100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
# test if is categorical
|
||||||
|
assert isinstance(result['price_bucket'].dtype, pd.CategoricalDtype)
|
||||||
|
assert result['price_bucket'].nunique() == 3 # as per context config
|
||||||
|
# distribution check
|
||||||
|
counts = result['price_bucket'].value_counts()
|
||||||
|
assert all(counts > 0)
|
||||||
|
assert counts.max() - counts.min() <= 10 # roughly equal distribution for 100 samples
|
||||||
|
# Test with empty DataFrame
|
||||||
|
df = pd.DataFrame()
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_names(pipeline_context):
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'eventName': ['click', 'view', 'purchase'],
|
||||||
|
'productId': ['prod_1', 'prod_2', None],
|
||||||
|
'price_bucket': ['PB_1', None, 'PB_3']
|
||||||
|
})
|
||||||
|
step = AugmentEventNamesStep(context=pipeline_context)
|
||||||
|
result = step.transform(df)
|
||||||
|
expected_event_names = [
|
||||||
|
'click_prod_1@PB_1',
|
||||||
|
'view',
|
||||||
|
'purchase'
|
||||||
|
]
|
||||||
|
assert result['eventName'].tolist() == expected_event_names
|
||||||
51
experiments/procesing/tests/test_demand.py
Normal file
51
experiments/procesing/tests/test_demand.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
ComputeDemandStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compute_demand(pipeline_context):
|
||||||
|
random.seed(42) # deterministic test
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
assert all(result['demand_score'] > 100/3 -10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_demand_skewed(pipeline_context):
|
||||||
|
random.seed(42) # deterministic test
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], weights=[0.7, 0.2, 0.1], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
# test for skewness
|
||||||
|
scores = result.set_index('productId')['demand_score'].to_dict()
|
||||||
|
assert scores['d018efc1-25e9-4284-b276-80386e048b25'] > \
|
||||||
|
scores['51266ddb-5b07-47b7-89ee-5b5cae94bb11'] > \
|
||||||
|
scores['2cd7f756-fc65-4ba0-ab01-74521c1fff43']
|
||||||
51
experiments/procesing/tests/test_fetch.py
Normal file
51
experiments/procesing/tests/test_fetch.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_interactions_data(pipeline_context):
|
||||||
|
step = FetchInteractionsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"eventName",
|
||||||
|
"dateIndex",
|
||||||
|
"experimentId",
|
||||||
|
"storeMode",
|
||||||
|
"metadata_elementText"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
|
||||||
|
def test_fetch_price_logs(pipeline_context):
|
||||||
|
step = FetchPriceLogsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"price",
|
||||||
|
"productId"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
prices = data['price'].to_list()
|
||||||
|
assert min(prices) >= 0
|
||||||
|
assert max(prices) <= 9999
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiments_fetching(pipeline_context):
|
||||||
|
interactions = FetchInteractionsStep(pipeline_context).transform(None)
|
||||||
|
assert interactions is not None
|
||||||
|
experiments = FetchExperimentsStep(pipeline_context)
|
||||||
|
experiment_data = experiments.transform(interactions)
|
||||||
|
assert experiment_data is not None
|
||||||
|
assert isinstance(experiment_data, pd.DataFrame)
|
||||||
|
assert not experiment_data.empty
|
||||||
|
assert 'id' in experiment_data.columns
|
||||||
|
assert len(experiment_data) == 2
|
||||||
|
assert '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35' in experiment_data['id'].values
|
||||||
87
experiments/procesing/tests/test_pricing.py
Normal file
87
experiments/procesing/tests/test_pricing.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit StaticPricer
|
||||||
|
pricer = StaticPricer()
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices match base prices
|
||||||
|
expected_prices = historical_data['base_price'].values
|
||||||
|
assert all(predicted_prices == expected_prices), "Predicted prices do not match base prices"
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit RandomPricer
|
||||||
|
pricer = RandomPricer(price_min=50.0, price_max=250.0, seed=42)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 250.0, "Predicted prices are above maximum bound"
|
||||||
|
# distribution check (not so strict)
|
||||||
|
assert len(set(predicted_prices)) > 1, "Predicted prices are not varied enough"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
def test_elasticity_based_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'productId': [1, 2, 3],
|
||||||
|
'elasticity': [-1.5, -0.5, -2.0],
|
||||||
|
'base_price': [100.0, 150.0, 200.0],
|
||||||
|
'mean_demand': [10, 20, 15]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit ElasticityBasedPricer
|
||||||
|
pricer = ElasticityBasedPricer(alpha=0.1, price_floor=50.0, price_ceil=300.0)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Create a mock state space with demand deviations
|
||||||
|
class MockStateSpace:
|
||||||
|
def __init__(self, demand):
|
||||||
|
self.demand = demand
|
||||||
|
|
||||||
|
# Simulate demand higher than mean for all products
|
||||||
|
state_space = MockStateSpace(demand=[15, 25, 20])
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 300.0, "Predicted prices are above maximum bound"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
# now we gotta check semantic validity
|
||||||
|
# since demand is higher than mean, prices should generally increase
|
||||||
|
for i, row in historical_data.iterrows():
|
||||||
|
base_price = row['base_price']
|
||||||
|
elasticity = row['elasticity']
|
||||||
|
expected_increase = base_price * (1 + 0.1 * abs(elasticity) * ((state_space.demand[i] - row['mean_demand']) / row['mean_demand']))
|
||||||
|
assert predicted_prices[i] >= base_price, f"Predicted price for product {row['productId']} did not increase as expected"
|
||||||
|
assert abs(predicted_prices[i] - expected_increase) < 1e-5, f"Predicted price for product {row['productId']} does not match expected calculation within 1e-5 tolerance"
|
||||||
8
experiments/pytest.ini
Normal file
8
experiments/pytest.ini
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = procesing/tests agents
|
||||||
|
python_files = test*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
41
lib/__init__.py
Normal file
41
lib/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
"""PHANTOM shared library
|
||||||
|
Exports unified utilities for features, state, config, kafka, and model registry
|
||||||
|
"""
|
||||||
|
from .config import (
|
||||||
|
PROJECT_ROOT, DATA_DIR, EXPERIMENTS_DIR,
|
||||||
|
AGENT_DATA_DIR, HUMAN_DATA_DIR, SIM_RUNS_DIR, MODEL_REGISTRY_DIR,
|
||||||
|
COLLECTED_DATA_DIR, NOTEBOOK_OUTPUT_DIR,
|
||||||
|
ensure_dir, get_data_path, get_experiments_path, get_sim_path,
|
||||||
|
KAFKA_HOST, KAFKA_PORT, KAFKA_BROKER,
|
||||||
|
REDIS_HOST, REDIS_PORT,
|
||||||
|
SUPABASE_URL, SUPABASE_ANON_KEY,
|
||||||
|
BACKEND_PORT, PROVIDER_PORT
|
||||||
|
)
|
||||||
|
from .state import (
|
||||||
|
make_state_repr, event_to_state, parse_state,
|
||||||
|
get_event_name, get_timestamp,
|
||||||
|
create_state_fn, create_event_name_fn, create_timestamp_fn
|
||||||
|
)
|
||||||
|
from .features import (
|
||||||
|
transition_histogram, temporal_signature, state_coverage, transition_entropy,
|
||||||
|
event_type_distribution, featurize_trajectory, parse_timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# config
|
||||||
|
'PROJECT_ROOT', 'DATA_DIR', 'EXPERIMENTS_DIR',
|
||||||
|
'AGENT_DATA_DIR', 'HUMAN_DATA_DIR', 'SIM_RUNS_DIR', 'MODEL_REGISTRY_DIR',
|
||||||
|
'COLLECTED_DATA_DIR', 'NOTEBOOK_OUTPUT_DIR',
|
||||||
|
'ensure_dir', 'get_data_path', 'get_experiments_path', 'get_sim_path',
|
||||||
|
'KAFKA_HOST', 'KAFKA_PORT', 'KAFKA_BROKER',
|
||||||
|
'REDIS_HOST', 'REDIS_PORT',
|
||||||
|
'SUPABASE_URL', 'SUPABASE_ANON_KEY',
|
||||||
|
'BACKEND_PORT', 'PROVIDER_PORT',
|
||||||
|
# state
|
||||||
|
'make_state_repr', 'event_to_state', 'parse_state',
|
||||||
|
'get_event_name', 'get_timestamp',
|
||||||
|
'create_state_fn', 'create_event_name_fn', 'create_timestamp_fn',
|
||||||
|
# features
|
||||||
|
'transition_histogram', 'temporal_signature', 'state_coverage', 'transition_entropy',
|
||||||
|
'event_type_distribution', 'featurize_trajectory', 'parse_timestamp',
|
||||||
|
]
|
||||||
65
lib/config.py
Normal file
65
lib/config.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""Unified path configuration for PHANTOM project
|
||||||
|
All hardcoded paths should reference this module
|
||||||
|
Paths can be overridden via environment variables
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# project root (directory containing lib/, experiments/, sim/, web/, backend/)
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||||
|
|
||||||
|
# data directories
|
||||||
|
DATA_DIR = Path(os.getenv('PHANTOM_DATA_DIR', PROJECT_ROOT / 'data'))
|
||||||
|
EXPERIMENTS_DIR = Path(os.getenv('PHANTOM_EXPERIMENTS_DIR', PROJECT_ROOT / 'experiments'))
|
||||||
|
|
||||||
|
# agent/human interaction data
|
||||||
|
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', DATA_DIR / 'agents'))
|
||||||
|
HUMAN_DATA_DIR = Path(os.getenv('PHANTOM_HUMAN_DATA_DIR', DATA_DIR / 'humans'))
|
||||||
|
|
||||||
|
# RL simulation runs
|
||||||
|
SIM_RUNS_DIR = Path(os.getenv('PHANTOM_SIM_RUNS_DIR', PROJECT_ROOT / 'sim' / 'rl' / 'runs'))
|
||||||
|
|
||||||
|
# model artifacts
|
||||||
|
MODEL_REGISTRY_DIR = Path(os.getenv('PHANTOM_MODEL_REGISTRY_DIR', DATA_DIR / 'models'))
|
||||||
|
|
||||||
|
# collected experiment data
|
||||||
|
COLLECTED_DATA_DIR = Path(os.getenv('PHANTOM_COLLECTED_DATA_DIR', EXPERIMENTS_DIR / 'agents' / 'collected_data'))
|
||||||
|
|
||||||
|
# notebook outputs
|
||||||
|
NOTEBOOK_OUTPUT_DIR = Path(os.getenv('PHANTOM_NOTEBOOK_OUTPUT_DIR', EXPERIMENTS_DIR / 'notebooks' / 'outputs'))
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path: Path) -> Path:
|
||||||
|
"""ensure directory exists, create if needed"""
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_data_path(*parts: str) -> Path:
|
||||||
|
"""construct path relative to DATA_DIR"""
|
||||||
|
return DATA_DIR.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_experiments_path(*parts: str) -> Path:
|
||||||
|
"""construct path relative to EXPERIMENTS_DIR"""
|
||||||
|
return EXPERIMENTS_DIR.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sim_path(*parts: str) -> Path:
|
||||||
|
"""construct path relative to SIM_RUNS_DIR"""
|
||||||
|
return SIM_RUNS_DIR.joinpath(*parts)
|
||||||
|
|
||||||
|
|
||||||
|
# service configuration (from .env)
|
||||||
|
KAFKA_HOST = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
|
KAFKA_PORT = os.getenv('KAFKA_PORT', '9092')
|
||||||
|
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
|
||||||
|
|
||||||
|
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
|
||||||
|
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
|
||||||
|
|
||||||
|
SUPABASE_URL = os.getenv('NEXT_PUBLIC_SUPABASE_URL', '')
|
||||||
|
SUPABASE_ANON_KEY = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
|
||||||
|
|
||||||
|
BACKEND_PORT = int(os.getenv('BACKEND_PORT', '5000'))
|
||||||
|
PROVIDER_PORT = int(os.getenv('PROVIDER_PORT', '5001'))
|
||||||
125
lib/features.py
Normal file
125
lib/features.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
"""Unified featurization utilities for trajectory -> feature vector conversion
|
||||||
|
Used by both experiments/ml/ and sim/rl/ components
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import List, Dict, Callable, Optional, Any, Set
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
def transition_histogram(events: List, state_fn: Callable, max_states: int = 50) -> np.ndarray:
|
||||||
|
"""compute normalized histogram of state transitions in trajectory
|
||||||
|
events: list of event objects/dicts
|
||||||
|
state_fn: function mapping event -> state string
|
||||||
|
max_states: maximum dimensions for histogram
|
||||||
|
"""
|
||||||
|
if len(events) < 2:
|
||||||
|
return np.zeros(max_states, dtype=np.float32)
|
||||||
|
states = [state_fn(e) for e in events]
|
||||||
|
trans_counts = defaultdict(int)
|
||||||
|
for s, s_next in zip(states, states[1:]):
|
||||||
|
trans_counts[(s, s_next)] += 1
|
||||||
|
total = sum(trans_counts.values())
|
||||||
|
hist = np.array(list(trans_counts.values())[:max_states], dtype=np.float32)
|
||||||
|
hist = np.pad(hist, (0, max(0, max_states - len(hist))))
|
||||||
|
return hist / (total + 1e-10)
|
||||||
|
|
||||||
|
|
||||||
|
def temporal_signature(events: List, ts_fn: Callable) -> np.ndarray:
|
||||||
|
"""extract temporal features: mean/std/skew of inter-event times plus count
|
||||||
|
events: list of event objects/dicts
|
||||||
|
ts_fn: function mapping event -> timestamp (float seconds)
|
||||||
|
returns: [mean_dt, std_dt, skew, n_intervals] array
|
||||||
|
"""
|
||||||
|
if len(events) < 2:
|
||||||
|
return np.zeros(4, dtype=np.float32)
|
||||||
|
times = sorted([ts_fn(e) for e in events])
|
||||||
|
diffs = np.diff(times).astype(np.float32)
|
||||||
|
if len(diffs) == 0:
|
||||||
|
return np.zeros(4, dtype=np.float32)
|
||||||
|
mean_dt, std_dt = np.mean(diffs), np.std(diffs) + 1e-10
|
||||||
|
skew = np.mean(((diffs - mean_dt) / std_dt) ** 3) if std_dt > 1e-8 else 0.0
|
||||||
|
return np.array([mean_dt, std_dt, skew, len(diffs)], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def state_coverage(events: List, state_fn: Callable, mdp_states: Set[str]) -> float:
|
||||||
|
"""fraction of MDP states visited by trajectory
|
||||||
|
events: list of event objects/dicts
|
||||||
|
state_fn: function mapping event -> state string
|
||||||
|
mdp_states: set of all possible MDP states
|
||||||
|
"""
|
||||||
|
if not mdp_states:
|
||||||
|
return 0.0
|
||||||
|
visited = set(state_fn(e) for e in events)
|
||||||
|
return len(visited & mdp_states) / len(mdp_states)
|
||||||
|
|
||||||
|
|
||||||
|
def transition_entropy(events: List, state_fn: Callable) -> float:
|
||||||
|
"""compute entropy of transition distribution (randomness of navigation)
|
||||||
|
higher entropy = more random browsing pattern
|
||||||
|
"""
|
||||||
|
if len(events) < 2:
|
||||||
|
return 0.0
|
||||||
|
states = [state_fn(e) for e in events]
|
||||||
|
trans_counts = defaultdict(int)
|
||||||
|
for s, s_next in zip(states, states[1:]):
|
||||||
|
trans_counts[(s, s_next)] += 1
|
||||||
|
total = sum(trans_counts.values())
|
||||||
|
probs = [c / total for c in trans_counts.values()]
|
||||||
|
return -sum(p * np.log(p + 1e-10) for p in probs)
|
||||||
|
|
||||||
|
|
||||||
|
def event_type_distribution(events: List, event_name_fn: Callable) -> np.ndarray:
|
||||||
|
"""compute proportions of different event type categories
|
||||||
|
returns: [page_view_ratio, hover_ratio, cart_ratio, purchase_ratio]
|
||||||
|
"""
|
||||||
|
if not events:
|
||||||
|
return np.zeros(4, dtype=np.float32)
|
||||||
|
n = len(events)
|
||||||
|
names = [event_name_fn(e).lower() for e in events]
|
||||||
|
return np.array([
|
||||||
|
sum(1 for nm in names if 'page' in nm or 'view' in nm) / n,
|
||||||
|
sum(1 for nm in names if 'hover' in nm) / n,
|
||||||
|
sum(1 for nm in names if 'cart' in nm) / n,
|
||||||
|
sum(1 for nm in names if 'purchase' in nm or 'checkout' in nm) / n
|
||||||
|
], dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def featurize_trajectory(events: List, state_fn: Callable, ts_fn: Callable,
|
||||||
|
event_name_fn: Callable, mdp_states: Optional[Set[str]] = None,
|
||||||
|
output_dim: int = 64) -> np.ndarray:
|
||||||
|
"""convert trajectory to fixed-dimension feature vector
|
||||||
|
events: list of event objects/dicts
|
||||||
|
state_fn: function mapping event -> state string
|
||||||
|
ts_fn: function mapping event -> timestamp (float)
|
||||||
|
event_name_fn: function mapping event -> event name string
|
||||||
|
mdp_states: optional set of all MDP states for coverage calculation
|
||||||
|
output_dim: desired output dimension (will pad/truncate)
|
||||||
|
"""
|
||||||
|
feats = []
|
||||||
|
feats.extend(transition_histogram(events, state_fn, max_states=40)) # 40 dims
|
||||||
|
feats.extend(temporal_signature(events, ts_fn)) # 4 dims
|
||||||
|
feats.append(state_coverage(events, state_fn, mdp_states or set())) # 1 dim
|
||||||
|
feats.append(transition_entropy(events, state_fn)) # 1 dim
|
||||||
|
feats.append(float(len(events))) # trajectory length
|
||||||
|
feats.append(float(len(set(state_fn(e) for e in events)))) # unique states
|
||||||
|
feats.extend(event_type_distribution(events, event_name_fn)) # 4 dims
|
||||||
|
|
||||||
|
feats = np.array(feats[:output_dim], dtype=np.float32)
|
||||||
|
if len(feats) < output_dim:
|
||||||
|
feats = np.pad(feats, (0, output_dim - len(feats)))
|
||||||
|
return feats
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timestamp(ts: Any) -> float:
|
||||||
|
"""parse various timestamp formats to float seconds"""
|
||||||
|
if ts is None:
|
||||||
|
return 0.0
|
||||||
|
if isinstance(ts, (int, float)):
|
||||||
|
return float(ts)
|
||||||
|
if isinstance(ts, str):
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
return 0.0
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user