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,218 @@
1
+ """New-Lapsed-Repeating (NLR) Segmentation for Customer Lifecycle Classification.
2
+
3
+ ## Business Context
4
+
5
+ Understanding customer lifecycle stages is fundamental to retail strategy. Customers naturally
6
+ move between periods of activity and inactivity, and identifying these transitions enables
7
+ targeted interventions at each stage.
8
+
9
+ ## The Business Problem
10
+
11
+ Retailers need to understand which customers are growing the base (new), which are loyal
12
+ (repeating), and which have stopped purchasing (lapsed). Without this classification,
13
+ marketing budgets are misallocated — spending acquisition dollars on existing customers
14
+ or ignoring at-risk customers who are about to churn.
15
+
16
+ ## Segment Definitions
17
+
18
+ Given two time periods (P1 and P2), customers are classified based on where they have
19
+ a positive aggregated value:
20
+
21
+ - **New**: Positive value in P2 only — these customers were acquired in the later period
22
+ - **Repeating**: Positive value in both P1 and P2 — these customers are retained
23
+ - **Lapsed**: Positive value in P1 only — these customers have stopped purchasing
24
+
25
+ A customer must have a positive aggregated value (> 0) in a period to be considered as
26
+ having "bought" in that period. Zero or negative values do not count. Customers with
27
+ no positive value in either period are excluded from the results entirely, as they do
28
+ not meet the criteria for any segment. When using non-spend aggregation functions like
29
+ ``count`` or ``nunique``, the threshold still applies but measures transaction presence
30
+ rather than spend.
31
+
32
+ ## Real-World Applications
33
+
34
+ ### New Customers
35
+ - Measure acquisition effectiveness period-over-period
36
+ - Design onboarding journeys to convert first-time buyers to repeat customers
37
+ - Track new customer quality (spend levels, category breadth)
38
+
39
+ ### Repeating Customers
40
+ - Core loyal base driving consistent revenue
41
+ - Cross-sell and upsell opportunities
42
+ - Loyalty program engagement and reward optimization
43
+
44
+ ### Lapsed Customers
45
+ - Win-back campaigns with targeted incentives
46
+ - Churn root cause analysis (price sensitivity, assortment gaps)
47
+ - Customer lifetime value recalculation and reactivation ROI modeling
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ import ibis
53
+ import pandas as pd
54
+
55
+ from openretailscience.options import ColumnHelper
56
+
57
+ SEGMENT_NEW = "New"
58
+ SEGMENT_REPEATING = "Repeating"
59
+ SEGMENT_LAPSED = "Lapsed"
60
+
61
+ _VALID_AGG_FUNCS = {"sum", "mean", "max", "count", "nunique"}
62
+
63
+
64
+ class NLRSegmentation:
65
+ """Segments customers into New, Repeating, and Lapsed based on presence across two periods.
66
+
67
+ NLRSegmentation compares customer purchasing activity across two defined time periods (P1 and P2) to classify
68
+ each customer's lifecycle stage. A customer is considered active in a period only if their aggregated value is
69
+ strictly positive (> 0). This enables retailers to measure acquisition, retention, and churn rates in a single
70
+ view, and to size the revenue impact of each lifecycle segment.
71
+
72
+ The segmentation is commonly used in period-over-period reporting (e.g., year-over-year, quarter-over-quarter)
73
+ to answer questions such as how many customers were retained, how many were lost, and how many are newly acquired.
74
+ When combined with spend data, it reveals whether revenue growth is driven by new customer acquisition or by
75
+ increasing spend from repeating customers.
76
+
77
+ Attributes:
78
+ table (ibis.Table): The underlying ibis Table expression containing the segmentation results.
79
+ Can be used for further ibis operations before materializing to a DataFrame.
80
+ df (pd.DataFrame): The materialized segmentation results as a pandas DataFrame, indexed by
81
+ customer_id (and group_col if specified). Cached after first access.
82
+
83
+ Example:
84
+ >>> import pandas as pd
85
+ >>> from openretailscience.segmentation.nlr import NLRSegmentation
86
+ >>> df = pd.DataFrame({
87
+ ... "customer_id": [1, 2, 3, 1, 3, 4],
88
+ ... "unit_spend": [50.0, 100.0, 80.0, 75.0, 60.0, 150.0],
89
+ ... "year": [2023, 2023, 2023, 2024, 2024, 2024],
90
+ ... })
91
+ >>> seg = NLRSegmentation(df=df, period_col="year", p1_value=2023, p2_value=2024)
92
+ >>> seg.df[["segment_name"]]
93
+ segment_name
94
+ customer_id
95
+ 1 Repeating
96
+ 2 Lapsed
97
+ 3 Repeating
98
+ 4 New
99
+ """
100
+
101
+ def __init__(
102
+ self,
103
+ df: pd.DataFrame | ibis.Table,
104
+ period_col: str,
105
+ p1_value: str | float | ibis.Scalar,
106
+ p2_value: str | float | ibis.Scalar,
107
+ value_col: str | None = None,
108
+ agg_func: str = "sum",
109
+ group_col: str | list[str] | None = None,
110
+ ) -> None:
111
+ """Segments customers into New, Repeating, and Lapsed based on positive aggregated value across two periods.
112
+
113
+ A customer is considered to have "bought" in a period only if their aggregated value_col
114
+ is strictly positive (> 0). Customers are then classified as:
115
+ - New: positive value in P2 only
116
+ - Repeating: positive value in both P1 and P2
117
+ - Lapsed: positive value in P1 only
118
+
119
+ Args:
120
+ df (pd.DataFrame | ibis.Table): Transaction data. Must contain customer_id, period_col,
121
+ and value_col columns.
122
+ period_col (str): Column containing period identifiers.
123
+ p1_value (str | float | ibis.Scalar): Value in period_col identifying period 1.
124
+ p2_value (str | float | ibis.Scalar): Value in period_col identifying period 2.
125
+ value_col (str | None, optional): Column to aggregate for determining customer activity.
126
+ Defaults to ColumnHelper().unit_spend.
127
+ agg_func (str, optional): Aggregation function to use when grouping by customer_id.
128
+ Defaults to "sum".
129
+ group_col (str | list[str] | None, optional): Column(s) to group by when calculating segments.
130
+ When specified, segments are calculated within each group independently.
131
+ Defaults to None.
132
+
133
+ Raises:
134
+ ValueError: If required columns are missing from the DataFrame, or if agg_func is not
135
+ a supported aggregation function.
136
+ """
137
+ self._df: pd.DataFrame | None = None
138
+
139
+ cols = ColumnHelper()
140
+ value_col = cols.unit_spend if value_col is None else value_col
141
+
142
+ self._group_col: list[str] | None = [group_col] if isinstance(group_col, str) else group_col
143
+
144
+ if isinstance(df, pd.DataFrame):
145
+ df = ibis.memtable(df)
146
+
147
+ # Validate required columns
148
+ required_cols = [cols.customer_id, value_col, period_col]
149
+ if self._group_col is not None:
150
+ required_cols.extend(self._group_col)
151
+
152
+ missing_cols = set(required_cols) - set(df.columns)
153
+ if len(missing_cols) > 0:
154
+ msg = f"The following columns are required but missing: {missing_cols}"
155
+ raise ValueError(msg)
156
+
157
+ p1_expr = p1_value if isinstance(p1_value, ibis.Expr) else ibis.literal(p1_value)
158
+ p2_expr = p2_value if isinstance(p2_value, ibis.Expr) else ibis.literal(p2_value)
159
+ if p1_expr.equals(p2_expr):
160
+ msg = f"p1_value and p2_value must be different, got '{p1_value}' for both"
161
+ raise ValueError(msg)
162
+
163
+ if agg_func not in _VALID_AGG_FUNCS:
164
+ msg = f"agg_func must be one of {sorted(_VALID_AGG_FUNCS)}, got '{agg_func}'"
165
+ raise ValueError(msg)
166
+
167
+ # Filter to only P1 and P2 rows
168
+ df = df.filter((df[period_col] == p1_value) | (df[period_col] == p2_value))
169
+
170
+ # Determine which periods each customer has positive spend in
171
+ group_cols = [cols.customer_id]
172
+ if self._group_col is not None:
173
+ group_cols.extend(self._group_col)
174
+
175
+ p1_col = f"{value_col}_p1"
176
+ p2_col = f"{value_col}_p2"
177
+
178
+ agg_method = getattr(df[value_col], agg_func)
179
+ p1_agg = agg_method(where=df[period_col] == p1_value)
180
+ p2_agg = agg_method(where=df[period_col] == p2_value)
181
+ customer = df.group_by(*group_cols).aggregate(
182
+ **{
183
+ p1_col: p1_agg.fill_null(0),
184
+ p2_col: p2_agg.fill_null(0),
185
+ },
186
+ )
187
+
188
+ # Exclude customers with no positive value in either period
189
+ customer = customer.filter((customer[p1_col] > 0) | (customer[p2_col] > 0))
190
+
191
+ # Classify: both periods -> Repeating, P1 only -> Lapsed, P2 only -> New
192
+ # Use of ifelse ensures compatibility with some ibis backends that do not support boolean expressions in cases
193
+ # statements
194
+ in_p1 = (customer[p1_col] > 0).ifelse(1, 0)
195
+ in_p2 = (customer[p2_col] > 0).ifelse(1, 0)
196
+ segment_expr = ibis.cases(
197
+ ((in_p1 == 1) & (in_p2 == 1), SEGMENT_REPEATING),
198
+ (in_p1 == 1, SEGMENT_LAPSED),
199
+ (in_p2 == 1, SEGMENT_NEW),
200
+ )
201
+
202
+ self.table: ibis.Table = customer.mutate(segment_name=segment_expr).select(
203
+ *group_cols,
204
+ "segment_name",
205
+ p1_col,
206
+ p2_col,
207
+ )
208
+
209
+ @property
210
+ def df(self) -> pd.DataFrame:
211
+ """Returns the DataFrame with segment names, indexed by customer_id (and group_col if specified)."""
212
+ if self._df is None:
213
+ cols = ColumnHelper()
214
+ index_cols = [cols.customer_id]
215
+ if self._group_col is not None:
216
+ index_cols.extend(self._group_col)
217
+ self._df = self.table.execute().set_index(index_cols)
218
+ return self._df
@@ -0,0 +1,307 @@
1
+ """Customer Segmentation Using RFM Analysis.
2
+
3
+ This module implements RFM (Recency, Frequency, Monetary) segmentation, a widely used technique in customer analytics
4
+ to categorize customers based on their purchasing behavior.
5
+
6
+ RFM segmentation assigns scores to customers based on:
7
+ 1. Recency (R): How recently a customer made a purchase.
8
+ 2. Frequency (F): How often a customer makes purchases.
9
+ 3. Monetary (M): The total amount spent by a customer.
10
+
11
+ ### Benefits of RFM Segmentation:
12
+ - **Customer Value Analysis**: Identifies high-value customers who contribute the most revenue.
13
+ - **Personalized Marketing**: Enables targeted campaigns based on customer purchasing behavior.
14
+ - **Customer Retention Strategies**: Helps recognize at-risk customers and develop engagement strategies.
15
+ - **Sales Forecasting**: Provides insights into future revenue trends based on past spending behavior.
16
+
17
+ ### Scoring Methodology:
18
+ - Each metric (R, F, M) is divided into 10 bins (0-9) using the NTILE(10) function.
19
+ - A higher score indicates a better customer (e.g., lower recency, higher frequency, and monetary value).
20
+ - The final RFM segment is computed as `R*100 + F*10 + M`, providing a unique customer classification.
21
+
22
+ This module leverages `pandas` and `ibis` for efficient data processing and integrates with retail analytics workflows
23
+ to enhance customer insights and business decision-making.
24
+ """
25
+
26
+ import datetime
27
+
28
+ import ibis
29
+ import pandas as pd
30
+
31
+ from openretailscience.options import ColumnHelper, get_option
32
+ from openretailscience.utils.validation import ensure_ibis_table
33
+
34
+
35
+ class RFMSegmentation:
36
+ """Segments customers using the RFM (Recency, Frequency, Monetary) methodology.
37
+
38
+ Customers are scored on three dimensions:
39
+ - Recency (R): Days since the last transaction (lower is better).
40
+ - Frequency (F): Number of unique transactions (higher is better).
41
+ - Monetary (M): Total amount spent (higher is better).
42
+
43
+ Each metric is ranked into bins using either NTILE or custom cut points where,
44
+ - The highest score represents the best score (top percentile of customers).
45
+ - The lowest score represents the lowest score (bottom percentile of customers).
46
+ The RFM segment is a 3-digit number (R*100 + F*10 + M), representing customer value.
47
+ """
48
+
49
+ def __init__(
50
+ self,
51
+ df: pd.DataFrame | ibis.Table,
52
+ current_date: str | datetime.date | None = None,
53
+ r_segments: int | list[float] = 10,
54
+ f_segments: int | list[float] = 10,
55
+ m_segments: int | list[float] = 10,
56
+ min_monetary: float | None = None,
57
+ max_monetary: float | None = None,
58
+ min_frequency: int | None = None,
59
+ max_frequency: int | None = None,
60
+ ) -> None:
61
+ """Initializes the RFM segmentation process.
62
+
63
+ Args:
64
+ df (pd.DataFrame | ibis.Table): A DataFrame or Ibis table containing transaction data.
65
+ Must include the following columns:
66
+ - customer_id
67
+ - transaction_date
68
+ - unit_spend
69
+ - transaction_id
70
+ current_date (Optional[Union[str, datetime.date]]): The reference date for calculating recency.
71
+ Can be a string (format: "YYYY-MM-DD"), a date object, or None (defaults to the current system date).
72
+ r_segments (Union[int, list[float]], optional): Number of bins (1-10) or custom percentile
73
+ cut points (max 9 cut points). Defaults to 10 bins.
74
+ f_segments (Union[int, list[float]], optional): Number of bins (1-10) or custom percentile
75
+ cut points (max 9 cut points). Defaults to 10 bins.
76
+ m_segments (Union[int, list[float]], optional): Number of bins (1-10) or custom percentile
77
+ cut points (max 9 cut points). Defaults to 10 bins.
78
+ min_monetary (Optional[float]): Minimum monetary value to include in segmentation.
79
+ Customers with total spend below this value will be excluded from the analysis.
80
+ max_monetary (Optional[float]): Maximum monetary value to include in segmentation.
81
+ Customers with total spend above this value will be excluded from the analysis.
82
+ min_frequency (Optional[int]): Minimum purchase frequency to include in segmentation.
83
+ Customers with fewer transactions will be excluded from the analysis.
84
+ max_frequency (Optional[int]): Maximum purchase frequency to include in segmentation.
85
+ Customers with more transactions will be excluded from the analysis.
86
+
87
+ Raises:
88
+ ValueError: If the dataframe is missing required columns, invalid segment parameters,
89
+ or invalid filter parameters.
90
+ TypeError: If the input data is not a pandas DataFrame or an Ibis Table.
91
+ """
92
+ self._df: pd.DataFrame | None = None
93
+ cols = ColumnHelper()
94
+ required_cols = [
95
+ cols.customer_id,
96
+ cols.transaction_date,
97
+ cols.unit_spend,
98
+ cols.transaction_id,
99
+ ]
100
+ df = ensure_ibis_table(df)
101
+
102
+ missing_cols = set(required_cols) - set(df.columns)
103
+ if missing_cols:
104
+ error_message = f"Missing required columns: {missing_cols}"
105
+ raise ValueError(error_message)
106
+
107
+ if isinstance(current_date, str):
108
+ current_date = datetime.date.fromisoformat(current_date)
109
+ elif current_date is None:
110
+ current_date = datetime.datetime.now(datetime.timezone.utc).date()
111
+ elif not isinstance(current_date, datetime.date):
112
+ raise TypeError("current_date must be a string in 'YYYY-MM-DD' format, a datetime.date object, or None")
113
+
114
+ self._validate_segments(r_segments, "r_segments")
115
+ self._validate_segments(f_segments, "f_segments")
116
+ self._validate_segments(m_segments, "m_segments")
117
+ self._validate_monetary_filters(min_monetary, max_monetary)
118
+ self._validate_frequency_filters(min_frequency, max_frequency)
119
+
120
+ self.r_segments = r_segments
121
+ self.f_segments = f_segments
122
+ self.m_segments = m_segments
123
+ self.min_monetary = min_monetary
124
+ self.max_monetary = max_monetary
125
+ self.min_frequency = min_frequency
126
+ self.max_frequency = max_frequency
127
+
128
+ self.table = self._compute_rfm(df, current_date)
129
+
130
+ def _validate_segments(self, segments: int | list[float], param_name: str) -> None:
131
+ """Validates segment parameters.
132
+
133
+ Args:
134
+ segments: The segment parameter to validate
135
+ param_name: Name of the parameter for error messages
136
+
137
+ Raises:
138
+ ValueError: If segment parameters are invalid
139
+ """
140
+ max_segments_int = 10
141
+ max_segments_list = 9
142
+ max_segments = 1
143
+
144
+ if isinstance(segments, int):
145
+ if segments < max_segments or segments > max_segments_int:
146
+ msg = f"{param_name} must be between {max_segments} and {max_segments_int} when specified as an integer"
147
+ raise ValueError(msg)
148
+ elif isinstance(segments, list):
149
+ if len(segments) == 0 or len(segments) > max_segments_list:
150
+ msg = (
151
+ f"{param_name} must contain between {max_segments} and"
152
+ f" {max_segments_list} cut points when specified as a list"
153
+ )
154
+ raise ValueError(msg)
155
+ if not all(isinstance(x, int | float) for x in segments): # UP038
156
+ msg = f"All cut points in {param_name} must be numeric"
157
+ raise ValueError(msg)
158
+ if not all(0 <= x <= 1 for x in segments):
159
+ msg = f"All cut points in {param_name} must be between 0 and 1"
160
+ raise ValueError(msg)
161
+ if len(segments) != len(set(segments)):
162
+ msg = f"Cut points in {param_name} must be unique"
163
+ raise ValueError(msg)
164
+ if segments != sorted(segments):
165
+ msg = f"Cut points in {param_name} must be in ascending order"
166
+ raise ValueError(msg)
167
+ else:
168
+ msg = f"{param_name} must be an integer or a list of floats"
169
+ raise TypeError(msg)
170
+
171
+ def _validate_monetary_filters(self, min_monetary: float | None, max_monetary: float | None) -> None:
172
+ if min_monetary is not None:
173
+ if not isinstance(min_monetary, int | float):
174
+ raise TypeError("min_monetary must be a numeric value")
175
+ if min_monetary < 0:
176
+ raise ValueError("min_monetary must be non-negative")
177
+
178
+ if max_monetary is not None:
179
+ if not isinstance(max_monetary, int | float):
180
+ raise TypeError("max_monetary must be a numeric value")
181
+ if max_monetary < 0:
182
+ raise ValueError("max_monetary must be non-negative")
183
+
184
+ if min_monetary is not None and max_monetary is not None and min_monetary >= max_monetary:
185
+ raise ValueError("min_monetary must be less than max_monetary")
186
+
187
+ def _validate_frequency_filters(self, min_frequency: float | None, max_frequency: float | None) -> None:
188
+ if min_frequency is not None:
189
+ if not isinstance(min_frequency, int):
190
+ raise TypeError("min_frequency must be an integer")
191
+ if min_frequency < 1:
192
+ raise ValueError("min_frequency must be at least 1")
193
+
194
+ if max_frequency is not None:
195
+ if not isinstance(max_frequency, int):
196
+ raise TypeError("max_frequency must be an integer")
197
+ if max_frequency < 1:
198
+ raise ValueError("max_frequency must be at least 1")
199
+
200
+ if min_frequency is not None and max_frequency is not None and min_frequency > max_frequency:
201
+ raise ValueError("min_frequency must be less than or equal to max_frequency")
202
+
203
+ def _compute_rfm(self, df: ibis.Table, current_date: datetime.date) -> ibis.Table:
204
+ """Computes the RFM metrics and segments customers accordingly.
205
+
206
+ Args:
207
+ df (ibis.Table): The transaction data table.
208
+ current_date (datetime.date): The reference date for calculating recency.
209
+
210
+ Returns:
211
+ ibis.Table: A table with RFM scores and segment values.
212
+ """
213
+ cols = ColumnHelper()
214
+ current_date_expr = ibis.literal(current_date)
215
+
216
+ customer_metrics = df.group_by(cols.customer_id).aggregate(
217
+ recency_days=current_date_expr.delta(df[cols.transaction_date].max().cast("date"), unit="day").cast(
218
+ "int32",
219
+ ),
220
+ frequency=df[cols.transaction_id].nunique(),
221
+ monetary=df[cols.unit_spend].sum(),
222
+ )
223
+
224
+ filtered_metrics = self._apply_filters(customer_metrics)
225
+
226
+ rfm_scores = filtered_metrics.mutate(
227
+ r_score=self._compute_score(filtered_metrics, "recency_days", self.r_segments, ascending=False),
228
+ f_score=self._compute_score(filtered_metrics, "frequency", self.f_segments, ascending=True),
229
+ m_score=self._compute_score(filtered_metrics, "monetary", self.m_segments, ascending=True),
230
+ )
231
+
232
+ return rfm_scores.mutate(
233
+ rfm_segment=(rfm_scores.r_score * 100 + rfm_scores.f_score * 10 + rfm_scores.m_score),
234
+ fm_segment=(rfm_scores.f_score * 10 + rfm_scores.m_score),
235
+ )
236
+
237
+ def _apply_filters(self, customer_metrics: ibis.Table) -> ibis.Table:
238
+ """Applies the specified filters to the customer metrics.
239
+
240
+ Args:
241
+ customer_metrics: Table with customer metrics (recency_days, frequency, monetary)
242
+
243
+ Returns:
244
+ Filtered table containing only customers meeting all filter criteria
245
+ """
246
+ filter_configs = [
247
+ ("monetary", self.min_monetary, self.max_monetary),
248
+ ("frequency", self.min_frequency, self.max_frequency),
249
+ ]
250
+
251
+ filtered_table = customer_metrics
252
+
253
+ for column_name, min_val, max_val in filter_configs:
254
+ if min_val is not None:
255
+ filtered_table = filtered_table.filter(filtered_table[column_name] >= min_val)
256
+
257
+ if max_val is not None:
258
+ filtered_table = filtered_table.filter(filtered_table[column_name] <= max_val)
259
+
260
+ return filtered_table
261
+
262
+ def _compute_score(
263
+ self,
264
+ table: ibis.Table,
265
+ column: str,
266
+ segments: int | list[float],
267
+ ascending: bool = True,
268
+ ) -> ibis.expr.types.IntegerColumn:
269
+ """Computes score for a given column using either NTILE or custom cut points.
270
+
271
+ Args:
272
+ table: The table containing the data
273
+ column: The column name to compute scores for
274
+ segments: Either number of bins or list of cut points
275
+ ascending: Whether lower values should get higher scores (for recency=False, for frequency/monetary=True)
276
+
277
+ Returns:
278
+ An Ibis expression representing the computed scores
279
+ """
280
+ order_fn = ibis.asc if ascending else ibis.desc
281
+ window = ibis.window(
282
+ order_by=[order_fn(table[column]), ibis.asc(table[ColumnHelper().customer_id])],
283
+ )
284
+
285
+ if isinstance(segments, int):
286
+ return ibis.ntile(segments).over(window)
287
+
288
+ percentile = table[column].percent_rank().over(window)
289
+
290
+ sorted_segments = sorted(segments)
291
+ case_expr = ibis.literal(0).cast("int32")
292
+
293
+ for i, cutpoint in enumerate(sorted_segments):
294
+ condition = percentile > ibis.literal(cutpoint)
295
+ case_expr = condition.ifelse(
296
+ ibis.literal(i + 1).cast("int32"),
297
+ case_expr,
298
+ )
299
+
300
+ return case_expr
301
+
302
+ @property
303
+ def df(self) -> pd.DataFrame:
304
+ """Returns the dataframe with the segment names."""
305
+ if self._df is None:
306
+ self._df = self.table.execute().set_index(get_option("column.customer_id"))
307
+ return self._df