mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
25 Commits
cleanup
...
pipeline-e
| Author | SHA1 | Date | |
|---|---|---|---|
| d0a5748ca1 | |||
| dd33f83e10 | |||
| 5d5795b212 | |||
| d0d18927cf | |||
| c8a69f0e3b | |||
| 8fae7851a6 | |||
| 73e46200c7 | |||
| e9d9c0e319 | |||
| b5c71e713b | |||
| e79edf2ef3 | |||
| f3bc81e0ed | |||
| 1054fe7720 | |||
| bdd72b5a85 | |||
| 33c20ec715 | |||
| 505c4fcd42 | |||
| eb30b04271 | |||
| 519b3b7f93 | |||
| b38f2b0c66 | |||
| f749bd749c | |||
| d8a3131d3c | |||
| a3ac3fba59 | |||
| cc841ae0a5 | |||
| 1bbfc699c2 | |||
| 219370cd95 | |||
| de7a386fc7 |
@@ -1,17 +0,0 @@
|
||||
.git
|
||||
.venv
|
||||
.venv-tpu
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/*.pyo
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
**/.ipynb_checkpoints
|
||||
wandb
|
||||
build
|
||||
paper/build
|
||||
paper/build-cais
|
||||
node_modules
|
||||
**/node_modules
|
||||
*.egg-info
|
||||
@@ -1,18 +0,0 @@
|
||||
# Copy this file to .env.sweep and fill in values.
|
||||
|
||||
# Required for wandb runs and sweep agent workers.
|
||||
WANDB_API_KEY=
|
||||
WANDB_ENTITY=
|
||||
WANDB_PROJECT=phantom-pricing
|
||||
|
||||
# Required for private repo bootstrap workers.
|
||||
GITHUB_TOKEN=
|
||||
|
||||
# Optional defaults for bootstrap mode.
|
||||
# REPO_URL=https://github.com/org/repo.git
|
||||
# BRANCH=main
|
||||
# WORKDIR=$HOME/PHANTOM-agent
|
||||
# SWEEP_ID=entity/project/id
|
||||
# AGENT_COUNT=0
|
||||
# AGENT_LOOP=1
|
||||
# RETRY_SECONDS=20
|
||||
48
.github/workflows/latex.yml
vendored
48
.github/workflows/latex.yml
vendored
@@ -19,56 +19,10 @@ jobs:
|
||||
with:
|
||||
root_file: main.tex
|
||||
working_directory: paper/src
|
||||
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
||||
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
|
||||
pre_compile: bash ../concat_code.sh
|
||||
- name: Upload PDF
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thesis-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
|
||||
|
||||
75
.gitignore
vendored
75
.gitignore
vendored
@@ -1,86 +1,13 @@
|
||||
# environment and secrets
|
||||
**/.env
|
||||
.env.*
|
||||
!.env.*.example
|
||||
**/.venv
|
||||
|
||||
# python build/cache artifacts
|
||||
**/__pycache__
|
||||
phantom.egg-info/
|
||||
*.egg-info/
|
||||
|
||||
# notebook artifacts
|
||||
**/.ipynb_checkpoints/
|
||||
**/.virtual_documents/
|
||||
|
||||
# editor/tool state
|
||||
**/.pdf-view-restore
|
||||
.nextstep
|
||||
.ignore-gitlogue
|
||||
.cloudflare
|
||||
|
||||
# generated svg/graphics
|
||||
**/session_*.svg
|
||||
**/*graph.svg
|
||||
**/auto/*.el
|
||||
|
||||
# misc generated
|
||||
*.old
|
||||
**/package-lock.json
|
||||
**/*.parquet
|
||||
**/_build/
|
||||
|
||||
# paper build artifacts
|
||||
paper/src/bib/auto
|
||||
paper/src/auto/*
|
||||
paper/src/bib/auto
|
||||
paper/template/*
|
||||
paper/build-cais/
|
||||
paper/src/main.pdf
|
||||
paper/src/main-blx.bib
|
||||
paper/src/svg-inkscape/
|
||||
paper/src/mirrors/
|
||||
paper/variations/
|
||||
paper/src/graphics/test_*.png
|
||||
thesis-latest.pdf
|
||||
|
||||
# experiment run artifacts and logs
|
||||
docs/goals/*.md
|
||||
PHANTOM.wiki/
|
||||
# Airflow logs - exclude DAG run logs
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
experiments/collected_data/
|
||||
experiments/agents/collected_data/
|
||||
tests/e2e/test-results/
|
||||
tests/e2e/node_modules/**
|
||||
|
||||
# rl/sim run outputs
|
||||
sim/rl/behavior_loader/*.dot
|
||||
sim/rl/behavior_loader/*.png
|
||||
sim/rl/behavior_loader/*.svg
|
||||
sim/rl/behavior_loader/*.pdf
|
||||
sim/rl/runs/
|
||||
lab/case/thesis/runs*/
|
||||
sim/case/thesis_simplified/runs*/
|
||||
|
||||
# model binaries
|
||||
engine/models/*.zip
|
||||
*.zip
|
||||
|
||||
# wandb local state
|
||||
wandb/
|
||||
|
||||
# data directory (large datasets)
|
||||
data/
|
||||
|
||||
# ktem local app data
|
||||
ktem_app_data/
|
||||
|
||||
# generated visualization pdfs
|
||||
*_mdp_viz.pdf
|
||||
phantom_env_comparison.png
|
||||
sim/phantom_env_comparison.png
|
||||
|
||||
# web clone
|
||||
PHANTOM_web/*
|
||||
|
||||
190
Makefile
190
Makefile
@@ -9,210 +9,48 @@ PYTHON := $(VENV)/bin/python
|
||||
PIP := $(VENV)/bin/pip
|
||||
PYTEST := $(VENV)/bin/pytest
|
||||
|
||||
SWEEP_ENV_FILE ?= .env.sweep
|
||||
|
||||
WANDB_ENTITY ?=
|
||||
WANDB_PROJECT ?= phantom-pricing
|
||||
SWEEP_ID ?=
|
||||
LOCAL_TRAIN_ARGS ?= --algo ppo --total-timesteps 50000
|
||||
AGENT_COUNT ?= 0
|
||||
|
||||
REPO_URL ?=
|
||||
BRANCH ?= main
|
||||
WORKDIR ?= $(HOME)/PHANTOM-agent
|
||||
AGENT_LOOP ?= 1
|
||||
RETRY_SECONDS ?= 20
|
||||
|
||||
TRAIN_IMAGE_REF := us-central1-docker.pkg.dev/phantom-trc/phantom/phantom-trainer
|
||||
TPU_NAME ?=
|
||||
TPU_ZONE ?= us-central2-b
|
||||
TPU_PROJECT ?= phantom-trc
|
||||
TPU_REPO_DIR ?= /tmp/PHANTOM
|
||||
|
||||
SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || true; set +a
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | train | train.agent | train.bootstrap | train.tpu.pod | train.tpu.vm | train.tpu.vm.sweep | stats.lines"
|
||||
@echo "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)"
|
||||
all: pdf
|
||||
|
||||
run.webapp:
|
||||
@cd web && npm install && npm run dev
|
||||
|
||||
$(BUILDDIR):
|
||||
mkdir -p paper/$(BUILDDIR)
|
||||
|
||||
.PHONY: pdf.build
|
||||
pdf.build: $(BUILDDIR)
|
||||
pdf: $(BUILDDIR)
|
||||
@echo "Concatenating source code..."
|
||||
@bash paper/concat_code.sh
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) -f \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
.PHONY: pdf.watch
|
||||
pdf.watch: $(BUILDDIR)
|
||||
watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
.PHONY: pdf.clean
|
||||
pdf.clean:
|
||||
clean:
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||
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):
|
||||
python3 -m venv $(VENV)
|
||||
$(PIP) install --upgrade pip
|
||||
|
||||
.PHONY: install
|
||||
install: $(VENV)
|
||||
$(PIP) install -r requirements.txt
|
||||
|
||||
.PHONY: train
|
||||
train: install
|
||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" WANDB_ENTITY="$(WANDB_ENTITY)" WANDB_PROJECT="$(WANDB_PROJECT)" \
|
||||
$(PYTHON) -m engine.train $(LOCAL_TRAIN_ARGS)
|
||||
test: $(VENV)
|
||||
$(PYTEST) -v
|
||||
|
||||
.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:
|
||||
count-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=$(TPU_PROJECT) --worker=all
|
||||
@$(SWEEP_ENV_LOAD); \
|
||||
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
||||
--command="WANDB_API_KEY='$$WANDB_API_KEY' SWEEP_ID='$(SWEEP_ID)' AGENT_COUNT='$(AGENT_COUNT)' sh /tmp/tpu_pod_run.sh"
|
||||
|
||||
.PHONY: train.tpu.vm.prepare
|
||||
train.tpu.vm.prepare:
|
||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||
TPU_NAME="$(TPU_NAME)" TPU_ZONE="$(TPU_ZONE)" TPU_PROJECT="$(TPU_PROJECT)" \
|
||||
LOCAL_REPO_DIR="$(CURDIR)" REMOTE_REPO_DIR="$(TPU_REPO_DIR)" \
|
||||
sh scripts/tpu_sync_repo.sh
|
||||
gcloud compute tpus tpu-vm scp scripts/tpu_vm_train.sh $(TPU_NAME):/tmp/tpu_vm_train.sh \
|
||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all
|
||||
|
||||
.PHONY: train.tpu.vm.run
|
||||
train.tpu.vm.run:
|
||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||
@test -n "$(LOCAL_TRAIN_ARGS)" || (echo "LOCAL_TRAIN_ARGS required, e.g. --algo ppo --jax --total-timesteps 200000" && exit 1)
|
||||
@$(SWEEP_ENV_LOAD); \
|
||||
gcloud compute tpus tpu-vm ssh $(TPU_NAME) \
|
||||
--zone=$(TPU_ZONE) --project=$(TPU_PROJECT) --worker=all \
|
||||
--command="REPO_DIR='$(TPU_REPO_DIR)' TRAIN_ARGS='$(LOCAL_TRAIN_ARGS)' WANDB_API_KEY='$$WANDB_API_KEY' sh /tmp/tpu_vm_train.sh"
|
||||
|
||||
.PHONY: train.tpu.vm
|
||||
train.tpu.vm: train.tpu.vm.prepare train.tpu.vm.run
|
||||
|
||||
.PHONY: train.tpu.vm.sweep
|
||||
train.tpu.vm.sweep:
|
||||
@test -n "$(TPU_NAME)" || (echo "TPU_NAME required, e.g. TPU_NAME=TPUlong" && exit 1)
|
||||
@test -n "$(SWEEP_ID)" || (echo "SWEEP_ID required, e.g. SWEEP_ID=lusiana/phantom-pricing/abc123" && exit 1)
|
||||
@$(SWEEP_ENV_LOAD); test -n "$$WANDB_API_KEY" || (echo "WANDB_API_KEY required — set it in $(SWEEP_ENV_FILE)" && exit 1)
|
||||
@$(SWEEP_ENV_LOAD); WANDB_API_KEY="$$WANDB_API_KEY" \
|
||||
python3 scripts/tpu_vm_sweep_agent.py \
|
||||
--sweep-id "$(SWEEP_ID)" \
|
||||
--tpu-name "$(TPU_NAME)" \
|
||||
--tpu-zone "$(TPU_ZONE)" \
|
||||
--tpu-project "$(TPU_PROJECT)" \
|
||||
--tpu-repo-dir "$(TPU_REPO_DIR)" \
|
||||
$(if $(filter-out 0,$(AGENT_COUNT)),--count $(AGENT_COUNT),)
|
||||
|
||||
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||
pdf: pdf.build
|
||||
clean: pdf.clean
|
||||
watch: pdf.watch
|
||||
run.webapp: web.dev
|
||||
test: test.backend
|
||||
count-lines: stats.lines
|
||||
all: pdf.build
|
||||
.PHONY: all pdf clean watch run.webapp install test
|
||||
|
||||
93
README.md
93
README.md
@@ -1,94 +1,5 @@
|
||||
<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://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
|
||||
```
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
64 spot Cloud TPU v6e chips in zone europe-west4-a
|
||||
32 spot Cloud TPU v4 chips in zone us-central2-b
|
||||
64 spot Cloud TPU v5e chips in zone us-central1-a
|
||||
64 spot Cloud TPU v6e chips in zone us-east1-d
|
||||
32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
||||
64 spot Cloud TPU v5e chips in zone europe-west4-b
|
||||
@@ -1,22 +0,0 @@
|
||||
# 32 spot Cloud TPU v4 chips in zone us-central2-b
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUv4s32spotUC2B
|
||||
export TPU_NAME=tpu-v4-32-uc2b-spot
|
||||
export ZONE=us-central2-b
|
||||
export ACCELERATOR_TYPE=v4-32
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv4
|
||||
|
||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--version=${RUNTIME_VERSION} \
|
||||
--spot \
|
||||
|| \
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION} \
|
||||
--spot
|
||||
@@ -1,13 +0,0 @@
|
||||
# 32 on-demand Cloud TPU v4 chips in zone us-central2-b
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUlong
|
||||
export ZONE=us-central2-b
|
||||
export ACCELERATOR_TYPE=v4-32
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv4
|
||||
#gcloud compute tpus tpu-vm create ${TPU_NAME} --zone=${ZONE} --project=${PROJECT_ID} --accelerator-type=${ACCELERATOR_TYPE} --version=${RUNTIME_VERSION}
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION}
|
||||
@@ -1,22 +0,0 @@
|
||||
# 64 spot Cloud TPU v5e chips in zone europe-west4-b
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUv5e64spotEW4B
|
||||
export TPU_NAME=tpu-v5e-64-ew4b
|
||||
export ZONE=europe-west4-b
|
||||
export ACCELERATOR_TYPE=v5e-64
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
||||
|
||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--version=${RUNTIME_VERSION} \
|
||||
--spot \
|
||||
|| \
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION} \
|
||||
--spot
|
||||
@@ -1,22 +0,0 @@
|
||||
# 64 spot Cloud TPU v5e chips in zone us-central1-a
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUv5e64spotUC1A
|
||||
export TPU_NAME=tpu-v5e-64-uc1a
|
||||
export ZONE=us-central1-a
|
||||
export ACCELERATOR_TYPE=v5e-64
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv5-lite
|
||||
|
||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--version=${RUNTIME_VERSION} \
|
||||
--spot \
|
||||
|| \
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION} \
|
||||
--spot
|
||||
@@ -1,22 +0,0 @@
|
||||
# 64 spot Cloud TPU v6e chips in zone europe-west4-a
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUv6e64spotEW4A
|
||||
export TPU_NAME=tpu-v6e-64-ew4a
|
||||
export ZONE=europe-west4-a
|
||||
export ACCELERATOR_TYPE=v6e-64
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
||||
|
||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--version=${RUNTIME_VERSION} \
|
||||
--spot \
|
||||
|| \
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION} \
|
||||
--spot
|
||||
@@ -1,22 +0,0 @@
|
||||
# 64 spot Cloud TPU v6e chips in zone us-east1-d
|
||||
export PROJECT_ID=phantom-trc
|
||||
export QR_NAME=TPUv6e64spotUE1D
|
||||
export TPU_NAME=tpu-v6e-64-ue1d
|
||||
export ZONE=us-east1-d
|
||||
export ACCELERATOR_TYPE=v6e-64
|
||||
export RUNTIME_VERSION=v2-alpha-tpuv6e
|
||||
|
||||
gcloud compute tpus tpu-vm create ${TPU_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--version=${RUNTIME_VERSION} \
|
||||
--spot \
|
||||
|| \
|
||||
gcloud compute tpus queued-resources create ${QR_NAME} \
|
||||
--project=${PROJECT_ID} \
|
||||
--zone=${ZONE} \
|
||||
--node-id=${TPU_NAME} \
|
||||
--accelerator-type=${ACCELERATOR_TYPE} \
|
||||
--runtime-version=${RUNTIME_VERSION} \
|
||||
--spot
|
||||
@@ -19,11 +19,11 @@ from procesing.pricers import (
|
||||
ElasticityBasedPricer
|
||||
)
|
||||
from procesing.steps import (
|
||||
StateSpace,
|
||||
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
|
||||
@@ -47,52 +47,122 @@ def health() -> dict:
|
||||
|
||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||
"""
|
||||
THIS is the fast lookup service (mechanism).
|
||||
Priority: session-keyed price > global optimal price > base price
|
||||
"""
|
||||
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
# 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'
|
||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self, backend_url: str):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
||||
|
||||
context = PipelineContext(
|
||||
provider=Provider(backend_url=os.getenv("BACKEND_URL")),
|
||||
store_mode=mode
|
||||
)
|
||||
|
||||
# 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'
|
||||
)
|
||||
pricing_model = registry.get_pricing_model('latest')
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
# PRIORITY 3: fallback to base price
|
||||
if pricing_model is None or elasticity_df is None:
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=1.0,
|
||||
elasticity=None,
|
||||
model_version='base'
|
||||
elasticity=None
|
||||
)
|
||||
|
||||
products = context.products
|
||||
if products.empty:
|
||||
raise HTTPException(500, "No products available in catalog")
|
||||
|
||||
# merge elasticity with product base prices
|
||||
products_with_meta = products.copy()
|
||||
products_with_meta['base_price'] = products_with_meta['metadata'].apply(
|
||||
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
|
||||
)
|
||||
|
||||
merged = products_with_meta[['id', 'base_price']].rename(
|
||||
columns={'id': 'productId'}
|
||||
).merge(
|
||||
elasticity_df[['productId', 'elasticity']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'elasticity': 0.0})
|
||||
|
||||
# compute demand: use pricer's mean_demand if available, else default
|
||||
demand_values = (pricing_model.mean_demand
|
||||
if hasattr(pricing_model, 'mean_demand') and pricing_model.mean_demand is not None
|
||||
else np.ones(len(merged)) * 10.0)
|
||||
|
||||
# build state space with session features if sessionId provided
|
||||
session_features = pd.DataFrame()
|
||||
if sessionId:
|
||||
try:
|
||||
# fetch recent session interactions from backend
|
||||
from procesing.steps.session import ExtractSessionFeaturesStep
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
t_end = datetime.utcnow()
|
||||
t_start = t_end - timedelta(hours=1)
|
||||
backend_url = os.getenv("BACKEND_URL")
|
||||
print(backend_url)
|
||||
|
||||
resp = requests.get(
|
||||
f"{os.getenv('BACKEND_URL')}/api/kafka/dump", # TODO: THIS IS SHIT, must fix this
|
||||
params={'topic': 'user-interactions', 't_start': t_start.isoformat(), 't_end': t_end.isoformat()},
|
||||
timeout=2
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
msgs = resp.json().get('messages', [])
|
||||
interactions_df = pd.DataFrame(msgs)
|
||||
|
||||
if not interactions_df.empty and 'sessionId' in interactions_df.columns:
|
||||
session_interactions = interactions_df[interactions_df['sessionId'] == sessionId]
|
||||
|
||||
if not session_interactions.empty:
|
||||
extractor = ExtractSessionFeaturesStep(context=context)
|
||||
session_features_df = extractor.transform(session_interactions)
|
||||
|
||||
if not session_features_df.empty:
|
||||
session_features = session_features_df.drop(columns=['sessionId'])
|
||||
except Exception as e:
|
||||
print(f"[session-features-error] {e}")
|
||||
# continue without session features
|
||||
|
||||
state = StateSpace(
|
||||
demand=demand_values,
|
||||
prices=merged['base_price'].values,
|
||||
session_features=session_features,
|
||||
product_ids=merged['productId'].values,
|
||||
elasticity=merged['elasticity'].values,
|
||||
metadata={'sessionId': sessionId, 'experimentId': experimentId}
|
||||
)
|
||||
|
||||
oracle = PredictPricesStep(context=context)
|
||||
prices_df = oracle.transform((pricing_model, state))
|
||||
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if product_price_row.empty:
|
||||
raise HTTPException(404, f"No pricing available for product {productId}")
|
||||
|
||||
optimal_price = float(product_price_row['predicted_price'].iloc[0])
|
||||
|
||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
||||
product_elasticity = (float(product_elasticity_row['elasticity'].iloc[0])
|
||||
if not product_elasticity_row.empty else None)
|
||||
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=product_elasticity
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
|
||||
@@ -12,5 +12,4 @@ graphviz
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
typing-extensions>=4.8.0
|
||||
pypickle
|
||||
pymc
|
||||
pickle5>=0.0.11; python_version < '3.8'
|
||||
|
||||
@@ -198,16 +198,12 @@ def dump_logs(
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=30000,
|
||||
fetch_max_wait_ms=10000,
|
||||
max_poll_records=1000
|
||||
consumer_timeout_ms=5000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if last_n and len(events) >= last_n * 2:
|
||||
break
|
||||
|
||||
consumer.close()
|
||||
|
||||
@@ -294,7 +290,6 @@ async def get_products(
|
||||
query = supabase.table(table).select('*')
|
||||
|
||||
# filter by exact date_index if provided
|
||||
# dateIndex from frontend is days from today, convert to days since epoch
|
||||
if dateIndex is not None:
|
||||
query = query.eq('date_index', dateIndex)
|
||||
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
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:
|
||||
container_name: "PHANTOM-backend"
|
||||
build:
|
||||
@@ -112,20 +92,23 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins
|
||||
- ./experiments/procesing:/opt/airflow/procesing
|
||||
- ./lib:/opt/airflow/lib
|
||||
command: version
|
||||
restart: "no"
|
||||
|
||||
@@ -139,20 +122,13 @@ services:
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
@@ -162,6 +138,12 @@ services:
|
||||
- REDIS_PORT=6379
|
||||
ports:
|
||||
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
||||
- ./lib:/opt/airflow/lib:ro
|
||||
command: webserver
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
@@ -182,20 +164,12 @@ services:
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- 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
|
||||
@@ -203,6 +177,12 @@ services:
|
||||
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
volumes:
|
||||
- ./experiments/airflow/dags:/opt/airflow/dags:ro
|
||||
- ./experiments/airflow/logs:/opt/airflow/logs
|
||||
- ./experiments/airflow/plugins:/opt/airflow/plugins:ro
|
||||
- ./experiments/procesing:/opt/airflow/procesing:ro
|
||||
- ./lib:/opt/airflow/lib:ro
|
||||
command: scheduler
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
@@ -228,9 +208,13 @@ services:
|
||||
- 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"
|
||||
volumes:
|
||||
- ./lib:/app/lib:ro
|
||||
- ./experiments/procesing:/app/procesing:ro
|
||||
- ./backend/provider:/app/provider:ro
|
||||
command: python -m uvicorn provider.app:app --host 0.0.0.0 --port 5001
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -21,10 +21,3 @@ RUN pip install --no-cache-dir \
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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"]
|
||||
@@ -14,13 +14,11 @@ RUN apt-get update && apt-get install -y \
|
||||
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/
|
||||
# Structure will be mounted via volumes:
|
||||
# /app/lib -> lib/
|
||||
# /app/procesing -> experiments/procesing/
|
||||
# /app/provider -> backend/provider/
|
||||
|
||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||
|
||||
WORKDIR /app/provider
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
CMD ["python", "-m", "uvicorn", "provider.app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/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}
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/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 "$@"
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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_publication_date" content="2025">
|
||||
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
||||
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
||||
<meta name="citation_pdf_url" content="TODO">
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="theme-color" content="#2563eb">
|
||||
@@ -233,13 +233,14 @@
|
||||
|
||||
<div class="is-size-5 publication-authors">
|
||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
||||
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
||||
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
|
||||
</div>
|
||||
|
||||
<div class="column has-text-centered">
|
||||
<div class="publication-links">
|
||||
<!-- TODO: Update with your arXiv paper ID -->
|
||||
<span class="link-block">
|
||||
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
||||
class="external-link button is-normal is-rounded is-dark">
|
||||
<span class="icon">
|
||||
<i class="fas fa-file-pdf"></i>
|
||||
@@ -314,10 +315,7 @@
|
||||
<h2 class="title is-3">Abstract</h2>
|
||||
<div class="content has-text-justified">
|
||||
<p>
|
||||
This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model to prove separability as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
||||
</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.
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -435,7 +433,8 @@
|
||||
<div class="container">
|
||||
<h2 class="title">Poster</h2>
|
||||
|
||||
<iframe src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
||||
<!-- TODO: Replace with your poster PDF -->
|
||||
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
|
||||
</iframe>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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()
|
||||
@@ -1,13 +0,0 @@
|
||||
"""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"]
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Orbax checkpoint helpers for JAX training runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import orbax.checkpoint as ocp
|
||||
|
||||
HAS_ORBAX = True
|
||||
except ImportError:
|
||||
HAS_ORBAX = False
|
||||
|
||||
|
||||
def _require_orbax() -> None:
|
||||
if not HAS_ORBAX:
|
||||
raise ImportError(
|
||||
"orbax-checkpoint is required for checkpoint support. "
|
||||
"Install engine/jax/requirements.txt first."
|
||||
)
|
||||
|
||||
|
||||
def create_manager(directory: str | Path, max_to_keep: int = 5):
|
||||
_require_orbax()
|
||||
root = Path(directory)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
options = ocp.CheckpointManagerOptions(
|
||||
max_to_keep=max(1, int(max_to_keep)), create=True
|
||||
)
|
||||
return ocp.CheckpointManager(root.as_posix(), ocp.PyTreeCheckpointer(), options)
|
||||
|
||||
|
||||
def save(manager, *, step: int, payload: Any) -> bool:
|
||||
_require_orbax()
|
||||
return bool(manager.save(int(step), payload))
|
||||
|
||||
|
||||
def latest_step(manager) -> int | None:
|
||||
_require_orbax()
|
||||
return manager.latest_step()
|
||||
|
||||
|
||||
def restore(manager, *, target: Any, step: int | None = None) -> Any:
|
||||
_require_orbax()
|
||||
step_to_restore = manager.latest_step() if step is None else int(step)
|
||||
if step_to_restore is None:
|
||||
return target
|
||||
return manager.restore(step_to_restore, items=target)
|
||||
@@ -1,287 +0,0 @@
|
||||
"""JAX-native PHANTOM environment with robust contamination step."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
try:
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise ImportError("engine.jax.env requires JAX") from exc
|
||||
|
||||
from .primitives import (
|
||||
_sample_sessions_jax,
|
||||
agent_probability_from_kl,
|
||||
batch_kl,
|
||||
compute_session_transitions,
|
||||
load_transition_data,
|
||||
purchase_flags,
|
||||
reward_with_coi_penalty,
|
||||
revenue_from_demand,
|
||||
weighted_demand,
|
||||
)
|
||||
|
||||
|
||||
class EnvParams(NamedTuple):
|
||||
n_products: int
|
||||
n_sessions: int
|
||||
max_episode_steps: int
|
||||
max_session_steps: int
|
||||
price_low: float
|
||||
price_high: float
|
||||
lambda_coi: float
|
||||
info_value: float
|
||||
robust_radius: float
|
||||
margin_floor: float
|
||||
margin_floor_patience: int
|
||||
action_scales: jax.Array
|
||||
alpha_nominal: float
|
||||
alpha_candidates: jax.Array
|
||||
human_T: jax.Array
|
||||
agent_T: jax.Array
|
||||
terminal_mask: jax.Array
|
||||
purchase_mask: jax.Array
|
||||
event_weights: jax.Array
|
||||
start_idx: int
|
||||
term_idx: int
|
||||
|
||||
|
||||
class EnvState(NamedTuple):
|
||||
prices: jax.Array
|
||||
demand: jax.Array
|
||||
step_count: jax.Array
|
||||
low_margin_streak: jax.Array
|
||||
last_agent_prob: jax.Array
|
||||
last_alpha_adv: jax.Array
|
||||
|
||||
|
||||
class CandidateEval(NamedTuple):
|
||||
reward: jax.Array
|
||||
revenue: jax.Array
|
||||
demand: jax.Array
|
||||
agent_prob: jax.Array
|
||||
leakage: jax.Array
|
||||
discount: jax.Array
|
||||
n_purchases: jax.Array
|
||||
n_agents: jax.Array
|
||||
|
||||
|
||||
def make_env_params(
|
||||
*,
|
||||
n_products: int,
|
||||
alpha: float,
|
||||
n_sessions: int,
|
||||
lambda_coi: float,
|
||||
robust_radius: float,
|
||||
robust_points: int,
|
||||
info_value: float,
|
||||
action_levels: int,
|
||||
action_scale_low: float,
|
||||
action_scale_high: float,
|
||||
price_low: float,
|
||||
price_high: float,
|
||||
max_episode_steps: int,
|
||||
max_session_steps: int = 40,
|
||||
margin_floor: float = 0.05,
|
||||
margin_floor_patience: int = 5,
|
||||
prefer_behavior_data: bool = True,
|
||||
) -> EnvParams:
|
||||
transition = load_transition_data(prefer_data=prefer_behavior_data).to_jax()
|
||||
if robust_radius <= 0.0 or robust_points <= 1:
|
||||
alpha_candidates = jnp.asarray([float(alpha)], dtype=jnp.float32)
|
||||
else:
|
||||
lo = max(0.0, float(alpha) - float(robust_radius))
|
||||
hi = min(1.0, float(alpha) + float(robust_radius))
|
||||
alpha_candidates = jnp.linspace(lo, hi, int(robust_points), dtype=jnp.float32)
|
||||
|
||||
action_scales = jnp.linspace(
|
||||
float(action_scale_low),
|
||||
float(action_scale_high),
|
||||
int(action_levels),
|
||||
dtype=jnp.float32,
|
||||
)
|
||||
return EnvParams(
|
||||
n_products=int(n_products),
|
||||
n_sessions=int(n_sessions),
|
||||
max_episode_steps=int(max_episode_steps),
|
||||
max_session_steps=int(max_session_steps),
|
||||
price_low=float(price_low),
|
||||
price_high=float(price_high),
|
||||
lambda_coi=float(lambda_coi),
|
||||
info_value=float(info_value),
|
||||
robust_radius=float(robust_radius),
|
||||
margin_floor=float(margin_floor),
|
||||
margin_floor_patience=int(margin_floor_patience),
|
||||
action_scales=action_scales,
|
||||
alpha_nominal=float(alpha),
|
||||
alpha_candidates=alpha_candidates,
|
||||
human_T=jnp.asarray(transition.human_T),
|
||||
agent_T=jnp.asarray(transition.agent_T),
|
||||
terminal_mask=jnp.asarray(transition.terminal_mask),
|
||||
purchase_mask=jnp.asarray(transition.purchase_mask),
|
||||
event_weights=jnp.asarray(transition.event_weights),
|
||||
start_idx=int(transition.start_idx),
|
||||
term_idx=int(transition.term_idx),
|
||||
)
|
||||
|
||||
|
||||
def _flatten_obs(demand: jax.Array, prices: jax.Array) -> jax.Array:
|
||||
return jnp.concatenate([demand.astype(jnp.float32), prices.astype(jnp.float32)])
|
||||
|
||||
|
||||
def _decode_action(
|
||||
prices: jax.Array, action: jax.Array, params: EnvParams
|
||||
) -> jax.Array:
|
||||
idx = jnp.clip(action.astype(jnp.int32), 0, params.action_scales.shape[0] - 1)
|
||||
scale = params.action_scales[idx]
|
||||
next_prices = prices * scale
|
||||
return jnp.clip(next_prices, params.price_low, params.price_high)
|
||||
|
||||
|
||||
def _evaluate_candidate(
|
||||
key: jax.Array,
|
||||
alpha_candidate: jax.Array,
|
||||
prices: jax.Array,
|
||||
params: EnvParams,
|
||||
) -> CandidateEval:
|
||||
states, products, actors, lengths = _sample_sessions_jax(
|
||||
key,
|
||||
params.human_T,
|
||||
params.agent_T,
|
||||
params.terminal_mask,
|
||||
params.start_idx,
|
||||
params.term_idx,
|
||||
alpha_candidate,
|
||||
params.n_products,
|
||||
params.n_sessions,
|
||||
params.max_session_steps,
|
||||
int(params.human_T.shape[0]),
|
||||
)
|
||||
session_trans = compute_session_transitions(
|
||||
states, lengths, int(params.human_T.shape[0])
|
||||
)
|
||||
delta_h, delta_a = batch_kl(session_trans, params.human_T, params.agent_T)
|
||||
agent_probs = agent_probability_from_kl(delta_h, delta_a)
|
||||
agent_prob = jnp.mean(agent_probs)
|
||||
|
||||
demand = weighted_demand(states, products, params.n_products, params.event_weights)
|
||||
revenue = revenue_from_demand(prices, demand)
|
||||
reward, leakage, discount = reward_with_coi_penalty(
|
||||
revenue,
|
||||
agent_prob,
|
||||
params.lambda_coi,
|
||||
params.info_value,
|
||||
)
|
||||
purchases = purchase_flags(states, params.purchase_mask)
|
||||
return CandidateEval(
|
||||
reward=reward,
|
||||
revenue=revenue,
|
||||
demand=demand,
|
||||
agent_prob=agent_prob,
|
||||
leakage=leakage,
|
||||
discount=discount,
|
||||
n_purchases=jnp.sum(purchases.astype(jnp.float32)),
|
||||
n_agents=jnp.sum(actors.astype(jnp.float32)),
|
||||
)
|
||||
|
||||
|
||||
def reset_env(key: jax.Array, params: EnvParams) -> tuple[jax.Array, EnvState]:
|
||||
prices = jax.random.uniform(
|
||||
key,
|
||||
shape=(params.n_products,),
|
||||
minval=params.price_low,
|
||||
maxval=params.price_high,
|
||||
)
|
||||
demand = jnp.zeros((params.n_products,), dtype=jnp.float32)
|
||||
state = EnvState(
|
||||
prices=prices,
|
||||
demand=demand,
|
||||
step_count=jnp.asarray(0, dtype=jnp.int32),
|
||||
low_margin_streak=jnp.asarray(0, dtype=jnp.int32),
|
||||
last_agent_prob=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
||||
last_alpha_adv=jnp.asarray(params.alpha_nominal, dtype=jnp.float32),
|
||||
)
|
||||
return _flatten_obs(demand, prices), state
|
||||
|
||||
|
||||
def step_env(
|
||||
key: jax.Array,
|
||||
state: EnvState,
|
||||
action: jax.Array,
|
||||
params: EnvParams,
|
||||
) -> tuple[jax.Array, EnvState, jax.Array, jax.Array, dict[str, jax.Array]]:
|
||||
prices = _decode_action(state.prices, action, params)
|
||||
n_candidates = params.alpha_candidates.shape[0]
|
||||
cand_keys = jax.random.split(key, n_candidates)
|
||||
evals = jax.vmap(
|
||||
lambda k, a: _evaluate_candidate(k, a, prices, params),
|
||||
in_axes=(0, 0),
|
||||
)(cand_keys, params.alpha_candidates)
|
||||
idx = jnp.argmin(evals.reward)
|
||||
|
||||
demand = evals.demand[idx]
|
||||
reward = evals.reward[idx]
|
||||
revenue = evals.revenue[idx]
|
||||
agent_prob = evals.agent_prob[idx]
|
||||
leakage = evals.leakage[idx]
|
||||
discount = evals.discount[idx]
|
||||
n_purchases = evals.n_purchases[idx]
|
||||
n_agents = evals.n_agents[idx]
|
||||
alpha_adv = params.alpha_candidates[idx]
|
||||
|
||||
step_count = state.step_count + 1
|
||||
avg_price = jnp.maximum(jnp.mean(prices), 1e-6)
|
||||
avg_margin = (avg_price - params.price_low) / avg_price
|
||||
next_streak = jnp.where(
|
||||
avg_margin < params.margin_floor, state.low_margin_streak + 1, 0
|
||||
)
|
||||
|
||||
margin_collapsed = next_streak >= params.margin_floor_patience
|
||||
done = (step_count >= params.max_episode_steps) | margin_collapsed
|
||||
|
||||
next_state = EnvState(
|
||||
prices=prices,
|
||||
demand=demand,
|
||||
step_count=step_count,
|
||||
low_margin_streak=next_streak,
|
||||
last_agent_prob=agent_prob,
|
||||
last_alpha_adv=alpha_adv,
|
||||
)
|
||||
obs = _flatten_obs(demand, prices)
|
||||
info = {
|
||||
"revenue": revenue,
|
||||
"agent_prob": agent_prob,
|
||||
"alpha_adv": alpha_adv,
|
||||
"coi_leakage": leakage,
|
||||
"coi_discount": discount,
|
||||
"n_purchases": n_purchases,
|
||||
"n_agents": n_agents,
|
||||
"avg_margin": avg_margin,
|
||||
}
|
||||
return obs, next_state, reward, done, info
|
||||
|
||||
|
||||
class PHANTOMJAXEnv:
|
||||
def __init__(self, params: EnvParams):
|
||||
self.params = params
|
||||
|
||||
def reset(self, key: jax.Array, params: EnvParams | None = None):
|
||||
return reset_env(key, self.params if params is None else params)
|
||||
|
||||
def step(
|
||||
self,
|
||||
key: jax.Array,
|
||||
state: EnvState,
|
||||
action: jax.Array,
|
||||
params: EnvParams | None = None,
|
||||
):
|
||||
return step_env(key, state, action, self.params if params is None else params)
|
||||
|
||||
def action_space_n(self, params: EnvParams | None = None) -> int:
|
||||
p = self.params if params is None else params
|
||||
return int(p.action_scales.shape[0])
|
||||
|
||||
def observation_dim(self, params: EnvParams | None = None) -> int:
|
||||
p = self.params if params is None else params
|
||||
return int(p.n_products * 2)
|
||||
@@ -1,495 +0,0 @@
|
||||
"""JAX-compatible primitives for PHANTOM session simulation and separability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import jax
|
||||
import jax.numpy as jnp
|
||||
|
||||
JAX_AVAILABLE = True
|
||||
except ImportError:
|
||||
jax = None # type: ignore[assignment]
|
||||
jnp = np # type: ignore[assignment]
|
||||
JAX_AVAILABLE = False
|
||||
|
||||
|
||||
STATE_START_KEYS = ("session_start", "start")
|
||||
TERMINAL_EVENT_TOKENS = (
|
||||
"session_end",
|
||||
"end",
|
||||
"purchase_complete",
|
||||
"checkout_start",
|
||||
"checkout",
|
||||
)
|
||||
PURCHASE_EVENT_TOKENS = (
|
||||
"purchase_complete",
|
||||
"purchase",
|
||||
"checkout_start",
|
||||
"checkout",
|
||||
)
|
||||
|
||||
CATEGORY_WEIGHTS = {"cart": 4.0, "dwell": 2.0, "nav": 1.0, "filter": 0.5}
|
||||
ACTION_CATEGORIES = {
|
||||
"cart": {"add_item", "add_to_cart", "remove", "checkout", "purchase"},
|
||||
"dwell": {
|
||||
"hover_title",
|
||||
"hover_paragraph",
|
||||
"hover_link",
|
||||
"hover_over_title",
|
||||
"hover_over_paragraph",
|
||||
"hover_over_link",
|
||||
"hover_over_button",
|
||||
},
|
||||
"nav": {
|
||||
"page_view",
|
||||
"view_item",
|
||||
"view",
|
||||
"learn_more",
|
||||
"learn_more_about_item",
|
||||
"view_item_page",
|
||||
"session_start",
|
||||
},
|
||||
"filter": {
|
||||
"search",
|
||||
"filter_date",
|
||||
"filter_price",
|
||||
"sort",
|
||||
"filter_for_date",
|
||||
"filter_for_price",
|
||||
"filter_for_amenities",
|
||||
"sort_change",
|
||||
},
|
||||
}
|
||||
DEFAULT_ACTION_WEIGHTS = {
|
||||
action: CATEGORY_WEIGHTS[group]
|
||||
for group, actions in ACTION_CATEGORIES.items()
|
||||
for action in actions
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TransitionData:
|
||||
"""Dense transition kernels and per-state metadata."""
|
||||
|
||||
human_T: np.ndarray
|
||||
agent_T: np.ndarray
|
||||
terminal_mask: np.ndarray
|
||||
purchase_mask: np.ndarray
|
||||
event_weights: np.ndarray
|
||||
event_names: tuple[str, ...]
|
||||
start_idx: int
|
||||
term_idx: int
|
||||
|
||||
def to_jax(self) -> "TransitionData":
|
||||
if not JAX_AVAILABLE:
|
||||
return self
|
||||
return TransitionData(
|
||||
human_T=jnp.asarray(self.human_T),
|
||||
agent_T=jnp.asarray(self.agent_T),
|
||||
terminal_mask=jnp.asarray(self.terminal_mask),
|
||||
purchase_mask=jnp.asarray(self.purchase_mask),
|
||||
event_weights=jnp.asarray(self.event_weights),
|
||||
event_names=self.event_names,
|
||||
start_idx=int(self.start_idx),
|
||||
term_idx=int(self.term_idx),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionBatch:
|
||||
states: np.ndarray
|
||||
products: np.ndarray
|
||||
actors: np.ndarray
|
||||
lengths: np.ndarray
|
||||
|
||||
|
||||
def _event_weight(name: str) -> float:
|
||||
if name in DEFAULT_ACTION_WEIGHTS:
|
||||
return float(DEFAULT_ACTION_WEIGHTS[name])
|
||||
if name.startswith("hover"):
|
||||
return float(CATEGORY_WEIGHTS["dwell"])
|
||||
if name.startswith("filter") or name in {"search", "sort", "sort_change"}:
|
||||
return float(CATEGORY_WEIGHTS["filter"])
|
||||
if name.startswith("add") or name in {
|
||||
"checkout",
|
||||
"checkout_start",
|
||||
"purchase",
|
||||
"remove_item",
|
||||
"purchase_complete",
|
||||
}:
|
||||
return float(CATEGORY_WEIGHTS["cart"])
|
||||
if any(token in name for token in TERMINAL_EVENT_TOKENS):
|
||||
return 0.0
|
||||
return float(CATEGORY_WEIGHTS["nav"])
|
||||
|
||||
|
||||
def _is_terminal(name: str) -> bool:
|
||||
return any(token in name for token in TERMINAL_EVENT_TOKENS)
|
||||
|
||||
|
||||
def _is_purchase(name: str) -> bool:
|
||||
return any(token in name for token in PURCHASE_EVENT_TOKENS)
|
||||
|
||||
|
||||
def _collect_events(*transitions: Mapping[str, Mapping[str, float]]) -> tuple[str, ...]:
|
||||
names: set[str] = set()
|
||||
for trans in transitions:
|
||||
for src, dsts in trans.items():
|
||||
names.add(src)
|
||||
names.update(dsts.keys())
|
||||
names.discard("__terminal__")
|
||||
return tuple(sorted(names))
|
||||
|
||||
|
||||
def _normalize_rows(matrix: np.ndarray, term_idx: int) -> np.ndarray:
|
||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||
dead_rows = np.isclose(row_sums.squeeze(-1), 0.0)
|
||||
if np.any(dead_rows):
|
||||
matrix[dead_rows] = 0.0
|
||||
matrix[dead_rows, term_idx] = 1.0
|
||||
row_sums = matrix.sum(axis=1, keepdims=True)
|
||||
return matrix / np.maximum(row_sums, 1e-8)
|
||||
|
||||
|
||||
def _dense_from_dict(
|
||||
transitions: Mapping[str, Mapping[str, float]],
|
||||
event_to_idx: Mapping[str, int],
|
||||
term_idx: int,
|
||||
) -> np.ndarray:
|
||||
n_states = len(event_to_idx)
|
||||
matrix = np.zeros((n_states, n_states), dtype=np.float32)
|
||||
for src, dsts in transitions.items():
|
||||
i = event_to_idx.get(src)
|
||||
if i is None:
|
||||
continue
|
||||
for dst, prob in dsts.items():
|
||||
j = event_to_idx.get(dst)
|
||||
if j is None:
|
||||
continue
|
||||
matrix[i, j] += float(prob)
|
||||
return _normalize_rows(matrix, term_idx)
|
||||
|
||||
|
||||
def compile_transition_data(
|
||||
human_transitions: Mapping[str, Mapping[str, float]],
|
||||
agent_transitions: Mapping[str, Mapping[str, float]],
|
||||
) -> TransitionData:
|
||||
event_names = _collect_events(human_transitions, agent_transitions)
|
||||
if not event_names:
|
||||
return fallback_transition_data()
|
||||
|
||||
event_names = tuple([*event_names, "__terminal__"])
|
||||
term_idx = len(event_names) - 1
|
||||
event_to_idx = {name: i for i, name in enumerate(event_names)}
|
||||
|
||||
human_T = _dense_from_dict(human_transitions, event_to_idx, term_idx)
|
||||
agent_T = _dense_from_dict(agent_transitions, event_to_idx, term_idx)
|
||||
|
||||
terminal_mask = np.array([_is_terminal(name) for name in event_names], dtype=bool)
|
||||
purchase_mask = np.array([_is_purchase(name) for name in event_names], dtype=bool)
|
||||
event_weights = np.array(
|
||||
[_event_weight(name) for name in event_names], dtype=np.float32
|
||||
)
|
||||
|
||||
terminal_mask[term_idx] = True
|
||||
|
||||
for idx, is_term in enumerate(terminal_mask):
|
||||
if not is_term:
|
||||
continue
|
||||
human_T[idx] = 0.0
|
||||
agent_T[idx] = 0.0
|
||||
human_T[idx, idx] = 1.0
|
||||
agent_T[idx, idx] = 1.0
|
||||
|
||||
start_idx = 0
|
||||
for key in STATE_START_KEYS:
|
||||
if key in event_to_idx:
|
||||
start_idx = int(event_to_idx[key])
|
||||
break
|
||||
|
||||
return TransitionData(
|
||||
human_T=human_T,
|
||||
agent_T=agent_T,
|
||||
terminal_mask=terminal_mask,
|
||||
purchase_mask=purchase_mask,
|
||||
event_weights=event_weights,
|
||||
event_names=event_names,
|
||||
start_idx=start_idx,
|
||||
term_idx=term_idx,
|
||||
)
|
||||
|
||||
|
||||
def fallback_transition_data() -> TransitionData:
|
||||
human = {
|
||||
"session_start": {
|
||||
"page_view": 0.80,
|
||||
"view_item_page": 0.15,
|
||||
"session_end": 0.05,
|
||||
},
|
||||
"page_view": {"view_item_page": 0.55, "search": 0.25, "session_end": 0.20},
|
||||
"view_item_page": {
|
||||
"learn_more_about_item": 0.40,
|
||||
"add_item_to_cart": 0.28,
|
||||
"session_end": 0.32,
|
||||
},
|
||||
"learn_more_about_item": {
|
||||
"add_item_to_cart": 0.50,
|
||||
"view_item_page": 0.30,
|
||||
"session_end": 0.20,
|
||||
},
|
||||
"add_item_to_cart": {
|
||||
"checkout_start": 0.58,
|
||||
"view_item_page": 0.24,
|
||||
"session_end": 0.18,
|
||||
},
|
||||
"checkout_start": {"purchase_complete": 0.70, "session_end": 0.30},
|
||||
"purchase_complete": {"session_end": 1.0},
|
||||
}
|
||||
agent = {
|
||||
"session_start": {
|
||||
"page_view": 0.90,
|
||||
"view_item_page": 0.08,
|
||||
"session_end": 0.02,
|
||||
},
|
||||
"page_view": {"view_item_page": 0.40, "search": 0.35, "session_end": 0.25},
|
||||
"view_item_page": {
|
||||
"learn_more_about_item": 0.55,
|
||||
"add_item_to_cart": 0.15,
|
||||
"session_end": 0.30,
|
||||
},
|
||||
"learn_more_about_item": {
|
||||
"view_item_page": 0.45,
|
||||
"add_item_to_cart": 0.20,
|
||||
"session_end": 0.35,
|
||||
},
|
||||
"add_item_to_cart": {
|
||||
"checkout_start": 0.42,
|
||||
"view_item_page": 0.28,
|
||||
"session_end": 0.30,
|
||||
},
|
||||
"checkout_start": {"purchase_complete": 0.52, "session_end": 0.48},
|
||||
"purchase_complete": {"session_end": 1.0},
|
||||
}
|
||||
return compile_transition_data(human, agent)
|
||||
|
||||
|
||||
def load_transition_data(prefer_data: bool = True) -> TransitionData:
|
||||
if not prefer_data:
|
||||
return fallback_transition_data()
|
||||
try:
|
||||
from ..lib.behavior import get_transition_models
|
||||
|
||||
human_trans, agent_trans = get_transition_models()
|
||||
return compile_transition_data(human_trans, agent_trans)
|
||||
except Exception:
|
||||
return fallback_transition_data()
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
|
||||
@partial(jax.jit, static_argnums=(8, 9, 10))
|
||||
def _sample_sessions_jax(
|
||||
key: jax.Array,
|
||||
human_T: jax.Array,
|
||||
agent_T: jax.Array,
|
||||
terminal_mask: jax.Array,
|
||||
start_idx: int,
|
||||
term_idx: int,
|
||||
alpha: float,
|
||||
n_products: int,
|
||||
n_sessions: int,
|
||||
max_steps: int,
|
||||
n_states: int,
|
||||
) -> tuple[jax.Array, jax.Array, jax.Array, jax.Array]:
|
||||
k_actor, k_product, k_step = jax.random.split(key, 3)
|
||||
start_idx_i32 = jnp.asarray(start_idx, dtype=jnp.int32)
|
||||
term_idx_i32 = jnp.asarray(term_idx, dtype=jnp.int32)
|
||||
actor_draw = jax.random.uniform(k_actor, (n_sessions,))
|
||||
actors = (actor_draw < alpha).astype(jnp.int32)
|
||||
products = jax.random.randint(
|
||||
k_product, (n_sessions,), 0, n_products, dtype=jnp.int32
|
||||
)
|
||||
|
||||
active_init = jnp.ones((n_sessions,), dtype=jnp.bool_)
|
||||
state_init = jnp.full((n_sessions,), start_idx_i32, dtype=jnp.int32)
|
||||
|
||||
def _scan_step(carry, _):
|
||||
states, active, rng = carry
|
||||
rng, k = jax.random.split(rng)
|
||||
probs_h = human_T[states]
|
||||
probs_a = agent_T[states]
|
||||
probs = jnp.where(actors[:, None] == 0, probs_h, probs_a)
|
||||
next_state = jax.random.categorical(k, jnp.log(probs + 1e-10), axis=-1)
|
||||
next_state = jnp.where(active, next_state, term_idx_i32)
|
||||
emitted = jnp.where(active, next_state, -1)
|
||||
is_terminal = terminal_mask[jnp.clip(next_state, 0, n_states - 1)]
|
||||
next_active = active & (~is_terminal)
|
||||
carry_states = jnp.where(next_active, next_state, term_idx_i32)
|
||||
return (carry_states, next_active, rng), emitted
|
||||
|
||||
_, state_t = jax.lax.scan(
|
||||
_scan_step, (state_init, active_init, k_step), None, length=max_steps
|
||||
)
|
||||
states = state_t.T
|
||||
lengths = jnp.sum(states >= 0, axis=1, dtype=jnp.int32)
|
||||
return states, products, actors, lengths
|
||||
|
||||
|
||||
def sample_sessions(
|
||||
key,
|
||||
transition_data: TransitionData,
|
||||
alpha: float,
|
||||
n_products: int,
|
||||
n_sessions: int,
|
||||
max_steps: int,
|
||||
) -> SessionBatch:
|
||||
if JAX_AVAILABLE:
|
||||
td = transition_data.to_jax()
|
||||
states, products, actors, lengths = _sample_sessions_jax(
|
||||
key,
|
||||
td.human_T,
|
||||
td.agent_T,
|
||||
td.terminal_mask,
|
||||
int(td.start_idx),
|
||||
int(td.term_idx),
|
||||
float(alpha),
|
||||
int(n_products),
|
||||
int(n_sessions),
|
||||
int(max_steps),
|
||||
int(td.human_T.shape[0]),
|
||||
)
|
||||
return SessionBatch(
|
||||
states=states, products=products, actors=actors, lengths=lengths
|
||||
)
|
||||
|
||||
rng = np.random.default_rng(int(np.asarray(key).reshape(-1)[0]))
|
||||
n_states = transition_data.human_T.shape[0]
|
||||
products = rng.integers(0, n_products, size=n_sessions, dtype=np.int32)
|
||||
actors = (rng.random(size=n_sessions) < alpha).astype(np.int32)
|
||||
states = np.full((n_sessions, max_steps), -1, dtype=np.int32)
|
||||
lengths = np.zeros((n_sessions,), dtype=np.int32)
|
||||
for i in range(n_sessions):
|
||||
current = int(transition_data.start_idx)
|
||||
mat = transition_data.agent_T if actors[i] == 1 else transition_data.human_T
|
||||
for t in range(max_steps):
|
||||
nxt = int(rng.choice(n_states, p=mat[current]))
|
||||
states[i, t] = nxt
|
||||
if transition_data.terminal_mask[nxt]:
|
||||
lengths[i] = t + 1
|
||||
break
|
||||
current = nxt
|
||||
if lengths[i] == 0:
|
||||
lengths[i] = max_steps
|
||||
return SessionBatch(
|
||||
states=states, products=products, actors=actors, lengths=lengths
|
||||
)
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
|
||||
@partial(jax.jit, static_argnums=(2,))
|
||||
def compute_session_transitions(states, lengths, n_states: int):
|
||||
src = states[:, :-1]
|
||||
dst = states[:, 1:]
|
||||
time_idx = jnp.arange(src.shape[1])[None, :]
|
||||
valid = (src >= 0) & (dst >= 0) & (time_idx < (lengths[:, None] - 1))
|
||||
src_clip = jnp.clip(src, 0, n_states - 1)
|
||||
dst_clip = jnp.clip(dst, 0, n_states - 1)
|
||||
src_oh = jax.nn.one_hot(src_clip, n_states)
|
||||
dst_oh = jax.nn.one_hot(dst_clip, n_states)
|
||||
counts = jnp.einsum(
|
||||
"nti,ntj,nt->nij", src_oh, dst_oh, valid.astype(jnp.float32)
|
||||
)
|
||||
row_sums = jnp.sum(counts, axis=-1, keepdims=True)
|
||||
return counts / (row_sums + 1e-10)
|
||||
|
||||
|
||||
else:
|
||||
|
||||
def compute_session_transitions(states, lengths, n_states: int):
|
||||
trans = np.zeros((states.shape[0], n_states, n_states), dtype=np.float32)
|
||||
for i in range(states.shape[0]):
|
||||
for t in range(max(int(lengths[i]) - 1, 0)):
|
||||
s = int(states[i, t])
|
||||
d = int(states[i, t + 1])
|
||||
if s >= 0 and d >= 0:
|
||||
trans[i, s, d] += 1.0
|
||||
row_sums = trans.sum(axis=-1, keepdims=True)
|
||||
return trans / (row_sums + 1e-10)
|
||||
|
||||
|
||||
def batch_kl(P, Q_human, Q_agent, eps: float = 1e-10):
|
||||
p = P + eps
|
||||
p = p / jnp.sum(p, axis=-1, keepdims=True)
|
||||
qh = Q_human[None, ...] + eps
|
||||
qa = Q_agent[None, ...] + eps
|
||||
delta_h = jnp.sum(p * jnp.log(p / qh), axis=(1, 2))
|
||||
delta_a = jnp.sum(p * jnp.log(p / qa), axis=(1, 2))
|
||||
return delta_h, delta_a
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
batch_kl = jax.jit(batch_kl)
|
||||
|
||||
|
||||
def agent_probability_from_kl(delta_h, delta_a, temperature: float = 1.0):
|
||||
t = jnp.maximum(float(temperature), 1e-6)
|
||||
exp_h = jnp.exp(-delta_h / t)
|
||||
exp_a = jnp.exp(-delta_a / t)
|
||||
return exp_a / (exp_h + exp_a + 1e-10)
|
||||
|
||||
|
||||
def estimate_alpha_from_kl(delta_h, delta_a, beta: float = 2.0):
|
||||
logits = beta * (delta_h - delta_a)
|
||||
return 1.0 / (1.0 + jnp.exp(-logits))
|
||||
|
||||
|
||||
def weighted_demand(states, products, n_products: int, event_weights):
|
||||
valid = states >= 0
|
||||
state_clip = jnp.clip(states, 0, event_weights.shape[0] - 1)
|
||||
weights = event_weights[state_clip] * valid
|
||||
per_session = jnp.sum(weights, axis=1)
|
||||
demand = jnp.zeros((n_products,), dtype=jnp.float32)
|
||||
demand = demand.at[products].add(per_session)
|
||||
total = jnp.sum(demand)
|
||||
return jnp.where(total > 0.0, (demand / total) * 100.0, demand)
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
weighted_demand = jax.jit(weighted_demand, static_argnums=(2,))
|
||||
|
||||
|
||||
def purchase_flags(states, purchase_mask):
|
||||
state_clip = jnp.clip(states, 0, purchase_mask.shape[0] - 1)
|
||||
hits = purchase_mask[state_clip] & (states >= 0)
|
||||
return jnp.any(hits, axis=1)
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
purchase_flags = jax.jit(purchase_flags)
|
||||
|
||||
|
||||
def revenue_from_demand(prices, demand):
|
||||
return jnp.dot(prices, demand)
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
revenue_from_demand = jax.jit(revenue_from_demand)
|
||||
|
||||
|
||||
def reward_with_coi_penalty(
|
||||
revenue, agent_prob: float, lambda_coi: float, info_value: float
|
||||
):
|
||||
leakage = agent_prob * info_value
|
||||
discount = jnp.clip(1.0 - lambda_coi * leakage, 0.0, 1.0)
|
||||
return revenue * discount, leakage, discount
|
||||
|
||||
|
||||
if JAX_AVAILABLE:
|
||||
reward_with_coi_penalty = jax.jit(reward_with_coi_penalty)
|
||||
@@ -1,5 +0,0 @@
|
||||
flax==0.10.7
|
||||
optax==0.2.7
|
||||
distrax==0.1.5
|
||||
orbax-checkpoint==0.11.32
|
||||
chex==0.1.90
|
||||
1319
engine/jax/train.py
1319
engine/jax/train.py
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
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
|
||||
@@ -1,134 +0,0 @@
|
||||
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)
|
||||
@@ -1,182 +0,0 @@
|
||||
"""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"])
|
||||
@@ -1,76 +0,0 @@
|
||||
import numpy as np
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def compute_agent_probability(
|
||||
trajectory: list, human_transitions: Dict, agent_transitions: Dict
|
||||
) -> float:
|
||||
"""estimate agent probability via KL divergence between trajectory transitions and reference models
|
||||
|
||||
compares empirical trajectory transition distribution to human/agent prototypes
|
||||
|
||||
args:
|
||||
trajectory: list of state/event strings from session
|
||||
human_transitions: reference transition dict from human MDP (event->event->prob)
|
||||
agent_transitions: reference transition dict from agent MDP (event->event->prob)
|
||||
|
||||
returns:
|
||||
agent probability in [0, 1] via softmax over KL divergences
|
||||
"""
|
||||
if len(trajectory) < 2:
|
||||
return 0.0 # insufficient data, assume human
|
||||
|
||||
# build empirical transition distribution from trajectory
|
||||
trans_counts = {}
|
||||
for s, s_next in zip(trajectory[:-1], trajectory[1:]):
|
||||
if s not in trans_counts:
|
||||
trans_counts[s] = {}
|
||||
trans_counts[s][s_next] = trans_counts[s].get(s_next, 0) + 1
|
||||
|
||||
# normalize to probabilities
|
||||
empirical = {}
|
||||
for s, nxt in trans_counts.items():
|
||||
total = sum(nxt.values())
|
||||
empirical[s] = {s_n: cnt / total for s_n, cnt in nxt.items()}
|
||||
|
||||
# compute KL divergence to each prototype
|
||||
def kl_div(p_dist: Dict, q_dist: Dict) -> float:
|
||||
eps = 1e-10
|
||||
# aggregate over all source states in empirical dist
|
||||
kl = 0.0
|
||||
for s in p_dist:
|
||||
if s not in q_dist:
|
||||
continue # skip states not in reference
|
||||
p_trans, q_trans = p_dist[s], q_dist[s]
|
||||
for k in p_trans:
|
||||
p_val = p_trans[k] + eps
|
||||
q_val = q_trans.get(k, 0.0) + eps
|
||||
kl += p_val * np.log(p_val / q_val)
|
||||
return kl
|
||||
|
||||
kl_human = kl_div(empirical, human_transitions)
|
||||
kl_agent = kl_div(empirical, agent_transitions)
|
||||
|
||||
# convert to probability via softmax (lower KL = higher prob)
|
||||
# agent_prob = exp(-kl_agent) / (exp(-kl_human) + exp(-kl_agent))
|
||||
exp_h = np.exp(-kl_human)
|
||||
exp_a = np.exp(-kl_agent)
|
||||
return float(exp_a / (exp_h + exp_a + 1e-10))
|
||||
|
||||
|
||||
def extract_purchases(trajectories: list) -> Dict[int, int]:
|
||||
purchases: Dict[int, int] = {}
|
||||
for traj in trajectories:
|
||||
if traj and "checkout" in traj[-1] and "_product" in traj[-1]:
|
||||
prod_id = int(traj[-1].rsplit("_product", 1)[1])
|
||||
purchases[prod_id] = purchases.get(prod_id, 0) + 1
|
||||
return purchases
|
||||
|
||||
|
||||
def compute_uplift_coi(
|
||||
prices: np.ndarray, purchases: Dict[int, int], baseline_prices: np.ndarray
|
||||
) -> float:
|
||||
# TODO: consider view-weighted fractional purchase for denser signal
|
||||
return float(
|
||||
sum(max(0.0, prices[k] - baseline_prices[k]) * n for k, n in purchases.items())
|
||||
)
|
||||
@@ -1,92 +0,0 @@
|
||||
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)
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
@@ -1,182 +0,0 @@
|
||||
"""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",
|
||||
)
|
||||
@@ -1,126 +0,0 @@
|
||||
"""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
|
||||
@@ -1,77 +0,0 @@
|
||||
"""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]))
|
||||
@@ -1,34 +0,0 @@
|
||||
"""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
|
||||
@@ -1,89 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,106 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,84 +0,0 @@
|
||||
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]
|
||||
@@ -1,85 +0,0 @@
|
||||
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
|
||||
@@ -1,54 +0,0 @@
|
||||
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]
|
||||
@@ -1,86 +0,0 @@
|
||||
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]
|
||||
@@ -1,93 +0,0 @@
|
||||
method: bayes
|
||||
metric:
|
||||
name: sweep/score
|
||||
goal: maximize
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
# fixed: always use JAX backend so TPU chips are actually exercised
|
||||
use_jax:
|
||||
value: true
|
||||
# all four algos have JAX implementations
|
||||
algo:
|
||||
values: [ppo, a2c, dqn, qtable]
|
||||
total_timesteps:
|
||||
values: [50000, 80000, 120000]
|
||||
checkpoint_interval:
|
||||
value: 200000
|
||||
seed:
|
||||
values: [13, 42, 77]
|
||||
n_products:
|
||||
values: [8, 10, 12]
|
||||
# COI framework parameters -- primary research variables
|
||||
alpha:
|
||||
distribution: uniform
|
||||
min: 0.1
|
||||
max: 0.6
|
||||
lambda_coi:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.6
|
||||
robust_radius:
|
||||
distribution: uniform
|
||||
min: 0.0
|
||||
max: 0.3
|
||||
robust_points:
|
||||
values: [3, 5, 7]
|
||||
info_value:
|
||||
distribution: uniform
|
||||
min: 0.5
|
||||
max: 2.0
|
||||
revenue_weight:
|
||||
values: [0.005, 0.01, 0.02]
|
||||
# shared hyperparameters
|
||||
learning_rate:
|
||||
distribution: log_uniform_values
|
||||
min: 1.0e-5
|
||||
max: 1.0e-3
|
||||
gamma:
|
||||
values: [0.97, 0.99, 0.995]
|
||||
# JAX parallelism -- key lever for TPU throughput
|
||||
jax_num_envs:
|
||||
values: [8, 16, 32]
|
||||
jax_num_steps:
|
||||
values: [64, 128, 256]
|
||||
jax_num_minibatches:
|
||||
values: [2, 4, 8]
|
||||
jax_update_epochs:
|
||||
values: [2, 4, 8]
|
||||
# PPO/A2C specific
|
||||
gae_lambda:
|
||||
values: [0.9, 0.95, 0.98]
|
||||
clip_range:
|
||||
values: [0.1, 0.2, 0.3]
|
||||
ent_coef:
|
||||
values: [0.0, 0.005, 0.01]
|
||||
# DQN specific
|
||||
buffer_size:
|
||||
values: [20000, 50000, 100000]
|
||||
batch_size:
|
||||
values: [128, 256, 512]
|
||||
learning_starts:
|
||||
values: [500, 1000, 3000]
|
||||
exploration_fraction:
|
||||
values: [0.1, 0.2, 0.3]
|
||||
exploration_final_eps:
|
||||
values: [0.01, 0.03, 0.05]
|
||||
# QTable specific
|
||||
q_lr:
|
||||
values: [0.03, 0.05, 0.1, 0.2]
|
||||
eps_end:
|
||||
values: [0.02, 0.05, 0.1]
|
||||
eps_decay:
|
||||
values: [0.999, 0.9995, 0.9999]
|
||||
# action space
|
||||
action_levels:
|
||||
values: [7, 9, 11]
|
||||
action_scale_low:
|
||||
values: [0.75, 0.8, 0.85]
|
||||
action_scale_high:
|
||||
values: [1.15, 1.2, 1.25]
|
||||
@@ -1,64 +0,0 @@
|
||||
method: bayes
|
||||
metric:
|
||||
name: sweep/score
|
||||
goal: maximize
|
||||
command:
|
||||
- ${env}
|
||||
- python
|
||||
- -m
|
||||
- engine.train
|
||||
parameters:
|
||||
use_jax:
|
||||
value: true
|
||||
# pmap requires all workers to compile the same computation graph shape,
|
||||
# so structural params are fixed -- only research/scalar params are swept
|
||||
algo:
|
||||
values: [ppo, a2c]
|
||||
jax_num_envs:
|
||||
value: 32
|
||||
jax_num_steps:
|
||||
value: 128
|
||||
jax_num_minibatches:
|
||||
value: 4
|
||||
jax_update_epochs:
|
||||
value: 4
|
||||
total_timesteps:
|
||||
value: 100000
|
||||
checkpoint_interval:
|
||||
value: 200000
|
||||
n_products:
|
||||
value: 10
|
||||
action_levels:
|
||||
value: 9
|
||||
# research parameters -- primary sweep targets
|
||||
alpha:
|
||||
distribution: uniform
|
||||
min: 0.1
|
||||
max: 0.6
|
||||
lambda_coi:
|
||||
distribution: uniform
|
||||
min: 0.05
|
||||
max: 0.6
|
||||
robust_radius:
|
||||
distribution: uniform
|
||||
min: 0.0
|
||||
max: 0.3
|
||||
info_value:
|
||||
distribution: uniform
|
||||
min: 0.5
|
||||
max: 2.0
|
||||
revenue_weight:
|
||||
values: [0.005, 0.01, 0.02]
|
||||
# training hyperparameters
|
||||
learning_rate:
|
||||
distribution: log_uniform_values
|
||||
min: 1.0e-5
|
||||
max: 1.0e-3
|
||||
gamma:
|
||||
values: [0.97, 0.99, 0.995]
|
||||
gae_lambda:
|
||||
values: [0.9, 0.95, 0.98]
|
||||
clip_range:
|
||||
values: [0.1, 0.2, 0.3]
|
||||
ent_coef:
|
||||
values: [0.0, 0.005, 0.01]
|
||||
568
engine/train.py
568
engine/train.py
@@ -1,568 +0,0 @@
|
||||
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 as _wandb
|
||||
|
||||
if hasattr(_wandb, "init") and callable(_wandb.init):
|
||||
wandb = _wandb
|
||||
HAS_WANDB = True
|
||||
else:
|
||||
wandb = None
|
||||
HAS_WANDB = False
|
||||
except ImportError:
|
||||
wandb = None
|
||||
HAS_WANDB = False
|
||||
|
||||
try:
|
||||
from stable_baselines3 import PPO, A2C, DQN
|
||||
from stable_baselines3.common.callbacks import EvalCallback
|
||||
from stable_baselines3.common.monitor import Monitor
|
||||
|
||||
HAS_SB3 = True
|
||||
except ImportError:
|
||||
HAS_SB3 = False
|
||||
|
||||
from .jax import JAX_AVAILABLE
|
||||
|
||||
|
||||
DEFAULT_CFG = {
|
||||
"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": 200_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")
|
||||
if not sweep_mode:
|
||||
pre_cfg = _cfg(overrides)
|
||||
if pre_cfg.get("use_jax"):
|
||||
try:
|
||||
import jax
|
||||
|
||||
if jax.process_count() > 1 and jax.process_index() != 0:
|
||||
return train_once(pre_cfg)
|
||||
except Exception:
|
||||
pass
|
||||
init_kwargs = {"mode": mode}
|
||||
if sweep_mode:
|
||||
run = wandb.init(**init_kwargs)
|
||||
else:
|
||||
run = wandb.init(project=project, config=overrides, **init_kwargs)
|
||||
|
||||
try:
|
||||
cfg = _cfg(_wandb_cfg_dict())
|
||||
if sweep_mode:
|
||||
for k, v in overrides.items():
|
||||
if k not in wandb.config:
|
||||
cfg[k] = v
|
||||
|
||||
metrics = train_once(cfg)
|
||||
step = int(metrics.get("train/global_step", cfg["total_timesteps"]))
|
||||
wandb.log(metrics, step=step)
|
||||
for k, v in metrics.items():
|
||||
run.summary[k] = v
|
||||
return metrics
|
||||
finally:
|
||||
if wandb.run is not None:
|
||||
wandb.finish()
|
||||
|
||||
|
||||
def run_local(overrides: dict) -> dict:
|
||||
cfg = _cfg(overrides)
|
||||
metrics = train_once(cfg)
|
||||
should_print = True
|
||||
if cfg.get("use_jax"):
|
||||
try:
|
||||
import jax
|
||||
|
||||
should_print = jax.process_index() == 0
|
||||
except Exception:
|
||||
should_print = True
|
||||
if should_print:
|
||||
print(json.dumps(metrics, indent=2))
|
||||
# sentinel line for machine-readable extraction; must stay on one line
|
||||
print("PHANTOM_METRICS:" + json.dumps(metrics))
|
||||
return metrics
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description="PHANTOM training and W&B sweeps")
|
||||
p.add_argument("--project", default=DEFAULT_CFG["project"])
|
||||
p.add_argument("--algo", choices=["ppo", "a2c", "dqn", "qtable"])
|
||||
p.add_argument("--seed", type=int)
|
||||
p.add_argument("--total-timesteps", type=int)
|
||||
p.add_argument("--alpha", type=float)
|
||||
p.add_argument("--N", type=int)
|
||||
p.add_argument("--n-products", type=int)
|
||||
p.add_argument("--lambda-coi", type=float)
|
||||
p.add_argument("--info-value", type=float)
|
||||
p.add_argument("--robust-radius", type=float)
|
||||
p.add_argument("--robust-points", type=int)
|
||||
p.add_argument("--learning-rate", type=float)
|
||||
p.add_argument("--gamma", type=float)
|
||||
p.add_argument("--gae-lambda", type=float)
|
||||
p.add_argument("--clip-range", type=float)
|
||||
p.add_argument("--ent-coef", type=float)
|
||||
p.add_argument("--revenue-weight", type=float)
|
||||
p.add_argument("--price-low", type=float)
|
||||
p.add_argument("--price-high", type=float)
|
||||
p.add_argument("--action-levels", type=int)
|
||||
p.add_argument("--action-scale-low", type=float)
|
||||
p.add_argument("--action-scale-high", type=float)
|
||||
p.add_argument("--max-steps", type=int)
|
||||
p.add_argument("--margin-floor", type=float)
|
||||
p.add_argument("--margin-floor-patience", type=int)
|
||||
p.add_argument("--arch", type=str)
|
||||
p.add_argument("--activation", type=str)
|
||||
p.add_argument("--jax", action="store_true")
|
||||
p.add_argument("--jax-num-envs", type=int)
|
||||
p.add_argument("--jax-num-steps", type=int)
|
||||
p.add_argument("--jax-num-minibatches", type=int)
|
||||
p.add_argument("--jax-update-epochs", type=int)
|
||||
p.add_argument("--jax-anneal-lr", type=str)
|
||||
p.add_argument("--checkpoint-interval", type=int)
|
||||
p.add_argument("--sweep-agent", action="store_true")
|
||||
p.add_argument("--sweep-id", type=str)
|
||||
p.add_argument("--count", type=int, default=0)
|
||||
p.add_argument("--offline", action="store_true")
|
||||
p.add_argument("--no-wandb", action="store_true")
|
||||
args = p.parse_args()
|
||||
|
||||
overrides = {
|
||||
"algo": args.algo,
|
||||
"seed": args.seed,
|
||||
"total_timesteps": args.total_timesteps,
|
||||
"alpha": args.alpha,
|
||||
"N": args.N,
|
||||
"n_products": args.n_products,
|
||||
"lambda_coi": args.lambda_coi,
|
||||
"info_value": args.info_value,
|
||||
"robust_radius": args.robust_radius,
|
||||
"robust_points": args.robust_points,
|
||||
"learning_rate": args.learning_rate,
|
||||
"gamma": args.gamma,
|
||||
"gae_lambda": args.gae_lambda,
|
||||
"clip_range": args.clip_range,
|
||||
"ent_coef": args.ent_coef,
|
||||
"revenue_weight": args.revenue_weight,
|
||||
"price_low": args.price_low,
|
||||
"price_high": args.price_high,
|
||||
"action_levels": args.action_levels,
|
||||
"action_scale_low": args.action_scale_low,
|
||||
"action_scale_high": args.action_scale_high,
|
||||
"max_steps": args.max_steps,
|
||||
"margin_floor": args.margin_floor,
|
||||
"margin_floor_patience": args.margin_floor_patience,
|
||||
"arch": args.arch,
|
||||
"activation": args.activation,
|
||||
"use_jax": args.jax,
|
||||
"jax_num_envs": args.jax_num_envs,
|
||||
"jax_num_steps": args.jax_num_steps,
|
||||
"jax_num_minibatches": args.jax_num_minibatches,
|
||||
"jax_update_epochs": args.jax_update_epochs,
|
||||
"checkpoint_interval": args.checkpoint_interval,
|
||||
"jax_anneal_lr": _truthy(args.jax_anneal_lr)
|
||||
if args.jax_anneal_lr is not None
|
||||
else None,
|
||||
}
|
||||
overrides = {k: v for k, v in overrides.items() if v is not None}
|
||||
|
||||
if args.sweep_agent:
|
||||
if args.no_wandb:
|
||||
raise ValueError("sweep agent requires wandb")
|
||||
if not args.sweep_id:
|
||||
raise ValueError("--sweep-id is required with --sweep-agent")
|
||||
mode = "offline" if args.offline else "online"
|
||||
wandb.agent(
|
||||
args.sweep_id,
|
||||
function=lambda: run_wandb(
|
||||
args.project, overrides, mode=mode, sweep_mode=True
|
||||
),
|
||||
count=args.count if args.count > 0 else None,
|
||||
)
|
||||
return
|
||||
|
||||
if args.no_wandb or not HAS_WANDB:
|
||||
run_local(overrides)
|
||||
return
|
||||
|
||||
run_wandb(args.project, overrides, mode="offline" if args.offline else "online")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,130 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Mapping
|
||||
|
||||
try:
|
||||
import wandb
|
||||
from wandb.errors import CommError
|
||||
|
||||
HAS_WANDB = True
|
||||
except ImportError:
|
||||
HAS_WANDB = False
|
||||
wandb = None # type: ignore[assignment]
|
||||
CommError = RuntimeError # type: ignore[assignment]
|
||||
|
||||
|
||||
def _safe_value(value: Any) -> Any:
|
||||
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||
return value
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_safe_value(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {str(k): _safe_value(value[k]) for k in sorted(value)}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _safe_scope(scope: str | None) -> str:
|
||||
raw = "manual" if scope in (None, "") else str(scope)
|
||||
cleaned = re.sub(r"[^A-Za-z0-9_.-]+", "-", raw).strip("-")
|
||||
return cleaned or "manual"
|
||||
|
||||
|
||||
def checkpoint_artifact_name(
|
||||
cfg: Mapping[str, Any], *, backend: str, sweep_id: str | None = None
|
||||
) -> str:
|
||||
payload = {k: _safe_value(cfg[k]) for k in sorted(cfg)}
|
||||
scope = _safe_scope(sweep_id)
|
||||
canonical = json.dumps(
|
||||
{"backend": backend, "scope": scope, "cfg": payload},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
digest = hashlib.sha1(canonical.encode("utf-8")).hexdigest()[:14]
|
||||
return f"phantom-{backend}-ckpt-{scope}-{digest}"[:128]
|
||||
|
||||
|
||||
def _is_missing_artifact_error(exc: Exception) -> bool:
|
||||
if isinstance(exc, CommError):
|
||||
msg = str(exc).lower()
|
||||
return "not found" in msg or "does not exist" in msg
|
||||
return False
|
||||
|
||||
|
||||
def download_latest_checkpoint(
|
||||
artifact_name: str, *, file_name: str
|
||||
) -> tuple[Path, dict[str, Any]] | None:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return None
|
||||
try:
|
||||
artifact = wandb.run.use_artifact(f"{artifact_name}:latest")
|
||||
except Exception as exc:
|
||||
if _is_missing_artifact_error(exc):
|
||||
return None
|
||||
raise
|
||||
directory = Path(artifact.download())
|
||||
checkpoint_path = directory / file_name
|
||||
if not checkpoint_path.exists():
|
||||
return None
|
||||
metadata = dict(getattr(artifact, "metadata", {}) or {})
|
||||
return checkpoint_path, metadata
|
||||
|
||||
|
||||
def _aliases_from_metadata(metadata: dict[str, Any] | None) -> list[str]:
|
||||
aliases = ["latest"]
|
||||
if metadata is None:
|
||||
return aliases
|
||||
if "step" in metadata:
|
||||
try:
|
||||
aliases.append(f"step-{int(metadata['step'])}")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return aliases
|
||||
|
||||
|
||||
def log_checkpoint_bytes(
|
||||
artifact_name: str,
|
||||
*,
|
||||
file_name: str,
|
||||
payload: bytes,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return False
|
||||
with TemporaryDirectory(prefix="phantom-ckpt-") as tmpdir:
|
||||
path = Path(tmpdir) / file_name
|
||||
path.write_bytes(payload)
|
||||
artifact = wandb.Artifact(
|
||||
name=artifact_name,
|
||||
type="checkpoint",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
artifact.add_file(path.as_posix(), name=file_name)
|
||||
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
|
||||
return True
|
||||
|
||||
|
||||
def log_checkpoint_file(
|
||||
artifact_name: str,
|
||||
*,
|
||||
file_path: str | Path,
|
||||
artifact_file_name: str,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
if not HAS_WANDB or wandb.run is None:
|
||||
return False
|
||||
src = Path(file_path)
|
||||
if not src.exists():
|
||||
return False
|
||||
artifact = wandb.Artifact(
|
||||
name=artifact_name,
|
||||
type="checkpoint",
|
||||
metadata=metadata or {},
|
||||
)
|
||||
artifact.add_file(src.as_posix(), name=artifact_file_name)
|
||||
wandb.log_artifact(artifact, aliases=_aliases_from_metadata(metadata))
|
||||
return True
|
||||
@@ -1,366 +0,0 @@
|
||||
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()
|
||||
@@ -1,117 +0,0 @@
|
||||
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}")
|
||||
346
experiments/airflow/dags/elasticity_pricing_dag.py
Normal file
346
experiments/airflow/dags/elasticity_pricing_dag.py
Normal file
@@ -0,0 +1,346 @@
|
||||
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,
|
||||
CreatePriceBucketsStep,
|
||||
AugmentEventNamesStep,
|
||||
ChunkByTimeWindowStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep,
|
||||
BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
|
||||
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):
|
||||
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'),
|
||||
window_size=dag_conf.get('window_size', '30s'),
|
||||
n_price_buckets=dag_conf.get('n_price_buckets', 5),
|
||||
elasticity_method=dag_conf.get('elasticity_method', 'point'),
|
||||
min_observations=dag_conf.get('min_observations', 2),
|
||||
)
|
||||
|
||||
# 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 create_price_buckets(**kwargs):
|
||||
"""Task: Create price buckets for interactions"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = CreatePriceBucketsStep(context)
|
||||
df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interactions_bucketed', value=pickle.dumps(df))
|
||||
logging.info(f"Created price buckets for {len(df)} interactions")
|
||||
return len(df)
|
||||
|
||||
def augment_event_names(**kwargs):
|
||||
"""Task: Augment event names with product and price schema"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_bucketed'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AugmentEventNamesStep(context)
|
||||
df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interactions_final', value=pickle.dumps(df))
|
||||
logging.info(f"Augmented event names for {len(df)} interactions")
|
||||
return len(df)
|
||||
|
||||
def chunk_interactions(**kwargs):
|
||||
"""Task: Chunk interactions into time windows"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_final'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ChunkByTimeWindowStep(context)
|
||||
chunks = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='interaction_chunks', value=pickle.dumps(chunks))
|
||||
logging.info(f"Generated {len(chunks)} interaction chunks")
|
||||
return len(chunks)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
"""Task: Compute demand vectors for all chunks"""
|
||||
ti = kwargs['ti']
|
||||
chunks = pickle.loads(ti.xcom_pull(key='interaction_chunks'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeDemandForChunksStep(context)
|
||||
demand_chunks = step.transform(chunks)
|
||||
|
||||
ti.xcom_push(key='demand_chunks', value=pickle.dumps(demand_chunks))
|
||||
logging.info(f"Computed demand for {len(demand_chunks)} chunks")
|
||||
return len(demand_chunks)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
"""Task: Aggregate price logs into time windows """
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AggregatePriceLogsStep(context)
|
||||
price_chunks = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='price_chunks', value=pickle.dumps(price_chunks))
|
||||
logging.info(f"Aggregated {len(price_chunks)} price chunks")
|
||||
return len(price_chunks)
|
||||
|
||||
def compute_elasticity(**kwargs):
|
||||
"""Task: Compute price elasticity from demand and price chunks"""
|
||||
ti = kwargs['ti']
|
||||
demand_chunks = pickle.loads(ti.xcom_pull(key='demand_chunks'))
|
||||
price_chunks = pickle.loads(ti.xcom_pull(key='price_chunks'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeElasticityStep(context)
|
||||
elasticity_df = step.transform((demand_chunks, price_chunks))
|
||||
|
||||
ti.xcom_push(key='elasticity_results', value=pickle.dumps(elasticity_df))
|
||||
logging.info(f"Computed elasticity for {len(elasticity_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(elasticity_df),
|
||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
||||
'median_elasticity': float(elasticity_df['elasticity'].median())
|
||||
}
|
||||
|
||||
def build_state_space(**kwargs):
|
||||
"""Task: Build state space from elasticity"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = BuildStateSpaceStep(context)
|
||||
state_space = step.transform(elasticity_df)
|
||||
|
||||
ti.xcom_push(key='state_space', value=pickle.dumps(state_space))
|
||||
logging.info("Built state space for pricing")
|
||||
return True
|
||||
|
||||
def fit_pricing_function(**kwargs):
|
||||
"""Task: Fit pricing function using elasticity"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = FitPricingFunctionStep(context)
|
||||
pricer = step.transform(elasticity_df)
|
||||
|
||||
ti.xcom_push(key='pricer', value=pickle.dumps(pricer))
|
||||
logging.info("Fitted pricing function")
|
||||
return True
|
||||
|
||||
def predict_prices(**kwargs):
|
||||
"""Task: Predict optimal prices"""
|
||||
ti = kwargs['ti']
|
||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
||||
state_space = pickle.loads(ti.xcom_pull(key='state_space'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = PredictPricesStep(context)
|
||||
prices_df = step.transform((pricer, state_space))
|
||||
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"Predicted prices for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
"""Task: Publish elasticity and pricing results to model registry"""
|
||||
ti = kwargs['ti']
|
||||
elasticity_df = pickle.loads(ti.xcom_pull(key='elasticity_results'))
|
||||
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(),
|
||||
'window_size': dag_conf.get('window_size', '30s'),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual'
|
||||
}
|
||||
|
||||
registry.publish_elasticity(elasticity_df, model_name='latest', metadata=metadata)
|
||||
|
||||
# get fitted pricer from XCom
|
||||
pricer = pickle.loads(ti.xcom_pull(key='pricer'))
|
||||
registry.publish_pricing_model(
|
||||
pricer,
|
||||
model_name='latest',
|
||||
metadata={**metadata, 'model_type': type(pricer).__name__}
|
||||
)
|
||||
|
||||
logging.info(f"Published elasticity + pricing for {len(elasticity_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(elasticity_df),
|
||||
'registry_status': 'success',
|
||||
'elasticity_mean': float(elasticity_df['elasticity'].mean())
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'elasticity_pricing_pipeline',
|
||||
default_args=default_args,
|
||||
description='E2E refactored pipeline: atomic steps with proper separation',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'elasticity', 'research', 'refactored'],
|
||||
) 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,
|
||||
)
|
||||
|
||||
# interaction processing branch
|
||||
t_create_buckets = PythonOperator(
|
||||
task_id='create_price_buckets',
|
||||
python_callable=create_price_buckets,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_augment_events = PythonOperator(
|
||||
task_id='augment_event_names',
|
||||
python_callable=augment_event_names,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_chunk_interactions = PythonOperator(
|
||||
task_id='chunk_interactions',
|
||||
python_callable=chunk_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=compute_demand,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# price processing branch (VECTORIZED)
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=aggregate_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# convergence: compute elasticity
|
||||
t_compute_elasticity = PythonOperator(
|
||||
task_id='compute_elasticity',
|
||||
python_callable=compute_elasticity,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# pricing tasks
|
||||
t_build_state = PythonOperator(
|
||||
task_id='build_state_space',
|
||||
python_callable=build_state_space,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fit_pricer = PythonOperator(
|
||||
task_id='fit_pricing_function',
|
||||
python_callable=fit_pricing_function,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_predict_prices = PythonOperator(
|
||||
task_id='predict_prices',
|
||||
python_callable=predict_prices,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# publish to registry
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=publish_results,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# dependency graph (clear atomic flow)
|
||||
# parallel fetches
|
||||
[t_fetch_interactions, t_fetch_price_logs]
|
||||
|
||||
# interaction branch: fetch -> bucket -> augment -> chunk -> demand
|
||||
t_fetch_interactions >> t_create_buckets >> t_augment_events >> t_chunk_interactions >> t_compute_demand
|
||||
|
||||
# price branch: fetch -> aggregate (vectorized)
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
|
||||
# convergence: both branches -> elasticity
|
||||
[t_compute_demand, t_aggregate_prices] >> t_compute_elasticity
|
||||
|
||||
# pricing: elasticity -> state + fit -> predict -> publish
|
||||
t_compute_elasticity >> [t_build_state, t_fit_pricer] >> t_predict_prices >> t_publish
|
||||
@@ -1,115 +0,0 @@
|
||||
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)
|
||||
@@ -1,269 +0,0 @@
|
||||
"""
|
||||
Session-Aware Pricing DAG
|
||||
THIS implements the core pricing computation (policy layer).
|
||||
|
||||
Flow: τ → θ̂ → D → p*
|
||||
1. Fetch recent sessions from Kafka (last 10 active)
|
||||
2. Extract features per session (τ → θ̂)
|
||||
3. Map features to demand proxy (θ̂ → D)
|
||||
4. Compute optimal prices (D → p*)
|
||||
5. Write to Redis session:{sessionId}:prices
|
||||
|
||||
Scheduled: every 1 minute when enabled
|
||||
"""
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps.session import ExtractSessionFeaturesStep
|
||||
from procesing.pricers.simple import SimpleSurgePricer, session_features_to_demand
|
||||
from procesing.pricing import StateSpace
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 1,
|
||||
'retry_delay': timedelta(seconds=30),
|
||||
}
|
||||
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
|
||||
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
|
||||
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
|
||||
|
||||
|
||||
def fetch_recent_sessions(**kwargs):
|
||||
"""
|
||||
Task: Fetch last N active sessions from Kafka.
|
||||
Returns: DataFrame of interaction events for recent sessions.
|
||||
"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
store_mode = dag_conf.get('store_mode', 'hotel')
|
||||
session_limit = dag_conf.get('session_limit', 10)
|
||||
|
||||
ctx = _get_context(store_mode)
|
||||
provider = ctx.provider
|
||||
|
||||
# fetch all recent interactions from Kafka
|
||||
try:
|
||||
interactions_df = provider.fetch_kafka_topic("user-interactions")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to fetch interactions: {e}")
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
if interactions_df.empty or 'sessionId' not in interactions_df.columns:
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
# identify last N active sessions (most recent by event count)
|
||||
recent_sessions = interactions_df['sessionId'].value_counts().head(session_limit).index.tolist()
|
||||
|
||||
# filter to only those sessions
|
||||
filtered_df = interactions_df[interactions_df['sessionId'].isin(recent_sessions)].copy()
|
||||
|
||||
kwargs['ti'].xcom_push(key='sessions_data', value=pickle.dumps(filtered_df))
|
||||
kwargs['ti'].xcom_push(key='session_ids', value=recent_sessions)
|
||||
|
||||
logging.info(f"Fetched {len(filtered_df)} events for {len(recent_sessions)} sessions")
|
||||
return len(recent_sessions)
|
||||
|
||||
|
||||
def extract_session_features(**kwargs):
|
||||
"""
|
||||
Task: Extract behavioral features from session trajectories.
|
||||
THIS implements τ → θ̂ transformation.
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
sessions_df = pickle.loads(ti.xcom_pull(key='sessions_data'))
|
||||
|
||||
if sessions_df.empty:
|
||||
ti.xcom_push(key='session_features', value=pickle.dumps(pd.DataFrame()))
|
||||
return 0
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
|
||||
# extract features using vectorized pipeline
|
||||
feature_extractor = ExtractSessionFeaturesStep(ctx)
|
||||
features_df = feature_extractor.transform(sessions_df)
|
||||
|
||||
ti.xcom_push(key='session_features', value=pickle.dumps(features_df))
|
||||
|
||||
logging.info(f"Extracted {len(features_df.columns)} features for {len(features_df)} sessions")
|
||||
logging.info(f"Feature columns: {list(features_df.columns)}")
|
||||
logging.info(f"Sample features (first session):\n{features_df.iloc[0].to_dict()}")
|
||||
|
||||
return len(features_df)
|
||||
|
||||
|
||||
def compute_session_prices(**kwargs):
|
||||
"""
|
||||
Task: Compute optimal prices for each session.
|
||||
THIS implements θ̂ → D → p* transformation.
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
features_df = pickle.loads(ti.xcom_pull(key='session_features'))
|
||||
|
||||
if features_df.empty:
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps({}))
|
||||
return 0
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
store_mode = dag_conf.get('store_mode', 'hotel')
|
||||
ctx = _get_context(store_mode)
|
||||
|
||||
# fetch product catalog for base prices
|
||||
products_df = ctx.provider.fetch_products(store_mode)
|
||||
if products_df.empty:
|
||||
logging.error("No products found in catalog")
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps({}))
|
||||
return 0
|
||||
|
||||
products_df['base_price'] = products_df['metadata'].apply(
|
||||
lambda m: m.get('base_price', 100.0) if isinstance(m, dict) else 100.0
|
||||
)
|
||||
|
||||
# initialize pricing model
|
||||
pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.15),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.95)
|
||||
)
|
||||
pricer.fit(products_df)
|
||||
|
||||
# compute prices per session
|
||||
price_results = {}
|
||||
n_products = len(products_df)
|
||||
|
||||
logging.info(f"Starting price computation for {len(features_df)} sessions, {n_products} products")
|
||||
logging.info(f"Pricer config: high_thresh={pricer.high_threshold}, low_thresh={pricer.low_threshold}, surge_mult={pricer.surge_multiplier}")
|
||||
|
||||
for idx, session_row in features_df.iterrows():
|
||||
session_id = session_row.get('sessionId')
|
||||
if not session_id:
|
||||
continue
|
||||
|
||||
# map features to demand proxy (θ̂ → D)
|
||||
session_features_single = pd.DataFrame([session_row])
|
||||
demand_proxy = session_features_to_demand(session_features_single)
|
||||
|
||||
logging.info(f"[Session {session_id}] Features → Demand: {demand_proxy:.2f}")
|
||||
logging.info(f"[Session {session_id}] Key features: velocity={session_row.get('interaction_velocity', 0):.2f}, cart_ratio={session_row.get('cart_to_view_ratio', 0):.2f}, item_views={session_row.get('item_views', 0)}")
|
||||
|
||||
# build state space
|
||||
state_space = StateSpace(
|
||||
demand=np.full(n_products, demand_proxy), # broadcast session demand to all products
|
||||
prices=products_df['base_price'].values,
|
||||
session_features=session_features_single
|
||||
)
|
||||
|
||||
# compute optimal prices (D → p*)
|
||||
optimal_prices = pricer.predict(state_space)
|
||||
|
||||
base_avg = products_df['base_price'].mean()
|
||||
optimal_avg = optimal_prices.mean()
|
||||
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
|
||||
|
||||
logging.info(f"[Session {session_id}] Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
|
||||
|
||||
# store as dict {productId: price}
|
||||
price_map = {
|
||||
str(products_df.iloc[i]['id']): float(optimal_prices[i])
|
||||
for i in range(n_products)
|
||||
}
|
||||
|
||||
price_results[session_id] = price_map
|
||||
|
||||
ti.xcom_push(key='price_results', value=pickle.dumps(price_results))
|
||||
|
||||
logging.info(f"Computed prices for {len(price_results)} sessions, {n_products} products each")
|
||||
return len(price_results)
|
||||
|
||||
|
||||
def publish_to_registry(**kwargs):
|
||||
"""
|
||||
Task: Write session prices to Redis registry.
|
||||
THIS is the write path: prices → session:{sessionId}:prices
|
||||
"""
|
||||
ti = kwargs['ti']
|
||||
price_results = pickle.loads(ti.xcom_pull(key='price_results'))
|
||||
|
||||
if not price_results:
|
||||
logging.warning("No prices to publish")
|
||||
return 0
|
||||
|
||||
registry = ModelRegistry()
|
||||
ttl = kwargs.get('dag_run').conf.get('ttl', 1800) if kwargs.get('dag_run') and kwargs.get('dag_run').conf else 1800
|
||||
|
||||
published_count = 0
|
||||
for session_id, price_map in price_results.items():
|
||||
registry.set_session_prices(session_id, price_map, ttl=ttl)
|
||||
published_count += 1
|
||||
|
||||
logging.info(f"Published prices for {published_count} sessions to registry (TTL={ttl}s)")
|
||||
|
||||
return {
|
||||
'sessions_published': published_count,
|
||||
'products_per_session': len(next(iter(price_results.values()))) if price_results else 0,
|
||||
'status': 'success'
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'session_pricing_pipeline',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description='Session-aware pricing: extract features → compute prices → publish to registry',
|
||||
schedule_interval='*/1 * * * *', # every 1 minute
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'session-aware', 'research', 'real-time'],
|
||||
) as dag:
|
||||
|
||||
t_fetch_sessions = PythonOperator(
|
||||
task_id='fetch_recent_sessions',
|
||||
python_callable=fetch_recent_sessions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_extract_features = PythonOperator(
|
||||
task_id='extract_session_features',
|
||||
python_callable=extract_session_features,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_compute_prices = PythonOperator(
|
||||
task_id='compute_session_prices',
|
||||
python_callable=compute_session_prices,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_to_registry',
|
||||
python_callable=publish_to_registry,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# linear dependency: fetch → extract → compute → publish
|
||||
t_fetch_sessions >> t_extract_features >> t_compute_prices >> t_publish
|
||||
@@ -1,220 +0,0 @@
|
||||
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.
|
||||
@@ -1,253 +0,0 @@
|
||||
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
|
||||
@@ -1,21 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,212 +0,0 @@
|
||||
# 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)
|
||||
@@ -1 +0,0 @@
|
||||
from .encoder import Window, extract_windows, build_windows, WindowDataset, PrototypeClassifier, train, loocv
|
||||
@@ -1,210 +0,0 @@
|
||||
"""Contrastive encoder via trajectory windowing. Classification by prototype distance."""
|
||||
import sys
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
|
||||
|
||||
from sim.rl.behavior_loader.loader import JointLoader, PayloadModel
|
||||
from arch import TrajectoryEncoder, featurize_trajectory, nt_xent_loss
|
||||
from typing import List, Dict, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import numpy as np, torch, torch.nn.functional as F, random, optuna
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import Adam
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
RUNS = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
|
||||
AGENT_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
HUMAN_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Window:
|
||||
events: List[PayloadModel]
|
||||
traj_id: str
|
||||
label: int # 0=human, 1=agent
|
||||
|
||||
|
||||
def extract_windows(events: List[PayloadModel], traj_id: str, label: int,
|
||||
sizes: List[int] = [5, 10, 15], stride: int = 2) -> List[Window]:
|
||||
"""Multi-scale overlapping windows from trajectory"""
|
||||
n = len(events)
|
||||
wins = [Window(events[i:i+s], traj_id, label) for s in sizes if n >= s for i in range(0, n-s+1, stride)]
|
||||
if n >= 3: wins.append(Window(events, traj_id, label)) # full traj
|
||||
return wins
|
||||
|
||||
|
||||
def build_windows(data: Dict[str, List], sizes=[5,10,15], stride=2) -> List[Window]:
|
||||
return [w for tid, evts in data.items()
|
||||
for w in extract_windows(evts, tid, 0 if tid.startswith('human_') else 1, sizes, stride)]
|
||||
|
||||
|
||||
class WindowDataset(Dataset):
|
||||
"""Yields (anchor, positive) pairs from same class"""
|
||||
def __init__(self, windows: List[Window], dim: int = 64):
|
||||
self.wins, self.dim = windows, dim
|
||||
self.by_label = {0: [i for i,w in enumerate(windows) if w.label==0],
|
||||
1: [i for i,w in enumerate(windows) if w.label==1]}
|
||||
self.by_traj = {}
|
||||
for i, w in enumerate(windows): self.by_traj.setdefault(w.traj_id, []).append(i)
|
||||
|
||||
def __len__(self): return len(self.wins)
|
||||
|
||||
def _feat(self, evts): return featurize_trajectory(evts, None, self.dim)
|
||||
|
||||
def _aug(self, evts): # subsample 70-100%
|
||||
if len(evts) < 4: return evts
|
||||
k = max(3, int(len(evts) * random.uniform(0.7, 1.0)))
|
||||
start = random.randint(0, len(evts) - k)
|
||||
return evts[start:start+k]
|
||||
|
||||
def __getitem__(self, idx):
|
||||
w = self.wins[idx]
|
||||
pool = [i for i in self.by_label[w.label] if self.wins[i].traj_id != w.traj_id]
|
||||
pos_idx = random.choice(pool) if pool else idx
|
||||
a = torch.tensor(self._feat(self._aug(w.events)), dtype=torch.float32)
|
||||
p = torch.tensor(self._feat(self._aug(self.wins[pos_idx].events)), dtype=torch.float32)
|
||||
return a, p, w.label
|
||||
|
||||
|
||||
class PrototypeClassifier:
|
||||
"""Classify by distance to class centroids"""
|
||||
def __init__(self, encoder: TrajectoryEncoder, device = 'cuda', dim=64):
|
||||
self.enc, self.dev, self.dim = encoder, device, dim
|
||||
self.centroids = {0: None, 1: None}
|
||||
|
||||
def fit(self, windows: List[Window]):
|
||||
self.enc.eval()
|
||||
embs = {0: [], 1: []}
|
||||
with torch.no_grad():
|
||||
for w in windows:
|
||||
x = torch.tensor(featurize_trajectory(w.events, None, self.dim), dtype=torch.float32)
|
||||
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
|
||||
embs[w.label].append(z)
|
||||
self.centroids = {k: torch.cat(v).mean(0, keepdim=True) if v else None for k, v in embs.items()}
|
||||
return self
|
||||
|
||||
def predict(self, events: List[PayloadModel]) -> Tuple[int, float, Dict]:
|
||||
"""Returns (pred, confidence, debug). Confidence via softmax over -distances."""
|
||||
self.enc.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(featurize_trajectory(events, None, self.dim), dtype=torch.float32)
|
||||
z = self.enc(x.unsqueeze(0).unsqueeze(1).to(self.dev))
|
||||
dists = {k: torch.norm(z - c, dim=1).item() for k, c in self.centroids.items() if c is not None}
|
||||
if not dists: return 0, 0.0, {'d': {}, 'p': [0.5, 0.5]}
|
||||
pred = min(dists, key=dists.get)
|
||||
d0, d1 = dists.get(0, 1e6), dists.get(1, 1e6) # softmax(-d) gives higher prob to closer centroid
|
||||
probs = F.softmax(torch.tensor([[-d0, -d1]]), dim=1).squeeze()
|
||||
return pred, probs[pred].item(), {'d': dists, 'p': probs.tolist()}
|
||||
|
||||
|
||||
def train(epochs=200, lr=5e-4, batch=16, dim=64, emb=32, temp=0.5,
|
||||
sizes=[5,10,15], stride=2, name=None, verbose=True):
|
||||
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
|
||||
wins = build_windows(data, sizes, stride)
|
||||
if verbose: print(f"Windows: {len(wins)} ({sum(w.label==0 for w in wins)}h/{sum(w.label==1 for w in wins)}a)")
|
||||
|
||||
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
enc = TrajectoryEncoder(dim, emb).to(dev)
|
||||
opt = Adam(enc.parameters(), lr=lr)
|
||||
loader = DataLoader(WindowDataset(wins, dim), batch_size=batch, shuffle=True, drop_last=True)
|
||||
|
||||
name = name or f"enc_{dim}_{emb}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS}/encoder/{name}")
|
||||
|
||||
for ep in range(epochs):
|
||||
enc.train()
|
||||
total, n = 0.0, 0
|
||||
for a, p, _ in loader:
|
||||
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
total += loss.item(); n += 1
|
||||
avg = total / max(n, 1)
|
||||
writer.add_scalar('loss-ntxent', avg, ep)
|
||||
if verbose and (ep+1) % 20 == 0: print(f"Epoch {ep+1}: {avg:.4f}")
|
||||
|
||||
writer.close()
|
||||
return enc, wins, dev
|
||||
|
||||
|
||||
def loocv(epochs=100, lr=5e-4, dim=64, emb=32, temp=0.5, sizes=[5,10,15], stride=2, verbose=True):
|
||||
"""Leave-one-trajectory-out CV"""
|
||||
data = JointLoader(HUMAN_DIR, AGENT_DIR).get_data()
|
||||
dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
results = []
|
||||
|
||||
for test_id in data:
|
||||
train_data = {k: v for k, v in data.items() if k != test_id}
|
||||
if not any(k.startswith('human_') for k in train_data) or not any(k.startswith('agent_') for k in train_data):
|
||||
continue
|
||||
|
||||
wins = build_windows(train_data, sizes, stride)
|
||||
enc = TrajectoryEncoder(dim, emb).to(dev)
|
||||
opt = Adam(enc.parameters(), lr=lr)
|
||||
loader = DataLoader(WindowDataset(wins, dim), batch_size=min(16, len(wins)//2 or 1),
|
||||
shuffle=True, drop_last=len(wins)>2)
|
||||
|
||||
for _ in range(epochs):
|
||||
enc.train()
|
||||
for a, p, _ in loader:
|
||||
loss = nt_xent_loss(enc(a.unsqueeze(1).to(dev)), enc(p.unsqueeze(1).to(dev)), temp)
|
||||
opt.zero_grad(); loss.backward(); opt.step()
|
||||
|
||||
clf = PrototypeClassifier(enc, dev, dim).fit(wins)
|
||||
pred, conf, dbg = clf.predict(data[test_id])
|
||||
actual = 0 if test_id.startswith('human_') else 1
|
||||
results.append((pred, actual, conf))
|
||||
if verbose: print(f"{test_id[:18]}: pred={pred} conf={conf:.2f} actual={actual} {'OK' if pred==actual else 'MISS'}")
|
||||
|
||||
if results:
|
||||
acc = sum(p==a for p,a,_ in results) / len(results)
|
||||
if verbose: print(f"\nAccuracy: {acc:.1%} ({sum(p==a for p,a,_ in results)}/{len(results)})")
|
||||
return acc, results
|
||||
return 0.0, []
|
||||
|
||||
|
||||
def hparam_tune(n_trials=50, epochs=60, n_jobs=2, verbose=True):
|
||||
"""Optuna hyperparameter search maximizing LOOCV accuracy"""
|
||||
def objective(trial):
|
||||
lr = trial.suggest_float('lr', 1e-5, 1e-2, log=True)
|
||||
dim = trial.suggest_categorical('dim', [32, 64, 128, 256])
|
||||
emb = trial.suggest_categorical('emb', [16, 32, 64, 128])
|
||||
temp = trial.suggest_float('temp', 0.05, 1.0)
|
||||
stride = trial.suggest_int('stride', 1, 4)
|
||||
sizes = [trial.suggest_int(f's{i}', 3, 20) for i in range(3)]
|
||||
sizes = sorted(set(sizes)) # unique sorted
|
||||
acc, _ = loocv(epochs, lr, dim, emb, temp, sizes, stride, verbose=False)
|
||||
return acc
|
||||
|
||||
study = optuna.create_study(direction='maximize', study_name='encoder_hparam',
|
||||
sampler=optuna.samplers.TPESampler(seed=42))
|
||||
study.optimize(objective, n_trials=n_trials, n_jobs=n_jobs, show_progress_bar=verbose)
|
||||
|
||||
best = study.best_params
|
||||
if verbose:
|
||||
print(f"\nBest accuracy: {study.best_value:.1%}")
|
||||
print(f"Best params: {best}")
|
||||
return best, study
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--mode', choices=['train', 'eval', 'hparam'], default='train')
|
||||
p.add_argument('--epochs', type=int, default=200)
|
||||
p.add_argument('--lr', type=float, default=5e-4)
|
||||
p.add_argument('--dim', type=int, default=128)
|
||||
p.add_argument('--emb', type=int, default=64)
|
||||
p.add_argument('--temp', type=float, default=0.1)
|
||||
p.add_argument('--sizes', type=str, default='5,10,15')
|
||||
p.add_argument('--stride', type=int, default=2)
|
||||
p.add_argument('--n_trials', type=int, default=50)
|
||||
args = p.parse_args()
|
||||
sizes = [int(x) for x in args.sizes.split(',')]
|
||||
|
||||
if args.mode == 'train':
|
||||
enc, wins, dev = train(args.epochs, args.lr, 16, args.dim, args.emb, args.temp, sizes, args.stride)
|
||||
elif args.mode == 'hparam':
|
||||
best, study = hparam_tune(args.n_trials, min(args.epochs, 60))
|
||||
else:
|
||||
loocv(args.epochs, args.lr, args.dim, args.emb, args.temp, sizes, args.stride)
|
||||
@@ -1,103 +0,0 @@
|
||||
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}")
|
||||
@@ -1,6 +0,0 @@
|
||||
torch
|
||||
tensorboard
|
||||
fastparquet
|
||||
pyarrow
|
||||
xgboost
|
||||
lightgbm
|
||||
@@ -1,137 +0,0 @@
|
||||
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)
|
||||
@@ -1,246 +0,0 @@
|
||||
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,957 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"True"
|
||||
]
|
||||
},
|
||||
"execution_count": 10,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from kafka import KafkaConsumer\n",
|
||||
"import pandas as pd\n",
|
||||
"import json\n",
|
||||
"import numpy as np\n",
|
||||
"import os\n",
|
||||
"from dotenv import load_dotenv\n",
|
||||
"import matplotlib.pyplot as plt\n",
|
||||
"from IPython.display import display, SVG, Image\n",
|
||||
"load_dotenv()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
||||
"RangeIndex: 73 entries, 0 to 72\n",
|
||||
"Data columns (total 13 columns):\n",
|
||||
" # Column Non-Null Count Dtype \n",
|
||||
"--- ------ -------------- ----- \n",
|
||||
" 0 sessionId 73 non-null object \n",
|
||||
" 1 eventName 73 non-null object \n",
|
||||
" 2 page 73 non-null object \n",
|
||||
" 3 productId 67 non-null object \n",
|
||||
" 4 storeMode 73 non-null object \n",
|
||||
" 5 userAgent 73 non-null object \n",
|
||||
" 6 ts 73 non-null object \n",
|
||||
" 7 metadata_referrer 6 non-null object \n",
|
||||
" 8 metadata_roomType 45 non-null object \n",
|
||||
" 9 metadata_price 45 non-null float64\n",
|
||||
" 10 metadata_nights 45 non-null float64\n",
|
||||
" 11 metadata_elementText 22 non-null object \n",
|
||||
" 12 metadata_dwellTime 22 non-null float64\n",
|
||||
"dtypes: float64(3), object(10)\n",
|
||||
"memory usage: 7.5+ KB\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
||||
"topic = \"user-interactions\"\n",
|
||||
"consumer = KafkaConsumer(\n",
|
||||
" topic, \n",
|
||||
" enable_auto_commit=True,\n",
|
||||
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
||||
" auto_offset_reset='earliest', \n",
|
||||
" bootstrap_servers=['localhost:9092'])\n",
|
||||
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
||||
"df = []\n",
|
||||
"for m in messages.values():\n",
|
||||
" for i in m:\n",
|
||||
" df.append(i.value)\n",
|
||||
"df = pd.DataFrame(df)\n",
|
||||
"# explode metadata col json\n",
|
||||
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
||||
"df.info()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>sessionId</th>\n",
|
||||
" <th>eventName</th>\n",
|
||||
" <th>page</th>\n",
|
||||
" <th>productId</th>\n",
|
||||
" <th>storeMode</th>\n",
|
||||
" <th>userAgent</th>\n",
|
||||
" <th>ts</th>\n",
|
||||
" <th>metadata_referrer</th>\n",
|
||||
" <th>metadata_roomType</th>\n",
|
||||
" <th>metadata_price</th>\n",
|
||||
" <th>metadata_nights</th>\n",
|
||||
" <th>metadata_elementText</th>\n",
|
||||
" <th>metadata_dwellTime</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>d176d7c9-4027-4702-9e31-2a71395cdda0</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:23:46.270Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:00.291Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:07.769Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>f0317a5d-e424-44e9-b784-c8f7291ffe31</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck...</td>\n",
|
||||
" <td>2025-11-14T13:26:15.010Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>269.0</td>\n",
|
||||
" <td>1.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>4</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.457Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>5</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:15.591Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>432</th>\n",
|
||||
" <td>214d9fad-9b00-40c3-bd0e-7739b6acd654</td>\n",
|
||||
" <td>click</td>\n",
|
||||
" <td>1762448192425</td>\n",
|
||||
" <td>DIV</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>/</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>1623.0</td>\n",
|
||||
" <td>493.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>6</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:21.483Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>7</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:22.646Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>8</th>\n",
|
||||
" <td>238dc588-a7ab-4c0e-bccd-6abca5076c66</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7...</td>\n",
|
||||
" <td>2025-11-14T13:27:25.889Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>264.0</td>\n",
|
||||
" <td>2.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>35</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>page_view</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:53:59.993Z</td>\n",
|
||||
" <td></td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>36</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:10.705Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Premium Room</td>\n",
|
||||
" <td>223.0</td>\n",
|
||||
" <td>3.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>37</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-0</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:11.771Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>416.0</td>\n",
|
||||
" <td>397.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Grand Plaza Hotel</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>38</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>view_item_page</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:29.772Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Standard Room</td>\n",
|
||||
" <td>267.0</td>\n",
|
||||
" <td>5.0</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>39</th>\n",
|
||||
" <td>013fc334-4045-4d5a-8739-dd0a8766a63b</td>\n",
|
||||
" <td>hover_over_title</td>\n",
|
||||
" <td>/products</td>\n",
|
||||
" <td>htl-1</td>\n",
|
||||
" <td>hotel</td>\n",
|
||||
" <td>Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53...</td>\n",
|
||||
" <td>2025-11-14T13:54:30.833Z</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>NaN</td>\n",
|
||||
" <td>Seaside Resort</td>\n",
|
||||
" <td>1200.0</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" sessionId eventName page \\\n",
|
||||
"0 d176d7c9-4027-4702-9e31-2a71395cdda0 page_view /products \n",
|
||||
"1 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view / \n",
|
||||
"2 f0317a5d-e424-44e9-b784-c8f7291ffe31 page_view /products \n",
|
||||
"3 f0317a5d-e424-44e9-b784-c8f7291ffe31 view_item_page /products \n",
|
||||
"4 238dc588-a7ab-4c0e-bccd-6abca5076c66 page_view /products \n",
|
||||
"5 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"6 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"7 238dc588-a7ab-4c0e-bccd-6abca5076c66 hover_over_title /products \n",
|
||||
"8 238dc588-a7ab-4c0e-bccd-6abca5076c66 view_item_page /products \n",
|
||||
"35 013fc334-4045-4d5a-8739-dd0a8766a63b page_view /products \n",
|
||||
"36 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"37 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"38 013fc334-4045-4d5a-8739-dd0a8766a63b view_item_page /products \n",
|
||||
"39 013fc334-4045-4d5a-8739-dd0a8766a63b hover_over_title /products \n",
|
||||
"\n",
|
||||
" productId storeMode userAgent \\\n",
|
||||
"0 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"1 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"2 None hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"3 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Geck... \n",
|
||||
"4 None hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"5 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"6 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"7 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"8 htl-0 hotel Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7... \n",
|
||||
"35 None hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"36 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"37 htl-0 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"38 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"39 htl-1 hotel Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/53... \n",
|
||||
"\n",
|
||||
" ts metadata_referrer metadata_roomType \\\n",
|
||||
"0 2025-11-14T13:23:46.270Z NaN \n",
|
||||
"1 2025-11-14T13:26:00.291Z NaN \n",
|
||||
"2 2025-11-14T13:26:07.769Z NaN \n",
|
||||
"3 2025-11-14T13:26:15.010Z NaN Premium Room \n",
|
||||
"4 2025-11-14T13:27:15.457Z NaN \n",
|
||||
"5 2025-11-14T13:27:15.591Z NaN Premium Room \n",
|
||||
"6 2025-11-14T13:27:21.483Z NaN Premium Room \n",
|
||||
"7 2025-11-14T13:27:22.646Z NaN NaN \n",
|
||||
"8 2025-11-14T13:27:25.889Z NaN Premium Room \n",
|
||||
"35 2025-11-14T13:53:59.993Z NaN \n",
|
||||
"36 2025-11-14T13:54:10.705Z NaN Premium Room \n",
|
||||
"37 2025-11-14T13:54:11.771Z NaN NaN \n",
|
||||
"38 2025-11-14T13:54:29.772Z NaN Standard Room \n",
|
||||
"39 2025-11-14T13:54:30.833Z NaN NaN \n",
|
||||
"\n",
|
||||
" metadata_price metadata_nights metadata_elementText metadata_dwellTime \n",
|
||||
"0 NaN NaN NaN NaN \n",
|
||||
"1 NaN NaN NaN NaN \n",
|
||||
"2 NaN NaN NaN NaN \n",
|
||||
"3 269.0 1.0 NaN NaN \n",
|
||||
"4 NaN NaN NaN NaN \n",
|
||||
"5 264.0 2.0 NaN NaN \n",
|
||||
"6 264.0 2.0 NaN NaN \n",
|
||||
"7 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"8 264.0 2.0 NaN NaN \n",
|
||||
"35 NaN NaN NaN NaN \n",
|
||||
"36 223.0 3.0 NaN NaN \n",
|
||||
"37 NaN NaN Grand Plaza Hotel 1200.0 \n",
|
||||
"38 267.0 5.0 NaN NaN \n",
|
||||
"39 NaN NaN Seaside Resort 1200.0 "
|
||||
]
|
||||
},
|
||||
"execution_count": 12,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"df.groupby('sessionId').head()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 13,
|
||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/plain": [
|
||||
"['013fc334-4045-4d5a-8739-dd0a8766a63b',\n",
|
||||
" '238dc588-a7ab-4c0e-bccd-6abca5076c66',\n",
|
||||
" 'd176d7c9-4027-4702-9e31-2a71395cdda0',\n",
|
||||
" 'f0317a5d-e424-44e9-b784-c8f7291ffe31']"
|
||||
]
|
||||
},
|
||||
"execution_count": 13,
|
||||
"metadata": {},
|
||||
"output_type": "execute_result"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"sessions = list(set(df['sessionId'])); sessions # 238dc588-a7ab-4c0e-bccd-6abca5076c66"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 14,
|
||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# map sessions to experiments"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 15,
|
||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
||||
" df = df.dropna(subset=['eventName'])\n",
|
||||
" events = df['eventName'].tolist()\n",
|
||||
" labels = pd.Index(events).unique().tolist()\n",
|
||||
" idx = {e:i for i,e in enumerate(labels)}\n",
|
||||
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
||||
" for a, b in zip(events, events[1:]):\n",
|
||||
" M[idx[a], idx[b]] += 1\n",
|
||||
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
||||
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
||||
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
||||
" return P, labels"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 16,
|
||||
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
||||
"from graphviz import Digraph\n",
|
||||
"import numpy as np\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"def _as_prob_df(matrix, labels=None):\n",
|
||||
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
||||
" if isinstance(matrix, pd.DataFrame):\n",
|
||||
" # Ensure square and aligned\n",
|
||||
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
||||
" return matrix\n",
|
||||
" matrix = np.asarray(matrix, dtype=float)\n",
|
||||
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
||||
" if labels is None:\n",
|
||||
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
||||
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
||||
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
||||
"\n",
|
||||
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
||||
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
||||
" edges = []\n",
|
||||
" for src in P.index:\n",
|
||||
" for dst in P.columns:\n",
|
||||
" w = float(P.loc[src, dst])\n",
|
||||
" if w > threshold:\n",
|
||||
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
||||
" return edges\n",
|
||||
"\n",
|
||||
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
||||
" \"\"\"\n",
|
||||
" fname: output file stem (no extension)\n",
|
||||
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
||||
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
||||
" threshold: hide edges with weight <= threshold\n",
|
||||
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
||||
" view: open after rendering\n",
|
||||
" \"\"\"\n",
|
||||
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
||||
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
||||
"\n",
|
||||
" g = Digraph(format=fmt)\n",
|
||||
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
||||
" g.attr(\"node\", shape=\"circle\")\n",
|
||||
"\n",
|
||||
" # ensure isolated nodes appear\n",
|
||||
" for node in P.index:\n",
|
||||
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
||||
"\n",
|
||||
" for src, dst, label in edges:\n",
|
||||
" g.edge(src, dst, label=label)\n",
|
||||
"\n",
|
||||
" g.render(fname, view=view, cleanup=True)\n",
|
||||
" return g\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 17,
|
||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"013fc334-4045-4d5a-8739-dd0a8766a63b\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-235.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-235.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-231.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-235.83C113.69,-235.83 133.31,-235.83 152.25,-235.83\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-239.33 162.1,-235.83 152.1,-232.33 152.1,-239.33\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-239.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-302.59C217.1,-314.51 223.14,-322.84 232.88,-322.84 239.27,-322.84 244.07,-319.26 247.28,-313.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-314.62 250.52,-304.02 243.95,-312.33 250.57,-314.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-326.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.68</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.48,-250.14C307.03,-251.43 313.58,-252.69 319.89,-253.83 340.12,-257.51 362.05,-261.1 382.5,-264.27\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.77,-267.7 392.19,-265.76 382.83,-260.78 381.77,-267.7\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-263.17\" font-family=\"Times,serif\" font-size=\"14.00\">0.29</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M292.09,-199.63C316.79,-184.27 346.14,-166.02 373.44,-149.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.08,-152.15 381.72,-143.89 371.38,-146.2 375.08,-152.15\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-185.68\" font-family=\"Times,serif\" font-size=\"14.00\">0.04</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M399.53,-246.73C384.12,-240.88 367.42,-235.6 351.39,-232.58 339.13,-230.28 326.03,-229.26 313.19,-229.04\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"313.51,-225.54 303.51,-229.04 313.51,-232.54 313.51,-225.54\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-236.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f0779e818b0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[]\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"8pt\" height=\"8pt\"\n",
|
||||
" viewBox=\"0.00 0.00 8.00 8.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 4)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-4 4,-4 4,4 -4,4\"/>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800fac980>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [0.00000000e+000 6.78571429e-001 2.85714286e-001 3.57142857e-002]\n",
|
||||
" [0.00000000e+000 1.00000000e+000 0.00000000e+000 0.00000000e+000]\n",
|
||||
" [2.05833592e-312 2.29175545e-312 4.94065646e-324 6.92110218e-310]]\n",
|
||||
"238dc588-a7ab-4c0e-bccd-6abca5076c66\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"565pt\" height=\"354pt\"\n",
|
||||
" viewBox=\"0.00 0.00 565.00 354.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 349.64)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-349.64 561.05,-349.64 561.05,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-109.83\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-105.16\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-197.83\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-193.16\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M92.02,-130.47C112.32,-140.25 137.13,-152.2 160.18,-163.3\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"158.39,-166.32 168.92,-167.51 161.43,-160.02 158.39,-166.32\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-157.78\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>view_item_page->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M214.74,-264.59C217.1,-276.51 223.14,-284.84 232.88,-284.84 239.27,-284.84 244.07,-281.26 247.28,-275.42\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"250.57,-276.62 250.52,-266.02 243.95,-274.33 250.57,-276.62\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-288.79\" font-family=\"Times,serif\" font-size=\"14.00\">0.19</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title -->\n",
|
||||
"<g id=\"node3\" class=\"node\">\n",
|
||||
"<title>hover_over_title</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-275.83\" rx=\"69.81\" ry=\"69.81\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-271.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_title</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_title -->\n",
|
||||
"<g id=\"edge3\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_title</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M289.6,-237.16C299.36,-242.77 309.67,-247.94 319.89,-251.83 339.45,-259.28 361.4,-264.43 382.1,-267.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"381.52,-271.43 391.95,-269.55 382.62,-264.52 381.52,-271.43\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-265.16\" font-family=\"Times,serif\" font-size=\"14.00\">0.38</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph -->\n",
|
||||
"<g id=\"node4\" class=\"node\">\n",
|
||||
"<title>hover_over_paragraph</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"463.22\" cy=\"-93.83\" rx=\"93.83\" ry=\"93.83\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"463.22\" y=\"-89.16\" font-family=\"Times,serif\" font-size=\"14.00\">hover_over_paragraph</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page->hover_over_paragraph -->\n",
|
||||
"<g id=\"edge4\" class=\"edge\">\n",
|
||||
"<title>view_item_page->hover_over_paragraph</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M300.22,-180.71C317.22,-175.46 335.24,-169.12 351.39,-161.83 358.97,-158.41 366.67,-154.57 374.29,-150.49\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"375.84,-153.63 382.92,-145.75 372.47,-147.5 375.84,-153.63\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-178.15\" font-family=\"Times,serif\" font-size=\"14.00\">0.44</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_title->view_item_page -->\n",
|
||||
"<g id=\"edge5\" class=\"edge\">\n",
|
||||
"<title>hover_over_title->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M398.52,-248.36C383.21,-242.16 366.82,-235.87 351.39,-230.58 338.42,-226.15 324.5,-221.86 310.94,-217.93\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"312.2,-214.65 301.62,-215.28 310.28,-221.39 312.2,-214.65\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-234.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.00</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->page_view -->\n",
|
||||
"<g id=\"edge6\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M369.13,-95.76C310.26,-97.17 232.59,-99.41 163.87,-102.58 145.72,-103.42 125.98,-104.58 108.06,-105.73\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"107.86,-102.24 98.1,-106.38 108.31,-109.22 107.86,-102.24\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-106.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.14</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- hover_over_paragraph->view_item_page -->\n",
|
||||
"<g id=\"edge7\" class=\"edge\">\n",
|
||||
"<title>hover_over_paragraph->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M372.68,-119.15C354.84,-125.32 336.5,-132.51 319.89,-140.58 312.9,-143.98 305.81,-147.87 298.86,-151.98\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"297.49,-148.71 290.78,-156.91 301.14,-154.69 297.49,-148.71\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"335.64\" y=\"-144.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.86</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0. 1. 0. 0. ]\n",
|
||||
" [0. 0.1875 0.375 0.4375 ]\n",
|
||||
" [0. 1. 0. 0. ]\n",
|
||||
" [0.14285714 0.85714286 0. 0. ]]\n",
|
||||
"d176d7c9-4027-4702-9e31-2a71395cdda0\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"104pt\" height=\"104pt\"\n",
|
||||
" viewBox=\"0.00 0.00 104.00 104.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 100.37)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-100.37 100.37,-100.37 100.37,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-48.19\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-43.51\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800f97110>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[0.]]\n",
|
||||
"f0317a5d-e424-44e9-b784-c8f7291ffe31\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"image/svg+xml": [
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
||||
" -->\n",
|
||||
"<!-- Pages: 1 -->\n",
|
||||
"<svg width=\"310pt\" height=\"160pt\"\n",
|
||||
" viewBox=\"0.00 0.00 310.00 160.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 156.44)\">\n",
|
||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-156.44 305.89,-156.44 305.89,4 -4,4\"/>\n",
|
||||
"<!-- page_view -->\n",
|
||||
"<g id=\"node1\" class=\"node\">\n",
|
||||
"<title>page_view</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"48.19\" cy=\"-69.01\" rx=\"48.19\" ry=\"48.19\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">page_view</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->page_view -->\n",
|
||||
"<g id=\"edge1\" class=\"edge\">\n",
|
||||
"<title>page_view->page_view</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M33.03,-115.09C34.09,-126.6 39.14,-135.19 48.19,-135.19 53.98,-135.19 58.13,-131.66 60.65,-126.1\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"64.01,-127.11 62.98,-116.56 57.21,-125.45 64.01,-127.11\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"48.19\" y=\"-139.14\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- view_item_page -->\n",
|
||||
"<g id=\"node2\" class=\"node\">\n",
|
||||
"<title>view_item_page</title>\n",
|
||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"232.88\" cy=\"-69.01\" rx=\"69.01\" ry=\"69.01\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"232.88\" y=\"-64.33\" font-family=\"Times,serif\" font-size=\"14.00\">view_item_page</text>\n",
|
||||
"</g>\n",
|
||||
"<!-- page_view->view_item_page -->\n",
|
||||
"<g id=\"edge2\" class=\"edge\">\n",
|
||||
"<title>page_view->view_item_page</title>\n",
|
||||
"<path fill=\"none\" stroke=\"black\" d=\"M96.71,-69.01C113.69,-69.01 133.31,-69.01 152.25,-69.01\"/>\n",
|
||||
"<polygon fill=\"black\" stroke=\"black\" points=\"152.1,-72.51 162.1,-69.01 152.1,-65.51 152.1,-72.51\"/>\n",
|
||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"130.12\" y=\"-72.96\" font-family=\"Times,serif\" font-size=\"14.00\">0.50</text>\n",
|
||||
"</g>\n",
|
||||
"</g>\n",
|
||||
"</svg>\n"
|
||||
],
|
||||
"text/plain": [
|
||||
"<graphviz.graphs.Digraph at 0x7f6800bf50f0>"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"[[5.0e-001 5.0e-001]\n",
|
||||
" [9.9e-324 1.5e-323]]\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def explore_session(session_id: str):\n",
|
||||
" subset = df[df['sessionId'] == session_id]\n",
|
||||
" print(session_id)\n",
|
||||
" P, labels = build_transition_prob_matrix(subset)\n",
|
||||
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
||||
" display(g)\n",
|
||||
" return P\n",
|
||||
"for session in sessions:\n",
|
||||
" print(explore_session(session))"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python (PHANTOM)",
|
||||
"language": "python",
|
||||
"name": "phantom"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -12,14 +12,16 @@ from procesing.steps import (
|
||||
ComputeDemandStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
# StateSpace,
|
||||
# BuildStateSpaceStep,
|
||||
ComputeElasticityStep,
|
||||
StateSpace,
|
||||
BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
from procesing.pipelines import (
|
||||
interaction_extraction_pipeline,
|
||||
price_extraction_pipeline,
|
||||
elasticity_computation_pipeline,
|
||||
pricing_pipeline,
|
||||
full_pipeline,
|
||||
)
|
||||
@@ -40,12 +42,14 @@ __all__ = [
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
# 'StateSpace',
|
||||
# 'BuildStateSpaceStep',
|
||||
'ComputeElasticityStep',
|
||||
'StateSpace',
|
||||
'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'interaction_extraction_pipeline',
|
||||
'price_extraction_pipeline',
|
||||
'elasticity_computation_pipeline',
|
||||
'pricing_pipeline',
|
||||
'full_pipeline',
|
||||
]
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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
|
||||
@@ -2,7 +2,7 @@ from sklearn.pipeline import Pipeline
|
||||
import pandas as pd
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
import os
|
||||
from typing import Union
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
@@ -13,15 +13,11 @@ from procesing.steps import (
|
||||
ChunkByTimeWindowStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep,
|
||||
BuildStateSpaceStep,
|
||||
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"""
|
||||
@@ -39,136 +35,104 @@ def price_extraction_pipeline(context: PipelineContext):
|
||||
])
|
||||
|
||||
|
||||
def product_features_pipeline(context: PipelineContext,
|
||||
def elasticity_computation_pipeline(context: PipelineContext,
|
||||
interactions_df: pd.DataFrame,
|
||||
price_logs_df: pd.DataFrame):
|
||||
demand_step = ComputeDemandStep(context)
|
||||
"""
|
||||
Compute elasticity from interactions and price logs.
|
||||
Manual orchestration needed for branching logic.
|
||||
"""
|
||||
# branch 1: chunk interactions and compute demand
|
||||
chunk_step = ChunkByTimeWindowStep(context)
|
||||
interaction_chunks = chunk_step.transform(interactions_df)
|
||||
|
||||
demand_step = ComputeDemandForChunksStep(context)
|
||||
demand_chunks = demand_step.transform(interaction_chunks)
|
||||
|
||||
# branch 2: aggregate price logs
|
||||
price_step = AggregatePriceLogsStep(context)
|
||||
join_step = JoinProductFeaturesStep(context)
|
||||
price_chunks = price_step.transform(price_logs_df)
|
||||
|
||||
# convergence: compute elasticity
|
||||
elasticity_step = ComputeElasticityStep(context)
|
||||
elasticity_df = elasticity_step.transform((demand_chunks, price_chunks))
|
||||
|
||||
return elasticity_df
|
||||
|
||||
|
||||
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):
|
||||
def pricing_pipeline(context: PipelineContext, elasticity_df: pd.DataFrame):
|
||||
"""
|
||||
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]
|
||||
Generate optimal prices from elasticity estimates.
|
||||
"""
|
||||
# build state space
|
||||
state_step = BuildStateSpaceStep(context)
|
||||
state_space = state_step.transform(elasticity_df)
|
||||
|
||||
# fit pricing function
|
||||
fit_step = FitPricingFunctionStep(context)
|
||||
pricer = fit_step.transform(elasticity_df)
|
||||
|
||||
# predict prices
|
||||
predict_step = PredictPricesStep(context)
|
||||
prices_df = predict_step.transform((pricer, state_space))
|
||||
|
||||
return prices_df
|
||||
|
||||
|
||||
def full_pipeline(context: PipelineContext):
|
||||
"""
|
||||
Complete end-to-end pipeline: data extraction -> elasticity -> pricing
|
||||
Returns: (elasticity_df, prices_df)
|
||||
"""
|
||||
# extract interactions
|
||||
interaction_pipe = interaction_extraction_pipeline(context)
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
|
||||
interactions_df = interaction_pipe.fit_transform(None)
|
||||
|
||||
# extract price logs
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
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)
|
||||
if interactions_df.empty or price_logs_df.empty:
|
||||
return None, None
|
||||
|
||||
return product_features_df, optimal_prices_df
|
||||
# compute elasticity
|
||||
elasticity_df = elasticity_computation_pipeline(
|
||||
context,
|
||||
interactions_df,
|
||||
price_logs_df
|
||||
)
|
||||
|
||||
if elasticity_df is None or elasticity_df.empty:
|
||||
return elasticity_df, None
|
||||
|
||||
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
|
||||
|
||||
# generate prices
|
||||
prices_df = pricing_pipeline(context, elasticity_df)
|
||||
|
||||
return elasticity_df, prices_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()
|
||||
class Provider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self, backend_url: str):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self, backend_url=backend_url)
|
||||
# example run
|
||||
context = PipelineContext(
|
||||
provider=Provider(backend_url="http://localhost:5000"),
|
||||
store_mode='hotel',
|
||||
)
|
||||
|
||||
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||
file_to_read = files.get(topic, files["user-interactions"])
|
||||
frames = []
|
||||
elasticity_df, prices_df = full_pipeline(context)
|
||||
|
||||
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}")
|
||||
if elasticity_df is not None and not elasticity_df.empty:
|
||||
print("Elasticity Estimates:")
|
||||
print(elasticity_df.to_string(index=False))
|
||||
else:
|
||||
print("No elasticity estimates computed.")
|
||||
|
||||
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")
|
||||
if prices_df is not None and not prices_df.empty:
|
||||
print("\nPredicted Prices:")
|
||||
print(prices_df.to_string(index=False))
|
||||
else:
|
||||
print("No prices predicted.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from procesing.pricers.base import PricingFunction
|
||||
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer
|
||||
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||
|
||||
__all__ = [
|
||||
@@ -8,7 +8,6 @@ __all__ = [
|
||||
'ElasticityBasedPricer',
|
||||
'StaticPricer',
|
||||
'RandomPricer',
|
||||
'SimpleSurgePricer',
|
||||
'SessionAwarePricer',
|
||||
'ProductSpecificSessionPricer'
|
||||
]
|
||||
|
||||
@@ -7,6 +7,15 @@ import pandas as pd
|
||||
class PricingFunction(ABC):
|
||||
"""
|
||||
Abstract base for pricing functions.
|
||||
|
||||
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
||||
|
||||
Where:
|
||||
Q_t ∈ R^n: demand vector at time t
|
||||
P_t ∈ R^n: price vector at time t
|
||||
S_t: session features (behavioral signals, interactions)
|
||||
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
||||
|
||||
Objective:
|
||||
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||
subject to:
|
||||
@@ -16,32 +25,26 @@ class PricingFunction(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fit(self, *kwargs):
|
||||
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
This is where we can think about some maximization of expected revenue
|
||||
over historical trajectories to learn parameters of the pricing function.
|
||||
(This however we cover move in the RL side of things)
|
||||
|
||||
Args:
|
||||
historical_data: DataFrame with elasticity, prices, demand signals
|
||||
**kwargs: additional training parameters
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
def predict(self, state_space) -> 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.
|
||||
Args:
|
||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||
|
||||
Returns:
|
||||
np.ndarray of shape (n_products, n_features)
|
||||
P_{t+1}: price vector in R^n
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,13 +57,3 @@ class ElasticityBasedPricer(PricingFunction):
|
||||
# enforce bounds
|
||||
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 3))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||
|
||||
@@ -107,36 +107,6 @@ class SessionAwarePricer(PricingFunction):
|
||||
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and session features"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 5))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
n_products = len(demand)
|
||||
|
||||
# extract session features
|
||||
velocity = 0.0
|
||||
view_depth = 0.0
|
||||
cart_to_view = 0.0
|
||||
|
||||
if not state_space.session_features.empty:
|
||||
sf = state_space.session_features.iloc[0]
|
||||
velocity = sf.get('interaction_velocity', 0.0)
|
||||
view_depth = sf.get('product_view_depth', 0.0)
|
||||
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||
|
||||
# broadcast session features to all products
|
||||
features = np.column_stack([
|
||||
self.elasticity,
|
||||
demand,
|
||||
np.full(n_products, velocity),
|
||||
np.full(n_products, view_depth),
|
||||
np.full(n_products, cart_to_view)
|
||||
])
|
||||
return features
|
||||
|
||||
|
||||
class ProductSpecificSessionPricer(PricingFunction):
|
||||
"""
|
||||
@@ -200,12 +170,3 @@ class ProductSpecificSessionPricer(PricingFunction):
|
||||
|
||||
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity and demand features for product-specific pricing"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
return np.column_stack([self.elasticity, demand])
|
||||
|
||||
@@ -3,46 +3,6 @@ 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"""
|
||||
|
||||
@@ -65,11 +25,6 @@ class StaticPricer(PricingFunction):
|
||||
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||
return self.base_prices.copy()
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Static pricer uses no features, returns empty array"""
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class RandomPricer(PricingFunction):
|
||||
"""Random pricing within bounds (for baseline comparison)"""
|
||||
@@ -91,68 +46,3 @@ class RandomPricer(PricingFunction):
|
||||
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])
|
||||
|
||||
@@ -18,17 +18,10 @@ class SupabaseProvider(DataProvider):
|
||||
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
|
||||
resp = self.supabase.table(f'{store_mode}_products').select(
|
||||
"id, room_type, date_index, metadata, availability"
|
||||
).execute()
|
||||
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||
|
||||
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||
if not experiment_ids:
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
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.join import JoinExperimentsStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep
|
||||
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
|
||||
)
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep, ComputeElasticityStep
|
||||
from procesing.steps.pricing import StateSpace, BuildStateSpaceStep, FitPricingFunctionStep, PredictPricesStep
|
||||
|
||||
__all__ = [
|
||||
'BaseContextStep',
|
||||
@@ -18,22 +13,15 @@ __all__ = [
|
||||
'FetchPriceLogsStep',
|
||||
'FetchExperimentsStep',
|
||||
'JoinExperimentsStep',
|
||||
'JoinProductFeaturesStep',
|
||||
'CreatePriceBucketsStep',
|
||||
'AugmentEventNamesStep',
|
||||
'AugmentInteractionsStep',
|
||||
'ChunkByTimeWindowStep',
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
'ComputeElasticityStep',
|
||||
'StateSpace',
|
||||
'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'ExtractSessionFeaturesStep',
|
||||
'JoinLabelsStep',
|
||||
'ValidateDataStep',
|
||||
'TemporalFeatureStep',
|
||||
'BehavioralFeatureStep',
|
||||
'ProductFeatureStep',
|
||||
'UserAgentFeatureStep',
|
||||
'_extract_features_for_session',
|
||||
]
|
||||
|
||||
@@ -2,93 +2,6 @@ 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"""
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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):
|
||||
"""
|
||||
@@ -17,7 +16,7 @@ class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, X) -> Any:
|
||||
def transform(self, X):
|
||||
"""Transform input using context. Must be implemented by subclass."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -7,16 +7,16 @@ class AggregatePriceLogsStep(BaseContextStep):
|
||||
"""
|
||||
Aggregate price logs into time windows using VECTORIZED operations.
|
||||
Input: price_logs_df
|
||||
Output: DataFrame with columns [productId, price]
|
||||
Output: list of price chunks with [productId, price]
|
||||
"""
|
||||
|
||||
def transform(self, price_logs_df: pd.DataFrame):
|
||||
if price_logs_df.empty:
|
||||
return pd.DataFrame(columns=['productId', 'price'])
|
||||
return []
|
||||
|
||||
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
|
||||
window_size = self.context.window_size
|
||||
|
||||
# ensure datetime
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||
@@ -24,19 +24,230 @@ class AggregatePriceLogsStep(BaseContextStep):
|
||||
|
||||
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()
|
||||
|
||||
# VECTORIZED: group by product, resample by time window, compute mean
|
||||
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
|
||||
|
||||
windowed = (
|
||||
df_indexed
|
||||
.groupby('productId')['price']
|
||||
.resample(window_size)
|
||||
.mean()
|
||||
.reset_index()
|
||||
)
|
||||
|
||||
# forward fill missing windows (carry last known price)
|
||||
windowed = windowed.sort_values([ts_col, 'productId'])
|
||||
windowed['price'] = windowed.groupby('productId')['price'].ffill()
|
||||
windowed = windowed.dropna(subset=['price'])
|
||||
|
||||
# group into chunks by window
|
||||
chunks = []
|
||||
for window_start, group in windowed.groupby(ts_col):
|
||||
price_vector = group[['productId', 'price']].copy()
|
||||
|
||||
# fill missing products with last known price before this window
|
||||
missing_products = set(unique_products) - set(price_vector['productId'])
|
||||
if missing_products:
|
||||
for pid in missing_products:
|
||||
last_price = df_indexed[
|
||||
(df_indexed['productId'] == pid) &
|
||||
(df_indexed.index < window_start)
|
||||
]['price']
|
||||
|
||||
if not last_price.empty:
|
||||
price_vector = pd.concat([
|
||||
price_vector,
|
||||
pd.DataFrame({'productId': [pid], 'price': [last_price.iloc[-1]]})
|
||||
], ignore_index=True)
|
||||
|
||||
if not price_vector.empty:
|
||||
chunks.append({
|
||||
'window_start': window_start,
|
||||
'window_end': window_start + pd.Timedelta(window_size),
|
||||
'price_vector': price_vector
|
||||
})
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
class ComputeElasticityStep(BaseContextStep):
|
||||
"""
|
||||
Compute price elasticity from demand and price chunks.
|
||||
Input: (demand_chunks, price_chunks)
|
||||
Output: elasticity_df [productId, elasticity, std_error, n_obs]
|
||||
"""
|
||||
|
||||
def transform(self, chunk_tuple: tuple):
|
||||
demand_chunks, price_chunks = chunk_tuple
|
||||
|
||||
method = self.context.config.get('elasticity_method', 'point')
|
||||
min_obs = self.context.config.get('min_observations', 2)
|
||||
|
||||
products = self.context.products
|
||||
all_product_ids = products['id'].unique()
|
||||
|
||||
# align chunks by window_start
|
||||
aligned = self._align_chunks(demand_chunks, price_chunks)
|
||||
|
||||
if not aligned:
|
||||
return pd.DataFrame({
|
||||
'productId': all_product_ids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
|
||||
# build time series per product
|
||||
product_series = self._build_timeseries(aligned)
|
||||
|
||||
# compute elasticity per product
|
||||
elasticities = []
|
||||
for pid, series in product_series.items():
|
||||
if len(series) < min_obs:
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': len(series)
|
||||
})
|
||||
continue
|
||||
|
||||
elast = self._compute_elasticity(series, method)
|
||||
elasticities.append({
|
||||
'productId': pid,
|
||||
'elasticity': elast['value'],
|
||||
'std_error': elast.get('std_error', 0.0),
|
||||
'n_obs': len(series)
|
||||
})
|
||||
|
||||
result_df = pd.DataFrame(elasticities)
|
||||
|
||||
# fill missing products with zero elasticity
|
||||
observed_pids = set(result_df['productId'])
|
||||
missing_pids = [p for p in all_product_ids if p not in observed_pids]
|
||||
|
||||
if missing_pids:
|
||||
missing_df = pd.DataFrame({
|
||||
'productId': missing_pids,
|
||||
'elasticity': 0.0,
|
||||
'std_error': 0.0,
|
||||
'n_obs': 0
|
||||
})
|
||||
result_df = pd.concat([result_df, missing_df], ignore_index=True)
|
||||
|
||||
return result_df
|
||||
|
||||
def _align_chunks(self, demand_chunks: List[Dict], price_chunks: List[Dict]):
|
||||
"""Align demand and price chunks by window_start"""
|
||||
price_lookup = {c['window_start']: c for c in price_chunks}
|
||||
aligned = []
|
||||
|
||||
for dc in demand_chunks:
|
||||
ws = dc['window_start']
|
||||
if ws in price_lookup:
|
||||
aligned.append({
|
||||
'window_start': ws,
|
||||
'window_end': dc['window_end'],
|
||||
'demand': dc['demand_vector'],
|
||||
'prices': price_lookup[ws]['price_vector']
|
||||
})
|
||||
|
||||
return aligned
|
||||
|
||||
def _build_timeseries(self, aligned: List[Dict]):
|
||||
"""Build time series [timestamp, price, quantity] per product"""
|
||||
series_by_product = {}
|
||||
|
||||
for chunk in aligned:
|
||||
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
||||
|
||||
for _, row in merged.iterrows():
|
||||
pid = row['productId']
|
||||
if pid not in series_by_product:
|
||||
series_by_product[pid] = []
|
||||
|
||||
series_by_product[pid].append({
|
||||
'timestamp': chunk['window_start'],
|
||||
'price': row['price'],
|
||||
'quantity': row['demand_score']
|
||||
})
|
||||
|
||||
return series_by_product
|
||||
|
||||
def _compute_elasticity(self, series: List[Dict], method: str):
|
||||
"""Compute point or arc elasticity"""
|
||||
prices = np.array([s['price'] for s in series])
|
||||
quantities = np.array([s['quantity'] for s in series])
|
||||
|
||||
# filter out zero/negative values
|
||||
valid = (prices > 0) & (quantities > 0)
|
||||
if valid.sum() < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
prices = prices[valid]
|
||||
quantities = quantities[valid]
|
||||
|
||||
if method == 'point':
|
||||
return self._point_elasticity(prices, quantities)
|
||||
elif method == 'arc':
|
||||
return self._arc_elasticity(prices, quantities)
|
||||
else:
|
||||
raise ValueError(f"Unknown elasticity method: {method}")
|
||||
|
||||
def _point_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
||||
"""Point elasticity via log-log regression: log(Q) = a + b*log(P), elasticity = b"""
|
||||
if len(prices) < 2:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
log_p = np.log(prices)
|
||||
log_q = np.log(quantities)
|
||||
|
||||
if log_p.std() == 0:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
cov = np.cov(log_p, log_q)[0, 1]
|
||||
var = np.var(log_p)
|
||||
b = cov / var
|
||||
|
||||
# std error estimate
|
||||
if len(prices) > 2:
|
||||
residuals = log_q - (log_q.mean() + b * (log_p - log_p.mean()))
|
||||
mse = (residuals ** 2).sum() / (len(prices) - 2)
|
||||
se_b = np.sqrt(mse / (len(prices) * var))
|
||||
else:
|
||||
se_b = 0.0
|
||||
|
||||
return {'value': b, 'std_error': se_b}
|
||||
|
||||
def _arc_elasticity(self, prices: np.ndarray, quantities: np.ndarray):
|
||||
"""Arc elasticity: average period-over-period elasticity"""
|
||||
elasticities = []
|
||||
|
||||
for i in range(1, len(prices)):
|
||||
p1, p2 = prices[i-1], prices[i]
|
||||
q1, q2 = quantities[i-1], quantities[i]
|
||||
|
||||
p_avg = (p1 + p2) / 2
|
||||
q_avg = (q1 + q2) / 2
|
||||
|
||||
if p_avg == 0 or q_avg == 0:
|
||||
continue
|
||||
|
||||
delta_p = p2 - p1
|
||||
delta_q = q2 - q1
|
||||
|
||||
if delta_p == 0:
|
||||
continue
|
||||
|
||||
e = (delta_q / q_avg) / (delta_p / p_avg)
|
||||
elasticities.append(e)
|
||||
|
||||
if not elasticities:
|
||||
return {'value': 0.0, 'std_error': 0.0}
|
||||
|
||||
return {
|
||||
'value': np.mean(elasticities),
|
||||
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
||||
}
|
||||
|
||||
@@ -2,11 +2,7 @@ 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
|
||||
"""Fetch raw interaction data from Kafka topic"""
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||
@@ -21,50 +17,19 @@ class FetchInteractionsStep(BaseContextStep):
|
||||
)
|
||||
|
||||
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
|
||||
"""Fetch price log data from Kafka topic"""
|
||||
|
||||
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
|
||||
return self.context.provider.fetch_kafka_topic('price-logs')
|
||||
|
||||
|
||||
class FetchExperimentsStep(BaseContextStep):
|
||||
|
||||
@@ -32,27 +32,3 @@ class JoinExperimentsStep(BaseContextStep):
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -2,34 +2,128 @@ 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
|
||||
@dataclass
|
||||
class StateSpace:
|
||||
"""
|
||||
State representation for pricing functions.
|
||||
|
||||
Components:
|
||||
Q_t: demand ∈ R^n (current demand signal per product)
|
||||
P_t: prices ∈ R^n (current/base prices)
|
||||
S_t: session_features (behavioral signals, interaction data)
|
||||
H_t: history = {Q_{t-k}, P_{t-k}, S_{t-k}} for k in [1, history_length]
|
||||
|
||||
Additionally stores:
|
||||
- product_ids: product identifiers (n,)
|
||||
- elasticity: price elasticity per product (n,)
|
||||
- metadata: arbitrary context (experiment_id, timestamp, etc.)
|
||||
"""
|
||||
demand: np.ndarray # Q_t ∈ R^n
|
||||
prices: np.ndarray # P_t ∈ R^n
|
||||
session_features: pd.DataFrame = field(default_factory=pd.DataFrame) # S_t
|
||||
|
||||
# augmented state components
|
||||
product_ids: Optional[np.ndarray] = None
|
||||
elasticity: Optional[np.ndarray] = None
|
||||
|
||||
# historical trajectory H_t = {(Q_{t-k}, P_{t-k}, S_{t-k})}
|
||||
history: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# metadata for context
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate dimensions."""
|
||||
n = len(self.demand)
|
||||
assert len(self.prices) == n, "demand and prices must have same dimension"
|
||||
if self.elasticity is not None:
|
||||
assert len(self.elasticity) == n, "elasticity must match dimension"
|
||||
if self.product_ids is not None:
|
||||
assert len(self.product_ids) == n, "product_ids must match dimension"
|
||||
|
||||
@property
|
||||
def n_products(self) -> int:
|
||||
"""Number of products in state space."""
|
||||
return len(self.demand)
|
||||
|
||||
def add_history(self, q: np.ndarray, p: np.ndarray, s: pd.DataFrame, max_length: int = 10):
|
||||
"""Append historical state to trajectory H_t."""
|
||||
self.history.append({'demand': q, 'prices': p, 'session_features': s})
|
||||
if len(self.history) > max_length:
|
||||
self.history.pop(0)
|
||||
|
||||
def get_history_window(self, k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Retrieve last k historical states."""
|
||||
return self.history[-k:] if len(self.history) >= k else self.history
|
||||
|
||||
|
||||
class BuildStateSpaceStep(BaseContextStep):
|
||||
"""
|
||||
Build state space from elasticity, demand, and price data.
|
||||
|
||||
Input: elasticity_df [productId, elasticity, ...], optional demand_df
|
||||
Output: StateSpace instance with Q_t, P_t, elasticity, product_ids
|
||||
"""
|
||||
|
||||
def transform(self, elasticity_df: pd.DataFrame, demand_df: Optional[pd.DataFrame] = None):
|
||||
products = self.context.products
|
||||
|
||||
# extract base prices from product metadata
|
||||
products_with_prices = products.copy()
|
||||
if 'metadata' in products_with_prices.columns:
|
||||
products_with_prices['base_price'] = products_with_prices['metadata'].apply(
|
||||
lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0
|
||||
)
|
||||
else:
|
||||
products_with_prices['base_price'] = 0
|
||||
|
||||
# merge with elasticity
|
||||
merged = products_with_prices[['id', 'base_price']].rename(
|
||||
columns={'id': 'productId'}
|
||||
).merge(
|
||||
elasticity_df[['productId', 'elasticity']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'elasticity': 0.0, 'base_price': 0.0})
|
||||
|
||||
# merge with demand if provided, else use default
|
||||
if demand_df is not None and 'demand' in demand_df.columns:
|
||||
merged = merged.merge(
|
||||
demand_df[['productId', 'demand']],
|
||||
on='productId',
|
||||
how='left'
|
||||
).fillna({'demand': 0.0})
|
||||
demand_vector = merged['demand'].values
|
||||
else:
|
||||
# default: uniform demand or use elasticity as proxy
|
||||
demand_vector = np.ones(len(merged)) * 10.0
|
||||
|
||||
return StateSpace(
|
||||
demand=demand_vector,
|
||||
prices=merged['base_price'].values,
|
||||
session_features=pd.DataFrame(),
|
||||
product_ids=merged['productId'].values,
|
||||
elasticity=merged['elasticity'].values,
|
||||
metadata={'timestamp': pd.Timestamp.now().isoformat()}
|
||||
)
|
||||
|
||||
|
||||
class FitPricingFunctionStep(BaseContextStep):
|
||||
"""
|
||||
Fit pricing function using data.
|
||||
Input: pricing_data
|
||||
Fit pricing function using elasticity data.
|
||||
Input: elasticity_df
|
||||
Output: fitted pricing function instance
|
||||
"""
|
||||
|
||||
def transform(self, pricing_data: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||
def transform(self, elasticity_df: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', ElasticityBasedPricer)
|
||||
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||
|
||||
pricer = pricing_class(**pricing_params)
|
||||
pricer.fit(pricing_data)
|
||||
pricer.fit(elasticity_df)
|
||||
|
||||
return pricer
|
||||
|
||||
|
||||
@@ -1,262 +1,114 @@
|
||||
"""
|
||||
Session feature extraction for ML training pipeline.
|
||||
Session feature extraction for S_t component of state space.
|
||||
Computes behavioral signals from interaction data already in pipeline.
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from typing import Optional, Dict, Any
|
||||
from collections import Counter
|
||||
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.
|
||||
Extract session-level behavioral features from interaction logs.
|
||||
|
||||
Input: interactions_df (user-interactions from earlier pipeline step)
|
||||
Output: session_features DataFrame [sessionId, feature_1, feature_2, ...]
|
||||
|
||||
Features computed:
|
||||
- total_interactions: count of all events
|
||||
- page_views, item_views, searches, cart_adds: event type counts
|
||||
- hovers: hover event counts
|
||||
- unique_products_viewed: distinct product IDs
|
||||
- interaction_velocity: events per minute
|
||||
- session_duration_sec: time span of session
|
||||
- avg_time_between_events: mean inter-event time
|
||||
- product_view_depth: max views for single product (attention signal)
|
||||
"""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
if X.empty:
|
||||
def transform(self, interactions_df: pd.DataFrame) -> pd.DataFrame:
|
||||
if interactions_df.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)
|
||||
# ensure timestamp column
|
||||
if 'ts' in interactions_df.columns:
|
||||
interactions_df = interactions_df.copy()
|
||||
interactions_df['ts'] = pd.to_datetime(interactions_df['ts'])
|
||||
|
||||
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')
|
||||
# group by session and compute features
|
||||
session_features = []
|
||||
for session_id, session_df in interactions_df.groupby('sessionId'):
|
||||
features = self._extract_features_for_session(session_id, session_df)
|
||||
session_features.append(features)
|
||||
|
||||
# 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 pd.DataFrame(session_features)
|
||||
|
||||
return result
|
||||
def _extract_features_for_session(self, session_id: str, session_df: pd.DataFrame) -> Dict[str, Any]:
|
||||
"""Compute features for single session."""
|
||||
features = {'sessionId': session_id}
|
||||
|
||||
# basic counts
|
||||
features['total_interactions'] = len(session_df)
|
||||
|
||||
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
|
||||
"""
|
||||
event_counts = session_df['eventName'].value_counts().to_dict()
|
||||
features['page_views'] = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
||||
features['item_views'] = event_counts.get('view_item_page', 0)
|
||||
features['searches'] = event_counts.get('search', 0)
|
||||
features['cart_adds'] = event_counts.get('add_item_to_cart', 0)
|
||||
|
||||
def transform(self, X : tuple) -> pd.DataFrame:
|
||||
data = X;
|
||||
if isinstance(data, tuple):
|
||||
features_df, experiments_df = data
|
||||
# hover events
|
||||
hover_events = ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button']
|
||||
features['hovers'] = sum(event_counts.get(ev, 0) for ev in hover_events)
|
||||
|
||||
# product-level signals
|
||||
product_ids = session_df['productId'].dropna()
|
||||
features['unique_products_viewed'] = product_ids.nunique()
|
||||
|
||||
if len(product_ids) > 0:
|
||||
product_view_counts = Counter(product_ids)
|
||||
features['product_view_depth'] = max(product_view_counts.values())
|
||||
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()
|
||||
features['product_view_depth'] = 0
|
||||
|
||||
if features_df.empty:
|
||||
return features_df
|
||||
if experiments_df.empty:
|
||||
features_df['is_agent'] = np.nan
|
||||
return features_df
|
||||
# temporal features
|
||||
if 'ts' in session_df.columns:
|
||||
timestamps = session_df['ts'].sort_values()
|
||||
features['session_duration_sec'] = (timestamps.max() - timestamps.min()).total_seconds()
|
||||
|
||||
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']
|
||||
if features['session_duration_sec'] > 0:
|
||||
features['interaction_velocity'] = (features['total_interactions'] / features['session_duration_sec']) * 60
|
||||
else:
|
||||
features['interaction_velocity'] = 0.0
|
||||
|
||||
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')
|
||||
# inter-event timing
|
||||
if len(timestamps) > 1:
|
||||
time_diffs = timestamps.diff().dropna().dt.total_seconds()
|
||||
features['avg_time_between_events'] = time_diffs.mean()
|
||||
features['std_time_between_events'] = time_diffs.std()
|
||||
else:
|
||||
features['avg_time_between_events'] = 0.0
|
||||
features['std_time_between_events'] = 0.0
|
||||
else:
|
||||
features['session_duration_sec'] = 0.0
|
||||
features['interaction_velocity'] = 0.0
|
||||
features['avg_time_between_events'] = 0.0
|
||||
features['std_time_between_events'] = 0.0
|
||||
|
||||
# cart/conversion signals
|
||||
features['cart_to_view_ratio'] = features['cart_adds'] / features['item_views'] if features['item_views'] > 0 else 0.0
|
||||
|
||||
return features
|
||||
|
||||
|
||||
class ValidateDataStep(BaseContextStep):
|
||||
class FilterSessionInteractionsStep(BaseContextStep):
|
||||
"""
|
||||
Data quality checks before training.
|
||||
Input: df
|
||||
Output: df (unchanged, but logs validation report to context)
|
||||
Filter interactions DataFrame to specific session.
|
||||
|
||||
Input: (interactions_df, session_id)
|
||||
Output: interactions_df filtered to session_id
|
||||
"""
|
||||
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
|
||||
def transform(self, data: tuple) -> pd.DataFrame:
|
||||
interactions_df, session_id = data
|
||||
return interactions_df[interactions_df['sessionId'] == session_id].copy()
|
||||
|
||||
@@ -144,7 +144,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 162.47,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:57.967Z'
|
||||
}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 743.49,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:57.993Z'
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 163.87,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:58.009Z'
|
||||
}
|
||||
}
|
||||
@@ -183,7 +183,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 397.46,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:05:58.049Z'
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ def mock_price_logs_raw_kafka():
|
||||
'price': 401.66,
|
||||
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||
'storeMode': 'hotel',
|
||||
'storeMode': 'shop',
|
||||
'ts': '2025-11-25T21:06:08.864Z'
|
||||
}
|
||||
}
|
||||
@@ -222,7 +222,7 @@ def mock_experiments():
|
||||
'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_market_mode': ['hotel', 'shop'],
|
||||
'xp_task_id': [None, None]
|
||||
})
|
||||
|
||||
@@ -269,13 +269,3 @@ def empty_context(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
|
||||
|
||||
@@ -6,7 +6,6 @@ from procesing.steps import (
|
||||
)
|
||||
|
||||
def test_compute_demand(pipeline_context):
|
||||
random.seed(42) # deterministic test
|
||||
step = ComputeDemandStep(context=pipeline_context)
|
||||
|
||||
# Test with normal interaction data
|
||||
@@ -27,7 +26,6 @@ def test_compute_demand(pipeline_context):
|
||||
|
||||
|
||||
def test_compute_demand_skewed(pipeline_context):
|
||||
random.seed(42) # deterministic test
|
||||
step = ComputeDemandStep(context=pipeline_context)
|
||||
|
||||
# Test with normal interaction data
|
||||
|
||||
353
experiments/procesing/tests/test_elasticity.py
Normal file
353
experiments/procesing/tests/test_elasticity.py
Normal file
@@ -0,0 +1,353 @@
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from procesing.steps import (
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep
|
||||
)
|
||||
|
||||
|
||||
def test_aggregate_price_logs_basic(pipeline_context):
|
||||
"""Test basic price aggregation into time windows"""
|
||||
step = AggregatePriceLogsStep(pipeline_context)
|
||||
|
||||
# Create price logs with known window structure
|
||||
df = pd.DataFrame({
|
||||
'ts': pd.date_range(start='2023-01-01 10:00:00', periods=100, freq='10s'),
|
||||
'productId': np.tile([
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||
], 34)[:100],
|
||||
'price': np.random.uniform(100, 200, 100)
|
||||
})
|
||||
|
||||
result = step.transform(df)
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
# each chunk should have window metadata and price vector
|
||||
for chunk in result:
|
||||
assert 'window_start' in chunk
|
||||
assert 'window_end' in chunk
|
||||
assert 'price_vector' in chunk
|
||||
assert isinstance(chunk['price_vector'], pd.DataFrame)
|
||||
assert 'productId' in chunk['price_vector'].columns
|
||||
assert 'price' in chunk['price_vector'].columns
|
||||
|
||||
|
||||
def test_aggregate_price_logs_handles_gaps(pipeline_context):
|
||||
"""Test that price aggregation forward-fills missing windows"""
|
||||
step = AggregatePriceLogsStep(pipeline_context)
|
||||
|
||||
# create sparse data with gaps
|
||||
df = pd.DataFrame({
|
||||
'ts': pd.to_datetime([
|
||||
'2023-01-01 10:00:00',
|
||||
'2023-01-01 10:00:05',
|
||||
'2023-01-01 10:02:00', # gap of ~2 mins
|
||||
'2023-01-01 10:02:30'
|
||||
]),
|
||||
'productId': [
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11'
|
||||
],
|
||||
'price': [100, 102, 150, 153]
|
||||
})
|
||||
|
||||
result = step.transform(df)
|
||||
assert isinstance(result, list)
|
||||
# should have multiple windows despite gaps
|
||||
assert len(result) >= 2
|
||||
|
||||
|
||||
def test_compute_elasticity_with_known_relationship(pipeline_context):
|
||||
"""Test elasticity computation with known price-demand relationship"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
# simulate elastic demand: when price ↑10%, demand ↓15% (elasticity ~ -1.5)
|
||||
base_price = 100
|
||||
base_demand = 50
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand * 0.85] # 15% decrease
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [base_demand * 0.70] # further decrease
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price * 1.10] # 10% increase
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [base_price * 1.20] # 20% increase
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert not result.empty
|
||||
assert 'productId' in result.columns
|
||||
assert 'elasticity' in result.columns
|
||||
assert 'n_obs' in result.columns
|
||||
|
||||
# check elasticity is negative (normal good)
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['elasticity'] < 0
|
||||
# should be roughly elastic (< -1)
|
||||
assert product_elast.iloc[0]['n_obs'] == 3
|
||||
|
||||
|
||||
def test_compute_elasticity_inelastic_product(pipeline_context):
|
||||
"""Test with inelastic demand: price changes, demand barely moves"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
base_price = 150
|
||||
base_demand = 40
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'demand_score': [base_demand]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'demand_score': [base_demand * 0.98] # tiny 2% decrease
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'price': [base_price]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['51266ddb-5b07-47b7-89ee-5b5cae94bb11'],
|
||||
'price': [base_price * 1.20] # 20% increase
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
product_elast = result[result['productId'] == '51266ddb-5b07-47b7-89ee-5b5cae94bb11']
|
||||
assert len(product_elast) == 1
|
||||
# inelastic: elasticity between 0 and -1
|
||||
assert -1 < product_elast.iloc[0]['elasticity'] < 0
|
||||
|
||||
|
||||
def test_compute_elasticity_multiple_products(pipeline_context):
|
||||
"""Test elasticity computation across multiple products simultaneously"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
products = [
|
||||
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||
]
|
||||
|
||||
# create 5 time windows with all 3 products
|
||||
demand_chunks = []
|
||||
price_chunks = []
|
||||
|
||||
for i in range(5):
|
||||
ts = pd.Timestamp('2023-01-01 10:00:00') + pd.Timedelta(f'{i*30}s')
|
||||
|
||||
demand_chunks.append({
|
||||
'window_start': ts,
|
||||
'window_end': ts + pd.Timedelta('30s'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': products,
|
||||
'demand_score': [
|
||||
50 * (0.9 ** i), # elastic: decreases as price rises
|
||||
40 * (0.98 ** i), # inelastic: barely changes
|
||||
30 * (0.85 ** i) # very elastic
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
price_chunks.append({
|
||||
'window_start': ts,
|
||||
'window_end': ts + pd.Timedelta('30s'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': products,
|
||||
'price': [
|
||||
100 * (1.05 ** i),
|
||||
150 * (1.10 ** i),
|
||||
120 * (1.08 ** i)
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert len(result) == 3 # all products should have elasticity
|
||||
assert set(result['productId']) == set(products)
|
||||
assert all(result['n_obs'] == 5)
|
||||
assert all(result['elasticity'] < 0) # all normal goods
|
||||
|
||||
|
||||
def test_compute_elasticity_insufficient_data(pipeline_context):
|
||||
"""Test behavior with insufficient observations"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
# only 1 observation
|
||||
demand_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [50]
|
||||
})
|
||||
}]
|
||||
|
||||
price_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
}]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
# should still return result but with low n_obs
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['n_obs'] == 1
|
||||
assert product_elast.iloc[0]['elasticity'] == 0.0 # not enough data
|
||||
|
||||
|
||||
def test_compute_elasticity_misaligned_chunks(pipeline_context):
|
||||
"""Test with non-overlapping demand and price windows"""
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
demand_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [50]
|
||||
})
|
||||
}]
|
||||
|
||||
price_chunks = [{
|
||||
'window_start': pd.Timestamp('2023-01-01 11:00:00'), # different time
|
||||
'window_end': pd.Timestamp('2023-01-01 11:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
}]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
# should handle gracefully with no aligned data
|
||||
assert isinstance(result, pd.DataFrame)
|
||||
assert all(result['n_obs'] == 0)
|
||||
|
||||
|
||||
def test_elasticity_arc_method(pipeline_context):
|
||||
"""Test arc elasticity computation method"""
|
||||
# configure context for arc method
|
||||
pipeline_context.config['elasticity_method'] = 'arc'
|
||||
step = ComputeElasticityStep(pipeline_context)
|
||||
|
||||
demand_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [100]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'demand_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'demand_score': [80]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
price_chunks = [
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:00'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [100]
|
||||
})
|
||||
},
|
||||
{
|
||||
'window_start': pd.Timestamp('2023-01-01 10:00:30'),
|
||||
'window_end': pd.Timestamp('2023-01-01 10:01:00'),
|
||||
'price_vector': pd.DataFrame({
|
||||
'productId': ['d018efc1-25e9-4284-b276-80386e048b25'],
|
||||
'price': [110]
|
||||
})
|
||||
}
|
||||
]
|
||||
|
||||
result = step.transform((demand_chunks, price_chunks))
|
||||
product_elast = result[result['productId'] == 'd018efc1-25e9-4284-b276-80386e048b25']
|
||||
assert len(product_elast) == 1
|
||||
assert product_elast.iloc[0]['elasticity'] < 0
|
||||
# reset config
|
||||
pipeline_context.config['elasticity_method'] = 'point'
|
||||
@@ -1,165 +0,0 @@
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from procesing.steps.session import (
|
||||
TemporalFeatureStep,
|
||||
BehavioralFeatureStep,
|
||||
ProductFeatureStep,
|
||||
UserAgentFeatureStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
ValidateDataStep,
|
||||
)
|
||||
|
||||
|
||||
# TemporalFeatureStep tests
|
||||
def test_temporal_empty(pipeline_context):
|
||||
result = TemporalFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_temporal_basic(pipeline_context, session_interactions):
|
||||
result = TemporalFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'session_duration_sec' in result.columns
|
||||
assert 'interaction_velocity' in result.columns
|
||||
assert 'max_velocity_5min' in result.columns
|
||||
assert result['total_interactions'].sum() == len(session_interactions)
|
||||
|
||||
|
||||
def test_temporal_timeout(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's1'],
|
||||
'ts': ['2025-01-01T10:00:00Z', '2025-01-01T11:00:00Z'], # 1 hour gap
|
||||
})
|
||||
result = TemporalFeatureStep(pipeline_context, timeout_sec=900).transform(df)
|
||||
assert result.iloc[0]['session_duration_sec'] == 0 # gap exceeds timeout
|
||||
|
||||
|
||||
# BehavioralFeatureStep tests
|
||||
def test_behavioral_empty(pipeline_context):
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_behavioral_counts(pipeline_context, session_interactions):
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'page_views' in result.columns
|
||||
assert 'item_views' in result.columns
|
||||
assert 'hover_events' in result.columns
|
||||
assert result['total_events'].sum() == len(session_interactions)
|
||||
|
||||
|
||||
def test_behavioral_hover_prefix(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's1'],
|
||||
'eventName': ['hover_over_custom', 'hover_over_button'],
|
||||
'page': ['/products', '/products'],
|
||||
})
|
||||
result = BehavioralFeatureStep(pipeline_context).transform(df)
|
||||
assert result.iloc[0]['hover_events'] == 2
|
||||
|
||||
|
||||
# ProductFeatureStep tests
|
||||
def test_product_empty(pipeline_context):
|
||||
result = ProductFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_product_features(pipeline_context, session_interactions):
|
||||
result = ProductFeatureStep(pipeline_context).transform(session_interactions)
|
||||
assert 'unique_products_viewed' in result.columns
|
||||
assert 'price_range' in result.columns
|
||||
assert result['unique_products_viewed'].sum() > 0
|
||||
|
||||
|
||||
# UserAgentFeatureStep tests
|
||||
def test_ua_empty(pipeline_context):
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert 'sessionId' in result.columns
|
||||
|
||||
|
||||
def test_ua_headless_detection(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2'],
|
||||
'userAgent': ['Mozilla/5.0 Chrome/120', 'HeadlessChrome/120'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
assert 'is_headless' in result.columns
|
||||
headless = dict(zip(result['sessionId'], result['is_headless']))
|
||||
assert headless['s1'] == False
|
||||
assert headless['s2'] == True
|
||||
|
||||
|
||||
def test_ua_browser_family(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2', 's3'],
|
||||
'userAgent': ['Mozilla/5.0 Firefox/120', 'Safari/605.1.15', 'Unknown'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
browsers = dict(zip(result['sessionId'], result['browser_family']))
|
||||
assert browsers['s1'] == 'Firefox'
|
||||
assert browsers['s2'] == 'Safari'
|
||||
assert browsers['s3'] == 'Other'
|
||||
|
||||
|
||||
def test_ua_automation_detection(pipeline_context):
|
||||
df = pd.DataFrame({
|
||||
'sessionId': ['s1', 's2'],
|
||||
'userAgent': ['Selenium WebDriver', 'Normal Chrome/120'],
|
||||
})
|
||||
result = UserAgentFeatureStep(pipeline_context).transform(df)
|
||||
auto = dict(zip(result['sessionId'], result['is_automation']))
|
||||
assert auto['s1'] == True
|
||||
assert auto['s2'] == False
|
||||
|
||||
|
||||
# ExtractSessionFeaturesStep tests
|
||||
def test_extract_empty(pipeline_context):
|
||||
result = ExtractSessionFeaturesStep(pipeline_context).transform(pd.DataFrame())
|
||||
assert result.empty
|
||||
|
||||
|
||||
def test_extract_merges_all(pipeline_context, session_interactions):
|
||||
result = ExtractSessionFeaturesStep(pipeline_context).transform(session_interactions)
|
||||
expected = ['session_duration_sec', 'total_events', 'unique_products_viewed', 'is_headless']
|
||||
for col in expected:
|
||||
assert col in result.columns
|
||||
assert 'experimentId' in result.columns
|
||||
|
||||
|
||||
# JoinLabelsStep tests
|
||||
def test_join_labels_tuple_input(pipeline_context):
|
||||
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1'], 'total_events': [5]})
|
||||
experiments = pd.DataFrame({'id': ['exp1'], 'xp_human_only': [True]})
|
||||
result = JoinLabelsStep(pipeline_context).transform((features, experiments))
|
||||
assert 'is_agent' in result.columns
|
||||
assert result.iloc[0]['is_agent'] == False
|
||||
|
||||
|
||||
def test_join_labels_empty_experiments(pipeline_context):
|
||||
features = pd.DataFrame({'sessionId': ['s1'], 'experimentId': ['exp1']})
|
||||
result = JoinLabelsStep(pipeline_context).transform((features, pd.DataFrame()))
|
||||
assert pd.isna(result.iloc[0]['is_agent'])
|
||||
|
||||
|
||||
# ValidateDataStep tests
|
||||
def test_validate_empty(pipeline_context):
|
||||
ValidateDataStep(pipeline_context).transform(pd.DataFrame())
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'empty'
|
||||
|
||||
|
||||
def test_validate_missing_cols(pipeline_context):
|
||||
df = pd.DataFrame({'sessionId': ['s1'], 'ts': ['2025-01-01']})
|
||||
ValidateDataStep(pipeline_context).transform(df)
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'invalid'
|
||||
assert 'eventName' in report['missing_cols']
|
||||
|
||||
|
||||
def test_validate_valid(pipeline_context, session_interactions):
|
||||
ValidateDataStep(pipeline_context).transform(session_interactions)
|
||||
report = pipeline_context.get_cached('validation_report')
|
||||
assert report['status'] == 'valid'
|
||||
assert report['sessions'] > 0
|
||||
@@ -1,41 +0,0 @@
|
||||
"""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',
|
||||
]
|
||||
@@ -1,65 +0,0 @@
|
||||
"""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
125
lib/features.py
@@ -1,125 +0,0 @@
|
||||
"""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
|
||||
@@ -1,54 +0,0 @@
|
||||
from kafka import KafkaConsumer
|
||||
import json
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
def get_interactions(
|
||||
topic='user-interactions',
|
||||
bootstrap_servers=None,
|
||||
from_beginning=True,
|
||||
max_records=None,
|
||||
timeout_ms=5000
|
||||
):
|
||||
"""Consume interaction events from Kafka.
|
||||
|
||||
Args:
|
||||
topic: Kafka topic name
|
||||
bootstrap_servers: Kafka broker address (default from env)
|
||||
from_beginning: Start from earliest offset if True
|
||||
max_records: Max number of records to fetch (None = all available)
|
||||
timeout_ms: Consumer poll timeout
|
||||
|
||||
Returns:
|
||||
List of parsed interaction event dicts
|
||||
"""
|
||||
if not bootstrap_servers:
|
||||
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
port = os.getenv('KAFKA_PORT', '9092')
|
||||
bootstrap_servers = f'{host}:{port}'
|
||||
|
||||
consumer = KafkaConsumer(
|
||||
topic,
|
||||
bootstrap_servers=bootstrap_servers,
|
||||
auto_offset_reset='earliest' if from_beginning else 'latest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda m: json.loads(m.decode('utf-8')),
|
||||
consumer_timeout_ms=timeout_ms
|
||||
)
|
||||
|
||||
events = []
|
||||
try:
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if max_records and len(events) >= max_records:
|
||||
break
|
||||
finally:
|
||||
consumer.close()
|
||||
|
||||
return events
|
||||
|
||||
if __name__ == '__main__':
|
||||
interactions = get_interactions(max_records=10)
|
||||
for event in interactions:
|
||||
print(event)
|
||||
@@ -2,14 +2,11 @@ import redis
|
||||
import pickle
|
||||
import json
|
||||
import pandas as pd
|
||||
from io import StringIO
|
||||
from typing import Optional, Dict, Any
|
||||
import os
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""
|
||||
Lightweight model registry using Redis for storing pricing models and elasticity data.
|
||||
@@ -17,23 +14,23 @@ class ModelRegistry:
|
||||
"""
|
||||
|
||||
def __init__(self, redis_host: str = None, redis_port: int = None):
|
||||
host = redis_host or os.getenv("REDIS_HOST", "localhost")
|
||||
port = redis_port or int(os.getenv("REDIS_PORT", "6378"))
|
||||
host = redis_host or os.getenv('REDIS_HOST', 'localhost')
|
||||
port = redis_port or int(os.getenv('REDIS_PORT', '6378'))
|
||||
|
||||
self.redis_client = redis.Redis(
|
||||
host=host, port=port, db=0, decode_responses=False
|
||||
host=host,
|
||||
port=port,
|
||||
db=0,
|
||||
decode_responses=False
|
||||
)
|
||||
self.metadata_prefix = "model:meta:"
|
||||
self.data_prefix = "model:data:"
|
||||
self.elasticity_prefix = "elasticity:"
|
||||
self.prices_prefix = "prices:"
|
||||
|
||||
def publish_elasticity(
|
||||
self,
|
||||
def publish_elasticity(self,
|
||||
elasticity_df: pd.DataFrame,
|
||||
model_name: str = "latest",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Store elasticity estimates in registry.
|
||||
|
||||
@@ -45,29 +42,25 @@ class ModelRegistry:
|
||||
key = f"{self.elasticity_prefix}{model_name}"
|
||||
|
||||
# serialize dataframe as JSON
|
||||
data_json = elasticity_df.to_json(orient="records")
|
||||
data_json = elasticity_df.to_json(orient='records')
|
||||
|
||||
# store data
|
||||
self.redis_client.set(key, data_json)
|
||||
|
||||
# store metadata
|
||||
meta = metadata or {}
|
||||
meta.update(
|
||||
{
|
||||
"n_products": len(elasticity_df),
|
||||
"mean_elasticity": float(elasticity_df["elasticity"].mean()),
|
||||
"model_type": "elasticity_snapshot",
|
||||
}
|
||||
)
|
||||
meta.update({
|
||||
'n_products': len(elasticity_df),
|
||||
'mean_elasticity': float(elasticity_df['elasticity'].mean()),
|
||||
'model_type': 'elasticity_snapshot'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(
|
||||
f"Published elasticity model '{model_name}' with {len(elasticity_df)} products"
|
||||
)
|
||||
log.info(f"Published elasticity model '{model_name}' with {len(elasticity_df)} products")
|
||||
|
||||
def get_elasticity(self, model_name: str = "latest") -> Optional[pd.DataFrame]:
|
||||
def get_elasticity(self, model_name: str = 'latest') -> Optional[pd.DataFrame]:
|
||||
"""Retrieve elasticity estimates from registry."""
|
||||
key = f"{self.elasticity_prefix}{model_name}"
|
||||
data_json = self.redis_client.get(key)
|
||||
@@ -77,16 +70,14 @@ class ModelRegistry:
|
||||
|
||||
# decode bytes to string if needed
|
||||
if isinstance(data_json, bytes):
|
||||
data_json = data_json.decode("utf-8")
|
||||
data_json = data_json.decode('utf-8')
|
||||
|
||||
return pd.read_json(StringIO(data_json), orient="records")
|
||||
return pd.read_json(data_json, orient='records')
|
||||
|
||||
def publish_pricing_model(
|
||||
self,
|
||||
def publish_pricing_model(self,
|
||||
pricing_function,
|
||||
model_name: str = "latest",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
model_name: str = 'latest',
|
||||
metadata: Optional[Dict[str, Any]] = None):
|
||||
"""
|
||||
Store a fitted pricing function object.
|
||||
|
||||
@@ -103,19 +94,17 @@ class ModelRegistry:
|
||||
|
||||
# store metadata
|
||||
meta = metadata or {}
|
||||
meta.update(
|
||||
{
|
||||
"model_class": pricing_function.__class__.__name__,
|
||||
"model_type": "pricing_function",
|
||||
}
|
||||
)
|
||||
meta.update({
|
||||
'model_class': pricing_function.__class__.__name__,
|
||||
'model_type': 'pricing_function'
|
||||
})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published pricing model '{model_name}' ({meta['model_class']})")
|
||||
|
||||
def get_pricing_model(self, model_name: str = "latest"):
|
||||
def get_pricing_model(self, model_name: str = 'latest'):
|
||||
"""Retrieve a pricing function from registry."""
|
||||
key = f"{self.data_prefix}{model_name}"
|
||||
model_bytes = self.redis_client.get(key)
|
||||
@@ -130,56 +119,17 @@ class ModelRegistry:
|
||||
models = {}
|
||||
|
||||
for key in self.redis_client.scan_iter(f"{self.metadata_prefix}*"):
|
||||
key_str = key.decode("utf-8") if isinstance(key, bytes) else key
|
||||
model_name = key_str.replace(self.metadata_prefix, "")
|
||||
key_str = key.decode('utf-8') if isinstance(key, bytes) else key
|
||||
model_name = key_str.replace(self.metadata_prefix, '')
|
||||
meta_json = self.redis_client.get(key)
|
||||
|
||||
if meta_json:
|
||||
if isinstance(meta_json, bytes):
|
||||
meta_json = meta_json.decode("utf-8")
|
||||
meta_json = meta_json.decode('utf-8')
|
||||
models[model_name] = json.loads(meta_json)
|
||||
|
||||
return models
|
||||
|
||||
def publish_prices(
|
||||
self,
|
||||
prices_df: pd.DataFrame,
|
||||
model_name: str = "latest",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""Store predicted prices in registry.
|
||||
|
||||
Args:
|
||||
prices_df: df with [productId, predicted_price, ...]
|
||||
model_name: identifier for this price snapshot
|
||||
metadata: additional info
|
||||
"""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = prices_df.to_json(orient="records")
|
||||
|
||||
self.redis_client.set(key, data_json)
|
||||
|
||||
meta = metadata or {}
|
||||
meta.update({"n_products": len(prices_df), "model_type": "predicted_prices"})
|
||||
|
||||
meta_key = f"{self.metadata_prefix}prices_{model_name}"
|
||||
self.redis_client.set(meta_key, json.dumps(meta))
|
||||
|
||||
log.info(f"Published prices '{model_name}' for {len(prices_df)} products")
|
||||
|
||||
def get_prices(self, model_name: str = "latest") -> Optional[pd.DataFrame]:
|
||||
"""Retrieve predicted prices from registry."""
|
||||
key = f"{self.prices_prefix}{model_name}"
|
||||
data_json = self.redis_client.get(key)
|
||||
|
||||
if data_json is None:
|
||||
return None
|
||||
|
||||
if isinstance(data_json, bytes):
|
||||
data_json = data_json.decode("utf-8")
|
||||
|
||||
return pd.read_json(StringIO(data_json), orient="records")
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if Redis connection is alive."""
|
||||
try:
|
||||
@@ -187,55 +137,3 @@ class ModelRegistry:
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def set_session_prices(
|
||||
self, session_id: str, prices: Dict[str, float], ttl: int = 1800
|
||||
):
|
||||
"""
|
||||
Store prices for a specific session.
|
||||
THIS is the write path for session-aware pricing.
|
||||
|
||||
Args:
|
||||
session_id: session identifier
|
||||
prices: dict of {productId: price}
|
||||
ttl: time-to-live in seconds (default 30min)
|
||||
"""
|
||||
if not prices:
|
||||
return
|
||||
|
||||
key = f"session:{session_id}:prices"
|
||||
# use Redis hash for O(1) lookup per product
|
||||
self.redis_client.hset(key, mapping={k: str(v) for k, v in prices.items()})
|
||||
self.redis_client.expire(key, ttl)
|
||||
|
||||
def get_session_price(self, session_id: str, product_id: str) -> Optional[float]:
|
||||
"""
|
||||
Lookup price for (sessionId, productId).
|
||||
THIS is the read path for fast provider lookup.
|
||||
|
||||
Returns: price or None if not found
|
||||
"""
|
||||
key = f"session:{session_id}:prices"
|
||||
price_str = self.redis_client.hget(key, product_id)
|
||||
|
||||
if price_str is None:
|
||||
return None
|
||||
|
||||
return float(
|
||||
price_str.decode("utf-8") if isinstance(price_str, bytes) else price_str
|
||||
)
|
||||
|
||||
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
|
||||
"""Get all prices for a session."""
|
||||
key = f"session:{session_id}:prices"
|
||||
prices_raw = self.redis_client.hgetall(key)
|
||||
|
||||
if not prices_raw:
|
||||
return {}
|
||||
|
||||
return {
|
||||
(k.decode("utf-8") if isinstance(k, bytes) else k): float(
|
||||
v.decode("utf-8") if isinstance(v, bytes) else v
|
||||
)
|
||||
for k, v in prices_raw.items()
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Utilities for loading separability artifacts and scoring interaction sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, List, Sequence
|
||||
|
||||
import joblib
|
||||
import numpy as np
|
||||
|
||||
from experiments.ml.arch import featurize_trajectory
|
||||
|
||||
|
||||
DEFAULT_ARTIFACT_DIR = Path("data/separability")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeparabilityArtifacts:
|
||||
scaler: object
|
||||
classifier: object
|
||||
states: List[str]
|
||||
event_transitions: Dict[str, Dict[str, float]]
|
||||
feature_dim: int
|
||||
|
||||
|
||||
def _normalize_events(raw_events: Sequence[object]) -> List[object]:
|
||||
events: List[object] = []
|
||||
for evt in raw_events:
|
||||
if hasattr(evt, "value") and hasattr(evt.value, "payload"):
|
||||
events.append(evt.value.payload)
|
||||
else:
|
||||
events.append(evt)
|
||||
events.sort(key=lambda e: getattr(e, "ts", ""))
|
||||
return events
|
||||
|
||||
|
||||
def _event_transition_distribution(events: Sequence[object]) -> Dict[str, Dict[str, float]]:
|
||||
counts: Dict[str, Dict[str, int]] = {}
|
||||
for src_evt, dst_evt in zip(events, events[1:]):
|
||||
src_name = getattr(src_evt, "eventName", "unknown")
|
||||
dst_name = getattr(dst_evt, "eventName", "unknown")
|
||||
counts.setdefault(src_name, {})
|
||||
counts[src_name][dst_name] = counts[src_name].get(dst_name, 0) + 1
|
||||
|
||||
distribution: Dict[str, Dict[str, float]] = {}
|
||||
for src, dsts in counts.items():
|
||||
total = float(sum(dsts.values()))
|
||||
distribution[src] = {dst: val / total for dst, val in dsts.items()} if total else {}
|
||||
return distribution
|
||||
|
||||
|
||||
def _kl_divergence(p: Dict[str, Dict[str, float]], q: Dict[str, Dict[str, float]]) -> float:
|
||||
eps = 1e-10
|
||||
total = 0.0
|
||||
for src, dsts in p.items():
|
||||
for dst, prob in dsts.items():
|
||||
ref = q.get(src, {}).get(dst, 0.0)
|
||||
total += (prob + eps) * np.log((prob + eps) / (ref + eps))
|
||||
return float(total)
|
||||
|
||||
|
||||
def load_artifacts(artifact_dir: Path | str = DEFAULT_ARTIFACT_DIR) -> SeparabilityArtifacts:
|
||||
artifact_dir = Path(artifact_dir)
|
||||
scaler_path = artifact_dir / "scaler.joblib"
|
||||
model_path = artifact_dir / "classifier.joblib"
|
||||
metadata_path = artifact_dir / "metadata.json"
|
||||
|
||||
if not (scaler_path.exists() and model_path.exists() and metadata_path.exists()):
|
||||
raise FileNotFoundError(
|
||||
f"Separability artifacts not found in {artifact_dir}. Run sim.strong_learner.train first."
|
||||
)
|
||||
|
||||
scaler = joblib.load(scaler_path)
|
||||
classifier = joblib.load(model_path)
|
||||
with open(metadata_path, "r", encoding="utf-8") as fin:
|
||||
metadata = json.load(fin)
|
||||
|
||||
return SeparabilityArtifacts(
|
||||
scaler=scaler,
|
||||
classifier=classifier,
|
||||
states=list(metadata["reference_states"]),
|
||||
event_transitions=metadata["event_transitions"],
|
||||
feature_dim=int(metadata["feature_dim"]),
|
||||
)
|
||||
|
||||
|
||||
def score_session(
|
||||
raw_events: Sequence[object],
|
||||
artifacts: SeparabilityArtifacts,
|
||||
) -> dict:
|
||||
events = _normalize_events(raw_events)
|
||||
if not events:
|
||||
return {"prob_agent": 0.0, "delta_h": 0.0, "delta_a": 0.0}
|
||||
|
||||
reference_mdp = {"states": artifacts.states}
|
||||
features = featurize_trajectory(events, mdp=reference_mdp, input_dim=artifacts.feature_dim)
|
||||
scaled = artifacts.scaler.transform(features.reshape(1, -1))
|
||||
prob_agent = float(artifacts.classifier.predict_proba(scaled)[0, 1])
|
||||
|
||||
session_dist = _event_transition_distribution(events)
|
||||
delta_h = _kl_divergence(session_dist, artifacts.event_transitions.get("human", {}))
|
||||
delta_a = _kl_divergence(session_dist, artifacts.event_transitions.get("agent", {}))
|
||||
|
||||
return {
|
||||
"prob_agent": prob_agent,
|
||||
"delta_h": delta_h,
|
||||
"delta_a": delta_a,
|
||||
}
|
||||
|
||||
|
||||
def estimate_alpha(prob_agent: float, delta_h: float, delta_a: float, temperature: float = 1.0) -> float:
|
||||
divergence_mass = delta_h + delta_a
|
||||
if divergence_mass <= 1e-8:
|
||||
return float(prob_agent)
|
||||
|
||||
ratio = delta_a / divergence_mass
|
||||
blended = 0.5 * prob_agent + 0.5 * ratio
|
||||
if temperature <= 0:
|
||||
return float(np.clip(blended, 0.0, 1.0))
|
||||
|
||||
scaled = 1.0 / (1.0 + np.exp(-temperature * (blended - 0.5)))
|
||||
return float(np.clip(scaled, 0.0, 1.0))
|
||||
|
||||
|
||||
def score_sessions(raw_sessions: Iterable[Sequence[object]], artifacts: SeparabilityArtifacts) -> List[dict]:
|
||||
return [score_session(events, artifacts) for events in raw_sessions]
|
||||
72
lib/state.py
72
lib/state.py
@@ -1,72 +0,0 @@
|
||||
"""Unified state representation utilities for MDP state encoding
|
||||
Used by both experiments/ and sim/ components for consistent state handling
|
||||
"""
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
def make_state_repr(page: str = None, product_id: str = None, event_name: str = None) -> str:
|
||||
"""create canonical state representation string from components
|
||||
format: page|productId|eventName
|
||||
"""
|
||||
p = page or 'unk'
|
||||
pid = product_id or 'none'
|
||||
en = event_name or 'unknown'
|
||||
return f"{p}|{pid}|{en}"
|
||||
|
||||
|
||||
def event_to_state(evt: Any) -> str:
|
||||
"""convert event object/dict to state string
|
||||
supports both object attributes and dict keys
|
||||
"""
|
||||
if isinstance(evt, dict):
|
||||
return make_state_repr(
|
||||
page=evt.get('page'),
|
||||
product_id=evt.get('productId'),
|
||||
event_name=evt.get('eventName') or evt.get('event_type')
|
||||
)
|
||||
return make_state_repr(
|
||||
page=getattr(evt, 'page', None),
|
||||
product_id=getattr(evt, 'productId', None),
|
||||
event_name=getattr(evt, 'eventName', None) or getattr(evt, 'event_type', None)
|
||||
)
|
||||
|
||||
|
||||
def parse_state(state_str: str) -> dict:
|
||||
"""parse state string back to components
|
||||
returns: {'page': str, 'productId': str, 'eventName': str}
|
||||
"""
|
||||
parts = state_str.split('|')
|
||||
return {
|
||||
'page': parts[0] if len(parts) > 0 and parts[0] != 'unk' else None,
|
||||
'productId': parts[1] if len(parts) > 1 and parts[1] != 'none' else None,
|
||||
'eventName': parts[2] if len(parts) > 2 and parts[2] != 'unknown' else None
|
||||
}
|
||||
|
||||
|
||||
def get_event_name(evt: Any) -> str:
|
||||
"""extract event name from event object/dict"""
|
||||
if isinstance(evt, dict):
|
||||
return evt.get('eventName') or evt.get('event_type') or ''
|
||||
return getattr(evt, 'eventName', None) or getattr(evt, 'event_type', None) or ''
|
||||
|
||||
|
||||
def get_timestamp(evt: Any) -> Any:
|
||||
"""extract timestamp from event object/dict"""
|
||||
if isinstance(evt, dict):
|
||||
return evt.get('ts') or evt.get('timestamp')
|
||||
return getattr(evt, 'ts', None) or getattr(evt, 'timestamp', None)
|
||||
|
||||
|
||||
def create_state_fn() -> Callable:
|
||||
"""factory for state representation function"""
|
||||
return event_to_state
|
||||
|
||||
|
||||
def create_event_name_fn() -> Callable:
|
||||
"""factory for event name extraction function"""
|
||||
return get_event_name
|
||||
|
||||
|
||||
def create_timestamp_fn() -> Callable:
|
||||
"""factory for timestamp extraction function (returns raw value, use features.parse_timestamp to convert)"""
|
||||
return get_timestamp
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user