Add OrderHistory.choose_tactical_model()
- the method implements a heuristic from the first research paper that chooses the most promising forecasting `*Model` based on the average daily demand in a `Pixel` for a given `train_horizon` - adjust the test scenario => `LONG_TRAIN_HORIZON` becomes `8` as that is part of the rule implemented in the heuristic
This commit is contained in:
parent
8926e9ff28
commit
af82951485
6 changed files with 199 additions and 35 deletions
|
@ -48,8 +48,9 @@ class Config:
|
||||||
# individual orders into time series.
|
# individual orders into time series.
|
||||||
TIME_STEPS = [60]
|
TIME_STEPS = [60]
|
||||||
|
|
||||||
# Training horizons (in full weeks) used
|
# Training horizons (in full weeks) used to train the forecasting models.
|
||||||
# to train the forecasting models.
|
# For now, we only use 8 weeks as that was the best performing in
|
||||||
|
# a previous study (note:4f79e8fa).
|
||||||
TRAINING_HORIZONS = [8]
|
TRAINING_HORIZONS = [8]
|
||||||
|
|
||||||
# The demand forecasting methods used in the simulations.
|
# The demand forecasting methods used in the simulations.
|
||||||
|
|
|
@ -29,6 +29,7 @@ A future `planning` sub-package will contain the `*Model`s used to plan the
|
||||||
`Courier`'s shifts a week ahead.
|
`Courier`'s shifts a week ahead.
|
||||||
""" # noqa:RST215
|
""" # noqa:RST215
|
||||||
|
|
||||||
|
from urban_meal_delivery.forecasts.models.base import ForecastingModelABC
|
||||||
from urban_meal_delivery.forecasts.models.tactical.horizontal import HorizontalETSModel
|
from urban_meal_delivery.forecasts.models.tactical.horizontal import HorizontalETSModel
|
||||||
from urban_meal_delivery.forecasts.models.tactical.realtime import RealtimeARIMAModel
|
from urban_meal_delivery.forecasts.models.tactical.realtime import RealtimeARIMAModel
|
||||||
from urban_meal_delivery.forecasts.models.tactical.vertical import VerticalARIMAModel
|
from urban_meal_delivery.forecasts.models.tactical.vertical import VerticalARIMAModel
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
"""Obtain and work with time series data."""
|
"""Obtain and work with time series data."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from typing import Tuple
|
from typing import Tuple
|
||||||
|
|
||||||
|
@ -7,6 +9,7 @@ import pandas as pd
|
||||||
|
|
||||||
from urban_meal_delivery import config
|
from urban_meal_delivery import config
|
||||||
from urban_meal_delivery import db
|
from urban_meal_delivery import db
|
||||||
|
from urban_meal_delivery.forecasts import models
|
||||||
|
|
||||||
|
|
||||||
class OrderHistory:
|
class OrderHistory:
|
||||||
|
@ -502,3 +505,53 @@ class OrderHistory:
|
||||||
n_days = (last_day - first_day).days + 1
|
n_days = (last_day - first_day).days + 1
|
||||||
|
|
||||||
return round(training_ts.sum() / n_days, 1)
|
return round(training_ts.sum() / n_days, 1)
|
||||||
|
|
||||||
|
def choose_tactical_model(
|
||||||
|
self, pixel_id: int, predict_day: dt.date, train_horizon: int,
|
||||||
|
) -> models.ForecastingModelABC:
|
||||||
|
"""Choose the most promising forecasting `*Model` for tactical purposes.
|
||||||
|
|
||||||
|
The rules are deduced from "Table 1: Top-3 models by ..." in the article
|
||||||
|
"Real-time demand forecasting for an urban delivery platform", the first
|
||||||
|
research paper published for this `urban-meal-delivery` project.
|
||||||
|
|
||||||
|
According to the research findings in the article "Real-time demand
|
||||||
|
forecasting for an urban delivery platform", the best model is a function
|
||||||
|
of the average daily demand (ADD) and the length of the training horizon.
|
||||||
|
|
||||||
|
For the paper check:
|
||||||
|
https://github.com/webartifex/urban-meal-delivery-demand-forecasting/blob/main/paper.pdf
|
||||||
|
https://www.sciencedirect.com/science/article/pii/S1366554520307936
|
||||||
|
|
||||||
|
Args:
|
||||||
|
pixel_id: pixel for which a forecasting `*Model` is chosen
|
||||||
|
predict_day: day for which demand is to be predicted with the `*Model`
|
||||||
|
train_horizon: time horizon available for training the `*Model`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
most promising forecasting `*Model`
|
||||||
|
|
||||||
|
# noqa:DAR401 RuntimeError
|
||||||
|
""" # noqa:RST215
|
||||||
|
add = self.avg_daily_demand(
|
||||||
|
pixel_id=pixel_id, predict_day=predict_day, train_horizon=train_horizon,
|
||||||
|
)
|
||||||
|
|
||||||
|
# For now, we only make forecasts with 8 weeks
|
||||||
|
# as the training horizon (note:4f79e8fa).
|
||||||
|
if train_horizon == 8:
|
||||||
|
if add >= 25: # = "high demand"
|
||||||
|
return models.HorizontalETSModel(order_history=self)
|
||||||
|
elif add >= 10: # = "medium demand"
|
||||||
|
return models.HorizontalETSModel(order_history=self)
|
||||||
|
elif add >= 2.5: # = "low demand"
|
||||||
|
# TODO: create HorizontalSMAModel
|
||||||
|
return models.HorizontalETSModel(order_history=self)
|
||||||
|
|
||||||
|
# = "no demand"
|
||||||
|
# TODO: create HorizontalTrivialModel
|
||||||
|
return models.HorizontalETSModel(order_history=self)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
'no rule for the given average daily demand and training horizon',
|
||||||
|
)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""Globals used when testing."""
|
"""Globals used when testing."""
|
||||||
|
|
||||||
import datetime
|
import datetime as dt
|
||||||
|
|
||||||
from urban_meal_delivery import config
|
from urban_meal_delivery import config
|
||||||
|
|
||||||
|
@ -11,10 +11,11 @@ YEAR, MONTH, DAY = 2016, 7, 1
|
||||||
# The hour when most test cases take place.
|
# The hour when most test cases take place.
|
||||||
NOON = 12
|
NOON = 12
|
||||||
|
|
||||||
# `START` and `END` constitute a 22-day time span.
|
# `START` and `END` constitute a 57-day time span, 8 full weeks plus 1 day.
|
||||||
# That implies a maximum `train_horizon` of `3` as that needs full 7-day weeks.
|
# That implies a maximum `train_horizon` of `8` as that needs full 7-day weeks.
|
||||||
START = datetime.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 0)
|
START = dt.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 0)
|
||||||
END = datetime.datetime(YEAR, MONTH, DAY + 21, config.SERVICE_END, 0)
|
_end = START + dt.timedelta(days=56) # `56` as `START` is not included
|
||||||
|
END = dt.datetime(_end.year, _end.month, _end.day, config.SERVICE_END, 0)
|
||||||
|
|
||||||
# Default time steps (in minutes), for example, for `OrderHistory` objects.
|
# Default time steps (in minutes), for example, for `OrderHistory` objects.
|
||||||
LONG_TIME_STEP = 60
|
LONG_TIME_STEP = 60
|
||||||
|
@ -28,6 +29,6 @@ VERTICAL_FREQUENCY_SHORT = 7 * 24
|
||||||
|
|
||||||
# Default training horizons, for example, for
|
# Default training horizons, for example, for
|
||||||
# `OrderHistory.make_horizontal_time_series()`.
|
# `OrderHistory.make_horizontal_time_series()`.
|
||||||
LONG_TRAIN_HORIZON = 3
|
LONG_TRAIN_HORIZON = 8
|
||||||
SHORT_TRAIN_HORIZON = 2
|
SHORT_TRAIN_HORIZON = 2
|
||||||
TRAIN_HORIZONS = (SHORT_TRAIN_HORIZON, LONG_TRAIN_HORIZON)
|
TRAIN_HORIZONS = (SHORT_TRAIN_HORIZON, LONG_TRAIN_HORIZON)
|
||||||
|
|
|
@ -1,37 +1,145 @@
|
||||||
"""Tests for the `OrderHistory.avg_daily_demand()` method."""
|
"""Tests for the `OrderHistory.avg_daily_demand()` and ...
|
||||||
|
|
||||||
|
`OrderHistory.choose_tactical_model()` methods.
|
||||||
|
|
||||||
|
We test both methods together as they take the same input and are really
|
||||||
|
two parts of the same conceptual step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from tests import config as test_config
|
from tests import config as test_config
|
||||||
|
from urban_meal_delivery.forecasts import models
|
||||||
|
|
||||||
|
|
||||||
def test_avg_daily_demand_with_constant_demand(
|
class TestAverageDailyDemand:
|
||||||
order_history, good_pixel_id, predict_at,
|
"""Tests for the `OrderHistory.avg_daily_demand()` method."""
|
||||||
):
|
|
||||||
"""The average daily demand must be the number of time steps ...
|
|
||||||
|
|
||||||
... if the demand is `1` at each time step.
|
def test_avg_daily_demand_with_constant_demand(
|
||||||
|
self, order_history, good_pixel_id, predict_at,
|
||||||
|
):
|
||||||
|
"""The average daily demand must be the number of time steps ...
|
||||||
|
|
||||||
Note: The `order_history` fixture assumes `12` time steps per day as it
|
... if the demand is `1` at each time step.
|
||||||
uses `LONG_TIME_STEP=60` as the length of a time step.
|
|
||||||
"""
|
|
||||||
result = order_history.avg_daily_demand(
|
|
||||||
pixel_id=good_pixel_id,
|
|
||||||
predict_day=predict_at.date(),
|
|
||||||
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == 12.0
|
Note: The `order_history` fixture assumes `12` time steps per day as it
|
||||||
|
uses `LONG_TIME_STEP=60` as the length of a time step.
|
||||||
|
"""
|
||||||
|
result = order_history.avg_daily_demand(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == 12.0
|
||||||
|
|
||||||
|
def test_avg_daily_demand_with_no_demand(
|
||||||
|
self, order_history, good_pixel_id, predict_at,
|
||||||
|
):
|
||||||
|
"""Without demand, the average daily demand must be `0.0`."""
|
||||||
|
order_history._data.loc[:, 'n_orders'] = 0
|
||||||
|
|
||||||
|
result = order_history.avg_daily_demand(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_avg_daily_demand_with_no_demand(
|
class TestChooseTacticalModel:
|
||||||
order_history, good_pixel_id, predict_at,
|
"""Tests for the `OrderHistory.choose_tactical_model()` method."""
|
||||||
):
|
|
||||||
"""Without demand, the average daily demand must be `0.0`."""
|
|
||||||
order_history._data.loc[:, 'n_orders'] = 0
|
|
||||||
|
|
||||||
result = order_history.avg_daily_demand(
|
def test_best_model_with_high_demand(
|
||||||
pixel_id=good_pixel_id,
|
self, order_history, good_pixel_id, predict_at,
|
||||||
predict_day=predict_at.date(),
|
):
|
||||||
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
"""With high demand, the average daily demand is `.>= 25.0`."""
|
||||||
)
|
# With 12 time steps per day, the ADD becomes `36.0`.
|
||||||
|
order_history._data.loc[:, 'n_orders'] = 3
|
||||||
|
|
||||||
assert result == 0.0
|
result = order_history.choose_tactical_model(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, models.HorizontalETSModel)
|
||||||
|
|
||||||
|
def test_best_model_with_medium_demand(
|
||||||
|
self, order_history, good_pixel_id, predict_at,
|
||||||
|
):
|
||||||
|
"""With medium demand, the average daily demand is `>= 10.0` and `< 25.0`."""
|
||||||
|
# With 12 time steps per day, the ADD becomes `24.0`.
|
||||||
|
order_history._data.loc[:, 'n_orders'] = 2
|
||||||
|
|
||||||
|
result = order_history.choose_tactical_model(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(result, models.HorizontalETSModel)
|
||||||
|
|
||||||
|
def test_best_model_with_low_demand(
|
||||||
|
self, order_history, good_pixel_id, predict_at,
|
||||||
|
):
|
||||||
|
"""With low demand, the average daily demand is `>= 2.5` and `< 10.0`."""
|
||||||
|
# With 12 time steps per day, the ADD becomes `12.0` ...
|
||||||
|
data = order_history._data
|
||||||
|
data.loc[:, 'n_orders'] = 1
|
||||||
|
|
||||||
|
# ... and we set three additional time steps per day to `0`.
|
||||||
|
data.loc[ # noqa:ECE001
|
||||||
|
# all `Pixel`s, all `Order`s in time steps starting at 11 am
|
||||||
|
(slice(None), slice(data.index.levels[1][0], None, 12)),
|
||||||
|
'n_orders',
|
||||||
|
] = 0
|
||||||
|
data.loc[ # noqa:ECE001
|
||||||
|
# all `Pixel`s, all `Order`s in time steps starting at 12 am
|
||||||
|
(slice(None), slice(data.index.levels[1][1], None, 12)),
|
||||||
|
'n_orders',
|
||||||
|
] = 0
|
||||||
|
data.loc[ # noqa:ECE001
|
||||||
|
# all `Pixel`s, all `Order`s in time steps starting at 1 pm
|
||||||
|
(slice(None), slice(data.index.levels[1][2], None, 12)),
|
||||||
|
'n_orders',
|
||||||
|
] = 0
|
||||||
|
|
||||||
|
result = order_history.choose_tactical_model(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: this should be the future `HorizontalSMAModel`.
|
||||||
|
assert isinstance(result, models.HorizontalETSModel)
|
||||||
|
|
||||||
|
def test_best_model_with_no_demand(
|
||||||
|
self, order_history, good_pixel_id, predict_at,
|
||||||
|
):
|
||||||
|
"""Without demand, the average daily demand is `< 2.5`."""
|
||||||
|
order_history._data.loc[:, 'n_orders'] = 0
|
||||||
|
|
||||||
|
result = order_history.choose_tactical_model(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.LONG_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO: this should be the future `HorizontalTrivialModel`.
|
||||||
|
assert isinstance(result, models.HorizontalETSModel)
|
||||||
|
|
||||||
|
def test_best_model_for_unknown_train_horizon(
|
||||||
|
self, order_history, good_pixel_id, predict_at, # noqa:RST215
|
||||||
|
):
|
||||||
|
"""For `train_horizon`s not included in the rule-based system ...
|
||||||
|
|
||||||
|
... the method raises a `RuntimeError`.
|
||||||
|
"""
|
||||||
|
with pytest.raises(RuntimeError, match='no rule'):
|
||||||
|
order_history.choose_tactical_model(
|
||||||
|
pixel_id=good_pixel_id,
|
||||||
|
predict_day=predict_at.date(),
|
||||||
|
train_horizon=test_config.SHORT_TRAIN_HORIZON,
|
||||||
|
)
|
||||||
|
|
|
@ -36,7 +36,7 @@ def bad_predict_at():
|
||||||
... not a long enough history so that both `SHORT_TRAIN_HORIZON`
|
... not a long enough history so that both `SHORT_TRAIN_HORIZON`
|
||||||
and `LONG_TRAIN_HORIZON` do not work.
|
and `LONG_TRAIN_HORIZON` do not work.
|
||||||
"""
|
"""
|
||||||
predict_day = test_config.END - datetime.timedelta(weeks=2, days=1)
|
predict_day = test_config.END - datetime.timedelta(weeks=6, days=1)
|
||||||
return datetime.datetime(
|
return datetime.datetime(
|
||||||
predict_day.year, predict_day.month, predict_day.day, test_config.NOON, 0,
|
predict_day.year, predict_day.month, predict_day.day, test_config.NOON, 0,
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue