Compare commits
135 Commits
baseline-s
...
agent-beha
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9843c5deab | ||
| 13959e4b28 | |||
|
|
2f481bd94b | ||
| 72877439ca | |||
| 0f5f8affab | |||
| ee70f02a1f | |||
| 22a2c255bd | |||
| ccc19f3493 | |||
| 00e3eff2fa | |||
| 440371dba4 | |||
| b05b510f70 | |||
| 04907df393 | |||
| b2f0746c01 | |||
| 7b2d80ac4c | |||
| 0ce12fbc3b | |||
| e9cf5f0736 | |||
| 82b54428b7 | |||
| 87a35fad2c | |||
| af23d2f736 | |||
| 9cb2b0fc44 | |||
| 7c330a19c6 | |||
|
|
eb95060380 | ||
| 61dd621532 | |||
| 4c368d48f2 | |||
| 3c141a4b6c | |||
| e89cb263d4 | |||
| 62a4008c29 | |||
| 8b429b7a8e | |||
| f9bf3de71e | |||
| 131323ef56 | |||
| ec4cf074e6 | |||
| 6a06a8af4a | |||
| 3fa98f375d | |||
| 201c98bcac | |||
| 8a08458478 | |||
| 7d09232e48 | |||
| 20132c084c | |||
| 26abff5864 | |||
| 4c7d9362af | |||
| ea45801845 | |||
|
|
574e05d9e0 | ||
| 52fe865598 | |||
| 28d3f6853e | |||
| 10e8397eec | |||
| 772772b5b9 | |||
| 6e06081d60 | |||
| 83d9bb2552 | |||
| fa2aca8b13 | |||
| cd6c3d6006 | |||
| 98a9a3738c | |||
| 1224841a82 | |||
| 4033e73ba1 | |||
| bae51daa1c | |||
| c5eae17924 | |||
| 28669ea4c3 | |||
| b0a1647956 | |||
| 19bb4fd517 | |||
| 4e2e41d943 | |||
| a033e77697 | |||
| 40e0b201e6 | |||
| a217d53556 | |||
| a6e6cc5d60 | |||
| fa89347c4e | |||
| 2b3d937be6 | |||
| 20c47fe85f | |||
| b7161573d7 | |||
| c15bb1882e | |||
| dee6f573e3 | |||
| 2ed200f870 | |||
| 56308ecb10 | |||
| 7fcd18c3cb | |||
| 5f607a58eb | |||
| 6aad196234 | |||
| e5060babfa | |||
| 80863e9b17 | |||
| a5029f2eab | |||
| c102ac482e | |||
| 08ade8dc89 | |||
| 95d4f0cee2 | |||
|
|
a9d73ccce5 | ||
| 3072e5f46e | |||
| a1e3166322 | |||
| 6f361b96a8 | |||
| eea019ab3f | |||
| a36973cb42 | |||
| 96180e9af1 | |||
|
|
e60c0c64e1 | ||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d | |||
|
|
221e71a503 | ||
|
|
f2271e368e | ||
|
|
a1916c966c | ||
|
|
a2a443c027 | ||
|
|
ef98141ca8 | ||
| d45b344264 | |||
| a0b956b242 | |||
|
|
8751583764 | ||
| 59d4fb7891 | |||
| 7c2a819122 | |||
| 5941ffd085 | |||
| 955102090d | |||
| d654bbf4b4 | |||
|
|
ad9423bf59 | ||
|
|
2a0e44ab24 | ||
|
|
c432c45343 | ||
|
|
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
|
||||||
|
|||||||
48
.github/workflows/latex.yml
vendored
@@ -19,10 +19,56 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
root_file: main.tex
|
root_file: main.tex
|
||||||
working_directory: paper/src
|
working_directory: paper/src
|
||||||
args: -pdf -interaction=nonstopmode -file-line-error -outdir=../build
|
args: -pdf -f -interaction=nonstopmode -file-line-error -outdir=../build
|
||||||
pre_compile: bash ../concat_code.sh
|
pre_compile: bash ../concat_code.sh
|
||||||
- name: Upload PDF
|
- name: Upload PDF
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: thesis-pdf
|
name: thesis-pdf
|
||||||
path: paper/build/main.pdf
|
path: paper/build/main.pdf
|
||||||
|
|
||||||
|
- name: Get current date
|
||||||
|
id: date
|
||||||
|
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload to Cloudflare R2
|
||||||
|
env:
|
||||||
|
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||||
|
AWS_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT }}
|
||||||
|
DATE: ${{ steps.date.outputs.date }}
|
||||||
|
BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||||
|
run: |
|
||||||
|
pip install boto3
|
||||||
|
python3 << 'EOF'
|
||||||
|
import boto3
|
||||||
|
import os
|
||||||
|
|
||||||
|
s3 = boto3.client('s3',
|
||||||
|
endpoint_url=os.environ['AWS_ENDPOINT_URL'],
|
||||||
|
aws_access_key_id=os.environ['AWS_ACCESS_KEY_ID'],
|
||||||
|
aws_secret_access_key=os.environ['AWS_SECRET_ACCESS_KEY']
|
||||||
|
)
|
||||||
|
|
||||||
|
date = os.environ['DATE']
|
||||||
|
bucket = os.environ['BUCKET_NAME']
|
||||||
|
|
||||||
|
# upload dated version
|
||||||
|
dated_filename = f"thesis-{date}.pdf"
|
||||||
|
s3.upload_file(
|
||||||
|
'paper/build/main.pdf',
|
||||||
|
bucket,
|
||||||
|
dated_filename,
|
||||||
|
ExtraArgs={'ContentType': 'application/pdf'}
|
||||||
|
)
|
||||||
|
print(f"Uploaded {dated_filename}")
|
||||||
|
|
||||||
|
# upload latest version
|
||||||
|
s3.upload_file(
|
||||||
|
'paper/build/main.pdf',
|
||||||
|
bucket,
|
||||||
|
'thesis-latest.pdf',
|
||||||
|
ExtraArgs={'ContentType': 'application/pdf'}
|
||||||
|
)
|
||||||
|
print(f"Uploaded thesis-latest.pdf")
|
||||||
|
EOF
|
||||||
|
|||||||
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
|
||||||
32
.gitignore
vendored
@@ -1,2 +1,32 @@
|
|||||||
**/.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
|
||||||
|
=======
|
||||||
|
**/_build/
|
||||||
|
paper/src/auto/*
|
||||||
|
paper/src/bib/auto
|
||||||
|
docs/goals/*.md
|
||||||
|
PHANTOM.wiki/
|
||||||
|
experiments/airflow/logs/*
|
||||||
|
experiments/airflow/logs/scheduler/
|
||||||
|
experiments/airflow/logs/dag_processor_manager/
|
||||||
|
experiments/collected_data/
|
||||||
|
experiments/agents/collected_data/
|
||||||
|
sim/rl/behavior_loader/*.dot
|
||||||
|
sim/rl/behavior_loader/*.png
|
||||||
|
sim/rl/behavior_loader/*.svg
|
||||||
|
sim/rl/behavior_loader/*.pdf
|
||||||
|
tests/e2e/node_modules/**
|
||||||
|
lab/case/thesis/runs*/
|
||||||
|
sim/case/thesis_simplified/runs*/
|
||||||
|
|||||||
88
Makefile
@@ -4,36 +4,94 @@ 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) -f \
|
||||||
-interaction=nonstopmode -file-line-error \
|
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
|
||||||
|
|
||||||
watch: $(BUILDDIR)
|
|
||||||
@cd $(SRCDIR) && \
|
|
||||||
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) \
|
|
||||||
-interaction=nonstopmode -file-line-error \
|
-interaction=nonstopmode -file-line-error \
|
||||||
-r ../.latexmkrc \
|
-r ../.latexmkrc \
|
||||||
-outdir=../$(BUILDDIR) $(TEX)
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
clean:
|
.PHONY: pdf.watch
|
||||||
|
pdf.watch: $(BUILDDIR)
|
||||||
|
@cd $(SRCDIR) && \
|
||||||
|
$(LATEXMK) -pvc -pdf -jobname=$(JOBNAME) -f \
|
||||||
|
-interaction=nonstopmode -file-line-error \
|
||||||
|
-r ../.latexmkrc \
|
||||||
|
-outdir=../$(BUILDDIR) $(TEX)
|
||||||
|
|
||||||
|
.PHONY: pdf.clean
|
||||||
|
pdf.clean:
|
||||||
@cd $(SRCDIR) && \
|
@cd $(SRCDIR) && \
|
||||||
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
$(LATEXMK) -C -jobname=$(JOBNAME) -outdir=../$(BUILDDIR) || true
|
||||||
rm -rf paper/$(BUILDDIR)/*
|
rm -rf paper/$(BUILDDIR)/*
|
||||||
|
|
||||||
|
.PHONY: test.backend
|
||||||
|
test.backend: $(VENV)
|
||||||
|
$(PYTEST) -v
|
||||||
|
|
||||||
.PHONY: 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 wordcount
|
||||||
|
wordcount:
|
||||||
|
@echo "Counting words in main text (excluding appendix)..."
|
||||||
|
@texcount -nosub -total -sum -1 \
|
||||||
|
$(SRCDIR)/chapters/01-intro.tex \
|
||||||
|
$(SRCDIR)/chapters/02-literature-review.tex \
|
||||||
|
$(SRCDIR)/chapters/03-methodology.tex \
|
||||||
|
$(SRCDIR)/chapters/04-results.tex \
|
||||||
|
$(SRCDIR)/chapters/05-discussion.tex \
|
||||||
|
$(SRCDIR)/chapters/06-conclusion.tex
|
||||||
|
|
||||||
|
|
||||||
|
.PHONY: pdf clean watch run.webapp test count-lines all
|
||||||
|
pdf: pdf.build
|
||||||
|
clean: pdf.clean
|
||||||
|
watch: pdf.watch
|
||||||
|
run.webapp: web.dev
|
||||||
|
test: test.backend
|
||||||
|
count-lines: stats.lines
|
||||||
|
all: pdf.build
|
||||||
93
README.md
@@ -1 +1,94 @@
|
|||||||
|
<img width="200" align="left" src="https://github.com/user-attachments/assets/d148b00d-e9f9-4280-89cc-0cc866e17251" />
|
||||||
|
|
||||||
|
### PHANTOM
|
||||||
|
|
||||||
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
[](https://github.com/velocitatem/PHANTOM/actions/workflows/latex.yml)
|
||||||
|
[](https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf)
|
||||||
|
[](https://sites.research.google/trc/faq/)
|
||||||
|
[](https://phantom-hotel.vercel.app)
|
||||||
|
[](https://phantom-airline.vercel.app)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
mindmap
|
||||||
|
PHANTOM((PHANTOM Project))
|
||||||
|
North Star
|
||||||
|
Study how automated actors change markets
|
||||||
|
Build an experimentation platform for real-world-like commerce
|
||||||
|
Two-loop learning system
|
||||||
|
Online observation loop
|
||||||
|
Offline "defense gym" loop
|
||||||
|
Core Economic Questions
|
||||||
|
Price Discovery
|
||||||
|
How prices respond to demand signals
|
||||||
|
How signal quality changes with bots/agents
|
||||||
|
Demand & Elasticity
|
||||||
|
Shifts in willingness-to-pay
|
||||||
|
Short-run vs long-run elasticity
|
||||||
|
Market Efficiency & Welfare
|
||||||
|
Consumer surplus vs producer surplus
|
||||||
|
Deadweight loss from frictions/manipulation
|
||||||
|
Price Discrimination & Segmentation
|
||||||
|
Behavioral feature-based segmentation
|
||||||
|
Fairness vs profitability tradeoffs
|
||||||
|
Information Asymmetry
|
||||||
|
Agents amplify search and arbitrage
|
||||||
|
Sellers infer more about buyers; buyers infer more about sellers
|
||||||
|
Strategic Interaction
|
||||||
|
Consumers vs firms vs agents
|
||||||
|
Feedback loops: policy ↔ behavior ↔ price
|
||||||
|
Market Power & Competition
|
||||||
|
Algorithmic pricing as competitive tool
|
||||||
|
Risks: tacit coordination / "algorithmic collusion"
|
||||||
|
Externalities
|
||||||
|
Congestion and attention costs
|
||||||
|
Spillovers: one segment’s behavior affects others’ prices
|
||||||
|
System-Level View
|
||||||
|
Participants
|
||||||
|
Humans
|
||||||
|
Agents (automated buyers/actors)
|
||||||
|
Firms (pricing decision-makers)
|
||||||
|
Platform (measurement + control layer)
|
||||||
|
Markets Simulated
|
||||||
|
Repeated transactions
|
||||||
|
Limited inventory / capacity constraints (conceptually)
|
||||||
|
Time dynamics (learning over time)
|
||||||
|
Interventions
|
||||||
|
Pricing policies
|
||||||
|
Experiment assignment / randomized exposure
|
||||||
|
Agent behavioral policies (task-driven)
|
||||||
|
Measurement & Causal Inference
|
||||||
|
What is observed
|
||||||
|
Actions (search, click, purchase intent)
|
||||||
|
Context (product attributes, time, exposure)
|
||||||
|
Outcomes (conversion, revenue, churn proxies)
|
||||||
|
Identification strategy
|
||||||
|
A/B tests and randomization
|
||||||
|
Counterfactual baselines
|
||||||
|
Robustness checks (offline replay)
|
||||||
|
Key metrics
|
||||||
|
Revenue / profit proxies
|
||||||
|
Conversion & bounce
|
||||||
|
Price volatility / stability
|
||||||
|
Welfare proxies (e.g., dispersion, access)
|
||||||
|
Risk, Governance, and Ethics
|
||||||
|
Manipulation & Integrity
|
||||||
|
Bot-driven demand distortion
|
||||||
|
Measurement contamination
|
||||||
|
Fairness & Transparency
|
||||||
|
Differential pricing concerns
|
||||||
|
Explainability and auditability
|
||||||
|
Safety Constraints
|
||||||
|
Guardrails on price moves
|
||||||
|
Monitoring for runaway feedback loops
|
||||||
|
Outputs
|
||||||
|
Insights
|
||||||
|
When do agents raise/lower prices via behavior shifts?
|
||||||
|
Which market designs are robust to automation?
|
||||||
|
Defenses
|
||||||
|
Agent-aware pricing policies (robust control)
|
||||||
|
Detection + mitigation strategies (feature-level separability)
|
||||||
|
Platform Value
|
||||||
|
Reusable testbed for market + AI-agent research
|
||||||
|
```
|
||||||
|
|||||||
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"]
|
||||||
21
docs/goals/goals.csv
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
store_mode,task_name,task_description,definition_of_done
|
||||||
|
airline,The Indecisive Executive (SEA-LAX),"You are traveling SEA to LAX for business. You prefer Business Class for the comfort, but you need to justify the expense to your company. 1) Find the Business Class option and check its price. 2) Compare it against the Economy option on the same route to see how much money you are saving or spending. 3) Spend some time weighing the pros and cons of the ""Flexible"" fare rule vs the standard one. 4) Ultimately, decide that your comfort is worth it and book the Business Class ticket.","Booking for SEA-LAX Business Class is completed."
|
||||||
|
airline,The Cross-Country Splurge (LAX-JFK),"You are flying LAX to JFK and want to treat yourself to First Class, but only if it's the right flight. 1) Find the First Class option. 2) thoroughly check the details (duration, arrival time). 3) Compare it with the Business Class option if available, or just look at other departure times to ensure this is the best schedule. 4) After confirming this is the absolute best option, proceed to book First Class.","Booking for LAX-JFK First Class is completed."
|
||||||
|
airline,The Budget Student (DFW-ORD),"You are a broke student flying DFW to ORD. You have a budget of roughly $200. 1) Find the cheapest Economy flight. 2) Before booking, frantically check if there are any other flights or if the ""Premium"" economy is somehow cheaper (it won't be, but you should check). 3) Hesitate for a moment to consider if you should just drive instead. 4) Resign yourself to the flight and book the Economy ticket.","Booking for DFW-ORD Economy Class is completed."
|
||||||
|
airline,The Quick Hop Commuter (LAX-SFO),"You need to get from LAX to SFO as fast as possible. Price is secondary to speed. 1) Search for flights and identify the one with the shortest duration (1h 30m). 2) Click into the details to verify the arrival time fits your schedule. 3) briefly explore if there's a Business Class upgrade available for this short flight. 4) Decide to stick with Economy since it's such a short trip and book it.","Booking for LAX-SFO is completed."
|
||||||
|
airline,The Status Chaser (SFO-SEA),"You are trying to earn airline points and need a ""Premium"" class ticket specifically. 1) Search SFO to SEA. 2) Filter or look for the Premium Economy option. 3) Compare the price gap between Premium and Standard Economy. 4) Browse the details to see if the ""Premium"" fare includes better baggage allowance. 5) Conclude it's worth the points and book the Premium seat.","Booking for SFO-SEA Premium Economy is completed."
|
||||||
|
airline,The Family Reunion (MIA-ATL),"You are booking for a family of 4 (2 adults, 2 children) flying MIA to ATL. 1) Search for 4 passengers. 2) You prefer Premium, but if the total is too high, you might settle for Economy. 3) Add Premium to your cart, look at the total, and hesitate. 4) Go back and check the Economy price for 4 people. 5) Decide to treat your family and go back to book the Premium option.","Booking for MIA-ATL (Premium) is completed."
|
||||||
|
airline,The Red Eye Skeptic (LAX-JFK),"You need to fly LAX to JFK but hate late arrivals. 1) Search for the flight and check the arrival time of the First Class option. 2) It arrives early morning (02:15), which worries you. 3) Spend some time looking for other flight options on different days to see if there's a better schedule. 4) Realize this is the only direct option that works and proceed to book it despite the time.","Booking for LAX-JFK is completed."
|
||||||
|
airline,The Refundable Requirement (ATL-DFW),"Your meeting in Dallas might get cancelled, so you strictly need a ""Refundable"" ticket. 1) Search ATL to DFW. 2) Find the First Class option and verify it lists ""Refundable"". 3) Check the Economy option to see if it is also refundable (it might not be). 4) Weigh the cost difference. 5) Choose the First Class Refundable option for peace of mind.","Booking for ATL-DFW First Class is completed."
|
||||||
|
airline,The Hub Connector (ORD-MIA),"You are flying ORD to MIA to catch a cruise. You cannot be late. 1) Search for the flight. 2) Verify the ""stops"" is 0 (Direct). 3) Click into details to check the duration. 4) Worry that 3h 30m might be too long in Economy. 5) Look for a Business class option. 6) Decide to save money for the cruise and book Economy.","Booking for ORD-MIA Economy is completed."
|
||||||
|
airline,The West Coast Hopper (SEA-LAX Business),"You fly this route often and usually pay around $700. 1) Search SEA to LAX. 2) Find the Business Class ticket. 3) Check if the price is near your usual $720 or if it's surged. 4) If it looks expensive, browse other dates to compare. 5) Return to your original desired date and book the Business Class seat.","Booking for SEA-LAX Business is completed."
|
||||||
|
hotel,The Honeymoon Suite (Presidential),"It is your honeymoon. You want the best room available, specifically one with a ""jacuzzi"". 1) Search for a room for 2 people. 2) Identify the ""Presidential Suite"". 3) Click details to confirm the amenities include a jacuzzi. 4) Browse the ""Executive Suite"" just to see what you are upgrading from. 5) Go back to the Presidential Suite, confirm it's the one you want, and book it.","Booking for the Presidential Suite is completed."
|
||||||
|
hotel,The Digital Nomad (Executive),"You are working remotely and strictly need a ""workspace"". 1) Search for a room. 2) Check the ""Executive Suite"" details for a workspace. 3) Check the ""Deluxe Room"" to see if it also has a workspace and is cheaper. 4) Compare the images (if available) or amenity lists of both. 5) Decide the Executive Suite looks more comfortable for a week of work and book it.","Booking for the Executive Suite is completed."
|
||||||
|
hotel,The Safety First (Superior),"You are traveling with valuables and need a ""safe"" in the room. 1) Search for a room. 2) Look at the ""Standard Room"" amenities. Does it have a safe? 3) Look at the ""Superior Room"". Verify it has a safe. 4) Compare the price difference. Is safety worth the extra cost? 5) Decide it is, and book the Superior Room.","Booking for the Superior Room is completed."
|
||||||
|
hotel,The Bachelor Party (Max Occupancy),"You are booking for 4 guys. You want everyone in one room if possible. 1) Search for 4 adults. 2) Find the room that fits 4 people (Presidential). 3) It looks expensive. Go back and search for 2 adults to see the price of a ""Standard Room"". 4) Calculate if booking two Standard Rooms is cheaper than one Presidential. 5) Decide it's too much hassle to manage two bookings and book the Presidential Suite.","Booking for the Presidential Suite is completed."
|
||||||
|
hotel,The Budget Refundable (Junior),"You want a cheap room but your dates might change, so it MUST be refundable. 1) Search for a room. 2) Sort by price or find the cheapest options. 3) Check the ""Standard"" and ""Superior"" rooms. Notice they are likely Non-Refundable. 4) Find the ""Junior Suite"" which is Refundable. 5) Grumble about the price difference but book the Junior Suite because you need the flexibility.","Booking for the Junior Suite is completed."
|
||||||
|
hotel,The View Hunter (Executive),"You want a room with a ""city_view"" or balcony. 1) Search for a room. 2) Check the amenities of the ""Deluxe Room"". 3) Check the amenities of the ""Executive Suite"". 4) Compare the prices. 5) Decide to treat yourself to the Executive Suite for the better view/balcony and book it.","Booking for the Executive Suite is completed."
|
||||||
|
hotel,The Just-A-Bed (Standard),"You just need a place to crash. Lowest price wins. 1) Search for a room. 2) Identify the absolute cheapest option (Standard Room). 3) Click details just to make sure it has ""wifi"". 4) Briefly glance at the ""Superior Room"" to see if the upgrade is <$10. 5) If not, go back and book the Standard Room immediately.","Booking for the Standard Room is completed."
|
||||||
|
hotel,The Family Vacation (Deluxe),"You are traveling with a child. You need a room that isn't too cramped but not a suite. 1) Search for 2 adults, 1 child. 2) Look at the ""Deluxe Room"". 3) Check the amenities for ""coffee_maker"" (parents need coffee). 4) Compare it with the ""Junior Suite"". 5) Decide the Deluxe Room is sufficient value and book it.","Booking for the Deluxe Room is completed."
|
||||||
|
hotel,The Long Stay (Junior),"You are staying for 7 nights. You want something nicer than a standard room but affordable. 1) Search for a room. 2) Look at the ""Junior Suite"". 3) Check the amenities for a ""mini_fridge"" or similar. 4) Compare the total cost for 7 nights against your budget. 5) Hesitate and look at the ""Standard Room"" price. 6) Decide the extra space of the Junior Suite is worth it for a long stay and book it.","Booking for the Junior Suite is completed."
|
||||||
|
hotel,The Last Minute Panic (Superior),"It's late and you need a room for tonight. 1) Search for a room for 1 person. 2) You recognize the ""Superior Room"" brand. 3) Click it. 4) Quickly verify check-in times or details. 5) Don't overthink it—book the Superior Room as fast as possible.","Booking for the Superior Room is completed."
|
||||||
|
@@ -47,7 +47,7 @@
|
|||||||
<meta name="citation_author" content="Rösel, Daniel">
|
<meta name="citation_author" content="Rösel, Daniel">
|
||||||
<meta name="citation_publication_date" content="2025">
|
<meta name="citation_publication_date" content="2025">
|
||||||
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
<meta name="citation_conference_title" content="IE University Bachelor's Thesis">
|
||||||
<meta name="citation_pdf_url" content="TODO">
|
<meta name="citation_pdf_url" content="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf">
|
||||||
|
|
||||||
<!-- Additional SEO -->
|
<!-- Additional SEO -->
|
||||||
<meta name="theme-color" content="#2563eb">
|
<meta name="theme-color" content="#2563eb">
|
||||||
@@ -233,14 +233,13 @@
|
|||||||
|
|
||||||
<div class="is-size-5 publication-authors">
|
<div class="is-size-5 publication-authors">
|
||||||
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
<span class="author-block">IE University<br>Bachelor's Thesis 2025</span>
|
||||||
<span class="eql-cntrb"><small><br>Advisor: <a href="SECOND AUTHOR PERSONAL LINK" target="_blank">Alberto Martín Izquierdo</a></small></span>
|
<span class="eql-cntrb"><small><br>Advisor: Alberto Martín Izquierdo</small></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="column has-text-centered">
|
<div class="column has-text-centered">
|
||||||
<div class="publication-links">
|
<div class="publication-links">
|
||||||
<!-- TODO: Update with your arXiv paper ID -->
|
|
||||||
<span class="link-block">
|
<span class="link-block">
|
||||||
<a href="https://arxiv.org/pdf/<ARXIV PAPER ID>.pdf" target="_blank"
|
<a href="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" target="_blank"
|
||||||
class="external-link button is-normal is-rounded is-dark">
|
class="external-link button is-normal is-rounded is-dark">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-file-pdf"></i>
|
<i class="fas fa-file-pdf"></i>
|
||||||
@@ -315,7 +314,10 @@
|
|||||||
<h2 class="title is-3">Abstract</h2>
|
<h2 class="title is-3">Abstract</h2>
|
||||||
<div class="content has-text-justified">
|
<div class="content has-text-justified">
|
||||||
<p>
|
<p>
|
||||||
The primary objective of this thesis is to develop and validate pricing heuristics that protect e-commerce platforms from systematic exploitation by Large Language Model (LLM) agents within dynamic pricing environments. As AI agents increasingly mediate consumer transactions, they enable users to circumvent the Cost of Information (the price premium accumulated through demand signal expression) by conducting reconnaissance in isolated sessions before executing purchases through clean sessions at base prices. This research will make an anticipatory contribution by adapting recommendation system methodologies to distinguish between genuine human browsing behaviour and agent-orchestrated information gathering, thereby enabling pricing systems to maintain margin integrity without degrading the user experience for legitimate customers or getting rid of leads generated by LLMs.
|
This research establishes the following contributions: definition and formalization of non-human transactors in e-commerce platforms, development of a testing-ground for capturing the behavioral essence of these transactors across a large variety of digital systems, construction of a discriminative model to prove separability as a strong learner for downstream mitigation of contamination by non-human entities, translation of such learned separability into existing dynamic pricing machine learning loops, and establishment of a high-level KPI-affecting causal effect and cost-saving framework for the future of internet commerce in the presence of such non-human learners.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This work develops behavioral signature models using recommendation system techniques to profile session-level interaction, temporal engagement, and cross-session correlation. The AI Agent market is forecasted to grow from around USD 5-8 billion in 2025 to USD 42-52 billion by 2030, raising the question of how these systems should be designed for future robustness and how to maintain a competitive edge in the analytical components of e-commerce platforms.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,8 +435,7 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<h2 class="title">Poster</h2>
|
<h2 class="title">Poster</h2>
|
||||||
|
|
||||||
<!-- TODO: Replace with your poster PDF -->
|
<iframe src="https://pub-d5b94a3c29fd40c6b3881946e463fdb7.r2.dev/thesis-latest.pdf" width="100%" height="550">
|
||||||
<iframe src="static/pdfs/sample.pdf" width="100%" height="550">
|
|
||||||
</iframe>
|
</iframe>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
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 |
66
engine/engine.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
from sys import platform
|
||||||
|
import numpy as np
|
||||||
|
from .lib.demand import generate_demand, estimate_demand
|
||||||
|
from .lib.behavior import sample_behavior
|
||||||
|
from logging import INFO, getLogger
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
logger.setLevel(INFO)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MarketEngine():
|
||||||
|
def __init__(self,
|
||||||
|
alpha = 0.5,
|
||||||
|
N = 100,
|
||||||
|
demand_distribution = (50, 10),
|
||||||
|
demand_sampling_function = np.random.normal):
|
||||||
|
self.Nagents = int(N*alpha)
|
||||||
|
self.Nhumans = int(N*(1-alpha))
|
||||||
|
self.demand = (demand_sampling_function, demand_distribution)
|
||||||
|
|
||||||
|
def act(self, prices):
|
||||||
|
demand = generate_demand(prices, *self.demand)
|
||||||
|
sample_n = lambda n, human: [sample_behavior(demand, human=human) for _ in range(n)]
|
||||||
|
human_t, agent_t = sample_n(self.Nhumans, True), sample_n(self.Nagents, False)
|
||||||
|
trajectories = human_t + agent_t
|
||||||
|
demand_estimate = estimate_demand(trajectories)
|
||||||
|
return demand_estimate
|
||||||
|
|
||||||
|
def measure(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class PricingEngine():
|
||||||
|
def __init__(self,
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def act(self, demand):
|
||||||
|
return np.random.uniform(low=25, high=100, size=10)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class Limbo():
|
||||||
|
def __init__(self,
|
||||||
|
platform,
|
||||||
|
market
|
||||||
|
) -> None:
|
||||||
|
self.platform_turn = True
|
||||||
|
self.platform = platform
|
||||||
|
self.market = market
|
||||||
|
self.output = None
|
||||||
|
|
||||||
|
def step(self):
|
||||||
|
# we could code golf this a little bit
|
||||||
|
if self.platform_turn:
|
||||||
|
self.output = self.platform.act(self.output)
|
||||||
|
else:
|
||||||
|
self.output = self.market.act(self.output)
|
||||||
|
print(self.output)
|
||||||
|
self.platform_turn = not self.platform_turn
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
platform = PricingEngine()
|
||||||
|
market = MarketEngine()
|
||||||
|
limbo = Limbo(platform, market)
|
||||||
|
for _ in range(10):
|
||||||
|
limbo.step()
|
||||||
3
engine/lib/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .demand import generate_demand, estimate_demand
|
||||||
|
from .behavior import sample_behavior
|
||||||
|
from .render import DashboardRenderer, style_axis
|
||||||
47
engine/lib/behavior.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
from sim.rl.behavior_loader.models import BehaviorModel, AgentBehaviorModel, aggregate_event_transitions
|
||||||
|
import pandas as pd
|
||||||
|
import numpy as np
|
||||||
|
from .demand import generate_demand
|
||||||
|
|
||||||
|
base_dir = "/home/velocitatem/Documents/Projects/PHANTOM/experiments"
|
||||||
|
human_dir, agent_dir = f"{base_dir}/collected_data/", f"{base_dir}/agents/collected_data/"
|
||||||
|
|
||||||
|
_cache = {} # lazy cache for models and base pivots
|
||||||
|
|
||||||
|
def _get_base_pivot(human: bool):
|
||||||
|
key = 'human' if human else 'agent'
|
||||||
|
if key not in _cache:
|
||||||
|
model = BehaviorModel(human_dir) if human else AgentBehaviorModel(agent_dir)
|
||||||
|
mdp = model.build_MDP()
|
||||||
|
_cache[key] = pd.DataFrame(aggregate_event_transitions(mdp)).fillna(0.0)
|
||||||
|
return _cache[key]
|
||||||
|
|
||||||
|
def adjust_behavior_to_condition(condition, transition_matrix):
|
||||||
|
# expand NxN transition matrix to (N*P)x(N*P) weighted by demand condition
|
||||||
|
cond_norm = condition / np.sum(condition)
|
||||||
|
n_products = len(condition)
|
||||||
|
base_vals = transition_matrix.values
|
||||||
|
base_cols, base_rows = transition_matrix.columns.tolist(), transition_matrix.index.tolist()
|
||||||
|
|
||||||
|
# expand via kronecker-like tiling: each cell becomes a P*P block weighted by outer product of cond_norm
|
||||||
|
expanded = np.kron(base_vals, np.outer(cond_norm, cond_norm))
|
||||||
|
new_cols = [f"{c}_product{p}" for c in base_cols for p in range(n_products)]
|
||||||
|
new_rows = [f"{r}_product{p}" for r in base_rows for p in range(n_products)]
|
||||||
|
return pd.DataFrame(expanded, index=new_rows, columns=new_cols)
|
||||||
|
|
||||||
|
def sample_behavior(condition, human=True, max_len=40):
|
||||||
|
base_pivot = _get_base_pivot(human)
|
||||||
|
adjusted_transitions = adjust_behavior_to_condition(condition, base_pivot)
|
||||||
|
|
||||||
|
trajectory = [np.random.choice(adjusted_transitions.index)]
|
||||||
|
while len(trajectory) < max_len or 'checkout' in trajectory[-1]:
|
||||||
|
probs = adjusted_transitions.loc[trajectory[-1]].values
|
||||||
|
sample = np.random.choice(adjusted_transitions.columns, p=probs/np.sum(probs) if np.sum(probs) > 0 else None)
|
||||||
|
trajectory.append(sample)
|
||||||
|
return trajectory
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
t=sample_behavior(generate_demand(np.array([10,20,30])), human=True)
|
||||||
|
print(t)
|
||||||
|
t=sample_behavior(generate_demand(np.array([10,20,30])), human=False)
|
||||||
|
print(t)
|
||||||
45
engine/lib/demand.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import logging
|
||||||
|
import numpy as np
|
||||||
|
from logging import getLogger
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
def generate_demand(prices, distribution_method = np.random.normal, distribution_params = (50.0, 10.0)):
|
||||||
|
# assumption 1: each product has an intrinsic valuation drawn from a normal distribution centered at 50
|
||||||
|
product_valuations = distribution_method(*distribution_params, size=len(prices))
|
||||||
|
# assumption 2: demand decreases as price increases, following a simple linear model
|
||||||
|
demand = np.maximum(0, product_valuations - prices) # demand cannot be negative
|
||||||
|
total = np.sum(demand)
|
||||||
|
demand = demand / total * 100 if total > 0 else demand # normalize to percentage, avoid div by zero
|
||||||
|
logger.info(f"Generated demand for prices {prices}: {demand} with valuations from distribution {distribution_params}")
|
||||||
|
return demand
|
||||||
|
|
||||||
|
def estimate_demand(trajectories):
|
||||||
|
demand_estimate = {}
|
||||||
|
for traj in trajectories:
|
||||||
|
for event in traj:
|
||||||
|
if 'view_product' in event:
|
||||||
|
product_id = int(event.split('_')[-1].replace('product', ''))
|
||||||
|
demand_estimate[product_id] = demand_estimate.get(product_id, 0) + 1
|
||||||
|
total_views = sum(demand_estimate.values())
|
||||||
|
for product_id in demand_estimate:
|
||||||
|
demand_estimate[product_id] = (demand_estimate[product_id] / total_views) * 100 # normalize to percentage
|
||||||
|
return demand_estimate
|
||||||
|
|
||||||
|
# Example usage
|
||||||
|
if __name__ == "__main__":
|
||||||
|
np.random.seed(42)
|
||||||
|
prices = np.array([20.0, 35.0, 50.0, 65.0])
|
||||||
|
demand = generate_demand(prices)
|
||||||
|
print("Generated Demand:", demand)
|
||||||
|
from .behavior import sample_behavior
|
||||||
|
N, alphat =200, 0.1
|
||||||
|
trajectories = []
|
||||||
|
for _ in range(int(N*(1 - alphat))):
|
||||||
|
trajectories.append(sample_behavior(demand, human=True))
|
||||||
|
for _ in range(int(N*alphat)):
|
||||||
|
trajectories.append(sample_behavior(demand, human=False))
|
||||||
|
demand_estimate = estimate_demand(trajectories)
|
||||||
|
print("Estimated Demand from Behavior:", demand_estimate)
|
||||||
|
delta = {k: demand_estimate.get(k, 0) - demand[i] for i, k in enumerate(range(len(prices)))}
|
||||||
|
delta = np.mean([np.abs(v) for v in delta.values()])
|
||||||
|
print("Demand Delta:", delta)
|
||||||
126
engine/lib/render.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""rendering logic for PHANTOM environment dashboard"""
|
||||||
|
import numpy as np
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.gridspec import GridSpec
|
||||||
|
|
||||||
|
|
||||||
|
def style_axis(ax, title: str = None, xlabel: str = None, ylabel: str = None):
|
||||||
|
ax.spines['top'].set_visible(False)
|
||||||
|
ax.spines['right'].set_visible(False)
|
||||||
|
if title: ax.set_title(title, fontsize=11, fontweight='bold', pad=8)
|
||||||
|
if xlabel: ax.set_xlabel(xlabel, fontsize=9)
|
||||||
|
if ylabel: ax.set_ylabel(ylabel, fontsize=9)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardRenderer:
|
||||||
|
"""stateful renderer for PHANTOM market dynamics visualization"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.fig = None
|
||||||
|
self.gs = None
|
||||||
|
|
||||||
|
def render(self, env) -> None:
|
||||||
|
if self.fig is None:
|
||||||
|
plt.ion()
|
||||||
|
self.fig = plt.figure(figsize=(14, 10))
|
||||||
|
self.gs = GridSpec(3, 3, figure=self.fig, hspace=0.35, wspace=0.3,
|
||||||
|
left=0.07, right=0.95, top=0.92, bottom=0.08)
|
||||||
|
plt.show(block=False)
|
||||||
|
|
||||||
|
self.fig.clear()
|
||||||
|
self.fig.suptitle(f'PHANTOM Market Dynamics [t={env._step_count}, a={env.alpha:.2f}]',
|
||||||
|
fontsize=14, fontweight='bold')
|
||||||
|
|
||||||
|
demand_mat = np.array(env._demand_history).T
|
||||||
|
price_mat = np.array(env._price_history).T
|
||||||
|
elasticity = env._compute_elasticity()
|
||||||
|
|
||||||
|
self._render_scatter(env)
|
||||||
|
self._render_elasticity_bar(env, elasticity)
|
||||||
|
self._render_session_pie(env)
|
||||||
|
self._render_price_heatmap(price_mat)
|
||||||
|
self._render_demand_heatmap(demand_mat)
|
||||||
|
self._render_correlation(env.n_products, price_mat, demand_mat)
|
||||||
|
self._render_revenue(env)
|
||||||
|
|
||||||
|
self.fig.canvas.draw_idle()
|
||||||
|
self.fig.canvas.flush_events()
|
||||||
|
|
||||||
|
def _render_scatter(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 0])
|
||||||
|
prices_flat = np.array(env._price_history).flatten()
|
||||||
|
demands_flat = np.array(env._demand_history).flatten()
|
||||||
|
product_ids = np.tile(np.arange(env.n_products), len(env._price_history))
|
||||||
|
ax.scatter(prices_flat, demands_flat, c=product_ids, cmap='plasma', alpha=0.6, s=15, edgecolors='none')
|
||||||
|
if len(prices_flat) > 1:
|
||||||
|
z = np.polyfit(prices_flat, demands_flat, 1)
|
||||||
|
p_line = np.linspace(prices_flat.min(), prices_flat.max(), 50)
|
||||||
|
ax.plot(p_line, np.polyval(z, p_line), '--', lw=1.5, alpha=0.8)
|
||||||
|
style_axis(ax, "Price-Demand Relationship", "Price ($)", "Demand")
|
||||||
|
|
||||||
|
def _render_elasticity_bar(self, env, elasticity):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 1])
|
||||||
|
ax.barh(range(env.n_products), elasticity, alpha=0.8)
|
||||||
|
ax.axvline(0, lw=0.8, alpha=0.5)
|
||||||
|
ax.axvline(-1, lw=1, ls='--', alpha=0.5)
|
||||||
|
ax.set_yticks(range(env.n_products))
|
||||||
|
ax.set_yticklabels([f'P{i}' for i in range(env.n_products)], fontsize=7)
|
||||||
|
style_axis(ax, "Price Elasticity", "(dQ/dP)(P/Q)", None)
|
||||||
|
|
||||||
|
def _render_session_pie(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[0, 2])
|
||||||
|
n_h, n_a = env.market.Nhumans, env.market.Nagents
|
||||||
|
wedges, _ = ax.pie([n_h, n_a], startangle=90, wedgeprops={'linewidth': 2, 'edgecolor': 'white'})
|
||||||
|
ax.legend(wedges, [f'H ({n_h})', f'A ({n_a})'], loc='lower center', fontsize=8,
|
||||||
|
frameon=False, bbox_to_anchor=(0.5, -0.05))
|
||||||
|
ax.set_title("Session Mix", fontsize=11, fontweight='bold')
|
||||||
|
|
||||||
|
def _render_price_heatmap(self, price_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[1, :2])
|
||||||
|
im = ax.imshow(price_mat, aspect='auto', cmap='viridis', origin='lower')
|
||||||
|
style_axis(ax, "Price Heatmap P(product, t)", "Step", "Product")
|
||||||
|
cbar = self.fig.colorbar(im, ax=ax, fraction=0.03, pad=0.02)
|
||||||
|
cbar.set_label('$', fontsize=8)
|
||||||
|
|
||||||
|
def _render_demand_heatmap(self, demand_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[1, 2])
|
||||||
|
im = ax.imshow(demand_mat, aspect='auto', cmap='Blues', origin='lower')
|
||||||
|
style_axis(ax, "Demand Q(product, t)", "Step", None)
|
||||||
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
|
|
||||||
|
def _render_correlation(self, n_products, price_mat, demand_mat):
|
||||||
|
ax = self.fig.add_subplot(self.gs[2, 0])
|
||||||
|
if price_mat.shape[1] > 2:
|
||||||
|
corr = np.corrcoef(price_mat, demand_mat)[:n_products, n_products:]
|
||||||
|
im = ax.imshow(corr, cmap='RdBu', vmin=-1, vmax=1, aspect='auto')
|
||||||
|
ax.set_xticks(range(n_products))
|
||||||
|
ax.set_yticks(range(n_products))
|
||||||
|
ax.set_xticklabels([f'Q{i}' for i in range(n_products)], fontsize=6)
|
||||||
|
ax.set_yticklabels([f'P{i}' for i in range(n_products)], fontsize=6)
|
||||||
|
self.fig.colorbar(im, ax=ax, fraction=0.046, pad=0.02)
|
||||||
|
style_axis(ax, "Price-Demand Correlation", None, None)
|
||||||
|
|
||||||
|
def _render_revenue(self, env):
|
||||||
|
ax = self.fig.add_subplot(self.gs[2, 1:])
|
||||||
|
n_steps = len(env._revenue_history)
|
||||||
|
demand_std = [np.std(d) for d in env._demand_history]
|
||||||
|
ax.fill_between(range(n_steps), env._revenue_history, alpha=0.3)
|
||||||
|
ax.plot(env._revenue_history, linewidth=2, label='Revenue')
|
||||||
|
ax.set_xlim(0, max(n_steps, 1))
|
||||||
|
ax.set_ylim(0, max(env._revenue_history) * 1.1 if env._revenue_history else 1)
|
||||||
|
|
||||||
|
ax2 = ax.twinx()
|
||||||
|
ax2.plot(range(n_steps), demand_std, linewidth=2, ls='-', alpha=0.9, label='sigma(Demand)')
|
||||||
|
d_min, d_max = min(demand_std), max(demand_std)
|
||||||
|
margin = (d_max - d_min) * 0.2 if d_max > d_min else 0.5
|
||||||
|
ax2.set_ylim(max(0, d_min - margin), d_max + margin)
|
||||||
|
ax2.set_ylabel('Demand sigma', fontsize=9)
|
||||||
|
|
||||||
|
style_axis(ax, "Revenue & Demand Dispersion", "Step", "Revenue ($)")
|
||||||
|
ax.legend(loc='upper left', fontsize=7, frameon=False)
|
||||||
|
ax2.legend(loc='upper right', fontsize=7, frameon=False)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.fig:
|
||||||
|
plt.close(self.fig)
|
||||||
|
self.fig = None
|
||||||
34
engine/studies/factors.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""shared factor definitions for experimental designs"""
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Factor:
|
||||||
|
name: str
|
||||||
|
levels: list
|
||||||
|
primary: bool = True # full cross vs sampled
|
||||||
|
|
||||||
|
# demand functions with compatible signatures
|
||||||
|
def demand_linear(mu, sigma, size): return np.maximum(0, np.random.normal(mu, sigma, size))
|
||||||
|
def demand_uniform(mu, sigma, size): return np.random.uniform(mu - sigma, mu + sigma, size)
|
||||||
|
def demand_exponential(mu, sigma, size): return np.random.exponential(mu, size)
|
||||||
|
def demand_logistic(mu, sigma, size): return np.random.logistic(mu, sigma, size)
|
||||||
|
|
||||||
|
DEMAND_FUNCTIONS = {
|
||||||
|
"linear": demand_linear,
|
||||||
|
"uniform": demand_uniform,
|
||||||
|
"exponential": demand_exponential,
|
||||||
|
"logistic": demand_logistic,
|
||||||
|
}
|
||||||
|
|
||||||
|
FACTORS = [
|
||||||
|
Factor("demand_fn", list(DEMAND_FUNCTIONS.keys()), primary=True),
|
||||||
|
Factor("alpha", [0.1, 0.3, 0.5, 0.7], primary=True),
|
||||||
|
Factor("n_products", [5, 15, 30, 50], primary=True),
|
||||||
|
Factor("demand_mu", [30.0, 50.0, 70.0], primary=False),
|
||||||
|
Factor("demand_sigma", [5.0, 10.0, 20.0], primary=False),
|
||||||
|
Factor("N", [100, 500, 1000], primary=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
SEEDS_PER_CONFIG = 5
|
||||||
89
engine/studies/full_factorial.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""full factorial design - all factor combinations"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "..")
|
||||||
|
import logging
|
||||||
|
from itertools import product
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from .factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def generate_configs():
|
||||||
|
"""generate all factor combinations with seeds"""
|
||||||
|
all_levels = [f.levels for f in FACTORS]
|
||||||
|
names = [f.name for f in FACTORS]
|
||||||
|
|
||||||
|
configs = []
|
||||||
|
for combo in product(*all_levels):
|
||||||
|
base = {names[i]: combo[i] for i in range(len(names))}
|
||||||
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
|
cfg = {**base, "seed": seed}
|
||||||
|
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
||||||
|
configs.append(cfg)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
def run_single(cfg: dict) -> dict:
|
||||||
|
"""execute one experiment config, return metrics"""
|
||||||
|
from engine.wrapper import PHANTOM
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
np.random.seed(cfg["seed"])
|
||||||
|
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=cfg["n_products"],
|
||||||
|
alpha=cfg["alpha"],
|
||||||
|
N=cfg["N"],
|
||||||
|
)
|
||||||
|
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||||
|
|
||||||
|
obs, _ = env.reset()
|
||||||
|
total_reward, steps = 0.0, 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
|
total_reward += reward
|
||||||
|
steps += 1
|
||||||
|
if term: break
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
return {
|
||||||
|
"id": cfg["id"],
|
||||||
|
"config": cfg,
|
||||||
|
"total_reward": total_reward,
|
||||||
|
"avg_reward": total_reward / steps if steps > 0 else 0.0,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_study(max_workers: int = None, output: str = "results_full.jsonl"):
|
||||||
|
configs = generate_configs()
|
||||||
|
log.info(f"full factorial: {len(configs)} configs ({len(configs)//SEEDS_PER_CONFIG} unique × {SEEDS_PER_CONFIG} seeds)")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
|
results.append(result)
|
||||||
|
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
||||||
|
|
||||||
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--workers", type=int, default=None)
|
||||||
|
p.add_argument("--output", default="results_full.jsonl")
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
configs = generate_configs()
|
||||||
|
log.info(f"design: {len(configs)} runs | factors: {[f.name for f in FACTORS]} | levels: {[len(f.levels) for f in FACTORS]}")
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
run_study(args.workers, args.output)
|
||||||
106
engine/studies/mixed_lh.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""mixed design: full factorial on primary factors, latin hypercube on secondary"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, "..")
|
||||||
|
import logging
|
||||||
|
from itertools import product
|
||||||
|
import json
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
import numpy as np
|
||||||
|
from scipy.stats.qmc import LatinHypercube
|
||||||
|
from factors import FACTORS, DEMAND_FUNCTIONS, SEEDS_PER_CONFIG
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LH_SAMPLES = 10
|
||||||
|
|
||||||
|
def generate_configs(lh_samples: int = LH_SAMPLES):
|
||||||
|
primary = [f for f in FACTORS if f.primary]
|
||||||
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
|
|
||||||
|
primary_grid = list(product(*[f.levels for f in primary]))
|
||||||
|
lhs = LatinHypercube(d=len(secondary), seed=42)
|
||||||
|
|
||||||
|
configs = []
|
||||||
|
for p_combo in primary_grid:
|
||||||
|
samples = lhs.random(n=lh_samples)
|
||||||
|
for s in samples:
|
||||||
|
sec_vals = {
|
||||||
|
secondary[i].name: secondary[i].levels[int(s[i] * len(secondary[i].levels))]
|
||||||
|
for i in range(len(secondary))
|
||||||
|
}
|
||||||
|
base = {primary[i].name: p_combo[i] for i in range(len(primary))}
|
||||||
|
base.update(sec_vals)
|
||||||
|
|
||||||
|
for seed in range(SEEDS_PER_CONFIG):
|
||||||
|
cfg = {**base, "seed": seed}
|
||||||
|
cfg["id"] = hashlib.md5(json.dumps(cfg, sort_keys=True).encode()).hexdigest()[:8]
|
||||||
|
configs.append(cfg)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
def run_single(cfg: dict) -> dict:
|
||||||
|
from engine.wrapper import PHANTOM
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
np.random.seed(cfg["seed"])
|
||||||
|
demand_fn = DEMAND_FUNCTIONS[cfg["demand_fn"]]
|
||||||
|
|
||||||
|
env = PHANTOM(
|
||||||
|
n_products=cfg["n_products"],
|
||||||
|
alpha=cfg["alpha"],
|
||||||
|
N=cfg["N"],
|
||||||
|
)
|
||||||
|
env.market.demand = (demand_fn, (cfg["demand_mu"], cfg["demand_sigma"]))
|
||||||
|
|
||||||
|
obs, _ = env.reset()
|
||||||
|
total_reward, steps = 0.0, 0
|
||||||
|
|
||||||
|
for _ in range(100):
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
|
total_reward += reward
|
||||||
|
steps += 1
|
||||||
|
if term: break
|
||||||
|
|
||||||
|
env.close()
|
||||||
|
return {
|
||||||
|
"id": cfg["id"],
|
||||||
|
"config": cfg,
|
||||||
|
"total_reward": total_reward,
|
||||||
|
"avg_reward": total_reward / steps,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_study(max_workers: int = None, output: str = "results_mixed.jsonl", lh_samples: int = LH_SAMPLES):
|
||||||
|
configs = generate_configs(lh_samples)
|
||||||
|
n_primary_cells = int(np.prod([len(f.levels) for f in FACTORS if f.primary]))
|
||||||
|
log.info(f"mixed LH: {len(configs)} configs ({n_primary_cells} primary × {lh_samples} LH × {SEEDS_PER_CONFIG} seeds)")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
with ProcessPoolExecutor(max_workers=max_workers) as ex:
|
||||||
|
for i, result in enumerate(ex.map(run_single, configs)):
|
||||||
|
results.append(result)
|
||||||
|
if (i+1) % 100 == 0: log.info(f"progress: {i+1}/{len(configs)}")
|
||||||
|
|
||||||
|
Path(output).write_text("\n".join(json.dumps(r) for r in results))
|
||||||
|
log.info(f"wrote {len(results)} results to {output}")
|
||||||
|
return results
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import argparse
|
||||||
|
p = argparse.ArgumentParser()
|
||||||
|
p.add_argument("--workers", type=int, default=None)
|
||||||
|
p.add_argument("--output", default="results_mixed.jsonl")
|
||||||
|
p.add_argument("--lh-samples", type=int, default=10)
|
||||||
|
p.add_argument("--dry-run", action="store_true", help="only show design size")
|
||||||
|
args = p.parse_args()
|
||||||
|
|
||||||
|
primary = [f for f in FACTORS if f.primary]
|
||||||
|
secondary = [f for f in FACTORS if not f.primary]
|
||||||
|
configs = generate_configs(args.lh_samples)
|
||||||
|
log.info(f"design: {len(configs)} runs | primary: {[f.name for f in primary]} | secondary (LH): {[f.name for f in secondary]}")
|
||||||
|
|
||||||
|
if not args.dry_run:
|
||||||
|
run_study(args.workers, args.output, args.lh_samples)
|
||||||
45
engine/train.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from stable_baselines3 import SAC
|
||||||
|
from stable_baselines3.common.callbacks import EvalCallback, BaseCallback
|
||||||
|
from .wrapper import PHANTOM
|
||||||
|
|
||||||
|
|
||||||
|
class RenderCallback(BaseCallback):
|
||||||
|
"""Renders environment on every step for live visualization."""
|
||||||
|
def __init__(self, env: PHANTOM):
|
||||||
|
super().__init__()
|
||||||
|
self.env = env
|
||||||
|
|
||||||
|
def _on_step(self) -> bool:
|
||||||
|
self.env.render()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
|
||||||
|
eval_env = PHANTOM(n_products=10, alpha=0.3, render_mode=None)
|
||||||
|
|
||||||
|
model = SAC(
|
||||||
|
"MultiInputPolicy",
|
||||||
|
env,
|
||||||
|
verbose=1,
|
||||||
|
learning_rate=3e-4,
|
||||||
|
buffer_size=50000,
|
||||||
|
batch_size=256,
|
||||||
|
tau=0.005,
|
||||||
|
gamma=0.99,
|
||||||
|
)
|
||||||
|
|
||||||
|
render_cb = RenderCallback(env)
|
||||||
|
eval_cb = EvalCallback(eval_env, eval_freq=1000, n_eval_episodes=5, verbose=1)
|
||||||
|
|
||||||
|
model.learn(total_timesteps=50000, callback=[render_cb, eval_cb])
|
||||||
|
model.save("phantom_sac")
|
||||||
|
|
||||||
|
# test trained policy
|
||||||
|
env = PHANTOM(n_products=10, alpha=0.3, render_mode="human")
|
||||||
|
obs, _ = env.reset()
|
||||||
|
for _ in range(100):
|
||||||
|
action, _ = model.predict(obs, deterministic=True)
|
||||||
|
obs, reward, term, trunc, _ = env.step(action)
|
||||||
|
env.render()
|
||||||
|
if term or trunc: break
|
||||||
|
env.close()
|
||||||
118
engine/wrapper.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import gymnasium as gym
|
||||||
|
from gymnasium import spaces
|
||||||
|
import numpy as np
|
||||||
|
from .engine import Limbo, MarketEngine, PricingEngine
|
||||||
|
from .lib.render import DashboardRenderer
|
||||||
|
|
||||||
|
|
||||||
|
class PHANTOM(gym.Env):
|
||||||
|
"""Gymnasium wrapper for the Limbo pricing-market simulation. Platform sets prices, market responds with demand."""
|
||||||
|
metadata = {"render_modes": ["human", "ansi"]}
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
n_products: int = 10,
|
||||||
|
alpha: float = 0.3,
|
||||||
|
N: int = 100,
|
||||||
|
price_bounds: tuple = (10.0, 150.0),
|
||||||
|
lambda_coi: float = 0.1,
|
||||||
|
render_mode: str = None):
|
||||||
|
super().__init__()
|
||||||
|
self.n_products = n_products
|
||||||
|
self.price_bounds = price_bounds
|
||||||
|
self.lambda_coi = lambda_coi
|
||||||
|
self.render_mode = render_mode
|
||||||
|
self.alpha = alpha
|
||||||
|
self.N = N
|
||||||
|
|
||||||
|
self.market = MarketEngine(alpha=alpha, N=N)
|
||||||
|
self._platform_stub = PricingEngine()
|
||||||
|
self._limbo = Limbo(self._platform_stub, self.market)
|
||||||
|
|
||||||
|
self.action_space = spaces.Box(
|
||||||
|
low=price_bounds[0], high=price_bounds[1],
|
||||||
|
shape=(n_products,), dtype=np.float32
|
||||||
|
)
|
||||||
|
self.observation_space = spaces.Dict({
|
||||||
|
"demand": spaces.Box(low=0.0, high=100.0, shape=(n_products,), dtype=np.float32),
|
||||||
|
"prices": spaces.Box(low=price_bounds[0], high=price_bounds[1], shape=(n_products,), dtype=np.float32),
|
||||||
|
})
|
||||||
|
|
||||||
|
self._prices = None
|
||||||
|
self._demand = None
|
||||||
|
self._step_count = 0
|
||||||
|
self._demand_history = []
|
||||||
|
self._price_history = []
|
||||||
|
self._revenue_history = []
|
||||||
|
self._renderer = None
|
||||||
|
|
||||||
|
def _get_obs(self) -> dict:
|
||||||
|
demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)], dtype=np.float32)
|
||||||
|
return {"demand": demand_arr, "prices": self._prices.astype(np.float32)}
|
||||||
|
|
||||||
|
def _compute_reward(self, prices: np.ndarray, demand: dict) -> float:
|
||||||
|
revenue = np.sum(prices * np.array([demand.get(i, 0.0) for i in range(self.n_products)]))
|
||||||
|
# TODO: implement supra-competitive price punishment
|
||||||
|
return float(revenue)
|
||||||
|
|
||||||
|
def _record_history(self):
|
||||||
|
demand_arr = np.array([self._demand.get(i, 0.0) for i in range(self.n_products)])
|
||||||
|
self._demand_history.append(demand_arr)
|
||||||
|
self._price_history.append(self._prices.copy())
|
||||||
|
self._revenue_history.append(np.sum(self._prices * demand_arr))
|
||||||
|
|
||||||
|
def reset(self, seed=None, options=None):
|
||||||
|
super().reset(seed=seed)
|
||||||
|
self._prices = np.random.uniform(*self.price_bounds, size=self.n_products)
|
||||||
|
self._demand = self.market.act(self._prices)
|
||||||
|
self._step_count = 0
|
||||||
|
self._demand_history, self._price_history, self._revenue_history = [], [], []
|
||||||
|
self._record_history()
|
||||||
|
return self._get_obs(), {}
|
||||||
|
|
||||||
|
def step(self, action: np.ndarray):
|
||||||
|
self._prices = np.clip(action, *self.price_bounds)
|
||||||
|
self._demand = self.market.act(self._prices)
|
||||||
|
self._step_count += 1
|
||||||
|
self._record_history()
|
||||||
|
|
||||||
|
reward = self._compute_reward(self._prices, self._demand)
|
||||||
|
terminated = self._step_count >= 100
|
||||||
|
|
||||||
|
return self._get_obs(), reward, terminated, False, {"step": self._step_count}
|
||||||
|
|
||||||
|
def _compute_elasticity(self) -> np.ndarray:
|
||||||
|
"""point elasticity: e = (dQ/dP) * (P/Q) via finite differences, clipped to [-5, 5]"""
|
||||||
|
if len(self._price_history) < 2:
|
||||||
|
return np.zeros(self.n_products)
|
||||||
|
p, q = np.array(self._price_history), np.array(self._demand_history)
|
||||||
|
dp, dq = np.diff(p, axis=0), np.diff(q, axis=0)
|
||||||
|
valid = np.abs(dp) > 0.5
|
||||||
|
with np.errstate(divide='ignore', invalid='ignore'):
|
||||||
|
elasticity = np.where(valid, (dq / dp) * (p[:-1] / np.maximum(q[:-1], 1.0)), 0.0)
|
||||||
|
elasticity = np.nan_to_num(np.clip(elasticity, -5.0, 5.0), nan=0.0)
|
||||||
|
return np.mean(elasticity, axis=0) if len(elasticity) > 0 else np.zeros(self.n_products)
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
if self.render_mode == "human":
|
||||||
|
if self._renderer is None:
|
||||||
|
self._renderer = DashboardRenderer()
|
||||||
|
self._renderer.render(self)
|
||||||
|
elif self.render_mode == "ansi":
|
||||||
|
return f"step={self._step_count}, prices={self._prices}, demand={self._demand}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self._renderer:
|
||||||
|
self._renderer.close()
|
||||||
|
self._renderer = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
env = PHANTOM(n_products=15, alpha=0.3, N=100, render_mode="human")
|
||||||
|
obs, _ = env.reset()
|
||||||
|
for step in range(100):
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, term, trunc, info = env.step(action)
|
||||||
|
env.render()
|
||||||
|
if term: break
|
||||||
|
env.close()
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
# Products
|
||||||
|
# Agents
|
||||||
|
# Pipeline
|
||||||
|
|
||||||
|
Our pipeline technically should follow principles in a style like this:
|
||||||
|
- Each step should be defined as an inheriting child of an scikit pipeline step, the granularity of the steps is dictated by the following: a step should be a transformation, augmentation or computation independently, no single stage should run multiple in-itself. This way we can modularize properly all the components and track properly in airflow. A stage can be defined as an sklearn step but then must be transalted to a function that takes the context in our DAG of airflow. All parametrization must be done via contexts.
|
||||||
|
|
||||||
|
|||||||
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',
|
||||||
|
]
|
||||||
114
experiments/procesing/contaminator.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from lib.separability import estimate_alpha, load_artifacts, score_session
|
||||||
|
|
||||||
|
|
||||||
|
# use relative import when in package context, fallback for standalone
|
||||||
|
try:
|
||||||
|
from sim.rl.behavior_loader.models import AgentBehaviorModel
|
||||||
|
except ImportError:
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "sim" / "rl" / "behavior_loader"))
|
||||||
|
from models import AgentBehaviorModel
|
||||||
|
|
||||||
|
# paths should be configurable via environment or relative to project root
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||||
|
AGENT_DATA_DIR = Path(os.getenv('PHANTOM_AGENT_DATA_DIR', PROJECT_ROOT / "experiments" / "agents" / "collected_data"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
SEPARABILITY_ARTIFACTS = load_artifacts()
|
||||||
|
except FileNotFoundError:
|
||||||
|
SEPARABILITY_ARTIFACTS = None
|
||||||
|
|
||||||
|
|
||||||
|
def remap_schema(df: pd.DataFrame, mapping: dict, on: str = "event_type") -> pd.DataFrame:
|
||||||
|
"""remap column values according to mapping dict, preserving unmapped values"""
|
||||||
|
df = df.copy()
|
||||||
|
df[on] = df[on].map(mapping).fillna(df[on])
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
def _states_to_events(states: list[str]) -> list[SimpleNamespace]:
|
||||||
|
events: list[SimpleNamespace] = []
|
||||||
|
for idx, state in enumerate(states):
|
||||||
|
parts = state.split("|") if isinstance(state, str) else ["page", "product", str(state)]
|
||||||
|
page = f"/{parts[0]}" if parts else "/"
|
||||||
|
product = parts[1] if len(parts) > 1 else "unknown"
|
||||||
|
event_name = parts[2] if len(parts) > 2 else parts[-1]
|
||||||
|
events.append(
|
||||||
|
SimpleNamespace(
|
||||||
|
eventName=event_name,
|
||||||
|
page=page,
|
||||||
|
productId=product,
|
||||||
|
ts=float(idx),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
def contaminate_dataset(df: pd.DataFrame, on: str = "event_type",
|
||||||
|
contamination_rate: float = 0.1,
|
||||||
|
agent_data_dir: Path = None) -> pd.DataFrame:
|
||||||
|
"""inject synthetic agent trajectories into a dataset
|
||||||
|
contamination_rate: fraction of final dataset that should be agent data (0.1 = 10% agents)
|
||||||
|
"""
|
||||||
|
data_dir = agent_data_dir or AGENT_DATA_DIR
|
||||||
|
model = AgentBehaviorModel(str(data_dir))
|
||||||
|
model.build_MDP() # ensure MDP is built before sampling
|
||||||
|
|
||||||
|
# compute event distribution from original data
|
||||||
|
event_dist = df[on].value_counts(normalize=True).to_dict()
|
||||||
|
total = sum(event_dist.values())
|
||||||
|
event_dist = {k: v / total for k, v in event_dist.items()}
|
||||||
|
|
||||||
|
# calculate how many synthetic events to add
|
||||||
|
N = len(df)
|
||||||
|
N_final = N / (1 - contamination_rate)
|
||||||
|
N_contaminate = int(N_final - N)
|
||||||
|
|
||||||
|
# sample start states weighted by original distribution
|
||||||
|
start_events = random.choices(list(event_dist.keys()), weights=list(event_dist.values()), k=N_contaminate)
|
||||||
|
|
||||||
|
# generate synthetic trajectories
|
||||||
|
new_rows = []
|
||||||
|
alpha_estimates = []
|
||||||
|
|
||||||
|
for start_event in start_events:
|
||||||
|
# sample trajectory from agent model, using a state that contains the event type
|
||||||
|
mdp_states = model.mdp.get('states', []) if model.mdp else []
|
||||||
|
matching_starts = [s for s in mdp_states if start_event in s]
|
||||||
|
if not matching_starts:
|
||||||
|
continue # skip if no matching start state
|
||||||
|
start_state = random.choice(matching_starts)
|
||||||
|
trajectory = model.sample_traj(start_state, max_len=20)
|
||||||
|
score_payload: list[SimpleNamespace] = []
|
||||||
|
score: dict[str, float] = {}
|
||||||
|
if SEPARABILITY_ARTIFACTS:
|
||||||
|
score_payload = _states_to_events(trajectory)
|
||||||
|
score = score_session(score_payload, SEPARABILITY_ARTIFACTS)
|
||||||
|
alpha_estimates.append(
|
||||||
|
estimate_alpha(score["prob_agent"], score["delta_h"], score["delta_a"], temperature=2.0)
|
||||||
|
)
|
||||||
|
|
||||||
|
for state in trajectory:
|
||||||
|
parts = state.split('|') if isinstance(state, str) else [start_event]
|
||||||
|
new_rows.append({
|
||||||
|
on: parts[-1] if parts else start_event,
|
||||||
|
'source': 'synthetic_agent',
|
||||||
|
'prob_agent': score.get('prob_agent') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
'delta_h': score.get('delta_h') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
'delta_a': score.get('delta_a') if SEPARABILITY_ARTIFACTS and score_payload else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
if new_rows:
|
||||||
|
contaminate_df = pd.DataFrame(new_rows)
|
||||||
|
df = pd.concat([df, contaminate_df], ignore_index=True)
|
||||||
|
if alpha_estimates:
|
||||||
|
df['estimated_alpha'] = sum(alpha_estimates) / len(alpha_estimates)
|
||||||
|
return df
|
||||||
34
experiments/procesing/context.py
Normal file
@@ -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
|
||||||