openretailscience 0.45.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openretailscience/__init__.py +3 -0
- openretailscience/analysis/__init__.py +0 -0
- openretailscience/analysis/cohort.py +185 -0
- openretailscience/analysis/composite_rank.py +372 -0
- openretailscience/analysis/cross_shop.py +354 -0
- openretailscience/analysis/customer.py +459 -0
- openretailscience/analysis/customer_decision_hierarchy.py +359 -0
- openretailscience/analysis/gain_loss.py +340 -0
- openretailscience/analysis/haversine.py +50 -0
- openretailscience/analysis/product_association.py +418 -0
- openretailscience/analysis/revenue_tree.py +553 -0
- openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
- openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
- openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
- openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
- openretailscience/constants.py +295 -0
- openretailscience/metrics/__init__.py +0 -0
- openretailscience/metrics/base.py +29 -0
- openretailscience/metrics/distribution/__init__.py +0 -0
- openretailscience/metrics/distribution/acv.py +81 -0
- openretailscience/metrics/distribution/pct_of_stores.py +118 -0
- openretailscience/options.py +842 -0
- openretailscience/plots/__init__.py +0 -0
- openretailscience/plots/area.py +114 -0
- openretailscience/plots/bar.py +375 -0
- openretailscience/plots/broken_timeline.py +197 -0
- openretailscience/plots/cohort.py +94 -0
- openretailscience/plots/heatmap.py +134 -0
- openretailscience/plots/histogram.py +276 -0
- openretailscience/plots/index.py +505 -0
- openretailscience/plots/line.py +379 -0
- openretailscience/plots/period_on_period.py +144 -0
- openretailscience/plots/price.py +275 -0
- openretailscience/plots/scatter.py +382 -0
- openretailscience/plots/styles/__init__.py +1 -0
- openretailscience/plots/styles/colors.py +215 -0
- openretailscience/plots/styles/font_utils.py +62 -0
- openretailscience/plots/styles/graph_utils.py +834 -0
- openretailscience/plots/styles/styling_helpers.py +155 -0
- openretailscience/plots/time.py +160 -0
- openretailscience/plots/tree_diagram.py +813 -0
- openretailscience/plots/venn.py +127 -0
- openretailscience/plots/waterfall.py +217 -0
- openretailscience/plugin.py +238 -0
- openretailscience/segmentation/__init__.py +0 -0
- openretailscience/segmentation/hml.py +97 -0
- openretailscience/segmentation/nlr.py +218 -0
- openretailscience/segmentation/rfm.py +307 -0
- openretailscience/segmentation/segstats.py +1177 -0
- openretailscience/segmentation/threshold.py +165 -0
- openretailscience/utils/__init__.py +0 -0
- openretailscience/utils/date.py +226 -0
- openretailscience/utils/filter_and_label.py +52 -0
- openretailscience/utils/label.py +79 -0
- openretailscience/utils/validation.py +45 -0
- openretailscience-0.45.0.dist-info/METADATA +364 -0
- openretailscience-0.45.0.dist-info/RECORD +60 -0
- openretailscience-0.45.0.dist-info/WHEEL +4 -0
- openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
|
@@ -0,0 +1,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
|