mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-15 17:43:36 +00:00
Compare commits
69 Commits
pipeline-e
...
claude/imp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e0f3d007c | ||
| 98a9a3738c | |||
| 1224841a82 | |||
| 4033e73ba1 | |||
| bae51daa1c | |||
| c5eae17924 | |||
| 28669ea4c3 | |||
| b0a1647956 | |||
| 19bb4fd517 | |||
| 4e2e41d943 | |||
| a033e77697 | |||
| 40e0b201e6 | |||
| a217d53556 | |||
| a6e6cc5d60 | |||
| fa89347c4e | |||
| 2b3d937be6 | |||
| 20c47fe85f | |||
| b7161573d7 | |||
| c15bb1882e | |||
| dee6f573e3 | |||
| 2ed200f870 | |||
| 56308ecb10 | |||
| 7fcd18c3cb | |||
| 5f607a58eb | |||
| 6aad196234 | |||
| e5060babfa | |||
| 80863e9b17 | |||
| a5029f2eab | |||
| c102ac482e | |||
| 08ade8dc89 | |||
| 95d4f0cee2 | |||
| 3072e5f46e | |||
| a1e3166322 | |||
| 6f361b96a8 | |||
| eea019ab3f | |||
| a36973cb42 | |||
| 96180e9af1 | |||
|
|
e60c0c64e1 | ||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d | |||
|
|
221e71a503 | ||
|
|
f2271e368e | ||
|
|
a1916c966c | ||
|
|
a2a443c027 | ||
|
|
ef98141ca8 | ||
| d45b344264 | |||
| a0b956b242 | |||
|
|
8751583764 | ||
| 59d4fb7891 | |||
| 7c2a819122 | |||
| 5941ffd085 | |||
| 955102090d | |||
| d654bbf4b4 | |||
|
|
ad9423bf59 | ||
|
|
2a0e44ab24 |
15
.gitignore
vendored
15
.gitignore
vendored
@@ -5,9 +5,20 @@
|
||||
**/.virtual_documents/
|
||||
**/session_*.svg
|
||||
**/*graph.svg
|
||||
paper/src/bib/auto
|
||||
**/auto/*.el
|
||||
*.old
|
||||
**/package-lock.json
|
||||
**/*.parquet
|
||||
**/_build/
|
||||
|
||||
# Airflow logs - exclude DAG run logs
|
||||
paper/src/bib/auto
|
||||
experiments/airflow/logs/*
|
||||
experiments/airflow/logs/scheduler/
|
||||
experiments/airflow/logs/dag_processor_manager/
|
||||
experiments/collected_data/
|
||||
experiments/agents/collected_data/
|
||||
sim/rl/behavior_loader/*.dot
|
||||
sim/rl/behavior_loader/*.png
|
||||
sim/rl/behavior_loader/*.svg
|
||||
sim/rl/behavior_loader/*.pdf
|
||||
tests/e2e/node_modules/**
|
||||
54
Makefile
54
Makefile
@@ -11,46 +11,74 @@ PYTEST := $(VENV)/bin/pytest
|
||||
|
||||
.DEFAULT_GOAL := help
|
||||
|
||||
all: pdf
|
||||
|
||||
run.webapp:
|
||||
@cd web && npm install && npm run dev
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
|
||||
|
||||
$(BUILDDIR):
|
||||
mkdir -p paper/$(BUILDDIR)
|
||||
|
||||
pdf: $(BUILDDIR)
|
||||
@echo "Concatenating source code..."
|
||||
.PHONY: pdf.build
|
||||
pdf.build: $(BUILDDIR)
|
||||
@bash paper/concat_code.sh
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
watch: $(BUILDDIR)
|
||||
.PHONY: pdf.watch
|
||||
pdf.watch: $(BUILDDIR)
|
||||
@cd $(SRCDIR) && \
|
||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
||||
-interaction=nonstopmode -file-line-error \
|
||||
-r ../.latexmkrc \
|
||||
-outdir=../$(BUILDDIR) $(TEX)
|
||||
|
||||
clean:
|
||||
.PHONY: pdf.clean
|
||||
pdf.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
|
||||
|
||||
test: $(VENV)
|
||||
$(PYTEST) -v
|
||||
|
||||
count-lines:
|
||||
.PHONY: stats.lines
|
||||
stats.lines:
|
||||
@find . \( -path '*/node_modules' -o -path '*/.venv' -o -path '*/venv' \) -prune -o \
|
||||
\( -name "*.ts" -o -name "*.py" \) -type f -print0 | xargs -0 cat | wc -l
|
||||
|
||||
.PHONY: all pdf clean watch run.webapp install test
|
||||
.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
|
||||
|
||||
11
README.md
11
README.md
@@ -1,5 +1,12 @@
|
||||
<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://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/
|
||||
|
||||
|
||||
@@ -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,122 +47,52 @@ def health() -> dict:
|
||||
|
||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||
"""
|
||||
THIS is the fast lookup service (mechanism).
|
||||
Priority: session-keyed price > global optimal price > base price
|
||||
"""
|
||||
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||
|
||||
metadata = product['metadata']
|
||||
base_price = metadata.get('base_price', 100.0)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
pricing_model = registry.get_pricing_model('latest')
|
||||
elasticity_df = registry.get_elasticity('latest')
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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()
|
||||
# PRIORITY 1: session-aware price (computed by Airflow worker)
|
||||
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
|
||||
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'
|
||||
)
|
||||
|
||||
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)
|
||||
# PRIORITY 2: global pre-computed prices (surge pricing)
|
||||
prices_df = registry.get_prices('latest')
|
||||
if prices_df is not None:
|
||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||
if not product_price_row.empty:
|
||||
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=None,
|
||||
model_version='surge'
|
||||
)
|
||||
|
||||
# PRIORITY 3: fallback to base price
|
||||
return PriceResponse(
|
||||
productId=productId,
|
||||
price=optimal_price,
|
||||
price=base_price,
|
||||
base_price=base_price,
|
||||
markup=optimal_price/base_price,
|
||||
elasticity=product_elasticity
|
||||
markup=1.0,
|
||||
elasticity=None,
|
||||
model_version='base'
|
||||
)
|
||||
|
||||
@app.get("/models")
|
||||
|
||||
@@ -12,4 +12,5 @@ graphviz
|
||||
python-dotenv>=1.0.0
|
||||
requests>=2.31.0
|
||||
typing-extensions>=4.8.0
|
||||
pickle5>=0.0.11; python_version < '3.8'
|
||||
pypickle
|
||||
pymc
|
||||
|
||||
@@ -198,12 +198,16 @@ def dump_logs(
|
||||
auto_offset_reset='earliest',
|
||||
enable_auto_commit=False,
|
||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||
consumer_timeout_ms=5000
|
||||
consumer_timeout_ms=30000,
|
||||
fetch_max_wait_ms=10000,
|
||||
max_poll_records=1000
|
||||
)
|
||||
|
||||
events = []
|
||||
for msg in consumer:
|
||||
events.append(msg.value)
|
||||
if last_n and len(events) >= last_n * 2:
|
||||
break
|
||||
|
||||
consumer.close()
|
||||
|
||||
@@ -290,6 +294,7 @@ 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,4 +1,24 @@
|
||||
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:
|
||||
@@ -92,23 +112,20 @@ services:
|
||||
depends_on:
|
||||
- postgres
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- _AIRFLOW_DB_MIGRATE=true
|
||||
- _AIRFLOW_WWW_USER_CREATE=true
|
||||
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||
- _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"
|
||||
|
||||
@@ -122,13 +139,20 @@ services:
|
||||
- airflow-init
|
||||
- redis
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
@@ -138,12 +162,6 @@ 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:
|
||||
@@ -164,12 +182,20 @@ services:
|
||||
redis:
|
||||
condition: service_started
|
||||
environment:
|
||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
- AIRFLOW__CORE__PARALLELISM=16
|
||||
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
|
||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||
- KAFKA_HOST=kafka
|
||||
- KAFKA_PORT=29092
|
||||
- BACKEND_URL=http://backend:5000
|
||||
@@ -177,12 +203,6 @@ 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:
|
||||
@@ -208,13 +228,9 @@ 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,3 +21,10 @@ 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
|
||||
|
||||
41
docker/Airflow.railway.dockerfile
Normal file
41
docker/Airflow.railway.dockerfile
Normal file
@@ -0,0 +1,41 @@
|
||||
FROM apache/airflow:2.7.3-python3.11
|
||||
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
supervisor \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER airflow
|
||||
|
||||
COPY requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
psycopg2-binary \
|
||||
apache-airflow-providers-postgres
|
||||
|
||||
ENV AIRFLOW_HOME=/opt/airflow
|
||||
ENV AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
||||
ENV AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||
ENV AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||
ENV AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||
|
||||
# copy all code into image (standalone - no volume mounts needed)
|
||||
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||
|
||||
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||
|
||||
# copy entrypoint script
|
||||
COPY --chown=airflow:root docker/airflow-railway-entrypoint.sh /entrypoint.sh
|
||||
USER root
|
||||
RUN chmod +x /entrypoint.sh
|
||||
USER airflow
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -14,11 +14,13 @@ RUN apt-get update && apt-get install -y \
|
||||
COPY backend/provider/requirements.txt /app/
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Structure will be mounted via volumes:
|
||||
# /app/lib -> lib/
|
||||
# /app/procesing -> experiments/procesing/
|
||||
# /app/provider -> backend/provider/
|
||||
# Copy application code into image
|
||||
COPY lib/ /app/lib/
|
||||
COPY experiments/procesing/ /app/procesing/
|
||||
COPY backend/provider/ /app/provider/
|
||||
|
||||
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "provider.app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
WORKDIR /app/provider
|
||||
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||
|
||||
20
docker/airflow-railway-entrypoint.sh
Normal file
20
docker/airflow-railway-entrypoint.sh
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# init db and create admin user on first run
|
||||
airflow db migrate
|
||||
|
||||
# create admin user if not exists
|
||||
airflow users create \
|
||||
--username "${AIRFLOW_ADMIN_USER:-admin}" \
|
||||
--password "${AIRFLOW_ADMIN_PASSWORD:-admin}" \
|
||||
--firstname Admin \
|
||||
--lastname User \
|
||||
--role Admin \
|
||||
--email admin@example.com || true
|
||||
|
||||
# start scheduler in background
|
||||
airflow scheduler &
|
||||
|
||||
# start webserver in foreground (Railway needs one foreground process)
|
||||
exec airflow webserver --port ${PORT:-8080}
|
||||
117
experiments/agents/run.py
Normal file
117
experiments/agents/run.py
Normal file
@@ -0,0 +1,117 @@
|
||||
from supabase import create_client, Client
|
||||
import os
|
||||
import random
|
||||
import asyncio
|
||||
import json
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from experiments.agents.agent import get_agent, AgentTypes
|
||||
from lib.kafka_client import get_interactions
|
||||
|
||||
load_dotenv()
|
||||
|
||||
RESULTS="/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
|
||||
client = create_client(
|
||||
os.getenv("NEXT_PUBLIC_SUPABASE_URL"),
|
||||
os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||
)
|
||||
def pick_random_task():
|
||||
mode = 'hotel'
|
||||
tasks = client.table("tasks").select("*").execute().data
|
||||
if mode == 'hotel':
|
||||
# drop all that have 'flight' in the description
|
||||
tasks = [task for task in tasks if 'flight' not in task['task_description'].lower()]
|
||||
return random.choice(tasks) if tasks else None
|
||||
|
||||
def clear_kafka_data():
|
||||
"""Delete and recreate Kafka topics to clear all data"""
|
||||
from kafka.admin import KafkaAdminClient, NewTopic
|
||||
from kafka.errors import UnknownTopicOrPartitionError
|
||||
import time
|
||||
|
||||
kafka_host = os.getenv('KAFKA_HOST', 'localhost')
|
||||
kafka_port = os.getenv('KAFKA_PORT', '9092')
|
||||
broker = f'{kafka_host}:{kafka_port}'
|
||||
|
||||
admin = KafkaAdminClient(bootstrap_servers=broker)
|
||||
topics = ['user-interactions', 'price-logs']
|
||||
|
||||
try:
|
||||
admin.delete_topics(topics, timeout_ms=5000)
|
||||
print(f"Deleted topics: {topics}")
|
||||
time.sleep(2)
|
||||
except UnknownTopicOrPartitionError:
|
||||
print("Topics don't exist, skipping delete")
|
||||
except Exception as e:
|
||||
print(f"Error deleting topics: {e}")
|
||||
|
||||
new_topics = [
|
||||
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||
]
|
||||
|
||||
try:
|
||||
admin.create_topics(new_topics=new_topics, validate_only=False)
|
||||
print(f"Recreated topics: {topics}")
|
||||
except Exception as e:
|
||||
print(f"Error creating topics: {e}")
|
||||
finally:
|
||||
admin.close()
|
||||
|
||||
def create_new_experiment(task_id):
|
||||
import uuid
|
||||
subject_name = f"agent_{str(uuid.uuid4())[:8]}"
|
||||
experiment = {
|
||||
"subject_name": subject_name,
|
||||
"xp_human_only": False,
|
||||
"xp_market_mode": "hotel",
|
||||
"xp_task_id": task_id,
|
||||
}
|
||||
response = client.table("experiments").insert(experiment).execute()
|
||||
return response.data[0] if response.data else None
|
||||
|
||||
if __name__ == "__main__":
|
||||
clear_kafka_data()
|
||||
|
||||
task = pick_random_task()
|
||||
if not task:
|
||||
print("No tasks available")
|
||||
exit(1)
|
||||
|
||||
experiment = create_new_experiment(task['id'])
|
||||
exp_id = experiment['id']
|
||||
exp_dir = f"{RESULTS}{exp_id}"
|
||||
os.makedirs(exp_dir, exist_ok=True)
|
||||
|
||||
# construct experiment URL with uuid param
|
||||
base_url = os.getenv('NEXT_PUBLIC_API_BASE', 'http://localhost:3000')
|
||||
agent_url = f"{base_url}/start-task?uuid={exp_id}"
|
||||
|
||||
print(f"Created experiment {exp_id} for task {task['id']}")
|
||||
print(f"Agent will interact with: {agent_url}")
|
||||
|
||||
# instantiate and run agent
|
||||
agent = get_agent(
|
||||
AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||
goal=task['task_description'],
|
||||
url=agent_url,
|
||||
timeout=300,
|
||||
headless=True
|
||||
)
|
||||
|
||||
result = asyncio.run(agent.act())
|
||||
print(f"Agent result: {result}")
|
||||
|
||||
# export interaction and price data from kafka
|
||||
interactions = get_interactions(topic='user-interactions', timeout_ms=3000)
|
||||
prices = get_interactions(topic='price-logs', timeout_ms=3000)
|
||||
|
||||
with open(f"{exp_dir}/int.json", 'w') as f:
|
||||
json.dump(interactions, f, indent=2)
|
||||
|
||||
with open(f"{exp_dir}/price.json", 'w') as f:
|
||||
json.dump(prices, f, indent=2)
|
||||
|
||||
print(f"Experiment {exp_id} completed.")
|
||||
print(f"Exported {len(interactions)} interactions and {len(prices)} price logs to {exp_dir}")
|
||||
@@ -1,346 +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,
|
||||
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
|
||||
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
115
experiments/airflow/dags/ml_training_pipeline.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from airflow import DAG, Dataset
|
||||
from airflow.decorators import task
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
ValidateDataStep,
|
||||
ExtractSessionFeaturesStep,
|
||||
JoinLabelsStep,
|
||||
)
|
||||
|
||||
TRAINING_DATASET = Dataset('phantom://ml/training-data')
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
|
||||
def _get_context(store_mode: str = 'hotel') -> PipelineContext:
|
||||
return PipelineContext(provider=CompositeProvider(), store_mode=store_mode)
|
||||
|
||||
|
||||
with DAG(
|
||||
'ml_training_pipeline',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description='ML training data pipeline: fetch -> validate -> extract features -> label -> publish',
|
||||
schedule=None,
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['ml', 'training', 'features', 'research'],
|
||||
) as dag:
|
||||
|
||||
@task
|
||||
def fetch_interactions(**kwargs) -> bytes:
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
df = FetchInteractionsStep(ctx).transform(None)
|
||||
logging.info(f"Fetched {len(df)} interactions, {df['sessionId'].nunique()} sessions")
|
||||
return pickle.dumps(df)
|
||||
|
||||
@task
|
||||
def validate_data(raw_data: bytes, **kwargs) -> bytes:
|
||||
df = pickle.loads(raw_data)
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
validated = ValidateDataStep(ctx).transform(df)
|
||||
report = ctx.get_cached('validation_report') or {}
|
||||
logging.info(f"Validation: {report.get('status')}, {report.get('sessions', 0)} sessions")
|
||||
return pickle.dumps(validated)
|
||||
|
||||
@task
|
||||
def extract_session_features(validated_data: bytes, **kwargs) -> bytes:
|
||||
df = pickle.loads(validated_data)
|
||||
if df.empty:
|
||||
logging.warning("Empty input, skipping feature extraction")
|
||||
return pickle.dumps(pd.DataFrame())
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
features = ExtractSessionFeaturesStep(ctx).transform(df)
|
||||
logging.info(f"Extracted {len(features.columns)} features for {len(features)} sessions")
|
||||
return pickle.dumps(features)
|
||||
|
||||
@task
|
||||
def join_labels(features_data: bytes, **kwargs) -> bytes:
|
||||
features_df = pickle.loads(features_data)
|
||||
if features_df.empty:
|
||||
logging.warning("Empty features, skipping label join")
|
||||
return pickle.dumps(pd.DataFrame())
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
ctx = _get_context(dag_conf.get('store_mode', 'hotel'))
|
||||
labeled = JoinLabelsStep(ctx).transform(features_df)
|
||||
n_agents = labeled['is_agent'].sum() if 'is_agent' in labeled.columns else 0
|
||||
logging.info(f"Labeled {len(labeled)} sessions: {n_agents} agents")
|
||||
return pickle.dumps(labeled)
|
||||
|
||||
@task(outlets=[TRAINING_DATASET])
|
||||
def publish_training_data(labeled_data: bytes, **kwargs) -> dict:
|
||||
labeled_df = pickle.loads(labeled_data)
|
||||
if labeled_df.empty:
|
||||
return {'status': 'skipped', 'reason': 'empty_data'}
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return {
|
||||
'status': 'success',
|
||||
'n_sessions': len(labeled_df),
|
||||
'n_features': len([c for c in labeled_df.columns if c not in ['sessionId', 'experimentId', 'is_agent']]),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
}
|
||||
|
||||
raw = fetch_interactions()
|
||||
validated = validate_data(raw)
|
||||
features = extract_session_features(validated)
|
||||
labeled = join_labels(features)
|
||||
publish_training_data(labeled)
|
||||
220
experiments/airflow/dags/surge_pricing_factory.py
Normal file
220
experiments/airflow/dags/surge_pricing_factory.py
Normal file
@@ -0,0 +1,220 @@
|
||||
from pandas.core.algorithms import factorize_array
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
DEFAULT_ARGS = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider):
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
|
||||
def _get_provider():
|
||||
return CompositeProvider()
|
||||
|
||||
def _make_task_callables(store_mode: str):
|
||||
"""Generate task callables bound to a specific store_mode."""
|
||||
|
||||
def get_context(**kwargs):
|
||||
return PipelineContext(provider=_get_provider(), store_mode=store_mode)
|
||||
|
||||
def fetch_interactions(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchInteractionsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
ctx = get_context(**kwargs)
|
||||
df = FetchPriceLogsStep(ctx).transform(None)
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"[{store_mode}] Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
demand_df = ComputeDemandStep(ctx).transform(df)
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"[{store_mode}] Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
ctx = get_context(**kwargs)
|
||||
price_df = AggregatePriceLogsStep(ctx).transform(df)
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"[{store_mode}] Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
ctx = get_context(**kwargs)
|
||||
joined_df = JoinProductFeaturesStep(ctx).transform((demand_df, price_df))
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"[{store_mode}] Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=dag_conf.get('high_threshold', 10),
|
||||
low_threshold=dag_conf.get('low_threshold', 2),
|
||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price', 'demand': 'demand_score'
|
||||
})
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"[{store_mode}] Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': store_mode,
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
registry.publish_prices(prices_df, model_name=f'{store_mode}_latest', metadata=metadata)
|
||||
logging.info(f"[{store_mode}] Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'store_mode': store_mode,
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
return {
|
||||
'fetch_interactions': fetch_interactions,
|
||||
'fetch_price_logs': fetch_price_logs,
|
||||
'compute_demand': compute_demand,
|
||||
'aggregate_price_logs': aggregate_price_logs,
|
||||
'join_product_features': join_product_features,
|
||||
'apply_surge_pricing': apply_surge_pricing,
|
||||
'publish_results': publish_results,
|
||||
}
|
||||
|
||||
|
||||
def create_surge_pricing_dag(store_mode: str) -> DAG:
|
||||
"""Factory: generates a surge pricing DAG for a given store_mode."""
|
||||
callables = _make_task_callables(store_mode)
|
||||
|
||||
dag = DAG(
|
||||
f'surge_pricing_{store_mode}',
|
||||
default_args=DEFAULT_ARGS,
|
||||
description=f'Surge pricing pipeline for {store_mode} store mode',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', store_mode],
|
||||
)
|
||||
|
||||
with dag:
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=callables['fetch_interactions'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=callables['fetch_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=callables['compute_demand'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=callables['aggregate_price_logs'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=callables['join_product_features'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=callables['apply_surge_pricing'],
|
||||
provide_context=True,
|
||||
)
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=callables['publish_results'],
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
|
||||
return dag
|
||||
|
||||
|
||||
# instantiate DAGs for Airflow to discover
|
||||
dag_airline = create_surge_pricing_dag('airline')
|
||||
dag_hotel = create_surge_pricing_dag('hotel')
|
||||
|
||||
# TODO: Refactor this factory from a surge pricing factory to a general pricing factory
|
||||
# We will do this by passing a pricing strategy class to the factory, since the generic pipeline is:
|
||||
# take all interaction data, group by sessionId and assign a new price vector to each session
|
||||
# in the grouping we get a subset of the interactions per sessionId and we can map that to some Features
|
||||
# we define a custom _get_features(interactions .) methodin the strategy class
|
||||
# we then run only the inference which is the .predict(trajectory) per-session which will give us a new price vector
|
||||
# this we then publish for each sessionId group
|
||||
# this might include no deleting most of the pricers we have defined and starting with a super simple surge-pricing algorithm that is no-fit only predict. This we can then test end-to-end and observe changes to prices according to a desired strategy - we have to define this one as a very short term strategy because we run sessions that take only a few minutes.
|
||||
253
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
253
experiments/airflow/dags/surge_pricing_pipeline.py
Normal file
@@ -0,0 +1,253 @@
|
||||
from airflow import DAG
|
||||
from airflow.operators.python import PythonOperator
|
||||
from airflow.utils.dates import days_ago
|
||||
from datetime import timedelta
|
||||
import pandas as pd
|
||||
import logging
|
||||
import sys
|
||||
import pickle
|
||||
import io
|
||||
|
||||
# add parent dir to path so procesing package can be imported
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
|
||||
from procesing.context import PipelineContext
|
||||
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
ComputeDemandStep,
|
||||
AggregatePriceLogsStep,
|
||||
JoinProductFeaturesStep,
|
||||
)
|
||||
from procesing.pricers.simple import SimpleSurgePricer
|
||||
|
||||
default_args = {
|
||||
'owner': 'phantom-research',
|
||||
'depends_on_past': False,
|
||||
'email_on_failure': False,
|
||||
'email_on_retry': False,
|
||||
'retries': 2,
|
||||
'retry_delay': timedelta(minutes=5),
|
||||
}
|
||||
|
||||
def get_provider():
|
||||
"""Factory to create composite provider"""
|
||||
class CompositeProvider(SupabaseProvider, BackendAPIProvider): # TODO: Fix this into one global provider singelton instead of multiple inheritance declarations acoss the codebase
|
||||
def __init__(self):
|
||||
SupabaseProvider.__init__(self)
|
||||
BackendAPIProvider.__init__(self)
|
||||
return CompositeProvider()
|
||||
|
||||
def get_context(**kwargs):
|
||||
"""Build pipeline context from Airflow config"""
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
return PipelineContext(
|
||||
provider=get_provider(),
|
||||
store_mode=dag_conf.get('store_mode', 'hotel'),
|
||||
)
|
||||
|
||||
# atomic task functions (each wraps one sklearn step)
|
||||
def fetch_interactions(**kwargs):
|
||||
"""Task: Fetch interaction data from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchInteractionsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='interactions_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} interaction records")
|
||||
return len(df)
|
||||
|
||||
def fetch_price_logs(**kwargs):
|
||||
"""Task: Fetch price logs from Kafka"""
|
||||
context = get_context(**kwargs)
|
||||
step = FetchPriceLogsStep(context)
|
||||
df = step.transform(None)
|
||||
|
||||
kwargs['ti'].xcom_push(key='price_logs_raw', value=pickle.dumps(df))
|
||||
logging.info(f"Fetched {len(df)} price records")
|
||||
return len(df)
|
||||
|
||||
def compute_demand(**kwargs):
|
||||
"""Task: Compute demand scores from interactions"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='interactions_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = ComputeDemandStep(context)
|
||||
demand_df = step.transform(df)
|
||||
# TODO: clear the xcom
|
||||
|
||||
|
||||
ti.xcom_push(key='demand_data', value=pickle.dumps(demand_df))
|
||||
logging.info(f"Computed demand for {len(demand_df)} products")
|
||||
return len(demand_df)
|
||||
|
||||
def aggregate_price_logs(**kwargs):
|
||||
"""Task: Aggregate price logs"""
|
||||
ti = kwargs['ti']
|
||||
df = pickle.loads(ti.xcom_pull(key='price_logs_raw'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = AggregatePriceLogsStep(context)
|
||||
price_df = step.transform(df)
|
||||
|
||||
ti.xcom_push(key='price_data', value=pickle.dumps(price_df))
|
||||
logging.info(f"Aggregated price logs for {len(price_df)} products")
|
||||
return len(price_df)
|
||||
|
||||
def join_product_features(**kwargs):
|
||||
"""Task: Join demand and price data"""
|
||||
ti = kwargs['ti']
|
||||
demand_df = pickle.loads(ti.xcom_pull(key='demand_data'))
|
||||
price_df = pickle.loads(ti.xcom_pull(key='price_data'))
|
||||
|
||||
context = get_context(**kwargs)
|
||||
step = JoinProductFeaturesStep(context)
|
||||
joined_df = step.transform((demand_df, price_df))
|
||||
|
||||
ti.xcom_push(key='product_features', value=pickle.dumps(joined_df))
|
||||
logging.info(f"Joined features for {len(joined_df)} products")
|
||||
return len(joined_df)
|
||||
|
||||
def apply_surge_pricing(**kwargs):
|
||||
"""Task: Apply surge pricing rules to generate optimal prices"""
|
||||
ti = kwargs['ti']
|
||||
product_features = pickle.loads(ti.xcom_pull(key='product_features'))
|
||||
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
# rename demand_score to demand for pricer compatibility
|
||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
||||
|
||||
high_thresh = dag_conf.get('high_threshold', 10)
|
||||
low_thresh = dag_conf.get('low_threshold', 2)
|
||||
surge_mult = dag_conf.get('surge_multiplier', 1.2)
|
||||
discount_mult = dag_conf.get('discount_multiplier', 0.9)
|
||||
|
||||
logging.info(f"Surge pricing config: high_thresh={high_thresh}, low_thresh={low_thresh}, surge_mult={surge_mult}, discount_mult={discount_mult}")
|
||||
logging.info(f"Demand stats: min={data['demand'].min():.2f}, max={data['demand'].max():.2f}, mean={data['demand'].mean():.2f}")
|
||||
logging.info(f"Products with high demand (>={high_thresh}): {(data['demand'] >= high_thresh).sum()}")
|
||||
logging.info(f"Products with low demand (<={low_thresh}): {(data['demand'] <= low_thresh).sum()}")
|
||||
|
||||
surge_pricer = SimpleSurgePricer(
|
||||
high_threshold=high_thresh,
|
||||
low_threshold=low_thresh,
|
||||
surge_multiplier=surge_mult,
|
||||
discount_multiplier=discount_mult
|
||||
)
|
||||
surge_pricer.fit(data)
|
||||
data['optimal_price'] = surge_pricer.predict()
|
||||
|
||||
base_avg = data['base_price'].mean()
|
||||
optimal_avg = data['optimal_price'].mean()
|
||||
price_change_pct = ((optimal_avg - base_avg) / base_avg) * 100
|
||||
|
||||
logging.info(f"Price adjustment: base_avg={base_avg:.2f}, optimal_avg={optimal_avg:.2f}, change={price_change_pct:+.1f}%")
|
||||
|
||||
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||
'price': 'current_price',
|
||||
'demand': 'demand_score'
|
||||
})
|
||||
|
||||
ti.xcom_push(key='predicted_prices', value=pickle.dumps(prices_df))
|
||||
logging.info(f"Applied surge pricing for {len(prices_df)} products")
|
||||
return len(prices_df)
|
||||
|
||||
def publish_results(**kwargs):
|
||||
"""Task: Publish surge pricing results to registry"""
|
||||
ti = kwargs['ti']
|
||||
prices_df = pickle.loads(ti.xcom_pull(key='predicted_prices'))
|
||||
|
||||
sys.path.insert(0, '/opt/airflow')
|
||||
from lib.model_registry import ModelRegistry
|
||||
|
||||
registry = ModelRegistry()
|
||||
dag_conf = kwargs.get('dag_run').conf if kwargs.get('dag_run') else {}
|
||||
|
||||
metadata = {
|
||||
'timestamp': pd.Timestamp.now().isoformat(),
|
||||
'store_mode': dag_conf.get('store_mode', 'hotel'),
|
||||
'dag_run_id': kwargs['dag_run'].run_id if kwargs.get('dag_run') else 'manual',
|
||||
'pricing_method': 'surge',
|
||||
'high_threshold': dag_conf.get('high_threshold', 10),
|
||||
'low_threshold': dag_conf.get('low_threshold', 2),
|
||||
'surge_multiplier': dag_conf.get('surge_multiplier', 1.2),
|
||||
'discount_multiplier': dag_conf.get('discount_multiplier', 0.9)
|
||||
}
|
||||
|
||||
registry.publish_prices(prices_df, model_name='latest', metadata=metadata)
|
||||
|
||||
logging.info(f"Published surge pricing for {len(prices_df)} products")
|
||||
|
||||
return {
|
||||
'n_products': len(prices_df),
|
||||
'registry_status': 'success',
|
||||
'mean_demand': float(prices_df['demand_score'].mean()) if 'demand_score' in prices_df.columns else None
|
||||
}
|
||||
|
||||
|
||||
# DAG definition
|
||||
with DAG(
|
||||
'surge_pricing_pipeline',
|
||||
default_args=default_args,
|
||||
description='Simple surge pricing pipeline: demand aggregation + rule-based pricing',
|
||||
schedule_interval='*/15 * * * *',
|
||||
start_date=days_ago(1),
|
||||
catchup=False,
|
||||
max_active_runs=1,
|
||||
tags=['pricing', 'surge', 'research', 'simplified'],
|
||||
) as dag:
|
||||
|
||||
# parallel data fetching
|
||||
t_fetch_interactions = PythonOperator(
|
||||
task_id='fetch_interactions',
|
||||
python_callable=fetch_interactions,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
t_fetch_price_logs = PythonOperator(
|
||||
task_id='fetch_price_logs',
|
||||
python_callable=fetch_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# compute demand from interactions
|
||||
t_compute_demand = PythonOperator(
|
||||
task_id='compute_demand',
|
||||
python_callable=compute_demand,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# aggregate price logs
|
||||
t_aggregate_prices = PythonOperator(
|
||||
task_id='aggregate_price_logs',
|
||||
python_callable=aggregate_price_logs,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# join demand and prices
|
||||
t_join_features = PythonOperator(
|
||||
task_id='join_product_features',
|
||||
python_callable=join_product_features,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# apply surge pricing
|
||||
t_surge_pricing = PythonOperator(
|
||||
task_id='apply_surge_pricing',
|
||||
python_callable=apply_surge_pricing,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# publish to registry
|
||||
t_publish = PythonOperator(
|
||||
task_id='publish_results',
|
||||
python_callable=publish_results,
|
||||
provide_context=True,
|
||||
)
|
||||
|
||||
# dependency graph: parallel fetch -> process -> join -> surge -> publish
|
||||
t_fetch_interactions >> t_compute_demand
|
||||
t_fetch_price_logs >> t_aggregate_prices
|
||||
[t_compute_demand, t_aggregate_prices] >> t_join_features >> t_surge_pricing >> t_publish
|
||||
21
experiments/ml/__init__.py
Normal file
21
experiments/ml/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from .evals import evaluate
|
||||
from .arch import (
|
||||
XGBoostAgentClassifier,
|
||||
LightGBMAgentClassifier,
|
||||
ContrastiveWeakClassifier,
|
||||
TrajectoryEncoder,
|
||||
WeakClassifier,
|
||||
contrastive_loss,
|
||||
featurize_trajectory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'evaluate',
|
||||
'XGBoostAgentClassifier',
|
||||
'LightGBMAgentClassifier',
|
||||
'ContrastiveWeakClassifier',
|
||||
'TrajectoryEncoder',
|
||||
'WeakClassifier',
|
||||
'contrastive_loss',
|
||||
'featurize_trajectory',
|
||||
]
|
||||
212
experiments/ml/arch.py
Normal file
212
experiments/ml/arch.py
Normal file
@@ -0,0 +1,212 @@
|
||||
# sklearn compatible models for agent detection
|
||||
from sklearn.base import BaseEstimator, ClassifierMixin
|
||||
from typing import Any, Optional, Tuple, Dict, List
|
||||
from abc import ABC, abstractmethod
|
||||
from collections import defaultdict
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import torch.nn.functional as F
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# add lib to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'lib'))
|
||||
from lib.features import (
|
||||
transition_histogram as _lib_transition_histogram,
|
||||
temporal_signature as _lib_temporal_signature,
|
||||
state_coverage as _lib_state_coverage,
|
||||
transition_entropy as _lib_transition_entropy,
|
||||
featurize_trajectory as _lib_featurize_trajectory,
|
||||
parse_timestamp
|
||||
)
|
||||
from lib.state import event_to_state, get_event_name, get_timestamp
|
||||
|
||||
TASK = 'classification'
|
||||
LABELS = ['human', 'agent']
|
||||
|
||||
|
||||
class WeakClassifier(BaseEstimator, ClassifierMixin, ABC):
|
||||
# a simple contrastive machine learning model learns to distinguish human/agent behavior
|
||||
# using weakly supervised contrastive learning + augmentation
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__()
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
|
||||
class TrajectoryEncoder(nn.Module):
|
||||
"""Encode variable-length event sequences to fixed-dim embedding via bidirectional LSTM"""
|
||||
def __init__(self, input_dim: int, embed_dim: int = 32, hidden_dim: int = 64):
|
||||
super().__init__()
|
||||
self.event_embed = nn.Linear(input_dim, hidden_dim)
|
||||
self.lstm = nn.LSTM(hidden_dim, hidden_dim, batch_first=True, bidirectional=True)
|
||||
self.proj = nn.Linear(hidden_dim * 2, embed_dim)
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor: # x: (batch, seq_len, input_dim)
|
||||
h = F.relu(self.event_embed(x))
|
||||
_, (hn, _) = self.lstm(h)
|
||||
hn = torch.cat([hn[-2], hn[-1]], dim=1) # concat bidirectional hidden states
|
||||
return F.normalize(self.proj(hn), dim=1) # L2 normalized
|
||||
|
||||
|
||||
class ContrastiveWeakClassifier(WeakClassifier):
|
||||
"""Contrastive learning classifier for human/agent trajectory discrimination"""
|
||||
def __init__(self, input_dim: int = 64, embed_dim: int = 32, margin: float = 1.0, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.input_dim = input_dim
|
||||
self.embed_dim = embed_dim
|
||||
self.margin = margin
|
||||
self.encoder = TrajectoryEncoder(input_dim, embed_dim)
|
||||
self.classifier = nn.Linear(embed_dim, 2)
|
||||
self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
self._fitted = False
|
||||
|
||||
def to_device(self):
|
||||
self.encoder.to(self.device)
|
||||
self.classifier.to(self.device)
|
||||
return self
|
||||
|
||||
def encode(self, x: torch.Tensor) -> torch.Tensor:
|
||||
return self.encoder(x.to(self.device))
|
||||
|
||||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||||
emb = self.encode(x)
|
||||
return self.classifier(emb)
|
||||
|
||||
def fit(self, X, y=None): # sklearn interface - actual training in weak.train.py
|
||||
self._fitted = True
|
||||
return self
|
||||
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
self.encoder.eval()
|
||||
self.classifier.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||
logits = self.forward(x)
|
||||
return torch.argmax(logits, dim=1).cpu().numpy()
|
||||
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
self.encoder.eval()
|
||||
self.classifier.eval()
|
||||
with torch.no_grad():
|
||||
x = torch.tensor(X, dtype=torch.float32).unsqueeze(1).to(self.device)
|
||||
logits = self.forward(x)
|
||||
return F.softmax(logits, dim=1).cpu().numpy()
|
||||
|
||||
|
||||
def contrastive_loss(anchor: torch.Tensor, positive: torch.Tensor, negative: torch.Tensor, margin: float = 0.3) -> torch.Tensor:
|
||||
"""Triplet loss using cosine similarity (for L2-normalized embeddings). margin in [0,1] range."""
|
||||
pos_sim = F.cosine_similarity(anchor, positive) # higher = more similar
|
||||
neg_sim = F.cosine_similarity(anchor, negative)
|
||||
return F.relu(neg_sim - pos_sim + margin).mean() # want pos_sim > neg_sim + margin
|
||||
|
||||
|
||||
def nt_xent_loss(z_i: torch.Tensor, z_j: torch.Tensor, temperature: float = 0.5) -> torch.Tensor:
|
||||
"""Normalized temperature-scaled cross entropy loss (SimCLR style)"""
|
||||
batch_size = z_i.size(0)
|
||||
z = torch.cat([z_i, z_j], dim=0) # (2N, embed_dim)
|
||||
sim = F.cosine_similarity(z.unsqueeze(1), z.unsqueeze(0), dim=2) / temperature
|
||||
mask = torch.eye(2 * batch_size, dtype=torch.bool, device=z.device)
|
||||
sim.masked_fill_(mask, -float('inf'))
|
||||
labels = torch.arange(batch_size, device=z.device)
|
||||
labels = torch.cat([labels + batch_size, labels]) # positive pairs
|
||||
return F.cross_entropy(sim, labels)
|
||||
|
||||
|
||||
# feature extraction utilities - delegating to lib.features for unified implementation
|
||||
# these wrappers maintain backwards compatibility for existing imports
|
||||
|
||||
def transition_histogram(events: List, state_fn, max_states: int = 50) -> np.ndarray:
|
||||
"""Compute normalized histogram of state transitions in trajectory"""
|
||||
return _lib_transition_histogram(events, state_fn, max_states)
|
||||
|
||||
|
||||
def temporal_signature(events: List, ts_fn) -> np.ndarray:
|
||||
"""Extract temporal features: mean/std/skew of inter-event times"""
|
||||
return _lib_temporal_signature(events, ts_fn)
|
||||
|
||||
|
||||
def state_coverage(events: List, state_fn, mdp_states: set) -> float:
|
||||
"""Fraction of MDP states visited by trajectory"""
|
||||
return _lib_state_coverage(events, state_fn, mdp_states)
|
||||
|
||||
|
||||
def transition_entropy(events: List, state_fn) -> float:
|
||||
"""Compute entropy of transition distribution (randomness of navigation)"""
|
||||
return _lib_transition_entropy(events, state_fn)
|
||||
|
||||
|
||||
def featurize_trajectory(events: List, mdp: Optional[Dict] = None, input_dim: int = 64) -> np.ndarray:
|
||||
"""Convert trajectory to fixed-dim feature vector - uses lib.features implementation"""
|
||||
mdp_states = set(mdp.get('states', [])) if mdp else set()
|
||||
|
||||
def _ts_fn(e):
|
||||
return parse_timestamp(get_timestamp(e))
|
||||
|
||||
def _event_name_fn(e):
|
||||
return get_event_name(e)
|
||||
|
||||
return _lib_featurize_trajectory(events, event_to_state, _ts_fn, _event_name_fn, mdp_states, input_dim)
|
||||
|
||||
|
||||
# gradient boosting classifiers for comparison baselines
|
||||
class XGBoostAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||
"""XGBoost classifier for human/agent detection from session features"""
|
||||
def __init__(self, n_estimators: int = 100, max_depth: int = 6, learning_rate: float = 0.1, **kwargs):
|
||||
self.n_estimators = n_estimators
|
||||
self.max_depth = max_depth
|
||||
self.learning_rate = learning_rate
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||
try:
|
||||
import xgboost as xgb
|
||||
self.model = xgb.XGBClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate, **self.kwargs)
|
||||
self.model.fit(X, y)
|
||||
except ImportError:
|
||||
raise ImportError("xgboost required for XGBoostAgentClassifier")
|
||||
return self
|
||||
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict(X)
|
||||
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict_proba(X)
|
||||
|
||||
|
||||
class LightGBMAgentClassifier(BaseEstimator, ClassifierMixin):
|
||||
"""LightGBM classifier for human/agent detection from session features"""
|
||||
def __init__(self, n_estimators: int = 100, max_depth: int = -1, learning_rate: float = 0.1, **kwargs):
|
||||
self.n_estimators = n_estimators
|
||||
self.max_depth = max_depth
|
||||
self.learning_rate = learning_rate
|
||||
self.model = None
|
||||
self.kwargs = kwargs
|
||||
|
||||
def fit(self, X: np.ndarray, y: np.ndarray):
|
||||
try:
|
||||
import lightgbm as lgb
|
||||
self.model = lgb.LGBMClassifier(n_estimators=self.n_estimators, max_depth=self.max_depth,
|
||||
learning_rate=self.learning_rate, verbose=-1, **self.kwargs)
|
||||
self.model.fit(X, y)
|
||||
except ImportError:
|
||||
raise ImportError("lightgbm required for LightGBMAgentClassifier")
|
||||
return self
|
||||
|
||||
def predict(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict(X)
|
||||
|
||||
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
||||
if self.model is None:
|
||||
raise ValueError("fit the model first")
|
||||
return self.model.predict_proba(X)
|
||||
103
experiments/ml/evals.py
Normal file
103
experiments/ml/evals.py
Normal file
@@ -0,0 +1,103 @@
|
||||
from sklearn.metrics import (accuracy_score, precision_score, recall_score,
|
||||
f1_score, roc_auc_score, confusion_matrix, roc_curve)
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from logging import getLogger
|
||||
import numpy as np
|
||||
import matplotlib.pyplot as plt
|
||||
import io
|
||||
from PIL import Image
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def log_feature_importance(writer, model, feature_names, epoch):
|
||||
"""Visualize and log feature importance to TensorBoard"""
|
||||
if not hasattr(model, 'feature_importances_') or model.feature_importances_ is None:
|
||||
return
|
||||
|
||||
importance = model.feature_importances_
|
||||
indices = np.argsort(importance)[::-1][:20] # top 20
|
||||
top_features = [feature_names[i] for i in indices]
|
||||
top_importance = importance[indices]
|
||||
|
||||
for i, (feat, imp) in enumerate(zip(top_features, top_importance)):
|
||||
writer.add_scalar(f'FeatureImportance/{feat}', imp, epoch)
|
||||
|
||||
fig, ax = plt.subplots(figsize=(10, 8))
|
||||
ax.barh(range(len(top_features)), top_importance, align='center')
|
||||
ax.set_yticks(range(len(top_features)))
|
||||
ax.set_yticklabels(top_features)
|
||||
ax.invert_yaxis()
|
||||
ax.set_xlabel('Importance')
|
||||
ax.set_title(f'Top 20 Feature Importance (Epoch {epoch})')
|
||||
ax.grid(axis='x', alpha=0.3)
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.tight_layout()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
img = Image.open(buf)
|
||||
img_arr = np.array(img)
|
||||
writer.add_image('FeatureImportance/Chart', img_arr, epoch, dataformats='HWC')
|
||||
plt.close()
|
||||
|
||||
def evaluate(perdicted_class, predicted_proba, true_class, writer: SummaryWriter, epoch: int):
|
||||
accuracy = accuracy_score(true_class, perdicted_class)
|
||||
precision = precision_score(true_class, perdicted_class, zero_division=0)
|
||||
recall = recall_score(true_class, perdicted_class, zero_division=0)
|
||||
f1 = f1_score(true_class, perdicted_class, zero_division=0)
|
||||
roc_auc = roc_auc_score(true_class, predicted_proba)
|
||||
|
||||
writer.add_scalar('Eval/Accuracy', accuracy, epoch)
|
||||
writer.add_scalar('Eval/Precision', precision, epoch)
|
||||
writer.add_scalar('Eval/Recall', recall, epoch)
|
||||
writer.add_scalar('Eval/F1_Score', f1, epoch)
|
||||
writer.add_scalar('Eval/ROC_AUC', roc_auc, epoch)
|
||||
|
||||
# confusion matrix
|
||||
cm = confusion_matrix(true_class, perdicted_class)
|
||||
tn, fp, fn, tp = cm.ravel()
|
||||
writer.add_scalar('Eval/TrueNeg', tn, epoch)
|
||||
writer.add_scalar('Eval/FalsePos', fp, epoch)
|
||||
writer.add_scalar('Eval/FalseNeg', fn, epoch)
|
||||
writer.add_scalar('Eval/TruePos', tp, epoch)
|
||||
|
||||
# specificity and sensitivity
|
||||
specificity = tn / (tn + fp) if (tn + fp) > 0 else 0
|
||||
sensitivity = recall # same as recall/TPR
|
||||
writer.add_scalar('Eval/Specificity', specificity, epoch)
|
||||
writer.add_scalar('Eval/Sensitivity', sensitivity, epoch)
|
||||
|
||||
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
|
||||
ax1.matshow(cm, cmap='Blues', alpha=0.7)
|
||||
for i in range(2):
|
||||
for j in range(2):
|
||||
ax1.text(j, i, str(cm[i, j]), ha='center', va='center', fontsize=14)
|
||||
ax1.set_xlabel('Predicted')
|
||||
ax1.set_ylabel('True')
|
||||
ax1.set_title(f'Confusion Matrix (Epoch {epoch})')
|
||||
ax1.set_xticks([0, 1])
|
||||
ax1.set_yticks([0, 1])
|
||||
ax1.set_xticklabels(['Human', 'Agent'])
|
||||
ax1.set_yticklabels(['Human', 'Agent'])
|
||||
|
||||
# ROC curve
|
||||
fpr, tpr, _ = roc_curve(true_class, predicted_proba)
|
||||
ax2.plot(fpr, tpr, label=f'AUC={roc_auc:.3f}', linewidth=2)
|
||||
ax2.plot([0, 1], [0, 1], 'k--', label='Random')
|
||||
ax2.set_xlabel('False Positive Rate')
|
||||
ax2.set_ylabel('True Positive Rate')
|
||||
ax2.set_title('ROC Curve')
|
||||
ax2.legend()
|
||||
ax2.grid(alpha=0.3)
|
||||
|
||||
buf = io.BytesIO()
|
||||
plt.tight_layout()
|
||||
plt.savefig(buf, format='png', dpi=100)
|
||||
buf.seek(0)
|
||||
img = Image.open(buf)
|
||||
img_arr = np.array(img)
|
||||
writer.add_image('Eval/Metrics', img_arr, epoch, dataformats='HWC')
|
||||
plt.close()
|
||||
|
||||
logger.info(f"Eval {epoch}: Acc={accuracy:.4f} Prec={precision:.4f} Rec={recall:.4f} F1={f1:.4f} AUC={roc_auc:.4f}")
|
||||
6
experiments/ml/requirements.txt
Normal file
6
experiments/ml/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
torch
|
||||
tensorboard
|
||||
fastparquet
|
||||
pyarrow
|
||||
xgboost
|
||||
lightgbm
|
||||
137
experiments/ml/train.py
Normal file
137
experiments/ml/train.py
Normal file
@@ -0,0 +1,137 @@
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
from sklearn.model_selection import train_test_split
|
||||
from logging import getLogger
|
||||
from pathlib import Path
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
import joblib
|
||||
from datetime import datetime
|
||||
from ml.evals import evaluate, log_feature_importance
|
||||
from ml.arch import XGBoostAgentClassifier, LightGBMAgentClassifier, LABELS
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
FEATURE_COLS_EXCLUDE = ['sessionId', 'experimentId', 'is_agent', 'xp_human_only', 'xp_market_mode', 'browser_family']
|
||||
RUNS_DIR = Path('ml/runs')
|
||||
CHECKPOINTS_DIR = Path('ml/checkpoints')
|
||||
|
||||
|
||||
def prepare_data(df):
|
||||
"""
|
||||
Prepare feature matrix and labels from raw dataframe
|
||||
Handles missing labels, feature selection, and categorical encoding
|
||||
Returns: (X, y, feature_cols)
|
||||
"""
|
||||
# drop rows with missing labels
|
||||
n_before = len(df)
|
||||
df = df[df['is_agent'].notna()].copy()
|
||||
n_dropped = n_before - len(df)
|
||||
if n_dropped > 0:
|
||||
logger.warning(f"Dropped {n_dropped} sessions with missing labels")
|
||||
|
||||
if len(df) == 0:
|
||||
logger.error("No labeled data available")
|
||||
return None, None, None
|
||||
|
||||
feature_cols = [c for c in df.columns if c not in FEATURE_COLS_EXCLUDE]
|
||||
|
||||
# handle categorical browser_family via one-hot encoding
|
||||
if 'browser_family' in df.columns:
|
||||
browser_dummies = pd.get_dummies(df['browser_family'], prefix='browser', drop_first=True)
|
||||
df = pd.concat([df, browser_dummies], axis=1)
|
||||
feature_cols.extend(browser_dummies.columns.tolist())
|
||||
|
||||
X = df[feature_cols].fillna(0)
|
||||
y = df['is_agent'].astype(int)
|
||||
|
||||
return X, y, feature_cols
|
||||
|
||||
|
||||
def train(data_path=None, model_type='xgboost', test_size=0.2, random_state=42,
|
||||
n_estimators=200, max_depth=6, learning_rate=0.05):
|
||||
"""
|
||||
Train agent detection classifier
|
||||
Args:
|
||||
data_path: path to labeled feature matrix CSV or parquet
|
||||
model_type: 'xgboost' or 'lightgbm'
|
||||
test_size: fraction for test split
|
||||
random_state: seed for reproducibility
|
||||
"""
|
||||
RUNS_DIR.mkdir(exist_ok=True)
|
||||
CHECKPOINTS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
run_name = f"{model_type}_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
writer = SummaryWriter(log_dir=RUNS_DIR / run_name)
|
||||
logger.info(f"Starting training run: {run_name}")
|
||||
|
||||
# load data
|
||||
if data_path is None:
|
||||
logger.error("data_path required")
|
||||
return
|
||||
df = pd.read_parquet(data_path)
|
||||
logger.info(f"Loaded {len(df)} sessions from {data_path}")
|
||||
|
||||
# prepare features and labels
|
||||
if 'is_agent' not in df.columns:
|
||||
logger.error("Missing is_agent column")
|
||||
return
|
||||
|
||||
X, y, feature_cols = prepare_data(df)
|
||||
if X is None:
|
||||
return
|
||||
|
||||
# class distribution
|
||||
n_agents = y.sum()
|
||||
n_humans = (y == 0).sum()
|
||||
logger.info(f"Class distribution: {n_humans} humans, {n_agents} agents" + (f" (ratio {n_humans / n_agents:.2f})" if n_agents > 0 else ""))
|
||||
|
||||
# train/test split with stratification
|
||||
X_train, X_test, y_train, y_test = train_test_split(
|
||||
X, y, test_size=test_size, random_state=random_state, stratify=y
|
||||
)
|
||||
logger.info(f"Train: {len(X_train)}, Test: {len(X_test)}")
|
||||
|
||||
# init model
|
||||
if model_type == 'xgboost':
|
||||
model = XGBoostAgentClassifier(
|
||||
n_estimators=n_estimators,
|
||||
max_depth=max_depth,
|
||||
learning_rate=learning_rate
|
||||
)
|
||||
elif model_type == 'lightgbm':
|
||||
model = LightGBMAgentClassifier(
|
||||
n_estimators=n_estimators,
|
||||
max_depth=max_depth,
|
||||
learning_rate=learning_rate
|
||||
)
|
||||
else:
|
||||
logger.error(f"Unknown model type: {model_type}")
|
||||
return
|
||||
|
||||
# train with eval set for early stopping
|
||||
model.fit(X_train, y_train, eval_set=[(X_test, y_test)])
|
||||
logger.info("Training complete")
|
||||
|
||||
# evaluate on test set
|
||||
y_pred = model.predict(X_test)
|
||||
y_prob = model.predict_proba(X_test)[:, 1]
|
||||
|
||||
evaluate(y_pred, y_prob, y_test, writer, epoch=0)
|
||||
|
||||
# log feature importance
|
||||
log_feature_importance(writer, model, X.columns.tolist(), epoch=0)
|
||||
|
||||
# save model
|
||||
model_path = CHECKPOINTS_DIR / f"{run_name}.pkl"
|
||||
joblib.dump({'model': model, 'feature_cols': X.columns.tolist(), 'run_name': run_name}, model_path)
|
||||
logger.info(f"Model saved to {model_path}")
|
||||
|
||||
writer.close()
|
||||
return model, X.columns.tolist()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
data_path = sys.argv[1]
|
||||
model_type = sys.argv[2] if len(sys.argv) > 2 else 'xgboost'
|
||||
train(data_path, model_type=model_type)
|
||||
246
experiments/ml/weak_train.py
Normal file
246
experiments/ml/weak_train.py
Normal file
@@ -0,0 +1,246 @@
|
||||
import sys
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/sim/rl/behavior_loader")
|
||||
sys.path.insert(0, "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml")
|
||||
|
||||
from sim.rl.behavior_loader.loader import AgentLoader, Loader, JointLoader, PayloadModel
|
||||
from sim.rl.behavior_loader.models import JointBehaviorModel
|
||||
from arch import ContrastiveWeakClassifier, contrastive_loss, featurize_trajectory
|
||||
from typing import List, Optional, Dict
|
||||
from datetime import datetime, timedelta
|
||||
from copy import deepcopy
|
||||
import numpy as np
|
||||
import random
|
||||
import torch
|
||||
from torch.utils.data import Dataset, DataLoader
|
||||
from torch.optim import Adam
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
|
||||
RUNS_DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/ml/runs"
|
||||
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
|
||||
def _perturb_ts(evt: PayloadModel, jitter_ms: int = 500) -> PayloadModel:
|
||||
"""Add random jitter to event timestamp"""
|
||||
new_evt = deepcopy(evt)
|
||||
try:
|
||||
ts = datetime.fromisoformat(evt.ts.replace('Z', '+00:00'))
|
||||
delta = timedelta(milliseconds=random.randint(-jitter_ms, jitter_ms))
|
||||
new_evt.ts = (ts + delta).isoformat()
|
||||
except:
|
||||
pass
|
||||
return new_evt
|
||||
|
||||
|
||||
def augment_trajectory(trajectory: List[PayloadModel], rate: float = 0.1) -> List[PayloadModel]:
|
||||
"""Apply random augmentation to trajectory for contrastive learning"""
|
||||
if len(trajectory) < 2:
|
||||
return trajectory
|
||||
|
||||
aug_type = random.choice(['window', 'shuffle', 'noise', 'drop'])
|
||||
|
||||
if aug_type == 'window': # random contiguous sub-sequence (70-100% length)
|
||||
min_len = max(2, int(len(trajectory) * 0.7))
|
||||
sub_len = random.randint(min_len, len(trajectory))
|
||||
start = random.randint(0, len(trajectory) - sub_len)
|
||||
return trajectory[start:start + sub_len]
|
||||
|
||||
elif aug_type == 'shuffle': # swap adjacent pairs with probability rate
|
||||
result = list(trajectory)
|
||||
for i in range(len(result) - 1):
|
||||
if random.random() < rate:
|
||||
result[i], result[i + 1] = result[i + 1], result[i]
|
||||
return result
|
||||
|
||||
elif aug_type == 'drop': # drop events with probability rate
|
||||
result = [e for e in trajectory if random.random() > rate]
|
||||
return result if len(result) >= 2 else trajectory[:2]
|
||||
|
||||
elif aug_type == 'noise': # perturb timestamps
|
||||
return [_perturb_ts(e, jitter_ms=500) for e in trajectory]
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
class TripletDataset(Dataset):
|
||||
"""Generate (anchor, positive, negative) triplets on-the-fly with augmentation"""
|
||||
def __init__(self, data: Dict[str, List[PayloadModel]], mdp: Optional[Dict], augment_fn, input_dim: int = 64, multiplier: int = 10):
|
||||
self.sessions = list(data.items())
|
||||
self.human_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('human_')]
|
||||
self.agent_ids = [i for i, (sid, _) in enumerate(self.sessions) if sid.startswith('agent_')]
|
||||
self.mdp = mdp
|
||||
self.augment = augment_fn
|
||||
self.input_dim = input_dim
|
||||
self.multiplier = multiplier
|
||||
|
||||
if not self.human_ids or not self.agent_ids:
|
||||
raise ValueError(f"Need both human ({len(self.human_ids)}) and agent ({len(self.agent_ids)}) sessions")
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.sessions) * self.multiplier
|
||||
|
||||
def __getitem__(self, idx: int):
|
||||
anchor_idx = idx % len(self.sessions)
|
||||
sid, events = self.sessions[anchor_idx]
|
||||
is_human = sid.startswith('human_')
|
||||
|
||||
anchor = featurize_trajectory(events, self.mdp, self.input_dim)
|
||||
positive = featurize_trajectory(self.augment(events), self.mdp, self.input_dim)
|
||||
|
||||
neg_pool = self.agent_ids if is_human else self.human_ids
|
||||
neg_idx = random.choice(neg_pool)
|
||||
negative = featurize_trajectory(self.sessions[neg_idx][1], self.mdp, self.input_dim)
|
||||
|
||||
label = 0 if is_human else 1 # 0=human, 1=agent
|
||||
return (torch.tensor(anchor, dtype=torch.float32),
|
||||
torch.tensor(positive, dtype=torch.float32),
|
||||
torch.tensor(negative, dtype=torch.float32),
|
||||
torch.tensor(label, dtype=torch.long))
|
||||
|
||||
|
||||
def train(epochs: int = 100, lr: float = 1e-3, batch_size: int = 4, input_dim: int = 64,
|
||||
embed_dim: int = 32, margin: float = 0.3, verbose: bool = True, run_name: str = None):
|
||||
"""Train contrastive weak classifier on human/agent trajectories"""
|
||||
joint = JointLoader(human_dir, agent_dir)
|
||||
data = joint.get_data()
|
||||
if verbose:
|
||||
print(f"Loaded {len(data)} sessions")
|
||||
|
||||
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||
ref_mdp = joint_model.build_MDP()
|
||||
|
||||
dataset = TripletDataset(data, ref_mdp, augment_trajectory, input_dim=input_dim)
|
||||
loader = DataLoader(dataset, batch_size=batch_size, shuffle=True, drop_last=True)
|
||||
|
||||
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||
model.to_device()
|
||||
|
||||
run_name = run_name or f"d{input_dim}_e{embed_dim}_lr{lr}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS_DIR}/train/{run_name}")
|
||||
|
||||
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||
ce_loss_fn = torch.nn.CrossEntropyLoss()
|
||||
|
||||
best_loss = float('inf')
|
||||
for epoch in range(epochs):
|
||||
model.encoder.train()
|
||||
model.classifier.train()
|
||||
total_loss, n_batches = 0.0, 0
|
||||
|
||||
for anchor, positive, negative, labels in loader:
|
||||
anchor, positive, negative, labels = [t.to(model.device) for t in [anchor, positive, negative, labels]]
|
||||
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1)) for t in [anchor, positive, negative]]
|
||||
|
||||
trip_loss = contrastive_loss(z_a, z_p, z_n, margin=model.margin)
|
||||
ce = ce_loss_fn(model.classifier(z_a), labels)
|
||||
loss = trip_loss + 0.5 * ce
|
||||
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
total_loss += loss.item()
|
||||
n_batches += 1
|
||||
|
||||
avg_loss = total_loss / max(n_batches, 1)
|
||||
writer.add_scalar('loss', avg_loss, epoch)
|
||||
|
||||
if verbose and (epoch + 1) % 10 == 0:
|
||||
print(f"Epoch {epoch+1}/{epochs}: loss={avg_loss:.4f}")
|
||||
if avg_loss < best_loss:
|
||||
best_loss = avg_loss
|
||||
|
||||
writer.close()
|
||||
if verbose:
|
||||
print(f"Done. Best={best_loss:.4f} TB:{RUNS_DIR}/train/{run_name}")
|
||||
|
||||
return model, ref_mdp
|
||||
|
||||
|
||||
def evaluate_loocv(input_dim: int = 64, embed_dim: int = 32, epochs_per_fold: int = 50,
|
||||
lr: float = 1e-3, margin: float = 0.3, run_name: str = None):
|
||||
"""Leave-one-out cross-validation given limited samples"""
|
||||
joint = JointLoader(human_dir, agent_dir)
|
||||
data = joint.get_data()
|
||||
session_ids = list(data.keys())
|
||||
|
||||
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||
ref_mdp = joint_model.build_MDP()
|
||||
|
||||
run_name = run_name or f"loocv_d{input_dim}_e{embed_dim}_m{margin}_{datetime.now():%Y%m%d_%H%M%S}"
|
||||
writer = SummaryWriter(f"{RUNS_DIR}/eval/{run_name}")
|
||||
|
||||
predictions, actuals = [], []
|
||||
|
||||
for fold_idx, test_sid in enumerate(session_ids):
|
||||
train_data = {k: v for k, v in data.items() if k != test_sid}
|
||||
test_events = data[test_sid]
|
||||
test_label = 0 if test_sid.startswith('human_') else 1
|
||||
|
||||
n_human = sum(1 for k in train_data if k.startswith('human_'))
|
||||
n_agent = sum(1 for k in train_data if k.startswith('agent_'))
|
||||
if n_human == 0 or n_agent == 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
dataset = TripletDataset(train_data, ref_mdp, augment_trajectory, input_dim=input_dim, multiplier=5)
|
||||
loader = DataLoader(dataset, batch_size=2, shuffle=True, drop_last=True)
|
||||
|
||||
model = ContrastiveWeakClassifier(input_dim=input_dim, embed_dim=embed_dim, margin=margin)
|
||||
model.to_device()
|
||||
optimizer = Adam(list(model.encoder.parameters()) + list(model.classifier.parameters()), lr=lr)
|
||||
|
||||
model.encoder.train()
|
||||
model.classifier.train()
|
||||
for _ in range(epochs_per_fold):
|
||||
for anchor, positive, negative, labels in loader:
|
||||
z_a, z_p, z_n = [model.encoder(t.unsqueeze(1).to(model.device)) for t in [anchor, positive, negative]]
|
||||
loss = contrastive_loss(z_a, z_p, z_n, margin=margin)
|
||||
optimizer.zero_grad()
|
||||
loss.backward()
|
||||
optimizer.step()
|
||||
|
||||
test_feat = featurize_trajectory(test_events, ref_mdp, input_dim)
|
||||
pred = model.predict(test_feat.reshape(1, -1))[0]
|
||||
predictions.append(pred)
|
||||
actuals.append(test_label)
|
||||
print(f" {test_sid[:12]}...: pred={pred}, actual={test_label}, {'OK' if pred == test_label else 'MISS'}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if predictions:
|
||||
acc = sum(p == a for p, a in zip(predictions, actuals)) / len(predictions)
|
||||
tp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 1)
|
||||
fp = sum(1 for p, a in zip(predictions, actuals) if p == 1 and a == 0)
|
||||
fn = sum(1 for p, a in zip(predictions, actuals) if p == 0 and a == 1)
|
||||
prec, rec = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
||||
f1 = 2 * prec * rec / max(prec + rec, 1e-10)
|
||||
writer.add_scalar('accuracy', acc, 0)
|
||||
writer.add_scalar('f1', f1, 0)
|
||||
writer.add_scalar('precision', prec, 0)
|
||||
writer.add_scalar('recall', rec, 0)
|
||||
writer.close()
|
||||
print(f"\nAccuracy: {acc:.2%} F1: {f1:.3f} TB:{RUNS_DIR}/eval/{run_name}")
|
||||
return acc, predictions, actuals
|
||||
writer.close()
|
||||
return 0.0, [], []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--mode', choices=['train', 'eval'], default='train')
|
||||
parser.add_argument('--epochs', type=int, default=100)
|
||||
parser.add_argument('--lr', type=float, default=1e-3)
|
||||
parser.add_argument('--margin', type=float, default=0.3)
|
||||
parser.add_argument('--input-dim', type=int, default=64)
|
||||
parser.add_argument('--embed-dim', type=int, default=32)
|
||||
parser.add_argument('--run-name', type=str, default=None)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == 'train':
|
||||
model, mdp = train(epochs=args.epochs, lr=args.lr, input_dim=args.input_dim,
|
||||
embed_dim=args.embed_dim, margin=args.margin, run_name=args.run_name)
|
||||
else:
|
||||
evaluate_loocv(input_dim=args.input_dim, embed_dim=args.embed_dim, epochs_per_fold=args.epochs,
|
||||
lr=args.lr, margin=args.margin, run_name=args.run_name)
|
||||
@@ -12,16 +12,14 @@ from procesing.steps import (
|
||||
ComputeDemandStep,
|
||||
ComputeDemandForChunksStep,
|
||||
AggregatePriceLogsStep,
|
||||
ComputeElasticityStep,
|
||||
StateSpace,
|
||||
BuildStateSpaceStep,
|
||||
# StateSpace,
|
||||
# BuildStateSpaceStep,
|
||||
FitPricingFunctionStep,
|
||||
PredictPricesStep,
|
||||
)
|
||||
from procesing.pipelines import (
|
||||
interaction_extraction_pipeline,
|
||||
price_extraction_pipeline,
|
||||
elasticity_computation_pipeline,
|
||||
pricing_pipeline,
|
||||
full_pipeline,
|
||||
)
|
||||
@@ -42,14 +40,12 @@ __all__ = [
|
||||
'ComputeDemandStep',
|
||||
'ComputeDemandForChunksStep',
|
||||
'AggregatePriceLogsStep',
|
||||
'ComputeElasticityStep',
|
||||
'StateSpace',
|
||||
'BuildStateSpaceStep',
|
||||
# 'StateSpace',
|
||||
# 'BuildStateSpaceStep',
|
||||
'FitPricingFunctionStep',
|
||||
'PredictPricesStep',
|
||||
'interaction_extraction_pipeline',
|
||||
'price_extraction_pipeline',
|
||||
'elasticity_computation_pipeline',
|
||||
'pricing_pipeline',
|
||||
'full_pipeline',
|
||||
]
|
||||
|
||||
113
experiments/procesing/contaminator.py
Normal file
113
experiments/procesing/contaminator.py
Normal file
@@ -0,0 +1,113 @@
|
||||
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
|
||||
from typing import Union
|
||||
import os
|
||||
from procesing.steps import (
|
||||
FetchInteractionsStep,
|
||||
FetchPriceLogsStep,
|
||||
@@ -13,11 +13,15 @@ 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"""
|
||||
@@ -35,104 +39,136 @@ def price_extraction_pipeline(context: PipelineContext):
|
||||
])
|
||||
|
||||
|
||||
def elasticity_computation_pipeline(context: PipelineContext,
|
||||
def product_features_pipeline(context: PipelineContext,
|
||||
interactions_df: pd.DataFrame,
|
||||
price_logs_df: pd.DataFrame):
|
||||
"""
|
||||
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
|
||||
demand_step = ComputeDemandStep(context)
|
||||
price_step = AggregatePriceLogsStep(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
|
||||
join_step = JoinProductFeaturesStep(context)
|
||||
|
||||
|
||||
def pricing_pipeline(context: PipelineContext, elasticity_df: pd.DataFrame):
|
||||
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):
|
||||
"""
|
||||
Generate optimal prices from elasticity estimates.
|
||||
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]
|
||||
"""
|
||||
# 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)
|
||||
interactions_df = interaction_pipe.fit_transform(None)
|
||||
|
||||
# extract price logs
|
||||
price_pipe = price_extraction_pipeline(context)
|
||||
|
||||
interactions_df = interaction_pipe.fit_transform(None)
|
||||
price_logs_df = price_pipe.fit_transform(None)
|
||||
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||
print(product_features_df.to_string())
|
||||
|
||||
if interactions_df.empty or price_logs_df.empty:
|
||||
return None, None
|
||||
# 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)
|
||||
|
||||
# compute elasticity
|
||||
elasticity_df = elasticity_computation_pipeline(
|
||||
context,
|
||||
interactions_df,
|
||||
price_logs_df
|
||||
)
|
||||
return product_features_df, optimal_prices_df
|
||||
|
||||
if elasticity_df is None or elasticity_df.empty:
|
||||
return elasticity_df, None
|
||||
|
||||
# generate prices
|
||||
prices_df = pricing_pipeline(context, elasticity_df)
|
||||
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||
"""
|
||||
Build labeled session-level feature matrix for ML model training.
|
||||
Pipeline: fetch -> validate -> extract features -> join labels
|
||||
|
||||
Returns:
|
||||
DataFrame with ~25 features per session + is_agent label
|
||||
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||
"""
|
||||
# fetch raw interactions
|
||||
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||
|
||||
# validate data quality (report cached in context)
|
||||
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||
if interactions_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# extract vectorized session features
|
||||
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||
if features_df.empty:
|
||||
return pd.DataFrame()
|
||||
|
||||
# join experiment labels (is_agent = ~xp_human_only)
|
||||
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||
|
||||
return labeled_df
|
||||
|
||||
|
||||
return elasticity_df, prices_df
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
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',
|
||||
)
|
||||
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()
|
||||
|
||||
elasticity_df, prices_df = full_pipeline(context)
|
||||
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||
file_to_read = files.get(topic, files["user-interactions"])
|
||||
frames = []
|
||||
|
||||
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.")
|
||||
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 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.")
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from procesing.pricers.base import PricingFunction
|
||||
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer
|
||||
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||
|
||||
__all__ = [
|
||||
@@ -8,6 +8,7 @@ __all__ = [
|
||||
'ElasticityBasedPricer',
|
||||
'StaticPricer',
|
||||
'RandomPricer',
|
||||
'SimpleSurgePricer',
|
||||
'SessionAwarePricer',
|
||||
'ProductSpecificSessionPricer'
|
||||
]
|
||||
|
||||
@@ -7,15 +7,6 @@ import pandas as pd
|
||||
class PricingFunction(ABC):
|
||||
"""
|
||||
Abstract base for pricing functions.
|
||||
|
||||
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
||||
|
||||
Where:
|
||||
Q_t ∈ R^n: demand vector at time t
|
||||
P_t ∈ R^n: price vector at time t
|
||||
S_t: session features (behavioral signals, interactions)
|
||||
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
||||
|
||||
Objective:
|
||||
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||
subject to:
|
||||
@@ -25,26 +16,32 @@ class PricingFunction(ABC):
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||
def fit(self, *kwargs):
|
||||
"""
|
||||
Offline training on historical data.
|
||||
This is where we can think about some maximization of expected revenue
|
||||
over historical trajectories to learn parameters of the pricing function.
|
||||
(This however we cover move in the RL side of things)
|
||||
|
||||
Args:
|
||||
historical_data: DataFrame with elasticity, prices, demand signals
|
||||
**kwargs: additional training parameters
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def predict(self, state_space) -> np.ndarray:
|
||||
def predict(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Generate optimal prices given current state.
|
||||
This is an abstract method that transitions from τ -> P*
|
||||
which is the mapping from the trajectory to optimal prices under
|
||||
some subset of session grouping (so, per sessionId)
|
||||
"""
|
||||
pass
|
||||
|
||||
Args:
|
||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
||||
|
||||
@abstractmethod
|
||||
def _get_features(self, *kwargs) -> np.ndarray:
|
||||
"""
|
||||
Extract features from trajectory for pricing decision.
|
||||
Returns:
|
||||
P_{t+1}: price vector in R^n
|
||||
np.ndarray of shape (n_products, n_features)
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@@ -57,3 +57,13 @@ class ElasticityBasedPricer(PricingFunction):
|
||||
# enforce bounds
|
||||
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 3))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||
|
||||
@@ -107,6 +107,36 @@ class SessionAwarePricer(PricingFunction):
|
||||
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity, demand, and session features"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 5))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
n_products = len(demand)
|
||||
|
||||
# extract session features
|
||||
velocity = 0.0
|
||||
view_depth = 0.0
|
||||
cart_to_view = 0.0
|
||||
|
||||
if not state_space.session_features.empty:
|
||||
sf = state_space.session_features.iloc[0]
|
||||
velocity = sf.get('interaction_velocity', 0.0)
|
||||
view_depth = sf.get('product_view_depth', 0.0)
|
||||
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||
|
||||
# broadcast session features to all products
|
||||
features = np.column_stack([
|
||||
self.elasticity,
|
||||
demand,
|
||||
np.full(n_products, velocity),
|
||||
np.full(n_products, view_depth),
|
||||
np.full(n_products, cart_to_view)
|
||||
])
|
||||
return features
|
||||
|
||||
|
||||
class ProductSpecificSessionPricer(PricingFunction):
|
||||
"""
|
||||
@@ -170,3 +200,12 @@ class ProductSpecificSessionPricer(PricingFunction):
|
||||
|
||||
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||
return prices
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Extract elasticity and demand features for product-specific pricing"""
|
||||
if state_space is None or self.elasticity is None:
|
||||
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||
return np.zeros((n, 2))
|
||||
|
||||
demand = np.asarray(state_space.demand)
|
||||
return np.column_stack([self.elasticity, demand])
|
||||
|
||||
@@ -3,6 +3,46 @@ import pandas as pd
|
||||
from procesing.pricers.base import PricingFunction
|
||||
|
||||
|
||||
def session_features_to_demand(session_features: pd.DataFrame) -> float:
|
||||
"""
|
||||
Map session behavioral features to demand proxy.
|
||||
THIS is the critical θ̂ → D transformation for rule-based pricing.
|
||||
|
||||
Logic:
|
||||
- High velocity → agent behavior → price up (revenue recovery)
|
||||
- High cart ratio → purchase intent → price up
|
||||
- Low activity → discount to convert
|
||||
|
||||
Returns: demand proxy score (0-20 range, higher = more demand)
|
||||
"""
|
||||
if session_features.empty:
|
||||
return 1.0
|
||||
|
||||
feat = session_features.iloc[0] if len(session_features) > 0 else {}
|
||||
|
||||
velocity = feat.get('interaction_velocity', 0)
|
||||
cart_ratio = feat.get('cart_to_view_ratio', 0)
|
||||
item_views = feat.get('item_views', 0)
|
||||
cart_adds = feat.get('cart_adds', 0)
|
||||
|
||||
# baseline demand
|
||||
demand = 1.0
|
||||
|
||||
# agent detection: high velocity → treat as high "demand" to price up
|
||||
if velocity > 2.0:
|
||||
demand += 10.0 # strong agent signal
|
||||
|
||||
# conversion intent: cart interaction → price up
|
||||
if cart_ratio > 0.1 or cart_adds > 0:
|
||||
demand += 5.0
|
||||
|
||||
# browsing depth: many views → interest signal
|
||||
if item_views > 3:
|
||||
demand += min(item_views, 5.0)
|
||||
|
||||
return min(demand, 20.0) # cap at 20
|
||||
|
||||
|
||||
class StaticPricer(PricingFunction):
|
||||
"""Static pricing: always return fixed base prices"""
|
||||
|
||||
@@ -25,6 +65,11 @@ class StaticPricer(PricingFunction):
|
||||
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||
return self.base_prices.copy()
|
||||
|
||||
def _get_features(self, state_space=None) -> np.ndarray:
|
||||
"""Static pricer uses no features, returns empty array"""
|
||||
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||
return np.zeros((n, 0))
|
||||
|
||||
|
||||
class RandomPricer(PricingFunction):
|
||||
"""Random pricing within bounds (for baseline comparison)"""
|
||||
@@ -46,3 +91,68 @@ 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,10 +18,17 @@ class SupabaseProvider(DataProvider):
|
||||
self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
|
||||
|
||||
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||
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()
|
||||
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||
if not resp.data:
|
||||
return pd.DataFrame()
|
||||
df = pd.DataFrame(resp.data)
|
||||
# normalize type column: hotel has room_type, airline has flight_type
|
||||
if 'room_type' in df.columns:
|
||||
df['product_type'] = df['room_type']
|
||||
elif 'flight_type' in df.columns:
|
||||
df['product_type'] = df['flight_type']
|
||||
return df
|
||||
|
||||
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||
if not experiment_ids:
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
from procesing.steps.base import BaseContextStep
|
||||
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||
from procesing.steps.join import JoinExperimentsStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep
|
||||
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep, ComputeElasticityStep
|
||||
from procesing.steps.pricing import StateSpace, BuildStateSpaceStep, FitPricingFunctionStep, PredictPricesStep
|
||||
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||
from procesing.steps.session import (
|
||||
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||
_extract_features_for_session
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'BaseContextStep',
|
||||
@@ -13,15 +18,22 @@ __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,6 +2,93 @@ 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,6 +1,7 @@
|
||||
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):
|
||||
"""
|
||||
@@ -16,7 +17,7 @@ class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||
return self
|
||||
|
||||
@abstractmethod
|
||||
def transform(self, X):
|
||||
def transform(self, X) -> Any:
|
||||
"""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: list of price chunks with [productId, price]
|
||||
Output: DataFrame with columns [productId, price]
|
||||
"""
|
||||
|
||||
def transform(self, price_logs_df: pd.DataFrame):
|
||||
if price_logs_df.empty:
|
||||
return []
|
||||
return pd.DataFrame(columns=['productId', 'price'])
|
||||
|
||||
df = price_logs_df.copy()
|
||||
ts_col = self.context.config.get('ts_col', 'ts')
|
||||
window_size = self.context.window_size
|
||||
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||
|
||||
# ensure datetime
|
||||
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||
@@ -24,230 +24,19 @@ class AggregatePriceLogsStep(BaseContextStep):
|
||||
|
||||
df = df.sort_values([ts_col, 'productId'])
|
||||
products = self.context.products
|
||||
unique_products = products['id'].unique()
|
||||
|
||||
# VECTORIZED: group by product, resample by time window, compute mean
|
||||
df_indexed = df.set_index(ts_col)
|
||||
|
||||
windowed = (
|
||||
df_indexed
|
||||
.groupby('productId')['price']
|
||||
.resample(window_size)
|
||||
.mean()
|
||||
.reset_index()
|
||||
# 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
|
||||
)
|
||||
|
||||
# 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'])
|
||||
unique_products = products['id'].unique()
|
||||
|
||||
# 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))
|
||||
}
|
||||
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
|
||||
|
||||
@@ -2,7 +2,11 @@ import pandas as pd
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
class FetchInteractionsStep(BaseContextStep):
|
||||
"""Fetch raw interaction data from Kafka topic"""
|
||||
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
|
||||
def transform(self, X=None):
|
||||
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||
@@ -17,19 +21,50 @@ 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"""
|
||||
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||
|
||||
def __init__(self, context, lookback: str = None):
|
||||
super().__init__(context)
|
||||
self.lookback = lookback
|
||||
|
||||
def transform(self, X=None):
|
||||
return self.context.provider.fetch_kafka_topic('price-logs')
|
||||
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||
|
||||
if df.empty:
|
||||
return df
|
||||
|
||||
# filter by store_mode from context
|
||||
if 'storeMode' in df.columns:
|
||||
df = df[df['storeMode'] == self.context.store_mode]
|
||||
|
||||
# Apply time filtering if lookback specified
|
||||
if self.lookback and 'ts' in df.columns:
|
||||
df['ts'] = pd.to_datetime(df['ts'])
|
||||
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||
df = df[df['ts'] >= cutoff]
|
||||
|
||||
return df
|
||||
|
||||
|
||||
class FetchExperimentsStep(BaseContextStep):
|
||||
|
||||
@@ -32,3 +32,27 @@ 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,128 +2,34 @@ 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
|
||||
|
||||
@dataclass
|
||||
class StateSpace:
|
||||
"""
|
||||
State representation for pricing functions.
|
||||
class State:
|
||||
def __init__(self,
|
||||
last_action : str,
|
||||
last_productId : str,
|
||||
last_price : float,
|
||||
session_features : np.ndarray
|
||||
):
|
||||
pass
|
||||
|
||||
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 elasticity data.
|
||||
Input: elasticity_df
|
||||
Fit pricing function using data.
|
||||
Input: pricing_data
|
||||
Output: fitted pricing function instance
|
||||
"""
|
||||
|
||||
def transform(self, elasticity_df: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', ElasticityBasedPricer)
|
||||
def transform(self, pricing_data: pd.DataFrame):
|
||||
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||
|
||||
pricer = pricing_class(**pricing_params)
|
||||
pricer.fit(elasticity_df)
|
||||
pricer.fit(pricing_data)
|
||||
|
||||
return pricer
|
||||
|
||||
|
||||
@@ -1,114 +1,262 @@
|
||||
"""
|
||||
Session feature extraction for S_t component of state space.
|
||||
Computes behavioral signals from interaction data already in pipeline.
|
||||
Session feature extraction for ML training pipeline.
|
||||
"""
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from typing import Optional, Dict, Any
|
||||
from collections import Counter
|
||||
import re
|
||||
from typing import Dict, Any
|
||||
from procesing.steps.base import BaseContextStep
|
||||
|
||||
EVENT_CATS = {
|
||||
'page_view': ['page_view'],
|
||||
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||
'cart_add': ['add_item_to_cart'],
|
||||
'purchase': ['purchase', 'checkout_complete'],
|
||||
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||
}
|
||||
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||
|
||||
|
||||
def _get_browser(s: str) -> str:
|
||||
if pd.isna(s): return 'Unknown'
|
||||
for name, pat in BROWSER_PATTERNS:
|
||||
if re.search(pat, s): return name
|
||||
return 'Other'
|
||||
|
||||
|
||||
class TemporalFeatureStep(BaseContextStep):
|
||||
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||
|
||||
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||
super().__init__(context)
|
||||
self.timeout_sec = timeout_sec
|
||||
self.velocity_window = velocity_window
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'ts' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
session_duration_sec=('active_diff', 'sum'),
|
||||
total_interactions=('sessionId', 'count'),
|
||||
avg_time_between_events=('time_diff', 'mean'),
|
||||
std_time_between_events=('time_diff', 'std'),
|
||||
min_time_between_events=('time_diff', 'min'),
|
||||
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||
).reset_index()
|
||||
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||
agg['interaction_velocity'] = np.where(
|
||||
agg['session_duration_sec'] > 0,
|
||||
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||
|
||||
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class BehavioralFeatureStep(BaseContextStep):
|
||||
"""Vectorized event counts and ratios per session."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty or 'eventName' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
for cat, events in EVENT_CATS.items():
|
||||
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||
|
||||
agg = df.groupby('sessionId').agg(
|
||||
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||
hover_events=('is_hover', 'sum'),
|
||||
# filter_events=('is_filter', 'sum'),
|
||||
).reset_index()
|
||||
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||
return agg
|
||||
|
||||
|
||||
class ProductFeatureStep(BaseContextStep):
|
||||
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
if df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||
|
||||
prod_df = df[df['productId'].notna()]
|
||||
if prod_df.empty:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||
|
||||
agg = prod_df.groupby('sessionId').agg(
|
||||
unique_products_viewed=('productId', 'nunique'),
|
||||
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||
max_price_seen=('price_seen', 'max'),
|
||||
).reset_index()
|
||||
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||
return agg
|
||||
|
||||
|
||||
class UserAgentFeatureStep(BaseContextStep):
|
||||
"""Parse userAgent into bot-detection signals."""
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||
df = X.copy()
|
||||
if df.empty or 'userAgent' not in df.columns:
|
||||
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||
|
||||
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||
|
||||
|
||||
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||
"""
|
||||
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)
|
||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||
Input: interactions_df
|
||||
Output: session-level feature matrix
|
||||
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
|
||||
"""
|
||||
|
||||
def transform(self, interactions_df: pd.DataFrame) -> pd.DataFrame:
|
||||
if interactions_df.empty:
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
if X.empty:
|
||||
return pd.DataFrame()
|
||||
df = X.copy()
|
||||
|
||||
# ensure timestamp column
|
||||
if 'ts' in interactions_df.columns:
|
||||
interactions_df = interactions_df.copy()
|
||||
interactions_df['ts'] = pd.to_datetime(interactions_df['ts'])
|
||||
# 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)
|
||||
|
||||
# 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)
|
||||
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')
|
||||
|
||||
return pd.DataFrame(session_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')
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# 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['product_view_depth'] = 0
|
||||
|
||||
# temporal features
|
||||
if 'ts' in session_df.columns:
|
||||
timestamps = session_df['ts'].sort_values()
|
||||
features['session_duration_sec'] = (timestamps.max() - timestamps.min()).total_seconds()
|
||||
|
||||
if features['session_duration_sec'] > 0:
|
||||
features['interaction_velocity'] = (features['total_interactions'] / features['session_duration_sec']) * 60
|
||||
else:
|
||||
features['interaction_velocity'] = 0.0
|
||||
|
||||
# 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
|
||||
return result
|
||||
|
||||
|
||||
class FilterSessionInteractionsStep(BaseContextStep):
|
||||
class JoinLabelsStep(BaseContextStep):
|
||||
"""
|
||||
Filter interactions DataFrame to specific session.
|
||||
|
||||
Input: (interactions_df, session_id)
|
||||
Output: interactions_df filtered to session_id
|
||||
Join experiment labels to session features.
|
||||
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||
Output: labeled feature matrix with is_agent column
|
||||
"""
|
||||
|
||||
def transform(self, data: tuple) -> pd.DataFrame:
|
||||
interactions_df, session_id = data
|
||||
return interactions_df[interactions_df['sessionId'] == session_id].copy()
|
||||
def transform(self, X : tuple) -> pd.DataFrame:
|
||||
data = X;
|
||||
if isinstance(data, tuple):
|
||||
features_df, experiments_df = data
|
||||
else:
|
||||
features_df = data
|
||||
if 'experimentId' not in features_df.columns:
|
||||
return features_df
|
||||
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||
|
||||
if features_df.empty:
|
||||
return features_df
|
||||
if experiments_df.empty:
|
||||
features_df['is_agent'] = np.nan
|
||||
return features_df
|
||||
|
||||
exp = experiments_df.copy()
|
||||
if 'id' in exp.columns:
|
||||
exp = exp.rename(columns={'id': 'experimentId'})
|
||||
if 'xp_human_only' in exp.columns:
|
||||
exp['is_agent'] = ~exp['xp_human_only']
|
||||
|
||||
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||
|
||||
|
||||
class ValidateDataStep(BaseContextStep):
|
||||
"""
|
||||
Data quality checks before training.
|
||||
Input: df
|
||||
Output: df (unchanged, but logs validation report to context)
|
||||
"""
|
||||
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||
|
||||
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||
df = X.copy()
|
||||
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||
if df.empty:
|
||||
report['status'] = 'empty'
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||
if missing:
|
||||
report['status'] = 'invalid'
|
||||
report['missing_cols'] = missing
|
||||
|
||||
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||
if 'experimentId' in df.columns:
|
||||
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||
|
||||
self.context.cache('validation_report', report)
|
||||
return df
|
||||
|
||||
|
||||
# legacy compat - kept for backwards compatibility with existing code
|
||||
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||
"""Single-session feature extraction (legacy interface)."""
|
||||
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||
'session_duration_sec', 'interaction_velocity',
|
||||
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||
if session_df.empty:
|
||||
return defaults
|
||||
|
||||
session_df = session_df.copy()
|
||||
if 'sessionId' not in session_df.columns:
|
||||
session_df['sessionId'] = 'tmp'
|
||||
|
||||
# use a dummy context for the steps
|
||||
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||
ctx = DummyCtx()
|
||||
|
||||
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||
p = ProductFeatureStep(ctx).transform(session_df)
|
||||
|
||||
result = {}
|
||||
for df in [t, b, p]:
|
||||
if not df.empty:
|
||||
for col in df.columns:
|
||||
if col != 'sessionId':
|
||||
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||
|
||||
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||
for old, new in remap.items():
|
||||
if old in result:
|
||||
result[new] = result.pop(old)
|
||||
return result
|
||||
|
||||
@@ -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': 'shop',
|
||||
'storeMode': 'hotel',
|
||||
'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': 'shop',
|
||||
'storeMode': 'hotel',
|
||||
'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': 'shop',
|
||||
'storeMode': 'hotel',
|
||||
'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': 'shop',
|
||||
'storeMode': 'hotel',
|
||||
'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': 'shop',
|
||||
'storeMode': 'hotel',
|
||||
'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', 'shop'],
|
||||
'xp_market_mode': ['hotel', 'airline'],
|
||||
'xp_task_id': [None, None]
|
||||
})
|
||||
|
||||
@@ -269,3 +269,13 @@ 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
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
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'
|
||||
75
lab/README.md
Normal file
75
lab/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# MOS (Money Operating System)
|
||||
|
||||
Research-grade quote-control simulator for studying dynamic pricing and market making policies.
|
||||
The system models pricing as a closed loop of **Quote → Arrival → Execution → Position**, enabling
|
||||
controlled experimentation with demand models, inventory constraints, and reward shaping.
|
||||
|
||||
## Core Loop
|
||||
|
||||
1. **Quote** – the policy posts prices (one-sided or two-sided depending on the mechanism).
|
||||
2. **Arrival** – a population model generates purchase opportunities or market orders.
|
||||
3. **Execution** – an execution model decides whether an arrival converts at the quoted price.
|
||||
4. **Position** – inventory/position limits censor fills and generate holding/shortage costs.
|
||||
5. **Observation & Reward** – censored fills and aggregate metrics are exposed to the agent, while
|
||||
objectives turn metrics into a scalar reward.
|
||||
|
||||
Each stage is pluggable via light-weight protocols so you can swap in alternative mechanisms,
|
||||
demand models, or objectives without rewriting the rest of the simulator.
|
||||
|
||||
## Package Layout
|
||||
|
||||
| Module | Purpose |
|
||||
|-------------------|---------|
|
||||
| `lab.outlet` | Core simulation engine, domain types, pricing mechanisms, objectives. |
|
||||
| `lab.population` | Demand arrival models, execution probability models, competitor/market dynamics. |
|
||||
| `lab.experiments` | Rollout utilities, baseline policies, and off-policy evaluation helpers. |
|
||||
| `lab.config` | Convenience factories for preconfigured retail and market-making environments. |
|
||||
|
||||
## Preconfigured Scenarios
|
||||
|
||||
### Retail Dynamic Pricing
|
||||
- Mechanism: posted prices with margin and delta constraints.
|
||||
- Arrivals: browsing sessions with contamination support (scrapers).
|
||||
- Execution: elasticity model with competitor cross-effects.
|
||||
- Position: inventory tracking with holding and shortage costs.
|
||||
- Market: reactive competitor that can trigger price wars.
|
||||
- Objective: PnL minus volatility, holding cost, and lost opportunity penalties.
|
||||
|
||||
```python
|
||||
from lab.config import make_retail_platform
|
||||
from lab.experiments import rollout, fixed_price_policy
|
||||
|
||||
platform = make_retail_platform()
|
||||
policy = fixed_price_policy(platform.instruments.refs)
|
||||
result = rollout(platform, policy, n_steps=100)
|
||||
print(result.total_pnl)
|
||||
```
|
||||
|
||||
### Market Making
|
||||
- Mechanism: two-sided quoting with bid/ask spreads.
|
||||
- Arrivals: Hawkes order flow for clustered demand.
|
||||
- Execution: Avellaneda–Stoikov style intensity model.
|
||||
- Position: inventory risk limits and quadratic penalty objective.
|
||||
- Market: geometric Brownian motion mid-price process.
|
||||
- Objective: PnL plus spread capture minus inventory risk.
|
||||
|
||||
```python
|
||||
from lab.config import make_market_making_platform
|
||||
from lab.experiments import rollout
|
||||
|
||||
platform = make_market_making_platform()
|
||||
mm_policy = lambda obs, t: (platform.instruments.refs, 1.0)
|
||||
result = rollout(platform, mm_policy, n_steps=200, seed=42)
|
||||
print(result.total_pnl)
|
||||
```
|
||||
|
||||
## Extending the Simulator
|
||||
|
||||
- Implement `lab.outlet.protocols.Mechanism` or `ArrivalModel` to introduce new pricing
|
||||
domains or demand processes.
|
||||
- Compose objectives with `lab.outlet.objectives.factory.make_composite` to study alternate
|
||||
reward formulations.
|
||||
- Use `lab.experiments.compare_policies` to benchmark candidate policies across multiple
|
||||
random seeds.
|
||||
|
||||
Comprehensive API documentation lives in `lab/docs` (build with `make html`).
|
||||
27
lab/__init__.py
Normal file
27
lab/__init__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
Quote-Control Simulator: Research-grade platform for dynamic pricing and market making
|
||||
|
||||
The platform abstracts pricing as: Quote -> Arrival -> Execution -> Position
|
||||
Supports multiple mechanisms:
|
||||
- PostedPrice: retail dynamic pricing
|
||||
- TwoSided: market making with bid-ask spreads
|
||||
- Auction: reserve/shading for auction settings
|
||||
|
||||
Example usage:
|
||||
from lab.config import make_retail_platform
|
||||
from lab.experiments import rollout, fixed_price_policy
|
||||
|
||||
platform = make_retail_platform()
|
||||
policy = fixed_price_policy(platform.instruments.refs)
|
||||
result = rollout(platform, policy, n_steps=100)
|
||||
print(f"Total PnL: {result.total_pnl:.2f}")
|
||||
"""
|
||||
|
||||
from .config import make_retail_platform, make_market_making_platform, RetailConfig, MarketMakingConfig
|
||||
from .outlet import Platform, PlatformConfig, Quote, Observation, StepResult
|
||||
|
||||
__all__ = [
|
||||
'make_retail_platform', 'make_market_making_platform',
|
||||
'RetailConfig', 'MarketMakingConfig',
|
||||
'Platform', 'PlatformConfig', 'Quote', 'Observation', 'StepResult',
|
||||
]
|
||||
6
lab/case/__init__.py
Normal file
6
lab/case/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Case studies implementing specific research scenarios.
|
||||
|
||||
Available cases:
|
||||
- thesis: PHANTOM thesis implementation with contaminated demand and DR-RL
|
||||
"""
|
||||
25
lab/case/thesis/__init__.py
Normal file
25
lab/case/thesis/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Thesis-specific implementation of the PHANTOM pricing defense framework.
|
||||
|
||||
This module implements the mathematical models from the thesis:
|
||||
- ContaminatedArrivalModel: Mixture demand Q(p) = (1-α)d_H + αd_A (Eq 3)
|
||||
- HybridExecutionModel: Divergent H/A behavior with separability (Section 2.1)
|
||||
- RobustStackelbergObjective: Maximin objective with COI penalty (Eq 23)
|
||||
- COIMetrics: Cost of Information tracking (Definition 1)
|
||||
|
||||
The platform configuration creates a research environment that directly
|
||||
maps to the thesis mathematical framework for DR-RL experiments.
|
||||
"""
|
||||
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
|
||||
from .execution import HybridExecutionModel, HybridExecutionConfig
|
||||
from .objectives import RobustStackelbergObjective, COIObjective
|
||||
from .platform import make_thesis_platform, ThesisConfig
|
||||
from .metrics import COIMetrics, compute_coi, compute_separability
|
||||
|
||||
__all__ = [
|
||||
'ContaminatedArrivalModel', 'ContaminatedArrivalConfig',
|
||||
'HybridExecutionModel', 'HybridExecutionConfig',
|
||||
'RobustStackelbergObjective', 'COIObjective',
|
||||
'make_thesis_platform', 'ThesisConfig',
|
||||
'COIMetrics', 'compute_coi', 'compute_separability',
|
||||
]
|
||||
327
lab/case/thesis/arrivals.py
Normal file
327
lab/case/thesis/arrivals.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""Contaminated arrivals using learned MDP kernels from behavior_loader.
|
||||
|
||||
Implements thesis demand model (Section 3.1):
|
||||
- Aggregate demand Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3)
|
||||
- Demand proxy q̂_{t,i} = Σ_s Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] (Eq 2)
|
||||
- Per-session separability via KL divergence Δ_H, Δ_A (Eq 20-21)
|
||||
|
||||
The arrival model samples sessions from a mixture of human/agent behavioral profiles,
|
||||
each session produces a trajectory τ_s and associated demand computation q(τ').
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from types import SimpleNamespace
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
import numpy as np
|
||||
from ...outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState
|
||||
from ...outlet.constants import Side, OpportunityType
|
||||
from ...outlet.math_util import poisson_arrivals
|
||||
|
||||
try:
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
from sim.rl.behavior_loader.models import (
|
||||
BehaviorModel, AgentBehaviorModel, aggregate_event_transitions, kl_divergence
|
||||
)
|
||||
REAL_MDP = True
|
||||
except ImportError:
|
||||
REAL_MDP = False
|
||||
kl_divergence = None
|
||||
|
||||
EVENT_PAGE = {"session_start": "/", "view_item_page": "/products", "learn_more_about_item": "/products/details",
|
||||
"add_item_to_cart": "/cart", "purchase_complete": "/checkout", "session_end": "/checkout/success"}
|
||||
EVENT_CANON = {"page_view": "session_start", "hover_over_paragraph": "view_item_page", "hover_over_title": "view_item_page",
|
||||
"view_item_page": "view_item_page", "learn_more_about_item": "learn_more_about_item",
|
||||
"add_item_to_cart": "add_item_to_cart", "checkout_start": "purchase_complete", "remove_item": "view_item_page"}
|
||||
|
||||
# action space partition A = A_nav ∪ A_cart ∪ A_filter ∪ A_dwell with signal weights ω (Table 1)
|
||||
ACTION_WEIGHTS: Dict[str, float] = {
|
||||
"add_item_to_cart": 0.8, "remove_item": 0.6, "checkout_start": 0.9, "purchase_complete": 1.0, # A_cart
|
||||
"hover_over_title": 0.3, "hover_over_paragraph": 0.35, "hover_over_link": 0.25, # A_dwell
|
||||
"page_view": 0.1, "session_start": 0.05, "view_item_page": 0.15, "learn_more_about_item": 0.2, # A_nav
|
||||
"search": 0.05, "filter_date": 0.05, "filter_price": 0.08, "sort": 0.03, "session_end": 0.0, # A_filter
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionDemand:
|
||||
"""Per-session demand computation per thesis formulation (Section 3.1).
|
||||
|
||||
Each session s ∈ S produces trajectory τ_s and demand proxy q̂. The platform uses
|
||||
divergence signals Δ_H, Δ_A to estimate per-session contamination α̂(τ').
|
||||
"""
|
||||
session_id: str
|
||||
q: Dict[int, float] # q̂_i demand proxy per product (Eq 2)
|
||||
trajectory: List[Dict] # τ_s = (e_{s,1}, ..., e_{s,L_s})
|
||||
delta_h: float = 0.0 # D_KL(T̂' || T̄_H) (Eq 20)
|
||||
delta_a: float = 0.0 # D_KL(T̂' || T̄_A) (Eq 21)
|
||||
alpha_hat: float = 0.0 # per-session contamination estimate
|
||||
actor_class: str = "H" # ground truth Y_s ∈ {H, A}
|
||||
theta: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def compute_demand_proxy(events: List[Dict], n_products: int) -> Dict[int, float]:
|
||||
"""Compute q̂_{t,i} = Σ_k ω(a_{s,k}) · 1[i_{s,k} = i] per Eq 2."""
|
||||
q = {i: 0.0 for i in range(n_products)}
|
||||
for e in events:
|
||||
action, pidx = e.get("eventName", ""), e.get("product_idx")
|
||||
if pidx is not None and 0 <= pidx < n_products:
|
||||
q[pidx] += ACTION_WEIGHTS.get(action, 0.1)
|
||||
return q
|
||||
|
||||
|
||||
def compute_session_divergence(events: List[Dict], ref_h: Dict, ref_a: Dict) -> Tuple[float, float]:
|
||||
"""Compute Δ_H, Δ_A divergence signals from trajectory (Eq 20-21)."""
|
||||
if not events or kl_divergence is None:
|
||||
return 0.0, 0.0
|
||||
# build empirical transition kernel from trajectory
|
||||
trans: Dict[str, Dict[str, int]] = {}
|
||||
prev = "session_start"
|
||||
for e in events:
|
||||
curr = e.get("eventName", "session_end")
|
||||
trans.setdefault(prev, {})
|
||||
trans[prev][curr] = trans[prev].get(curr, 0) + 1
|
||||
prev = curr
|
||||
# normalize to probabilities
|
||||
kernel = {}
|
||||
for s, dests in trans.items():
|
||||
total = sum(dests.values())
|
||||
kernel[s] = {d: c / total for d, c in dests.items()} if total > 0 else {}
|
||||
# aggregate to event-level and compute KL divergence against reference kernels
|
||||
delta_h = sum(kl_divergence(kernel.get(s, {}), ref_h.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
||||
delta_a = sum(kl_divergence(kernel.get(s, {}), ref_a.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
||||
return delta_h, delta_a
|
||||
|
||||
def _canonicalize(raw: Dict) -> Dict:
|
||||
out = {}
|
||||
for src, dsts in raw.items():
|
||||
sc = EVENT_CANON.get(src, src)
|
||||
out.setdefault(sc, {})
|
||||
for dst, p in dsts.items():
|
||||
dc = EVENT_CANON.get(dst, dst)
|
||||
out[sc][dc] = out[sc].get(dc, 0.0) + p
|
||||
return {s: {k: v/sum(d.values()) for k, v in d.items()} for s, d in out.items() if sum(d.values()) > 0}
|
||||
|
||||
|
||||
class BehavioralProfile:
|
||||
"""Markov profile from learned MDP kernels (Section 3.5.2).
|
||||
|
||||
Transition kernel T̂_Y estimated via MLE: P̂(s'|s) = N(s,s') / Σ_k N(s,k) (Eq 19)
|
||||
"""
|
||||
STATES = ["session_start", "view_item_page", "learn_more_about_item", "add_item_to_cart", "purchase_complete", "session_end"]
|
||||
# fallback kernels T̄_H, T̄_A when real data unavailable
|
||||
FALLBACK_H = {"session_start": {"view_item_page": 0.85, "session_end": 0.15},
|
||||
"view_item_page": {"learn_more_about_item": 0.4, "add_item_to_cart": 0.3, "view_item_page": 0.2, "session_end": 0.1},
|
||||
"learn_more_about_item": {"add_item_to_cart": 0.5, "view_item_page": 0.3, "session_end": 0.2},
|
||||
"add_item_to_cart": {"purchase_complete": 0.6, "view_item_page": 0.25, "session_end": 0.15},
|
||||
"purchase_complete": {"session_end": 1.0}}
|
||||
FALLBACK_A = {"session_start": {"view_item_page": 0.95, "session_end": 0.05},
|
||||
"view_item_page": {"learn_more_about_item": 0.6, "view_item_page": 0.25, "add_item_to_cart": 0.1, "session_end": 0.05},
|
||||
"learn_more_about_item": {"view_item_page": 0.5, "add_item_to_cart": 0.15, "learn_more_about_item": 0.3, "session_end": 0.05},
|
||||
"add_item_to_cart": {"view_item_page": 0.4, "purchase_complete": 0.2, "session_end": 0.4},
|
||||
"purchase_complete": {"session_end": 1.0}}
|
||||
|
||||
def __init__(self, actor: str, pprobs: np.ndarray, data_dir: str = ""):
|
||||
self.actor, self.pprobs = actor, np.clip(pprobs, 0.0, 0.95)
|
||||
self.trans = self._load(data_dir) # T̂_Y transition kernel
|
||||
self._ensure_terminal()
|
||||
self.dwell = {s: (1.2, 0.5) if actor == "agents" else (2.0, 1.2) for s in self.STATES}
|
||||
|
||||
def _load(self, data_dir: str) -> Dict:
|
||||
if not REAL_MDP or not data_dir:
|
||||
print("using fallback")
|
||||
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
||||
try:
|
||||
mdp = (AgentBehaviorModel if self.actor == "agents" else BehaviorModel)(data_dir).build_MDP()
|
||||
raw = aggregate_event_transitions(mdp) if mdp.get("transitions") else {}
|
||||
return _canonicalize(raw) if raw else dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
||||
except Exception:
|
||||
print("using fallback")
|
||||
return dict(self.FALLBACK_A if self.actor == "agents" else self.FALLBACK_H)
|
||||
|
||||
def _ensure_terminal(self):
|
||||
self.trans.setdefault("purchase_complete", {})["session_end"] = self.trans.get("purchase_complete", {}).get("session_end", 1.0)
|
||||
self.trans.setdefault("session_start", {"view_item_page": 0.7, "learn_more_about_item": 0.2, "session_end": 0.1})
|
||||
|
||||
def _tprobs(self, state: str, pidx: int) -> Dict[str, float]:
|
||||
probs = dict(self.trans.get(state, {"session_end": 1.0}))
|
||||
if state == "add_item_to_cart":
|
||||
base = probs.get("purchase_complete", 0.0)
|
||||
df = float(self.pprobs[pidx]) * (0.3 if self.actor == "agents" else 1.0)
|
||||
adj = np.clip(base * 0.5 + df * 0.5, 0.0, 0.95)
|
||||
rem = max(1e-6, 1.0 - adj)
|
||||
other = sum(v for k, v in probs.items() if k != "purchase_complete")
|
||||
probs = {k: (adj if k == "purchase_complete" else v * rem / max(other, 1e-6)) for k, v in probs.items()}
|
||||
total = sum(probs.values())
|
||||
return {k: v/total for k, v in probs.items()} if total > 0 else {"session_end": 1.0}
|
||||
|
||||
def sample(self, rng: np.random.Generator, sid: str, prices: np.ndarray, costs: np.ndarray) -> Tuple[List[Dict], List[SimpleNamespace]]:
|
||||
events, fevts = [], []
|
||||
state, t, pidx = "session_start", 0.0, int(rng.integers(0, len(prices)))
|
||||
cost, cprice = float(costs[pidx]), max(float(prices[pidx]), float(costs[pidx]) * 1.05)
|
||||
|
||||
while state != "session_end" and len(events) < 40:
|
||||
if state != "session_start":
|
||||
row = {"session_id": sid, "actor": "agent" if self.actor == "agents" else "human",
|
||||
"eventName": state, "product_idx": pidx, "productId": f"product-{pidx:04d}",
|
||||
"price_offered": cprice, "price_paid": 0.0, "page": EVENT_PAGE.get(state, "/"),
|
||||
"ts": t, "unit_cost": cost, "base_price": float(prices[pidx])}
|
||||
if state == "purchase_complete":
|
||||
row["price_paid"] = max(cprice * (1.0 + rng.normal(0.0, 0.015)), cost)
|
||||
events.append(row)
|
||||
fevts.append(SimpleNamespace(eventName=state, page=row["page"], productId=row["productId"], ts=t))
|
||||
|
||||
probs = self._tprobs(state, pidx)
|
||||
state = rng.choice(list(probs.keys()), p=list(probs.values()))
|
||||
sh, sc = self.dwell.get(state, (2.0, 1.0))
|
||||
t += max(0.3, rng.gamma(shape=sh, scale=sc))
|
||||
return events, fevts
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContaminatedArrivalConfig:
|
||||
base_rate: float = 20.0
|
||||
alpha_contamination: float = 0.2
|
||||
alpha_drift: float = 0.0
|
||||
alpha_bounds: tuple[float, float] = (0.0, 0.5)
|
||||
human_views_range: tuple[int, int] = (1, 4)
|
||||
agent_views_range: tuple[int, int] = (3, 10)
|
||||
agent_systematic: bool = True
|
||||
use_real_behavior: bool = True
|
||||
human_data_dir: str = ""
|
||||
agent_data_dir: str = ""
|
||||
|
||||
|
||||
class ContaminatedArrivalModel:
|
||||
"""Mixture model Q(p) = (1-α)E[d(p;θ_H)] + αE[d(p;θ_A)] + ε_t (Eq 3).
|
||||
|
||||
Samples sessions from human/agent behavioral profiles, computes per-session
|
||||
demand proxy q̂ and divergence signals Δ_H, Δ_A for separability.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ContaminatedArrivalConfig | None = None):
|
||||
self.cfg = cfg or ContaminatedArrivalConfig()
|
||||
self._alpha = self.cfg.alpha_contamination
|
||||
self._scount = 0
|
||||
self._profiles: Dict[str, BehavioralProfile] = {}
|
||||
self._ref_kernels: Dict[str, Dict] = {} # T̄_H, T̄_A reference kernels
|
||||
self._session_demands: List[SessionDemand] = [] # collected session demands
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
return self._alpha
|
||||
|
||||
def _profile(self, actor: str, pprobs: np.ndarray) -> BehavioralProfile:
|
||||
key = actor
|
||||
if key not in self._profiles:
|
||||
ddir = self.cfg.agent_data_dir if actor == "agents" else self.cfg.human_data_dir
|
||||
if not ddir and self.cfg.use_real_behavior:
|
||||
base = Path(__file__).parent.parent.parent.parent / "experiments"
|
||||
ddir = str(base / ("agents/collected_data" if actor == "agents" else "collected_data"))
|
||||
profile = BehavioralProfile(actor, pprobs, ddir if self.cfg.use_real_behavior else "")
|
||||
self._profiles[key] = profile
|
||||
self._ref_kernels[key] = profile.trans # cache T̄_Y for divergence
|
||||
return self._profiles[key]
|
||||
|
||||
def get_ref_kernels(self) -> Tuple[Dict, Dict]:
|
||||
"""Return reference transition kernels T̄_H, T̄_A for divergence computation."""
|
||||
return (self._ref_kernels.get("humans", BehavioralProfile.FALLBACK_H),
|
||||
self._ref_kernels.get("agents", BehavioralProfile.FALLBACK_A))
|
||||
|
||||
def get_session_demands(self) -> List[SessionDemand]:
|
||||
"""Return collected session demands for downstream analysis."""
|
||||
return self._session_demands
|
||||
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
|
||||
"""Sample arrivals as per Eq 3: mixture of human/agent demand distributions.
|
||||
|
||||
For each session s, computes:
|
||||
- Trajectory τ_s from behavioral profile sampling
|
||||
- Demand proxy q̂ via weighted action aggregation (Eq 2)
|
||||
- Divergence signals Δ_H, Δ_A for separability (Eq 20-21)
|
||||
- Per-session contamination estimate α̂(τ')
|
||||
"""
|
||||
cfg = self.cfg
|
||||
if cfg.alpha_drift != 0:
|
||||
self._alpha = np.clip(self._alpha + cfg.alpha_drift * rng.normal(), *cfg.alpha_bounds)
|
||||
hidden.contamination = self._alpha
|
||||
|
||||
n_sess = poisson_arrivals(cfg.base_rate * hidden.true_demand_intensity, dt, rng)
|
||||
prices, costs = instruments.refs, instruments.costs
|
||||
margin = np.clip((prices - costs) / np.maximum(costs, 1e-3), -0.9, 2.0)
|
||||
hprob, aprob = 0.08 * np.exp(-1.2 * margin), 0.05 * np.exp(-0.6 * margin)
|
||||
ref_h, ref_a = self.get_ref_kernels()
|
||||
|
||||
opps = []
|
||||
for _ in range(n_sess):
|
||||
self._scount += 1
|
||||
sid = f"s{self._scount:06d}"
|
||||
is_agent = rng.random() < self._alpha
|
||||
actor, probs = ("agents", aprob) if is_agent else ("humans", hprob)
|
||||
profile = self._profile(actor, probs)
|
||||
events, fevts = profile.sample(rng, sid, prices, costs)
|
||||
|
||||
# compute demand proxy q̂ per Eq 2
|
||||
q = compute_demand_proxy(events, instruments.n)
|
||||
|
||||
# compute divergence signals Δ_H, Δ_A per Eq 20-21
|
||||
delta_h, delta_a = compute_session_divergence(events, ref_h, ref_a)
|
||||
# per-session contamination estimate α̂(τ') = σ(β(Δ_H - Δ_A))
|
||||
alpha_hat = 1.0 / (1.0 + np.exp(-2.0 * (delta_h - delta_a))) if (delta_h + delta_a) > 0 else 0.5
|
||||
|
||||
theta = ({'price_sensitivity': rng.uniform(0.05, 0.2), 'base_conversion': 0.01, 'info_value': 1.0} if is_agent
|
||||
else {'price_sensitivity': rng.uniform(1.5, 4.0), 'base_conversion': rng.uniform(0.2, 0.5), 'info_value': 0.0})
|
||||
|
||||
# store session demand for downstream analysis
|
||||
self._session_demands.append(SessionDemand(
|
||||
session_id=sid, q=q, trajectory=events, delta_h=delta_h, delta_a=delta_a,
|
||||
alpha_hat=alpha_hat, actor_class="A" if is_agent else "H", theta=theta))
|
||||
|
||||
viewed = list({e["product_idx"] for e in events if "product_idx" in e})
|
||||
if not viewed:
|
||||
vr = cfg.agent_views_range if is_agent else cfg.human_views_range
|
||||
viewed = list(rng.choice(instruments.n, size=min(rng.integers(*vr), instruments.n), replace=False))
|
||||
|
||||
for vi, iid in enumerate(viewed):
|
||||
opps.append(Opportunity(
|
||||
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
|
||||
instrument_id=int(iid), size=1.0, t=t + rng.uniform(0, dt),
|
||||
context={'session_id': sid, 'actor_class': 'AGENT' if is_agent else 'HUMAN', 'is_agent': is_agent,
|
||||
'reconnaissance_intent': is_agent, 'view_index': vi, 'total_views': len(viewed),
|
||||
'theta': theta, 'trajectory_events': fevts, 'mdp_trajectory': events,
|
||||
'demand_proxy': q, 'alpha_hat': alpha_hat, 'delta_h': delta_h, 'delta_a': delta_a}))
|
||||
return opps
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdversarialArrivalConfig:
|
||||
base_rate: float = 5.0
|
||||
n_parallel_agents: int = 3
|
||||
query_all_products: bool = True
|
||||
|
||||
|
||||
class AdversarialArrivalModel:
|
||||
"""Adversarial coordination (Theorem 1): as N->inf, COI->0."""
|
||||
|
||||
def __init__(self, cfg: AdversarialArrivalConfig | None = None):
|
||||
self.cfg = cfg or AdversarialArrivalConfig()
|
||||
self._qcount = 0
|
||||
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState, rng: np.random.Generator) -> list[Opportunity]:
|
||||
cfg, opps = self.cfg, []
|
||||
for _ in range(poisson_arrivals(cfg.base_rate, dt, rng)):
|
||||
self._qcount += 1
|
||||
for ai in range(cfg.n_parallel_agents):
|
||||
sid = f"adv{self._qcount:06d}-{ai}"
|
||||
prods = np.arange(instruments.n) if cfg.query_all_products else rng.choice(instruments.n, size=1)
|
||||
for iid in prods:
|
||||
opps.append(Opportunity(
|
||||
id=f"{sid}-{iid}", type=OpportunityType.SESSION, side=Side.BUY,
|
||||
instrument_id=int(iid), size=1.0, t=t,
|
||||
context={'session_id': sid, 'actor_class': 'AGENT', 'is_agent': True, 'adversarial': True,
|
||||
'agent_index': ai, 'query_group': self._qcount,
|
||||
'theta': {'price_sensitivity': 0.0, 'base_conversion': 0.0, 'info_value': 1.0}}))
|
||||
return opps
|
||||
378
lab/case/thesis/coi.py
Normal file
378
lab/case/thesis/coi.py
Normal file
@@ -0,0 +1,378 @@
|
||||
"""Cost of Information (COI) computation for thesis pricing simulation.
|
||||
|
||||
Implements the corrected COI formulation:
|
||||
|
||||
COI = E[p] - p
|
||||
|
||||
where:
|
||||
- E[p] = expected price BEFORE information revelation (window start price)
|
||||
- p = actual transaction price (price at which sales occur)
|
||||
|
||||
The fundamental insight is that COI should measure PRICE EROSION over time,
|
||||
not instantaneous margin leakage. When agents explore across sessions:
|
||||
1. They reveal demand signals that drive platform price adjustments
|
||||
2. Coordinated agents can find the minimum price across their session pool
|
||||
3. The price path from window start to transaction captures information leakage
|
||||
|
||||
Key components:
|
||||
- COIWindow: Windowed price erosion measurement over K steps
|
||||
- compute_coi_window: Per-episode COI from session-level transactions
|
||||
- coi_erosion: Order statistic erosion (Theorem 1: N agents -> min price)
|
||||
|
||||
This fixes the fundamental error of treating COI as instantaneous margin × alpha.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, TYPE_CHECKING
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .simplified import Session
|
||||
|
||||
EPS = 1e-10
|
||||
|
||||
|
||||
@dataclass
|
||||
class COIWindow:
|
||||
"""Windowed COI measurement capturing price erosion over time.
|
||||
|
||||
Attributes:
|
||||
policy: Platform's intended COI (prices at window start - cost)
|
||||
agent: Realized COI for agents (prices at transaction - cost)
|
||||
leak: COI leakage = policy - agent (price erosion due to exploration)
|
||||
survival_ratio: Fraction of intended COI that survives (agent/policy)
|
||||
policy_by_product: Per-product policy COI
|
||||
agent_by_product: Per-product agent COI
|
||||
demand_weights: Demand weights used for aggregation
|
||||
"""
|
||||
policy: float = 0.0 # E[p] - c at window start
|
||||
agent: float = 0.0 # p_transaction - c
|
||||
leak: float = 0.0 # policy - agent = price erosion
|
||||
survival_ratio: float = 1.0 # agent / policy
|
||||
policy_by_product: np.ndarray = field(default_factory=lambda: np.zeros(1))
|
||||
agent_by_product: np.ndarray = field(default_factory=lambda: np.zeros(1))
|
||||
demand_weights: np.ndarray = field(default_factory=lambda: np.zeros(1))
|
||||
|
||||
def to_dict(self) -> Dict[str, float]:
|
||||
return {
|
||||
'coi_policy': self.policy,
|
||||
'coi_agent': self.agent,
|
||||
'coi_leak': self.leak,
|
||||
'coi_survival': self.survival_ratio,
|
||||
}
|
||||
|
||||
|
||||
def compute_coi_window(
|
||||
sessions: List["Session"],
|
||||
costs: np.ndarray,
|
||||
demand_mapping: Dict[str, float] = None,
|
||||
window_prices: np.ndarray = None,
|
||||
) -> COIWindow:
|
||||
"""Compute COI from session data using the corrected formulation.
|
||||
|
||||
COI = E[p_start] - p_transaction
|
||||
|
||||
This measures how much the platform's pricing power eroded during the window.
|
||||
Price at window start represents E[p] (what we expected to charge).
|
||||
Transaction prices represent p (what we actually charged).
|
||||
|
||||
Args:
|
||||
sessions: List of sessions with events containing price_seen and purchases
|
||||
costs: Product costs array
|
||||
demand_mapping: Optional session_id -> demand proxy mapping
|
||||
window_prices: Optional explicit window start prices (otherwise use first seen)
|
||||
|
||||
Returns:
|
||||
COIWindow with erosion metrics
|
||||
"""
|
||||
if not sessions:
|
||||
n = len(costs)
|
||||
zeros = np.zeros(n)
|
||||
return COIWindow(policy=0.0, agent=0.0, leak=0.0, survival_ratio=1.0,
|
||||
policy_by_product=zeros, agent_by_product=zeros, demand_weights=zeros)
|
||||
|
||||
n = len(costs)
|
||||
demand_mapping = demand_mapping or {}
|
||||
|
||||
# Track prices seen at start (E[p]) and transaction prices (p)
|
||||
first_prices = np.zeros(n) # first price seen per product (window start proxy)
|
||||
transaction_prices = np.zeros(n) # prices at which purchases occurred
|
||||
transaction_counts = np.zeros(n)
|
||||
view_counts = np.zeros(n)
|
||||
demand_weights = np.zeros(n)
|
||||
|
||||
for sess in sessions:
|
||||
sid = sess.sid
|
||||
sess_demand = demand_mapping.get(sid, 1.0)
|
||||
|
||||
for e in sess.events:
|
||||
pidx = e.product_idx
|
||||
if pidx < 0 or pidx >= n:
|
||||
continue
|
||||
|
||||
price_seen = float(e.price_seen)
|
||||
|
||||
# Track first price seen (proxy for E[p] at window start)
|
||||
if view_counts[pidx] == 0:
|
||||
first_prices[pidx] = price_seen
|
||||
view_counts[pidx] += 1
|
||||
|
||||
# Track transaction prices
|
||||
if e.action == "purchase":
|
||||
transaction_prices[pidx] += price_seen
|
||||
transaction_counts[pidx] += 1
|
||||
demand_weights[pidx] += sess_demand
|
||||
|
||||
# Compute per-product COI
|
||||
# Policy COI: what we intended to charge (first seen price - cost)
|
||||
policy_by_product = np.zeros(n)
|
||||
agent_by_product = np.zeros(n)
|
||||
|
||||
for i in range(n):
|
||||
if view_counts[i] > 0:
|
||||
# Use explicit window prices if provided, else first seen
|
||||
start_price = window_prices[i] if window_prices is not None else first_prices[i]
|
||||
policy_by_product[i] = max(0, start_price - costs[i])
|
||||
|
||||
if transaction_counts[i] > 0:
|
||||
avg_transaction = transaction_prices[i] / transaction_counts[i]
|
||||
agent_by_product[i] = max(0, avg_transaction - costs[i])
|
||||
|
||||
# Aggregate with demand weighting
|
||||
total_demand = np.sum(demand_weights) + EPS
|
||||
weights = demand_weights / total_demand
|
||||
|
||||
# Only count products with transactions for fair comparison
|
||||
active_mask = transaction_counts > 0
|
||||
if np.any(active_mask):
|
||||
policy = float(np.sum(policy_by_product[active_mask] * weights[active_mask]) /
|
||||
(np.sum(weights[active_mask]) + EPS))
|
||||
agent = float(np.sum(agent_by_product[active_mask] * weights[active_mask]) /
|
||||
(np.sum(weights[active_mask]) + EPS))
|
||||
else:
|
||||
# No transactions - use view-weighted policy COI
|
||||
view_weights = view_counts / (np.sum(view_counts) + EPS)
|
||||
policy = float(np.sum(policy_by_product * view_weights))
|
||||
agent = policy # No erosion without transactions
|
||||
|
||||
# Leak = price erosion due to information revelation
|
||||
leak = max(0, policy - agent)
|
||||
survival = agent / (policy + EPS) if policy > EPS else 1.0
|
||||
|
||||
return COIWindow(
|
||||
policy=policy,
|
||||
agent=agent,
|
||||
leak=leak,
|
||||
survival_ratio=float(np.clip(survival, 0, 1)),
|
||||
policy_by_product=policy_by_product,
|
||||
agent_by_product=agent_by_product,
|
||||
demand_weights=demand_weights,
|
||||
)
|
||||
|
||||
|
||||
def coi_erosion(policy_coi: float, agent_coi: float) -> float:
|
||||
"""Compute COI erosion rate: (policy - agent) / policy.
|
||||
|
||||
Returns the fraction of intended COI that was lost to information leakage.
|
||||
0 = no erosion, 1 = complete erosion.
|
||||
"""
|
||||
if policy_coi < EPS:
|
||||
return 0.0
|
||||
return float(np.clip((policy_coi - agent_coi) / policy_coi, 0, 1))
|
||||
|
||||
|
||||
def order_statistic_erosion(n_agents: int, price_std: float, base_margin: float = 1.0) -> float:
|
||||
"""Compute COI erosion from order statistic effect (Theorem 1).
|
||||
|
||||
When N agents independently query prices:
|
||||
- Each sees a price p_i ~ N(μ, σ²)
|
||||
- They coordinate to buy at min(p_1, ..., p_N)
|
||||
- Expected minimum: μ - σ * E[order_stat]
|
||||
|
||||
As N -> ∞, E[min] -> p_min, so COI -> 0.
|
||||
|
||||
This quantifies the price discovery benefit of multiple sessions.
|
||||
|
||||
Args:
|
||||
n_agents: Number of independent agent sessions
|
||||
price_std: Standard deviation of price distribution
|
||||
base_margin: Expected margin (μ - cost)
|
||||
|
||||
Returns:
|
||||
Erosion rate in [0, 1]
|
||||
"""
|
||||
if n_agents <= 1 or price_std < EPS:
|
||||
return 0.0
|
||||
|
||||
# For standard normal order statistics, E[min of N] ≈ -Φ^{-1}(1/(N+1))
|
||||
# For large N, this grows like sqrt(2 * log(N))
|
||||
log_n = np.log(n_agents)
|
||||
if log_n < 0.1:
|
||||
return 0.0
|
||||
|
||||
# Extreme value theory: expected min shift
|
||||
shift = price_std * (np.sqrt(2 * log_n) -
|
||||
(np.log(log_n) + np.log(4 * np.pi)) / (2 * np.sqrt(2 * log_n) + EPS))
|
||||
|
||||
# Erosion = shift / base_margin, capped at 1
|
||||
return float(np.clip(shift / (base_margin + EPS), 0, 1))
|
||||
|
||||
|
||||
@dataclass
|
||||
class COITracker:
|
||||
"""Track COI over multiple windows for temporal analysis.
|
||||
|
||||
This addresses the user's insight: compute COI over K episodes to see
|
||||
how prices change from window start to end.
|
||||
|
||||
If at start of window price is A and by end it's B, the difference
|
||||
A - B represents COI leakage from exploratory sessions.
|
||||
"""
|
||||
window_size: int = 10 # K episodes per window
|
||||
_price_history: List[np.ndarray] = field(default_factory=list)
|
||||
_transaction_history: List[np.ndarray] = field(default_factory=list)
|
||||
_coi_history: List[float] = field(default_factory=list)
|
||||
|
||||
def add_step(self, prices: np.ndarray, transactions: np.ndarray = None):
|
||||
"""Record price observation for current step."""
|
||||
self._price_history.append(prices.copy())
|
||||
if transactions is not None:
|
||||
self._transaction_history.append(transactions.copy())
|
||||
|
||||
def compute_window_coi(self, costs: np.ndarray) -> float:
|
||||
"""Compute COI over the current window.
|
||||
|
||||
COI = E[p_start] - E[p_end] for the window.
|
||||
This captures price erosion due to information revelation.
|
||||
"""
|
||||
if len(self._price_history) < 2:
|
||||
return 0.0
|
||||
|
||||
# Get prices at window boundaries
|
||||
window_start = max(0, len(self._price_history) - self.window_size)
|
||||
start_prices = self._price_history[window_start]
|
||||
end_prices = self._price_history[-1]
|
||||
|
||||
# COI = (start_price - cost) - (end_price - cost) = start_price - end_price
|
||||
start_margin = np.mean(start_prices - costs)
|
||||
end_margin = np.mean(end_prices - costs)
|
||||
|
||||
coi = max(0, start_margin - end_margin)
|
||||
self._coi_history.append(coi)
|
||||
return coi
|
||||
|
||||
def get_cumulative_erosion(self, costs: np.ndarray) -> float:
|
||||
"""Compute total COI erosion from first observation to now."""
|
||||
if len(self._price_history) < 2:
|
||||
return 0.0
|
||||
|
||||
initial = np.mean(self._price_history[0] - costs)
|
||||
current = np.mean(self._price_history[-1] - costs)
|
||||
return max(0, initial - current)
|
||||
|
||||
def get_erosion_trend(self) -> float:
|
||||
"""Get average COI per window (erosion rate)."""
|
||||
if not self._coi_history:
|
||||
return 0.0
|
||||
return float(np.mean(self._coi_history))
|
||||
|
||||
def reset(self):
|
||||
"""Reset tracker for new episode."""
|
||||
self._price_history.clear()
|
||||
self._transaction_history.clear()
|
||||
self._coi_history.clear()
|
||||
|
||||
|
||||
def compute_multi_session_coi(
|
||||
sessions: List["Session"],
|
||||
costs: np.ndarray,
|
||||
alpha: float,
|
||||
initial_prices: np.ndarray,
|
||||
) -> Dict[str, float]:
|
||||
"""Compute COI accounting for multi-session agent behavior.
|
||||
|
||||
This is the key fix for the fundamental error:
|
||||
- Agents use different sessions to gather information
|
||||
- Each session reveals price information
|
||||
- Coordinated agents find the minimum across their session pool
|
||||
|
||||
The COI is computed as:
|
||||
1. What platform intended to charge: initial_prices - costs
|
||||
2. What agents actually paid: min(prices seen across sessions) - costs
|
||||
3. Leak = (1) - (2)
|
||||
|
||||
Args:
|
||||
sessions: All sessions in the episode
|
||||
costs: Product costs
|
||||
alpha: Contamination level (fraction of agent sessions)
|
||||
initial_prices: Prices at episode start (E[p])
|
||||
|
||||
Returns:
|
||||
Dictionary with COI metrics
|
||||
"""
|
||||
n = len(costs)
|
||||
|
||||
# Separate agent and human sessions by ground truth label
|
||||
agent_sessions = [s for s in sessions if s.actor == "A"]
|
||||
human_sessions = [s for s in sessions if s.actor == "H"]
|
||||
|
||||
# Track prices seen by agents per product (for min finding)
|
||||
agent_prices_seen: Dict[int, List[float]] = {i: [] for i in range(n)}
|
||||
human_prices_paid: Dict[int, List[float]] = {i: [] for i in range(n)}
|
||||
|
||||
for sess in agent_sessions:
|
||||
for e in sess.events:
|
||||
if 0 <= e.product_idx < n:
|
||||
agent_prices_seen[e.product_idx].append(e.price_seen)
|
||||
|
||||
for sess in human_sessions:
|
||||
for e in sess.events:
|
||||
if 0 <= e.product_idx < n and e.action == "purchase":
|
||||
human_prices_paid[e.product_idx].append(e.price_seen)
|
||||
|
||||
# Compute COI components
|
||||
policy_coi = float(np.mean(initial_prices - costs)) # E[p] - c
|
||||
|
||||
# Agent COI: they find the minimum price via exploration
|
||||
agent_coi_by_product = np.zeros(n)
|
||||
for i in range(n):
|
||||
if agent_prices_seen[i]:
|
||||
min_price = min(agent_prices_seen[i])
|
||||
agent_coi_by_product[i] = max(0, min_price - costs[i])
|
||||
else:
|
||||
agent_coi_by_product[i] = initial_prices[i] - costs[i]
|
||||
|
||||
agent_coi = float(np.mean(agent_coi_by_product))
|
||||
|
||||
# Human COI: they pay whatever price is offered
|
||||
human_coi_by_product = np.zeros(n)
|
||||
for i in range(n):
|
||||
if human_prices_paid[i]:
|
||||
avg_price = np.mean(human_prices_paid[i])
|
||||
human_coi_by_product[i] = max(0, avg_price - costs[i])
|
||||
else:
|
||||
human_coi_by_product[i] = initial_prices[i] - costs[i]
|
||||
|
||||
human_coi = float(np.mean(human_coi_by_product))
|
||||
|
||||
# Total leak: weighted by contamination
|
||||
# Agents erode COI, humans pay full price
|
||||
realized_coi = (1 - alpha) * human_coi + alpha * agent_coi
|
||||
leak = policy_coi - realized_coi
|
||||
|
||||
# Order statistic effect: more agents = more erosion
|
||||
n_agents = len(agent_sessions)
|
||||
price_std = float(np.std(initial_prices))
|
||||
order_erosion = order_statistic_erosion(n_agents, price_std, policy_coi)
|
||||
|
||||
return {
|
||||
'policy_coi': policy_coi,
|
||||
'agent_coi': agent_coi,
|
||||
'human_coi': human_coi,
|
||||
'realized_coi': realized_coi,
|
||||
'leak': leak,
|
||||
'order_stat_erosion': order_erosion,
|
||||
'n_agent_sessions': n_agents,
|
||||
'n_human_sessions': len(human_sessions),
|
||||
'survival_ratio': realized_coi / (policy_coi + EPS) if policy_coi > EPS else 1.0,
|
||||
}
|
||||
91
lab/case/thesis/execution.py
Normal file
91
lab/case/thesis/execution.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Execution models with divergent H/A behavior using ground truth labels."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict
|
||||
import numpy as np
|
||||
from ...outlet.types import Opportunity, Quote, InstrumentSet, MarketState
|
||||
from ...outlet.math_util import sigmoid, safe_log, EPS
|
||||
|
||||
|
||||
@dataclass
|
||||
class HybridExecutionConfig:
|
||||
human_base_prob: float = 0.3
|
||||
human_elasticity: float = 2.5
|
||||
agent_conversion: float = 0.01
|
||||
cross_elasticity: float = 0.4
|
||||
quality_weight: float = 0.2
|
||||
use_separability: bool = False
|
||||
|
||||
|
||||
class HybridExecutionModel:
|
||||
"""Execution with divergent H/A behavior using ground truth labels."""
|
||||
|
||||
def __init__(self, cfg: HybridExecutionConfig | None = None):
|
||||
self.cfg = cfg or HybridExecutionConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
cfg, idx = self.cfg, int(opp.instrument_id)
|
||||
price, ref, cost = float(quote.prices[idx]), float(instruments.refs[idx]), float(instruments.costs[idx])
|
||||
ctx = opp.context
|
||||
theta = ctx.get('theta', {})
|
||||
is_agent = ctx.get('is_agent', False)
|
||||
|
||||
if is_agent:
|
||||
return cfg.agent_conversion * theta.get('base_conversion', 1.0)
|
||||
|
||||
# human logit discrete choice
|
||||
sens = theta.get('price_sensitivity', cfg.human_elasticity)
|
||||
base = theta.get('base_conversion', cfg.human_base_prob)
|
||||
u_price = -sens * safe_log(price / (ref + EPS))
|
||||
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
|
||||
u_quality = cfg.quality_weight * quality
|
||||
|
||||
u_comp = 0.0
|
||||
if market and market.competitor_quotes is not None:
|
||||
cp = market.competitor_quotes[idx]
|
||||
if cp < price:
|
||||
u_comp = -cfg.cross_elasticity * (price - cp) / ref
|
||||
|
||||
utility = safe_log(base / (1 - base + EPS)) + u_price + u_quality + u_comp
|
||||
return float(sigmoid(utility))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
if context is None:
|
||||
return fills / (self.cfg.human_base_prob + EPS)
|
||||
agent_frac = context.get('contamination', 0.0)
|
||||
return fills / (self.cfg.human_base_prob * (1 - agent_frac) + EPS)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeparableExecutionConfig:
|
||||
human_funnel: Dict[str, float] = None
|
||||
agent_funnel: Dict[str, float] = None
|
||||
|
||||
def __post_init__(self):
|
||||
self.human_funnel = self.human_funnel or {'view_to_detail': 0.4, 'detail_to_cart': 0.3, 'cart_to_purchase': 0.6}
|
||||
self.agent_funnel = self.agent_funnel or {'view_to_detail': 0.8, 'detail_to_cart': 0.05, 'cart_to_purchase': 0.1}
|
||||
|
||||
|
||||
class SeparableExecutionModel:
|
||||
"""Execution with Markov funnel kernels using ground truth labels."""
|
||||
|
||||
def __init__(self, cfg: SeparableExecutionConfig | None = None):
|
||||
self.cfg = cfg or SeparableExecutionConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
is_agent = opp.context.get('is_agent', False)
|
||||
probs = self.cfg.agent_funnel if is_agent else self.cfg.human_funnel
|
||||
p = probs['view_to_detail'] * probs['detail_to_cart'] * probs['cart_to_purchase']
|
||||
|
||||
if not is_agent:
|
||||
idx = int(opp.instrument_id)
|
||||
price_ratio = quote.prices[idx] / (instruments.refs[idx] + EPS)
|
||||
p *= np.exp(-0.5 * (price_ratio - 1.0))
|
||||
return float(np.clip(p, 0, 1))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet, context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
h = self.cfg.human_funnel
|
||||
exp_conv = h['view_to_detail'] * h['detail_to_cart'] * h['cart_to_purchase']
|
||||
return fills / (exp_conv + EPS)
|
||||
102
lab/case/thesis/metrics.py
Normal file
102
lab/case/thesis/metrics.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Thesis metrics for COI and behavioral analysis using ground truth labels."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict
|
||||
import numpy as np
|
||||
from ...outlet.types import StepLogs, StepMetrics, Quote, InstrumentSet
|
||||
from ...outlet.math_util import safe_log, EPS
|
||||
|
||||
|
||||
@dataclass
|
||||
class COIMetrics:
|
||||
coi_level: float = 0.0
|
||||
coi_leakage: float = 0.0
|
||||
realized_premium: float = 0.0
|
||||
theoretical_max: float = 0.0
|
||||
erosion_rate: float = 0.0
|
||||
|
||||
def to_dict(self) -> dict[str, float]:
|
||||
return {k: getattr(self, k) for k in ['coi_level', 'coi_leakage', 'realized_premium', 'theoretical_max', 'erosion_rate']}
|
||||
|
||||
|
||||
def compute_coi(quote: Quote, instruments: InstrumentSet, metrics: StepMetrics, contamination: float) -> COIMetrics:
|
||||
prices, costs, refs = quote.prices, instruments.costs, instruments.refs
|
||||
margins = prices - costs
|
||||
coi_level = float(np.mean(margins))
|
||||
theoretical_max = float(np.mean(costs))
|
||||
realized_premium = (metrics.revenue - metrics.cost) / metrics.units_traded if metrics.units_traded > 0 else 0.0
|
||||
price_var = float(np.var(prices / refs))
|
||||
coi_leakage = contamination * (coi_level + price_var)
|
||||
erosion_rate = contamination * coi_level / (theoretical_max + EPS)
|
||||
return COIMetrics(coi_level=coi_level, coi_leakage=coi_leakage, realized_premium=realized_premium,
|
||||
theoretical_max=theoretical_max, erosion_rate=erosion_rate)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SeparabilityMetrics:
|
||||
classification_accuracy: float = 0.0
|
||||
estimated_alpha: float = 0.0
|
||||
n_human_sessions: int = 0
|
||||
n_agent_sessions: int = 0
|
||||
|
||||
|
||||
def compute_separability(logs: StepLogs, true_alpha: float) -> SeparabilityMetrics:
|
||||
"""Compute separability using ground truth labels only."""
|
||||
if logs.events is None or len(logs.events) == 0:
|
||||
return SeparabilityMetrics(estimated_alpha=true_alpha)
|
||||
|
||||
sessions: Dict[str, bool] = {}
|
||||
for evt in logs.events:
|
||||
sid = evt.metadata.get('session_id', evt.opportunity_id)
|
||||
if sid not in sessions:
|
||||
sessions[sid] = evt.metadata.get('is_agent', False)
|
||||
|
||||
n_agent = sum(1 for is_agent in sessions.values() if is_agent)
|
||||
n_human = len(sessions) - n_agent
|
||||
est_alpha = n_agent / len(sessions) if sessions else 0.0
|
||||
|
||||
return SeparabilityMetrics(
|
||||
classification_accuracy=1.0, # ground truth is always correct
|
||||
estimated_alpha=est_alpha,
|
||||
n_human_sessions=n_human,
|
||||
n_agent_sessions=n_agent)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RevenueAttribution:
|
||||
total_revenue: float = 0.0
|
||||
human_revenue: float = 0.0
|
||||
agent_revenue: float = 0.0
|
||||
human_conversion: float = 0.0
|
||||
agent_conversion: float = 0.0
|
||||
|
||||
|
||||
def compute_attribution(logs: StepLogs, metrics: StepMetrics) -> RevenueAttribution:
|
||||
if logs.executions is None:
|
||||
return RevenueAttribution(total_revenue=metrics.revenue)
|
||||
|
||||
human_rev, agent_rev, human_cnt, agent_cnt = 0.0, 0.0, 0, 0
|
||||
for exe in logs.executions:
|
||||
if exe.propensity < 0.05:
|
||||
agent_rev += exe.price * exe.size_filled
|
||||
agent_cnt += 1
|
||||
else:
|
||||
human_rev += exe.price * exe.size_filled
|
||||
human_cnt += 1
|
||||
|
||||
total_exp = logs.aggregates.get('n_arrivals', 1)
|
||||
return RevenueAttribution(
|
||||
total_revenue=metrics.revenue, human_revenue=human_rev, agent_revenue=agent_rev,
|
||||
human_conversion=human_cnt / (total_exp * 0.8 + EPS),
|
||||
agent_conversion=agent_cnt / (total_exp * 0.2 + EPS))
|
||||
|
||||
|
||||
def order_statistic_erosion(n_agents: int, price_variance: float) -> float:
|
||||
"""COI erosion from Theorem 1: as N->inf, min(p_1..p_N)->p_min."""
|
||||
if n_agents <= 1:
|
||||
return 0.0
|
||||
sigma, log_n = np.sqrt(price_variance), safe_log(n_agents)
|
||||
if log_n < 1:
|
||||
return 0.0
|
||||
shift = sigma * (np.sqrt(2 * log_n) - (safe_log(log_n) + safe_log(4 * np.pi)) / (2 * np.sqrt(2 * log_n) + EPS))
|
||||
return float(min(shift / (sigma * 2 + EPS), 1.0))
|
||||
228
lab/case/thesis/objectives.py
Normal file
228
lab/case/thesis/objectives.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Thesis-specific objectives implementing robust pricing under contamination.
|
||||
|
||||
Implements the Maximin objective from Eq 23:
|
||||
π* = argmax_π min_{Q ∈ U_ε} E_d~Q[R(p,d) - λ·COI(p)]
|
||||
|
||||
Key components:
|
||||
- COIObjective: Cost of Information penalty (Definition 1)
|
||||
- RobustStackelbergObjective: Full maximin objective with Wasserstein robustness
|
||||
- UXPenalty: User experience degradation from volatility
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from ...outlet.objectives.base import BaseObjective, CompositeObjective
|
||||
from ...outlet.types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
||||
from ...outlet.math_util import safe_log, EPS
|
||||
|
||||
class COIObjective(BaseObjective):
|
||||
"""Cost of Information penalty from Definition 1.
|
||||
|
||||
COI(π) = E[P] - p_min
|
||||
|
||||
The expected price premium over marginal cost represents the platform's
|
||||
pricing power. Agent reconnaissance erodes this by revealing price
|
||||
distribution to buyers.
|
||||
|
||||
We implement COI_leakage = f(τ') · InfoValue(p, τ')
|
||||
where f(τ') is the estimated agent probability.
|
||||
"""
|
||||
|
||||
def __init__(self, lambda_coi: float = 1.0, use_revelation: bool = False):
|
||||
"""
|
||||
Args:
|
||||
lambda_coi: Weight on COI penalty
|
||||
use_revelation: If True, use -log(π(p)) as info value (penalizes rare prices)
|
||||
"""
|
||||
self.lambda_coi = lambda_coi
|
||||
self.use_revelation = use_revelation
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
# COI_leakage = α · InfoValue
|
||||
alpha = hidden.contamination
|
||||
|
||||
if self.use_revelation:
|
||||
# revelation surrogate: rare prices reveal more about policy
|
||||
# InfoValue = -log(π(p|τ')) ≈ surprise of the price
|
||||
price_surprise = np.mean(np.abs(quote.prices - instruments.refs) / (instruments.refs + EPS))
|
||||
info_value = price_surprise
|
||||
else:
|
||||
# query-tax surrogate: each agent query incurs constant leakage
|
||||
info_value = 1.0
|
||||
|
||||
leakage = alpha * info_value
|
||||
return -self.lambda_coi * leakage
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
alpha = hidden.contamination
|
||||
margins = (quote.prices - instruments.costs) / (instruments.costs + EPS)
|
||||
return {
|
||||
'coi_penalty': self.reward(quote, instruments, metrics, hidden, obs),
|
||||
'contamination': alpha,
|
||||
'avg_margin': float(np.mean(margins)),
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class RobustObjectiveConfig:
|
||||
"""Configuration for robust Stackelberg objective.
|
||||
|
||||
Attributes:
|
||||
lambda_coi: Weight on COI penalty (λ in Eq 23)
|
||||
lambda_ux: Weight on UX penalty
|
||||
lambda_volatility: Weight on price volatility penalty
|
||||
gamma_inventory: Inventory risk aversion
|
||||
wasserstein_epsilon: Ambiguity set radius (ε in Eq 21)
|
||||
"""
|
||||
lambda_coi: float = 0.5
|
||||
lambda_ux: float = 0.1
|
||||
lambda_volatility: float = 0.2
|
||||
gamma_inventory: float = 0.1
|
||||
wasserstein_epsilon: float = 0.1
|
||||
|
||||
class RobustStackelbergObjective(BaseObjective):
|
||||
"""Implements the Maximin Objective from thesis Eq 23.
|
||||
|
||||
π* = argmax_π min_{Q ∈ U_ε(P̂_N)} E_d~Q[R(p,d) - λ·COI(p)]
|
||||
|
||||
The objective balances:
|
||||
1. Revenue R(p,d) from human purchases
|
||||
2. COI penalty for information leakage to agents
|
||||
3. UX penalty for price volatility
|
||||
4. Inventory/holding costs
|
||||
|
||||
The min over ambiguity set U_ε is approximated by penalizing
|
||||
high contamination scenarios more heavily.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: RobustObjectiveConfig | None = None):
|
||||
self.cfg = cfg or RobustObjectiveConfig()
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
cfg = self.cfg
|
||||
|
||||
# 1. base revenue (R(p,d))
|
||||
revenue = metrics.revenue
|
||||
cost = metrics.cost
|
||||
profit = revenue - cost
|
||||
|
||||
# 2. COI penalty: scales with contamination and margin extraction
|
||||
# high margins + high contamination = high leakage
|
||||
alpha = hidden.contamination
|
||||
margins = quote.prices - instruments.costs
|
||||
avg_margin = float(np.mean(margins))
|
||||
coi_penalty = cfg.lambda_coi * avg_margin * alpha
|
||||
|
||||
# 3. UX penalty: price volatility harms legitimate users
|
||||
volatility_penalty = cfg.lambda_volatility * metrics.volatility
|
||||
|
||||
# 4. inventory/position cost
|
||||
position_penalty = cfg.gamma_inventory * metrics.position_cost
|
||||
|
||||
# 5. lost opportunity cost (stockouts)
|
||||
lost_penalty = 0.1 * metrics.lost_opportunity
|
||||
|
||||
# robust adjustment: under adversarial distribution Q,
|
||||
# expect lower revenue and higher costs
|
||||
# approximate via worst-case contamination within ε-ball
|
||||
worst_case_alpha = min(alpha + cfg.wasserstein_epsilon, 1.0)
|
||||
robustness_penalty = cfg.wasserstein_epsilon * avg_margin * worst_case_alpha
|
||||
|
||||
total = profit - coi_penalty - volatility_penalty - position_penalty - lost_penalty - robustness_penalty
|
||||
|
||||
return total
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
cfg = self.cfg
|
||||
alpha = hidden.contamination
|
||||
margins = quote.prices - instruments.costs
|
||||
avg_margin = float(np.mean(margins))
|
||||
|
||||
return {
|
||||
'revenue': metrics.revenue,
|
||||
'cost': metrics.cost,
|
||||
'profit': metrics.revenue - metrics.cost,
|
||||
'coi_penalty': -cfg.lambda_coi * avg_margin * alpha,
|
||||
'volatility_penalty': -cfg.lambda_volatility * metrics.volatility,
|
||||
'position_penalty': -cfg.gamma_inventory * metrics.position_cost,
|
||||
'lost_penalty': -0.1 * metrics.lost_opportunity,
|
||||
'robustness_penalty': -cfg.wasserstein_epsilon * avg_margin * min(alpha + cfg.wasserstein_epsilon, 1.0),
|
||||
'contamination': alpha,
|
||||
'avg_margin_pct': avg_margin / (float(np.mean(instruments.costs)) + EPS),
|
||||
}
|
||||
|
||||
class UXPenalty(BaseObjective):
|
||||
"""User experience penalty from price volatility.
|
||||
|
||||
High price volatility degrades UX for legitimate human users.
|
||||
This term ensures the defense doesn't harm real customers while
|
||||
protecting against agent reconnaissance.
|
||||
"""
|
||||
|
||||
def __init__(self, scale: float = 1.0, max_acceptable_volatility: float = 0.1):
|
||||
self.scale = scale
|
||||
self.max_vol = max_acceptable_volatility
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
# penalty increases quadratically beyond threshold
|
||||
excess_vol = max(0, metrics.volatility - self.max_vol)
|
||||
return -self.scale * (excess_vol ** 2)
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {
|
||||
'ux_penalty': self.reward(quote, instruments, metrics, hidden, obs),
|
||||
'volatility': metrics.volatility,
|
||||
}
|
||||
|
||||
class AdaptiveObjective(BaseObjective):
|
||||
"""Objective that adapts weights based on estimated contamination.
|
||||
|
||||
When contamination is low, focus on revenue maximization.
|
||||
When contamination is high, increase COI defense weight.
|
||||
"""
|
||||
|
||||
def __init__(self, base_lambda_coi: float = 0.3, max_lambda_coi: float = 2.0,
|
||||
adaptation_rate: float = 2.0):
|
||||
self.base_lambda = base_lambda_coi
|
||||
self.max_lambda = max_lambda_coi
|
||||
self.rate = adaptation_rate
|
||||
|
||||
def _adaptive_lambda(self, alpha: float) -> float:
|
||||
# sigmoid scaling: λ(α) = base + (max-base) * sigmoid(rate*(α-0.5))
|
||||
from ...outlet.math_util import sigmoid
|
||||
scale = sigmoid(self.rate * (alpha - 0.3))
|
||||
return self.base_lambda + (self.max_lambda - self.base_lambda) * scale
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
alpha = hidden.contamination
|
||||
lambda_coi = self._adaptive_lambda(alpha)
|
||||
|
||||
profit = metrics.revenue - metrics.cost
|
||||
margins = quote.prices - instruments.costs
|
||||
coi_penalty = lambda_coi * float(np.mean(margins)) * alpha
|
||||
|
||||
return profit - coi_penalty
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
alpha = hidden.contamination
|
||||
return {
|
||||
'profit': metrics.revenue - metrics.cost,
|
||||
'adaptive_lambda': self._adaptive_lambda(alpha),
|
||||
'contamination': alpha,
|
||||
}
|
||||
|
||||
def make_thesis_objective(lambda_coi: float = 0.5, lambda_ux: float = 0.1,
|
||||
lambda_vol: float = 0.2) -> CompositeObjective:
|
||||
"""Create the standard thesis objective composition."""
|
||||
return CompositeObjective([
|
||||
(RobustStackelbergObjective(RobustObjectiveConfig(
|
||||
lambda_coi=lambda_coi, lambda_ux=lambda_ux, lambda_volatility=lambda_vol)), 1.0),
|
||||
])
|
||||
176
lab/case/thesis/platform.py
Normal file
176
lab/case/thesis/platform.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""Thesis platform with real MDP behavioral models and separability scoring."""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import numpy as np
|
||||
from ...outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
|
||||
PostedPriceMechanism, make_instruments, InstrumentType, LogLevel)
|
||||
from ...outlet.mechanisms.posted_price import PostedPriceConfig
|
||||
from ...outlet.observation import DefaultObservationBuilder, ObservationConfig
|
||||
from .arrivals import ContaminatedArrivalModel, ContaminatedArrivalConfig
|
||||
from .execution import HybridExecutionModel, HybridExecutionConfig
|
||||
from .objectives import RobustStackelbergObjective, RobustObjectiveConfig
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThesisConfig:
|
||||
# instruments
|
||||
n_instruments: int = 10
|
||||
cost_range: tuple[float, float] = (5.0, 50.0)
|
||||
margin_range: tuple[float, float] = (0.2, 0.5)
|
||||
|
||||
# contamination (Section 3.1)
|
||||
alpha_contamination: float = 0.2
|
||||
alpha_drift: float = 0.0
|
||||
alpha_bounds: tuple[float, float] = (0.0, 0.5)
|
||||
|
||||
# objectives (Eq 23)
|
||||
lambda_coi: float = 0.5
|
||||
lambda_ux: float = 0.1
|
||||
lambda_volatility: float = 0.2
|
||||
wasserstein_epsilon: float = 0.1
|
||||
|
||||
# arrivals
|
||||
sessions_per_step: int = 30
|
||||
human_views_range: tuple[int, int] = (1, 4)
|
||||
agent_views_range: tuple[int, int] = (3, 10)
|
||||
|
||||
# inventory
|
||||
initial_inventory: float = 100.0
|
||||
holding_cost_rate: float = 0.002
|
||||
|
||||
# real behavioral models (from sim.rl)
|
||||
use_real_behavior: bool = True
|
||||
use_separability: bool = False # disabled until classifier trained
|
||||
human_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data"
|
||||
agent_data_dir: str = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data"
|
||||
|
||||
# simulation
|
||||
max_steps: int = 500
|
||||
seed: int | None = 24
|
||||
log_level: LogLevel = LogLevel.AGG_ONLY
|
||||
|
||||
|
||||
def _resolve_data_dirs(cfg: ThesisConfig) -> tuple[str, str]:
|
||||
"""Resolve data directories for behavioral models."""
|
||||
base = Path(__file__).parent.parent.parent.parent / "experiments"
|
||||
human = cfg.human_data_dir or str(base / "collected_data")
|
||||
agent = cfg.agent_data_dir or str(base / "agents/collected_data")
|
||||
return human, agent
|
||||
|
||||
|
||||
def make_thesis_platform(cfg: ThesisConfig | None = None) -> Platform:
|
||||
"""Create platform with real MDP behavioral models.
|
||||
|
||||
Implements:
|
||||
- Contaminated arrivals using learned MDP kernels from behavior_loader
|
||||
- Hybrid execution with real separability scoring from lib.separability
|
||||
- Robust Stackelberg objective (Eq 23)
|
||||
"""
|
||||
cfg = cfg or ThesisConfig()
|
||||
rng = np.random.default_rng(cfg.seed)
|
||||
human_dir, agent_dir = _resolve_data_dirs(cfg)
|
||||
|
||||
instruments = make_instruments(
|
||||
n=cfg.n_instruments, cost_range=cfg.cost_range, margin_range=cfg.margin_range,
|
||||
inst_type=InstrumentType.SKU, rng=rng)
|
||||
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
|
||||
|
||||
arrival = ContaminatedArrivalModel(ContaminatedArrivalConfig(
|
||||
base_rate=cfg.sessions_per_step,
|
||||
alpha_contamination=cfg.alpha_contamination,
|
||||
alpha_drift=cfg.alpha_drift,
|
||||
alpha_bounds=cfg.alpha_bounds,
|
||||
human_views_range=cfg.human_views_range,
|
||||
agent_views_range=cfg.agent_views_range,
|
||||
use_real_behavior=cfg.use_real_behavior,
|
||||
human_data_dir=human_dir,
|
||||
agent_data_dir=agent_dir,
|
||||
))
|
||||
|
||||
execution = HybridExecutionModel(HybridExecutionConfig(
|
||||
use_separability=cfg.use_separability,
|
||||
))
|
||||
|
||||
mechanism = PostedPriceMechanism(PostedPriceConfig(max_delta_pct=0.15, min_margin_pct=0.05))
|
||||
position = PositionModel(PositionConfig(initial_position=cfg.initial_inventory, holding_cost_rate=cfg.holding_cost_rate))
|
||||
|
||||
market = None
|
||||
objective = RobustStackelbergObjective(RobustObjectiveConfig(
|
||||
lambda_coi=cfg.lambda_coi, lambda_ux=cfg.lambda_ux,
|
||||
lambda_volatility=cfg.lambda_volatility, wasserstein_epsilon=cfg.wasserstein_epsilon))
|
||||
|
||||
obs_builder = DefaultObservationBuilder(ObservationConfig(mask_true_demand=True))
|
||||
platform_cfg = PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
||||
seed=cfg.seed, log_level=cfg.log_level, mask_demand=True)
|
||||
|
||||
return Platform(instruments=instruments, mechanism=mechanism, arrival=arrival, execution=execution,
|
||||
position=position, market=market, obs_builder=obs_builder, objective=objective, cfg=platform_cfg)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AblationConfig(ThesisConfig):
|
||||
disable_coi_penalty: bool = False
|
||||
disable_ux_penalty: bool = False
|
||||
disable_contamination: bool = False
|
||||
disable_real_behavior: bool = False
|
||||
|
||||
|
||||
def make_ablation_platform(cfg: AblationConfig) -> Platform:
|
||||
if cfg.disable_coi_penalty:
|
||||
cfg.lambda_coi = 0.0
|
||||
if cfg.disable_ux_penalty:
|
||||
cfg.lambda_ux = 0.0
|
||||
if cfg.disable_contamination:
|
||||
cfg.alpha_contamination = 0.0
|
||||
if cfg.disable_real_behavior:
|
||||
cfg.use_real_behavior = False
|
||||
cfg.use_separability = False
|
||||
return make_thesis_platform(cfg)
|
||||
|
||||
|
||||
def sweep_contamination(alpha_values: list[float], base_cfg: ThesisConfig | None = None,
|
||||
n_steps: int = 100, seed: int = 42) -> dict[float, dict]:
|
||||
"""Test performance across contamination levels (Theorem 1 validation)."""
|
||||
from ...experiments.eval import rollout, fixed_price_policy
|
||||
|
||||
results = {}
|
||||
base_cfg = base_cfg or ThesisConfig()
|
||||
|
||||
for alpha in alpha_values:
|
||||
cfg = ThesisConfig(**{k: v for k, v in base_cfg.__dict__.items() if k != 'alpha_contamination'},
|
||||
alpha_contamination=alpha)
|
||||
platform = make_thesis_platform(cfg)
|
||||
policy = fixed_price_policy(platform.instruments.refs)
|
||||
result = rollout(platform, policy, n_steps, seed=seed)
|
||||
results[alpha] = {
|
||||
'total_reward': result.total_reward,
|
||||
'total_pnl': result.total_pnl,
|
||||
'avg_conversion': result.avg_conversion,
|
||||
'final_contamination': platform._hidden.contamination,
|
||||
}
|
||||
return results
|
||||
|
||||
|
||||
def sweep_behavior_modes(base_cfg: ThesisConfig | None = None, n_steps: int = 100, seed: int = 42) -> dict[str, dict]:
|
||||
"""Compare real vs synthetic behavioral models."""
|
||||
from ...experiments.eval import rollout, fixed_price_policy
|
||||
|
||||
base_cfg = base_cfg or ThesisConfig()
|
||||
modes = {
|
||||
'real_mdp': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': True}),
|
||||
'synthetic': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': False, 'use_separability': False}),
|
||||
'real_mdp_no_sep': ThesisConfig(**{**base_cfg.__dict__, 'use_real_behavior': True, 'use_separability': False}),
|
||||
}
|
||||
|
||||
results = {}
|
||||
for name, cfg in modes.items():
|
||||
platform = make_thesis_platform(cfg)
|
||||
policy = fixed_price_policy(platform.instruments.refs)
|
||||
result = rollout(platform, policy, n_steps, seed=seed)
|
||||
results[name] = {
|
||||
'total_reward': result.total_reward,
|
||||
'total_pnl': result.total_pnl,
|
||||
'avg_conversion': result.avg_conversion,
|
||||
}
|
||||
return results
|
||||
136
lab/case/thesis/run_experiment.py
Normal file
136
lab/case/thesis/run_experiment.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python
|
||||
"""Thesis simulation experiments with real MDP behavioral models."""
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from lab.case.thesis.platform import make_thesis_platform, ThesisConfig
|
||||
from lab.case.thesis.metrics import compute_coi, compute_separability
|
||||
from lab.experiments.eval import compare_policies
|
||||
import numpy as np
|
||||
|
||||
|
||||
def demo_basic_simulation():
|
||||
print("=" * 70)
|
||||
print("THESIS SIMULATION: Contaminated Dynamic Pricing (Real MDP Kernels)")
|
||||
print("=" * 70)
|
||||
|
||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, lambda_coi=0.5,
|
||||
max_steps=100, seed=42, use_real_behavior=True)
|
||||
platform = make_thesis_platform(cfg)
|
||||
|
||||
print(f"\nInstruments: {platform.instruments.n}")
|
||||
print(f"Reference prices: {platform.instruments.refs.round(2)}")
|
||||
print(f"Costs: {platform.instruments.costs.round(2)}")
|
||||
print(f"Initial contamination alpha={cfg.alpha_contamination}")
|
||||
print(f"Using real behavior: {cfg.use_real_behavior}")
|
||||
|
||||
result = platform.reset(seed=42)
|
||||
total_reward, coi_history = 0, []
|
||||
|
||||
print(f"\n{'Step':>5} {'Reward':>10} {'PnL':>10} {'COI':>8} {'alpha':>6} {'Conv':>8}")
|
||||
print("-" * 55)
|
||||
|
||||
for t in range(cfg.max_steps):
|
||||
action = platform.instruments.refs * np.random.uniform(0.95, 1.15, size=platform.instruments.n)
|
||||
result = platform.step(action)
|
||||
total_reward += result.reward
|
||||
coi = compute_coi(platform._quote, platform.instruments, result.metrics, result.hidden.contamination)
|
||||
coi_history.append(coi.coi_level)
|
||||
|
||||
if t % 20 == 0:
|
||||
print(f"{t:5d} {result.reward:10.2f} {result.metrics.pnl:10.2f} "
|
||||
f"{coi.coi_level:8.2f} {result.hidden.contamination:6.2f} {result.metrics.conversion:8.3f}")
|
||||
|
||||
print("-" * 55)
|
||||
print(f"Total Reward: {total_reward:.2f}")
|
||||
print(f"Average COI: {np.mean(coi_history):.2f}")
|
||||
print(f"COI Trend: {coi_history[-1] - coi_history[0]:+.2f}")
|
||||
|
||||
|
||||
def demo_contamination_sweep():
|
||||
print("\n" + "=" * 70)
|
||||
print("EXPERIMENT: COI Erosion vs Contamination (Theorem 1)")
|
||||
print("=" * 70)
|
||||
|
||||
from lab.case.thesis.platform import sweep_contamination
|
||||
trials = 20
|
||||
alpha_values = [i/trials for i in range(trials)]
|
||||
results = sweep_contamination(alpha_values, n_steps=100, seed=42)
|
||||
|
||||
print(f"\n{'alpha':>6} {'Reward':>12} {'PnL':>12} {'Conv':>10}")
|
||||
print("-" * 45)
|
||||
for alpha, m in sorted(results.items()):
|
||||
print(f"{alpha:6.2f} {m['total_reward']:12.2f} {m['total_pnl']:12.2f} {m['avg_conversion']:10.3f}")
|
||||
|
||||
rewards = [results[a]['total_reward'] for a in sorted(results.keys())]
|
||||
dataset = np.array([[a, r] for a, r in zip(alpha_values, rewards)])
|
||||
trend = np.corrcoef(dataset[:, 0], dataset[:, 1])[0, 1]
|
||||
print(f"Trend (alpha~reward correlation): {trend:.3f}")
|
||||
|
||||
|
||||
def demo_policy_comparison():
|
||||
print("\n" + "=" * 70)
|
||||
print("EXPERIMENT: Policy Comparison under Contamination")
|
||||
print("=" * 70)
|
||||
|
||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.25, max_steps=100, seed=42)
|
||||
platform = make_thesis_platform(cfg)
|
||||
|
||||
def fixed_policy(obs, t): return platform.instruments.refs.copy(), 1.0
|
||||
def aggressive_policy(obs, t): return platform.instruments.refs * 1.3, 1.0
|
||||
def conservative_policy(obs, t): return platform.instruments.refs * 1.05, 1.0
|
||||
def adaptive_policy(obs, t):
|
||||
fills = obs[platform.instruments.n:2*platform.instruments.n]
|
||||
exp = obs[2*platform.instruments.n:3*platform.instruments.n]
|
||||
conv = np.sum(fills) / (np.sum(exp) + 1e-8)
|
||||
return platform.instruments.refs * (1.0 + 0.2 * conv), 1.0
|
||||
|
||||
policies = {'fixed': fixed_policy, 'aggressive': aggressive_policy,
|
||||
'conservative': conservative_policy, 'adaptive': adaptive_policy}
|
||||
results = compare_policies(platform, policies, n_steps=100, n_runs=3, seed=42)
|
||||
|
||||
print(f"\n{'Policy':>15} {'Reward':>12} {'Std':>10} {'PnL':>12} {'Conv':>10}")
|
||||
print("-" * 65)
|
||||
for name, r in sorted(results.items(), key=lambda x: -x[1]['mean_reward']):
|
||||
print(f"{name:>15} {r['mean_reward']:12.2f} {r['std_reward']:10.2f} "
|
||||
f"{r['mean_pnl']:12.2f} {r['mean_conversion']:10.3f}")
|
||||
|
||||
|
||||
def demo_session_analysis():
|
||||
"""Analyze session-level behavior from MDP trajectories."""
|
||||
print("\n" + "=" * 70)
|
||||
print("EXPERIMENT: Session Analysis (Ground Truth)")
|
||||
print("=" * 70)
|
||||
|
||||
from lab.outlet.constants import LogLevel
|
||||
cfg = ThesisConfig(n_instruments=5, alpha_contamination=0.3, max_steps=50,
|
||||
log_level=LogLevel.FULL, seed=42, use_real_behavior=True)
|
||||
platform = make_thesis_platform(cfg)
|
||||
|
||||
result = platform.reset(seed=42)
|
||||
human_sessions, agent_sessions = 0, 0
|
||||
|
||||
for t in range(cfg.max_steps):
|
||||
action = platform.instruments.refs * 1.1
|
||||
result = platform.step(action)
|
||||
sep = compute_separability(result.logs, result.hidden.contamination)
|
||||
human_sessions += sep.n_human_sessions
|
||||
agent_sessions += sep.n_agent_sessions
|
||||
|
||||
total = human_sessions + agent_sessions
|
||||
print(f"\nTotal sessions: {total}")
|
||||
print(f"Human sessions: {human_sessions} ({100*human_sessions/total:.1f}%)")
|
||||
print(f"Agent sessions: {agent_sessions} ({100*agent_sessions/total:.1f}%)")
|
||||
print(f"True contamination: {cfg.alpha_contamination:.1%}")
|
||||
print(f"Observed contamination: {agent_sessions/total:.1%}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
demo_basic_simulation()
|
||||
demo_contamination_sweep()
|
||||
# demo_policy_comparison()
|
||||
# demo_session_analysis()
|
||||
104
lab/case/thesis/separability.py
Normal file
104
lab/case/thesis/separability.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Behavioral separability for thesis human/agent classification.
|
||||
|
||||
Implements KL-divergence based separability scoring (Eq 20-21):
|
||||
- Δ_H = D_KL(T̂' || T̄_H): divergence from human reference kernel
|
||||
- Δ_A = D_KL(T̂' || T̄_A): divergence from agent reference kernel
|
||||
- α̂(τ') = σ(β(Δ_H - Δ_A)): per-session contamination estimate
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Dict, List, TYPE_CHECKING
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .simplified import Session
|
||||
|
||||
|
||||
# Reference transition kernels T̄_H, T̄_A estimated from real data (Eq 19)
|
||||
TRANS_H = {
|
||||
"start": {"view": 0.85, "end": 0.15},
|
||||
"view": {"detail": 0.4, "add_to_cart": 0.3, "view": 0.2, "end": 0.1},
|
||||
"detail": {"add_to_cart": 0.5, "view": 0.3, "end": 0.2},
|
||||
"add_to_cart": {"purchase": 0.6, "view": 0.25, "end": 0.15},
|
||||
"purchase": {"end": 1.0},
|
||||
"checkout": {"purchase": 0.8, "end": 0.2},
|
||||
"hover": {"view": 0.5, "detail": 0.3, "end": 0.2},
|
||||
}
|
||||
|
||||
TRANS_A = {
|
||||
"start": {"view": 0.95, "end": 0.05},
|
||||
"view": {"detail": 0.6, "view": 0.25, "add_to_cart": 0.1, "end": 0.05},
|
||||
"detail": {"view": 0.5, "add_to_cart": 0.15, "detail": 0.3, "end": 0.05},
|
||||
"add_to_cart": {"view": 0.4, "purchase": 0.2, "end": 0.4},
|
||||
"purchase": {"end": 1.0},
|
||||
"checkout": {"purchase": 0.3, "end": 0.7},
|
||||
"hover": {"view": 0.6, "detail": 0.35, "end": 0.05},
|
||||
}
|
||||
|
||||
|
||||
def kl_div(p: Dict[str, float], q: Dict[str, float], eps: float = 1e-10) -> float:
|
||||
"""Compute KL(p || q) with smoothing."""
|
||||
if not p or not q:
|
||||
return 0.0
|
||||
all_keys = set(p.keys()) | set(q.keys())
|
||||
total = 0.0
|
||||
for k in all_keys:
|
||||
pk = p.get(k, eps)
|
||||
qk = q.get(k, eps)
|
||||
if pk > eps:
|
||||
total += pk * np.log(pk / max(qk, eps))
|
||||
return max(0.0, total)
|
||||
|
||||
|
||||
def build_kernel(events: List) -> Dict[str, Dict[str, float]]:
|
||||
"""Build empirical transition kernel from event sequence."""
|
||||
trans: Dict[str, Dict[str, int]] = {}
|
||||
prev = "start"
|
||||
for e in events:
|
||||
curr = getattr(e, 'action', None) or e.get('action', 'end') if isinstance(e, dict) else 'end'
|
||||
trans.setdefault(prev, {})
|
||||
trans[prev][curr] = trans[prev].get(curr, 0) + 1
|
||||
prev = curr
|
||||
# add terminal transition
|
||||
trans.setdefault(prev, {})
|
||||
trans[prev]["end"] = trans[prev].get("end", 0) + 1
|
||||
|
||||
# normalize to probabilities
|
||||
kernel = {}
|
||||
for s, dests in trans.items():
|
||||
total = sum(dests.values())
|
||||
kernel[s] = {d: c / total for d, c in dests.items()} if total > 0 else {"end": 1.0}
|
||||
return kernel
|
||||
|
||||
|
||||
def compute_divergence(kernel: Dict[str, Dict[str, float]], ref_h: Dict = None, ref_a: Dict = None) -> tuple[float, float]:
|
||||
"""Compute Δ_H, Δ_A divergence from reference kernels (Eq 20-21)."""
|
||||
ref_h = ref_h or TRANS_H
|
||||
ref_a = ref_a or TRANS_A
|
||||
delta_h = sum(kl_div(kernel.get(s, {}), ref_h.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
||||
delta_a = sum(kl_div(kernel.get(s, {}), ref_a.get(s, {})) for s in kernel) / max(len(kernel), 1)
|
||||
return delta_h, delta_a
|
||||
|
||||
|
||||
def estimate_alpha(session: "Session", beta: float = 2.0) -> float:
|
||||
"""Estimate per-session contamination α̂(τ') = σ(β(Δ_H - Δ_A)).
|
||||
|
||||
High Δ_H (far from human) and low Δ_A (close to agent) -> high α̂ (likely agent).
|
||||
"""
|
||||
if not session.events:
|
||||
return 0.5
|
||||
kernel = build_kernel(session.events)
|
||||
delta_h, delta_a = compute_divergence(kernel)
|
||||
|
||||
if delta_h + delta_a < 1e-6:
|
||||
return 0.5
|
||||
|
||||
# sigmoid: high when trajectory is more divergent from human than agent
|
||||
return 1.0 / (1.0 + np.exp(-beta * (delta_h - delta_a)))
|
||||
|
||||
|
||||
def batch_estimate_alpha(sessions: List["Session"]) -> tuple[float, List[float]]:
|
||||
"""Estimate aggregate and per-session contamination."""
|
||||
if not sessions:
|
||||
return 0.0, []
|
||||
alphas = [estimate_alpha(s) for s in sessions]
|
||||
return float(np.mean(alphas)), alphas
|
||||
227
lab/case/thesis/simplified.py
Normal file
227
lab/case/thesis/simplified.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Minimal implementation of thesis pricing system.
|
||||
|
||||
Implements the core loop: prices -> sessions -> demand -> prices
|
||||
with behavioral separability and robust pricing objective.
|
||||
|
||||
Objects:
|
||||
- Session trajectories tau_s from mixture of H/A behavioral profiles
|
||||
- Demand proxy q_hat via weighted action aggregation
|
||||
- COI leakage penalty for agent reconnaissance
|
||||
- Limbo: alternating price/demand history for trajectory analysis
|
||||
|
||||
COI Correction (Jan 2026):
|
||||
The fundamental COI formulation is:
|
||||
COI = E[p_start] - p_transaction
|
||||
|
||||
This measures price erosion over time, not instantaneous margin × alpha.
|
||||
Agents use multiple sessions to gather information and find minimum prices.
|
||||
The price path from episode start to transaction captures information leakage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Tuple
|
||||
import numpy as np
|
||||
|
||||
from .coi import COIWindow, compute_coi_window
|
||||
from .separability import TRANS_H, TRANS_A, kl_div, build_kernel, compute_divergence, estimate_alpha
|
||||
|
||||
ACTION_WEIGHTS = {"add_to_cart": 0.8, "checkout": 0.9, "purchase": 1.0, "view": 0.15, "detail": 0.25, "hover": 0.3, "start": 0.05, "end": 0.0}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Event:
|
||||
action: str
|
||||
product_idx: int
|
||||
price_seen: float
|
||||
ts: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
sid: str
|
||||
events: List[Event]
|
||||
actor: str # H or A (ground truth label)
|
||||
theta: Dict[str, float] = field(default_factory=dict)
|
||||
|
||||
|
||||
def compute_demand(session: Session) -> float:
|
||||
"""Compute demand proxy q_hat = sum_k omega(a_k) for session."""
|
||||
return sum(ACTION_WEIGHTS.get(e.action, 0.1) for e in session.events)
|
||||
|
||||
|
||||
def sample_trajectory(rng: np.random.Generator, trans: Dict, prices: np.ndarray, costs: np.ndarray, theta: Dict[str, float],
|
||||
is_agent: bool, session_noise: float = 0.02, surge: float = 0.08, max_mult: float = 1.8) -> Tuple[List[Event], int]:
|
||||
"""Sample session trajectory from behavioral kernel."""
|
||||
pidx = int(rng.integers(0, len(prices)))
|
||||
cost, base = float(costs[pidx]), float(prices[pidx]) * (1.0 + rng.normal(0.0, session_noise))
|
||||
base = float(np.clip(base, cost * 1.01, float(prices[pidx]) * 2.0))
|
||||
price, signal, state, t = base, 0.0, "start", 0.0
|
||||
events = []
|
||||
|
||||
while state != "end" and len(events) < 30:
|
||||
probs = trans.get(state, {"end": 1.0})
|
||||
nxt = rng.choice(list(probs.keys()), p=list(probs.values()))
|
||||
if nxt == "purchase": # purchase conversion check
|
||||
rel = max((price - cost) / (cost + 1e-6), 0.0)
|
||||
p_buy = float(np.clip(theta.get("base_conv", 0.2) * np.exp(-theta.get("price_sens", 2.0) * rel), 0.0, 1.0))
|
||||
if rng.random() > p_buy:
|
||||
nxt = "end"
|
||||
state = nxt
|
||||
if state not in {"start", "end"}:
|
||||
events.append(Event(action=state, product_idx=pidx, price_seen=float(price), ts=t))
|
||||
signal += float(ACTION_WEIGHTS.get(state, 0.1))
|
||||
price = float(np.clip(base * (1.0 + surge * signal), cost * 1.01, base * max_mult))
|
||||
t += max(0.2, rng.gamma(1.5, 0.8) if is_agent else rng.gamma(2.0, 1.2))
|
||||
return events, pidx
|
||||
|
||||
|
||||
def put_prices_to_market(prices: np.ndarray, costs: np.ndarray, alpha: float = 0.2, n_sessions: int = 50,
|
||||
seed: int | None = None) -> Tuple[List[Session], Dict[str, float]]:
|
||||
"""Generate sessions from mixture model. Returns sessions and demand mapping sid -> q_hat."""
|
||||
rng = np.random.default_rng(seed)
|
||||
sessions, demand = [], {}
|
||||
for i in range(n_sessions):
|
||||
sid = f"s{i:04d}"
|
||||
is_agent = rng.random() < alpha
|
||||
trans = TRANS_A if is_agent else TRANS_H
|
||||
theta = {"price_sens": rng.uniform(0.05, 0.2), "base_conv": 0.01} if is_agent else \
|
||||
{"price_sens": rng.uniform(1.5, 4.0), "base_conv": rng.uniform(0.2, 0.5)}
|
||||
events, _ = sample_trajectory(rng, trans, prices, costs=costs, theta=theta, is_agent=is_agent)
|
||||
session = Session(sid=sid, events=events, actor="A" if is_agent else "H", theta=theta)
|
||||
sessions.append(session)
|
||||
demand[sid] = compute_demand(session)
|
||||
return sessions, demand
|
||||
|
||||
|
||||
@dataclass
|
||||
class LimboUpdate:
|
||||
utype: str # "prices" or "demand"
|
||||
data: np.ndarray | Dict[str, float]
|
||||
t: int
|
||||
|
||||
|
||||
class Limbo:
|
||||
"""Historical trajectory of alternating price/demand observations."""
|
||||
|
||||
def __init__(self):
|
||||
self.history: List[LimboUpdate] = []
|
||||
self._t = 0
|
||||
|
||||
def add_update(self, utype: str, data: np.ndarray | Dict[str, float]) -> Dict:
|
||||
self.history.append(LimboUpdate(utype=utype, data=data, t=self._t))
|
||||
self._t += 1
|
||||
return {"action": "observe_demand" if utype == "prices" else "set_prices"}
|
||||
|
||||
def get_prices_history(self) -> List[np.ndarray]:
|
||||
return [u.data for u in self.history if u.utype == "prices"]
|
||||
|
||||
def get_demand_history(self) -> List[Dict[str, float]]:
|
||||
return [u.data for u in self.history if u.utype == "demand"]
|
||||
|
||||
|
||||
class System:
|
||||
"""Main pricing system implementing robust Stackelberg objective.
|
||||
|
||||
Manages the alternating loop: set prices p_t -> observe demand Q_hat(p_t) ->
|
||||
estimate contamination alpha from behavioral signals -> compute next prices.
|
||||
"""
|
||||
|
||||
def __init__(self, n_products: int = 10, costs: np.ndarray | None = None, lambda_coi: float = 0.5, seed: int | None = 42):
|
||||
self.n = n_products
|
||||
self.rng = np.random.default_rng(seed)
|
||||
self.costs = costs if costs is not None else self.rng.uniform(10, 50, n_products)
|
||||
self.refs = self.costs * (1 + self.rng.uniform(0.2, 0.5, n_products))
|
||||
self.lambda_coi = lambda_coi
|
||||
self.limbo = Limbo()
|
||||
self._alpha_est = 0.2
|
||||
self._sessions: List[Session] = []
|
||||
self._last_sessions: List[Session] = []
|
||||
self._last_coi: COIWindow | None = None
|
||||
|
||||
@property
|
||||
def alpha(self) -> float:
|
||||
return self._alpha_est
|
||||
|
||||
def _estimate_alpha_from_sessions(self) -> float:
|
||||
if not self._sessions:
|
||||
return self._alpha_est
|
||||
return float(np.mean([estimate_alpha(s) for s in self._sessions[-50:]]))
|
||||
|
||||
def _revenue_under_demand(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
|
||||
agg = np.zeros(self.n)
|
||||
for sid, q in demand.items():
|
||||
sess = next((s for s in self._sessions if s.sid == sid), None)
|
||||
if sess and sess.events:
|
||||
agg[sess.events[0].product_idx] += q
|
||||
return float(np.dot(prices, agg))
|
||||
|
||||
def _compute_coi_window(self, demand: Dict[str, float]) -> COIWindow:
|
||||
if not self._last_sessions:
|
||||
zeros = np.zeros(self.n, dtype=float)
|
||||
return COIWindow(policy=0.0, agent=0.0, leak=0.0, survival_ratio=0.0,
|
||||
policy_by_product=zeros, agent_by_product=zeros, demand_weights=zeros)
|
||||
return compute_coi_window(self._last_sessions, self.costs, demand_mapping=demand)
|
||||
|
||||
def _objective(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
|
||||
"""Robust objective: R(p,d) - lambda * COI_leak."""
|
||||
profit = self._revenue_under_demand(prices, demand) - float(np.sum(self.costs))
|
||||
self._last_coi = self._compute_coi_window(demand)
|
||||
return profit - self.lambda_coi * self._last_coi.leak
|
||||
|
||||
def compute_prices(self, demand: Dict[str, float] | None = None) -> np.ndarray:
|
||||
"""Compute next prices via heuristic margin adjustment based on alpha estimate."""
|
||||
self._alpha_est = self._estimate_alpha_from_sessions()
|
||||
margin_scale = 1.0 - 0.5 * self._alpha_est # defensive pricing under high contamination
|
||||
margins = (self.refs - self.costs) * margin_scale
|
||||
noise = self.rng.normal(0, 0.02, self.n) * self.costs
|
||||
prices = np.clip(self.costs + margins + noise, self.costs * 1.02, self.refs * 1.3)
|
||||
self.limbo.add_update("prices", prices)
|
||||
return prices
|
||||
|
||||
def observe_demand(self, prices: np.ndarray, alpha_true: float = 0.2, n_sessions: int = 50) -> Dict[str, float]:
|
||||
sessions, demand_map = put_prices_to_market(prices, costs=self.costs, alpha=alpha_true,
|
||||
n_sessions=n_sessions, seed=int(self.rng.integers(0, 10000)))
|
||||
self._last_sessions = sessions
|
||||
self._sessions.extend(sessions)
|
||||
self.limbo.add_update("demand", demand_map)
|
||||
return demand_map
|
||||
|
||||
def step(self, alpha_true: float = 0.2, n_sessions: int = 50) -> Tuple[np.ndarray, Dict[str, float], float, COIWindow]:
|
||||
demand_hist = self.limbo.get_demand_history()
|
||||
prices = self.compute_prices(demand_hist[-1] if demand_hist else None)
|
||||
demand = self.observe_demand(prices, alpha_true, n_sessions)
|
||||
reward = self._objective(prices, demand)
|
||||
return prices, demand, reward, self._last_coi or self._compute_coi_window(demand)
|
||||
|
||||
def run(self, n_steps: int = 100, alpha_true: float = 0.2) -> Dict:
|
||||
traj = {"prices": [], "demand": [], "rewards": [], "alpha_est": [], "alpha_true": alpha_true,
|
||||
"coi_policy": [], "coi_agent": [], "coi_leak": [], "coi_survival": []}
|
||||
for _ in range(n_steps):
|
||||
p, d, r, coi = self.step(alpha_true)
|
||||
traj["prices"].append(p); traj["demand"].append(d); traj["rewards"].append(r)
|
||||
traj["alpha_est"].append(self._alpha_est)
|
||||
traj["coi_policy"].append(coi.policy); traj["coi_agent"].append(coi.agent)
|
||||
traj["coi_leak"].append(coi.leak); traj["coi_survival"].append(coi.survival_ratio)
|
||||
return traj
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys = System(n_products=5, seed=42)
|
||||
traj = sys.run(n_steps=20, alpha_true=0.25)
|
||||
print(f"avg reward: {np.mean(traj['rewards']):.2f}, final alpha_hat: {traj['alpha_est'][-1]:.3f}, "
|
||||
f"COI_policy: {np.mean(traj['coi_policy']):.3f}, COI_agent: {np.mean(traj['coi_agent']):.3f}, leak: {np.mean(traj['coi_leak']):.3f}")
|
||||
|
||||
prices = np.array([20.0, 35.0, 50.0, 25.0, 40.0])
|
||||
costs = np.array([15.0, 28.0, 40.0, 18.0, 30.0])
|
||||
sessions, demand = put_prices_to_market(prices, costs=costs, alpha=0.3, n_sessions=20, seed=123)
|
||||
print(f'sessions: {len(sessions)}, agents: {sum(1 for s in sessions if s.actor=="A")}')
|
||||
|
||||
for n in [1, 5, 10, 50, 100]:
|
||||
# theoretical: erosion = 1 - 2/(N+1) for uniform order statistic
|
||||
print(f'N={n:3d} agents -> COI erosion: {1.0 - 2.0/(n+1):.3f}')
|
||||
|
||||
events = [Event('view', 0, 20.0, 0.1), Event('detail', 0, 20.0, 0.5), Event('cart', 0, 20.0, 1.0), Event('purchase', 0, 20.0, 2.0)]
|
||||
print(f'human-like session alpha_hat: {estimate_alpha(Session(sid="test", events=events, actor="H")):.3f}')
|
||||
|
||||
events_a = [Event('view', 0, 20.0, 0.1), Event('detail', 0, 20.0, 0.2), Event('view', 0, 20.0, 0.3), Event('detail', 0, 20.0, 0.4)]
|
||||
print(f'agent-like session alpha_hat: {estimate_alpha(Session(sid="test2", events=events_a, actor="A")):.3f}')
|
||||
302
lab/case/thesis/simplified_env.py
Normal file
302
lab/case/thesis/simplified_env.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""Gymnasium-compatible RL environment for thesis pricing system.
|
||||
|
||||
Wraps simplified.System with standard Gym interface for training pricing policies.
|
||||
Supports multiple reward modes and contamination scenarios.
|
||||
|
||||
Action: price multipliers [0.5, 1.5] applied to reference prices
|
||||
Observation: [prices, demand_agg, alpha_est, margins, position_proxy]
|
||||
Reward: configurable objective (revenue, profit, robust, coi-aware)
|
||||
|
||||
COI Correction (Jan 2026):
|
||||
The fundamental COI formulation is now:
|
||||
COI = E[p_start] - p_transaction
|
||||
|
||||
This measures price erosion over time, not instantaneous margin × alpha.
|
||||
Agents using different sessions gather information and drive prices down.
|
||||
The COITracker now tracks prices over windows to capture this effect.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, Tuple
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
HAS_GYM = True
|
||||
except ImportError:
|
||||
HAS_GYM = False
|
||||
|
||||
from .simplified import System, Session, Event, Limbo, put_prices_to_market, compute_demand, estimate_alpha
|
||||
from .coi import COIWindow, compute_coi_window, coi_erosion, COITracker, compute_multi_session_coi
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvConfig:
|
||||
n_products: int = 5
|
||||
max_steps: int = 200
|
||||
sessions_per_step: int = 30
|
||||
alpha_true: float = 0.2
|
||||
alpha_drift: float = 0.0
|
||||
alpha_bounds: Tuple[float, float] = (0.0, 0.6)
|
||||
lambda_coi: float = 0.5
|
||||
lambda_vol: float = 0.1
|
||||
reward_mode: str = "robust" # revenue | profit | robust | coi_aware
|
||||
normalize_reward: bool = True
|
||||
seed: int | None = 42
|
||||
|
||||
|
||||
def aggregate_purchases(sessions: list[Session], n_products: int, costs: np.ndarray) -> Tuple[np.ndarray, float, float]:
|
||||
"""Aggregate purchases from sessions, returns (counts, revenue, cost)."""
|
||||
purchases = np.zeros(n_products, dtype=float)
|
||||
revenue, cost = 0.0, 0.0
|
||||
for sess in sessions:
|
||||
for e in sess.events:
|
||||
if e.action == "purchase" and 0 <= e.product_idx < n_products:
|
||||
purchases[e.product_idx] += 1.0
|
||||
revenue += float(e.price_seen)
|
||||
cost += float(costs[e.product_idx])
|
||||
return purchases, revenue, cost
|
||||
|
||||
|
||||
class PricingEnv(gym.Env if HAS_GYM else object):
|
||||
"""RL environment for dynamic pricing under agent contamination.
|
||||
|
||||
Platform sets prices p_t, market responds with mixture demand Q(p) = (1-alpha)*D_H + alpha*D_A.
|
||||
Agent estimates contamination alpha_hat from behavioral signals.
|
||||
Reward balances profit vs COI leakage.
|
||||
"""
|
||||
metadata = {"render_modes": ["human", "ansi"]}
|
||||
|
||||
def __init__(self, cfg: EnvConfig | None = None):
|
||||
if not HAS_GYM:
|
||||
raise ImportError("gymnasium required")
|
||||
self.cfg = cfg or EnvConfig()
|
||||
self.n = self.cfg.n_products
|
||||
self._sys: System | None = None
|
||||
self._t = 0
|
||||
self._alpha = self.cfg.alpha_true
|
||||
self._last_prices: np.ndarray | None = None
|
||||
self._last_demand: Dict[str, float] | None = None
|
||||
self._episode_rewards: list[float] = []
|
||||
self._demand_agg = np.zeros(self.n)
|
||||
|
||||
# COI tracking: store initial prices for E[p] calculation
|
||||
self._initial_prices: np.ndarray | None = None
|
||||
self._coi_tracker = COITracker(window_size=10)
|
||||
self._last_coi_metrics: Dict[str, float] = {}
|
||||
self._last_window_coi: float = 0.0
|
||||
|
||||
self.action_space = spaces.Box(low=0.5, high=1.5, shape=(self.n,), dtype=np.float32)
|
||||
obs_dim = self.n + self.n + 1 + 1 + self.n + 1 # prices + demand + alpha_hat + alpha + margins + t
|
||||
self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(obs_dim,), dtype=np.float32)
|
||||
|
||||
def _build_obs(self) -> np.ndarray:
|
||||
if self._sys is None:
|
||||
return np.zeros(self.observation_space.shape[0], dtype=np.float32)
|
||||
prices = self._last_prices if self._last_prices is not None else self._sys.refs
|
||||
return np.concatenate([
|
||||
prices / (self._sys.refs + 1e-6),
|
||||
self._demand_agg / (np.sum(self._demand_agg) + 1e-6),
|
||||
[self._sys.alpha, self._alpha],
|
||||
(prices - self._sys.costs) / (self._sys.costs + 1e-6),
|
||||
[self._t / self.cfg.max_steps],
|
||||
]).astype(np.float32)
|
||||
|
||||
def _compute_reward(self, prices: np.ndarray, demand: Dict[str, float]) -> float:
|
||||
cfg, sys = self.cfg, self._sys
|
||||
if sys is None:
|
||||
return 0.0
|
||||
|
||||
# aggregate demand per product
|
||||
agg = np.zeros(self.n)
|
||||
for sid, q in demand.items():
|
||||
sess = next((s for s in sys._sessions if s.sid == sid), None)
|
||||
if sess and sess.events:
|
||||
agg[sess.events[0].product_idx] += q
|
||||
self._demand_agg = agg
|
||||
|
||||
_, revenue, cost = aggregate_purchases(sys._last_sessions, self.n, sys.costs)
|
||||
profit = revenue - cost
|
||||
|
||||
vol_penalty = 0.0
|
||||
if self._last_prices is not None:
|
||||
vol_penalty = cfg.lambda_vol * float(np.mean(np.abs(prices - self._last_prices) / (sys.refs + 1e-6)))
|
||||
|
||||
# Track prices for windowed COI calculation
|
||||
self._coi_tracker.add_step(prices)
|
||||
|
||||
# CORRECTED COI CALCULATION:
|
||||
# COI = E[p_start] - p_transaction (price erosion over time)
|
||||
# Use initial prices as E[p] and compute multi-session COI
|
||||
coi_metrics = compute_multi_session_coi(
|
||||
sessions=sys._last_sessions,
|
||||
costs=sys.costs,
|
||||
alpha=self._alpha,
|
||||
initial_prices=self._initial_prices,
|
||||
)
|
||||
leak = float(coi_metrics['leak'])
|
||||
|
||||
# Also compute window-based COI for trend analysis
|
||||
window_coi = self._coi_tracker.compute_window_coi(sys.costs)
|
||||
|
||||
# Store both for info dict
|
||||
self._last_coi_metrics = coi_metrics
|
||||
self._last_window_coi = window_coi
|
||||
|
||||
# For backward compatibility, also compute the old-style COI
|
||||
coi = compute_coi_window(sys._last_sessions, sys.costs, demand_mapping=demand)
|
||||
|
||||
reward_fns = {
|
||||
"revenue": lambda: revenue,
|
||||
"profit": lambda: profit,
|
||||
"robust": lambda: profit - cfg.lambda_coi * leak - vol_penalty,
|
||||
"coi_aware": lambda: profit - cfg.lambda_coi * (1 + 2 * sys.alpha) * leak - vol_penalty,
|
||||
}
|
||||
r = reward_fns.get(cfg.reward_mode, lambda: profit)()
|
||||
return float(r / (float(np.sum(sys.refs)) + 1e-6)) if cfg.normalize_reward else float(r)
|
||||
|
||||
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
|
||||
seed = seed if seed is not None else self.cfg.seed
|
||||
self._sys = System(n_products=self.n, lambda_coi=self.cfg.lambda_coi, seed=seed)
|
||||
self._t, self._alpha = 0, self.cfg.alpha_true
|
||||
self._last_prices, self._last_demand = None, None
|
||||
self._episode_rewards, self._demand_agg = [], np.zeros(self.n)
|
||||
|
||||
# COI tracking: store initial prices as E[p] for COI = E[p] - p calculation
|
||||
self._initial_prices = self._sys.refs.copy()
|
||||
self._coi_tracker.reset()
|
||||
|
||||
return self._build_obs(), {"alpha_true": self._alpha, "alpha_est": self._sys.alpha,
|
||||
"costs": self._sys.costs.copy(), "refs": self._sys.refs.copy()}
|
||||
|
||||
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
|
||||
if self._sys is None:
|
||||
raise RuntimeError("call reset() first")
|
||||
|
||||
action = np.clip(action, 0.5, 1.5)
|
||||
prices = np.clip(self._sys.refs * action.astype(np.float64), self._sys.costs * 1.01, self._sys.refs * 2.0)
|
||||
demand = self._sys.observe_demand(prices, alpha_true=self._alpha, n_sessions=self.cfg.sessions_per_step)
|
||||
self._sys.limbo.add_update("prices", prices)
|
||||
self._sys._alpha_est = self._sys._estimate_alpha_from_sessions()
|
||||
|
||||
reward = self._compute_reward(prices, demand)
|
||||
self._episode_rewards.append(reward)
|
||||
self._last_prices, self._last_demand = prices.copy(), demand
|
||||
self._t += 1
|
||||
|
||||
# compute info metrics using shared helper
|
||||
purchases, revenue, cost = aggregate_purchases(self._sys._last_sessions, self.n, self._sys.costs)
|
||||
n_agents = int(self._alpha * self.cfg.sessions_per_step)
|
||||
coi = compute_coi_window(self._sys._last_sessions, self._sys.costs, demand_mapping=demand)
|
||||
|
||||
# Corrected COI metrics (price erosion over time)
|
||||
coi_m = self._last_coi_metrics
|
||||
|
||||
info = {
|
||||
"alpha_true": self._alpha, "alpha_est": self._sys.alpha,
|
||||
"alpha_error": abs(self._alpha - self._sys.alpha),
|
||||
"revenue": float(revenue), "profit": float(revenue - cost), "cost": float(cost),
|
||||
"n_purchases": int(np.sum(purchases)),
|
||||
"avg_margin": float(np.mean((prices - self._sys.costs) / self._sys.costs)),
|
||||
"n_sessions": len(demand), "n_agents": n_agents, "price_std": float(np.std(prices)),
|
||||
# Legacy COI metrics (for backward compatibility)
|
||||
"coi_erosion": coi_erosion(coi.policy, coi.agent),
|
||||
"coi_policy": float(coi.policy), "coi_agent": float(coi.agent),
|
||||
"coi_leakage": float(coi.leak), "coi_survival": float(coi.survival_ratio),
|
||||
# CORRECTED COI metrics: E[p] - p (price erosion)
|
||||
"coi_policy_corrected": float(coi_m.get('policy_coi', 0)),
|
||||
"coi_agent_corrected": float(coi_m.get('agent_coi', 0)),
|
||||
"coi_human_corrected": float(coi_m.get('human_coi', 0)),
|
||||
"coi_realized": float(coi_m.get('realized_coi', 0)),
|
||||
"coi_leak_corrected": float(coi_m.get('leak', 0)),
|
||||
"coi_order_stat_erosion": float(coi_m.get('order_stat_erosion', 0)),
|
||||
"coi_survival_corrected": float(coi_m.get('survival_ratio', 1.0)),
|
||||
"coi_window": float(self._last_window_coi),
|
||||
"cumulative_reward": sum(self._episode_rewards), "step": self._t,
|
||||
}
|
||||
return self._build_obs(), reward, self._t >= self.cfg.max_steps, False, info
|
||||
|
||||
def render(self, mode: str = "human") -> str | None:
|
||||
if self._sys is None or self._last_prices is None:
|
||||
return None
|
||||
out = f"t={self._t}/{self.cfg.max_steps} | alpha_true={self._alpha:.3f} alpha_hat={self._sys.alpha:.3f} | " \
|
||||
f"prices: {self._last_prices.round(1)} | demand: {self._demand_agg.round(2)} | " \
|
||||
f"reward: {self._episode_rewards[-1] if self._episode_rewards else 0:.3f}"
|
||||
if mode == "human":
|
||||
print(out)
|
||||
return out
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class ContaminationSweepEnv(PricingEnv):
|
||||
"""Environment that sweeps through contamination levels during training."""
|
||||
|
||||
def __init__(self, cfg: EnvConfig | None = None, alpha_schedule: list[float] | None = None):
|
||||
super().__init__(cfg)
|
||||
self._schedule = alpha_schedule or [0.1, 0.2, 0.3, 0.4, 0.5]
|
||||
self._schedule_idx = 0
|
||||
|
||||
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
|
||||
if options and options.get("advance_schedule", False):
|
||||
self._schedule_idx = (self._schedule_idx + 1) % len(self._schedule)
|
||||
self.cfg.alpha_true = self._schedule[self._schedule_idx]
|
||||
return super().reset(seed, options)
|
||||
|
||||
|
||||
class AdversarialEnv(PricingEnv):
|
||||
"""Environment with adversarial contamination dynamics.
|
||||
|
||||
Contamination increases when prices are predictable (agents exploit).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: EnvConfig | None = None, exploitation_rate: float = 0.02):
|
||||
super().__init__(cfg)
|
||||
self._exploit_rate = exploitation_rate
|
||||
self._price_history: list[np.ndarray] = []
|
||||
|
||||
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, dict]:
|
||||
obs, reward, term, trunc, info = super().step(action)
|
||||
if self._last_prices is not None:
|
||||
self._price_history.append(self._last_prices.copy())
|
||||
predictability = 0.0
|
||||
if len(self._price_history) > 10:
|
||||
predictability = 1.0 / (float(np.std(self._price_history[-10:])) + 0.1)
|
||||
self._alpha = np.clip(self._alpha + self._exploit_rate * predictability * self._sys.rng.random(), *self.cfg.alpha_bounds)
|
||||
info["predictability"] = predictability
|
||||
return obs, reward, term, trunc, info
|
||||
|
||||
def reset(self, seed: int | None = None, options: dict | None = None) -> Tuple[np.ndarray, dict]:
|
||||
self._price_history = []
|
||||
return super().reset(seed, options)
|
||||
|
||||
|
||||
def make_env(cfg: EnvConfig | None = None, env_type: str = "standard") -> PricingEnv:
|
||||
return {"sweep": ContaminationSweepEnv, "adversarial": AdversarialEnv}.get(env_type, PricingEnv)(cfg)
|
||||
|
||||
|
||||
# baseline policies
|
||||
fixed_price_policy = lambda refs, margin=0.0: np.ones(len(refs), dtype=np.float32) * (1.0 + margin)
|
||||
random_policy = lambda n, rng=None: (rng or np.random.default_rng()).uniform(0.7, 1.3, n).astype(np.float32)
|
||||
adaptive_policy = lambda obs, n, base=0.1: np.ones(n, dtype=np.float32) * (1.0 + base * (1.0 - 0.4 * obs[2 * n]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cfg = EnvConfig(n_products=100, max_steps=100, alpha_true=0.25, reward_mode="robust")
|
||||
env = make_env(cfg)
|
||||
obs, info = env.reset()
|
||||
print(f"initial: alpha={info['alpha_true']:.2f}")
|
||||
|
||||
total_reward = 0.0
|
||||
for t in range(cfg.max_steps):
|
||||
action = adaptive_policy(obs, cfg.n_products)
|
||||
obs, reward, done, _, info = env.step(action)
|
||||
total_reward += reward
|
||||
if t % 10 == 0:
|
||||
env.render()
|
||||
if done:
|
||||
break
|
||||
|
||||
print(f"\ntotal reward: {total_reward:.2f}, final alpha_hat: {info['alpha_est']:.3f}")
|
||||
336
lab/case/thesis/train.py
Normal file
336
lab/case/thesis/train.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""RL training for thesis pricing system with thesis-aligned metrics.
|
||||
|
||||
Trains pricing policies using stable-baselines3 with TensorBoard logging.
|
||||
Tracks COI erosion, alpha estimation error, and economic KPIs per thesis formulation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from dataclasses import dataclass, asdict, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Callable, Any
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
from stable_baselines3 import PPO, SAC, A2C
|
||||
from stable_baselines3.common.callbacks import BaseCallback, EvalCallback
|
||||
from stable_baselines3.common.vec_env import DummyVecEnv
|
||||
from stable_baselines3.common.monitor import Monitor
|
||||
HAS_SB3 = True
|
||||
except ImportError:
|
||||
HAS_SB3 = False
|
||||
|
||||
try:
|
||||
from torch.utils.tensorboard import SummaryWriter
|
||||
HAS_TB = True
|
||||
except ImportError:
|
||||
HAS_TB = False
|
||||
|
||||
from .simplified_env import PricingEnv, EnvConfig, make_env, adaptive_policy, fixed_price_policy, random_policy
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeMetrics:
|
||||
reward: float = 0.0
|
||||
revenue: float = 0.0
|
||||
profit: float = 0.0
|
||||
coi_erosion: float = 0.0
|
||||
coi_leakage: float = 0.0
|
||||
alpha_error: float = 0.0
|
||||
avg_margin: float = 0.0
|
||||
n_agents: int = 0
|
||||
steps: int = 0
|
||||
|
||||
def accumulate(self, info: Dict[str, Any]) -> None:
|
||||
self.steps += 1
|
||||
self.reward += info.get('reward', 0)
|
||||
self.revenue += info.get('revenue', 0)
|
||||
self.profit += info.get('profit', 0)
|
||||
self.coi_erosion += info.get('coi_erosion', 0)
|
||||
self.coi_leakage += info.get('coi_leakage', 0)
|
||||
self.alpha_error += abs(info.get('alpha_true', 0) - info.get('alpha_est', 0))
|
||||
self.avg_margin += info.get('avg_margin', 0)
|
||||
self.n_agents += info.get('n_agents', 0)
|
||||
|
||||
def normalized(self) -> Dict[str, float]:
|
||||
s = max(self.steps, 1)
|
||||
return {k: getattr(self, k) / s for k in ['revenue', 'profit', 'coi_erosion', 'coi_leakage', 'alpha_error', 'avg_margin', 'n_agents']}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExperimentConfig:
|
||||
algo: str = "ppo"
|
||||
total_timesteps: int = 100_000
|
||||
n_envs: int = 4
|
||||
eval_freq: int = 5000
|
||||
n_eval_episodes: int = 10
|
||||
log_dir: str = "lab/case/thesis/runs"
|
||||
seed: int = 42
|
||||
n_products: int = 10
|
||||
max_steps: int = 200
|
||||
alpha_true: float = 0.2
|
||||
reward_mode: str = "robust"
|
||||
experiment_name: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.experiment_name is None:
|
||||
self.experiment_name = f"{self.algo}_a{self.alpha_true:.2f}_{self.reward_mode}"
|
||||
|
||||
|
||||
class Policy:
|
||||
"""Unified policy interface for baselines and trained models."""
|
||||
|
||||
def __init__(self, policy_fn: Callable[[np.ndarray, int], np.ndarray], name: str):
|
||||
self._fn, self.name = policy_fn, name
|
||||
|
||||
def predict(self, obs: np.ndarray, deterministic: bool = True) -> tuple[np.ndarray, None]:
|
||||
return self._fn(obs, (len(obs) - 3) // 3), None
|
||||
|
||||
@staticmethod
|
||||
def fixed(margin: float = 0.15) -> "Policy":
|
||||
return Policy(lambda obs, n: fixed_price_policy(np.ones(n), margin), f"fixed_{margin:.2f}")
|
||||
|
||||
@staticmethod
|
||||
def adaptive(base_margin: float = 0.15) -> "Policy":
|
||||
return Policy(lambda obs, n: adaptive_policy(obs, n, base_margin), f"adaptive_{base_margin:.2f}")
|
||||
|
||||
@staticmethod
|
||||
def random() -> "Policy":
|
||||
return Policy(lambda obs, n: random_policy(n), "random")
|
||||
|
||||
@staticmethod
|
||||
def myopic(greed: float = 0.3) -> "Policy":
|
||||
def _fn(obs: np.ndarray, n: int) -> np.ndarray:
|
||||
demand_norm = obs[n:2*n] if len(obs) > 2*n else np.ones(n) * 0.5
|
||||
return np.ones(n, dtype=np.float32) * np.clip(1.0 + greed * (1 + np.mean(demand_norm)), 0.5, 1.5)
|
||||
return Policy(_fn, f"myopic_{greed:.1f}")
|
||||
|
||||
|
||||
def log_metrics(writer: SummaryWriter | None, metrics: Dict[str, float], prefix: str, step: int) -> None:
|
||||
if writer is None:
|
||||
return
|
||||
for k, v in metrics.items():
|
||||
writer.add_scalar(f'{prefix}/{k}', v, step)
|
||||
|
||||
|
||||
class MetricsCallback(BaseCallback):
|
||||
def __init__(self, writer: SummaryWriter | None, verbose: int = 0):
|
||||
super().__init__(verbose)
|
||||
self._writer = writer
|
||||
|
||||
def _on_step(self) -> bool:
|
||||
if self._writer is None:
|
||||
return True
|
||||
for info in self.locals.get('infos', []):
|
||||
t = self.num_timesteps
|
||||
self._writer.add_scalar('economics/revenue', info.get('revenue', 0), t)
|
||||
self._writer.add_scalar('economics/profit', info.get('profit', 0), t)
|
||||
self._writer.add_scalar('economics/margin', info.get('avg_margin', 0), t)
|
||||
self._writer.add_scalar('coi/erosion', info.get('coi_erosion', 0), t)
|
||||
self._writer.add_scalar('coi/leakage', info.get('coi_leakage', 0), t)
|
||||
self._writer.add_scalar('alpha/estimation_error', abs(info.get('alpha_true', 0) - info.get('alpha_est', 0)), t)
|
||||
self._writer.add_scalar('agents/count', info.get('n_agents', 0), t)
|
||||
return True
|
||||
|
||||
|
||||
def make_vec_env(cfg: ExperimentConfig, n_envs: int = 1) -> DummyVecEnv:
|
||||
def _make():
|
||||
return Monitor(make_env(EnvConfig(n_products=cfg.n_products, max_steps=cfg.max_steps,
|
||||
alpha_true=cfg.alpha_true, reward_mode=cfg.reward_mode, seed=cfg.seed)))
|
||||
return DummyVecEnv([_make for _ in range(n_envs)])
|
||||
|
||||
|
||||
def run_episodes(policy: Policy | Any, env: PricingEnv, n_episodes: int) -> List[EpisodeMetrics]:
|
||||
"""Run policy for n episodes and collect metrics."""
|
||||
metrics = []
|
||||
for _ in range(n_episodes):
|
||||
obs, _ = env.reset()
|
||||
ep, done = EpisodeMetrics(), False
|
||||
while not done:
|
||||
action, _ = policy.predict(obs, deterministic=True)
|
||||
obs, reward, term, trunc, info = env.step(action)
|
||||
done = term or trunc
|
||||
ep.accumulate(info)
|
||||
ep.reward += reward
|
||||
metrics.append(ep)
|
||||
return metrics
|
||||
|
||||
|
||||
def evaluate_policy(policy: Policy | Any, cfg: ExperimentConfig, n_episodes: int = 20) -> Dict[str, float]:
|
||||
env = make_env(EnvConfig(n_products=cfg.n_products, max_steps=cfg.max_steps,
|
||||
alpha_true=cfg.alpha_true, reward_mode=cfg.reward_mode, seed=cfg.seed + 999))
|
||||
metrics = run_episodes(policy, env, n_episodes)
|
||||
return {
|
||||
'reward_mean': np.mean([m.reward for m in metrics]), 'reward_std': np.std([m.reward for m in metrics]),
|
||||
**{f'{k}_mean': np.mean([m.normalized()[k] for m in metrics])
|
||||
for k in ['revenue', 'profit', 'coi_erosion', 'coi_leakage', 'alpha_error', 'avg_margin']},
|
||||
}
|
||||
|
||||
|
||||
def run_baseline(policy: Policy, vec_env: DummyVecEnv, total_steps: int, writer: SummaryWriter | None):
|
||||
obs, n_envs = vec_env.reset(), vec_env.num_envs
|
||||
ep_rewards = np.zeros(n_envs)
|
||||
|
||||
for step in range(0, total_steps, n_envs):
|
||||
actions = np.array([policy.predict(obs[i])[0] for i in range(n_envs)])
|
||||
obs, rewards, dones, infos = vec_env.step(actions)
|
||||
ep_rewards += rewards
|
||||
for i, info in enumerate(infos):
|
||||
if writer:
|
||||
writer.add_scalar('economics/revenue', info.get('revenue', 0), step)
|
||||
writer.add_scalar('economics/profit', info.get('profit', 0), step)
|
||||
writer.add_scalar('economics/margin', info.get('avg_margin', 0), step)
|
||||
writer.add_scalar('coi/erosion', info.get('coi_erosion', 0), step)
|
||||
writer.add_scalar('coi/leakage', info.get('coi_leakage', 0), step)
|
||||
writer.add_scalar('alpha/estimation_error', abs(info.get('alpha_true', 0) - info.get('alpha_est', 0)), step)
|
||||
writer.add_scalar('agents/count', info.get('n_agents', 0), step)
|
||||
if dones[i]:
|
||||
if writer:
|
||||
writer.add_scalar('rollout/ep_reward', ep_rewards[i], step)
|
||||
ep_rewards[i] = 0
|
||||
|
||||
|
||||
def train(cfg: ExperimentConfig) -> Dict[str, Any]:
|
||||
is_baseline = cfg.algo.lower() in ["fixed", "adaptive", "random", "myopic"]
|
||||
if not HAS_SB3 and not is_baseline:
|
||||
raise ImportError("stable-baselines3 required: pip install stable-baselines3[extra]")
|
||||
|
||||
log_path = Path(cfg.log_dir) / cfg.experiment_name
|
||||
log_path.mkdir(parents=True, exist_ok=True)
|
||||
with open(log_path / "config.json", "w") as f:
|
||||
json.dump(asdict(cfg), f, indent=2)
|
||||
|
||||
writer = SummaryWriter(log_path) if HAS_TB else None
|
||||
train_env, eval_env = make_vec_env(cfg, cfg.n_envs), make_vec_env(cfg, 1)
|
||||
|
||||
if is_baseline:
|
||||
policy = {"fixed": Policy.fixed, "adaptive": Policy.adaptive, "random": Policy.random, "myopic": Policy.myopic}[cfg.algo.lower()]()
|
||||
run_baseline(policy, train_env, cfg.total_timesteps, writer)
|
||||
final_metrics = evaluate_policy(policy, cfg)
|
||||
else:
|
||||
algo_cls = {"ppo": PPO, "sac": SAC, "a2c": A2C}[cfg.algo.lower()]
|
||||
common = dict(verbose=1, seed=cfg.seed, tensorboard_log=str(log_path), device="auto")
|
||||
model = {
|
||||
"ppo": lambda: PPO("MlpPolicy", train_env, learning_rate=3e-4, n_steps=2048, batch_size=64, n_epochs=10, gamma=0.99, gae_lambda=0.95, clip_range=0.2, ent_coef=0.01, **common),
|
||||
"sac": lambda: SAC("MlpPolicy", train_env, learning_rate=1e-4, buffer_size=50_000, batch_size=512, tau=0.02, gamma=0.99, learning_starts=1000, ent_coef="auto_0.1", train_freq=4, **common),
|
||||
"a2c": lambda: A2C("MlpPolicy", train_env, learning_rate=7e-4, n_steps=5, gamma=0.99, **common),
|
||||
}[cfg.algo.lower()]()
|
||||
|
||||
cb = MetricsCallback(writer)
|
||||
eval_cb = EvalCallback(eval_env, best_model_save_path=str(log_path / "best"), log_path=str(log_path),
|
||||
eval_freq=cfg.eval_freq, n_eval_episodes=cfg.n_eval_episodes, deterministic=True)
|
||||
model.learn(cfg.total_timesteps, callback=[cb, eval_cb], progress_bar=True)
|
||||
model.save(log_path / "final_model")
|
||||
policy = model
|
||||
final_metrics = evaluate_policy(model, cfg)
|
||||
|
||||
if writer:
|
||||
log_metrics(writer, final_metrics, 'final', cfg.total_timesteps)
|
||||
writer.close()
|
||||
|
||||
train_env.close(); eval_env.close()
|
||||
with open(log_path / "results.json", "w") as f:
|
||||
json.dump(final_metrics, f, indent=2)
|
||||
return {"path": str(log_path), "metrics": final_metrics}
|
||||
|
||||
|
||||
def _train_alpha(args: tuple) -> tuple[str, Dict]:
|
||||
"""Worker for parallel sweep - must be top-level for pickling."""
|
||||
cfg_dict, alpha = args
|
||||
cfg_dict["alpha_true"] = alpha
|
||||
cfg_dict["experiment_name"] = f"{cfg_dict['algo']}_a{alpha:.2f}_{cfg_dict['reward_mode']}"
|
||||
sweep_cfg = ExperimentConfig(**cfg_dict)
|
||||
print(f"[alpha={alpha:.2f}] starting")
|
||||
metrics = train(sweep_cfg)["metrics"]
|
||||
print(f"[alpha={alpha:.2f}] done")
|
||||
return f"alpha_{alpha:.2f}", metrics
|
||||
|
||||
|
||||
def run_sweep(cfg: ExperimentConfig, alphas: List[float] | None = None, max_workers: int | None = None) -> Dict[str, Dict]:
|
||||
alphas = alphas or [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]
|
||||
cfg_dict = asdict(cfg)
|
||||
|
||||
if max_workers == 1: # sequential fallback
|
||||
results = dict(_train_alpha((cfg_dict.copy(), a)) for a in alphas)
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_train_alpha, (cfg_dict.copy(), a)): a for a in alphas}
|
||||
results = {}
|
||||
for fut in as_completed(futures):
|
||||
key, metrics = fut.result()
|
||||
results[key] = metrics
|
||||
|
||||
summary_path = Path(cfg.log_dir) / f"sweep_{cfg.algo}_{cfg.reward_mode}.json"
|
||||
with open(summary_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nSweep results saved to {summary_path}")
|
||||
return results
|
||||
|
||||
|
||||
def _train_policy(args: tuple) -> tuple[str, Dict]:
|
||||
"""Worker for parallel policy comparison."""
|
||||
cfg_dict, algo = args
|
||||
cfg_dict["algo"] = algo
|
||||
cfg_dict["experiment_name"] = f"cmp_{algo}_a{cfg_dict['alpha_true']:.2f}"
|
||||
cmp_cfg = ExperimentConfig(**cfg_dict)
|
||||
print(f"[{algo}] starting")
|
||||
metrics = train(cmp_cfg)["metrics"]
|
||||
print(f"[{algo}] done")
|
||||
return algo, metrics
|
||||
|
||||
|
||||
def compare_policies(cfg: ExperimentConfig, policies: List[str] | None = None, max_workers: int | None = None) -> Dict[str, Dict]:
|
||||
policies = policies or ["fixed", "adaptive", "myopic", "random"]
|
||||
cfg_dict = asdict(cfg)
|
||||
|
||||
if max_workers == 1:
|
||||
results = dict(_train_policy((cfg_dict.copy(), p)) for p in policies)
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=max_workers) as pool:
|
||||
futures = {pool.submit(_train_policy, (cfg_dict.copy(), p)): p for p in policies}
|
||||
results = {}
|
||||
for fut in as_completed(futures):
|
||||
algo, metrics = fut.result()
|
||||
results[algo] = metrics
|
||||
|
||||
cmp_path = Path(cfg.log_dir) / f"compare_a{cfg.alpha_true:.2f}.json"
|
||||
with open(cmp_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"\nComparison saved to {cmp_path}")
|
||||
for algo, m in results.items():
|
||||
print(f" {algo:12s}: reward={m['reward_mean']:.2f} coi_erosion={m['coi_erosion_mean']:.4f} alpha_err={m['alpha_error_mean']:.4f}")
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Train RL pricing policies")
|
||||
parser.add_argument("--algo", default="ppo", choices=["ppo", "sac", "a2c", "fixed", "adaptive", "random", "myopic"])
|
||||
parser.add_argument("--steps", type=int, default=100_000)
|
||||
parser.add_argument("--alpha", type=float, default=0.2)
|
||||
parser.add_argument("--reward-mode", default="robust", choices=["revenue", "profit", "robust", "coi_aware"])
|
||||
parser.add_argument("--n-products", type=int, default=10)
|
||||
parser.add_argument("--n-envs", type=int, default=4)
|
||||
parser.add_argument("--seed", type=int, default=42)
|
||||
parser.add_argument("--log-dir", default="lab/case/thesis/runs")
|
||||
parser.add_argument("--sweep", action="store_true", help="run contamination sweep")
|
||||
parser.add_argument("--compare", action="store_true", help="compare all baselines")
|
||||
parser.add_argument("--workers", type=int, default=None, help="max parallel workers for sweep (None=auto, 1=sequential)")
|
||||
args = parser.parse_args()
|
||||
|
||||
cfg = ExperimentConfig(algo=args.algo, total_timesteps=args.steps, alpha_true=args.alpha,
|
||||
reward_mode=args.reward_mode, n_products=args.n_products,
|
||||
n_envs=args.n_envs, seed=args.seed, log_dir=args.log_dir)
|
||||
|
||||
if args.sweep:
|
||||
run_sweep(cfg, max_workers=args.workers)
|
||||
elif args.compare:
|
||||
compare_policies(cfg, max_workers=args.workers)
|
||||
else:
|
||||
result = train(cfg)
|
||||
print(f"\nTraining complete: {result['path']}")
|
||||
print(f"Metrics: {json.dumps(result['metrics'], indent=2)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
156
lab/config.py
Normal file
156
lab/config.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
Configuration and factory functions for creating pre-configured platforms.
|
||||
|
||||
This module provides:
|
||||
- RetailConfig, MarketMakingConfig: Configuration dataclasses
|
||||
- make_retail_platform: Factory for retail dynamic pricing scenarios
|
||||
- make_market_making_platform: Factory for market making scenarios
|
||||
|
||||
Example:
|
||||
>>> from lab.config import make_retail_platform
|
||||
>>> platform = make_retail_platform(RetailConfig(n_instruments=5))
|
||||
>>> result = platform.reset(seed=42)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from .outlet import (Platform, PlatformConfig, PositionModel, PositionConfig,
|
||||
PostedPriceMechanism, TwoSidedMechanism, make_instruments,
|
||||
InstrumentType, LogLevel)
|
||||
from .outlet.mechanisms.posted_price import PostedPriceConfig
|
||||
from .outlet.mechanisms.two_sided import TwoSidedConfig
|
||||
from .population import (SessionArrivalModel, PoissonArrivalModel, HawkesArrivalModel,
|
||||
ElasticityExecutionModel, IntensityExecutionModel,
|
||||
ReactiveCompetitorModel, GBMMarketModel)
|
||||
from .population.arrivals import SessionArrivalConfig, PoissonArrivalConfig, HawkesArrivalConfig
|
||||
from .population.execution import ElasticityConfig, IntensityConfig
|
||||
from .population.competitors import ReactiveCompetitorConfig, GBMMarketConfig
|
||||
from .outlet.objectives.factory import retail_objective, market_making_objective
|
||||
|
||||
@dataclass
|
||||
class RetailConfig:
|
||||
"""Configuration for retail dynamic pricing scenario.
|
||||
|
||||
Attributes:
|
||||
n_instruments: Number of products to price
|
||||
cost_range: (min, max) for random product costs
|
||||
margin_range: (min, max) for random initial margins
|
||||
initial_inventory: Starting inventory per product
|
||||
holding_cost_rate: Cost per unit per step for holding
|
||||
sessions_per_step: Number of browsing sessions per step
|
||||
contamination: Fraction of sessions that are scrapers
|
||||
max_steps: Maximum episode length
|
||||
seed: Random seed for reproducibility
|
||||
"""
|
||||
n_instruments: int = 10
|
||||
cost_range: tuple[float, float] = (5.0, 50.0)
|
||||
margin_range: tuple[float, float] = (0.2, 0.5)
|
||||
initial_inventory: float = 100.0
|
||||
holding_cost_rate: float = 0.002
|
||||
sessions_per_step: int = 30
|
||||
contamination: float = 0.1
|
||||
max_steps: int = 500
|
||||
seed: int | None = None
|
||||
|
||||
def make_retail_platform(cfg: RetailConfig | None = None) -> Platform:
|
||||
"""Create a pre-configured retail dynamic pricing platform.
|
||||
|
||||
Components:
|
||||
- Mechanism: PostedPriceMechanism (single price per product)
|
||||
- Arrivals: SessionArrivalModel (browsing sessions with views)
|
||||
- Execution: ElasticityExecutionModel (price sensitivity)
|
||||
- Market: ReactiveCompetitorModel (can trigger price wars)
|
||||
- Objective: PnL - holding_cost - volatility - lost_opportunity
|
||||
|
||||
Args:
|
||||
cfg: Configuration (uses defaults if None)
|
||||
|
||||
Returns:
|
||||
Configured Platform instance
|
||||
"""
|
||||
cfg = cfg or RetailConfig()
|
||||
rng = np.random.default_rng(cfg.seed)
|
||||
|
||||
instruments = make_instruments(cfg.n_instruments, cfg.cost_range, cfg.margin_range,
|
||||
InstrumentType.SKU, rng)
|
||||
instruments.position = np.full(cfg.n_instruments, cfg.initial_inventory)
|
||||
|
||||
mechanism = PostedPriceMechanism(PostedPriceConfig())
|
||||
arrival = SessionArrivalModel(SessionArrivalConfig(
|
||||
sessions_per_step=cfg.sessions_per_step, contamination=cfg.contamination))
|
||||
execution = ElasticityExecutionModel(ElasticityConfig())
|
||||
position = PositionModel(PositionConfig(
|
||||
initial_position=cfg.initial_inventory,
|
||||
holding_cost_rate=cfg.holding_cost_rate))
|
||||
market = ReactiveCompetitorModel(ReactiveCompetitorConfig(), refs=instruments.refs)
|
||||
objective = retail_objective()
|
||||
|
||||
return Platform(
|
||||
instruments=instruments, mechanism=mechanism, arrival=arrival,
|
||||
execution=execution, position=position, market=market, objective=objective,
|
||||
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
||||
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
|
||||
)
|
||||
|
||||
@dataclass
|
||||
class MarketMakingConfig:
|
||||
"""Configuration for market making scenario.
|
||||
|
||||
Attributes:
|
||||
n_instruments: Number of assets to quote
|
||||
initial_mid: Initial mid-price for assets
|
||||
mu: Price drift (expected return)
|
||||
sigma: Price volatility
|
||||
gamma: Inventory risk aversion parameter
|
||||
base_arrival_rate: Order arrival rate (Hawkes baseline)
|
||||
max_steps: Maximum episode length
|
||||
seed: Random seed for reproducibility
|
||||
"""
|
||||
n_instruments: int = 5
|
||||
initial_mid: float = 100.0
|
||||
mu: float = 0.0
|
||||
sigma: float = 0.02
|
||||
gamma: float = 0.1
|
||||
base_arrival_rate: float = 20.0
|
||||
max_steps: int = 1000
|
||||
seed: int | None = None
|
||||
|
||||
def make_market_making_platform(cfg: MarketMakingConfig | None = None) -> Platform:
|
||||
"""Create a pre-configured market making platform.
|
||||
|
||||
Components:
|
||||
- Mechanism: TwoSidedMechanism (bid-ask spread quoting)
|
||||
- Arrivals: HawkesArrivalModel (clustered order flow)
|
||||
- Execution: IntensityExecutionModel (distance-based fills)
|
||||
- Market: GBMMarketModel (geometric Brownian motion mid-prices)
|
||||
- Objective: PnL + spread_capture - inventory_risk
|
||||
|
||||
Args:
|
||||
cfg: Configuration (uses defaults if None)
|
||||
|
||||
Returns:
|
||||
Configured Platform instance
|
||||
"""
|
||||
cfg = cfg or MarketMakingConfig()
|
||||
rng = np.random.default_rng(cfg.seed)
|
||||
|
||||
instruments = make_instruments(cfg.n_instruments, (cfg.initial_mid*0.9, cfg.initial_mid*1.1),
|
||||
(0.0, 0.0), InstrumentType.ASSET, rng)
|
||||
instruments.position = np.zeros(cfg.n_instruments)
|
||||
|
||||
mechanism = TwoSidedMechanism(TwoSidedConfig())
|
||||
arrival = HawkesArrivalModel(HawkesArrivalConfig(base_rate=cfg.base_arrival_rate))
|
||||
execution = IntensityExecutionModel(IntensityConfig())
|
||||
position = PositionModel(PositionConfig(
|
||||
initial_position=0.0, min_position=-500, max_position=500,
|
||||
holding_cost_rate=0.0)) # use inventory risk penalty instead
|
||||
market = GBMMarketModel(GBMMarketConfig(mu=cfg.mu, sigma=cfg.sigma),
|
||||
initial=instruments.refs)
|
||||
objective = market_making_objective(gamma=cfg.gamma, sigma=cfg.sigma)
|
||||
|
||||
return Platform(
|
||||
instruments=instruments, mechanism=mechanism, arrival=arrival,
|
||||
execution=execution, position=position, market=market, objective=objective,
|
||||
cfg=PlatformConfig(n_instruments=cfg.n_instruments, max_steps=cfg.max_steps,
|
||||
seed=cfg.seed, log_level=LogLevel.AGG_ONLY)
|
||||
)
|
||||
12
lab/docs/Makefile
Normal file
12
lab/docs/Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = .
|
||||
BUILDDIR = _build
|
||||
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
39
lab/docs/conf.py
Normal file
39
lab/docs/conf.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../..'))
|
||||
|
||||
project = 'Quote-Control Simulator'
|
||||
copyright = '2025, PHANTOM Research'
|
||||
author = 'PHANTOM Research'
|
||||
release = '0.1.0'
|
||||
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.napoleon',
|
||||
'sphinx.ext.viewcode',
|
||||
'sphinx.ext.intersphinx',
|
||||
'sphinx.ext.autosummary',
|
||||
]
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
|
||||
|
||||
html_theme = 'alabaster'
|
||||
html_static_path = ['_static']
|
||||
|
||||
autodoc_default_options = {
|
||||
'members': True,
|
||||
'undoc-members': True,
|
||||
'show-inheritance': True,
|
||||
}
|
||||
|
||||
napoleon_google_docstring = True
|
||||
napoleon_numpy_docstring = True
|
||||
napoleon_include_init_with_doc = True
|
||||
|
||||
intersphinx_mapping = {
|
||||
'python': ('https://docs.python.org/3', None),
|
||||
'numpy': ('https://numpy.org/doc/stable/', None),
|
||||
}
|
||||
|
||||
autosummary_generate = True
|
||||
40
lab/docs/index.rst
Normal file
40
lab/docs/index.rst
Normal file
@@ -0,0 +1,40 @@
|
||||
Quote-Control Simulator
|
||||
=======================
|
||||
|
||||
Research-grade platform for dynamic pricing and market making experiments.
|
||||
|
||||
The platform abstracts pricing as: **Quote → Arrival → Execution → Position**
|
||||
|
||||
Supports multiple mechanisms:
|
||||
|
||||
* **PostedPrice**: retail dynamic pricing
|
||||
* **TwoSided**: market making with bid-ask spreads
|
||||
* **Auction**: reserve/shading for auction settings
|
||||
|
||||
Quick Start
|
||||
-----------
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from lab.config import make_retail_platform
|
||||
from lab.experiments import rollout, fixed_price_policy
|
||||
|
||||
platform = make_retail_platform()
|
||||
policy = fixed_price_policy(platform.instruments.refs)
|
||||
result = rollout(platform, policy, n_steps=100)
|
||||
print(f"Total PnL: {result.total_pnl:.2f}")
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
system_overview
|
||||
modules/outlet
|
||||
modules/population
|
||||
modules/experiments
|
||||
|
||||
Indices
|
||||
-------
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
14
lab/docs/modules/experiments.rst
Normal file
14
lab/docs/modules/experiments.rst
Normal file
@@ -0,0 +1,14 @@
|
||||
Experiments
|
||||
===========
|
||||
|
||||
Evaluation & OPE
|
||||
----------------
|
||||
|
||||
.. automodule:: lab.experiments.eval
|
||||
:members:
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
.. automodule:: lab.config
|
||||
:members:
|
||||
77
lab/docs/modules/outlet.rst
Normal file
77
lab/docs/modules/outlet.rst
Normal file
@@ -0,0 +1,77 @@
|
||||
Outlet (Core Simulator)
|
||||
=======================
|
||||
|
||||
Types
|
||||
-----
|
||||
|
||||
.. automodule:: lab.outlet.types
|
||||
:members:
|
||||
|
||||
Constants
|
||||
---------
|
||||
|
||||
.. automodule:: lab.outlet.constants
|
||||
:members:
|
||||
|
||||
Protocols
|
||||
---------
|
||||
|
||||
.. automodule:: lab.outlet.protocols
|
||||
:members:
|
||||
|
||||
Platform
|
||||
--------
|
||||
|
||||
.. automodule:: lab.outlet.platform
|
||||
:members:
|
||||
|
||||
Stock & Position
|
||||
----------------
|
||||
|
||||
.. automodule:: lab.outlet.stock
|
||||
:members:
|
||||
|
||||
Observation
|
||||
-----------
|
||||
|
||||
.. automodule:: lab.outlet.observation
|
||||
:members:
|
||||
|
||||
Mechanisms
|
||||
----------
|
||||
|
||||
Posted Price
|
||||
~~~~~~~~~~~~
|
||||
|
||||
.. automodule:: lab.outlet.mechanisms.posted_price
|
||||
:members:
|
||||
|
||||
Two-Sided (Market Making)
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. automodule:: lab.outlet.mechanisms.two_sided
|
||||
:members:
|
||||
|
||||
Auction
|
||||
~~~~~~~
|
||||
|
||||
.. automodule:: lab.outlet.mechanisms.auction
|
||||
:members:
|
||||
|
||||
Objectives
|
||||
----------
|
||||
|
||||
.. automodule:: lab.outlet.objectives.base
|
||||
:members:
|
||||
|
||||
.. automodule:: lab.outlet.objectives.penalties
|
||||
:members:
|
||||
|
||||
.. automodule:: lab.outlet.objectives.factory
|
||||
:members:
|
||||
|
||||
Math Utilities
|
||||
--------------
|
||||
|
||||
.. automodule:: lab.outlet.math_util
|
||||
:members:
|
||||
20
lab/docs/modules/population.rst
Normal file
20
lab/docs/modules/population.rst
Normal file
@@ -0,0 +1,20 @@
|
||||
Population Models
|
||||
=================
|
||||
|
||||
Arrival Models
|
||||
--------------
|
||||
|
||||
.. automodule:: lab.population.arrivals
|
||||
:members:
|
||||
|
||||
Execution Models
|
||||
----------------
|
||||
|
||||
.. automodule:: lab.population.execution
|
||||
:members:
|
||||
|
||||
Competitor / Market Models
|
||||
--------------------------
|
||||
|
||||
.. automodule:: lab.population.competitors
|
||||
:members:
|
||||
97
lab/docs/system_overview.rst
Normal file
97
lab/docs/system_overview.rst
Normal file
@@ -0,0 +1,97 @@
|
||||
System Overview
|
||||
===============
|
||||
|
||||
The simulator organises dynamic pricing and market-making experiments as a
|
||||
closed loop with the following stages:
|
||||
|
||||
* **Quote** – a policy or agent emits a :class:`lab.outlet.types.Quote`. The
|
||||
quote is normalised and validated by a concrete
|
||||
:class:`lab.outlet.protocols.Mechanism` implementation
|
||||
(posted-price, two-sided, auction).
|
||||
* **Arrival** – a :class:`lab.outlet.protocols.ArrivalModel` samples a stream of
|
||||
:class:`lab.outlet.types.Opportunity` objects given the current time,
|
||||
instrument catalogue, and market state.
|
||||
* **Execution** – the :class:`lab.outlet.protocols.ExecutionModel` converts an
|
||||
opportunity into a probabilistic fill using the active quote, optional
|
||||
competitor prices, and demand-side context.
|
||||
* **Position** – a :class:`lab.outlet.protocols.PositionModel` enforces
|
||||
inventory or position constraints, censors oversized fills, and accrues
|
||||
holding and shortage costs.
|
||||
* **Observation & Reward** – the
|
||||
:class:`lab.outlet.protocols.ObservationBuilder` constructs the censored view
|
||||
exposed to the agent, while a :class:`lab.outlet.protocols.Objective`
|
||||
transforms :class:`lab.outlet.types.StepMetrics` into a scalar reward with an
|
||||
optional breakdown per term.
|
||||
|
||||
These components are orchestrated by :class:`lab.outlet.platform.Platform`,
|
||||
which manages internal hidden state, deterministic seeding, and logging.
|
||||
|
||||
Component Matrix
|
||||
----------------
|
||||
|
||||
=============================== ==============================================
|
||||
Layer Responsibilities / Examples
|
||||
=============================== ==============================================
|
||||
Mechanisms Quote normalisation, execution semantics
|
||||
(`posted_price`, `two_sided`, `auction`).
|
||||
Population models Arrivals (:mod:`lab.population.arrivals`),
|
||||
execution probability models
|
||||
(:mod:`lab.population.execution`), and
|
||||
competitor or market dynamics
|
||||
(:mod:`lab.population.competitors`).
|
||||
Position management Inventory limits, replenishment, holding and
|
||||
shortage costs (:mod:`lab.outlet.stock`).
|
||||
Observation & logging Censored observations and optional event logs
|
||||
(:mod:`lab.outlet.observation`).
|
||||
Objectives Reward composition utilities
|
||||
(:mod:`lab.outlet.objectives`).
|
||||
Experiments Rollout helpers, baseline policies, off-policy
|
||||
evaluation (:mod:`lab.experiments.eval`).
|
||||
=============================== ==============================================
|
||||
|
||||
Preconfigured Platforms
|
||||
-----------------------
|
||||
|
||||
Two high-level factories in :mod:`lab.config` wire common combinations of the
|
||||
building blocks:
|
||||
|
||||
* **Retail dynamic pricing** – posted-price mechanism, session arrivals with
|
||||
contamination, elasticity-based executions, reactive competitor model, and a
|
||||
composite objective that penalises volatility, holding costs, and lost
|
||||
opportunities.
|
||||
* **Market making** – two-sided quoting, Hawkes order flow, intensity-based
|
||||
executions, geometric Brownian motion mid-prices, and an objective combining
|
||||
PnL, spread capture, and quadratic inventory risk.
|
||||
|
||||
State & Reset Behaviour
|
||||
-----------------------
|
||||
|
||||
When you call :meth:`lab.outlet.platform.Platform.reset`, the platform resets
|
||||
instrument positions, quotes, and hidden state, but component implementations
|
||||
may maintain their own internal buffers. For reproducible experiments:
|
||||
|
||||
* Reuse freshly instantiated arrival/market models per episode, or add explicit
|
||||
``reset`` methods if the model keeps history (for example,
|
||||
:class:`lab.population.arrivals.HawkesArrivalModel` maintains an event
|
||||
history, while :class:`lab.population.competitors.ReactiveCompetitorModel`
|
||||
tracks prior competitor quotes).
|
||||
* Seed randomness through the factory configuration (``RetailConfig.seed`` or
|
||||
``MarketMakingConfig.seed``) or pass a seed to ``Platform.reset`` for
|
||||
deterministic rollouts.
|
||||
|
||||
Extending the Platform
|
||||
----------------------
|
||||
|
||||
To support a new domain:
|
||||
|
||||
1. Create custom Mechanism/Arrival/Execution/Market/Observation components by
|
||||
implementing the respective protocol in :mod:`lab.outlet.protocols`.
|
||||
2. Compose a new objective with
|
||||
:func:`lab.outlet.objectives.factory.make_composite` or write a bespoke
|
||||
:class:`lab.outlet.objectives.base.BaseObjective`.
|
||||
3. Wire everything together via :class:`lab.outlet.platform.Platform` directly
|
||||
or expose a helper factory in :mod:`lab.config`.
|
||||
|
||||
Use :func:`lab.experiments.rollout` and
|
||||
:func:`lab.experiments.compare_policies` to benchmark candidate policies under
|
||||
multiple random seeds, collecting per-step logs for analysis or OPE.
|
||||
7
lab/experiments/__init__.py
Normal file
7
lab/experiments/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from .eval import (rollout, RolloutResult, compare_policies, compute_ips, OPEResult,
|
||||
fixed_price_policy, cost_plus_margin_policy, random_walk_policy, epsilon_greedy_policy)
|
||||
|
||||
__all__ = [
|
||||
'rollout', 'RolloutResult', 'compare_policies', 'compute_ips', 'OPEResult',
|
||||
'fixed_price_policy', 'cost_plus_margin_policy', 'random_walk_policy', 'epsilon_greedy_policy',
|
||||
]
|
||||
213
lab/experiments/eval.py
Normal file
213
lab/experiments/eval.py
Normal file
@@ -0,0 +1,213 @@
|
||||
"""
|
||||
Evaluation utilities for policy testing and off-policy evaluation.
|
||||
|
||||
This module provides:
|
||||
- rollout: Run a policy on the platform for multiple steps
|
||||
- compare_policies: Compare multiple policies with statistics
|
||||
- Baseline policies: fixed_price, cost_plus_margin, random_walk, epsilon_greedy
|
||||
- OPE estimators: IPS and SNIPS for off-policy evaluation
|
||||
|
||||
Example:
|
||||
>>> from lab.config import make_retail_platform
|
||||
>>> from lab.experiments.eval import rollout, fixed_price_policy
|
||||
>>> platform = make_retail_platform()
|
||||
>>> policy = fixed_price_policy(platform.instruments.refs)
|
||||
>>> result = rollout(platform, policy, n_steps=100)
|
||||
>>> print(f"Total PnL: {result.total_pnl:.2f}")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Any
|
||||
import numpy as np
|
||||
from ..outlet.platform import Platform
|
||||
from ..outlet.types import StepResult, StepLogs, Quote
|
||||
|
||||
# Policy signature: takes (observation_flat, timestep) -> (action_prices, propensity)
|
||||
Policy = Callable[[np.ndarray, int], tuple[np.ndarray, float]]
|
||||
|
||||
@dataclass
|
||||
class RolloutResult:
|
||||
"""Results from a policy rollout.
|
||||
|
||||
Attributes:
|
||||
rewards: Per-step rewards
|
||||
metrics: Per-step StepMetrics objects
|
||||
logs: Per-step StepLogs objects
|
||||
total_reward: Sum of rewards
|
||||
total_pnl: Sum of PnL from metrics
|
||||
avg_conversion: Average conversion rate
|
||||
"""
|
||||
rewards: list[float]
|
||||
metrics: list[Any]
|
||||
logs: list[StepLogs]
|
||||
total_reward: float
|
||||
total_pnl: float
|
||||
avg_conversion: float
|
||||
|
||||
def rollout(platform: Platform, policy: Policy, n_steps: int, seed: int | None = None) -> RolloutResult:
|
||||
"""Execute a policy on the platform for n_steps.
|
||||
|
||||
Args:
|
||||
platform: The simulation platform
|
||||
policy: Function (obs, t) -> (action, propensity)
|
||||
n_steps: Number of steps to run
|
||||
seed: Random seed for reproducibility
|
||||
|
||||
Returns:
|
||||
RolloutResult with rewards, metrics, and summary statistics
|
||||
"""
|
||||
result = platform.reset(seed)
|
||||
rewards, metrics, logs = [], [], []
|
||||
|
||||
for t in range(n_steps):
|
||||
obs_flat = result.obs.to_flat()
|
||||
action, propensity = policy(obs_flat, t)
|
||||
result = platform.step(action, propensity)
|
||||
rewards.append(result.reward)
|
||||
metrics.append(result.metrics)
|
||||
logs.append(result.logs)
|
||||
if result.terminated or result.truncated:
|
||||
break
|
||||
|
||||
return RolloutResult(
|
||||
rewards=rewards, metrics=metrics, logs=logs,
|
||||
total_reward=sum(rewards),
|
||||
total_pnl=sum(m.pnl for m in metrics),
|
||||
avg_conversion=np.mean([m.conversion for m in metrics])
|
||||
)
|
||||
|
||||
# Baseline policies for comparison
|
||||
|
||||
def fixed_price_policy(refs: np.ndarray) -> Policy:
|
||||
"""Policy that always quotes at reference prices."""
|
||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
||||
return refs.copy(), 1.0
|
||||
return policy
|
||||
|
||||
def cost_plus_margin_policy(costs: np.ndarray, margin: float = 0.3) -> Policy:
|
||||
"""Policy that quotes at cost * (1 + margin)."""
|
||||
prices = costs * (1 + margin)
|
||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
||||
return prices.copy(), 1.0
|
||||
return policy
|
||||
|
||||
def random_walk_policy(refs: np.ndarray, volatility: float = 0.05,
|
||||
rng: np.random.Generator | None = None) -> Policy:
|
||||
"""Policy that performs a random walk around reference prices."""
|
||||
rng = rng or np.random.default_rng()
|
||||
prices = refs.copy()
|
||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
||||
nonlocal prices
|
||||
delta = rng.normal(0, volatility, len(prices))
|
||||
prices = prices * (1 + delta)
|
||||
prices = np.clip(prices, refs * 0.5, refs * 2.0)
|
||||
return prices.copy(), 1.0
|
||||
return policy
|
||||
|
||||
def epsilon_greedy_policy(base_policy: Policy, refs: np.ndarray,
|
||||
epsilon: float = 0.1, rng: np.random.Generator | None = None) -> Policy:
|
||||
"""Wrap a policy with epsilon-greedy exploration."""
|
||||
rng = rng or np.random.default_rng()
|
||||
def policy(obs: np.ndarray, t: int) -> tuple[np.ndarray, float]:
|
||||
if rng.random() < epsilon:
|
||||
action = refs * rng.uniform(0.8, 1.2, len(refs))
|
||||
return action, epsilon / len(refs)
|
||||
else:
|
||||
action, _ = base_policy(obs, t)
|
||||
return action, 1 - epsilon
|
||||
return policy
|
||||
|
||||
# Off-Policy Evaluation (OPE)
|
||||
|
||||
@dataclass
|
||||
class OPEResult:
|
||||
"""Results from off-policy evaluation.
|
||||
|
||||
Attributes:
|
||||
ips_estimate: Inverse Propensity Scoring estimate
|
||||
snips_estimate: Self-normalized IPS estimate (more stable)
|
||||
n_samples: Number of samples used
|
||||
effective_samples: Effective sample size (accounts for variance)
|
||||
"""
|
||||
ips_estimate: float
|
||||
snips_estimate: float
|
||||
n_samples: int
|
||||
effective_samples: float
|
||||
|
||||
def compute_ips(logs: list[StepLogs], rewards: list[float],
|
||||
target_policy: Policy, behavior_propensities: list[float] | None = None) -> OPEResult:
|
||||
"""Compute IPS and SNIPS estimators for off-policy evaluation.
|
||||
|
||||
Uses logged propensities to estimate expected reward under a target
|
||||
policy from data collected under a behavior policy.
|
||||
|
||||
Args:
|
||||
logs: Step logs containing propensities
|
||||
rewards: Observed rewards from behavior policy
|
||||
target_policy: Policy to evaluate (not currently used, assumes deterministic)
|
||||
behavior_propensities: Override propensities if not in logs
|
||||
|
||||
Returns:
|
||||
OPEResult with IPS, SNIPS estimates and sample statistics
|
||||
"""
|
||||
if behavior_propensities is None:
|
||||
# extract from logs
|
||||
behavior_propensities = []
|
||||
for log in logs:
|
||||
if log.executions:
|
||||
avg_prop = np.mean([e.propensity for e in log.executions])
|
||||
else:
|
||||
avg_prop = 1.0
|
||||
behavior_propensities.append(avg_prop)
|
||||
|
||||
# compute importance weights
|
||||
weights = []
|
||||
for i, (log, bp) in enumerate(zip(logs, behavior_propensities)):
|
||||
# target propensity would need obs reconstruction - simplified here
|
||||
tp = 1.0 # assume deterministic target
|
||||
w = tp / (bp + 1e-8)
|
||||
weights.append(w)
|
||||
|
||||
weights = np.array(weights)
|
||||
rewards = np.array(rewards)
|
||||
|
||||
# IPS estimate
|
||||
ips = np.sum(weights * rewards) / len(rewards)
|
||||
|
||||
# SNIPS (self-normalized)
|
||||
snips = np.sum(weights * rewards) / (np.sum(weights) + 1e-8)
|
||||
|
||||
# effective sample size
|
||||
ess = (np.sum(weights) ** 2) / (np.sum(weights ** 2) + 1e-8)
|
||||
|
||||
return OPEResult(ips_estimate=ips, snips_estimate=snips,
|
||||
n_samples=len(rewards), effective_samples=ess)
|
||||
|
||||
def compare_policies(platform: Platform, policies: dict[str, Policy],
|
||||
n_steps: int = 100, n_runs: int = 5, seed: int = 42) -> dict[str, dict]:
|
||||
"""Compare multiple policies with statistical summary.
|
||||
|
||||
Args:
|
||||
platform: Simulation platform
|
||||
policies: Dict mapping policy names to policy functions
|
||||
n_steps: Steps per rollout
|
||||
n_runs: Number of rollouts per policy (different seeds)
|
||||
seed: Base random seed
|
||||
|
||||
Returns:
|
||||
Dict mapping policy names to result dicts with mean/std statistics
|
||||
"""
|
||||
results = {}
|
||||
for name, policy in policies.items():
|
||||
run_results = []
|
||||
for i in range(n_runs):
|
||||
r = rollout(platform, policy, n_steps, seed=seed + i)
|
||||
run_results.append(r)
|
||||
|
||||
results[name] = {
|
||||
'mean_reward': np.mean([r.total_reward for r in run_results]),
|
||||
'std_reward': np.std([r.total_reward for r in run_results]),
|
||||
'mean_pnl': np.mean([r.total_pnl for r in run_results]),
|
||||
'mean_conversion': np.mean([r.avg_conversion for r in run_results]),
|
||||
}
|
||||
return results
|
||||
17
lab/outlet/__init__.py
Normal file
17
lab/outlet/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from .constants import Side, MechanismType, InstrumentType, OpportunityType, EventType, LogLevel
|
||||
from .types import (Instrument, InstrumentSet, Quote, Opportunity, Execution,
|
||||
StepEvent, StepLogs, StepMetrics, MarketState, HiddenState, Observation, StepResult)
|
||||
from .stock import PositionModel, PositionConfig, make_instruments
|
||||
from .platform import Platform, PlatformConfig
|
||||
from .observation import DefaultObservationBuilder, ObservationConfig
|
||||
from .mechanisms import PostedPriceMechanism, TwoSidedMechanism, AuctionMechanism
|
||||
|
||||
__all__ = [
|
||||
'Side', 'MechanismType', 'InstrumentType', 'OpportunityType', 'EventType', 'LogLevel',
|
||||
'Instrument', 'InstrumentSet', 'Quote', 'Opportunity', 'Execution',
|
||||
'StepEvent', 'StepLogs', 'StepMetrics', 'MarketState', 'HiddenState', 'Observation', 'StepResult',
|
||||
'PositionModel', 'PositionConfig', 'make_instruments',
|
||||
'Platform', 'PlatformConfig',
|
||||
'DefaultObservationBuilder', 'ObservationConfig',
|
||||
'PostedPriceMechanism', 'TwoSidedMechanism', 'AuctionMechanism',
|
||||
]
|
||||
83
lab/outlet/constants.py
Normal file
83
lab/outlet/constants.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Constants and enumerations for the Quote-Control simulator.
|
||||
|
||||
This module defines the core enums used throughout the platform to ensure
|
||||
type safety and consistent semantics across different pricing mechanisms.
|
||||
"""
|
||||
from enum import Enum, auto
|
||||
|
||||
class Side(Enum):
|
||||
"""Transaction side indicator.
|
||||
|
||||
Attributes:
|
||||
BUY: Buyer-initiated transaction (customer purchases, market buy order)
|
||||
SELL: Seller-initiated transaction (market sell order, short sale)
|
||||
"""
|
||||
BUY = auto()
|
||||
SELL = auto()
|
||||
|
||||
class MechanismType(Enum):
|
||||
"""Pricing mechanism type defining how quotes translate to executions.
|
||||
|
||||
Attributes:
|
||||
POSTED_PRICE: Single posted price per instrument (retail dynamic pricing)
|
||||
TWO_SIDED_QUOTE: Bid-ask spread quoting (market making, liquidity provision)
|
||||
AUCTION: Reserve price or bid shading (ad auctions, marketplaces)
|
||||
"""
|
||||
POSTED_PRICE = auto()
|
||||
TWO_SIDED_QUOTE = auto()
|
||||
AUCTION = auto()
|
||||
|
||||
class InstrumentType(Enum):
|
||||
"""Type of instrument being priced.
|
||||
|
||||
Attributes:
|
||||
SKU: Retail product with inventory constraints
|
||||
ASSET: Financial instrument with position limits
|
||||
LOAN: Credit product with interest rate pricing
|
||||
SUBSCRIPTION: Recurring service with periodic fees
|
||||
"""
|
||||
SKU = auto()
|
||||
ASSET = auto()
|
||||
LOAN = auto()
|
||||
SUBSCRIPTION = auto()
|
||||
|
||||
class OpportunityType(Enum):
|
||||
"""Type of arrival opportunity.
|
||||
|
||||
Attributes:
|
||||
SESSION: Retail browsing session with potential purchase intent
|
||||
MARKET_ORDER: Financial market order arrival (buy or sell)
|
||||
REQUEST: Service or credit request requiring quote response
|
||||
"""
|
||||
SESSION = auto()
|
||||
MARKET_ORDER = auto()
|
||||
REQUEST = auto()
|
||||
|
||||
class EventType(Enum):
|
||||
"""Type of logged event during simulation.
|
||||
|
||||
Attributes:
|
||||
ARRIVAL: New opportunity arrived in the system
|
||||
EXPOSURE: Quote was shown to an arrival
|
||||
EXECUTION: Transaction was executed
|
||||
ABANDON: Opportunity abandoned without execution
|
||||
CANCEL: Pending order was cancelled
|
||||
"""
|
||||
ARRIVAL = auto()
|
||||
EXPOSURE = auto()
|
||||
EXECUTION = auto()
|
||||
ABANDON = auto()
|
||||
CANCEL = auto()
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Verbosity level for step logging.
|
||||
|
||||
Attributes:
|
||||
NONE: No logging, fastest execution
|
||||
AGG_ONLY: Only aggregate statistics per step
|
||||
FULL: Full event-level logging with propensities for OPE
|
||||
"""
|
||||
NONE = auto()
|
||||
AGG_ONLY = auto()
|
||||
FULL = auto()
|
||||
86
lab/outlet/gym_wrapper.py
Normal file
86
lab/outlet/gym_wrapper.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""
|
||||
Gymnasium-compatible wrapper for the Quote-Control platform.
|
||||
|
||||
Provides a standard Gym interface for RL training:
|
||||
- observation_space: Box space with flattened observation
|
||||
- action_space: Box space with price multipliers [0.5, 2.0]
|
||||
- reset(), step(), render(), close() methods
|
||||
|
||||
Example:
|
||||
>>> from lab.config import make_retail_platform
|
||||
>>> from lab.outlet.gym_wrapper import QuoteGymEnv
|
||||
>>> env = QuoteGymEnv(make_retail_platform())
|
||||
>>> obs, info = env.reset()
|
||||
>>> obs, reward, done, truncated, info = env.step(env.action_space.sample())
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
HAS_GYM = True
|
||||
except ImportError:
|
||||
HAS_GYM = False
|
||||
|
||||
from .platform import Platform, PlatformConfig
|
||||
from .types import Quote, InstrumentSet, StepResult
|
||||
|
||||
class QuoteGymEnv:
|
||||
"""Gymnasium-compatible environment wrapper.
|
||||
|
||||
Wraps a Platform instance with standard Gym interface.
|
||||
Actions are price multipliers in [0.5, 2.0] applied to reference prices.
|
||||
Observations are flattened numpy arrays containing quotes, fills, exposures.
|
||||
"""
|
||||
|
||||
def __init__(self, platform: Platform):
|
||||
if not HAS_GYM:
|
||||
raise ImportError("gymnasium required for QuoteGymEnv")
|
||||
self.platform = platform
|
||||
self.n = platform.instruments.n
|
||||
self._last_result: StepResult | None = None
|
||||
|
||||
# action space: price adjustments as multipliers [0.5, 2.0]
|
||||
self.action_space = spaces.Box(low=0.5, high=2.0, shape=(self.n,), dtype=np.float32)
|
||||
|
||||
# observation space
|
||||
obs_dim = self.n * 4 # quotes + fills + exposures + position
|
||||
if platform.market:
|
||||
obs_dim += self.n # competitor quotes
|
||||
self.observation_space = spaces.Box(low=-np.inf, high=np.inf,
|
||||
shape=(obs_dim,), dtype=np.float32)
|
||||
|
||||
def reset(self, seed: int | None = None, options: dict | None = None) -> tuple[np.ndarray, dict]:
|
||||
result = self.platform.reset(seed)
|
||||
self._last_result = result
|
||||
return result.obs.to_flat().astype(np.float32), result.info
|
||||
|
||||
def step(self, action: np.ndarray) -> tuple[np.ndarray, float, bool, bool, dict]:
|
||||
# convert action (multipliers) to absolute prices
|
||||
refs = self.platform.instruments.refs
|
||||
prices = refs * action
|
||||
result = self.platform.step(prices)
|
||||
self._last_result = result
|
||||
return (result.obs.to_flat().astype(np.float32), result.reward,
|
||||
result.terminated, result.truncated, result.info)
|
||||
|
||||
def render(self) -> None:
|
||||
if self._last_result:
|
||||
m = self._last_result.metrics
|
||||
print(f"t={self.platform._t} pnl={m.pnl:.2f} units={m.units_traded:.0f} "
|
||||
f"conv={m.conversion:.3f} vol={m.volatility:.3f}")
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def make_env(platform: Platform) -> QuoteGymEnv:
|
||||
return QuoteGymEnv(platform)
|
||||
|
||||
if HAS_GYM:
|
||||
# register if gymnasium available
|
||||
try:
|
||||
gym.register(id='QuoteControl-v0', entry_point='outlet.gym_wrapper:QuoteGymEnv')
|
||||
except:
|
||||
pass # already registered or other issue
|
||||
57
lab/outlet/math_util.py
Normal file
57
lab/outlet/math_util.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Numerical utilities for stable computation.
|
||||
|
||||
This module provides numerically stable implementations of common operations:
|
||||
- safe_exp, safe_log: Avoid overflow/underflow
|
||||
- softmax: Numerically stable softmax
|
||||
- sigmoid, clamp: Standard transformations
|
||||
- intensity_decay: Avellaneda-Stoikov fill intensity
|
||||
- inventory_penalty: Quadratic inventory risk
|
||||
- poisson_arrivals, hawkes_intensity: Arrival process helpers
|
||||
|
||||
All functions accept both scalars and numpy arrays.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
EPS = 1e-8 # small constant to avoid division by zero
|
||||
MAX_EXP = 700.0 # maximum safe exponent to avoid overflow
|
||||
|
||||
def safe_exp(x: np.ndarray | float) -> np.ndarray | float:
|
||||
return np.exp(np.clip(x, -MAX_EXP, MAX_EXP))
|
||||
|
||||
def safe_log(x: np.ndarray | float) -> np.ndarray | float:
|
||||
return np.log(np.maximum(x, EPS))
|
||||
|
||||
def clamp(x: np.ndarray | float, lo: float, hi: float) -> np.ndarray | float:
|
||||
return np.clip(x, lo, hi)
|
||||
|
||||
def sigmoid(x: np.ndarray | float) -> np.ndarray | float:
|
||||
return 1.0 / (1.0 + safe_exp(-x))
|
||||
|
||||
def softmax(x: np.ndarray, axis: int = -1) -> np.ndarray:
|
||||
x_max = np.max(x, axis=axis, keepdims=True)
|
||||
exp_x = safe_exp(x - x_max)
|
||||
return exp_x / (np.sum(exp_x, axis=axis, keepdims=True) + EPS)
|
||||
|
||||
def geometric_series(base: float, ratio: float, n: int) -> np.ndarray:
|
||||
return base * (ratio ** np.arange(n))
|
||||
|
||||
def ema(old: float, new: float, alpha: float = 0.1) -> float:
|
||||
return alpha * new + (1 - alpha) * old
|
||||
|
||||
def intensity_decay(distance: float, kappa: float = 1.0) -> float:
|
||||
"""Avellaneda-Stoikov style fill intensity decay with quote distance"""
|
||||
return safe_exp(-kappa * distance)
|
||||
|
||||
def inventory_penalty(q: float, gamma: float = 0.1, sigma: float = 1.0) -> float:
|
||||
"""Quadratic inventory risk penalty"""
|
||||
return gamma * sigma**2 * q**2 / 2
|
||||
|
||||
def poisson_arrivals(rate: float, dt: float, rng: np.random.Generator) -> int:
|
||||
return rng.poisson(rate * dt)
|
||||
|
||||
def hawkes_intensity(base: float, history: np.ndarray, alpha: float, beta: float, t: float) -> float:
|
||||
"""Self-exciting Hawkes process intensity"""
|
||||
if len(history) == 0: return base
|
||||
decays = safe_exp(-beta * (t - history[history < t]))
|
||||
return base + alpha * np.sum(decays)
|
||||
5
lab/outlet/mechanisms/__init__.py
Normal file
5
lab/outlet/mechanisms/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .posted_price import PostedPriceMechanism
|
||||
from .two_sided import TwoSidedMechanism
|
||||
from .auction import AuctionMechanism
|
||||
|
||||
__all__ = ['PostedPriceMechanism', 'TwoSidedMechanism', 'AuctionMechanism']
|
||||
73
lab/outlet/mechanisms/auction.py
Normal file
73
lab/outlet/mechanisms/auction.py
Normal file
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Auction mechanism for reserve pricing and bid shading.
|
||||
|
||||
In this mechanism, the agent sets reserve prices that affect
|
||||
win probability and clearing prices. Used for ad auctions,
|
||||
marketplace auctions, and similar settings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
||||
from ..constants import Side
|
||||
from ..math_util import clamp, sigmoid
|
||||
|
||||
@dataclass
|
||||
class AuctionConfig:
|
||||
"""Configuration for auction mechanism.
|
||||
|
||||
Attributes:
|
||||
min_reserve: Minimum reserve price
|
||||
max_reserve: Maximum reserve price
|
||||
base_win_prob: Baseline win probability at reference reserve
|
||||
sensitivity: How much higher reserves reduce win probability
|
||||
"""
|
||||
min_reserve: float = 0.0
|
||||
max_reserve: float = 100.0
|
||||
base_win_prob: float = 0.3
|
||||
sensitivity: float = 2.0
|
||||
|
||||
class AuctionMechanism:
|
||||
"""Auction mechanism for reserve pricing.
|
||||
|
||||
The agent sets reserve prices that affect:
|
||||
- Win probability: higher reserves reduce chance of winning
|
||||
- Clearing price: bounded between reserve and simulated max bid
|
||||
|
||||
Win probability: base_prob * sigmoid(-sensitivity * (reserve - ref) / ref)
|
||||
Clearing price: max(reserve, min(max_bid, reserve + random_increment))
|
||||
|
||||
Only BUY-side opportunities are processed (auction wins).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: AuctionConfig | None = None):
|
||||
self.cfg = cfg or AuctionConfig()
|
||||
|
||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
||||
rng: np.random.Generator) -> Quote:
|
||||
reserves = clamp(quote.prices, self.cfg.min_reserve, self.cfg.max_reserve)
|
||||
return Quote(prices=reserves, propensity=quote.propensity, metadata=quote.metadata)
|
||||
|
||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
||||
instruments: InstrumentSet, market: MarketState | None,
|
||||
rng: np.random.Generator) -> Execution | None:
|
||||
if opp.side != Side.BUY: return None
|
||||
idx = int(opp.instrument_id)
|
||||
reserve = float(quote.prices[idx])
|
||||
ref = instruments.refs[idx]
|
||||
|
||||
# win probability decreases with higher reserve
|
||||
relative_reserve = (reserve - ref) / (ref + 1e-8)
|
||||
win_prob = self.cfg.base_win_prob * sigmoid(-self.cfg.sensitivity * relative_reserve)
|
||||
|
||||
if rng.random() > win_prob: return None
|
||||
|
||||
# clearing price is between reserve and some max bid (simulated)
|
||||
max_bid = ref * (1 + rng.exponential(0.2))
|
||||
clearing = max(reserve, min(max_bid, reserve + rng.exponential(0.1) * ref))
|
||||
|
||||
return Execution(
|
||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
||||
price=clearing, propensity=quote.propensity * win_prob, t=opp.t
|
||||
)
|
||||
84
lab/outlet/mechanisms/posted_price.py
Normal file
84
lab/outlet/mechanisms/posted_price.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Posted price mechanism for retail dynamic pricing.
|
||||
|
||||
In this mechanism, the agent posts a single price per instrument.
|
||||
Buyers decide whether to purchase based on the posted price.
|
||||
This is the standard e-commerce dynamic pricing model.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
||||
from ..constants import Side
|
||||
from ..math_util import clamp
|
||||
|
||||
@dataclass
|
||||
class PostedPriceConfig:
|
||||
"""Configuration for posted price mechanism.
|
||||
|
||||
Attributes:
|
||||
min_price: Absolute minimum price
|
||||
max_price: Absolute maximum price
|
||||
max_delta_pct: Maximum price change per step as fraction of previous
|
||||
min_margin_pct: Minimum margin over cost basis
|
||||
round_to: Price rounding granularity (None = no rounding)
|
||||
"""
|
||||
min_price: float = 0.01
|
||||
max_price: float = 1000.0
|
||||
max_delta_pct: float = 0.2
|
||||
min_margin_pct: float = 0.05
|
||||
round_to: float | None = 0.01
|
||||
|
||||
class PostedPriceMechanism:
|
||||
"""Posted price mechanism for retail dynamic pricing.
|
||||
|
||||
The agent posts a single price per product. Constraints enforced:
|
||||
- Prices within [min_price, max_price]
|
||||
- Margin at least min_margin_pct above cost
|
||||
- Price changes limited to max_delta_pct per step
|
||||
- Prices rounded to round_to granularity
|
||||
|
||||
Only BUY-side opportunities are processed (customers purchasing).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: PostedPriceConfig | None = None):
|
||||
self.cfg = cfg or PostedPriceConfig()
|
||||
|
||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
||||
rng: np.random.Generator) -> Quote:
|
||||
prices = quote.prices.copy()
|
||||
costs = instruments.costs
|
||||
refs = instruments.refs
|
||||
c = self.cfg
|
||||
|
||||
# enforce min margin
|
||||
min_prices = costs * (1 + c.min_margin_pct)
|
||||
prices = np.maximum(prices, min_prices)
|
||||
|
||||
# enforce absolute bounds
|
||||
prices = clamp(prices, c.min_price, c.max_price)
|
||||
|
||||
# enforce max delta if we have history
|
||||
if 'prev_prices' in quote.metadata:
|
||||
prev = quote.metadata['prev_prices']
|
||||
max_change = prev * c.max_delta_pct
|
||||
prices = clamp(prices, prev - max_change, prev + max_change)
|
||||
|
||||
# round prices
|
||||
if c.round_to:
|
||||
prices = np.round(prices / c.round_to) * c.round_to
|
||||
|
||||
return Quote(prices=prices, propensity=quote.propensity,
|
||||
metadata={**quote.metadata, 'prev_prices': prices})
|
||||
|
||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
||||
instruments: InstrumentSet, market: MarketState | None,
|
||||
rng: np.random.Generator) -> Execution | None:
|
||||
if opp.side != Side.BUY: return None # posted price is buy-only
|
||||
idx = int(opp.instrument_id)
|
||||
price = float(quote.prices[idx])
|
||||
return Execution(
|
||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
||||
price=price, propensity=quote.propensity, t=opp.t
|
||||
)
|
||||
89
lab/outlet/mechanisms/two_sided.py
Normal file
89
lab/outlet/mechanisms/two_sided.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Two-sided quoting mechanism for market making.
|
||||
|
||||
In this mechanism, the agent posts both bid and ask prices.
|
||||
Execution depends on the distance from the market mid-price.
|
||||
This models liquidity provision in financial markets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from ..types import Quote, Opportunity, Execution, InstrumentSet, MarketState
|
||||
from ..constants import Side
|
||||
from ..math_util import clamp, intensity_decay
|
||||
|
||||
@dataclass
|
||||
class TwoSidedConfig:
|
||||
"""Configuration for two-sided quoting mechanism.
|
||||
|
||||
Attributes:
|
||||
min_spread: Minimum bid-ask spread
|
||||
max_spread: Maximum bid-ask spread
|
||||
min_price: Absolute minimum price
|
||||
max_price: Absolute maximum price
|
||||
fill_kappa: Intensity decay parameter (higher = faster decay with distance)
|
||||
"""
|
||||
min_spread: float = 0.01
|
||||
max_spread: float = 0.5
|
||||
min_price: float = 0.01
|
||||
max_price: float = 10000.0
|
||||
fill_kappa: float = 1.5
|
||||
|
||||
class TwoSidedMechanism:
|
||||
"""Two-sided quoting mechanism for market making.
|
||||
|
||||
The agent posts bid (buy) and ask (sell) prices around a mid-point.
|
||||
Fill probability decays exponentially with distance from mid-price,
|
||||
following the Avellaneda-Stoikov intensity model.
|
||||
|
||||
Both BUY and SELL opportunities are processed:
|
||||
- BUY: customer buys at agent's ask price
|
||||
- SELL: customer sells at agent's bid price
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: TwoSidedConfig | None = None):
|
||||
self.cfg = cfg or TwoSidedConfig()
|
||||
|
||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
||||
rng: np.random.Generator) -> Quote:
|
||||
prices = quote.prices.copy()
|
||||
spreads = quote.spreads.copy() if quote.spreads is not None else np.full_like(prices, 0.02)
|
||||
c = self.cfg
|
||||
|
||||
prices = clamp(prices, c.min_price, c.max_price)
|
||||
spreads = clamp(spreads, c.min_spread, c.max_spread)
|
||||
|
||||
# ensure bids < asks
|
||||
half_spread = spreads / 2
|
||||
bids = prices - half_spread
|
||||
asks = prices + half_spread
|
||||
bids = np.maximum(bids, c.min_price)
|
||||
asks = np.minimum(asks, c.max_price)
|
||||
spreads = asks - bids
|
||||
prices = (bids + asks) / 2
|
||||
|
||||
return Quote(prices=prices, spreads=spreads, propensity=quote.propensity,
|
||||
metadata=quote.metadata)
|
||||
|
||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
||||
instruments: InstrumentSet, market: MarketState | None,
|
||||
rng: np.random.Generator) -> Execution | None:
|
||||
idx = int(opp.instrument_id)
|
||||
mid = market.mid_prices[idx] if market and market.mid_prices is not None else quote.prices[idx]
|
||||
|
||||
if opp.side == Side.BUY:
|
||||
price = float(quote.asks[idx]) if quote.asks is not None else float(quote.prices[idx])
|
||||
distance = price - mid
|
||||
else:
|
||||
price = float(quote.bids[idx]) if quote.bids is not None else float(quote.prices[idx])
|
||||
distance = mid - price
|
||||
|
||||
# probabilistic fill based on distance from mid
|
||||
fill_prob = intensity_decay(abs(distance), self.cfg.fill_kappa)
|
||||
if rng.random() > fill_prob: return None
|
||||
|
||||
return Execution(
|
||||
opportunity_id=opp.id, instrument_id=opp.instrument_id,
|
||||
side=opp.side, size_requested=opp.size, size_filled=opp.size,
|
||||
price=price, propensity=quote.propensity * fill_prob, t=opp.t
|
||||
)
|
||||
11
lab/outlet/objectives/__init__.py
Normal file
11
lab/outlet/objectives/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from .base import BaseObjective, CompositeObjective
|
||||
from .penalties import (PnLObjective, VolatilityPenalty, HoldingCostPenalty,
|
||||
LostOpportunityCostPenalty, InventoryRiskPenalty, SpreadCaptureReward)
|
||||
from .factory import make_objective, make_composite, retail_objective, market_making_objective
|
||||
|
||||
__all__ = [
|
||||
'BaseObjective', 'CompositeObjective',
|
||||
'PnLObjective', 'VolatilityPenalty', 'HoldingCostPenalty',
|
||||
'LostOpportunityCostPenalty', 'InventoryRiskPenalty', 'SpreadCaptureReward',
|
||||
'make_objective', 'make_composite', 'retail_objective', 'market_making_objective',
|
||||
]
|
||||
48
lab/outlet/objectives/base.py
Normal file
48
lab/outlet/objectives/base.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Base classes for reward objectives.
|
||||
|
||||
Objectives compute scalar rewards from step metrics. The CompositeObjective
|
||||
allows combining multiple objectives with weights for multi-objective optimization.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from ..types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
||||
|
||||
class BaseObjective(ABC):
|
||||
"""Abstract base class for reward objectives.
|
||||
|
||||
Subclasses must implement reward() and breakdown() methods.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float: ...
|
||||
|
||||
@abstractmethod
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]: ...
|
||||
|
||||
class CompositeObjective(BaseObjective):
|
||||
"""Weighted sum of multiple objectives.
|
||||
|
||||
Allows combining multiple reward terms (e.g., PnL - holding_cost - volatility).
|
||||
|
||||
Args:
|
||||
objectives: List of (objective, weight) tuples
|
||||
"""
|
||||
|
||||
def __init__(self, objectives: list[tuple[BaseObjective, float]]):
|
||||
self.objectives = objectives
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return sum(w * obj.reward(quote, instruments, metrics, hidden, obs)
|
||||
for obj, w in self.objectives)
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
bd = {}
|
||||
for obj, w in self.objectives:
|
||||
for k, v in obj.breakdown(quote, instruments, metrics, hidden, obs).items():
|
||||
bd[k] = w * v
|
||||
return bd
|
||||
82
lab/outlet/objectives/factory.py
Normal file
82
lab/outlet/objectives/factory.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Factory functions for creating objectives.
|
||||
|
||||
Provides:
|
||||
- make_objective: Create single objective by name
|
||||
- make_composite: Create weighted combination of objectives
|
||||
- retail_objective: Default objective for retail pricing
|
||||
- market_making_objective: Default objective for market making
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from .base import BaseObjective, CompositeObjective
|
||||
from .penalties import (PnLObjective, VolatilityPenalty, HoldingCostPenalty,
|
||||
LostOpportunityCostPenalty, InventoryRiskPenalty, SpreadCaptureReward)
|
||||
|
||||
REGISTRY: dict[str, type[BaseObjective]] = {
|
||||
'pnl': PnLObjective,
|
||||
'volatility': VolatilityPenalty,
|
||||
'holding_cost': HoldingCostPenalty,
|
||||
'lost_opportunity': LostOpportunityCostPenalty,
|
||||
'inventory_risk': InventoryRiskPenalty,
|
||||
'spread_capture': SpreadCaptureReward,
|
||||
}
|
||||
|
||||
def make_objective(name: str, **kwargs) -> BaseObjective:
|
||||
"""Create an objective by name.
|
||||
|
||||
Args:
|
||||
name: Objective name (pnl, volatility, holding_cost, lost_opportunity,
|
||||
inventory_risk, spread_capture)
|
||||
**kwargs: Passed to objective constructor
|
||||
|
||||
Returns:
|
||||
Instantiated objective
|
||||
"""
|
||||
if name not in REGISTRY:
|
||||
raise ValueError(f"Unknown objective: {name}. Available: {list(REGISTRY.keys())}")
|
||||
return REGISTRY[name](**kwargs)
|
||||
|
||||
def make_composite(spec: list[tuple[str, float, dict]] | dict[str, float]) -> CompositeObjective:
|
||||
"""Create composite objective from specification.
|
||||
|
||||
Args:
|
||||
spec: Either:
|
||||
- list of (name, weight, kwargs) tuples for full control
|
||||
- dict of {name: weight} for simple cases
|
||||
|
||||
Returns:
|
||||
CompositeObjective with specified components
|
||||
"""
|
||||
objectives = []
|
||||
if isinstance(spec, dict):
|
||||
for name, weight in spec.items():
|
||||
objectives.append((make_objective(name), weight))
|
||||
else:
|
||||
for name, weight, kwargs in spec:
|
||||
objectives.append((make_objective(name, **kwargs), weight))
|
||||
return CompositeObjective(objectives)
|
||||
|
||||
def retail_objective(volatility_weight: float = 0.1, holding_weight: float = 0.5,
|
||||
stockout_weight: float = 0.3) -> CompositeObjective:
|
||||
"""Default objective for retail dynamic pricing.
|
||||
|
||||
Reward = PnL - volatility_weight*volatility - holding_weight*holding_cost
|
||||
- stockout_weight*lost_opportunity
|
||||
"""
|
||||
return make_composite({
|
||||
'pnl': 1.0,
|
||||
'volatility': volatility_weight,
|
||||
'holding_cost': holding_weight,
|
||||
'lost_opportunity': stockout_weight,
|
||||
})
|
||||
|
||||
def market_making_objective(gamma: float = 0.1, sigma: float = 1.0) -> CompositeObjective:
|
||||
"""Default objective for market making.
|
||||
|
||||
Reward = PnL + 0.5*spread_capture - inventory_risk(gamma, sigma)
|
||||
"""
|
||||
return CompositeObjective([
|
||||
(PnLObjective(), 1.0),
|
||||
(SpreadCaptureReward(), 0.5),
|
||||
(InventoryRiskPenalty(gamma=gamma, sigma=sigma), 1.0),
|
||||
])
|
||||
101
lab/outlet/objectives/penalties.py
Normal file
101
lab/outlet/objectives/penalties.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Standard objective components and penalties.
|
||||
|
||||
This module provides common reward terms:
|
||||
- PnLObjective: Basic profit and loss
|
||||
- VolatilityPenalty: Penalize price volatility for UX
|
||||
- HoldingCostPenalty: Inventory holding cost
|
||||
- LostOpportunityCostPenalty: Stockout/missed fill cost
|
||||
- InventoryRiskPenalty: Quadratic inventory risk (market making)
|
||||
- SpreadCaptureReward: Bid-ask spread capture (market making)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from .base import BaseObjective
|
||||
from ..types import Quote, InstrumentSet, StepMetrics, HiddenState, Observation
|
||||
from ..math_util import inventory_penalty
|
||||
|
||||
class PnLObjective(BaseObjective):
|
||||
"""Profit and loss reward (revenue - cost)."""
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return metrics.pnl
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'pnl': metrics.pnl, 'revenue': metrics.revenue, 'cost': metrics.cost}
|
||||
|
||||
class VolatilityPenalty(BaseObjective):
|
||||
"""Penalize price volatility for user experience."""
|
||||
|
||||
def __init__(self, scale: float = 1.0):
|
||||
self.scale = scale
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return -self.scale * metrics.volatility
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'volatility_penalty': -self.scale * metrics.volatility}
|
||||
|
||||
class HoldingCostPenalty(BaseObjective):
|
||||
"""Penalty for inventory holding costs."""
|
||||
|
||||
def __init__(self, scale: float = 1.0):
|
||||
self.scale = scale
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return -self.scale * metrics.position_cost
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'holding_cost_penalty': -self.scale * metrics.position_cost}
|
||||
|
||||
class LostOpportunityCostPenalty(BaseObjective):
|
||||
"""Penalty for lost sales due to stockouts or missed fills."""
|
||||
|
||||
def __init__(self, scale: float = 1.0):
|
||||
self.scale = scale
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return -self.scale * metrics.lost_opportunity
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'lost_opportunity_penalty': -self.scale * metrics.lost_opportunity}
|
||||
|
||||
class InventoryRiskPenalty(BaseObjective):
|
||||
"""Quadratic inventory risk penalty (Avellaneda-Stoikov style).
|
||||
|
||||
Penalty = gamma * sigma^2 * q^2 / 2, where q is total position.
|
||||
Encourages market makers to keep inventory near zero.
|
||||
"""
|
||||
|
||||
def __init__(self, gamma: float = 0.1, sigma: float = 1.0):
|
||||
self.gamma = gamma
|
||||
self.sigma = sigma
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
if obs.position is None: return 0.0
|
||||
q = np.sum(obs.position)
|
||||
return -inventory_penalty(q, self.gamma, self.sigma)
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'inventory_risk_penalty': self.reward(quote, instruments, metrics, hidden, obs)}
|
||||
|
||||
class SpreadCaptureReward(BaseObjective):
|
||||
"""Reward for capturing bid-ask spread in market making."""
|
||||
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> float:
|
||||
return metrics.spread_capture
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState, obs: Observation) -> dict[str, float]:
|
||||
return {'spread_capture': metrics.spread_capture}
|
||||
92
lab/outlet/observation.py
Normal file
92
lab/outlet/observation.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Observation construction with demand censoring.
|
||||
|
||||
This module provides the ObservationBuilder that constructs agent observations
|
||||
from step data. The key invariant is that observations only contain censored
|
||||
data (fills) and never true demand, ensuring proper research conditions.
|
||||
|
||||
The ObservationConfig controls what is included in observations:
|
||||
- Position visibility
|
||||
- Market/competitor visibility
|
||||
- Demand proxy method
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from .types import Quote, InstrumentSet, StepLogs, StepMetrics, MarketState, HiddenState, Observation
|
||||
|
||||
@dataclass
|
||||
class ObservationConfig:
|
||||
"""Configuration for observation construction.
|
||||
|
||||
Attributes:
|
||||
include_position: Include current position in observation
|
||||
include_market: Include market/competitor state in observation
|
||||
mask_true_demand: If True, observation excludes true demand (research mode)
|
||||
demand_proxy: Method for demand proxy ('fills', 'exposures', 'weighted')
|
||||
exposure_weights: Weights for weighted demand proxy
|
||||
"""
|
||||
include_position: bool = True
|
||||
include_market: bool = True
|
||||
mask_true_demand: bool = True
|
||||
demand_proxy: str = 'fills'
|
||||
exposure_weights: dict[str, float] | None = None
|
||||
|
||||
class DefaultObservationBuilder:
|
||||
"""Constructs censored observations for the agent.
|
||||
|
||||
Ensures the key research invariant: observations contain only
|
||||
censored fills (realized sales), never true demand. True demand
|
||||
is placed in the info dict for research analysis only.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ObservationConfig | None = None):
|
||||
self.cfg = cfg or ObservationConfig()
|
||||
|
||||
def build(self, quote: Quote, instruments: InstrumentSet, logs: StepLogs,
|
||||
metrics: StepMetrics, market: MarketState | None,
|
||||
hidden: HiddenState, mask_demand: bool, t: int) -> Observation:
|
||||
n = instruments.n
|
||||
cfg = self.cfg
|
||||
|
||||
# always show censored fills
|
||||
fills = logs.censored_fills if logs.censored_fills is not None else np.zeros(n)
|
||||
|
||||
# compute exposures from logs
|
||||
if logs.events:
|
||||
exposures = np.zeros(n)
|
||||
for e in logs.events:
|
||||
if e.instrument_id is not None:
|
||||
exposures[e.instrument_id] += 1
|
||||
else:
|
||||
exposures = logs.aggregates.get('exposures', np.zeros(n))
|
||||
|
||||
# position - only if configured and available
|
||||
position = None
|
||||
if cfg.include_position and instruments.position is not None:
|
||||
position = instruments.position.copy()
|
||||
|
||||
# market state - only if configured
|
||||
obs_market = market if cfg.include_market else None
|
||||
|
||||
return Observation(
|
||||
quotes=quote.prices.copy(),
|
||||
position=position,
|
||||
fills=fills,
|
||||
exposures=exposures,
|
||||
market=obs_market,
|
||||
t=t
|
||||
)
|
||||
|
||||
def make_space(self, n_instruments: int, include_market: bool = True) -> dict:
|
||||
"""Returns dict describing observation space for gym"""
|
||||
space = {
|
||||
'quotes': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
||||
'fills': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
||||
'exposures': {'shape': (n_instruments,), 'low': 0, 'high': np.inf},
|
||||
}
|
||||
if self.cfg.include_position:
|
||||
space['position'] = {'shape': (n_instruments,), 'low': -np.inf, 'high': np.inf}
|
||||
if include_market:
|
||||
space['competitor_quotes'] = {'shape': (n_instruments,), 'low': 0, 'high': np.inf}
|
||||
return space
|
||||
285
lab/outlet/platform.py
Normal file
285
lab/outlet/platform.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""
|
||||
Main simulation platform orchestrating the Quote-Control loop.
|
||||
|
||||
The Platform class is the central coordinator that:
|
||||
1. Receives pricing actions (quotes) from the agent
|
||||
2. Generates arrivals via the ArrivalModel
|
||||
3. Processes executions via Mechanism and ExecutionModel
|
||||
4. Applies position censorship via PositionModel
|
||||
5. Computes metrics and reward via Objective
|
||||
6. Returns censored observations
|
||||
|
||||
Example:
|
||||
>>> from lab.config import make_retail_platform
|
||||
>>> platform = make_retail_platform()
|
||||
>>> result = platform.reset(seed=42)
|
||||
>>> result = platform.step(platform.instruments.refs * 1.1)
|
||||
>>> print(f"PnL: {result.metrics.pnl:.2f}")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from .types import (Quote, Opportunity, Execution, InstrumentSet, StepLogs, StepMetrics,
|
||||
StepEvent, MarketState, HiddenState, Observation, StepResult)
|
||||
from .constants import LogLevel, EventType, Side
|
||||
from .protocols import Mechanism, ArrivalModel, ExecutionModel, PositionModel, MarketModel, ObservationBuilder, Objective
|
||||
from .stock import PositionModel as DefaultPositionModel, PositionConfig
|
||||
from .observation import DefaultObservationBuilder, ObservationConfig
|
||||
from .objectives.factory import retail_objective
|
||||
|
||||
@dataclass
|
||||
class PlatformConfig:
|
||||
"""Configuration for the simulation platform.
|
||||
|
||||
Attributes:
|
||||
n_instruments: Number of instruments in the simulation
|
||||
max_steps: Maximum steps before episode terminates
|
||||
dt: Time duration per step (affects arrival rates)
|
||||
log_level: Verbosity of logging (NONE, AGG_ONLY, FULL)
|
||||
mask_demand: If True, observations exclude true demand (research mode)
|
||||
seed: Random seed for reproducibility
|
||||
"""
|
||||
n_instruments: int = 10
|
||||
max_steps: int = 1000
|
||||
dt: float = 1.0
|
||||
log_level: LogLevel = LogLevel.AGG_ONLY
|
||||
mask_demand: bool = True
|
||||
seed: int | None = None
|
||||
|
||||
class Platform:
|
||||
"""Main simulation orchestrator implementing Quote -> Arrival -> Execution -> Position.
|
||||
|
||||
The Platform coordinates all components to simulate a pricing environment:
|
||||
- Mechanism: validates quotes and determines execution logic
|
||||
- ArrivalModel: generates demand opportunities
|
||||
- ExecutionModel: computes acceptance probabilities
|
||||
- PositionModel: manages inventory/position and censorship
|
||||
- MarketModel: updates competitor/market state
|
||||
- ObservationBuilder: constructs censored observations
|
||||
- Objective: computes reward from metrics
|
||||
|
||||
Attributes:
|
||||
instruments: The instrument set being priced
|
||||
mechanism: Quote validation and execution mechanism
|
||||
arrival: Demand arrival generator
|
||||
execution: Acceptance probability model
|
||||
position: Inventory/position manager
|
||||
market: Competitor/market dynamics (optional)
|
||||
obs_builder: Observation constructor
|
||||
objective: Reward function
|
||||
cfg: Platform configuration
|
||||
"""
|
||||
|
||||
def __init__(self, instruments: InstrumentSet, mechanism: Mechanism,
|
||||
arrival: ArrivalModel, execution: ExecutionModel,
|
||||
position: PositionModel | None = None,
|
||||
market: MarketModel | None = None,
|
||||
obs_builder: ObservationBuilder | None = None,
|
||||
objective: Objective | None = None,
|
||||
cfg: PlatformConfig | None = None):
|
||||
self.instruments = instruments
|
||||
self.mechanism = mechanism
|
||||
self.arrival = arrival
|
||||
self.execution = execution
|
||||
self.position = position or DefaultPositionModel(PositionConfig())
|
||||
self.market = market
|
||||
self.obs_builder = obs_builder or DefaultObservationBuilder()
|
||||
self.objective = objective or retail_objective()
|
||||
self.cfg = cfg or PlatformConfig(n_instruments=instruments.n)
|
||||
|
||||
self._t: int = 0
|
||||
self._rng: np.random.Generator = np.random.default_rng(self.cfg.seed)
|
||||
self._quote: Quote | None = None
|
||||
self._market_state: MarketState | None = None
|
||||
self._hidden: HiddenState = HiddenState()
|
||||
self._prev_prices: np.ndarray | None = None
|
||||
|
||||
def reset(self, seed: int | None = None) -> StepResult:
|
||||
"""Reset the platform to initial state.
|
||||
|
||||
Args:
|
||||
seed: Random seed (overrides config seed if provided)
|
||||
|
||||
Returns:
|
||||
Initial StepResult with zeroed metrics and initial observation
|
||||
"""
|
||||
self._t = 0
|
||||
self._rng = np.random.default_rng(seed or self.cfg.seed)
|
||||
self._hidden = HiddenState()
|
||||
self._prev_prices = self.instruments.refs.copy()
|
||||
|
||||
# reset position
|
||||
self.position.reset(self.instruments, self._rng)
|
||||
self.instruments.position = self.position.position
|
||||
|
||||
# initial quote at reference prices
|
||||
self._quote = Quote(prices=self.instruments.refs.copy(), propensity=1.0,
|
||||
metadata={'prev_prices': self._prev_prices})
|
||||
self._quote = self.mechanism.apply_quote(self._quote, self.instruments, self._rng)
|
||||
|
||||
# initial market state
|
||||
if self.market:
|
||||
self._market_state = self.market.step(0, self._quote, self._hidden, self._rng)
|
||||
|
||||
# build initial observation
|
||||
logs = StepLogs(aggregates={'reset': True},
|
||||
true_demand=np.zeros(self.instruments.n),
|
||||
censored_fills=np.zeros(self.instruments.n))
|
||||
metrics = StepMetrics()
|
||||
obs = self.obs_builder.build(self._quote, self.instruments, logs, metrics,
|
||||
self._market_state, self._hidden, self.cfg.mask_demand, 0)
|
||||
|
||||
return StepResult(obs=obs, reward=0.0, terminated=False, truncated=False,
|
||||
info={'true_demand': logs.true_demand}, metrics=metrics,
|
||||
logs=logs, hidden=self._hidden)
|
||||
|
||||
def step(self, action: np.ndarray, propensity: float = 1.0) -> StepResult:
|
||||
"""Execute one simulation step with the given pricing action.
|
||||
|
||||
The step proceeds as follows:
|
||||
1. Apply quote constraints via mechanism
|
||||
2. Update market/competitor state
|
||||
3. Generate arrivals
|
||||
4. Process arrivals -> executions with acceptance check
|
||||
5. Apply position censorship to executions
|
||||
6. Update position state
|
||||
7. Compute metrics (PnL, costs, etc.)
|
||||
8. Build logs with propensities
|
||||
9. Construct censored observation
|
||||
10. Compute reward
|
||||
|
||||
Args:
|
||||
action: Price vector for all instruments
|
||||
propensity: P(action | behavior policy) for OPE logging
|
||||
|
||||
Returns:
|
||||
StepResult containing observation, reward, metrics, logs, and hidden state
|
||||
"""
|
||||
self._t += 1
|
||||
cfg = self.cfg
|
||||
|
||||
# 1. apply quote from action
|
||||
self._quote = Quote(prices=action, propensity=propensity,
|
||||
metadata={'prev_prices': self._prev_prices})
|
||||
self._quote = self.mechanism.apply_quote(self._quote, self.instruments, self._rng)
|
||||
self._prev_prices = self._quote.prices.copy()
|
||||
self._hidden.quote_history.append(self._quote.prices.copy())
|
||||
|
||||
# 2. update market/competitors
|
||||
if self.market:
|
||||
self._market_state = self.market.step(self._t, self._quote, self._hidden, self._rng)
|
||||
self._hidden.market_history.append(self._market_state)
|
||||
|
||||
# 3. generate arrivals
|
||||
opps = self.arrival.sample(self._t, cfg.dt, self.instruments,
|
||||
self._market_state, self._hidden, self._rng)
|
||||
|
||||
# 4. process opportunities -> executions
|
||||
executions: list[Execution] = []
|
||||
events: list[StepEvent] = []
|
||||
true_demand = np.zeros(self.instruments.n)
|
||||
|
||||
for opp in opps:
|
||||
# log exposure
|
||||
if cfg.log_level == LogLevel.FULL:
|
||||
events.append(StepEvent(t=opp.t, type=EventType.EXPOSURE,
|
||||
instrument_id=opp.instrument_id,
|
||||
opportunity_id=opp.id,
|
||||
price=float(self._quote.prices[opp.instrument_id]),
|
||||
propensity=self._quote.propensity))
|
||||
|
||||
# check acceptance
|
||||
prob = self.execution.prob(opp, self._quote, self.instruments,
|
||||
self._market_state, self._rng)
|
||||
if self._rng.random() < prob:
|
||||
# create execution
|
||||
exe = self.mechanism.process_opportunity(opp, self._quote, self.instruments,
|
||||
self._market_state, self._rng)
|
||||
if exe:
|
||||
true_demand[exe.instrument_id] += exe.size_requested
|
||||
# apply position censorship
|
||||
exe = self.position.apply_execution(exe)
|
||||
executions.append(exe)
|
||||
if cfg.log_level == LogLevel.FULL:
|
||||
events.append(StepEvent(t=exe.t, type=EventType.EXECUTION,
|
||||
instrument_id=exe.instrument_id,
|
||||
opportunity_id=exe.opportunity_id,
|
||||
price=exe.price, size=exe.size_filled,
|
||||
propensity=exe.propensity))
|
||||
|
||||
# 5. update position state
|
||||
self.position.step(self._t)
|
||||
self.instruments.position = self.position.position
|
||||
|
||||
# 6. compute metrics
|
||||
censored_fills = np.zeros(self.instruments.n)
|
||||
revenue = 0.0
|
||||
cost = 0.0
|
||||
spread_capture = 0.0
|
||||
|
||||
for exe in executions:
|
||||
censored_fills[exe.instrument_id] += exe.size_filled
|
||||
if exe.side == Side.BUY:
|
||||
revenue += exe.price * exe.size_filled
|
||||
cost += self.instruments.costs[exe.instrument_id] * exe.size_filled
|
||||
else:
|
||||
revenue -= exe.price * exe.size_filled
|
||||
cost -= self.instruments.costs[exe.instrument_id] * exe.size_filled
|
||||
# spread capture for market making
|
||||
if self._quote.spreads is not None and self._market_state and self._market_state.mid_prices is not None:
|
||||
mid = self._market_state.mid_prices[exe.instrument_id]
|
||||
if exe.side == Side.BUY:
|
||||
spread_capture += (exe.price - mid) * exe.size_filled
|
||||
else:
|
||||
spread_capture += (mid - exe.price) * exe.size_filled
|
||||
|
||||
pnl = revenue - cost
|
||||
units = float(np.sum(censored_fills))
|
||||
lost = float(np.sum(true_demand - censored_fills))
|
||||
|
||||
# volatility
|
||||
volatility = 0.0
|
||||
if len(self._hidden.quote_history) > 1:
|
||||
prev = self._hidden.quote_history[-2]
|
||||
volatility = float(np.mean(np.abs(self._quote.prices - prev) / (prev + 1e-8)))
|
||||
|
||||
metrics = StepMetrics(
|
||||
pnl=pnl, revenue=revenue, cost=cost, units_traded=units,
|
||||
position_cost=self.position.holding_cost,
|
||||
lost_opportunity=self.position.shortage_cost + lost * np.mean(self._quote.prices) * 0.1,
|
||||
spread_capture=spread_capture, volatility=volatility,
|
||||
conversion=units / (len(opps) + 1e-8),
|
||||
per_instrument={'fills': censored_fills, 'demand': true_demand}
|
||||
)
|
||||
|
||||
# 7. build logs
|
||||
logs = StepLogs(
|
||||
events=events if cfg.log_level == LogLevel.FULL else None,
|
||||
executions=executions if cfg.log_level == LogLevel.FULL else None,
|
||||
aggregates={'n_arrivals': len(opps), 'n_executions': len(executions),
|
||||
'exposures': np.bincount([o.instrument_id for o in opps],
|
||||
minlength=self.instruments.n).astype(float)},
|
||||
true_demand=true_demand,
|
||||
censored_fills=censored_fills
|
||||
)
|
||||
|
||||
# 8. build observation
|
||||
obs = self.obs_builder.build(self._quote, self.instruments, logs, metrics,
|
||||
self._market_state, self._hidden, cfg.mask_demand, self._t)
|
||||
|
||||
# 9. compute reward
|
||||
reward = self.objective.reward(self._quote, self.instruments, metrics, self._hidden, obs)
|
||||
breakdown = self.objective.breakdown(self._quote, self.instruments, metrics, self._hidden, obs)
|
||||
# print(f"Step {self._t}: Reward={reward:.2f}, Breakdown={breakdown}")
|
||||
|
||||
|
||||
# 10. check termination
|
||||
terminated = self._t >= cfg.max_steps
|
||||
truncated = False
|
||||
|
||||
info = {'true_demand': true_demand, 'breakdown': self.objective.breakdown(
|
||||
self._quote, self.instruments, metrics, self._hidden, obs)}
|
||||
|
||||
return StepResult(obs=obs, reward=reward, terminated=terminated, truncated=truncated,
|
||||
info=info, metrics=metrics, logs=logs, hidden=self._hidden)
|
||||
297
lab/outlet/protocols.py
Normal file
297
lab/outlet/protocols.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Protocol definitions for pluggable simulator components.
|
||||
|
||||
This module defines the interfaces (Protocols) that allow swapping different
|
||||
implementations for each stage of the Quote -> Arrival -> Execution -> Position
|
||||
pipeline. All protocols use structural subtyping (duck typing).
|
||||
|
||||
Protocols:
|
||||
Mechanism: How quotes translate to executions (posted price, two-sided, auction)
|
||||
ArrivalModel: How opportunities arrive (Poisson, Hawkes, sessions)
|
||||
ExecutionModel: Acceptance probability given quote (elasticity, intensity)
|
||||
PositionModel: Inventory/position management and censorship
|
||||
MarketModel: Competitor/market dynamics
|
||||
ObservationBuilder: Constructs agent observations with censoring
|
||||
Objective: Computes reward from metrics
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, Any, TYPE_CHECKING
|
||||
import numpy as np
|
||||
if TYPE_CHECKING:
|
||||
from .types import (Quote, Opportunity, Execution, InstrumentSet, StepLogs,
|
||||
StepMetrics, HiddenState, Observation, MarketState)
|
||||
from .constants import LogLevel
|
||||
|
||||
class Mechanism(Protocol):
|
||||
"""Defines how quotes translate to executions.
|
||||
|
||||
The Mechanism is the core abstraction that differentiates pricing domains:
|
||||
- PostedPrice: single price, buyer decides to purchase or not
|
||||
- TwoSided: bid/ask spread, execution depends on distance from mid
|
||||
- Auction: reserve price affects win probability and clearing price
|
||||
|
||||
Methods:
|
||||
apply_quote: Enforce constraints and return valid quote
|
||||
process_opportunity: Determine execution given opportunity and quote
|
||||
"""
|
||||
def apply_quote(self, quote: Quote, instruments: InstrumentSet,
|
||||
rng: np.random.Generator) -> Quote:
|
||||
"""Apply mechanism-specific constraints to a quote.
|
||||
|
||||
Args:
|
||||
quote: Raw quote from policy
|
||||
instruments: Current instrument set with costs/refs
|
||||
rng: Random generator for stochastic constraints
|
||||
|
||||
Returns:
|
||||
Constrained quote satisfying mechanism rules (min margin, max delta, etc.)
|
||||
"""
|
||||
...
|
||||
|
||||
def process_opportunity(self, opp: Opportunity, quote: Quote,
|
||||
instruments: InstrumentSet, market: MarketState | None,
|
||||
rng: np.random.Generator) -> Execution | None:
|
||||
"""Process an opportunity against the current quote.
|
||||
|
||||
Args:
|
||||
opp: Incoming opportunity (session, order, request)
|
||||
quote: Current posted quote
|
||||
instruments: Instrument set
|
||||
market: Current market state (competitor prices, mid-prices)
|
||||
rng: Random generator
|
||||
|
||||
Returns:
|
||||
Execution if opportunity converts, None otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
class ArrivalModel(Protocol):
|
||||
"""Generates opportunities (demand arrivals) for each step.
|
||||
|
||||
Different arrival models capture different demand dynamics:
|
||||
- Poisson: constant rate, memoryless
|
||||
- Hawkes: self-exciting, clustered arrivals
|
||||
- Session: retail browsing with multi-product views
|
||||
|
||||
Methods:
|
||||
sample: Generate opportunities for a time interval
|
||||
"""
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> list[Opportunity]:
|
||||
"""Sample opportunities for time interval [t, t+dt).
|
||||
|
||||
Args:
|
||||
t: Current time
|
||||
dt: Time interval length
|
||||
instruments: Available instruments
|
||||
market: Current market state
|
||||
hidden: Hidden state (contains demand intensity, contamination)
|
||||
rng: Random generator
|
||||
|
||||
Returns:
|
||||
List of opportunities arriving in this interval
|
||||
"""
|
||||
...
|
||||
|
||||
class ExecutionModel(Protocol):
|
||||
"""Computes acceptance/execution probability given quote and context.
|
||||
|
||||
Different models capture different demand responses:
|
||||
- Elasticity: price sensitivity with competitor cross-effects
|
||||
- Intensity: distance-based fill probability (market making)
|
||||
- Logit: discrete choice model
|
||||
|
||||
Methods:
|
||||
prob: Compute acceptance probability
|
||||
uncensor: Estimate true demand from censored fills
|
||||
"""
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
"""Compute probability that opportunity accepts the quote.
|
||||
|
||||
Args:
|
||||
opp: Opportunity to evaluate
|
||||
quote: Current quote
|
||||
instruments: Instrument set
|
||||
market: Market state (competitor prices affect cross-elasticity)
|
||||
rng: Random generator
|
||||
|
||||
Returns:
|
||||
Probability in [0, 1] that opportunity executes
|
||||
"""
|
||||
...
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
"""Estimate true demand from censored fills.
|
||||
|
||||
Used for demand estimation research under inventory censorship.
|
||||
|
||||
Args:
|
||||
fills: Observed (censored) fill counts
|
||||
instruments: Instrument set
|
||||
context: Additional context (exposures, prices shown)
|
||||
|
||||
Returns:
|
||||
Estimated true demand counts
|
||||
"""
|
||||
...
|
||||
|
||||
class PositionModel(Protocol):
|
||||
"""Manages inventory (retail) or position (finance).
|
||||
|
||||
Handles:
|
||||
- Position constraints and censorship
|
||||
- Holding costs (retail) or inventory risk (finance)
|
||||
- Replenishment and order receipt
|
||||
|
||||
Methods:
|
||||
reset: Initialize position state
|
||||
available: Query available capacity for a trade
|
||||
apply_execution: Censor execution by available position
|
||||
step: Process time-based updates (replenishment, holding cost)
|
||||
|
||||
Properties:
|
||||
position: Current position vector
|
||||
holding_cost: Cost incurred this step from holding position
|
||||
"""
|
||||
def reset(self, instruments: InstrumentSet, rng: np.random.Generator) -> None:
|
||||
"""Initialize position state for new episode."""
|
||||
...
|
||||
|
||||
def available(self, instrument_id: int, side: Any) -> float:
|
||||
"""Query available capacity for a trade.
|
||||
|
||||
Args:
|
||||
instrument_id: Which instrument
|
||||
side: BUY or SELL
|
||||
|
||||
Returns:
|
||||
Maximum tradeable size given current position
|
||||
"""
|
||||
...
|
||||
|
||||
def apply_execution(self, exe: Execution) -> Execution:
|
||||
"""Apply position constraints to an execution.
|
||||
|
||||
Args:
|
||||
exe: Proposed execution with size_requested
|
||||
|
||||
Returns:
|
||||
Censored execution with size_filled <= available capacity
|
||||
"""
|
||||
...
|
||||
|
||||
def step(self, t: float) -> None:
|
||||
"""Process time-based position updates.
|
||||
|
||||
Handles replenishment receipt, holding cost calculation, etc.
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
def position(self) -> np.ndarray:
|
||||
"""Current position vector (positive=long/inventory, negative=short)."""
|
||||
...
|
||||
|
||||
@property
|
||||
def holding_cost(self) -> float:
|
||||
"""Holding cost incurred this step."""
|
||||
...
|
||||
|
||||
class MarketModel(Protocol):
|
||||
"""Models external market dynamics and competitor behavior.
|
||||
|
||||
For retail: competitor price dynamics (static, reactive, stochastic)
|
||||
For finance: mid-price process (GBM, mean-reverting)
|
||||
|
||||
Methods:
|
||||
step: Update market state given agent's quotes
|
||||
"""
|
||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> MarketState:
|
||||
"""Update market state for this timestep.
|
||||
|
||||
Args:
|
||||
t: Current time
|
||||
self_quotes: Agent's current quotes (competitors may react)
|
||||
hidden: Hidden state (regime info)
|
||||
rng: Random generator
|
||||
|
||||
Returns:
|
||||
Updated market state with competitor prices, mid-prices, volatility
|
||||
"""
|
||||
...
|
||||
|
||||
class ObservationBuilder(Protocol):
|
||||
"""Constructs agent observations with appropriate censoring.
|
||||
|
||||
Critical for research: ensures agent only sees censored fills,
|
||||
never true demand (which goes in info dict).
|
||||
|
||||
Methods:
|
||||
build: Construct observation from step data
|
||||
"""
|
||||
def build(self, quote: Quote, instruments: InstrumentSet, logs: StepLogs,
|
||||
metrics: StepMetrics, market: MarketState | None,
|
||||
hidden: HiddenState, mask_demand: bool, t: int) -> Observation:
|
||||
"""Build observation for agent.
|
||||
|
||||
Args:
|
||||
quote: Current quote
|
||||
instruments: Instrument set with positions
|
||||
logs: Step logs with true_demand and censored_fills
|
||||
metrics: Computed metrics
|
||||
market: Market state
|
||||
hidden: Hidden state (not included in obs)
|
||||
mask_demand: If True, exclude true demand from observation
|
||||
t: Current timestep
|
||||
|
||||
Returns:
|
||||
Observation containing only observable quantities
|
||||
"""
|
||||
...
|
||||
|
||||
class Objective(Protocol):
|
||||
"""Computes reward from step metrics.
|
||||
|
||||
Supports composite objectives with weighted terms:
|
||||
- PnL (profit)
|
||||
- Position costs (holding, inventory risk)
|
||||
- Lost opportunity (stockouts)
|
||||
- Volatility penalty (UX)
|
||||
- Spread capture (market making)
|
||||
|
||||
Methods:
|
||||
reward: Compute scalar reward
|
||||
breakdown: Get per-term contribution for analysis
|
||||
"""
|
||||
def reward(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState,
|
||||
obs: Observation) -> float:
|
||||
"""Compute scalar reward for this step.
|
||||
|
||||
Args:
|
||||
quote: Current quote
|
||||
instruments: Instrument set
|
||||
metrics: Step metrics (pnl, costs, etc.)
|
||||
hidden: Hidden state
|
||||
obs: Agent observation
|
||||
|
||||
Returns:
|
||||
Scalar reward value
|
||||
"""
|
||||
...
|
||||
|
||||
def breakdown(self, quote: Quote, instruments: InstrumentSet,
|
||||
metrics: StepMetrics, hidden: HiddenState,
|
||||
obs: Observation) -> dict[str, float]:
|
||||
"""Get reward breakdown by component.
|
||||
|
||||
Useful for analyzing which terms dominate the reward.
|
||||
|
||||
Returns:
|
||||
Dict mapping term names to their contributions
|
||||
"""
|
||||
...
|
||||
151
lab/outlet/stock.py
Normal file
151
lab/outlet/stock.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Inventory/position management and instrument factories.
|
||||
|
||||
This module provides:
|
||||
- PositionConfig: Configuration for position constraints and costs
|
||||
- PositionModel: Manages inventory (retail) or position (finance)
|
||||
- make_instruments: Factory for creating instrument sets
|
||||
|
||||
The PositionModel handles demand censorship by limiting executions
|
||||
to available inventory, computing holding costs, and managing replenishment.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
import numpy as np
|
||||
from .types import Instrument, InstrumentSet, Execution
|
||||
from .constants import Side, InstrumentType
|
||||
|
||||
@dataclass
|
||||
class PositionConfig:
|
||||
"""Configuration for position/inventory management.
|
||||
|
||||
Attributes:
|
||||
initial_position: Starting inventory (None = unlimited, float = same for all)
|
||||
max_position: Maximum long position per instrument
|
||||
min_position: Maximum short position (negative, for finance)
|
||||
holding_cost_rate: Cost per unit per step for holding inventory
|
||||
shortage_cost_rate: Opportunity cost rate for stockouts
|
||||
lead_time: Steps until replenishment orders arrive
|
||||
"""
|
||||
initial_position: np.ndarray | float | None = None
|
||||
max_position: float = 1000.0
|
||||
min_position: float = -1000.0
|
||||
holding_cost_rate: float = 0.001
|
||||
shortage_cost_rate: float = 0.05
|
||||
lead_time: int = 0
|
||||
|
||||
@dataclass
|
||||
class PositionModel:
|
||||
"""Manages inventory (retail) or position (finance) with censorship.
|
||||
|
||||
Key responsibilities:
|
||||
- Track current position per instrument
|
||||
- Censor executions when position is insufficient
|
||||
- Compute holding costs per step
|
||||
- Track shortage/stockout costs
|
||||
- Handle replenishment orders with lead time
|
||||
|
||||
For retail: position is inventory (positive), selling reduces it
|
||||
For finance: position can be positive (long) or negative (short)
|
||||
"""
|
||||
cfg: PositionConfig
|
||||
n: int = 0
|
||||
_position: np.ndarray = field(default_factory=lambda: np.array([]))
|
||||
_pending_orders: list[tuple[int, np.ndarray]] = field(default_factory=list)
|
||||
_step_holding_cost: float = 0.0
|
||||
_step_shortage_cost: float = 0.0
|
||||
|
||||
def reset(self, instruments: InstrumentSet, rng: np.random.Generator) -> None:
|
||||
self.n = instruments.n
|
||||
if self.cfg.initial_position is None:
|
||||
self._position = np.full(self.n, np.inf) # unlimited
|
||||
elif isinstance(self.cfg.initial_position, (int, float)):
|
||||
self._position = np.full(self.n, float(self.cfg.initial_position))
|
||||
else:
|
||||
self._position = self.cfg.initial_position.copy().astype(np.float64)
|
||||
self._pending_orders = []
|
||||
self._step_holding_cost = 0.0
|
||||
self._step_shortage_cost = 0.0
|
||||
|
||||
def available(self, instrument_id: int, side: Side) -> float:
|
||||
pos = self._position[instrument_id]
|
||||
if np.isinf(pos): return np.inf
|
||||
if side == Side.BUY:
|
||||
return max(0, pos) # can sell up to current inventory
|
||||
else:
|
||||
return max(0, self.cfg.max_position - pos) # can buy up to max
|
||||
|
||||
def apply_execution(self, exe: Execution) -> Execution:
|
||||
idx = int(exe.instrument_id)
|
||||
avail = self.available(idx, exe.side)
|
||||
filled = min(exe.size_requested, avail)
|
||||
shortage = exe.size_requested - filled
|
||||
|
||||
if exe.side == Side.BUY:
|
||||
self._position[idx] -= filled # sold from inventory
|
||||
else:
|
||||
self._position[idx] += filled # bought into inventory
|
||||
|
||||
if shortage > 0:
|
||||
self._step_shortage_cost += shortage * exe.price * self.cfg.shortage_cost_rate
|
||||
|
||||
return Execution(
|
||||
opportunity_id=exe.opportunity_id, instrument_id=exe.instrument_id,
|
||||
side=exe.side, size_requested=exe.size_requested,
|
||||
size_filled=filled, price=exe.price, propensity=exe.propensity, t=exe.t
|
||||
)
|
||||
|
||||
def order(self, quantity: np.ndarray) -> None:
|
||||
if self.cfg.lead_time > 0:
|
||||
self._pending_orders.append((self.cfg.lead_time, quantity.copy()))
|
||||
else:
|
||||
self._position += quantity
|
||||
|
||||
def step(self, t: float) -> None:
|
||||
# compute holding cost
|
||||
pos = np.where(np.isinf(self._position), 0, self._position)
|
||||
self._step_holding_cost = float(np.sum(np.abs(pos)) * self.cfg.holding_cost_rate)
|
||||
|
||||
# receive pending orders
|
||||
new_pending = []
|
||||
for (remaining, qty) in self._pending_orders:
|
||||
if remaining <= 1:
|
||||
self._position += qty
|
||||
else:
|
||||
new_pending.append((remaining - 1, qty))
|
||||
self._pending_orders = new_pending
|
||||
|
||||
@property
|
||||
def position(self) -> np.ndarray:
|
||||
return np.where(np.isinf(self._position), -1, self._position)
|
||||
|
||||
@property
|
||||
def holding_cost(self) -> float:
|
||||
return self._step_holding_cost
|
||||
|
||||
@property
|
||||
def shortage_cost(self) -> float:
|
||||
return self._step_shortage_cost
|
||||
|
||||
def make_instruments(n: int, cost_range: tuple[float, float] = (1.0, 10.0),
|
||||
margin_range: tuple[float, float] = (0.2, 0.5),
|
||||
inst_type: InstrumentType = InstrumentType.SKU,
|
||||
rng: np.random.Generator | None = None) -> InstrumentSet:
|
||||
"""Factory function to create a random instrument set.
|
||||
|
||||
Args:
|
||||
n: Number of instruments to create
|
||||
cost_range: (min, max) for uniform cost sampling
|
||||
margin_range: (min, max) for uniform margin sampling
|
||||
inst_type: Type of instruments (SKU, ASSET, etc.)
|
||||
rng: Random generator (uses default if None)
|
||||
|
||||
Returns:
|
||||
InstrumentSet with n instruments having random costs and margins
|
||||
"""
|
||||
rng = rng or np.random.default_rng()
|
||||
costs = rng.uniform(*cost_range, n)
|
||||
margins = rng.uniform(*margin_range, n)
|
||||
items = [Instrument(id=i, type=inst_type, cost_basis=c, reference_price=c*(1+m))
|
||||
for i, (c, m) in enumerate(zip(costs, margins))]
|
||||
return InstrumentSet(instruments=items)
|
||||
318
lab/outlet/types.py
Normal file
318
lab/outlet/types.py
Normal file
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Core data types for the Quote-Control simulator.
|
||||
|
||||
This module defines the fundamental data structures used throughout the platform:
|
||||
- Identifiers (InstrumentId, OpportunityId, AgentId)
|
||||
- Domain objects (Instrument, Quote, Opportunity, Execution)
|
||||
- Logging structures (StepEvent, StepLogs, StepMetrics)
|
||||
- State containers (MarketState, HiddenState, Observation, StepResult)
|
||||
|
||||
All dataclasses are designed to be serializable and numpy-compatible.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, NewType
|
||||
import numpy as np
|
||||
from .constants import Side, InstrumentType, OpportunityType, EventType
|
||||
|
||||
InstrumentId = NewType('InstrumentId', int) # unique instrument index
|
||||
OpportunityId = NewType('OpportunityId', str) # unique opportunity/session ID
|
||||
AgentId = NewType('AgentId', str) # unique agent/actor ID
|
||||
|
||||
@dataclass
|
||||
class Instrument:
|
||||
"""Represents a priceable entity in the simulation.
|
||||
|
||||
An instrument can be a retail SKU, financial asset, loan product, or subscription.
|
||||
The cost_basis represents the fundamental value (marginal cost for retail,
|
||||
mid-price for assets, funding rate for loans).
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this instrument
|
||||
type: Category of instrument (SKU, ASSET, LOAN, SUBSCRIPTION)
|
||||
cost_basis: Fundamental cost or value (marginal cost, mid-price, funding rate)
|
||||
reference_price: Base or fair price used for action scaling
|
||||
attrs: Additional attributes (quality score, category, volatility, etc.)
|
||||
"""
|
||||
id: InstrumentId
|
||||
type: InstrumentType
|
||||
cost_basis: float
|
||||
reference_price: float
|
||||
attrs: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class InstrumentSet:
|
||||
"""Collection of instruments with optional position tracking.
|
||||
|
||||
Provides vectorized access to instrument properties for efficient computation.
|
||||
Position can be positive (long/inventory) or negative (short) for financial assets.
|
||||
|
||||
Attributes:
|
||||
instruments: List of Instrument objects
|
||||
position: Current position per instrument (None = unlimited capacity)
|
||||
|
||||
Properties:
|
||||
n: Number of instruments
|
||||
costs: Vector of cost bases
|
||||
refs: Vector of reference prices
|
||||
"""
|
||||
instruments: list[Instrument]
|
||||
position: np.ndarray | None = None
|
||||
|
||||
@property
|
||||
def n(self) -> int: return len(self.instruments)
|
||||
@property
|
||||
def costs(self) -> np.ndarray: return np.array([i.cost_basis for i in self.instruments], np.float32)
|
||||
@property
|
||||
def refs(self) -> np.ndarray: return np.array([i.reference_price for i in self.instruments], np.float32)
|
||||
|
||||
@dataclass
|
||||
class Quote:
|
||||
"""Price quote set by the policy - the action in the MDP.
|
||||
|
||||
Supports multiple quoting mechanisms:
|
||||
- Posted price: only `prices` field used
|
||||
- Two-sided: `prices` as mid, `spreads` for bid-ask width
|
||||
- Auction: `prices` as reserve prices
|
||||
|
||||
The propensity field is critical for off-policy evaluation (OPE).
|
||||
|
||||
Attributes:
|
||||
prices: Posted prices (retail) or mid-quotes (market making)
|
||||
spreads: Bid-ask spread width for two-sided quoting (None for posted price)
|
||||
propensity: P(this quote | behavior policy) for importance sampling
|
||||
metadata: Additional info (prev_prices for delta constraints, etc.)
|
||||
|
||||
Properties:
|
||||
bids: Computed bid prices (mid - spread/2)
|
||||
asks: Computed ask prices (mid + spread/2)
|
||||
"""
|
||||
prices: np.ndarray
|
||||
spreads: np.ndarray | None = None
|
||||
propensity: float = 1.0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def bids(self) -> np.ndarray | None:
|
||||
return self.prices - self.spreads/2 if self.spreads is not None else None
|
||||
@property
|
||||
def asks(self) -> np.ndarray | None:
|
||||
return self.prices + self.spreads/2 if self.spreads is not None else None
|
||||
|
||||
@dataclass
|
||||
class Opportunity:
|
||||
"""An arrival event that may result in a transaction.
|
||||
|
||||
Opportunities are the demand side of the simulation:
|
||||
- Retail: browsing session with purchase intent
|
||||
- Market making: incoming market order
|
||||
- Lending: loan application
|
||||
|
||||
The context dict carries segment/type information used by execution models.
|
||||
|
||||
Attributes:
|
||||
id: Unique identifier for this opportunity
|
||||
type: Category (SESSION, MARKET_ORDER, REQUEST)
|
||||
side: BUY or SELL intent
|
||||
instrument_id: Which instrument the opportunity targets
|
||||
size: Requested transaction size (units, shares, principal)
|
||||
t: Arrival timestamp
|
||||
context: Segment info (is_scraper, credit_score, urgency, etc.)
|
||||
"""
|
||||
id: OpportunityId
|
||||
type: OpportunityType
|
||||
side: Side
|
||||
instrument_id: InstrumentId
|
||||
size: float = 1.0
|
||||
t: float = 0.0
|
||||
context: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class Execution:
|
||||
"""A realized transaction after acceptance and position censorship.
|
||||
|
||||
The difference between size_requested and size_filled represents
|
||||
censored demand due to inventory/position constraints.
|
||||
|
||||
Attributes:
|
||||
opportunity_id: Links back to the originating Opportunity
|
||||
instrument_id: Which instrument was traded
|
||||
side: BUY or SELL
|
||||
size_requested: Original requested size (true demand)
|
||||
size_filled: Actual filled size after censorship
|
||||
price: Execution price
|
||||
propensity: Combined propensity for OPE (quote * acceptance)
|
||||
t: Execution timestamp
|
||||
"""
|
||||
opportunity_id: OpportunityId
|
||||
instrument_id: InstrumentId
|
||||
side: Side
|
||||
size_requested: float
|
||||
size_filled: float
|
||||
price: float
|
||||
propensity: float = 1.0
|
||||
t: float = 0.0
|
||||
|
||||
@dataclass
|
||||
class StepEvent:
|
||||
"""Generic logged event"""
|
||||
t: float
|
||||
type: EventType
|
||||
instrument_id: InstrumentId | None = None
|
||||
opportunity_id: OpportunityId | None = None
|
||||
price: float | None = None
|
||||
size: float | None = None
|
||||
propensity: float = 1.0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class StepLogs:
|
||||
"""Container for all logging data from a simulation step.
|
||||
|
||||
Supports both detailed event logging (for OPE) and aggregate-only mode
|
||||
(for fast simulation). The true_demand vs censored_fills distinction
|
||||
is critical for research on demand estimation under censorship.
|
||||
|
||||
Attributes:
|
||||
events: Detailed event log (None if LogLevel != FULL)
|
||||
executions: List of executed transactions (None if LogLevel != FULL)
|
||||
aggregates: Always-available aggregate statistics
|
||||
true_demand: Oracle demand before censorship (for research, not in obs)
|
||||
censored_fills: Realized fills after position constraints (observable)
|
||||
"""
|
||||
events: list[StepEvent] | None = None
|
||||
executions: list[Execution] | None = None
|
||||
aggregates: dict[str, Any] = field(default_factory=dict)
|
||||
true_demand: np.ndarray | None = None
|
||||
censored_fills: np.ndarray | None = None
|
||||
|
||||
@dataclass
|
||||
class StepMetrics:
|
||||
"""Computed metrics for a single simulation step.
|
||||
|
||||
Metrics are domain-aware: retail uses revenue/cost/holding_cost,
|
||||
market making uses spread_capture and inventory risk.
|
||||
|
||||
Attributes:
|
||||
pnl: Profit and loss (revenue - cost for retail, mark-to-market for finance)
|
||||
revenue: Gross revenue from sales/executions
|
||||
cost: Cost of goods sold or position acquisition cost
|
||||
units_traded: Total units/shares transacted
|
||||
position_cost: Holding cost (retail) or inventory risk penalty (finance)
|
||||
lost_opportunity: Cost of stockouts or missed fills
|
||||
spread_capture: Bid-ask spread captured (market making)
|
||||
volatility: Price volatility metric for UX consideration
|
||||
conversion: Fill rate (executions / opportunities)
|
||||
per_instrument: Per-instrument breakdowns (fills, demand, etc.)
|
||||
"""
|
||||
pnl: float = 0.0
|
||||
revenue: float = 0.0
|
||||
cost: float = 0.0
|
||||
units_traded: float = 0.0
|
||||
position_cost: float = 0.0
|
||||
lost_opportunity: float = 0.0
|
||||
spread_capture: float = 0.0
|
||||
volatility: float = 0.0
|
||||
conversion: float = 0.0
|
||||
per_instrument: dict[str, np.ndarray] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class MarketState:
|
||||
"""External market conditions and competitor state.
|
||||
|
||||
For retail: competitor_quotes drives cross-elasticity effects.
|
||||
For finance: mid_prices and volatility drive execution dynamics.
|
||||
|
||||
Attributes:
|
||||
competitor_quotes: Competitor posted prices (retail)
|
||||
mid_prices: Market mid-prices for assets (finance)
|
||||
volatility: Per-instrument volatility estimate
|
||||
regime: Market regime identifier (normal, price_war, high_vol, etc.)
|
||||
t: Timestamp of this market state
|
||||
"""
|
||||
competitor_quotes: np.ndarray | None = None
|
||||
mid_prices: np.ndarray | None = None
|
||||
volatility: np.ndarray | None = None
|
||||
regime: str = 'normal'
|
||||
t: float = 0.0
|
||||
|
||||
@dataclass
|
||||
class HiddenState:
|
||||
"""Internal simulator state not exposed to the agent.
|
||||
|
||||
Contains oracle information for research analysis and
|
||||
history needed for non-stationary dynamics.
|
||||
|
||||
Attributes:
|
||||
true_demand_intensity: Latent demand multiplier
|
||||
contamination: Fraction of arrivals that are adversarial/scraper
|
||||
regime: Current market/competitor regime
|
||||
quote_history: History of agent quotes for volatility calculation
|
||||
market_history: History of market states for analysis
|
||||
"""
|
||||
true_demand_intensity: float = 1.0
|
||||
contamination: float = 0.0
|
||||
regime: str = 'normal'
|
||||
quote_history: list[np.ndarray] = field(default_factory=list)
|
||||
market_history: list[MarketState] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class Observation:
|
||||
"""Observable state provided to the agent - censored view only.
|
||||
|
||||
Critical invariant: Observation never contains true_demand, only
|
||||
censored fills. This enforces the censorship research setting.
|
||||
|
||||
Attributes:
|
||||
quotes: Current posted quotes (the agent's last action)
|
||||
position: Current inventory/position state
|
||||
fills: Censored execution counts per instrument
|
||||
exposures: Opportunity exposure counts per instrument
|
||||
market: Observable market state (competitor prices, volatility)
|
||||
t: Current timestep
|
||||
extra: Additional observable features
|
||||
|
||||
Methods:
|
||||
to_flat: Flatten to numpy array for gym compatibility
|
||||
"""
|
||||
quotes: np.ndarray
|
||||
position: np.ndarray | None
|
||||
fills: np.ndarray
|
||||
exposures: np.ndarray
|
||||
market: MarketState | None
|
||||
t: int
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_flat(self) -> np.ndarray:
|
||||
"""Flatten observation to 1D numpy array for gym environments."""
|
||||
parts = [self.quotes, self.fills, self.exposures]
|
||||
if self.position is not None: parts.append(self.position)
|
||||
if self.market and self.market.competitor_quotes is not None:
|
||||
parts.append(self.market.competitor_quotes)
|
||||
return np.concatenate([p.flatten() for p in parts])
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
"""Complete result from a simulation step.
|
||||
|
||||
Follows gymnasium convention for obs, reward, terminated, truncated, info.
|
||||
Additionally provides metrics, logs, and hidden state for research.
|
||||
|
||||
Attributes:
|
||||
obs: Observable state (censored)
|
||||
reward: Scalar reward from objective function
|
||||
terminated: Episode ended naturally (max_steps reached)
|
||||
truncated: Episode ended early (bankruptcy, constraint violation)
|
||||
info: Additional info dict (contains true_demand for research)
|
||||
metrics: Computed metrics for this step
|
||||
logs: Event logs and aggregates
|
||||
hidden: Internal simulator state (oracle info)
|
||||
"""
|
||||
obs: Observation
|
||||
reward: float
|
||||
terminated: bool
|
||||
truncated: bool
|
||||
info: dict[str, Any]
|
||||
metrics: StepMetrics
|
||||
logs: StepLogs
|
||||
hidden: HiddenState
|
||||
10
lab/population/__init__.py
Normal file
10
lab/population/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from .arrivals import PoissonArrivalModel, HawkesArrivalModel, SessionArrivalModel
|
||||
from .execution import ElasticityExecutionModel, IntensityExecutionModel, LogitExecutionModel
|
||||
from .competitors import (StaticCompetitorModel, ReactiveCompetitorModel,
|
||||
StochasticCompetitorModel, GBMMarketModel)
|
||||
|
||||
__all__ = [
|
||||
'PoissonArrivalModel', 'HawkesArrivalModel', 'SessionArrivalModel',
|
||||
'ElasticityExecutionModel', 'IntensityExecutionModel', 'LogitExecutionModel',
|
||||
'StaticCompetitorModel', 'ReactiveCompetitorModel', 'StochasticCompetitorModel', 'GBMMarketModel',
|
||||
]
|
||||
168
lab/population/arrivals.py
Normal file
168
lab/population/arrivals.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Arrival models for generating demand opportunities.
|
||||
|
||||
This module provides different arrival processes:
|
||||
- PoissonArrivalModel: Constant-rate memoryless arrivals
|
||||
- HawkesArrivalModel: Self-exciting clustered arrivals (market orders)
|
||||
- SessionArrivalModel: Retail browsing sessions with multi-product views
|
||||
|
||||
Each model implements the ArrivalModel protocol and generates Opportunity objects
|
||||
that flow through the execution pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
import numpy as np
|
||||
from uuid import uuid4
|
||||
from ..outlet.types import Opportunity, InstrumentSet, MarketState, HiddenState
|
||||
from ..outlet.constants import Side, OpportunityType
|
||||
from ..outlet.math_util import poisson_arrivals, hawkes_intensity
|
||||
|
||||
@dataclass
|
||||
class PoissonArrivalConfig:
|
||||
"""Configuration for Poisson arrival process.
|
||||
|
||||
Attributes:
|
||||
base_rate: Expected arrivals per unit time (scaled by hidden.true_demand_intensity)
|
||||
side_probs: Probability distribution over BUY/SELL sides
|
||||
"""
|
||||
base_rate: float = 10.0
|
||||
side_probs: dict[Side, float] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.side_probs is None:
|
||||
self.side_probs = {Side.BUY: 1.0}
|
||||
|
||||
class PoissonArrivalModel:
|
||||
"""Homogeneous Poisson arrival process.
|
||||
|
||||
Generates arrivals at a constant rate (modulated by demand intensity).
|
||||
Suitable for stationary demand or as a baseline model.
|
||||
|
||||
The actual arrival count follows Poisson(rate * dt * intensity).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: PoissonArrivalConfig | None = None):
|
||||
self.cfg = cfg or PoissonArrivalConfig()
|
||||
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> list[Opportunity]:
|
||||
n_arrivals = poisson_arrivals(self.cfg.base_rate * hidden.true_demand_intensity, dt, rng)
|
||||
opps = []
|
||||
for _ in range(n_arrivals):
|
||||
inst_id = rng.integers(0, instruments.n)
|
||||
side = rng.choice(list(self.cfg.side_probs.keys()),
|
||||
p=list(self.cfg.side_probs.values()))
|
||||
opps.append(Opportunity(
|
||||
id=str(uuid4())[:8], type=OpportunityType.SESSION,
|
||||
side=side, instrument_id=inst_id, size=1.0, t=t,
|
||||
context={'segment': 'default'}
|
||||
))
|
||||
return opps
|
||||
|
||||
@dataclass
|
||||
class HawkesArrivalConfig:
|
||||
"""Configuration for Hawkes self-exciting process.
|
||||
|
||||
Attributes:
|
||||
base_rate: Baseline arrival intensity
|
||||
alpha: Excitation strength (how much each arrival increases intensity)
|
||||
beta: Decay rate (how quickly excitation fades)
|
||||
side_probs: Probability distribution over BUY/SELL sides
|
||||
"""
|
||||
base_rate: float = 5.0
|
||||
alpha: float = 0.5
|
||||
beta: float = 1.0
|
||||
side_probs: dict[Side, float] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.side_probs is None:
|
||||
self.side_probs = {Side.BUY: 0.5, Side.SELL: 0.5}
|
||||
|
||||
class HawkesArrivalModel:
|
||||
"""Self-exciting Hawkes point process for clustered arrivals.
|
||||
|
||||
Models order flow where arrivals cluster in time (momentum, herding).
|
||||
Intensity: lambda(t) = base + alpha * sum(exp(-beta * (t - t_i)))
|
||||
|
||||
Used for market making scenarios where orders arrive in bursts.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: HawkesArrivalConfig | None = None):
|
||||
self.cfg = cfg or HawkesArrivalConfig()
|
||||
self._history: np.ndarray = np.array([])
|
||||
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> list[Opportunity]:
|
||||
intensity = hawkes_intensity(
|
||||
self.cfg.base_rate * hidden.true_demand_intensity,
|
||||
self._history, self.cfg.alpha, self.cfg.beta, t
|
||||
)
|
||||
n_arrivals = poisson_arrivals(intensity, dt, rng)
|
||||
opps = []
|
||||
for i in range(n_arrivals):
|
||||
arr_t = t + rng.uniform(0, dt)
|
||||
self._history = np.append(self._history, arr_t)
|
||||
inst_id = rng.integers(0, instruments.n)
|
||||
side = rng.choice(list(self.cfg.side_probs.keys()),
|
||||
p=list(self.cfg.side_probs.values()))
|
||||
opps.append(Opportunity(
|
||||
id=str(uuid4())[:8], type=OpportunityType.MARKET_ORDER,
|
||||
side=side, instrument_id=inst_id,
|
||||
size=rng.exponential(1.0), t=arr_t,
|
||||
context={'intensity': intensity}
|
||||
))
|
||||
# decay old history
|
||||
self._history = self._history[self._history > t - 10]
|
||||
return opps
|
||||
|
||||
@dataclass
|
||||
class SessionArrivalConfig:
|
||||
"""Configuration for retail session arrivals.
|
||||
|
||||
Attributes:
|
||||
sessions_per_step: Number of browsing sessions per step
|
||||
views_per_session: (min, max) product views per session
|
||||
contamination: Fraction of sessions that are scrapers/bots
|
||||
"""
|
||||
sessions_per_step: int = 20
|
||||
views_per_session: tuple[int, int] = (1, 5)
|
||||
contamination: float = 0.0
|
||||
|
||||
class SessionArrivalModel:
|
||||
"""Retail browsing session model with multi-product views.
|
||||
|
||||
Each session views multiple products, generating one opportunity per view.
|
||||
Scraper sessions (controlled by contamination) view more products
|
||||
but convert at lower rates (handled by ExecutionModel).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: SessionArrivalConfig | None = None):
|
||||
self.cfg = cfg or SessionArrivalConfig()
|
||||
|
||||
def sample(self, t: float, dt: float, instruments: InstrumentSet,
|
||||
market: MarketState | None, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> list[Opportunity]:
|
||||
n_sessions = self.cfg.sessions_per_step
|
||||
contamination = hidden.contamination if hidden else self.cfg.contamination
|
||||
opps = []
|
||||
|
||||
for _ in range(n_sessions):
|
||||
is_scraper = rng.random() < contamination
|
||||
n_views = rng.integers(*self.cfg.views_per_session)
|
||||
sid = str(uuid4())[:8]
|
||||
|
||||
# scrapers view more products
|
||||
if is_scraper:
|
||||
n_views = min(instruments.n, n_views * 3)
|
||||
|
||||
viewed = rng.choice(instruments.n, size=min(n_views, instruments.n), replace=False)
|
||||
for inst_id in viewed:
|
||||
opps.append(Opportunity(
|
||||
id=f"{sid}-{inst_id}", type=OpportunityType.SESSION,
|
||||
side=Side.BUY, instrument_id=int(inst_id), size=1.0, t=t,
|
||||
context={'session_id': sid, 'is_scraper': is_scraper, 'n_views': n_views}
|
||||
))
|
||||
return opps
|
||||
189
lab/population/competitors.py
Normal file
189
lab/population/competitors.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Market and competitor models for external dynamics.
|
||||
|
||||
This module provides models for competitor pricing (retail) and market dynamics (finance):
|
||||
- StaticCompetitorModel: Fixed competitor prices
|
||||
- ReactiveCompetitorModel: Competitor reacts to agent's prices, can trigger price wars
|
||||
- StochasticCompetitorModel: Random walk competitor prices
|
||||
- GBMMarketModel: Geometric Brownian Motion for asset mid-prices
|
||||
|
||||
Each model implements the MarketModel protocol.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
import numpy as np
|
||||
from ..outlet.types import Quote, MarketState, HiddenState
|
||||
from ..outlet.math_util import clamp, ema
|
||||
|
||||
@dataclass
|
||||
class StaticCompetitorConfig:
|
||||
"""Configuration for static competitor.
|
||||
|
||||
Attributes:
|
||||
markup: Fixed percentage markup over reference prices
|
||||
"""
|
||||
markup: float = 0.1
|
||||
|
||||
class StaticCompetitorModel:
|
||||
"""Static competitor with fixed markup pricing.
|
||||
|
||||
Competitor prices = reference * (1 + markup).
|
||||
Useful as a baseline or for testing without competitor dynamics.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: StaticCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
||||
self.cfg = cfg or StaticCompetitorConfig()
|
||||
self.refs = refs
|
||||
|
||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> MarketState:
|
||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
||||
comp_prices = refs * (1 + self.cfg.markup)
|
||||
return MarketState(competitor_quotes=comp_prices, regime='static', t=t)
|
||||
|
||||
@dataclass
|
||||
class ReactiveCompetitorConfig:
|
||||
"""Configuration for reactive competitor.
|
||||
|
||||
Attributes:
|
||||
follow_weight: Smoothing weight for price following (0=ignore, 1=instant)
|
||||
band_pct: Maximum deviation from reference prices
|
||||
war_threshold: Relative price diff that triggers price war
|
||||
war_aggression: How much competitor cuts prices during war
|
||||
"""
|
||||
follow_weight: float = 0.3
|
||||
band_pct: float = 0.1
|
||||
war_threshold: float = -0.15
|
||||
war_aggression: float = 0.2
|
||||
|
||||
class ReactiveCompetitorModel:
|
||||
"""Competitor that reacts to agent's prices with price war dynamics.
|
||||
|
||||
The competitor follows the agent's prices with smoothing.
|
||||
If the agent undercuts significantly (beyond war_threshold),
|
||||
a price war is triggered where the competitor becomes more aggressive.
|
||||
|
||||
This creates non-stationary dynamics that test policy robustness.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ReactiveCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
||||
self.cfg = cfg or ReactiveCompetitorConfig()
|
||||
self.refs = refs
|
||||
self._prices: np.ndarray | None = None
|
||||
self._in_war: bool = False
|
||||
|
||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> MarketState:
|
||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
||||
c = self.cfg
|
||||
|
||||
if self._prices is None:
|
||||
self._prices = refs.copy()
|
||||
|
||||
# check for price war trigger
|
||||
relative_diff = (self_quotes.prices - self._prices) / (self._prices + 1e-8)
|
||||
if np.any(relative_diff < c.war_threshold):
|
||||
self._in_war = True
|
||||
elif np.all(relative_diff > -c.war_threshold / 2):
|
||||
self._in_war = False
|
||||
|
||||
# update prices
|
||||
if self._in_war:
|
||||
target = self_quotes.prices * (1 - c.war_aggression)
|
||||
hidden.regime = 'price_war'
|
||||
else:
|
||||
target = self_quotes.prices * (1 + c.follow_weight * 0.05)
|
||||
hidden.regime = 'normal'
|
||||
|
||||
# follow with smoothing
|
||||
new_prices = np.array([ema(old, new, c.follow_weight)
|
||||
for old, new in zip(self._prices, target)])
|
||||
|
||||
# stay within band
|
||||
new_prices = clamp(new_prices, refs * (1 - c.band_pct), refs * (1 + c.band_pct))
|
||||
self._prices = new_prices
|
||||
|
||||
return MarketState(competitor_quotes=new_prices, regime=hidden.regime, t=t)
|
||||
|
||||
@dataclass
|
||||
class StochasticCompetitorConfig:
|
||||
"""Configuration for stochastic competitor.
|
||||
|
||||
Attributes:
|
||||
drift: Price drift per step
|
||||
volatility: Price volatility (std of random shocks)
|
||||
mean_revert: Mean reversion strength toward reference
|
||||
"""
|
||||
drift: float = 0.0
|
||||
volatility: float = 0.02
|
||||
mean_revert: float = 0.1
|
||||
|
||||
class StochasticCompetitorModel:
|
||||
"""Ornstein-Uhlenbeck style stochastic competitor prices.
|
||||
|
||||
Prices follow: dP = drift + mean_revert*(ref - P) + volatility*P*dW
|
||||
|
||||
Provides non-stationary competitor dynamics independent of agent actions.
|
||||
Useful for testing robustness to market noise.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: StochasticCompetitorConfig | None = None, refs: np.ndarray | None = None):
|
||||
self.cfg = cfg or StochasticCompetitorConfig()
|
||||
self.refs = refs
|
||||
self._prices: np.ndarray | None = None
|
||||
|
||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> MarketState:
|
||||
refs = self.refs if self.refs is not None else self_quotes.prices
|
||||
c = self.cfg
|
||||
|
||||
if self._prices is None:
|
||||
self._prices = refs.copy()
|
||||
|
||||
# Ornstein-Uhlenbeck style dynamics
|
||||
n = len(self._prices)
|
||||
noise = rng.normal(0, c.volatility, n)
|
||||
reversion = c.mean_revert * (refs - self._prices)
|
||||
self._prices = self._prices + c.drift + reversion + noise * self._prices
|
||||
self._prices = np.maximum(self._prices, refs * 0.5)
|
||||
|
||||
return MarketState(competitor_quotes=self._prices.copy(), regime='stochastic', t=t)
|
||||
|
||||
@dataclass
|
||||
class GBMMarketConfig:
|
||||
"""Configuration for GBM market model.
|
||||
|
||||
Attributes:
|
||||
mu: Price drift (expected return)
|
||||
sigma: Price volatility
|
||||
dt: Time step size
|
||||
"""
|
||||
mu: float = 0.0
|
||||
sigma: float = 0.1
|
||||
dt: float = 1.0
|
||||
|
||||
class GBMMarketModel:
|
||||
"""Geometric Brownian Motion model for asset mid-prices.
|
||||
|
||||
Standard Black-Scholes dynamics: dS = mu*S*dt + sigma*S*dW
|
||||
|
||||
Used for market making scenarios where the underlying asset price
|
||||
follows a random walk. The agent quotes around this moving mid-price.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: GBMMarketConfig | None = None, initial: np.ndarray | None = None):
|
||||
self.cfg = cfg or GBMMarketConfig()
|
||||
self._mids = initial
|
||||
|
||||
def step(self, t: float, self_quotes: Quote, hidden: HiddenState,
|
||||
rng: np.random.Generator) -> MarketState:
|
||||
if self._mids is None:
|
||||
self._mids = self_quotes.prices.copy()
|
||||
|
||||
c = self.cfg
|
||||
n = len(self._mids)
|
||||
z = rng.standard_normal(n)
|
||||
self._mids = self._mids * np.exp((c.mu - 0.5*c.sigma**2)*c.dt + c.sigma*np.sqrt(c.dt)*z)
|
||||
|
||||
vol = np.full(n, c.sigma)
|
||||
return MarketState(mid_prices=self._mids.copy(), volatility=vol, regime='gbm', t=t)
|
||||
174
lab/population/execution.py
Normal file
174
lab/population/execution.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
Execution models for computing acceptance/fill probabilities.
|
||||
|
||||
This module provides different models for how opportunities convert to executions:
|
||||
- ElasticityExecutionModel: Price elasticity with competitor cross-effects (retail)
|
||||
- IntensityExecutionModel: Distance-based fill intensity (market making)
|
||||
- LogitExecutionModel: Discrete choice model
|
||||
|
||||
Each model implements the ExecutionModel protocol.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
import numpy as np
|
||||
from ..outlet.types import Opportunity, Quote, InstrumentSet, MarketState
|
||||
from ..outlet.constants import Side
|
||||
from ..outlet.math_util import sigmoid, safe_log, intensity_decay, EPS
|
||||
|
||||
@dataclass
|
||||
class ElasticityConfig:
|
||||
"""Configuration for price elasticity execution model.
|
||||
|
||||
Attributes:
|
||||
base_prob: Baseline purchase probability at reference price
|
||||
price_sensitivity: Own-price elasticity coefficient
|
||||
cross_elasticity: Competitor price cross-elasticity
|
||||
scraper_conversion: Multiplier for scraper conversion (typically << 1)
|
||||
"""
|
||||
base_prob: float = 0.3
|
||||
price_sensitivity: float = 2.0
|
||||
cross_elasticity: float = 0.5
|
||||
scraper_conversion: float = 0.01
|
||||
|
||||
class ElasticityExecutionModel:
|
||||
"""Price elasticity model for retail dynamic pricing.
|
||||
|
||||
P(buy) = base_prob * exp(-sensitivity * log(price/ref)) * cross_effect * scraper_mult
|
||||
|
||||
Higher prices reduce purchase probability exponentially.
|
||||
Competitor undercutting shifts demand away from the platform.
|
||||
Scrapers convert at a much lower rate (reconnaissance, not purchase).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: ElasticityConfig | None = None):
|
||||
self.cfg = cfg or ElasticityConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
price = quote.prices[idx]
|
||||
ref = instruments.refs[idx]
|
||||
|
||||
# base probability adjusted by price ratio
|
||||
log_ratio = safe_log(price / ref)
|
||||
prob = self.cfg.base_prob * np.exp(-self.cfg.price_sensitivity * log_ratio)
|
||||
|
||||
# cross-elasticity: competitor undercutting increases their share
|
||||
if market and market.competitor_quotes is not None:
|
||||
comp_price = market.competitor_quotes[idx]
|
||||
if comp_price < price:
|
||||
prob *= np.exp(-self.cfg.cross_elasticity * (price - comp_price) / ref)
|
||||
|
||||
# scrapers convert at much lower rate
|
||||
if opp.context.get('is_scraper', False):
|
||||
prob *= self.cfg.scraper_conversion
|
||||
|
||||
return float(np.clip(prob, 0, 1))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
# simple imputation: assume fills = prob * exposures, invert
|
||||
exposures = context.get('exposures', fills) if context else fills
|
||||
avg_prob = self.cfg.base_prob
|
||||
return fills / (avg_prob + EPS)
|
||||
|
||||
@dataclass
|
||||
class IntensityConfig:
|
||||
"""Configuration for intensity-based execution model.
|
||||
|
||||
Attributes:
|
||||
base_intensity: Baseline fill intensity
|
||||
kappa: Decay rate with distance from mid-price
|
||||
vol_scale: Volatility multiplier for fill intensity
|
||||
"""
|
||||
base_intensity: float = 1.0
|
||||
kappa: float = 1.5
|
||||
vol_scale: float = 0.5
|
||||
|
||||
class IntensityExecutionModel:
|
||||
"""Avellaneda-Stoikov style fill intensity for market making.
|
||||
|
||||
Fill probability decays exponentially with distance from mid-price:
|
||||
P(fill) = base * exp(-kappa * |quote - mid|) * (1 + vol_scale * sigma)
|
||||
|
||||
Tighter spreads (closer to mid) have higher fill probability.
|
||||
Higher volatility increases fill probability (more aggressive traders).
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: IntensityConfig | None = None):
|
||||
self.cfg = cfg or IntensityConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
|
||||
# get mid price from market or use quote price
|
||||
if market and market.mid_prices is not None:
|
||||
mid = market.mid_prices[idx]
|
||||
else:
|
||||
mid = quote.prices[idx]
|
||||
|
||||
# compute distance from mid
|
||||
if opp.side == Side.BUY:
|
||||
exec_price = quote.asks[idx] if quote.asks is not None else quote.prices[idx]
|
||||
distance = exec_price - mid
|
||||
else:
|
||||
exec_price = quote.bids[idx] if quote.bids is not None else quote.prices[idx]
|
||||
distance = mid - exec_price
|
||||
|
||||
# intensity decays with distance
|
||||
intensity = self.cfg.base_intensity * intensity_decay(abs(distance), self.cfg.kappa)
|
||||
|
||||
# volatility increases fill probability
|
||||
if market and market.volatility is not None:
|
||||
vol = market.volatility[idx]
|
||||
intensity *= (1 + self.cfg.vol_scale * vol)
|
||||
|
||||
return float(np.clip(intensity, 0, 1))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
return fills # market making doesn't have same censorship concept
|
||||
|
||||
@dataclass
|
||||
class LogitConfig:
|
||||
"""Configuration for logit discrete choice model.
|
||||
|
||||
Attributes:
|
||||
beta_0: Intercept (base utility)
|
||||
beta_price: Price coefficient (typically negative)
|
||||
beta_quality: Quality attribute coefficient
|
||||
"""
|
||||
beta_0: float = 0.5
|
||||
beta_price: float = -1.5
|
||||
beta_quality: float = 0.3
|
||||
|
||||
class LogitExecutionModel:
|
||||
"""Discrete choice logit model for purchase probability.
|
||||
|
||||
Utility: U = beta_0 + beta_price * (price/ref) + beta_quality * quality
|
||||
P(buy) = sigmoid(U)
|
||||
|
||||
Provides a theoretically grounded demand model from economics literature.
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: LogitConfig | None = None):
|
||||
self.cfg = cfg or LogitConfig()
|
||||
|
||||
def prob(self, opp: Opportunity, quote: Quote, instruments: InstrumentSet,
|
||||
market: MarketState | None, rng: np.random.Generator) -> float:
|
||||
idx = int(opp.instrument_id)
|
||||
price = quote.prices[idx]
|
||||
ref = instruments.refs[idx]
|
||||
quality = instruments.instruments[idx].attrs.get('quality', 0.5)
|
||||
|
||||
# utility
|
||||
u = self.cfg.beta_0 + self.cfg.beta_price * (price / ref) + self.cfg.beta_quality * quality
|
||||
|
||||
# choice probability via sigmoid
|
||||
return float(sigmoid(u))
|
||||
|
||||
def uncensor(self, fills: np.ndarray, instruments: InstrumentSet,
|
||||
context: dict[str, Any] | None = None) -> np.ndarray:
|
||||
return fills / (self.cfg.beta_0 + EPS)
|
||||
59
lab/run_example.py
Normal file
59
lab/run_example.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python
|
||||
"""Example script demonstrating the Quote-Control platform"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import numpy as np
|
||||
from lab.config import make_retail_platform, make_market_making_platform
|
||||
from lab.experiments.eval import (rollout, compare_policies, fixed_price_policy,
|
||||
cost_plus_margin_policy, random_walk_policy)
|
||||
|
||||
def demo_retail():
|
||||
print("=" * 60)
|
||||
print("RETAIL DYNAMIC PRICING DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
platform = make_retail_platform()
|
||||
print(f"Instruments: {platform.instruments.n}")
|
||||
print(f"Reference prices: {platform.instruments.refs[:5].round(2)}...")
|
||||
|
||||
# compare policies
|
||||
policies = {
|
||||
'fixed': fixed_price_policy(platform.instruments.refs),
|
||||
'cost_plus_30%': cost_plus_margin_policy(platform.instruments.costs, 0.3),
|
||||
'cost_plus_50%': cost_plus_margin_policy(platform.instruments.costs, 0.5),
|
||||
'random_walk': random_walk_policy(platform.instruments.refs, 0.03),
|
||||
}
|
||||
|
||||
results = compare_policies(platform, policies, n_steps=100, n_runs=3)
|
||||
|
||||
print("\nPolicy Comparison (100 steps, 3 runs):")
|
||||
print("-" * 50)
|
||||
for name, r in sorted(results.items(), key=lambda x: -x[1]['mean_pnl']):
|
||||
print(f"{name:20s} PnL={r['mean_pnl']:8.1f} +/- {r['std_reward']:6.1f} "
|
||||
f"conv={r['mean_conversion']:.3f}")
|
||||
|
||||
def demo_market_making():
|
||||
print("\n" + "=" * 60)
|
||||
print("MARKET MAKING DEMO")
|
||||
print("=" * 60)
|
||||
|
||||
platform = make_market_making_platform()
|
||||
print(f"Instruments: {platform.instruments.n}")
|
||||
print(f"Initial mids: {platform.instruments.refs.round(2)}")
|
||||
|
||||
# simple policy: quote at mid with fixed spread
|
||||
def mm_policy(obs: np.ndarray, t: int):
|
||||
mids = platform.instruments.refs # would use obs in real policy
|
||||
return mids, 1.0
|
||||
|
||||
result = rollout(platform, mm_policy, n_steps=200, seed=42)
|
||||
print(f"\nRollout (200 steps):")
|
||||
print(f" Total PnL: {result.total_pnl:.2f}")
|
||||
print(f" Avg conversion: {result.avg_conversion:.3f}")
|
||||
print(f" Total spread capture: {sum(m.spread_capture for m in result.metrics):.2f}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
demo_retail()
|
||||
demo_market_making()
|
||||
41
lib/__init__.py
Normal file
41
lib/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""PHANTOM shared library
|
||||
Exports unified utilities for features, state, config, kafka, and model registry
|
||||
"""
|
||||
from .config import (
|
||||
PROJECT_ROOT, DATA_DIR, EXPERIMENTS_DIR,
|
||||
AGENT_DATA_DIR, HUMAN_DATA_DIR, SIM_RUNS_DIR, MODEL_REGISTRY_DIR,
|
||||
COLLECTED_DATA_DIR, NOTEBOOK_OUTPUT_DIR,
|
||||
ensure_dir, get_data_path, get_experiments_path, get_sim_path,
|
||||
KAFKA_HOST, KAFKA_PORT, KAFKA_BROKER,
|
||||
REDIS_HOST, REDIS_PORT,
|
||||
SUPABASE_URL, SUPABASE_ANON_KEY,
|
||||
BACKEND_PORT, PROVIDER_PORT
|
||||
)
|
||||
from .state import (
|
||||
make_state_repr, event_to_state, parse_state,
|
||||
get_event_name, get_timestamp,
|
||||
create_state_fn, create_event_name_fn, create_timestamp_fn
|
||||
)
|
||||
from .features import (
|
||||
transition_histogram, temporal_signature, state_coverage, transition_entropy,
|
||||
event_type_distribution, featurize_trajectory, parse_timestamp
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# config
|
||||
'PROJECT_ROOT', 'DATA_DIR', 'EXPERIMENTS_DIR',
|
||||
'AGENT_DATA_DIR', 'HUMAN_DATA_DIR', 'SIM_RUNS_DIR', 'MODEL_REGISTRY_DIR',
|
||||
'COLLECTED_DATA_DIR', 'NOTEBOOK_OUTPUT_DIR',
|
||||
'ensure_dir', 'get_data_path', 'get_experiments_path', 'get_sim_path',
|
||||
'KAFKA_HOST', 'KAFKA_PORT', 'KAFKA_BROKER',
|
||||
'REDIS_HOST', 'REDIS_PORT',
|
||||
'SUPABASE_URL', 'SUPABASE_ANON_KEY',
|
||||
'BACKEND_PORT', 'PROVIDER_PORT',
|
||||
# state
|
||||
'make_state_repr', 'event_to_state', 'parse_state',
|
||||
'get_event_name', 'get_timestamp',
|
||||
'create_state_fn', 'create_event_name_fn', 'create_timestamp_fn',
|
||||
# features
|
||||
'transition_histogram', 'temporal_signature', 'state_coverage', 'transition_entropy',
|
||||
'event_type_distribution', 'featurize_trajectory', 'parse_timestamp',
|
||||
]
|
||||
65
lib/config.py
Normal file
65
lib/config.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Unified path configuration for PHANTOM project
|
||||
All hardcoded paths should reference this module
|
||||
Paths can be overridden via environment variables
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# project root (directory containing lib/, experiments/, sim/, web/, backend/)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
|
||||
# data directories
|
||||
DATA_DIR = Path(os.getenv('PHANTOM_DATA_DIR', PROJECT_ROOT / 'data'))
|
||||
EXPERIMENTS_DIR = Path(os.getenv('PHANTOM_EXPERIMENTS_DIR', PROJECT_ROOT / 'experiments'))
|
||||
|
||||
# agent/human interaction data
|
||||
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', DATA_DIR / 'agents'))
|
||||
HUMAN_DATA_DIR = Path(os.getenv('PHANTOM_HUMAN_DATA_DIR', DATA_DIR / 'humans'))
|
||||
|
||||
# RL simulation runs
|
||||
SIM_RUNS_DIR = Path(os.getenv('PHANTOM_SIM_RUNS_DIR', PROJECT_ROOT / 'sim' / 'rl' / 'runs'))
|
||||
|
||||
# model artifacts
|
||||
MODEL_REGISTRY_DIR = Path(os.getenv('PHANTOM_MODEL_REGISTRY_DIR', DATA_DIR / 'models'))
|
||||
|
||||
# collected experiment data
|
||||
COLLECTED_DATA_DIR = Path(os.getenv('PHANTOM_COLLECTED_DATA_DIR', EXPERIMENTS_DIR / 'agents' / 'collected_data'))
|
||||
|
||||
# notebook outputs
|
||||
NOTEBOOK_OUTPUT_DIR = Path(os.getenv('PHANTOM_NOTEBOOK_OUTPUT_DIR', EXPERIMENTS_DIR / 'notebooks' / 'outputs'))
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
"""ensure directory exists, create if needed"""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def get_data_path(*parts: str) -> Path:
|
||||
"""construct path relative to DATA_DIR"""
|
||||
return DATA_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
def get_experiments_path(*parts: str) -> Path:
|
||||
"""construct path relative to EXPERIMENTS_DIR"""
|
||||
return EXPERIMENTS_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
def get_sim_path(*parts: str) -> Path:
|
||||
"""construct path relative to SIM_RUNS_DIR"""
|
||||
return SIM_RUNS_DIR.joinpath(*parts)
|
||||
|
||||
|
||||
# service configuration (from .env)
|
||||
KAFKA_HOST = os.getenv('KAFKA_HOST', 'localhost')
|
||||
KAFKA_PORT = os.getenv('KAFKA_PORT', '9092')
|
||||
KAFKA_BROKER = f"{KAFKA_HOST}:{KAFKA_PORT}"
|
||||
|
||||
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost')
|
||||
REDIS_PORT = int(os.getenv('REDIS_PORT', '6379'))
|
||||
|
||||
SUPABASE_URL = os.getenv('NEXT_PUBLIC_SUPABASE_URL', '')
|
||||
SUPABASE_ANON_KEY = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY', '')
|
||||
|
||||
BACKEND_PORT = int(os.getenv('BACKEND_PORT', '5000'))
|
||||
PROVIDER_PORT = int(os.getenv('PROVIDER_PORT', '5001'))
|
||||
125
lib/features.py
Normal file
125
lib/features.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Unified featurization utilities for trajectory -> feature vector conversion
|
||||
Used by both experiments/ml/ and sim/rl/ components
|
||||
"""
|
||||
import numpy as np
|
||||
from collections import defaultdict
|
||||
from typing import List, Dict, Callable, Optional, Any, Set
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def transition_histogram(events: List, state_fn: Callable, max_states: int = 50) -> np.ndarray:
|
||||
"""compute normalized histogram of state transitions in trajectory
|
||||
events: list of event objects/dicts
|
||||
state_fn: function mapping event -> state string
|
||||
max_states: maximum dimensions for histogram
|
||||
"""
|
||||
if len(events) < 2:
|
||||
return np.zeros(max_states, dtype=np.float32)
|
||||
states = [state_fn(e) for e in events]
|
||||
trans_counts = defaultdict(int)
|
||||
for s, s_next in zip(states, states[1:]):
|
||||
trans_counts[(s, s_next)] += 1
|
||||
total = sum(trans_counts.values())
|
||||
hist = np.array(list(trans_counts.values())[:max_states], dtype=np.float32)
|
||||
hist = np.pad(hist, (0, max(0, max_states - len(hist))))
|
||||
return hist / (total + 1e-10)
|
||||
|
||||
|
||||
def temporal_signature(events: List, ts_fn: Callable) -> np.ndarray:
|
||||
"""extract temporal features: mean/std/skew of inter-event times plus count
|
||||
events: list of event objects/dicts
|
||||
ts_fn: function mapping event -> timestamp (float seconds)
|
||||
returns: [mean_dt, std_dt, skew, n_intervals] array
|
||||
"""
|
||||
if len(events) < 2:
|
||||
return np.zeros(4, dtype=np.float32)
|
||||
times = sorted([ts_fn(e) for e in events])
|
||||
diffs = np.diff(times).astype(np.float32)
|
||||
if len(diffs) == 0:
|
||||
return np.zeros(4, dtype=np.float32)
|
||||
mean_dt, std_dt = np.mean(diffs), np.std(diffs) + 1e-10
|
||||
skew = np.mean(((diffs - mean_dt) / std_dt) ** 3) if std_dt > 1e-8 else 0.0
|
||||
return np.array([mean_dt, std_dt, skew, len(diffs)], dtype=np.float32)
|
||||
|
||||
|
||||
def state_coverage(events: List, state_fn: Callable, mdp_states: Set[str]) -> float:
|
||||
"""fraction of MDP states visited by trajectory
|
||||
events: list of event objects/dicts
|
||||
state_fn: function mapping event -> state string
|
||||
mdp_states: set of all possible MDP states
|
||||
"""
|
||||
if not mdp_states:
|
||||
return 0.0
|
||||
visited = set(state_fn(e) for e in events)
|
||||
return len(visited & mdp_states) / len(mdp_states)
|
||||
|
||||
|
||||
def transition_entropy(events: List, state_fn: Callable) -> float:
|
||||
"""compute entropy of transition distribution (randomness of navigation)
|
||||
higher entropy = more random browsing pattern
|
||||
"""
|
||||
if len(events) < 2:
|
||||
return 0.0
|
||||
states = [state_fn(e) for e in events]
|
||||
trans_counts = defaultdict(int)
|
||||
for s, s_next in zip(states, states[1:]):
|
||||
trans_counts[(s, s_next)] += 1
|
||||
total = sum(trans_counts.values())
|
||||
probs = [c / total for c in trans_counts.values()]
|
||||
return -sum(p * np.log(p + 1e-10) for p in probs)
|
||||
|
||||
|
||||
def event_type_distribution(events: List, event_name_fn: Callable) -> np.ndarray:
|
||||
"""compute proportions of different event type categories
|
||||
returns: [page_view_ratio, hover_ratio, cart_ratio, purchase_ratio]
|
||||
"""
|
||||
if not events:
|
||||
return np.zeros(4, dtype=np.float32)
|
||||
n = len(events)
|
||||
names = [event_name_fn(e).lower() for e in events]
|
||||
return np.array([
|
||||
sum(1 for nm in names if 'page' in nm or 'view' in nm) / n,
|
||||
sum(1 for nm in names if 'hover' in nm) / n,
|
||||
sum(1 for nm in names if 'cart' in nm) / n,
|
||||
sum(1 for nm in names if 'purchase' in nm or 'checkout' in nm) / n
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
def featurize_trajectory(events: List, state_fn: Callable, ts_fn: Callable,
|
||||
event_name_fn: Callable, mdp_states: Optional[Set[str]] = None,
|
||||
output_dim: int = 64) -> np.ndarray:
|
||||
"""convert trajectory to fixed-dimension feature vector
|
||||
events: list of event objects/dicts
|
||||
state_fn: function mapping event -> state string
|
||||
ts_fn: function mapping event -> timestamp (float)
|
||||
event_name_fn: function mapping event -> event name string
|
||||
mdp_states: optional set of all MDP states for coverage calculation
|
||||
output_dim: desired output dimension (will pad/truncate)
|
||||
"""
|
||||
feats = []
|
||||
feats.extend(transition_histogram(events, state_fn, max_states=40)) # 40 dims
|
||||
feats.extend(temporal_signature(events, ts_fn)) # 4 dims
|
||||
feats.append(state_coverage(events, state_fn, mdp_states or set())) # 1 dim
|
||||
feats.append(transition_entropy(events, state_fn)) # 1 dim
|
||||
feats.append(float(len(events))) # trajectory length
|
||||
feats.append(float(len(set(state_fn(e) for e in events)))) # unique states
|
||||
feats.extend(event_type_distribution(events, event_name_fn)) # 4 dims
|
||||
|
||||
feats = np.array(feats[:output_dim], dtype=np.float32)
|
||||
if len(feats) < output_dim:
|
||||
feats = np.pad(feats, (0, output_dim - len(feats)))
|
||||
return feats
|
||||
|
||||
|
||||
def parse_timestamp(ts: Any) -> float:
|
||||
"""parse various timestamp formats to float seconds"""
|
||||
if ts is None:
|
||||
return 0.0
|
||||
if isinstance(ts, (int, float)):
|
||||
return float(ts)
|
||||
if isinstance(ts, str):
|
||||
try:
|
||||
return datetime.fromisoformat(ts.replace('Z', '+00:00')).timestamp()
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return 0.0
|
||||
54
lib/kafka_client.py
Executable file
54
lib/kafka_client.py
Executable file
@@ -0,0 +1,54 @@
|
||||
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)
|
||||
@@ -26,6 +26,7 @@ class ModelRegistry:
|
||||
self.metadata_prefix = "model:meta:"
|
||||
self.data_prefix = "model:data:"
|
||||
self.elasticity_prefix = "elasticity:"
|
||||
self.prices_prefix = "prices:"
|
||||
|
||||
def publish_elasticity(self,
|
||||
elasticity_df: pd.DataFrame,
|
||||
@@ -130,6 +131,46 @@ class ModelRegistry:
|
||||
|
||||
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(data_json, orient='records')
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""Check if Redis connection is alive."""
|
||||
try:
|
||||
@@ -137,3 +178,49 @@ 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()
|
||||
}
|
||||
|
||||
72
lib/state.py
Normal file
72
lib/state.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""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
|
||||
@@ -11,3 +11,4 @@ pytest-asyncio
|
||||
uv
|
||||
scikit-learn
|
||||
supabase
|
||||
pymc
|
||||
|
||||
97
sim/rl/behavior_loader/loader.py
Normal file
97
sim/rl/behavior_loader/loader.py
Normal file
@@ -0,0 +1,97 @@
|
||||
import os
|
||||
import json
|
||||
from pydantic import BaseModel as Base
|
||||
|
||||
class PayloadModel(Base):
|
||||
sessionId: str
|
||||
experimentId: str | None
|
||||
eventName: str
|
||||
page: str | None
|
||||
productId: str | None
|
||||
metadata: dict
|
||||
storeMode: str
|
||||
userAgent: str
|
||||
ts: str
|
||||
|
||||
class ValueModel(Base):
|
||||
payload: PayloadModel
|
||||
encoding: str
|
||||
isPayloadNull: bool
|
||||
schemaId: int
|
||||
size: int
|
||||
|
||||
class InteractionModel(Base):
|
||||
partitionID: int
|
||||
offset: int
|
||||
timestamp: int
|
||||
compression: str
|
||||
isTransactional: bool
|
||||
headers: list
|
||||
key: dict
|
||||
value: ValueModel
|
||||
|
||||
def _is_admin(page: str | None) -> bool:
|
||||
return page is not None and page.startswith("/admin/")
|
||||
|
||||
class Loader:
|
||||
def __init__(self, src_dir: str):
|
||||
self.src_dir = src_dir
|
||||
self.entries = os.listdir(src_dir)
|
||||
if not self.entries: raise ValueError("empty directory")
|
||||
self.data = self._load_sessions()
|
||||
|
||||
def _load_sessions(self) -> dict:
|
||||
sessions = {}
|
||||
for entry in self.entries:
|
||||
with open(f"{self.src_dir}/{entry}/int.json") as f:
|
||||
raw = json.load(f)
|
||||
ints = [InteractionModel(**i) for i in raw]
|
||||
sessions[entry] = [i for i in ints if not _is_admin(i.value.payload.page)]
|
||||
return sessions
|
||||
|
||||
def get_data(self) -> dict:
|
||||
return self.data
|
||||
|
||||
def get_entries(self) -> tuple[list[str], int]:
|
||||
return self.entries, len(self.entries)
|
||||
|
||||
class AgentLoader(Loader):
|
||||
def _load_sessions(self) -> dict:
|
||||
sessions = {}
|
||||
for entry in self.entries:
|
||||
with open(f"{self.src_dir}/{entry}/int.json") as f:
|
||||
raw = json.load(f)
|
||||
ints = [PayloadModel(**i) for i in raw]
|
||||
sessions[entry] = [i for i in ints if not _is_admin(i.page)]
|
||||
return sessions
|
||||
|
||||
class JointLoader:
|
||||
def __init__(self, human_dir: str, agent_dir: str):
|
||||
self.human_loader = Loader(human_dir)
|
||||
self.agent_loader = AgentLoader(agent_dir)
|
||||
self.data = self._merge()
|
||||
self.entries = list(self.data.keys())
|
||||
|
||||
def _merge(self) -> dict:
|
||||
return {
|
||||
**{f"human_{sid}": [e.value.payload for e in evts]
|
||||
for sid, evts in self.human_loader.get_data().items()},
|
||||
**{f"agent_{sid}": evts
|
||||
for sid, evts in self.agent_loader.get_data().items()}
|
||||
}
|
||||
|
||||
def get_data(self) -> dict:
|
||||
return self.data
|
||||
|
||||
def get_entries(self) -> tuple[list[str], int]:
|
||||
return self.entries, len(self.entries)
|
||||
|
||||
if __name__ == "__main__":
|
||||
agent_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/agents/collected_data/"
|
||||
human_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||
|
||||
for name, cls, path in [("agent", AgentLoader, agent_dir),
|
||||
("human", Loader, human_dir),
|
||||
("joint", lambda d: JointLoader(human_dir, d), agent_dir)]:
|
||||
ldr = cls(path) if name != "joint" else cls(agent_dir)
|
||||
print(f"Loaded {len(ldr.get_entries()[0])} {name} sessions")
|
||||
256
sim/rl/behavior_loader/models.py
Normal file
256
sim/rl/behavior_loader/models.py
Normal file
@@ -0,0 +1,256 @@
|
||||
try:
|
||||
from loader import Loader, AgentLoader, JointLoader
|
||||
except ImportError:
|
||||
from sim.rl.behavior_loader.loader import Loader, AgentLoader, JointLoader
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Tuple, Set
|
||||
import numpy as np
|
||||
import graphviz
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# import lib utilities for optional use - models keep their own _state_repr for backwards compat
|
||||
# with the specific event structure (evt.value.payload)
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'lib'))
|
||||
try:
|
||||
from lib.state import make_state_repr as lib_make_state_repr
|
||||
from lib.features import transition_histogram as lib_transition_histogram
|
||||
except ImportError:
|
||||
lib_make_state_repr = None
|
||||
lib_transition_histogram = None
|
||||
print("lib no includable")
|
||||
|
||||
|
||||
|
||||
class BehaviorModel:
|
||||
def __init__(self, src_dir: str, loader_cls=Loader):
|
||||
self.loader = loader_cls(src_dir)
|
||||
self.data = self.loader.get_data()
|
||||
self.entries, self.num_entries = self.loader.get_entries()
|
||||
self.mdp = None
|
||||
|
||||
def _state_repr(self, evt) -> str:
|
||||
p = evt.value.payload
|
||||
return f"{p.page or 'unk'}|{p.productId or 'none'}|{p.eventName}"
|
||||
|
||||
def _sort_key(self, evt):
|
||||
return evt.timestamp
|
||||
|
||||
def _extract_sessions(self) -> List[List[str]]:
|
||||
trajs = []
|
||||
for evts in self.data.values():
|
||||
if len(evts) < 2: continue
|
||||
states = [self._state_repr(e) for e in sorted(evts, key=self._sort_key)]
|
||||
trajs.append(states)
|
||||
return trajs
|
||||
|
||||
def _calc_transitions(self, trajs: List[List[str]]) -> Tuple[Dict, Set]:
|
||||
trans, states = defaultdict(lambda: defaultdict(int)), set()
|
||||
for traj in trajs:
|
||||
for s, s_next in zip(traj, traj[1:]):
|
||||
trans[s][s_next] += 1
|
||||
states.update([s, s_next])
|
||||
return trans, states
|
||||
|
||||
def _calc_rewards(self, trajs: List[List[str]]) -> Dict:
|
||||
rwd = defaultdict(list)
|
||||
for traj in trajs:
|
||||
n = len(traj)
|
||||
for i, s in enumerate(traj):
|
||||
rwd[s].append(i / n)
|
||||
return rwd
|
||||
|
||||
def _normalize_trans(self, cnts: Dict) -> Dict:
|
||||
return {s: {s_n: cnt/sum(nxt.values()) for s_n, cnt in nxt.items()}
|
||||
for s, nxt in cnts.items()}
|
||||
|
||||
def build_MDP(self) -> Dict:
|
||||
trajs = self._extract_sessions()
|
||||
trans_cnt, states = self._calc_transitions(trajs)
|
||||
trans_prob = self._normalize_trans(trans_cnt)
|
||||
state_rwd = self._calc_rewards(trajs)
|
||||
|
||||
self.mdp = {
|
||||
'states': sorted(states),
|
||||
'num_states': len(states),
|
||||
'transitions': trans_prob,
|
||||
'state_values': {s: np.mean(r) for s, r in state_rwd.items()},
|
||||
'state_rewards': state_rwd,
|
||||
'trans_counts': trans_cnt,
|
||||
}
|
||||
return self.mdp
|
||||
|
||||
def transition_prob(self, s: str, s_next: str) -> float:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
return self.mdp['transitions'].get(s, {}).get(s_next, 0.0)
|
||||
|
||||
def state_value(self, s: str) -> float:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
return self.mdp['state_values'].get(s, 0.0)
|
||||
|
||||
def sample_traj(self, start: str, max_len: int = 50) -> List[str]:
|
||||
if not self.mdp: raise ValueError("build MDP first")
|
||||
path, curr = [start], start
|
||||
for _ in range(max_len):
|
||||
nxt = self.mdp['transitions'].get(curr, {})
|
||||
if not nxt: break
|
||||
curr = np.random.choice(list(nxt.keys()), p=list(nxt.values()))
|
||||
path.append(curr)
|
||||
return path
|
||||
|
||||
def extract_trajectory_features(self, events: List, max_trans_dim: int = 50) -> np.ndarray:
|
||||
"""Convert trajectory to feature vector using MDP structure for contrastive learning"""
|
||||
if not self.mdp:
|
||||
self.build_MDP()
|
||||
|
||||
states = [self._state_repr(e) for e in sorted(events, key=self._sort_key)]
|
||||
features = []
|
||||
|
||||
# transition histogram over MDP state space
|
||||
trans_counts = defaultdict(int)
|
||||
for s, s_next in zip(states, states[1:]):
|
||||
trans_counts[(s, s_next)] += 1
|
||||
all_trans = [(s, t) for s in self.mdp['states'] for t in self.mdp['transitions'].get(s, {}).keys()]
|
||||
trans_vec = [trans_counts.get(tr, 0) for tr in all_trans[:max_trans_dim]]
|
||||
trans_vec = trans_vec + [0] * (max_trans_dim - len(trans_vec)) # pad
|
||||
total_trans = sum(trans_counts.values()) or 1
|
||||
features.extend([v / total_trans for v in trans_vec])
|
||||
|
||||
# state coverage ratio
|
||||
visited = set(states)
|
||||
features.append(len(visited) / max(self.mdp['num_states'], 1))
|
||||
|
||||
# temporal entropy of transitions
|
||||
if len(states) > 1:
|
||||
trans_probs = [self.transition_prob(s, s_n) for s, s_n in zip(states, states[1:])]
|
||||
entropy = -sum(p * np.log(p + 1e-10) for p in trans_probs if p > 0)
|
||||
features.append(entropy / max(len(states), 1))
|
||||
else:
|
||||
features.append(0.0)
|
||||
|
||||
# trajectory length and unique state count
|
||||
features.append(len(states))
|
||||
features.append(len(visited))
|
||||
|
||||
# state value statistics along trajectory
|
||||
vals = [self.state_value(s) for s in states]
|
||||
if vals:
|
||||
features.extend([np.mean(vals), np.std(vals), np.min(vals), np.max(vals)])
|
||||
else:
|
||||
features.extend([0.0, 0.0, 0.0, 0.0])
|
||||
|
||||
return np.array(features, dtype=np.float32)
|
||||
|
||||
|
||||
class AgentBehaviorModel(BehaviorModel):
|
||||
def __init__(self, src_dir: str):
|
||||
super().__init__(src_dir, AgentLoader)
|
||||
|
||||
def _state_repr(self, evt) -> str:
|
||||
return f"{evt.page or 'unk'}|{evt.productId or 'none'}|{evt.eventName}"
|
||||
|
||||
def _sort_key(self, evt):
|
||||
return evt.ts
|
||||
|
||||
class JointBehaviorModel(BehaviorModel):
|
||||
def __init__(self, human_dir: str, agent_dir: str):
|
||||
self.loader = JointLoader(human_dir, agent_dir)
|
||||
self.data = self.loader.get_data()
|
||||
self.entries, self.num_entries = self.loader.get_entries()
|
||||
self.mdp = None
|
||||
|
||||
def _state_repr(self, evt) -> str:
|
||||
return f"{evt.page or 'unk'}|{evt.productId or 'none'}|{evt.eventName}"
|
||||
|
||||
def _sort_key(self, evt):
|
||||
return evt.ts
|
||||
|
||||
def aggregate_event_transitions(mdp: Dict) -> Dict[str, Dict[str, float]]:
|
||||
evt_trans = defaultdict(lambda: defaultdict(float))
|
||||
for s, trans in mdp['transitions'].items():
|
||||
src = s.split('|')[2]
|
||||
for s_next, prob in trans.items():
|
||||
dst = s_next.split('|')[2]
|
||||
evt_trans[src][dst] += prob
|
||||
|
||||
for src in evt_trans:
|
||||
total = sum(evt_trans[src].values())
|
||||
if total > 0:
|
||||
evt_trans[src] = {dst: p/total for dst, p in evt_trans[src].items()}
|
||||
return dict(evt_trans)
|
||||
|
||||
def visualize_mdp(model: BehaviorModel, threshold: float = 0.05, output: str = "mdp_graph",
|
||||
fmt: str = "svg", view: bool = False, export_dot: bool = False):
|
||||
if not model.mdp: raise ValueError("build MDP first")
|
||||
|
||||
evt_trans = aggregate_event_transitions(model.mdp)
|
||||
g = graphviz.Digraph(format=fmt)
|
||||
g.attr(rankdir='LR', size='30')
|
||||
g.attr('node', shape='circle', width='1', height='1')
|
||||
|
||||
events = set(evt_trans.keys()) | {e for trans in evt_trans.values() for e in trans.keys()}
|
||||
for evt in events:
|
||||
g.node(evt)
|
||||
|
||||
for src, dsts in evt_trans.items():
|
||||
for dst, prob in dsts.items():
|
||||
if prob > threshold:
|
||||
g.edge(src, dst, label=f'{prob:.2f}')
|
||||
|
||||
g.render(output, view=view, cleanup=True)
|
||||
print(f"Saved MDP graph to {output}.{fmt}")
|
||||
|
||||
if export_dot:
|
||||
with open(f"{output}.dot", 'w') as f:
|
||||
f.write(g.source)
|
||||
print(f"Exported DOT source to {output}.dot")
|
||||
|
||||
return g
|
||||
|
||||
def kl_divergence(p: Dict[str, float], q: Dict[str, float]) -> float:
|
||||
eps = 1e-10
|
||||
# p + log(p / q) summed over all keys in P
|
||||
return sum((p[k] + eps) * np.log((p[k] + eps) / (q.get(k, 0.0) + eps)) for k in p)
|
||||
|
||||
if __name__ == "__main__":
|
||||
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
|
||||
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
|
||||
|
||||
human_model = BehaviorModel(human_dir)
|
||||
human_mdp = human_model.build_MDP()
|
||||
print(f"Built MDP: {human_mdp['num_states']} states, "
|
||||
f"{sum(len(t) for t in human_mdp['transitions'].values())} transitions")
|
||||
if not human_mdp['states']:
|
||||
exit("No states found")
|
||||
visualize_mdp(human_model, threshold=0.05, output="human_mdp_viz", fmt="pdf", export_dot=True)
|
||||
|
||||
agent_model = AgentBehaviorModel(agent_dir)
|
||||
agent_mdp = agent_model.build_MDP()
|
||||
print(f"AGENT... Built MDP: {agent_mdp['num_states']} states, "
|
||||
f"{sum(len(t) for t in agent_mdp['transitions'].values())} transitions")
|
||||
if not agent_mdp['states']:
|
||||
exit("No states found")
|
||||
visualize_mdp(agent_model, threshold=0.05, output="agent_mdp_viz", fmt="pdf", export_dot=True)
|
||||
|
||||
human_evt = aggregate_event_transitions(human_mdp)
|
||||
agent_evt = aggregate_event_transitions(agent_mdp)
|
||||
common = set(human_evt.keys()) & set(agent_evt.keys())
|
||||
|
||||
if not common:
|
||||
exit("No common event types for KL divergence analysis")
|
||||
|
||||
kl_divs = sorted([(e, kl_divergence(human_evt[e], agent_evt[e])) for e in common],
|
||||
key=lambda x: x[1], reverse=True)
|
||||
|
||||
print(f"Average KL divergence: {np.mean([kl for _, kl in kl_divs]):.4f}")
|
||||
print("\nMost divergent event types:")
|
||||
for evt, kl in kl_divs:
|
||||
print(f" {evt}: {kl:.4f}")
|
||||
|
||||
print("\n=== Joint Model (Human + Agent Combined) ===")
|
||||
joint_model = JointBehaviorModel(human_dir, agent_dir)
|
||||
joint_mdp = joint_model.build_MDP()
|
||||
print(f"Built joint MDP: {joint_mdp['num_states']} states, "
|
||||
f"{sum(len(t) for t in joint_mdp['transitions'].values())} transitions")
|
||||
if joint_mdp['states']:
|
||||
visualize_mdp(joint_model, threshold=0.05, output="joint_mdp_viz", fmt="pdf", export_dot=True)
|
||||
228
sim/rl/engine.py
Normal file
228
sim/rl/engine.py
Normal file
@@ -0,0 +1,228 @@
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Any
|
||||
from sim.rl.environment import BusinessLogicConstraints
|
||||
|
||||
|
||||
"""
|
||||
An angine by default should have its own demand estimation mechanism from the observed observations whihc are the computer feature.
|
||||
From these features we then follow the researc hstructure of q -> p with a testable and must be updatable mechanism.
|
||||
"""
|
||||
|
||||
class BasePricingEngine(ABC):
|
||||
"""base interface for all pricing engines"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
self.c = constraints
|
||||
self.rng = np.random.default_rng(seed)
|
||||
self.step_count = 0
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
"""compute new prices given current state and observation from environment
|
||||
|
||||
args:
|
||||
current_prices: current price vector [N]
|
||||
observation: dict containing 'price', 'demand', and possibly interaction data
|
||||
|
||||
returns:
|
||||
new_prices: updated price vector [N]
|
||||
"""
|
||||
pass
|
||||
|
||||
def update(self, observation: Dict[str, Any], reward: float, done: bool, info: Dict[str, Any]) -> None:
|
||||
"""Default no-op update. Engines can override as needed."""
|
||||
self.last_observation = observation
|
||||
self.last_reward = reward
|
||||
self.last_info = info
|
||||
|
||||
|
||||
|
||||
def reset(self):
|
||||
"""reset engine state for new episode"""
|
||||
self.step_count = 0
|
||||
|
||||
|
||||
class WildPricingEngine(BasePricingEngine):
|
||||
"""production-like pricing using online elasticity estimation via EWMA regression"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
# per-product unit costs (unknown to customers; known to platform)
|
||||
self.unit_cost = self.rng.uniform(8.0, 40.0, size=self.c.product_catalogue_size).astype(np.float32)
|
||||
# online elasticity estimate (start moderately elastic)
|
||||
self.e_hat = np.full((self.c.product_catalogue_size,), -1.3, dtype=np.float32)
|
||||
# EWMA state for log-log regression
|
||||
self.mu_logp = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.mu_logq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.cov_pq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.var_p = np.ones(self.c.product_catalogue_size, dtype=np.float32)
|
||||
# knobs typical in production
|
||||
self.lr = 0.08
|
||||
self.ewma = 0.05
|
||||
self.eps_explore = 0.03
|
||||
self.explore_scale = 0.03
|
||||
|
||||
def _safe_elasticity(self, e: np.ndarray) -> np.ndarray:
|
||||
return np.clip(e, -5.0, -1.05)
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.e_hat = np.full((self.c.product_catalogue_size,), -1.3, dtype=np.float32)
|
||||
self.mu_logp = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.mu_logq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.cov_pq = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
self.var_p = np.ones(self.c.product_catalogue_size, dtype=np.float32)
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
# extract demand signal (from env observation) as proxy for sales
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catalogue_size, dtype=np.float32))
|
||||
return self._update_from_demand(current_prices, demand)
|
||||
|
||||
def _update_from_demand(self, prices: np.ndarray, sold: np.ndarray) -> np.ndarray:
|
||||
# log transforms (add 1 to handle zeros)
|
||||
logp = np.log(np.clip(prices, 1e-3, None)).astype(np.float32)
|
||||
logq = np.log(sold + 1.0).astype(np.float32)
|
||||
# EWMA moments for per-product regression: logq ≈ a + e*logp
|
||||
a = self.ewma
|
||||
dp = logp - self.mu_logp
|
||||
dq = logq - self.mu_logq
|
||||
self.mu_logp = (1 - a) * self.mu_logp + a * logp
|
||||
self.mu_logq = (1 - a) * self.mu_logq + a * logq
|
||||
self.cov_pq = (1 - a) * self.cov_pq + a * (dp * dq)
|
||||
self.var_p = (1 - a) * self.var_p + a * (dp * dp + 1e-6)
|
||||
e_new = self.cov_pq / (self.var_p + 1e-6)
|
||||
self.e_hat = self._safe_elasticity(0.9 * self.e_hat + 0.1 * e_new)
|
||||
# profit-optimal price for isoelastic demand (if e < -1)
|
||||
e = self.e_hat
|
||||
p_star = self.unit_cost * (e / (e + 1.0))
|
||||
# smooth toward p_star
|
||||
new_prices = (1 - self.lr) * prices + self.lr * p_star
|
||||
# exploration (small random perturbations)
|
||||
if self.rng.random() < self.eps_explore:
|
||||
noise = self.rng.normal(0.0, self.explore_scale, size=new_prices.shape).astype(np.float32)
|
||||
new_prices = new_prices * (1.0 + noise)
|
||||
# apply business guardrails (max change + bounds)
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = prices * ratio
|
||||
new_prices = np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
return new_prices
|
||||
|
||||
|
||||
class StaticPricingEngine(BasePricingEngine):
|
||||
"""baseline: fixed prices throughout episode"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.fixed_prices = None
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.fixed_prices = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.fixed_prices is None:
|
||||
self.fixed_prices = current_prices.copy()
|
||||
return self.fixed_prices.copy()
|
||||
|
||||
|
||||
class SimpleDemandEngine(BasePricingEngine):
|
||||
"""demand-driven pricing: increase price when demand rises, decrease when it falls"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.prev_demand = None
|
||||
self.lr = 0.05
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.prev_demand = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catalogue_size, dtype=np.float32))
|
||||
if self.prev_demand is None:
|
||||
self.prev_demand = demand.copy()
|
||||
return current_prices.copy()
|
||||
# simple rule: if demand increases, raise price; if decreases, lower price
|
||||
delta_d = demand - self.prev_demand
|
||||
price_adj = self.lr * np.sign(delta_d) * np.abs(delta_d) / (np.abs(self.prev_demand) + 1.0)
|
||||
new_prices = current_prices * (1.0 + price_adj)
|
||||
self.prev_demand = demand.copy()
|
||||
# apply constraints
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = current_prices * ratio
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
|
||||
|
||||
class RandomWalkEngine(BasePricingEngine):
|
||||
"""random walk pricing with mean reversion"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.target_price = None
|
||||
self.volatility = 0.02
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.target_price = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.target_price is None:
|
||||
self.target_price = current_prices.copy()
|
||||
# random walk with mean reversion toward target
|
||||
noise = self.rng.normal(0.0, self.volatility, size=current_prices.shape).astype(np.float32)
|
||||
reversion = 0.01 * (self.target_price - current_prices)
|
||||
new_prices = current_prices * (1.0 + noise) + reversion
|
||||
# apply constraints
|
||||
max_adj = self.c.max_price_adjustment
|
||||
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||
new_prices = current_prices * ratio
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
|
||||
|
||||
class ThompsonSamplingEngine(BasePricingEngine):
|
||||
"""bayesian bandit approach per product treating price as discrete action"""
|
||||
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||
super().__init__(constraints, seed)
|
||||
self.n_price_levels = 5
|
||||
self.alpha = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.beta = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.price_grid = None
|
||||
self.last_actions = None
|
||||
|
||||
def reset(self):
|
||||
super().reset()
|
||||
self.alpha = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.beta = np.ones((self.c.product_catalogue_size, self.n_price_levels), dtype=np.float32)
|
||||
self.price_grid = None
|
||||
self.last_actions = None
|
||||
|
||||
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||
self.step_count += 1
|
||||
if self.price_grid is None:
|
||||
# define price grid per product
|
||||
lo = current_prices * 0.7
|
||||
hi = current_prices * 1.3
|
||||
self.price_grid = np.linspace(lo, hi, self.n_price_levels).T
|
||||
demand = observation.get('demand', np.zeros(self.c.product_catalogue_size, dtype=np.float32))
|
||||
# update beliefs based on last action
|
||||
if self.last_actions is not None:
|
||||
for i in range(self.c.product_catalogue_size):
|
||||
a = self.last_actions[i]
|
||||
reward = demand[i]
|
||||
if reward > 0.5:
|
||||
self.alpha[i, a] += reward
|
||||
else:
|
||||
self.beta[i, a] += 1.0
|
||||
# thompson sampling: sample from posterior, pick best
|
||||
new_prices = np.zeros(self.c.product_catalogue_size, dtype=np.float32)
|
||||
actions = np.zeros(self.c.product_catalogue_size, dtype=int)
|
||||
for i in range(self.c.product_catalogue_size):
|
||||
theta = self.rng.beta(self.alpha[i], self.beta[i]).astype(np.float32)
|
||||
actions[i] = int(np.argmax(theta))
|
||||
new_prices[i] = self.price_grid[i, actions[i]]
|
||||
self.last_actions = actions
|
||||
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||
682
sim/rl/environment.py
Normal file
682
sim/rl/environment.py
Normal file
@@ -0,0 +1,682 @@
|
||||
import gymnasium as gym
|
||||
from gymnasium import spaces
|
||||
import numpy as np
|
||||
from dataclasses import dataclass
|
||||
import pandas as pd
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
|
||||
from lib.separability import load_artifacts, score_session, estimate_alpha
|
||||
from sim.rl.behavior_loader.models import AgentBehaviorModel, BehaviorModel, aggregate_event_transitions
|
||||
|
||||
try:
|
||||
import jax
|
||||
from sim.rl.jax_core import JAX_AVAILABLE, compile_transitions, fallback_transitions, sample_sessions, compute_metrics
|
||||
from sim.rl.jax_core import session_features, compute_session_transitions, compute_divergences, estimate_alpha_batch
|
||||
except ImportError:
|
||||
JAX_AVAILABLE = False
|
||||
|
||||
# "learner" agent learning to optimize pricing
|
||||
# "agent" part of environment creating demand signals that learner processes
|
||||
|
||||
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
|
||||
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
|
||||
@dataclass
|
||||
class BusinessLogicConstraints():
|
||||
max_price_adjustment: float = 0.30
|
||||
system_max_price: float = 500.0
|
||||
system_min_price: float = 1.0
|
||||
product_catalogue_size: int = 100
|
||||
episode_length: int = 2000
|
||||
sessions_per_step: int = 250
|
||||
agent_share: float = 0.2
|
||||
agent_recon_multiplier: float = 6.0
|
||||
agent_purchase_probability: float = 0.20
|
||||
coi_strength: float = 0.25
|
||||
coi_threshold: float = 4.0
|
||||
coi_sigmoid_temp: float = 1.25
|
||||
base_human_demand: float = 0.08
|
||||
base_agent_demand: float = 0.05
|
||||
human_price_elasticity: float = -1.2 # assumptions here
|
||||
agent_price_elasticity: float = -0.6
|
||||
w_agent_loss: float = 1.0
|
||||
w_volatility: float = 5.0
|
||||
w_estimation_error: float = 0.25
|
||||
seed: int = 7
|
||||
|
||||
|
||||
def _sigmoid(x: np.ndarray) -> np.ndarray:
|
||||
return 1.0 / (1.0 + np.exp(-x))
|
||||
|
||||
EVENT_PAGE_MAP = {
|
||||
"session_start": "/",
|
||||
"page_view": "/",
|
||||
"view_item_page": "/products",
|
||||
"learn_more_about_item": "/products/details",
|
||||
"add_item_to_cart": "/cart",
|
||||
"checkout_start": "/checkout",
|
||||
"purchase_complete": "/checkout",
|
||||
"session_end": "/checkout/success",
|
||||
}
|
||||
|
||||
# map real collected event names to canonical simulation states
|
||||
EVENT_CANONICAL_MAP = {
|
||||
"page_view": "session_start",
|
||||
"hover_over_paragraph": "view_item_page",
|
||||
"hover_over_title": "view_item_page",
|
||||
"view_item_page": "view_item_page",
|
||||
"learn_more_about_item": "learn_more_about_item",
|
||||
"add_item_to_cart": "add_item_to_cart",
|
||||
"checkout_start": "purchase_complete",
|
||||
"remove_item": "view_item_page",
|
||||
}
|
||||
|
||||
|
||||
def _canonicalize_transitions(raw_trans: Dict[str, Dict[str, float]]) -> Dict[str, Dict[str, float]]:
|
||||
"""Map real event transition names to canonical simulation states."""
|
||||
canonical: Dict[str, Dict[str, float]] = {}
|
||||
for src, dsts in raw_trans.items():
|
||||
src_canon = EVENT_CANONICAL_MAP.get(src, src)
|
||||
if src_canon not in canonical:
|
||||
canonical[src_canon] = {}
|
||||
for dst, prob in dsts.items():
|
||||
dst_canon = EVENT_CANONICAL_MAP.get(dst, dst)
|
||||
canonical[src_canon][dst_canon] = canonical[src_canon].get(dst_canon, 0.0) + prob
|
||||
# re-normalize after aggregation
|
||||
for src in canonical:
|
||||
total = sum(canonical[src].values())
|
||||
if total > 0:
|
||||
canonical[src] = {k: v / total for k, v in canonical[src].items()}
|
||||
return canonical
|
||||
|
||||
|
||||
class BehavioralProfile:
|
||||
"""Synthetic Markov profile used to generate interaction sessions.
|
||||
Uses aggregate_event_transitions from models.py to build transition kernels from real data."""
|
||||
|
||||
def __init__(self, actor: str, purchase_probs: np.ndarray):
|
||||
self.actor = actor
|
||||
self.purchase_probs = np.clip(purchase_probs, 0.0, 0.95)
|
||||
self.states = [
|
||||
"session_start",
|
||||
"view_item_page",
|
||||
"learn_more_about_item",
|
||||
"add_item_to_cart",
|
||||
"purchase_complete",
|
||||
"session_end",
|
||||
]
|
||||
model = AgentBehaviorModel(agent_dir) if actor == "agents" else BehaviorModel(human_dir)
|
||||
mdp = model.build_MDP()
|
||||
raw_trans = aggregate_event_transitions(mdp) if mdp.get("transitions") else {}
|
||||
self.transitions = _canonicalize_transitions(raw_trans) if raw_trans else self._fallback_transitions()
|
||||
self._ensure_terminal_states()
|
||||
self.dwell_params = self._extract_dwell_params(mdp)
|
||||
|
||||
def _ensure_terminal_states(self):
|
||||
# guarantee purchase_complete leads to session_end and session_start exists
|
||||
if "purchase_complete" not in self.transitions:
|
||||
self.transitions["purchase_complete"] = {"session_end": 1.0}
|
||||
elif "session_end" not in self.transitions.get("purchase_complete", {}):
|
||||
self.transitions["purchase_complete"]["session_end"] = 1.0
|
||||
total = sum(self.transitions["purchase_complete"].values())
|
||||
self.transitions["purchase_complete"] = {k: v/total for k, v in self.transitions["purchase_complete"].items()}
|
||||
if "session_start" not in self.transitions:
|
||||
self.transitions["session_start"] = {"view_item_page": 0.7, "learn_more_about_item": 0.2, "session_end": 0.1}
|
||||
|
||||
def _fallback_transitions(self) -> Dict[str, Dict[str, float]]:
|
||||
return {
|
||||
"session_start": {"view_item_page": 0.85, "session_end": 0.15},
|
||||
"view_item_page": {"learn_more_about_item": 0.4, "add_item_to_cart": 0.3, "view_item_page": 0.2, "session_end": 0.1},
|
||||
"learn_more_about_item": {"add_item_to_cart": 0.5, "view_item_page": 0.3, "session_end": 0.2},
|
||||
"add_item_to_cart": {"purchase_complete": 0.6, "view_item_page": 0.25, "session_end": 0.15},
|
||||
"purchase_complete": {"session_end": 1.0},
|
||||
}
|
||||
|
||||
def _extract_dwell_params(self, mdp: Dict) -> Dict[str, Tuple[float, float]]:
|
||||
state_vals = mdp.get("state_values", {})
|
||||
params = {}
|
||||
for state in self.states:
|
||||
# try canonical and raw state names
|
||||
val = state_vals.get(state, 0.5)
|
||||
for raw, canon in EVENT_CANONICAL_MAP.items():
|
||||
if canon == state and raw in state_vals:
|
||||
val = state_vals[raw]
|
||||
break
|
||||
shape = 1.5 + val * 2.0
|
||||
scale = 0.8 + (1.0 - val) * 1.2
|
||||
params[state] = (shape, scale)
|
||||
return params
|
||||
|
||||
def _transition_probs(self, state: str, product_idx: int) -> Dict[str, float]:
|
||||
probs = dict(self.transitions.get(state, {"session_end": 1.0}))
|
||||
if state == "add_item_to_cart":
|
||||
base = probs.get("purchase_complete", 0.0)
|
||||
demand_factor = float(self.purchase_probs[int(product_idx)])
|
||||
if self.actor == "agents":
|
||||
demand_factor *= 0.7
|
||||
adjusted = np.clip(base * 0.5 + demand_factor * 0.5, 0.0, 0.95)
|
||||
remainder = max(1e-6, 1.0 - adjusted)
|
||||
other_total = sum(v for k, v in probs.items() if k != "purchase_complete")
|
||||
scale = remainder / max(other_total, 1e-6)
|
||||
for key in probs:
|
||||
if key == "purchase_complete":
|
||||
probs[key] = adjusted
|
||||
else:
|
||||
probs[key] = probs[key] * scale
|
||||
total = sum(probs.values())
|
||||
if total <= 0:
|
||||
return {"session_end": 1.0}
|
||||
return {state: val / total for state, val in probs.items()}
|
||||
|
||||
def sample_session(
|
||||
self,
|
||||
rng: np.random.Generator,
|
||||
session_id: str,
|
||||
prices: np.ndarray,
|
||||
unit_cost: np.ndarray,
|
||||
) -> Tuple[List[Dict[str, Any]], List[SimpleNamespace]]:
|
||||
"""Generate a single session trajectory respecting business constraints."""
|
||||
events: List[Dict[str, Any]] = []
|
||||
feature_events: List[SimpleNamespace] = []
|
||||
state = "session_start"
|
||||
t = 0.0
|
||||
product_idx = int(rng.integers(0, len(prices)))
|
||||
product_id = f"product-{product_idx:04d}"
|
||||
|
||||
|
||||
# enforce price >= cost constraint (lipschitz bound on pricing)
|
||||
# This is a sort of last resort to not let an pricing learner go rogue
|
||||
cost = float(unit_cost[product_idx])
|
||||
constrained_price = max(float(prices[product_idx]), cost * 1.05) # 5% min margin
|
||||
|
||||
while state != "session_end" and len(events) < 40:
|
||||
if state != "session_start":
|
||||
row = {
|
||||
"session_id": session_id,
|
||||
"actor": "agent" if self.actor == "agents" else "human",
|
||||
"eventName": state,
|
||||
"product_idx": product_idx,
|
||||
"productId": product_id,
|
||||
"price_offered": constrained_price,
|
||||
"price_paid": 0.0,
|
||||
"page": EVENT_PAGE_MAP.get(state, "/"),
|
||||
"ts": t,
|
||||
"unit_cost": cost,
|
||||
"base_price": float(prices[product_idx]),
|
||||
}
|
||||
if state == "purchase_complete":
|
||||
noise = float(rng.normal(0.0, 0.015))
|
||||
row["price_paid"] = max(constrained_price * (1.0 + noise), cost)
|
||||
events.append(row)
|
||||
feature_events.append(
|
||||
SimpleNamespace(
|
||||
eventName=row["eventName"],
|
||||
page=row["page"],
|
||||
productId=row["productId"],
|
||||
ts=row["ts"],
|
||||
)
|
||||
)
|
||||
|
||||
transitions = self._transition_probs(state, product_idx)
|
||||
next_state = rng.choice(list(transitions.keys()), p=list(transitions.values()))
|
||||
shape, scale = self.dwell_params.get(state, (2.0, 1.0))
|
||||
dwell = max(0.3, rng.gamma(shape=shape, scale=scale))
|
||||
t += dwell
|
||||
state = next_state
|
||||
|
||||
return events, feature_events
|
||||
|
||||
|
||||
def _load_behavioral_profile(actor: str, demand_forcing: np.ndarray) -> BehavioralProfile:
|
||||
"""returns a behavioral profile for generating synthetic sessions
|
||||
actor: 'humans' or 'agents'
|
||||
demand_forcing: per-product purchase probabilities used to weight interactions
|
||||
"""
|
||||
return BehavioralProfile(actor, demand_forcing)
|
||||
|
||||
|
||||
class CommercePlatform:
|
||||
"""state management for the environment, simulates demand"""
|
||||
def __init__(self, product_catalogue_size: int, max_price: float, min_price: float, constraints: BusinessLogicConstraints):
|
||||
self.product_catalogue_size = product_catalogue_size
|
||||
self.max_price = max_price
|
||||
self.min_price = min_price
|
||||
self.constraints = constraints
|
||||
self.simulation_history: List[Dict[str, Any]] = []
|
||||
self._rng = np.random.default_rng(constraints.seed)
|
||||
self._last_interaction_df: pd.DataFrame = pd.DataFrame()
|
||||
self.unit_cost = np.random.uniform(low=15.0, high=60.0, size=(self.product_catalogue_size,)).astype(np.float32)
|
||||
self.base_price = np.random.uniform(low=60.0, high=140.0, size=(self.product_catalogue_size,)).astype(np.float32)
|
||||
self.alpha_hat = constraints.agent_share
|
||||
try:
|
||||
self.separability_artifacts = load_artifacts()
|
||||
except FileNotFoundError:
|
||||
self.separability_artifacts = None
|
||||
|
||||
def setup_true_demand(self, prices: np.ndarray) -> Dict[str, np.ndarray]:
|
||||
p = np.clip(prices, self.min_price, self.max_price)
|
||||
cost = np.clip(self.unit_cost, self.min_price * 0.2, self.max_price)
|
||||
margin = np.clip((p - cost) / np.maximum(cost, 1e-3), -0.9, 2.0)
|
||||
# isoelastic demand approximation
|
||||
human_prob = self.constraints.base_human_demand * np.exp(self.constraints.human_price_elasticity * margin)
|
||||
agent_prob = self.constraints.base_agent_demand * np.exp(self.constraints.agent_price_elasticity * margin)
|
||||
return {
|
||||
"human_purchase_prob": np.clip(human_prob, 0.0, 0.95),
|
||||
"agent_purchase_prob": np.clip(agent_prob, 0.0, 0.95),
|
||||
}
|
||||
|
||||
def _simulate_sessions(self, prices: np.ndarray) -> Tuple[pd.DataFrame, Dict[str, Any]]:
|
||||
demand = self.setup_true_demand(prices)
|
||||
T = self.constraints.sessions_per_step
|
||||
effective_share = float(np.clip(self.alpha_hat, 0.0, 0.95))
|
||||
n_agent_sessions = max(1, int(round(T * effective_share)))
|
||||
n_human_sessions = max(1, T - n_agent_sessions)
|
||||
|
||||
session_map = {
|
||||
"humans": n_human_sessions,
|
||||
"agents": n_agent_sessions,
|
||||
}
|
||||
pprob_map = {
|
||||
"humans": demand["human_purchase_prob"],
|
||||
"agents": demand["agent_purchase_prob"],
|
||||
}
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
session_scores: List[Dict[str, float]] = []
|
||||
demand_human = np.zeros_like(prices, dtype=np.float32)
|
||||
demand_agent = np.zeros_like(prices, dtype=np.float32)
|
||||
|
||||
for actor, n_sessions in session_map.items():
|
||||
profile = _load_behavioral_profile(actor, pprob_map[actor])
|
||||
for idx in range(n_sessions):
|
||||
session_id = f"{actor}_{idx:06d}"
|
||||
session_rows, feature_events = profile.sample_session(
|
||||
self._rng, session_id, prices, self.unit_cost
|
||||
)
|
||||
rows.extend(session_rows)
|
||||
if session_rows:
|
||||
df_session = pd.DataFrame(session_rows)
|
||||
purchases = df_session[df_session["eventName"] == "purchase_complete"]
|
||||
if not purchases.empty:
|
||||
counts = purchases.groupby("product_idx").size()
|
||||
if actor == "agents":
|
||||
demand_agent[counts.index.to_numpy(dtype=int)] += counts.to_numpy(dtype=np.float32)
|
||||
else:
|
||||
demand_human[counts.index.to_numpy(dtype=int)] += counts.to_numpy(dtype=np.float32)
|
||||
if self.separability_artifacts and feature_events:
|
||||
score = score_session(feature_events, self.separability_artifacts)
|
||||
session_scores.append(score)
|
||||
|
||||
interactions_df = pd.DataFrame(rows)
|
||||
diagnostics = {
|
||||
"alpha_hat": float(self.alpha_hat),
|
||||
"session_scores": session_scores,
|
||||
"demand_human": demand_human,
|
||||
"demand_agent": demand_agent,
|
||||
}
|
||||
|
||||
if session_scores:
|
||||
alphas = [
|
||||
estimate_alpha(s["prob_agent"], s["delta_h"], s["delta_a"], temperature=2.0)
|
||||
for s in session_scores
|
||||
]
|
||||
mean_alpha = float(np.mean(alphas))
|
||||
# exponential moving average for stability
|
||||
self.alpha_hat = 0.7 * self.alpha_hat + 0.3 * mean_alpha
|
||||
diagnostics.update(
|
||||
{
|
||||
"alpha_hat": float(self.alpha_hat),
|
||||
"delta_h_mean": float(np.mean([s["delta_h"] for s in session_scores])),
|
||||
"delta_a_mean": float(np.mean([s["delta_a"] for s in session_scores])),
|
||||
"prob_agent_mean": float(np.mean([s["prob_agent"] for s in session_scores])),
|
||||
}
|
||||
)
|
||||
|
||||
self._last_interaction_df = interactions_df
|
||||
return interactions_df, diagnostics
|
||||
|
||||
def compute_interaction_features(self, interaction_df: pd.DataFrame) -> Dict[str, float]:
|
||||
if interaction_df.empty:
|
||||
return {
|
||||
"revenue_observed": 0.0,
|
||||
"revenue_oracle": 0.0,
|
||||
"agent_loss": 0.0,
|
||||
"true_human_purchases": 0.0,
|
||||
"true_agent_purchases": 0.0,
|
||||
"mean_sale_price": 0.0,
|
||||
"look_to_book": 0.0,
|
||||
"coi": 0.0,
|
||||
"expected_premium": 0.0,
|
||||
}
|
||||
|
||||
purchases = interaction_df[interaction_df["eventName"] == "purchase_complete"]
|
||||
human_purchases = purchases[purchases["actor"] == "human"]
|
||||
agent_purchases = purchases[purchases["actor"] == "agent"]
|
||||
|
||||
revenue_observed = float(purchases["price_paid"].sum())
|
||||
revenue_oracle = float(purchases["base_price"].sum())
|
||||
agent_loss = float((agent_purchases["base_price"] - agent_purchases["price_paid"]).sum())
|
||||
|
||||
mean_sale_price = float(purchases["price_paid"].mean()) if not purchases.empty else 0.0
|
||||
views = float((interaction_df["eventName"] == "view_item_page").sum())
|
||||
look_to_book = float(views / (len(purchases) + 1e-6))
|
||||
true_human = float(len(human_purchases))
|
||||
true_agent = float(len(agent_purchases))
|
||||
|
||||
human_prices = human_purchases["price_offered"] if not human_purchases.empty else pd.Series(dtype=float)
|
||||
human_costs = human_purchases["unit_cost"] if not human_purchases.empty else pd.Series(dtype=float)
|
||||
human_base = human_purchases["base_price"] if not human_purchases.empty else pd.Series(dtype=float)
|
||||
coi = 0.0
|
||||
if not human_prices.empty and not human_costs.empty:
|
||||
# COI = E[P] - p_min where p_min is cost, accounting for expected premium (base - realized)
|
||||
margin = human_prices.mean() - human_costs.mean()
|
||||
expected_premium = human_base.mean() - human_prices.mean() if not human_base.empty else 0.0
|
||||
coi = float(np.maximum(0.0, margin - expected_premium * 0.5))
|
||||
|
||||
return {
|
||||
"revenue_observed": revenue_observed,
|
||||
"revenue_oracle": revenue_oracle,
|
||||
"agent_loss": agent_loss,
|
||||
"true_human_purchases": true_human,
|
||||
"true_agent_purchases": true_agent,
|
||||
"mean_sale_price": mean_sale_price,
|
||||
"look_to_book": look_to_book,
|
||||
"coi": coi,
|
||||
"expected_premium": float(expected_premium) if not human_base.empty else 0.0,
|
||||
}
|
||||
|
||||
def _session_feature_table(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Extract per-session behavioral features for separability analysis."""
|
||||
if df.empty:
|
||||
return pd.DataFrame()
|
||||
g = df.groupby("session_id", sort=False)
|
||||
session_duration = g["ts"].max() - g["ts"].min()
|
||||
total_interactions = g.size()
|
||||
avg_time_between = g["ts"].apply(lambda x: float(np.diff(np.sort(x.to_numpy())).mean()) if len(x) > 1 else 0.0)
|
||||
interaction_velocity = total_interactions / (session_duration + 1e-6)
|
||||
views = g.apply(lambda x: int((x["eventName"] == "view_item_page").sum()), include_groups=False)
|
||||
cart_adds = g.apply(lambda x: int((x["eventName"] == "add_item_to_cart").sum()), include_groups=False)
|
||||
purchases = g.apply(lambda x: int((x["eventName"] == "purchase_complete").sum()), include_groups=False)
|
||||
learn_more = g.apply(lambda x: int((x["eventName"] == "learn_more_about_item").sum()), include_groups=False)
|
||||
conversion_rate = purchases / (views + 1e-6)
|
||||
is_agent = g["actor"].apply(lambda s: bool((s == "agent").any()), include_groups=False)
|
||||
# price sensitivity features
|
||||
price_variance = g["price_offered"].var().fillna(0.0)
|
||||
avg_price_seen = g["price_offered"].mean().fillna(0.0)
|
||||
products_viewed = g["product_idx"].nunique()
|
||||
|
||||
return pd.DataFrame({
|
||||
"session_duration_sec": session_duration.astype(float),
|
||||
"avg_time_between_events": avg_time_between.astype(float),
|
||||
"total_interactions": total_interactions.astype(int),
|
||||
"interaction_velocity": interaction_velocity.astype(float),
|
||||
"item_views": views.astype(int),
|
||||
"cart_adds": cart_adds.astype(int),
|
||||
"purchases": purchases.astype(int),
|
||||
"learn_more_clicks": learn_more.astype(int),
|
||||
"conversion_rate": conversion_rate.astype(float),
|
||||
"price_variance": price_variance.astype(float),
|
||||
"avg_price_seen": avg_price_seen.astype(float),
|
||||
"products_viewed": products_viewed.astype(int),
|
||||
"is_agent": is_agent.astype(bool),
|
||||
}).reset_index()
|
||||
|
||||
def get_interaction_data(self) -> np.ndarray:
|
||||
if self._last_interaction_df.empty:
|
||||
return np.array([], dtype=object)
|
||||
return self._last_interaction_df.to_dict(orient="records")
|
||||
|
||||
|
||||
class PHANTOMEnv(gym.Env):
|
||||
metadata = {"render_modes": []}
|
||||
|
||||
def __init__(self, constraints: Optional[BusinessLogicConstraints] = None, use_jax: bool = True):
|
||||
super().__init__()
|
||||
self.constraints = constraints if isinstance(constraints, BusinessLogicConstraints) else BusinessLogicConstraints()
|
||||
self.use_jax = use_jax and JAX_AVAILABLE
|
||||
self.action_space = spaces.Box(low=-self.constraints.max_price_adjustment,
|
||||
high=self.constraints.max_price_adjustment,
|
||||
shape=(self.constraints.product_catalogue_size,), dtype=np.float32)
|
||||
n_products = self.constraints.product_catalogue_size
|
||||
self.observation_space = spaces.Dict({
|
||||
"elasticity": spaces.Dict({
|
||||
"price": spaces.Box(
|
||||
low=np.full((n_products,), self.constraints.system_min_price, dtype=np.float32),
|
||||
high=np.full((n_products,), self.constraints.system_max_price, dtype=np.float32),
|
||||
dtype=np.float32),
|
||||
"demand": spaces.Box(
|
||||
low=np.zeros((n_products,), dtype=np.float32),
|
||||
high=np.full((n_products,), 1e6, dtype=np.float32),
|
||||
dtype=np.float32),
|
||||
}),
|
||||
"market": spaces.Dict({
|
||||
"alpha_hat": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
|
||||
"revenue_rate": spaces.Box(low=0.0, high=1e6, shape=(1,), dtype=np.float32),
|
||||
"conversion_rate": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
|
||||
"price_volatility": spaces.Box(low=0.0, high=1.0, shape=(1,), dtype=np.float32),
|
||||
}),
|
||||
"cost": spaces.Box(low=0.0, high=self.constraints.system_max_price, shape=(n_products,), dtype=np.float32),
|
||||
})
|
||||
self.commerce_platform = CommercePlatform(
|
||||
product_catalogue_size=self.constraints.product_catalogue_size,
|
||||
max_price=self.constraints.system_max_price,
|
||||
min_price=self.constraints.system_min_price,
|
||||
constraints=self.constraints)
|
||||
self._rng = np.random.default_rng(self.constraints.seed)
|
||||
self.t = 0
|
||||
self._prev_prices: Optional[np.ndarray] = None
|
||||
self.state: Dict[str, Any] = {}
|
||||
self._jax_key = None
|
||||
self._jax_trans = None
|
||||
if self.use_jax:
|
||||
self._jax_key = jax.random.PRNGKey(self.constraints.seed)
|
||||
self._init_jax_transitions()
|
||||
|
||||
def _init_jax_transitions(self):
|
||||
try:
|
||||
human_profile = _load_behavioral_profile("humans", np.ones(self.constraints.product_catalogue_size) * 0.1)
|
||||
agent_profile = _load_behavioral_profile("agents", np.ones(self.constraints.product_catalogue_size) * 0.1)
|
||||
self._jax_trans = compile_transitions(human_profile, agent_profile).to_jax()
|
||||
except Exception:
|
||||
self._jax_trans = fallback_transitions().to_jax()
|
||||
|
||||
def reset(self, seed: Optional[int] = None, options: Optional[dict] = None):
|
||||
super().reset(seed=seed)
|
||||
if seed is not None:
|
||||
self._rng = np.random.default_rng(seed)
|
||||
self.commerce_platform._rng = np.random.default_rng(seed)
|
||||
if self.use_jax:
|
||||
self._jax_key = jax.random.PRNGKey(seed)
|
||||
self.commerce_platform.alpha_hat = self.constraints.agent_share
|
||||
self.t = 0
|
||||
init_prices = self._rng.uniform(
|
||||
low=60.0,
|
||||
high=140.0,
|
||||
size=(self.constraints.product_catalogue_size,),
|
||||
).astype(np.float32)
|
||||
self.commerce_platform.unit_cost = self._rng.uniform(
|
||||
low=15.0,
|
||||
high=60.0,
|
||||
size=(self.constraints.product_catalogue_size,),
|
||||
).astype(np.float32)
|
||||
self.commerce_platform.base_price = init_prices.copy()
|
||||
self._prev_prices = init_prices.copy()
|
||||
self.state = {
|
||||
"elasticity": {
|
||||
"price": init_prices,
|
||||
"demand": np.zeros((self.constraints.product_catalogue_size,), dtype=np.float32),
|
||||
},
|
||||
"market": {
|
||||
"alpha_hat": np.array([self.constraints.agent_share], dtype=np.float32),
|
||||
"revenue_rate": np.array([0.0], dtype=np.float32),
|
||||
"conversion_rate": np.array([0.0], dtype=np.float32),
|
||||
"price_volatility": np.array([0.0], dtype=np.float32),
|
||||
},
|
||||
"cost": self.commerce_platform.unit_cost.astype(np.float32),
|
||||
}
|
||||
return self.state, {}
|
||||
|
||||
def _step_jax(self, new_prices: np.ndarray) -> Tuple[Dict, Dict]:
|
||||
self._jax_key, subkey = jax.random.split(self._jax_key)
|
||||
alpha = float(np.clip(self.commerce_platform.alpha_hat, 0.0, 0.95))
|
||||
n_agent = max(1, int(self.constraints.sessions_per_step * alpha))
|
||||
n_human = max(1, self.constraints.sessions_per_step - n_agent)
|
||||
batch = sample_sessions(subkey, self._jax_trans, n_human, n_agent, len(new_prices))
|
||||
sim = compute_metrics(batch, new_prices, self.commerce_platform.unit_cost, self.commerce_platform.base_price)
|
||||
result = {"revenue_observed": sim.revenue, "revenue_oracle": sim.revenue_oracle,
|
||||
"agent_loss": sim.agent_loss, "coi": sim.coi, "look_to_book": sim.look_to_book,
|
||||
"mean_sale_price": sim.mean_sale_price, "true_human_purchases": sim.n_human_purchases,
|
||||
"true_agent_purchases": sim.n_agent_purchases}
|
||||
diagnostics = {"demand_human": sim.demand_human, "demand_agent": sim.demand_agent, "alpha_hat": alpha}
|
||||
return result, diagnostics
|
||||
|
||||
def step(self, action: np.ndarray):
|
||||
self.t += 1
|
||||
base_prices = self.state["elasticity"]["price"].astype(np.float32)
|
||||
new_prices = np.clip(base_prices * (1.0 + action.astype(np.float32)),
|
||||
self.constraints.system_min_price,
|
||||
self.constraints.system_max_price).astype(np.float32)
|
||||
|
||||
self.state["elasticity"]["price"] = new_prices
|
||||
if self.use_jax:
|
||||
result, diagnostics = self._step_jax(new_prices)
|
||||
else:
|
||||
interactions_df, diagnostics = self.commerce_platform._simulate_sessions(new_prices)
|
||||
result = self.commerce_platform.compute_interaction_features(interactions_df)
|
||||
COI = float(result.get("coi", 0.0))
|
||||
|
||||
demand_vector = diagnostics.get("demand_human", np.zeros_like(new_prices)) + diagnostics.get(
|
||||
"demand_agent", np.zeros_like(new_prices)
|
||||
)
|
||||
self.state["elasticity"]["demand"] = demand_vector.astype(np.float32)
|
||||
|
||||
volatility = 0.0 if self._prev_prices is None else \
|
||||
float(np.mean(np.abs((new_prices - self._prev_prices) / (self._prev_prices + 1e-6))))
|
||||
self._prev_prices = new_prices.copy()
|
||||
|
||||
# update market observation features
|
||||
total_demand = float(np.sum(demand_vector))
|
||||
total_purchases = float(result.get("true_human_purchases", 0.0) + result.get("true_agent_purchases", 0.0))
|
||||
conv_rate = total_purchases / max(total_demand, 1.0)
|
||||
self.state["market"] = {
|
||||
"alpha_hat": np.array([float(diagnostics.get("alpha_hat", self.commerce_platform.alpha_hat))], dtype=np.float32),
|
||||
"revenue_rate": np.array([float(result.get("revenue_observed", 0.0))], dtype=np.float32),
|
||||
"conversion_rate": np.array([float(np.clip(conv_rate, 0.0, 1.0))], dtype=np.float32),
|
||||
"price_volatility": np.array([float(volatility)], dtype=np.float32),
|
||||
}
|
||||
self.state["cost"] = self.commerce_platform.unit_cost.astype(np.float32)
|
||||
|
||||
# extract metrics with safe defaults for incomplete simulation
|
||||
revenue_observed = float(result.get("revenue_observed", 0.0))
|
||||
agent_loss = float(result.get("agent_loss", 0.0))
|
||||
|
||||
reward = (revenue_observed
|
||||
- COI
|
||||
- self.constraints.w_agent_loss * agent_loss
|
||||
- self.constraints.w_volatility * volatility
|
||||
- self.constraints.w_estimation_error)
|
||||
|
||||
terminated = self.t >= self.constraints.episode_length
|
||||
info = {
|
||||
"t": self.t,
|
||||
"revenue_observed": revenue_observed,
|
||||
"revenue_oracle": float(result.get("revenue_oracle", revenue_observed)),
|
||||
"agent_loss": agent_loss,
|
||||
"ux_volatility": volatility,
|
||||
"look_to_book": float(result.get("look_to_book", 0.0)),
|
||||
"mean_sale_price": float(result.get("mean_sale_price", 0.0)),
|
||||
"true_human_purchases_total": float(result.get("true_human_purchases", 0.0)),
|
||||
"true_agent_purchases_total": float(result.get("true_agent_purchases", 0.0)),
|
||||
"coi": COI,
|
||||
"alpha_hat": diagnostics.get("alpha_hat", self.commerce_platform.alpha_hat),
|
||||
"mean_human_demand": float(np.mean(diagnostics.get("demand_human", np.zeros_like(new_prices)))),
|
||||
"mean_agent_demand": float(np.mean(diagnostics.get("demand_agent", np.zeros_like(new_prices)))),
|
||||
}
|
||||
if "delta_h_mean" in diagnostics:
|
||||
info.update(
|
||||
{
|
||||
"delta_h_mean": diagnostics["delta_h_mean"],
|
||||
"delta_a_mean": diagnostics["delta_a_mean"],
|
||||
"prob_agent_mean": diagnostics["prob_agent_mean"],
|
||||
}
|
||||
)
|
||||
return self.state, float(reward), terminated, False, info
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import matplotlib.pyplot as plt
|
||||
from collections import defaultdict
|
||||
|
||||
env = PHANTOMEnv(constraints=BusinessLogicConstraints())
|
||||
obs, _ = env.reset(seed=42)
|
||||
metrics = defaultdict(list)
|
||||
total_reward = 0.0
|
||||
done = False
|
||||
|
||||
while not done:
|
||||
action = env.action_space.sample()
|
||||
obs, reward, done, _, info = env.step(action)
|
||||
total_reward += reward
|
||||
p_mean = float(np.mean(obs["elasticity"]["price"]))
|
||||
q_mean = float(np.mean(obs["elasticity"]["demand"]))
|
||||
p_std = float(np.std(obs["elasticity"]["price"]))
|
||||
|
||||
metrics['t'].append(info['t'])
|
||||
metrics['price_mean'].append(p_mean)
|
||||
metrics['price_std'].append(p_std)
|
||||
metrics['demand_mean'].append(q_mean)
|
||||
metrics['revenue_observed'].append(info['revenue_observed'])
|
||||
metrics['revenue_oracle'].append(info['revenue_oracle'])
|
||||
metrics['agent_loss'].append(info['agent_loss'])
|
||||
metrics['ux_volatility'].append(info['ux_volatility'])
|
||||
metrics['look_to_book'].append(info['look_to_book'])
|
||||
metrics['reward'].append(reward)
|
||||
metrics['human_purchases'].append(info['true_human_purchases_total'])
|
||||
metrics['agent_purchases'].append(info['true_agent_purchases_total'])
|
||||
metrics['coi'].append(info.get('coi', 0.0))
|
||||
metrics['alpha_hat'].append(info.get('alpha_hat', env.commerce_platform.alpha_hat))
|
||||
metrics['mean_human_demand'].append(info.get('mean_human_demand', 0.0))
|
||||
metrics['mean_agent_demand'].append(info.get('mean_agent_demand', 0.0))
|
||||
metrics['delta_h_mean'].append(info.get('delta_h_mean', 0.0))
|
||||
metrics['delta_a_mean'].append(info.get('delta_a_mean', 0.0))
|
||||
metrics['prob_agent_mean'].append(info.get('prob_agent_mean', 0.0))
|
||||
|
||||
if info['t'] % 20 == 0 or done:
|
||||
print(f"t={info['t']:03d} p={p_mean:6.2f}±{p_std:4.2f} q={q_mean:6.2f} "
|
||||
f"rev={info['revenue_observed']:7.2f} oracle={info['revenue_oracle']:7.2f} "
|
||||
f"loss={info['agent_loss']:6.2f} ux={info['ux_volatility']:.3f} "
|
||||
f"coi={info.get('coi', 0.0):6.2f} alpha={info.get('alpha_hat', 0.0):4.2f} "
|
||||
f"ltb={info['look_to_book']:5.2f} r={reward:7.2f}")
|
||||
|
||||
print(f"total_reward={total_reward:.2f}")
|
||||
|
||||
fig, axes = plt.subplots(3, 4, figsize=(18, 12))
|
||||
fig.suptitle('PHANTOM Environment Run', fontsize=14, fontweight='bold')
|
||||
|
||||
plot_configs = [
|
||||
('price_mean', 'Mean Price', 'Price'),
|
||||
('demand_mean', 'Mean Demand (All)', 'Demand'),
|
||||
('mean_human_demand', 'Mean Human Demand', 'Count'),
|
||||
('mean_agent_demand', 'Mean Agent Demand', 'Count'),
|
||||
('revenue_observed', 'Revenue (Observed)', 'Revenue'),
|
||||
('agent_loss', 'Agent Loss (Oracle - Observed)', 'Loss'),
|
||||
('coi', 'Cost of Information', 'COI'),
|
||||
('alpha_hat', 'Estimated α̂', 'alpha'),
|
||||
('ux_volatility', 'UX Volatility (Price Change)', 'Volatility'),
|
||||
('look_to_book', 'Look-to-Book Ratio', 'Ratio'),
|
||||
('reward', 'Step Reward', 'Reward'),
|
||||
('prob_agent_mean', 'Avg Agent Probability', 'Probability'),
|
||||
]
|
||||
|
||||
for idx, (key, title, ylabel) in enumerate(plot_configs):
|
||||
ax = axes[idx // 4, idx % 4]
|
||||
ax.plot(metrics['t'], metrics[key], color='blue', alpha=0.7, linewidth=1.5)
|
||||
ax.set_xlabel('Step')
|
||||
ax.set_ylabel(ylabel)
|
||||
ax.set_title(title, fontsize=10, fontweight='bold')
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig('phantom_env_comparison.png', dpi=150, bbox_inches='tight')
|
||||
print("Plot saved to phantom_env_comparison.png")
|
||||
plt.show()
|
||||
11
sim/rl/jax_core/__init__.py
Normal file
11
sim/rl/jax_core/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""JAX-accelerated simulation core for PHANTOM environment."""
|
||||
from .transitions import TransitionData, compile_transitions, fallback_transitions, JAX_AVAILABLE
|
||||
from .simulation import SessionBatch, SimResult, sample_sessions, compute_metrics
|
||||
from .features import session_features, compute_session_transitions
|
||||
from .separability import compute_divergences, estimate_alpha_batch
|
||||
|
||||
__all__ = [
|
||||
"JAX_AVAILABLE", "TransitionData", "compile_transitions", "fallback_transitions",
|
||||
"SessionBatch", "SimResult", "sample_sessions", "compute_metrics",
|
||||
"session_features", "compute_session_transitions", "compute_divergences", "estimate_alpha_batch",
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user