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:
Alexander Hess 2021-02-02 11:29:27 +01:00
parent 8926e9ff28
commit af82951485
Signed by: alexander
GPG key ID: 344EA5AB10D868E0
6 changed files with 199 additions and 35 deletions

View file

@ -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.

View file

@ -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

View file

@ -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',
)

View file

@ -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)

View file

@ -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,
)

View file

@ -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,
)