Skip to content

Assortment Example

A short example for assortment optimization under the conditional Logit and more.

# Importing the right base libraries
import os
# Remove GPU use
os.environ["CUDA_VISIBLE_DEVICES"] = ""

import sys
sys.path.append("../../")

import numpy as np

Introduction

Dataset

We will use the TaFeng Dataset that is available on Kaggle. You can load it automatically with Choice-Learn !

from choice_learn.datasets import load_tafeng
# Short illustration of the dataset
tafeng_df = load_tafeng(as_frame=True)
tafeng_df.head()
TRANSACTION_DT CUSTOMER_ID AGE_GROUP PIN_CODE PRODUCT_SUBCLASS PRODUCT_ID AMOUNT ASSET SALES_PRICE
0 11/1/2000 1104905 45-49 115 110411 4710199010372 2 24 30
1 11/1/2000 418683 45-49 115 120107 4710857472535 1 48 46
2 11/1/2000 1057331 35-39 115 100407 4710043654103 2 142 166
3 11/1/2000 1849332 45-49 Others 120108 4710126092129 1 32 38
4 11/1/2000 1981995 50-54 115 100205 4710176021445 1 14 18

Choice Model Specification

In this example we will use the sales_price and age_group features to estimate a discrete choice model in the form of a conditional MNL:

for a customer $z$ and a product $i$, we define the utility function:

with: - $u_i$ the base utility of product $i$ - $p_i$ the price of product $i$ - $e_{dem(z)}$ the price elasticity of customer $z$ depending of its age

We decide to estimate three coefficients of price elasticity for customers <=25 y.o, 26<=.<=55 y.o. and =>56 y.o.

# Let's reload the TaFeng dataset as a Choice Dataset
dataset = load_tafeng(as_frame=False, preprocessing="assort_example")

# The age categories are encoded as OneHot features:
print("Age Categories Encoding for choices 0, 4 and 16:")
print(dataset.shared_features_by_choice[0][[0, 4, 16]])
WARNING:root:Shared Features Names were not provided, will not be able to
                                    fit models needing them such as Conditional Logit.
WARNING:root:Items Features Names were not provided, will not be able to
                                fit models needing them such as Conditional Logit.


Age Categories Encoding for choices 0, 4 and 16:
[[0. 1. 0.]
 [1. 0. 0.]
 [0. 0. 1.]]

Let's define a custom model that would fit our formulation using Choice-Learn's ChoiceModel inheritance:

import tensorflow as tf
from choice_learn.models.base_model import ChoiceModel


class TaFengMNL(ChoiceModel):
    """Custom model for the TaFeng dataset."""

    def __init__(self, **kwargs):
        """Instantiation of our custom model."""
        # Standard inheritance stuff
        super().__init__(**kwargs)

        # Instantiation of base utilties weights
        # We have 25 items in the dataset making 25 weights
        self.base_utilities = tf.Variable(
                            tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 25))
                        )
        # Instantiation of price elasticities weights
        # We have 3 age categories making 3 weights
        self.price_elasticities = tf.Variable(
                            tf.random_normal_initializer(0.0, 0.02, seed=42)(shape=(1, 3))
                        )
        # Don't forget to add the weights to be optimized in self.weights !
        self.trainable_weights = [self.base_utilities, self.price_elasticities]

    def compute_batch_utility(self,
                              shared_features_by_choice,
                              items_features_by_choice,
                              available_items_by_choice,
                              choices):
        """Method that defines how the model computes the utility of a product.

        Parameters
        ----------
        shared_features_by_choice : tuple of np.ndarray (choices_features)
            a batch of shared features
            Shape must be (n_choices, n_shared_features)
        items_features_by_choice : tuple of np.ndarray (choices_items_features)
            a batch of items features
            Shape must be (n_choices, n_items_features)
        available_items_by_choice : np.ndarray
            A batch of items availabilities
            Shape must be (n_choices, n_items)
        choices_batch : np.ndarray
            Choices
            Shape must be (n_choices, )

        Returns:
        --------
        np.ndarray
            Utility of each product for each choice.
            Shape must be (n_choices, n_items)
        """
        # Unused arguments
        _ = (available_items_by_choice, choices)

        # Get the right price elasticity coefficient according to the age cateogry
        price_coeffs = tf.tensordot(shared_features_by_choice,
                                    tf.transpose(self.price_elasticities),
                                    axes=1)
        # Compute the utility: u_i + p_i * c
        return tf.multiply(items_features_by_choice[:, :, 0], price_coeffs) + self.base_utilities

Choice Model Estimation

We estimate the coefficients values using .fit:

model = TaFengMNL(optimizer="lbfgs", epochs=1000, tolerance=1e-4)
history = model.fit(dataset, verbose=1)
Using L-BFGS optimizer, setting up .fit() function


