Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: ci

on:
push:
pull_request:

jobs:
pipeline:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: requirements*.txt

- name: Install dependencies
run: pip install -r requirements-dev.txt

- name: Data, train, evaluate, sanity check
run: make all

- name: Data is reproducible from the seed
run: git diff --exit-code data/

- name: Unit tests
run: make test
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: data train evaluate sanity app all clean
.PHONY: data train evaluate sanity test app all clean

PYTHON ?= python

Expand All @@ -14,6 +14,9 @@ evaluate:
sanity:
$(PYTHON) -m src.models.sanity_check

test:
$(PYTHON) -m pytest -q tests

app:
streamlit run app.py

Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

<p align="left">
<a href="https://dynamic-pricing-0trm.streamlit.app/"><img src="https://img.shields.io/badge/Live%20demo-Streamlit-ff4b4b?logo=streamlit&logoColor=white" alt="Live demo"></a>
<a href="https://github.com/0trm/dynamic-pricing/actions/workflows/ci.yml"><img src="https://github.com/0trm/dynamic-pricing/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src="https://img.shields.io/badge/ML-Supervised-lightgrey" alt="ML">
<img src="https://img.shields.io/badge/Models-Prophet%20%2B%20XGBoost-lightgrey" alt="Models">
</p>
Expand All @@ -17,11 +18,14 @@ An ML-powered dynamic pricing and decision support system for ticket pricing in
## Quickstart

```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt # Python 3.12, pinned versions
make all # synthetic data, train, holdout evaluation, elasticity sanity check
make test # unit tests (optimizer bounds, cold start, save/load)
make app # open the human-in-the-loop Streamlit page locally
```

CI runs `make all` and `make test` on every push. The live demo sleeps after 12 hours without traffic (a Streamlit Community Cloud rule), so the first visit may need a click to wake it and about a minute to train.

---

## The problem
Expand Down Expand Up @@ -83,7 +87,7 @@ The **Dynamic Pricing Engine** ingests historical data from the **Club's Data Sy
| Naive: last 7 days per series | 37.4% | 0.544 | 8.7 | 15.2 | 1.00 |
| Naive: global mean | 79.7% | -0.428 | 18.6 | 27.0 | 2.13 |

The baseline that matters is the last-7-days naive: the forecast anyone could make at the cutoff without a model. The ensemble's WAPE is **29% lower** than it. The XGBoost stage does the heavy lifting: on its own, trained directly on sales, it beats the ensemble on WAPE and MAE, while the ensemble edges it on R² and RMSE (fewer large misses). Prophet alone is worse than the naive. Reproducible with `RANDOM_SEED=42` in `src/data/make_dataset.py`.
The baseline that matters is the last-7-days naive: the forecast anyone could make at the cutoff without a model. The ensemble's WAPE is **29% lower** than it. The XGBoost stage does the heavy lifting: on its own, trained directly on sales, it beats the ensemble on WAPE and MAE, while the ensemble edges it on R² and RMSE (fewer large misses). Prophet alone is worse than the naive. Reproducible with `RANDOM_SEED=42` in `src/data/make_dataset.py` and the pinned versions in `requirements.txt`.

#### Cold start

Expand Down Expand Up @@ -218,7 +222,9 @@ dynamic-pricing/
├── README.md
├── app.py # Streamlit HiTL page
├── config.py # Paths and reference dates
├── requirements.txt
├── requirements.txt # Pinned runtime dependencies
├── requirements-dev.txt # + pytest
├── tests/ # Unit tests: optimizer bounds, cold start, save/load
├── assets/ # Diagrams and images
├── data/
│ └── 03_synthetic/
Expand Down Expand Up @@ -248,9 +254,10 @@ dynamic-pricing/
### Reproducing the results

