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