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