Skip to content

Commit

Permalink
Out of stock forecasting (#25)
Browse files Browse the repository at this point in the history
Predict when a product will run out of stock, based on historic data.
  • Loading branch information
kjagiello authored Feb 25, 2017
1 parent 6a5c464 commit df2168d
Show file tree
Hide file tree
Showing 9 changed files with 220 additions and 3 deletions.
3 changes: 3 additions & 0 deletions requirements/base.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,6 @@ requests==2.8.1
pytz==2015.7
raven==5.8.1
pynarlivs==0.9.0
numpy==1.12.0
scipy==0.18.1
scikit-learn==0.18.1
18 changes: 16 additions & 2 deletions src/shop/admin.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import tempfile
from datetime import date
from django import forms
from django.shortcuts import get_object_or_404
from django.contrib import admin, messages
Expand Down Expand Up @@ -421,10 +422,12 @@ def has_delete_permission(self, request, obj=None):

@admin.register(models.Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ('name', 'code', 'qty', 'price', 'active',)
list_display = ('name', 'code', 'qty', 'price', 'active',
'_out_of_stock_forecast',)
list_filter = ('active', 'category',)
search_fields = ('code', 'name',)
readonly_fields = ('qty', 'date_created', 'date_modified',)
readonly_fields = ('qty', 'date_created', 'date_modified',
'_out_of_stock_forecast',)
ordering = ('name',)
inlines = (ProductTransactionCreatorInline,)
fieldsets = (
Expand All @@ -444,10 +447,21 @@ class ProductAdmin(admin.ModelAdmin):
'qty',
'date_created',
'date_modified',
'_out_of_stock_forecast'
)
}),
)

def _out_of_stock_forecast(self, obj=None):
if obj is None or obj.out_of_stock_forecast is None:
return None
fmt = '<span style="color: {};">{}</span>'
forecast = obj.out_of_stock_forecast
color = 'red' if forecast <= date.today() else 'default'
return fmt.format(color, forecast)
_out_of_stock_forecast.allow_tags = True
_out_of_stock_forecast.admin_order_field = 'out_of_stock_forecast'

class Media:
css = {
'all': (
Expand Down
92 changes: 92 additions & 0 deletions src/shop/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import logging
import numpy as np
from itertools import accumulate
from datetime import date
from django.db import transaction
from django.db.models import Sum
from django.db.models.functions import TruncDay
from django.contrib.contenttypes.models import ContentType
from sklearn.svm import SVR
from . import models, enums, suppliers, exceptions

log = logging.getLogger(__name__)
Expand Down Expand Up @@ -239,3 +245,89 @@ def assign_free_stocktake_chunk(user_id, stocktake_id):
chunk_obj.owner_id = user_id
chunk_obj.save()
return chunk_obj


@transaction.atomic
def predict_quantity(product_id, target):
"""Predicts when a product will reach the target quantity."""
product_obj = models.Product.objects.get(id=product_id)
if product_obj.qty <= target:
# No prediction if already at the target quantity.
return None

# Find the last restock transaction
qs = product_obj.transactions.finalized()
restock_trx = qs.restocks().order_by('-date_created').first()
if restock_trx is None:
# The product has never been restocked.
return None

initial_qty = qs \
.filter(date_created__lte=restock_trx.date_created) \
.aggregate(qty=Sum('qty'))['qty'] or 0
trx_objs = qs \
.filter(date_created__gt=restock_trx.date_created) \
.annotate(date=TruncDay('date_created')) \
.values('date') \
.annotate(aggregated_qty=Sum('qty')) \
.values('date', 'aggregated_qty')
if not trx_objs:
# No data points to base the prediction on.
return None

date_offset = trx_objs[0]['date'].toordinal()

# At this point we want to generate data-points that we will feed into a
# Epsilon-Support Vector Regression model. Initially, the data-points
# look like following:
#
# +---+----+-----+----+----+
# | x | 0 | 1 | 2 | 4 |
# +---+----+-----+----+----+
# | y | -5 | -10 | -2 | -3 |
# +---+----+-----+----+----+
#
# We want however to include the initial quantity and we do that by adding
# it at x = -1:
#
# +---+-----+----+-----+----+----+
# | x | -1 | 0 | 1 | 2 | 4 |
# +---+-----+----+-----+----+----+
# | y | 100 | -5 | -10 | -2 | -3 |
# +---+-----+----+-----+----+----+
#
# In the final step, we want to convert all the values at x >= 0 into
# actuall quantity levels, not just differences, so the data looks like
# this:
#
# +---+-----+----+----+----+----+
# | x | -1 | 0 | 1 | 2 | 4 |
# +---+-----+----+----+----+----+
# | y | 100 | 95 | 85 | 83 | 80 |
# +---+-----+----+----+----+----+
#
# The cryptic code below does just that.
x = [trx_obj['date'].toordinal() - date_offset for trx_obj in trx_objs]
x = [-1] + x
x = np.asarray(x).reshape(-1, 1)

y = [trx_obj['aggregated_qty'] for trx_obj in trx_objs]
y = [initial_qty] + y
y = np.asarray(list(accumulate(y)))

# Fit the SVR model using above data. We rely here on the linear kernel as
# our experiments showed that that gave the best results.
svr = SVR(kernel='linear', C=1e2)
svr.fit(x, y)
if svr.coef_ >= 0:
# The function is non-decreasing, so no prediction can be made.
return None
days = (-initial_qty / svr.coef_).astype(int).item()
return date.fromordinal(date_offset + days)


@transaction.atomic
def update_out_of_stock_forecast(product_id):
product_obj = models.Product.objects.get(id=product_id)
product_obj.out_of_stock_forecast = predict_quantity(product_id, target=0)
product_obj.save()
Empty file.
9 changes: 9 additions & 0 deletions src/shop/management/commands/predict_out_of_stock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from django.core.management.base import BaseCommand
import shop.api


class Command(BaseCommand):
def handle(self, *args, **options):
products = shop.api.list_products()
for product in products:
shop.api.update_out_of_stock_forecast(product.id)
21 changes: 21 additions & 0 deletions src/shop/migrations/0017_auto_20170222_2204.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-22 22:04
from __future__ import unicode_literals

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):

dependencies = [
('shop', '0016_auto_20170214_0749'),
]

operations = [
migrations.AddField(
model_name='product',
name='out_of_stock_forecast',
field=models.DateField(blank=True, null=True),
)
]
1 change: 1 addition & 0 deletions src/shop/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ class Product(UUIDModel, TimeStampedModel):
currency_choices=settings.CURRENCY_CHOICES
)
active = models.BooleanField(default=True)
out_of_stock_forecast = models.DateField(blank=True, null=True)

