mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-07-16 01:53:37 +00:00
Compare commits
20 Commits
e2e-testin
...
pre-run-we
| Author | SHA1 | Date | |
|---|---|---|---|
| 90f4cd0bfb | |||
| 4ea390e78e | |||
| 07fb861723 | |||
| 90f57cb9b9 | |||
| d865357695 | |||
| 961302a21a | |||
| 0d214a469f | |||
| acf731efcb | |||
| 9a8525a854 | |||
| 29f51d56d1 | |||
| c56c7f6537 | |||
| b1882b6049 | |||
| 57a7e0c571 | |||
| c8c44d0453 | |||
| f950565264 | |||
| aae124f5ea | |||
| c5caee21b1 | |||
| fe7dafed0a | |||
| fa65fe992d | |||
|
|
221e71a503 |
2
Makefile
2
Makefile
@@ -48,8 +48,10 @@ test.backend: $(VENV)
|
|||||||
test.e2e:
|
test.e2e:
|
||||||
@cd tests/e2e && npm install
|
@cd tests/e2e && npm install
|
||||||
@cd tests/e2e && npx playwright install chromium
|
@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: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: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
|
@cd tests/e2e && npm test
|
||||||
|
|
||||||
.PHONY: test.all
|
.PHONY: test.all
|
||||||
|
|||||||
@@ -47,53 +47,52 @@ def health() -> dict:
|
|||||||
|
|
||||||
@app.get("/api/{mode}/price/{productId}", response_model=PriceResponse)
|
@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)):
|
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]
|
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")
|
if not product: raise HTTPException(404, f"Product {productId} not found")
|
||||||
|
|
||||||
metadata = product['metadata']
|
metadata = product['metadata']
|
||||||
base_price = metadata.get('base_price', 100.0)
|
base_price = metadata.get('base_price', 100.0)
|
||||||
|
|
||||||
# fetch pre-computed prices from registry
|
# 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')
|
prices_df = registry.get_prices('latest')
|
||||||
elasticity_df = registry.get_elasticity('latest')
|
if prices_df is not None:
|
||||||
|
|
||||||
if prices_df is None:
|
|
||||||
# fallback: no pre-computed prices available
|
|
||||||
return PriceResponse(
|
|
||||||
productId=productId,
|
|
||||||
price=base_price,
|
|
||||||
base_price=base_price,
|
|
||||||
markup=1.0,
|
|
||||||
elasticity=None
|
|
||||||
)
|
|
||||||
|
|
||||||
# lookup pre-computed price for this product
|
|
||||||
product_price_row = prices_df[prices_df['productId'] == productId]
|
product_price_row = prices_df[prices_df['productId'] == productId]
|
||||||
if product_price_row.empty:
|
if not product_price_row.empty:
|
||||||
# product not in pre-computed prices, fallback to base
|
optimal_price = float(product_price_row['optimal_price'].iloc[0])
|
||||||
return PriceResponse(
|
|
||||||
productId=productId,
|
|
||||||
price=base_price,
|
|
||||||
base_price=base_price,
|
|
||||||
markup=1.0,
|
|
||||||
elasticity=None
|
|
||||||
)
|
|
||||||
|
|
||||||
optimal_price = float(product_price_row['optimal_price'].iloc[0]) # TODO: use optimal_price everywhere as aresult
|
|
||||||
|
|
||||||
# get elasticity if available
|
|
||||||
product_elasticity = None
|
|
||||||
if elasticity_df is not None:
|
|
||||||
product_elasticity_row = elasticity_df[elasticity_df['productId'] == productId]
|
|
||||||
if not product_elasticity_row.empty:
|
|
||||||
product_elasticity = float(product_elasticity_row['elasticity'].iloc[0])
|
|
||||||
|
|
||||||
return PriceResponse(
|
return PriceResponse(
|
||||||
productId=productId,
|
productId=productId,
|
||||||
price=optimal_price,
|
price=optimal_price,
|
||||||
base_price=base_price,
|
base_price=base_price,
|
||||||
markup=optimal_price/base_price,
|
markup=optimal_price/base_price,
|
||||||
elasticity=product_elasticity
|
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")
|
@app.get("/models")
|
||||||
|
|||||||
@@ -198,12 +198,16 @@ def dump_logs(
|
|||||||
auto_offset_reset='earliest',
|
auto_offset_reset='earliest',
|
||||||
enable_auto_commit=False,
|
enable_auto_commit=False,
|
||||||
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
value_deserializer=lambda x: json.loads(x.decode('utf-8')),
|
||||||
consumer_timeout_ms=5000
|
consumer_timeout_ms=30000,
|
||||||
|
fetch_max_wait_ms=10000,
|
||||||
|
max_poll_records=1000
|
||||||
)
|
)
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for msg in consumer:
|
for msg in consumer:
|
||||||
events.append(msg.value)
|
events.append(msg.value)
|
||||||
|
if last_n and len(events) >= last_n * 2:
|
||||||
|
break
|
||||||
|
|
||||||
consumer.close()
|
consumer.close()
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
services:
|
services:
|
||||||
|
tensorboard-rl:
|
||||||
tensorboard:
|
|
||||||
image: tensorflow/tensorflow:latest
|
image: tensorflow/tensorflow:latest
|
||||||
container_name: "PHANTOM-tensorboard"
|
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:
|
ports:
|
||||||
- "6006:6006"
|
- "6006:6006"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -103,11 +112,14 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
- 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_DB_MIGRATE=true
|
||||||
- _AIRFLOW_WWW_USER_CREATE=true
|
- _AIRFLOW_WWW_USER_CREATE=true
|
||||||
- _AIRFLOW_WWW_USER_USERNAME=admin
|
- _AIRFLOW_WWW_USER_USERNAME=admin
|
||||||
@@ -127,14 +139,20 @@ services:
|
|||||||
- airflow-init
|
- airflow-init
|
||||||
- redis
|
- redis
|
||||||
environment:
|
environment:
|
||||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
- 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__EXPOSE_CONFIG=true
|
||||||
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
- AIRFLOW__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- KAFKA_PORT=29092
|
||||||
- BACKEND_URL=http://backend:5000
|
- BACKEND_URL=http://backend:5000
|
||||||
@@ -164,13 +182,20 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
- AIRFLOW__CORE__EXECUTOR=SequentialExecutor
|
- AIRFLOW__CORE__EXECUTOR=LocalExecutor
|
||||||
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
- AIRFLOW__DATABASE__SQL_ALCHEMY_CONN=postgresql+psycopg2://airflow:airflow@postgres/airflow
|
||||||
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
- AIRFLOW__CORE__FERNET_KEY=${AIRFLOW_FERNET_KEY}
|
||||||
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
- AIRFLOW__CORE__DAGS_ARE_PAUSED_AT_CREATION=true
|
||||||
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
- AIRFLOW__CORE__LOAD_EXAMPLES=false
|
||||||
- AIRFLOW__CORE__ENABLE_XCOM_PICKLING=true
|
- 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__WEBSERVER__SECRET_KEY=${AIRFLOW_SECRET_KEY}
|
||||||
|
- AIRFLOW__API__AUTH_BACKENDS=airflow.api.auth.backend.basic_auth
|
||||||
- KAFKA_HOST=kafka
|
- KAFKA_HOST=kafka
|
||||||
- KAFKA_PORT=29092
|
- KAFKA_PORT=29092
|
||||||
- BACKEND_URL=http://backend:5000
|
- BACKEND_URL=http://backend:5000
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from pandas.core.algorithms import factorize_array
|
||||||
from airflow import DAG
|
from airflow import DAG
|
||||||
from airflow.operators.python import PythonOperator
|
from airflow.operators.python import PythonOperator
|
||||||
from airflow.utils.dates import days_ago
|
from airflow.utils.dates import days_ago
|
||||||
@@ -208,3 +209,12 @@ def create_surge_pricing_dag(store_mode: str) -> DAG:
|
|||||||
# instantiate DAGs for Airflow to discover
|
# instantiate DAGs for Airflow to discover
|
||||||
dag_airline = create_surge_pricing_dag('airline')
|
dag_airline = create_surge_pricing_dag('airline')
|
||||||
dag_hotel = create_surge_pricing_dag('hotel')
|
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.
|
||||||
|
|||||||
@@ -120,15 +120,31 @@ def apply_surge_pricing(**kwargs):
|
|||||||
# rename demand_score to demand for pricer compatibility
|
# rename demand_score to demand for pricer compatibility
|
||||||
data = product_features.rename(columns={'demand_score': 'demand'})
|
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(
|
surge_pricer = SimpleSurgePricer(
|
||||||
high_threshold=dag_conf.get('high_threshold', 10),
|
high_threshold=high_thresh,
|
||||||
low_threshold=dag_conf.get('low_threshold', 2),
|
low_threshold=low_thresh,
|
||||||
surge_multiplier=dag_conf.get('surge_multiplier', 1.2),
|
surge_multiplier=surge_mult,
|
||||||
discount_multiplier=dag_conf.get('discount_multiplier', 0.9)
|
discount_multiplier=discount_mult
|
||||||
)
|
)
|
||||||
surge_pricer.fit(data)
|
surge_pricer.fit(data)
|
||||||
data['optimal_price'] = surge_pricer.predict()
|
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={
|
prices_df = data[['productId', 'price', 'base_price', 'optimal_price', 'demand']].rename(columns={
|
||||||
'price': 'current_price',
|
'price': 'current_price',
|
||||||
'demand': 'demand_score'
|
'demand': 'demand_score'
|
||||||
|
|||||||
@@ -7,15 +7,6 @@ import pandas as pd
|
|||||||
class PricingFunction(ABC):
|
class PricingFunction(ABC):
|
||||||
"""
|
"""
|
||||||
Abstract base for pricing functions.
|
Abstract base for pricing functions.
|
||||||
|
|
||||||
Defines mapping: f(Q_t, P_t, S_t, H_t) -> P_{t+1}
|
|
||||||
|
|
||||||
Where:
|
|
||||||
Q_t ∈ R^n: demand vector at time t
|
|
||||||
P_t ∈ R^n: price vector at time t
|
|
||||||
S_t: session features (behavioral signals, interactions)
|
|
||||||
H_t = {Q_{t-k}, P_{t-k}, S_{t-k}}: historical state trajectory
|
|
||||||
|
|
||||||
Objective:
|
Objective:
|
||||||
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
maximize E[R_T] = E[Σ P_t^T · Q_t]
|
||||||
subject to:
|
subject to:
|
||||||
@@ -28,10 +19,10 @@ class PricingFunction(ABC):
|
|||||||
def fit(self, *kwargs):
|
def fit(self, *kwargs):
|
||||||
"""
|
"""
|
||||||
Offline training on historical data.
|
Offline training on historical data.
|
||||||
|
This is where we can think about some maximization of expected revenue
|
||||||
|
over historical trajectories to learn parameters of the pricing function.
|
||||||
|
(This however we cover move in the RL side of things)
|
||||||
|
|
||||||
Args:
|
|
||||||
historical_data: DataFrame with elasticity, prices, demand signals
|
|
||||||
**kwargs: additional training parameters
|
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -39,12 +30,18 @@ class PricingFunction(ABC):
|
|||||||
def predict(self, *kwargs) -> np.ndarray:
|
def predict(self, *kwargs) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Generate optimal prices given current state.
|
Generate optimal prices given current state.
|
||||||
|
This is an abstract method that transitions from τ -> P*
|
||||||
|
which is the mapping from the trajectory to optimal prices under
|
||||||
|
some subset of session grouping (so, per sessionId)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
Args:
|
@abstractmethod
|
||||||
state_space: StateSpace object containing Q_t, P_t, S_t, H_t
|
def _get_features(self, *kwargs) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Extract features from trajectory for pricing decision.
|
||||||
Returns:
|
Returns:
|
||||||
P_{t+1}: price vector in R^n
|
np.ndarray of shape (n_products, n_features)
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -57,3 +57,13 @@ class ElasticityBasedPricer(PricingFunction):
|
|||||||
# enforce bounds
|
# enforce bounds
|
||||||
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
prices = np.clip(prices, self.price_floor, self.price_ceil)
|
||||||
return prices
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity, demand, and demand deviation for each product"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 3))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
demand_dev = (demand - self.mean_demand) / (self.mean_demand + 1e-6)
|
||||||
|
return np.column_stack([self.elasticity, demand, demand_dev])
|
||||||
|
|||||||
@@ -107,6 +107,36 @@ class SessionAwarePricer(PricingFunction):
|
|||||||
|
|
||||||
return prices
|
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):
|
class ProductSpecificSessionPricer(PricingFunction):
|
||||||
"""
|
"""
|
||||||
@@ -170,3 +200,12 @@ class ProductSpecificSessionPricer(PricingFunction):
|
|||||||
|
|
||||||
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
prices = np.clip(base_prices, self.price_floor, self.price_ceil)
|
||||||
return prices
|
return prices
|
||||||
|
|
||||||
|
def _get_features(self, state_space=None) -> np.ndarray:
|
||||||
|
"""Extract elasticity and demand features for product-specific pricing"""
|
||||||
|
if state_space is None or self.elasticity is None:
|
||||||
|
n = len(self.elasticity) if self.elasticity is not None else 0
|
||||||
|
return np.zeros((n, 2))
|
||||||
|
|
||||||
|
demand = np.asarray(state_space.demand)
|
||||||
|
return np.column_stack([self.elasticity, demand])
|
||||||
|
|||||||
@@ -3,6 +3,46 @@ import pandas as pd
|
|||||||
from procesing.pricers.base import PricingFunction
|
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):
|
class StaticPricer(PricingFunction):
|
||||||
"""Static pricing: always return fixed base prices"""
|
"""Static pricing: always return fixed base prices"""
|
||||||
|
|
||||||
@@ -25,6 +65,11 @@ class StaticPricer(PricingFunction):
|
|||||||
raise ValueError("Must call fit() or provide base_prices in constructor")
|
raise ValueError("Must call fit() or provide base_prices in constructor")
|
||||||
return self.base_prices.copy()
|
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):
|
class RandomPricer(PricingFunction):
|
||||||
"""Random pricing within bounds (for baseline comparison)"""
|
"""Random pricing within bounds (for baseline comparison)"""
|
||||||
@@ -47,6 +92,11 @@ class RandomPricer(PricingFunction):
|
|||||||
self.n_products = len(state_space.demand)
|
self.n_products = len(state_space.demand)
|
||||||
return self.rng.uniform(self.price_min, self.price_max, size=self.n_products)
|
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):
|
class SimpleSurgePricer(PricingFunction):
|
||||||
"""
|
"""
|
||||||
@@ -70,18 +120,22 @@ class SimpleSurgePricer(PricingFunction):
|
|||||||
def fit(self, market_data: pd.DataFrame):
|
def fit(self, market_data: pd.DataFrame):
|
||||||
"""Extract base prices from product catalog or historical averages"""
|
"""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
|
self.base_prices = market_data['base_price'].to_numpy() if 'base_price' in market_data.columns else market_data['price'].values
|
||||||
self.demand_history = market_data['demand'].to_numpy() if 'demand' in market_data.columns else np.zeros_like(self.base_prices)
|
return self
|
||||||
|
|
||||||
def predict(self) -> np.ndarray:
|
def predict(self, state_space) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Adjust prices based on current demand using surge rules.
|
Adjust prices based on current demand using surge rules.
|
||||||
state_space.demand: demand counts per product
|
state_space.demand: demand proxy per product (from session features)
|
||||||
state_space.prices: current prices (fallback if base_prices not set)
|
state_space.prices: base prices
|
||||||
"""
|
"""
|
||||||
current_prices = self.base_prices if self.base_prices is not None else np.ones_like(demand_vector) * 99.99
|
demand = np.asarray(state_space.demand) if state_space and hasattr(state_space, 'demand') else np.array([0])
|
||||||
demand = self.demand_history if self.demand_history is not None else np.zeros_like(current_prices)
|
base = np.asarray(state_space.prices) if state_space and hasattr(state_space, 'prices') else self.base_prices
|
||||||
new_prices = current_prices.copy()
|
|
||||||
|
|
||||||
|
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
|
high_mask = demand >= self.high_threshold
|
||||||
new_prices[high_mask] *= self.surge_multiplier
|
new_prices[high_mask] *= self.surge_multiplier
|
||||||
|
|
||||||
@@ -89,3 +143,16 @@ class SimpleSurgePricer(PricingFunction):
|
|||||||
new_prices[low_mask] *= self.discount_multiplier
|
new_prices[low_mask] *= self.discount_multiplier
|
||||||
|
|
||||||
return new_prices
|
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])
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class ExtractSessionFeaturesStep(BaseContextStep):
|
|||||||
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
Vectorized session feature extraction - replaces O(n^2) per-row loop.
|
||||||
Input: interactions_df
|
Input: interactions_df
|
||||||
Output: session-level feature matrix
|
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:
|
def transform(self, X: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
|||||||
@@ -178,3 +178,49 @@ class ModelRegistry:
|
|||||||
return True
|
return True
|
||||||
except:
|
except:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def set_session_prices(self, session_id: str, prices: Dict[str, float], ttl: int = 1800):
|
||||||
|
"""
|
||||||
|
Store prices for a specific session.
|
||||||
|
THIS is the write path for session-aware pricing.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: session identifier
|
||||||
|
prices: dict of {productId: price}
|
||||||
|
ttl: time-to-live in seconds (default 30min)
|
||||||
|
"""
|
||||||
|
if not prices:
|
||||||
|
return
|
||||||
|
|
||||||
|
key = f"session:{session_id}:prices"
|
||||||
|
# use Redis hash for O(1) lookup per product
|
||||||
|
self.redis_client.hset(key, mapping={k: str(v) for k, v in prices.items()})
|
||||||
|
self.redis_client.expire(key, ttl)
|
||||||
|
|
||||||
|
def get_session_price(self, session_id: str, product_id: str) -> Optional[float]:
|
||||||
|
"""
|
||||||
|
Lookup price for (sessionId, productId).
|
||||||
|
THIS is the read path for fast provider lookup.
|
||||||
|
|
||||||
|
Returns: price or None if not found
|
||||||
|
"""
|
||||||
|
key = f"session:{session_id}:prices"
|
||||||
|
price_str = self.redis_client.hget(key, product_id)
|
||||||
|
|
||||||
|
if price_str is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return float(price_str.decode('utf-8') if isinstance(price_str, bytes) else price_str)
|
||||||
|
|
||||||
|
def get_session_all_prices(self, session_id: str) -> Dict[str, float]:
|
||||||
|
"""Get all prices for a session."""
|
||||||
|
key = f"session:{session_id}:prices"
|
||||||
|
prices_raw = self.redis_client.hgetall(key)
|
||||||
|
|
||||||
|
if not prices_raw:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
(k.decode('utf-8') if isinstance(k, bytes) else k): float(v.decode('utf-8') if isinstance(v, bytes) else v)
|
||||||
|
for k, v in prices_raw.items()
|
||||||
|
}
|
||||||
|
|||||||
63
sim/rl/behavior_loader/loader.py
Normal file
63
sim/rl/behavior_loader/loader.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import os
|
||||||
|
from pydantic import BaseModel as Base
|
||||||
|
import json
|
||||||
|
|
||||||
|
class PayloadModel(Base):
|
||||||
|
sessionId: str
|
||||||
|
experimentId: str | None
|
||||||
|
eventName: str
|
||||||
|
page: str | None
|
||||||
|
productId: str | None
|
||||||
|
metadata: dict
|
||||||
|
storeMode: str
|
||||||
|
userAgent: str
|
||||||
|
ts: str
|
||||||
|
|
||||||
|
class ValueModel(Base):
|
||||||
|
payload: PayloadModel
|
||||||
|
encoding: str
|
||||||
|
isPayloadNull: bool
|
||||||
|
schemaId: int
|
||||||
|
size: int
|
||||||
|
|
||||||
|
class InteractionModel(Base):
|
||||||
|
partitionID: int
|
||||||
|
offset: int
|
||||||
|
timestamp: int
|
||||||
|
compression: str
|
||||||
|
isTransactional: bool
|
||||||
|
headers: list
|
||||||
|
key: dict
|
||||||
|
value: ValueModel
|
||||||
|
|
||||||
|
class Loader:
|
||||||
|
def __init__(self, src_dir: str):
|
||||||
|
self.src_dir = src_dir
|
||||||
|
self.entries = os.listdir(src_dir)
|
||||||
|
if not self.entries: raise ValueError("empty directory")
|
||||||
|
self.data = self._load_sessions()
|
||||||
|
|
||||||
|
def _is_admin_page(self, interaction: InteractionModel) -> bool:
|
||||||
|
page = interaction.value.payload.page
|
||||||
|
return page and page.startswith("/admin/")
|
||||||
|
|
||||||
|
def _load_sessions(self) -> dict:
|
||||||
|
sessions = {}
|
||||||
|
for entry in self.entries:
|
||||||
|
int_path = f"{self.src_dir}/{entry}/int.json"
|
||||||
|
raw = json.load(open(int_path))
|
||||||
|
ints = [InteractionModel(**i) for i in raw]
|
||||||
|
sessions[entry] = [i for i in ints if not self._is_admin_page(i)]
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
def get_data(self) -> dict:
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def get_entries(self) -> tuple[list[str], int]:
|
||||||
|
return self.entries, len(self.entries)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||||
|
loader = Loader(DIR)
|
||||||
|
_, n = loader.get_entries()
|
||||||
|
print(f"Loaded {n} sessions from {DIR}")
|
||||||
144
sim/rl/behavior_loader/models.py
Normal file
144
sim/rl/behavior_loader/models.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
from loader import Loader
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Dict, List, Tuple, Set
|
||||||
|
import numpy as np
|
||||||
|
import graphviz
|
||||||
|
|
||||||
|
DIR = "/home/velocitatem/Documents/Projects/PHANTOM/experiments/collected_data/"
|
||||||
|
|
||||||
|
class BehaviorModel:
|
||||||
|
def __init__(self, src_dir: str = DIR):
|
||||||
|
self.loader = Loader(src_dir)
|
||||||
|
self.data = self.loader.get_data()
|
||||||
|
self.entries, self.num_entries = self.loader.get_entries()
|
||||||
|
self.mdp = None
|
||||||
|
|
||||||
|
def _state_repr(self, evt) -> str:
|
||||||
|
p = evt.value.payload
|
||||||
|
return f"{p.page or 'unk'}|{p.productId or 'none'}|{p.eventName}"
|
||||||
|
|
||||||
|
def _extract_sessions(self):
|
||||||
|
# transform raw events into sequential state trajectories per session
|
||||||
|
trajectories = []
|
||||||
|
for sid, evts in self.data.items():
|
||||||
|
if len(evts) < 2: continue
|
||||||
|
states = [self._state_repr(e) for e in sorted(evts, key=lambda x: x.timestamp)]
|
||||||
|
trajectories.append(states)
|
||||||
|
return trajectories
|
||||||
|
|
||||||
|
def _calc_transitions(self, trajectories: List[List[str]]) -> Tuple[Dict, Set]:
|
||||||
|
trans = defaultdict(lambda: defaultdict(int))
|
||||||
|
states = set()
|
||||||
|
for traj in trajectories:
|
||||||
|
for i in range(len(traj) - 1):
|
||||||
|
s, s_next = traj[i], traj[i+1]
|
||||||
|
trans[s][s_next] += 1
|
||||||
|
states.update([s, s_next])
|
||||||
|
return trans, states
|
||||||
|
|
||||||
|
def _calc_rewards(self, trajectories: List[List[str]]) -> Dict:
|
||||||
|
# reward based on session progression depth
|
||||||
|
rwd = defaultdict(list)
|
||||||
|
for traj in trajectories:
|
||||||
|
n = len(traj)
|
||||||
|
for i, s in enumerate(traj):
|
||||||
|
rwd[s].append(i / n)
|
||||||
|
return rwd
|
||||||
|
|
||||||
|
def _normalize_trans(self, counts: Dict) -> Dict:
|
||||||
|
return {s: {s_n: cnt/sum(nxt.values()) for s_n, cnt in nxt.items()}
|
||||||
|
for s, nxt in counts.items()}
|
||||||
|
|
||||||
|
def build_MDP(self) -> Dict:
|
||||||
|
trajs = self._extract_sessions()
|
||||||
|
trans_cnt, states = self._calc_transitions(trajs)
|
||||||
|
trans_prob = self._normalize_trans(trans_cnt)
|
||||||
|
state_rwd = self._calc_rewards(trajs)
|
||||||
|
state_val = {s: np.mean(r) for s, r in state_rwd.items()}
|
||||||
|
|
||||||
|
self.mdp = {
|
||||||
|
'states': sorted(list(states)),
|
||||||
|
'num_states': len(states),
|
||||||
|
'transitions': trans_prob,
|
||||||
|
'state_values': state_val,
|
||||||
|
'state_rewards': state_rwd,
|
||||||
|
'trans_counts': trans_cnt,
|
||||||
|
}
|
||||||
|
return self.mdp
|
||||||
|
|
||||||
|
def transition_prob(self, s: str, s_next: str) -> float:
|
||||||
|
if not self.mdp: raise ValueError("build MDP first")
|
||||||
|
return self.mdp['transitions'].get(s, {}).get(s_next, 0.0)
|
||||||
|
|
||||||
|
def state_value(self, s: str) -> float:
|
||||||
|
if not self.mdp: raise ValueError("build MDP first")
|
||||||
|
return self.mdp['state_values'].get(s, 0.0)
|
||||||
|
|
||||||
|
def sample_traj(self, start: str, max_len: int = 50) -> List[str]:
|
||||||
|
if not self.mdp: raise ValueError("build MDP first")
|
||||||
|
path = [start]
|
||||||
|
curr = start
|
||||||
|
for _ in range(max_len):
|
||||||
|
nxt = self.mdp['transitions'].get(curr, {})
|
||||||
|
if not nxt: break
|
||||||
|
curr = np.random.choice(list(nxt.keys()), p=list(nxt.values()))
|
||||||
|
path.append(curr)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def visualize_mdp(model: BehaviorModel, threshold: float = 0.05, output: str = "mdp_graph", fmt: str = "svg", view: bool = False, export_dot: bool = False):
|
||||||
|
"""visualize MDP as directed graph using graphviz, aggregated by event type"""
|
||||||
|
if not model.mdp: raise ValueError("build MDP first")
|
||||||
|
|
||||||
|
# aggregate transitions by event type
|
||||||
|
evt_trans = defaultdict(lambda: defaultdict(float))
|
||||||
|
for s, trans in model.mdp['transitions'].items():
|
||||||
|
evt_src = s.split('|')[2]
|
||||||
|
for s_next, prob in trans.items():
|
||||||
|
evt_dst = s_next.split('|')[2]
|
||||||
|
evt_trans[evt_src][evt_dst] += prob
|
||||||
|
|
||||||
|
# normalize aggregated transitions
|
||||||
|
for evt_src in evt_trans:
|
||||||
|
total = sum(evt_trans[evt_src].values())
|
||||||
|
if total > 0:
|
||||||
|
for evt_dst in evt_trans[evt_src]:
|
||||||
|
evt_trans[evt_src][evt_dst] /= total
|
||||||
|
|
||||||
|
g = graphviz.Digraph(format=fmt)
|
||||||
|
g.attr(rankdir='LR', size='30')
|
||||||
|
g.attr('node', shape='circle', width='1', height='1')
|
||||||
|
|
||||||
|
# collect all event types
|
||||||
|
events = set(evt_trans.keys())
|
||||||
|
for trans in evt_trans.values():
|
||||||
|
events.update(trans.keys())
|
||||||
|
|
||||||
|
# add nodes for each event type
|
||||||
|
for evt in events:
|
||||||
|
g.node(evt)
|
||||||
|
|
||||||
|
# add edges above threshold
|
||||||
|
for evt_src in evt_trans:
|
||||||
|
for evt_dst, prob in evt_trans[evt_src].items():
|
||||||
|
if prob > threshold:
|
||||||
|
g.edge(evt_src, evt_dst, label=f'{prob:.2f}')
|
||||||
|
|
||||||
|
g.render(output, view=view, cleanup=True)
|
||||||
|
print(f"Saved MDP graph to {output}.{fmt}")
|
||||||
|
|
||||||
|
if export_dot:
|
||||||
|
dot_file = f"{output}.dot"
|
||||||
|
with open(dot_file, 'w') as f:
|
||||||
|
f.write(g.source)
|
||||||
|
print(f"Exported DOT source to {dot_file}")
|
||||||
|
|
||||||
|
return g
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
model = BehaviorModel(DIR)
|
||||||
|
mdp = model.build_MDP()
|
||||||
|
print(f"Built MDP: {mdp['num_states']} states, {sum(len(t) for t in mdp['transitions'].values())} transitions")
|
||||||
|
if not mdp['states']:
|
||||||
|
print("No states found")
|
||||||
|
exit(1)
|
||||||
|
visualize_mdp(model, threshold=0.05, output="mdp_viz", fmt="pdf", export_dot=True)
|
||||||
227
sim/rl/engine.py
Normal file
227
sim/rl/engine.py
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
from os import kill
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Dict, Any
|
||||||
|
from environment import BusinessLogicConstraints
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
An angine by default should have its own demand estimation mechanism from the observed observations whihc are the computer feature.
|
||||||
|
From these features we then follow the researc hstructure of q -> p with a testable and must be updatable mechanism.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class BasePricingEngine(ABC):
|
||||||
|
"""base interface for all pricing engines"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
self.c = constraints
|
||||||
|
self.rng = np.random.default_rng(seed)
|
||||||
|
self.step_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
"""compute new prices given current state and observation from environment
|
||||||
|
|
||||||
|
args:
|
||||||
|
current_prices: current price vector [N]
|
||||||
|
observation: dict containing 'price', 'demand', and possibly interaction data
|
||||||
|
|
||||||
|
returns:
|
||||||
|
new_prices: updated price vector [N]
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def update(obs, reward, done, info):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""reset engine state for new episode"""
|
||||||
|
self.step_count = 0
|
||||||
|
|
||||||
|
|
||||||
|
class WildPricingEngine(BasePricingEngine):
|
||||||
|
"""production-like pricing using online elasticity estimation via EWMA regression"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
super().__init__(constraints, seed)
|
||||||
|
# per-product unit costs (unknown to customers; known to platform)
|
||||||
|
self.unit_cost = self.rng.uniform(8.0, 40.0, size=self.c.product_catelogue_size).astype(np.float32)
|
||||||
|
# online elasticity estimate (start moderately elastic)
|
||||||
|
self.e_hat = np.full((self.c.product_catelogue_size,), -1.3, dtype=np.float32)
|
||||||
|
# EWMA state for log-log regression
|
||||||
|
self.mu_logp = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.mu_logq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.cov_pq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.var_p = np.ones(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
# knobs typical in production
|
||||||
|
self.lr = 0.08
|
||||||
|
self.ewma = 0.05
|
||||||
|
self.eps_explore = 0.03
|
||||||
|
self.explore_scale = 0.03
|
||||||
|
|
||||||
|
def _safe_elasticity(self, e: np.ndarray) -> np.ndarray:
|
||||||
|
return np.clip(e, -5.0, -1.05)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
super().reset()
|
||||||
|
self.e_hat = np.full((self.c.product_catelogue_size,), -1.3, dtype=np.float32)
|
||||||
|
self.mu_logp = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.mu_logq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.cov_pq = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
self.var_p = np.ones(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
self.step_count += 1
|
||||||
|
# extract demand signal (from env observation) as proxy for sales
|
||||||
|
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||||
|
return self._update_from_demand(current_prices, demand)
|
||||||
|
|
||||||
|
def _update_from_demand(self, prices: np.ndarray, sold: np.ndarray) -> np.ndarray:
|
||||||
|
# log transforms (add 1 to handle zeros)
|
||||||
|
logp = np.log(np.clip(prices, 1e-3, None)).astype(np.float32)
|
||||||
|
logq = np.log(sold + 1.0).astype(np.float32)
|
||||||
|
# EWMA moments for per-product regression: logq ≈ a + e*logp
|
||||||
|
a = self.ewma
|
||||||
|
dp = logp - self.mu_logp
|
||||||
|
dq = logq - self.mu_logq
|
||||||
|
self.mu_logp = (1 - a) * self.mu_logp + a * logp
|
||||||
|
self.mu_logq = (1 - a) * self.mu_logq + a * logq
|
||||||
|
self.cov_pq = (1 - a) * self.cov_pq + a * (dp * dq)
|
||||||
|
self.var_p = (1 - a) * self.var_p + a * (dp * dp + 1e-6)
|
||||||
|
e_new = self.cov_pq / (self.var_p + 1e-6)
|
||||||
|
self.e_hat = self._safe_elasticity(0.9 * self.e_hat + 0.1 * e_new)
|
||||||
|
# profit-optimal price for isoelastic demand (if e < -1)
|
||||||
|
e = self.e_hat
|
||||||
|
p_star = self.unit_cost * (e / (e + 1.0))
|
||||||
|
# smooth toward p_star
|
||||||
|
new_prices = (1 - self.lr) * prices + self.lr * p_star
|
||||||
|
# exploration (small random perturbations)
|
||||||
|
if self.rng.random() < self.eps_explore:
|
||||||
|
noise = self.rng.normal(0.0, self.explore_scale, size=new_prices.shape).astype(np.float32)
|
||||||
|
new_prices = new_prices * (1.0 + noise)
|
||||||
|
# apply business guardrails (max change + bounds)
|
||||||
|
max_adj = self.c.max_price_adjustment
|
||||||
|
ratio = np.clip(new_prices / (prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||||
|
new_prices = prices * ratio
|
||||||
|
new_prices = np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||||
|
return new_prices
|
||||||
|
|
||||||
|
|
||||||
|
class StaticPricingEngine(BasePricingEngine):
|
||||||
|
"""baseline: fixed prices throughout episode"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
super().__init__(constraints, seed)
|
||||||
|
self.fixed_prices = None
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
super().reset()
|
||||||
|
self.fixed_prices = None
|
||||||
|
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
self.step_count += 1
|
||||||
|
if self.fixed_prices is None:
|
||||||
|
self.fixed_prices = current_prices.copy()
|
||||||
|
return self.fixed_prices.copy()
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleDemandEngine(BasePricingEngine):
|
||||||
|
"""demand-driven pricing: increase price when demand rises, decrease when it falls"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
super().__init__(constraints, seed)
|
||||||
|
self.prev_demand = None
|
||||||
|
self.lr = 0.05
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
super().reset()
|
||||||
|
self.prev_demand = None
|
||||||
|
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
self.step_count += 1
|
||||||
|
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||||
|
if self.prev_demand is None:
|
||||||
|
self.prev_demand = demand.copy()
|
||||||
|
return current_prices.copy()
|
||||||
|
# simple rule: if demand increases, raise price; if decreases, lower price
|
||||||
|
delta_d = demand - self.prev_demand
|
||||||
|
price_adj = self.lr * np.sign(delta_d) * np.abs(delta_d) / (np.abs(self.prev_demand) + 1.0)
|
||||||
|
new_prices = current_prices * (1.0 + price_adj)
|
||||||
|
self.prev_demand = demand.copy()
|
||||||
|
# apply constraints
|
||||||
|
max_adj = self.c.max_price_adjustment
|
||||||
|
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||||
|
new_prices = current_prices * ratio
|
||||||
|
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class RandomWalkEngine(BasePricingEngine):
|
||||||
|
"""random walk pricing with mean reversion"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
super().__init__(constraints, seed)
|
||||||
|
self.target_price = None
|
||||||
|
self.volatility = 0.02
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
super().reset()
|
||||||
|
self.target_price = None
|
||||||
|
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
self.step_count += 1
|
||||||
|
if self.target_price is None:
|
||||||
|
self.target_price = current_prices.copy()
|
||||||
|
# random walk with mean reversion toward target
|
||||||
|
noise = self.rng.normal(0.0, self.volatility, size=current_prices.shape).astype(np.float32)
|
||||||
|
reversion = 0.01 * (self.target_price - current_prices)
|
||||||
|
new_prices = current_prices * (1.0 + noise) + reversion
|
||||||
|
# apply constraints
|
||||||
|
max_adj = self.c.max_price_adjustment
|
||||||
|
ratio = np.clip(new_prices / (current_prices + 1e-6), 1 - max_adj, 1 + max_adj)
|
||||||
|
new_prices = current_prices * ratio
|
||||||
|
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
class ThompsonSamplingEngine(BasePricingEngine):
|
||||||
|
"""bayesian bandit approach per product treating price as discrete action"""
|
||||||
|
def __init__(self, constraints: BusinessLogicConstraints, seed: int = 0):
|
||||||
|
super().__init__(constraints, seed)
|
||||||
|
self.n_price_levels = 5
|
||||||
|
self.alpha = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||||
|
self.beta = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||||
|
self.price_grid = None
|
||||||
|
self.last_actions = None
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
super().reset()
|
||||||
|
self.alpha = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||||
|
self.beta = np.ones((self.c.product_catelogue_size, self.n_price_levels), dtype=np.float32)
|
||||||
|
self.price_grid = None
|
||||||
|
self.last_actions = None
|
||||||
|
|
||||||
|
def compute_prices(self, current_prices: np.ndarray, observation: Dict[str, Any]) -> np.ndarray:
|
||||||
|
self.step_count += 1
|
||||||
|
if self.price_grid is None:
|
||||||
|
# define price grid per product
|
||||||
|
lo = current_prices * 0.7
|
||||||
|
hi = current_prices * 1.3
|
||||||
|
self.price_grid = np.linspace(lo, hi, self.n_price_levels).T
|
||||||
|
demand = observation.get('demand', np.zeros(self.c.product_catelogue_size, dtype=np.float32))
|
||||||
|
# update beliefs based on last action
|
||||||
|
if self.last_actions is not None:
|
||||||
|
for i in range(self.c.product_catelogue_size):
|
||||||
|
a = self.last_actions[i]
|
||||||
|
reward = demand[i]
|
||||||
|
if reward > 0.5:
|
||||||
|
self.alpha[i, a] += reward
|
||||||
|
else:
|
||||||
|
self.beta[i, a] += 1.0
|
||||||
|
# thompson sampling: sample from posterior, pick best
|
||||||
|
new_prices = np.zeros(self.c.product_catelogue_size, dtype=np.float32)
|
||||||
|
actions = np.zeros(self.c.product_catelogue_size, dtype=int)
|
||||||
|
for i in range(self.c.product_catelogue_size):
|
||||||
|
theta = self.rng.beta(self.alpha[i], self.beta[i]).astype(np.float32)
|
||||||
|
actions[i] = int(np.argmax(theta))
|
||||||
|
new_prices[i] = self.price_grid[i, actions[i]]
|
||||||
|
self.last_actions = actions
|
||||||
|
return np.clip(new_prices, self.c.system_min_price, self.c.system_max_price).astype(np.float32)
|
||||||
320
sim/rl/environment.py
Normal file
320
sim/rl/environment.py
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
from sys import intern
|
||||||
|
import gymnasium as gym
|
||||||
|
from gymnasium import spaces
|
||||||
|
from matplotlib import interactive
|
||||||
|
import numpy as np
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import pandas as pd
|
||||||
|
from typing import Callable, Optional, Dict, Any, List
|
||||||
|
|
||||||
|
# "learner" agent learning to optimize pricing
|
||||||
|
# "agent" part of environment creating demand signals that learner processes
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BusinessLogicConstraints():
|
||||||
|
max_price_adjustment: float = 0.30
|
||||||
|
system_max_price: float = 500.0
|
||||||
|
system_min_price: float = 1.0
|
||||||
|
product_catelogue_size: int = 100
|
||||||
|
episode_length: int = 200
|
||||||
|
sessions_per_step: int = 250
|
||||||
|
agent_share: float = 0.25
|
||||||
|
agent_recon_multiplier: float = 6.0
|
||||||
|
agent_purchase_probability: float = 0.20
|
||||||
|
coi_strength: float = 0.25
|
||||||
|
coi_threshold: float = 4.0
|
||||||
|
coi_sigmoid_temp: float = 1.25
|
||||||
|
base_human_demand: float = 0.08
|
||||||
|
base_agent_demand: float = 0.05
|
||||||
|
human_price_elasticity: float = -1.2 # assumptions here
|
||||||
|
agent_price_elasticity: float = -0.6
|
||||||
|
w_agent_loss: float = 1.0
|
||||||
|
w_volatility: float = 5.0
|
||||||
|
w_estimation_error: float = 0.25
|
||||||
|
seed: int = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _sigmoid(x: np.ndarray) -> np.ndarray:
|
||||||
|
return 1.0 / (1.0 + np.exp(-x))
|
||||||
|
|
||||||
|
class CommercePlatform:
|
||||||
|
"""
|
||||||
|
This is just an extension of the state management for the environment, it does not implement anything dynamic just helps us simulate demand.
|
||||||
|
"""
|
||||||
|
def __init__(self,
|
||||||
|
product_catelogue_size: int,
|
||||||
|
max_price: float,
|
||||||
|
min_price: float,
|
||||||
|
constraints: BusinessLogicConstraints):
|
||||||
|
self.product_catelogue_size = product_catelogue_size
|
||||||
|
self.product_supply = np.random.uniform(low=10, high=50, size=(self.product_catelogue_size,))
|
||||||
|
self.max_price = max_price
|
||||||
|
self.min_price = min_price
|
||||||
|
self.constraints = constraints
|
||||||
|
self.simulation_history: List[Dict[str, Any]] = []
|
||||||
|
self._rng = np.random.default_rng(constraints.seed)
|
||||||
|
self._last_interaction_df: pd.DataFrame = pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
def setup_true_demand(self, prices: np.ndarray) -> Dict[str, np.ndarray]:
|
||||||
|
# ground truth purchase propensities
|
||||||
|
p = np.clip(prices, self.min_price, self.max_price)
|
||||||
|
pn = p / self.max_price
|
||||||
|
human_prob = self.constraints.base_human_demand * (pn ** self.constraints.human_price_elasticity)
|
||||||
|
agent_prob = self.constraints.base_agent_demand * (pn ** self.constraints.agent_price_elasticity)
|
||||||
|
return {
|
||||||
|
"human_purchase_prob": np.clip(human_prob, 0.0, 0.95),
|
||||||
|
"agent_purchase_prob": np.clip(agent_prob, 0.0, 0.95)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _load_behavioral_profile(actor : str, demand_forcing):
|
||||||
|
"""
|
||||||
|
This returns a markov chain with average weights which we get from interaction data of our experiments.
|
||||||
|
This defines transition probabilities between different events:
|
||||||
|
search -> view_item_price_binN: 0.7
|
||||||
|
view_item_price_binN -> add_to_cart: 0.2
|
||||||
|
we also must reweight with the demand_forcing vector or purchase probabilities per-product
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_sessions(self, base_prices: np.ndarray) -> pd.DataFrame:
|
||||||
|
demand = self.setup_true_demand(base_prices)
|
||||||
|
human_pprob = demand["human_purchase_prob"]
|
||||||
|
agent_pprob = demand["agent_purchase_prob"]
|
||||||
|
events: List[Dict[str, Any]] = []
|
||||||
|
T = self.constraints.sessions_per_step
|
||||||
|
n_agent_sessions = int(round(T * self.constraints.agent_share))
|
||||||
|
n_human_sessions = T - n_agent_sessions
|
||||||
|
n_agent_ids = max(1, n_agent_sessions // 2)
|
||||||
|
session_map = {
|
||||||
|
'humans': n_human_sessions,
|
||||||
|
'agents': n_agent_ids
|
||||||
|
}
|
||||||
|
pprob_map = {
|
||||||
|
'humans': human_pprob,
|
||||||
|
'agents': agent_pprob
|
||||||
|
}
|
||||||
|
joint_events = []
|
||||||
|
for actor, n_sessions in session_map.items():
|
||||||
|
bp = _load_behavioral_profile(actor, pprob_map[actor])
|
||||||
|
counter = 0
|
||||||
|
events = []
|
||||||
|
while counter < n_sessions:
|
||||||
|
session_events = []
|
||||||
|
while len(session_events) == 0 or session_events[-1]['action'] == 'checkout':
|
||||||
|
interaction_event = bp.sample(self._rng)
|
||||||
|
interaction_event['session_id'] = f'{actor}_{counter:06d}'
|
||||||
|
# TODO any other assignments
|
||||||
|
session_events.append(interaction_event)
|
||||||
|
events.extend(session_events)
|
||||||
|
counter += 1
|
||||||
|
joint_events.extend(events)
|
||||||
|
|
||||||
|
return pd.DataFrame(joint_events)
|
||||||
|
|
||||||
|
def compute_interaction_features(self, interaction_df: pd.DataFrame) -> Dict[str, float]:
|
||||||
|
if interaction_df.empty:
|
||||||
|
return {"mean_sale_price": 0.0, "look_to_book": 0.0}
|
||||||
|
purchases = interaction_df[interaction_df["action"] == "purchase"]
|
||||||
|
mean_sale_price = float(purchases["price_paid"].mean()) if not purchases.empty else 0.0
|
||||||
|
views = float((interaction_df["action"] == "view").sum())
|
||||||
|
buys = float((interaction_df["action"] == "purchase").sum())
|
||||||
|
return {"mean_sale_price": mean_sale_price, "look_to_book": float(views / (buys + 1e-6))}
|
||||||
|
|
||||||
|
def _session_feature_table(self, df: pd.DataFrame) -> pd.DataFrame:
|
||||||
|
# TODO: adapt this
|
||||||
|
if df.empty:
|
||||||
|
return pd.DataFrame()
|
||||||
|
g = df.groupby("session_id", sort=False)
|
||||||
|
session_duration = g["t"].max() - g["t"].min()
|
||||||
|
total_interactions = g.size()
|
||||||
|
avg_time_between = g["t"].apply(lambda x: float(np.diff(np.sort(x.to_numpy())).mean()) if len(x) > 1 else 0.0)
|
||||||
|
interaction_velocity = total_interactions / (session_duration + 1e-6)
|
||||||
|
views = g.apply(lambda x: int((x["action"] == "view").sum()), include_groups=False)
|
||||||
|
cart_adds = g.apply(lambda x: int((x["action"] == "cart").sum()), include_groups=False)
|
||||||
|
purchases = g.apply(lambda x: int((x["action"] == "purchase").sum()), include_groups=False)
|
||||||
|
conversion_rate = purchases / (views + 1e-6)
|
||||||
|
is_agent = g["actor"].apply(lambda s: bool((s == "agent").any()), include_groups=False)
|
||||||
|
|
||||||
|
return pd.DataFrame({
|
||||||
|
"session_duration_sec": session_duration.astype(float),
|
||||||
|
"avg_time_between_events": avg_time_between.astype(float),
|
||||||
|
"total_interactions": total_interactions.astype(int),
|
||||||
|
"interaction_velocity": interaction_velocity.astype(float),
|
||||||
|
"item_views": views.astype(int),
|
||||||
|
"cart_adds": cart_adds.astype(int),
|
||||||
|
"purchases": purchases.astype(int),
|
||||||
|
"conversion_rate": conversion_rate.astype(float),
|
||||||
|
"is_agent": is_agent.astype(bool),
|
||||||
|
}).reset_index()
|
||||||
|
|
||||||
|
def get_interaction_data(self) -> np.ndarray:
|
||||||
|
if self._last_interaction_df.empty:
|
||||||
|
return np.array([], dtype=object)
|
||||||
|
return self._last_interaction_df.to_dict(orient="records")
|
||||||
|
|
||||||
|
|
||||||
|
class PHANTOMEnv(gym.Env):
|
||||||
|
metadata = {"render_modes": []}
|
||||||
|
|
||||||
|
def __init__(self, constraints):
|
||||||
|
super().__init__()
|
||||||
|
self.constraints = BusinessLogicConstraints()
|
||||||
|
self.action_space = spaces.Box(low=-self.constraints.max_price_adjustment,
|
||||||
|
high=self.constraints.max_price_adjustment,
|
||||||
|
shape=(self.constraints.product_catelogue_size,), dtype=np.float32)
|
||||||
|
self.observation_space = spaces.Dict({
|
||||||
|
"elasticity": spaces.Dict({
|
||||||
|
"price": spaces.Box(
|
||||||
|
low=np.full((self.constraints.product_catelogue_size,), self.constraints.system_min_price, dtype=np.float32),
|
||||||
|
high=np.full((self.constraints.product_catelogue_size,), self.constraints.system_max_price, dtype=np.float32),
|
||||||
|
dtype=np.float32),
|
||||||
|
"demand": spaces.Box(
|
||||||
|
low=np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32),
|
||||||
|
high=np.full((self.constraints.product_catelogue_size,), 1e6, dtype=np.float32),
|
||||||
|
dtype=np.float32),
|
||||||
|
})
|
||||||
|
# TODO: define more features that we compute from the interaction data
|
||||||
|
})
|
||||||
|
self.commerce_platform = CommercePlatform(
|
||||||
|
product_catelogue_size=self.constraints.product_catelogue_size,
|
||||||
|
max_price=self.constraints.system_max_price,
|
||||||
|
min_price=self.constraints.system_min_price,
|
||||||
|
constraints=self.constraints)
|
||||||
|
self._rng = np.random.default_rng(self.constraints.seed)
|
||||||
|
self.t = 0
|
||||||
|
self._prev_prices: Optional[np.ndarray] = None
|
||||||
|
self.state: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def reset(self, seed: Optional[int] = None, options: Optional[dict] = None):
|
||||||
|
super().reset(seed=seed)
|
||||||
|
if seed is not None:
|
||||||
|
self._rng = np.random.default_rng(seed)
|
||||||
|
self.commerce_platform._rng = np.random.default_rng(seed)
|
||||||
|
self.t = 0
|
||||||
|
init_prices = self._rng.uniform(low=60.0, high=140.0, size=(self.constraints.product_catelogue_size,)).astype(np.float32)
|
||||||
|
self._prev_prices = init_prices.copy()
|
||||||
|
self.state = {
|
||||||
|
"elasticity": {
|
||||||
|
"price": init_prices,
|
||||||
|
"demand": np.zeros((self.constraints.product_catelogue_size,), dtype=np.float32),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.state, {}
|
||||||
|
|
||||||
|
def step(self, action: np.ndarray):
|
||||||
|
self.t += 1
|
||||||
|
base_prices = self.state["elasticity"]["price"].astype(np.float32)
|
||||||
|
new_prices = np.clip(base_prices * (1.0 + action.astype(np.float32)),
|
||||||
|
self.constraints.system_min_price,
|
||||||
|
self.constraints.system_max_price).astype(np.float32)
|
||||||
|
|
||||||
|
self.state["elasticity"]["price"] = new_prices
|
||||||
|
# TODO: use the commerce platform to simulate sessions
|
||||||
|
interactions_df = self.commerce_platform._simulate_sessions(new_prices)
|
||||||
|
result = self.commerce_platform.compute_interaction_features(interactions_df)
|
||||||
|
# TODO: implement COI computation to use in reward
|
||||||
|
COI = 0.0
|
||||||
|
|
||||||
|
volatility = 0.0 if self._prev_prices is None else \
|
||||||
|
float(np.mean(np.abs((new_prices - self._prev_prices) / (self._prev_prices + 1e-6))))
|
||||||
|
self._prev_prices = new_prices.copy()
|
||||||
|
|
||||||
|
revenue_observed = float(result["revenue_observed"])
|
||||||
|
agent_loss = float(result["agent_loss"])
|
||||||
|
|
||||||
|
reward = (revenue_observed
|
||||||
|
- COI
|
||||||
|
- self.constraints.w_agent_loss * agent_loss
|
||||||
|
- self.constraints.w_volatility * volatility
|
||||||
|
- self.constraints.w_estimation_error
|
||||||
|
)
|
||||||
|
|
||||||
|
terminated = self.t >= self.constraints.episode_length
|
||||||
|
info = {
|
||||||
|
"t": self.t,
|
||||||
|
"revenue_observed": revenue_observed,
|
||||||
|
"revenue_oracle": float(result["revenue_oracle"]),
|
||||||
|
"agent_loss": agent_loss,
|
||||||
|
"ux_volatility": volatility,
|
||||||
|
"mean_internal_error": err_mean,
|
||||||
|
"look_to_book": float(result["interaction_features"].get("look_to_book", 0.0)),
|
||||||
|
"mean_sale_price": float(result["interaction_features"].get("mean_sale_price", 0.0)),
|
||||||
|
"true_human_purchases_total": float(np.sum(result["true_human_demand"])),
|
||||||
|
"true_agent_purchases_total": float(np.sum(result["true_agent_purchases"])),
|
||||||
|
}
|
||||||
|
return self.state, float(reward), terminated, False, info
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
runs = {}
|
||||||
|
for use_defense in (False, True):
|
||||||
|
env = PHANTOMEnv(use_defense=use_defense)
|
||||||
|
obs, _ = env.reset(seed=42)
|
||||||
|
metrics = defaultdict(list)
|
||||||
|
total_reward = 0.0
|
||||||
|
done = False
|
||||||
|
|
||||||
|
while not done:
|
||||||
|
action = env.action_space.sample()
|
||||||
|
obs, reward, done, _, info = env.step(action)
|
||||||
|
total_reward += reward
|
||||||
|
p_mean = float(np.mean(obs["elasticity"]["price"]))
|
||||||
|
q_mean = float(np.mean(obs["elasticity"]["demand"]))
|
||||||
|
p_std = float(np.std(obs["elasticity"]["price"]))
|
||||||
|
|
||||||
|
metrics['t'].append(info['t'])
|
||||||
|
metrics['price_mean'].append(p_mean)
|
||||||
|
metrics['price_std'].append(p_std)
|
||||||
|
metrics['demand_mean'].append(q_mean)
|
||||||
|
metrics['revenue_observed'].append(info['revenue_observed'])
|
||||||
|
metrics['revenue_oracle'].append(info['revenue_oracle'])
|
||||||
|
metrics['agent_loss'].append(info['agent_loss'])
|
||||||
|
metrics['ux_volatility'].append(info['ux_volatility'])
|
||||||
|
metrics['look_to_book'].append(info['look_to_book'])
|
||||||
|
metrics['reward'].append(reward)
|
||||||
|
metrics['human_purchases'].append(info['true_human_purchases_total'])
|
||||||
|
metrics['agent_purchases'].append(info['true_agent_purchases_total'])
|
||||||
|
|
||||||
|
if info['t'] % 20 == 0 or done:
|
||||||
|
print(f"defense={'ON ' if use_defense else 'OFF'} t={info['t']:03d} p={p_mean:6.2f}±{p_std:4.2f} "
|
||||||
|
f"q={q_mean:6.2f} rev={info['revenue_observed']:7.2f} oracle={info['revenue_oracle']:7.2f} "
|
||||||
|
f"loss={info['agent_loss']:6.2f} ux={info['ux_volatility']:.3f} "
|
||||||
|
f"ltb={info['look_to_book']:5.2f} r={reward:7.2f}")
|
||||||
|
|
||||||
|
runs[use_defense] = metrics
|
||||||
|
print(f"defense={'ON ' if use_defense else 'OFF'} total_reward={total_reward:.2f}\n")
|
||||||
|
|
||||||
|
fig, axes = plt.subplots(3, 3, figsize=(15, 12))
|
||||||
|
fig.suptitle('PHANTOM Environment: Defense OFF vs ON', fontsize=14, fontweight='bold')
|
||||||
|
|
||||||
|
plot_configs = [
|
||||||
|
('price_mean', 'Mean Price', 'Price'),
|
||||||
|
('demand_mean', 'Mean Demand Estimate', 'Demand'),
|
||||||
|
('revenue_observed', 'Revenue (Observed)', 'Revenue'),
|
||||||
|
('agent_loss', 'Agent Loss (Oracle - Observed)', 'Loss'),
|
||||||
|
('ux_volatility', 'UX Volatility (Price Change)', 'Volatility'),
|
||||||
|
('look_to_book', 'Look-to-Book Ratio', 'Ratio'),
|
||||||
|
('reward', 'Step Reward', 'Reward'),
|
||||||
|
('human_purchases', 'Human Purchases', 'Count'),
|
||||||
|
('agent_purchases', 'Agent Purchases', 'Count'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for idx, (key, title, ylabel) in enumerate(plot_configs):
|
||||||
|
ax = axes[idx // 3, idx % 3]
|
||||||
|
for use_defense, label, color in [(False, 'No Defense', 'red'), (True, 'With Defense', 'blue')]:
|
||||||
|
m = runs[use_defense]
|
||||||
|
ax.plot(m['t'], m[key], label=label, color=color, alpha=0.7, linewidth=1.5)
|
||||||
|
ax.set_xlabel('Step')
|
||||||
|
ax.set_ylabel(ylabel)
|
||||||
|
ax.set_title(title, fontsize=10, fontweight='bold')
|
||||||
|
ax.legend(loc='best', fontsize=8)
|
||||||
|
ax.grid(True, alpha=0.3)
|
||||||
|
|
||||||
|
plt.tight_layout()
|
||||||
|
plt.savefig('phantom_env_comparison.png', dpi=150, bbox_inches='tight')
|
||||||
|
print("Plot saved to phantom_env_comparison.png")
|
||||||
|
plt.show()
|
||||||
149
sim/rl/train.py
Normal file
149
sim/rl/train.py
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import numpy as np
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Type, Optional
|
||||||
|
import pickle
|
||||||
|
from torch import neg_
|
||||||
|
from torch.utils.tensorboard import SummaryWriter
|
||||||
|
from environment import PHANTOMEnv, FastTrainingConstraints, BusinessLogicConstraints
|
||||||
|
from engine import (BasePricingEngine, WildPricingEngine, StaticPricingEngine,
|
||||||
|
SimpleDemandEngine, RandomWalkEngine, ThompsonSamplingEngine)
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Target training loop:
|
||||||
|
have base prices p0 from env reset and run the env step, collect reward and metrics
|
||||||
|
pass this to the pricing engine which computes the price action to take based on previous reward by learning
|
||||||
|
the new action gets passed to the step
|
||||||
|
so we alternate, step -> reward -> engine (produces price delta) -> step with price delta -> reward
|
||||||
|
to make sure the reinforcement learning inside the engine can learn we need to have trajectory of prices
|
||||||
|
CURRENT SOLUTION BELOW does not implement correct learning or updates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class EngineTrainer:
|
||||||
|
"""wrapper to run pricing engines through episodes and collect metrics"""
|
||||||
|
def __init__(self, engine: BasePricingEngine, env: PHANTOMEnv,
|
||||||
|
tb_writer: Optional[SummaryWriter] = None):
|
||||||
|
self.engine = engine
|
||||||
|
self.env = env
|
||||||
|
self.episode_metrics = []
|
||||||
|
self.tb_writer = tb_writer
|
||||||
|
self.global_step = 0
|
||||||
|
|
||||||
|
def train(self, n_episodes: int, seed: int = 42):
|
||||||
|
|
||||||
|
obs, _ = self.env.reset(seed=seed)
|
||||||
|
prices = None
|
||||||
|
for ep in range(n_episodes):
|
||||||
|
prices = self.engine.compute_prices(prices, obs)
|
||||||
|
obs, reward, done, _, info = self.env.step(prices)
|
||||||
|
self.engine.update(obs, reward, done, info)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return self.episode_metrics
|
||||||
|
|
||||||
|
def evaluate(self, n_episodes: int = 10, seed: int = 100) -> Dict:
|
||||||
|
"""evaluate trained engine"""
|
||||||
|
results = {k: [] for k in ['total_reward', 'revenue_observed', 'revenue_oracle',
|
||||||
|
'agent_loss', 'ux_volatility', 'look_to_book']}
|
||||||
|
for ep in range(n_episodes):
|
||||||
|
metrics = self.run_episode(seed=seed + ep)
|
||||||
|
for k in results: results[k].append(metrics[k])
|
||||||
|
return {k: (np.mean(v), np.std(v)) for k, v in results.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def make_env(fast: bool = True):
|
||||||
|
constraints = FastTrainingConstraints() if fast else BusinessLogicConstraints()
|
||||||
|
return PHANTOMEnv(constraints=constraints)
|
||||||
|
|
||||||
|
|
||||||
|
def train_engine(engine_cls: Type[BasePricingEngine], env: PHANTOMEnv,
|
||||||
|
n_episodes: int, seed: int = 42,
|
||||||
|
tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
|
||||||
|
constraints = env.constraints
|
||||||
|
engine = engine_cls(constraints=constraints, seed=seed)
|
||||||
|
trainer = EngineTrainer(engine, env, tb_writer=tb_writer)
|
||||||
|
trainer.train(n_episodes, seed=seed)
|
||||||
|
return trainer
|
||||||
|
|
||||||
|
|
||||||
|
def save_trainer(trainer: EngineTrainer, path: Path):
|
||||||
|
"""save engine state and metrics"""
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, 'wb') as f:
|
||||||
|
pickle.dump({
|
||||||
|
'engine': trainer.engine,
|
||||||
|
'metrics': trainer.episode_metrics
|
||||||
|
}, f)
|
||||||
|
logger.info(f"Saved trainer to {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def load_trainer(path: Path, env: PHANTOMEnv,
|
||||||
|
tb_writer: Optional[SummaryWriter] = None) -> EngineTrainer:
|
||||||
|
"""load saved engine"""
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
data = pickle.load(f)
|
||||||
|
trainer = EngineTrainer(data['engine'], env, tb_writer=tb_writer)
|
||||||
|
trainer.episode_metrics = data['metrics']
|
||||||
|
return trainer
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
base_dir = Path("./runs")
|
||||||
|
base_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
engines = {
|
||||||
|
"Wild": WildPricingEngine,
|
||||||
|
"Static": StaticPricingEngine,
|
||||||
|
# "SimpleDemand": SimpleDemandEngine,
|
||||||
|
"RandomWalk": RandomWalkEngine,
|
||||||
|
"ThompsonSampling": ThompsonSamplingEngine,
|
||||||
|
}
|
||||||
|
defenses = [False, True]
|
||||||
|
n_train_episodes = 50
|
||||||
|
n_eval_episodes = 10
|
||||||
|
seed = 42
|
||||||
|
fast_mode = True
|
||||||
|
|
||||||
|
logger.info(f"Training config: {n_train_episodes} episodes per engine, fast_mode={fast_mode}")
|
||||||
|
|
||||||
|
trained_trainers = {}
|
||||||
|
|
||||||
|
for engine_name, engine_cls in engines.items():
|
||||||
|
for use_defense in defenses:
|
||||||
|
defense_label = "defense_on" if use_defense else "defense_off"
|
||||||
|
run_name = f"{engine_name}_{defense_label}"
|
||||||
|
log_dir = base_dir / run_name
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
logger.info(f"Training {engine_name} with defense={use_defense}")
|
||||||
|
logger.info(f"Log directory: {log_dir}")
|
||||||
|
|
||||||
|
env = make_env(fast=fast_mode)
|
||||||
|
tb_writer = SummaryWriter(log_dir=str(log_dir))
|
||||||
|
trainer = train_engine(engine_cls, env, n_train_episodes, seed, tb_writer=tb_writer)
|
||||||
|
tb_writer.close()
|
||||||
|
|
||||||
|
save_path = log_dir / "trainer.pkl"
|
||||||
|
save_trainer(trainer, save_path)
|
||||||
|
|
||||||
|
trained_trainers[run_name] = (trainer, env)
|
||||||
|
|
||||||
|
logger.info("Starting evaluation")
|
||||||
|
|
||||||
|
for run_name, (trainer, env) in trained_trainers.items():
|
||||||
|
logger.info(f"Evaluating {run_name}")
|
||||||
|
results = trainer.evaluate(n_episodes=n_eval_episodes, seed=seed + 1000)
|
||||||
|
for metric, (mean, std) in results.items():
|
||||||
|
logger.info(f" {metric:20s}: {mean:10.2f} ± {std:6.2f}")
|
||||||
|
|
||||||
|
logger.info(f"Results saved to: {base_dir}")
|
||||||
@@ -9,8 +9,8 @@ interface InteractionEvent {
|
|||||||
const dumpKafkaTopic = async (backendUrl: string, topic: string) => {
|
const dumpKafkaTopic = async (backendUrl: string, topic: string) => {
|
||||||
const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`);
|
const resp = await fetch(`${backendUrl}/api/kafka/dump?topic=${topic}`);
|
||||||
if (!resp.ok) throw new Error(`Kafka dump failed: ${resp.status}`);
|
if (!resp.ok) throw new Error(`Kafka dump failed: ${resp.status}`);
|
||||||
const { messages = [] } = await resp.json();
|
const { data = [] } = await resp.json();
|
||||||
return messages as any[];
|
return data as any[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const waitForInteractionEvent = async (
|
export const waitForInteractionEvent = async (
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ export default defineConfig({
|
|||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
workers: 5,
|
workers: 1,
|
||||||
reporter: 'list',
|
reporter: 'list',
|
||||||
use: {
|
use: {
|
||||||
baseURL: process.env.WEB_URL || 'http://localhost:3000',
|
baseURL: process.env.WEB_URL || 'http://localhost:3000',
|
||||||
trace: 'retain-on-failure',
|
trace: 'retain-on-failure',
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
},
|
},
|
||||||
timeout: 60000,
|
timeout: 180000,
|
||||||
expect: {
|
expect: {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
addToCart,
|
addToCart,
|
||||||
} from '../helpers/interactions';
|
} from '../helpers/interactions';
|
||||||
import { getSessionEvents } from '../helpers/kafka';
|
import { getSessionEvents } from '../helpers/kafka';
|
||||||
|
import { runSessionPricing } from '../helpers/airflow';
|
||||||
|
|
||||||
test.describe('SessionAwarePricer E2E', () => {
|
test.describe('SessionAwarePricer E2E', () => {
|
||||||
const STORE_TYPE = 'hotel';
|
const STORE_TYPE = 'hotel';
|
||||||
@@ -23,6 +24,9 @@ test.describe('SessionAwarePricer E2E', () => {
|
|||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
const productId2 = await humanLikeViewProduct(page, STORE_TYPE);
|
const productId2 = await humanLikeViewProduct(page, STORE_TYPE);
|
||||||
|
|
||||||
|
await runSessionPricing(STORE_TYPE);
|
||||||
|
|
||||||
const secondPrice = await getPriceFromDOM(page);
|
const secondPrice = await getPriceFromDOM(page);
|
||||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||||
|
|
||||||
@@ -40,11 +44,13 @@ test.describe('SessionAwarePricer E2E', () => {
|
|||||||
await rapidViewProductViaFlow(page, 8, 100, STORE_TYPE);
|
await rapidViewProductViaFlow(page, 8, 100, STORE_TYPE);
|
||||||
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
expect(await verifySessionConsistency(page, sessionId)).toBeTruthy();
|
||||||
|
|
||||||
await page.waitForTimeout(2500);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
const events = await getSessionEvents(backendUrl, sessionId);
|
const events = await getSessionEvents(backendUrl, sessionId);
|
||||||
expect(events.length).toBeGreaterThanOrEqual(8);
|
expect(events.length).toBeGreaterThanOrEqual(8);
|
||||||
|
|
||||||
|
await runSessionPricing(STORE_TYPE);
|
||||||
|
|
||||||
await page.goto(`/products/${productId}`);
|
await page.goto(`/products/${productId}`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const agentPrice = await getPriceFromDOM(page);
|
const agentPrice = await getPriceFromDOM(page);
|
||||||
@@ -59,14 +65,12 @@ test.describe('SessionAwarePricer E2E', () => {
|
|||||||
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
const productId = await viewProductViaFlow(page, STORE_TYPE);
|
||||||
const baselinePrice = await getPriceFromDOM(page);
|
const baselinePrice = await getPriceFromDOM(page);
|
||||||
|
|
||||||
const startTime = Date.now();
|
|
||||||
await rapidViewProductViaFlow(page, 10, 80, STORE_TYPE);
|
await rapidViewProductViaFlow(page, 10, 80, STORE_TYPE);
|
||||||
const duration = (Date.now() - startTime) / 1000;
|
|
||||||
|
|
||||||
const eventsPerSec = 10 / duration;
|
const events = await getSessionEvents(backendUrl, sessionId);
|
||||||
expect(eventsPerSec).toBeGreaterThan(2.0);
|
expect(events.length).toBeGreaterThanOrEqual(10);
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
await runSessionPricing(STORE_TYPE);
|
||||||
|
|
||||||
await page.goto(`/products/${productId}`);
|
await page.goto(`/products/${productId}`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
@@ -105,8 +109,11 @@ test.describe('SessionAwarePricer E2E', () => {
|
|||||||
|
|
||||||
await rapidViewProductViaFlow(page, 2, 150, STORE_TYPE);
|
await rapidViewProductViaFlow(page, 2, 150, STORE_TYPE);
|
||||||
|
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1000);
|
||||||
await humanLikeViewProduct(page, STORE_TYPE);
|
await humanLikeViewProduct(page, STORE_TYPE);
|
||||||
|
|
||||||
|
await runSessionPricing(STORE_TYPE);
|
||||||
|
|
||||||
const finalPrice = await getPriceFromDOM(page);
|
const finalPrice = await getPriceFromDOM(page);
|
||||||
|
|
||||||
expect(Math.abs(finalPrice - baselinePrice) / baselinePrice).toBeLessThan(0.3);
|
expect(Math.abs(finalPrice - baselinePrice) / baselinePrice).toBeLessThan(0.3);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
verifySessionConsistency,
|
verifySessionConsistency,
|
||||||
} from '../helpers/interactions';
|
} from '../helpers/interactions';
|
||||||
import { waitForInteractionEvent, countProductViews } from '../helpers/kafka';
|
import { waitForInteractionEvent, countProductViews } from '../helpers/kafka';
|
||||||
|
import { runSurgePricing } from '../helpers/airflow';
|
||||||
|
|
||||||
test.describe('SimpleSurgePricer E2E', () => {
|
test.describe('SimpleSurgePricer E2E', () => {
|
||||||
const STORE_TYPE = 'hotel';
|
const STORE_TYPE = 'hotel';
|
||||||
@@ -29,7 +30,7 @@ test.describe('SimpleSurgePricer E2E', () => {
|
|||||||
|
|
||||||
await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE);
|
await rapidViewProductViaFlow(page, 5, 200, STORE_TYPE);
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
const evt = await waitForInteractionEvent(backendUrl, sessionId, 'view_item_page');
|
const evt = await waitForInteractionEvent(backendUrl, sessionId, 'view_item_page');
|
||||||
expect(evt).not.toBeNull();
|
expect(evt).not.toBeNull();
|
||||||
@@ -37,6 +38,8 @@ test.describe('SimpleSurgePricer E2E', () => {
|
|||||||
const viewCount = await countProductViews(backendUrl, productId);
|
const viewCount = await countProductViews(backendUrl, productId);
|
||||||
expect(viewCount).toBeGreaterThanOrEqual(5);
|
expect(viewCount).toBeGreaterThanOrEqual(5);
|
||||||
|
|
||||||
|
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||||
|
|
||||||
await page.goto(`/products/${productId}`);
|
await page.goto(`/products/${productId}`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const surgedPrice = await getPriceFromDOM(page);
|
const surgedPrice = await getPriceFromDOM(page);
|
||||||
@@ -72,7 +75,9 @@ test.describe('SimpleSurgePricer E2E', () => {
|
|||||||
|
|
||||||
await rapidViewProductViaFlow(page, 5, 150, STORE_TYPE);
|
await rapidViewProductViaFlow(page, 5, 150, STORE_TYPE);
|
||||||
|
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||||
|
|
||||||
await page.goto(`/products/${productId}`);
|
await page.goto(`/products/${productId}`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
@@ -81,6 +86,8 @@ test.describe('SimpleSurgePricer E2E', () => {
|
|||||||
|
|
||||||
await page.waitForTimeout(12000);
|
await page.waitForTimeout(12000);
|
||||||
|
|
||||||
|
await runSurgePricing(STORE_TYPE, 3, 1);
|
||||||
|
|
||||||
await page.goto(`/products/${productId}`);
|
await page.goto(`/products/${productId}`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
const decayedPrice = await getPriceFromDOM(page);
|
const decayedPrice = await getPriceFromDOM(page);
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export async function GET(req: NextRequest) {
|
|||||||
const providerUrl = process.env.PRICING_PROVIDER_URL || 'http://localhost:5001';
|
const providerUrl = process.env.PRICING_PROVIDER_URL || 'http://localhost:5001';
|
||||||
try {
|
try {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
|
// THIS is our entry point into the dynamic pricing where we reference the context of the sesion and experiment and ask for a price to assign to the trajectory which is expressed
|
||||||
|
// The whole pipeline gets triggered from here.
|
||||||
if (sessionId) queryParams.append('sessionId', sessionId);
|
if (sessionId) queryParams.append('sessionId', sessionId);
|
||||||
if (experimentId) queryParams.append('experimentId', experimentId);
|
if (experimentId) queryParams.append('experimentId', experimentId);
|
||||||
|
|
||||||
@@ -55,11 +57,11 @@ export async function GET(req: NextRequest) {
|
|||||||
price = Math.round(randomBase * 100) / 100;
|
price = Math.round(randomBase * 100) / 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
// log price to kafka for elasticity computation
|
// log price to kafka asynchronously (non-blocking)
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
const backendUrl = process.env.BACKEND_URL || 'http://localhost:5000';
|
||||||
try {
|
// fire and forget - don't await to avoid blocking response
|
||||||
await fetch(`${backendUrl}/api/kafka/price-log`, {
|
fetch(`${backendUrl}/api/kafka/price-log`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -70,10 +72,11 @@ export async function GET(req: NextRequest) {
|
|||||||
storeMode,
|
storeMode,
|
||||||
ts: timestamp,
|
ts: timestamp,
|
||||||
}),
|
}),
|
||||||
});
|
}).catch(err => {
|
||||||
} catch (err) {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
console.error('[price-log-error]', err);
|
console.error('[price-log-error]', err);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ export default function CartPage() {
|
|||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={clearCart}
|
onClick={clearCart}
|
||||||
className="text-sm text-red-600 hover:underline"
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: 'var(--accent-warning)' }}
|
||||||
>
|
>
|
||||||
Clear cart
|
Clear cart
|
||||||
</button>
|
</button>
|
||||||
@@ -42,7 +43,7 @@ export default function CartPage() {
|
|||||||
{itemCount === 0 ? (
|
{itemCount === 0 ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
<p className="text-gray-500 mb-4">Your cart is empty</p>
|
||||||
<a href="/" className="text-blue-600 hover:underline">Browse our selection</a>
|
<a href="/" className="hover:underline" style={{ color: 'var(--text-accent)' }}>Browse our selection</a>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -54,15 +55,11 @@ export default function CartPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="px-2 py-0.5 text-xs font-medium rounded bg-blue-100 text-blue-800">
|
|
||||||
{item.type}
|
|
||||||
</span>
|
|
||||||
<h3 className="font-semibold">{item.name}</h3>
|
<h3 className="font-semibold">{item.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.type === 'hotel' && (
|
{item.type === 'hotel' && (
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
<p>{String(item.metadata.roomType)}</p>
|
|
||||||
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
<p>{String(item.metadata.checkIn)} - {String(item.metadata.checkOut)}</p>
|
||||||
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
<p>{String(item.metadata.nights)} night{Number(item.metadata.nights) > 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,7 +78,8 @@ export default function CartPage() {
|
|||||||
<p className="text-xl font-bold mb-2">${item.price}</p>
|
<p className="text-xl font-bold mb-2">${item.price}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRemove(item.id, item.type)}
|
onClick={() => handleRemove(item.id, item.type)}
|
||||||
className="text-sm text-red-600 hover:underline"
|
className="text-sm hover:underline"
|
||||||
|
style={{ color: 'var(--accent-warning)' }}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@@ -100,7 +98,7 @@ export default function CartPage() {
|
|||||||
dispatchInteraction('checkout_start', undefined, { total, itemCount });
|
dispatchInteraction('checkout_start', undefined, { total, itemCount });
|
||||||
window.location.href = '/checkout';
|
window.location.href = '/checkout';
|
||||||
}}
|
}}
|
||||||
className="w-full py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-medium transition-colors"
|
className="btn-primary w-full"
|
||||||
>
|
>
|
||||||
Proceed to Checkout
|
Proceed to Checkout
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
--bg-secondary: #f5f5f5;
|
--bg-secondary: #f5f5f5;
|
||||||
--text-primary: #333333;
|
--text-primary: #333333;
|
||||||
--text-secondary: #666666;
|
--text-secondary: #666666;
|
||||||
|
--accent-primary: #007aff;
|
||||||
|
--accent-primary-hover: #0051d5;
|
||||||
|
--accent-primary-light: #e6f2ff;
|
||||||
--spacing-sm: 8px;
|
--spacing-sm: 8px;
|
||||||
--spacing-md: 16px;
|
--spacing-md: 16px;
|
||||||
--spacing-lg: 32px;
|
--spacing-lg: 32px;
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Travel Booking Platform",
|
||||||
description: "Generated by create next app",
|
description: "Book flights and hotels with dynamic pricing",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import type { EventName } from '@/lib/events';
|
import type { EventName } from '@/lib/events';
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
import type { Hotel } from '@/lib/hotel-utils';
|
||||||
|
import { getHotelImageUrl } from '@/lib/hotel-utils';
|
||||||
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
import { useHoverTracking } from '@/hooks/useHoverTracking';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
@@ -47,8 +48,6 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
window.location.href = `/hotel/products/${hotel.id}`;
|
window.location.href = `/hotel/products/${hotel.id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageUrl = `https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=400&h=300&fit=crop`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="hotel-card cursor-pointer"
|
className="hotel-card cursor-pointer"
|
||||||
@@ -56,7 +55,7 @@ export default function HotelCard({ hotel }: { hotel: Hotel }) {
|
|||||||
>
|
>
|
||||||
<div className="hotel-image relative overflow-hidden">
|
<div className="hotel-image relative overflow-hidden">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={getHotelImageUrl(hotel.id, { w: 400, h: 300 })}
|
||||||
alt={hotel.name}
|
alt={hotel.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { Hotel } from '@/lib/hotel-utils';
|
import type { Hotel } from '@/lib/hotel-utils';
|
||||||
|
import { getHotelImageUrl } from '@/lib/hotel-utils';
|
||||||
import PriceDisplay from '@/components/ui/PriceDisplay';
|
import PriceDisplay from '@/components/ui/PriceDisplay';
|
||||||
|
|
||||||
interface HotelDetailsProps {
|
interface HotelDetailsProps {
|
||||||
@@ -43,13 +44,11 @@ const PriceTotalDisplay = ({ productId, nights }: { productId: string; nights: n
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
export default function HotelDetails({ product, onAddToCart, addedToCart }: HotelDetailsProps) {
|
||||||
const imageUrl = `https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800&h=600&fit=crop`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
<div className="w-full flex flex-col lg:flex-row gap-12 py-8">
|
||||||
<div className="w-full lg:w-1/2 rounded-lg aspect-[4/3] overflow-hidden shrink-0">
|
<div className="w-full lg:w-1/2 rounded-lg aspect-[4/3] overflow-hidden shrink-0">
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={getHotelImageUrl(product.id, { w: 800, h: 600 })}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const NavLink = ({ href, children }: { href: string; children: React.ReactNode }
|
|||||||
href={href}
|
href={href}
|
||||||
className={`px-4 py-2 rounded-md transition-colors ${
|
className={`px-4 py-2 rounded-md transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[var(--accent-primary)] font-semibold'
|
? 'bg-[var(--accent-primary)] text-white font-semibold'
|
||||||
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
: 'hover:bg-[var(--accent-primary-light)] text-[var(--text-primary)]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export interface Flight {
|
|||||||
availability: number;
|
availability: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EPOCH = new Date(0);
|
import { dateToDaysFromToday, dateToIndex, todayIndex } from './date-utils';
|
||||||
|
|
||||||
export const transformProduct = (p: AirlineProduct): Flight => {
|
export const transformProduct = (p: AirlineProduct): Flight => {
|
||||||
const { id, flight_type, date_index, metadata, availability } = p;
|
const { id, flight_type, date_index, metadata, availability } = p;
|
||||||
@@ -52,24 +52,4 @@ export const transformProduct = (p: AirlineProduct): Flight => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// convert date string to days from today
|
export { dateToDaysFromToday, dateToIndex, todayIndex };
|
||||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
|
||||||
const target = new Date(dateStr);
|
|
||||||
target.setHours(0, 0, 0, 0);
|
|
||||||
const today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// convert date string to date_index (days since epoch)
|
|
||||||
export const dateToIndex = (dateStr: string): number => {
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|
||||||
// get current date_index
|
|
||||||
export const todayIndex = (): number => {
|
|
||||||
const now = new Date();
|
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|||||||
23
web/src/lib/date-utils.ts
Normal file
23
web/src/lib/date-utils.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const EPOCH = new Date(0);
|
||||||
|
const MS_PER_DAY = 86400000;
|
||||||
|
|
||||||
|
export const dateToDaysFromToday = (dateStr: string): number => {
|
||||||
|
const target = new Date(dateStr);
|
||||||
|
target.setHours(0, 0, 0, 0);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return Math.floor((target.getTime() - today.getTime()) / MS_PER_DAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dateToIndex = (dateStr: string): number => {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return Math.floor((d.getTime() - EPOCH.getTime()) / MS_PER_DAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const todayIndex = (): number => {
|
||||||
|
const now = new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
return Math.floor((now.getTime() - EPOCH.getTime()) / MS_PER_DAY);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { EPOCH, MS_PER_DAY };
|
||||||
@@ -25,7 +25,7 @@ export interface Hotel {
|
|||||||
nights: number;
|
nights: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EPOCH = new Date(0);
|
import { EPOCH, MS_PER_DAY, dateToDaysFromToday, dateToIndex, todayIndex } from './date-utils';
|
||||||
|
|
||||||
export const transformProduct = (p: HotelProduct): Hotel => {
|
export const transformProduct = (p: HotelProduct): Hotel => {
|
||||||
const { id, room_type, date_index, metadata } = p;
|
const { id, room_type, date_index, metadata } = p;
|
||||||
@@ -37,14 +37,14 @@ export const transformProduct = (p: HotelProduct): Hotel => {
|
|||||||
// legacy: treat as offset from today
|
// legacy: treat as offset from today
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
today.setHours(0, 0, 0, 0);
|
today.setHours(0, 0, 0, 0);
|
||||||
checkIn = new Date(today.getTime() + date_index * 86400000);
|
checkIn = new Date(today.getTime() + date_index * MS_PER_DAY);
|
||||||
} else {
|
} else {
|
||||||
// proper: days since epoch
|
// proper: days since epoch
|
||||||
checkIn = new Date(EPOCH.getTime() + date_index * 86400000);
|
checkIn = new Date(EPOCH.getTime() + date_index * MS_PER_DAY);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nights = 1;
|
const nights = 1;
|
||||||
const checkOut = new Date(checkIn.getTime() + nights * 86400000);
|
const checkOut = new Date(checkIn.getTime() + nights * MS_PER_DAY);
|
||||||
|
|
||||||
const formatOpts: Intl.DateTimeFormatOptions = {
|
const formatOpts: Intl.DateTimeFormatOptions = {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
@@ -65,24 +65,34 @@ export const transformProduct = (p: HotelProduct): Hotel => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// convert date string to days from today
|
const hotelImagePool = [
|
||||||
export const dateToDaysFromToday = (dateStr: string): number => {
|
'photo-1566073771259-6a8506099945',
|
||||||
const target = new Date(dateStr);
|
'photo-1551882547-ff40c63fe5fa',
|
||||||
target.setHours(0, 0, 0, 0);
|
'photo-1590490360182-c33d57733427',
|
||||||
const today = new Date();
|
'photo-1582719478250-c89cae4dc85b',
|
||||||
today.setHours(0, 0, 0, 0);
|
'photo-1596701062351-8c2c14d1fdd0',
|
||||||
return Math.floor((target.getTime() - today.getTime()) / 86400000);
|
'photo-1631049307264-da0ec9d70304',
|
||||||
|
'photo-1578683010236-d716f9a3f461',
|
||||||
|
'photo-1540518614846-7eded433c457',
|
||||||
|
'photo-1505693416388-ac5ce068fe85',
|
||||||
|
'photo-1522771739844-6a9f6d5f14af',
|
||||||
|
'photo-1562438668-bcf0ca6578f0',
|
||||||
|
'photo-1595576508898-0ad5c879a061',
|
||||||
|
];
|
||||||
|
|
||||||
|
const hashString = (s: string): number => {
|
||||||
|
let h = 0;
|
||||||
|
for (let i = 0; i < s.length; i++) {
|
||||||
|
h = ((h << 5) - h) + s.charCodeAt(i);
|
||||||
|
h = h & h;
|
||||||
|
}
|
||||||
|
return Math.abs(h);
|
||||||
};
|
};
|
||||||
|
|
||||||
// convert date string to date_index (days since epoch)
|
export const getHotelImageUrl = (hotelId: string, size: { w: number; h: number } = { w: 400, h: 300 }): string => {
|
||||||
export const dateToIndex = (dateStr: string): number => {
|
const idx = hashString(hotelId) % hotelImagePool.length;
|
||||||
const d = new Date(dateStr);
|
const photoId = hotelImagePool[idx];
|
||||||
return Math.floor((d.getTime() - EPOCH.getTime()) / 86400000);
|
return `https://images.unsplash.com/${photoId}?w=${size.w}&h=${size.h}&fit=crop`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// get current date_index
|
export { dateToDaysFromToday, dateToIndex, todayIndex };
|
||||||
export const todayIndex = (): number => {
|
|
||||||
const now = new Date();
|
|
||||||
now.setHours(0, 0, 0, 0);
|
|
||||||
return Math.floor((now.getTime() - EPOCH.getTime()) / 86400000);
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user