WARNING:root:L-BFGS Opimization finished:
WARNING:root:---------------------------------------------------------------
WARNING:root:Number of iterations: 225
WARNING:root:Algorithm converged before reaching max iterations: True

We can observe estimated coefficients with the .weights argument:

print("Model Negative Log-Likelihood: ", model.evaluate(dataset))
print("Model Weights:")
print("Base Utilities u_i:", model.trainable_weights[0].numpy())
print("Price Elasticities:", model.trainable_weights[1].numpy())
Model Negative Log-Likelihood:  tf.Tensor(2.7657256, shape=(), dtype=float32)
Model Weights:
Base Utilities u_i: [[ 0.5068263   2.935736    1.998015    0.5470789   0.72602475  1.0055478
  -0.7196758  -0.970541   -0.00946927 -3.042058    1.0770373   1.6368566
  -3.6405432  -1.2479168   3.0117846   1.6831478   1.8547137  -1.2627332
  -1.1671457  -0.08575154 -1.773998   -1.9642268  -1.7941352   1.5037025
  -0.7460297 ]]
Price Elasticities: [[-0.06286521 -0.05761966 -0.05427208]]

As a short analysis we can observe that the price elasticiy in negative as expected and the younger the population the more impacted by the price.\ Our models looks good enough for a first and fast modelization. Now let's see how to compute an optimal assortment using our model.

Assortment Optimization

Preparing the data

The first step is to compute the utility of each product. Here, let's consider that the last prices will also be the future prices of our products in our future assortment.\ It can be easily adapted if theses prices were to be changed.\ We can compute each age category utility using the compute_batch_utility method of our ChoiceModel:

future_prices = np.stack([dataset.items_features_by_choice[0][-1]]*3, axis=0)
age_category = np.array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]).astype("float32")
predicted_utilities = model.compute_batch_utility(shared_features_by_choice=age_category,
                                                  items_features_by_choice=future_prices,
                                                  available_items_by_choice=None,
                                                  choices=None
                                                  )

We compute the ratio of each age category appearance in our dataset to obtain an average utility for each product.

age_frequencies = np.mean(dataset.shared_features_by_choice[0], axis=0)

final_utilities = []
for freq, ut in zip(age_frequencies, predicted_utilities):
    final_utilities.append(freq*ut)
final_utilities = np.mean(final_utilities, axis=0)
print("Estimated final utilities for each product:", final_utilities)
Estimated final utilities for each product: [-0.24978125 -0.3917887  -0.7043624  -0.5408898  -0.4812412  -0.38806686
 -0.6586153  -0.93256587 -0.72640586 -1.5850058  -1.3158809  -0.17763059
 -1.6322378  -0.83469564 -0.49966928 -0.80931807 -1.0566555  -0.8396344
 -0.8077719  -0.69473463 -0.99102306 -1.0163671  -1.0167683  -1.3830209
 -0.4294889 ]

We need to define what quantity needs to be optimized by our assortment. A usual answer is to optimize the revenue or margin. In our case we do not have these values, so let's say that we want to obtain the assortment with 12 products that will generate the highest turnover.

Choice-Learn's AssortmentOptimizer

Choice-Learn integrates algorithms for assortment planning based on Gurobi or OR-Tools. You can choose which solver you want by specifying solver="gurobi" or solver="or-tools".\ Gurobi needs a license (free for Academics), however, it is usually faster than the open-source OR-Tools.\ Let's see an example.

solver = "gurobi"
# solver = "or-tools"
from choice_learn.toolbox.assortment_optimizer import MNLAssortmentOptimizer

opt = MNLAssortmentOptimizer(
    solver=solver,
    utilities=np.exp(final_utilities), # Utilities need to be transformed with exponential function
    itemwise_values=future_prices[0][:, 0], # Values to optimize for each item, here price that is used to compute turnover
    assortment_size=12) # Size of the assortment we want
assortment, opt_obj = opt.solve()
print("Our Optimal Assortment is:")
print(assortment)
print("With an estimated average revenue of:", opt_obj)
Our Optimal Assortment is:
[0. 1. 1. 1. 0. 0. 0. 1. 1. 1. 1. 0. 1. 0. 1. 1. 1. 0. 0. 0. 0. 0. 0. 1.
 0.]
With an estimated average revenue of: 51.57688285623652

Latent Class Assortment Optimizer

This simplistic version is not optimal since it uses an averaged utility of each product over the population resulting in an approximative probability.\ Choice-Learn also proposes an implementation of the Mixed-Integer Programming approach described in [1]. This version works for latent class models and fits well in our case with different populations.\ The usage is similar with the object LatentClassAssortmentOptimizer.

from choice_learn.toolbox.assortment_optimizer import LatentClassAssortmentOptimizer

