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
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Cohort Analysis and User Segmentation.
|
|
2
|
+
|
|
3
|
+
This module implements functionality for performing cohort analysis, a powerful technique used in customer analytics
|
|
4
|
+
and retention strategies.
|
|
5
|
+
|
|
6
|
+
Cohort analysis helps in understanding customer behavior over time by grouping users based on shared characteristics
|
|
7
|
+
or experiences, such as sign-up date, first purchase, or marketing campaign interaction. This method provides
|
|
8
|
+
valuable insights into user engagement, retention, and lifetime value, which businesses can leverage in various ways:
|
|
9
|
+
|
|
10
|
+
1. Customer retention analysis: By tracking how different cohorts behave over time, businesses can identify trends
|
|
11
|
+
in user engagement and develop strategies to improve customer loyalty.
|
|
12
|
+
|
|
13
|
+
2. Marketing performance evaluation: Understanding how different user groups respond to marketing efforts helps in
|
|
14
|
+
optimizing campaigns for higher conversions and better ROI.
|
|
15
|
+
|
|
16
|
+
3. Product lifecycle insights: Analyzing user activity across cohorts can reveal product adoption trends and inform
|
|
17
|
+
feature development or enhancements.
|
|
18
|
+
|
|
19
|
+
4. Revenue forecasting: Cohort-based revenue tracking enables more accurate predictions of future earnings and
|
|
20
|
+
helps in financial planning.
|
|
21
|
+
|
|
22
|
+
5. Personalization and segmentation: Businesses can tailor their offerings based on cohort behavior to enhance
|
|
23
|
+
customer experience and increase retention rates.
|
|
24
|
+
|
|
25
|
+
The module employs key metrics such as retention rate, churn rate, and customer lifetime value (CLV) to measure
|
|
26
|
+
cohort performance and user engagement over time:
|
|
27
|
+
|
|
28
|
+
- Retention Rate: The percentage of users who continue to engage with a product or service over a given period.
|
|
29
|
+
- Churn Rate: The percentage of users who stop engaging with the product within a specific timeframe.
|
|
30
|
+
- Customer Lifetime Value (CLV): The predicted total revenue a customer will generate throughout their relationship
|
|
31
|
+
with the business.
|
|
32
|
+
|
|
33
|
+
By leveraging cohort analysis, businesses can make data-driven decisions to enhance customer experience, improve
|
|
34
|
+
marketing strategies, and drive long-term growth.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from typing import ClassVar
|
|
38
|
+
|
|
39
|
+
import ibis
|
|
40
|
+
import numpy as np
|
|
41
|
+
import pandas as pd
|
|
42
|
+
|
|
43
|
+
from openretailscience.options import ColumnHelper
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CohortAnalysis:
|
|
47
|
+
"""Class for performing cohort analysis and visualization."""
|
|
48
|
+
|
|
49
|
+
VALID_PERIODS: ClassVar[set[str]] = {"year", "quarter", "month", "week", "day"}
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
df: pd.DataFrame | ibis.Table,
|
|
54
|
+
aggregation_column: str,
|
|
55
|
+
agg_func: str = "nunique",
|
|
56
|
+
period: str = "month",
|
|
57
|
+
percentage: bool = False,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Initializes the Cohort Analysis object.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
df (pd.DataFrame | ibis.Table): The dataset containing transaction data.
|
|
63
|
+
aggregation_column (str): The column to apply the aggregation function on (e.g., 'unit_spend').
|
|
64
|
+
agg_func (str, optional): Aggregation function (e.g., "nunique", "sum", "mean"). Defaults to "nunique".
|
|
65
|
+
period (str): Period for cohort analysis (must be "year", "quarter", "month", "week", or "day").
|
|
66
|
+
percentage (bool): If True, converts cohort values into retention percentages relative to the first period.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ValueError: If `period` is not one of the allowed values.
|
|
70
|
+
ValueError: If `df` is missing required columns
|
|
71
|
+
(`customer_id`, `transaction_date`, or `aggregation_column`).
|
|
72
|
+
"""
|
|
73
|
+
cols = ColumnHelper()
|
|
74
|
+
|
|
75
|
+
if period not in self.VALID_PERIODS:
|
|
76
|
+
error_message = f"Invalid period '{period}'. Allowed values: {self.VALID_PERIODS}."
|
|
77
|
+
raise ValueError(error_message)
|
|
78
|
+
|
|
79
|
+
required_cols = [
|
|
80
|
+
cols.customer_id,
|
|
81
|
+
cols.transaction_date,
|
|
82
|
+
aggregation_column,
|
|
83
|
+
]
|
|
84
|
+
missing_cols = [col for col in required_cols if col not in df.columns]
|
|
85
|
+
|
|
86
|
+
if missing_cols:
|
|
87
|
+
error_message = f"Missing required columns: {missing_cols}"
|
|
88
|
+
raise ValueError(error_message)
|
|
89
|
+
|
|
90
|
+
self.df = self._calculate_cohorts(
|
|
91
|
+
df=df,
|
|
92
|
+
agg_func=agg_func,
|
|
93
|
+
period=period,
|
|
94
|
+
aggregation_column=aggregation_column,
|
|
95
|
+
percentage=percentage,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _fill_cohort_gaps(
|
|
99
|
+
self,
|
|
100
|
+
cohort_analysis_table: pd.DataFrame,
|
|
101
|
+
period: str,
|
|
102
|
+
) -> pd.DataFrame:
|
|
103
|
+
"""Fills gaps in the cohort analysis table for missing periods.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
cohort_analysis_table (pd.DataFrame): The cohort analysis table to fill gaps in.
|
|
107
|
+
period (str): The period of analysis (year, quarter, month, week, or day).
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
pd.DataFrame: Cohort table with missing periods filled.
|
|
111
|
+
"""
|
|
112
|
+
cohort_analysis_table.index = pd.to_datetime(cohort_analysis_table.index)
|
|
113
|
+
|
|
114
|
+
min_period = cohort_analysis_table.index.min()
|
|
115
|
+
max_period = cohort_analysis_table.index.max()
|
|
116
|
+
|
|
117
|
+
period_lookup = {"year": "YS", "quarter": "QS", "month": "MS", "week": "W", "day": "D"}
|
|
118
|
+
full_range = pd.date_range(start=min_period, end=max_period, freq=period_lookup[period])
|
|
119
|
+
cohort_analysis_table = cohort_analysis_table.reindex(full_range, fill_value=0)
|
|
120
|
+
if cohort_analysis_table.shape[1] > 0:
|
|
121
|
+
max_period_since = cohort_analysis_table.columns.max()
|
|
122
|
+
all_periods = range(max_period_since + 1)
|
|
123
|
+
cohort_analysis_table = cohort_analysis_table.reindex(columns=all_periods, fill_value=0)
|
|
124
|
+
return cohort_analysis_table
|
|
125
|
+
|
|
126
|
+
def _calculate_cohorts(
|
|
127
|
+
self,
|
|
128
|
+
df: pd.DataFrame | ibis.Table,
|
|
129
|
+
aggregation_column: str,
|
|
130
|
+
agg_func: str = "nunique",
|
|
131
|
+
period: str = "month",
|
|
132
|
+
percentage: bool = False,
|
|
133
|
+
) -> pd.DataFrame:
|
|
134
|
+
"""Computes a cohort analysis table based on transaction data.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
df (pd.DataFrame | ibis.Table): The dataset containing transaction data.
|
|
138
|
+
aggregation_column (str): The column to apply the aggregation function on (e.g., 'unit_spend').
|
|
139
|
+
agg_func (str, optional): Aggregation function (e.g., "nunique", "sum", "mean"). Defaults to "nunique".
|
|
140
|
+
period (str): Period for cohort analysis (must be "year", "quarter", "month", "week", or "day").
|
|
141
|
+
percentage (bool): If True, converts cohort values into retention percentages relative to the first period.
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
pd.DataFrame: Cohort analysis table with user retention values.
|
|
145
|
+
"""
|
|
146
|
+
cols = ColumnHelper()
|
|
147
|
+
|
|
148
|
+
ibis_table = ibis.memtable(df) if isinstance(df, pd.DataFrame) else df
|
|
149
|
+
|
|
150
|
+
filtered_table = ibis_table.mutate(
|
|
151
|
+
period_shopped=ibis_table[cols.transaction_date].truncate(period),
|
|
152
|
+
period_value=ibis_table[aggregation_column],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
customer_cohort = filtered_table.group_by(cols.customer_id).aggregate(
|
|
156
|
+
min_period_shopped=filtered_table.period_shopped.min(),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
cohort_table = (
|
|
160
|
+
filtered_table.join(customer_cohort, [cols.customer_id])
|
|
161
|
+
.group_by("min_period_shopped", "period_shopped")
|
|
162
|
+
.aggregate(period_value=getattr(filtered_table.period_value, agg_func)())
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
cohort_table = cohort_table.mutate(
|
|
166
|
+
period_since=cohort_table.period_shopped.delta(cohort_table.min_period_shopped, unit=period),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
cohort_df = cohort_table.execute().drop_duplicates(subset=["min_period_shopped", "period_since"])
|
|
170
|
+
|
|
171
|
+
cohort_analysis_table = cohort_df.pivot(
|
|
172
|
+
index="min_period_shopped",
|
|
173
|
+
columns="period_since",
|
|
174
|
+
values="period_value",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
if percentage:
|
|
178
|
+
first_period = cohort_analysis_table[0].replace(0, np.nan)
|
|
179
|
+
cohort_analysis_table = cohort_analysis_table.div(first_period, axis=0).round(2)
|
|
180
|
+
|
|
181
|
+
cohort_analysis_table = cohort_analysis_table.fillna(0)
|
|
182
|
+
cohort_analysis_table = self._fill_cohort_gaps(cohort_analysis_table, period)
|
|
183
|
+
cohort_analysis_table.index.name = "min_period_shopped"
|
|
184
|
+
|
|
185
|
+
return cohort_analysis_table
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Composite Rank Analysis Module for Multi-Factor Retail Decision Making.
|
|
2
|
+
|
|
3
|
+
## Business Context
|
|
4
|
+
|
|
5
|
+
In retail, critical decisions like product ranging, supplier selection, and store
|
|
6
|
+
performance evaluation require balancing multiple competing factors. A product might
|
|
7
|
+
have high sales but low margin, or a supplier might offer great prices but poor
|
|
8
|
+
delivery reliability. Composite ranking enables data-driven decisions by combining
|
|
9
|
+
multiple performance metrics into a single, actionable score.
|
|
10
|
+
|
|
11
|
+
## Real-World Applications
|
|
12
|
+
|
|
13
|
+
1. **Product Range Optimization**: Rank products for listing/delisting decisions based on:
|
|
14
|
+
- Sales velocity (units per week)
|
|
15
|
+
- Gross margin percentage
|
|
16
|
+
- Stock turn rate
|
|
17
|
+
- Customer satisfaction scores
|
|
18
|
+
- Return rates
|
|
19
|
+
|
|
20
|
+
2. **Supplier Performance Management**: Evaluate suppliers using:
|
|
21
|
+
- On-time delivery percentage
|
|
22
|
+
- Price competitiveness
|
|
23
|
+
- Quality scores (defect rates)
|
|
24
|
+
- Payment terms flexibility
|
|
25
|
+
- Order fill rates
|
|
26
|
+
|
|
27
|
+
3. **Store Performance Assessment**: Rank stores for investment decisions based on:
|
|
28
|
+
- Sales per square foot
|
|
29
|
+
- Conversion rates
|
|
30
|
+
- Labor productivity
|
|
31
|
+
- Customer satisfaction (NPS)
|
|
32
|
+
- Shrinkage rates
|
|
33
|
+
|
|
34
|
+
4. **Category Management**: Prioritize categories for space allocation using:
|
|
35
|
+
- Category growth rates
|
|
36
|
+
- Market share
|
|
37
|
+
- Profitability
|
|
38
|
+
- Cross-category purchase influence
|
|
39
|
+
- Seasonal consistency
|
|
40
|
+
|
|
41
|
+
## How It Works
|
|
42
|
+
|
|
43
|
+
The module creates individual rankings for each metric, then combines these rankings
|
|
44
|
+
using aggregation functions (mean, sum, min, max) to produce a final composite score.
|
|
45
|
+
This approach normalizes metrics with different scales and ensures each factor contributes
|
|
46
|
+
appropriately to the final decision.
|
|
47
|
+
|
|
48
|
+
## Business Value
|
|
49
|
+
|
|
50
|
+
- **Objective Decision Making**: Removes bias by systematically weighing all factors
|
|
51
|
+
- **Scalability**: Can evaluate thousands of products/stores/suppliers simultaneously
|
|
52
|
+
- **Transparency**: Clear methodology that stakeholders can understand and trust
|
|
53
|
+
- **Flexibility**: Different aggregation methods suit different business strategies
|
|
54
|
+
- **Actionable Output**: Direct ranking enables clear cut-off decisions
|
|
55
|
+
|
|
56
|
+
Key Features:
|
|
57
|
+
- Creates individual ranks for multiple columns with business metrics
|
|
58
|
+
- Supports both ascending and descending sort orders for each metric
|
|
59
|
+
- Combines individual ranks using business-appropriate aggregation functions
|
|
60
|
+
- Handles tie values for fair comparison
|
|
61
|
+
- Utilizes Ibis for efficient query execution on large retail datasets
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from typing import Any
|
|
65
|
+
|
|
66
|
+
import ibis
|
|
67
|
+
import ibis.expr.types as ir
|
|
68
|
+
import pandas as pd
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class CompositeRank:
|
|
72
|
+
"""Creates multi-factor composite rankings for retail decision-making.
|
|
73
|
+
|
|
74
|
+
The CompositeRank class enables retailers to make data-driven decisions by combining
|
|
75
|
+
multiple performance metrics into a single, actionable ranking. This is essential for
|
|
76
|
+
scenarios where no single metric tells the complete story.
|
|
77
|
+
|
|
78
|
+
## Business Problem Solved
|
|
79
|
+
|
|
80
|
+
Retailers face complex trade-offs daily: Should we keep the high-volume product with
|
|
81
|
+
low margins or the high-margin product with slow sales? Which supplier offers the best
|
|
82
|
+
overall value when considering price, quality, and reliability? This class provides a
|
|
83
|
+
systematic approach to these multi-dimensional decisions.
|
|
84
|
+
|
|
85
|
+
## Example Use Case: Product Range Review
|
|
86
|
+
|
|
87
|
+
When conducting quarterly range reviews, a retailer might rank products by:
|
|
88
|
+
- Sales performance (higher is better → descending order)
|
|
89
|
+
- Days of inventory (lower is better → ascending order)
|
|
90
|
+
- Customer rating (higher is better → descending order)
|
|
91
|
+
- Return rate (lower is better → ascending order)
|
|
92
|
+
|
|
93
|
+
The composite rank identifies products that perform well across ALL metrics, not just
|
|
94
|
+
excel in one area. Products with the best composite scores are clear "keep" decisions,
|
|
95
|
+
while those with the worst scores are candidates for delisting.
|
|
96
|
+
|
|
97
|
+
## Aggregation Strategies
|
|
98
|
+
|
|
99
|
+
Different business contexts require different aggregation approaches:
|
|
100
|
+
- **Mean**: Balanced scorecard approach, all factors equally important
|
|
101
|
+
- **Min**: Conservative approach, focus on worst-performing metric
|
|
102
|
+
- **Max**: Optimistic approach, highlight strength in any area
|
|
103
|
+
- **Sum**: Cumulative performance across all dimensions
|
|
104
|
+
|
|
105
|
+
## Actionable Outcomes
|
|
106
|
+
|
|
107
|
+
The composite rank directly supports decisions like:
|
|
108
|
+
- Top 20% composite rank → Increase inventory investment
|
|
109
|
+
- Bottom 20% composite rank → Consider delisting or markdown
|
|
110
|
+
- Middle 60% → Maintain current strategy, monitor for changes
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def __init__(
|
|
114
|
+
self,
|
|
115
|
+
df: pd.DataFrame | ibis.Table,
|
|
116
|
+
rank_cols: list[tuple[str, str] | str],
|
|
117
|
+
agg_func: str,
|
|
118
|
+
ignore_ties: bool = False,
|
|
119
|
+
group_col: str | list[str] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Initialize the CompositeRank class for multi-criteria retail analysis.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
df (pd.DataFrame | ibis.Table): Product, store, or supplier performance data.
|
|
125
|
+
rank_cols (List[Union[Tuple[str, str], str]]): Metrics to rank with their optimization direction.
|
|
126
|
+
Examples for product ranging:
|
|
127
|
+
- ("sales_units", "desc") - Higher sales are better
|
|
128
|
+
- ("days_inventory", "asc") - Lower inventory days are better
|
|
129
|
+
- ("margin_pct", "desc") - Higher margins are better
|
|
130
|
+
- ("return_rate", "asc") - Lower returns are better
|
|
131
|
+
If just a string is provided, ascending order is assumed.
|
|
132
|
+
agg_func (str): How to combine individual rankings:
|
|
133
|
+
- "mean": Balanced scorecard (most common for range reviews)
|
|
134
|
+
- "sum": Total performance score (for bonus calculations)
|
|
135
|
+
- "min": Worst-case performance (for risk assessment)
|
|
136
|
+
- "max": Best-case performance (for opportunity identification)
|
|
137
|
+
ignore_ties (bool, optional): How to handle identical values:
|
|
138
|
+
- False (default): Products with same sales get same rank (fair comparison)
|
|
139
|
+
- True: Force unique ranks even for ties (strict ordering needed)
|
|
140
|
+
group_col (str | list[str], optional): Column(s) to partition rankings by group.
|
|
141
|
+
- None (default): Rank across entire dataset (current behavior)
|
|
142
|
+
- If specified: Calculate ranks independently within each group
|
|
143
|
+
Examples for group-based ranking:
|
|
144
|
+
- "product_category": Rank products within each category
|
|
145
|
+
- "store_region": Rank stores within their regions
|
|
146
|
+
- "supplier_type": Rank suppliers within their specialization
|
|
147
|
+
|
|
148
|
+
Raises:
|
|
149
|
+
ValueError: If specified metrics are not in the data or sort order is invalid.
|
|
150
|
+
ValueError: If aggregation function is not supported.
|
|
151
|
+
ValueError: If group_col is specified but doesn't exist in the data.
|
|
152
|
+
|
|
153
|
+
Examples:
|
|
154
|
+
>>> # Global ranking: Rank all products together (current behavior)
|
|
155
|
+
>>> ranker = CompositeRank(
|
|
156
|
+
... df=product_data,
|
|
157
|
+
... rank_cols=[
|
|
158
|
+
... ("weekly_sales", "desc"),
|
|
159
|
+
... ("margin_percentage", "desc"),
|
|
160
|
+
... ("stock_cover_days", "asc"),
|
|
161
|
+
... ("customer_rating", "desc")
|
|
162
|
+
... ],
|
|
163
|
+
... agg_func="mean"
|
|
164
|
+
... )
|
|
165
|
+
>>> # Products with lowest composite_rank should be reviewed for delisting
|
|
166
|
+
|
|
167
|
+
>>> # Group-based ranking: Rank products within each category
|
|
168
|
+
>>> ranker = CompositeRank(
|
|
169
|
+
... df=product_data,
|
|
170
|
+
... rank_cols=[
|
|
171
|
+
... ("weekly_sales", "desc"),
|
|
172
|
+
... ("margin_percentage", "desc"),
|
|
173
|
+
... ("stock_cover_days", "asc")
|
|
174
|
+
... ],
|
|
175
|
+
... agg_func="mean",
|
|
176
|
+
... group_col="product_category"
|
|
177
|
+
... )
|
|
178
|
+
>>> # Electronics products ranked against other electronics
|
|
179
|
+
>>> # Apparel products ranked against other apparel
|
|
180
|
+
"""
|
|
181
|
+
self._df: pd.DataFrame | None = None
|
|
182
|
+
if isinstance(df, pd.DataFrame):
|
|
183
|
+
df = ibis.memtable(df)
|
|
184
|
+
|
|
185
|
+
self._validate_group_col(group_col, df)
|
|
186
|
+
rank_mutates = self._process_rank_columns(rank_cols, df, group_col, ignore_ties)
|
|
187
|
+
df = df.mutate(**rank_mutates)
|
|
188
|
+
self.table = self._create_composite_ranking(df, rank_mutates, agg_func)
|
|
189
|
+
|
|
190
|
+
def _validate_group_col(self, group_col: str | list[str] | None, df: ibis.Table) -> None:
|
|
191
|
+
"""Validate group_col parameter against the DataFrame columns.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
group_col (str | list[str] | None): Column name or list of column names to partition rankings by.
|
|
195
|
+
None skips validation entirely.
|
|
196
|
+
df (ibis.Table): The table whose columns are validated against.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
TypeError: If group_col is not a string, list, or None.
|
|
200
|
+
ValueError: If group_col is an empty list.
|
|
201
|
+
ValueError: If any specified group columns are not found in the DataFrame.
|
|
202
|
+
"""
|
|
203
|
+
if group_col is None:
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
if isinstance(group_col, str):
|
|
207
|
+
group_col = [group_col]
|
|
208
|
+
elif not isinstance(group_col, list):
|
|
209
|
+
msg = f"group_col must be a string or list of strings. Got {type(group_col).__name__}."
|
|
210
|
+
raise TypeError(msg)
|
|
211
|
+
|
|
212
|
+
if len(group_col) == 0:
|
|
213
|
+
msg = "group_col must not be an empty list. Use None for global ranking."
|
|
214
|
+
raise ValueError(msg)
|
|
215
|
+
|
|
216
|
+
missing_cols = sorted(set(group_col) - set(df.columns))
|
|
217
|
+
if len(missing_cols) > 0:
|
|
218
|
+
msg = f"Group column(s) {missing_cols} not found in the DataFrame"
|
|
219
|
+
raise ValueError(msg)
|
|
220
|
+
|
|
221
|
+
def _process_rank_columns(
|
|
222
|
+
self,
|
|
223
|
+
rank_cols: list[tuple[str, str] | str],
|
|
224
|
+
df: ibis.Table,
|
|
225
|
+
group_col: str | list[str] | None,
|
|
226
|
+
ignore_ties: bool,
|
|
227
|
+
) -> dict[str, ir.IntegerColumn]:
|
|
228
|
+
"""Process rank columns and create ranking expressions.
|
|
229
|
+
|
|
230
|
+
Validates each column specification, then builds an ibis ranking expression
|
|
231
|
+
for each metric using the appropriate window function and tie-handling strategy.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
rank_cols (list[tuple[str, str] | str]): Column specifications to rank. Each element is
|
|
235
|
+
either a string (column name, defaults to ascending) or a tuple of (column_name, sort_order).
|
|
236
|
+
df (ibis.Table): The table containing the columns to rank.
|
|
237
|
+
group_col (str | list[str] | None): Column(s) to partition the ranking window by,
|
|
238
|
+
or None for global ranking.
|
|
239
|
+
ignore_ties (bool): If True, uses row_number for unique ranks. If False, uses rank
|
|
240
|
+
which assigns the same rank to tied values.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
dict[str, ir.IntegerColumn]: Mapping of rank column names (e.g., "sales_rank") to ibis ranking expressions.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If a specified column is not found in the DataFrame.
|
|
247
|
+
ValueError: If a sort order is not one of "asc", "ascending", "desc", or "descending".
|
|
248
|
+
"""
|
|
249
|
+
if len(rank_cols) == 0:
|
|
250
|
+
msg = "rank_cols must contain at least one column specification"
|
|
251
|
+
raise ValueError(msg)
|
|
252
|
+
|
|
253
|
+
valid_sort_orders = ["asc", "ascending", "desc", "descending"]
|
|
254
|
+
rank_mutates = {}
|
|
255
|
+
|
|
256
|
+
for col_spec in rank_cols:
|
|
257
|
+
col_name, sort_order = self._parse_column_spec(col_spec)
|
|
258
|
+
|
|
259
|
+
# Validate column exists and sort order is valid
|
|
260
|
+
if col_name not in df.columns:
|
|
261
|
+
msg = f"Column '{col_name}' not found in the DataFrame"
|
|
262
|
+
raise ValueError(msg)
|
|
263
|
+
if sort_order.lower() not in valid_sort_orders:
|
|
264
|
+
msg = f"Sort order must be one of {valid_sort_orders}. Got '{sort_order}'"
|
|
265
|
+
raise ValueError(msg)
|
|
266
|
+
|
|
267
|
+
order_by = ibis.asc(df[col_name]) if sort_order in ["asc", "ascending"] else ibis.desc(df[col_name])
|
|
268
|
+
window = self._create_window(group_col, df, order_by)
|
|
269
|
+
|
|
270
|
+
# Calculate rank based on ignore_ties parameter (using 1-based ranks)
|
|
271
|
+
rank_col = ibis.row_number().over(window) + 1 if ignore_ties else ibis.rank().over(window) + 1
|
|
272
|
+
rank_mutates[f"{col_name}_rank"] = rank_col
|
|
273
|
+
|
|
274
|
+
return rank_mutates
|
|
275
|
+
|
|
276
|
+
def _parse_column_spec(self, col_spec: tuple[str, str] | str) -> tuple[str, str]:
|
|
277
|
+
"""Parse a column specification into a column name and sort order.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
col_spec (tuple[str, str] | str): Either a column name string (defaults to "asc")
|
|
281
|
+
or a tuple of (column_name, sort_order).
|
|
282
|
+
|
|
283
|
+
Returns:
|
|
284
|
+
tuple[str, str]: A tuple of (column_name, sort_order).
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
ValueError: If a tuple is provided but does not contain exactly two elements.
|
|
288
|
+
"""
|
|
289
|
+
if isinstance(col_spec, str):
|
|
290
|
+
return col_spec, "asc"
|
|
291
|
+
if len(col_spec) != 2: # noqa: PLR2004 - Error message below explains the value
|
|
292
|
+
msg = f"Column specification must be a string or a tuple of (column_name, sort_order). Got {col_spec}"
|
|
293
|
+
raise ValueError(msg)
|
|
294
|
+
return col_spec
|
|
295
|
+
|
|
296
|
+
def _create_window(
|
|
297
|
+
self,
|
|
298
|
+
group_col: str | list[str] | None,
|
|
299
|
+
df: ibis.Table,
|
|
300
|
+
order_by: ir.Column,
|
|
301
|
+
) -> Any: # noqa: ANN401 - WindowedExpr is an internal ibis type; Any is safest here
|
|
302
|
+
"""Create an ibis window specification for ranking.
|
|
303
|
+
|
|
304
|
+
Builds the appropriate window function based on whether rankings should be
|
|
305
|
+
global or partitioned by group column(s).
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
group_col (str | list[str] | None): Column(s) to partition the window by.
|
|
309
|
+
None creates a global window, a string or list creates a grouped window.
|
|
310
|
+
df (ibis.Table): The table containing the group columns.
|
|
311
|
+
order_by (ir.Column): An ibis ordering expression (e.g., ibis.asc or ibis.desc) for the window.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
Any: An ibis window specification for use with ranking functions.
|
|
315
|
+
"""
|
|
316
|
+
if group_col is None:
|
|
317
|
+
return ibis.window(order_by=order_by)
|
|
318
|
+
if isinstance(group_col, str):
|
|
319
|
+
return ibis.window(group_by=df[group_col], order_by=order_by)
|
|
320
|
+
return ibis.window(group_by=[df[col] for col in group_col], order_by=order_by)
|
|
321
|
+
|
|
322
|
+
def _create_composite_ranking(
|
|
323
|
+
self,
|
|
324
|
+
df: ibis.Table,
|
|
325
|
+
rank_mutates: dict[str, ir.IntegerColumn],
|
|
326
|
+
agg_func: str,
|
|
327
|
+
) -> ibis.Table:
|
|
328
|
+
"""Create the final composite ranking by aggregating individual rank columns.
|
|
329
|
+
|
|
330
|
+
Combines the individual ranking columns into a single composite_rank column
|
|
331
|
+
using the specified aggregation function.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
df (ibis.Table): The table with individual rank columns already added.
|
|
335
|
+
rank_mutates (dict[str, ir.IntegerColumn]): Mapping of rank column names to their expressions,
|
|
336
|
+
used to identify which columns to aggregate.
|
|
337
|
+
agg_func (str): Aggregation function to combine ranks. Must be one of
|
|
338
|
+
"mean", "sum", "min", or "max".
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
ibis.Table: The input table with an additional composite_rank column.
|
|
342
|
+
|
|
343
|
+
Raises:
|
|
344
|
+
ValueError: If agg_func is not one of the supported aggregation functions.
|
|
345
|
+
"""
|
|
346
|
+
column_refs = [df[col] for col in rank_mutates]
|
|
347
|
+
agg_expr = {
|
|
348
|
+
"mean": sum(column_refs) / len(column_refs),
|
|
349
|
+
"sum": sum(column_refs),
|
|
350
|
+
"min": ibis.least(*column_refs),
|
|
351
|
+
"max": ibis.greatest(*column_refs),
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if agg_func.lower() not in agg_expr:
|
|
355
|
+
msg = f"Aggregation function must be one of {list(agg_expr.keys())}. Got '{agg_func}'"
|
|
356
|
+
raise ValueError(msg)
|
|
357
|
+
|
|
358
|
+
return df.mutate(composite_rank=agg_expr[agg_func])
|
|
359
|
+
|
|
360
|
+
@property
|
|
361
|
+
def df(self) -> pd.DataFrame:
|
|
362
|
+
"""Returns ranked data ready for business decision-making.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
pd.DataFrame: Performance data with ranking columns added:
|
|
366
|
+
- Original metrics (sales, margin, etc.)
|
|
367
|
+
- Individual rank columns (e.g., sales_rank, margin_rank)
|
|
368
|
+
- composite_rank: Final combined ranking for decisions
|
|
369
|
+
"""
|
|
370
|
+
if self._df is None:
|
|
371
|
+
self._df = self.table.execute()
|
|
372
|
+
return self._df
|