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,354 @@
1
+ """Cross-Shopping Analysis Module for Category and Brand Interaction Insights.
2
+
3
+ ## Business Context
4
+
5
+ Cross-shopping analysis reveals how customers navigate between different product
6
+ categories, brands, or store locations. This intelligence drives critical retail
7
+ decisions from store layout and category adjacencies to promotional bundling and
8
+ targeted marketing campaigns.
9
+
10
+ ## Real-World Problem
11
+
12
+ Retailers often make incorrect assumptions about customer behavior. They might place
13
+ baby products far from beer, not realizing these categories have high cross-shopping
14
+ rates (the famous "diapers and beer" phenomenon). Cross-shop analysis replaces
15
+ assumptions with data-driven insights about actual customer purchase patterns.
16
+
17
+ ## Business Applications
18
+
19
+ 1. **Store Layout Optimization**
20
+ - Place frequently cross-shopped categories near each other
21
+ - Create logical customer journey paths through the store
22
+ - Reduce friction in the shopping experience
23
+
24
+ 2. **Promotional Strategy**
25
+ - Bundle products from highly cross-shopped categories
26
+ - Time promotions to capture cross-category purchases
27
+ - Design "buy from A, get discount on B" offers
28
+
29
+ 3. **Category Management**
30
+ - Understand category interdependencies
31
+ - Identify opportunity categories for existing shoppers
32
+ - Spot categories at risk when others decline
33
+
34
+ 4. **Multi-Channel Strategy**
35
+ - Analyze cross-shopping between online and physical stores
36
+ - Optimize channel-specific assortments
37
+ - Design omnichannel customer journeys
38
+
39
+ 5. **Competitive Analysis**
40
+ - Understand customer loyalty across competing brands
41
+ - Identify vulnerable competitor segments
42
+ - Design conquest strategies for shared customers
43
+
44
+ ## Business Value
45
+
46
+ - **Increased Basket Size**: Strategic placement increases impulse purchases
47
+ - **Customer Retention**: Better store experience reduces defection
48
+ - **Marketing Efficiency**: Target promotions to actual behavior patterns
49
+ - **Strategic Insights**: Understand true category relationships
50
+ - **Competitive Advantage**: Leverage unique customer behavior insights
51
+
52
+ ## Visualization Output
53
+
54
+ The module generates Venn diagrams showing:
55
+ - Exclusive shoppers for each category/brand
56
+ - Overlap segments with cross-shopping behavior
57
+ - Relative size of each segment by customer count or spend
58
+ - Percentage breakdowns for strategic planning
59
+ """
60
+
61
+ from collections.abc import Callable
62
+
63
+ import ibis
64
+ import pandas as pd
65
+ from matplotlib.axes import Axes, SubplotBase
66
+
67
+ from openretailscience.options import get_option
68
+ from openretailscience.plots import venn
69
+
70
+
71
+ class CrossShop:
72
+ """Analyzes customer cross-shopping behavior between categories, brands, or locations.
73
+
74
+ The CrossShop class reveals hidden relationships in customer purchasing patterns,
75
+ enabling retailers to optimize everything from store layouts to marketing campaigns.
76
+ By understanding which products customers buy together across shopping trips, retailers
77
+ can make smarter decisions about product placement, promotions, and assortment.
78
+
79
+ ## Business Problem Solved
80
+
81
+ Many retail decisions assume customer behavior that may not reflect reality. For example:
82
+ - Are organic shoppers also buying conventional products?
83
+ - Do online grocery shoppers still visit physical stores?
84
+ - Which private label categories attract national brand buyers?
85
+
86
+ This analysis provides definitive answers with actionable percentages.
87
+ """
88
+
89
+ @staticmethod
90
+ def _generate_default_labels(count: int) -> list[str]:
91
+ """Generate default alphabetical labels for groups.
92
+
93
+ Args:
94
+ count (int): Number of labels to generate.
95
+
96
+ Returns:
97
+ list[str]: List of alphabetical labels (A, B, C, etc.).
98
+ """
99
+ return [chr(65 + i) for i in range(count)]
100
+
101
+ def __init__(
102
+ self,
103
+ df: pd.DataFrame | ibis.Table,
104
+ group_1_col: str,
105
+ group_1_val: str,
106
+ group_2_val: str,
107
+ group_2_col: str | None = None,
108
+ group_3_col: str | None = None,
109
+ group_3_val: str | None = None,
110
+ labels: list[str] | None = None,
111
+ group_col: str | None = None,
112
+ value_col: str | None = None,
113
+ agg_func: str = "sum",
114
+ ) -> None:
115
+ """Initialize cross-shopping analysis between retail categories, brands, or locations.
116
+
117
+ Args:
118
+ df (pd.DataFrame | ibis.Table): Transaction data with customer purchases.
119
+ group_1_col (str): Column identifying first segment (e.g., "category", "brand", "channel").
120
+ group_1_val (str): Value to analyze for first segment (e.g., "organic", "Brand_A", "online").
121
+ group_2_val (str): Value to analyze for second segment.
122
+ group_2_col (str, optional): Column identifying second segment.
123
+ Defaults to group_1_col.
124
+ group_3_col (str, optional): Column for three-way analysis.
125
+ Defaults to group_1_col when group_3_val provided.
126
+ group_3_val (str, optional): Value for third segment. Defaults to None.
127
+ labels (list[str], optional): Custom labels for diagram (e.g., ["Organic", "Local"]).
128
+ Defaults to alphabetical labels [A, B, C].
129
+ group_col (str, optional): Grouping column (e.g., customer_id, store_id,
130
+ segment_name). Defaults to customer_id from options.
131
+ value_col (str, optional): Metric to analyze (sales, units, visits).
132
+ Defaults to spend column from options.
133
+ agg_func (str, optional): How to combine customer values ("sum", "mean", "count").
134
+ Defaults to "sum" for total opportunity sizing.
135
+
136
+ Returns:
137
+ None
138
+
139
+ Raises:
140
+ ValueError: If required columns missing or label count doesn't match groups.
141
+
142
+ Business Examples:
143
+ >>> # Analyze organic vs conventional shoppers
144
+ >>> cross_shop = CrossShop(
145
+ ... df=transactions,
146
+ ... group_1_col="product_type",
147
+ ... group_1_val="organic",
148
+ ... group_2_val="conventional",
149
+ ... labels=["Organic", "Conventional"]
150
+ ... )
151
+ ...
152
+ >>> # Three-way analysis
153
+ >>> cross_shop = CrossShop(
154
+ ... df=transactions,
155
+ ... group_1_col="channel",
156
+ ... group_1_val="online",
157
+ ... group_2_val="store",
158
+ ... group_3_val="mobile",
159
+ ... labels=["Online", "Store", "Mobile"]
160
+ ... )
161
+ ...
162
+ >>> # Custom customer column
163
+ >>> cross_shop = CrossShop(
164
+ ... df=transactions,
165
+ ... group_1_col="brand",
166
+ ... group_1_val="Nike",
167
+ ... group_2_val="Adidas",
168
+ ... group_col="user_id"
169
+ ... )
170
+ """
171
+ # Apply smart defaults for simplified interface
172
+ group_col = group_col or get_option("column.customer_id")
173
+ value_col = value_col or get_option("column.unit_spend")
174
+
175
+ # Default group_2_col and group_3_col to group_1_col when columns are not provided
176
+ if group_2_col is None:
177
+ group_2_col = group_1_col
178
+ if group_3_val is not None and group_3_col is None:
179
+ group_3_col = group_1_col
180
+
181
+ required_cols = [group_col, value_col]
182
+ missing_cols = set(required_cols) - set(df.columns)
183
+ if len(missing_cols) > 0:
184
+ msg = f"The following columns are required but missing: {missing_cols}"
185
+ raise ValueError(msg)
186
+
187
+ self.group_count = 2 if group_3_col is None else 3
188
+
189
+ if (labels is not None) and (len(labels) != self.group_count):
190
+ raise ValueError("The number of labels must be equal to the number of group indexes given")
191
+
192
+ self.labels = labels if labels is not None else self._generate_default_labels(self.group_count)
193
+
194
+ self.cross_shop_df = self._calc_cross_shop(
195
+ df=df,
196
+ group_1_col=group_1_col,
197
+ group_1_val=group_1_val,
198
+ group_2_col=group_2_col,
199
+ group_2_val=group_2_val,
200
+ group_3_col=group_3_col,
201
+ group_3_val=group_3_val,
202
+ group_col=group_col,
203
+ value_col=value_col,
204
+ agg_func=agg_func,
205
+ labels=labels,
206
+ )
207
+ self.cross_shop_table_df = self._calc_cross_shop_table(
208
+ df=self.cross_shop_df,
209
+ value_col=value_col,
210
+ )
211
+
212
+ @staticmethod
213
+ def _calc_cross_shop(
214
+ df: pd.DataFrame | ibis.Table,
215
+ group_1_col: str,
216
+ group_1_val: str,
217
+ group_2_col: str,
218
+ group_2_val: str,
219
+ group_3_col: str | None = None,
220
+ group_3_val: str | None = None,
221
+ group_col: str | None = None,
222
+ value_col: str | None = None,
223
+ agg_func: str = "sum",
224
+ labels: list[str] | None = None,
225
+ ) -> pd.DataFrame:
226
+ """Calculate the cross-shop dataframe that will be used to plot the diagram.
227
+
228
+ Args:
229
+ df (pd.DataFrame | ibis.Table): The input DataFrame or ibis Table containing transactional data.
230
+ group_1_col (str): Column name for the first group.
231
+ group_1_val (str): Value to filter for the first group.
232
+ group_2_col (str): Column name for the second group.
233
+ group_2_val (str): Value to filter for the second group.
234
+ group_3_col (str, optional): Column name for the third group. Defaults to None.
235
+ group_3_val (str, optional): Value to filter for the third group. Defaults to None.
236
+ group_col (str, optional): Grouping column (e.g., customer_id, store_id,
237
+ segment_name). Defaults to customer_id from options.
238
+ value_col (str, optional): The column to aggregate. Defaults to option column.unit_spend.
239
+ agg_func (str, optional): The aggregation function. Defaults to "sum".
240
+ labels (list[str], optional): The labels for the groups. Defaults to None.
241
+
242
+ Returns:
243
+ pd.DataFrame: The cross-shop dataframe.
244
+
245
+ Raises:
246
+ ValueError: If group_3_col or group_3_val is populated, then the other must be as well.
247
+ """
248
+ if isinstance(df, pd.DataFrame):
249
+ df: ibis.Table = ibis.memtable(df)
250
+ if (group_3_col is None) != (group_3_val is None):
251
+ raise ValueError("If group_3_col or group_3_val is populated, then the other must be as well")
252
+
253
+ # Apply defaults for group_col and value_col
254
+ group_col = group_col or get_option("column.customer_id")
255
+ value_col = value_col or get_option("column.unit_spend")
256
+
257
+ # Using a temporary value column to avoid duplicate column errors during selection.
258
+ # This happens when `value_col` has the same name as `group_col`, causing conflicts
259
+ # in `.select()`.
260
+ temp_value_col = "temp_value_col"
261
+ df = df.mutate(**{temp_value_col: df[value_col]})
262
+
263
+ group_1 = (df[group_1_col] == group_1_val).cast("int32").name("group_1")
264
+ group_2 = (df[group_2_col] == group_2_val).cast("int32").name("group_2")
265
+ group_3 = (df[group_3_col] == group_3_val).cast("int32").name("group_3") if group_3_col else None
266
+
267
+ group_cols = ["group_1", "group_2"]
268
+ select_cols = [df[group_col], group_1, group_2]
269
+ if group_3 is not None:
270
+ group_cols.append("group_3")
271
+ select_cols.append(group_3)
272
+
273
+ cs_df = df.select([*select_cols, df[temp_value_col]]).order_by(group_col)
274
+ cs_df = (
275
+ cs_df.group_by(group_col)
276
+ .aggregate(
277
+ **{col: cs_df[col].max().name(col) for col in group_cols},
278
+ **{temp_value_col: getattr(cs_df[temp_value_col], agg_func)().name(temp_value_col)},
279
+ )
280
+ .order_by(group_col)
281
+ ).execute()
282
+
283
+ cs_df["groups"] = cs_df[group_cols].apply(tuple, axis=1)
284
+
285
+ # Use default alphabetical labels if none provided
286
+ if labels is None:
287
+ labels = CrossShop._generate_default_labels(len(group_cols))
288
+
289
+ group_label_series = cs_df[group_cols].apply(
290
+ lambda x: [labels[i] for i, grp_val in enumerate(x) if grp_val == 1],
291
+ axis=1,
292
+ )
293
+ cs_df["group_labels"] = group_label_series.map(lambda x: "No Groups" if len(x) == 0 else ", ".join(x))
294
+
295
+ column_order = [group_col, *group_cols, "groups", "group_labels", temp_value_col]
296
+ cs_df = cs_df[column_order]
297
+ cs_df = cs_df.set_index(group_col)
298
+ return cs_df.rename(columns={temp_value_col: value_col})
299
+
300
+ @staticmethod
301
+ def _calc_cross_shop_table(
302
+ df: pd.DataFrame,
303
+ value_col: str = get_option("column.unit_spend"),
304
+ ) -> pd.DataFrame:
305
+ """Calculate the aggregated cross-shop table that will be used to plot the diagram.
306
+
307
+ Args:
308
+ df (pd.DataFrame): The cross-shop dataframe.
309
+ value_col (str, optional): The column to aggregate. Defaults to option column.unit_spend.
310
+
311
+ Returns:
312
+ pd.DataFrame: The cross-shop table.
313
+ """
314
+ df = df.groupby(["groups", "group_labels"], dropna=False)[value_col].sum().reset_index().copy()
315
+ df["percent"] = df[value_col] / df[value_col].sum()
316
+ return df
317
+
318
+ def plot(
319
+ self,
320
+ title: str | None = None,
321
+ source_text: str | None = None,
322
+ vary_size: bool = False,
323
+ figsize: tuple[int, int] | None = None,
324
+ ax: Axes | None = None,
325
+ subset_label_formatter: Callable | None = None,
326
+ **kwargs: dict[str, any],
327
+ ) -> SubplotBase:
328
+ """Generate Venn diagram showing customer segment overlaps.
329
+
330
+ Args:
331
+ title (str, optional): Chart title (e.g., "Cross-Shopping: Organic vs Conventional").
332
+ source_text (str, optional): Data source attribution. Defaults to None.
333
+ vary_size (bool, optional): Scale circles by segment value for visual impact.
334
+ True = larger segments appear bigger. Defaults to False.
335
+ figsize (tuple[int, int], optional): Plot dimensions. Defaults to None.
336
+ ax (Axes, optional): Existing axes for subplot integration. Defaults to None.
337
+ subset_label_formatter (callable, optional): Custom formatting for percentages.
338
+ Default shows one decimal place (e.g., "34.5%").
339
+ **kwargs (dict[str, any]): Additional diagram customization options.
340
+
341
+ Returns:
342
+ SubplotBase: Matplotlib axes containing the cross-shop visualization.
343
+ """
344
+ return venn.plot(
345
+ df=self.cross_shop_table_df,
346
+ labels=self.labels,
347
+ title=title,
348
+ source_text=source_text,
349
+ vary_size=vary_size,
350
+ figsize=figsize,
351
+ ax=ax,
352
+ subset_label_formatter=subset_label_formatter or (lambda x: f"{x:.1%}"),
353
+ **kwargs,
354
+ )