openretailscience 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Customer Decision Hierarchy Analysis for Product Substitutability and Range Optimization.
|
|
2
|
+
|
|
3
|
+
## Business Context
|
|
4
|
+
|
|
5
|
+
Customer Decision Hierarchy (CDH) analysis reveals how customers perceive products
|
|
6
|
+
as substitutes or complements. This critical intelligence informs range planning,
|
|
7
|
+
assortment optimization, and delisting decisions by understanding which products
|
|
8
|
+
customers view as interchangeable versus essential variety.
|
|
9
|
+
|
|
10
|
+
## The Business Problem
|
|
11
|
+
|
|
12
|
+
Retailers often struggle with range rationalization decisions:
|
|
13
|
+
- Which products can be delisted without losing customers?
|
|
14
|
+
- When does variety add value versus create confusion?
|
|
15
|
+
- Which products are true substitutes in customers' minds?
|
|
16
|
+
- How to optimize shelf space without sacrificing choice?
|
|
17
|
+
|
|
18
|
+
CDH analysis answers these questions by analyzing actual switching behavior rather
|
|
19
|
+
than relying on product attributes or manager intuition.
|
|
20
|
+
|
|
21
|
+
## How It Works
|
|
22
|
+
|
|
23
|
+
The analysis examines customer purchase patterns to identify substitutability:
|
|
24
|
+
- Products rarely bought by the same customer → likely substitutes
|
|
25
|
+
- Products often bought by the same customer → complements or variety-seeking
|
|
26
|
+
- Uses Yule's Q coefficient to measure substitutability strength
|
|
27
|
+
- Creates hierarchical clusters showing substitution relationships
|
|
28
|
+
|
|
29
|
+
## Real-World Applications
|
|
30
|
+
|
|
31
|
+
1. **Range Rationalization**
|
|
32
|
+
- Identify safe delisting candidates within substitute clusters
|
|
33
|
+
- Maintain one option per cluster to preserve choice
|
|
34
|
+
- Reduce SKU count while maintaining customer satisfaction
|
|
35
|
+
|
|
36
|
+
2. **New Product Introduction**
|
|
37
|
+
- Understand which existing products new items might cannibalize
|
|
38
|
+
- Position new products to fill gaps rather than duplicate
|
|
39
|
+
- Predict source of volume for new launches
|
|
40
|
+
|
|
41
|
+
3. **Private Label Strategy**
|
|
42
|
+
- Identify national brand products suitable for PL alternatives
|
|
43
|
+
- Understand where PL can substitute vs. complement
|
|
44
|
+
- Optimize PL/NB mix within categories
|
|
45
|
+
|
|
46
|
+
4. **Space Optimization**
|
|
47
|
+
- Allocate more space to non-substitutable products
|
|
48
|
+
- Reduce facings for products within same substitute cluster
|
|
49
|
+
- Optimize variety/productivity trade-off
|
|
50
|
+
|
|
51
|
+
5. **Markdown Strategy**
|
|
52
|
+
- Clear substitute products sequentially, not simultaneously
|
|
53
|
+
- Understand which products can drive category traffic
|
|
54
|
+
- Identify products that won't cannibalize when promoted
|
|
55
|
+
|
|
56
|
+
## Business Value
|
|
57
|
+
|
|
58
|
+
- **Efficient Assortment**: Reduce complexity without losing sales
|
|
59
|
+
- **Better Space Productivity**: Allocate space based on true variety value
|
|
60
|
+
- **Improved Margins**: Replace duplicative SKUs with unique offerings
|
|
61
|
+
- **Customer Satisfaction**: Maintain perceived choice while reducing confusion
|
|
62
|
+
- **Strategic Clarity**: Data-driven approach to range decisions
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
from typing import Literal
|
|
66
|
+
|
|
67
|
+
import matplotlib.pyplot as plt
|
|
68
|
+
import numpy as np
|
|
69
|
+
import pandas as pd
|
|
70
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
71
|
+
from scipy.cluster.hierarchy import dendrogram, linkage
|
|
72
|
+
from scipy.sparse import csr_matrix
|
|
73
|
+
|
|
74
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
75
|
+
from openretailscience.options import ColumnHelper, get_option
|
|
76
|
+
from openretailscience.plots.styles.styling_helpers import PlotStyler
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class CustomerDecisionHierarchy:
|
|
80
|
+
"""Analyzes product substitutability patterns to optimize retail assortments.
|
|
81
|
+
|
|
82
|
+
The CustomerDecisionHierarchy class identifies which products customers view as
|
|
83
|
+
substitutes versus essential variety. This enables data-driven range planning
|
|
84
|
+
decisions that maintain customer choice while improving operational efficiency.
|
|
85
|
+
|
|
86
|
+
## Business Insight
|
|
87
|
+
|
|
88
|
+
Traditional range planning often assumes products in the same category are
|
|
89
|
+
substitutes (e.g., all yogurts are interchangeable). However, customer behavior
|
|
90
|
+
reveals the truth: some customers always buy both Greek and regular yogurt
|
|
91
|
+
(complements), while others switch between strawberry and raspberry flavors
|
|
92
|
+
(substitutes).
|
|
93
|
+
|
|
94
|
+
## Substitutability Logic
|
|
95
|
+
|
|
96
|
+
The analysis identifies substitutes through purchase patterns:
|
|
97
|
+
- **High substitutability**: Customers buy product A OR product B, rarely both
|
|
98
|
+
- **Low substitutability**: Customers often buy both A AND B
|
|
99
|
+
- **Exclusion logic**: Products bought in same transaction can't be substitutes
|
|
100
|
+
|
|
101
|
+
## Decision Framework
|
|
102
|
+
|
|
103
|
+
The hierarchy output guides range decisions:
|
|
104
|
+
- **Tight clusters**: Strong substitutes - keep best performer
|
|
105
|
+
- **Loose clusters**: Weak substitutes - maintain variety
|
|
106
|
+
- **Separate branches**: Different needs - preserve both
|
|
107
|
+
- **Isolated products**: Unique value - protect from delisting
|
|
108
|
+
|
|
109
|
+
## Example Use Case
|
|
110
|
+
|
|
111
|
+
A supermarket analyzing yogurt finds:
|
|
112
|
+
- Cluster 1: Store brand vanilla, strawberry, raspberry (substitutes)
|
|
113
|
+
- Cluster 2: Greek plain, Greek honey (substitutes)
|
|
114
|
+
- Separate branch: Kids' squeezable yogurt (unique need)
|
|
115
|
+
|
|
116
|
+
Decision: Can reduce flavor variety in Cluster 1, maintain Greek options,
|
|
117
|
+
must keep kids' yogurt despite low sales.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
df: pd.DataFrame,
|
|
123
|
+
product_col: str,
|
|
124
|
+
exclude_same_transaction_products: bool = True,
|
|
125
|
+
method: Literal["yules_q"] = "yules_q",
|
|
126
|
+
random_state: int = 42,
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Initialize customer decision hierarchy analysis for range optimization.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
df (pd.DataFrame): Transaction data with customer purchase history.
|
|
132
|
+
Must contain: customer_id, transaction_id, and product identifier.
|
|
133
|
+
product_col (str): Column containing products to analyze for substitutability
|
|
134
|
+
(e.g., "product_name", "sku", "brand", "subcategory").
|
|
135
|
+
exclude_same_transaction_products (bool, optional): Whether products bought
|
|
136
|
+
together in one transaction should be considered non-substitutes.
|
|
137
|
+
True = If customer buys milk and eggs together, they're not substitutes.
|
|
138
|
+
False = Include all purchase patterns.
|
|
139
|
+
Defaults to True (recommended for most retail contexts).
|
|
140
|
+
method (Literal["yules_q"], optional): Statistical method for measuring
|
|
141
|
+
substitutability. "yules_q" measures association strength between
|
|
142
|
+
binary purchase patterns. Defaults to "yules_q".
|
|
143
|
+
random_state (int, optional): Seed for reproducible clustering results.
|
|
144
|
+
Important for consistent range planning decisions. Defaults to 42.
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
ValueError: If required columns are missing from the dataframe.
|
|
148
|
+
|
|
149
|
+
Business Example:
|
|
150
|
+
>>> # Analyze substitutability in coffee category
|
|
151
|
+
>>> cdh = CustomerDecisionHierarchy(
|
|
152
|
+
... df=transactions,
|
|
153
|
+
... product_col="brand_flavor", # e.g., "Folgers_Original"
|
|
154
|
+
... exclude_same_transaction_products=True # Bought together = not substitutes
|
|
155
|
+
... )
|
|
156
|
+
>>> # Use results to identify which coffee SKUs can be delisted
|
|
157
|
+
"""
|
|
158
|
+
cols = ColumnHelper()
|
|
159
|
+
required_cols = [cols.customer_id, cols.transaction_id, product_col]
|
|
160
|
+
missing_cols = set(required_cols) - set(df.columns)
|
|
161
|
+
if len(missing_cols) > 0:
|
|
162
|
+
msg = f"The following columns are required but missing: {missing_cols}"
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
|
|
165
|
+
self.random_state = random_state
|
|
166
|
+
self.product_col = product_col
|
|
167
|
+
self.pairs_df = self._get_pairs(df, exclude_same_transaction_products, product_col)
|
|
168
|
+
self.distances = self._calculate_distances(method=method)
|
|
169
|
+
|
|
170
|
+
@staticmethod
|
|
171
|
+
def _get_pairs(df: pd.DataFrame, exclude_same_transaction_products: bool, product_col: str) -> pd.DataFrame:
|
|
172
|
+
cols = ColumnHelper()
|
|
173
|
+
if exclude_same_transaction_products:
|
|
174
|
+
pairs_df = df[[cols.customer_id, cols.transaction_id, product_col]].drop_duplicates()
|
|
175
|
+
pairs_to_exclude_df = (
|
|
176
|
+
pairs_df.groupby(cols.transaction_id)
|
|
177
|
+
.filter(lambda x: len(x) > 1)[[cols.customer_id, product_col]]
|
|
178
|
+
.drop_duplicates()
|
|
179
|
+
)
|
|
180
|
+
# Drop all rows from pairs_df where customer_id and product_name are in pairs_to_exclude_df
|
|
181
|
+
pairs_df = pairs_df.merge(
|
|
182
|
+
pairs_to_exclude_df,
|
|
183
|
+
on=[cols.customer_id, product_col],
|
|
184
|
+
how="left",
|
|
185
|
+
indicator=True,
|
|
186
|
+
)
|
|
187
|
+
pairs_df = pairs_df[pairs_df["_merge"] == "left_only"][[cols.customer_id, product_col]].drop_duplicates()
|
|
188
|
+
else:
|
|
189
|
+
pairs_df = df[[cols.customer_id, product_col]].drop_duplicates()
|
|
190
|
+
|
|
191
|
+
return pairs_df.reset_index(drop=True).astype("category")
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _calculate_yules_q(bought_product_1: np.array, bought_product_2: np.array) -> float:
|
|
195
|
+
"""Calculates the Yule's Q coefficient between two binary arrays.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
bought_product_1 (np.array): Binary array representing the first bought product. Each element is 1 if the
|
|
199
|
+
customer bought the product and 0 if they didn't.
|
|
200
|
+
bought_product_2 (np.array): Binary array representing the second bought product. Each element is 1 if the
|
|
201
|
+
customer bought the product and 0 if they didn't.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
float: The Yule's Q coefficient.
|
|
205
|
+
|
|
206
|
+
Raises:
|
|
207
|
+
ValueError: If the lengths of `bought_product_1` and `bought_product_2` are not the same.
|
|
208
|
+
ValueError: If `bought_product_1` or `bought_product_2` is not a boolean array.
|
|
209
|
+
|
|
210
|
+
"""
|
|
211
|
+
if len(bought_product_1) != len(bought_product_2):
|
|
212
|
+
raise ValueError("The bought_product_1 and bought_product_2 must be the same length")
|
|
213
|
+
if len(bought_product_1) == 0:
|
|
214
|
+
return 0.0
|
|
215
|
+
if bought_product_1.dtype != bool or bought_product_2.dtype != bool:
|
|
216
|
+
raise ValueError("The bought_product_1 and bought_product_2 must be boolean arrays")
|
|
217
|
+
|
|
218
|
+
a = np.count_nonzero(bought_product_1 & bought_product_2)
|
|
219
|
+
b = np.count_nonzero(bought_product_1 & ~bought_product_2)
|
|
220
|
+
c = np.count_nonzero(~bought_product_1 & bought_product_2)
|
|
221
|
+
d = np.count_nonzero(~bought_product_1 & ~bought_product_2)
|
|
222
|
+
|
|
223
|
+
# Calculate Yule's Q coefficient
|
|
224
|
+
denominator = a * d + b * c
|
|
225
|
+
if denominator == 0:
|
|
226
|
+
# Both a*d and b*c are zero, making Q mathematically undefined (0/0).
|
|
227
|
+
# Return 0.0 (no association) because NaN would break scipy's linkage() downstream.
|
|
228
|
+
return 0.0
|
|
229
|
+
|
|
230
|
+
return (a * d - b * c) / denominator
|
|
231
|
+
|
|
232
|
+
def _get_yules_q_distances(self) -> float:
|
|
233
|
+
"""Calculate the Yules Q distances between pairs of products.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
float: The Yules Q distances between pairs of products.
|
|
237
|
+
"""
|
|
238
|
+
# Create a sparse matrix where the rows are the customers and the columns are the products
|
|
239
|
+
# The values are True if the customer bought the product and False if they didn't
|
|
240
|
+
product_matrix = csr_matrix(
|
|
241
|
+
(
|
|
242
|
+
[True] * len(self.pairs_df),
|
|
243
|
+
(
|
|
244
|
+
self.pairs_df[self.product_col].cat.codes,
|
|
245
|
+
self.pairs_df[get_option("column.customer_id")].cat.codes,
|
|
246
|
+
),
|
|
247
|
+
),
|
|
248
|
+
dtype=bool,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# Calculate the number of customers and products
|
|
252
|
+
n_products = product_matrix.shape[0]
|
|
253
|
+
|
|
254
|
+
# Create an empty matrix to store the yules q values
|
|
255
|
+
yules_q_matrix = np.zeros((n_products, n_products), dtype=float)
|
|
256
|
+
|
|
257
|
+
# Loop through each pair of products
|
|
258
|
+
for i in range(n_products):
|
|
259
|
+
arr_i = product_matrix[i].toarray()
|
|
260
|
+
for j in range(i + 1, n_products):
|
|
261
|
+
# Calculate the yules q value for the pair of products
|
|
262
|
+
arr_j = product_matrix[j].toarray()
|
|
263
|
+
yules_q_dist = 1 - self._calculate_yules_q(arr_i, arr_j)
|
|
264
|
+
|
|
265
|
+
# Store the yules q value in the matrix
|
|
266
|
+
yules_q_matrix[i, j] = yules_q_dist
|
|
267
|
+
yules_q_matrix[j, i] = yules_q_dist
|
|
268
|
+
|
|
269
|
+
# Normalize the yules q values to be between 0 and 1 and return
|
|
270
|
+
return (yules_q_matrix + 1) / 2
|
|
271
|
+
|
|
272
|
+
def _calculate_distances(
|
|
273
|
+
self,
|
|
274
|
+
method: Literal["yules_q"],
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Calculates distances between items using the specified method.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
method (Literal["yules_q"], optional): The method to use for calculating distances.
|
|
280
|
+
|
|
281
|
+
Raises:
|
|
282
|
+
ValueError: If the method is not valid.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
None
|
|
286
|
+
"""
|
|
287
|
+
# Check method is valid
|
|
288
|
+
if method == "yules_q":
|
|
289
|
+
distances = self._get_yules_q_distances()
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError("Method must be 'yules_q'")
|
|
292
|
+
|
|
293
|
+
return distances
|
|
294
|
+
|
|
295
|
+
def plot(
|
|
296
|
+
self,
|
|
297
|
+
title: str = "Customer Decision Hierarchy",
|
|
298
|
+
x_label: str | None = None,
|
|
299
|
+
y_label: str | None = None,
|
|
300
|
+
ax: Axes | None = None,
|
|
301
|
+
figsize: tuple[int, int] | None = None,
|
|
302
|
+
source_text: str | None = None,
|
|
303
|
+
**kwargs: dict[str, any],
|
|
304
|
+
) -> SubplotBase:
|
|
305
|
+
"""Plots the customer decision hierarchy dendrogram.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
title (str, optional): The title of the plot. Defaults to None.
|
|
309
|
+
x_label (str, optional): The label for the x-axis. Defaults to None.
|
|
310
|
+
y_label (str, optional): The label for the y-axis. Defaults to None.
|
|
311
|
+
ax (Axes, optional): The matplotlib Axes object to plot on. Defaults to None.
|
|
312
|
+
figsize (tuple[int, int], optional): The figure size. Defaults to None.
|
|
313
|
+
source_text (str, optional): The source text to annotate on the plot. Defaults to None.
|
|
314
|
+
**kwargs (dict[str, any]): Additional keyword arguments to pass to the dendrogram function.
|
|
315
|
+
|
|
316
|
+
Returns:
|
|
317
|
+
SubplotBase: The matplotlib SubplotBase object.
|
|
318
|
+
"""
|
|
319
|
+
linkage_matrix = linkage(self.distances, method="ward")
|
|
320
|
+
labels = self.pairs_df[self.product_col].cat.categories
|
|
321
|
+
|
|
322
|
+
if ax is None:
|
|
323
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
324
|
+
|
|
325
|
+
orientation = kwargs.get("orientation", "top")
|
|
326
|
+
default_x_label, default_y_label = (
|
|
327
|
+
("Products", "Distance") if orientation in ["top", "bottom"] else ("Distance", "Products")
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
gu.standard_graph_styles(
|
|
331
|
+
ax=ax,
|
|
332
|
+
title=title,
|
|
333
|
+
x_label=gu.not_none(x_label, default_x_label),
|
|
334
|
+
y_label=gu.not_none(y_label, default_y_label),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Set the y label to be on the right side of the plot
|
|
338
|
+
if orientation == "left":
|
|
339
|
+
ax.yaxis.tick_right()
|
|
340
|
+
ax.yaxis.set_label_position("right")
|
|
341
|
+
elif orientation == "bottom":
|
|
342
|
+
ax.xaxis.tick_top()
|
|
343
|
+
ax.xaxis.set_label_position("top")
|
|
344
|
+
|
|
345
|
+
dendrogram(linkage_matrix, labels=labels, ax=ax, **kwargs)
|
|
346
|
+
styler = PlotStyler()
|
|
347
|
+
styler.apply_ticks(ax)
|
|
348
|
+
|
|
349
|
+
# Rotate the x-axis labels if they are too long
|
|
350
|
+
if orientation in ["top", "bottom"]:
|
|
351
|
+
plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
|
|
352
|
+
|
|
353
|
+
# Set the font properties for the tick labels
|
|
354
|
+
gu.standard_tick_styles(ax)
|
|
355
|
+
|
|
356
|
+
if source_text is not None:
|
|
357
|
+
gu.add_source_text(ax=ax, source_text=source_text)
|
|
358
|
+
|
|
359
|
+
return ax
|