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,1177 @@
1
+ """Segment Performance Analysis for Retail Business Intelligence.
2
+
3
+ ## Business Context
4
+
5
+ Retailers need to understand performance differences across various business dimensions -
6
+ whether comparing customer segments, store locations, product categories, brands, channels,
7
+ or any other grouping. This module transforms transactional data into actionable insights
8
+ by calculating key performance metrics for any segment or combination of segments.
9
+
10
+ ## The Business Problem
11
+
12
+ Business stakeholders receive segment data but struggle to answer performance questions:
13
+ - Which stores/categories/customer segments generate the most revenue?
14
+ - How do transaction patterns differ between segments?
15
+ - What's the customer density and spending behavior by segment?
16
+ - Are certain combinations of segments more valuable than others?
17
+
18
+ Without segment performance analysis, decisions are made on incomplete information
19
+ rather than data-driven insights about segment value and behavior.
20
+
21
+ ## Real-World Applications
22
+
23
+ ### Customer Segment Analysis
24
+ - Compare RFM segments: Which customer types drive the most revenue?
25
+ - Analyze geographic segments: Regional performance differences
26
+ - Age/demographic segments: Spending patterns by customer characteristics
27
+
28
+ ### Store/Location Analysis
29
+ - Store performance comparison: Revenue per customer, transaction frequency
30
+ - Regional analysis: Market penetration and customer behavior by area
31
+ - Channel analysis: Online vs in-store performance metrics
32
+
33
+ ### Product/Category Analysis
34
+ - Category performance: Which product lines drive customer frequency?
35
+ - Brand analysis: Private label vs national brand customer behavior
36
+ - SKU analysis: Performance metrics for product rationalization decisions
37
+
38
+ ### Multi-Dimensional Analysis
39
+ - Store + Customer segment: High-value customers by location
40
+ - Category + Channel: Product performance across sales channels
41
+ - Brand + Geography: Regional brand performance variations
42
+
43
+ This module calculates comprehensive statistics including spend, customer counts,
44
+ transaction frequency, average basket size, and custom business metrics for any
45
+ segment combination.
46
+ """
47
+
48
+ import warnings
49
+ from itertools import chain, combinations
50
+ from typing import Any, Literal
51
+
52
+ import ibis
53
+ import pandas as pd
54
+ from matplotlib.axes import Axes, SubplotBase
55
+
56
+ import openretailscience.plots.styles.graph_utils as gu
57
+ from openretailscience.options import ColumnHelper, get_option
58
+ from openretailscience.plots.styles.colors import COLORS
59
+
60
+ __all__ = ["SegTransactionStats", "cube", "rollup"]
61
+
62
+ # Maximum number of dimensions for CUBE mode before warning about exponential growth.
63
+ # CUBE generates 2^n grouping sets, so 6 dimensions = 64 sets (reasonable), but 7+ dimensions
64
+ # = 128+ sets (potentially expensive). This threshold balances flexibility with performance awareness.
65
+ MAX_CUBE_DIMENSIONS_WITHOUT_WARNING = 6
66
+
67
+
68
+ def cube(*columns: str) -> list[tuple[str, ...]]:
69
+ """Generate CUBE grouping sets (all possible combinations).
70
+
71
+ CUBE generates all 2^n combinations of the specified columns, from full detail down to
72
+ grand total. Returns a list of tuples that can be passed directly to grouping_sets,
73
+ or used with fixed columns in a nested list specification.
74
+
75
+ This matches SQL's GROUP BY CUBE(A, B), C syntax.
76
+
77
+ Args:
78
+ *columns (str): Column names to include in the CUBE operation
79
+
80
+ Returns:
81
+ list[tuple[str, ...]]: List of tuples representing all CUBE combinations
82
+
83
+ Raises:
84
+ ValueError: If no columns are provided
85
+ TypeError: If any column is not a string
86
+ UserWarning: If more than MAX_CUBE_DIMENSIONS_WITHOUT_WARNING columns
87
+
88
+ Example:
89
+ >>> from openretailscience.segmentation import cube
90
+ >>>
91
+ >>> # Simple CUBE - returns list of tuples
92
+ >>> cube("store", "region")
93
+ [("store", "region"), ("store",), ("region",), ()]
94
+ >>>
95
+ >>> # Use directly (equivalent to explicit list of tuples)
96
+ >>> stats = SegTransactionStats(
97
+ ... data=df,
98
+ ... segment_col=["store", "region", "date"],
99
+ ... grouping_sets=cube("store", "region", "date")
100
+ ... )
101
+ >>>
102
+ >>> # CUBE with fixed columns - wrap in tuple
103
+ >>> stats = SegTransactionStats(
104
+ ... data=df,
105
+ ... segment_col=["store", "region", "date"],
106
+ ... grouping_sets=[(cube("store", "region"), "date")]
107
+ ... )
108
+ >>> # Produces 4 grouping sets (2^2 from CUBE):
109
+ >>> # [("store", "region", "date"), ("store", "date"), ("region", "date"), ("date",)]
110
+ """
111
+ if len(columns) == 0:
112
+ raise ValueError("cube() requires at least one column")
113
+
114
+ # Validate all columns are strings
115
+ for col in columns:
116
+ if not isinstance(col, str):
117
+ msg = f"All column names must be strings. Got {type(col).__name__}: {col}"
118
+ raise TypeError(msg)
119
+
120
+ # Validation: warn if too many dimensions
121
+ num_grouping_sets = 2 ** len(columns)
122
+ if len(columns) > MAX_CUBE_DIMENSIONS_WITHOUT_WARNING:
123
+ warnings.warn(
124
+ f"CUBE with {len(columns)} dimensions will generate {num_grouping_sets} grouping sets, "
125
+ f"which may be computationally expensive. Consider using ROLLUP mode or limiting to "
126
+ f"{MAX_CUBE_DIMENSIONS_WITHOUT_WARNING} dimensions.",
127
+ UserWarning,
128
+ stacklevel=2,
129
+ )
130
+
131
+ # Expansion: generate all 2^n combinations and return as list
132
+ return list(
133
+ chain.from_iterable(combinations(columns, size) for size in range(len(columns), -1, -1)),
134
+ )
135
+
136
+
137
+ def rollup(*columns: str) -> list[tuple[str, ...]]:
138
+ """Generate ROLLUP grouping sets (hierarchical aggregation levels).
139
+
140
+ ROLLUP generates n+1 hierarchical levels from right to left. Returns a list of tuples
141
+ that can be passed directly to grouping_sets, or used with fixed columns in a nested
142
+ list specification.
143
+
144
+ This matches SQL's GROUP BY ROLLUP(A, B), C syntax.
145
+
146
+ Args:
147
+ *columns (str): Column names in hierarchical order (left = highest level)
148
+
149
+ Returns:
150
+ list[tuple[str, ...]]: List of tuples representing ROLLUP hierarchy levels
151
+
152
+ Raises:
153
+ ValueError: If no columns are provided
154
+ TypeError: If any column is not a string
155
+
156
+ Example:
157
+ >>> from openretailscience.segmentation import rollup
158
+ >>>
159
+ >>> # Simple ROLLUP - returns list of tuples
160
+ >>> rollup("year", "quarter", "month")
161
+ [("year", "quarter", "month"), ("year", "quarter"), ("year",), ()]
162
+ >>>
163
+ >>> # Use directly (equivalent to explicit list of tuples)
164
+ >>> stats = SegTransactionStats(
165
+ ... data=df,
166
+ ... segment_col=["year", "quarter", "month"],
167
+ ... grouping_sets=rollup("year", "quarter", "month")
168
+ ... )
169
+ >>>
170
+ >>> # ROLLUP with fixed column - wrap in tuple
171
+ >>> stats = SegTransactionStats(
172
+ ... data=df,
173
+ ... segment_col=["year", "quarter", "month", "store"],
174
+ ... grouping_sets=[(rollup("year", "quarter", "month"), "store")]
175
+ ... )
176
+ >>> # Produces 4 grouping sets (3+1 from ROLLUP):
177
+ >>> # [("year", "quarter", "month", "store"), ("year", "quarter", "store"),
178
+ >>> # ("year", "store"), ("store",)]
179
+ """
180
+ if len(columns) == 0:
181
+ raise ValueError("rollup() requires at least one column")
182
+
183
+ # Validate all columns are strings
184
+ for col in columns:
185
+ if not isinstance(col, str):
186
+ msg = f"All column names must be strings. Got {type(col).__name__}: {col}"
187
+ raise TypeError(msg)
188
+
189
+ # Expansion: generate n+1 hierarchical levels and return as list
190
+ return [tuple(columns[:i]) for i in range(len(columns), -1, -1)]
191
+
192
+
193
+ class SegTransactionStats:
194
+ """Calculates transaction performance statistics for any business segment or dimension.
195
+
196
+ Analyzes transaction data across segments like customer types, store locations,
197
+ product categories, brands, channels, or any combination to reveal performance
198
+ differences and guide business decisions.
199
+
200
+ The class automatically calculates key retail metrics including total spend,
201
+ unique customers, transaction frequency, spend per customer, and custom
202
+ aggregations for comparison across segments.
203
+ """
204
+
205
+ def __init__(
206
+ self,
207
+ data: pd.DataFrame | ibis.Table,
208
+ segment_col: str | list[str] = "segment_name",
209
+ calc_total: bool | None = None,
210
+ extra_aggs: dict[str, tuple[str, str]] | None = None,
211
+ calc_rollup: bool | None = None,
212
+ rollup_value: Any | list[Any] = "Total", # noqa: ANN401 - Any is required for ibis.literal typing
213
+ unknown_customer_value: int | str | ibis.Scalar | ibis.expr.types.BooleanColumn | None = None,
214
+ grouping_sets: Literal["rollup", "cube", "total"] | list[tuple[str, ...]] | None = None,
215
+ ) -> None:
216
+ """Calculates transaction statistics by segment.
217
+
218
+ Args:
219
+ data (pd.DataFrame | ibis.Table): The transaction data. The dataframe must contain the columns
220
+ customer_id, unit_spend and transaction_id. If the dataframe contains the column unit_quantity, then
221
+ the columns unit_spend and unit_quantity are used to calculate the price_per_unit and
222
+ units_per_transaction.
223
+ segment_col (str | list[str], optional): The column or list of columns to use for the segmentation.
224
+ Defaults to "segment_name".
225
+ calc_total (bool | None, optional): Whether to include the total row. Defaults to True if grouping_sets is
226
+ None. Cannot be used with grouping_sets parameter.
227
+ Note: This parameter is planned for deprecation. Use grouping_sets parameter for new code.
228
+ extra_aggs (dict[str, tuple[str, str]], optional): Additional aggregations to perform.
229
+ The keys in the dictionary will be the column names for the aggregation results.
230
+ The values are tuples with (column_name, aggregation_function), where:
231
+ - column_name is the name of the column to aggregate
232
+ - aggregation_function is a string name of an Ibis aggregation function (e.g., "nunique", "sum")
233
+ Example: {"stores": ("store_id", "nunique")} would count unique store_ids.
234
+ calc_rollup (bool | None, optional): Whether to calculate rollup totals. Defaults to False if grouping_sets
235
+ is None. When True and multiple segment columns are provided, the method generates subtotal rows for
236
+ both:
237
+ - Prefix rollups: progressively aggregating left-to-right (e.g., [A, B, Total], [A, Total, Total]).
238
+ - Suffix rollups: progressively aggregating right-to-left (e.g., [Total, B, C], [Total, Total, C]).
239
+ A grand total row is also included when calc_total is True.
240
+ Note: This differs from grouping_sets='rollup' which generates only prefix rollups (SQL standard).
241
+ Performance: adds O(n) extra aggregation passes where n is the number of segment
242
+ columns. For large hierarchies, consider disabling rollups or reducing columns.
243
+ Cannot be used with grouping_sets parameter.
244
+ Note: This parameter is planned for deprecation. Use grouping_sets parameter for new code.
245
+ rollup_value (Any | list[Any], optional): The value to use for rollup totals. Can be a single value
246
+ applied to all columns or a list of values matching the length of segment_col, with each value
247
+ cast to match the corresponding column type. Defaults to "Total".
248
+ unknown_customer_value
249
+ (int | str | ibis.Scalar | ibis.expr.types.BooleanColumn | None, optional):
250
+ Value or expression identifying unknown customers for separate tracking.
251
+ When provided, metrics are split into identified, unknown, and total variants.
252
+ Accepts simple values (e.g., -1), ibis literals, or boolean expressions
253
+ (e.g., data["customer_id"] < 0). Requires customer_id column. Defaults to None.
254
+ grouping_sets (Literal["rollup", "cube"] | list[list[str] | tuple[str, ...]] | None, optional):
255
+ Grouping sets mode. Mutually exclusive with calc_total/calc_rollup when explicitly set.
256
+ - "rollup": SQL ROLLUP (hierarchical aggregation from right to left). Generates [A,B,C], [A,B], [A], [].
257
+ - "cube": SQL CUBE (all possible combinations). Generates 2^n grouping sets for n dimensions.
258
+ - list: Custom grouping sets (list of lists/tuples). Specify arbitrary dimension combinations.
259
+ Each element must be a list or tuple of column names from segment_col. Empty list/tuple ()
260
+ represents grand total. Automatically deduplicates and validates column names.
261
+ - None: Use calc_total/calc_rollup behavior (default).
262
+ Defaults to None.
263
+
264
+ Raises:
265
+ ValueError: If grouping_sets is used with explicit calc_total or calc_rollup.
266
+ ValueError: If grouping_sets is not a valid value.
267
+
268
+ Example:
269
+ >>> # Hierarchical rollup using grouping_sets
270
+ >>> stats = SegTransactionStats(
271
+ ... data=df,
272
+ ... segment_col=["region", "store", "product"],
273
+ ... grouping_sets="rollup",
274
+ ... )
275
+ >>>
276
+ >>> # All combinations using CUBE
277
+ >>> stats = SegTransactionStats(
278
+ ... data=df,
279
+ ... segment_col=["region", "store", "product"],
280
+ ... grouping_sets="cube",
281
+ ... )
282
+ >>>
283
+ >>> # Custom grouping sets for specific dimension combinations
284
+ >>> stats = SegTransactionStats(
285
+ ... data=df,
286
+ ... segment_col=["region", "store", "product"],
287
+ ... grouping_sets=[
288
+ ... ("region", "product"), # Regional product performance (skip store)
289
+ ... ("product",), # Product-only totals
290
+ ... () # Grand total
291
+ ... ],
292
+ ... )
293
+ >>>
294
+ >>> # Legacy behavior (backward compatible)
295
+ >>> stats = SegTransactionStats(
296
+ ... data=df,
297
+ ... segment_col=["region", "store"],
298
+ ... calc_total=True,
299
+ ... calc_rollup=False,
300
+ ... )
301
+ """
302
+ self._df: pd.DataFrame | None = None
303
+ # Convert data to ibis.Table if it's a pandas DataFrame
304
+ if isinstance(data, pd.DataFrame):
305
+ data = ibis.memtable(data)
306
+ elif not isinstance(data, ibis.Table):
307
+ raise TypeError("data must be either a pandas DataFrame or an ibis Table")
308
+
309
+ cols = ColumnHelper()
310
+
311
+ if isinstance(segment_col, str):
312
+ segment_col = [segment_col]
313
+
314
+ if len(segment_col) == 0:
315
+ msg = "segment_col cannot be an empty list. At least one segment column must be specified."
316
+ raise ValueError(msg)
317
+
318
+ required_cols = [
319
+ cols.unit_spend,
320
+ cols.transaction_id,
321
+ *segment_col,
322
+ *filter(lambda x: x in data.columns, [cols.unit_qty, cols.customer_id]),
323
+ ]
324
+
325
+ missing_cols = set(required_cols) - set(data.columns)
326
+ if len(missing_cols) > 0:
327
+ msg = f"The following columns are required but missing: {missing_cols}"
328
+ raise ValueError(msg)
329
+
330
+ # Validate extra_aggs if provided
331
+ self._validate_extra_aggs(data, extra_aggs)
332
+
333
+ self.segment_col = segment_col
334
+ self.extra_aggs = {} if extra_aggs is None else extra_aggs
335
+ self.rollup_value = rollup_value
336
+ self.unknown_customer_value = unknown_customer_value
337
+
338
+ # Validate grouping_sets parameter
339
+ self._validate_grouping_sets_params(grouping_sets, calc_total, calc_rollup)
340
+
341
+ # Normalize parameters as local variables (only in legacy mode)
342
+ if grouping_sets is None:
343
+ calc_total = True if calc_total is None else calc_total
344
+ calc_rollup = False if calc_rollup is None else calc_rollup
345
+
346
+ self.table = self._calc_seg_stats(
347
+ data,
348
+ segment_col,
349
+ calc_total,
350
+ self.extra_aggs,
351
+ calc_rollup,
352
+ rollup_value,
353
+ unknown_customer_value,
354
+ grouping_sets,
355
+ )
356
+
357
+ @staticmethod
358
+ def _get_col_order(include_quantity: bool, include_customer: bool, include_unknown: bool = False) -> list[str]:
359
+ """Returns the default column order.
360
+
361
+ Args:
362
+ include_quantity (bool): Whether to include the columns related to quantity.
363
+ include_customer (bool): Whether to include customer-based columns.
364
+ include_unknown (bool, optional): Whether to include unknown customer columns. Defaults to False.
365
+
366
+ Returns:
367
+ list[str]: The default column order.
368
+ """
369
+ cols = ColumnHelper()
370
+
371
+ column_configs = [
372
+ (cols.agg.unit_spend, True),
373
+ (cols.agg.transaction_id, True),
374
+ (cols.agg.customer_id, include_customer),
375
+ (cols.agg.unit_qty, include_quantity),
376
+ (cols.calc.spend_per_cust, include_customer),
377
+ (cols.calc.spend_per_trans, True),
378
+ (cols.calc.trans_per_cust, include_customer),
379
+ (cols.calc.price_per_unit, include_quantity),
380
+ (cols.calc.units_per_trans, include_quantity),
381
+ ]
382
+
383
+ # Add unknown customer columns if tracking unknown customers
384
+ if include_unknown:
385
+ unknown_configs = [
386
+ (cols.agg.unit_spend_unknown, True),
387
+ (cols.agg.transaction_id_unknown, True),
388
+ (cols.agg.unit_qty_unknown, include_quantity),
389
+ (cols.calc.spend_per_trans_unknown, True),
390
+ (cols.calc.price_per_unit_unknown, include_quantity),
391
+ (cols.calc.units_per_trans_unknown, include_quantity),
392
+ ]
393
+ column_configs.extend(unknown_configs)
394
+
395
+ # Add total columns
396
+ total_configs = [
397
+ (cols.agg.unit_spend_total, True),
398
+ (cols.agg.transaction_id_total, True),
399
+ (cols.agg.unit_qty_total, include_quantity),
400
+ (cols.calc.spend_per_trans_total, True),
401
+ (cols.calc.price_per_unit_total, include_quantity),
402
+ (cols.calc.units_per_trans_total, include_quantity),
403
+ ]
404
+ column_configs.extend(total_configs)
405
+
406
+ return [col for col, condition in column_configs if condition]
407
+
408
+ @staticmethod
409
+ def _create_typed_literals(
410
+ data: ibis.Table,
411
+ columns: list[str],
412
+ values: list[Any],
413
+ ) -> dict[str, ibis.Scalar]:
414
+ """Create a dictionary of ibis literals with proper column types.
415
+
416
+ Args:
417
+ data (ibis.Table): The data table containing column type information
418
+ columns (list[str]): List of column names
419
+ values (list[Any]): List of values to convert to typed literals
420
+
421
+ Returns:
422
+ dict[str, ibis.Scalar]: Dictionary mapping column names to typed literals
423
+ """
424
+ mutations = {}
425
+ for i, col in enumerate(columns):
426
+ col_type = data[col].type()
427
+ mutations[col] = ibis.literal(values[i], type=col_type)
428
+ return mutations
429
+
430
+ @staticmethod
431
+ def _validate_extra_aggs(data: ibis.Table, extra_aggs: dict[str, tuple[str, str]] | None) -> None:
432
+ """Validate extra_aggs parameter.
433
+
434
+ Args:
435
+ data (ibis.Table): The data table to validate against
436
+ extra_aggs (dict[str, tuple[str, str]] | None): Extra aggregations to validate
437
+
438
+ Raises:
439
+ ValueError: If column doesn't exist or aggregation function is not available
440
+ """
441
+ if extra_aggs is None:
442
+ return
443
+
444
+ for col_tuple in extra_aggs.values():
445
+ col, func = col_tuple
446
+ if col not in data.columns:
447
+ msg = f"Column '{col}' specified in extra_aggs does not exist in the data"
448
+ raise ValueError(msg)
449
+ if not hasattr(data[col], func):
450
+ msg = f"Aggregation function '{func}' not available for column '{col}'"
451
+ raise ValueError(msg)
452
+
453
+ @staticmethod
454
+ def _validate_grouping_sets_params(
455
+ grouping_sets: Literal["rollup", "cube", "total"] | list[tuple[str, ...]] | None,
456
+ calc_total: bool | None,
457
+ calc_rollup: bool | None,
458
+ ) -> None:
459
+ """Validate grouping_sets parameter (type checking only).
460
+
461
+ Column validation happens in _generate_grouping_sets() since it requires segment_col.
462
+
463
+ Args:
464
+ grouping_sets: The grouping_sets parameter value
465
+ calc_total (bool | None): Whether to include grand total
466
+ calc_rollup (bool | None): Whether to generate rollup subtotals
467
+
468
+ Raises:
469
+ ValueError: If grouping_sets is used with explicit calc_total or calc_rollup
470
+ ValueError: If grouping_sets is not a valid value
471
+ TypeError: If grouping_sets has invalid type
472
+ """
473
+ if grouping_sets is None:
474
+ # Warn if relying on implicit calc_total=True default (calc_total will be removed)
475
+ if calc_total is None and calc_rollup is None:
476
+ warnings.warn(
477
+ "The calc_total parameter is deprecated and will be removed in a future version. "
478
+ "To maintain the current behavior of including a grand total, use grouping_sets='total' "
479
+ "or grouping_sets=[()] instead. "
480
+ "See documentation for more flexible aggregation control with the grouping_sets parameter.",
481
+ FutureWarning,
482
+ stacklevel=3,
483
+ )
484
+ return
485
+
486
+ # Mutual exclusivity check
487
+ if calc_total is not None or calc_rollup is not None:
488
+ raise ValueError("Cannot use grouping_sets with calc_total or calc_rollup")
489
+
490
+ # String validation
491
+ if isinstance(grouping_sets, str):
492
+ if grouping_sets not in ["rollup", "cube", "total"]:
493
+ msg = (
494
+ f"grouping_sets must be 'rollup', 'cube', 'total', a list of tuples,"
495
+ f" or None. Got: '{grouping_sets}'"
496
+ )
497
+ raise ValueError(msg)
498
+
499
+ # List validation - only accept tuples for consistency (Ticket 5 design)
500
+ elif isinstance(grouping_sets, list):
501
+ if len(grouping_sets) == 0:
502
+ raise ValueError("grouping_sets list cannot be empty")
503
+
504
+ # Validate each element is a tuple (consistency: always list of tuples)
505
+ for item in grouping_sets:
506
+ if not isinstance(item, tuple):
507
+ msg = f"Each element must be a tuple. Got: {type(item).__name__}"
508
+ raise TypeError(msg)
509
+
510
+ @staticmethod
511
+ def _flatten_item(item: tuple) -> list[tuple[str, ...]]:
512
+ """Flatten a single item into grouping sets.
513
+
514
+ Uses structural detection to distinguish explicit sets from specifications:
515
+ - Tuple of strings only → explicit grouping set (return as-is)
516
+ - Tuple containing a list → specification to expand (cube()/rollup() result + optional fixed columns)
517
+
518
+ The cube()/rollup() functions return lists, so we detect them by checking if the tuple
519
+ contains a list element.
520
+
521
+ Args:
522
+ item (tuple): A tuple that is either an explicit grouping set or a specification
523
+
524
+ Returns:
525
+ list[tuple[str, ...]]: List of one or more grouping sets
526
+
527
+ Raises:
528
+ ValueError: If specification tuple contains multiple cube()/rollup() calls or is empty
529
+ TypeError: If specification tuple contains invalid types
530
+
531
+ Example:
532
+ >>> # Explicit set (tuple of strings only)
533
+ >>> _flatten_item(("region", "store"))
534
+ [("region", "store")]
535
+ >>>
536
+ >>> # Specification (tuple containing cube() result + fixed column)
537
+ >>> cube_result = [("region", "store"), ("region",), ("store",), ()]
538
+ >>> _flatten_item((cube_result, "date"))
539
+ [
540
+ ("region", "store", "date"),
541
+ ("region", "date"),
542
+ ("store", "date"),
543
+ ("date",)
544
+ ]
545
+ >>>
546
+ >>> # Invalid: Multiple cube()/rollup() calls
547
+ >>> _flatten_item((cube("region"), rollup("store"))) # ValueError
548
+ >>>
549
+ >>> # Invalid: Mixed types (integers not allowed)
550
+ >>> _flatten_item((cube("region"), 123)) # TypeError
551
+ >>>
552
+ >>> # Invalid: Empty cube()/rollup() result
553
+ >>> _flatten_item(([],)) # ValueError
554
+ """
555
+ # Check if tuple contains a list (cube()/rollup() result)
556
+ has_list = any(isinstance(elem, list) for elem in item)
557
+
558
+ if not has_list:
559
+ # Explicit grouping set - all elements are strings
560
+ return [item]
561
+
562
+ # Specification to flatten - must contain exactly one cube()/rollup() result + optional fixed columns
563
+ grouping_sets_list = None
564
+ fixed_cols = []
565
+
566
+ for element in item:
567
+ if isinstance(element, list):
568
+ # This is a cube()/rollup() result (list of tuples)
569
+ if grouping_sets_list is not None:
570
+ raise ValueError("Only one cube()/rollup() call allowed per specification")
571
+ grouping_sets_list = element
572
+ elif isinstance(element, str):
573
+ # Fixed column
574
+ fixed_cols.append(element)
575
+ else:
576
+ msg = f"Invalid type in specification tuple: {type(element).__name__}"
577
+ raise TypeError(msg)
578
+
579
+ # Validate cube()/rollup() result is not empty
580
+ if len(grouping_sets_list) == 0:
581
+ raise ValueError("Specification tuple must contain non-empty cube() or rollup() result")
582
+
583
+ # Flatten: append fixed columns to each set
584
+ fixed_suffix = tuple(fixed_cols)
585
+ return [gs + fixed_suffix for gs in grouping_sets_list]
586
+
587
+ @staticmethod
588
+ def _generate_grouping_sets(
589
+ segment_col: list[str],
590
+ calc_total: bool | None = None,
591
+ calc_rollup: bool | None = None,
592
+ grouping_sets: (
593
+ Literal["rollup", "cube", "total"] | list[tuple[str, ...] | tuple[list | str, ...]] | None
594
+ ) = None,
595
+ ) -> list[tuple[str, ...]]:
596
+ """Generate grouping sets based on grouping_sets parameter or calc_total/calc_rollup settings.
597
+
598
+ Args:
599
+ segment_col (list[str]): The segment columns to generate grouping sets for
600
+ calc_total (bool | None): Whether to include grand total (ignored if grouping_sets is not None)
601
+ calc_rollup (bool | None): Whether to generate rollup subtotals (ignored if grouping_sets is not None)
602
+ grouping_sets: Grouping sets mode ('rollup', 'cube', list of tuples, or None)
603
+
604
+ Returns:
605
+ list[tuple[str, ...]]: List of grouping set tuples. Each tuple contains the
606
+ column names to group by for that grouping set. Empty tuple () represents
607
+ grand total.
608
+
609
+ Raises:
610
+ ValueError: If custom grouping set contains column not in segment_col
611
+
612
+ Example:
613
+ >>> # ROLLUP mode
614
+ >>> _generate_grouping_sets(["region", "store", "product"], grouping_sets="rollup")
615
+ [
616
+ ("region", "store", "product"), # full detail
617
+ ("region", "store"), # rollup level 1
618
+ ("region",), # rollup level 2
619
+ (), # grand total
620
+ ]
621
+
622
+ >>> # Custom grouping sets
623
+ >>> _generate_grouping_sets(
624
+ ... ["region", "store", "product"],
625
+ ... grouping_sets=[("region", "product"), ("product",), ()]
626
+ ... )
627
+ [
628
+ ("region", "product"), # Regional product performance
629
+ ("product",), # Product-only totals
630
+ (), # Grand total
631
+ ]
632
+
633
+ >>> # Legacy mode (calc_total/calc_rollup)
634
+ >>> _generate_grouping_sets(["region", "store", "product"], True, True, None)
635
+ [
636
+ ("region", "store", "product"), # base grouping
637
+ ("region", "store"), # prefix rollup
638
+ ("region",), # prefix rollup
639
+ ("store", "product"), # suffix rollup
640
+ ("product",), # suffix rollup
641
+ (), # grand total
642
+ ]
643
+ """
644
+ # Handle string shortcuts - delegate to helper functions
645
+ if grouping_sets == "rollup":
646
+ return rollup(*segment_col)
647
+
648
+ if grouping_sets == "cube":
649
+ return cube(*segment_col)
650
+
651
+ if grouping_sets == "total":
652
+ return [tuple(segment_col), ()]
653
+
654
+ # Handle list of tuples - flatten each item
655
+ if isinstance(grouping_sets, list):
656
+ expanded = []
657
+ for item in grouping_sets:
658
+ expanded.extend(SegTransactionStats._flatten_item(item))
659
+
660
+ # Deduplicate (order not preserved, but not needed)
661
+ expanded = list(set(expanded))
662
+
663
+ # Validate columns (applies to list modes)
664
+ all_mentioned_cols = {col for gs in expanded for col in gs}
665
+ invalid_cols = all_mentioned_cols - set(segment_col)
666
+ if invalid_cols:
667
+ msg = (
668
+ f"Columns {sorted(invalid_cols)} in grouping_sets not found in segment_col {segment_col}. "
669
+ f"All grouping set columns must be in segment_col."
670
+ )
671
+ raise ValueError(msg)
672
+
673
+ unmentioned_cols = set(segment_col) - all_mentioned_cols
674
+ if unmentioned_cols:
675
+ msg = (
676
+ f"Columns {sorted(unmentioned_cols)} in segment_col are not mentioned in any grouping set. "
677
+ f"All segment_col columns must appear in at least one grouping set. "
678
+ f"Either remove these columns from segment_col or include them in at least one grouping set."
679
+ )
680
+ raise ValueError(msg)
681
+
682
+ return expanded
683
+
684
+ # Legacy mode - delegate to helper
685
+ return SegTransactionStats._generate_legacy_grouping_sets(segment_col, calc_total, calc_rollup)
686
+
687
+ @staticmethod
688
+ def _generate_legacy_grouping_sets(
689
+ segment_col: list[str],
690
+ calc_total: bool | None,
691
+ calc_rollup: bool | None,
692
+ ) -> list[tuple[str, ...]]:
693
+ """Generate grouping sets using legacy calc_total/calc_rollup parameters.
694
+
695
+ Args:
696
+ segment_col (list[str]): The segment columns
697
+ calc_total (bool | None): Whether to include grand total
698
+ calc_rollup (bool | None): Whether to generate rollup subtotals
699
+
700
+ Returns:
701
+ list[tuple[str, ...]]: List of grouping set tuples
702
+ """
703
+ grouping_sets_list = [tuple(segment_col)] # Base grouping always included
704
+
705
+ if calc_rollup:
706
+ # Prefix rollups: progressively remove from the right
707
+ grouping_sets_list.extend(tuple(segment_col[:i]) for i in range(1, len(segment_col)))
708
+
709
+ # Suffix rollups: progressively remove from the left (only if calc_total=True)
710
+ if calc_total:
711
+ grouping_sets_list.extend(tuple(segment_col[i:]) for i in range(1, len(segment_col)))
712
+
713
+ if calc_total:
714
+ grouping_sets_list.append(()) # Empty tuple = grand total
715
+
716
+ return grouping_sets_list
717
+
718
+ @staticmethod
719
+ def _execute_grouping_sets(
720
+ data: ibis.Table,
721
+ grouping_sets: list[tuple[str, ...]],
722
+ segment_col: list[str],
723
+ rollup_value: list[Any],
724
+ aggs: dict[str, Any],
725
+ ) -> ibis.Table:
726
+ """Execute all grouping sets and union results.
727
+
728
+ This method handles ALL grouping set execution uniformly, including:
729
+ - Base grouping (full segment_col)
730
+ - Rollup groupings (subsets of segment_col)
731
+ - Grand total (empty tuple)
732
+
733
+ Each grouping set is executed independently and results are unioned together.
734
+
735
+ Args:
736
+ data (ibis.Table): The data table to aggregate
737
+ grouping_sets (list[tuple[str, ...]]): List of grouping set tuples to execute.
738
+ Each tuple contains column names to group by. Empty tuple () means grand total.
739
+ segment_col (list[str]): All segment columns (used for mutation)
740
+ rollup_value (list[Any]): Rollup values for each segment column
741
+ aggs (dict[str, Any]): Aggregation specifications
742
+
743
+ Returns:
744
+ ibis.Table: Union of all grouping set results
745
+
746
+ Example:
747
+ >>> grouping_sets = [
748
+ ... ("region", "store", "product"), # base
749
+ ... ("region", "store"), # rollup
750
+ ... ("region",), # rollup
751
+ ... () # grand total
752
+ ... ]
753
+ >>> _execute_grouping_sets(data, grouping_sets, segment_col, rollup_value, aggs)
754
+ """
755
+ results = []
756
+
757
+ for gs in grouping_sets:
758
+ if len(gs) == 0:
759
+ # Grand total: aggregate all data, no GROUP BY
760
+ result = data.aggregate(**aggs)
761
+ # Mutate ALL segment columns to rollup_value
762
+ mutations = SegTransactionStats._create_typed_literals(data, segment_col, rollup_value)
763
+ result = result.mutate(**mutations)
764
+ else:
765
+ # Regular grouping: group by specified columns
766
+ group_cols = list(gs)
767
+ result = data.group_by(group_cols).aggregate(**aggs)
768
+
769
+ # Mutate columns NOT in this grouping set to rollup_value
770
+ mutation_cols = [col for col in segment_col if col not in gs]
771
+ if len(mutation_cols) > 0:
772
+ mutation_values = [rollup_value[segment_col.index(col)] for col in mutation_cols]
773
+ mutations = SegTransactionStats._create_typed_literals(data, mutation_cols, mutation_values)
774
+ result = result.mutate(**mutations)
775
+
776
+ results.append(result)
777
+
778
+ # Union all results - first result is the base, union the rest
779
+ return results[0].union(*results[1:]) if len(results) > 1 else results[0]
780
+
781
+ @staticmethod
782
+ def _create_unknown_flag(
783
+ data: ibis.Table,
784
+ unknown_customer_value: int | str | ibis.Scalar | ibis.expr.types.BooleanColumn,
785
+ ) -> ibis.expr.types.BooleanColumn:
786
+ """Create a boolean flag identifying unknown customers.
787
+
788
+ Args:
789
+ data (ibis.Table): The data table
790
+ unknown_customer_value (int | str | ibis.Scalar | ibis.expr.types.BooleanColumn):
791
+ The value or expression identifying unknown customers
792
+
793
+ Returns:
794
+ ibis.expr.types.BooleanColumn: Boolean expression identifying unknown customers
795
+ """
796
+ cols = ColumnHelper()
797
+
798
+ if isinstance(unknown_customer_value, ibis.expr.types.BooleanColumn):
799
+ return unknown_customer_value
800
+ if isinstance(unknown_customer_value, ibis.Scalar):
801
+ return data[cols.customer_id] == unknown_customer_value
802
+ # Simple value (int/str)
803
+ return data[cols.customer_id] == ibis.literal(unknown_customer_value)
804
+
805
+ @staticmethod
806
+ def _build_standard_aggs(
807
+ data: ibis.Table,
808
+ extra_aggs: dict[str, tuple[str, str]] | None = None,
809
+ ) -> dict[str, Any]:
810
+ """Build standard aggregations without unknown customer tracking.
811
+
812
+ Args:
813
+ data (ibis.Table): The data table
814
+ extra_aggs (dict[str, tuple[str, str]] | None): Additional aggregations
815
+
816
+ Returns:
817
+ dict[str, Any]: Aggregation specifications
818
+ """
819
+ cols = ColumnHelper()
820
+ agg_specs = [
821
+ (cols.agg.unit_spend, cols.unit_spend, "sum"),
822
+ (cols.agg.transaction_id, cols.transaction_id, "nunique"),
823
+ (cols.agg.unit_qty, cols.unit_qty, "sum"),
824
+ (cols.agg.customer_id, cols.customer_id, "nunique"),
825
+ ]
826
+
827
+ aggs = {agg_name: getattr(data[col], func)() for agg_name, col, func in agg_specs if col in data.columns}
828
+
829
+ # Add extra aggregations if provided
830
+ if extra_aggs:
831
+ aggs.update({agg_name: getattr(data[col], func)() for agg_name, (col, func) in extra_aggs.items()})
832
+
833
+ return aggs
834
+
835
+ @staticmethod
836
+ def _build_unknown_aggs(
837
+ data: ibis.Table,
838
+ unknown_flag: ibis.expr.types.BooleanColumn,
839
+ extra_aggs: dict[str, tuple[str, str]] | None = None,
840
+ ) -> dict[str, Any]:
841
+ """Build aggregations with unknown customer tracking.
842
+
843
+ Args:
844
+ data (ibis.Table): The data table
845
+ unknown_flag (ibis.expr.types.BooleanColumn): Boolean flag identifying unknown customers
846
+ extra_aggs (dict[str, tuple[str, str]] | None): Additional aggregations
847
+
848
+ Returns:
849
+ dict[str, Any]: Aggregation specifications for identified, unknown, and total variants
850
+ """
851
+ cols = ColumnHelper()
852
+ aggs = {}
853
+
854
+ # Identified customers only (where NOT unknown)
855
+ # Use coalesce to ensure proper types: int for counts, float for sums
856
+ aggs[cols.agg.unit_spend] = data[cols.unit_spend].sum(where=~unknown_flag).coalesce(0.0)
857
+ aggs[cols.agg.transaction_id] = data[cols.transaction_id].nunique(where=~unknown_flag).coalesce(0)
858
+ aggs[cols.agg.customer_id] = data[cols.customer_id].nunique(where=~unknown_flag).coalesce(0)
859
+ if cols.unit_qty in data.columns:
860
+ aggs[cols.agg.unit_qty] = data[cols.unit_qty].sum(where=~unknown_flag).coalesce(0)
861
+
862
+ # Unknown customers (where unknown)
863
+ # Use coalesce to ensure proper types: int for counts, float for sums
864
+ aggs[cols.agg.unit_spend_unknown] = data[cols.unit_spend].sum(where=unknown_flag).coalesce(0.0)
865
+ aggs[cols.agg.transaction_id_unknown] = data[cols.transaction_id].nunique(where=unknown_flag).coalesce(0)
866
+ if cols.unit_qty in data.columns:
867
+ aggs[cols.agg.unit_qty_unknown] = data[cols.unit_qty].sum(where=unknown_flag).coalesce(0)
868
+
869
+ # Total (all customers)
870
+ aggs[cols.agg.unit_spend_total] = data[cols.unit_spend].sum()
871
+ aggs[cols.agg.transaction_id_total] = data[cols.transaction_id].nunique()
872
+ if cols.unit_qty in data.columns:
873
+ aggs[cols.agg.unit_qty_total] = data[cols.unit_qty].sum()
874
+
875
+ # Add extra aggregations with three variants
876
+ if extra_aggs:
877
+ suffix_unknown = get_option("column.suffix.unknown_customer")
878
+ suffix_total = get_option("column.suffix.total")
879
+ for agg_name, (col, func) in extra_aggs.items():
880
+ # Use coalesce with 0 for count functions, 0.0 for others
881
+ coalesce_value = 0 if func in ("nunique", "count") else 0.0
882
+ aggs[agg_name] = getattr(data[col], func)(where=~unknown_flag).coalesce(coalesce_value)
883
+ aggs[f"{agg_name}_{suffix_unknown}"] = getattr(data[col], func)(where=unknown_flag).coalesce(
884
+ coalesce_value,
885
+ )
886
+ aggs[f"{agg_name}_{suffix_total}"] = getattr(data[col], func)()
887
+
888
+ return aggs
889
+
890
+ @staticmethod
891
+ def _calc_seg_stats(
892
+ data: ibis.Table,
893
+ segment_col: str | list[str],
894
+ calc_total: bool | None,
895
+ extra_aggs: dict[str, tuple[str, str]] | None = None,
896
+ calc_rollup: bool | None = None,
897
+ rollup_value: Any | list[Any] = "Total", # noqa: ANN401 - Any is required for ibis.literal typing
898
+ unknown_customer_value: int | str | ibis.Scalar | ibis.expr.types.BooleanColumn | None = None,
899
+ grouping_sets: Literal["rollup", "cube", "total"] | list[tuple[str, ...]] | None = None,
900
+ ) -> ibis.Table:
901
+ """Calculates the transaction statistics by segment.
902
+
903
+ Args:
904
+ data (ibis.Table): The transaction data.
905
+ segment_col (list[str]): The columns to use for the segmentation.
906
+ calc_total (bool | None): Whether to include the total row (ignored if grouping_sets is not None).
907
+ extra_aggs (dict[str, tuple[str, str]], optional): Additional aggregations to perform.
908
+ The keys in the dictionary will be the column names for the aggregation results.
909
+ The values are tuples with (column_name, aggregation_function).
910
+ calc_rollup (bool | None, optional): Whether to calculate rollup totals (ignored if grouping_sets is not
911
+ None). When True with multiple segment columns, subtotal rows are added for all non-empty prefixes and
912
+ suffixes of the hierarchy. For example, with [A, B, C], prefixes include [A, B, Total], [A, Total,
913
+ Total]; suffixes include [Total, B, C], [Total, Total, C]. Performance: O(n) additional aggregation
914
+ passes for suffixes, where n is the number of segment columns.
915
+ rollup_value (Any | list[Any], optional): The value to use for rollup totals. Can be a single value
916
+ applied to all columns or a list of values matching the length of segment_col, with each value
917
+ cast to match the corresponding column type. Defaults to "Total".
918
+ unknown_customer_value
919
+ (int | str | ibis.Scalar | ibis.expr.types.BooleanColumn | None, optional):
920
+ Value or expression identifying unknown customers for separate tracking.
921
+ When provided, metrics are split into identified, unknown, and total variants.
922
+ Accepts simple values (e.g., -1), ibis literals, or boolean expressions.
923
+ Defaults to None.
924
+ grouping_sets (Literal["rollup", "cube", "total"] | list[tuple[str, ...]] | None, optional):
925
+ Grouping sets mode ('rollup', 'cube', list of tuples, or None). Defaults to None.
926
+
927
+ Returns:
928
+ pd.DataFrame: The transaction statistics by segment.
929
+
930
+ """
931
+ cols = ColumnHelper()
932
+
933
+ # Ensure segment_col is a list
934
+ segment_col = [segment_col] if isinstance(segment_col, str) else segment_col
935
+
936
+ # Normalize rollup_value to always be a list matching segment_col length
937
+ rollup_value = [rollup_value] * len(segment_col) if not isinstance(rollup_value, list) else rollup_value
938
+
939
+ # Validate rollup_value list length
940
+ if len(rollup_value) != len(segment_col):
941
+ msg = (
942
+ f"If rollup_value is a list, its length must match the number of segment"
943
+ f" columns. Expected {len(segment_col)}, got {len(rollup_value)}"
944
+ )
945
+ raise ValueError(msg)
946
+
947
+ # Validate and create unknown flag if unknown_customer_value is provided
948
+ unknown_flag = None
949
+ if unknown_customer_value is not None:
950
+ if cols.customer_id not in data.columns:
951
+ msg = f"Column '{cols.customer_id}' is required when unknown_customer_value parameter is specified"
952
+ raise ValueError(msg)
953
+ unknown_flag = SegTransactionStats._create_unknown_flag(data, unknown_customer_value)
954
+
955
+ # Build aggregations based on unknown customer tracking
956
+ aggs = (
957
+ SegTransactionStats._build_unknown_aggs(data, unknown_flag, extra_aggs)
958
+ if unknown_flag is not None
959
+ else SegTransactionStats._build_standard_aggs(data, extra_aggs)
960
+ )
961
+
962
+ # Generate ALL grouping sets based on current parameters
963
+ grouping_sets_list = SegTransactionStats._generate_grouping_sets(
964
+ segment_col,
965
+ calc_total,
966
+ calc_rollup,
967
+ grouping_sets,
968
+ )
969
+
970
+ # Execute all grouping sets uniformly - no special cases
971
+ final_metrics = SegTransactionStats._execute_grouping_sets(
972
+ data,
973
+ grouping_sets_list,
974
+ segment_col,
975
+ rollup_value,
976
+ aggs,
977
+ )
978
+
979
+ # Calculate derived metrics
980
+ # Note: .nullif(0) converts zero denominators to NULL, preventing division by zero errors.
981
+ # Some database backends (e.g., SQL Server) raise errors on X/0 rather than returning NULL.
982
+ final_metrics = final_metrics.mutate(
983
+ **{
984
+ cols.calc.spend_per_trans: ibis._[cols.agg.unit_spend] / ibis._[cols.agg.transaction_id].nullif(0),
985
+ },
986
+ )
987
+
988
+ if cols.unit_qty in data.columns:
989
+ final_metrics = final_metrics.mutate(
990
+ **{
991
+ cols.calc.price_per_unit: ibis._[cols.agg.unit_spend] / ibis._[cols.agg.unit_qty].nullif(0),
992
+ cols.calc.units_per_trans: ibis._[cols.agg.unit_qty]
993
+ / ibis._[cols.agg.transaction_id].nullif(0).cast("float"),
994
+ },
995
+ )
996
+
997
+ if cols.customer_id in data.columns:
998
+ final_metrics = final_metrics.mutate(
999
+ **{
1000
+ cols.calc.spend_per_cust: ibis._[cols.agg.unit_spend] / ibis._[cols.agg.customer_id].nullif(0),
1001
+ cols.calc.trans_per_cust: ibis._[cols.agg.transaction_id]
1002
+ / ibis._[cols.agg.customer_id].nullif(0).cast("float"),
1003
+ },
1004
+ )
1005
+
1006
+ # Add derived metrics for unknown and total when tracking unknown customers
1007
+ if unknown_flag is not None:
1008
+ # Unknown customer derived metrics
1009
+ final_metrics = final_metrics.mutate(
1010
+ **{
1011
+ cols.calc.spend_per_trans_unknown: ibis._[cols.agg.unit_spend_unknown]
1012
+ / ibis._[cols.agg.transaction_id_unknown].nullif(0),
1013
+ },
1014
+ )
1015
+
1016
+ # Total derived metrics
1017
+ final_metrics = final_metrics.mutate(
1018
+ **{
1019
+ cols.calc.spend_per_trans_total: ibis._[cols.agg.unit_spend_total]
1020
+ / ibis._[cols.agg.transaction_id_total].nullif(0),
1021
+ },
1022
+ )
1023
+
1024
+ # Quantity-based derived metrics for unknown and total
1025
+ if cols.unit_qty in data.columns:
1026
+ final_metrics = final_metrics.mutate(
1027
+ **{
1028
+ cols.calc.price_per_unit_unknown: ibis._[cols.agg.unit_spend_unknown]
1029
+ / ibis._[cols.agg.unit_qty_unknown].nullif(0),
1030
+ cols.calc.units_per_trans_unknown: ibis._[cols.agg.unit_qty_unknown]
1031
+ / ibis._[cols.agg.transaction_id_unknown].nullif(0).cast("float"),
1032
+ cols.calc.price_per_unit_total: ibis._[cols.agg.unit_spend_total]
1033
+ / ibis._[cols.agg.unit_qty_total].nullif(0),
1034
+ cols.calc.units_per_trans_total: ibis._[cols.agg.unit_qty_total]
1035
+ / ibis._[cols.agg.transaction_id_total].nullif(0).cast("float"),
1036
+ },
1037
+ )
1038
+
1039
+ return final_metrics
1040
+
1041
+ @property
1042
+ def df(self) -> pd.DataFrame:
1043
+ """Returns the dataframe with the transaction statistics by segment."""
1044
+ if self._df is None:
1045
+ cols = ColumnHelper()
1046
+ include_quantity = cols.agg.unit_qty in self.table.columns
1047
+ include_customer = cols.agg.customer_id in self.table.columns
1048
+ include_unknown = self.unknown_customer_value is not None
1049
+ col_order = [
1050
+ *self.segment_col,
1051
+ *SegTransactionStats._get_col_order(
1052
+ include_quantity=include_quantity,
1053
+ include_customer=include_customer,
1054
+ include_unknown=include_unknown,
1055
+ ),
1056
+ ]
1057
+
1058
+ # Add any extra aggregation columns to the column order
1059
+ if hasattr(self, "extra_aggs") and self.extra_aggs:
1060
+ if include_unknown:
1061
+ # Add identified, unknown, and total variants for each extra agg
1062
+ suffix_unknown = get_option("column.suffix.unknown_customer")
1063
+ suffix_total = get_option("column.suffix.total")
1064
+ for agg_name in self.extra_aggs:
1065
+ col_order.append(agg_name)
1066
+ col_order.append(f"{agg_name}_{suffix_unknown}")
1067
+ col_order.append(f"{agg_name}_{suffix_total}")
1068
+ else:
1069
+ col_order.extend(self.extra_aggs.keys())
1070
+
1071
+ self._df = self.table.execute()[col_order]
1072
+ return self._df
1073
+
1074
+ def plot(
1075
+ self,
1076
+ value_col: str,
1077
+ title: str | None = None,
1078
+ x_label: str | None = None,
1079
+ y_label: str | None = None,
1080
+ ax: Axes | None = None,
1081
+ orientation: Literal["vertical", "horizontal"] = "vertical",
1082
+ sort_order: Literal["ascending", "descending"] | None = None,
1083
+ source_text: str | None = None,
1084
+ hide_total: bool = True,
1085
+ **kwargs: dict[str, Any],
1086
+ ) -> SubplotBase:
1087
+ """Plots the value_col by segment.
1088
+
1089
+ .. deprecated::
1090
+ This method is deprecated. Use :func:`openretailscience.plots.bar.py` instead.
1091
+
1092
+ Args:
1093
+ value_col (str): The column to plot.
1094
+ title (str, optional): The title of the plot. Defaults to None.
1095
+ x_label (str, optional): The x-axis label. Defaults to None. When None the x-axis label is blank when the
1096
+ orientation is horizontal. When the orientation is vertical it is set to the `value_col` in title case.
1097
+ y_label (str, optional): The y-axis label. Defaults to None. When None the y-axis label is set to the
1098
+ `value_col` in title case when the orientation is horizontal. Then the orientation is vertical it is
1099
+ set to blank
1100
+ ax (Axes, optional): The matplotlib axes object to plot on. Defaults to None.
1101
+ orientation (Literal["vertical", "horizontal"], optional): The orientation of the plot. Defaults to
1102
+ "vertical".
1103
+ sort_order (Literal["ascending", "descending", None], optional): The sort order of the segments.
1104
+ Defaults to None. If None, the segments are plotted in the order they appear in the dataframe.
1105
+ source_text (str, optional): The source text to add to the plot. Defaults to None.
1106
+ hide_total (bool, optional): Whether to hide the total row. Defaults to True.
1107
+ **kwargs: Additional keyword arguments to pass to the Pandas plot function.
1108
+
1109
+ Returns:
1110
+ SubplotBase: The matplotlib axes object.
1111
+
1112
+ Raises:
1113
+ ValueError: If the sort_order is not "ascending", "descending" or None.
1114
+ ValueError: If the orientation is not "vertical" or "horizontal".
1115
+ ValueError: If multiple segment columns are used, as plotting is only supported for a single segment column.
1116
+ """
1117
+ warnings.warn(
1118
+ "SegTransactionStats.plot() is deprecated and will be removed in a future version. "
1119
+ "Use openretailscience.plots.bar instead.",
1120
+ DeprecationWarning,
1121
+ stacklevel=2,
1122
+ )
1123
+
1124
+ if sort_order not in ["ascending", "descending", None]:
1125
+ raise ValueError("sort_order must be either 'ascending' or 'descending' or None")
1126
+ if orientation not in ["vertical", "horizontal"]:
1127
+ raise ValueError("orientation must be either 'vertical' or 'horizontal'")
1128
+ if len(self.segment_col) > 1:
1129
+ raise ValueError("Plotting is only supported for a single segment column")
1130
+
1131
+ default_title = f"{value_col.title()} by Segment"
1132
+ kind = "bar"
1133
+ if orientation == "horizontal":
1134
+ kind = "barh"
1135
+
1136
+ # Use the first segment column for plotting
1137
+ plot_segment_col = self.segment_col[0]
1138
+ val_s = self.df.set_index(plot_segment_col)[value_col]
1139
+ if hide_total:
1140
+ val_s = val_s[val_s.index != "Total"]
1141
+
1142
+ if sort_order is not None:
1143
+ ascending = sort_order == "ascending"
1144
+ val_s = val_s.sort_values(ascending=ascending)
1145
+
1146
+ ax = val_s.plot(
1147
+ kind=kind,
1148
+ color=COLORS["green"][500],
1149
+ legend=False,
1150
+ ax=ax,
1151
+ **kwargs,
1152
+ )
1153
+
1154
+ if orientation == "vertical":
1155
+ plot_y_label = gu.not_none(y_label, value_col.title())
1156
+ plot_x_label = gu.not_none(x_label, "")
1157
+ decimals = gu.get_decimals(ax.get_ylim(), ax.get_yticks())
1158
+ ax.yaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
1159
+ else:
1160
+ plot_y_label = gu.not_none(y_label, "")
1161
+ plot_x_label = gu.not_none(x_label, value_col.title())
1162
+ decimals = gu.get_decimals(ax.get_xlim(), ax.get_xticks())
1163
+ ax.xaxis.set_major_formatter(lambda x, pos: gu.human_format(x, pos, decimals=decimals))
1164
+
1165
+ ax = gu.standard_graph_styles(
1166
+ ax,
1167
+ title=gu.not_none(title, default_title),
1168
+ x_label=plot_x_label,
1169
+ y_label=plot_y_label,
1170
+ )
1171
+
1172
+ if source_text is not None:
1173
+ gu.add_source_text(ax=ax, source_text=source_text)
1174
+
1175
+ gu.standard_tick_styles(ax)
1176
+
1177
+ return ax