# cached quantity
qty = models.IntegerField(verbose_name=_('quantity'), default=0)
Expand Down
6 changes: 6 additions & 0 deletions src/shop/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,11 @@ class ProductTrxQuerySet(models.QuerySet):
def finalized(self):
return self.filter(trx_status=enums.TrxStatus.FINALIZED)

def restocks(self):
return self.filter(trx_type__in=[
enums.TrxType.INVENTORY,
enums.TrxType.CORRECTION
])

def quantity(self):
return self.finalized().aggregate(qty=models.Sum('qty'))['qty'] or 0
73 changes: 72 additions & 1 deletion src/shop/tests/test_api.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from unittest import mock
from datetime import datetime, date
from decimal import Decimal
from unittest import mock

from moneyed import Money

from django.test import TestCase
from django.contrib.auth.models import User
from django.utils import timezone

from ..suppliers.base import SupplierBase, DeliveryItem, SupplierProduct
from .. import api, models, enums, exceptions
Expand Down Expand Up @@ -320,3 +322,72 @@ def test_assign_free_stocktake_chunk(self):
# Work is finished
obj5 = api.assign_free_stocktake_chunk(user_obj1.id, stocktake_obj.id)
self.assertIsNone(obj5)

def test_predict_quantity(self):
initial_qty = 100
initial_timestamp = datetime(2016, 11, 14, 0)
trx_data = [
(-5, datetime(2016, 11, 15, 0)),
(-10, datetime(2016, 11, 16, 0)),
(-5, datetime(2016, 11, 18, 0)),
(-5, datetime(2016, 11, 18, 1)),
]
product = factories.ProductFactory.create()
# No product transactions yet
timestamp = api.predict_quantity(product.id, product.qty - 1)
self.assertIsNone(timestamp)
factories.ProductTrxFactory.create(
product=product,
qty=initial_qty,
trx_type=enums.TrxType.INVENTORY,
date_created=timezone.make_aware(initial_timestamp)
)
# Product restocked, but no purchases made yet
timestamp = api.predict_quantity(product.id, 0)
self.assertIsNone(timestamp)
for qty, timestamp in trx_data:
factories.ProductTrxFactory.create(
product=product,
qty=qty,
date_created=timezone.make_aware(timestamp),
trx_type=enums.TrxType.PURCHASE
)
timestamp = api.predict_quantity(product.id,
target=product.qty)
self.assertIsNone(timestamp)
timestamp = api.predict_quantity(product.id, 0)
self.assertEqual(timestamp, date(2016, 11, 30))

def test_predict_quantity_non_decreasing_function(self):
initial_qty = 100
initial_timestamp = datetime(2016, 11, 14, 0)
trx_data = [
(-5, datetime(2016, 11, 15, 0)),
(+5, datetime(2016, 11, 15, 0)),
]
product = factories.ProductFactory.create()
factories.ProductTrxFactory.create(
product=product,
qty=initial_qty,
trx_type=enums.TrxType.INVENTORY,
date_created=timezone.make_aware(initial_timestamp)
)
for qty, timestamp in trx_data:
factories.ProductTrxFactory.create(
product=product,
qty=qty,
date_created=timezone.make_aware(timestamp),
trx_type=enums.TrxType.PURCHASE
)
# Product restocked, but no purchases made yet
timestamp = api.predict_quantity(product.id, 0)
self.assertIsNone(timestamp)

@mock.patch('shop.api.predict_quantity')
def test_update_quantity_prediction(self, predict_quantity_mock):
product = factories.ProductFactory.create()
predict_quantity_mock.return_value = date(1337, 1, 1)
api.update_out_of_stock_forecast(product.id)
predict_quantity_mock.assert_called_once_with(product.id, target=0)
product.refresh_from_db()
self.assertEqual(product.out_of_stock_forecast, date(1337, 1, 1))

0 comments on commit df2168d

Please sign in to comment.