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.
|
||||
TIME_STEPS = [60]
|
||||
|
||||
# Training horizons (in full weeks) used
|
||||
# to train the forecasting models.
|
||||
# Training horizons (in full weeks) used 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]
|
||||
|
||||
# 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.
|
||||
""" # 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.realtime import RealtimeARIMAModel
|
||||
from urban_meal_delivery.forecasts.models.tactical.vertical import VerticalARIMAModel
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
"""Obtain and work with time series data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
from typing import Tuple
|
||||
|
||||
|
@ -7,6 +9,7 @@ import pandas as pd
|
|||
|
||||
from urban_meal_delivery import config
|
||||
from urban_meal_delivery import db
|
||||
from urban_meal_delivery.forecasts import models
|
||||
|
||||
|
||||
class OrderHistory:
|
||||
|
@ -502,3 +505,53 @@ class OrderHistory:
|
|||
n_days = (last_day - first_day).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."""
|
||||
|
||||
import datetime
|
||||
import datetime as dt
|
||||
|
||||
from urban_meal_delivery import config
|
||||
|
||||
|
@ -11,10 +11,11 @@ YEAR, MONTH, DAY = 2016, 7, 1
|
|||
# The hour when most test cases take place.
|
||||
NOON = 12
|
||||
|
||||
# `START` and `END` constitute a 22-day time span.
|
||||
# That implies a maximum `train_horizon` of `3` as that needs full 7-day weeks.
|
||||
START = datetime.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 0)
|
||||
END = datetime.datetime(YEAR, MONTH, DAY + 21, config.SERVICE_END, 0)
|
||||
# `START` and `END` constitute a 57-day time span, 8 full weeks plus 1 day.
|
||||
# That implies a maximum `train_horizon` of `8` as that needs full 7-day weeks.
|
||||
START = dt.datetime(YEAR, MONTH, DAY, config.SERVICE_START, 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.
|
||||
LONG_TIME_STEP = 60
|
||||
|
@ -28,6 +29,6 @@ VERTICAL_FREQUENCY_SHORT = 7 * 24
|
|||
|
||||
# Default training horizons, for example, for
|
||||
# `OrderHistory.make_horizontal_time_series()`.
|
||||
LONG_TRAIN_HORIZON = 3
|
||||
LONG_TRAIN_HORIZON = 8
|
||||
SHORT_TRAIN_HORIZON = 2
|
||||
TRAIN_HORIZONS = (SHORT_TRAIN_HORIZON, LONG_TRAIN_HORIZON)
|
||||
|
|
|
@ -1,10 +1,22 @@
|
|||
"""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 urban_meal_delivery.forecasts import models
|
||||
|
||||
|
||||
class TestAverageDailyDemand:
|
||||
"""Tests for the `OrderHistory.avg_daily_demand()` method."""
|
||||
|
||||
def test_avg_daily_demand_with_constant_demand(
|
||||
order_history, good_pixel_id, predict_at,
|
||||
self, order_history, good_pixel_id, predict_at,
|
||||
):
|
||||
"""The average daily demand must be the number of time steps ...
|
||||
|
||||
|
@ -21,9 +33,8 @@ def test_avg_daily_demand_with_constant_demand(
|
|||
|
||||
assert result == 12.0
|
||||
|
||||
|
||||
def test_avg_daily_demand_with_no_demand(
|
||||
order_history, good_pixel_id, predict_at,
|
||||
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
|
||||
|
@ -35,3 +46,100 @@ def test_avg_daily_demand_with_no_demand(
|
|||
)
|
||||
|
||||
assert result == 0.0
|
||||
|
||||
|
||||
class TestChooseTacticalModel:
|
||||
"""Tests for the `OrderHistory.choose_tactical_model()` method."""
|
||||
|
||||
def test_best_model_with_high_demand(
|
||||
self, order_history, good_pixel_id, predict_at,
|
||||
):
|
||||
"""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
|
||||
|
||||
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`
|
||||
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(
|
||||
predict_day.year, predict_day.month, predict_day.day, test_config.NOON, 0,
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue