Compare commits
86 Commits
baseline-s
...
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 | ||
|
|
c432c45343 | ||
|
|
8b76d24ade | ||
|
|
894ce87a5d | ||
|
|
ab8b8787a8 | ||
| 9bb6f842f4 | |||
| 53a39b07dd | |||
| 4acfb019f8 | |||
|
|
37b2099ee0 | ||
|
|
7ece6e82cb | ||
| 6b7060450c | |||
|
|
f6e780fdf1 | ||
|
|
f427943ebc | ||
| aa98b2d169 | |||
| 5777437540 | |||
| e677170fd2 | |||
|
|
811c4334e1 | ||
|
|
e02be30748 |
21
.env.example
@@ -1,5 +1,18 @@
|
|||||||
HOSTNAME=localhost
|
# Network configuration
|
||||||
|
HOSTNAME=localhost # hostname for service discovery across docker network
|
||||||
|
|
||||||
# PORTS
|
# Application configuration
|
||||||
KAFKA_PORT=9092
|
STORE_MODE=hotel # platform mode: 'hotel' or 'airline' - determines product catalog and UI theme
|
||||||
REDIS_PORT=6377
|
NEXT_PUBLIC_API_BASE=http://localhost:3000 # base URL for API endpoints, must be valid URL format
|
||||||
|
NEXT_PUBLIC_APP_ENV=dev # application environment: 'dev' or 'prod' - controls logging, error handling
|
||||||
|
NEXT_PUBLIC_HOVER_THRESHOLD=1200 # hover threshold in milliseconds for UI interactions
|
||||||
|
|
||||||
|
# Backend service
|
||||||
|
BACKEND_URL=http://localhost:5000 # backend API URL for kafka ingestion (set to railway service URL in prod)
|
||||||
|
|
||||||
|
# Service ports - used by docker-compose and service communication
|
||||||
|
BACKEND_PORT=5000 # backend server port for kafka ingestion API
|
||||||
|
KAFKA_HOST=localhost # kafka broker hostname - set to remote host in prod (e.g., kafka.example.com)
|
||||||
|
KAFKA_PORT=9092 # kafka broker port for event streaming
|
||||||
|
REDIS_PORT=6377 # redis port for worker queue and caching
|
||||||
|
REDPANDA_CONSOLE_PORT=8084 # redpanda console UI port for kafka monitoring
|
||||||
|
|||||||
30
.github/workflows/pytest.yml
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
name: Run Tests
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'experiments/**'
|
||||||
|
- 'backend/**'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- '.github/workflows/pytest.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'experiments/**'
|
||||||
|
- 'backend/**'
|
||||||
|
- 'requirements.txt'
|
||||||
|
- '.github/workflows/pytest.yml'
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.13'
|
||||||
|
cache: 'pip'
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m venv .venv
|
||||||
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
- name: Run tests
|
||||||
|
run: .venv/bin/pytest -v
|
||||||
22
.gitignore
vendored
@@ -1,2 +1,24 @@
|
|||||||
**/.env
|
**/.env
|
||||||
**/.venv
|
**/.venv
|
||||||
|
**/__pycache__
|
||||||
|
**/.ipynb_checkpoints/
|
||||||
|
**/.virtual_documents/
|
||||||
|
**/session_*.svg
|
||||||
|
**/*graph.svg
|
||||||
|
**/auto/*.el
|
||||||
|
*.old
|
||||||
|
**/package-lock.json
|
||||||
|
**/*.parquet
|
||||||
|
**/_build/
|
||||||
|
|
||||||
|
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/**
|
||||||
63
Makefile
@@ -4,36 +4,81 @@ BUILDDIR := build
|
|||||||
TEX := main.tex
|
TEX := main.tex
|
||||||
JOBNAME := main
|
JOBNAME := main
|
||||||
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
PDF := paper/$(BUILDDIR)/$(JOBNAME).pdf
|
||||||
|
VENV := .venv
|
||||||
|
PYTHON := $(VENV)/bin/python
|
||||||
|
PIP := $(VENV)/bin/pip
|
||||||
|
PYTEST := $(VENV)/bin/pytest
|
||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
all: pdf
|
.PHONY: help
|
||||||
|
help:
|
||||||
run.webapp:
|
@echo "pdf.build pdf.watch pdf.clean | test.backend test.e2e test.all | web.dev | install | stats.lines"
|
||||||
@cd web && npm install && npm run dev
|
|
||||||
|
|
||||||
$(BUILDDIR):
|
$(BUILDDIR):
|
||||||
mkdir -p paper/$(BUILDDIR)
|
mkdir -p paper/$(BUILDDIR)
|
||||||
|
|
||||||
pdf: $(BUILDDIR)
|
.PHONY: pdf.build
|
||||||
@echo "Concatenating source code..."
|
pdf.build: $(BUILDDIR)
|
||||||
@bash paper/concat_code.sh
|
@bash paper/concat_code.sh
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
$(LATEXMK) -pdf -jobname=$(JOBNAME) \
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
watch: $(BUILDDIR)
|
.PHONY: pdf.watch
|
||||||
|
pdf.watch: $(BUILDDIR)
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
clean:
|
.PHONY: pdf.clean
|
||||||
|
pdf.clean:
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
rm -rf paper/$(BUILDDIR)/*
|
||||||
|
|
||||||
|
.PHONY: test.backend
|
||||||
|
test.backend: $(VENV)
|
||||||
|
$(PYTEST) -v
|
||||||
|
|
||||||
.PHONY: all pdf clean watch run.webapp
|
.PHONY: test.e2e
|
||||||
|
test.e2e:
|
||||||
|
@cd tests/e2e && npm install
|
||||||
|
@cd tests/e2e && npx playwright install chromium
|
||||||
|
@test -f tests/e2e/.env || cp tests/e2e/.env.example tests/e2e/.env
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:5000/health > /dev/null 2>&1; do sleep 1; done' || (echo "Backend not ready" && exit 1)
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:3000 > /dev/null 2>&1; do sleep 1; done' || (echo "Web app not ready" && exit 1)
|
||||||
|
@timeout 30 bash -c 'until curl -sf http://localhost:8085/health > /dev/null 2>&1; do sleep 1; done' || (echo "Airflow not ready" && exit 1)
|
||||||
|
@cd tests/e2e && npm test
|
||||||
|
|
||||||
|
.PHONY: test.all
|
||||||
|
test.all: test.backend test.e2e
|
||||||
|
|
||||||
|
.PHONY: web.dev
|
||||||
|
web.dev:
|
||||||
|
@cd web && npm install && npm run dev
|
||||||
|
|
||||||
|
$(VENV):
|
||||||
|
python3 -m venv $(VENV)
|
||||||
|
$(PIP) install --upgrade pip
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: $(VENV)
|
||||||
|
$(PIP) install -r requirements.txt
|
||||||
|
|
||||||
|
.PHONY: 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: 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
@@ -1 +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://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
[](https://sites.research.google/trc/faq/)
|
||||||
|
[](https://phantom-hotel.vercel.app)
|
||||||
|
[](https://phantom-airline.vercel.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
112
backend/provider/app.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Literal, Optional
|
||||||
|
import uvicorn, os, sys
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Local imports of registry and pricing function
|
||||||
|
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../experiments/")
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
from procesing.steps import (
|
||||||
|
PredictPricesStep
|
||||||
|
)
|
||||||
|
from procesing import PipelineContext
|
||||||
|
sys.path.append(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
print(os.path.dirname(os.path.abspath(__file__))+ "/../../lib/")
|
||||||
|
from lib.model_registry import ModelRegistry
|
||||||
|
|
||||||
|
# Config
|
||||||
|
app = FastAPI(title="PHANTOM Pricing Provider")
|
||||||
|
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"])
|
||||||
|
|
||||||
|
supabase: Client = create_client(os.getenv("NEXT_PUBLIC_SUPABASE_URL"), os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY"))
|
||||||
|
registry = ModelRegistry()
|
||||||
|
|
||||||
|
class PriceResponse(BaseModel):
|
||||||
|
productId: str
|
||||||
|
price: float
|
||||||
|
base_price: float
|
||||||
|
markup: float
|
||||||
|
elasticity: Optional[float] = None
|
||||||
|
model_version: str = 'latest'
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health() -> dict:
|
||||||
|
return {"status": "healthy", "redis": registry.health_check()}
|
||||||
|
|
||||||
|
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
||||||
|
def get_price(mode: Literal['hotel', 'airline'], productId: str, sessionId: Optional[str] = Query(None), experimentId: Optional[str] = Query(None)):
|
||||||
|
"""
|
||||||
|
THIS is the fast lookup service (mechanism).
|
||||||
|
Priority: session-keyed price > global optimal price > base price
|
||||||
|
"""
|
||||||
|
product = supabase.table(f'{mode}_products').select("metadata").eq('id', productId).execute().data[0]
|
||||||
|
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||||
|
|
||||||
|
metadata = product['metadata']
|
||||||
|
base_price = metadata.get('base_price', 100.0)
|
||||||
|
|
||||||
|
# PRIORITY 1: session-aware price (computed by Airflow worker)
|
||||||
|
if sessionId:
|
||||||
|
session_price = registry.get_session_price(sessionId, productId)
|
||||||
|
if session_price is not None:
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=session_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=session_price/base_price,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='session-aware'
|
||||||
|
)
|
||||||
|
|
||||||
|
# PRIORITY 2: global pre-computed prices (surge pricing)
|
||||||
|
prices_df = registry.get_prices('latest')
|
||||||
|
if prices_df is not None:
|
||||||
|
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||||
|
if not product_price_row.empty:
|
||||||
|
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=optimal_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=optimal_price/base_price,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='surge'
|
||||||
|
)
|
||||||
|
|
||||||
|
# PRIORITY 3: fallback to base price
|
||||||
|
return PriceResponse(
|
||||||
|
productId=productId,
|
||||||
|
price=base_price,
|
||||||
|
base_price=base_price,
|
||||||
|
markup=1.0,
|
||||||
|
elasticity=None,
|
||||||
|
model_version='base'
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.get("/models")
|
||||||
|
def list_models(): return registry.list_models()
|
||||||
|
|
||||||
|
@app.post("/models/reload")
|
||||||
|
def reload_models():
|
||||||
|
elasticity, pricing_model = registry.get_elasticity('latest'), registry.get_pricing_model('latest')
|
||||||
|
return {
|
||||||
|
"elasticity_loaded": bool(elasticity),
|
||||||
|
"n_products": len(elasticity) if elasticity is not None else 0,
|
||||||
|
"pricing_model_loaded": bool(pricing_model),
|
||||||
|
"model_class": pricing_model.__class__.__name__ if pricing_model else None
|
||||||
|
}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PROVIDER_PORT", "5001")))
|
||||||
16
backend/provider/requirements.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
pydantic
|
||||||
|
numpy
|
||||||
|
pandas
|
||||||
|
scikit-learn
|
||||||
|
redis
|
||||||
|
supabase
|
||||||
|
confluent-kafka>=2.3.0
|
||||||
|
kafka-python
|
||||||
|
graphviz
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
typing-extensions>=4.8.0
|
||||||
|
pypickle
|
||||||
|
pymc
|
||||||
367
backend/server/app.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# boilerplate code
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, Any
|
||||||
|
import uvicorn
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from kafka import KafkaProducer, KafkaAdminClient, KafkaConsumer
|
||||||
|
from kafka.admin import NewTopic
|
||||||
|
from kafka.errors import TopicAlreadyExistsError
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from supabase import create_client, Client
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# kafka producer - lazy init
|
||||||
|
_producer: Optional[KafkaProducer] = None
|
||||||
|
|
||||||
|
# supabase client - lazy init
|
||||||
|
_supabase: Optional[Client] = None
|
||||||
|
|
||||||
|
def get_supabase() -> Client:
|
||||||
|
global _supabase
|
||||||
|
if _supabase is None:
|
||||||
|
url = os.getenv('NEXT_PUBLIC_SUPABASE_URL')
|
||||||
|
key = os.getenv('NEXT_PUBLIC_SUPABASE_ANON_KEY')
|
||||||
|
if not url or not key:
|
||||||
|
raise ValueError("Supabase credentials not configured")
|
||||||
|
_supabase = create_client(url, key)
|
||||||
|
return _supabase
|
||||||
|
|
||||||
|
def get_producer() -> KafkaProducer:
|
||||||
|
global _producer
|
||||||
|
if _producer is None:
|
||||||
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
|
broker = f'{host}:{port}' if port else host
|
||||||
|
print(f"[KAFKA_INIT] Connecting to broker: {broker}")
|
||||||
|
_producer = KafkaProducer(
|
||||||
|
bootstrap_servers=[broker],
|
||||||
|
value_serializer=lambda v: json.dumps(v).encode('utf-8'),
|
||||||
|
key_serializer=lambda k: k.encode('utf-8') if k else None,
|
||||||
|
acks=1,
|
||||||
|
retries=3,
|
||||||
|
max_in_flight_requests_per_connection=5,
|
||||||
|
request_timeout_ms=30000,
|
||||||
|
api_version_auto_timeout_ms=10000,
|
||||||
|
max_block_ms=5000, # don't block send() for more than 5s
|
||||||
|
)
|
||||||
|
print(f"[KAFKA_INIT] Producer created successfully")
|
||||||
|
return _producer
|
||||||
|
|
||||||
|
class EventPayload(BaseModel):
|
||||||
|
sessionId: str
|
||||||
|
experimentId: Optional[str] = None
|
||||||
|
eventName: str
|
||||||
|
page: str
|
||||||
|
productId: Optional[str] = None
|
||||||
|
metadata: Optional[dict[str, Any]] = None
|
||||||
|
storeMode: str
|
||||||
|
userAgent: Optional[str] = None
|
||||||
|
ts: Optional[str] = None
|
||||||
|
|
||||||
|
class PriceLogPayload(BaseModel):
|
||||||
|
productId: str
|
||||||
|
price: float
|
||||||
|
sessionId: str
|
||||||
|
experimentId: Optional[str] = None
|
||||||
|
storeMode: str
|
||||||
|
ts: Optional[str] = None
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup_event():
|
||||||
|
"""create kafka topics on startup"""
|
||||||
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
|
broker = f'{host}:{port}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
print(f"[STARTUP] Creating Kafka topics on {broker}")
|
||||||
|
admin = KafkaAdminClient(
|
||||||
|
bootstrap_servers=[broker],
|
||||||
|
request_timeout_ms=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
topics = [
|
||||||
|
NewTopic(name='user-interactions', num_partitions=3, replication_factor=1),
|
||||||
|
NewTopic(name='price-logs', num_partitions=3, replication_factor=1)
|
||||||
|
]
|
||||||
|
|
||||||
|
admin.create_topics(new_topics=topics, validate_only=False)
|
||||||
|
print(f"[STARTUP] Topics created successfully")
|
||||||
|
admin.close()
|
||||||
|
except TopicAlreadyExistsError:
|
||||||
|
print(f"[STARTUP] Topics already exist, skipping creation")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[STARTUP] Failed to create topics: {e}")
|
||||||
|
print(f"[STARTUP] Will rely on auto-creation on first message")
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
kafka_status = "unknown"
|
||||||
|
try:
|
||||||
|
producer = get_producer()
|
||||||
|
# attempt to get cluster metadata to verify connection
|
||||||
|
producer.bootstrap_connected()
|
||||||
|
kafka_status = "connected"
|
||||||
|
except Exception as e:
|
||||||
|
kafka_status = f"error: {str(e)}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "healthy",
|
||||||
|
"kafka": kafka_status,
|
||||||
|
"kafka_broker": f"{os.getenv('KAFKA_HOST', 'localhost')}:{os.getenv('KAFKA_PORT', '9092')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kafka/ingest")
|
||||||
|
async def ingest_logs(event: EventPayload):
|
||||||
|
try:
|
||||||
|
if not event.ts:
|
||||||
|
event.ts = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
producer = get_producer()
|
||||||
|
future = producer.send(
|
||||||
|
'user-interactions',
|
||||||
|
key=event.sessionId,
|
||||||
|
value=event.model_dump()
|
||||||
|
)
|
||||||
|
# add callback for error logging but don't block
|
||||||
|
future.add_errback(lambda e: print(f"[KAFKA_SEND_ERROR] {e}"))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/kafka/price-log")
|
||||||
|
async def ingest_price_log(price_log: PriceLogPayload):
|
||||||
|
try:
|
||||||
|
if not price_log.ts:
|
||||||
|
price_log.ts = datetime.utcnow().isoformat() + 'Z'
|
||||||
|
|
||||||
|
producer = get_producer()
|
||||||
|
future = producer.send(
|
||||||
|
'price-logs',
|
||||||
|
key=price_log.productId,
|
||||||
|
value=price_log.model_dump()
|
||||||
|
)
|
||||||
|
future.add_errback(lambda e: print(f"[KAFKA_PRICE_LOG_ERROR] {e}"))
|
||||||
|
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[PRICE_LOG_ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/kafka/dump")
|
||||||
|
def dump_logs(
|
||||||
|
topic: str = 'user-interactions',
|
||||||
|
last_n: Optional[int] = None,
|
||||||
|
t_start: Optional[str] = None,
|
||||||
|
t_end: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""dump all messages from specified kafka topic
|
||||||
|
|
||||||
|
params:
|
||||||
|
topic: kafka topic to dump (default: user-interactions)
|
||||||
|
last_n: return only last n messages (default: all)
|
||||||
|
t_start: filter by start timestamp iso format
|
||||||
|
t_end: filter by end timestamp iso format
|
||||||
|
"""
|
||||||
|
if topic not in ['user-interactions', 'price-logs']:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid topic")
|
||||||
|
|
||||||
|
host = os.getenv('KAFKA_HOST', 'localhost')
|
||||||
|
port = os.getenv('KAFKA_PORT', '9092')
|
||||||
|
broker = f'{host}:{port}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
consumer = KafkaConsumer(
|
||||||
|
topic,
|
||||||
|
bootstrap_servers=[broker],
|
||||||
|
auto_offset_reset='earliest',
|
||||||
|
enable_auto_commit=False,
|
||||||
|
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||||
|
consumer_timeout_ms=30000,
|
||||||
|
fetch_max_wait_ms=10000,
|
||||||
|
max_poll_records=1000
|
||||||
|
)
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for msg in consumer:
|
||||||
|
events.append(msg.value)
|
||||||
|
if last_n and len(events) >= last_n * 2:
|
||||||
|
break
|
||||||
|
|
||||||
|
consumer.close()
|
||||||
|
|
||||||
|
# apply filters
|
||||||
|
if t_start or t_end:
|
||||||
|
filtered = []
|
||||||
|
for e in events:
|
||||||
|
ts = e.get('ts')
|
||||||
|
if ts:
|
||||||
|
if t_start and ts < t_start:
|
||||||
|
continue
|
||||||
|
if t_end and ts > t_end:
|
||||||
|
continue
|
||||||
|
filtered.append(e)
|
||||||
|
events = filtered
|
||||||
|
|
||||||
|
if last_n and last_n > 0:
|
||||||
|
events = events[-last_n:]
|
||||||
|
|
||||||
|
return {"success": True, "count": len(events), "data": events}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[DUMP_ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/products/{product_id}")
|
||||||
|
async def get_product_by_id(product_id: str):
|
||||||
|
"""fetch single product by id from either hotel_products or airline_products"""
|
||||||
|
try:
|
||||||
|
supabase = get_supabase()
|
||||||
|
|
||||||
|
# try hotel_products first
|
||||||
|
response = supabase.table('hotel_products').select('*').eq('id', product_id).execute()
|
||||||
|
if response.data and len(response.data) > 0:
|
||||||
|
return {"success": True, "data": response.data[0]}
|
||||||
|
|
||||||
|
# try airline_products
|
||||||
|
response = supabase.table('airline_products').select('*').eq('id', product_id).execute()
|
||||||
|
if response.data and len(response.data) > 0:
|
||||||
|
return {"success": True, "data": response.data[0]}
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[PRODUCT_BY_ID_ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.get("/api/products/type/{product_type}")
|
||||||
|
async def get_products(
|
||||||
|
product_type: str,
|
||||||
|
dateIndex: Optional[int] = None,
|
||||||
|
origin: Optional[str] = None,
|
||||||
|
destination: Optional[str] = None,
|
||||||
|
tripType: Optional[str] = None,
|
||||||
|
adults: Optional[int] = None,
|
||||||
|
children: Optional[int] = None,
|
||||||
|
infants: Optional[int] = None,
|
||||||
|
rooms: Optional[int] = None
|
||||||
|
):
|
||||||
|
"""fetch products from supabase based on type (hotel or airline)
|
||||||
|
|
||||||
|
params:
|
||||||
|
product_type: either 'hotel' or 'airline'
|
||||||
|
dateIndex: optional days offset from today (e.g., 0=today, 1=tomorrow, -1=yesterday)
|
||||||
|
origin: (airline) departure airport code
|
||||||
|
destination: (airline/hotel) arrival airport or hotel location
|
||||||
|
tripType: (airline) roundtrip, oneway, multicity
|
||||||
|
adults, children, infants: passenger counts
|
||||||
|
rooms: (hotel) number of rooms
|
||||||
|
"""
|
||||||
|
if product_type not in ['hotel', 'airline']:
|
||||||
|
raise HTTPException(status_code=400, detail="product_type must be 'hotel' or 'airline'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
supabase = get_supabase()
|
||||||
|
table = f'{product_type}_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)
|
||||||
|
|
||||||
|
response = query.execute()
|
||||||
|
results = response.data
|
||||||
|
|
||||||
|
# apply in-memory filters based on metadata for airline products
|
||||||
|
if product_type == 'airline' and results:
|
||||||
|
filtered = []
|
||||||
|
for product in results:
|
||||||
|
metadata = product.get('metadata', {})
|
||||||
|
|
||||||
|
# filter by origin airport
|
||||||
|
if origin:
|
||||||
|
dep = metadata.get('departure', {})
|
||||||
|
if dep.get('airport') != origin:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# filter by destination airport
|
||||||
|
if destination:
|
||||||
|
arr = metadata.get('arrival', {})
|
||||||
|
if arr.get('airport') != destination:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# passenger count validation (ensure total capacity)
|
||||||
|
if adults is not None or children is not None or infants is not None:
|
||||||
|
total_pax = (adults or 0) + (children or 0) + (infants or 0)
|
||||||
|
avail = product.get('availability', 0)
|
||||||
|
if avail < total_pax:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(product)
|
||||||
|
|
||||||
|
results = filtered
|
||||||
|
|
||||||
|
# apply in-memory filters for hotel products
|
||||||
|
elif product_type == 'hotel' and results:
|
||||||
|
filtered = []
|
||||||
|
for product in results:
|
||||||
|
metadata = product.get('metadata', {})
|
||||||
|
|
||||||
|
# filter by occupancy capacity
|
||||||
|
if adults is not None:
|
||||||
|
max_occ = metadata.get('max_occupancy', 2)
|
||||||
|
if max_occ < adults:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# filter by room availability
|
||||||
|
if rooms is not None:
|
||||||
|
avail = product.get('availability', 0)
|
||||||
|
if avail < rooms:
|
||||||
|
continue
|
||||||
|
|
||||||
|
filtered.append(product)
|
||||||
|
|
||||||
|
results = filtered
|
||||||
|
|
||||||
|
return {"success": True, "count": len(results), "data": results}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
print(f"[PRODUCTS_ERROR] {e}")
|
||||||
|
print(traceback.format_exc())
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
PORT=int(os.getenv("BACKEND_PORT", 5000))
|
||||||
|
uvicorn.run("server:app", host="0.0.0.0", port=PORT, reload=True)
|
||||||
6
backend/server/requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
fastapi==0.104.1
|
||||||
|
uvicorn[standard]==0.24.0
|
||||||
|
kafka-python==2.0.2
|
||||||
|
pydantic==2.5.0
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
supabase==2.9.1
|
||||||
@@ -1,15 +1,57 @@
|
|||||||
services:
|
services:
|
||||||
|
tensorboard-rl:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
container_name: "PHANTOM-tensorboard-rl"
|
||||||
|
ports:
|
||||||
|
- "6007:6006"
|
||||||
|
volumes:
|
||||||
|
- ./sim/rl/runs:/logs
|
||||||
|
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
tensorboard-ml:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
container_name: "PHANTOM-tensorboard-ml"
|
||||||
|
ports:
|
||||||
|
- "6006:6006"
|
||||||
|
volumes:
|
||||||
|
- ./experiments/ml/runs:/logs
|
||||||
|
command: tensorboard --logdir=/logs --host=0.0.0.0 --port=6006
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
container_name: "PHANTOM-backend"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/backend.Dockerfile
|
||||||
|
ports:
|
||||||
|
- "${BACKEND_PORT:-5000}:5000"
|
||||||
|
environment:
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_PORT=5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
depends_on:
|
||||||
|
- kafka
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
container_name: "PHANTOM-redis"
|
container_name: "PHANTOM-redis"
|
||||||
image: redis:7-alpine
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Redis.dockerfile
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6378}:6379"
|
- "${REDIS_PORT:-6378}:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- phantom_redis_data:/data
|
- phantom_redis_data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
zookeeper:
|
zookeeper:
|
||||||
container_name: "PHANTOM-zookeeper"
|
container_name: "PHANTOM-zookeeper"
|
||||||
image: confluentinc/cp-zookeeper:latest
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Zookeeper.dockerfile
|
||||||
environment:
|
environment:
|
||||||
ZOOKEEPER_CLIENT_PORT: 2181
|
ZOOKEEPER_CLIENT_PORT: 2181
|
||||||
ports:
|
ports:
|
||||||
@@ -17,7 +59,9 @@ services:
|
|||||||
|
|
||||||
kafka:
|
kafka:
|
||||||
container_name: "PHANTOM-kafka"
|
container_name: "PHANTOM-kafka"
|
||||||
image: confluentinc/cp-kafka:7.5.0
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: Kafka.dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- zookeeper
|
- zookeeper
|
||||||
environment:
|
environment:
|
||||||
@@ -36,7 +80,9 @@ services:
|
|||||||
|
|
||||||
redpanda-console:
|
redpanda-console:
|
||||||
container_name: "PHANTOM-redpanda-console"
|
container_name: "PHANTOM-redpanda-console"
|
||||||
image: docker.redpanda.com/redpandadata/console:latest
|
build:
|
||||||
|
context: ./docker
|
||||||
|
dockerfile: RedpandaConsole.dockerfile
|
||||||
depends_on:
|
depends_on:
|
||||||
- kafka
|
- kafka
|
||||||
environment:
|
environment:
|
||||||
@@ -45,6 +91,149 @@ services:
|
|||||||
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
- "${REDPANDA_CONSOLE_PORT:-8080}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
container_name: "PHANTOM-postgres"
|
||||||
|
image: postgres:13
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=airflow
|
||||||
|
- POSTGRES_PASSWORD=airflow
|
||||||
|
- POSTGRES_DB=airflow
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
airflow-init:
|
||||||
|
container_name: "PHANTOM-airflow-init"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- _AIRFLOW_DB_MIGRATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_CREATE=true
|
||||||
|
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||||
|
- _AIRFLOW_WWW_USER_PASSWORD=admin
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: version
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
airflow-webserver:
|
||||||
|
container_name: "PHANTOM-airflow-webserver"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
- airflow-init
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||||
|
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||||
|
- AIRFLOW__WEBSERVER__EXPOSE_CONFIG=true
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
ports:
|
||||||
|
- "${AIRFLOW_WEBSERVER_PORT:-8085}:8080"
|
||||||
|
command: webserver
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "--fail", "http://localhost:8080/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
airflow-scheduler:
|
||||||
|
container_name: "PHANTOM-airflow-scheduler"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Airflow.dockerfile
|
||||||
|
depends_on:
|
||||||
|
airflow-webserver:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
environment:
|
||||||
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
|
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
||||||
|
- AIRFLOW__CORE__PARALLELISM=16
|
||||||
|
- AIRFLOW__CORE__DAG_CONCURRENCY=8
|
||||||
|
- AIRFLOW__CORE__MAX_ACTIVE_RUNS_PER_DAG=4
|
||||||
|
- AIRFLOW__SCHEDULER__MIN_FILE_PROCESS_INTERVAL=30
|
||||||
|
- AIRFLOW__SCHEDULER__DAG_DIR_LIST_INTERVAL=60
|
||||||
|
- AIRFLOW__SCHEDULER__PARSING_PROCESSES=2
|
||||||
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- BACKEND_URL=http://backend:5000
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
command: scheduler
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", 'airflow jobs check --job-type SchedulerJob --hostname "$${HOSTNAME}"']
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 5
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
pricing-provider:
|
||||||
|
container_name: "PHANTOM-pricing-provider"
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/Provider.dockerfile
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
- kafka
|
||||||
|
environment:
|
||||||
|
- PROVIDER_PORT=5001
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- KAFKA_HOST=kafka
|
||||||
|
- KAFKA_PORT=29092
|
||||||
|
- NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
- NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
- BACKEND_URL=http://localhost:5000
|
||||||
|
ports:
|
||||||
|
- "${PROVIDER_PORT:-5001}:5001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
phantom_kafka_data:
|
phantom_kafka_data:
|
||||||
phantom_redis_data:
|
phantom_redis_data:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
30
docker/Airflow.dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
FROM apache/airflow:2.7.3-python3.11
|
||||||
|
|
||||||
|
USER root
|
||||||
|
|
||||||
|
# install system deps if needed
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
USER airflow
|
||||||
|
|
||||||
|
# copy requirements for pipeline dependencies
|
||||||
|
COPY requirements.txt /tmp/requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
||||||
|
|
||||||
|
# install postgres driver and providers
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
psycopg2-binary \
|
||||||
|
apache-airflow-providers-postgres
|
||||||
|
|
||||||
|
# set airflow home
|
||||||
|
ENV AIRFLOW_HOME=/opt/airflow
|
||||||
|
|
||||||
|
COPY --chown=airflow:root experiments/airflow/dags ${AIRFLOW_HOME}/dags
|
||||||
|
COPY --chown=airflow:root experiments/procesing ${AIRFLOW_HOME}/procesing
|
||||||
|
COPY --chown=airflow:root lib ${AIRFLOW_HOME}/lib
|
||||||
|
|
||||||
|
# create logs and plugins dirs (airflow expects them)
|
||||||
|
RUN mkdir -p ${AIRFLOW_HOME}/logs ${AIRFLOW_HOME}/plugins
|
||||||
41
docker/Airflow.railway.dockerfile
Normal file
@@ -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"]
|
||||||
7
docker/Kafka.dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM confluentinc/cp-kafka:7.5.0
|
||||||
|
|
||||||
|
# Expose Kafka ports
|
||||||
|
# 9092: External client connections
|
||||||
|
# 29092: Internal broker communication
|
||||||
|
# 9999: JMX monitoring port
|
||||||
|
EXPOSE 9092 29092 9999
|
||||||
26
docker/Provider.dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies including graphviz
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
graphviz \
|
||||||
|
libgraphviz-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy and install Python dependencies
|
||||||
|
COPY backend/provider/requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code into image
|
||||||
|
COPY lib/ /app/lib/
|
||||||
|
COPY experiments/procesing/ /app/procesing/
|
||||||
|
COPY backend/provider/ /app/provider/
|
||||||
|
|
||||||
|
ENV PYTHONPATH=/app:/app/lib:/app/procesing
|
||||||
|
|
||||||
|
WORKDIR /app/provider
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5001"]
|
||||||
4
docker/Redis.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM redis:7-alpine
|
||||||
|
|
||||||
|
# Expose Redis port
|
||||||
|
EXPOSE 6379
|
||||||
4
docker/RedpandaConsole.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM docker.redpanda.com/redpandadata/console:latest
|
||||||
|
|
||||||
|
# Expose Redpanda Console web UI port
|
||||||
|
EXPOSE 8080
|
||||||
4
docker/Zookeeper.dockerfile
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
FROM confluentinc/cp-zookeeper:latest
|
||||||
|
|
||||||
|
# Expose Zookeeper client port
|
||||||
|
EXPOSE 2181
|
||||||
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}
|
||||||
12
docker/backend.Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/server/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY backend/server/app.py .
|
||||||
|
|
||||||
|
EXPOSE 5000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "5000"]
|
||||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 363 KiB After Width: | Height: | Size: 363 KiB |
|
Before Width: | Height: | Size: 496 KiB After Width: | Height: | Size: 496 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 197 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
# Products
|
||||||
|
# Agents
|
||||||
|
# Pipeline
|
||||||
|
|
||||||
|
Our pipeline technically should follow principles in a style like this:
|
||||||
|
- Each step should be defined as an inheriting child of an scikit pipeline step, the granularity of the steps is dictated by the following: a step should be a transformation, augmentation or computation independently, no single stage should run multiple in-itself. This way we can modularize properly all the components and track properly in airflow. A stage can be defined as an sklearn step but then must be transalted to a function that takes the context in our DAG of airflow. All parametrization must be done via contexts.
|
||||||
|
|
||||||
|
|||||||
0
experiments/__init__.py
Normal file
1
experiments/agents/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Agentic behavior runner for PHANTOM research platform."""
|
||||||
47
experiments/agents/agent.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from .base import Agent as BaseAgent
|
||||||
|
from browser_use import Browser, Agent, ChatOpenAI
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class AgentTypes(str, Enum):
|
||||||
|
GENERIC_BROWSER_USE_AGENT = "generic_browser_use_agent"
|
||||||
|
|
||||||
|
def _build_prompt(goal : str, environment_url : str) -> str:
|
||||||
|
#TODO: Improve prompt engineering here and experiment with
|
||||||
|
return f"""You are an autonomous agent tasked with achieving the following goal: {goal}
|
||||||
|
You have access to a web browser to interact with the environment at {environment_url}.
|
||||||
|
Use the browser to navigate, gather information, and perform actions necessary to accomplish your goal.
|
||||||
|
Be thorough and ensure you complete the task fully."""
|
||||||
|
|
||||||
|
class GenericBrowserUseAgent(BaseAgent):
|
||||||
|
def __init__(self,
|
||||||
|
goal: str,
|
||||||
|
url: str = "http://localhost:3000",
|
||||||
|
timeout: int = 300,
|
||||||
|
llm_model: str = "gpt-5-mini",
|
||||||
|
headless: bool = True):
|
||||||
|
super().__init__(goal, url, timeout)
|
||||||
|
self.llm_model = ChatOpenAI(model=llm_model)
|
||||||
|
self.browser = Browser(headless=headless)
|
||||||
|
self.agent = Agent(task=_build_prompt(goal, url),
|
||||||
|
llm=self.llm_model,
|
||||||
|
browser=self.browser)
|
||||||
|
async def act(self) -> str:
|
||||||
|
self.result = await self.agent.run()
|
||||||
|
# https://github.com/browser-use/browser-use/blob/main/browser_use/agent/views.py#L301
|
||||||
|
return self.result.final_result()
|
||||||
|
|
||||||
|
def get_agent(agent_type: AgentTypes, **kwargs) -> Agent:
|
||||||
|
if agent_type == AgentTypes.GENERIC_BROWSER_USE_AGENT:
|
||||||
|
return GenericBrowserUseAgent(**kwargs)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown agent type: {agent_type}")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
JTBD= "Find me the cheapest room in Madrid for 2 people in the next two days, review each hotel room in detail and then add it to cart."
|
||||||
|
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT,
|
||||||
|
goal=JTBD,
|
||||||
|
url="http://localhost:3000/start-task?uuid=d10f5ab3-a7b7-4e97-8d94-ab06f1537c0a",
|
||||||
|
timeout=300)
|
||||||
|
R=asyncio.run(agent.act())
|
||||||
|
print(R)
|
||||||
19
experiments/agents/base.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
class Agent(ABC):
|
||||||
|
"""Base interface for browser automation agents"""
|
||||||
|
|
||||||
|
def __init__(self, goal: str, url: str = "http://localhost:3000", timeout: int = 300):
|
||||||
|
self.goal = goal
|
||||||
|
self.url = url
|
||||||
|
self.timeout = timeout
|
||||||
|
self.result: Optional[str] = None
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def act(self) -> str:
|
||||||
|
"""Execute goal and return result text"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def final_result(self) -> Optional[str]:
|
||||||
|
return self.result
|
||||||
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}")
|
||||||
30
experiments/agents/test.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
from experiments.agents.agent import get_agent, AgentTypes
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_init():
|
||||||
|
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="test", url="http://example.com", timeout=100)
|
||||||
|
assert agent.goal == "test"
|
||||||
|
assert agent.url == "http://example.com"
|
||||||
|
assert agent.timeout == 100
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_agent():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
get_agent("invalid", goal="test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.skipif("OPENAI_API_KEY" not in os.environ, reason="OPENAI_API_KEY not set")
|
||||||
|
async def test_agent_execution():
|
||||||
|
agent = get_agent(AgentTypes.GENERIC_BROWSER_USE_AGENT, goal="get page title", url="https://example.com", timeout=60)
|
||||||
|
|
||||||
|
result = await agent.act()
|
||||||
|
assert result
|
||||||
|
assert agent.final_result()
|
||||||
|
assert agent.final_result().history[-1].result[-1].is_done == True
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert "example" in result.lower()
|
||||||
|
assert len(result) > 0
|
||||||
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
@@ -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
@@ -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
|
||||||
@@ -1,721 +0,0 @@
|
|||||||
{
|
|
||||||
"cells": [
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 98,
|
|
||||||
"id": "62eafcd9-5462-4063-8873-0e7fb9add907",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"True"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 98,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"from kafka import KafkaConsumer\n",
|
|
||||||
"import pandas as pd\n",
|
|
||||||
"import json\n",
|
|
||||||
"import numpy as np\n",
|
|
||||||
"import os\n",
|
|
||||||
"from dotenv import load_dotenv\n",
|
|
||||||
"import matplotlib.pyplot as plt\n",
|
|
||||||
"from IPython.display import display, SVG, Image\n",
|
|
||||||
"load_dotenv()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 86,
|
|
||||||
"id": "4af65cb4-e8cf-4877-b2db-13ac19f3838f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"<class 'pandas.core.frame.DataFrame'>\n",
|
|
||||||
"RangeIndex: 141 entries, 0 to 140\n",
|
|
||||||
"Data columns (total 10 columns):\n",
|
|
||||||
" # Column Non-Null Count Dtype \n",
|
|
||||||
"--- ------ -------------- ----- \n",
|
|
||||||
" 0 sessionId 141 non-null object \n",
|
|
||||||
" 1 eventType 141 non-null object \n",
|
|
||||||
" 2 ts 141 non-null int64 \n",
|
|
||||||
" 3 targetEl 14 non-null object \n",
|
|
||||||
" 4 targetUrl 1 non-null object \n",
|
|
||||||
" 5 metadata_path 141 non-null object \n",
|
|
||||||
" 6 metadata_referrer 6 non-null object \n",
|
|
||||||
" 7 metadata_x 14 non-null float64\n",
|
|
||||||
" 8 metadata_y 14 non-null float64\n",
|
|
||||||
" 9 metadata_scrollY 121 non-null float64\n",
|
|
||||||
"dtypes: float64(3), int64(1), object(6)\n",
|
|
||||||
"memory usage: 11.1+ KB\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"KAFKA_PORT=os.getenv(\"KAFKA_PORT\", 9092)\n",
|
|
||||||
"topic = \"user-interactions\"\n",
|
|
||||||
"consumer = KafkaConsumer(\n",
|
|
||||||
" topic, \n",
|
|
||||||
" enable_auto_commit=True,\n",
|
|
||||||
" value_deserializer=lambda x: json.loads(x.decode('utf-8')),\n",
|
|
||||||
" auto_offset_reset='earliest',\n",
|
|
||||||
" bootstrap_servers=['localhost:9092'])\n",
|
|
||||||
"messages=consumer.poll(timeout_ms=1000,max_records=10000)\n",
|
|
||||||
"df = []\n",
|
|
||||||
"for m in messages.values():\n",
|
|
||||||
" for i in m:\n",
|
|
||||||
" df.append(i.value)\n",
|
|
||||||
"df = pd.DataFrame(df)\n",
|
|
||||||
"# explode metadata col json\n",
|
|
||||||
"df = df.join(pd.json_normalize(df.pop(\"metadata\"), sep=\".\").add_prefix(\"metadata_\"))\n",
|
|
||||||
"df.info()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 87,
|
|
||||||
"id": "f6819a1c-32ab-49c7-845b-5df7bf60f561",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/html": [
|
|
||||||
"<div>\n",
|
|
||||||
"<style scoped>\n",
|
|
||||||
" .dataframe tbody tr th:only-of-type {\n",
|
|
||||||
" vertical-align: middle;\n",
|
|
||||||
" }\n",
|
|
||||||
"\n",
|
|
||||||
" .dataframe tbody tr th {\n",
|
|
||||||
" vertical-align: top;\n",
|
|
||||||
" }\n",
|
|
||||||
"\n",
|
|
||||||
" .dataframe thead th {\n",
|
|
||||||
" text-align: right;\n",
|
|
||||||
" }\n",
|
|
||||||
"</style>\n",
|
|
||||||
"<table border=\"1\" class=\"dataframe\">\n",
|
|
||||||
" <thead>\n",
|
|
||||||
" <tr style=\"text-align: right;\">\n",
|
|
||||||
" <th></th>\n",
|
|
||||||
" <th>sessionId</th>\n",
|
|
||||||
" <th>eventType</th>\n",
|
|
||||||
" <th>ts</th>\n",
|
|
||||||
" <th>targetEl</th>\n",
|
|
||||||
" <th>targetUrl</th>\n",
|
|
||||||
" <th>metadata_path</th>\n",
|
|
||||||
" <th>metadata_referrer</th>\n",
|
|
||||||
" <th>metadata_x</th>\n",
|
|
||||||
" <th>metadata_y</th>\n",
|
|
||||||
" <th>metadata_scrollY</th>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" </thead>\n",
|
|
||||||
" <tbody>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>0</th>\n",
|
|
||||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
|
||||||
" <td>pageview</td>\n",
|
|
||||||
" <td>1761226211163</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td></td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>1</th>\n",
|
|
||||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761226218090</td>\n",
|
|
||||||
" <td>MAIN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>815.0</td>\n",
|
|
||||||
" <td>331.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>2</th>\n",
|
|
||||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761226220890</td>\n",
|
|
||||||
" <td>MAIN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>1129.0</td>\n",
|
|
||||||
" <td>605.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>3</th>\n",
|
|
||||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761226225801</td>\n",
|
|
||||||
" <td>DIV</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>532.0</td>\n",
|
|
||||||
" <td>545.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>4</th>\n",
|
|
||||||
" <td>1761225843899-qaiwwwyj2o</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761226229364</td>\n",
|
|
||||||
" <td>DIV</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>481.0</td>\n",
|
|
||||||
" <td>399.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>5</th>\n",
|
|
||||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
|
||||||
" <td>pageview</td>\n",
|
|
||||||
" <td>1761227236426</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td></td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>6</th>\n",
|
|
||||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761227239328</td>\n",
|
|
||||||
" <td>DIV</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>202.0</td>\n",
|
|
||||||
" <td>351.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>7</th>\n",
|
|
||||||
" <td>1761227236286-e7mphcvw6t</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761227244783</td>\n",
|
|
||||||
" <td>A</td>\n",
|
|
||||||
" <td>https://vercel.com/new?utm_source=create-next-...</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>377.0</td>\n",
|
|
||||||
" <td>723.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>8</th>\n",
|
|
||||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
|
||||||
" <td>pageview</td>\n",
|
|
||||||
" <td>1761828261783</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td></td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>9</th>\n",
|
|
||||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
|
||||||
" <td>click</td>\n",
|
|
||||||
" <td>1761828266484</td>\n",
|
|
||||||
" <td>H1</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>527.0</td>\n",
|
|
||||||
" <td>169.0</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>10</th>\n",
|
|
||||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
|
||||||
" <td>scroll</td>\n",
|
|
||||||
" <td>1761828270314</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>51.666668</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>11</th>\n",
|
|
||||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
|
||||||
" <td>scroll</td>\n",
|
|
||||||
" <td>1761828270328</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>50.000000</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" <tr>\n",
|
|
||||||
" <th>12</th>\n",
|
|
||||||
" <td>1761828056433-0gz7aboz86h</td>\n",
|
|
||||||
" <td>scroll</td>\n",
|
|
||||||
" <td>1761828270336</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>/</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>NaN</td>\n",
|
|
||||||
" <td>49.166668</td>\n",
|
|
||||||
" </tr>\n",
|
|
||||||
" </tbody>\n",
|
|
||||||
"</table>\n",
|
|
||||||
"</div>"
|
|
||||||
],
|
|
||||||
"text/plain": [
|
|
||||||
" sessionId eventType ts targetEl \\\n",
|
|
||||||
"0 1761225843899-qaiwwwyj2o pageview 1761226211163 NaN \n",
|
|
||||||
"1 1761225843899-qaiwwwyj2o click 1761226218090 MAIN \n",
|
|
||||||
"2 1761225843899-qaiwwwyj2o click 1761226220890 MAIN \n",
|
|
||||||
"3 1761225843899-qaiwwwyj2o click 1761226225801 DIV \n",
|
|
||||||
"4 1761225843899-qaiwwwyj2o click 1761226229364 DIV \n",
|
|
||||||
"5 1761227236286-e7mphcvw6t pageview 1761227236426 NaN \n",
|
|
||||||
"6 1761227236286-e7mphcvw6t click 1761227239328 DIV \n",
|
|
||||||
"7 1761227236286-e7mphcvw6t click 1761227244783 A \n",
|
|
||||||
"8 1761828056433-0gz7aboz86h pageview 1761828261783 NaN \n",
|
|
||||||
"9 1761828056433-0gz7aboz86h click 1761828266484 H1 \n",
|
|
||||||
"10 1761828056433-0gz7aboz86h scroll 1761828270314 NaN \n",
|
|
||||||
"11 1761828056433-0gz7aboz86h scroll 1761828270328 NaN \n",
|
|
||||||
"12 1761828056433-0gz7aboz86h scroll 1761828270336 NaN \n",
|
|
||||||
"\n",
|
|
||||||
" targetUrl metadata_path \\\n",
|
|
||||||
"0 NaN / \n",
|
|
||||||
"1 NaN / \n",
|
|
||||||
"2 NaN / \n",
|
|
||||||
"3 NaN / \n",
|
|
||||||
"4 NaN / \n",
|
|
||||||
"5 NaN / \n",
|
|
||||||
"6 NaN / \n",
|
|
||||||
"7 https://vercel.com/new?utm_source=create-next-... / \n",
|
|
||||||
"8 NaN / \n",
|
|
||||||
"9 NaN / \n",
|
|
||||||
"10 NaN / \n",
|
|
||||||
"11 NaN / \n",
|
|
||||||
"12 NaN / \n",
|
|
||||||
"\n",
|
|
||||||
" metadata_referrer metadata_x metadata_y metadata_scrollY \n",
|
|
||||||
"0 NaN NaN NaN \n",
|
|
||||||
"1 NaN 815.0 331.0 NaN \n",
|
|
||||||
"2 NaN 1129.0 605.0 NaN \n",
|
|
||||||
"3 NaN 532.0 545.0 NaN \n",
|
|
||||||
"4 NaN 481.0 399.0 NaN \n",
|
|
||||||
"5 NaN NaN NaN \n",
|
|
||||||
"6 NaN 202.0 351.0 NaN \n",
|
|
||||||
"7 NaN 377.0 723.0 NaN \n",
|
|
||||||
"8 NaN NaN NaN \n",
|
|
||||||
"9 NaN 527.0 169.0 NaN \n",
|
|
||||||
"10 NaN NaN NaN 51.666668 \n",
|
|
||||||
"11 NaN NaN NaN 50.000000 \n",
|
|
||||||
"12 NaN NaN NaN 49.166668 "
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 87,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"df.groupby('sessionId').head()"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 88,
|
|
||||||
"id": "380eca5f-8304-4fb2-be32-e8bcfd312085",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"text/plain": [
|
|
||||||
"['1761225843899-qaiwwwyj2o',\n",
|
|
||||||
" '1761828056433-0gz7aboz86h',\n",
|
|
||||||
" '1761227236286-e7mphcvw6t']"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"execution_count": 88,
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "execute_result"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"sessions = list(set(df['sessionId'])); sessions"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 89,
|
|
||||||
"id": "f4ae6f81-dcb8-44be-aee7-30dbc3a6bae1",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# map sessions to experiments"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 101,
|
|
||||||
"id": "050d90a4-20a9-47f5-b998-c31178a54cb3",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"def build_transition_prob_matrix(df: pd.DataFrame):\n",
|
|
||||||
" df = df.dropna(subset=['eventType'])\n",
|
|
||||||
" events = df['eventType'].tolist()\n",
|
|
||||||
" labels = pd.Index(events).unique().tolist()\n",
|
|
||||||
" idx = {e:i for i,e in enumerate(labels)}\n",
|
|
||||||
" M = np.zeros((len(labels), len(labels)), dtype=float)\n",
|
|
||||||
" for a, b in zip(events, events[1:]):\n",
|
|
||||||
" M[idx[a], idx[b]] += 1\n",
|
|
||||||
" row_sums = M.sum(axis=1, keepdims=True)\n",
|
|
||||||
" with np.errstate(divide='ignore', invalid='ignore'):\n",
|
|
||||||
" P = np.divide(M, row_sums, where=row_sums>0) # row-normalized\n",
|
|
||||||
" return P, labels"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 107,
|
|
||||||
"id": "e68f9004-82f5-4826-aece-e3dc6e15a18f",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": [
|
|
||||||
"# https://medium.com/data-science/time-series-data-markov-transition-matrices-7060771e362b\n",
|
|
||||||
"from graphviz import Digraph\n",
|
|
||||||
"import numpy as np\n",
|
|
||||||
"import pandas as pd\n",
|
|
||||||
"\n",
|
|
||||||
"def _as_prob_df(matrix, labels=None):\n",
|
|
||||||
" \"\"\"Return a square DataFrame with index=columns=labels.\"\"\"\n",
|
|
||||||
" if isinstance(matrix, pd.DataFrame):\n",
|
|
||||||
" # Ensure square and aligned\n",
|
|
||||||
" assert (matrix.index == matrix.columns).all(), \"Index/columns must match.\"\n",
|
|
||||||
" return matrix\n",
|
|
||||||
" matrix = np.asarray(matrix, dtype=float)\n",
|
|
||||||
" assert matrix.shape[0] == matrix.shape[1], \"Matrix must be square.\"\n",
|
|
||||||
" if labels is None:\n",
|
|
||||||
" raise ValueError(\"labels are required when matrix is not a DataFrame\")\n",
|
|
||||||
" assert len(labels) == matrix.shape[0], \"labels length must match matrix size.\"\n",
|
|
||||||
" return pd.DataFrame(matrix, index=list(labels), columns=list(labels))\n",
|
|
||||||
"\n",
|
|
||||||
"def _df_to_edgelist(P: pd.DataFrame, threshold=0.0, round_digits=2):\n",
|
|
||||||
" \"\"\"Build weighted edges > threshold.\"\"\"\n",
|
|
||||||
" edges = []\n",
|
|
||||||
" for src in P.index:\n",
|
|
||||||
" for dst in P.columns:\n",
|
|
||||||
" w = float(P.loc[src, dst])\n",
|
|
||||||
" if w > threshold:\n",
|
|
||||||
" edges.append((str(src), str(dst), f\"{w:.{round_digits}f}\"))\n",
|
|
||||||
" return edges\n",
|
|
||||||
"\n",
|
|
||||||
"def render_graph(fname, matrix, ls_index=None, threshold=0.0, fmt=\"svg\", view=False):\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" fname: output file stem (no extension)\n",
|
|
||||||
" matrix: NumPy array or pandas DataFrame of transition PROBABILITIES\n",
|
|
||||||
" ls_index: ordered labels (required if matrix is not a DataFrame)\n",
|
|
||||||
" threshold: hide edges with weight <= threshold\n",
|
|
||||||
" fmt: 'svg'|'png'|'pdf' etc.\n",
|
|
||||||
" view: open after rendering\n",
|
|
||||||
" \"\"\"\n",
|
|
||||||
" P = _as_prob_df(matrix, labels=ls_index)\n",
|
|
||||||
" edges = _df_to_edgelist(P, threshold=threshold)\n",
|
|
||||||
"\n",
|
|
||||||
" g = Digraph(format=fmt)\n",
|
|
||||||
" g.attr(rankdir=\"LR\", size=\"30\")\n",
|
|
||||||
" g.attr(\"node\", shape=\"circle\")\n",
|
|
||||||
"\n",
|
|
||||||
" # ensure isolated nodes appear\n",
|
|
||||||
" for node in P.index:\n",
|
|
||||||
" g.node(str(node), width=\"1\", height=\"1\")\n",
|
|
||||||
"\n",
|
|
||||||
" for src, dst, label in edges:\n",
|
|
||||||
" g.edge(src, dst, label=label)\n",
|
|
||||||
"\n",
|
|
||||||
" g.render(fname, view=view, cleanup=True)\n",
|
|
||||||
" return g\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": 108,
|
|
||||||
"id": "e255a2c1-6454-4e5e-89f6-ef8ac51ab6cc",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"image/svg+xml": [
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
|
||||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
|
||||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
|
||||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
|
||||||
" -->\n",
|
|
||||||
"<!-- Pages: 1 -->\n",
|
|
||||||
"<svg width=\"228pt\" height=\"124pt\"\n",
|
|
||||||
" viewBox=\"0.00 0.00 228.00 124.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
|
||||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 119.83)\">\n",
|
|
||||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
|
||||||
"<!-- pageview -->\n",
|
|
||||||
"<g id=\"node1\" class=\"node\">\n",
|
|
||||||
"<title>pageview</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click -->\n",
|
|
||||||
"<g id=\"node2\" class=\"node\">\n",
|
|
||||||
"<title>click</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- pageview->click -->\n",
|
|
||||||
"<g id=\"edge1\" class=\"edge\">\n",
|
|
||||||
"<title>pageview->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click->click -->\n",
|
|
||||||
"<g id=\"edge2\" class=\"edge\">\n",
|
|
||||||
"<title>click->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</svg>\n"
|
|
||||||
],
|
|
||||||
"text/plain": [
|
|
||||||
"<graphviz.graphs.Digraph at 0x7fd404165c70>"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "display_data"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"[[0. 1.]\n",
|
|
||||||
" [0. 1.]]\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"image/svg+xml": [
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
|
||||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
|
||||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
|
||||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
|
||||||
" -->\n",
|
|
||||||
"<!-- Pages: 1 -->\n",
|
|
||||||
"<svg width=\"358pt\" height=\"132pt\"\n",
|
|
||||||
" viewBox=\"0.00 0.00 358.00 132.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
|
||||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 128.41)\">\n",
|
|
||||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-128.41 354.16,-128.41 354.16,4 -4,4\"/>\n",
|
|
||||||
"<!-- pageview -->\n",
|
|
||||||
"<g id=\"node1\" class=\"node\">\n",
|
|
||||||
"<title>pageview</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- pageview->pageview -->\n",
|
|
||||||
"<g id=\"edge1\" class=\"edge\">\n",
|
|
||||||
"<title>pageview->pageview</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M30.86,-87.29C31.64,-98.6 36.22,-107.16 44.58,-107.16 49.94,-107.16 53.74,-103.65 55.99,-98.15\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"59.33,-99.28 57.99,-88.77 52.48,-97.82 59.33,-99.28\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-111.11\" font-family=\"Times,serif\" font-size=\"14.00\">0.2</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click -->\n",
|
|
||||||
"<g id=\"node2\" class=\"node\">\n",
|
|
||||||
"<title>click</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- pageview->click -->\n",
|
|
||||||
"<g id=\"edge2\" class=\"edge\">\n",
|
|
||||||
"<title>pageview->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.8</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click->pageview -->\n",
|
|
||||||
"<g id=\"edge3\" class=\"edge\">\n",
|
|
||||||
"<title>click->pageview</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M150.74,-29.52C143.93,-26.96 136.67,-24.68 129.66,-23.33 119.02,-21.28 107.71,-22.06 96.96,-24.24\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"96.33,-20.79 87.47,-26.6 98.02,-27.59 96.33,-20.79\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-27.28\" font-family=\"Times,serif\" font-size=\"14.00\">0.3</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click->click -->\n",
|
|
||||||
"<g id=\"edge4\" class=\"edge\">\n",
|
|
||||||
"<title>click->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.6</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- scroll -->\n",
|
|
||||||
"<g id=\"node3\" class=\"node\">\n",
|
|
||||||
"<title>scroll</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"314.16\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">scroll</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click->scroll -->\n",
|
|
||||||
"<g id=\"edge5\" class=\"edge\">\n",
|
|
||||||
"<title>click->scroll</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M220.12,-44.58C234.44,-44.58 251.18,-44.58 266.47,-44.58\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"266.31,-48.08 276.31,-44.58 266.31,-41.08 266.31,-48.08\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"248.91\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">0.1</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- scroll->scroll -->\n",
|
|
||||||
"<g id=\"edge6\" class=\"edge\">\n",
|
|
||||||
"<title>scroll->scroll</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M301.93,-78.86C302.06,-89.86 306.13,-98.58 314.16,-98.58 319.18,-98.58 322.66,-95.17 324.59,-89.93\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"327.99,-90.78 326.15,-80.35 321.08,-89.66 327.99,-90.78\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"314.16\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</svg>\n"
|
|
||||||
],
|
|
||||||
"text/plain": [
|
|
||||||
"<graphviz.graphs.Digraph at 0x7fd406e21a90>"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "display_data"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"[[0.25 0.75 0. ]\n",
|
|
||||||
" [0.28571429 0.57142857 0.14285714]\n",
|
|
||||||
" [0. 0.00826446 0.99173554]]\n"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"data": {
|
|
||||||
"image/svg+xml": [
|
|
||||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n",
|
|
||||||
"<!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\"\n",
|
|
||||||
" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\">\n",
|
|
||||||
"<!-- Generated by graphviz version 13.1.2 (0)\n",
|
|
||||||
" -->\n",
|
|
||||||
"<!-- Pages: 1 -->\n",
|
|
||||||
"<svg width=\"228pt\" height=\"124pt\"\n",
|
|
||||||
" viewBox=\"0.00 0.00 228.00 124.00\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\">\n",
|
|
||||||
"<g id=\"graph0\" class=\"graph\" transform=\"scale(1 1) rotate(0) translate(4 119.83)\">\n",
|
|
||||||
"<polygon fill=\"white\" stroke=\"none\" points=\"-4,4 -4,-119.83 223.66,-119.83 223.66,4 -4,4\"/>\n",
|
|
||||||
"<!-- pageview -->\n",
|
|
||||||
"<g id=\"node1\" class=\"node\">\n",
|
|
||||||
"<title>pageview</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"44.58\" cy=\"-44.58\" rx=\"44.58\" ry=\"44.58\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"44.58\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">pageview</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click -->\n",
|
|
||||||
"<g id=\"node2\" class=\"node\">\n",
|
|
||||||
"<title>click</title>\n",
|
|
||||||
"<ellipse fill=\"none\" stroke=\"black\" cx=\"183.66\" cy=\"-44.58\" rx=\"36\" ry=\"36\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-39.91\" font-family=\"Times,serif\" font-size=\"14.00\">click</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- pageview->click -->\n",
|
|
||||||
"<g id=\"edge1\" class=\"edge\">\n",
|
|
||||||
"<title>pageview->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M89.33,-44.58C104.32,-44.58 121.13,-44.58 136.31,-44.58\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"136.04,-48.08 146.04,-44.58 136.04,-41.08 136.04,-48.08\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"118.41\" y=\"-48.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"<!-- click->click -->\n",
|
|
||||||
"<g id=\"edge2\" class=\"edge\">\n",
|
|
||||||
"<title>click->click</title>\n",
|
|
||||||
"<path fill=\"none\" stroke=\"black\" d=\"M171.43,-78.86C171.56,-89.86 175.63,-98.58 183.66,-98.58 188.68,-98.58 192.16,-95.17 194.09,-89.93\"/>\n",
|
|
||||||
"<polygon fill=\"black\" stroke=\"black\" points=\"197.49,-90.78 195.65,-80.35 190.58,-89.66 197.49,-90.78\"/>\n",
|
|
||||||
"<text xml:space=\"preserve\" text-anchor=\"middle\" x=\"183.66\" y=\"-102.53\" font-family=\"Times,serif\" font-size=\"14.00\">1.0</text>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</g>\n",
|
|
||||||
"</svg>\n"
|
|
||||||
],
|
|
||||||
"text/plain": [
|
|
||||||
"<graphviz.graphs.Digraph at 0x7fd4041662b0>"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"metadata": {},
|
|
||||||
"output_type": "display_data"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "stdout",
|
|
||||||
"output_type": "stream",
|
|
||||||
"text": [
|
|
||||||
"[[0. 1.]\n",
|
|
||||||
" [0. 1.]]\n"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"source": [
|
|
||||||
"def explore_session(session_id: str):\n",
|
|
||||||
" subset = df[df['sessionId'] == session_id] # not .where(...)\n",
|
|
||||||
" P, labels = build_transition_prob_matrix(subset)\n",
|
|
||||||
" g = render_graph(f\"session_{session_id}\", P, ls_index=labels, threshold=0.01, fmt=\"svg\", view=False)\n",
|
|
||||||
" display(g)\n",
|
|
||||||
" return P\n",
|
|
||||||
"for session in sessions:\n",
|
|
||||||
" print(explore_session(session))"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cell_type": "code",
|
|
||||||
"execution_count": null,
|
|
||||||
"id": "4d278c2d-406e-4dc0-b219-5f7b236e852b",
|
|
||||||
"metadata": {},
|
|
||||||
"outputs": [],
|
|
||||||
"source": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"metadata": {
|
|
||||||
"kernelspec": {
|
|
||||||
"display_name": "Python (PHANTOM)",
|
|
||||||
"language": "python",
|
|
||||||
"name": "phantom"
|
|
||||||
},
|
|
||||||
"language_info": {
|
|
||||||
"codemirror_mode": {
|
|
||||||
"name": "ipython",
|
|
||||||
"version": 3
|
|
||||||
},
|
|
||||||
"file_extension": ".py",
|
|
||||||
"mimetype": "text/x-python",
|
|
||||||
"name": "python",
|
|
||||||
"nbconvert_exporter": "python",
|
|
||||||
"pygments_lexer": "ipython3",
|
|
||||||
"version": "3.13.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nbformat": 4,
|
|
||||||
"nbformat_minor": 5
|
|
||||||
}
|
|
||||||
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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
|||||||
|
torch
|
||||||
|
tensorboard
|
||||||
|
fastparquet
|
||||||
|
pyarrow
|
||||||
|
xgboost
|
||||||
|
lightgbm
|
||||||
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
@@ -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)
|
||||||
51
experiments/procesing/__init__.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import DataProvider, SupabaseProvider, BackendAPIProvider
|
||||||
|
from procesing.steps import (
|
||||||
|
BaseContextStep,
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
# StateSpace,
|
||||||
|
# BuildStateSpaceStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
)
|
||||||
|
from procesing.pipelines import (
|
||||||
|
interaction_extraction_pipeline,
|
||||||
|
price_extraction_pipeline,
|
||||||
|
pricing_pipeline,
|
||||||
|
full_pipeline,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PipelineContext',
|
||||||
|
'DataProvider',
|
||||||
|
'SupabaseProvider',
|
||||||
|
'BackendAPIProvider',
|
||||||
|
'BaseContextStep',
|
||||||
|
'FetchInteractionsStep',
|
||||||
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
# 'StateSpace',
|
||||||
|
# 'BuildStateSpaceStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'interaction_extraction_pipeline',
|
||||||
|
'price_extraction_pipeline',
|
||||||
|
'pricing_pipeline',
|
||||||
|
'full_pipeline',
|
||||||
|
]
|
||||||
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
|
||||||
34
experiments/procesing/context.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
from typing import Any, Dict
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class PipelineContext:
|
||||||
|
"""
|
||||||
|
Context for pipeline execution holding config, provider, and cached data.
|
||||||
|
Enables dependency injection and eliminates global state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
provider: DataProvider,
|
||||||
|
store_mode: str,
|
||||||
|
window_size: str = '30s',
|
||||||
|
**config):
|
||||||
|
self.provider = provider
|
||||||
|
self.store_mode = store_mode
|
||||||
|
self.window_size = window_size
|
||||||
|
self.config = config
|
||||||
|
self._cache: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def get_cached(self, key: str, default=None):
|
||||||
|
return self._cache.get(key, default)
|
||||||
|
|
||||||
|
def cache(self, key: str, value):
|
||||||
|
self._cache[key] = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def products(self) -> pd.DataFrame:
|
||||||
|
"""Lazy-load and cache product catalog, single fetch per pipeline run"""
|
||||||
|
if 'products' not in self._cache:
|
||||||
|
self._cache['products'] = self.provider.fetch_products(self.store_mode)
|
||||||
|
return self._cache['products']
|
||||||
332
experiments/procesing/elasticity.py
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from supabase import create_client, Client
|
||||||
|
import os
|
||||||
|
|
||||||
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||||
|
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||||
|
|
||||||
|
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||||
|
|
||||||
|
class TemporalElasticityEstimator(BaseEstimator, TransformerMixin):
|
||||||
|
"""
|
||||||
|
Compute price elasticity from time-series demand and price data.
|
||||||
|
|
||||||
|
Elasticity = (% change in quantity) / (% change in price)
|
||||||
|
|
||||||
|
Works with chunked time-window data from ChunkInteractionsIntoSteps.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
method:str='point',
|
||||||
|
min_observations:int=2,
|
||||||
|
smooth_window:Optional[int]=None):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
method: 'point' (point elasticity) or 'arc' (arc elasticity)
|
||||||
|
min_observations: min data points needed per product
|
||||||
|
smooth_window: if set, apply rolling avg smoothing to time series
|
||||||
|
"""
|
||||||
|
self.method = method
|
||||||
|
self.min_observations = min_observations
|
||||||
|
self.smooth_window = smooth_window
|
||||||
|
|
||||||
|
def fit(self, X):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self,
|
||||||
|
demand_chunks: List[Dict],
|
||||||
|
price_chunks: List[Dict],
|
||||||
|
store_mode: str = 'hotel') -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
demand_chunks: list from ChunkInteractionsIntoSteps + DemandEstimator
|
||||||
|
each item: {'window_start', 'window_end', 'demand_vector'}
|
||||||
|
price_chunks: list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||||
|
store_mode: 'hotel' or 'airline' to fetch all products
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
df with [productId, elasticity, std_error, n_observations]
|
||||||
|
"""
|
||||||
|
# fetch all products from database
|
||||||
|
all_products = supabase.table(f'{store_mode}_products').select("id").execute()
|
||||||
|
all_product_ids = [p['id'] for p in all_products.data]
|
||||||
|
|
||||||
|
aligned = self._align_chunks(demand_chunks, price_chunks)
|
||||||
|
if not aligned:
|
||||||
|
# return all products with zero elasticity
|
||||||
|
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_product_timeseries(aligned)
|
||||||
|
|
||||||
|
# compute elasticity per product
|
||||||
|
elasticities = []
|
||||||
|
for pid, series in product_series.items():
|
||||||
|
if len(series) < self.min_observations:
|
||||||
|
# assign 0 elasticity for products with insufficient data
|
||||||
|
elasticities.append({
|
||||||
|
'productId': pid,
|
||||||
|
'elasticity': 0.0,
|
||||||
|
'std_error': 0.0,
|
||||||
|
'n_obs': len(series)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# apply smoothing if requested
|
||||||
|
if self.smooth_window and len(series) >= self.smooth_window:
|
||||||
|
series = self._smooth_series(series, self.smooth_window)
|
||||||
|
|
||||||
|
elast = self._compute_elasticity(series)
|
||||||
|
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 in missing products with zero elasticity
|
||||||
|
observed_pids = set(result_df['productId'].unique())
|
||||||
|
missing_pids = [pid for pid in all_product_ids if pid 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, price_chunks):
|
||||||
|
"""Align demand and price data by matching time windows."""
|
||||||
|
aligned = []
|
||||||
|
|
||||||
|
# create lookup for price chunks by window_start
|
||||||
|
price_lookup = {chunk['window_start']: chunk for chunk in price_chunks}
|
||||||
|
|
||||||
|
for demand_chunk in demand_chunks:
|
||||||
|
window_start = demand_chunk['window_start']
|
||||||
|
if window_start in price_lookup:
|
||||||
|
aligned.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': demand_chunk['window_end'],
|
||||||
|
'demand': demand_chunk['demand_vector'],
|
||||||
|
'prices': price_lookup[window_start]['price_vector']
|
||||||
|
})
|
||||||
|
|
||||||
|
return aligned
|
||||||
|
|
||||||
|
def _build_product_timeseries(self, aligned_chunks):
|
||||||
|
"""Build time series [price, quantity] per product."""
|
||||||
|
# vectorize chunk merging instead of iterating rows
|
||||||
|
all_merged = []
|
||||||
|
for chunk in aligned_chunks:
|
||||||
|
merged = chunk['demand'].merge(chunk['prices'], on='productId', how='inner')
|
||||||
|
merged['timestamp'] = chunk['window_start']
|
||||||
|
all_merged.append(merged[['productId', 'timestamp', 'price', 'demand_score']])
|
||||||
|
|
||||||
|
if not all_merged:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# concat all chunks and group by productId in one pass
|
||||||
|
combined = pd.concat(all_merged, ignore_index=True)
|
||||||
|
series_by_product = {
|
||||||
|
pid: group[['timestamp', 'price', 'demand_score']].rename(
|
||||||
|
columns={'demand_score': 'quantity'}
|
||||||
|
).to_dict('records')
|
||||||
|
for pid, group in combined.groupby('productId')
|
||||||
|
}
|
||||||
|
|
||||||
|
return series_by_product
|
||||||
|
|
||||||
|
def _smooth_series(self, series, window):
|
||||||
|
"""Apply rolling average smoothing."""
|
||||||
|
df = pd.DataFrame(series)
|
||||||
|
df['price_smooth'] = df['price'].rolling(window=window, center=True).mean()
|
||||||
|
df['quantity_smooth'] = df['quantity'].rolling(window=window, center=True).mean()
|
||||||
|
df = df.dropna()
|
||||||
|
|
||||||
|
return [{'timestamp': row['timestamp'],
|
||||||
|
'price': row['price_smooth'],
|
||||||
|
'quantity': row['quantity_smooth']}
|
||||||
|
for _, row in df.iterrows()]
|
||||||
|
|
||||||
|
def _compute_elasticity(self, series):
|
||||||
|
"""Compute elasticity from time series."""
|
||||||
|
if len(series) < 2:
|
||||||
|
return {'value': 0.0, 'std_error': 0.0}
|
||||||
|
|
||||||
|
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 self.method == 'point':
|
||||||
|
return self._point_elasticity(prices, quantities)
|
||||||
|
elif self.method == 'arc':
|
||||||
|
return self._arc_elasticity(prices, quantities)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown method: {self.method}")
|
||||||
|
|
||||||
|
def _point_elasticity(self, prices, quantities):
|
||||||
|
"""
|
||||||
|
Point elasticity using 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)
|
||||||
|
|
||||||
|
# simple linear regression
|
||||||
|
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 (avoid div by zero)
|
||||||
|
if len(prices) <= 2:
|
||||||
|
se_b = 0.0
|
||||||
|
else:
|
||||||
|
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))
|
||||||
|
|
||||||
|
return {'value': b, 'std_error': se_b}
|
||||||
|
|
||||||
|
def _arc_elasticity(self, prices, quantities):
|
||||||
|
"""
|
||||||
|
Arc elasticity: average of period-over-period elasticities.
|
||||||
|
E_t = (ΔQ/Q_avg) / (ΔP/P_avg)
|
||||||
|
"""
|
||||||
|
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 None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'value': np.mean(elasticities),
|
||||||
|
'std_error': np.std(elasticities) / np.sqrt(len(elasticities))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def aggregate_price_logs(price_logs: pd.DataFrame,
|
||||||
|
window_size: str = '1H',
|
||||||
|
ts_col: str = 'ts',
|
||||||
|
store_mode : str = 'hotel') -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Recover price vectors treating prices as persistent state changes.
|
||||||
|
|
||||||
|
Prices are set-operations that persist until next change. For each window:
|
||||||
|
- If price logs exist: average all changes within window
|
||||||
|
- If no logs: carry forward last price before window end
|
||||||
|
|
||||||
|
Args:
|
||||||
|
price_logs: df with [productId, price, ts, ...]
|
||||||
|
window_size: time window size matching ChunkInteractionsIntoSteps
|
||||||
|
ts_col: timestamp column name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of dicts with {'window_start', 'window_end', 'price_vector'}
|
||||||
|
where price_vector is df with [productId, price]
|
||||||
|
"""
|
||||||
|
if price_logs.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = price_logs.copy()
|
||||||
|
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values([ts_col, 'productId'])
|
||||||
|
all_products=supabase.table(f'{store_mode}_products').select("id, room_type, date_index, metadata, availability").execute()
|
||||||
|
all_products = pd.DataFrame(all_products.data)
|
||||||
|
unique_products = all_products['id'].unique()
|
||||||
|
|
||||||
|
# generate windows across data range
|
||||||
|
min_time, max_time = df[ts_col].min(), df[ts_col].max()
|
||||||
|
windows = pd.date_range(
|
||||||
|
start=min_time.floor(window_size),
|
||||||
|
end=max_time,
|
||||||
|
freq=window_size
|
||||||
|
)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
|
||||||
|
for window_start in windows:
|
||||||
|
window_end = window_start + pd.Timedelta(window_size)
|
||||||
|
price_vector = []
|
||||||
|
|
||||||
|
# all products with price history by window_end
|
||||||
|
#historical_products = df[df[ts_col] < window_end]['productId'].unique()
|
||||||
|
historical_products = unique_products.tolist()
|
||||||
|
|
||||||
|
for pid in historical_products:
|
||||||
|
product_data = df[df['productId'] == pid]
|
||||||
|
|
||||||
|
# logs within window
|
||||||
|
in_window = product_data[
|
||||||
|
(product_data[ts_col] >= window_start) &
|
||||||
|
(product_data[ts_col] < window_end)
|
||||||
|
]
|
||||||
|
|
||||||
|
if not in_window.empty:
|
||||||
|
# average changes within window
|
||||||
|
price = in_window['price'].mean()
|
||||||
|
else:
|
||||||
|
# carry forward: last price before window end
|
||||||
|
before_window = product_data[product_data[ts_col] < window_end]
|
||||||
|
if before_window.empty:
|
||||||
|
continue
|
||||||
|
price = before_window['price'].iloc[-1]
|
||||||
|
|
||||||
|
price_vector.append({'productId': pid, 'price': price})
|
||||||
|
|
||||||
|
if price_vector:
|
||||||
|
chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_end,
|
||||||
|
'price_vector': pd.DataFrame(price_vector)
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
245
experiments/procesing/metrics.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
"""
|
||||||
|
Revenue and KPI benchmark framework for pricing strategies.
|
||||||
|
|
||||||
|
Computes session-level and aggregate metrics to compare pricing functions:
|
||||||
|
- Revenue: R_T = Σ P_t^T · Q_t
|
||||||
|
- Conversion rate
|
||||||
|
- Average order value (AOV)
|
||||||
|
- Agent exploitation loss: L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
from typing import Dict, List, Any, Optional
|
||||||
|
from dataclasses import dataclass, field, asdict
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionMetrics:
|
||||||
|
"""KPIs for single session."""
|
||||||
|
session_id: str
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
|
||||||
|
# interaction metrics
|
||||||
|
total_interactions: int = 0
|
||||||
|
page_views: int = 0
|
||||||
|
item_views: int = 0
|
||||||
|
searches: int = 0
|
||||||
|
cart_adds: int = 0
|
||||||
|
|
||||||
|
# revenue metrics
|
||||||
|
items_purchased: int = 0
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_item_price: float = 0.0
|
||||||
|
conversion_rate: float = 0.0
|
||||||
|
|
||||||
|
# pricing signals
|
||||||
|
total_price_shown: float = 0.0 # sum of all prices displayed
|
||||||
|
avg_markup: float = 0.0 # avg (price / base_price)
|
||||||
|
|
||||||
|
# behavioral features (for agent detection)
|
||||||
|
interaction_velocity: float = 0.0 # interactions per minute
|
||||||
|
session_duration_sec: float = 0.0
|
||||||
|
unique_products_viewed: int = 0
|
||||||
|
|
||||||
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AggregateMetrics:
|
||||||
|
"""Aggregate KPIs across sessions/experiments."""
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
n_sessions: int = 0
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
total_revenue: float = 0.0
|
||||||
|
avg_revenue_per_session: float = 0.0
|
||||||
|
median_revenue_per_session: float = 0.0
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
total_conversions: int = 0
|
||||||
|
conversion_rate: float = 0.0 # purchases / sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
avg_markup: float = 0.0
|
||||||
|
median_markup: float = 0.0
|
||||||
|
|
||||||
|
# agent exploitation metrics
|
||||||
|
estimated_agent_sessions: int = 0 # sessions flagged as agent-driven
|
||||||
|
agent_revenue: float = 0.0
|
||||||
|
human_revenue: float = 0.0
|
||||||
|
agent_loss: float = 0.0 # L_agent = R_oracle - R_observed (if available)
|
||||||
|
|
||||||
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
|
||||||
|
class MetricsComputer:
|
||||||
|
"""Compute session and aggregate metrics from interaction/price logs."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_session_metrics(
|
||||||
|
session_id: str,
|
||||||
|
interactions: pd.DataFrame,
|
||||||
|
price_logs: pd.DataFrame,
|
||||||
|
purchases: Optional[pd.DataFrame] = None,
|
||||||
|
experiment_id: Optional[str] = None
|
||||||
|
) -> SessionMetrics:
|
||||||
|
"""
|
||||||
|
Compute metrics for single session.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: session identifier
|
||||||
|
interactions: user-interactions events for this session
|
||||||
|
price_logs: price-logs for this session
|
||||||
|
purchases: purchase events (if available)
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
"""
|
||||||
|
metrics = SessionMetrics(session_id=session_id, experiment_id=experiment_id)
|
||||||
|
|
||||||
|
if interactions.empty:
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
# interaction counts
|
||||||
|
event_counts = interactions['eventName'].value_counts().to_dict()
|
||||||
|
metrics.total_interactions = len(interactions)
|
||||||
|
metrics.page_views = event_counts.get('page_view', 0) + event_counts.get('view_item_page', 0)
|
||||||
|
metrics.item_views = event_counts.get('view_item_page', 0)
|
||||||
|
metrics.searches = event_counts.get('search', 0)
|
||||||
|
metrics.cart_adds = event_counts.get('add_item_to_cart', 0)
|
||||||
|
|
||||||
|
# unique products viewed
|
||||||
|
metrics.unique_products_viewed = interactions['productId'].dropna().nunique()
|
||||||
|
|
||||||
|
# session duration
|
||||||
|
if 'ts' in interactions.columns:
|
||||||
|
timestamps = pd.to_datetime(interactions['ts'])
|
||||||
|
metrics.session_duration_sec = (timestamps.max() - timestamps.min()).total_seconds()
|
||||||
|
if metrics.session_duration_sec > 0:
|
||||||
|
metrics.interaction_velocity = (metrics.total_interactions / metrics.session_duration_sec) * 60
|
||||||
|
|
||||||
|
# revenue from purchases
|
||||||
|
if purchases is not None and not purchases.empty:
|
||||||
|
metrics.items_purchased = len(purchases)
|
||||||
|
metrics.total_revenue = purchases['price'].sum() if 'price' in purchases.columns else 0.0
|
||||||
|
metrics.avg_item_price = metrics.total_revenue / metrics.items_purchased if metrics.items_purchased > 0 else 0.0
|
||||||
|
metrics.conversion_rate = 1.0 if metrics.items_purchased > 0 else 0.0
|
||||||
|
|
||||||
|
# pricing metrics
|
||||||
|
if not price_logs.empty:
|
||||||
|
metrics.total_price_shown = price_logs['price'].sum()
|
||||||
|
# compute markup if base_price available in price logs or join with product catalog
|
||||||
|
if 'base_price' in price_logs.columns:
|
||||||
|
valid_markup = price_logs[price_logs['base_price'] > 0]
|
||||||
|
if not valid_markup.empty:
|
||||||
|
metrics.avg_markup = (valid_markup['price'] / valid_markup['base_price']).mean()
|
||||||
|
|
||||||
|
return metrics
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compute_aggregate_metrics(
|
||||||
|
session_metrics_list: List[SessionMetrics],
|
||||||
|
experiment_id: Optional[str] = None,
|
||||||
|
agent_detector_fn: Optional[callable] = None
|
||||||
|
) -> AggregateMetrics:
|
||||||
|
"""
|
||||||
|
Aggregate metrics across sessions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics_list: list of SessionMetrics
|
||||||
|
experiment_id: experiment identifier
|
||||||
|
agent_detector_fn: optional function to classify session as agent (returns bool)
|
||||||
|
"""
|
||||||
|
agg = AggregateMetrics(experiment_id=experiment_id)
|
||||||
|
agg.n_sessions = len(session_metrics_list)
|
||||||
|
|
||||||
|
if agg.n_sessions == 0:
|
||||||
|
return agg
|
||||||
|
|
||||||
|
df = pd.DataFrame([m.to_dict() for m in session_metrics_list])
|
||||||
|
|
||||||
|
# revenue aggregates
|
||||||
|
agg.total_revenue = df['total_revenue'].sum()
|
||||||
|
agg.avg_revenue_per_session = df['total_revenue'].mean()
|
||||||
|
agg.median_revenue_per_session = df['total_revenue'].median()
|
||||||
|
|
||||||
|
# conversion aggregates
|
||||||
|
agg.total_conversions = (df['items_purchased'] > 0).sum()
|
||||||
|
agg.conversion_rate = agg.total_conversions / agg.n_sessions
|
||||||
|
|
||||||
|
# pricing aggregates
|
||||||
|
valid_markups = df[df['avg_markup'] > 0]
|
||||||
|
if not valid_markups.empty:
|
||||||
|
agg.avg_markup = valid_markups['avg_markup'].mean()
|
||||||
|
agg.median_markup = valid_markups['avg_markup'].median()
|
||||||
|
|
||||||
|
# agent detection (if detector provided)
|
||||||
|
if agent_detector_fn is not None:
|
||||||
|
agent_flags = [agent_detector_fn(m) for m in session_metrics_list]
|
||||||
|
agg.estimated_agent_sessions = sum(agent_flags)
|
||||||
|
|
||||||
|
agent_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if is_agent)
|
||||||
|
human_revenue = sum(m.total_revenue for m, is_agent in zip(session_metrics_list, agent_flags) if not is_agent)
|
||||||
|
|
||||||
|
agg.agent_revenue = agent_revenue
|
||||||
|
agg.human_revenue = human_revenue
|
||||||
|
|
||||||
|
return agg
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def compare_pricing_strategies(
|
||||||
|
experiments: Dict[str, List[SessionMetrics]],
|
||||||
|
baseline_experiment_id: Optional[str] = None
|
||||||
|
) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Compare multiple pricing strategies/experiments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
experiments: dict mapping experiment_id -> list of SessionMetrics
|
||||||
|
baseline_experiment_id: experiment to use as baseline for comparison
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with comparative metrics
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
baseline_agg = None
|
||||||
|
|
||||||
|
for exp_id, session_metrics in experiments.items():
|
||||||
|
agg = MetricsComputer.compute_aggregate_metrics(session_metrics, experiment_id=exp_id)
|
||||||
|
result = agg.to_dict()
|
||||||
|
|
||||||
|
if exp_id == baseline_experiment_id:
|
||||||
|
baseline_agg = agg
|
||||||
|
|
||||||
|
results.append(result)
|
||||||
|
|
||||||
|
df = pd.DataFrame(results)
|
||||||
|
|
||||||
|
# add relative metrics if baseline exists
|
||||||
|
if baseline_agg is not None:
|
||||||
|
df['revenue_lift_pct'] = ((df['total_revenue'] - baseline_agg.total_revenue) / baseline_agg.total_revenue * 100)
|
||||||
|
df['conversion_lift_pct'] = ((df['conversion_rate'] - baseline_agg.conversion_rate) / baseline_agg.conversion_rate * 100)
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def simple_agent_detector(session_metrics: SessionMetrics, velocity_threshold: float = 5.0) -> bool:
|
||||||
|
"""
|
||||||
|
Simple heuristic agent detector based on interaction velocity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_metrics: SessionMetrics instance
|
||||||
|
velocity_threshold: interactions per minute threshold (default: 5.0)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if session likely agent-driven
|
||||||
|
"""
|
||||||
|
# agents tend to have higher interaction velocity and lower session duration
|
||||||
|
if session_metrics.interaction_velocity > velocity_threshold:
|
||||||
|
return True
|
||||||
|
# agents often view many products quickly without converting
|
||||||
|
if session_metrics.unique_products_viewed > 10 and session_metrics.conversion_rate == 0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
174
experiments/procesing/pipelines.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
from sklearn.pipeline import Pipeline
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from procesing.providers import SupabaseProvider, BackendAPIProvider
|
||||||
|
import os
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
JoinExperimentsStep,
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep,
|
||||||
|
ChunkByTimeWindowStep,
|
||||||
|
ComputeDemandForChunksStep,
|
||||||
|
AggregatePriceLogsStep,
|
||||||
|
FitPricingFunctionStep,
|
||||||
|
PredictPricesStep,
|
||||||
|
ComputeDemandStep,
|
||||||
|
JoinProductFeaturesStep,
|
||||||
|
ExtractSessionFeaturesStep,
|
||||||
|
JoinLabelsStep,
|
||||||
|
ValidateDataStep,
|
||||||
|
)
|
||||||
|
from procesing.pricers import SimpleSurgePricer
|
||||||
|
|
||||||
|
def interaction_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting and augmenting interaction data"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchInteractionsStep(context)),
|
||||||
|
('create_buckets', CreatePriceBucketsStep(context)),
|
||||||
|
('augment_events', AugmentEventNamesStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def price_extraction_pipeline(context: PipelineContext):
|
||||||
|
"""Pipeline for extracting price logs"""
|
||||||
|
return Pipeline([
|
||||||
|
('fetch', FetchPriceLogsStep(context)),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def product_features_pipeline(context: PipelineContext,
|
||||||
|
interactions_df: pd.DataFrame,
|
||||||
|
price_logs_df: pd.DataFrame):
|
||||||
|
demand_step = ComputeDemandStep(context)
|
||||||
|
price_step = AggregatePriceLogsStep(context)
|
||||||
|
join_step = JoinProductFeaturesStep(context)
|
||||||
|
|
||||||
|
|
||||||
|
demand_data = demand_step.transform(interactions_df)
|
||||||
|
price_data= price_step.transform(price_logs_df)
|
||||||
|
joined_data = join_step.transform((demand_data, price_data))
|
||||||
|
|
||||||
|
return joined_data
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def pricing_pipeline(context: "PipelineContext",
|
||||||
|
data: pd.DataFrame,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9) -> pd.DataFrame:
|
||||||
|
|
||||||
|
if data.empty or 'productId' not in data.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
surge_pricer = SimpleSurgePricer()
|
||||||
|
surge_pricer.fit(data)
|
||||||
|
data['optimal_price'] = surge_pricer.predict()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def full_pipeline(context: PipelineContext,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
"""
|
||||||
|
Complete end-to-end pipeline: data extraction -> demand/price aggregation -> surge pricing
|
||||||
|
|
||||||
|
Args:
|
||||||
|
context: Pipeline context
|
||||||
|
high_threshold: Demand threshold for surge pricing
|
||||||
|
low_threshold: Demand threshold for discounts
|
||||||
|
surge_multiplier: Price multiplier for high demand
|
||||||
|
discount_multiplier: Price multiplier for low demand
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (product_features_df, optimal_prices_df)
|
||||||
|
- product_features_df: [productId, demand_score, price]
|
||||||
|
- optimal_prices_df: [productId, current_price, optimal_price, demand_score]
|
||||||
|
"""
|
||||||
|
interaction_pipe = interaction_extraction_pipeline(context)
|
||||||
|
price_pipe = price_extraction_pipeline(context)
|
||||||
|
|
||||||
|
interactions_df = interaction_pipe.fit_transform(None)
|
||||||
|
price_logs_df = price_pipe.fit_transform(None)
|
||||||
|
product_features_df = product_features_pipeline(context, interactions_df, price_logs_df)
|
||||||
|
print(product_features_df.to_string())
|
||||||
|
|
||||||
|
# generate optimal prices using surge rules
|
||||||
|
optimal_prices_df = pricing_pipeline(context, product_features_df,
|
||||||
|
high_threshold=high_threshold,
|
||||||
|
low_threshold=low_threshold,
|
||||||
|
surge_multiplier=surge_multiplier,
|
||||||
|
discount_multiplier=discount_multiplier)
|
||||||
|
|
||||||
|
return product_features_df, optimal_prices_df
|
||||||
|
|
||||||
|
|
||||||
|
def ml_training_pipeline(context: PipelineContext) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Build labeled session-level feature matrix for ML model training.
|
||||||
|
Pipeline: fetch -> validate -> extract features -> join labels
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DataFrame with ~25 features per session + is_agent label
|
||||||
|
Columns: sessionId, experimentId, temporal/behavioral/product/ua features, is_agent
|
||||||
|
"""
|
||||||
|
# fetch raw interactions
|
||||||
|
interactions_df = FetchInteractionsStep(context).transform(None)
|
||||||
|
|
||||||
|
# validate data quality (report cached in context)
|
||||||
|
interactions_df = ValidateDataStep(context).transform(interactions_df)
|
||||||
|
if interactions_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# extract vectorized session features
|
||||||
|
features_df = ExtractSessionFeaturesStep(context).transform(interactions_df)
|
||||||
|
if features_df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
# join experiment labels (is_agent = ~xp_human_only)
|
||||||
|
labeled_df = JoinLabelsStep(context).transform(features_df)
|
||||||
|
|
||||||
|
return labeled_df
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
|
||||||
|
class ExperimentsProvider(SupabaseProvider, BackendAPIProvider):
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
base_path = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/" # os.path.join(os.path.dirname(__file__), "collected_data")
|
||||||
|
if not os.path.isdir(base_path):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
files = {"user-interactions": "int.json", "price-logs": "price.json"}
|
||||||
|
file_to_read = files.get(topic, files["user-interactions"])
|
||||||
|
frames = []
|
||||||
|
|
||||||
|
for d in os.listdir(base_path):
|
||||||
|
full_path = os.path.join(base_path, d, file_to_read)
|
||||||
|
if not os.path.isfile(full_path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = pd.read_json(full_path)
|
||||||
|
payloads = pd.DataFrame([r['payload'] for r in data['value'].to_list()])
|
||||||
|
frames.append(payloads)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Could not process {full_path}: {e}")
|
||||||
|
|
||||||
|
return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
|
||||||
|
|
||||||
|
# demo: run ML training pipeline
|
||||||
|
context = PipelineContext(provider=ExperimentsProvider(), store_mode='hotel')
|
||||||
|
features = ml_training_pipeline(context)
|
||||||
|
print(f"Feature matrix: {features.shape}")
|
||||||
|
print(features.head())
|
||||||
|
print(features.info())
|
||||||
|
|
||||||
|
features.to_parquet("features.parquet")
|
||||||
14
experiments/procesing/pricers/__init__.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
from procesing.pricers.simple import StaticPricer, RandomPricer, SimpleSurgePricer
|
||||||
|
from procesing.pricers.session_aware import SessionAwarePricer, ProductSpecificSessionPricer
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'PricingFunction',
|
||||||
|
'ElasticityBasedPricer',
|
||||||
|
'StaticPricer',
|
||||||
|
'RandomPricer',
|
||||||
|
'SimpleSurgePricer',
|
||||||
|
'SessionAwarePricer',
|
||||||
|
'ProductSpecificSessionPricer'
|
||||||
|
]
|
||||||
67
experiments/procesing/pricers/base.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
|
class PricingFunction(ABC):
|
||||||
|
"""
|
||||||
|
Abstract base for pricing functions.
|
||||||
|
Objective:
|
||||||
|
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||||
|
subject to:
|
||||||
|
Q_t = g(P_t, S_t) (demand response via elasticity)
|
||||||
|
P_t ≥ C (cost floor)
|
||||||
|
minimize L_agent = R_oracle - R_observed
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fit(self, *kwargs):
|
||||||
|
"""
|
||||||
|
Offline training on historical data.
|
||||||
|
This is where we can think about some maximization of expected revenue
|
||||||
|
over historical trajectories to learn parameters of the pricing function.
|
||||||
|
(This however we cover move in the RL side of things)
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def predict(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Generate optimal prices given current state.
|
||||||
|
This is an abstract method that transitions from τ -> P*
|
||||||
|
which is the mapping from the trajectory to optimal prices under
|
||||||
|
some subset of session grouping (so, per sessionId)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _get_features(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Extract features from trajectory for pricing decision.
|
||||||
|
Returns:
|
||||||
|
np.ndarray of shape (n_products, n_features)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, observation: Dict[str, Any]):
|
||||||
|
"""
|
||||||
|
Online learning update (optional).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
observation: dict with {state, action, reward, next_state}
|
||||||
|
- state: StateSpace before pricing decision
|
||||||
|
- action: prices shown (P_t)
|
||||||
|
- reward: revenue/conversion signal
|
||||||
|
- next_state: StateSpace after user interaction
|
||||||
|
"""
|
||||||
|
pass # default: no online learning
|
||||||
|
|
||||||
|
def get_params(self) -> Dict[str, Any]:
|
||||||
|
"""Return pricing function parameters for serialization."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def set_params(self, params: Dict[str, Any]):
|
||||||
|
"""Load pricing function parameters from dict."""
|
||||||
|
pass
|
||||||
69
experiments/procesing/pricers/elasticity.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Pricing based on demand elasticity estimates.
|
||||||
|
f(Q, S) = base_price * (1 + alpha * elasticity * demand_deviation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, alpha: float = 0.1, price_floor: float = 0.0, price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Calibrate from historical elasticity estimates.
|
||||||
|
Expects: [productId, elasticity, base_price, mean_demand]
|
||||||
|
"""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' column")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on demand deviation and elasticity.
|
||||||
|
Higher demand -> increase price (but less for elastic goods)
|
||||||
|
"""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
if len(demand) != len(self.elasticity):
|
||||||
|
raise ValueError(f"Demand vector size {len(demand)} != elasticity size {len(self.elasticity)}")
|
||||||
|
|
||||||
|
# compute demand deviation from mean
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
|
||||||
|
# adjust price: if demand high and elastic, don't increase much
|
||||||
|
# if demand high and inelastic, increase more
|
||||||
|
price_multiplier = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
prices = self.base_prices * price_multiplier
|
||||||
|
|
||||||
|
# enforce bounds
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 3))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||||
211
experiments/procesing/pricers/session_aware.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""
|
||||||
|
Session-aware pricing functions that leverage behavioral features S_t.
|
||||||
|
These pricers aim to minimize L_agent = R_oracle - R_observed.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
from procesing.pricers.elasticity import ElasticityBasedPricer
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAwarePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Extends elasticity-based pricing with session behavioral signals.
|
||||||
|
|
||||||
|
f(Q, P, S) = base_price * elasticity_factor * session_factor
|
||||||
|
|
||||||
|
Where session_factor adjusts for:
|
||||||
|
- interaction_velocity (agent detection proxy)
|
||||||
|
- product_view_depth (interest signal)
|
||||||
|
- cart_to_view_ratio (conversion intent)
|
||||||
|
|
||||||
|
Strategy: charge higher prices to suspected agents (high velocity)
|
||||||
|
to recover oracle revenue from reconnaissance sessions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
beta_velocity: float = 0.05,
|
||||||
|
beta_attention: float = 0.03,
|
||||||
|
agent_velocity_threshold: float = 5.0,
|
||||||
|
agent_markup: float = 1.2,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
alpha: elasticity sensitivity
|
||||||
|
beta_velocity: interaction velocity weight
|
||||||
|
beta_attention: product attention weight
|
||||||
|
agent_velocity_threshold: velocity above which to apply agent markup
|
||||||
|
agent_markup: price multiplier for suspected agent sessions
|
||||||
|
price_floor, price_ceil: price bounds
|
||||||
|
"""
|
||||||
|
self.alpha = alpha
|
||||||
|
self.beta_velocity = beta_velocity
|
||||||
|
self.beta_attention = beta_attention
|
||||||
|
self.agent_velocity_threshold = agent_velocity_threshold
|
||||||
|
self.agent_markup = agent_markup
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
# fitted parameters
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
"""Calibrate from historical elasticity data."""
|
||||||
|
if 'elasticity' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate prices with session awareness."""
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base elasticity-driven pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
elasticity_factor = 1 + self.alpha * np.abs(self.elasticity) * demand_dev
|
||||||
|
|
||||||
|
# session-aware adjustments
|
||||||
|
session_factor = np.ones(n_products)
|
||||||
|
|
||||||
|
if not state_space.session_features.empty:
|
||||||
|
sf = state_space.session_features.iloc[0] # single session features
|
||||||
|
|
||||||
|
# agent detection via velocity
|
||||||
|
velocity = sf.get('interaction_velocity', 0.0)
|
||||||
|
if velocity > self.agent_velocity_threshold:
|
||||||
|
# suspected agent: apply markup to recover oracle revenue
|
||||||
|
session_factor *= self.agent_markup
|
||||||
|
|
||||||
|
# attention signal: higher view depth -> user interested -> can charge more
|
||||||
|
view_depth = sf.get('product_view_depth', 0)
|
||||||
|
if view_depth > 0:
|
||||||
|
attention_boost = 1 + self.beta_attention * np.log1p(view_depth)
|
||||||
|
session_factor *= attention_boost
|
||||||
|
|
||||||
|
# cart presence: if user has items in cart, slightly increase prices
|
||||||
|
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||||
|
if cart_to_view > 0.1:
|
||||||
|
session_factor *= (1 + 0.02) # small boost for conversion intent
|
||||||
|
|
||||||
|
prices = self.base_prices * elasticity_factor * session_factor
|
||||||
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
|
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity, demand, and session features"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 5))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# extract session features
|
||||||
|
velocity = 0.0
|
||||||
|
view_depth = 0.0
|
||||||
|
cart_to_view = 0.0
|
||||||
|
|
||||||
|
if not state_space.session_features.empty:
|
||||||
|
sf = state_space.session_features.iloc[0]
|
||||||
|
velocity = sf.get('interaction_velocity', 0.0)
|
||||||
|
view_depth = sf.get('product_view_depth', 0.0)
|
||||||
|
cart_to_view = sf.get('cart_to_view_ratio', 0.0)
|
||||||
|
|
||||||
|
# broadcast session features to all products
|
||||||
|
features = np.column_stack([
|
||||||
|
self.elasticity,
|
||||||
|
demand,
|
||||||
|
np.full(n_products, velocity),
|
||||||
|
np.full(n_products, view_depth),
|
||||||
|
np.full(n_products, cart_to_view)
|
||||||
|
])
|
||||||
|
return features
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSpecificSessionPricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Session-aware pricer with product-specific demand signals.
|
||||||
|
|
||||||
|
Uses S_t to extract per-product interaction counts and adjusts pricing
|
||||||
|
for products the user has already viewed/hovered.
|
||||||
|
|
||||||
|
Strategy: products viewed multiple times = high interest -> price up
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
alpha: float = 0.1,
|
||||||
|
view_boost: float = 0.02,
|
||||||
|
max_view_boost: float = 0.15,
|
||||||
|
price_floor: float = 0.0,
|
||||||
|
price_ceil: float = np.inf):
|
||||||
|
self.alpha = alpha
|
||||||
|
self.view_boost = view_boost
|
||||||
|
self.max_view_boost = max_view_boost
|
||||||
|
self.price_floor = price_floor
|
||||||
|
self.price_ceil = price_ceil
|
||||||
|
|
||||||
|
self.elasticity = None
|
||||||
|
self.base_prices = None
|
||||||
|
self.mean_demand = None
|
||||||
|
self.product_ids = None
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame, **kwargs):
|
||||||
|
if 'elasticity' not in historical_data.columns or 'productId' not in historical_data.columns:
|
||||||
|
raise ValueError("historical_data must contain 'elasticity' and 'productId'")
|
||||||
|
|
||||||
|
self.elasticity = historical_data['elasticity'].values
|
||||||
|
self.base_prices = (historical_data['base_price'].values
|
||||||
|
if 'base_price' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 100)
|
||||||
|
self.mean_demand = (historical_data['mean_demand'].values
|
||||||
|
if 'mean_demand' in historical_data.columns
|
||||||
|
else np.ones(len(historical_data)) * 10)
|
||||||
|
self.product_ids = historical_data['productId'].values
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
if self.elasticity is None:
|
||||||
|
raise ValueError("Must call fit() before predict()")
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
n_products = len(demand)
|
||||||
|
|
||||||
|
# base pricing
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
base_prices = self.base_prices * (1 + self.alpha * np.abs(self.elasticity) * demand_dev)
|
||||||
|
|
||||||
|
# product-specific session adjustments
|
||||||
|
if not state_space.session_features.empty and state_space.product_ids is not None:
|
||||||
|
# extract product interaction counts from session metadata
|
||||||
|
# (this would require session features to include per-product signals)
|
||||||
|
# for now, use uniform boost as placeholder
|
||||||
|
# TODO: extend session feature extraction to include product-specific counts
|
||||||
|
pass
|
||||||
|
|
||||||
|
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||||
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity and demand features for product-specific pricing"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 2))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
return np.column_stack([self.elasticity, demand])
|
||||||
158
experiments/procesing/pricers/simple.py
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.pricers.base import PricingFunction
|
||||||
|
|
||||||
|
|
||||||
|
def session_features_to_demand(session_features: pd.DataFrame) -> float:
|
||||||
|
"""
|
||||||
|
Map session behavioral features to demand proxy.
|
||||||
|
THIS is the critical θ̂ → D transformation for rule-based pricing.
|
||||||
|
|
||||||
|
Logic:
|
||||||
|
- High velocity → agent behavior → price up (revenue recovery)
|
||||||
|
- High cart ratio → purchase intent → price up
|
||||||
|
- Low activity → discount to convert
|
||||||
|
|
||||||
|
Returns: demand proxy score (0-20 range, higher = more demand)
|
||||||
|
"""
|
||||||
|
if session_features.empty:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
feat = session_features.iloc[0] if len(session_features) > 0 else {}
|
||||||
|
|
||||||
|
velocity = feat.get('interaction_velocity', 0)
|
||||||
|
cart_ratio = feat.get('cart_to_view_ratio', 0)
|
||||||
|
item_views = feat.get('item_views', 0)
|
||||||
|
cart_adds = feat.get('cart_adds', 0)
|
||||||
|
|
||||||
|
# baseline demand
|
||||||
|
demand = 1.0
|
||||||
|
|
||||||
|
# agent detection: high velocity → treat as high "demand" to price up
|
||||||
|
if velocity > 2.0:
|
||||||
|
demand += 10.0 # strong agent signal
|
||||||
|
|
||||||
|
# conversion intent: cart interaction → price up
|
||||||
|
if cart_ratio > 0.1 or cart_adds > 0:
|
||||||
|
demand += 5.0
|
||||||
|
|
||||||
|
# browsing depth: many views → interest signal
|
||||||
|
if item_views > 3:
|
||||||
|
demand += min(item_views, 5.0)
|
||||||
|
|
||||||
|
return min(demand, 20.0) # cap at 20
|
||||||
|
|
||||||
|
|
||||||
|
class StaticPricer(PricingFunction):
|
||||||
|
"""Static pricing: always return fixed base prices"""
|
||||||
|
|
||||||
|
def __init__(self, base_prices: np.ndarray = None):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Extract base prices from historical data"""
|
||||||
|
if 'base_price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['base_price'].values
|
||||||
|
elif 'price' in historical_data.columns:
|
||||||
|
self.base_prices = historical_data['price'].values
|
||||||
|
else:
|
||||||
|
raise ValueError("historical_data must contain 'base_price' or 'price' column")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Return static base prices regardless of state"""
|
||||||
|
if self.base_prices is None:
|
||||||
|
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||||
|
return self.base_prices.copy()
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Static pricer uses no features, returns empty array"""
|
||||||
|
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||||
|
return np.zeros((n, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class RandomPricer(PricingFunction):
|
||||||
|
"""Random pricing within bounds (for baseline comparison)"""
|
||||||
|
|
||||||
|
def __init__(self, price_min: float = 50.0, price_max: float = 500.0, seed: int = None):
|
||||||
|
self.price_min = price_min
|
||||||
|
self.price_max = price_max
|
||||||
|
self.seed = seed
|
||||||
|
self.n_products = None
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
|
||||||
|
def fit(self, historical_data: pd.DataFrame):
|
||||||
|
"""Learn number of products"""
|
||||||
|
self.n_products = len(historical_data)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""Generate random prices"""
|
||||||
|
if self.n_products is None:
|
||||||
|
self.n_products = len(state_space.demand)
|
||||||
|
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Random pricer uses no features"""
|
||||||
|
n = self.n_products if self.n_products else 0
|
||||||
|
return np.zeros((n, 0))
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleSurgePricer(PricingFunction):
|
||||||
|
"""
|
||||||
|
Rule-based surge pricer adjusting prices via demand thresholds.
|
||||||
|
Logic: if demand > high_threshold -> surge, if demand < low_threshold -> discount.
|
||||||
|
Simpler and more controllable than curve fitting approaches.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_prices: np.ndarray = None,
|
||||||
|
high_threshold: int = 10,
|
||||||
|
low_threshold: int = 2,
|
||||||
|
surge_multiplier: float = 1.2,
|
||||||
|
discount_multiplier: float = 0.9):
|
||||||
|
self.base_prices = base_prices
|
||||||
|
self.high_threshold = high_threshold
|
||||||
|
self.low_threshold = low_threshold
|
||||||
|
self.surge_multiplier = surge_multiplier
|
||||||
|
self.discount_multiplier = discount_multiplier
|
||||||
|
|
||||||
|
def fit(self, market_data: pd.DataFrame):
|
||||||
|
"""Extract base prices from product catalog or historical averages"""
|
||||||
|
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||||
|
return self
|
||||||
|
|
||||||
|
def predict(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Adjust prices based on current demand using surge rules.
|
||||||
|
state_space.demand: demand proxy per product (from session features)
|
||||||
|
state_space.prices: base prices
|
||||||
|
"""
|
||||||
|
demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
|
||||||
|
base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
|
||||||
|
|
||||||
|
if base is None:
|
||||||
|
base = np.ones(len(demand)) * 99.99
|
||||||
|
|
||||||
|
# ensure float dtype to allow multiplication by float multipliers
|
||||||
|
new_prices = base.astype(np.float64).copy()
|
||||||
|
high_mask = demand >= self.high_threshold
|
||||||
|
new_prices[high_mask] *= self.surge_multiplier
|
||||||
|
|
||||||
|
low_mask = demand <= self.low_threshold
|
||||||
|
new_prices[low_mask] *= self.discount_multiplier
|
||||||
|
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract demand and base price features for each product"""
|
||||||
|
if state_space is None:
|
||||||
|
n = len(self.base_prices) if self.base_prices is not None else 0
|
||||||
|
return np.zeros((n, 2))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand) if hasattr(state_space, 'demand') else np.array([0])
|
||||||
|
base = np.asarray(state_space.prices) if hasattr(state_space, 'prices') else self.base_prices
|
||||||
|
if base is None:
|
||||||
|
base = np.ones(len(demand)) * 99.99
|
||||||
|
|
||||||
|
return np.column_stack([demand, base])
|
||||||
272
experiments/procesing/pricing.py
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
r"""
|
||||||
|
Our state space comes as:
|
||||||
|
$Q_t in R^n$ - our demand at a time t
|
||||||
|
$P_t in R^n$ - prices at time t
|
||||||
|
$S_t$ some form of interaction session features
|
||||||
|
|
||||||
|
This is a single sate which we map under
|
||||||
|
|
||||||
|
$f: (Q, S, H) \to P_{t+1}$
|
||||||
|
|
||||||
|
With:
|
||||||
|
|
||||||
|
$H_t = \{Q_{t-k}, P_{t-k}, S_{t-k}\}$
|
||||||
|
|
||||||
|
|
||||||
|
We can have f be literally anything, analytical or learned or rule based or an RL policy.
|
||||||
|
|
||||||
|
Our goal is to mazimize the expected revenue:
|
||||||
|
|
||||||
|
$E[R_T] = E[\sum_{t=1}^T P_t^T \dot Q_t]$
|
||||||
|
|
||||||
|
subject to Q_t = g(P_t, S_t) : demand response to price (estimated via elasticity) and P_t ≥ C : prices above cost floor and additionally minimizing the following:
|
||||||
|
|
||||||
|
$L_{agent} = R_{oracle} - R_{observed}
|
||||||
|
|
||||||
|
where: R_oracle = revenue if we knew agent intentions (from recon session) and R_observed = revenue under current pricing policy f
|
||||||
|
|
||||||
|
I would start be defning a pricing function interface and standardizing how to train that based on historical data and define how to make it behave for online training (if we do that)
|
||||||
|
|
||||||
|
We also need to develop a solid benchmark with mapping revenue and full KPIs from session interactions to measure differences between different price learning methods
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import os
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
from supabase import create_client, Client
|
||||||
|
|
||||||
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL", "")
|
||||||
|
SUPABASE_KEY = os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY", "")
|
||||||
|
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
||||||
|
|
||||||
|
def expected_revenue(prices: np.ndarray, demand: np.ndarray) -> float:
|
||||||
|
"""Returns: expected revenue R_t = P_t^T * Q_t"""
|
||||||
|
return float(np.dot(prices, demand))
|
||||||
|
|
||||||
|
class StateSpace:
|
||||||
|
def __init__(self,
|
||||||
|
demand : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||||
|
prices : np.ndarray, # at time t, only values (assuming aligned by productId order)
|
||||||
|
session_features : pd.DataFrame):
|
||||||
|
self.demand = demand # Q_t
|
||||||
|
self.prices = prices # P_t
|
||||||
|
self.session_features = session_features # S_t
|
||||||
|
self.history = [] # H_t
|
||||||
|
|
||||||
|
class PricingFunction(BaseEstimator, TransformerMixin, ABC):
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fit(self, historical_data):
|
||||||
|
"""
|
||||||
|
Train the pricing function based on historical data.
|
||||||
|
historical_data: list of StateSpace instances with known outcomes
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Train method must be implemented by subclass.")
|
||||||
|
|
||||||
|
def transform(self, state_space) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Predict the next prices given the current state space.
|
||||||
|
state_space: StateSpace instance
|
||||||
|
Returns: predicted prices P_{t+1}
|
||||||
|
"""
|
||||||
|
raise NotImplementedError("Predict method must be implemented by subclass.")
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleLinearPricingFunction(PricingFunction):
|
||||||
|
def __init__(self, price_sensitivity: float = -0.1):
|
||||||
|
super().__init__()
|
||||||
|
self.price_sensitivity = price_sensitivity
|
||||||
|
|
||||||
|
def fit(self, historical_data):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace) -> np.ndarray:
|
||||||
|
new_prices = state_space.prices + self.price_sensitivity * state_space.demand
|
||||||
|
return np.maximum(new_prices, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ElasticityBasedPricingFunction(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue-maximizing pricing using elasticity estimates.
|
||||||
|
|
||||||
|
For each product, optimal price P* maximizes R = P * Q(P)
|
||||||
|
where Q(P) follows power law: Q(P) = Q_0 * (P/P_0)^ε
|
||||||
|
|
||||||
|
Taking derivative dR/dP = 0 gives optimal markup:
|
||||||
|
P* = P_0 * (1 + 1/ε) if ε < -1 (elastic)
|
||||||
|
|
||||||
|
For inelastic demand (|ε| < 1), we apply bounded markup.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
cost_floor: float = 0.5,
|
||||||
|
max_markup: float = 2.0,
|
||||||
|
min_markup: float = 1.0,
|
||||||
|
inelastic_markup: float = 1.3):
|
||||||
|
super().__init__()
|
||||||
|
self.cost_floor = cost_floor # prices as fraction of base
|
||||||
|
self.max_markup = max_markup # max price = base * max_markup
|
||||||
|
self.min_markup = min_markup # min price = base * min_markup
|
||||||
|
self.inelastic_markup = inelastic_markup # default for |ε| < 1
|
||||||
|
self.elasticity_map = {} # productId -> elasticity
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
elasticity_df: df with [productId, elasticity, std_error, n_obs]
|
||||||
|
"""
|
||||||
|
if elasticity_df is not None and not elasticity_df.empty:
|
||||||
|
self.elasticity_map = dict(zip(
|
||||||
|
elasticity_df['productId'],
|
||||||
|
elasticity_df['elasticity']
|
||||||
|
))
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
state_space: current state (prices = base prices)
|
||||||
|
product_ids: array of productIds aligned with state_space.prices
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
optimized prices P_{t+1}
|
||||||
|
"""
|
||||||
|
base_prices = state_space.prices
|
||||||
|
|
||||||
|
if product_ids is None:
|
||||||
|
# fallback: use positional index as productId (not ideal)
|
||||||
|
product_ids = np.arange(len(base_prices))
|
||||||
|
|
||||||
|
new_prices = np.zeros_like(base_prices)
|
||||||
|
|
||||||
|
for i, (base_p, pid) in enumerate(zip(base_prices, product_ids)):
|
||||||
|
elasticity = self.elasticity_map.get(pid, 0.0)
|
||||||
|
|
||||||
|
if elasticity < -1: # elastic demand
|
||||||
|
# optimal markup: (1 + 1/ε)
|
||||||
|
markup = 1 + (1 / elasticity)
|
||||||
|
optimal_p = base_p * markup
|
||||||
|
elif elasticity > -1 and elasticity < 0: # inelastic
|
||||||
|
# conservative markup
|
||||||
|
optimal_p = base_p * self.inelastic_markup
|
||||||
|
else: # ε ≥ 0 (demand increases with price, or no data)
|
||||||
|
# no elasticity data or anomalous, keep base price
|
||||||
|
optimal_p = base_p
|
||||||
|
|
||||||
|
# apply bounds
|
||||||
|
optimal_p = np.clip(
|
||||||
|
optimal_p,
|
||||||
|
base_p * self.min_markup,
|
||||||
|
base_p * self.max_markup
|
||||||
|
)
|
||||||
|
optimal_p = max(optimal_p, self.cost_floor)
|
||||||
|
|
||||||
|
new_prices[i] = optimal_p
|
||||||
|
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualElasticityPricing(PricingFunction):
|
||||||
|
"""
|
||||||
|
Revenue optimization with contextual adjustments based on session features.
|
||||||
|
|
||||||
|
Combines elasticity-based pricing with surge/demand-based multipliers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
base_pricer: ElasticityBasedPricingFunction = None,
|
||||||
|
demand_sensitivity: float = 0.1,
|
||||||
|
surge_threshold: float = 0.7):
|
||||||
|
super().__init__()
|
||||||
|
self.base_pricer = base_pricer or ElasticityBasedPricingFunction()
|
||||||
|
self.demand_sensitivity = demand_sensitivity
|
||||||
|
self.surge_threshold = surge_threshold
|
||||||
|
|
||||||
|
def fit(self, elasticity_df: pd.DataFrame):
|
||||||
|
self.base_pricer.fit(elasticity_df)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def transform(self, state_space: StateSpace, product_ids: np.ndarray = None) -> np.ndarray:
|
||||||
|
# get base optimal prices from elasticity
|
||||||
|
base_optimal = self.base_pricer.transform(state_space, product_ids)
|
||||||
|
|
||||||
|
# compute surge multiplier from demand
|
||||||
|
if len(state_space.demand) > 0:
|
||||||
|
demand_normalized = state_space.demand / (state_space.demand.max() + 1e-8)
|
||||||
|
surge_multiplier = 1 + self.demand_sensitivity * np.maximum(
|
||||||
|
demand_normalized - self.surge_threshold, 0
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
surge_multiplier = np.ones_like(base_optimal)
|
||||||
|
|
||||||
|
return base_optimal * surge_multiplier
|
||||||
|
|
||||||
|
# Example usage:
|
||||||
|
if __name__ == "__main__":
|
||||||
|
from pipeline import interaction_pipeline, price_data_pipeline, elasticity_pipeline
|
||||||
|
|
||||||
|
store_mode = 'hotel'
|
||||||
|
interaction_data = interaction_pipeline.fit_transform(None)
|
||||||
|
price_data = price_data_pipeline.fit_transform(None)
|
||||||
|
|
||||||
|
elasticity_df = elasticity_pipeline(interaction_data, price_data, window_size="30s", store_mode=store_mode)
|
||||||
|
|
||||||
|
# fetch all products with base prices from database
|
||||||
|
products_resp = supabase.table(f'{store_mode}_products').select("id, metadata").execute()
|
||||||
|
products_df = pd.DataFrame(products_resp.data)
|
||||||
|
|
||||||
|
# extract base_price from metadata
|
||||||
|
products_df['base_price'] = products_df['metadata'].apply(lambda m: m.get('base_price', 0) if isinstance(m, dict) else 0)
|
||||||
|
products_df = products_df.rename(columns={'id': 'productId'})[['productId', 'base_price']]
|
||||||
|
|
||||||
|
# override with logged prices where available
|
||||||
|
if not price_data.empty:
|
||||||
|
if 'ts' in price_data.columns and not pd.api.types.is_datetime64_any_dtype(price_data['ts']):
|
||||||
|
price_data['ts'] = pd.to_datetime(price_data['ts'])
|
||||||
|
|
||||||
|
# get latest logged price per product
|
||||||
|
price_logs_agg = price_data.sort_values('ts').groupby('productId', as_index=False).last()
|
||||||
|
|
||||||
|
# merge: start with all products (base prices), override with logged prices
|
||||||
|
products_df = products_df.merge(
|
||||||
|
price_logs_agg[['productId', 'price']],
|
||||||
|
on='productId',
|
||||||
|
how='left'
|
||||||
|
)
|
||||||
|
products_df['final_price'] = products_df['price'].fillna(products_df['base_price'])
|
||||||
|
else:
|
||||||
|
products_df['final_price'] = products_df['base_price']
|
||||||
|
|
||||||
|
# merge with elasticity
|
||||||
|
if elasticity_df is not None and not elasticity_df.empty:
|
||||||
|
price_data_merged = products_df[['productId', 'final_price']].merge(
|
||||||
|
elasticity_df[['productId', 'elasticity']],
|
||||||
|
on='productId',
|
||||||
|
how='left'
|
||||||
|
).fillna({'elasticity': 0.0})
|
||||||
|
|
||||||
|
prices = price_data_merged['final_price'].values
|
||||||
|
elasticities = price_data_merged['elasticity'].values
|
||||||
|
else:
|
||||||
|
prices = np.array([])
|
||||||
|
elasticities = np.array([])
|
||||||
|
|
||||||
|
print(elasticities)
|
||||||
|
print(prices)
|
||||||
|
|
||||||
|
state_space = StateSpace(
|
||||||
|
demand=elasticities,
|
||||||
|
prices=prices,
|
||||||
|
session_features=interaction_data
|
||||||
|
)
|
||||||
|
|
||||||
|
pricing_function = SimpleLinearPricingFunction(price_sensitivity=-0.05)
|
||||||
|
pricing_function.fit([]) # No training data for simple model
|
||||||
|
predicted_prices = pricing_function.transform(state_space)
|
||||||
|
|
||||||
|
print("Predicted Prices:", predicted_prices)
|
||||||
5
experiments/procesing/providers/__init__.py
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.providers.supabase import SupabaseProvider
|
||||||
|
from procesing.providers.backend import BackendAPIProvider
|
||||||
|
|
||||||
|
__all__ = ['DataProvider', 'SupabaseProvider', 'BackendAPIProvider']
|
||||||
19
experiments/procesing/providers/backend.py
Executable file
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
|
||||||
|
class BackendAPIProvider(DataProvider):
|
||||||
|
"""Concrete backend API implementation"""
|
||||||
|
def __init__(self, backend_url: str = None):
|
||||||
|
self.backend_url = backend_url or os.getenv("BACKEND_URL", "http://localhost:5000")
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
resp = requests.get(f"{self.backend_url}/api/kafka/dump?topic={topic}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
if not data.get('success') or not data.get('data'):
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return pd.DataFrame(data['data'])
|
||||||
21
experiments/procesing/providers/base.py
Executable file
@@ -0,0 +1,21 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import List
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
class DataProvider(ABC):
|
||||||
|
"""Abstract interface for data access, enables DI and testing"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
"""Fetch product catalog for given store mode"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
"""Fetch experiment metadata for given IDs"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
"""Fetch data from Kafka topic via backend API"""
|
||||||
|
pass
|
||||||
42
experiments/procesing/providers/supabase.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import os
|
||||||
|
import pandas as pd
|
||||||
|
import requests
|
||||||
|
from typing import List
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
class SupabaseProvider(DataProvider):
|
||||||
|
"""Concrete Supabase + backend API implementation"""
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
supabase_url: str = None,
|
||||||
|
supabase_key: str = None,):
|
||||||
|
load_dotenv()
|
||||||
|
self.supabase_url = supabase_url or os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
self.supabase_key = supabase_key or os.getenv("NEXT_PUBLIC_SUPABASE_ANON_KEY")
|
||||||
|
self.supabase: Client = create_client(self.supabase_url, self.supabase_key)
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
# hotel uses room_type, airline uses flight_type; select all and normalize
|
||||||
|
resp = self.supabase.table(f'{store_mode}_products').select("*").execute()
|
||||||
|
if not resp.data:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = pd.DataFrame(resp.data)
|
||||||
|
# normalize type column: hotel has room_type, airline has flight_type
|
||||||
|
if 'room_type' in df.columns:
|
||||||
|
df['product_type'] = df['room_type']
|
||||||
|
elif 'flight_type' in df.columns:
|
||||||
|
df['product_type'] = df['flight_type']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if not experiment_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
resp = self.supabase.table('experiments').select(
|
||||||
|
'id, subject_name, xp_human_only, xp_market_mode, xp_task_id, '
|
||||||
|
'task:tasks(task_name, task_description, task_def_of_done)'
|
||||||
|
).in_('id', experiment_ids).execute()
|
||||||
|
|
||||||
|
return pd.DataFrame(resp.data) if resp.data else pd.DataFrame()
|
||||||
39
experiments/procesing/steps/__init__.py
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.steps.fetch import FetchInteractionsStep, FetchPriceLogsStep, FetchExperimentsStep
|
||||||
|
from procesing.steps.join import JoinExperimentsStep, JoinProductFeaturesStep
|
||||||
|
from procesing.steps.augment import CreatePriceBucketsStep, AugmentEventNamesStep, AugmentInteractionsStep
|
||||||
|
from procesing.steps.chunk import ChunkByTimeWindowStep
|
||||||
|
from procesing.steps.demand import ComputeDemandStep, ComputeDemandForChunksStep
|
||||||
|
from procesing.steps.elasticity import AggregatePriceLogsStep
|
||||||
|
from procesing.steps.pricing import FitPricingFunctionStep, PredictPricesStep
|
||||||
|
from procesing.steps.session import (
|
||||||
|
ExtractSessionFeaturesStep, JoinLabelsStep, ValidateDataStep,
|
||||||
|
TemporalFeatureStep, BehavioralFeatureStep, ProductFeatureStep, UserAgentFeatureStep,
|
||||||
|
_extract_features_for_session
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'BaseContextStep',
|
||||||
|
'FetchInteractionsStep',
|
||||||
|
'FetchPriceLogsStep',
|
||||||
|
'FetchExperimentsStep',
|
||||||
|
'JoinExperimentsStep',
|
||||||
|
'JoinProductFeaturesStep',
|
||||||
|
'CreatePriceBucketsStep',
|
||||||
|
'AugmentEventNamesStep',
|
||||||
|
'AugmentInteractionsStep',
|
||||||
|
'ChunkByTimeWindowStep',
|
||||||
|
'ComputeDemandStep',
|
||||||
|
'ComputeDemandForChunksStep',
|
||||||
|
'AggregatePriceLogsStep',
|
||||||
|
'FitPricingFunctionStep',
|
||||||
|
'PredictPricesStep',
|
||||||
|
'ExtractSessionFeaturesStep',
|
||||||
|
'JoinLabelsStep',
|
||||||
|
'ValidateDataStep',
|
||||||
|
'TemporalFeatureStep',
|
||||||
|
'BehavioralFeatureStep',
|
||||||
|
'ProductFeatureStep',
|
||||||
|
'UserAgentFeatureStep',
|
||||||
|
'_extract_features_for_session',
|
||||||
|
]
|
||||||
140
experiments/procesing/steps/augment.py
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentInteractionsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Consolidated step: create price buckets, augment event names, join experiments.
|
||||||
|
Input: (interactions_df, price_logs_df)
|
||||||
|
Output: enriched interactions_df
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
interactions_df, price_logs_df = data
|
||||||
|
|
||||||
|
if interactions_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Step 1: Create price buckets
|
||||||
|
interactions_df = self._create_price_buckets(interactions_df)
|
||||||
|
|
||||||
|
# Step 2: Augment event names
|
||||||
|
interactions_df = self._augment_event_names(interactions_df)
|
||||||
|
|
||||||
|
# Step 3: Join experiments (optional)
|
||||||
|
if 'experimentId' in interactions_df.columns:
|
||||||
|
interactions_df = self._join_experiments(interactions_df)
|
||||||
|
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
def _create_price_buckets(self, df: pd.DataFrame):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
if 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _augment_event_names(self, df: pd.DataFrame):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
|
|
||||||
|
def _join_experiments(self, df: pd.DataFrame):
|
||||||
|
"""Join experiment metadata if experimentId present"""
|
||||||
|
exp_ids = df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return df
|
||||||
|
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids)
|
||||||
|
if experiments_df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
return df.merge(
|
||||||
|
experiments_df,
|
||||||
|
left_on='experimentId',
|
||||||
|
right_on='id',
|
||||||
|
how='left',
|
||||||
|
suffixes=('', '_exp')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePriceBucketsStep(BaseContextStep):
|
||||||
|
"""Create price bucket labels from price data"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty or 'metadata_price' not in df.columns:
|
||||||
|
df['price_bucket'] = ""
|
||||||
|
return df
|
||||||
|
|
||||||
|
n_buckets = self.context.config.get('n_price_buckets', 5)
|
||||||
|
|
||||||
|
if df['metadata_price'].notnull().sum() > 0:
|
||||||
|
try:
|
||||||
|
price_buckets = pd.qcut(
|
||||||
|
df['metadata_price'],
|
||||||
|
q=n_buckets,
|
||||||
|
labels=[f"PB_{i+1}" for i in range(n_buckets)],
|
||||||
|
duplicates='drop'
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
# fallback for insufficient unique values
|
||||||
|
price_buckets = df['metadata_price'].apply(
|
||||||
|
lambda x: f"P_{int(x)}" if pd.notnull(x) else ""
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
price_buckets = pd.Series([""] * len(df), index=df.index)
|
||||||
|
|
||||||
|
df['price_bucket'] = price_buckets
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class AugmentEventNamesStep(BaseContextStep):
|
||||||
|
"""Augment event names with product and price bucket schema"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Create schema: _productId@price_bucket
|
||||||
|
has_product = df.get('productId', pd.Series()).notnull()
|
||||||
|
has_bucket = df.get('price_bucket', pd.Series()).notnull()
|
||||||
|
|
||||||
|
df['metadata_schema'] = np.where(
|
||||||
|
has_product & has_bucket,
|
||||||
|
"_" + df['productId'].astype(str) + "@" + df['price_bucket'].astype(str),
|
||||||
|
""
|
||||||
|
)
|
||||||
|
|
||||||
|
df['eventName'] = df['eventName'] + df['metadata_schema']
|
||||||
|
return df
|
||||||
32
experiments/procesing/steps/base.py
Executable file
@@ -0,0 +1,32 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from sklearn.base import BaseEstimator, TransformerMixin
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
class BaseContextStep(BaseEstimator, TransformerMixin, ABC):
|
||||||
|
"""
|
||||||
|
Base for all pipeline steps.
|
||||||
|
Each step is stateless, context-driven, and performs ONE transformation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, context: PipelineContext):
|
||||||
|
self.context = context
|
||||||
|
|
||||||
|
def fit(self, X=None, y=None):
|
||||||
|
"""Most steps don't need training"""
|
||||||
|
return self
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def transform(self, X) -> Any:
|
||||||
|
"""Transform input using context. Must be implemented by subclass."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_params(self, deep=True):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
return {'context': self.context}
|
||||||
|
|
||||||
|
def set_params(self, **params):
|
||||||
|
"""sklearn compatibility"""
|
||||||
|
if 'context' in params:
|
||||||
|
self.context = params['context']
|
||||||
|
return self
|
||||||
34
experiments/procesing/steps/chunk.py
Executable file
@@ -0,0 +1,34 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ChunkByTimeWindowStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Chunk dataframe into time windows.
|
||||||
|
Returns list of dicts with window metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, df: pd.DataFrame):
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
window_size = self.context.window_size
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values(ts_col)
|
||||||
|
df['_window'] = df[ts_col].dt.floor(window_size)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
for idx, (window_start, group) in enumerate(df.groupby('_window')):
|
||||||
|
chunks.append({
|
||||||
|
'window_start': window_start,
|
||||||
|
'window_end': window_start + pd.Timedelta(window_size),
|
||||||
|
'window_idx': idx,
|
||||||
|
'data': group.drop(columns=['_window'])
|
||||||
|
})
|
||||||
|
|
||||||
|
return chunks
|
||||||
61
experiments/procesing/steps/demand.py
Executable file
@@ -0,0 +1,61 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class ComputeDemandStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Compute demand vector for a single time window or dataframe.
|
||||||
|
Input: single chunk dict OR raw dataframe
|
||||||
|
Output: demand dataframe with [productId, demand_score]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, chunk):
|
||||||
|
# handle both chunk dict and raw dataframe
|
||||||
|
if isinstance(chunk, dict):
|
||||||
|
interactions = chunk['data']
|
||||||
|
window_meta = {k: v for k, v in chunk.items() if k != 'data'}
|
||||||
|
else:
|
||||||
|
interactions = chunk
|
||||||
|
window_meta = {}
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
# apply filters if configured
|
||||||
|
session_filter = self.context.config.get('session_filter')
|
||||||
|
experiment_filter = self.context.config.get('experiment_filter')
|
||||||
|
|
||||||
|
if session_filter and 'sessionId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['sessionId'] == session_filter]
|
||||||
|
if experiment_filter and 'experimentId' in interactions.columns:
|
||||||
|
interactions = interactions[interactions['experimentId'] == experiment_filter]
|
||||||
|
|
||||||
|
interactions_with_products = interactions.dropna(subset=['productId'])
|
||||||
|
|
||||||
|
if interactions_with_products.empty:
|
||||||
|
demand_df = pd.DataFrame({
|
||||||
|
'productId': unique_products,
|
||||||
|
'demand_score': 0
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# crosstab for simple demand count
|
||||||
|
demand_df = pd.crosstab(
|
||||||
|
interactions_with_products['productId'],
|
||||||
|
'count'
|
||||||
|
).reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
demand_df.columns = ['productId', 'demand_score']
|
||||||
|
|
||||||
|
# attach window metadata if present
|
||||||
|
if window_meta:
|
||||||
|
return {**window_meta, 'demand_vector': demand_df}
|
||||||
|
return demand_df
|
||||||
|
|
||||||
|
|
||||||
|
class ComputeDemandForChunksStep(BaseContextStep):
|
||||||
|
"""Apply ComputeDemandStep to list of chunks"""
|
||||||
|
|
||||||
|
def transform(self, chunks: list):
|
||||||
|
if not chunks:
|
||||||
|
return []
|
||||||
|
|
||||||
|
demand_step = ComputeDemandStep(self.context)
|
||||||
|
return [demand_step.transform(chunk) for chunk in chunks]
|
||||||
42
experiments/procesing/steps/elasticity.py
Executable file
@@ -0,0 +1,42 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Dict, List
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class AggregatePriceLogsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Aggregate price logs into time windows using VECTORIZED operations.
|
||||||
|
Input: price_logs_df
|
||||||
|
Output: DataFrame with columns [productId, price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, price_logs_df: pd.DataFrame):
|
||||||
|
if price_logs_df.empty:
|
||||||
|
return pd.DataFrame(columns=['productId', 'price'])
|
||||||
|
|
||||||
|
df = price_logs_df.copy()
|
||||||
|
ts_col = self.context.config.get('ts_col', 'ts')
|
||||||
|
#window_size = self.context.window_size WE ARE NOT USING CHUNKS ANYMORE
|
||||||
|
|
||||||
|
# ensure datetime
|
||||||
|
if not pd.api.types.is_datetime64_any_dtype(df[ts_col]):
|
||||||
|
df[ts_col] = pd.to_datetime(df[ts_col])
|
||||||
|
|
||||||
|
df = df.sort_values([ts_col, 'productId'])
|
||||||
|
products = self.context.products
|
||||||
|
# get base price from metadata if available 1) read the metadata col as json and get the base_price
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: row['metadata'].get('base_price', 0) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
|
||||||
|
unique_products = products['id'].unique()
|
||||||
|
|
||||||
|
df_indexed = df.set_index(ts_col)
|
||||||
|
# we return a df of average price per product over the entire period
|
||||||
|
# TODO: maybe consider different opration to handle price aggregation over time
|
||||||
|
avg_prices = df_indexed.groupby('productId')['price'].mean().reindex(unique_products, fill_value=0).reset_index()
|
||||||
|
avg_prices.columns = ['productId', 'price']
|
||||||
|
# fill 0s with base_price from products
|
||||||
|
base_price_map = products.set_index('id')['base_price'].to_dict()
|
||||||
|
return avg_prices
|
||||||
81
experiments/procesing/steps/fetch.py
Executable file
@@ -0,0 +1,81 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class FetchInteractionsStep(BaseContextStep):
|
||||||
|
"""Fetch raw interaction data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('user-interactions')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# Explode metadata JSON column
|
||||||
|
if 'metadata' in df.columns:
|
||||||
|
df = df.join(
|
||||||
|
pd.json_normalize(df.pop('metadata'), sep='.').add_prefix('metadata_')
|
||||||
|
)
|
||||||
|
|
||||||
|
df = df.dropna(subset=['eventName'])
|
||||||
|
# drop all where page has /admin/
|
||||||
|
df = df[~df['page'].str.contains('/admin/', na=False)]
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Remap dateIndex if present
|
||||||
|
if 'metadata_dateIndex' in df.columns:
|
||||||
|
df['dateIndex'] = df['metadata_dateIndex'].astype('Int64')
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchPriceLogsStep(BaseContextStep):
|
||||||
|
"""Fetch price log data from Kafka topic with optional time and store_mode filtering"""
|
||||||
|
|
||||||
|
def __init__(self, context, lookback: str = None):
|
||||||
|
super().__init__(context)
|
||||||
|
self.lookback = lookback
|
||||||
|
|
||||||
|
def transform(self, X=None):
|
||||||
|
df = self.context.provider.fetch_kafka_topic('price-logs')
|
||||||
|
|
||||||
|
if df.empty:
|
||||||
|
return df
|
||||||
|
|
||||||
|
# filter by store_mode from context
|
||||||
|
if 'storeMode' in df.columns:
|
||||||
|
df = df[df['storeMode'] == self.context.store_mode]
|
||||||
|
|
||||||
|
# Apply time filtering if lookback specified
|
||||||
|
if self.lookback and 'ts' in df.columns:
|
||||||
|
df['ts'] = pd.to_datetime(df['ts'])
|
||||||
|
cutoff = pd.Timestamp.now() - pd.Timedelta(self.lookback)
|
||||||
|
df = df[df['ts'] >= cutoff]
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
class FetchExperimentsStep(BaseContextStep):
|
||||||
|
"""Fetch experiment metadata for given interaction data"""
|
||||||
|
|
||||||
|
def transform(self, interactions_df: pd.DataFrame):
|
||||||
|
if interactions_df.empty or 'experimentId' not in interactions_df.columns:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
exp_ids = interactions_df['experimentId'].dropna().unique().tolist()
|
||||||
|
if not exp_ids:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
return self.context.provider.fetch_experiments(exp_ids)
|
||||||
58
experiments/procesing/steps/join.py
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
import pandas as pd
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
class JoinExperimentsStep(BaseContextStep):
|
||||||
|
"""Join experiment metadata to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, experiments_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
interactions_df, experiments_df = data
|
||||||
|
|
||||||
|
if experiments_df.empty:
|
||||||
|
return interactions_df
|
||||||
|
|
||||||
|
# Flatten nested task field if present
|
||||||
|
if 'task' in experiments_df.columns and experiments_df['task'].notnull().any():
|
||||||
|
task_norm = pd.json_normalize(experiments_df['task'].dropna())
|
||||||
|
task_norm.index = experiments_df[experiments_df['task'].notnull()].index
|
||||||
|
experiments_df = experiments_df.drop('task', axis=1).join(task_norm, rsuffix='_task')
|
||||||
|
|
||||||
|
# Rename for clarity
|
||||||
|
experiments_df = experiments_df.rename(columns={
|
||||||
|
'id': 'experimentId',
|
||||||
|
'subject_name': 'exp_subject',
|
||||||
|
'xp_human_only': 'exp_human_only',
|
||||||
|
'xp_market_mode': 'exp_market_mode',
|
||||||
|
'xp_task_id': 'exp_task_id'
|
||||||
|
})
|
||||||
|
|
||||||
|
return interactions_df.merge(experiments_df, on='experimentId', how='left')
|
||||||
|
|
||||||
|
class JoinProductFeaturesStep(BaseContextStep):
|
||||||
|
"""Join product features to interactions"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
data: (interactions_df, products_df)
|
||||||
|
Returns:
|
||||||
|
merged interactions dataframe
|
||||||
|
"""
|
||||||
|
demand_df, price_df = data
|
||||||
|
|
||||||
|
# get base prices from products if available
|
||||||
|
products = self.context.products
|
||||||
|
products['base_price'] = products.apply(
|
||||||
|
lambda row: float(row['metadata'].get('base_price', 0.0)) if isinstance(row['metadata'], dict) else 0,
|
||||||
|
axis=1
|
||||||
|
)
|
||||||
|
products = products[['id', 'base_price']].rename(columns={'id': 'productId'})
|
||||||
|
|
||||||
|
if price_df.empty:
|
||||||
|
return demand_df
|
||||||
|
return demand_df.merge(price_df, on='productId', how='left').merge(products, on='productId', how='left')
|
||||||
55
experiments/procesing/steps/pricing.py
Executable file
@@ -0,0 +1,55 @@
|
|||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from procesing.pricers.simple import StaticPricer
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
from procesing.pricers import ElasticityBasedPricer
|
||||||
|
|
||||||
|
class State:
|
||||||
|
def __init__(self,
|
||||||
|
last_action : str,
|
||||||
|
last_productId : str,
|
||||||
|
last_price : float,
|
||||||
|
session_features : np.ndarray
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class FitPricingFunctionStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Fit pricing function using data.
|
||||||
|
Input: pricing_data
|
||||||
|
Output: fitted pricing function instance
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, pricing_data: pd.DataFrame):
|
||||||
|
pricing_class = self.context.config.get('pricing_function_class', StaticPricer)
|
||||||
|
pricing_params = self.context.config.get('pricing_function_params', {})
|
||||||
|
|
||||||
|
pricer = pricing_class(**pricing_params)
|
||||||
|
pricer.fit(pricing_data)
|
||||||
|
|
||||||
|
return pricer
|
||||||
|
|
||||||
|
|
||||||
|
class PredictPricesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Predict optimal prices using fitted pricing function.
|
||||||
|
Input: (pricer, state_space)
|
||||||
|
Output: prices_df [productId, predicted_price]
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, data: tuple):
|
||||||
|
pricer, state_space = data
|
||||||
|
|
||||||
|
products = self.context.products
|
||||||
|
product_ids = products['id'].values
|
||||||
|
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
return pd.DataFrame({
|
||||||
|
'productId': product_ids,
|
||||||
|
'predicted_price': predicted_prices
|
||||||
|
})
|
||||||
262
experiments/procesing/steps/session.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"""
|
||||||
|
Session feature extraction for ML training pipeline.
|
||||||
|
"""
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
import re
|
||||||
|
from typing import Dict, Any
|
||||||
|
from procesing.steps.base import BaseContextStep
|
||||||
|
|
||||||
|
EVENT_CATS = {
|
||||||
|
'page_view': ['page_view'],
|
||||||
|
'item_view': ['view_item_page', 'learn_more_about_item'],
|
||||||
|
'cart_add': ['add_item_to_cart'],
|
||||||
|
'purchase': ['purchase', 'checkout_complete'],
|
||||||
|
'hover': ['hover_over_title', 'hover_over_paragraph', 'hover_over_link', 'hover_over_button'],
|
||||||
|
# 'filter': ['filter', 'search', 'apply_filter'],
|
||||||
|
}
|
||||||
|
HEADLESS_RE = re.compile(r'HeadlessChrome|Headless|PhantomJS', re.I)
|
||||||
|
AUTOMATION_RE = re.compile(r'Selenium|Playwright|Puppeteer|WebDriver|chromedriver|geckodriver', re.I)
|
||||||
|
BROWSER_PATTERNS = [('Chrome', r'Chrome/[\d.]+'), ('Firefox', r'Firefox/[\d.]+'),
|
||||||
|
('Safari', r'Safari/[\d.]+'), ('Edge', r'Edg/[\d.]+')]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_browser(s: str) -> str:
|
||||||
|
if pd.isna(s): return 'Unknown'
|
||||||
|
for name, pat in BROWSER_PATTERNS:
|
||||||
|
if re.search(pat, s): return name
|
||||||
|
return 'Other'
|
||||||
|
|
||||||
|
|
||||||
|
class TemporalFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized time-based features: durations, velocities, gaps."""
|
||||||
|
|
||||||
|
def __init__(self, context, timeout_sec: float = 900, velocity_window: str = '5min'):
|
||||||
|
super().__init__(context)
|
||||||
|
self.timeout_sec = timeout_sec
|
||||||
|
self.velocity_window = velocity_window
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'ts' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
df['ts_dt'] = pd.to_datetime(df['ts'])
|
||||||
|
df = df.sort_values(['sessionId', 'ts_dt'])
|
||||||
|
df['time_diff'] = df.groupby('sessionId')['ts_dt'].diff().dt.total_seconds()
|
||||||
|
df['active_diff'] = df['time_diff'].where(df['time_diff'] <= self.timeout_sec, 0)
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
session_duration_sec=('active_diff', 'sum'),
|
||||||
|
total_interactions=('sessionId', 'count'),
|
||||||
|
avg_time_between_events=('time_diff', 'mean'),
|
||||||
|
std_time_between_events=('time_diff', 'std'),
|
||||||
|
min_time_between_events=('time_diff', 'min'),
|
||||||
|
session_start_hour=('ts_dt', lambda x: x.min().hour),
|
||||||
|
).reset_index()
|
||||||
|
agg['std_time_between_events'] = agg['std_time_between_events'].fillna(0)
|
||||||
|
agg['interaction_velocity'] = np.where(
|
||||||
|
agg['session_duration_sec'] > 0,
|
||||||
|
(agg['total_interactions'] / agg['session_duration_sec']) * 60, 0)
|
||||||
|
|
||||||
|
vel = df.set_index('ts_dt').groupby('sessionId').resample(self.velocity_window, include_groups=False).size()
|
||||||
|
max_velocity = vel.groupby('sessionId').max().rename('max_velocity_5min')
|
||||||
|
agg = agg.merge(max_velocity, on='sessionId', how='left')
|
||||||
|
agg['max_velocity_5min'] = agg['max_velocity_5min'].fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class BehavioralFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized event counts and ratios per session."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'eventName' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
for cat, events in EVENT_CATS.items():
|
||||||
|
df[f'is_{cat}'] = df['eventName'].isin(events)
|
||||||
|
df['is_hover'] = df['is_hover'] | df['eventName'].str.startswith('hover_over_')
|
||||||
|
|
||||||
|
agg = df.groupby('sessionId').agg(
|
||||||
|
total_events=('eventName', 'count'), unique_pages=('page', 'nunique'),
|
||||||
|
page_views=('is_page_view', 'sum'), item_views=('is_item_view', 'sum'),
|
||||||
|
cart_adds=('is_cart_add', 'sum'), purchases=('is_purchase', 'sum'),
|
||||||
|
hover_events=('is_hover', 'sum'),
|
||||||
|
# filter_events=('is_filter', 'sum'),
|
||||||
|
).reset_index()
|
||||||
|
agg['cart_to_view_ratio'] = np.where(agg['item_views'] > 0, agg['cart_adds'] / agg['item_views'], 0)
|
||||||
|
agg['conversion_rate'] = np.where(agg['item_views'] > 0, agg['purchases'] / agg['item_views'], 0)
|
||||||
|
agg['hover_intensity'] = np.where(agg['total_events'] > 0, agg['hover_events'] / agg['total_events'], 0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class ProductFeatureStep(BaseContextStep):
|
||||||
|
"""Vectorized product interaction features: diversity, depth, price sensitivity."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
price_col = next((c for c in ['metadata_base_price', 'metadata_price', 'base_price'] if c in df.columns), None)
|
||||||
|
df['price_seen'] = pd.to_numeric(df[price_col], errors='coerce') if price_col else np.nan
|
||||||
|
|
||||||
|
prod_df = df[df['productId'].notna()]
|
||||||
|
if prod_df.empty:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId', 'unique_products_viewed', 'product_view_depth', 'avg_price_seen', 'min_price_seen', 'max_price_seen', 'price_range']))
|
||||||
|
|
||||||
|
agg = prod_df.groupby('sessionId').agg(
|
||||||
|
unique_products_viewed=('productId', 'nunique'),
|
||||||
|
product_view_depth=('productId', lambda x: x.value_counts().iloc[0] if len(x) > 0 else 0),
|
||||||
|
avg_price_seen=('price_seen', 'mean'), min_price_seen=('price_seen', 'min'),
|
||||||
|
max_price_seen=('price_seen', 'max'),
|
||||||
|
).reset_index()
|
||||||
|
agg['price_range'] = (agg['max_price_seen'] - agg['min_price_seen']).fillna(0)
|
||||||
|
return agg
|
||||||
|
|
||||||
|
|
||||||
|
class UserAgentFeatureStep(BaseContextStep):
|
||||||
|
"""Parse userAgent into bot-detection signals."""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame|pd.Series:
|
||||||
|
df = X.copy()
|
||||||
|
if df.empty or 'userAgent' not in df.columns:
|
||||||
|
return pd.DataFrame(columns=pd.Series(['sessionId']))
|
||||||
|
|
||||||
|
ua = df.groupby('sessionId')['userAgent'].first().reset_index()
|
||||||
|
ua['is_headless'] = ua['userAgent'].str.contains(HEADLESS_RE, na=False)
|
||||||
|
ua['is_automation'] = ua['userAgent'].str.contains(AUTOMATION_RE, na=False)
|
||||||
|
ua['browser_family'] = ua['userAgent'].apply(_get_browser)
|
||||||
|
return ua[['sessionId', 'is_headless', 'is_automation', 'browser_family']]
|
||||||
|
|
||||||
|
|
||||||
|
class ExtractSessionFeaturesStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||||
|
Input: interactions_df
|
||||||
|
Output: session-level feature matrix
|
||||||
|
THIS is our main mapping from tau (trajectory) to some features vector theta - we need to do this very well. This is what will go into demand esimation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
if X.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
df = X.copy()
|
||||||
|
|
||||||
|
# run all feature steps and merge on sessionId
|
||||||
|
temporal = TemporalFeatureStep(self.context).transform(df)
|
||||||
|
behavioral = BehavioralFeatureStep(self.context).transform(df)
|
||||||
|
product = ProductFeatureStep(self.context).transform(df)
|
||||||
|
ua = UserAgentFeatureStep(self.context).transform(df)
|
||||||
|
|
||||||
|
result = temporal
|
||||||
|
for other in [behavioral, product, ua]:
|
||||||
|
if not other.empty and 'sessionId' in other.columns:
|
||||||
|
result = result.merge(other, on='sessionId', how='left')
|
||||||
|
|
||||||
|
# carry forward experimentId for label joining
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
exp_map = df.groupby('sessionId')['experimentId'].first()
|
||||||
|
result = result.merge(exp_map, on='sessionId', how='left')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class JoinLabelsStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Join experiment labels to session features.
|
||||||
|
Input: (features_df, experiments_df) or features_df (fetches experiments)
|
||||||
|
Output: labeled feature matrix with is_agent column
|
||||||
|
"""
|
||||||
|
|
||||||
|
def transform(self, X : tuple) -> pd.DataFrame:
|
||||||
|
data = X;
|
||||||
|
if isinstance(data, tuple):
|
||||||
|
features_df, experiments_df = data
|
||||||
|
else:
|
||||||
|
features_df = data
|
||||||
|
if 'experimentId' not in features_df.columns:
|
||||||
|
return features_df
|
||||||
|
exp_ids = features_df['experimentId'].dropna().unique().tolist()
|
||||||
|
experiments_df = self.context.provider.fetch_experiments(exp_ids) if exp_ids else pd.DataFrame()
|
||||||
|
|
||||||
|
if features_df.empty:
|
||||||
|
return features_df
|
||||||
|
if experiments_df.empty:
|
||||||
|
features_df['is_agent'] = np.nan
|
||||||
|
return features_df
|
||||||
|
|
||||||
|
exp = experiments_df.copy()
|
||||||
|
if 'id' in exp.columns:
|
||||||
|
exp = exp.rename(columns={'id': 'experimentId'})
|
||||||
|
if 'xp_human_only' in exp.columns:
|
||||||
|
exp['is_agent'] = ~exp['xp_human_only']
|
||||||
|
|
||||||
|
cols = ['experimentId'] + [c for c in ['is_agent', 'xp_human_only', 'xp_market_mode'] if c in exp.columns]
|
||||||
|
return features_df.merge(exp[cols].drop_duplicates(), on='experimentId', how='left')
|
||||||
|
|
||||||
|
|
||||||
|
class ValidateDataStep(BaseContextStep):
|
||||||
|
"""
|
||||||
|
Data quality checks before training.
|
||||||
|
Input: df
|
||||||
|
Output: df (unchanged, but logs validation report to context)
|
||||||
|
"""
|
||||||
|
REQUIRED = ['sessionId', 'eventName', 'ts']
|
||||||
|
|
||||||
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
df = X.copy()
|
||||||
|
report = {'status': 'valid', 'rows': len(df), 'sessions': 0}
|
||||||
|
if df.empty:
|
||||||
|
report['status'] = 'empty'
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
missing = [c for c in self.REQUIRED if c not in df.columns]
|
||||||
|
if missing:
|
||||||
|
report['status'] = 'invalid'
|
||||||
|
report['missing_cols'] = missing
|
||||||
|
|
||||||
|
report['sessions'] = df['sessionId'].nunique() if 'sessionId' in df.columns else 0
|
||||||
|
report['null_sessions'] = int(df['sessionId'].isna().sum()) if 'sessionId' in df.columns else 0
|
||||||
|
if 'experimentId' in df.columns:
|
||||||
|
report['null_experiments'] = int(df['experimentId'].isna().sum())
|
||||||
|
|
||||||
|
self.context.cache('validation_report', report)
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# legacy compat - kept for backwards compatibility with existing code
|
||||||
|
def _extract_features_for_session(session_df: pd.DataFrame, session_timeout_sec: float = 900) -> Dict[str, Any]:
|
||||||
|
"""Single-session feature extraction (legacy interface)."""
|
||||||
|
defaults = {k: 0 for k in ['total_interactions', 'page_views', 'item_views', 'searches',
|
||||||
|
'cart_adds', 'hovers', 'unique_products_viewed', 'product_view_depth',
|
||||||
|
'session_duration_sec', 'interaction_velocity',
|
||||||
|
'avg_time_between_events', 'std_time_between_events', 'cart_to_view_ratio']}
|
||||||
|
if session_df.empty:
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
session_df = session_df.copy()
|
||||||
|
if 'sessionId' not in session_df.columns:
|
||||||
|
session_df['sessionId'] = 'tmp'
|
||||||
|
|
||||||
|
# use a dummy context for the steps
|
||||||
|
class DummyCtx: config = {} # should maybe inherit but whatever
|
||||||
|
ctx = DummyCtx()
|
||||||
|
|
||||||
|
t = TemporalFeatureStep(ctx, timeout_sec=session_timeout_sec).transform(session_df)
|
||||||
|
b = BehavioralFeatureStep(ctx).transform(session_df)
|
||||||
|
p = ProductFeatureStep(ctx).transform(session_df)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for df in [t, b, p]:
|
||||||
|
if not df.empty:
|
||||||
|
for col in df.columns:
|
||||||
|
if col != 'sessionId':
|
||||||
|
result[col] = df[col].iloc[0] if len(df) > 0 else 0
|
||||||
|
|
||||||
|
remap = {'hover_events': 'hovers', 'filter_events': 'searches', 'unique_pages': 'unique_pages_visited'}
|
||||||
|
for old, new in remap.items():
|
||||||
|
if old in result:
|
||||||
|
result[new] = result.pop(old)
|
||||||
|
return result
|
||||||
0
experiments/procesing/tests/__init__.py
Normal file
281
experiments/procesing/tests/conftest.py
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from typing import List
|
||||||
|
from procesing.providers.base import DataProvider
|
||||||
|
from procesing.context import PipelineContext
|
||||||
|
|
||||||
|
|
||||||
|
class MockProvider(DataProvider):
|
||||||
|
"""Mock provider for testing, holds in-memory fixtures"""
|
||||||
|
|
||||||
|
def __init__(self, products_df=None, experiments_df=None, kafka_data=None):
|
||||||
|
self._products = products_df if products_df is not None else pd.DataFrame()
|
||||||
|
self._experiments = experiments_df if experiments_df is not None else pd.DataFrame()
|
||||||
|
self._kafka_data = kafka_data if kafka_data is not None else {}
|
||||||
|
|
||||||
|
def fetch_products(self, store_mode: str) -> pd.DataFrame:
|
||||||
|
return self._products.copy()
|
||||||
|
|
||||||
|
def fetch_experiments(self, experiment_ids: List[str]) -> pd.DataFrame:
|
||||||
|
if self._experiments.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
return self._experiments[
|
||||||
|
self._experiments['id'].isin(experiment_ids)
|
||||||
|
].copy()
|
||||||
|
|
||||||
|
def fetch_kafka_topic(self, topic: str) -> pd.DataFrame:
|
||||||
|
return self._kafka_data.get(topic, pd.DataFrame()).copy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_products():
|
||||||
|
"""Standard product catalog fixture with realistic IDs from test data"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': [
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
],
|
||||||
|
'name': ['Junior Suite', 'Superior Room', 'Deluxe Room'],
|
||||||
|
'base_price': [200.0, 150.0, 180.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for interactions, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 203, 'timestamp': 1764102082676,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'learn_more_about_item',
|
||||||
|
'page': '/hotel/products/d018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'type': 'hotel', 'dateIndex': 1, 'roomType': 'Junior Suite'},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:22.674Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 204, 'timestamp': 1764102086982,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'page_view',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': None,
|
||||||
|
'metadata': {'referrer': ''},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:26.947Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 205, 'timestamp': 1764102091825,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'eventName': 'hover_over_title',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'Superior Room', 'dateIndex': 1, 'dwellTime': 1200},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:31.823Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 206, 'timestamp': 1764102094193,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': '51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1307},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:34.191Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 207, 'timestamp': 1764102101970,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': 'bbbbcccc-dddd-eeee-ffff-000011112222',
|
||||||
|
'eventName': 'hover_over_paragraph',
|
||||||
|
'page': '/hotel/products',
|
||||||
|
'productId': 'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'metadata': {'elementText': 'price', 'dateIndex': 1, 'dwellTime': 1201},
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T20:21:41.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_interactions(mock_interactions_raw_kafka):
|
||||||
|
"""Processed interaction DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
records = [msg['value']['payload'] for msg in mock_interactions_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs_raw_kafka():
|
||||||
|
"""Raw Kafka message structure for price logs, matches production format"""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 32, 'timestamp': 1764104757969,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 162.47,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.967Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 33, 'timestamp': 1764104757995,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 743.49,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:57.993Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 34, 'timestamp': 1764104758011,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 163.87,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.009Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 35, 'timestamp': 1764104758050,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2ddabbfc-4127-48fc-86dc-ebc4c677efa2',
|
||||||
|
'price': 397.46,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:05:58.049Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'partitionID': 0, 'offset': 36, 'timestamp': 1764104768865,
|
||||||
|
'value': {
|
||||||
|
'payload': {
|
||||||
|
'productId': '2cd7f756-fc65-4ba0-ab01-74521c1fff43',
|
||||||
|
'price': 401.66,
|
||||||
|
'sessionId': 'd423ce8a-77aa-4c9a-94d4-d1adddcc3472',
|
||||||
|
'experimentId': '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35',
|
||||||
|
'storeMode': 'hotel',
|
||||||
|
'ts': '2025-11-25T21:06:08.864Z'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_price_logs(mock_price_logs_raw_kafka):
|
||||||
|
"""Processed price logs DataFrame (what provider.fetch_kafka_topic returns)"""
|
||||||
|
# extract payloads and flatten
|
||||||
|
records = [msg['value']['payload'] for msg in mock_price_logs_raw_kafka]
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
df['timestamp'] = pd.to_datetime(df['ts'])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_experiments():
|
||||||
|
"""Standard experiment metadata fixture matching Supabase schema"""
|
||||||
|
return pd.DataFrame({
|
||||||
|
'id': ['53aefd07-f66a-4d7f-ba8b-7ea1fc562d35', 'bbbbcccc-dddd-eeee-ffff-000011112222'],
|
||||||
|
'created_at': pd.to_datetime(['2025-11-25T20:00:00Z', '2025-11-26T10:00:00Z']),
|
||||||
|
'subject_name': ['Session A', 'Session B'],
|
||||||
|
'xp_human_only': [True, False],
|
||||||
|
'xp_market_mode': ['hotel', 'airline'],
|
||||||
|
'xp_task_id': [None, None]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_provider(mock_products, mock_experiments, mock_interactions, mock_price_logs):
|
||||||
|
"""Fully configured mock provider"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=mock_products,
|
||||||
|
experiments_df=mock_experiments,
|
||||||
|
kafka_data={
|
||||||
|
'user-interactions': mock_interactions,
|
||||||
|
'price-logs': mock_price_logs
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pipeline_context(mock_provider):
|
||||||
|
"""Standard pipeline context for testing"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=mock_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s',
|
||||||
|
n_price_buckets=3
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_provider():
|
||||||
|
"""Provider with no data, for edge case testing"""
|
||||||
|
return MockProvider(
|
||||||
|
products_df=pd.DataFrame(columns=['id', 'name', 'base_price']),
|
||||||
|
experiments_df=pd.DataFrame(columns=['id', 'created_at', 'subject_name', 'xp_human_only', 'xp_market_mode', 'xp_task_id']),
|
||||||
|
kafka_data={'user-interactions': pd.DataFrame(), 'price-logs': pd.DataFrame()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def empty_context(empty_provider):
|
||||||
|
"""Context with empty provider"""
|
||||||
|
return PipelineContext(
|
||||||
|
provider=empty_provider,
|
||||||
|
store_mode='hotel',
|
||||||
|
window_size='30s'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session_interactions(mock_interactions):
|
||||||
|
"""Enriched interaction data for session feature extraction tests"""
|
||||||
|
df = mock_interactions.copy()
|
||||||
|
df['userAgent'] = ['Mozilla/5.0 Chrome/120', 'Mozilla/5.0 Chrome/120',
|
||||||
|
'HeadlessChrome/120', 'HeadlessChrome/120', 'HeadlessChrome/120']
|
||||||
|
df['metadata_base_price'] = [None, None, 150.0, 150.0, 200.0]
|
||||||
|
return df
|
||||||
45
experiments/procesing/tests/test_augement.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
CreatePriceBucketsStep,
|
||||||
|
AugmentEventNamesStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_bucketing(pipeline_context):
|
||||||
|
step = CreatePriceBucketsStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal price data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'metadata_price': random.sample(range(10, 1000), 100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
# test if is categorical
|
||||||
|
assert isinstance(result['price_bucket'].dtype, pd.CategoricalDtype)
|
||||||
|
assert result['price_bucket'].nunique() == 3 # as per context config
|
||||||
|
# distribution check
|
||||||
|
counts = result['price_bucket'].value_counts()
|
||||||
|
assert all(counts > 0)
|
||||||
|
assert counts.max() - counts.min() <= 10 # roughly equal distribution for 100 samples
|
||||||
|
# Test with empty DataFrame
|
||||||
|
df = pd.DataFrame()
|
||||||
|
result = step.transform(df)
|
||||||
|
assert 'price_bucket' in result.columns
|
||||||
|
assert result.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_augment_names(pipeline_context):
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'eventName': ['click', 'view', 'purchase'],
|
||||||
|
'productId': ['prod_1', 'prod_2', None],
|
||||||
|
'price_bucket': ['PB_1', None, 'PB_3']
|
||||||
|
})
|
||||||
|
step = AugmentEventNamesStep(context=pipeline_context)
|
||||||
|
result = step.transform(df)
|
||||||
|
expected_event_names = [
|
||||||
|
'click_prod_1@PB_1',
|
||||||
|
'view',
|
||||||
|
'purchase'
|
||||||
|
]
|
||||||
|
assert result['eventName'].tolist() == expected_event_names
|
||||||
49
experiments/procesing/tests/test_demand.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import pytest
|
||||||
|
import random
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
ComputeDemandStep
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_compute_demand(pipeline_context):
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
assert all(result['demand_score'] > 100/3 -10)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_demand_skewed(pipeline_context):
|
||||||
|
step = ComputeDemandStep(context=pipeline_context)
|
||||||
|
|
||||||
|
# Test with normal interaction data
|
||||||
|
df = pd.DataFrame({
|
||||||
|
'ts': pd.date_range(start='2023-01-01', periods=100, freq='h'),
|
||||||
|
'productId': random.choices([
|
||||||
|
'd018efc1-25e9-4284-b276-80386e048b25',
|
||||||
|
'51266ddb-5b07-47b7-89ee-5b5cae94bb11',
|
||||||
|
'2cd7f756-fc65-4ba0-ab01-74521c1fff43'
|
||||||
|
], weights=[0.7, 0.2, 0.1], k=100),
|
||||||
|
'eventName': random.choices(['view', 'click', 'purchase'], k=100)
|
||||||
|
})
|
||||||
|
result = step.transform(df)
|
||||||
|
assert type(result) == pd.DataFrame
|
||||||
|
assert not result.empty
|
||||||
|
assert set(result['productId']) == set(pipeline_context.products['id'])
|
||||||
|
# test for skewness
|
||||||
|
scores = result.set_index('productId')['demand_score'].to_dict()
|
||||||
|
assert scores['d018efc1-25e9-4284-b276-80386e048b25'] > \
|
||||||
|
scores['51266ddb-5b07-47b7-89ee-5b5cae94bb11'] > \
|
||||||
|
scores['2cd7f756-fc65-4ba0-ab01-74521c1fff43']
|
||||||
51
experiments/procesing/tests/test_fetch.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
from procesing.steps import (
|
||||||
|
FetchInteractionsStep,
|
||||||
|
FetchPriceLogsStep,
|
||||||
|
FetchExperimentsStep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_interactions_data(pipeline_context):
|
||||||
|
step = FetchInteractionsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"eventName",
|
||||||
|
"dateIndex",
|
||||||
|
"experimentId",
|
||||||
|
"storeMode",
|
||||||
|
"metadata_elementText"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
|
||||||
|
def test_fetch_price_logs(pipeline_context):
|
||||||
|
step = FetchPriceLogsStep(pipeline_context)
|
||||||
|
data = step.transform(None)
|
||||||
|
assert data is not None
|
||||||
|
assert isinstance(data, pd.DataFrame)
|
||||||
|
expected_cols = [
|
||||||
|
"price",
|
||||||
|
"productId"
|
||||||
|
]
|
||||||
|
for expected in expected_cols:
|
||||||
|
assert expected in data.columns
|
||||||
|
prices = data['price'].to_list()
|
||||||
|
assert min(prices) >= 0
|
||||||
|
assert max(prices) <= 9999
|
||||||
|
|
||||||
|
|
||||||
|
def test_experiments_fetching(pipeline_context):
|
||||||
|
interactions = FetchInteractionsStep(pipeline_context).transform(None)
|
||||||
|
assert interactions is not None
|
||||||
|
experiments = FetchExperimentsStep(pipeline_context)
|
||||||
|
experiment_data = experiments.transform(interactions)
|
||||||
|
assert experiment_data is not None
|
||||||
|
assert isinstance(experiment_data, pd.DataFrame)
|
||||||
|
assert not experiment_data.empty
|
||||||
|
assert 'id' in experiment_data.columns
|
||||||
|
assert len(experiment_data) == 2
|
||||||
|
assert '53aefd07-f66a-4d7f-ba8b-7ea1fc562d35' in experiment_data['id'].values
|
||||||
87
experiments/procesing/tests/test_pricing.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import pytest
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from procesing.pricers import (
|
||||||
|
StaticPricer,
|
||||||
|
RandomPricer,
|
||||||
|
ElasticityBasedPricer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_static_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit StaticPricer
|
||||||
|
pricer = StaticPricer()
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices match base prices
|
||||||
|
expected_prices = historical_data['base_price'].values
|
||||||
|
assert all(predicted_prices == expected_prices), "Predicted prices do not match base prices"
|
||||||
|
|
||||||
|
|
||||||
|
def test_random_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'product_id': [1, 2, 3],
|
||||||
|
'base_price': [100.0, 150.0, 200.0]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit RandomPricer
|
||||||
|
pricer = RandomPricer(price_min=50.0, price_max=250.0, seed=42)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(None)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 250.0, "Predicted prices are above maximum bound"
|
||||||
|
# distribution check (not so strict)
|
||||||
|
assert len(set(predicted_prices)) > 1, "Predicted prices are not varied enough"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
def test_elasticity_based_pricer_fit_and_predict():
|
||||||
|
# Sample historical data
|
||||||
|
historical_data = pd.DataFrame({
|
||||||
|
'productId': [1, 2, 3],
|
||||||
|
'elasticity': [-1.5, -0.5, -2.0],
|
||||||
|
'base_price': [100.0, 150.0, 200.0],
|
||||||
|
'mean_demand': [10, 20, 15]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Initialize and fit ElasticityBasedPricer
|
||||||
|
pricer = ElasticityBasedPricer(alpha=0.1, price_floor=50.0, price_ceil=300.0)
|
||||||
|
pricer.fit(historical_data)
|
||||||
|
|
||||||
|
# Create a mock state space with demand deviations
|
||||||
|
class MockStateSpace:
|
||||||
|
def __init__(self, demand):
|
||||||
|
self.demand = demand
|
||||||
|
|
||||||
|
# Simulate demand higher than mean for all products
|
||||||
|
state_space = MockStateSpace(demand=[15, 25, 20])
|
||||||
|
|
||||||
|
# Predict prices
|
||||||
|
predicted_prices = pricer.predict(state_space)
|
||||||
|
|
||||||
|
# Assert that predicted prices are within bounds
|
||||||
|
assert predicted_prices.min() >= 50.0, "Predicted prices are below minimum bound"
|
||||||
|
assert predicted_prices.max() <= 300.0, "Predicted prices are above maximum bound"
|
||||||
|
assert len(predicted_prices) == len(historical_data), "Number of predicted prices does not match number of products"
|
||||||
|
|
||||||
|
# now we gotta check semantic validity
|
||||||
|
# since demand is higher than mean, prices should generally increase
|
||||||
|
for i, row in historical_data.iterrows():
|
||||||
|
base_price = row['base_price']
|
||||||
|
elasticity = row['elasticity']
|
||||||
|
expected_increase = base_price * (1 + 0.1 * abs(elasticity) * ((state_space.demand[i] - row['mean_demand']) / row['mean_demand']))
|
||||||
|
assert predicted_prices[i] >= base_price, f"Predicted price for product {row['productId']} did not increase as expected"
|
||||||
|
assert abs(predicted_prices[i] - expected_increase) < 1e-5, f"Predicted price for product {row['productId']} does not match expected calculation within 1e-5 tolerance"
|
||||||
8
experiments/pytest.ini
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = procesing/tests agents
|
||||||
|
python_files = test*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
asyncio_mode = auto
|
||||||
|
asyncio_default_fixture_loop_scope = function
|
||||||
125
experiments/seed_products.py
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import random
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from supabase import create_client, Client
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SUPABASE_URL = os.getenv("NEXT_PUBLIC_SUPABASE_URL")
|
||||||
|
SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
|
||||||
|
|
||||||
|
if not SUPABASE_SERVICE_KEY:
|
||||||
|
log.error("SUPABASE_SERVICE_ROLE_KEY not found in environment")
|
||||||
|
raise ValueError("Missing SUPABASE_SERVICE_ROLE_KEY - required for admin operations")
|
||||||
|
|
||||||
|
supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
|
||||||
|
|
||||||
|
DAYS = 14
|
||||||
|
|
||||||
|
# hotel room configurations
|
||||||
|
ROOMS = {
|
||||||
|
"Presidential Suite": {'amenities': ['ocean_view', 'balcony', 'jacuzzi', 'butler_service', 'premium_minibar'], 'total': 1, 'image_url': "", "base_price": 450, 'name': 'Presidential Suite', 'refundable': True, 'max_occupancy': 4},
|
||||||
|
"Executive Suite": {'amenities': ['city_view', 'balcony', 'workspace', 'lounge_access'], 'total': 2, 'image_url': "", "base_price": 280, 'name': 'Executive Suite', 'refundable': True, 'max_occupancy': 3},
|
||||||
|
"Junior Suite": {'amenities': ['garden_view', 'mini_fridge', 'coffee_maker'], 'total': 5, 'image_url': "", "base_price": 180, 'name': 'Junior Suite', 'refundable': True, 'max_occupancy': 2},
|
||||||
|
"Deluxe Room": {'amenities': ['city_view', 'work_desk', 'coffee_maker'], 'total': 8, 'image_url': "", "base_price": 140, 'name': 'Deluxe Room', 'refundable': False, 'max_occupancy': 2},
|
||||||
|
"Superior Room": {'amenities': ['wifi', 'tv', 'safe'], 'total': 12, 'image_url': "", "base_price": 110, 'name': 'Superior Room', 'refundable': False, 'max_occupancy': 2},
|
||||||
|
"Standard Room": {'amenities': ['wifi', 'tv'], 'total': 20, 'image_url': "", "base_price": 85, 'name': 'Standard Room', 'refundable': False, 'max_occupancy': 2},
|
||||||
|
}
|
||||||
|
|
||||||
|
# flight configurations
|
||||||
|
FLIGHTS = {
|
||||||
|
"JFK-LAX-Economy": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 180, 'base_price': 250},
|
||||||
|
"JFK-LAX-Business": {'departure': {'time': '08:00', 'airport': 'JFK'}, 'arrival': {'time': '11:30', 'airport': 'LAX'}, 'duration': '5h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 30, 'base_price': 850},
|
||||||
|
"ORD-MIA-Economy": {'departure': {'time': '14:15', 'airport': 'ORD'}, 'arrival': {'time': '18:45', 'airport': 'MIA'}, 'duration': '3h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 200, 'base_price': 180},
|
||||||
|
"SFO-SEA-Premium": {'departure': {'time': '06:30', 'airport': 'SFO'}, 'arrival': {'time': '08:45', 'airport': 'SEA'}, 'duration': '2h 15m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': False, 'total': 60, 'base_price': 420},
|
||||||
|
"ATL-DFW-First": {'departure': {'time': '16:00', 'airport': 'ATL'}, 'arrival': {'time': '17:30', 'airport': 'DFW'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 12, 'base_price': 1600},
|
||||||
|
"LAX-SFO-Economy": {'departure': {'time': '10:00', 'airport': 'LAX'}, 'arrival': {'time': '11:30', 'airport': 'SFO'}, 'duration': '1h 30m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'standard', 'refundable': False, 'total': 150, 'base_price': 120},
|
||||||
|
"MIA-ATL-Premium": {'departure': {'time': '19:00', 'airport': 'MIA'}, 'arrival': {'time': '20:45', 'airport': 'ATL'}, 'duration': '1h 45m', 'stops': 0, 'cabin_class': 'premium', 'fare_rule': 'standard', 'refundable': True, 'total': 50, 'base_price': 380},
|
||||||
|
"DFW-ORD-Economy": {'departure': {'time': '07:30', 'airport': 'DFW'}, 'arrival': {'time': '10:15', 'airport': 'ORD'}, 'duration': '2h 45m', 'stops': 0, 'cabin_class': 'economy', 'fare_rule': 'basic', 'refundable': False, 'total': 190, 'base_price': 160},
|
||||||
|
"SEA-LAX-Business": {'departure': {'time': '13:00', 'airport': 'SEA'}, 'arrival': {'time': '15:30', 'airport': 'LAX'}, 'duration': '2h 30m', 'stops': 0, 'cabin_class': 'business', 'fare_rule': 'flexible', 'refundable': True, 'total': 40, 'base_price': 720},
|
||||||
|
"LAX-JFK-First": {'departure': {'time': '18:00', 'airport': 'LAX'}, 'arrival': {'time': '02:15', 'airport': 'JFK'}, 'duration': '5h 15m', 'stops': 0, 'cabin_class': 'first', 'fare_rule': 'flexible', 'refundable': True, 'total': 16, 'base_price': 1850},
|
||||||
|
}
|
||||||
|
|
||||||
|
def gen_hotel_products():
|
||||||
|
"""generate hotel room products for next DAYS days"""
|
||||||
|
data = []
|
||||||
|
for day in range(DAYS):
|
||||||
|
for room_type, rdata in ROOMS.items():
|
||||||
|
data.append({
|
||||||
|
'room_type': room_type,
|
||||||
|
'date_index': day + 1,
|
||||||
|
'metadata': rdata,
|
||||||
|
'availability': random.randint(0, rdata['total'])
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def gen_airline_products():
|
||||||
|
"""generate flight products for next DAYS days"""
|
||||||
|
data = []
|
||||||
|
for day in range(DAYS):
|
||||||
|
for flight_type, fdata in FLIGHTS.items():
|
||||||
|
data.append({
|
||||||
|
'flight_type': flight_type,
|
||||||
|
'date_index': day + 1,
|
||||||
|
'metadata': fdata,
|
||||||
|
'availability': random.randint(0, fdata['total'])
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def clear_table(table_name: str):
|
||||||
|
"""clear all records from a table"""
|
||||||
|
try:
|
||||||
|
resp = supabase.table(table_name).select('id').execute()
|
||||||
|
if resp.data:
|
||||||
|
ids = [row['id'] for row in resp.data]
|
||||||
|
chunk_size = 100
|
||||||
|
for i in tqdm(range(0, len(ids), chunk_size), desc=f"Clearing {table_name}", unit="chunk"):
|
||||||
|
chunk = ids[i:i+chunk_size]
|
||||||
|
supabase.table(table_name).delete().in_('id', chunk).execute()
|
||||||
|
log.info(f"Deleted {len(ids)} records from {table_name}")
|
||||||
|
else:
|
||||||
|
log.info(f"{table_name} already empty")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to clear {table_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def seed_table(table_name: str, data: list[dict]):
|
||||||
|
"""insert records into a table"""
|
||||||
|
try:
|
||||||
|
chunk_size = 100
|
||||||
|
total = len(data)
|
||||||
|
for i in tqdm(range(0, total, chunk_size), desc=f"Seeding {table_name}", unit="chunk"):
|
||||||
|
chunk = data[i:i+chunk_size]
|
||||||
|
supabase.table(table_name).insert(chunk).execute()
|
||||||
|
log.info(f"Inserted {total} records into {table_name}")
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Failed to seed {table_name}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def main():
|
||||||
|
|
||||||
|
log.info("Generating hotel products...")
|
||||||
|
hotel_products = gen_hotel_products()
|
||||||
|
log.info(f"Generated {len(hotel_products)} hotel products")
|
||||||
|
|
||||||
|
log.info("Generating airline products...")
|
||||||
|
airline_products = gen_airline_products()
|
||||||
|
log.info(f"Generated {len(airline_products)} airline products\n")
|
||||||
|
|
||||||
|
log.info("Clearing existing products...")
|
||||||
|
clear_table('hotel_products')
|
||||||
|
clear_table('airline_products')
|
||||||
|
|
||||||
|
log.info("Seeding products...")
|
||||||
|
seed_table('hotel_products', hotel_products)
|
||||||
|
seed_table('airline_products', airline_products)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||