```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
make clean # remove cached artifacts
make all # data + train + evaluate (accuracy + decision quality) + sanity check
make all # data + train + evaluate (accuracy, decisions, cold start) + sanity check
make test # unit tests
make app # launch the Streamlit page

# Or run the CLI examples directly:
Expand Down
4 changes: 2 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import json
import os
from datetime import datetime
from datetime import UTC, datetime
from pathlib import Path

import pandas as pd
Expand Down Expand Up @@ -128,7 +128,7 @@ def load_data() -> pd.DataFrame:
st.divider()
if st.button("✓ Approve recommended price", type="primary"):
proposal = {
'timestamp': datetime.utcnow().isoformat(),
'timestamp': datetime.now(UTC).isoformat(),
'match_id': int(match_id),
'seat_zone': seat_zone,
'days_until_match': int(days_until_match),
Expand Down
2 changes: 2 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-r requirements.txt
pytest==9.1.1
18 changes: 8 additions & 10 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
pandas>=2.2.0
numpy>=2.0.0
joblib>=1.4.0
scikit-learn>=1.5.0
prophet>=1.1.5
xgboost>=2.0.0
streamlit>=1.36.0
jupyterlab==4.2.1
matplotlib
seaborn
# Pinned to the versions the README numbers were produced with (Python 3.12).
pandas==3.0.6
numpy==2.5.3
joblib==1.6.0
scikit-learn==1.9.1
prophet==1.5.0
xgboost==3.4.1
streamlit==1.65.0
21 changes: 21 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import os
import sys

import pandas as pd
import pytest

sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))

import config
from src.models.train_demand_model import train_ensemble


@pytest.fixture(scope='session')
def data() -> pd.DataFrame:
return pd.read_csv(config.SYNTHETIC_DATA_PATH)


@pytest.fixture(scope='session')
def small_model(data):
"""Ensemble trained on three matches: enough series to exercise every path, fast enough for CI."""
return train_ensemble(data[data['match_id'].isin([1, 2, 3])])
56 changes: 56 additions & 0 deletions tests/test_engine.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import numpy as np
import pytest

import config
from src.data.make_dataset import price_effect
from src.decision_engine.constants import ZONE_BASE_PRICES
from src.decision_engine.optimize import OptimizationEngine
from src.models.evaluate import true_revenue_share
from src.models.train_demand_model import price_support


def test_true_optimum_is_one_and_a_quarter_times_base():
ratios = np.linspace(0.5, 2.5, 2001)
assert ratios[np.argmax(ratios * price_effect(ratios))] == pytest.approx(1.25, abs=1e-3)


def test_price_support_is_inside_observed_prices(data):
for zone, (lo, hi) in price_support(data).items():
prices = data.loc[data['seat_zone'] == zone, 'ticket_price']
assert prices.min() <= lo < hi <= prices.max() + 1


def test_recommendations_stay_inside_the_band(data, small_model):
rows = data[data['match_id'].isin([1, 2, 3])].drop(columns=[config.TARGET_COLUMN]).sample(30, random_state=0)
prices = OptimizationEngine(model=small_model).recommend_prices(rows)
for price, zone in zip(prices, rows['seat_zone']):
lo, hi = small_model.price_support[zone]
assert lo <= price <= hi


def test_batch_recommendation_matches_single_row_curve(data, small_model):
engine = OptimizationEngine(model=small_model)
row = data[data['match_id'] == 2].drop(columns=[config.TARGET_COLUMN]).iloc[[10]].reset_index(drop=True)
curve = engine.revenue_curve(row)
assert engine.recommend_prices(row)[0] == curve.loc[curve['projected_revenue'].idxmax(), 'price']


def test_unseen_match_uses_the_pooled_model(data, small_model):
unseen = data[data['match_id'] == 9].drop(columns=[config.TARGET_COLUMN])
with_fallback = small_model.predict(unseen)
pooled = np.clip(small_model.pooled_xgb.predict(small_model.feature_pipeline.transform(unseen)), 0, None)
np.testing.assert_allclose(with_fallback, pooled)


def test_true_revenue_share_is_one_at_the_optimum(data):
zones = data['seat_zone'].drop_duplicates().reset_index(drop=True)
base = zones.map(ZONE_BASE_PRICES).to_numpy()
np.testing.assert_allclose(true_revenue_share(1.25 * base, zones), 1.0, atol=1e-3)


def test_save_and_load_round_trip(tmp_path, data, small_model):
small_model.save(str(tmp_path))
loaded = type(small_model).load(str(tmp_path))
rows = data[data['match_id'] == 1].drop(columns=[config.TARGET_COLUMN]).head(20)
np.testing.assert_allclose(loaded.predict(rows), small_model.predict(rows))
assert loaded.price_support == small_model.price_support
Loading