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.
Files changed (60) hide show
  1. openretailscience/__init__.py +3 -0
  2. openretailscience/analysis/__init__.py +0 -0
  3. openretailscience/analysis/cohort.py +185 -0
  4. openretailscience/analysis/composite_rank.py +372 -0
  5. openretailscience/analysis/cross_shop.py +354 -0
  6. openretailscience/analysis/customer.py +459 -0
  7. openretailscience/analysis/customer_decision_hierarchy.py +359 -0
  8. openretailscience/analysis/gain_loss.py +340 -0
  9. openretailscience/analysis/haversine.py +50 -0
  10. openretailscience/analysis/product_association.py +418 -0
  11. openretailscience/analysis/revenue_tree.py +553 -0
  12. openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
  13. openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
  14. openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
  15. openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
  16. openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
  17. openretailscience/constants.py +295 -0
  18. openretailscience/metrics/__init__.py +0 -0
  19. openretailscience/metrics/base.py +29 -0
  20. openretailscience/metrics/distribution/__init__.py +0 -0
  21. openretailscience/metrics/distribution/acv.py +81 -0
  22. openretailscience/metrics/distribution/pct_of_stores.py +118 -0
  23. openretailscience/options.py +842 -0
  24. openretailscience/plots/__init__.py +0 -0
  25. openretailscience/plots/area.py +114 -0
  26. openretailscience/plots/bar.py +375 -0
  27. openretailscience/plots/broken_timeline.py +197 -0
  28. openretailscience/plots/cohort.py +94 -0
  29. openretailscience/plots/heatmap.py +134 -0
  30. openretailscience/plots/histogram.py +276 -0
  31. openretailscience/plots/index.py +505 -0
  32. openretailscience/plots/line.py +379 -0
  33. openretailscience/plots/period_on_period.py +144 -0
  34. openretailscience/plots/price.py +275 -0
  35. openretailscience/plots/scatter.py +382 -0
  36. openretailscience/plots/styles/__init__.py +1 -0
  37. openretailscience/plots/styles/colors.py +215 -0
  38. openretailscience/plots/styles/font_utils.py +62 -0
  39. openretailscience/plots/styles/graph_utils.py +834 -0
  40. openretailscience/plots/styles/styling_helpers.py +155 -0
  41. openretailscience/plots/time.py +160 -0
  42. openretailscience/plots/tree_diagram.py +813 -0
  43. openretailscience/plots/venn.py +127 -0
  44. openretailscience/plots/waterfall.py +217 -0
  45. openretailscience/plugin.py +238 -0
  46. openretailscience/segmentation/__init__.py +0 -0
  47. openretailscience/segmentation/hml.py +97 -0
  48. openretailscience/segmentation/nlr.py +218 -0
  49. openretailscience/segmentation/rfm.py +307 -0
  50. openretailscience/segmentation/segstats.py +1177 -0
  51. openretailscience/segmentation/threshold.py +165 -0
  52. openretailscience/utils/__init__.py +0 -0
  53. openretailscience/utils/date.py +226 -0
  54. openretailscience/utils/filter_and_label.py +52 -0
  55. openretailscience/utils/label.py +79 -0
  56. openretailscience/utils/validation.py +45 -0
  57. openretailscience-0.45.0.dist-info/METADATA +364 -0
  58. openretailscience-0.45.0.dist-info/RECORD +60 -0
  59. openretailscience-0.45.0.dist-info/WHEEL +4 -0
  60. 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