opt = LatentClassAssortmentOptimizer(
    solver=solver,
    class_weights=age_frequencies, # Weights of each class
    class_utilities=np.exp(predicted_utilities), # utilities in the shape (n_classes, n_items)
    itemwise_values=future_prices[0][:, 0], # Values to optimize for each item, here price that is used to compute turnover
    assortment_size=12) # Size of the assortment we want
assortment, opt_obj = opt.solve()
print("Our Optimal Assortment is:")
print(assortment)
print("With an estimated average revenue of:", opt_obj)
print("Totalling", np.sum(assortment), "items in the assortment, which is fine with our limit of 12.")
Our Optimal Assortment is:
[0. 1. 1. 1. 1. 1. 0. 0. 1. 0. 1. 1. 0. 0. 1. 1. 1. 0. 0. 0. 0. 0. 0. 1.
 0.]
With an estimated average revenue of: 35.79552031037327
Totalling 12.0 items in the assortment, which is fine with our limit of 12.

With this version, our results are slightly more precise - however we used integer in the LP formulation that can lead to slower results with large number of items.

Adding capacity constraints

It is possible to add some constraints. A recurrent case is that the assortment of product will be placed in store and we need to take into account the available space.

For the example we will imaginary values for each item size and a maximum total size of the assortment of 35.

np.random.seed(123)
sizes = np.random.randint(1, 10, size=len(assortment))
print("The random items sizes are:", sizes)
print("Capacity of previous optimal assortment:", np.sum(sizes * assortment))
print("Higher than our limit of 35!")
The random items sizes are: [3 3 7 2 4 7 2 1 2 1 1 4 5 1 1 5 2 8 4 3 5 8 3 5 9]
Capacity of previous optimal assortment: 43.0
Higher than our limit of 35!
opt = LatentClassAssortmentOptimizer(
    solver=solver,
    class_weights=age_frequencies, # Weights of each class
    class_utilities=np.exp(predicted_utilities), # utilities in the shape (n_classes, n_items)
    itemwise_values=future_prices[0][:, 0], # Values to optimize for each item, here price that is used to compute turnover
    assortment_size=12) # Size of the assortment we want

opt.add_maximal_capacity_constraint(itemwise_capacities=sizes, maximum_capacity=35)

assortment, opt_obj = opt.solve()
print("Our Optimal Assortment is:")
print(assortment)
print("With an estimated average revenue of:", opt_obj)
print("Size of our assortment:", np.sum((assortment > 0)), "which is fine with our limit of 12!")
print("Capacity of our new assortment:", np.sum(sizes * assortment), "which is below our limit of 35!")
Our Optimal Assortment is:
[0. 1. 1. 1. 1. 0. 0. 0. 0. 0. 1. 1. 0. 0. 1. 1. 1. 0. 0. 0. 0. 0. 0. 1.
 0.]
With an estimated average revenue of: 35.46667393198682
Size of our assortment: 10 which is fine with our limit of 12!
Capacity of our new assortment: 34.0 which is below our limit of 35!

The opposite constraint exists with .add_minimal_capacity_constraint() that adds a minimal value to be exceeded.

Pricing and Assortment Optimization

Since our model takes the price into consideration, it is possible to optimize both the assortment and the price of the products of the assortment at the same time !

The optimization is slightly more complex. The idea is to define a discretization of the prices with the correspondign utilities and itemwise values.

Let's take as an example a product $i$ whose utility function is $U(i) = u_i - p_i$ with $u_i$=1.5 and $p_i$ its price.\ We decide that the price range we accept to sell $i$ at is [2.5, 3.5] and to discretize into 6 values.\ If we have the cost $c_i=2.0$ we can use the margin $m_i = p_i -c_i$ as itemwise value otherwise we can take the revenue, $r_i=p_i$.

Price Utility Margin Revenue
2.5 -1.0 0.5 2.5 
2.7 -1.2 0.7 2.7 
2.9 -1.4 0.9 2.9 
3.1 -1.6 1.1 3.1 
3.3 -1.8 1.3 3.3 
3.5 -2.0 1.5 3.5 

The idea in the optimization is either not to choose the item because another item is more valuable or to choose at most one price that is optimal toward our objective.

Coming back to our example:

# Lets create a discretized grid of prices for each item
prices_grid = []
for item_index in range(25):
    min_price = 0.9 * np.min(dataset.items_features_by_choice[0][:, item_index])
    max_price = 1.1 * np.max(dataset.items_features_by_choice[0][:, item_index])
    prices_grid.append(np.linspace(min_price, max_price, 10))
prices_grid = np.stack(prices_grid, axis=0)

