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,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
|
+
)
|