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,3 @@
1
+ from openretailscience.plugin import plugin_manager
2
+
3
+ __all__ = ["plugin_manager"]
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