# Computing the corresponding utilities
items_utilities = []
for age_index in [0, 1, 2]:
    age_category = np.zeros((len(prices_grid[0]), 3)).astype("float32")
    age_category[:, age_index] = 1.
    predicted_utilities = model.compute_batch_utility(shared_features_by_choice=age_category,
                                                  items_features_by_choice=np.expand_dims(np.transpose(prices_grid), axis=-1),
                                                  available_items_by_choice=None,
                                                  choices=None
                                                  )
    items_utilities.append(np.exp(predicted_utilities).T)
item_utilities = np.stack(items_utilities, axis=0)

print(prices_grid.shape, item_utilities.shape)
(25, 10) (3, 25, 10)

We use another AssortmentOptimizer class:

from choice_learn.toolbox.assortment_optimizer import LatentClassPricingOptimizer
opt = LatentClassPricingOptimizer(
    solver=solver,
    class_weights=age_frequencies, # Weights of each class
    class_utilities=item_utilities, # utilities in the shape (n_classes, n_items)
    itemwise_values=prices_grid, # Values to optimize for each item, here price that is used to compute turnover
    assortment_size=12) # Size of the assortment we want

# opt.add_maximal_capacity_constraint(itemwise_capacities=sizes, maximum_capacity=35)

assortment, opt_obj = opt.solve()
print("Our Optimal Assortment is:")
print(assortment)
print("With an estimated average revenue of:", opt_obj)
print("Size of our assortment:", np.sum((assortment > 0)), "which is fine with our limit of 12!")
Our Optimal Assortment is:
[ 0.         59.7        59.7        41.8        41.8        41.8
  0.          0.          0.          0.         60.01111111 41.8
  0.          0.         59.62222222 56.06666667 57.6         0.
  0.         53.12222222  0.          0.          0.         56.7
  0.        ]
With an estimated average revenue of: 41.21468607531388
Size of our assortment: 12 which is fine with our limit of 12!

We can first observe that the estimated average revenue is higher than the previous one with the chosen prices.\ Let's look at the difference:

print("| Previous price","|", "Optimized price |")
print("------------------------------------")
for i in range(len(assortment)):
    if assortment[i] > 0:
        print("|     ", future_prices[0][i, 0], "     |     ", np.round(assortment[i], 1), "      |")
| Previous price | Optimized price |
------------------------------------
|      72.0      |      59.7       |
|      72.0      |      59.7       |
|      38.0      |      41.8       |
|      38.0      |      41.8       |
|      38.0      |      41.8       |
|      88.0      |      60.0       |
|      38.0      |      41.8       |
|      79.0      |      59.6       |
|      72.0      |      56.1       |
|      88.0      |      57.6       |
|      35.0      |      53.1       |
|      99.0      |      56.7       |

As previously, we can add capacity constraints:

opt = LatentClassPricingOptimizer(
    solver=solver,
    class_weights=age_frequencies, # Weights of each class
    class_utilities=item_utilities, # utilities in the shape (n_classes, n_items)
    itemwise_values=prices_grid, # Values to optimize for each item, here price that is used to compute turnover
    assortment_size=12) # Size of the assortment we want

opt.add_maximal_capacity_constraint(itemwise_capacities=sizes, maximum_capacity=35)

assortment, opt_obj = opt.solve()
print("Our Optimal Assortment is:")
print(assortment)
print("With an estimated average revenue of:", opt_obj)
print("Size of our assortment:", np.sum((assortment > 0)), "which is fine with our limit of 12!")
print("Capacity of our new assortment:", np.sum(sizes * (assortment > 0)), "which is below our limit of 35!")
Our Optimal Assortment is:
[ 0.         59.7        59.7        41.8         0.          0.
  0.          0.         41.8         0.         60.01111111 41.8
  0.          0.         59.62222222 56.06666667 57.6         0.
  0.         53.12222222  0.          0.          0.         56.7
  0.        ]
With an estimated average revenue of: 41.164100003155916
Size of our assortment: 11 which is fine with our limit of 12!
Capacity of our new assortment: 35 which is below our limit of 35!

Ending Notes

  • In this example, the outside option is automatically integrated in the AssortmentOptimizer and not computed through the model. If you compute the outside option utility and give it to AssortmentOptimizer you can set its attribute outside_option_given to True.
  • The current AssortmentOptimzer uses Gurobi for which you need a license (free for Academics) or OR-Tools that is OpenSource.
  • If you want to add custom constraints you can use the base code of the AssortmentOptimizer and manually add your constraints. Future developments will add an easy interface to integrate such needs.

References

[1] Isabel Méndez-Díaz, Juan José Miranda-Bront, Gustavo Vulcano, Paula Zabala, A branch-and-cut algorithm for the latent-class logit assortment problem, Discrete Applied Mathematics, Volume 164, Part 1, 2014, Pages 246-263, ISSN 0166-218X, https://doi.org/10.1016/j.dam.2012.03.003.