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,127 @@
|
|
|
1
|
+
"""This module provides functionality for creating Venn and Euler diagrams from pandas DataFrames.
|
|
2
|
+
|
|
3
|
+
It is designed to visualize relationships between sets, highlighting intersections and differences between them.
|
|
4
|
+
|
|
5
|
+
### Core Features
|
|
6
|
+
|
|
7
|
+
- **Supports 2-set and 3-set Diagrams**: Allows visualization of up to three overlapping sets.
|
|
8
|
+
- **Venn and Euler Diagrams**: Uses Venn diagrams by default; switches to Euler diagrams when `vary_size=True`.
|
|
9
|
+
- **Customizable Colors and Labels**: Automatically assigns colors and labels for subset representation.
|
|
10
|
+
- **Dynamic Sizing**: Adjusts circle sizes for Euler diagrams to reflect proportions.
|
|
11
|
+
- **Title and Source Attribution**: Optionally adds a title and source text.
|
|
12
|
+
|
|
13
|
+
### Use Cases
|
|
14
|
+
|
|
15
|
+
- **Set Comparisons**: Identify shared and unique elements across two or three sets.
|
|
16
|
+
- **Proportional Representation**: Euler diagrams ensure area-accurate representation.
|
|
17
|
+
- **Data Overlap Visualization**: Helps in understanding relationships within categorical data.
|
|
18
|
+
|
|
19
|
+
### Limitations and Warnings
|
|
20
|
+
|
|
21
|
+
- **Only Supports 2 or 3 Sets**: Does not extend to Venn diagrams with more than three sets.
|
|
22
|
+
- **Pre-Aggregated Data Required**: The module does not perform data aggregation; input data
|
|
23
|
+
should already be structured correctly.
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
|
|
29
|
+
import pandas as pd
|
|
30
|
+
from matplotlib import pyplot as plt
|
|
31
|
+
from matplotlib.axes import Axes, SubplotBase
|
|
32
|
+
from matplotlib_set_diagrams import EulerDiagram, VennDiagram
|
|
33
|
+
|
|
34
|
+
from openretailscience.options import PlotStyleHelper
|
|
35
|
+
from openretailscience.plots.styles import graph_utils as gu
|
|
36
|
+
from openretailscience.plots.styles.colors import get_plot_colors
|
|
37
|
+
from openretailscience.plots.styles.font_utils import get_font_properties
|
|
38
|
+
from openretailscience.plots.styles.styling_helpers import PlotStyler
|
|
39
|
+
|
|
40
|
+
MAX_SUPPORTED_SETS = 3
|
|
41
|
+
MIN_SUPPORTED_SETS = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def plot(
|
|
45
|
+
df: pd.DataFrame,
|
|
46
|
+
labels: list[str],
|
|
47
|
+
title: str | None = None,
|
|
48
|
+
source_text: str | None = None,
|
|
49
|
+
vary_size: bool = False,
|
|
50
|
+
figsize: tuple[int, int] | None = None,
|
|
51
|
+
ax: Axes | None = None,
|
|
52
|
+
subset_label_formatter: Callable | None = None,
|
|
53
|
+
**kwargs: dict[str, any],
|
|
54
|
+
) -> SubplotBase:
|
|
55
|
+
"""Plots a Venn or Euler diagram using subset sizes extracted from a DataFrame.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
df (pd.DataFrame): DataFrame with 'groups' and 'percent' columns.
|
|
59
|
+
labels (list[str]): Labels for the sets in the diagram.
|
|
60
|
+
title (str, optional): Title of the plot. Defaults to None.
|
|
61
|
+
source_text (str, optional): Source text for attribution. Defaults to None.
|
|
62
|
+
vary_size (bool, optional): Whether to vary circle size based on subset sizes. Defaults to False.
|
|
63
|
+
figsize (tuple[int, int], optional): Size of the plot. Defaults to None.
|
|
64
|
+
ax (Axes, optional): Matplotlib axes object to plot on. Defaults to None.
|
|
65
|
+
subset_label_formatter (callable, optional): Function to format subset labels. Defaults to None.
|
|
66
|
+
**kwargs: Additional keyword arguments.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
SubplotBase: The matplotlib axes object with the plotted diagram.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If the number of sets is not 2 or 3.
|
|
73
|
+
"""
|
|
74
|
+
num_sets = len(labels)
|
|
75
|
+
if num_sets not in {MIN_SUPPORTED_SETS, MAX_SUPPORTED_SETS}:
|
|
76
|
+
raise ValueError("Only 2-set or 3-set Venn diagrams are supported")
|
|
77
|
+
|
|
78
|
+
default_colors = get_plot_colors(num_sets)
|
|
79
|
+
colors = kwargs.pop("color", default_colors)
|
|
80
|
+
|
|
81
|
+
zero_group = (0, 0) if num_sets == MIN_SUPPORTED_SETS else (0, 0, 0)
|
|
82
|
+
percent_s = df.loc[df["groups"] != zero_group, ["groups", "percent"]].set_index("groups")["percent"]
|
|
83
|
+
subset_sizes = percent_s.to_dict()
|
|
84
|
+
|
|
85
|
+
subset_labels = {
|
|
86
|
+
k: subset_label_formatter(v) if subset_label_formatter else str(v) for k, v in subset_sizes.items()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ax is None:
|
|
90
|
+
_, ax = plt.subplots(figsize=figsize)
|
|
91
|
+
|
|
92
|
+
diagram_class = EulerDiagram if vary_size else VennDiagram
|
|
93
|
+
diagram = diagram_class(
|
|
94
|
+
set_labels=labels,
|
|
95
|
+
subset_sizes=subset_sizes,
|
|
96
|
+
subset_labels=subset_labels,
|
|
97
|
+
set_colors=colors,
|
|
98
|
+
ax=ax,
|
|
99
|
+
**kwargs,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
center_x, center_y, displacement = 0.5, 0.5, 0.1
|
|
103
|
+
styler = PlotStyler()
|
|
104
|
+
style = PlotStyleHelper()
|
|
105
|
+
for text in diagram.set_label_artists:
|
|
106
|
+
text.set_fontproperties(get_font_properties(style.label_font))
|
|
107
|
+
text.set_fontsize(style.label_size)
|
|
108
|
+
if num_sets == MAX_SUPPORTED_SETS and not vary_size:
|
|
109
|
+
x, y = text.get_position()
|
|
110
|
+
direction_x, direction_y = x - center_x, y - center_y
|
|
111
|
+
scale = displacement / (direction_x**2 + direction_y**2) ** 0.5
|
|
112
|
+
text.set_position((x + scale * direction_x, y + scale * direction_y))
|
|
113
|
+
|
|
114
|
+
for subset_id in subset_sizes:
|
|
115
|
+
if subset_id not in diagram.subset_label_artists:
|
|
116
|
+
continue
|
|
117
|
+
text = diagram.subset_label_artists[subset_id]
|
|
118
|
+
text.set_fontproperties(get_font_properties(style.label_font))
|
|
119
|
+
|
|
120
|
+
if title:
|
|
121
|
+
styler.apply_title(ax, title, pad=style.title_size + 20)
|
|
122
|
+
|
|
123
|
+
if source_text is not None:
|
|
124
|
+
ax.set_xticklabels([], visible=False)
|
|
125
|
+
gu.add_source_text(ax=ax, source_text=source_text, is_venn_diagram=True)
|
|
126
|
+
|
|
127
|
+
return ax
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""This module provides functionality to generate a waterfall chart.
|
|
2
|
+
|
|
3
|
+
A visualization commonly used to illustrate how
|
|
4
|
+
different positive and negative values contribute to a cumulative total.Waterfall charts are effective in showing
|
|
5
|
+
the incremental impact of individual components, making them particularly useful for financial analysis,
|
|
6
|
+
performance tracking, and visualizing changes over time.
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
- **Waterfall Chart Creation**: Displays how different positive and negative values affect a starting total.
|
|
11
|
+
- **Data Label Formatting**: Supports custom formatting for data labels, including absolute
|
|
12
|
+
values, percentages, or both.
|
|
13
|
+
- **Net Line and Bar Display**: Optionally includes a net line and net bar to show the overall
|
|
14
|
+
cumulative result.
|
|
15
|
+
- **Customizable Plot Style**: Options to customize chart titles, axis labels, and remove zero
|
|
16
|
+
amounts for better clarity.
|
|
17
|
+
- **Handling of Zero Amounts**: Allows removal of zero amounts from the plot to avoid cluttering the chart.
|
|
18
|
+
- **Interactive Elements**: Supports custom annotations for the chart with source text.
|
|
19
|
+
|
|
20
|
+
### Use Cases
|
|
21
|
+
|
|
22
|
+
- **Financial Analysis**: Show the breakdown of profits and losses over multiple periods, or how
|
|
23
|
+
different cost categories affect overall margin.
|
|
24
|
+
- **Revenue Tracking**: Track how revenue or other key metrics change over time, and visualize
|
|
25
|
+
the impact of individual contributing factors.
|
|
26
|
+
- **Performance Visualization**: Highlight how various business or product categories affect
|
|
27
|
+
overall performance, such as sales, expenses, or growth metrics.
|
|
28
|
+
- **Budget Breakdown**: Visualize how different spending categories contribute to a total budget over a period.
|
|
29
|
+
|
|
30
|
+
### Functionality Details
|
|
31
|
+
|
|
32
|
+
- **plot()**: Generates a waterfall chart from a list of amounts and labels. It supports
|
|
33
|
+
additional customization for display settings, labels, and source text.
|
|
34
|
+
- **format_data_labels()**: A helper function used to format the data labels according to the
|
|
35
|
+
specified format (absolute, percentage, both).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
import warnings
|
|
39
|
+
from typing import Literal
|
|
40
|
+
|
|
41
|
+
import pandas as pd
|
|
42
|
+
from matplotlib.axes import Axes
|
|
43
|
+
|
|
44
|
+
import openretailscience.plots.styles.graph_utils as gu
|
|
45
|
+
from openretailscience.options import PlotStyleHelper
|
|
46
|
+
from openretailscience.plots.styles.colors import get_named_color
|
|
47
|
+
from openretailscience.plots.styles.font_utils import get_font_properties
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def plot(
|
|
51
|
+
amounts: list[float],
|
|
52
|
+
labels: list[str],
|
|
53
|
+
title: str | None = None,
|
|
54
|
+
y_label: str | None = None,
|
|
55
|
+
x_label: str = "",
|
|
56
|
+
source_text: str | None = None,
|
|
57
|
+
data_label_format: Literal["absolute", "percentage", "both"] | None = None,
|
|
58
|
+
display_net_bar: bool = False,
|
|
59
|
+
display_net_line: bool = False,
|
|
60
|
+
remove_zero_amounts: bool = True,
|
|
61
|
+
ax: Axes | None = None,
|
|
62
|
+
**kwargs: dict[str, any],
|
|
63
|
+
) -> Axes:
|
|
64
|
+
"""Generates a waterfall chart.
|
|
65
|
+
|
|
66
|
+
Waterfall plots are particularly good for showing how different things add or subtract from a starting number. For
|
|
67
|
+
instance:
|
|
68
|
+
- Changes in sales figures from one period to another
|
|
69
|
+
- Breakdown of profit margins
|
|
70
|
+
- Impact of different product categories on overall revenue
|
|
71
|
+
|
|
72
|
+
They are often used to identify key drivers of financial performance, highlight areas for
|
|
73
|
+
improvement, and communicate complex data stories to stakeholders in an intuitive manner.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
amounts (list[float]): The amounts to plot.
|
|
77
|
+
labels (list[str]): The labels for the amounts.
|
|
78
|
+
title (str, optional): The title of the chart. Defaults to None.
|
|
79
|
+
y_label (str, optional): The y-axis label. Defaults to None.
|
|
80
|
+
x_label (str, optional): The x-axis label. Defaults to None.
|
|
81
|
+
source_text (str, optional): The source text to add to the plot. Defaults to None.
|
|
82
|
+
data_label_format (Literal["absolute", "percentage", "both", "none"], optional): The format of the data labels.
|
|
83
|
+
Defaults to "absolute".
|
|
84
|
+
display_net_bar (bool, optional): Whether to display a net bar. Defaults to False.
|
|
85
|
+
display_net_line (bool, optional): Whether to display a net line. Defaults to False.
|
|
86
|
+
remove_zero_amounts (bool, optional): Whether to remove zero amounts from the plot. Defaults to True
|
|
87
|
+
ax (Axes, optional): The matplotlib axes object to plot on. Defaults to None.
|
|
88
|
+
**kwargs: Additional keyword arguments to pass to the Pandas plot function.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Axes: The matplotlib axes object.
|
|
92
|
+
"""
|
|
93
|
+
if len(amounts) != len(labels):
|
|
94
|
+
raise ValueError("The lengths of amounts and labels must be the same")
|
|
95
|
+
|
|
96
|
+
data_label_format = data_label_format.lower() if data_label_format else None
|
|
97
|
+
if data_label_format is not None and data_label_format not in [
|
|
98
|
+
"absolute",
|
|
99
|
+
"percentage",
|
|
100
|
+
"both",
|
|
101
|
+
]:
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"data_label_format must be either 'absolute', 'percentage', 'both', or None.",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
df = pd.DataFrame({"labels": labels, "amounts": amounts})
|
|
107
|
+
|
|
108
|
+
if remove_zero_amounts:
|
|
109
|
+
df = df[df["amounts"] != 0]
|
|
110
|
+
|
|
111
|
+
amount_total = df["amounts"].sum()
|
|
112
|
+
|
|
113
|
+
positive_color = get_named_color("positive")
|
|
114
|
+
negative_color = get_named_color("negative")
|
|
115
|
+
|
|
116
|
+
default_colors = (df["amounts"] > 0).map({True: positive_color, False: negative_color}).to_list()
|
|
117
|
+
bottom = df["amounts"].cumsum().shift(1).fillna(0).to_list()
|
|
118
|
+
|
|
119
|
+
if display_net_bar:
|
|
120
|
+
# Append a row for the net amount
|
|
121
|
+
df.loc[len(df)] = ["Net", amount_total]
|
|
122
|
+
default_colors.append(get_named_color("difference"))
|
|
123
|
+
bottom.append(0)
|
|
124
|
+
|
|
125
|
+
# Create the plot
|
|
126
|
+
width = kwargs.pop("width", 0.8)
|
|
127
|
+
color = kwargs.pop("color", default_colors)
|
|
128
|
+
ax = df.plot.bar(
|
|
129
|
+
x="labels",
|
|
130
|
+
y="amounts",
|
|
131
|
+
legend=None,
|
|
132
|
+
bottom=bottom,
|
|
133
|
+
color=color,
|
|
134
|
+
width=width,
|
|
135
|
+
ax=ax,
|
|
136
|
+
**kwargs,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
extra_title_pad = 25 if data_label_format != "none" else 0
|
|
140
|
+
ax = gu.standard_graph_styles(
|
|
141
|
+
ax,
|
|
142
|
+
title=title,
|
|
143
|
+
y_label=gu.not_none(y_label, "Amounts"),
|
|
144
|
+
x_label=x_label,
|
|
145
|
+
title_pad=10 + extra_title_pad,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
decimals = gu.get_decimals(ax.get_ylim(), ax.get_yticks())
|
|
149
|
+
ax.yaxis.set_major_formatter(
|
|
150
|
+
lambda x, pos: gu.human_format(x, pos, decimals=decimals),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Add a black line at the y=0 position
|
|
154
|
+
ax.axhline(y=0, color="black", linewidth=1, zorder=-1)
|
|
155
|
+
if data_label_format is not None:
|
|
156
|
+
labels = format_data_labels(
|
|
157
|
+
df["amounts"],
|
|
158
|
+
amount_total,
|
|
159
|
+
data_label_format,
|
|
160
|
+
decimals,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
style = PlotStyleHelper()
|
|
164
|
+
ax.bar_label(
|
|
165
|
+
ax.containers[0],
|
|
166
|
+
label_type="edge",
|
|
167
|
+
labels=labels,
|
|
168
|
+
padding=5,
|
|
169
|
+
fontsize=style.label_size - 1,
|
|
170
|
+
fontproperties=get_font_properties(style.label_font),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if display_net_line:
|
|
174
|
+
ax.axhline(y=amount_total, color="black", linewidth=1, linestyle="--")
|
|
175
|
+
|
|
176
|
+
if source_text is not None:
|
|
177
|
+
gu.add_source_text(ax=ax, source_text=source_text)
|
|
178
|
+
|
|
179
|
+
gu.standard_tick_styles(ax)
|
|
180
|
+
|
|
181
|
+
return ax
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def format_data_labels(
|
|
185
|
+
amounts: pd.Series,
|
|
186
|
+
total_change: float,
|
|
187
|
+
label_format: str,
|
|
188
|
+
decimals: int,
|
|
189
|
+
) -> list[str]:
|
|
190
|
+
"""Format the data labels based on the specified format.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
amounts (pd.Series): The amounts to format.
|
|
194
|
+
total_change (float): The total change (sum of amounts) used for percentage calculations.
|
|
195
|
+
label_format (str): The format of the data labels ("absolute", "percentage", or "both").
|
|
196
|
+
decimals (int): The number of decimal places for formatting.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
list[str]: A list of formatted data label strings.
|
|
200
|
+
"""
|
|
201
|
+
if label_format == "absolute":
|
|
202
|
+
return amounts.apply(lambda x: gu.human_format(x, decimals=decimals + 1)).tolist()
|
|
203
|
+
|
|
204
|
+
if total_change == 0:
|
|
205
|
+
warnings.warn(
|
|
206
|
+
"Total change is zero, cannot calculate percentages. Percentage labels will be omitted.",
|
|
207
|
+
UserWarning,
|
|
208
|
+
stacklevel=2,
|
|
209
|
+
)
|
|
210
|
+
if label_format == "percentage":
|
|
211
|
+
return [""] * len(amounts)
|
|
212
|
+
return [gu.human_format(x, decimals=decimals + 1) for x in amounts]
|
|
213
|
+
|
|
214
|
+
if label_format == "percentage":
|
|
215
|
+
return amounts.apply(lambda x: f"{x / total_change:.0%}").tolist()
|
|
216
|
+
|
|
217
|
+
return [f"{gu.human_format(x, decimals=decimals + 1)} ({x / total_change:.0%})" for x in amounts]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Plugin management module for openretailscience."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import inspect
|
|
7
|
+
import traceback
|
|
8
|
+
import types
|
|
9
|
+
from importlib.metadata import entry_points
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from typing import Self
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PluginManager:
|
|
18
|
+
"""Manages plugins for openretailscience package."""
|
|
19
|
+
|
|
20
|
+
_instance: PluginManager | None = None
|
|
21
|
+
|
|
22
|
+
def __new__(cls: type[PluginManager]) -> Self:
|
|
23
|
+
"""Singleton pattern for plugin manager."""
|
|
24
|
+
if cls._instance is None:
|
|
25
|
+
cls._instance = super().__new__(cls)
|
|
26
|
+
cls._instance._classes = {}
|
|
27
|
+
cls._instance._functions = {}
|
|
28
|
+
cls._instance._registered_classes = set()
|
|
29
|
+
cls._instance._registered_functions = set()
|
|
30
|
+
cls._instance._plugin_loaded = False
|
|
31
|
+
return cls._instance
|
|
32
|
+
|
|
33
|
+
def register_method(self, target_class: type, name: str, method: Callable) -> None:
|
|
34
|
+
"""Register a method with the plugin manager for a specific class.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
target_class: Class to extend
|
|
38
|
+
name: Method name
|
|
39
|
+
method: Method to register
|
|
40
|
+
"""
|
|
41
|
+
class_name = target_class.__name__
|
|
42
|
+
self._classes.setdefault(class_name, {})[name] = method
|
|
43
|
+
|
|
44
|
+
def register_function_extension(self, target_function: Callable, name: str, method: Callable) -> None:
|
|
45
|
+
"""Register an extension method for a standalone function.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
target_function: Function to extend
|
|
49
|
+
name: Extension method name
|
|
50
|
+
method: Method to register
|
|
51
|
+
"""
|
|
52
|
+
function_name = target_function.__name__
|
|
53
|
+
self._functions.setdefault(function_name, {})[name] = method
|
|
54
|
+
|
|
55
|
+
def extensible(self, cls_or_func: type | Callable) -> type | Callable:
|
|
56
|
+
"""Decorator to make a class or function extensible via plugins."""
|
|
57
|
+
if inspect.isclass(cls_or_func):
|
|
58
|
+
return self._make_class_extensible(cls_or_func)
|
|
59
|
+
return self._make_function_extensible(cls_or_func)
|
|
60
|
+
|
|
61
|
+
def _make_class_extensible(self, cls: type) -> type:
|
|
62
|
+
"""Make a class extensible via plugins."""
|
|
63
|
+
original_init = cls.__init__
|
|
64
|
+
|
|
65
|
+
@functools.wraps(original_init)
|
|
66
|
+
def wrapped_init(self, *args, **kwargs): # noqa: ANN001, ANN002, ANN003, ANN202
|
|
67
|
+
original_init(self, *args, **kwargs)
|
|
68
|
+
plugin_mgr = plugin_manager
|
|
69
|
+
if not plugin_mgr._plugin_loaded:
|
|
70
|
+
plugin_mgr._load_all_plugins()
|
|
71
|
+
plugin_mgr.apply_methods_to_instance(self)
|
|
72
|
+
|
|
73
|
+
cls.__init__ = wrapped_init
|
|
74
|
+
self._registered_classes.add(cls.__name__)
|
|
75
|
+
return cls
|
|
76
|
+
|
|
77
|
+
def _make_function_extensible(self, func: Callable) -> Callable:
|
|
78
|
+
"""Make a function extensible via plugins."""
|
|
79
|
+
|
|
80
|
+
@functools.wraps(func)
|
|
81
|
+
def wrapped_func(*args, **kwargs): # noqa: ANN002, ANN003, ANN202
|
|
82
|
+
if not plugin_manager._plugin_loaded:
|
|
83
|
+
plugin_manager._load_all_plugins()
|
|
84
|
+
|
|
85
|
+
result = func(*args, **kwargs)
|
|
86
|
+
|
|
87
|
+
function_name = func.__name__
|
|
88
|
+
if function_name in self._functions:
|
|
89
|
+
return ExtensibleFunctionResult(result, function_name)
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
self._registered_functions.add(func.__name__)
|
|
93
|
+
return wrapped_func
|
|
94
|
+
|
|
95
|
+
def _load_all_plugins(self) -> None:
|
|
96
|
+
"""Load all plugins from entry points once."""
|
|
97
|
+
failed_plugins = []
|
|
98
|
+
try:
|
|
99
|
+
for entry_point in entry_points(group="openretailscience.plugins"):
|
|
100
|
+
try:
|
|
101
|
+
plugin_register_fn = entry_point.load()
|
|
102
|
+
plugin_register_fn(self)
|
|
103
|
+
except Exception as plugin_error: # noqa: BLE001, PERF203
|
|
104
|
+
failed_plugins.append((entry_point.name, str(plugin_error)))
|
|
105
|
+
traceback.print_exc()
|
|
106
|
+
self._plugin_loaded = True
|
|
107
|
+
if failed_plugins:
|
|
108
|
+
print(f"Warning: {len(failed_plugins)} plugins failed to load: {failed_plugins}") # noqa: T201
|
|
109
|
+
except Exception as e: # noqa: BLE001
|
|
110
|
+
print(f"Error loading plugins: {e}") # noqa: T201
|
|
111
|
+
traceback.print_exc()
|
|
112
|
+
|
|
113
|
+
def apply_methods_to_instance(self, instance: object) -> None:
|
|
114
|
+
"""Apply all registered methods to an instance."""
|
|
115
|
+
class_name = instance.__class__.__name__
|
|
116
|
+
methods = self._classes.get(class_name, {})
|
|
117
|
+
for name, method in methods.items():
|
|
118
|
+
bound = types.MethodType(method, instance)
|
|
119
|
+
setattr(instance, name, bound)
|
|
120
|
+
|
|
121
|
+
def apply_extensions_to_function_result(self, wrapper: object, function_name: str) -> None:
|
|
122
|
+
"""Apply registered extensions to a function result wrapper."""
|
|
123
|
+
for name, method in self._functions.get(function_name, {}).items():
|
|
124
|
+
|
|
125
|
+
def create_extension(wrap: object, extension_method: Callable[..., None], method_name: str) -> None:
|
|
126
|
+
def extension_wrapper(*args: tuple, **kwargs: dict) -> object:
|
|
127
|
+
return extension_method(wrap._result, *args, **kwargs)
|
|
128
|
+
|
|
129
|
+
extension_wrapper.__name__ = method_name
|
|
130
|
+
return extension_wrapper
|
|
131
|
+
|
|
132
|
+
extension = create_extension(wrapper, method, name)
|
|
133
|
+
setattr(wrapper, name, extension)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ExtensibleFunctionResult:
|
|
137
|
+
"""Wrapper for function results that can have extension methods."""
|
|
138
|
+
|
|
139
|
+
def __init__(self, result: object, function_name: str) -> None:
|
|
140
|
+
"""Initialize a FunctionResultWrapper."""
|
|
141
|
+
self._result = result
|
|
142
|
+
self._function_name = function_name
|
|
143
|
+
plugin_manager.apply_extensions_to_function_result(self, function_name)
|
|
144
|
+
|
|
145
|
+
def __getattr__(self, name: str) -> object:
|
|
146
|
+
"""Delegate attribute access to the wrapped result."""
|
|
147
|
+
if hasattr(self._result, name):
|
|
148
|
+
return getattr(self._result, name)
|
|
149
|
+
type_name = type(self._result).__name__
|
|
150
|
+
message = f"'{type_name}' object has no attribute '{name}'"
|
|
151
|
+
raise AttributeError(message)
|
|
152
|
+
|
|
153
|
+
def __repr__(self) -> str:
|
|
154
|
+
"""Represent the wrapper as the wrapped result."""
|
|
155
|
+
return repr(self._result)
|
|
156
|
+
|
|
157
|
+
def __str__(self) -> str:
|
|
158
|
+
"""Convert the wrapper to string as the wrapped result."""
|
|
159
|
+
return str(self._result)
|
|
160
|
+
|
|
161
|
+
def __iter__(self) -> None | Iterator:
|
|
162
|
+
"""Return an iterator if the result supports iteration."""
|
|
163
|
+
if hasattr(self._result, "__iter__"):
|
|
164
|
+
return iter(self._result)
|
|
165
|
+
type_name = type(self._result).__name__
|
|
166
|
+
message = f"'{type_name}' object is not iterable"
|
|
167
|
+
raise TypeError(message)
|
|
168
|
+
|
|
169
|
+
def __getitem__(self, key: object) -> object:
|
|
170
|
+
"""Support indexing if the wrapped result supports it."""
|
|
171
|
+
if not hasattr(self._result, "__getitem__"):
|
|
172
|
+
type_name = type(self._result).__name__
|
|
173
|
+
message = f"'{type_name}' object does not support indexing"
|
|
174
|
+
raise TypeError(message)
|
|
175
|
+
return self._result[key]
|
|
176
|
+
|
|
177
|
+
def __len__(self) -> int:
|
|
178
|
+
"""Return the length of the wrapped result if it has one."""
|
|
179
|
+
if not hasattr(self._result, "__len__"):
|
|
180
|
+
type_name = type(self._result).__name__
|
|
181
|
+
message = f"'{type_name}' object has no len()"
|
|
182
|
+
raise TypeError(message)
|
|
183
|
+
return len(self._result)
|
|
184
|
+
|
|
185
|
+
def __add__(self, other: object) -> object:
|
|
186
|
+
"""Support addition if the wrapped result supports it."""
|
|
187
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
188
|
+
return self._result + other._result
|
|
189
|
+
return self._result + other
|
|
190
|
+
|
|
191
|
+
def __radd__(self, other: object) -> object:
|
|
192
|
+
"""Support right addition if the wrapped result supports it."""
|
|
193
|
+
return other + self._result
|
|
194
|
+
|
|
195
|
+
def __mul__(self, other: object) -> object:
|
|
196
|
+
"""Support multiplication if the wrapped result supports it."""
|
|
197
|
+
return self._result * other
|
|
198
|
+
|
|
199
|
+
def __rmul__(self, other: object) -> object:
|
|
200
|
+
"""Support right multiplication if the wrapped result supports it."""
|
|
201
|
+
return other * self._result
|
|
202
|
+
|
|
203
|
+
def __eq__(self, other: object) -> bool:
|
|
204
|
+
"""Support equality comparison."""
|
|
205
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
206
|
+
return self._result == other._result
|
|
207
|
+
return self._result == other
|
|
208
|
+
|
|
209
|
+
def __hash__(self) -> int:
|
|
210
|
+
"""Support hashing by delegating to the wrapped result."""
|
|
211
|
+
return hash(self._result)
|
|
212
|
+
|
|
213
|
+
def __lt__(self, other: object) -> bool:
|
|
214
|
+
"""Support less than comparison."""
|
|
215
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
216
|
+
return self._result < other._result
|
|
217
|
+
return self._result < other
|
|
218
|
+
|
|
219
|
+
def __gt__(self, other: object) -> bool:
|
|
220
|
+
"""Support greater than comparison."""
|
|
221
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
222
|
+
return self._result > other._result
|
|
223
|
+
return self._result > other
|
|
224
|
+
|
|
225
|
+
def __le__(self, other: object) -> bool:
|
|
226
|
+
"""Support less than or equal comparison."""
|
|
227
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
228
|
+
return self._result <= other._result
|
|
229
|
+
return self._result <= other
|
|
230
|
+
|
|
231
|
+
def __ge__(self, other: object) -> bool:
|
|
232
|
+
"""Support greater than or equal comparison."""
|
|
233
|
+
if isinstance(other, ExtensibleFunctionResult):
|
|
234
|
+
return self._result >= other._result
|
|
235
|
+
return self._result >= other
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
plugin_manager = PluginManager()
|
|
File without changes
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Heavy-Medium-Light (HML) Segmentation for Customer Value Classification.
|
|
2
|
+
|
|
3
|
+
## Business Context
|
|
4
|
+
|
|
5
|
+
The 80/20 rule dominates retail customer behavior: typically 20% of customers generate 80%
|
|
6
|
+
of revenue. HML segmentation formalizes this insight by classifying customers into Heavy,
|
|
7
|
+
Medium, Light, and Zero spenders, enabling targeted strategies for each value tier.
|
|
8
|
+
|
|
9
|
+
## The Business Problem
|
|
10
|
+
|
|
11
|
+
All customers are not equal, but many retailers treat them the same way. Marketing budgets
|
|
12
|
+
are wasted on low-value customers while high-value customers don't receive appropriate
|
|
13
|
+
attention. Without clear customer value classification, businesses struggle to:
|
|
14
|
+
- Allocate marketing spend effectively
|
|
15
|
+
- Design appropriate service levels
|
|
16
|
+
- Create relevant offers for different customer types
|
|
17
|
+
- Identify at-risk high-value customers
|
|
18
|
+
|
|
19
|
+
## Real-World Applications
|
|
20
|
+
|
|
21
|
+
### Heavy Spenders (Top ~20%)
|
|
22
|
+
- VIP programs with exclusive access and premium support
|
|
23
|
+
- Personalized shopping experiences and dedicated account management
|
|
24
|
+
- Early access to new products and sales
|
|
25
|
+
- Higher-value promotional offers and loyalty rewards
|
|
26
|
+
|
|
27
|
+
### Medium Spenders (Middle ~30%)
|
|
28
|
+
- Growth-focused marketing to move them toward Heavy tier
|
|
29
|
+
- Category expansion offers to increase wallet share
|
|
30
|
+
- Loyalty programs designed to increase purchase frequency
|
|
31
|
+
- Targeted promotions based on purchase history
|
|
32
|
+
|
|
33
|
+
### Light Spenders (Lower ~50%)
|
|
34
|
+
- Cost-effective digital marketing channels
|
|
35
|
+
- Basic loyalty programs and promotional offers
|
|
36
|
+
- Automated email campaigns for reactivation
|
|
37
|
+
- Focus on retention rather than acquisition costs
|
|
38
|
+
|
|
39
|
+
### Zero Spenders
|
|
40
|
+
- Win-back campaigns for previously active customers
|
|
41
|
+
- Low-cost reactivation offers
|
|
42
|
+
- Analysis for churn prevention insights
|
|
43
|
+
- Potential customer file purging for database hygiene
|
|
44
|
+
|
|
45
|
+
This module extends ThresholdSegmentation to implement the standard HML classification
|
|
46
|
+
using Pareto-based percentile thresholds for consistent, business-relevant segments.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from typing import Literal
|
|
50
|
+
|
|
51
|
+
import ibis
|
|
52
|
+
import pandas as pd
|
|
53
|
+
|
|
54
|
+
from openretailscience.segmentation.threshold import ThresholdSegmentation
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HMLSegmentation(ThresholdSegmentation):
|
|
58
|
+
"""Segments customers into Heavy, Medium, Light and Zero spenders based on the total spend."""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
df: pd.DataFrame | ibis.Table,
|
|
63
|
+
value_col: str | None = None,
|
|
64
|
+
agg_func: str = "sum",
|
|
65
|
+
zero_value_customers: Literal["separate_segment", "exclude", "include_with_light"] = "separate_segment",
|
|
66
|
+
group_col: str | list[str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Segments customers into Heavy, Medium, Light and Zero spenders based on the total spend.
|
|
69
|
+
|
|
70
|
+
HMLSegmentation is a subclass of ThresholdSegmentation and based around an industry standard definition. The
|
|
71
|
+
thresholds for Heavy (top 20%), Medium (next 30%) and Light (bottom 50%) are chosen based on the pareto
|
|
72
|
+
distribution, commonly know as the 80/20 rule. It is typically used in retail to segment customers based on
|
|
73
|
+
their spend, transaction volume or quantities purchased.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
df (pd.DataFrame | ibis.Table): A dataframe with the transaction data.
|
|
77
|
+
The dataframe must contain a customer_id column.
|
|
78
|
+
value_col (str, optional): The column to use for the segmentation.
|
|
79
|
+
Defaults to get_option("column.unit_spend").
|
|
80
|
+
agg_func (str, optional): The aggregation function to use when grouping by customer_id. Defaults to "sum".
|
|
81
|
+
zero_value_customers (Literal["separate_segment", "exclude", "include_with_light"], optional): How to handle
|
|
82
|
+
customers with zero spend. Defaults to "separate_segment".
|
|
83
|
+
group_col (str | list[str] | None, optional): Column(s) to group by when calculating segments. When
|
|
84
|
+
specified, segments are calculated within each group independently. For example, setting
|
|
85
|
+
group_col="store_id" calculates Heavy/Medium/Light segments within each store. Defaults to None.
|
|
86
|
+
"""
|
|
87
|
+
thresholds = [0.500, 0.800, 1]
|
|
88
|
+
segments = ["Light", "Medium", "Heavy"]
|
|
89
|
+
super().__init__(
|
|
90
|
+
df=df,
|
|
91
|
+
value_col=value_col,
|
|
92
|
+
agg_func=agg_func,
|
|
93
|
+
thresholds=thresholds,
|
|
94
|
+
segments=segments,
|
|
95
|
+
zero_value_customers=zero_value_customers,
|
|
96
|
+
group_col=group_col,
|
|
97
|
+
)
|