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,340 @@
1
+ """Gain loss analysis (switching analysis) on a DataFrame.
2
+
3
+ Assesses customer movement between brands or products over time.
4
+
5
+ Gain loss analysis, also known as switching analysis, is a marketing analytics technique used to
6
+ assess customer movement between brands or products over time. It helps businesses understand the dynamics of customer
7
+ acquisition and churn. Here's a concise definition: Gain loss analysis examines the flow of customers to and from a
8
+ brand or product, quantifying:
9
+
10
+ 1. Gains: New customers acquired from competitors
11
+ 2. Losses: Existing customers lost to competitors
12
+ 3. Net change: The overall impact on market share
13
+
14
+ This analysis helps marketers:
15
+
16
+ - Identify trends in customer behavior
17
+ - Evaluate the effectiveness of marketing strategies
18
+ - Understand competitive dynamics in the market
19
+ """
20
+
21
+ import pandas as pd
22
+ from matplotlib.axes import Axes, SubplotBase
23
+
24
+ import openretailscience.plots.styles.graph_utils as gu
25
+ from openretailscience.options import ColumnHelper, get_option
26
+ from openretailscience.plots import bar
27
+ from openretailscience.plots.styles.colors import COLORS
28
+
29
+
30
+ class GainLoss:
31
+ """Perform gain loss analysis on a DataFrame.
32
+
33
+ Assesses customer movement between brands or products over time.
34
+ """
35
+
36
+ def __init__(
37
+ self,
38
+ df: pd.DataFrame,
39
+ p1_index: list[bool] | pd.Series,
40
+ p2_index: list[bool] | pd.Series,
41
+ focus_group_index: list[bool] | pd.Series,
42
+ focus_group_name: str,
43
+ comparison_group_index: list[bool] | pd.Series,
44
+ comparison_group_name: str,
45
+ group_col: str | None = None,
46
+ value_col: str = get_option("column.unit_spend"),
47
+ agg_func: str = "sum",
48
+ ) -> None:
49
+ """Calculate the gain loss table for a given DataFrame at the customer level.
50
+
51
+ Args:
52
+ df (pd.DataFrame): The DataFrame to calculate the gain loss table from.
53
+ p1_index (list[bool]): The index for the first time period.
54
+ p2_index (list[bool]): The index for the second time period.
55
+ focus_group_index (list[bool]): The index for the focus group.
56
+ focus_group_name (str): The name of the focus group.
57
+ comparison_group_index (list[bool]): The index for the comparison group.
58
+ comparison_group_name (str): The name of the comparison group.
59
+ group_col (str | None, optional): The column to group by. Defaults to None.
60
+ value_col (str, optional): The column to calculate the gain loss from. Defaults to option column.unit_spend.
61
+ agg_func (str, optional): The aggregation function to use. Defaults to "sum".
62
+ """
63
+ # # Ensure no overlap between p1 and p2
64
+ if not df[p1_index].index.intersection(df[p2_index].index).empty:
65
+ raise ValueError("p1_index and p2_index should not overlap")
66
+
67
+ if not df[focus_group_index].index.intersection(df[comparison_group_index].index).empty:
68
+ raise ValueError("focus_group_index and comparison_group_index should not overlap")
69
+
70
+ if not len(p1_index) == len(p2_index) == len(focus_group_index) == len(comparison_group_index):
71
+ raise ValueError(
72
+ "p1_index, p2_index, focus_group_index, and comparison_group_index should have the same length",
73
+ )
74
+
75
+ required_cols = [get_option("column.customer_id"), value_col] + ([group_col] if group_col is not None else [])
76
+ missing_cols = set(required_cols) - set(df.columns)
77
+ if len(missing_cols) > 0:
78
+ msg = f"The following columns are required but missing: {missing_cols}"
79
+ raise ValueError(msg)
80
+
81
+ self.focus_group_name = focus_group_name
82
+ self.comparison_group_name = comparison_group_name
83
+ self.group_col = group_col
84
+ self.value_col = value_col
85
+
86
+ self.gain_loss_df = self._calc_gain_loss(
87
+ df=df,
88
+ p1_index=p1_index,
89
+ p2_index=p2_index,
90
+ focus_group_index=focus_group_index,
91
+ comparison_group_index=comparison_group_index,
92
+ group_col=group_col,
93
+ value_col=value_col,
94
+ agg_func=agg_func,
95
+ )
96
+ self.gain_loss_table_df = self._calc_gains_loss_table(
97
+ gain_loss_df=self.gain_loss_df,
98
+ group_col=group_col,
99
+ )
100
+
101
+ @staticmethod
102
+ def process_customer_group(
103
+ focus_p1: float,
104
+ comparison_p1: float,
105
+ focus_p2: float,
106
+ comparison_p2: float,
107
+ focus_diff: float,
108
+ comparison_diff: float,
109
+ ) -> tuple[float, float, float, float, float, float]:
110
+ """Process the gain loss for a customer group.
111
+
112
+ Args:
113
+ focus_p1 (float | int): The focus group total in the first time period.
114
+ comparison_p1 (float | int): The comparison group total in the first time period.
115
+ focus_p2 (float | int): The focus group total in the second time period.
116
+ comparison_p2 (float | int): The comparison group total in the second time period.
117
+ focus_diff (float | int): The difference in the focus group totals.
118
+ comparison_diff (float | int): The difference in the comparison group totals.
119
+
120
+ Returns:
121
+ tuple[float, float, float, float, float, float]: The gain loss for the customer group.
122
+ """
123
+ if focus_p1 == 0 and comparison_p1 == 0:
124
+ return focus_p2, 0, 0, 0, 0, 0
125
+ if focus_p2 == 0 and comparison_p2 == 0:
126
+ return 0, -1 * focus_p1, 0, 0, 0, 0
127
+
128
+ if focus_diff > 0:
129
+ focus_inc_dec = focus_diff if comparison_diff > 0 else max(0, comparison_diff + focus_diff)
130
+ elif comparison_diff < 0:
131
+ focus_inc_dec = focus_diff
132
+ else:
133
+ focus_inc_dec = min(0, comparison_diff + focus_diff)
134
+
135
+ increased_focus = max(0, focus_inc_dec)
136
+ decreased_focus = min(0, focus_inc_dec)
137
+
138
+ transfer = focus_diff - focus_inc_dec
139
+ switch_from_comparison = max(0, transfer)
140
+ switch_to_comparison = min(0, transfer)
141
+
142
+ return 0, 0, increased_focus, decreased_focus, switch_from_comparison, switch_to_comparison
143
+
144
+ @staticmethod
145
+ def _calc_gain_loss(
146
+ df: pd.DataFrame,
147
+ p1_index: list[bool],
148
+ p2_index: list[bool],
149
+ focus_group_index: list[bool],
150
+ comparison_group_index: list,
151
+ group_col: str | None = None,
152
+ value_col: str = get_option("column.unit_spend"),
153
+ agg_func: str = "sum",
154
+ ) -> pd.DataFrame:
155
+ """Calculate the gain loss table for a given DataFrame at the customer level.
156
+
157
+ Args:
158
+ df (pd.DataFrame): The DataFrame to calculate the gain loss table from.
159
+ p1_index (list[bool]): The index for the first time period.
160
+ p2_index (list[bool]): The index for the second time period.
161
+ focus_group_index (list[bool]): The index for the focus group.
162
+ comparison_group_index (list[bool]): The index for the comparison group.
163
+ group_col (str | None, optional): The column to group by. Defaults to None.
164
+ value_col (str, optional): The column to calculate the gain loss from. Defaults to option column.unit_spend.
165
+ agg_func (str, optional): The aggregation function to use. Defaults to "sum".
166
+
167
+ Returns:
168
+ pd.DataFrame: The gain loss table.
169
+ """
170
+ cols = ColumnHelper()
171
+ df = df[p1_index | p2_index].copy()
172
+ df[cols.customer_id] = df[cols.customer_id].astype("category")
173
+
174
+ grp_cols = [cols.customer_id] if group_col is None else [group_col, cols.customer_id]
175
+
176
+ p1_df = pd.concat(
177
+ [
178
+ df[focus_group_index & p1_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
179
+ df[comparison_group_index & p1_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
180
+ df[(focus_group_index | comparison_group_index) & p1_index]
181
+ .groupby(grp_cols, observed=False)[value_col]
182
+ .agg(agg_func),
183
+ ],
184
+ axis=1,
185
+ )
186
+ p1_df.columns = ["focus", "comparison", "total"]
187
+
188
+ p2_df = pd.concat(
189
+ [
190
+ df[focus_group_index & p2_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
191
+ df[comparison_group_index & p2_index].groupby(grp_cols, observed=False)[value_col].agg(agg_func),
192
+ df[(focus_group_index | comparison_group_index) & p2_index]
193
+ .groupby(grp_cols, observed=False)[value_col]
194
+ .agg(agg_func),
195
+ ],
196
+ axis=1,
197
+ )
198
+ p2_df.columns = ["focus", "comparison", "total"]
199
+
200
+ gl_df = p1_df.merge(p2_df, on=grp_cols, how="outer", suffixes=("_p1", "_p2")).fillna(0)
201
+
202
+ # Remove rows that are all 0 due to grouping by customer_id as a categorical with observed=False
203
+ gl_df = gl_df[~(gl_df == 0).all(axis=1)]
204
+
205
+ gl_df["focus_diff"] = gl_df["focus_p2"] - gl_df["focus_p1"]
206
+ gl_df["comparison_diff"] = gl_df["comparison_p2"] - gl_df["comparison_p1"]
207
+ gl_df["total_diff"] = gl_df["total_p2"] - gl_df["total_p1"]
208
+
209
+ (
210
+ gl_df["new"],
211
+ gl_df["lost"],
212
+ gl_df["increased_focus"],
213
+ gl_df["decreased_focus"],
214
+ gl_df["switch_from_comparison"],
215
+ gl_df["switch_to_comparison"],
216
+ ) = zip(
217
+ *gl_df.apply(
218
+ lambda x: GainLoss.process_customer_group(
219
+ focus_p1=x["focus_p1"],
220
+ comparison_p1=x["comparison_p1"],
221
+ focus_p2=x["focus_p2"],
222
+ comparison_p2=x["comparison_p2"],
223
+ focus_diff=x["focus_diff"],
224
+ comparison_diff=x["comparison_diff"],
225
+ ),
226
+ axis=1,
227
+ ),
228
+ strict=False,
229
+ )
230
+
231
+ return gl_df
232
+
233
+ @staticmethod
234
+ def _calc_gains_loss_table(
235
+ gain_loss_df: pd.DataFrame,
236
+ group_col: str | None = None,
237
+ ) -> pd.DataFrame:
238
+ """Aggregates the gain loss table to show the total gains and losses across customers.
239
+
240
+ Args:
241
+ gain_loss_df (pd.DataFrame): The gain loss table at customer level to aggregate.
242
+ group_col (str | None, optional): The column to group by. Defaults to None.
243
+
244
+ Returns:
245
+ pd.DataFrame: The aggregated gain loss table
246
+ """
247
+ if group_col is None:
248
+ return gain_loss_df.sum().to_frame("").T
249
+
250
+ return gain_loss_df.groupby(level=0).sum()
251
+
252
+ def plot(
253
+ self,
254
+ title: str | None = None,
255
+ x_label: str | None = None,
256
+ y_label: str | None = None,
257
+ ax: Axes | None = None,
258
+ source_text: str | None = None,
259
+ move_legend_outside: bool = False,
260
+ **kwargs: dict[str, any],
261
+ ) -> SubplotBase:
262
+ """Plot the gain loss table using the bar.plot wrapper.
263
+
264
+ Args:
265
+ title (str | None, optional): The title of the plot. Defaults to None.
266
+ x_label (str | None, optional): The x-axis label. Defaults to None.
267
+ y_label (str | None, optional): The y-axis label. Defaults to None.
268
+ ax (Axes | None, optional): The axes to plot on. Defaults to None.
269
+ source_text (str | None, optional): The source text to add to the plot. Defaults to None.
270
+ move_legend_outside (bool, optional): Whether to move the legend outside the plot. Defaults to False.
271
+ kwargs (dict[str, any]): Additional keyword arguments to pass to the plot.
272
+
273
+ Returns:
274
+ SubplotBase: The plot
275
+ """
276
+ green_colors = [COLORS["green"][700], COLORS["green"][500], COLORS["green"][300]]
277
+ red_colors = [COLORS["red"][700], COLORS["red"][500], COLORS["red"][300]]
278
+
279
+ increase_cols = ["new", "increased_focus", "switch_from_comparison"]
280
+ decrease_cols = ["lost", "decreased_focus", "switch_to_comparison"]
281
+ all_cols = increase_cols + decrease_cols
282
+
283
+ plot_df = self.gain_loss_table_df.copy()
284
+ default_y_label = self.focus_group_name if self.group_col is None else self.group_col
285
+ plot_data = plot_df.copy()
286
+
287
+ color_dict = {col: green_colors[i] for i, col in enumerate(increase_cols)}
288
+ color_dict.update({col: red_colors[i] for i, col in enumerate(decrease_cols)})
289
+
290
+ kwargs.pop("stacked", None)
291
+
292
+ ax = bar.plot(
293
+ df=plot_data,
294
+ value_col=all_cols,
295
+ title=gu.not_none(title, f"Gain Loss from {self.focus_group_name} to {self.comparison_group_name}"),
296
+ y_label=gu.not_none(y_label, default_y_label),
297
+ x_label=gu.not_none(x_label, self.value_col),
298
+ orientation="horizontal",
299
+ ax=ax,
300
+ source_text=source_text,
301
+ move_legend_outside=move_legend_outside,
302
+ stacked=True,
303
+ **kwargs,
304
+ )
305
+
306
+ for i, container in enumerate(ax.containers):
307
+ col_name = all_cols[i]
308
+ for patch in container:
309
+ patch.set_color(color_dict[col_name])
310
+
311
+ legend_labels = [
312
+ "New",
313
+ f"Increased {self.focus_group_name}",
314
+ f"Switch From {self.comparison_group_name}",
315
+ "Lost",
316
+ f"Decreased {self.focus_group_name}",
317
+ f"Switch To {self.comparison_group_name}",
318
+ ]
319
+
320
+ if ax.get_legend():
321
+ ax.get_legend().remove()
322
+
323
+ legend = ax.legend(
324
+ legend_labels,
325
+ frameon=True,
326
+ bbox_to_anchor=(1.05, 1) if move_legend_outside else None,
327
+ loc="upper left" if move_legend_outside else "best",
328
+ )
329
+ legend.get_frame().set_facecolor("white")
330
+ legend.get_frame().set_edgecolor("white")
331
+
332
+ ax.axvline(0, color="black", linewidth=0.5)
333
+
334
+ decimals = gu.get_decimals(ax.get_xlim(), ax.get_xticks())
335
+ ax.xaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
336
+ ax.grid(axis="x", linestyle="--", alpha=0.7)
337
+
338
+ gu.standard_tick_styles(ax)
339
+
340
+ return ax
@@ -0,0 +1,50 @@
1
+ """Geospatial Distance Analysis for Retail Location Intelligence.
2
+
3
+ This module provides efficient geospatial distance calculations that power location-based retail analytics and
4
+ strategic decision-making.
5
+
6
+ ## Technical Features
7
+
8
+ - **Ibis-Based Computation**: Scalable processing within existing data pipelines
9
+ - **Database Integration**: Calculations performed in SQL databases for efficiency
10
+ - **Haversine Formula**: Accurate great-circle distance computation
11
+ - **Backend Agnostic**: Works with multiple database and processing engines
12
+
13
+ ## Limitations
14
+
15
+ - **Spherical Earth Assumption**: Minor inaccuracies due to Earth's actual oblate shape
16
+ - **Straight-Line Distance**: Measures "as the crow flies", not driving distances
17
+ - **Requires Trigonometric Functions**: Backend must support mathematical functions
18
+ """
19
+
20
+ import ibis
21
+
22
+
23
+ def haversine_distance(
24
+ lat_col: ibis.Column,
25
+ lon_col: ibis.Column,
26
+ target_lat_col: ibis.Column,
27
+ target_lon_col: ibis.Column,
28
+ radius: float = 6371.0,
29
+ ) -> ibis.Column:
30
+ """Computes the Haversine distance between two sets of latitude and longitude columns.
31
+
32
+ Parameters:
33
+ lat_col (ibis.Column): Column containing source latitudes.
34
+ lon_col (ibis.Column): Column containing source longitudes.
35
+ target_lat_col (ibis.Column): Column containing target latitudes.
36
+ target_lon_col (ibis.Column): Column containing target longitudes.
37
+ radius (float, optional): Earth's radius in kilometers (default: 6371 km).
38
+
39
+ Returns:
40
+ ibis.Column: An Ibis expression representing the computed distances.
41
+ """
42
+ lat1_rad = lat_col.radians()
43
+ lat2_rad = target_lat_col.radians()
44
+ delta_lat = (target_lat_col - lat_col).radians()
45
+ delta_lon = (target_lon_col - lon_col).radians()
46
+
47
+ a = (delta_lat / 2).sin().pow(2) + lat1_rad.cos() * lat2_rad.cos() * (delta_lon / 2).sin().pow(2)
48
+ c = 2 * a.sqrt().asin()
49
+
50
+ return radius * c